pax_global_header00006660000000000000000000000064152730327170014521gustar00rootroot0000000000000052 comment=31c6f325275c28da546988f228fbaaf36fc70746 derek73-python-nameparser-456b024/000077500000000000000000000000001527303271700167075ustar00rootroot00000000000000derek73-python-nameparser-456b024/.claude/000077500000000000000000000000001527303271700202225ustar00rootroot00000000000000derek73-python-nameparser-456b024/.claude/agents/000077500000000000000000000000001527303271700215035ustar00rootroot00000000000000derek73-python-nameparser-456b024/.claude/agents/design-docs-reviewer.md000066400000000000000000000063571527303271700260650ustar00rootroot00000000000000--- name: design-docs-reviewer description: Reviews changes to docs/design/ (rules/, decisions/, mechanisms.md, and AGENTS.md itself, which carries the conventions the other three rest on) for the failure modes that this doc system's own tests cannot catch — unreproducible measurements, rules that contradict the decision entries they rest on, statements that contradict their own examples, and prose edits that change what the doc parsers see. Use before merging any PR that touches docs/design/, and after distilling a design session into those files. tools: Bash, Read, Grep, Glob --- You review changes to `docs/design/` for a Python name-parsing library whose design docs are partly machine-checked. Your job is the part the machines cannot do. **You report findings. You never edit files.** No fixes, no suggestions phrased as edits — the calling session applies them. ## The axes `docs/design/AGENTS.md` holds the review axes and the conventions they rest on: what the suite already enforces — so a finding one of those guards would catch is a false positive — the dated-count rule, and the corpus-population caution. **Read that file and run every axis it lists.** Work from the file, never from a summary, including one you have seen before: the list grows, and individual axes get broadened in place. It is the only copy on purpose. This agent used to restate the axes and drifted three ways against it — a missing axis, a narrowed one, and a corpus count whose correction was recorded there and never reached the copy here (#473, decisions/review-agent-single-source). Do not reintroduce a summary. ## How to run 1. `git diff master...HEAD -- docs/design/` for the scope. Also `git diff HEAD -- docs/design/` for uncommitted work; review often runs before the commit. 2. Read the changed rules and every decision entry they cite in full. Not the diff hunks — the surrounding rule and entry, because the rule-vs-examples, rule-vs-decision and precedence axes need context the hunk does not carry. 3. Recompute. Use `uv run --frozen python - <<'EOF'` for measurement scripts and `uv run --frozen pytest -q` for the suite. A bare `python`/`pytest` picks up the wrong environment here. 4. State which tree you measured on — `git rev-parse --short HEAD` and whether the tree is dirty. Stale measurements have produced rounds of already-fixed findings in this repo. ## Reporting Report each finding as: : — Why it is wrong: Concrete consequence: Confidence: CONFIRMED (measured) | PLAUSIBLE (reasoned) Rank most severe first. Quote both sides of a contradiction verbatim — a paraphrase of a doc claim is not evidence about the doc. If an axis found nothing, say so in one line. A silent axis is indistinguishable from an unrun one, and this repo has a recurring inert-measurement failure mode. **Do not report:** style, wording preferences, "consider adding", missing content you cannot name a consequence for, or anything the suite already enforces — AGENTS.md names those guards, and a finding one of them would catch is a false positive. An empty report is a fine outcome and is more useful than a padded one. derek73-python-nameparser-456b024/.claude/commands/000077500000000000000000000000001527303271700220235ustar00rootroot00000000000000derek73-python-nameparser-456b024/.claude/commands/docs-review.md000066400000000000000000000030251527303271700245740ustar00rootroot00000000000000--- description: Review docs/design/ changes for the failure modes the doc tests cannot catch argument-hint: "[base-ref, default master]" allowed-tools: Bash, Read, Grep, Glob, Agent --- Review this branch's changes to `docs/design/` using the `design-docs-reviewer` agent. Base ref: `${1:-master}` 1. Check there is something to review: `git diff ${1:-master}...HEAD --stat -- docs/design/` plus `git diff HEAD --stat -- docs/design/` for uncommitted work. If both are empty, say so and stop — do not review an empty diff. 2. Confirm the suite is green first, so the agent can assume the machine-checked layer holds: `uv run --frozen pytest -q`. If it fails, report that and stop; a red suite falsifies the machine-checked layer docs/design/AGENTS.md tells the agent to assume, and it will waste effort rediscovering known breakage. 3. Dispatch the `design-docs-reviewer` agent with the base ref and the changed-file list. Let it run its own axes — do not pre-summarize the diff for it, and do not tell it what you think the issues are. Its value is reading what is written rather than what was meant, and a primed agent loses exactly that. 4. Relay its findings verbatim in your reply — the agent's report is not shown to the user. Add your own assessment of each: agree, disagree with reasoning, or need-more-information. You are not obliged to accept a finding, but you are obliged to say why you reject one. Do not apply fixes as part of this command. Report, then let the user decide what lands. derek73-python-nameparser-456b024/.editorconfig000066400000000000000000000004431527303271700213650ustar00rootroot00000000000000# http://editorconfig.org root = true [*] charset = utf-8 end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true [*.{py,rst,ini}] indent_style = space indent_size = 4 [*.{html,json,yml}] indent_style = space indent_size = 2 [*.md] trim_trailing_whitespace = false derek73-python-nameparser-456b024/.git-blame-ignore-revs000066400000000000000000000011441527303271700230070ustar00rootroot00000000000000# Revisions git blame should skip. Enable once with: # git config blame.ignoreRevsFile .git-blame-ignore-revs # Add only whitespace/format-only commits. # # Caveat, measured on the entry below: --ignore-revs-file cannot pass # blame through a commit that MERGES lines. A reflowed line does not # exist in the parent, so there is nothing to re-attribute it to, and # the 43 affected lines stay attributed to the reformat either way. # The entry is kept for future format-only commits that map 1:1. # docs: unwrap hard-wrapped prose (whitespace only, no content change) 5e2ae60b0a287b6cf9ed5609823f39fe91568da0 derek73-python-nameparser-456b024/.github/000077500000000000000000000000001527303271700202475ustar00rootroot00000000000000derek73-python-nameparser-456b024/.github/dependabot.yml000066400000000000000000000011521527303271700230760ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "monthly" # nameparser has no runtime dependencies; this watches the one optional-extra # dependency, namedivider-python (the [ja] segmenter). pyproject floats >=0.4 so # fresh installs always get the newest release, but CI's ja-extra job installs # from uv.lock — without these bumps, CI keeps proving an old version while # users install a newer one. - package-ecosystem: "uv" directory: "/" schedule: interval: "monthly" allow: - dependency-name: "namedivider-python" derek73-python-nameparser-456b024/.github/workflows/000077500000000000000000000000001527303271700223045ustar00rootroot00000000000000derek73-python-nameparser-456b024/.github/workflows/codeql.yml000066400000000000000000000010131527303271700242710ustar00rootroot00000000000000name: CodeQL on: push: branches: [ master ] pull_request: branches: [ master ] schedule: - cron: '30 4 * * 1' permissions: contents: read jobs: analyze: name: Analyze runs-on: ubuntu-latest permissions: security-events: write steps: - uses: actions/checkout@v7 - name: Initialize CodeQL uses: github/codeql-action/init@v4.38.2 with: languages: python, actions - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v4.38.2 derek73-python-nameparser-456b024/.github/workflows/python-package.yml000066400000000000000000000130251527303271700257420ustar00rootroot00000000000000# This workflow will install Python dependencies, run tests and lint with a variety of Python versions # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions name: Test the Python package on: workflow_dispatch: push: branches: [ master ] pull_request: branches: [ master ] permissions: contents: read jobs: build: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.11", "3.12", "3.13", "3.14", "3.15"] env: # Without this, uv sync honors .python-version (3.11) over the # matrix interpreter and every job silently tests 3.11. UV_PYTHON: ${{ matrix.python-version }} # Coverage is collected in ONE job, the one that uploads it; the # others would pay for a report nobody reads. 3.14 because # coverage.py runs its sys.monitoring core there by default, which # stops reporting a line once it is recorded: a line tracer # (sys.settrace, its core below 3.14) is called on every line # every time, and cost CI's 3.12 job most of its ~12 minutes on # the property grids in tests/v2/test_properties.py (measured # 2026-09-29: the three #397 grids took 139s under the settrace core # on 3.12, 24s under sys.monitoring, 17s with no coverage). The package # has no version-conditional code, so one interpreter's report # stands for all of them. COVERAGE_ON: ${{ matrix.python-version == '3.14' }} steps: - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} - name: Install uv uses: astral-sh/setup-uv@v7 with: enable-cache: true - name: Install dependencies run: uv sync --group dev - name: Run linter run: uv run ruff check - name: Check types run: uv run mypy - name: Run Tests run: | # Two passes: the suite in parallel (pyproject's addopts), which # deselects the clock-timed `serial` tests, then those alone, since # other workers' load fails their timing ratios (tests/conftest.py). if [ "$COVERAGE_ON" = "true" ]; then uv run pytest --cov=nameparser --cov-report= uv run pytest -n0 -m serial --cov=nameparser --cov-append --cov-report=xml else uv run pytest uv run pytest -n0 -m serial fi uv run --with build python -m build --sdist uv run --with twine twine check dist/* uv run sphinx-build -b html docs dist/docs uv run sphinx-build -b doctest docs dist/doctest uv run python -m doctest README.rst - name: Upload coverage to Codecov if: env.COVERAGE_ON == 'true' uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} files: ./coverage.xml fail_ci_if_error: false # The optional nameparser[ja] segmenter (#272). Its own job, not a # matrix entry: the matrix above must keep proving that the package # installs and passes with NO third-party dependency at all, which is # the promise the extra is carved out of. Everything the extra adds # is under tests/v2/ (the ja pack's integration tests, which skip # without it). ja-extra: runs-on: ubuntu-latest # The floor and the newest Python whose wheels the lock pins: uv.lock # splits numpy and scipy at 3.12, so the floor is the only row that # installs the older branch. namedivider-python's compiled # dependencies (numpy, scipy, pandas, regex; lightgbm is py3-none) # had no cp315 wheels at the versions uv.lock pinned before #259, # and numpy's and scipy's sdist builds fail, so moving the top row to # a new Python can need a lock bump first. strategy: fail-fast: false matrix: python-version: ["3.11", "3.15"] env: UV_PYTHON: ${{ matrix.python-version }} steps: - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} - name: Install uv uses: astral-sh/setup-uv@v7 with: enable-cache: true - name: Install dependencies run: uv sync --group dev --extra ja - name: Run Tests # The import check first: every test the extra adds is guarded by # a find_spec("namedivider") skipif, so a job where the extra # silently failed to install is a job where all of them SKIP and # the step still passes green. This line is what makes that # failure loud -- without it the job proves nothing on the day it # matters. # # The _PREFIXED_SHAPES clock guard in tests/v2/test_benchmark.py # skips under a LINE tracer (sys.settrace -- coverage's core below # py3.14), which dilutes the C-level cost it measures below its # bound (#553). No CI job installs one today -- coverage runs on # 3.14 alone, under its sys.monitoring core, which does not dilute # the cost -- but this job is the one that must measure it # whatever the build matrix's coverage setup becomes, so the env # var below makes a line tracer here fail those rows rather than # skip them: a Python downgrade under coverage, # COVERAGE_CORE=ctrace, or a debugger. env: NAMEPARSER_REQUIRE_CLOCK_GUARDS: "1" run: | uv run python -c "import namedivider" uv run pytest tests/v2/ -q uv run pytest tests/v2/ -q -n0 -m serial derek73-python-nameparser-456b024/.github/workflows/python-publish.yml000066400000000000000000000011021527303271700260060ustar00rootroot00000000000000name: Upload Published Python Package on: release: types: [published] jobs: deploy: runs-on: ubuntu-latest environment: pypi permissions: id-token: write steps: - uses: actions/checkout@v7 - name: Set up Python uses: actions/setup-python@v7 with: python-version: '3.x' - name: Install dependencies run: | python -m pip install --upgrade pip pip install build - name: Build package run: python -m build - name: Publish package uses: pypa/gh-action-pypi-publish@release/v1 derek73-python-nameparser-456b024/.gitignore000066400000000000000000000004771527303271700207070ustar00rootroot00000000000000*.hgrc *.DS_Store __pycache__/ *.py[cod] .python2/ MANIFEST nameparser.egg-info/ build *.egg .coverage dist .idea Pipfile Pipfile.lock .tm_properties # virtual environments .env/ .venv/ venv/ env/ # tools .claude/* !.claude/agents/ !.claude/commands/ .codex/ .gemini/ .pytest_cache/ # docs docs/_* docs/superpowers/ derek73-python-nameparser-456b024/.python-version000066400000000000000000000000051527303271700217070ustar00rootroot000000000000003.11 derek73-python-nameparser-456b024/.readthedocs.yaml000066400000000000000000000003071527303271700221360ustar00rootroot00000000000000version: 2 build: os: ubuntu-24.04 tools: python: "3.12" sphinx: configuration: docs/conf.py python: install: - method: pip path: . - requirements: docs/requirements.txt derek73-python-nameparser-456b024/AGENTS.md000066400000000000000000003722241527303271700202240ustar00rootroot00000000000000# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Workflow For non-trivial changes, use a feature branch and open a PR. Branch naming: `fix/issue-NNN-short-description` or `feat/short-description`. Before opening the PR, if the change alters parser behavior or internals, *read* the Architecture, Extension Patterns, and Gotchas sections of this file against the change — don't grep for it: AGENTS.md paraphrases behavior in its own words, so text made stale by a code change rarely matches the code's phrasing (a doc-staleness sweep driven by grep terms from the diff will miss it every time). The same applies when scoping a doc-review pass or a subagent prompt: include AGENTS.md in the list of docs to check, or it won't be checked. After a review round, **review the fix commit too** — second-round passes on #398, #400, #403 and #737 each found defects in the first round's fixes. The commonest shape is a WIDENED check that quietly narrows somewhere: #737's fix widened a vocabulary exclusion from "the same kind" to "the same set" and stopped refusing the excluded kind itself once a property moved it to the other set, a case no test listed because the old check had made it impossible. When a fix widens a check, keep a test row for every case the narrower form already refused, and run the new rows against the pre-fix code to see each one fail. **Check whether the PR description still needs updating before merging.** A review that moves behavior or retracts a measurement leaves it stale, and a stale body reads as authoritative. **When a defect is found that an earlier check would have caught, write the check down where the next person will meet it, in the same PR.** A review finding, a failing sweep or a regression that arrived because nobody thought to test a shape is a lesson about the repository, not only about the change, and it is lost when the PR merges unless it lands somewhere a later session reads before making the same mistake. Ask, before merging: what should I have checked, and where would I have looked? Then put it there — a missed input shape in the sweeps in `nameparser/AGENTS.md` (comma shapes, then `name_order`, then spellings, then the encoding, each earned this way); a trap in a module's behavior under Gotchas; a design-review miss in docs/design/AGENTS.md's axes; a measurement or test-design trap in mechanisms.md's Verification shapes or Field notes; a reusable design move in mechanisms.md proper. Name the PR or issue that earned it and the input that showed it, so the rule can be checked against its case. #610 is the shape: its sweep test found that the maiden take missed a shape-only numeral, and the spelling sweep in `nameparser/AGENTS.md` records the class (`VI` beside `V` and `III`). This is how the file accumulates expertise rather than history — prefer amending an existing paragraph to adding a new one, and delete a lesson the code has since made impossible. **A scripted multi-edit that asserts as it goes discards everything when a late pattern misses.** Assert every pattern before applying any, then verify the edit landed — three times in one session a docs batch failed its last assertion, wrote nothing, and reported success, invisible because prose edits fail no test. And replace by an anchored match, never across a whole file: a history line quotes the wrong spelling on purpose, and #909's `sed` turning `segment_scripts=()` into `frozenset()` also rewrote #337's record of what shipped wrong, until the design-docs review found it. ## Rules documentation (docs/design/) Three committed contributor docs carry the parser's normative rules and their reasons, enforced by the doc tests in tests/v2/ (design_docs.py, rules_doc.py, rules_budget.py and the test_doc_*/test_rules_*/test_design_docs modules). Before proposing a design or a fix that touches parser behavior, read docs/design/mechanisms.md and the relevant docs/design/rules/ sections — the catalog is keyed by problem shape, and the pattern you are about to invent is often already there. - **rules/** — NORMATIVE rules (intended behavior, domain-topic sections, executable examples; `deviates:` markers track known parser gaps), one file per rule and per section Background. Every rule is written in the skeleton format (#761), which replaced an indented block format: a plain-language When / Then / Because head, Except bullets each carrying its own fenced example, Precedence and Accepted bullets, and a footer for the signal and pointers, described in docs/design/rules/README.md. Cite as `rules/P1`; code comments quote a verbatim excerpt in double quotes, checked by tests/v2/test_doc_citations.py. - **decisions/** — the ADR-style record, one file per key: dated entries, Declined: (rejected WITH evidence), Excluded: (what must stay out of a wordlist and why), Open: (issue links, never restated; also keyed to a vocabulary set for contested memberships — the right-rule-wrong-set class), 3-0-reevaluations (append whenever a design cites 1.4 parity as load-bearing). - **mechanisms.md** — problem-shape catalog with citable contract statements; stage-attribution claims in comments must cite an entry verbatim, never restate it in fresh words. In a `.py` file cite it as `mechanisms.md#SLUG ("a quote")` on one line, the form tests/v2/test_doc_citations.py reads, checking the slug and the quote; inside docs/design/'s rules, decisions and mechanisms.md the same test checks the slug alone. Nothing reads a citation in AGENTS.md or a tools README, so grep for the slug there when an entry is renamed. #801's first draft wrote `mechanisms.md's NAME-DATA-JOBS`, a form the check reads nowhere, and its fix put the quote on the next line, where a docstring citation's quote is not read either. **Each rule and decisions entry is its own named file** (`rules/S6-case-signal-at-the-suffix-slot.md`, `decisions/vocabulary-collisions.md`), and each folder's README.md indexes them; a new rule or a new decisions key is a new file plus its index line (a new decisions file opens with `## Standing` and `## Log`, decisions/README.md; older files of Declined, Excluded, Open, removal or mapping lines are indexed under its Reference lists and need no block), and `tests/v2/test_design_docs.py` holds each index line to the file's heading and name, and tests/v2/design_docs.py is the one module that knows the layout (#713). Citations carry the ID only, never the name, so a file can be renamed freely. **When you cite a rule or decision in chat, a commit or a PR, give its name with its ID** — "S6 (the case signal at the suffix slot)" — since the ID alone tells the reader nothing about which rule it is. **Same-PR amendment rule.** Any PR that changes or clarifies parser behavior — or the boundaries around a documented rule, since a neighboring change can invalidate a rule's stated limits without touching its code — amends the rules in the same diff (the doc diff is part of the reviewable change, like tests). A change that resolves or reverses a design question adds a decisions entry; a new reusable pattern adds a mechanisms.md entry; a fixed deviation removes its `deviates:` marker in the same PR (the examples test forces this). Issues proposing behavior changes should be drafted in rule shape — When, Then and Because, the exceptions with their examples and boundaries, accepted consequences, open questions, exclusions — so landing an accepted proposal is a copy, not a rewrite. **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()` and the two case-repair views (`capitalized()` and `capitalized(force=True)`, each only where it changes the text, #826) for every name, so an initials or case 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, the facade's own `capitalize()` -- 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. 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/. **A nested AGENTS.md is a file in its folder, and a tool that reads the folder whole reads it too.** Root AGENTS.md is loaded by every session, so guidance that applies to one folder belongs in that folder's AGENTS.md, which loads when a session reads a file there: `nameparser/AGENTS.md` (the sweeps), `nameparser/_pipeline/AGENTS.md` (ambiguity reports), `nameparser/vocab/AGENTS.md` (adding a word), `docs/AGENTS.md` and `docs/design/AGENTS.md`. Before adding one, check what reads its folder: `tools/vocab/build.py` refuses every non-TOML file in `nameparser/vocab/` but the names in its `SKIPPED`, so that a misnamed language file fails loudly, and #911's vocab file failed eleven tests until it joined that set. **Working on docs/design/ has its own AGENTS.md.** `docs/design/AGENTS.md` carries the landing-a-design distillation checklist, the primary-source review rule, the dated-count convention, and the review axes. Claude Code loads it automatically when a session reads or edits anything under docs/design/; if your tool does not do nested discovery, read it yourself before touching those files or reviewing a change to them. **Triaging a design backlog: shape before value.** When a pile of open questions has to be ordered, relatedness will not partition it — in a parser nearly everything touches particles or suffixes. The line that does is whether a decision changes WHAT THE MODEL CAN EXPRESS or fills in a value in a shape already fixed. Settle the shape questions first, as a batch: they are few, they are usually independent of each other (so their own ordering does not matter), and each one collapses or re-frames a run of the value questions below it. The rest are leaves needing one measurement and one answer apiece, in any order. Worked example, 2026-08-16 (#386): 30 open issues and ~13 open design questions reduced to three shape decisions, which decided or re-framed fourteen of them — and two of the three turned out smaller than their issues claimed, because the issues had gone stale. Check the age of an issue's premises before believing its cost estimate. **Markdown line width: don't hard-wrap prose.** Measured 2026-08-16 — wrapped and unwrapped render identically, docs/design/ is not in the Sphinx build, and unwrapping is byte-identical (newline→space is 1:1), so the width buys nothing and costs diff churn (74% of #386's decisions.md deletions were reflow, not change). AGENTS.md and docs/design/ were unwrapped in one whitespace-only commit on 2026-08-16; keep new prose unwrapped rather than reformatting again. The blame cost was 43 lines of 1717, measured, not the wholesale reset predicted before doing it — git's diff maps most joined lines back to whichever commit wrote their opening text. `.git-blame-ignore-revs` lists the commit and is worth enabling (`git config blame.ignoreRevsFile .git-blame-ignore-revs`) for future format-only commits, but it recovered none of those 43: `--ignore-revs-file` cannot pass blame through a commit that MERGES lines, because the joined line has no counterpart in the parent to re-attribute to. One exception, enforced: a rule's example, `Signal:` and pointer lines are parsed per line, so a wrapped one is a hard error naming its rule. A rule's prose is unwrapped like any other, its examples standing in fences (#761). **Guard tests** SHOULD carry a recorded negative control — the answer with the guard off, stored as data (the `_UNGUARDED_EFFECT` shape in tests/v2/pipeline/test_state.py; see mechanisms.md's Verification shapes). Re-take it after the last commit that adds tests, before merge: a review round that adds rows moves the count, and three recorded controls in a row went stale that way (#625 27 → 30, #636 4 → 9, #637 5 → 7 → 11), each caught only by the next review. **Invariant tests over a generated grid share ONE parsed grid, and a grid pairs a text only with configurations that can read it differently.** Both rules are in `tests/v2/test_properties.py` (`_connective_findings`, `_off_switch_findings`, `_rows`) and both were bought at a price: four tests each walking the same 170,100-row grid, and a grid that was a full cross product of texts × lexicons × policies, together took that module from 11s to 115s and CI's build jobs from ~5 minutes to 17–25 (under coverage every parse costs several times more, and every later PR pays it). A new invariant over an existing grid joins that grid's walk instead of opening its own; a new grid states its measured runtime where it is built. Trim a grid by SHAPE — a variant lexicon reaches only the texts holding the word it adds — never by sampling, and prove the dropped rows were duplicates by signature before dropping them. Mutation counts in the tests' docstrings are counts over the grid, so re-measure them whenever the grid moves -- and so do the DESIGN DOCS that quote them. A recorded control reaches `docs/design/decisions/` and `docs/design/mechanisms.md` as well as the docstring, nothing checks those copies, and a grid change silently makes them wrong about a number they present as measured: the #397 second review moved four such figures at once (2026-09-20). Move them in the same commit, and where the older number is worth keeping, label it with the population it was measured over rather than deleting it. ## Commands ```bash # Preferred: use uv run (works without activating the venv) # Alternative: .venv/bin/ if the venv is already active # Run all tests (includes --doctest-modules, so doctests in nameparser/ are also run). # Parallel by default (pytest-xdist, `-n auto --dist worksteal` in pyproject's addopts), # which DESELECTS the clock-timed `serial` tests (tests/v2/test_benchmark.py) -- run # them in the second command. `-n0` runs everything serially in one process. uv run pytest uv run pytest -n0 -m serial # Run a single test file / class / method uv run pytest tests/test_python_api.py uv run pytest tests/test_python_api.py::HumanNamePythonTests::test_utf8 # Type check (covers nameparser/ and tests/, per pyproject.toml's [tool.mypy] packages) uv run mypy # Lint (the whole repo, tests and tools included -- what CI's "Run linter" step runs) uv run ruff check # Debug how a specific name string is parsed: 2.0 core parse() -- # prints the ParsedName repr raw and capitalized, then initials; # --json emits the component dict uv run python -m nameparser "Dr. Juan Q. Xavier de la Vega III" # Rebuild the compiled vocabulary after editing nameparser/vocab/*.toml # (--check exits 1 if nameparser/config/_compiled.py is stale), and look # words up by text, kind, issue or exclusion uv run python tools/vocab/build.py uv run python tools/vocab/query.py --word 殿 # Build docs uv run sphinx-build -b html docs dist/docs # Maintain docs/release_log.rst as changes land: # - Keep an "Unreleased" entry at the top: `* X.Y.Z - Unreleased` # - Add one bullet per notable change; prefix with Add/Fix/Remove/Change # - A bullet is an index, not the record: a bold lead (plain text only -- # RST markup does not nest, so no ``literal`` or role inside ** **), ONE # before/after example naming the releases it differs from, a limit # sentence only where a caller would trip on it, then the issue link(s): # :issue:`123` or :issue:`123`, :issue:`124` (sphinx.ext.extlinks; a PR # number works too). The mechanism, boundary cases and measurements live # in the issue and docs/design/decisions/, never in the bullet -- # 2.4.0's entry was cut from 10,765 words to 1,491 on 2026-10-11 # - Merge bullets a user would see as one change, with several links, # and carry every merged bullet's limits over: 2.7's merge of eight # phase-2j bullets read 'a bare one reverses after a comma' over Vice # and Grand, which stay titles there, until review ran them # - Version is decided at release time (patch/minor/major per semver) # - Format matches existing entries — see the 2.4.0 block for a current example # Release checklist (PyPI publish is triggered automatically by GitHub Actions on release creation) # 0. Sweep for stale prose. Grepping for the changed SYMBOL finds almost none of # it: prose describes behavior in words, not identifiers. Walk these sites # explicitly — every one of them has gone stale at least once. # - docs/*.rst: usage.rst examples and API surface, plus any file listing # config constants or HumanName kwargs # - migrate.rst's "Changed in ..." list: the shapes whose 1.4 reading # moved, each with a doctest. Every release log bullet saying "1.4.0 # through" the last release adds one. 2.5.0 nearly shipped with none # of its nine # - README.rst's announcement at the top: it is the PyPI project page # (pyproject's `readme`), so it should lead with the release being # shipped, drawn from the release log's summary. 2.4.0 nearly shipped # still announcing 2.0 and 2.1. Check it with `python -m doctest # README.rst` and `uv run --with readme_renderer python -m # readme_renderer README.rst -o /tmp/readme.html` (PyPI's renderer) # - docstrings that autodoc renders (nameparser/_policy.py, _types.py, ...). # These ARE the API reference in modules.html, so a docs/*.rst-only sweep # structurally misses half the rendered page — that is how the guide and # the reference ended up teaching different Policy spellings in 2.1 # - user-facing MESSAGES: warnings and raise text often hand the reader code # to paste, and it has to be code that still works (and type-checks) # - pipeline stage header blocks: each _pipeline/*.py docstring declares # "Reads:". That is checkable, so check it rather than reading it: # compare it against grep -oE '\b(policy|lexicon)\.[a-z_]+' on the module # - tests/v2/cases.py notes, which explain why a row lands where it does # - docs/design/rules/, decisions/ and mechanisms.md -- READ, don't # grep: the excerpt and example tests catch citation and example drift, # but statement and Background prose can still be wrong about behavior # that changed # - AGENTS.md itself, for stale commands, architecture notes, or gotchas # - the pickle-break bullet: pickle every public type (Lexicon, Policy, # Parser, ParsedName, Ambiguity, HumanName, a locale pack) under the # last release's wheel and load each in the tree; don't derive it # from a dataclass diff. 2.7's bullet named Lexicon and Parser and # missed the locale packs, which carry a Lexicon # And check for open Dependabot PRs on uv.lock (namedivider-python) and merge them # first — pyproject floats >=0.4 so fresh installs get the newest namedivider, but # CI's ja-extra job installs from uv.lock and only tests what the lock pins # 1. Read what the release moved: `uv run python tools/differential/snapshot.py # --since v`. It prints every corpus name that reads # differently, grouped by changed field. That report is the source for the release notes' # behavior claims (see Release-log claims above); a move nobody can # account for is a finding to settle before the log is drafted. # 2. Clear PRE_RELEASE in nameparser/_version.py — it carries 'dev' through the # cycle (see step 9), so releasing is setting it to ''. VERSION should already # be the version you are shipping; bump it here only if step 9 was skipped. # For an RC, set PRE_RELEASE = 'rc1' (etc.) instead — __version__ joins it # without a dot ('2.0.0rc1'). # Also set the `version:` field in CITATION.cff to this release. It is the ONE # version string that does NOT track the tree: it names the last citable # released artifact, so it lags on purpose for the whole cycle and catches up # here. Nothing checks this — no test, no CI — so it is on you. # 3. Stamp "Unreleased" → "X.Y.Z - Month DD, YYYY" in docs/release_log.rst # 4. git commit + git tag -a vX.Y.Z -m "Release X.Y.Z" # 5. git push origin master && git push origin vX.Y.Z ← tag must be pushed separately before gh release create # 6. gh release create vX.Y.Z --title "vX.Y.Z" --notes "..." # 7. Close the vX.Y.Z milestone and create a new "Next Release" one: # MILESTONE=$(gh api repos/derek73/python-nameparser/milestones --jq '.[] | select(.title=="vX.Y.Z") | .number') # gh api -X PATCH repos/derek73/python-nameparser/milestones/$MILESTONE -f state=closed # gh api -X POST repos/derek73/python-nameparser/milestones -f title="Next Release" # 8. Set DEFAULT_BASELINE in tools/differential/compare.py to the version # just released, so a bare compare.py measures against it. # 9. Open the next cycle's VERSION: bump VERSION in nameparser/_version.py to # the minor now being worked, and set PRE_RELEASE = 'dev'. The tree then says # what it is building rather than what it last shipped -- docs/conf.py reads # __version__ directly, so Read the Docs' "latest" is otherwise stuck on the # previous release for the whole cycle. # It also restores compare.py's version tell: with the tree and # the baseline wheel both reporting the same release, only the __file__ path # distinguishes them, which is the weak state test_tell_rejects_a_module_ # loaded_from_the_checkout documents. A dev tree differs on both halves. # 'dev' normalizes to X.Y.0.dev0 -- above the last release, below the one it # precedes -- so an install from master can never masquerade as the release. # Leave CITATION.cff alone: it names the last RELEASED version and is meant # to lag until step 2 of the next release. ``` Enable debug logging to see the parser's internal decisions: ```python import logging logging.getLogger('HumanName').setLevel(logging.DEBUG) ``` ## Architecture The library has two layers: data — the per-word TOML in `nameparser/vocab/`, compiled into the `nameparser/config/` modules — and logic, the 2.0 pipeline in the underscore-private `nameparser/_*.py` modules (see 2.0 API modules below). `nameparser/parser.py` and `nameparser/config/__init__.py` are only re-exports of the v1-compat facade (`_facade.py`, `_config_shim.py`); the v1 parser itself was deleted at the M11 swap and is readable as `git show 2d5d8c2:nameparser/parser.py`. **Design philosophy — positional and language-agnostic.** The parser assigns parts by *position* plus small sets of words that join to neighbors; it never detects language. A name's language can't be reliably inferred from Latin-script transliteration ("Ali" is Arabic or Italian; "Van"/"Della"/"Bin" are first names in some cultures, particles in others), so language-specific rules belong in opt-in `Constants` config, never global defaults. Many "wrong for language X" reports (#133, #150, #130, #85, #103, #146, #83) are irreducible ambiguities — e.g. `de Mesnil` (want last name) vs `Van Johnson` (want first name) are the same `[prefix][word]` shape. Before adding a rule, confirm it doesn't break the opposite case (run the full suite — Portuguese and "Van Johnson" tests are the usual canaries). **The one scoped exception (2.1, #271/#272): script-conditional behavior is permitted exactly where the SCRIPT ITSELF — not statistics about it — determines the convention.** The never-detect-language rule is about Latin *transliteration*, where the signal is destroyed; native script is a different question, answered per behavior rather than per script. What it licenses today, each stated in its rule, with the history in the decisions file named beside it or, where none is, the one of the same ID: - Wholly-Han, wholly-Hangul and kana-licensed names read family-first (`Policy.script_orders`): zh and ja both write family-first in native script, so order cannot be misread though the language is unknowable. Hiragana licenses Japanese (#272), while pure katakana, full-width or halfwidth, keeps the positional default: it may be a transcribed foreign name already in its source order or a Japanese reading written family-first, so its script settles nothing and a caller who knows opts in through `script_orders` (#594) — rules/W4. - Unspaced hangul splits into surname + given name on the Korean census surnames shipped as default vocabulary: nothing but Korean is written in hangul, and the surnames are a closed set — rules/W1. - The 间隔号 U+00B7 between classified characters marks a transcription, so the name keeps its declared order (rules/W4) and never segments (rules/W1's interpunct Except); it divides only when flanked, being also the Catalan punt volat (`Gal·la`) — rules/T3 (#298). - The nakaguro (U+30FB and halfwidth U+FF65) separates words unconditionally, in tokenize, so neither policy opt-out (`script_orders={}`, `segment_scripts=frozenset()`) reaches it: its codepoints are CJK-only — rules/T2, history in decisions/T3. - A listed CJK honorific glued to the end of a name splits off, but only an entry that can never end a name (#308): the same not-a-guess standard applied to WORDS rather than a script, the vocabulary carrying the license itself, so neither opt-out reaches it either. Its reach across a family comma is tolerated rather than contract — rules/W2, rules/W3, decisions/W2. - Han segmentation stays OPT-IN (`locales.ZH`; `locales.JA` with a `Parser(segmenter=...)`): a Chinese surname list corrupts Japanese kanji names (高 is a common Chinese surname, so 高橋一郎 would split 高+橋一郎 where the correct reading is 高橋+一郎), and no surname list divides a kanji name at all — rules/W1. Latin-script input is never touched by any of this: "Kim Min-jun" is genuinely order-ambiguous and stays governed by `name_order` and opt-in packs. Before adding a script-conditional rule, work out which of the three it is — certain, certain for this one behavior only, or a statistical guess wearing a script's clothes. **A constant's membership is a question you may reopen.** Proposing that a word be ADDED, REMOVED or MOVED between vocabulary sets is ordinary design work — a shipped entry is not evidence that anyone judged it. `SUFFIX_ACRONYMS` arrived in `af5bdab` as a bulk Wikipedia import never reviewed against surname collisions: 572 of its 577 alphabetic entries leave `family` empty in `"John "` against five ambiguous-gated exceptions (recomputed 2026-09-07; the fifth is `ba`; 568 of 575 against seven, measured 2026-09-25 after #540), and `sa`, `se` and `om` are borne as surnames (measured 2026-08-23; all three since decided, `sa` removed from the vocabulary on 2026-10-25, #857), as were `rai`, `cha`, `ba` and `mc` before `decisions/suffix-acronym-collisions` decided all four — `rai` and `cha` removed, `ba` marked ambiguous, `mc` left alone as no borne name at all. The same entry marked `meng` and `lac` ambiguous on 2026-09-25 (#540). When a fix starts to look like new machinery, check the vocabulary first. Criterion: `decisions/vocabulary-collisions`, with #360's positional qualifier. **Sweep the forms a change can reach: comma shapes, then `name_order`.** No comma, a FULL name before the comma, a ONE-WORD name before the comma and NOTHING before the comma are four paths, and the last two are the misses. For orders, `tests/v2/test_cases.py` runs every row under all three but asserts only R2's family partition there, so it checks nothing a new change moves: parse your change's names in each comma shape AND each order yourself. The grid each needs (an empty head and a nickname-only head, a particle, connective and suffixed surname before the comma, two title-particles at the head, a dual second in a title run, a trailing title after an empty head), how to read a grid's moves, and the predicate and exclusion-list traps are in `nameparser/AGENTS.md`, beside the spelling sweep. **Then sweep the spellings: a class reached by vocabulary is also reached by SHAPE.** A test that uses only the listed spelling of a word class walks only the vocabulary path, and the shape path is a separate branch that can be missing while every test passes, so when a change touches a word class, test one spelling from each path. The classes where a path was missed (roman numerals, credentials and titles, a separator's spacing, a full stop inside a word, capitals between name words, adjacent pairs), each with the PR that found it, are in `nameparser/AGENTS.md`, which loads when a session reads anything under `nameparser/`, the vocabulary TOML included. **And the encoding: a letter class written `[^\W\d_]` or tested with `isalpha()` is blind to a combining mark.** Python's `\w` and `isalpha()` do not match Mn, so a letter written as base plus accent (NFD) ends a word early; the vocabulary path is immune because `_normalize` NFC-folds, the shape path is not. Which predicates were blind (#652, #665), how each was fixed, what is deliberately left uncovered and the guards to add a row to are in `nameparser/AGENTS.md`, beside the other sweeps. ### Vocabulary (`nameparser/vocab/`) and the configuration layer (`nameparser/config/`) **The default vocabulary is written per word, in TOML, and compiled.** `nameparser/vocab/.toml` holds one file per BCP 47 language tag, each `[[word]]` entry a `text` and its readings keyed by kind. What a file means, how an entry is built, the kinds and properties with the set each compiles into, exclusions, bearer counts and the steps for adding a word are in `nameparser/vocab/README.md`, the reference for anyone editing the TOML; `tests/v2/test_vocab.py` holds its kind, property, derived-marker and reserved-name tables to the names `build.py` accepts and the sets it compiles, so read it rather than restating it here. The parser never reads the TOML: `tools/vocab/build.py` compiles it into the committed `nameparser/config/_compiled.py`, frozenset literals one entry per line, and runs the build checks that replaced the modules' import-time asserts (its docstring numbers them). Each `config/` module re-exports its constants from there (`TITLES = _compiled.TITLES`) under its autodoc docstring, so the public names, the docs and the deprecated-alias bridge did not move. `tests/v2/test_vocab.py` fails when `_compiled.py` is stale, naming the refresh command, and gives every build check a mutation. The model, the phasing and what was declined: decisions/vocabulary-model. The `config/` SETS are frozen since 2.2 (#293): there is no `.add()`/`.remove()` on any of them, so a default word list is changed at runtime by configuring an object — a private `Constants` for `HumanName`, a `Lexicon` for the 2.0 API — never by editing the constant, and the shipped default is changed by editing the TOML and rebuilding. `CONSTANTS`/`Constants` still hand out mutable `SetManager`s; the freeze is on the module set constants they copy from. **Neither dict was frozen, and `CAPITALIZATION_EXCEPTIONS` is not covered by anything else either.** `REGEXES` is a compiled-pattern table rather than vocabulary and was never in #293's scope; `CAPITALIZATION_EXCEPTIONS` is vocabulary-shaped and is a decided, in-scope exemption. So the split-default hazard the freeze closes is still live for it, measured on 2.2 and re-measured 2026-10-25 with the key below, the earlier `dphil` example having become a shipped mask in #797: `CAPITALIZATION_EXCEPTIONS['dclinpsy'] = 'DClinPsy'` reaches a freshly built `Constants` and neither the cached `Lexicon.default()` nor the shared `CONSTANTS`. Same advice — configure the object (`constants.capitalization_exceptions[...]`, or `dataclasses.replace(lexicon, capitalization_exceptions=...)`). `tests/v2/test_contracts.py::test_every_vocabulary_constant_is_frozen` names both dicts as explicit exemptions rather than letting its `isinstance` filter drop them. - `titles.py` — `TITLES` (every title reading; prenominals), `GIVEN_NAME_TITLES` (the readings with `addresses = "given"`, e.g. "Sir", which treat the following name as given, not family — a subset of the title readings by construction, though not of `TITLES`: a word that may also be a given name, such as `king` or `sheikh`, compiles to `TITLES_AMBIGUOUS` instead) and `AFTER_GIVEN_TITLES` (the salutation readings with `after_given = true`, read as the title where one ends the given part after a family comma, rules/H8, #835 — a subset by construction, with no v1 `Constants` attribute); `TITLES_AMBIGUOUS` (a title reading beside a `given` one, derived, compiled INSTEAD of into `TITLES`: a title only inside a title phrase, rules/H9, #752 phase 2i), `TITLE_MODIFIERS` and `TITLE_STYLES` (by kind) and `TITLE_PHRASES` (the two-word phrases `attaches` makes), none with a v1 attribute - `suffixes.py` — `SUFFIX_ACRONYMS` (suffix readings with `acronym = true`, matched with every period removed, e.g. "M.D.") and `SUFFIX_WORDS` (the rest, e.g. "Jr."), disjoint because a word has one suffix kind; `SUFFIX_ACRONYMS_AMBIGUOUS` (an acronym reading on a word with a `given` or `family` reading, derived since 2026-10-25: the build refuses the phase 1 flag `may_be_name`); `SUFFIX_WORDS_AMBIGUOUS` (its word twin, a word reading on a word with a name reading, `fils` and the agnomens, gated by the count, rules/S11, since 2026-10-25) with `AGNOMENS` inside it (the `agnomen` kind, whose writing never leans); and `GLUED_HONORIFICS` (#308, `attached`), the subset of `SUFFIX_WORDS` the peel may split off the END of a name token — a separate, harsher bar, since the glued position has no writer-drawn boundary to lean on, and each word refused it carries `excluded = { attached = … }` - `particles.py` — `PARTICLES` (family-name particles, e.g. "de", "van") and `NON_GIVEN_NAME_PARTICLES`, the curated subset that is *never* a standalone given name (the particles whose word has no `given` reading, each stating `may_be_given = false`; a `given` reading on the word is what makes a particle possibly given, the phase 1 flag `may_be_given = true` refused since 2026-10-25). What the subset licenses is rules/P1's fold in `post_rules` (the "rule 1b" of older issue comments — decisions/legacy-rule-numbers maps the old numbers), and the fold has ONE site: the piece that OPENS the name. A lone never-given particle there, with at least one more name word behind it, starts the family. Titles do not move the opening position (rules/P4), so `Sir de Mesnil` reads family `de Mesnil` under every `name_order`, as `de Mesnil` does. How far the fold reaches depends on the order the name was read under: by default it takes the rest of the name (`de Mesnil Jean` → family `de Mesnil Jean`); under either family-first order it takes the particle run plus ONE name unit and lays the rest out by the order (`de Mesnil Jean` → family `de Mesnil`, given `Jean`); after a family comma it takes the rest of that part whatever the order (`Smith, de Mesnil Jean` → family `Smith de Mesnil Jean`). The leading particle stays a lone piece for the fold to find — `group` does not chain it forward, and the fold walks the particles after it itself — so `de la Vega` reads family `de la Vega` under all three orders. The GIVEN position is not a site. It was a second one until #467, which removed it because under a family-first order that slot holds what the caller DECLARED to be the given name: `Mesnil de` under `FAMILY_FIRST` or `FAMILY_FIRST_GIVEN_LAST` reads given `de`, family `Mesnil`. Nor is a particle inside the name: `Juan de la Vega` under a family-first order reads family `Juan`, given `de la Vega`, with no ambiguity recorded — what the vocabulary forbids is the bare particle as a given name, not a name part that begins with one. A bare `de` has nothing to fold into and is placed by position like any one-word name: given `de` by default, family `de` under both family-first orders. A leading particle OUTSIDE the set is left to position — `van Gogh` is given `van`, family `Gogh` by default and family `van`, given `Gogh` under both family-first orders — and records a `PARTICLE_OR_GIVEN` ambiguity under all three; a never-given particle opening the name records none (`de Mesnil`), and neither does an ambiguous one inside it (`Vincent van Gogh`). `Lexicon.particles_ambiguous` is its complement within `PARTICLES`, so the two mark OPPOSITE sets — see the flip warning in `docs/migrate.rst` before translating either - `bound_given_names.py` — `BOUND_GIVEN_NAMES` (`bound_given` readings, e.g. "abdul", "abu"; one that is also a particle needs a `given` reading, a build check); a group-stage rule joins the first non-title piece to its following piece before roles are assigned, reserving a family word unless a family comma has already fixed it or a given-name title stands ahead (#369, rules/P5) (ported from v1's `_join_bound_first_name`) - `given_names.py` — `GIVEN_NAME_ABBREVIATIONS` (#789, a `given` reading carrying `same_as`, the full name: "chas", "wm", Filipino "ma"), the given names written short that rules/H2's shape yields to; no v1 `Constants` attribute, and nothing else reads it - `conjunctions.py` — `CONJUNCTIONS` (e.g. "and", "of") used to chain multi-word titles and to join name parts (rules/P3), plus `CONJUNCTIONS_AMBIGUOUS` (#383/#479, rules/P8, `may_be_initial`, refused by the build on anything but a cased single letter), the subset of single cased letters that read as an INITIAL in a name written wholly in one case — a marker set with no v1 `Constants` attribute of its own, so the only v1 knob that reaches it is deleting the conjunction - `maiden_markers.py` — `MAIDEN_MARKERS` (`birth_marker` readings, e.g. "née", "geb.") routing the following name to `maiden`; with an `addresses = "given"` title, the one kind a phrase may be (`z domu`) - `_ko_surnames.py` — `KOREAN_SURNAMES` (re-exported by `surnames.py`, whose read warns since 2.8.2, 3.0 making it private), the census list the 2.0 API splits unspaced hangul on (#271), still written in Python with an import-time `assert_normalized`: a segmentation list, outside the per-word model, like the locale packs' lists. With `maiden_markers.py` it is one of the two data modules `Constants` has **no** attribute for: both reach the parse only through `Constants._snapshot()` → `Lexicon`, so the v1 surface stays frozen and there is no v1 knob to turn either off (the opt-out is the 2.0 `Policy`) - `capitalization.py` — `CAPITALIZATION_EXCEPTIONS` mapping of case MASKS (e.g. `{'phd': 'PhD'}`), compiled from the `cased` property, at most one per word: each value is its key's own letters and digits recased, laid over the word as written, so `ph.d.` repairs to `Ph.D.` and repair never writes the value's own punctuation into the word (#459); `Lexicon` raises `ValueError` on a value that spells anything else - `regexes.py` — dict of compiled regular expressions (wrapped in `RegexTupleManager` by `Constants`) `config/__init__.py` re-exports the v1-compat `Constants`, `CONSTANTS` and managers from `_config_shim.py`. `Constants` wraps the constants in `SetManager` and `TupleManager` instances, and `Constants._snapshot()` translates them into the `Lexicon` and `Policy` the core parses with. A module-level singleton `CONSTANTS` is shared across all `HumanName` instances by default. **Two-tier config pattern**: `CONSTANTS` is global; pass `constants=Constants()` for per-instance config (`None` raises `TypeError` since 2.0, #261). After modifying per-instance config you must call `hn.parse_full_name()` again. `SetManager` normalizes elements (lowercase, leading/trailing periods stripped) at every entry point — constructor, `add()`/`remove()`, set operators, and `in` membership checks (`__contains__`, #244) — and raises `TypeError` for bare strings/bytes and non-str elements, so callers don't need to worry about case. `TupleManager`/`RegexTupleManager` apply the equivalent bare-string/bytes guard to their own constructor, plus a check that no element of an iterable-of-pairs argument is itself a `str`/`bytes` — a 2-character string is a valid dict-update-sequence element, so without the check it silently shreds into a key/value pair (#242). **Assigning a non-manager to a `Constants` set field raises `TypeError`** (`c.conjunctions = 'and'`, #241), in `Constants.__setattr__`: a bare string stored there would turn later `in` checks into substring tests. ## 2.0 API modules (`nameparser/_*.py`, `tests/v2/`) The 2.0 rewrite lands as underscore-private modules alongside the v1 code. These conventions apply to all new-API code and are stricter than the v1 sections above. 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 new module is underscore-private (`_types.py`, `_lexicon.py`, `_policy.py`, `_locale.py`, `_render.py`, `_pipeline/`, `_parser.py`, plus the facade layer: `_facade.py`, `_config_shim.py`). The public import surface is exactly `nameparser` and `nameparser.locales`; `nameparser/__init__.py` holds re-exports and `__all__` only — no logic. Since the M11 swap, the old paths are import-path-preserving re-exports: `nameparser.parser` re-exports the `_facade` `HumanName`, `nameparser.config` re-exports the `_config_shim` names (`Constants`, `CONSTANTS`, `SetManager`, `TupleManager`, `RegexTupleManager`); the `config/` DATA modules stay the vocabulary source through 2.x. The whole facade layer is deleted in 3.0. - **Facade layer** (`_facade.py`, `_config_shim.py`): the v1-compat `HumanName`/`Constants` over the core. Key mechanisms: `Constants._generation` dirty-tracking (every mutation bumps; facades resolve their `Parser` lazily via `_cached_parser(lexicon, policy)`); `Constants._snapshot()` mirrors `_lexicon._default_lexicon()` (equality-pinned); the facade pickles v1-SHAPED state (component lists, one `__setstate__` path for 1.4 and 2.x blobs; components rebuild via `replace()`, never a re-parse); `_V1_HOOKS` overrides warn once per subclass (#280). Since 2.8 every use 3.0 removes warns, on every call at the caller's line (decisions/3-0-reevaluations, 2026-10-28): construction of `HumanName` and of a `Constants` (the `CONSTANTS` singleton is built past `__init__`, so importing stays quiet), a pickle load in each `__setstate__` (with `__copy__`/`__deepcopy__` defined so a copy is no load), and a PEP 562 `__getattr__` read of `nameparser.config`'s managers and `nameparser.util.lc`, the library reading the private names. "The caller's line" is a claim about every call depth that reaches a warning: a fixed `stacklevel` is exact for one, and the shared-`CONSTANTS` warning sat inside the shim for every manager mutation (`CONSTANTS.titles.add(...)`), hidden by Python's default filters, until 2.8.0's release review, so a warning reached at several depths walks out of its module (`Constants._bump`, `_lexicon._warn_dead_entry`), and its test sweeps each shape (`test_mutating_the_shared_constants_warns_at_the_caller`). Each message's offered code is pinned in `tests/v2/test_deprecations.py`; the suite ignores the messages by message in pyproject's `filterwarnings`, and a test whose `catch_warnings()` block calls `simplefilter` re-applies them with `tests/removals.py`'s `ignore_removals()`, the one list a test holds to pyproject's. The compat contract is the migration spec's promise: warning-free 1.4 code behaves identically except release-log-classified fixes — 2.0 was verified against 1.4-on-PyPI by a classified differential gate, retired in #664; `tools/differential/compare.py --baseline 1.4.0` still prints every corpus name that reads differently from 1.4.0, unclassified, when the question comes up. `tools/differential/README.md` owns the corpus roster and what each file is blind to; don't restate it here. `parser.py:NNNN` citations throughout the 2.0 code refer to the PRE-swap v1 file, deleted at the M11 swap; resolve them with `git show 2d5d8c2:nameparser/parser.py`. - **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 — vocabulary is single-sourced from the v1 data modules through 2.x, e.g. `config/maiden_markers.py`); `_locale` sits on `_lexicon`+`_policy` (plus `_types` for the shared pickle mixin); `_render` imports `_types` and `_lexicon` (for `Lexicon.default()` and `_normalize`); `_pipeline/*` imports `_types`+`_lexicon`+`_policy` plus in-package `_pipeline` helpers; `_parser` sits on everything except `_render`; the facade layer (`_facade`, `_config_shim`, `parser`, `config/__init__`, `__main__`) may import anything public plus `_render`; 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. Sanctioned deviation, facade layer only: `HumanName` and the shim `Constants` organize by v1 concern groups (`# -- render defaults --`, `# -- config / parsing --`, `# -- fields --`, ..., dunders and pickle last) — the classes mirror v1's own surface and die in 3.0; the canonical order still binds every core type. - **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). - **Guard, hint, and emit for the WHOLE family, and parametrize the test over it**: a check added to one member of a set belongs on all of it, and the test must sweep the family, not one example. This session shipped `_reject_str_and_mapping` on `Policy` but not `PolicyPatch`, the bytes decode hint on three of five config entry points, and a regex-sync roster missing four of its copies — each a separate follow-up bug that a `{class} × {field} × {bad-value}` parametrization would have caught and a per-example test hid. When you find you're guarding member N, grep for the other members first. - **Ambiguities are emitted at the DECISION site**: an `Ambiguity` records a fork the parse had to call, not a token that sits in an ambiguous vocabulary, so emit where the branch is taken, in every stage that takes one, and name the reading not taken. The contract is mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE; the working rules (branches that change nothing, the field a report names, the stages that decide `PARTICLE_OR_GIVEN`, `alternative`, and how to test an emitter) are in `nameparser/_pipeline/AGENTS.md`, which loads when a session reads a pipeline stage. - **A kind is worth adding only if a reader would hesitate too**: the test is not "does the code take a branch" but whether a person reading that input would genuinely be unsure. "Smith, John V" reads as a middle initial to anyone -- the comma settles it -- so reporting it would be noise that teaches callers to ignore the field, which costs more than the missing report. Reachability of the second branch is necessary, not sufficient. Prefer leaving a fork silent and documenting the omission over emitting on input nobody finds ambiguous. - **Parser owns config-dependent conveniences**: `Parser.matches`/`Parser.capitalized`/`Parser.revise` exist because the `ParsedName` equivalents fall back to DEFAULT config for str/omitted arguments (documented loudly in both docstrings). `revise` harvests tokens from a full sub-parse of each replacement value (tags kept minus `FOLDED_TAG`, roles forced, the R1 entry pass `suffix_entries` re-run over the forced state so a suffix value's entries follow its own commas, ambiguities discarded); the merge tail is shared with `replace()` via `ParsedName._with_field_tokens`. `Parser.capitalized` delegates through `name.capitalized(self.lexicon)` specifically so `_parser` never imports `_render` — keep it that way. - **Per-word vocabulary fields warn on multi-word entries** (`_normset`/`_normpairs` via `_warn_dead_entry`, UserWarning, never a raise — see the given_name_titles Gotcha for why raising is wrong). `given_name_titles` is the one multi-word-matched field and is exempt; `_edit` passes `warn=False` (add() warns once via the new instance's `__post_init__`; remove() stores nothing). The default vocabulary and every locale pack must stay warning-free (`test_default_lexicon_builds_warning_free`, `test_pack_vocabulary_entries_are_single_words`). - **Invariants guard harm, not no-ops**: add a constructor check when violating it produces a *wrong parse*, not when it produces *nothing*. A false positive costs a working configuration; a true positive on an inert condition costs the user nothing, so that trade is never worth taking. `suffix_acronyms_ambiguous ∩ suffix_words` is guarded because the overlap loses a family name; `given_name_titles` is not, because an unreachable entry is simply never consulted (see Gotchas). Before adding one, construct the config it forbids and check what actually breaks. **Between raise and silence sits the construction-time `UserWarning`**, for a gap that is statically decidable, harmless to SOME deliberate caller, and indistinguishable-from-working for everyone else: the segmenterless activation (#337 — `parser_for(locales.JA)` without a segmenter behaved exactly like a working parser minus the feature) warns rather than raises because the inert JA registration is itself a pinned property, and a warning is filterable by the caller who wants exactly that. The message must carry every applicable remedy and no inapplicable one (the `ja_segmenter` hint fires only when a Japanese script is among the dead ones). Test fuzzers that legitimately construct such configs suppress the warning by MESSAGE, never by category — a blanket `UserWarning` ignore would mask the next construction diagnostic (`_quiet_parser` in `tests/v2/test_properties.py` is the pattern). - **The shim TRANSLATES; it never raises on a config v1 accepted (one decided exception, #459, at the end of this bullet), and never silently changes the parse**: `Constants._snapshot()` is a translation boundary between v1's model and v2's invariants, and every transformation there carries its v1-reachability argument in a comment. These exist today — `first_name_titles` re-folded per word (v1 joins-then-`lc`, v2 normalizes-then-joins), `suffix_acronyms_ambiguous ∩ acronyms` (a provable no-op), `suffix_words − ambiguous` (v1 already accepts the word via the acronym branch, so the addition is inert there), `particles_ambiguous ∪ (bound ∩ particles)` (a pinned deviation, `test_bound_never_given_prefix_deviates_on_two_pieces`), `honorific_tails = GLUED_HONORIFICS ∩ suffix_words` (#308 behavior with no v1 manager of its own, so the one v1 knob that reaches it is deleting the suffix word — which turns the peel off, `test_snapshot_removing_a_honorific_word_turns_the_peel_off`), `conjunctions_ambiguous = CONJUNCTIONS_AMBIGUOUS ∩ conjunctions` (#383/#479 behavior with no v1 manager of its own either, so the one v1 knob that reaches it is deleting the conjunction — which turns the marking off, `test_snapshot_removing_a_conjunction_turns_the_marker_off`), `after_given_titles = AFTER_GIVEN_TITLES ∩ titles` (#835, likewise: deleting the title turns H8 off for it, `test_snapshot_removing_a_title_turns_its_after_given_reading_off`), `suffix_words_ambiguous = SUFFIX_WORDS_AMBIGUOUS ∩ suffix_words` with `agnomens` inside it (#752, likewise: deleting the suffix word turns S11's gate off, `test_snapshot_removing_a_suffix_word_turns_its_gate_off`), `title_modifiers`, `title_styles` and `title_phrases` intersected with the titles (#752 phase 2i, rules/H9, likewise), `titles_ambiguous` passed whole except where the caller emptied `titles`, v1's way of turning title detection off, which drops it too (`test_snapshot_cleared_titles_turn_the_title_phrase_off`), and except the words 2.6.0 listed as titles (`moved`, from the frozen `config/_titles_2_6_0.py`): v1's default `titles` keeps holding them, so `c.titles.remove('king')` works as on 2.6.0, and the snapshot passes a kept one to `titles_ambiguous` and a removed one to neither (decisions/H9, 2026-10-26; `test_removing_a_moved_title_turns_every_title_reading_off`), `given_name_abbreviations` passed whole (#789: a word a v1 caller adds to `titles` is still a title, the leading peel asking the titles before the shape), and `maiden_delimiters − nickname_delimiters` on the POLICY half of the same method (v1 precedence: a pair in both v1 buckets parses as a nickname, while `Policy` resolves the overlap the other way, so the subtraction is what keeps the facade at v1 behavior, `test_snapshot_overlap_keeps_v1_nickname_precedence`), and `_v1_matchable`, which runs ahead of every other one: an entry v1 could never match — empty after `lc()`, or holding edge whitespace, a whitespace run or any whitespace character other than a single space, since no v1 piece does — is dropped from all nine set fields and from the `capitalization_exceptions` keys and named in one `UserWarning` whose remedy runs (#541; translated, it started matching once `Lexicon` stripped it or tripped a `Lexicon` invariant the set algebra could not see, `test_an_entry_v1_could_not_match_is_dropped_with_one_warning`), except the two whitespace typos in 1.4.0's own shipped `TITLES`, which a restored 1.4 pickle carries and are dropped without the warning. The same filter drops an entry `Lexicon` folds to empty — since 2.3.0's `FULL_STOPS` fold, a lone CJK full stop, which 1.4.0 through 2.2.0 could match on a token made of that stop but which since 2.3.0 can be neither held nor matched, so translating it raised wherever `Lexicon` received it (#582, the one departure from 1.4.0 this filter makes) — and the two cross-set computations `Lexicon` re-checks after its own fold (`suffix_words − ambiguous` and `bound ∩ particles`) compare `_normalize`d spellings, while every other field passes raw as before (an edge full stop, `'ma。'` beside the ambiguous `ma`, had raised `Lexicon`'s gate-bypass check since 2.3.0; folding EVERY field instead was measured to move readings on configs that never raised, `test_an_edge_full_stop_entry_outside_the_two_checks_reads_as_before`). Note that the `maiden_delimiters − nickname_delimiters` one is on the `Policy`, not the `Lexicon` — the roster is per-`_snapshot()`, not per-vocabulary-field, so a sweep that only reads the `Lexicon(...)` call misses it. When a v1 config cannot satisfy a v2 invariant, work out what v1 actually *does* with it — usually nothing — and reproduce that; weakening the invariant or letting the raise through are both wrong. **Test the case the translation decides**, not one where both branches agree: a test using an input v1 parses identically with and without the config pins nothing. A `capitalization_exceptions` value that does not spell its key's letters and digits raises `ValueError` at snapshot rather than being translated (#459), because v1 substituted such a value for the word and 2.4 repair only recases, so there is no v1 behavior to reproduce; pinned by `test_a_mismatched_exception_value_raises_at_the_first_parse`. - **Reprs are bounded**: render which fields deviate from a named baseline and by how much, never contents (`Lexicon(default + titles: +2)`). `PolicyPatch`'s repr shows only set (non-UNSET) fields; `_order_repr` must never raise even on an unvalidated patch's garbage `name_order` (PolicyPatch defers validation to apply time); the sweep test in `tests/v2/test_reprs.py` pins that no config repr leaks the UNSET sentinel. - **Every pipeline stage is one module holding one public function of the same name** (`_tokenize.py`/`tokenize`), with the exceptions named where they stand — `_group.py`'s `marker_run_length`, a shared predicate (mechanisms.md#ONE-PREDICATE-PER-QUESTION), and since #511 `_post_rules.py`'s `suffix_entries`, the R1 entry pass as a function, because `Parser.revise` runs that one pass over a forced-role sub-parse and a stage's tail cannot be called on its own — **and its module docstring declares the contract in three labelled lines**: `Consumes:` what it takes from `ParseState`, `Produces:` what it hands back, `Reads:` which `Policy`/`Lexicon` fields it consults. `Reads:` is the load-bearing one — it makes "which stage do I touch for this feature?" a grep rather than a read-through. The authority for the stage set is `_pipeline/__init__.py`'s `STAGES` (eight, and not public API) with the field-ownership map in `ParseState`'s docstring, pinned by `tests/v2/pipeline/test_state.py`; NOT the rules, which are implementation-free by their own preamble and whose `implemented:` names modules honoring a rule rather than stages. A `_pipeline/` module that is not a stage says so in its first line instead (`_assemble.py`: "Not a stage: …", omitting `Reads:` because it consults neither), so absence of the three lines is a claim about the module rather than an oversight. Provenance: §5 of the 2026-07-11 conventions spec, recorded here 2026-08-16. - **Frames are a budget for hot and scaling paths; everywhere else, prefer the shared predicate.** The parser's cost is counted in Python frames (`sys.setprofile` "call" events — deterministic and load-immune, which is why #475 chose them over wall-clock timing), but one frame is small: measured 2026-09-28 on py3.11, a trivial call costs about 13 ns against 40–80 µs for a whole parse, so one frame per parse is roughly 0.03% of it. What the budget exists to catch is how a cost SCALES and how it ACCUMULATES, so weigh an extra frame by four questions, in order. (1) Does it multiply with the input — per token, per member, per pair? Then it matters, and a hand-inlined fast path can earn its place (the inline tag tests ahead of `_pieces.starts_a_credential_run` in its loops, each a necessary half of the predicate so nothing can drift; `_vocab.run_word_fold` was the measured example until #613 deleted the run test it served). Folding a copy into a shared helper is where such a pre-check gets lost: #617 replaced `_comma._title_chains`, whose loop tested the conjunction tag inline, with a call into group's loop, which asks `is_conj_piece` per piece, and every family-comma parse paid two frames per word of the part (`Smith, John` +2) while the reference row fell -- review caught it; the fix gates the call on the tag walk the caller already makes, and `test_a_comma_part_with_no_free_connective_skips_the_join` holds it. Count a comma name, not only the reference row, when a fold touches the comma path. (2) How many names pay it — every parse, where plain names like `Smith, John` must keep their cost, or only a rare shape? (3) Does it move an enforced guard — the ±2% reference band in `test_benchmark.py` (`_CALL_BASELINE`, decisions/parse-cost) or a scaling ratio? (4) What does the inline alternative cost to maintain? A hand copy of a predicate that can drift is the defect class mechanisms.md#ONE-PREDICATE-PER-QUESTION names, and it compounds; a NARROWER question stated exactly is not a copy and may stay inline on that ground alone. A constant frame on a rare shape that no guard sees loses to readability: #544's cleanup declined `is_title_piece` for one frame on comma credential runs (24 ns a parse, no guard moved), a stricter call than this rule asks for; by this rule the inline test is still right, but because it asks the narrower question (a lone word in both vocabularies), not because of the frame. The converse holds too: the frame counter cannot see C-level work (`x in some_list`, slices, string concatenation), so "no new frames" is not "no new cost" — #553's quadratic was a list membership test inside a loop, invisible to every frame guard. - **A claim about WHICH STAGE or WHICH LAYER does something is checkable — check it before writing it.** The pipeline is eight stages with a written ownership map (`ParseState`'s docstring, pinned by `tests/v2/pipeline/test_state.py`), and `parse(s).tokens` prints every token's role and tags, so "extract assigns this", "classify never sees that", "group consumes it" each have a one-command answer. #329's prose claimed delimited maiden content is *"claimed whole before classify has tagged anything inside it"*; measured, `classify` tags the marker fine and only the CONSUMING is missing, because `_group`'s rule walks `pieces` and a token that already carries a role is not in `pieces`. Two different mechanisms, one plausible sentence covering both. That single claim then shipped SIX times across three correction rounds, which is the part worth internalizing: **when a mechanism claim turns out wrong, sweep for where else you wrote it, and sweep again at the END of the change over the words the change itself just added.** Prose density here means one idea lives in a docstring, a case note, a release-log entry and this file at once; the implementer working against a wrong mechanism is the person most likely to restate it; and rewriting a mechanism claim is writing one, so the correction earns the same one-command check as the original — two of the six instances were fresh errors introduced by the sentence fixing the previous one. **Adjacency is the trap.** The claim that feels already-known is the one about the neighbouring stage or the neighbouring layer: one comment block in `tests/v2/test_facade_cases.py` got the exception type, the raising layer, the skip mechanism, the count of skipped rows, and which row was blocked all wrong at once — every one a claim about `_config_shim` versus real 1.4.0, written from reasoning, in the file whose whole job is translating between them. What finally held was not better prose but moving the claim into a test (`_CORE_ONLY_IDS`), which cannot be wrong the way a sentence can. - **Typing/docs**: `from __future__ import annotations`; `frozen=True, slots=True` on every public dataclass; strict-profile mypy flags via per-module overrides in pyproject (`strict = true` itself is not valid per-module). Docstrings state contracts in prose with **no doctest blocks** — `--doctest-modules` makes every example a test; behavior examples go to unit tests per the lean-docs rule. **Document the positive direction of a partial property**: "a non-empty `ambiguities` is a signal to act on" is checkable, while "an empty one means no fork occurred" is a universal negative needing exhaustive verification -- that claim was written twice and falsified twice, at sites the author had not audited. - **The segmenter contract**: the optional `Parser(segmenter=...)` hook is parse-totality's ONE exception (locales spec section 4). Everything inside that exception is a bug in USER CODE, never a fact about the name, so it is surfaced rather than absorbed: the segmenter's own exceptions propagate, and the two protocol violations the stage can detect for itself — an answer of the wrong type, and one cutting at or past the end of the token it was handed — raise `TypeError`/`ValueError` from `_script_segment` for the same reason. The line to hold when adding a check there: a protocol violation by the segmenter's AUTHOR raises, while an adapter's defense against its own third-party library (`locales/ja.py`'s repertoire, length, reconstruction and score guards) declines with `None`, because what those catch is a fact about the content. - **Pickling**: v2 types must round-trip (`Parser` is picklable by construction, and it holds a `Lexicon`; the one qualifier is that a `Parser` pickles iff its segmenter does — see the segmenter bullet above). Every frozen type assigns `_guarded_getstate`/`_guarded_setstate` (`_types.py`) in its class body (`@dataclass(slots=True)` would override inherited pickle methods) — unpickling fails at the LOAD site on field-layout skew, with one widening: a field added after its type shipped carries `metadata={_types.ADDED_IN: ""}` and a default, and an older pickle missing it loads with the default (`Ambiguity.alternative`, 2.7) -- a missing field without the marker, default or not, an unknown field and a renamed one are still refused, each a row in `tests/v2/test_types.py::_REFUSED_SKEWS`, so a new field on a pickled type takes the marker or breaks every older pickle; values are deliberately NOT re-validated (pickle is not a security boundary; canonical state comes from a validated instance). `Lexicon` keeps its own copy of the guard (layering) plus the `mappingproxy` slot rebuild; a new unpicklable slot type needs the same treatment plus a round-trip test. - **One sanctioned global**: the (future) cached default `Parser`. Lazily cached FROZEN singletons (`Lexicon.default()`'s `functools.cache`, the future default parser) are constants, not state; any second piece of module-level MUTABLE state requires amending the conventions doc, on purpose, in review. Sanctioned exceptions, facade layer only: `_config_shim.CONSTANTS` (the v1 shared singleton, mutable by design) and `_facade._WARNED_SUBCLASSES` (the once-per-subclass hook-warning dedup set) — both deleted with the layer in 3.0. - **Tests**: all v2 tests live in the `tests/v2/` package (its `conftest.py` overrides the v1 dual-run fixture — v2 code never reads shared `CONSTANTS`), one test module per source module plus the cross-cutting ones (`test_reprs.py`, `test_layering.py`, `test_contracts.py`, `test_properties.py`, `test_benchmark.py`, the `cases.py`/`test_cases.py` table, `test_regex_sync.py`, which pins the pipeline's hand-copied patterns against their sources, and `test_snapshot.py`, which holds every corpus name's reading to `tools/differential/snapshot.jsonl` — sharing `_differential_fixtures.py` with `test_differential.py` and `test_corpora.py` for the handles on `tools/differential/`), names stating behavior. Never assert `Lexicon.default()` contents; the narrow sourcing spot-checks in `test_default_sources_v1_vocabulary` that pin the v1→v2 migration contract (e.g. the flipped `particles_ambiguous` model) are the sanctioned exception. ## Extension Patterns **Adding a word to the vocabulary** — the steps are in `nameparser/vocab/README.md`, and the traps they have run into (other readings that clash, reading the real-name counts, the predicates that test a member's spelling, user-doc examples that use the word, recording the vetting on the word) are in `nameparser/vocab/AGENTS.md`, which loads when a session reads a vocabulary TOML file. First check the word's OTHER readings (`uv run python tools/vocab/query.py --word `), then its bearers (`uv run python tools/name_usage.py `, with `--boxes` adding rows for the fielded local corpora that count the word by box and country, and `--country CC` the country a pack is for). For a title that is also a name (rules/H9's batches, #913), `uv run python tools/vocab/word_report.py ... --base ` prints the H9 shape grid, the word's incidence in every fetched local corpus and its usage rows in one run, and `--rank` orders candidates before a batch is cut; `uv run python tools/differential/measure.py --base ` then runs every corpus check of the change, one after another, the local ones against the base. **Before adding a short/common word to `PARTICLES` globally**, test it mid-string against realistic 3-token names, not just check for English-word collisions: Korean/Vietnamese given names put a short syllable in the middle slot (`Park In Hwan`, `Nguyen To Nga`), and Western names put a bare initial there (`John V. Smith`). A word that looks safe ("nobody is named 'to'") can still swallow a real middle name/initial into the last name once it's a global prefix — confirmed regressions for `to`/`in`/`an`/`ten`/`then` and bare `v` this way (PR #191). **Adding a curated sub-set of an existing vocabulary set** (a marker over a base set, as `AFTER_GIVEN_TITLES` is over `TITLES`) — it is a behavioral PROPERTY of a reading, not a second word list: a row in `tools/vocab/build.py`'s `BEHAVIORAL` table, a branch in `compile_pool` adding the words whose reading carries it, a constant in its `CONSTANTS` roster and a key of the same name in its `Compiled` TypedDict (an import-time assert holds the two to one list), and the `NAME = _compiled.NAME` assignment in the config module. The subset relation then holds by construction, since the property cannot exist without its reading, so it needs no check. A marker that records a clash with a NAME is the other shape: derived from the word's name reading beside the clashing one (`SUFFIX_ACRONYMS_AMBIGUOUS`, `SUFFIX_WORDS_AMBIGUOUS`, and the complement `NON_GIVEN_NAME_PARTICLES` works from), not written as a property, so it too holds by construction (decisions/vocabulary-model, 2026-10-25). A relation construction does not give (`∩ == ∅` with a set the sub-set cannot logically overlap; a sub-set member that is also a title is silently inert, title handling consuming it first) goes in `check_pool`, with a mutation in `tests/v2/test_vocab.py`'s table. Do **not** forbid `titles ∩ particles` — that overlap is intentional (`freiherr`, `st`). On the v1 side a sub-set new in 2.x, such as `honorific_tails`, `conjunctions_ambiguous`, `after_given_titles` or `suffix_words_ambiguous`, gets no `Constants` attribute and rides in `_snapshot()` as an intersection with its base, the shim roster under the 2.0 conventions listing each (the v1 ATTRIBUTE names did not move in #293, only the module constants did). A runtime `.add()` to the sub-set's `SetManager` does **not** propagate to the parent's (same as `first_name_titles`→`titles`), so a caller adding a brand-new word adds it to both. Two traps a gating set met (#752 phase 2i). Where a caller listing a word in the BASE set has always meant "read it as this everywhere" (the 1.4 recipe `c.titles.add("dean")`), the gated words compile OUT of the base rather than into both, or the gate overrides the caller's listing: `TITLES_AMBIGUOUS` is not a subset of `TITLES` for that reason, and a word in both reads as a title. And `_VOCAB_FIELDS` is read whole by two rosters in `_pipeline/_vocab.py`, the capitals shape's "unlisted" (`in_any_wordlist`) and the comma contrast's "claimed as non-name" (`_NON_NAME_FIELDS`), so a new field joins both by default; a field whose words may be names leaves both, which the grid found as `John Smith, DEAN` reversed where C6 had read a credential; #789's `given_name_abbreviations` met it again (`John Smith, JAS` and `Jas SMITH, XYZ` reversed), found by review because its grid held only the dotted spellings the set was for -- a new field's grid holds the spellings it must NOT move too. **Validating a new parsing rule** — before implementing, simulate it in a throwaway script against `TEST_NAMES` (plus a few target-language examples) to catch regressions/false-positives early. E.g. this surfaced the `last_base` `do`/`st`/`mc` empties and the patronymic `"David Michael Abramovich"` false-reorder. After the change, `tests/v2/test_snapshot.py` lists every corpus name it moved, grouped by changed field -- read them, and re-record with `uv run python tools/differential/snapshot.py --record` once every move is intended, so the PR diff of `snapshot.jsonl` shows the reviewer the same list. `uv run python tools/differential/cldr_agreement.py` reports how the tree's agreement with CLDR's stated name fields moved; `tests/v2/test_cldr_agreement.py` fails on any move, and `tools/differential/cldr_agreement.json` doubles as a measured list of names the parser and CLDR read differently. The same command and test score the GND corpus's real names (`gnd_fields.jsonl`, record `gnd_agreement.json`) by the same rules, and `--record` rewrites every record, printing per source what it accepts. A locale-pack change reads the second view the same command prints, per pack (`cldr_agreement_locales.json`, #672, and `wikidata_native_agreement_locales.json` for real Han and kana names); its `JA` rows run only with the `ja` extra installed. A change to Han, Hangul or kana order or to Korean segmentation reads `wikidata_native_agreement.json` too: the only real names in those scripts the corpora hold. It holds no honorific, so a change to the honorific peel moves nothing there. A change to comma or credential handling also reads the local corpus of hand-typed names (`snapshot.py --local ctgov-officials --base `, which reads the base from a baseline kept for its package's files and never the shared snapshot beside the corpus (a base read once from the same fetch is copied rather than parsed again; `--sample N` reads a fixed sample while iterating; the shared snapshot beside the corpus is one file per source for every worktree, and #752's phase 2f found ctgov reporting a sibling branch's 1,334 joined-credential readings as its own moves against it); tools/differential/README.md's "Local corpora"): the one population of names nobody chose for a test or a catalog, so it shows how often each shape is typed, and the one no CI run sees. A change to title handling reads `ukec-individuals` the same way: UK donors' names in spoken order and mixed case, in 8,219 of whose 15,214 `parse()` read a title on 2026-10-22. A change to particles or generational words reads `snapshot.py --local tse-donors` the same way, Brazilian names in spoken order dense with both. A change to initials, to a title word outside English, or to how a script's combining marks are read reads `snapshot.py --local nepal-candidates` the same way: Nepal's local election candidates, each a romanized name (surnames written as initials, `B.K.`, `K.C.`; honorific titles such as `Guru` in front, `Shekh`, a title there until #752's phase 2j made it one only inside a title phrase, and `Md.`, which its period makes a title by shape) beside the same name in Devanagari, written with vowel signs and the virama, combining marks the letter-shape tests do not count as letters (decisions/nfd-shapes), and with the joiners ZWJ and ZWNJ. It is the only large population of South Asian names the corpora hold, and its corpus reads both scripts. A change to non-Latin scripts or to Arabic-script names reads `snapshot.py --local jrc-names` the same way, and `local_corpora.py groups jrc-names`: the spellings of one person across scripts and transliterations, harvested from news text, with 17,074 Arabic-script spellings where the committed corpora hold 315 distinct names with an Arabic letter, 96 of them CLDR's fictional renderings (2026-10-24), and under a licence that keeps even a reading of one of its names in the cache. A change to which field a word lands in also reads `local_agreement.py psc-individuals --base ` the same way: real names with the four boxes their filers typed them into (UK companies' people with significant control), scored field by field and by nationality; and `local_agreement.py orcid-researchers --base `, ORCID holders' own two boxes (given names, family name) from every country, scored by the country they work or live in -- a population each person split for themselves, academic but worldwide, the one to read for a change a language pack makes; and `local_agreement.py patentsview-inventors --base `, US patents' inventors in the two boxes their filings give, split by a professional filer, with how many people filed under each name in each country, the one to read for how common a name's word is among a country's US-patent inventors. These are the only real fielded populations outside the committed fields files, and no CI run sees them either. ## Gotchas **The suite runs in parallel, so a test that passes only after another one has warmed a cache fails at random** — `--dist worksteal` hands each worker a different slice of the tests on every run, splitting modules too. Three `tests/v2/test_vocab.py` tests repointed `tools/vocab/build.py`'s `ROOT` at a fixture tree while its lazily loaded `_lexicon.py` was still read from `ROOT`, and passed for as long as the suite ran serially, because an earlier test always loaded it first; each failed when run alone. Run a new test on its own (`uv run pytest -n0 path::test`) before trusting it, and bind a path at import, as `build.py`'s `VOCAB`, `OUTPUT` and `_LEXICON_PATH` are, when tests repoint the root it is derived from. A `functools.cache` is per process too, so tests sharing a grid walk (below) take the walk once per worker that runs one of them. **Code branching on an optional extra takes the untested branch only in CI** — `namedivider` sits in the local `.venv` from earlier work but is absent from every build job, so `if _JA_AVAILABLE else None` runs the PRESENT path locally and the ABSENT path in CI. That broke #337 after a locally-green suite. Before pushing anything that touches the ja extra, run the suite without it: `uv pip uninstall namedivider-python && uv run pytest -q`, then reinstall. **A local run on 3.11 cannot see a deprecation newer interpreters raise** — `.python-version` pins 3.11, pytest's `filterwarnings = ["error"]` turns every warning into a failure, and CI runs 3.11 through 3.15, so a stdlib call deprecated in a later release passes here and fails the newer jobs. #795 passed `maxsplit` to `re.split` positionally (deprecated in 3.13) and failed four jobs while 3.11 and 3.12 passed. Before pushing new code, run it on the newest interpreter CI uses too: `uv run --python 3.15 pytest -q` (uv fetches it; the next plain `uv run` rebuilds the 3.11 venv). Pass `maxsplit`, `count` and `flags` to `re.split`/`re.sub` by keyword. **Parsing must never write into `Constants`** — `self.C` is usually the shared `CONSTANTS` singleton, so a write makes parse results order-dependent and thread-unsafe (a real bug through 1.2.x, when v1 registered dotted abbreviations like `Lt.Gov.` into the config). `ParsingDoesNotMutateConfigTests` (`tests/test_constants.py`) snapshots the whole config around a parse, discovering collections structurally via `Constants.__getstate__()`, so a new collection is watched with nothing to register. **The `nameparser/config` vocabulary constants are frozen, and the 1.x names for them are a bridge for CALLERS only** (2.2, #293) — `TITLES.add("dean")` raises `AttributeError` at the line that writes it, so a runtime addition goes on a config OBJECT instead: `c = Constants(); c.titles.add("dean"); HumanName(name, constants=c)` for the v1 API, `Parser(lexicon=Lexicon.default().add(titles={"dean"}))` for the 2.0 one. Only the 2.0 one is warning-free: since 2.8 constructing a `Constants` warns that 3.0 removes it, and mutating the shared `CONSTANTS` warns too. What the freeze buys is measured in `docs/migrate.rst`: `Lexicon.default()` is `functools.cache`d and reads the constants once, a v1 `Constants` copies them at every construction, and the shared `CONSTANTS` is a copy taken at import — so pre-freeze, an edit after the first parse reached only a freshly built `Constants`, an edit before any parse reached `parse()` and a fresh `Constants` but still not the shared singleton, and which of those happened depended on nothing the reader could see. Separately, every retired 1.x name (`PREFIXES`, `NON_FIRST_NAME_PREFIXES`, `BOUND_FIRST_NAMES`, `FIRST_NAME_TITLES`, `SUFFIX_NOT_ACRONYMS`) still resolves for a caller, with a `DeprecationWarning` naming its new path — but nothing inside `nameparser/` may spell one, in code OR in a comment: `tests/v2/test_config_aliases.py::test_no_internal_code_reads_a_retired_vocabulary_name` scans every `.py` in the package and fails on a hit outside the alias table that owns it. What an internal read costs is attribution: the warning names the file that performed the read, so the caller is told about a deprecation inside `nameparser/` they cannot act on, and a run under `-W error::DeprecationWarning` dies in library code. It does *not* cost the caller their own warning, and the reason is the warnings module rather than anything this bridge does: `__warningregistry__` lives in the READING module's globals, so repeats are suppressed per LOCATION, and a library read at `internal_reader.py:1` plus a caller read at `real_caller.py:1` produce two warnings — the same two that a plain `warnings.warn` from two files produces, verified against that control. What the bridge contributes is the *absence* of a write-back: `config/_deprecated.py` keeps serving retired names through `__getattr__` for the life of the process instead of caching the resolved value into module globals, which would silence every reader after the first — and the first is whoever imported earliest, routinely a dependency whose author is not the person who has to edit anything. Don't "optimize" that lookup. That scan does not reach `docs/` or this file, which is why the rename needed a prose sweep of its own. **`HumanName.C` is a property backed by `_C`, but pickles under the public key `'C'`** — `__init__` and direct assignment both validate, so an invalid value (or `None`, removed in 2.0, #261) raises `TypeError` immediately instead of surfacing later deep in parsing (#239). `__getstate__`/`__setstate__` deliberately translate `self._C` ↔ a `'C'` key in the pickled dict (with the `CONSTANTS`-singleton-becomes-`None` sentinel) rather than pickling `_C` directly, so the on-disk pickle format is v1's — don't "simplify" that translation away or 1.4 pickles and tests that hand-build a state dict with a `'C'` key will break. **Titles permanently shadow first names — be conservative** — any word in `TITLES` is always consumed as a title and can never be parsed as a first name. `"Dean"` is the canonical example: it's a common academic title *and* a common given name, so it is not in `TITLES` but in `TITLES_AMBIGUOUS`, a title only inside a title phrase (`Dean of Students`, `Associate Dean`; rules/H9, #752 phase 2i), and users who need it everywhere add it via opt-in `Constants`. A title that is also a given name now has that machinery: a `given` reading on the word moves it there, decided word by word in measured batches (decisions/vocabulary-model's 2026-10-24 order). A rule that reads a title by its neighbours in the title run meets more phrase shapes than the qualifier-then-head it was designed on: the object of a connective (`President of the United States`, `the Queen's Bench`, whose `united`, `states` and `bench` are modifiers with no head after them), a head before `of` (`Member of Parliament`), and a qualifier written after its head (`Chargé d'Affaires`, `Princess Royal`). Phase 2i's first build, "a modifier needs a head title after it", broke `President of the United States`, `Chargé d'Affaires` and the committed `Queen's Bench` row, found by the case table and the committed corpora, and then read a modifier ENDING the name out of H5's trailing run (`Jane Smith Vice.`), found by the design-docs review; put each shape, and the trailing position, in the grid of any such rule. Before adding a word to `TITLES`, ask: "Could this plausibly be someone's given name in any culture?" If yes, don't add it globally; it belongs in caller-supplied `Constants` instead. This same caution applies to international honorifics — `Frau` is a first name in some contexts, and `Prince` and `Sheikh` were titles until 2.7 gave them a `given` reading, which moved them to `TITLES_AMBIGUOUS` (rules/H9). It also applies to any prefix sub-set gated on "never a first name": obscure-looking foreign particles are surprisingly often real given names — `Von` (Von Miller), `Vander` (Brazilian, also the Arcane character). When unsure, exclude — a missing member just means that name isn't auto-handled, whereas a wrong member misparses a real person. The decided exception is a word borne as a name only RARELY in the leading position — mostly a surname written last (`graf`, `greve`, `knight`), a few with small given-name or surname-first counts (`herra`, `hrabia`): the title claim acts only in front, so it ships, and a name that begins with it (`Greve Anna`, `Herra Wijaya` → title) is the accepted cost. That is a judgment by incidence, made per word, with the counts taken recorded on the word's entry in `nameparser/vocab/` (herra's, hrabia's; graf, which predates #606, and greve and knight, which #606 proposed, carry none) and the class in `decisions/salutation-titles` (rules/H1's Accepted clause states the cost), not a rule a sweep can apply: a word that commonly leads a real name (Vietnamese `Ông`, the given names `thiru`, `puan`) is still out of `TITLES`; `marshal` reads as a title only in a phrase (`Field Marshal`). `tools/name_usage.py` produces the counts this judgment starts from (role counts, plus Wikidata citizenship to tell where a family name is written first); its Wikidata column is a lower bound (Shehu Shagari has no given-name statement), so a small count there proves little and a zero proves nothing, and its nam_dict row gives a band per country rather than a count, with a missing name proving nothing either. **The period-abbreviation title inference runs at the head of the GIVEN-NAME part, not the head of the name** — an unrecognized multi-letter word ending in a single trailing period (`_pieces._PERIOD_ABBREV`, a hand copy of the `period_abbreviation` regex, `{2,}` letters — it was assign's until #424 and group's until #439) is treated as a title in the leading title run, e.g. `"Insp. Jane Morse"` → `title='Insp.'`. "Leading" is per SEGMENT: `_peel_leading_titles` is called for NO_COMMA segment 0, SUFFIX_COMMA segment 0, and FAMILY_COMMA **segment 1**, so `"Morse, Det. Insp. Jane"` → `title='Det. Insp.'` and a lone `"Smith, Xyz."` → `title='Xyz.'` — long-standing, verified against 1.4.0, and the mechanism behind #296 (`"Smith, Jr."` read title in 1.4.0; the comma's credential read takes it as suffix `Jr.` now). The docs said "leading word" until 2026-08-01 and were wrong for every comma path. FAMILY_COMMA **segment 0** peels too since #701, but by VOCABULARY only (rules/H6, in assign's family-comma branch rather than `_peel_leading_titles`): `Dr. Smith, John` → `title='Dr.'`, while `Xyz. Smith, John` keeps family `Xyz. Smith`, and a title-particle (`St`, `Freiherr`) stops the run, as does a title with nothing but post-nominals behind it (`Dr. Jr., John` keeps family `Dr.`); a title/suffix dual with a name word behind it still peels (`Rev. Sr. Smith, Mary`). It does not mutate `C.titles`, so the periodless form (`"Insp"`) is unaffected elsewhere. The `{2,}` length requirement — not a separate initials check — is what excludes single-letter initials like `"J."`; the same word after the given name is left as a middle name. **The inference OUTRANKS vocabulary where it runs**: `"Esq. Smith"` → `title='Esq.'` even though `esq` is suffix-only vocabulary, because the shape rule fires before anything consults the suffix sets. The one vocabulary it yields to, since #789 (phase 2k of #752), is a given name written short (`Lexicon.given_name_abbreviations`, a `given` reading with `same_as` in the TOML): `"Chas. Smith"` → `given='Chas.'`, read off classify's `vocab:given-abbreviation` tag in `is_leading_title` and off the lexicon in `_vocab.is_title_shaped`, the two spellings of one test that `test_is_title_shaped_and_is_leading_title_agree` holds together. The second spelling is what rules/C4's comma count and the title phrase's head run ask, so a change to the shape moves comma names nobody wrote it for: #789's first grid held no comma and missed `Chas. Smith, Ed` (given 'Ed', family 'Chas. Smith' before, suffix 'Ed' after), which only mutating `is_title_shaped`'s half alone showed. Put a word of the ambiguous class after a comma behind the shaped word (`X. Smith, Ed`), and a modifier in front of it (`Vice X. Smith, Ed`), in the grid of any change to the shape, and mutate each spelling separately. **The INFERENCE still runs in one direction only, and that is what the two slots share and where they part** (rewritten 2026-09-08, #316): a period-marked word is claimed by SHAPE at the front and by VOCABULARY at the back, so an unlisted abbreviation opening a name is a title while an unlisted abbreviation ending one is a NAME word (`"John Smith Xyz."` → `family='Xyz.'`). A trailing period-marked word the vocabulary knows as a title now IS one (`"John Smith Prof."` → `title='Prof.'`, `rules/H5`) — the comma path read a lone `Smith, Prof.` that way already, and the given part's `Smith, John Prof.` since 2.3.0 (1.4.0 through 2.2.0 read `Doe, Jane Mrs.` middle 'Mrs.'), so the two agree now — and a trailing period-marked word the UNAMBIGUOUS SUFFIX vocabulary knows is still a suffix, the suffix run being peeled first (`"John Smith Esq."` → `suffix='Esq.'`). A BARE trailing title word stays a name word (`"John Smith Sir"`, `"Mary Jane Pope"`), which is the doctrine line below, save a listed salutation ending the given part after a family comma (rules/H8, `"Doe, Jane Mrs"` → `title='Mrs'`). Meanwhile `period_joined_vocab` resolves INTERIOR-period tokens (`Lt.Gov.`, `Msc.Ed.`) to title-or-suffix by vocabulary, and `_extract._suffix_shaped` treats any period-final delimited content as not-a-nickname. Four sites, and the trailing one now resolves by two vocabularies in order rather than one; unifying the rest is open design work, not settled. (#109; see `docs/usage.rst` "Titles you didn't configure") **Cyrillic suffix regexes need `re.I` even when the pattern is suffix-only** — a Latin title-cased word (`Ivanovich`) keeps its suffix lowercase, so `re.I` seemed skippable; but an irregular Cyrillic suffix can be nearly the whole word (`ильич`), so title-casing capitalizes into the suffix itself (`Ильич`). `east_slavic_patronymic_cyrillic` shipped without `re.I` on the Latin reasoning and silently failed on capitalized irregular forms — don't assume Latin's title-case safety transfers to Cyrillic. (#185) **Suffix words vs the initial veto** — single-letter roman numeral suffixes (`i`, `v`) are suffix words but also initial-shaped, so `_vocab.is_suffix_strict` (v1's `is_suffix`) vetoes them through `_vocab.is_initial`. `_vocab.is_suffix_lenient` accepts suffix words without the veto and is only safe in unambiguous positions, after a comma (#136, #144). **The veto is Latin-only since #320**: `is_initial` ANDs the bare shape test (`is_initial_shaped`) with "not in `_policy._NO_INITIALS`", the four scripts that have no initials (Han, Hangul, Hiragana, Katakana), so the single-character CJK suffix words (씨, 様, 氏, 군, 양, 님, 殿) written with a period read as suffixes: `"김민준 씨."` gives suffix `씨.`. `V.`/`I.` still lose to the veto and still need the lenient test. **Comparing against v1 needs `PYTHONSAFEPATH=1` AND a directory outside the worktree** — `uv run --isolated --no-project --with 'nameparser==1.4.0'` still puts the checkout's `nameparser/` ahead of the pinned wheel on `sys.path`, so the "v1" side silently imports the branch and every comparison reports parity. Run it as `cd && PYTHONSAFEPATH=1 uv run --isolated --no-project --with 'nameparser==1.4.0' python -c "..."`. This produces false confidence rather than an error, so it invalidates results without ever looking wrong. **A `tests/v2/cases.py` row's `parity` means 1.4.0, and nothing checks it** -- the table runs every row against the tree, so a row labelled `parity` that only matches master passes for good. #614 added eight such rows, compared against master; #620 found them by running the wheel this way and relabelled each with the change its reading came from, found by `git bisect run` in a scratch worktree (`git worktree add --detach`, never this one). Run the wheel before writing `parity`. **A group-side reading of a family comma's given part that asks `tail_reading` disagrees with the given part's own walk, and the disagreement is accepted.** `tail_reading` reads the part as the comma-less name reads it, plus the bare salutations rules/H8 adds after a family comma, which a predictor of this part asks for with `salutations=True` (#835); assign's walk over the given part after a family comma reads by its own predicates -- V before a third comma part is read strictly (#144), staying a name word (`Smith, John van der Berg V, Jr.` reads middle 'van der Berg V'), and P6 attaches a period-marked title-particle (`St.`) the comma-less reading takes as a title. So a bound or a decline group computes with `tail_reading` acts where the walk then reads no title: #623's chain bound (`Smith, John Mc Prof. V, Jr.` → suffix 'Mc, Jr.', middle 'Prof. V') and #637's join decline (`Smith, abdul Prof. V, Jr.` → middle 'Prof. V', `Smith, abdul St.` → family 'St. Smith'); a numeral VI to X was a third such shape until #784 read it as a suffix on both sides. Both were recorded rather than narrowed (Derek: garbage input; decisions/H5 and #P5, rules/P5's Accepted clause). Before writing another group-side predictor of the given part, sweep those two shapes and decide, rather than rediscovering the gap in review. And a change to the trailing peel ITSELF reaches the given part through those predictors without touching them: #718's words-to-spare count for a one-letter numeral, asked through `trailing_run_start`, made the chain take the V in `Smith, Freiherr von Berg V` (given 'von Berg V', unreported) while assign's walk, which never counts there (S4: "read without it"), read it as the suffix -- found by review on a grid of family-comma and empty-head shapes behind a title-particle; the predictor now passes `count_numeral=False`. When a change moves what the peel takes, put a family comma with a title-particle head in its grid. **A comparison that runs both sides in one tree reports 0 differences, which is what success looks like.** `tools/differential/compare.py` has been hardened against this — it generates its baseline worker into a temp dir, strips `PYTHONPATH` from the child, and aborts unless both the version AND the resolved path check out on each side; its README's three invocation traps are the analysis behind that design, worth reading before changing the harness. **The exposure is ad-hoc comparisons you write yourself**, where the same collapse has a cause the harness cannot disarm for you: **the shell's working directory persists between tool calls**, so a two-tree comparison written as two `cd`s silently runs both halves in whichever tree it landed in. Pin absolute paths, and assert `nameparser.__file__` on both sides the way `compare.py` does. The general rule outlives any particular trap: before believing a null result, prove the harness can report a difference — a clean run and a broken harness are the same output. The proof has to come from the input class the null result is about: #683 moved `tools/name_usage.py` to a POST and checked it with `shehu`, which is ASCII and so cannot show that the server decodes a Cyrillic body. A misdecoded query still runs and matches nothing, so the Cyrillic zeros it was meant to vouch for proved nothing until `Иван` (17,119 given) went through the same path. The converse trap is a mutation that moves nothing: it shows the code is unexercised by your grids, not that it is dead. #638 was filed calling group's chain re-ask dead on a grid with no title-particle head before a particle run and a dotted acronym; the next review found 400 moves (`Smith, Freiherr van Berg X.Y.Z.`) and the issue closed the same day. Before calling code dead, look for the input that reaches it. A CACHE of readings is the same trap one step removed: a wrong key copies one tree's readings in as another's, and the check then reports nothing. #859 first keyed `snapshot.py --local`'s kept baselines by `git rev-parse HEAD:nameparser`, which answers for the enclosing checkout, so a gitignored copy inside one (`build/lib`) took the checkout's key; two reviewers found it. Key a cached reading by what the import reads -- the files on disk, hashed again after the read -- never by what version control says should be there. And a switch that changes no file defeats any file key: #752's spike set its options by environment variable, so after the first run every option copied that run's cached local readings until the variables joined the key (2026-10-24). A SHARED file of readings is the same trap with no key at all: a local corpus's `snapshot.jsonl` and `psc-individuals`' `agreement.jsonl` are one file per corpus for every worktree, decided by whoever recorded last, and on 2026-10-25 parallel sessions of #752's phase 2 read a sibling's re-record as their own moves -- the silent case being a sibling's record from a branch holding the same change, which reads as "no moves". Use `--base ` whenever another session may be measuring: it reads the base from a baseline keyed by its package's files and never touches the shared file. A kept reading is published under its key only once it is whole and its tree unchanged: `--base`'s first draft had the agreement child write straight to the kept path, through a temporary name every session shared, so two sessions scoring one base could keep a record cut on a line boundary, which every later session would reuse. Write into a folder of the run's own, read it back against a count, take the digest again, then rename it into place. **`SUFFIX_ACRONYMS` and `SUFFIX_WORDS` are DISJOINT by construction (since 2026-10-18; an import-time assert from 2026-09-08) — a word has one suffix kind, and its reading is an acronym or a word (`acronym`), so the vocabulary build refuses a word classified twice; the fix is to choose, not to relax the check** — the two branches normalize differently (the word test strips only edge periods, the acronym test strips all of them), so a word in both is matched by two rules and which one fired is unreadable from outside. `esq` was the single overlap and left `SUFFIX_ACRONYMS`, which is what made the assert possible; the whole algebra that had made the overlap safe, and the criterion that decided which set a word belongs to, are in the `suffix-acronym-collisions` entry of `docs/design/decisions/` if anyone wants them. **`Lexicon.given_name_titles` is deliberately unvalidated against `titles` — do not add a check** — the lookup key is the space-joined run of `Role.TITLE` tokens, built by the parse — matched whole OR by that key's LAST word since #489, a run addressing the way its final title does — and a conjunction inside a run is itself tagged `Role.TITLE`, so `"sir and dame"` is a matchable key whose middle word lives in `conjunctions`. A whole-entry check rejected multi-word entries; a per-word check rejected that one. No static relation over the vocabulary sets decides reachability. An unreachable entry is inert — nothing consults it and nothing misparses — so the condition being "guarded" costs the user nothing while each guard cost a working configuration. If a diagnostic is wanted, it must be non-blocking — the multi-word UserWarning in `_normset` is the shipped example; a raise remains wrong. Note the SHIPPED data cannot carry a multi-word given-name title without a spurious warning: `GIVEN_NAME_TITLES ⊆ TITLES ∪ TITLES_AMBIGUOUS` puts the entry in `titles` or `titles_ambiguous` too, each per-word warned; user-supplied v2 Lexicons are unaffected (`add(given_name_titles=...)` alone is silent). **`_normalize` must reach a fixed point** — storage and match-time share the one fold, and `Lexicon.__setstate__` re-validates, so a value that changes on re-normalization changes under its owner. `strip().strip(".")` alone is not idempotent (`'. a .'` → `' a '` → `'a'`). The loop is the fix; keep any new stripping inside it. **Anything built on `_normalize` must converge too** — `_fold_words` runs `_normalize` per word and DROPS the words that fold away (`_title_key` is that list space-joined, and `_run_addresses_by_given` reads the list itself, so its last-word arm is the last word of the FOLDED key by construction); keeping the empty slot stored `'lt .'` as `'lt '`, a key match-time can never rebuild (so the entry is silently inert) and `__setstate__` rejects on the next round-trip as "not written by this version". **Perf regressions are caught by the scaling tests, not the absolute-time ones** — the `_thousand_names` tests use constant-size, delimiter-free input and are structurally blind to a complexity regression. A new inner loop, a policy-gated stage or a shape that needs a prefix gets a row in `tests/v2/test_benchmark.py`: its module docstring says which table, and the comment beside each table carries the measurements behind its rule. Coverage is collected on ONE interpreter, 3.14's (`COVERAGE_ON` in `.github/workflows/python-package.yml`, whose comment says why). **A JSONL file written with `json.dumps(..., ensure_ascii=False)` is cut by `str.splitlines()`** — `json.dumps` leaves U+2028, U+2029 and U+0085 unescaped, and `splitlines()` splits at all three, so one name pasted from a document holding a line separator turns its line into two halves of invalid JSON, and the file into one no reader can load. #801's review found it in the local corpora (a single official among 536,981 would have made the cache unloadable for good), and the review of that fix found it again one file over, in `snapshot.py`'s `dump`/`load`, which the fix had not touched. So it is fixed at the shared source now: `corpus_build.jsonl_line` escapes the three and `corpus_build.jsonl_lines` cuts at `"\n"` only, and `corpus_build`'s corpus and fields writers, `snapshot.py`, `cldr_agreement.py`, `local_corpora.py`, `local_psc.py`'s extracts and `local_agreement.py` go through them. A new JSONL writer or reader in tools/ goes through the same two. Still outside them: the builders that write their own lines (`build_cjk_corpus.py`, `_conventions`, `_shapes`, `_rules`, `_issues`), `compare.py`'s worker stream, the tests' corpus reads (`_differential_fixtures.py`, `test_corpora.py`), and the extracts of `local_orcid` and `local_patentsview`, streamed through `gzip.open(..., newline="\n")` rather than read whole for their 25M and 5.3M lines, which cuts at `"\n"` only as `jsonl_lines` does; none of the 16 committed JSONL files held one on 2026-10-21. **A row count does not prove a CSV whole: Python's `csv` returns a body cut inside a row as a short last row, without raising** -- even inside an open quote (`'a,b\r\n"x,y'` gives `[['a', 'b'], ['x,y']]`). So a fetch that checks its row count against the source's stated total, and tolerates a few malformed rows, passes a cut body one name short. #842's review found it in `ukec-individuals` (two reviewers independently); the fix requires the body to end with a line break and its last row not to be short (a cut only shortens a row; a long last row is left to the malformed share), with a test for each arm, since a quoted field holding a line break lets a cut end at one. A new CSV source (a `tools/differential/local_.py` module) takes the same check (`local_ukec._ukec_rows`'s last-row test), and its skip shares are tested at their shipped values, not only patched open for a small fixture. **Expected-failure tests use `@pytest.mark.xfail`** — the test classes are plain pytest classes, not `unittest.TestCase`, so `@unittest.expectedFailure` is not honored. **`lc()` strips leading and trailing periods** — `'M.D.'` → `'m.d'`, not `'md'` (interior periods are preserved). Shipped keys in `capitalization_exceptions` are dot-free, so lookups must also try `.replace('.', '')`; whatever key matches, its value is a case mask applied to the word as written, never substituted for it (#459). **`nickname_delimiters`/`maiden_delimiters` built-ins are string sentinels, not compiled patterns** — each default key (`quoted_word`, `double_quotes`, `parenthesis`, and the rest) stores its own *name* as a plain `str`, and `Constants._snapshot()` maps that key to the `(open, close)` pair the 2.0 `Policy` carries (`_SENTINEL_PAIRS` in `_config_shim.py`). Routing a built-in between buckets is still a `pop()` + assign (`maiden_delimiters['parenthesis'] = nickname_delimiters.pop('parenthesis')`) to carry the sentinel over — measured, that flips `"Jane (Jones) Smith"` from `nickname='Jones'` to `maiden='Jones'`. **2.0 note:** the two things this bullet used to justify the sentinel with are both gone, and only the v1 history above still holds. `CONSTANTS.regexes.parenthesis = ...` now raises `TypeError` (2.0 configures parsing through named `Policy` flags), so the sentinel no longer buys post-construction tracking of a `regexes` override; and a caller's *custom* key is refused by `_DelimiterManager` — its message points at `Policy(nickname_delimiters=...)` — rather than accepted as a compiled pattern. No `isinstance(raw_pattern, re.Pattern)` branch survives, and `HumanName.parse_nicknames` is gone as a method — but do NOT sweep the name itself as dead: it is still live in `_facade.py`'s `_V1_HOOKS`, where a v1 subclass that overrides it earns the once-per-subclass hook warning (#280). Don't count the default keys in prose either: this bullet said "three" until #273 added eight typographic pairs in one commit — five Western (`smart_double_quotes`, `low_high_quotes`, `right_double_quotes`, `guillemets`, `reversed_guillemets`) and three CJK — taking it to eleven, and #597 added a twelfth, `halfwidth_corner_brackets`, on 2026-10-03. (#22) **`TupleManager.__setattr__`/`__delattr__` guard dunder names too, not just `__getattr__`** — constructing a subscripted generic, e.g. `TupleManager[re.Pattern[str] | str]({...})` (needed so mypy sees the right value type instead of inferring one from the dict literal), makes `typing`'s `GenericAlias.__call__` set `__orig_class__` on the new instance right after `__init__` returns. Before this guard existed, `__setattr__` was a bare `dict.__setitem__` alias, so that assignment silently inserted a bogus `'__orig_class__'` entry into the dict itself, corrupting `.values()`/iteration for *every* `TupleManager`/`RegexTupleManager` instance, not just the one being constructed — this bit `nickname_delimiters`'s construction (#22) before the guard was added. Same fix shape as the `__getattr__` dunder guard above: fall back to `object.__setattr__`/`object.__delattr__` for dunder names, dict-backed storage for everything else. **Doctests** — docstring examples in `nameparser/*.py` run under `uv run pytest` (`--doctest-modules`; `testpaths` is `tests` + `nameparser` only). The `.rst` doctests in `docs/` (`usage.rst`, `customize.rst`) and `README.rst` are also run in CI: the `python-package.yml` workflow runs `sphinx-build -b doctest docs dist/doctest` (covers every `.rst` under `docs/`) and `python -m doctest README.rst` as separate steps after the existing `-b html` build. Both should pass with zero failures — expect a clean run, not baked-in noise. **An example needing an optional extra cannot be a doctest.** `namedivider-python` is not in the `dev` group, so it reaches only the `ja-extra` job; a `.. doctest::` requiring a segmenter passes locally, where it is incidentally installed, and fails `sphinx-build -b doctest` in all five build jobs. Use `.. code-block:: python` for those. That is why the Japanese examples in `usage.rst` are code-blocks while the Korean and Chinese honorific ones are live doctests — the latter go through VOCABULARY, which ships. Same rule for `README.rst`. Every `.. doctest::` block in one `.rst` file shares a single namespace — the per-document default group — so **both** local variables and global state carry forward, in document order. Two consequences, and the first is the one that bites. A variable bound in one block clobbers that name in every later block, so inserting a section that binds something common breaks examples further down the file that were passing and that you never touched: writing the #271 usage.rst section with `name = parse("김민준")` in it made `name.initials()` six blocks later return `'민. 김.'` instead of `'J. Q. X. V.'`. Pick a distinctive variable name (`minjun`), or reuse whatever the surrounding blocks already bind. Second, examples that mutate the shared `CONSTANTS` singleton (e.g. `capitalize_name`, `titles.clear()`) must reset it at the end of the same block, because nothing else will — an unreset mutation leaks into every later example in the same build (this caused several real failures before it was fixed). If an example isn't actually demonstrating shared-singleton behavior, prefer a local `Constants()` instance instead of `CONSTANTS` — no reset needed, and no ordering dependency on other examples. `SetManager.__repr__` sorts its elements for this reason too: plain `set()` iteration order depends on per-process string hash randomization, so an unsorted repr would make ellipsis-style doc examples (`SetManager({'10th', ..., 'zoologist'})`) unreproducible across runs. Don't use the bare `python3 -m doctest .rst` CLI (no `optionflags`) to check files under `docs/` — it ignores each block's `:options:` directive (e.g. `+NORMALIZE_WHITESPACE`, `+ELLIPSIS`) and reports false-positive whitespace failures that look like real regressions. `uv run sphinx-build -b doctest docs ` is the faithful check for those. `README.rst` has no `:options:` directives (plain `::` blocks, not `.. doctest::`), so the bare `python -m doctest README.rst` CLI is fine there — that's what CI actually runs. **A `Constants` attribute docstring is not a place for a doctest, and nothing runs one there (2.0 note; the rest of this paragraph is v1 history).** v1's `Constants` documented attributes like `patronymic_name_order` with a bare string literal after the assignment, Sphinx's attribute-docstring convention. That string never became a real `__doc__`, so `--doctest-modules` (which walks `__doc__` attributes) never saw a `.. doctest::` inside it, and a stale example once slipped through CI (`middle_name_as_last`, #133); `tests/test_config_attribute_docstrings.py` (#195) recovered those literals with `ast` and ran their examples explicitly. Both halves are gone: since the M11 swap the real `Constants` lives in `nameparser/_config_shim.py` with bare type annotations (`patronymic_name_order: bool`) and no attribute docstrings, and the test was deleted in M12 (`a689e1d8`) because its AST walk found no class in the re-export shim and nothing to run in the real one. If an attribute docstring with an example ever returns, remember that `--doctest-modules` will not see it: put the example in a unit test instead, per the lean-docs rule in the 2.0 conventions above. **`uv run mypy` covers `tests/` too** (`pyproject.toml`'s `[tool.mypy] packages = ["nameparser", "tests"]`, PR #250) — a test that deliberately passes an off-contract value to assert a `TypeError`/`ValueError`, or exercises a documented falsy-disables-the-feature toggle (e.g. `regexes.emoji = False`), needs a `# type: ignore[code]` comment on that line rather than a signature change. Before reaching for an ignore, check whether the "error" is really a source annotation that hasn't caught up with already-supported runtime behavior — widen the annotation instead in that case. **`initials_separator` is intra-group only** — it controls the joiner between consecutive initials *within* a name group (e.g. two middle names in `middle_list`). Spaces *between* groups come from `initials_format`. To fully concatenate initials you need both `initials_separator=""` and `initials_format="{first}{middle}{last}"`. **`pr/NNN` local branches** track upstream PRs — don't commit to them by accident. Check `git branch --show-current` before starting work. **Title vs suffix is positional for BARE words, and the leading period-abbreviation rule overrides even that** — a word matching `TITLES` at the front of a name becomes `title`; the same word matching `SUFFIX_ACRONYMS`/`SUFFIX_WORDS` at the end becomes `suffix` (never both, regardless of the word's real-world meaning). The `TITLES`/suffix overlap was audited in #296 (2026-08-23): the pure postnominals (`jr`, `junior`, `phd`, `do`, `se`) left `TITLES`, the v1-residue `dr`/`sra` left the suffix sets, and the ten words still in both (`ms`, `sr`, `lt`, `ra`, `vc`, and the ranks `cpl`, `cpo`, `cpt`, `csm`, `sgm`; `md` and `sa` were duals too until #853 took `md`'s title reading out, a leading `Md` being the given name Muhammad, and #857 took `sa` out of the vocabulary) are deliberate duals that position decides. External test sources (old issue gists, etc.) sometimes assert `suffix` for a leading professional abbreviation like `RA`/`PD`/`Dipl.-Ing.` — that's the source data being wrong, not a parser bug. Verify position before "fixing" it. Two qualifications the older "purely positional" wording papered over, the first measured 2026-08-01 and the second answered 2026-09-08 (#316): a PERIOD-marked leading word is claimed by the shape rule before any vocabulary is read (`"Esq. Smith"` → `title`, though `esq` is suffix-only), save a listed title and, since #789, a given name written short (`"Chas. Smith"` → `given`), and a PERIOD-marked trailing word is claimed by VOCABULARY — first the suffix sets, which the peel reads before anything else (`"John Smith Esq."` → `suffix`), then the titles (`"John Smith Prof."` → `title='Prof.'`, `rules/H5`) — while an unlisted abbreviation there stays a name part (`"John Smith Xyz."` → `family='Xyz.'`) unless it is written as two or more period-separated chunks, which since 2.4 IS a trailing shape rule (`Policy.unlisted_dotted_suffixes`, default on: `"John Smith X.Y.Z."` → `suffix='X.Y.Z.'`, read by the same words-to-spare count a bare ambiguous acronym takes, `rules/S3`), except paired initials in a script with capitals, which stay a name word (`"Ram Bahadur B.K."` → `family='B.K.'`, #864). The SINGLE trailing period is still not one, deliberately — it is the abbreviation shape any word can wear. The bare word is where "positional" still holds whole -- outside a part past the second comma, which holds no name word, so C2 reads a bare title word there as a title since #603 (`Eric H. Holder, Jr., Attorney General`), and outside a listed salutation ending the given part after a family comma, which rules/H8 reads as the title since #835 (`Doe, Jane Mrs`), the comma having marked the surname's place -- and the reason it must: `TITLES` holds words that are in no suffix set — 787 of them on 2026-10-11 (746 before #606's salutations), measured with `L = Parser().lexicon; len(L.titles - L.suffix_acronyms - L.suffix_words)`, a figure that grows with the vocabulary and never shrinks the argument — and many are ordinary surnames (`pope`, `bishop`, `judge`, `sheriff`, `baron`, `master`, ...), so a vocabulary-first rule over BARE trailing words would read `"Mary Jane Pope"` as `title='Pope'`, `family='Jane'`. The period is what separates the safe case from that one, and it separates it by being a WRITING convention rather than by making the collision go away: `"Mary Jane Pope"` keeps `family='Pope'` while `"Mary Jane Pope."` reads `title='Pope.'`, `family='Jane'`, a cost accepted under the input-is-a-name premise rather than one the rule prevents (the trailing-position bullet in decisions/vocabulary-exclusions, measured 2026-09-09). Since #316 that sentence describes the shipped rule rather than an aspiration. ### Tests (`tests/`) **Prefer behavior tests over constant-content tests** — don't assert on the literal contents/structure of config constants (e.g. `SUFFIX_ACRONYMS == SET_A | SET_B`, `'x' in SOME_SET`). Test observable parsing behavior instead (`HumanName(...)` output). Constant-content assertions just create a second place to update whenever the lists change. A parse-behavior test can still be a disguised constant-content test if it only exercises one or two hardcoded entries from a large list (e.g. guarding a specific missing-comma typo in `SUFFIX_ACRONYMS`) — it documents that one instance without catching the same bug class elsewhere in the list. Prefer a test on the structural property itself, or skip a dedicated test for narrow, unrepeatable typo fixes. **A guard that decides its own scope can stop guarding without failing.** Two shapes hit in #329, both in live code. `_POLICY_SHAPES` in `test_benchmark.py` had one entry, and pytest turns an **empty parametrize into a skip, not a failure**, so deleting it would have dissolved the quadratic guard into the skip count. And `test_facade_cases.py::_constants_for` decides which case rows it can express: its gate had been structurally unsatisfiable since the maiden-wins canonicalization landed, silently skipping seven rows, while an unenumerated `Policy` field would instead let a row run MISTRANSLATED. Neither could fail, because a skip is indistinguishable from "correctly declined". Assert the table is non-empty, give each shape a reachability probe, and tie any hand-written field enumeration to `dataclasses.fields(...)` the way `test_policy.py`'s `test_policy_patch_mirrors_policy_field_names` does. **A component that filters its own input needs a test on the filter, not only on what survives it.** The same holds for a record split by scope: a check that reads each scope's share of a record never sees an entry that falls in no scope, so test that every entry has one — #672's per-pack CLDR ratchet missed record entries for a file dropped from `LOCALE_FILES`, and its re-record carried them over as if their pack were merely unmeasured. **Deleting a test module: sort its tests by the code they exercise, not by their names.** A module named for a retired tool also holds tests of code that survives it. #664 deleted `test_ledger_guards.py` and rewrote `test_differential.py`, triaging both by test name, and review found five tests guarding live code in the discard pile: four of `build_issues_corpus.py`'s harvester, and the check that `compare.py` reports the entries it skips. For each test, grep its module-level handles (`load_tool("...")`, imports) and ask whether that code is also being deleted. **When adding a new aggregate property** (like `given_names` or `surnames`), always include a test for the empty path — a name that produces no value for that property, which must render `''`. Example: `HumanName("Williams")` for a given-names property (last-name-only string has no first or middle). **`python_classes = ["*Tests", "*TestCase"]`** in `pyproject.toml` — suffix style (`FooTests`), NOT prefix (`TestFoo`); wrong style silently skips discovery. Tests run under **pytest** (via `uv run pytest`) and are split one file per concern (`tests/test_titles.py`, `tests/test_suffixes.py`, etc.). `tests/base.py` holds `HumanNameTestBase` — a plain (non-`unittest`) base whose `m()` helper is a custom assert that prints the original name string on failure (plus thin `assert*` shims so the moved test bodies are unchanged). `tests/conftest.py` defines an autouse fixture that snapshots/restores the scalar and collection `CONSTANTS` config around each test to keep tests order-independent (it also ran every test twice over `empty_attribute_default` until 2.0 removed that setting, #255). `TEST_NAMES` (in `tests/test_variations.py`) is a list of name strings permuted into comma-separated variants as a regression check. Tests that should fail use `@pytest.mark.xfail`. When adding a parsing case, add it to the relevant `tests/test_*.py` file and consider adding the base form to `TEST_NAMES`. Test classes that parse with a dedicated flagged `Constants` (e.g. `patronymic_name_order=True`) subclass `FlaggedConstantsTestBase` (`tests/base.py`) and set `constants_kwargs = {...}` — don't hand-write per-class `setup_method`/`hn()` fixture pairs. derek73-python-nameparser-456b024/AUTHORS000066400000000000000000000000451527303271700177560ustar00rootroot00000000000000Derek Gulbranson derek73-python-nameparser-456b024/CITATION.cff000066400000000000000000000006631527303271700206060ustar00rootroot00000000000000cff-version: 1.2.0 message: "If you use this software, please cite it as below." title: python-nameparser abstract: "A simple Python module for parsing human names into their individual components." type: software authors: - family-names: Gulbranson given-names: Derek repository-code: "https://github.com/derek73/python-nameparser" url: "https://github.com/derek73/python-nameparser" license: LGPL-2.1-or-later version: "2.8.2" derek73-python-nameparser-456b024/CONTRIBUTING.md000066400000000000000000000051111527303271700211360ustar00rootroot00000000000000Contributing ============== Development Environment Setup -------------------------------- Install dev dependencies: uv sync Running Tests --------------- uv run pytest # parallel; --doctest-modules is set in pyproject.toml, so doctests run too uv run pytest -n0 -m serial # the clock-timed benchmarks, which a parallel run skips `-n0` runs the whole suite serially in one process. Run a single test file or test: uv run pytest tests/test_titles.py uv run pytest tests/test_titles.py::TitleTestCase You can also pass a name string to see how it will be parsed: $ python -m nameparser "secretary of state hillary rodham-clinton" Initials: H. R. CI runs tests against every supported Python version (see the matrix in `.github/workflows/python-package.yml`) via GitHub Actions on every push and pull request. Changing the Word Lists ------------------------- The default vocabulary (titles, suffixes, particles and the rest) lives in `nameparser/vocab/*.toml`, one file per language tag and one entry per word, listing what the word can be read as. The parser reads a module compiled from those files, so after editing one, rebuild it: uv run python tools/vocab/build.py The build checks the files and refuses, with a message, a mistake it can detect: a typo in a kind or property, an unnormalized spelling, a reading a word is recorded as refused. `tools/vocab/query.py` looks words up (`--word`, `--kind`, `--issue`, `--excluded`). Writing Tests ---------------- If you make changes, please include tests with example names that should parse correctly. It's a good idea to include tests of alternate comma placement formats of the name to ensure that the 3 code paths for the 3 formats work in the same way. Unless you add better coverage someplace else, add a few examples of your names to `TEST_NAMES`. A test attempts to try the 3 different comma variations of these names automatically and make sure things don't blow up, so it can be a helpful regression indicator. New Releases ------------ Releases are published automatically to PyPI via GitHub Actions. To cut a release, create and publish a new GitHub Release — the workflow will build and upload the package using trusted publishing (no API token or twine needed). derek73-python-nameparser-456b024/LICENSE000066400000000000000000000011751527303271700177200ustar00rootroot00000000000000Copyright Derek Gulbranson . http://derekgulbranson.com/ ----- LGPL-2.1+ http://www.opensource.org/licenses/lgpl-license.html This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. derek73-python-nameparser-456b024/MANIFEST.in000066400000000000000000000001201527303271700204360ustar00rootroot00000000000000include AUTHORS include LICENSE include README.rst recursive-include tests *.py derek73-python-nameparser-456b024/README.rst000066400000000000000000000151051527303271700204000ustar00rootroot00000000000000Name Parser =========== |Build Status| |PyPI| |PyPI version| |Documentation| |License| |Downloads| |Codecov| nameparser parses human names into seven fields — title, given, middle, family, suffix, nickname, maiden. Results are immutable, configuration is composable, and locale packs are opt-in. 📣 **nameparser 2.8 is out, the last 2.x feature release.** ⚠️ **2.8.1 adds three more DeprecationWarnings** for things 3.0 changes: configuring a ``Lexicon`` through its set fields (``Lexicon.default().add(titles={"dean"})``), reading the vocabulary constants in ``nameparser.config`` (``TITLES`` and the rest; 2.8.2 adds ``surnames``), and ``nameparser.config.regexes``. A test run that turns warnings into errors (``-W error``, or pytest's ``filterwarnings = error``) fails where your code does any of these; see the `migration guide `__. ⚠️ **HumanName is deprecated, and 3.0 removes it.** From 2.8 the 1.x compatibility layer — ``HumanName``, ``Constants`` and the shared ``CONSTANTS`` — emits a ``DeprecationWarning`` where your code constructs it, loads it from a pickle, or changes the shared ``CONSTANTS``. It keeps working through 2.x; ``parse()`` is the replacement, and `Coming from 1.x`_ below says where to start. After 2.8 the 2.x line takes fixes only, and new work goes into 3.0. 2.8 also carries on 2.7's reading of titles that are also given names: a title only inside a title phrase, so ``Queen Latifah`` keeps the given name ``Queen`` while ``Her Majesty Queen Elizabeth`` reads the title. The `release log `__ has the details. Please `open an issue `__ for anything that parses wrong. Chinese, Japanese and Korean names written in their own scripts are read family-first, unspaced Korean names are split against the census surname list, and CJK honorifics are recognized. See `East Asian names `__. Installation ------------ :: pip install nameparser Requires Python 3.11+. Quick Start Example -------------------- .. code-block:: python >>> from nameparser import parse >>> name = parse("Dr. Juan Q. Xavier de la Vega III (Doc Vega)") >>> name >>> name.family_base, name.family_particles ('Vega', 'de la') >>> name.given_names, name.surnames ('Juan Q. Xavier', 'Q. Xavier de la Vega') >>> name.render("{family}, {given}") 'de la Vega, Juan' >>> parse("Jack MA").suffix # capitals mark a credential 'MA' >>> parse("Jack Ma").family # and a surname 'Ma' >>> parse("Jane Smith, PhD MA").suffix 'PhD MA' >>> parse("김민준").family # Korean: unspaced, split on the census list '김' >>> parse("高橋 みなみ").family # Japanese: kanji with kana, family first '高橋' >>> parse("김민준씨").suffix # an honorific written against the name '씨' >>> parse("г-н Иван Петров").title # Cyrillic title 'г-н' >>> parse("محمد بن سلمان").family # Arabic: بن chains onto the family name 'بن سلمان' >>> from nameparser import locales, parser_for >>> chinese = parser_for(locales.ZH) # Han text does not say which language >>> chinese.parse("毛泽东").family # so splitting it is opt-in '毛' >>> russian = parser_for(locales.RU) >>> russian.parse("Сидоров Иван Петрович").family 'Сидоров' >>> locales.available() ('ja', 'ru', 'tr_az', 'zh') Learn more ---------- * `Using the parser `__ — the full tour: input shapes, aggregates, rendering, comparison, ambiguities, tokens * `Customizing the parser `__ — vocabulary, behavior, and presentation * `Locale packs `__ — opt-in bundles for East Slavic patronymics, Turkic markers, and more * There's also a CLI: ``python -m nameparser --json "Doe, John"`` Coming from 1.x ---------------- ``HumanName`` and ``CONSTANTS`` keep working through 2.x — same imports, same attributes, same mutation API — and warn from 2.8 on, since 3.0 removes them. Run your tests under ``python -W error::DeprecationWarning`` to find the sites; a read of ``CONSTANTS`` does not warn. What 2.0 removes is the batch of deprecations 1.3 and 1.4 announced, so if your test suite runs clean on 1.4 under ``python -W error::DeprecationWarning``, you are nearly done. Two things that check will not catch: four removals 1.4 never warned about (three raise on contact, the fourth only warns), and one that changes results silently — ``name == "John Smith"`` is now ``False``. Later 2.x releases add a few breaks of their own. `Migrating from HumanName `__ covers all of them, and translates a v1 customization into the new API whenever that's convenient for you. See the `release log `__ for the full list of changes in the 2.0 series. License ------- LGPL licensed. See `LICENSE `__ for details. .. |Build Status| image:: https://github.com/derek73/python-nameparser/actions/workflows/python-package.yml/badge.svg :target: https://github.com/derek73/python-nameparser/actions/workflows/python-package.yml .. |PyPI| image:: https://img.shields.io/pypi/v/nameparser.svg :target: https://pypi.org/project/nameparser/ .. |Documentation| image:: https://readthedocs.org/projects/nameparser/badge/?version=latest :target: http://nameparser.readthedocs.io/en/latest/?badge=latest .. |PyPI version| image:: https://img.shields.io/pypi/pyversions/nameparser.svg :target: https://pypi.org/project/nameparser/ .. |License| image:: https://img.shields.io/pypi/l/nameparser.svg :target: https://pypi.org/project/nameparser/ .. |Downloads| image:: https://static.pepy.tech/badge/nameparser :target: https://pepy.tech/project/nameparser .. |Codecov| image:: https://codecov.io/gh/derek73/python-nameparser/branch/master/graph/badge.svg :target: https://codecov.io/gh/derek73/python-nameparser derek73-python-nameparser-456b024/codecov.yml000066400000000000000000000001401527303271700210470ustar00rootroot00000000000000# Codecov still reports its commit statuses; this only drops the per-PR comment. comment: false derek73-python-nameparser-456b024/docs/000077500000000000000000000000001527303271700176375ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/AGENTS.md000066400000000000000000000213021527303271700211400ustar00rootroot00000000000000# Writing the user docs (docs/*.rst) This file covers the Sphinx user documentation: `usage.rst`, `customize.rst`, `locales.rst`, `migrate.rst`, `concepts.rst` and the rest of `docs/*.rst`. `docs/design/` is not user documentation and has its own `docs/design/AGENTS.md`; the mechanics of doctests (shared namespace, optional extras, which doctest runner to trust) are in the root `AGENTS.md` under Gotchas → Doctests, and are not repeated here. The rules below were distilled from PR #588, which restructured `customize.rst` after three of its Policy table rows had grown to 24–38 source lines each (measured on master, 2026-10-02), and from the review rounds on that PR. Two rules carry lessons from earlier docs work, cited where they appear. Every rule names the failure it prevents, because each of those failures happened. ## Structure **A table is an index.** A row states what the field does and its default, in a sentence or two, and links to a section with `:ref:`. Detail, boundary cases and examples go in the section. A description cell past about five lines is the signal that the row has stopped being an index. Rows grow by accretion — each fix adds its boundary case where the field is already described — so when you have a new case to document, add it to the section, never to the row. `customize.rst`'s Policy table is the model: every row whose field has a section links to it, and a field that needs no more than its row (`middle_as_family`) has no section at all. A row that has a section and does not link to it is a dead end — #588's first pass left five. **One topic per paragraph; subheadings for a section with several.** A paragraph that answers more than one question gets split, and a section holding more than one task gets subheadings named for what the reader came to do ("Routing a pair to maiden names", "Teaching the splitter a surname"), not for the mechanism. Keep an existing section title when you add subheadings under it: other pages link to titles (`` `Words that are also ordinary names`_ ``), and renaming breaks them. **List shape follows content shape.** Rules applied in order — where a later rule is reached only when an earlier one declined — are a numbered list, and prose that says "in steps" or "first … then" is a numbered list waiting to happen. Parallel options (three settings, three choices with their costs) are a bulleted list with a bold lead per item. Running prose is for an argument, not for an enumeration. **Put background before behavior.** A section on a domain the reader may not know (East Asian naming, patronymics, post-nominals) opens with how that domain works — enough to make the rules derivable — then says what the library does automatically, then where automation stops and what the user does about it. Name the mechanism plainly ("assigned family-first", "split off the end of the token"), never gesture at it ("comes out right", "goes a step further"). **Place a note beside what it explains.** Adding subheadings moves the boundaries a block appears to belong to: a `.. note::` that closed a section reads as the last subsection's own once that section has subheadings. Move it next to the example it explains. **Heading levels on a page run `=`, `-`, `~`, `^`, `"`.** Sphinx assigns levels by the order each underline character first appears in the file, so use them in that order. Five levels is the useful limit — the fifth renders as `
` and only works for short headings; past that, promote the parent section instead of nesting further. ## Examples **A recipe is a doctest; an edge case is a unit test.** A *recipe* — how to do the thing the reader came to do — belongs in the docs as a `.. doctest::` block, which teaches and is CI-checked at once. The line between the two is whether the prose states the behavior: a claim the section makes to the reader (`edd` repairs to `EDD`, not a mask) earns a doctest beside it, which is what keeps the claim true; an edge case the prose has no reason to mention earns one sentence at most and a unit test, never a doctest (PR #216). An example sitting as prose inside a table cell is the worst of both: it teaches from the wrong place and nothing checks it. **An example must be non-vacuous.** Show the baseline before the customization, and check that they differ: an `add(titles={"chancellor"})` example once shipped (fixed in 0ab19fdb) where `chancellor` was already a default title, so the add was a no-op and the doctest passed demonstrating nothing. Prefer a word absent from the defaults for a teachable reason (`dean`, which is also a given name). **An example must run warning-free.** Readers paste examples, so a warning the doctest build prints is one every reader gets. Fix the example, not the build: a `Lexicon.empty()` used only to drop titles also dropped the Korean surnames and drew the segmenterless warning, and the fix was an example that empties only the field it is about. Run the doctest build under Verifying below and expect no output at all. **Spell examples so they type-check.** nameparser ships `py.typed`, so readers' examples run under mypy: write `()` and `frozenset()` rather than `{}` and bare set literals for `Policy` fields (the root `AGENTS.md`'s validation bullet covers the same rule for messages). **Give doctest variables distinctive names** (`cred`, `caps_off`, `name_jr`), never `name`, `lex` or `policy` in a new block unless you mean to reuse the binding above — all blocks on a page share one namespace (root `AGENTS.md`, Gotchas → Doctests). ## Claims **Measure every claim before you move it.** Restructuring is rewriting, and rewriting a claim is making one. Run each example and each stated behavior against the tree before the prose moves, not after. Combining sentences is where claims break: two rows each true of their own field become one intro that sounds true of both. #588's first draft did this twice — "the fork is reported either way" sounded true of both fields and was false of all-caps words (`Smith, XYZ` reports nothing), and a comparison to the listed acronym `MA` was wrong (`Jack MA` is a suffix, `Jack X.Y.Z.` is not). It was not even true of the dotted field it came from: a review found `John Smith, X.Y.Z.` takes the credential silently. Narrowing is the usual repair ("whatever pair encloses it" became "any configured pair": square brackets are not a default pair). **Splitting breaks claims too.** Moving a sentence under one subheading makes it read as that subsection's alone. The rule for when the two-letter initials exception gives way landed under "Dotted acronyms" and stopped covering capitals, which it does (`John Smith, PhD MJ`). **Check that nothing was dropped.** After a restructuring, list the distinct facts in each old passage (`git show master:docs/`) and find each one in the new text — moved, reworded with the same meaning, or deliberately cut and said so in the commit. #588's review found version notes ("since 2.4") and two examples lost in the move. **Check a claim end to end, through the call the reader makes.** "One `phd` key covers `Phd`" was true of the key match and false of `capitalized()`'s output, which leaves mixed case alone unless `force=True`. Verify against what the reader would observe, not an intermediate step. **A claim about code needs the code.** "An empty `honorific_tails` stops the peel at its first guard" is checked by reading the guard, not by parsing a name. Find it through the stage headers: each stage module (the eight in `nameparser/_pipeline/__init__.py`'s `STAGES`) declares the `Policy`/`Lexicon` fields it reads on a `Reads:` line. The shared helpers `_vocab.py` and `_pieces.py` have no such line and read the lexicon too, so grep them as well. ## Linking **Link, don't restate.** When another page already covers a topic, give one line and a `:ref:` to it; two copies drift apart. When the content is a list the code ships (the maiden markers, the delimiter pairs), link the module with `:mod:` instead of copying the list into prose, where it goes stale the next time the list changes. **Put a label at the heading you link to**, and link with `:ref:` to the section rather than `:doc:` to the page, which lands the reader at the top of a long page. Add the label in the same commit as the first link to it. ## Verifying Before committing a docs change, run both builds and read their output — not just their exit codes: ```bash uv run sphinx-build -q -b doctest docs /tmp/doctest uv run sphinx-build -q -b html docs /tmp/html ``` Both should print nothing. The HTML build prints an unresolved `:ref:` or an unknown title link (`` `Some heading`_ ``) as a WARNING and still exits 0, and CI's HTML step has no `-W`, so CI will not catch it either — reading the output is the only check. A doctest that emits a warning likewise prints and passes. derek73-python-nameparser-456b024/docs/Makefile000066400000000000000000000151721527303271700213050ustar00rootroot00000000000000# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = _build # User-friendly check for sphinx-build ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) endif # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" @echo " text to make text files" @echo " man to make manual pages" @echo " texinfo to make Texinfo files" @echo " info to make Texinfo files and run them through makeinfo" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" @echo " xml to make Docutils-native XML files" @echo " pseudoxml to make pseudoxml-XML files for display purposes" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: rm -rf $(BUILDDIR)/* html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Nameparser.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Nameparser.qhc" devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/Nameparser" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Nameparser" @echo "# devhelp" epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." latexpdfja: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through platex and dvipdfmx..." $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." texinfo: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ "(use \`make info' here to do that automatically)." info: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." gettext: $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." xml: $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml @echo @echo "Build finished. The XML files are in $(BUILDDIR)/xml." pseudoxml: $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml @echo @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." derek73-python-nameparser-456b024/docs/concepts.rst000066400000000000000000000317231527303271700222150ustar00rootroot00000000000000How the parser works ==================== ``parse()`` turns a name string into a :class:`~nameparser.ParsedName`. This page explains the model behind that call: how a string becomes tokens and tokens become fields, how those tokens get their roles, where configuration lives and why it is split the way it is, why parsers are plain values, and what happens when a name is genuinely ambiguous. The task pages all build on these five ideas. From string to name -------------------- Every parse follows the same path: the input string is split into :class:`tokens `, each token is assigned one of the seven roles — ``title``, ``given``, ``middle``, ``family``, ``suffix``, ``nickname``, ``maiden`` — and every string you read off the result is computed from those tokens at read time. Parsing ``"Dr. Juan Q. Xavier de la Vega III"`` produces eight tokens. The first is ``Dr.`` with the ``title`` role; ``de``, ``la``, and ``Vega`` each carry the ``family`` role, which is why ``name.family`` returns ``"de la Vega"`` — the field is a view that joins the family-role tokens in order, not a stored string. Each token also records where it came from. A :class:`~nameparser.Span` is a pair of character positions bounding the token in the original string: ``Dr.`` has span ``(0, 3)``, and ``name.original[0:3]`` is exactly ``"Dr."``. Internally, spans let the pipeline refer to a token by position instead of by text. v1 re-found name pieces by searching for matching text, so a name with a repeated word could make the parser rewrite the wrong occurrence (`issue #100 `_ and its relatives); a position cannot be confused with a look-alike. :class:`Tokens ` also carry tags — a second, independent label alongside their role, recording how a token was classified rather than what part of the name it belongs to — but only a handful of them are part of the stable API, collected in :data:`~nameparser.STABLE_TAGS`: ``particle``, ``conjunction``, ``initial``, and ``joined``. Any tag written with a namespace prefix, like ``vocab:...``, is provenance information for debugging how a token got classified — it can change shape between releases and isn't something to match against in your own code. If you need to branch on how a token was classified, branch on role or on one of the stable tags above, not on a namespaced one. :class:`~nameparser.ParsedName` is frozen: there is no attribute assignment, ever. If a parse is almost right and you want to fix one field, you call ``.replace()``, which returns a new :class:`~nameparser.ParsedName` with that field changed and everything else — tokens, spans, the rest of the roles — carried over unchanged (``replace()`` tokens carry no vocabulary classification; :meth:`Parser.revise ` classifies the new value the way a parse of that value on its own would). ``str()`` renders the default view; nothing about calling it mutates the value you called it on. How roles are decided --------------------- Roles are not assigned in one pass, and two layers do most of the work. A vocabulary layer runs first and claims words for what they *are*, wherever they sit: titles, particles, conjunctions, recognized suffixes, and anything set off by nickname or maiden delimiters. Titles chain, so ``"Asst. Vice Chancellor"`` is one title, and a title that is also a given name, such as ``Dean``, is claimed only inside such a phrase (``"Dean of Students"``); particles join forward, so ``de la`` attaches to ``Vega``. Whatever the vocabulary layer has not claimed is left to a positional layer, which assigns purely by where a word sits: the first unclaimed word is the given name, the last is the family name, and anything between them is the middle name. ``name_order``, an explicit comma and ``script_orders`` change what "first" and "last" mean here, as does the opt-in ``two_family_names``, which takes the last two name words as the family (:ref:`two-family-names`). ``script_orders`` covers the East Asian writing that settles its own order: a name wholly in Han or hangul, or a Japanese name in kanji and kana — any of them, hiragana alone included — unless it is katakana alone, which is how a foreign name is transcribed. The Chinese interpunct ``·`` dividing such a name walks that back, marking a transcription that follows ``name_order`` like any other name. Three rules can still override the arrangement: a lone name word beside a given-name title such as ``Sir`` is the given name under every order, a particle that can never be a given name, opening the name, opens the surname under every order rather than becoming the given name (both in :ref:`order-vocabulary-first`), and the opt-in ``patronymic_rules`` reorder, under the default order, a name whose patronymic marks its parts. A few words are read by how they are written, which is neither a list lookup nor position alone: the writing proposes a reading, and where the word stands decides whether it lands. An unlisted word of two or more letters with a single period at its end, at the front of the given-name part, is a title though no list holds it (``"Insp. Jane Morse"``; see :ref:`abbreviated-titles`). Since 2.4, an unlisted acronym written in periods (``X.Y.Z.``) is a credential behind a name that can spare it, and one written in capitals (``XYZ``) is, by default, right after a comma behind a full name that writes a word of its own in mixed case, as ``Smith`` is (:ref:`unlisted-credentials`). That is the whole parser in outline, and it explains its character. A word nameparser has never seen still gets a sensible role, because the positional layer does not need to recognize anything. The same word can play different parts in different places — ``Lt.`` is a title before a name and a post-nominal after it, which is why the field names ``title`` and ``suffix`` are really "pre-nominal" and "post-nominal". And nothing is statistical: there is no model and no training data, so the same input always parses the same way, and a parse that is wrong is wrong reproducibly, which is what makes it fixable by configuration. The split also tells you which container a setting belongs in, before you look anything up: if you are teaching the parser a *word*, it goes in the :class:`~nameparser.Lexicon`; if you are changing how unclaimed words are *arranged*, or choosing where the unlisted-credential readings apply, it goes in the :class:`~nameparser.Policy`. .. _config-containers: Configuration lives in three containers ---------------------------------------- Every piece of nameparser configuration falls into exactly one of three places, and which one is decided by a single question: what does this setting vary with? * :class:`~nameparser.Lexicon` holds everything that varies by **language**: the vocabulary — titles, particles, suffixes, conjunctions, and the rest of the word lists the parser matches against. * :class:`~nameparser.Policy` holds everything that varies by **data source or application**: the behavior switches — name order, patronymic rules, delimiters, strip flags — anything that changes how the pipeline runs, not what words it recognizes. * :ref:`Rendering arguments ` cover everything that varies by **output destination**: the ``spec`` you pass to ``render(spec)``, or a keyword to ``initials()``/``capitalized()``. "Dean" is a common given name, so the default reads it as a title only inside a title phrase ("Dean of Students"), and "Dean Martin" keeps given "Dean". But in some data it is more common as a title. The right reading is a fact about the domain the names come from: that makes it a :class:`~nameparser.Lexicon` entry. A CRM that wraps nicknames in square brackets — ``John [Johnny] Smith`` — instead of quotes is a fact about that one data source's export format, not about the language of the names in it — that's a :class:`~nameparser.Policy` (``nickname_delimiters=frozenset({('[', ']')})``). One particular report wanting names formatted as "Family, Given" while every other consumer of the same parsed data wants "Given Family" is a fact about where the string is going next, decided at the moment you render it — that's a rendering argument, not something baked into how the name was parsed. This replaces v1's single ``Constants`` object, which mixed all three concerns — vocabulary, behavior, and output formatting — into one mutable bag plus a ``string_format`` template string. Sorting a setting into the right container is largely mechanical once you ask the "varies by what?" question above; see :doc:`migrate` for the attribute-by-attribute mapping from the old ``Constants`` to the new containers. Parsers are values -------------------- ``parse()`` is a convenience function over a module-level default :class:`~nameparser.Parser`. You only need to build your own :class:`~nameparser.Parser` (directly, or via :func:`~nameparser.parser_for`) when you want non-default vocabulary or behavior — and when you do, build it once and reuse it. Constructing a :class:`~nameparser.Parser` validates its configuration up front, so it's cheap but not free; parsing individual names is the hot path, and a :class:`~nameparser.Parser` is designed to be called many times without reconstruction. :class:`~nameparser.Lexicon`, :class:`~nameparser.Policy`, :class:`~nameparser.Parser`, and :class:`~nameparser.ParsedName` are all frozen and hashable. That means they're safe to share across threads without locking, safe to use as dict keys or cache keys, and equality means exactly what it says — two :class:`~nameparser.Parser` instances built from equal configuration are equal values, not merely two objects that happen to behave the same. Every piece of configuration in the 2.0 API is a frozen value — including the module-level default parser itself. Honest ambiguity ------------------ Parsing never raises. Pass in a string that doesn't look like a name at all, and you get back a :class:`~nameparser.ParsedName` with empty fields, not an exception. The parser's job is to make a reasonable call on real-world text, not to reject it. The single exception is code you supplied yourself: a :data:`~nameparser.Segmenter` passed to ``Parser(segmenter=...)`` runs inside the parse, and its own exceptions propagate rather than being swallowed — a failure there is a bug in your callable, not a fact about the name. Some calls are irreducibly ambiguous — both readings are legitimate, and no amount of rule-tuning resolves them without breaking some other name. Those surface as entries on ``ParsedName.ambiguities`` instead of being silently guessed away. The canonical example: a leading "Van" reads as a name of its own rather than as the start of a surname — the right call for the actor Van Johnson, the wrong one for a bare "Van Buren", and nothing in the two-word shape distinguishes them — so the parse records a ``particle-or-given`` ambiguity alongside its answer. (Which name that piece then becomes is a separate question, and ``name_order``'s: the given name under the default.) You can inspect ``ambiguities`` to decide, case by case, whether your data needs a second look; each report also names the reading it did not take (:ref:`reading-not-taken`). An ambiguity records a *decision*, not a word. The same token in a different position may present no fork at all: ``do`` is in the ambiguous post-nominal vocabulary, but in ``"Joao da Silva do Amaral de Souza"`` it sits mid-name, where nothing has to choose between readings — so nothing is recorded. A comma can settle the question before it arises, too: ``"Ma, Jack"`` fixes the family name, so the credential reading never comes up, and so can the writing: ``"John Smith M.A."`` and, in a mixed-case name, ``"John Smith MA"`` are written as credentials, while ``"John Smith Ma"`` has to call it and says so. Some decisions are conventions rather than readings — a name whose one name word nothing else decides (such as a title, a family comma, a nickname or maiden name beside it, a script that settles its own order, or the word's own vocabulary or shape) has nothing to compare, and an input the title peel eats down to one last title word reads that word as the name for want of anything else — and since 2.3 those are reported too, so a field the library merely had to pick is a field you can see it picked. A title standing entirely alone reports nothing: ``"Dr."``, ``"Sir"`` and ``"Prince of Wales"`` are all title and no name, so there is no reading to have doubted. An empty ``ambiguities`` is therefore not a certificate of certainty. Reporting is deliberately partial: :class:`~nameparser.AmbiguityKind` lists the forks worth flagging, and even those are not reported everywhere they occur. What a comma buys is narrower than it looks — it names which words are the surname, and stays quiet about that much, but it leaves the rest of the name to be read as usual, so ``"Beethoven, Ludwig van"`` still reports the trailing ``van`` it had to call. Coverage grows over releases. Treat a non-empty ``ambiguities`` as a signal to act on; do not read an empty one as a guarantee. derek73-python-nameparser-456b024/docs/conf.py000066400000000000000000000221271527303271700211420ustar00rootroot00000000000000# ruff: noqa: E402 # # Nameparser documentation build configuration file, created by # sphinx-quickstart on Fri May 16 01:29:58 2014. # # This file is execfile()d with the current directory set to its # containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import sys import os from datetime import date # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. sys.path.insert(0, os.path.abspath('..')) import nameparser # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. #needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.doctest', 'sphinx.ext.viewcode', 'sphinx.ext.extlinks', ] # :issue:`575` renders as a "#575" link. GitHub redirects /issues/N to # /pull/N when N is a PR, so the one role serves both. extlinks = { 'issue': ('https://github.com/derek73/python-nameparser/issues/%s', '#%s'), } # Declaration order is canonical in this codebase (Role order drives the # seven-field order everywhere), so the reference lists members in # source order, not alphabetically -- Lexicon's vocabulary grouping and # Policy's field ordering match the customize.rst table. autodoc_member_order = 'bysource' # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # The suffix of source filenames. source_suffix = '.rst' # The encoding of source files. #source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # General information about the project. project = 'Nameparser' copyright = f'{date.today():%Y}, Derek Gulbranson' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = nameparser.__version__ # The full version, including alpha/beta/rc tags. release = version # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. #language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: #today = '' # Else, today_fmt is used as the format for a strftime call. #today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = ['_build'] # The reST default role (used for this markup: `text`) to use for all # documents. #default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. #add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). #add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. #show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. #modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. #keep_warnings = False # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = 'furo' html_theme_options = { 'source_repository': 'https://github.com/derek73/python-nameparser/', 'source_branch': 'master', 'source_directory': 'docs/', } # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. #html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. #html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". #html_title = None # A shorter title for the navigation bar. Default is the same as html_title. #html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. #html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. #html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". # (empty: docs/_static does not exist; a non-existent entry warns on # every build and blocks -W) html_static_path = [] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. #html_extra_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. #html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. #html_use_smartypants = True # Custom sidebar templates, maps document names to template names. #html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. #html_additional_pages = {} # If false, no module index is generated. #html_domain_indices = True # If false, no index is generated. #html_use_index = True # If true, the index is split into individual pages for each letter. #html_split_index = False # If true, links to the reST sources are added to the pages. #html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. #html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. #html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. #html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). #html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = 'Nameparserdoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). #'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). #'pointsize': '10pt', # Additional stuff for the LaTeX preamble. #'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ ('index', 'Nameparser.tex', 'Nameparser Documentation', 'Derek Gulbranson', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. #latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. #latex_use_parts = False # If true, show page references after internal links. #latex_show_pagerefs = False # If true, show URL addresses after external links. #latex_show_urls = False # Documents to append as an appendix to all manuals. #latex_appendices = [] # If false, no module index is generated. #latex_domain_indices = True # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ ('index', 'nameparser', 'Nameparser Documentation', ['Derek Gulbranson'], 1) ] # If true, show URL addresses after external links. #man_show_urls = False # -- Options for Texinfo output ------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ('index', 'Nameparser', 'Nameparser Documentation', 'Derek Gulbranson', 'Nameparser', 'A simple python modules for parsing human names into components.', 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. #texinfo_appendices = [] # If false, no module index is generated. #texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. #texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. #texinfo_no_detailmenu = False doctest_global_setup = """from nameparser import HumanName from nameparser.config import CONSTANTS, Constants CONSTANTS = Constants() """ derek73-python-nameparser-456b024/docs/contributing.rst000066400000000000000000000004411527303271700230770ustar00rootroot00000000000000Contributing ============ The project is hosted on GitHub: https://github.com/derek73/python-nameparser Find more information about running tests and contributing the project at the projects contribution guide. https://github.com/derek73/python-nameparser/blob/master/CONTRIBUTING.md derek73-python-nameparser-456b024/docs/customize.rst000066400000000000000000001776621527303271700224360ustar00rootroot00000000000000Customizing the parser ======================= Every piece of nameparser configuration sorts into one of three places by asking what it varies with: vocabulary varies by **language** (:class:`~nameparser.Lexicon`), behavior varies by **data source or application** (:class:`~nameparser.Policy`), and presentation varies by **output destination** (a rendering argument). See :doc:`concepts` for why the split is drawn there. Vocabulary: Lexicon -------------------- .. note:: Every recipe in this section that names a ``Lexicon`` set field (``add(titles=...)``, ``Lexicon(...)``, ``dataclasses.replace``) or reads a ``nameparser.config`` constant emits a ``DeprecationWarning`` since 2.8.1: 3.0 configures vocabulary by reading instead. They still work throughout 2.x. See :ref:`deprecated-2-8-1` for what changes. Adding and removing words ~~~~~~~~~~~~~~~~~~~~~~~~~~ .. doctest:: >>> from nameparser import Lexicon, Parser, parse >>> parse("Dean Robert Johns").given 'Dean' >>> lex = Lexicon.default().add(titles={"dean"}) >>> Parser(lexicon=lex).parse("Dean Robert Johns").title 'Dean' By default ``Dean`` is a title only inside a title phrase such as ``Dean of Students`` (:ref:`titles-also-given-names`); listing it in ``titles`` makes it a title wherever it stands. :meth:`~nameparser.Lexicon.add` and :meth:`~nameparser.Lexicon.remove` both return a new :class:`~nameparser.Lexicon` — the one you started from (here, ``Lexicon.default()``) is never mutated. Every field accepts a plain set of lowercase words, keyword by field name (``titles`` above; ``particles``, ``suffix_words``, and the rest work the same way) — see :doc:`modules` for the full field list. The default word lists themselves — ``TITLES``, ``PARTICLES`` and the other frozensets in ``nameparser.config`` — are frozen, so a runtime addition belongs on a :class:`~nameparser.Lexicon` as above, or on a private ``Constants`` if you are still parsing through ``HumanName`` (``Constants`` and ``HumanName`` are deprecated since 2.8, and removed in 3.0). ``REGEXES`` and ``CAPITALIZATION_EXCEPTIONS`` are the two members the freeze does not cover — they are still plain dicts. Editing one at runtime is not a supported override, and it is not a clean no-op either: the edit reaches a freshly built ``Constants``, while the shared ``CONSTANTS`` (copied at import) and the cached :meth:`~nameparser.Lexicon.default` never see it. That is the same inconsistent reach the freeze removed for the word lists, so these overrides belong on a config object too. Five of these lists were renamed in 2.2 to match the field names used here: ``PREFIXES``, ``NON_FIRST_NAME_PREFIXES``, ``BOUND_FIRST_NAMES``, ``FIRST_NAME_TITLES`` and ``SUFFIX_NOT_ACRONYMS`` became ``PARTICLES``, ``NON_GIVEN_NAME_PARTICLES``, ``BOUND_GIVEN_NAMES``, ``GIVEN_NAME_TITLES`` and ``SUFFIX_WORDS``, and two modules moved with them. Names outside that list, ``TITLES`` among them, are unchanged. Every 1.x name still imports, with a ``DeprecationWarning``, until 3.0 — see :doc:`migrate` for the full mapping. Vocabulary entries are matched one word at a time, with two exceptions, so a multi-word entry like ``titles={"grand moff"}`` can never match; the constructor warns when it sees one (``capitalization_exceptions`` keys included — they are looked up per word too). The exceptions are ``given_name_titles``, looked up as the space-joined run of words already read as titles or as that run's last word — several titles written together are one form of address and the last one does the addressing, so ``"Her Majesty Queen Elizabeth"`` is read by ``queen`` — and ``maiden_markers``, matched by lookahead over the words as written: ``maiden_markers={"z domu"}`` matches the pair and neither word alone, which is how the shipped Polish entry works. The words have to stand together — a bracketed clause or a comma between them ends the run, and the first word is then an ordinary name word. Where a phrase entry and a word entry starting with it are both configured, the phrase wins where it matches and the word matches everywhere else. No warning is raised for a multi-word entry in either of these two fields, since there it is not a mistake. The limit is on *storage*, not on the shape a name can have. Adjacent suffix words are reassembled after they match, so a multi-word credential is reachable as its component words even though the phrase itself cannot be stored: .. doctest:: >>> from nameparser import parse >>> parse("John Smith, MD PhD").suffix 'MD PhD' That has held since 1.4.0. A credential whose words are not in the default vocabulary is reached by adding those words, not the phrase. After a comma the defaults already read an unlisted all-caps run (see :ref:`all-caps-credentials`), so the difference shows at the end of a name: .. doctest:: >>> parse("John Smith LEED AP").suffix '' >>> lex = Lexicon.default().add(suffix_acronyms={"leed", "ap"}) >>> Parser(lexicon=lex).parse("John Smith LEED AP").suffix 'LEED AP' Removing works the same way, and drops the word from recognition: .. doctest:: >>> lean = Lexicon.default().remove(titles={"professor"}) >>> Parser(lexicon=lean).parse("Professor Robert Johns").title '' Marking a subset of another field ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Several fields mark a subset of another, qualifying how the base field's words are read. Their entries belong in the base field too, so add a word to both, and remove it from the marker first. An entry outside its base is an *orphan*: four markers refuse one with a ``ValueError`` naming it, because there it would change the parse; the rest leave it unchecked, because there it is never read. .. list-table:: :header-rows: 1 :widths: 30 34 36 * - Marker - Base - An orphan * - ``particles_ambiguous`` - ``particles`` - raises ``ValueError`` * - ``suffix_acronyms_ambiguous`` - ``suffix_acronyms`` - raises ``ValueError`` * - ``honorific_tails`` - ``suffix_words`` - raises ``ValueError`` * - ``suffix_words_ambiguous`` - ``suffix_words`` - raises ``ValueError`` * - ``agnomens`` - ``suffix_words_ambiguous`` - is never read * - ``conjunctions_ambiguous`` - ``conjunctions`` - is never read * - ``after_given_titles`` - ``titles`` - is never read * - ``given_name_titles``, ``title_modifiers``, ``title_styles`` - ``titles`` or ``titles_ambiguous`` - is never read So ``remove(conjunctions={"e"})`` simply works, and the stale ``conjunctions_ambiguous`` entry is never consulted. ``given_name_titles`` is unchecked for a second reason: it matches a run of titles as one space-joined string, or by the run's last word, so a legitimate entry like ``"sir and dame"`` is no single word of ``titles``. ``titles_ambiguous`` is no subset of ``titles`` at all; see :ref:`titles-also-given-names`. .. _title-detection-off: Turning title detection off ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The subset rule matters most when clearing a field wholesale. Emptying ``titles`` alone orphans every ``given_name_titles`` entry, so the two go together, with ``titles_ambiguous``, whose words are titles inside a phrase ("Dean of Students") without being in ``titles``: .. doctest:: >>> d = Lexicon.default() >>> lean = d.remove(titles=set(d.titles), ... given_name_titles=set(d.given_name_titles), ... titles_ambiguous=set(d.titles_ambiguous)) >>> Parser(lexicon=lean).parse("Dean of Students Jane Smith").title '' >>> Parser(lexicon=lean).parse("Hon Solo").given 'Hon' Emptying the title vocabulary does not switch titles off entirely, though. A word ending in a period, standing at the front of the part that carries the given name, is read as a title structurally, without consulting ``titles`` at all — that is what lets unfamiliar ranks and abbreviations work (see :ref:`abbreviated-titles`): .. doctest:: >>> bare = Parser(lexicon=lean) >>> bare.parse("Professor John Smith").title # vocabulary gone '' >>> bare.parse("Dr. John Smith").title # structural, stays 'Dr.' Combining two lexicons ~~~~~~~~~~~~~~~~~~~~~~~ Whole lexicons compose with ``|``, which unions field by field — handy for keeping a shared house vocabulary separate from a per-source one and combining them at parser construction: .. doctest:: >>> house = Lexicon.empty().add(titles={"dean"}) >>> per_source = Lexicon.empty().add(titles={"provost"}) >>> sorted((house | per_source).titles) ['dean', 'provost'] .. _case-exceptions: Fixing the case of a particular word ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ``capitalization_exceptions`` is the one pair-valued field — each entry maps a lowercase key to a *case mask*: the key's own letters and digits, each in the case it should take (``"phd"`` → ``"PhD"``). Case repair lays the mask over the word as it was written and keeps every other character where it stood, so it recases a word and never re-spells it. A value that spells anything else raises ``ValueError`` when the lexicon is built. Being pair-valued, the field isn't a fit for ``add()``/``remove()``. Change it with ``dataclasses.replace()`` instead, and pass the result to ``capitalized()``: .. doctest:: >>> import dataclasses >>> from nameparser import parse >>> str(parse("jane smith dclinpsy").capitalized()) 'Jane Smith Dclinpsy' >>> default = Lexicon.default() >>> lex = dataclasses.replace( ... default, ... capitalization_exceptions=tuple(default.capitalization_exceptions) ... + (("dclinpsy", "DClinPsy"),)) >>> str(parse("jane smith dclinpsy").capitalized(lex)) 'Jane Smith DClinPsy' >>> str(parse("JANE SMITH D.CLINPSY.").capitalized(lex)) 'Jane Smith D.ClinPsy.' Note the ``tuple(...) + ...``: assigning a bare ``(("dclinpsy", "DClinPsy"),)`` would *replace* the default exceptions rather than extend them, so the shipped masks (``phd``, ``bsc``, ``psyd`` and the rest) would be lost and those words fall back to the all-capitals acronym repair: ``john smith phd`` would give ``John Smith PHD`` rather than ``John Smith PhD``. A mask also outranks the lowercase that case repair gives a surname particle (``de la Vega``). The shipped map uses that for the Irish particles, which are written capitalized: .. doctest:: >>> str(parse("SEÁN Ó MURCHÚ").capitalized()) 'Seán Ó Murchú' >>> str(parse("JUAN DE LA VEGA").capitalized()) 'Juan de la Vega' Your own entry outranks it the same way, which is how to keep a family's capitalized particle. A mask applies wherever its word stands, though, so it recases every other name with that particle too: .. doctest:: >>> str(parse("martin van buren").capitalized()) 'Martin van Buren' >>> van_lex = dataclasses.replace( ... default, ... capitalization_exceptions=tuple(default.capitalization_exceptions) ... + (("van", "Van"),)) >>> str(parse("martin van buren").capitalized(van_lex)) 'Martin Van Buren' >>> str(parse("ludwig van beethoven").capitalized(van_lex)) 'Ludwig Van Beethoven' How a key matches a word ^^^^^^^^^^^^^^^^^^^^^^^^ The key is matched against the token with punctuation normalized away, not against the raw text, so one ``"phd"`` entry covers ``phd``, ``PHD`` and ``Ph.D.`` alike, and you don't need a separate key for each way a source might punctuate it. (A mixed-case ``Phd`` matches too, but mixed case is left as written unless you pass ``force=True``; see :ref:`rendering-arguments`.) Each word keeps its own punctuation: ``Ph.D.`` repairs to ``Ph.D.``, not ``PhD``. Punctuation in the *value* is never written into the word; it only marks which of the mask's letters are joined. That matters for one case: a single letter the writer split off beside a full stop is an initial, and is capitalized even where the mask keeps that letter inside a longer run: .. doctest:: >>> str(parse("john smith ph.d.").capitalized()) 'John Smith Ph.D.' >>> str(parse("john smith p.h.d.").capitalized()) 'John Smith P.H.D.' Words that need no entry ^^^^^^^^^^^^^^^^^^^^^^^^ An acronym already listed in ``suffix_acronyms``, plain or dotted, and a roman numeral are written in capitals by case repair on its own. Most listed acronyms whose usual spelling is not all capitals already carry a shipped mask (``DSc``, ``PsyD``, ``PharmD``, ``MDiv`` and others): .. doctest:: >>> str(parse("john smith md iv").capitalized()) 'John Smith MD IV' >>> str(parse("john smith psyd").capitalized()) 'John Smith PsyD' The exception is an acronym that is also a name word, such as ``meng`` or ``edd``. A mask applies wherever its word stands, so it would re-spell a person called Meng or Edd, and these get none: they repair in plain capitals. The reasoning is the Excluded block for ``CAPITALIZATION_EXCEPTIONS`` (meng, edd, lac, ded) in ``docs/design/decisions/vocabulary-exclusions.md``. .. doctest:: >>> str(parse("john smith edd").capitalized()) 'John Smith EDD' Acronyms the vocabulary doesn't list ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ An acronym ``suffix_acronyms`` doesn't list repairs according to how it is written: - **Plain** (``dclinpsy``), it parses as an ordinary name word and repairs as one: ``Dclinpsy``, as in the first example above. - **Dotted** (``d.clinpsy.``), it is a suffix by shape alone (see :ref:`unlisted-credentials`) and repairs in all capitals the same as a listed acronym does: ``D.CLINPSY.``. Either way, give it a ``suffix_acronyms`` entry, or a mask of its own, rather than relying on this fallback. .. _ambiguous-words: Words that are also ordinary names ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Some vocabulary words are also ordinary name words: an acronym suffix that is also borne as a surname, a particle that doubles as a given name, a connective letter that doubles as an initial. Four fields mark them — ``suffix_acronyms_ambiguous``, ``suffix_words_ambiguous``, ``particles_ambiguous`` and ``conjunctions_ambiguous``, one each for ``suffix_acronyms``, ``suffix_words``, ``particles`` and ``conjunctions``. They add no vocabulary by themselves; they narrow how an existing entry is read when it appears alone, and the parse reports the fork as an ambiguity. Titles that are also given names work differently, with a word list of their own, ``titles_ambiguous`` (:ref:`titles-also-given-names`). Credentials that are also surnames ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ``ma`` is a shipped example of ``suffix_acronyms_ambiguous``. It is both a credential and a common surname, so it is listed there. Written with its periods it is a suffix outright; written bare, the capitalization decides: .. doctest:: >>> parse("Jack Ma").family 'Ma' >>> parse("Jack M.A.").suffix 'M.A.' >>> parse("Jack MA").suffix 'MA' The full reading of a bare marked acronym, in order: - **ALL CAPITALS** in a mixed-case name leans to the credential: it is a suffix even with no words to spare (``"Jack MA"``). The capitals settle it as the periods do, so no ambiguity is reported, and a word after it in the given part after a family comma reads as part of the suffix (``"Smith, John MA Jones"`` gives suffix ``MA Jones``). - **Title-case** in a mixed-case name leans to the surname: it stays the family name even WITH words to spare (``"John Smith Ma"``). - **No signal** — all lowercase in a mixed-case name, or any spelling in a name written wholly in one case — falls back to the count: a suffix with two or more words before it, not counting a title or a nickname (``"John Smith ma"``), the family name otherwise (``"JACK MA"``). - **After a comma** the count of name words before the comma decides FIRST, and the case is read only where the count leaves the word a name. ``"John Smith, Ba"`` reads suffix ``Ba`` on the count alone, and ``"Smith, BA"`` reads suffix ``BA`` on the capitals lean. What still reads as the given name is ``"Smith, Ba"`` (one word, and Title-case has no credential lean) and ``"smith, ba"`` (one word, one case, no lean at all). - **In brackets after the name**, the capitals in a mixed-case name settle it as the credential too, and so does a period inside the word: ``"John Smith (BA)"`` and ``"John Smith (B.A)"`` give suffix ``BA`` and ``B.A``. Any other spelling there, and any spelling inside the name, falls through to nickname parsing (``"John Smith (Ba)"``, ``"Jeffrey (JD) Bricken"``), reported as a ``suffix-or-nickname`` ambiguity. Generation words that are also surnames ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ``suffix_words_ambiguous`` does the same for a suffix word: French ``fils`` and the Brazilian agnomens ``Filho``, ``Neto`` and ``Sobrinho`` mark a generation, and each is a surname too. An agnomen, written in the case of the name around it and listed in ``agnomens``, is a suffix only where the name keeps a word for its family. The writing of ``fils`` speaks as well: in a mixed-case name a Title-case ``Fils`` is the family name even with a word to spare: .. doctest:: >>> parse("Pedro Alves Neto").suffix 'Neto' >>> parse("Ana Neto").family 'Neto' >>> parse("Marie Fils").family 'Fils' A caller whose data writes an agnomen as part of the surname takes it out by removing the word from ``suffix_words`` and from the two markers over it, together: an orphan in ``suffix_words_ambiguous`` raises ``ValueError``, and one left in ``agnomens`` is simply never read: .. doctest:: >>> agnomen = {"neto"} >>> kept = Lexicon.default().remove( ... agnomens=agnomen, suffix_words_ambiguous=agnomen, suffix_words=agnomen) >>> Parser(lexicon=kept).parse("Pedro Alves Neto").family 'Neto' Mark a word, or leave it out ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ When you add a word that is both a credential and a name, weigh how often it is one against the other. There are three choices, and none is free: - **Unambiguous** (``suffix_acronyms`` alone): the credential reading is taken silently, and a wrong claim can lose a real person's surname. - **Marked ambiguous** (``suffix_acronyms_ambiguous`` too): the reading depends on the writing as above, and every bare reading reports the fork. - **Left out of** ``suffix_acronyms`` **altogether**: right when the name reading is far more common, as for an acronym whose credential is tenuous or specialized beside a common surname. That is what the default vocabulary did with ``rai`` and ``cha`` in 2.3; a caller who needs one adds it back with ``Lexicon.default().add(suffix_acronyms={"cha"})``. .. _titles-also-given-names: Titles that are also given names ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Many titles are borne as given names too: ``Dean``, ``King``, ``Prince``, ``Grace``, ``Major``, ``Sheikh``. A title addresses the name after it, so at the front of a name there is no count of words to read, and what tells the person ``Dean Smith`` from the office in ``Dean of Students`` is the phrase the word stands in. Since 2.7 these words are listed in ``titles_ambiguous`` rather than ``titles``, and each is a title only inside a title phrase: behind a style (``His Majesty King``) or a modifier (``Associate Dean``), before ``of`` (``Dean of Students``), or in a listed two-word phrase (``Major General``). Anywhere else it is a name word: .. doctest:: >>> parse("Dean Smith").given 'Dean' >>> parse("Associate Dean Smith").title 'Associate Dean' >>> parse("Dean of Students Jane Smith").title 'Dean of Students' >>> parse("Major General John Smith").title 'Major General' Where such a word opens the name and a listed phrase could have held it, the parse reports a ``title-or-name`` ambiguity, its alternative the title reading (:ref:`reading-not-taken`). A word no listed phrase holds, such as ``Grace`` (a title in ``His Grace``), reports nothing standing alone: .. doctest:: >>> dean_alt = parse("Dean Smith").ambiguities[0] >>> dean_alt.kind.value, dean_alt.alternative.kind.value ('title-or-name', 'title') >>> parse("Grace Smith").ambiguities () Four fields hold this vocabulary: - ``titles_ambiguous`` — the titles read only inside a phrase. A word listed in ``titles`` as well is a title wherever it stands, so ``add(titles={"dean"})`` (the first example on this page) is how to read one as a title everywhere. - ``title_modifiers`` — the words that qualify a head title (``associate``, ``vice``). - ``title_styles`` — the forms of address a title may stand behind (``majesty``, ``highness``, ``excellency``). - ``title_phrases`` — the two-word phrases, each read only where both words are titles. Removing one reads its words apart: .. doctest:: >>> parse("Chief Justice John Roberts").title 'Chief Justice' >>> chief = Lexicon.default().remove(title_phrases={"chief justice"}) >>> Parser(lexicon=chief).parse("Chief Justice John Roberts").title 'Chief' Particles that are also given names ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ``particles_ambiguous`` is the same idea for surname particles. A particle listed there may also be a given name, which is what makes a leading one a decision to take; a particle *not* listed there never is, so there is nothing to decide. That shows up in what a particle standing *alone* at the front of a name does: a listed one is a name part in its own right, while an unlisted one pulls the rest of the name into the surname and leaves no given name at all. Which field a *listed* particle lands in is ``name_order``'s question, covered below; an unlisted one opening the name is the surname under every order, because a word that can never be a given name leaves the order nothing to decide. .. doctest:: >>> parse("van Gogh").given # 'van' may be a given name 'van' >>> parse("de Mesnil").given # 'de' may not '' >>> parse("de Mesnil").family 'de Mesnil' A comma forestalls the question rather than answering it. Writing the surname before the comma has already said which words are the surname, so a particle at the front of them decides nothing, and whatever follows the comma is the given name as usual: .. doctest:: >>> parse("de Mesnil, Juan").given # the comma named the surname 'Juan' If your data never uses ``Van`` as a given name, take it out of the ambiguous set: a leading ``van`` is then no decision at all, so no ambiguity is recorded and it becomes part of the surname — under any ``name_order``, since that is what taking the word out asserted: .. doctest:: >>> lex = Lexicon.default().remove(particles_ambiguous={"van"}) >>> Parser(lexicon=lex).parse("van Gogh").family 'van Gogh' For ``particles_ambiguous`` the default runs the other way from credentials: a particle that is not borne as a given name belongs outside the set, which is where ``mc`` and ``ste`` were moved (#360). .. _connectives-also-initials: One-letter connectives that are also initials ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ``conjunctions_ambiguous`` is the same idea for one-letter connectives. A single letter written against the name's own case is an initial and one written with it is the connective — but a name written wholly in one case, all upper or all lower, says nothing either way, and this is the set that decides it there. ``e`` and ``i`` are the two entries shipped: a bare ``E`` or ``I`` initial is common where those letters between two surnames are rarer, and ``y`` runs the other way, so ``y`` joins even written as a bare capital. .. doctest:: >>> parse("jose e maria santos").middle # 'e' reads as an initial 'e maria' >>> parse("JUAN GARCIA Y LOPEZ").family # 'y' joins 'GARCIA Y LOPEZ' >>> parse("Jose e Maria Santos").given # mixed case decides itself 'Jose e Maria' A member also reports the fork, so a caller can see which reading was taken: .. doctest:: >>> [a.kind for a in parse("jose e maria santos").ambiguities] [] If your data is Portuguese, where ``e`` links surnames the way ``y`` does in Spanish, take it out and the connective reading comes back: .. doctest:: >>> lex = Lexicon.default().remove(conjunctions_ambiguous={"e"}) >>> Parser(lexicon=lex).parse("jose e maria santos").given 'jose e maria' If your data is Catalan or Polish, where ``i`` links two surnames the way ``y`` does in Spanish, take that one out instead and the link joins in a one-case name too: .. doctest:: >>> lex = Lexicon.default().remove(conjunctions_ambiguous={"i"}) >>> Parser(lexicon=lex).parse("josep carod i rovira").family 'carod i rovira' If your data is Dutch, where a bare single letter is an initial and never a connective, add the other one instead: .. doctest:: >>> lex = Lexicon.default().add(conjunctions_ambiguous={"y"}) >>> Parser(lexicon=lex).parse("juan garcia y lopez").middle 'garcia y' Bound given names ~~~~~~~~~~~~~~~~~~ ``bound_given_names`` holds given-name prefixes that attach to the following word to form one given name — ``abdul``, ``abu``, ``umm`` and their Arabic-script spellings (``عبد``, ``أبو``, ``أم``) among them: .. doctest:: >>> parse("abdul salam ahmed salem").given 'abdul salam' Add your own, or empty the set to switch the behavior off entirely: .. doctest:: >>> lex = Lexicon.default().add(bound_given_names={"mohamad"}) >>> Parser(lexicon=lex).parse("mohamad salam ahmed salem").given 'mohamad salam' >>> d = Lexicon.default() >>> off = d.remove(bound_given_names=set(d.bound_given_names)) >>> Parser(lexicon=off).parse("abdul salam ahmed salem").given 'abdul' Behavior: Policy ----------------- When your data source or application needs different parsing behavior — a different name order, stricter suffix rules, extra delimiters — set it on :class:`~nameparser.Policy`, a small, closed set of fields, listed below. .. list-table:: :header-rows: 1 :widths: 22 28 50 * - Field - Type - Effect * - ``name_order`` - one of the three exported order constants - Assigns positional input to given/middle/family in this order: ``GIVEN_FIRST`` (default), ``FAMILY_FIRST`` or ``FAMILY_FIRST_GIVEN_LAST``. A family comma overrides it. See :ref:`name-order`. * - ``script_orders`` - pairs of ``Script`` and an order - Assigns a name in one of these scripts in the paired order, whatever ``name_order`` says. Defaults to family-first for a name wholly in Han or Hangul, or in kanji and kana other than katakana alone. See :ref:`east-asian-defaults`. * - ``segment_scripts`` - ``frozenset[Script]`` - Scripts whose unspaced names are split into surname and given name. Defaults to Hangul. See :ref:`east-asian-defaults`. * - ``patronymic_rules`` - ``frozenset[PatronymicRule]`` - Reorders patronymic-shaped names via opt-in detectors — East 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. * - ``middle_as_family`` - ``bool`` - Folds ``middle`` into ``family`` instead of splitting them — for naming systems with no middle-name concept. Defaults to ``False``. * - ``two_family_names`` - ``bool`` - Reads the last two name words as the family name, for data where people carry two family names (Spain, most of Latin America). Defaults to ``False``. See :ref:`two-family-names`. * - ``nickname_delimiters`` - ``frozenset[tuple[str, str]]`` - Routes content enclosed by these delimiter pairs to ``nickname``. Defaults to :data:`~nameparser.DEFAULT_NICKNAME_DELIMITERS` — straight quotes and parentheses plus the typographic conventions (smart quotes, guillemets, CJK brackets, ...). See :ref:`brackets`. * - ``maiden_delimiters`` - ``frozenset[tuple[str, str]]`` - Routes content enclosed by these delimiter pairs to ``maiden`` instead of ``nickname``. Needed only for a clause that does not announce itself: ``"(née Jones)"`` is a maiden name without it. Defaults to empty. See :ref:`brackets`. * - ``extra_suffix_delimiters`` - ``frozenset[str]`` - Adds separators that split suffix groups, e.g. ``" | "`` for ``"Jane Smith, RN | CRNA"``. Additions only — the comma, the semicolon, and a hyphen or slash standing alone split suffix groups after a whole name and cannot be replaced. See :ref:`suffix-delimiters`. * - ``lenient_comma_suffixes`` - ``bool`` - Reads an initial-shaped suffix word after a comma as a suffix: ``"John Smith, V"`` is John Smith the fifth when ``True`` (default); ``False`` reads ``V`` as a given-name initial instead, and ``"john smith, v"`` the same way, since a name written all in one case reads as its capitals. Multi-letter suffixes (``III``, ``MD``) are unaffected. * - ``unlisted_dotted_suffixes`` - ``bool`` - Reads an unlisted dotted acronym such as ``X.Y.Z.`` as a credential where the name's shape allows it. Defaults to ``True``. See :ref:`unlisted-credentials`. * - ``unlisted_caps_suffixes`` - :class:`~nameparser.CapsSuffixes` - Where an unlisted all-caps word such as ``XYZ`` reads as a credential: after a comma (``AFTER_COMMA``, the default), at the end of a name as well (``EVERYWHERE``), or nowhere (``OFF``). See :ref:`unlisted-credentials`. * - ``strip_emoji`` - ``bool`` - Excludes emoji from tokenization — they appear in no field or rendered view, though ``original`` keeps them. Defaults to ``True``. See :ref:`strip-flags`. * - ``strip_bidi`` - ``bool`` - Excludes bidirectional control characters the same way. Defaults to ``True``. See :ref:`strip-flags`. To apply a :class:`PolicyPatch ` directly -- without going through a locale pack -- call :meth:`Policy.patched() `: .. doctest:: >>> from nameparser import Policy, PolicyPatch >>> Policy().patched(PolicyPatch(middle_as_family=True)) Policy(middle_as_family=True) .. _name-order: Family-first name order ~~~~~~~~~~~~~~~~~~~~~~~~ ``name_order`` is the one most likely to matter for data that is not in Western order. Positional input is assigned in the order you declare — with the three vocabulary exceptions noted under :ref:`order-vocabulary-first` — so a name written family-first — Hungarian, here — parses as written instead of needing to be rearranged afterwards: .. doctest:: >>> from nameparser import Parser, Policy, FAMILY_FIRST, parse >>> parse("Nagy Laszlo Peter").family # default GIVEN_FIRST 'Peter' >>> family_first = Parser(policy=Policy(name_order=FAMILY_FIRST)) >>> name = family_first.parse("Nagy Laszlo Peter") >>> name.family, name.given, name.middle ('Nagy', 'Laszlo', 'Peter') An explicit comma still wins, on the reasoning that someone who wrote one meant it — so the same parser reads ``"Thomas, John"`` as family-then-given regardless of the configured order: .. doctest:: >>> family_first.parse("Thomas, John").family 'Thomas' A comma that only sets off suffixes leaves the order governing the name before it: .. doctest:: >>> family_first.parse("Smith John, Jr.").given 'John' A Vietnamese full name needs a third order. It is written family, then middle, then given — the name a person is actually called by is the *last* word, not the second. Family-first order gets the family name right and then reverses the remaining two, so ``FAMILY_FIRST_GIVEN_LAST`` exists for the names that read this way: .. doctest:: >>> from nameparser import FAMILY_FIRST_GIVEN_LAST >>> family_first.parse("Tran Quoc Toan").given # FAMILY_FIRST 'Quoc' >>> given_last = Parser(policy=Policy(name_order=FAMILY_FIRST_GIVEN_LAST)) >>> viet = given_last.parse("Tran Quoc Toan") >>> viet.family, viet.middle, viet.given ('Tran', 'Quoc', 'Toan') Nothing keys this order to a script the way the East Asian defaults below do — Vietnamese is written in the Latin alphabet, which carries no order of its own — so it applies only where you set it, and there is no ``vn`` locale pack. Declaring the order settles where a surname ends ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ A surname particle joins forward, onto the word after it. Where a particle *ends* the name there is nothing ahead of it to join, and what it belongs to is decided by what the writing says rather than by the word. Two things say it, and both amount to someone stating that the family name came first — a family comma, and a declared family-first order: .. doctest:: >>> parse("Jong, Anke de").family # the comma says so 'de Jong' >>> family_first.parse("Jong Anke de").family # the order says so 'de Jong' The comma and the order are one shape ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ That pair is not a coincidence but one shape written two ways: form 4 (``Title Family Given Middle Middle [Particle] [, Suffix]``) is form 2 (``[Title] Family [Suffix], Title Given (Nickname) Middle Middle[,] Suffix [, Suffix]``) with the comma removed and the family folded inline — titles included, so a title that form 2 writes after the comma leads the name in form 4 instead. If your records are family-first without commas, ``Policy(name_order=FAMILY_FIRST)`` reads them the way the comma format is already read. Measured over the whole particle vocabulary — every particle nameparser ships, crossed with three families and three given names, 630 generated pairs in all — 603 of 630 agree (2026-08-30); the executable form of this correspondence is ``tests/v2/test_order_correspondence.py``. Where the correspondence stops ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Three limits keep that statement honest: - **It covers one shape, not comma deletion in general.** A name whose shape changes when the comma is removed — a title or suffix crossing to a different position — parses as the shape it becomes, not as a disagreeing reading of form 2. - **A word that is both particle and suffix reads differently in the two writings.** The shipped words in both vocabularies are ``do``, ``mc`` and ``vd`` (2026-10-02). The particle attachment outranks the suffix reading on the comma side alone — that is the scope the rule is stated in — so ``parse("Ménil, Christophe vd")`` reads family ``vd Ménil``, while ``family_first.parse("Ménil Christophe vd")`` reads family ``Ménil`` and suffix ``vd``. It is that asymmetry, not a precedence, that breaks the correspondence, and a listing ending in one of those words is the whole of the 27 disagreeing pairs measured above, not scatter. - **Only** ``FAMILY_FIRST`` **is reached.** It is the only order that puts a trailing piece in the *middle*, where a particle means nothing. ``FAMILY_FIRST_GIVEN_LAST`` puts it in the given slot, where your own declaration already says it is the given name, and no comma format writes the given name last, so form 5 has no comma twin to correspond to in the first place: .. doctest:: >>> given_last.parse("Nguyen Thi Van").given 'Van' Where a leading particle run stops ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The declaration also bounds how far a *leading* particle run reaches. With no order declared, nothing marks where the surname ends and a particle followed by several words really can be all surname, so the whole name is read as one. Declaring family-first asserts that what follows the family is not more surname, which settles it: .. doctest:: >>> parse("de Mesnil Jean").family # nothing says where it ends 'de Mesnil Jean' >>> family_first.parse("de Mesnil Jean").family # the order does 'de Mesnil' >>> family_first.parse("de Mesnil Jean").given 'Jean' In the default order, write the comma for that reading. Under a family-first order the run stops after one name *word* rather than one token, so it cannot cut inside a conjunction-joined run or a bound given-name pair — ``"de la Vega y Santos Juan"`` keeps family ``de la Vega y Santos``. Where two or more words are left over, the two family-first orders differ from each other: .. doctest:: >>> family_first.parse("de la Cruz Juan Carlos").middle 'Carlos' >>> given_last.parse("de la Cruz Juan Carlos").middle 'Juan' .. _order-vocabulary-first: Where the vocabulary answers first ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ In three places the vocabulary layer answers before ``name_order`` is consulted at all: - **A middle word that is also a shipped particle** is claimed by the vocabulary. That is why the Vietnamese example above is not the more obvious ``"Nguyen Van Minh"``: ``Van`` is the Dutch particle ``van``, so that name reads family ``Nguyen`` with ``Van Minh`` given under *both* family-first orders, and the choice between them makes no difference. - **A never-given particle opening the name** overrides the declared order outright: where a particle that can never be a given name stands alone as the opening piece, the whole name is the surname, in every ``name_order``. ``"de Mesnil"`` is family ``de Mesnil`` under both family-first orders exactly as it is by default, not family ``de`` with ``Mesnil`` given — a word that can never be a given name leaves the order nothing to decide. Only the never-given set does this: ``"van Gogh"`` reads family ``van``, given ``Gogh`` under a family-first order, because ``van`` *can* be a given name and so leaves a real question to answer. - **A lone name word beside a given-name title** is the given name in every ``name_order`` (since 2.5): ``"Sir John"`` reads given ``John`` under both family-first orders, as it does by default, while ``"Dr John"`` still reads family ``John`` there. `Words that are also ordinary names`_ covers dropping a word from a vocabulary, or moving one between those two sets. .. _two-family-names: Names with two family names ~~~~~~~~~~~~~~~~~~~~~~~~~~~ In Spain and most of Latin America a person carries two family names, the father's first and then the mother's, and writes both after the given names: Gabriel García Márquez's family name is ``García Márquez``. Written given-first, that name has the same shape as a name with a middle name, so the default reads the first family name as a middle name: .. doctest:: >>> gabo = parse("Gabriel García Márquez") >>> gabo.middle, gabo.family ('García', 'Márquez') ``two_family_names=True`` reads the last two name words as the family. Given names before them stay given and middle names, a particle counts with the word it attaches to, titles and suffixes are not counted, and an initial written alone never joins the family: .. doctest:: >>> two_names = Parser(policy=Policy(two_family_names=True)) >>> two_names.parse("Gabriel García Márquez").family 'García Márquez' >>> ana = two_names.parse("Ana María García López") >>> ana.given, ana.middle, ana.family ('Ana', 'María', 'García López') >>> two_names.parse("Dr. Juan Pérez de la Vega Jr.").family 'Pérez de la Vega' A family that already holds two names keeps what it has, whether a hyphen or ``y`` joins them (``"Ana María García-López"``, ``"José Ortega y Gasset"``) or a particle chain took them both: a compound given name opening with a particle, such as ``"María del Carmen García López"``, reads family ``del Carmen García López`` with the switch on or off. A name of two words is unchanged, and a comma naming the family decides by itself, so ``"García López, Ana"`` needs no switch at all. Under a family-first ``name_order`` the *first* two name words are the family. Turn it on only for data written this way. A name with one family name behind two given names, ``"Juan Carlos Pérez"``, loses ``Carlos`` to the family under the switch, and Argentine names, which carry one family name, are mostly that shape. Portuguese and Brazilian names carry two family names too, the mother's first, but the people who write them often give the last one alone as their family name: among the UK company register's 5,333 Portuguese filers the switch breaks 1,481 names and mends 1,221 (measured 2026-10-24), while among ORCID's researchers working or living in Portugal, who split their own names into its boxes, it mends 4,295 and breaks 2,034 of 20,000 (measured 2026-10-27): the two populations disagree, so measure the data you have before switching it on. Filipino names often carry a middle name between the given name and the family name, traditionally the mother's maiden surname, and their bearers count it with their given names: ``"Maria Santos Reyes"`` has family ``Reyes``, which the switch reads as ``Santos Reyes``. Among ORCID's researchers in the Philippines it breaks 8,234 names of 20,000 and mends 265. The parser reports nothing for the names the switch moves: the shape it reads is the same either way. .. _east-asian-defaults: East Asian defaults, and turning them off ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Two defaults key on the *script* a name is written in rather than on anything you set: a name written wholly in Han or Hangul — or in kanji and kana, unless it is katakana alone — is assigned family-first (``script_orders``), and an unspaced hangul name is split into surname and given name against the shipped Korean census list (``segment_scripts``). :ref:`east-asian-names` explains the naming conventions both rest on; this section is how to switch them off. Two further behaviors are not policy fields at all, and are covered last. Switching off order and splitting ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The two defaults switch off separately: .. doctest:: >>> parse("김민준").family # both defaults on '김' >>> positional = Parser(policy=Policy(script_orders=())) >>> positional.parse("김민준").family # still split '민준' >>> unsplit = Parser(policy=Policy(segment_scripts=frozenset())) >>> unsplit.parse("김민준").family # one token, not split '김민준' The two switches interact, and clearing only ``script_orders`` produces a third behavior rather than the old one: the split still runs, so ``김민준`` still becomes two tokens, and the positional default then assigns them given-first — the surname lands in ``given``. To restore nameparser 2.0's reading exactly, clear both fields. .. note:: Every field here is annotated with its canonical *storage* type rather than with everything the constructor accepts — the same as ``capitalization_exceptions``, and for the same reason: the annotation is what you get back when you READ the attribute, which is the commoner operation. The constructor is deliberately wider. It takes any mapping for ``script_orders``, any iterable of ``Script`` for ``segment_scripts``, and plain strings wherever a ``Role`` is wanted (``Role`` is a ``StrEnum`` precisely so that works). A dataclass cannot express those two types separately, so the examples in this guide use the spellings that check clean under mypy — ``()`` and ``frozenset(...)`` rather than ``{}`` and a bare set literal. The wider spellings parse identically; they just need a ``# type: ignore[arg-type]`` if you run a type checker. Teaching the splitter a surname ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ To teach the splitter a surname it doesn't ship with, add it to the ``surnames`` vocabulary like any other word: .. doctest:: >>> lex = Lexicon.default().add(surnames={"김민"}) >>> Parser(lexicon=lex).parse("김민준").family '김민' Chinese surnames are deliberately absent from that default set, because splitting Han text requires knowing Chinese from Japanese; :doc:`locales` covers the opt-in ``zh`` pack that supplies them. Japanese names ^^^^^^^^^^^^^^ The Japanese behaviors ride these same two fields, so they need no switches of their own. ``script_orders=()`` clears the kana-licensed entry along with the Han and Hangul ones, so a name in kanji and kana reads given-first again: .. doctest:: >>> parse("山田 エミ").family '山田' >>> positional.parse("山田 エミ").given '山田' ``segment_scripts=frozenset()`` deactivates every script at once, which also stops a parser consulting whatever segmenter it was given. The segmenter has an off-switch of its own as well: ``Parser(segmenter=None)``, which is the default. See :ref:`segmenter-contract` for what a segmenter is expected to do with text it does not handle. Behaviors no policy field controls ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Two behaviors apply however both fields are set: - **The katakana middle dot** ``・`` separates tokens the way a space does, decided in tokenization. - **A glued honorific**, a listed CJK honorific at the end of a name token, is split off it. The honorific vocabulary carries its own license rather than borrowing a script's: every entry is a word that can never end a name, so there is no per-script question for ``segment_scripts`` to answer. .. doctest:: >>> both_off = Parser( ... policy=Policy(script_orders=(), segment_scripts=frozenset())) >>> both_off.parse("マイケル・ジャクソン").family 'ジャクソン' >>> both_off.parse("田中さん").suffix 'さん' The honorific vocabulary is the peel's off-switch. Removing an entry from ``honorific_tails`` leaves that honorific glued, while the spaced form still reads it as a suffix: .. doctest:: >>> no_san = Parser( ... lexicon=Lexicon.default().remove(honorific_tails={"さん"})) >>> no_san.parse("田中さん").family '田中さん' >>> no_san.parse("田中 さん").suffix 'さん' ``honorific_tails`` marks a subset of ``suffix_words``, so dropping a word from it alone orphans nothing and needs no matching ``suffix_words`` edit. Emptying it is also how to opt out of what the peel *costs* a non-ASCII parse: an empty ``honorific_tails`` stops the peel at its first check, whereas ``segment_scripts`` never gated it and so cannot turn it off. .. _brackets: Nicknames, maiden names, and brackets ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Maiden markers ^^^^^^^^^^^^^^ A maiden name is usually announced by a marker word, and then it needs no brackets and nothing configured: .. doctest:: >>> parse("Jane Smith née Jones").maiden 'Jones' :ref:`nicknames-and-maiden-names` in the usage guide covers how the marked forms read. The marker words themselves are the ``maiden_markers`` vocabulary, a ``Lexicon`` field, shipped in several languages (see :mod:`nameparser.config.maiden_markers`). Add your own like any other word; a marker you add works bare and in brackets alike: .. doctest:: >>> parse("Jane Smith formerly Jones").maiden # not a marker '' >>> marked = Parser( ... lexicon=Lexicon.default().add(maiden_markers={"formerly"})) >>> marked.parse("Jane Smith formerly Jones").maiden 'Jones' >>> marked.parse("Jane Smith (formerly Jones)").maiden 'Jones' A multi-word entry such as the shipped ``z domu`` is matched as a phrase, not word by word; see `Vocabulary: Lexicon`_ above. How a bracketed clause is read ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ A delimiter pair carries no meaning of its own, so what a clause enclosed in one reads as is settled in steps, and the pair is asked last: 1. **Suffix-shaped content** is taken first. The brackets are dropped and what was inside parses as if it had been written bare, which is not the same as the clause becoming the suffix. 2. **A clause that announces itself**, opening with a recognized maiden marker and carrying a word after it, is a maiden name inside any configured pair, with nothing configured (since 2.2). The marker is dropped from the value. 3. **Everything else is decided by the pair**: content in a ``nickname_delimiters`` pair is a nickname, and content in a ``maiden_delimiters`` pair is a maiden name. .. doctest:: >>> name_jr = parse("Jane Smith (née Jr.)") # step 1 >>> name_jr.family, name_jr.suffix ('née', 'Jr.') >>> parse("Jane Smith (née Jones)").maiden # step 2 'Jones' >>> parse('Jane Smith "née Jones"').maiden # step 2, any pair 'Jones' >>> parse("Jane (Jones) Smith").nickname # step 3 'Jones' Routing a pair to maiden names ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Step 3 is what ``maiden_delimiters`` is for. It reaches two kinds of clause, not one: content with no marker word in it, such as a birth surname written bare in parentheses, and a lone marker word. Listing a pair in ``maiden_delimiters`` drops it from the effective ``nickname_delimiters`` set automatically, and the one-liner is the whole recipe: .. doctest:: >>> policy = Policy(maiden_delimiters=frozenset({("(", ")")})) >>> Parser(policy=policy).parse("Jane (Jones) Smith").maiden 'Jones' A marker is dropped from the value only where it stands as its own word with a name word after it. A lone marker is kept as the value — and so is a multi-word one filling the clause, such as ``z domu`` — and so is a marker written against the name it marks, which is one token with it, so ``旧姓`` stays in the value too: .. doctest:: >>> parse("Jane Smith (Nee)").nickname 'Nee' >>> Parser(policy=policy).parse("Jane Smith (Nee)").maiden 'Nee' >>> Parser(policy=policy).parse("Jane Smith (z domu)").maiden 'z domu' >>> cjk_parens = frozenset({("(", ")")}) # full-width >>> cjk = Parser(policy=Policy(maiden_delimiters=cjk_parens)) >>> cjk.parse("山田花子(旧姓佐藤)").maiden '旧姓佐藤' Adding a delimiter pair ^^^^^^^^^^^^^^^^^^^^^^^ To *add* a delimiter pair rather than reroute one, build on the exported default — assigning a bare set replaces the built-in pairs instead of extending them, the same trap as ``capitalization_exceptions``: .. doctest:: >>> from nameparser import DEFAULT_NICKNAME_DELIMITERS >>> parse("Benjamin {Ben} Franklin").middle # not a pair by default '{Ben}' >>> policy = Policy( ... nickname_delimiters=DEFAULT_NICKNAME_DELIMITERS | {("{", "}")}) >>> Parser(policy=policy).parse("Benjamin {Ben} Franklin").nickname 'Ben' .. _suffix-delimiters: Suffixes not separated by commas ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ A comma is the usual separator between post-nominals, but sources write others. A few are read with no configuration, any other can be declared, and a credential joined to another, or split by a space, needs neither. Separators read without configuration ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ A semicolon separates post-nominals as a comma does. Glued to the word before it, it stays in the suffix as written: .. doctest:: >>> name = parse("Jane Smith, RN; CRNA") >>> name.given, name.family, name.suffix ('Jane', 'Smith', 'RN; CRNA') So does a hyphen or a slash standing alone, with a space on each side, after a whole name and its comma or after a further comma (since 2.7). It is dropped, as a comma between suffixes would be: .. doctest:: >>> name = parse("Jane Smith, RN - CRNA") >>> name.given, name.family, name.suffix ('Jane', 'Smith', 'RN, CRNA') >>> parse("Jane Smith, Dr. / MD").given 'Jane' Glued to a word, a hyphen or slash is the word's own, so ``Jane Smith, RN- CRNA`` reads as it always has, with ``RN-`` as the given name and ``Jane Smith`` as the family. Behind a single word (``Smith, RN - CRNA``) nothing is split, and before the comma the hyphen is a word too, so it never reaches a hyphenated surname written with spaces (``Smith - Jones``). Declaring another separator ^^^^^^^^^^^^^^^^^^^^^^^^^^^ ``extra_suffix_delimiters`` handles sources that separate post-nominals with something else. Undeclared, the separator is a word. Since 2.4 such a name still parses when a credential opens the part after the comma, because that makes the whole part the suffix, but the separator stays in the suffix as written. Declared, it splits the suffix into groups the way a comma does: .. doctest:: >>> name = parse("Jane Smith, RN | CRNA") >>> name.given, name.family, name.suffix ('Jane', 'Smith', 'RN | CRNA') >>> policy = Policy(extra_suffix_delimiters=frozenset({" | "})) >>> name = Parser(policy=policy).parse("Jane Smith, RN | CRNA") >>> name.given, name.family, name.suffix ('Jane', 'Smith', 'RN, CRNA') A declared separator also counts glued to a word, as the semicolon does, and stays in the suffix as written there: declaring ``" - "`` reads ``Jane Smith, RN- CRNA`` as given ``Jane``, family ``Smith``. Through 2.3 the undeclared reading of ``Jane Smith, RN - CRNA`` was given ``RN``, family ``Jane Smith`` and suffix ``CRNA``, so the delimiter was the only way to get the name right, and through 2.6 it was the only way to split the suffix. Credentials written joined or split ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ A hyphen or a slash written between two credentials with no space needs no declaring when every part is a listed credential that is not also a name, because the word then reads as one credential (since 2.7). A part that may be a name, such as ``MA``, keeps the word a name word, since a hyphen also joins surnames, unless the word is written in capitals, which :ref:`all-caps-credentials` reads on its own terms: .. doctest:: >>> joined = parse("Jane Smith, RN/CRNA") >>> joined.given, joined.family, joined.suffix ('Jane', 'Smith', 'RN/CRNA') >>> joined = parse("Jane Smith, MA/PhD") >>> joined.given, joined.family ('MA/PhD', 'Jane Smith') A credential written with a space inside it, such as ``Pharm D``, is read as one credential too (since 2.7), where the name has words to spare in front of it: .. doctest:: >>> parse("John Smith, Pharm D").suffix 'Pharm D' .. _unlisted-credentials: Credentials the vocabulary doesn't list ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ No suffix list holds every post-nominal, so two ``Policy`` fields, both new in 2.4, read an unlisted word as a credential from how it is written: in periods (``X.Y.Z.``), or in capitals (``XYZ``). The writing cannot settle it alone, because a surname can be written either way, so both fields also decide by position: the word has to stand behind a name that can spare it. Both fields share one exception. Two letters alone right after a comma are how a person's initials are written, and the words before the comma may be one surname of two words, so ``"García Márquez, G.J."`` and ``"García Márquez, MJ"`` keep given ``G.J.`` and ``MJ``. The dotted spelling reports the bare reading as a fork; the capitals do not. The exception gives way to evidence that the letters are a credential: an unambiguous post-nominal in front of them that is not also a title, or another unlisted dotted or all-caps word beside them that its own field reads as a credential. A title in front of them says the opposite: .. doctest:: >>> parse("John Smith, PhD X.Y.").suffix 'PhD X.Y.' >>> parse("John Smith, PhD MJ").suffix 'PhD MJ' >>> parse("García Márquez, MJ XYZ").suffix 'MJ XYZ' >>> cred = parse("García Márquez, Ms G.J.") >>> cred.title, cred.given ('Ms', 'G.J.') .. _dotted-credentials: Dotted acronyms ^^^^^^^^^^^^^^^ ``unlisted_dotted_suffixes`` is on by default. It reads a token of two or more period-separated chunks as a credential at the end of a name, right after a comma behind two or more name words, at the end of the given part after a family comma, and at the end of a maiden marker's clause. Case is irrelevant; the periods are the signal. With nothing to spare in front of it, the word stays a name. At the end of a name, given part or clause, either reading reports the fork as a ``suffix-or-name`` ambiguity, so a record that reads wrong can still be found; right after a comma behind a full name, the credential reading is taken silently: .. doctest:: >>> cred = parse("John Smith X.Y.Z.") >>> cred.family, cred.suffix ('Smith', 'X.Y.Z.') >>> parse("Jack X.Y.Z.").family 'X.Y.Z.' >>> cred = parse("Doe, John X.Y.Z.") >>> cred.given, cred.suffix ('John', 'X.Y.Z.') >>> parse("Doe, X.Y.Z.").given 'X.Y.Z.' >>> cred = parse("Jane Doe nee Smith X.Y.Z.") >>> cred.maiden, cred.suffix ('Smith', 'X.Y.Z.') >>> cred = parse("John Smith, X.Y.Z.") >>> cred.suffix, cred.ambiguities ('X.Y.Z.', ()) Five things are not this shape. A token the vocabulary already knows (``M.A.``, ``Ph.D.``) is read by the vocabulary. A single trailing period is how any word is abbreviated, so ``"John Smith Xyz."`` keeps family ``Xyz.``. Every chunk must be alphabetic, so a digit anywhere refuses it (``"John Smith 1.4"`` keeps family ``1.4``). A script with no period abbreviations of its own refuses it too (``"John Smith 田.中."`` keeps family ``田.中.``). And two single letters, each with its period, are how initials are written, and how some surnames are, such as the Nepali ``B.K.``: in a script with capitals they stay a name word wherever they end a name, given part or clause, and still report the fork, while three letters or more are a credential as before. Right after a comma they are the exception above: .. doctest:: >>> nepali = parse("Ram Bahadur B.K.") >>> nepali.middle, nepali.family ('Bahadur', 'B.K.') >>> parse("Doe, John B.K.").middle 'B.K.' Setting the field to ``False`` reads such a token as name material everywhere, and still reports the fork. It does not bring back the pre-2.4 reading of a chunk that is one ASCII character, a roman numeral or the digit ``2``, as a credential. That reading was retired outright, not put behind this switch, so ``"Jack X.Y.I."`` and the version string ``"John Smith 1.4.2"`` keep their last word as the family name either way: .. doctest:: >>> dotted_off = Parser(policy=Policy(unlisted_dotted_suffixes=False)) >>> dotted_off.parse("John Smith X.Y.Z.").family 'X.Y.Z.' >>> dotted_off.parse("Jane Doe nee Smith X.Y.Z.").maiden 'Smith X.Y.Z.' .. _all-caps-credentials: All-caps words ^^^^^^^^^^^^^^ ``unlisted_caps_suffixes`` reads an unlisted word of two or more capital letters, with no period in it, and since 2.7 one of capitals joined by a hyphen or a slash (``PU-PH``, ``MS-OMPT``) with a part of two or more letters. Its value is a :class:`~nameparser.CapsSuffixes`, because where capitals mean a credential depends on a second convention: many records write the SURNAME in capitals (``"Jean DUPONT"``, ``"DUPONT, Jean"``). The capitals only mean something against a name that does not use them. The name must hold a word of its own with a capital and a lowercase last letter (``Smith``, ``DiCaprio``) that the vocabulary does not claim as a title, particle or credential. A record written wholly in capitals, or wholly in lowercase, keeps every word a name word. ``AFTER_COMMA`` (the default) """"""""""""""""""""""""""""" ``CapsSuffixes.AFTER_COMMA`` reads the word only in the part right after a comma with two or more name words before it. The all-caps surname convention never writes capitals there: .. doctest:: >>> from nameparser import CapsSuffixes >>> cred = parse("John Smith, XYZ") >>> cred.family, cred.suffix ('Smith', 'XYZ') >>> parse("Smith, XYZ").given 'XYZ' >>> parse("JOHN SMITH, XYZ").given 'XYZ' >>> cred = parse("John Smith, PU-PH") >>> cred.family, cred.suffix ('Smith', 'PU-PH') ``EVERYWHERE`` """""""""""""" ``CapsSuffixes.EVERYWHERE`` also reads the end of a name, the given part's last word after a family comma, and the word ending a maiden marker's clause. Those are the places an all-caps surname IS written, which is why it is not the default: .. doctest:: >>> caps_everywhere = Parser( ... policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE)) >>> caps_everywhere.parse("John Smith XYZ").suffix 'XYZ' >>> caps_everywhere.parse("Doe, John XYZ").suffix 'XYZ' >>> cred = caps_everywhere.parse("Jane Doe nee Smith XYZ") >>> cred.maiden, cred.suffix ('Smith', 'XYZ') >>> cred = caps_everywhere.parse("Jean Pierre DUPONT") >>> cred.family, cred.suffix ('Pierre', 'DUPONT') ``OFF`` """"""" ``CapsSuffixes.OFF`` reads none of them and reports nothing, as 2.3 did. It is the way to keep a given name written in capitals after a two-word surname, which the default reads as a credential: .. doctest:: >>> parse("García Márquez, GABRIEL").suffix 'GABRIEL' >>> caps_off = Parser( ... policy=Policy(unlisted_caps_suffixes=CapsSuffixes.OFF)) >>> caps_off.parse("García Márquez, GABRIEL").given 'GABRIEL' .. _strip-flags: Keeping emoji and control characters ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The strip flags keep characters the parser removes by default. Note what happens to an emoji you keep — it becomes a token like any other, and lands in the middle name: .. doctest:: >>> str(parse("Sam 😊 Smith")) # stripped by default 'Sam Smith' >>> kept = Parser(policy=Policy(strip_emoji=False)).parse("Sam 😊 Smith") >>> str(kept), kept.middle ('Sam 😊 Smith', '😊') ``strip_bidi=False`` does the same for invisible bidirectional control characters, which is occasionally what you want when round-tripping right-to-left text verbatim. .. _rendering-arguments: Presentation: rendering arguments ---------------------------------- Once a name is parsed, how it's displayed is a separate decision made at the point of output, not baked into the parse. Three methods on :class:`~nameparser.ParsedName` cover it — see :doc:`modules` for full signatures: * :meth:`~nameparser.ParsedName.render` fills a format spec from the seven role fields. * :meth:`~nameparser.ParsedName.initials` is the same idea narrowed to first letters, with its own ``delimiter``/``separator`` arguments. * :meth:`~nameparser.ParsedName.capitalized` returns a new, case-fixed :class:`~nameparser.ParsedName` instead of a string. It only touches a name whose words are written in a single case (all lower, all upper) unless you pass ``force=True`` — mixed case is left alone by default on the assumption that someone already capitalized it on purpose. The suffixes don't count toward that: ``III`` or ``PhD`` written the usual way says nothing about how the name was cased. A suffix written in more than one case is the writer's spelling and is kept as written (``john smith EdD`` gives ``John Smith EdD``) unless you pass ``force=True``. .. doctest:: >>> from nameparser import parse >>> name = parse("Dr. Juan Q. Xavier de la Vega III") >>> name.render("{family}, {given} {middle}") 'de la Vega, Juan Q. Xavier' >>> name.initials(spec="{given}{middle}{family}", delimiter="", separator="") 'JQXV' >>> str(parse("DR. JUAN DE LA VEGA").capitalized()) 'Dr. Juan de la Vega' >>> str(parse("JuAn DE LA vEGA").capitalized()) 'JuAn DE LA vEGA' >>> str(parse("JuAn DE LA vEGA").capitalized(force=True)) 'Juan de la Vega' >>> str(parse("juan garcia III").capitalized()) 'Juan Garcia III' Looking for v1's ``string_format``? It's the ``render(spec)`` argument now — pass your own format string per call instead of setting it once on a shared config object. A spec chooses what the output is *for*. The default is written for display and does not survive a reparse — it parenthesizes the maiden name, which reads back as a nickname. When the rendered string will be parsed again, spell the marker out (``née {maiden}``) so the field round-trips; see :ref:`the round-trip note in the tour `. Sharing a configured parser ---------------------------- A :class:`~nameparser.Parser` is a frozen value, so the way to share one configuration across a codebase is the same way you'd share any other constant: build it once at module level and import it wherever you parse. .. code-block:: python # myapp/names.py from nameparser import Lexicon, Parser, Policy lex = Lexicon.default().add(titles={"dean"}) policy = Policy(strip_emoji=False) parser = Parser(lexicon=lex, policy=policy) # elsewhere from myapp.names import parser name = parser.parse(raw_name) Because :class:`~nameparser.Parser` and its ``lexicon``/``policy`` are immutable and hashable, ``parser`` is safe to import and call from multiple threads with no locking — there is no shared mutable state to protect, unlike v1's module-level ``CONSTANTS``. derek73-python-nameparser-456b024/docs/design/000077500000000000000000000000001527303271700211105ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/AGENTS.md000066400000000000000000000564731527303271700224320ustar00rootroot00000000000000# Working on docs/design/ Loaded when a session touches these documents; the root AGENTS.md carries what fires from elsewhere (the same-PR amendment rule, prose counting claims, release-log claims, backlog triage, markdown line width, guard-test negative controls). rules/ states intended parsing behavior and is NORMATIVE; decisions/ is the dated ADR log behind it; mechanisms.md catalogs reusable patterns and verification shapes. rules/ and decisions/ hold one named file per entry, indexed by their README.md. Each declares its own conventions at the top (for the two folders, in the README) — read those before describing what one of them contains (axis 4). **Starting a design: pin the negative space first.** A spec's "X is not supported" is a claim about today's parser; hold it with a case row for X written BEFORE the implementation, so a row that passes unexpectedly kills the premise before any code exists. The 2026-07-30 comma-suffix spec did this for one item (`suffix_phrase_is_comma_scoped`) and not for its central premise, which is the half that cost a full plan (#291). **Rethinking a rule that grew by patching.** Three signs together mean the next fix should be a rethink, not another patch: the rule works by scanning and stopping; each later fix added a stop, an exception or a check on one; and a guard exists whose job is to model what another stage would do. The third sign is the decisive one — a rule that must predict other rules' behavior to stay correct is defending an invariant it should be built from. #601 is the worked case. Five issues (#424, #533, #535, #538, #549) had each added a stop or a release check to M2, and its decisions.md section reached about 13,000 words. The rethink (mechanisms.md#READ-WITHOUT-THEN-BIND, #LICENSED-BY-NEIGHBOR) deleted the release machinery, cut the rule's statement to 883 words (counted 2026-10-04), and turned the invariant from a checked property into the construction. The rethink was drafted in rule shape and prototyped in seven variants behind one switch before anything landed (#601's comments); the variants that failed did so on measured counts, not on review opinion. Rule length alone is not the signal: S2 (before #682 split it into S2 and S4–S9) and R4 had each gathered 35 dated decisions entries, and R4's are mostly vocabulary and rendering cases rather than added stops (counted 2026-10-04). **One rule per piece of evidence.** A rule states one signal in the written name — a count, a case contrast, a shape, a neighbor — and what that signal decides. A decision that weighs several signals is a hub: its rule states the decision, the precedence among its signals, and a map naming the rule that states each one — the bullet list under its Then, with the precedence in its Precedence bullets (C1 is the worked hub: its map is the bullet list under its Then, and its Precedence bullets state the order among C4–C6 and between C5 and C12, the strict knob having left it for C14 when the C section moved to the skeleton (#761); S2's map named S4–S8 until PR #745 gave it a `Signal:` line, and now names the rules that line lists; the check holds a hub's map and its `Signal:` line to each other, both ways, not either to the rules the hub ought to name). New evidence for a decision is therefore a new rule plus a line in the hub's map, never a new clause in the hub or in a sibling. Before adding a clause to a statement, name what it reads in the text: if the rule does not already read it, the clause is a new rule; if it is where this rule's own signal stops deciding, it belongs here, with a `· boundary` example. This is a different cause from the patching above. S2 and C1 did not grow by stops: each grew by clauses that were sound on their own, every review passing, until #682 split S2 into S2 and S4–S9 (PR #716) and #728 split C1 into C1 and C3–C11 (PR #734) — each retargeting every reference to the old ID by hand and leaving a note in each moved decisions entry that the old ID "means the rule as it then stood". A new rule costs an index line. Length is still not the signal; count the signals. Each rule names its own on a `Signal:` line, a hub naming the rules it maps after any signal it reads itself (rules/README.md), and a line that has to name two signals, joined by ` + `, marks the rule a split candidate. Each rule's size is recorded in tests/v2/rules_budget.json, so a rule that grows fails the suite and re-records: not a cap, but a diff a reviewer reads, which is where axis 12 starts. A clause that leaves a split candidate without a new ID goes to the rule whose `Signal:` line already reads what the clause reads, not to the rule it reads most like: the 2026-10-19 splits (P8, P9, C13) first proposed moving S2's written-with-its-periods clause into S4, the ambiguous-acronym rule, which would have given S4's count a shape beside it and made S4 the next candidate; it went to S3, whose signal is the periods. Check the destination's `Signal:` line before moving a clause, as you check the source's. **Landing a design.** The gitignored spec (docs/superpowers/specs/) is the working medium; it dies with the branch, and the docs are the record. Before a design PR merges, walk its spec (including amendments) and distill the durable residue: decisions made or reversed → decisions entries; proposals rejected with evidence → Declined:; vocabulary that must stay out → Excluded:; behavior the design settled → rules/ (with a deviates: marker if unshipped); reusable patterns → mechanisms.md; options weighed → a weighing entry. Then check the spec cites nothing the docs don't now carry — a spec section with no committed home when the PR merges is lost, not deferred (a 2026-08-16 sweep of eight weeks of specs recovered nine such items). The same-PR amendment rule above covers code-driven changes; this covers the design-driven ones. **Moving text between files breaks every positional reference in it, and in what pointed at it.** "The bullet above", "this file's Excluded block", "the W2 section" are true only where the text stood. #713 split decisions.md by heading and then moved the vocabulary-keyed blocks into decisions/vocabulary-exclusions, and its last review found thirteen such references left pointing at nothing: three inside the moved blocks, ten in entries elsewhere that located a block by position. The citation check cannot see them, because none is a citation. After a move, grep the moved text and the whole folder for `above`, `below`, `this file`, `this section` and the moved block's name, and turn each hit into a key-form pointer (`decisions/P3's bullet of that date`). Splitting an ID is the same move in the other direction (#682, S2 into S2 and S4–S9): every reference to the old ID needs a reader's choice of new one, and the references come in more spellings than the citation check reads — `rules/S2` and `decisions/S2`, a bare `S2` in rule prose, and the short forms older entries use, `#S2` and "the #544 entry under S2". #682's first pass retargeted the first three and its review found nine of the last kind still pointing at entries that had left the file; grep all five before calling the retarget done. #728 (C1 into C1 and C3–C11) added two more. A blanket note that an old ID in a moved entry "means the rule as it then stood" is true only where the pointer meant the whole rule: "C1's two-dotted-groups sentence" means C5 now, so retarget each pointer that names a piece. And the prose a split writes fresh is new normative text that no guard reads against its statement. That covers each piece's Because (its Rationale, in the block format the rules were then written in), and a `· boundary` added so a piece has one. #728's first C4 Rationale called two words before the comma "a whole name", which C5's exception denies. Its first C10 boundary carried no policy annotation, so the rule never ran on it. Read each new Because against its piece's Except bullets, and run each new boundary under the annotation its rule needs. Restructuring a rule — a split, or a change of format like #761's move to the skeleton — changes no rule's meaning: list the distinct facts in the old text (`git show master:docs/design/rules/`) and find each one in the new, in a field, a bullet or a dated decisions entry, and check that every old example still runs or that the PR says why it went. A fact found nowhere was dropped, and a code-comment quote of the rule left with no verbatim home is the usual sign of one. A restructuring that finds a rule wrong records it (a `deviates:` marker or an issue) rather than fixing it in the same diff. **A count in a dated entry is evidence, not a live fact.** decisions entries are snapshots by convention, so measurements belong in them — but a reader wanting TODAY's number must not have to trust the snapshot's date. Where an entry quotes something that drifts (vocabulary sizes, corpus counts, set compositions), give the one-liner that recomputes it, and phrase the argument so it survives the digits moving — "the two shares differ by orders of magnitude" outlives "58% vs 0.65%". A count that carries no argument is better deleted than dated: "over every name in the corpus, no prefilter" says what "over all 782 names" says, and cannot go stale. Do NOT reach for a test asserting the count — that is the constant-content pattern, and it fails on every legitimate vocabulary addition. #326 is the cautionary case: it quoted a vocabulary composition, carried a date, and was stale in five days. **Primary-source review.** When doc content is distilled from a session's work, have that session (or its transcript) review its own sections before or soon after landing — attribution flattening and inverted arguments are visible only to the source. Reviewers state which tree each measurement ran on (stale fetches produced three rounds of already-fixed findings), and landed corrections are re-verified here before committing. Two shapes, needing different instruments. CROSS-SESSION distillation is what the rule above is written for — the #381 arc harvested nine named sessions, and for each the source transcript is an artifact independent of the reviewing session, so "review your own sections" is a two-artifact comparison. SAME-SESSION design work (#386) has no such artifact: spec and docs were written in one context, so the same instruction degenerates into re-reading the working memory that produced any error, and the rationale inverts — when the source is NOW, the source is what got it wrong. There, compare against artifacts instead, in this measured order of yield: re-derive every number with a freshly written script (2 wrong claims on #386); re-read the written spec against a checklist for FIDELITY, not just presence (1); interrogate NAMED contested rule pairs one at a time (2). Unaided prose reading found none — so a general "coherence read" by the author is the one form to distrust, because a green report from the weakest instrument manufactures confidence rather than supplying it. **Reviewing a docs/design change: the axes.** Run these before merging anything that touches rules/, decisions/, mechanisms.md — or THIS FILE, which carries the conventions those three rest on and has the same failure modes. Axes 3, 5, 6 and 12 are rules-specific (they need rule statements, examples and the rules a rule names); the other eight apply to any of the four — axis 11 included, since an invariant over two inputs is prompted by what a rule implies but is RUN without reading one, which is the whole of its value. Say which axes you ran and which you skipped as inapplicable — a checklist that asks for something meaningless teaches the reader to skim it. Axes 1–9 were each earned by a defect that survived every other check on #386, where the review passes surfaced ten issues in total — nine of them by these axes, one by the spec self-review above — and unaided prose reading surfaced none. Axes 10 and 11 were earned the same way on other arcs — #291's, which PREDATES this list, and #461/#466's, which follows it. Axis 12 was earned by no defect at all, which is its point: it covers the growth every other axis passes. The parenthetical is the case that earned the axis. Skip what the suite already enforces. `tests/v2/test_rules_doc.py`, `tests/v2/test_doc_citations.py` and `tests/v2/test_design_docs.py` machine-check example lines and their `deviates:` today-values, boundaries, the one-of `implemented:`/`tracked:` rule, `implemented:` against the modules that actually cite the rule, the rule IDs a rule names anywhere in its prose, cross-doc anchor IDs, citation excerpts quoted verbatim, legacy citation forms (the pre-#713 file-anchor form among them), that every rule and decisions file is indexed once, under a title matching its heading and file name, each decisions file's `## Standing` block against its live entries and against the index's Reference lists, the one kind of file that carries none, a rule file reusing an ID a decisions file records `Retired:` and a misspelled `Retired:` line, the README's copy of rules/D2 against the file and its split-candidate grep against the rules' Signal lines, that the NFD-typed examples stay decomposed, a denylist of spellings that shipped wrong (`tests/v2/test_doc_spellings.py`, over nameparser/ and docs/), each rule's `Signal:` line (its presence, spelling and place) and the README's list of the tags those lines use, a hub's rules against the bullets under its Then, both ways, a comment naming an Except or Precedence bullet against that bullet's bold lead, the skeleton grammar in `tests/v2/test_rules_skeleton.py` (labels, field order, fences, the footer, one paragraph each for When, Tolerated and Then, the two sentence limits — Then one sentence, Because three at most — and an example under every Except bullet), the refusal of a rule in the retired block format, and — in `tests/v2/test_rules_budget.py` — each rule's recorded size. Assume they pass; a finding one of them would catch is a false positive. Read those files rather than trusting this sentence — it named five of the nine guards when written, and goes stale as guards are added. 1. **Recompute every number.** Any count, ratio or "N names" in the diff gets recomputed by a script written now, never read. (A filter described as returning one name returned three; a rule's stated blast radius listed two names its own scope excluded.) 2. **Your detector is a second, unreviewed implementation.** It must read the same vocabulary and boundaries the rule reads. (A particle-run detector that walked only the never-given half — the rule chains through ANY particle — reported 50 false movers where the true count was 1, printing cleanly both times.) 3. **Rule vs. its own examples.** (P1 said the fold takes "the particle and the one name word"; its example `de la Vega` is two particles onto one word.) An Except bullet's fence is examples of that bullet, not of the rule: a row there must be one where Then does NOT hold, so read each row in an Except fence against its own bullet's bold lead and move a row Then still decides out of it — to the main fence, or to the Accepted bullet that records it as a cost. Nothing checks this — the runner asserts a row's output, never which fence it sits in — and the #761 migrations, which sorted every rule's examples into fences by hand, put rows where Then held into an Except fence twice that review caught: H1's given-name rows (PR #779, back to the listing-record Accepted bullet) and S3's switched-off `Msc.Ed.` row (PR #783, to the main fence). 4. **Any claim about a document vs. what that document says about itself.** Two directions, both bitten. Inward: follow every `history:` and read the entry (P6's rationale asserted what decisions/vocabulary-collisions denies, and the rule's own guard exists because the assertion is false). Outward: when prose anywhere — this file, a code comment, another design doc — describes what one of these files contains or does, read that file's preamble or entry conventions first (an AGENTS.md bullet claimed the rules name the pipeline stages; their preamble forbids exactly that — "Statements are implementation-free: no stage names, no function names, no regexes"). Those declarations sit at the top of files people cite without reading. Mind their scope before calling a violation: the rules' ban binds STATEMENTS, which `_statements()` in test_doc_citations.py defines — a rule's fields, When, Tolerated, Then and its bullets, Because, and the Except, Precedence and Accepted bullets, without fences or footer — so Backgrounds and Not-in-scope may name functions and do. 5. **Does the change move another rule's OUTPUT?** A marker lands on the rule whose STATEMENT changed; a rule can move another's output without touching its statement, and the runner cannot see it. Walk the rules it names: the IDs in its When, Then, Except and Precedence. (family_base moved initials(); R3 had no marker.) 6. **Contested inputs: is precedence stated?** Naming a rule pins nothing: an ID in Then or Except does not say which rule wins. A rule states precedence in a `**Precedence:**` bullet — the input, the winning rule, why. Walk the same IDs as axis 5 and ask, for each, whether an input both rules claim has its winner written down. (P6 and S2 both claimed `Berg, Jan vd`.) 7. **General clause vs. adjudicated scope.** Enumerate the vocabulary a shape-clause reaches. (P6's precedence was argued for `vd` and swept in `do` and `mc`.) Enumerate the sites and orders too: a field or a bullet reads rule-wide, so a sentence argued for one site — a comma, a family-first reading — covers every site unless its words, or its bullet's bold lead, name the one it was about. Parse each such sentence's claim under the other site and order. (#777: four P6 clauses about the comma site and one about the family-first site, moved out of the paragraphs that had scoped them, read as covering both, each falsified by the other site's input, `Berg Jan vd` under family-first and `van, Jan de`.) 8. **Guard docstring vs. what the guard enforces**, especially when the promise spans two test modules. 9. **Prose is input to the doc parsers.** In a rule (tests/v2/rules_skeleton.py), every non-blank line inside a fence is an example and must parse; outside a fence, a line, label text or bullet text that parses as an example or opens with a quote or a bracket is refused, as is any line holding an arrow and an assertion (` → family=`) outside a backtick span, so an example cannot sit unrun in prose; so is a `Signal:` or `no-boundary:` line above the `---` footer, a second When, Tolerated or Then paragraph, a bullet marker other than `- ` (a numbered item included), a nested list in any marker or a heading in a bullet's continuation lines, a line opening a block-format rule (`X1. ` at the start of a line, inside a field included), and an unknown or repeated part of the pointer line; a column-0 line ends the rule only when it is a markdown heading (`#` and a space); a fence is a bare ``` line, and one carrying a language tag, or a `~~~` fence, is refused; a line opening `**Word:**`, the colon inside the bold, is a field label and is refused unless Word is one of the seven labels; a line opening with a label word (When, Tolerated, Then, Because, Except, Precedence, Accepted, or Rationale) and a colon in any other spelling is refused as a near miss; other bold text at the start of a line, such as `**Note**:` with the colon outside the bold, is read as the open prose field's prose, and after a bullet list it is a line outside any field and refused. Outside a rule, only a section's `## Name (X)` heading, each letter once, and its one `Background:` paragraph may stand: a subheading in a rule file, a misspelled rule heading or a fence in a section file is refused, glued to the Background or not (the glued check reads structure at the start of a line only), as is an example in a Background and a section heading with no Background. A comment's quoted values join the citation block above them. 10. **Re-run every capability claim.** Axis 1 recomputes numbers; this one recomputes CAN and CANNOT. A statement that the parser never or cannot produce some reading is measured by a parse run written now, never derived from a mechanism — most of all when it justifies building something. (#291 derived "a multi-word credential is unparseable" from "vocabulary is matched one token at a time" — which `given_name_titles` had been an exception to since 2026-07-19, a month before that arc, and `maiden_markers` is a second since #434 — and specified a vocabulary set, a matching unit, a predicate and a `Lexicon` field on it; `parse("John Smith, MD PhD").suffix` had been `'MD PhD'` since 1.4.0.) 11. **State the invariant that relates two DIFFERENT inputs.** Axes 1–10 compare a document to another document, to its own examples, or to the implementation, so every one of them can only find code that DISAGREES with a rule — none of them can find a rule that is simply wrong. When a rule implies that two spellings of the same name must parse alike, write that as an invariant over both inputs and run it: it consults no rule statement, so it is not blind that way. Distinct from an invariant over two VIEWS of one parse, like `test_the_family_partitions_into_particles_and_base` in tests/v2/test_cases.py — that one holds however wrong the rule is. (Two review rounds a month apart found real defects and missed the premise under them: #461 was backed out after the rule turned out wrong, #466 closed unmerged for the same reason, and in both, every reviewer checked the code against the rule and THE RULE was at fault. The worked invariant: the family-comma writing under the default order and the comma-less writing under FAMILY_FIRST must parse alike. Measured, it holds for the great majority of the particle vocabulary and fails exactly on the words that are also suffix vocabulary — recovering P6's contested-membership set from behavior, having read no rule. Composition, measurement and recompute recipe: decisions/two-input-invariants.) 12. **Name the signal each new clause reads.** For every clause the diff adds to a rule's statement — for every Except bullet, since each is a place where another reading takes over — say what in the written name it reads, and check it against the rule's `Signal:` line; a diff that moves tests/v2/rules_budget.json is where to look first. A signal the rule did not already read is a new rule wanting its own ID and a line in the hub's map ("One rule per piece of evidence" above), and a clause that restates another rule's condition instead of citing it is the same finding from the other side. This is a structural finding, not a style one: report it under the usual format, the consequence being the next split and its hand retargeting. (S2 reached seven rules' worth of clauses and C1 ten, one sound clause at a time, every review passing, until #682 and #728 split them after the fact.) Also: before reporting "N names move", report the size of the population that COULD move — a small count over a corpus blind to the shape is evidence about the corpus. For rules/P6 the population is the comma-bearing names that END in a particle — a small fraction of the comma-bearing names, which are themselves under a third of the corpus; decisions/P6 carries the dated measurement and the recompute. Do not restate a corpus count here. Axis 1 over this very sentence once caught it conflating the population with the movers, and the corrected figures that replaced it were superseded within the month as the corpus grew — a standing count in this file is wrong twice over, once for the class and once for the digits. derek73-python-nameparser-456b024/docs/design/decisions/000077500000000000000000000000001527303271700230705ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/decisions/3-0-reevaluations.md000066400000000000000000000572501527303271700266010ustar00rootroot00000000000000# 3-0-reevaluations — decisions shaped by the v1 shim Promoted 2026-08-15 from session memory (Derek's 2026-07-30 ask; promotion approved 2026-08-15). Discipline when appending: mark each entry (A) "would decide differently without the shim" — real 3.0 work — or (B) "cited 1.4 parity but stands on its own", recorded so nobody re-litigates it. Append here whenever a design choice cites 1.4 parity or the shim as a load-bearing reason. Standing discipline for the removals themselves: every removal warns in a RELEASED version first — the rule established by the 1.3.0 eq/hash work (#223/#224), the reason the v1.4 milestone existed, and the reason FACADE-CONTRACT's "warning-free on 1.4" anchor works at all. 3.0's shim removals follow the same bridge. The CONVERSE rule, declined-with-reasoning twice in the migration design (2026-07-11): a bridge warning must WAIT when the replacement does not yet exist — 1.4 warned on neither shared-CONSTANTS mutation nor the subclass hooks, because users had no actionable response until 2.0, and #262's contract docs shipped in the same release (warn-while-documenting is a mixed message). ## Standing - The facade speaks v1's fields, `first` and `last`, over a core that speaks `given` and `family`, CJK names included; 3.0 can drop the aliasing — 2026-08-15. - Render's default format is v1's, so an interpunct transcription renders space-joined (`str(HumanName("威廉·莎士比亚"))` is `'威廉 莎士比亚'`) and a caller reinstates the dot with a format of their own; 3.0's render defaults are a clean slate — 2026-08-15. - A `.pyi` stub typing `Policy.__init__` wide and its attributes narrow is a 3.0 candidate, deferred from #334 for want of a signature list with its own sync test — 2026-08-15. - The vocabulary DATA modules keep `nameparser.config` as their home in 3.0, so only the five shim names leave the package, and the five 2.2 alias messages (`prefixes.PREFIXES` and the rest) point at paths that stay — 2026-10-28 #357 (Derek). - `force_mixed_case_capitalization` is a `Constants` attribute and goes in 3.0 with it, the 2.8 construction warning being its removal warning — 2026-10-28 #925 (Derek), WARNINGS. - Pickle-guard layout breaks landing in a minor release are shim-era friction; the guarded raise stands on its own — 2026-08-15. - Reading positionally where no script licenses family-first is the safe direction on its own, family-first being the marked case that needs affirmative evidence — 2026-08-15. - From 2.8, the last 2.x feature release, the uses 3.0 removes warn with a `DeprecationWarning` on every call at the caller's line naming code that runs: constructing `HumanName` or a `Constants`, loading either from a pickle, mutating the shared `CONSTANTS`, and reading `nameparser.config`'s three managers or `nameparser.util.lc`; importing `nameparser`, `nameparser.parser` or `HumanName` stays quiet, and so does reading the shared `CONSTANTS`, which no warning reaches — 2026-10-28 #925 (Derek), WARNINGS. - The 3.0 core API — `parse`, `Parser`, `Lexicon`, `Policy`, `Locale` and `ParsedName` — is frozen except for additions, so anything 3.0 later breaks there is a new decision needing its own 2.x warning — 2026-10-28 #925 (Derek), THE CORE API. - The deprecation bridge's mechanics are the template for every remaining 2.x→3.0 shim: PEP 562 module `__getattr__` plus `__all__`, a warning per read location rather than per process, and the `TYPE_CHECKING` split — 2026-08-15. - A scoped config override through contextvars (`with nameparser.config.use(c):`) stays parked for 3.0, the shared `CONSTANTS` surviving 2.x as the one config channel that reaches parses inside code a caller does not own — 2026-07-06. - The 1.3.0-era legacy-pickle skip of `suffixes_prefixes_titles` rides in the shim's `__setstate__` and dies with it — 2026-08-16. - An explicit family comma beats script for Latin text, a doctrine inherited from v1 that stands on its own; for CJK script it is a description, not a promise (Elsewhere, below) — 2026-08-15. - Particle vocabulary is language judgement wearing a position heuristic, so a caller who knows a tradition should be able to opt into a tuned set, which needs packs to carry vocabulary overrides and not only additions; 3.0 work — 2026-08-17. - Which single-letter connectives read as initials differs by language, a per-word policy that `conjunctions_ambiguous` carries without packs (`e` and `i`); a pack can add a member, so an `nl` pack could mark `y`, and cannot remove one (the next line) — 2026-08-18; 2026-09-20 #397. - A locale pack's vocabulary is unioned onto the base and can remove nothing, so a caller removing a base entry, such as `e` from `conjunctions_ambiguous` for Portuguese, does it by hand; accepted for 2.x and part of 3.0's pack design — 2026-09-13, SHOULD A LOCALE PACK. - The snapshot and `compare.py` read every corpus name under the default `Lexicon`, no corpus entry carrying a locale, so neither can show a pack's effect; the fix, a locale per corpus entry, is parked with the question above — 2026-09-13, THE DIFFERENTIAL HARNESS. - The shim raises on a `capitalization_exceptions` value that does not spell its key's letters and digits, as the core `Lexicon` does; only where the raise lands for a v1 caller, at the first parse, is shim-era — 2026-09-23 #459. - The shim's filter of v1 `Constants` entries dies with the shim, the core having no v1 reading to reproduce: it drops with one warning an entry v1 could never match, 1.4.0's two shipped `TITLES` typos silently, keeps a capitalized or edge-period key that v1 never matched and that matches now, an accepted widening, and drops a lone CJK full stop that 1.4.0 matched, its one departure from 1.4.0 (decisions/R4) — 2026-10-02 #541. Not standing: the harness bullet's "at all", overtaken for the agreement records, which score the CLDR renderings under their language's pack since #673 (issue #672) and the native-script names since #693, both recorded in tools/differential/README.md and in no decisions entry — 2026-09-13, THE DIFFERENTIAL HARNESS; the parking lot's requests-Session-style `Parser(constants)` API, overtaken by 2.0's `Parser(lexicon=..., policy=...)` — 2026-07-06. Elsewhere: the explicit-comma line narrowed to a description for CJK script — decisions/cjk-comma-demotion's 2026-09-01 #469, SCOPES the FAMILY_COMMA bullet entry; the 1.4 gate the harness-baseline bullet expected 3.0 to dissolve, already retired, `compare.py --baseline 1.4.0` answering the question when asked — decisions/differential-ledger's 2026-10-13 #664 entries; the vocabulary model packs are to narrow, a strict pack dropping other languages' readings — decisions/vocabulary-model's 2026-10-15/16 entry; what the shim's filter keeps and drops — decisions/R4's 2026-10-02 #541 and 2026-10-02 #582 entries. Open: #753 (phase 3, language packs, where the three pack bullets land). ## Log - 2026-08-15 (its harness-baseline bullet superseded by decisions/differential-ledger's 2026-10-13 #664 retirement of the classified gate; its FAMILY_COMMA bullet superseded in part, narrowed for CJK script by decisions/cjk-comma-demotion's 2026-09-01 #469 entry; its nameparser.config removal-scope bullet superseded by 2026-10-28 #357, below) — THE LIST PROMOTED FROM SESSION MEMORY: the undated (A) and (B) bullets below, but for the three the lines after this one date (the parking lot, the legacy-pickle skip and `force_mixed_case_capitalization`). - 2026-07-06 — THE CONFIG-MODEL PARKING LOT: the (A) bullet opening "Parking lot from the 2026-07-06 config-model discussion" below. - 2026-08-16 — THE LEGACY-PICKLE SKIP, promoted from session memory by that day's sweep: the (B) bullet opening "The 1.3.0-era legacy-pickle property-key skip" below. - 2026-08-17 — PARTICLE VOCABULARY AS LOCALE PACKS: the (A) bullet so headed below. - 2026-08-18 (its open #383 and its pack-only requirement superseded in part by decisions/P8's 2026-09-13 #383 + #479 entry, whose `conjunctions_ambiguous` is the per-word policy without packs; its six single-letter conjunctions seven since decisions/P3's 2026-09-20 #397 entry) — LOCALE CONTROL OF P3's SINGLE-LETTER CARVE-OUT: the (A) bullet so headed below. - 2026-08-29 (its removal candidacy superseded by 2026-10-28 #925 (Derek), WARNINGS, below) — `force_mixed_case_capitalization` AS A SECOND ROUTE INTO R5's OVERRIDE: the (A) bullet opening with that attribute below. - 2026-09-13 — SHOULD A LOCALE PACK BE ABLE TO REMOVE BASE VOCABULARY: the (A) bullet so headed below. - 2026-09-13 — THE DIFFERENTIAL HARNESS CANNOT SEE A LOCALE PACK'S EFFECT: the (A) bullet so headed below. - 2026-09-20 #397 — A SECOND INSTANCE OF THE SAME PER-LOCALE QUESTION: the (A) bullet so headed below. - 2026-09-23 #459 — THE SHIM RAISES on a v1 config it once accepted: the (B) bullet so headed below. - 2026-10-02 #541 — THE SHIM DROPS v1 entries v1 could never match: the (B) bullet opening THE SHIM DROPS below. - (A) v1 field vocabulary at the facade boundary: CJK semantics squeeze into first/last through HumanName while the core speaks given/family. 3.0 could drop the aliasing entirely. - (A) Render's v1-inherited string_format defaults, including space-joined output for interpunct transcriptions (#298: str(HumanName("威廉·莎士比亚")) == "威廉 莎士比亚"; users reinstate the dot via custom format). 3.0 render defaults are a clean slate. - (A) The differential harness baseline is 1.4-on-PyPI: post-shim, the migration promise it verifies dissolves; the successor baseline is presumably last-2.x. Machinery survives; corpus contracts change. - (A) A .pyi stub for Policy (wide __init__, narrow attributes) — deferred from #334, see the differential-ledger Declined entry. - (A) nameparser.config removal scope: the 3.0 schedule says "nameparser.config in its entirety" while actually enumerating only the five shim exports; whether the DATA modules keep the package as their home or move under the core is open, and Lexicon's public field docs cross-reference nameparser.config.particles et al. (see the maintainer note in nameparser/config/__init__.py). - (A) `force_mixed_case_capitalization` as a second route into R5's override (2026-08-29, recorded while extracting R5). The core takes `force` per call and nothing else; the attribute reaches the same behavior only because the facade resolves it when no per-call value is given, which is v1 parity and nothing more. Measured: with it True, a bare `.capitalize()` on `HumanName('Shirley Maclaine')` leaves it `'Shirley MacLaine'` (mutator: read the result back with `str()`). Without the shim there is one route, and R5's statement could say so plainly instead of being written to cover both. Recorded as a removal CANDIDATE — nothing is decided here, and the bridge discipline above applies whenever it is. - (B) Pickle-guard layout breaks landing in minors (2.1's __setstate__ breaks): the guarded-raise design is right regardless; only the in-a-minor friction is shim-era. - (B) Positional-read-when-unlicensed as the safe direction (script_orders fallbacks, #298 dot-suppression granularity): coincides with 1.4 parity but stands alone — family-first is the marked case needing affirmative evidence. - (B) The deprecation-bridge shape (#293/#354) is the template for every remaining 2.x→3.0 shim (the bridge DISCIPLINE — warn in a released version first — predates it, from #223/#224; this entry is the module-__getattr__ mechanics): PEP 562 module __getattr__ PLUS __all__ (star imports never reach __getattr__ — measured: `from ...prefixes import *` bound nothing and leaked the helper); warn per read-location rather than per process (a write-back let a vendored dependency's first read consume the only warning); and the TYPE_CHECKING split, because a module __getattr__ silently disables mypy's attr-defined checking for the whole module (measured on a py.typed package). - (A) Parking lot from the 2026-07-06 config-model discussion, 3.0-shaped by design: scoped config override via contextvars (`with nameparser.config.use(c):`, the decimal.localcontext pattern — fixes test pollution and config wars while keeping the shared default), and a requests-Session-style Parser(constants) API (rejected then as a full rewrite; the shared CONSTANTS survives 2.x because it is the only config channel that reaches parses inside code users don't own — #262's contract framing). - (B) The 1.3.0-era legacy-pickle property-key skip (suffixes_prefixes_titles in old blobs) rides in the shim's __setstate__ and dies with it. - (B) The FAMILY_COMMA doctrine (rule W3): inherited from v1's lastname-comma but correct on its own terms — an explicit comma is stronger evidence than script. - (A) 2026-08-17 — PARTICLE VOCABULARY AS LOCALE PACKS. Raised while reversing decisions/P1's order-independence decision (its 2026-08-16 order-precedence keystone entry, reversed by its 2026-08-17 entry; this pointer said "above" until 2026-10-22, from the single decisions.md this file was split out of): every argument in the leading-particle thread is a language judgement wearing a position heuristic. `de` and `do` lead surnames in Vietnamese, `von` is German, `dos` Portuguese, `das` both Portuguese and a borne Bengali surname — and the per-word comments in nameparser/config/particles.py (reading notes in nameparser/vocab/und.toml from 2026-10-18, and since #749 in the file of each language whose names the particle occurs in) cite exactly these traditions as their justification. Locale packs already exist (ru, tr_az, zh, ja) and already carry vocabulary and rotations, so particles are the obvious next tenant: a caller who knows the tradition could opt into a tuned set instead of the parser splitting the difference globally. Not 2.x work — it needs the packs to carry vocabulary OVERRIDES rather than additions, and it would give C-i's positional qualifier a per-locale answer rather than one global one. - (A) 2026-08-18 — LOCALE CONTROL OF P3's SINGLE-LETTER CARVE-OUT, and the sharper requirement it puts on the entry above: a pack would have to carry per-word POLICY, not just membership. Which single letters a tradition wants joined differs by language — Spanish writes "y" and would want "e" read as an initial, Portuguese wants the reverse, Dutch would want every single letter read as an initial. One global rule serves all three today: a single ALPHABETIC character in a name of fewer than four pieces stays a name word, and a bare Latin capital never joins (#383). It is v1's Google Code issue 11 fix ("john e smith") generalized to a vocabulary v1 did not have — the set now holds six single-letter conjunctions (y, e, и, і, й, و), and the carve-out reaches all of them because it counts characters, while the capital veto reaches only the Latin ones because it tests a Latin shape. That asymmetry is the visible seam and it is why #383 is open. A pack that could only ADD or REPLACE vocabulary could not express any of it; the join threshold and the initial veto would have to be per-word, per-pack values. Measured while documenting P3: "&" is single-character but not a letter, so it joins at any length ("Juan & Garcia" is one part while "Juan y Garcia" is three). - (A) 2026-09-13 — SHOULD A LOCALE PACK BE ABLE TO REMOVE BASE VOCABULARY? `Locale.lexicon` is UNIONED onto the base and never removes — the field's own comment says so — which makes `conjunctions_ambiguous` (decisions/P8, 2026-09-13) expressible by a pack in ONE DIRECTION ONLY: an `nl` pack can add "y", and a `pt` pack cannot remove "e", so a Portuguese caller has to write `Parser(lexicon=Lexicon.default().remove(conjunctions_ambiguous={"e"}))` by hand. Accepted for 2.x and parked here rather than filed, because 3.0 is when a wider set of locale packs gets designed and this is part of that work (Derek, 2026-09-13). It is the 2026-08-17 PARTICLE VOCABULARY AS LOCALE PACKS bullet's "overrides rather than additions" requirement meeting a second tenant, which is what makes it a shape question rather than one pack's inconvenience. - (A) 2026-09-13 — THE DIFFERENTIAL HARNESS CANNOT SEE A LOCALE PACK'S EFFECT AT ALL. `compare.py` builds one `Parser` per name ORDER and nothing else, and a `Case.locale` row projects into the corpora as a bare name string — no corpus row carries a locale key, verified over every `tools/differential/corpus*.jsonl` file on 2026-09-13 — so every corpus name is parsed under the DEFAULT `Lexicon` on both sides. A pack's effect on a name can therefore never diff, at any baseline. That is fine while packs only ADD vocabulary the default already carries most of, and stops being fine the moment the bullet above is answered yes. The shape of the fix is a `locale` carried per corpus ENTRY the way `shape` already carries an order, resolved at the baselines that ship the pack and skipped below them. Parked with the bullet above rather than filed, for the same reason: the two are one piece of work (Derek, 2026-09-13). - (A) 2026-09-20 #397 — A SECOND INSTANCE OF THE SAME PER-LOCALE QUESTION, cross-referenced rather than parked again. Catalan/Polish `i` joins `conjunctions_ambiguous` beside `e`, so the marked subset now carries two letters and the 2026-08-18 bullet above — which counted six single-letter conjunctions and named the per-word-policy requirement — describes a set of seven, `i` included. Nothing in that bullet's argument moves: the subset IS the per-word policy the bullet asked for, made configurable without packs, and the pack-side half is still what the 2026-09-13 SHOULD A LOCALE PACK BE ABLE TO REMOVE BASE VOCABULARY? (A) bullet above asks (this pointer named only its date until 2026-10-22, which two bullets share), since a `ca` or `pl` caller still has to write `Lexicon.default().remove(conjunctions_ambiguous={"i"})` by hand. See `decisions/P3`'s 2026-09-20 bullet. - (B) 2026-09-23 #459 — THE SHIM RAISES on a v1 config it once accepted: a `capitalization_exceptions` value that does not spell its key's letters and digits raises `ValueError` at the first parse (decisions/R4, 2026-09-23). Cited against the shim's never-raise rule and taken anyway, so it is recorded here: the raise stands on its own — a case mask has no reading for a value that re-spells its word, and the core `Lexicon` raises the same way with no shim involved — and only where it LANDS for a v1 caller, at the first parse rather than at construction, is shim-era. 3.0 has nothing left to decide. - (B) 2026-10-02 #541 — THE SHIM DROPS, with a warning, every v1 `Constants` entry v1 could never match (decisions/R4, 2026-10-02). Cited from 1.4 parity, and it dies with the shim: the core `Lexicon` normalizes edge whitespace away and has no v1 reading to reproduce, so 3.0 has nothing left to decide. - (B) 2026-10-26 #752 — THE SHIM KEEPS 2.6.0'S TITLE LIST as v1's default `titles`, frozen in `nameparser/config/_titles_2_6_0.py`, so a title a later release made phrase-only (decisions/H9, 2026-10-26, the v1 title list) is still listed and removable. Cited from v1 parity, and it dies with the shim: the core `Lexicon` keeps the two sets apart and has no v1 list to reproduce, so 3.0 deletes the file with the facade and decides nothing. - 2026-10-28 #357 (Derek) — THE VOCABULARY DATA MODULES KEEP `nameparser.config` IN 3.0. SUPERSEDES: the 2026-08-15 bundle's nameparser.config removal-scope bullet. What 3.0 removes from the package is the five shim names (`Constants`, `CONSTANTS`, `SetManager`, `TupleManager`, `RegexTupleManager`); `titles`, `particles`, `suffixes`, `regexes` and the rest stay where `Lexicon`'s field docs already point, the core keeps importing them from there, and so 2.8 needs no bridge for them. The five 2.2 alias messages (`prefixes.PREFIXES` → `particles.PARTICLES` and the rest, #357's subject) therefore stand as written: each names a path 3.0 keeps. Decided in chat with the orchestrating session of the 2.8 deprecation work, answering the 2.8 deprecation inventory's first question. - 2026-10-28 #925 (Derek), WARNINGS — 2.8 WARNS WHERE CODE USES WHAT 3.0 REMOVES, and is the last 2.x feature release; after it master is 3.0 development and 2.x takes fixes only. SUPERSEDES: the 2026-08-29 entry's removal candidacy for `force_mixed_case_capitalization` (last paragraph). This keeps the 2.0 RFC's promise that a `HumanName` construction warning ships in a 2.x minor before 3.0 (docs/design/nameparser-2.0-rfc.md), which no release had kept: until 2.8 a caller who never mutated the shared `CONSTANTS` was never warned. What warns, and how: - Constructing `HumanName` (a subclass included) and a caller-built `Constants`, `Constants.copy()` included; the import-time `CONSTANTS` singleton is built past `__init__`, so `import nameparser` never warns. The cost of exempting it: reading the shared `CONSTANTS` (`'dr' in CONSTANTS.titles`) warns nowhere, so code that only reads it passes `-W error::DeprecationWarning` on 2.8 and breaks in 3.0; docs/migrate.rst says so. - Loading a `HumanName` or `Constants` pickle, from `__setstate__`, since unpickling skips `__init__` and the load is the one use construction never sees; each names what to store instead (`parse(text).as_dict()` or the text; a pickled `Parser`). `copy.copy` and `copy.deepcopy` go through the same hooks, a subclass's own included, with only the load warning filtered, a copy being no load; `copy.copy(CONSTANTS)` stays quiet with them, and a `HumanName` built on the copy warns. - Reading `SetManager`, `TupleManager` or `RegexTupleManager` from `nameparser.config`, and `lc` from `nameparser.util`, through a PEP 562 `__getattr__`: construction cannot reach a caller who only imports them. The library's own reads go through the private names and stay quiet. `from nameparser.config import *` warns for the three managers its `__all__` names, and `from nameparser.util import *` for `lc`, a cost recorded rather than designed around: dropping the names from `__all__` would remove them from a star import unwarned. - Importing `nameparser.HumanName`, `nameparser.parser` and `from nameparser import *` stays quiet (the inventory's A2 and A3): construction catches every real use, and a warning on the name would reach star-importers who never construct one. - The shared-`CONSTANTS` mutation message drops the private `Constants` it offered as an escape, which 3.0 removes too, and names `Parser(lexicon=Lexicon.default().add(titles={'dean'}))`; the hook-override message (#280) adds that `HumanName` is removed in 3.0. Every warning is a `DeprecationWarning` rather than a `FutureWarning`, emitted on every call at the caller's line and never once per process (a process flag lets a dependency's first `HumanName` consume the warning meant for the caller, the reason the 2026-08-15 bridge template warns per read location, and turns this suite's `filterwarnings = error` into an order-dependent failure under `--dist worksteal`). A `DeprecationWarning` is hidden by default outside `__main__`, so the release log, README and docs/migrate.rst announce the removal loudly instead. Every message names code that type-checks and runs, pinned by tests/v2/test_deprecations.py. `force_mixed_case_capitalization`, a removal candidate since 2026-08-29, is a `Constants` attribute, so removing `Constants` removes it and the construction warning is its removal warning; that follows from the facade's removal rather than being decided apart from it. Phase 4's output model is announced in one line of docs/migrate.rst ("details with the 3.0 alphas"), nothing undecided listed. - 2026-10-28 #925 (Derek), THE CORE API — THE 3.0 CORE API IS FROZEN EXCEPT FOR ADDITIONS: `parse`, `Parser`, `Lexicon`, `Policy`, `Locale` and `ParsedName` keep their 2.x shape through 3.0, gaining fields, members and methods but losing and renaming none. Anything 3.0 later finds it must break there is a new decision, and it needs a 2.x release warning about it first, the bridge discipline in this file's preamble; Phase 3's draft, which assumes `Locale` and `Policy` keep their 2.x shape after the facade's removal, rests on this entry. derek73-python-nameparser-456b024/docs/design/decisions/A1-ambiguity-report.md000066400000000000000000000702201527303271700271550ustar00rootroot00000000000000# A1 — the ambiguity report ## Standing - A report whose fork the rest of the parse resolved to neither branch is withdrawn in assemble, the one place that sees final roles: a connective-or-initial letter roled suffix or title loses that report and keeps its own, so `JOHN QUINCY SMITH I` reports `suffix-or-name` alone — 2026-09-20 #397. - The decision stays at its emitter and assemble words the field from the word's final role (`PendingAmbiguity.field_tail`), so a report names the field its word lands in whatever rule moved it: `, Ma Dr.` reports 'Ma' "read as a family name", and `Kim Min Do` under FAMILY_FIRST reports 'Do' read as a family name; a placeholder in the detail was declined — 2026-10-08 #626. - A report on a part past the second comma names no field: `John Smith, Jr., Dr. Bart` reports "segment 'Dr. Bart' beyond the recognized comma structures; read best-effort"; naming every field the part's words land in was declined — 2026-10-10 #629. - The listed-member report after a family comma names the role the comma binds, worded at the emitter: `John, Si.` reads title 'Si.' and reports "read as a title" (its example was `John, Ma.` until decisions/H2 made `Ma.` there a given name written short) — 2026-10-21 #793. - A report of a fork carries the reading it did not take, `Ambiguity.alternative`: a `Reading` of a kind, a source (`vocabulary`, `declared`, `shape` or `position`) and the field the word would land in with every other word read as the parse read it, named at the emitter, deferred to assign or assemble where a later stage knows the field, and withheld where no field could hold it (a connective letter opening its part, `E SMITH JONES`); a report of a shape carries none, and the field is not compared — 2026-10-26 #752. Elsewhere: P8's "connects nothing", withdrawn once over final roles in assemble, is decisions/S10's 2026-10-18 #722's /simplify pass entry. Open: none in this file. ## Log - 2026-09-20 #397 (second review) — A REPORT MUST NOT CONTRADICT THE READING BESIDE IT, and the shape could not arise before this cycle. `classify`'s `conjunction-or-initial` fork offers exactly two readings and its detail says which it took ("it is read as an initial"). Where the rest of the parse then roles that letter SUFFIX, the fork resolved to NEITHER branch: `JOHN QUINCY SMITH I` carried the connective-or-initial beside a `suffix-or-name` saying the same token reads as a generational suffix, and `john smith i`, `HENRY I`, `SMITH, JOHN I`, `JOSEP CAROD I ROVIRA III` and `abdul e i` were the same. `i` is the first word that is both a MARKED connective and suffix vocabulary, so no parse before #397 could reach it — which is why the rule is written down now rather than having been. DECIDED: the report is WITHDRAWN where the role is SUFFIX or TITLE, in `_pipeline/_assemble.py`, the one place that sees final roles and the ambiguity list together — and beside a withdrawal that was already there, for a report whose referents did not survive assembly. That is the narrow reading of mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE rather than an exception to it: classify still owns the fork and still takes it where it is taken; what goes is a report whose subject the parse went on to read as something else. Emitting later was the alternative and was rejected as more invasive for the same answer — the fork's inputs (the case class, the own-words span) live in classify and moving the emitter moves them. MEASURED: over the review's 43,925-name grid under eight configurations, 9,806 rows on 1,637 distinct names lose exactly one `conjunction-or-initial` and nothing else — no report of any other kind moves and none is gained. On the deduped corpus-union-cases population the only name affected is `JOHN QUINCY SMITH I`, a row this round added, so the ledger's three-name `fix(#397) the Catalan link reports a connective-or-initial in a one-case name` rule is untouched: all three of its names role the letter MIDDLE and all three still report. The invariant is `tests/v2/test_properties.py::test_no_report_contradicts_the_reading_beside_it`, which states it twice — no connective-or-initial on a token roled SUFFIX or TITLE, and no token named by two reports whose details assert different readings — and fails on 8,204 rows at `dc3bdf9c`, 0 here. - 2026-10-08 #626 (its literal wording for the comma's credential reading superseded in part by #793 below) — A REPORT NAMES THE FIELD ITS WORD LANDS IN. Six of assign's reports worded the field from the role the word held at assign (`read as a given name`), and rules in post_rules still move a word after that: H1 moves the name behind a title to the family, P1's family-first fold takes the particle run and one unit and lays the rest out by the order, P6's attachment takes a trailing name word into the family at the end of a name read family-first, and under opt-in policies the patronymic rotations (O1, O2) and `middle_as_family`'s fold (O3) move one too. So `Attorney General of Minnesota` reported 'General of Minnesota' "read as a given name by convention" with the unit in the family (since 2.3.0, #491's join clause); `Kim Min Do` under FAMILY_FIRST told the caller 'Do' was "read as a middle name" with 'Do' in the family, and `de Kim Ma` under FAMILY_FIRST said 'Ma' was a middle name with 'Ma' the given name (both this cycle, since #289 made the Title-case word a name word); `, Ma Dr.` said 'Ma' after the comma was "read as the given name" with 'Ma' in the family (this cycle, since #289 added that comma report; 2.3.0 reads the same fields and reports nothing); and `Van Ivan Petrovich` under the East Slavic patronymic rule said the leading 'Van' was "read as a given name" after O1 rotated it into the family (at 2.3.0 too). #614's second review had fixed one instance of the class (`Freiherr von Berg Ma`) by moving its report to the stage that knows the outcome. DECIDED: the DECISION stays at its site and the WORDING of the field moves to assemble, the one place that knows final roles (the same reasoning as the 2026-09-20 withdrawal above). An emitter whose detail names a field a later rule could change writes the sentence up to the field and hands the rest as `PendingAmbiguity.field_tail` (`""` where the field ends the sentence); assemble joins them around `FIELD_READING` of the first referent's final role. All six such emitters in assign use it -- H4's all-suffix name, title-or-name's join clause, O5's lone word, the trailing peel's picks, the leading ambiguous particle and the comma report on the word after a family comma -- including those right on every measured input, so no later rule can make them wrong. A report in a name emptied for want of content is withdrawn rather than shipped half-worded (no emitter reaches that today). A placeholder in the detail was declined: token text can carry any characters, and a name containing the placeholder would corrupt its own report. Reports worded where the field is settled keep their literal wording: a comma's credential reading, the suffix run's and the numeral fork's, H4's "the name" and P6's own "joins the family". The comma report's given-side wording moves from "the given name" to "a given name" with the rest. MISSED, then caught in review: the first draft converted five emitters and its grid had no empty-head comma with a trailing title, the shape nameparser/AGENTS.md's comma sweep already asks for; four of the review's five readers found the sixth emitter on `, Ma Dr.`. The draft also named only H1 and P6 as movers; P1's fold was found by stepping the stages, and the review of the fix found the opt-in movers O1 to O3, which no grid over the three orders reaches -- reverting the leading-particle emitter passed the whole suite until rows under those policies pinned it. MEASURED 2026-10-08, each report's field phrase compared against its tokens' final roles. RECIPE: texts are the 1,536 names of `tools/differential/corpus*.jsonl` and 347 `tests/v2/cases.py` texts (the table at 0c54662b, the parent of #614's first commit, less the corpus names; today's table adds rows this entry's review wrote for the shapes the grid missed, so it reads more wrong claims on master, 6 more as measured on the review), plus every head in {'John', 'John Smith', 'John Q. Smith', 'Mary Ann Smith', 'John van Smith', 'Juan de la Vega', 'John van', 'anh van', 'Jan Freiherr von Berg', 'Freiherr von Berg', 'Freiherr St', 'St', 'Dr. St', 'Freiherr', 'Dr.', 'Mr. and Mrs.', 'Attorney General of', 'abdul salam', 'Juan y Garcia', 'Jane Doe nee van der Berg', 'Dr. John Smith', 'JOHN SMITH', 'john smith', 'Smith', 'de Mesnil', 'Dr. de Mesnil', 'Kim Min', 'Kim Min Jun', 'Ortega y Gasset', 'van', 'Do', ''} followed by one or two words (repetition allowed) from the list [PhD, MA, Ma, DO, Do, do, vd, mc, Jr, III, V, V., VI, X.Y.Z., Ph. D., Esq., Prof., Sir, Ed, van, and, Jones, ba, John, Smith, Minnesota, de, St, Freiherr], space-joined and stripped; 20,000 draws after Python's `random.seed(626)`, each `random.choice(heads)`, then `random.randint(3, 4)` words each `random.choice(words)`, space-joined and stripped, both lists in the order written here; then, unstripped, `{head}, {word}`, `Smith, {head} {word}` with any double space replaced by one, and `{word}, {head}` for every head and word, and `, {w1} {w2}` and `(Bob), {w1} {w2}` for every ordered pair of words; the empty text dropped and duplicates removed -- 53,517 texts; each parsed under the three orders, so the opt-in movers are outside these figures; a claim is a match of `(reads? as|joins) ()` for the phrases `_FIELD_CLAIMS` in tests/v2/test_cases.py lists plus "the given name", and it is wrong when its tokens' roles are not among the fields the phrase names. On master (26cdb891) 2,725 of 89,092 claims were wrong, in three emitters: title-or-name's join clause (1,575), the trailing peel's picks (1,114) and the comma report (36); on the tree, 0. The comparator is master, which words the field at assign; the tree words it at assemble. Fingerprint against master over the differential corpora, master's case-table names and a seeded comma grid (13,090 names) under six policies (the three orders, `lenient_comma_suffixes=False`, a `/` suffix delimiter and `CapsSuffixes.OFF`): no role, kind, initials or rendering moves; the details that change are 6,395 comma reports on 1,086 names reworded from "the given name" to "a given name", 85 on 15 names now naming the family they land in, and 40 title-or-name join reports on 10 names now naming the family. Pinned by `test_a_report_names_the_field_its_word_lands_in` in tests/v2/test_cases.py, over every case row under all three orders except the locale rows (a row with its own policy only as declared), whose recorded control is 14 failures on master, and by `test_the_field_sweep_sees_the_claims_it_checks`, which records how many claims that sweep checks; the opt-in movers by the rows `Van Ivan Petrovich` (East Slavic), `Van Ali Veli oglu` (Turkic) and `Do Bishop Do` (FAMILY_FIRST with `middle_as_family`), which the sweep reads as declared. - 2026-10-10 #629 — A REPORT ON WORDS IN TWO FIELDS NAMES NEITHER. Segment's `comma-structure` report on a part past the second comma said the part was "consumed as suffix best-effort", true through 2.3.0, when the whole part read as suffix. Since #603 (rules/C2) a title word there reads as a title, so `John Smith, Jr., Dr. Bart` was told 'Dr.' was a suffix with 'Dr.' in the title (this cycle, never shipped). DECIDED: the report names no field: "segment 'Dr. Bart' beyond the recognized comma structures; read best-effort". It says what is certain, that the part is outside the recognized comma structures, and the fields say where each word went. The input is malformed, so the report is not worth machinery: the fix is one string, and a part that reads wholly as suffix loses the word "suffix" from its detail, which is not contract (kinds are). Declined: naming every field the part's words land in, worded at assemble from the final roles like #626's `field_tail` ("consumed as title and suffix best-effort"). It was built (0d5fea40, PR #630) and reviewed: it needed a second tail on `PendingAmbiguity`, an assemble branch and an equality check in the field guard, and the branch withdrew the report from a name emptied of content (`", , -"`), against rules/A2's "ambiguities born from its punctuation survive on the empty result", which no grid caught because every grid word had letters. More code to say more about garbage input; the user-facing gain did not pay for it. MEASURED 2026-10-10, tree against master bc243869 over the names of `tools/differential/corpus*.jsonl` plus every `tests/v2/cases.py` text, each under the three orders, comparing every report's kind, detail and referents and the seven fields: only the detail of `comma-structure` reports moves, and no report appears or goes. - 2026-10-21 #793 — AMENDS #626's list of reports worded where the field is settled, for the comma's credential reading. The listed-member report after a family comma said "read as a credential" of a word the listing form binds as a title (`John, Si.`, `John, Ma.`), so that field was not settled by the wording alone. It now names the role the comma stage binds, "a title" or "a credential", worded at the emitter rather than through `field_tail`, since nothing later moves a word of the part the binding empties. The measurement is decisions/suffix-acronym-collisions' 2026-10-21 (#793) entry. - 2026-10-26 #752 (phase 2m) — A REPORT CARRIES THE READING IT DID NOT TAKE. decisions/vocabulary-model's 2026-10-23 #752 entry planned it: an `Ambiguity` carries the reading the parse did not take, and a word the credential's run takes by position alone is told from a word with a declared name reading by that reading's source, so it needs no new ambiguity kind. Derek decided on 2026-10-26 to ship it in 2.7 and to hold the reading taken, `Token.reading`, for 3.0. DECIDED: `Ambiguity.alternative: Reading | None`, declared `compare=False`, so two reports and two names compare as before and `ParsedName.comparison_key` is untouched. `Reading` is a frozen, slotted, picklable value of three fields, exported with its two enums: `kind` (`ReadingKind`: credential, generation, agnomen, post-nominal, title, particle, conjunction, initial, name), `role`, and `source` (`ReadingSource`). Its FIELD is the one the word would land in had the call gone the other way with every other word of the name read as the parse read it, which is what a caller moving one word needs. A non-name reading names its field by what it is: the suffix for a credential, a generation, an agnomen or another post-nominal, the title for a title, the family for a particle (rules/P2). A name reading takes the field the positional read gives one more name word in its place: the last name word's under the order that read the part (`JOHN SMITH MA` gives `family`, under FAMILY_FIRST `middle`), the given part's given or middle name after a family comma, the family before one, the maiden name in a marker's clause, and the given name of the listing form where the comma's decision bound the part as a credential run (`John Smith Jr., AD` gives `given`). Where the name piece in front would take the word in, it lands where that piece lands, named at assemble from the piece's final role: a particle chain, which runs to the end of the part's name words (`Juan de la Vega VI` under FAMILY_FIRST gives `given`, the chain's field), a lone particle not opening the part, which stood unchained only for want of a word behind it, and a bound given name opening the part where a family comma or a given-name title licenses its join (`Rahman, Abdul MB ChB` gives `given`, `Sir abdul VI` too); a particle P6 attached is not trailing once the word stands behind it, so it takes nothing in, and a bound join is no chain though `abu` is a particle too (`Smith, Abu Mary VI` gives `middle`). A never-given particle opening the part takes the word into the family by rules/P1's fold, in every order and part (`Dr. mc VI`, `Smith, Dr. de VI` give `family`). O5's lone word names the other primary field from its final role, after any move, and `middle_as_family` folds a middle into the family as it folds the parse's. DECIDED: four sources, where the RFC's "Readings on the parse" named three. `vocabulary` is a listed word in the reading's category, `declared` a word the vocabulary also declares a name (the derived markers of decisions/vocabulary-model's 2026-10-25 #752 entry, and a given name written short), `shape` a roman numeral, dotted letters or a one-letter initial, and `position` an ordinary word nothing lists, a name only by where it stands: `John Smith PhD Jones` gives `position` for `Jones` where `JOHN SMITH MA` gives `declared` for `MA`, which is the case the plan needed the source for. A shape-admitted member of the ambiguous class carries the class's tag beside the shape tag, so the shape tag decides that it declares no name. DECIDED: every emitter of every fork kind fills it at its decision site (mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE). Two stages run before the field exists, classify's connective-or-initial letter and group's particle chain, and defer it (`_state.Deferred.JOINED` and `UNCHAINED`) for assign to name off the positions it reads: the connective's join takes in the name word on each side, so where those stand in two pieces the joined unit takes the place of the first (`JOSE E MARIA SANTOS` gives `given`), and where both are already inside the letter's own piece the join changes nothing the piece did not hold and the letter lands where its piece lands, named at assemble (`MARIA DA SILVA E SOUZA`, a case row, gives `family`); a join that would leave the name one word is none, rules/P3 keeping a single-letter connective in a three-word name a name word, so there too the letter keeps its own field (`JOHN E SMITH` gives `middle`), where a family comma's surname is the word that lets the given part join (`SMITH, JOHN E MARY` gives `given`); the unchained particle stands as one more unit in front of its piece. One assign cannot place stays unnamed and surfaces as no alternative, in two shapes on 2026-10-26: a connective letter opening its part, which has no word before it to join, so no field could hold the reading (`E SMITH JONES`, a case row since; `e j smith` and `e and e` in the committed corpora), and in the case sweep the chain's report in `Freiherr von Berg Dr. and Ed. Prof.`, under each order, whose chained piece ends in a title a connective's join made. The first was found by the design-docs review; rules/A1 names it. The three kinds that report a shape (comma-structure, unbalanced-delimiter, segmentation) carry none; `order` is reserved and emitted nowhere. ACCEPTED: a listed suffix word that is neither a generation by its tag nor an agnomen reads as `post-nominal`, so `jr` and `esq` alike, because telling them apart needs the per-word kind the reading index below would carry; only H4's input of nothing but post-nominals reaches it (`Rinpoche`). And the field holds every other word fixed, so it is not the field a re-parse with the word's reading removed would give wherever that re-parse reads something else differently: on 2026-10-26, 14 of the sweep's (row, order) pairs differ from a plain stand-in word that way. In 11 the stand-in moves a neighbour's reading (`John Smith VI Jones`, where a plain word for `VI` frees `Jones` from the run); in 3 it moves its own, `Andrew Perkins (X.Y.Z.)`, whose dotted content rules/S1 reads as if written bare, so its name reading is the bare word's, `family`, where a plain word in the brackets stays a nickname. A further 296 reports (299 once the PR review's `MARIA DA SILVA E SOUZA` row joined, 302 with #903's `Nasreen Sultana, MBBS`) cannot be checked that way at all, since the stand-in lands in the taken field: their decision was the position's. DECLINED: the reading's `languages`, the files holding it. It needs a per-word reading index compiled into `_compiled.py`, the first parse-time use of the TOML's descriptive data. Measured 2026-10-26 on Python 3.11 on Derek's machine with bytecode cached, as an installed wheel has it: an index of the 1,790 words that hold a reading, 1,975 readings by word and kind, each with its files, added as a dict literal at the end of `_compiled.py`, costs about 0.35 ms of an 18.2 ms `import nameparser` (the median of 30 fresh processes, twice each, under a `PYTHONPYCACHEPREFIX`), about 2%, and a lookup costs each report a `_normalize` call. RECIPE for the index: for each `nameparser/vocab/*.toml` file and each `[[word]]` table, every table-valued key but `excluded` is a reading; group them by `text`, then kind, collecting the file stems. The cost is small; the reason is that the index describes the default vocabulary, not the `Lexicon` a parse used, so a word a caller or a pack adds would report no language and a word one removes would still report its file. It waits for 3.0 with `Token.reading`, which needs the same index. DECLINED too: naming a name reading's field by parsing again with the reading removed, which is exact and costs a parse per report, quadratic over the scaling shapes that report once per unit. DECIDED: the pickle guard widened. `_types._guarded_setstate` refused any pickle whose field set differed from the class's, so the new field would have broken every `ParsedName` pickled with a report. A field added after its type shipped now carries `metadata={ADDED_IN: "2.7"}` and a default, and a pickle missing it loads with the default; a missing field without the marker (with a default or not: `Token.tags`, `ParsedName.ambiguities`), an unknown field and a renamed one are still refused. MEASURED 2026-10-26: each of the seven refusal rows in tests/v2/test_types.py raises under the pre-2.7 guard as under this one, and the admitted row and a `ParsedName` pickled by the 2.6.0 wheel (committed as tests/v2/data/parsedname_v26.pickle) raise under the pre-2.7 guard and load under this one; pickles from the 2.0.0 and 2.3.0 wheels load too. DECIDED: the snapshot records the alternatives, one string per report that carries one, in report order (`GIVEN_OR_FAMILY: name as family (position)`), so a later change moving one shows in the snapshot's diff, and losing one of two identical ones does too (`DOE, JOHN MA JD` holds two; the first draft recorded a set, which the PR review found hiding that). A snapshot's first line names the keys its recording tree could record (`snapshot.HEADER`), and a comparison leaves out a key either side did not record, read off that header rather than off whether a row holds one, which a 2.7 snapshot of names no report reaches would have failed; a snapshot without the header, a tag's or a local corpus's, records no alternatives (`snapshot.RECORDED_LATER`). `compare.py --baseline` against a release before 2.7 leaves them out too, its worker saying the wheel has no `Ambiguity.alternative`: on 2026-10-26 `--baseline 2.6.0` reported 1,088 entries moved with them compared, 867 of them only the key appearing, and 221 without, the role and ambiguity moves alone; `--since v2.6.0` reported 357 before and after. RECIPE for the measurements below: `uv run python tools/differential/snapshot.py`, `uv run python tools/differential/cldr_agreement.py`, and for each local corpus `snapshot.py --local --base ` and `local_agreement.py psc-individuals --base `. MISSED, then caught in review: the first draft named four fields wrong, each found by the code review's stand-in oracle on shapes no case row held. A credential or numeral with no name word before it counted the name word the carve-out makes as none (`PhD VI` gave `given`, where `family`). A numeral behind a particle P6 then attached took the particle's attached field (`Smith, John van VI` gave `family`, where `middle`), its test of P6's tag asked before P6 wrote it. A bound given name took a numeral behind it with no family word left for its join (`abdul VI` gave `given`, where `family`). And P6's own reports named the particle's field without asking whether the chain in front would take a name word in (`Juan de la Vega van` under FAMILY_FIRST gave `middle`, where `given`). The second review, of the fix commit, found two of those fixes narrowing elsewhere: the chain test the P6 reports now asked took a bound join for a chain (`Smith, Abu Mary van` gave `given`, where `middle`), and the bound join's licence was granted to a family comma alone, not to a given-name title (`Sir abdul VI` gave `family`, where `given`); it also found the P1 fold unmodelled (`Dr. mc VI` under FAMILY_FIRST gave `given`, where `family`). The PR's own review then found three more, and a review of its fixes a fourth. `capitalized()` rebuilt every report without its alternative, unseen by any test because the field is not compared, so the rebuilt name still equalled the original. A connective inside a joined piece got none, the join assumed to merge two pieces: 1,651 of the 4,967 connective reports in the tse-donors local corpus on 2026-10-26, 1 after the fix, the opening letter's. And the given-name title licence for the bound join was asked of every title token rather than of the leading run group's join asks through `_lexicon._run_addresses_by_given` (`abdul VI Sir.` gave `given`, where `family`; `Sir Dr. abdul VI` under FAMILY_FIRST, the run's last word not addressing by the given name, likewise). A review of those fixes found the connective's field ignoring P3's three-word carve-out, inherited from the first draft (`JOHN E SMITH` gave `given`, where its twin `John e Smith` reads middle 'e'; under FAMILY_FIRST `family`, where `given`). The shapes are rows of `_REVIEW_SHAPES` in tests/v2/test_cases.py, which fails 34 of its then 48 parses with the first round's fixes reverted, 14 with the second's, and 3 of its 57 with the third's licence fix; `test_an_alternative_has_its_recorded_kind_role_and_source` holds the reports the stand-in oracle cannot check (a credential run's, whose stand-in the same run reads, and a one-case connective's, whose stand-in is an initial too), one row per branch choosing a kind or a source, and the `middle_as_family` fold; and `test_capitalized_keeps_each_reports_alternative` compares the alternatives themselves. ACCEPTED, from the second review's grid of every one- to three-word run of 21 words in six comma shapes under nine policies: under `two_family_names` the field a chain in front ends in includes rules/O6's move, which the word behind it would have prevented (`John Freiherr V Freiherr` under FAMILY_FIRST gives `family`, where a stand-in lands in the middle name), and a bound given name behind a title-particle joins at group, where the particle still reads as a name word, which this model does not see (`Freiherr Abu VI` under FAMILY_FIRST gives `given`, where `family`). Both are left as they are: the first is an opt-in policy's, and modelling the second is modelling group. ACCEPTED as well, from the PR review: a one-case connective between two particles is taken by the chain in front rather than joined (`John Van e Van`, its mixed-case twin reading middle 'Van e'), so the alternative's `family`, the join the letter would make, is a reading the parse would not give either. MEASURED 2026-10-26 against master 957509d1: no reading moves. The committed snapshot matched before re-recording, and the re-record adds the key to 969 of its 6,476 rows and changes nothing else (the review's `E SMITH JONES` row, a new corpus name, joins without one, and the PR review's `MARIA DA SILVA E SOUZA` joins with one); every agreement record matches; the seven local snapshots (ctgov-officials, fec-individuals, jrc-names, nepal-candidates, tse-donors, tw-donors, ukec-individuals) and the PSC agreement match the base, and again against 6ba78d0d once #901's title batch was merged in. The reference row (`_CALL_BASELINE`, `tools/perf/call_count.py`) keeps its 305 frames and `Smith, John` its 158. Over the snapshot's names read in the default order, every one of the 789 names with one report pays 2 to 13 frames more (`JOHN SMITH MA` 215 to 223), the shape kinds' 2 for the conversion that finds no alternative, and 7 of the 4,840 names that report nothing pay 2 to 7 for a report made and then withdrawn (`jones, john e`). The case sweep's oracle, which re-parses each name reading with a plain stand-in word, checks 436 reports and skips 296 whose stand-in keeps the taken field (299 with the PR review's row, 302 with #903's); negative controls: with the trailing name reading pinned to the family it fails 63 parses, and without the piece in front taking the word in, 16. derek73-python-nameparser-456b024/docs/design/decisions/A2-empty-name.md000066400000000000000000000015611527303271700257310ustar00rootroot00000000000000# A2 — the empty name ## Standing - An input with no letter or digit parses to the empty name, where v1 kept `parse(".")` as given '.': `.` and `.,` read every field empty — 2026-07 PR #288. - A report born from the punctuation survives the emptying, so malformed input stays distinguishable from blank input: `(` reports `unbalanced-delimiter` — 2026-07 PR #288. Elsewhere: #629's declined field-naming report would have withdrawn a report from a name emptied of content (`", , -"`), against this rule; decisions/A1's 2026-10-10 #629 entry. Open: none in this file. ## Log - 2026-07 (v2 core, PR #288) — v1 kept parse(".") as first="."; 2.0 empties content-free input instead. The born-empty-ambiguity survival was a review fix in the rc1 arc: an unbalanced-delimiter report must outlive the emptying, or malformed input becomes indistinguishable from blank input. derek73-python-nameparser-456b024/docs/design/decisions/C1-suffix-comma-decision.md000066400000000000000000000435221527303271700300520ustar00rootroot00000000000000# C1 — the suffix-comma decision ## Standing - Only the part after the first comma decides, and the listing form stands unless more than one word precedes the comma, so `Smith, PhD` keeps it — 2026-07 PR #288. How the words are counted is decisions/C3's; the exception for a suffix word ending the name before the comma is decisions/C12's. - The decision consults the suffix vocabulary by definition — 2026-07 plan deviation #3. - The lenient token test is the default; `lenient_comma_suffixes=False` restores the strict one — 2026-07-12/13 PR #288. - The wholly-suffix predicate lives in the vocabulary layer, shared with the honorific peel's segment test — 2026-08 #319. - The comma is decided once, after classify, and a part read as postnominal is bound before any join; a connective surname counts its words, nothing after the decision unsettles what it bound, and a positional read before a suffix comma reports as the comma-less name does — 2026-10-06 #613, decisions (1)–(4) and the costs accepted with them. - The CJK stage asks the comma decision itself rather than a copy of it — 2026-10-06 #613, the CJK radar tier. - An empty part before the comma is read by the decision, not left to the listing walk — 2026-10-06 #613, the PR review. - A bound caps word that carries the name's case contrast keeps its capitals' marks, so case repair leaves it as written (rules/R4) — 2026-10-06 #613, the PR review. - A generation ending the name before the comma meets paired initials after it this way: dotted, they stay the given name and the generation the suffix (C5); undotted as two capitals, they are the credential (C12); a roman numeral S10 reads stays the suffix too — 2026-10-19 #761; 2026-10-25 #774. Not standing: 2026-10-04 #611's "the rethinks are filed, not done" — #613 rethought C1 (folding P6 in) and #614 S2, both shipped in 2.4.0, which overtook its "neither for 2.4". Open: none in this file. ## Log - 2026-07 (v2 core, PR #288) — v1 parity: only the second segment decides (v1 parser.py:1318), and ">1 word before the first comma" is v1's guard, which is why "Smith, PhD" keeps the listing form. The strict/lenient knob's blast radius on default vocabulary is exactly the single-letter Roman numerals i and v — the only initial-shaped members of the shipped suffix words — which is what makes rules/C1's "V." examples the canonical pair. - 2026-07 (plan deviation #3, recorded) — the decision is definitionally vocabulary-dependent: there is no way to recognize a credential run without consulting the suffix word lists, so the structural stage reads vocabulary through one predicate. - 2026-07-12/13 (v2 Policy work, PR #288) — the lenient token test is the default and `lenient_comma_suffixes=False` restores the strict one. (An earlier entry here credited #291/#296 — git author dates place both the field and its wiring in the Policy commits, and the #291/#296 design doc never mentions the knob.) - 2026-08 #319 — the wholly-suffix predicate was lifted into the vocabulary layer so the comma decision and the honorific peel's segment test cannot drift apart. - 2026-10-04 #611 — C1, S2 AND P6 GREW BY PATCHING, AND EACH SHOWS THE DECISIVE SIGN; THE RETHINKS ARE FILED, NOT DONE. The question was docs/design/AGENTS.md's, asked of the three longest rules after M2 (#601): does a guard model what another stage would do? C1: `segment` predicts group's particle chain (the #562 pair test) and assign's capitals reading (the skip #562 broke), mirrors classify's tags in its unit counts, and decides the structure in two stages, assign re-deciding #296's positional read with a second count -- the copy #603 extended. S2: the words-to-spare count runs over pieces group merges, so the chain re-asks the peel and rolls back (`John van Mc`), P5 compares two views, group emits S2's report, and the peel runs up to five times a parse. P6: assign holds back exactly the particle tail post_rules will attach (`particle_tail(..., sticky_from)`). Decided: #603 shipped as a targeted change inside today's machinery; the rethinks are #613 (C1, read the comma once after classify and bind, P6 folded in) and #614 (S2, read the trailing run once over units and bind), C1 first because S2's unit count should be shared with it, neither for 2.4. - 2026-10-06 (Derek), #613 — C1 IS DECIDED ONCE, AFTER CLASSIFY, AND THE PART IT DECIDES IS BOUND BEFORE ANY JOIN. #611 found the decisive sign in C1: segment decided the comma structure before any word was tagged, so it modelled what later stages would do -- group's particle chain unsettling a run the capitals settled (#562's pair), assign's reading of a capitals-settled run -- and assign re-decided #296's positional read with a second name-word count. The decision now runs at the head of group (`_comma.decide`), once classify has tagged every word: the part after the first comma is read ONCE by `segment_suffix_reading`, the reading a part holding no name word gets, with the ambiguous words the count licenses passed in, and a postnominal part's SUFFIX and TITLE roles are bound there, so no later stage can read it differently (mechanisms.md#READ-WITHOUT-THEN-BIND, its second instance after M2's #601). segment hands every comma form over as FAMILY_COMMA and keeps C2's flag; script_segment, which runs between, asks its own narrower question (`_postnominal_behind_a_whole_name`, the vocabulary-only suffix test behind two surname units, its peeled tail excluded). Deleted with it: segment's decision block and #564's contrast (moved into `_comma`), classify's comma-run caps branch, assign's comma reading, positional count and second report, `_vocab.run_word_fold`, and the settled-run mirror test in `test_properties.py`, whose whole job was to hold segment's model of group equal to group -- with one decider there is no model left to hold. DECIDED (Derek, 2026-10-06), each measured before the code was written: (1) a CONNECTIVE surname counts its words before the comma, as #575 decided for every surname but a particle chain -- `Ortega y Gasset, Dr.` reads title 'Dr.', given 'Ortega', middle 'y', family 'Gasset' (b547c247: family 'Ortega y Gasset'), and PR #615's Vega rows, which had pinned the connective as one unit, reverse; (2) nothing after the decision unsettles what it bound, so #562's pair report goes silent (`John Smith, PhD vd DO` keeps suffix 'PhD vd DO' and loses `suffix-or-name`) and a part of credentials holding particles side by side is the credential run behind any name (`De La Cruz, DO DO DO` reads suffix 'DO DO DO', b547c247 given 'DO', middle 'DO DO'); (3) no particle run, connective join or bound given name reaches into a part read as postnominal -- `John Smith, Ph. D. and Mary Jones` reads suffix 'Ph. D. and Mary Jones' (b547c247: family 'John Smith', given 'Ph. D. and Mary', middle 'Jones'), `De La Cruz, MA DO Mr.` reads suffix 'MA DO', title 'Mr.' (b547c247: given 'MA', middle 'DO Mr.', the chain having taken the title), and the one join kept inside the part is P3's connective binding title words into one title (`John Smith, Mr. and Mrs.` keeps title 'Mr. and Mrs.', `_comma._title_chains`; the first draft lost it, and the corpus now carries the name); (4) the positional read of a name before a suffix comma reports as the comma-less name does (`Freiherr von Berg, DO` gains the chain's `particle-or-given`, as `Freiherr von Berg` reports), and a part before the comma that is one name word with a suffix in it is the listing form's family rather than a positional read: `Smith Jr., Esq.` reads family 'Smith', suffix 'Jr., Esq.', and `Smith Jr., V` given 'V', family 'Smith', suffix 'Jr.', as `Smith Jr., John` reads (b547c247: given 'Smith' with `given-or-family`, and given 'Smith', suffix 'Jr., V'). ACCEPTED WITH THEM, found in measurement or review rather than decided ahead. A connective between a credential and a title joins nothing, since it takes its kind from the credential in front: `John Smith, PhD and Dr.` reads suffix 'PhD and', title 'Dr.' with a report (b547c247: given 'PhD and Dr.'), garbage either way, pinned as the cost of (3). The split credential written around another word is not merged, merging being for adjacent halves: `John Smith, Ph. Ma D.` reads title 'Ph.', given 'Ma', middle 'D.', family 'John Smith' (b547c247: suffix 'Ph. Ma D.'), garbage too. A LISTED member written with a period reads as it does without one behind a whole name, the count licensing it before the lone-piece route's period inference can (`John Smith, Ma.` suffix 'Ma.' as `John Smith, Ma`; b547c247 title 'Ma.'), and `John Smith, PhD Ma.` reports the count's flip over the part where b547c247 reported the anchored pick, as `John Smith, PhD Ma` always did. The garbage `Ph. D., Jr.` reads family 'Ph. D.' (b547c247: title 'Ph.', family 'D.'), its watched row re-recorded in compare.py. THE CJK RADAR TIER, settled in review and then by Derek. script_segment runs before group, so it cannot read decide's answer, and it needs one: whether the part after the comma is the postnominal part of a whole name decides whether the name before it divides and where a glued honorific peels from. The first draft asked a hand copy of C1 there, by the vocabulary alone, and the copy drifted from decide in four review rounds -- the ambiguous class's count (`김민준 박, MA` read as a credential run behind a two-word name while the name stayed whole), decide's numeral refusal and policy-selected suffix test (`김민준 박, MA V` divided behind a family comma, family '김 민준 박'), a suffix before the comma counted as a name word (`김민준 Jr., PhD` divided, the division then making decide agree), and the split `Ph. D.` (`김민준 Ph. D., PhD`) -- each drift dividing a name decide read as a family comma, or leaving whole one it read as a suffix comma. DECIDED (Derek, 2026-10-06, the /simplify pass): script_segment asks `_comma.decide` itself, on a classified copy of the name as written, and only where the answer can change something (the peel once its two candidate sites differ, the division once there is a token to divide), so no Latin or accented-Latin comma name pays for it. The division now follows decide in both directions (`test_the_division_never_outruns_the_comma_decision`: on 3320d700, the last tree with the copy, 0 of its 300 parses divided behind a family comma and 44 stayed whole behind a suffix comma; now 0 and 0). Against b547c247 that restores every reading the copy had moved (`Smith 김민준씨, XYZ`, `John 田中さん, XYZ`, `김민준 박, X.Y.Z.` divide and peel as master read them), and moves one class master read otherwise: a title and one CJK name word before a credential the capitals license, which decide reads as a suffix comma -- `Dr 김민준, MA` reads title 'Dr', given '민준', family '김', as `Dr 김민준` does (b547c247: family '김민준', the division declined and H1 making the one word the family), and the pre-existing tolerated row `Dr. 김민준씨, MA` moves with it; so does a glued honorific in a part decide reads as postnominal, `John Smith, Dr. J.씨` reading title 'Dr.', given 'John', family 'Smith', suffix 'J.씨' as `John Smith, J.씨` always did (b547c247 peeled the 씨 from the part and read given 'J.', family 'John Smith'). Measured on the /simplify pass's 78,282-parse fingerprint: 1,106 parses move, every one a CJK name before the comma or a `J.씨` after it. `마틴 킹, MA` reads as master did; its report counts three name words, decide reading the divided name. `_vocab.surname_unit_count`, which only the copy asked, is deleted. DELIMITER CORES are dropped only behind a whole name, which is rules/C10's Accepted v1 parity (`Smith, MD - PhD - FACS` keeps its cores): the first draft dropped them behind a one-word family too. WHAT MOVES: over every corpus name and an 80,423-text comma grid under three policies, 3,631 texts move, 1,773 in a role, every default-policy role mover in a class decided or accepted above; four corpus names move. Measured 2026-10-06 against b547c247 (PR #615's merge), PR #616. COST: the reference parse and `John Smith` are unchanged, and the comma shapes cost fewer frames or up to four more (`Smith, John` 179 → 160, `John Smith, MA` 249 → 253). `sys.setprofile` call counts against b547c247, PR #616. THE PR REVIEW, after the two rounds above. An EMPTY part before the comma had decide return early, leaving the part to the listing walk: `, PhD` read given 'PhD', `, Jr.` title 'Jr.', `, MD PhD` title 'MD', family 'PhD', where b547c247 and 2.3.0 read suffix -- decide now reads the part behind an empty head, which names no whole name, so the comma stays the family comma with the part bound (the heads of every grid above were non-empty, so none saw it). Three C-level costs the frame guards could not see are clock rows in `_PREFIXED_SHAPES`: `_title_chains`' title rebuild, the paired-initials test's per-pair copies and the core filter's list scan. And one render view: decide cleared its caps-shape marks whenever C1's run test declined, so a part #603's opener bound anyway kept its all-caps word without them, and case repair wrote `John Smith, PhD Jones XYZ` as '... PhD Jones Xyz' (rules/R4); the marks now stay on every bound caps word that carries the name's contrast, roles and reports unmoved over the harness (`test_a_bound_caps_word_keeps_its_capitals_in_case_repair`). SUPERSEDES: decisions/C4's 2026-10-01 #562 bullet's mechanism and its accepted reports (its pair report and the settled-run test); decisions/C13's 2026-10-04 #603 bullet's "segment decides" wording; and decisions/C3's #575 bullet's account of WHY segment builds its unit facts from vocabulary -- `_comma._whole_name` counts over classify's tags, and `name_word_count`, which reads the vocabulary, is asked by decide and by script_segment, the agreement sweep `test_surname_unit_tags_agree_with_classify` keeping the vocabulary view equal to the tags. - 2026-10-18 #728 — SPLIT. rules/C1 held what are now C1 and C3–C11, and this file their history; each entry moved to the file of the rule it decided, and the #429, #436/#437 and #511 entries, which decide R1's entry pass, to decisions/R1. The entries kept here moved nothing but the pointers the split broke and the measurement paragraphs, each cut to one line naming its population, counts, baseline and PR. The #611 entry stays here, an open question on #728. - 2026-10-19 (Derek), #761 — A GENERATION BEFORE THE COMMA AGAINST PAIRED INITIALS AFTER IT: DOTTED, THE INITIALS ARE THE GIVEN NAME AND THE GENERATION STAYS THE SUFFIX; UNDOTTED, THEY ARE THE CREDENTIAL. rules/C12 (a suffix word ending the name before the comma makes the comma a suffix comma) and rules/C5 (paired initials after the comma are the given name) both claim `John Smith Jr., M.J.`, and no rule text said which wins; the C section's move to the skeleton format, which gives a hub Precedence bullets, asked. Measured on 732a2716: `John Smith Jr., M.J.` reads given 'M.J.', family 'John Smith', suffix 'Jr.', and `John Smith Jr., MJ` reads given 'John', family 'Smith', suffix 'Jr., MJ', each reporting `suffix-or-name`. Decided: that reading is the rule, for every generation C12 reads, written in rules/C1's Precedence with both as examples and in C5's and C12's Precedence where they meet. It is the reading C12's Accepted clause (`Lloret Palol II, JA`) and its `Smith Jones Jr., J.A.` boundary already gave behind a two-word surname. The review found a roman numeral S10 reads by shape breaking it today: `John Smith V, M.J.` gives given 'M.J.', family 'John Smith V' and no suffix, while `John Smith V, MJ` reads suffix 'V, MJ'. So does every numeral S10 reads (`John Smith VI, M.J.`, `I`, `IX`, `X` alike). Derek ruled that a bug, so C1's Precedence carries the intended reading as two `deviates:` examples, V for the one-letter count and VI for the shape-only path, tracked by #774. The same pass collected into C1's Precedence the order among C4, C5 and C6 that their own texts already state (C4 counts name words first and asks written case second, C5 is the exception to that count, C6 gates when an unlisted all-caps word joins the class), deciding nothing new there; and the strict switch left C1 for rules/C14 (decisions/C14). - 2026-10-25 #774 — A NUMERAL ENDING THE FAMILY PART IS READ AS THE COMMA-LESS NAME READS IT. The family part's walk took a word into the suffix by the suffix-piece test alone, so a listed generation stayed the suffix (`John Smith III, M.J.` → suffix 'III') while `V`, which the initial veto refuses, and `VI`, which no list holds, went into the family (`John Smith V, M.J.` → family 'John Smith V'), the split rules/C1's #761 Precedence example tracked as a `deviates:` row. The walk now asks the numeral's predicate (decisions/vocabulary-model's 2026-10-25 2f entry) of a single-word piece shaped as a roman numeral with nothing but suffix pieces behind it: the generation where it reads as one, a one-letter numeral needing two pieces of the part in front of it, as S10's count asks of the comma-less name's pieces, and reported where a shape reads it, as the comma-less name reports. `John Smith V, M.J.` and `John Smith VI, M.J.` keep the numeral as the suffix, `Smith VI, John` reads suffix 'VI', and `Smith V, John` keeps family 'Smith V' (one word in front), silent, the comma having named the family. The two `deviates:` markers come off. ACCEPTED, found by the PR's reviews: a part holding a connective surname reads its one-letter numeral as its comma-less twin does only where the two join the connective alike. The part before the comma joins `Vega y Lopez` into one piece where the comma-less `Vega y Lopez V` leaves it unjoined, so `Vega y Lopez V, Mary` keeps family 'Vega y Lopez V' where the twin reads suffix 'V'. Counting a join's words instead fixed that row and broke the longer joins, which both spellings join (`Smith and Jones V, Mary`, `Ortega y Gasset y Lopez V, Mary`, family 'V' comma-less and suffix 'V' with the count), so the piece count stands. Also as on master: `St Smith V, Mary` (a title-particle is no title run before a family comma, rules/H6) and `Jane nee Smith VI, Mary` (the clause keeps the numeral) differ from their twins. derek73-python-nameparser-456b024/docs/design/decisions/C10-suffix-delimiter-parts-a-suffix-run.md000066400000000000000000000464641527303271700327020ustar00rootroot00000000000000# C10 — a suffix delimiter parts a suffix run ## Standing - A delimiter the policy declares parts a trailing suffix part as a comma would: each side reads as the same text with a comma in the delimiter's place, the core dropped, so no join, maiden walk or link reaches across it (`Smith, John, PhD - and MD` → suffix 'PhD, and MD' under `extra_suffix_delimiters={' - '}`) — 2026-10-03 #549. - Outside a trailing part, before the first comma or in the listing form's given part, the core stays a word (v1 parity), and a core inside a token keeps the token whole (`RN/CRNA` under `/`) — 2026-10-03 #549. - The semicolon is a suffix delimiter every policy has, beside the comma, and a delimiter glued to one end of a word after the first comma leaves the word read as the word without it, the text kept (`John Smith, MD; PhD` → suffix 'MD; PhD'); a semicolon before the first comma stays a word — 2026-10-24 #752. - A hyphen and a slash standing alone are suffix delimiters every policy has too, after the first comma (`John Smith, MD - PhD` → suffix 'MD, PhD'), but neither is read glued to a word or inside one unless the caller declares it, so `al-` and `MD/PhD` keep their readings and only the semicolon and a declared core glue — 2026-10-27 #889. Elsewhere: a maiden marker in a part after a suffix comma is an ordinary word, core or no core, so under `extra_suffix_delimiters={' - '}` `Smith, John, PhD née Puig - i Soler` reads suffix 'PhD née Puig, i Soler' with no maiden name, where #549 gave maiden 'Puig', and the clause #549 accepted losing beside a core (`Smith, John, MD - née Jones Smith` → suffix 'MD, née Jones Smith') is lost without one too (`Smith, John, MD née Jones Smith` → suffix 'MD née Jones Smith' under any policy), so the loss rules/C10's Accepted bullet calls the delimiter's cost is M2's reading now — decisions/M2's 2026-10-04 (Derek), #601 entry; `Smith, RN - CRNA` reads suffix 'RN - CRNA', the core a word of the run a credential opens — decisions/C13's 2026-10-04 (Derek), #603 entry. Open: none in this file. ## Log - 2026-10-03 (Derek), #549 (its maiden 'Puig' in `Smith, John, PhD née Puig - i Soler` superseded by #601, decisions/M2) — A DECLARED DELIMITER PARTS A TRAILING SUFFIX PART AS A COMMA WOULD, AND THIS REVERSES #538'S SKIP READING (decisions/M2, 2026-09-26). The question #549 asked was whether a core a connective join swallows should be skipped (the text read as if the core were absent) or should end the run it stands in; Derek's ground for the second was the one a caller has when declaring one: a delimiter set to ` - ` is meant to behave like a comma around suffixes, and the simplest parser that honours that is one where it IS a comma there. So `group` cuts a tail segment at its cores before grouping anything, groups each part as the comma twin's own segment would be grouped, and drops the cores, which post_rules' entry pass still reads back as boundaries. No join, maiden walk or link search can reach across a core because none of them ever sees one, and the machinery that let them step over it is gone: the `cores` parameter of `_group_segment`, `_maiden_take`, `_run_neighbours` and `_link_joins_inside_the_clause`, `_maiden_take`'s `seen` index list, the `skip` arguments of `peel_walk` and `trailing_start`, and the #206 drop block that ran after the joins. One call per parse fewer (`tools/perf/call_count.py`, py3.11, 2026-10-03: parse 369 against 370 at 061f02da). WHAT MOVES, against 1.4.0 and 2.3.0, measured 2026-10-03 with the released wheels (PR #600). A connective beside a core no longer joins across it: `HumanName("Smith, John, PhD - and MD", suffix_delimiter=" - ").suffix` is 'PhD, and MD', 1.4.0's answer, where 2.0.0 through 2.3.0 gave 'PhD - and MD'. `Smith, John, Puig - i Soler` and `Smith, John, PhD née Puig - i Soler` read as 2.3.0 did again ('Puig, i Soler'; maiden 'Puig', suffix 'PhD, i Soler'). A MAIDEN MARKER BESIDE A CORE TAKES NO CLAUSE, the larger cost by count: `Smith, John, MD - née Jones Smith` reads suffix 'MD, née Jones Smith' and `Smith, John, PhD née - Jones` 'PhD née, Jones', where 2.0.0 through 2.3.0 read maiden 'Jones Smith' and 'Jones'; rules/C10 records it as Accepted. At the default policy nothing moves. THE DECLINED READING, TAKEN. #538 declined the boundary reading because it moves `Smith, John, PhD née Puig - i Soler` from maiden 'Puig i Soler' to maiden 'Puig' with suffix 'PhD, i Soler', "a birth-name link pushed into the credentials". That is what the same text with a comma typed in the core's place reads, and it is what 2.3.0 read. The marker beside a core (WHAT MOVES, above) is the same trade at a higher price, a maiden name lost rather than a link moved, and it is taken on the same ground; the review of this change found it, the first draft of this entry having weighed only the link shape. The comma reading is accepted as the cost of one rule for a declared separator, where the skip reading kept two (a lone core dropped like a comma, a core beside a connective stepped over like a word) and #549 was the seam between them. INVARIANT, pinned by `tests/v2/test_properties.py::test_a_delimiter_core_in_a_tail_reads_as_its_comma_twin`: every ROLE field of a name with a ` - ` core in a part after the suffix comma equals that of the same text with a comma in its place, over 129 generated texts; 102 disagreed at 061f02da, 0 after. It replaces #538's `test_a_delimiter_core_reads_as_if_it_were_not_written`, whose invariant this decision reverses. Scoped to TRAILING parts on purpose: before the first comma, or in the given part of the listing form, the core is a word (rules/C10's Accepted entry, v1 parity), and a comma typed there changes the name's structure rather than parting a suffix run, so the twin is a different parse. The ambiguity reports are not part of it: C2 reports `comma-structure` once per extra comma part, so a typed comma can add a report the core does not (`Smith, John, Puig - Puig` against `Smith, John, Puig, Puig`). MEASURED 2026-10-03 against 061f02da (PR #600), over 19,972 texts generated after four heads plus the 1,505 corpus names: 2,624 parses move under ` - ` (2,384 in the suffix alone, 240 in suffix and maiden, every one of the 240 a maiden name lost to a marker beside a core), the same 2,624 under ` / ` and 2,528 under ` and `, with no other field and no report moving; one corpus name moves under each of two policies; and the comma twin disagreed on 752 of 2,100 texts for each of the `Smith, John,` and `Smith, John, Jr.,` heads before and on none after. SUPERSEDES, under M2: the 2026-09-26 #538 entry (its stepping, its invariant and its Not-repaired list, which this closes); the 2026-09-22 #397 follow-up's bound argument (a core can no longer stand between a marker and the clause's first word, the segment being cut at it first); and the #418 reorder entry's closing repair, which screened a tail's cores out of the marker pass so that `Smith, John, PhD née - Jones` read maiden 'Jones' -- it reads suffix 'PhD née, Jones' now, as above. Entries there that describe the walk stepping over a tail's cores (the #424 and #533 bullets) describe machinery this change deletes; they are dated and left as written. Not changed: a core OUTSIDE a tail segment is still a word (rules/C10's Accepted entry), and a core inside a token (`RN/CRNA` under `/`) still reads by `splits_into_suffixes` and keeps the token whole. - 2026-10-24 #752 (D15 of the vocabulary phase 2 spec) — THE SEMICOLON IS A SUFFIX DELIMITER BY DEFAULT, AND A DELIMITER GLUED TO A WORD AFTER THE FIRST COMMA READS AS THE WORD WITHOUT IT. ClinicalTrials.gov's study officials (`ctgov-officials`, data as of 2026-10-21) write a semicolon between credentials where a comma would go: 494 of their names hold one, 480 after a comma, and of those 377 glue it to the word before (`MD; PhD`), 95 put it inside a word (`MD;PhD`), 4 stand it alone and 4 mix the forms; every release from 1.4.0 through 2.5.0 read the glued forms as the listing form, given 'MD;', family 'John Smith'. Derek asked on 2026-10-24 for both to be investigated as a 2.x change, the semicolon as a default delimiter and a glued delimiter that counts; the design below is this entry's, not yet reviewed. WHERE THE DEFAULT LIVES. A constant beside `_vocab.delimiter_cores`, which every reader of the delimiters already calls, not a default value of `Policy.extra_suffix_delimiters`: that field is a set a caller replaces (the facade's `suffix_delimiter` replaces it too), so a default inside it would be lost to every caller who declares a delimiter of their own, and its docstring promised additions only. No policy removes the semicolon, as none removes the comma: Derek decided on 2026-10-24 that it gets no opt-out switch, built in like the comma. THE GLUED WORD, AND WHY EVERY READER ASKS ITS STEM. One predicate, `_vocab.glued_stem`, answers what a core glued to one end of a word leaves, and four sites ask it: classify tags a word after the first comma as its stem, `_comma.decide` reads the stem in its shape tests (the caps shape and paired initials), `is_wholly_suffix` reads it in the run test, and segment's C2 flag reads a glued title. The first draft read the stem in the run test alone, as the D15 spike had: over the officials it put 365 reversed names right. Tagging the stem put 412 right, the rest being words other rules read by their tags, a listed credential opening the part (C13), a word of the ambiguous class (C4) and a glued title; reading the stem in the comma decision's shape tests added the unlisted all-caps credentials C6 admits (`MBBS;`), 431 in all. Behind a head that is not a whole name the run test asks the strict token test, as C8 reads `Smith, V MD`, so `Smith, V; MD` keeps given 'V;'. JOINING WORDS JOIN NOTHING ACROSS IT. A stem's connective and bound-given tags are dropped: tagged in full, `Smith, Juan y; Maria` joined 'y; Maria' to 'Juan' as one given name, a connective reaching across the delimiter C10 says none reaches across. A particle's are dropped too, its backward attachment with them, Derek decided on 2026-10-24: the design-docs review found `Berg, Jan van; MD` reading middle 'van;' where both twins read family 'van Berg' (P6 attaching the particle backward, away from the delimiter), the tags were kept, and the second review then found the kept tags joining forward across the delimiter (`Berg, de; Smith` → family 'Berg de; Smith', P1's fold, and P2's chain inside the given part). Blocking the forward joins alone would guard every site that reads the particle tag, about a dozen against P6's two, for shapes no local corpus holds (no glued particle after a comma in the officials, FEC, TSE, PSC, UKEC or Nepal corpora); so a glued particle joins nothing, middle 'van;' as every release from 1.4.0 read it, and rules/C10's Accepted bullet records the cost. A WORD DELIMITER IS NOT GLUED. Only a core written without a letter or digit (`_vocab.glues`) is read glued: the review found ` and ` under a declared policy cutting `Stand` into `St` and reading it as a title (`John Smith, Stand` → title 'Stand'), since the core is found inside words by a substring test; a word core parts only standing alone, as before. ONE ANSWER WHATEVER THE SET ORDER. `glued_stem` tries every gluable core, in sorted order, and takes one standing once at an end of the word: the second review found it stopping at the first core the frozenset yielded, so under `extra_suffix_delimiters={'/'}` `Jane Smith, MD/PhD; MBA` read suffix 'MD/PhD; MBA' on 5 of 8 hash seeds and given 'MD/PhD;' on 3, the '/' inside the word hiding the ';' at its end. BEHIND ONE NAME WORD a semicolon standing alone stays a word of the run the credential opens (`Smith, MD ; PhD` → suffix 'MD ; PhD'), as a declared core does there: the parity reason rules/C10 gives for a declared core does not reach the semicolon, which no release read as a delimiter, and the reading is kept so that every delimiter follows one rule, not on its own merits; the officials hold no such name (the 4 standing alone all stand behind a whole name). MEASURED 2026-10-24 against 638685b4. Officials: 443 of the 494 move, 431 a reversed name put right, 7 a title read as a title where it had been the given name (6 with the semicolon glued), 2 a standing-alone semicolon now rendered as a comma ('MD, PhD', rules/C10's Accepted bullet), 1 a semicolon standing between a title and a credential no longer the given name, 1 a semicolon glued to the front of an unlisted credential, 1 a report alone; none read worse, each read by hand. The 51 that do not move: 14 hold the semicolon only before the first comma or have no comma (rules/T1's question, out of scope, with FEC's 8 the 22 names D15 set aside), and 37 after a comma hold a word no rule reads as a credential. FEC individuals (15 names with a semicolon, data as of 2026-10-18), TSE donors (none), the PSC agreement record, the committed snapshot (no name moved; the rules examples added twelve) and the CLDR and GND agreement records: nothing moves. INVARIANTS, in `tests/v2/test_properties.py`: `test_a_glued_semicolon_reads_as_its_space_twin`, over 924 texts after eleven comma heads, a second comma part or none and the three orders (726 of them disagreed at 638685b4), and `test_a_semicolon_standing_alone_in_a_tail_reads_as_its_comma_twin`, #549's grid under no policy (all 129 disagreed at 638685b4). A wider sweep, every one-to-three-word part from sixteen words (credentials, titles, particles, connectives, a numeral, initials, the split `Ph.`/`D.`, a marker) after seven heads under three orders, 269,136 glued texts: no text that read as its space twin at 638685b4 reads otherwise now; 70,941 moved onto their twins, 36,174 disagree with them as they did, 1,446 moved without reaching them, and the classes left are the glued joining words (particles, connectives) and the ones read by spelling, recorded in rules/C10's Accepted bullets (a split `Ph. D.;`, a numeral ending the given part). COST. Two frames fewer on the reference name (305 against 307, py3.11, `tools/perf/call_count.py --against 638685b4`) and three fewer on `Smith, John` (158 against 161, one parse counted by `sys.setprofile` after a warm-up), the default `delimiter_cores` returning the constant where it had built a set. - 2026-10-27 #889 — A HYPHEN AND A SLASH STANDING ALONE AFTER THE FIRST COMMA ARE BUILT-IN DELIMITERS, AND THEY DO NOT GLUE. #889 asked whether the default had learned enough that `suffix_delimiter` and `extra_suffix_delimiters` could go. Measured on 8a65ee18 over ClinicalTrials.gov's study officials (`ctgov-officials`, data as of 2026-10-21), after the first comma: ` & ` stands in 184 names, ` and ` in 140, ` - ` in 54, ` / ` in 38, ` + ` and ` : ` in one each, ` | ` in none. Declaring ` - ` or ` / ` put a reversed name right where a title stood before the separator (`Michael Thomas, Prof. / MD` read given '/', family 'Michael Thomas'), and otherwise split the suffix ('MD - PhD' → 'MD, PhD'). Declaring ` & ` moved 6 names' fields and broke a title phrase (`Gavin Solomon, President & CEO` → title 'President CEO'), and ` and ` moved 34, nearly all job descriptions (`Professor of obs and gyn`); so Derek chose on 2026-10-27 the hyphen and the slash only, and kept `extra_suffix_delimiters` for the separators a caller still declares (the en and em dashes, ` | `, ` + `, ` : `), which the officials write too rarely for a default. WHY THEY DO NOT GLUE. The first build added both cores to the built-in set whole, as the semicolon is, and broke rules/P6's `al- Rashid`: a hyphen glued to the end of a word is the Arabic article's own spelling (#700), and a slash or hyphen inside one is a joined credential (`MD/PhD`, #887). So the set splits in two: `_vocab.BUILTIN_SUFFIX_DELIMITERS` holds the cores that part standing alone (`;`, `-`, `/`), and `_vocab.glued_cores` the ones read glued to a word or inside one (the semicolon and every declared core). A caller who declares ` - ` still gets the glued reading the semicolon has, as before; a declared ` - ` reads `John Smith, V - al-` with family 'John Smith', the glued hyphen taken for a delimiter, where the built-in keeps 'al- John Smith', and the sweep below found no other difference between the two where the core stands alone. Accepted, and recorded in rules/C10: `John Smith, MD- PhD` keeps given 'MD-', family 'John Smith', as every release from 1.4.0 read it. WHERE IT REACHES. A sweep of 69,804 texts on 8a65ee18, under each of the three orders 23,268: every ordered pair of the twenty-three words `MD PhD M.D. RN CRNA Jr. III V Dr. Prof. Smith Jones Mary van de al- y and née FACS Ed Ma Chief`, joined by each of the seven separators ` - `, ` / `, `- `, ` -`, `/ `, ` /`, ` – ` (standing alone, glued on either side, the en dash) after each of the six heads `John Smith, `, `Smith, John, `, `Smith, `, `, `, `John Smith ` and `Smith, John ` (22,218), and every ordered pair of `Smith Jones Mary MD van` joined by each separator in the six frames `John A-B`, `A-B, John`, `John A-B, MD`, `A-B, John, MD`, `Mary A-B & John Smith` and `John & Mary A-B` (1,050); moved 4,686, every one with the hyphen or slash standing alone, behind a whole name or in a part after a second comma. None moved before the first comma, behind one name word or none, glued, or under the en dash, and no `&`-joined name moved. Of the 4,686, 4,650 read every role but the suffix as the same text with a comma in the core's place does; the 36 that do not are a title beside the connective `and` (`John Smith, Dr. - and` → title 'Dr. and'), where a typed comma makes a different structure. The design-docs review's wider grid (twenty-three words of its own, pairs after six heads, both cores, three orders: 19,044 texts, 8,952 moved) found the same class with `of` and on either side of the core (`Prof. - of Surgery`, `and - Dr.`, `Dr. / and MD`), 192 texts, every one a connective beside a title; a declared ` - ` read them so at 8a65ee18 too. Kept as rules/C10's last Accepted bullet rather than fixed, Derek decided on 2026-10-27: the title run reads connectives wherever it runs, and the officials hold no such name. MEASURED 2026-10-27 against 8a65ee18 with `--base`. Officials: 72 of 536,981 move, 7 a reversed name put right (a title or `Physician` before the separator: `Davut Deniz Uzun, Dr. / MD`, `Patrick Kutschar, Ass. - Prof. Dr.`), 2 reporting nothing where they had reported a fork, and the other 63 the suffix split at the separator ('MD / PhD' → 'MD, PhD'), 3 of them a separator dropped from the end of the suffix ('PhD /' → 'PhD'); none reads worse. FEC individuals, UKEC donors, TSE donors, the Nepal candidates, JRC-Names, the Taiwan donors and the PSC agreement score: nothing moves. The committed snapshot: 9 names, every one the suffix split (`Steven Hardman, MD - DO - DDS` → 'MD, DO, DDS', which #156 asked for); the rules examples added thirteen. The CLDR, GND, Wikidata and conventions agreement records: nothing moves. INVARIANT, in `tests/v2/test_properties.py`: `test_a_hyphen_or_slash_standing_alone_in_a_tail_reads_as_its_comma_twin`, #549's grid under no policy for each core; all 129 texts disagreed at 8a65ee18 for each. - 2026-10-18 #728 — SPLIT FROM decisions/C1 with the rule. The entries above were written under decisions/C1 while this rule was part of rules/C1, and moved here unchanged but for the pointers the move broke, the measurement paragraphs, each cut to one line naming its population, counts, baseline and PR, and the superseded passages, each cut to its decision and what replaced it; where one says rules/C1 or C1, it means the rule as it then stood, which held this one. derek73-python-nameparser-456b024/docs/design/decisions/C11-what-the-comma-decision-reports.md000066400000000000000000000277471527303271700320570ustar00rootroot00000000000000# C11 — what the comma decision reports ## Standing - A part read wholly as suffixes carries no report that a word of it was read as a name, so a particle chained or an acronym taken into a name is never reported there: `John Smith, Jr., Freiherr von Richthofen` reports only `comma-structure` — 2026-09-28 #544. - A word of the ambiguous class its capitals settle carries no report wherever it stands in the part after the comma (`Doe, MA PhD`, `John Smith, MA MA`, `Smith, MA PhD`, `John Smith, PhD MA`, `John Smith, MA` are silent), a word the capitals do not settle reporting as before (`Doe, Ma PhD`, `JOHN SMITH, MA`) — 2026-10-25 #785, superseding the 2026-10-01 report on an opening word; no `suffix-or-name` report lands past the second comma (`Smith, John, MA`, `John Smith, Jr., MA`) — 2026-10-01. - The part after a suffix comma reports each word a credential in front of it reads as the run although it may be a name word (`John Smith, PhD van der Berg` → `suffix-or-name` on 'van', 'der' and 'Berg'), and C2's no-name-reading clause means only a word read as a name — 2026-10-11 #641. Elsewhere: no particle chain reaches such a part at all, which replaced #544's withheld report list: the part after a suffix comma is bound before any join — decisions/C1's 2026-10-06 (Derek), #613 entry — and a part past the second comma makes no chain — decisions/C7's 2026-10-11 (Derek), #636 entry; `pair_only` asks `is_title_piece` — decisions/C5's 2026-10-19 (Derek), follow-up to PR #738 entry; the listed-member report names the role its word is bound to ("a title" for `John, Si.`, "a credential" for `John, MA`) — decisions/suffix-acronym-collisions' 2026-10-21 (#793) entry and decisions/A1's 2026-10-21 #793 entry; a maiden marker past the second comma being an ordinary word, so `Smith, John, Jr nee Jones MA` reads suffix 'Jr nee Jones MA' where the 2026-10-01 entry read a maiden clause — decisions/M2's 2026-10-04 (Derek), #601 entry. Open: none in this file. ## Log - 2026-09-28 (Derek), #544 (its mechanism superseded by #613, decisions/C1, for the part after a suffix comma and by #636, decisions/C7, for parts past the second comma) — A PART READ WHOLLY AS SUFFIXES REPORTS NO NAME READING OF ITS WORDS. group's particle chain runs over every comma segment, and its two emitters — `particle-or-given` when a particle behind a word of both the title and the particle vocabulary chains (since 2.0.0, de264af1) and `suffix-or-name` when the chain takes an ambiguous acronym into the name (#289/#516, 59d8f38a, in no release) — reported inside a TAIL segment, which assign reads wholly as suffixes. Each such report named a reading the parse never made, against rules/A1's "A report names the reading the parse took". A family comma's tail was already silent, since group hands the chain no report list anywhere after a family comma; the suffix comma's tails were not. Measured on the released wheels: `John Smith, Jr., Freiherr von Richthofen` reports `particle-or-given` on 'von', a token in the suffix role, at 2.0.0, 2.1.0, 2.2.0 and 2.3.0; `John Smith, Jr., PhD van Ma` and `John Smith, Jr., PhD Do Ma` report it on 'van' and 'Do' at 2.0.0 and 2.1.0 only. The `suffix-or-name` half reached `John Smith, Jr., PhD Do Ma`, `John Smith, MA, PhD Do Ma` and `John Smith, Jr., PhD van Ma` on master (e10e83b4), and the run rule of decisions/C4's 2026-09-27 #544 entry made it reachable behind ONE comma: `John Smith, PhD Do Ma` carried C1's flip and a second report on 'Ma'. FIXED by scope, not by a new test at the emitter: group passes the chain no report list in a tail segment either, so both emitters go quiet there together, and rules/C2 states the boundary for any part consumed wholly as suffixes. (Mechanism SUPERSEDED by #636, decisions/C7, 2026-10-11: group makes no chain in a tail segment at all, and the part after a suffix comma had reached none since #613, so the report list is withheld only after a family comma.) The maiden channel is a separate parameter and is untouched: a tail segment's reader is NONE, so the maiden walk reports nothing there to begin with. No mechanisms.md entry: this is AMBIGUITY-AT-THE-DECISION-SITE's own contract (a report fires only where the parse chose between live readings) applied to a stage whose reading a later stage overrides for the whole segment. MEASURED 2026-09-28, the tree against the same tree with `None if family_comma else ambiguities` restored in `group()` (the comparator), each parse recorded as its seven fields plus `(kind, [(token text, token role)])` per report, under all three name orders: 0 of the 1441 differential-corpus names move, so the gate has nothing to classify; over a comma grid — the prefixes `John Smith, `, `John Smith, Jr., ` and `Smith, John, ` times every run of one to three words drawn with repetition from {PhD, MA, Ma, Do, van, de, Jr, MEng, Ed, y, i}, each text as written, lowercased and uppercased, deduplicated to 11,049 texts — 360 parses (120 texts, every one of them mixed case) lose one `suffix-or-name` report apiece, every removed report on a token in the suffix role, 0 reports added, 0 field moves. 21 of the 120 texts carry one comma and are the run rule's reach; the other 99 carry two and moved the same way (297 parses) when the same one-line change was applied to master e10e83b4; the `Smith, John, ` prefix moves nothing, being a family comma. The grid holds no word of both the title and the particle vocabulary, so the `particle-or-given` half is witnessed by the case row alone. Pinned by the case rows `a_credential_run_after_the_comma_reports_no_chain_fork` and `a_part_past_the_second_reports_no_particle_fork`; the rows the chain still reports on outside a tail are `the_chain_reports_the_acronym_it_takes` and `titled_particle_chain_survives_a_title_that_is_also_a_particle`. - 2026-10-01 (Derek) (its maiden clause past the second comma superseded by #601, decisions/M2) — A CAPITALS-SETTLED RUN OPENED BY A WORD OF THE CLASS REPORTS, AND RULES.MD#C1 NOW SAYS SO. C1 said a run whose every class word is written in capitals "reads whole in silence". That held only behind another credential: a class word OPENING the part is the first word after the comma, whose decision C1 reports either way, so `Doe, MA PhD`, `John Smith, MA MA` and `Smith, MA PhD` read wholly as suffixes and report `suffix-or-name` once, on that word, while `John Smith, PhD MA` and `Smith, PhD MA` are silent. The behavior predates #562 and is kept: nobody repeats `MA` at the end of their name on purpose, so the report is the right signal. Statement corrected, no parse moved. MEASURED 2026-10-01 on master b39c370c, the prefixes `John Smith, ` and `Smith, ` times every run of two or three words drawn with repetition from {MA, BA, ED, DO, JD, PhD, MD, Jr, Esq.} holding at least one class word: all 900 runs opening with a class word report, 895 of them read whole (14 of those hold a #562 particle pair, decisions/C4, whose count flip reports over the whole part rather than on the first word) and the other five being `Smith, MA DO DO` and its like, the one-name-word chain #562 left open; of the 560 opening with another credential, 554 read whole in silence, and the other six hold a #562 particle pair — four reading whole and reporting (`John Smith, PhD DO DO`), two being `Smith, PhD DO DO` and `Smith, Jr DO DO`. Recompute: parse that grid and bucket by whether the first word is a class word, whether `ambiguities` is empty, and whether the suffix is the whole part. The same day (Derek) rules/S2's first-slot precedence sentence gained the pointer to that exception, so the next reader does not rediscover it: S2 says the count decides the first slot after a family comma before case, true for one word (`John Smith, MA` flips by the count) and false for a capitals-settled run, where C1's shortcut (rules/C4 since #728) reads the capitals first and the comma keeps its family reading. And S2's list of reporting slots lost "the segments beyond it": no `suffix-or-name` report lands past the second comma (`Smith, John, MA` and `John Smith, Jr., MA` are silent). It once did: at #530's merge cc78c960 `John Smith, Jr., PhD Do Ma` reported `suffix-or-name` on 'Ma' beside its `comma-structure` flag, from group's chain emitter, which the 2026-09-28 #544 bullet (decisions/C11) silenced in a tail. S2 now points at C2 for what such a part does report, which is not only `comma-structure` — an unclosed delimiter there adds `unbalanced-delimiter` (`John Smith, Jr., (Bob`), and a maiden clause standing in it is read as one (`Smith, John, Jr nee Jones MA`, maiden 'Jones MA'). Derek, 2026-10-01: the silence is right. - 2026-10-11 (Derek), #641 — C2'S NO-NAME-READING CLAUSE MEANS A WORD READ AS A NAME, AND C1'S PER-WORD REPORT IS THE PART'S OWN. rules/C2 said a part the parse consumes wholly as suffixes "raises no report about reading a word of it as a name", whether it is the part after a suffix comma or a part beyond the second; but the part after a suffix comma reports each word its opening credential takes (`John Smith, PhD van der Berg` → suffix-or-name on 'van', 'der' and 'Berg', "follows a credential, so it reads as part of the suffix run; it may be a name word"), the report rules/S2 states ("A name word the run absorbs is reported `suffix-or-name`"), which C1's credential-opening branch (rules/C8 since #728, rules/C13 since 2026-10-19) reaches by reading the words after it as that run reads them, and which C1's example pins (`John Smith, PhD Jones` → `suffix-or-name`). The rule text and the code had disagreed since #603 added the report (in `assign`; #613 later moved it into `_comma.decide`), and no test held either side's wording. DECIDED (Derek): the report stays -- a word behind a credential in a part the comma made postnominal is the fork it reports in `John Smith PhD Jones` -- and C2's sentence is narrowed to what it meant: no report that a word of such a part was READ AS a name (a particle chained onto a name word, an acronym taken into the name). The part's own reports are listed by their rules: C1's report of the decision at its comma (the flip, or the first word after the comma), S2's report on each word a credential in front of it reads as a suffix although it may be a name word (a name word the run absorbs, a member the company decides), and C2's flag. Measured by the review over about 1.2 million parses of suffix-comma and tail parts: those are the only reports such a part carries. A text change only: no reading or report moves (the suite and the five gates unchanged), and the one code comment quoting the old sentence quotes the new. - 2026-10-18 #728 — SPLIT FROM decisions/C1 with the rule. The entries above were written under decisions/C1 while this rule was part of rules/C1, and moved here unchanged but for the pointers the move broke, the measurement paragraphs, each cut to one line naming its population, counts, baseline and PR, and the superseded passages, each cut to its decision and what replaced it; where one says rules/C1 or C1, it means the rule as it then stood, which held this one. - 2026-10-19 (Derek), follow-up to PR #738 — THE REPORT'S PAIRED-INITIALS EXCEPTION (`pair_only`) ASKS `is_title_piece` FOR THE TITLES IN FRONT OF THE FIRST PAIR. Recorded under decisions/C5 with the speaker scan it was changed beside, the measurement and the one configured move. - 2026-10-21 #793 — THE LISTED-MEMBER REPORT NAMES THE ROLE ITS WORD IS BOUND TO, "a title" where the listing form binds the member as one (`John, Si.`) and "a credential" where it binds a suffix (`John, MA`). Recorded under decisions/suffix-acronym-collisions with its grid and measurement, and as an amendment in decisions/A1. - 2026-10-25 #785 (Derek, 2026-10-23) — A DECISION THE CAPITALS SETTLED IS NOT REPORTED. SUPERSEDES the 2026-10-01 Standing line's opening-word report. The comma's decision reported a listed member written in capitals in a mixed-case name, as the opening word of the part or as the part C4's count flipped, though its writing had settled it (rules/S6); it reports neither now, and a part whose every such word is settled reads in silence wherever they stand. The record is decisions/S6's entry of this date. C12-suffix-word-ending-the-name-before-the-comma.md000066400000000000000000000223071527303271700342050ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/decisions# C12 — a suffix word ending the name before the comma ## Standing - A suffix word ending a whole name before the comma — a generation, `Esq.`, an honorific, not an acronym — makes the comma a suffix comma where the name carries C6's case contrast, the part after it deciding only whether it holds a name: `John Smith II, AD` → given 'John', family 'Smith', suffix 'II, AD' under the default order. A word in the part written as a name, or an initial, keeps the listing form (`Lloret Palol II, Josep Antoni` → given 'Josep'; `John Smith Jr., J.` → given 'J.') — 2026-10-18 #656. - The generation alone does not decide, and a name in one case or a caseless script keeps the listing form: `JOHN SMITH JR, AD` → given 'AD' — 2026-10-18 #656. - A roman numeral is evidence by the trailing fork's predicate, not the suffix-word tag, so `John Smith VI, AD` reads as `John Smith III, AD` does; the words in front of the suffix word are counted as C3 counts, and a one-letter numeral needs a name word to spare as S10 counts it, so `Smith VI, AD` and `Smith V, AD` keep the listing form — 2026-10-18 #656. - An honorific is evidence as a generation is (`Tenzin Gyatso Rinpoche, AD` → suffix 'Rinpoche, AD'), and dotted paired initials after the suffix word keep the listing form (`Smith Jones Jr., J.A.` → given 'J.A.') — 2026-10-18 #656. - In that part a title run a connective joins reads as a title, as one title word does, a dual inside the run included (`John Smith Jr., Mr. and Mrs. AD` → title 'Mr. and Mrs.', suffix 'Jr., AD'; `John Smith Jr., AD Sr. and Mr.` → title 'Sr. and Mr.'), while a lone dual reads as a suffix (`John Smith Jr., Sr. AD` → suffix 'Jr., Sr. AD') — 2026-10-19 #735. Elsewhere: paired initials after a generation, dotted the given name and undotted the credential (`John Smith Jr., M.J.` → given 'M.J.'; `John Smith Jr., MJ` → suffix 'Jr., MJ') — decisions/C1's 2026-10-19 (Derek), #761 entry; `mbchb`, whose addition #656 declined, is listed (`Smith, MBChB` → suffix 'MBChB') — decisions/suffix-acronym-collisions' 2026-10-21 (#797) entry. Open: none in this file. ## Log - 2026-10-18 (Derek), [#656](https://github.com/derek73/python-nameparser/issues/656) (its declined `mbchb` superseded by #797, decisions/suffix-acronym-collisions) — A SUFFIX WORD ENDING A WHOLE NAME BEFORE THE COMMA IS EVIDENCE THAT THE COMMA IS A SUFFIX COMMA, AND THE PART AFTER IT DECIDES ONLY WHETHER IT HOLDS A NAME. Until this rule only the part after the comma decided (C1), so an unlisted credential there that no shape rule admitted reversed the whole name into the listing form: `John Smith II, AD` read given 'AD', family 'John Smith'. Derek's ground, in the #720/#656 session: a generation is part of the name, more so than a credential, and is commonly written before the comma that starts the credentials (`John J Ingram III, MD`), and `II` counts as such a word though it is also initial-shaped. The evidence is the suffix WORD (`vocab:suffix-word`: every member of `suffix_words`, 170 on 2026-10-18 by `len(Lexicon.default().suffix_words)` — the generations, `esq`, `ret`, `vet`, `2`, and the honorifics, `rinpoche`, जी, 先生, 씨 among them), not an acronym, because a listing with a long surname puts an acronym there as readily (`von bergen wessels MD, pennie`, a corpus.jsonl row, keeps its given name). DECLINED: the generation alone deciding. CLDR's own renderings hold the counter-case, a two-word surname with a generation before a listing comma: `Excm. Sr. Lloret Palol II, Josep Antoni Carles Joan` and `Excma. Dra. Van Dalen Hoogeveen II, Maria da Graça César Martín`, both keep the listing form and their given names before and after this rule (their title and family fields disagree with CLDR for other reasons, the title run left in the family). So the part after the comma still decides, by a narrower question than C1's: a word in it written as a name (`_vocab.written_as_a_name`, unlisted, no period) or an initial keeps the listing form. That needs the name's case contrast, which is C6's test (`_comma._name_contrast`) reused rather than restated; without it a given name written in capitals is indistinguishable from `AD`, so a one-case name and a caseless script decline (the Arabic, Gujarati, Kannada and Malayalam CLDR renderings of this shape stay as they read). THE NUMERALS, found by the sweep before review. The evidence is reached by vocabulary AND by shape: `ii`–`iv`, `v` and `i` carry the suffix-word tag, but `VI` to `X` are numerals by shape alone, so a tag-only test read `John Smith III, AD` and left `John Smith VI, AD` in the listing form, #610's class. The numeral path asks `_vocab.is_trailing_numeral_suffix` (`_vocab.reads_as_generation` since #752), the trailing fork's own predicate, behind a C-level `ROMAN.match` filter in `decide` so the listing form pays no frame. Both paths then count the words IN FRONT of the generation as C3 does (`_whole_name(seg0[:-1])`): `Smith VI` is one word and a numeral, as `Smith III` is, where counting the whole part had made the shape-only numeral its own name word. And a one-letter numeral is evidence only where S10 reads it as the generation, a name word to spare in front of it (`name_word_count`, a title being none): `Smith V, AD` and `St. Berg V, AD` read the V as a name word and keep the listing form. THE HONORIFICS, found by the design-docs review: the tag reaches every suffix word, so `Tenzin Gyatso Rinpoche, AD` reads suffix 'Rinpoche, AD' where the parent read given 'AD'. Kept as stated rather than narrowed to generations: an honorific written after the name stands in the same place a generation does, and no vocabulary set separates the generations from the rest. Dotted paired initials were the review's other find: `Smith Jones Jr., J.A.` read suffix 'Jr., J.A.', since the veto asked only the `initial` tag; `is_paired_initials` joins it, as C5 keeps such a pair the given name. DECLINED: adding `mbchb` to SUFFIX_ACRONYMS, approved in the session before the rule was measured. `MBChB` ends in a capital, so it is not written as a name and the rule reads `van den Berg jnr., MBChB PhD` without it; a vocabulary member would also reach `Smith, MBChB`, which nothing here measured. Measured 2026-10-18 on this change against its parent: five corpus names move, every one a CLDR rendering of the shape and each to the agreeing reading on family, given, suffix and title where CLDR states them (`Sig. Fabio Massimo Carlo Alberto Martini II, AD` in both orders, `prof. dr. Ada Cornelia César Martín von Brühl Jr, univ. bacc`, `Prof. dr. Cornelia Margarita Sofia Aletta van den Berg jnr., MBChB PhD`, `Skeggjason Hr. Geir Jón Magnús Brynjar yngri, þingmaður`); the last still disagrees on its given names, CLDR's `Geir Jón Magnús Brynjar` against `Hr. Geir Jón Magnús Brynjar`, a title inside a surname-first name, which is not this rule. No other corpus name moves, read off `snapshot.py`'s report. The issue's comment counted 25 disagreements of the shape on 2026-10-13; the `Jr, MP` renderings among them read correctly on the parent already, MP being listed. - 2026-10-19 (Derek), PR #735's altitude review — A TITLE RUN A CONNECTIVE JOINS (P3, C7) READS AS A TITLE IN THE PART, AS A SINGLE TITLE WORD DOES. The rule's title test was a hand copy of C13's (C8's until its 2026-10-19 split) that asked only a one-word piece's vocabulary tags, so a joined title (piece tag `title`) fell to the suffix side: `John Smith Jr., Mr. and Mrs. AD` read suffix 'Jr., Mr. and Mrs. AD', while `John Smith, PhD Mr. and Mrs. AD` (C13) and `John Smith Jr., Mr. and Mrs.` (no credential) both read title 'Mr. and Mrs.'. `_behind_a_generation` now asks C13's predicates in C13's order, `not is_suffix_piece` then `is_title_piece`. They agree with the copy on every one-word piece, a lone dual still reading as the suffix (`John Smith Jr., Sr. AD`); they differ in principle only on an `initial`-tagged dual, which the rule's own veto turns away first. A dual INSIDE a joined title now reads as part of the title (`John Smith Jr., AD Sr. and Mr.` → title 'Sr. and Mr.'), as C13 reads it (`John Smith, PhD Sr. and Mr.`), found by the PR's code review. Measured 2026-10-19 against the parent (a4549178, extracted with `git archive`, `__file__` asserted on both sides), over `John Smith Jr.`, `John Smith II`, `John Smith VI`, `John Smith V`, `John J Ingram III`, `Dr. Smith Jr.`, `John Smith Esq.`, `Mary Jane Smith Sr.` before the comma × the parts `Mr. and Mrs. AD`, `Mr. and Mrs.`, `Mr. and Mrs. Smith`, `AD Mr. and Mrs.`, `Secretary of State`, `Secretary of State AD`, `Lt. Gov. AD`, `Lt.Gov. AD`, `Mr. & Mrs. AD`, `Dame and Sir AD`, `Prof. and Dr. MD`, `AD Sr. and Mr.`, `Mr. and MD AD`, `Dr. and Ms AD`, `MD`, `Ms`, `Ms AD`, `Sr.`, `CPT`, `CPT AD`, `AD CPT`, `MD Ms`, `Sr. MD`, `Sir AD`, `Dr. AD`, `Prof. AD`, `Rev. Sr.`, `AD Dr.`, `Dr.`, `Sir`, `PhD`, `AD` after it × the three orders: 192 of 768 readings move, exactly the eight parts where a joined title stands beside an unlisted credential, under every name and order, each from suffix to title. A joined title beside a listed credential (`Prof. and Dr. MD`) or with none (`Secretary of State`) already read as a title, the rule not being reached. No ambiguity kind or `initials()` moves, and no corpus name moves (the snapshot gains only the rule's new examples). C13-credential-opening-the-part-after-the-comma.md000066400000000000000000000205221527303271700341200ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/decisions# C13 — a credential opening the part after the comma ## Standing - A suffix word that starts S8's credential run, with only titles in front of it, opens the part after the comma as the postnominal part, and every word after it reads as that run reads: a title word as a title, any other as a suffix; each word the opened part takes from the given name is reported — 2026-10-04 #603. - Behind a one-word name before the comma the opened part is all suffixes (`Doe, PhD Jones`); reading the next word as the given name was declined — 2026-10-04 #603. - A title/suffix dual opens nothing, nor does a member of the ambiguous class or a suffix word S8's run start refuses — 2026-10-04 #603. - The split credential `Ph. D.` starts the run as `PhD` does — 2026-10-04 #603. - Under strict mode the veto refuses an initial-shaped word the suffix reading, not the opened run (`Smith, PSM I.`), stated in rules/C14 since its split — 2026-10-04 #603. - A title word past the second comma that is not also suffix vocabulary is a title. This is rules/C2's decision, indexed here because #603's entry is its only record (no decisions file is keyed C2) — 2026-10-04 #603. Open: none in this file. ## Log - 2026-10-04 (Derek), #603 (its no-comma `John Smith Ph. D. Jones` left reading family 'Jones' superseded by #752's phase 2e, decisions/S12, the merged pair starting the run there too) (decided at group's head since #613, decisions/C1; its `John Smith, MD Jones`, `MD - PhD - FACS` and `Smith, MD PhD Ma` readings and `md`'s dual status superseded by #853, decisions/indic-honorifics, `MD` being no title there) — A CREDENTIAL OPENING THE PART AFTER THE COMMA MAKES IT THE POSTNOMINAL PART, WHATEVER FOLLOWS; AND A TITLE WORD PAST THE SECOND COMMA IS A TITLE. One word that is neither suffix nor title vocabulary used to flip the whole comma reading: `John Smith, PhD Jones` read family 'John Smith', given 'PhD', where `John Smith, MD FACS` and `Eric H. Holder, Jr. Attorney General` were already read as a name and its postnominals. Now the first suffix word of the part, with only titles in front of it, opens the part when it starts S2's run (`_pieces.starts_a_credential_run`, #602's own predicate, so the two runs cannot disagree about what starts one), and every word after it reads as that run reads: a title word as a title, any other as a suffix. The mechanism was the one #296 built for a part holding no name word, extended rather than added beside; where this entry had segment deciding it, #613 (decisions/C1) decides it once at group's head. Every word the opened part takes that would otherwise have made it the given part is reported (`suffix-or-name`), as #602's run reports what it absorbs. THE ONE-WORD PATH, DECIDED (Derek): the whole part is suffixes. `Doe, PhD Jones` reads family 'Doe', suffix 'PhD Jones', no given name, as `Smith, PhD` already reads. DECLINED: reading the credential as a suffix and the next name word as the given name (`Smith, PhD Jones` → given 'Jones'), proposed by analogy with a title at the head of the given part. Asked whether that is a real format, it is not -- nobody writes `Family, Credential Given` on purpose -- and the one realistic input it served, a misplaced generation (`Smith, Jr. John` for `Smith Jr., John`), is garbage in, garbage out. One reading for both comma paths was the deciding ground: the one-word path disagreeing with the full-name path is the shape #429, #430 and #432 each found. Accepted cost: `Smith, Jr. John` loses 'John' to the suffix, where 2.3 read given 'John' with 'Jr.' misfiled as a title. A TITLE/SUFFIX DUAL OPENS NOTHING (Derek). A word in both vocabularies (`md`, `ms`, `sr`, the ranks `lt`, `cpl` and the rest) is the listing form's title in front of a given name, `Smith, Ms Jane`, and a credential behind it opens nothing either (`Smith, Ms PhD Jones` keeps given 'PhD', as `Smith, MD PhD Ma` keeps given 'Ma' under #544). DECLINED after building it: a dual opening the part behind two or more words before the comma. Derek first chose it for `John Smith, MD Jones`; the reviews found what the count cannot see -- a surname of two words (`García Márquez, Ms Gabriela`, `Smith Jones, Sr. Maria`), a title in front of a one-word surname (`Dr. Smith, Ms Jane`, the count taking the title as a word), and spaced initials (`García Márquez, Ms G. J.`) each lost the given name -- and with the word explained (a dual is a word in both lists, decided by position), Derek chose that duals never open. So `John Smith, MD Jones` and the `MD - PhD - FACS` delimiter rows keep their 2.3 reading. An ambiguous-class member opens nothing either (`John Smith, Ma Jones` keeps given 'Ma'), nor does a suffix word S2's run start refuses (`John Smith, vd Jones`). STRICT MODE: the veto refuses an initial-shaped word the suffix READING, not the run. `Smith, PSM I.` under `lenient_comma_suffixes=False` reads suffix 'PSM I.' (2.3: given 'PSM', suffix 'I.'), reporting the absorbed 'I.', which matches #602's no-comma run (`John Smith PhD V.` reports; its given-part run does not, a disagreement #602 left and this does not touch). THE SPELLING GAP THIS FOUND, FIXED HERE. `starts_a_credential_run` refused any piece longer than one token, so the split credential `Ph. D.` group merges started neither #602's run nor this one while `PhD` started both: `Smith, John Ph. D. Jones` read middle 'Jones' where `Smith, John PhD Jones` read suffix 'PhD Jones'. The merged piece now starts the run, the "suffix" piece tag being the Ph./D. merge's alone; `trailing_candidates` had already admitted it, so its pre-check and the predicate now agree. The no-comma spelling `John Smith Ph. D. Jones` still reads family 'Jones': the trailing peel's walk never holds the merged piece, which is a separate path and is left (nameparser/AGENTS.md's spelling sweep). C2, WIDENED (Derek): a word of the title vocabulary in a part past the second comma that is not also suffix vocabulary is a title -- the part stands behind a comma, the line H5 draws for reading a title from the end of a name -- and a piece holding a suffix word stays a suffix, a dual and a connective join that took one (`PhD - and MD`) alike. `Eric H. Holder, Jr., Attorney General` reads title 'Attorney General', and a part of title and suffix words is recognized rather than flagged. The flag's test is asked word by word against the title vocabulary, a period-joined word through `period_joined_vocab` as classify tags it (`John Smith, Jr., Lt.Gov.`, which the code review found still flagged), so `Secretary of State` reads its title but keeps `comma-structure`: knowing that group's title chain takes the connective would be `segment` modelling `group`, the sign #611 is about. Every release through 2.3.0 read these as suffixes, 1.4.0 included (`Eric H. Holder, Jr., Attorney General` suffix 'Jr., Attorney General', `Smith, John, Prof.` suffix 'Prof.', measured with the released wheels 2026-10-04); a first draft of this entry said 1.4.0 read them as titles, inferring it from the 1.4.0 run reporting no unexplained diff, when the reason was the broad `fix(comma-family)` rule there claiming them, which #603's two rules now precede by declaration. Accepted consequence: a noble rank there reads as a title, `John Smith, Jr., Freiherr von Richthofen` → title 'Freiherr', suffix 'Jr., von Richthofen'. WHAT MOVES, measured 2026-10-04 against 97d36e0d (PR #615): four of the 1,515 corpus names, every one intended (`Steven Hardman, RN - CRNA` now reads given 'Steven', suffix 'RN - CRNA', and `John, Smith, Dr.`, `Andrew Perkins, Jr., Col. (Ret)` and `1 & 2, 3 4 5, Mr.` read their tail title); and 589 of 98,630 generated texts under three policies, 581 of them a part a credential that is not also a title opens or a title past the second comma, one the two-name-piece count reading a connective-joined piece beside a suffix word as the family (`Jr. i Ó VI Attorney, Ph.`), and the other seven read by hand. Frames are unchanged on the reference band and plain names, and `John Smith, PhD Jones` pays 338 → 361. - 2026-10-19 — SPLIT FROM decisions/C8 with the rule. The #603 entry above was written under decisions/C1 while this rule was part of rules/C1, moved to decisions/C8 with the listing form's clause in #728, and moved here unchanged when rules/C8 split; where it says rules/C1, C1 or C8, it means the rule as it then stood, which held this one. Its C2, WIDENED paragraph decides rules/C2 (a title word past the second comma) and stays rules/C2's. C14-strict-mode-a-suffix-word-written-as-an-initial.md000066400000000000000000000054701527303271700347270ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/decisions# C14 — strict mode: a suffix word written as an initial ## Standing - Strict mode is a rule of its own: the switch reads only the caller's configuration, and it vetoes an initial-shaped suffix word in the comma's decision and in a part after the comma that is nothing but suffix words, except in a run a credential opens, which takes the vetoed word (`Smith, PSM I.`, decisions/C13's #603), while the given part's walk after a family comma is strict-blind — 2026-10-19 #761. Open: none in this file; the switch's reach into the count before the comma (`Smith V., Ma`) stays decisions/S4's 2026-09-18 rough edge. ## Log - 2026-10-19 (Derek), #761 — STRICT MODE IS A RULE OF ITS OWN. The strict comma-suffix switch (`Policy(lenient_comma_suffixes=False)`) was a sentence of rules/C1 and a clause of rules/C13, and rules/C9 and rules/T4 restated it by pointing at C1. It reads nothing in the written name, only the caller's configuration, so under docs/design/AGENTS.md's one rule per piece of evidence it is a rule rather than a clause of the hub; split out while the C section moved to the skeleton format. Moved here unchanged in meaning: C1's sentence and its two strict examples (`John Smith, V.`, `john smith, v`), C13's `Smith, PSM I.` exception, which becomes this rule's boundary, and C9's clause that the run ends at an initial-shaped word. C1, C9, C13 and T4 now cite this rule, and so does W3 for the honorific split-off, whose comma-side question the switch also reaches (`田中さん, V.` keeps さん glued under it; decisions/W2's 2026-10-27 entry, which holds what was then root AGENTS.md's honorific-peel paragraph, and the case row `ja_honorific_glued_family_comma_strict_knob`). The switch's own history stays where it was decided: decisions/C1's 2026-07 and 2026-07-12/13 entries (its blast radius, the one-letter roman numerals, and its date) and decisions/C13's #603 STRICT MODE paragraph (the opened run takes the vetoed word). The given part's walk after a family comma is strict-blind, measured on d7e1d47a (`Smith, John V`, `Smith, John PhD I` and `Smith, John Jr. V.` read the same under the switch as without it; C14's `Smith, John V` boundary pins the first), so this rule scopes the veto to the comma's decision and to a part after the comma that is nothing but suffix words. decisions/C9's #430/#432 sentence that "the strict knob is consulted" and "the run ends at the numeral" means that credential-run continuation, not the given part's walk. Two drafts of this rule overstated the reach (a run "inside a comma part", then "in the part after the comma", which in the listing form is the given part too); the PR reviews found both. One reach of the switch no rule states, the count before the comma reading the vetoed word as a name word (`Smith V., Ma` → family 'V.'), stays recorded as decisions/S4's 2026-09-18 rough edge. derek73-python-nameparser-456b024/docs/design/decisions/C15-comma-with-nothing-before-it.md000066400000000000000000000151161527303271700313270ustar00rootroot00000000000000# C15 — a comma with nothing before it ## Standing - A comma with nothing before it is a family comma naming no surname, the part after it read as the given part after a family comma reads, held by an invariant over two inputs rather than by a sweep shape: `, X` reads as `Smith, X` reads, less the surname, its fields and ambiguity kinds alike; a particle P6 attaches is then the family alone (`, Jane van` → family 'van') — 2026-10-27 #892. - H1 is the one exception: a title and one name word, or a bound pair standing for it, read as the family (`, Dr. John` → family 'John', `, Dr. abdul Rahman` → family 'abdul Rahman'), the comma naming no surname to decide the field by, as a comma with nothing but titles after it names no given part — 2026-10-27 (Derek). - No code is written for the empty head alone: P6 attaches behind it as behind any family comma, ahead of H1, so a particle ending the name leaves H1 two name words and the exception does not apply (`, Dr. Jones vd` → given 'Jones', family 'vd') — 2026-10-27 #892 (Derek). Open: none in this file. ## Log - 2026-10-27 #892 (its `vd` and `mc` bullet superseded by 2026-10-27 #892 (Derek), below) — A COMMA WITH NOTHING BEFORE IT IS A FAMILY COMMA NAMING NO SURNAME. The shape had no rule: clauses in rules/H8, rules/O6 and rules/M2 and review findings in decisions/C1, S10, A1 and H5 each decided it locally, and nameparser/AGENTS.md made every comma change sweep it. Real names almost never take it: 10 of about 6 million local-corpus names (FEC 4, tse 3, PSC 2, tw-donors 1, the last opening with a fullwidth comma, `,邱詩涵`, which reads as `, 邱詩涵` does), the issue's count of 2026-10-25 missing the fullwidth one; recounted 2026-10-27 by scanning each local corpus's names for one opening, after spaces, with `,` or `,`. The alternative, dropping the comma so `, John Smith` reads as `John Smith`, was declined: the shape's one source is an empty surname field in listing data (#613's review, `, PhD` and `, Jr.`), and reading it as a whole name contradicts that source. - Measured on master (8a65ee18) before any change, over the grid `tests/v2/test_properties.py::test_an_empty_head_reads_as_its_family_comma_twin` builds (10 title runs × 32 bodies × 11 trailing words, 3,519 given parts under each of three orders): every given part reads as its twin but 1,075 per order, all of them H1's, so the rule landed with no code change. A first comparison's eight differing given parts holding `MA`, among them `, MA`, `, (Bob) MA`, `, J. MA` and `, JOHN SMITH MA`, were the twin's doing, not the empty head's: a surname written `Smith` brings a case contrast the given part lacks, so `Smith, MA` reads suffix 'MA' by its capitals (rules/S6) while `, MA` and `SMITH, MA` both read given 'MA'. The test writes the twin's surname in the given part's case for that reason, and rules/C15's Accepted bullet records the reading. - The particle-and-suffix words `vd` and `mc` ending the name behind H1's family reading (`, Dr. Jones vd`) are the exception's knock-on, not a second one: the twin attaches the word to its surname (rules/P6), the empty head has no family to attach it to once H1 has made the lone word the family, and it reads as the suffix. Behind a given-name title H1 reads no family and P6 attaches as in the twin (`, Sir Jones vd` → family 'vd'). - The exception is stated by its result, not by the comma-less name: `, Jane Mrs` reads family 'Jane' where `Jane Mrs` reads family 'Mrs' (a bare salutation is a title only after a family comma, rules/H8), and `, Dr. abdul Rahman` reads family 'abdul Rahman' where `Dr. abdul Rahman` reads given 'abdul', family 'Rahman'. A first draft compared against the comma-less name and the design-docs review found it false on 493 of the 1,075 H1 rows per order. - The invariant compares fields and ambiguity KINDS, never a report's detail: `, Sir Jones vd` reports that 'vd' "joins the family the comma named", a comma that names none, and passes. Report-detail grids keep the empty head (nameparser/AGENTS.md). - Recompute: `uv run pytest -n0 "tests/v2/test_properties.py::test_an_empty_head_reads_as_its_family_comma_twin"`; its docstring records the negative control. - 2026-10-27 (Derek) — H1 STAYS THE ONE EXCEPTION, stated as the comma deciding nothing. "Keep H1 as the one exception, stated as a comma with nothing before it not deciding the field." `, Dr. John` keeps family 'John', where its twin `Smith, Dr. John` reads given 'John': rules/H1 already says a comma with nothing but titles after it names no given part and so does not decide the field, and a comma with nothing before it names no family, the mirror case. Following the invariant instead would have read given 'John' for every such row, 1,075 per order on the grid; rules/H8's Accepted bullet records the trailing spellings' family reading (`, Jane Mrs`, and the period-marked one since H5, #316). - 2026-10-27 #892 (Derek) — THE EMPTY HEAD'S OWN CODE IS GONE, the question #892's fourth point asked. SUPERSEDES: the `vd` and `mc` bullet of 2026-10-27 #892, which read `, Dr. Jones vd` as suffix 'vd'. A search of the pipeline for code that reads an empty part before the comma found one site: P6's second attachment site, in post_rules after H1 and M4, with the guard in assign's comma site that kept the attachment from acting there (decisions/P6, #613). Switching both off moved 112 given parts per order on the invariant grid, every one a particle-and-suffix word `vd` or `mc` ending the name behind a title that addresses by surname (`, Dr. Jones vd`, `, Mr Jones mc`, `, Jones vd Mrs`), each from family 'Jones', suffix 'vd' to given 'Jones', family 'vd' with a `suffix-or-name` report, and moved them onto the twin: no row failed the invariant, and H1's rows fell from 1,075 to 963 per order. Off the grid the PR review found two more classes, each onto its twin too: the same tail behind a third comma part (`, Dr. Jones vd, PhD` → family 'vd', suffix 'PhD', where it read suffix 'vd, PhD'), and a never-given particle opening the part with no name word behind it (`, de PhD vd` → given 'de', suffix 'PhD vd', as `Smith, de PhD vd` and `, de vd` read, where the removed site, which lacked the comma site's P1 guard, read family 'vd', suffix 'PhD', reported). An ambiguous particle there already read so (`, Dr. Jones van` → family 'van'), and the site had kept the comma-less name's reading (`Dr. Jones vd`). Removed, with "ok, remove it". #890's floor for `, Dr. fils` is not the empty head's: it is rules/C4's count of name words before the comma, which a one-word head meets too (`Sir Alexandre, fils`). None of the 10 real names with the shape holds a trailing particle. derek73-python-nameparser-456b024/docs/design/decisions/C3-counting-the-words-before-the-comma.md000066400000000000000000000320431527303271700325270ustar00rootroot00000000000000# C3 — counting the words before the comma ## Standing - A particle run and the one name word it attaches to are one word in every count of the part before the comma, so a particle surname counts as a one-word surname does: `De La Cruz, Ed` → given 'Ed', family 'De La Cruz'; `van der Berg, MA` → family 'van der Berg', suffix 'MA' — 2026-10-01 #575. - Where that surname is the whole part, the comma settles P1's fork for a leading particle that could be a given name, under every name order: `Van Buren, Ed` → given 'Ed', family 'Van Buren'; `Van Johnson, Dr.` → title 'Dr.', family 'Van Johnson' — 2026-10-01 #575. - The particle reaches one word, as P1's fold does, so a word after the surname is a second word and the part keeps the positional read with P1's fork: `van Buren John, Ed` → given 'van', middle 'Buren', family 'John', suffix 'Ed' under the default order — 2026-10-01 #575. - A bound given-name pair is no one word to the count (`abdul Salam, Ed` → suffix 'Ed'), and a bound given-name word that is a particle too counts as the particle: `Abu Bakar, Ed` → given 'Ed', family 'Abu Bakar'; `Abu Bakar, PhD` → family 'Abu Bakar', suffix 'PhD' — 2026-10-01 #575. - A word of both the title and the particle vocabulary is no particle where it opens the part and chains inside a surname: `St John, PhD` → title 'St'; `Freiherr von Berg, PhD` → title 'Freiherr'; `de St Pierre, Ed` → given 'Ed', family 'de St Pierre'; and `Freiherr von Berg, Ed` → family 'Freiherr von Berg', given 'Ed' — 2026-10-01 #575. - A title in front is a word to the count an unambiguous credential takes, so `Dr. van der Berg, PhD` reads as `Dr. van der Berg` does, given 'van' with P1's fork; and behind a one-word particle surname paired initials keep the given name, `De La Cruz, M.J. K.L.` → given 'M.J.', middle 'K.L.', reporting twice — 2026-10-01 #575; 2026-10-24 #864. Elsewhere: the count runs once, in `_comma.decide` over classify's tags, with `name_word_count`'s vocabulary view held equal to them; a connective surname counts its words (`Ortega y Gasset, Dr.` → title 'Dr.', given 'Ortega', middle 'y', family 'Gasset' under the default order; `Ortega y Gasset, Ed` → suffix 'Ed'); and a part before the comma that is one name word with a suffix in it counts as the listing form's one word (`Smith Jr., Esq.` → family 'Smith', suffix 'Jr., Esq.'; `Smith Jr., V` → given 'V', family 'Smith', suffix 'Jr.') — decisions/C1's 2026-10-06 (Derek), #613 entry, its decisions (1) and (4); what keeps `Freiherr` in the family is the particle stop, not the rank's history, so `Graf Lambsdorff, Otto` reads title 'Graf' — decisions/H6's 2026-10-17 (Derek), #701 entry; a given-name title alone after the comma makes a lone surname the given name (`Cruz, Sir` and `Smith Jr., Sir` → given 'Cruz', 'Smith') while a particle surname keeps the family (`van der Berg, Sir` → family 'van der Berg') — decisions/H1's 2026-10-18 #720 entry; a listed title opening the part before a family comma peels (`Prof. Cruz, Ed` → title 'Prof.', given 'Ed', family 'Cruz'), which this file's #575 entry had read as part of the family — decisions/H6's 2026-10-17 #701 entry. Open: none in this file. ## Log - 2026-10-01 (Derek), #575 (where the count runs superseded by #613, decisions/C1) (`De La Cruz, M.J. K.L.`'s suffix 'K.L.' superseded by #864, decisions/S3: middle 'K.L.') (`Prof. Cruz, Ed`'s family superseded by #701, decisions/H6) — THE COUNT BEFORE THE COMMA COUNTS A PARTICLE SURNAME ONCE. Every count rules/C1 makes of the part before the comma — v1's "more than one word" for an unambiguous credential, the ambiguous class's name-word count (#289, #544), and assign's two-name-word test for the positional read after a comma followed by no name word (#296/#325) — counted tokens or pieces, so `De La Cruz` was three words and `van der Berg` two (the ambiguous `van` stands as its own piece, P1's fork). This cycle's count had therefore read `De La Cruz, Ed` as a credential comma with no given name and split `van der Berg, MA` into given 'van', family 'der Berg' — an unreleased regression against 2.3.0, which read both as the listing form — and 1.4.0 through 2.3.0 all split `van der Berg, PhD`. DECIDED: a particle run and the one name word it attaches to are one word in all three counts, so a particle surname reads as a one-word surname does. THE COMMA SETTLES P1's FORK, and that is the decision rather than a side effect. Standing alone, `Van Buren` is given 'Van', family 'Buren' with a `particle-or-given` report, so counting it as one surname makes the comma's reading disagree with the standalone one. Derek's call, after weighing the narrow alternative (count only a part P1 already reads as all surname, i.e. one led by a never-given particle like `de`): the listing form puts a surname before the comma, so the comma is evidence the standalone parse does not have, and the readings it gives are the ones a person takes — `Van Buren, Ed` given 'Ed', family 'Van Buren'; `van der Berg, MA` family 'van der Berg', suffix 'MA' (one name word, so C1 reads the case (rules/C4 since #728), and capitals in a mixed-case name make the credential). `Van Johnson, Dr.` moves with it, family 'Van Johnson', title 'Dr.', where 2.2.0 and 2.3.0 read given 'Van' and `tests/v2/pipeline/test_assign.py` had pinned the positional fork; that test now uses `Van Johnson Smith, Dr.`, which still has two name words. THE PARTICLE REACHES ONE WORD, as P1's fold does, not to the end of the part as P2's chain does: `de Mesnil Jean, Dr.` under a family-first order is family 'de Mesnil', given 'Jean', two name words. The cost, and it is real for an ambiguous particle: a `van`-led part with a word after the surname counts two and keeps the positional read with P1's fork, so `van Buren John, Ed` reads given 'van', middle 'Buren', family 'John' and `van der Berg Smith, PhD` given 'van', family 'der Berg Smith', both unchanged from master. Under the default order a never-given particle loses nothing (P1's fold makes `de Mesnil Jean` all surname anyway). `_vocab.unit_ends` carries both reaches behind a `chain` flag, post_rules' fold reading the full chain as before. ONLY PARTICLE CHAINS, not mechanisms.md#UNIT-PARTITION's full set — stated there as the Contract's one exception, and in P3's statement, whose "ONE name word wherever another rule counts them" now names C1's count (rules/C3 since #728) as the exception. A bound given-name pair builds a GIVEN name, and P5 gives up a family word where the name has no other (`abdul Salam` alone is given 'abdul', family 'Salam'), so `abdul Salam, Ed` keeps the credential reading. Whether P3 joins a single-letter connective depends on the words of the whole name — `Carod i Rovira, Josep` joins as four words where `Ortega y Gasset` alone, three, does not — and the count is part of deciding what the whole name is, so segment could reach P3's answer only by copying P3; `Ortega y Gasset, Ed` keeps the credential reading. Approved as "particle chains and connective joins", narrowed to particles when P3's exception surfaced in implementation (the first draft joined `John e Smith, III` into one family name, caught by `tests/test_conjunctions.py`). A WORD THAT IS ALSO A TITLE IS NO PARTICLE WHERE IT OPENS THE PART. `TITLES ∩ particles` is `{freiherr, st}` and `bound_given_names ∩ particles` is `{abu, أبو, ابو}` (recompute: `L = Parser().lexicon; L.particles & L.titles`, `L.particles & L.bound_given_names`). The first draft counted every particle, and the docs review found it reading `Freiherr von Berg, PhD` as family 'Freiherr von Berg' and `St John, PhD` as family 'St John', where every release reads the title. The first fix then excluded both overlaps in EVERY position, and the review of that fix found two errors in it. Position: inside a surname a title-particle chains as P1 and P2 chain it, and the blanket exclusion brought #575's own defect back for `de St Pierre, Ed`, `De St. Croix, Ed` and `de Abu Bakar, Ed` (family only, no given name, where 2.3.0 read given 'Ed'). And direction: 2.0 through 2.3 read `Abu Bakar, Ed` as given 'Ed', family 'Abu Bakar', so excluding the bound-given particle gave released behavior up rather than keeping it — and Abu Bakar is a common Malay surname. DECIDED: a title-particle is no particle only where it LEADS the part, and a bound given-name particle is a particle. So `Freiherr von Berg, PhD` and `St John, PhD` keep the title, `de St Pierre, Ed` reads given 'Ed', and `Abu Bakar, Ed` reads given 'Ed', family 'Abu Bakar' while `Abu Bakar, PhD` reads family 'Abu Bakar', suffix 'PhD' where 1.4.0 through 2.3.0 read given 'Abu'. The agreement test compares each token in both positions; it cannot see which position a caller passes, so case rows pin each count's flag: `de St Pierre, Ed` segment's, and `St John, Dr.` and `von St Johann, PhD` assign's, one per direction (the review of this round found assign's flag fixed to either constant passing every test, and both mutants now fail). `Freiherr von Berg, Ed` reads family 'Freiherr von Berg', given 'Ed' — 2.0.0's through 2.3.0's reading too, so no change against a release; only this cycle's count had read title 'Freiherr', family 'von Berg', suffix 'Ed'. The surname counts once and the title is no name word, so the count reads the listing form, which keeps a leading title in the family as it already did for `Prof. Cruz, Ed` (superseded for listed titles by #H6, 2026-10-17: the count is unchanged, and the listed title then peels). For `Prof.` that is a cost (rules/C3's Accepted line); for a German rank it is the right reading (Derek, 2026-10-01): since 1919 a former noble title is part of the legal surname, written between the given name and the particle (Karl-Theodor Freiherr von und zu Guttenberg), so `Freiherr von Berg` is the family name. That is also why `freiherr` is in PARTICLES at all — added with the German prefixes in 3e14ea20 (#18) without a stated reason, and the reason is this one. `Freiherr von Berg, PhD` keeps title 'Freiherr', the unambiguous credential's count taking the title as a word; both are readings of one name, and neither is changed here. A title in front is otherwise outside the fix: v1's count for an unambiguous credential counts the title as a word, so `Dr. van der Berg, PhD` reads as `Dr. van der Berg` does alone, given 'van'. `De La Cruz, M.J. K.L.` reads given 'M.J.', suffix 'K.L.', reporting twice, as `Cruz, M.J. K.L.` does (Derek, 2026-10-01): with one name word before the comma the paired-initials count no longer reaches it, and C1's two-dotted-groups sentence (rules/C5 since #728) now says "behind two or more name words". #563 had read it as a flipped credential run with no given name. SEGMENT RAN BEFORE CLASSIFY, so its count built its unit facts from the vocabulary while assign derived them from classify's tags over every token of the part, so a suffix word stops a particle in both (the first draft's assign count dropped suffix pieces first and read `van Jr. Berg, Mr.` as family 'van Berg', found by the code review). The mechanism is superseded by #613 (decisions/C1): `_comma._whole_name` counts over classify's tags, and `test_classify.test_surname_unit_tags_agree_with_classify` keeps the vocabulary view equal to the tags (its negative control is `_SURNAME_UNIT_CONTROL`). BLAST RADIUS, measured 2026-10-01 against 65afcdb3 (PR #576) over every corpus name under the three orders (fields, ambiguity kinds, `initials()`): the gate exits 0 at all five baselines and one of the 1459 names moves, `De La Cruz, M.J. K.L.` (decided above). Against 2.3.0 the move is a class rather than a list: a leading ambiguous particle and one word, before a comma followed by an unambiguous credential or a title alone, reads as one surname (`Abu Bakar, PhD`, `van Gogh, Jr.`, `Van Johnson, Dr.`). COST, `sys.setprofile` call counts against 65afcdb3 (PR #576): `John Smith, PhD` 209 → 217, the unambiguous credential path building its token list, and `Smith, John`, `John Smith, MA` and the benchmark reference unchanged. - 2026-10-06 (Derek), #613 — A CONNECTIVE SURNAME COUNTS ITS WORDS BEFORE THE COMMA, and a part before the comma that is one name word with a suffix in it is the listing form's family: decisions (1) and (4) of decisions/C1's #613 entry, recorded there with the decision they were taken with. - 2026-10-18 #728 — SPLIT FROM decisions/C1 with the rule. The entries above were written under decisions/C1 while this rule was part of rules/C1, and moved here unchanged but for the pointers the move broke, the measurement paragraphs, each cut to one line naming its population, counts, baseline and PR, and the superseded passages, each cut to its decision and what replaced it; where one says rules/C1 or C1, it means the rule as it then stood, which held this one. - 2026-10-24 #864 — THE SECOND PAIR BEHIND A ONE-WORD PARTICLE SURNAME IS A MIDDLE NAME. `De La Cruz, M.J. K.L.` keeps the 2026-10-01 #575 reading's given 'M.J.' and its two reports, and 'K.L.', which ends the given part, reads middle where it read suffix: paired initials of a script with capitals are no credential at the trailing slots since decisions/S3's 2026-10-24 #864 entry, which holds the decision. 1.4.0's reading. C4-ambiguous-credential-class-at-the-comma.md000066400000000000000000000165071527303271700332700ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/decisions# C4 — the ambiguous credential class at the comma ## Standing - Behind two or more name words, a part after the comma of two or more words, every one a suffix word or a word of the ambiguous class (listed or by shape), at least one of the class and none a single-letter roman numeral, is the credential run, and the flip reports once over the whole part: `John Smith, Ed Ma` → suffix 'Ed Ma'; `Jane Doe, MS LAc` → suffix 'MS LAc'. A title/suffix dual opening such a part counts as a suffix word, and a part whose every class word is listed and written in capitals in a mixed-case name is read on the capitals rather than the count — 2026-09-27 #544. - A word of both the particle and the unambiguous suffix vocabulary (`vd`, `mc`) is a suffix word in the run and still speaks for nothing behind it in S7's company, in every casing: `John Smith, vd Ma` → suffix 'vd Ma' and `JOHN SMITH, VD MA` → suffix 'VD MA', each reporting `suffix-or-name`; `do`, in the ambiguous half, is a word of the class itself, so `John Smith, Jr do` reports where `John Smith, Jr vd` does not — 2026-10-01 #554. Elsewhere: nothing after the comma's decision unsettles the part it bound, so particles side by side in a credential part stay in the run (`John Smith, PhD vd DO` → suffix 'PhD vd DO'; `De La Cruz, DO DO DO` → suffix 'DO DO DO') and #562's pair report is silent (`John Smith, PhD DO DO` reports nothing) — decisions/C1's 2026-10-06 (Derek), #613 entry, its decision (2), which this file's #613 stub indexes; paired initials as the exception to the count — decisions/S9's 2026-09-30 (Derek), #563 entry; the uniform-case `vd` and `mc` that #554 left to #573 — decisions/P9's 2026-10-02 (Derek), #573 entry; the mixed-case `Doe, Jane PhD vd Ma`, which #554 left to #578, reads suffix 'PhD vd Ma' and reports — decisions/S8's 2026-10-04 (Derek), #602 entry, reversing decisions/S5's #578. Open: none in this file. ## Log - 2026-09-27 (Derek), #544 — THE NAME-WORD COUNT READS A RUN. The single-token rule rules/C1 then stated for the ambiguous class generalizes to a post-comma part of two or more words, every one suffix vocabulary or a class member (listed or by shape), at least one a member and none a single-letter roman numeral: behind two or more name words the part is the credential run, and the flip reports once over the whole part (`John Smith, Ed Ma`, `Jane Doe, MS LAc`). A title/suffix dual opening the part counts as suffix vocabulary there, and a run whose every member is listed and leans credential is left to the family-comma path, which already reads it whole. The forks and their measurements are decisions/S7's #544 entry. - 2026-10-01 (Derek), #562 — SUPERSEDED by #613 (decisions/C1, 2026-10-06), whose decision (2) binds the part before any chain runs and silences the pair report. Decided here then: a part holding two particles side by side, which group's chain had unsettled from the capitals' reading (`John Smith, PhD DO DO` read given 'PhD', family 'DO DO John Smith'), is read by the count, two name words before the comma flipping it to the credential run, reported; the reports that flip made on shapes the family-comma path had read whole were accepted (Derek: none of them is a name anyone would write on purpose). 1.4.0 read every one of these names as the fix did. Left open then, as #562 asked: `Smith, PhD DO DO`, one name word before the comma, and `John Smith, PhD van der`. - 2026-10-01 (Derek), #554 — A WORD OF BOTH THE PARTICLE AND THE SUFFIX VOCABULARY COUNTS IN THE RUN, AND STILL SPEAKS FOR NOTHING. After #544 the run test (`_segment.py`) counts `vd` and `mc` as suffix words, while S2's company (`_pieces._anchors`) refuses to let them speak for the word behind them, an exclusion added within PR #552 after that PR's own anchor pass had brought back a split of `Smith vd Ma, John` into family 'Smith Ma', suffix 'vd'. That split is not only an intermediate state of #552: 2.2.0 and 2.3.0 shipped it (2.0.0 and 2.1.0 read family 'Smith vd Ma'), #530 removed it in this cycle, and #552 kept it removed. The issue framed that as two answers to one question and offered (b): end the run at such a particle, so that one "has a live name reading" predicate decides both. DECIDED (a), the shipped behavior, and no parse moves. They are two questions. Run membership asks what the word IS, and `vd` is unambiguous suffix vocabulary; company asks whether the word SPEAKS FOR the one behind it, and a particle cannot, P2 joining it forward to exactly that word. Both answers follow from the word's vocabulary, and they differ by design rather than by drift; S2's "end the run" is the company's run, not the part C1 counts. The clause reaches exactly `vd` and `mc`: `do`, also in both vocabularies, is in the ambiguous half and so is already a word of C1's class, reporting where these two do not (`John Smith, Jr do` against `John Smith, Jr vd`). Recompute the set: `L = Parser().lexicon; L.particles & (L.suffix_acronyms | L.suffix_words) - L.suffix_acronyms_ambiguous`, and drop the subtraction to see `do` with them. The evidence is the writing that carries no other signal. Neither reading of `vd Ma` after a comma is realistic, a particle in front of a Chinese family name being as rare as `vd` or `mc` as a credential, so the mixed-case writing is an arbitrary edge case and decides nothing. Written in one case, where S2 has no capitals to read and the vocabulary is all there is, the part reads as the credential run, and that is the reading a reader would take: measured 2026-10-01 on master (fc682e36), `JOHN SMITH, VD MA` and `john smith, vd ma` read given 'JOHN'/'john', family 'SMITH'/'smith', suffix 'VD MA'/'vd ma' and report `suffix-or-name`, as `John Smith, vd Ma` does, and `John Smith, mc Ma` reads the same in every casing. It is also 1.4.0's reading, verified against the released wheel the same day: 1.4.0 read suffix 'vd Ma' and 'VD MA', and 2.0.0 through 2.3.0 read the whole name as the family. (b) would add a particle exception to C1, and to #563's paired-initials test besides, in order to give `JOHN SMITH, VD MA` family 'JOHN SMITH VD MA' with no given name: the worse reading, on exactly the input where nothing else speaks. #563's site needs nothing of its own. `García Márquez, vd G.J.` reads as `García Márquez, PhD G.J.` does, in every casing: given 'García', family 'Márquez', suffix 'vd G.J.', and neither reports. If a surname split by a credential run in silence is a defect, it is the run's, not the particle's. Open: #573 — uniform-case `vd`/`mc` before a comma and after a family comma (decided 2026-10-02, decisions/P9's entry), and the silent mixed-case `Doe, Jane PhD vd Ma` (now #578). Recompute: `parse(s)` with `as_dict()` and `[a.kind.value for a in parse(s).ambiguities]` over each string above and its `.upper()` and `.lower()`. - 2026-10-06 (Derek), #613 — NOTHING AFTER THE DECISION UNSETTLES WHAT IT BOUND, so the #562 pair report goes silent: decision (2) of decisions/C1's #613 entry. - 2026-10-18 #728 — SPLIT FROM decisions/C1 with the rule. The entries above were written under decisions/C1 while this rule was part of rules/C1, and moved here unchanged but for the pointers the move broke, the measurement paragraphs, each cut to one line naming its population, counts, baseline and PR, and the superseded passages, each cut to its decision and what replaced it; where one says rules/C1 or C1, it means the rule as it then stood, which held this one. derek73-python-nameparser-456b024/docs/design/decisions/C5-paired-initials-after-the-comma.md000066400000000000000000000103751527303271700317120ustar00rootroot00000000000000# C5 — paired initials after the comma ## Standing - The speaker scan (an unambiguous suffix word in front of the pairs that is not also title vocabulary) and the report's paired-initials exception (`pair_only`) ask `is_title_piece`, so a title a connective joins reads as a lone title word does; no default-vocabulary reading moves, and the one configured move, a configured connective that a configured delimiter splits into suffix words, is accepted — 2026-10-19 #738. Elsewhere: paired initials are the exception to the ambiguous class's count, read as the given name after the comma (`García Márquez, G.J.` → given 'G.J.', family 'García Márquez'), and what in front of them makes them the credential — decisions/S9's 2026-09-30 (Derek), #563 entry; behind a one-word particle surname (`De La Cruz, M.J. K.L.` → given 'M.J.', middle 'K.L.', the second pair ending the given part as decisions/S3's 2026-10-24 #864 entry reads it) — decisions/C3's 2026-10-01 (Derek), #575 entry; a generation ending the name before the comma keeps dotted paired initials the given name and makes two undotted capitals the credential (`John Smith Jr., M.J.` → given 'M.J.', suffix 'Jr.'; `John Smith Jr., MJ` → suffix 'Jr., MJ') — decisions/C1's 2026-10-19 (Derek), #761 entry. Open: none in this file. ## Log - 2026-09-30 (Derek), #563 — PAIRED INITIALS ARE THE EXCEPTION TO THE NAME-WORD COUNT. The decision is recorded under decisions/S9 (#563), with the dotted shape it belongs to; decisions/C3's #575 entry records how `De La Cruz, M.J. K.L.` reads behind a one-word particle surname. This bullet is C5's pointer to both. - 2026-10-19 (Derek), follow-up to PR #738 — THE TWO TITLE TESTS IN C5'S DECISION AND C11'S REPORT ASK `is_title_piece`, THE SHARED PREDICATE; UNDER THE DEFAULT VOCABULARY NO READING MOVES. The speaker scan ('an unambiguous suffix word in front of them that is not also title vocabulary') and `pair_only` (only titles in front of the first pair, the C11 report's paired-initials exception) each hand-copied a one-word `vocab:title` test that a title P3 joins does not pass; #738 found the same copy in C12 reading `Mr. and Mrs.` as a credential. The two tests differ only on such a joined title, and both sit behind C4's run: the speaker scan runs only where the part is the credential run, and the report asks `pair_only` only beside it. A joined title's free connective fails C4's every-word-a-suffix-word test, a connective that IS suffix vocabulary being frozen rather than joined, so neither site meets one by default. Measured 2026-10-19 against the parent (550720c6, extracted with `git archive`, `__file__` asserted on both sides), over `John Smith`, `Smith`, `Garcia Marquez`, `John Smith Jr.`, an empty head, `Dr. John Smith`, `Mary Jane Smith` before the comma × the lead-ins (none), `Mr. and Mrs.`, `Mr. & Mrs.`, `Dr.`, `Dr. and Ms`, `Mr. and Ph. D.`, `Ph. D. and Mr.`, `Secretary of State`, `MD`, `Ms`, `PhD`, `Mr. and MD`, `MD and Ms`, `AD`, `Prof. Dr.` × the tails `G.J. A.B.`, `GJ AB`, `G.J.`, `GJ`, `G.J. MD`, `G.J. PhD`, `A.B. G.J. MD`, `AD`, `XY ZW`, `G.J. XYZ`, `M.J` × the three orders: 0 of 3,465 readings, ambiguity details or `initials()` move. Counted by wrapping `is_title_piece` there, the speaker test never disagrees with the copy, and `pair_only` disagrees on 6 inputs, every one behind a generation (`John Smith Jr., Mr. and Mrs. XY ZW`), where the run failed and C12's report is chosen. The PR's reviews widened it to about 200,000 default-vocabulary parses each, with more heads, lead-ins and the comma policies, and found no move. A CONFIGURED connective that a configured delimiter splits into suffix words passes the run, and there the swap moves readings, found by the PR's code review: with `Lexicon.default().add(conjunctions={'phd/md'})` and `Policy(extra_suffix_delimiters=frozenset({'/'}))`, `John Smith, Ms. PHD/MD Ph. D. GJ` read given 'John', family 'Smith', suffix 'Ms. PHD/MD Ph. D. GJ' and now reads title 'Ms. PHD/MD Ph. D.', given 'GJ', family 'John Smith', the joined title no longer speaking for the pair; and `John Smith, MD PHD/MD Ms. G.J. K.L.` keeps its fields and now reports SUFFIX_OR_NAME. ACCEPTED (Derek, in the PR's review): a joined title reads as a lone title word does, the direction #738 took, on a configuration no default reaches. C6-unlisted-all-caps-word-needs-the-names-case-contrast.md000066400000000000000000000027171527303271700356210ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/decisions# C6 — an unlisted all-caps word needs the name's case contrast ## Standing Elsewhere: this rule's decision, that an unlisted all-caps word after the comma joins the ambiguous credential class by default behind two name words, where the name carries a case contrast (`John Smith, XYZ` → suffix 'XYZ'; `Smith, XYZ` → given 'XYZ'; `MÜLLER WEIß, HANS` → given 'HANS'), with its two-letter rule and the mixed-run membership — decisions/S9's 2026-10-01 (Derek), #564 entry, where the caps shape's switch is decided; the contrast test reused for a suffix word ending the name before the comma — decisions/C12's 2026-10-18 (Derek), #656 entry. Open: none in this file. ## Log - 2026-10-01 (Derek), #564 — AN UNLISTED ALL-CAPS WORD IS A CREDENTIAL IN THE PART AFTER A COMMA BEHIND TWO NAME WORDS, BY DEFAULT. The decision, its two-letter rule and the mixed-run membership are recorded under decisions/S9 (#564), where the caps shape's switch has always been decided; this bullet is C6's pointer to it. - 2026-10-18 #728 — SPLIT FROM decisions/C1 with the rule. The entries above were written under decisions/C1 while this rule was part of rules/C1, and moved here unchanged but for the pointers the move broke, the measurement paragraphs, each cut to one line naming its population, counts, baseline and PR, and the superseded passages, each cut to its decision and what replaced it; where one says rules/C1 or C1, it means the rule as it then stood, which held this one. C7-postnominal-part-is-read-before-any-join.md000066400000000000000000000146571527303271700334320ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/decisions# C7 — a postnominal part is read before any join ## Standing - No particle chain is made in a part past the second comma, after either comma, so C2 reads every title word there as a title (`John Smith, Jr., van der Berg Prof.` → title 'Prof.', suffix 'Jr., van der Berg'), and the chain's report list is withheld only after a family comma — 2026-10-11 #636. Elsewhere: a part the comma's decision reads as postnominal is bound before any join, so no particle run, connective join or bound given name reaches into it (`John Smith, Ph. D. and Mary Jones` → suffix 'Ph. D. and Mary Jones'; `De La Cruz, MA DO Mr.` → suffix 'MA DO', title 'Mr.'), the one join kept inside it being P3's connective binding title words into one title (`John Smith, Mr. and Mrs.` → title 'Mr. and Mrs.') — decisions/C1's 2026-10-06 (Derek), #613 entry, its decision (3), which this file's #613 stub indexes; the title join inside the part is group's own connective loop, which merges a connective run first (`Smith, Chancellor of the Exchequer MP` → title 'Chancellor of the Exchequer', suffix 'MP') and freezes the generational class — decisions/P3's 2026-10-06 #617 entry, which this file's #617 cross-reference indexes; the bound join declining a title word past the second comma (`John Smith, Jr., abdul Dr. Smith` → title 'Dr.'), the gap #636's review found — decisions/P5's 2026-10-11 (Derek), #637 and #640 entry; the per-word report in the part after a suffix comma, the other gap it found, staying (`John Smith, PhD van der Berg`) — decisions/C11's 2026-10-11 (Derek), #641 entry. Open: none in this file. ## Log - 2026-10-06 (Derek), #613 — NO PARTICLE RUN, CONNECTIVE JOIN OR BOUND GIVEN NAME REACHES INTO A PART READ AS POSTNOMINAL: decision (3) of decisions/C1's #613 entry, which binds the part before any join. - 2026-10-06 #617 — CROSS-REFERENCE: the title join decision (3) of decisions/C1's #613 entry makes inside a part read as postnominal is no longer `_comma._title_chains` but group's own loop, `_pieces.join_connectives`, and it now merges a connective run first (`Smith, Chancellor of the Exchequer MP` reads suffix 'MP') and freezes the generational class; decided and measured in decisions/P3's 2026-10-06 #617 entry, which amends (3). - 2026-10-11 (Derek), #636 — NO PARTICLE CHAIN IS MADE IN A PART THAT HOLDS NO NAME WORD. A part past the second comma, after either comma, is read wholly as suffixes, its title words as titles (C2, #603), but group still ran the particle chain over it, which builds a name unit no reading uses (the part after a suffix comma, which C2 covers too, had reached no chain since #613, `_comma.decide` binding it and emptying its segment before any join; a suffix-comma grid moves nothing here -- `{head} {w1 … wk}` for k from 1 to 3 over the heads `John Smith, Jr.`, `John Smith, PhD`, `, PhD`, `John Smith, MD Ma`, `John Smith, Jr. MA`, `Smith, PhD`, `, Jr.`, `John Smith, X.Y.Z.` and the words `van der de la von und St Freiherr do mc vd du Dr. Dr Prof. Prof Attorney Sir Lt.Gov. Mr. MA Ma PhD X.Y.Z. VI V Jr. Berg Jones`, 606,216 parses under the three orders, a sample reaching both comma structures): a particle past the part's first word took the words behind it, a title word among them, so `John Smith, Jr., van der Berg Prof.` read suffix 'Jr., van der Berg Prof.' where `John Smith, Jr., Prof.` and `John Smith, Jr., van Berg Prof.` (whose leading `van` chains nothing, P4) read title 'Prof.'. Now group makes no chain in such a part, and asks no chain bound there, so C2 reads every title word in it, bare (`Prof`) or mid-part (`van der Prof. Berg`) alike. DECIDED (Derek): no chain there, rather than the issue's proposal, #623's bound (`trailing_run_start`) at this site; that reads trailing titles only (H5), narrower than C2's rule, and adds a call where removing the chain takes one away. The chain's two report emitters had been handed no report list in these parts for the same reason (rules/C2's no-name-reading clause, 2026-09-28); with no chain there is nothing to suppress, and the handoff now suppresses reports only after a family comma (removing the tail half moved nothing over the suite, #624's comma-tail grid's 292,950 parses, every corpus name and case text, and the suffix-comma grid above). MEASURED 2026-10-11 against 3b2b6f0d (PR #639), over #624's comma-tail grid (97,650 texts under three orders) and every corpus name and case text: 18,318 parses on 6,106 grid texts move a role and none outside the grid (15,330 put a title word that stood behind a particle run into the title field, 2,988 take a particle back out of a title the chain had joined it to), and the four other grids move nothing. Recorded negative control: with the chain run in these parts again, 9 tests fail, and with it run only after a family comma, the review's rows `Smith, John, van der Berg Prof.` and `,, van der Berg Prof.` do. THE REVIEW (2026-10-11), measured, all garbage-neutral or better, no reading worse over the review's generated tail-part sweeps under thirteen configurations (fields, reports, `initials()`, `capitalized()` and the facade's views compared; the populations were the reviewer's and are not recorded here, so no count is quoted): a word in both the title and particle vocabularies inside a tail part now reads as a title as a plain title there already did (`John Smith, Jr., Berg St Smith` → title 'St'); a particle between two titles there becomes a suffix entry of its own (`John Smith, Jr., St von Mr. and Mrs.` → title 'St Mr. and Mrs.', suffix 'Jr., von'). Two gaps outside this change, already on master: P5's bound join still runs in a tail part after a suffix comma (`John Smith, Jr., abdul Dr. Smith` keeps 'Dr.' in the suffix where `John Smith, Jr., Berg Dr. Smith` reads title 'Dr.'), and rules/C2's no-name-reading clause is contradicted for the part after a suffix comma by #603's per-word report (`John Smith, PhD van der Berg` reports suffix-or-name on each word). Open: #640, #641. (Both settled 2026-10-11: #640 by #637's entry, decisions/P5; #641, decisions/C11.) - 2026-10-18 #728 — SPLIT FROM decisions/C1 with the rule. The entries above were written under decisions/C1 while this rule was part of rules/C1, and moved here unchanged but for the pointers the move broke, the measurement paragraphs, each cut to one line naming its population, counts, baseline and PR, and the superseded passages, each cut to its decision and what replaced it; where one says rules/C1 or C1, it means the rule as it then stood, which held this one. derek73-python-nameparser-456b024/docs/design/decisions/C8-listing-forms-part-after-the-comma.md000066400000000000000000000352371527303271700324040ustar00rootroot00000000000000# C8 — the listing form's part after the comma ## Standing - A part after the comma that holds no name word, titles and suffixes only, fixes no family boundary, so a part before it with more than one name word keeps its positional read, order and all: `John Smith, Mr.` → title 'Mr.', given 'John', family 'Smith', and family 'John' under either family-first order — 2026-08-23 #296. - The condition is no name word, not all titles, counted over name pieces: `John Smith, Mr. Jr.` → given 'John', family 'Smith', suffix 'Jr.'; `Smith Jr., Mr.` → family 'Smith', suffix 'Jr.' — 2026-08-23 #428. - P1's family-first fold reaches a positionally read name before the comma as it does without the comma: `de Mesnil Jean, Dr.` → family 'de Mesnil', given 'Jean' under either family-first order — 2026-08-23 #428. - A part after the comma that is nothing but suffix pieces is the credential run, whole, read before any title reading of its words: `Smith, Ph. D. Jr.` → suffix 'Ph. D. Jr.'; `Smith, Sr.` → suffix 'Sr.' where `Sr. Garcia` reads title 'Sr.'; `Smith, Esq.` → suffix 'Esq.'; and `Smith, Dr. Jr.` → title 'Dr.', suffix 'Jr.' — 2026-08-23 #296. - #296's audit of the title/suffix overlap stands: `do`, `jr`, `junior`, `phd` and `se` left the titles, `dr` and `sra` the suffix sets, so a leading `PhD` or `Jr` is a name word (`Jr John Smith` → given 'Jr'), `Do Quang Minh` reads given 'Do' with the particle-or-given fork, and a trailing bare `Dr` is a name word (`John Smith Dr` → family 'Dr') — 2026-08-23 #296. - `ms` stays out of the ambiguous set, a dual that position decides (`sa` left the vocabulary altogether on 2026-10-25, #857, below): `Smith, Ms.` → suffix 'Ms.'; `Smith, Ms. Jane` → title 'Ms.'; `John Smith, MS` → suffix 'MS' — 2026-08-23 #428. - A delimiter core the policy names is a word in the listing form's part after the comma, not structure, v1's limitation kept as parity (rules/C10's Accepted) — 2026-08-23 #428. Elsewhere: `md`, which #296's audit kept a dual, left the titles — decisions/indic-honorifics' 2026-10-23 (Derek), #853 entry. A credential opening the part after the comma, which C8 held until 2026-10-19 — decisions/C13's 2026-10-04 (Derek), #603 entry, which also makes `Smith, RN - CRNA` read suffix 'RN - CRNA' with the core a word of the run; a lone name word before a part with no given name, as in `Smith, Dr.` → title 'Dr.', family 'Smith', and `Smith, Sir` → given 'Smith' — decisions/H1's 2026-10-18 #720 entry; the period gate the #428 review decided, an ambiguous acronym counting when written with a period after each letter (`Smith, Ed.` stays no credential) — decisions/S3's 2026-10-19 entry; `田中さん, Dr.` keeping its honorific glued, the #428 review's recorded limit, tolerated under rules/W3 — decisions/cjk-comma-demotion's 2026-09-01 #469 entry; the chain's report on a positionally read name before the comma (`Freiherr von Berg Ma, Mr.` reports `particle-or-given`), the #428 review's other limit — decisions/C1's 2026-10-06 (Derek), #613 entry, its decision (4). Open: none in this file. ## Log - 2026-08-23 #296/#325 (its `ms` and `sa` joining the ambiguous set superseded by the 2026-08-23 (review of PR #428, four reviewers) entry, which keeps both out, and that entry's `sa` half by 2026-10-25 #857; its `md` dual superseded by #853, decisions/indic-honorifics) — the listing form's post-comma part is read for what it is. Three commits of the approved #291/#296 bundle (spec 2026-07-30, plan 2026-08-01; see decisions/comma-suffix-arc), in the plan's order. (1) A comma followed only by titles fixed no family boundary, so the pre-comma name keeps its positional read: `John Smith, Mr.` reads given 'John', family 'Smith', title 'Mr.' where 2.0 had merged the family ('Bob Jones, author' moves with it at every baseline). "Nothing but titles" is title-shaped AND not suffix vocabulary — the plan's helper said only the first, and would have read `Smith, PhD Jr.` as the title run 'PhD Jr.' and `Smith, Mr. Jr.` as 'Mr. Jr.' (both master's suffix 'Jr.'; the gate and a measurement against master caught them). (2) The TITLES/suffix overlap audit and the ordering, one commit because each alone is wrong (the plan's F1): reading the credential run first would make `Smith, Dr.` a suffix while 'dr' was SUFFIX_WORDS residue, and the audit alone leaves `Smith, Jr.` a title by the period-abbreviation inference. The audit table's dispositions ship as approved — 'do', 'jr', 'junior', 'phd', 'se' out of TITLES; 'dr' and 'sra' out of the suffix sets; 'ms' and 'sa' into the ambiguous set — with ONE deviation on evidence the table did not have: 'md' keeps dual membership, because bare 'Md' before a name is the Bengali and South Asian Muslim abbreviation of Muhammad (#343/#345's corpus rows `Md Abdul Karim`, `Md. Abdul Karim`; the drop read the first as given 'Md', middle 'Abdul') while 'MD' after the name is the degree — position decides, as for 'sr'. The ordering is wider than the plan's lone piece: a post-comma part that is nothing but suffix pieces is the credential run, whole, which is what #325 needed (`Smith, Ph. D. Jr.` had put the split credential in the given name once a second suffix made it no longer lone — a 1.4.0 regression; v1 read suffix 'Ph. D.', title 'Jr.'). Vocabulary decides which words qualify and position breaks the tie for the genuine duals: `Smith, Sr.` is Senior where `Sr. Garcia` is Señor, `Smith, Esq.` is the postnominal where H2 still reads `Esq. Smith` as a title (H2 carries that as Accepted). `Smith, Dr. Jr.` keeps the 2.0 deviation's other case — a title, then the last piece as the suffix (v1 read first 'Jr.'; 2.0 through master suffix 'Jr.') — and `Smith, John Jr.` its given name. Consequences the audit carries, all measured and pinned: a leading 'PhD' or 'Jr' is a name word; `Do Quang Minh` reads given 'Do' with the particle-or-given fork, and `Dr. Do Van Johnson, MD` given 'Do', family 'Van Johnson' (the #367 rule for that name is replaced; TITLES ∩ ambiguous is {freiherr, st} now, which keeps the chained emitter reachable); a trailing bare 'Dr' is a name word, joining every other title-only word there (#316 is the open question); `田中さん, PhD` reads suffix 'さん, PhD'; the #100 prefix-join tests use 'lt', a genuine dual, as their token. #291 (multi-word credentials) is the bundle's fourth commit and its own PR. - 2026-08-23 (review of PR #428, four reviewers) (its `sa` half superseded by 2026-10-25 #857; its `Smith, RN - CRNA` given 'RN' superseded by #603, decisions/C13; its unreported `Freiherr von Berg Ma, Mr.` superseded by #613, decisions/C1) — the repair's condition is "no name word", not "all titles": `John Smith, Mr. Jr.` (a title and a postnominal) had kept the merge the rationale argues against, and reads given 'John', family 'Smith', suffix 'Jr.' now. Its guard counts NAME pieces: the positional read peels a trailing suffix first, so `Smith Jr., Mr.` — two pieces, one name — read positionally lost its family (the code review); it keeps family 'Smith', suffix 'Jr.' at every baseline. The positional read records its ORDER, and post_rules' leading-piece scan keys on "assign records no order after a family comma" rather than on the structure, so P1's family-first fold reaches the pre-comma name as it does without the comma — `de Mesnil Jean, Dr.` under a family-first order had read family 'de' (the test review). The credential run does NOT step over a delimiter core the policy names (`Smith, RN - CRNA` reads given 'RN' under `extra_suffix_delimiters` as without it): the code review asked for it, a draft did it, and tests/test_suffixes.py's "the delimiter must not affect parsing at all" outside the suffix-comma form is v1 parity pinned on purpose — rules/C10 carries the limit as Accepted. A second deviation from the audit table: `ms` and `sa` do NOT join the ambiguous set — the gate is position-blind and the collision is not; gated, `John Smith, MS` lost its suffix-comma route and read title 'MS', and `Smith, Ms.` passed the gate on its one period and read as a credential anyway. Both are genuine duals, and position decides: `Smith, Ms.` reads suffix 'Ms.' (title at every baseline; rules/C8 carries it as Accepted, with `Smith, Ms. Jane` keeping the title) and `John Smith, MS` keeps suffix 'MS'. And the gate itself now means what S2 said (rules/S3 since 2026-10-19): an ambiguous acronym counts when written with ITS periods, one after each letter (`M.A.`, `J.D.`), not on any period — `Smith, Ed.` had become a credential on a name's trailing period, and `Jack Ma.` had read suffix 'Ma.' since 2.0 (S3 carries the boundary since 2026-10-19; 1.4.0 read family 'Ma.'). Two limits recorded rather than fixed: `田中さん, Dr.` keeps the honorific glued, joining master's `田中さん, Mr.` — the honorific peel runs in script_segment on the suffix-comma and no-comma structures, before group or assign can say the comma fixed nothing (C1 Accepted); and group's chain emitter still reports no PARTICLE_OR_GIVEN on the family-comma path, so `Freiherr von Berg Ma, Mr.` reads as its no-comma twin but without the report — group runs before assign decides which read applies, and plumbing a deferred report through the state is more than the shape is worth today. - 2026-10-04 (Derek), #603 — A credential opening the part after the comma makes it the postnominal part, whatever follows. The record moved to decisions/C13 with that clause (2026-10-19). - 2026-10-18 #728 — SPLIT FROM decisions/C1 with the rule. The entries above were written under decisions/C1 while this rule was part of rules/C1, and moved here unchanged but for the pointers the move broke, the measurement paragraphs, each cut to one line naming its population, counts, baseline and PR, and the superseded passages, each cut to its decision and what replaced it; where one says rules/C1 or C1, it means the rule as it then stood, which held this one. - 2026-10-19 — RULE SPLIT: a credential opening the part after the comma left rules/C8 for rules/C13, one rule per piece of evidence (docs/design/AGENTS.md); C8 keeps the part with no name word and the part that is nothing but suffix words. The #603 entry moved to decisions/C13, a cross-reference standing in its place. Where the entries here or elsewhere say rules/C8 of the opened part (the #613 and #544 entries in decisions/C1 among them), that piece is rules/C13's now. - 2026-10-25 #857 — `sa` leaves the vocabulary: its military reading (Seaman Apprentice, a title) and its credential reading are both refused on the word (`excluded`), superseding the 2026-08-23 entry's `sa` half and decisions/suffix-acronym-collisions' 2026-10-21 held-out entry. It reads as any name word does, whatever the case, because the Portuguese surname Sá written without its accent is the one bearer the corpora show. The path to it: marking `sa` ambiguous (`may_be_name`) fixed `Maria de Sa` but left three costs, and the evidence for each reading it was keeping came back empty. (1) A one-case name ending in `SA` still read a suffix (S6), so all 255 SÁ / SA pairs of tse-donors (#843) still parted. (2) The title reading took the slot in front of a name and alone after a family comma, which `ma` does not (`Smith, Sa` → title 'Sa' where `Smith, Ma` → given 'Ma'; `Smith, John PhD Sa` → title 'Sa', suffix 'PhD', unreported). (3) Every name read with `Sa` as a title in the committed and eight local corpora is a person whose given name is Sa (`Sa Liu`, `Sa Mahammed Ilyas`) or the Arabic Sa ad (`Sa ad Hariri`), or whose Portuguese surname Sá is written without its accent (`Sa Pinto`), none a rank; and every SA or S.A. suffix is a company (`Galderma S.A.`, `Novartis Biociências SA`), a South African qualifier (`FCP(SA)`), or initials typed without stops (`MANN, S.A.`, `BROWN, MARYAM SA`) — no person writes it as a credential, the 167 NPPES providers aside, whom `name_usage.py` counts and nobody has read by item. Readings now: `Maria de Sa`, `MARIA DE SA`, `maria de sa` → family 'de Sa'/'DE SA'/'de sa' (was family 'de', suffix 'Sa'/'SA'/'sa' since 1.4.0); `Joao Sa`, `ANA SA`, `ANA SILVA SA` → family 'Sa'/'SA'; `SA Smith` → given 'SA', family 'Smith'; `Smith, SA` → given 'SA' (was suffix 'SA', #296's audit row); `Mrs Sa Li` → title 'Mrs', given 'Sa' (the second title word is gone). Accepted: a credential `SA` after a mixed-case name is a name word, so `John Smith SA` reads middle 'Smith', family 'SA' (rules/S9 admits an unlisted all-caps word as a credential by shape only behind a comma, and this one has no vocabulary behind it); and so are the comma shapes, the same reading an unlisted two-letter caps word gets (`John Smith, XY`): `John Smith, SA` → given 'SA', family 'John Smith' (the name merged into the family, where 1.4.0 through 2.5.0 read given 'John', family 'Smith', suffix 'SA'), `John Smith, SA, PhD` → family 'John Smith', suffix 'PhD', `Smith, John SA` → middle 'SA', and `, SA` → given 'SA' (1.4.0 read title 'SA'; 2.5.0 suffix 'SA'). The corpora hold 15 such after-comma names in ctgov-officials and 10 in fec-individuals (`Spector, SA` went suffix → given 'SA'), and NPPES counts 167 US healthcare providers who write it, none read by item: if they turn out to be a credential, that is the evidence to restore it on. `Smith, John PhD Sa` keeps suffix 'PhD Sa' and reports SUFFIX_OR_NAME; `John Smith S.A.` and `Smith, S.A.` go through the unlisted-dotted rule and report SUFFIX_OR_NAME; a parenthesised `(SA)` after a credential, ClinicalTrials.gov's `FCP (SA)`, is a nickname 'SA' where it was inside the suffix (25 names in ctgov-officials). Local corpora, `snapshot.py --local` against the base 8c965d74 (2026-10-25): tse-donors moves 1,613 of 848,351 names, about 700 gaining the `SA` as part of the family name (`ADAO DE SA FERREIRA` → family 'DE SA FERREIRA', was family 'DE', suffix 'SA FERREIRA'; `WESLEY VITOR SA PEREIRA` → family 'PEREIRA'), and `local_corpora.py pairs tse-donors --agreement` reads 0 accent-only pairs with two surnames where the base read 255 SÁ / SA; ctgov-officials 54 of 352,104, 25 of them the `(SA)` nickname; psc-individuals 40 of 1,034,888 (three `Mrs Sa Li`-shaped names lose a title); ukec-individuals 0. Putting a reading back is cheap — add it and delete the matching `excluded` entry, which the build checks — but the form matters: a `credential` reading WITH `may_be_name` returns the costs the path above ended on (255 parting pairs, `Smith, Sa` reading a title once `military` is back), and one without it returns `Maria de Sa` reading suffix. (Corrected 2026-10-25, #752: the marker is now derived, so `sa`'s `family` and `given` readings mark a `credential` reading put back, and `may_be_name` is a build error; the unmarked form would need those counts moved into the reading's `note`, as `ld`'s are.) A caller who needs `SA` as a credential adds it to their own lexicon. C9-one-character-suffix-word-inside-a-comma-part.md000066400000000000000000000260061527303271700343230ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/decisions# C9 — a one-character suffix word inside a comma part ## Standing - Inside a comma part a one-character suffix word is read by what stands before it: behind another suffix word it continues the run, with a period or without (`Smith, PSM I` → suffix 'PSM I'; `Smith, PSM I.` → suffix 'PSM I.'), and behind a name word an ASCII period makes it a middle initial while the bare letter, with no further comma after it, is the suffix (`Smith, John V.` → middle 'V.'; `Smith, John V` → suffix 'V', v1 parity) — 2026-08-25 #430. - The class is one-character suffix vocabulary, not a roman numeral (`Smith, John 2.` → middle '2.'), and a run's first word that is title vocabulary too stays in the run (`Smith, RA I` → suffix 'RA I'; `Smith, Jr. I` → suffix 'Jr. I') — 2026-08-25 #430. - The given part asks no initial in front of a numeral, the no-comma reading of one, so a numeral behind an initial stays the suffix (`Chang, Andy C I` → middle 'C', suffix 'I') — 2026-08-25 #430. - A lone letter ending the given part ahead of a further comma is the middle initial whatever stands before it, a credential run in front stopping short of it (`Smith, John PhD I, Jr.` → middle 'I', suffix 'PhD, Jr.'), at the cost of splitting a certification level from its credential (`Smith, John PSM I, Jr.`) — 2026-10-25 #819. - The given part's walk asks the numeral's predicate, which reads a one-letter numeral there by the vocabulary, so an unlisted letter stays the middle initial (`SMITH, JOHN X`), and a numeral of two or more letters as the generation (`Smith, John VI` → suffix 'VI') — 2026-10-25 #819. - The given part's walk is strict-blind and the fork reports nothing: `Smith, John V` reads suffix 'V' under `lenient_comma_suffixes=False` as under the default, with no `suffix-or-name`, where `John Smith V` reports — 2026-08-25 #430. Elsewhere: the strict switch, which vetoes the numeral in the comma's decision and in a part that is nothing but suffix words — decisions/C14's 2026-10-19 (Derek), #761 entry; a credential starting a run to the end of its part, which reads `Smith, John PSM I I.` as suffix 'PSM I I.' where #430 had accepted middle 'I I.' — decisions/S8's 2026-10-04 (Derek), #602 entry; a bare letter behind a name word with a suffix comma after it stays a middle initial (`Smith, John V, Jr.` → middle 'V', suffix 'Jr.') — decisions/v1-xfail-triage's 2026-09-01 (Derek's triage) entry; an ideographic or fullwidth full stop is no abbreviation mark there (`Smith, John V。` → suffix 'V。') — decisions/cjk-full-stops' 2026-09-10 #322/#323 bundle. Open: none in this file. ## Log - 2026-08-25 #430 / #432 (its accepted `Smith, John PSM I I.` reading superseded by #602, decisions/S8) — one boundary, fixed as one: inside a comma segment a ONE-CHARACTER suffix word — the only kind a reader could take for an initial — is read by WHAT STANDS BEFORE IT. Behind another suffix it describes that suffix and continues the run, period included — `Smith, PSM I` and `Smith, PSM I.` are suffix 'PSM I'/'PSM I.' where they read given 'PSM' plus suffix 'I', an initial in that slot being no shape anyone writes. Behind a name word the period is decisive, marking an abbreviation and so name material — `Smith, John V.` is middle 'V.' where it read suffix 'V.', which is what `Smith, John B.` always did; the only thing that made V differ is that V is also suffix vocabulary. The bare form is the boundary and does not move: `Smith, John V` stays suffix 'V' (v1 parity, #144), which is why the test is the PERIOD and not the numeral. SCOPE, stated as the code reads rather than as the intent was argued: the class is one-character SUFFIX VOCABULARY, not "a roman numeral". It sweeps in `2` (`Smith, John 2.` is middle '2.'), misses `X` and `IX` (roman, but not vocabulary — `Smith, PSM X` is unchanged), and never touched `II`/`III`/`IV` at all, which are two characters and so were never initial-shaped and never vetoed. Anyone adding a one-character suffix in a script that has initials extends it. And the strict knob is consulted: under `lenient_comma_suffixes=False` the initial veto stands and the run ends at the numeral, which the first draft silently overrode by reading no policy at all. #430's cause was rules/S2's initial veto in `is_suffix_piece`, which keeps a numeral out of a suffix reading — right after a name, wrong inside a credential run — so the segment did not look like a run at all and fell to the given-and-suffix walk. Two wrong answers came out of that one cause, and a fix verified on `Smith, PSM I` alone would have left the other: where the run's first word is TITLES vocabulary too (`Smith, MD I`, and `Smith, Jr. I` through the period-abbreviation inference) the leading-title peel took it instead, giving title 'MD' plus given 'I'. THREE readers, one reading, which is the part worth copying. The gate that says a segment is a credential run, assign's router that assigns its roles, and group's one-entry join that renders it must agree PIECE FOR PIECE, and #429 shipped the inverse of its own fix by deriving that agreement twice. `segment_suffix_reading` returns the per-piece verdicts and all three read it (mechanisms.md#ONE-PREDICATE-PER-QUESTION); the intermediate state where only the gate and the router had been converted rendered `Smith, PSM I` as 'PSM, I', the render splitting a run whose roles were already right — the same tell as #429, one predicate along. TWO readers since 2026-09-06: #436/#437 took the render join out of group, and `segment_suffix_reading`'s remaining readers are assign's gate and assign's router, one call site between them. The predicate is not moved — one call site is no reason to relocate what two sites will contest again — and what is worth carrying away is unchanged, since the reason the three had to agree is the reason the two still do. Declined, and the v1 suite caught it: asking `is_trailing_numeral_suffix` (`_vocab.reads_as_generation` since #752) for #432's half. It answers the period question — `_ROMAN` is anchored and admits no period — but it also refuses a numeral standing behind an initial-shaped piece, which is a NO-COMMA rule and the opposite of this path's parity: `Chang, Andy C I` is first 'Andy', middle 'C', suffix 'I', and asking that predicate here made the numeral a middle. decisions.md's #401/#421 entry records the same fork declining to transfer to this walk under LENIENT. The half that applies is the period alone. BLAST RADIUS, over the 1069 corpus names against 56fb505c (PR #443): seven carry a one-character numeral behind a word inside a comma segment, every one bare, and none moves; the only movers are the three names this change added as examples, so the case table is the whole coverage. `Berg, abdul V` cannot reach the walk at all, the bound-given join making `abdul V` one piece, which is why the LENIENT mirror #401/#421 declined does not collide here. Two things the /simplify round settled by measurement rather than argument. The walk asks `is_suffix_piece(pieces[m - 1])` — a VOCABULARY test — where the reading carries the verdict it just recorded, and unifying them looks obviously right until you run it: the walk assigns `PSM` to GIVEN in `Smith, PSM I.`, so a reading-style flag says "behind a name" and sends the numeral to middle, losing the strict parity restored two commits earlier. The two sites ask different questions on purpose, and the vocabulary answer is the load-bearing one. The cost is a reading the walk cannot reach: `Smith, John PSM I I.` moves from middle 'I' + suffix 'PSM, I.' to middle 'I I.' + suffix 'PSM', two consecutive one-character numerals behind a credential — accepted, both readings being defensible for a shape nobody writes, and recorded here rather than chased at the price of two correct pins. The walk is also strict-blind, pre-existing and unchanged: it calls is_suffix_lenient unconditionally, so `Smith, John V` reads suffix 'V' under `lenient_comma_suffixes=False` as under the default. The reading consults the knob and the walk does not, which is now conspicuous where it was merely unexamined. Not this change's to fix. Not done, and recorded rather than left implicit: neither half emits an ambiguity, though the no-comma `John Smith V` reports `suffix-or-name` for the same fork. The comma path has never reported it (decisions/C8's #428 review entry records PARTICLE_OR_GIVEN suppressed there for a related reason), and adding an emitter needs a trigger, a stage-ownership entry and a case row — more than this fix, and #432 raises it as a secondary observation rather than a requirement. - 2026-10-18 #728 — SPLIT FROM decisions/C1 with the rule. The entries above were written under decisions/C1 while this rule was part of rules/C1, and moved here unchanged but for the pointers the move broke, the measurement paragraphs, each cut to one line naming its population, counts, baseline and PR, and the superseded passages, each cut to its decision and what replaced it; where one says rules/C1 or C1, it means the rule as it then stood, which held this one. - 2026-10-25 #819 — A LONE LETTER ENDING THE GIVEN PART AHEAD OF A FURTHER COMMA IS THE MIDDLE INITIAL, AND THE WALK ASKS THE NUMERAL'S PREDICATE. Derek agreed reading (A) of #819 on 2026-10-23 with the numeral predicate (decisions/vocabulary-model's 2026-10-23 #752 entry, item 8): the further comma has named the suffix, so a letter ending the given part in front of it has no generation left to be, and #602's credential run (decisions/S8) stops short of it. `Smith, John PhD I, Jr.` and `Smith, John PhD I., Jr.` read middle 'I' and 'I.' again, as before #602, and the run's report on them goes with the reading; `Smith, John PhD I` (no further comma) keeps suffix 'PhD I'. The clause reads a letter written as an initial (rules/T5: a bare capital A to Z, or a letter with its period), numeral or not (`Smith, John PSM X, Jr.` → middle 'X'), since what it asks is the slot, not the word; a bare accented or non-Latin capital is no initial there and stays in the run (`Smith, John PhD É, Jr.` → suffix 'PhD É, Jr.'). WHAT IT COSTS: `Smith, John PSM I, Jr.`, a certification level written ahead of a generation, splits from its credential (rules/C9's Accepted bullet). The spike that proposed this measured no corpus moving; on this change the committed snapshot holds no such name either, and the local corpora were read with it (decisions/vocabulary-model's 2026-10-25 2f entry). THE WALK'S NUMERAL QUESTIONS are the predicate's (decisions/vocabulary-model, mechanisms.md#ONE-PREDICATE-PER-QUESTION): the last-piece test asks it of a bare one-letter numeral, and in this part, where neither the count nor an initial in front is asked, it answers by the vocabulary, so `Smith, John V` keeps suffix 'V' and `SMITH, JOHN X` middle 'X' (FEC writes a middle initial `X` there, the constraint the predicate's design recorded); a one-letter numeral behind a suffix word in a part of nothing but suffix words is asked the same way, its answer unchanged. A numeral of two or more letters, which this rule never governed, is read in this part by S10 since the same change (`Smith, John VI` → suffix 'VI', reported). derek73-python-nameparser-456b024/docs/design/decisions/D1-segmenterless-activation-warning.md000066400000000000000000000037741527303271700323510ustar00rootroot00000000000000# D1 — the segmenterless-activation warning ## Standing - `parser_for(locales.JA)` without a segmenter warns and does not raise: the inert registration is itself a pinned property, and a warning is filterable by the caller who wants it; the Japanese install hint appears only when a Japanese script is among the dead ones, a hangul-only gap getting the generic remedies — 2026-08 #337, WARN rather than raise - The warning re-emits from `parser_for`'s frame, so the location reported is the caller's own call — 2026-08 #337, the warning exists because - The message offers code that type-checks, `Policy(segment_scripts=frozenset())`, pinned by a test, with the bare tuple literal in the denylist test — 2026-08-07 #339. Open: none in this file. ## Log - 2026-08 #337 — WARN rather than raise, deliberately: registering the JA pack without the extra must stay safe (the inert registration is itself a pinned property), and a warning is filterable by the caller who wants exactly that inertness. The Japanese install hint is conditional — it appears only when a Japanese script is among the dead scripts; a hangul-only gap gets the generic remedies. - 2026-08 #337 — the warning exists because parser_for(locales.JA) without segmenter= used to build a parser that silently behaved like a working one minus the feature; it re-emits from the parser_for frame so the reported location is the caller's own call. - 2026-08-07 #339 — diagnostics that hand the reader code must hand code that type-checks: the message's offered deactivation used a bare tuple literal for segment_scripts — an arg-type error under mypy in a py.typed package — and became frozenset(), pinned by a test asserting the offered spelling. The known-bad spelling is in the denylist test. The structural fact behind the recurrence: autodoc renders docstrings into the API reference, so docs/*.rst is NOT the boundary of "the docs" — a guide and the reference can teach different spellings on the same rendered page, invisibly to any .rst-only sweep. derek73-python-nameparser-456b024/docs/design/decisions/D2-construction-raises-parse-never-does.md000066400000000000000000000022131527303271700330460ustar00rootroot00000000000000# D2 — construction raises, parse never does ## Standing - Every raise in the locale-apply path is a plain `TypeError` or `ValueError`, so the rewrap with the locale's code cannot break on an exotic exception signature — 2026-07 PR #288. The two Declined lines below (2026-07-27) stand as written: no zh-vs-ja kind, and `order` stays reserved and unemitted for script-resolved names, while `segmentation` fires on a genuine multi-split fork (`夏侯惇` under `locales.ZH` reports it, and `남궁민수` by default). Open: none in this file. ## Log - 2026-07 (v2 core, PR #288) — every raise in the locale-apply path is a plain TypeError/ValueError so the wrap-with-locale-code rewrap cannot break on exotic exception signatures. Declined (ambiguity kinds for script-resolved names, 2026-07-27): - A "han-script" zh-vs-ja kind — applying the ZH pack IS the disambiguation; per-name flags after an explicit opt-in are noise. - ORDER emission for script-resolved names — native-script order is convention, not a guess; the reserved kind stays unemitted. SEGMENTATION fires only on a genuine multi-split vocabulary fork (夏侯惇, 남궁민수). derek73-python-nameparser-456b024/docs/design/decisions/H1-title-and-one-name-word.md000066400000000000000000000512771527303271700302230ustar00rootroot00000000000000# H1 — a title and one name word ## Standing - What stands beside the one name word decides nothing: a suffix, a nickname or a maiden name leaves a title's family reading in place (`Dr. Smith née Jones` → family 'Smith', `'Smitty' Dr. Jones` → family 'Jones') — 2026-08-25 #410. - The guard tests which roles are unoccupied and counts no units, a gap recorded and not fixed: a given run of two name units moves whole (`Freiherr de V Jr` → family 'de V') — 2026-08-25 #410. - A title run addresses as its last title does, its key matched whole or by its last word through the one predicate H1 and P5's licence share: `Her Majesty Queen Elizabeth` and `Mr Sir John` read given 'Elizabeth' and 'John' — 2026-09-08 #489. - Where a run stands before the one name word, that run addresses; a run behind the word decides its field only when none stands before, and a run written around a nickname is one run: `Sir John Prof.` → given 'John', `Dr. Smith Sir.` → family 'Smith', `Smith Sir.` → given 'Smith', `Dr. 'Smitty' Sir John` → given 'John' — 2026-09-08 #489; 2026-09-09 #316. - `prince` and `princess` are given-name titles and `lord` and `lady` are not, decided rather than deferred, since the text does not say which rank a bearer holds: `His Royal Highness Prince Harry` → given 'Harry' (the bare `Prince Harry` reads given 'Prince' in the default order since 2026-10-27, `prince` being a title only inside a title phrase, decisions/H9), `Lord Byron` → family 'Byron', `His Excellency Lord Duncan` → family 'Duncan' — 2026-09-10 #519. - H1 decides the field of the one name word before a comma with no given name after it, and of the one name word of a name read family-first: `Mary II, Dame` → given 'Mary' (`Elizabeth II, Queen` until 2026-10-27, when `queen` became a title only inside a title phrase, decisions/H9), `Smith, Sir` → given 'Smith' with the family empty, `Sir John` and `Pope Francis` given under every order — 2026-10-18 #720. - A particle surname keeps the family where something settled it, a comma (`van der Berg, Sir`, `Van, Sir`) or P1's fold asked of the leading name piece (`Sir de Mesnil`); an ambiguous particle no comma settled reads given (`Sir Abu Bakar`, `Sir Van` family-first; `Sheik Abu Bakar` until 2026-10-27, decisions/H9, which under the family-first orders now reads the word as the family and the particle given by position), and so does a never-given one the fold does not take (`St Sir de Mesnil` under every order) — 2026-10-18 #720. Elsewhere: the graf class, a title word borne as a name but rarely as its first word shipping as a title (`Greve Anna` → title 'Greve', family 'Anna'), is decisions/salutation-titles' 2026-10-04 #606 bundle; the renunciate class, the given-name titles of traditions that drop the surname (`Swami Vivekananda` → given 'Vivekananda'), is decisions/indic-honorifics' 2026-09-06 #346/#344/#343 bundle. Open: [#913](https://github.com/derek73/python-nameparser/issues/913) (the title words still read as titles though borne as names, #348's question answered by rules/H9: `Baron Davis` reads title 'Baron'; `Prince Fielder` reads given 'Prince' in the default order since 2026-10-27, decisions/H9); [#815](https://github.com/derek73/python-nameparser/issues/815) (whether N3 sets a suffix aside as this rule does, the nickname-led question the 2026-08-25 #410 entry left open: `'Smitty' Jones Jr.` reads given 'Jones', family ''). ## Log - 2026-08-25 (#410) — H1's "and nothing else" counted a suffix, a nickname and a maiden name as further name words, so a title-plus-surname name reported no family the moment any of them stood beside it: `Dr. Smith` reads family 'Smith' and `Dr. Smith née Jones` read given 'Smith' with the family empty. #410 reports the maiden flavor; measured, all three roles suppress the rule identically and the fix is one term in one guard, so the term went rather than the maiden role being special-cased. What decided the width is H1's own rationale — a title addresses by surname — which says nothing about what stands BESIDE the name. The rationale is stated for H1 rather than as a doc-wide principle about what a suffix is, deliberately: N3 counts a suffix the other way (`'Smitty' Jones Jr.` reads given 'Jones', family ''), and which of the two readings is right for a nickname-led name is not decided here. #399 is what made this urgent rather than latent: stopping the particle chain at the marker routed the canonical title-and-particle shape (`Freiherr von Richthofen geb. Albrecht`) into H1 for the first time. N3 moves with it, without N3 changing: `'Smitty' Dr. Jones` declines N3's one-piece count as it always did, and H1 now names the family behind it, so it reads family 'Jones' where it read given 'Jones' through 2.1. Five corpus names change reading at every baseline (PR #444); one of them, `Freiherr von Richthofen V`, only narrowed a diff the `fix(#424)` ledger rule already classified, so no run named it. The v1 suite shipped the nickname reading as a strict xfail (`tests/test_nicknames.py::test_nickname_and_last_name_with_title`), which passes now. Known gap between the statement and the guard: H1 says "exactly one name word" and "that word", but the guard never counts units. It tests which ROLES are unoccupied — no middle, no family — and then retags every given token, so a name whose given run is two name WORDS (units, not tokens) reaches it and moves both: `Freiherr de V Jr` reads family 'de V'. The distinction matters for reading the claim that follows: a multi-token given run is ordinary — the rules.md example `Freiherr von Richthofen V` has one, its two tokens being a single particle unit — and it is a run of two UNITS that no real input produces. A 58,338-name sweep found no corpus name and no rules.md example with one — but it is recorded because decisions.md elsewhere leans on H1's "exactly one name word" as a settled scope when narrowing P5's licence, and that reading of H1 is stronger than the code behind it. Not fixed here: the widening this entry records is about which roles disqualify the rule, not about how it counts. - 2026-09-08 [#489](https://github.com/derek73/python-nameparser/issues/489) (the #316/#489 bundle) (its VOCABULARY SCOPE superseded in part by #519, below, and its TRAILING REACH's front slot by the 2026-09-09 review round 2 entry, below) — a title RUN addresses as its LAST title does. `Her Majesty Queen Elizabeth` read family `Elizabeth` because the `given_name_titles` lookup was keyed on the whole run, and `her majesty queen` is no shipped entry. A run is written as several titles and is addressed the way its final one is, so the run's folded key is now matched whole OR by its last word. **This AMENDS the 2026-08-22 #369 entry under #P5**, which keyed both sites on the whole run so they could never disagree; the amendment keys both on the LAST word and the invariant is untouched — H1's retag and P5's licence still cannot read one run two ways, through one predicate beside `_title_key`. What the amendment reopens is that entry's EXAMPLE and not its reasoning: `'mr sir'` was #369's own instance of a run neither site treats as a given-name title, and it now IS one to both, `sir` being the last word. The whole-run arm is kept for a caller's multi-word phrase entry — `lt col` is stored and matched as one key — and its order carries nothing: the `or` short-circuits but decides nothing, since for a ONE-WORD run the key IS its last word. Every shipped given-name title is a single word, asserted in tests/v2/test_lexicon.py rather than counted here, so under the default vocabulary that arm can only ever match a one-word run, which the last-word arm reads the same way. Recompute the single-word property with `L = Parser().lexicon; sorted(t for t in L.given_name_titles if " " in t)`, which gives `[]` on 2026-09-08; the set holds 47 entries that day, and the argument survives the digit moving. Measured over the corpus glob as it stood at the fix: ONE name moves, `Her Majesty Queen Elizabeth`. TWO after the docs commit that follows, which puts `Dr. Sir John` in corpus_rules.jsonl as an H1 example — re-measured there with the predicate's last-word arm dropped to a whole-run key, so the diff read forwards is this amendment's. Probes that move with it: `Her Majesty Queen Elizabeth II`, `Reverend Mother Teresa` (`mother` is a given-name title), `Dr. Sir John`, `Mr Sir John`, `Xyz. Sir John` — H2's unlisted word in the run does not change the run's last word — and, through P5's licence, `Sir Sheikh abdul rahman`, which reads given `abdul rahman` with an EMPTY FAMILY, the Accepted outcome H1 already records for `Sir John`. VOCABULARY SCOPE, recorded so the boundary is not read as a gap this bundle left. `prince`, `princess`, `lord` and `lady` are not given-name titles, so `His Excellency Lord Duncan` still reads family `Duncan` and `Her Royal Highness Princess Anne` family `Anne`; `king`, `queen` and `mother` are. Whether the first four should join is a vocabulary question with its own frequency argument — it would move `Prince Harry` and `Lady Gaga` — and is deliberately out of this bundle, filed as [#519](https://github.com/derek73/python-nameparser/issues/519). Recompute the membership with `sorted(Parser().lexicon.given_name_titles)`. Frame delta measured at zero on both entry points: each site's read sits inside the branch it serves, and the reference name `Dr. Juan0000 de la Vega III` enters neither — H1's guard wants an unoccupied family, P5's a bound given-name word. TRAILING REACH, added 2026-09-09 in review of the docs commit. H1's site asks which ROLES are unoccupied and never where the title stands, so a run that #H5 chained in BEHIND the one name word decides that word's field exactly as a run in front of it does. Measured on this tree: `Smith Sir.` reads given `Smith` with an empty family and `Smith Queen.` reads given `Smith`, both runs ending in a given-name title, while `Smith Dr.` reads family `Smith`. No code moved for this — the reading has been the shipped one since the trailing walk landed — but the STATEMENT said "a title followed by exactly one name word" and described only the front slot, so what the review changed is the statement, an example line (`"Smith Sir." → given="Smith"`, which puts the name in corpus_rules.jsonl), and the `interacts:` lines, H1 gaining H3 and H5 and H5 gaining H1. The trailing half is where H1 and H5 meet: H5 decides which words leave the name, H1 decides the field of the one left standing. - 2026-09-09 (the #316/#489 bundle, review round 2) — WHICH run addresses, where a name carries a title run at BOTH ends. The TRAILING REACH paragraph above is right about the trailing slot and was incomplete about the front one: H1 collected every `Role.TITLE` token into ONE run, and with #316(a) shipped that run can span the whole name. `Sir John Prof.` keyed `sir prof`, whose last word `prof` is no given-name title, so it read title `Sir Prof.`, family `John` where `Sir John` reads given `John` — the title written behind the name changing what the title in front of it addressed by. `Queen Elizabeth Prof.` lost its given name the same way, and `Dr. Smith Sir.` moved in the other direction, the trailing `sir` making the leading `dr` address by given name and turning family `Smith` into given `Smith`. THE RULE: the run standing BEFORE the one name word is the run that addresses; a run standing behind it decides only when none stands before. The split is at the NAME WORD, and the first fix split at the first token of ANOTHER ROLE instead, which a nickname written in front of the titles satisfies: `'Smitty' Sir Jones Prof.` then found no leading run and read family `Jones` where `'Smitty' Sir Jones` reads given, and `Dr. 'Smitty' Sir John` lost the run it had — the same defect one nickname away, caught by the design review's two-input invariant. H1's own rationale is what decides it, saying in as many words that what stands beside the name word does not decide this reading; so a run written around a nickname is ONE run and `Dr. 'Smitty' Sir John` reads given `John` as `Dr. Sir John` does. That is #H5's transparency principle applied to the one-word name — `X Prof.` is `X` plus a title, so adding the title cannot change how the words in front of it are read — and it leaves the trailing slot's own reading untouched, `Smith Sir.` still reading given `Smith` because no run stands in front of it. rules/H1's statement carries the ordering and gains two examples, `"Sir John Prof." → given="John"` and `"Dr. Smith Sir." → family="Smith"`; #H5's one-name-word clause is conditioned the same way. FOUND BY THE PR REVIEW'S P5 FINDING, and that is the part worth keeping. The reserve fix under #P5 was correct on its own terms and left `Sir abdul rahman Prof.` reading family `abdul rahman`: the join fired for the right reason and the field was still wrong. What the review then asked was which run each site reads, and the answer was that they read DIFFERENT runs — group's licence has always read only the pieces ahead of the bound word (`range(fk)`), while H1 read every title token — so the 2026-08-22 #369 invariant under #P5, that H1 and P5 cannot read one run two ways, had been broken by the composite keying without any site's own tests noticing. Both now read the leading run, through the same predicate, and `nameparser/_pipeline/_group.py` records that beside the licence. MEASURED 2026-09-09 against the fix's parent `e489dc1` over every corpus name (PR #520): only the two rules.md examples this entry adds move (`Sir John Prof.`, `Dr. Smith Sir.`), no corpus name having had a title run at both ends. `Dr. King Sir.` is untouched and stays #H4's: its leading run is `dr king`, whose last word `king` IS a given-name title, so the word the peel left standing reads given `Sir.` with `title-or-name` reported. The end-to-end invariant is `tests/v2/test_parser.py::test_the_p5_licence_and_h1_read_a_title_run_the_same_way`, whose nickname rows are the only ones that separate the two splits above; frame delta zero on both entry points. - 2026-09-10 [#519](https://github.com/derek73/python-nameparser/issues/519) — `prince` and `princess` join the given-name titles; `lord` and `lady` do not. This RESOLVES the VOCABULARY SCOPE paragraph of the 2026-09-08 #489 bullet above, which is left as written: it named the four as not given-name titles and filed the question, and two of the four have now moved. The criterion is rules.md H Background's — membership follows how the title addresses — and `prince` and `princess` address by the given name (`Prince Harry`, `Princess Anne`), with no surname reading of the word behind it. The clause is for the two words and not for a royal class: TITLES holds `emperor`, `tsar`, `sultan`, `pharaoh`, `archduke`, `maharani` and their pairs, none of them weighed here, and `Emperor Akihito` still reads family `Akihito`. `lord` and `lady` split the same way as each other, by the bearer's rank, which the text does not carry: the given-name form is a courtesy style for children of the senior ranks alone — younger sons of dukes and marquesses (`Lord Peter`), daughters of dukes, marquesses and earls (`Lady Diana`) — and every peer and every wife takes the title or surname (`Lord Byron`, `Lady Thatcher`). [Debrett's "The trouble with titles"](https://debretts.com/the-trouble-with-titles/) and [Bratman's peerage primer](https://www.dbratman.net/nobility.html), both read 2026-09-10, agree on that boundary, Debrett's naming `Lady Jane Debrett` for a baronet's wife as the typical error. The issue gave `lord` the surname reading outright, hedged with a "mostly"; the hedge is this class. The set has no way to say "sometimes" — the `venerable` reasoning under #indic-honorifics — so Derek's call was to leave both out, decided rather than deferred, and the issue's proposed frequency test on `lady` was not run. Both are pinned as negative controls (`Lord Byron`, `Lady Gaga`) beside `His Excellency Lord Duncan`. Both read sites move, through the one predicate: H1's fold reads `Prince Harry` given `Harry` and `Her Royal Highness Princess Anne` given `Anne` (the #489 bundle's negative control, flipped), and P5's licence reads `Prince abdul Rahman` given `abdul Rahman` with an empty family, as `Sir abdul Rahman` does. What membership does NOT decide is whether the word is a title at all: `Prince Fielder` reads given `Fielder` now and read family `Fielder` before, wrong both ways, and that is [#348](https://github.com/derek73/python-nameparser/issues/348)'s collision. Measured 2026-09-10 over every name in `tools/differential/corpus*.jsonl` (1308 rows), parsed on this tree and on the same tree with a Lexicon whose `given_name_titles` lacks the two words, diffed across the seven fields and the ambiguity kinds: exactly TWO names move, `Prince Charles` and `Prince Fielder`, both radar-tier in corpus_issues.jsonl, both `{family, given}`. THREE after the docs commit, which puts `Her Royal Highness Princess Anne` in corpus_rules.jsonl as an H1 example. `Prince of Wales Jr` does not move — the joined run addresses by `wales`. Recompute by parsing the corpus glob twice with the two Lexicons and diffing. TITLES membership is unchanged, TITLES being the union. - 2026-10-18 [#720](https://github.com/derek73/python-nameparser/issues/720) — H1 decides the field of the one name word before a comma with no given name after it, and of the one name word of a name read family-first. Option A of the issue (H1 decides), Derek's call over option B (the comma decides, 1.4.0's reading of `Elizabeth II, Queen`, `John III, Sir` and `Smith, Sir`; 1.4.0 read `Anne, Princess` as first `Anne`, which this restores). `Elizabeth II, Queen` read family `Elizabeth` because assign writes the word before a family comma to the family and H1's site only ever moved given → family; the shape-only `John VI, Sir` read given `John` by accident, its numeral counting as a second word and making the comma a suffix comma, so the reading turned on how the numeral was spelled. The site now has the other direction: a family with no given and no middle, whose addressing run is a given-name title, moves to the given name. It is not gated on the structure, and that is what closes the issue's third open question too: under either family-first order assign places the one word of `Sir John` in the family, and H1 had never moved it back, as the 2026-08-22 #P5 entry recorded for `Sheik abdul salam` ("H1's exemption never runs under a family-first order"); both now read given under every order, P5's Accepted clause amended to match. A particle in the word keeps the family where something settled it: after a comma, C3's evidence that the part is a surname (`van der Berg, Sir`, `Van, Sir`); without one, a never-given particle, which P1's fold takes (`Sir de Mesnil`). An AMBIGUOUS particle with no comma is a word the declared order placed, so it reads given: the first draft skipped every particle and left `Aunt Della`, `Sir Van` and the corpus name `Sheik Abu Bakar` reading family under FAMILY_FIRST, against this clause and P5's amended one; the design-docs review found it by comparing `T X` under all three orders against `X, T` over every given-name title. Accepted, from the issue: a listing record whose given field holds only a given-name title (`Smith, Sir`) reads given `Smith` with the family empty, as `Sir Smith` does. Measured 2026-10-18 over every corpus name against the parent under all three orders (PR #735): under the default order only the four new H1 examples with a given-name title move (`Anne, Princess`, `Elizabeth II, Queen`, `John III, Sir`, `Smith, Sir`); under each family-first order 30 names move from family to given, every one a given-name title and one name word (`Pope Francis`, `Sheik Abu Bakar`). THE FOLD'S OWN PREDICATE (eb0081d7, the PR's /simplify). The never-given hold had been a hand copy of P1's test asked of the first family TOKEN; the cleanup made it ask P1's `is_lone_never_given_particle` of the leading name PIECE, as the fold does. The two disagree where a title-particle (`St`, `Freiherr`) opens the title run ahead of a given-name title: group then chains the never-given particle forward, the fold does not fire, and the default order already read given `de Mesnil` in `St Sir de Mesnil`; the copy held the family under the family-first orders, so the order decided what the default did not. Now every order reads given, as this entry's clause says. Found by the refactor's own review, which measured 868 texts where the two predicates disagreed, every one with `St` or `Freiherr`, and moved readings only under a family-first order; no corpus name has the shape, so the snapshot did not move. The commit's "No reading moves" is wrong for this shape; it is pinned by the H1 example `St Sir de Mesnil` family-first. The snapshot reads the default order only, so the family-first moves are this entry's to record. Recompute by dumping the corpus glob under the three orders on both trees and diffing. derek73-python-nameparser-456b024/docs/design/decisions/H2-leading-abbreviation-title.md000066400000000000000000000253061527303271700310540ustar00rootroot00000000000000# H2 — the leading-abbreviation title ## Standing - An abbreviation of two or more letters ending in its one period, opening the part that carries the given name (the whole name, or the part after a family comma), is a title even unlisted and even where it is suffix vocabulary, and a single-letter initial is not: `Insp. Jane Morse` → title 'Insp.', `Morse, Det. Insp. Jane` → title 'Det. Insp.', `Esq. Smith` → title 'Esq.', `J. Smith` → given 'J.' — 2026-06-30 #288. - The shape is an unbroken run of letters, so an unlisted abugida abbreviation stays a name word and only vocabulary recognizes it — 2026-06-30 #288. - The shape declines a period-marked word carrying a script with no initials: `田中. 太郎` → family '田中.', where `Smith. John` keeps title 'Smith.' — 2026-09-10. - C13 wins for a credential opening the part after a family comma (`Smith, Esq. John` → suffix 'Esq. John'), and the shape still reads a title there for a word that starts no S8 run (`Smith, Insp. John`, `Smith, Do. John` → title) — 2026-10-20 #761; its `Smith, Ma. John` example moved to the given name on 2026-10-26 #789. - A given name written short outranks the shape: a word the vocabulary lists with a `given` reading whose `same_as` is the full name, period-marked where the shape would fire, is the given name and the name reads as it would with the full name there (`Chas. Smith` → given 'Chas.', `Mrs Ma. Liza Cruz` → title 'Mrs', given 'Ma.'). The shipped set is the traditional English abbreviations and the Filipino `ma` and `jo`; a listed title still wins, and the reading acts nowhere else — 2026-10-26 #789. Elsewhere: #316's question 2, a symmetric leading rule reading `Esq. Smith` as a suffix, is declined and the trailing slot reads vocabulary, decisions/H5's 2026-09-08 #316 entry (the undated `Decided 2026-09-08` paragraph below records the hook it closed here); the shape does not fire before a family comma, decisions/H6's 2026-10-17 #701 and 2026-10-18 #710 entries; a letter typed decomposed is the same letter to the shape, decisions/nfd-shapes' 2026-10-12 #652 entry, and the script veto reads the composed spelling, its 2026-10-13 #665 entry; halfwidth katakana is classified since #594 (decisions/W4). Open: none in this file. ## Log - 2026-06-30 (leading-period-title design; v2 core, PR #288) — the shape test is v1 parity (period_abbreviation): two-plus letters then a period, bare initials exempt. Its site is the head of the part CARRYING THE GIVEN NAME — the whole name, or the post-comma part under a family comma — not "the head of the name"; that scope correction is PR #315 (2026-08-01, docs-only), verified against 1.4.0 from PyPI, so the parity claim is real and the narrower description never was. The extraction litmus (2026-08-15): the spec drafted this rule as "recognized by vocabulary, not by written shape" and the live parser falsified that framing — the shape heuristic is real, and what the abugida gap (#343/#344) shows is its LIMIT, not its absence. Recorded as the rule's Accepted consequence — and the 2026-08-15 landing initially re-narrowed the scope to "name- opening", correcting one error while preserving another; the eighth review round fixed it. Decided 2026-09-08 (was Open: [#316](https://github.com/derek73/python-nameparser/issues/316), what a trailing title-vocabulary word should do while the comma paths disagreed): the trailing slot reads VOCABULARY, not shape (#H5), and the leading inference here stays unconditional — #316's open question 2, a symmetric leading rule that would read "Esq. Smith" as a suffix, is DECLINED. #109 shipped the leading inference on purpose, the family comma already carries the credential case ("Smith, Esq." → suffix, this rule's own Accepted clause), and reversing S2's "a suffix never opens the string" for period-marked words for the sake of one word is the wrong trade. The two comma paths agree now: "John Smith Prof." and "Smith, Prof." both read title Prof. See #H5. - 2026-09-10 — the shape declines a word carrying a script with no initials (#323); the argument, the halfwidth-kana limit and the measurements are in #cjk-full-stops. That limit was closed on 2026-10-03 by #594, which classifies halfwidth katakana (decisions/W4). - 2026-10-20 (Derek), #761 — C13 WINS OVER H2 FOR A CREDENTIAL OPENING THE PART AFTER A FAMILY COMMA. The H section's skeleton migration (PR #779) found the two rules each claiming `Smith, Esq. John`: rules/H2 made an abbreviation opening the part after a family comma a title "even when the word is suffix vocabulary", with an exception only for a part that is nothing but suffix words (C8), while rules/C13 makes a credential opening that part the postnominal part however it goes on. The parser already read it C13's way, suffix 'Esq. John' with `suffix-or-name` under all three orders, and neither rule said so. Derek decided C13 wins, with no behavior change; rules/H2 gains the Precedence bullet and its Then names the exception. What the win covers was measured over the suffix vocabulary, each alphabetic word of two or more letters written title-case with a period in `Smith, John` (2026-10-20, py3.11, the tree at #779): 666 words read as the postnominal part, and 22 still read as a title by H2's shape — the 12 title/suffix duals (`Cpl.`), 7 ambiguous acronyms (`Ma.`, `Do.`) and `Abd.`, `Mc.` and `Vd.` — none of them a word that starts S8's run. So the bullet is scoped to C13's own words rather than to every listed suffix word, which Derek confirmed once shown the 22 (keeping the parse unchanged), and `Smith, Ma. John` is an example beside `Smith, Esq. John` and the unlisted `Smith, Insp. John`. - 2026-10-26 #789 (#752, phase 2k) — A GIVEN NAME WRITTEN SHORT OUTRANKS THE SHAPE. The traditional English abbreviations of given names (`Chas.` Charles, `Thos.` Thomas, `Jas.` James, `Geo.` George, `Robt.` Robert, `Benj.` Benjamin, `Jno.` John, `Saml.` Samuel, `Edw.` Edward, `Richd.` Richard, `Wm.` William) and the Filipino `Ma.` (María) and `Jo.` (José) opened the part carrying the given name as titles by the shape, from 1.4.0 through 2.6.0 (checked against the 1.4.0, 2.0.0, 2.3.0 and 2.6.0 wheels: `Chas. Smith` → title 'Chas.', `Ma. Cristina Reyes` → title 'Ma.', given 'Cristina'), and a reader of either would not hesitate: each is the given name. Agreed by Derek on 2026-10-23 as part of #752's design, decisions/vocabulary-model's entry of that date. Each word takes a `given` reading whose `same_as` is the full name, which compiles it into `GIVEN_NAME_ABBREVIATIONS` (`Lexicon.given_name_abbreviations`), and the shape declines a word in it: a vocabulary reading outranks a reading a shape rule derived, the precedence the vocabulary model allows a language pack and applies here in the default. The precedence is the shape's alone. A listed title is asked before the shape and still wins, so a caller who lists `chas` as a title gets the 1.4 reading back; and nothing else reads the set, so the bare word (`Ma Smith`, `Smith, John Ma`, `John Smith MA`) and the dotted word anywhere but the head of the given-name part (`John Smith Ma.` → family 'Ma.', `John Ma. Smith` → middle 'Ma.') read as they did, `ma` keeping its surname and credential readings. - **What the name reads as.** Wherever the shape read the abbreviation as a title, the name now reads, field for field, as it does with the plain given name `Jack` in the abbreviation's place, its case matched, and wherever the shape read none, as it did: `test_a_given_name_written_short_reads_as_a_given_name` (tests/v2/test_properties.py) holds that for each shipped word in its three period-marked spellings, in the 20 of its 21 slots where the shape or the comma's count reads one, and holds the three bare spellings to the set-emptied reading in all 21, under each of the three orders. One reading is not the full name's: rules/C6's case contrast skips a period-marked word, so `Chas. SMITH, XYZ` reverses (given 'XYZ', family 'Chas. SMITH') as `J. SMITH, XYZ` does and as it did before, where `Charles SMITH, XYZ` reads suffix 'XYZ'; left as it is, the contrast's question being C6's (found by the second review). So under a family-first order `Chas. Smith` reads family 'Chas.', given 'Smith', as `Jack Smith` does: the declared order places the word, which the shape had kept out of every name field. The comma moves too, since rules/C4 counts the name words before a comma by asking the shape: `Chas. Smith, Ed` reads given 'Chas.', suffix 'Ed', as `John Smith, Ed` does, where `Chas.` had counted as a title and the name reversed (given 'Ed', family 'Chas. Smith'). The bare spellings read as they did in every slot of that test, a capitals one behind a comma included (`John Smith, JAS` keeps suffix 'JAS'): its first version had put the set in the wordlist rosters the capitals shape and the name-contrast test read, which reversed that name and `Jas SMITH, XYZ` until the code review found it. - **Measured** against master 12384fae (2026-10-26), with `snapshot.py` for the committed snapshot, `cldr_agreement.py` for the agreement views, `snapshot.py --local --base ` for ctgov-officials, fec-individuals and tse-donors, and `local_agreement.py psc-individuals --base `: the committed snapshot moves 2 names, `Ma.` opening the name and rules/H2's own example `Smith, Ma. John`, now given 'Ma.'; the CLDR, GND and Wikidata agreement views do not move, and the conventions view gains its one `Ma.` rendering's title. ctgov-officials moves 15 of 536,981 officials, each `Ma.` opening the name in mixed case; fec-individuals moves 24 of 1,893,964 names, each written in capitals after a family comma (`WM.` 20, `MA.` 2, `GEO.` 1, `EDW.` 1); tse-donors moves 2 of 854,531 pairs, `MA.` opening the name. psc-individuals, scored against the fields its filers typed, has 86 records fixed, 0 broken and 2 changed, all 88 `Ma.` opening the forename box: 81 behind a salutation filed in the title box (#853's shape, `Mrs Ma. Liza …`), 7 with no title. The spike of 2026-10-24 counted the same. - **Vetting**, recorded on each word in en.toml and fil.toml: `tools/name_usage.py` counts each as a given name in US SSA data (Chas 3,223, Wm 2,736, Geo 1,324, Robt 468, Thos 166, Jas 124, Jno 11, Edw 6 births, summed over the years; `Benj`, `Saml` and `Richd` under the source's floor), and none as a credential in NPPES. `Jas` is also a surname (117 bearers in the 2010 US Census) and `Jo` a given name of its own (180,911 US births) and a Korean surname, and `Ma` a Chinese surname and a listed credential; a period-marked word opening the given part is a given name on each of these readings, and the credential reading had never taken it there, the shape reading a title first. `tools/vocab/query.py --word` found no other reading on any of the thirteen but `ma`'s and `wm`'s refused title. derek73-python-nameparser-456b024/docs/design/decisions/H3-title-runs-floor.md000066400000000000000000000135711527303271700271160ustar00rootroot00000000000000# H3 — the title run's floor ## Standing - The leading title run gives back its last word where everything behind the run is suffix pieces and that word is not suffix vocabulary: `Dr Pope Jr` → title 'Dr', family 'Pope', suffix 'Jr' — 2026-09-08 #316. - It gives back one word only: a joined title unit stays the title (`Prince of Wales Jr` → family 'Jr'), and where the word given back is the whole name it reads as the given name (`Dr Jr` → given 'Dr', suffix 'Jr', reporting `title-or-name`) — 2026-09-08 #316. - The floor asks about the run's last word and nothing else: a run ending in suffix vocabulary keeps it (`Dr Pope RA PhD` → title 'Dr Pope RA'), and a bare numeral behind the run is the numeral fork's, not this floor's (`Dr Pope V` → title 'Dr Pope', given 'V') — 2026-09-08 #316. Open: none in this file. ## Log - 2026-09-08 (the #316/#489 bundle) (its `Dr Smith V` reading superseded by #718, decisions/S10) — the leading title run leaves a name word standing, and a post-nominal is not one. Derek's question is what opened it, verbatim: "Why does `Dr King Jr` need to parse `king` differently than `Dr King`? Jr is a recognized suffix, so it could not count as a following name." The mechanism behind the old reading is an ORDER: the leading peel runs before the trailing suffix peel and its only floor was "a title needs a following piece", so it took `Dr King` whole and left `Jr` to be the name — title `Dr King`, family `Jr`, no suffix at all. The floor added here is a second one, asked of the run's last word: where everything behind the run is a suffix piece and that word is not itself suffix vocabulary, the run gives it back. `Dr King Jr` now reads title `Dr`, family `King`, suffix `Jr` — what v1 wanted (#v1-xfail-triage), what rules/S2's descriptive note predicted, and what the comma spelling `King, Dr Jr` has always given. - Measured over the differential corpus glob as it stood at the fix — 1123 distinct names, 1263 rows, before this bundle's own rules.md examples entered corpus_rules.jsonl and made it 1136 and 1284 — tree against the same tree with the floor removed: exactly one name changes, `Dr King Jr`. FOUR after the docs commit, which puts `Dr. King MD`, `Dr Jr` and `Sir Jr` in corpus_rules.jsonl as examples of the floor and its edge (THREE from 2026-09-09, the /simplify round having taken `Sir Jr` out of rules.md — see #H5 — where `Dr Jr` pins the same reading) — re-measured there, and `Prince of Wales Jr` enters with them and does NOT move, which is the one-word gate witnessed in the corpus. The case row `Dr. King MD` moves the same way. Recompute by parsing every name in `tools/differential/corpus*.jsonl` twice, once on the tree and once with `_pieces.leading_titles` monkeypatched back to the bare "a title needs a following piece" loop, and diffing the seven name fields plus `ambiguities`; 1.4.0 gives the degenerate reading for both names, so the change diffs at every baseline. - The word given back must be a NAME CANDIDATE, and that is what leaves the degenerate inputs alone. All-suffix inputs are untouched because the run's last word is itself suffix vocabulary — `MD DDS` keeps title `MD`, family `DDS`, and `Jr. Ph. D.` keeps title `Jr.`, suffix `Ph. D.` — and all-title inputs are untouched because there is no rest for the floor to read: `Dr.`, `Marquess of Bath` and `Coach` are unchanged. - ONE WORD is the gate, and it is a Task 1 review outcome rather than the drafting's shape. A joined title unit given back would lose the title entirely, which is worse than the reading it replaces: `Prince of Wales Jr` keeps title `Prince of Wales`, family `Jr` rather than becoming given `Prince of Wales` with no title at all. `Lord Chancellor Jr`, whose run is two separate words, does move — title `Lord`, family `Chancellor`, suffix `Jr`. - The edge accepted: where the run's whole content is the word given back, no title is left to make the reading H1's, so the word stands as the NAME rather than as the family. `Dr Jr` reads given `Dr`, suffix `Jr` and `Sir Jr` given `Sir`, suffix `Jr`, both reporting `title-or-name` by H4's lone-title-word convention (2026-09-09: `Sir Jr` is no longer written down anywhere but this sentence — once the floor empties the run no branch reads `vocab:given-title`, so the two readings are one and `Dr Jr` carries it). That is the residue rules/S2's note is now scoped to. - Two residuals the floor does not reach, recorded because they look like misses and are not. A run whose LAST word is itself suffix vocabulary is not given back, so `Dr King MD PhD` still reads title `Dr King MD`, family `PhD` — the floor asks about one word, not about the run's contents. And the floor asks `is_suffix_piece`, which vetoes a bare initial-shaped numeral, so `Dr King V` keeps the whole run as the title and reads given `V` (`king` being a given-name title and the run's last word, which is #H1's 2026-09-08 amendment), where `Dr Smith V` reads family `Smith`, suffix `V`. That numeral fork is #401/#421's territory and is deliberately outside this floor. - The floor lands in `leading_titles` rather than in assign because that predicate is the ONE answer to where the leading run ends (mechanisms.md#ONE-PREDICATE-PER-QUESTION): assign sets the roles from it and group's chain guard reads the same count, so a floor in assign would have given the two sites different runs. One cost measured and one shape forced by it: the branch's entry gate is two inline tag reads rather than `is_suffix_piece`, because `leading_titles` runs four times per parse and asking the authoritative predicate first cost 8 calls per parse of the reference name against a band with room for two (#parse-cost). The tag reads are the cheapest NECESSARY condition for the next piece to be a suffix piece at all, so an ordinary titled name leaves the branch without entering a frame; `is_suffix_piece` stays the predicate that ANSWERS. Frame delta measured at zero on both entry points. H4-input-that-is-nothing-but-vocabulary-still-has-to-name-somebody.md000066400000000000000000000333261527303271700377750ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/decisions# H4 — an input that is nothing but vocabulary still has to name somebody ## Standing - An input the title peel eats down to one last word that is itself title vocabulary reads that word as the name and reports `title-or-name`, the `detail` naming the word: `Lord Chancellor` → family 'Chancellor', `Dr. Pope` reports — 2026-09-07 #491. - `title-or-name` is emitted at assign's lone-name-word site, not at H1's retag, so it is reported under every name order, and so is the suffix half's `suffix-or-name`; the peel half's `detail` names no field — 2026-09-07 #491. - Nothing is reported where no word was chosen or where the word left standing is no title word: a title alone or a title run with nothing behind it (`Dr.`, `Prince of Wales`), and a title before an ordinary word (`Dr. Smith`) — 2026-09-07 #491. - An input whose every word is post-nominal vocabulary reads its first word as the name and reports `suffix-or-name` (`Rinpoche`, `QC MP` → given 'QC', `PhD`), reached only where no title was peeled (`RA DDS` reports nothing) and not with a maiden name beside it (`abd née Jones`) or for a lone CJK honorific a script order placed (`さん`); `Jr.` alone is H2's title — 2026-09-07 #491. - `MA` alone reports `given-or-family` where `PhD` alone reports `suffix-or-name`, an asymmetry between this rule and O5 recorded and not fixed — 2026-09-07 #491. - A lone name unit that is a join carrying title vocabulary reports `title-or-name` too (`John of Bishop`, `Smith and Bishop`; `prince` was the example until it left TITLES on 2026-10-27, decisions/H9), stated since 2026-10-20 as rules/H7 — 2026-09-07 #491. - The join clause (rules/H7's, recorded here) stands beside the peel clause, so a peeled title, a maiden name or a claimed word does not silence it (`Dr. Smith and Bishop`, `van and Bishop`, `Attorney General of Minnesota` report) — 2026-09-08 #518. Elsewhere: the title run's floor that leaves `Pope` standing in `Dr Pope Jr` and `Dr. Pope MD`, both reporting here, is decisions/H3's 2026-09-08 entry; the suffix and join halves' `detail` naming the field the word lands in, which moves with the order (`Rinpoche` 'a given name' by default, 'a family name' family-first), is decisions/A1's 2026-10-08 #626 entry; the join clause's move into a rule of its own is decisions/H7's 2026-10-20 #761 entry. Open: none in this file. ## Log - 2026-09-07 #491 (its join clause's place on O5's branch superseded by #518, below, and its silent `Dr King Jr` and `Dr. King MD` by the title run's floor, decisions/H3) — the reading is unchanged and the silence is what ends. Handed a string the title peel eats down to one last word which is itself title vocabulary, the parser reads that word as the name; decisions/v1-xfail-triage recorded it in the fourth of its NOT FIXED entries — "this is a name parser, not a title parser" — and said in the same breath that what is actually wrong is the guess being silent. It now reports `title-or-name`, with `detail` naming the word that was made into the name. Six corpus names gain it when this is written: the Queen's Bench string, `Lord Chancellor`, `Dr. King`, `The Rt Hon`, `His Holiness` and `His Holiness the Dalai Lama`. Three read contract-tier and three radar, and the tier split is an artifact of the documentation rather than of the shape — the three contract ones are contract because this bundle made them rules.md examples, which puts them in corpus_rules.jsonl. (2026-09-08, the #316/#489 bundle: the peel half's population was TEN, and the count moved for two different reasons. `Dr King Jr` and `Dr. King MD` gained the report because the title run's floor now leaves a name word standing where the run used to swallow it (#H3), and `Dr Jr` gained it as the same floor's residue; those three also ENTERED the corpus in that commit, as rules.md examples of the floor. `Sir Jr` gained the report too and reads exactly as `Dr Jr` does, but it is NO corpus member and is counted nowhere here: it entered corpus_rules.jsonl in the same commit as a rules.md example and left again on 2026-09-09, when the /simplify round dropped the example as a duplicate of `Dr Jr`'s (#H5). So the peel half is NINE from that round, six contract and three radar. Recompute rather than trusting any of these numbers: parse every name in the `tools/differential/corpus*.jsonl` glob and collect the ones whose `ambiguities` carry the kind, keeping the tier the FIRST file holding the name declares with the contract files sorted first, as `main()` loads them — the README's table is where the tiers are, and sorting the glob plainly instead reads `Dr. King`, `Dr King Jr` and the Queen's Bench string as radar and gives three and six.) `Dr. King` is the one worth arguing about and it is deliberate: `king` is in the titles vocabulary for the addressing forms, which the triage entry above decided and did not reopen, so `Dr. King` IS an input whose last standing word is title vocabulary and the rule claims it. Reporting there is honest rather than noisy — the reading came from a convention, not from anything the input says — and a caller who wants only the exotic cases has `detail` to filter on. ONE emitter, and it is at assign's lone-name-word site rather than at H1's retag, which is where the drafting put it. H1 is not the site: under a declared family-first order the assignment places the word in the family directly and H1 never runs, so an emitter there would report under one order and not the other for a reading that is the same either way. Measured under the default order, FAMILY_FIRST and FAMILY_FIRST_GIVEN_LAST, the six report identically. That placement is also why the `detail` names no field, unlike O5's: under the default order H1 retags the word after assign, so a field named at the emitter would be the one the word was placed in and not the one it ends in — and the fork the kind reports is title-versus-name, which no field answers either way. What is silent, all measured 2026-09-08. A lone title word: `Dr.`, `Sir`, `King` and the chained `Prince of Wales` are a title run with nothing behind it, the peel takes the whole string, no word is left standing to be read as a name, and nothing was chosen — mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE's "a branch that runs but changes nothing is not a decision". A title with an ordinary word behind it: `Dr. Smith` and `King Charles` leave a word standing, but not a title-vocabulary one, so H1 alone explains them. And a title followed by post-nominal vocabulary: `Dr King Jr` and `Dr. King MD` peel `Dr King` and `Dr. King` WHOLE, leaving a credential that the bare-suffix carve-out makes the name — a different convention, and its report is scoped to inputs no title stands in (spelled `n == 0` when this was written, one clause of the `field_undecided` predicate since the 2026-09-08 consolidation recorded under O5), so a title in front of the run takes the input out of it and leaves the reading H1's. **Corrected 2026-09-08 (the #316/#489 bundle):** the two example names no longer read that way and the sentence is wrong about them, though the SCOPE it describes is unchanged and still true of the suffix half. The title run's floor gives back a one-word last piece where everything behind the run is post-nominal (#H3), so `Dr King Jr` reads title `Dr`, family `King`, suffix `Jr` and `Dr. King MD` title `Dr.`, family `King`, suffix `MD`. Both now leave a NAME word standing, and both report through this rule's TITLE half rather than being silent — `king` being title vocabulary is what makes them the peel shape. What is still true, and is what the sentence was written to say, is that the SUFFIX half is reached only where no title was peeled first: `MD DDS` has a title peeled and reports nothing, `DDS` being no title. rules/H4's Accepted clause is rewritten to match, and `Dr. King MD` is an example line there now. The peeled titles are never tested for anything: H2 makes an unlisted abbreviation a title by SHAPE, and `Xyz. Smith` is not this input. What the rule turns on is the word left standing. A refinement of Derek's, made after the population was measured and widening the rule past the all-titles shape it was drafted for: a lone name word that is a JOIN (P3) carrying title vocabulary reports `title-or-name` too, the fork there being whether the title word inside the unit is a title at all rather than which field the unit takes. `John of Prince` and `Smith and Prince` are the measured inputs; no corpus name reaches that branch, because a join LED by a title word is chained into a title run by H3 (`Prince of Wales`), so the two are pinned as case rows rather than as rules.md examples. It sits on O5's branch and takes precedence there, which is why O5's statement says a title silences THIS kind and not every report at the site. The suffix half is the same argument on the other vocabulary and needed no new kind. An input whose every word is post-nominal vocabulary reads its first word as a name — assign's "everything suffix-shaped after titles: first one is the name" carve-out — and that is the doubt SUFFIX_OR_NAME already names, so it reports that. `Rinpoche` and `QC MP` are the corpus names; `PhD`, `MBA` and `III` are the same shape. `Jr.` alone is NOT the shape at all: H2's opening-abbreviation rule reads it as a title before the suffix vocabulary is consulted, which is that rule's stated precedence and is recorded here because the expectation going in was that `Jr.` alone read as a name. The guard carries two exclusions of its own. A maiden name beside the credential says the input is not post-nominal vocabulary and nothing else, so `abd née Jones` is out for the reason M4 keeps it out of O5's report. And the report is scoped to names no script order placed, so a lone glued CJK honorific — さん, 씨, 선생님 — reports nothing: that is the same shape read through the glued-honorific rules (W2, #271/#308) and the script's own order, and whether those readings should report is left to the arc that revisits them rather than settled here. An asymmetry on the boundary between this rule and O5, noted and deliberately not fixed. `MA` and `Ma` alone report `given-or-family`; `PhD` alone reports `suffix-or-name`. Both are a bare credential with nothing beside it, and what separates them is which gate reads them: `ma` is an AMBIGUOUS acronym, so S2's gate declines to peel it and the word stands as the one name word, which is O5's branch; `phd` is unambiguous, so it peels to suffix, leaves no name piece, and reaches the bare-suffix carve-out, which is this rule's. Two conventions, two kinds, and the reading each name gets is the same either way — the caller sees a report in both cases and only the kind differs. Fixing it would mean one of the two gates changing what it reads, which moves fields for a bundle that moves none. Nothing moves but the report, and the rules go in the three 2.x ledgers only — two of them at first and a third after the 2026-09-08 round below, which is where the ten names and the join clause's own rule come from. None is a copy of a wordlist and all three are declared in `_NOT_A_VOCABULARY_COPY`: a member copying TITLES would reach `Dr. Smith`, `Rabbi Cohen` and every other title-plus-surname name, and a member copying the suffix sets would reach `John Smith MD` and `Smith Jr.`, none of which moves. What selects these names is a SHAPE — the peel leaving one unit, and vocabulary standing in it — which no wordlist expresses. The population is EIGHT distinct names when this is written, TWELVE after the #316/#489 bundle and ELEVEN from 2026-09-09, when the /simplify round on the bundle's PR took `Sir Jr` out of rules.md and so out of corpus_rules.jsonl (#H5); the ten this entry says twice above names two OTHER quantities — the ten corpus ROWS the eight names occupy, `Dr. King` and the Queen's Bench string each appearing in two corpus files, and the peel half's ten after the bundle, itself nine from that same round. Recompute all three by parsing every name in the `tools/differential/corpus*.jsonl` glob, collecting the ones whose `ambiguities` carry the kind, and counting rows and distinct names separately (2026-09-09: eight names in ten rows at a0b93f0, eleven names in fourteen rows on this branch, nine of the eleven the peel half and two the join half). - 2026-09-08 #518 review round — the join clause is HOISTED beside the peel clause instead of sitting under O5's. It had been written inside O5's `n == 0` leg, so a leading title, a maiden marker or a vocabulary claim on the word silenced it — and every one of those decides which FIELD the unit takes, which is not what this clause asks. Measured before and after on this branch: `Lord Chancellor née Jones` and `Dr. King née Jones` (maiden), `Dr. Smith and Prince` and `Mr. John and King` (a peeled title), `van and Prince` and `J. and Prince` (a claimed word) were all silent and all now report `title-or-name`, with no role moving on any of them. The maiden pair takes the peel half and the other four the join half. TWO CORPUS NAMES MOVE, which the drafting expected to be zero: `Attorney General of Minnesota` and `Deputy Secretary of State`, both a title peeled in front of a joined unit that stands last — and a title needs a following piece, so H3 cannot chain the join into a title run the way it chains `Prince of Wales`. "No corpus name reaches the branch" was true only of the guarded version, and the claim is corrected in rules/H4 as well. They take a third `feat(#491)` ledger rule of their own rather than joining the all-titles alternation, whose issue line describes an all-titles input, which neither of these is. Also this round: the emitter comment's carve-out list no longer names "a group-flagged credential" (no such condition is in the code, and `phd_split` pins the opposite), and the `Order` NamedTuple is `EffectiveOrder` with field `order`, `roles` having been the name of two different things three lines apart. derek73-python-nameparser-456b024/docs/design/decisions/H5-trailing-title-run.md000066400000000000000000000710421527303271700274220ustar00rootroot00000000000000# H5 — the trailing title run ## Standing - After the trailing suffix run, successive period-marked words the title vocabulary lists chain into the title from the end, leaving one name word standing, and join the title field in input order: `John Smith Prof.` → title 'Prof.', family 'Smith'; `John Smith Prof. Dr.` → title 'Prof. Dr.'; `Dr. John Smith Prof.` → title 'Dr. Prof.' — 2026-09-08 #316. - The back is claimed by vocabulary and the front by shape, so an unlisted abbreviation (`John Smith Xyz.` → family 'Xyz.') and a bare title word (`John Smith Sir`, `Mary Jane Pope`) are name words there, the walk asking `is_title_piece` and never `is_leading_title` — 2026-09-08 #316. A listed salutation ending the given part after a family comma is the exception, read as the title bare (decisions/H8). - No fork is reported; where the doubt is real, the word left standing carries it (H4) — 2026-09-08 #316. - The given part after a family comma gets the walk: `Smith, John Prof.` → title 'Prof.', given 'John' — 2026-09-08 #316. - The trailing peel and the title chain are one reading run to a fixed point (`_pieces.tail_reading`), so a trailing title is transparent to the suffix reading (`John Smith Jr. Prof.` → suffix 'Jr.'; `abdul rahman MA Prof.` reads as `abdul rahman MA` does, plus the title), and the bound join takes no piece the chain takes (`Sir abdul Prof.` → given 'abdul') — 2026-09-09 (the /simplify round on the bundle's PR). - Transparency holds for one name word where a title run stands in front of it (`Sir John Prof.` → given 'John'); with none in front, the trailing title decides that word's field as H1 reads it (`Smith Prof.` → family 'Smith', `Smith Sir.` → given 'Smith') — 2026-09-09 (review round 2). - On a main segment the particle chain stops in front of a trailing title (`John van der Berg Prof.` → title 'Prof.', family 'van der Berg'), and a title a connective joined into a longer unit is no single word (`John Smith Prof. and Dr.` → family 'Prof. and Dr.') — 2026-10-07 #614. - After a family comma the given part's chain stops where `tail_reading`'s run begins, and P6 reads the particle as though the title were absent: `Smith, John van der Berg Prof.` → middle 'van der Berg', `Smith, Jan van Prof.` → family 'van Smith' — 2026-10-10 #623. - Where that bound and the given part's own walk disagree behind a title, garbage shapes read off it, recorded and not fixed (`Smith, John Mc Prof. V, Jr.` → suffix 'Mc, Jr.', middle 'Prof. V'; the entry's `Smith, John Mc Prof. VI` has read title 'Prof.', suffix 'VI' since #784) — 2026-10-10 #623. Elsewhere: a maiden clause in front of a trailing title is no join that hides it (`Mary Smith née Jones Prof.` → title 'Prof.'), decisions/M2's 2026-09-26 #535 entry; a bare ambiguous acronym ending the given part after a family comma is the credential, so `Doe, John Prof. MA` reads title 'Prof.', suffix 'MA', decisions/S5's 2026-09-18 #531 entry on the comma report's reach; the bound join after a family comma declines a title word this run takes (`Berg, abdul Prof.` → title 'Prof.'), decisions/P5's 2026-10-11 #637 entry; a part past the second comma makes no particle chain, so `John Smith, Jr., van der Berg Prof.` reads title 'Prof.', decisions/C7's 2026-10-11 #636 entry; a report names the field its word lands in, closing the `Dr. John of Prince` detail mismatch, decisions/A1's 2026-10-08 #626 entry; the cost of a period behind an ordinary surname that is title vocabulary (`Mary Jane King.` → title 'King.', family 'Jane') is the trailing-position bullet of decisions/vocabulary-exclusions. Open: none in this file. ## Log - 2026-09-08 (the #316/#489 bundle; closes #316(a)) (A1's scope superseded by review round 2 and its single re-peel by the /simplify round, both below; A1's `John Prof. MA` family 'MA' by #289, decisions/S6, `John MA` reading suffix 'MA'; A2's `Smith, John Prof. MA` middle by #531, decisions/S5; the detail mismatch by #626, decisions/A1; Q3's unconditional H2 in part by #761, decisions/H2, C13 winning after a family comma; the bare title word's name-word reading in part by 2026-10-24 #835, decisions/H8, for a listed salutation ending the given part after a family comma) — a trailing run of period-marked title words is a title. `John Smith Prof.` read family `Prof.` and lost the surname while `Smith, Prof.` read title `Prof.`; the two comma paths disagreed and #316 is that question. They agree now. - **The doctrine, stated rather than left to be inferred.** A period-marked word is claimed by SHAPE at the front and by VOCABULARY at the back. At the front an unlisted abbreviation is a title (#H2) and the shape outranks the vocabulary, which is why `Esq. Smith` reads title though `esq` is post-nominal vocabulary. At the back there is no shape rule, so only a listed title word chains into the title and `John Smith Xyz.` keeps family `Xyz.`. A BARE title word at the back is a name word — `John Smith Sir` and `Mary Jane King` are untouched — TITLES holding ordinary surnames being exactly why, which is the Excluded (TITLES) block in decisions/vocabulary-exclusions ("the trailing-position rule that must NOT be adopted"). The asymmetry is honest rather than an oversight: #109 shipped the leading inference on purpose, and no blanket trailing vocabulary rule is safe while king, judge and bishop are titles. - **Q3 declined: H2 stays unconditional.** #316's open question 2 asked for the symmetric leading rule, `Esq. Smith` → suffix. Declined: it reverses rules/S2's "a suffix never opens the string" for period-marked words for the sake of one word, and the family comma already carries the credential case (`Smith, Esq.` → suffix). Recorded at #H2, where the `Open: #316` hook was. - **Q2 decided: a RUN, mirroring #H3.** Successive single words that wear the abbreviation shape AND are title vocabulary chain into the title from the end, floor one name piece, so `John Smith Prof. Dr.` reads title `Prof. Dr.` rather than making `Prof.` a name word. Not chosen: one word only, which would have left `Prof.` standing as a name word in exactly the input the rule is about. The title field joins in INPUT order and the rendering needed no change — the title view joins TITLE tokens in token order already, so `Dr. John Smith Prof.` reads title `Dr. Prof.` for free. - **The predicate refuses `is_leading_title` and that refusal is the rule.** The walk uses `is_title_piece`, the vocabulary read, shared with the leading run so the two cannot disagree about what a title WORD is while disagreeing, deliberately, about what a title SHAPE is. `is_leading_title` carries H2's unlisted-abbreviation inference; with it, `John Smith Xyz.` would lose its family name to a title. That is the plan's mutation check for this commit. - **No fork is reported**, which is #316's open question 4. Under the input-is-a-name premise a period-marked title word is not a reading a reader would hesitate over; where the doubt is real it is the word left STANDING that carries it, and #H4 already reports that. No `AmbiguityKind` is added by this bundle and `tests/v2/test_contracts.py::_AMBIGUITY_TRIGGERS` is unchanged. - **A1, the peel order, decided by Derek: the first peel is PROVISIONAL and a trailing title is TRANSPARENT to the suffix reading.** `X Prof. Y` reads exactly as `X Y` reads, plus the title. SCOPED 2026-09-09, in review of the docs commit: that is a claim about inputs where a name word still stands on both sides of the chain — two or more name words. Where the chain leaves ONE name word there is no second reading for it to be transparent to, and #H1 decides the field instead: `Smith Prof.` reads family `Smith` where `Smith` alone reads given `Smith` and reports `given-or-family`, and `Smith Sir.` reads given `Smith` with an empty family, `sir` being a given-name title. All three measured on this tree; rules/H5's statement carries the scope and `_assign.py`'s excerpt of it was updated in the same edit. The problem is order: with ONE peel, `Prof.` standing behind `Jr.` stopped the suffix peel before `Jr.`, and the title walk then removed the very word that had been blocking it — `John Smith Jr. Prof.` read family `Jr.`, a generational suffix promoted to the family name, which is worse output than the reading it replaced. So the pieces the walk takes are spliced out and ONE peel runs over what stands, in original order, and that second answer alone places a piece or reports a fork. Measured: `John Smith Jr. Prof.` reads suffix `Jr.`; `John Prof. MA` reads the family `MA` that `John MA` reads, S2's reserve keeping a bare ambiguous acronym the family of a two-word name where a second peel laid over a first read family `John`, suffix `MA`; and `John Smith V Prof. VI` reads what `John Smith V VI` reads — middle `Smith V`, family `VI`, nothing reported — where two peels each reporting their own last piece reported twice. The choice is CORPUS-NEUTRAL: both variants move the same eight names, no more and no fewer, so it is a question about output quality alone. - **A2, the family-comma segment-1 path, decided: it gets the walk.** The spec's condition was "if the comma segment gate already routes those, say so and leave it". Measured, it does not: `Smith, John Prof.` read middle `Prof.` at 1.4.0, 2.0.0, 2.1.0, 2.2.0 and at this branch's parent — all five measured — while `Smith, John Prof. Dr.` read middle `Prof.`, suffix `Dr.` through 2.1.0 and middle `Prof. Dr.` from 2.2.0, `dr` having left the suffix vocabulary in #296. The eleven comma rows the spec calls "already routed" are the `Smith, Prof.` shape, where segment 1 holds NO name word and `segment_suffix_reading` reads it piece by piece — a different gate and a different mechanism. So the segment-1 walk was genuinely missing; with it, `Smith, John Prof.` reads title `Prof.`, given `John`, family `Smith`. It moved NO corpus name at the fix; `Smith, John Prof.` is a rules/H5 example, so it ENTERS the corpus in the docs commit that follows and is one of the seventeen in the population bullet below. Case rows pin it either way. The segment's walk follows the same transparency principle with its own lenient loop, which is why the candidates are the pieces that walk would not read as a suffix rather than the strict suffix test alone (`Smith, John Prof. Jr.` must reach past the post-nominal, `Smith, John Prof. V` past the numeral the lenient tail test claims, #144). - **What A2 does NOT reach, and why that is right.** `Smith, John Prof. MA` still reads middle `Prof. MA`: after a family comma a bare ambiguous acronym is a MIDDLE name and has been since 2.0 (the `Smith, Ed` cost S2 already accepted), so `MA` stands at the end of the segment, is not a period-marked title word, and stops the walk before it starts. The walk reads from the end; it does not hunt. - **Measured population: FIVE corpus names at the fix, SEVENTEEN after the docs commit that follows it and the two review rounds on it.** Re-measured 2026-09-09 at `5568fdc` and on the branch of PR #520 by parsing every `tools/differential/corpus*.jsonl` name with the walk on and disabled (recipe, and the disabling stub, which changed with the /simplify round below, in PR #520): the five are the four planted `John Smith Dr.`/`Mr.`/`Prof.`/`Rev.` rows and `Andrew Perkins (Mgr.)`, a genuine hit since rules/S1 reads bracketed content as if written bare, eleven of the twelve added are rules examples entering corpus_rules.jsonl, and the twelfth, `毛 泽东 Dr.`, entered the CJK corpus in the first review round, the one member in a native script; so the growth is documentation rather than reach. - **The inline frame-free gates at the two walk sites were REMOVED in review**, and this is the ONE-PREDICATE-PER-QUESTION half of the entry. Each site had a cheap inline test written to match the walk's own first condition; that is a second implementation of the question, and the measurement that justified it did not survive re-running. The band test runs early in a session where the facade sits at 453, so the "one frame of headroom" claim did not reproduce. With the gates gone the walk costs +1 frame on each entry point, inside the plan's target of two and inside `test_facade_cost_stays_within_its_band`. The walk's own cheapness is where the saving lives instead: the abbreviation-shape test is a compiled regex (a C call, no Python frame) and runs BEFORE the vocabulary call, and almost no name ends in a period-marked word, so the ordinary parse pays one match and stops. - **A pre-existing detail mismatch, recorded and NOT fixed.** #H4's join shape reports `title-or-name` with a `detail` that says the unit was "read as a given name by convention", while under the default order H1 retags the unit to the family — so `Dr. John of Prince` reports that text with the unit in `family`. This rule adds a second input with the same mismatch, `John of Prince Prof.`, and fixes neither: the wording predates this bundle, the fork the kind reports is title-versus-name which no field answers either way, and #H4 already records why the detail names no field for the peel shape. - 2026-09-09 (review round 2) (its post-comma `Berg, abdul Prof.` gap superseded by #637, decisions/P5) — **A1's transparency holds for the ONE-name-word case too, and the scope written a day earlier was the defect rather than the boundary.** A1 was scoped that morning to inputs where a name word still stands on both sides of the chain, handing the one-word case to #H1 unconditionally: "the title behind that word decides its field as a title in front of it would". #H1 was then reading BOTH ends of the name as one title run, so `Sir John Prof.` read family `John` where `Sir John` reads given `John` — the trailing title changing what the leading one addressed by, which is exactly the non-transparency A1 denies. The clause is now conditioned on there being no run in FRONT of the word: where one stands it addresses and the chained trailing title only joins the title field, so `X Prof.` reads as `X` plus the title for one name word as for two. Where no run stands in front the trailing one still decides, unchanged — `Smith Prof.` reads family `Smith`, `Smith Sir.` given `Smith`. Nothing in the walk moved; the fix is entirely in which run H1 keys, and #H1's 2026-09-09 entry carries it, the measurement and the mutation. The #P5 clause below reads the same way afterwards and gains its second half: a bound given-name word behind a trailing title joins as it joins with the title absent AND lands in the same field. Said of the COMMA-LESS writing, and the review that found the H1 defect measured why the qualifier is needed: after a family comma the reserve reads no peel at all, so the join fires over the trailing title word and takes it into the given name — `Berg, abdul Prof.` reads given `abdul Prof.` where `Berg, John Prof.` reads title `Prof.`. That is PARITY (1.4.0 reads it the same, measured) and a gap in the segment path rather than a boundary of the rule, so it is recorded rather than fixed here: the clause carries the scope, and the case row `title_word_trailing_behind_a_bound_pair_after_a_comma` pins it without making it normative. Tracked as part of #316. (FIXED by #637, decisions/P5, 2026-10-11: the join declines the trailing title word, and the row pins the title.) - 2026-09-09 (the /simplify round on the bundle's PR) (its `John Prof. MA` and `John Prof. MA Prof.` given 'John', family 'MA' superseded by #289, decisions/S6: family 'John', suffix 'MA') — **the trailing peel and the title chain are ONE reading run to a FIXED POINT, and A1's "ONE peel runs over what stands" was a truncation of it.** Two contrived readings contradicted clauses this section states, both found by an altitude review reading the code against the rule rather than by a test. (1) THE SUBTRACTION MODEL. #P5's reserve modelled assign's reading as the peel's count minus the chain's take, on each of the two views the join compares. That arithmetic equals assign's answer only while the chain's take does not change what the peel would do, and at rules/S4's bare-ambiguous reserve it does: `abdul rahman MA` reads given `abdul`, family `rahman`, suffix `MA` — the join declining because peeling the acronym unjoined and not joined is a suffix reading the join would change — while `abdul rahman MA Prof.` read given `abdul rahman`, family `MA`, the subtraction having counted `MA` a name word to spare. The Accepted clause below, "a bound given-name word behind one joins exactly as it joins with the title absent", was false as stated. (2) THE TRUNCATED FIXED POINT. The re-peel ran ONCE, so a second title was read half way: `John Prof. MA` reads title `Prof.`, given `John`, family `MA`, and `John Prof. MA Prof.` read title `Prof.`, given `John`, family `Prof.`, suffix `MA` — the re-peel un-peeled the acronym and re-exposed the FIRST title, which nothing then took. Both measured on the branch tip, 2026-09-09. - **The fix is one function, `_pieces.tail_reading`:** peel, chain, splice the chained pieces out, peel again over what is left, until the chain takes nothing. Assign calls it in place of its inline sequence and all its reports still come from the final peel; the reserve calls it on both views and compares the suffix lists it returns, and both subtractions are deleted. `abdul rahman MA Prof.` now reads given `abdul`, family `rahman`, suffix `MA` under title `Prof.`, and `John Prof. MA Prof.` reads title `Prof. Prof.`, given `John`, family `MA`. One further clause had to be said in the code rather than left to the arithmetic: the join must not take a piece the chain takes (#P5 — "a trailing title word the run takes is no word to spare"), which the subtraction had enforced by accident, by leaving that piece in the tail it compared. Without it `Sir abdul Prof.` joins the title into the given name; with it the reading is title `Sir Prof.`, given `abdul`, as before. `trailing_titles` returns the count it LEAVES STANDING in the same edit, the way `peel_trailing` counts, so the two compose at the call site without arithmetic at all. - **Cost, and what paid it.** The shared helper is one frame on the common path, where the inline sequence was none. It is paid by `_effective_order` taking the segment's pieces and the name-piece indices — the shape every piece-layer predicate takes — instead of a list built for it, which on 3.11 is a comprehension frame on every parse; the reference name stays at 416 parse / 453 facade. The helper returns a bare 3-tuple rather than a NamedTuple for the same budget: a NamedTuple's `__new__` is itself a frame, measured at +1 here. - **How big each defect's class is, measured as the rule states it.** Measured 2026-09-09 against `7ac2218` (the round's parent; PR #520), over the 3,210 three- and four-word arrangements in A1's two-name-word scope ending in `Prof.` or `Dr.` (recipe in PR #520): 786 broke transparency on the branch tip and 698 still do, all the P2/M2 residue the rule accepts as a boundary (`John de Prof.`, `Mary née Prof.`); of the 88 that close, 56 carry one period-marked title word and a bound given-name word (the subtraction model's reach) and 32 carry two (the truncated fixed point's), and over the sweep's 82,712 comma-less and family-comma inputs 156 parses move, none a comma spelling. - **Nothing else moved, and it was measured rather than argued.** Over every corpus name and every string literal in `tests/v2/cases.py` on the branch tip and on the fix (PR #520), none of the 2,674 strings on both trees reads differently; the two readings above are in no corpus and each takes a case row (`title_word_trailing_run_is_read_to_a_fixed_point`, `title_word_trailing_behind_a_bound_pair_at_the_peel_reserve`). The same round dropped the `Sir Jr` example from rules/S2 and its case row: it reads by the same branches as `Dr Jr`, the same roles and the same reported kind, the run being empty by then and no branch reading `vocab:given-title` — so the pair pinned one reading twice. - 2026-09-26 #535 — CROSS-REFERENCE: the maiden half of rules/H5's "the chain reads PIECES" Accepted row now reads the title — a maiden clause is no longer a join that puts the trailing word out of the chain's reach, so `Mary Smith née Jones Prof.` reads title 'Prof.' — decided in the 2026-09-26 #535 entry under `### M2`. - 2026-10-07 #614 (its family-comma half superseded by #623, below) — the accepted cost 'the chain reads PIECES, so a join that ran earlier puts the word out of reach' is retired for the particle chain on a main segment: the chain is read with S2's run, once, after P3's connective joins and before the particle chain is made, so a particle chain stops in front of a trailing title -- `John van der Berg Prof.` reads title 'Prof.', family 'van der Berg' (every release from 1.4.0: family 'van der Berg Prof.'), the example rules/H5 carried for the cost now pinning the reading. It stands for a connective's join, made before the read, so a title it joined into a longer unit is no single word (`John Smith Prof. and Dr.` keeps family 'Prof. and Dr.', the case row that pins the one-word gate), and it stood after a family comma (SUPERSEDED IN PART by #623, below: there the chain now stops in front of the title). decisions/S2, this date. - 2026-10-10 (Derek), #623 (its two follow-up gaps superseded by #636, decisions/C7, and #637, decisions/P5) — AFTER A FAMILY COMMA THE PARTICLE CHAIN STOPS IN FRONT OF A TRAILING TITLE TOO. Assign's walk over the given part already reads the trailing title run, with its own suffix reading (#144's lenient tail with two parts, #602's run, P6's attachment), so `Smith, John Prof.` reads title 'Prof.'. What it lacked was a chain that left the title standing: group bounded the chain there by `trailing_start`, the suffix peel alone, so the chain took the title into the particle's name before the walk met it, and `Smith, John van der Berg Prof.` read middle 'van der Berg Prof.' where `John van der Berg Prof.` reads title 'Prof.' since #614 (1.4.0 and 2.3.0 read middle 'van der Berg Prof.' for the comma spelling). At that site the bound is now `_pieces.trailing_run_start`, where the run `tail_reading` takes begins, its titles included, so the chain stops in front of the title and the walk reads it: title 'Prof.', middle 'van der Berg'. Nothing new is counted -- only the chain's stop moves -- so the issue's question of which words-to-spare floor the given part uses does not arise, and the comma's own readings of a numeral or a bare acronym there (`Smith, John van der Berg VI` middle 'van der Berg VI') do not move. P6 (the issue's open question) reads after the title run, as though the title were absent: `Smith, Jan van Prof.` → title 'Prof.', family 'van Smith', as `Smith, Jan van` reads. The bound is asked only where a particle stands past the chain's lead, by an inline tag scan, since the chain otherwise merges nothing -- which also drops the `trailing_start` call master made for every family-comma part. DECIDED (Derek): the narrower bound. Declined: the issue's proposal, reading the given part's run once ahead of the chain and splitting it off as the main segment's is (#614). It would have assign place the given part off group's read, a second reader beside the walk's own predicates, for a class the bound alone closes: the transparency measurement below finds no text the narrower fix leaves broken that the read would mend. MEASURED 2026-10-10 against 4d5631c2 (#634's merge), py3.11, `nameparser.__file__` asserted on each side. A given-part grid -- `{family}, {head} {middle} {tail}`, and the same with `, Jr.` appended, for family in `Smith`, `van der Berg`, `Ortega y Gasset`, (empty), `(Bob)`; head in `John`, `Dr. John`, `Freiherr`, `abdul`, `Freiherr St`, `J.`; middle in (none), `van`, `van der`, `de la`, `van Berg`, `van der Berg`, `Mc Mc`, `von und zu`, `Berg`, `Berg van`, `do`, `St`, `y Lopez`; tail in (none), `Prof.`, `Dr.`, `Prof. Dr.`, `Prof`, `Sir`, `Prof. PhD`, `PhD Prof.`, `Jr. Prof.`, `Prof. Jr.`, `MA`, `Ma`, `VI`, `V`, `X.Y.Z.`, `Ph. D.`, `Esq.`, `née Jones`, `Prof. Jones`, `Xyz.`, `King.`, `Prof. V`, `MA Prof.`, `Lt.Gov.` -- 18,480 texts under the three orders and `lenient_comma_suffixes=False`, comparing the seven fields and every report: 17,340 of the 73,920 parses move a role and none a report alone, all but 500 parses (125 texts) putting a trailing period-marked title into the title field (PR #635); H5's transparency over the grid's 5,390 default-order texts ending in a trailing title breaks on 392 against master's 3,048, and on no text master kept (352 H1's move behind an empty head, 40 P5's post-comma join taking the title, fixed by #637); 500 parses (125 texts) put a title in front of a numeral with a third comma part into the middle, garbage; the S2 stress grid's comma half (#614's recipe) and #624's title-particle grid move 56 and 24 of the same class, #625's particle grid, every corpus name and case text, and #624's comma-tail grid none. Frames: a family-comma name with a particle pays 2 with no trailing run, 4 with a suffix, and the moved shapes 38 to 73 in a sample, rare shapes no guard sees, and one without a particle pays less (`Smith, John Quincy` 221 → 213, `Doe, John MA` 254 → 227), which `test_a_given_part_with_no_particle_skips_the_chain_bound` holds; recorded negative control: with the old bound, 8 tests fail. THE REVIEW (2026-10-10). The bound is `tail_reading`'s run, read as the comma-less name reads it, and the walk reads the given part by its own predicates, so where the two disagree on a word behind a title the chain stops for a title the walk does not take, or cannot cross one to reach a particle P6 takes: a title in front of a shape-only numeral (`Smith, John Mc Prof. VI` → suffix 'Mc', middle 'Prof. VI'; master middle 'Mc Prof. VI'; the walk never reads 'VI' as a suffix after a comma), in front of a numeral with a third comma part (the 500 above), in the middle of the given part in front of a particle (`Smith, John van Prof. Mc` → title 'Prof.', middle 'van', family 'Mc Smith'), and a period-marked title-particle ending it, which P6 attaches (`Smith, John van MA St.` → suffix 'MA', family 'St. Smith'). Every such shape is garbage, most read as master already reads the text without the title (`Smith, John Mc VI` suffix 'Mc'), and none is a reading a well-formed name loses: over about 1.6 million generated rows under every order, the opt-in policies, one-case writing and caller lexicons, and over a 134,720-pair transparency grid (`Smith, ` and `, ` heads; one to three words from `John`, `van`, `der`, `von und zu`, `Berg`, `Ma`, `MA`, `V`, `VI`, `Jr.`, `PhD`, `Ph. D.`, `Mc`, `St.`, `St`, `Freiherr`, `X.Y.Z.`, `Esq.`, `abdul`, `Prof.`; a trailing ` Prof.`, with and without `, Jr.` after it; the three orders and `lenient_comma_suffixes=False`; transparent where given, middle, family, nickname and maiden equal the title-less text's and the title gains exactly ` Prof.`; master broken on 53,384 pairs, the tree on 9,734), no text transparent to its title on master is broken now. (The first review named `Smith, John Ph. D. St. Prof.` as one, its oracle reading the direction backwards: master gives suffix 'Ph. D. St. Prof.' against the title-less 'family St. Smith', and the tree is transparent.) Recorded, not fixed (Derek): garbage, and narrowing the bound would add a condition for it. The review also found the bound's particle gate held by no test, two of its narrowings undoing readings master gives -- a scan from the name's start rather than the chain's lead (`Smith, Freiherr von Berg Prof.`) and a test of single-token particles alone (`Smith, John von und zu Berg Prof.`) -- now rows of their own, with an empty-head row (`, Jan van Prof.`, nameparser/AGENTS.md's comma sweep), and the frame saving now held as above. Each of the three mutations fails a test. The re-ask after the chain (`left < tail`) still decides readings, though its documented case (`Freiherr von Berg Ma`) takes the read-once path since #614: removed, it moves 400 parses of the given-part grid on master and here alike, a title-particle head before a particle run and a dotted acronym (`Smith, Freiherr van Berg X.Y.Z.` → given 'van Berg X.Y.Z.', as 1.4.0 reads it; without it suffix 'X.Y.Z.'). It decides a main segment's run with a name piece inside it too (`Freiherr van Berg Dr. X.Y.Z.` → family 'van Berg Dr. X.Y.Z.', as master and 2.3.0 read it; without it given 'van Berg Dr.', family 'X.Y.Z.', 1.4.0's reading), a garbage shape no row holds. The first review reported it changing nothing over a grid with no such shape, and #638 was filed on that; the fix review found it live and held by no test, and the row `the_chain_runs_again_where_its_merges_free_the_acronym` (parity) holds it now. Left for follow-ups, two gaps outside this change: a part past the second comma reads a trailing title behind a particle into the suffix (`John Smith, Jr., van der Berg Prof.`, where `John Smith, Jr., Prof.` reads title 'Prof.'; #636), and P5's post-comma join taking a title (the 40 above; #637). derek73-python-nameparser-456b024/docs/design/decisions/H6-title-before-a-family-comma.md000066400000000000000000000300631527303271700310370ustar00rootroot00000000000000# H6 — a title before a family comma ## Standing - A run of title words opening the part before a family comma is the title, joining the title field in input order with any title after the comma, and the comma decision is untouched, so the reading is the same under every name order: `Dr. Smith, John` → title 'Dr.', family 'Smith'; `Prof. Dr. Brühl, Ada Cornelia` → title 'Prof. Dr.' — 2026-10-17 #701. - The run admits a word by vocabulary only, never by H2's shape, since the slot is a surname's slot too and a surname can open with an abbreviated word: `Xyz. Smith, John` → family 'Xyz. Smith', `Sta. Maria, Juan` → family 'Sta. Maria' — 2026-10-17 #701; 2026-10-18 #710. - The run leaves the family its last piece (`Dr., John` → family 'Dr.'), stops at a word that is a particle too (`St Pierre, Ed` and `Freiherr von Berg, Ed` keep their families, while `Dr. Freiherr von Berg, Ed` peels 'Dr.'), and stops where nothing but post-nominals stands behind a title (`RA PhD Jr., John` → family 'RA'), so a title/suffix dual with a name word behind it peels (`Rev. Sr. Smith, Mary` → title 'Rev. Sr.') — 2026-10-17 #701. - A surname-like title opening a two-word surname is the title (`Pope Smith, John` → title 'Pope'), an accepted cost — 2026-10-17 #701. - `gov` is a title (`Lt. Gov. Smith, John` → title 'Lt. Gov.'), the first piece the run leaves is the family whatever it is (`Dr. PhD Smith, John` → family 'PhD Smith'), and the particle stop is all that keeps a rank in the family, so `Graf Lambsdorff, Otto` reads title 'Graf' — 2026-10-17 #701. - The comma-less writing of an abbreviated surname is not this rule's and still splits it (`Juan Sta. Maria` → middle 'Sta.', family 'Maria'); listing `sta` and `sto` as particles is weighed and not decided — 2026-10-18 #710. Open: [#709](https://github.com/derek73/python-nameparser/issues/709) (whether `graf`, `fürst`, `prinz` and the other ranks read as `freiherr` does); [#725](https://github.com/derek73/python-nameparser/issues/725) (whether `sta` and `sto` join the particles, so `Juan Sta. Maria` reads family 'Sta. Maria'). ## Log - 2026-10-17 (Derek), #701 — A TITLE OPENING THE PART BEFORE A FAMILY COMMA IS THE TITLE. `Dr. Smith, John` read family 'Dr. Smith', and every release did the same: 1.4.0 from PyPI gives last 'Dr. Smith' and 'Prof. Brühl' (suffix 'Dr.') for `Prof. Dr. Brühl, Ada Cornelia` (measured 2026-10-17, `PYTHONSAFEPATH=1`, wheel path asserted). The reading was written down on purpose as Accepted clauses of rules/H2 and #H3 ("before a family comma the pre-comma text is wholly the family name") and assumed by C1's Freiherr clause ('Prof. Cruz, Ed', rules/C3's Accepted clause since #728); all three are rewritten here. The listing form moves the surname to the front and leaves the title where it was written, and CLDR's index renderings write exactly that in several locales (`prof. dr. von Selimović Hodžić, Izeta Amra`, `г-н Вернер, Иван Асен`). DECIDED (Derek, the five points of the design proposal): (1) VOCABULARY ONLY. H2's shape does not fire before a family comma, so `Xyz. Smith, John` keeps family 'Xyz. Smith' — the slot is a surname's slot too, as the trailing slot is (#H5), and every CLDR case is a listed title. (2) The run leaves the family its last piece (`Dr., John`, `Greve, Anna`). (3) A word that is a particle too stops the run, bare or period-marked, so `St Pierre, Ed` and the 1919 legal-surname reading of `Freiherr von Berg, Ed` (rules/C3's Accepted clause) stand, and a title in front of them still peels. (4) The peeled titles join the title field in input order, and the comma decision (`_comma.decide`) is untouched — the count before the comma already treated a title as no name word. (5) ACCEPTED COST: a bare surname-like title opening a two-word surname is the title, `King Smith, John` → title 'King', as `Greve Anna` already reads (#H1). ADDED IN THE PROTOTYPE, not in the proposal: #H3's floor. The first version peeled `MD` from `MD PhD Jr., John`, leaving family 'PhD' — which a case row (`family_comma_segment_zero_is_not_the_run`) caught. A title addresses somebody, and a post-nominal is not somebody, so the run stops where nothing but post-nominals stands behind it: `Dr. Jr., John` keeps family 'Dr.'. The second version stopped in front of ANY piece of the suffix vocabulary, and the design-docs review found it too broad: a title/suffix dual standing SECOND in the run blocked it and then read as a suffix inside the family (`Rev. Sr. Smith, Mary` → family 'Rev. Smith', suffix 'Sr.', where the comma-less `Rev. Sr. Mary Smith` reads title 'Rev. Sr.'), and so did `Mc` and `vd`, which the family loop's own P2 clause reads as a surname's head (`Dr. Mc Donald, John`). Its check compared `T Smith, John` with `T John Smith` over every title word, bare and period-marked, alone, behind `Dr.` and ahead of it (4,788 pairs over the 798 title words before #705's merge): 36 disagreed, 12 the decided particle class and 24 the twelve duals in second position. The floor asks the H3 question instead — is everything behind this title post-nominal? — so the duals peel wherever a name word follows them, as they do in front of a name with no comma. Declined: excluding the title/suffix duals from the run (it would keep `Ms Smith, Jane` whole); peeling by H2's shape too (the trailing doctrine, #H5, says only vocabulary claims a word in a surname's slot). MEASURED 2026-10-17 against master `eb2c1fa5`, py3.11. Snapshot (`uv run python tools/differential/snapshot.py --since master`, before this change's own rows entered the corpora): 20 entries move, all one shape — a title leaves the family for the title field — and 18 of their names are held only by corpus_cldr.jsonl. Several of those are names C1 reads as a family comma though the part after it is a credential CLDR's vocabulary lacks (`hr. Hans Henrik Sven Åge Møller, MF`); the title still comes out right there, and the family stays as wrong as before. CLDR agreement: 29 fields now agree and 28 more get closer to CLDR's value, with none regressing. Merging master after #705 listed `bapak` adds one more of the same shape, `Bapak Adinata, Dwi Putro bin MP` (title 'Bapak' out of family 'bin Bapak Adinata'), two more CLDR fields agreeing and one closer. Cost (`sys.setprofile` call events, one warm-up): `Dr. Juan Q. Xavier de la Vega III` 366 → 366 (the benchmark's `_REFERENCE` row, 307, is unmoved too), `Smith, John` 161 → 161, `Smith Jones, John` 203 → 203 (an inline tag test ahead of `is_title_piece` keeps a part no title opens free of frames), `Dr. Smith, John` 200 → 201 (one `is_title_piece`; the floor's `is_suffix_piece` on 'Smith' replaces the family loop's, which no longer asks the first family piece), `Prof. Dr. Brühl, Ada` 279 → 280. The family loop does not re-ask a piece at or past the floor, so a long post-nominal run costs one frame more than on master, not one per member: `Dr. Smith Jr. PhD MD, John` 306 → 307 (310 before the /simplify round), `Dr. Smith` plus twenty `PhD`, `, John` 903 → 904 (924 before). Those two changes moved no reading on a grid of 88,740 family-comma heads (one to four words from `Dr.`, `Prof`, `St`, `Freiherr`, `Sr.`, `Ms`, `Smith`, `van`, `Vd`, `PhD`, `Jr.`, `MA`, `V`, `de`, `Rev.`, `Mc`, `née`, before `, John`) under the default and `FAMILY_FIRST` orders, comparing fields, ambiguity kinds and `initials()`. THE PR REVIEW (2026-10-17), three decisions. (a) `gov` JOINS TITLES (Derek). `Lt. Gov. Smith, John` read title 'Lt.', family 'Gov. Smith' — a compound split across two fields, where master kept it whole in the family and the comma-less `Lt. Gov. John Smith` reads title 'Lt. Gov.' by H2's shape. The run is vocabulary-only, so the fix is the vocabulary: `gov` is the abbreviation of the listed `governor`. Counts (`uv run python tools/name_usage.py Gov`, 2026-10-17): 236 US Census 2010 surname bearers, rank 80,419, 91.53% Asian/Pacific Islander; SSA given names under 5 a year; Wikidata 3 family-name bearers, all Israeli. The claim acts only in front, so `John Gov` keeps family 'Gov', and a surname written first loses it (`Gov Sokha` → title 'Gov'), the graf-class trade (#salutation-titles) at a smaller incidence. No corpus name moved. The other unlisted second words the review found (`insp`, `sen`, `supt`, `cmdr`, `atty`, `msgr`) still split; each is a vocabulary question on its own. (b) `Dr. PhD Smith, John` KEEPS the branch reading, family 'PhD Smith' (Derek): the first piece the run leaves is family whatever it is, as piece 0 is with no title (`PhD Smith, John`, read that way by 1.4.0 too); master and 1.4.0 gave `Dr. PhD Smith, John` family 'Dr. Smith', suffix 'PhD'. Garbage input, so no row. (c) `Graf Lambsdorff, Otto` reads title 'Graf' while `Freiherr von Berg, Ed` keeps the rank, and the clause first written for it gave the 1919 legal-surname argument as the reason — which holds for every German rank alike. The reason is the particle stop: `freiherr` is the one rank in `particles`. rules/H6's Accepted clause now says so, and the vocabulary question (should `graf`, `fürst`, `prinz` and the rest read as `freiherr` does, with and without a comma) is [#709](https://github.com/derek73/python-nameparser/issues/709). - 2026-10-18 (Derek), #710 — POINT (1) KEEPS ITS VOCABULARY-ONLY READING, AND NOW HAS A NAMED COST BEHIND IT. Closing #710 raised it again: `Xyz. Smith, John` keeps family 'Xyz. Smith', and since no surname ends in a period, title 'Xyz.' looked like a fair reading. The 2026-10-17 entry gave only the doctrine (the slot is a surname's slot, as at #H5) and the absence of need (every CLDR case is a listed title). It named no surname the shape would break. There is one: a surname can OPEN with an abbreviated word. Filipino records write Santa and Santo as `Sta.` and `Sto.`, and H2's shape (two or more letters, one trailing period) claims both. So `Sta. Maria, Juan`, which reads family 'Sta. Maria' today, would read title 'Sta.', family 'Maria'. US Census 2010 surname bearers, measured 2026-10-18 with `uv run python tools/name_usage.py "Sta. Maria"` (and likewise for each): Sta. Ana 408, Sta. Maria 376, Sto. Domingo 163, Sta. Cruz 110, each over 90% Asian/Pacific Islander. The Census strips punctuation (it looks these up as `STAMARIA`), but the unabbreviated spelling is a separate entry (`Santa Maria`, 12,375, 77% Hispanic), so these counts are the bearers recorded with the abbreviation; what the fold hides is whether they wrote its period, which is what H2's shape needs. `ste` is a particle and no title, so `Ste. Marie, Jean` never enters H6's run today; under a shape peel, point (3)'s particle stop is what would keep it whole. `sta` and `sto` are in no vocabulary set, so nothing would stop the shape on them. `Mt.` and `Ft.` have the same shape and are rare as surnames (`Mt. Pleasant` and `Ft. Smith` each under 100 Census bearers, same date). rules/H6's rationale now names the class, and `Sta. Maria, Juan` is a boundary example there. NOT FIXED BY THIS RULE: the comma-less writing splits the same surname. `Juan Sta. Maria` reads given 'Juan', middle 'Sta.', family 'Maria' (and `Juan Sto. Domingo` likewise), so it disagrees with `Sta. Maria, Juan` on the family; `Jean Ste. Marie` reads family 'Ste. Marie' because `ste` is a particle. Listing `sta`/`sto` as particles (measured with `Lexicon.default().add(particles={"sta", "sto"})`) fixes both comma-less readings and leaves `Sta. Maria, Juan` as it is. Added that way they are never-given, so P1's fold takes a bare leading one: `Sta Juan` moves from given 'Sta', family 'Juan' to family 'Sta Juan' (bare `Sta` has 120 US Census surname bearers). Listed as ambiguous instead (adding them to `particles_ambiguous` as well), the comma-less readings are fixed the same way and `Sta Juan` keeps given 'Sta', with a `PARTICLE_OR_GIVEN` report. Neither listing has been decided. Declined: peeling by H2's shape before a family comma, again. Reopening it would need a stop for `sta`/`sto`. The particle listing above would supply one only if the peel kept point (3)'s particle stop, and that has to be done on purpose: H2's shape at the head of a name with no comma has no such stop, so `Ste. Marie Jean` reads title 'Ste.' today. H7-joined-name-unit-carrying-title-vocabulary.md000066400000000000000000000031351527303271700340540ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/decisions# H7 — a joined name unit carrying title vocabulary ## Standing - A name unit of more than one word carrying title vocabulary reports `title-or-name`, stated in a rule of its own, split out of rules/H4 — 2026-10-20 #761. Elsewhere: the clause itself (its reach to every such join, the hoist past a peeled title, a maiden name or a claimed word, and the two corpus names it reached) was decided under decisions/H4, in its 2026-09-07 #491 entry and its 2026-09-08 #518 review-round entry. Open: none in this file. ## Log - 2026-10-20 (Derek), #761 — THE JOIN CLAUSE IS A RULE OF ITS OWN. The H section's skeleton migration (PR #779) found rules/H4's join clause reaching past H4's own scope: H4 is about an input that holds no name word but vocabulary, while the join clause reports `title-or-name` for `Smith and King`, `John and King` and `Attorney General of Minnesota`, which hold ordinary words. Derek decided the clause moves to a new rule, with no behavior change. rules/H7 carries its text verbatim save two edits: "reports `title-or-name` as well" lost "as well", having no H4 statement beside it, and "the two corpus names the clause reaches" became "the two corpus names the clause reached on 2026-09-08", a dated count rather than a standing one, since the examples H7 gains put more names in the corpus. H4 keeps the "nothing but vocabulary" scope with a Then bullet pointing here, H7 points back for a unit of one word, and rules/O5's pointer names both. The examples are new: the old clause had none, its inputs being pinned in tests/v2/cases.py, so corpus_rules.jsonl gains its names; no reading moves. derek73-python-nameparser-456b024/docs/design/decisions/H8-salutation-ending-the-given-part.md000066400000000000000000000226271527303271700321550ustar00rootroot00000000000000# H8 — a salutation ending the given part ## Standing - A salutation ending the given part after a family comma, written bare or with a stop at its edge that H5's abbreviation shape does not take (`Mr..`), is the title, read as its period-marked spelling reads in every comma shape and under every name order: `Doe, Jane Mrs` → title 'Mrs', given 'Jane'; `DOE, JOHN Q MR` → title 'MR', middle 'Q' — 2026-10-24 #835. - Only the salutations marked `after_given` in the vocabulary qualify, and a salutation borne as a given name or a surname is left out by an exclusion carrying its counts, since a middle name may be either (`Doe, John Goodman` keeps middle 'Goodman') — 2026-10-24 #835. - No other title kind qualifies (`Doe, John Pope` and `Doe, Jane Dr` keep their middle names), a title/suffix dual stays a suffix (`Doe, Jane Ms`), and the comma-less writing is H5's (`Jane Doe Mrs` keeps family 'Mrs') — 2026-10-24 #835. - A salutation that is someone's middle name reads as the title after a family comma, an accepted cost (`Doe, Jane Miss`); so does the empty head's one given word moving to the family as the period-marked spelling moves it (`, Jane Mrs` → family 'Jane') — 2026-10-24 #835. - The rule reads the comma, not a declared order: `Doe Jane Mrs` under FAMILY_FIRST keeps middle 'Mrs', so the family-comma invariant of decisions/two-input-invariants does not hold for a trailing salutation — 2026-10-24 #835. Open: [#877](https://github.com/derek73/python-nameparser/issues/877) (whether a bare `I` or `V` the salutation leaves ending the given part, read as the suffix by rules/C9, should report `suffix-or-name`). ## Log - 2026-10-27, #766 — `heren` is no salutation: the plural never stands before one person's name, so its title reading was refused and with it its `after_given` mark (decisions/vocabulary-model's entry of this date). `Doe, Jan Heren` reads middle 'Heren' again, as 1.4.0 through 2.5.0 read it; the vetting below, which kept it on its small counts, is the record of 2026-10-24. `herren`, which that vetting excluded from `after_given`, left the titles too. - 2026-10-24, #835 — A BARE SALUTATION ENDING A FAMILY COMMA'S GIVEN PART IS THE TITLE. `Doe, Jane Mrs` read middle 'Mrs' while `Doe, Jane Mrs.` (H5) and `Doe, John Jr Mr` (the salutation behind a post-nominal) read title, and `Doe, John Mr Jr` read middle 'Mr'; 1.4.0 and 2.5.0 read the first as middle 'Mrs' too (wheels checked 2026-10-24). The FEC's itemized contributions write `FAMILY, GIVEN MIDDLE SALUTATION` in capitals, the period often left off: on #834's `fec-individuals` corpus (file dated 2026-10-18, measured 2026-10-22, figures in #835) 13,243 names ended in a bare title word read as a middle name, `MR` 8,483 of them. THE REASON H5's BARE-WORD CLAUSE DOES NOT REACH HERE. H5 keeps a bare title word at the end of a name as a name word because TITLES holds ordinary surnames (`Mary Jane King`). After a family comma the surname's place is marked, so only the middle-name reading is left against the title, and of the title kinds (#750) the salutation is the one that is a form of address and not something a person is named after. So the rule is keyed on that kind, and narrower than the kind: the property marks the salutations not borne as names. THE VOCABULARY. A behavioral title property, `after_given = true`, allowed on a `salutation` reading only (a build check), compiles into `AFTER_GIVEN_TITLES` and the new `Lexicon.after_given_titles`; v1's `Constants` has no attribute for it, and `_snapshot()` intersects it with the titles, so deleting the title is the v1 knob. Considered and not taken: keying the rule on the `salutation` kind itself, with exclusions for the borne words — it makes a kind compile into a behavior, where every behavior so far is a property (decisions/vocabulary-model). THE VETTING (`uv run python tools/name_usage.py --json` over every salutation and the romanizations of the non-Latin ones, 2026-10-24). A middle name may be a given name or a surname, so a word borne as either is excluded. Excluded, each with its counts on the word: goodman (US Census 2010 surname 73,136), herr (11,980), herren (4,466), master (2,735; SSA given 476), mister (1,211; SSA 746), señora (SSA given 1,065, as SENORA), signor (999), signore (895), herra (638), paní (403, as PANI), señor (282, as SENOR), härra (237, as HARRA), fru and frú (a Cameroonian surname, about 10,800 bearers by the note on sv.toml's entry, and 132 in the Census), пан (Wikidata family 743), the Bengali মোঃ and মো (they abbreviate Mohammad, which #853 reads as a given name where it leads), श्री/শ্রী (also an element of given names, Sri or Shri; one Wikidata given-name bearer, excluded on the doubt rather than the count) and बाबू (romanized Babu, Census surname 1,496, ranked a given name in India and Sri Lanka by nam_dict, and an element of compound given names such as Ram Babu). A diacritic-free count stands for an accented word, the Census and SSA writing none. A romanization counts for a word in another script only where its bearers write that script: बाबू's do, while the bearers of the romanizations of सौ (Sau, Vietnamese), মি (Mi, Korean and Chinese), κα (Kyria, US given names) and пані (Pani, an Indian surname) do not, so those are kept. Kept with small counts, recorded on each word as a name reading: miss (Census surname 164), heren (123), sra (Census surname 149, SSA given 27), signora (SSA given 90), señorita, bwana and mr (SSA under 10 each), frau (Wikidata family 42), and gospodin (a Bulgarian given name in nam_dict, band 5, kept because a Bulgarian middle name is a patronymic, Gospodinov, rather than the given name itself). The line at roughly 200 US bearers is a judgment, not a measured threshold. ms and sr carry no flag, being title/suffix duals the suffix reading takes first. `hr` was excluded on the FEC read below. Recompute the set: `uv run python tools/vocab/query.py --kind salutation`, and the refusals with `--excluded after_given`. THE MECHANISM. H5's trailing title chain, in the given part after a family comma, admits a word the vocabulary marks, matched after its edge stops are folded, so a doubled stop (`Mr..`, which is not H5's abbreviation shape) is admitted as the bare word is. Everything H5's chain already does there — transparency to the suffix reading, the floor leaving one name piece, the title joining the field in input order — holds for it unchanged. The group-side predictors of that part (the particle chain's bound and the bound given-name join's decline, which read the part as `tail_reading` reads it) admit the same words, so they cannot disagree with the walk (the Gotcha on group-side predictors in AGENTS.md). Measured: over a grid of nine family-comma heads (an empty head, a nickname-only head, particle and connective surnames, a suffix and a title before the comma), 21 given parts (initials, a bound given name, particles, a title-particle, an ambiguous acronym, numerals V and VI, a post-nominal, a one-case part), seven salutation spellings and six tails (a second comma, a trailing suffix, acronym or period-marked title), under all three name orders, every one of the 23,814 bare rows reads exactly as its period-marked twin reads, fields and ambiguity kinds both, and none of the period-marked rows moved against master `3f8e29b2`. THE CORPORA (measured 2026-10-24 against master `3f8e29b2`, py3.11). No committed corpus name moved: the snapshot gains only this rule's own examples, and the CLDR, GND and Wikidata agreement views match their records. `snapshot.py --local fec-individuals`, base recorded with `--tree`: 12,381 of 1,893,964 names move, each a salutation leaving the middle name for the title, some with a credential or a generational word behind it: `MR` 9,339, `MRS` 1,908, `MISS` 1,108, `MR..` 24, and two more joining a title already read. Three had read `HR` as the Danish and German abbreviation of Herr, one of them behind a woman's given name: in US data it is more likely two initials run together, so `hr` was excluded on that evidence and those three are not among the moves. 385 of the moves lose a `CONJUNCTION_OR_INITIAL` report (`DOE, JOHN E MR`): with the salutation gone the letter has no name word behind it to connect, which is what P8 asks. In 361 of them the letter stays a middle initial; the other 24 are among the 59 below. `ukec-individuals` does not move. A CONSEQUENCE NOT DECIDED HERE: 59 of the FEC moves end the given part in a lone `V` or `I` once the salutation is the title (`DOE, JANE I MRS`), which then reads as the generational suffix, as `DOE, JANE I` and `DOE, JANE I MRS.` already read on master (#144's lenient tail test after a family comma). In these names it is a middle initial. The bare spelling now reads as its twins read; whether #144's reading should change is its own question. THE FAMILY-FIRST WRITING (the design-docs review). A declared FAMILY_FIRST order fixes the surname as a comma does, but the rule reads only the comma: `Doe Jane Mrs` under FAMILY_FIRST keeps middle 'Mrs' (FAMILY_FIRST_GIVEN_LAST: given 'Mrs'). decisions/two-input-invariants pairs those two writings; over the 82 flagged words written without a stop, an apostrophe or a hyphen, each after three family and three given names, they agree on 738 of 738 pairs on master and on none now. Recorded as an Accepted clause of rules/H8 rather than extended: the declared order says where the surname is, not where the given part ends, and the trailing slot of a comma-less name is H5's. derek73-python-nameparser-456b024/docs/design/decisions/H9-title-phrase.md000066400000000000000000002401471527303271700263010ustar00rootroot00000000000000# H9 — the title phrase ## Standing - A title that may be a given name is a title only inside a title phrase, and elsewhere reads as a word the vocabulary does not list: `Dean Smith` reads given 'Dean', `Dean of Students Jane Smith` title 'Dean of Students' — 2026-10-25 #752. - The fork is reported `title-or-name` where the word is the first name word after any titles and no comma has marked the given name: `Dean Smith` and `Dean Smith, MD` report, `SMITH, DEAN MR.` does not — 2026-10-26 #752, phase 2i (Derek). Only where a phrase the vocabulary lists holds the word: `Grace Smith` reports nothing, `Marshal Yanda` does — 2026-10-26 #752, phase 2i, the report's reach (Derek). - `grace` (a style) and `marshal` (a rank) are titles that may be given names, `sergeant` attaches to the word `major` and `major` to `general` (`attaches` naming a word where a kind would reach too far): `His Grace John Smith`, `Field Marshal Bernard Montgomery` and `Sergeant Major John Smith` read the title; `Mr Justice Smith` keeps given 'Justice', no exception behind a salutation — 2026-10-26 #752, phase 2i (Derek). - Its partner is a modifier or a style before it, a title before it that attaches to it or a title after it that it attaches to (`attaches`), or `of` after it; a salutation is never one, nor any other title before it (#853's `Mr Dean Smith`), nor a title after it it does not attach to (`SMITH, DEAN MR.`) — 2026-10-25 #752. - A modifier in the leading run of titles before the first comma is a title only with a head title after it in that run, `of` after it, or in the object of `of` or `the` there; outside that run (ending the name, H5's, or after a comma) it reads as any title does, the given part after a family comma included — 2026-10-25 #752; save a modifier that may be a given name, which the run's test reads in every part (`Lau, Wing Kai` → given 'Wing', `John Smith Wing.` → family 'Wing.') — 2026-10-26 #752, phase 2j, `wing`. - A title that may be a given name which a caller lists in `titles` is a title wherever it stands, which keeps the 1.4 recipe `c.titles.add("dean")`; a v1 caller who empties `titles` turns the phrase reading off with them, while one who replaces the list keeps it (`c.titles.clear(); c.titles.add('dr')` still reads `Dean of Students` as the title), v1 holding no knob for the set — 2026-10-25 #752. A title 2.6.0 listed that a later batch made a title only inside a phrase stays in v1's default `titles`, read by the phrase while it is there and as no title once removed, so `c.titles.remove('king')` works as in 2.6.0; listing it in v1 cannot make it a title everywhere, which takes `Lexicon.default().add(titles={'king'})` — 2026-10-26 #752, the v1 title list. ## Log - **2026-10-25 #752, phase 2i (its report in a family comma's given part, and its costs `His Grace John Smith` and `Field Marshal Bernard Montgomery`, superseded by 2026-10-26 #752, phase 2i (Derek); its cost `Lau, Wing`, by 2026-10-26 #752, phase 2j, `wing`) — A TITLE THAT MAY BE A GIVEN NAME IS A TITLE ONLY INSIDE A TITLE PHRASE, AND A MODIFIER AT THE HEAD OF THE NAME ONLY WITH A HEAD BESIDE IT.** Carries out items 10, 11 and 12 of decisions/vocabulary-model's 2026-10-23 #752 entry (decision 10 at the head of the name only; the phrase partner; #853's word after a title), measured here on the rule as built rather than on the spike those items were decided on. THE WORDS IT READS TODAY. `dean`, `justice` and `major` gain a title reading beside a given one (`justice` until now excluded as a given name, #606, its cost `Chief Justice John Roberts` read as given 'Justice'), so the rule's reach is exactly what master read as name words, now reading the title only in a phrase. Declaring given readings on the titles decisions/vocabulary-model's 2026-10-24 entry ordered (`wing` first) is 2j's, one measured PR per batch. The words leave `titles` for `titles_ambiguous` rather than staying in both, and that is a compat decision, not a convenience: `Constants().titles.add("dean")` is the documented 1.4 recipe for a title the default leaves out, and a word in both sets would be read by the phrase test whatever the caller listed, so `Dean Smith` would lose the title the caller asked for. Measured: with dean in both, `test_the_documented_replacements_for_an_in_place_edit_work` and four shim controls failed, `Dean Smith` reading given 'Dean' with dean added to the titles. THE PARTNER (item 11). A modifier or a style before the word, `of` after it, a title before it that attaches to it, or a title after it that it attaches to: `attaches`, a title property naming title kinds, compiles into two-word phrases (`chief` attaching to office and military words, `major` to military ones; count them with `uv run python -c "from nameparser import Lexicon; print(len(Lexicon.default().title_phrases))"`). A connective other than `of` is no partner: `Dean and Mary Smith` is two given names, as master read it. OUTSIDE A PHRASE THE WORD IS UNLISTED, EVERYWHERE. Classify tags it no title, and the two vocabulary rosters that ask whether a word is listed at all, the capitals credential's "unlisted" (rules/C6) and the comma contrast's "claimed as non-name text", leave the set out. The first draft left them in, and the grid below found `John Smith, DEAN`, read suffix 'DEAN' by C6 on master, reversed to given 'DEAN', family 'John Smith'. Keeping the word unlisted outside its phrase is also what the design's spike measured for the 2j words, which it took out of `titles` altogether. THE REPORT. `title-or-name` where the word opens the part carrying the given name, read as a name word, at the leading-title peel; not where a phrase settles it, and not inside the name. It is the rule's own fork: the reader of `Dean Smith` can hesitate. Most of what this PR moves is the report: 1,737 of FEC's 1,790 moved names (a `FAMILY, DEAN` given part), 125 of ctgov's 203. The report goes the direction the RFC's front-of-the-name section states, where the rule reads the word as a name; item 12 of the 2026-10-23 #752 entry worded it "only where the rule decided against a declared name reading", which read literally is the other direction, where the title won. The RFC's direction is the one taken: the fork a reader hesitates over is `Dean Smith`, not `Dean of Students`. DECISION 10 (item 10). A modifier in the leading run of titles before the first comma is a title with a head title after it in that run (any title that is not a modifier, or H2's shape) or in the object of `of` there (`President of the United States`, `the Queen's Bench`). The spike read only "a head title after it", and on this tree that rule broke `President of the United States`, `Chargé d'Affaires`, `Member of Parliament` and the committed `Queen's Bench` row, which it read given 'Bench' (the cost the 2026-10-23 entry recorded); the object of `of` fixes the first and the last. The first build also carried the object across any connective, so `Mr. and Mrs. Wing Lau` kept title 'Mr. and Mrs. Wing' (the code review); only `of` and `the` carry it now (`the`, to keep `The Grand Lau` a title as master reads it), and it reads given 'Wing'. A third fix of the first round, dropping `of` after a modifier as a partner once `member` was an office, narrowed shapes master read right (`Mother Superior of the Convent Mary Smith`, `Bearer of the Seal John Smith`, found by the second review of the fix commit); it is restored, each with a case row. It also read the whole part before the comma as the head, so a modifier ENDING the name lost H5's trailing title (`Jane Smith Vice.`, title 'Vice.' on master and 2.6.0; the design-docs review); only the leading run is the head now. Reclassified, each a word that heads or completes its phrase rather than qualifying a head after it: `resident` and `rt` to occupation and `pro` to academic (item 10's own, a part after a comma written as the person's role), `d'affaires` to office (it stands behind its head, `chargé`) and `member` to office (the head noun of `Designated Member` and `Member of Parliament`; `Designated Member` was four PSC records the first build broke). After a comma a modifier is read as listed. #853'S RULE ON ITS OWN (item 12). Measured as the item asks, on the rule itself: a scratch tree of this PR with a `given` reading on the nine words of the item's measurement (`sheikh`, `shaikh`, `wing`, `prince`, `princess`, `sultan`, `sultana`, `mahdi`, `king`), against master `a46b3a6e`, PSC records holding one of them as the forename box's first word or the title box's last (the spike's `psc_pos` count): after another title, 643 fixed and 0 broken (the spike's 644 and 0); alone at the head, 152 fixed; the title box alone, 24 set aside. So the rule lands without declaring any of those words, and its live reach in this PR is `dean`, `justice` and `major`, which master read as name words already. THE GIVEN READINGS #835 RECORDED ON SALUTATIONS. Thirteen salutations carried a `given` reading as evidence for or against H8's `after_given` (`mr`, `mister`, `herr`, `master`, `señora` and eight more), which under derivation would have made `Mr Smith` read given 'Mr'. Their counts move into the salutation reading's `note`, as `ld`'s did (decisions/vocabulary-model, 2026-10-23 item 2): the README's convention for evidence against marking. No reading moves. A title that is also a particle (`st`, `freiherr`) keeps its given reading, which derives the particle's marker, and is left out of the set: the particle rules read it at the front (rules/P4). Recompute: `uv run python tools/vocab/query.py --word mr`. THE CORPORA (measured 2026-10-25 against master `a46b3a6e`, py3.11, every local check with `--base`). Committed: 12 entries move (the `Dean …` and `Major …` names gain the report, `Wing Lau` and `Grand Rounds` read the modifier as the given name, the `Dean of Chemistry` rows read the phrase as the title) and 31 names new to the committed corpora join from the rules' examples (`snapshot.py --since a46b3a6e`); CLDR's conventions row `Wing Lau` now agrees on given and title, and no agreement line regresses. PSC (`local_agreement.py psc-individuals`, 1,039,174 records scored, the fetch dated 2026-10-22): 178 fixed, 3 broken, 16 changed, the broken all a role typed in the title box (`British` 2, `Director Uk` 1), the fixed mostly a salutation before `Wing` and `Wing` alone (the first build broke four more, `Designated Member`, which `member`'s reclassification fixed). ctgov-officials 203 moved, 125 of them the report alone; FEC 1,790, 1,737 the report alone (a `FAMILY, DEAN` given part), the other 53 a modifier no longer peeled from the family part by H6 (`FIELD SMITH, JOHN` keeps family 'FIELD SMITH', as 1.4.0 read it) or the FEC placeholder `INFORMATION REQUESTED`; tse-donors 23, 14 the report and 9 a modifier opening a name read as the given name (`ROCK`, `AIR`). Consistency (mechanisms.md#PAIRED-SPELLINGS-PER-CHANGE): 35 jrc-names persons and 4 nepal-candidates pairs now alike, none apart, tse unchanged. THE SWEEPS. A grid of 34,380 parses (twelve fronts, salutations, `Dr.`, a dual second in a title run `Rev. Sr.`, the both-vocabulary heads `Freiherr`, `St` and `St.`, a style, a modifier and `Mr. and Mrs.`, before fifteen heads in their bare, capitals, lower-case and period-marked spellings, eight followers and eleven tails, in the comma shapes with an empty head, a nickname head, a particle surname and a title before the comma, under given-first and family-first orders) moved only the shapes this entry names. Two residuals read and accepted: a phrase extended by a coordinating join behind it (`Chief Dean and Mary Smith` reads title 'Chief Dean and Mary'), and nonsense heads (`Chief Justice General Smith`). THE REVIEW FIXES (code review and design-docs review of the first build; the corpus figures above were re-taken after both review rounds, on the final tree). A period-joined title in a part holding a contested or modifier word lost its tag (`Lt.Gov. Dean Smith` read given 'Lt.Gov.'): only the two sets' words are read out of the title now. The modifier fixes above. And v1's `c.titles.clear()` left `Dean of Students` a title, its words being in no v1 set: the shim drops `titles_ambiguous` with the titles. Accepted, in rules/H9: `Mr Justice Smith` (an English High Court judge's style) reads given 'Justice', a salutation being no partner; a modifier whose head is not listed loses the title (`His Grace John Smith` → given 'His', where master read title 'His', given 'Grace', and likewise `Most Worshipful Grand Master` and `Air Cdre`, whose heads are not titles; `Grand Duke of Tuscany`, duke refused by the vocabulary; `Field Marshal Bernard Montgomery` now given 'Field', marshal refused too, where it read title 'Field', given 'Marshal'), and so does one written after its head (`Princess Royal Anne`, `Mother Superior Mary Smith`); the given part after a family comma reads a modifier as listed (`Lau, Wing` → title 'Wing', where `Wing Lau` reads given 'Wing'), the cost decision 10 took by keeping the gate out of comma parts; and a coordinating join behind a phrase extends the title (`Chief Dean and Mary Smith`). - **2026-10-26 #752, phase 2i (Derek) (`Grace` opening a name reporting, superseded by 2026-10-26 #752, phase 2i, the report's reach (Derek)) — NO REPORT WHERE A COMMA HAS MARKED THE GIVEN NAME; `grace` AND `marshal` ARE PHRASE-ONLY TITLES; `Mr Justice` STAYS A GIVEN NAME.** SUPERSEDES: in the 2026-10-25 #752, phase 2i entry, the report in a family comma's given part, its accepted costs for `His Grace John Smith` and `Field Marshal Bernard Montgomery`, and `major` attaching to the military kind. Derek's answers to the questions the PR raised, relayed 2026-10-26: - THE REPORT. In `SMITH, DEAN` the family comma has already marked `Dean` as the given name, so no reader hesitates, the test AGENTS.md sets for a reportable fork (its example is `Smith, John V`). The leading-title peel of a family comma's given part reports nothing now; the comma-less name and the part before a suffix comma still report (`Dean Smith, MD`). FEC's 1,737 report-only moves, every one a `FAMILY, DEAN` given part, are gone. - `grace` AND `marshal`. Each gains a title reading beside a dated `given` one, as `justice` did: `grace` a style ('His Grace'), `marshal` a military rank, `marshal`'s exclusion (#606) replaced. A modifier before each is its partner (`His`, `Field`), so `His Grace John Smith` reads title 'His Grace' (master read title 'His', given 'Grace') and `Field Marshal Bernard Montgomery` title 'Field Marshal' (1.4.0 through master read title 'Field', given 'Marshal'). `sergeant` and `sgt` attach to the word `major`, so `Sergeant Major John Smith` reads title 'Sergeant Major' (1.4.0 through master read title 'Sergeant', given 'Major'). - `attaches` MAY NAME A TITLE WORD, and `major` and `sergeant` name one. The review of these changes found that making `marshal` a contested military word widened every attachment to the military kind: the build made `sergeant marshal`, `sgt marshal` and `major marshal` phrases, so `Sgt Marshal Smith` lost the given name master and 1.4.0 read ('Marshal'), and `Major Marshal Smith` read title 'Major Marshal'. A kind reaches every word that later joins it, so a contested word joining the kind becomes a partner nobody chose; `sergeant` and `sgt` now attach to `major` and `major` to `general`, the one phrase each was for. `chief` keeps its kinds (office and military), its phrases with a contested word being real ranks and offices (`Chief Justice`, `Chief Marshal`). Each with a case row. - A STYLE OR MODIFIER IS A PARTNER ONLY WHERE IT IS A TITLE THERE. `grace` is now both a style and a title that may be a given name, so in `Grace Dean Smith` the rule as worded made `Grace` the partner of `Dean`; the tree read given 'Grace' anyway (the run stops at a name word), but the two readings disagreed. The partner before is now asked as read, left to right, and rules/H9 says so. Bearer counts are on the words (`uv run python tools/vocab/query.py --word grace`). - `Mr Justice`. An English High Court judge is styled `Mr Justice Smith`, so Derek asked for counts before deciding an exception. Measured 2026-10-26: the PSC register (fetch dated 2026-10-22) holds 17 records whose display name has a salutation and then `Justice`, and all 17 file `Justice` in the forename box; no record files it in the title box. ctgov-officials and fec-individuals hold none, and the committed corpora none outside rules/H9's own example. Every filer who writes it reads it as the given name, so no exception: rules/H9's Accepted clause stands. - MEASURED (2026-10-26, against master `a46b3a6e`, every local check with `--base`). PSC 178 fixed, 3 broken, 16 changed, as before. FEC 53 moved, none for the report alone. ctgov-officials 318 moved, 240 for the report alone (`dean` 115, `grace` 123), and tse-donors 94, 85 for the report alone (`grace` 69, `dean` 14, `marshal` 2): `Grace` opening a name now reports, `grace` being a common given name. The committed corpora: `His Grace John Smith` and `Field Marshal Bernard Montgomery` read the title, `SMITH, DEAN MR.` drops its report. Consistency unchanged (jrc 35 and nepal 4 now alike, none apart). - **2026-10-26 #752, phase 2i, the report's reach (Derek) — A TITLE NO LISTED PHRASE HOLDS REPORTS NOTHING STANDING ALONE.** SUPERSEDES: in the 2026-10-26 #752, phase 2i (Derek) entry, `Grace` opening a name reporting. Derek's answer to the volume that entry measured (`grace` 123 of ctgov's 240 report-only moves, 69 of tse's 85), relayed 2026-10-26: keep `Grace Smith` from reporting, by a rule derived from each word's phrases rather than a flag on the word. - THE RULE. A contested word some phrase in `title_phrases` holds can head a title of itself, so a reader of `Dean Smith` can hesitate; a word no listed phrase holds is a title only behind a style or a modifier (`His Grace`), so standing alone it is no title anyone hesitates over, the test AGENTS.md sets for a reportable fork. The question is asked of the phrases `attaches` compiles (`_vocab.in_a_title_phrase`), so no property was added, and only of the phrases classify can read, two words each a title or a title that may be a given name: a caller's `your grace`, whose `your` is no title, makes `Grace Smith` report nothing (the review of this change). The review also measured the first version splitting every phrase per call, 175 frames more on each name opening with `Grace` (344 against 169); the words are now cached per vocabulary. - WHICH WORDS FALL WHERE (the default vocabulary, 2026-10-26). Report: `dean`, `justice` and `marshal`, each held by a phrase only because `chief` attaches to their kinds (office, military: `chief dean`, `chief justice`, `chief marshal`), and `major`, which attaches to `general` and which `sergeant`, `sgt` and `chief` attach to. Silent: `grace`, a style, held by none, since no title attaches to the style kind. The partners the rule reads but `title_phrases` does not hold — a modifier or style before the word, `of` after it — are the same for every contested word, so they cannot tell one word from another, and the derivation does not read them. Its consequence: a word reports only while a phrase holds it, so narrowing `chief` to a list of words, or a caller's `Lexicon` with no phrases, silences the report for the words it drops. - MEASURED (2026-10-26, against master `a46b3a6e`, every local check with `--base`). ctgov-officials 195 moved, 117 for the report alone (`dean` 115); tse-donors 25, 16 for the report alone (`dean` 14, `marshal` 2); fec-individuals 53, as before. The difference from the entry above is exactly the `grace` names (123 and 69). The committed corpora: `Grace Dean Smith` drops its report, the one move. - **2026-10-26 #752, phase 2j, `wing` — A MODIFIER THAT MAY BE A GIVEN NAME IS READ BY ITS RUN'S TEST IN EVERY PART.** SUPERSEDES: in 2026-10-25 #752, phase 2i, the Accepted cost `Lau, Wing` → title 'Wing'. The first batch of the title words decisions/vocabulary-model's 2026-10-24 #752 (Derek) entry ordered. Phase 2i's decision 10 had already taken `wing`'s head-of-the-name fixes (`Wing Lau`, `Mr Wing Lau`: PSC's 25 fixed of that entry's ranking), so what a `given` reading on `wing` reaches is the two sites decision 10 left as listed: after a comma, and ending the name (H5's). A word in both `title_modifiers` and `titles_ambiguous` takes the modifier test in every part (`_vocab.title_readings`, written so in phase 2i): a title only with a head title after it in its run, `of` after it, or in the object of `of` or `the`. After a comma `Lau, Wing Kai` reads given 'Wing', middle 'Kai', where 1.4.0 through 2.6.0 read title 'Wing', given 'Kai', and `Lau, Wing Commander` keeps the title; ending the name `John Smith Wing.` reads family 'Wing.', where 2.6.0 read title 'Wing.' (1.4.0 read the family too). Alone after a comma `wing` now reads as `dean` has since phase 2i: `John Smith, Wing` reverses to given 'Wing', family 'John Smith', as `John Smith, Dean` does, the cost decision 10 measured for a role word and accepted here because the listing form is what writes the word there (of FEC's 22 names writing `WING` after a comma, the 14 that open the part with it are all `FAMILY, WING …`, the other 8 holding it behind another given word; ctgov writes none); and past a second comma C2 reads it as the suffix with a `comma-structure` report (`Smith, John, Wing`), as `Smith, John, Dean`. Elsewhere the two take different tests, so `wing` is no template for the contested titles: the review's twin grid, `Dean` in `Wing`'s place, read different roles for 1,854 of its 8,820 texts under the default order (`Vice Wing Kai` given 'Vice', `Vice Dean Kai` title 'Vice Dean'). The modifier test, not a contested word's partners, decides it beside a style: `Hon. Wing Lau` keeps given 'Wing' where `Hon. Dean Smith` reads title 'Hon. Dean', and `The Hon. Wing Lau` reads the title through the object of `the`, both as master read them. Ending the name the run's test counts H2's shape as a head, so a period-marked suffix behind keeps the title (`John Smith Wing. Jr.` → title 'Wing.', as master read it) where an unmarked one does not (`John Smith Wing. MD`); both are recorded in rules/H9's Accepted clause. No listed phrase holds `wing`, so `Wing Lau` still reports nothing (the report's reach, above). - THE COUNTS, recorded on the word (`uv run python tools/name_usage.py Wing`, 2026-10-26): given US SSA 188 births, Wikidata 17 (a lower bound), nam_dict band 1 in China; family Census 12,614, Wikidata 218. PSC's filers put `Wing` in the forename box 158 times as its first word (#853's table) and never in the title box. - MEASURED (2026-10-26, against master `12384fae`, py3.11, every local check with `--base`; the shape grid, 26,460 parses, by the design-docs review). No corpus holds a trailing `Wing.` or a full name before `, Wing`. fec-individuals 14 moved, every one a `FAMILY, WING …` donor now read with given 'WING' (`AA, WING` 7, `AA, WING AA…` 6, `AA, WING A. MR.` 1). The committed corpora move one name, this rule's former Accepted example `Lau, Wing` (`snapshot.py`); CLDR and GND agreement unchanged (`cldr_agreement.py`). PSC (1,039,174 scored, the fetch dated 2026-10-22), ctgov-officials and tse-donors do not move: PSC scores its names in spoken order, which decision 10 already reads. Spelling consistency (mechanisms.md#PAIRED-SPELLINGS-PER-CHANGE, per person, against master): jrc-names 0 now alike, 0 apart, of 218,822 persons with two or more spellings; nepal-candidates 0 and 0 of 126,981 pairs. No spelling in either writes `Wing` after a comma. - THE BOUNDARY LEFT. The modifier test takes any title after the word as its head, a salutation too, so `LAU, WING MR` keeps title 'WING MR' where `LAU, DEAN MR` reads given 'DEAN'. No FEC donor writes it: the one with a salutation writes an initial between (`AA, WING A. MR.`), which breaks the run, and reads right. Pinned as a case row rather than narrowed. Recompute: `uv run python tools/differential/snapshot.py --local fec-individuals --base ` (and `ctgov-officials`, `tse-donors`), `uv run python tools/differential/local_agreement.py psc-individuals --base `. - **2026-10-26 #752, phase 2j, `mahdi` and `bard` (#867) — THE SECOND TITLE BATCH: TWO TITLES WRITTEN BEFORE A NAME AS GIVEN NAMES, AND AS EPITHETS WITH THEIR ARTICLE.** The batch decisions/vocabulary-model's 2026-10-24 #752 (Derek) entry put second, `bard` joining `mahdi` from #867. Each gains a dated `given` reading, so each leaves `titles` for `titles_ambiguous` and is a title only inside a title phrase. No listed phrase holds either, so neither reports (the report's reach, above): `Mahdi Karimi`, `Mr Mahdi Karimi`, `Karimi, Mahdi` and `Bard Larsen` read the given name, unreported, where 1.4.0 through 2.6.0 read title 'Mahdi' or 'Bard' (and `Mr Mahdi`). Unlike `wing`, neither is a modifier, so the contested word's partners apply: a modifier or a style before it, or `of` after it (`Grand Mahdi Karimi` and `Mahdi of Sudan` keep the title; `Mahdi Grand Karimi` does not). Like every word H9 reads, each now reads as a name word wherever no phrase holds it: ending the name (`John Karimi Mahdi.` → family 'Mahdi.', as 1.4.0 through 2.2.0 read it, where 2.3.0 through 2.6.0 read a title), alone after a full name and a comma (`John Karimi, Mahdi` → given 'Mahdi', family 'John Karimi', as `John Smith, Dean` reads), and past a second comma (`Karimi, John, Mahdi` → suffix, `comma-structure`, as C2 reads `Smith, John, Dean`). - WHAT THE TITLE READINGS SERVED, AND THE COST. `mahdi` is a messianic title written as an epithet, never as an honorific before a person's own name; `bard`, from the 2014 title list with no recorded reason (#867), is mostly the Norwegian given name `Bård` with its accent dropped (in jrc-names, 31 of 36 persons with a spelling that starts `Bard` also have one that starts `Bård`). The epithet glued or alone reads the same in 1.4.0, 2.6.0 and this tree (`Muhammad Ahmad al-Mahdi` family 'al-Mahdi', `The Mahdi` given 'The'), but written with its article before a name or after a comma it read as the title in every release and now reads as the given name: `The Mahdi Muhammad Ahmad` → given 'The Mahdi', `Muhammad Ahmad, the Mahdi` → given 'the Mahdi', family 'Muhammad Ahmad', and the same for `The Bard William Shakespeare` and `William Shakespeare, the Bard` (found by the design-docs review). No local corpus writes either shape: 0 spellings holding `the Mahdi` or `the Bard` in jrc-names, fec-individuals, ctgov-officials, psc-individuals or ukec-individuals (2026-10-26), against the 79 PSC records and 26 ctgov and FEC names the given reading fixes. Recorded in rules/H9's Accepted clause. Under both family-first orders the joined epithet before a name is the family name (`The Mahdi Muhammad Ahmad` → family 'The Mahdi'), the comma shapes the given name under all three. - THE BECAUSE, WIDENED. rules/H9's Because had said a word after a comma is read as written save a modifier also borne as a given name, wrong for `dean` since phase 2i (`John Smith, Dean` → given 'Dean'); it now names every word borne as a given name, and the claim rests on what was measured there: `dean`'s FEC listing forms (phase 2i), `wing`'s 14 and `mahdi`'s and `bard`'s 17 FEC and 6 ctgov names, every one the listing form's given name. `justice`, `major`, `marshal` and `grace` alone after a comma were not measured, and `John Smith, Major`, a rank in a listing, is the shape that would test the claim for a later batch. - THE COUNTS, recorded on the words (`uv run python tools/name_usage.py Mahdi Bard Bård`, 2026-10-26): `mahdi` given US SSA 2,398 births, Wikidata 141 (a lower bound; Iran 43, Iraq 9), nam_dict band 5 in Arabia/Persia, family Census 1,241, Wikidata 13; `bard` given US SSA 218 (SSA folds `Bård`), Wikidata 10 and 107 more spelled `Bård`, nam_dict band 5 in Norway as `Bård`, family Census 5,953, Wikidata 100. PSC's filers put `Mahdi` in the forename box 84 times as its first word and never in the title box (#853, #867). - MEASURED (2026-10-26, against master `32049ade`, py3.11, every local check with `--base`). PSC (1,039,174 scored, the fetch dated 2026-10-22): 79 records fixed, none broken, 6 changed, the 6 still disagreeing only because the filer typed two words in the surname box, which the default order reads as middle and family (`Mr MAHDI Aa Aa`, `Aa MAHDI Aa Aa`). ctgov-officials 6 moved, each a `Mahdi Aa, CRED` shape now read with given 'Mahdi'; fec-individuals 20, all fixes: `AA, MAHDI` 10, `AA, BARD` 6, and one each of `AA AA, MAHDI`, `AA, BARD AA`, `AA, BARD A. T` and `BARD AA, AA`, whose family part keeps `BARD` as a surname where H6 had peeled it as a title. The committed corpora, CLDR and GND agreement, and tse-donors do not move: no committed corpus held either word before this change, whose rules examples add two. Spelling consistency (mechanisms.md#PAIRED-SPELLINGS-PER-CHANGE, per person, against master): jrc-names 44 persons now alike and none apart, of 218,822 with two or more spellings, the spellings that read the word as a title and those that did not; nepal-candidates 0 and 0. Recompute: `uv run python tools/differential/local_agreement.py psc-individuals --base `, and `uv run python tools/differential/snapshot.py --local ctgov-officials --base ` (and `fec-individuals`, `tse-donors`). - **2026-10-26 #752, phase 2j, `buddha`, `siddha` and `gyani` (#869) — THE THIRD TITLE BATCH: THE FIRST HALVES OF NEPALI COMPOUND GIVEN NAMES.** The batch decisions/vocabulary-model's 2026-10-24 #752 (Derek) entry put third, Derek having agreed there that the three go in the default gate and `guru` to a `ne` pack (its Devanagari twin `गुरु` must move with it; none of the three has a listed twin). Each gains a declared `given` reading and is a title only inside a title phrase; no listed phrase holds any of them, so nothing is reported. `Buddha Bahadur Tamang`, `Gyani Maya Gurung`, `Siddha Raj Joshi` and `Tamang, Buddha Bahadur` read given 'Buddha', 'Gyani', 'Siddha', where 1.4.0 through 2.6.0 read the word as the title and the compound's second half as the given name. A title phrase keeps the title (`His Holiness Buddha Ram`, `Buddha of Kapilavastu Ram`), and the other sites read as for `mahdi` (the entry above): ending the name, alone after a full name and a comma, past a second comma, and before a family comma, where H6 no longer peels the word from the family part (`Buddha Tamang, Ram` → family 'Buddha Tamang', `Dr. Buddha Tamang, Ram` → title 'Dr.', family 'Buddha Tamang'). Under either family-first order the word before a name is the family name (`Lord Buddha Tamang` → family 'Buddha'). Written with its period before `and` it joins the next word as a name (`Buddha. and Maya Gurung` → given 'Buddha. and Maya'), as an unlisted word does: master and 2.6.0 already read `Dean. and Mary Smith` so, and rules/H9's connective precedence bullet and a new P3 bullet in rules/H2 now say it, H9's H2 bullet naming the exception (the review of this batch found both rules claiming the shape always won; the reading is 1.4.0's, and `Xyz. and Mary Smith` reads the same). Behind another title the period-marked spelling stays a title by H2's shape (`Mr Buddha. Tamang` → title 'Mr Buddha.', as master read `Mr Dean. Smith`), which H9's H2 bullet now states for the leading run, not only the front. - THE COUNTS. Outside Nepal the sources barely see the words (`uv run python tools/name_usage.py Buddha Siddha Gyani`, 2026-10-26): `buddha` given US SSA 6 births and Wikidata 4, recorded as counts; `siddha` and `gyani` reach neither source's floor, so their readings stand without a count and their notes give nepal-candidates' figures, 12 and 38 of 126,981 pairs (Buddha 83), every one the first half of a compound given name, none after a comma. - THE COSTS, in rules/H9's Accepted clause, none written by any name the corpora hold (0 matches in the eight local corpora and the committed ones, 2026-10-26): the Sikh title in the spelling `Gyani` before a name reads as the given name (`Gyani Zail Singh`; its usual spelling `giani` has no given reading and keeps the title); a title before the word that is no style, modifier or attaching title is no partner (`Lord Buddha Tamang` → given 'Buddha'; `His Holiness Buddha Ram` and `Grand Buddha Ram` keep the title); and the epithet with its article reads as the `mahdi` entry's does, after a comma and before a name (`Ram Tamang, the Buddha` → given 'the Buddha', `The Buddha Ram` → given 'The Buddha'). - MEASURED (2026-10-26, against master `021028b2`, py3.11, every local check with `--base`). nepal-candidates: 121 romanized names moved (`snapshot.py --local nepal-candidates`), 117 the word read out of the title alone and 4 gaining or losing a report: 2 lose H4's `title-or-name`, the name having been nothing but vocabulary (`Gyani Lama`, `lama` a title too, as `Lord Buddha` and `Lord Mahdi` lose it), and 2 gain the `suffix-or-name` any name with a trailing credential word and words to spare gets (`Buddha Lama ma` reads as `Ram Lama ma`). Spelling consistency (mechanisms.md#PAIRED-SPELLINGS-PER-CHANGE, romanized against Devanagari, by structure): 128 pairs now alike, none apart. ctgov-officials 4 moved, all fixes, 2 of them one official written twice whose credential master read as the given name (`Buddha Aa, CAPS, Aa, CAPS` → given 'Buddha', the credentials suffixes, `suffix-or-name` on the name word between them). PSC (1,039,174 scored), fec-individuals, tse-donors, jrc-names, the committed corpora and CLDR and GND agreement do not move. Recompute: `uv run python tools/differential/snapshot.py --local nepal-candidates --base ` (and `ctgov-officials`); the pairs compare `local_corpora._read`'s structure of each record's `name` and `devanagari` in each tree, run from that tree's checkout. - **2026-10-26 #752, phase 2j, `king` (Derek) — THE FOURTH TITLE BATCH: A MONARCH'S BARE TITLE READS AS THE GIVEN NAME.** The batch decisions/vocabulary-model's 2026-10-24 #752 (Derek) entry put fourth. `king` gains a dated `given` reading and is a title only inside a title phrase; no listed phrase holds it, so nothing is reported. `King Curtis`, `Mr King Smith` and `SMITH, KING MR` read given 'King', where 1.4.0 through 2.6.0 read title 'King' and `Mr King` (`SMITH, KING MR`: title 'KING', given 'MR' in 1.4.0 through 2.1.0, title 'KING MR' from 2.2.0). A style keeps the title (`His Majesty King Charles` → title 'His Majesty King', given 'Charles', the title still addressing by the given name), as does `of` (`King of England`). The other sites read as for `mahdi` and `buddha` (the entries above): before a family comma (`King Smith, John` → family 'King Smith', 1.4.0's reading, where 2.5.0 through 2.6.0 peeled `King`), ending the name (`Mary Smith King.` → family 'King.'), alone after a full name and a comma, past a second comma, and under a family-first order the word before a name is the family. - THE COUNTS, recorded on the word (`uv run python tools/name_usage.py King`, 2026-10-26): given US SSA 37,890 births, Wikidata 178 (a lower bound; United States 92), nam_dict band 1 in the U.S.A. and China; family Census 465,422 (rank 34), Wikidata 4,554. - THE COST, measured and decided. A monarch written with the bare title in front reads `King` as the given name: `King Henry` → given 'King', family 'Henry', where 1.4.0 through 2.6.0 read title 'King', given 'Henry'; so do `King Charles III`, `King John V.` and `King and Queen Smith` (given 'King and Queen'). `queen` is in no batch, so `Queen Elizabeth II` keeps its title. In the committed corpora the cost is three rows (`King Henry`, `King John V.`, `King John Alexander V`). Of the local corpora only jrc-names, drawn from news text, writes monarchs: 20 persons (39 spellings) open with `King`, by a reading of each about half monarchs or legendary kings and half people whose given name, stage name or nickname is King; PSC, FEC, ctgov and ukec write none. Shown these figures, Derek decided on 2026-10-26: "Accept the royal cost for king". Recorded in rules/H9's Accepted clause. - `king` WAS THE WORKED EXAMPLE OF A TITLE THAT IS ALSO A SURNAME, in rules H1, H3 to H8, M2, S2 and S8, their decisions Standing lines, AGENTS.md, docs/usage.rst, two code comments and eleven case rows and tests. Each now uses `pope`, chosen because it reads as `king` read on master in every one of those examples (19 inputs checked against `queen`, `bishop`, `pope`, `baron`, `judge`, `sheriff`, `mayor` and `emperor`; only `pope` and `queen` matched all, `pope` being the commoner surname). decisions/v1-xfail-triage's "`king` stays in TITLES" is superseded there. - MEASURED (2026-10-26, against master `957509d1`, py3.11, every local check with `--base`). PSC (1,039,174 scored): 58 records fixed, none broken, 5 changed. fec-individuals 73 moved, all fixes: `AA, KING …` given names (51) and `KING AA, AA` family parts that H6 had peeled (22). ctgov-officials 12, all fixes: officials whose given name is King (`King A. Aa`), and one whose credential master read as the given name (`Aa King, AA Aa` → given 'Aa', family 'King', the credentials suffixes). tse-donors, ukec-individuals, jrc-names and nepal-candidates pairs (0 now alike, 0 apart: both spellings of a person move together), CLDR and GND agreement do not move. The committed corpora move 11 names before the rules examples were rewritten: the three royal rows, `King Curtis` (#348's example, fixed), `King Smith, John` and `Mary Jane King.` (1.4.0's readings), a maiden clause, and four that only lose H4's `title-or-name`, the word no longer being title vocabulary (`Dr. King`, `Dr King Jr`, `Dr. King MD`; `Smith and King` reports `given-or-family` instead). Recompute: `uv run python tools/differential/local_agreement.py psc-individuals --base `, and `uv run python tools/differential/snapshot.py --local fec-individuals --base ` (and `ctgov-officials`). - **2026-10-26 #752, phase 2j, `sultana` and `sultan` (Derek) — THE FIFTH AND SIXTH TITLE BATCHES, COMBINED.** The batches decisions/vocabulary-model's 2026-10-24 #752 (Derek) entry put fifth and sixth; measured apart and together, `sultana` costing nothing and `sultan` only the royal class accepted for `king` (the entry above), Derek combined them on 2026-10-26. Each gains a dated `given` reading (`sultan`'s on its tr.toml entry, the other two files' readings deriving the same marker) and is a title only inside a title phrase; no listed phrase holds either, so nothing is reported. `Sultana Begum`, `Mrs Sultana Begum`, `Sultan Khan`, `Mr Sultan Khan`, `Khan, Sultan` and `Sultan Bin Zayed` read the given name, where 1.4.0 through 2.6.0 read the title (and `Mrs Sultana`, `Mr Sultan`). A style keeps the title (`His Majesty Sultan Hassanal Bolkiah`), as does `of` (`Sultan of Brunei`), and the Ottoman writing after the name was already a name word (`Hurrem Sultan`). The other sites read as for `king`. - THE COUNTS, recorded on the words (`uv run python tools/name_usage.py Sultana Sultan`, 2026-10-26): `sultana` given US SSA 275 births, Wikidata 19 (Bangladesh 5), nam_dict band 3 in Romania and Arabia/Persia, family Census 3,471, Wikidata 34; `sultan` given US SSA 2,396, Wikidata 151 (Turkey 28, Saudi Arabia 13), nam_dict band 7 in Turkey, family Census 3,363, Wikidata 106 (Ottoman Empire 26, the title written after the name). - THE COST, `sultan`'s only: a sultan written with the bare title in front reads it as the given name (`Sultan Hassanal Bolkiah` → given 'Sultan', where 1.4.0 through 2.6.0 read title 'Sultan', given 'Hassanal'; so `Sultan Qaboos bin Said`), the class accepted for `king`; in PSC one filer typed `Sultan` in the title box before a name, the one broken record. Sized afterwards, at the review's prompting, in the news text of jrc-names: 135 persons (266 spellings) open with `Sultan` or `Sultana`, by a reading of each about 40 rulers (mostly Ottoman sultans and today's Gulf and Malay rulers), about 80 people whose given name is Sultan, and 13 mosques and other places, a cost about four times `king`'s 10 there. - FOUND BY THE REVIEW, NOT FIXED HERE (fixed by the 2026-10-26 #752, the v1 title list entry below): A v1 `remove` OF A WORD 2.6.0 LISTED NOW RAISES. A title that gains a given reading compiles out of `TITLES` (the 2026-10-25 #752, phase 2i entry of decisions/vocabulary-model), so `Constants().titles` no longer holds it and v1's strict `remove` raises `KeyError`: `c.titles.remove('sultan')`, which 2.6.0 accepted (and which then read `Sultan Khan` with first 'Sultan'), raises on this tree, and so does it for every word phase 2j moved out of the titles 2.6.0 listed (`wing`, `mahdi`, `bard`, `buddha`, `siddha`, `gyani`, `king`, `sultana`); phase 2i's own words were never in 2.6.0's titles, so removing them raised before too. The shim is not to raise on a config v1 accepted (AGENTS.md), so this is a defect of the batches, left for its own change before 2.7.0 ships. - NOT REACHED, for `sheikh`'s batch. `sheikh` is a style, so under rules/H9 it partners a contested word behind it: `Sheikh Sultan Khan` keeps title 'Sheikh Sultan', as master reads it and as `Sheikh Dean Smith` reads, where in Gulf names the word is the person's given name. - MEASURED (2026-10-26, against master `6ba78d0d`, py3.11, every local check with `--base`). Both words: PSC (1,039,174 scored) 111 records fixed, 1 broken (the title box above), 6 changed, each still wrong for another reason, mostly a two-word surname box; ctgov-officials 41 moved; fec-individuals 21; tse-donors 1; spelling pairs (mechanisms.md#PAIRED-SPELLINGS-PER-CHANGE) jrc-names 9 persons and nepal-candidates 6 pairs now alike, none apart. `sultana` alone: PSC 28 fixed, none broken, 1 changed (a two-word surname box); ctgov 7, every one a fix, 6 of them `Aa Sultana, CRED` names whose credential master read as the given name (`Nasreen Sultana, MBBS` → given 'Nasreen', family 'Sultana', suffix 'MBBS', reported `suffix-or-name`); FEC 5 (`AA, SULTANA`); tse 1; nepal 1. The committed corpora and CLDR and GND agreement do not move. Recompute: `uv run python tools/differential/local_agreement.py psc-individuals --base `, and `uv run python tools/differential/snapshot.py --local ctgov-officials --base ` (and `fec-individuals`, `tse-donors`). - **2026-10-26 #752, the v1 title list — A v1 `Constants().titles` HOLDS EVERY TITLE 2.6.0 LISTED THAT IS STILL A TITLE OF EITHER KIND.** The review of phase 2j's fifth and sixth batches found that a word a batch moved from `titles` to `titles_ambiguous` also left v1's default `titles`, which the shim copied from TITLES: `Constants().titles.remove('king')` raised `KeyError` and `'king' in Constants().titles` was False, where 2.6.0 accepted the removal and answered True. Measured against the 2.6.0 wheel (`PYTHONSAFEPATH=1`, `nameparser.__file__` asserted on both sides), word by word over all nine v1 set attributes and the `capitalization_exceptions` keys: `titles` alone had lost words (`bard`, `buddha`, `gyani`, `king`, `mahdi`, `siddha`, `wing`, and `sultan` and `sultana` once the batch above landed), and `suffix_not_acronyms` had gained the seven agnomens and `fils` of phase 2h, a vocabulary addition its release bullet records. `suffix_acronyms_ambiguous` (derived in phase 2a), `given_name_abbreviations` (2k) and the phase 2h sets have no v1 manager that lost a word. The translation: `nameparser/config/_titles_2_6_0.py` freezes 2.6.0's TITLES, generated from the wheel and never edited, and v1's default `titles` is TITLES with every 2.6.0 title now in `titles_ambiguous` added back (`moved`). `Constants._build_snapshot` passes a moved word to `titles_ambiguous` while the caller keeps it, so a default `Constants` still translates to `Lexicon.default()`, and passes it to neither once removed, which turns its modifier, style and phrase readings off with it, as removing any title does: `c.titles.remove('king')` then reads `Hon. King Smith` as title 'Hon.', given 'King', 2.6.0's reading after the same removal. A word 2.6.0 never listed (`dean`, `justice`, `grace`, `major`, `marshal`) is not in `moved`, so `c.titles.add('dean')` still makes it a title everywhere. The gap, accepted because v1's one bit per word cannot carry three readings: adding a moved word in v1 restores the default, the phrase reading, and not 2.6.0's title everywhere, which a caller gets from the 2.0 API (`Lexicon.default().add(titles={'king'})`). Keyed on the compiled sets rather than a hand list, so a later batch that moves another 2.6.0 title (`sheikh`) is covered with no edit, as `sultan` and `sultana` were when the batch above merged in, and `tests/v2/test_config_shim.py` sweeps every moved word by the same derivation: membership, set operators, `remove`, `discard`, re-adding, a replaced list, pickle, deepcopy and copy, 37 of its 39 rows failing on master `6ba78d0d` (the two that pass are the non-empty guard and `dean`'s recipe), with rows added in review for a spelling Lexicon folds onto a moved word (`king。`, `wing。`: the moved test and the modifier, style and phrase intersections compare folded spellings, #582's rule, the second review finding that folding the first alone lost `Wing Commander`) and for a replaced list's unlisted words. The default parse does not move: the snapshot, the CLDR, GND and native agreement records and the facade case rows pass unchanged. - **2026-10-26 #752, phase 2j, `shaikh` (Derek) — THE SEVENTH TITLE BATCH: THE FIRST STYLE OF THE BATCHES.** The batch decisions/vocabulary-model's 2026-10-24 #752 (Derek) entry put seventh, `sheikh` left a batch of its own. `shaikh` gains a dated `given` reading and is a title only inside a title phrase; no listed phrase holds it, so nothing is reported. `Shaikh Ahmed Khan`, `Mr Shaikh Ahmed` and `Khan, Shaikh` read given 'Shaikh', where 1.4.0 through 2.6.0 read title 'Shaikh' (and `Mr Shaikh`). A style keeps the title (`His Highness Shaikh Ahmed Khan`, as 1.4.0 read it), as does a modifier (`Grand Shaikh Ahmed`) or `of` (`Shaikh of Bahrain`); the period-marked spelling at the front is a title by H2's shape (`Shaikh. Ahmed`). The other sites read as for `king` (the entry above): before a family comma H6 no longer peels it (`Shaikh Smith, Ahmed` → family 'Shaikh Smith', 1.4.0's reading), ending the name (`Ahmed Khan Shaikh.` → family 'Shaikh.', 1.4.0's), alone after a full name and a comma, past a second comma, and under a family-first order the word before a name is the family. - A STYLE AS A PARTNER. `shaikh` is a style (the second contested one, after `grace`), so where it is a title it partners a contested word behind it (`His Highness Shaikh Sultan Khan` → title 'His Highness Shaikh Sultan'); where it is read as a name word it partners nothing, being no title there (`Shaikh Sultan Khan` → given 'Shaikh', middle 'Sultan', where master read title 'Shaikh Sultan'). Nor does it partner where only H2's shape makes it a title, the partner test counting a contested word a title only where its own phrase did (`_vocab.title_readings`): `Shaikh. Sultan Khan` → title 'Shaikh.', given 'Sultan', where master and 1.4.0 read title 'Shaikh. Sultan', and `Shaikh. Dean Smith` → given 'Dean', now reported `title-or-name`, as 1.4.0 read it. Master read `Grace. Dean Smith` so already, while a modifier so written partners (`Wing. Dean Smith` → title 'Wing. Dean'); the review of this batch found the shape, which rules/H9's H2 bullet now states. No local corpus writes `Shaikh.` before another word. The 2026-10-26 `sultana` and `sultan` entry's note on `Sheikh Sultan Khan` stands for `sheikh`, still a title. - THE COUNTS, recorded on the word (`uv run python tools/name_usage.py Shaikh`, 2026-10-26): given US SSA 41 male births, Wikidata none, nam_dict not listed; family Census 6,063 (asian/PI 89.61%), Wikidata 49 (India 7, Pakistan 5). Mostly a South Asian surname, which stands last and was a name word already; first in the name it is a given-name element, as PSC's filers write it (below). - THE COST, accepted by Derek on 2026-10-27 ("accepted"), shown the share of rulers below beside `king`'s and `sultan`'s and that `His Highness` in front keeps the title: a ruler or a member of a ruling house written with the bare title in front reads it as the given name (`Shaikh Hamad bin Isa Al Khalifa` → given 'Shaikh', middle 'Hamad bin Isa', where 1.4.0 through 2.6.0 read title 'Shaikh', given 'Hamad'), the class accepted for `king` and `sultan`, here larger in news text and smaller in record data. In jrc-names 59 persons have a spelling opening with `Shaikh`: by a reading of each, about 38 are Gulf rulers and members of their houses (`Shaikh` is the Bahraini English press's spelling), about 13 clerics and tribal or political leaders, and about 8 people whose name opens with it. In PSC the forename box holds it first in 38 of the 41 moved records and the title box in 2, beside 3 title-box filings newly set aside (below). - MEASURED (2026-10-26, against master `fb4cafac`, py3.11, every local check with `--base`). PSC (1,039,171 scored, 3 more title boxes set aside, each `Shaikh` filed as a title) 33 records fixed, 1 broken (`Shaikh` filed in the title box), 7 changed, each still wrong for another reason (among them a two-word surname box and a title-box filing); ctgov-officials 8 moved, 7 fixes (4 `Shaikh A Aa, CRED` read with the initial as the given name, 2 `Aa Shaikh, CRED, CRED` whose credential master read as the given name), 1 still reading its credential as the given name; fec-individuals 2, both fixes after a family comma (`AA, SHAIKH` and `AA, SHAIKH AA`: given 'SHAIKH' where master read the title); tse-donors and nepal-candidates none; spelling pairs (mechanisms.md#PAIRED-SPELLINGS-PER-CHANGE) jrc-names 0 persons now alike and 12 now apart, every one a person spelled both `Shaikh` and `Sheikh`, which `sheikh`'s batch is to rejoin. No committed corpus name held `shaikh` before the batch's three rule examples, so they and CLDR and GND agreement do not move, and could not have. A v1 `Constants().titles` keeps `shaikh` with no edit (the v1 title list entry above), its sweep in `tests/v2/test_config_shim.py` gaining six rows. Recompute: `uv run python tools/differential/local_agreement.py psc-individuals --base `, and `uv run python tools/differential/snapshot.py --local ctgov-officials --base ` (and `fec-individuals`, `tse-donors`, `nepal-candidates`). - **2026-10-27 #752, phase 2j, `prince` and `princess` (Derek) — THE EIGHTH TITLE BATCH: TWO GIVEN-NAME TITLES.** The batch decisions/vocabulary-model's 2026-10-24 #752 (Derek) entry put next, the two words taken together as one rank in two genders. Both gain a dated `given` reading and are titles only inside a title phrase; no listed phrase holds either, so nothing is reported. `Prince Fielder`, `Mr Prince Smith`, `Ms Princess Smith` and `Smith, Princess` read given 'Prince' or 'Princess', where 1.4.0 through 2.6.0 read the title (`Prince Fielder` given 'Fielder' from 2.3.0, last 'Fielder' in 1.4.0). A style keeps the title (`His Royal Highness Prince Harry`, `Her Royal Highness Princess Anne`), and so does `of` (`Prince of Wales`) or a modifier (`Grand Prince Vladimir`); inside the phrase each still addresses by the given name (decisions/H1's 2026-09-10 #519 entry), so the word behind it is the given name. The other sites read as for `king`: before a family comma H6 no longer peels it (`Prince Smith, John` → family 'Prince Smith', 1.4.0's reading), ending the name, alone after a full name and a comma, and past a second comma. `prince` was a worked example in three places, each now written with a word that reads as it did on master: H7's join (`John of Bishop`, `Dr. Smith and Bishop`, `John of Bishop Prof.`, where `John of Prince` now reports `given-or-family`, `prince` being no longer TITLES vocabulary), H1's comma example (`Anne, Swami`), and H9's modifier written after its head (`Her Royal Highness Princess Royal Anne`, the style keeping `princess` a title for `royal` to follow). The #519 case rows that pin the membership write the style in front. - THE COUNTS, recorded on the words (`uv run python tools/name_usage.py Prince` and `Princess`, 2026-10-27): `prince` given US SSA 24,151 births (23,601 male), Wikidata 204 (United States 53, Ghana 19, Nigeria 10), nam_dict band 1 in the U.S.A. and India/Sri Lanka; family Census 47,168 (rank 743), Wikidata 525. `princess` given US SSA 15,982 births, Wikidata 29 (Nigeria 6, Ghana 3), nam_dict band 1 in the U.S.A.; family Census under 100, Wikidata 1. - THE COST, accepted by Derek on 2026-10-27, shown the share of royals below beside `king`'s, `sultan`'s and `shaikh`'s and that a style or `of` keeps the title: a royal written with the bare title in front reads it as the given name (`Prince Harry` → given 'Prince', family 'Harry', where 2.3.0 through 2.6.0 read title 'Prince', given 'Harry'), the class accepted for `king`, `sultan` and `shaikh`. In jrc-names 43 persons have a spelling opening with a bare `Prince` or `Princess` (one not followed by `of`): by a reading of each, about 33 are royals, 8 people whose given name is Prince, and 2 no person at all (a fictional character, a place). In PSC 12 of the 233 moved records file the word in the title box (10 broken, 2 changed). - MEASURED (2026-10-27, against master `ac81de9c`, py3.11, every local check with `--base`). PSC 205 records fixed, 10 broken, every one `Prince` (6) or `Princess` (4) filed in the title box, which the scorer now sets aside, 18 changed, most now reading the given name right and splitting a two-word surname box, 2 of them title-box filings; ctgov-officials 6 moved, all fixes: 4 open with the word, 3 of them before a credential comma, 1 glues an initial to it (`J.Prince Aa, CRED`), and 1 is `Aa Prince, CRED`, whose credential master read as the given name; fec-individuals 43, all fixes, every one `AA, PRINCE` or `AA, PRINCESS` in the listing form, 28 of which had read no family name; nepal-candidates 3, fixes; jrc-names 101 spellings moved, the royals above and `Archduchess of X Princess Y`, a title before the word that is no partner of it; tse-donors and ukec-individuals none; spelling pairs (mechanisms.md#PAIRED-SPELLINGS-PER-CHANGE) jrc-names 4 persons now alike and 0 apart, nepal-candidates 3 and 0. The committed corpora move by `Prince Charles` and `Prince Fielder` (corpus_issues, #348's examples) beside the batch's own rule examples; CLDR and GND agreement do not move. A v1 `Constants().titles` keeps both words with no edit (the v1 title list entry above). Recompute: `uv run python tools/differential/local_agreement.py psc-individuals --base `, and `uv run python tools/differential/snapshot.py --local ctgov-officials --base ` (and `fec-individuals`, `tse-donors`, `ukec-individuals`, `nepal-candidates`, `jrc-names`). - **2026-10-27 #752, phase 2j, `sheikh` and its spellings (Derek) — THE NINTH AND LAST TITLE BATCH.** `sheikh`, `sheik`, `shekh`, `shaik` and `cheikh` gain dated `given` readings and are titles only inside a title phrase, as `shaikh` became in the seventh batch; no listed phrase holds any of them, so nothing is reported. `Sheikh Ahmed Khan`, `Mr Sheikh Ahmed`, `Khan, Sheikh`, `Cheikh Anta Diop` and `Shekh Abdul Rahim` read the word as the given name, where 1.4.0 through 2.6.0 read the title. A style keeps it (`His Highness Sheikh Ahmed Khan`, as 1.4.0 read it), as does a modifier (`Grand Sheikh Ahmed`) or `of` (`Sheikh of Kuwait`), and the period-marked spelling at the front is a title by H2's shape (`Sheikh. Ahmed Khan`). The other sites read as for `shaikh`: before a family comma (`Sheikh Smith, Ahmed` → family 'Sheikh Smith', 1.4.0's reading), ending the name, alone after a full name and a comma, and past a second comma. A style read as a name word partners nothing (`Sheikh Sultan Khan` → given 'Sheikh', middle 'Sultan', where master read title 'Sheikh Sultan'), and behind a style it partners the contested word as before (`His Highness Sheikh Sultan Khan`), closing the NOT REACHED bullet of the 2026-10-26 `sultana` and `sultan` entry above. - WHICH SPELLINGS. Vetted by where UK PSC's filers put each (the first word of the forename box against any word of the title box, 2026-10-27): `sheikh` 174 against 19, `sheik` 23 against 1, `shekh` 6 against 1, `shaik` 14 against 0, `cheikh` 14 against 0. `shaykh`, the scholarly transliteration, stays a title everywhere: no filer writes it in front and no source counts it as a given name; the one jrc-names person it now holds apart from its other spellings (below) is the cost. - THE COUNTS, recorded on the words (`uv run python tools/name_usage.py Sheikh Shekh Sheik Shaykh Shaik Cheikh`, 2026-10-27): `sheikh` given US SSA 436, Wikidata 21, family Census 6,462 (rank 5,384), Wikidata 60; `sheik` given SSA 39, family Census 401, Wikidata 4; `shaik` given SSA 10, family Census 1,053 (93.73% asian/PI), Wikidata 14; `cheikh` given SSA 512, Wikidata 51 (Senegal 34), family Census 112, Wikidata 14; `shekh` no US or Wikidata count, its evidence the filers above and 378 of Nepal's local candidates. - THE COST, accepted by Derek on 2026-10-27 ("accept the ruler cost"), shown the share of rulers below beside the earlier batches' and the bound-pair split: a ruler, cleric or leader written with the bare title in front reads it as the given name (`Sheikh Mohammed bin Rashid Al Maktoum` → given 'Sheikh', middle 'Mohammed bin Rashid', where 1.4.0 through 2.6.0 read title 'Sheikh', given 'Mohammed'), the class accepted for `king`, `sultan`, `shaikh` and `prince`. In jrc-names 174 persons have a spelling opening with a bare form of the word: by a reading of each, about 40 Gulf rulers and members of their houses, about 55 clerics and militia, tribal or political leaders, about 60 people whose own name opens with it (most of them Senegalese and Mauritanian `Cheikh`s, beside Bangladeshi and Malaysian bearers), and the rest unclear. In PSC 16 of the 244 moved records file the word in the title box (13 broken, 3 changed), while Qatari and Emirati ruling-house members there file it in the forename box. - MEASURED (2026-10-27, against master `79a4111f`, py3.11, every local check with `--base`). PSC (1,039,144 scored) 209 records fixed, 13 broken, every one the word filed in the title box (12 `Sheikh`, 1 `Sheik`), 22 changed, most now reading the given name and splitting the filer's middle box differently; ctgov-officials 10 moved, 8 fixes (4 `Aa Sheikh, CRED` whose credential master read as the given name, 4 opening with `Sheikh` or `Cheikh`) and 2 lower-case or credential-run names still wrong; fec-individuals 26, all fixes, every one `AA, SPELLING…`, 18 of which had read no family name; nepal-candidates 267, every one opening with `Shekh` (265) or `Sheikh` (2); jrc-names 571 spellings; tse-donors and ukec-individuals none. Spelling pairs (mechanisms.md#PAIRED-SPELLINGS-PER-CHANGE): jrc-names 37 persons now alike and 1 apart (spelled `Shaykh` beside `Sheikh` and `Cheikh`), and all 12 persons the `shaikh` entry above left apart are alike again; nepal-candidates 280 pairs now alike and 0 apart, the romanized `Shekh` now reading as its Devanagari does. The committed corpora move by two names in corpus_issues, #367's `Sheik Abu Bakar` (given 'Sheik', family 'Abu Bakar') and `Sheik abdul salam` (given 'Sheik', middle 'abdul', family 'salam'), beside the batch's own rule examples, where `Sir` now stands for `Sheik` (below); CLDR and GND agreement do not move. A v1 `Constants().titles` keeps all five with no edit (the v1 title list entry above). - A BOUND GIVEN NAME BEHIND IT. Read as a name word, the word licenses no bound-given join (rules/P5's licence is a given-name TITLE's), and the bound word is not the name's first piece, so the pair it would have made splits: `Sheikh abdul Rahman` → given 'Sheikh', middle 'abdul', family 'Rahman', where master read title 'Sheikh', given 'abdul Rahman' (and 1.4.0 given 'abdul', last 'Rahman'); `Shekh Abdul Rahim` and #367's `Sheik abdul salam` read so, as `Prince abdul Rahman` has since the 2026-10-27 `prince` and `princess` entry. The reading follows from the word being a name, as `Ahmed abdul Rahman` reads, and is the bound-pair face of this entry's THE COST bullet, accepted with it; a style in front keeps the join (`His Highness Sheikh abdul Rahman` → given 'abdul Rahman'). - WORKED EXAMPLES MOVED. `Sheik` was rules/P5's given-name title licensing the bound join (`Sheik abdul salam` and its family-first and suffix rows) and rules/H1's `Sheik Abu Bakar`; `Sir` replaces it, reading as `Sheik` read on master in every one of them under all three orders. The title-run row `Sir Sheikh abdul rahman` is `Dr. Sir abdul rahman`. Recompute: `uv run python tools/differential/local_agreement.py psc-individuals --base `, and `uv run python tools/differential/snapshot.py --local ctgov-officials --base ` (and `fec-individuals`, `tse-donors`, `ukec-individuals`, `nepal-candidates`, `jrc-names`). - **2026-10-27 #913, `marquis` and `marquise` (Derek) — THE FIRST OF #913's TITLE BATCHES: TWO NOBLE TITLES BORNE AS GIVEN NAMES.** Ranked first by `uv run python tools/vocab/word_report.py --rank` over #913's 22 words: the most US births of any word left and no UK PSC filer writing either in the title box, and Derek chose the two words on 2026-10-27. Both gain a dated `given` reading and are titles only inside a title phrase; no listed phrase holds either, so nothing is reported. `Marquis Johnson`, `Mr Marquis Smith`, `Ms Marquise Smith` and `Johnson, Marquise` read given 'Marquis' or 'Marquise', where 1.4.0 through 2.7.0 read the title. `of` keeps the title (`Marquis of Queensberry`, `The Marquis of Bute John Crichton-Stuart`), and so does a modifier (`Grand Marquis Smith`). The other sites read as for `king` and `prince`: before a family comma H6 no longer peels it (`Marquis Smith, John` → family 'Marquis Smith', 1.4.0's reading), ending the name with its period, alone after a full name and a comma, and past a second comma. Neither word was a worked example anywhere in the rules, tests or docs. - THE COUNTS, recorded on the words (`uv run python tools/name_usage.py Marquis` and `Marquise`, 2026-10-27): `marquis` given US SSA 25,041 births (24,567 male), Wikidata 81 (United States 48), nam_dict band 1 in the U.S.A.; family Census 8,737 (rank 4,065), Wikidata 171 (Canada 41, France 27). `marquise` given US SSA 10,995 births (10,603 male), Wikidata 30 (United States 13, France 7), nam_dict band 1 in the U.S.A.; family Census 106, Wikidata 1. - THE COST, accepted by Derek on 2026-10-27 ("accept the cost"), shown the two jrc-names nobles beside the 14 given-name spellings and that `of` keeps the title: a noble written with the bare title in front reads it as the given name (`Marquis de Lafayette` → given 'Marquis', family 'de Lafayette', where 1.4.0 through 2.7.0 read title 'Marquis'), the class accepted for `king`, `sultan`, `shaikh`, `prince` and `sheikh`; `de` is no partner, `of` being English only. Of the 17 jrc-names spellings opening with either word, by a reading of each, 2 are French nobles written with `de`, 14 spell 13 people whose given name it is, and the last is a nobility phrase with `of` that keeps the title. No PSC filer writes either word first in the title box. - MEASURED (2026-10-27, against master `cfe8bf92`, py3.11, `uv run python tools/differential/measure.py --base `, 14 steps). PSC (1,039,144 scored) 3 records fixed, 0 broken, 1 changed, now reading the given name and splitting a two-word surname box; ctgov-officials 3 moved, all fixes, each opening with `Marquis` before a credential comma; fec-individuals 28, all fixes, every one `AA, MARQUIS…` or `AA, MARQUISE…` in the listing form (19 with no other given word, 6 with an initial behind it, 2 with a trailing `MR.`, 1 with a hyphenated surname written with a space), which had read no given name or an initial as the given name; jrc-names 16 spellings moved, the 14 given-name spellings and 2 nobles above; nepal-candidates, tse-donors, tw-donors and ukec-individuals none; spelling pairs (mechanisms.md#PAIRED-SPELLINGS-PER-CHANGE) jrc-names, nepal-candidates and tse-donors 0 now alike and 0 apart. No committed corpus name held either word before the batch's two rule examples, so CLDR and GND agreement do not move, and could not have. A v1 `Constants().titles` keeps both with no edit (the v1 title list entry above), the sweep in `tests/v2/test_config_shim.py` reaching them through `TITLES_2_6_0 & TITLES_AMBIGUOUS`. - **2026-10-27 #913, `deacon`, `burgess` and `ranger` (Derek) — A #913 TITLE BATCH: THREE TITLES BORNE AS GIVEN NAMES AND SURNAMES.** Derek chose the five words `marquis`, `marquise`, `deacon`, `burgess` and `ranger` from #913's `word_report.py --rank` ranking, and had these three in a PR of their own, the first two being #923's. Each gains a dated `given` reading and is a title only inside a title phrase. `Deacon Jones`, `Mr Deacon Smith`, `Smith, Deacon`, `Burgess Meredith` and `Ranger Smith` read the given name, where 1.4.0 through 2.7.0 read the title. `of` keeps the title (`Deacon of the Church Jane Smith`, `Burgess of Edinburgh`). Before a family comma H6 no longer peels the word, so a surname that opens with it is whole (`Burgess Smith, John` → family 'Burgess Smith', 1.4.0's reading). `chief` attaches to the office kind, so `Chief Burgess` is a listed phrase and `Burgess Meredith` reports `title-or-name`, as `Dean Smith` does; `deacon` and `ranger` stand in no listed phrase and report nothing. None of the three was a worked example anywhere in the rules, tests or docs. - THE COUNTS, recorded on the words (`uv run python tools/name_usage.py Deacon Burgess Ranger`, 2026-10-27): `deacon` given US SSA 11,570 births (all male), Wikidata 37 (United States 16); family Census 3,322 (rank 9,756), Wikidata 287. `burgess` given US SSA 1,089, Wikidata 34 (United States 14); family Census 73,931 (rank 444), Wikidata 882. `ranger` given US SSA 1,518, Wikidata none listed; family Census 2,504 (rank 12,417), Wikidata 48. - THE COST, accepted by Derek on 2026-10-27 ("Accept the cost"), shown the one PSC title-box filing and that `of` keeps the title: a cleric written with the bare title, or behind `Rev.` or a salutation, reads it as the given name (`The Rev. Deacon John Smith` → given 'Deacon', middle 'John', where 1.4.0 through 2.7.0 read title 'The Rev. Deacon'), the religious class accepted for `buddha` and `gyani`, which rules/H9's Accepted bullet now states for a title that opens given names in its own tradition too. In PSC one record files `Deacon` in the title box, and it is the batch's one broken record; no other corpus at hand writes a deacon that way. A word that names a unit or a thing reads its first word as a given name too (`Ranger Regiment`), input that is no person's name. - MEASURED (2026-10-27, against master `cfe8bf92`, py3.11, `uv run python tools/differential/measure.py --base `, 14 steps). PSC (1,039,144 scored) 6 records fixed, 1 broken (above), 0 changed, the fixes `Deacon` filed in the forename box; ctgov-officials none; fec-individuals 25, all fixes: 20 in the listing form `AA, DEACON…`, `AA, BURGESS…` or `AA, RANGER…`, which had read no given name or an initial as the given name, and 5 with a two-word surname opening with the word before the comma (`BURGESS AA, AA`), which had read the word as the title; jrc-names 19 spellings moved, by a reading of each 12 spelling about 11 people whose given name it is, 6 that name no person (a fossil site, military units, vehicles, a street), and 1 unclear; 8 of the 19 now report `title-or-name`, the six `Burgess` spellings and two whose second word is itself title vocabulary; nepal-candidates, tse-donors, tw-donors and ukec-individuals none; spelling pairs (mechanisms.md#PAIRED-SPELLINGS-PER-CHANGE) jrc-names 1 person now alike and 0 apart. No committed corpus name held any of the three before the batch's rule examples, so CLDR and GND agreement do not move, and could not have. A v1 `Constants().titles` keeps all three with no edit (the v1 title list entry above). Recompute: `uv run python tools/differential/measure.py --base `. - **2026-10-27 #913, `queen` (Derek) — A #913 TITLE BATCH: A GIVEN-NAME TITLE THAT ADDRESSES BY THE GIVEN NAME.** Chosen as a batch of its own, `queen` gains a dated `given` reading and is a title only inside a title phrase; no listed phrase holds it, so nothing is reported. `Queen Latifah`, `Ms Queen Smith` and `Smith, Queen` read given 'Queen', where 1.4.0 through 2.7.0 read the title. Because the word addressed by the given name, the move also moves the word behind it: master read `Queen Latifah` as title 'Queen', given 'Latifah', and `SMITH, QUEEN` as title 'QUEEN', given 'SMITH' with no family name; both now read the family. A style keeps the title (`Her Majesty Queen Elizabeth` → given 'Elizabeth', decisions/H1's #489 entry), and so does `of` (`Queen of England`); inside the phrase it still addresses by the given name. The other sites read as for `king`: before a family comma H6 no longer peels it (`Queen Smith, Mary` → family 'Queen Smith', 1.4.0's reading), ending the name with its period, alone after a full name and a comma, and past a second comma. - WORKED EXAMPLES MOVED. `Elizabeth II, Queen` was rules/H1's example of a given-name title alone after a comma deciding the field of the word before it; `Mary II, Dame` replaces it, reading as the queen row read on master under all three orders, and migrate.rst's 2.5 entry takes `John III, Sir`. `tests/v2/test_properties.py`'s empty-head grid writes `Dame` where it wrote `Queen`, keeping its recorded 963 rows; `tests/v2/test_word_report.py`'s fixture title is `Sir`; the v1 `test_queen_exception_to_first_name_rule` becomes `test_queen_is_a_first_name_outside_a_title_phrase`, as `king`'s did. - THE COUNTS, recorded on the word (`uv run python tools/name_usage.py Queen`, 2026-10-27): given US SSA 16,123 births (16,092 female), Wikidata 25 (United States 8, Nigeria 5), nam_dict band 1 in the U.S.A.; family Census 18,557 (rank 1,945), Wikidata 66. - THE COST, accepted by Derek on 2026-10-27 as the `king` cost was, its three parts below each accepted, shown the share of royals in jrc-names below and that a style or `of` keeps the title: a monarch written with the bare title in front reads it as the given name (`Queen Elizabeth II` → given 'Queen', family 'Elizabeth', suffix 'II', where 1.4.0 through 2.7.0 read title 'Queen'), the class accepted for `king`; so does one alone after a comma (`Elizabeth II, Queen`, which read given 'Elizabeth' from 2.5.0), and a title written after it that is no partner of it reads as a name word (`Queen Mother Elizabeth` → given 'Queen', middle 'Mother', where master read title 'Queen Mother'). In jrc-names, of the 11 moved spellings, 9 are 5 queens regnant or consort and 2 one person whose stage name opens with the word, so news text writes the royal far more often than the given name; in PSC no filer types `Queen` into the title box, and every moved record files it as the forename. - MEASURED (2026-10-27, against master `04d7086b`, py3.11, `uv run python tools/differential/measure.py --base `, 14 steps). PSC (1,039,144 scored) 14 records fixed, 0 broken, 2 changed, each now reading the given name and splitting a multi-word middle box differently; ctgov-officials 2, both fixes (`Queen Aa, CRED`, which had read the surname as the given name); fec-individuals 14, all fixes, every one `AA, QUEEN…` in the listing form, 12 of which had read the surname as the given name and no family; jrc-names 11 spellings, the 9 royals and 2 given-name spellings above; nepal-candidates, tse-donors, tw-donors and ukec-individuals none; spelling pairs (mechanisms.md#PAIRED-SPELLINGS-PER-CHANGE) 0 now alike and 0 apart. The committed corpora move by `Queen Elizabeth` (given 'Queen', family 'Elizabeth') beside the batch's rule examples; CLDR and GND agreement do not move. A v1 `Constants().titles` keeps `queen` with no edit (the v1 title list entry above). Recompute: `uv run python tools/differential/measure.py --base `. - **2026-10-27 #913, royal title phrases (Derek) — `Queen Mother`, `King Consort`, `Queen Consort`, `Prince Consort`, `Princess Consort` AND `Princess Royal` ARE TITLE PHRASES, AND A MODIFIER IS IN ONE BEHIND A TITLE THAT ATTACHES TO IT.** The royal batches (`king`, `prince`, `princess`, `queen`) left these phrases reading as names: `Prince Consort Albert` read given 'Prince', middle 'Consort'. Now `king` and `prince` attach to `consort`, `queen` to `consort` and `mother`, and `princess` to `consort` and `royal`; `consort` is a new modifier, and it and `royal` address by the given name, so the word behind the phrase is the given name (`Queen Consort Camilla` → title 'Queen Consort', given 'Camilla'; `Anne, Princess Royal` → given 'Anne'). `mother` was already a title addressing by the given name, so `Queen Mother Elizabeth` needed only the `attaches` entry. - THE RULE CHANGE. A modifier written after its head lost its title (the Accepted clause, whose `Her Royal Highness Princess Royal Anne` → given 'Royal' was the example). rules/H9's modifier clause now finds the phrase also in a title before the modifier that attaches to it, `_vocab.title_readings` asking `f"{prev} {n}" in title_phrases` in its modifier pass; a modifier after a head that does not attach to it still loses its title (`Her Royal Highness Princess Grand Anne` → given 'Grand'), the Accepted example now. - `royal` GAINS A DATED `given` READING (US SSA 24,394 births, Census rank 2,319). Without it, `royal` addressing by the given name read a lone `ROYAL` after a family comma as rules/H1's given-name title, so `BALES, ROYAL` read given 'BALES' and no family, which the first measurement found in 31 FEC names. With it, `royal` is a modifier that may be a given name, read everywhere as in its run, as `wing` is: `BALES, ROYAL` and `Smith, Royal` read given 'Royal', and `Royal Smith` stays given 'Royal'. - THE REPORTS, accepted by Derek on 2026-10-27 as H9's contract: a listed phrase now holds `king`, `queen`, `prince`, `princess` and `royal`, so each, read as a name word where it is the first name word after any titles and no comma has marked the given name, is reported `title-or-name`, as `Dean Smith` is (`King Curtis`, `Prince Fielder`, `Mr Prince Smith`, `Royal Smith`), the surname behind a title included (`Dr. King`, `Dr King Jr`, where the report is noise). No field moves with them. The 2j case rows that asserted no report for these now assert one. - THE COST, accepted by Derek on 2026-10-27: `FISH, ROYAL SR` reads given 'FISH' and no family, `sr` being a title as well as a suffix, so `ROYAL SR` is a title run with a head and H1 reads the word before the comma as the given name; one FEC name. A phrase with nothing behind it still names its last word (`Princess Royal` → title 'Princess', given 'Royal'), H4's reading of an input that is all vocabulary. - MEASURED (2026-10-27, against master `598167f4`, which holds the queen batch, py3.11, `uv run python tools/differential/measure.py --base `, 16 steps). Fields: fec-individuals 49, 47 fixes, nearly every one `AA, ROYAL…`, where the queen head read title 'ROYAL' and no given name, 1 broken (above) and 1 input that names one person twice; jrc-names 6 spellings, `Princess Royal Anne`'s and a `consort marquise of X`; PSC and orcid-researchers agreement no record moved (ORCID 1,831,533 scored). Reports only: orcid-researchers 548 (`prince` 329, `princess` 103, `king` 70, `queen` 24, `royal` 15, a few behind `Dr.` or `Prof.`), PSC 310 records (`prince` 119, `princess` 58, `king` 49, `queen` 13, the rest `royal` and salutation forms), jrc-names 154 spellings, ctgov-officials 17, nepal-candidates 3, the committed corpora 13; tse-donors, tw-donors and ukec-individuals none; spelling pairs jrc-names 0 now alike and 1 apart; CLDR and GND agreement do not move. A v1 `Constants().titles` keeps all four royal titles, and `royal` and `consort` reach it as titles with no edit. Recompute: `uv run python tools/differential/measure.py --base `. - **2026-10-28 #913, `chancellor`, AND `judge` KEPT A TITLE (Derek) — THE LAST 2.8 TITLE BATCH: ONE TITLE BORNE AS A GIVEN NAME, AND THE PHRASES THAT KEEP JUDICIAL AND ACADEMIC TITLES WHOLE.** `judge` and `chancellor` were proposed together; Derek shipped `chancellor` alone and kept `judge` a title on 2026-10-28. `chancellor` gains a dated `given` reading and is a title only inside a title phrase: `Chancellor Smith`, `Mr Chancellor Smith` and `Smith, Chancellor` read given 'Chancellor', where 1.4.0 through 2.7.0 read the title. Before a family comma H6 no longer peels it (`Chancellor Case, Nancy` → family 'Chancellor Case', 1.4.0's reading). The phrases keep it: a modifier (`Vice Chancellor`, `Associate Chancellor`), `of` (`Chancellor of the Exchequer`), and `attaches` data added here for the phrases a modifier does not reach: `lord` attaches to `chancellor` and `justice` and `lady` to `justice` (`Lord Chancellor`, `Lord Justice`, `Lady Justice`), `deputy` to the office kind as `chief` does (`Deputy Chancellor`, `Deputy Judge`), `pro` to `chancellor` (`Pro Chancellor`), and `magistrate` to `judge` (`Magistrate Judge`, the US federal office, which with `judge` moved had lost its second word). `Lord Justice Leveson` and `Lady Justice Arden` now read the one title, where 1.4.0, which listed no `justice`, through 2.7.0, where it is contested (phase 2i), read title 'Lord', given 'Justice'. Since `Vice Chancellor` and `Lord Chancellor` are listed phrases, a bare `Chancellor` opening a name reports `title-or-name`, as `Dean Smith` does. Phrases that read the same on master and stay out of scope: `Circuit Judge`, `Probate Judge`, `His Honour Judge` and the hyphenated `Vice-Chancellor`. - `judge` KEPT A TITLE, its evidence recorded in the note on its title reading so a later batch meets it: given US SSA 3,826 births (all male), Wikidata 7, nam_dict band 1 in the U.S.A.; family Census 10,453 (rank 3,414), Wikidata 133; in PSC 0 filers put it in the forename box and 0 in the title box. Measured as a contested title against master `f207e4f1`, it broke 13 FEC records, every one a judge filing `FAMILY, JUDGE GIVEN` (9) or `FAMILY, JUDGE` alone (4), and 2 jrc-names spellings of news text before a surname, against 2 fixes in all the local corpora (one ORCID and one PSC filer whose given name it is). The `TEST_NAMES` judges (`Judge James M. Moody`) and every example that uses `judge` as an ordinary surname in TITLES (the H section's Background, rules/H5, rules/H6 and rules/H7) keep it. - THE COUNTS for `chancellor`, recorded on the word (`uv run python tools/name_usage.py chancellor`, 2026-10-28): given US SSA 2,648 births (2,643 male), Wikidata 13 (United States 3); family Census 4,147 (rank 7,974), Wikidata 77 (United Kingdom 11, United States 8). - THE COST, accepted by Derek on 2026-10-28: a chancellor written with the bare title reads it as the given name (`Chancellor Merkel` → given 'Chancellor', family 'Merkel', where 1.4.0 through 2.7.0 read title 'Chancellor'), so does one alone after a full name and a comma (`John Smith, Chancellor` reverses to given 'Chancellor', family 'John Smith', the role-word class `John Smith, Dean` accepted), and a bare first-word `Chancellor` reports `title-or-name`. - WORKED EXAMPLES MOVED. The v1 tests that add a custom title and parse it (`tests/test_constants.py`, `tests/test_python_api.py`) add `bursar`, in no default set, where they added `chancellor`, which v1's `Constants().titles` still holds; rules/H9 gains the `Lord Chancellor` and `Chancellor Smith` examples. - MEASURED (2026-10-28, against master `f207e4f1`, py3.11, `uv run python tools/differential/measure.py --base `, 16 steps). fec-individuals 10, all fixes: 4 `AA, CHANCELLOR` alone, which had read no given name, 1 `AA, CHANCELLOR A.`, 2 `AA, CHANCELLOR MR.`, and 3 with a two-word surname opening with the word before the comma, which had read it as the title; jrc-names 5 spellings, 3 fixed (`Lord Justice` and `Lady Justice` before a surname) and 2 broken, spellings of one head of government written with the bare title, now reporting `title-or-name`; ctgov-officials 1 fix (a `Deputy` and an office title after a comma, now one title through `deputy`'s attaches); orcid-researchers, psc-individuals, nepal-candidates, tse-donors, tw-donors and ukec-individuals none; ORCID (1,831,533 scored) and PSC (1,039,145 scored) agreement do not move; spelling pairs jrc-names 0 now alike and 0 apart; CLDR and GND agreement do not move. The committed corpora move by `Chancellor Jane Smith` (given 'Chancellor') beside the batch's rule examples. A v1 `Constants().titles` keeps `chancellor` and `judge` with no edit, and `c.titles.remove('chancellor')` turns every title reading of it off (the v1 title list entry above). Recompute: `uv run python tools/differential/measure.py --base `. derek73-python-nameparser-456b024/docs/design/decisions/M1-delimited-maiden-names.md000066400000000000000000000225131527303271700301640ustar00rootroot00000000000000# M1 — delimited maiden names ## Standing - No delimiter pair reads as maiden by default; a caller declares one in `maiden_delimiters`, because enclosure conventions vary — 2026-07-03 PR #288. - A marker-led clause holding a word past its marker reads as maiden without the pair declared (M3), so what declaring a pair buys is a markerless clause and a clause of nothing but its marker: `Cherice J. (Johnson) Williams` reads nickname 'Johnson' by default and maiden 'Johnson' under a declared pair, and this rule still governs every clause in a declared pair — 2026-08-26 #335 (M3). - A pair declared for both maiden and nickname names reads maiden, the overlap canonicalized out of the effective nickname set before parsing and through `apply_patch` too, silently; the v1 facade keeps v1's nickname-wins reading by subtracting on its side — 2026-07-18. - The marker word inside a declared clause is dropped where the clause holds a word past the marker run (`Jane Smith (née Jones)` → maiden 'Jones'), and a clause of nothing but its marker keeps its words, Nee being an attested surname (`Jane Smith (Nee)` → maiden 'Nee') — 2026-08-04 #329; 2026-08-26 #434. - Clauses are independent, each dropping or keeping its own marker (`Jane Smith (Nee) (Jones)` → maiden 'Nee Jones' under a declared pair), and the contentless `(née —)` alone parses to every field empty and `bool()` False — 2026-08-04 #329. - Neighbour-scoping the drop is declined: it would read `Jane Smith nee Nee Jones` as maiden 'Jones' where it reads 'Nee Jones' — 2026-08-04 #329. - A clause of nothing but a phrase marker keeps its words too, so `Maria Kowalska (z domu)` under a declared pair reads maiden 'z domu', a name nobody bears, emitted deliberately because the parser cannot know the pair did not enclose an unfamiliar surname — 2026-08-26 #434. Not standing: 2026-08-05 #329, the deferral of marker detection inside a nickname clause to #335, carried out when M3 landed (#335); its premise is corrected by 2026-08-26 #335; the differential gates both entries measure were retired in #664. Elsewhere: a marker followed by a comma is no marker in this rule's drop, nor a phrase marker the writer divided with a comma (`Maria Kowalska (z, domu Nowak)` and `Jane (née ,Jones) Smith` under a declared pair keep maiden 'z, domu Nowak' and 'née ,Jones') — decisions/N1's 2026-10-12 #653 entry; the delimiters trumping every reading inside them (`Jane Doe (nee Smith MA)` → maiden 'Smith MA', reporting nothing) — decisions/M2's 2026-09-19 (Derek), #533 review — THE DELIMITERS TRUMP entry; the phrase marker itself — decisions/M2's 2026-08-26 #434 entry. Open: none in this file. ## Log - 2026-07-03 (maiden-bucket design, landed via the v2 core, PR #288) (where its opt-in boundary runs superseded in part by 2026-08-26 #335 (M3) below) — the maiden reading of a delimiter pair is opt-in because enclosure conventions genuinely vary; there is no default pair. - 2026-07-18 (cc7063f; Derek's proposal out of a docs-review pain point — routing parens to maiden used to require editing both buckets in tandem, and the nickname default's contents were not discoverable) — bucket overlap is canonicalized before parsing: a pair listed for maiden is dropped from the effective nickname set (maiden wins), and the public DEFAULT_NICKNAME_DELIMITERS constant landed with it. The v1 facade restores v1's nickname-wins reading by pre-subtracting on its side. Weighed and ruled the same day: maiden-wins applies THROUGH apply_patch too — a pack's maiden pair silently removes a user's unrelated explicit nickname pair; warn-on-removal was considered (a silent-failure review flagged the site) and rejected, the silence ruled intended. - 2026-08-04 #329 (PR #331) (its "multi-word clause" condition and "clause-size guard" superseded in part by 2026-08-26 #434 below) — the marker word inside a delimited clause is dropped from a multi-word clause during grouping; the extraction itself keeps the whole enclosed span, so nothing is lost when there is no marker. Three decisions argued separately: the clause-size guard exists because Nee is an attested surname (Irish Ní/Nee, a Chinese romanization) — load-bearing, not defensive, and mutation-proven; clauses are independent — "(Nee) (Jones)" reads maiden "Nee Jones", each clause dropping or keeping its own marker (the first implementation leaked across clauses, a real defect); and the contentless "(née —)" alone now parses to every field empty and bool() False (an explicit alternative keeping maiden "née —" was rejected; pinned by the maiden_marker_delimited_content_free case). - M1 and M2 are disjoint by construction, which is the no-conflict guarantee: M2's walk covers joining structure that role-bearing words never enter, while M1's drop reaches only extracted content. Verified independently at review over 7,775 records: 967 diffs, every one in the maiden field. Declined: - Neighbour-scoping the drop (drop a marker whose next word also reads maiden) — implemented, reviewed and rejected: it also fires on the bare-marker path, eating the surname out of "Jane Smith nee Nee Jones" (maiden "Jones" instead of "Nee Jones"), and it leaks across adjacent clauses. It is the obvious implementation, which is why this entry exists. - 2026-08-05 #329/#335 — marker auto-detection inside a nickname-delimited clause was deferred to #335 on a corpus measurement: 山田 花子(旧姓 佐藤) is in the CJK differential corpus so the #329 change was gate-visible, while "Jane Smith (née Jones)" is in no corpus — shipping auto-detection in 2.1 would have let a real Latin-affecting change ride under a "0 Latin-only" gate report. - 2026-08-26 #335 — correction to the 2026-08-05 entry above, which reasoned from "the #329 change was gate-visible". It was not. Under the default policy the corpora run, no name reached the drop pass at all, so 山田 花子(旧姓 佐藤) moving between 2.0.0 and 2.1.0 was the East Asian order flip and not #329. Nor is #329 gate-visible now that M3 routes marker-led clauses to maiden without configuration: a ledger rule narrows by which FIELDS move and never by what they hold, so reverting the drop leaves the same six names moving the same fields and all three gates green (measured by mutation 2026-08-26). The {nickname, maiden} pair is the 2.1.0 ledger's; the other two baselines classify the CJK name differently, which is why those ledgers are split and must stay so. The deferral #329 argued for was still the right call — it was about a Latin-affecting change riding under a gate report, and that half held — but the premise is corrected here rather than left to be re-used. tools/differential/README.md carries the same correction beside the table it belongs to. - 2026-08-26 #335 (M3) — the opt-in this section opens with narrowed, and the boundary is not where the 2026-07-03 entry drew it. A clause whose content announces itself no longer needs its pair declared, so what configuring maiden_delimiters buys is now markerless content and one-word clauses. See #M3 for the whole reasoning; M1 still governs every clause whose pair is configured, and M3 defers to it for the marker drop itself. - 2026-08-26 #434 — correction to the 2026-08-04 #329 entry above, on the two statements a phrase entry falsified, and a decision that entry's reasoning does not reach. Its "dropped from a multi-word clause" is no longer the condition: "(z domu)" under a configured pair is a two-word clause whose marker is NOT dropped, because both words ARE the marker. The condition has always been "the clause holds a word past its marker" and reads that way in the code; with one-word markers the two were indistinguishable, which is why the entry could say the wrong one for two years without cost. Nor is there a "clause-size guard" any more, load-bearing or otherwise — the containment test now spans the whole marker run, so its subject is the token AFTER the run rather than the second token of the clause. What survives unchanged is the REASON that guard was written: Nee is an attested surname (Irish Ní/Nee, a Chinese romanization), so a clause of nothing but its marker keeps its words, and that argument is mutation-proven still. Recorded here rather than only under #M2, because a future PR reading M1's own history pointer would otherwise find "the clause-size guard" described as load-bearing and restore something that reintroduces the phrase defect while passing every doc test. The decision the rewrite forces, stated so a later PR has something to weigh against. Under a configured maiden pair, "Maria Kowalska (z domu)" emits maiden "z domu" — the marker itself as the whole maiden name. That is not what the phrase argument under #M2 says about those words: "z domu" is borne as no name in any position, which is exactly why shipping it as a phrase is safe. So the value is a name nobody has. It is emitted anyway, and deliberately: M1's carve-out is that a clause holding nothing past its marker keeps its WORDS, and the parser has no way to know a caller's configured pair did not enclose an unfamiliar surname — the same ignorance that makes "(Nee)" keep its word. The alternative readings, an empty maiden or a nickname, both require the parser to judge its own vocabulary's words unnameable, which nothing else in the library does. Cheap to revisit: the shape is one rules.md example and one case row away from a decision either way, and no corpus name has it. derek73-python-nameparser-456b024/docs/design/decisions/M2-maiden-marker-rule.md000066400000000000000000002461631527303271700273630ustar00rootroot00000000000000# M2 — the maiden-marker rule ## Standing - A marker that counts takes the words after it as the maiden name and is itself dropped, as a delimiter character is: `Maria López née García Pérez` → maiden 'García Pérez' — 2026-07-03 #274. - A marker counts only in the name before any comma or in the surname part before a family comma, behind a name word past the leading title run, and not straight behind an unambiguous suffix word, unless it is the first word of its part, a bare suffix word never opening the string (S2; `Jr née Jones` → maiden 'Jones', while `Dr. Jr née Jones` reads no maiden name), or a connective; anywhere else it is an ordinary word: `Doe nee Smith, Jane` → maiden 'Smith', while `Doe, Jane nee Smith` reads middle 'nee Smith', `Smith, John, PhD née Jones` suffix 'PhD née Jones', `Jane and née Jones` no maiden name, and `Dr. née Jones Smith V` no maiden name, its lone title being no name word — 2026-10-04 (Derek), #601. - A particle before a marker counts as the name word it stands behind, Do, Le and Van being common Vietnamese surnames (`Anh Do née Tran` → maiden 'Tran', family 'Do' under the default order; `Jane van nee Smith` → maiden 'Smith'), and the title run is measured over the words before the marker, so `Lord Chancellor née Jones` reads maiden 'Jones' — 2026-10-04 (Derek), #601. - The clause takes its words up to the trailing run that the trailing rules read at the end of the name with the clause removed, and the words it gives up take the roles that reading gives them: `Jane Doe née Jones Smith V` → maiden 'Jones Smith', suffix 'V', while `John née Jones Smith V` keeps maiden 'Jones Smith V', the clause-free `John V` reading V as the family (S10), and `John nee Smith ba` keeps maiden 'Smith ba' as `John ba` reads 'ba' as a name — 2026-10-04 (Derek), #601. - The first word after the marker is always taken, the marker having announced a name, a lone roman numeral included (`Jane Smith née V` → maiden 'V'), unless it is an unambiguous suffix word, where the marker declines and is a word (`Jane Smith nee PhD` → family 'nee', suffix 'PhD' under the default order) — 2026-10-04 (Derek), #601. - Before a family comma no trailing rule reads the part, so the clause ends at the first suffix word, the words from it on read as that part reads them, and it keeps a title, numeral or class member it reaches, reporting none: `Doe nee Smith Jr. Prof., Jane` → family 'Doe Prof.', suffix 'Jr.', maiden 'Smith'; `Doe nee Smith Prof., Jane` → maiden 'Smith Prof.'; `Doe nee Smith V, Jane` → maiden 'Smith V'; `Doe nee Smith Ma, Jane` → maiden 'Smith Ma', no report — 2026-10-04 (Derek), #601; 2026-09-26 #535. - A shape-only roman numeral ends the clause where the clause-free name reads it as the suffix, a title or the merged `Ph. D.` behind it included: `John née Jones Smith VI` → maiden 'Jones Smith', suffix 'VI'; `Jane Doe nee Smith VI Prof.` → suffix 'VI', title 'Prof.' — 2026-10-04 #610. - The take reads its clause-free view over `_pieces.trailing_candidates`, written beside the peel, a superset of what `tail_reading` takes with no exception and no case fact — 2026-10-04 #610; 2026-10-18 (Derek), #731 reverted. - Where a trailing rule reads the part, a trailing title ends the clause, and a credential or numeral in front of it gets the stop it gets with the title absent: `Jane Doe nee Smith Prof.` → title 'Prof.', maiden 'Smith'; `Jane Doe nee Smith MA Prof.` and `… Prof. MA` both → maiden 'Smith', suffix 'MA'; `Jane Doe nee Smith V Prof.` → suffix 'V' — 2026-09-26 #535. - The title chain never takes the first word after the marker: `Jane Doe nee Pope.` keeps maiden 'Pope.', `Jane Doe nee Prof. Dr.` reads maiden 'Prof.', title 'Dr.', and a first word in the title vocabulary counts as a word, so `Jane Doe nee Pope. ba` gives up 'ba' as `Jane Doe nee Smith ba` does — 2026-09-26 #535. - Accepted, the transparency boundary: where the clause keeps the credential the two spellings differ, a title inside the kept text unable to leave without the words behind it (`Doe nee Smith ba Prof.` → title 'Prof.', maiden 'Smith ba'; `Doe nee Smith Prof. ba` → maiden 'Smith Prof. ba'), and H5's `Pope.` cost reaches a birth name (`Jane Doe nee Smith Pope.` → title 'Pope.', maiden 'Smith', the example `King.` until `king` left the titles on 2026-10-26) — 2026-09-26 #535. - Where a trailing rule reads the part, a member of the ambiguous class ending the maiden name is kept by the clause and reported, nobody writing a credential straight after the marker as its floor: `Jane Doe nee MA` → maiden 'MA', `Jane Doe nee Smith Ma` → maiden 'Smith Ma', and `Jane Doe nee Smith MA` → suffix 'MA', each reporting `suffix-or-name` but the last, whose capitals settle it in silence since 2026-10-25 #785 (decisions/S6) — 2026-09-19 (Derek), #533. - Accepted with it: `JANE DOE NEE YO-YO MA` → maiden 'YO-YO', suffix 'MA', the one-case reading's cost; `Jane Doe nee Smith X.Y.Z.` → suffix 'X.Y.Z.', as `John Doe X.Y.Z.` reads; `Jane Smith nee Jones and MA` → maiden 'Jones and', suffix 'MA', the take running before the joins — 2026-09-19 (Derek), #533. - A word the clause gives up reads as a post-nominal or title or the clause keeps it, so where a trailing rule reads the part every word written after a taken marker is roled maiden, title or suffix (before a family comma it is not, as above); held since #601 by binding the clause-free reading as roles — 2026-09-19 (Derek), #533 review, A WORD; 2026-10-04 (Derek), #601. - Recorded rather than fixed: a bare delimiter token can end a clause, `Jane Doe nee Smith - MA` reading maiden 'Smith -', suffix 'MA' both by default and with `extra_suffix_delimiters=frozenset({"-"})`; #820 asks whether a lone dash should be a word at all — 2026-09-19 (Derek), #533 review, A WORD. - Delimiters outrank every reading inside them: `Jane Doe (nee Smith MA)` and `Jane Doe (nee Smith Ma)` keep the whole span as the maiden name and report nothing, while `Jane Doe (nee Smith) MA` reads suffix 'MA', reported until its capitals settled it in 2026-10-25 #785 (`Jane Doe (nee Smith) ma` still reports) — 2026-09-19 (Derek), #533 review, THE DELIMITERS TRUMP. - Accepted, the exception: bracket content ending in a period is suffix-shaped (S1), so the brackets are dropped and the clause is read bare: `Jane Doe (nee Smith Prof.)` → title 'Prof.', maiden 'Smith' — 2026-09-26 #535. - A link joining two words of the clause does not end it, P3's class test asked with the clause's bounds: `Jane Doe nee Puig i Soler` → maiden 'Puig i Soler', `Maria Carod i Rovira nee Puig i Soler` → maiden 'Puig i Soler' with 'Carod i Rovira' kept whole, while `Jane Doe nee i Soler` declines (no maiden name), and `Jane Doe nee Puig i III`, `… Puig i MA` and `… Puig i` read suffix 'i III', 'i MA' and 'i' — 2026-09-20 (Derek), #397 review. - Recorded with it: the one-case spellings agree (`jane doe nee puig i soler` → maiden 'puig i soler'), `Jane Doe nee Puig i Ma` keeps maiden 'Puig i Ma' and reports, and `Jane Doe nee MA i Soler` reads maiden 'MA i Soler' in silence, as its `y` twin does — 2026-09-20 (Derek), #397 review. - The marker pass runs before every join in the grouping stage, so no join counts or merges words the clause takes: `Juan y Garcia née Jones` → maiden 'Jones', 'y' a name word of its own (family 'Garcia' under the default order); accepted, a suffix word straight after the marker declines it even with a connective beside it (`Jane née Jr y Jones` → family 'Jr y Jones' under the default order) — 2026-08-21 #412. - A marker the consumer declines is a word and rides inside a particle chain that reaches it (`Jane van der Berg née` → 'van der Berg née' one name part, the family under the default order), and a taken marker bounds a particle join arriving from its left (`Ursula von der Leyen geb. Albrecht` → maiden 'Albrecht', 'von der Leyen' one name part), a part of nothing but particles reading by R2 (`Jane de la née Jones` → family 'de la' under the default order) — 2026-08-19 #399; 2026-08-21 #412. - A marker may be a phrase, matched whole and only where its words stand together in one role and one comma part, the Polish "z domu" the first shipped: `Maria Kowalska z domu Nowak` → maiden 'Nowak', `Anna z Nowak` and `Anna z (domu) Nowak` → no maiden name, 'z' a name word (family 'Nowak' under the default order; the second nickname 'domu') — 2026-08-26 #434. - One predicate, `_pipeline._vocab.maiden_marker_run`, answers where a marker starts and ends for all four sites (classify's tagging, extract's clause test, group's piece test and clause drop), pinned by `test_every_marker_site_ends_the_run_in_the_same_place`; a guard re-deriving the run in `_marker_run_pieces` is declined as unfireable — 2026-08-26 #434. - Recorded rather than closed: two adjacent clauses of one role are not told apart, so `Jane (z) (domu) Jones` tags a run nothing reads (nickname 'z domu', no maiden name); a per-token unit id is the fix to reach for — 2026-08-26 #434. - Inside the clause S2's company reads as it does anywhere (`Jane Doe nee Smith PhD MEng` → maiden 'Smith', suffix 'PhD MEng'), and a marker behind a credential is an ordinary word (`Jones Jr. née Smith` → middle 'Jr. née' under the default order), which the credential's run takes in where the run starts (`Jane Doe Jr. nee Smith` → suffix 'Jr. nee Smith') — 2026-09-27 (Derek), #544; 2026-10-04 (Derek), #601. - Degenerate input gets no designed reading (garbage in, garbage out): `Berg, Jane van der nee Smith DO` → middle 'van der nee Smith', family 'Berg', suffix 'DO' is recorded so its move is not read as a regression — 2026-10-04 (Derek), #601. Not standing: the deviation recorded rather than repaired by 2026-09-22 #397 follow-up, settled by 2026-10-03 #549 (the #538 entry it superseded is marked so in its head); the #548 deferral of 2026-09-26 #535, closed into #601 and #602; the trailing-title follow-up drafted for Derek by 2026-09-19 (Derek), #533; #535 took it; the skeleton migration of 2026-10-19 #761, carried out. Elsewhere: a maiden name not making H1 decline (`Dr. Jane née Smith` → family 'Jane') — decisions/H1's 2026-08-25 (#410) entry; a one-letter numeral needing words to spare in the clause-free name, which keeps the V of `John née Jones Smith V` in the maiden name — decisions/S10's 2026-10-17 (Derek), #718 entry; a marked clause in brackets, read whole as the maiden name under the default policy — decisions/M3; a comma after a marker inside a clause unmarking it, and the divergence between M3's whitespace test and tokenize living on through the nakaguro (`佐藤 花子 "旧姓・田中" (旧姓 鈴木)` → nickname '旧姓 田中', maiden '鈴木') — decisions/N1's 2026-10-12 #653 entry and decisions/M3; the lone name word left beside a maiden name being the family (`Jane née Jones Smith` → family 'Jane', maiden 'Jones Smith') — decisions/M4; a declared delimiter parting a trailing suffix part — decisions/C10's 2026-10-03 (Derek), #549 entry; the credential's trailing run taking a suffix word and everything behind it (`Jane van der Berg née Jr Jones` → 'van der Berg née' one name part, the family under the default order, suffix 'Jr Jones') — decisions/S8 (#602); a maiden clause's numeral not held to its capitals — decisions/T4's 2026-10-18 (Derek), #731 reverted entry; `roz` and Scandinavian `f.` kept out of the markers, now on the words (`uv run python tools/vocab/query.py --excluded birth_marker`) — decisions/vocabulary-exclusions. Open: [#317](https://github.com/derek73/python-nameparser/issues/317) — the fullwidth-colon marker (`山田 花子 旧姓:佐藤` arrives with `旧姓:佐藤` as one word; the head-peel question). Open: [#820](https://github.com/derek73/python-nameparser/issues/820) — whether a lone dash is a separator, which decides `Jane Doe nee Smith - MA` (maiden 'Smith -'). ## Log - 2026-07-03 (maiden-bucket design; #274 filed 2026-07-07, landed in the v2 core, PR #288) (its reach into the given part after a family comma and into a part after a suffix comma, and its stop at the first suffix word outside the surname part before a family comma, superseded by #601, the 2026-10-04 entry) — the marker takes everything after it up to a trailing suffix, greedily: "née Jones Smith" is a two-word maiden name, matching how the marker is actually used in running text. The marker itself is dropped as structural, like a delimiter character. - 2026-08-19 #399 (PR #409) (its stop superseded by the 2026-08-21 #412 / #417 / #418 reorder; its `St St née` report moved by #624, decisions/P2, the row now `St van née`) — a maiden marker bounds a particle join reaching it from the left, where the marker is actually taken as a marker. rules.md carried the opposite as an Accepted limitation ("a particle chain swallows a marker in its path, the join (P2) running first"), but that was a consequence of stage order rather than a judgment: the chain merges pieces before the marker is consumed, and the consumer looks for a lone marker piece which by then no longer exists. So `Ursula von der Leyen geb. Albrecht` lost the maiden name that `Ursula Leyen geb. Albrecht` kept. The gate on the stop is the whole substance of the rule and was added in review, not in the first cut. Sharing one marker-piece predicate between the stop and the consumer settles what a marker piece IS; it does NOT settle whether the consumer takes, because the consumer also needs a non-suffix piece after the marker. Stopping without that left the marker as a piece of its own, and a lone trailing piece takes a role field: `Jane van der Berg née` read middle 'van der Berg', family 'née' — the marker became the surname and the real surname was demoted, which is the stranding the stop exists to prevent, one field over. Three consequences of the ungated form, each measured and each fixed by the gate: `Jane van der Nee` moved a Dutch bearer of the attested surname Nee out of the family (M1 already names Nee as a surname); `Ursula von der Leyen geb.`, an ordinary truncated record, reported the marker as the surname; and `St St née` stopped reporting its particle-or-given fork, because group's emitter is guarded on the chain having merged something and the stop made it merge nothing for a different reason than the guard assumes — an A1 violation of the shape #405 tracks, and worse than #405's, since it REMOVES a report callers already see. The gate tests the consumer's own condition rather than restating it, which is what makes the two halves agree. M2 states the gate ("a marker left as a word bounds nothing"); the trailing-marker rows pin it. Weighed against moving the maiden handler ahead of the chain, and the recorded reason for preferring the stop has been corrected twice. It is NOT that the reorder disturbs names whose maiden side carries its own particle chain: `Anna Müller geb. von der Berg` is identical before #399, after it, and under a reconstructed reorder, and mechanically cannot differ — no suffix and no conjunction on the maiden side means merged and unmerged pieces hand the consumer the same words. The reason is scope: the reorder changes what the consumer receives for every name rather than adding one condition, and two review sweeps found no test and no corpus name that separates the two options, so it would ship unmeasurable. A review sweep also found the reorder would reach maiden names the stop does not (the P3/P5 join-swallow below), and would drop P3's rootname count when the maiden words leave early. That second effect was recorded here as a COST and #411 has since shown it is the opposite: P3's count has the same defect P5's reserve had, counting words a later pass removes, so `juan y garcia nee jones` loses its family name entirely. Correcting the ledger rather than leaving it: the reorder's real price is scope — it changes what the consumer receives for every name — not that consequence. It stays available, and #418 tracks the P3 count on its own. Two things fell out rather than being decided separately: the same issue's open question about the leftover given slot (the marker used to compete for it, giving `de la Cruz née Vega` given "née" under a family-first order; consumed and dropped, it never reaches the placement — though a marker the consumer declines still does, which is M2's "just a word" reading and not a leak), and the wholly-particle family a stop can leave, which #404's R2 reading already covers — "Jane de la née Jones" reports family "de la" with a non-empty base. A 305,364-parse sweep at review found no empty-base or partition violation either side of the change. Two limits stay, now recorded in M2 rather than left implicit: the bound reaches only a marker standing as a word of its own, so P3's connective join and P5's bound-given join each still absorb a marker before the bound can see it — the deleted Accepted clause covered a class of which this fixed one instance and left two. (P5's was closed by #411 on 2026-08-21, and P3's the same day by the #412 / #417 / #418 entry below, which takes the reorder this entry set aside.) Tests carry the whole verification: of the marker-bearing differential corpus names none has a particle ahead of the marker, so the gate is blind to this class and its clean exit says nothing about the change. A synthetic cross-script row was written to reach the CJK corpus builder — the only route a case row has into any corpus — and then removed: it used that corpus's provenance as a back door, and it pinned nothing the Latin rows did not. The honest route is [#413](https://github.com/derek73/python-nameparser/issues/413), where the harvester's pattern turns out to miss every name written in backticks, which is how this tracker writes them. `Ursula von der Leyen geb. Albrecht` sat in #399's own title the entire time. - 2026-08-21 #412 / #417 / #418 (PR #420) (its tail-core repair superseded by #549, decisions/C10; its readings of `Jane Smith née Jones Jr y Smith`, `Jane van der Berg née Jr Jones` and `Abd van der Berg née Jr Jones` moved by #602's credential run, decisions/S8, which reads 'Jr y Smith' and 'Jr Jones' as suffixes) — the marker pass runs before every join in the grouping stage. The three issues were one shape: a join rule computed something — P3's word count (#418), the chain's stop condition (#417), the connective join's range (#412) — against a piece list the marker pass would rewrite a few lines later. #411 had fixed the same shape in P5's reserve by hand, excluding the departing words and declining a join onto the marker, and #418 argued the general fix was a guard at the merge primitive: no join may span a marker piece. Stated unconditionally that guard is the ungated stop the 2026-08-19 entry above measured and rejected (it strands `Jane van der Berg née`), so it would have to be gated on "a marker the consumer will take", evaluated live at every merge — #417's shared predicate, plus a per-merge scan the `"and " * n` benchmark shape would have to tolerate, plus the count exclusions kept in step. The reorder set aside above gets the property by construction: the consumer runs first, what it takes is gone before any count or join looks, and the stop, the exclusion and the shared-predicate question all disappear, a net deletion. P5's decline-onto-a-marker test stays, because it is about what a name word is, not about counting — measured: removing it re-read `Berg, abdul née PhD` as given 'abdul née'. It tests the LONE marker piece, which is the only marker the consumer can have declined on its own; a declined marker the particle chain has already taken travels with the chain, and P5 joins the chain — `Abd van der Berg née Jr Jones` reads given 'Abd van der Berg née' where the old stop had kept the marker out of it. Review found this; rules/P5 now says "standing as a word of its own" for that reason. The recorded ground for setting the reorder aside was that nothing separated it from the stop. That stopped being true when #413's harvest landed, two hours after #418 was filed: eleven corpus names flip P3's carve-out under an appended maiden clause (eight losing the family — #418 counted seven and six, before the harvest), and seven show the connective join swallowing the marker — `johnny y`, `Juan de y`, `e and e` with the marker behind the connective, and `Claus SchroederVP of`, `Jon Dough and`, `Jon Dough and of`, `jones, john e` with the marker ahead of it — where #412 recorded no corpus name behind it at all (review counted the seven; the first draft of this entry had three). WHAT MOVES, measured over every corpus name, every corpus name with a maiden clause appended (comma names included) and every case row, 4,180 inputs, against the pre-change tree (PR #420): 55 differ, each an input this change adds, a shape of the three issues, or the clause ceasing to change how the rest of the name reads, and no ambiguity report moves. The suffix-comma tails, the ordering cost the #411 entry left unmeasured, were byte-identical under the default policy, which review showed was the wrong population: with `extra_suffix_delimiters` configured a delimiter core after a marker in a tail became a word the pass could take, and the repair made before merge, screening a tail's cores out of the pass, was reversed by #549 (the 2026-10-03 entry below). At the gate the #412 pair moved at 2.0.0 and 2.1.0 (fix(#274) at 1.4.0), and P3's new example `Juan y Garcia née Jones` at all three, the one #418 shape the gate can see. The one reading the order costs: the consumer sees unjoined pieces, so "up to any trailing suffix" now means the first suffix WORD after the marker, and a connective beside it cannot un-suffix it first. `Jane née Jr y Jones` read maiden 'Jr y Jones' and now declines (family 'Jr y Jones'). The shape is a suffix homograph ANYWHERE in the maiden name with a connective beside it, not only in first position — review measured `Jane Smith née Jones Jr y Smith` going from maiden 'Jones Jr y Smith' to maiden 'Jones', family 'Jr y Smith', middle 'Smith': the maiden walk stops at the homograph, and the connective then builds a family name out of what is left, demoting the real one. The first draft of this entry priced it as the first-word shape, from a sweep that appended only ` née Jr Jones`. Accepted rather than built around, and the one place a merge-time guard would have read differently. #417's question — whether `van der Berg née` is a family name — is answered by consistency rather than decided: a marker the consumer declines is M2's "just a word" and rides inside whatever join reaches it, which is the reading `Jane van der Berg née` already had. The fields of `Jane van der Berg née Jr Jones` do not change at all (middle 'van der Berg née Jr', family 'Jones'); only the pieces do, which is why its pin is at the piece level. The invariant test that hid the shape now lists it. Review added two pins the first cut had left to prose. A corpus-wide assertion that appending a maiden clause moves no other field (tests/v2/test_parser.py), with #410's lone-residual shape as its one boundary until #410 closed it on 2026-08-25 — `Dr. Jane née Smith` reads family 'Jane' now, as `Dr. Jane` does, so the assertion covers every corpus name that parses to anything, and the only skip left is M2's own (nothing at all before the marker). And a field-level row for the chain-carried marker. One guard is deliberately left without a pin: `_is_maiden_marker_piece`'s lone-piece half has no default-vocabulary input that reaches it now that the pass runs first (dropping it leaves the suite and a 337k-name review sweep identical), and it stays as M2's own definition — "standing as a word of its own" — rather than as a guard a test holds. - 2026-08-22 #424 — SUPERSEDED by 2026-10-04 (Derek), #601 (its double peel replaced by the clause-free reading; `John née Jones Smith V` and `Jane Smith née V` keep the V in the maiden name, with #718's count, decisions/S10; `Dr. née Jones Smith V` takes no clause, the marker standing behind a lone title; its acronym deferral superseded earlier by #533, the 2026-09-19 entry) — the walk stops before the trailing numeral assign reads as the suffix. "Up to any trailing suffix" was asked with the suffix-piece test, whose initial veto does not see a bare `V`: `John née Jones Smith V` read maiden 'Jones Smith V' where `John Smith V` reads suffix 'V'. The walk now also stops at `_trailing_start` — the S2 peel read from the marker on, over the pieces as they stand, stepping over a tail segment's delimiter cores as the walk does — but at the NUMERAL only. The bare-acronym fork counts pieces, and the walk removes the very pieces it counted: `John née Jones Smith Ma` peeled over the pieces as they stand reads the acronym as a credential with words to spare, and once 'Jones Smith' has left the name it is the family of a two-piece name — maiden 'Jones Smith', family 'Ma', which is worse than the maiden 'Jones Smith Ma' it had always read. The numeral fork reads the piece before the numeral, and the take removes that very piece: afterwards assign sees the piece before the MARKER there, and where that is initial-shaped the fork does not fire — the first draft stopped the walk all the same, and `J. née Jones Smith V` read maiden 'Jones Smith', family 'V' (both reviews found it: 27 of the design-docs review's 144 marker shapes, every one a head ending in an initial; the pool is unrecorded). The walk runs the peel again over the view the take would leave — the pieces before the marker and the numeral on, built as P5's reserve builds its view — so it stops only where the numeral reads as the suffix both as written and as left: `J. née Jones Smith V` keeps maiden 'Jones Smith V', as `J. V` reads the V as a name, and `Dr. née Jones Smith V` keeps maiden 'Jones Smith V', since assign peels the title first and a numeral alone is no fork (the first re-ask checked the preceding piece only and handed that V to the given name — the code review). As written too: `Jane née Jones J. V` keeps maiden 'Jones J. V' though the take would leave `Jane V`, the words as written being what the marker takes; the second docs review found the statement saying only the second half. The acronym is left to assign, recorded as Accepted. The peel is read from the marker rather than after it, so `Jane Smith née V` declines as `Jane Smith née PhD` does — nothing after the marker but a suffix, the marker stays a word, suffix 'V' — which is 1.4.0's reading (the first draft read maiden 'V', and the 1.4.0 gate is what showed it); and `Jane née Jones J. V` keeps maiden 'Jones J. V' (the piece before is initial-shaped). 1.4.0 had no maiden support, so the examples fall under the #274 rule at that baseline where its fields reach, and `J. née Jones Smith V` (v1 read suffix 'V') under an accepted rule of its own; against 2.0.0 and 2.1.0 the examples that move are classified — `John née Jones Smith V` in maiden and suffix, `Jane Smith née V` in the five fields the marker's survival touches — and `J. née Jones Smith V` is 2.x parity. - 2026-08-25 #410 — the maiden name no longer makes H1 decline; the rule counts name words and a marker-announced name is not one. The decision and its width are recorded at decisions/H1, since the term it removed was never maiden-specific. - 2026-08-26 #434 (its comma-glued divergence witness superseded in part by #653, decisions/N1, as its own text records) — a maiden marker may be a PHRASE, and the Polish "z domu" ships as the first one. The rule had assumed a marker is one word everywhere it was written down, and the library's own advice made that assumption load-bearing: the dead-entry warning told a caller with a multi-word entry to "split it into separate entries", which for this field produces two wrong readings rather than one inert entry. With z and domu stored separately, "Maria Kowalska z domu Nowak" reads maiden "domu Nowak" — the marker's own second word inside the value #434 reports — and "Anna z Nowak" loses its family name outright, because a bare preposition is then a marker and M2 hands a marker every word after it. So the warning was not merely unhelpful for this field; following it was the defect. A phrase entry matched whole is the only shape that fixes both: z is an ordinary Polish preposition and domu an ordinary noun, and neither ever claims anything alone. A run predicate — identify a run of marker words, then look the joined run up — was rejected, and the reason is the difference between the two exceptions this makes. given_name_titles is the STORAGE precedent and NOT the matching one. Its entries are stored space-joined and folded per word (_lexicon._title_key, since whole-phrase normalization would leave 'lt. col' with its interior period), and markers copy exactly that. But its MATCHING identifies the run per word first — 'lt' and 'col' are each title vocabulary, so the parse has already read both as titles before anything is joined — and z and domu are markers in no vocabulary at all, so no run can be identified before the lookup. Markers therefore need genuine multi-token lookahead, longest first, which is a different mechanism wearing the same storage rule. Worth knowing that until this change NO shipped vocabulary set held a single multi-word entry, given_name_titles included: the exception existed for caller-configured vocabulary only, and "z domu" is the first phrase the library itself ships. That the phrase is safe where its words are not is the C-i question answered one level up, and the contrast worth drawing is with the neighbour in the Excluded (MAIDEN_MARKERS) block in decisions/vocabulary-exclusions. #vocabulary-collisions C-i asks whether a word is borne as an ordinary name in the position the vocabulary claim acts on; roz fails it — Roz is a diminutive of Rosalind and stands exactly where M2's claim acts, the word after a given name — and MAIDEN_MARKERS has no ambiguous subset to express a partial answer in, so the only remedies available there were ship and do not ship, and it was removed. z fails C-i at least as badly. What changes is that the claim need not be made about z at all: the phrase is the unit that claims, and "z domu" is borne as no name in any position. C-i is a test on the claiming unit, and a phrase entry is a way of choosing a different unit rather than a way of passing the test with the old one. This is not a general escape — a phrase whose words are common and whose pairing is also common would fail C-i as a phrase — but it is the answer whenever the collision is with a word that only a longer phrase makes unambiguous. Markers are consulted at FOUR sites, which is one more than the title-run key's three, and _title_key's own docstring names the failure mode ("a divergence between them fails silently: the entry simply stops matching"). So one predicate answers "does a marker start here, and where does it end" — _pipeline._vocab.maiden_marker_run, in the vocabulary layer because it is a question about token TEXTS, per mechanisms.md#ONE-PREDICATE-PER-QUESTION's rule that the destination follows the layer. The four are classify's tagging, extract's clause test, and group's TWO — the piece test M2's take is built on and the drop inside an extracted clause (#329), which is the pair that makes four where titles have three. classify calls the predicate in a sequence pass, tagging a run's head "vocab:maiden-marker" as before and its continuations "vocab:maiden-marker-cont"; extract calls it over a clause's whitespace words; both of group's sites read those tags rather than re-deriving the run, which is the same mechanism's recorded-answer half, available because group runs after classify. tests/v2/pipeline/test_group.py::test_every_marker_site_ends_the_run_in_the_same_place is the contract, modelled on the P5/H1 title-run test: it collects each site's answer to "how many words is the marker" over nine spellings and asserts they cannot disagree, and each of the four sites has been mutated to confirm it fails. One divergence between those sites is deliberately PRESERVED and must survive any future unification. extract splits a clause on WHITESPACE while the tokenizer splits punctuation off, so "née," is marker-led to classify and not to M3. That is what keeps group's role filter reachable, and cases.py::marker_glued_to_punctuation_keeps_the_clause_a_nickname is the row that pins it — 'Jane "née, Janie" Smith (née Jones)' reads nickname 'née Janie', maiden 'Jones'. Sharing the predicate did not merge the two questions, because each site hands it a different sequence of words; confirmed by mutation, where making _maiden_marked strip commas leaves that row (and its facade twin) the only failures. SUPERSEDED IN PART 2026-10-12 by #653: inside a clause the tokenizer now keeps a comma on its word, so 'née,' is no marker to either site and that row reads nickname 'née, Janie'. The divergence survives through rules/T2's nakaguro, which tokenize divides at and the whitespace split does not, and cases.py::marker_divided_by_a_nakaguro_keeps_the_clause_a_nickname is the row that pins it now ('佐藤 花子 "旧姓・田中" (旧姓 鈴木)' reads nickname '旧姓 田中', maiden '鈴木'). The commit order carries a finding about this repo's tests worth stating on its own. Taken in the plan's order — predicate, then storage plus the warning carve-out, then the sites — the middle commit would have exempted maiden_markers from the dead-entry warning before anything could match a phrase. That is not merely untidy: pyproject sets filterwarnings = ["error"], so a test that configures a phrase entry FAILS until the exemption lands, which means the carve-out cannot be deferred past the sites either. The two constraints leave one arrangement — the predicate alone, then storage and the four sites together, then the shipped entry — and the middle commit is fused for that reason rather than by preference. Shipping "z domu" last is what keeps any bisected revision from holding a phrase entry nothing can match. Review found one real defect, and it is the one this whole design exists to prevent. A marker run is TAGGED over the whole span-sorted token stream and CONSUMED over one segment, and the two populations are not the same: segment's role-None filter keeps only tokens extract has not already given a role, then buckets what is left by the commas before them. So a phrase written across a bracketed clause — "Anna z (domu) Nowak" — had both its words tagged while the piece walk could see only the first, and M2 took a proper PREFIX of the phrase for the whole marker: family 'Anna', maiden 'Nowak', a bare Polish preposition eating the name, and "Maria z (domu) Kowalska Nowak" losing its family name outright. Reachable on shipped vocabulary under the default policy, and it falsified three of this branch's own claims at once, in three files. The fix is a rule about the TAG rather than a guard on the consumer: classify refuses to tag a run whose tokens are not structurally contiguous — same role, so a clause edge ends a run, and the same comma bucket, computed the way segment computes it. Refusing rather than truncating, because truncating hands M2 the same wrong prefix one word shorter. The lookahead is bounded at the boundary before the predicate is asked, so a two-word entry refused at a clause edge still leaves a one-word entry starting there free to match, and an all-single-word vocabulary skips the walk entirely. rules/M2 now states the qualifier — a phrase is recognized only whole AND only where its words stand together — which it did not, and that gap is part of why the defect was invisible. The defensive complement was declined and the reason is worth recording: having _marker_run_pieces re-derive the run from token indices and refuse a mismatch would be a guard that, with the tagging rule in place, can never fire — untestable code standing in for an invariant one stage already guarantees. What it would have bought is written into that function's docstring instead, which now names segment's role-None filter as the second reason `seen` can skip a token; the sentence that said the continuation "is always in `seen`" without naming that reason is what made the gap invisible to review. The contract test should have caught this and did not, and the hole is worth naming because it is a property of how the contract was FORMULATED. It varied the marker's SPELLING across nine forms and held its PLACEMENT fixed, so every run it built was structurally contiguous by construction and no probe ever asked what the sites do when a run straddles a boundary — which is precisely where two sites walking different token populations can disagree. The placement axis is now its own test over eight placements, asserting two things no spelling can reach: that every tagged run has one role and one comma bucket, and that group drops a run whole or declines it. Mutation-checked in three pieces, which is what shows the two halves of the rule are both load-bearing: removing the contiguity bound outright fails both new case rows and all three straddling placements; removing only the ROLE half fails the two clause placements and both rows; removing only the COMMA half fails exactly one placement and NO case row, because a comma-split run happens to be declined downstream for an unrelated reason today. That last one is the argument for a tag-level contract test in its own right — the only witness to half the rule is an assertion about tags, not about fields. Perf, measured as medians of five INTERLEAVED rounds (every variant timed once per round, since timing trees in separate processes across sessions gave a spread the size of the effect) over 8000 parses of a four-name mix, against master before PR #448: the shipped mechanism costs nothing this benchmark resolves (-0.3% and -1.3% on two runs, the run pass alone having been +6.8%), the marker pass having replaced classify's per-token membership test, and the reviewer reproduced the stages on other hardware. Four structural choices got it there, and each is a smaller version of the same move: ask the question once and pass the answer along. The fast path lives INSIDE the predicate rather than at its callers, so it stays an implementation detail and not a second answer. maiden_marker_head takes the FOLD rather than the text (suffix_as_written's shape, "n is _normalize(text), passed in so callers normalize once"), and classify folds once per token for the marker pass and the vocabulary tags together, which took the common token from two folds to one — fewer than before this work. maiden_marker_run folds each word once and keys from a prefix, where _title_key(words[:n]) per candidate length re-folds the whole prefix and made a ONE-word hit cost more than a two-word one; sixteen of the seventeen shipped entries are one word. And the marker pass returns its decision as index -> tag for classify's single token-building loop to write, rather than rebuilding every marker token a second time with dataclasses.replace, which is the most expensive call in a parse. The comma-bucket sweep the contiguity rule needs is deferred until a marker head is actually found, so a single-word vocabulary and a phrase vocabulary over a name holding no marker both skip it entirely. One limit is recorded rather than closed. The contiguity test is role plus comma bucket, and role is one-directional: a role change is always a clause edge, but two ADJACENT clauses of the same role are indistinguishable, so 'Jane (z) (domu) Jones' does tag a run across two separate nickname clauses. Nothing reads it — the piece walk never sees role-bearing tokens and the clause drop is scoped to one span — and the parse is identical with and without the phrase mechanism. The clean fix is a per-token structural UNIT id derived from state.segments and state.extracted, which would replace role-plus-bucket with one comparison and close the adjacent-clause case as a side effect. Deliberately not taken here: it changes a mechanism four correctness reviewers had just cleared, inside a pass whose stated business was not to change behavior. It is the shape to reach for if this test ever needs to grow a third term. - 2026-09-19 (Derek), #533 (its NOT TRANSPARENT HERE pair superseded by #535, the 2026-09-26 entry; its release check, its family-comma cases and its `do` pair after a family comma superseded in part by #601, the 2026-10-04 entry) — THE CLAUSE ENDS AT A TRAILING CREDENTIAL, AND THE DOUBLE CHECK THE 2026-08-22 BULLET DEFERRED NOW EXISTS FOR THE ACRONYM TOO. That bullet stopped the walk before the trailing numeral and left the bare-acronym fork alone, with its reason stated: the acronym fork COUNTS pieces and the walk removes the very pieces it counted, so a peel read over the pieces as they stand said "credential with words to spare" about `John née Jones Smith Ma` and would have left maiden 'Jones Smith', family 'Ma'. The reason was right and is unchanged; what it argued for was a deferral, and the answer is the one the numeral got — ask the peel TWICE, once over the pieces as written and once over the name the take would leave, and stop only where both read the word as the credential. "Left to assign" meant "read in silence", assign never getting the question at all, the clause's tokens carrying the maiden role before pieces exist for it: the end of a maiden clause was the last trailing position in the library where a member of the ambiguous credential class was read without a report. WHICH RULE reads the name left standing is the part the numeral's version did not have to answer, because a numeral reads the same way everywhere and a credential does not. With no comma it is the trailing peel; after a family comma it is #531's slot, where the comma has already settled the count and the writing decides alone; before that comma the words are the family the comma named and a stop would hand one of them to `family` rather than to `suffix`; past a second comma the segment is read as credentials whole and no trailing rule is consulted. The last two READ AS NONE, which is a statement and not a default — the first prototype reported on `Smith, John, Jr nee Jones MA`, where the walk had asked nothing. A FIFTH CASE, after a family comma (`Jane Doe, Jr nee Smith MA`), was read here as maiden 'Smith', suffix 'Jr MA', silently; the review bullet below settled it the other way, the clause keeping 'Smith MA' and reporting, and #601 then made a marker in that given part an ordinary word. THE SECOND CHECK ASKS WHETHER THE READER TAKES THAT WORD, not whether it takes something, and the distinction is not academic: the numeral's existing re-ask tests whether the view's trailing run starts at the view's end, exactly right for a fork that reads one piece, and under it `JOHN NEE JONES SMITH MA PHD` leaves `JOHN MA PHD`, whose peel takes 'PHD' and then DECLINES 'MA' for want of words to spare — so the weaker question answers yes while the member becomes the FAMILY name, which is the 2026-08-22 disaster one word further on. Measured both ways on the prototype; the shipped check compares the view's run start against the member's own index in that view, and `JOHN NEE JONES SMITH MA PHD` keeps maiden 'JONES SMITH MA' with suffix 'PHD' and reports. AFTER A FAMILY COMMA THE CHECK NEEDED #531'S FLOOR as well as its lean (`Doe, Jane nee Smith MA do` kept 'MA do' whole), a given-part reach #601 removed. THE FLOOR ON THE FIRST WORD IS A CLAMP, NOT A VETO, and the first draft got two names wrong by making it one: stated as "a member that is the ONLY word after the marker stays the maiden name" and implemented as "cancel the stop when nothing would be left", it cancels too much wherever the peel consumed that first word AND words behind it — `Doe, J. nee MA ba` peels 'ba' and then 'MA', so the first piece the peel took IS the only maiden word, and the veto handed 'ba' back to the clause, giving maiden 'MA ba' where `Doe, J. ba` reads suffix 'ba'. Clamped to the piece after the marker instead, that name keeps maiden 'MA' and reads suffix 'ba', reporting both. The floor itself is deliberate and diverges from the `née V` / `née PhD` precedent, where the marker declines and stays an ordinary word: certain suffix vocabulary declines the marker, while an ambiguous word is kept by the clause it ends, because that class is the one carrying no evidence of which it is — its members are borne surnames as well as credentials, and nobody writes a credential straight after the marker. 1.4.0 read `Jane Doe nee MA` as last 'nee', suffix 'MA' (measured on the wheel), so this half restores nothing and does not try to. THE `do` PAIR is #531's reading, unchanged and now SHARED as a predicate rather than spelled twice (`credential_at_the_given_slot` in `_pipeline/_pieces.py`; the drift would have been silent, each site's own tests going on passing): `Doe, Jane nee Smith do` keeps maiden 'Smith do', `Doe, Jane nee Smith Do` keeps 'Smith Do', `Doe, Jane nee Smith DO` gives maiden 'Smith' with suffix 'DO', and the comma-less `Jane Doe nee Smith do` reads suffix 'do' — right for the reason the whole change is, since `John Doe do` reads suffix 'do' today, P6 does not run without a comma, and a clause must not change how a word outside it reads. ACCEPTED COSTS, each measured on this tree: `JANE DOE NEE YO-YO MA` reads maiden 'YO-YO', suffix 'MA', a two-word birth surname losing its last word in a one-case record — and `JANE YO-YO MA` reads suffix 'MA' too, so the cost is the one-case reading's rather than this rule's, and in mixed case the writing saves it (`Jane Doe nee Yo-Yo Ma` keeps maiden 'Yo-Yo Ma' and reports); `Jane Doe nee Smith X.Y.Z.` reads suffix where 1.4.0 read last 'X.Y.Z.', the same trade #531 took at its own slot and for agreement with `John Doe X.Y.Z.` rather than for parity; (two costs listed here were WITHDRAWN by the PR review and are no longer costs at all — `Doe, Dr. nee Smith MA` and `Berg, abdul nee Jones MA` both keep their maiden words and report; the bullet below carries the reason); `Jane Smith nee Jones and MA` reads maiden 'Jones and' with suffix 'MA', the take running before the joins (#420) so the connective is a piece of its own when the walk stops; and a report on a declined word adds noise without moving a field, which is #530's stated rule and the trade #531 took at its slot. NOT TRANSPARENT HERE: a trailing title. `Jane Doe nee Smith MA Prof.` is unchanged and silent while `Jane Doe nee Smith Prof. MA` gives maiden 'Smith Prof.' with suffix 'MA' and reports, because the walk reads the trailing credential run and the H5 title chain is read by the rule the walk does not run. Accepted for #533, pinned as a pair of boundary example lines under M2; moving the walk onto the title-aware reading would change the numeral half too, which the 2026-08-22 bullet settled with its own measurements, and the pair belongs to one decision about what "trailing" means inside a clause — a follow-up is drafted for Derek rather than filed here. THE `one_case` PLUMBING AT THESE SITES IS NOW LIVE, and decisions/S6's 2026-09-18 bullet headed THE PREDICTION THAT DID NOT SURVIVE ITS OWN MEASUREMENT stands as what was true then: `numeral_only` answered off the peeled numeral and the numeral fork is decided before the peel reads a lean, so the fact reached only the bare-acronym fork, which that reading discarded. The acronym fork is asked now. Re-measured 2026-09-19 over the population that entry's recipe names (PR #534): dropping the argument inside the walk moved 0 parses over the corpus of the day before and 36 of 10,752 (1,792 names, six distinct names) with this change's own rows, so the plumbing was live and the corpus could not show it, the blindness mechanisms.md's corpus field note names. - 2026-09-19 (Derek), #533 review (its two view checks superseded in part by #601, the 2026-10-04 entry) — A WORD THE CLAUSE GIVES UP READS AS A POST-NOMINAL OR THE CLAUSE KEEPS IT, AND THAT INVARIANT REPLACES THE ACCEPTED SILENT MOVERS ABOVE. The PR review measured what the released words actually do, and three of them do not land in `suffix`: `Doe, Prof. nee Smith A.B.` read given 'A.B.' (66 distinct names in the review's own sweep, the same shape reaching `ba`, `X.Y.Z.`, caps-on `XYZ` and the lower-case spellings); `Berg, Jane van der nee Smith DO` read family 'van der DO Berg' and `Jane Doe nee Smith DO DO` read family 'DO DO', a word crossing from the BIRTH name into the current one, which is #424's failure from the other side; and `Berg, abdul nee Jones MA` read given 'abdul MA'. Two of those had been written up as ACCEPTED COSTS in the bullet above, on the argument that the clause form AGREES with the clause-less one. The agreement is real and it is not the question: the clause-less `Berg, abdul MA` puts the word in the given name because nobody promised otherwise, while a CLAUSE promised that the word was either the birth name or a credential, and a third answer is the promise broken in silence. So the direction is the parent's, conservative: THE STOP IS RIGHT ONLY WHERE THE RELEASED WORD ENDS THE PARSE SUFFIX-ROLED, and where the take cannot promise that, the clause keeps the word (and reports it, the emitter asking whether a trailing rule reads these words at all rather than whether the view check then passed). Two view checks carried it, a name word ahead of the member at the given slot and the withdrawal of a release a P2 chain or P5 join would absorb; #601 replaced the release checks with the clause-free reading bound as roles. MEASURED over the review's 142,518-name corpus under six policies (855,108 parses): 0 invariant violations at 2f57ff21 and on this tree against 1,446 in 40 shapes at the round's starting commit, and no moved token left unreported among 128,424 moved parses against 2f57ff21, against 4,100 silent moved tokens in 59 shapes before; the property grid in `tests/v2/test_properties.py` fails 790 of its 8,466 parses at d97d3eb7. TWO ACCEPTED SILENCES REMAIN AND ARE NOT SILENCES OF THIS KIND: a member with a name word BEHIND it was never asked about, and a delimited clause is not a fork at all (below). ONE ROUGH EDGE IS RECORDED RATHER THAN FIXED, and it PREDATES this change: with `extra_suffix_delimiters=('-',)`, `Jane Doe nee Smith - MA` gives maiden 'Smith -' — a clause ending on a bare delimiter token. 2f57ff21 does the same thing one word later (`Jane Doe nee Smith - PhD` gives maiden 'Smith -' there too), so what #533 changes is only which words reach the edge. Walking the stop back over it is not the two-line change it looks like: the delimiter-core set the walk already steps over is EMPTY off a tail segment, which is exactly where this name's `-` sits, so the fix would have to widen what counts as a core on every path and would move the parent's reading as well. Left for its own issue. - 2026-09-19 (Derek), #533 review — THE DELIMITERS TRUMP ANY OTHER READING, and that is a settled position rather than an un-asked fork. `Jane Doe (nee Smith Ma)` and `Jane Doe (nee Smith MA)` both keep the WHOLE span as the maiden name and report nothing, in every release including this one; the marker INSIDE the delimiters is what tells the parser the span is a maiden clause, even where the pair is not configured in `maiden_delimiters` (M3). The writer drew the boundary, so no fork is called and there is nothing to report — which is a different kind of quiet from the silences M2 and `AmbiguityKind.SUFFIX_OR_NAME` document, where a fork WAS available and the parser declined to ask it. The boundary cuts both ways: `Jane Doe (nee Smith) MA` gives suffix 'MA' and reports, the member standing outside the span being an ordinary trailing credential. Behavior is unchanged from 2f57ff21 and was not touched; what the review added is the three rules.md example lines, the case rows, and the statement in M2 and in the `SUFFIX_OR_NAME` docstring, because an undocumented settled position reads as an oversight to the next person who measures it. - 2026-09-20 (Derek), #397 review (its comma form superseded by #601, a marker in the given part after a family comma being an ordinary word) — A LINK INSIDE A MAIDEN CLAUSE STAYS IN THE BIRTH NAME, AND THE SPEC'S Q3 WAS WRONG ABOUT THIS BY MEASUREMENT. The walk has ended the birth name at the first suffix WORD after the marker since the #420 reorder, and the Catalan link is also the roman numeral, so `Jane Doe nee Puig i Soler` read maiden 'Puig' and left 'i Soler' outside the clause — middle 'Doe i', family 'Soler' at master 46651750, and the same truncation at every 2.x release. THE TRUNCATION PREDATES #397. What #397 added is the harm: with the link joining, those leaked words merged into the CURRENT surname, and 0fbcaa0b read family 'Doe i Soler' — a word of one person's birth name inside the surname they carry now, which is the class of failure #424 and #533 exist to prevent, arrived at from a third direction. The comma form leaks into the given part instead (`Doe, Jane nee Puig i Soler` read given 'Jane i Soler'), and a Catalan head loses the distinction between its own link and the clause's (`Maria Carod i Rovira nee Puig i Soler` read family 'Carod i Rovira i Soler'). THE SPEC'S Q3 said this interaction was unchanged; it was written from the rule and not from a parse, and the four names above are what a measurement said instead. Derek decided on 2026-09-20 to fix it in #397's own PR rather than file it, on the ground that the PR is what turned a truncation into a leak. THE FIX IS P3'S OWN CLASS TEST, ASKED WITH THE CLAUSE'S BOUNDS. The walk does not stop at a suffix piece that is a connective PLACED TO JOIN inside the clause — a name word of the clause on its left and one on its right, which is the condition the 41de3df6 join already asks through `_name_word_beside`, called here rather than restated (mechanisms.md#ONE-PREDICATE-PER-QUESTION). Two bounds make it the CLAUSE's question and not the segment's, and each is a control: the left bound is the first piece after the whole marker run, so the marker is no name word (`Jane Doe nee i Soler` declines as before, family 'nee i Soler'); the right bound is where assign's trailing run begins over the pieces as written, so a generation or credential ending the clause is outside it (`Jane Doe nee Puig i III` keeps suffix 'i III', `Jane Doe nee Puig i MA` suffix 'i MA' — and the MA one needs the BOUND, carrying no `vocab:suffix` tag for the piece test to refuse it by). The right bound is `trailing_start`'s own answer read off the peel pair already in hand, not a second call. #533's trailing-credential stop and #424's numeral stop are byte-identical across the change, measured. MEASURED, and the pair of numbers is the argument. A new property grid — 4,590 clause texts (five heads including a Catalan one with its own link, all seventeen shipped markers, six clause bodies, five trailing runs, comma and no-comma) against the default policy, both family-first orders and strict comma, 15,810 rows, of which 10,540 hold a link the `y` spelling puts between two birth-name words — carries two invariants in `tests/v2/test_properties.py`. THE y TWIN (`y` is the same connective with the ambiguity removed, so it is an oracle and not a second subject): the `i` parse's seven fields equal the `y` parse's, letter for letter apart from the link. It fails on ALL 10,540 guarded rows at 0fbcaa0b AND at the parent 46651750 — no clause shape holding a joining link read as its twin did — and on 0 here. NO BIRTH-NAME WORD IN THE CURRENT NAME, delimited by the twin's own maiden tokens: 28,985 tokens at 0fbcaa0b against 22,185 at 46651750, and 0 here. The GAP between those two is exactly what this branch added, and it is why the y twin alone would not have told the story: the twin fails identically at both commits while the harm doubled between them. THE GUARD IS NARROWER THAN "the twin keeps the link", by measurement rather than by preference. `Jane Doe nee Puig y` keeps its `y` in maiden 'Puig y' while `Jane Doe nee Puig i` reads maiden 'Puig' with suffix 'i' — deliberately, the link joining nothing there — so a guard asking only whether the twin kept the letter would demand the two agree where the rules say they must not. `Puig y III` and `y Soler` are the same shape from the other two sides, and the grid keeps all three as the bodies the guard has to refuse. INV6's off-switch exemption is NOT widened, and the reason is the one AGENTS.md gives for not widening an exemption a grid cannot reach: measured 2026-09-20, 1,680 rows of that grid carry both a class letter and a marker and 0 of them put the letter after the marker, its generator placing the connective among the name's own words. A clause link has its own grid instead. TWO SECOND-ORDER MOVEMENTS, both recorded rather than repaired. The two one-case spellings part company, which is P8's own Accepted clause (P3's until the 2026-10-19 split) reaching a taken marker rather than a declined one: classify leaves a letter after a marker to the mixed-case rule, so `JANE DOE NEE PUIG I SOLER` never moved (the capital is an initial, and the initial veto kept the walk going all along) while `jane doe nee puig i soler` goes from family 'doe i soler' to maiden 'puig i soler'. And a report is GAINED where a longer clause ends on an ambiguous credential: `Jane Doe nee Puig i Ma` keeps maiden 'Puig i Ma' and says `suffix-or-name` where 0fbcaa0b read family 'Doe i Ma' in silence — the clause's own emitter, reaching a word the truncation had put out of its reach. A THIRD, found by the second review and the mirror of that one: a report is LOST where the clause takes a class member it used to end at. `Jane Doe nee MA i Soler` reads maiden 'MA i Soler' in silence where the parent 46651750 read maiden 'MA' with a `suffix-or-name` on it. That FOLLOWS from the link no longer ending the clause and is not a second decision: the report is the walk's own, raised on the LAST word the clause kept where a trailing rule was asked about it, and with the link joining, 'MA' has a name word behind it and is no longer that word — which is exactly what `Jane Doe nee MA Smith` has always done with the same acronym. The `y` twin is the oracle and it AGREES, in fields and in reports: `Jane Doe nee MA y Soler` reads maiden 'MA y Soler' in silence at the parent and here. Measured 2026-09-20 over the review grid, 24 names lose the report this way and all 24 agree with their `y` twin on every field and every report. - 2026-09-22 #397 follow-up — A DELIMITER CORE PAST THE CLAUSE'S FIRST WORD PASSES FOR THE NAME WORD BESIDE A LINK, RECORDED AS A DEVIATION RATHER THAN REPAIRED. The link exception above wants "a connective standing between two name words of the clause", and a separator the caller declared through `Policy.extra_suffix_delimiters` is structure, not a name word — so a link with one beside it joins nothing and should end the clause like any other suffix word. Between the marker and the clause's first word that already holds, the bound refusing the core before either piece test is asked. PAST that first word it does not: the core is an ordinary index to the run walk, which steps over connectives and nothing else, so it stands in for the name word on the link's left and the clause runs on past a title it would otherwise stop at. MEASURED 2026-09-22 under `extra_suffix_delimiters=(" - ",)`: `Smith, John, PhD née Puig Mr. - i Soler` reads maiden 'Puig Mr. i Soler' where its separator-less twin `Smith, John, PhD née Puig Mr. i Soler` stops at 'Puig Mr.'. THE POPULATION, measured 2026-09-21 over the branch's own sweep (corpus, cases.py, the property grids and a 50,925-name generated set with cores, under thirteen core-bearing policies): the answer differs from a core-skipping reading in 8,094 parses over 1,278 texts, 288 of them moving `maiden`, none reachable at the default policy. NOT REPAIRED HERE: the fix threads the core set through three call sites into the run walk and moves the parent's reading as well, which makes it its own change rather than a rider on a review round. Open: #538. PINNED TWICE MEANWHILE. rules/M2 carries the shape as a `deviates: #538` example under an `extra_suffix_delimiters-dash` annotation — the first entry `tests/v2/rules_doc.py`'s registry has had for that field, named after the Policy field and carrying the delimiter in the suffix because the field's value is a set rather than a flag. And `tests/v2/pipeline/test_group.py::test_a_core_beside_a_link_wrongly_passes_for_a_word_until_538` holds the pair at the piece level, named so nobody reads it as the contract. ONE COST OF THE DOC EXAMPLE: the differential gate parsed it with the default facade, where the dash is a name word, so it moved there for the 2026-09-20 link fix and was classified as that at all five baselines. - 2026-09-26 #538 — SUPERSEDED by 2026-10-03 #549 — A DELIMITER CORE IS STEPPED OVER BY THE LINK'S NEIGHBOUR SEARCH, AS A CONNECTIVE IS (Derek, 2026-09-26). The 2026-09-22 follow-up above recorded the deviation; this resolves it. `_run_neighbours` treats a lone core like a connective, so the word on a link's side is the one past the core and a maiden clause reads exactly as the same text written without the core: `Smith, John, PhD née Puig Mr. - i Soler` under `extra_suffix_delimiters=(" - ",)` now stops at maiden 'Puig Mr.' as its separator-less twin does. Its invariant (6 of 81 generated texts disagreed at e0f1a2fa, 0 after) was replaced with the reading by #549's, under decisions/C10. Declined: THE BOUNDARY READING, which the 2026-09-22 entry's wording implied ("a link with one beside it joins nothing") — a core ending the neighbour search on its side. It fixes the reported shape equally, and it moves `Smith, John, PhD née Puig - i Soler` and `… Puig i - Soler` from maiden 'Puig i Soler' to maiden 'Puig', suffix 'PhD i Soler' — a birth-name link pushed into the credentials, where the skip reading leaves both as they read. Default-unreachable either way. THE SAME STEPPING APPLIED OUTSIDE A CLAUSE, a lone core beside a connective being dropped as #206 drops any (191 of 8,097 generated core-bearing tail texts moved `suffix` and no other field, measured 2026-09-26 against the same tree with no cores handed in), while a core a join merged survived in the suffix text; #549 (the next entry) reversed the skip reading and settled both. - 2026-10-03 #549 (its `Smith, John, PhD née Puig - i Soler` maiden reading superseded in part by #601, the next day, a marker in a part after a suffix comma being an ordinary word: suffix 'PhD née Puig, i Soler') — SUPERSEDED: the skip reading the #538 entry above settles is reversed, and a declared delimiter in a trailing part now parts it as a comma would, so `Smith, John, PhD née Puig - i Soler` reads maiden 'Puig' with suffix 'PhD, i Soler', the reading #538 declined and the one 2.3.0 gave, and a marker with a core straight before or after it takes no clause (`Smith, John, PhD née - Jones` reads suffix 'PhD née, Jones', reversing the #418 entry's repair). The decision, its measurement and the invariant that replaces #538's are under decisions/C10's 2026-10-03 entry. - 2026-09-26 #535 (its release question, its joins, its given-part reach, its family-comma numeral reading `Doe nee Smith V, Jane` and the further splits #601 closed superseded in part by #601, the 2026-10-04 entry, which keeps the first-suffix-word stop there) — THE MAIDEN WALK READS THE TRAILING TITLE CHAIN, AND EVERY STOP ASKS ONE RELEASE QUESTION OF THE SPAN IT GIVES UP (Derek, 2026-09-26). This resolves the NOT TRANSPARENT HERE clause of the 2026-09-19 #533 entry above, which recorded `Jane Doe nee Smith MA Prof.` and `Jane Doe nee Smith Prof. MA` disagreeing and deferred the pair as one decision about what "trailing" means inside a clause. BOTH HALVES WERE TAKEN TOGETHER: a trailing title ends the clause (`Jane Doe nee Smith Prof.` reads title 'Prof.', maiden 'Smith', where 2.3.0 read maiden 'Smith Prof.'), and a credential or numeral standing in front of that title gets the stop it gets with the title absent (`… Smith MA Prof.` gives suffix 'MA', `… Smith V Prof.` suffix 'V'). Only the first half would have left the pair disagreeing in the other direction; H5's transparency is a statement about the peel and the chain read together to their fixed point, so the walk now reads the end of the clause the way assign reads the end of the name — `tail_reading`, the shared predicate mechanisms.md#ONE-PREDICATE-PER-QUESTION names — and over the forms the transparency property test below exercises, the two spellings give one answer. They split where something in or ahead of the clause stands to take the title — for example a particle chain (ahead of the clause or inside it), a bound-given join, a name left with no name word, or, after a family comma, a title word in front of a class member with a title behind it, which the given part's chain stops short of exactly as it does with no marker (`Doe, Jane nee Smith Rev. MA Prof.` reads maiden 'Smith Rev.', title 'Prof.', suffix 'MA', where `… Rev. Prof. MA` reads title 'Rev. Prof.', maiden 'Smith', suffix 'MA'; bare `Doe, Jane Rev. MA Prof.` reads middle 'Rev.') — and the Accepted pairs recorded here and in rules/M2 are examples of that, not a complete list. `tests/v2/test_properties.py::test_a_trailing_title_is_transparent_to_the_maiden_clause` holds that as an invariant over the two inputs, over heads and runs where the credential is given up and nothing ahead would take the title, with its negative control at e0f1a2fa in its docstring. ACCEPTED, THE TRANSPARENCY BOUNDARY: where the clause KEEPS the credential the spellings differ, because a clause is one contiguous run and a title inside the kept text cannot leave without the words behind it — `Doe nee Smith ba Prof.` reads title 'Prof.', maiden 'Smith ba', and `Doe nee Smith Prof. ba` maiden 'Smith Prof. ba'; `abdul nee Smith MA Prof.` reads title 'Prof.', family 'abdul', maiden 'Smith MA', and `abdul nee Smith Prof. MA` given 'abdul', maiden 'Smith Prof. MA' (2.3.0 kept every word in all four). The report differs with them: `Doe, Dr. nee Smith MA` and `… Prof. MA` report `suffix-or-name` on the kept MA, `… MA Prof.` reports nothing, the kept member no longer ENDING the clause, which is what M2's report is asked of. rules/M2 carries the first pair as an Accepted example. ACCEPTED, FURTHER SPLITS, measured 2026-09-26. Where something ahead of the clause would take the title, the spellings differ even with the credential given up in both, because the first-suffix-word stop ends the clause before the title check runs and the title check alone declines: `Jane van der Berg nee Smith PhD Prof.` reads title 'Prof.', maiden 'Smith', suffix 'PhD', and `… Prof. PhD` maiden 'Smith Prof.', suffix 'PhD' — H5's accepted particle-chain boundary inherited, the bare `Jane van der Berg Smith PhD Prof.` / `… Prof. PhD` splitting the same way — and `Berg, abdul nee Smith PhD Prof.` (bound-given join) and `Doe, Dr. nee Smith PhD Prof.` (no name word left) split likewise; the parent d9d80492 and 2.3.0 read all of these as the tree does, so only the claim is new. And a released particle with a title behind it is withdrawn: `Jane Doe nee Smith DO Prof.` reads title 'Prof.', maiden 'Smith DO', where `… Prof. DO` reads title 'Prof.', maiden 'Smith', suffix 'DO' (2.3.0 maiden 'Smith DO Prof.' and 'Smith Prof. DO'); `do` likewise. A particle INSIDE the clause does it from the other side, its chain able to take the credential behind it: `Jane Doe nee Smith do MA Prof.` reads title 'Prof.', maiden 'Smith do MA', and `… do Prof. MA` title 'Prof.', maiden 'Smith do', suffix 'MA' (2.3.0 kept every word in both). rules/M2 carries a pair of each shape as Accepted examples. ACCEPTED, H5'S REACH: `Jane Doe nee Smith King.` reads title 'King.', maiden 'Smith' (2.3.0 maiden 'Smith King.'), H5's accepted `Mary Jane King.` cost now reaching the last word of a birth name as it reaches the last word of a current one; rules/M2 carries it as an Accepted example. READER SCOPE IS UNCHANGED: the chain is consulted only where a trailing rule reads the clause's words (no comma, the part before a suffix comma, the given part after a family comma); before a family comma and in a tail segment the walk reads the peel alone and the clause keeps the title (`Doe nee Smith Prof., Jane` keeps maiden 'Smith Prof.'). THE FIRST-WORD FLOOR MOVED INTO THE CHAIN. `trailing_titles` and `tail_reading` take a `floor` (1 everywhere else), and the walk passes the position just past the marker's first word, so the chain never takes that word: `Jane Doe nee King.` keeps its maiden name (TITLES holds borne surnames and the marker announced one), and `Jane Doe nee Prof. Dr.` keeps 'Prof.' and gives up 'Dr.'. The first draft applied the floor as a clamp on the title stop after the chain had run, and that is measurably wrong rather than merely inelegant: a chain allowed to take the first word has already spliced it out of the count the re-peel reads, so `Jane Doe nee King. ba` kept 'ba' in the clause where `Jane Doe nee Smith ba` gives it up. `test_a_title_first_word_counts_as_a_word` is the invariant (a title-vocabulary first word against an ordinary one, the credential behind it read alike) and its docstring records the clamp's failure count. ONE RELEASE CHECK FOR THREE STOPS: the numeral, credential and title stops each asked `_release_reads_off`, per reader, whether the name the take would leave reads the whole span a stop gives up as titles or suffixes; #601 replaced the release question with the clause-free reading bound as roles. THE JOINS: a released span a P2 chain or the P5 join would absorb was withdrawn, release machinery #601 removed. THREE NUMERAL-STOP READINGS moved with it, each the release invariant at the numeral stop and mostly after a family comma (`Berg, abdul nee Smith V`, `Doe, Jane nee Smith V, PhD`, `Doe, Jane nee V, PhD`), where #601 made the marker an ordinary word. A LINK THE WALK STOPS AT asked the release question too, where the title chain refused a link the exception would have joined (`Jane Doe nee Smith i DO Prof.`), scoped by a 411,936-parse link grid (6,195 fixed, 0 new violations, measured 2026-09-26); release machinery #601 removed. A RELEASED TITLE THAT IS ALSO A PARTICLE was kept after a family comma (`Doe, Jane nee Smith St.`), P6 attaching it to the family; a given-part reach #601 removed. A FOURTH MEASUREMENT, of the M2 invariant over a twelve-head fuzz (107,352 parses) against e0f1a2fa: 588 texts fixed and 12 newly violating, all `Dr. nee Smith i MA|V` behind a title, #548's class. ACCEPTED: `Jane Doe (nee Smith Prof.)` reads title 'Prof.', maiden 'Smith' — bracket content ending in a period is suffix-shaped (S1), so the brackets are dropped and the clause is read bare, outside the reach of the delimiter precedence; rules/M2 carries it as an Accepted example. MEASURED 2026-09-26, the tree against its parent d9d80492, over three generated grids, and these are dated snapshots. Recipe: grid A is each head in {`Doe, Jane`, `Doe, J.`, `Doe, Prof.`, `Berg, abdul`, `Doe, Jane van`, `Jane Doe`, `J. Doe`, `Dr.`, `abdul`, `Jane van der Berg`, `John`}, then ` nee `, then every sequence of one to three words (repetition allowed) from {Smith, Jones, Prof., Dr., Sir, MA, DO, PhD, V, III, i, do, van, Ma, M.A., King., Rev., ba}, then either nothing or `, PhD`, each text as written, upper-cased and lower-cased, duplicates removed — 327,936 texts; grid B, aimed at the bound-given join, is the same construction over heads {`Dr. abdul`, `abdul rahman`, `Dr. abdul rahman`, `Berg, Dr. abdul`, `Berg, abdul rahman`, `abu`, `Berg, abu`, `Mr. abu`, `Dr.`, `Berg, Dr.`, `abdul`, `Berg, abdul`}, words {Smith, Prof., MA, V, do, van, PhD, Ma, ba, III, Jr., M.A.} and tails {nothing, `, PhD`, `, Jr., MD`}, plus these fourteen texts, as written only: `Jane Doe nee Ph. D. Prof.`, `Jane Doe nee Ph. D. Smith Prof.`, `Jane Doe z domu King. ba`, `Jane Doe z domu Smith Prof.`, `Jane Doe nee King. Prof. ba`, `Jane Doe nee King. Prof.`, `Doe, Jane nee Smith V,`, `Doe, Jane nee Smith V, ` (with the trailing space), `Doe, Jane, nee Smith V`, `Doe, Jane nee Smith V Prof., PhD`, `Doe, Jane nee Smith Prof. V, PhD`, `Doe, Jane nee Smith MA, PhD`, `Doe, Jane nee Smith V (Jr.)`, `Doe, Jane nee Smith V "Bo"` — 173,057 texts. The invariant tested is M2's: in a parse with a non-empty maiden field, every token written after the ` nee ` marker is roled maiden, title or suffix (so the two `z domu` texts are parsed and not checked). Grid C, a four-word probe of the title chain after a family comma, is each head in {`Doe, Jane`, `Jane Doe`, `Doe, J.`, `John Smith`, `Doe, Jane Mary`}, then ` nee `, then every sequence of four words (repetition allowed) from {Smith, Jones, Prof., Dr., King., MA, PhD, V, Jr., ba, do}, then either nothing or `, PhD`, as written only — 146,410 texts. Per grid (newly violating at the tree, fixed, violating at both), measured on the tree as narrowed: A 0, 2,012 and 17,854; B 0, 1,222 and 11,149, one of those last the check itself counting the quoted nickname in `Doe, Jane nee Smith V "Bo"`; C 0, 1,368 and 15,534. In grids A and B, every other remaining violation has the clause ending immediately before a word of the unambiguous suffix vocabulary written the way the plain suffix-word stop takes it (PhD, III, M.A., Jr., and a lower-case i or v; never a bare capital, which reads as an initial), which is where that stop ends a clause without asking a release question. DEFERRED: the walk's plain suffix-word stop — the one ending the clause at the first suffix word, as distinct from the trailing numeral, credential and title stops — asks no release question at all, and where the words behind it cannot read as post-nominals they land in a name part: on degenerate heads the stop word itself does (`Dr. nee Smith PhD Prof.` reads family 'PhD', `Doe nee Smith Jr. Prof., Jane` family 'Doe Prof.'), and with an ordinary head the words behind it do (`Doe, Jane nee Smith PhD Smith` reads middle 'Smith'). Unchanged here, and what "the clause keeps it" should mean where the kept words would follow a credential the clause itself ended at is its own question; rules/M2 carries `Doe nee Smith Jr. Prof., Jane` as an Accepted example meanwhile. The trailing numeral's stop has the same gap before a family comma, where it is made over the peel alone with no release question: `Doe nee Smith V, Jane` reads family 'Doe V', maiden 'Smith' (so did 2.2.0, 2.3.0 and the parent d9d80492; 2.0.0 and 2.1.0 read maiden 'Smith V'), and rules/M2 carries it beside the other. Open: #548. COST, `sys.setprofile` call events in one parse after a warm-up on CPython 3.11.16, d9d80492 → tree (PR #550): `John Smith`, `Jane Doe Prof.` and `John Smith MA` unchanged, `Jane Doe nee Smith` 244 → 246, `… MA` 377 → 388, `… Prof.` 276 → 372, the one shape that now runs the chain and a release check. - 2026-09-27 (Derek), #544 (its boundary across the clause superseded in part by #601) — S2'S COMPANY CLAUSE DOES NOT REACH ACROSS A CLAUSE, recorded as an Accepted boundary rather than repaired. #601 superseded it: a marker behind a credential is an ordinary word now, and the credential's run takes the clause in (rules/M2's Accepted `Jane Doe Jr. nee Smith`). Inside the clause the company reads as it does anywhere: `Jane Doe nee Smith PhD MEng` ends the clause at 'PhD' and reads suffix 'PhD MEng', maiden 'Smith'. - 2026-10-04 (Derek), #601 (its `Jane Doe, PhD née Smith` reading superseded in part by #603, decisions/C13: suffix 'PhD née Smith') — A MARKER COUNTS ONLY BEHIND A SURNAME, AND IT TAKES THE WORDS AFTER IT UP TO THE TRAILING RUN THE NAME WOULD END WITH IF THE CLAUSE WERE NOT WRITTEN. This SUPERSEDES the release machinery of the #533 and #535 entries above (the release question, the per-stop checks, the anchored-head boundary of the #544 entry) and the given-part and suffix-comma reach the 2026-07-03 rule had; the #399, #420, #434 and delimiter entries stand. The trigger was #548, `Dr. nee Smith PhD Prof.`, where 2.3.0 read family 'PhD', maiden 'Smith': a clause whose head was nothing but a title, and a release check asking whether each word it gave up could still read as a post-nominal. #548 was closed into this issue and #602 (S2's run) because both halves of its answer were rules rather than repairs. THE HEAD RULE (Derek): a marker is one only in the name before any comma or the surname part before a family comma, behind at least one name word past the leading title run, and not straight behind an unambiguous suffix word or a connective. Anywhere else it is an ordinary word — in the given part after a family comma (`Doe, Jane nee Smith` reads middle 'nee Smith', 1.4.0's reading) and in a part after a suffix comma (`Smith, John, PhD née Jones` reads suffix 'PhD née Jones', also 1.4.0's). Derek's framing was that a maiden marker always follows a family name, and that a marker following something the parser has decided is a title, suffix, particle or connective is not a marker. THE PARTICLE HALF WAS DROPPED BY MEASUREMENT: prototype variant 1 refused a marker behind particle vocabulary and so declined `Anh Do née Tran` and `Mai Le née Nguyen`, Do, Le and Van being particles that are also the commonest Vietnamese surnames; a particle counts as the surname (`Jane van nee Smith` reads family 'van', maiden 'Smith'). THE BOUND (Derek): the clause takes its words up to the run of post-nominals and titles that the trailing rules read at the end of the name with the clause removed, and the words it gives up take the roles that reading gives them — the TRAILING rule's reading of the clause-free name, not the full parse of it, so a head the full parse would read differently (H1's lone title, a P5 join) cannot change where the clause ends. The first word after the marker is always taken, the marker having announced a name — a lone roman numeral included, so `Jane Smith née V` reads maiden 'V' where the clause-free `Jane Smith V` reads suffix 'V', accepted rather than given a numeral exception (rules/M2's Accepted block) — unless it is an unambiguous suffix word, where the marker declines (`Jane Smith nee PhD` reads family 'nee', suffix 'PhD', as before). Before a family comma the clause keeps the 2026-07-03 stop at the first suffix word, since no trailing rule reads that part's end. VARIANTS, prototyped 2026-10-03 behind an environment switch on one tree (the numbers are in #601's comments): v2/v3 grew the clause until the existing release check accepted it and reached 0 invariant violations but inherited the check's caution about particles (`Anh Do geb. de la Vega PhD Prof.` kept 'PhD Prof.' in the maiden name); v4 read the run once over the name as written without binding it, and is the recorded negative control in tests/v2/test_properties.py (350 of the clause grid's 4,482 parses fail under it, measured 2026-10-04); v5/v6 bound that reading as roles but still counted the clause's words among the words to spare, so `John nee Smith ba` read 'ba' as a credential where `John ba` reads a name; v7, the clause-free reading bound as roles, was adopted. Against #535's grids v7 left 0 violations of the M2 invariant where the tree then had 15,586 (grid A, 327,936 texts) and 8,713 (grid B, 173,057), measured 2026-10-03 with an invariant check written to #535's recipe. WHAT THE PROTOTYPE MISSED, found implementing it: the numeral fork. A trailing roman numeral is a suffix only by its shape against the word before it, and v7 read the clause's last word without that test, so `John née Jones Smith VI` kept 'VI' in the maiden name; the take now asks `is_trailing_numeral_suffix` (`_vocab.reads_as_generation` since #752) of the last piece, and cases.py's `a_numeral_the_clause_free_name_reads_ends_the_clause` pins it. And the head's title run is measured over the words before the marker, a lone title excepted: measured over the whole segment, `Lord Chancellor née Jones` counted 'Chancellor' as a title and refused the marker. TRIAGE, 2026-10-04: the prototype's 68 failing case rows (40 facade failures among them) were re-pinned as `fix(#601)` with their group named in the note, bar the head-rule bug above; tests/v2/test_parser.py's corpus-append invariant became two-sided (appending ` née X` moves no other field, OR the take records why it declined), with a named set for the suffixes that themselves hold a marker. DEGENERATE INPUT, NO READING DESIGNED FOR (Derek, 2026-10-04: garbage in, garbage out). Two names that read worse by eye moved, and they are recorded here so the move is not mistaken for a regression, not because either reading is wanted: `Berg, Jane van der nee Smith DO` reads middle 'van der nee Smith', family 'Berg' where 2.3.0 read family 'van der Berg', maiden 'Smith DO' — the marker in a given part being a word, the particles no longer trail the given name (P6); and `Jane Doe, PhD née Smith` reads given 'PhD', middle 'née Smith', family 'Jane Doe' where 2.3.0 read given 'Jane', family 'Doe', suffix 'PhD', maiden 'Smith', which 2.3.0 reached only through the clause taking the marker (the comma part opening with a credential is #603's shape). Measured 2026-10-04 against 2.3.0. COST: the take is one function and no release helper survives; the #533 review's comma-head grids could no longer reach a take at all and were replaced by counting heads (tests/v2/test_properties.py's `_maiden_clause_grid`, 4,482 parses). - 2026-10-04 #610 — THE MAIDEN TAKE'S CANDIDATE WORDS ARE ONE FUNCTION BESIDE THE PEEL, AND SHARING IT FIXED A GAP THE HAND COPY HAD. `_maiden_take` chose the clause words to read its clause-free view over with a filter of its own (`_clause_tail_word` plus a numeral clause), a hand copy of the peel's and the chain's admissions whose contract — a superset of what `tail_reading` takes — nothing stated or tested. It is now `_pieces.trailing_candidates`, written beside `peel_trailing`, and tests/v2/pipeline/test_pieces.py pins the superset at the take itself, on the unjoined pieces the take is handed, over a generated grid (37,044 clauses through `group`). The grid found the copy wrong twice. The numeral fork reads a shape-only roman numeral (`VI`, in no wordlist) as the WALK's last piece, and two things leave it last without standing at the end of the name: a period-marked title behind it, which the chain takes first, and the merged `Ph. D.`, which group flags and the walk never holds. The copy admitted the numeral only as the clause's last word, so `Jane Doe nee Smith VI Prof.` kept maiden 'Smith VI' where `John Smith VI Prof.` reads suffix 'VI', and `Jane Doe nee Smith VI Ph. D.` the same way. Both now end the clause at the numeral, as rules/M2's statement already said they should. Recorded negative control: the loop as #601 shipped it misses 432 of that grid's clauses, every one this shape. The test's first version read plain names through all of `group`, on pieces the joins had already merged, which review caught: there the old loop missed 288 names, and a first draft of the fix, covering the title half only, still missed 111 — the `Ph. D.` half, which that grid is what found. Against master 8a8459a8 over the 95,813-text equivalence grid the change moved 10 readings, all of this class. The same issue lifted P6's particle-tail walk into `_pieces.particle_tail`, read off roles, so post_rules' attachment and assign's given-part credential run (#602) ask one walk; that half moved none of the 95,813 readings. - 2026-10-18, #731 — THE CANDIDATE TEST IS A SUPERSET WITH ONE STATED EXCEPTION. SUPERSEDED by 2026-10-18 #731 reverted (PR #733) — the entry below; the full text is in git and in PR #732. - 2026-10-18 (Derek), #731 reverted — the #731 entry above is superseded: `trailing_candidates` takes no case fact again and is a superset of what `tail_reading` takes, without the exception. Its statement keeps naming S10. History: decisions/T4's revert entry. - 2026-10-19 #761 (skeleton migration) — the rule text no longer carries its history. Its Accepted bullet on a lone roman numeral after the marker closed "Decided with #601 (decisions/M2, 2026-10-04)"; that decision is the 2026-10-04 #601 entry here. The statement's exception for bracketed content ending in a period now stands in the delimiter Precedence bullet it qualifies, and the Accepted clause restating it stays an Accepted bullet, carrying its example. derek73-python-nameparser-456b024/docs/design/decisions/M3-marker-led-clause.md000066400000000000000000000300311527303271700271610ustar00rootroot00000000000000# M3 — the marker-led clause ## Standing - A bracketed clause whose content opens with a recognized marker and holds a word after it reads as the maiden name whichever bucket its pair sits in, under the default policy: `Jane Smith (née Jones)` → maiden 'Jones' — 2026-08-26 #335 (M3, PR on fix/335-maiden-marked-clause). - The clause is extracted, not unmasked into M2's bare take, so the closing delimiter bounds it: `Jane (née Jones) Smith` → maiden 'Jones' (given 'Jane', family 'Smith' under the default order), where the bare `Jane née Jones Smith` reads maiden 'Jones Smith'; S1 takes suffix-shaped content first, so `Jane Smith (née Jr.)` reads suffix 'Jr.' and no maiden name, `née` a name word (the family under the default order) — 2026-08-26 #335, the MECHANISM. - The condition is a word after the marker, not the marker alone, a marker with nothing after it marking nothing: `Jane Smith (née)` and `Jane Smith (Nee)` read nickname 'née' and 'Nee' by default, and `Jane Smith (Nee) (Jones)` nickname 'Nee Jones', so the division from M1 is one word against a marker plus a word — 2026-08-26 #335, the condition is a word AFTER. - The word taken after the marker is not tested for being a name word, the bracket being the clause's right boundary, so a suffix word M2's bare take declines is the maiden name here: `Jane Smith (née PhD)` → maiden 'PhD', where the bare `Jane Smith née PhD` reads suffix 'PhD' and no maiden name, `née` a name word (the family under the default order) — 2026-08-26 #335, accepted consequence. - The marker drop inside the clause is M1's mechanism and cites M1; rules/M3's `implemented:` names `_group.py` and `_vocab.py` beside `_extract.py` since #653 for its own comma clause, not for the drop — 2026-08-26 #335, rules/M3 lists. - `_maiden_marked` asks a whitespace-split question of the clause's first word where tokenize asks a token-level one, and that divergence is what keeps group's drop pass for a non-maiden clause reachable; since #653 it lives on through rules/T2's nakaguro, pinned by `cases.py::marker_divided_by_a_nakaguro_keeps_the_clause_a_nickname` (`佐藤 花子 "旧姓・田中" (旧姓 鈴木)` → nickname '旧姓 田中', maiden '鈴木'), so unifying the two tests orphans that filter — 2026-08-26 #335, the durable finding. - A rule that extends where a vocabulary claim acts re-asks C-i of the whole set it reads; M3 made the marker vocabulary act on the first word of any bracketed clause, and the re-ask removed `roz` (`Rosalind Roz Smith` reads no maiden name, middle 'Roz' under the default order) — 2026-08-26 #335, M3 makes. - Declaring `maiden_delimiters` is a declaration about the data, still needed for markerless and one-word clauses: `Cherice J. (Johnson) Williams` reads nickname 'Johnson' by default — 2026-08-26 #335, what configuring. Elsewhere: a marker followed by a comma, standing alone or glued to either word, is no marker (`Jane (née , Jones) Smith` → nickname 'née , Jones') — decisions/N1's 2026-10-12 #653 entry; the delimiters trumping every reading inside the clause (`Jane Doe (nee Smith MA)` → maiden 'Smith MA') — decisions/M2's 2026-09-19 (Derek), #533 review — THE DELIMITERS TRUMP entry; the bare take's first-word floor, which reads `Jane Smith née V` as maiden 'V' — decisions/M2's 2026-10-04 (Derek), #601 entry; `roz`'s exclusion, now on the word (`uv run python tools/vocab/query.py --word roz`) — decisions/vocabulary-exclusions' Excluded (MAIDEN_MARKERS) block. Open: the fullwidth-colon marker (`旧姓:佐藤` arriving as one word) is decisions/M2's Open line for #317. ## Log - 2026-08-26 #335 (M3, PR on fix/335-maiden-marked-clause) — a bracketed clause whose content opens with a recognized marker word and carries a word after it reads as the maiden name, whichever bucket the enclosing pair sits in. The parse already had the information: "Jane Smith née Jones" gave maiden "Jones" while "Jane Smith (née Jones)" gave nickname "née Jones", because ( ) is a nickname pair by default and nothing looked inside it. The clause says "maiden" out loud, so the caller should not have to say it in Policy. - 2026-08-26 #335 — the MECHANISM, and the issue proposed the other one. Masking the delimiters and letting M2's bare-marker rule consume the content would avoid a second implementation, and it was rejected on measurement: the two readings agree on a trailing clause and diverge on an interior one. "Jane (née Jones) Smith" reads given "Jane", family "Smith", maiden "Jones" under extraction; masking turns it into the bare "Jane née Jones Smith", which reads maiden "Jones Smith" and family "" (measured 2026-08-26), because M2's take runs to the end of the name. The closing delimiter is a boundary the writer typed and masking throws it away. Extraction was also the SMALLER change — extract already emits Role.MAIDEN regions and group already drops the marker inside one (#329), so this is a role swap at the existing emit site plus one predicate, in the branch S1 has already declined. That placement is what keeps S1 first, but be exact about the mechanism, because the obvious mutation does not test it: hoisting the swap above the _suffix_shaped call changes nothing at all (measured 2026-08-26), since the S1 branch never reads `role` -- it masks the two delimiter spans and hands the content to the token stream, and the role it would have carried is never consulted. What inverts the precedence is giving M3 the clause first, an early `extracted.append((Role.MAIDEN, inner))` ahead of the S1 test: that reads "Jane Smith (née Jr.)" as family "Smith", maiden "Jr." instead of family "née", suffix "Jr.", and M3's own example line for it fails. So the precedence is pinned, and by that example rather than by the branch shape. - 2026-08-26 #335 — the condition is a word AFTER the marker, not the marker alone. The reason is M1's: a one-word clause keeps its word because Nee is an attested surname (Irish Ní/Nee, and a Chinese romanization), and a marker with nothing after it marks nothing. Be precise about what pins it, because the obvious answer is wrong: M1's own boundary line carries the maiden-parens annotation, so it asserts the CONFIGURED reading and passes either way — built and measured 2026-08-26, a len(words) > 0 predicate still gives it maiden "Nee". What the loosened predicate actually breaks, measured by mutation 2026-08-26, is M3's own boundary example ("Jane Smith (née)" → nickname "née") and tests/v2/cases.py::maiden_marked_clause_one_word_stays_a_nickname in both runners — three pytest failures — AND the 2.1.0 differential gate, which goes red with three unexplained names: "Jane Smith (Nee)", "Jane Smith (Nee) (Jones)" and "Jane Smith (née)". The gate is the stronger signal and the one to look at first. The middle name is why: loosened, it reads maiden "Nee" and nickname "Jones", splitting one bracket pair into the maiden field and the next into the nickname field, which is the direct contradiction of M1's "two enclosures read as one maiden name". Anyone re-litigating the second-word test should check those, not M1's line. So the real division of labour is NOT markerless versus marker-led, which is how it was first written into rules.md and had to be corrected at review: it is one word versus a marker plus a word. "Jane Smith (Nee)" is marker-led and still reads nickname "Nee" by default, maiden "Nee" only with the pair configured (both measured 2026-08-26). - 2026-08-26 #335 (its bare `Jane Smith née V` contrast superseded in part by #601, decisions/M2, the bare take now reading maiden 'V' too; rules/M3's Accepted witness is `(née PhD)` since #762, the V row standing in the main fence) — accepted consequence: the word taken after the marker is not tested for being a name word, so M3 does not stop at a suffix word where M2's bare take does. "Jane Smith (née V)" reads maiden "V" while the bare "Jane Smith née V" reads suffix "V" and family "née" (both measured). The reason is structural rather than an oversight: M3 hands the WHOLE clause to Role.MAIDEN and the bracket is its right boundary, so the trailing-suffix rule never sees the word as trailing the name at all. Pinned as M3's Accepted line in rules.md, which puts "Jane Smith (née V)" in the differential corpus and classified in all three ledgers. - 2026-08-26 #335 (its `_extract.py`-alone list superseded in part by #653, decisions/N1, which put M3's comma clause in `_group.py` and `_vocab.py`) — rules/M3 lists implemented: nameparser/_pipeline/_extract.py alone, and that is deliberate rather than an omission. M3 decides a REGION'S ROLE; the marker drop it describes ("the marker itself dropped, as M1 drops it") is M1's mechanism in _group.py, which cites M1 and is governed by it. Adding _group.py to M3's list would mean inventing an M3 citation there for behavior M1 already owns — and test_doc_citations requires implemented: to equal the set of modules that cite the rule, so the two would have to move together. Do not re-open this. - 2026-08-26 #335 (its comma witness superseded in part by #653, the note at its end) — the durable finding, and the one worth reading before touching either side: _maiden_marked asks a WHITESPACE-SPLIT, _normalize'd question of the clause's first word, while tokenize and classify ask a token-level one. _normalize strips a trailing period but not a comma, so 'née,' is not a marker to M3 while the tokenizer splits the comma off and still tags the token vocab:maiden-marker. That divergence is the only thing keeping the "role is not Role.MAIDEN" branch of _group.group's clause-scoped drop pass reachable at all: without it, a marker-led NICKNAME clause could no longer exist, since M3 would have converted every one to MAIDEN before group ran. Anyone "unifying" the two tests would orphan that filter silently. Its pin was tests/v2/cases.py::marker_glued_to_punctuation_keeps_the_clause_a_nickname, with a maiden clause beside the nickname one because the drop pass is gated on the name holding a maiden region at all. The row that used to pin the filter, maiden_marker_delimited_beside_a_nickname_clause, lost the job to M3: both its clauses are marker-led, so both are maiden now and there is no contrast left in it. SUPERSEDED IN PART 2026-10-12 by #653: a clause's comma now stays on its word, so 'née,' is no marker to the tokenizer either and the comma row no longer reaches the branch; the divergence lives on through rules/T2's nakaguro, and the pin is cases.py::marker_divided_by_a_nakaguro_keeps_the_clause_a_nickname (see #N1's #653 bullet). - 2026-08-26 #335 — M3 makes the marker vocabulary act in a THIRD position. M2's claim acts on the word after a name word; M1's on the content of a configured pair; M3's now on the first word of any bracketed clause, under the default policy. #vocabulary-collisions C-i is keyed on the position a claim acts on, so extending where a rule acts re-asks C-i of the whole set it reads, and nothing in the process prompts that — the rule was designed, reviewed and approved without the set being re-examined. Review afterwards found one entry that fails C-i, the Czech abbreviation roz, and its disposition is the Excluded (MAIDEN_MARKERS) block in decisions/vocabulary-exclusions, which is keyed to the vocabulary set rather than to any rule: it was removed. Two things worth separating, since the finding surfaced during this work and is easy to file under it — the defect was M2's and predates #335 (bare "Rosalind Roz Smith" lost its family name on master), and M3's own boundary already excluded the common one-word "(Roz)" spelling. What belongs to M3 is only the widening, and the lesson is the general one: re-ask C-i of a vocabulary set whenever a rule extends where its claim acts. - 2026-08-26 #335 — what configuring maiden_delimiters still buys, since M3 removes the commonest reason to reach for it: markerless clauses, and one-word clauses. "Cherice J. (Johnson) Williams" is a real US convention and a corpus name, and nothing in that clause says maiden, so it stays a nickname by default — only a caller who knows their data can say otherwise. The knob is a declaration about the DATA; M3 is a reading of what a clause declares about itself. M4-maiden-marker-makes-the-lone-name-word-the-family.md000066400000000000000000000401161527303271700350620ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/decisions# M4 — a maiden marker makes the lone name word the family ## Standing - A maiden name beside exactly one name word makes that word the family name, under every name order: `Smith née Jones` → family 'Smith', maiden 'Jones', where through 2.1 it read given 'Smith' with the family empty. The rule is H1's sibling, so a titled name is H1's at both outcomes (`Dr. Jane née Smith` → family 'Jane'; `Sir John née Jones` keeps given 'John') — 2026-08-27 #445 (M4, PR on fix/445-maiden-marked-lone-name). - O4 decides nothing at one name word, so the reading M4 replaces is O5's convention, a guess fixed in advance, and M4 is an exception to it rather than a contradiction of it — 2026-08-27 #445, O4 was SILENT. - M4 changes only what position decided: a word the vocabulary claims as a given name keeps the reading its position gives it (`abd née Jones` → given 'abd' under the default order, family under either family-first order), and so does a word read as an initial (`J. née Jones Smith V` → given 'J.' by default), both read off tags classify recorded — 2026-08-27 #445, the two carve-outs. - M4 counts what the take leaves, not where the marker stood, so the bracketed spelling restores 1.4.0's family (`Smith (née Jones)` → family 'Smith', maiden 'Jones') and the interior spelling is a reading no earlier version gave (`Jane née Jones Smith` → family 'Jane', maiden 'Jones Smith') — 2026-08-27 #445, the two halves. - A nickname holds nothing off: where N3 declines, its count not setting a suffix aside, M4 names the family (`'Smitty' Jones née Smith Jr.` → family 'Jones', suffix 'Jr.', maiden 'Smith') — 2026-08-27 #445, a precedence claim. - `tests/v2/test_parser.py::test_a_maiden_clause_changes_nothing_else` asserts M4's flip over the corpus rather than skipping the names it moves: family takes what given held and every other field stands still — 2026-08-27 #445, the corpus-wide. - M4 is keyed on the maiden NAME, not on a marker, so a clause in a declared pair counts as a marked one does: `Smith (Jones)` under `Policy(maiden_delimiters=frozenset({("(", ")")}))` → family 'Smith', maiden 'Jones' — 2026-08-27 #445 (review round), M4 is keyed. - M4 counts GIVEN tokens where H1 tests which roles are empty, so a joined name unit counts as its words and the two disagree, recorded as a gap and left open: `Dr. Dean of Chemistry` → family 'Dean of Chemistry', while `Dean of Chemistry née Jones` keeps given 'Dean of Chemistry' — 2026-08-27 #445 (review round), a known gap. Not standing: the ledger markers the entry corrected, that ledger having been retired in #664 — 2026-08-27 #445, what the ledger's; the re-measurement of #449's population the entry promised, taken in decisions/O5's #449 entry — 2026-08-27 #445, the relationship to #449. Elsewhere: a marker counts only behind a surname, so `'Smitty' Jones Jr. née Smith` takes no clause and reads no maiden name (given 'Jones', middle 'Jr. née', family 'Smith' under the default order) — decisions/M2's 2026-10-04 (Derek), #601 entry; the `initial` tag that carve-out reads is no longer purely a shape claim — decisions/P8's 2026-09-13 #383/#479, AMENDS entry; a one-case bare letter is the initial its capital is (`j nee smith` → given 'j' under the default order) — decisions/T4's 2026-10-18, #723 entry; the maiden name no longer making H1 decline — decisions/H1's 2026-08-25 (#410) entry; the report a lone name word with nothing to decide it carries — decisions/O5. Open: none in this file. ## Log - 2026-08-27 #445 (M4, PR on fix/445-maiden-marked-lone-name) — Derek's rationale, in his terms: normally if there is only one name we assume it is a given name because we have to pick one, but maiden markers mark a previous surname before marriage, so it would not make sense to include one if there was no other surname for it to clarify — and given that, when a marker leaves only one name word we switch the assumption and read that word as the family name. `parse("Smith née Jones")` reported given 'Smith' with an EMPTY family through 2.1 and reads family 'Smith', maiden 'Jones' since. The rule is written in post_rules as H1's sibling and placed directly under it, so the interaction is decidable by reading rather than by running: where H1 fired there is no GIVEN left for M4 to move, and where H1 declined because the title addresses by given name ("Sir John née Jones") the `not titles` guard keeps M4 off the same word. A titled name is H1's at both outcomes, which is what keeps H1's given-name-title carve-out intact. - 2026-08-27 #445 — the third time in the 2.2 bundle that a name carrying a maiden clause reported no family, and the three share a SYMPTOM rather than a cause. State that carefully, because the first draft of this entry claimed one cause for all three and was wrong. #410 (H1): a title plus one name word reported no family the moment a suffix, nickname or maiden name stood beside it. #411 (P5's reserve): the bound given-name join counted the marker and the maiden name as words it could spend, and spent the family name. Those two ARE one cause -- a count that included words which are no part of the name -- and the grep that would find a fourth is a rule counting name words without first asking which of them are name words. #445 is not that: it changes no count anywhere (post_rules gains a new block, assign gains only a comment), and `parse("Smith")` with no clause beside it at all already read given 'Smith' with an empty family, so nothing was being swallowed. Its cause is the one the next entry gives: O4 decides nothing at one name word, and the reading came from a convention nobody had written down. The site that grep would miss is `_name_positions`'s `count == 1` branch, silent for every order and every name -- which is exactly what O5 now records. - 2026-08-27 #445 — O4 was SILENT at exactly one name word, which is why the shipped reading had no rule to point at and why O5 now exists. O4 reads a name by comparing where its words stand ("the first name word is the given name, the last is the family name"); with one word the first IS the last, so there is nothing to compare and the rule decides nothing. O5 records the reading as the convention it is — a guess fixed in advance so that the same input always reads the same way, not a determination about the word — and names H1, N3 and M4 as the rules that DO decide such a name. Written that way deliberately: a rule asserting "a lone name word is the given name" as a fact would have made #445 a contradiction of the documented behavior instead of an exception to a documented guess, and would make #449 one too. - 2026-08-27 #445 — the two carve-outs are not inventions, and both rest on mechanisms.md#TWO-LAYER-ASSIGN: the positional layer never overrides a vocabulary claim, and M4 changes only what POSITION decided. A word the vocabulary claims as a given name keeps that reading ('abd née Jones' — `vocab:bound-given`), and so does a word written as an initial ('J. née Jones Smith V' — the `initial` shape tag; amended 2026-09-13 under P8: the tag is no longer purely a shape claim). Both are read off tags classify already recorded rather than off a predicate of M4's own, and both witnesses are real corpus names rather than constructed cases, so each carve-out has something in the differential that would notice its loss. Mutation-checked on a scratch copy: dropping the `initial` carve-out fails that name's case row, its facade twin, M4's own boundary example and the pre-existing assertion in test_the_chain_and_the_walk_stop_where_the_peel_begins; dropping `vocab:bound-given` fails the other name's two runners, M4's boundary, P5's own doc example, O5's own `abd née Jones` line and the corpus-wide maiden-clause property on 'abdul' -- six in all, the O5 line having been added by the review round, and the list is exhaustive as re-run against the final tree. - 2026-08-27 #445 — the two halves of Derek's answer differ in their relationship to 1.4.0, and a reader should not have to re-derive it. The bracketed spelling RESTORES v1: `Smith (née Jones)` read family 'Smith', nickname 'née Jones' on 1.4.0, 2.0.0 and 2.1.0 alike, and now reads family 'Smith', maiden 'Jones' — the clause changes hands and the family name stays put, so the 1.4.0 gate's diff on that name SHRANK to {nickname, maiden}. The interior spelling is a NEW reading: `Jane née Jones Smith` read first 'Jane', middle 'née Jones', last 'Smith' on 1.4.0 and reads family 'Jane', maiden 'Jones Smith' now, the real surname being inside the maiden value by M2's greedy take. That the marker stands inside the name changes nothing for M4, which counts what the take LEAVES rather than where the marker stood — one name word left that way is one name word, which is the widest half of the decision and the half no earlier version agrees with. - 2026-08-27 #445 (its `'Smitty' Jones Jr. née Smith` witness superseded in part by #601, decisions/M2, a marker behind a credential being an ordinary word) — a precedence claim corrected while drafting, and it is exactly the class this bundle keeps producing. The first draft of M4 said a name carrying a nickname is N3's rather than this rule's. Measured false: `'Smitty' Jones Jr. née Smith` reads family 'Jones', because N3's count does not set a suffix aside, so N3 declines and M4 fires. A nickname holds nothing off — where N3 has already named the family M4 finds nothing left to move, and where N3 declined M4 names it. The general lesson is the one #410 and #411 already taught from the other side: a rule that counts name words and a rule that counts something else will disagree at the edges, and the disagreement is only visible if the interaction is measured rather than asserted. - 2026-08-27 #445 — what the ledger's `# revisit when #445 lands` markers bought, and what they got wrong. Four blocks carried them, and each predicted that a "keep the family" fix would leave the diff a subset of the fields its rule already declared, so the rule would go on explaining the name and the change would be absorbed with the gate green. That was wrong in a way the rule's own shape settles: M4 MOVES the one name word rather than adding one, so `given` empties as `family` fills, the diff outgrows every declaration, and eight names arrived UNEXPLAINED at all three baselines. Two counts run through this and they are different sets, so name which is which: NINE corpus names change reading, of which eight arrive as new diffs — the ninth, 'Smith (née Jones)', has its 1.4.0 diff SHRINK instead, its family agreeing with v1 again. The four markers between them named NINE names to re-measure, of which seven moved: the two that did not are M4's carve-outs, and the two movers the markers could not name were not corpus names until this change's own examples added them. The markers earned their keep all the same — they named the right rule, the right field and very nearly the right set — so they are corrected in place rather than deleted, and the correction says what actually happened. THREE of the four moved in the opposite direction from their own prediction, and getting that number right took two passes. The fix(#335) rule for 'Smith (née Jones)' appears in all three ledgers, and every copy feared absorbing a WIDENED diff while the diff in fact SHRANK to {nickname, maiden}. The first pass narrowed only the 1.4.0 copy, on a measurement of that wheel, and left the other two at four fields on the reasoning that 2.0.0 and 2.1.0 'read `given`, and the baseline cannot move'. They did not: only the BARE spelling ever read `given`, and this rule holds the bracketed one, which reads family 'Smith', nickname 'née Jones' on every released version. So two rules stood over a two-field diff declaring four, and classify's subset test would have explained a `given`/`family` regression on that name in silence -- the absorption those very notes existed to prevent, surviving the round that answered them. A third reviewer found it by instrumenting the comparator to dump real diff-field sets, and that is the lesson worth keeping: ledger prose cannot be checked by reading it, and the round that corrects a marker is exactly where a wrong premise gets written down with confidence. - 2026-08-27 #445 (its `'Smitty' Jones Jr.` witness and the count of fourteen superseded in part by #601, decisions/M2) — the corpus-wide property test is the repair worth copying. tests/v2/test_parser.py's `test_a_maiden_clause_changes_nothing_else` asserts over the corpus that appending " née Jones" adds a maiden name and moves no other field, and M4 falsifies it for fourteen names. Skipping them would have bought a green suite and lost the check; instead the test computes M4's guard from the base parse's tokens and asserts the flip — family takes what `given` held, every other field standing still — so the class that used to be a hole is now the strongest witness the rule has, fourteen names against the six rows cases.py carries. It is also what executes M4's nickname precedence, `'Smitty' Jones Jr.` being one of the fourteen. - 2026-08-27 #445 (review round) — M4 is keyed on the maiden NAME, not on a marker, and the statement was corrected to say so after two reviewers measured the same defect independently. The guard tests for a token in the MAIDEN role, and M1's caller-configured pair produces one with no marker anywhere: under `Policy(maiden_delimiters=frozenset({("(", ")")}))`, `Smith (Jones)` reads family 'Smith', maiden 'Jones', and so do M1's own boundary examples `Smith (Nee)` and `Smith (z domu)`. The BEHAVIOUR is right — the rationale transfers to a declared pair without a word of change, since what announces a former surname is the clause, not the vocabulary that marked it — so the fix was to the rule, which had said "a recognized maiden marker that takes its name and leaves exactly one name word" and now says "a maiden name standing beside exactly one name word". That covers M1, M2 and M3 uniformly, M1 is in M4's `interacts:` and M4 in M1's, and the configured path has an example line of its own. The general lesson is the one this bundle keeps teaching from a new angle: a statement written from the case that prompted the change describes that case, not the code, and only measuring the OTHER paths into the same guard finds the difference. - 2026-08-27 #445 (review round) — a known gap recorded rather than closed, the way H1 carries its own. M4's guard counts GIVEN tokens; H1 counts nothing at all (it tests which roles are unoccupied). So a name word another rule has joined counts as several here and M4 declines where H1 fires: `Dr. Dean of Chemistry` reads family 'Dean of Chemistry' while `Dean of Chemistry née Jones` keeps given 'Dean of Chemistry', the connective join (P3) having left three GIVEN tokens. rules/P3 says the joined part is one name word wherever another rule counts them, so the two siblings genuinely disagree and this is a gap rather than a boundary. Not closed here: widening the count to units moves zero corpus names (measured), and a behaviour change nobody has approved does not belong in a branch whose blast radius was settled. The sibling claim in the code comment and in this entry is narrowed to match — sibling except in what it counts. - 2026-08-27 #445 — the relationship to #449, briefly, because the two look like the same question and are not. #449 asks whether a lone name word with nothing to decide it should REPORT an ambiguity; #445 removes a class of names from that population by giving them something that decides. So #445 reduces #449's reach rather than competing with it, and #449's measurement — a naive "one name word, nothing decided it" condition fires on 19 of 1,085 corpus names, only three or four of them the case it is about — is re-taken after this lands. O5 cites #449 in prose and deliberately carries no `deviates:` marker: that marker asserts an intended output the runner then checks strictly, and #449 had not decided one. It has since — 2026-09-07, decisions/O5 — and the answer arrived as ordinary example lines rather than as a marker, the report being shipped behavior and not a deviation. The re-measurement this bullet promises is taken there: the naive condition is wider than either number suggests, firing on 143 of the 1123 corpus names, and O5's own population is 27. derek73-python-nameparser-456b024/docs/design/decisions/N1-default-delimiter-pairs.md000066400000000000000000000177261527303271700304170ustar00rootroot00000000000000# N1 — the default delimiter pairs ## Standing - The halfwidth corner brackets `「」` are a default nickname pair, with the matching v1 key `halfwidth_corner_brackets` (a v1 `Constants` restored from a pickle keeps the keys it was saved with), and like every pair they are not script-gated: `山田 「タロー」 タロウ` reads family '山田', given 'タロウ', nickname 'タロー', and `John 「Jack」 Smith` nickname 'Jack' — 2026-10-03 #597. - A comma inside a delimited clause is word text and divides nothing: `Jane (Janey, Jo) Smith` reads nickname 'Janey, Jo', and a punctuation-only clause reads as its punctuation, a nickname rules/N3 counts (`Jones (,)` → family 'Jones', no report) — 2026-10-12 #653. - A comma after a maiden marker unmarks it, standing alone or glued to either word, asked through one predicate by M3 and M1's drop: `Jane (née , Jones) Smith` reads nickname 'née , Jones'; dropping the comma with the marker was declined, and a trailing comma rides as written (`Jane (née Jones,) Smith` → maiden 'Jones,') — 2026-10-12 #653. - Accepted: a whitespace run inside a clause collapses to one space, and the v1 `nickname_list` keeps one element per word, the comma riding on its word — 2026-10-12 #653. Elsewhere: the typographic pairs themselves (#273) and the leftmost-match scan that reads them are decisions/N2's 2026-07-18 entries; the curly single quotes' absence from the default pairs is decisions/vocabulary-exclusions'. Open: none in this file. ## Log - 2026-10-03 #597 — THE HALFWIDTH CORNER BRACKETS ARE A DEFAULT NICKNAME PAIR. `「」` (U+FF62/U+FF63) join `DEFAULT_NICKNAME_DELIMITERS` beside `「」`, and the v1 facade gains the matching `halfwidth_corner_brackets` key, so `HumanName` reads them as `parse()` does. They are the same punctuation as `「」` in JIS X 0201's narrower encoding, the legacy data halfwidth katakana comes from (#594), where they were the only corner brackets available, and they appear in no non-CJK text. Before this every release kept the clause in the name, brackets and all — a middle where it stood spaced (`山田 「タロー」 タロウ`), part of one given word where it was glued (`山田「タロー」太郎`) — and since #594 the bracket also cost the spaced name its order: the halfwidth kana now classify, but the unclassified bracket kept `山田 「タロー」 タロウ` from being wholly East Asian script, so it read given 山田 (in released versions the unclassified kana did that on their own). A v1 `Constants` restored from a pickle keeps the keys it was saved with and so does not gain the pair, the same policy #273's pairs followed: restored configuration is never widened behind the caller's back. A pair is not script-gated, so `John 「Jack」 Smith` reads nickname Jack as `John 「Jack」 Smith` does. Nothing else needs a halfwidth twin: Unicode has no halfwidth form of `『』` or of the fullwidth parentheses. MEASURED 2026-10-03: no corpus line held a halfwidth bracket before this change, so the three `fix(#597)` case rows are the whole population, and they move at every baseline. RECOMPUTE: parse `山田 「タロー」 タロウ`, `山田「タロー」太郎` and `John 「Jack」 Smith` here and on each baseline from PyPI and read `nickname` beside the name fields. - 2026-10-12 #653 — A DELIMITED CLAUSE KEEPS ITS COMMA. `Jane (Janey, Jo) Smith` reads nickname 'Janey, Jo' again, as 1.4.0 did, where 2.0.0 through 2.4.0 gave 'Janey Jo'. The comma was lost in tokenize, not in the view: `_tokenize_region` serves the main stream and the extracted regions alike, and treated `COMMA_CHARS` as a separator in both, although only the main stream records a segmentation offset for it -- inside a region it divided the clause into words and recorded nothing, so the view's space join had no comma left to keep. It shipped that way with the first tokenizer (7e341a95, 2026-07-12) and no entry chose it; `;`, `/` and `&` were always kept. Now a comma separates only where it records an offset, so inside a clause it is word text on the word it touches (`Janey,`), and the view's space join restores the writer's spacing (`Janey,Jo`, `Janey , Jo` and `,Jo` all read as 1.4.0 read them). WHAT ELSE MOVED, all by the same token change: a marker glued to a comma is no marker inside a clause, as it already was in the main stream (`Jane Smith née, Jones` reads no maiden), so under `maiden_delimiters` `Jane (née, Jones) Smith` keeps maiden 'née, Jones' where the dropped comma had let the drop pass take 'née'; and a phrase marker divided by a comma inside a clause no longer matches, which the M Background already stated (`Maria Kowalska (z, domu Nowak)` keeps maiden 'z, domu Nowak' under `maiden_delimiters`; rules/M1 now pins it). A punctuation-only clause reads as its punctuation (`Jane (,) Smith` → nickname ','), as `(;)` and `(-)` already did and as 1.4.0 did — and a name holding one now has a nickname for rules/N3 to count, so `Jones (,)` reads family 'Jones' with no ambiguity report where 2.x read given 'Jones', reporting GIVEN_OR_FAMILY since #449 in 2.3 (1.4.0: last 'Jones', nickname ','; the same for `(,) Jones` and `Jones ","`). A COMMA AFTER A MARKER UNMARKS IT (rules/M3, decided in review 2026-10-12): once the clause kept its comma, a spaced one after the marker (`Jane (née , Jones) Smith`) passed M3's whitespace test, since ',' was a word after the marker, and the drop pass then took the marker and left maiden ', Jones'; `(née , ) Smith` gave maiden ','. Rather than read the spaced comma as glued `née,` is not, M3 and M1's drop now ask the one predicate `_vocab.comma_follows_marker` of the word after the marker run, so a comma standing alone or glued to either word unmarks it: `Jane (née , Jones) Smith` reads nickname 'née , Jones', which is 1.4.0's reading, where 2.2 through 2.4 read maiden 'Jones'; under `maiden_delimiters` the clause keeps its marker, `(née ,Jones)` reading maiden 'née ,Jones'. Declined: dropping the comma with the marker, which restores 2.4's maiden 'Jones' for the spaced comma while the glued `(née, Jones)` stays a nickname, so the two spellings would disagree. A trailing comma rides as written (`(née Jones,)` → maiden 'Jones,'). The two sites hand the predicate different strings — extract a whitespace word, group a token — so it reads past any leading character that is neither a comma nor a letter or digit: a second review found `Jane (née \u200f, Jones) Smith`, visually the spaced-comma name, read maiden 'née , Jones' while it tested only the first character, M3 seeing the right-to-left mark tokenize drops and group the bare comma (the same for an emoji or a nakaguro before the comma). cases.py's marker_followed_by_a_comma_behind_a_bidi_mark_is_no_marker pins it. ACCEPTED: a whitespace run inside a clause still collapses to one space (1.4.0 kept 'Janey,Jo Bo' verbatim), and the v1 `nickname_list` stays one element per word as it has been since 2.0 (1.4.0 gave one per clause), the comma now riding on its word (`['Janey,', 'Jo']`) as `;` always did. THE DROP-PASS WITNESS MOVED: `marker_glued_to_punctuation_keeps_the_clause_a_nickname` had been the only case row reaching the `role is not Role.MAIDEN` branch of `_group.group`'s drop pass, and it reached it through the dropped comma; `marker_divided_by_a_nakaguro_keeps_the_clause_a_nickname` (`佐藤 花子 "旧姓・田中" (旧姓 鈴木)`) reaches it through rules/T2's nakaguro instead, and is now the one row that fails with the branch removed (measured 2026-10-12). MEASURED 2026-10-12: before the rules.md examples landed no corpus name held a comma inside a clause, and the gate at 2.4.0 read 0 diffs with the fix applied; with them, the two examples are the 2 intentional diffs at each of 2.0.0 through 2.4.0 under the `fix(#653)` rule, and parity at 1.4.0. The witness row joins `corpus_cjk.jsonl` and diffs at 1.4.0, 2.0.0 and 2.1.0 only, classified under the #335 rules there (a new literal-keyed `fix(#335/#272)` rule at 1.4.0, one more literal in the existing #335 rules at 2.0.0 and 2.1.0); every baseline's gate exits 0. N2-same-character-quotes-and-shared-character-conventions.md000066400000000000000000000041051527303271700363060ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/decisions# N2 — same-character quotes and shared-character conventions ## Standing - The typographic delimiter pairs ship as default delimiters — 2026-07-18 #273. - Conventions that share a character are read by one interleaved leftmost-match scan, the leftmost valid opener winning (`Hans „Erster“ und “Zweiter” Müller` → nickname 'Erster Zweiter'), and a dangling-open report on a character inside another pair's match is filtered out — 2026-07-18 (05fe693, the same day) - The word-internal carve-out covers only the straight apostrophe, the one delimiter character real names carry mid- and end-of-word: `Mari' Aube'` reads family "Aube'" with no report, while `Mari" Aube"` reports `unbalanced-delimiter` twice — 2026-07-18 (05fe693, the same day) Open: none in this file. ## Log - 2026-07-18 #273 (7ee6e3a) (its per-pair scan superseded the same day by the 05fe693 entry below) — the typographic delimiter pairs shipped with a per-pair full-text scan; three independent review agents broke it the same day: with two shared-character conventions genuinely present, the earlier-sorted pair stole the close and the legitimate match dropped silently, zero ambiguity. - 2026-07-18 (05fe693, the same day) — the interleaved leftmost-match restructure replaced it (an altitude review called it the right depth); the author's own first fix — post-hoc offset filtering of unbalanced candidates alone — was superseded within the day and survives only as the bulk-recorded dangler filter rules/N2 now names. The word-internal carve-out covers ONLY the straight apostrophe because it is the one delimiter character that occurs mid- and end-of-word in real names; Derek's distinguishing test: with double quotes the same shape ("Mari\" Aube\"") is genuinely ambiguous, while Mari' Aube' is not. A cached delimiter-charset prescreen landed in the same commit, measured: no-delimiter names parse faster than the pre-#273 baseline. Declined: - The offset-filter as the whole fix — it repaired the reported symptom (spurious unbalanced flags) while leaving the stolen- close mis-extraction in place. derek73-python-nameparser-456b024/docs/design/decisions/N3-lone-word-nickname-rule.md000066400000000000000000000034731527303271700303350ustar00rootroot00000000000000# N3 — the lone-word nickname rule ## Standing - The rule fires only where the nickname accompanies exactly one piece in total, decided in assignment where the count is settled, and a title counts against it: `'Smitty' Jones` reads family 'Jones', while `'Smitty' Dr. Jones` reaches H1, which since #410 names the family, so it reads family 'Jones' too — 2026-07 PR #288. Not standing: 2026-10-19 #761, the skeleton migration, carried out. Elsewhere: the bare spelling's one name word reads as the family name by M4, which reaches the bracketed and the bare spelling alike, decisions/M4's 2026-08-27 #445 entries; a punctuation-only clause is a nickname this rule counts (`Jones (,)` → family 'Jones'), decisions/N1's 2026-10-12 #653 entry. Open: none in this file. ## Log - 2026-07 (v2 core, PR #288; recorded plan deviation #2 of the core plan) — v1's rule counted pieces before grouping; the v2 port fires only when the nickname accompanies exactly ONE piece in total — a title counts against it, so "'Smitty' Dr. Jones" reaches H1 with a title and one name word left standing — through 2.1 that meant given="Jones" with the family empty, and since #410 (2026-08-25) H1 names the family, so it reads family="Jones". The count is unchanged; what moved is what happens after it declines. The rule lives in assignment because that is where the piece count is settled. (An earlier wording here said "one non-title piece", predicting the opposite output; the coherence review measured the truth.) - 2026-10-19 #761 (skeleton migration) — the rule text no longer carries its history. Its last Accepted bullet said the bare spelling's one name word had been read as the family name "since #445": that date belongs to M4, which arrived on 2026-08-27 and reaches the bracketed and the bare spelling alike (decisions/M4's #445 entries). derek73-python-nameparser-456b024/docs/design/decisions/O1-east-slavic-rotation.md000066400000000000000000000210601527303271700277360ustar00rootroot00000000000000# O1 — East Slavic rotation ## Standing - East Slavic handling is opt-in, the shape v1 set in PR #154 because unconditional detection breaks Latin names whose endings collide (the log's undated first line), and v2 kept the rotation at v1 parity, rebuilding position from assigned roles: `Сидоров Иван Петрович` under `locales.RU` → family 'Сидоров', given 'Иван', middle 'Петрович' — 2026-07-12 PR #288. - The rotation is scoped to the default order by a gate on the order assign read: under FAMILY_FIRST `Мицкевич Адам Юзеф` reads family 'Мицкевич', given 'Адам', middle 'Юзеф' as plain FAMILY_FIRST does, and the natural-order `Иван Петрович Сидоров` reads family 'Иван', given 'Петрович', middle 'Сидоров', an accepted consequence pinned in tests/v2/test_locales.py rather than in rules/O1's examples — 2026-09-07 #384. - Re-deriving the order from the patronymic trace under a declared family-first order, and reporting where the trace and the declared order disagree, are declined — 2026-09-07 #384. Not standing: 2026-08-15; the rotation and name-order question it left riding the order-constants issue, which closed without answering it, answered by 2026-09-07 #384. Open: none in this file. ## Log - (v1 era, PR #154) — why patronymic handling is OPT-IN at all: unconditional detection breaks ordinary Latin names whose endings collide (Martin, Franklin, Benjamin), the finding that forced v1's Russian work behind a flag and set the opt-in shape v2 inherited. - 2026-07-12 (landed in the v2 core, PR #288) — v1 parity pinned live: the rotation reconstructs token position from assigned roles, which is faithful to v1 only under the default given-first order. - 2026-08-15 — the rotation × non-default name_order interaction question rode #270, which closed 2026-07-28 with the order constants and no recorded answer for the rotations. - 2026-09-07 #384 — OPTION 1: the rotations are scoped to the DEFAULT order, and a gate on the read order is what makes that true. The principle is decisions/P1's 2026-09-07 entry and rules.md's Name order Background — a name order is a property of the data source, the caller declares it, the declaration governs what no vocabulary has claimed, and a declared family-first order outranks the shape. This rotation exists to RESTORE the default reading from a family-first listing, which is the whole of its job, so a caller who has DECLARED a family-first order has already supplied what it would have inferred and position decides instead. The first draft of this entry said the scoping was by CONSTRUCTION and there was nothing to implement, on the reasoning that the rotation asks whether the word standing in the FAMILY position carries a patronymic ending and only the default order puts a name's last word there. That is false, and measuring it is what found it: under a family-first order the family slot holds the FIRST word, and a patronymic-DERIVED surname standing first carries the same ending, so such a name reached the rotation and was rotated out of the reading the caller declared. Measured 2026-09-07 before the gate, `Мицкевич Адам Юзеф` under `parser_for(locales.RU, base=Parser(policy=Policy(name_order=FAMILY_FIRST)))` read family `Адам`, given `Юзеф`, middle `Мицкевич`, where plain FAMILY_FIRST reads family `Мицкевич`, given `Адам`, middle `Юзеф`; `Рабинович Иван Лев` read family `Иван` the same way. After the gate both read as plain FAMILY_FIRST does. The condition is on `state.order` — the order assign actually READ — rather than `policy.name_order`, for the reason decisions/P1's fold gives: a script_orders entry can override the policy, and the roles the rotation reads are the ones assign made. No corpus name moves at any of the four baselines, and sweeping the corpora under each pack with FAMILY_FIRST declared shows no difference either: they hold no three-word name whose FIRST word carries an East Slavic patronymic ending, and no four-word name whose FIRST word is a standalone Turkic marker (both counts 0 over the `tools/differential/corpus*.jsonl` union, measured 2026-09-07 by applying `_post_rules`'s own four patronymic patterns to the first word). The general lesson is docs/design/AGENTS.md's axis 10, re-run every capability claim — an option chosen because it needs no code is exactly where the no-code claim goes unmeasured. Two of the gate's three conditions have no possible witness and are recorded here instead: `read_order is None` cannot co-occur with a rotation shape, since assign leaves the order unrecorded only where it positioned at most one name word, and `state.order` differs from `policy.name_order` only through `script_orders`, whose scripts are East Asian and so cannot match the patronymic regexes. Unwitnessed by any test, on the precedent decisions/P6's 2026-08-30 #467 entry sets. Measured 2026-09-07 with `parser_for(locales.RU, base=Parser(policy=Policy(name_order=FAMILY_FIRST)))`. The family-first listing reads the same either way: `Сидоров Иван Петрович` gives family `Сидоров`, given `Иван`, middle `Петрович` under the default order by rotation, and the identical parse under FAMILY_FIRST by position. The divergence is on natural-order input — `Иван Петрович Сидоров` gives family `Сидоров` by rotation under the default order, and family `Иван`, given `Петрович`, middle `Сидоров` under FAMILY_FIRST. That row is ACCEPTED, not a defect: the caller said this source is written family-first and is being taken at their word. rules/O1 carries it as an Accepted consequence. O2 is the same decision on the Turkic marker and its 2026-09-07 entry says so. The shapes differ, which is why the two rules state their own consequences rather than sharing one: under FAMILY_FIRST `Ali Ahmad Vali oglu` reads family `Ali` exactly as the rotation would, and it is under FAMILY_FIRST_GIVEN_LAST that it reads given `oglu`, middle `Ahmad Vali` — the marker in a name field, which is O2's existing Accepted consequence reached by a second route. Both readings are pinned in tests/v2/test_locales.py beside O1's row (measured 2026-09-07). Where the row is pinned, and why not in rules.md. The example grammar admits ONE annotation, and `tests/v2/rules_doc.py`'s `resolve_annotation` resolves it to a policy, a locale or a gated locale — never a pair. Composite keys exist for other pairings (`family-first+middle_as_family`, `[ja+segmenter]`), but no registered annotation combines a pack with an order, so the two cannot be written on the same example line. Registering a combined policy would be a test change made inside a docs decision. #470 asked whether an example line should declare its format and was closed not-planned on 2026-09-02 — "no notation is needed. The Accepted clause is already the right home for this" — so prose in the rule is the decided home here, not a workaround. The parse is a `tests/v2/test_locales.py` row beside the pack's other rows, and rules/O1 states it in prose. Declined: - Option 2, suppressing the rotation and re-deriving the order from the patronymic trace under a declared family-first order — the declaration already carries the information this would recompute, and recomputing it means overriding the caller with a string-level guess, which is the principle above inverted. - Option 3, reporting an ambiguity wherever a patronymic trace disagrees with the declared order — the report would fire on natural-order input inside a family-first source, and the caller cannot tell that from a mis-declared record, which is the only thing the report would be for. It is NOT true that it would fire on every correct family-first parse: a family-first listing CARRYING THE TRACE reads the same under both orders — the rotation fires only where the last word carries the ending, and it then makes the first word the family exactly as position does — so the trace and the position agree there and nothing is reported. Scoped deliberately, the unscoped claim being false: `Сидоров Иван Лев` is a family-first listing with no trace, and it reads family `Лев` under the ru pack's default order against family `Сидоров` under FAMILY_FIRST (measured 2026-09-07). What is left is a signal on the one shape where the declaration is doing its job, indistinguishable from the shape where the declaration is wrong. A report that cannot be filtered on is the failure decisions/P6's 2026-08-29 entry records for a different rule; here it is avoidable by not adding it. derek73-python-nameparser-456b024/docs/design/decisions/O2-turkic-rotation.md000066400000000000000000000061641527303271700270350ustar00rootroot00000000000000# O2 — Turkic rotation ## Standing - Under Turkic handling a name of exactly four name words ending in a standalone marker reads family-first, at v1 parity: `Ali Ahmad Vali oglu` under `locales.TR_AZ` → family 'Ali', given 'Ahmad', middle 'Vali oglu'; any other count keeps the positional reading, the marker standing in a name field (`Ali Ahmad oglu` → family 'oglu') — 2026-07-02 PR #288. - The marker patterns are NFC literals with `qızı` a literal alternative, dotless ı not case-folding to i, so all-caps `QIZI` falls to the ASCII alternative and decomposed input does not match (`Ali Ahmad Vali oğlu` typed decomposed → family 'oğlu'); suffix-attached Kazakh and Uzbek patronymics (`Әбішұлы`) are out of scope — 2026-07-02. - The rotation stands down under a declared family-first order by O1's gate on the order assign read: `oglu Ahmad Vali Ali` under FAMILY_FIRST reads family 'oglu' as plain FAMILY_FIRST does, and `Ali Ahmad Vali oglu` under FAMILY_FIRST_GIVEN_LAST reads given 'oglu', middle 'Ahmad Vali' — 2026-09-07 #384. - Re-deriving the order from the marker under a declared family-first order, and reporting where the marker and the declared order disagree, are declined for decisions/O1's reasons — 2026-09-07 #384. Not standing: 2026-08-15, the same open interaction question as decisions/O1's, answered by 2026-09-07 #384. Open: none in this file. ## Log - 2026-07-02 (Turkic design; landed in the v2 core, PR #288) — shape fixed at exactly four name words (1 given + 2 middle + 1 marker), v1 parity; other shapes keep their positional reading even when that leaves the marker in a name field (see the rule's Accepted consequence). - 2026-07-02 (design, orthography coverage) — dotless ı does not case-fold to i, so qızı is a literal alternative in the pattern (re.I cannot bridge it; all-caps QIZI falls to the ASCII alternative); the patterns are NFC literals and NFD input does not match. Scoping decline: suffix-attached Kazakh/Uzbek patronymics (Әбішұлы) are bound-suffix morphology, not a standalone marker, and deliberately out. - 2026-08-15 — same rotation/name_order interaction status as O1. - 2026-09-07 #384 — OPTION 1, the same answer as decisions/O1's entry of the same date and on the same principle: the rotation restores the default reading from a family-first listing, so a declared family-first order supplies that reading instead and the rotation stands down. It stands down by the same gate on the read order, for the reason O1's entry records — the marker test reads the word in the FAMILY position, which under a family-first order is the FIRST word, so a name opening with the marker reached it. Measured 2026-09-07 before the gate, `oglu Ahmad Vali Ali` with Turkic handling and FAMILY_FIRST read family `Ahmad`; after it, family `oglu`, which is what plain FAMILY_FIRST reads and this rule's own 2026-07-02 Accepted consequence — the marker in a name field. The natural-order twin `Ali Ahmad Vali oglu` is decisions/O1's entry of the same date, which measures it under both family-first orders and pins it in tests/v2/test_locales.py. Options 2 and 3 are Declined for O1's reasons. O4-positional-assignment-and-declared-order.md000066400000000000000000000052061527303271700335570ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/decisions# O4 — positional assignment and declared order ## Standing - Romanized Chinese order is declared, not detected, pinyin carrying no signal and diaspora making locale no guide: a caller whose data is family-first passes `Policy(name_order=FAMILY_FIRST)` — 2026-08-07 #83. - There are three order constants because Vietnamese needs the third: `Nguyễn Thị Minh Khai` reads given 'Khai', middle 'Thị Minh', family 'Nguyễn' under FAMILY_FIRST_GIVEN_LAST, where FAMILY_FIRST strands given 'Thị' — 2026-08-07 #146. - Free-form order tuples are declined (#270's draft shape; the decline is the log's undated Declined line, which no dated entry records): a tuple of strings raises `name_order elements must be Role members` (rules/D2) and a two-role tuple `name_order must be one of the exported orders` — 2026-08-07 #146. - `middle_as_family` is declined as the way to drop Vietnamese middles, giving family 'Thị Minh Nguyễn' for `Nguyễn Thị Minh Khai` under FAMILY_FIRST_GIVEN_LAST; `given_names` is the view that stays correct — 2026-08-07 #146. Open: none in this file. ## Log - 2026-08-07 #83 — romanized Chinese order is answered by DECLARATION, not detection: pinyin carries no signal and diaspora makes locale no guide (the issue's own 2019/2021 conclusions); Policy(name_order=FAMILY_FIRST) is the answer the thread wanted. - 2026-08-07 #146 — Vietnamese needs the THIRD order constant: measured on "Nguyễn Thị Minh Khai", FAMILY_FIRST strands given="Thị" while FAMILY_FIRST_GIVEN_LAST reads given="Khai", middle="Thị Minh", family="Nguyễn". This is why three order constants exist rather than two. - Provenance: the three-constants argument originates in #270's own body (2026-07-07): "A boolean family_name_first flag was considered and rejected: Vietnamese order is [family][middle][given]." The 2026-08-07 #146 measurement below is the verification; #270 is the origin. Declined: - Free-form order tuples (('last','middle','first')-style, #270's original draft shape) — the shipped design is exported order constants with tuple rejection at construction (rule D2's [bad-name-order] example is the pinned message). - middle_as_family as the way to suppress the middle slot for Vietnamese (2026-08-07 #146) — measured: it merges the middles into the family, giving family="Thị Minh Nguyễn" for "Nguyễn Thị Minh Khai" under family-first-given-last — a plausible-looking wrong answer. There is no policy field that suppresses middle, and name_order rejects a two-role tuple ("name_order must be one of the exported orders"); given_names (given + middle) is the view that stays correct wherever the internal boundary falls. O5-lone-name-words-convention-and-reporting-it.md000066400000000000000000000324161527303271700341720ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/decisions# O5 — the lone name word's convention, and reporting it ## Standing - The one-word convention is reported as `given-or-family`, its `detail` naming the field it chose, and the report is the same under every order while the field follows it: `Andrew` → given 'Andrew' by default and family 'Andrew' under FAMILY_FIRST, both reporting — 2026-09-07 #449. - A script whose own convention settles the order decides the field and silences the report (`毛泽东` → family '毛泽东', no report), while a script rule declining leaves the convention to report (`マイケル`, `王·Smith`, `田中、太郎`, `Andersonさん`); a piece with no letter or digit is no name word, so `(` keeps only its unbalanced-delimiter report — 2026-09-07 #449. - A suffix or a nickname beside the lone word decides nothing about its field and silences nothing: `Smith Jr.` and `'Smitty' Jones Jr.` report — 2026-09-07 #449. - The report is narrowed to an input nothing else stands in: a word its own tag claimed (`abdul`, `de`, `J.`) and a word H1, N3 or M4 placed (`Sir John`, `'Smitty' Jones`, `abd née Jones`) report nothing — 2026-09-07 #449. - A title after a family comma silences the report, the title deciding the field: `John III, Dr.` → family 'John', `John III, Sir` → given 'John', neither reporting — 2026-09-08 #518. - The family-comma silencer stays a Then bullet with no example, its one input (`, Andrew` → given 'Andrew', no report) being no name anyone types — 2026-10-20 #761. Elsewhere: a maiden marker makes the lone name word the family, decisions/M4's 2026-08-27 #445 entries; a one-letter numeral behind one word is a name word since #718, so `John V` and `Mohamad X`, two of the #449 entry's nine, report `suffix-or-name` and no longer reach this site, decisions/S10; a bare letter in a one-case name is an initial and reports nothing (`j`), decisions/S10's 2026-10-18, #723 entry and rules/T4. Open: none in this file. ## Log - 2026-09-07 #449 (its reason for `マイケル` reporting, a wholly-katakana name read as a transcription, superseded by decisions/W4's 2026-10-03 #594: the script settles no order, transcription or not) — the convention is REPORTED, as `given-or-family`, with `detail` naming the field it chose. rules/O5 has said since #445 that the one-word reading is a guess fixed in advance rather than a determination; what #449 asked is what the library should SAY about it, and the answer is the one A1 gives everywhere else — a caller can only act on doubt that is reported. `detail` names the field rather than the kind naming it, because the field follows the read order and the kind does not: that is the PARTICLE_OR_GIVEN precedent, recorded at that kind's docstring. What the measurement settled was the POPULATION, not the principle. The naive condition — one name piece placed, nothing else looked at — fires on 143 of the 1123 distinct corpus names, because a title peel, a nickname, a maiden clause, a trailing credential run or a script order can each leave exactly one piece standing where the convention decided nothing. The 2026-08-27 #445 entry below records "19 of 1,085" for a differently written naive condition on a smaller corpus; both numbers are naive and neither is O5's. Guarded on O5's own carve-out list the population is 27 corpus names, 12 contract and 15 radar, and the other 116 report no `given-or-family`, 8 of them reporting `title-or-name` instead (H4's two branches are ahead of O5's at the same line). A first draft wrote 110, which is the subtraction done wrong. Recompute by wrapping `_name_positions` with a recorder that fires at `count == 1`, parsing the deduped `corpus*.jsonl` glob through it, and comparing that set against the names whose parse reports the kind; measured 2026-09-08 on this branch. The guard itself is the code, which is where it should be read. Two conditions are in the guard that O5's statement had not named, and both are in it now. A script whose own convention settles the order decided the reading, so W4 outranks this the way H1 does — that is what keeps 毛泽东, 高橋みなみ, 씨 and the rest of the script-ordered names out. Asking that question needed a shape change: `_effective_order` used to return the roles triple alone, and a caller comparing that triple against the declared `name_order` cannot tell a script rule DECIDING from a declaration standing unopposed — under a declared family-first order a Han name's W4 entry and the declaration agree. It now returns an `Order(roles, by_script)` and the emitter reads the flag. The second condition is content: a piece with no letter or digit in it is no name word, A2 empties such an input, and a convention report on an empty name would describe a reading nobody got — so `parse("(")` keeps its unbalanced-delimiter report and gains nothing. Five CJK-bearing names DO report, and none of them is a script rule deciding. Three are a script rule DECLINING: マイケル (W4 gives katakana no order of its own, a wholly-katakana name being read as a transcription), 王·Smith (T3's interpunct suppresses the script_orders lookup whole) and 田中、太郎 (the comma sits inside the token, so the text resolves to no single script). Two are a glued honorific peeled off a Latin stem — Andersonさん and Anderson선생님 — where what is left is Latin and has no script order at all. Recorded because "no CJK name reports" was the expectation going in, and it is wrong in a way that is right: where the script rules decline, what is left IS the one-word convention. Four conditions were drafted into the guard and none of them shipped, but they were dropped for TWO different reasons, and a first draft of this entry flattened both into "inert". Each was measured the same way: restore that one clause on the O5 branch of a `git archive` copy of this commit, reparse the deduped `corpus*.jsonl` glob, and count which of the 27 lose the report. Two are genuinely INERT, silencing ZERO names (measured 2026-09-08, and again after the 2026-09-08 review round below). `len(state.segments) == 1` is inert OVER THE CORPUS rather than by construction, and a first draft of this line gave the construction reason, which is false: a family comma that names a family does read segment 0 wholly as the family and never positionally, but a comma followed by NO name word hands segment 0 back to the positional read, and `Smith V, Dr.` reaches this very site that way. What silences it is not the clause but the title in segment 1 — after the review round below, which is what tells the read that a title stands there. `not flagged`: a split credential decides the field no more than a whole one does. The general shape is the one this file keeps recording — a guard written from the cases that prompted it carries terms the code already excludes — and the instrument that finds them is mutation, not reading. The other two were dropped DELIBERATELY, and the same mutation prices each. `not suffix_pieces` silences NINE of the 27 — `Smith Jr.`, `'Smitty' Jones Jr.`, `Carod i`, `Donald mc`, `Jack M.A.`, `John V`, `Mohamad X`, `Andersonさん`, `Anderson선생님` — and `not has_nickname` silences ONE, `'Smitty' Jones Jr.`. Both were dropped on one principle: a suffix or a nickname standing beside the lone name word decides nothing about that word's FIELD, which is the only question this report is about. `Smith Jr.` and `'Smitty' Jones Jr.` are S2's peel taking the suffix and the convention placing what is left, so they report. A first draft justified the nickname clause with "N3 returns before this site", and that is false as written: `_assign.py`'s N3 branch tests `len(pieces) == 1 and len(rest) == 1 and has_nickname`, so it returns only where the segment holds ONE piece, and `'Smitty' Jones Jr.` holds two and reaches this line. What N3 takes is the nickname-plus-one-word shape, not every name a nickname stands in. Not reported, deliberately: `abdul`, where bound-given vocabulary claimed the word; `de`, where a lone particle's reading is P4's; and `J.`, where the initial's shape is the claim. Those are M4's `_NEVER_FLIPPED` pair plus the particle, read off the tags classify already recorded rather than off a predicate of this emitter's own — literally so since 2026-09-08: `_NEVER_FLIPPED` moved to `_state` beside `WorkToken.tags` (post_rules imports _assign, so _assign cannot reach the other way) and `_WORD_ALREADY_CLAIMED` is that frozenset unioned with `particle`, so the two sets cannot drift. Also not reported: `Sir John`, `'Smitty' Jones` and `abd née Jones`, which O5's example block lists because the convention is what H1, N3 and M4 each left behind on them. The rule's SCOPE and the report's scope differ there, and the difference is stated in the rule rather than left to be discovered: the report is narrowed to an input nothing else stands in, because a report on a name where a title, a nickname or a maiden name is visible in the output tells the caller less than the output already does. The report is order-independent, which is not the same as the FIELD being order-independent: `Andrew` reports under the default order with `detail` naming given and under FAMILY_FIRST with `detail` naming family. Measured over the whole corpus under the default order, FAMILY_FIRST and FAMILY_FIRST_GIVEN_LAST, the set of names reporting this kind is the same 27 in all three; the only names whose reported kinds move with the order at all are four pre-existing `particle-or-given` reports. Nothing moves but the report. Every role is identical before and after — measured by parsing all 1123 corpus names under all three orders against a `git archive` of the parent commit, 3369 parses, zero role differences — and the three 2.x ledgers classify the 27 names on `_ambiguities` alone; `_ambiguities` cannot enter a diff below baseline 2.0, so `expected_since_1.4.0.toml` is untouched and its 368 intentional diffs are unchanged. SIX ledger rules rather than one, because an alternation carrying a script-classified member is claimed by the honorific pin in tests/v2/test_ledger_guards.py, which would demand it be a hand copy of GLUED_HONORIFICS: the twenty-two Latin-and-Arabic names ride one alternation, declared in `_NOT_A_VOCABULARY_COPY`, and the five CJK-bearing names take a literal-anchored rule each, which is also where their arguments differ. The three ledger copies are NOT byte-identical, which the drafting expected them to be: in `expected_since_2.0.0.toml` alone the two glued-honorific rules declare `["_ambiguities", "given", "suffix"]`, because at that baseline #308's peel has not shipped and its diff arrives in the same rule as this report, and the gate's over-declared check refuses a rule declaring a role no diff it explains moves. - 2026-09-08 #518 review round (the entry above ran into a second day; its own measurements are dated 2026-09-08 under a 2026-09-07 header) — a title standing after a family comma now silences the report. `John V, Dr.` is the shape: a comma with no name word after it hands segment 0 back to the positional read, the leading-title peel counts only what stands in segment 0, so `n` is 0 and the report named `given` for a word H1 then wrote to `family` — the caller was told the convention chose a field the output does not show. `_assign_main` takes a keyword-only `titled`, which the family-comma call fills from the gate's own reading (a False in it is a title, not a suffix), and the field-deciding emitters require `not titled`. Measured on this branch: `John V, Dr.`, `Smith V, Prince` and `Smith V, Dr.` keep every role and lose the `given-or-family` report, keeping the roman numeral's; `John V, Sir` does too, and it is the one worth naming — a given-name title leaves the word in `given`, which is the field the report would have named, so the report and the reading agreeing is what a silenced clause looks like when the rule that decided it happens to agree. Zero corpus names move: no corpus name reaches the site under a comma at all, which is also why the `len(state.segments) == 1` clause above measures inert. Consolidated the same day, 2026-09-08, and BEHAVIOR IDENTICAL. Segment 1 is now assigned BEFORE segment 0's positional read, so the title standing after the comma already carries a `Role.TITLE` when that read reaches its emitters; the keyword is gone and `n == 0`, `not titled`, the maiden scan and `not resolved.by_script` are one `field_undecided` predicate that both field-deciding emitters read. The token scan subsumes `n == 0` because nothing before assign sets `Role.TITLE` and assign sets it in exactly two places, the leading-title peel and the post-comma reading. Measured against a `git archive` of the previous commit: every distinct name in the deduped `corpus*.jsonl` glob plus the shapes named in this entry and in the #491 entries — 1136 names, 5680 parses — reads the same seven roles and reports the same kinds, details and token texts under the default order, FAMILY_FIRST and FAMILY_FIRST_GIVEN_LAST. One shape constraint came out of the measurement rather than the reading: the two token scans sit UNDER the peel count rather than beside it, because run on every parse they cost 10 calls/name and put the facade 457 over the 456 ceiling of its band (decisions/parse-cost). - 2026-10-20 #761 — the family-comma silencer stays a Then bullet with no example, by decision: the only one-word input of that shape, `, Andrew`, is not a name anyone types, so it stays out of the corpus names (Derek, PR #781). derek73-python-nameparser-456b024/docs/design/decisions/O6-two-family-names.md000066400000000000000000000277601527303271700271010ustar00rootroot00000000000000# O6 — two family names ## Standing - Two family names are an opt-in `Policy` switch, `two_family_names`, off by default, and not a locale pack: the convention is Spain's and most of Latin America's but not Argentina's, and given-first it is the same string shape as a name with a middle name, so no default can read it — 2026-10-24 #754. - Both family names stay in `family` through 2.x; a second-surname field is the RFC's Phase 4 output model (docs/design/vocabulary-model-rfc.md, "The rules a pack can choose"), and a pack may later switch this rule on — 2026-10-24 #754. - The family's words are counted as P1's fold counts them, a connective join and a hyphen counting as their words, so a family already holding two keeps what it has; the word taken must count as one by the same count and be no initial written alone — 2026-10-24 #754. - A name its script (W4) reads in an order other than the declared `name_order` is left alone; where the two agree it is read like any other — 2026-10-24 #754. - Under a family-first order the first two name words are the family, the remainder laid out as the positions after a family — 2026-10-24 #754. - The switch runs before `middle_as_family` and is not refused beside it: given-first, the two together read as `middle_as_family` alone — 2026-10-24 #754. - The switch reports nothing — 2026-10-24 #754. - The switch reports no alternative either, so the reading it did not take is not surfaced: the caller declared the convention — 2026-10-27 #752 (Derek). ## Log - 2026-10-24 #754 — TWO FAMILY NAMES ARE AN OPT-IN SWITCH THAT TAKES THE NAME WORD BESIDE THE FAMILY. The PSC register's fielded names (`local_agreement.py psc-individuals`) put two-word surnames read as middle plus family as the largest disagreement there, 22,168 names, 72% not Iberian (the ranking on #754). Given-first, `Ana García López` is the same shape as a name with a middle name, so the default cannot change; the convention is not one country's, so it is a `Policy` field rather than `locales.ES`. Portuguese order, the mother's name first, is out of scope. What the rule settles, and why each way: - **The count.** A particle run counts with the word it attaches to (P1's reach, so `Juan Pérez de la Vega` gives family `Pérez de la Vega`); a P2 chain counts the words it chained, so `Ana María de la Vega Rodríguez` keeps family `de la Vega Rodríguez`; a connective join counts its words, so `José María Ortega y Gasset` keeps family `Ortega y Gasset`; titles, suffixes and nicknames count as nothing. The rest of the name is split as assign read it, so a P2 chain is never split, and the word taken is counted the same way and must count as one, so a hyphenated given name (`Ana María-José López`) or a P5 pair (`Ana abdul Rahman López`) stays where it is. - **A hyphen joins two words**, and so do the Unicode hyphens, the figure and en dashes, the minus sign and the small and fullwidth hyphen-minus, but not an em dash, which is punctuation between words (widened by the #875 review, PR #878; no PSC record holds any of the characters added there, so the figures below stand). Counting `García-López` as one word would take the word before it. Measured over the 34,195 scored PSC records whose name holds an ASCII hyphen, with the switch on: 31,920 agree counting the hyphen's words against 19,649 counting the token (the default reads 32,958), Spanish filers 189 against 182 of 252, British 20,437 against 10,700 of 21,797. - **Not an initial, a mark or a script's order.** The PR review (#754) found the first draft taking a middle initial (`Ana M. García` → family `M. García`), a spaced dash (`García - López` → family `- López`) and the second syllable of a spaced Han or Hangul name read family-first by W4 (`王 小 明` → family `王 小`); each is refused now. An initial writes a given name; W4's order is the script's, not the caller's declaration, and no script it keys writes two family names as separate words. The second review found the refusals narrower than the first fix's prose: only an initial written alone is refused (`Ana J.R. García` and an abbreviation such as `Mª` are taken, accepted rather than widened, the run-together shape being a credential's as often as an initial's), the parse cannot tell a script order from a declaration that agrees with it (`王 小 明` under a declared FAMILY_FIRST moves, accepted), and the mark test asked `isalnum`, now `isalpha`, so a lone number is no family name either. - **Family-first.** The declared order says where the family stands, so the first two name words are the family and the rest is laid out as P1's family-first fold lays out its own: `García Márquez Gabriel` gives given `Gabriel`. A particle P6 attached at the far end stays family where it stands. - **Precedence.** `middle_as_family` folds every middle, which includes the word this rule takes, so the switch runs first and the fold takes what is left; refusing the pair would cost a working configuration for no wrong reading (AGENTS.md, "Invariants guard harm, not no-ops"). A patronymic rotation that fired decided the family already, and the rule finds no middle standing right before that family, so it stands down; where none fired (natural order, or any family-first order, where the rotations stand down) a patronymic is a name word like any other and may be taken — accepted, rules/O6. Against P1's family-first reach of one word the switch wins, the caller having said the family holds two. A family comma decides by itself, an empty-head one included. - **No report.** The caller declared the convention, as a declared order is followed without a report; the fork is real, but reported on most names of three words or more it would teach the caller to ignore the field. No existing kind names it (`ORDER` is about field order); whether the reading not taken is reported belongs to #752, which derives ambiguity from clashing readings. Measured 2026-10-24 on this branch after both reviews' fixes, with the switch on, the default unchanged (the snapshot gains the rule's and case table's example texts and changes no reading; every agreement record matches). PSC (`psc-individuals`, the fetch recorded on #849, 1,039,171 records scored), all boxes agreeing before → after, records fixed and broken: | Filers | Records | Before → after | Fixed | Broken | |---|---:|---|---:|---:| | Spanish | 6,089 | 31.8% → 80.2% | 3,849 | 902 | | Mexican | 402 | 25.9% → 80.1% | 274 | 56 | | Colombian | 377 | 29.2% → 84.4% | 261 | 53 | | Venezuelan | 237 | 40.5% → 75.9% | 130 | 46 | | Chilean | 151 | 35.1% → 77.5% | 93 | 29 | | Peruvian | 116 | 21.6% → 81.0% | 80 | 11 | | Argentine | 320 | 81.9% → 45.6% | 54 | 170 | | Portuguese | 5,333 | 61.1% → 56.3% | 1,221 | 1,481 | | Brazilian | 1,920 | 53.4% → 55.5% | 601 | 562 | | Everyone, as if it were the default | 1,039,171 | 96.7% → 47.2% | 22,959 | 537,743 | Among Spanish filers it fixes names of four name words 1,480 to 162 and of three 2,325 to 732, counted by the default reading's given, middle and family words. Of the records it breaks, 889 of 902 Spanish and 1,331 of 1,481 Portuguese filed a one-word surname. In the GND and Wikidata fielded corpora, the given-first renderings of the Spanish and Latin American double-surname strata (GND `compound-iberia` and `compound-latam`, Wikidata `surname2-es`, `-mx` and `-co`) disagree with the source on 315 of 390 by default, the RFC's count, and on 17 with the switch: 298 fixed, none broken; their index-form renderings disagree on none either way. The same switch over every other given-first rendering breaks 43 of GND's 408 and 141 of Wikidata's 250, and in the native-script Wikidata corpus moves 31 three- and four-word Arabic, Cyrillic, Bengali and Devanagari names, 11 to agreeing and 20 away. Recompute: for PSC, `local_agreement.read` per record under `Parser()` and under `Parser(policy=Policy(two_family_names=True))`, grouped by `group_of`, skipping records set aside on either side; for the fielded corpora, `cldr_agreement.disagreements` over the renderings with `shape` None, once with `_PARSERS[None]` as shipped and once set to the switched parser (clearing `_parser`'s cache), split by `label` and `order`. The comparator is the default parser on the same tree. - 2026-10-27 #752 (Derek) — THE SWITCH SURFACES NO ALTERNATIVE: the reading it did not take is not reported, as the 2026-10-24 #754 entry's No report bullet left for #752 to answer. Phase 2m surfaces the reading not taken as `Ambiguity.alternative`, which rides on a report, so a switch that reports nothing carries none. The caller turned the convention on, which is the declaration that settles the fork, as a declared order settles field order without a report; a report on most names of three words or more would still teach the caller to ignore the field. - 2026-10-27 #754 — MEASURED ON NAMES THE BEARERS SPLIT: ORCID's researchers (`local_agreement.py orcid-researchers`, the local corpus #927 added) typed their own given-names and family-name boxes, so a reading of the switch there is scored against the person, at a scale PSC holds only for Spain (20,000 records a country where PSC held 116 Peruvian filers). The switch is the convention most Spanish-speaking countries write: in the six below other than Argentina, agreement rises under it to between 85.6% and 93.7%, each fixing at least eight records for every one it breaks (a ratio of these six, not of every Spanish-speaking country: Uruguay's is 7 to 1). Where the convention is not written it breaks names, most of them filed with a one-word family box, and nearly all outside Brazil (1,922 of Brazil's 2,267). Argentina repeats PSC's finding. The Philippines is a population PSC barely held: Filipino names often carry a middle name between the given name and the family name, traditionally the mother's maiden surname, and the holders file it with their given names (8,024 of the 8,234 records the switch breaks hold a one-word family box; `Maria Santos Reyes` reads family `Reyes` by default, `Santos Reyes` with the switch). Portugal reads the other way from PSC's Portuguese filers, the switch mending more than it breaks among researchers working or living there; the populations differ in more than who wrote the names (nationality against country of work, company owners against researchers), so this is consistent with the docs' point that the result depends on who wrote the name down rather than a measure of it. All boxes agreeing before → after, records fixed and broken, and the broken records whose family box holds one word; a country is where the holder works or lives: | Country | Records | Before → after | Fixed | Broken | Broken, one-word family | |---|---:|---|---:|---:|---:| | PE | 20,000 | 13.4% → 93.7% | 16,385 | 324 | 321 | | CO | 20,000 | 23.6% → 91.7% | 14,413 | 789 | 780 | | MX | 20,000 | 31.3% → 91.9% | 12,655 | 539 | 528 | | VE | 20,000 | 37.4% → 85.6% | 10,871 | 1,230 | 1,215 | | CL | 20,000 | 45.8% → 92.2% | 9,976 | 700 | 685 | | ES | 20,000 | 48.0% → 91.5% | 9,353 | 649 | 626 | | BR | 20,000 | 47.3% → 71.0% | 6,999 | 2,267 | 1,922 | | PT | 20,000 | 70.0% → 81.3% | 4,295 | 2,034 | 1,975 | | AR | 20,000 | 80.3% → 59.7% | 3,069 | 7,204 | 7,056 | | IN | 20,000 | 73.0% → 64.5% | 582 | 2,268 | 2,265 | | US | 20,000 | 90.8% → 88.3% | 620 | 1,109 | 1,102 | | GB | 20,000 | 95.2% → 90.7% | 592 | 1,494 | 1,477 | | PH | 20,000 | 94.7% → 54.9% | 265 | 8,234 | 8,024 | Measured on master at 598167f4 over the 2025 Public Data File's extract fetched 2026-10-27. Recompute: `local_agreement.read` per record of `local_corpora.load("orcid-researchers")` with `FIELDED["orcid-researchers"]`, under `Parser()` and under `Parser(policy=Policy(two_family_names=True))` with `_title_words` of the default lexicon, grouped by the record's `country`; a record is fixed where it agrees only with the switch, broken where it agrees only without it, and the last column counts the broken records whose `family` box holds one word. A corpus capped at 20,000 records a country shows each country's convention, not how many people write it. derek73-python-nameparser-456b024/docs/design/decisions/P1-lone-particle-fold.md000066400000000000000000000567611527303271700273670ustar00rootroot00000000000000# P1 — lone particle fold ## Standing Examples read under the default order unless a line names another. - The fold's site is read from the joining structure, a lone never-given particle PIECE opening the name, not from the roles assigned, so the fold holds under every name order (`de Mesnil` → family 'de Mesnil' under all three) — 2026-08 #359. - A title that is not also a particle does not move the opening position (the title-particles are P4's exception, Elsewhere below): `Sir de Mesnil` reads as `de Mesnil` does, family 'de Mesnil' under every order — 2026-08 #367. - Grouping is vocabulary's job and assignment the declared order's, the one reach of an order into grouping's question being the leading run's stopping point, below — 2026-08-16 #364, #365, #368; 2026-08-17. - Under the default order the leading run takes the rest of the name (`de Mesnil Jean`, `de la Cruz Juan Carlos` → all family), and that is the reading rather than a gap: a name order is a property of the data source, a never-given particle marks where a surname begins, and the remedy for a family-first listing is the declaration or the family comma — 2026-08-17; 2026-09-07 #471. - Under a family-first order the run takes the particles and ONE name unit and lays the rest out by the order (`de Mesnil Jean` → family 'de Mesnil', given 'Jean'), the declaration being evidence of where the surname ends — 2026-08-17. - The stop lives in the fold, reading the order assign actually used, and a unit is a particle chain, a conjunction join or a bound given-name pair read off the tags (`de la Vega y Santos Juan` family-first → family 'de la Vega y Santos') — 2026-08-18 #395. - A particle chain inside the name is grouped and positioned like any other group, so `Juan de la Vega` under a family-first order reads family 'Juan', given 'de la Vega': what the never-given vocabulary forbids is the bare particle as a given name, not a name part that begins with one — 2026-08-16 #368 REVERSED. - The rule no longer carries the history of its given-position site: a particle where a family-first order puts the given name is not folded (`Mesnil de` family-first → given 'de'), kept as the rule's last Accepted bullet, the decision being decisions/P6's (Elsewhere below) — 2026-10-19 #761 (skeleton migration). - A bare particle has nothing to fold into and is a lone name word read by O5: `de` → given 'de' under the default order, family 'de' under a family-first one — 2026-10-19 (Derek), #761. - After a family comma the run takes the rest of that part, whatever order is declared, the comma having already fixed the surname (`Smith, de Mesnil Jean` → family 'Smith de Mesnil Jean' under all three orders) — 2026-08-18 #395, family comma. Elsewhere: a word of both the title and the particle vocabulary at the head (`St`, `Freiherr`, bare or period-marked) is rules/P4's exception and not this fold's site, the particle behind it being inside a name P2's chain makes (`St de Mesnil Jean` family-first → title 'St', family 'de Mesnil Jean') — decisions/P2's 2026-10-10 (Derek), #624 entry. The GIVEN position under a family-first order is no fold site (`Mesnil de` family-first → given 'de') — decisions/P6's 2026-08-30 #467 entry. Which particles are never-given is decisions/vocabulary-collisions' criterion, applied by #360 (2026-08-18), which moved `mc` and `ste` into the set. Open: none in this file. Reporting an ambiguity on the shapes neither order settles was left out of the 2026-09-07 #471 entry on purpose and is no open question here. ## Log A lone PIECE is the whole test — deliberately narrower than "a never-given particle is never reported as the given name," which would be false (the over-broad invariant shipped to five sites before #361's review falsified it with three counter-examples). The counter-example set has since shrunk, and its shrinkage is the section's history: "Sir de Mesnil" fell to #367 (titles became transparent); "Juan de la Vega" under family-first — the whole chain in the given position — was called working-as-intended by #359, contested by #368, and is working-as-intended again as of the 2026-08-16 entries below. The survivor is the degenerate bare "de". - 2026-08 #359 — the opening site is read from joining structure (pieces), not from assigned roles, so the fold holds under every name_order. Before this, the role-only read let "de Mesnil" split under a family-first order. - 2026-08 #367 — titles are transparent to the fold: "Sir de Mesnil" now reads like "de Mesnil". Fixed by removing the title→particle chain in grouping, not by touching this rule. - 2026-08-16 (order-precedence keystone; #364, #365, #368) (its order-independence half superseded by 2026-08-17, below) — the stage split is the decision, and the three issues are one question seen from three angles. GROUPING is vocabulary's job and is order-independent: a particle joins forward through consecutive particles and stops at the first non-particle, and no name_order moves that stopping point. ASSIGNMENT is name_order's job: groups take roles by the declared order. The bugs existed because the implementation runs the two in the opposite dependency — `assign` hands out positions from `_effective_order` and `post_rules` then inspects a fixed list of ROLES, so P1's fold sites and P2's chain had coverage that varied with the declared order by accident. Consequences, each recorded in its own right below: the fold takes only the particle's own group (#364); the middle position needs no third site once grouping is order-independent (#365); and #368 reverses. - 2026-08-16 #364 (under the default order superseded by 2026-08-17, below, the one-word stop holding under a family-first order only) — the fold takes the particle RUN and the ONE name word it attaches to, not every remaining word. "de Mesnil Jean" is family="de Mesnil" plus given="Jean". Run, not particle: "de la Vega" is two particles onto one word, and an earlier wording here said "the particle", which its own example contradicted (rule-vs-decision-record review). Nothing ever argued for "takes everything"; it was the shape of v1's handle_non_first_name_prefix, not a decision. Measured before deciding, over every name in the three differential corpora with NO string prefilter: exactly ONE family holds words beyond its particle's group — "de Mesnil Garcia". #364's own body warns the change "breaks the v1 parity tools/differential protects" and that "each ledger would need re-examining"; measured, it is one name and one ledger entry. A shape filter (no comma, leading never-given particle, three or more words) returns THREE candidates, of which two do not move: "de la Vega" is one group start to finish, and "de Mesnil Jr." has only two name words because Jr. is a suffix. The pre-merge fact-check caught this stated as "the filter gives one name", which it does not. Measurement trap, recorded because the fact-check fell into it twice: the particle group runs through ANY particle, not only the never-given ones — "de la Vega" chains never-given "de" through AMBIGUOUS "la" onto "Vega". A detector that walks only the never-given run splits the group after "de la" and reports 50 false movers. - 2026-08-16 #368 REVERSED — shipped behavior is correct. "Juan de la Vega" under FAMILY_FIRST groups [Juan][de la Vega] and assigns family="Juan", given="de la Vega". The earlier decision ("the particle wins ... whatever order was declared") was made before the grouping/assignment split was stated and cannot survive it: a mid-name chain HAS a head word and is positioned like any other group. What NON_GIVEN_NAME_PARTICLES guarantees is that the bare word never reads as a given name, not that no name part may begin with one. The asymmetry with the leading case is P4's, not an exception invented here: a leading particle chains nothing, so without the fold pure position makes the BARE particle the given name. That is measurable today on the ambiguous half, where no fold fires — "van Mesnil Juan" gives given="van", middle="Mesnil", family="Juan". given="de" is the reading the vocabulary exists to forbid, and the fold is what prevents it. The all-orders agreement in P1 is deliberate and is W4's shape: a wholly-hangul name reads family="김" under every declared order because the script carries a signal the order does not override, and a leading never-given particle is the Latin-script analogue. decisions/O4 already draws the line — "Words no vocabulary has claimed read by position" — so name_order governs the unclaimed remainder, which is most inputs. - 2026-08-17 (the reason it gives for the default-order reach superseded by 2026-09-07 #471, below) — SUPERSEDES the order-independence half of the 2026-08-16 order-precedence keystone entry above (#364, #365, #368). That entry says "no name_order moves that stopping point"; it may. Declaring FAMILY_FIRST or FAMILY_FIRST_GIVEN_LAST is precisely an assertion that what follows the family is NOT more surname, and a particle run's stopping point is exactly the question of where surname material ends — so the declaration is evidence about it, not merely about which slot the result lands in. The grouping/assignment split survives everywhere else; what is narrowed is the claim that the stopping point is one of the things order cannot touch. Concretely: under GIVEN_FIRST a leading particle run keeps joining to the end of the name. (This entry originally said that makes "de Mesnil Jean" read "exactly like pennie von bergen wessels — the same shape". Measured while implementing #395, that is false and is corrected here rather than left standing: pennie is the GIVEN name there, von is ambiguous so this fold can never fire on it, and the surname reading comes from P2's mid-name chain. What the real name does support is the weaker claim the argument needs — that a particle followed by several words can be all surname, "von Bergen Wessels" being one.) Under a family-first order the run stops after its first non-particle name word, because the caller has said the rest is given or middle. Accepted, and it is the cost that decided #364 three separate ways before this: "de la Vega Juan" under the DEFAULT order stays wholly a surname. A caller who means family "de la Vega" plus given "Juan" writes the comma. The mixed-language shapes are what make the greedy reading look wrong, and the parser cannot see language. Why the question kept thrashing, worth recording so a fourth attempt does not start from scratch: "how much does a leading particle run take" is a LANGUAGE judgement being forced through a POSITION heuristic, in a parser that has correctly refused to detect language (decisions/O4, rules.md's Not-in-scope). Every argument in the thread — `de`/`do` as Vietnamese surnames, `von` as German, `dos` as Portuguese — is really about which tradition the name belongs to. The order declaration is the one place a CALLER supplies that information instead of the parser guessing, which is why keying on it is better than any position rule we tried. Implementation consequence: the stopping point may now read name_order, which the superseded sentence forbade. Whether that lives in grouping (order-aware) or as a split in assignment is open — PR #391 showed assignment cannot split an existing piece today, so choosing assignment means giving it token-level slicing. Supersedes nothing else: #368's reversal, the trailing-orphan rule P6, and the claim that a leading never-given particle takes the FAMILY rather than a given name all stand. - 2026-08-18 #395 — WHERE THE STOP LIVES, settling what the entry above left open. Not grouping and not assignment: the fold in post_rules, which already retags tokens and is the only site that can express the stop without moving a piece boundary. Grouping was PR #394 (four regressions) and assignment was PR #391, where the finding was that assign cannot split an existing piece — and the piece is the problem: "de la Cruz Juan Carlos" groups as [de][la Cruz Juan Carlos], the ambiguous particle having chained forward, so any rule that stops "after the first name word" must cut INSIDE a piece. post_rules can, because roles are per token and nothing downstream reads pieces (measured: only _assign, which runs before it, and post_rules itself). Grouping therefore stays order-independent, which keeps the 2026-08-16 keystone intact everywhere except the reach of this one fold. The order is READ, not re-derived: assign now records the order it actually used on ParseState.order, and the fold keys on that. policy.name_order would have been wrong — a script_orders entry can put the family first under a given-first policy, and the roles assign already wrote would then disagree with the roles the fold computes. The same reasoning already appears one function away, where the PARTICLE_OR_GIVEN emitter reads the role off the token rather than assuming given. A unit, not a token: a particle chain (P2), a conjunction join (P3) and a bound given-name pair (P5) each count once. All three are read off the TAGS rather than the pieces, but for two different reasons, and the difference matters to anyone who tries to simplify this. The conjunction join grouping DID build and the prefix chain then swallowed: the name reaches the fold as [de][la Vega y Santos Juan], the ambiguous particle having chained forward over the join, so the JOIN's boundary is gone. The leading particle keeps its own piece, and must — the fold's site test wants a lone piece, so a one-piece name would not fold at all. The bound-given join grouping never built at all: P5 joins only where the bound word is the first non-title piece, and at a fold site the first piece is the particle, so "ibn Awf abdul Rahman" arrives as four separate pieces. Restoring piece boundaries in group would fix the first and silently split the second. It also makes P5's new implemented: pointer stronger than a citation — this is the only place that join happens in this shape. This is what keeps "de la Vega y Santos Juan" from stopping between Vega and Santos — the case rules/P3 was amended for on 2026-08-17, now executable. Measured, with the denominator this log requires: of 751 corpus names, SEVEN reach P1's leading fold (DE MESNIL, De Groot, de Mesnil, de Mesnil Garcia, de Mesnil Jr., de la Vega, dos Santos) and exactly ONE of those has anything past the run to lose, so one moves (de Mesnil Garcia) under each family-first order and none under the default. The single mover is a fact about the corpus holding no family-first listing with leftovers, not about the blast radius. The differential harness cannot see any of it — it runs the corpus under the default policy against 1.4.0, which has no name_order — so its exit 0 is evidence for the accepted cost and for nothing else. The verification that counts is the two-leftover shape: mutation-checked, discarding name_order from the leftover placement fails three tests across three layers — the FAMILY_FIRST_GIVEN_LAST case row (one of the two, the FAMILY_FIRST row being blind to it by construction), test_post_rules.py's test_the_two_family_first_orders_differ_at_two_leftovers, and rules.md's P1 example for that order. An earlier draft of this sentence said "exactly one, and no other test in the suite": true when measured, false one commit later, because two of the three guards were added after the measurement and it was not re-run. Recording the failure mode as well as the number, since it is this log's recurring one. - 2026-09-07 #471 DECLINED — the greedy reach under the default order is the READING, not a gap in one, and what decides it is a principle rather than a limit: a name order is a property of the DATA SOURCE, not of a string. The caller declares it to match how their records are written: the declaration governs what no vocabulary has claimed and a declared family-first order outranks the shape; rules.md's Name order Background states it. decisions/O1's 2026-09-07 entry applies the same principle to the patronymic rotations. Applied here: a never-given particle marks where a surname BEGINS, multi-word surnames are ordinary — Spanish double surnames above all — and under the default order nothing has said this surname ends before the string does. So `de la Torre Vega` is a surname-only string read right, and `de la Cruz Juan Carlos` is a family-first listing read under the wrong order. The remedy is the declaration or the comma: FAMILY_FIRST already gives family `de la Cruz`, given `Juan`, middle `Carlos` (#395, the 2026-08-18 entry above), and the family comma fixes the surname whatever order is declared. `de la Family Family Given` and `de la Family Given Given` are undecidable under EITHER order, which is what makes the comma the answer rather than a better heuristic. This SUPERSEDES the REASON the 2026-08-17 entry above gives for the same outcome — "The mixed-language shapes are what make the greedy reading look wrong, and the parser cannot see language." That was right and weaker. The order is not a language judgement at all: it is a declaration about the data, and a caller who has one supplies it whether or not a parser could ever have guessed it. The outcome that entry reached, and everything else in it, stands — and so does its "Why the question kept thrashing" paragraph: the REACH question is a language judgement, which is why the order declaration is where the caller supplies it. What is superseded is only the claim that the parser's blindness to language is the reason. It also answers the 2026-08-16 #364 entry's "Nothing ever argued for 'takes everything'; it was the shape of v1's handle_non_first_name_prefix, not a decision." This entry argues for it. The v1 provenance is unchanged and the argument is new, which is the only thing that had been missing. Measured 2026-09-07 on the tree, and on the 1.4.0, 2.0.0, 2.1.0 and 2.2.0 wheels, which agree on every default-order row: `de la Torre Vega` gives family `de la Torre Vega`; `de la Cruz Juan Carlos` gives family `de la Cruz Juan Carlos`; `de Mesnil Jean` gives family `de Mesnil Jean`; and the comma form `de la Torre Vega, Juan` gives family `de la Torre Vega`, given `Juan` under both orders. Under FAMILY_FIRST `de la Torre Vega` reads family `de la Torre`, given `Vega` — the cost the declaration accepts, and the reason a surname-only record in a family-first source still wants its comma. Recompute with `Parser().parse(name)` and `Parser(policy=Policy(name_order=FAMILY_FIRST)).parse(name)`; for the wheels, `HumanName(name).last` from a directory outside the checkout so the tree cannot shadow them. Nothing moves. No parse changes; the `parity` row `leading_never_given_particle_two_leftovers` in tests/v2/cases.py and the guard `test_the_family_first_fold_is_not_explained_under_the_default_order` in tests/v2/test_ledger_guards.py stay as the pins of the decided behavior; no release-note bullet is written, there being no behavior to note and no by-design precedent in docs/release_log.rst. rules/P1 gains `de la Torre Vega` as an example, which puts the name in the contract corpus corpus_rules.jsonl. Its parity is over the SEVEN roles, not the `initials` view: at the 1.4.0 baseline only, the name falls into the already-recorded class `fix(initials-per-word) a particle chain inside a name part` beside its sibling `de la Cruz Juan Carlos` — the v1→v2 per-word initialling shipped since 2.0.0, not a regression — so the 1.4.0 intentional count goes 367 → 368 and that rule's recorded claim goes 107 → 108 names with a new digest. The three 2.x baselines are unmoved at 263 / 175 / 37 (296 / 210 / 72 after #449 and #491 landed the same day; #471 itself moved none), and `unexplained: 0` at all four. The `Open:` pointer to #471 that stood at the end of decisions/P6's 2026-08-30 #467 entry is removed with this entry, which is where the question is now answered. P1's own `Open:` block below is #360's and is untouched. Deliberately NOT decided here: reporting an ambiguity on the shapes neither order settles — the honest-output flag — would be an AmbiguityKind and belongs with the ambiguity bundle (#449/#491/#348) if anywhere. Declining the reach change does not decline that. Two thirds of that bundle shipped 2026-09-07 as decisions/O5 and decisions/H4; #348 was left OUT by choice, being a question about the TITLES vocabulary rather than about a decision site, and the shapes this sentence is about are neither one — no rule fixes them by convention, so there is no branch to report at. - 2026-10-19 #761 (skeleton migration) — the rule no longer carries the history of its second site. A never-given particle standing where a declared order puts the GIVEN name was a P1 site until #467, and dropping it was a correction rather than a narrowing: that slot holds what the caller DECLARED to be the given name, and the never-given vocabulary supplies a reading where position leaves the question open rather than vetoing one position has already given. The argument and its measurements are decisions/P6's 2026-08-30 #467 entry; rules/P1 keeps the reading itself in its last Accepted bullet, scoped to a family-first order (a particle in that given slot is not folded), with `Mesnil de` under FAMILY_FIRST as its example. The default-order reach's "by design" points at the 2026-09-07 #471 entry above, which the rule no longer restates. - 2026-10-19 (Derek), #761 — A BARE PARTICLE IS A LONE NAME WORD. The Accepted bullet said a bare "de" stays the given name, which held only under the default order: under either family-first order it reads family 'de'. Nothing folds a lone particle, so it reads as O5's convention reads any lone name word, and the bullet now says that, with the family-first example. No reading moved. - 2026-08-18 #395, family comma — AFTER A FAMILY COMMA THE FOLD TAKES THE REST OF THAT PART, WHATEVER ORDER IS DECLARED: the comma has already fixed the surname, so no positional read is left for an order to narrow (`Smith, de Mesnil Jean` → family 'Smith de Mesnil Jean' under all three orders). Decided with #395 (commit 4627604f, whose release note says a family comma is untouched "where the comma has already fixed the surname") and stated in rules/P1 since, but the #395 entry above never recorded it; this entry, written 2026-10-22 for #762, dates it from that commit so the Standing block can point at it. Declined: - A strict xfail asserting "de Mesnil" → family under FAMILY_FIRST (#359 review) — #359 deliberately left those semantics open, and a strict xfail decides the question by the back door. - Keying the leading-particle exception on "the first piece that is not a title" — the obvious implementation, and wrong: st, do and freiherr are titles AND ambiguous particles, so it collapsed "St John Smith" into one given name and broke test_add_title (which adds "te", also a particle). The shipped predicate is "not a title or a prefix". - 2026-08-16 — deleting P4 so a leading particle chains and is then positioned, which is the only way to make "de Mesnil Jean" vary by declared order. It avoids given="de" (the group would be [de Mesnil]) but breaks "de la Vega": measured, a single group is positioned by the declared order — "Cher" reads given under GIVEN_FIRST — so "de la Vega" would read given="de la Vega" unless a further rule forced a particle-headed group into the family. Add that rule and [de Mesnil][Jean] yields the #364 reading anyway, so the deletion buys nothing and costs P4. Decided 2026-08-18 (was Open: [#360](https://github.com/derek73/python-nameparser/issues/360), which particles count as never-given): #360 applied the criterion at decisions/vocabulary-collisions and moved `mc` and `ste` into the set (from the may-be-given half), adding the Spanish/Portuguese articles; later vocabulary work extends the set under the same criterion (#604's Irish particles). derek73-python-nameparser-456b024/docs/design/decisions/P2-particles-join-forward.md000066400000000000000000000466531527303271700302740ustar00rootroot00000000000000# P2 — particles join forward ## Standing Examples read under the default order unless a line names another. - A title that is not also a particle does not displace the leading particle out of the leading position (a title-particle leads, below): `Sir de Mesnil` groups [Sir] [de] [Mesnil], the leading particle chaining nothing (P4), and its family reading is P1's fold; so `Dr. Van Johnson` reads as `Van Johnson` does, given 'Van' — 2026-08 #367. - The chain stops where the trailing run begins, so a run of the unambiguous suffix vocabulary opening it is the suffix (`John Smith Mc V` → suffix 'Mc V') while one continuing a prefix run is a particle (`John van Mc` → family 'van Mc'); the fork reads the piece before a numeral as it stands (`John van der J. V` → family 'van der J. V'), and a numeral with a suffix behind it stays in the chain (`John van der Berg V Jr` → family 'van der Berg V') — 2026-08-22 #424. - Where the name begins has one definition, assign's leading-title run, H2's unlisted abbreviations included, so `Xyz. van Johnson` reads as `Dr. van Johnson` does — 2026-08-22 #424. - The leading position is decided once, as the LAST title in assign's leading-title run, read as written before H3's give-back, that is also a particle, else the first piece past the run: `Freiherr St John Smith MA` → title 'Freiherr St', given 'John', family 'Smith', suffix 'MA' (default order); `Freiherr St van Berg MA` → title 'Freiherr St', family 'van Berg'; `Dr. Mc Mc` → given 'Dr.', suffix 'Mc Mc' (default order) — 2026-10-10 (Derek), #624. - Assign's re-read fallback, where group's joins leave no name word past the titles, stays, since it guards totality — 2026-10-10 (Derek), #624. - Particle groups are distributed across the middle and family fields rather than combined, the combined double-surname reading living in the surnames view (`Joao da Silva do Amaral de Souza` → middle 'da Silva do Amaral', family 'de Souza', surnames 'da Silva do Amaral de Souza') — 2026-08-16. - Suffix pieces are not skipped when the chain looks for the leading piece, since skipping them reads worse: `Ph.D. Van Johnson` → given 'Ph.D.', family 'Van Johnson'; `Dr. Ph. D. Van Johnson` → family 'Van Johnson' — 2026-10-19 #760. Elsewhere: the stop is S2's trailing run, read once after P3's joins and before the chain is made, counting the chain's own run as the one name word it will make (`John van der Berg V` → family 'van der Berg', suffix 'V'), the re-ask after the chain's merges running only where the run was not read there, and on a main segment the chain stops in front of a trailing title (`John van der Berg Prof.` → title 'Prof.', family 'van der Berg' under the default order) — decisions/S2's 2026-10-07 (Derek), #614 entry and decisions/H5's 2026-10-07 #614 entry, indexed here by this file's 2026-10-07 #614 cross-reference. A particle that is also suffix vocabulary heads the name word behind it before a family comma in any case (`SMITH VD MA, JOHN` → family 'SMITH VD MA') — decisions/P9's 2026-10-02 #573 entry. A bare ambiguous acronym written Title-case in a mixed-case name joins the chain (`John van der Berg Ma` → family 'van der Berg Ma') and one written in capitals ends it with no words to spare (`Freiherr von Berg MA` → family 'von Berg', suffix 'MA') — decisions/S6's 2026-09-14 #289 + #516 and 2026-09-15 (Derek) entries; the absorbed acronym reports `suffix-or-name`, and a word a particle run took (`anh van do`) reports nothing — decisions/S6's 2026-09-18, WHOLE-PR REVIEW ROUND entry; a one-letter numeral behind a title and a chain has no word to spare and joins the chain (`Freiherr von Richthofen V` → family 'von Richthofen V') — decisions/S10's 2026-10-17 (Derek), #718 entry. After a family comma the given part's chain stops where `tail_reading`'s run begins — decisions/H5's 2026-10-10 (Derek), #623 entry. Open: none in this file. ## Log - 2026-08 #367 — REMOVED a chain, it did not create one: before, the title displaced the particle out of the leading position, so "Sir de Mesnil" grouped [Sir][de Mesnil] — a chain — and reported given="de Mesnil" with no family at all. After, "de" is the leading name piece, and a leading particle chains nothing (rule P4): the family reading comes from P1's fold, which is why P1's fold needed no change (its interacts: points here). Grouped today: [Sir] [de] [Mesnil]. - 2026-08-16 — GROUP DISTRIBUTION IS LOAD-BEARING, the undated bullet below, dated for #762 on 2026-10-22 so the Standing block can point at it, by the commit that first recorded it (c2381f6b). No commit decided it: the split fields and the combined `surnames` view are v1's (9a3d187c, 2018), held by its Portuguese tests. - Group distribution is load-bearing, not incidental: the v1 Portuguese tests (test_portuguese_dos, test_portuguese_prefixes) require multiple particle groups to land across middle and family, while #132 wanted the combined double-surname reading — the same shape with opposing wants, which is why the combined reading lives in the surnames VIEW and the split in the fields. - 2026-08-22 #424 (its `John van der Berg Ma` and `Freiherr von Berg MA` readings superseded by decisions/S6's 2026-09-14 #289 and 2026-09-15 entries, its `Freiherr von Richthofen V` boundary by decisions/S10's 2026-10-17 #718, its read over the pieces as written and its re-ask by #614, and its leading scan stopping at the first title-particle by #624, below) — the chain stops where assign's trailing peel begins. The chain ran "until a trailing suffix begins" and asked with the suffix-piece test, which vetoes a bare `V` as an initial — the #401 question at a third site — so `John van der Berg V` read family 'van der Berg V' (1.4.0 read it so too: shipped since 1.x), and `John van der Berg Ma` read family 'van der Berg Ma' where 1.4.0 read suffix 'Ma' — a 2.0 regression on S2's other fork. Both now stop where `_trailing_start` says the run begins: the S2 peel (`_peel_trailing` over `_peel_walk`, shared with assign and P5's reserve since #425) read over the pieces as they stand from the first name piece, once per segment, kept as a length from the end that the chain's merges ahead of it do not move (the benchmark's `particles` shape is the guard). The chain takes both forks — its merges leave the acronym at least the three pieces the fork counted, so assign peels it after as the walk did before — where the maiden walk takes only the numeral (the M2 entry says why). The fork reads the piece before the numeral as it stands, so `John van der J. V` keeps family 'van der J. V', as `John J. V` reads the V as a name; and a numeral with a suffix behind it is not last in the walk, so `John van der Berg V Jr` keeps family 'van der Berg V'. No differential corpus name had either shape; the rules examples carry them — the numeral against every baseline, the acronym against 2.0.0 and 2.1.0. Two more sites were asking assign's question with a test of their own, both found by the reviews of this change. The walk's starting point and P4's leading-particle scan asked group's `title()`, which does not see H2's unlisted abbreviations — assign peels those as titles all the same — so `Xyz. van Johnson` chained where `Dr. van Johnson` did not (#367 had keyed the exception on the first piece of the name, by group's test), and `Xyz. van Berg MA` read family 'van Berg MA' on every 2.x tree — the chain had taken the acronym, as it took every acronym; the draft that stopped before it left assign two pieces where the fork had counted three, and MA became the family. Both ask assign's own test now — `_is_leading_title` and the period-abbreviation pattern moved down into group, with `_leading_titles` as the one definition of where the name begins: the chain's walk, the bound join's first name piece (the second docs review found that one still on group's test — `Xyz. abdul John Smith` joined nothing where `Dr. abdul John Smith` read given 'abdul John') and, through `_is_leading_title`, the scan and the PARTICLE_OR_GIVEN report — so `Xyz. van …` reads as `Dr. van …` does. `Esq. van Gogh`, a corpus name, moves with it (1.4.0 read family 'van Gogh' for both, as #367's rule records; its own rule at every baseline, and one for the P4 example). A particle of the unambiguous suffix vocabulary too (vd, mc) is a suffix piece to the peel: where it opens the trailing run the chain stops before it as before any suffix word and the peel takes it — `John Smith Mc V` reads suffix 'Mc, V' where master and 1.4.0 read family 'Mc V', a class the second docs review found unrecorded (2,554 of its 117,306 constructed names, an eighteen-word pool that puts the words in every position) — and where it continues a prefix run, the run takes it as a particle, so `John van Mc` keeps family 'van Mc', every baseline's reading and the one P6 chose for the shape after a comma. Both are Accepted under P2. The fork's count was the last thing the chain asked on its own authority, and the reviews found it wrong behind a title-and-particle word: #367 stops the leading-particle scan at `Freiherr`, `St`, `Do` — the name's own first piece — where assign's title peel steps over them, so the chain takes the name's first word, and the acronym the fork counted with three pieces met assign with two: `Freiherr von Berg MA` read given 'von Berg', family 'MA' (1.4.0's reading, as it happens; master read family 'von Berg MA'). So the chain asks the peel again over the pieces it leaves — `chain(tail)`, then `_trailing_start` over what it built, and a second run without the stop where the verdict changed, the snapshot being one copy per segment with a trailing run — and takes what assign will not peel: family 'von Berg MA'. The numeral cannot flip, a chain group never being initial-shaped, so `Freiherr von Richthofen V` keeps suffix 'V', and its chain, the one name piece left, reads as `Dr. Smith V` reads — where 1.4.0 and master read family 'von Richthofen V'; the code review asked for that boundary to be on record, and P2 carries it. #410 (2026-08-25) then moved the name half of both: a title with one name word behind it names the family whatever stands beside it, so the pair reads family 'von Richthofen' and family 'Smith' today, where each read `given` with no family when this entry was written. The boundary is unchanged — the chain still stops before the numeral, and the two names still read alike; only the field the one name piece lands in moved. This name is the fifth corpus name #410 moves, and the only one already classified, its ledger rule's `fields` spanning the narrower diff. - 2026-10-02 (Derek), #573 — the part before a family comma: a particle that is suffix vocabulary too heads the name word behind it in any case. Decided with P6's third exception and S2's never-given lead; the record is decisions/P9's entry of the same date, where this clause moved with P6's exception (2026-10-19). - 2026-10-07 #614 — CROSS-REFERENCE: the chain's stop is the trailing run S2 reads once, after P3's joins and before the chain is made, counting the chain's own run -- a particle run, the words both particles and suffix vocabulary behind it included, and the plain words after it -- as the one name word it will make; the re-ask after the chain's own merges (the 'Freiherr von Berg Ma' rollback above) runs only where the run was not read there. decisions/S2, this date; rules/P2's statement amended. - 2026-10-10 (Derek), #624 — WHERE THE NAME STARTS IS DECIDED ONCE, AND OF TWO TITLE-PARTICLES THE SECOND LEADS. `TITLES ∩ particles_ambiguous` is `{freiherr, st}`. Such a word in the leading titles is the chain's leading position (P4), so the particle behind it is inside a name (`Freiherr von Berg` → family 'von Berg'). Group's chain found that position with a scan of its own that stopped at the FIRST such word, and chained the second as a surname particle, while assign read both as titles and the trailing read's unit count (`_chain_units`, #620) opened a unit inside the titles to follow the chain: `Freiherr St John Smith MA` read title 'Freiherr', family 'St John Smith', with a title-or-name report saying it was "read as a family name by convention", where `Dr. St John Smith` and `Sir St John Smith` read title 'Dr. St' / 'Sir St', given 'John', family 'Smith'. DECIDED (Derek: consistency, one shared answer, no special case for garbage): `_pieces.chain_lead` gives the leading position once -- the LAST title in assign's own title run (`leading_titles`) that is also a particle, else the first piece past the run -- and group's chain and the read's count both take it. The run is read as WRITTEN, before H3's give-back (Derek, the review's option (a)): a title the run hands back to the name because only suffix words follow it is still a title to the chain, so the particle behind it is the name's leading piece and chains nothing (P4). `leading_titles` stops at a piece that is itself a leading title only where it gave that piece back or at the segment's last piece (a title needing a following piece), so `chain_lead` recognises the give-back from `n` alone, the last piece ruled out by its position, behind the same inline tag test, and no second title walk is needed. No particle stands between the lead and the run's end, so the chain opens no unit inside the titles: the read's `at` and its `q > k + 1` gate are gone, and group's chain skips every piece up to the lead (`k <= leading`, equivalent to the old `k == leading` wherever the lead was the first title-particle, since no particle stood before it). `Freiherr St John Smith MA` → title 'Freiherr St', given 'John', family 'Smith', suffix 'MA'; `Freiherr St van Berg MA` → title 'Freiherr St', family 'van Berg'. NOT DONE: the issue also proposed retiring assign's re-read fallback (`read = None` where group's joins left no name word past the titles). Measured, it still does work this change does not touch: disabled, sixteen or seventeen more tests fail depending on how it is disabled, most of them as an IndexError, so it guards totality and not only a reading -- among them the case row `a_title_read_before_the_chain_can_strand_the_name` (`Freiherr von Berg Dr. and Ed. Prof.`, where a CONNECTIVE join makes the name a title), the locale non-interference tests and two case-repair property tests. It stays. MEASURED 2026-10-10 against 8e6e524a (#631's merge), py3.11, `nameparser.__file__` asserted on each side. A title-particle head grid -- every sequence of one to four words over `Freiherr St Dr. Sir Xyz. von van de do Mc John Smith MA PhD VI Prof. née Jr. and`, plus `Smith, ` and `, Jr.` over `Freiherr St Dr. von John`, 137,810 names under the three orders, comparing the seven fields and every report: 5,964 role moves, no report-only move. 5,868 have two title-particles in the leading title run (a period-shaped word such as `Jr.` and a connective title such as `and Freiherr` counting as titles there), all now titles -- where H3 then gives the last back, the word behind it is the post-nominal H3 saw rather than a word that title chained (`Dr. Freiherr St Mc` → title 'Dr. Freiherr', family 'St', suffix 'Mc'; master family 'St Mc'). 96 are H3's give-back before a run of dual particles, read as H3 states (`Dr. Mc Mc` → given 'Dr.', suffix 'Mc Mc'; master given 'Dr.', middle 'Mc', family 'Mc', the read having counted the second 'Mc' as bound into a run the chain never built). The first draft read the give-back as the chain's lead and gave `Dr. Mc Mc` title 'Dr.', family 'Mc Mc', chaining the name's leading particle behind a plain title against P4, and with a caller lexicon (`esq` added as an ambiguous particle) `Dr. esq vd` chained 'esq' and reported it; the review caught both, and option (a) is what this entry records. A comma-tail grid, `{head} {w1 … wk}` for k from 1 to 3 over the heads `John Smith, Jr.,`, `Smith, John,`, `Smith, Jr.,`, `,,`, `Smith,,`, `John Smith, PhD, Jr.,` and the words `Dr. Dr Attorney`, `Secretary of State` (one word of the list), `Lt.Gov. Freiherr St von`, `van der` (one word), `Bart Jones Jr. PhD MA Ma X.Y.Z. nee VI V 'Bob' (Bob) Prof.`, `Mr. and Mrs.` (one word), `I y` -- 97,650 texts, three orders: 1,368 parses on 456 texts move, every one a part past the second comma led by two title-particles, now all titles as C2 reads a title word there (`,, Freiherr Freiherr Bart` → title 'Freiherr Freiherr', suffix 'Bart'; master title 'Freiherr', suffix 'Freiherr Bart'). The S2 stress grid (#614's recipe): 0 moves. Every corpus name and case text at 8e6e524a under the three orders: only the case rows #624 rewrites move. The five differential gates exit 0. Cost: every frame baseline moves DOWN by 4 (decisions/parse-cost, this date). ROWS: five rows pinned the old decision and now read the new one (`fix(#624)`, each note giving the earlier reading): `Freiherr St van Berg MA`, `Freiherr Freiherr Prof do`, `St St VI`, `Freiherr St MA`, `Freiherr St Prof.`; `Freiherr St John Smith MA`, the issue's example, is added, and `Dr. Mc Mc` pins the give-back half (the review found that half unpinned, a revert passing the suite). The marker row that held group's particle-or-given emitter on `St St née` moves to `St van née`, the same structure with a chained particle that is not a title (parity), since `St St née` no longer reaches that emitter. - 2026-10-19 #760 — MOVED FROM the chain's code comment in `_group.py`, the facts the entries above did not hold. LINEAGE: the chain is v1's prefix chain — a non-leading prefix run absorbs everything to the next prefix or suffix — with v1's `leading_first_name` rule keeping the first piece a name (`Van Johnson`). #367's SHAPE, beside the `Sir de Mesnil` entry above: keyed on index 0, a title displaced the particle and the chain fired, so identical name text parsed two ways — `Van Johnson` given 'Van', family 'Johnson'; `Dr. Van Johnson` family 'Van Johnson'. SUFFIX PIECES ARE NOT SKIPPED when the chain looks for the leading piece, and the reason is what skipping them would do rather than what it would cost. The shapes that decide it are those whose leading piece lands in `given`: `Ph.D. Van Johnson`, `II Van Johnson` and `Msc.Ed. Van Johnson` each read given 'Ph.D.'/'II'/'Msc.Ed.' with family 'Van Johnson', and skipping the piece moves `Van` out of the family into the middle name (given 'Ph.D.', middle 'Van', family 'Johnson') — a worse reading on three shapes, fixing none. `Jr. Van Johnson`, the shape that looks as if it needs the skip, classifies its leading piece as a TITLE and is already covered by the title run. The example that opened this argument, `Ph. D. Van Johnson`, left it with #371 (the pair no longer merges at the head of the input, so there is no suffix-shaped leading piece to skip); `Dr. Ph. D. Van Johnson` replaces it — family 'Van Johnson' as shipped, family 'Johnson' with the skip. - 2026-10-25 #785 — IN THE GIVEN PART AFTER A FAMILY COMMA THE CHAIN STOPS AT A MEMBER ITS CAPITALS SETTLE, as at a suffix word, since the run S8 starts behind the given word takes it and the words behind it (`Smith, Freiherr von Berg MA Jones` → given 'von Berg', suffix 'MA Jones', as its dotted spelling reads), where the chain had taken them into the given name. Elsewhere the trailing read's bound already ends the chain at a member the read takes (decisions/S6's `Freiherr von Berg MA`), and a stop at one the read declines strands it: the review of the fix found `Freiherr von Berg MA Ma` reading given 'von Berg', middle 'MA', family 'Ma' under a stop everywhere, so no-comma names keep the chain's reading (family 'von Berg MA Ma', where the dotted spelling reads suffix 'M.A. Ma'). Found by #785's code review; the record is decisions/S6's review entries of this date. derek73-python-nameparser-456b024/docs/design/decisions/P3-connectives.md000066400000000000000000001214171527303271700262200ustar00rootroot00000000000000# P3 — connectives ## Standing Examples read under the default order unless a line names another. - A connective-joined run is ONE name word wherever a rule counts name words, H1 and P1's family-first stop included, except C3's count before a comma (Elsewhere, below): `Mr. Jack and Jill` → family 'Jack and Jill' where `Mr. Jack Jill` reads given 'Jack'; `de la Vega y Santos Juan` family-first → family 'de la Vega y Santos' — 2026-08-17. - The three-word carve-out counts the name's own words, once the maiden name has left: `juan y garcia nee jones` → middle 'y', family 'garcia', maiden 'jones' — 2026-08-21 #418. - `i` is in `conjunctions` and `conjunctions_ambiguous` together, so `Josep Carod i Rovira` and `Carod i Rovira, Josep` read family 'Carod i Rovira'; the off switch is `Lexicon.default().remove(conjunctions={"i"})`, or deleting `i` from `C.conjunctions`, and a name's fields and repair equal the off switch's but for the three exemptions the entry names (a join between two name words, a disagreement about the letter being an initial, a connective placed among the name words in repair) — 2026-09-20 #397. - A single-letter connective counts as a name word toward the three-word carve-out wherever it is placed to join, whatever else the vocabulary says it is — 2026-09-20 #397. - A connective that is also generational vocabulary joins only where a name word stands on each side of it, looked for past any run of connectives and between assign's leading-title run and the trailing run H5's reading gives: `Josep Lluis Carod i III` → suffix 'i III'; `John Quincy Adams i MA Prof.` → family 'Adams', suffix 'i MA', title 'Prof.'. The class is a vocabulary test, not a length test, and the three-word carve-out alone stays single-letter — 2026-09-20 #397. - The both-sides question is answered once per run of connectives, both sides in one call — 2026-09-21 #397 follow-up. - Accepted with that scope: a link with nothing on its right joins nothing (`Carod i` → given 'Carod', suffix 'i'); a connective run joins the whole name (`Henry i of England` → given 'Henry i of England'); a lowercase `i` between two name words of a mixed-case name joins silently (`John Paul i Smith` → family 'Paul i Smith'); a particle is a name word for the test (`Josep Carod i de Rovira` → middle 'Carod i de', family 'Rovira'); and in a one-case name `i` is P8's initial and reports (`JOHN I SMITH` → middle 'I', `conjunction-or-initial`) — 2026-09-20 #397. - `y` stays out of `conjunctions_ambiguous` beside the two marked letters — 2026-09-20 #397. - The comma decision joins title words with group's connective loop, `_pieces.join_connectives`, so a connective run merges in the comma part (`John Smith, Minister of the Interior and Mrs.` → title 'Minister of the Interior and Mrs.'), a lone single-letter connective there is left to P3's count (`Juan Garcia Lopez, Sr. y Sra.` → given 'y', title 'Sr. Sra.'), and every connective of the generational class is frozen there, a neighbour's join still able to take it in (`Smith, Dr. and i` → title 'Dr. and i') — 2026-10-06 #617. - With the off switch a one-case `i` is the initial its capital is (rules/S10), so `rovira, i` and `john smith i jr` read alike with it on and off; and a connective letter ending its part is not reported — 2026-10-18, #721. - P3 keeps the join and its three-word count, and the single-letter connective read as an initial is rules/P8's, the entries deciding it having moved to decisions/P8 — 2026-10-19. - A frozen link joins nothing of its own but a neighbour's join can absorb it: `Josep Carod Rovira Puig y i` → family 'Puig y i' — 2026-10-19 #760. Elsewhere: P3's joins are made before S2's trailing run is read, so the run counts a joined part as one name word (`Juan y Garcia X.Y.Z.` → family 'X.Y.Z.') and a salutation keeps its connective (`Mr. and Mrs.` → title 'Mr. and Mrs.') — decisions/S2's 2026-10-07 (Derek), #614 entry, indexed here by the 2026-10-07 #614 cross-reference. A declared separator in a trailing suffix part ends the search as a comma would, so a connective never joins across it — decisions/C10's 2026-10-03 (Derek), #549 entry, indexed here by the 2026-10-03 #549 cross-reference; C3's count of the words before a comma counts a connective surname's words (`Ortega y Gasset, Dr.` → given 'Ortega', middle 'y', family 'Gasset') — decisions/C1's 2026-10-06 (Derek), #613 entry. Open: none in this file. ## Log - 2026-08-17 (for C3's count before a comma superseded by decisions/C1's 2026-10-06 #613) — a conjunction-joined run counts as ONE name word in every rule that counts them. Raised while bounding the leading particle run (#395): a run that stops "after the first name word" would otherwise be able to stop INSIDE a part P3 built, splitting "de la Vega y Santos Juan" between "Vega" and "Santos". This is not new behavior — H1 already counts this way, measured: "Mr. Jack and Jill" gives family="Jack and Jill" while "Mr. Jack Jill" gives given="Jack", family="Jill". What is new is that rules.md says so, which is what binds #395's implementation. Recorded because the first draft of the clause asserted that no rule could show it today and shipped an Accepted note to that effect; the shipped H1 pair was simply never run. Detector failure of the usual shape — reasoning about which rules count instead of parsing a name and looking. - 2026-08-21 #418 (PR #420) — the three-word carve-out counts the name that remains once the maiden name leaves. The count was taken while the marker and the maiden name were still pieces, so `juan y garcia nee jones` counted five, 'y' joined, and the family was empty once the clause left — given 'juan y garcia', family ''. Nine corpus names show it under an appended clause. Closed by order, not by an exclusion: the marker pass now runs before the count (decisions/M2, 2026-08-21), and the three-piece gate ahead of the count, which had the same exposure, is closed the same way. rules/P3 now says the count is of the name's own words. The question #397 and #411 left open — whether a word's membership in one vocabulary should suppress it from a count gating a rule keyed on another — is untouched: this was never a membership question, the words are simply not in the name. - 2026-09-20 #397 (its one-case reports on a letter ending its part, and its off switch restoring the one-case role movers, superseded by 2026-10-18, #721, below; the particle-chain gap it recorded as not fixed superseded by #614, decisions/H5) — DECIDED AND SHIPPED: `i` joins `conjunctions` and `conjunctions_ambiguous` together, which is what decisions/P8's 2026-09-13 #383 + #479 entry said would happen and which half of it the conjunctions Open block in decisions/vocabulary-exclusions called contested. `parse("Josep Carod i Rovira")` gives family "Carod i Rovira" where every release from 1.4.0 through 2.3.0 gave middle "Carod i" with family "Rovira"; `parse("Josep Lluis Carod i Rovira")` gives middle "Lluis" with that same family; and `parse("Carod i Rovira, Josep")` gives family "Carod i Rovira" where they read family "Carod Rovira" and took the link into `suffix` as a generation marker (all three measured 2026-09-20 on the released 1.4.0, 2.0.0, 2.1.0, 2.2.0 and 2.3.0 wheels from a throwaway environment, this tree shadowing a wheel otherwise). THE OFF SWITCH is `Lexicon.default().remove(conjunctions={"i"})`, and on the v1 surface deleting `i` from `C.conjunctions`; both restore all three readings above byte for byte, measured 2026-09-20. That is a PROPERTY rather than an anecdote, and it is this change's durable statement, and it takes THREE exemptions rather than two once case repair is included — the earlier wording of this sentence named two and the release note named none. A name's FIELDS equal its reading with the letter out of the vocabulary unless (1) a link joined between two name words or (2) the two readings disagree about the letter being an initial; its REPAIR, plain and forced, equals the off switch's unless one of those two holds or (3) the parse placed the letter among the NAME words as a connective, where R4 keeps it lower-case and the off switch — for which it is no connective at all — title-cases it: `parse("Dr. John i Smith").capitalized(force=True)` keeps 'i' against the off switch's 'Dr. John I Smith', and `Carod i Rovira` and `Josep i Rovira` are the same shape. Measured 2026-09-20 (PR #536) over the shipped grid's 28,448 class-letter rows: 1,462 take the join exemption, 9,296 the initial-reading one and 2,228 more the third, which is INV7's alone; `tests/v2/test_properties.py::test_a_link_that_joins_nothing_changes_no_field` holds it, its recorded control 1,480 failing parses at `c8550b64`, 154 at `dc3bdf9c` and 0 here, and its docstring is the live figure. THE COUNT REACHES THE CLASS (Derek, 2026-09-20), which ANSWERS the C-ii question the 2026-08-21 #418 bullet above left open — "whether a word's membership in one vocabulary should suppress it from a count gating a rule keyed on another". It does not: a single-letter connective counts as a name word toward P3's three-word carve-out whatever else the vocabulary says it is, and `i` is also the roman numeral. The alternative was rejected on its own arithmetic rather than on taste — counting the link as the generation it also spells lets a connective raise the bar for ITS OWN join, so "Josep Carod i Rovira", the shortest linked name and the commonest one, would be the only shape that failed to link while the four-word spellings joined. The arithmetic runs the other way too, which is why the count is scoped to where the word is PLACED TO JOIN: a connective joining nothing would otherwise LOWER the bar for an unrelated one, and "Carod y Rovira i" is that shape — it counted the trailing generation and let the "y" join. It reads given "Carod", middle "y", family "Rovira", suffix "i" here, the same FIELDS the parent gave — its initials moved, but under R3's rule rather than this one. ACCEPTED CONSEQUENCE OF REACHING THE CLASS, and the one row the design's reading tables show it touching: under `Lexicon.default().add(particles={"y"})`, `Juan Velasquez y Garcia` goes from given "Juan" / middle "Velasquez" / family "y Garcia" to given "Juan" / family "Velasquez y Garcia" — the custom lexicon now AGREES with the default one, which is the right direction — and its `family_base` becomes "Velasquez Garcia", a pre-existing R2 limit (`family_base` drops a particle wherever it stands, not only leading) surfaced rather than created here. Measured 2026-09-20 on this branch. WIDER THAN ONE ROW, and the second review measured how much: the design's reading tables show one name because they hold one of this shape, not because one moves. Over the review's 43,925-name generated grid, `add(particles={"y"})` moves a word the parent roled SUFFIX into a name part on 130 names BEYOND what the default lexicon already moves there — "ABDUL Y ROVIRA MA" goes from given "ABDUL" / family "Y ROVIRA" / suffix "MA" to given "ABDUL Y ROVIRA" / family "MA" — and 71 of those 130 still report `suffix-or-name` on the moved word, so the reading is marked rather than silent. Four more lose a title, all four the same name in its four spellings ("Jr. y Soler Puig MA" and its casings). The direction is the one the row above describes: the custom lexicon converges on the default one, the particle joining what the connective already joined. Measured 2026-09-20. THE BOTH-SIDES SCOPE, AND THE REVIEW THAT FIXED IT. A connective that is also generational vocabulary joins only where a NAME WORD stands on each side of it — a word the rest of the parse reads as a name word rather than as a credential, the trailing numeral or a title, looked for past any run of connectives standing between. The FIRST implementation tested POSITION (an interior piece) rather than the neighbours' CLASS, and swallowed a credential: "Josep Lluis Carod i III" read family "Carod i III" where the "III" is the generation. An independent review found it. It is recorded here because the invariant that ought to have caught it cannot: INV1 inspects the part the join PRODUCED, where the absorbed credential is itself the name word standing on the right, so that name SATISFIES it — the general lesson is a field note in mechanisms.md of this date, and the instrument is `tests/v2/test_properties.py::test_a_trailing_credential_never_joins_into_a_name_part`, which asks the off-switch parse instead and carries its own control (639 failing parses at `c8550b64` over the shipped grid, 105 at `dc3bdf9c`, 0 here; 960 over the full cross product, a dated snapshot). THE BOUND THE CONDITION READS, AND THE SECOND REVIEW THAT FIXED IT (2026-09-20). "A name word" is looked for between assign's leading-title run and the start of its trailing suffix run, and that second bound was read by `_pieces.trailing_start`, the peel over the pieces as WRITTEN. A trailing TITLE hides the suffix run from that peel — it takes nothing and answers `len(pieces)` — so the condition was told a credential was a name word and the join swallowed it: `John Quincy Adams i MA Prof.` read family 'Adams i MA' with no report and an `initials()` of "J. Q. A. M.", where `John Quincy Adams i MA`, the same name one title shorter, reads family 'Adams' with suffix 'i MA' and reports the acronym. `John Quincy i MA Prof.`, `Josep Lluis i MA Prof.` and their one-case spellings are the rest of it. THE FIX IS H5's OWN READING, `_pieces.trailing_start_past_titles`, which runs the peel and the title chain to the fixed point `tail_reading` already computes for assign — so the join and the placement agree about where the name ends, which is what H5's sentence says they must. Asked once per segment and only where such a connective was found, so no ordinary name pays it; frame-neutral on `tools/perf/call_count.py` (parse=406.00, facade=443.00). Measured over the review's 43,925-name grid under eight configurations: 27 rows move, on 9 distinct names, every one of them onto the reading the off-switch parser gives. AND THE CONDITION IS ASKED ONCE PER RUN, not once per member, which is a THIRD fix in the same clause and was a defect the second review left standing (found in its own report, fixed 2026-09-20). `_name_word_beside` walked the run of connectives beside a link to find the first name word past it, and both callers ask it per member, so a name holding a run of n links walked it n times and the stage went quadratic in the run's length: `"Josep " + "i " * n + "Rovira"` grew 3.8x per doubling against the 2.0x every other shape holds — 59ms at n=800 and 214ms at 1,600, where the parent 46651750 reads 8.3ms — and `"Jane Doe nee Puig " + "i " * n + "Soler"` the same, the clause's own caller. Every member of one contiguous run has the SAME nearest name word on each side, so `_group._run_neighbours` answers it once for the whole segment in two linear passes, built on the same gate as the two bounds. All four shapes now read 1.97-2.03x per doubling to n=1,600. THE PRICE IS A CONSTANT ON SHORT NAMES and is recorded rather than hidden: one call plus `_is_conj_piece` for the pieces the walk never reached, so `Josep Carod i Rovira` goes 304 -> 307 frames and `Jane Doe nee Puig i Soler` 307 -> 312, against 6,741 -> 2,652 for that second name with a run of 64. A name with no generational connective is untouched (`John Smith` 163, `Smith, John` 185, `Juan Garcia y Lopez` 285, `Jane Doe nee Smith` 233, all unmoved). Byte-identical over 1,748,904 parses — 145,742 names, the corpus, the case table, all three property grids and a run-heavy generated set, under three lexicons and four policies — same digest on both trees. Guarded twice, because one instrument cannot reach both callers: `tests/v2/test_benchmark.py`'s thirteenth `_SHAPES` row (`link_run`, `"i Und "`) times the join's caller, and `test_a_clause_link_run_does_not_cost_quadratically` counts frames for the clause's, which no repeated unit can express. NOT FIXED AND RECORDED INSTEAD, because it is neither this rule's nor this branch's: the PARTICLE CHAIN reads the same bound the same blind way, so a trailing title lets it run to the end of the segment. `de la ma prof.`, `de la e ma prof.`, `van der berg ma prof.` and `Josep de Carod y Rovira MA Prof.` all put the title inside the family at the parent 46651750 and at 1.4.0, with no letter of this class in them at all. What the branch does is send MORE names down it — once `i` is marked, a one-case `de la i ma` stops ending in a suffix piece and the chain reaches past it — so `de la i ma prof.` and `de la i rovira prof.` now read as their `e` twins always have. 25 names of the review grid are in this family and none of them is a `_name_word_beside` case. It is the #535 boundary and belongs to its own change; `tests/v2/test_properties.py`'s INV1-strengthened carries a LINK-FREE CONTROL so the invariant can tell the two apart, and says so where it is asked. THE CLASS TEST IS NOT A LENGTH TEST, corrected by the second review (2026-09-20). The stage's own filter carried a `len(tok.text) != 1` beside the two tag tests, narrowing this clause to ONE-LETTER connectives where the rule names a class. Caller-reachable: under `Lexicon.default().add(conjunctions={"og"}, suffix_words={"og"})`, `John Quincy Smith og` read family 'Smith og' with the filter and reads family 'Smith' with suffix 'og' without it — the rule's own answer. DROPPED, measured byte-identical over every oracle in use (fields, reports, `initials()`, `capitalized()` plain and forced, every token's role) across 359,053 parses: the 351,400-parse review grid under eight lexicon/policy/locale configurations, the 2,175-parse sweep of `tests/v2/cases.py` under three orders and the 5,478-parse sweep of the differential corpora under six. It has to be — `i` is the only member of the class in the default vocabulary and in every locale pack, and it is one letter. The THREE-WORD carve-out stays single-letter, because that is what its own sentence says. THE SCOPE'S ALTERNATIVES, MEASURED 2026-09-20 rather than argued, by building each alternative from this tree and sweeping the 1558 non-empty corpus-union-cases names under eight configurations (default, `lenient_comma_suffixes=False`, both family-first orders, and the four locale packs). ABSENT — no both-sides condition at all — differs on five names, and every one of them is a row this bundle added: "John Quincy Smith i" would read family "Smith i", "Josep Lluis Carod i III" family "Carod i III", "Josep Lluis Carod i V" family "Carod i V", "Carod y de Rovira i" given "Carod y de" with family "Rovira i", and "Rovira, Josep Carod i Jr." middle "Carod i Jr.". Nothing that stood in the corpus at the parent separates absent from narrow, so those rows ARE the evidence and pruning one retires it. WIDE — the same condition with the generational-vocabulary test dropped, so it reaches every single-character connective — differs on five as well, and THREE of those were in the corpus already: "Mr. & Mrs. John Smith" would read title "Mr." with given "&" and middle "Mrs. John", "1 & 2, 3 4 5, Mr." would lose "2" out of the family, and "Jane née Jr y Jones" would read middle "née Jr y" with family "Jones". So the class test is not decoration — widening the rule past the link-and-generation class breaks "Mr. & Mrs." — and narrow is both what the design asked for and what measurement supports. (A "0 differences over 1545 names" figure was measured for this scope before the review supplied the class test and before those rows existed; it is superseded here.) ACCEPTED, THE TWO-WORD SHAPE: "Carod i" keeps given "Carod" with suffix "i", and "Josep Carod i" keeps family "Carod" with suffix "i", because a connective with nothing to its right is connecting nothing. 2.0.0 through 2.3.0 read BOTH of those the same way; 1.4.0 read the two-word one differently, giving family "i" with given "Carod" (measured on the wheels 2026-09-20, correcting an earlier draft of this bullet that claimed 1.4.0 parity for it). That does not move the accepted cost, because the 1.4.0 difference is not this rule's: the two-word shape's routing left 1.4.0's reading in 2.0, which is why the case row carries `fix(suffix-routing)` rather than a connective classification. What this rule decides is that the link does not join there, and 2.0 through 2.3 agree with it. The ledger rule that called this NOT WANTED is rewritten in place rather than deleted — it still explains the name, and the standing is decided now rather than unwanted. ACCEPTED, A CONNECTIVE RUN: "Henry i of England" reads given "Henry i of England" with a `given-or-family` report, where the parent read given "Henry" with family "i of England". The "i" and the "of" merge as a run and the run joins the whole name into one piece — the same thing "Duke of Edinburgh" already did. ACCEPTED, THE LOWER-CASE REGNAL NUMERAL, and it is the shape that makes the join FIRE with no report at all on input nobody would call Catalan: a mixed-case name with a lowercase `i` between two name words joins like any other link, so "John Paul i Smith" reads family "Paul i Smith" where the parent read middle "Paul i", and "john i Smith Jones" reads given "john i Smith" where it read middle "i Smith". Nothing marks the reading, because nothing is ambiguous by this rule's lights — the letter IS connective vocabulary and it IS joining two name words — and a report there would fire on every Catalan name too (rules/A1's hesitating-reader test). The frequency is the argument for accepting it: measured 2026-09-20 over the deduped corpus-union-cases population of 1,568 non-empty names under the three orders, the join fires on SIX names and five of them are the Catalan rows this bundle added; the sixth is "Henry i of England", the paragraph above. A regnal numeral written lowercase between two name words is the shape that would pay, and the corpus holds one. ACCEPTED, THE PARTICLE ON THE RIGHT. A particle is a name word for the both-sides test, so the link joins across one: "Josep de Carod i Rovira" reads family "de Carod i Rovira", which is the Catalan reading, while "Josep Carod i de Rovira" reads middle "Carod i de" with family "Rovira" where the parent read middle "Carod i" with family "de Rovira" — the join takes the particle as its right-hand neighbour and the particle chain then follows the joined piece instead of the tail word. Recorded rather than repaired: which side a particle standing between a link and a surname belongs to is P2's question and not this one. ACCEPTED, AND THE HONEST STATEMENT OF THE ONE-CASE HALF, written out because this branch's own first commit message overclaimed that one-case names keep their reading. `i` is in the MARKED subset, so in a name written wholly in one case it reads as an INITIAL and reports `conjunction-or-initial`, exactly as `e` has since #383/#479. For "JOSEP CAROD I ROVIRA" and "josep carod i rovira" the fields are indeed unchanged and only the report is new. But a one-case name reports wherever a bare `i`/`I` stands among the NAME'S OWN WORDS, which is a great many more names than the Catalan ones — "JOHN I SMITH" and "john i smith" one report each, "JOHN SMITH I" and "john smith i" two, "HENRY I" and "henry i" three. A letter inside a maiden clause is read by the clause's rules and stays silent, exactly as `e` does ("JANE DOE NEE I JONES" reports nothing), which is the own-words scope decisions/P8's 2026-09-13 #383 + #479 entry defines. And in ALL-LOWER names the initial reading MOVES FIELDS wherever the parent read the lower-case letter as the generation. Each such name now reads as its ALL-CAPS twin already did: "rovira, i" gives given "i" where it gave suffix "i" ("ROVIRA, I" already gave given "I"); "john smith i jr" gives middle "smith" with family "i" where it gave family "smith" with suffix "i jr" ("JOHN SMITH I JR" already did); "maier, amy i, jr." gives middle "i" with suffix "jr." where it gave suffix "i, jr." (the corpus name "Maier, Amy I, Jr." reads middle "I" and does not move); and "josep de carod i rovira" gives family "de carod i rovira" where it gave middle "de carod i" with family "rovira". None of those four is a corpus name; all are measured 2026-09-20 on this tree and against the parent's. NO CHANGE TO THE EXCLUDED BLOCK, recorded as a decision rather than left implicit (Derek, 2026-09-20). It now lists "y" against TWO marked letters rather than one, and that is still right: the argument that put "y" outside is about "y" — the commonest Hispanic compound and this library's oldest fixture — while "i" matches "e" exactly, a bare I initial being as common as a bare E. - 2026-09-21 #397 follow-up — AMENDS the "AND THE CONDITION IS ASKED ONCE PER RUN" paragraph above, whose frame figures stand as measured at `6048eb5d`. BOTH SIDES ARE NOW ONE CALL. A connective is placed to join only where a name word stands on EACH side of it, so neither caller ever wanted one side's answer: `_name_word_beside(k, step, ...)` was asked twice and the two answers ANDed at the call site, in the `frozen` loop and again in `_link_joins_inside_the_clause`. It is `_group._between_name_words(k, lo, hi, ...)` now and answers for both — one Python frame per generational connective instead of two, with `step` and its ternary gone. Nothing else moved: the left arm still short-circuits the right, and the arrays, the bounds and the two piece tests are the same tests in the same order. Byte-identical over 3,931,700 parses — 157,188 names (the corpus, cases.py, the property grids and a run-heavy generated set) under six lexicons and four policies, plus the facade surface and the #528 no-parse paths — same sha256 for all twenty-six digest rows and the dumps compare equal. THE OLD NAME STILL STANDS WHERE IT WAS WRITTEN, and this sentence is the grep trail: `_name_word_beside` is the spelling in decisions/M2's 2026-09-20 #397 bullet and in the "NOT FIXED AND RECORDED INSTEAD" paragraph above, and dated entries are not edited, so neither is corrected — a search for either name reaches the whole arc. One place the old name was NOT a citation and is renamed: `tests/v2/test_properties.py`'s per-side mirror, now `_name_word_on_the_side`. That helper is a deliberately independent second implementation, reading spans and off-switch roles where the parser reads pieces and tags, and carrying the implementation's own name made it read as a call into the thing it checks rather than as a model of it. THE FIGURES, AND THE INTERPRETER, because the pair above spliced two and this one does not. Re-measured 2026-09-21 on py3.11 through `tests/v2/test_benchmark.py`'s own `_frames_for` shape, `b9ed1429` → `9fd84463` → this tree: `Josep Carod i Rovira` 311 → 314 → 313, `Josep Lluis Carod i III` 377 → 381 → 380, `Jane Doe nee Puig i Soler` 315 → 320 → 319, `John Quincy Adams i MA Prof.` 438 → 443 → 442, and the clause guard's own pair 1,125 → 875 → 859 at sixteen links and 6,741 → 2,651 → 2,587 at sixty-four (3.01x, re-pinned in the test). `tools/perf/call_count.py` is unmoved at parse=406.00 facade=443.00 — its reference name carries no link — and so are `John Smith` 172, `Smith, John` 203, `Juan Garcia y Lopez` 291, `John and Jane Smith` 293, `Jane Doe nee Smith` 245 and `Jane Doe nee Smith PhD` 340, none of which reaches the predicate. WHICH OF THE EARLIER FIGURES REPRODUCE, stated exactly where this sentence first said only that one half "very nearly" did: re-measured 2026-09-22 on py3.11, the short-name pairs (304 → 307, 307 → 312) do not reproduce at all, and of the run-of-64 pair (6,741 → 2,652) the LEFT half does and the right does not — `b9ed1429` reads 6,741 on the nose, while `6048eb5d`, the tree its 2,652 was taken on, reads 2,651 here, the same figure `9fd84463` reads. So the splice runs through a single arrow rather than between two of them, which is exactly the spliced table `tools/perf/call_count.py`'s docstring was written about. Timings are unmoved: `"Josep " + "i " * n + "Rovira"` and `"Jane Doe nee Puig " + "i " * n + "Soler"` both read 1.96-2.02x per doubling from n=200 to n=1,600, 11.36ms and 9.39ms at the top end against 11.58ms and 9.58ms at `9fd84463`. AND THE FOLD BOUGHT A TEST, which is the part worth keeping: while the predicate was called once per side, a mutation of the suffix or the title test hit BOTH sides at once and the left-hand rows killed it, so the right-hand halves were never separately covered — their own rows (`Josep Lluis Carod i Jr.`, `i Mr.`) stand at the END of the name, where `hi` refuses them before either piece test is asked. Folded, the two sides mutate independently and both right-hand tests survived the whole suite. `test_a_credential_or_honorific_mid_name_on_the_right_too` is the pair that kills them, and every arm of the folded predicate now dies by a named test (recorded in its docstring). - 2026-09-26 #538 — SUPERSEDED by 2026-10-03 #549 — CROSS-REFERENCE: rules/P3's separator sentence (a declared delimiter is read past as a connective is) is decided and measured in the 2026-09-26 #538 entry under `### M2`. - 2026-10-03 #549 — CROSS-REFERENCE: that separator sentence is reversed; it now says a declared separator ends the search as a comma would, a connective never joining across one, and the decision is decisions/C10's 2026-10-03 entry. - 2026-10-06 #617 — the comma decision joins title words with GROUP'S connective loop, not a copy of it. `_comma.decide` reads the part after the first comma before group joins anything (#613), and the one join the part can hold — a connective binding title words into one title, `John Smith, Mr. and Mrs.` — was made by `_comma._title_chains`, a second copy of the P3 loop in `_group_segment`. The two had already parted three ways: the copy merged no connective run before joining, skipped a single-letter connective unconditionally where group skips it only under the rootname count's carve-out, and derived the title kind alone. Both now call `_pieces.join_connectives` (mechanisms.md#ONE-PREDICATE-PER-QUESTION), which takes the carve-out's answer from its caller (`letter_stays`: group's `total < 4`, always true for the comma decision's own reading, and reaching a LONE single-letter connective only, one beside another connective merging into their run first), a `titles_only` mode for the comma part (a join is made only into a title and takes no prefix kind, any other connective left a word for the reading to place, which is what the copy did), and the `frozen` token set group already passed itself. The merge and the piece predicates it needs moved with it (`merge_pieces`, `joined_tags`, `is_conj_piece`, `is_prefix_piece`), and `_comma._reading_pieces` builds the reading's input, calling the shared loop only where the part holds a connective left free — nearly no part does, and the first cut, calling it always, cost every family-comma parse two frames per word of the part, one `is_conj_piece` call in each of the loop's two passes (`Smith, John` +2, `Smith, John` with sixteen more given words +34, measured inside decide in #617's review); `test_a_comma_part_with_no_free_connective_skips_the_join` holds the gate, with that control recorded. DECIDED (Derek, 2026-10-06): (1) A CONNECTIVE RUN merges in the comma part as it does in the name — P3's own "connective runs included" — so a title holding `of the` reads whole there too, and a single letter beside another connective joins as a member of their run. `John Smith, Minister of the Interior and Mrs.` reads given 'John', family 'Smith', title 'Minister of the Interior and Mrs.' (83f4e914: family 'John Smith', the copy having joined 'Minister of the' alone and left 'Interior' a name word); `Smith, Chancellor of the Exchequer MP` reads suffix 'MP' (83f4e914: given 'MP'); `John Smith, Mr. y and Mrs.` reads title 'Mr. y and Mrs.' behind given 'John' (83f4e914: family 'John Smith'). The garbage case the issue named, `Prof. Smith, and and Dr.`, reads title 'Prof. and and Dr.', family 'Smith' (83f4e914: title 'and and Dr.', family 'Prof. Smith'). Every one of these reads as 2.3.0 read it, checked against the released wheel, and so did b547c247: what moves on realistic input was #613's copy regressing names it never meant to touch, between releases, so no release note follows. A LONE single-letter connective is unchanged: the comma decision's reading joins no title across one, so a part of titles it separates is no titles-alone part and the listing form reads it, where P3's own count decides the join — `Juan Garcia Lopez, Sr. y Sra.` keeps given 'y' and title 'Sr. Sra.', while `Smith, Sr. y Sra. Juan Maria` joins title 'Sr. y Sra.' behind given 'Juan', both as 2.3.0 and 83f4e914 read them. A case row pins the first. No rules.md text states it, because it is no rule of the comma's: the first review round added a C1 clause saying a lone letter after a comma always stays a word, and the second struck it with the `Juan Maria` reading, P3 deciding in the listing part as everywhere else. (2) EVERY CONNECTIVE OF THE GENERATIONAL CLASS IS FROZEN in the part the comma decision reads, P3's "joins only where a name word stands on each side of it" applied to a part read for credentials, which holds no name word — group freezes the ones it finds without a name word beside them, the same rule. Frozen means no join or run of its own; a neighbour's join may still take one in, as in group (`Smith, Dr. and i` reads title 'Dr. and i'). The default class is `i`, which as one letter the copy never joined and which reaches the freeze only through a run: without it the shared run merge took `Rovira, Dr. i i` into title 'Dr. i i', which `test_a_link_that_joins_nothing_changes_no_field` caught. A generational word of more letters reaches it in a plain join too, which only caller vocabulary can spell, and there the copy DID join it: under `Lexicon.default().add(conjunctions={"og"}, suffix_words={"og"})`, `Smith, Mr. og Mrs.` reads title 'Mr. Mrs.', suffix 'og' (83f4e914 and 2.3.0: title 'Mr. og Mrs.'; b547c247 as now), and as a plain connective `og` still joins. MEASURED 2026-10-06 against 83f4e914, py3.11, over that tree's population — each branch-added case row and the new rules.md example are fixes against it by construction and not counted here. Two session harnesses, recorded as recipes: each parses its names on both trees and compares the seven role fields, every ambiguity's kind and detail, and `initials()` (the fingerprint adds `capitalized()` and each ambiguity's token spans). The FINGERPRINT (78,282 parses): every corpus name and case row, plus 6,000 seeded draws (`random.seed(7)`) of a front from `'', Smith, John Smith, John Q. Smith, Dr. Smith, de la Vega, SMITH, LLOYD WEBBER, Smith Jr., Vega y Lopez, 김민준 박, 김민준, Dr 김민준` and one to four words behind its comma from the 40 words `Smith John Jane de van vd VD mc do DO MA Ma PhD Ph. D. MD Jr. Jr V V. VI III G.J. XY XYZ X.Y.Z. Dr. Mr. and Prof. Sir i Und RN/CRNA / Jones 김민준 박 J.씨 씨`, three in ten with a third part (each draw in that order: `choice(fronts)`, `randint(1, 4)`, `choice(words)` per word, `random() < .3`, `choice(words)`), plus every two-word pair from that list behind `John Smith, `, `, ` and `Doe, Jane `, under six policies (default, both family-first orders, strict comma suffixes, `/` as a suffix delimiter, caps suffixes off); 30 parses differ, every one a generated name with two connectives side by side after the comma, and no corpus name and no case row moves. The CONNECTIVE GRID (485,300 parses): every three-word string over the 30 words `and of the y e i Und & Mr. Mrs. Dr. Prof. Secretary State Sir PhD MA MD Jr. III V de van der la John Smith Jones J. Ma`, bare and behind `John Smith, `, plus 40,000 seeded draws (`random.seed(617)`) of a front from `'', Smith, John Smith, Dr. Smith, Prof. Smith, de la Vega, Vega y Lopez, SMITH` and one to five of those words, written both as `front, words` and `words front`, three in ten with a third part (drawn in the fingerprint's order, `randint(1, 5)`), under four policies (default, family-first, strict comma suffixes, `&` as a suffix delimiter), and 2,700 parses under the `og` lexicon, every pair of those words in the three templates `John Quincy Smith og {a} {b}`, `John Smith, og {a} {b}` and `Smith, {a} og {b}`; 5,911 differ, 5,590 with two connectives side by side after the comma counting `og` as one, the other 321 the `og` freeze; 4,131 move a role and 1,780 only reports, and in every one of those 1,780 each report the branch adds names a run of two or more connectives, reported once as one run (`John Smith, III and and PhD` reports `'and and' follows a credential` where it reported each `and`). The five differential gates exit 0 and no diff moves but the new example's; that one new rules.md example reads as every 2.x baseline reads it and moves three 1.4.0 rules' corpus reach by one each. Frame cost falls by 10 a parse on every interpreter's reference row, and by 39 more once the /simplify pass gated group's call as the comma's is gated (decisions/parse-cost, this date); per name on 3.11, warm, against 83f4e914, `Smith, John`, `John Smith, PhD` and `John Smith, PhD MD MBA RN` cost what they did (161, 206, 294), a long given part less (`Smith, John Quincy Adams Bob` 332 → 297), and a comma part holding a connective pays the shared loop (`John Smith, Mr. and Mrs.` 262 → 269). AMENDS decisions/C1's 2026-10-06 #613 entry, whose decision (3) names `_comma._title_chains` as where the title join is made and describes the join as P3's connective binding title words: it is `_comma._reading_pieces` calling `_pieces.join_connectives` now, and the join merges a connective run first and freezes the generational class, so (3)'s account holds of the join's PRODUCT, one title, and no longer of its reach — rules/C1 (rules/C7 since #728) is amended to match. The `_PREFIXED_SHAPES` `title_chain` row, recorded there as the clock guard on the copy's slice rebuild, guards the shared loop on the same input (`'Mr. and ' * n` behind `John Smith, `), its control re-taken on the moved code (tests/v2/test_benchmark.py records it); the `_SHAPES` `conjunctions` row guards group's calls as before. - 2026-10-07 #614 — CROSS-REFERENCE: P3's joins are made before S2's trailing run is read, so the run counts a joined part as one name word, as this rule says every count does (`Juan y Garcia X.Y.Z.` keeps family 'X.Y.Z.'), and a salutation keeps its connective (`Mr. and Mrs.` reads title 'Mr. and Mrs.'). #614's first design read before these joins too and broke both; #614's review reversed it (decisions/S2, this date). - Provenance: the single-letter-connective guard is v1's fix for Google Code issue 11 ("john e smith", 2013, commit 33676c9) — the "#11" citations that circulated pointed at a GitHub accident, not the real source. Recorded so the archaeology stays done. - 2026-10-18, #721 — THE OFF SWITCH NO LONGER RESTORES THE ONE-CASE ROLE MOVERS. With `i` removed from the conjunctions, a one-case `i` is a one-letter roman numeral, which since #721 is the initial its capital is (rules/S10), so `rovira, i`, `john smith i jr` and `maier, amy i, jr.` read with the switch off exactly as with it on, and as their all-caps twins read either way — where 2.3.0 read them as suffixes, the disagreement with the capitals that decisions/P3's 2026-09-20 bullet "ACCEPTED, AND THE HONEST STATEMENT OF THE ONE-CASE HALF" measured. The off-switch grid's property is unchanged, and these rows no longer lean on its exemption (2): the two readings now agree on them (on: `initial`; off: `initial`, where 2.3.0's off reading was not), so they pass on equal fields. The exemption still guards the rows it was written for (#721's design-docs review measured the rows only it saves falling from thousands on master to a few hundred, all of them a connective `i` before a strict-comma numeral such as `I ROVIRA, V` — a dated snapshot). And a connective letter ending its part is not reported (rules/P8, "unless nothing of its part stands to its right"): the fork had no other way to go. - 2026-10-19 — RULE SPLIT: the single-letter connective read as an initial left rules/P3 for rules/P8, one rule per piece of evidence (docs/design/AGENTS.md), P3 keeping the join and its three-word count and P8 taking the case contrast. The entries deciding that half moved to decisions/P8 with it: #267 (2026-07-30), the #383 + #479 entry with its #528, #289/#516 and #492/#478 amendments, the two 2026-09-13 #383/#479 entries after it, and the 2026-09-24 #478 cross-reference. The #397 entries stay here, since `i` joining is P3's question, and so does #721's off-switch entry; where they say the one-case half or the marked subset, that half is rules/P8's now. - 2026-10-19 #760 — MOVED FROM group's code comment on the both-sides test, the one fact the #397 entries above did not hold. "No join of its own" is the whole claim a frozen link carries: a NEIGHBOUR's join can still absorb it, since both loops of `join_connectives` skip a frozen piece as the join's SUBJECT and nothing keeps it out of the span another connective's join takes. `Josep Carod Rovira Puig y i` freezes the trailing 'i' (nothing stands on its right) and the 'y' beside it joins across it all the same, for family 'Puig y i', which is what the tree before #397's both-sides scope read there too; pinned by `test_a_frozen_link_is_still_absorbed_by_a_neighbours_join` in tests/v2/pipeline/test_group.py. derek73-python-nameparser-456b024/docs/design/decisions/P5-bound-given-names.md000066400000000000000000001077211527303271700272220ustar00rootroot00000000000000# P5 — bound given names ## Standing - The join is vocabulary-driven: a bound given-name word joins the name word after it, and needs a name word to spare — 2026-06-30. - `abd` is a bound given-name word and the credential ABD, position deciding at the two ends (`Jane Smith ABD` → suffix, `Smith, Abd` → suffix 'Abd'); the three-token `abd al rahman` does not join, `al` chaining forward as a particle — 2026-08-18. - The join never absorbs a lone maiden marker, and the reserve counts the name the marker pass leaves (`abdul Berg née Jones` → family 'Berg') — 2026-08-21 #411; 2026-08-21 #418. - A given-name title licenses the join with one word to spare, read through the predicate H1 reads a title run with: `Sir abdul salam` → given 'abdul salam' (`Sheik` until 2026-10-27, when it became a title only inside a title phrase, decisions/H9), `Dr. abdul salam` → family 'salam'. The licensed join takes one word, never a particle chain (`Sir abdul van der Berg`), and a bound word that is also a particle reads as the bound word, reporting nothing (`Sir Abu Bakar`) — 2026-08-22 #369. - The join declines a word that reads as a suffix: `abdul Jr Smith Berg` → given 'abdul', `abdul Ph. D. Smith Berg` → suffix 'Ph. D.' — 2026-08-22 #401. - S2's trailing run is read once before the join is tried, so the words to spare are the name pieces it leaves and the join moves no suffix reading (`abdul J. V` → given 'abdul J.', family 'V'; `Sir abdul Ma` reports `suffix-or-name`) — 2026-10-07 #614. - After a family comma the pair is the given name whatever follows (`Berg, abdul V` → given 'abdul V'), a bare ambiguous acronym the run does not take is a name word (`abdul Ma Smith` → given 'abdul Ma'), and a title word inside the name joins like a name word (`abdul Sir Smith Berg`) — 2026-08-22 #425. - The join declines a title word a trailing rule reads as a title: after a family comma one H5's trailing run takes (`Berg, abdul Prof.` → title 'Prof.'), and past the second comma any title word — 2026-10-11 #637. Open: none in this file. ## Log - 2026-06-30 (first-name-prefix-join design; v1-era, carried into the v2 port) — the join is vocabulary-driven and deliberately tiny. - 2026-08-18 — `abd` ADDED, and the reserve count corrected to let it work. It is the transliteration for the spellings that write the article as its own word ("Abd Allah", "Abd al-Rahman"), which abdul/abdel/abdal do not match; the Arabic-script عبد has covered the same word since #269, so only the Latin side was short. Not a C-ii collision, though it looks like one: C-ii settles a PRECEDENCE by frequency where neither reading is a name, and here one reading is a name and neither had to be given up. It is C-i's positional qualifier (2026-08-17) applied to a bound-given word — the naming use is LEADING, the credential use trailing, which is the `de` row of that table ('a TRAILING surname — no clash') rather than the `vd` row. `abd` is also the postnominal ABD ("All But Dissertation") in SUFFIX_ACRONYMS, and all three credential spellings still parse ("Jane Smith ABD", "Jane Smith, ABD", "Jane Smith A.B.D."). - 2026-08-21 #411 (PR #416) — the reserve counts the name the join will actually leave behind, and the join never absorbs a marker. P5 has always said the join "needs a name word to spare, so two name words alone do not join"; the count was taken while the maiden marker and the maiden name were still pieces, because group's marker pass runs later in the same stage. `abdul Berg née Jones` counted four words, joined, and left no family name at all — given 'abdul Berg', family ''. That spelling is the pre-existing witness: it reads the same way at 2.0.0 and 2.1.0. `abd` does NOT — it reads correctly at 2.1.0 and acquired the defect only when #400 made it a bound given-name word this cycle, so for that spelling this is a regression #400 introduced and this closes. #399's chain stop widened the class again by putting the particle spellings in front of the reserve for the first time. Excluding the departing words is not sufficient on its own, and review is what established that. Where the maiden walk stops at an inner suffix the excluded span is short, enough words survive it to clear the threshold, and the join fires and takes the MARKER — after which nothing leaves and the count that authorised the join was reasoning about a name that never came to be. So the join now declines outright when the piece it would absorb is a marker, which is what P5 meant all along: it joins the bound word to a name word, and a marker is not one. Tested directly rather than through the span, because the two differ exactly where it matters — a marker with nothing but a suffix after it has no span at all, yet the join would still have absorbed it (`Berg, abdul née PhD` read given 'abdul née'). What sharing `_maiden_span` between the count and the marker pass buys is one definition of what a maiden span IS. It does not make the two agree about what leaves: they run at different times on different piece lists, and the join between them could invalidate the span the count was taken against. Forbidding the join to absorb a marker is what closes that, not the sharing — the sharing only keeps the two from disagreeing about the span itself. Two consequences. It closes the P5 half of the join-swallow #412 records, so that issue narrows to P3's connective join (closed the same day by the #412 / #417 / #418 entry under M2, which also retires this entry's by-hand exclusion). And where the bound word is also suffix vocabulary, a declining join after a family comma leaves the S2 suffix reading and the name has no given name at all — `Berg, abd née Jones` reads family 'Berg', suffix 'abd', maiden 'Jones', which is exactly how `Berg, abd` alone has always parsed. `abd` is the only shipped word in both sets. Two alternatives to the exclusion, recorded so "reorder" is not read as one option with one price. The rejected one moves the maiden handler ahead of the PARTICLE CHAIN, which changes what the consumer receives for every name — that is the scope objection, and it stands. A narrower one was not weighed at the time: move the marker pass to run after P2/P3's joins but BEFORE P5, inside _group_segment rather than in group(). Under that arrangement the reserve counts the surviving name directly, so the span prediction, the exclusion and the marker guard all disappear, and P2/P3 are untouched. Its real cost is ordering against the tail-segment delimiter-core drop, which today runs between the joins and the marker pass and would be reversed for comma tails — unmeasured, which is why this records the option rather than taking it. No corpus name produces a #411-attributable diff — every baseline already reads these names the way the fix does — so the ledger rule records the side effect on `van der Berg, abdul née Jones` rather than the defect. That name became an M2 example in the #399 fix and reached the corpus when #414's rules-doc corpus harvested it the following day. What made it look impossible: adding the word alone changed nothing on the common three-word shape. The reserve count in the join gate excluded suffix-vocabulary pieces, and `abd` IS one, so its own piece did not count toward the three it needed — the rule declined silently. Counting the claimed piece as a name piece fixes it, and is right on its own terms: the reserve asks whether enough OTHER words are left to spare. Measured, the vocabulary-only alternatives both cost something the count fix does not — moving `abd` into SUFFIX_ACRONYMS_AMBIGUOUS loses "Jane Smith, ABD", and dropping it from SUFFIX_ACRONYMS loses the postnominal entirely. Two limits, both measured and both accepted. The three-token "abd al rahman" still does not join — `al` is a particle and chains forward, which is the half of the original exclusion that survives. And position decides at the two ENDS only: in the given slot of a family-comma name the credential still wins, so "Smith, Abd" reports suffix 'Abd' with no given name where "Smith, Abdul" reports the given name, and no ambiguity is emitted for it. Same shape as #397 (Catalan `i`, where dual membership shrinks the rootname count P3's carve-out tests), and the second instance the SAME DAY, #397 having been filed two and a half hours earlier. The general question — whether a word's membership in one set should suppress it from a count gating a rule keyed on another — is open there. - 2026-08-21 #418 (PR #420) — #411's by-hand exclusion of the departing words is gone, subsumed by the marker pass running before the reserve is counted (decisions/M2, 2026-08-21). Its other half stays: the join still declines when the piece it would absorb is a lone marker, because the only marker left by then is one the consumer declined, and P5 says a marker is not a name word — measured, removing the test re-read `Berg, abdul née PhD` as given 'abdul née'. "Lone" is load-bearing and review is what made it explicit: a declined marker the particle chain has already taken is a word of the chain, and the join takes the chain — `Abd van der Berg née Jr Jones` reads given 'Abd van der Berg née', where the old chain stop had kept the marker outside it. P5's statement now says "standing as a word of its own". The narrower reorder recorded above (marker pass after P2/P3's joins, before P5) was overtaken by the full one ahead of every join, which closes #412 and #418 as well, and the tail-segment ordering it priced as unmeasured was measured: byte-identical under the default policy, a real regression with a suffix delimiter configured, found in review and closed before merge (the M2 entry has the shapes). One row re-pinned: `abd née Jones Jr Smith Berg` reads given 'abd Jr', as `abd Jr Smith Berg` already does — P5 absorbing a suffix piece when the reserve allows is pre-existing and independent of markers, noted rather than fixed here. - 2026-08-22 #369 (its `'mr sir'` example superseded by #489, decisions/H1, and its family-first `Sheik abdul salam` family 'abdul salam' superseded by #720, decisions/H1: given under every order) — a given-name title licenses the join with one word to spare. #367 regressed `Sheik Abu Bakar` from given 'Abu Bakar' to given 'Abu', family 'Bakar', and its release note recorded why the old reading had been an accident: `abu` is a particle as well as a bound word, and the title used to displace it out of the leading position so the particle CHAIN took 'Bakar'. `Sheik abdul salam` showed that a bound word alone never joined behind a title — the reserve wanted three name words and a title is not one. The question the issue put was whether `Sheik abdul salam` is a two-word given name with no surname, or given 'abdul' plus family 'salam'. Decided on the signal the title already carries: `sheik` is a GIVEN-NAME title, and H1 reads that membership as the assertion that the one word after it is the given name, family empty. P5 now reads it the same way — behind a given-name title the family comma's LENIENT reserve applies, 'abdul salam' joins, and `family=''` is the Accepted outcome H1 records for "Sir John". Not "after any title": `Dr. abdul salam` keeps given 'abdul', family 'salam', because `dr` addresses by family and the reserve exists for exactly that word. Keyed on the whole title run through `_title_key`, exactly as post_rules keys H1, so the two rules cannot read one run two ways: 'mr sir' is not a given-name title to either. Only STRICT relaxes — the family comma's DISABLED segment has no given name to join, and its post-comma segment is LENIENT already. AMENDED 2026-09-08 (#489, the #316/#489 bundle): the key is no longer the whole run. Both sites now match the run's folded key WHOLE or by its LAST WORD, and the invariant this entry rests on — that H1 and P5 cannot read one run two ways — is what the amendment preserves rather than what it costs, both sites still reading through one predicate beside `_title_key`. What moves is this entry's own example: `'mr sir'` IS a given-name-title run to both sites now, `sir` being its last word, so `mr sir abdul rahman` joins where the sentence above says it does not. The leaf test that pinned the old reading, test_group's `test_the_title_run_is_one_key_as_h1_reads_it`, is renamed `test_the_title_run_is_read_as_h1_reads_it`; it runs the GROUP stage alone, so what it pins is P5's side — that `mr sir abdul rahman` joins — and not the agreement between the two sites. The agreement rests on the shared predicate, `_run_addresses_by_given` beside `_title_key`, which is the one place either site asks the question, and it is ASSERTED end to end by `tests/v2/test_parser.py::test_the_p5_licence_and_h1_read_a_title_run_the_same_way`: eleven title runs, each parsed in both spellings, requiring `parse(f'{title} John').family == ''` and `parse(f'{title} abdul rahman').family == ''` to agree run by run. Nothing else in this entry changes: `Dr. abdul salam` still keeps given `abdul`, family `salam`, `dr` addressing by family. See #H1 for the measurement and the vocabulary scope. Measured at all three baselines (PR #422): `Sheik abdul salam` and the `Sheik abdul salam Jr` rules example move (given, family) and no other corpus name's fields do; `Sheik Abu Bakar` returns to its 1.4.0, 2.0.0 and 2.1.0 fields and loses the PARTICLE_OR_GIVEN report it carried through 2.1, a bound word read as the bound word not being a fork; `الشيخ عبد الله` reads given 'عبد الله' where it read given 'عبد', family 'الله'. Two things review added before merge. The licence lifts the reserve for two WORDS, so the piece it lets the join take must be a single word: a particle chain is one piece, but it is the family name P2 built, and without that clause `Sir abdul van der Berg` read given 'abdul van der Berg', family '' — where the untitled name keeps family 'van der Berg', and so does `Sir John van der Berg`. H1's own scope is "exactly one name word", and the licence now matches it. `Sheik abdul al Rahman` therefore stays given 'abdul', family 'al Rahman', the same limit the `abd` entry above records for the three-token spelling. `Sir abdul van der Berg` is a rules.md example and so sits in the rules corpus, where it reads byte-identical to every baseline — the gate witnesses the clause; `Sheik abdul al Rahman` is in no corpus, and the group test is its only witness. And P5 now states the precedence `Sheik Abu Bakar` only exercised: a bound word that is also a particle is read as the bound word, the join outranking P4's leading-position reading, with no fork reported. That is the reading `Abu Bakar Salim` has always had — P5's join and P4's "the words stay separate" were never reconciled in writing — and the two rules now point at each other. Three more from the PR review. The licence takes a name WORD, not a suffix: lowered to LENIENT, the reserve would let the join absorb a suffix piece — `Sheik abdul Jr Smith` read given 'abdul Jr' — which is the shape #421 records for the post-comma LENIENT path and which the STRICT reserve had kept away from titled names; the licence now declines a suffix piece on its own, and #421 will make that decline general. One shape it cannot decline belongs to #401: `Sir abdul V` reads given 'abdul V', because the reserve's initial veto counts a bare `V` as a name word, and the lowered threshold now lets that count fire where STRICT did not — recorded in #401 rather than fixed here. And the join is order-blind, so under a family-first order the licensed pair lands in the FAMILY with no given name: `Sheik abdul salam` reads family 'abdul salam' under both `FAMILY_FIRST` and `FAMILY_FIRST_GIVEN_LAST` (was family 'abdul', given 'salam'), as `Sir John` reads family 'John' there — H1's exemption never runs under a family-first order, so the two rules still agree. Accepted rather than scoped to the given-first read: the effective order is settled in assign, after group, and the outcome is the one H1 already gives the same shape. Two corpus names move under each family-first order, `Sheik abdul salam` and `Sheik Abu Bakar`, measured by hand — the gate reads the default order only. - 2026-08-22 #401 / #421 — the reserve and the join ask the same question, and it is the one assign answers. Two defects, one cause: P5 says the join "needs a name word to spare" and joins "the word after it", and neither site knew what a name word was. The reserve counted the pieces that were not suffix pieces, and `_is_suffix_piece` vetoes on the initial tag — right for assign's middle-initial question, wrong for the reserve's: a bare `V` carries both `vocab:suffix` and `initial`, so `abdul Smith V` counted three name words, joined, and assign then read the `V` as the suffix it always was — given 'abdul Smith', family '' (#401; shipped since 1.x — 1.4.0 read first 'abdul Smith', last 'V' — every bound word, only the single-letter numerals V, I, X). The join never asked at all: with a word to spare it took a suffix piece as "the word after" — `abdul Jr Smith Berg` read given 'abdul Jr' (#421; 1.4.0 parity) — and where the suffix was the split credential, merge() unioned the piece tags, the joined piece became a suffix piece, and assign sent the bound word to the suffix field: `abdul Ph. D. Smith Berg` read suffix 'abdul Ph. D.', a 2.0 regression (v1's fix_phd extracted the credential before parsing, so position never mattered). Fixed with one predicate, `reads_as_suffix`: a suffix piece, or the trailing roman numeral assign's fork takes — and that fork is now `_vocab.is_trailing_numeral_suffix` (`_vocab.reads_as_generation` since #752), lifted out of assign so the two stages share its definition instead of the reserve carrying a copy (the `_ROMAN` pattern moved with it; the regex-sync pin follows it). The reserve counts a piece out when it reads as a suffix, and the join declines the piece it would take when it does — the same decline it already made for a marker. The mirror is assign's fork condition for condition: the numeral is the LAST piece and not the first name piece — not "the piece before it is not a title", which the first draft wrote and the design-docs review falsified, `jr` being title vocabulary as well as a suffix, so `abdul Smith Jr V` lost its family exactly as before — and the piece before it is read as the join would leave it, the joined pair's first token being the bound word (`abdul J. V`, the design-docs review's other find, otherwise suppressed the fork for the reserve alone and read given 'abdul J.', family ''; the reserve now declines, and assign, seeing the unjoined pieces, reads given 'abdul', middle 'J.', family 'V', exactly as it reads `John J. V`). Two more the code review found: assign drops the group-flagged credential pieces (the Ph. D. merge) from its walk at any position before the trailing peel, so a numeral can be last in that walk without being the last piece — `abdul Smith V Ph. D.` lost its family until "last" and "the piece before" were read over the pieces assign keeps — pinned at the piece level only, because a trailing split credential is the shape the 1.4.0 ledger's `[[never]]` entry refuses to explain any diff on, and v1's reading of that name carried the #401 family loss; the example stayed out of the rules corpus rather than the never-entry being narrowed for one bound-word spelling; and the post-comma walk has no numeral fork at all (a trailing numeral there is read by a lenient last-of-two rule, or as a middle initial with a third part), so a mirror applied under LENIENT declined `Berg, abdul V` and `Berg, abdul V, jr` against every baseline with nothing classifying it — the numeral branch is scoped to STRICT, and after a family comma the reserve counts suffix pieces alone, as it always did. So `abdul Smith V Jr` still joins and assign reads family 'V', and `abdul V Smith` still joins an initial, which is a name word. What is NOT mirrored: S2's other positional fork, the bare ambiguous acronym. Where the join leaves two pieces no family is lost — `abdul Smith Ma` reads family 'Ma' where `John Smith Ma` reads suffix 'Ma', assign keeping the acronym as the family of a two-piece name — but with a suffix word between, the join leaves three, assign peels the acronym and then the suffix, and the family is gone: `abdul Smith Jr Ma` reads given 'abdul Smith', family '', suffix 'Jr, Ma' where `John Smith Jr Ma` keeps family 'Smith'. Pre-existing and byte-identical before this fix, and the whole of what remains of #401's shape on the main walk: a review sweep of the STRICT path found 132 family-losing shapes, every one ending in a bare ambiguous acronym. Left for #425 rather than widened into here, and framed there as sharing assign's peel over a post-join VIEW rather than mirroring a second fork by hand: with a view the count is the view's length, the remap and the first-piece carve-out fall away, and the other two sites that stop at a suffix with the same initial veto — P2's chain and M2's walk, which take a trailing numeral the same way (#424) — would ask the question once. The walk was also computed once rather than per piece after review measured the per-piece rebuild as quadratic (13x growth for 4x the input against the benchmark's 6x bound, on a shape no benchmark unit led with); `bound_given` is that unit now. Readings: `abdul Smith V` → given 'abdul', family 'Smith', suffix 'V', the SUFFIX_OR_NAME report unchanged; `abdul Jr Smith Berg` → given 'abdul', middle 'Jr Smith', where `John Jr Smith Berg` puts it; `abdul Ph. D. Smith Berg` → given 'abdul', middle 'Smith', suffix 'Ph. D.' — the SUFFIX field is 1.4.0's again, the given is not: v1's fix_phd extracted the credential before parsing and the join then took 'Smith', reading given 'abdul Smith' — which the issue reports correctly, and then mislabels its own proposed reading as "1.4.0's reading restored". The decline is chosen over reaching past the credential for the next name word, because P5 joins "the word after it": a join that skipped a suffix would read a shape no one writes as if the suffix were not there, and the marker decline already sets the precedent that the join takes the word after it or nothing. A judgement, recorded as one; `Berg, abdul Jr Smith` → suffix 'Jr'; and `Sir abdul V`, the shape #369's licence had widened #401 onto, reads suffix 'V'. #369's licence-local suffix clause is retired, subsumed by the general decline. `bound_given_join_sees_only_the_surviving_name` re-pins to given 'abd', middle 'Jr Smith', as the #418 entry anticipated. No differential corpus name has either shape — measured: the gate was byte-identical at all three baselines before the rules.md examples went in — so the examples carry the shapes into the rules corpus, where they move against every baseline and are classified there. The 1.4.0 `fix(#401)` rule lists three fields where the 2.x rules list two, because 1.4.0 did not even read the V as a suffix; a draft with two fields handed the name to the fields-only suffix-routing catch-all, whose capped printout hid it, and this entry briefly called #401 a 2.0 regression on that inert measurement — the design-docs review caught it. EXPLAINED NOTHING is not byte-identical. - 2026-08-22 #425 (the view the reserve compared, and the #369 precedent applied to `Sheik abdul Ma`, superseded by #614) — the reserve runs assign's peel over the view the join would leave. #423 had the reserve re-derive assign's trailing peel by hand — the flagged-credential drop, the walk's start, the numeral fork's conditions, the preceding piece remapped to "as the join would leave it" — with each seam held by a pin, and the bare-ambiguous-acronym fork not mirrored at all: `abdul Smith Jr Ma` read given 'abdul Smith', family '', suffix 'Jr, Ma' where `John Smith Jr Ma` keeps family 'Smith', the whole residue of #401's shape on the main walk (every family-losing bound-word shape in the sweep below contains one). The altitude review's framing is taken: the peel is one function, `_peel_trailing` over the list `_peel_walk` builds (the walk's input defined once too, after the /simplify pass found it spelled at three sites), housed in group beside the piece predicates because assign imports those and group cannot import assign; assign calls it and reports what it returns (the numeral fork's piece, the acronym picks, in the order it always reported them — byte-identical over the suite and the three gates); and the reserve builds the VIEW merge() would leave — the pair one piece, its tags the union — and calls the same function. `BoundJoin`'s "the value IS the threshold" trick survives restated as post-join name pieces: STRICT 2, LENIENT 1. The remap and the `k == first_name_k` carve-out are gone, the joined piece being two tokens and never a suffix piece. - 2026-10-07 #614 — THE RESERVE READS NO VIEW: S2's trailing run is read once, after P3's joins and before the bound-given join is tried, and split off, so the words to spare are the name pieces it left, and the join can move no suffix reading because nothing in group reads the run again (rules/P5 amended; decisions/S2, this date). SUPERSEDES the two-view comparison, which survives only where the run is not read there (a run with a name piece inside it), and with it two pinned readings: `abdul J. V` and `Sir abdul J. V` now join, given 'abdul J.' with family 'V' -- unjoined the initial in front suppresses the numeral fork, and that is the one reading taken now, where the joined view had read the V as a suffix and declined; 1.4.0 read the same, 2.0 and 2.1 suffix 'V', 2.2 and 2.3 middle 'J.' -- and `Sheik abdul Ma` reports the pick the read weighed (`suffix-or-name`), superseding the #425 bullet's application of the #369 precedent to the pair's suffix fork: the read decides 'Ma' before the join, so the fork is a fork. #369's own precedent, a bound word that is also a particle read as the bound word with no PARTICLE_OR_GIVEN report, is untouched (`Sheik Abu Bakar` reports nothing). Accepted by Derek as a class with the rest of #614's moves. (Corrected 2026-10-21: this paragraph and the next continue the 2026-08-22 #425 entry above, PR #426; the #614 bullet was inserted between them on 2026-10-07.) One principle came out of writing the ledger that the view alone did not state. `abdul Smith Ma` had read given 'abdul Smith', family 'Ma' since 2.0, and the #423 entry called that the lossless shape; the 1.4.0 gate's catch-all count said otherwise — 1.4.0 reads first 'abdul', last 'Smith', suffix 'Ma', as `John Smith Ma` reads, and the fields-only catch-all had absorbed the diff since the example went in (the count is the tell, per the #401 entry). Unjoined, the acronym has words to spare and peels as a credential; joined, the view is two pieces and the fork keeps it as the family — the join was CHANGING what assign reads. So the rule is stated as P5 now has it: the join joins two name words into one and changes nothing else, which the code asks directly: the pieces the peel takes are the same either way (a count proxy, "the view leaves exactly one name word fewer", was the first spelling; the altitude review pointed out it held only because the peel is a tail walk, and the comparison states the rule). That comparison subsumes the unjoined-piece check #423 added for `abdul V` (unjoined the peel takes the V, joined nothing) and restores `abdul Smith Ma` to 1.4.0's reading; `abdul Smith Berg Ma` keeps its join, the acronym peeling either way. Every #423 pin holds. Measured (PR #426): the 1.4.0 gate byte-identical at the code change, the catch-all back at 15, and `abdul Smith Jr Ma` and `abdul Smith Ma` moving against 2.0.0 and 2.1.0; over the design-docs review's 67,860 constructed main-walk shapes (abdul, abd, abu × a twelve-word pool × tails of one to four, no comma shapes; the pool is the claim), 2,160 had lost the family, every one holding a bare ambiguous acronym, none does after, and 5,166 move against master in all. The title-word class below was outside that population and moves after a family comma too (`Berg, abdul Sir`); so was the licence class at the end of this entry. Three things the principle does not reach, recorded so nobody applies it there. The post-comma LENIENT path is untouched (AMENDED by #637, below: its decline now reads H5's trailing run where a title word follows the bound word), and NOT for the reason first written here ("its walk has no peel" — it reads a trailing numeral by the lenient last-of-two rule, as the #423 entry says): the family is fixed there and the pair is the given whatever follows, so the principle is not applied, and `Berg, abdul V` keeps given 'abdul V' though the V would be the suffix unjoined — 1.4.0 parity, and exactly the regression #423 measured when a mirror was applied there; P5 now carries it as Accepted. The vocabulary decline is the UNAMBIGUOUS vocabulary: a bare ambiguous acronym the peel does not take is a name word, so `abdul Ma Smith` reads given 'abdul Ma' (1.4.0 read middle 'Ma' — a 2.x difference no corpus witnesses, now an Accepted example). And the principle is about the suffix reading, not the report: `Sheik abdul Ma` reports no SUFFIX_OR_NAME where `Sheik John Ma` does, the #369 precedent — a bound word read as the bound word is not a fork. One more class moves, found by the code review and then measured against 1.4.0: the 2.x reserve had excluded TITLE pieces from its count — an unrecorded deviation from v1, whose join counted suffixes out and nothing else — and the shared peel, following assign, reads a mid-name title word as the name word it is. `Berg, abdul Sir` (given 'abdul', middle 'Sir' since 2.0) and `abdul Smith Sir` (declined since 2.0) read given 'abdul Sir' and given 'abdul Smith', family 'Sir' — 1.4.0's readings, both; `abdul Sir Smith Berg` never stopped reading given 'abdul Sir'. No corpus name has the shapes that move, and no rules example carries one — `abdul Sir Smith Berg`, P5's example of the reading, is byte-identical at every baseline, so the class is pinned in tests alone (the release log says so; the late review of this entry found the sentence here implying otherwise). The same review found the clause had taken `do`, `freiherr` and `st` from P6 — recorded under P6. What the review actually caught was the tag artefact that exclusion had been hiding: the conjunction merge derives a `title` piece tag for a chain such as "Sheikh and Ahmad", merge()'s union handed it to the pair, and assign peeled the pair as a leading title — `abdul Sheikh and Ahmad Bakar Smith` read title 'abdul Sheikh and Ahmad' on 2.0 and 2.1 (v1 read it as the given), and `abdul Sheikh and Ahmad Bakar`, which the exclusion had kept unjoined, would have too once it went. The bound join drops the tag, and the view drops it the same way — the pair is a given name whatever tag the word carried. One more class the comparison moves, found by the code review's classification of every residual master-to-branch diff (12 of 17,640 constructed names — a fourteen-word pool, bare and behind `Sir`, tails of two and three, 6 × (14² + 14³); the review did not record the pool — one shape; 360 of 360 over {Sir, Sheikh, Dame, Sheik, King, Father} × {abdul, abd, abu, umm} × {J., V, X, A., I} × {V, X, I}): behind a given-name title, a numeral after an initial-shaped piece. `Sir abdul J. V` read given 'abdul J.', suffix 'V' since #369 — the fork suppressed unjoined, fired joined, and the licence's threshold of one let the join through — and the comparison declines it: given 'abdul', middle 'J.', family 'V', exactly as `Sir John J. V`. Pinned at both levels; it was the only shape a looser comparison let through under mutation at the test review. #424 — P2's chain and M2's walk taking a trailing numeral — is the remaining asker of the same question and now has `_peel_trailing` to ask it of. - 2026-10-11 (Derek), #637 and #640 — THE BOUND JOIN DECLINES A TITLE WORD A TRAILING RULE READS AS A TITLE. P5 joins a bound given-name word to the word after it, and declined a maiden marker and a suffix piece; a title word it took, so where a trailing rule would have read that word as a title the joined piece was no title word to read. After a family comma the join reads no peel (the LENIENT reserve, #423), so `Berg, abdul Prof.` read given 'abdul Prof.' where `Berg, John Prof.` reads title 'Prof.' (#637, the gap the 2026-09-09 A1 entry recorded); in a part past the second comma after a suffix comma, where the join runs STRICT, `John Smith, Jr., abdul Dr. Smith` read suffix 'Jr., abdul Dr. Smith' where `John Smith, Jr., Berg Dr. Smith` reads title 'Dr.' (#640). Now the join declines a title word a trailing rule reads as a title: after a family comma, one H5's trailing run takes from the given part (`tail_reading`, asked only when a title word follows the bound word), and in a part past the second comma any title word, which C2 reads as a title. `Berg, abdul Prof.` → title 'Prof.', given 'abdul'; `John Smith, Jr., abdul Dr. Smith` → title 'Dr.'. DECIDED (Derek): this narrower clause (a′) rather than declining every title word (a). P5 states that a title word standing in the name is a name word and joins like one (H3), and (a) undid it: it failed six tests, a P5 rules example and the two tests named for that clause among them, and over the bound-given grid below moved 4,044 parses, 3,000 of them a join split with no title read (`(Bob), Abu Bishop` → given 'Abu', middle 'Bishop') beside the 1,044 that (a′) moves (six tests measured against 4455962f's suite). (a′) leaves every join on a title word inside the name standing -- `abdul Sir Smith Berg`, `Berg, abdul Prof. Ali`, `Smith, abdul Bishop`. MEASURED 2026-10-11 against 4455962f (#639's merge; this change is PR #643), over a 5,184-text bound-given grid (twelve heads × `abdul`, `Abu`, `abd al` × sixteen next words × nine tails) under the three orders: 1,044 parses move, every one a title word into the title field, 660 of them in a part past the second comma; #623's given-part grid moves 280, 260 of the same class and 20 the declined join below; no corpus name or case text but `Berg, abdul Prof.`, and nothing on the S2, #624 or #625 grids; the five gates exit 0; frames unmoved on the reference rows, up to `Berg, abdul Prof.` 207 → 228 on rare shapes no guard sees; recorded negative controls, 11 failing tests with no title declined and 5 with the past-the-second-comma half removed. THE REVIEW (2026-10-11). The decline after a family comma asks H5's run as the comma-less name reads it (`tail_reading`), and the given part's own walk reads some words behind a title otherwise, the disagreement #623's entry (decisions/H5) records for the chain's bound: there the join is declined and no title is read. A shape-only numeral VI to X, or V before a third comma part, which the walk keeps a name word (`Smith, abdul Prof. VI` → given 'abdul', middle 'Prof. VI', where master and 1.4.0 read given 'abdul Prof.', middle 'VI'); a period-marked title that is also a particle, which P6 then attaches with its report (`Smith, abdul St.` → family 'St. Smith', particle-or-given); and a bound word that is also a particle before one (`Smith, Abu St.` → title 'St.', where `Smith, Zed St.` reads family 'St. Smith'). Every such shape is garbage; the first two read as the same name with an ordinary given word does. Recorded, not narrowed, as #623's disagreement was (Derek, #623), and rules/P5 states it as an Accepted clause; the review's sweep found nothing reading worse than master on a real name and no comma-less name moving. It also found two mechanisms no test held, now rows: the bare-title half past the second comma (`John Smith, Jr., abdul Sir Smith`) and the whole trailing run after a family comma rather than the last word (`Berg, abdul Prof. Dr.`). - 2026-10-25 #785 — AFTER A FAMILY COMMA THE JOIN DECLINES A MEMBER ITS CAPITALS SETTLE, as it declines a word of the unambiguous suffix vocabulary: in a name of mixed case a listed member of the ambiguous class written in capitals is the credential (rules/S6), and the given part's trailing slot or S8's run then takes it, so `Smith, Abdul MA` reads given 'Abdul', suffix 'MA', where 1.4.0 and 2.6.0 read given 'Abdul MA' (`Smith, abdul ma` still joins). With no comma the join stands (`Abdul MA Smith` → given 'Abdul MA'): declined there, nothing takes the member, which reads as a middle name in silence, as the review of the fix found. Found by #785's code review; the record is decisions/S6's review entry of this date. derek73-python-nameparser-456b024/docs/design/decisions/P6-trailing-orphan-particle.md000066400000000000000000001237101527303271700306000ustar00rootroot00000000000000# P6 — the trailing orphan particle ## Standing Examples read under the default order unless a line names another. - After a family comma a particle run ending the name attaches to the family the comma named and renders before it (`Jong, Anke de` → family 'de Jong'; `Vega, Juan de la` → family 'de la Vega'), the surnames view following — 2026-08-16 #379, #380, #365. - The attachment is keyed on the token's vocabulary, not its assigned role; the words-to-spare guard is a piece test that must leave a GIVEN word ahead of the run (`Nguyen, Van` → given 'Van'); and the walk looks past trailing pieces that hold no name, unless they are particle vocabulary, so `Berg, Jan van Jr.` reads as `Berg, Jan van, Jr.` does, family 'van Berg' — 2026-08-18 #379/#380 IMPLEMENTED. - After a family comma a word of both the particle and the suffix vocabulary is the particle, P6 outranking S2, except where the capitals, S7's company or P9's post-nominal run speak (the #531 line and Elsewhere, below) (`Berg, Jan vd` → family 'vd Berg'), the shape sweeping in `mc` unweighed (`Berg, Jan mc` → family 'mc Berg') — 2026-08-16 (pre-merge coherence pass). - `Jong, vd`, with no given word left, is the listing form's (rules/C8): family 'Jong', suffix 'vd' — 2026-08-23. - P5's bound join runs first and takes a trailing particle as its pair (`Berg, abdul van` and `Berg, abdul do` → given 'abdul van', 'abdul do'), except one of the unambiguous suffix vocabulary too, which the join declines and the attachment takes (`Berg, abdul vd` → family 'vd Berg') — 2026-08-22 (late review of #425's docs, PR #426). - The attachment reports the fork it decides, in the kind naming the reading it overrode: `suffix-or-name` where the run had been read as a post-nominal (`Berg, Jan vd`), `particle-or-given` where it holds an ambiguous particle (`Nguyen, Thi Van`, `Beethoven, Ludwig van`), and nothing for a never-given particle (`Jong, Piet de`) or a run read as name words with no ambiguous member (`Berg, Jan de vd`); where a caller's word reaches both arms the suffix arm wins — 2026-08-29 #405. - Without a comma the attachment fires under FAMILY_FIRST alone, where the run ENDS the name and stands in a MIDDLE, with no vocabulary test and no re-layout, the family needing a base of its own (`Beethoven Ludwig van` family-first → family 'van Beethoven'; `van Berg Jan de` family-first → family 'van'); under the default order the positional reading stands (`Jong Anke de` → family 'de'), and the given position under a family-first order is no P1 site (`Ménil de` family-first → given 'de') — 2026-08-30 #467. - A member of the ambiguous credential class written in capitals in a mixed-case name is the credential and the attachment stands down (`Doe, John DO` → suffix 'DO'), where `Doe, John Do` and the one-case `SMITH, JOHN DO` still attach; the condition reads the ambiguous tag, not the suffix role, so `vd` and `mc` stay in the run — 2026-09-18 #531. - The comma site is decided in assign, right after the walk over the given part, asking P1's condition itself so `Smith, de Mesnil van` stays the fold's (family 'Smith de Mesnil van'); the no-comma site stays in post_rules — 2026-10-06 #613. - The comma site is assign's behind an EMPTY part before the comma too, ahead of H1, which then finds a family and stands down (`, Mr. Jones vd` → title 'Mr.', given 'Jones', family 'vd', as `Smith, Mr. Jones vd` reads given 'Jones', family 'vd Smith') — 2026-10-27 #892 (Derek). - The elided particles and the hyphen-attached Arabic articles, a closed list in each apostrophe spelling, are never-given particle vocabulary, and the rule is unchanged: `Ivoi, Paul d'` → family "d' Ivoi", `Rashid, Harun al-` → family 'al- Rashid'; a glued form is one word; and case repair asks the whole token the particle question first — 2026-10-17 #700. - The comma site and the no-comma family-first site are held to each other by tests/v2/test_order_correspondence.py, which a change breaking either side should expect to fail first — 2026-10-20 #761. Not standing: 2026-08-16; the `tracked: #379, #380` pointer it put in place of `implemented:` — the rule shipped two days later (2026-08-18 #379/#380 IMPLEMENTED) and carries `implemented:`; the one-of check that entry added still holds every rule. Elsewhere: a word of both the particle and the unambiguous suffix vocabulary inside a run of post-nominals keeps the post-nominal reading, rules/P9's since this file's 2026-10-19 split — decisions/P9's 2026-10-02 #573 entry; a member of the ambiguous credential class behind an unambiguous credential in one run of suffix words is the credential in any spelling and case, and the attachment stands down (`Doe, John PhD Do` → suffix 'PhD Do') — decisions/S7's 2026-09-27 (Derek), #544 entry headed THE #531 PAIRING GAINS A SECOND EXCEPTION; a capital `DO` with a particle in front of it still attaches, the two being one run (`Doe, John van DO` → family 'van DO Doe') — decisions/S5's 2026-09-18 (Derek), #531 entry CAPITALS DECIDE FOR `do`. Behind a trailing title after a family comma P6 reads the particle as though the title were absent (`Smith, Jan van Prof.` → title 'Prof.', family 'van Smith') — decisions/H5's 2026-10-10 (Derek), #623 entry; what `vd` means as a word is decisions/vocabulary-collisions' C-ii, and `mc`'s inheriting it was closed by design in #454 (decisions/suffix-acronym-collisions). Open: none in this file. `Jong, vd` is no longer an open question here (2026-08-23), and #380's was decided 2026-08-19 in the Log's "Decided" line. ## Log - 2026-08-23 — `Jong, vd` is C1's now, not P6's: C1's listing-form clause (#296's bundle, rules/C8 since #728) reads a post-comma part that is nothing but suffix words as the credential run, and `vd` is suffix vocabulary, so the row reads family 'Jong', suffix 'vd' by statement (it read so before, by the lone-piece deviation). The #380 scope note below calling it deliberately unresolved is superseded for that one row; the P6 question — a tussenvoegsel with no given word left — is unchanged. - 2026-08-18 #379/#380 IMPLEMENTED (its attachment overriding a post-nominal `do` superseded by #531, below, and by decisions/S7's #544 behind a credential, and for a `vd` inside a post-nominal run by decisions/P9's #573) — the rule shipped as stated, both `deviates:` markers out. Keyed on the token's VOCABULARY rather than its assigned role, which is what gives the S2 precedence its effect: assign reads a trailing `vd`/`mc`/`do` as a post-nominal, and the attachment overrides that after a comma. The words-to-spare guard is a PIECE test, not a count — every trailing piece that is wholly particles attaches, and the run must leave a GIVEN word ahead of it, so "Nguyen, Van" keeps its only given word. Not "a name word": see the defect below, where that wider reading passed on family text P1 had just produced. MIDDLE was in the test briefly and came out — 0 hits over 740,552 instrumented guard sites, and structurally unreachable, since the only rule that leaves a MIDDLE with no GIVEN ahead of it is gated on state.order, which assign never records on the family-comma path P6 runs on. Measured (PR #403): ONE of 751 corpus names moves ("Vega, Juan de la"), at all three baselines, and it needed a new ledger rule in each. Two defects found in review, both from the rule's neighbours rather than from its own logic. P1's fold runs EARLIER in the same function and retags all of segment 1 to FAMILY, so a words-to-spare test written as "some name role remains" passed on family text P1 had just produced — and the attachment then hoisted the particle in front of a base it never preceded ("Smith, de Mesnil van" → 'van Smith de Mesnil'). 1.4 gives 'de Mesnil van Smith' for that input and 2.1 gives 'Smith de Mesnil van', so neither the defect nor the fix restores 1.4 — what the fix restores is 2.1, the pre-comma-family reading having moved back in 2.0. The test has to name GIVEN, which is what the rule says. And a post-nominal sits BEHIND the tussenvoegsel in this listing, so the backward walk stopped on it and the attachment never fired: "Berg, Jan van Jr." and "Berg, Jan van, Jr." parsed two ways on nothing but a comma. The walk now steps over a trailing piece holding no name, unless it is itself particle vocabulary — `vd` arrives suffix-roled and IS the run. Fallout worth its own record: "Vega, Juan de la" was the input tests/test_initials.py used to build an ALL-PARTICLE middle name, and this rule takes that shape away from THAT input. It does not remove the shape — "Vega, Santa de la", "Berg, Van der" and "Nguyen, Van Le" all still produce one in the default order, because the trailing run swallows the given word too and the attachment then declines. The test is repointed at the last of those rather than deleted. Whether such a middle should exist at all is #402 — for a never-given particle it is #365's defect, and for an ambiguous one the parser reports it as a middle name and then declines to initial it, which is a miscount either way. - 2026-08-16 (order-precedence keystone; #379, #380, #365) (the comma-only scope of its Scope bullet superseded by 2026-08-30 #467, below, for FAMILY_FIRST) — a particle ending the name has nothing to link forward to, and no particle is a name by itself, so it attaches to the family name standing beside it and renders BEFORE it. The distinction from a chain is what makes this a rule rather than an exception: a chained group has a head word and can be positioned; an orphan has no head, so position has nothing to work with. - Scope: the COMMA form only, deliberately. "Jong, Anke de" is unambiguous — the comma has already named the family. Without the comma the written shape is not settled: "Jong Anke de" may be a misformatted listing (arguably a missing comma under a declared family-first order) and "Jong de" may be a given name beside a particle. Those keep their positional reading and are not tracked as deviations. - The words-to-spare guard is load-bearing, not incidental. #379's own subject is "van", which is in the AMBIGUOUS half — so a rule keyed to never-given particles alone would not fix the issue it was filed for, while a rule with no guard breaks Vietnamese "Nguyen, Van" (given="Van") by eating the only given word. The guard is S2's shape, reused: consume only when the name has words to spare. - Rendering before the family has precedent in rules/R1 — folded family words under O3 already "render before the rest of the family wherever they stood in the string". The surnames view renders backwards today ("Vega de la", "Jong de") and flips with this rule. - Measured under this rule's OWN comma scope, over 782 corpus names (245 of them comma-bearing): exactly one moves, "Vega, Juan de la" → family="de la Vega". A pre-merge fact-check corrected an earlier count of three here — "Smith van der" and "Sander van" have no comma, so the rule as scoped does not reach them; they were measured before the comma scoping was chosen and carried forward unfiltered. - What that number means is the opposite of reassuring. The corpus holds 245 comma names, TWO of which end in a particle and one of which clears the words-to-spare guard ("Nguyen, Van" is the other, and the guard is what keeps it a given name), so it is very nearly blind to the shape this rule governs — Dutch and Flemish listings are barely represented. Treat the one as evidence about the corpus, not about the blast radius, and run tools/differential at all three baselines in the implementing PR regardless of how small this looks. - 2026-08-16 (pre-merge coherence pass) (its unqualified precedence over S2 superseded in part by #531, below, decisions/S7's #544 and decisions/P9's #573) — P6 and S2 CONTEST "Berg, Jan vd": today S2 wins and reports suffix="vd", while P6's marker asserts family="vd Berg". P6 wins, and the rule says so in its statement rather than leaving the pair to file order. The reason is C-ii's, not a new judgement: vd as the British Volunteer Decoration is rarer than vd as van der, and a trailing abbreviation AFTER A FAMILY COMMA is the tussenvoegsel position specifically. Scope check on the precedence, so it cannot creep: it reaches only words that are both particle and suffix vocabulary, in the trailing-orphan position, under a family comma, with a given word to spare. "John Smith, PhD" and "Smith, Jr." are untouched (not particles), and "Jong, vd" is untouched (no given word remains), which is why that row stays out of scope rather than becoming a counter-example. Recorded because the pair was declared in `interacts:` and left unresolved — `interacts:` is advisory by design and pins nothing, so a declared interaction is a prompt to state the outcome, not a substitute for stating it. - 2026-08-16 — P6 is the first rule in rules.md that nothing implements. Legitimate per the preamble (the document is normative, and a gap is a tracked deviation), but it left the rule pointing at nothing, so the shape got a pointer rather than an exception: `tracked: #379, #380` in place of `implemented:`, with exactly one of the two required of every rule. An unimplemented rule can no longer sit here untracked, and a shipped rule cannot keep a stale tracking pointer after its issues close. Mutation-tested three ways before being believed (drop the pointer, carry both, malformed refs); each fails. - The RATIONALE first shipped here was wrong and is corrected: it read "no particle is a name by itself", which is true only of the never-given half — decisions/vocabulary-collisions says the opposite in as many words ("most particles are short words that double as names"). The error mattered rather than merely reading badly: the words-to-spare guard exists BECAUSE the rule reaches ambiguous particles (#379's own subject is "van"), so the rationale undercut its own guard. Caught in review, after a coherence pass that interrogated rule-vs-rule pairs and never checked rule-vs-decision-record — which is the gap to close next time, not a one-off. Decided 2026-08-19 (was Open: [#380](https://github.com/derek73/python-nameparser/issues/380), "Berg, Jan vd" under this rule): the attachment reads `vd` as *van der* and joins it to the family the comma named (family "vd Berg"); the vd reading itself is decisions/vocabulary-collisions (C-ii). The no-given-word case "Jong, vd" is still deliberately unresolved — see the scope note. - 2026-08-22 (late review of #425's docs, PR #426) — the bound join runs before P6 sees the name, and P6 now says so. P5's new title-word clause took `do`, `freiherr` and `st` — title AND particle vocabulary, until #296's audit took `do` out of TITLES, since when the join takes `do` as the particle it is — away from the attachment: `Berg, abdul do` had read family 'do Berg' since #379 and reads given 'abdul do' now. That is 1.4.0's reading, and it is what every other particle already did: `Berg, abdul van` read given 'abdul van' at 1.4.0, on 2.0 and 2.1, and on master before #426 — the old reserve's title exclusion had held only the title-word trio back. The precedence is the stage order (P5 joins in group; P6 attaches in post_rules, over what is left -- in assign since #613, after the given part is read, still over what P5 left) rather than a judgement about tussenvoegsels behind bound words, and both rules carry it as Accepted with `Berg, abdul van` as the example, parity at every baseline. The precedence has one exception, found by the review of #424's docs when the Accepted clause claimed the attachment "never sees" the particle: a trailing particle of the unambiguous suffix vocabulary too — `vd`, `mc`; `do` is ambiguous, and a name word to the join — is a suffix piece to the join, which never takes one (#421), so the attachment does see it, and `Berg, abdul vd` reads family 'vd Berg' where 1.4.0 read given 'abdul vd'. Both rules say so, with the example; at 1.4.0 the fix(comma-precomma-family) rule already reaches it (a rule written for it was shadowed, and dropped). - 2026-08-29 #405 (the A1 deviation, reversed) — the attachment reports the fork it decides, and the Accepted clause saying it does not is deleted rather than re-tracked. What that clause rested on was assign's own scoping comment: after a comma that fixed the family, a leading given-position particle is "not meaningfully ambiguous". True of the FAMILY — the comma names which piece that is — and not of the particle standing behind the given name, which P6 decides on its own a stage later, out of the same vocabulary that makes `Van Johnson` report `particle-or-given` with no comma in sight. The old state was the shape A1 forbids: `Van Johnson` reported the fork, `Nguyen, Thi Van` decided the identical fork silently and lost a given name doing it. TWO kinds, keyed on WHAT THE ATTACHMENT OVERRODE and not on what the words are, because `mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE` asks the emitted fork to be the one taken. Where assign had read the run as a post-nominal, the overridden reading is the post-nominal — `suffix-or-name`, which is exactly the S2 precedence this rule already claims in writing. Otherwise, where the run holds an ambiguous particle (`van`, `Van`, `la`), the overridden reading is that word as a name word — `particle-or-given`. One kind for both would assert "particle or given" about a word the parse had read as a credential and never weighed as a name, naming a branch nobody considered. The key is the READING and not the vocabulary, and the docs review caught a first draft that said otherwise ("a word that is also suffix vocabulary declines a post-nominal"). That wording is wrong for both of the words it most obviously describes. `do` is the third member of the particle ∩ suffix-acronym overlap and reports `particle-or-given`: it sits in the AMBIGUOUS acronym half, which had already left it a name word, so no credential reading was ever taken for the attachment to override. And a run of several words is read whole — `Berg, Jan de vd` reports NOTHING though `vd` is suffix vocabulary, because behind `de` the run was read as name words, where the lone `Berg, Jan vd` reports `suffix-or-name`. Both are pinned in tests/v2/cases.py; neither is in any corpus, so nothing else would have caught the wrong statement. The two arms are ORDERED rather than asserted disjoint, and that is a correction to the plan, which recorded them as disjoint by construction ("a suffix-vocabulary particle arrives suffix-roled and IS the whole run"). That premise is a property of the SHIPPED vocabulary, not of the rule: `vd` and `mc` are the only words in both the particle and the unambiguous suffix sets and neither is ambiguous particle vocabulary, so no shipped input reaches both arms — measured, 0 both-arm runs over 29,400 generated names (every ordered pair of the 70-word particle vocabulary in six family-comma shapes: bare, behind a given word, behind a title, before `Jr.`, before `MD`, and with the pair split across the comma). But a caller's `Lexicon` may put one word in both, and the `assert` the plan asked for DID fire on `Berg, Jan zz` under a Lexicon adding `zz` to `particles_ambiguous` and `suffix_acronyms`. `rules/A1` says parsing never fails on any input and names exactly one exception, a caller's segmenter; an `AssertionError` out of a stage would be a second. So the code decides instead: the suffix arm wins, because assign had read the word as a post-nominal and the name-word reading was never on the table for the attachment to decline. Pinned in tests/v2/pipeline/test_post_rules.py. The never-given half stays SILENT, and that is the substantive half of the decision. `Jong, Piet de` has no live alternative: `de` is not a name in any reading and is not suffix vocabulary either, so the attachment chose between one reading and nothing, which is not a fork. Reporting it would be "the code got here" keying, the failure this project already has on record (a fork reported for every ambiguous particle at once on `Dr. Van Jr.` — the count is left out on purpose, since decisions/vocabulary-collisions says to recompute before quoting it). Measured over the 1094-name differential corpus (PR #460): P6's attachment fires on 9 names, 5 on the ambiguous arm, 2 on the suffix arm (`Berg, Jan vd`, `Berg, abdul vd`) and 2 silent (`Jong, Anke de`, `Jong, Piet de`), and seven gain an ambiguity kind with no role field moving that was not already moving; 304 of the 1094 carry a comma and only 13 end in a particle, 11 of them rows the #379/#380 arc added, so read the seven as evidence about the corpus rather than the blast radius — the reach is a SHAPE, every family-comma listing ending in an ambiguous particle, and a caller holding such records sees a report on all of them; the gate was byte-identical at all three baselines once the two #379/#380 ledger rules were widened to carry `_ambiguities`. The most visible consequence, and it is not the Vietnamese one: `Beethoven, Ludwig van` — the textbook-correct Dutch listing, read exactly right — now carries a report. That is honest rather than regrettable. The parser cannot separate it from `Nguyen, Thi Van`; the two are the same string shape over the same vocabulary, and a report on one is a report on the other. A caller who wants only the doubtful cases has nothing here to filter on, because there is nothing to filter on in the input either. - 2026-08-30 #467 (the no-comma site, and the given position given back) — a declared family-first order names the family as a comma does, so P6's attachment fires without a comma too. It is keyed on TWO things, and review showed the second is not implied by the first: the run must stand in a MIDDLE — the one position that means nothing for a particle, middles being further given names — and it must END the name. Together they name the order without asking it: the default order ends with the FAMILY and FAMILY_FIRST_GIVEN_LAST with the GIVEN name, so only FAMILY_FIRST can put a name's last word in a middle. This DECIDES the parenthesis the 2026-08-16 Scope bullet left open ("arguably a missing comma under a declared family-first order") and narrows that bullet to the default order. P1 LOSES ITS GIVEN-POSITION SITE in the same change, and that half is a correction rather than a narrowing. That slot holds what the caller DECLARED to be the given name; the never-given vocabulary supplies a reading where position leaves the question open and vetoes none that position has already given. `Ménil de` under either family-first order now reports given `de`. THE PREMISE THIS REPLACES WAS FALSE, and it had been stated in three places (rules/P6's rationale, `_lexicon.py`, `config/particles.py`): that a never-given particle "cannot be a name at all". Measured, all 33 members report given == themselves when parsed bare. This is the SECOND correction of the same over-claim in this entry — the 2026-08-16 note above records dropping "no particle is a name by itself" for the identical reason. The conclusions drawn from it were mostly right; the reason was not, and it is load-bearing, because stating it as a property of the VOCABULARY invites the same inference for a particle ending the name, where there is nothing to join forward to and the writing is all there is. NO VOCABULARY TEST AT THE NEW SITE, and that is the substance of the design. The comma path needs one because a comma cannot separate the Dutch reading from the Vietnamese; the declared order can, and does both at once — `Beethoven Ludwig van` under FAMILY_FIRST gives family 'van Beethoven' though `van` is ambiguous vocabulary, and `Nguyen Thi Van` under FAMILY_FIRST_GIVEN_LAST keeps given 'Van' though it is the same word in the same set. A never-given test here would have excluded 37 ambiguous particles — von, di, da, del, le, and `van` itself, the flagship word of the listing the rule is named for. An earlier attempt (#466) carried exactly that test and failed on the Dutch case because of it. NO RE-LAYOUT EITHER, and this is a property of ONE order rather than a simplification: under FAMILY_FIRST the roles run family, given, middle, middle…, so dropping a trailing MIDDLE leaves every other piece's role untouched. It is false of FAMILY_FIRST_GIVEN_LAST (family, middle…, given) and of the default order, which is the second thing the ends-the-name test secures. #466 removed a piece and re-laid the leftover out, which lost a given name outright on `van Berg Jan de` and promoted a post-nominal into the given slot on `Berg Jan Jr. de`. Both defects are structurally unreachable here. The one guard that remains is the base: attaching to an all-particle family renders one particle in front of another, and R2 reads those words as ordinary name words, which no rule reorders. THE INVARIANT THIS RESTS ON, and the reason to trust it over the argument it replaced: for a name of the form `Family Given [Particle]`, the family-comma writing under the default order and the comma-less writing under FAMILY_FIRST parse identically. Measured over ALL 70 particles × 3 families × 3 givens = 630 names: 0 of 630 agreed before this change, **603 of 630** after. The invariant draws the never-given/ambiguous line by itself instead of having it asserted, which is why no vocabulary test appears at the site. The 27 failures are the three words that are BOTH particle and suffix vocabulary — `vd`, `mc`, `do` — and they are a real limit, not noise: P6's precedence over S2 is stated inside the comma paragraph and justified by the comma ("a trailing abbreviation after a family comma is the tussenvoegsel far more often"), so the comma form gives family 'vd Berg' where the comma-less family-first form gives suffix 'vd'. The rule now says so where it states the correspondence. A FIRST DRAFT OF THIS ENTRY CLAIMED 216 of 216, and the number came from a script that sliced the particle vocabulary — `sorted(particles)[:14]` — which excluded exactly those three words. That is AGENTS.md's axis 2 ("your detector is a second, unreviewed implementation") in its purest form: the detector agreed with the design because it inherited the design's blind spot. Recompute over the WHOLE vocabulary, never a sample. Scope the invariant to that SHAPE as well: 219 of the 304 comma-bearing corpus names parse differently with the comma simply removed, because a comma also delimits titles and suffixes. (An earlier draft said "181 of 247", which reproduces under no definition of "comma name" — the 304 matches the count the 2026-08-29 entry above already records.) An asymmetry falls out of it rather than being carved: shape 2 places the given name BEFORE the middles, so it corresponds to FAMILY_FIRST only. There is no comma format that puts the given name last, so FAMILY_FIRST_GIVEN_LAST has no twin to inherit an equivalence from. That is why the trailing particle is one order's business (#469). ONE EXAMPLE LINE WAS DELETED RATHER THAN UPDATED: `"Nguyen Thi Van" family-first → middle="Van"`. The name is written in FAMILY_FIRST_GIVEN_LAST; its FAMILY_FIRST reading is wrong by construction, so the line pinned a value nobody wants and nothing should depend on. Recorded here because the general question — whether a rules.md example should declare its format, and whether the doc test should refuse to run one outside it — is #470 and is NOT decided by this change. REACH: 30 of 6606 parses move — this tree's 1101-name corpus × three `name_order` values × `middle_as_family` off and on — over twelve names, NONE under the default order. Recompute by parsing the `tools/differential/corpus*.jsonl` files of this tree, deduped, under each of the six policies, and comparing the seven role fields against a checkout of the parent commit reading the SAME corpus files; the comparator is the pre-change code, never a construction of this rule. Dated snapshot, measured 2026-08-30. Note the corpus grew from master's 1094 by this change's own example lines, which is why the count must name the tree it was taken on. The differential gate cannot see any of it: `compare.py` parses every name as `HumanName(name)` and sweeps no policy, so no non-default `name_order` behavior has ever been compared across versions. Output is byte-identical at all three baselines apart from the added corpus names, none of which diffs. WHAT REVIEW FOUND, because the corpus could not. Four agents on PR #472 found the site firing outside the order it was reasoned from, in two directions, and the differential corpus reported neither: 52 default-order names (a conjunction stops a particle's forward chain and leaves it standing in a middle — `Maria Luisa y de la Cruz` gave family 'de la Cruz' where master gives 'la Cruz') and 366 FAMILY_FIRST_GIVEN_LAST folds of a particle that does not end the name (`de Anke van y` gave family 'van de Anke' with the given name still behind it). Of 1099 corpus names only 6 hold a conjunction-then-particle adjacency and none in this shape, so the REACH recipe honestly reported 0 default-order movers throughout. The count was right and the population was blind; "none under the default order" was a property of the corpus, not of the code. Both are now `· boundary` examples in rules.md so the doc runner holds them. The repair is one clause — the run must END the name — and it subsumes the order test a first repair added: measured over 542,592 generated parses, an explicit `state.order[0] is Role.FAMILY` never decides anything the ends-the-name test has not already decided, so it is not in the code. What IS kept is `state.order is not None`, which is what keeps this site off the family-comma path (0 co-firings with P6's comma site over ~892,000 parses). A stale index list was repaired with it: O1 and O2 retag between roles without recomputing, and this site was the first consumer of `families` after them (33,840 stale observations instrumented). No shipped input reaches a wrong answer through it — no `_TURKIC`/`_EAST_SLAVIC`-matching word is particle vocabulary — so the fix is unwitnessed by any test and is recorded here instead. - 2026-09-18 #531 (cross-amendment) — THE PRECEDENCE OVER S2 GAINS ONE EXCEPTION, AND THE RUN WALK GAINS ONE STAND-DOWN CONDITION. An all-caps member of the AMBIGUOUS credential class, in a name written in more than one case, reads as the credential and this attachment stands down: `Doe, John DO` is suffix `DO` where `Doe, John Do` and `SMITH, JOHN DO` still attach. The argument, the accepted cost (`SMITH, JOHN DO` paired with `NASCIMENTO, EDSON ARANTES DO`) and the two carve-outs are in decisions/S5's 2026-09-18 `do` bullet; what belongs here is the CONDITION, which is narrowed to the ambiguous tag rather than to the suffix role: a suffix-roled single-token piece carrying that tag is not part of the run, while `vd` and `mc` — unambiguous suffix vocabulary, also particles, also suffix-roled — stay inside it and are unaffected. Keying on the role instead would have stood the attachment down for them too, and keying on nothing would have let it silently override the slot's reading (`Doe, John DO` read family `DO Doe` with the condition absent, verified). The 2026-08-18 entry's "assign reads a trailing `vd`/`mc`/`do` as a post-nominal, and the attachment overrides that after a comma" is unchanged for `vd` and `mc` and now has this one exception for `do`. - 2026-10-02 (Derek), #573 — A word of both the particle and the unambiguous suffix vocabulary reads alike in every case at three sites, P6's third exception among them. The record moved to decisions/P9 with that exception (2026-10-19). - 2026-10-06 #613 (its site behind an empty family part superseded by 2026-10-27 #892, below) — THE COMMA SITE IS DECIDED IN THE PASS THAT READS THE GIVEN PART. #611 found P6 showing the decisive sign smaller: assign held back from #602's run exactly the particle tail post_rules would attach a stage later, by asking `particle_tail` over roles that meant "not yet read" there and "suffix" in post_rules. The attachment and its third exception (`_inside_a_credential_run`) moved into assign, right after the given-part walk, and `particle_tail` is asked ONCE, at the moment the walk reaches #602's run: the pieces in front of the run hold the walk's roles and the run's words none, the state in which its answer equals the one over the finished roles (argued piece by piece in its docstring). post_rules ran P1's leading fold before P6, so assign asks P1's condition itself (`is_lone_never_given_particle` on the given piece), which is what keeps `Smith, de Mesnil van` the fold's. H1 and M4, the other rules ahead of the old site, are gated on there being no family, which a family comma always has. The no-comma site (#467) stays in post_rules, since it runs over the order P1's family-first redistribution leaves. NOTHING MOVES, measured 2026-10-06 against the commit before it (PR #616): a 103,786-text P6 grid (ten family-comma heads behind which zero to three of seventeen particle, credential and name words, with and without a trailing `, Jr.`, under four policies, comparing every token's role and tag, every report in order and `initials()`) and the #613 harness's 80,423 texts are identical, the gate byte-identical at all five baselines and the suite unchanged. ONE SHAPE DID MOVE, and the PR review found it on a grid with an EMPTY part before the comma, a head no measurement above held: there no family is named, H1 and M4 are not gated off, and they had made the given word the family before the old site attached -- `, Mr. Jones vd` read title 'Mr.', family 'Jones', suffix 'vd', and assign attaching first read given 'Jones', family 'vd' (177 texts on that grid). Reading P6 strictly instead (no family named, no attachment) moved 1,302 texts against master, `, Jan van` among them, so the attachment behind an empty family part stays in post_rules, after H1 and M4, through the same helper; that grid now differs from b547c247 only in C1's decided classes. Frames drop on every family-comma name, post_rules' block having paid a second `particle_tail` and a generator-driven search for the given word on every one, tail or not, where assign now attaches only behind a non-empty tail: `Smith, John` 169 → 160, `Smith, John, Jr.` 213 → 204, `Doe, Jane nee Smith` 302 → 293, a no-comma name unchanged. - 2026-10-17 #700 — THE ELIDED PARTICLE AND THE HYPHEN-ATTACHED ARABIC ARTICLE ARE PARTICLE VOCABULARY, NEVER-GIVEN, AND THE RULE IS UNCHANGED. Library catalogs file `Paul d'Ivoi` as `Ivoi, Paul d'` and `Ḥusnī az-Zaʿīm` as `Zaʿīm, Ḥusnī az-`, the particle split off with its mark still on it; both read as a middle name (1.4.0 and 2.4 alike) because neither written form was in `PARTICLES`. P6 asks only the particle tag, so the fix is the vocabulary alone: no code moved. Three decisions, each taken by Derek on the #700 session's options. ELIDED SET: `d'`, `l'`, `dell'`, each also in its U+2019 spelling, and `de’` and `’t` beside the existing `de'` and `'t` (`’t` added in the PR review, a gap the test review found), since nothing folds the typographic apostrophe to the ASCII one; particles.py asserts at import that every elided entry has both spellings (a vocabulary build check since 2026-10-18, decisions/vocabulary-model). ARTICLES: a CLOSED list — `al-`, `ul-`, the article assimilated to each of the fourteen sun letters in ASCII and DMG spellings (`ar-`, `az-`, `aš-`, `ash-`, `aṭ-`, …; lām's being `al-` itself), and the Egyptian `el-` with its assimilations in ASCII spellings only (`es-`, `esh-`, `ed-`, …; no `eš-` or `eṣ-`) — all never-given, where the bare `al` stays ambiguous (Al Gore): written apart with its hyphen, the word is the article and nothing else. RENDERING: the attached family joins with a space like every particle, `d' Ivoi` and `al- Rashid`. Declined: a shape rule (a vowel, consonants, a hyphen) in place of the list. It would need a new predicate and the bare-word collision check the issue named, for a population the list already covers; the corpora attest seven article spellings (`al- az- aš- ar- es- as- aṭ-`), all in it — recompute: every whitespace- or comma-bounded token of `tools/differential/corpus*.jsonl` matching `\w{1,4}-` at a name's end. Not decided here: the GLUED forms (`Paul d'Ivoi`, `Harun al-Rashid`) stay one word with no particle split, out of #700's scope by its own statement. Hyphen-minus only: U+2010 and the non-breaking hyphen are not listed. The Scottish Gaelic `a’` (`Mac a’ Ghobhainn`, in corpus_cldr.jsonl) is mid-name and not an index-form particle, and was not added. Nor are the elisions no corpus holds (`dall'`, `nell'`, `degl'`): the list is closed, so such a spelling reads as a middle name until it is listed. RENDERING FOLLOWED THE VOCABULARY, found in review: R4's `capitalized()` split a token at its mark and asked the particle test of the stem, so `IVOI, PAUL D'` repaired to `Paul D' Ivoi` — 30 of the 32 first-listed spellings, all but `al-` and `de’`, whose stems are particles themselves. The whole token is now asked the particle arm's question first, and lowercased only where that arm would lowercase it; wherever the arm declines (an all-particle part, the given or suffix role, a recorded casing) the token goes to the per-word walk as before — a first version sent every whole-token particle down the arm's other clauses instead, which moved `smith, john, ed-` from 'ED-' to 'Ed-' and a configured `van-der` from 'Van-Der' to 'Van-der' (the fix commit's own review). The one shipped word outside #700 it reaches is `'t`, whose stem is `t`: `van 't Hoff` repairs to 'van 't Hoff', where 1.4.0 through 2.4.2 wrote 'van 'T Hoff' — R4's reading, and the right Dutch one. A casing configured for a stem (`capitalization_exceptions` `al` → `AL`) no longer reaches the marked spelling, which is now a particle in its own right: `al-` is lowercased. (Corrected 2026-10-22, #762: the mask still reaches `al-` in the given name and in a family that is `al-` alone, `al-` → 'AL-' and `john smith al-` → 'John Smith AL-'; in the middle name, or in a family with other words before or after it, `al-` is lowercased, `ahmad al- rashid` → 'Ahmad al- Rashid', `smith, al-` → 'Smith al-' and `smith, john al- jones` → 'John al- Jones Smith'; measured on master under `capitalized()` and `capitalized(force=True)`.) The snapshot cannot see a render view (decisions/R4); measured instead by `capitalized()` and `capitalized(force=True)` against `00dd2759`, over every name in `de6c045d`'s `tools/differential/corpus*.jsonl` as written, lowercased and uppercased (28 names move, every move lowercasing one token ending in `'` or `-`), and over every particle in eight positions under all three orders (adding `'T` → `'t`, which no corpus name held until R4's `van 't hoff` example joined corpus_rules.jsonl). The boundary `Ivoi, d'` was taken out of rules/P6: P6 does not attach there, and the reading it gets instead (family `Ivoi d'`, as `Jong, de` has read since 2.0) is the never-given vocabulary's family-comma reading, which P6's statement does not describe — a P6 example would have pinned another rule's output. It moves from given `d'` (1.4.0 through 2.4.2, the word being no particle there), and so does every new entry standing alone after a comma (`Smith, al-`); it stays pinned as a case row. Measured 2026-10-17 against the parent commit `b4a004c3`: 39 corpus names move, every one the same shape — a trailing `d'` or article leaves the middle name and joins the family (38 in corpus_gnd.jsonl, `Orval, Pierre d'` in corpus_conventions.jsonl), and no ambiguity kind changes. Recompute: `uv run python tools/differential/snapshot.py --since b4a004c3`. That is evidence about the corpus as much as the change: these are the comma-bearing names ending in one of the new forms, and GND's index forms are where they occur. - 2026-10-19 — RULE SPLIT: the third exception (a lone word of both the particle and the unambiguous suffix vocabulary inside a run of post-nominals) left rules/P6 for rules/P9, one rule per piece of evidence (docs/design/AGENTS.md), with rules/P2's clause on the same word before a family comma; #573 decided both. The #573 entry moved to decisions/P9 and a cross-reference stands in its place. Where an entry here says P6's third exception or `_inside_a_credential_run` (the #613 entry), that piece is rules/P9's now. - 2026-10-20 #761 (skeleton migration) — three passages of the block-format statement left the rule as argument or history rather than statement. The draft that tested the family-first slot alone and fired on 52 default-order names and 366 FAMILY_FIRST_GIVEN_LAST middles, and the FAMILY_FIRST line for `Nguyen Thi Van` removed rather than updated (#470), are the 2026-08-30 #467 entry above. The third is moved here: the comma site and the no-comma family-first site are checked against each other directly, as a pair, rather than only against their own examples — tests/v2/test_order_correspondence.py generates both writings and asserts they parse alike, plus a negative-control sweep pinning the disagreeing set the precedence over S2 names, so a change that breaks one side of that pair should expect that test, not the rule file, to say so first (decisions/two-input-invariants). - 2026-10-27 #892 (Derek) — THE EMPTY-HEAD SITE IS GONE. SUPERSEDES: the 2026-10-06 #613 entry's keeping the attachment behind an empty family part in post_rules, after H1 and M4. rules/C15 reads a comma with nothing before it as a family comma naming no surname, so the attachment behind it is the comma site's, in assign, ahead of H1 and M4. #613 had kept a second site in post_rules for that head, after H1 and M4, so that `, Mr. Jones vd` kept the reading it had before #613 (family 'Jones', suffix 'vd'); that reading is the comma-less name's (`Mr. Jones vd`), not the twin's, and decisions/C15 holds the empty head to its twin. Removing it moves the particle-and-suffix words `vd` and `mc` ending the name behind a title that addresses by surname, 112 given parts per order on C15's invariant grid and the same tail behind a third comma part (`, Dr. Jones vd, PhD`), each now given 'Jones', family 'vd', with a `suffix-or-name` report, where an ambiguous particle already read so (`, Dr. Jones van` → family 'van'). A never-given particle opening the part with no name word behind it now meets the comma site's P1 guard, which the removed site lacked, and keeps the tail a suffix as its twin does (`, de PhD vd` → given 'de', suffix 'PhD vd'). The S13 shape decisions/S13 left as found, `, Jones (MA) vd`, reads its twin's nickname 'MA' with it, the particle being family before S13's pass reads what follows the clause. P7-one-letter-particle-against-the-initial-it-spells.md000066400000000000000000000137561527303271700352550ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/decisions# P7 — a one-letter particle against the initial it spells ## Standing Examples read under the default order unless a line names another. - A one-letter word written with its period is an initial and not a particle, whatever particle the letter spells, in every position (`Juan Ó. Pérez` and `Pérez, Juan Ó.` → middle 'Ó.', `Ó. Pérez` → given 'Ó.', under the default order), and both `initials()` keep it; written bare it is the particle (`Juan Ó Pérez` → family 'Ó Pérez'), the accepted cost for an accented initial written without its period — 2026-10-04 #604. - The veto is one helper, `_lexicon._spells_an_initial`, asked after a membership hit at every reader of the particle vocabulary — classify's tags, the unit count before a comma, case repair and the facade — so `Ó. Pérez, Ed` reads as `Q. Pérez, Ed` does — 2026-10-04 #604. - The period is the ASCII one, so `Juan Ó。 Pérez` keeps the particle; the check for the scripts that write no initials is not reproduced, and a caller's one-character Han, Hangul or kana particle written with a period is vetoed, an accepted residue no shipped particle reaches — 2026-10-04 (design review; Derek). Open: none in this file. ## Log - 2026-10-04 #604 — the PERIOD decides, stated as a shape rather than as a property of one word. `ó` is the first one-letter particle the vocabulary has held, and the particle match folds edge periods away, so `ó` in PARTICLES alone also claimed `Ó.` — the initial of Óscar, Ólafur, Ólöf. Measured on the branch before the veto: `Juan Ó. Pérez` and `J. Ó. Pérez` read family `Ó. Pérez`, `Pérez, Juan Ó.` read family `Ó. Pérez` too (P6 attaching the trailing "particle" behind the comma), and both the core's and the facade's `initials()` dropped the Ó. With the veto every one reads middle `Ó.` (given `Ó.` for `Ó. Pérez`) and initials keep it. A one-letter word written with its period is an initial, whatever particle the letter spells; The same line already stands for a single-letter connective, pinned in the code: 'e.' in 'john e. smith' is tagged an initial, not the Spanish conjunction (classify's initial veto on the conjunction tag). rules/P8 does not state it — P8 decides that fork by case, not by the period — and the only rules.md statement of the period line is R4's hyphen clause, scoped to a hyphenated word in case repair. - WHERE the veto lives, and why it is one helper asked at four sites: `_lexicon._spells_an_initial`. Besides classify's tags, the particle vocabulary is read directly by `_vocab.surname_unit_tags` (the comma unit count, #575), `_render._cap_word`'s case repair, and the facade's `_is_particle` (the v1 last-name split and initials), and a site that kept bare membership read `Ó.` as a particle where classify does not: `Ó. Pérez, Ed` became family `Ó. Pérez`, given `Ed`, where `Q. Pérez, Ed` reads given `Q.`, family `Pérez`, suffix `Ed`. _render and the facade may not import the pipeline (tests/v2/test_layering.py), so the helper sits in the one module all of them already import. Every site asks it AFTER its membership hit, so a word no particle set holds pays no frame for it (AGENTS.md's frame budget: the classify call is per token). A fifth reader, segment's settled-run test in the comma credential walk (#562, deleted with segment's decision in #613), was NOT a site: an initial ends that walk before the particle question is asked, so a veto there changed nothing and was left out. Measured 2026-10-04 while the draft still asked the helper there (the call has since been removed, so that instrument no longer exists): with the helper answering False at that site alone, over every two-to-four-word arrangement of `Ó. ó. de vd Pérez Juan PhD MD VD DO do Do MA Ma Jr.` holding `Ó.` or `ó.`, in every comma position, under both `lenient_comma_suffixes` settings, no reading moved. To re-check it on this tree, wrap `_segment._normalize` so a one-letter period-written word never matches the particle set — the second review did, over 49,999 texts from `Ó. de vd DO do Pérez Juan MA PhD Ed Jr. MD VD van Q.` written as-is, upper and lower, under both settings: 0 moved, against 48 for a positive control that vetoes every particle at that site. The guard is the two-input invariant in tests/v2/test_properties.py — a name holding `Ó.` reads as the same name holding `Q.` in every view — with `_P7_SITE_EFFECT` recording, per site, how many grid texts break with that site's veto removed. - 2026-10-04 (design review; Derek) — THE PERIOD IS THE ASCII ONE, matching `_vocab.is_initial`'s shape. The first draft took any of `FULL_STOPS`, so `Ó。` was vetoed although is_initial calls it no initial; now it is the particle, as `Q。` is no initial. The second half of is_initial — the scripts that write no initials (#320, `_policy._NO_INITIALS`) — is NOT reproduced: `_lexicon` may not import `_policy` (tests/v2/test_layering.py), and `_render` and the facade, which must ask the same question, can reach neither. Accepted as the residue: a CALLER's one-character particle in Han, Hangul or kana, written with a period, is vetoed where is_initial would call it no initial (`Lexicon.default().add(particles={'之'})` reads `王 之. 明` with `之.` outside the particle). No shipped particle is such a character. A hand copy of the script check, pinned by a sync test, was the alternative and was declined for a case only a caller can construct. - Accepted: a bare `Juan Ó Pérez`, an Óscar initial written without its period, reads family `Ó Pérez`. Accented initials normally carry the period, and the bare letter is exactly how the particle is written. Declined: - A per-entry "matches only when written bare" flag (the issue's first framing, a new Lexicon field or set) — the period convention is a fact about INITIALS, not about `ó`, so it is stated once for every one-letter particle, including any a caller configures. A field would be one more vocabulary axis to translate through the v1 shim and to sweep in every guard roster, for a question no entry answers differently. P8-single-letter-connective-against-the-initial-it-spells.md000066400000000000000000000417271527303271700363070ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/decisions# P8 — a single-letter connective against the initial it spells ## Standing - In a name written in more than one case, a bare Latin capital connective reads as an initial and a Cyrillic capital joins where P3's word count lets a connective join at all (`Иван Сергей И Петров`; at three words it stays a middle name) — 2026-07-30 #267; 2026-09-13 #383 + #479. - In a name written wholly in one case, a member of `conjunctions_ambiguous` reads as an initial and reports `conjunction-or-initial`, and any other single-letter connective joins where P3's count lets it; the default members are `e`, decided here, and `i`, added with the conjunction itself by decisions/P3's #397 entry as this log foresaw — 2026-09-13 #383 + #479. - Both questions are asked over the name's own words, those before a maiden marker's run and outside a delimited clause; the one-case member's `initial` tag is given per token at every name length, three words included, though no role moves there: `john e smith` capitalizes to `John E Smith` and reports `conjunction-or-initial`, where with `e` removed from the subset it gives `John e Smith` and no report — 2026-09-13 #383 + #479. - The subset is the knob, with no switch beside it; that a locale pack cannot remove a member is accepted — 2026-09-13 #383 + #479. - The facade's `initials()` follows the parse's tags as the core does (the #528 amendment inside the entry) — 2026-09-13 #383 + #479. - An orphan `conjunctions_ambiguous` entry is inert and nothing checks it — 2026-09-13 #383/#479, `conjunctions_ambiguous` is deliberately. - The `initial` tag is not purely a shape claim: a marked connective in a one-case name carries it (amends decisions/M4) — 2026-09-13 #383/#479, AMENDS. - Inside a hyphenated word the connective keeps its lowercase in case repair, decided in decisions/R4 — 2026-09-24 #478. Open: none in this file. ## Log - 2026-07-30 #267 — the four-word single-letter asymmetry: a bare Latin capital connective is vetoed as an initial while a Cyrillic capital joins. #267's closure ("v2.0 behaves this way by default … verify it was the right call") showed only the Cyrillic half; the Latin-capital veto was never separately adjudicated, which rules/P8 records as an Accepted consequence (rules/P3 until the 2026-10-19 split). - 2026-09-13 #383 + #479 (its `JUAN Y GARCIA` "J. G." reading in both views superseded by 2026-09-20 #461, decisions/R3: both give "J. Y. G.") — BLESSED for the mixed-case half, and DECIDED BY VOCABULARY for the one-case half, which is the half the question never reached. A single-letter connective joins only on positive evidence. Where a name is written in more than one case the writing is that evidence and nothing moves: a bare LATIN capital reads as an initial ("Jose E Maria Santos" → middle "E Maria"), which is #383 answered with "bless". The shape is still LATIN and the phrase "Latin-capital veto" does not retire with this change — `_INITIAL` is ASCII-only, so a Cyrillic capital is never initial-shaped, joins, and #267's reading above is untouched. An earlier draft of this work claimed the phrase retires; it was overstated, and what became evidence-based is the one-case half alone. WHERE THE WRITING SAYS NOTHING. A name written wholly in ONE case — all upper and all lower alike — carries no case evidence about any letter in it. The reading there comes from a new marker subset `Lexicon.conjunctions_ambiguous` ⊂ `conjunctions`, default `{e}`: a member reads as an INITIAL and reports the new `AmbiguityKind.CONJUNCTION_OR_INITIAL`, a non-member JOINS even written as a bare capital and reports nothing. That is the third instance of the ambiguous-subset pattern, after `particles_ambiguous` and `suffix_acronyms_ambiguous`, so no mechanisms.md entry is owed. ROW 1 OF #479 IS THE DEFECT, NOT ROW 4 (Derek, 2026-09-12). The one-case lowercase reading is the one that was wrong, not the one-case uppercase reading that looked wrong beside it. WHY `e` AND NOT `y`. A bare E initial is common (Edward, Elizabeth) and an "e" between two surnames is rare outside couple listings; a bare Y initial is rare and "y" between two surnames is the commonest Hispanic compound and this library's oldest fixture ("Velasquez y Garcia"). The 2026-08-18 LOCALE CONTROL bullet under #3-0-reevaluations already said that which letters a tradition wants joined is per-word POLICY rather than membership; this subset is that policy made configurable, which is the smaller half of what that bullet asked for and ships without locale packs. `i` is not here because it is not conjunction vocabulary at all yet; it ships in the subset when #397 adds it, a bare I initial being as common as a bare E. MEASURED 2026-09-13 over the 1174 distinct names of the deduped `tools/differential/corpus*.jsonl` glob (PR #527, whose text of this entry carries the recompute recipe): SEVENTEEN are written in one case and carry a cased single-letter connective, the WHOLE population this fork can reach, and ten move something against 2.3.0; none of the eight held before the PR moves a role, and the two role moves are new example names breaking 1.4.0 parity in OPPOSITE directions — `jose e maria santos` now given "jose", middle "e maria", and `JUAN GARCIA Y LOPEZ` now family "GARCIA Y LOPEZ" (on the 1.4.0 and 2.3.0 wheels); the ledgers at all five baselines classify them. THE RULE IS PER TOKEN AT CLASSIFY TIME AND REACHES EVERY NAME LENGTH (Derek, 2026-09-13), the three-word shape included: `john e smith` reads middle "e" tagged as an initial, so `parse(...).initials()` gives "j. e. s." where 2.3.0 gave "j. s." and `capitalized()` gives "John E Smith" where 2.3.0 gave "John e Smith". P3's three-word carve-out is untouched — it already says a single letter in a short name is more likely an initial, and this is the same judgement extended to the letter's case — so no ROLE moves at three words and the visible change is in the two derived views. BOTH QUESTIONS ARE ASKED OF THE NAME'S OWN WORDS — the case class AND the fork, not just the three-word count. Tokens standing before the first maiden-marker run head and not already carrying a role from a delimited clause are the name; the rest are not. Found by measurement rather than by design: appending " née Jones" made an all-caps name mixed-case and flipped the fork, failing the repo's invariant that a maiden clause changes nothing else, and a delimited `"Y"` was being conjunction-tagged (so R4 lowercased it) while a delimited `"E"` reported. ACCEPTED edge, and it is WIDER than an early draft of this entry said (measured 2026-09-13, in review): a maiden marker's run and every word AFTER it leave the own-words span the moment classify tags the marker, and they stay outside whether or not the marker pass later DECLINES the marker and leaves it a word. So a declined marker does two things, not one. It still does not count toward the case class, which is visible only when the declined marker word is the only differently-cased token (`JUAN Y GARCIA née` joins where `JUAN Y GARCIA née Jones` keeps "Y" a name word) — that much the early draft had. But it also leaves any connective standing after it to the MIXED-CASE rule rather than to the one-case fork, so a bare Latin capital there is an initial and its lowercase spelling joins. That is the one shape in which a name's OWN words read differently in its two one-case spellings: `JUAN NÉE JR Y LOPEZ` reads given "JUAN", middle "NÉE JR Y" with its "Y" tagged `initial`, while `juan née jr y lopez` reads given "juan", middle "née", family "jr y lopez" with its "y" tagged `conjunction`. SCOPE THAT PHRASE TO THE OWN WORDS, because a clause's words are not covered by it and a re-review caught the over-reach: a letter inside a delimited or maiden clause is read as it always was, which still means a different TAG in the two spellings, and case repair shows it — measured 2026-09-13, `JANE VAN DER BERG NÉE Y JONES` capitalizes to `Jane van der Berg (Y Jones)` where `jane van der berg née y jones` gives `(y Jones)`, and `JUAN "Y" GARCIA LOPEZ` keeps nickname `"Y"` where the lowercase spelling keeps `"y"`. The FIELDS agree in both pairs; what differs is the repaired case of a clause's word, which is that clause's rules doing their job rather than this one's. ACCEPTED rather than repaired, on two grounds: classify cannot know what group will decline, and a clause's words are read by the clause's own rules — which is the same doctrine the own-words span states, applied to the one shape where it bites. rules/P8's Accepted clause (P3's until the 2026-10-19 split) carries the same wording and the same example pair. (2026-10-11, release sweep: #602's credential run now reads `JR Y LOPEZ` as suffixes in the capitals spelling, so the clause carries the pair `DR. NÉE JUAN Y LOPEZ` / `dr. née juan y lopez` instead, the marker declined behind a title the same way: middle "JUAN Y" with "Y" an initial, against family "juan y lopez".) REPORT SCOPE IS SUBSET MEMBERS ONLY. Both readings are live only there; "y" in a one-case name is not in doubt, and reporting it would be noise on every lowercase Hispanic record. Matches how `particles_ambiguous` and `suffix_acronyms_ambiguous` report, and matches AGENTS.md's "a kind is worth adding only if a reader would hesitate too". CASELESS LETTERS NEVER ENTER THE FORK. Arabic و has no case, so `token.upper() != token.lower()` is false and today's reading stands. A caseless-script input counts as "one case" under the helper, harmlessly, because the fork also requires a cased token. NO SWITCH. The subset is the knob: remove "e" to restore the joining reading, add "y" for a Dutch-style "every single letter is an initial". That is also this shape's answer to #516's switch question. ACCEPTED, AND RECORDED RATHER THAN FIXED: a locale pack cannot express "remove e". `Locale.lexicon` is unioned onto the base and never removes — its own field comment says "a pack never removes base vocabulary" — so an `nl` pack CAN add "y" while a `pt` caller has to write `Parser(lexicon=Lexicon.default().remove(conjunctions_ambiguous={"e"}))` by hand. The first (A) bullet added to #3-0-reevaluations on this date asks whether packs should be able to remove vocabulary at all. ACCEPTED, AND DEFERRED TO A FOLLOW-UP ISSUE (Derek, 2026-09-13): the facade and the core now disagree about the same letter. `HumanName.initials()` does not follow the parse's tags — `_facade._process_initial` re-derives "is this the connective" from vocabulary plus initial shape on the part's raw text — so `HumanName("john e smith").initials()` stays "j. s." while `parse("john e smith").initials()` gives "j. e. s.", and `JUAN Y GARCIA` splits the other way, facade "J. Y. G." against core "J. G.". Both measured here 2026-09-13 and pinned by a contrastive test rather than left to prose. `_facade.py` is untouched in this PR; the fix has R4's shape — consult the PARSED token wherever the part maps to one and fall back to the vocabulary only where it does not — and it is a follow-up issue, not yet filed, so no number is cited here. `capitalize()` is not split: it follows the parse on both surfaces, so `HumanName("john e smith").capitalize()` gives "John E Smith" too. 2026-09-13 #528 — AMENDS the paragraph above: CLOSED THE SAME DAY. `_facade.py` was touched after all — #528 shipped the fix the paragraph predicted, with the R4 shape it predicted: the facade consults the PARSED token wherever the part maps to one and falls back to the vocabulary only where it does not. Both views give "j. e. s." and "J. G." now (measured 2026-09-13 after the fix); the contrastive test the paragraph above describes became the agreement test `tests/v2/test_render.py::test_facade_initials_follow_the_one_case_fork`. `decisions/R3`'s 2026-09-13 entry carries the fix, its accepted costs and the one corpus name where the two views still differ. `capitalize()` was never split: it followed the parse on both surfaces throughout the window the paragraph above describes, giving "John E Smith" for that whole window — not "all along", since 2.3.0, before #383/#479 landed, gave "John e Smith" for the same name. TWO OTHER CAUSES SHARE THE `_initials` FIELD IN THE LEDGERS AND NEITHER IS THIS PR'S, recorded because a reader meeting them under these names will reach for this entry. The Arabic `محمد و علي` diffs on `_initials` at 1.4.0 only (`م. و. ع.` → `م. ع.`), measured byte-identical either side of the fork: it is a pre-existing #269 consequence — a recognized non-Latin connective contributes no initial — surfaced by the row entering the contract corpus, and `expected_since_1.4.0.toml` ledgers it as `feat(#269)`. `JUAN Y GARCIA` diffs on `_initials` at 2.0.0 through 2.2.0 for a DIFFERENT reason than it does at 2.3.0: at those three baselines `fix(#462)` already admits the name (the facade moved `J. G.` → `J. Y. G.` there), and this PR's core move `J. Y. G.` → `J. G.` gets its own rule at 2.3.0 alone. Two causes, one field, documented in a dated paragraph on `fix(#462)` in those three ledgers rather than as a competing rule. Out of scope, each its own issue: #492 (whether a cased suffix token counts as case evidence — `is_one_case` is written so R5 and #492 can share it later, but render does not import it here); #478 (hyphenated connective repair — its spaced-form claim now depends on "y" staying OUT of the subset); #461 (R3's clause); #289 and #516 (the same case-class fact read at the suffix and post-comma slots); the render-side conjunction fallback for spliced raw text, which stays vocabulary-keyed as rules/R4 states. - 2026-09-15 #289/#516 — AMENDS the out-of-scope list above: #289 and #516 are no longer out of scope, having shipped in 2.4 on the fact this entry's own fork computes. The fact is now a `ParseState` field rather than a classify local, taken over the SAME own-words span this entry defines, and `_pieces.own_words` is where that span lives for both readers. #492 stays out of scope and stays deferred. See decisions/S6. - 2026-09-23 #492/#478 — AMENDS the out-of-scope list above again: both have landed, in RENDER only. #492: R5's gate leaves the suffixes out (decisions/R5, 2026-09-23); this entry's fork is untouched and still counts a cased suffix, which is the R5/parser split that entry records, and render still does not import `is_one_case`. #478: a connective part inside a hyphenated word keeps its lowercase (decisions/R4, 2026-09-23); the spaced-form claim it rested on is unchanged, and still depends on `y` staying out of the subset. - 2026-09-13 #383/#479 — `conjunctions_ambiguous` is deliberately NOT registered in `_SUBSET_FIELDS`, so an orphan marker entry raises nothing. An orphan there is INERT rather than harmful: the classify fork tests membership in `conjunctions` before it reads the subset, and the emitter tests it again, so a marker entry whose base word is gone is never consulted by either. AGENTS.md's invariants rule guards harm, not no-ops, and the precedent is this file's #given-name-titles Declined entry, where two attempts at a check each cost a working configuration to forbid a condition that costs nothing. What holds the SHIPPED constant to the subset relation is an import-time assert in `nameparser/config/conjunctions.py` (which also holds it to cased single letters, the only entries the fork can read; since 2026-10-18 the subset holds by construction and the letter check is a vocabulary build check, decisions/vocabulary-model), and the v1 shim's intersection, which is a provable no-op. So `remove(conjunctions={"e"})` simply works, leaving a stale marker entry behind that does nothing. Pinned by `test_removing_a_conjunction_leaves_its_ambiguous_marker_alone` in tests/v2/test_lexicon.py and by the orphan behavioral pin in tests/v2/pipeline/test_classify.py, both of which cite this entry. - 2026-09-13 #383/#479 — AMENDS decisions/M4's 2026-08-27 #445 entry, which calls `initial` "the `initial` shape tag": since this change the tag is no longer purely a shape claim, because a marked single-letter connective in a one-case name carries it without being initial-SHAPED. rules/M4's statement now says "read as an initial" for that reason, and M4's carve-out itself is unchanged — it still reads the tag classify recorded rather than a predicate of its own. - 2026-09-24 #478 — CROSS-REFERENCE, no change to this rule: case repair's hyphen clause is the one place a marked letter in a name written wholly in one case is read as the connective rather than an initial — `maria silva-e-sousa` repairs to `Maria Silva-e-Sousa` beside the spaced `Maria Silva E Sousa`, and `conjunctions_ambiguous` does not reach the hyphenated form. Decided there, with its cost (`J-E-P DUPONT` → `J-e-P Dupont`): decisions/R4's 2026-09-24 #478 bullet. - 2026-10-19 — SPLIT FROM decisions/P3 with the rule. The entries above were written under decisions/P3 while this rule was part of rules/P3, and moved here unchanged but for the pointers the move broke; where one says rules/P3 or P3, it means the rule as it then stood, which held this one, except where it names the three-word carve-out or the own-words span, which rules/P3 still states. What moved and why: decisions/P3's entry of this date. P9-particle-that-is-also-a-suffix-word-beside-a-family-comma.md000066400000000000000000000224551527303271700364500ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/decisions# P9 — a particle that is also a suffix word, beside a family comma ## Standing - A particle that is also an unambiguous suffix word, with a name piece behind it in the part before a family comma, heads that name and stays family in any case (`SMITH VD MA, JOHN`) — 2026-10-02 #573. - Such a word standing between post-nominals after a family comma keeps the post-nominal reading in any case and reports the fork it decided (`suffix-or-name`); with nothing behind it, it ends the name and attaches by P6 — 2026-10-02 #573. - A never-given particle opening the given part makes the member behind it a name rather than a credential. This is rules/S5's site, indexed here because this entry is its record (decisions/S5's #573 entry points back here) — 2026-10-02 #573. Open: none in this file. The mixed-case `Doe, Jane PhD vd Ma`, which #573 left aside, was decided silent the same day (decisions/S5's #578) and that was reversed by #602 (decisions/S8's 2026-10-04 entry): it reads suffix 'PhD vd Ma'. ## Log - 2026-10-02 (Derek), #573 — A WORD OF BOTH THE PARTICLE AND THE UNAMBIGUOUS SUFFIX VOCABULARY READS ALIKE IN EVERY CASE AT THE THREE SITES WHERE THE UNIFORM CASE SPLIT IT. Resolves the shapes decisions/C4's 2026-10-01 #554 entry left open. `vd` and `mc` split in names written wholly in one case, in three shapes, each split one stage acting on a reading the next never took. The mixed-case spellings were not all right either: `Smith vd Ma, John` read family 'Smith Ma', suffix 'vd' in 2.2.0 and 2.3.0, #530 having fixed it only in this cycle, and the fixes below move mixed-case names as well as uniform ones (blast radius, below). BEFORE A FAMILY COMMA (P2 when decided, rules/P9 since its 2026-10-19 split). `SMITH VD MA, JOHN` read family 'SMITH MA', suffix 'VD' (2.2.0, 2.3.0 and this cycle's tree; 2.0.0 and 2.1.0 read family 'SMITH VD MA'). In one case the S2 trailing reading takes `VD MA`, so the particle chain stops before `VD`; the part before a family comma then takes strict suffix words one piece at a time, and the lone `VD` was peeled from between two family words. DECIDED in that per-piece read: a particle that is suffix vocabulary too, with a name piece behind it in the part, heads that name and stays family (P2's join), so `VD MA` reads family whatever the case. Not decided in the chain: a comma followed by no name word reads that same part positionally, trailing credential included (`Berg de MA, Prof.` keeps suffix 'MA' and its report), and the chain runs before that choice is made. A first draft skipped the chain's stop for the whole family part and moved exactly that shape; a widened grid caught it. Declined: reading `VD MA` as credentials, family 'SMITH', which the comma's naming of the family argues against. `SMITH VD, JOHN` and `SMITH VD JR, JOHN` keep the suffix: nothing, or a suffix word, stands behind the particle. BETWEEN CREDENTIALS AFTER A FAMILY COMMA (P6 when decided, rules/P9 since its 2026-10-19 split). `DOE, JANE PHD VD MA` read family 'VD DOE', suffix 'PHD, MA': assign had read `PHD VD MA` whole, and P6's attachment, which outranks the suffix reading for such a word by design, reached into the run and pulled `VD` out. DECIDED: P6 gains a third exception — a lone such word with a post-nominal read in front of it and another behind, a title between it and the one in front transparent (H5), keeps the post-nominal reading in any case, reported as S2's credential fork. The test is the roles assign gave, asking whether it read the run whole, so a member read as the credential in front speaks as a degree does (`DOE, JANE MA VD PHD` stays whole); a first draft asked S2's company query instead, which counts only an unambiguous credential in front, and missed that shape and the titled one (`DOE, JANE PHD PROF. VD MA`). A generation counts on either side, and that is a decision rather than a side effect: P6's tussenvoegsel stands right behind the given name, so any post-nominal between them says the word is not one, and `Berg, Jan PhD vd Jr.` reads suffix 'PhD vd Jr.' where 2.2.0 and 2.3.0 read family 'vd Berg', suffix 'PhD, Jr.' — the shape `Berg, Jan van Jr.` P6's rationale names has no post-nominal in front and still attaches. No title is skipped BEHIND the word: a title there leaves it a name in assign (`DOE, JANE PHD VD PROF. MA` reads middle 'VD PROF.', as before), so a skip there was dead code, measured never to fire over the grid below, and was removed. Boundaries: with nothing behind it the word ends the name and attaches (`Doe, Jane PhD vd` → family 'vd Doe', 2.2.0's and 2.3.0's reading); two such words side by side are one particle run, which P6 takes whole as its first exception says (`DOE, JANE PHD VD MC MA` → family 'VD MC DOE', unchanged); a plain particle between credentials was never read as a post-nominal and attaches as before (`Doe, Jane PhD de PhD` → family 'de Doe'). Declined: middle 'VD MA', 2.0.0's and 2.1.0's reading — under #554 run membership asks what the word IS. ONE NEVER-GIVEN PARTICLE OPENING THE GIVEN PART (S2, P1). `SMITH, VD MA` read family 'SMITH VD', suffix 'MA'. S2's given-part slot reads a member ending the given part as the credential because "the words to spare are there by construction", the first word after the comma being the given name; a never-given particle is not one — P1's fold takes it forward into the family — so `MA` was read as a credential with no given name left, and the fold took `VD` alone. DECIDED: where the given part opens with a never-given particle and nothing but particles stands before the member, chained (`VD DE LA`) or not, the member is the word the particle attaches to and reads as a name: family 'SMITH VD MA', the reading of 2.0.0 through 2.3.0 and of `Smith, vd Ma`. The particle test is P1's own predicate (`_pieces.is_lone_never_given_particle`), so the slot cannot drift from the fold it predicts. Plain never-given particles share the shape (`smith, de ma`). Boundaries: capitals in a mixed-case name still make the member the credential, as they do with no words to spare anywhere (`Smith, de MA` keeps suffix 'MA'); an ambiguous particle can be a given name (`SMITH, VAN MA` keeps given 'VAN', suffix 'MA'); and a name word between the particle and the member is the word it attaches to, leaving the member a word to spare (`SMITH, VD BERG MA` keeps suffix 'MA'). Declined: family 'SMITH', suffix 'VD MA' (as `SMITH, PHD MA` reads) — under #554 `VD` speaks for nothing, so only the missing given name could have made `MA` a credential, and it is the particle's surname. NOT DECIDED HERE: the mixed-case `Doe, Jane PhD vd Ma` (middle 'vd Ma', silent), the issue's fourth bullet. Its reading is unchanged; whether the family-comma path should report the chain's fork there is a separate question. Open: #578 (decided the same day, the S2 section's #578 entry: silent, as it reads). Nor the positional read of a part before a comma followed by no name word, where the case still splits the word: `Smith vd Ma, Prof.` reads given 'Smith', family 'vd Ma', and `SMITH VD MA, PROF.` family 'SMITH', suffix 'VD MA', on this tree and on `e56dd296` alike — that part reads as the comma-less name does, and the comma-less name splits the same way. BLAST RADIUS, measured 2026-10-02 with `e56dd296` (the pre-change master) as the comparator. The gate's output is byte-identical at all five baselines apart from the tree path: no corpus name holds these shapes, the rules.md examples aside. Over a generated grid, 5,676 of 409,498 parses move, every one in one of two classes — 3,534 where a family gains the words behind its particle, 2,142 where `vd` or `mc` leaves the family for the credential run — and no list of ambiguity kinds changes; 1,310 of the movers are written in mixed case. Recompute: from the pool `vd mc de van do Ma MA PhD Jr John Berg V la Prof.`, build every `<1 or 2 words>, `, `<3 words>, <1 word>`, `Smith , John`, `Doe, Jane <2-4 words>` and `Smith, <2-4 words>`; deduplicate, then parse each as written, `.upper()` and `.lower()`, keyed by the resulting text; keep the entries whose `as_dict()` or sorted ambiguity kinds differ between the trees, and class each by whether the new family holds every old family word and more, or the old family lost only `vd`/`mc` while the suffix kept every word. Cost: the given-slot test was QUADRATIC as first written, rescanning the particle run once per member — the second review measured 29,670 → 165,591 frames on `SMITH, VD ` + `DE `×k + `MA `×k at k = 256 against `e56dd296`, where an earlier wall-clock check at k = 100 and 400 had read 3.18× and passed for linear. The run's end is now kept once per title set: 1.90–1.99× per doubling of k from 16 to 256, and `_REREAD_SHAPES["lead"]` in tests/v2/test_benchmark.py guards it (generator frames at 8 units against 32: 3.38× kept, 7.53× rescanning, bound 6.0). The frame-count reference band did not move. - 2026-10-19 — SPLIT FROM rules/P2 and rules/P6 with the rule: rules/P2's clause on the part before a family comma and rules/P6's third exception, the two sites #573 decided for a word of both vocabularies. The #573 entry above moved here from decisions/P6, unchanged but for the two site headings, which name the rule each site was in; where it says P2 or P6 elsewhere, it means the rule as it then stood. Its third site, a never-given particle opening the given part, is rules/S5's, which keeps its own cross-reference. derek73-python-nameparser-456b024/docs/design/decisions/R1-fields-are-views-of-the-parsed-words.md000066400000000000000000000742201527303271700326350ustar00rootroot00000000000000# R1 — fields are views of the parsed words ## Standing - One comma segment is one suffix entry, and the boundary between entries is the comma the writer typed: two consecutive post-nominals are one entry where they sit in the same comma bucket, `comma_bucket` being the function the segments are built with, and nothing between them parts the run (`Smith, MD PhD` and `John Smith MD PhD` → suffix 'MD PhD', `John Smith, MD, Bart` → 'MD, Bart', `Smith Jr., Mr. Jr.` → 'Jr., Jr.') — 2026-09-06 #436/#437. - A name word or a dropped delimiter core parts a run, and a token whose role renders into another field (title, nickname or maiden) or a dropped token that is no core does not (`Smith, MD Dr. PhD`, `Smith, MD "Doc" PhD` and `Smith, MD (nee Jones) PhD` → suffix 'MD PhD'); the core set is `delimiter_cores`, the derivation group's drop site reads, imported rather than repeated — 2026-09-06 #436/#437. - The parting scan reads the core set whole, by text, ungated by the tail segment group drops cores on, so a dropped token whose text the policy names parts the run whatever dropped it (`Smith, MD (nee Jones) PhD` → 'MD PhD' by default and 'MD, PhD' under `extra_suffix_delimiters=frozenset({" nee "})`); the pass runs after roles are settled, since the span rule read role-blind would join two middle names, and last in post_rules by convention rather than necessity — 2026-10-19 #760. - Within a piece the `joined` tag renders a merged piece as one unit whatever role it holds, so `Smith, Rev. Dr.` keeps `title_list == ['Rev.', 'Dr.']` and a `Ph. D.` merge stays one unit in any field; a role-aware tag, a render-time span scan, marking the boundary at the core-drop site and a third branch in group beside `tail` were declined — 2026-08-23 #429; 2026-09-06 #436/#437. - Group still drops a delimiter core only on a tail segment, v1 `expand_suffix_delimiter` parity (#206) — 2026-08-23 #429; 2026-09-06 #436/#437. - `str()` is a rendering, not a canonical form: a suffix run survives a render and a reparse on the three writings #429 fixed (`test_a_suffix_run_survives_a_render_and_a_reparse`), and no rule promises a general round trip — 2026-09-06 #436/#437. - A suffix value handed to `revise()` derives its entries from its own commas by the rule a whole name uses, `revise` forcing the role and re-running `suffix_entries` over the forced state, so `revise(n, suffix=n.suffix)` keeps 'MD PhD' for `John Smith MD PhD`; the value is classified on its own, so `김민준씨, J.씨` revises its suffix '씨, J.씨' to '씨, J. 씨', and a policy delimiter parts a value only where its own words give it a tail segment (under `extra_suffix_delimiters=frozenset({" - "})`, `Doe, John, MD PhD - FACS` reads 'MD PhD, FACS' and `revise` with 'MD PhD - FACS' keeps it whole) — 2026-09-06 #511. - `revise` keeps the sub-parse's tags minus FOLDED_TAG, so a within-piece mark survives (`revise(n, family="John Ph. D. Smith")` keeps 'D.' marked `joined`) and a between-piece suffix mark left on a forced non-suffix role is a tag-only oddity left as it is; a list-valued `revise` and a text-only delimiter read inside it were declined, and `replace(suffix="MD, PhD")` rendering 'MD,, PhD' is left as is, `replace` whitespace-splitting by contract — 2026-09-06 #511. Not standing: the measurements and re-pinnings of #436/#437 and #511, which decide nothing — 2026-09-06 #436/#437; 2026-09-06 #511; the move of these entries from decisions/C1, carried out — 2026-10-18 #728; the re-count moved from rules/R1's revise() clause, a measurement — 2026-10-20 #761. Elsewhere: `segment_suffix_reading` read once by the comma decision at the head of group, no longer by assign — decisions/C1's 2026-10-06 (Derek), #613 entry; a declared delimiter parting a trailing suffix part as a comma would — decisions/C10; the split `Ph. D.` opening the part after a comma as the postnominal part (`Smith, Ph. D. Smith` → suffix 'Ph. D. Smith', where the #436/#437 entry's DECLINED list cites `first_list == ['Ph. D.']`) — decisions/C13's 2026-10-04 #603 entry. Open: none in this file. ## Log - 2026-08-23 #429 (the between-piece entry rule superseded by 2026-09-06 #436/#437, and assign as the one caller of `segment_suffix_reading` by decisions/C1's 2026-10-06 (Derek), #613, which moved the read into the comma decision) — one comma segment is one suffix entry, and the two stages disagreed about which segments those are. group asked by segment INDEX (`tail_start`: 1 after a suffix comma, 2 after a family comma), which is right wherever assign reads the segment as suffixes for the same structural reason — but not after a family comma, where segment 1 is a name slot assign re-reads by CONTENT with `_segment_holds_no_name` and routes piece by piece (#296/#325). So the roles were right and the render was not: `Smith, MD PhD` gave suffix 'MD, PhD' with a comma the writer never typed, and `Smith, CBE MC`, `Smith, BSc MBA` and `Smith, PhD Jr.` the same, while the full-name `John Smith, MD PhD` has rendered 'MD PhD' since 1.4.0. The predicate moved down into group beside the piece predicates it already reads — the same direction `_leading_titles` moved in #424, and the only direction available, since assign imports group. (#436/#437, the next entry, took group's call away again on 2026-09-06, leaving assign the one caller; the predicate itself stays in the piece layer.) SCOPE, stated as the condition actually reads rather than as the first draft described it: any family comma whose following segment holds no name word, NOT only a one-word family. `John Smith, Jr. III` renders suffix 'Jr. III' (1.4.0's reading), and a title-led segment is in too — `Smith, Dr. MD PhD` renders 'MD PhD'. The differential cannot witness the widening: of 1069 corpus names, 303 carry a structure comma, the predicate is true for 36, only 9 of those have two or more pieces in the segment, and none of the 9 combines a multi-word pre-comma name with two suffix pieces. 0 unexplained is therefore evidence about the corpus, not about the class — the case table carries it instead. THE REGRESSION THE REVIEW FOUND, and the reason this entry is long: the first draft let ANY piece open an entry, as the tail block always had. That is safe on a tail segment, where assign routes every piece to SUFFIX — which is what `tail` means — and wrong off it, where a title piece routes to TITLE. Two failures, one cause. The `joined` tag is role-BLIND and the facade heals it for every role, so a title opening an entry collapsed `title_list` from ['Rev.', 'Dr.'] to ['Rev. Dr.'] on `Smith, Rev. Dr.`; and where the pre-comma name left a suffix, the tag glued the next suffix BACKWARD across the writer's own comma — `Smith Jr., Mr. Jr.` rendered suffix 'Jr. Jr.', the exact inverse of the bug being fixed. Neither is visible to the differential, which compares strings, nor to the case table as it stood, which asserts the title STRING (space-joined either way). The rule that replaces it separates two joins that had been one: WITHIN a piece the tag renders a merged piece as one unit, whatever role it holds — that half still stands; BETWEEN pieces it continued an entry, and only a piece that rendered into the same run could do that. The entry was sticky across a piece that was not in it, so an interleaved title did not split the run it sat in (`Smith, MD Dr. PhD` renders 'MD PhD'), and a delimiter core closed it, which was the one thing that separated two entries. That between-piece half is GONE since 2026-09-06: #436/#437 replaced it with a rule over the commas the writer typed, the bullet below. Both readings it was built for are unchanged — the stickiness holds by construction now rather than by code. Scoped deliberately, and the scoping is the other decision: `tail` still gates delimiter-core dropping, which is v1 `expand_suffix_delimiter` parity (#206, commit 021823e — three code comments say #191, which is the German/Dutch vocabulary PR, and are corrected here) and a TAIL rule. The core set is threaded to the block as `seg_cores` so the widened condition cannot drop cores where `tail` says not to. Worth knowing before anyone tries to pin that: for every realistic delimiter the two spellings are indistinguishable by construction, because the core must first pass the very predicate that gates the block — the only reachable class is a policy whose delimiter core is itself suffix or title vocabulary (`Policy(extra_suffix_delimiters={" jr "})`, `Smith, MD jr`), which the case table does not enshrine. COST, measured against d6087d9e (PR #435): `_segment_holds_no_name` ran twice per family-comma parse, pure recomputation (408 of 506 comma-bearing test literals reached both sites, 0 disagreements), and removing the second call was worth 1.2–2.2% of a family-comma parse and 0% of every other, so the number was recorded instead of a cache. R1 gained the separator sentence with this change. That the invariant it states — one comma segment is one suffix entry — was written down in no rule is a large part of why two stages disagreed about it for years: C1 governs routing, R1 governed word ORDER and never mentioned separation, and the only statement of the rule was a code comment. Left alone, measured and pre-existing: the NO-COMMA path comma-joins a space-separated run too (`John Smith MD PhD` renders 'MD, PhD' at 1.4.0, 2.0.0, 2.1.0 and master alike), and that path has no writer-drawn boundary, so it is a separate question. CLOSED 2026-09-06 by #436/#437, the entry after this one, which moved the boundary to the comma the writer typed and pinned the round trip (`test_a_suffix_run_survives_a_render_and_a_reparse` in tests/v2/test_render.py). The name `Smith, MD PhD` entered the corpora as this fix's example; a fourth 1.4.0 ledger rule written for it explained nothing and was dropped, #373's dormant-rule diagnosis doing its job (the ledgers were retired in #664). - 2026-09-06 #436/#437 (its one call site of `segment_suffix_reading` superseded by decisions/C1's 2026-10-06 (Derek), #613; its `Smith, Ph. D. Smith` reading by decisions/C13's 2026-10-04 (Derek), #603; its `Smith, MD nee Jones PhD` reading by decisions/M2's 2026-10-04 (Derek), #601, a marker in the given part after a family comma being an ordinary word (title 'MD', given 'nee', middle 'Jones', suffix 'PhD'), re-measured in the 2026-10-19 #760 entry below; and its 'PhD, FACS' readings of `Smith, MD - PhD - FACS` and `John Doe, MD - PhD - FACS` under the default policy, and of the `Smith` row under `extra_suffix_delimiters={' - '}`, all now suffix 'PhD - FACS', superseded by decisions/S8's 2026-10-04 (Derek), #602, which puts the dash after PhD in the credential run, the parting half held instead on a name word that survives, `Smith, Jane abd Jones PhD` → suffix 'abd, PhD') — the entry boundary is the comma the writer typed, not the shape of the segments. Two consecutive post-nominals are one entry iff they sit in the same comma bucket and nothing between them parts the run; `comma_bucket` is the function `segment` builds segments with, so "same part" here is an identity with segment's answer rather than a resemblance to it (mechanisms.md#ONE-PREDICATE-PER-QUESTION). What PARTS a run is a name word — given, middle or family — or a dropped delimiter core (#206). What does NOT part it is a token whose role renders into some other field altogether: the pass names all three of them beside itself as `_RENDERS_ELSEWHERE` — title, nickname and maiden — and such a token is not standing in the run at all, which is what keeps `Smith, MD Dr. PhD` one entry (#429's stickiness, now by construction) and `Smith, MD "Doc" PhD` and `Smith, MD (nee Jones) PhD` with it. Nor does a dropped token that is not a core part it: that is the bare maiden marker of `Smith, MD nee Jones PhD`, dropped with no role at all, so the role test cannot see it. Which is why the dropped arm reads the core SET rather than treating every dropped index as a boundary — a core is the one dropped token the writer typed AS a separator — and the set is `delimiter_cores` in the vocabulary layer, the same derivation off `Policy.extra_suffix_delimiters` that group's drop site reads, imported rather than repeated so the drop site and this pass cannot disagree about what a core is. That is mechanisms.md#ONE-PREDICATE-PER-QUESTION again, one level down from the readers #430 counted. FOUND, and it is the reason this is a fix rather than a change: R1 stated the rule in words on 2026-08-23 and the NO-COMMA path had violated it since the sentence was written — #429 added the sentence and recorded the violation without fixing it — its bullet above opens "Left alone, measured and pre-existing" and says of the no-comma path that it "is a separate question". `John Smith MD PhD` rendered suffix 'MD, PhD' at 1.4.0, 2.0.0, 2.1.0 and 2.2.0 alike, a comma nobody typed, and it is now R1's own example line. This bullet is R1's recorded history; it was filed under decisions/C1 until #728 gave R1 a decisions file of its own. WHAT GROUP LOST, and it is the whole between-piece half: `tail` by index, #429's `segment_suffix_reading` verdict ORed into it, the per-piece gate, and the stickiness across an interleaved title. Three shape-derived answers to one question was the defect, and the review of #429 had already found that shape wrong in both directions at once. The stickiness needs no code now: `Smith, MD Dr. PhD` has no comma between MD and PhD and the title between them renders into another field, so they join, while `Smith Jr., Mr. Jr.` has the writer's own comma and they do not — both still pinned. What stays in group is core DROPPING keyed on `tail` through `seg_cores` (v1 `expand_suffix_delimiter` parity, #206, a TAIL rule) and the within-piece tag that heals the `Ph. D.` merge, which is role-blind and right for every role. `segment_suffix_reading` is left where it is with ONE call site, in assign; decisions/C9's #430 entry records three readers, and the render join was the third. AMENDED FROM THE SPEC TWICE, both times by measurement, and this is the half worth reading twice. The approved predicate parted an entry only at a DROPPED delimiter core. Measured 2026-09-06, the two corpus names the spec names as that class — `Smith, MD - PhD - FACS` and `John Doe, MD - PhD - FACS` — drop no core at all under the default policy the corpora compare under, the dash being no delimiter there: the dashes survive as ordinary given/middle words between PhD and FACS, and a rule reading `dropped` alone joins them where 'PhD, FACS' is pinned. `cases.py`'s `suffix_delimiter_detection` had the same shape pinned already. So the predicate asks what STANDS between the two rather than only what was dropped, and both halves are pinned, one row each, because a dropped-core test alone passes on the rule that broke the corpus. Reading the dropped arm against the core set also retires the spec's open question of how the pass tells a core from a maiden marker: no `WorkToken` tag and no new `ParseState` field, the set being the one group already derives. THEN AMENDED AGAIN, in review of the FIRST DRAFT of this bundle's code commit — caught before that commit was rewritten, so the commit as it shipped already names all three transparent roles — and the second find is worth as much as the decision. That first draft's predicate made TITLE the only transparent role and treated every dropped index as a boundary, and it was wrong in both directions at once. Measured 2026-09-06 against the parent tree — parse the strings on a pristine checkout of the parent, then against a copy of the pass with `_RENDERS_ELSEWHERE` cut to the title alone and the dropped arm reading any drop as a boundary — three comma-path shapes that render 'MD PhD' at the parent went to 'MD, PhD': `Smith, MD "Doc" PhD`, `Smith, MD (nee Jones) PhD` and `Smith, MD nee Jones PhD`, a nickname, a parenthesised maiden clause and a bare maiden marker, one regression apiece. And the no-comma twin of the first, `John Smith MD "Doc" PhD`, rendered 'MD, PhD' at the parent and stayed there under that predicate — the very bug being fixed, left unfixed on a nickname-carrying shape. The differential witnessed none of the four, so the gate was green over all of it and the code review is what caught it; the general form is that a transparency written from the roles a corpus happens to hold reaches only the roles that corpus happens to hold. Four stage tests pin the four shapes now, and the pass renders 'MD PhD' for all four. MEASURED 2026-09-06 against f5deae01 (PR #510), compare.py at each baseline: the gate exits 0 with nothing unexplained; intentional diffs 352 → 360 at 1.4.0, 247 → 254 at 2.0.0, 155 → 166 at 2.1.0 and 14 → 28 at 2.2.0, the corpus growing by one name, the R1 example `John Smith MD PhD`; one name, `abdul Smith Jr V`, changes ledger rule, at 2.0.0 and 2.1.0. VIEWS, over the 1117 corpus names against f5deae01 (PR #510): 13 of the 1116 pre-existing names move, 14 with the new R1 example, each in `suffix_list` and the v2 suffix field and tags only, and every one from a comma-joined render to a space-joined one. RE-PINNED (PR #510): nine `cases.py` rows (`title_plus_one_word_two_suffixes`, `family_segment_multiple_suffixes`, `suffix_run`, `family_comma_segment_zero_is_not_the_run`, `family_comma_numeral_behind_a_suffix_is_not_an_initial` and four CJK honorific rows), eight assertions outside the table (four in the v1-parity suite, a deliberate v1 deviation the release log names) and four rules example lines (P2's `John Smith Mc V`, P5's `abdul Smith Jr Ma`, rules/C9's `Smith, John PhD I.` and W3's `田中さん 様.`), each losing a comma the writer never typed. ROUND-TRIPPING is stable on the shapes #429 fixed, which is what that entry recorded as the one load-bearing consequence of leaving this open: `str()` of a fixed parse is a no-comma string, and the no-comma path put the comma back. `test_a_suffix_run_survives_a_render_and_a_reparse` in tests/v2/test_render.py asserts `parse(str(parse(s))).suffix == parse(s).suffix` over the three writings. NOT stable in general, and the general claim is the one to resist — measured 2026-09-06 by evaluating that same equality over every corpus name, at the parent tree and here: 35 of 1116 names failed at the parent and 23 of 1117 fail here, thirteen fixed and ONE newly failing. The one is `Smith, John PhD I.`, and it is this same artifact seen from the other side: it round-tripped only because the render emitted 'John Smith PhD, I.' with a comma the writer never typed, which the no-comma path read back as a run. The render writes 'John Smith PhD I.' now, and that string reads middle 'Smith PhD', family 'I.' at the parent and here alike — a bare trailing initial behind a name is name material, #430's own boundary — so the two spellings genuinely disagree and the comma had been hiding it. The 23 that remain are the standing class: `str()` is a rendering, not a canonical form, and no rule promises it re-parses. NOT FIXED, and named because this change makes it reachable on more inputs: `revise(n, suffix=n.suffix)` is not the identity on a space-joined run. `Parser.revise` classifies each value by a full sub-parse of that value alone — its own docstring records the limit, the value being "classified ON ITS OWN" — and a bare 'MD PhD' carries no comma for the entry rule to route by, so the two words come back as two entries and the field re-renders 'MD, PhD'. Pre-existing, and the same shape as the `str()` limit above rather than a new one. Measured 2026-09-06 over every corpus name, comparing `revise(parse(n), suffix=parse(n).suffix).suffix` against `parse(n).suffix` at the parent tree and here: 24 of the parent's 1116 names fail and 38 of the 1117 here do, none of the parent's 24 recovering, and the 14 added are exactly the 14 movers — the 13 pre-existing names plus the R1 example. Not this change's to fix: the entry rule reads the commas in a whole name, and what `revise` hands its sub-parse is a field, so closing it means deciding what a field value's separators mean, which is a `revise` question. Answered, and FIXED, by #511 — the next bullet. DECLINED, all four with the evidence: marking the boundary at the core-drop site (#437's MARK-DONT-STRIP shape) — `dropped` already holds the fact with its span, and a second recording of it is the duplication that mechanism exists to prevent, one level up; making the `"joined"` tag role-aware — within a piece it is role-blind and correct for every role, `Smith, Ph. D. Smith` giving `first_list == ['Ph. D.']`, and only the between-piece half was ever a suffix concept; a render-time span scan instead of the recorded tag — `_facade.__setstate__` and `ParsedName.replace()` synthesise span-less tokens, so an unpickled name has nothing to scan and the tag IS the entry structure a pickle carries; and fixing the no-comma path inside group's block as a third branch beside `tail` and `reading`, which is the shape #429 took. - 2026-09-06 #511 — a suffix value handed to `revise()` derives its entries from its own commas, by the rule a whole name uses. `Parser.revise` sub-parsed each value and forced every harvested token to the named role AFTER the sub-parse had run R1's entry pass, which keys on Role.SUFFIX; a bare 'MD PhD' reads there as a title and a family name, so the pass joined nothing and the field rendered 'MD, PhD'. The fix runs the same pass again: `revise` now sub-parses to a ParseState, forces the role on every non-dropped token, and calls `suffix_entries` — the pass, lifted unedited out of post_rules' tail into a function the two callers share (mechanisms.md#ONE-PREDICATE-PER-QUESTION) — over the forced state, then assembles and harvests as before. So a comma in the value parts two credentials and a space joins them. TWO SPELLINGS of the lifted pass, and the reason is the call budget: a first draft had post_rules call the state-in/state-out wrapper, and the second ParseState build cost three more calls per parse against the band tests/v2/test_benchmark.py holds (py3.11, 2026-09-06, tools/perf/call_count.py: 450 calls/name before the move, 451 with the in-place worker `_mark_suffix_entries` that post_rules now calls, 454 with the draft; the facade band tops at 455.9), so the worker writes in place as every other post rule does and the wrapper exists for the one caller with no token list of its own. NO `_run` HELPER for the same reason: a draft routed `parse()` through a private state builder shared with `revise`, and that cost one frame per parse on the hot path (py3.11, 2026-09-06: 415 calls/name against 414 without it, in a 402-418 band), so the four-line construction is spelled twice and `parse()` is untouched. MEASURED 2026-09-06 over the 1117 distinct corpus names, revising each of the 368 with a non-empty suffix by its own suffix: 38 suffixes moved before and 1 after, and the differential gate was byte-identical at all four baselines (recipe in the #511 issue body). THE ONE LEFT is '김민준씨, J.씨', and it is not entry structure: the whole-name parse keeps 'J.씨' one glued suffix token, suffix '씨, J.씨', while the sub-parse of the bare value '씨, J.씨' peels the honorific off the initial, so the revised field renders '씨, J. 씨' — right entries, the spurious comma of before ('씨, J., 씨') gone, one word read differently by the value's own parse than by the whole name's. That is the "classified ON ITS OWN" limit `revise`'s docstring has always recorded, and CJK honorific peeling is a W-rule question this change does not move; pinned in `test_revise_reads_a_glued_honorific_on_its_own`. SUPERSEDES the phd-merge acceptance of 'Ph., D.' on this path (its bullet says how). STALE TAGS, tried and backed out: a draft cleared the sub-parse's own "joined" with the forcing and re-derived it, because the pass only ADDS the tag and `revise(n, family="Jones MD PhD")` carries the sub-parse's between-piece suffix mark on 'PhD' onto a FAMILY token. Measured 2026-09-06 against `330ee55`, the clear also destroyed every WITHIN-piece mark on a non-suffix value — 'D.' of `revise(n, family="John Ph. D. Smith")` lost the merge mark the #436 bullet's DECLINED list calls role-blind and correct for every role — while on a ParsedName only the suffix string view reads "joined" (`_text_for`'s suffix_join gate) — the facade's `_list_for` heals it for every role, but no path puts a revised name into a HumanName, the v1 setters going through `replace()`, so wiring those setters onto `revise` is the change that would show the stale mark, as `last_list == ['Jones', 'MD PhD']` — `initials()` and every field string being identical at both trees for every shape measured. So the tags are kept minus FOLDED_TAG as before; the between-piece mark on a forced non-suffix role is a tag-only oddity that predates this change and stays, and for a suffix value nothing depends on the sub-parse's marks, every pair it joined sharing a bucket with no parting token and the pass setting it again; pinned in `test_revise_keeps_the_sub_parses_within_piece_mark`. Dropped tokens keep their role and tags through the forcing, because the pass filters them by index and assemble omits them. ONE MORE LIMIT, pinned in `test_revise_leaves_a_policy_delimiter_unparted_without_a_tail_segment`, and it is the "classified ON ITS OWN" limit again rather than a rule of revise's: a delimiter the policy names through `extra_suffix_delimiters` is dropped, and so parts entries, only on a segment after a comma that the reading of the words makes a tail, and a value with no comma of its own has none — under `Policy(extra_suffix_delimiters=frozenset({" - "}))` the whole name 'Doe, John, MD PhD - FACS' renders 'MD PhD, FACS' while `revise(n, suffix="MD PhD - FACS")` renders 'MD PhD - FACS', the dash surviving as a token and the forced role making it a suffix word; measured 2026-09-06, a value whose own words read with a tail segment does part ('John Doe, MD - FACS' revises to 'John Doe, MD, FACS') and one after a suffix comma does not ('MD, PhD - FACS' stays), which is how the value's words read as a name deciding it. The round-trip is unaffected, the whole-name view having already rendered that boundary as a comma. DECLINED: a list-valued `revise(p, suffix=["MD PhD", "FACS"])`, which widens the API and leaves the string path where it was; documenting the limit with pins alone, the docstring already recording it and #511's measurement showing it reachable on every space-joined run #436 produced; and a text-only read of the delimiter cores inside `revise`, a second rule for a case no round-trip reaches. Out of scope and left as is: `ParsedName.replace(suffix="MD, PhD")` renders 'MD,, PhD', `replace` whitespace-splitting by contract and the facade setters riding on it for v1 parity. - 2026-10-18 #728 — MOVED FROM decisions/C1. These entries decide that one comma segment is one suffix entry, which is R1's entry pass rather than the comma decision, and were filed under decisions/C1 until #728; they moved unchanged but for the pointers the move broke, the measurement paragraphs, each cut to one line naming its population, counts, baseline and PR, and the superseded passages, each cut to its decision and what replaced it. - 2026-10-19 #760 — MOVED FROM the entry pass's code comment (`_post_rules._mark_suffix_entries`), the facts the entries above did not hold. ORDER: the pass runs after assign because it keys on Role.SUFFIX; the same span rule run role-blind would join the A and B of `John A B Smith` into one middle_list element, pinned by `test_the_pass_runs_after_roles_are_settled` in tests/v2/pipeline/test_post_rules.py. Within post_rules it runs last by convention, not by necessity: every retag there targets a name role (`_retag` is called with FAMILY, GIVEN, MIDDLE and `_name_positions`' return, which is those three, and the `role=Role.FAMILY` replaces of P6's no-comma attachment and O3's fold are FAMILY too), so no rule moves a token into or out of SUFFIX or {TITLE, NICKNAME, MAIDEN} and the predicate reads the same answer wherever in the stage it stands (measured 2026-09-06 by reading the stage's retag targets). THE UNGATED CORE READ: group drops a core only on a `tail` segment, while the pass reads `delimiter_cores` whole and asks by TEXT alone, so a dropped token whose text the policy names parts the run whatever dropped it. The gate is not needed: a core group dropped was on a tail segment by construction, and the one case the ungated read adds is a maiden marker the policy also lists — under `Policy(extra_suffix_delimiters=frozenset({" nee "}))`, `John Doe, MD nee Jones PhD` renders 'MD, PhD' where the default policy renders 'MD PhD' (measured 2026-09-06), the writer's configuration having named that text a separator. That example no longer reaches the case: re-measured 2026-10-19 while moving it here, `John Doe, MD nee Jones PhD` reads title 'MD', given 'nee', middle 'Jones', family 'John Doe' and suffix 'PhD' under both policies, the comma now read as a family comma, so the marker is never dropped and the pass sees one post-nominal; `Smith, MD nee Jones PhD`, the bare-marker example of the #436/#437 entry above, reads the same way. (Corrected 2026-10-22, #762: both moved with #601, measured either side of 1037c87c, where before it they read maiden 'Jones' with suffix 'MD PhD' under the default policy and 'MD, PhD' under the `" nee "` one, the contrast this paragraph was written to show; what moved is the marker, which no longer takes a clause in that part, and for `Smith, …` the comma was a family comma before as after.) The witness today is the bracketed clause: `Smith, MD (nee Jones) PhD` renders 'MD PhD' under the default policy and 'MD, PhD' under that one, extract having dropped the marker as Role.MAIDEN and group never dropping it, so the ungated text read is what parts the run (re-measured 2026-10-19 in #760's review). The same input is the filter's example below. THE DROPPED-INDEX FILTER is belt-and-braces: cores leave `pieces` before assign and get no role, and the one dropped token that arrives roled is the MAIDEN one tokenize roles from an extracted clause (`Smith, MD (nee Jones) PhD` drops index 2, the marker, as Role.MAIDEN); the filter keeps the suffix list and the parting scan agreeing about what a dropped index is. - 2026-10-20 #761 — MOVED FROM rules/R1's revise() Accepted clause when R1 moved to the skeleton format, the measurement it carried: the #511 bullet counted 368 of 1117 distinct corpus names with a suffix on 2026-09-06; re-measured 2026-09-10 with that bundle's rows in the corpora, 372 of 1156 distinct names, and the same single failure, '김민준씨, J.씨'. derek73-python-nameparser-456b024/docs/design/decisions/R2-all-particles-family-base-divergence.md000066400000000000000000000362431527303271700327370ustar00rootroot00000000000000# R2 — the all-particles family_base divergence ## Standing - A family part whose every word is particle vocabulary is a part where none of them is doing a particle's work, so its words read as ordinary name words: they anchor the base and leave the particles view, position deciding and whether a word is borne as a surname not entering into it (`Anh Do` → family_base 'Do', family_particles ''; `Juan van der` → family_base 'van der') — 2026-08-18 #404/#385. - So a non-empty family always has a non-empty base (`Del Toro` under FAMILY_FIRST → family_base 'Del'), and the base and particles views partition the family, asserted over the whole case table — 2026-08-18 #404/#385. - The words keep their `particle` tag, which means "from the particle vocabulary wherever it lands", and the decision is recorded beside it as UNJOINED_TAG rather than by stripping the tag — 2026-08-18 #404/#385. - `initials()` reads the base, so the anchoring moves R3's output: `Anh Do` initials 'A. D.' and `Juan van der` 'J. v. d.' — 2026-08-16 (pre-merge coherence pass); 2026-08-18 #404/#385. - A particle run that joins nothing still chains whole into one part, so `Juan Smith van der` reads family 'van der' and middle 'Smith'; splitting it was declined, roles reassembling adjacent same-role pieces into one part and the base losing its full extent — 2026-08-18. - The unjoined mark is a statement about a whole part, and `initials()` honors it for every word in it, a connective included: under `Lexicon.default().add(particles={'y'})`, `Juan de y` reads family_base 'de y' and initials 'J. d. y.'; narrowing the readmission to the `particle` tag alone was tried and backed out — 2026-08-29 #461; 2026-09-20 #461. Elsewhere: where a connective initials, a part holding nothing else for it to join (`Jon Dough and` → 'J. D.', `Juan de y` → 'J. y.') — decisions/R3's 2026-09-20 #461 entry; case repair keeping that connective lowercase, its carve-out not gated on the mark (under `Lexicon.default().add(particles={'y'})`, `Juan de y` forced → family 'De y') — decisions/R4's 2026-08-29, the CONJUNCTION CARVE-OUT entry; case repair reading the unjoined mark for the whole part (`ANH DO` → 'Anh Do') — decisions/R4's 2026-08-29 #407 entry; the snapshot recording `initials()`, so a change to the base is visible there — decisions/differential-ledger's 2026-10-13 #664, a committed snapshot replaces the classified gate entry. Open: none in this file. ## Log - 2026-08-18 #404/#385 — R2's DISCRIMINATOR REVERSED. It said a family written wholly out of particle vocabulary has a base where one of those words "is itself borne as an ordinary surname", and that only the words that are never anyone's name stay particles. That is a vocabulary test, and it is the wrong one: a particle earns its name by joining forward to the word it modifies, so a particle with nothing to join is not doing a particle's work whatever it is borne as. POSITION decides, and the borne-as-surname question does not arise. The invariant that replaces it, and the reason this is worth a rule rather than three bug fixes: a non-empty family always has a non-empty base. A particle needs a base to attach to. Measured, 43 of 751 corpus names x 3 orders violated it — "Del Toro" under FAMILY_FIRST reported family 'Del' with no base, losing the surname from the base and from the initials. Marked rather than untagged (mechanisms.md#MARK-DONT-STRIP): `particle` is stable API and means "from the particle vocabulary wherever it lands", which stays true; UNJOINED_TAG records what was decided. That also leaves #405's ambiguity emitter a predicate to key on, which stripping would have destroyed for exactly the never-given half where the fork is most interesting. Blast radius: 44 of 2,253 parses move and every one is VIEWS-only — the seven role fields are byte-identical everywhere, so the differential harness cannot see this change at all (it compares roles only). Tests are the whole verification, and the invariant is one of them. Amended 2026-09-02 (#484): the harness now compares `initials()` under the `_initials` pseudo-field for names whose roles agree, so it CAN see this change — the 27 names are classified by the `fix(#385/#402)` rule at 1.4.0, 2.0.0 and 2.1.0. Tests are no longer the whole verification. - Recorded 2026-08-16, intent UNVERIFIED (the v2 core's readings below superseded by 2026-08-18 #404/#385, which gives `Anh Do` family_base 'Do' and initials 'A. D.'): the v1-era design held that a family name cannot be only particles ("Anh Do" — Do is a surname AND a particle), so last_base was guarded non-empty. The facade still guards (HumanName("Anh Do").last_base == "Do"); the v2 core does not (parse("Anh Do").family_base == "", family_particles == "Do"), and the surname vanishes from initials (parse("Anh Do").initials() == "A."). - 2026-08-16 #385 — SUPERSEDED by 2026-08-18 #404/#385 — RESOLVED by the collision criterion, not on its own terms: the issue's option 3 ("guard the view only when a word is vocabulary-ambiguous") is what decisions/vocabulary-collisions produces when applied here. "Do" is borne as an ordinary surname, so it is ambiguous vocabulary and anchors the base; "van der" is never anyone's name, so an all-particle family there genuinely has no base. The two rows of the issue's table were never one case. This is the keystone's clearest payoff: #385 was filed as a leaf with three options and no way to choose between them, and the criterion picks one without arguing about family_base at all. - ANSWERED 2026-08-18 by the entry above, and recorded rather than left implicit: "Do" does NOT remain in family_particles once it is the base. The two views partition the family — parse("Anh Do").family_particles is "" and family_base is "Do" — which is asserted as a partition over the whole case table, not just as an example. Was: "Still open inside the resolution: whether Do remains in family_particles once it is also the base. Recorded here rather than left to the implementing PR to decide by accident." - 2026-08-16 (pre-merge coherence pass) (`Juan van der` staying "J." superseded by 2026-08-18 #404/#385) — the resolution moves R3 too, and R3 now carries its own marker. Initials read the BASE family word, so anchoring "Do" changes parse("Anh Do").initials() from "A." to "A. D." while "Juan van der" stays "J." (no borne name, no base, and initials of a bare particle run would be nonsense). The general lesson, worth more than this instance: a deviates: marker gets written on the rule whose STATEMENT changed, but a rule can change another rule's OUTPUT without touching its statement, and nothing looks for that — the runner asserts per example line, so an unmarked downstream rule stays green precisely because its own examples avoid the affected input. When adding a marker, walk the changed rule's `interacts:` targets and ask whether any of THEIR examples move. - 2026-08-29 #461 (the given group left unsettled, and the readings that "may not move", superseded by 2026-09-20 #461) — TRIED AND BACKED OUT; the question is back on the issue. The unjoined mark readmits the words of an all-particle part to `initials()`, and #461 narrowed that readmission to the `particle` tag alone, on the authority of R3's "A CONJUNCTION never initials, so a base that is one contributes nothing even then". It was reverted before merge, in the same PR. What the narrowing got wrong is best said in terms of the MARK rather than of the fields. The mark is a statement about a whole PART — none of its words is doing the work its tag names — and #461 honored that statement for some of the part's words while keeping one of them out. Under `Lexicon.default().add(particles={'y'})`, `parse("Juan de y")` is such a part: `family_base` is `de y`, both words carry the mark, and #461 initialed `J. d.`, admitting the `de` as the name word the mark makes it and refusing the `y`. R2's reasoning does not split that way. A particle with nothing left to join is not acting as a particle, which is the whole reason the mark exists; a conjunction inside that same part has nothing left to join either, for the same reason and at the same moment, so it is a name word of the part and initials with the rest. Scope that carefully, because its general form is far wider than the claim and would be wrong: this is about a part the mark has ALREADY turned into name words, not about any conjunction that happens to join nothing. Outside such a part the skip stands whatever the conjunction is or is not joining, and none of those moved or may move — `parse("Juan y Garcia")` initials `J. G.` with the `y` a middle name word by P3, `parse("Juan Velasquez y Garcia")` initials `J. V. G.` over base `Velasquez y Garcia`, `parse("Jon Dough and")` initials `J. D.` over base `Dough and`. Base and initials differ in every one of those, legitimately, and R3's own `"Juan de y" → initials="J."` line is a fourth, its `family_base` being `y` under the default vocabulary. So "the base holds a word the initials do not" is NOT the criterion and was not the finding; the mark is. What is therefore in question is R3's "even then" clause, which carries the carve-out into the one part where the joining has stopped — not the code that failed to implement it. The clause is left standing and #461 now asks whether it belongs there; nothing else in R3 moved, and the paragraph #461 added under it is gone. R4's conjunction sentence rests on that clause by name ("the carve-out R3 states for initials") and is untouched here, so whoever settles #461 settles R4's cross-reference with it. Note what the backout cannot record: a `deviates:` marker hangs on an example LINE and this shape has none to hang on — `particles` and `conjunctions` are disjoint in the default vocabulary and in every locale pack, so no input string the doc runner parses reaches it. rules.md itself therefore carries no trace, and this bullet with mechanisms.md#RENDER-HONORS-THE-PARSE is where the gap is written down. It is not the only gap under R3's conjunction sentence, and the other is older, wider and markable: "A CONJUNCTION never initials" is unqualified, while a conjunction in the GIVEN group has always initialed — `parse("John and Jane Smith")` gives `J. a. J. S.` and `parse("Duke of Edinburgh")` `D. o. E.`, 25 of the 1094 corpus names, all reachable from the default vocabulary. Recorded here because it was found here; it is not #461's to fix. RESOLVED into the normative document on 2026-08-29, in the review of PR #463: rules/R3 now scopes its carve-out to the middle and base family words and declares the given group unsettled, in prose and WITHOUT a `deviates:` marker. The marker was considered and rejected on its own definition — it states the INTENDED value, and there is none to state. R3 counts name words while P3 makes a connective and its neighbours ONE name word, so `John and Jane Smith` has four candidate answers and no entry anywhere picks one: `J. a. J. S.` today, `J. J. S.` reading the carve-out as written, `J. S.` reading P3's join as a single name word, and `J a J. S.` at 1.4.0 (measured on the released wheel, which joins the run rather than skipping the conjunction). Marking it would put an invented value in a normative document and hold the parser to it, since the runner asserts the today-value strictly. The marker's `#N` slot has no owner either — the sentence above disclaims #461 for this gap, and filing its own issue is a maintainer's call rather than a docs commit's. What "markable" meant two sentences up is the MECHANICAL property that an input string exists to hang a marker on, unlike the #461 shape where none does; it was never a claim that the intended value is known. Neither direction is visible to the gate: 0 of the 1094 corpus names move either way, and the counts hold at 229 / 194 / 102 with 0 unexplained at 1.4.0 / 2.0.0 / 2.1.0. Both views of the contested token are pinned rather than left to prose — `test_repair_keeps_a_conjunction_lowercase_in_a_particle_part` holds R4's ungated conjunct, gating which had passed the entire suite until #461's test existed, and `test_initials_readmits_a_conjunction_in_a_particle_part` holds today's initials answer the way a `deviates:` marker would, so re-deciding #461 fails the suite until that pin moves with it. - 2026-09-20 #461 — AMENDS the 2026-08-29 bullet above, which said of four readings that "none of those moved or may move". Two of the four moved and two did not, and the half that survived is the half the criterion was always about. WHAT MOVED: `parse("Juan y Garcia")` initials "J. y. G." where that bullet recorded "J. G.", and R3's own `"Juan de y"` example line reads "J. y." where it read "J.". WHAT DID NOT: `parse("Juan Velasquez y Garcia")` is still "J. V. G." over base "Velasquez y Garcia", and `parse("Jon Dough and")` is still "J. D." over base "Dough and" (all four measured 2026-09-20 on this branch). The criterion that separates them is the one the bullet above named, and it is still not a word count: the question is asked of the WHOLE PART, and a part holding another name word for the connective to join — the part's working particles set aside — is a part where the connective is doing a connective's work and contributes nothing. "Velasquez y Garcia" and "Dough and" each hold such a word, so the skip stands there; the family "y", the family "de y" and the middle "y" of "Juan y Garcia" hold none, so the connective is its part's own name word and initials with it. So that bullet was RIGHT ABOUT THE MECHANISM and wrong only about the clause it preserved. Its finding — that a mark honored for some of a part's words and not for one of them is honoring nothing — is exactly what rules/R3 now states, for all three groups at once, and it is why the fact is computed ONCE over the whole part rather than re-derived per word by each view. What changed is the clause it left standing: R3's "A CONJUNCTION never initials, so a base that is one contributes nothing even then" is conditional now, and "none of those may move" was the reading of the day rather than a decision. decisions/R3's 2026-09-20 bullet carries the rule as taken. The pin that bullet describes did its job: `test_initials_readmits_a_conjunction_in_a_particle_part` was written to fail when #461 was re-decided, and it failed. Declined: - 2026-08-18 — the GROUPING half of #404: a particle run that joins nothing does not chain, so "Jong van der" would split into middle 'van' plus family 'der'. Measured and rejected, though NOT for the reason first recorded here. The first draft said the split makes the family "no longer all-particle so the base fix stops firing" — false, and `der` and `la` are both shipped particles, so a family of either IS all-particle and the rule fires on it ("Juan Smith der" gives base 'der'). What the split actually costs is the SCOPE of the base and a stray particle relocated: grouping can decline to merge but cannot keep the words apart, because roles re-assemble them and two adjacent same-role pieces are one part at the field level. "Juan Smith van der" becomes middle 'Smith van', family 'der' — a base of 'der' rather than 'van der', and a middle name nobody wrote. Keeping the run whole in one part is what gives the base its full extent. The split reading needs the leftover distribution to know these are separate units, which is mechanisms.md#UNIT-PARTITION's problem. derek73-python-nameparser-456b024/docs/design/decisions/R3-initials-honor-the-fold.md000066400000000000000000001177261527303271700303510ustar00rootroot00000000000000# R3 — initials honor the fold ## Standing - Each group initials in the order its field reads: written order, except folded family words (O3's fold, P6's attachment), which initial first as they render first (`Hassan, Mohamad Ahmad Ali` under `middle_as_family` → 'M. A. A. H.', `der, y van` → 'y. v. d.'), which brought the `middle_as_family` orders back to 1.4.0's (`Doe, Dr. John A.` → 'J. A. D.') — 2026-08-30 #408. - The fold partition applies to every role the view renders, though only the family can carry the tag today, pinned from a hand-built name with two tokens per role; P6's half has no rules/R3 example line, its one witness `der, y van` having a contested parse, and is pinned in a unit test — 2026-08-30 #408. - A connective contributes nothing where it is joining, a part holding another name word for it to join, the part's working particles set aside, and initials like any name word where its part holds nothing else; one rule for every group, asked of the whole part and never by a word count (`John and Jane Smith` → 'J. J. S.', `Juan y Garcia` → 'J. y. G.', `Juan Velasquez y Garcia` → 'J. V. G.', `Jon Dough and` → 'J. D.', `Juan de y` → 'J. y.') — 2026-09-20 #461. - The fact is decided once per part and recorded on the token as UNJOINED_CONJUNCTION_TAG, a marker separate from UNJOINED_TAG so `family_base`, `family_particles` and case repair do not move; the core view and the facade both read it, and `Parser.revise` recomputes both marks (`revise(n, family='de y')` → 'J. y.' for `Juan Smith`) — 2026-09-20 #461. - The given group's answer is a deliberate 1.4.0 parity break (1.4.0 'J a J. S.' for `John and Jane Smith`), and a lone connective's initial restores 1.4.0's own answer (`JUAN Y GARCIA` → 'J. Y. G.', `محمد و علي` → 'م. و. ع.') — 2026-09-20 #461. - The facade's `HumanName.initials()` reads the parse's connective tags wherever a parsed token backs the word, so a dotted or bare capital initial is kept (`Scott E. Werner` → 'S. E. W.', `john e smith` → 'j. e. s.'); a token carrying `UNCLASSIFIED_TAG` falls back to the vocabulary through `_render._reads_as_conjunction`, the helper case repair calls, and the particle question stays `_is_particle`'s live vocabulary lookup — 2026-09-01 #462; 2026-09-13 #528. - Accepted: the connective answer is fixed at parse time, so under a private `Constants`, `Juan Velasquez y Garcia` keeps 'J. V. G.' after `conjunctions.remove('y')` until `full_name` is assigned again, which gives 'J. V. y. G.' — 2026-09-13 #528. - Accepted: a name that never went through the tokenizer carries no tags and no marks, so a pickle round trip, `copy.copy`, `copy.deepcopy` and the keyword constructor take the vocabulary fallback (`john e smith` → 'j. s.', `JUAN GARCIA Y LOPEZ` → 'J. G. Y. L.', `Juan de y` → 'J.' after `copy.deepcopy`), the remedy being to assign `full_name` again — 2026-09-13 #528; 2026-09-20 #461. - `_process_initial` takes an optional `tokens=` that `initials()` always passes, a stated break on a private hook: a two-argument override raises `TypeError`, one that accepts the keyword and drops it falls onto the string path, and only one that forwards it receives the fix; a subclass that overrides a `*_list` property has that member initialed from the override's strings (`last_list` returning `['Zorro']` gives `john e smith` 'j. e. Z.') — 2026-09-13 #528. - `Ph. D., John` still differs between the views ('J. P D.' on the facade, 'J. P. D.' on the core), the facade initialing the merged element as one; that is a grouping difference, not this rule's — 2026-09-13 #528. - Accepted: the facade's fallback can show what the core's cannot, a caller-added conjunction written initial-shaped in a script with no initials (with `太` added, `Wang Chen 太. Li` → 'W. C. L.', and the same family spliced in → 'W. C. 太. L.'), the remedy being to assign `full_name` again — 2026-09-13 #528. - An initial is the shortest start of its word that composes to the composed word's first character, written in the form the writer used (`e\u0301mile zola` → 'e\u0301. z.', `マイケル シ\u3099ャクソン` → 'マ. シ\u3099.'), a first code point excluded from composition kept as written (`\u095bाकिर` → '\u095b.', its nukta kept); composing the initial to NFC and taking the first letter with its following marks were declined — 2026-10-02 #585. - A word is a particle only where its position makes it one, so a listed particle standing as a name word initials (`Nguyen, Van Le` → 'V. L. N.', `Anh Do` → 'A. D.') — 2026-10-19 (Derek), #761. Not standing: the skeleton migration, carried out — 2026-10-19 #761 (skeleton migration). Elsewhere: R3's earlier history, the #461 backout and the all-particle readmission, a family of nothing but particles contributing its words — decisions/R2's 2026-08-29 #461 and 2026-08-18 #404/#385 entries; a spliced field initialing every word on the core view (`replace(family='de la vega')` → 'j. d. l. v.') — decisions/R4's 2026-08-29 (#458 review, then the review of PR #463) entry; the snapshot recording `initials()`, so an initials-only change shows there — decisions/differential-ledger's 2026-10-13 #664, a committed snapshot replaces the classified gate entry. Open: [#464](https://github.com/derek73/python-nameparser/issues/464) — a `Parser.initials` crossing, which would let `initials()` answer a spliced field under the parse's vocabulary. ## Log R3's earlier history is under `decisions/R2`, which this entry does not repeat: the #461 backout and the unsettled given-group conjunction question are recorded there because they were decided alongside R2's all-particle clause. This entry is R3's own, and R3's `history:` points here. - 2026-08-30 #408 — DONE: `initials()` orders folded words first, as every other view of the same parse already did. O3's fold and P6's attachment both TAG rather than move a token (mechanisms.md#FOLDED_TAG — spans cannot reorder), and `_types._text_for` partitions on that tag for every role it renders, so the `family` field reads `van der` where the tokens sit `der ... van`. `initials()` walked `tokens_for(role)` in written order and never asked, so the two views disagreed about one parse: `parse("der, y van")` gave family `van der` and initials `y. d. v.`, and it gives `y. v. d.` now. This is mechanisms.md#RENDER-HONORS-THE-PARSE's second shape, filed as its worked instance, so the design is not re-argued here — only what the measurement added to it. WHAT THE MEASUREMENT ADDED, and it reframes the issue from a consistency fix to a parity restoration. v1 had this feature as `CONSTANTS.middle_name_as_last` (`_config_shim` maps it to `Policy.middle_as_family`) and had NO general `name_order`, so only the two default-order policies have a v1 reference at all. Measured on the released 1.4.0 wheel over the 1094-name deduped corpus, from a script run outside the worktree that asserts `nameparser.__version__` first: at the default policy 1 name moves and agrees with 1.4.0 neither before nor after; under `middle_as_family` 71 move, of which **54 come into exact 1.4.0 agreement and NOT ONE moves away from it**. Worked pair: `Doe, Dr. John A.` gives `J. A. D.` at 1.4.0, gave `J. D. A.` here, and gives `J. A. D.` again; `Brundridge, Contessa A` likewise `C. A. B.` / `C. B. A.` / `C. A. B.`. The facade had been giving the folded answer all along — `HumanName.initials()` reads `first_list`/`middle_list`/`last_list`, and `_facade._list_for` prepends the carriers exactly as `_text_for` does — so the core view was simultaneously out of step with the field beside it, with the facade above it, and with 1.4.0 behind it. A view that stopped honoring a record had been reproducing a v1 bug v1 did not have. THE 17 THAT AGREE WITH 1.4.0 NEITHER WAY, recorded because the obvious explanation is wrong — and the FIRST REPLACEMENT for it was wrong the same way, corrected by this branch's docs review before merge. It is not that their parse already differs: only 2 of the 17 parse differently from 1.4.0 under this policy, `Smith, John V.` (#432's dotted numeral) and `남궁민수 지훈` (the unspaced-hangul surname split and the script order, rules/W1). The other 15 have SEVEN BYTE-IDENTICAL FIELDS at 1.4.0 and here — `der, y van` INCLUDED, which an earlier draft of this bullet filed with the two above by carrying over a measurement taken at the OTHER policy, where its parse does differ. Of those 15, 14 differ from 1.4.0 only in v1's initials GRANULARITY — v1 initials one group per `*_list` element, so a run its parse joined into one element (`Velasquez y Garcia`, `abdul salam`) yields `V G.` where 2.x, one token per word, yields `V. G.` — and strip the periods and spaces and all 14 come into 1.4.0's letter ORDER after this change, none having been in it before (`JVGQ` → `JQVG`). The 15th is `der, y van`, and it is worth naming rather than lumping: its fields match 1.4.0 exactly and its initials do not, because 1.4.0 contributes NOTHING for a family that is all particles (`van der` → `y.`) where 2.x contributes its words. That is R3's own adjudicated clause and decisions/R2's territory — R3 carries `"Juan van der" → initials="J. v. d."` against 1.4.0's `J.` — so this one name is a divergence this document has already decided in favor of, not an unexplained residue. So the parity claim is stronger than "54 of 71 and the rest unexplained": 68 of the 71 come into 1.4.0's ordering, 54 of them exactly, one more is R2's decided divergence, and 2 have a parse difference that predates this change. BLAST RADIUS by policy, over the same corpus × three `name_order` values × `middle_as_family` off/on (6564 parses, 660 move). Default order: 1 / 71. FAMILY_FIRST: 1 / 294. FAMILY_FIRST_GIVEN_LAST: 1 / 292. STATE THE POPULATION BESIDE THE COUNT, per this file's own rule: the shape an order change can be observed in is a contributing folded token standing AFTER a contributing unfolded one of the same role, and at the default order the corpus holds exactly 1 such name without `middle_as_family` and 72 with it. So the movers are 1 of 1 and 71 of 72 — a near-total hit rate over a corpus that carries one instance of P6's half, not a blast radius near zero. (The one non-mover is `Chang, Andy C I`, where the folded `C` and the family `Chang` share a letter and the reorder is invisible in initials.) Nine corpus names carry the tag at all at the default order; the other eight have a single contributing family token, which makes the partition the identity. The `middle_as_family` half is where the reach is, which follows from the producers: O3 folds every middle word, while P6's attachment moves initials only where the attached run is READMITTED by the unjoined mark — a family that is all particles — so it reaches one corpus name in each order and the same one. RECOMPUTE, and the COMPARATOR is the half that has to be stated, because the obvious phrasing of it measures nothing: parse the `tools/differential/corpus*.jsonl` files deduped under each of the six policies and compare `initials()` against THE PRE-CHANGE RENDERING — the same contributing tokens left in WRITTEN order. What this sentence said until 2026-08-30 was "against a folded-first partition of `tokens_for(role)`", which is what `initials()` now IS: that recipe compares the shipped implementation with itself and yields 0 differing parses, the inert-measurement shape (mechanisms.md's Verification shapes section) written into the very sentence meant to prevent it, and the only recompute path the repository offered for docs/release_log.rst's counts as well. CONTRIBUTING is the other half of the recipe and it comes FIRST: apply this view's own skip filter — outside the given group a token tagged `particle` or `conjunction` contributes nothing unless it also carries UNJOINED_TAG — and compare the two orders only over what survives it. Leave the filter out and every skipped word reads as a difference instead: 1546 rather than 660 against written order, and 1018 rather than 0 against the folded partition. Re-measured this way on 2026-08-30 and reproducing the counts above: 660 of 6564 parses, 1 / 71 at the default order, 1 / 294 and 1 / 292 under the two family-first orders. THE VERIFICATION LIMIT, and it is decisions/R4's, restated because a plausible reading of "one name moves at the default policy" is that the ledger owes it a rule. It does not, and could not: `tools/differential/compare.py` compares the seven role fields plus `_ambiguities`, and `initials()` is not among them — the same blindness recorded for `capitalized()` under R4. Measured rather than reasoned: the gate at 1.4.0 / 2.0.0 / 2.1.0 on the pre-change tree and on the changed one gave byte-identical outputs over 1094 corpus names (229 / 194 / 102 intentional diffs, `unexplained: 0`). NO LEDGER RULE WAS ADDED, and none was needed. What stands in its place is `rules/R3`'s example line and `tests/v2/test_render.py`, both mutation-checked. Amended 2026-09-02 (#484): the blindness this paragraph rests on is retired — the gate compares `initials()` for roles-identical names now, so an initials-only change DOES owe a ledger rule. #408 still owes none, and for a different reason than the one recorded here: its movement is 1.4.0 parity, and the one corpus name it moves at the default order (`der, y van`) diffs on ROLES at all three baselines, under `fix(#379)`, so the pseudo-field never enters. The semantics are in `decisions/differential-ledger, the initials view`. WHY R3 GAINED A SENTENCE rather than leaning on R1. R1 already says folded family words "render before the rest of the family wherever they stood in the string" — but its subject is "Every FIELD", and `initials()` is not a field: R3 calls it "this view", and it takes a format spec and two separators rather than being read as an attribute. So R1 does not reach it by its own words, and #408 is precisely what leaving that to inference costs. R3's new clause states the order in R3's own voice, carries the O3 example line (`"Hassan, Mohamad Ahmad Ali"` under `middle_as_family` → `initials="M. A. A. H."`, reusing R1's own input so `corpus_rules.jsonl` is unchanged at 241 names), and R1 and R3 now name each other in `interacts:` — the field says which order, the view says it follows the field. P6's half gets no example line, for the same reason R1 carries none: the only input that witnesses it is `der, y van`, whose PARSE is itself contested, so pinning its initials normatively would pin a value another open question can move. It is pinned in the unit test instead, which is where today's-behavior pins belong. THE PARTITION IS PER ROLE, not scoped to FAMILY, and this is a deliberate choice rather than a copied line. Both producers re-role to FAMILY, so FAMILY is the only role a parse can put the tag on today and the GIVEN and MIDDLE arms are unreachable. They are there because `_text_for` partitions for every role it renders and the two views must not diverge again — the same uniformity `_post_rules`' own UNJOINED_TAG loop takes for its three roles, "for uniformity with the rule, not because all three are observable". A producer that ever folded into another part would otherwise reopen #408 there with nothing to fail. `test_initials_folds_in_every_role_it_renders` pins it from a hand-built name, since no input string can. Mutation-checked four ways — scoping the partition to FAMILY, and skipping it for GIVEN, for MIDDLE, for FAMILY — and each fails that test. It took THREE drafts of the fixture to make that true, and the two misses are the same miss one role apart. The first carried two GIVEN tokens and ONE MIDDLE: a one-element partition is the identity, so the MIDDLE arm was asserted and unpinned, and skipping the partition for MIDDLE passed the entire suite. The second fixed MIDDLE and carried NO FAMILY token at all, in a test named for every role it renders — so skipping the partition for FAMILY passed this test, and was caught only by its siblings and by R3's example line, neither of which is about per-role application. A zero-element group is the identity too, and reads even less like a gap than a one-element one, which is why the second miss survived a review that had just named the first. Two tokens per role is what closes it, and the general form is carried as mechanisms.md#TWO-ELEMENT-GROUPS rather than left here: a test written to pin a partition, a sort or a dedup needs at least two elements in every group it claims to cover, or the claim rides on a no-op. - 2026-09-01 #462 (its shape test superseded by 2026-09-13 #528) — DONE, the facade's twin: `HumanName.initials()` dropped a dotted or bare-capital `E`/`Y` from the middle and family groups because `_process_initial` tested conjunction membership alone, where 1.4.0's `is_conjunction` was "in the set AND NOT `is_an_initial`". `Scott E. Werner` gave `S. W.` from 2.0.0 through 2.2.0 and gives `S. E. W.` again; `John E Smith` (bare capital) and `Juan Y. Garcia` likewise; `parse().initials()` never had the bug, since `_classify` tags `E.` an initial and the render reads the tag (mechanisms.md#RENDER-HONORS-THE-PARSE). The issue said the bare form was "still correctly dropped", which is true of lowercase `e` and false of bare capital `E` — 7 of the 14 corpus names that move are bare capitals. Restored with `_render._INITIAL`, v1's own `initial` shape, because the facade may import `_render` and not `_pipeline`; scoped to `_process_initial`, its only caller, so a future reader of `_is_conjunction` does not inherit a decision made for initials. Found by #484's pseudo-field at the 1.4.0 baseline, where it had sat as 14 unreported diffs; the gate could not see it before because the seven fields never moved. - 2026-09-13 #528 (its `juan y garcia` accepted-cost witness and the `JUAN Y GARCIA` "J. G." reading superseded by 2026-09-20 #461) — DONE: `HumanName.initials()` reads the parse's tags. The facade's initials view was the last derived view still re-deciding the CONNECTIVE question after the parse had answered it — scoped to that question deliberately, since the PARTICLE question is still re-decided from the lexicon in two places by decision, `_cap_word`'s particle conjunct (`decisions/R4`'s NOT DONE bullet) and `_is_particle` here, R4 drawing that boundary per question rather than per view. #462's fix above is why the connective half was still being re-decided: restoring v1's `is_conjunction` ("in the set AND NOT `is_an_initial`") restored a SHAPE test standing in for a tag, which agreed with the parse only while the parser agreed with the shape. #383/#479 ended that the same day it landed — rules/P8 now reads a one-case single-letter connective from vocabulary rather than from case, and no shape over the raw word can see it — so the two views of one parse disagreed: `HumanName("john e smith").initials()` gave "j. s." against the core's "j. e. s.", and `HumanName("JUAN Y GARCIA").initials()` gave "J. Y. G." against the core's "J. G.". Both give the core's answer now. This is mechanisms.md#RENDER-HONORS-THE-PARSE applied to the last view that had not taken it, and #458's principle reaching the facade layer. THE SHAPE IS R4's, not a new one. A word backed by a token asks `"conjunction" in tok.tags`; a token carrying `UNCLASSIFIED_TAG` — text spliced in by `hn.middle = ...` through `ParsedName.replace()`, or restored by the v1 pickle path in `__setstate__` — was read by no parse, so the vocabulary answers, through `_render._reads_as_conjunction`, the same helper `_cap_word` calls for the same tokens. One question and not two: whether a word is a connective is a fact the word can answer alone, while whether a particle is acting as a particle is a fact about the whole part, which is R4's own boundary and is why `_is_particle` stays a live vocabulary lookup here. `_is_conjunction` lost its only caller and is gone, which retires the last sentence of the 2026-09-01 entry above — there is no future reader of it to inherit a decision made for initials. A STATED PRIVATE-METHOD BREAK, accepted rather than worked around. `_process_initial` keeps v1's `(name_part, firstname=False)` signature for direct callers and gains an optional `tokens=`, and `_initials_lists` always passes it — so a v1-shaped subclass that overrides `_process_initial` with the two-argument signature now raises `TypeError` the first time `initials()` runs. The alternative considered was a string wrapper kept over a token core, which would leave such an override silently INEFFECTIVE instead: it would be called, and its answer discarded by the token path beside it. A loud break on a private hook beats a quiet one, and it is stated in the method's own comment and pinned by a test. FORWARDING IS STILL THE ONLY WAY TO RECEIVE THE FIX: an override must ACCEPT `tokens` **and forward it**, `def _process_initial(self, name_part, firstname=False, tokens=None): return super()._process_initial(name_part, firstname, tokens=tokens)`, which gives `Forwards("john e smith").initials()` the fix's own answer, "j. e. s.". Widening the signature alone does not receive the fix either — `**kwargs`, or an explicit `tokens=None` that the super() call drops. Revised 2026-09-14, before this paragraph ever reached master: the token path now passes the group's own text as `name_part` (`" ".join(tok.text for tok in group)`) rather than an empty placeholder, precisely so an override that ignores the `tokens` keyword falls onto the STRING path and behaves as it did before this upgrade, rather than going quietly blank. Measured 2026-09-14: `WidensOnly("john e smith").initials()` is "j. s.", the pre-#528 answer, where the forwarding override above and the library itself both give "j. e. s." — the STRING path's vocabulary fallback reads the middle "e" as the connective and drops it, where the token path's parse-backed reading (restored by #528) does not. So "accept a `tokens` keyword" is still the wrong instruction on its own; the forwarding is what receives the fix, not merely what avoids a `TypeError`. This is the only break in #528; on the public v1 surface nothing moves but the initials of six corpus names: `john e smith`, `john e jones`, `jones, john e`, `jose e maria santos`, `JUAN GARCIA Y LOPEZ` and `JUAN Y GARCIA`, the six the ONE CORPUS NAME paragraph below counts. A SECOND STATED BREAK, FOUND AND FIXED IN REVIEW (2026-09-14): before #528, `_initials_lists` read `self.first_list`/`self.middle_list`/`self.last_list` — public properties a v1 subclass may override — and the move to `_list_tokens_for(member)` above dropped that: an overridden property was silently ignored by `initials()` alone, while `last_base` (via `_split_last`, which reads `self.last_list`), `surnames` (`self.middle_list + self.last_list`) and `given_names` (`self.first_list + self.middle_list`, which never reaches `last_list` at all) kept honoring it. Fixed by checking, per member, whether the class overrides that property (`getattr(type(self), f"{member}_list") is not getattr(HumanName, f"{member}_list")` — three class-attribute identity checks, nothing on the parse path) and, where it is overridden, taking the pre-#528 STRING path over the override's own strings instead of the token walk. Measured 2026-09-14 against `git show 338daf7:nameparser/_facade.py` (before #528): a three-property `Sub` (`first_list` uppercasing every element of `super().first_list`; `middle_list` dropping every element of `super().middle_list` that starts with `"X"`; `last_list` always returning `["Zorro"]`) on `"john Xavier smith"` gives "J. Z." both there and here — reading only the `last_list` override into the recipe predicts "j. X. Z." instead (measured 2026-09-14 with `last_list` alone overridden on this tree), since the upper-cased first and the filtered-out `Xavier` both feed the result too — where the unfixed tree gave "j. X. s."; a one-property `Sub` (only `last_list` overridden, same `["Zorro"]`) on `"john e smith"` gives "j. e. Z." — the un-overridden first/middle keep the (post-#528) token path's own answer, and only the overridden last takes the pre-#528 fallback. Honoring the override, rather than leaving it silently ignored, is why "This is the only break in #528" above still holds: a second silent break on a PUBLIC surface would have gone unnoticed by every existing test, unlike the private-method break above, which is loud by design. Pinned at `tests/v2/test_facade.py::test_initials_honor_an_overridden_list_property`. ACCEPTED COST, MEASURED (2026-09-13, this branch): for a word backed by a token the connective answer is fixed at parse time, as `capitalize()`'s already was. `C = Constants(); h = HumanName("juan y garcia", constants=C)` gives "j. g."; `C.conjunctions.remove("y")` leaves it "j. g." with no re-parse, where before #528 it gave "j. y. g." immediately; `h.full_name = "juan y garcia"` re-parses and gives "j. y. g.". Unpinned before this change and pinned by it, at `tests/v2/test_facade.py::test_initials_freeze_the_connective_answer_at_parse_time`. ACCEPTED COST, THE SECOND ONE, and it is the no-parse paths rather than the configuration, THREE of them, not one: state restoration (pickle, `copy.copy`, `copy.deepcopy`) and the keyword constructor all skip the tokenizer, so every word they carry takes the vocabulary fallback instead of a parse's tag. `__setstate__` stamps every restored token `UNCLASSIFIED_TAG`, because a v1 pickle carries the `*_list` STRINGS and no tags, so a restored name is spliced text throughout; `copy.copy` and `copy.deepcopy` use the same `__getstate__`/`__setstate__` state hooks, so a copy takes the identical fallback (measured 2026-09-14). The THIRD producer, unrecorded until this review round: `HumanName(first=..., middle=..., last=...)` routes straight through the field-setter properties in `__init__` and never calls `_apply_full_name`, so its tokens are `UNCLASSIFIED_TAG` for the identical reason a restored pickle's are — not a new bug, a third route to a cost already accepted. `HumanName(first="john", middle="e", last="smith").initials()` gives "j. s." where `HumanName("john e smith").initials()` gives "j. e. s."; `HumanName(first="JUAN", middle="Y", last="GARCIA").initials()` gives "J. Y. G." where `HumanName("JUAN Y GARCIA").initials()` gives "J. G." (measured 2026-09-14). A round-tripped, copied or deep-copied `JUAN Y GARCIA` therefore gives "J. Y. G." where the live parse gives "J. G.", and a round-tripped, copied or deep-copied `john e smith` gives "j. s." where the live parse gives "j. e. s." (the pickle round trip measured 2026-09-13, copy and deepcopy measured 2026-09-14, all three giving the identical two values). `capitalize()` has disagreed with the live parse on exactly those two names for exactly that reason since the tag was introduced — "Juan Y Garcia" against "Juan y Garcia", "John e Smith" against "John E Smith", all four measured the same day — and the keyword constructor gives the identical disagreement, `HumanName(first="john", middle="e", last="smith").capitalize()` leaving "John e Smith" against the live parse's "John E Smith" on both this tree and 338daf7 (measured 2026-09-14) — so this is the existing shape reaching one more view rather than a new one, and the remedy for the state-restoration pair is the one `__setstate__`'s own comment gives: the restored state is a v1 blob, not a parse; the keyword constructor has no state to restore an argument from — the caller supplied fields, not a string, so there was never a tokenizer run to have tagged them. Over the deduped corpus glob's 1174 names rebuilt from their seven keyword-constructible fields, 1 differs from the live parse before #528 (`Ph. D., John`, below) and 7 after, the added six being this entry's six movers. Pinned at `tests/v2/test_facade.py::test_initials_of_an_unpickled_or_copied_name_ask_the_vocabulary_too`. THE POPULATION, MEASURED 2026-09-14 over the deduped `tools/differential/corpus*.jsonl` glob read in both line shapes (1174 names; a dict-only read stops at 528): exactly the six movers above give a different `initials()` after a pickle round trip, `copy.copy` or `copy.deepcopy` than the live parse, up from 0 before #528. ONE CORPUS NAME STILL DIVERGES and it is not this rule's. Measured over the deduped `tools/differential/corpus*.jsonl` glob (1174 distinct names) before and after: seven names had the two views disagreeing, six moved here, and `Ph. D., John` remains — the facade merges "Ph." and "D." into ONE list element (v1's `fix_phd`) and renders "P D" with the separator and no inner delimiter, giving "J. P D." against the core's "J. P. D.". That is `fix(initials-per-word) the Ph. D. merge`, a 2.0.0 rendering change ledgered at 1.4.0 since #484, and #528 preserves the element boundaries exactly so it neither moves nor is absorbed. RECOMPUTE by parsing every name of the glob on both surfaces and diffing `initials()`; the before half needs the pre-#528 `_facade.py` and `_render.py` on the path, which `git show` writes into a scratch copy of the package — never a checkout in a shared worktree. DIFFERENTIAL, re-run 2026-09-13 at 0 unexplained at all five baselines: at 1.4.0 four of the six took a new rule (`fix(#528) the facade's initials follow the parse's connective tags`) and two moved roles under #383/#479's rule; nothing new at 2.0.0 through 2.2.0; at 2.3.0 the existing rule stands. THE FACADE'S FALLBACK CAN SHOW WHAT THE CORE'S CANNOT, accepted rather than repaired. Recorded in mechanisms.md#RENDER-HONORS-THE-PARSE until #759 moved it here (2026-10-19), being a decision rather than a mechanism. The shape is a caller-added conjunction written initial-shaped in a script with no initials (`太.`, `씨.`): classify reads it through `is_initial()`, whose #320 repertoire veto the fallback's copy of `_INITIAL` does not carry, layering forbidding the import (test_regex_sync pins the two copies to each other and to config). Before #528 case repair was the fallback's only reader and could show nothing, `lower()` and `capitalize()` giving the same string over a caseless script; the facade's `initials()` is a second reader and can, since a word the fallback calls a connective contributes no initial while one it does not contributes a letter. Measured 2026-09-13 under `Constants()` with `conjunctions.add("太")`: `HumanName("Wang Chen 太. Li").initials()` is "W. C. L.", classify tagging `太.` a conjunction in the family, while splicing the identical family text in, `h.last = "Chen 太. Li"`, gives "W. C. 太. L."; `capitalized(force=True)` shows nothing on either. Accepted because the fallback is reached only for text no parse read, a spliced field being the caller's own text, and the remedy is the documented crossing, which differs by view: `Parser.revise()` for the parsed name (rules/R3's Accepted clause), and for the facade, which has no `revise`, assigning `full_name` again, which restores "W. C. L." (measured the same day). The core `ParsedName.initials()` cannot show it, being handed no vocabulary. - 2026-09-20 #461 — DONE, and the clause is settled for every group at once. THE RULE AS TAKEN: a connective contributes nothing where it is JOINING — a part holding another name word for it to join, the part's working particles set aside — and where its part holds nothing else it is joining nothing, so it initials like any other name word and agrees with the base. That picks ONE of the four candidate answers the 2026-08-29 bullet under `decisions/R2` listed for "John and Jane Smith" and makes the other three unreachable: `parse("John and Jane Smith").initials()` is "J. J. S.", not the "J. a. J. S." of 2.0 through 2.3, not P3's one-name-word "J. S.", and not 1.4.0's run-together "J a J. S.". THE CRITERION IS NEVER A WORD COUNT, which is what keeps `parse("Jon Dough and")` at "J. D." over base "Dough and" and `parse("Juan Velasquez y Garcia")` at "J. V. G." — a part of two words where one is the connective is still a part where it joins, and a part of three is no different. BOTH VIEWS, ONE MARK. The fact is about a PART, so it is decided once where the parts are settled and recorded on the token (`UNJOINED_CONJUNCTION_TAG`, mechanisms.md#MARK-DONT-STRIP), and the core view and the v1 facade both read it. That is the direct answer to what the first #461 attempt found: a mark honored for some of a part's words and not for one of them is honoring nothing. A SEPARATE marker rather than a widening of `UNJOINED_TAG`, because that one is also read by `family_particles`/`family_base` and by `_cap_word`, and none of those three may move. `Parser.revise` recomputes both marks, the same reason `_remarked` recomputes the first. BLAST RADIUS, MEASURED 2026-09-20 (re-measured at the end of the second review round) against the parent `46651750` (PR #536) over the deduped corpus glob plus `tests/v2/cases.py`'s texts, 1568 non-empty names, under eight configurations: at the default order 12 names move roles, 3 more reports, and 56 `initials()` (22 gaining a letter, 34 losing one; `HumanName.initials()` the same 56), family-first 11 / 3 / 34 and `FAMILY_FIRST_GIVEN_LAST` 12 / 3 / 64; restricted to the 1543 names at the parent, 2 / 3 / 46, 1 / 3 / 24 and 2 / 3 / 52. The gate's `_initials` sees only names whose roles stayed put, so a count from its classified summary is a FLOOR. TWO OF THE GAINS ARE 1.4.0 PARITY RESTORED, and that is what retires two ledger rules: `JUAN Y GARCIA` gives "J. Y. G." and `محمد و علي` gives "م. و. ع.", which is what 1.4.0 gave for both. The two ranges differ and an earlier draft of this bullet ran them together, so state them apart (measured on all five wheels 2026-09-20): `محمد و علي` gave "م. ع." at 2.0.0, 2.1.0, 2.2.0 AND 2.3.0, while `JUAN Y GARCIA` gave "J. G." at 2.0.0, 2.1.0 and 2.2.0 and went back to 1.4.0's "J. Y. G." at 2.3.0 — the reason being the one the 2026-09-13 #383/#479 entry under P8 records, and the 2.4.0 release note says the same. The readmission is a parity RESTORATION on those two names rather than a deviation, and the given-group half in the other direction is the deviation — see rules/R3's Accepted clause, which states both. THE FRAME BUDGET WENT DOWN: `tools/perf/call_count.py` reads `py3.11 parse=406.00 facade=443.00` against the parent's `412.00 / 449.00`, the two marks coming off ONE walk over the roles instead of two generator expressions; both land inside the `_CALL_BASELINE` bands, so no baseline edit and no `decisions/parse-cost` decision is owed. THE NO-PARSE PATHS KEEP THE OLDER READING, the same degradation the 2026-09-13 #528 bullet above accepted and for the same reason: a token the parse never saw carries no mark, so the view falls back to the vocabulary. Measured 2026-09-20, `HumanName("JUAN GARCIA Y LOPEZ").initials()` is "J. G. L." live and "J. G. Y. L." after `pickle`, `copy.copy` or `copy.deepcopy`; a name built from keyword fields carries no tags either, and so does one whose `*_list` property a subclass overrides. The remedy is the v1 one — assign `full_name` again — since the facade has no `revise`. HOW FAR IT REACHES, measured 2026-09-20 over the same 1,568 names against 46651750: the v1 `initials()` of a pickled, copied or deep-copied name differs from the live parse's on 34 names against 6 at the parent. #461's lone-connective half is what multiplies it: every part whose only word is the connective initials live and drops after the round trip, so `HumanName("Juan de y").initials()` is "J. y." live and "J." after `copy.deepcopy`. - 2026-10-02 #585 — DONE: an initial is the shortest start of its word that composes to the composed word's first character, written in the form the writer used. `initials()` on both surfaces had taken a token's first CODE POINT (`t.text[0]` in `_render.initials`, `word[0]` and `w[0]` in `_facade`), which for a name typed decomposed (NFD) is a base letter without the mark that completes it: `émile zola` initialled `e. z.` against `é. z.`, and — the reach the issue did not name, found by the corpus walk's control — decomposed katakana lost its dakuten, `マイケル ジャクソン` initialling `マ. シ.` against `マ. ジ.`. `_render._first_letter` is the one helper both surfaces call. One shape has no such start: a mark composing with nothing that canonical order puts between the letter and a mark that does (`o` + U+0331 + U+0301, whose composed spelling is `ó` + U+0331), where the initial is the composed character decomposed, `o` + U+0301 — found by the docs review, 2026-10-02. That fallback is for a start LONGER than one code point: a first code point that is a whole character Unicode excludes from composition (`क़`, U+0958, which NFC writes `क` + a nukta) is kept as written, its nukta with it, as before this change; the second review found the first draft of the fallback stripping it (`\u095bाकिर` initialling `ज.`; corrected 2026-10-22: the word was stored decomposed, U+091C U+093C, which any NFC editor writes for U+095B and which initials `ज.`, its composed spelling beginning with ज, so the escape spells the precomposed character meant). And the fallback writes the character wholly decomposed even where the writer composed part of it (`ẹ` + U+0331 + U+0302 gives `e` + U+0323 + U+0302) — the twin holds, the written form does not, accepted as contrived. WHY NOT "THE FIRST LETTER AND THE COMBINING MARKS AFTER IT", the shape #542 used for case repair and the issue's own option 1, measured before writing it: decomposed hangul jamo are LETTERS (category Lo), not marks, so it would initial a decomposed `김` as `ᄀ` where the composed name gives `김`; and a Bengali vowel sign IS a mark (Mc), so it would move a COMPOSED `কিরণ` from `ক.` to `কি.`, which is a question about Indic syllables nobody has decided, and a change the gate would see on every composed Indic name. Composing the prefix answers both: a composed word gives `text[0]` exactly as before, so no composed name's initials move. DECLINED: composing the initial to NFC (the issue's option 2, and what outside PR #586 did, on the core surface only). It fixes the dropped mark but hands back a composed initial for a decomposed name, where #542 settled that the render views keep the form they were given (decisions/R4, 2026-10-02). VERIFICATION, which the gate DOES see, unlike #542's: `_initials` is a compared pseudo-field (#484), and the two NFD example lines this change adds to rules/R3 diff at every baseline, classified by a `fix(#585)` `_initials` rule in each of the five ledgers. `tests/v2/test_properties.py::test_a_decomposed_name_renders_as_its_composed_twin` joins #542's walk rather than opening its own and records 44 disagreements (22 texts, each on both surfaces) over 139 texts with `text[0]` restored (measured 2026-10-02), with a live control beside it. - 2026-10-19 #761 (skeleton migration) — the rule text no longer carries its argument and history, which moved here or were already here. WHY THE ORDER CLAUSE IS STATED IN R3 and not left to R1 (R1's subject is every field, and `initials()` is a view, not a field) is the 2026-08-30 #408 entry's "WHY R3 GAINED A SENTENCE"; the rule keeps the clause itself, and that the field settles order and nothing else. THE FACADE VIEW'S MECHANISM — its connective question falling back through `_render._reads_as_conjunction`, the helper case repair calls, and its particle question being `_is_particle`'s live vocabulary lookup — is the 2026-09-13 #528 entry; the rule keeps what the view does (reads the parse's connective reading where a token backs the word, looks every word up for the particle question) and the two contrasts it states, `de la vega` spliced and `Ph. D., John`. THE PARITY-BREAK HISTORY moved: before #461 the given group's connective initial was pinned by tests and stated by no rule ("pinned-but-undocumented"), #461 specified it by the same sentence as the middle and family groups, and that one sentence is what reduced the four candidate answers the 2026-08-29 bullet under `decisions/R2` listed for "John and Jane Smith" to one — the 2026-09-20 #461 entry above holds the argument and its measurements; the rule keeps the parity break and restoration as its last Accepted bullet. Nothing about the behavior moved. - 2026-10-19 (Derek), #761 — A PARTICLE IS A PARTICLE BY POSITION. "Particles contribute nothing" read as contradicting `Nguyen, Van Le` → "V. L. N.", where the vocabulary's `van` initials. It does not: whether a word is a particle is settled by where it stands in the name, not by membership in the set, and `Van` there is a given name. A Then bullet now says so, carrying `Nguyen, Van Le` and `Anh Do`. No reading moved. derek73-python-nameparser-456b024/docs/design/decisions/R4-case-repair-reads-the-unjoined-mark.md000066400000000000000000003134661527303271700325200ustar00rootroot00000000000000# R4 — case repair reads the unjoined mark ## Standing - Case repair consults the unjoined mark for the whole part: a part whose every word is particle vocabulary repairs as ordinary name words, as R2 reads it (`ANH DO` → 'Anh Do', `anh van do` → 'Anh Van Do') — 2026-08-29 #407. - That is a deliberate deviation from 1.4.0, which lowercased on vocabulary alone, and its cost is accepted: a degenerate all-particle family capitalizes too (`juan van der` → 'Juan Van Der', 1.4.0 'Juan van der') — 2026-08-29, this is a DELIBERATE DEVIATION; 2026-08-29, the ACCEPTED COST. - `capitalized()` is a compared surface: the snapshot records both case views, `capitalized()` where it changes the parsed text and `capitalized(force=True)` where it changes what `capitalized()` gave, so a change that moves case lists every corpus name it moved; the rules/R4 and rules/R5 example lines and the unit and property tests still pin the behavior itself — 2026-10-22 #826. - The conjunction carve-out is not gated on the unjoined mark: a connective inside an all-particle part keeps its lowercase (under `Lexicon.default().add(particles={'y'})`, `anh y van` → 'Anh y Van'), a case no default-vocabulary example can witness — 2026-08-29, the CONJUNCTION CARVE-OUT. - A field spliced in with `replace()` carries no reading, so the all-particle clause does not reach it (`replace(family='de la')` → 'de la' forced, where a parse gives 'De La'); `Parser.revise()` is the supported crossing and repairs as the parse does. That is a documented boundary, not a defect — 2026-08-29, the REPLACE/REVISE BOUNDARY. - Case repair's conjunction test reads the parse's `conjunction` tag rather than re-deriving the word's class (`juan e-f smith` → 'Juan E-F Smith'); the particle test still keys on lexicon membership, by decision, so `replace()` and a parse part there — 2026-08-29 #458. - A token carrying `UNCLASSIFIED_TAG` (stamped by `replace()` and the facade's v1 pickle load only) falls back to the vocabulary for the per-word conjunction-or-initial question and never for the per-part particle question: `replace(family='velasquez y garcia')` → 'Velasquez y Garcia' — 2026-08-29 (#458 review). - `initials()` has no such fallback, since it is handed no lexicon and guessing the default one erases fields parsed under a custom one; `replace(family='de la vega').initials()` → 'j. d. l. v.' is accepted until #464 gives that view a crossing — 2026-08-29 (#458 review, then the review of PR #463). - A single-case word the parse put in the suffix role from the acronym vocabulary repairs to capitals unless a mask records its casing, asked before the Mac/Mc convention (`john smith mba` → 'MBA', `mcse` → 'MCSE'); the gate is the role, so a spliced suffix repairs alike and a name word sharing the spelling does not — 2026-09-13 #459. - The guard on the generation exception is the pair, the suffix role and the `vocab:suffix` tag: a connective read as the generation repairs as the generation (`Carod i` forced → 'Carod I'), while a connective the suffix field merely holds keeps its lowercase (`Smith, John, and` forced → 'John Smith and', a spliced `suffix='i'` → 'i'), and a lone middle connective stays lowercase (`Carod i Rovira`) — 2026-09-20 #461/#397. - The exceptions map holds case masks and nothing else, a value being its key's own letters and digits recased, laid over the word as the writer punctuated it wherever the word stands (`phd`, `ph.d.`, `PH.D.` → 'PhD', 'Ph.D.', 'Ph.D.'; `phd smith` → 'PhD Smith'); `md` → 'MD' and `phd` → 'PhD' are deliberate deviations from 1.4.0 through 2.3.0 — 2026-09-23 #459, DECIDED. - A value that does not spell its key's letters and digits is a `ValueError` at every `Lexicon` entry point, compared as alphanumerics, whose message offers a recased key that constructs; a mask the word's letters miscount against is declined and the next clause repairs — 2026-09-23 #459, THE CHECK. - A v1 `Constants` carrying such a value raises at its first parse, the one decided exception to the shim's never-raise rule, with a v1-spelled hint carried by a typed exception; a punctuated value such as 1.4.0's `M.D.` is a legal mask and loads — 2026-09-23 #459, A DECIDED EXCEPTION; 2026-09-24 #459/#492/#478 (branch review). - A mask's punctuation marks where its letters are joined and is never written into the word: a letter split off beside a full stop takes capitals only where the mask writes it inside a run of two or more (`p.h.d.` → 'P.H.D.', a caller's `h.c` mask keeps `h.c.`), and casing goes through the whole word where that keeps its length — 2026-09-23 #459 (review), A MASK'S PUNCTUATION. - A punctuated value is accepted silently; a warning for one and a shim translation of 1.4.0's dotted pairs were declined, both resting on the false premise that the punctuation is ignored — 2026-09-23 #459 (review), DECLINED. - Repair changes case and nothing else, compared under `casefold()`, with two inherited Unicode boundaries left as they are: `ß` uppercases to `SS` through the shape clauses and the mask path alike (`john smith ß.x.` → 'SS.X.'), and the dotless `ı` matches the roman shape (`john smith ıı` → 'II') — 2026-09-23 #459 (review), THE INVARIANT'S UNICODE BOUNDARY; 2026-09-24 #459/#492/#478 (branch review). - Mask values are stored NFC-composed, and the hyphen clause's initial test is the pinned constant `_render._DOTTED_INITIAL` — 2026-09-24 #459/#492/#478 (branch review). - A word the parse put in a name role repairs as a name word whatever vocabulary holds it, numerals included (`qc mp` → 'Qc MP', `iv smith` → 'Iv Smith', `Md Abdul Karim` forced → 'Md Abdul Karim'); keeping `ii`, `iii`, `iv` as masks was declined — 2026-09-23 #459 item 4. - Period insertion is not case repair's: a canonical dotted spelling (`md` → `M.D.`) would belong to a normalization view that may re-spell a word, an idea parked with no issue — 2026-09-23, PERIOD INSERTION IS OUT. - A suffix-roled word that is a roman numeral from `i` to `x` by shape repairs to capitals and keeps its period (`john smith vi` → 'VI', `iii.` → 'III.'); `xi` and a numeral after a family comma are parse limits, read as name words (`john smith xi` → 'John Smith Xi', `john smith, vi` → 'Vi John Smith') — 2026-09-23 #459, NUMERALS. - A suffix read by its dotted shape repairs as an acronym (`john smith x.y.z.` → 'X.Y.Z.'), the role gate keeping a surname a name word, which since #838 a dotted word shows only where the two readings write it differently (`Jack X.Y.Z.` forced → 'Jack X.Y.Z.' either way; `qc mp` → 'Qc MP'); an unlisted dotted credential in mixed case reads all-caps under force only (`John Smith B.Tech.` forced → 'B.TECH.') and is kept as written on the default path, a caller's mask recovering it — 2026-09-23 #459, A SUFFIX READ; 2026-09-24. - Inside a hyphenated token a connective part with a worded part on each side keeps its lowercase (`jose ortega-y-gasset` and `JOSE ORTEGA-Y-GASSET` → 'Ortega-y-Gasset'), an edge part and a single letter marked with a period repairing as name text (`juan y-garcia` → 'Y-Garcia', `j.-e.-p. dupont` → 'J.-E.-P. Dupont'); `Te Awanui-a-Rangi` is out of reach, `a` being no connective — 2026-09-23 #478. - The hyphen clause keeps that reading in a name written wholly in one case, against P8's spaced reading, at an accepted cost (`J-E-P DUPONT` → 'J-e-P Dupont'), and P8's `conjunctions_ambiguous` does not reach a hyphenated word — 2026-09-24 #478. - The shipped map carries a mask for each listed acronym conventionally written in mixed case (`john smith psyd` → 'PsyD'), and none for one borne as a name, a mask applying in every position (`meng`, `edd`, `lac`, `ded`, refused); `sorted(Lexicon.default().capitalization_exceptions)` recomputes the list — 2026-09-24 #459. - A v1 `Constants` entry v1 could never match, or that `Lexicon` folds to empty, is dropped from every set field and exceptions key with one warning whose remedy runs, 1.4.0's two shipped `TITLES` typos silently; a capitalized or edge-period key keeps matching, an accepted widening — 2026-10-02 #541; 2026-10-02 #582. - The two cross-set computations `Lexicon` re-checks compare folded spellings, so an edge CJK full stop reads as its folded spelling; folding every field was declined — 2026-10-02 #582. - A decomposed word repairs as its composed twin and keeps the encoding it was typed in (`josé garcía` typed NFD → 'José García'): a combining mark belongs to the letter before it, and the shape clauses ask their regex of the composed spelling; composing the output was declined — 2026-10-02 #542. - A mask outranks the particle lowercase, in the shipped map (`SEÁN Ó MURCHÚ` → 'Seán Ó Murchú', with `ní` and `ua`) and in a caller's (`van` → 'Van' gives 'Ludwig Van Beethoven'); `mac` and `binti` carry none and stay lowercase — 2026-10-04 #604. - A listed acronym credential joined by a hyphen or slash repairs whole in capitals in the suffix role (`john smith, otr/l` → 'OTR/L', `john smith, fnp-c` → 'FNP-C', which was 'Fnp-C' before), and a recorded casing for the whole token wins in any role — 2026-10-21 #808. - A word's full stops divide it into runs written as words of their own (`j.r.r. tolkien` → 'J.R.R. Tolkien', `st.john smith` → 'St.John Smith', forced `J.Smith` → 'J.Smith'), and a word opening with a caseless modifier letter takes its capital on the letter after it (`AHMAD IBN-ʿALĪ` → 'Ahmad ibn-ʿAlī', `ʻIOLANI` → 'ʻIolani'), a digit not being passed over (`3rd`); the cost, an unlisted abbreviation whose ending follows its full stop reading as two words (the Sardinian `DUT.RA` → 'Dut.Ra', where 1.4.0 through 2.5.0 gave 'Dut.ra'; and an abbreviated second word reads as its spaced spelling does, `dr.philol.` → 'Dr.Philol.', where they gave 'Dr.philol.'), is accepted, a listed casing being the remedy, which the Italian abbreviated salutations carry (`SIG.RA` → 'Sig.ra', `dott.ssa` → 'Dott.ssa') — 2026-10-22 #838; 2026-10-22 #840. Not standing: the rewording of rules/R4's vocabulary promise and its `Juan McDonald` row, carried out — 2026-08-29, DEBT DISCHARGED; the correction of mechanisms.md#RENDER-HONORS-THE-PARSE, carried out — 2026-09-23, mechanisms; the whitespace raises the branch review left for the orchestrator to file, filed and fixed as #541 — 2026-09-24 #459/#492/#478 (branch review); the measurements, which decide nothing — 2026-09-23 #459/#478. Elsewhere: a suffix written in more than one case kept as written on the default path, and the gate reading every token but the suffixes — decisions/R5's 2026-09-24 #492 entry and its 2026-09-23 #492 — DECIDED entry; the whole token asked the particle question first, so `IVOI, PAUL D'` → 'Paul d' Ivoi' and `jacobus henricus van 't hoff` → 'Jacobus Henricus van 't Hoff', and a caller's mask on a stem not reaching its marked spelling where the particle arm takes the whole token (`al` → `AL` leaves the attached `al-` of `Rashid, Harun al-` lowercase, while an all-particle family or a given name still gives `AL-`) — decisions/P6's #700 entry; a word classify tags with the caps shape kept in capitals (`John Smith, XYZ` forced → 'XYZ') — decisions/S9; a bound caps word carrying the name's case contrast left as written — decisions/C1's 2026-10-06 #613, the PR review; masks recorded per word as the `cased` property of `nameparser/vocab/` — decisions/vocabulary-model's 2026-10-18 #737 entry; the snapshot recording `initials()` but not `capitalized()` — decisions/differential-ledger's 2026-10-13 #664 — a committed snapshot replaces the classified gate entry; the mixed-case masks of the credentials #797 added (`mbchb` → 'MBChB') — decisions/suffix-acronym-collisions' 2026-10-21 (#797) entry; `knt` → 'Knt' — decisions/salutation-titles' 2026-10-04 #606 line; the facade's initials reading the parse's tags, which closed #462 — decisions/R3's 2026-09-01 #462 and 2026-09-13 #528 entries. Open: [#464](https://github.com/derek73/python-nameparser/issues/464) — a `Parser.initials` crossing, which would let `initials()` answer a spliced field under the parse's vocabulary. ## Log - 2026-08-29 #407 — DECIDED: case repair consults the mark #404 introduced, and consults it for the WHOLE PART rather than for a particle standing alone. Until now three views read it and the fourth did not, which is how `parse("ANH DO")` came to report family_base 'DO' and initials 'A. D.' while `capitalized()` returned 'Anh do' — the same word treated as an ordinary name word by three readers and as a particle by the fourth. The scope question is the only real choice here, and standing-alone loses on the first Vietnamese name that tests it: `anh van do` has family `van do`, two particle-vocabulary words and neither of them alone, so a rule keyed on aloneness would capitalize `Anh Do` and leave `Anh van do`, making the same surname read differently for having a given-name word in front of it. rules/R2 states the test as "Every word", not "standing alone", and this is the case that shows why it is stated that way. One predicate, four views. - 2026-08-29 — this is a DELIBERATE DEVIATION from 1.4.0, not a parity restoration, said plainly because the surrounding branch work is mostly restorations and a reader will otherwise assume this is one. Measured on the released 1.4.0 wheel: `HumanName('ANH DO').capitalize()` leaves `'Anh do'` and `HumanName('anh van do').capitalize()` leaves `'Anh van do'`; both are `'Anh Do'` and `'Anh Van Do'` on this branch. There was nothing to restore — v1 lowercased on vocabulary membership alone and had no notion of a particle with nothing to join, the mark being a 2.x invention. - 2026-08-29 — the ACCEPTED COST, agreed before the change rather than discovered after it: a degenerate family that is nothing but particles capitalizes too, so `juan van der` renders `Juan Van Der` where 1.4.0 gave `Juan van der`. This is the same trade rules/R2 already took for `family_base` — those words ARE the base, so they are name words, and a parser that called them name words for sorting and particles for display would be the incoherent option. Blast radius, measured 2026-08-29 by running both predicates in one process (PR #460): 4 of the 1090 pre-change corpus names move under plain `capitalized()` and 23 under `force=True`, the extra 19 being mixed-case inputs the gate holds back (rules/R5); 5 and 24 over 1094 with this change's own mover `anh van do`. - 2026-08-29 (its claims that no run at any baseline can see a change that moves only case, that nothing is owed for it, and its 2026-09-02 conclusion, superseded by #826 for every baseline from 2.0.0; at 1.4.0 they still hold) — THE VERIFICATION LIMIT, which matters more than the change. `tools/differential/compare.py` compares the seven role fields plus `_ambiguities` and nothing else, so `capitalized()` is not a compared field and NO ledger run at any baseline can see this deviation: the corpus counts and the three intentional-diff totals hold across it precisely because the harness is blind to it, and a green run is evidence about roles, not about case. mechanisms.md#FACADE-CONTRACT promises that v1-visible changes are "release-log-classified fixes, each verified in the differential ledger". The first half is kept — docs/release_log.rst carries the 2.2.0 bullet — and the SECOND HALF CANNOT BE, for this change or for any other that moves only case. Standing in its place: rules/R4's own example rows (`ANH DO`, `anh van do`, and the `juan de la vega` boundary), rules/R5's, and the v1-facade tests in tests/test_capitalization.py. The ones that pin the fix are mutation-checked against deletion of the tag consult; the `juan de la vega` control passes that mutation BY DESIGN, which is what makes it a control rather than a witness. Read the silence as a SCOPE and not as a gap: the harness compares parses, case repair is a render view, and nothing is owed on the tracker for it — the next bullet is why that is a decision rather than a deferral. Amended 2026-09-02 (#484): the premise moved and the conclusion did not. `compare.py` compares the seven role fields plus `_ambiguities` AND `_initials`, so "nothing else" is now one pseudo-field short and a render view is no longer invisible by construction — but `initials()` is the only one added, deliberately (`decisions/differential-ledger, the initials view`), so `capitalized()` is still not a compared field, and the next bullet's "the compared surfaces stay the seven roles plus `_ambiguities`" should be read as naming the surfaces case repair is absent from. Do NOT read #484 as the precedent that answers the next bullet: it MEETS that bullet's two objections rather than waiving them. `_initials` is a diff-of-diffs gated on the fields agreeing — precisely the instrument the next bullet declines for case — and the column did arrive pre-failed, by 140 entries at 1.4.0 as measured on this branch's base, all of them since classified. What earned it its place is a demonstrated ESCAPE: #408 moved initials for a whole minor with the gate green, and the 98 per-word names had been moving since 2.0.0, unseen and unclassified. So the next bullet's case for no `_capitalized` column stands until a case change escapes the same way, and that is the test a future proposal has to meet. - 2026-08-29 — SUPERSEDED by 2026-10-22 #826 — WHY CASE IS NOT A COMPARED SURFACE, settled here rather than left open, because "add a column" is the obvious answer to the bullet above and it is the wrong instrument. What the harness is FOR is parsing behavior — tools/differential/README.md says to run it "when touching parsing behavior, and before cutting a release" — and what earns it its two-process cost is that a role is EMERGENT: the eight stages fold over one name and each can move what the next sees, so the reach of a vocabulary or rule edit is not derivable by reading the diff and has to be measured over a corpus instead. Case repair is the opposite kind of thing. `_render._cap_word` is a pure function of `(word, role, tags, lexicon)` with four branches — the particle/conjunction lowercasing, the exceptions map, the Mac/Mc convention, and plain `capitalize` — sitting behind R5's single gate, so an edit to it is legible: you can see what you touched. The measurement agrees with the reasoning. Over the 1094 corpus names the 1.4.0 and 2.1.0 wheels ALREADY disagree on `capitalize(force=True)` for 139 of them (recompute by running `HumanName(n).capitalize(force=True)` under each wheel over the corpus files deduped, and diffing the two maps; the count was the same over the 1090 names the corpora carried before this change). Nearly every one is an echo of a parse diff the field comparison already classifies — a token that changed role capitalizes differently — so a column would re-report the ledger's own explained diffs in a vocabulary its rules cannot match, and would arrive pre-failed by 139 entries. Isolating the residue means comparing case only where the fields agree, a diff-of-diffs, to reach a class that a unit test reaches directly. So the compared surfaces stay the seven roles plus `_ambiguities`, by design and not by omission, and case is pinned where case belongs: the `rules/R4` and `#R5` example rows, and tests/test_capitalization.py. - 2026-08-29 — DEBT DISCHARGED, the obligation the last bullet of decisions/R5 left this commit; recorded here so the pointer resolves rather than dangling. The FALSEHOOD: R4 promised repair "vocabulary exceptions (McDonald) included" flatly, which `str(parse('Juan Mcdonald').capitalized())` — `'Juan Mcdonald'` — falsifies, the gate refusing before any vocabulary is consulted. The statement now scopes that promise to where repair acts at all and hands the where to R5. A SECOND error in the same eight words turned up while rewriting them, and is worth recording because it had survived unread for as long as the first: McDonald is not a vocabulary exception. The shipped exceptions are five entries — II, III, IV, M.D., Ph.D. (recompute with `Lexicon.default().capitalization_exceptions_map`) — and `Mc`/`Mac` is a separate convention applied to any name shaped like one, so the old parenthetical illustrated the promise with the one mechanism it was not about. The statement now names an exception the vocabulary actually records (Ph.D.) and the Mac/Mc convention separately. The AMBIGUITY: "an already-correct name comes back unchanged" is true read as idempotence and reads as false under bearer-correctness, and the repair was to say WHICH is meant — a name already written the way repair would write it comes back unchanged — plus one clause refusing the inference a reader might otherwise draw, that a spelling its bearer chose is not spared for having been chosen. That clause is deliberately a REFUSAL and not a carve-out: sparing deliberately single-cased names is behavior the parser does not have and that R5's rationale declines on the record, so `str(parse('bell hooks').capitalized())` is still `'Bell Hooks'`. R4's boundary moved too: the old `"Juan McDonald" → capitalized="Juan McDonald"` row was inert — it passes with R5's gate deleted — so it became a `capitalized_forced=` row, which asserts what repair APPLIES rather than what the gate keeps and fails when the Mac/Mc convention is removed (measured by neutering it in one process: `'Juan Mcdonald'`). The boundary is now `juan de la vega`, a working particle run R4's new clause deliberately does not reach. - 2026-08-29 (its R3 initials premise superseded by decisions/R3's 2026-09-20 #461) — the CONJUNCTION CARVE-OUT, stated in rules/R4 rather than left to a code comment, and the reason the comment gave was false. `_cap_word`'s conjunction conjunct is deliberately NOT gated on the unjoined mark, and the behavior is right: R3 already carries the carve-out for initials ("A CONJUNCTION never initials, so a base that is one contributes nothing even then"), and R4 was the half of the pair that carried it nowhere. What the comment claimed instead was an impossibility — `particles` and `conjunctions` are disjoint, so no conjunction token can ever carry the mark — and disjointness is a property of the SHIPPED vocabulary while both sets are public, configurable API. Measured: under `Lexicon.default().add(particles={'y'})`, `anh y van` parses to an all-particle family whose `y` carries `conjunction`, `particle` and the mark at once, and repairs to `Anh y Van`; gating the conjunct too, in one process, gives `Anh Y Van`. So the clause decides something and is not a no-op. It gets NO example row, and that is a limit of the document rather than a choice: the runner parses with the default vocabulary, over which `particles ∩ conjunctions` is empty in the default lexicon and in all four locale packs, so no input string can witness it. `der, y van` is the nearest thing and does not witness it either — the `y` there is a GIVEN word standing beside the all-particle family, not inside it (tests/test_capitalization.py says so at the row). - 2026-08-29 — the REPLACE/REVISE BOUNDARY, raised by the review of this branch as a gap needing a systematic fix and corrected by Derek to what it actually is: the documented boundary, with a supported path across it. `_cap_word` keys the particle TEST on lexicon membership but the REPAIR on the unjoined mark, and the mark is a parse product — so `parse('de la').capitalized(force=True)` is `'De La'` while `base.replace(family='de la').capitalized(force=True)` is `'de la'`, `replace()` splicing raw text into a field without classifying it. `Parser.revise()` is the method that classifies it, and its docstring already promised exactly this: each value gets a full sub-parse "so the stable tags survive and the tag-driven views ... behave as if the text had been parsed". Measured: `Parser().revise(base, family='de la').capitalized(force=True)` is `'De La'`. So there is nothing to fix — rules/R4 carries it as an Accepted boundary naming `revise()`, not as a deviation, and `revise()`'s enumeration of the tag-driven views gains `capitalized()`, which #407 made the fourth. (#458 would additionally make `replace()` agree, by keying the test on the `particle` tag rather than re-deriving membership from the word — the same shape it already names for `conjunction`/`initial`. That is a convenience, not the resolution.) It is a limit and not a regression either way: 2.1.0 gives `'de la'` on the spliced path too, so only the parsed path moved. - 2026-08-29 #458 — DONE: case repair's conjunction test reads the `conjunction` TAG instead of re-deriving the word's class at render time, so a view honors the decision the parse recorded (mechanisms.md#VOCAB-TAGS) rather than taking it again. The two copies of the question were not the same question, which is the reason this was worth doing over a corpus that cannot see it: `_classify.py` asks `is_initial(token.text)` — the shape test ANDed with a repertoire test since #320 — while `_render.py` asked a bare pattern, shape only, over each WORD of a token's text rather than the token. It does NOT retire the hand-sync obligation the module carried in a comment ("keep in sync with `nameparser/_pipeline/_vocab.py` by hand"), and an earlier draft of this bullet said it did: the tag read has nothing to keep in sync, but `_render._INITIAL` was deleted and then restored in the same PR, the fallback in the bullet below giving it a reader again. The obligation is narrower and sharper than before — the two copies no longer answer the same question about the same token, so what has to hold is that the fallback answers as `_vocab` would — and test_regex_sync is what holds it. MEASURED on the #458 branch (PR #463): 0 of the 1094 corpus names move under `capitalized()` or `capitalized(force=True)`, over 6564 rows (three spellings × the default lexicon and one adding particle `y`), and decisions/R5's force-versus-case counts hold. Movement EXISTS outside that population, and the issue's "no output moves" is a claim about the corpus only: the old predicate re-decided per word, so `juan e-f smith` capitalized to `Juan e-F Smith` — the Italian conjunction inside a hyphenated middle name — and now gives `Juan E-F Smith`, agreeing with the uppercase spelling it always gave. An earlier draft of this change moved untagged tokens the same way — a family `replace()` splices in as `de y` repairing to `de Y` — and that was a REGRESSION, caught in review before it shipped and fixed in the bullet below; read this bullet with that one. NOT DONE, and the parenthetical in the boundary bullet above should be read as still open: the PARTICLE conjunct still keys on lexicon membership, so `replace()` does not agree there. Making it read the `particle` tag is a second change with its own blast radius — it moves a boundary rules/R4 states in prose — and #458 as filed does not ask for it. - 2026-08-29 (#458 review) — THE FALLBACK FOR TEXT THE PARSE NEVER SAW, and why it is scoped to one of the two questions `_cap_word` asks. Reading the tag and nothing else regressed every ASSIGNED field holding a conjunction, because a value `replace()` splices in is never classified and so carries no tag to read. Measured on the released 1.4.0 and 2.1.0 wheels, all agreeing and all broken by the tag-only reading: `h.last = 'velasquez y garcia'` then `capitalize(force=True)` gives `John Velasquez y Garcia`, `h.middle = 'e.'` gives `John E. Smith`, `h.last = 'smith-y'` gives `John Smith-y`, `h.first = 'y'` gives `y Smith`. That is the canonical Spanish surname and the shape `y` is in the vocabulary FOR, so an Accepted clause was not available: it had to be fixed. RULE: a token carrying `UNCLASSIFIED_TAG` was never read, so there is no decision to honor and the view falls back to the vocabulary. Note what the discriminator is NOT, in two directions, the second of which took a second review round to find. It is not UNTAGGEDNESS: an ordinary parsed name word carries no tags either (`velasquez` parses to `tags=[]`), so that would have swept in the whole parsed corpus. And it is not the absent SPAN, which is what the first fix used. Span-less means SYNTHETIC, a wider set than unclassified: `Parser.revise()` builds span-less tokens too, from a full sub-parse whose tags it keeps deliberately, and its docstring promises the tag-driven views "behave as if the text had been parsed" — so keying the fallback on the span overrode exactly the tags `revise()` exists to preserve. Measured on the branch that did it: `revise(name, middle='e-f').capitalized(force=True)` gave `e-F` where `parse('john e-f smith')` gave `E-F`, and under `Parser(lexicon=Lexicon())` a revised family `de la vega` initialed `j. v.` where the parse initialed `j. d. l. v.`. So the mark is stamped by the two producers of genuinely unclassified text — `ParsedName.replace()`, and the facade's v1 pickle load, which rebuilds a name from `*_list` strings and no tags — and a HAND-BUILT span-less token is not marked, so it takes the tag path like everything else in the library. That is the tag-driven semantics every other view already has, and it is a change from the span reading in exactly one place: a hand-built span-less token whose text is CONJUNCTION vocabulary now capitalizes rather than lowercasing (measured: a hand-built family `velasquez y garcia` repairs to `Velasquez Y Garcia` untagged, `Velasquez y Garcia` with either the `conjunction` tag or the mark). The PARTICLE conjunct is unaffected either way — it keys on lexicon membership rather than on the mark, which is the open item this bullet's neighbours record — so a hand-built `de la Vega` is unchanged. SCOPED TO THE PER-WORD QUESTION, and this is the durable half. *Is this word a conjunction rather than an initial* is a property of the word and its spelling: an unread token has no ambiguity the parse would have resolved differently, so the vocabulary gives the answer the parser would have given, v1's initial carve-out included — which is why `_render._INITIAL` came back and why test_regex_sync's two-copy assertion matters MORE now, the fallback being right only while it answers as `_vocab` would. *Is this particle acting as a particle here* is a property of the whole PART, and answering it needs a reading on every word of the part, which is exactly what a spliced field lacks. `_cap_word` is handed the whole token's tags — it is not blind to the part because it walks one word, and an earlier draft of this bullet said it was — but with no tag on any word of the part there is nothing to re-derive the part answer FROM, so the fallback here is the word question alone and the particle half falls through to plain particle treatment. Measured cost of the wide reading IN CASE REPAIR, so nobody re-derives it: `replace(family='de la')` gives `de la` today and a parse gives `De La`; widening case repair's fallback would make them agree and would thereby REVERSE the replace/revise boundary recorded two bullets above, with `revise()` as its documented crossing. Do not widen it without reopening the decision. Verified after the fix: `replace(family='velasquez y garcia').capitalized(force=True)` is `Velasquez y Garcia`, `replace(family='de la').capitalized(force=True)` is `de la`, and the parsed and `revise()` paths are unchanged. - 2026-08-29 (#458 review, then the review of PR #463) — the SAME FALLBACK in `initials()`: WRITTEN, MEASURED AND DROPPED before merge. The defect it aimed at is real and older than #458 — `replace(family='velasquez y garcia').initials()` gives `j. v. y. g.` where the parse, the facade and 1.4.0 all give `j. v. g.` (the facade and 1.4.0 measured through the TWIN of that splice — `h = HumanName('juan smith')` then `h.last = 'velasquez y garcia'`, the assigned construction this bullet is about, keeping `first`), and `replace(family='de la vega')` gives `j. d. l. v.` for `j. v.` — and it is unfixed again. What killed the fix is the parameter list: `initials()` takes no lexicon, so the fallback had to reach for `Lexicon.default()` and GUESS. `capitalized(lexicon=...)` is handed the caller's vocabulary and so guesses nothing, which is why its fallback stays. Measured cost of the guess, and the reason a merely-imperfect fallback was not good enough: under `Parser(lexicon=Lexicon.default().add(particles={'y'}))`, `parse('Juan de y').initials()` is `J. d. y.` and `parse('Juan Perez').replace(family='de y').initials()` was `J. d. y.` before the fallback and `J.` after it — an entire field's initials gone, because the default vocabulary reads `de y` as particles-plus-conjunction where the caller's reads it as an all-particle part. Guessing the wrong vocabulary for a name parsed under a custom one is worse than answering from tags alone, and `initials()` cannot be told better. The missing crossing is the whole of it: `Parser` carries `capitalized`, `matches` and `revise` as its custom-lexicon crossings and no `initials`, so there is no supported way to hand this view the parse's vocabulary. #464 is filed for that; when it lands, the fallback becomes answerable rather than a guess and this decision is worth reopening. ACCEPTED until then, and stated so a reader does not re-derive it as a bug: `replace(family='de la vega').initials()` is `j. d. l. v.` where the facade and a parse both give `j. v.`. The reach is the v2 core only — `HumanName.initials_list()` computes from the field STRINGS with its own lexicon (`_facade._initials_lists`) and gives `['j', 'v', 'g']` at 1.4.0, 2.1.0 and here, under that same assignment. That is why the facade cannot be used as evidence that the core is fine. Do NOT read `_initials_lists` as a v1 parity REFERENCE, though, which is how an earlier wording of this bullet labelled it: #462 is open on a parity break in that exact path, and it is a break inside the facade rather than upstream of it. `HumanName('Scott E. Werner')` partitions to `first='Scott'`, `middle='E.'`, `last='Werner'` at 1.4.0, at 2.1.0 and here — byte-identical fields — and `initials()` on them is `S. E. W.` at 1.4.0 and `S. W.` at 2.1.0 and here, the facade reading the middle `E` as the conjunction; assigning those three fields by hand reproduces each version's answer, so no parse difference is involved. The parity label was therefore doing work its own measurement cannot support, and only the per-name measurements are load-bearing here. MEASURE THIS BULLET WITH THE ASSIGNED CONSTRUCTION, and name the construction beside each value: the review of PR #463 measured `HumanName('juan velasquez y garcia')` — the PARSED form — against sentences about the assigned one, and 'corrected' two true claims into false ones. The two constructions agree from 2.0 on and DISAGREE at 1.4.0, where the parsed form gives `['j', 'v g']` and `j. v g.`. The `last` string is byte-identical either way; `initials_list()` reads `last_list`, which the 1.4.0 parse leaves as `['velasquez y garcia']` — the conjunction run joined into one element — where assignment re-splits it to `['velasquez', 'y', 'garcia']`. So `computes from the field STRINGS` is exact for 2.x and true of the ASSIGNED form only at 1.4.0. Two things learned in the attempt are worth keeping though the code is gone. `_types._remarked` is not the bug and needs no change: it recomputes the mark from TAGS after every edit, which is right for a record of what the parse decided and simply silent about text nobody classified. And any future fallback must be scoped per ROLE, not per name — a role the parse classified whole is decided by its tags however the other roles were built — a distinction that passes every other test in the suite and needs a hand-built name to witness. - 2026-08-29 — WHY THE BOUNDARY WENT UNNOTICED UNTIL #407, which is where a future reader should look for it. For an ALL-PARTICLE part the other three tag-driven views give the same answer through `replace()` and `revise()` alike: measured over `de la`, `van der`, `do`, `de` and `van de la`, all five agree on `family_particles=''`, on a `family_base` holding the whole part, and on initials from every word. They converge because an UNTAGGED part and a MARKED all-particle part reach the same place by different routes — untagged, no word is recognized as a particle; marked, none is ACTING as one — and all three views only ask which words are particles. Case repair is the one view that asks a second question, since it must also decide whether to lowercase, so it is where the two routes first come apart. The mirror case confirms the reading: on a MIXED part the convergence is the other way round — `de la vega` and `van der berg` diverge in all three views between `replace()` and `revise()` (`replace()` reports particles `''` and base `'de la vega'` where `revise()` reports `'de la'` and `'vega'`) and AGREE on case repair, R4's all-particle clause not reaching them. So before #407 the distinction was invisible on exactly the shape the clause is about, and visible only on shapes the clause does not govern. - 2026-09-13 #459 (landed 2026-09-22, PR #521) (its five-entry map and its `BSC`/`MSC` cost superseded by the 2026-09-23 #459 masks entry) — DECIDED: a credential acronym the exceptions map does not carry is an initialism, so a single-case word the parse put in the SUFFIX role from `suffix_acronyms` repairs to its all-caps spelling rather than a title-cased one (the clause in `_render._cap_word`, after the exceptions-map lookup and before the Mac/Mc rule — the order is load-bearing, since `mcse` is in the acronym set and matches the Mac/Mc shape, and reads `MCSE` only because the acronym clause is asked first; rules/R4 states the precedence and pins it with a row). The scope is narrow on purpose. The exceptions map is consulted first, so its five entries — the spellings `str.capitalize()` gets wrong, `md` → M.D. and `phd` → Ph.D. because they are punctuated, and `ii`/`iii`/`iv` → II/III/IV because a numeral is written all-caps and they are `suffix_words` the acronym clause would never see — keep theirs and the clause never touches them. The repair is gated on the SUFFIX role, so a word that is in the acronym vocabulary but parsed as an ordinary name word (`anh van do` → `Anh Van Do`) still repairs as that name word -- the gate is the whole reason the fix is safe on surnames that share a spelling with a credential. Because it is the ROLE and not a parse tag, the gate also reaches a suffix spliced in as raw text: `parse("john smith").replace(suffix="mba").capitalized()` gives `MBA` like the parsed name does, which is the contrast R4's Accepted paragraph draws against the all-particle clause. The same PR removed `ph` from `suffix_acronyms`; see `Excluded (SUFFIX_ACRONYMS — ph ...)`, beside the esq block, for why. The wider design the issue proposed (letter masks, `md` leaving the map, the given-role half of `QC MP`) stays on the rescoped #459; this clause is the narrow part #459 already accepts, not a re-litigation of those. Reach, measured 2026-09-22 on the PR #521 branch merged with master at 23e52dc6, against the pre-change `_cap_word` swapped in one process, over the deduped corpus glob (1338 names): of 178 names with acronym vocabulary in a suffix token, 30 written in a single case, 22 move on the default path and 119 under `force=True`. The ratio is the stable claim: every single-case name with an acronym suffix the map does not carry moves, and nothing else does. Accepted costs, deferred to the rescoped #459 rather than relitigated here: the all-caps default reaches words conventionally written mixed-case -- `bsc`/`msc` read `BSC`/`MSC` under `force=True`, and `Dr. med. univ. Margit Popp, MSc` is a corpus name that reads `MSC` under `force=True` only, its mixed case holding it back on the default path under R5 -- which the letter-mask design #459 defers is meant to recover; `ii`/`iii`/`iv` are `suffix_words` rather than acronyms and need the exceptions map precisely because the clause would not see them there; and because the ambiguous five (`ba`, `do`, `ed`, `jd`, `ma`) are in `suffix_acronyms`, the clause moves WHICH parse triggers the repair rather than preventing it -- `john smith ed` → `John Smith ED`, `john smith ba` → `BA`, and `smith, ms.` → `MS.` on the default path. That is the same #342/#454-class cost #459 already accepts, and the alternative (reading classify's `vocab:suffix` tag) was measured and costs `jd` → `Jd`, because `jd` carries `vocab:suffix-ambiguous` and not `vocab:suffix`, so the role gate is the right instrument. - 2026-09-20 #461/#397 — THE GROUNDING MOVED, AND THEN A DEFECT THE GROUNDING EXPOSED. R4's sentence rested on R3 by name ("the carve-out R3 states for initials"), and R3's carve-out is conditional now while R4's is not, so the cross-reference is CUT and replaced by this rule's own reason: a connective that initials because it joins nothing is still not written the way a name is written. Measured 2026-09-20 (PR #536), plain and forced, over nine connective names: byte-identical to the parent, before and after the repair below. WHAT THE PLAIN CALL DOES, stated over a population rather than over nine names, because "capitalized() does not move" was drafted for this bullet and is false as written. Swept 2026-09-20 over the 1558 non-empty corpus-union-cases names under eight configurations against the parent commit, `capitalized()` moves on SIX distinct names and every one of them is a name whose ROLES moved under the join — it renders different fields rather than treating a word differently. The correct statement is therefore: where only `initials()` moves, `capitalized()` is byte-identical; where the roles move, it follows them. WHAT THE FORCED CALL DID, AND THE DEFECT THAT WAS FOUND WRITING THIS RECORD. The sweep above was run for the PLAIN claim and turned up a second answer: `capitalized(force=True)` moved on ten further names whose roles, initials and plain repair all stayed put, every one a lower-case `i` — `parse("Carod i")` forced gave "Carod i" where 1.4.0 and 2.3.0 gave "Carod I", and nine more names likewise. Nine of those were a DEFECT and are REPAIRED here; the remaining two are the rule. WHY IT WAS A DEFECT, and the line R4 now draws. In those names the parse read the letter as the GENERATION it also spells and roled the token SUFFIX — but classify had tagged it `conjunction`, and repair's conjunction conjunct read the tag without asking the role, so a generation was lower-cased as a connective. Derek's principle decides it: a connective with nothing to join is not acting as a connective. Repair now asks the ROLE the parse decided rather than the tag alone, and a suffix-roled token is repaired as the suffix it was read as. `parse("John Quincy Smith i")` forced gives "John Quincy Smith I" again on BOTH surfaces, the facade repairing through the same helper. WHAT REMAINS AND IS NOT A DEFECT: `Carod i Rovira` and `Josep i Rovira`, where the letter is a lone MIDDLE word the parse tagged `conjunction` — three-word names, so P3's carve-out leaves the letter a name word and nothing joined. Forced repair keeps it lower-case, which is R4's own rule and exactly the answer the same shape has always had for `y`: `Carod y Rovira` and `Josep y Rovira` give "Carod y Rovira" and "Josep y Rovira" too. Those two names are the whole of what #397 moves on this view. AND THEN THE REPAIR ITSELF WAS TOO WIDE, found by the second review round (2026-09-20). The guard shipped as the suffix ROLE alone, and the role says only where a word LANDED. A plain connective can land in the suffix field without being generational vocabulary at all — the third part of a comma form is read as the suffix run whatever its words are — so `parse("Smith, John, and").capitalized(force=True)` gave 'John Smith And' where 1.4.0, 2.0.0 through 2.3.0 and the parent 46651750 all give 'John Smith and', and the same for `, y`, `, e`, `, und`, `, of` and `Doe, Jane, and Jr.`. A field SPLICED in went the same way and against R4's own Accepted paragraph, which says a field nobody read asks the vocabulary: `HumanName(first="John", last="Smith", suffix="de y")` repaired forced to 'John Smith De, Y' against 'John Smith De, y' on both wheels. THE GUARD IS NOW THE PAIR — the suffix role AND the `vocab:suffix` tag — because the clause turns on the GENERATION and the role alone cannot see it; classify's tag is the vocabulary half of the decision and reading both re-derives neither. Invisible to everything already watching: no name of the off-switch grid carries the shape (its oracle is a letter of the class, and none of these words is one) and no corpus name carries it either, which is why INV7b was written over a grid of its own. ACCEPTED, and the one reading this moves rather than restores: a field spliced to `suffix="i"` now repairs to 'John Smith i' where the parent gave 'John Smith I'. `i` is connective vocabulary on this branch and was not at the parent, so the spliced field follows `y` now — which is R4's Accepted paragraph working as written, the PARSED name being unaffected because it has a reading and the reading is the generation. MEASURED over the same eight configurations (PR #536): the forced-only set is ten names before the fix and two after; the invariant `tests/v2/test_properties.py::test_a_letter_that_did_not_join_repairs_as_the_off_switch_does` fails on 7,860 repairs at `e540d4c5` (6,843 core, 1,017 v1) and on 0 after, and on 8,054 with the generation conjunct removed (its docstring holds the live figure). The differential cannot see case (the 2026-08-29 bullets above), which is why this needed an invariant rather than a ledger rule. - 2026-09-23 #459 — DECIDED (Derek, 2026-09-23): the exceptions map holds case MASKS, and nothing else. A value is its key's own letters and digits, each in the case it takes, and case repair lays it over the word as the writer punctuated it, keeping every other character where the writer put it — so one `phd` entry repairs `phd` to `PhD`, `ph.d.` to `Ph.D.` and `PH.D.` to `Ph.D.`, and the map can hold a conventionally mixed-case acronym (`BSc`, `MSc`) without a dotted spelling per source. A replacement string is substitution, not capitalization: it is how `md` became `M.D.`, how `iii.` lost its period, and why the map never grew past the five entries it was imported with in 2011. The shipped map is `phd` → `PhD`, `bsc` → `BSc`, `msc` → `MSc`; `md`, `ii`, `iii` and `iv` left it, a suffix `md` now repairing by the acronym clause and a suffix numeral by the numeral clause (both below). Lookup is unchanged — the edge-stripped key, then the period-free one — and the map stays ROLE-FREE: an entry is the caller saying how a word is written wherever it stands (`phd smith` gives `PhD Smith`). rules/R4 now states the invariant every clause serves — repair changes case and nothing else — and `tests/v2/test_properties.py::test_case_repair_changes_case_and_nothing_else` holds it over every corpus name and case-table text, plain and forced, on both surfaces, under the shipped lexicon and one with the map emptied, comparing under `str.casefold()` (the Unicode-boundary bullet below says why that comparator). `md` → `MD` and `phd` → `PhD` are DELIBERATE DEVIATIONS from every release, 1.4.0 through 2.3.0, all of which gave the dotted forms (measured 2026-09-23 on all five wheels). There is no contributor intent behind the period insertion to preserve: the map, its `md` entry and `capitalize()` itself arrived together in the 2011 svn import (45a15394), authored by Derek. - 2026-09-23 #459 — THE CHECK, and two places it departs from the approved spec, both by measurement. A value that does not spell its key's letters and digits is a `ValueError` at `Lexicon` construction (`_lexicon._normpairs`). Both sides are folded through `_normalize` and compared as ALPHANUMERICS (`_lexicon._alnum`), so `('2nd', '2ND')` is accepted and `('2nd', '3ND')` raises — a comparison of letters alone would have let a digit key take any other digit. The message names the entry and offers the key at hand recased — `capitalization_exceptions=(('jr', 'JR'),)` for a `jr` key — a tuple of pairs, because the field is annotated with what it stores and a dict literal there would be an arg-type error in the pasted code; where `.upper()` changes the alphanumeric count (`straße` → `STRASSE`) it offers the key unchanged instead, an identity mask, so the offered code always constructs. Every entry point reaches it: construction, `dataclasses.replace()`, `|`, unpickling (`__setstate__` re-runs `__post_init__`), and the v1 shim's snapshot; `tests/v2/test_lexicon.py` sweeps all five. FIRST departure: the spec compared the value against the KEY as "already normalized, period-free and lowercase" — it is not period-free, `_normalize` stripping only EDGE full stops, so `Ph.D.` is stored `ph.d` and, compared raw, a key written with an interior period could never validate. SECOND: the spec's "the two letter sequences are always equal in length" is false. The key is NFC-composed and the word is not, so a word written in decomposed hangul (`씨` as two jamo) finds a one-letter key. The applier, `_render._apply_mask`, declines there (it returns None) and `_cap_word` falls through to its next clause — a caseless script has no case to recase, so nothing is lost — pinned by `tests/v2/test_render.py::test_the_mask_keeps_every_non_letter_and_declines_a_miscount`. Without the decline the naive applier raised `IndexError` inside `capitalized()` (measured in review on 2026-09-23, on the prototype and again by the spec review). - 2026-09-23 #459 — A DECIDED EXCEPTION to the shim's never-raise rule (AGENTS.md: "The shim TRANSLATES; it never raises on a config v1 accepted"). A v1 `Constants` whose `capitalization_exceptions` carries a value that does not spell its key — `{'jr': 'Junior'}` — raises the `ValueError` above at `Constants._snapshot()`, i.e. at the first parse. Taken rather than translated because there is nothing to translate TO: 1.4.0 substituted such a value for the word, 2.4's repair only recases, and so the only v1-faithful reading is the one this change removes. No such value was found in the repository, the docs, the issue tracker or any test when the design was approved; the hypothesis fuzzer drew arbitrary pairs and now draws recasings of its key. A value that merely carries PUNCTUATION is not one of these: it is a legal mask (the joins bullet below), so a v1 `Constants` unpickled from 1.4.0, still carrying `md` → `M.D.` and `phd` → `Ph.D.`, loads and parses — measured 2026-09-23, `john smith md` giving `John Smith MD` under it. Pinned by `tests/test_capitalization.py::test_a_mismatched_exception_value_raises_at_the_first_parse`; recorded in AGENTS.md's shim bullet and cross-referenced from #3-0-reevaluations. - 2026-09-23 #459 (review) — A MASK'S PUNCTUATION MARKS WHERE ITS LETTERS ARE JOINED, and is never written into the word. Letters written side by side in a value are one RUN, and any character between them that is not a letter — a full stop, a space, a digit — ends the run. That matters for exactly one case, the LONE INITIAL: a single letter the writer split off beside a full stop (a `FULL_STOPS` member, not the ASCII period alone, #322) is written in capitals whatever the mask says there, but only where the mask writes that letter inside a run of two or more — the writer split a chunk the mask keeps together, so each split piece is an initial. So `p.h.d.` under `PhD` gives `P.H.D.` (the mask is ONE three-letter run), `b.sc.` under `BSc` gives `B.Sc.`, `ph.d.` gives `Ph.D.`, and `d.phil.` under a caller's `DPhil` gives `D.Phil.`; while `h.c.` under a caller's own `h.c` mask, which spells each letter alone, stays `h.c.`, `a.bc` under `abc` gives `A.bc`, `ab.c` gives `ab.C`, `2b.` under `2b` stays `2b.`, and `x2b.c` under `x2bc` gives `x2B.C`. Everywhere else a value's punctuation changes nothing: `('md', 'M.D.')` and `('md', 'MD')` repair every word alike (measured on `md`, `m.d.`, `md smith` and `m.d`). The rule took three drafts, and the two wrong ones are the record worth keeping. The FIRST laid the mask letter by letter with no initial rule and REGRESSED what substitution had got right: `b.s.c.` repaired to `B.S.c.` and `p.h.d.` to `P.h.D.`, where the parent 4d0680e6 gave `B.S.C.` and `Ph.D.`. The SECOND capitalized every lone letter beside a stop, which overrode a caller's own mask — measured in review on 2026-09-23, `_apply_mask('h.c.', 'h.c')` gave `'H.C.'` and `dr. h.c. hans meier` forced gave `Dr. H.C. Hans Meier`, where the caller had written the case they wanted. The third reads the mask's runs, as above, and is rules/R4's `p.h.d.` line. CASING goes through the whole word — `word.lower()`/`word.upper()` — wherever both keep the word's length, so context-sensitive letters come out as the language writes them (`ΚΟΣ` under `Κος` gives `Κος`, the final sigma intact), and per character otherwise (`straße` under `STRAßE` gives `STRAßE`). KNOWN LIMIT: a mask letter reads as upper when it is not lower, so a titlecase letter (`Dž`, Unicode category Lt) reads as upper and `dž` under `Dž` gives `DŽ`; no per-character titlecase mapping is attempted. Pinned in tests/v2/test_render.py by `test_a_split_initial_is_capitalized_only_where_the_mask_keeps_it_joined`, `test_a_masks_punctuation_marks_its_joins_and_is_never_written` and `test_a_mask_cases_through_the_whole_word_for_context_sensitive_letters`. - 2026-09-23 #459 (review) — DECLINED, after being written: a construction-time `UserWarning` for a punctuated value, and a shim translation of the two pre-2.4 shipped pairs. Both were implemented during review and then dropped, because both rested on a premise the code falsified — that a value's punctuation is IGNORED. The warning fired on a value whose punctuation differed from its key's (`'M.D.'` for `md`, `'h.c'` for `hc`) and told the caller to write the value without it (`Write the value as 'hc'`); but `_render._letter_run_ge2` READS a mask's punctuation, so following the advice changed the output — `('hc', 'h.c')` keeps `dr. h.c. hans meier` at `h.c.` under force, where `('hc', 'hc')` gives `H.C.`. The translation rewrote `md` → `M.D.` and `phd` → `Ph.D.`, byte-equal, to their unpunctuated masks in `Constants._snapshot()`; it changed no output (those pairs repair every word alike, above) and existed only so a v1 `Constants` still carrying them — one unpickled from 1.4.0, for instance — would not trip the warning, so with the warning gone it had nothing left to do. A punctuated value is simply a legal mask, accepted silently at every entry point (`tests/v2/test_lexicon.py::test_a_value_may_carry_punctuation_that_marks_its_joins`). The lesson generalizes: "this input is silently ignored" is a MECHANISM claim about what the code reads, and it earns the same one-command check as any other before a diagnostic is built on it. - 2026-09-23 #459 (review) — THE INVARIANT'S UNICODE BOUNDARY, and why rules/R4 words it as it does. The approved spec stated the invariant as "no character is added, removed or reordered"; rules/R4 says "the repaired word is the word as written, recased", because two inherited cases break the stronger reading and neither is this change's to fix. Python's casing tables uppercase `ß` to `SS`, so a clause that uppercases can lengthen a word: `john smith ß.x.` reads a by-shape credential and repairs to `John Smith SS.X.`, casefold-equal and one character longer (the parent 4d0680e6 gave `Ss.x.`, longer too). And the roman shape matches under `re.I`, which admits the dotless `ı`, so `john smith ıı` reads suffix and repairs to `John Smith II` — not even casefold-equal to its input, `'ıı'.casefold()` being `ıı` (the parent gave `Iı`). The first comes from the casing tables and the second from the parser's own pattern; neither is fixed. `tests/v2/test_properties.py::test_case_repair_changes_case_and_nothing_else` compares under `str.casefold()` because of the first, and that comparator has a boundary on each side: it CANNOT see a repair that trades `ss` for `ß` or back, the two folding alike — a recorded blind spot — and it WOULD report the second, which it does not today only because no corpus name or case-table text spells a numeral with `ı`. A length check beside it was considered and declined: it would flag the `ß` boundary this bullet records as accepted. - 2026-09-23 #459 item 4 — DECIDED (Derek, 2026-09-23): a word the parse put in a NAME role repairs as a name word, whatever vocabulary it is in. `qc mp` gives `Qc MP`, `ed smith` gives `Ed Smith`, `john smith ed` gives `John Smith ED`: the parse decides the role and reports the fork (`suffix-or-name`), and repair follows the role (mechanisms.md#RENDER-HONORS-THE-PARSE). Closes the item with no code. What the decision also reaches, found while planning and NOT in the approved spec, which said `iv smith` would keep `IV Smith` "through the numeral rule": the numeral clause is suffix-gated — gating it is what keeps the given name `Vi` from reading `VI` — so the four words that left the map lose, in any role but the suffix, the role-free capitals the map gave them. Measured 2026-09-23: `iv smith` gives `Iv Smith` and `john iii smith` gives `John Iii Smith`, where every release from 1.4.0 through 2.3.0 gave `IV Smith` and `John III Smith`; `II Van Johnson` gives `Ii van Johnson` under force; and `md` read as a TITLE — the parse's reading of a leading `MD` — repairs as ordinary title text, so `MD DDS` gives `Md Dds` and `Md Abdul Karim`, the usual abbreviation of Mohammed and a shape-tagged corpus row, gives `Md Abdul Karim` under force where every release gave `M.D. Abdul Karim`. DECIDED (Derek, 2026-09-23, when the plan surfaced it): a numeral in a name role repairs as a name word, the same rule read to its end. DECLINED, the alternative put to him with it: keeping `ii`, `iii` and `iv` in the map as masks, which would have restored the three numerals in a name role while leaving a title `md` at `Md`. - 2026-09-23 — PERIOD INSERTION IS OUT of `capitalized()` for good, and PARKED rather than declined: if anyone ever asks for a canonical dotted spelling (`md` → `M.D.`), it belongs in a normalization view — Derek's working name for it is `repair()` — which may re-spell a word, not in a case repair, which may not. Not a commitment; no issue filed. - 2026-09-23 #459 — NUMERALS: a SUFFIX-roled word whose text, edge periods stripped, fullmatches the roman shape repairs to capitals. It reaches `i` through `x`, which is why `ii`/`iii`/`iv` left the map: `vi` through `ix` read as suffix by that shape, carry no vocabulary tag, and title-cased to `Vi`, `Vii`, `Viii`, `Ix` (measured on 4d0680e6, and `Vi`, `Vii` on every release wheel 1.4.0 through 2.3.0); and `john smith iii.` now keeps its period, `III.`, where every release gave `III`. The shape is `_vocab._ROMAN`, v1's `roman_numeral`, and `_render` holds a HAND COPY pinned against config by `tests/v2/test_regex_sync.py`: the approved spec said the clause would import the predicate, but tests/v2/test_layering.py's ALLOWED table gives `_render.py` `_types` and `_lexicon` and nothing else, and `_render._INITIAL` is the precedent for exactly this. The copy re-derives no parse decision — the role is the parse's, read as recorded; the pattern answers only how a numeral is written. THE PARSE LIMIT beside it, recorded and not fixed here: the roman shape stops at `x`, so `john smith xi` reads family `xi` and repairs to `John Smith Xi`, and after a family comma `john smith, vi` reads given `vi` and repairs to `Vi John Smith` — both rules/R4 boundary lines, and both for #459's close-out. The #397 generation guard is unchanged and still decides first: `Carod i` forced gives `Carod I`, the numeral clause now being what writes the capital. - 2026-09-23 #459 (its default-path `B.TECH.` superseded by 2026-09-24, decisions/R5's #492; its `Jack X.y.z.` superseded by 2026-10-22 #838) — A SUFFIX READ BY ITS DOTTED SHAPE is an acronym to repair: the acronym clause fires on a SUFFIX-roled token whose period-free text is listed OR which carries `SHAPE_ACRONYM_TAG` (#516's by-shape reading, rules/S3), so `john smith x.y.z.` repairs to `John Smith X.Y.Z.` where the parent gave `X.y.z.`. The constant moved from `_pipeline/_state.py` to `_types.py`, which `_render` may import, and `_state` imports it back — still one spelling, which is what the constant exists for. The role gate holds: `Jack X.Y.Z.` keeps its surname and repairs as a name word, `Jack X.y.z.` under force. ACCEPTED: an unlisted dotted credential written in mixed case reads all-caps wherever repair acts, since a by-shape member has no listed casing to honor — `John Smith B.Tech.` gives `John Smith B.TECH.` under force, where every release gave `B.tech.`, no better. Its reach onto the DEFAULT path through the suffix-free gate (decisions/R5, 2026-09-23), `john smith B.Tech.` giving `John Smith B.TECH.`, is superseded by the 2026-09-24 SUPERSEDED IN PART bullet below. A caller who writes such a credential adds a mask: `('btech', 'BTech')` gives `B.Tech.` on either spelling (measured 2026-09-23). - 2026-09-23 #478 — DECIDED: inside `_cap_text`, a part of a HYPHENATED token that is connective vocabulary keeps its lowercase when a WORDED part stands on each side of it within the token (split on `-`; a part counts only if it holds a word, and an empty part does not count and is skipped, so a trailing hyphen supplies no neighbour while a doubled hyphen changes nothing: `juan garcia--y-lopez` gives `Juan Garcia--y-Lopez`). An edge part repairs as ordinary name text. `jose ortega-y-gasset` and `JOSE ORTEGA-Y-GASSET` both give `Jose Ortega-y-Gasset`, and `maria silva-e-sousa` gives `Maria Silva-e-Sousa`. It reads POSITION and VOCABULARY, as the particle arm already does per sub-word (`maria da-silva` → `Maria da-Silva`, unchanged) — a question about a sub-word the parse never asked, the parse having read the hyphenated word as one untagged token, which is the same standing mechanisms.md#RENDER-HONORS-THE-PARSE records for the particle arm — and it does not re-derive the initial-versus-conjunction class from a word's case, which is what #458 removed. The edges are pinned on both sides: `juan e-f smith` keeps `Juan E-F Smith` and `juan y-garcia` gives `Juan Y-Garcia`, and in a three-part word `juan y-garcia-lopez` gives `Juan Y-Garcia-Lopez` and `juan garcia-lopez-y` gives `Juan Garcia-Lopez-Y`. A SINGLE LETTER MARKED WITH A PERIOD is an initial there, as the parse reads it in spaced text, never the connective (the guard is `not re.fullmatch(r"\w\.", part)`): the first draft lowered the `e.` in `j.-e.-p. dupont`, giving `J.-e.-P. Dupont` where every release from 1.4.0 through 2.3.0 gave `J.-E.-P. Dupont`, a regression found in review and pinned since as a rules/R4 boundary line. A MULTI-letter word marked with a period is not an initial and stays reachable, as its spaced reading is (`hans smith und. jones` tags `und.` a conjunction), so `hans smith-und.-jones` gives `Hans Smith-und.-Jones`. This reverses, for the interior position only, what #458's tag read did to the shape: 2.2.0 and 2.3.0 gave `Ortega-Y-Gasset` for both spellings — the side effect #478 reported, which the 2026-08-29 #458 bullet above does not record — and #458's edge answer stands. Parity, measured on the released wheels: 1.4.0, 2.0.0 and 2.1.0 re-decided per word and gave `Jose Ortega-y-Gasset` lowered but `Jose Ortega-Y-Gasset` upper, so the all-caps half is a parity break with them on #479's one-case precedent; they also gave `Juan y-Garcia` at the edge, which neither 2.2.0, 2.3.0 nor this tree does. KNOWN NON-REACH, with its witness: the Māori corpus name `Te Awanui-a-Rangi Black` still repairs to `Te Awanui-A-Rangi Black` under force, because `a` is not connective vocabulary and must not become one (a spaced `a` is an initial or a name elsewhere; the connectives' Excluded block in decisions/vocabulary-exclusions says so). ACCEPTED: `mary-e-smith` reads `Mary-e-Smith` — no evidence the shape exists, and the hyphen is the writer joining the parts. Every role, since a hyphenated given name is the same text shape; a middle-role row is pinned beside the family ones (`smith, jose ortega-y-gasset` forced gives `Jose Ortega-y-Gasset Smith`). - 2026-09-23 #459/#478 — MEASURED against the parent 4d0680e6 (PR #539), both trees imported by path over the same deduped corpus glob, every token's plain and forced repair on core and facade, each moved token attributed by its first changed sub-word: over 1375 names (re-measured 2026-09-24), a mask or a word that left the map 16 names default / 84 forced, the dotted shape and numerals 3 / 16, a hyphenated link 4 / 5 (every one a rules/R4 example line); over the parent's own 1340 names 11 / 78, 1 / 14 and 0 / 0. Core and facade move on the same number of names, and no role field moves. - 2026-09-23 — mechanisms.md#RENDER-HONORS-THE-PARSE CORRECTED, found in this change's re-read of that entry: it still said case repair carries the connective carve-out "ON R3's authority", quoting R4's pre-#461 text ("the carve-out R3 states for initials"), and that `.initials()` gives `j. a. j. s.` for `john and jane smith`. Both had been stale since #461 (2026-09-20), which cut R4's cross-reference to R3 in favor of R4's own reason and stopped a connective with a name word beside it from initialing. The entry now states R4's own reason, dates the cut, and names where the two views part today (`Juan de y`: initials `J. y.`, forced repair `Juan de y`), re-measured 2026-09-23. - 2026-09-24 #459/#492/#478 (branch review) ((a)'s splitting half superseded by #542; (d)'s raises on whitespace and empty entries superseded by #541 and #582) — FOUR FINDINGS FROM THE REVIEW ROUND, recorded together because each is a small correction to a bullet already above rather than a new decision. (a) THE UNICODE BOUNDARY IS NOT SCOPED TO THE TWO SHAPE CLAUSES, AND NOT TO ONE WAY A MASK LETTER COMES OUT UPPER EITHER. The 2026-09-23 bullet above states the `ß` and dotless-`ı` boundary against `_cap_word`'s acronym/numeral clauses, but the MASK path can lengthen a word through `ß` exactly the same way. `_apply_mask` decides per letter whether it is written upper -- by the mask's own case, or by the split-off-initial force -- and then takes one casing step: the letter at that position in the whole-word `word.upper()`/`word.lower()` when both keep the word's length, otherwise a per-character `c.upper()`/`c.lower()`. `'ß'.upper()` is `'SS'`, so wherever the word holds a `ß` the whole-word form changes the length and every letter goes through `c.upper()`, which lengthens a `ß` written upper to `SS` whichever of the two reasons made it upper. Through the force: an identity mask validates fine (`('a.ß', 'aß')` spells the key's own letters unchanged), so `john a.ß smith` forced gives middle `A.SS` -- casefold-equal to its input and one character longer, the same trade the shape clauses make. Through the mask's own case, no full stop or split letter involved: `('straße', 'STRAẞE')`, the value spelling `straße`'s own letters unchanged with the actual German capital `ẞ` (not the digraph `'STRASSE'`, which the validator refuses as a VALUE for failing to spell `ß` at all), validates and `john straße` forced gives family `STRASSE`. The validator's refusal of `('straße', 'STRASSE')` is a different question -- whether the VALUE spells the KEY -- and says nothing about what the applier can produce from a mask that does validate. This same boundary is where `capitalized()`'s own "Idempotent" docstring claim narrows: forcing `A.SS` (that first pass's own output) through the SAME lexicon a second time gives `A.ss` (`A.Ss` since #838, the fall-through writing each full-stop chunk as a word), not `A.SS` back, because the second pass folds the word to `a.ss` and the exceptions map's key is still `a.ß` -- a different letter sequence, not merely a different case of one -- so the lookup that found the entry on the first pass misses on the second and the word falls through to plain title-casing. Pinned by `tests/v2/test_render.py::test_a_masks_upper_fallback_can_lengthen_a_word_through_ss` and `tests/v2/test_render.py::test_a_lengthening_mask_is_not_a_fixpoint_under_a_second_forced_pass`. The same boundary reaches plain title-casing with no mask involved, by two distinct mechanisms: LENGTHENING, where a letter's own upper() is a different letter SEQUENCE that stays \w -- 'ʼn' (one letter) upper-cases to the two cased characters 'ʼN', so `parse("ʼna smith").capitalized(force=True)` gives given `ʼNa`, and forcing THAT output a second time gives `ʼna` ('N' is no longer the word's first character, so `str.capitalize()` lower-cases it on the second pass) -- and SPLITTING, through a letter whose upper() is a base letter plus a combining mark ('ǰ'), retired by #542 (the 2026-10-02 bullet below). (b) MASK VALUES ARE STORED NFC-COMPOSED. `_normpairs` compared a value against its key NFC-composed but stored the value AS WRITTEN, so a value written in decomposed form spelled one composed letter as a base letter plus a non-alpha combining mark -- changing which letters `_letter_run_ge2` reads as split off beside a full stop from what the composed spelling of the identical value would read. `john smith pé.x.` forced gave `Pé.X.` under a composed mask and `Pé.x.` under the decomposed spelling of the SAME value. Fixed by NFC-composing the value at the same point the key is composed; pinned by `tests/v2/test_lexicon.py::test_a_decomposed_value_is_compared_and_stored_nfc_composed` and `tests/v2/test_render.py::test_a_decomposed_mask_value_reads_the_same_split_as_composed`. (c) THE HYPHEN CLAUSE'S INITIAL TEST IS NOW A PINNED MODULE CONSTANT. `_cap_text`'s inline `re.fullmatch(r"\w\.", part)` was a third hand copy of the initial shape's period half, invisible to `tests/v2/test_regex_sync.py`'s hand-copy sweep because it was never hoisted to a module-level pattern. Hoisted to `_render._DOTTED_INITIAL` and registered as `_INITIAL`'s period alternative; pinned by `tests/v2/test_regex_sync.py::test_dotted_initial_is_the_period_alternative_of_initial`. (d) THE V1 RAISE NOW CARRIES A V1-SPELLED HINT, VIA A TYPED EXCEPTION. The 2026-09-23 bullet above records that a mismatched-mask value raises `ValueError` at a v1 `Constants`' first parse; the message it raised was `Lexicon`'s own, offering a `Lexicon(capitalization_exceptions=...)` constructor call to a caller looking at a `TupleManager` assignment through `Constants`. `_lexicon._normpairs` now raises a dedicated `_MaskValueError(ValueError)` for exactly that one shape, built in the `UnicodeDecodeError` idiom: `__init__(self, key, normalized_key, value)` stores the three offending inputs and computes `self.offered` (from `_offered_mask`) and the message from them, rather than taking a pre-built message. `__reduce__` returns `(type(self), (self.key, self.normalized_key, self.value), self.__dict__)` -- reconstructing from the three fields and then restoring `self.__dict__` (so an attribute added after construction, e.g. `add_note()`, survives too) -- since the inherited default (`type(self)(*self.args)`, `self.args` holding only the message) cannot reconstruct an instance that needs three fields, not one, and raises `TypeError` trying; a worker in a `ProcessPoolExecutor` pickles an exception it raises to deliver it to the caller, so an unpicklable one there surfaces as `BrokenProcessPool` instead. `_config_shim._build_snapshot` wraps ONLY the `Lexicon(...)` construction in `try`/`except _MaskValueError` and re-raises a message built fresh from `e.key`/`e.value`/`e.offered` -- not from `str(e)`, which is the 2.0 exception's own message offering the 2.0 `capitalization_exceptions=((...),)` constructor spelling no v1 caller can paste -- with `constants.capitalization_exceptions[e.key] = e.offered`. Every OTHER `ValueError` `Lexicon(...)` can raise passes through unchanged, by TYPE rather than by a message-substring test. Of those, the three SUBSET checks (`particles_ambiguous` inside `particles`, `suffix_acronyms_ambiguous` inside `suffix_acronyms`, `honorific_tails` inside `suffix_words`) cannot fire from here: each is built as an intersection or union with its OWN base, so the normalized image of that relation is still a subset whatever the raw spelling. THE CONTRADICTION CHECK AND THE GATE-BYPASS CHECK ARE A DIFFERENT SHAPE, AND DO REACH `Lexicon(...)` -- this bullet claimed otherwise ("cannot arise ... observing no raise") until the confirmation round measured the opposite. Both compare two sets built by SEPARATE set arithmetic in `_build_snapshot`, on the RAW SetManager entries, before Lexicon's own per-field normalization ever runs: `SetManager` strips edge periods but not edge whitespace, while `Lexicon`'s normalization strips both, so an entry carrying edge whitespace can slip past the shim's own subtraction or union and still collide once Lexicon normalizes it independently. Measured: `c.suffix_not_acronyms.add('ba ')` then a parse raises Lexicon's own gate-bypass check (`an ambiguous suffix acronym must not also be a suffix word; in both: ba`), and `c.bound_first_names.add("'t ")` then a parse raises the contradiction check -- both pass through the `except _MaskValueError` clause above unchanged, being a different exception type. Pre-existing at HEAD and at master alike, not introduced by this session and not fixed here -- left for the orchestrator to file as its own issue. What DOES reach `Lexicon(...)` here unchanged, always, is an entry normalizing to empty, in `capitalization_exceptions` or in ANY set field -- measured: `c.titles.add("...")` then a parse raises Lexicon's own `titles entry '' normalizes to empty`, unaltered -- since nothing above translates that away, and a non-str value's `TypeError`, a raise v1 also had, later, at `capitalize()`. Declined: a first draft pre-validated `capitalization_exceptions` in isolation, a second call to `_normpairs` ahead of `Lexicon(...)` gated on a message-substring test; measured to make a multi-word key's dead-entry warning fire twice on the shim path, and to rest on a filter that was untested (deleting it passed the whole suite) and wrong (an unrelated ValueError then got the v1 hint appended regardless). Pinned by `tests/test_capitalization.py::test_a_mismatched_exception_value_raises_at_the_first_parse` (the hint, and that it carries no 2.0 constructor spelling), `tests/v2/test_config_shim.py::test_an_unrelated_capitalization_exceptions_valueerror_has_no_v1_hint` (the pass-through), `tests/v2/test_config_shim.py::test_a_multiword_capitalization_exceptions_key_warns_exactly_once` (the single warning), and `tests/v2/test_lexicon.py::test_mask_value_error_survives_pickle_and_copy` (the pickle/copy round-trip). - 2026-09-24 #459 — DECIDED (Derek, 2026-09-24): the shipped map gains a mask for each listed acronym whose conventional spelling is mixed case, taken together with decisions/R5's keep-as-written bullet of the same day, which is the half that surfaced the need. The reviewer who measured the R5 regression named nine keys — `edd`, `dsc`, `psyd`, `meng`, `dmin`, `thd`, `drph`, `pharmd`, `mdiv` — and a sweep of `SUFFIX_ACRONYMS` for further entries whose usual spelling is unambiguously mixed found ten more: `bt`, `chfc`, `cpht`, `diplac`, `kt`, `phc`, `rph` and `thm`, and `lac` and `ded`. Of those nineteen, four are left out (below), so SHIPPED is fifteen — seven of the reviewer's nine and eight of the sweep's ten — every key a `suffix_acronyms` member: `bt` → `Bt` (Baronet), `chfc` → `ChFC` (Chartered Financial Consultant), `cpht` → `CPhT` (Certified Pharmacy Technician), `diplac` → `DiplAc` (Diplomate in Acupuncture, written `Dipl. Ac.`), `dmin` → `DMin` (Doctor of Ministry), `drph` → `DrPH` (Doctor of Public Health), `dsc` → `DSc` (Doctor of Science), `kt` → `Kt` (Knight Bachelor), `mdiv` → `MDiv` (Master of Divinity), `pharmd` → `PharmD` (Doctor of Pharmacy), `phc` → `PhC` (Pharmaceutical Chemist), `psyd` → `PsyD` (Doctor of Psychology), `rph` → `RPh` (Registered Pharmacist), `thd` → `ThD` (Doctor of Theology), `thm` → `ThM` (Master of Theology). The four LEFT OUT are two of the reviewer's nine, `meng` and `edd`, and two of the sweep's ten, `lac` and `ded` (the CAPITALIZATION_EXCEPTIONS block for meng, edd, lac and ded in decisions/vocabulary-exclusions): a mask applies wherever its word stands (the 2026-09-23 masks bullet above), so a key borne as a name re-spells a person — the #vocabulary-collisions C-i test read with the mask's reach, which is every position. Candidates considered and not taken for want of one settled spelling: `djur` (`D.Jur.` beside the German `Dr. jur.`), and `litk`, `litl`, `litp`, `mscmsm`, whose credentials could not be identified. WHY BOTH HALVES: each serves a population the other cannot. Keeping a suffix as written serves the writer who spelled it (`john smith EdD`), but a name written wholly in one case carries no spelling to keep, its suffix being one-case too, so only a mask gives `john smith psyd` → `John Smith PsyD` (`PSYD` by the acronym clause without it; `Psyd` on every release 1.4.0 through 2.3.0, measured 2026-09-24); and a mask cannot reach an unlisted credential or a caller's own spelling, which the keep rule does. No corpus name written before this change carries any of the fifteen keys (measured 2026-09-24 over the parent's 1340 names and this tree's 1375), so the MEASURED bullet above moves only by the rules.md line `john smith psyd`. Recompute the list: `sorted(Lexicon.default().capitalization_exceptions)`. Pinned on both surfaces by `tests/v2/test_render.py::test_a_shipped_mask_spells_its_suffix_in_either_single_case`, which derives its rows from the shipped pairs and so pins the property rather than the list, and the exclusion's fork by `test_a_listed_acronym_that_is_a_name_word_gets_no_mask`, whose recorded negative control is the `meng`/`edd` masks added back. - 2026-09-24 — SUPERSEDED IN PART: the 2026-09-23 bullet above headed "A SUFFIX READ BY ITS DOTTED SHAPE" accepted that `john smith B.Tech.` gives `John Smith B.TECH.` on the DEFAULT path once the suffix-free gate admitted the name. That half no longer holds: decisions/R5's keep-as-written bullet (2026-09-24) keeps a suffix written in more than one case, so the default path gives `John Smith B.Tech.`, the credential as written — every release 1.4.0 through 2.3.0 returned the whole name untouched, `john smith B.Tech.` (measured 2026-09-24). The acceptance stands under force only — `John Smith B.TECH.` with `force=True`, where every release gave `B.tech.` — and the mask remedy it names is unchanged. The rules/R5 example line proposed for this spelling (`juan garcia B.Tech.`) was not added: the differential gate reported it UNEXPLAINED at every baseline, the by-shape suffix reading being a role change no ledger rule claims for that name, so it is pinned as a unit test instead (`tests/v2/test_render.py::test_a_suffix_written_in_more_than_one_case_is_kept_as_written`). - 2026-09-24 #478 — DECIDED (Derek, 2026-09-24): the hyphen clause keeps its reading in a name written wholly in one case, and the case where that disagrees with rules/P3 is a recorded boundary, not a change. The two collide on a marked letter. Spaced, a letter the vocabulary marks as reading both ways reads as an initial in a one-case name (P8), so `maria silva e sousa` repairs to `Maria Silva E Sousa`; hyphenated, the interior part reads as the connective whatever the name's case, so `maria silva-e-sousa` repairs to `Maria Silva-e-Sousa`. Derek's reasoning: hyphenating `Silva-e-Sousa` is the writer joining the surname on purpose, so the interior word is a connective by that act, whatever the one-case rule would say of the spaced form. THE COST, measured 2026-09-24: a one-case name whose hyphenated bare initials happen to spell a connective — `J-E-P DUPONT` gives `J-e-P Dupont`, where every release 1.4.0 through 2.3.0 and the parent 4d0680e6 gave `J-E-P Dupont`, and `JOHN A-Y-B SMITH` gives `John A-y-B Smith` (every release: `John A-Y-B Smith`). The period-marked spelling is unaffected (`j.-e.-p. dupont` keeps `J.-E.-P. Dupont`, the 2026-09-23 #478 bullet). `conjunctions_ambiguous`, P8's knob, does NOT reach a hyphenated word: under `Lexicon.default().add(conjunctions_ambiguous={"y"})` the spaced `JOSE ORTEGA Y GASSET` repairs to `Jose Ortega Y Gasset` (default lexicon: `Jose Ortega y Gasset`) while `JOSE ORTEGA-Y-GASSET` stays `Jose Ortega-y-Gasset` under both. rules/R4's hyphen sentence said the hyphens join the name "as the spaced connective would", which claimed an equality the one-case case breaks; it now says the hyphens are the writer's join and states the split, with an Accepted paragraph and the `J-E-P DUPONT` boundary line. Pinned by `tests/v2/test_render.py::test_the_hyphen_is_the_writers_join_even_in_a_one_case_name`. - 2026-10-02 #541 (its "Also left" sentence superseded by #582) — THE "LEFT FOR THE ORCHESTRATOR TO FILE" FINDING of the 2026-09-24 branch-review bullet (d) above is #541, and it was wider than that bullet saw: besides the two raises it names, an entry carrying whitespace v1 never matched SILENTLY ACTIVATED in every set field the shim copies through and in `capitalization_exceptions` keys — measured 2026-10-02 against 1.4.0 (run outside the worktree, `PYTHONSAFEPATH=1`): `titles ' dean '` gave title `dean` on `dean john smith` where 1.4.0 gave first `dean`, and likewise `prefixes`, `suffix_acronyms`, `suffix_not_acronyms`, `conjunctions`, `bound_first_names` and a `' zzc '` key; the raise (`suffix_not_acronyms 'ma '`) and the activation (`titles ' dean '`) both reproduce on the 2.0.0, 2.2.0 and 2.3.0 wheels. DECIDED (Derek): `Constants._snapshot()` drops every entry `_config_shim._v1_matchable` rejects — empty after `lc()`, or not equal to its own single-spaced re-join — from all nine set fields and the keys, BEFORE its set algebra, and names them in one `UserWarning` whose remedy runs (`tests/v2/test_config_shim.py::test_the_offered_remedy_runs_and_silences_the_warning`). The test is exact because a v1 piece comes from a whitespace split re-joined only with single spaces; it generalizes the filter `given_name_titles` already had, and `tests/v2/test_config_shim.py::_UNFILTERED_OUTCOME` records what each swept row does with it off. One exception, decided when the 1.4 pickle test caught it: every release from at least 0.5.8 through 1.4.0 SHIPPED two such entries in `TITLES` (`'actor '`, `'television '`), so a restored 1.4 pickle carries them and its user never wrote them; they are dropped WITHOUT the warning (`_V14_SHIPPED_UNMATCHABLE`, held to the pickle's own unmatchable set by `test_the_1_4_shipped_unmatchable_roster_is_exactly_the_pickles`), keeping that pickle warning-free. The exemption is keyed by entry, not by provenance, so a user who writes `'actor '` or `'television '` themselves is not warned either — accepted, since the two strings are 1.x's own typos. The drop is a reading change for that pickle: `Actor John Smith` gave title `Actor` on 2.0.0 through 2.3.0 and gives first `Actor` now, as 1.4.0 did. DECLINED: stripping whitespace in `SetManager` (activates every v1-inert entry above), and a silent drop for user entries (Derek: the entry is certainly a typo and certainly dead, so say so). ACCEPTED WIDENING, deliberately left: a key written with capitals or edge periods (`'McDonald'`, `'phd.'`) never matched on 1.4.0, whose lookup is `lc(word)` against keys stored as written, but has matched since 2.0 through `Lexicon`'s fold; dropping it would break a working 2.x config to restore an inertness nobody wanted. Also left: a set entry `Lexicon` folds to empty that `lc()` does not, a lone non-ASCII full stop (`'。'`), still raised at the first parse; superseded by #582 below, which drops it with the warning. - 2026-10-02 #582 — SUPERSEDES the #541 bullet's "Also left" sentence above. DECIDED (Derek): an entry `Lexicon._normalize` folds to empty is dropped by `_config_shim._v1_matchable` with the #541 warning, in every set field and key. The case is a lone CJK full stop (`'。'`, `'.'`, `'。'`, or a run of them), which 2.3.0's `FULL_STOPS` fold (#322/#323) empties while v1's `lc()` strips only `.`. Measured 2026-10-02 on released wheels run outside the checkout with `PYTHONSAFEPATH=1`: 1.4.0 through 2.2.0 accept `c.titles.add('。')` and read `。 john smith` with title `。`, while 2.3.0 raises `ValueError` at the first parse, as did this tree before the change, in `titles`, `prefixes`, `suffix_acronyms`, `suffix_not_acronyms`, `conjunctions`, `bound_first_names` and a key. ACCEPTED DEPARTURE from 1.4.0, the only one this filter makes: such an entry used to act on a token that is nothing but that full stop. Since 2.3.0 `Lexicon` can neither hold the entry nor match the token, its lookup fold emptying both, so reproducing v1 is not on offer and the choice was drop or raise. The warning's wording moved with it, from "nameparser 1.x never matched" to "match no name word". `given_name_titles`' `if t` filter was deleted as unreachable: `_title_key` is empty only when every word folds away, and then the whole entry folds to empty and was already dropped. With no shim-reachable plain `ValueError` left from `_normpairs`, `test_an_unrelated_capitalization_exceptions_valueerror_has_no_v1_hint` turns the filter off to keep the except-by-type clause pinned. THE SAME FOLD, ONE CHARACTER IN: an entry with a CJK full stop at its EDGE (not only full stops) passed the filter, and the set algebra compared it raw while `Lexicon` folded it, so `c.suffix_not_acronyms.add('ma。')` (beside the ambiguous `ma`) raised the gate-bypass check and a bound `'zed。'` beside a never-given particle `zed` raised the contradiction check, both from 2.3.0 on; measured accepted on 1.4.0 and 2.2.0 by the review of this change. `_build_snapshot` now compares `_normalize`d spellings in exactly the two computations `Lexicon` re-checks after its own fold — dropping a `suffix_not_acronyms` entry whose fold is an ambiguous acronym's, and taking into `particles_ambiguous` every particle whose fold is a bound given name's — so such an entry reads exactly as its folded spelling (`test_an_edge_full_stop_collision_reads_as_its_folded_spelling`; the fold-off control is `_UNFOLDED_OUTCOME`). That reading is the `dean。` widening below, not v1's: 1.4.0 matched `ma。` only on a `ma。` token. DECLINED, after being written and reviewed: folding EVERY kept entry before the set algebra. `non_first_name_prefixes` and `suffix_acronyms_ambiguous` reach `Lexicon` only through raw set algebra, so folding them is a NEW match, not the 2.3.0 one: `prefixes zz` with `non_first_name_prefixes 'zz。'` moved `zz smith` from first `zz` to last `zz smith`, an NFD/NFC pair across those fields moved the same way, and `suffix_acronyms_ambiguous 'zq。'` beside `suffix_acronyms zq` moved `john zq` from suffix to last — the particle readings the same on 1.4.0, 2.2.0, 2.3.0 and the pre-fold tree, the `zq` one on 2.2.0, 2.3.0 and the pre-fold tree (1.4.0 gives last `zq` there by its own two-word reading, not by matching the entry). They are pinned at those readings by `test_an_edge_full_stop_entry_outside_the_two_checks_reads_as_before`. ACCEPTED WIDENING, left as it is (Derek): since 2.3.0 a `titles` entry `'dean。'` also matches a bare `dean`, where 1.4.0 matched only `dean。`. The same fold makes it, and dropping the entry would lose the `dean。` match too, so it sits beside the capitalized-key widening above. - 2026-10-02 #542 — A DECOMPOSED WORD IS REPAIRED AS ITS COMPOSED TWIN, and the repaired text keeps the form it was typed in: no clause composes its OUTPUT. Two mechanisms do it. The word splitter and the mask's neighbour tests read a combining mark (Unicode category M) as part of the letter before it; the three clauses that ask a regex about a whole word's SHAPE — Mac/Mc and the two initial tests — ask it of the word's composed spelling. They differ only for a mark with no precomposed form, where composing leaves the word as written and so agrees with the parse: under a caller-added conjunction `q́`, `ivan q́. petrov` parses `q́.` as the conjunction, and the hyphen clause keeps `Petrov-q́.-Sidorov` lowercase as it does (measured by the docs review, 2026-10-02). A name typed NFD (macOS file names, some databases) had been split at each mark by `_WORD`'s `(\w|\.)+`, `\w` matching no mark, so `josé garcía` repaired to `José GarcíA` on every release from 1.4.0. Python's `re` has no `\p{M}`, and a hand-written class of the combining blocks would be a copy of Unicode data that drifts (it would already miss Adlam, a cased script whose marks sit outside them), so the test is `unicodedata.category(ch)[0] == "M"`, asked by `_render._sub_words` after each `_WORD` match and by `_render._beside` for a letter's neighbours. DECLINED: NFC-composing before repair, the issue's option 2. It fixes the split but hands back text in a form the writer did not use, which is a change beyond case under rules/R4 — `tests/v2/test_properties.py::test_case_repair_changes_case_and_nothing_else` compares with `casefold()`, which does not normalize, and would report it. THE ISSUE'S CLAIM THAT THE MASK NEEDED NO CHANGE ONCE THE WORD REACHED IT WHOLE WAS MEASURED FALSE, and the Mac/Mc clause needed one too; both were found by fixing the splitter alone and comparing again. `_apply_mask`'s split-off-initial override and `_letter_run_ge2` read a letter's neighbour by raw index, which in NFD is the mark rather than the full stop or the letter past it: with the splitter alone, under a caller's `('éx', 'éx')` mask `john smith é.x.` forced gave `É.X.` composed and `é.X.` decomposed — a shape that AGREED before the fix (`É.X.` both ways) only because the old splitter cut the word at its mark before the mask was looked up — and under `('éex', 'éeX')` `john smith ée.x` gave `ée.X` against `éE.X` (`ÉE.X` before the fix). Both read past marks now. The Mac/Mc clause's `\w{2,}` fails at the mark, so with the splitter alone `macée` gave `Macée` decomposed against `MacÉe` composed (`MacéE` before the fix, the split having made the last `e` a word of its own); the clause is now DECIDED on the composed spelling and applied to the word as written. The two initial-shape tests, `_DOTTED_INITIAL` in the hyphen clause and `_INITIAL` in the unclassified-text fallback, matched the raw word and now match its composed spelling, so a decomposed `й.` — `й` is the one default conjunction that decomposes — is the initial its composed twin is: `ivan petrov-й.-sidorov` repaired to `Ivan Petrov-й.-Sidorov` decomposed against `Ivan Petrov-Й.-Sidorov` composed, and a middle spliced in as decomposed `й.` kept `й.` against `Й.`, at 97af1f02 and at this change's first commit alike (found by the docs review, 2026-10-02). AMENDS the 2026-09-24 branch-review bullet (a): its SPLITTING half is retired. `parse("ǰo smith").capitalized(force=True)` gives `J̌o`, and a second forced pass now gives `J̌o` back rather than `J̌O`, the mark staying with its letter (measured 2026-10-02); the LENGTHENING half (`ß`, `ʼn`) stands. A mark with no letter before it heads no word: `str.capitalize()` on a word beginning with one would upper-case the mark and leave the letter after it lowercase. Out of reach and left there, two of them: `initials()`, a rules/R3 view rather than case repair, takes a token's first character, which for a decomposed initial letter is the base letter without its accent (`parse("émile zola")` initials `é. z.` composed and `e. z.` decomposed, measured 2026-10-02; fixed the same day under #585, decisions/R3); and unspaced hangul, whose NFD parse differs from its NFC parse because segmentation matches the census list as written (docs/usage.rst, "Decomposed text"), so repair follows a different parse; hangul jamo are letters, not marks, and nothing here touches them. VERIFICATION: the differential gate cannot see this — `capitalized()` is not a compared surface (this entry, 2026-08-29); the two NFD rows this change adds to the rules corpus reach the gate through the role fields, `_ambiguities` and `_initials` only. Standing in its place: rules/R4's two NFD example lines, `tests/v2/test_properties.py::test_a_decomposed_name_repairs_as_its_composed_twin` (renamed `test_a_decomposed_name_renders_as_its_composed_twin` by #585, which joined initials to the walk), which decomposes every corpus and case-table text that decomposes, compares its repair with the composed text's on both surfaces plain and forced, and records as its control 144 disagreements over 137 texts with 97af1f02's `_render.py` over this change's corpus (measured 2026-10-02), and `tests/v2/test_render.py::test_a_decomposed_word_repairs_as_its_composed_twin` and `test_a_spliced_decomposed_initial_reads_as_its_composed_twin` for the shapes no corpus name reaches, whose two mask rows were mutation-checked by restoring raw-index neighbours. - 2026-10-04 #604 — DECIDED (Derek, 2026-10-04): a mask outranks the particle lowercase. Adding the Irish particles moved case repair the wrong way: `SEÁN Ó MURCHÚ` repaired to `Seán ó Murchú`, where 2.3.0 gave `Seán Ó Murchú` (Ó having been a middle initial there), and `ní` and `ua` the same, because repair writes every particle in lowercase while Irish writes these three capitalized. The map is already "how a word is written wherever it stands" (the role-free note in `_cap_word`), so the particle arm now steps aside for a word the map carries, and the map gains `ó`→`Ó`, `ní`→`Ní`, `ua`→`Ua`. Each mask is its word's own title case, so a bare word in any other role repairs as it would without one. The one reach past particles is the dotted spelling of the two-letter masks: each writes its word as one run, so R4's split-off-letter clause capitalizes both letters, and `u.a. smith` repairs to `U.A. Smith` where it gave `U.a. Smith`, `n.í. smith` to `N.Í. Smith` where it gave `N.í. Smith` (measured 2026-10-04, with and without the three masks; `ó` has no dotted twin), which is right for two initials. Measured before deciding, no shipped exceptions key was a particle (`set(Lexicon.default().capitalization_exceptions_map) & Lexicon.default().particles` was empty), so in the SHIPPED vocabulary the precedence moved nothing but the three new entries. A CALLER's map is another matter, and this is a deliberate change there too (Derek, 2026-10-04, after the design and code reviews both found it): `c.capitalization_exceptions['van'] = 'Van'` makes `ludwig van beethoven` repair to `Ludwig Van Beethoven`, where 1.4.0 through 2.3.0 gave `Ludwig van Beethoven`, the particle having won in v1's `cap_word` and every release since. Kept because an entry is how the caller says a word is written wherever it stands, and a caller who maps `van` to `Van` plausibly means it; the release bullet says so, since the differential gate cannot see `capitalized()` (#R3, above). Pinned by test_a_caller_particle_mask_outranks_the_particle_lowercase. Not extended to `mac`, which repairs to `mac` as before (`Liam mac Murchú`): `mac` is an ambiguous particle shared with Scottish and other spellings, and nothing in #604 weighed it. The Malay `binti`/`binte` carry no mask and keep their lowercase (`Ina binti Navalamar`, where 2.3.0 gave `Ina Binti Navalamar`). - 2026-10-21 #808 — a listed acronym credential joined by a hyphen or slash repairs in capitals as one word (`otr/l` → `OTR/L`, `fnp-bc` → `FNP-BC`). The per-word walk split the token and capitalized each half as a name part (`Otr/L`, `Fnp-Bc`); `pa-c` repaired to `Pa-C` on 2.0.0 through 2.5.0 (measured on the wheels, 2026-10-21) and reads `PA-C` on master before this change, each half capitalizing correctly by luck. The test is the whole token against `suffix_acronyms` in the SUFFIX role, asked after the particle arm and before the split, and a recorded casing (mask) for the token wins, laid over the whole token in any role. That half is new in the same change: no shipped joined entry records a casing, but a caller's `otr/l` → `OTr/L` was silently dropped before it, the per-word walk never asking the map about the whole token. Pinned by test_a_joined_acronym_repairs_whole_and_a_recorded_casing_wins. Hyphenated name parts and titles are untouched since neither is a suffix-role acronym. `capitalized()` is invisible to the snapshot, so no count is claimed. - 2026-10-22 #826 — CASE IS A COMPARED SURFACE: the snapshot records `capitalized()` and `capitalized(force=True)` for every corpus name, reversing 2026-08-29, WHY CASE IS NOT A COMPARED SURFACE on the test that entry's 2026-09-02 amendment set, a case change that escapes as #408's initials did. Two have. #478 shipped `jose ortega-y-gasset` repaired to `Ortega-Y-Gasset` in 2.2.0 and 2.3.0 (the all-caps spelling had read that way since 2.0.0), and #808 repaired `PA-C` to `Pa-C` and `FNP-BC` to `Fnp-Bc` from 2.0.0 through 2.5.0, each with every check green. The 2026-08-29 entry's other objection, a column arriving pre-failed by 139 entries against 1.4.0, was about the differential ledger. The snapshot (#664) records the tree's own reading and reports only what a change moves, so the column started empty. BOTH VIEWS, because they see different things: only the plain view sees rules/R5's gate, and the one-case names where #478 and #808 showed are its population; the forced view runs the repair over the mixed-case majority, which the gate otherwise hides. Over the 6006 entries on this date, 238 record a `capitalized` view and 695 a `capitalized_force` view. EACH VIEW IS RECORDED ONLY WHERE IT MOVES SOMETHING: `capitalized` where it renders other than `str()` of the parse, `capitalized_force` where it renders other than `capitalized()` does. A name repair leaves alone records neither, so a role move echoes into a case view only in a name repair changes, and there whenever it changes the rendering, since a view is the whole repaired name (`JOHN SMITH` → 'John Smith', under FAMILY_FIRST 'Smith John', no word recased): the echo the initials column has. A snapshot recorded before this entry carries no case view, so `snapshot.py` compares the views only against one that records them (`--since v2.5.0` reported 961 entries before that rule, 910 of them the keys appearing, and 51 after it, as master's own snapshot does). NEGATIVE CONTROLS, run as `snapshot.py --tree ` against this snapshot. The tree before #808's fix (`1ae2aedc^`) moves three entries: `JOHN SMITH, FNP-BC` and `john smith, otr/l` in `capitalized`, and `John Smith, otr/l` in `capitalized_force`, the last seen by the forced view alone. The tree before #478's fix (`20cf32f6^`) moves 405 entries, most of them later behavior changes; among them `JOSE ORTEGA-Y-GASSET` and `jose ortega-y-gasset` move in `capitalized`, and `Jose Ortega-Y-Gasset` in `capitalized_force`, which only the forced view sees. The facade's `capitalize()` is not recorded, since it renders through the same repair. `compare.py` records both views for a 2.x baseline and neither for 1.4.0, whose rows go through `facade_view`. Cost on this date: the two views take 0.18s of `snapshot.read`'s 1.38s, and the file grew from 799,124 to 847,676 bytes. The forced view put some long-standing repairs on record, among them `A.B. Vajpayee` → 'A.b. Vajpayee' and `Ibn-ʿAlī` → 'ibn-ʿalī'. They are recorded as the tree reads them, not adjudicated here. SUPERSEDES: 2026-08-29, WHY CASE IS NOT A COMPARED SURFACE; and in 2026-08-29, THE VERIFICATION LIMIT, its claims that no ledger run at any baseline can see a change that moves only case and that nothing is owed for one, and its 2026-09-02 amendment's conclusion that no `capitalized()` column is owed yet. `compare.py --baseline 2.1.0` now shows that entry's own deviation (`ANH DO` → 'Anh Do', where 2.1.0 gave 'Anh do'); a 1.4.0 baseline still cannot. - 2026-10-22 #838 — CAPITALS INSIDE A WORD. The fall-through was `str.capitalize()` on the whole `_WORD` match, which holds a word's interior full stops and any modifier letter (category Lm, which `\w` matches) that opens it, so a capital not standing first was lowered: `j.r.r. tolkien` → 'J.r.r. Tolkien', `AHMAD IBN-ʿALĪ` → 'Ahmad ibn-ʿalī', and under force `A.B. Vajpayee` → 'A.b. Vajpayee'. 1.4.0 through 2.5.0 give the old readings (wheels, measured 2026-10-22, `HumanName(n).capitalize(force=True)`), so this is a fix, not a regression. The fix title-cases each full-stop chunk from its first character outside Lm. TWO ANSWERS. Every chunk is a word of its own rather than every letter a capital, which gives `St.John`, not `ST.JOHN`, and matches the hyphen split: a chunk takes the Mac/Mc convention as a hyphen part does (`J.MCDONALD SMITH` → 'J.McDonald Smith', where 2.5.0 gave 'J.mcdonald'; `mcdonald.jones` → 'McDonald.Jones'), which the first version missed, the Mac branch then running before the split and keeping everything after the first full stop as written (review of #839). Only Lm is passed over: an apostrophe of any spelling (`'`, U+2018, U+2019) is not a word character, so `'abd` was already 'Abd'; a digit is not passed over, or `3rd` would become '3Rd'. THE COST, ACCEPTED: an unlisted abbreviation whose own ending follows its full stop reads as two words. The snapshot showed it on the CLDR fictional `Dut.ra Prof.ra` ('Dut.Ra Prof.Ra' under force), and with it go the one-case Italian salutations (`SIG.RA` → 'Sig.Ra', `DOTT.SSA` → 'Dott.Ssa') that `str.capitalize()` had always repaired correctly, and the Latvian and Norwegian degrees (`dr.philol.` → 'Dr.Philol.', `st.repr.` → 'St.Repr.', three CLDR-only rows), which now read as their spaced spellings do (`Dr. med. univ.` forced → 'Dr. Med. Univ.', decisions/R5's title-repair evidence). A NARROWING WAS BUILT AND DECLINED: two or more letters after the last full stop, closed by none, kept lowercase as an abbreviation's written ending. It kept `Sig.ra` but gave `st.john` → 'St.john', `lt.gov` → 'Lt.gov' and forced `J.Smith` → 'J.smith', and still missed `dr.philol.`, whose ending is closed; a shape cannot tell an ending from a joined name or word, so the remedy is a listed casing, a `cased` mask asked before this clause. DECIDED (Derek, 2026-10-22): the `St.John` reading, with the Italian abbreviated salutations to be listed as titles with masks before 2.6.0 ships. Unlisted joined credentials are a vocabulary question as well (`jd.cpa` → 'Jd.Cpa'). BONUS: 'ʼn' upper-cases to the Lm apostrophe plus 'N', so 'ʼNa' is now a fixpoint, where the second pass gave 'ʼna'; capitalized()'s docstring no longer lists it. SNAPSHOT, 2026-10-22: the moved entries are listed in this change's diff of snapshot.jsonl, each read. Pinned by rules/R4's examples (each fix row fails on the parent) and test_a_word_takes_its_capital_past_a_caseless_lead. - 2026-10-22 #840 — THE REMEDY FOR #838's ITALIAN COST SHIPS: the abbreviated salutations and letter-heading honorifics whose ending follows their full stop are titles with `cased` masks (decisions/salutation-titles), so `SIG.RA MARIA ROSSI` repairs to 'Sig.ra Maria Rossi' again, as 1.4.0 through 2.5.0 gave it, and `dott.ssa` to 'Dott.ssa'. The mask is asked before the chunk split, the precedence the 2026-10-22 #838 entry relied on. The accepted cost stands for an unlisted ending (the Sardinian `DUT.RA` → 'Dut.Ra', CLDR's `Dut.ra Prof.ra`). A spaced `Sig. ra` is out of reach: it joins as a title run, `ra` being a listed title, and forced repair writes 'Sig. Ra'. derek73-python-nameparser-456b024/docs/design/decisions/R5-case-repair-gate.md000066400000000000000000000744441527303271700270240ustar00rootroot00000000000000# R5 — the case-repair gate ## Standing - Case repair acts only on a name written entirely in one case, and a name whose words outside the suffix are written in more than one case is kept as written, whether that casing is right not entering into it (`juan mcdonald` → 'Juan McDonald', `SHIRLEY MACLAINE` → 'Shirley MacLaine', `Shirley Maclaine` kept), unless repair was asked for anyway (`Shirley Maclaine` forced → 'Shirley MacLaine'); the refusal predates the git history and the override shipped in 0.4.0, and the suffix's place in the test is the 2026-09-23 #492, DECIDED line's — 2026-08-29 (#407 arc); 2026-08-29, WHY R5'S STATEMENT WAS REWORDED; 2026-09-23 #492, DECIDED. - The boundary example of rules/R5's main fence is the forced `Shirley Maclaine` line, the refusal being R5's effect rather than its absence, and no example witnesses an already-correct mixed-case name, kept and repaired being the same string there — 2026-08-29, why the gap survived; 2026-08-29, WHY R5'S STATEMENT WAS REWORDED. - The rule names no route for the override: there are two, `capitalized(force=True)` per call and the v1 `force_mixed_case_capitalization` attribute the facade still honors (set on a private `Constants`, `HumanName('Shirley Maclaine')` and a bare `.capitalize()` give 'Shirley MacLaine') — 2026-08-29, the override has TWO routes. - `capitalized_forced` is a test-side pseudo-field the doc runner resolves, not a parser field — 2026-08-29, `capitalized_forced` is a test-side pseudo-field. - Forcing repair does not make the output independent of how the input was cased, so rules/R5 does not state that it does; the property is pinned in tests/test_capitalization.py, where `JUAN Y GARCIA` and `juan y garcia` both give 'Juan y Garcia' since a one-case name stopped reading a bare capital connective as an initial — 2026-08-29, WHAT THE OVERRIDE DOES AND DOES NOT PROMISE; 2026-08-29, and therefore NOT stated in rules; 2026-09-13 #383/#479. - The gate reads every token but the suffix-roled ones, a suffix saying nothing about how the writer cased the name (`juan garcia III` → 'Juan Garcia III', `JUAN GARCIA Jr.` → 'Juan Garcia Jr.'), nickname and maiden included (`jane doe nee SMITH III` and `juan garcia (Bob)` kept as written); forcing was not the answer, being lossy on correctly cased names (`Danny DeVito` forced → 'Danny Devito') — 2026-09-23 #492, DECIDED. - The parser's one-case readings still count a cased suffix where the gate does not, so one name can be mixed to the parser and one-case to repair: `john e jones III` → 'John e Jones III' against `john e jones iii` → 'John E Jones III', `jack MA` → 'Jack MA', and `MD, PhD` → 'Md PhD' — 2026-09-23 #492, THE R5 / PARSER SPLIT. - A title stays evidence, title repair being the ordinary word repair (`Dr. juan garcia` kept, `dr. juan garcia III` → 'Dr. Juan Garcia III'), which may move once title repair is trusted — 2026-09-23 #492, TITLES STAY EVIDENCE. - Where repair is not forced the same one-case test is asked of each suffix on its own, so a suffix written in more than one case is kept as written (`john smith EdD` → 'John Smith EdD', `john smith B.Tech.` → 'John Smith B.Tech.'), at the accepted cost of keeping a garbled spelling with the deliberate one (`juan garcia Iii` → 'Juan Garcia Iii', 'Juan Garcia III' forced); force repairs both — 2026-09-24 #492. Not standing: the debt the extraction left rules/R4, discharged there — 2026-08-29, DEBT this extraction leaves; the #492 measurement, which decides nothing — 2026-09-23 #492, MEASURED. Elsewhere: the masks that serve a one-case name this rule cannot reach (`john smith psyd` → 'John Smith PsyD') — decisions/R4's 2026-09-24 #459 entry; the debt discharged — decisions/R4's 2026-08-29 DEBT DISCHARGED entry; the parse-time case fact still counting the judged suffix (`Jack MA`) — decisions/S6's 2026-09-23 #492 entry; a one-case single-letter connective read from vocabulary — decisions/P8; the v1 test of `juan garcia III` no longer an expected failure — decisions/v1-xfail-triage's 2026-09-23 #492 entry. Open: none in this file. ## Log - 2026-08-29 (#407 arc) (the suffix's place in the gate superseded by 2026-09-23 #492) — EXTRACTION, not a decision: the parser is untouched. The two halves of R5 have separate provenance, and conflating them is easy enough that the first draft of this entry did. The REFUSAL — repair skips any name already carrying more than one case — is older than the git history: `git log -S "name == name.upper() or name == name.lower()" --reverse` bottoms out at 45a1539 (2011-02-03), the initial import from svn, where `capitalize()` already opens with that guard and a bare `return`. (A path-filtered search answers 280895b instead, the same-day commit that moved the module into `nameparser/`; the code did not change there.) The OVERRIDE is bf1e0a5, 2016-06-02, which did not add the refusal but wrapped it — `if not (name == name.upper() or ...)` became `if not force and not (...)` — and shipped in 0.4.0 (June 2, 2016; its own release-log line is docs/release_log.rst under that heading). So R5's statement as a whole holds from 0.4.0 on. rules.md had never said any of it, though bf1e0a5's diff shows the API docstring already did: "It will not adjust the case of names entered in mixed case" was there before that commit edited around it. The gap was rules.md's alone. Evidence, measured on the released 1.4.0 wheel — the last v1 release and one of the differential baselines, so a natural thing to measure against and not a release that introduced anything here — and re-measured on this branch today, facade and core agreeing: `HumanName('Shirley Maclaine').capitalize()` leaves `'Shirley Maclaine'` — mixed case, wrong, and kept — while the same name under `force=True` gives `'Shirley MacLaine'`; `HumanName('Juan McDonald').capitalize()` leaves `'Juan McDonald'`. rules.md's own preamble classifies behavior in this position as "pinned-but-undocumented — an extraction gap to close, not a specification", which is why the fix lands in the document rather than in `_render.py`. - 2026-08-29 — why the gap survived, and what the new boundary buys. R4 promised that "an already-correct name comes back unchanged", which is idempotence: true of everything the gate returns, and silent about the gate. The two readings were indistinguishable through R4's boundary, because `"Juan McDonald"` comes back unchanged under `force=True` as well — an already-correct input cannot witness a rule whose subject is what gets kept REGARDLESS of correctness. `Shirley Maclaine` separates them, so R5's block pins it twice: kept as written on a plain `capitalized=` line, and repaired on the forced line. Under idempotence the plain line would have to fail. The BOUNDARY sits on the forced line, which took a correction to get right — the first draft marked the refusal, and the refusal is R5's effect, not its absence. R5's own stated exception is repair-regardless, so that is the non-firing case (rules.md's preamble: the boundary is "usually the rule's OWN stated exception"). - 2026-08-29 — WHY R5'S STATEMENT WAS REWORDED RATHER THAN GIVEN A THIRD EXAMPLE, recorded at length because the wrong turn was taken twice and the reasoning is reusable. R5 first said a mixed-case name is returned untouched "whether or not its casing is correct". That phrasing reads as a disjunction with two branches, and invites the question of which example witnesses the already-correct one. Nothing can: for a mixed-case name that repair would not alter, kept and repaired are the SAME STRING by construction, so no example line distinguishes a parser with the gate from one without it. The unwitnessable branch is a property of the claim, not a gap in our choice of names — and the bullet above had already said so ("an already-correct input cannot witness a rule whose subject is what gets kept REGARDLESS of correctness") before two review rounds pushed past it. Two rows were tried and both withdrawn. `"Juan McDonald" → capitalized="Juan McDonald"` was INERT (mechanisms.md's inert-measurement class): it passes with R5's gate deleted, which is the whole failure shape that class names. `"Vincent Van Gogh" → capitalized="Vincent Van Gogh"` did discriminate — measured by comparing the shipped call against gate-off behavior, which the forced call reproduces exactly, since deleting the gate is what `force` already does: shipped `'Vincent Van Gogh'`, gate-off `'Vincent van Gogh'`, the particle rule lowercasing `Van` the moment repair runs — but it was REDUNDANT, not complementary. It asserts the same proposition as the `Shirley Maclaine` row above it: repair would change this name, and the gate keeps it anyway. The two differ only in which repair rule would have fired, and which rule repair applies is R4's subject, not R5's. And the frame it was chosen under was itself wrong, which is the more useful half of the lesson (reframed 2026-08-29 on Derek's correction, while R4 was being reworked). The row was picked to be bearer-correct — a name whose casing the bearer would endorse — and that frame drags the document into per-name arguments about whose spelling is right: this one needed `Vincent van Gogh` to be the correct spelling, which P6's own examples contradict, and the tussenvoegsel convention behind them is amended later on this branch anyway. CORRECTNESS DOES NOT ENTER INTO IT. Mixed case is the writer making an explicit choice, and repair defers to that choice rather than judging it — a name kept is not a name endorsed. Read that way the withdrawn row needed no claim about Dutch orthography at all, and the reason it needed one is exactly that the frame was wrong. The fix was to stop claiming the unwitnessable branch: the statement now says a mixed-case name is kept and that whether its casing is right does not enter into the decision, which `Shirley Maclaine` — casing wrong, kept — witnesses whole. GENERAL LESSON, and the reason this is long: when an example cannot be found for half a rule, suspect the STATEMENT before suspecting the example set. A phrasing that promises more branches than the behavior has will absorb inert examples indefinitely, each one looking like progress. Caution for the commit that reworks R4 and will choose its own mixed-case rows: avoid a name whose family base is wholly particle vocabulary (`Anh Van Do`, base `Van Do`), because the #407 work changes how those capitalize and would neuter such a row silently; `gogh` and `vega` are in neither `particles` nor `particles_ambiguous`, `van` and `do` are in both. - 2026-08-29 — the override has TWO routes, and the rule states neither, by design. Per-call is the obvious one. The second is a v1 Constants attribute the facade still honors, `force_mixed_case_capitalization` (nameparser/_facade.py resolves it when no per-call value is given; docs/release_log.rst records it as "still honored through the facade"). Measured today: with that attribute set True, `HumanName('Shirley Maclaine')` then a bare `.capitalize()` — no argument at all — LEAVES `'Shirley MacLaine'` (the v1 call mutates in place and returns None, so the name is read back with `str()`). An earlier draft of R5 said "only an explicit request to repair regardless overrides that", which that measurement falsifies; the statement now says repair was asked for anyway, without saying by what route (it read `repair regardless of how the name is cased was asked for` until 2026-08-29, when that phrasing turned out to have a second reading -- see the override bullet below), because rules.md is implementation-free by its own preamble. - 2026-08-29 — `capitalized_forced` is a test-side pseudo-field, not a parser field. The route the EXAMPLES use is the per-call argument to `capitalized()`, and an argument is not a policy, locale or extras gate — the only three things rules.md's grammar admits in an example's annotation slot — so it cannot ride that slot, and the doc runner grew a resolver branch instead. (The facade attribute above is a second route to the same behavior, not a second thing to assert; the core takes the argument only.) The R5 block's example lines are asserted by the suite like any others. Regenerating `corpus_rules.jsonl` from them adds `Shirley Maclaine` (235 lines, from 234). The harness dedupes across corpus files, so a new example name costs a population slot exactly when no other corpus already carries that same string; `corpus.jsonl` already carried this one, so the population held at 1090 (the withdrawn `Vincent Van Gogh`, in no other corpus, had made it 1091), and the gate exited 0 with 0 unexplained at all three baselines either way. - 2026-08-29 (its mechanism, v1's initial carve-out, superseded by 2026-09-13 #383/#479) — WHAT THE OVERRIDE DOES AND DOES NOT PROMISE, from Derek's framing of R5 and then measured, because the framing implies a property that is ALMOST true and the gap is the useful part. The framing first, and it supersedes the correctness talk elsewhere in this entry: mixed case is the writer making an explicit choice, and repair defers to that choice instead of judging it. Nothing is being called correct or incorrect — a name kept is a name whose writer said something about it, and a name repaired is one whose writer did not. The property that seems to follow is that asking for repair REGARDLESS should ignore the given casing entirely, so one name repairs to one string however it was written. MEASURED over the 1094 corpus names and it does NOT hold. On the v2 core — `parse(n).capitalized(force=True)` against `parse(n.upper()).capitalized()`, rendering all seven roles — the two differ for 63 names, and the lowercase direction for 18. Naming the surface matters here, and BOTH counts move with the surface, which an earlier draft of this bullet got half right: through `HumanName` and `str()`, which is what tests/test_capitalization.py uses, the counts are 62 and 16 — and the two directions are dropped for DIFFERENT reasons, which an earlier draft of this bullet ran together under the first. UPPER: the facade's default render spec omits the maiden name, so `str()` cannot see `Jane van der Berg née y Jones` (the whole of the upper difference, and it is a conjunction inside the maiden name — `maiden` is `y Jones` forced against `Y Jones` uppercased and every other role is byte-identical). LOWER: the two the facade drops are `John van der J. V` and `abdul V Smith`, and the maiden field is EMPTY in both directions for both, so the omitted role explains neither. What hides them is that `str()` CONCATENATES adjacent roles, so a token that crosses a role BOUNDARY and moves nothing else is invisible in the joined string: `John van der J. V` is family `van der J. V` forced against family `van der J.` plus suffix `V` lowercased, and `abdul V Smith` is given `Abdul V` forced against given `Abdul` plus middle `V` lowercased — same seven roles rendered, same string joined. Re-derived 2026-08-29 on this branch, and named per name so the next reader can check the arithmetic: core 63/18, facade 62/16. Two things about those numbers are the opposite of what one would guess. UPPERCASE IS THE WORSE DIRECTION, not the clean one. And the misses are not merely the pipeline's case-sensitivity leaking in: of the 63, only 25 move a role at all, and the other 38 parse byte-identically and diverge inside the repair (25 and 37 through the facade, the missing name being one of the byte-identical ones). The mechanism is v1's initial carve-out — a conjunction is not lowercased where it is written initial-shaped, and initial-shaped means one CAPITAL letter — so uppercasing turns every one-letter conjunction into an initial (`Velasquez y Garcia, Dr. Juan Q.` forced keeps `y`; the same name uppercased then repaired gives `Y`, with a byte-identical partition either side -- the comma form is the one to cite here, the space-written `Dr. Juan Q. Velasquez y Garcia` being a member of the 25 whose roles DO move), and lowercasing turns a middle initial `E` into the Italian conjunction. 1.4.0 does the same (`JUAN Y GARCIA` capitalizes to `Juan Y Garcia`), so this is inherited, and it is recorded here rather than fixed here. Recompute both directions by running the two forms over the corpus files deduped and diffing. - 2026-08-29 (its `juan y garcia` exception superseded by 2026-09-13 #383/#479) — and therefore NOT stated in rules.md, which is a deliberate choice rather than an oversight. The document's examples are keyed on input STRINGS, so any statement of the property invites exactly the test that falsifies it — re-case the input, expect the same output — and the counterexamples are already in the corpora. The property is true of the repair given a parse, and rules.md speaks input-to-output; a rule stating it would be over-broad in the one direction a reader would check. What R5's statement says is enough for the promise that IS kept: a mixed-case name is kept unless repair was asked for anyway. That clause was REWORDED for this, and the reword is the whole point rather than a tidy-up. It read `unless repair regardless of how the name is cased was asked for`, which carries two readings -- the intended one, that the request overrides the keeping, and a second one, that the repair disregards the input's casing, which is this property in nearly this bullet's own words. A reader taking the second reading would run the re-casing test predicted above, land on `Velasquez y Garcia, Dr. Juan Q.` (in the corpus today), and conclude the RULE is wrong when only the phrasing was. Nine words, and they asserted the thing the paragraph exists to deny. The property is pinned in tests/test_capitalization.py instead, over names carrying no single-letter word whose class case decides, with `juan y garcia` beside it as the recorded exception. R5's example block gains `"SHIRLEY MACLAINE" → capitalized="Shirley MacLaine"` from this work, and it earns its place on its own ground rather than as half of a convergence pair: it is the only row in the block that fails when the gate is narrowed to lowercase-only, every other row passing that mutation. Measured three ways — gate deleted (passes, so it does not witness the gate's existence), gate narrowed to accept only all-lowercase (FAILS, and alone in the block), Mac/Mc convention deleted (fails, with the other two rows). Until it was added, R5 stated that repair acts on a name written entirely in one case and witnessed only the lowercase half of it. That lowercase half is still `"juan mcdonald"`, which is byte for byte an R4 row as well, and the duplication is deliberate rather than an editing slip: the two rules make different claims about the same line — R4 that the repair honors the Mac/Mc convention, R5 that an all-lowercase name is acted on at all — and dropping it from R5 would leave the gate's lowercase half unwitnessed inside the rule that states the gate. Five other rows already sit under two rules apiece for the same reason (P5/P6 twice, P5/O5, N3/M4, W1/W3). - 2026-08-29 — DEBT this extraction leaves, named so the next commit inherits an obligation rather than a rediscovery. Pulling the gate out into R5 leaves R4 carrying ONE falsehood and ONE ambiguity — different defects wanting different repairs, and `interacts: R5` carries neither, the field being advisory. FALSE: R4 promises repair "vocabulary exceptions (McDonald) included", but `str(parse('Juan Mcdonald').capitalized())` is `'Juan Mcdonald'` — the gate refuses before any vocabulary is consulted, and only `str(parse('Juan Mcdonald').capitalized(force=True))`, `'Juan McDonald'`, reaches the exception. R4 needs its promise scoped to names the gate admits. AMBIGUOUS, not false: R4's "an already-correct name comes back unchanged" means correct by the repair's own conventions, i.e. idempotence, and under that meaning it is true; a reader hears correct as the bearer writes it, and under THAT meaning `str(parse('bell hooks').capitalized())` — `'Bell Hooks'` — looks like a counterexample. It is not one, because `bell hooks` is not already-correct in R4's sense. What R4 owes is a disambiguation of "correct", NOT a narrowing to spare deliberately single-cased names: that would be new behavior, and R5's own rationale declines it on the ground that single case leaves the repair nothing to read. Also for that commit, and inert as things stand: R4's boundary row `"Juan McDonald" → capitalized="Juan McDonald"` passes with R5's gate deleted, exactly like the R5 row that was withdrawn above; rewriting it to `capitalized_forced=` makes it discriminate for R4's own subject but still witnesses nothing about the already-correct question. This commit adds R5 and touches R4 only on its pointer line, leaving both defects as found rather than half-fixed by a commit whose subject is something else. - 2026-09-13 #383/#479 — AMENDMENT to the 2026-08-29 override bullet above: the MECHANISM it names is gone, and with it the direction it called surprising. That bullet explained the re-casing gap by v1's initial carve-out — "uppercasing turns every one-letter conjunction into an initial" — and cited `Velasquez y Garcia, Dr. Juan Q.` forced (keeping "y") against the same name uppercased then repaired (giving "Y"). Measured on this branch today, all three spellings agree: forced, uppercased-then-repaired and lowercased-then-repaired each give `Dr. Juan Q. Velasquez y Garcia`, and the space-written `Dr. Juan Q. Velasquez y Garcia` does the same. A one-case name no longer reads a bare capital connective as an initial on shape; the vocabulary decides, and "y" is outside the marked subset (decisions/P8, 2026-09-13). Nothing about R5's gate changed — only what repair finds on the other side of it. THE DIRECTION REVERSED, which is the part worth carrying: uppercase was the worse direction and is now the better one. Recipe, one comparator, both sides — over the deduped `tools/differential/corpus*.jsonl` glob, compare `parse(n).capitalized(force=True)` against `parse(n.upper()).capitalized()` and against `parse(n.lower()).capitalized()`, rendering all seven role fields, and count the names that differ in each direction; run it once on this tree and once against a released wheel installed in a throwaway environment (the tree shadows the wheel otherwise). Measured 2026-09-13 over 1174 names: the 2.3.0 wheel gives 69 upper and 23 lower, this tree gives 11 upper and 18 lower. On the facade surface — `HumanName(n).capitalize(force=True)` read back with `str()`, against the same call on the re-cased spellings — 2.3.0 gives 68 and 20, this tree 10 and 15; the surface still matters for the reasons the 2026-08-29 bullet names (the default render spec omits the maiden name, and `str()` concatenates adjacent roles). The uppercase count collapsed; the lowercase one did not, its remaining members being what the carve-out never explained. The 63/18 and 62/16 figures in the bullet above are a dated snapshot over a 1094-name corpus and are not reproducible today in either direction — do not treat them as this recipe's before-column. rules/R5 needs no matching amendment: it never carried the claim. The 2026-08-29 bullet that follows the override bullet above says so in its own words ("and therefore NOT stated in rules.md"), and R5's Rationale, statement and example lines were re-read on this branch to confirm it. What DID move in the suite is the mirrored pin in tests/test_capitalization.py, where `JUAN Y GARCIA` had been pinned as inherited 1.4.0 behavior ("Juan Y Garcia") and now agrees with its lowercase spelling ("Juan y Garcia"), with a mixed-case control beside it. - 2026-09-23 #492 — DECIDED (Derek, 2026-09-23): the gate reads every token but the SUFFIX-roled ones. R5 says mixed case is evidence the writer cased the NAME deliberately, and a suffix written the way a suffix is written — `III`, `PhD`, `Jr.` — says nothing about how the writer cased the name words. So `juan garcia III` repairs to `Juan Garcia III` where every release, 1.4.0 through 2.3.0, returned it untouched, and `juan garcia PhD`, `JUAN GARCIA Jr.` and `dr. juan garcia III` repair the same way. v1's test for it, `test_capitalization_exception_for_already_capitalized_III`, was a known failure since 2012 — added 2012-10-07 (e2df6937) as a plain failing test against the Google Code tracker's issue 22, marked xfail 2026-09-01 (d02dc41e) — and this change retires the marker and the `_KNOWN_FAILURE` suffix the test carried. Every token not roled SUFFIX stays in the gate text, NICKNAME and MAIDEN included: `jane doe nee SMITH III` and `juan garcia (Bob)` are left alone (pinned in `tests/v2/test_render.py::test_the_gate_leaves_the_suffixes_out`, where a mutant filtering those roles too survived the suite otherwise). A name whose non-suffix tokens are caseless is one-case exactly as the whole name was, so only the suffix repairs (`김민준 phd` → `민준 김 PhD`); a name with NO non-suffix token is one the parse never produces (rules/H4 — measured, `Jr.` is a title, `PhD` a given name, `MD, PhD` family and suffix), so only a synthetic `ParsedName` reaches the empty text, which is one-case, and every suffix repairs to its own fixpoint. Force is unchanged. WHY "USE FORCE" IS NOT THE ANSWER: `force=True` is lossy on correctly cased names — `Danny DeVito` → `Danny Devito`, `Shia LaBeouf` → `Shia Labeouf`, `Anne-Marie DuPont` → `Anne-Marie Dupont`, `Bill deBlasio` → `Bill Deblasio` (re-measured 2026-09-23) — so a batch caller cannot force the lowercase rows without damaging the cased ones beside them. - 2026-09-23 #492 — THE R5 / PARSER SPLIT, decided and recorded rather than left for a reader to discover. The parser's own one-case readings — rules/P8's single-letter connective and rules/S6's case signal at the suffix slot — still count a cased suffix, as Derek's 2026-09-19 comment on #492 and decisions/S6's 2026-09-14 bullet require (the judged token stays in the span: `Jack MA` is mixed because of `MA`); R5's gate does not. So one name can be mixed to the parser and one-case to repair. `john e jones III` is mixed to P8, `III` included, so its `e` reads as a connective and it repairs to `John e Jones III`, while `john e jones iii` is one-case, reads `e` as an initial and repairs to `John E Jones III`; the corpus name `john e jones, III` moves the first way on the default path, where every release left it untouched. The gate follows the parse's suffix call wherever the parse makes one, the ambiguous credential class included, so two shapes outside the corpus move on the default path as well: `jack MA` gives `Jack MA` — S2's lean read `MA` as the credential, and the gate then leaves it out — and `MD, PhD` gives `Md PhD`, an input that is nothing but vocabulary (rules/H4's shape) read as family `MD` and suffix `PhD`, where 2.2.0 and 2.3.0 left `MD PhD` untouched; the family `MD` repairs as a name word, by the item-4 decision under R4. All four strings are pinned by `tests/v2/test_render.py::test_the_gate_and_the_parser_read_a_cased_suffix_differently`. - 2026-09-23 #492 — TITLES STAY EVIDENCE, and the reason is a limit of title REPAIR rather than a claim about titles. Title repair is still plain `str.capitalize()`, and widening the gate to titles would surface its defects on the default path. Measured under `force=True`, where they already show (re-measured 2026-09-23): `Lt.Gov. juan e garcia` → `Lt.gov. Juan e Garcia` (fixed by #838, 2026-10-22: 'Lt.Gov.'), `The Lord of the Universe` → `the Lord of the Universe`, `Dr. med. univ. Margit Popp` → `Dr. Med. Univ. Margit Popp`. So `Dr. juan garcia` is left alone (rules/R5's boundary line). This may move when title repair is trusted. - 2026-09-23 #492 — MEASURED against the parent 4d0680e6 (PR #539) over the deduped corpus glob, the population being the names the old gate declines and the new one admits, the movers those whose default-path string changes (by decisions/R4's MEASURED comparator): 17 and 12 over the parent's 1340 names (the approved spec's "17 move" was the population), 22 and 17 over this change's 1375, the five new movers being R5's own example lines (re-measured 2026-09-24). No forced repair moves, as none can: the gate is never consulted under force. - 2026-09-24 #492 — DECIDED (Derek, 2026-09-24): where repair was not forced, a SUFFIX token written in more than one case is kept as written; one written in a single case is repaired like any other token, and force repairs both, unchanged. It is the gate's own one-case test asked of each suffix on its own (`_render._in_one_case`, one predicate for both), and it is the 2026-09-23 #492 reasoning read to its end: a suffix is left out of the gate because a cased one says nothing about how the writer cased the NAME, and for the same reason a mixed-case one is the writer's spelling of the credential, which repair has no evidence to overrule. THE REGRESSION THAT SURFACED IT, measured by the branch's code reviewer and re-measured 2026-09-24: once the gate stopped counting the suffix, a one-case name with a conventionally mixed-case listed acronym was admitted and the acronym clause wrote the acronym in capitals — `john smith EdD` → `John Smith EDD`, `JANE DOE, DSc` → `Jane Doe DSC`, `juan garcia PsyD` → `Juan Garcia PSYD` — where the parent 4d0680e6 and every release 1.4.0 through 2.3.0 returned those names untouched. They now give `John Smith EdD`, `Jane Doe DSc` and `Juan Garcia PsyD`. The accepted `B.Tech.` → `B.TECH.` on the default path (decisions/R4, 2026-09-23) was the same shape, and its default-path half is superseded (decisions/R4, 2026-09-24). THE COST, accepted: "as written" keeps a garbled spelling with the deliberate one, so `juan garcia Iii` gives `Juan Garcia Iii` on the default path (`Juan Garcia III` forced) and `john smith Mba` gives `John Smith Mba`, where the branch before this decision gave `III` and `MBA`; every release returned both names untouched. A rules/R5 boundary line pins `Iii`. Why not keep only a spelling the vocabulary does not know how to repair: that would re-derive, per word, a judgment about which mixed spellings are deliberate, which is the evidence R5 says repair cannot read. The masks added the same day (decisions/R4) are the other half: they serve the one-case names this rule cannot reach (`john smith psyd` → `PsyD`). No corpus name written before this decision moves by it (measured 2026-09-24 against the branch before it, over this tree's 1375 names: the only movers are the new rules.md lines). Pinned by `tests/v2/test_render.py::test_a_suffix_written_in_more_than_one_case_is_kept_as_written`, core and facade; a mutant dropping the per-token test fails it, `test_the_gate_leaves_the_suffixes_out` and two rules/R5 lines, and one dropping its `force` guard fails it and the forced `EdD` line. derek73-python-nameparser-456b024/docs/design/decisions/README.md000066400000000000000000000571201527303271700243540ustar00rootroot00000000000000# Decisions The why behind the rules: a lightweight ADR log, keyed by rule ID (`P2 — `), by mechanism slug, or by a short slug for cross-cutting and tooling decisions. Each key is one file, named for its key and name and listed in the index below, so every `decisions/P2` reference in code or in the rules resolves to one file. A new entry under an existing key goes in that key's file; a new key is a new file and an index line, and the file opens with `## Standing` and `## Log` (below). The gitignored planning specs are the working medium and die with their branches; this folder is the record. A landing design PR distills its spec's durable residue into its entry files (the checklist is AGENTS.md's "Landing a design"). Entry conventions: - **Dated decision entries**, append-only for DECISIONS, not for text. Each entry cites its issue or PR. A new entry opens with its verdict — a capitalized headline sentence (`C1 IS DECIDED ONCE, AFTER CLASSIFY, …`) ahead of the evidence, so a reader skimming the file reads the verdicts; older entries that open otherwise are left as written, rewording them being none of the edits below. The date is the DECISION's, never the release's (add "shipped in X" alongside if useful), and git author dates outrank remembered ones — two review rounds corrected exactly these two errors. Four kinds of edit to an existing entry, and no others: 1. **A changed or reversed decision is a new entry.** The old entry's verdict and reasons are never edited; the new one names it on a `SUPERSEDES:` line, and the old one gains, right after its head, `SUPERSEDED by #N —`. Reversed IN PART, the old entry's head names the part instead, as decisions/C3's #575 head does (`(where the count runs superseded by #613, decisions/C1)`). Older entries that announce what they replace by opening `SUPERSEDED:` or `SUPERSEDED IN PART:` (decisions/M2's #549, decisions/R4's 2026-09-24) are live, the marker of a superseded entry being `SUPERSEDED by`. This is the part that keeps a reason from being rewritten to fit a later outcome. 2. **A factual error is corrected in place**, with a note saying what was wrong — a date, an attribution, a measurement or a pointer. decisions/C1's 2026-07-12/13 entry, whose parenthetical moved a misdated credit, is the shape. 3. **Compaction, on a stated trigger.** A superseded entry is cut to a tombstone: its date, issue, verdict line and `SUPERSEDED by`, plus whatever it recorded that the superseding entry did not replace — a gap it found standing before its change, or an option it declined that the reversal does not answer, stays beneath the tombstone (decisions/T4's #731); a superseded passage inside a live entry is cut to its decision and what replaced it. A measurement paragraph (what moves, cost, blast radius) is cut to one line naming its population, counts, baseline and PR once its change has shipped in a release. The full text stays in git and in the PR the entry cites. #728 is the worked compaction. A harvested entry (below) keeps its framing line through compaction, a tombstone included, and its measured-vs-remembered markings wherever its measurements survive. A trap #759's review found: a one-line replacement carries each count with the classification the old text gave that count and no other (decisions/S2's #614 line had given a whole grid the classification measured on one slice of it). 4. **A per-word fact that moves into the vocabulary's TOML is REPLACED by a query that finds it** (`uv run python tools/vocab/query.py --issue 661`, `--excluded generation`), not left beside a dated "moved" note, which would keep two copies to drift. Only the per-word sentences go; the decision, its criterion and its measurement stay as written. Sanctioned since 2026-10-18 (decisions/vocabulary-model). - **`## Standing`**, at the head of a file: the one part edited freely. One line per decision in force, in the present tense, ending with the entry that decided it by its exact date and the first issue its head names (`— 2026-10-06 #613`), or by its date alone where the head names none; where several entries share that date and issue, the pointer goes on with the words its entry's head continues with (`— 2026-09-14 (Derek)`, `— 2026-10-18 #718 review`), the longest such continuation winning and a date inside those words being part of them; a pointer with no words names the entry whose head ends there, and one whose words continue no head there fails, since nothing tells a typo from prose, so prose after such a pointer goes after a `.` or `;` (the words end there, or where the next pointer's date begins); where two heads are the same words, the pointer goes on with the words its entry's text opens with (`— 2026-09-13 #383/#479, AMENDS`), a key rather than prose, and so it does where another head of that date and issue continues past those words (decisions/M3's `— 2026-08-26 #335, the MECHANISM`, beside its `#335 (M3, …)` head); and a pointer still naming more than one entry fails as ambiguous (a parenthesized supersession note in a head is not read), plus a `Not standing:` line for entries whose plan was carried out or whose schedule events overtook, its pointers read and failing as a Standing line's do except that they may name a superseded entry — a decision reversed by a later DECISION is marked under 1 instead and an `Open:` line for each open question. It indexes the log and is never a decision itself: a Standing line with no entry behind it, or only a superseded one, fails `tests/v2/test_design_docs.py::test_every_standing_line_points_at_a_live_entry`, and changing a decision is still a new entry, which updates Standing in the same diff. The entries follow under `## Log`. An entry is a log bullet opening with its date, bare or in bold (`- **2026-10-14 #679 — …**`); a file's title founds no entry, so a bundle recorded as undated bullets under a dated title gains, when its block is written, one dated line opening its Log and naming the bundle, its verdict the title's words (`- 2026-09-10 #322/#323 — WHICH CHARACTERS ARE A PERIOD ON CJK TEXT: the bullets below.`). It dates what the title already dates, so it adds no decision and edits no entry. A file with no dated title dates its bundle line by the date its preamble gives the bullets. A bullet decided on another day gains a dated line of its own beside the bundle line, naming the bullet by the words it opens with, since a pointer reads only a bullet that opens with its date (decisions/3-0-reevaluations, decisions/comma-suffix-arc, decisions/cjk-full-stops). The date is the decision's: the one the bullet's own text gives it (`- (A) 2026-08-17 — …`, `Amended 2026-08-23`; a date given for something else, such as when code landed, is not one), or, where it gives none, the author date of the commit that added the bullet. Find that commit with `git log -S --reverse` over `docs/design/`, not over the file, which every bullet entered on 2026-10-17 when #713 split decisions.md, and search a phrase short enough to have fit one line before the 2026-08-16 unwrap (5e2ae60b), a first hit there meaning the phrase crossed a wrapped line. A bullet whose text gives no date and that was written the day its bundle was keeps the bundle line's date, even where that date is older (decisions/comma-suffix-arc's Declined bullets). Where a later decision reversed part of the bundle, its head says which, under edit kind 1. That note is for the reader: a pointer never reads a parenthesized note containing "superseded" (edit kind 1's wording), so a Standing line resting on a bundle is checked by hand against what the bundle's later entries and other files reversed, as for any entry superseded in part. Every decisions file opens with one except the reference lists (below), a new file's cheapest moment to write it being its first entry; the files that predated the rule (C1's block landed 2026-10-19) were given theirs under #762, finished 2026-10-22, and the exempt list that held them while it ran is retired. `test_every_decisions_file_opens_with_standing_or_is_a_reference_list` fails on a file with no block that is not a reference list (the heading on a line of its own; the words quoted in prose do not count). The block stands ahead of every `## Log` heading; a `## Log` before it is a finding, and so is a heading that misspells `## Standing` (a trailing space, a different level), which would otherwise switch the pointer checks off. A decision of another rule's that this log is the record of stays a bullet naming its rule (decisions/C13's line for rules/C2, decisions/P9's for rules/S5); one recorded under another key is an `Elsewhere:` line pointing at that entry, not a bullet. A file of Declined, Excluded, Open, removal or mapping lines is a reference list instead, indexed under the Reference lists heading at the end of the index: it carries no block. A listed file carrying a block or taking a log's shape (a dated entry, a bullet opening with a tag and a date as `- (A) 2026-08-17 — …`, or a dated title) is a finding, as is a listed key outside the five fixed in `tests/v2/design_docs.py`'s `REFERENCE_LISTS` (2026-10-21, #762; fixed 2026-10-22). Whether a file in none of those shapes is a list or a log is a judgment the check cannot make, made in review. 3-0-reevaluations is not one: other files amend its lines without its saying so (decisions/cjk-comma-demotion scopes its FAMILY_COMMA line), the shape a Standing block's `Not standing:` and `Elsewhere:` lines are for. Five files split out on 2026-10-19 (C13, C14, P8, P9, T5) showed the rule was needed: none had a block, though C1's had landed the same day. **Before writing a line, look for what reversed it elsewhere.** A decision can be overturned by an entry in another file, which the log it stands in never mentions; #776 found two lines wrong that way, one while writing and one in review (P9's #578 note, reversed by decisions/S8's #602; P8's subset, widened by decisions/P3's #397), and #762's first batch (2026-10-21) found five entries whose heads did not say what had replaced part of them (decisions/S6's accepted `abdul Smith Jr Ma`, reversed by decisions/S7's #544; decisions/S4's `Doe, Jane nee Smith MA`, by decisions/M2's #601; decisions/H3's `Dr Smith V`, by decisions/S10's #718, caught only in that batch's PR review; decisions/P5's view comparison and decisions/S10's one-case `v`, by their own files' #614 and #721). So grep the whole folder for each issue and example a line cites, parse each example on the tree, and where a later decision reversed part of an entry mark that entry's head under 1, so the log says what Standing says. - **`Retired:`**, for a rule taken out of rules/: a line of its own in the rule's decisions file, opening `Retired:` and naming the date, the issue and what replaced the rule (`Retired: 2026-11-02 #800 — folded into C1.`), in the Standing block where the file has one, beside the dated entry that decided it. The ID is then never reused: `tests/v2/test_design_docs.py::test_no_rule_reuses_a_retired_id` refuses a rule file carrying it, a `Retired:` line in a file keyed by no rule ID, and a line that misspells `Retired:` (bold, a list marker or number, lowercase), which would switch the refusal off for its ID. No rule had been retired when the check landed (2026-10-20), so it guarded nothing on the tree until the first one is; its synthetic case is what shows it bites. - **Harvested entries** (content landed from a session's report) keep their provenance: a "measurements are that session's, spot-checked at landing" framing line, the contributor's measured-vs-remembered markings where they matter, and — the hard-won one — a flagged uncertainty is resolved by reading the source artifact, never by inference from the neighboring argument. - **`Declined:`** — proposals rejected WITH the evidence that killed them. Resolved-as-no is a decision; without a home for it, the next person re-derives the rejected proposal and its measurement. - **`Excluded:`** — standing prohibitions with indefinite lifetime, keyed by vocabulary set: entries that must stay OUT of a wordlist, each with its reason. Distinct from Declined because the failure mode differs — nobody re-derives a rejected proposal, but someone sweeping a wordlist ships the excluded entry as a bug. **A word-level refusal is better as data**: an `excluded` key on the word's TOML entry makes adding the refused reading a build error that quotes the reason, which prose cannot do. So these blocks narrow to CLASS-level prohibitions (a criterion that keeps a whole kind of word out) as their words move, under the exception above; a block whose words have not moved yet stays as it is. - **`Open:`** — unresolved questions as issue links with one-line handles. The ISSUE is canonical; this block never restates it. Two keyings: under a rule ID for questions about the rule, and — like `Excluded:` — keyed to a VOCABULARY SET for contested memberships, the category neither `deviates:` nor `Excluded:` covers: the rule is right and a word's set membership is the question (the shape: `rai` in the suffix acronyms and `swami` absent from the given-name titles, both since answered — see decisions/suffix-acronym-collisions and decisions/indic-honorifics). The blocks keyed by vocabulary rather than by a rule live in decisions/vocabulary-exclusions, headed by the set (or sets) they bind, so a wordlist editor meets the `Excluded:` and `Open:` blocks together; add a new block there, beside any block for the same set. An entry may still record its own keep-outs inline (decisions/generational-words lists SUFFIX_WORDS ones), so search the folder for the set's name too. - **Weighing entries** for contested questions: the options considered, each option's intended effect, and the accepted costs of the option chosen. The costs accepted here are the artifacts the rule lists under its `Accepted:` consequences; the two link by rule ID. ## Index ## Titles & honorifics (H) - [H1 — a title and one name word](H1-title-and-one-name-word.md) - [H2 — the leading-abbreviation title](H2-leading-abbreviation-title.md) - [H3 — the title run's floor](H3-title-runs-floor.md) - [H4 — an input that is nothing but vocabulary still has to name somebody](H4-input-that-is-nothing-but-vocabulary-still-has-to-name-somebody.md) - [H5 — the trailing title run](H5-trailing-title-run.md) - [H6 — a title before a family comma](H6-title-before-a-family-comma.md) - [H7 — a joined name unit carrying title vocabulary](H7-joined-name-unit-carrying-title-vocabulary.md) - [H8 — a salutation ending the given part](H8-salutation-ending-the-given-part.md) - [H9 — the title phrase](H9-title-phrase.md) ## Particles & surname prefixes (P) - [P1 — lone particle fold](P1-lone-particle-fold.md) - [P2 — particles join forward](P2-particles-join-forward.md) - [P3 — connectives](P3-connectives.md) - [P5 — bound given names](P5-bound-given-names.md) - [P6 — the trailing orphan particle](P6-trailing-orphan-particle.md) - [P7 — a one-letter particle against the initial it spells](P7-one-letter-particle-against-the-initial-it-spells.md) - [P8 — a single-letter connective against the initial it spells](P8-single-letter-connective-against-the-initial-it-spells.md) - [P9 — a particle that is also a suffix word, beside a family comma](P9-particle-that-is-also-a-suffix-word-beside-a-family-comma.md) ## Suffixes: generational & credentials (S) - [S1 — a bracketed credential is not a nickname](S1-bracketed-credential-is-not-a-nickname.md) - [S2 — a suffix word ends a name and never opens the string](S2-suffix-word-ends-a-name-and-never-opens-the-string.md) - [S3 — credentials written in joined chunks](S3-credentials-written-in-joined-chunks.md) - [S4 — the ambiguous acronym needs words to spare](S4-ambiguous-acronym-needs-words-to-spare.md) - [S5 — the given part's trailing slot after a family comma](S5-given-parts-trailing-slot-after-a-family-comma.md) - [S6 — the case signal at the suffix slot](S6-case-signal-at-the-suffix-slot.md) - [S7 — the company of an unambiguous credential](S7-company-of-an-unambiguous-credential.md) - [S8 — the credential's trailing run](S8-credentials-trailing-run.md) - [S9 — unlisted words that join the ambiguous class by shape](S9-unlisted-words-that-join-the-ambiguous-class-by-shape.md) - [S10 — the trailing roman numeral](S10-trailing-roman-numeral.md) - [S12 — a credential split across a space](S12-credential-split-across-a-space.md) - [S11 — a suffix word that is also a name](S11-suffix-word-that-is-also-a-name.md) - [S13 — a bracketed member settled by its writing](S13-bracketed-member-settled-by-its-writing.md) ## Nicknames & quoted names (N) - [N1 — the default delimiter pairs](N1-default-delimiter-pairs.md) - [N2 — same-character quotes and shared-character conventions](N2-same-character-quotes-and-shared-character-conventions.md) - [N3 — the lone-word nickname rule](N3-lone-word-nickname-rule.md) ## Maiden names (M) - [M1 — delimited maiden names](M1-delimited-maiden-names.md) - [M2 — the maiden-marker rule](M2-maiden-marker-rule.md) - [M3 — the marker-led clause](M3-marker-led-clause.md) - [M4 — a maiden marker makes the lone name word the family](M4-maiden-marker-makes-the-lone-name-word-the-family.md) ## Commas & structure (C) - [C1 — the suffix-comma decision](C1-suffix-comma-decision.md) - [C3 — counting the words before the comma](C3-counting-the-words-before-the-comma.md) - [C4 — the ambiguous credential class at the comma](C4-ambiguous-credential-class-at-the-comma.md) - [C5 — paired initials after the comma](C5-paired-initials-after-the-comma.md) - [C6 — an unlisted all-caps word needs the name's case contrast](C6-unlisted-all-caps-word-needs-the-names-case-contrast.md) - [C7 — a postnominal part is read before any join](C7-postnominal-part-is-read-before-any-join.md) - [C8 — the listing form's part after the comma](C8-listing-forms-part-after-the-comma.md) - [C9 — a one-character suffix word inside a comma part](C9-one-character-suffix-word-inside-a-comma-part.md) - [C10 — a suffix delimiter parts a suffix run](C10-suffix-delimiter-parts-a-suffix-run.md) - [C11 — what the comma decision reports](C11-what-the-comma-decision-reports.md) - [C12 — a suffix word ending the name before the comma](C12-suffix-word-ending-the-name-before-the-comma.md) - [C13 — a credential opening the part after the comma](C13-credential-opening-the-part-after-the-comma.md) - [C14 — strict mode: a suffix word written as an initial](C14-strict-mode-a-suffix-word-written-as-an-initial.md) - [C15 — a comma with nothing before it](C15-comma-with-nothing-before-it.md) ## Name order (O) - [O1 — East Slavic rotation](O1-east-slavic-rotation.md) - [O2 — Turkic rotation](O2-turkic-rotation.md) - [O4 — positional assignment and declared order](O4-positional-assignment-and-declared-order.md) - [O5 — the lone name word's convention, and reporting it](O5-lone-name-words-convention-and-reporting-it.md) - [O6 — two family names](O6-two-family-names.md) ## Scripts & writing systems (W) - [W1 — unspaced CJK division](W1-unspaced-cjk-division.md) - [W2 — the glued-honorific peel](W2-glued-honorific-peel.md) - [W3 — the writer's divisions are respected](W3-writers-divisions-are-respected.md) - [W4 — script-scoped order](W4-script-scoped-order.md) ## Tokens, initials & punctuation (T) - [T1 — separators, not joiners](T1-separators-not-joiners.md) - [T3 — the interpunct's flank guard](T3-interpuncts-flank-guard.md) - [T4 — the bare letter as an initial](T4-bare-letter-as-an-initial.md) - [T5 — the written shape of an initial](T5-written-shape-of-an-initial.md) ## Ambiguity & tie-breaking (A) - [A1 — the ambiguity report](A1-ambiguity-report.md) - [A2 — the empty name](A2-empty-name.md) ## Rendering & views (R) - [R1 — fields are views of the parsed words](R1-fields-are-views-of-the-parsed-words.md) - [R2 — the all-particles family_base divergence](R2-all-particles-family-base-divergence.md) - [R3 — initials honor the fold](R3-initials-honor-the-fold.md) - [R4 — case repair reads the unjoined mark](R4-case-repair-reads-the-unjoined-mark.md) - [R5 — the case-repair gate](R5-case-repair-gate.md) ## Construction & configuration diagnostics (D) - [D1 — the segmenterless-activation warning](D1-segmenterless-activation-warning.md) - [D2 — construction raises, parse never does](D2-construction-raises-parse-never-does.md) ## Named topics - [3-0-reevaluations — decisions shaped by the v1 shim](3-0-reevaluations.md) - [cjk-comma-demotion — the script shapes are pure, the wrappers are tolerated (2026-09-01, #469)](cjk-comma-demotion.md) - [cjk-full-stops — which characters are a period on CJK text (2026-09-10, #322/#323)](cjk-full-stops.md) - [comma-suffix-arc — #291/#296/#316 (2026-07-26 → 2026-08-01)](comma-suffix-arc.md) - [comparison-surface — why value equality died, and what replaced it](comparison-surface.md) - [deviates-registry — packs stay pure data (option C)](deviates-registry.md) - [differential-ledger — the differential ledger's arcs](differential-ledger.md) - [generational-words — junior and senior in other languages (2026-10-14, #661)](generational-words.md) - [indic-honorifics — the renunciate class and the Indic honorific vocabulary (2026-09-06, #346/#344/#343)](indic-honorifics.md) - [initials-repertoire — is_initial across scripts (#320)](initials-repertoire.md) - [ma-do — ambiguous acronyms by decision](ma-do.md) - [nfd-shapes — shape tests read letters, and a decomposed letter is one letter](nfd-shapes.md) - [normalization-fold — lower(), not casefold(); comparison folds harder](normalization-fold.md) - [parse-cost — what a parse is allowed to cost](parse-cost.md) - [phd-merge — the "Ph. D." split](phd-merge.md) - [render-default — the default view's format](render-default.md) - [review-agent-single-source — the review axes live in one file](review-agent-single-source.md) - [salutation-titles — salutations, offices and ranks for the languages TITLES had none for (2026-10-04, #606)](salutation-titles.md) - [script-table-placement — a recorded reversal](script-table-placement.md) - [suffix-acronym-collisions — the trailing-position collision class, decided (2026-09-07, #342/#454)](suffix-acronym-collisions.md) - [suffix-field-composition — three kinds of thing in one field](suffix-field-composition.md) - [two-input-invariants — checking a rule against something that is not the code](two-input-invariants.md) - [v1-xfail-triage — the eight inherited aspirations, four retired and four kept (three of those eight since fixed, 2026-09-08 and 2026-09-23)](v1-xfail-triage.md) - [vocabulary-collisions — when a word earns the ambiguous marking](vocabulary-collisions.md) - [vocabulary-model — each word described by its readings (2026-10-15 → 2026-10-18)](vocabulary-model.md) ## Reference lists Files of Declined, Excluded, Open, removal or mapping lines rather than a dated log, none of whose lines a later decision reverses without saying so on that line. Each line already says what stands, so they carry no Standing block (the entry conventions above). A file listed here takes no log's shape — no dated entry (a bullet opening with its date; a date inside a Declined or Excluded line is not one), no bullet opening with a tag and a date, no dated title — and the five listed here are the only ones (`REFERENCE_LISTS`, its size asserted): a new decisions file opens with `## Standing` whatever it holds. - [api-churn-declines — do not re-propose without new cause](api-churn-declines.md) - [given-name-titles — deliberately unvalidated](given-name-titles.md) - [legacy-rule-numbers — the old docstring numbering](legacy-rule-numbers.md) - [removed-v1-surface](removed-v1-surface.md) - [vocabulary-exclusions — words kept out of a vocabulary set, and contested memberships](vocabulary-exclusions.md) derek73-python-nameparser-456b024/docs/design/decisions/S1-bracketed-credential-is-not-a-nickname.md000066400000000000000000000043021527303271700331360ustar00rootroot00000000000000# S1 — a bracketed credential is not a nickname ## Standing - The rule says what the escape does: quotes escape as brackets do, and a clause of two or more words is not suffix-shaped unless an ASCII period ends it, so `(PhD MBA)` keeps its nickname reading with nothing reported, as 1.4.0 read it — 2026-10-21 #807. Elsewhere: an ambiguous member alone in the brackets staying a nickname, reported, is decided under decisions/suffix-acronym-collisions (its Standing line ending 2026-09-25 #540; 2026-10-21) and decisions/ma-do's 2026-07-17 entry; a member after the name whose writing settles it reading as the suffix is decisions/S13 (2026-10-26 #810). ## Log - 2026-10-21 (Derek), #807 — S1 IS NARROWED TO WHAT THE ESCAPE DOES, NOT WIDENED TO WHAT ITS THEN HAD SAID. The rule's Then said suffix-shaped bracketed content reads as if written bare, and #807's review measured two places the parser had never done so: a clause of two or more words is never suffix-shaped unless it ends in a period (`John Smith (PhD MBA)` → nickname 'PhD MBA', nothing reported; `(MBA PhD.)` → suffix), the escape asking its question of the whole clause; and every default nickname pair escapes alike, quotes included (`John Smith "MBA"` → suffix, `"MA"` → nickname, reported). Derek: narrow the rule for both rather than change the parser. 1.4.0 reads `(PhD MBA)` as nickname 'PhD MBA' and the ASCII-quoted `"MBA"` and `'MBA'` as suffix 'MBA' (measured 2026-10-21, the wheel run from a scratch dir), so neither is a 2.x move; the typographic and CJK pairs (`«MBA»`, `“MBA”`, `「MBA」`, `(MBA)`) were no delimiters in 1.4.0, which read each as a family name, and escape alike since 2.x added them. The third gap the review found, `John Smith (M.A)` → nickname where bare `John Smith M.A` → suffix (1.4.0: suffix in both), was left to an issue rather than narrowed. Limits the review measured and the rule now states or leaves: the period is tested on the raw clause, so `(PhD MBA. )` stays a nickname, and an ideographic full stop does not count (`(PhD MBA。)`), as decisions/cjk-full-stops records for one word; under a caller-declared maiden pair the same clauses read as maiden names, the report being a nickname's alone. derek73-python-nameparser-456b024/docs/design/decisions/S10-trailing-roman-numeral.md000066400000000000000000000607101527303271700303430ustar00rootroot00000000000000# S10 — the trailing roman numeral ## Standing - A one-letter roman numeral is the generation only with words to spare, counted in name units as S4 counts: `John Smith V` → suffix 'V', while `John V` and `Malcolm X` keep the letter as the family; a title is no word to spare (`Dr Smith V` → family 'V') and a particle chain is one word (`Freiherr von Richthofen V` → family 'von Richthofen V') — 2026-10-17 #718. - A lowercase numeral in a mixed-case name is the generation and reports nothing (`John Smith vi`); a name written in one case is counted as its capitals are — 2026-10-17 #718; 2026-10-18 #721. - The declined numeral reports `suffix-or-name` as the taken one does, assign alone reporting it — 2026-10-18 #718; 2026-10-18 #718's PR review. - The count is not asked in the given part after a family comma: `Smith, Freiherr von Berg V` keeps suffix 'V' — 2026-10-18 #718 review. - Every numeral rule asks one predicate, which reads a numeral of two or more letters as the generation wherever the trailing run holds it (`John Smith VI MD` → suffix 'VI MD'), written with its period too (`Georg VI.` → suffix 'VI.'), and leans a Title-case one in a mixed-case name to the name, reported (`Nguyen Van Vi` → family 'Van Vi'); in the given part after a family comma a numeral of two or more letters is the generation (`Smith, John VI`) and a one-letter one only where listed (`SMITH, JOHN X` → middle 'X') — 2026-10-25 #752. - A one-letter numeral with a credential behind it is a name word and reports (`John Smith V PhD` → family 'V'); requiring all three name fields, or never reading the comma-less letter as the generation, was declined (`John W. Ingram V` keeps suffix 'V') — 2026-10-18 #721's PR review. - A marked letter with no given, middle or family word after it in its part loses its connective report, withdrawn once over final roles, in assemble — 2026-10-18 #722's /simplify pass. - A bare letter in a one-case name is the initial its capital is (rules/T4), and the one-letter numerals are an instance of that rule — 2026-10-18 #723. - The one-letter reading rests on the count, not on the generation being rare, and a one-case part before a capitalized credential reads under C12 (`john j v, PhD`) — 2026-10-19 #761. Elsewhere: a one-letter numeral before a credential split across a space reads as it reads before the one-word spelling (`John Smith V Ph. D.` → family 'V', reported), where 2.3.0 through 2.6.0 read suffix 'V Ph. D.' (1.4.0 suffix 'Ph. D., V') — decisions/S12's 2026-10-25 #752 entry. Open: none in this file. ## Log - 2026-10-17 (Derek), #718 (its ACCEPTED one-case `v` superseded by #721) — A ONE-LETTER NUMERAL NEEDS WORDS TO SPARE, AND THE RULE IS WRITTEN DOWN. Until this entry no rule stated the comma-less numeral at all: the behavior was pinned in tests/v2/cases.py and credited in code comments to rules/S2, whose statement covers the suffix vocabulary and never the shape fork that reads `VI` to `X`, nor the initial veto that sends `V`, `I` and `X` there. The extraction found one reading worth changing. `John V` and `Malcolm X` read suffix with an EMPTY family in every 2.x release (2.0.0 through 2.4.2 measured), alongside 2.0.0's `Johnson PhD` fix; 1.4.0 read family `V` and `X`. Derek: a generational V is mostly archaic, X rarer still and I hardly ever written without a comma (Sr. and Senior are what people write), so a capital letter standing alone behind the given name is far likelier an initial or a one-letter surname. The numeral was never what S2's Accepted clause (an unambiguous suffix is consumed even when that leaves no family) covers, since the fork has always reported itself ambiguous; S4's count is the analogue, and the numeral now asks it. WHAT MOVES. The count is S4's own, in name UNITS, so a particle chain is one word (P2) and a title none. Measured on the shipped change against origin/master at 958d252e, the snapshot moves four corpus names: `John V` and `Mohamad X` (family V and X, as 1.4.0 read them), `John née Jones Smith V` (the clause-free name is `John V`, so the V is no suffix and the clause, which M2's statement takes up to the trailing run the clause-free name reads, keeps it: maiden 'Jones Smith V'), and `Freiherr von Richthofen V`, family 'von Richthofen V': behind a title the chain is the one name word, so the V has no word to spare and the chain takes it as P2 takes any name word, as it already took `Freiherr von Richthofen Ma`. That last is the reading a scratch prototype counting raw pieces did not show, and the one most worth a second look. Four case rows moved with it, their purposes re-pinned on new rows (PR #719). `Sir abdul V` reads given 'abdul V': the V is a name word, and P5's licence behind a given-name title joins it. Nothing with a word to spare moves: `John W. Ingram V`, `King John Alexander V`, `Larry James Johnson I` (a v1 test) keep the suffix and the report. A title is no word to spare, so `Dr Smith V` reads given Smith, family V, which is 1.4.0's reading too, where `Dr. von Richthofen V` keeps suffix V, the particle and the surname being two words there. THE LOWERCASE HALF. A numeral in lower case in a mixed-case name is not how anyone writes an initial, so it is the generation and reports nothing (Derek). Measured before the change, the reports had no rationale: `John Smith v` and `John Smith i` were silent because the listed letters reach the vocabulary path, `John Smith vi` and `John Smith x` reported because the shape fork reports everything it takes, and the one-case `john smith i` reported because the connective tag (P8, #479) makes a one-case `i` an initial. Now the mixed-case lower case is uniformly silent and a one-case name is counted like capitals, `v` excepted (the ACCEPTED paragraph below). `john smith i` keeps its report, by Derek's call. Declined: dropping the shape fork for single capital letters outright. Prototyped in a scratch edit, it moved 26 readings and cost the real generational names the count keeps: `John W. Ingram V` read middle 'W. Ingram', family 'V', and `John van der Berg V` folded the numeral into the particle chain. A variant that dropped the fork for I and X only, on top of the count, moved four more, among them the v1 test `Larry James Johnson I`. ACCEPTED, and recorded in rules/S10: a one-case lowercase `v` is read as the vocabulary reads it, so `john v` still loses its family and `john smith v` reports nothing, where `JOHN V` and `john x` keep their family and `JOHN SMITH V` reports — the listed lowercase letter is taken as a suffix word before the numeral fork is reached, and closing that means touching the suffix-vocabulary path, which is not this change. Not `i`, though it is listed too: a one-case `i` is P8's connective, which classify reads as an initial, so it reaches the fork and is counted (`john i` → family 'i'; `john smith i` reports). And a one-letter numeral with a credential behind it is not the last word and is read as a name word (`John Smith V PhD` → family V, 1.4.0 parity), where `John Smith III PhD` reads suffix 'III PhD'. Recompute: the moved names are this commit's diff of tools/differential/snapshot.jsonl (or `uv run python tools/differential/snapshot.py` on the parent tree with this commit's code). The Declined paragraph's figures are a prototype's, over every cases.py row and snapshot name parsed before and after the patch in one process, on master at 958d252e: its 26 replace `_pieces.is_trailing_numeral_suffix` (`_pieces.reads_as_generation` since #752) with a wrapper returning False for a text that is one capital `I`, `V` or `X` (periods stripped) and the original's answer otherwise; its "four more" apply that wrapper to `I` and `X` alone on top of the shipped count. Both are dated snapshots by Counting claims; nothing re-runs them. - 2026-10-18 (Derek), #718 (its particle-chain report and its stand-down superseded by #718's PR review and #722's last review) — THE DECLINED NUMERAL REPORTS TOO, BOTH DIRECTIONS AS S4'S FORK DOES. The count's first form reported only the generation, so `John V` read family V in silence while `Jack Ma` reports keeping its family; Derek: report both, as `Jack Ma` does. A declined numeral is a pick of the walk, and its branches are taken in three places, so three sites report it (mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE): assign where it stays a lone name word (`John V` — "read as a family name rather than a generational suffix"), the particle chain where it joins the run (`Freiherr von Richthofen V`), and a maiden clause that keeps it as its last word (`John née Jones Smith V`, `abdul nee Smith V`). Each wording is its own, since the acronym pick's ("both a post-nominal and an ordinary name") misdescribes a numeral. ONE STAND-DOWN, FOUND BY INV10 rather than designed: where classify had already reported a one-case connective `i` as an initial, the numeral's report stood down rather than put a second report on the token, INV10's kind pair being left to mean "two readings" everywhere else; #722's last review (below) replaced the stand-down with one withdrawal. - 2026-10-18, #718 review — THE COUNT IS NOT ASKED IN THE GIVEN PART AFTER A FAMILY COMMA. The code review's grid (family-comma and empty-head shapes behind a title-particle, about 558,000 parses) found 2,880 parses where the first form moved a numeral from the suffix into the given or family name with no report: `Smith, Freiherr von Berg V` read given 'von Berg V', `, Freiherr von V` family 'von V', `Smith, St van V` given 'van V', where `Smith, von V` and `Smith, Freiherr John V` kept suffix V. The cause was not a choice: assign's walk over that part never counts the numeral (rules/S4: the given part's trailing slot "is read without it (S5)"), but the particle chain's bound there asks `tail_reading`, which the count had reached, so the chain took a word assign would have read as the suffix. The bound now reads with the count off (`trailing_run_start`, `count_numeral=False`), and all four read as they did on master (2.2.0 onward, since #424; 1.4.0 read the numeral into the name). The class is the one the AGENTS.md gotcha on group-side predictors of the given part names, met from the other side: the predictor did not change, the peel it asks did. The S4 analogue, `Smith, Freiherr von Berg Ma` → given 'von Berg Ma', is master's reading and is left alone here. - 2026-10-18, #718's PR review — TWO OF THE THREE REPORT SITES ARE ONE, AND THE CLAUSE ASKS THE CASE. The test-coverage review mutated each new emitter out in a copy of the tree. Removing the particle chain's numeral report moved no field and no kind — assign's pick loop reported the same `suffix-or-name`, worded from the final role ('read as a family name') — so the chain's branch was only choosing a sentence, and it is gone: a declined numeral stays one of the read's picks, and assign alone reports it, the decision having been the read's and not the chain's. The maiden clause's stand-down on a letter already reported as an initial could not fire at all (classify reads no connective inside a clause), so that call is gone too. And the clause's report on a numeral its take gave up did not ask the case: `Jane Doe nee Smith vi` and `x` reported where every other path had gone silent, the drift mechanisms.md#ONE-PREDICATE-PER-QUESTION names; the take now asks `numeral_settled_by_case` as assign does, `VI` still reporting beside them. Five rows the first draft labelled `fix(#718)` read field for field as 1.4.0 does and are `parity`, as are the two Freiherr rows added here; a restored 1.4.0 reading is parity by the table's own definition (rules/S4's `bare_surname_is_not_a_credential` is the precedent). - 2026-10-18 (Derek), #721 (its classify skip of a letter ending its part superseded by #722's /simplify pass) — A ONE-CASE ONE-LETTER NUMERAL IS THE INITIAL ITS CAPITAL IS, AND A LETTER ENDING ITS PART IS NO CONNECTIVE. Two Accepted gaps of the entries above close. First, `john v` read suffix 'v' with no family while `JOHN V` and `john x` were counted: `V` carries the `initial` tag (a bare capital) and every reader of the trailing run honors it — the suffix veto, the particle chain, the credential run — while a lowercase `v` was a plain listed suffix word. A first prototype (not shipped) routed a one-case `v` to the numeral fork at the walk's last position only, and a lower/upper invariant — every one-case name parses as its capitals, fields and report kinds — showed it too shallow (`john v phd`, `john v, phd` and `freiherr von smith v` still disagreed). The fix that holds tags the letter where its capital is tagged: in a name written in one case, a one-letter roman numeral is an `initial` (classify, reading the NAME-level one-case fact so a maiden clause's `v` is one too, as #516's caps branch reads it). And #721's reviews found the numeral fork's own test of the word in front case-dependent: a bare lowercase letter is never initial-shaped, so `john j v` read suffix 'v' where `JOHN J V` reads family 'V' (on master too). In a one-case name a bare cased letter in front now counts as written as an initial. The invariant is `test_a_one_case_name_reads_as_its_capitals` in tests/v2/test_properties.py, its grid and its two recorded controls stated there: master disagreed on 288 of its 1,575 pairs, the branch on none. Second, Derek on the issue: `john i` reporting `conjunction-or-initial` is wrong "because there's nothing for a conjunction to join to there". P3 already said a connective with nothing to its right connects nothing; classify's report now skips a letter that is the last own word of its part — before a comma, a marker's clause or the end. Such a letter is read as an initial and not reported, and where it is a roman numeral the numeral fork reports `suffix-or-name` instead (`john i`, `HENRY I`, `carod i`); a marked letter that is no numeral is simply silent (`john e`, `jones, john e`). Not all such letters are the walk's last word: a trailing title (rules/H5) is an own word to classify that the walk peels first, so in `john i dr.` the letter keeps P8's report and is still the numeral fork's declined pick. The stand-down the entry above added (a letter already reported as an initial reports nothing new) is what keeps that to one report, and it stays: a first draft of this change deleted it as dead on a mutation run over an intermediate tree, and both reviews then found `john i dr.`, `JOHN I DR.` and `henry i prof.` carrying both reports on one token, where master carries `conjunction-or-initial` alone. Two comma rows go silent rather than move — `rovira, i` and `maier, amy i, jr.` — since the comma path has never reported the numeral (decisions/C9, "Not done"). WHAT MOVES, the snapshot against master at 2bfa9533: six corpus names (`john v` family 'v', `doe, jane v phd do` middle 'v', four in their reports alone) and one rules example deleted with S10's Accepted clause (`john nee smith i`, now maiden 'smith i'); decisions/P3's off switch no longer restores 2.3.0's three one-case readings (that file's #721 entry; PR #722). - 2026-10-18 (Derek), #721's PR review (its assign withdrawal and its kept stand-down superseded by #722's last review and /simplify pass) — A NUMERAL BEFORE A CREDENTIAL IS REPORTED, AND THE TWO-WORD COUNT STANDS. Derek: `john i phd` should report `suffix-or-name` as `john i, phd` does, and `John Smith V PhD` keeps family 'V' with the report — so the Accepted clause's reading stands and only the report is added; the fields do not move (1.4.0 read the same). The part's last name word with nothing but the trailing run behind it is the site: assign, once the trailing read has said what that run is, withdraws classify's connective report on a marked letter there (rules/P8 now says "unless no name word of its part stands to its right") and reports a roman numeral there, worded for what follows it. So `john i dr.` reports the numeral once, `john e phd` and `jose e jr` report nothing, and `john smith i jr` reports `suffix-or-name` where it reported `conjunction-or-initial`. #718's stand-down is kept as a defensive check, and says so: measured on this commit's tree, the full suite and a 1,680-parse trailing-title sweep fail and pass alike without it. Declined: two stricter readings Derek weighed on the PR, both measured over the snapshot corpus. (A) a one-letter numeral is the generation only when given, middle and family are all filled — 11 readings move, among them `Hardie Leannon V`; (B) a comma-less one-letter numeral is never the generation — 18 move, among them `John W. Ingram V`. Both are real people who write the numeral without a comma: #36 (2015) reported `Hardie Leannon V` reading last 'V' as a bug, #136 (2022) came from John W. Ingram V writing his name with and without the comma, and Derek found the same sheriff written `John W Ingram V` and, once, `John Ingram V` (Brunswick Beacon, his candidate profile). The two-word count reads all four as the generation; the rows `a_real_generation_with_a_middle_initial` and `a_real_generation_with_no_middle_name` pin the last two. And the review's other defect: the one-case test of the word in front accepted any one-character cased letter, so `IVAN Ж V` and `JOSÉ É V` moved off their mixed-case reading and a decomposed `é` read otherwise than a composed one. It asks now whether the letter's capital is initial-shaped, which is the rule the docstring states (`john j v` as `JOHN J V`; `é`, `ж` and an uncased word as before). The grid gains a non-ASCII letter in front and a shape-only three-letter numeral; master disagrees on 315 of its 1,872 pairs, the branch on none. - 2026-10-18 (Derek), #722's last review — ONE WITHDRAWAL, AT THE END OF ASSIGN, AND CORRECTIONS TO THE ENTRY ABOVE. Two reviews of the round above found P8's broadened exception ("unless no name word of its part stands to its right") implemented only where the part's last name word is one token on the no-comma path: `john van e phd` (the letter ending a particle chain), `smith, john e phd` (the given part after a family comma) and `, john i phd` kept `conjunction-or-initial`, and `freiherr von i dr.` reached #718's stand-down and lost the numeral's report to the connective's. The withdrawal is one role-based pass at the end of the stage now, over every segment: a marked letter that is its segment's last name-role word with only suffix- and title-roled words after it loses classify's report. The numeral's own report is emitted before it, so one token never carries both, and the stand-down is gone — not "kept as a defensive check", which the entry above said on a measurement that reached no particle chain. The maiden clause gains the numeral report's twin (`Jane Doe nee Smith V PhD`, maiden 'Smith V', reported), the same fork taken a stage earlier. Corrections to the entry above, each measured on this commit's tree: option B moves 19 readings, not 18, and A 11, over the snapshot's 5,711 names with the stated patches to `_pieces.peel_trailing`'s count (A: `k - 3 >= second`; B: the count dropped) — the first figures were over an earlier snapshot plus fifteen named inputs. #36 and #136 do not say their names are real people's: #36 reports 'Hardie Leannon V' (possibly generated test data), #136 a reporter writing 'John W. Ingram, V' and 'John W. Ingram V'. The real person is the sheriff Derek found, written 'John W Ingram V' and 'John Ingram V'. And "1.4.0 read the same" holds for the capital spellings only: 1.4.0 read `john smith v phd` and `john smith i jr` with the letter in the suffix. THE PREMISE BEHIND #718, REVISED. #718's entry quotes Derek that a generational V is "mostly archaic". On this PR he found otherwise: searching shows many people named John Ingram with every variation of generational marker and middle initial, written without a comma unless a credential follows ("MBA", "MD"). Nothing decided here moves — the two-word count reads each of those as the generation — but #718's lone-numeral reading (`John V`, family 'V') now rests on the count alone, a name of two words having no word to spare for a generation, not on the generation being rare. - 2026-10-18, #722's /simplify pass — P8'S "CONNECTS NOTHING" (P3'S BEFORE THE 2026-10-19 SPLIT) IS ASKED IN ONE PLACE, OVER FINAL ROLES. The last two rounds answered rules/P8's "unless no name word of its part stands to its right" twice: classify skipped its report on a letter that was its part's last own word by position (`ends`), and assign withdrew it at the end of the stage for a letter followed only by suffix and title roles. Both go; assemble, which already withdrew this report where the letter was roled SUFFIX or TITLE (rules/A1), withdraws it too where no given, middle or family word follows the letter in its segment — the roles being final there and nothing downstream moving them. Measured, the one site reads everything the two did, and restores one reading the position cut had wrongly silenced: where no clause forms and the marker is read as a name word, a name word does follow the letter, so `jose e nee` (family 'nee') and `smith, e nee smith` report `conjunction-or-initial` again, as master did. The two reports on a numeral a post-nominal follows (assign's, and the maiden take's twin) share one predicate, `_vocab.reads_as_a_reported_numeral`. Open: "a name written wholly in one case says nothing about any letter in it" is applied to the roman-numeral letters only. In a one-case name a bare capital is tagged an initial and a bare lowercase letter is not, unless it is `v`, `i` or `x` (this PR) or a marked connective (P8), so the lowercase and all-caps spellings of a one-case name read differently wherever a reader honors the tag — before #721 too: M4 makes a lone name word before a marker the family but not an initial (`j nee smith` family 'j', `J NEE SMITH` given 'J'), O5 is silenced by one (a lone `j` reports given-or-family, `J` does not), and the connective join defers to one (`jose j y lopez` family 'j y lopez', `JOSE J Y LOPEZ` middle 'J Y', family 'LOPEZ'). Tagging every one-case single letter as its capital is tagged is a behavior change beyond this PR: #723. - 2026-10-18, #723 — THE OPEN QUESTION ABOVE IS ANSWERED, FOR EVERY LETTER. A bare letter in a one-case name is the initial its capital is (rules/T4), and the one-letter numerals are an instance of that rule rather than a case of their own: S10's own one-case sentences are unchanged, and classify's numeral test is gone into T4's. History: decisions/T4. - 2026-10-19 (Derek), #761 — THE RARITY PREMISE LEAVES THE RATIONALE, AND THE SUFFIX-COMMA PRECEDENCE NAMES C12. The Because no longer says the generation a one-letter numeral would name is rare: the one-letter reading rests on the words-to-spare count (#722 above), not on rarity. The Precedence bullet for the part before a suffix comma had said it reads as the same name written without the comma (C1); that was false for a one-case part before a capitalized credential (`john j v, PhD` reads family 'j', suffix 'v, PhD', where `john j v` reads family 'v'; 2.5.0 the same). That reading is C12 deciding the comma and written case being asked of the whole name, credential included, so the part belongs to a name of mixed case (T4); the bullet now says so and carries both shapes as examples. No reading moved. - 2026-10-25 #752 — EVERY NUMERAL RULE ASKS ONE PREDICATE, AND THE ONE-LETTER NUMERAL BEFORE A CREDENTIAL STAYS A NAME. Derek agreed on 2026-10-23 that every rule weighing a numeral asks one predicate, answering for `i` to `x` as this rule's fork does (decisions/vocabulary-model's 2026-10-23 #752 entry, item 8); the sites and the measurements are that file's 2026-10-25 #752, phase 2f entry. For this rule: the fork reads a numeral of two or more letters anywhere in the trailing run, not only as its last word, so `John Smith VI MD` reads suffix 'VI MD' as the listed `III MD` did (#784); a numeral of two or more letters keeps its reading written with a period, as `III.` did (`Georg VI.`, the German ordinal, and GND's two other regnal rows read suffix where they read family); a Title-case numeral in a mixed-case name leans to the name and reports, the third reading agreed (`Nguyen Van Vi`, 1.4.0's fields; PSC's three Vietnamese `Vi` surnames fixed, one `Viii` written as a generation broken). A numeral the vocabulary lists in two or more letters is not weighed: neither an initial in front nor Title case moves it (`John J. III`, `John Smith Iii` keep the suffix). KEPT: the #721 reading of a one-letter numeral before a credential (`John Smith V PhD` → family 'V', reported). Read with the count instead, as the predicate reads one ending the name, it moved 6 tse-donors names, all wrong (a lone letter before `JR` abbreviating a surname), and the committed rows `John Smith V PhD` and its maiden twins; so the peel reads a one-letter numeral only as the walk's last piece, as before. derek73-python-nameparser-456b024/docs/design/decisions/S11-suffix-word-that-is-also-a-name.md000066400000000000000000000343201527303271700316720ustar00rootroot00000000000000# S11 — a suffix word that is also a name ## Standing - A generation word with a `given` or `family` reading is gated as a bare ambiguous acronym is: the generation where the name has words to spare, the name otherwise, either reading reported (`Pedro Alves Neto` → suffix 'Neto', `Ana Neto` → family 'Neto') — 2026-10-25 #752. - The Brazilian agnomens (`filho`, `filha`, `neto`, `neta`, `netto`, `sobrinho`, `sobrinha`) are generation readings of the `agnomen` kind, gated by the count alone, their writing never leaning; `fils` is a `generation` reading whose lower case in a mixed-case name is the generation and whose Title case leans to the name (`Marie Fils` → family 'Fils') — 2026-10-25 #752. - A suffix word that is also a name makes no chunk claim inside a word with interior periods, which reads by its dotted shape as it did before the word was listed: `Pedro M.Filho` keeps family 'M.Filho', and `Pedro Alves M.Filho` reads suffix 'M.Filho' as on master — 2026-10-25 #752. - Only name words in front of a generation word that is also a name count toward its words to spare, and a suffix word there is no company for it: `Pedro Junior Filho` and `Pedro VI Filho` keep family 'Filho' — 2026-10-25 (Derek), #890. - The floor holds before a generation word's case: a lower-case `fils` in a mixed-case name is the generation only where the name keeps a family word, so `Dumas fils` and `Pedro Junior fils` read family 'fils' — 2026-10-25 (Derek), #890, THE FLOOR. - The abbreviations Brazilian filings write for the agnomens are agnomen spellings `same_as` their word, gated as it is: `fh`, `fo`, `filh` (filho), `net`, `nt` (neto), `sob`, `sobr` (sobrinho), each with a `family` reading, counted or declared, so that a name of two words keeps it (`Ana Fo` → family 'Fo'); `fil` and `sb` are refused on the word, and the single letters `F` and `N` are left as initials — 2026-10-27 #843. Open: none in this file. ## Log - 2026-10-25 #752 (its ACCEPTED suffix word in front of an agnomen superseded by 2026-10-25 (Derek), #890) — A GENERATION WORD THAT IS ALSO A NAME IS GATED BY THE COUNT, AND ITS KIND SAYS WHAT ITS WRITING MEANS. Carries out the generation half of the 2026-10-23 #752 entry in decisions/vocabulary-model (phase 2h), replacing decisions/generational-words' 2026-10-23 #843 decision to keep the agnomens family names. An agnomen is no part of the surname a family shares: it marks its bearer against a relative of the same name, as Júnior does, so it ends the name the way a generation does. But each one is a borne name as well, `Neto` a surname (US Census 1,645, Wikidata 276+) and a given name (Wikidata 12+), `Neta` a given name (US SSA 3,226 female births, mostly the Hebrew Neta), `Filho` and `Netto` surnames, counts from `tools/name_usage.py` on 2026-10-25 recorded on the words in `pt.toml` and `pt-BR.toml`, and a plain generation reading leaves a two-word name with no family name. So the reading is gated as an ambiguous acronym is, by the words to spare (rules/S4's count), through the same sites. Case is a cue for a generation word and none for an agnomen: French writes `Alexandre Dumas fils` with the generation in lower case and `Marie Fils` with her surname in Title case, where an agnomen is written in the case of the name around it (`Pedro Alves Neto`, `PEDRO ALVES NETO`). Capitals say nothing for either, a surname being often written in them. The kind carries the difference: an `agnomen` reading and a `generation` reading each beside a name reading derive `SUFFIX_WORDS_AMBIGUOUS`, and the agnomen kind `AGNOMENS` inside it. WHY THE COUNT ALONE FOR THE AGNOMENS, MEASURED. The only spike run before the design was agreed carried S6's Title-case lean, which kept 98 mixed-case ctgov agnomens as names and moved nothing in PSC. The count-only reading was measured on origin/master 225f3d6f against that tree (`--base`, counts and shapes only; Python 3.11): PSC 23 records fixed, 20 broken, 8 changed, every one of them a name of three words or more, so no two-word name moves. The 23 fixed: the filer typed both surnames, the second the agnomen, in the surname box; the 20 broken: the filer typed only the agnomen as the surname, the surname before it in the middle box. Filers split on where the agnomen's companion goes, and the reading follows the larger half. Shown these figures, Derek decided on 2026-10-25: "keep the count-only agnomen gate". The committed corpora move 4 names: `Pedro Alves Filho` gains the agnomen as the suffix, `Marie Fils` gains a report, and CLDR's French sample and its family-first twin gain `fils` as the suffix (cldr_agreement: 4 fields newly agreeing, 1 still wrong but changed). tse-donors: 21,059 of 848,351 filed names move, all of them in capitals, 12,811 where the agnomen had been the whole family name and the word before it now is, 8,198 where the family held more, and 45 a report only; FEC 12, reports only; ctgov 110 entries, 4 a report only, most of the rest the agnomen joining the credentials after a comma (`Neto, MD`, as `Júnior, MD` reads). The spelling pairs: of the 405 tse filed/registry pairs that differ only by a trailing agnomen, master gives both spellings the same last family word in 1, the gate in all 405; over all 205,925 differing pairs, compared given and family word by word with accents folded and an initial matching its word, 604 go from apart to alike and 258 from alike to apart, against the lean spike's 604 and 304. The 258 are mostly pairs master read alike only because the agnomen was the family name in both spellings, and the 46 fewer are the ones the lean's case cue moved. ACCEPTED. A word standing straight before the agnomen counts as a name word, as before an ambiguous acronym: an initial there is left as the family name (`Pedro J. Neto` → family 'J.', 568 tse names, the initial most often abbreviating the surname), a particle as a family name of its own (`Pedro de Neto` → family 'de', one tse name), and a given name that is a generation word (`PEDRO JUNIOR FILHO`) or dotted initials (`PEDRO C.M. FILHO`) join the agnomen in a suffix run that leaves no family name (5 tse names). A three-word name whose surname is an agnomen spelling loses it to the suffix, reported (`Ruth Ann Neta`). The particle chain's report on a member it takes into the name reaches an agnomen inside a name, `PEDRO DE JESUS NETO DE SOUSA` reading as before with a report, as `PEDRO DE JESUS MA DE SOUSA` does. NOT DONE. The filed abbreviations of the agnomens read as the family name, where the registry spelling of the same donor now reads the agnomen as the suffix, so those pairs stay apart: `FH` 229, `NT` 119, `FO` 99, `SOBR` 19, `FILH` 16, `F` 15, `NET` 12, `SB` 11 pairs and fewer of others. Listing them is #843's, short capitals meeting initials and credentials. Recompute: `uv run python tools/differential/snapshot.py --local tse-donors --base ` (and `ctgov-officials`, `fec-individuals`), `uv run python tools/differential/local_agreement.py psc-individuals --base `; the pair counts read tse-donors' records through `tools/differential/local_corpora.py` (`require("tse-donors")`, `read_records(..., text_only=True)`), keep the 205,925 whose filed `name` and `registry` differ, and parse both spellings in each tree, run from that tree's checkout; a pair is alike when its given names and its family names match word by word, each word folded (NFD, combining marks dropped, upper case, edge periods stripped) and a one-letter word matching any word it begins. The 405 are the pairs whose longer spelling, split on spaces in upper case, is the shorter one plus one trailing agnomen, compared on the family's last word. `local_corpora.py pairs tse-donors --agreement` reports in whole percents, which do not move for these counts. - 2026-10-25 (Derek), #890 (its accepted lower-case `fils` superseded by 2026-10-25 (Derek), #890, THE FLOOR) — A SUFFIX WORD IN FRONT OF AN AGNOMEN IS NO WORD TO SPARE. SUPERSEDES: the ACCEPTED sentence of 2026-10-25 #752 on a generation word or dotted initials in front of an agnomen. Derek approved the fix before merge, on the language-packs session's finding: the entry above accepted that a generation word or credential straight before an agnomen joined it in the suffix, leaving no family name, as a cost of 5 tse names, but that count was of capitals only, and the shape is ordinary in mixed case (`Pedro Junior Filho`, `Pedro Júnior Neto`, `Pedro Jr. Filho`, each read with no family name). One word was counted twice: the count took `Junior` as the agnomen's word to spare and the peel then took `Junior` as a suffix; and an unambiguous suffix word in front made the agnomen a credential by rules/S7's company. A generation word that is also a name now counts only the name words in front of it, skipping what the trailing run takes there (a suffix word, a numeral of two or more letters read as the generation, which the review of the fix found still counted in `Pedro VI Filho`, a member of the ambiguous class its writing makes a suffix, `Pedro MA Filho`, and a bare member any of those carries in front of it, `Pedro Junior Ma Filho` and `Pedro Junior do Filho`, the second review's), and keeps no company, so `Pedro Junior Filho` reads given 'Pedro', middle 'Junior', family 'Filho' as master did, while `Pedro Alves Junior Filho` still reads suffix 'Junior Filho'; dotted initials in front read as master did too (`PEDRO C.M. FILHO` → middle 'C.M.', family 'FILHO'), and so does a credential (`Pedro PhD Neto` → family 'Neto'). The count asks a scan that stops at the first word the walk keeps, not the walk itself: asking the walk over the words in front was tried and is exponential on a run of agnomens, quadratic when memoized, and the scaling guard (`agnomens`, `carried_agnomens` in tests/v2/test_benchmark.py) refuses both. The lower-case `fils` of a mixed-case name is settled by its case and not the count, so `Pedro Junior fils` still reads suffix 'Junior fils' (rules/S11's Accepted). - 2026-10-25 (Derek), #890, THE FLOOR — A NAME KEEPS ITS FAMILY WORD BEFORE A GENERATION WORD'S CASE IS READ. SUPERSEDES: the lower-case `fils` sentence of 2026-10-25 (Derek), #890, and the 2026-10-25 #752 entry's reading of `Dumas fils`. Derek: "apply the floor to fils too". In the gate the agreed design orders (decisions/vocabulary-model, 2026-10-23 #752), the floor, a name keeping a word to address, is the first step and the form cue the second, so case may settle a generation word against the name only where the floor already holds. A lower-case `fils` in a mixed-case name had been taken with no word to spare, as an acronym's capitals are, so `Dumas fils` read given 'Dumas' with no family name and `Pedro Junior fils` suffix 'Junior fils'. Both now read family 'fils', as master did; `Alexandre Dumas fils` and `Pedro Alves Junior fils` keep the suffix, `Marie Fils` her family name, and after a comma, where a family is already named, lower case still settles it (`Dumas, Alexandre fils` → suffix 'fils'). The same holds after a comma with no name before it, the review of the fix found: `, Dr. fils` and `(Pe), Dr. fils` had read suffix 'fils' with no family, the comma decision taking the lower case where no family is named, and read family 'fils' now, as master did; and its second review found the head's name-word count too generous for this, a joined salutation (`Mr. and Mrs., fils`) and a lone name word behind a given-name title (`Sir Alexandre, fils`) counting one where no family remains, so at the first slot after a comma a generation word's lower case adds nothing to rules/C4's count, which reads two name words before the comma first: `Dumas, fils` reads given 'fils', family 'Dumas', as master did, and `Alexandre Dumas, fils` suffix 'fils'. An acronym keeps its capitals' lean with no word to spare (rules/S6, `Jack MA`); an agnomen has no case to weigh, so no other gated word changes. Measured with `--base` against master 225f3d6f: no corpus name moves against the commit before. - 2026-10-27 #843 — THE AGNOMENS' ABBREVIATIONS ARE SPELLINGS OF THE AGNOMEN. Brazilian donors' filings (tse-donors) abbreviate the agnomen where the registry spells it, and #843's last comment counted the filed/registry pairs identical but for that last word (2026-10-25): FILHO as FH 230, FO 99, FILH 17, F 15, FIL 10; NETO as NT 122, NET 12, N 6; SOBRINHO as SOBR 19, SB 11, SOB 4. After #890 the registry's agnomen read as the suffix and the filed abbreviation as the family name, so each such pair read apart. Each abbreviation is now an `agnomen` reading `same_as` its word in `pt-BR.toml`, with a `family` reading (dated counts where `tools/name_usage.py` found bearers, `fo` US Census 238 and `net` 118; a declared one otherwise, as `filha` has), so S11's count gates it exactly as it gates the word. Measured with `tools/differential/measure.py --base` against master 6ae585f3: tse-donors moves 1,555 names (fh 801, nt 492, fo 166, sobr 51, filh 21, net 17, sob 7), and 1,481 filed/registry pairs now read alike, none apart; PSC has no move; elsewhere 6 names move fields, five of them no person's name (an organization's acronym after a particle in four JRC-Names spellings, and an FEC committee name) and one an FEC person's name in surname, given-name order ending in NET, which moves from middle to suffix as `Smith, John NET` does, a cost accepted with S11's (rules/S5 reads the given part's last word by its writing); 17 more gain a report. The words also move the comma shapes, which is what a credential reading of `nt` (13 NPPES providers) would want: `John Smith, NT` read given 'NT', family 'John Smith' on master and reads suffix 'NT' now, and `Smith, John NT` and `Smith, John NET` move from middle to suffix; `John Smith, NET` was a suffix already. Two were measured and refused on the word, with the reason as an `excluded` entry: `fil`, a Slavic and Polish surname (US Census 220), which took two UK PSC filers' surname Fil to the suffix, one a regression, against 11 tse moves; and `sb`, also the S.B. degree that 23 ClinicalTrials.gov officials write, which `capitalized(force=True)` recased to `Sb` and which would read as Sobrinho's spelling. The single letters `F` and `N` are not listed: written exactly as an initial is, nothing in the writing tells them apart, so `JOSE SILVA F` keeps family 'F'. `Fº`, the ordinal spelling, appears in no tse-donors pair (filings are in plain capitals), so it was not listed. derek73-python-nameparser-456b024/docs/design/decisions/S12-credential-split-across-a-space.md000066400000000000000000000576441527303271700320370ustar00rootroot00000000000000# S12 — a credential split across a space ## Standing Examples name each field as the default order reads it; under the family-first orders the given and family names trade places. - Two adjacent words of one to five letters each, the first of two or more, whose period-free letters spell unambiguous suffix vocabulary while the two are not both suffix vocabulary on their own, and neither a particle or a connective written without its period, are one credential, a member of the ambiguous class, unless a connective borders the pair (`John Smith Pharm D` → suffix 'Pharm D'; `John Smith M. Sc.` → family 'Sc.'; `Jane Doe de D Junior` → family 'de D'; `John Smith Pharm D and Mary` → family 'D and Mary') — 2026-10-25 #752. - The count decides it as it decides a bare member, and where the count declines it the pair is the two words it is written as: at the end of a name by S4's words to spare (`John Pharm D` → middle 'Pharm', family 'D'), after a comma by C4's count (`John Smith, Pharm D` → suffix; `Smith, Pharm D` → given 'Pharm'), ending the given part after a family comma by S5's reading (`Smith, John Pharm D` → suffix); the part before a family comma merges none (`Smith Pharm D, John` → family 'Smith Pharm D') — 2026-10-25 #752. - Behind two name words with no comma, or after the given word after a family comma, the pair starts S8's run, the one member of the class that does (`John Smith Pharm D Jones` → suffix 'Pharm D Jones', reported) — 2026-10-25 #752. - Where the count takes the pair the reading reports `suffix-or-name`, at the end of a name, after a comma (but where the part already reports as a whole, and in a part past the second comma, which C2 reads), at the end of the given part after a family comma, as a run start and where a maiden clause releases it (`John Smith Pharm D` and `John Smith MB ChB` → suffix, reported, `MB ChB` no longer reporting 'ChB' as a word `mb`'s run absorbed); where the count declines it the two words report what they reported unmerged (`SMITH, SAM E.` → given 'SAM', middle 'E.', no report) — 2026-10-25 #752 (Derek), the report. - The Ph./D. pair stays the unambiguous credential decisions/phd-merge reads, merged by its spelling whatever the vocabulary lists, and outranks a pair overlapping it (`Jane Doe Dr. Ph. D.` → title 'Dr.', suffix 'Ph. D.', where 'Dr. Ph.' folds to the listed 'drph') — 2026-10-25 #752. - Two dotted single letters merge only where #872's `is_cased_paired_initials` says they are no paired initials, in a script without capitals, not in one that writes no abbreviations, and only where their letters spell listed vocabulary, which no default entry does (`John Smith J. R.` → family 'R.'; `John Smith 博. 士.` → family '士.') — 2026-10-25 #752; 2026-10-25 #752, review. - The Ph./D. pair starts S8's run from where it stands, though the walk leaves it out (`John Smith Ph. D. Jones` → suffix 'Ph. D. Jones', reported, #827), and a one-letter numeral in front of it is the name word it is before `PhD` (`John Smith V Ph. D.` → family 'V', reported) — 2026-10-25 #752. - Accepted: a pair opening with a single letter stays two words, so `John Smith, M. D.`, `M D` and `M. Sc.` keep the reversed reading; and a given name and its initial read as the credential their letters spell behind two name words with no comma and ending the given part after a family comma (`JOHN SMITH SAM E.` and `Smith, John Sam E.` → suffix, reported since the report entry below), and case repair then writes them as the acronym (`JOHN SMITH SAM E.` → 'John Smith SAM E.') — 2026-10-25 #752; 2026-10-25 #752, second review. - Case repair writes a pair group merged by its fold's mask, as the one-word spelling is written, on every comma path (`john smith, PHARM D` → 'Pharm D', as `PHARMD` → 'PharmD'), and unforced keeps a pair written in two cases as written, as the one-word spelling is kept (`john smith pharm D`, as `pharmD`) — 2026-10-25 #752, review; 2026-10-25 #752, second review. Open: none in this file. ## Log - 2026-10-25 #752 (its SILENT paragraph superseded by the 2026-10-25 #752 (Derek), the report entry below) — A CREDENTIAL WRITTEN SPLIT ACROSS A SPACE IS ONE WORD, A MEMBER OF THE AMBIGUOUS CLASS THAT THE COUNT DECIDES (phase 2e: item 6 of decisions/vocabulary-model's 2026-10-23 #752 entry, agreed by Derek on 2026-10-23, and #827's reading (A)). `_vocab.split_pair` was the Ph./D. test alone (decisions/phd-merge); it now answers any pair with the piece tag it merges with, "suffix" for Ph./D. and "split" for the rest, and classify asks it once per adjacent pair of a segment, writing the answer on the pair's first word, so group's merge and the comma's reading of its part read a tag rather than each asking the vocabulary. THE PAIR. Each word is one to five letters with or without its period, the two together spell unambiguous suffix vocabulary with the periods removed, and they are not both suffix vocabulary on their own (`MB ChB` merges, `chb` being unlisted; `MD PhD` stays two credentials). The first word has two or more letters: FEC's donors write a middle initial before a trailing salutation (`SMITH, JOHN G. MR.`), which folds to a listed credential. Measured on this change with the length test removed from the predicate and its pre-test (fec-individuals fetched 2026-10-18, against this tree), 4,441 FEC names would move, `G. MR.` reading as `gmr` in 2,577 and `C. MS.` as `cms` in 1,133, against 206 ctgov names it would put right (`M. D.` 128, `M. Sc.` 28). (`A. MS.`, the 2026-10-23 entry's example, folds to `ams`, an ambiguous credential, which the fold test refuses whatever the opening letter.) Two dotted single letters are the one opening letter asked further, by #872's `is_cased_paired_initials` rather than a copy of it: in a script with capitals they are paired initials, the reading rules/S3 gives them joined, so only a caseless script's pair merges, and only where its letters spell a listed word, which no default entry does (the overlap the 2026-10-25 one-predicate entry in decisions/vocabulary-model left to this change to state). Two refusals the measurement below added: a particle or a connective written without its period is no chunk, since it joins name words (`DE D`, a particle and a surname written short, folds to the listed `ded` in a tse-donors name behind `JUNIOR`, the case table's witness being `Jane Doe de D Junior`), while a letter written with its period is an initial, not the connective it spells (`SAM E.` stays a pair, which the count then declines); and where two pairs overlap the Ph./D. pair merges (`Dr. Ph. D.`, whose `Dr. Ph.` folds to the listed `drph`, the case table's `the_split_degree_outranks_a_pair_overlapping_it`). WHERE IT IS READ. As a member, by the count: in a main segment group merges the pair and S2's trailing read decides it, the words to spare S4 asks of a bare member, and a pair the read did not take is split back into its two words before anything else reads the segment, so a declined pair reads exactly as the two words read unmerged; in the part after a comma `_comma.decide` reads the part with the pair merged, C4 counting the name words before the comma, and where that reading does not take the pair as a credential reads the part again as written, so an `MB` the pair hid opens the part again (C13) and `John Smith, MB ChB BAO` keeps the suffix reading it had (the first version left the pair merged there, which made `MB` the given name in 15 ctgov names); in the given part after a family comma group merges the pair only behind the given word, ahead of its joins, so the particle chain stops in front of it and P5's bound join declines it as it declines a suffix word (the second review below), and S5's walk reads it as it reads a member ending the part, its writing saying nothing else. The part before a family comma reads no member as a suffix (`Smith MA, John`), so no pair merges there. The merged pair has no case of its own to lean on, so it reads as a member admitted by shape does (`St van Berg Pharm D` keeps family 'van Berg Pharm D', as `St van Berg X.Y.Z.` does). THE RUN (S8). S8 refuses a member of the ambiguous class as a run start, because a member is a word of the name in its other reading and nothing in front of it says otherwise. A split credential's other reading is two words of the name, a given name and an initial, and the run starts only where two name words already stand in front of it, the words to spare its own count asks: so it is the one member that starts the run, behind two name words with no comma and after the given word after a family comma, as its one-word spelling does (`John Smith Pharm D Jones` → suffix 'Pharm D Jones'). The Ph./D. pair, which the walk leaves out because assign reads it as a suffix wherever it stands, starts the run from where it stands, in front of the first word behind it (#827, reading (A): `John Smith Ph. D. Jones` → suffix 'Ph. D. Jones', reported, as `John Smith PhD Jones` reads; `John Smith Ph. D. MEng` → suffix 'Ph. D. MEng', the reading 2.3.0 gave). And a one-letter numeral with the pair behind it is the name word it is before `PhD`, Derek's 2026-10-25 call on #886 for the one-word spelling (`John Smith V Ph. D.` → family 'V', reported; 2.3.0 through 2.6.0 read suffix 'V Ph. D.' (1.4.0 suffix 'Ph. D., V', the V a suffix too), which rules/S10's Accepted clause had stated). Reading the numeral as the generation before the split spelling alone was the other option; it would keep the two spellings apart for one numeral shape, and no name in the five local corpora measured below moved by it, so the committed rules example is the only reading the choice moves. SILENT. A member reports either reading at the slots S4 names; the split pair reports neither. Where the count takes it, its letters spell a listed credential nobody hesitates over (`John Smith Pharm. D.`); where the count declines it, the two words are a given name and an initial (`SMITH, SAM E.`), read unmerged and reporting nothing, as they did before. The one reading the silence hides is Accepted (ii) below. ACCEPTED. (i) `M. D.`, `M D` and `M. Sc.` open with a single letter and stay two words, so after a comma behind a whole name the name stays reversed: in ctgov-officials (fetched 2026-10-21), of the 205 distinct names whose whole part after the comma is one of the five spellings the 2026-10-23 tally named, 133 still read the part's first word as the given name (`M. D.` 71, `M D` 41, `M. Sc.` 20, and one `Pharm D` behind a one-word head), where 185 did before (`Pharm. D.` read title 'Pharm.' there, and is not among them). (Correcting decisions/vocabulary-model's 2026-10-23 #752 entry, item 6: its "149 of the 359" officials still reversing counted `M. D.` and `M D` alone, and `M. Sc.`, which opens with a single letter, stays unmerged too.) (ii) A given name and its initial behind two name words with no comma read as the credential their letters spell (`JOHN SMITH SAM E.` → suffix 'SAM E.'), in silence; the FEC writes such a name behind a comma and one family word, where C4's count refuses it. MEASURED (2026-10-25, Python 3.11.17, against master 225f3d6f, every local check with `--base` that checkout, so no shared snapshot was read or recorded): the committed snapshot moves 2 names, both rules examples (`John Smith Ph. D. Jones`, `John Smith V Ph. D.`), and gains S12's 27 new examples; every CLDR, GND, Wikidata and conventions agreement record matches. ctgov-officials (fetched 2026-10-21) moves 470 entries: 229 drop a report (`MB ChB` 179, `MB BCh` 48, `DM Sc` 2, read as one credential where `mb` or `dm` started the run and reported the word behind it), 128 move a field, each a split credential read whole where the name had read reversed or the credential as a name word (`Pharm D` and its spellings 110, `Psy D` 5, `Dr PH` 5, `Ed D` 3, `Sc. D.` 1, and `MB ChB` or `MB BCh` behind another degree 4); 111 move only the force-repaired case view, and 2 the unforced view too, a `ph D` written in two cases now kept as written as `phD` is (rules/R5) where master repaired it to 'Ph D'; 339 in all move that view, every one an `MB ChB` or `MB BCh` repaired by its fold's mask as its one-word twin is (`MB Chb` → `MB ChB`, `MB CHB` → `MB ChB`), 228 of them among the reports, and `Ed D` forcing to `ED D` as a bare `Ed` read as a suffix does. (Before the code review's fix (iv) the figure was 357, the case view moving only where the comma's marks reached, and 468 before the second review's.) tse-donors moves 2 (`JUNIOR` typed `JUN IOR`, now the suffix), and its pairs view (`local_corpora.py pairs tse-donors --agreement`) is unchanged; fec-individuals moves 1 (`USN R`, now the suffix run's); nepal-candidates moves none; psc-individuals (1,039,171 records scored) matches the base: 0 fixed, 0 broken, 0 changed, 1,004,782 agreeing on every field. The spike figures in decisions/vocabulary-model's 2026-10-23 entry (352 ctgov, 7 tse, 30 FEC for the first-chunk rule; 391, 34 and 41 with the run start) were a cruder rule's, with no count, no refusal of a particle chunk and no retry after a comma; they were not re-taken. FRAMES (`sys.setprofile` call events over a second parse, py3.11, so one below `_CALL_BASELINE`'s count): `_REFERENCE` (`Dr. Juan0001 de la Vega III`) and `Dr. Juan Q. Xavier de la Vega III`, `John Smith`, `Smith, John` and `John Smith, PhD` cost what they did (304, 363, 148, 157, 201), and so do accented names (`Ngô Bảo Châu` 262) once the review's fix (v) landed. Classify's pair test is C-level up to the call, so a call is made only for a pair that passes the shape, the opening letter's length and the fold's membership: `John Smith Ph. D.` 232 → 233, the 64-link benchmark row unmoved once the length joined the pre-test (`i i` folds to the listed `ii`, and the first version called the predicate for every pair of links, +188). A name holding a member pair pays for its reading: `John Smith Pharm D` 230 → 238, `John Smith, MB ChB` 250 → 266, `Smith, John Pharm D` 253 → 286 (284 before the design-docs review's fix (i), which merges the given part's pair after the joins). Recompute: `uv run python tools/differential/snapshot.py --since ` for the committed snapshot; for each local corpus `snapshot.py --local --base `, and `local_agreement.py psc-individuals --base `; the accepted figure by reading `corpus.jsonl` of ctgov-officials, keeping each name holding a comma whose text after its first comma, stripped, is one of `M. D.`, `M D`, `M. Sc.`, `Pharm D` and `Pharm. D.`, and counting those whose given name opens with that part's first word, on this tree and on the parent. - 2026-10-25 #752, review — SEVEN DEFECTS IN THE FIRST VERSION, found by the PR's code review on a grid of 3,726 texts under the three orders compared against master. (i) A connective beside a pair joined the merged piece into a wider one, which kept its tag and was read whole: `John Smith Pharm D and Mary` read suffix 'Pharm D and Mary' and `John Pharm D and Mary` family 'Pharm D and Mary', where master reads family 'D and Mary'. A pair a connective borders no longer merges, and the two readers of the tag ask for a piece of exactly two words. (ii) Classify's pre-test asked the fold's membership of the Ph./D. pair too, so a configuration without `phd` (`Constants().suffix_acronyms.remove('phd')`) stopped merging it, where v1's fix_phd merged it by its spelling and 1.4.0 and master read suffix 'Ph. D.'; the pair's own pattern now answers ahead of the vocabulary. (iii) C3's count of the part before a family comma merged a member pair, so `MB ChB, PhD` read given 'MB', family 'ChB' where master reads family 'MB ChB'; it reads the part as written now. (iv) Case repair marked a pair's capitals only where the comma's decision bound it, so the same words repaired two ways (`john smith, PHARM D` kept 'PHARM D', `john smith PHARM D` gave 'Pharm D'); render now repairs every suffix pair by its fold's mask (`MB CHB` → 'MB ChB' under 'MBChB'), else in capitals for a listed acronym, and the comma's marks are gone. The force view of `MB BCH` is now 'MB BCh', the mask of 'mbbch', where master kept 'MB BCH' as a caps word. (v) The pre-test let every non-ASCII fold through to the call, a frame per pair of short words in any accented name (`Ngô Bảo Châu` 263 → 269); it looks the composed fold up in C now and costs what master does, and a pair of two suffix words (`MD PhD`) and an opening single letter before a dotted word of two letters (FEC's `A. MS.`) are refused by tags and lengths before the call. (vi) The maiden clause's report read the first word of the piece ending it, so `Jane Doe nee Ed. D.` reported 'Ed.' ending the maiden name; a split pair the clause keeps is read in silence, as everywhere. (vii) The comma form of the given name and initial (`Smith, John Sam E.` → suffix 'Sam E.', where master read middle 'Sam E.') was unrecorded; it is Accepted with the no-comma form, as S5 reads any member ending the given part whose writing says nothing else, and a split pair has no writing to say it. Each fix has a case row or a test that fails on the first version (tests/v2/test_render.py's `test_a_split_credential_repairs_as_its_one_word_twin`, tests/v2/test_facade.py's `test_the_ph_d_pair_merges_whatever_the_vocabulary_says`), run against it before landing. - 2026-10-25 #752, design-docs review (its fix (i), the merge after the joins, superseded by the 2026-10-25 #752, second review entry below) — TWO MORE CODE DEFECTS AND THE RECORD'S CORRECTIONS. (i) In the given part after a family comma group merged the pair ahead of its joins, so a bound given name took it: `Rahman, Abdul MB ChB` read given 'Abdul MB ChB' where master reads given 'Abdul', suffix 'MB ChB', against rules/P5's "The join never absorbs" a suffix word. The pair is merged there only after the joins now (`_group._merge_given_pairs`), so a join that took one of its words leaves it as written (`Smith, abdul Pharm D Jones` reads as master, given 'abdul Pharm'). (ii) Two dotted single letters in a script that writes no abbreviations merged where their letters are listed: `John Smith 博. 士.` read suffix '博. 士.', `김 민준 박. 사.` suffix '박. 사.', 18 default honorifics and titles of two characters folding so. rules/S3 refuses that script's periods, and so does the pair now, which makes true the record's "no default entry". (iii) The record's corrections: the particle refusal's case is `DE D` (`ded`), not `DE A`, whose fold is unlisted, so the rule's example witnessed nothing; the opening-letter test's evidence is the measurement in THE PAIR above, not `A. MS.`; rules/S12 states its precedence over S6's case and S5's report, and rules/S8 its own over a split pair as a Precedence bullet rather than a clause of its Then; decisions/C13, decisions/phd-merge and decisions/S8 are marked where their statements moved, and mechanisms.md's ONE-PREDICATE-PER-QUESTION names the predicate's callers as they are. - 2026-10-25 #752, second review — FOUR DEFECTS IN THE FIXES, found by a review of the two fix commits. SUPERSEDES: the design-docs review entry's fix (i). (i) Merging the given part's pair after group's joins let the particle chain take its first word, since the chain then saw `Pharm` and `D` apart and its bound stopped behind them: `Smith, John van Pharm D` read middle 'van Pharm D' where `Smith, John van PharmD` reads family 'van Smith', suffix 'PharmD', and the bound join took `Pharm` the same way (`Smith, abdul Pharm D` → given 'abdul Pharm'). The pair is merged ahead of the joins again, the chain stopping in front of it, and P5's bound join declines it as it declines a suffix word (rules/P5's Except), which keeps `Rahman, Abdul MB ChB` reading suffix 'MB ChB'; the widened fix had narrowed the chain's stop, the shape AGENTS.md's review paragraph names. (ii) Classify's refusal of two suffix words stood in front of the Ph./D. answer, so a lexicon listing `ph` and `d` stopped the merge (`John Ph. D. Smith` read middle 'Ph. D.' with `Lexicon.default().add(suffix_words={"ph", "d"})`), against "merged by its spelling whatever the vocabulary lists"; it is asked of the other pairs only. (iii) Case repair recased every tagged pair, so of two overlapping pairs (`MB CHB` and `CHB A`, `chba` being listed) the second wrote over the first (`JOHN SMITH MB CHB A` → 'MB CHB A'); it reads only the pair group merged, its second word carrying the merge's `joined` mark. (iv) Unforced, case repair asked each word of a pair whether it is in one case, where the one-word spelling is asked whole, so `john smith pharm D` gave 'Pharm D' while `john smith pharmD` keeps 'pharmD'; the pair is asked whole now and kept as written (rules/R5). And the record: case repair costs no frame on a name holding no pair (`capitalized(force=True)` of `John Smith` 59, as on master; the first fix called the function on every repair, +1), and the given name and initial the count takes (`JOHN SMITH SAM E.`) repair as the acronym their letters spell, recorded with that Accepted reading. Each fix has a case row or a test that fails on the commit it fixes. - 2026-10-25 #752 (Derek), the report — A SPLIT CREDENTIAL THE COUNT TAKES IS REPORTED, AS EITHER READING OF A MEMBER IS. SUPERSEDES: the first entry's SILENT paragraph. Derek, on #891's review: the merged pair is a member of the ambiguous class, which reports either reading (`John Smith MA` reports `suffix-or-name`), and with no report a given name and its initial that spell a credential become one where no caller can find them (`John Smith Ed D`, `Smith, Mary Ed D.`, `JOHN SMITH SAM E.`). So wherever the count takes the pair it reports `suffix-or-name` with one wording ("reads as a credential", a phrasing tests/v2/test_cases.py's field sweep already checks against the field the pair lands in): at assign's placement for the end of a name, the end of the given part and a run start, at `_comma.decide`'s binding for C4's count unless the flip it took part in already reports the part, and at the maiden take's release. A pair the count declines reads as its two words did unmerged and reports what they did, which on master is nothing. The cost is a report on the pairs nobody hesitates over (`John Smith Pharm D`), which the class's report carries for `John Smith MA` too. Measured 2026-10-25 against master 225f3d6f with `--base`, as the first entry: 353 ctgov-officials readings carry the report, 119 of them where master reported no `suffix-or-name` at all, the rest the `MB ChB` and run names whose absorbed-word report it replaces (so the 229 names that dropped a report in the first entry keep the kind, and ctgov moves 469 entries in all: 120 in the ambiguity kinds, each with a field move, 339 only in the force-repaired case view, and the rest in fields or case views without a kind move); the committed snapshot's rules examples gain it where the count takes a pair; tse-donors, fec-individuals, nepal-candidates and psc-individuals read as in the first entry (2, 1, none, matching the base). The field sweep's count goes from 1460 to 1487, nine case rows claiming the report under each order. - 2026-10-25 #752, review of the report — ONE PAIR REPORTED TWICE. After a comma, a pair behind a credential that opens the part (C13) was reported as a word the run absorbed and again as the pair (`Smith, MD Pharm D`, `, MD Pharm D`, `Smith, MD MB ChB`: 168 parses an order over the review's grid), where `John Smith MD Pharm D` reports it once. C13's absorbed report leaves a split pair out, so the pair reports itself (case row `a_split_credential_behind_an_opening_credential_reports_once`). The review also found the Standing line's "after a comma" wider than the code: a part the comma's decision already reports as a whole, and a part past the second comma, carry no pair report, as the Standing line now says. - 2026-10-25 #752, over #890 — RE-MEASURED: #890 merged first and took the ID S11, so this rule and file became S12. Against master b1e5a292 (phase 2h) with `--base`, every figure of the report entry above holds: ctgov-officials 469 entries, tse-donors 2, fec-individuals 1, nepal-candidates none, psc-individuals matching the base (1,004,785 of 1,039,171 agreeing on every field there, #890's own count), and the committed snapshot 2 moves and 28 new rules examples. S13-bracketed-member-settled-by-its-writing.md000066400000000000000000000316231527303271700334250ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/decisions# S13 — a bracketed member settled by its writing ## Standing - A member of the ambiguous class alone in brackets or quotes after the name, its writing settling it as the credential, is the suffix, read in one pass, and reports nothing (`John Smith (MA)`, `John Smith (M.A)`, `Smith, John (MA)` → suffix); any other member stays the nickname S1 reads and reports `suffix-or-nickname`, a dotted member inside the name included (`John Smith (Ed)`, `John Smith (Ma)`, `JOHN SMITH (MA)`, `Jean (M.A) Smith`) — 2026-10-26 #810. - The writing that settles it is read by the rules that read writing: a period inside the word, S3's evidence, whatever case the name is in, and capitals in a name of mixed case, S6's, the case being that of the name's own words and never the clause's, so `john smith (MA)` keeps the nickname where bare `john smith MA` reads suffix. S13's own signal is the position alone — 2026-10-26 #810. - Only a clause after the name is settled, behind its last name word: one in a name with no word of its own keeps the nickname (`Dr. (MA)`, `(M.A)`, where the review of the one-pass commit found both settled), and one with a name word after it keeps the nickname however it is written (`Jeffrey (JD) Bricken`, `Jean 'JD' Smith`, `Smith (MA), John`), while a suffix, a title or a maiden clause after it does not count, however it is written (`John Smith (MA), PhD`, `John Smith (MA) Ph. D.`, `John Smith (MA) Dr.`), asked of the roles the parse read rather than of the following words' tags — 2026-10-26 #810. - Where the rest of the name then reads otherwise than beside a bracketed unambiguous credential in the same place, the member's twin, the difference is an accepted cost, recorded in rules/S13's Accepted bullets and counted by tests/v2/test_parser.py's census (`Jack (MA)` → family 'Jack', where `Jack (PhD)` reads given 'Jack') — 2026-10-26 #810. Elsewhere: P6's attachment after a comma with nothing before it, which the #810 entry's second review left putting a name word behind a settled clause, runs ahead of this rule's pass since decisions/P6's 2026-10-27 #892 entry, so `, Jones (MA) vd` reads nickname 'MA', family 'vd', as its twin `Smith, Jones (MA) vd` does. ## Log - **2026-10-26 #810 (Derek, 2026-10-23; the single signal and the one pass Derek, 2026-10-26) — A BRACKETED MEMBER OF THE AMBIGUOUS CLASS AFTER THE NAME, SETTLED BY ITS WRITING, IS THE SUFFIX, READ IN ONE PASS.** Carries out item 16 of decisions/vocabulary-model's 2026-10-23 #752 entry, as phase 2l of #752. SUPERSEDES: in decisions/ma-do, the 2026-07-17 entry's side effect, in part (a parenthesized `(MA)` or `(DO)` no longer escaping to suffix), for a member after the name whose writing settles it. #810 asked whether `John Smith (M.A)` should read as `John Smith M.A` does, and found `(M.A)`, `"M.A"` and `(MA)` all reading as a nickname where 1.4.0 read the bracketed ones as a suffix; 2.0.0 through 2.6.0 read each as a nickname (measured 2026-10-26 on the wheels, `John Smith (MA)`, `John Smith (M.A)`, `Smith, John (MA)`). The escape had refused every member since `ma` joined the class (decisions/ma-do, 2026-07-17), and #796's 33 new members widened its reach. - **Why the writing, and why after the name.** After the name, brackets hold either a credential or the name the person goes by, and the count of words cannot tell the two apart there, the name being whole without the clause. The writing can: credentials are written in capitals and with their periods, names are not, which is S6's and S3's ground. Inside the name the same member let out of the brackets would read as a middle name, where the brackets more likely hold the name the person goes by: a spike in the design round (2026-10-23, against 638685b4; a dated snapshot, the spike's switch being in no tree) that escaped every member written in capitals or with an interior period moved `JD` from the nickname to the middle name in two committed rows, `Jean 'JD' Smith` and `JEFFREY (JD) BRICKEN`. So a clause with a name word after it is not settled. - **Why the name's case, not the clause's.** The same spike asked the bracketed word's own case and moved 6 FEC names, every one wrong: the corpus is written wholly in capitals, so capitals there settle nothing, and `(ED)` in such a name is the nickname. Asking the case of the name's own words, the fact S6 records for every other site (`settled_by_case`), moves none of them. The clause's own words are no name of their own, so `john smith (MA)` asks the case of `john smith` and keeps the nickname, where bare `john smith MA` reads suffix; the two differ by construction, the bare word being one of the name's own words. - **Why the suffix, not the bare reading.** S1's escape lets suffix-shaped content read as if written bare. A settled member cannot, because since #872 a word of two dotted single letters in a script with capitals reads bare as paired initials (decisions/S3), so `John Smith (M.A)` let out of its brackets would read family 'M.A'. The clause is read as the suffix directly, the brackets having already set it apart from the name's words. The decision is made first in post_rules, where every word's role is read, and the `suffix-or-nickname` report moved there with it, from classify, so that it is raised where the fork is decided. Frames counted with `sys.setprofile`, py3.11, 2026-10-26, against master: `Smith, John` 158 and `John Smith` 149 unchanged; `John Smith (MA)` 214 → 210, `Jack (MA)` 174 → 171; a name holding a clause it does not settle pays 2 to 5 (`John (Bob) Smith` 204 → 206, `John Smith (Ed)` 214 → 219). - **Declined: the twin reading, folding the pipeline again (Derek, 2026-10-26, "the one pass version").** A draft of this PR read a settled member exactly as its twin, a bracketed unambiguous credential in the same place, reads, fields and reports alike: post_rules recorded the settled clauses and the stages folded again from the start, extract escaping each clause and classify giving its word the credential's tag, until no new clause settled (a settled word joining the name's own words can make a one-case name mixed and settle a second clause). Its review rounds found three more places to bring in line with the twin, the part past a second comma among them. It cost a whole parse per settled name (`John Smith (MA)` 214 → 416 frames) and machinery in four stages for a shape that moves 3 real names in the corpora measured here (ctgov-officials'; 35 names hold it, ctgov's 29 and FEC's 6). In one pass the rest of the name is read as beside a nickname, and where that differs from the twin the difference is an accepted cost, recorded in rules/S13's Accepted bullets: a lone name word stays N3's family name (`Jack (MA)`; `Jack (PhD)` reads given 'Jack', reported); a maiden clause after the member stays the maiden name, where the twin's credential run takes it (`John Smith (MA) née Jones`); the case is asked once, so a capitals member settled only once another settled word made the name mixed stays the nickname (`JOHN SMITH (m.a) (MA)`, where the twin `JOHN SMITH (mba) (MA)` reads suffix 'mba MA'); and a part past a second comma is weighed without the member (`Smith, John, X.Y.Z. (MA)` reports nothing where `(MBA)` draws `comma-structure`). tests/v2/test_parser.py's `test_where_a_bracketed_member_reads_otherwise_than_its_twin` records the census of such differences over a grid of comma shapes, heads and the three orders (396 of 1,080 readings on 2026-10-26, by head), so a change that moves the count shows up; the folded draft differed from the twin in none of the settled rows. - **Why post_rules, found by the code review.** The first draft decided in group and called a word after the clause a name word unless it carried the suffix tag. A suffix the tag does not mark then kept the clause a nickname while its listed twin let it through: `John Smith (MA) VI` and `(MA) Ph. D.` against `(MA) III` and `(MA) PhD`, the same for a member read by its capitals (`(MA) DO`), an unlisted dotted credential (`X.Y.Z.`) and `fils`, and a title after the clause (`John Smith (MA) Dr.`, `Doe, Jane (MA) Mrs`). Asking the roles the parse has read answers for every spelling at once. The same draft scanned the whole name once per clause, quadratic in the number of bracketed members (`"(M.A) " * n` 13.7× for 4× the input, against 3.3× after the fix, py3.11, 2026-10-26); the pass now maps clauses to tokens once, and test_benchmark.py's `bracket_members` row guards it, failing on the first draft twice in two runs. - **Recorded as a rule of its own, reading one signal (Derek, 2026-10-26).** The position it reads (after the name) is evidence S1's shape signal does not read (docs/design/AGENTS.md, axis 12), so it is not an S1 clause. What settles the member is the writing, and the writing is other rules' evidence, so S13 cites them rather than reading it itself: the period inside the word is S3's, capitals in a mixed-case name S6's, and the escape from the nickname S1's; its Signal line is the position, a hub of S1, S3 and S6. An earlier draft named the period as a second signal of S13's own, a split candidate at birth. S3 reads two dotted letters bare as initials, and S13's Precedence bullet states that the brackets after the name outrank that reading. The dotted arm has no real-name population in any corpus measured here (none of ctgov-officials' 29 bracketed members, nor FEC's 6, is dotted), so it rests on Derek's decision of 2026-10-23 and on 1.4.0's reading of `John Smith (M.A)` as suffix, not on a measurement. A dotted member left the nickname inside the name is reported, as a tagged one is: the report had keyed on the class tag, which a dotted word in a clause does not carry, while S1's bullet, widened to every member, said it was reported (`Jean (M.A) Smith` reported nothing on master and on the first draft). S1's member bullet keeps the nickname reading for the members this rule does not settle, and its Precedence names this rule. - **Declined: escaping every member.** A spike escaping every bracketed member to its bare reading (2026-10-23, against 638685b4, a dated snapshot like the one above) moved 27 ctgov-officials names and took `Ed` out of the nickname 17 times, `Ed` being the nickname of Edward far more often than a credential written in Title case. - **Left as found by the second review**: one post rule can still put a name word behind a settled clause, P6's attachment after a comma with nothing before it, which runs after the pass that settles (`, Jones (MA) vd`); the shape has no name before its comma and nothing in the corpora writes it. - **Kept from the twin draft's reviews.** A clause is the member alone only where its whole text is the member, as extract's escape reads a clause, so a character tokenize drops beside it (an emoji, a zero-width space, a nakaguro) leaves it the nickname, as its twin `(PhD 😀)` is no escape (`John Smith (MA 😀)`); a first draft counted tokens and settled it. A word read as a name after the clause keeps the member a nickname, as it would sit inside the name (`John Smith (MA) Ma`, family 'Ma'), the position being read on the nickname reading. The draft's comma-structure fix is not needed in one pass: the part past a second comma is tested while the clause is still a nickname, so `Smith, John, Jr. (M.A)` reports nothing, as its twin does. - **Accepted:** the twin differences the declined bullet above lists, in rules/S13's Accepted bullets. - **Measured** (2026-10-26, against master 12384fae with `--base` on a clean checkout, and again on the one-pass tree with a name word required before the clause, 482691a6, against master 13539131, #897, with the same moves): the committed snapshot moves 3 names, each a member read as the suffix in place of the nickname, its `suffix-or-nickname` report gone (`John Smith (MA)`, `Andrew Perkins (JD)`, `John Smith PhD (MA)` → suffix 'PhD MA'); every CLDR, GND, Wikidata and conventions agreement record matches, the locale views included. Of the local corpora, ctgov-officials moves 3 of the 29 names holding one member alone in brackets or quotes (the other 26: 2 already the suffix by a final period, `Ed.`; 23 in Title case, every one `Ed`, 3 of them inside the name and 1 in a one-case name; and 1 in lower case), each a capitals member in a mixed-case name after a credential behind a comma (shapes `Aa A Aa, AA (AA)`, `Aa Aa, AaAaAa (AA)`, `Aa Aa, Aa.AA(AA)`), read suffix, the credential it is. fec-individuals holds 6 such names, all in one-case names, and none moves; tse-donors and ukec-individuals hold none and match; psc-individuals holds none, and its agreement record matches. - **Recompute:** `uv run python tools/differential/snapshot.py` and `cldr_agreement.py` from this tree, `snapshot.py --local --base ` for ctgov-officials, fec-individuals, tse-donors and ukec-individuals, and `local_agreement.py psc-individuals --base `; the population is the names in each corpus holding a delimited clause of one word whose period-free fold is in `Lexicon.default().suffix_acronyms_ambiguous`, by delimiter pair, position, case and spelling. S2-suffix-word-ends-a-name-and-never-opens-the-string.md000066400000000000000000000612001527303271700352350ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/decisions# S2 — a suffix word ends a name and never opens the string ## Standing Examples name each field as the default order reads it; under the family-first orders the given and family names trade places. - The trailing run of a main segment (no comma, or the part before a suffix comma) of three or more pieces is read once, after the `Ph. D.` merge, the maiden take and P3's connective joins and before the particle chain (P2) and the bound given-name join (P5), and binds: neither join reaches a word it took (`John van der Berg Prof.` → title 'Prof.', family 'van der Berg'), and a salutation keeps its connective (`Mr. and Mrs.` → title 'Mr. and Mrs.') — 2026-10-07 #614. - The read counts a particle run and the plain words the chain joins to it as one name word, and a connective join as one, while a word the read weighs (a suffix word, a member of the ambiguous class by listing or by shape, an initial, a roman numeral by shape, a period-marked title) keeps a unit of its own: `Freiherr von Berg X.Y.Z.` keeps family 'von Berg X.Y.Z.', `Juan de la Vega VI` reads suffix 'VI' and `Freiherr von Berg MA X.Y.Z.` suffix 'MA X.Y.Z.' — 2026-10-07 #614; 2026-10-08 #620. - The count is taken as marks over the pieces as written, asking the chain's own run end, never through a merged copy of the pieces — 2026-10-08 #620. - Group merges each run of two or more adjacent particles past the chain's leading position before the read, so the read counts, stops at and absorbs the run as one piece; the merged piece keeps `prefix` and drops `title` — 2026-10-10 #625. - A pick the particle chain takes into the name is reported by the chain, in its own words (`Jan vd Ma` → family 'vd Ma', reported), and assign reports the read's other picks — 2026-10-07 #614. - Where the run is not read once (a segment of fewer than three pieces, or a run with a name piece inside it, as in `John Dr. G.J.`), group re-asks the peel and assign reads for itself, the procedure before #614; and where a name group's joins leave no name word in front of the run, assign reads it again so it keeps one — 2026-10-07 #614. - The numeral fork reads the piece in front of the numeral as written, not the unit's first word (`John van B and Smith X` → family 'van B and Smith X') — 2026-10-08 #620. - The chain reports `particle-or-given` where its run claims something past the particle or is a run the merge made (`Freiherr von der`) — 2026-10-10 #625. Not standing: the split of 2026-10-17 #682, the clause move of 2026-10-19 and the skeleton migration of 2026-10-20 #761, each carried out. Elsewhere: where the name starts behind a title-particle head, which the chain and the count share — decisions/P2's 2026-10-10 (Derek), #624 entry; the bound given-name reserve reading no view — decisions/P5's 2026-10-07 #614 entry. Open: none in this file. ## Log - 2026-10-07 (Derek), #614 (its counting view, a merged copy of the pieces, superseded by #620, below, and the view's name start inside the titles by #624, decisions/P2) — S2'S TRAILING RUN IS READ ONCE, BETWEEN P3'S CONNECTIVE JOINS AND THE PARTICLE CHAIN, AND BINDS. The issue's diagnosis held on the day: the words-to-spare count is taken over pieces and group's joins make pieces, so group re-asked the peel before and after its own joins -- the particle chain's stop and its re-ask and rollback, the bound-given reserve's two views (P5) -- and assign then read the run again. Now the run of a main segment (no comma, or the part before a suffix comma) of three or more pieces is read once, after the Ph. D. merge, the maiden take and P3's connective joins, which do not depend on the run and make the units it counts, and before the particle chain and the bound-given join, which do; the run is split off, the chain and the join group the name in front of it, and the run is put back unchanged (`_pieces.read_trailing_run`). The chain's stop is the end of what is left and the reserve counts the name pieces the read left; assign places and reports the run off the read (`ParseState.tail_reads`, `_assign._placed`). The read COUNTS a particle run and the plain name words after it as the one unit the chain will make of them (`_counting_view`, a copy; rules/P3's own count, mechanisms.md#UNIT-PARTITION), the words both particles and suffix vocabulary straight behind a particle among them, while anything the read weighs stays a word of its own -- suffix vocabulary, the ambiguous class and its shape-only members, roman numerals by shape, initials, period-marked titles. STALE IN THE ISSUE, measured before designing: segment no longer decides the comma or emits S2 reports (#613 moved C1 to `_comma.decide`); assign predicts P1's fold but no longer P6, which it decides since #613; the two comma restart inputs (`John Smith, PhD vd DO`, `PhD DO DO`) no longer reach the chain, decide binding that part first; `credential_run` already applied in one place; and #613's `name_word_count` reads particle and suffix facts at P1's reach and ignores connective and bound-given joins, so S2 could not share it as it stood. The issue's 'licensed by neighbour', read literally, moves about 25 inputs (`JOHN SMITH MA`, six S3 example lines) and still five read as scoped to the duals, so it was not taken; the counting view's particle run is the one part of it the design needed. THE FIRST DESIGN AND THE REVIEW THAT REVERSED IT. Derek decided first that the read WINS OVER EVERY JOIN, connectives included -- prototyped both ways in a scratch copy -- and that implementation passed the suite, the gates and the fingerprint. #614's PR review then found it broke what the corpus never tests: a salutation alone read its connective as the family name (`Mr. and Mrs.` gave title 'Mr. Mrs.', family 'and' -- the H5 chain took 'Mrs.' before P3 could join), a joined leading salutation counted as three words (`Mr. and Mrs. Jr` gave family 'and', `Mr. and Mrs. Do` a spurious report), and the read counted a particle chain's or a connective join's words one by one where rules/P3 says a joined part is one name word wherever another rule counts (`Freiherr von Berg X.Y.Z.` and `Juan y Garcia X.Y.Z.` took the acronym as a credential against every release, while `Freiherr Berg X.Y.Z.` kept it the family). DECIDED (Derek, 2026-10-07): connectives join first, and the read counts the chain's units -- which returns every one of those to master's reading, and most of the first design's 'tail beats connectives' class with them. A second-round finding the counting view itself made: folding the plain words after a particle into its unit swallowed a shape-only roman numeral (`Juan de la Vega VI` read family 'de la Vega VI'), the #610 class nameparser/AGENTS.md's spelling sweep records, so a word the numeral fork reads by shape stays out of the unit. DECIDED BEFORE THE REVERSAL AND STANDING: (1) the read wins over the particle chain and the bound-given join; (2) the consequences below, accepted as a class. WHAT MOVES. One realistic reading: a trailing period-marked title behind a particle chain is the title, `John van der Berg Prof.` reading title 'Prof.', family 'van der Berg' (every release 1.4.0-2.3.0 and 0c54662b: family 'van der Berg Prof.'), which retires the cost rules/H5 recorded as accepted; the six heads of the stress grid below that end in a particle chain move the same way with `Dr.` or `Prof.` behind them. The garbage `Freiherr von Berg Dr. and Ed. Prof.` reads title 'Freiherr von Berg Dr. and Ed.', family 'Prof.' (0c54662b family 'von Berg Dr. and Ed. Prof.'): the title is read off before the chain, which then joins a piece carrying the joined title and reads as a leading title, so assign reads the name again. P5 no longer re-reads the joined view: `abdul J. V` and `Sir abdul J. V` read given 'abdul J.', family 'V' -- 1.4.0's reading restored; 2.0 and 2.1 read suffix 'V', 2.2 and 2.3 kept 'J.' a middle name -- the initial in front suppressing the numeral fork in the one reading taken, three name words standing and the join leaving two; and `Sheik abdul Ma` reports the pick the read weighed (decisions/P5, this date). Reports: a pick the particle chain takes into the name is reported by the chain, in its own words, and group takes it out of the read's picks so assign does not report it again (#614's /simplify: assign first skipped a pick it found already reported, inferring group's decision from group's output), so `Jan vd Ma`, `John de Ma`, `John van der Berg Ma` and `Nguyen Van Lac` report as on 0c54662b. The second review found why it must be the chain: assign words a pick by the role it holds before post_rules, and under a leading title-particle that is the given role the fold then moves, so with assign reporting `Freiherr von Berg Ma` told the caller 'read as a given name' of a word in the family. The chain also reports a member the read never weighed, one it takes short of the run, its scan ending at the next particle (`Freiherr von Berg Ed Do` reports 'Ed'). One report moves for the better: a pick the run keeps is reported as the suffix it reads as, `Juan y Garcia Mc Do MD van Ed` reporting 'Ed' 'read as a suffix' where 0c54662b said the particle chain took it into the name, of a word in the suffix. THE SECOND REVIEW (bfa798fb, 2026-10-07) found four defects in the counting view and the report hand-off, every one on a shape the corpus, the gates and the first stress grid held no instance of, and each now has a case row that dies without its fix. (1) The read started at the end of the leading titles counted in PIECES, applied to the view: a title that is also a particle opens a unit inside the titles, so the index landed past the name and `Freiherr St van Berg MA` read family 'St van Berg MA' (0c54662b suffix 'MA'); the view now returns where the name starts in its own indices -- the first unit opened at or before the titles' end, since a unit opened inside them makes a name of them (SUPERSEDED IN PART by #624, decisions/P2, 2026-10-10: the chain now opens no unit inside the titles) even where it ends short (`Freiherr Freiherr Prof do` lost its report on 'do' until that second half). (2) Assign reported the chain's picks, in the wrong field's words, above. (3) The view made units INSIDE #602's credential run, which hid a title word from the run's title test: `John Smith MD van Secretary Jones` lost title 'Secretary' to the suffix. Past a run starter a unit is now the bare particle run, as the chain's own merge leaves it. (4) A `title` piece-tag exclusion in `_counted_as_one` made garbage differ from 0c54662b (`Freiherr von Mr. and Mrs. X.Y.Z.`) and guarded nothing (3) did not; removed rather than pinned. NOT THE SAME READ AS M2'S: the maiden take reads its own clause-free view of the words before any join, word by word, as rules/M2 states, and its Accepted clause already covers what that does where a particle chain would take the run; S2's read counts the chain's units after P3's joins. Two reads of one name answer different questions -- where the clause ends, where the name's run starts -- and are kept apart rather than made one here. RESIDUALS, the paths that read as before #614: a segment of fewer than three pieces (no join reaches it); a run with a name piece inside it (an H5 title in front of a name word the peel declined, `John Dr. G.J.`, `John van V Dr. V`), which is not split -- group re-asks and assign reads for itself; and a name group's joins leave without a name word in front of the run, which assign reads again so it keeps one -- the same procedure as before #614, not always the same answer, the Freiherr garbage above being the measured case. MEASURED 2026-10-07 against 0c54662b (#618's merge), py3.11, `nameparser.__file__` asserted on each side; #619, merged between, moved no reading. The two #617 harnesses as decisions/P3's 2026-10-06 entry records them (the fingerprint, 78,324 parses; the connective grid, 485,300), and an S2 stress grid built for this entry: the 22 heads `John`, `John Smith`, `John Q. Smith`, `Mary Ann Smith`, `John van Smith`, `Juan de la Vega`, `John van`, `anh van`, `Jan Freiherr von Berg`, `Freiherr von Berg`, `abdul salam`, `abdul rahman al-said`, `Juan y Garcia`, `Josep Carod i Rovira`, `Jane Doe nee van der Berg`, `Dr. John Smith`, `JOHN SMITH`, `john smith`, `Smith`, `de Mesnil`, `Kim Min Jun`, `Ortega y Gasset`, each followed by every one- and two-word tail over the 35 words `PhD MA Ma DO Do do vd VD mc Mc MD Jr Jr. Sr III V V. VI I X.Y.Z. XYZ Ph. D. Esq. Prof. Dr. Sir MBA RN G.J. Ed van and Jones Ed. ba` (`Ph. D.` one word of the list); then 25,000 draws seeded `random.seed(614)`, each `choice(heads)`, `randint(3, 5)` and that many `choice(words)`, joined by spaces; then every head before a comma with every two-word pair behind it, and `Smith, ` and ` , John` for every word; deduplicated, under four policies (default, both family-first orders, strict comma suffixes) -- 81,138 names, 324,552 parses, comparing the seven roles, every ambiguity's kind and detail, `initials()` and `capitalized()`. Results: fingerprint 6 parses move (`John van der Berg Prof.` under six policies), connective grid 1,248 (1,212 the title class, 36 garbage) and stress grid 8,472 (its head-plus-one-word slice under the default policy, 12 parses, all the title class), no corpus or case name among them; five differential gates exit 0 with the one move classified `fix(#614)` (PR #621). COST, `tools/perf/call_count.py` against adf6da88 (#619's merge): `parse` 317 -> 308 frames on 3.11, segments of fewer than three pieces unchanged (decisions/parse-cost, this date; PR #621). - 2026-10-08 (Derek), #620 (its BOUND mark and the gathering of an absorbed particle run superseded by #625, below) — S2'S READ COUNTS THE CHAIN'S UNITS OVER THE PIECES AS WRITTEN, RATHER THAN MERGING A COPY OF THEM. The #614 read above counted through a merged copy of the pieces, a second statement of P2's chain with its own stop list, and its reviews found three defects that were that copy's: an index applied across the two layouts (`Freiherr St van Berg MA`), a unit formed inside #602's run hiding a title (`John Smith MD van Secretary Jones`), and a shape-only numeral folded into a unit (`Juan de la Vega VI`), each patched in the copy. Now `_pieces._chain_units` marks each piece by the unit the chain will put it in -- OPENS, JOINED (a name word the chain joins to the run in front of it) or BOUND (a particle inside the run, a word the run took; retired by #625, this section, 2026-10-10, group merging the run first) -- asking `chain_run_end`, the chain's run end, which `_group`'s chain now calls too. The peel's words to spare (`second_unit`) and #602's run start count in those marks and the peel stops at any piece the chain joins into a run, which is a name word whichever mark it carries; everything else reads the pieces as written, so nothing is hidden, and the copy, its view index and its credential-run gate are gone. WHAT THE COUNT KEEPS. A word the reading weighs -- a suffix word of either kind, the ambiguous class, a word in it by shape, an initial, a roman numeral by shape, a period-marked title word (`_weighed`) -- keeps a unit of its own though the chain may join it, as a position always counted it; only the plain words behind a particle run are JOINED, and a unit with nothing past its opener moves no name start into the titles (SUPERSEDED IN PART by #624, decisions/P2, 2026-10-10: no unit opens inside the titles, so the gate is gone). The first prototype marked every word the chain reaches JOINED, by the chain's own rule alone, and the review found it wrong in two shapes the earlier count read right: `Freiherr von Berg MA X.Y.Z.` kept 'MA X.Y.Z.' in the family (a member the chain would join counted as no word in front of the next), and `St St VI` and `Freiherr St MA` read title 'St St', 'Freiherr St' with the numeral or acronym the whole family (a unit of words the read then takes, which the chain never builds). Both masters read all three as they read now. (#624 since reads `St St VI` and `Freiherr St MA` as title 'St St' / 'Freiherr St' again, by a different route: the chain and the count now share one leading position, the last title-particle, so neither builds the unit -- decisions/P2, 2026-10-10.) The copy's exclusion list had been carrying the count positions always kept -- a member in front counts -- and the marks keep it without the copy. The numeral fork still reads the piece in front of the numeral as written -- reading the unit's first word broke rules/P2's `John van der J. V` -- so `John van B and Smith X` reads family 'van B and Smith X' again, the reading #614's copy had moved. ALSO COUNTED IN UNITS: #602's run start, a particle opening a chain run counting once and its bound particle not at all (`John der la Jr. Prof. Smith` keeps its run, `van la Smith Secretary Jr. Dr. Smith` starts none, as on master). And `credential_run`, on the read's path, gathers adjacent particles into the one absorbed piece the chain makes of them (`Dr. John Smith Esq. RN Mc Mc` reports 'Mc Mc'; SUPERSEDED by #625, this section, 2026-10-10: group merges the run before the read, which absorbs it as one piece); M2's clause-free view reports word by word as it always has. DECLINED, measured: a units test in `tail_reading`'s resume condition. A splice lowers a count only by taking out titles that open units of their own, and with fewer than two units in front of it every piece there is in the first piece's chain run, which the titles behind it join; fuzzed over 372,330 names, the test changed nothing. CLASSIFICATION, found writing this entry's rows: eight case rows #614 added as `parity` did not match 1.4.0 -- they had been compared against master -- and are relabelled with the change each reading came from, bisected (#424, #289, #296, #516, #602 twice; R2 and P2 for two that read so since the 2.0 pipeline). MEASURED 2026-10-08 against 4278693d (#621's merge), with the #614 harnesses and recipe above: fingerprint and connective grid 0 moves, S2 stress grid 12 (three garbage names under four policies, none in the head-plus-one-word slice), five gates exit 0, frames unchanged (decisions/parse-cost; PR #622). /SIMPLIFY (2026-10-08), measured. Each pass of `tail_reading` had rescanned for the second unit from the front, a C-level quadratic no frame guard saw: `Freiherr von` + `Berg `*n + `MA Dr. `*n read 5.77x for 4x the input against master's 4.08x; the lookup is now made once per read and handed to every pass (4.03x), and `test_the_second_unit_is_found_once_per_read` counts it. `_weighed` is pinned to the reading by `test_a_word_the_read_takes_is_never_one_the_count_joined` (no piece marked JOINED is ever in the run the read takes; 320 grid names fail with the test answering False). The gathering of an absorbed particle run reads the BOUND marks, and #602's run start one condition. Grids byte-identical, frames unchanged. Left for a follow-up, each moving readings: merging the tail's particle runs in group before the read, which would retire the BOUND stop and the gathering (80 grid names move, `Dr. mc mc`, and the chain's particle report needs re-keying; done by #625, this section, 2026-10-10, where #624 had already moved those 80); and the name start decided once, the item below. LEFT OPEN, by Derek's scoping: reading a family comma's given part the same way, and deciding the name's start once for a title-particle head (#620's related paths, which change readings). The second was settled by #624 (decisions/P2, 2026-10-10), the first by #623 with a narrower bound rather than a second read (decisions/H5, 2026-10-10). - 2026-10-10 (Derek), #625 — THE TAIL'S PARTICLE RUNS ARE MERGED IN GROUP BEFORE S2'S READ, SO THE READ NEEDS NO BOUND MARK AND NO GATHERING. Since #614 split the trailing run off ahead of the chain, the read sees particles the chain has not merged yet, and #620 re-created the chain's particle-to-particle merge in the reader: `_chain_units` marked a particle standing straight behind another BOUND, the peel stopped at it, and `credential_run` gathered an absorbed run of them into one report. Now group merges each run of two or more adjacent particles past the chain's leading position (`_pieces.merge_particle_runs`, at the trailing site, after P3's joins and before the read) into the piece the chain would open its run with, so the read counts, stops at and absorbs the run as one piece, and the BOUND mark, the peel's stop for it and the gathering are gone. The merged piece keeps `prefix` and drops `title`: a P3 join carries the title tag in from a word in both vocabularies (`St und Berg`), and on the merged run it read the run as a leading title. The title run and the lead are read once, before the merge, and handed to the read, which had asked both again. The chain's PARTICLE_OR_GIVEN report keyed on its run claiming something past the particle (`j > k + 1`), which a merged run has done already though the chain then claims nothing more (`Freiherr von der`); it now keys on that or on the run being one the merge made (`premerged`) -- not on the piece holding several tokens, which a P3-joined phrase does too, and which moved 11,358 reports on the grid below (`Dr. St Do und`). The rule text is unchanged: rules/S2 already counts a particle run, the duals straight behind a particle included, as the one name word the chain makes. What grows is code, not concepts: the read loses a mark and two branches and re-creates nothing of the chain, group gains the merge (about 45 more lines than it removes across `_pieces.py` and `_group.py`, docstrings included). DECIDED (Derek): retiring the two reader-side mechanisms is worth the moves -- every one is garbage input, now read consistently, and none is a released reading restored. MEASURED 2026-10-10 against 505d6dec (#632's merge) over a 395,148-text particle-run grid under the three orders: 774 role moves (258 texts under each order), every one holding the title-tagged `St und` join, none a released reading (over the 258 default-order texts master equals 2.3.0 on 152, the tree on none); the #614 stress grid moves one text's report (`de Mesnil Ma do mc and Ph. D.`), and the #624 grids, every corpus name and case text and the five gates move nothing; with the merge off 30 tests in tests/v2 fail; +3 frames on the reference row (decisions/parse-cost, this date; PR #634). THE REVIEW (2026-10-10) found the change reaching further, every shape garbage, none given a row (Derek), no move going the other way: (1) any P3 join whose left word is in both the title and particle vocabularies, (2) a title-particle head, a particle run, then a title and a dotted word (`Freiherr van der Dr. G.J. MA`), (3) H3's give-back behind a leading title over a run of duals (`Dr. vd Jr. mc mc`); two runs in one name and the read's title run taken before the merge, unheld by any test, gained the rows `two_particle_runs_merge_each` and `a_second_run_of_duals_is_the_family` and `test_the_read_takes_the_title_run_as_written_before_the_merge` (PR #634). - 2026-10-17 #682 — THE RULE WAS SPLIT, AND SO WAS THIS LOG. rules/S2 had grown to seven rules, and each now has its own ID and its own decisions entry, which took the entries written under this key about it: decisions/S3, which shared this log (the roman-chunk retirement and the digit-dotted tokens), decisions/S4 (the count and the slots that report), decisions/S5 (the given part's trailing slot after a family comma, #531, #573, #578), decisions/S6 (written case, #289, with the review and measurement rounds of the #289 + #516 arc), decisions/S7 (company, #544), decisions/S8 (the credential's run, #602, #627, #679) and decisions/S9 (the by-shape class, #516's two switches, #563, #564). What stays here is the trailing read itself (#614, #620, #625), which rules/S2 kept. The entries keep their text as written: one dated before this entry that says rules/S2 means the rule as it then stood. - 2026-10-19 — CLAUSE MOVED OUT: the ambiguous acronym written with its periods left rules/S2 for rules/S3, the rule whose signal that shape is, and S2's rationale lost the sentence that argued for it. S2's own signal is now the one position it states, a suffix word ending the name and never opening the string. Record: decisions/S3's entry of this date. - 2026-10-20, #761 — THE SKELETON MIGRATION MOVED THE TITLE CHAIN'S HISTORY OUT OF THE RULE. rules/S2's Accepted clause on the title chain carried how it got there: that `Dr King Jr` read title `Dr King`, family `Jr` until H3's floor (2026-09-08), that `Dr Smith Jr`, which always read as stated, was the contrast that isolated the cause, and that `Sir Jr` was the spelling the clause first named. decisions/H3's 2026-09-08 bullets and decisions/v1-xfail-triage's `Dr King Jr` bullet hold that record. The rule keeps what is still true: the floor as a Precedence bullet citing H3, and the edge it cannot reach (`Dr Jr`, `Sir Jr`) as an Accepted bullet. The rest of the statement moved whole: its opening-the-string and family-comma boundaries and its ordering against P3, P2 and P5 became Precedence bullets, and its map became the bullets under Then. derek73-python-nameparser-456b024/docs/design/decisions/S3-credentials-written-in-joined-chunks.md000066400000000000000000000474151527303271700330420ustar00rootroot00000000000000# S3 — credentials written in joined chunks ## Standing - A dotted token loses its chunk claim only where every chunk the vocabulary matches is a single ASCII character (`i`, `v`, `2`): `John Smith R.A.I.` then reads suffix 'R.A.I.' by shape, with a report, and `Jack X.Y.I.` keeps family 'X.Y.I.', while `Msc.Ed.`, `JD.CPA`, `Lt.Gov.` and `J.씨` keep their chunk readings; retiring every chunk match was declined — 2026-09-15. - A digit-dotted token (`1.4.2`) loses the chunk claim and is no member of the shape class either, so it reads as a name word, silently (`John Smith 1.4.2` → family '1.4.2') — 2026-09-18. - An ambiguous acronym written with its periods, one after each letter or one after each of two or more letter chunks, counts as unambiguous, and a single trailing period does not (`Jack M.A.` → suffix, `Jack Ma.` → family); the clause stood in rules/S2 until it moved here — 2026-10-19. - Paired initials of a script with capitals (two single letters each followed by a period, the second optional) are no word of the ambiguous class at the trailing slots and read as the name word they are written as, reported, as the switch off reads them: `Ram Bahadur B.K.` → middle 'Bahadur', family 'B.K.'; `Bahadur, Ram B.K.` → middle 'B.K.'; `Ram Bahadur B.K., PhD` → family 'B.K.', suffix 'PhD'. At the first slot after a comma they stay C5's (`John Smith, X.Y. P.Q.` → suffix), behind an unambiguous suffix word they join the run (`Ram Bahadur PhD B.K.` → suffix 'PhD B.K.') while behind an ambiguous or by-shape credential with no unambiguous word in front of it they end the name and take it in (`Jane Doe Ma R.T.` → middle 'Doe Ma', family 'R.T.'; a member its capitals settle starts the run instead, `Jane Doe MA R.T.` → suffix 'MA R.T.', 2026-10-25 #785), and three dotted letters, longer chunks and a caseless script's pair keep the position reading (`John Smith X.Y.Z.`, `John Smith B.Tech.`, `สมชาย ใจดี พ.บ.` → suffix) — 2026-10-24 #864. - A word joined by a hyphen or a slash reads as a suffix where every chunk counts as a suffix as written, listed and not a bare member of the ambiguous class, a chunk's own periods counting as in a word of its own: `John Smith, MD/PhD` → suffix 'MD/PhD', `John Smith MD-PhD` → suffix 'MD-PhD', `John Smith, M.D./Ph.D.` → suffix; one chunk that does not count leaves the word as it was (`John Smith MD-Jones` → family, `John Smith, MA/PhD` → given 'MA/PhD', family 'John Smith'), a word of single-ASCII-character chunks is none (`Jack I-V` → family 'I-V'), a hyphen-joined word written as a hyphenated name is (first chunk in Title case, the rest in Title case or lower case) is a name word (`Kim, Ae-Ra`, `Shin Ae-ra` keep the given and family), while capitals keep the joined reading (`KIM, AE-RA` → suffix), and a joiner standing alone or glued to one side, a dash other than the hyphen-minus and a joined split credential make no joined word — 2026-10-25 #752. Open: none in this file. ## Log - 2026-09-15 (Derek) — THE ROMAN-CHUNK ACCIDENT RETIRES, NARROWLY. rules/S3's chunk rule survives except where every chunk the vocabulary matches is a SINGLE ASCII CHARACTER — the accident exactly. Measured, `{w for w in (L.suffix_acronyms | L.suffix_words) if len(w) == 1}` is `{i, v, 2}` plus the glued CJK honorific tails `{様, 殿, 氏, 군, 님, 씨, 양}`, so "single ASCII character" names the first three and only those — CHARACTER because `2` is a digit and is in the set, ASCII because `씨` is the one that must KEEP its chunk claim (`J.씨`). So `John Smith R.A.I.` and `John Smith J.u.n.i.o.r.` reach the shape class and read suffix by POSITION with the same fields plus the report, `Jack X.Y.I.` reads family where it read suffix (1.4.0's reading), and `Msc.Ed.`, `JD.CPA`, `Lt.Gov.`, `J.씨` keep their chunk-derived readings. The WIDE retirement — any chunk match yielding to the shape — was MEASURED AND REJECTED: it moves three more corpus names, `Doe, John Msc.Ed.` losing a real credential to `middle` and the two `김민준씨, J.씨` rows re-routing their honorific peel, for nothing this design wants. - 2026-09-18 — DIGIT-DOTTED TOKENS MOVE, SILENTLY, AND IT IS ACCEPTED. The narrow retirement's set of single-ASCII-character suffix entries is `{i, v, 2}` — the roman numerals AND the lone digit — so a dotted token whose only chunk match is that digit loses the chunk claim, and the SHAPE class cannot take it either: `period_joined_vocab`'s shape verdict wants every chunk `isalpha()`, which no digit is. Measured against 1f78bef: `John Smith 1.4.2` suffix `1.4.2` → family `1.4.2`; `Smith, 1.4.2` family `Smith`/suffix `1.4.2` → given `1.4.2`/family `Smith`; `John Smith, 1.4.2` given `John`/family `Smith`/suffix `1.4.2` → given `1.4.2`/family `John Smith`. No report at any policy, and the switch does not reach it either way. ACCEPTED: a version string read as a credential was the same accident the retirement removes, and the three readings are pinned as case rows. `John Smith 1.4` was already family at 1f78bef and is unaffected — two chunks, neither a match. docs/release_log.rst's wording is corrected from "a single-character roman numeral" to the digit-inclusive form, which is what the code tests. - 2026-10-17 #682 — SPLIT FROM decisions/S2. rules/S3 was already a rule of its own, but its history pointed at decisions/S2, so the entries above were written there; when #682 split S2 they moved here unchanged but for pointers the move broke. - 2026-10-24 #864 — PAIRED INITIALS ARE NO CREDENTIAL AT THE TRAILING SLOTS. SUPERSEDES, in part, decisions/S9's 2026-09-30 #563 entry: its choice (iii) kept #516's reading at the trailing slots and left the no-comma question open, and this answers it for paired initials. Since 2.4.0 the position reading took a surname written as two dotted initials wherever the name had a word to spare: Nepali surnames are written that way (B.K., K.C., B.C., D.C.), and `Ram Bahadur B.K.` read family 'Bahadur', suffix 'B.K.' where 2.0.0 through 2.3.0 read family 'B.K.' (wheels, measured 2026-10-24). Two dotted single letters is how initials are written, and C5 already reads the shape as a name at the first slot after a comma. THE BOUNDARY is the pair, not every run of single letters: measured with the wider variant (any number of single-letter chunks), 11 committed corpus names move, every one a decided credential example of this rule or S9 (`John Smith X.Y.Z.`, `Q.W.E.R.T.`, `J.u.n.i.o.r.`, `R.A.I.`, `Jane Doe nee Smith X.Y.Z.`) or CLDR's Russian `к.м.н.`, for 3 more Nepal names and 3 more readings in the local ClinicalTrials.gov and FEC corpora together. THE SCRIPT has capitals: CLDR's Thai renderings write degrees as dotted letters (`พ.บ.`, the MD, and `ท.บ.`), a caseless script having no capitals to write initials in, and the unscoped pair moved both into the name, one into the family and one into the middle. THE SEAM: classify writes the shape tag without the class tag, the state the switch off leaves every dotted word in, so every trailing slot (the end of the name, the word ending the given part after a family comma, a maiden clause's end, the part before a suffix comma) reads the pair as a name word and reports the fork through the switch-off branch that already existed; `_comma.decide` restores the class tag in the part it reads, where C5 decides. Narrowing the shape verdict itself, in `period_joined_vocab`, was measured first and declined: the comma's credential run reads the class too, so `John Smith, X.Y. P.Q.` lost its suffix (given 'X.Y.', middle 'P.Q.') and `John Smith, PhD X.Y.` gained a report; holding back only classify's tag, without the restore, cost the same. MOVED IN THE COMMITTED CORPORA: two names, each to 1.4.0's reading, `García Márquez, Ed G.J.` (middle 'G.J.', was suffix) and `De La Cruz, M.J. K.L.` (middle 'K.L.', was suffix); CLDR agreement unmoved. LOCAL CORPORA (counts only), each recorded first from origin/master 638685b4 with `--record --tree`: nepal-candidates, 1,095 of its 237,408 entries move (the romanized names of 1,221 of its 126,981 name pairs), every one to the dotted pair (or a GC, below) as the family name, and 31 of the corpus's ASCII names ending in a two-letter dotted word still do not read it as the family, 20 of them `D.C.`, 8 `G.M.` and 2 `M.C.`, all listed credentials, and one after a comma, C5's; fec-individuals, 293 move, 289 of them a pair ending the given part after a family comma moving from suffix to middle (in one, two pairs, both now middle) and 4 a lone `GC` after a comma moving to the given name; ctgov-officials, 9 of 352,104, 7 a dotted pair ending a name, mostly before a suffix comma, moving from suffix to family, none of them a listed credential, and 2 a `GC`; psc-individuals (local_agreement.py), 4 records progress and 1 regresses, a filed surname of a word and a dotted pair now read with the pair alone as the family; ukec-individuals, none. THE ACCEPTED COST is an unlisted two-letter credential written with its periods after a full name, which reads as the family name, reported: King's Counsel is the case (`John Smith K.C.`), unlisted, and written KC by 4 of 15,214 UK donors (`tools/name_usage.py kc`, the ukec-individuals row); 1.4.0 read the same. A SECOND ACCEPTED COST, found by the design-docs review: only an unambiguous suffix word in front makes the pair the credential run's (`Ram Bahadur PhD B.K.`, `Jane Doe Ph.D. R.T.` → suffix); behind a credential that is itself ambiguous, listed (`MA`, `MEng`) or read by position (`X.Y.Z.`, `XYZ` under EVERYWHERE), the pair ends the name and the credential is taken into it, `Jane Doe MA R.T.` reading middle 'Doe MA', family 'R.T.' where 2.4.0 through 2.5.0 read suffix 'MA R.T.' and 1.4.0 through 2.3.0 the same as now; 1 local entry takes this shape, in nepal-candidates (counted as paired initials of a cased script standing right after a word tagged `vocab:suffix-ambiguous` that is not also a suffix, the review's count; none in the other seven local corpora). An unambiguous word in front of the ambiguous one starts the run, which then takes both (`Jane Doe PhD MA R.T.` → suffix 'PhD MA R.T.', rules/S8). Recorded as a cost rather than changed (Derek, 2026-10-24). `D.C.`, `G.M.` and `M.C.` are listed and so untouched: `dc` stays on 99,172 NPPES providers against 21 Nepal name pairs, and `gm` and `mc` were not weighed here. `gc` left the suffix acronyms in the same change (decisions/suffix-acronym-collisions' 2026-10-24 #864 entry). Pinned by the #864 block of `tests/v2/cases.py` and the `B.K` and `พ.บ.` rows of `test_ambiguous_class_candidate_agrees_with_the_tag`. - 2026-10-19 — CLAUSE MOVED IN: an ambiguous acronym written with its periods, one after each letter or one after each of two or more letter chunks, counts unambiguously, and a single trailing period does not. It stood in rules/S2 until now and is a shape, the periods, which is this rule's signal, so it moved here, one rule per piece of evidence (docs/design/AGENTS.md). It was decided in #296's review (decisions/C8's PR #428 review entry) and widened to letter chunks by #544 (decisions/suffix-acronym-collisions' #544 entry), and no entry under decisions/S2 recorded it. Where earlier entries and code call it S2's period gate, it is this rule's now. - 2026-10-25 #752 — JOINED CHUNKS: A WORD JOINED BY A HYPHEN OR A SLASH IS A CREDENTIAL WHERE EVERY CHUNK IS ONE (phase 2c, item 5 of decisions/vocabulary-model's 2026-10-23 #752 entry, agreed by Derek on 2026-10-23). The chunk reading this rule gives interior periods is extended to the two other joiners a credential is written with, by a stricter test: the periods' ANY becomes EVERY, and a chunk must count as a suffix as written, which a bare member of the ambiguous class (`MA`, `Ed`) does not, because a hyphen also joins names (`Smith-Jones`, `Mary-Jane`) and a slash sits between a name and a credential in typed data. The test is asked where a word's suffix reading as written is asked, so classify's tag, the comma's decision and every predicate testing a word's text read the joined word alike. A chunk is read as a word of its own, so a chunk written with its periods counts (`M.D./Ph.D.`, and `M.A./Ph.D.`, the ambiguous member written so). Not read: a joiner standing alone or glued to one side (`MD / PhD` already read as a run of suffix words; `MD/ PhD` keeps `MD/` as a name word, 10 ClinicalTrials.gov entries, and `MD /PhD` 1), the en dash and other dash characters (no entry joins two listed credentials with one), and `&` (63 entries join two listed credentials with it, left for its own change). MEASURED (2026-10-25, against 2af8da8e; every local corpus recorded from that base with `--tree`): `tools/differential/snapshot.py` moves nothing but the rule's own new examples; `cldr_agreement.py` matches every record; `local_agreement.py psc-individuals` matches its record; tse-donors, 0 of 848,351 entries; fec-individuals, 1 of 1,893,964, a doubled joined credential leaving the middle name for the suffix; ctgov-officials, 1,333 of 352,104, the population being the 1,592 words in it joined by a hyphen or a slash whose every chunk counts (the rest already read as suffixes, in a part past a suffix comma). Of the 1,333, 1,319 are a joined credential after a comma that had been read as the given name, the name reversed around it, now the suffix; 2 a joined credential ending a name without a comma, now the suffix where it was the family; and 12 move a report alone, 11 dropping a comma-structure report (a third part now read wholly as credentials) and 1 a suffix-or-name report. Of the 1,321 whose fields move, 3 also take a neighbouring word into the suffix with a new report, each as the unjoined twin reads: a word after the credential (`John Smith, MD/PhD Candidate` reads as `John Smith, PhD Candidate` does) and, once, a capitals credential ending the part before the comma (rules/C12). The joined words are 82 distinct credentials, every one a degree or certification combination, the commonest `MD-PhD` (494), `MD/PhD` (473), `PhD/MD` (65) and `MS-PT` (53); 68 entries hold a chunk written with its periods, which the 2026-10-23 measurement's letters-only test did not read (its 1,254). COST: a word holding a joiner pays three profiler frames at each site that asks its suffix reading as written, stopping at its first chunk that does not count, and a word holding none pays nothing, the joiners being tested in C first (`John Smith` 150 frames before and after, `Mary Smith-Jones` 156 → 159, `Smith-Jones, Mary-Jane` 169 → 175, py3.11). Pinned by the #752 rows of tests/v2/cases.py and `test_suffix_as_written_reads_a_joined_credential` (tests/v2/pipeline/test_vocab.py), whose refused rows pass on the pre-change tree and whose admitted rows fail on it. - 2026-10-25 #752, review of phase 2c — THREE BOUNDARIES THE FIRST VERSION MISSED, found by the PR's code and design-docs reviews. (i) The single-character exception binds a joined word too: `i` and `v` are suffix words, so `Jack I-V` read suffix 'I-V' with no family where 1.4.0 through 2.6.0 read family 'I-V' and the period twin `Jack I.V.` still does; a joined word now needs a chunk that is not a single ASCII character once its own periods are set aside, as a period-split word's chunks are (`Jack I.-V.` keeps family 'I.-V.', the second review's find against the first fix, which counted the period; `John Smith, MS-V` keeps suffix 'MS-V'), and the Except bullet carries the joined rows. No local corpus entry moved back: the measured 1,333 hold no such word. (ii) ACCEPTED: a Korean given name romanized with a hyphen between two syllables the vocabulary lists as unambiguous credentials, `ae` and `ra` (`Kim, Ae-Ra`, `Shin Ae-ra`), reads as the credential, unreported after a comma and leaving no family without one (`Shin Ae-ra` → given 'Shin', suffix 'Ae-ra'). The spaced spelling already did on the base (`Kim, Ae Ra` → suffix 'Ae Ra', `Shin Ae Ra` → suffix 'Ae Ra'), so this is the vocabulary collision decisions/vocabulary-collisions weighs, which the hyphen had been hiding, not a new reading of the joiner; giving `ae` and `ra` a `given` reading, which would make both ambiguous by derivation (decisions/vocabulary-model's 2026-10-25 #752 entry), moves the spaced spelling too and is left for its own change. The reviewer's scan of every romanized Korean syllable found `ae`, `am`, `ra` and `i` counting as suffixes; `i` alone is no joined credential since (i), and no committed or local corpus entry holds a name-cased hyphenated pair of them. (iii) A split credential joined to another (`MD/Ph. D.`) and the dash characters other than the hyphen-minus join nothing, now stated in the rule. - 2026-10-25 #752 (Derek) — A HYPHENATED NAME'S WRITING IS A FORM CUE. SUPERSEDES the review entry's (ii), which accepted `Kim, Ae-Ra` reading suffix 'Ae-Ra'. Derek: "refuse a hyphen join whose every chunk is Title case and then mark AE-RA as the accepted". A word joined by a hyphen whose first chunk is in Title case (one capital, then lower case, its periods set aside) and whose every other chunk is in Title case or lower case is a name word whatever its chunks are listed as, the design's cue that Title case leans to the name (the 2026-10-23 #752 entry's form-cue table, decisions/vocabulary-model). Its review (2026-10-25) narrowed and stated three edges: a word holding a slash anywhere takes no cue (`Kim, Ae-Ra/Md` keeps the joined reading); a one-letter later chunk in lower case is a name's tail like any other (`Md-v`); and a word holding an interior period is read by its periods first, so the cue never sees it (`Kim, Ae.-Ra.` → suffix by the chunk rule, an unwritten shape). The lower-case tail is this change's widening of Derek's wording, because the Revised Romanization writes a given name's second syllable in lower case (`Shin Ae-ra`), the spelling the code review's case was in; `Ae-Ra` and `Ae-ra` both keep the name. Left to the every-chunk test: a chunk with a capital past its first letter (`PhD-Md` → suffix), a chunk in capitals (`MD-PhD`), a word opening in lower case (`md-phd`, the lower-case writing of a credential) and a slash, which joins no name; no local corpus holds a slash-joined credential in Title case, so the slash's exemption is decided by what the cue is for rather than by a count. ACCEPTED in its place: the capitals (`KIM, AE-RA`, `Kim, AE-RA`) and the lower-case writing (`kim, ae-ra`) still read as the credential, a name in capitals having no Title case for the writing to show; the spaced `Kim, Ae Ra` losing its given name is older than phase 2c and stays the `ae`/`ra` vocabulary question. ACCEPTED too: a credential whose chunks are all typed in Title case reads as a name word, reversing the name after a comma (`John Smith, Phd-Md` → given 'Phd-Md'; likewise `Jr-Md`, `Md-Rn`), the writing being the cue; none of the 34 distinct hyphen-joined credential words in the local corpora is written that way (the review's scan, 2026-10-25). MEASURED (2026-10-25): no word in any local corpus joins two listed credentials with a hyphen or a slash in Title case throughout (ctgov-officials, fec-individuals, tse-donors, psc-individuals, ukec-individuals, jrc-names, nepal-candidates; FEC's 31 joined credentials are all capitals), so the cue gives back none of phase 2c's 1,333 ctgov moves; ctgov-officials still moves 1,862 entries over both phases and fec-individuals 1; the committed corpora move only the rules' and cases' new rows. Pinned by `Kim, Ae-Ra` and `Shin Ae-ra` in tests/v2/cases.py, 1.4.0's readings (wheel), and the cue's rows in `test_suffix_as_written_reads_a_joined_credential`. - 2026-10-25 #785 (Derek, 2026-10-23) — THE ACCEPTED COST OF #864 NARROWS. Paired initials behind a listed member written in capitals in a mixed-case name join the run the member now starts (rules/S8), so `Jane Doe MA R.T.` reads suffix 'MA R.T.', as 2.4.0 through 2.5.0 did, where #864 had read middle 'Doe MA', family 'R.T.'; behind a member the capitals do not settle the pair still ends the name (`Jane Doe Ma R.T.`, `JANE DOE MA R.T.`). The record is decisions/S6's entry of this date. derek73-python-nameparser-456b024/docs/design/decisions/S4-ambiguous-acronym-needs-words-to-spare.md000066400000000000000000000246751527303271700333350ustar00rootroot00000000000000# S4 — the ambiguous acronym needs words to spare ## Standing - At a comma the words-to-spare count reaches the listed ambiguous set as well as the shape class: two name words before the comma make the member the credential whatever its case (`John Smith, Ed`, `john smith, ma` → suffix), one leaves it the given name (`Royce, Ed`) — 2026-09-15. - The comma path reports `suffix-or-name` whenever a member of the class is the first word after the comma, lean or none (`Smith, ma`, `毛泽东, MA`), save a listed member its capitals settle in a mixed-case name, which reports nothing (`Smith, MA`, `John Smith, MA`; 2026-10-25 #785, decisions/S6) — 2026-09-17. - Every decision at these slots reports the existing `suffix-or-name`; no new ambiguity kind was added — 2026-09-14. - Under strict mode, `Smith V., Ma` reads family 'V.', the vetoed word counting as a name word before the comma; left as it is and pinned, and recorded as the reach of the switch rules/C14 does not state — 2026-09-18. - A member ending a maiden marker's clause is read by the same count (`Jane Doe nee Smith MA` → maiden 'Smith', suffix 'MA'); a one-case name before the clause is the accepted exception, the clause hiding the member's case (`JANE DOE nee Smith Ma` → suffix 'Ma', where `JANE DOE Ma` keeps family 'Ma') — 2026-09-19 #533. Open: none in this file. ## Log - 2026-09-15 (spec review) — THE COUNT REACHES THE LISTED SET TOO — one uniform rule for the whole ambiguous class rather than two. `John Smith, Ed` → given John, family Smith, suffix Ed; `john smith, ma` and `JOHN SMITH, MA` move the same way, each being a one-case name that takes the positional rule, which now reaches the comma structure. All three are 1.4.0 RESTORATIONS — measured on the wheel, v1 reads all three as suffixes — so today's tree is what deviates and this restores parity rather than opening distance from it. Corpus population: ZERO (verified by grepping `tools/differential/corpus*.jsonl` for all three; `Royce, Ed` is the corpus's only member of the sibling one-word shape), so no corpus mover count moves and `Davis Royce, Ed` is the case row that carries the decision. - 2026-09-17, corrected — THE COMMA-PATH REPORT TRACKS THE FORK CONSULTED, NOT THE LEAN. A first pass gated the family-comma report on whether `ambiguous_lean` fired, which wrongly silenced `Smith, ma` (all-lower, no lean) and `毛泽东, MA` (caseless, no lean) — the TRAILING slot has never gated its own report on the lean either (a bare ambiguous acronym reported before #289 too, pick declined or not), because the report is about a fork the parse CONSULTED, and the fork is consulted whenever a class member sits in the slot, lean or no lean. Fixed: the guard is class MEMBERSHIP on the first post-comma piece (`vocab:suffix-ambiguous` or `shape:acronym`, never the lean) — `Smith, ma` reports `"'ma' after the comma is also an ordinary name word; read as the given name"` (since #626, 2026-10-08: "read as a given name", the field worded from where the word lands), `毛泽东, MA` reports the same wording for `'MA'`, and `Smith, MA PhD` (a two-piece post-comma part) reports through its FIRST piece regardless of what follows it, exactly as a one-piece part would. The structure decision reports in `segment`, where that branch is taken, and the family-comma reading in `assign`, so one DECISION never reports twice — a second ambiguous token elsewhere is a second fork and reports on its own (`Smith MA, Ed` carries two `suffix-or-name` reports, one per fork, plus a `given-or-family` for the lone remaining word). - 2026-09-14 (Derek), scope corrected 2026-09-18 — NO NEW AmbiguityKind. Every decision at these slots emits the existing `AmbiguityKind.SUFFIX_OR_NAME` — no new kind — including the comma path, which is the first report of the comma's OWN decision (listing or credential run) in the library (`COMMA_STRUCTURE` already reports on the comma path, but as a shape the parse could not recognize, not a fork it called); the comma-quiet policy in `_types.py` and rules/C1 (rules/C11 since #728) gain an exception scoped to the ambiguous class. Switch B off emits nothing. This is narrower than an earlier wording of this entry claimed: an attachment decided AFTER a family comma is a separate fork, P6's, and it has reported on its own since 2.3 — measured on this tree and at master `1f78bef` (2.3.0), `parse("Berg, Jan vd")` gives given `'Jan'`, family `'vd Berg'`, `ambiguities=['suffix-or-name']`, emitted from `_pipeline/_post_rules.py`'s attachment fork, and `AmbiguityKind.SUFFIX_OR_NAME`'s own docstring in `_types.py` already lists that family-comma case. THREE READING SITES, NOT TWO. The trailing suffix slot (`_pieces.peel_trailing`/`listed_lean`) and the post-comma given slot (`_pieces.segment_suffix_reading`) are the two the decision names first; `_vocab.is_wholly_suffix` over a `groups[2:]` TAIL segment is the third, and part of the blast radius rests on it: `Steven Hardman, MD, DO, DDS` loses its `comma-structure` flag only because the lean makes segment 2 (`DO`) wholly suffix. That is the one place this arc QUIETS a report rather than adding one, and it has a case row of its own because a quieted flag is invisible to a reader sweeping for new ones. - 2026-09-18 — A KNOWN ROUGH EDGE UNDER `lenient_comma_suffixes=False`, FOR DEREK'S ATTENTION. Measured: `Smith V., Ma` gives given `Smith`, family `V.`, suffix `Ma`, and `Smith V, Ma` gives given `Smith`, suffix `V, Ma`. The cause is the strict knob rather than the credential class: under strict, the initial-shaped `V.` fails the suffix test, so `name_word_count` sees TWO name words before the comma, flips the structure, and `Smith V.` is then read positionally. The control that shows it is the knob's and not this arc's: `Smith V., PhD` reads given `Smith`, family `V.`, suffix `PhD` under the same knob on master. Consistent, then, and left as it is — recorded with case rows so a future change to either the knob or the count has to notice it. - 2026-09-19 (Derek), #533 (its family-comma examples, which no longer form a clause, and the maiden walk's call of the shared predicate superseded by #601, decisions/M2) — THE CLASS'S LAST SILENT TRAILING POSITION IS CLOSED, AND #531'S READING NOW LIVES IN ONE PLACE. The slot list in rules/S2 gains the trailing slot of a maiden marker's clause, and #S3's enumeration gains it with the by-shape spellings; the rule that governs what the clause does with those words is M2's, and decisions/M2 is where its reasoning lives. The gap decisions/S5's #531 bullet of 2026-09-18 left open as out of scope is the one this closes, from both sides: `Doe, Jane nee Smith MA` now gives maiden 'Smith' with suffix 'MA' and reports, and `John Smith nee Jones R.A.I.` gives suffix 'R.A.I.' again — a RESTORATION rather than a change, since 2.3.0 read it that way (measured on the wheel) and this unreleased cycle moved it into the maiden name when #516 took dotted tokens out of the certain-suffix class, where no corpus file held the name and no gate could see it. Two things belong here rather than under M2. FIRST, `credential_at_the_given_slot` in `_pipeline/_pieces.py` is now where #531's reading of a class member ending the given part is written, with two callers — assign's walk over that part, and the maiden walk's second check over the name a take would leave. Spelling it twice is precisely the "condition written to match it" mechanisms.md#ONE-PREDICATE-PER-QUESTION names, and the drift would have been silent, since each site's own tests would have gone on passing. It is a text-and-tags question, which is what puts it in `_pieces` rather than beside either caller — the destination follows the LAYER, not the topic. It costs one frame PER MEMBER asked at that slot: measured 2026-09-19 against 2f57ff21 per `Parser.parse`, `Doe, John MA` goes 310 → 311 and `Doe, John MA Ma MA`, which asks four times, 439 → 443, while a name with no member there never reaches it and pays nothing. The reference band does not move (412/449 on `uv run python tools/perf/call_count.py`), no test pins 310, and Derek took the trade rather than keep two conditions in step across two stages with no test that asks them both. The maiden path got one frame CHEAPER in the same pass, the numeral reading calling the peel pair it had wrapped rather than the wrapper: `Jane Doe nee Smith` 249 → 248. SECOND, THE ONE-CASE HEAD IS AN ACCEPTED EXCEPTION AND IT IS THE M2 INSTANCE OF #492'S DEFERRED QUESTION about whether a cased suffix token counts as case evidence. `DOE, JANE nee Smith Ma` reads suffix 'Ma' where the clause-less `DOE, JANE Ma` keeps middle 'Ma', because the own-words span stops at the marker — rules/P8 puts "a maiden marker's run and every word after it" outside the name's own words from the moment the marker is tagged — so the member's own Title-casing is not in the span `one_case` is computed over and the clause HIDES the contrast. That is THE PREDICATE KEEPS THE JUDGED TOKEN IN THE SPAN, decisions/S6's 2026-09-14 #289 + #516 entry, failing structurally rather than by oversight: at this slot the judged token is never in the span, and that entry's own answer — include it — cannot be had here. Widening the span for this one question would change `one_case` for the whole name, and three sites read it, so the exception is accepted instead. Measured 2026-09-19: 114 of 2016 generated pairs — every listed member and both by-shape spellings, in three cased spellings, against sixteen heads and six clause bodies of NAME WORDS ONLY — against 186 allowlisted and 984 disagreeing outside the class before the change, and 0 outside it after. The PR review widened that grid to three markers (`nee`, `née`, `geb.`) and three policies (the default and each 2.4 switch), and the class is INDIFFERENT to both: 1,026 of 18,144, exactly 9x the original in both columns, and still 0 disagreeing outside it. `tests/v2/test_properties.py` carries the sweep with the class defined STRUCTURALLY (`one_case` true of the clause form and false of the clause-less one) rather than as a name list. The recorded control beside it is now the SET and not only its size, as a digest over the members: a count cannot notice a swap, one pair leaving and another arriving, which is the same blindness the count was added to close one level up. - 2026-10-17 #682 — SPLIT FROM decisions/S2 with the rule. The entries above were written under decisions/S2 while this rule was part of rules/S2, and moved here unchanged but for pointers the move broke; where one says rules/S2 it means the rule as it then stood, which held this one. S5-given-parts-trailing-slot-after-a-family-comma.md000066400000000000000000000446361527303271700345500ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/decisions# S5 — the given part's trailing slot after a family comma ## Standing Examples name each field as the default order reads it; under the family-first orders the given and family names trade places. - After a family comma a member of the ambiguous class ending the given part is read by its writing, not the count, and reports whichever way it goes unless its capitals in a mixed-case name settled it, which reports nothing (2026-10-25 #785): `Doe, John MA` → suffix 'MA', `Doe, John Ma` → middle 'Ma', `Doe, John X.Y.Z.` → suffix 'X.Y.Z.'; the bare-acronym half restores 1.4.0's role, the dotted half leaves it to agree with `John Doe X.Y.Z.` — 2026-09-18 #531, THE COMMA REPORT'S REACH NOW. - Accepted with it: one-case and caseless names take the credential with no case evidence (`doe, john ma` → suffix 'ma', `田中, 太郎 MA` → suffix 'MA'), `Doe, John van MA` reads family 'van Doe', suffix 'MA', `Doe, John Prof. MA` gains title 'Prof.', and `DOE, JOHN ED` reads suffix 'ED' — 2026-09-18 #531, THE COMMA REPORT'S REACH NOW. - `do`, the one member that is also a particle, is read by its capitals at this slot: in a mixed-case name `Doe, John DO` reads suffix 'DO', and every other spelling attaches to the family by P6, which reports the fork (`Doe, John do` → family 'do Doe'), unless a member its capitals settle stands in front and its run takes the word (`Doe, John MA do` → suffix 'MA do', 2026-10-25 #785); the one-case `SMITH, JOHN DO` reading family 'DO SMITH' is the accepted cost, paired with `NASCIMENTO, EDSON ARANTES DO` — 2026-09-18 #531, CAPITALS DECIDE. - A particle in front of the caps `DO` takes it out of the slot (`Doe, John van DO` → family 'van DO Doe'), and a particle behind a member that is not also an unambiguous suffix word ends the given part where this slot reads it (`Doe, John ma do` keeps middle 'ma'; a member its capitals settle starts the credential's run instead, which takes it, `Doe, John MA do` → suffix 'MA do', 2026-10-25 #785), while `vd` and `mc` are looked past (`Doe, John MA vd` → suffix 'MA', family 'vd Doe') — 2026-09-18 #531, CAPITALS DECIDE. - A never-given particle opening the given part leaves no word to spare, so the member behind it is the surname it heads unless mixed-case capitals say credential (`SMITH, VD MA` → family 'SMITH VD MA'; `Smith, de MA` → suffix 'MA') — 2026-10-02 #573. - Two report movements stay as recorded: `van MA` reports only `suffix-or-name` (none since 2026-10-25 #785, its capitals settling it: `John Smith van MA` is silent and `Doe, John van MA` reports P6's `particle-or-given` alone), and `John Smith Ma Ma` reports once; and `John Smith www.example.com` reads suffix 'www.example.com', reported, the dotted shape unable to tell a URL from an acronym — 2026-09-18. Not standing: the split of 2026-10-17 #682, carried out. Elsewhere: `Doe, Jane PhD vd Ma` reads suffix 'PhD vd Ma' and reports, the credential starting the given part's run — decisions/S8's 2026-10-04 (Derek), #602 entry; an unambiguous credential in front of the member overrides P6 as the capitals do (`Doe, John PhD Do` → suffix 'PhD Do') — decisions/S7's 2026-09-27 (Derek), #544 entry headed THE #531 PAIRING GAINS A SECOND EXCEPTION; the maiden gap the #531 entry left open, closed by decisions/S4's 2026-09-19 (Derek), #533 entry and since moved by decisions/M2's 2026-10-04 (Derek), #601 entry, under which a marker after a family comma is an ordinary word (`Doe, Jane nee Smith MA` → middle 'nee Smith', suffix 'MA'). Open: none in this file. ## Log - 2026-09-18 (its open follow-up superseded by #531, the 2026-09-18 (Derek) entry below headed THE COMMA REPORT'S REACH NOW INCLUDES, which reads and reports the slot) — THE COMMA REPORT'S REACH IS THE FIRST POST-COMMA PIECE, AND THE GIVEN SEGMENT'S TRAILING SLOT IS AN OPEN FOLLOW-UP. `assign`'s family-comma emitter reads `state.pieces[1][0]` — the first piece after the comma — so a class member standing at the END of the given part is never asked about. PRE-EXISTING and unchanged by this arc: `Doe, John MA` reads middle `MA` and `Doe, John X.Y.Z.` middle `X.Y.Z.`, both silent, at 1f78bef and on this tree alike. What this arc adds is names that MOVED into that silence: `Doe, John J.u.n.i.o.r.` and `Doe, John X.Y.I.` went suffix → middle, and `John Smith nee Jones R.A.I.` suffix → maiden (the maiden walk absorbs it). `Freiherr von Berg X.Y.I.` was listed here too and does not belong: corrected in the verification round of 2026-09-18, it moved suffix → family but GAINED a report rather than losing one. At 1f78bef and on the 2.3.0 wheel it read title `Freiherr`, family `von Berg`, suffix `X.Y.I.`, reporting `particle-or-given` alone; on this tree it reads title `Freiherr`, family `von Berg X.Y.I.` and reports `particle-or-given` AND `suffix-or-name`, the chain emitter taking it by its shape tag. The role moved into a slot that does not report and the report came from the chain instead, so the name is evidence for the emitter rather than for the silence. The universal claims are narrowed to what ships rather than the emitters widened: `nameparser/_types.py`'s `SUFFIX_OR_NAME` member now names the slots it covers and the one it does not, and rules/S3's "and the same at a comma" now says which parts of a comma form it means. Widening it is a MAINTAINER DECISION and is left open, filed as [#531](https://github.com/derek73/python-nameparser/issues/531): the slot is `_assign`'s given-part walk, the fork is genuine there, and whether a middle initial's neighbourhood should start reporting is a judgement about noise (rules/A1's "a kind is worth adding only if a reader would hesitate too" applies to a SLOT the same way). And the open question is a 1.4.0 PARITY question as well as a noise one, which the verification round of 2026-09-18 established and the entry had backwards: `Doe, John MA` reads suffix `MA` on the 1.4.0 wheel and middle `MA` at 2.0.0 through 2.3.0 and here, so the silence at this slot is a 2.0-era reading that has never matched v1 — "every release" was the wrong span and it is now "every 2.x release" wherever this is stated (`nameparser/_types.py`'s `SUFFIX_OR_NAME` member, docs/release_log.rst). The dotted sibling does not divide that way: `Doe, John X.Y.Z.` is middle `X.Y.Z.` at 1.4.0 too. So a maintainer widening the slot is choosing between reporting the fork and restoring v1's ROLE, which are different repairs, and only the bare-acronym half has a parity claim behind it. Recorded under 3-0-reevaluations' standing rule that a design citing 1.4 parity as load-bearing says so where the design lives. Two more report movements recorded here rather than repaired: `van MA` LOSES its `particle-or-given` (1f78bef reported both kinds; the caps lean now takes `MA` to suffix, leaving `van` the only name piece, and assign's leading-particle emitter wants more pieces following), and `John Smith Ma Ma` reports ONCE where 1f78bef reported twice (the first `Ma` is a middle name now, not a second declined pick). And a reach of the default-on dotted switch worth naming: `John Smith www.example.com` reads suffix `www.example.com` with a report, where 1f78bef read family — a URL is three alphabetic period-separated chunks and the shape rule cannot tell it from an acronym. - 2026-09-18 (Derek), #531 — CAPITALS DECIDE FOR `do`, AND THE PARTICLE RULE KEEPS EVERY OTHER SPELLING. `do` is the one member of the ambiguous credential class (`ba`, `do`, `ed`, `jd`, `ma` — recompute with `sorted(Lexicon.default().suffix_acronyms_ambiguous)`) that is also particle vocabulary, so the trailing slot of the given part and P6's attachment want the same word. Where the writing carries a positive credential lean — an all-caps member in a name written in more than one case — the credential reading wins and P6 stands down: `Doe, John DO` reads suffix `DO` and reports `suffix-or-name`. Every other spelling attaches exactly as it did before, with P6's own `particle-or-given` and no second report: `Doe, John do`, `Doe, John Do`, `DOE, JOHN DO`, `doe, john do` all read family `do Doe` in their own case. THE PAIRING IS THE ARGUMENT, and the accepted cost and the reason are the two halves of it. In ONE CASE the rule cannot tell `NASCIMENTO, EDSON ARANTES DO` from `SMITH, JOHN DO` and reads both as the particle — right about the Brazilian record, wrong about the osteopath, whose `DO` 1.4.0 read as a suffix; both report `particle-or-given`, which is how a caller finds the second. In MIXED CASE it can, and does: `Nascimento, Edson Arantes do` keeps family `do Nascimento` while `Doe, John DO` gives suffix `DO`. Accepted rather than repaired: the one-case reading is the commoner of the two collisions, and the Nascimento record is the name a wrong answer would damage. The other four members are not particles and are unaffected. This is the exception rules/P6's "this attachment outranks the suffix reading (S2)" clause now names. Implementing it takes TWO carve-outs rather than one, which is worth recording because the second is in the REPORT and not the reading: the reading carve-out takes a particle-tagged member only on a POSITIVE credential lean, since `Doe, John do` leans nothing and the positional reading would otherwise take it — the wrong ANSWER, not merely a stray report; and the emitter beside it skips a particle-tagged member it did not itself take, since an emitter gated on tag membership alone reported both kinds on every `do` row. Where P6 owns the fork, P6 reports it, which is mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE read strictly. ONE SHAPE THE PAIRING DOES NOT REACH — a shape, not a spelling, and the difference is the review finding: ANY particle or particle chain standing in front of the caps `DO` takes it out of this slot, because grouping's prefix chain merges the two into ONE particle piece before the slot exists and there is then no lone member for the capitals to lean on. It is a fact about this WORD being particle vocabulary itself, not about particles generally — the caps `MA` in `Doe, John van MA` is not swallowed, reads as the credential, and leaves `van` to attach behind it. `Doe, John van DO` reads family `van DO Doe`, and so do the `de`, `von`, `del`, `la` and `van der` spellings of it (measured 2026-09-19 — parse them and read `family`). Pre-existing and unmoved by #531; `Doe, John van DO` is the case row and rules/P6's example line, and P6's statement carries the carve-out. The mirror shape is silent rather than merely unreached and is recorded on `AmbiguityKind.SUFFIX_OR_NAME`'s fourth boundary: a particle BEHIND the member ends the given part where this walk reads it, so `Doe, John MA do` keeps middle `MA` beside family `do Doe` though the capitals would otherwise have taken it, the attachment that moves `do` running a stage later. - 2026-09-18 (Derek), #531 (its open maiden gap, `Doe, Jane nee Smith MA`, superseded by #533, decisions/S4, and then by #601, decisions/M2) — THE COMMA REPORT'S REACH NOW INCLUDES THE GIVEN SEGMENT'S TRAILING SLOT, AND THE OPEN FOLLOW-UP IS CLOSED. The 2026-09-18 bullet above headed THE COMMA REPORT'S REACH IS THE FIRST POST-COMMA PIECE recorded this slot as an open maintainer decision and named the two questions it turned on — whether a middle initial's neighbourhood should start reporting (a noise judgement, rules/A1) and whether the silence was a 1.4 parity gap. Both are answered here, and that bullet stands as it landed. The slot now reads and reports: `Doe, John MA` gives suffix `MA` and `Doe, John Ma` keeps middle `Ma`, each saying which way it went. Derek chose to restore the ROLE and report both ways — one rule for both spellings — over a report-only change and over a capitals-only one, because the issue exists in the first place because two spellings of one name disagree. The noise question was settled by MEASURING THE DISAGREEMENT rather than by argument: over a generated sweep of 78 pairs — five listed members and two by-shape tokens in three cased spellings each, plus five controls in one spelling apiece, so 26 words against three name shapes — 48 pairs disagreed about whether the word was a credential or a name, and every one of the 48 disagreed in the same direction, the comma form declining what the comma-less form took. After this change 3 disagree and all three are the lower-case `do` rows P6 owns. The sweep and its allowlist are `tests/v2/test_properties.py`; a slot that answers differently from the same name written without a comma is not a quiet slot, it is an inconsistent one. Recorded under `3-0-reevaluations`' standing rule because v1 parity is LOAD-BEARING for one half of this and explicitly NOT for the other: `Doe, John MA` reads suffix `MA` on the 1.4.0 wheel, so the bare-acronym half RESTORES v1's role and the report is all that is new there — its 1.4.0 ledger rule neither retired nor narrowed to `_ambiguities` (nothing below baseline 2.0 can diff on that pseudo-field) but was RE-POINTED to the four names where the writing declines the credential, which keep the 2.0-era middle name against v1. `Doe, John X.Y.Z.` reads middle `X.Y.Z.` at 1.4.0 too, so the dotted half LEAVES v1 and carries a 1.4.0 ledger rule of its own; it moves to match the comma-less `John Doe X.Y.Z.` and rules/S3's shape rule, not to restore anything. BLAST RADIUS, stated the way this log's own rule asks: twenty corpus names move, and only TWO of them were in any corpus before this branch (`Doe, John MA` and `Doe, John X.Y.Z.`, both admitted by #530's own arc) — the other eighteen are this change's own case rows, so what the differential measures on pre-existing data is two names, and the population the rule reaches is a SHAPE (every family-comma listing whose given part ends in a member of this class) that the corpora barely sample. ACCEPTED COSTS, all measured on the differential corpora: `SMITH, JOHN DO` keeps family `DO SMITH` where 1.4.0 read suffix `DO`, paired with the Nascimento record in the bullet above; one-case and caseless names take the credential with no case evidence at all, so `DOE, MARY JO MA`, `doe, john ma`, `田中, 太郎 MA` and `김, 민준 MA` all read a suffix, which is what 1.4.0 read for each of the four — "caseless is inert" holds for the LEAN and not for the outcome, since `is_one_case` answers True for a script with no case and the positional reading then decides; `Doe, John van MA` loses its middle to the family, reading family `van Doe`, suffix `MA` with two reports where it read middle `van MA` in silence, which is `Berg, Jan van Jr.`'s reading arriving through a shape it could not reach before (Derek accepted it 2026-09-18 as P6 working correctly rather than as a cascade to carve out); `Doe, John Prof. MA` gains a TITLE role `Prof.` never had, H5's transparency reaching it once `MA` leaves the walk, landing it on the same answer as `Doe, John MA Prof.`; `Smith, LEED AP` moves under the default-off caps switch alone, to given `LEED`, family `Smith`, suffix `AP` with two reports, so no default reading is at stake; and a genuine middle name that is also a class member is now a credential wherever it ends the given part and is not Title-cased — `DOE, JOHN ED` reads suffix `ED` — which is the same cost the comma-less form has carried since 2.0. NOT REPAIRED HERE and left open: the maiden walk claims `Smith MA` whole in `Doe, Jane nee Smith MA` before this slot exists, so the rule cannot reach it and the name stays silent with maiden `Smith MA` — the same gap #530's close-out saw from the other side with `John Smith nee Jones R.A.I.`, and it is out of scope for #531. - 2026-10-02 (Derek), #573 — a never-given particle opening the given part after a family comma leaves no word to spare; the member behind it is the surname it heads, unless mixed-case capitals say credential. The record is decisions/P9's entry of the same date (decisions/P6's until 2026-10-19). - 2026-10-02 (Derek), #578 — SUPERSEDED by 2026-10-04 #602, decisions/S8, under which the credential starts the given part's run and `Doe, Jane PhD vd Ma` reads suffix 'PhD vd Ma' — A PARTICLE BETWEEN A CREDENTIAL AND A TITLE-CASE WORD STAYS SILENT, AS IT READS. `Doe, Jane PhD vd Ma` reads given 'Jane', middle 'vd Ma', family 'Doe', suffix 'PhD', reporting nothing. No new rule decides that: S2's given-part slot already says a particle in front of a member takes the member out of the slot, and where the word behind reads as a name the two are one name, asked nothing and reporting nothing — `vd` reads as `van` does (`Doe, Jane PhD van Ma`, the same reading), and S2's own boundary example is `Doe, John van Ma`. The writing is malformed whichever reading is meant: a Title-case `Ma` behind a lowercase `vd` behind a `PhD` is neither a Dutch listing nor a credential run as anyone writes one. A reader leaning anywhere leans suffix (Derek: both words follow a known credential, and the writer likely did not know how suffixes are capitalized), but that is a guess about a writer's mistake, not a convention the writing carries. Declined: (a) reading `vd Ma` as suffixes, which needs code to make a credential speak across a particle — against P2 (a particle joins the word behind it) and #554 (`vd` speaks for nothing behind it); (b) reporting the fork, which needs an emitter on the family-comma path for a decision the particle chain makes before assign knows which read the part gets (AGENTS.md: prefer leaving a fork silent and documenting the omission over machinery for input nobody reads with confidence). Where the writing does speak the parser already follows it: `Doe, Jane PhD vd MA` reads suffix 'PhD vd MA' and reports (#573). Pinned by the case row `a_particle_after_a_credential_heads_a_title_case_name`. - 2026-10-17 #682 — SPLIT FROM decisions/S2 with the rule. The entries above were written under decisions/S2 while this rule was part of rules/S2, and moved here unchanged but for pointers the move broke; where one says rules/S2 it means the rule as it then stood, which held this one. - 2026-10-25 #785 (Derek, 2026-10-23) — A MEMBER ITS CAPITALS SETTLE IS NOT ASKED HERE AS A FORK. At this slot a listed member written in capitals in a mixed-case name already read as the credential; it now reports nothing (rules/S6), and as a run start it takes the words behind it into the suffix (rules/S8): `Doe, John MA Smith` → suffix 'MA Smith' and `Doe, John MA do` → suffix 'MA do', where both read the member as a middle name, and `Doe, John MA Ma` → suffix 'MA Ma'. The rows that pinned the slot's reach and its floor are written in lower case now, which no writing settles, and read as before. 1.4.0 read `Doe, John MA do` and `Doe, John MA Ma` as suffix 'MA, do' and 'MA, Ma'. The record is decisions/S6's entry of this date. derek73-python-nameparser-456b024/docs/design/decisions/S6-case-signal-at-the-suffix-slot.md000066400000000000000000001022231527303271700315270ustar00rootroot00000000000000# S6 — the case signal at the suffix slot ## Standing - In a name written in more than one case, a member of `suffix_acronyms_ambiguous` written in capitals leans credential and is taken with nothing to spare (`Jack MA` → suffix 'MA'), and one in any other cased form that is not wholly lower leans surname (`John Smith Ma` → family 'Ma'); an all-lower member, or a name written in one case, takes the positional count (`JOHN SMITH MA`, `john smith ma` → suffix) — 2026-09-14 #289. - The case fact counts the judged word itself, so `JOHN SMITH Ma` is mixed case and `Ma` leans surname. Whether a cased suffix counts as case evidence is answered for render only (decisions/R5); the parse keeps counting it — 2026-09-14 #289; 2026-09-23 #492. - At the first slot after a comma the count goes first, of name words, and the lean is read only where the count leaves the word a name: `Smith, MA` → suffix, `Smith, Ma` and `Smith, ma` → given, `Smith Jr., MA` → suffix 'Jr., MA' — 2026-09-14 (Derek); 2026-10-20 #761. - A caseless script carries no lean, and the name-word count still reaches it: `마틴 킹, MA` → suffix 'MA', `毛泽东, MA` → given 'MA' — 2026-09-17. - The one-case fact is computed over the name's own words by one shared helper (`_pieces.own_words`), which classify always calls and every other stage that reads the fact calls rather than copying it — 2026-09-14, corrected. - `Freiherr von Berg MA` reads family 'von Berg', suffix 'MA', the lean ending P2's chain with no words to spare — 2026-09-15 (Derek). - The whole-PR review's five readings stand: an acronym the particle chain absorbs reports `suffix-or-name` (`John van der Berg Ma`); the CJK stage asks the post-comma run with the one-case fact; a by-shape credential in a tail segment raises no `comma-structure`; a word a caller lists as a surname is never caps-shaped; and a dotted listed member takes the lean — 2026-09-18, WHOLE-PR REVIEW ROUND. - A listed member of `suffix_acronyms_ambiguous` written in capitals in a name of mixed case is settled as the credential by its writing and reports nothing at any slot that reads it (`John Smith MA`, `Jack MA` but its `given-or-family`, `John Smith, MA`, `Doe, John DO`), and it starts the credential's trailing run (rules/S8) after a family comma (`Smith, John MA Jones` → suffix 'MA Jones') and elsewhere only where no name word stands behind it, a name word there keeping the capitals a name word as the writer's initials may be (`John Smith MA Jones`, `Maria Ana MA Silva` keep their family names; 2026-10-26 (Derek)); the clash settled is the one with the name reading alone, so the word keeps its tags and its other readings (`Doe, John DO` keeps family 'Doe'), a maiden clause still keeps it as its first word and reports it (`Jane Doe nee MA`), and nothing before a comma leaves it the given name, reported (`, MA Smith`) — 2026-10-25 #785 (Derek, 2026-10-23); 2026-10-25 #785, review. Elsewhere: the case a generation word that is also a name reads (rules/S6's bullet on it) is decisions/S11's 2026-10-25 #752 entry. Open: none in this file. ## Log - 2026-09-14 #289 + #516 (this PR) — A BARE AMBIGUOUS ACRONYM IS READ BY THE EVIDENCE THE WRITING CARRIES, and written case is evidence the words-to-spare count does not have. In a name written in more than one case, a member of `suffix_acronyms_ambiguous` written in CAPITALS leans credential and is taken as a suffix with nothing to spare (`Jack MA` → given 'Jack', suffix 'MA', as `Jack MD` has always read); one written in any other cased form that is not wholly lower leans SURNAME and stays a name word with plenty to spare (`John Smith Ma` → middle 'Smith', family 'Ma'). An all-lower member carries no lean, a name written wholly in one case carries none at all, and both take the positional rule unchanged — `JOHN SMITH MA` suffix, `ANH DO` family, `john smith ma` suffix. Switch-free and scoped to the listed set: `ba do ed jd ma`. THE PREDICATE KEEPS THE JUDGED TOKEN IN THE SPAN, and that is what gives the right answers here. `JOHN SMITH Ma` is MIXED because of `Ma`, and a Title-case word against all-caps neighbours is exactly the surname signal this design wants; excluding the judged token would make the name one-case and hand `Ma` to the positional rule. Same for `Jack MA` read the other way — included, the name is mixed and the capitals lean credential; excluded, `own == ['Jack']` is one case and the peel keeps the family. The general question — whether a cased suffix token counts as case evidence — is #492's and stays deferred; this entry records that the INCLUDING reading is what this design rests on. - 2026-09-14 (Derek) — THE LEAN REACHES THE POST-COMMA GIVEN SLOT: `Smith, MA` → family 'Smith', suffix 'MA', while `Smith, Ma` and `Smith, ma` stay given. Positive evidence outranks position there, so the credential reading fires with ONE word before the comma where the positional rule would not. AND THE COUNT AT A COMMA IS OF NAME WORDS. For this class, "words to spare" in `X, Y` form means the part before the comma holds two or more NAME words: `Smith Jr., MA` has two tokens and one name word, and a token count hands its family to `given` (measured on the prototype). rules/C1 (the second clause rules/C8's since #728) already said both things — "more than one word precedes the comma" in its opening clause and "a part before the comma with more than one name word" later — so the doc edit is a reconciliation inside C1 rather than a new claim. - 2026-09-17, corrected — A CASELESS SCRIPT IS INERT ONLY AS A LEAN, NOT AS THE NAME-WORD COUNT. `is_one_case` answers True trivially for text with no case to write a contrast in, so `ambiguous_lean` returns `None` for `毛泽东, MA`, `마틴 킹, MA` and `田中 太郎, MA` alike — the LEAN really is inert by construction, as first written. But the comma count above is of NAME words "whatever case the name is written in", which is orthogonal to case and reaches a caseless script the same as any other: `마틴 킹, MA` and `田中 太郎, MA` each hold TWO pre-comma name words, so the structure flips and both read suffix 'MA' — a PARITY RESTORATION, not a fresh deviation (1.4.0 read both as suffix on the wheel), with the Hangul/Han surname split then running positionally over what the comma leaves (`마틴 킹` → given '틴', middle '킹', family '마'; `田中 太郎` → given '太郎', family '田中'), unrelated to this design and #271/#272's pre-existing behavior. `毛泽东, MA` does not move — one pre-comma token, the count never reaches two. Pinned `a_caseless_script_wrote_no_contrast_hangul` and `a_caseless_script_wrote_no_contrast_japanese` (tests/v2/cases.py), both `tolerated=True`. - 2026-09-14, corrected 2026-09-17 and 2026-09-18 — WHERE THE FACT LIVES, AND WHY THERE. The comma forms need the class decision at SEGMENT time, two stages before the own-words span is computable from marker TAGS. Four options were measured on a runtime probe (baseline 410.00 frames): segment computing it with a direct marker-head walk, +15; the same re-running the whole marker pass, +16; the fact staying in classify while classify RE-DECIDES `structure`, +0 but refused ON ORDERING — `structure` is read between segment and classify by `_script_segment.py`, which returns early on FAMILY_COMMA, so a flip decided in classify would leave the surname-split gate un-run on a name that turns out to have a suffix comma (measured: `Smith 김민준씨, MA` reads given 'Smith', middle '김', family '민준', suffix '씨, MA' on this tree — the figure first written here, given 'MA' with family 'Smith 김민준씨', was the reading before the flip moved to segment, re-measured 2026-09-18 — and a DIFFERENT wrong split again with the flip decided before script_segment — two stages disagreeing about one parse is what mechanisms.md#PIPELINE-STAGE-CONTRACTS exists to forbid); the marker pass and the fact MOVING to segment, +8, refused because killing the duplicated fold means carrying it on the state through CJK splits, a new bug surface in the one stage that changes token COUNT. SHIPPED: one shared helper, `_pieces.own_words(tokens, comma_offsets, markers, marker_tags=None)`, called by classify always (passing its own tag map) and by segment only when a comma form could turn on the fact. COST, as totals against `1f78befd` (the merge base this arc branched from), re-measured 2026-09-18 with a `sys.setprofile` harness shaped like `tools/perf/call_count.py`'s: the reference name 411 → 412 and `John Smith` 175 → 176, every non-trigger comma name +4 (`Smith, John` 203 → 207, `John Smith, Jr.` 229 → 233, `Smith, John, Jr.` 253 → 257), the trigger `Smith, MA` 203 → 225, and with switch B on `Smith, John` +7 and `Smith, XYZ` +40 (tests/v2/test_policy.py's dated comment carries those two and its recipe); the benchmark band and `_CALL_BASELINE` never moved (PR #530). `own_words` shares its exact span-cutting walk with classify's own cut through a second shared function, `_vocab.tag_marker_runs` (moved out of `_classify.py` so `_pieces.py`'s own layering rule — it may import `_state` and `_vocab` only — is not broken): both call sites run the identical walk over the identical inputs, including `comma_offsets`, so a marker phrase like `z domu` is bucketed the same way at a comma either side. A corpus probe over 1174 entries finds 0 cut and 0 verdict differences between the two call shapes. The caps predicate is one shared function, `_vocab.caps_shape_candidate`, called from classify, `ambiguous_class_candidate` and `_segment.py`'s run test alike — the usual "a shared call costs the default a frame" objection does not apply, because every caller's own first conjunct is `policy.unlisted_caps_suffixes` itself, False by default. NO MECHANISMS ENTRY IS OWED, and here is why: the promoted fact is STATE-OFFSET-CHANNELS' "a pre-token fact is recorded on the ParseState and consulted later" applied to a name-level boolean rather than to offsets, and `ParseState.order` is the precedent that entry and ONE-PREDICATE-PER-QUESTION both already name. That entry's own sentence — "recorded as offsets … or by presence alone where the fact is name-level" — already covers it; the one edit made there is to name `one_case` as the third channel. - 2026-09-15 (Derek) (item (ii) superseded by #544, decisions/S7's "ITEM (ii) IS REVERSED" entry) — ACCEPTED, NOT REPAIRED, TWO SHAPES. (i) `Freiherr von Berg MA` MOVES: it was listed as unreachable by the peel and as staying fix(#424)'s, and measured, the caps lean makes it reachable, because the lean ends P2's chain with no words to spare where the count would not. Today title 'Freiherr', family 'von Berg MA'; designed title 'Freiherr', family 'von Berg', suffix 'MA'; 1.4.0 first 'von Berg', last 'MA'. Three readings exist and the designed one ships; it moves a rules/P2 example line and re-scopes fix(#424)'s ledger rule, whose `fields` gains `suffix`. (ii) `abdul Smith Jr Ma` reads `Jr` as a MIDDLE NAME: the surname lean breaks the peel AT `Ma`, so the unambiguous `Jr` in front of it is never reached. Today given 'abdul', family 'Smith', suffix 'Jr Ma'; designed given 'abdul Smith', middle Jr, family 'Ma'. One case row rather than real data, but the shape is real — a name-leaning ambiguous acronym BLOCKS the peel of a genuine suffix behind it — and the walk stopping at the declined pick is the rule, not a bug the peel owes a repair. - 2026-09-18 — THE BLAST RADIUS IS THE GATE'S OWN COUNT, NOT A PREDICTION. Measured 2026-09-18 at 2.3.0 with `tools/differential/compare.py` (re-measured after the review and verification rounds): corpus 1234 names, intentional diffs 51 (41 under this arc's four fix rules), unexplained 0, radar unclassified 7; larger than the plan's "11 role + 4 report-only" prediction by corpus growth and two mechanisms the prototype predated, never by a wider rule (PR #530). - 2026-09-18, WHOLE-PR REVIEW ROUND — WHAT IT MOVED, MEASURED BEFORE AND AFTER. Ten findings; the five that changed behavior are here with the shape each was found by, since the shape is what generalizes. (1) A LOST REPORT. Where the prefix chain absorbed an acronym the S2 peel had DECLINED, nobody emitted the flag S2 promises for either reading: `assign` reports from `peel_trailing`'s picks and a pick reaches it only as a LONE piece, so the token it would have reported on no longer existed. Before: `John van der Berg Ma`, `John de Ma`, `Dr. John van Smith Ma`, `John van Smith Ma Jr.` and `John Smith Mc Ma` gave their family names with `ambiguities` empty; after, each names `suffix-or-name` once and no role moves. The emitter is at the chain's own merge and declines where the PARTICLE run took the word (`anh van do`, `anh van mc` — P6's fork, not S2's, and 1.4.0 read both silently). Recompute: parse the five names and read `ambiguities`. (2) TWO STAGES ASKING ONE QUESTION WITH DIFFERENT INPUTS. `_script_segment` asked `is_wholly_suffix` of the post-comma run WITHOUT the `one_case` fact `segment` had recorded one stage earlier, so `Kim김민준씨, MA` read family `Kim김민준씨`, suffix `MA` (씨 unpeeled) while `Kim김민준씨, PhD` read family `Kim김민준`, suffix `씨, PhD` — one name, two credential spellings, two divisions. After: both peel. `Jo김민준씨, DO` and `Dr. 김민준씨, MA` move with them; `김민준씨, MA` does NOT, being wholly one case (Hangul is caseless and `MA` contrasts with nothing), which is the doctrine holding rather than an exception. rules/W3's claim that the two stages differ "in what else they require, not in what they ask of the words" was false until this and is true now. (3) A REPORT ABOUT THE PARSER'S OWN NEW READING. rules/S3's narrow retirement moved `R.A.I.`, `X.Y.I.` and `J.u.n.i.o.r.` from the vocabulary verdict into the shape class, and `segment`'s tail-segment test reads `is_wholly_suffix`, which is blind to that class — so `John Smith, MD, R.A.I.`, `John Smith, Ph.D., R.A.I.` and `John Smith, MD, J.u.n.i.o.r.` gained a `comma-structure` flag 2.3 did not raise. After: quiet, at the default. The test is narrowed to the BY-SHAPE half deliberately — a LISTED member reaches that reading through the case lean, and `STEVEN HARDMAN, MD, DO, DDS` (one case, nothing leans) keeps its flag as the lean's own recorded negative control; `John Smith, MD, Ma` keeps it too. With `unlisted_dotted_suffixes=False` the flag stands, the token being name material there. C2 is amended to state all of this. (4) THE WORDLIST ROSTER. `_vocab.caps_shape_candidate`'s exclusion named ELEVEN of `_lexicon._VOCAB_FIELDS`' thirteen: `Lexicon.default().add(surnames={"dupont"})` with the caps switch on still read `Jean Pierre DUPONT` as given `Jean`, family `Pierre`, suffix `DUPONT` — a caller listing a word as a SURNAME and getting a credential, which is this switch's own worst failure arriving through the one wordlist that says "family name". After: family `DUPONT`, no report. The roster is derived from the field tuple, with a per-field test; `honorific_tails`, the other omission, was already excluded transitively (⊆ `suffix_words` by Lexicon invariant) and joins for completeness. (5) A LISTED MEMBER READ BY SHAPE. A caller may list a DOTTED entry in `suffix_acronyms_ambiguous` ('a.b'), and the whole token then matches that set while `suffix_as_written`'s period-free acronym lookup ('ab') misses it — so the chunk view reached classify's shape branch, `SHAPE_ACRONYM_TAG` rode along, and `_pieces.listed_lean` (which declines wherever that tag is) silenced the caller's own case lean: `Jack A.B.` read family where `Jack MA` reads suffix. After: suffix `A.B.`, the lean applying. Nothing default moves, the shipped ambiguous vocabulary carrying no periods. Also in the round and behavior-neutral: `Policy.unlisted_dotted_suffixes` and `unlisted_caps_suffixes` moved to the END of `Policy` and `PolicyPatch` — inserted mid-class, they had re-bound every positional argument from `strip_emoji` on, which is API for a dataclass that is not `kw_only`; the bool-validation roster is derived from `dataclasses.fields`, the hand-written twin in the tests having named neither new flag; `ambiguous_class_candidate` lost an optional `one_case` parameter no production caller ever passed, and with it a caps branch that answered False for every name the library has parsed; and `one_case` is REQUIRED on the four piece predicates that own it. - 2026-09-18 — THE PREDICTION THAT DID NOT SURVIVE ITS OWN MEASUREMENT, kept because the failure mode is this log's recurring one. A review round called THREE `one_case` plumbing sites in `_group.py` inert and proposed removing them. Measured with a runtime wrapper over 1,642 names under six policies — the default, both family-first orders, strict commas, and each 2.4 switch flipped — 9,852 parses: the maiden walk's PAIR is inert, 0 parses move, and it is inert BY CONSTRUCTION rather than by corpus luck (`numeral_only` answers off `peeled.numeral`, and the numeral fork is decided before the peel reads a lean). The chain-tail measure is not: dropping it there moves 18, on `John van der Berg Ma`, `John de Ma` and `Freiherr von Berg MA` under every one of the six. Both figures are at the call sites. The population is the DISTINCT union, in file order, of every `tools/differential/corpus*.jsonl` entry, every `tests/v2/cases.py` text, and `tests/test_variations.TEST_NAMES` with the three comma permutations `test_variations_of_TEST_NAMES` builds (no-comma, family-comma, and suffix-comma where the name has a suffix), empty strings dropped; the wrapper patches the call sites in a copy of the module source and rebinds `_pipeline.STAGES`, which binds stage functions at import and would otherwise leave the mutation inert. This entry first carried 2,338 names and 14,028 parses, which that recipe does not reproduce by any reading of it — the distinct union is 1,642, summing the three sources' distinct counts gives 2,202, and not deduping at all gives 2,690 — so both figures were re-measured on this tree in the verification round of 2026-09-18 and restated here and at the call sites. The FINDING was unharmed: 0 and 18 reproduce exactly, on the same three names, under all six policies, and the control that drops all four sites at once moves those same 18, which is what shows the wrapper can report a difference at all. The argument was right for two sites out of three and would have taken a third with it. - 2026-09-18, VERIFICATION ROUND — THE REPORT INVENTORY AN UPGRADER SEES, 2.3.0 → TREE, AND ITS RECIPE. Over the corpus deduped by name, against the 2.3.0 wheel, one report is lost (`comma-structure` on `Steven Hardman, MD, DO, DDS`) and one changes kind (`Jack X.Y.I.`, `given-or-family` → `suffix-or-name`), so the release bullet's second named loss, `John Smith, MD, R.A.I.`, which reports nothing at 2.3.0, was false and was corrected; per-name multiset difference of `ambiguities` kinds in both directions, recipe in PR #530. - 2026-09-23 #492 — THE DEFERRED QUESTION the 2026-09-14 predicate paragraph and decisions/S4's 2026-09-19 #533 bullet name — whether a cased suffix token counts as case evidence — IS ANSWERED FOR RENDER ONLY. R5's gate now leaves the suffixes out (decisions/R5, 2026-09-23); the parse-time fact this section rests on keeps counting the judged token exactly as written above, so `Jack MA` still leans credential and `DOE, JANE nee Smith Ma` still reads suffix `Ma`. The split is decided and recorded under R5, with the shapes it makes visible there (`jack MA` at this slot, `john e jones III` at P8's). - 2026-10-17 #682 — SPLIT FROM decisions/S2 with the rule. The entries above were written under decisions/S2 while this rule was part of rules/S2, and moved here unchanged but for pointers the move broke; where one says rules/S2 it means the rule as it then stood, which held this one. - 2026-10-19 #760 — MOVED FROM the chain emitter's code comment in `_group.py`, the cost record the entries above did not hold. The emitter is written inline against the tags with no new walk — `pieces[j - 1]` is the last piece the merge is about to claim, one index and one frozenset test — so the ordinary chained name (`de la Vega`) pays no frame for it. Its `not is_prefix_piece(last, ...)` floor is tested LAST, behind the `isdisjoint` (a C call, no frame), because it costs a frame (two until group's predicate closures were inlined): asked ahead of the tag test, through the closure, it moved the reference name from 412 to 414 frames, every chained name paying for a question only an ambiguous acronym makes interesting (measured in the #289/#516 review round, 2026-09-18, commit 59d8f38a, interpreter unrecorded; the reference row has since moved for other reasons, decisions/parse-cost). - 2026-10-20 (Derek), #761 — "A COMMA", NOT "A FAMILY COMMA". rules/S6 said the count decides first at the first slot after a family comma, where rules/S4 said after a comma; the parser follows S4 at a suffix comma too (`John Smith, Ma` reads suffix `Ma`, reported). Found in the skeleton migration (PR #783); S6's Precedence lead now says "a comma". No behavior moves. - 2026-10-25 #785 (Derek, 2026-10-23) — CAPITALS SETTLE A LISTED CREDENTIAL, AND A CLASH SETTLED BY THE WRITING IS NOT REPORTED (#752's phase 2g: the form step of decisions/vocabulary-model's 2026-10-23 #752 entry, item 7, answering #785's first three questions; its fourth was answered there, no new kind). In a name written in more than one case a listed member of the ambiguous class written in capitals was already READ as the credential (the 2026-09-14 entries above); it was still REPORTED as a fork at every slot, while the same word written with its periods (`M.A.`, rules/S3) reported nothing, and it did not start S8's run where the dotted spelling did. #785 asked whether the two writings should answer alike: they do now. `John Smith MA` reports nothing where it reported `suffix-or-name`; `John Smith MA Jones` reads suffix 'MA Jones', reported for 'Jones', where it read middle 'Smith MA', family 'Jones'; a name written in one case keeps its report (`JOHN SMITH MA`), and so does a Title-case member, which leans to the name and is weighed by the count. HOW, AND WHY NOT A RETAG. The phase 2 spike settled the word by retagging it as an unambiguous credential, and that erased its OTHER readings: `do` is a particle too, and once it stopped being a member of the ambiguous class rules/P6's attachment took it (`Doe, John DO` → family 'DO Doe'), and the maiden clause, whose first word is kept by the clause whatever it is, lost its maiden name (`Jane Doe nee MA`). So the word keeps every tag, and one predicate (`_pieces.settled_by_case`, a listed acronym whose lean is the credential, a suffix word that is also a name being rules/S11's) is asked where a reading is chosen: the trailing peel takes a settled member without recording a pick, so no reporter downstream meets it; the comma's decision and the given part's trailing slot leave its report out; and S8's run start admits it, the particle test in front still refusing `DO` (`John Smith DO Jones` keeps family 'DO Jones'). The case asked is ParseState's one-case fact over the name's own words, so a word in a maiden clause or in brackets is read by the whole name's case, never its own; the spike asked a bracketed word's own case and moved 7 FEC reports for nothing. WHERE IT STILL REPORTS. Where something before the writing keeps the word a name it is read as before and reported: the first word a maiden clause takes (rules/M2, `Jane Doe nee MA` → maiden 'MA'), and the word after a comma with nothing in front of it, which C4's count leaves the given name (`, MA Smith` → given 'MA'). A bracketed member is rules/S1's, unchanged here (phase 2l). ACCEPTED (superseded by the 2026-10-26 (Derek) entry below, which reads such initials as names). A writer's initials in capitals between the given names and the family name, where they spell a listed member, start the run, and the family name reads as a suffix (rules/S8's Accepted clause, `Maria Ana MA Silva` → suffix 'MA Silva', as `Maria Ana M.A. Silva` already read). In ctgov-officials (fetched 2026-10-21), 9 distinct names write a listed member in capitals between two Title-case name words with no comma, and 6 of them now read this way. The other 5 field moves there are lists of credentials after a comma that the parse reads as a reversed name, a credential taken for the given name (unchanged), whose list behind the member now reads as the suffix where it read as middle names. No name in that shape is written with the periods there. MEASURED (2026-10-25, Python 3.11.17, against master a46b3a6e, every local check with `--base` that checkout): the committed snapshot moves 54 names, 48 a dropped report and 6 a field, every one a case-row or rules text, and gains 20 texts and loses 1, the rows added and those rewritten to keep reaching their emitter; every CLDR, GND, Wikidata and locale agreement record matches. ctgov-officials moves 1,778 readings: 1,767 drop a report and nothing else, each a capitals listed member (`DO` 1,090, `MA` 426, `BA` 184, `JD` 35, `PA` 11, the rest five or fewer; by shape `Aa Aa, DO` 607, `Aa Aa Aa, DO` 244, `Aa Aa, MA` 176), and 11 move a field, each the run a settled member starts (the 6 accepted above and the 5 credential lists), 10 of them gaining the report on the name word the run absorbs; no other reading gains a report. fec-individuals, written in one case, and tse-donors match the base; psc-individuals matches it on every record (1,004,785 of 1,039,171 agreeing). The phase 2 spike's figures (71 committed, 1,853 ctgov, 7 FEC) were taken on another base with the retag, and are not this change's. FRAMES (`sys.setprofile` call events over a second parse, py3.11): the reference row and plain names cost what they did (`Dr. Juan0001 de la Vega III` 305, `John Smith` 149, `Smith, John` 158, `John Smith, PhD` 202); a name holding a member pays for the predicate where it is asked (`John Smith Ma` 213 → 220, `JOHN SMITH MA` 212 → 215, `John Smith MA` 212 → 203, the pick and its report gone) and for the run it now reads (`John Smith MA Jones` 232 → 259). Recompute: `uv run python tools/differential/snapshot.py --since `; for each local corpus `snapshot.py --local --base `, and `local_agreement.py psc-individuals --base `; the ctgov shape count by reading `corpus.jsonl` of ctgov-officials, keeping each distinct name whose part before its first comma holds, after two words, a word whose letters are a listed member written in capitals between two words that each open with a capital and are not wholly in capitals, and counting those whose suffix holds it, on this tree and on the parent. - 2026-10-25 #785, review — THREE SITES THE FIRST VERSION LEFT ASKING THE OLD QUESTION, found by the PR's code review on a grid of 104,676 parses (125 shapes, 23 spellings of the member as written, in upper and in lower case, three orders, four policies) against master, and a twin grid comparing each capitals member with its dotted spelling. (i) The comma's report on the part's opening word was withheld only where every listed member of the part was settled, so `Smith, MA PhD Ma` still reported its settled `MA` beside the Title-case `Ma` S7's company reads; it asks the word's own writing now. (ii) P2's chain stopped at a suffix word and not at a settled member, so after a family comma with a title-particle head the chain took the member and the words behind it into the given name in silence (`Smith, Freiherr von Berg MA Jones` → given 'von Berg MA Jones', where `Smith, Freiherr von Berg M.A. Jones` reads suffix 'M.A. Jones'); `_pieces.chain_run_end` asks `settled_by_case` now, the chain's units with it, and the member starts the run. (iii) P5's bound given-name join took a settled member as the given name's second word (`Smith, Abdul MA` → given 'Abdul MA' in silence, where `Smith, Abdul M.A.` reads suffix 'M.A.'); it declines one now, as it declines a suffix word (decisions/P5). Measured after the fixes, against the same master: the twin grid's capitals-against-dotted disagreements fall from 4,062 on master to 2,250, every one of which master shares; the main grid's removed reports all name a settled member and no report is added; ctgov-officials, fec-individuals and tse-donors move exactly as the entry above records, the three sites reaching no name in them. The design-docs review found the API reference (`AmbiguityKind.SUFFIX_OR_NAME`'s docstring), rules/P6 and Standing lines in decisions/M2, S4 and S5 still stating the old reports, and S8's Accepted clause narrower than its cost: a family name written in capitals by convention reads the same way (`Jean Pierre MA DUPONT` → suffix 'MA DUPONT'), and a name word behind the member that is title vocabulary reads as a title in silence (`Mary Jane MA King` → title 'King', as `Mary Jane M.A. King` reads); all are amended. Left as it reads: `John Smith MA y` reads family 'MA y' where `John Smith PhD y` reads suffix 'PhD y', a trailing connective no writing produces. - 2026-10-25 #785, review of the fix — THE CHAIN STOP AND THE JOIN'S DECLINE ARE NARROWED TO THE GIVEN PART AFTER A FAMILY COMMA. SUPERSEDES the review entry's (ii) and (iii) where they read "wherever". Applied everywhere, both left a settled member where nothing then took it, read as a name word in silence: the chain stopped in front of a member the trailing read declined (`Freiherr von Berg MA Ma` → given 'von Berg', middle 'MA', family 'Ma', and under the family-first orders given 'MA'; 162 parses of the fix review's grid, the only moves of the fix away from both master and the dotted spelling), and the join declined one with no comma (`Abdul MA Smith` → middle 'MA'). After a family comma the member always has a reader, the given part's trailing slot or the run S8 starts behind the given word, so the two widenings stand there and nowhere else; outside it the readings are master's (decisions/P2, decisions/P5). The comma's opening-word guard was found sound. - 2026-10-26 (Derek), #785 — A NAME WORD BEHIND THE CAPITALS KEEPS THEM A NAME WORD, WITH NO FAMILY COMMA. SUPERSEDES the 2026-10-25 #785 entry's ACCEPTED paragraph and its review entry's widening of that clause to capitals family names and title words. Derek, on PR #895: narrow #785's second question rather than accept its cost. In a name with no family comma, a listed member settled by its capitals does not start rules/S8's credential run where a name word stands right behind it: a particle, a word in a script with no case, or a word in Title case or in capitals that is no suffix word, no word of the class by shape and no member its own capitals settle, a particle run or a joined surname that group has merged counting by its first word (the narrowing's review found `Maria Ana MA de la Vega`, `Maria Ana MA Ortega y Gasset` and `John Smith MA 田中` starting the run under a first draft that asked only a one-word piece; a lower-case word does not count, as the rule was stated, so `John Smith MA jones` reads suffix 'MA jones'). The Ph./D. pair, which the walk leaves out, is no name word either: `John Smith MA Ph. D. Jones` reads suffix 'MA Ph. D. Jones' as its dotted twin does, where master read family 'MA' (found when codecov showed the split-credential exclusion unexercised). Such a word is evidence the capitals are the writer's initials (`Maria Ana MA Silva`) or part of a name written in capitals by convention (`Jean Pierre MA DUPONT`, the capitals family name counting as the name word, a reading the narrowing decides rather than leaves open), as the design's cue table has Title case lean to the name; and a title word behind them stays the family name it is (`Mary Jane MA King`). Each of the four keeps the reading 1.4.0 through 2.6.0 gave (middle 'Ana MA', family 'Silva', and so on). The cost, Accepted in rules/S8: `John Smith MA Jones` keeps middle 'Smith MA', family 'Jones', in silence as before, where `John Smith M.A. Jones` reads suffix 'M.A. Jones'. A credential behind the member still lets it start the run (`John Smith MA JD Jones` → suffix 'MA JD Jones'), and after a family comma nothing changes: the run starts behind the given word whatever follows (`Smith, John MA Jones` → suffix 'MA Jones'), which is where ctgov's five credential-list fixes stand. `_pieces.run_start` asks `_name_word_behind` of a settled member, and the given part's predictor (`trailing_run_start`) passes `initials_cue=False` so it keeps agreeing with assign's walk there. A first draft keyed the test on the class tag, which the caps switch (`CapsSuffixes.EVERYWHERE`) also writes on a numeral it admits by shape, so `John Smith VI Ma` lost its generation reading under that switch (middle 'Smith VI', family 'Ma'); the test asks `settled_by_case` now, and a case row holds the shape. MEASURED (2026-10-26, Python 3.11.17, against master a46b3a6e with `--base`): ctgov-officials moves 1,772 readings, the 1,767 dropped reports of the entries above and 5 field moves, the credential lists after a comma; the 6 initials readings are gone and nothing else moves. fec-individuals, tse-donors and psc-individuals match the base, and every agreement record matches. The committed snapshot moves 55 names against master, 48 a dropped report and 7 a field, every one a case-row or rules text. On the first review's grid of 104,676 parses, master against this tree, 1,227 parses move a field, each a mixed-case name whose capitals member now reads as a suffix, every report removed names a member that ends in the suffix field, and none is added; capitals-against-dotted disagreements are 3,078, every one of which master shares. derek73-python-nameparser-456b024/docs/design/decisions/S7-company-of-an-unambiguous-credential.md000066400000000000000000000627571527303271700330320ustar00rootroot00000000000000# S7 — the company of an unambiguous credential ## Standing Examples name each field as the default order reads it; under the family-first orders the given and family names trade places. - A listed member of the ambiguous class standing behind an unambiguous credential in one run of suffix words, other members between passing it on, reads as the credential at every trailing slot whatever its writing or the count (`John Smith PhD Ed Ma` → suffix 'PhD Ed Ma'; `Smith, PhD MEng` and `Doe, Jane PhD MEng` → suffix 'PhD MEng'); a credential behind the member speaks for nothing (`Wang Ma PhD` keeps family 'Ma') — 2026-09-27 #544, A CREDENTIAL RUN. - Where no credential run starts (S8's, or C13's in a part a credential opens after a comma), as with one name word before the credential and no comma, what anchors nothing and ends the run is the walk's own leading piece (`PhD Ma` → given 'PhD', family 'Ma'), a connective (`Jane Doe nee Puig i Ma` keeps maiden 'Puig i Ma'), a single-letter roman numeral in any case (`Om Jr V Ma` keeps family 'Ma', where `Om Jr III Ma` reads suffix 'Jr III Ma'), and a word of both the particle and the unambiguous suffix vocabulary (`Jan vd Ma` → family 'vd Ma'; `Smith vd Ma, John` → family 'Smith vd Ma'), whose exclusion is of the word in front only; where either run starts it takes the numeral, the particle and a name word (`John Smith PhD V Ma` and `Smith, PhD V Ma` → suffix 'PhD V Ma'), while a connective right behind the credential is joined to it first, so S8's run never starts (`John Smith PhD y Ma` → family 'PhD y Ma', `Smith, John PhD and Ma` → middle 'PhD and Ma'), except in C13's part (`Smith, PhD and Ma` → suffix 'PhD and Ma'), and one further in is the run's (`John Smith PhD Jones and Ma` → suffix 'PhD Jones and Ma') — 2026-09-27 #544, A CREDENTIAL RUN. - A title/suffix dual in the given part's leading title run after a one-word family comma is a title there, anchors nothing and silences the company for the part (`Smith, Ms Ma` → given 'Ma'; `Smith, RA PhD Ma` → title 'RA', given 'PhD', middle 'Ma'); anywhere else a dual anchors as any suffix word (`Jane Doe, MS LAc` → suffix 'MS LAc', at the accepted cost `John Smith, Ms Ma` → suffix 'Ms Ma') — 2026-09-27 #544, A CREDENTIAL RUN. - A member the company decides reports `suffix-or-name` where it was decided (`Smith, PhD Ma`); a member its own capitals made the credential reports nothing new (`John Smith, PhD MA`) — 2026-09-27 #544, A CREDENTIAL RUN. - Accepted: `Om Jr Ma` reads given 'Om', suffix 'Jr Ma' with no family name, and `Smith, MA PhD Ma` reads suffix 'MA PhD Ma' with no given name; the comma and given-slot spellings of the split `Ph. D.` anchor (`Smith, Ph. D. MEng` → suffix 'Ph. D. MEng') — 2026-09-27 #544, A CREDENTIAL RUN. - An unambiguous credential in front of the member overrides P6's attachment at the given slot, as the capitals do (`Doe, John PhD Do` → suffix 'PhD Do'); the one-case record with nothing in front keeps P6's reading (`NASCIMENTO, EDSON ARANTES DO` → family 'DO NASCIMENTO') — 2026-09-27 #544, THE #531 PAIRING. - `abdul Smith Jr Ma` reads given 'abdul', family 'Smith', suffix 'Jr Ma', the anchor reversing decisions/S6's accepted item (ii) — 2026-09-27 (#544). Not standing: the split of 2026-10-17 #682, carried out. Elsewhere: C1's count reading a post-comma run as it reads one word (`John Smith, Ed Ma` → suffix 'Ed Ma') — decisions/C4's 2026-09-27 (Derek), #544 entry; a listed member's chunked dotted spelling passing the period gate (`Wang M.Eng.`) — decisions/suffix-acronym-collisions' 2026-09-27 (#544) entry; a title between the credential and the member, and a member a particle chain took, read into the credential's run (`John Smith PhD Prof. Ma` → suffix 'PhD Ma', title 'Prof.') — decisions/S8's 2026-10-04 (Derek), #602 entry; a marker behind a credential is an ordinary word the run takes (`Jane Doe Jr. nee Smith Ma` → suffix 'Jr. nee Smith Ma') — decisions/M2's 2026-10-04 (Derek), #601 entry; `om` marked ambiguous, so it anchors nothing (`John Om Ma` → family 'Ma') — decisions/suffix-acronym-collisions' 2026-10-21 entry marking 33 credential readings ambiguous; the split `Ph. D.` in a main segment starting the credential's run, so `John Smith Ph. D. MEng` reads suffix 'Ph. D. MEng' — decisions/S12's 2026-10-25 #752 entry. Open: none in this file. ## Log - 2026-09-27 (Derek), #544 (its ACCEPTED LIMITS' merged `Ph. D.` outside the no-comma walk superseded by #752's phase 2e, decisions/S12, the merged pair starting the credential's run) (the `doe, jane v phd do` reading in its (7) and its 2026-09-28 addendum superseded by #721, decisions/S10: the one-case `v` is an initial and ends the run, so the name reads middle 'v', suffix 'phd do', the company still taking 'do') (its (10)'s `md` examples superseded by #853, decisions/indic-honorifics, `md` being no title: `Smith, MD PhD Ma` reads suffix 'MD PhD Ma') (its (6) and the addendum's `Jane Doe, PhD, Jr nee van Ma` superseded by #601, decisions/M2, a marker behind a credential or past a comma being an ordinary word; its ACCEPTED LIMITS' `John Smith PhD Prof. Ma`, `John Smith PhD Do Do` and `Smith, PhD Do Ma` superseded by the credential's run, #602, decisions/S8, and #603, decisions/C13, which read each as suffixes; (d)'s `John Smith PhD V Ma` superseded the same way, suffix 'PhD V Ma'; (c)'s `John Om Ma` superseded by the 2026-10-21 marking of `om` ambiguous, decisions/suffix-acronym-collisions, so it reads family 'Ma') — A CREDENTIAL RUN KEEPS ITS AMBIGUOUS MEMBERS: C1'S NAME-WORD COUNT READS A RUN (rules/C4 since #728) AS IT READS ONE WORD, AN UNAMBIGUOUS CREDENTIAL IN FRONT OF A MEMBER SPEAKS FOR IT AT EVERY TRAILING SLOT, AND A LISTED MEMBER'S CHUNKED DOTTED SPELLING PASSES THE PERIOD GATE. Three readers never looked past the member itself: C1 flipped a part to the suffix comma by the name-word count only when it was ONE token, and the trailing readers — the no-comma peel, the comma part read wholly as credentials, the given part's trailing slot — met the member first, took its name lean and stopped, so the credential in front of it was never reached. #540 marked `meng` and `lac`, whose conventional spellings lean name, which turned the one-case-row cost decisions/S6's 2026-09-15 item (ii) accepted into the ordinary spelling of two degrees. DECIDED, ten forks, (9) and (10) on 2026-09-28. (1) THE WHOLE RUN AT C1: two or more words after the comma, every one suffix vocabulary or a class candidate, at least one a candidate, behind two or more name words, is the credential run — all-member runs (`John Smith, Ed Ma`) and a leading member (`John Smith, MEng PhD`) included; offered and declined were an anchored-only rule and the issue's own "last word ambiguous" wording, which contradicted its `Ed Ma` consequence. (2) ALL TRAILING PATHS, ANCHORED IN FRONT: a listed member with an unambiguous credential in front of it, through other members, reads as the credential on the no-comma peel, in the comma part read wholly as credentials and at the given slot; a credential BEHIND it anchors nothing (`Wang Ma PhD` keeps family 'Ma'). (3) THE CHUNKED DOTTED GATE: a listed member written in two or more period-closed letter chunks (`M.Eng.`, `L.Ac.`) passes as `M.A.` does; one trailing period (`Ma.`, `Ed.`) still does not. (4) SINGLE-LETTER ROMAN NUMERALS neither anchor nor count toward a C1 run, S3's single-character retirement being the precedent; they peel as before, and the anchor's half of the exclusion is a matter of their shape, boundary (d) below. (5) NO NEW REPORT WHERE THE WRITING SETTLED IT: a run whose every member is LISTED and leans credential — S2's lean, capitals in a mixed-case name — is not flipped, the family-comma path already reading it as the credential run with the fields and the report it always had (`John Smith, PhD MA`) — narrowed 2026-10-01 by decisions/C4's #562 entry: a part holding two particles side by side is read by the count instead, and reports; the alternative, flipping such a run and suppressing only the flip's report, removes reports the family-comma path makes today, `John Smith, MA Jr` losing the one it makes for 'MA'. Because the lean is the listed set's, a by-shape member in capitals never settles a run: `John Smith, X.Y.Z. MA` flips to suffix 'X.Y.Z. MA' and reports. (6) THE ANCHOR DOES NOT REACH ACROSS A MAIDEN CLAUSE: `Jane Doe Jr. nee Smith Ma` keeps maiden 'Smith Ma' where `Jane Doe Jr. Ma` reads suffix 'Jr. Ma' — rules/M2's Accepted boundary, and the M2 bullet of this date. (7) AN ANCHOR OVERRIDES P6 AT THE GIVEN SLOT: `doe, jane v phd do` reads suffix 'v phd do' where it read family 'do doe'; the bullet below amends #531's pairing. (8) THE DUAL EXCLUSION IS SCOPED TO THE GIVEN PART'S LEADING TITLE RUN, NOT THE C1 RUN: after a full name the dual counts as the suffix vocabulary it is, as the legacy disjunct already counted it (`John Smith, MS MA`, `John Smith, MD`), and that is what reads the issue's headline `Jane Doe, MS LAc` as suffix 'MS LAc'. (9) A MEMBER THE COMPANY DECIDES REPORTS WHERE IT WAS DECIDED, in a family comma's part read wholly as credentials as at the peel: each member whose own writing declined and which the credential in front made the credential reports `suffix-or-name` at that reading. `Smith, PhD Ma`, `Smith, PhD MEng` and `Smith, Ph. D. MEng` report the member; `Smith, Dr. PhD LAc` and `John Quincy Smith, Prof. PhD LAc` report 'LAc'; `john smith, v phd ma` reports 'ma'; `John Smith, PhD Ma Prof.`, which C1 does not flip for its trailing title, reports 'Ma' where its twin `John Smith, PhD Ma` reports C1's flip once over the part. A member its own capitals made the credential stays silent (`Smith, PhD MA`), and the part's first piece, which nothing stands in front of, is the first-slot report's word and never this one's. (10) A DUAL IN THE GIVEN PART'S LEADING TITLE RUN SILENCES THE WHOLE PART: refinement (b) below reads such a dual as a title that speaks for nothing, and in a part whose leading title run holds one no credential behind the dual speaks for a member of the ambiguous class either, so the company never reads such a part wholly as credentials: the titles give it its reading, the dual a title and the next word the given name, and behind that given name the given slot reads as it reads any (`Smith, MD PhD Jr Ma` reads suffix 'Jr Ma', as `Smith, MD Jane Jr Ma` does). A part with no member to speak for, or only members whose capitals decide them, reads as it did: `Smith, MD PhD` suffix 'MD PhD', `Smith, Ms MD MA` suffix 'Ms MD MA'. `Smith, Ms MD Ma`, `Smith, MD MS Ma` and `SMITH, MD MS BA` read title 'Ms MD' / 'MD MS', given 'Ma' / 'BA'; `Smith, MD PhD Ma` reads title 'MD', given 'PhD', middle 'Ma', the given slot reporting 'Ma'; e10e83b4 read each of them so. The run is the walk's leading title run, so a dual behind a plain title stands in it too (`Smith, Prof. MD Ma` and `smith, prof. md ma` read title 'Prof. MD', given 'Ma'; `Smith, Dr. MD PhD Ma` keeps given 'PhD'), while a plain title alone silences nothing (`Smith, Dr. PhD LAc` reads title 'Dr.', suffix 'PhD LAc'). Behind a real given name the company reads as ever (`Doe, Jane MD Ma` and `Doe, Jane Ms Ma` read suffix 'MD Ma' and 'Ms Ma'), and past the title run a dual speaks like any suffix word (`Smith, PhD Ms Ma` reads suffix 'PhD Ms Ma'); `Smith, Ms Ma`, `Nguyen, Sr Ba` and `Smith, MD Do` are unchanged. WHAT MAY ANCHOR, four boundaries of the company clause, each a rule. (a) A CONNECTIVE (P3) anchors nothing and ends the run: the generational `i` is also Catalan's conjunction, and `Jane Doe nee Puig i Ma` keeps its link, maiden 'Puig i Ma'. (b) A TITLE/SUFFIX DUAL IN THE GIVEN PART'S LEADING TITLE RUN after a one-word family comma is a title there and anchors nothing — and, by (10), no credential behind it speaks for a member in that part — so the given slot's pass starts past the leading title run: `Smith, Ms Ma`, `Nguyen, Sr Ba` and `Smith, MD Do` keep their given names, and `Smith, MD MA Ma` keeps middle 'Ma', no unambiguous credential standing in its run once the title is set aside. Everywhere else a dual anchors like any suffix word (`John Smith MD MEng`, and (8)). (c) THE WALK'S OWN LEADING PIECE NEVER ANCHORS. In a name with no comma before the run, the leading piece is the name word H4's carve-out keeps whatever its vocabulary, and letting it anchor spends that name: in `Om Ma` and `PhD Ma` 'Ma' stays a name word under every name order (family 'Ma' in the default order, given 'Ma' under both family-first orders), where an anchoring `PhD` would leave given 'PhD', suffix 'Ma' and no family at all. One name word in front frees the same word — `John Om Ma` reads suffix 'Om Ma'. A family comma's given part has no such position, its first piece being read by the comma part's own credential reading, so `Smith, PhD MEng` reads family 'Smith', suffix 'PhD MEng'. (d) A SINGLE-LETTER ROMAN NUMERAL, IN ANY CASE (`V`, `v`, `I.`), anchors nothing for its SHAPE — one letter is how a middle initial is written, and S3 retired single-character vocabulary matches for the same reason (`John Smith PhD V Ma` keeps family 'Ma') — not because a generation is no credential. The rule is stated by that shape and the code tests exactly it (`_vocab.is_single_letter_numeral`); "initial-shaped" was the wrong word for it, `is_initial_shaped` answering False for a lower-case `v` that the exclusion covers too. And the exclusion is not about generations: a multi-letter numeral or generational word anchors as any suffix word does (`John Smith PhD III Ma` reads suffix 'PhD III Ma', `abdul Smith Jr Ma` suffix 'Jr Ma'). ACCEPTED COSTS, each a reading this design chooses. `John Smith, Ms Ma` → suffix 'Ms Ma', reported, as `John Smith, Ms` alone already reads suffix (8). `Om Jr Ma` → given 'Om', suffix 'Jr Ma' and no family name: 'Jr' is not the leading piece, so it anchors 'Ma', and rules/S2's standing Accepted clause already consumes an unambiguous suffix that leaves no family (`Om Jr` → given 'Om', suffix 'Jr'); every release from 2.0.0 made that same role assignment. `doe, jane v phd do` reports `suffix-or-name` where it reported `particle-or-given`, the fork reported being the credential pick rather than P6's attachment (7). And a one-word family comma whose given part OPENS with a capitalised `MA` in a mixed-case name, with an unambiguous credential behind it, reads the whole part as a credential run, family 'Smith' and no given name, where the parent kept given 'MA': `Smith, MA PhD Ma` read given 'MA', middle 'Ma', suffix 'PhD' and reads suffix 'MA PhD Ma'. 'MA' leans credential on its capitals, so it is read as the credential it is written as, and what follows it is then the company's (or, for `M.Eng.`, the chunked gate's). Measured 2026-09-28 on the grid below: 138 parses (46 texts, three orders each) go from given 'MA' to no given name, 72 of them with no `M.Eng.` in the run; 12 of the 138 are the parses the attribution below leaves unattributed, the rest falling in the anchor and `M.Eng.` classes. Each reports 'MA' at the first slot, and a member behind the credential reports as the company's pick (9). ACCEPTED LIMITS, each keeping the member a name word, rules/S2's Accepted block and case rows: the merged `Ph. D.` is outside the no-comma walk (`John Smith Ph. D. MEng` family 'MEng'; the comma and given-slot spellings do anchor it, `Smith, Ph. D. MEng` and `Doe, Jane Ph. D. MEng` reading suffix 'Ph. D. MEng'), a title between the credential and the member breaks the run (`John Smith PhD Prof. Ma`), and a particle member P2 has chained before the peel is out of reach (`John Smith PhD Do Do` family 'Do Do'; `Smith, PhD Do Ma` given 'PhD', middle 'Do Ma'). THE TWO-INPUT CHECK. tests/v2/test_properties.py's M2 clause-agreement walk asks every parse it takes both directions of the company clause: a listed member behind a qualifying credential reads as one, and a member read as a credential because of the word in front has that word in the suffix too. Recorded negative controls, from that test's docstring: 48 failing parses with the anchor off, 30 with the walk's leading piece allowed to anchor, 0 on this tree; re-run 2026-09-28 after (9) and (10), unmoved. The walk holds no comma head that opens its part with a dual, so (10) is pinned by case rows and a unit test instead (`a_dual_opening_the_given_part_turns_the_anchor_off`, `a_dual_opening_the_given_part_silences_a_later_degree`, test_pieces' `test_a_dual_in_the_leading_title_run_turns_the_anchor_off`). REVERSED BY THIS ENTRY rather than edited: decisions/S6's 2026-09-15 ACCEPTED item (ii) (the bullet after this entry says so); the three "wrongly moved" reversals decisions/S9's 2026-09-14 paragraph THE RUN IS THE CAPS CLASS'S ALONE records — `John Smith, Ed Ma`, `John Smith, ma do` and `John Smith, X.Y.Z. A.B.` are credential runs now by C1's own count (rules/C4 since #728) under every policy that keeps their words in the class (with `unlisted_dotted_suffixes` off, `X.Y.Z.` and `A.B.` are no class members and the third keeps the listing form), while that paragraph's narrowing of the CAPS run test stands; and #540's two pending questions (the `suffix-acronym-collisions` bullet of this date). MEASURED 2026-09-28, the #544 tree against its parent e10e83b4, py3.11, after (9) and (10); the 2026-09-27 figures this paragraph first carried were taken before them and are superseded. Recompute: every run of one to three words from {PhD, MD, Jr, MA, Ma, MEng, M.Eng., LAc, Ed, Do, ba, MS, V, Prof.} behind each of `Wang {}`, `John Smith {}`, `John Quincy Smith {}`, `Dr. John Smith {}`, `John Smith, {}`, `John Quincy Smith, {}`, `Smith, {}`, `Doe, Jane {}`, `Jane Doe nee Smith {}`, `Doe, Jane nee Smith {}` and `Jan van der Berg {}`, each written as given, lower-cased and upper-cased and deduped, parsed under the three name orders at otherwise default policy, the seven role fields and the ambiguity kinds compared against the same grid parsed by e10e83b4. THE DETECTOR, which both the invariants and the attribution read, looks only at the run's own words, compared case-free: {phd, md, jr, ms, m.eng.} are the unambiguous credentials, {ma, meng, lac, ed, do, ba} the members; a member is ANCHORED where an unambiguous credential stands in front of it in the run with nothing but members between, except that `ms` or `md` opening a comma part anchors nothing; and a run word is IN A NAME FIELD where it appears among the space-split words of given, middle, family or maiden more often than the run's unconstrained occurrences of it account for. The two invariants count parses with an anchored member in a name field, and with an unambiguous credential in one. THE ATTRIBUTION puts each moved parse in ONE class, the first that holds, in this order: THE ANCHOR, where the tree's two violation counts are each no higher than the parent's and one is lower; THE CHUNKED `M.Eng.`, where the run holds `M.Eng.`; A LONE `v`, where re-running the first test with `v` counted as an unambiguous credential makes it hold; THE C1 RUN (rules/C4 since #728), a comma shape with two or more name words before the comma whose run is only credentials and members, at least one a member; and anything left unattributed. Results: 254,496 parses over 84,832 texts, 89,825 move (29,943 texts), every one attributed but the 12 `Smith, MA PhD ba` parses above, with 0 new violations of either invariant (an anchored member in a name field 29,199 → 1,530, an unambiguous credential in one 36,675 → 4,734); the differential corpora at the parent move four intended names; the reference frame band is unmoved, the new questions paying only where they are asked (PR #552). NO MECHANISMS ENTRY IS OWED: the anchor pass is ONE-PREDICATE-PER-QUESTION's `_pieces.credential_anchors`, which `segment_suffix_reading` reads inline for the frame budget under a keep-in-step note, and its linearity is the answer #531's trailing floor and #397's `_run_neighbours` already give — one forward pass per question, never a look-behind per member. tests/v2/test_benchmark.py's `credential_run` shape guards it: the per-member look-behind measured 15.8× for 4× the input at base 800, against 4.13–4.21× on this tree at every base (that module's own record). ADDENDUM 2026-09-28 (Derek). (e) A PARTICLE ANCHORS NOTHING, a fifth boundary beside the four WHAT MAY ANCHOR lists: a word of both the particle and the unambiguous suffix vocabulary (`vd` and `mc` in the shipped lexicon; `do` sits in the ambiguous half, so it is a member and never an anchor) heads the family name behind it (P2 when decided, rules/P9 since 2026-10-19), and anchoring there split the family around a suffix — `Smith vd Ma, John` read family 'Smith Ma', suffix 'vd'; `Jan vd Ma` suffix 'vd Ma' and no family; `Smith Mc Ma, John` and `D. Mc Ba Ed, Smith` the same way. Each reads as e10e83b4 reads it again (family 'Smith vd Ma', 'vd Ma', 'Smith Mc Ma', 'D. Mc Ba Ed'). The exclusion is of the word in front only: a particle MEMBER behind a credential is still spoken for (`doe, jane v phd do` keeps suffix 'v phd do'), and a C1 run (rules/C4 since #728) holding the particle is C1's reading rather than the anchor's (`Jan Berg, vd Ma` reads suffix 'vd Ma', as `Jan Berg, vd` reads suffix 'vd'). Measured over the grid of MEASURED above with `vd` and `Mc` added (382,734 parses), against the tree before this addendum: 9,426 parses move, every one holding `vd` or `Mc` directly in front of a member, 8,940 returning to e10e83b4's reading exactly and the other 486 differing only by `M.Eng.`'s own change (3); the differential corpora and the grid without the two words move 0 (PR #552). THE PASS IS ASKED ONLY WHERE A SUFFIX PIECE IS IN REACH: `_pieces.anchor_in_reach` walks back through the lone members in front of a declined member on tags alone and answers False wherever the pass must — the first piece past the members is no suffix piece, or there is none — and it is asked only before the pass exists, one lookup answering after that, so a run of members stays linear. An ordinary name ending in a declined member stops paying for the pass (`Jan vd Ma` 308 → 243 frames, py3.11), and a name the pass does read pays the test on top (+3). On the no-comma peel the reach starts past the walk's leading piece, which never anchors — so a by-shape member, which reaches the pass only at the peel's second position (a lean of None with a word to spare is consumed first), finds nothing in reach, and the by-shape guard that stood there was deleted as unreachable. `segment_suffix_reading` calls `credential_anchors` (`first_kept=False`, a comma part's first piece being a run member like any other) in place of the inline copy NO MECHANISMS describes, and the keep-in-step note goes with it. The pass is built only where the reach test finds a suffix piece in front of a declined member, which no member opening the part has, and a part whose name word stands ahead of any member returns before one is asked: a plain comma name pays nothing (built on every declined member instead, `Smith, Ed`, `Smith, Ma` and `Smith, Ed John` paid 215, 215 and 273 against 213, 213 and 270), a title in front pays the reach test alone, and a part the company decides pays three frames over the copy. THE TWO-INPUT CHECK's second control reads 42, not 30, with no change to the property: the walk's leading piece is now held out of the pass twice on the no-comma peel, by `credential_anchors` and by the reach test, either alone suffices, and with both removed the by-shape heads the deleted guard held back (`PhD X.Y.Z.`) fail beside the listed ones; the first control is unmoved at 48. And the 2026-09-28 C2 bullet's "a TAIL segment, which assign reads wholly as suffixes" holds outside a maiden clause standing in it — `Jane Doe, PhD, Jr nee van Ma` keeps maiden 'van Ma' — which rules/C2's statement now says. The reach test's one-way exactness is held by tests/v2/pipeline/test_pieces.py's `test_anchor_in_reach_never_hides_an_anchor`, over the case table's texts and runs of one to three words behind two heads; its recorded negative control, the reach test reading a `vocab:suffix` token as no suffix piece, fails at 878 member positions. - 2026-09-27 (#544) — THE 2026-09-15 ACCEPTED ITEM (ii) IS REVERSED, and the bullet stands as it landed. `abdul Smith Jr Ma` reads given 'abdul', family 'Smith', suffix 'Jr Ma' — 2.3.0's reading — because the unambiguous 'Jr' in front of the Title-case 'Ma' anchors it (the entry above): the peel takes both, and P5's reserve, now seeing the family the join would take, declines the join. Item (i) stands. The case row is now `a_credential_in_front_anchors_a_declined_pick`, and rules/S2's Accepted block names the shapes that still keep the company out of reach. - 2026-09-27 (Derek), #544 (its `doe, jane v phd do` reading superseded by #721, decisions/S10: in a name written in one case the `v` is an initial, which ends the run, so the name now reads middle 'v', suffix 'phd do', the company still taking 'do') — THE #531 PAIRING GAINS A SECOND EXCEPTION, and decisions/S5's CAPITALS DECIDE FOR `do` stands as it landed. That bullet let only a positive credential lean override P6's attachment at the given slot; an unambiguous credential IN FRONT of the member now overrides it as well, the degree being a second and stronger signal: `doe, jane v phd do` reads suffix 'v phd do' and reports `suffix-or-name` where it read family 'do doe' and reported P6's fork. The one-case record the pairing protects has nothing in front of its particle, so `NASCIMENTO, EDSON ARANTES DO` still reads family 'DO NASCIMENTO' and reports `particle-or-given`. - 2026-10-17 #682 — SPLIT FROM decisions/S2 with the rule. The entries above were written under decisions/S2 while this rule was part of rules/S2, and moved here unchanged but for pointers the move broke; where one says rules/S2 it means the rule as it then stood, which held this one. derek73-python-nameparser-456b024/docs/design/decisions/S8-credentials-trailing-run.md000066400000000000000000000430641527303271700306170ustar00rootroot00000000000000# S8 — the credential's trailing run ## Standing Examples name each field as the default order reads it; under the family-first orders the given and family names trade places. - An unambiguous suffix word of two or more letters, in any script, standing after two name words with no comma or before a suffix comma, or after the given word in the part after a family comma, starts a run to the end of its part: every later word reads as a suffix except a title word, which reads as a title, and an absorbed name word reports `suffix-or-name` (`John Smith PhD Jones` → suffix 'PhD Jones'; `Eric H. Holder Jr. Attorney General` → suffix 'Jr.', title 'Attorney General') — 2026-10-04 #602; 2026-10-14 #679. - A roman numeral of two or more letters that reads as the generation starts the run as a listed one does (`John Smith VI Jones` → suffix 'VI Jones', as `John Smith III Jones`), its shape alone reported — 2026-10-25 #784. - What never starts the run: a title word (`Mary Jane Pope Smith` keeps family 'Smith'), a member of the ambiguous class but a credential split across a space (below) and a listed member its capitals settle (below), a single letter, a connective (`Josep Carod i Rovira` keeps family 'Carod i Rovira'), and a word that is also particle or bound given-name vocabulary (`Mohamed Ali Abd Allah` → family 'Allah'); a title/suffix dual does start it — 2026-10-04 #602. - An honorific or generational word starts the run as a credential does, whatever its script (`राम शर्मा साहब मोहन` → suffix 'साहब मोहन'); the one-character honorifics stay out by the two-letter minimum — 2026-10-14 #679. - A lone particle counts toward the two name words only where the credential stands right behind it (`John von PhD Jones` → family 'von', suffix 'PhD Jones', under the family-first orders given 'von'); a particle run counts once, and a particle the run took is not asked (`Freiherr von vd PhD Jones` keeps middle 'PhD') — 2026-10-10 #627. - Lifting the credential out of the name, the family-comma path's older reading (`John Smith PhD Jones` as family 'Jones', suffix 'PhD'), was declined — 2026-10-04 #602. - An ambiguous acronym written with its periods starts the run (`John Smith M.A. Jones` → suffix 'M.A. Jones'); and so, since 2026-10-25 #785, does a listed member written in capitals in a name of mixed case, its writing settling it, after the given word after a family comma (`Smith, John MA Jones` → suffix 'MA Jones') and elsewhere only where no name word stands behind it (`John Smith MA JD Jones` → suffix 'MA JD Jones', while `John Smith MA Jones` keeps family 'Jones', 2026-10-26 (Derek)); `John Smith Ma Jones` and `JOHN SMITH MA JONES` keep family 'Jones', and `John Smith DO Jones` family 'DO Jones', its particle reading refusing the start — 2026-10-20 #761; 2026-10-25 #785; 2026-10-26 (Derek), #785. Not standing: the split of 2026-10-17 #682, carried out. Elsewhere: the split `Ph. D.` starting the run in a main segment too, from where it stands though the walk leaves it out, and any other credential split across a space starting it as the one member of the ambiguous class that does (`John Smith Ph. D. Jones` → suffix 'Ph. D. Jones', reported; `John Smith Pharm D Jones` → suffix 'Pharm D Jones') — decisions/S12's 2026-10-25 #752 entry; a credential opening the part after the comma, and the split `Ph. D.` starting the run as `PhD` does (`Smith, John Ph. D. Jones` → suffix 'Ph. D. Jones') — decisions/C13's 2026-10-04 (Derek), #603 entry; the run stopping short of a lone letter ending the given part ahead of a further comma (`Smith, John PhD I, Jr.` → middle 'I', suffix 'PhD, Jr.') — decisions/C9's 2026-10-25 #819 entry. Open: none in this file. ## Log - 2026-10-04 (Derek), #602 (its Latin restriction and its non-Latin honorific exclusion superseded by #679, below; its lone particle's count superseded in part by #627, below; its merged `Ph. D.` kept out of reach superseded after a comma by #603, decisions/C13, and in a main segment by #752's phase 2e, decisions/S12) — A CREDENTIAL AFTER THE NAME CORE STARTS A RUN TO THE END OF ITS PART. An unambiguous Latin suffix word (the Latin restriction retracted by #679, below) of two or more letters standing after two name words with no comma (a lone particle not counting toward the two; qualified by #627, below), or after the given word in the part after a family comma, makes every later word of that part a suffix, except a title word, which reads as a title: `John Smith PhD Jones` → suffix 'PhD Jones', `Eric H. Holder Jr. Attorney General` → suffix 'Jr.', title 'Attorney General'. A name word the run absorbs is reported `suffix-or-name`, a reader hesitating over `Jones` there. Before this the three comma shapes disagreed — middle 'Smith PhD' with no comma, middle 'Jones' and suffix 'PhD' after a family comma, and suffix 'Jr., Jones' after a suffix comma (C2's tail) — and the no-comma reading made a credential a middle name, the one reading nobody intends. The suffix-comma path already read it this way, and the comma spelling of the Holder name always did. WHAT STARTS THE RUN is one predicate, `_pieces.starts_a_credential_run`, read by the trailing peel (`credential_run`, applied to every finished peel, so assign, P5's reserve, P2's chain stop and the maiden walk see one answer) and by the given-part walk. EXCLUDED, each with its reason: a TITLE WORD — 746 title words are in no suffix set (measured 2026-10-04: `len(L.titles - L.suffix_acronyms - L.suffix_words)` for the default lexicon), many of them surnames (`King`, `Bishop`, `Judge`), so `Mary Jane King Smith` keeps family 'Smith'; a title is read from the end of a name only behind a comma or with a period (H5, Derek). The AMBIGUOUS CLASS, which carries no evidence of which it is. A SINGLE LETTER, in any case: a lowercase `i` or `v` carries no `initial` tag, and found by the v1 off-switch test, `Josep Carod i Rovira` with `i` removed from the conjunctions read suffix 'i Rovira'. A CONNECTIVE. A word that is also PARTICLE or BOUND GIVEN-NAME vocabulary — found by the suite, not foreseen: `abd` is in the credential list (ABD) and heads `Abd Allah`, and `Mohamed Ali Abd Allah` read suffix 'Abd Allah' until it was excluded. The NON-LATIN HONORIFIC words, an honorific followed by a name word being a different question from a credential followed by one (retracted by #679, below). A word that is also a title (`MD`, `Ms`, `Sr.`) does start the run: position decides, and after the name core it is the suffix. DECLINED: lifting the credential out — `John Smith PhD Jones` as given 'John', middle 'Smith', family 'Jones', suffix 'PhD', the family-comma path's old reading. It keeps every name word in a name field, but it reads a credential as something written in the middle of a name, which is the reading the rule rejects. REVERSED BY THIS ENTRY rather than edited: decisions/S5's #578 bullet (`Doe, Jane PhD vd Ma` now reads suffix 'PhD vd Ma', the credential starting the given part's run), and two of the three shapes #544's ACCEPTED LIMITS (decisions/S7) kept out of the company's reach — a title standing between the credential and the member, and a member a particle chain had taken. The case rows `a_title_between_degree_and_member_keeps_it_a_name` and `a_member_the_particle_chain_took_is_out_of_reach` keep their ids and now pin the run. The third, the merged `Ph. D.`, stays out of reach: that piece is outside the walk the run is read over. MEASURED 2026-10-04, every name in `tools/differential/corpus*.jsonl` parsed on this tree and on aa401d80 with `nameparser.__file__` asserted on each side: 7 of 1,514 move, four of them this entry's own new rules/S2 examples. The three already in the corpus: `Jane van der Berg née Jr Jones` (suffix 'Jr Jones', where the marker's declined clause had left middle 'van der Berg née Jr'), and `John Doe, MD - PhD - FACS` and `Smith, MD - PhD - FACS`, whose dash inside the given part, a word there under every policy (#549), is now in the run: suffix 'PhD - FACS' where they read middle '-', suffix 'PhD, FACS'. The comma-part question — whether a part OPENING with a credential makes the comma a suffix comma (`John Smith, PhD Jones` still reads family 'John Smith') — is #603's. - 2026-10-10 (Derek), #627 — A LONE PARTICLE WITH THE RUN RIGHT BEHIND IT COUNTS AS A NAME WORD. #602's run start needs two name words before the credential, and "a lone particle is not a name word for the count", for `de Mesnil`, where the particle and the word behind it are one surname. Since #620 the count is taken in the chain's units, so a particle that opens a unit counts once (`van der`, `von Berg`) and a P3-joined phrase is one piece (`von und zu`); the lone particle stayed uncounted only where nothing joins it, which is exactly where a credential stands behind it. So `John von PhD Jones` read middle 'von PhD', family 'Jones' -- the chain later taking the credential as the particle's surname -- while `John van der PhD Jones` and `John von und zu PhD Jones` read family 'van der' / 'von und zu', suffix 'PhD Jones', and the comma spelling `Smith, John von PhD Jones` already read suffix 'PhD Jones'. The issue's question was which way to make them agree. DECIDED (Derek): the particle stops at the credential, so it is a name word of its own and the rest is suffix: `John von PhD Jones` → family 'von', suffix 'PhD Jones'. The input is malformed (a particle is not written before a post-nominal), so the decision is whichever reading the shared count already gives the other forms, not a special case for this one: the existing clause gains its own limit, a lone particle counting when the run starts right behind it, in `run_start`'s one loop. The caller without the chain's units (`trailing_start`) shares the expression so the two cannot disagree, but no reading shows that half: it changes the position it returns for `Smith, John von PhD Jones` (2 where master gave 4), so group's chain takes its stopped path and asks `trailing_start` again, reaching the same reading at 16 more frames (339 → 355 on py3.11; a rare shape no frame guard sees, decided for the shared expression over a per-caller copy); a mutant ignoring the flag there passed the suite with the grid byte-identical (#627's reviews). The 1.4.0 and 2.3.0 wheels read middle 'von PhD', as they read `von und zu PhD`; the joined forms changed this cycle with #602. Declined: counting every unit, a lone particle included. It moved 2,836 S2-grid parses rather than 2,432, the extra ones all `de Mesnil `, where it counted the surname as two words, and it split the two callers: a leading `van` counted before another particle (`van la Smith Jr. Jones` → suffix 'Jr. Jones') but not before a name word (`van Smith Jr. Jones`), and the case row `van la Smith Secretary Jr. Dr. Smith` caught it. MEASURED 2026-10-10 against 195c4081 (#630's merge), py3.11, `nameparser.__file__` asserted on each side. S2 stress grid (decisions/S2's #614 entry's recipe, 324,552 parses): 2,432 role moves, no report-only move; every move is a lone particle -- in that grid `van`, or a dual `do`/`mc`/`vd` in any case -- with an unambiguous credential right behind it becoming the family (under the family-first orders the given name, the family staying first), the run taking the rest. The grid's comma shapes do not move; the review found the same move in shapes the grid lacks, as intended: other particles (`von`, `de`, `du`, `la`, `bin`), and a head before a suffix comma (`John von PhD Jones, Jr.` → family 'von', suffix 'PhD Jones, Jr.'). Fingerprint of every `tools/differential/corpus*.jsonl` name and `tests/v2/cases.py` text at 195c4081 under the three orders (1,925 texts, the seven fields and every report): 0 moves; this change adds one text, its own example, which moves. The five differential gates exit 0. Held by the case row `John von PhD Jones` (`fix(#627)`) against the row `van la Smith Secretary Jr. Dr. Smith`, whose lone particle has a name word behind it and still does not count, and the row `Freiherr von vd PhD Jones`, where the particle standing right before the credential is one the run took and the flag is not asked of it (a mutant asking it of every piece passed the suite until that row). - 2026-10-14 (Derek), #679 — ANY UNAMBIGUOUS SUFFIX WORD OF TWO OR MORE LETTERS STARTS THE RUN, WHATEVER ITS SCRIPT; THE HONORIFIC EXCEPTION IS RETRACTED. #602 excluded "the NON-LATIN HONORIFIC words, an honorific followed by a name word being a different question from a credential followed by one", and drew that line by script, the vocabulary having no honorific marker. #661 then added Cyrillic and Devanagari GENERATIONAL words the script line refused too, splitting Serbian by the script it is written in (#generational-words). Measured 2026-10-14 over every non-Latin suffix word (51) in six shapes under three orders (918 parses), the exception protected no correct reading: an honorific followed by a name word read as a MIDDLE name, in silence (`राम शर्मा साहब मोहन` gave middle 'शर्मा साहब', family 'मोहन'; `משה כהן ז"ל דוד` middle 'כהן ז"ל'), and dropping it reads the honorific as the suffix it is with the word behind it absorbed and reported, as `John Smith PhD Jones` reads. 360 of the 576 honorific parses move and 285 of the 342 generational ones, the same 120 and 95 under each order; the one-character honorifics (씨 様 氏 殿 군 님 양, and जी, whose vowel sign is no letter) never move, S2's two-letter minimum keeping them out already. No realistic name with a name word after an honorific was found, and no corpus name outside rules.md's own examples moves; of the three S2 examples that had pinned the refusal, two now pin the run, and the mixed-script `Taro Tanaka さん Jones` gives way to `राम शर्मा साहब मोहन`. Recompute: the words are master's `Lexicon.default().suffix_words` (after #681) whose letters are not all LATIN by Unicode name; each goes between same-script name words in the shapes `{a} {b} {w} {c}`, `{b}, {a} {w} {c}`, `{a} {b}, {w} {c}`, `{a} {b} {w} {c} {c}`, `{a} {w} {c}` and `{b} {a} {w} {c}`, under each of the three orders, keyed by the order's NAME (the first count keyed by its first role and lost an order, two orders both beginning with FAMILY); compare `as_dict()` and the ambiguity kinds with `_pieces.starts_a_credential_run`'s script test in and out. Generational words are not a class of their own here: a suffix word is a suffix word, which is what drops `_pieces._all_latin` (#681, the same day) and the need for any honorific or generational marker as #679's roster adds non-Latin words. - 2026-10-17 #682 — SPLIT FROM decisions/S2 with the rule. The entries above were written under decisions/S2 while this rule was part of rules/S2, and moved here unchanged but for pointers the move broke; where one says rules/S2 it means the rule as it then stood, which held this one. - 2026-10-20 (Derek), #761 — AN AMBIGUOUS ACRONYM WRITTEN WITH ITS PERIODS STARTS THE RUN. The skeleton migration (PR #783) found S8 ("a member of the ambiguous class" never starts the run) and S3 (an ambiguous acronym written with its periods "counts unambiguously") both claiming `John Smith M.A. Jones`, with no text naming the winner; the parser reads suffix `M.A. Jones`. Derek: "writing periods makes it unambiguously not a name". S3 wins, written as a Precedence bullet in rules/S8; no behavior moves. The bullet is scoped to the periods: the capitals alone do not start the run (`John Smith MA Jones` reads middle `Smith MA`, family `Jones`), a question tracked in [#785](https://github.com/derek73/python-nameparser/issues/785). - 2026-10-25 #784 — A NUMERAL THAT READS AS THE GENERATION STARTS THE RUN, WHATEVER ITS SPELLING. The run start asked the suffix vocabulary, so a listed numeral started it and a numeral S10 reads by its shape did not: `John Smith III Jones` → suffix 'III Jones', `John Smith VI Jones` → family 'Jones'. It now asks the numeral's predicate (decisions/vocabulary-model's 2026-10-25 2f entry) for a numeral of two or more letters, with the word in front and the name's case, so a Title-case `Vi` in a mixed-case name, which leans to the name, and a numeral behind an initial start nothing. A single letter still never starts the run. The numeral starting the run reports `suffix-or-name` where its shape alone reads it, as S10's numeral does anywhere it is weighed. The given part after a family comma starts its run the same way (`Smith, John VI Jones`). Separately, #819's reading of a lone letter ahead of a further comma stops the run short of that letter (decisions/C9's 2026-10-25 #819 entry). - 2026-10-25 #785 (Derek, 2026-10-23) — A LISTED MEMBER ITS CAPITALS SETTLE STARTS THE RUN, as its dotted spelling has since 2026-10-20 #761, answering #785's second question. Asked of the run start's one predicate (`_pieces.starts_a_credential_run`), whose particle test still refuses `DO`, so a member's other readings answer as before; the inline pre-tests ahead of it (the trailing run's start and the given part's) admit a member, as they admit a numeral. Accepted: a writer's initials in capitals between the given names and the family name that spell a listed member start the run too (rules/S8's Accepted clause). The record, with what was measured, is decisions/S6's entry of this date. - 2026-10-26 (Derek), #785 — WITH NO FAMILY COMMA A NAME WORD BEHIND A SETTLED MEMBER KEEPS IT FROM STARTING THE RUN. SUPERSEDES the rule's Accepted clause of 2026-10-25 (capitals initials and capitals family names reading as suffixes): `Maria Ana MA Silva` keeps family 'Silva', and the Accepted cost is now `John Smith MA Jones` keeping family 'Jones'. The record is decisions/S6's entry of this date. S9-unlisted-words-that-join-the-ambiguous-class-by-shape.md000066400000000000000000001312121527303271700360620ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/decisions# S9 — unlisted words that join the ambiguous class by shape ## Standing Examples name each field as the default order reads it; under the family-first orders the given and family names trade places. - `Policy.unlisted_dotted_suffixes`, default on: an unlisted token of two or more period-separated chunks joins the ambiguous class by shape, whole-token vocabulary still winning, and the positional rule decides and reports (`John Smith X.Y.Z.` → suffix, `Jack X.Y.Z.` → family); case is no evidence (`john smith x.y.z.` → suffix); switched off, the token is name material and the fork still reports — 2026-09-14 (Derek). - The dotted shape admits only alphabetic chunks (`John Smith 1.4` → family '1.4', unreported) and no script without period abbreviations (`John Smith 田.中.` → family '田.中.') — 2026-09-17/18 #516 review rounds. - A dotted token of unlisted single-letter chunks joins by shape and reports (`Smith, J.R.` → given 'J.R.'), and the comma's wholly-suffix test never reads the by-shape class (`Smith Jr., A.B.` → given 'A.B.', suffix 'Jr.') — 2026-09-17 #516 review round. - At the first slot after a comma, paired initials (two single letters joined by a period, the trailing period optional) are the given name and report (`García Márquez, G.J.` → given 'G.J.'); only an unambiguous suffix word in front that is not title vocabulary, or a second dotted-shape word in the part, makes them the credential run (`John Smith, PhD X.Y.` → suffix 'PhD X.Y.'; `García Márquez, Ms G.J.` → title 'Ms', given 'G.J.'), while a word behind (`De La Cruz, M.J. PhD`) or a class member in front (`García Márquez, Ed G.J.` → given 'Ed') speaks for nothing, and three letters run together read by the count (`García Márquez, G.J.R.` → suffix 'G.J.R.') — 2026-09-30 #563. - A flip resting only on dotted-shape words is silent (`John Smith, X.Y.Z. G.J.`) unless the only words speaking for a pair are other pairs (`John Smith, X.Y. P.Q.` reports); at the trailing slots paired initials are no word of the class and read as the name word they are written as, reported (`John Smith R.T.` → family 'R.T.', `Doe, John R.T.` → middle 'R.T.'), while three dotted letters keep #516's reading (`John Smith X.Y.Z.` → suffix) — 2026-09-30 #563; 2026-10-24 #864. - `Policy.unlisted_caps_suffixes` takes OFF, AFTER_COMMA or EVERYWHERE, default AFTER_COMMA, and a bool raises: an unlisted all-caps alphabetic word of two or more letters joins the class by shape in the part right after a comma behind two or more name words (`John Smith, XYZ` → suffix 'XYZ', `Smith, XYZ` → given 'XYZ'), beside listed credentials as well as alone (`John Smith, PhD XYZ` → suffix 'PhD XYZ'), and under EVERYWHERE at every suffix position — 2026-10-01 #564. - The comma's case contrast is carried by a word of the name before the comma that holds a capital, ends in a lowercase cased letter and is claimed by no wordlist as a non-name, so an all-caps record keeps its given name (`MÜLLER WEIß, HANS`, `LLOYD WEBBER, ANDREW PhD`) and a name written in lowercase reads the listing form (`john smith, XYZ` → given 'XYZ'); a lone two-letter word is never admitted at the comma (`García Márquez, MJ` → given 'MJ') — 2026-10-01 #564. - Accepted: a given name written in capitals behind a two-word surname reads as the credential (`García Márquez, GABRIEL` → suffix 'GABRIEL', reported), OFF being the escape; and case repair follows the reading, a caps word in the part a suffix comma opened being tagged under AFTER_COMMA, one in a third or later part only under EVERYWHERE — 2026-10-01 #564. - Under EVERYWHERE the cost is the three-word name (`Jean Pierre DUPONT` → family 'Pierre', suffix 'DUPONT'), while a two-word name keeps its family and reports (`Jean DUPONT`); unlisted is checked by wordlist membership rather than tags (`John Smith NEE` keeps family 'NEE'), and a roman numeral is the numeral fork's before the shape is asked (`John Smith VI` → suffix 'VI') — 2026-09-14 (Derek), corrected 2026-09-18. - The caps shape has a joined spelling: an unlisted word of capitals whose letter chunks are joined by a hyphen or a slash, one of them of two or more letters, joins the class wherever the caps shape is admitted, gated as it is (`John Smith, PU-PH` → suffix 'PU-PH', reported; `JOHN SMITH, PU-PH` and `Smith, PU-PH` → given; `John Smith, A-B` and `John Smith, Pu-Ph` → given), and under EVERYWHERE at every suffix position as the unjoined word is (`Jean Pierre DUPONT-MORETTI` → suffix) — 2026-10-25 #752. - Neither switch reaches the v1 shim, which translates neither: `HumanName` follows both defaults and a v1 caller cannot turn them off (`HumanName("John Smith X.Y.Z.").suffix` and `HumanName("John Smith, XYZ").suffix` are 'X.Y.Z.' and 'XYZ'), the caps half since the AFTER_COMMA default — 2026-09-14 (Derek), corrected 2026-09-18; 2026-10-01 #564. - A rule's statement names no `Policy` field; the example annotations carry the field names — 2026-09-18, RULES.MD STATEMENTS. - An example's text is a default-policy corpus name whatever its annotation, so an annotated-only example is checked for a default-policy diff like any other — 2026-09-18, corrected. Not standing: the split of 2026-10-17 #682, carried out. Elsewhere: the wordlist roster the caps entry names, eleven of the thirteen vocabulary fields, corrected to all of them (a caller's surname keeps `Jean Pierre DUPONT` its family under EVERYWHERE) — decisions/S6's 2026-09-18, WHOLE-PR REVIEW ROUND entry; `De La Cruz, M.J. K.L.` → given 'M.J.', middle 'K.L.' behind a one-word particle surname (the middle since decisions/S3's 2026-10-24 #864 entry) — decisions/C3's 2026-10-01 (Derek), #575 entry; the three listed and dotted runs the caps entry kept apart (`John Smith, Ed Ma`, `John Smith, ma do`, `John Smith, X.Y.Z. A.B.`), credential runs by C4's count — decisions/S7's 2026-09-27 (Derek), #544 entry headed A CREDENTIAL RUN KEEPS ITS AMBIGUOUS MEMBERS; the contrast test reused for a suffix word ending the name before the comma — decisions/C12's 2026-10-18 (Derek), #656 entry. Open: none in this file. ## Log - 2026-09-14 (Derek) (its `John Smith, A.B.` → suffix and its "reports either way" at the first slot after a comma superseded by #563, below) — #516's DOTTED HALF IS A SWITCH, `Policy.unlisted_dotted_suffixes`, DEFAULT ON: an unlisted token of two or more period-separated chunks, any chunk length (`X.Y.Z.`, `B.Tech.`, `Q.W.E.R.T.`), joins the ambiguous class BY SHAPE; whole-token vocabulary still wins (`M.A.`, `Ph.D.`, `A.B.C.` — `abc` IS a suffix acronym). The positional rule then decides and reports either way: `John Smith X.Y.Z.` → suffix, `Jack X.Y.Z.` → family, `Smith, A.B.` → given, `John Smith, A.B.` → suffix. Case is irrelevant — the periods are the evidence — so `john smith x.y.z.` reads as its mixed-case twin. Switch OFF: name material everywhere as 2.3 read it, and the fork is STILL reported, the parser having chosen the name reading over a credential one. - 2026-09-17/18, #516 review rounds — THE SHAPE VERDICT NEEDS TWO FURTHER GATES, both found by measuring after the shape verdict shipped. (i) ALPHABETIC: `period_joined_vocab`'s "shape" branch now requires every chunk to be `.isalpha()` — a bare digit chunk is not an acronym letter by any reading, so `Smith, 1.4`, `John Smith 1.4` and `John Smith, 1.4` stay unclaimed (no report, no role move), where an unguarded shape verdict had wrongly admitted them; the delimited `Bridge (1.4)` control only proved digits reach extract's escape, saying nothing about the same digits undelimited, which is the gap this closes. (ii) INITIALLESS-SCRIPT: the same test `is_title_shaped` already makes for H2's own leading-abbreviation inference (#323) — `text.isascii() or not in_initialless_script(text)` — now gates the shape verdict too, so `John Smith 田.中.`, `김 민준 이.박.` and `John Smith たな.か.` stay family: a script with no period abbreviations at all has nothing for interior periods to abbreviate, so a CJK word glued into period-separated characters is not spelling an acronym either. `John Smith 田.中.` is pinned `tolerated=True` rather than `shape=` — a Latin period convention glued onto CJK characters is a composed form no writing system produces, and the case table's own validator refuses a Latin shape tag on CJK text; `이.박.` and `たな.か.` are covered by direct `period_joined_vocab` assertions instead of a second corpus row each. - 2026-09-17, #516 review round — `SMITH, J.R.` READS BY SHAPE FOR A REASON UNRELATED TO WHY IT DECLINES THE LISTED WORD. `_normalize` keeps interior periods ON PURPOSE (v1 parity, pinned 2026-07-17: `'J.R.'` must not collapse to the listed suffix word `'jr'`), so `ambiguous_class_member` correctly answers False for `Smith, J.R.`, `Smith, E.S.Q.` and `Smith, E.T., Jr.` for a reason that has nothing to do with #516. What #516 adds is a SEPARATE route: each chunk (`'j'`/`'r'`, `'e'`/`'s'`/`'q'`, `'e'`/`'t'`) is an unlisted single-letter chunk nothing in the vocabulary claims, so the token joins the class BY SHAPE instead, exactly as `'K.D.'` does in `J.A. K.D.`. The READING is unchanged in all three — one pre-comma name word is never enough to flip the structure — but each now gains `suffix-or-name`, the class having been considered and declined rather than never reached at all. `is_wholly_suffix` NEVER READS THE BY-SHAPE CLASS. A first version admitted a by-shape member there unconditionally, reasoning it was needed for `John Smith, A.B.`'s structure flip; proved wrong by mutation — that flip already goes through `_segment.py`'s own `pre_comma_names >= 2` disjunct alone, and nothing else in the suite depended on the admission except this predicate's own two unit tests. Left in, the admission combined with C1's LEGACY token-count disjunct (which counts WORDS, not NAME words, and predates the name-word-count decision of decisions/S6, 2026-09-14) to flip `Smith Jr., A.B.` to given `'Smith'`, suffix `'Jr., A.B.'` with a self-contradicting "holds 1 name words" report. Fixed by dropping the admission outright: the by-shape class reaches the comma form ONLY through `_vocab.ambiguous_class_candidate`, never through `is_wholly_suffix`. `Smith Jr., A.B.` now reads given `'A.B.'`, family `'Smith'`, suffix `'Jr.'` — the same shape the LISTED class reads for `Smith Jr., Ma` and `Smith Jr., ma` (given `'Ma'`/`'ma'`, family `'Smith'`, suffix `'Jr.'`), not for `Smith Jr., MA` (given `''`, family `'Smith'`, suffix `'Jr., MA'` — the caps lean has already moved that one into the suffix run, measured on this tree; at 2.3.0 it read given `'MA'`, family `'Smith'`, suffix `'Jr.'`, the very shape `Smith Jr., A.B.` reads now) — with the report; classification `parity`, not `fix(#516)`: FULL PARITY on the 1.4.0 wheel (first A.B., last Smith, suffix Jr.), only the report being new. - 2026-09-14 (Derek), corrected 2026-09-18 (its default superseded by #564, below; its ELEVEN-list roster by decisions/S6's 2026-09-18 whole-PR review round, which checks every wordlist; the three readings its THE RUN IS THE CAPS CLASS'S ALONE paragraph pinned superseded by #544, decisions/S7) — #516's ALL-CAPS HALF IS A SWITCH TOO, `Policy.unlisted_caps_suffixes`, DEFAULT OFF, and the asymmetry is the decision. An unlisted all-caps word of two or more letters, with no period ANYWHERE (`isalpha()` rejects a period anywhere, not merely an interior one, so a single TRAILING period, H2's own shape, is unaffected either way), in a name `one_case` says is written in more than one case, joins the class by shape (`_vocab.caps_shape_candidate`). UNLISTED MEANS IN NO WORDLIST AT ALL, not merely "no whole-token suffix vocabulary" — found only by measuring after a narrower, tag-based guard shipped and reversed: `titles`, `given_name_titles`, `particles`, `particles_ambiguous`, `conjunctions`, `conjunctions_ambiguous`, `bound_given_names`, `suffix_acronyms`, `suffix_acronyms_ambiguous`, `suffix_words` and `maiden_markers` — ELEVEN lists, checked by direct membership rather than by tag, since `segment` calls the shape test before `classify` has tagged anything. Measured with the narrower guard alone (`John Smith DE`, `John Smith NEE` wrongly moved to suffix), and stated here as a recipe rather than a fixed count, since a raw per-list membership tally and a de-duplicated one (a word appearing in two lists, `abu`/`أبو`/`ابو` across `bound_given_names` and `particles_ambiguous`, is one leak, not two) give different totals: `len([w for w in getattr(lexicon, name) if w.isascii() and len(w) >= 2 and w.isalpha()])` per excluded list, run over the shipped `Lexicon.default()`, gives on the order of dozens for `particles` and `particles_ambiguous` and single digits for `conjunctions` and `bound_given_names` — every one of them a wordlist entry the caps shape test's earlier, narrower guard did not check at all. The one figure that reproduces exactly, because ASCII case is what the test needs and every other maiden marker is a non-Latin or non-ASCII spelling the shape test's own `isupper()`/`isalpha()` gate excludes on its own: FOUR maiden markers are ASCII (`geb`, `geboren`, `geborene`, `nee`), out of `len(lexicon.maiden_markers)` total (17, measured 2026-09-18) — `'NEE'`/`'GEB'` are two of those four, capitalized, which is what the narrower guard let through. ACCEPTED GAP, recorded rather than chased: an all-caps German title spelled with `SS` for `ß` (`GROSSFÜRST`) is unlisted by this same lookup too, for a reason predating this switch entirely — `_normalize` uses `lower()`, never `casefold()`, specifically so it does not corrupt `großfürst` to the misspelling `grossfürst`, and classify's own existing `vocab:title` check has never recognized this spelling either. Left for whoever next generalizes the ß-fold question. THE RUN IS THE CAPS CLASS'S ALONE. The multi-token shape `'LEED AP'` needs a RUN test (`John Smith, LEED AP` → suffix `'LEED AP'`), but the listed and dotted halves are single-token classes by construction — the listed set is inherently bare words and the dotted half never needed more than one token — so testing the run over either of them asks a question this design never posed. An un-narrowed run test measured three reversals before this was caught: `John Smith, Ed Ma` (both listed, Title-case, which #289's OWN lean reads as NAME), `John Smith, ma do` (both listed, lower) and `John Smith, X.Y.Z. A.B.` (both dotted) all wrongly moved to suffix as a run. Narrowed to the caps branch alone; `Smith, LEED AP` still declines and reports, and all three reversals are now pinned identical OFF/ON. `JACK VI` AND `JOHN SMITH VI` DO NOT MOVE, AND NOT FOR THE REASON A FIRST DRAFT GAVE. `VI` passes the caps shape test and carries both `SHAPE_ACRONYM_TAG` and `vocab:suffix-ambiguous` with the switch on — the predicate reaches it, contrary to what an early write-up of this entry said. What actually holds it unchanged is rules/S2's OWN roman-numeral fork (`_pieces.is_trailing_numeral_suffix`, `_vocab.reads_as_generation` since #752), which claims a bare `'VI'` piece and decrements the peel's walk BEFORE the bare-ambiguous/shape branches beneath it are ever reached — a mechanism this switch does not touch and never held `VI` back through non-membership. THE COST IS A TWO-WORD CONTRAPOSITIVE, not a flat one. Off by default because French and Korean records write the SURNAME in capitals — `Jean DUPONT`, `Minjun KIM` and `Jean Pierre DUPONT` all read family at 1.4.0, at 2.3 and at this default — and `isupper()` is script-agnostic, so the same decline holds in ANY script with a case contrast (`Иван ИВАНОВ`, `Jean ÉCOLE`, measured). Turning it on does not cost every candidate alike: at TWO words there are no words to spare, so `Jean DUPONT` and `Minjun KIM` report the fork and KEEP their family — the class is considered and declined, the mirror of the listed lean's own words-to-spare rule. At THREE words there ARE, and `Jean Pierre DUPONT` gives family `'Pierre'`, suffix `'DUPONT'` — the one genuine cost, and a swallowed family name is the worse of the two failures #vocabulary-collisions C-i already argues from. Default-off is 1.4.0 parity for the whole class regardless; on, nothing but the report changes at two words, and only the three-word shape actually moves. Off, NOTHING is reported either. - OUT OF SCOPE, each its own issue or entry: no tokenizer split (`J.R.R.`, `A.B.` stay single tokens); the trailing-period-only shape is NOT the signal (`John Smith Xyz.` → family, and AGENTS.md's trailing-abbreviation paragraph stands for bare words and sentence-final stops); no new `AmbiguityKind`; no locale packs; #492 stays deferred. NEITHER SWITCH REACHES THE v1 SHIM, and that is a consequence stated rather than repaired: `lenient_comma_suffixes` has no v1 manager either and `_build_snapshot()` simply does not translate it. Switch A is DEFAULT ON, so `HumanName` changes with it and a v1 caller cannot turn it off — the deliberate reading of the bridge, where the facade tracks the parser's defaults and 2.x behavior changes reach it (the #528 precedent). - 2026-09-18 — RULES.MD STATEMENTS NEVER NAME A POLICY FIELD, AND THIS ARC MADE THAT LOAD-BEARING. rules.md's own preamble binds statements to be implementation-free ("no stage names, no function names, no regexes"), and no existing statement names a `Policy` field either — C1 says "strict mode" and leaves `strict-comma-suffixes` to the annotation slot. S2's and S3's switch clauses follow that precedent rather than setting a new one: the prose describes `unlisted_dotted_suffixes` and `unlisted_caps_suffixes` as caller configuration and points at the example lines, and the ANNOTATIONS carry the exact field names (`unlisted_dotted_suffixes-off`, `unlisted_caps_suffixes-on`, registered in `tests/v2/rules_doc.py`'s `POLICIES` beside `strict-comma-suffixes`). Recorded here rather than as a silent edit to the preamble or to two rules' prose: if a future design wants a field named directly in a statement, that is a preamble amendment for someone to propose, not a convention this entry changes on its own authority. - 2026-09-18, corrected — `BUILD_RULES_CORPUS.PY` HARVESTING A POLICY-ANNOTATED-ONLY EXAMPLE IS LONG-STANDING, NOT NEW, AND THIS ARC ADDS TWO MORE INSTANCES OF IT. Its own docstring says "Policy annotations are deliberately ignored... a family-first-scoped example is simply one more name to diff" — an earlier draft of this bullet claimed that sentence was written when every annotated example had a default-policy sibling; measured false, at the commit that wrote it and on this tree alike. Recompute: parse every rule's examples with `tests/v2/rules_doc.py`'s own `parse_rules_doc`, group by `text` across the WHOLE document, and keep every text whose every appearance's annotation resolves (`resolve_annotation`) to kind `"policy"` rather than `"locale"`/`"gated_locale"` and never appears bare. That gives **19** pre-existing examples (`Mesnil de`, `Smith, de Mesnil Jean`, `de la Vega y Santos Juan`, `ibn Awf abdul Rahman`, `Nguyen Thi Van`, `Jane Smith (Nee)`, `Jane Smith (Nee) (Jones)`, `Garcia Juan Carlos`, `Nguyễn Thị Minh Khai`, `Del Toro`, `Anh Van Do`, `Beethoven Ludwig van`, `Berg Jan de Jr.`, `Garcia`, `Ménil Christophe de`, `Ménil de`, `Smith (Jones)`, `de Anke van y`, `van Berg Jan de`) plus this arc's own **2** (`Jean DUPONT`, `Jean Pierre DUPONT`, both `unlisted_caps_suffixes-on`, rules/S2) — **21** total on this tree. So the shape was accepted the day the docstring was written, not discovered here; what this arc adds is two more members, checked here for the reason the advice below names: `Jean Pierre DUPONT`'s DEFAULT reading (given Jean, middle Pierre, family DUPONT — measured; the annotated ON reading, given Jean, family Pierre, suffix DUPONT, is the only one rules.md states) never appeared in the doc before, and it produces no diff at any of the five baselines either way. Recorded per the same over-collection principle that keeps `(` and `.,` in the corpus (`tools/differential/README.md`'s provenance table): an annotated example's TEXT is a default-policy corpus name regardless of its annotation, full stop, and every one of the 21 — not only this PR's two — is a standing instance a reader should check for a default-policy diff on before assuming it free. - 2026-09-18 — THE COMMA HARNESS AND THE FRAME BAND, AFTER THE ROUND. Unchanged at the default (412/449 on the reference name, and the module-`parse` totals in decisions/S6's WHERE THE FACT LIVES entry hold exactly); with switch B on, `Smith, John` and `Smith, XYZ` each drop one frame, the dead caps branch having left `ambiguous_class_candidate` (PR #530). - 2026-09-30 (Derek), #563 (its `De La Cruz, M.J. K.L.` reading superseded by #575, decisions/C3) (its choice (iii), the trailing slots keeping #516's reading for paired initials, and the second round's `García Márquez, Ed G.J.` suffix 'G.J.', superseded by #864, decisions/S3) — PAIRED INITIALS ARE THE EXCEPTION TO C1'S NAME-WORD COUNT, AND A FLIP ON THE DOTTED SHAPE ALONE IS SILENT. The 2026-09-14 (Derek) entry above, #516's DOTTED HALF IS A SWITCH, read `John Smith, A.B.` as suffix 'A.B.' on the count of two name words before the comma. The count cannot tell a given name and a family name from ONE surname written in two words, and the dotted shape's own most common member is a person's initials, so the unreleased tree read `García Márquez, G.J.` as given 'García', family 'Márquez', suffix 'G.J.', `De La Cruz, M.J.` with no given name at all, and `van der Berg, A.J.` as given 'van' — where 1.4.0 through 2.3.0 read each as initials. A particle check would have caught only the last two; `García Márquez` and `Lloyd Webber` look exactly like `John Smith`. What the parser CAN see is the token, so the line is drawn there: two single letters joined by a period, with or without one after the second (`_vocab.is_paired_initials`; `García Márquez, G.J` reads as `G.J.` does), read as the given name after the comma and report the fork, and only an UNAMBIGUOUS suffix word IN FRONT of them that is not also title vocabulary, or a second word the class admits by dotted shape in the same part, makes them the credential run (`John Smith, PhD X.Y.`, `John Smith, X.Y. P.Q.`). Three letters or more (`X.Y.Z.`) and any longer chunk (`B.Tech.`) read by the count as before. Three choices made with it (Derek, 2026-09-30): (i) FRONT ONLY — a word behind the pair does not speak, so `De La Cruz, M.J. PhD` keeps given 'M.J.' and `John Smith, A.B. PhD` reads given 'A.B.', the direction rules/S2's company clause already takes; a LISTED dotted word behind does not speak either (`John Smith, A.B. Ph.D.` → given 'A.B.'), the second-word exception being for words the class admits by shape; (ii) SILENT FLIP — a flip resting only on words the class admits by dotted shape reports nothing, `B.Tech.` included, because the only such word a reader takes for a name is a pair of initials, and the comma flips a pair only where a word beside it has already said otherwise; a LISTED member in the same part still reports the flip (`John Smith, PhD MEng`, `John Smith, X.Y.Z. MA`); (iii) THE COMMA SLOT ONLY — the trailing slot (`John Smith R.T.`), the given part's trailing slot after a family comma (`Doe, John R.T.`) and the maiden clause keep #516's reading and its report; #563 leaves the no-comma question open. A generational word in front speaks like any unambiguous suffix word (`García Márquez, Jr. G.J.` → suffix 'Jr. G.J.'), as S2's company clause has it — unless it is also a title, so `García Márquez, Sr G.J.` reads title 'Sr', given 'G.J.'. ACCEPTED: `García Márquez, G.J.R.` reads suffix 'G.J.R.' — initials are conventionally written apart, as separate words this rule never reads (`García Márquez, G. J. R.` gives given 'G.', middle 'J. R.'), and three letters run together are far likelier a credential. REVERSED BY THIS ENTRY rather than edited, for the dotted half at the first slot after a comma only: the #516 SWITCH entry's `John Smith, A.B.` → suffix and its "reports either way"; the 2026-09-14 NO NEW AmbiguityKind entry's "Every decision at these slots emits" SUFFIX_OR_NAME; and rules/C1's "A decision either way at this comma is reported" (rules/C11's since #728). ALSO FIXED: the trailing slot's report described a by-shape pick in the listed member's words ("written without periods is both a post-nominal"), false of `X.Y.Z.` on both counts; it now says the word is shaped like a post-nominal but listed in no vocabulary. REVIEW ROUND, same day. The docs review found the first version let a word of both the title and the suffix vocabulary speak for the pair, since C1 (rules/C4 since #728) counts such a word opening the part as a suffix word: `García Márquez, Ms G.J.` read given 'García', family 'Márquez', suffix 'Ms G.J.' with NO report — #563's own defect, made silent — where 2.3.0 read title 'Ms', given 'G.J.'. The twelve title/suffix duals (`ms`, `md`, `sr`, `lt`, `sa`, `ra`, `vc`, `cpl`, `cpo`, `cpt`, `csm`, `sgm`) all reached it. Fixed by letting only a suffix word that is not title vocabulary speak: in front of a pair, a dual is the title of the given part the pair opens, as S2 already reads a dual in that part's title run, and `Ms G.J.`, `MD G.J.`, `Lt G.J.` now read title plus given as every release did. The title lookup runs only once a pair is met. The code review's frame count found the single-token path asking `ambiguous_class_member` a second time, +2 frames on every reporting comma name (`John Smith, MA`, 252 → 254, profiler call events); for a word past the candidate test LISTED is exactly "no period" (`ambiguous_class_member` declines any period), which is now asked inline. Measured after both fixes against master: `John Smith, X.Y.Z.` 295 → 286 and `García Márquez, G.J.` 338 → 350 frames, plain comma names unchanged (PR #567). SECOND REVIEW ROUND, same day, on the first round's fix commit. (a) TWO PAIRS (Derek): `De La Cruz, M.J. K.L.` read family 'De La Cruz', suffix 'M.J. K.L.' — no given name — in silence, the silent-flip rationale ("only paired initials are taken for a name") failing on exactly the case where each word speaking for a pair is itself a pair. The reading stands, two dotted groups not being how anyone writes a person's initials, but that flip now REPORTS: when the only words speaking for a pair are other pairs, every shape word in the part being one. `John Smith, X.Y. P.Q.` gains the report with it; `John Smith, X.Y.Z. G.J.` and `John Smith, PhD G.J. K.L.`, where something else speaks, stay silent. The same rule reports `García Márquez, Ms G.J. K.L.`, whose honorific the run takes, where the first round's fix took it in silence. (b) A CLASS MEMBER IN FRONT SPEAKS FOR NOTHING (Derek): `García Márquez, Ed G.J.`, `Ma G.J.` and `MA G.J.` flipped on the member in front, where S2's company lets only an unambiguous credential speak for a member. Now the comma keeps the family, given 'Ed' — and 'G.J.' then ends the given part, the slot (iii) left alone, so it reads suffix 'G.J.' as `Doe, John R.T.` does, both forks reported; 1.4.0 and 2.3.0 gave middle 'G.J.'. Resolving that slot is the no-comma question #563 leaves open. (c) WORDING, no behavior: C1's (now rules/C4's) sentence counting a dual opening a part as a suffix word now defers to the pair's titles; the pair's report reaches it only where it opens the part, `Smith, Ms G.J.` having always been silent (the comma report's reach is the first post-comma piece, 2026-09-18); and the silence is stated as "no listed word takes part", which covers `García Márquez, PhD G.J.`, where "dotted-shape words alone" did not. SIMPLIFY ROUND, same day, behavior-identical (0 diffs over 21,604 parses: every corpus name, every quoted string in tests/v2/cases.py and 115 composed `pre, post` probes, under four policies, comparing fields and ambiguity details against the pre-round commit c125f69b; the same harness finds 252 diffs against master). One finding was a cost, not a style point: the speaker test scanned every word in front of EACH pair for a non-title, so `John Smith, MD MD ... G.J. G.J. ...` cost duals × pairs `_normalize` calls (163 at 8 of each, 1,387 at 32, py3.11). Only the first pair's scan can change the answer, since every later pair has the same words in front and more, so it is asked once: 107 and 395. `tests/v2/test_benchmark.py::test_the_paired_initials_title_scan_does_not_cost_quadratically` guards the ratio and fails at c125f69b. The run loop also asks LISTED as "no period", as the single-token test does, and `flip_reports` is set once after the run decision rather than piecemeal. MEASURED 2026-09-30 against master b98b26e3 over every corpus name: 11 of 1453 distinct names differ, every one a name this change's rules examples and case rows added; over master's 1441 names only `John Smith, A.B.` holds the shape that could move, so the count is evidence about the corpus rather than about the rule's reach (recipe in PR #567). - 2026-10-01 (Derek), #564 — THE ALL-CAPS HALF READS THE COMMA POSITION BY DEFAULT, AND THE SWITCH HAS THREE SETTINGS. Supersedes the default of the 2026-09-14 entry above (its reasoning stands for the positions it was argued over). That entry turned the whole caps half off because French and Korean records write the SURNAME in capitals; but the convention writes them at the end of a name (`Jean DUPONT`) or before a comma (`DUPONT, Jean`), never after a comma behind a full name, so the reason for the off default never reached the comma position and that position was switched off with it. The corpus held three names of exactly that shape — `Ahmad Jayadi, CHA`, `John Smith, RAI`, `The Rt Hon Kenneth Clarke QC MP, HMG` — all credentials, all read as the given name at 2.3.0. DECIDED, with Derek's calls marked. (1) `Policy.unlisted_caps_suffixes` becomes a `CapsSuffixes` StrEnum, OFF / AFTER_COMMA / EVERYWHERE, default AFTER_COMMA (Derek: an enum over two booleans or a mixed `bool | "comma"`, so every state is named and no meaningless combination exists). The field was new in 2.4 and unreleased, so no deprecation is owed; a bool raises a TypeError naming both replacements. The third value exists because redefining "off" would have left no setting that keeps a given name written in capitals (Derek's question, `García Márquez, GABRIEL`). (2) AFTER_COMMA reads only the part right after the first comma, behind two or more name words — the comma's own structure decision, which segment makes from the text before classify runs. The given part's last word after a family comma (`Doe, John XYZ`) and a maiden clause's last word are trailing slots and stay behind EVERYWHERE, as the comma-less end of a name does. (3) A lone two-letter word is never admitted at the comma, in any setting (Derek): it is the undotted form of #563's paired initials, and two words before the comma may be one surname, so `García Márquez, MJ` keeps given 'MJ'. Inside a longer part a two-letter word is read exactly as #563 reads the dotted pair — measured 2026-10-01 on the shipped tree by swapping `MJ` ↔ `M.J.` in runs of one to three words from {MJ, PhD, XYZ, CPA, Jr., MA, LEED, III} after seven name prefixes whose own words carry the contrast (`García Márquez`, `Smith Jones`, `Mr García Márquez`, `de García Márquez`, `García Márquez née Smith`, `John st Smith`, `García y López`): the comma's structure decision differed on 0 of 1295. After five all-caps prefixes it differed on 325 of 925, by design, `MJ` being no class member without the contrast while the dotted shape is case-free (`GARCÍA MÁRQUEZ, CPA MJ` keeps given 'CPA' where `CPA M.J.` makes the run). Recompute: run segment and read `ParseState.structure` for each pair. The equivalence is the comma's decision only: at the given part's last word after a family comma a dotted `M.J.` is a credential by shape (S3) where `MJ` is one only under EVERYWHERE, so `García Márquez, MA M.J.` reads suffix 'M.J.' and `MA MJ` middle 'MJ' — so `García Márquez, MJ PhD` keeps given 'MJ' as `De La Cruz, M.J. PhD` does, and C1 (rules/C5 since #728) says so by pointing at #563's own sentences rather than restating them. Two restatements were wrong: the first draft's "three letters or more, or a run holding such a word" (both reviews), and its replacement, "with only a credential behind it ... while a credential in front makes the run", which #563's vocabulary rules contradict (`John Smith, MA MJ` reads given 'MA'; `García Márquez, MJ XYZ` makes the run). (3b) An unlisted all-caps word is a by-shape member of C1's run (rules/C4's and C6's since #728) (#544), beside listed credentials, not only in a run of all-caps words (Derek, after review): before, `John Smith, XYZ` read suffix while `John Smith, PhD XYZ` read given 'PhD' and `John Smith, XYZ Jr.` given 'XYZ' — more evidence for a credential producing a name reading, already true under the old `True` and made a default by this change. The contrast has to be the NAME's, and it is carried only by one of the name's own words before the comma (`own_words`, so no maiden clause or delimited content) holding a capital and whose last LETTER is lowercase (`_vocab.written_as_a_name`), with no period and not claimed by a wordlist as a title, particle, connective, credential, generation, maiden marker or honorific (`_vocab.claimed_as_non_name`) (Derek). Every earlier draft asked for "a lowercase letter somewhere, less some exclusions", and each leaked a class the next review found: a mixed-case credential (`LLOYD WEBBER, ANDREW PhD`), a maiden clause or title (`LLOYD WEBBER née Smith, ANDREW PhD`, `Mr LLOYD WEBBER, ANDREW`), a generation, connective or shape title (`LLOYD WEBBER Jr., ANDREW`, `GARCÍA y LÓPEZ, ANDREW`, `Insp. LLOYD WEBBER, ANDREW`), and glued particles and unlisted lowercase words (`GISCARD d'ESTAING, VALÉRY`, `HAFEZ al-ASSAD, BASHAR`, `LLOYD ap RHYS, DAFYDD`) — no closed list separates an all-caps record from a mixed-case name. A Title-case draft (`str.istitle()`) closed those but read too narrowly, missing interior capitals and elisions (`DiCaprio`, `IJzerman`, `al-Rashid`, `d'Estaing`, `McDonald` — every one a mixed-case name that kept a given 'XYZ') and taking a lone capital as Title case (`de GAULLE C, CHARLES` lost its given name). Derek weighed "contains a capital" (an all-caps record with any lowercase word then always carries the contrast) and "not all capitals but contains a capital" (which takes `d'ESTAING` and `al-ASSAD`, the lowercase standing before the capitals) and first chose a capital directly followed by a lowercase letter, with a leading `Mc`/`Mac` skipped. Its review found the skip was one member of a class: any capitalized prefix glued to a surname written in capitals supplies such a pair (`LLOYD FitzGERALD, RONALD`, `PAOLO DeVITO, MARCO`, `JEAN LaFLEUR, PIERRE`, `DICK VanDYKE, JOHN`, `PAUL DuBOIS, JEAN`, `JEAN St-PIERRE, MARC`, and `LLOYD SMITH-McDONALD, RONALD`, the skip applying only at a word's start), as does `ß`, which has no capital (`MÜLLER WEIß, HANS`) — each lost its given name — while titlecase digraphs failed it (`Džokić Ljubić, XYZ`) and it cost a frame per letter. The shipped test, Derek's: a capital in the word and its last letter lowercase. A surname written in capitals ends in a capital whatever is glued in front of it, so every one of those records keeps its given name with no prefix list, and `McDonald` and `Džokić` pass. "Last letter" is the last letter that carries CASE, not the last character: the word is composed (NFC) first; every non-letter and every caseless letter is passed over (`Jones'`, `Smith2`, `Smith)`, `Jonesʼ` with U+02BC, the transliteration letters in `Wafāʾ`, a trailing `李`); and so is a lowercase letter whose capital is not one character (`ß` → `SS`, `ĸ` with none, the `fi` ligature), which is no evidence of case — `WEIß` is written in capitals, `Weiß` is not. Two cuts got this wrong: the first tested the last character, and its review found every name typed with decomposed accents and ending in an accented letter losing the contrast (`José André, XYZ`, `Lê Thị Hà, XYZ`, `René Noé, XYZ`, `Chloé Zoé, MBA XYZ` read given where their composed spellings read suffix), with `Jones'`, `Smith2` and `Smith)`; the second skipped non-letters but stopped at a caseless letter, so `Asmāʾ Wafāʾ, XYZ` and `JOHN Jonesʼ, XYZ` kept given 'XYZ' (its review). MEASURED 2026-10-02 against `a16927c6`, the capital-then-lowercase commit, over 4286 inputs (every corpus name, every comma-holding quoted string in tests/v2/cases.py, and a grid of mixed-case, all-caps and decomposed-accent prefixes before six parts, under all three settings): the capitalized-surname prefixes, `MÜLLER WEIß` and `KUJAĸ de PETERSEN` keep their given names, `Džokić Ljubić` gains the contrast, `JOHN O'NEILL's` and `JEAN-pierre DUPONT` gain one (accepted below), nothing else moves, and OFF equals `unlisted_caps_suffixes=False` on `7394078a` on all 4286 (recipe in PR #577). ACCEPTED with it (Derek): a name written wholly in lowercase reads the listing form, `john smith, XYZ` keeping given 'XYZ', an unlisted title written in mixed case (`Doña GARCÍA LÓPEZ, MARÍA`) still carries the contrast, and so do two writings in an otherwise all-caps record that end in a lowercase letter: a glued possessive or plural (`JOHN O'NEILL's, XYZ`, `JOHN SMITHs, XYZ` read suffix `XYZ`) and a hyphenated part written in mixed case or lowercase (`JOHN SMITH-Jones, XYZ`, `JEAN-pierre DUPONT, XYZ`). ACCEPTED with (4): in a mixed-case name a capitalized given name followed by a generation goes the same way as one alone, `García Márquez, JUAN Jr.` reading suffix 'JUAN Jr.', reported; and behind a particle-led surname of two name words the flip leaves no given name at all, `De La Cruz García, MARÍA` reading family 'De La Cruz García', suffix 'MARÍA', because P1 reads a never-given particle's part as all surname. (4) ACCEPTED (Derek): a given name written in capitals behind a two-word surname without particles reads as the credential, `García Márquez, GABRIEL` giving given 'García', family 'Márquez', suffix 'GABRIEL', reported; capitalizing the given and not the family is not a convention, and OFF is the escape hatch. #575 was the prerequisite: before it, the count read `Van Buren, MARTIN` and `De La Cruz, MARIA` as two or three name words and this default would have taken their given names too. WHAT MOVES, measured 2026-10-01 with the gate at all five baselines: the three corpus names above plus `John Smith, XYZ` and `John Smith, LEED AP` (closing a `deviates: #291` marker), all classified `fix(#564)`, the radar-unclassified count unchanged; `John Smith, RAI` and `Ahmad Jayadi, CHA` read suffix again by their capitals, parity at 1.4.0; `Smith, XYZ` keeps given 'XYZ', silent at the default and reported under EVERYWHERE (PR #577). CASE REPAIR FOLLOWS THE READING. The comma decision is segment's, made from the text, while case repair keeps a word in capitals only where classify wrote the shape tag (rules/R4); the first draft tagged only under EVERYWHERE, so `parse("John Smith, XYZ").capitalized(force=True)` rendered 'John Smith Xyz' at the default and 'John Smith XYZ' under EVERYWHERE (found by the docs review, axis 5). Classify now tags a caps-shaped word in the part a suffix comma opened under AFTER_COMMA too, so both settings render that part alike, and rules/R4 names the caps shape beside the dotted one; `test_render`'s forced-repair test fails with the tagging removed (an R4 example could not witness it, R5 leaving a mixed-case name unrepaired unless forced). A caps word in a third or later comma part is still tagged only under EVERYWHERE, so `John Smith, MD, XYZ` renders 'Xyz' at the default, as master does. COST, `sys.setprofile` call counts against `git archive origin/master`: ordinary and listed-credential comma names unchanged, `John Smith, XYZ` 251 → 266, and an all-caps record with a capitalized given name and a credential (`LLOYD WEBBER, ANDREW PhD`) a constant +26 (the second draft's per-character contrast test had made `'LLOYD'*64 + ' WEBBER, ANDREW PhD'` +350), the contrast linear in the words before the comma and constant in a word's letters, all paid only once a caps word is in hand (PR #577). The comma test is on by default, so three C-level checks go before any call: two or more words before the comma (the first draft lacked it, and `Smith, JOHN`, a common record format, paid +25 for a flip it can never make — the code review), the first word in capitals, and the lone-two-letter length. Every caller of the caps predicate asks first, in C, what it would decline anyway — alphabetic capitals, not a listed suffix word — so a listed credential never pays for the call (the review of the first fix found `John Smith, CPA` +4 and `John Smith, Ph. D.` +5 before these). - 2026-10-17 #682 — SPLIT FROM decisions/S2 with the rule. The entries above were written under decisions/S2 while this rule was part of rules/S2, and moved here unchanged but for pointers the move broke; where one says rules/S2 it means the rule as it then stood, which held this one. - 2026-10-24 #864 — PAIRED INITIALS LEAVE THE CLASS AT THE TRAILING SLOTS. The 2026-09-30 #563 entry's choice (iii) left those slots with #516's reading; for paired initials of a script with capitals that question is answered under decisions/S3, the rule whose position reading took them, and this bullet is S9's pointer to it. - 2026-10-25 #752 — THE CAPS SHAPE'S JOINED SPELLING (phase 2d, the unlisted half of item 5 of decisions/vocabulary-model's 2026-10-23 #752 entry, agreed by Derek on 2026-10-23; #797's half A). An unlisted word of capitals in letter chunks joined by `-` or `/`, one chunk of two or more letters, is the caps shape: `John Smith, PU-PH` reads suffix 'PU-PH' with the class's report where 1.4.0 through 2.6.0 read given 'PU-PH', family 'John Smith'. It joins the class exactly where and as the unjoined word does, so the C4 count and C6's case contrast gate it after a comma by default (`JOHN SMITH, PU-PH`, `Smith, PU-PH` → given) and EVERYWHERE takes it at every suffix position, its cost the unjoined word's (`Jean Pierre DUPONT-MORETTI` reads as `Jean Pierre DUPONT` does); keeping it to the comma under EVERYWHERE too was not built, since it would make one shape two by position. "Unlisted" is the whole word in no wordlist, as for the unjoined word; a word whose every chunk counts as a suffix as written is rules/S3's joined credential, read unambiguously before this shape is asked (`MD-PHD`), so the two overlap in no reading. Single-letter chunks alone are how initials are joined (`A-B`) and stay out, and a chunk holding a digit or a period is no letter chunk. REACHABILITY: the comma's candidate list, C12's case-repair marks and classify under EVERYWHERE each ask `isalpha()` and `isupper()` in C before calling the predicate, and the joined spelling fails `isalpha()`, so each pre-filter widened with it to a capitals word holding a joiner; the 2026-10-23 measurement's first run moved nothing for the same reason. MEASURED (2026-10-25, against the phase 2c commit 15d5426f, every local corpus recorded from it with `--tree`): `tools/differential/snapshot.py` moves nothing but the rules' own new examples; `cldr_agreement.py` matches every record; `local_agreement.py psc-individuals` matches its record; tse-donors and fec-individuals, none (FEC is written in one case, so the contrast never admits a word there); ctgov-officials, 529 of 352,104 entries. Of the 529, 519 are a joined capitals word after a comma, now the suffix with a suffix-or-name report (5 of them in a third part, where the comma-structure report stays): 516 had read it as the given name with the name reversed around it, and 3 had read a listed credential opening the part as the given name and the joined word as the middle (`John Smith, MSN ACNP-BC`), the whole part now the suffix. 10 move only in `capitalized(force=True)`, a joined capitals word already in a suffix run now keeping its capitals (rules/R4). The words are 104 distinct credentials and job titles, `PU-PH` (258) and `MCU-PH` (30), the French hospital-university ranks, and `MS-OMPT` (32) the commonest; none is a name. The 2026-10-23 figure, 759, was measured without phase 2c, which now reads about 230 of them as listed credentials. One of the 516 also takes a word before the comma into the suffix, as the unjoined twin reads: a capitals word between a listed credential and the comma (`Jean Claude MD DUPONT, PU-PH` → family 'Claude', suffix 'MD DUPONT, PU-PH', reported), where the part before the comma reads as the name without it (rules/C1) and `Jean Claude MD DUPONT` alone already gives suffix 'MD DUPONT' by rules/S8's run; ACCEPTED here, since it is S8's reading of a capitals surname behind a credential, the same with `XYZ` or `PhD` after the comma. COST: a capitals word holding a joiner pays the caps shape's path, as an unjoined capitals word already does (`SMITH-JONES, MARY-JANE` 175 → 189 profiler frames, py3.11), and a word with a lowercase letter or no joiner pays nothing new (`Mary Smith-Jones` 159, `John Smith, XYZ` 264, before and after). Pinned by the #752 rows of tests/v2/cases.py and `test_caps_shape_candidate_reads_joined_capitals` (tests/v2/pipeline/test_vocab.py). derek73-python-nameparser-456b024/docs/design/decisions/T1-separators-not-joiners.md000066400000000000000000000113411527303271700303240ustar00rootroot00000000000000# T1 — separators, not joiners ## Standing - An ignorable character is a separator, never removed with its neighbors joined as v1's squash_emoji and squash_bidi did: `A😀B` reads given 'A', family 'B' — 2026-07 PR #288. - The `strip_bidi=False` half of the opt-out carries no example, by decision; with it `John‏Smith` (a right-to-left mark between the words) reads given 'John‏Smith', the mark kept — 2026-10-20 #761. - The zero-width space (U+200B) and the byte-order mark (U+FEFF) are separators with no opt-out; the zero-width non-joiner and joiner (U+200C, U+200D), the word joiner (U+2060) and the soft hyphen (U+00AD) stay inside the word, and an invisible character the tokenizer does not handle is reported nowhere — 2026-10-24 #851. Open: none in this file. ## Log - 2026-07 (v2 core, PR #288) — v1's squash_emoji/squash_bidi REMOVED the character and joined its neighbors. (v1.3.0 had no bidi handling at all: squash_bidi entered late v1 via #266, 2026-07-07, on the emoji precedent's shape.) ('A😀B' → 'AB'); v2 makes an ignorable character a separator ('A😀B' → 'A', 'B'). The unavoidable consequence of every part being an exact positioned piece of the input: with no rewriting stage, nothing can splice two half-words together. - 2026-10-20 (Derek), #761 — the `strip_bidi=False` half of the opt-out carries no example, by decision: the example would be a name holding an invisible direction mark (`John‏Smith`, which reads given `John‏Smith` with the marks kept), and that is not worth a new policy annotation in `tests/v2/rules_doc.py`'s registry. - 2026-10-24 (Derek), #851 — the zero-width space and the byte-order mark join T1's set, unconditionally, like whitespace and the katakana middle dot (rules/T2): neither strip switch fits, since neither is an emoji or a direction control, and no caller would keep either. Measured over every committed corpus and the eight local corpora on 2026-10-24: U+200B stands only in `tw-donors`, at the start of 520 of its 308,667 names, each of which read `given` where its twin without the character reads `family` (`​王小明`), so splitting there moves those 520 and nothing else. U+FEFF stands in 12 `jrc-names` spellings, each beside the space between two words (`Jerome Kerviel`), web text rather than a stray byte-order mark at a field's start; separating there changes no role and takes the character out of the field. Beside the interpunct the two read as whitespace does, not as a stripped direction mark (`威廉·​莎士比亚` reads as `威廉· 莎士比亚`): no corpus holds the combination, and the simpler rule won. - Excluded: the zero-width non-joiner and joiner, which decide how the letters beside them join and stand inside Persian (Arabic-script), Devanagari, Malayalam and Kannada names in the corpora (thousands between letters in `nepal-candidates` alone); about 575 of them stand at a word's edge (a run of joiners counted at an edge when whitespace or the string's edge is beside it), where they join nothing (`Antonio ‌Negri`), and are left to the caller. The word joiner, whose meaning is no break. The soft hyphen, mostly inside a word: of the 66 names holding one, 52 are an accented í decoded with the wrong encoding (`NATALíCIO`, and `Pri­ncipe` without the `Ã`), 10 are typesetting hyphens at a syllable break (`Puig­demont`), and 4 end the name (`Ayman al-Zawahiri­`, read with the hyphen kept in the family); none is the parser's to repair. - Accepted (Derek, 2026-10-24, PR #874 review): the two do not count as a space where a quote or a maiden marker is recognized, which happens before words are divided and tests for whitespace, so a glued quote is no nickname (`'Bob' Smith` reads given `'Bob'`) and a marker glued to its name leaves the clause a nickname (`Jane (née​Jones) Smith` reads nickname `née Jones`) — the katakana middle dot's shape, pinned in `tests/v2/cases.py` as `marker_divided_by_a_nakaguro_keeps_the_clause_a_nickname`. `ParsedName.replace()` and the v1 setters split a value at whitespace only, as for the middle dot and emoji. A zero-width space after a hyphen, a line-break hint in typeset text, divides the name (`Mary-​Jane Smith` reads given `Mary-`, middle `Jane`, where master kept `Mary-​Jane`). No corpus holds a zero-width space or byte-order mark inside a delimited clause or after a hyphen, so widening the quote and marker tests was declined as machinery for an unwritten shape. - Declined: an ambiguity reporting an invisible character the tokenizer leaves in a word. An ambiguity records a reading the parse had to choose; this is a fact about the data, which the caller can find as easily as the parser can. Declined too: a cleanup recipe in the user docs — cleaning input is the caller's job, and the docs would own it. derek73-python-nameparser-456b024/docs/design/decisions/T3-interpuncts-flank-guard.md000066400000000000000000000040161527303271700304460ustar00rootroot00000000000000# T3 — the interpunct's flank guard ## Standing - U+00B7 divides only between classified-script characters, being also the Catalan punt volat interior to names (`Gal·la Smith` → given 'Gal·la'), while the nakaguro needs no such guard — 2026-07-30 #298, U+00B7 divides only - The scope is the codepoint, not a blanket "dot = transcription" rule: U+00B7 records the transcription fact and suppresses the surname split, the name keeping the declared order rather than the script's (by default `威廉·莎士比亚` → given '威廉', family '莎士比亚'), and U+30FB and U+FF65 stay pure separators whose pieces the script licence reads (`高橋・一郎` → family '高橋'); cross-convention input reads by the codepoint it was typed with — 2026-07-30 #298, codepoint scope The Declined line below stands as written: a half-flanked interpunct (`王·Smith`) stays in its word and reports nothing of its own. Open: none in this file. ## Log - 2026-07-30 #298 — codepoint scope, chosen over a blanket "dot = transcription" rule that would have flipped 高橋・一郎, a correct and deliberately pinned #272 reading: U+00B7 records the transcription fact and suppresses division; U+30FB/U+FF65 stay pure separators whose pieces the script license reads (rule W4's interpunct Except and its katakana-middle-dot Then bullet demonstrate both). Cross-convention input reads by the codepoint it was actually typed with — a chosen limitation, recorded in #298's comments. Declined: - Ambiguity emission on a half-flanked interpunct ("王·Smith") — proposed in the 2.1 PR review, declined because the dot decides silently under the no-emission decision; the undivided-dot-stays-in-the-word behavior went to docs instead. - 2026-07-30 #298 — U+00B7 divides only between classified-script characters because it is also the Catalan punt volat, interior to legitimate names (Gal·la). The nakaguro (T2) needs no such guard: its codepoints are CJK-only and appear in no other script's names, which is what licenses an unconditional rule. derek73-python-nameparser-456b024/docs/design/decisions/T4-bare-letter-as-an-initial.md000066400000000000000000000326711527303271700305420ustar00rootroot00000000000000# T4 — the bare letter as an initial ## Standing - In a name written wholly in one case a bare letter is tagged an initial wherever its capital, one ASCII capital, would be, so the two spellings read alike: `j nee smith` reads given 'j' as `J NEE SMITH` reads given 'J', and `jose j y lopez` middle 'j y'; `é` and `ж`, whose capitals are no initial, read as before in both spellings — 2026-10-18 #723 - A connective letter among the name's own words stays P8's, the vocabulary answering before this rule is asked (`JOSE I LOPEZ` reports `conjunction-or-initial`) — 2026-10-18 #723 - A letter among the own words asks the own words' case (`j nee Smith` → given 'j'), and a letter outside them asks the whole name's: `DR. NÉE JUAN y LOPEZ` reads family 'JUAN y LOPEZ' and `MARY SMITH NEE v JONES` middle 'SMITH NEE v', family 'JONES' — 2026-10-18 #723's code review - The strict suffix test vetoes a one-case lower-case letter as it vetoes its capital: under `Policy(lenient_comma_suffixes=False)` `john smith, v` reads given 'v' as `JOHN SMITH, V` reads given 'V', and by default `田中さん v` reads given '田中さん', family 'v' — 2026-10-18 #727 - A maiden clause's numeral is not held to its capitals, an Accepted clause of the rule: `john smith nee x x` reads suffix 'x', maiden 'x', as 2.2.0 through 2.4.2 read it — 2026-10-18 #731 reverted - The written shape of an initial is rules/T5's, and this rule keeps the case contrast — 2026-10-19 Open: none in this file. ## Log - 2026-10-18, #723 — A ONE-CASE BARE LETTER IS THE INITIAL ITS CAPITAL IS. #721 applied S10's "a name written wholly in one case says nothing about any letter in it" to the one-letter roman numerals only (decisions/S10, the Open line of its /simplify entry), so a bare lowercase `j` was a name word where `J` was an initial, and every rule that reads an initial differently read the two spellings of one name differently: M4 (`j nee smith` family 'j', `J NEE SMITH` given 'J'), O5 (a lone `j` reported `given-or-family`, `J` did not), and P3's count, which passes over an initial (`jose j y lopez` family 'j y lopez', `JOSE J Y LOPEZ` middle 'J Y', family 'LOPEZ', which 1.4.0 read too). The rule is now stated once, for every letter, and the numeral case is an instance of it: in a name written in one case, a bare letter is tagged an initial wherever its capital would be. Its capital is asked of the bare-capital shape (one ASCII capital), so `é` and `ж`, whose capitals are no initial, read as before in both spellings. `ı` and `ſ`, whose capitals are `I` and `S`, are tagged initials too; `ı`'s twin is not a true one, since `I` is also the marked Catalan connective and `ı` is in no vocabulary (`JOSE I LOPEZ` reports `conjunction-or-initial`, `jose ı lopez` nothing). Measured against 16ee2851 (PR #726) on a scratch grid not committed (letters a–z, `é` and `ж` in 35 shapes, three heads and all three orders, each lowercase text against its `.upper()`): 16ee2851 disagreed on 504 of 7,794 distinct pairs, this tree on none, and no capital spelling moved. The committed invariant is `test_a_one_case_name_reads_as_its_capitals` (tests/v2/test_properties.py), whose grid gains the issue's shapes and the letters `j`, `y` and `e`; its stated controls are the recompute. WHAT MOVES: lower-case letters in a name whose own words are written in one case, each onto its capital twin's reading — wholly lower-case names, and (since the case question is asked of the own words, P3) a one-case name beside a clause of another case, so `j nee Smith` reads given 'j' as `J nee Smith` does. In three classes — a lone letter loses O5's report, a letter before a maiden marker keeps the given name M4 would have taken, and a letter beside a single-letter link is passed over by P3's count, so the link in what is then a three-word name stays a name word. No corpus name moved (the snapshot and the CLDR, GND and native-script agreement records are unchanged). And P8's Accepted paragraph (P3's until the 2026-10-19 split) on a declined marker loses its "one shape" in which the name's own words read differently in the two spellings: `dr. née juan y lopez` read family 'juan y lopez' where `DR. NÉE JUAN Y LOPEZ` reads middle 'JUAN Y', because the `y` past the declined marker is outside the own words P8's fork reads; it is this rule's letter now, and reads as its capital does. And one move the snapshot cannot see (R4): case repair of a connective letter in a one-case maiden clause, now tagged an initial as its capital is, so `jose lopez nee y garcia` repairs to `Jose Lopez (Y Garcia)` where it gave `(y Garcia)` — the same tag in the two spellings that P8's rewritten paragraph intends. No corpus name's `capitalized()` moved (the design-docs review compared every one across the two trees). A GAP THE RULE DOES NOT CLOSE, found by the design-docs review and present on 16ee2851 too: under `Policy(lenient_comma_suffixes=False)` the strict comma-suffix test asks a one-letter numeral's shape as written, not its tag, so `john smith, v` reads suffix 'v' where `JOHN SMITH, V` reads given 'V', and `smith, john, i` reports nothing where `SMITH, JOHN, I` reports `comma-structure`. The committed grid runs the default policy only. Left for its own change, and taken by the #727 entry below (was Open: #727). The connective letters are left where P8 put them. Among the name's own words, P8's fork answers by vocabulary before this rule is asked — the marked `e` and `i` read as initials and report, the unmarked `y` as a connective — and does so in both spellings, so the invariant already held for them; a connective letter outside the own words was always read by the bare-capital shape in its capital spelling, and its lower-case spelling now follows. - 2026-10-18, #723's code review — A LETTER OUTSIDE THE OWN WORDS ASKS THE WHOLE NAME'S CASE. The first version gated the new tag on the one-case fact classify records, which is asked of the name's own words only (P3), so a lower-case letter in a clause of a name whose own words are capitals passed it: `MARIA GARCIA NEE LOPEZ y FERNANDEZ` and `MARIA GARCIA (LOPEZ y FERNANDEZ)` tagged the `y` an initial (case repair wrote `Lopez Y Fernandez`), and `DR. NÉE JUAN y LOPEZ` moved from family 'JUAN y LOPEZ' to middle 'JUAN y' — mixed-case writing, which this rule reads as no initial, and moves the twin-only grids could not see, since every pair in them is wholly one case. The fix asks a word outside the own words about the whole name instead; an own word keeps the own-words fact, so a clause beside the name still changes nothing about the name's own letters (P3). The defect predated #723 for the numeral letters, which #721 gated the same way; they are read by this rule now and move with it, and the move is not only a tag (found by the design-docs review). After a marker among capitals, a lower-case numeral no longer holds the clause up as an initial would: `MARY SMITH NEE v JONES` reads middle 'SMITH NEE v', family 'JONES' (on 16ee2851, maiden 'v JONES'), `MARY SMITH NEE i JONES` family 'NEE i JONES', and `JANE DOE NEE SMITH v` drops `suffix-or-name`. Each is the reading of its mixed-case spelling (`Mary Smith nee v Jones`) and of 2.4.2 (and 1.4.0 for the first), so only the unreleased #721 had moved them and no release bullet is owed; the capital twin `MARY SMITH NEE V JONES` keeps maiden 'V JONES'. Pinned by the row `a_lowercase_numeral_after_a_marker_among_capitals_is_no_initial`. Pinned by `test_a_lowercase_clause_letter_among_capitals_is_no_initial` and the case row `a_lowercase_letter_past_a_declined_marker_among_capitals_joins`. The first entry's figures were measured on the first version, and the twin grids are unchanged by the fix (both spellings of every pair in them are wholly one case). - 2026-10-18, #727 — THE STRICT SUFFIX TEST VETOES A ONE-CASE LETTER AS IT VETOES ITS CAPITAL. The gap the first entry recorded: `is_suffix_strict` vetoes an initial by the word's text (`is_initial`), not by classify's tag, so a one-case lowercase `v` or `i` — an initial under this rule — passed where its capital was vetoed. Two callers reached it. Under `Policy(lenient_comma_suffixes=False)`, C1's comma tests (`is_wholly_suffix` and the name-word count): `john smith, v` read suffix 'v' where `JOHN SMITH, V` reads given 'V', and `smith, john, i` reported nothing where its capitals report `comma-structure`. And under the default policy, W2's scan-back (`_script_segment._is_post_nominal`): `田中さん v` peeled さん and read suffix 'さん v' where `田中さん V` reads given '田中さん', family 'V' — a CJK name with a Latin letter beside it is one case under the own-words test, the letter being the only cased word in it, so nothing in the writing contrasts it. The strict test now takes the one-case fact as an argument and vetoes a bare lowercase letter whose capital is A to Z where it is true; segment, `_comma` and script_segment pass the fact they already hold, and segment asks for it only under the strict knob. A separate keyword from `is_wholly_suffix`'s `one_case`, which is the credential LEAN and is None by design at callers that want no lean; they still want the letters read by this rule. The lenient default takes a listed suffix word whatever its shape and never reads the argument. Measured: the committed invariant's grid gains the three comma shapes and `田中さん {n}`, and runs every comma-holding shape again under the strict knob (the knob reaches nothing else); 2444f847 disagreed on 72 of its 4,554 pairs (54 strict comma pairs, 18 honorific), this tree on none, and the third recorded control is that 72. `田中さん v` now reads as 1.4.0 read it; 2.1 through 2.4.2 peeled it (suffix 'さん, v' in 2.1 and 2.2, 'さん v' from 2.3). No corpus name moved. Open: none from this entry; #727 closes the gap the first entry left open. - 2026-10-18, #731 — THE MAIDEN TAKE'S CANDIDATE TEST ASKS THE ONE-CASE FACT. SUPERSEDED by 2026-10-18 #731 reverted (PR #733) — the entry below; the full text is in git and in PR #732. The gap and the declined option below predate #731 (ad486f66) and still stand; they were written under #731, so "this tree" and "the committed grid" there mean #731's, a grid the revert deleted (its text is in PR #732). Re-measured 2026-10-19 on a rebuild of that grid: the readings and the 108 hold. The declined option's results ("0 field and 0 report differences", and the 270) were measured under #731 and were not re-run as that option; an approximation of it re-run the same day (whole-name fact passed to the take's view) closes the fields but leaves 27 of the 396 rebuilt pairs differing in their reports, so read "closes both" as #731's measurement, unconfirmed since. A GAP LEFT OPEN, on ad486f66 as on this tree: the view the take reads asks the own words' case, so a lower-case clause numeral behind own words in capitals is counted as a capital would be, where behind Title case it is S10's "lower case in a name of mixed case", the generation and unreported. With two own name words the fields agree and only the report differs (108 pairs of the committed grid: `JOHN SMITH nee x x` reports `suffix-or-name`, `John Smith nee x x` does not); with ONE own name word there is no word to spare and the fields differ too (`SMITH NEE j x` maiden 'j x', `Smith Nee j x` suffix 'x', maiden 'j'; the code review's finding). The committed grid therefore holds two own words in front only, and says so. Declined, measured: passing the whole name's fact to the view as well. It closes both (0 field and 0 report differences on a grid with one-word, two-word and titled heads) but the view also asks S6's credential lean, which P8 says is asked of the own words P3's span holds, and moving it there fails `test_a_maiden_clause_does_not_change_how_a_trailing_word_reads` (tests/v2/test_properties.py) on 270 of its 20,412 pairs (with it, `J. nee Smith BA` would read the credential where `J. BA` reads a name). Closing the gap needs the peel to take the numeral's case fact apart from the lean's — a design change beyond this fix. - 2026-10-18 (Derek), #731 reverted — A MAIDEN CLAUSE'S NUMERAL IS NOT HELD TO ITS CAPITALS. The #731 entry above is superseded and its change reverted. Derek: this rule and maiden names do not really interact, because nobody writes a maiden marker followed by an initial, or two. Asked whether settling the gap that entry left open would simplify the parser, the answer was the opposite: it needs the peel to carry two case facts, one for S6's lean and one for the numeral. And #731 itself had bought `john smith nee x x` and `john smith nee j x` — both that unwritten shape — a case argument on `trailing_candidates`, a whole-name case test in the take, a stated exception to the candidates' superset contract (decisions/M2's #731 entry), two grid shapes and a second invariant with their controls, all reverted. The readings go back to 2.2.0 through 2.4.2's (`john smith nee x x` suffix 'x', maiden 'x', reported), stated as an Accepted clause of this rule with both spellings as examples. Kept from that PR: M2 naming S10 among the trailing rules its take asks, which was true before #731 and stays true. What this rule still says of a clause's letters — classify reads one by the whole name's case (the #723 code-review entry) — stands: the shape that guards is a connective inside a clause (`MARIA GARCIA NEE LOPEZ y FERNANDEZ`), which names do take. - 2026-10-19 — RULE SPLIT: the written shape of an initial (its period, or a bare unaccented Latin capital) left rules/T4 for rules/T5, and T4 keeps the case contrast every entry here decided. Record: decisions/T5's entry of this date. derek73-python-nameparser-456b024/docs/design/decisions/T5-written-shape-of-an-initial.md000066400000000000000000000025401527303271700311200ustar00rootroot00000000000000# T5 — the written shape of an initial ## Standing - The written shape of an initial is its own rule, split from T4, which keeps the case contrast; the shape itself was decided in v1, narrowed to scripts that write initials by #320 (decisions/initials-repertoire) and asked of the composed spelling since #665 (decisions/nfd-shapes) — 2026-10-19. Elsewhere: the one-letter generations recorded off by default (rules/T5's Accepted bullet on them) is decisions/vocabulary-model's 2026-10-25 #752, phase 2h entry. Open: none in this file. ## Log - 2026-10-19 — SPLIT FROM rules/T4, one rule per piece of evidence (docs/design/AGENTS.md): T4 read two signals, the shape a one-letter word is written in and a bare lower-case letter's case against the name's own words, and its `Signal:` line named both. The shape moved here and T4 keeps the case contrast, since every entry under decisions/T4 (#723, #727, #731) decided the case half. The shape itself is older than both rules and is recorded where it was decided: v1's `is_an_initial` (`_vocab._INITIAL`, a period after one word character or a bare A to Z capital), narrowed to scripts that write initials by #320 (decisions/initials-repertoire), and asked of the composed spelling since #665 (decisions/nfd-shapes). P7 (a one-letter particle against the initial it spells) is this shape against particle vocabulary. derek73-python-nameparser-456b024/docs/design/decisions/W1-unspaced-cjk-division.md000066400000000000000000000342161527303271700300760ustar00rootroot00000000000000# W1 — unspaced CJK division ## Standing - Korean division is on by default and an unrecognized word stays whole, an unsplit name being recoverable where a wrong split is not: `김민준` → family '김', given '민준', `남궁민수` → family '남궁', `뿡민준` → family '뿡민준' — 2026-07-27 #271. - Han division is opt-in per pack, Han text not identifying its language: `毛泽东` reads family '毛泽东' by default and family '毛' under `locales.ZH`; a listed surname is a dictionary certainty and wins before a pluggable segmenter is asked — 2026-07-29 #272. - Japanese division is delegated to namedivider, and JMnedict is not bundled as segmentation data (the log's Declined line) — 2026-07-29 #272. - Script classification composes its input to NFC while segmentation reads the raw text, so a decomposed name degrades to no split, never a wrong one: `김민준` typed decomposed reads family '김민준'; vocabulary matching composes too since #322, the correction inside the entry — 2026-07-29. - `locales.ZH` and `locales.JA` are corpus alternatives; stacked, a listed Chinese surname still wins before the segmenter (`高橋一郎` divides 高 + 橋一郎 under ZH+JA as under ZH alone), and kana gating follows the script function order uses, so a wholly-katakana token never gates in — 2026-08. - The ZH single-character list is the census rows plus the union of Taiwan's and Wikidata's top 300, written by `tools/zh_surnames.py`, so `柯洁` and `关羽` under `locales.ZH` read family '柯' and '关' — 2026-10-18 #695. - Accepted under `locales.ZH`: a Japanese name splits wrongly more often (`安達禎` → family '安'), and so does a transcription written without the 间隔号 (`安娜` → family '安', given '娜') — 2026-10-18 #695. - A listed compound wins over its first character whatever the name's length, and a token that is wholly a surname stays whole: `张简明` → family '张简', given '明', and `张简` → family '张简' under `locales.ZH`; compounds stay hand-curated against bearer data, 东野 and 成公 left out — 2026-10-18 #695. - A pack holding every ranked surname, a stricter divide-after-the-first-character mode, the unlicensed or copyrighted sources and the unattested Hundred Family Surnames compounds are declined — 2026-10-18 #695. - A name the interpunct divides stands division down, vocabulary and segmenter alike (decisions/T3, #298), and rules/W1 states it as an Except rather than an Accepted cost: `安东尼·陈志明` under `locales.ZH` → family '陈志明', given '安东尼' — 2026-10-20 (Derek), #761. Elsewhere: a segmenter is asked only of a name its writer left undivided, decisions/W3's 2026-08 entry; the Japanese surnames and married doubles kept out of the ZH list, decisions/vocabulary-exclusions' #695 lines. Open: none in this file. ## Log - 2026-07-27 #271 (decision; shipped in 2.1.0 via PR #294) — Korean division ships as a default: the census surname list is closed, hangul is self-selecting (a hangul entry can only match hangul text), and being unsplit is recoverable while a wrong split is not — which is also why an unrecognized name stays whole. The filed proposal (#271, 2026-07-07) asked for OPT-IN segmentation for Korean too, "like all localization"; default-on is the later refinement, and the census/self-selecting argument above is what justified promoting Korean past the blanket opt-in stance. - 2026-07-29 #272 (the ja amendment; shipped in 2.1.0 via PR #297) — Han division is opt-in per language pack because Han text does not identify its language (高橋一郎 under a Chinese list divides wrongly); a pluggable segmenter takes what the vocabulary declines, so pack + segmenter compose mechanically: a listed surname is a dictionary certainty and wins. - 2026-07-29 (ja amendment §1a) — script CLASSIFICATION NFC-normalizes its input (NFD katakana carries combining marks outside the block; NFD hangul decomposes to jamo, which would miss the order rule for macOS-origin names) while SEGMENTATION stays raw — chosen over offset-mapping complexity because NFD then degrades to no-split, never to a wrong split. The read-only folds are the deliberate exceptions to "nothing rewrites the text" (rules.md T Background), and neither ever enters a token. (Corrected 2026-09-10, #cjk-full-stops: this read "segmentation MATCHING stays raw" and "the one deliberate exception". Vocabulary matching composes NFC too since #322 — `_lexicon._normalize` folds every lookup and every stored entry the same way — so the folds are two, and what stays raw is segmentation proper: the surname site's membership test and the peel's tail slice, which index the token's own text. The no-split degradation is what still holds there.) - 2026-08 — zh and ja packs are corpus ALTERNATIVES, one per corpus; stacking them (parser_for(ZH, JA, segmenter=...)) is for genuinely mixed data that accepts the trade: a listed Chinese surname wins before the segmenter is consulted, so 高橋一郎 still divides 高 + 橋一郎 under ZH+JA exactly as under ZH alone. Kana gating resolves through the same script function order uses, so kana-licensed composites (高橋みなみ) gate in under JA while pure-katakana tokens never do (amendment 2026-07-27: activation is per script because the ambiguity is per script). - 2026-10-18 #695 (weighing; folds in #657) — the ZH surname list reaches past the 2020 census top 100. The question was what a caller who applies `locales.ZH` wants, and the answer taken is Chinese names divided: applying the pack is already the "my data is Chinese" declaration (decisions/W1's 2026-08 entry, on zh and ja as corpus alternatives), and a Japanese corpus has `locales.JA`. So a common Chinese name left whole (柯洁 → family 柯洁) counts as a failure, not as the safe side of W1's preference for an undivided word over a wrong division. The single-character list became the census rows plus the union of two rankings' top 300: Taiwan's Ministry of the Interior surname ranking (2023, Open Government Data License v1) and Wikidata's Chinese family names (Q1093580) ranked by bearers (CC0). Both, because each is biased by region in its own direction: Taiwan ranks Hokkien surnames high (詹 37, 柯 46), and Wikidata counts notable people, Korean and Vietnamese bearers of the same character included, and types some common surnames outside Q1093580 — 46 of the 344 selected surnames reach the list only through Taiwan, among them 关 and 区 (both named in #657), 颜, 东 and 兰. On the native-script corpus alone Wikidata's list did as well for less (the cutoff measurements in this entry); the union was taken for the surnames that corpus does not hold. `tools/zh_surnames.py` derives the block and writes it into zh.py; it adds a traditional character's simplified form from Unihan when Unihan gives exactly one in the BMP, and adds a traditional form only where a source attests it, because the reverse mapping is one-to-many (余's traditional variants include 餘, which is no one's surname). Measured on 16ee2851 against this change, with `parser_for(locales.ZH)`: the 80 Chinese rows of the native-script corpus went from 74 to 80 agreeing (`wikidata_native_agreement_locales.json` holds it); the CLDR locale view stayed at 42/96, and every rendering that moved there was wrong before and after (a given name alone, 文傑 → 文 + 傑 where it had been family 文傑; the transcription 辛巴达; the given-first 安怡张). Recompute: `uv run python tools/differential/cldr_agreement.py`. Accepted: Japanese names read under ZH split wrongly more often. On the 60 Japanese rows of the native-script corpus, wrong splits went from 2 to 11 (安達禎 → 安, 平賀淳 → 平, 吉川亨 → 吉, …), taken from "left whole"; the rows read right stayed at 12. On CLDR's native Japanese renderings, 安藤一郎 now gives 安 in the two family-first orders. Transcribed foreign names written without the 间隔号 split too (安娜 → 安 + 娜), since 安, 艾 and 伊 are surnames inside the cut. Recompute: parse each `ja-jp` row of `wikidata_native_fields.jsonl` under `parser_for(locales.ZH)` at the parent commit and at this one, and count a family that is a proper prefix of the text but not the stated surname. Accepted: a listed compound wins over its first character in every name that opens with it, whatever the name's length — longest match first, the same mechanism the married-double exclusion in decisions/vocabulary-exclusions guards against. 张简明 reads 张简 + 明 where it had read 张 + 简明, 贺兰芳 reads 贺兰 + 芳, 范姜伟 reads 范姜 + 伟, and a two-character name that spells a compound stays whole (张简 is family 张简, not 张 + 简, a token that is wholly a surname not being divided). This was already true of the 34 shipped compounds (司马), and the compounds admitted are borne as one surname — 張簡 by 9,162 people in the Taiwan file — where a married double is two. The cost is on record: Wikidata has 25 people whose family name is the 张 item alone and whose Chinese label opens with 张简 (measured 2026-10-18). 14 are labelled with the two characters alone, 9 of them with given name 简 stated, so they now read whole where they had read 张 + 简; the longer ones are Taiwanese 張簡 bearers tagged with the single character or undetermined (the Tang and Ming 张简修). Recompute: people with P734 = Q804909 and a zh, zh-hans, zh-hant, zh-cn or zh-tw label starting 张简 or 張簡, with their P735 labels. The cutoff (wrong Japanese splits of 60, Chinese rows right of 80, each list run through the tool's `select` with the census rows and the shipped compounds): Wikidata alone at 200, 4 and 79; at 300, 9 and 80; at 400, 12 and 80. Taiwan alone at 200, 4 and 78; at 300, 10 and 79 (茅 is its 326th, in `read_moi`'s order); at 400, 12 and 80. Both at 200, 5 and 79; at 300, 11 and 80; at 400, 14 and 80. The corpus does not force 300: both lists at 212 read all 80 rows with 7 wrong Japanese splits, Wikidata alone at 212 with 6, and 211 misses a row. 300 leaves a margin over a corpus of 80 public figures, who skew toward common surnames (the reason #695 gave for expecting ordinary data to miss more), at the cost of 4 more Japanese wrong splits there than 212. (The first measurement, taken before the Unihan step existed, had Taiwan at 300 reading 77: without simplified forms it missed 卫 and 华.) Compounds stay hand-curated (the note above `_COMPOUND` in zh.py states the admission test). A compound ships where Wikidata or the Taiwan file attests a bearer, and where no Wikidata bearer of its first character alone has a name opening with it; the check was read by hand, since most of its hits are compound bearers tagged with the single character (左丘明 under 左, 鍾離權 under 鍾). Amended the same day, after the second review: the check also fails where such a bearer's stated given name (P735) opens with the compound's second character, and a compound that fails it still ships when its living bearers in the Taiwan file outnumber those readings by orders of magnitude. 張簡 is the case (9,162 bearers against 9 people named 張 + 簡, the cost this entry's compound Accepted clause records); 东野 fails without it (no Taiwan bearers against 2 people named 东 + 野) and is left out. 25 simplified compounds were added, 钟离 and 段干 among them. Recompute the check: for each compound, people whose P734 is the item labelled with its first character, whose Chinese label opens with the compound, and whose P735 label opens with its second character. - 2026-10-20 (Derek), #761 — the interpunct stand-down moves from Accepted to Except in the skeleton migration (PR #782): a name the interpunct divides is inside When and Then stops holding there, which is the format's definition of an exception. Words and example unchanged. Declined: - JMnedict as bundled segmentation data (#272's body, 2026-07-07) — the pip packaging (jamdict-data) was last compiled 2021-04; JMnedict carries no frequency data, so it cannot resolve the ambiguous 2+2/3+1 splits that motivate a segmenter at all; and bundling raised licensing questions the core avoids by delegating to namedivider — whose code and GBDT model are MIT, whose surname data carries Myoji-Yurai terms permitting exactly this name-dividing use, and whose CC-BY-SA BERT model is unused (the 2026-07-29 amendment corrected an earlier flat-"MIT" description; so does this entry). The kind of decline someone re-proposes in two years. - A pack with every surname either source ranks (#695 review, 2026-10-18). Taiwan's single-character surnames past the 300th hold 77,355 of its 23,373,283 people (0.33%). Recompute over the order `tools/zh_surnames.py`'s `read_moi` returns — single unified ideographs ranked among themselves, which is not the CSV's `ranking` column, since that column counts two-character surnames too — summing the population column for every surname past index 300. The tail is where the characters that open Japanese surnames sit (in that order: 山 369, 川 656, 大 787, 小 788, 中 920, 本 1008). For data that is Chinese, a complete list acts as "divide after the first character unless a compound opens the name"; the list's work past the head is screening out text that is not a Chinese name, and the tail stops paying for it. A stricter "divide after the first character" mode was raised in the same review and declined: one ZH pack. - Sources not used as data: the PRC Ministry of Public Security name reports (top 100 only, no licence); Yuan Yida's 300- and 400-surname books (copyrighted; usable as a cross-check only); the CRAN package ChineseNames (GPL-3, not redistributable here); zh.wikipedia's 中国姓氏排名 tables (CC BY-SA, compiled from the sources above). - Hundred Family Surnames compounds no source attests a bearer of (宗政, 谷梁, 东门, 南门, 归海 and others). They divide nothing real, and where the first character is a listed surname they would cut a name that opens with it (宗 + 政X). - 东野 — two Wikidata people named 东野 have 野 as their stated given name, and the Taiwan file has no 東野 bearer to outweigh them (the amended compound test, 2026-10-18). - 成公 — 成公亮, the guqin player, is 成 + 公亮, and a Wikidata bearer of 成 whose name opens with 成公 is the case the compound test exists to refuse. derek73-python-nameparser-456b024/docs/design/decisions/W2-glued-honorific-peel.md000066400000000000000000000447401527303271700277120ustar00rootroot00000000000000# W2 — the glued-honorific peel ## Standing - A glued entry peels only where it can never end a name, the vocabulary carrying the license with no structural or per-script gate: `田中さん` → family '田中', suffix 'さん' and `김민준씨` → suffix '씨', while 양, 군, 氏, 博士 and 殿 are spaced-only (`김지양` → given '지양', `田中殿` → family '田中殿') and 君 is in neither set though くん peels (`王君` → family '王君', `田中くん` → suffix 'くん') — 2026-08 #308. - The peel crosses the 间隔号, which still stops the surname split beside it: `马丁·路德·金씨` → family '金', suffix '씨' — 2026-08 #312. - A wholly suffix-shaped part after a family comma is declined as the peel's site only where the part before it offers a site of its own: under the default policy `田中さん, V.` peels before the comma (family '田中', given 'V.', suffix 'さん') and `이, J.씨` is scanned anyway (given 'J.', suffix '씨') — 2026-08 #319. Both are comma forms, which rules/W3 describes rather than promises (Elsewhere below). - The #671 credential, office and generational words are suffix words spaced and after a comma, and all but 部長/部长 peel glued: `田中 部長` → suffix '部長' while `田中部長` stays family '田中部長', and `田中社長` → suffix '社長', `陳怡君二世` → family '陳怡君', suffix '二世' — 2026-10-17 #671; 2026-10-17 #707. - After a listed surname under `locales.ZH` the division leaves such a word as a whole token, which reads as a suffix word: `王二世` → family '王', suffix '二世', `李部長` → family '李', suffix '部長' — 2026-10-17 #671. - 小 as "Jr." (written before the name, and a common given-name character) is kept out of the suffix words, and 冯/馮 as "von" (a top-30 surname) out of the particles — 2026-10-17 #671. - The peel takes one word: `王雅婷婷婷二世議員` → family '王雅婷婷婷二世', suffix '議員' — 2026-10-17 #707. - Accepted: an unlisted compound office or degree word splits at its listed tail, spaced or glued (`田中 副社長` → given '副', suffix '社長'; `岸田文雄衆議院議員` → family '岸田文雄衆議院', suffix '議員'), the older `김민준의학박사` → given '민준의학', suffix '박사' with them — 2026-10-17 #707. - The spaced and glued bars differ, an entry being safe spaced and unsafe glued but never the reverse; a CJK entry is self-selecting; and kana is no license by itself, hiragana spelling Japanese given names (`高橋 みなみ` → given 'みなみ'), so the kana honorifics were vetted one at a time — 2026-10-18. - Only a family comma brings a second run into the scan, being the one structure that splits the name itself: a post-nominal inside the one run is stepped over (`김민준씨 Jr.` → family '김', suffix '씨 Jr.'), a part past the second run is never reached (`Dr 김민준씨, Jr., 박씨` → suffix '씨, Jr., 박씨'), and the scan reads segments rather than tokens, so a nickname is no site (`김민준씨 (Jimmy)` → suffix '씨', nickname 'Jimmy') — 2026-10-19 #760. Elsewhere: a post-nominal word standing on its own is never the name's end and never dissected (`김민준 박사님` → suffix '박사님', `선생님` → family '선생님'), decisions/cjk-comma-demotion's 2026-09-05 entry that restored it to W2; the family-comma crossing is tolerated input, that file's 2026-09-01 #469 entries and rules/W3; a full stop glued to the honorific rides with it (`김민준씨.` → suffix '씨.'), decisions/cjk-full-stops' 2026-09-10 #322/#323 line; 二世's generational reading, and #707's peel reversing #679's refusal of it, decisions/generational-words' 2026-10-21 #762 entry; the peel asks the comma's own decision rather than a copy of it, decisions/C1's 2026-10-06 (Derek), #613 entry. Open: none in this file. ## Log - 2026-08 #308 — an entry peels only where it can never end a name: 씨/님/さん/様/先生 peel; 양/군/氏/博士/殿 stay spaced-only; 君 is in neither set while its kana spelling くん peels. The vocabulary carries the license, so no structural or per-script gate stands over the peel. - 2026-08 #312 (its family-comma half superseded as contract by decisions/cjk-comma-demotion's 2026-09-01 #469: that crossing is tolerated input, rules/W3) — the peel crosses the family comma and the 间隔号: both answer where a name DIVIDES into surname and given, a question the peel never asks. Provenance matters here: neither gate was argued for the peel specifically — both came with the placement (#312's own framing) — so the crossing was a repair of inherited gates, not a designed-in property. - 2026-08 #319 (superseded as contract by decisions/cjk-comma-demotion's 2026-09-01 #469, THE LINE IS FORMAT PURITY: the comma forms it reads are tolerated input, rules/W3) — a wholly suffix-shaped second run is declined as a peel site (the "田中さん, V." shape), but only when the name's own run offers a site, since a glued honorific is itself part of what makes a run read as suffix-shaped. - 2026-10-17 #671 (its no-glued-peel verdict superseded by #707 below for every word but 部長/部长) — SPACED AND AFTER-COMMA ONLY, no glued peel (Derek, 2026-10-17). 議員/议员, 国会議員/國會議員, 太平紳士/太平绅士, 医学博士/醫學博士, 二世 and the job titles 社長/部長/課長 and the simplified 社长/部长/课长 (Derek, 2026-10-17: the characters are shared and the offices are real in both languages; no borne-name data either way, #707) join `SUFFIX_WORDS`; none joins `GLUED_HONORIFICS`. The spaced bar is only that a word standing alone is no name, so they ship; the glued bar ("can it END a name?") needs real-name counts that this change did not get — the Wikidata suffix query timed out and the native-script Wikidata corpus holds none of the words — and 部長 fails it on argument alone (長 is a borne given name, 阿部長). A follow-up can vet 医学博士, 議員 and 太平紳士 one at a time. Consequence measured on `parser_for(locales.ZH)`: after a listed surname each word divides off, `王二世` family 王, suffix 二世 (it read given 二世) and `李部長` family 李, suffix 部長, because the segmenter's division is not a peel and the split-off 二世 is then a whole-token suffix word, the way 王博士 already reads; `陳怡君二世` still reads given 怡君二世 (glued, nothing divides it; superseded for the glued question by the #707 entry below). ジュニア was already shipped by #661, and #679 declined 二世 as CLDR's generation word ("regnal, and glued"); here it ships spaced-only as the Chinese/Japanese numeral, which does not reverse that (corrected 2026-10-21, #762: wrong — #671 put 二世 in `SUFFIX_WORDS` as the generational numeral (a `generation` reading since #737) and #707 let it peel glued, which reverses #679's refusal of it, as decisions/generational-words' 2026-10-21 #762 entry records). Excluded as the issue stated: 小 (a prefix and a common given-name character) and 冯/馮 for "von" (a top-30 surname). - 2026-10-17 #707 — the #671 words meet the glued bar, all but 部長/部长 (Derek, 2026-10-17). 議員/议员, 国会議員/國會議員, 太平紳士/太平绅士, 医学博士/醫學博士, 二世, 社長/課長 and 社长/课长 join `GLUED_HONORIFICS`; 部長/部长 stay spaced-only on the #671 argument, since 長 is a borne given name (Wikidata holds it as a Chinese given name, Q96240389; the Japanese reading rests on the argument) and 阿部長 can be 阿部 + 長 — the measurement below found no such bearer, and a zero that is a lower bound does not refute the argument. The measurement is `tools/name_usage.py`'s ends-with row, new in #707: a local index of every CJK label on eleven name-item classes (family name, given name and their Japanese, Chinese, Korean, male, female, unisex and undifferentiated-kanji subclasses; 215,798 index entries on 128,695 items, 215,696 distinct item-label pairs, fetched 2026-10-17 by the first index, whose character class stopped at the Basic Multilingual Plane; the rebuilt index that takes supplementary-plane Han too (215,820 entries, same day) gave the same answer for every word, 殿 and the boundary pairs below), matched locally, with the matching items' bearers counted live. Two questions per word: does any name item's label END in it, and does any person bear a family name ending in the word's head and a given name equal to its tail (the 阿部 + 長 shape)? Neither found anything for any spelling, with one exception that is a data error: the only item ending in 二世 is Q18339792, the given name Odo, whose Chinese label carries a regnal numeral (厄德二世), and its 109 bearers are Western people named Odo. Of the boundary shapes, only 二+世, 社+長, 社+长, 部+長 and 部+长 had a family name and a given-name item to pair at all, and each counted 0 people. What makes the zeros evidence: the index agreed exactly with a live `STRENDS` query on all 20 class × word cells it was cross-checked on (five subclasses × 殿, 子, 郎, 田, the largest 403 items); 子 found 439 items and 2,436 given-name bearers, 2,081 of them Japanese; and 田 + 太郎 counted 22 people across the boundary. What limits them: Wikidata's coverage, not the index's — 殿 ends 13 name items where a surname dictionary lists about 90, and Japanese family-name items number 4,919. Two shortcuts were measured and rejected before the index: `STRENDS` over every label of a name class timed out at WDQS's 60s with no person join, and WDQS full-text search found 4 family names ending in 殿, matching only where its tokenizer split the character out. Consequence: the snapshot moved four CLDR renderings, each losing its glued word to `suffix` (`王雅婷婷婷二世議員`, both spellings of `…小惠儀太平紳士`, `馮·陳·怡君·達印·二世醫學博士`), and `陳怡君二世`, which the case table and rules.md W2 pin; cldr_agreement records one suffix field newly agreeing (`二世 醫學博士`, where the peel takes 醫學博士 and the 二世 left behind is a whole-token suffix word) and none regressing. The peel still takes one word: `王雅婷婷婷二世議員` peels 議員 and keeps 二世 in the name. Recompute: `uv run python tools/name_usage.py 議員 议员 国会議員 國會議員 太平紳士 太平绅士 医学博士 醫學博士 二世 社長 部長 課長 社长 部长 课长`; the first run builds the index (33 minutes on 2026-10-17, resumable), and `--refresh-index` rebuilds it. ACCEPTED COST, found by the design-docs review and kept (Derek, 2026-10-17): the vetting asked whether a NAME ends in each word and never whether a longer, unlisted OFFICE word does, and common ones do — 衆議院議員 and 参議院議員, 副社長 and 代表取締役社長, 元議員 and 市议员, 副課長, 獣医学博士. The peel matches at the end of the token, spaced or glued, so each splits at the listed tail: `岸田文雄衆議院議員` reads family 岸田文雄衆議院, suffix 議員, and `田中 副社長` given 副, suffix 社長, where before the word stayed whole in the name. Neither reading is right; the compound's head is what the peel leaves behind. No corpus held a compound office word when the review found this, which is why the snapshot was silent; the two W2 examples now put `岸田文雄衆議院議員` and `田中 副社長` in corpus_rules.jsonl. Older entries carry the same cost unrecorded until now (`김민준의학박사` reads given 민준의학, suffix 박사). Listing a compound whole would peel it whole, longest first. - 2026-10-18 (vocabulary phase 1, decisions/vocabulary-model) — RECORDED HERE from the #307/#308 vetting note in nameparser/config/suffixes.py, which the move of the vocabulary to TOML retired; its per-word facts went to each word's TOML entry (`uv run python tools/vocab/query.py --issue 308`). Three class-level points had no other home. The spaced and glued bars are different, and an entry can be safe spaced and unsafe glued, never the other way round, because the spaced position is a token boundary its writer drew. A Han, kana or hangul entry is self-selecting, like the Korean surnames: it can only ever match CJK text. And kana is no license by itself: hiragana spells Japanese GIVEN names (高橋みなみ, pinned in the case table), which is why さん, さま, くん and ちゃん were vetted one at a time as never name-final rather than waved through as kana. Further spaced candidates (여사, 太太) wait on the same case-by-case argument. - 2026-10-19 #760 (the two facts it left to AGENTS.md's script paragraph moved into this file on 2026-10-27, below) — MOVED FROM the peel's code comment in `_script_segment.py` (`_peel_honorific_tail`), the facts the #312/#319 entries above and AGENTS.md's script paragraph did not hold (that paragraph keeps why the run predicate is not the token predicate's plural, and what `lenient_comma_suffixes=False` does here); parses re-run 2026-10-19. WHY ONLY THE FAMILY COMMA CROSSES: a comma is how a writer says which runs are the name, and a family comma is the one structure that splits the name itself across two of them (`김, 민준씨` is runs (0,) and (1,)), with the honorific as often glued to the given side as to the family. Every other structure keeps the whole name in segments[0], so the asymmetry is the point rather than an accident of two cases. "The rest is post-nominals" is true of the SUFFIX comma only: under NO_COMMA segment returns one run and a trailing post-nominal is INSIDE it (`김민준씨 Jr.` is one run of two tokens, family '김', suffix '씨 Jr.'), which is why the scan-back steps over such a token rather than never reaching it. WHY THE RUN PREDICATE IS ASKED: the structure says nothing about whether segments[1] is name text — segment hands every comma form over as FAMILY_COMMA (#613), and a one-word part turned a wholly suffix-shaped remainder into FAMILY_COMMA anyway (`田中さん, V.`). THE SCOPE HOLDS OFF A JUNK TAIL past the second run: `Dr 김민준씨, Jr., 박씨` is a postnominal part behind a whole name (`_postnominal_behind_a_whole_name`), so the scan stays in segments[0] and 박씨 in a third run is never reached (family '김', suffix '씨, Jr., 박씨'). AN EMPTY SECOND RUN stays in scope and contributes nothing: `is_wholly_suffix` is False on it by its own contract (v1 read `Doe,, Jr.` as a family comma), which is the reading the peel wants, flattening an empty run adding no site. FLATTENING SEGMENTS, NOT `state.tokens`, is load-bearing: extracted nickname and maiden content is in tokens but in no segment, and scanning tokens would put the site on a nickname (`김민준씨 (Jimmy)` would peel nothing); the case row `ko_honorific_glued_given_nickname` pins it (family '김', given '민준', suffix '씨', nickname 'Jimmy'). WHY THE PREDICATE IS CIRCULAR HERE ALONE, and so needs the second condition: not because `_comma.decide` asks it too (at group's head, after this stage), but because decide SPENDS the answer differently — it reads the run's shape and stops, the answer being the structure, while the peel reads the same shape and then decides whether to strip the very honorific that produced it. Declining a run that holds the only site does not fall back to another site; it loses the peel outright, and with it the given name, which lands in suffix (`이, J.씨` would read suffix 'J.씨'). DROPPED AS STALE, not moved: the comment said the declined credential lands "`PhD` a title"; it is a suffix (`田中さん, PhD` → suffix 'さん, PhD', rules/W3's example line), as AGENTS.md's script paragraph then said (moved into this file on 2026-10-27). - 2026-10-27 #909 — MOVED FROM AGENTS.md's script paragraph, which now keeps the doctrine and points at the rules; every parse below re-run 2026-10-27. THE REACH, by example: under a family comma the scan reads the first two runs and nothing past them, so `김, 민준 지훈씨` peels (family '김', given '민준', middle '지훈', suffix '씨') while `김, 민준, 지훈씨` (suffix '지훈씨') and `김,, 민준씨` (suffix '민준씨') do not, both landing the honorific in a third run. WHAT #319 FIXED: before it, the peel walked into such a second run, took `V.` for its site, found no listed tail and abandoned. THE SECOND CONDITION, by example: the part before the comma must offer a site of its own before a suffix-shaped second run is declined, so `선생님, J.씨` is scanned anyway like `이, J.씨` (given 'J.', suffix '씨'), both keeping the pre-#319 reading, and `김민준씨, J.씨`, with a site on both sides, peels the person's own 씨 rather than the one behind the comma (family '김민준', suffix '씨, J.씨'). UNIFORM IN THE PEEL, not in where the credential lands, which is assign's question and differs by spelling: `田中さん, V.` reads given 'V.', while `田中さん, PhD` and `田中さん, Ph. D.` read suffix 'さん, PhD' and 'さん, Ph. D.'. THE RUN PREDICATE IS NOT THE TOKEN PREDICATE (`_is_post_nominal`) PLURALIZED: it says yes both to what the token predicate vetoes (`V.`, `V`, `I`, the class the defect was reported as) and to what the token predicate never sees, since `period_joined_vocab` and the delimiter routes are the run predicate's alone (`Msc.Ed.` and `J.씨` reach it that way, and `田中さん, Msc.Ed.` reads suffix 'さん, Msc.Ed.' with the rest). UNDER `Policy(lenient_comma_suffixes=False)` (rules/C14) the call drops to the strict token test, so `田中さん, V.`, `田中さん, V` and `田中さん, I` read their credential as name text and keep the pre-#319 answer (family '田中さん'), while `田中さん, Ph. D.` peels regardless, its merged `phd` passing the strict test. `田中さん, 太郎` is untouched (family '田中さん', given '太郎'), and not because of its comma: the honorific is not at the end of the name, 太郎 is. WHY THE PEEL ASKS `_comma.decide` (on a classified copy of the name as written, since #613's /simplify) RATHER THAN A HAND COPY: the copy drifted from decide four times in that PR's review (the ambiguous class's count, the numeral refusal, a suffix before the comma, the split `Ph. D.`), each drift dividing a name decide read as a family comma or leaving whole one it read as a suffix comma; decisions/C1's 2026-10-06 (Derek), #613 entry. WHY THE SPACED-ONLY ENTRIES ARE: 김지양 and 田中博士 are names, and about 90 Japanese surnames end in 殿; 君 is in neither set because 王君 is a complete Chinese name. Neither policy opt-out reaches the peel (`Policy(script_orders={}, segment_scripts=frozenset())` still reads `田中さん` suffix 'さん' and `김민준씨` suffix '씨'): the vocabulary carries the license, so the script switches have nothing to say about it. derek73-python-nameparser-456b024/docs/design/decisions/W3-writers-divisions-are-respected.md000066400000000000000000000107061527303271700321320ustar00rootroot00000000000000# W3 — the writer's divisions are respected ## Standing - A segmenter answers where an undivided name divides, so it is asked only when the gated token is the name part's only script-written one: `山田 太郎` was divided by its writer and keeps family '山田'; the peeled tail is exempt, and a spaced honorific counts as a division, which keeps `佐藤 氏`, `田中 様`, `鈴木 先生` and `中村 教授` whole at the cost of the one division `山田太郎 様` — 2026-08. - Under a family comma the honorific split-off runs ahead of the stand-down, so it crosses the comma (`김, 민준씨` → family '김', given '민준', suffix '씨') while division stands down for vocabulary and segmenter alike (`남궁민수, 지훈` → family '남궁민수') — 2026-08 #312. That every comma form is tolerated input, described rather than promised, is decisions/cjk-comma-demotion's (Elsewhere below). - The stage runs after comma segmentation, so the comma structure never depends on the split: `김민준, Jr.` reads family '김민준', suffix 'Jr.' — 2026-07. - The comma crossing is stated at W3 rather than at W2, the comma being the part no East Asian writing system produces, and the rule's comma and period examples are watched by the snapshot and pinned at HEAD by tolerated case rows rather than promised — 2026-10-19 #761. Elsewhere: the line between promised and tolerated input is format purity, decisions/cjk-comma-demotion's 2026-09-01 #469 entries; the division and the split-off follow C1's own reading of the comma (`Dr 김민준, MA` → title 'Dr', family '김', given '민준', suffix 'MA'), decisions/C1's 2026-10-06 (Derek), #613 entry; the period shapes (`田中さん.` → family '田中', suffix 'さん.'), decisions/cjk-full-stops. Open: none in this file. ## Log - 2026-08 (segmenter amendment 2026-07-29) — a segmenter answers where an UNDIVIDED name divides, so it is consulted only when the gated token is the name part's only script-written one: "山田 太郎" was divided by its writer and must not have its family divided again. The peeled tail is exempt (that boundary was manufactured, not written); a SPACED honorific is not — at that position it cannot be told from a spaced name element. The trade was measured before deciding: counting spaced honorifics keeps four real surnames whole (佐藤 氏, 田中 様, 鈴木 先生, 中村 教授) and costs the one division 山田太郎 様. - 2026-08 #312 (superseded as contract by decisions/cjk-comma-demotion's 2026-09-01 #469, THE LINE IS FORMAT PURITY: the family-comma forms are tolerated input, rules/W3) — under FAMILY_COMMA the whole stage stood down until the peel moved in front of the gate: the comma doctrine is about the input's structure, not the split's source, so it covers vocabulary and segmenter identically, while an honorific is no part of the name whichever side of the comma it glues to. - 2026-07 — the stage runs AFTER comma segmentation on purpose: running before would make the comma structure depend on the split ("김민준, Jr." pre-split would read suffix-comma on vocabulary alone; as written it stays the listing form). - 2026-10-19 #761 (skeleton migration) — the rule text no longer carries its argument and history, which moved here. WHY THE COMMA CROSSING IS STATED AT W3 AND NOT W2: the honorific split-off's crossing of a family comma is a claim about the comma, and the comma is the part no East Asian writing system produces, so it belongs with the tolerated rule rather than with W2's normative one; rules/W3 keeps the crossing itself as a Precedence bullet. THE PERIOD HISTORY: `田中さん.` and `김민준씨.` read as titles until 2026-09-10 (#323), a reading measured on 2026-09-05 and pinned by nothing, which is why the move was free — decisions/cjk-full-stops holds the argument and decisions/cjk-comma-demotion the parses; the rule keeps the current reading of each period shape, and all three period examples are tolerated case rows in tests/v2/cases.py. THE WATCHING: the rule's `tolerated:` line said that its eight comma and period example lines (five comma, three period) stay watched by the snapshot (tools/differential/snapshot.jsonl, over corpus_rules.jsonl and corpus_cjk.jsonl) and that the `tolerated` rows of tests/v2/cases.py pin them at HEAD; checked 2026-10-19, each of the eight texts is in both corpus files and in the snapshot. The rule's `**Tolerated:**` field keeps the reason and which lines illustrate rather than promise; nothing about the behavior moved. derek73-python-nameparser-456b024/docs/design/decisions/W4-script-scoped-order.md000066400000000000000000000263701527303271700276020ustar00rootroot00000000000000# W4 — script-scoped order ## Standing - The family-first override is keyed to the script of the written name, never a guessed language, and Latin transliteration is untouched: `김 민준` and `山田 太郎` read family-first under every declared order, while `Kim Min-jun` reads given 'Kim' by default — 2026-07-27. - Han and kana together with at least one kana is licensed Japanese: `高橋 みなみ` → family '高橋' under every declared order — 2026-07-29 #272. - A wholly-katakana name keeps the declared order because its script cannot tell a Japanese reading from a transcription, halfwidth included (`マイケル ジャクソン` → given 'マイケル', `ヤマダ タロウ` → given 'ヤマダ'), and a caller who knows adds `(Script.KATAKANA, FAMILY_FIRST)` to `script_orders` — 2026-10-03 #594. - The halfwidth kana block U+FF65–FF9F is katakana in the script table, decided by widening the table and not by an NFKC fold, which reaches past the kana (`JOHN`, `㈱`): `山田 タロウ` → family '山田', and `タナカ. John` reads given 'タナカ.' and no title — 2026-10-03 #594. - A voicing mark standing after a kana takes that kana's script, in the classification copy only: `ア゙イ タロウ` keeps the declared order (given 'ア゙イ'), `あ゙い たろう` → family 'あ゙い', `山田 ア゙イ` → family '山田'; a mark after anything else keeps the table's answer (`김゙민준` → given '김゙민준', undivided) — 2026-10-03 #596. - Taking the voicing marks out of the hiragana range, and narrowing the fix to the two combining marks, are declined; a mark with nothing before it keeps the table's answer and reads family-first, unpromised (`゙アイ タロウ` → family '゙アイ' under every declared order) — 2026-10-03 #596. - A name the 间隔号 divides keeps the declared order: `威廉·莎士比亚` → given '威廉' by default and family '威廉' under FAMILY_FIRST — 2026-10-03 #596; rules/W4 states the stand-down as an Except — 2026-10-20 (Derek), #761. Elsewhere: the katakana middle dot divides like a space and its pieces read by the script license (`威廉・莎士比亚` → family '威廉'), decisions/T3's 2026-07-30 #298 entry on codepoint scope; a lone word in a script with no order of its own still reports `given-or-family` (`マイケル`), decisions/O5's 2026-09-07 #449 entry. Open: none in this file. ## Log - 2026-07-27 (script-scoped order amendment) (its transcription rationale for wholly-katakana names superseded by #594 below, the behavior standing; its wholly-Han clause narrowed for a name the 间隔号 divides by decisions/T3's 2026-07-30 #298) — the family-first override is keyed to the SCRIPT of the written name, never to a guessed language: wholly-Han, wholly-Hangul, and kana-licensed Japanese read family-first because zh, ko and ja all write family-first in native script; wholly-katakana names are predominantly transcriptions and keep the declared order. Latin transliterations are never touched. - 2026-07-29 #272 — the kana license: Han∪kana with at least one kana cannot be Chinese and is not a transcription, so 高橋みなみ reads family-first though it is written in two scripts. - 2026-10-03 #594 (its voicing-mark argument against an NFKC fold superseded by #596 below) — HALFWIDTH KATAKANA IS KATAKANA. The script table classifies the halfwidth kana block U+FF65–FF9F as `Script.KATAKANA`, so `山田 タロウ` reads family 山田 as `山田 エミ` does; every release through 2.3.0 left the block unclassified and read it given 山田, family タロウ, the positional reading. Legacy data written for JIS X 0201 systems, which had no kanji, spells names this way — bank, payroll and CSV exports. DECIDED by widening the table rather than by folding: nothing rewrites the text before parsing (rules.md's T Background), so normalization at tokenize was never available, and an NFKC fold at classification — the other place the issue offered — reaches far past the kana (`JOHN` to `JOHN`, `㈱` to `(株)`), and it would move a wholly-katakana name across W4's line: a voicing mark with no precomposed form to join (`ア゙`, `ン゙`) folds to the COMBINING U+3099, which sits in the HIRAGANA block, so the folded token reads as kana-licensed Japanese and `ア゙イ タロウ` would read family-first where the unfolded one keeps the declared order (measured 2026-10-03 by parsing the NFKC spelling `ア゙イ タロウ`, which reads family `ア゙イ`; a two-input sweep of halfwidth names against their NFC(NFKC) spellings differed only where such a mark stood; RECOMPUTE: parse each halfwidth name and `unicodedata.normalize("NFC", unicodedata.normalize("NFKC", name))` and compare the fields). The interpunct flank guard is NOT the hazard, though an earlier wording of this bullet said it was: it reads raw text and asks only whether a character is classified at all, which hiragana and katakana both are. The same measurement shows a pre-existing gap the fold would have widened rather than created: full-width katakana typed with an uncomposable combining U+3099 already takes the kana license today. A block range is how the table already decides (U+30FB and the voicing marks are in by block), and it takes the voicing marks U+FF9E/U+FF9F in because they are spacing characters following their base. The span starts at U+FF65, the halfwidth nakaguro, which tokenize already treats as a separator (rules/T2), and stops short of U+FF61–FF64, whose U+FF61 is a full stop. Everything keyed on the table follows: the kana license, the second-East-Asian-word count that keeps a segmenter from being asked (`高橋一郎 タロウ`), the 间隔号's flank guard (`タロウ·ヤマダ` divides), and `_NO_INITIALS`, which closes the limit #cjk-full-stops recorded (`タナカ. John` read title `タナカ.`). THE RATIONALE FOR WHOLLY-KATAKANA NAMES IS AMENDED, NOT THE BEHAVIOR: a wholly-halfwidth name keeps the declared order exactly as a wholly-katakana one does (`ヤマダ タロウ` reads given ヤマダ), but the 2026-07-27 reason — that such names are predominantly transcriptions — is weaker here, since a system with no kanji wrote Japanese names in katakana too, family-first. What stands is the narrower claim: the script cannot tell a Japanese reading from a transcription, so it settles no order, and a caller who knows their katakana is Japanese adds `(Script.KATAKANA, FAMILY_FIRST)` to `script_orders` (docs/usage.rst's Boundaries section carries the recipe). `name_order=FAMILY_FIRST` works too, at the cost of reversing Latin names as well. MEASURED 2026-10-03: the differential gate saw nothing before this change because no corpus line held halfwidth kana; the eight case rows #594 added are its whole population, five of them contract movers (`山田 タロウ`, `山田 ダイスケ`, `山田 ペーター`, the unspaced `山田タロウ`, `タロウ·ヤマダ`), two tolerated radar movers (`タナカ. John`, `マイケル.`) and one parity row (`ヤマダ タロウ`). RECOMPUTE: parse the eight on this tree and on 2.3.0 from PyPI and read `title` beside the name fields. - 2026-10-03 #596 — A VOICING MARK TAKES THE SCRIPT OF THE KANA BEFORE IT. The four kana voicing marks U+3099–309C are in the hiragana BLOCK, which is how the script table decides, but none belongs to either syllabary: a mark voices the kana it follows. NFC composes one only where a precomposed voiced kana exists (カ + U+3099 is ガ), so `ア゙イ`, `ン゙` and every spacing ゛/゜ kept a hiragana-block character inside a katakana word, the word took the kana license, and a wholly-katakana name read family-first — the gap the #594 entry above measured, present since 2.1.0. DECIDED by deleting, from the classification copy only, every mark standing after a KANA, so the word reads as its base's script; token text is untouched. A mark after anything else — hangul, Han, Latin, or nothing — voices no kana and keeps the table's answer, as before. That scope is a review finding, not a nicety: the first cut deleted every mark with ANY character before it, which handed `김゙민준` the hangul script, and the surname division, which cuts the raw text, then split the mark off its base — given 김, family `゙민준`, unreported, where 2.3.0 left the word whole and reported given-or-family; `parser_for(locales.ZH)` cut `王゙小明` the same way. A case row watches `김゙민준` — marked tolerated, the reading of a mark after a non-kana being unpromised (rules.md's W Background) — so the radar tier, not the contract, sees it move. Declined: taking the marks out of the hiragana range instead. Measured 2026-10-03 on patched copies of this tree, it fixes the katakana names as well but leaves a word holding a mark in NO script, so `あ゙い たろう` loses family-first, `山田 ア゙イ` loses the kana license and reads family `ア゙イ`, and the unspaced `山田ア゙イ` reads given; the two parity case rows #596 added pin those readings. Declined too: narrowing to the two combining marks, which leaves `ア゛イ タロウ` family-first. NFD-typed text was the issue's worry and is not affected by either option, since classification composes first: NFD `やまだ がくと`, `ジョン ガイ` and `김 민준` parse identically under all of them. A mark with nothing before it keeps the table's answer, so `゙アイ タロウ` still reads family-first; that is degenerate input, recorded in rules.md's W Background as unpromised rather than given a branch. ONE ARGUMENT IN THE #594 ENTRY ABOVE NO LONGER HOLDS: an NFKC fold would no longer move a wholly-katakana name across W4's line, since the combining mark it produces (`ア゙` to `ア゙`) now reads as the katakana it follows — #594's own RECOMPUTE, run 2026-10-03 on this tree over `ア゙イ タロウ`, `ン゙ タロウ`, `山田 ア゙イ`, `マイケル ア゙イ`, `ア゚イ タロウ`, `山田 タロウ` and `ヤマダ タロウ` under all three orders, finds no role differing. The range decision stands on its other ground, the fold's reach past the kana (`JOHN`, `㈱`). The interpunct clause of rules/W4 was corrected in the same change: a name the 间隔号 divides keeps the DECLARED order, not its source order — under `name_order=FAMILY_FIRST` `威廉·莎士比亚` reads family 威廉 — and an example pins it. MEASURED 2026-10-03: no corpus line held an uncomposable mark before this change, so the case rows #596 added are the whole population. At 2.1.0, 2.2.0 and 2.3.0 the three `fix(#596)` rows move; at 2.0.0, 2.1.0 and 2.2.0 `김゙민준` moves in its report alone, #449's given-or-family being new in 2.3, and is parity at 1.4.0, which has no report. At 1.4.0 and 2.0.0, which had no kana license, the three movers are parity, while `あ゙い たろう` and `山田 ア゙イ` move as every native-script CJK name does there, under the existing `fix(#271/#272/#298)` rule. RECOMPUTE: parse `ア゙イ タロウ`, `ア゛イ タロウ`, `マイケル ア゙イ`, `あ゙い たろう`, `山田 ア゙イ` and `김゙민준` here and on each baseline from PyPI. - 2026-10-20 (Derek), #761 — the interpunct stand-down moves from Accepted to Except in the skeleton migration (PR #782): a name the interpunct divides is inside When and Then stops holding there, which is the format's definition of an exception. Words and examples unchanged. derek73-python-nameparser-456b024/docs/design/decisions/api-churn-declines.md000066400000000000000000000005161527303271700270660ustar00rootroot00000000000000# api-churn-declines — do not re-propose without new cause Declined (2026-07-06, the post-1.3.0 modernization sweep; each on churn-outweighs-benefit grounds): the hn.C → constants rename; capitalization-knob consolidation; the logger rename. (Lenient matching, declined in the same sweep, is recorded under comparison-surface.) derek73-python-nameparser-456b024/docs/design/decisions/cjk-comma-demotion.md000066400000000000000000000551231527303271700270750ustar00rootroot00000000000000# cjk-comma-demotion — the script shapes are pure, the wrappers are tolerated (2026-09-01, #469) Closes #469, and continues the corpus-tier arc below rather than standing apart from it: the tier split gave the differential somewhere to WATCH a name without promising it, and this is the first doctrine narrowing to spend that. No parser behavior moves anywhere in it. Counts are this session's and every one is recomputable from the checked-in tree — `wc -l` over tools/differential/, `uv run python tools/differential/build_shapes_corpus.py --coverage`, and one `uv run python tools/differential/compare.py --baseline X` run per baseline, read off its `corpora:` and `corpus:` lines. ## Standing - The line between promised and tolerated CJK input is format purity, not comma class: shapes 6 and 7 admit wholly classified-script text, and every composed form around a CJK name (a family-comma listing, the honorific-comma forms, a leading Latin title, a trailing credential comma) is tolerated, parsed best-effort and changeable without notice — 2026-09-01 #469, THE LINE IS FORMAT PURITY. - decisions/3-0-reevaluations' explicit-comma-beats-script line stands for Latin script and is narrowed to a description for CJK script, its bullet left unedited — 2026-09-01 #469, SCOPES the FAMILY_COMMA bullet. - The clause decisions/C8's #428 snapshot attributes to C1's Accepted consequences (`田中さん, Dr.` keeping its honorific glued, family '田中さん', title 'Dr.') is read at rules/W3, in its first Then bullet since the #761 skeleton migration and tolerated under its Tolerated field, the snapshot left as written — 2026-09-01 #469, the. - The shipped release notes calling the comma crossing a fix (two 2.1.0 entries) are left as written, the demotion being 2.3's news — 2026-09-01. - A period after a CJK honorific is a listing artifact of the same class, so `田中さん 様.`, `김민준 씨.` and `김민준 양.` are tolerated rows, demoted on the class argument and not on a demoted twin — 2026-09-05, THE SWEEP'S CRITERION. - A shape-tagged case row admits no ASCII character but the space between two name words, and an untagged row may carry ASCII, so `John 王`, `王·Smith`, `Andersonさん` and `Anderson선생님` stay contract — 2026-09-05, THE CRITERION IS RESTATED AS THE DOCTRINE. - `山田 太郎 (マイケル・ジャクソン)` stays contract, its ASCII parentheses being one spelling of a delimiter Japanese writes: it, `(…)` and `「…」` all read family '山田', given '太郎', nickname 'マイケル ジャクソン', where ASCII square brackets are no delimiter — 2026-09-05, `山田 太郎 (マイケル・ジャクソン)` IS KEPT. - W2's punctuation half moved to W3 as description and its second half stays normative at W2: a post-nominal word standing on its own is never the name's end and never dissected (`김민준 박사님` → suffix '박사님', `선생님` → family '선생님') — 2026-09-05, W2's PUNCTUATION HALF MOVED. - A period on a separate post-nominal word rides into the suffix and moves no division (`田中さん 様.` → family '田中', suffix 'さん 様.', as `田中さん 様` divides) — 2026-09-05, FOUND while rewriting the clause. Elsewhere: since #322 a period on a name word or glued to the honorific changes no division either, which reverses this file's 2026-09-05 finding that a period on the name's own last word is decisive (`田中さん 太郎.` → family '田中さん', given '太郎.', as `田中さん 太郎` reads; `田中さん.` → family '田中', suffix 'さん.') — decisions/cjk-full-stops' 2026-09-10 #322/#323 line. Open: none in this file. ## Log - 2026-09-01 #469 — THE SCRIPT SHAPES ARE PURE, THE WRAPPERS ARE TOLERATED: the bullets below. - 2026-09-01 #469 — THE LINE IS FORMAT PURITY, and not comma class. Shapes 6 ("Family Given [Honorific]") and 7 (the interpunct/katakana transcription in source order) admit wholly classified-script text and refuse a comma and every ASCII letter; each composed form around a CJK name — the family-comma listing W3 reads, the honorific-comma probe zoo, a leading Latin title, a trailing credential comma — is TOLERATED input instead: parsed best-effort, contract-exempt, changeable without notice. What the inventory is FOR is the whole argument. It tabulates written ARRANGEMENTS, and a comma-wrapped or Latin-wrapped CJK name is not an arrangement any of the three writing systems produces — it is a listing convention from elsewhere with a CJK name inside it. So the admission test is a property of the FORMAT, and a rule reading such input describes rather than promises. Supersedes an in-session fork of this same day's design, recorded because it is a line the next reader would redraw the same wrong way. The first cut kept trailing CREDENTIAL commas contract while demoting the family comma, on the theory that the two comma classes differ in kind — a credential comma being a Latin convention wrapped around anything, where a family comma makes a claim about the name's own division. The shape lens replaced it: both are wrappers, neither is a form the script writes, and a doctrine promising one while tolerating the other has to keep explaining a distinction its own admission test cannot state. Purity states the line in one sentence and needs no comma taxonomy at all. - 2026-09-01 #469 — SCOPES the FAMILY_COMMA bullet under 3-0-reevaluations below, which reads "inherited from v1's lastname-comma but correct on its own terms — an explicit comma is stronger evidence than script". It stands unchanged for LATIN script, which is the script it was argued about. For CJK script it is narrowed to a description: an explicit comma still beats script and the parser still reads it that way, but every input carrying one is a form no East Asian writing system produces, so what the parser makes of it is current behavior and not a promise. The bullet is not edited — it was right about what it was reasoning over, and this entry is the scope it never had to state while Latin was the only script in the room. - 2026-09-01 #469 — the 2026-08-23 (PR #428) snapshot under decisions/C8 (its #428 review entry) attributes `田中さん, Dr.` keeping its honorific glued to "(C1 Accepted)". That clause now lives in rules/W3's `tolerated:` note: the BEHAVIOR is unchanged and only its home moved, W3 being where the CJK comma forms are set down now that they are described rather than promised. The snapshot stays as written — it was true when it was taken, and this is the note that says where to read the clause instead. - CLDR, PULLED VERBATIM 2026-09-01 — #469 asked for the citation and asked that it be a pull rather than a recollection, so the failures are recorded with the findings. The paths named in the plan (`https://raw.githubusercontent.com/unicode-org/cldr/main/common/personNames/ko.xml`, `.../zh.xml`, `.../ja.xml`) all 404: there is no `common/personNames/` directory in the repository, and the data is a `` element inside `common/main/.xml`. Read there instead, from unicode-org/cldr at `main`, commit `8e4fb0fa1e287c4612b512cf2ef77e890467da28`; the grammar from https://www.unicode.org/reports/tr35/tr35-personNames.html. The grammar first, verbatim, because it decides whether absence is evidence: "A namePattern is composed of a sequence of field IDs, each enclosed in curly braces, and separated by zero or more literal characters (eg, space or comma + space)." A comma between surname and given is therefore expressible, and its absence below is a choice the locale data makes rather than a limit of the format. The `order="surnameFirst" usage="referring" formality="formal"` patterns, verbatim, long then medium then short: ko {surname} {given} {given2} {credentials} · {surname} {given} {credentials} · {surname} {given} zh {generation}{surname} {given} {given2} {credentials}{title} · {generation}{surname} {given} {given2-initial}{credentials} · {surname} {given} {given2} ja {surname} {given2} {given}{title} · {surname} {given}{title} · {surname} {given}{title} Counted rather than eyeballed: each of the three files holds 42 `namePattern` elements, and not one of the 126 contains a comma of any width (U+002C, U+FF0C, U+3001) or a semicolon. The delimiter between surname and given is a single space throughout. THE ONE COMMA IN REACH, recorded because the record must not overclaim. It is in the locale-neutral root, whose sorting pattern reads verbatim `{surname} {surname2}, {title} {given} {given2} {credentials}`. `sorting` is a list-ordering format and not a referring one — UTS #35 verbatim: "Used to format names for a sorted list. example: “Brown, William” [medium, informal]" — and ko and zh override all six of their sorting slots comma-free (`{surname} {given}` and kin), while ja overrides five and leaves `order="sorting" length="medium" usage="referring" formality="formal"` as `↑↑↑`, inheriting root's comma-bearing pattern. So the honest reading, and it is the stronger one for being stated with its exception: no native pattern in any of the three writes a comma between surname and given, and the sole comma any of them can reach is INHERITED from the locale-neutral default, in the format whose entire job is sorted lists. That is the "listing convention carried in from elsewhere" the W Background already named, arriving from CLDR rather than from this project's own reasoning — the pull CONFIRMS the writing-system rationale, and complicates it only by showing the exact seam where a comma does get in. Two findings worth keeping past the comma question. zh writes `{surname}{title}` with no delimiter at all (and ja `{surname} {given}{title}`), which corroborates rules/W2's glued honorific from the formatter's side, independently of the peel's own vocabulary argument. And ja declares `・` — a foreign name's spaces become the nakaguro — which is shape 7's divider reached from the formatting direction rather than the parsing one. - 2026-09-01 — the SHIPPED 2.2.0 release notes are left untouched, deliberately, and this bullet is the record of that choice. docs/release_log.rst describes the comma-crossing as a fix in two 2.1.0 entries ("Fix a comma or a 间隔号 stopping the glued-honorific peel", and #319's `田中さん, V.` entry). Both were true statements about what those releases did, made while the crossing was contract and #312 had argued it. Demoting the rule does not make a released note false about its release, and editing shipped notes to agree with a later doctrine would cost the log the one property it exists for. The demotion is 2.3's news and belongs in 2.3's notes; the log is a history, not a mirror of the current contract. - COUNTS, 2026-09-01 (PR #488): 29 case rows carry `tolerated=True` over 26 distinct texts; `corpus_cjk.jsonl` went 98 → 73 and `corpus_cjk_tolerated.jsonl` was created at 25, then 26, moving names and dropping none; `corpus_rules.jsonl` went 252 → 248; and the gate's intentional diffs were unmoved at every baseline with unexplained 0, a classified diff counting identically on either tier. The 26th text, the given-side listing `지훈, 남궁민수`, was a rules.md example with no case row and was given a tolerated row rather than dropped. - 2026-09-05 — THE SWEEP'S CRITERION WAS NARROWER THAN THE DOCTRINE IT IMPLEMENTED, and three rows sat in the gap. The 2026-09-01 sweep asked, over the CJK-bearing case rows, for a comma or an ASCII letter. A trailing ASCII period is neither, so `田中さん 様.`, `김민준 씨.` and `김민준 양.` — the three `fix(#320)` probes — stayed contract; and `田中さん 様.` was then chosen as rules/W2's replacement example for its "ignores surrounding punctuation" clause on the strength of passing that same test, so the miss propagated into the normative document the same day. A period standing after a CJK honorific is a listing artifact no writing system produces, which is this entry's own class in its own words: the admission test judges the FORMAT, and `様.` is no more an arrangement Japanese writes than `田中さん, PhD` is. The three rows carry `tolerated=True` now, and nothing about the parse moves — the rows still pin #320's mechanism at HEAD, and the texts are still compared and classified at every baseline, from the radar file instead of the contract one. CORRECTED IN REVIEW, 2026-09-05: a first draft of this bullet called all three "the spaced twin of a comma row that WAS demoted", and only two are. `田中さん, 様.` and `김민준, 씨.` are both case rows and both in `corpus_cjk_tolerated.jsonl`; `김민준, 양.` is no row at all — enumerating the CASES texts containing `양` returns `김지양`, `양 미선`, `김민준 양`, `김민준 양.` and `양 지훈`, and nothing with a comma. `김민준 양.` is demoted on the class argument alone, which is what carries the other two as well: an existing demoted twin is corroboration, never the warrant, or the class would end wherever the case table happens to stop. (Recompute: read `CASES` and collect the distinct texts containing the character.) - 2026-09-05 — THE CRITERION IS RESTATED AS THE DOCTRINE rather than as a sample of it. A SHAPE-TAGGED row (shapes 6/7, `Case.__post_init__` in tests/v2/cases.py) now admits no ASCII character but the space between two name words, so a period, a digit and a bracket are refused beside the comma and the Latin letter the check already refused, and the next composed form does not need a fresh criterion to catch it. The gate is a property of the shape TAG and not of CJK text, which is the half that keeps it honest: an UNTAGGED row may carry ASCII, and several deliberately do. The four mixed-script rows commit `d562164` reviewed one by one — `John 王`, `王·Smith`, `Andersonさん`, `Anderson선생님` — keep the contract tier on the reasons recorded there, untouched by this. - 2026-09-05 — `山田 太郎 (マイケル・ジャクソン)` IS KEPT contract: it is the one text the wider sweep surfaces and this entry declines to demote. Its ASCII parentheses are not a convention carried in from elsewhere — they are one SPELLING of a delimiter Japanese writing does produce, and the parser reads the spellings identically. Measured 2026-09-05 under the default facade: `(マイケル・ジャクソン)`, the fullwidth `(マイケル・ジャクソン)` and the corner-bracketed `「マイケル・ジャクソン」` all give family 山田, given 太郎, nickname `マイケル ジャクソン` (recompute: parse the three; ASCII square brackets are NOT in that set and give family `ジャクソン]`, which is what makes this a delimiter roster rather than a rule about brackets). What the row pins is the nakaguro INSIDE a delimited nickname (`fix(#272)`), and which of the three delimiters spells it is incidental to that. - 2026-09-05 — W2's PUNCTUATION HALF MOVED to W3 with its example; ITS SECOND HALF IS NORMATIVE AND STAYED, and this bullet is the within-branch correction of a first draft that took both. The clause read "The split-off ignores surrounding punctuation, but never treats a part that is not name text as the name's end". The FIRST half had no pure witness and could not be given one — native CJK writing puts no punctuation around a name for a split-off to ignore, so every input it describes is a listing form, which is W3's ground — and it moved there as description, rewritten to what is measured rather than to what it asserted (the bullet below). The SECOND half is a different claim and the reason given for cutting it does not reach it: it is what the parser does with NO comma and no period anywhere in the input, and contract-tier pure names depend on it. Measured 2026-09-05 by forcing `_is_post_nominal` to False in memory — the predicate the site scan `_peel_site` reads in nameparser/_pipeline/_script_segment.py — and re-parsing (family, given, suffix, title): `田中さん 様` ships `('田中', '', 'さん, 様', '')` and gives `('田中さん', '', '様', '')` without the step; `김민준 박사님` ships `('김', '민준', '박사님', '')` and gives `('김', '민준', '박사, 님', '')`; `선생님` ships `('선생님', '', '', '')` and gives `('선', '생', '님', '')`. The three break DIFFERENT clauses of the half, and a first draft of this bullet said all three were dissection. The restored sentence makes two claims — the post-nominal word is never the name's END, and it is never DISSECTED — and `田中さん 様` fails only the first: `様` comes out WHOLE as the suffix, undissected, but it is what the split-off takes as its SITE, so the peel lands on the post-nominal word and `田中さん` behind it is left unpeeled, where shipped the scan steps past `様` and peels `さん` off `田中さん`. `김민준 박사님` and `선생님` are the dissection failures, `박사님` coming apart into `박사, 님` and `선생님` into `선, 생, 님`. Between them the three witness both clauses, and neither clause is witnessed twice over. Two of the three, `김민준 박사님` and `선생님`, are `corpus_cjk.jsonl` names on the CONTRACT tier, and `ko_honorific_glued_doctor_spaced` is the #308/#271 regression row for the first; that row pins `suffix="박사님"`, which is what `parse('김민준 박사님').suffix` returns under the default facade, measured 2026-09-05. Under W2's remaining FIRST statement alone — a listed honorific glued to the end of the name's last name word splits off once — `박사님` ends in the listed tail `님` and reads as peelable, giving `박사` + `님`. So a W2 cut back to that one sentence would have BEGUN contradicting the regression row its own example line pins, not stopped: the second half is what keeps rules.md agreeing with the shipped parse, and that is the argument for restoring it. Restored in W2's register as "A part that is not name text — a post-nominal word standing on its own — is never the name's end: the split-off steps past it to the name word behind, and never dissects it." — witnessed by `김민준 박사님` → `suffix="박사님"` and the boundary line `선생님` → `family="선생님"`, both texts `corpus_cjk.jsonl` already held, so the two example lines arrive in `corpus_rules.jsonl` without adding a name to the pool. `田中さん 様` would have been the third witness and is NOT added: it is not a case row, and a new contract name needs a 1.4.0 classification this bundle does not have. `_script_segment.py` carries the restored sentence as a second W2 citation, on `_peel_site` where the scan-back lives. RECOMPUTE: rebind `_is_post_nominal` to `lambda state, i: False` on the module and re-parse the three. - 2026-09-05, FOUND while rewriting the clause (its verdict's consequence that a period on the name's own last word decides the order, and its glued-period clause, superseded by decisions/cjk-full-stops' 2026-09-10 #322/#323: a period on a name word or glued to the honorific changes no division) — A PERIOD ON THE NAME'S OWN LAST WORD IS NOT IGNORED; IT IS DECISIVE, and the moved half asserted the opposite. The half said the split-off "ignores surrounding punctuation", and W3's first descriptive rewrite of it kept that shape, attributing the crossing to the punctuation. Five parses, 2026-09-05 under the default facade, say otherwise — counted by INPUT STRING, the convention the rest of this bullet uses when it says "the five inputs" and "parse the five strings". A first draft counted four by counting the SENTENCES below instead, the last of which carries two inputs (`田中さん.` and `김민준씨.`). Six, if the no-period twin `田中さん 太郎` quoted inside the third sentence as its control counts as a parse of its own rather than as part of that item. `田中さん 様` → family `田中`, suffix `さん, 様`: the crossing happens with no punctuation in the input at all, so the vocabulary licenses it and not the period. `田中さん 様.` → family `田中`, suffix `さん, 様.`: the period rides along and moves nothing, `様.` reading post-nominal-strict exactly as `様` does. `田中さん 太郎.` → family `太郎.`, given `田中さん`: a period on a NAME word, and no crossing — and not ignored either, since the same input without it is family `田中さん`, given `太郎`, the order reading flipped by the period alone. `田中さん.` → title `田中さん.`, and `김민준씨.` → title `김민준씨.`: a period glued to the honorific's own word stands between the honorific and that word's end, no listed tail matches, the peel declines and the whole text reads as a title — the parse a period changes most, not least. Of the five inputs, the ONLY one where a period changes nothing is the one where it sits on a word the split-off was going to step past regardless. So W3 now says the step past a post-nominal word is W2's and needs no punctuation, that a period on a separate post-nominal word rides into the suffix, and that a period glued to the honorific's own word decides against the split-off. AMENDED 2026-09-10 by #cjk-full-stops, and it is the summary that goes rather than the measurements: every parse above keeps its date and is what the parser did on 2026-09-05 (the two suffix values render space-joined since #436/#437, the next day, `さん 様` for `さん, 様`). Of the four period-bearing inputs, ALL FOUR now divide exactly as their no-period twins — `田中さん 太郎.` reads family `田中さん`, given `太郎.`, the classification fold making the period invisible to the order rule, and `田中さん.` and `김민준씨.` peel their honorific with the stop riding on it — so the period changes no division at all here, and the third clause of the sentence before this one is superseded: a period glued to the honorific's own word rides WITH the honorific now. What survives is the first two clauses and the finding this bullet is titled for, inverted in its consequence rather than in its content: the period was decisive because a site was reading it, and the answer was to stop three more sites reading it. RECOMPUTE: parse the five strings, each also without its period, and read `title` alongside the name fields — a rewrite that reads only `family` and `suffix` sees two empty results and can call them a null effect. - COUNTS, 2026-09-05 (PR #505): `corpus_cjk.jsonl` went 73 → 70 and `corpus_cjk_tolerated.jsonl` 26 → 29, moving three texts and dropping none; `corpus_rules.jsonl` went 248 → 247 → 249 without growing the compared pool; 32 case rows carry `tolerated=True` over 29 texts; the gate's intentional diffs were unmoved at every baseline with unexplained 0; and the sweep (CJK-bearing rows not declaring `tolerated` that carry an ASCII character other than a space) returns eight texts before the bundle and five after, the period class gone and the letter class untouched. derek73-python-nameparser-456b024/docs/design/decisions/cjk-full-stops.md000066400000000000000000000630711527303271700262760ustar00rootroot00000000000000# cjk-full-stops — which characters are a period on CJK text (2026-09-10, #322/#323) ONE GAP SEEN FROM TWO SIDES, which is why the two issues were answered together. #322 asked WHICH CHARACTERS count as a full stop: the parser knew the ASCII period and nothing else, so `김민준 씨.` — written with the fullwidth stop a Japanese or Chinese IME produces by default — missed the suffix entry that `김민준 씨.` reached, and so did the ideographic `씨。` and its halfwidth `씨。`. #323 asked WHERE a full stop is read at all: the one site that knew the ASCII period was the lexicon's edge strip, so a stop glued to a name word was invisible to a vocabulary lookup and to nothing else. Either answer alone leaves the other half broken — a fullwidth stop the lookup folds would still have taken its token out of the script reading — so the bundle is one arc with four code commits and this record. ## Standing - `_lexicon.FULL_STOPS` (`..。。`: the ASCII period and the fullwidth, ideographic and halfwidth ideographic stops) answers "which characters are a period" once, read by the lookup and storage fold, the script-classification fold, the surname site's head match, and the honorific peel's tail match, so `김민준 씨.`, `씨。` and `씨。` each read suffix as `씨.` does — 2026-09-10 #322. - A stop glued to a name word is read at the edge of every site that reads the set, not only the lookup: `양. 지훈` reads given '지훈', family '양.', `양 지훈.` keeps its family-first order, `김. 민준` divides as family '김.', given '민준', and `김민준.` as family '김', given '민준.', the stop riding with the remainder — 2026-09-10 #322. - A stop on a glued honorific rides with it: `田中さん.` reads family '田中', suffix 'さん.' and `김민준씨.` family '김', suffix '씨.', as their stop-less spellings do — 2026-09-09. - H2's opening-abbreviation shape declines a period-marked word carrying a script with no initials, so `田中.` reads family '田中.' and `田中. 太郎` family '田中.', given '太郎' while `Smith. John` and `Проф. Иванов` keep title; the veto is contains-any, so a word carrying such a character is no title even beside Latin letters — 2026-09-09. - The fold is script-agnostic at the edge, so a Latin or Cyrillic word wearing a wide stop reaches the vocabulary that only the ASCII period reached (`Dr。 John Smith` and `Проф。 Иванов` read title, `John Smith V。` and `Smith, John V。` read suffix 'V。' where `Smith, John V.` keeps its middle initial); the four ASCII-only edge tests on Latin shapes (the initial `J.`, the bracketed credential `(Mgr.)`, the apostrophe rule, the trailing `V.` carve-out) stay ASCII, and a name with only interior wide stops (`M.B.A.`) is untouched — 2026-09-10 #322. - `_normalize` composes NFC for a non-ASCII word at storage and at match time, which makes the fold symmetric, so NFD hangul, Latin and the other composable scripts match their vocabulary (`NFD("김민준, 씨.")` reads family '김민준', suffix '씨.'); the segmentation sites stay raw, so an NFD name goes unsplit rather than divided — 2026-09-10 #322. - NFKC was declined: it folds `.` and `。` but not `。`, and at the storage fold it would rewrite stored vocabulary, which the `lower()`-not-`casefold()` decision already refused — 2026-09-10 #322. - A pluggable segmenter is handed the core of a token, its trailing stops removed, and a leading stop keeps a token out of the surname site only (`.김민준` stays one word) and not out of the honorific peel (`.김민준씨` peels to `.김민준` and `씨`) — 2026-09-10 #322. - A bracketed CJK credential degrades, a limit kept and not fixed: `(김민준.) John Smith` reads given '김', middle '민준. John', family 'Smith', since S1's escape calls a clause suffix-shaped by its ASCII period alone — 2026-09-10 #322. - A `Lexicon` pickled before 2.3.0 whose caller-added entries the widened fold touches is refused at load, with the rebuild-from-source fix carried by the 2.3.0 release note and no forgiving load path — 2026-09-10 #322 (Derek). Not standing: the halfwidth-kana limit and the segmenter limit of the "Recorded limits" bullet, both CLOSED, the first by #594 (decisions/W4) and the second by the review round that recorded it — 2026-09-10 #322. Open: none in this file. ## Log - 2026-09-10 #322/#323 — WHICH CHARACTERS ARE A PERIOD ON CJK TEXT: the bullets below. - 2026-09-09 — TWO READINGS JOINED THE BUNDLE, approved in session the day before it landed: the bullet so headed below, the glued-period honorific (rules/W3) and H2's opening-abbreviation shape on a CJK word. - **FULL_STOPS names four characters; five sites read them.** `nameparser/_lexicon.py::FULL_STOPS` is `"..。。"`: the ASCII period, FULLWIDTH FULL STOP U+FF0E, IDEOGRAPHIC FULL STOP U+3002 and HALFWIDTH IDEOGRAPHIC FULL STOP U+FF61. Four sites import the string, so "which characters are a period" is answered once: `_lexicon._normalize` (the lookup AND storage fold), `_vocab._normalized_for_script` (the script-classification fold), and in `_script_segment` the surname site's head match and the honorific peel's tail match. The FIFTH site reads no stop set at all. `_pieces.is_leading_title` — rules/H2's opening-abbreviation shape — refuses a period-marked word whose text carries a character of a script with no initials, through `_vocab.in_initialless_script` (renamed from `_in_initialless_script`; the same veto `is_initial` has carried since #320), so `_PERIOD_ABBREV` stays an ASCII-period pattern and the four-character set is not copied there. Not every period test reads this set, and the ones that do not are named so the claim is checkable: the interior-period shapes — the dotted acronym `M.A.` and the split `Ph. D.` (`_vocab`) — and FOUR ASCII-only edge tests on Latin shapes across THREE modules — the initial `J.` (`_vocab`), the bracketed credential `(Mgr.)` and the word-internal apostrophe rule (`_extract`), and the trailing middle-initial carve-out `V.` (`_assign`) — stay ASCII. What that does NOT mean is that the wider stops leave Latin text alone. The TESTS stay ASCII; the vocabulary FOLD does not, so a Latin word wearing a wide stop reaches vocabulary an ASCII-only carve-out would have kept it out of — `V。` folds to `v` and does not end in the ASCII period that `_reads_as_a_trailing_suffix`'s carve-out tests (`text.endswith(".")`, `_assign.py` ~line 479, whose docstring calls the period "the whole carve-out"), so the carve-out never fires for `V。` and it is roman five, suffix, where `V.` stays a middle initial through that same carve-out — not through `is_initial`, which is not the decider despite reading true on both spellings: `is_initial("V")` is True, yet `Smith, John V` (no period at all) still reads suffix `V`, so being initial-shaped never by itself keeps a word out of suffix. `Jr。` needs no carve-out argument and simply reaches its entry. An earlier wording of this passage called `M.B.A.` and `V。` "a fullwidth-Latin question this set does not raise" (corrected 2026-09-10): that was right for `V。` alone. `V。` is an edge stop on a bare word, and the fold raises the question at every such edge, moving the parse as above. `M.B.A.` does NOT move — every one of its periods is interior, none at an edge for `_lexicon._normalize`'s edge strip to reach, so the fold never touches it at all, and the dotted-acronym reading in `_vocab.suffix_as_written` (`n.replace(".", "")`) is ASCII-only regardless of the fold: `_normalize("M.B.A.")` is `m.b.a`, which matches no acronym form the ASCII-only test recognizes. MEASURED 2026-09-10: `parse("John Smith M.B.A.")` and `parse("John Smith, M.B.A.")` are identical on `d37b8ec` and on this tree — `M.B.A.` stays exactly as it was. The set raises the question at every EDGE fold, and the four ASCII-only tests are the places it is not raised rather than a boundary on the set. MEASURED 2026-09-10 on this tree: `Andrew Perkins (Mgr。)` reads nickname `Mgr。` where `Andrew Perkins (Mgr.)` reads title `Mgr.`; `Smith, John V。` reads suffix `V。` where `Smith, John V.` reads middle `V.`; and `John Smith, Jr。` reads suffix `Jr。` where `d37b8ec` read given `Jr。`, family `John Smith`. The last two are case rows now (`latin_roman_numeral_with_an_ideographic_full_stop`, `latin_suffix_with_an_ideographic_full_stop`), and `is_suffix_strict("Jr。", Lexicon.default())` and `_normalize("Dr。") == "dr"` pin the fold's Latin reach at unit level. Nothing rewrites token text at any of the five: the stop stays on the token it arrived with, and every parsed part is still an exact slice of the input (rules.md's T Background). - **NFC composes at LOOKUP, and that is what makes the fold symmetric.** `_normalize` now composes NFC for a non-ASCII word, at match time and at storage both. NFD hangul decomposes to jamo and missed every vocabulary entry, so `NFD("김민준, 씨.")` read title `씨.`, family `김민준` and now reads family `김민준`, suffix `씨.`. MEASURED 2026-09-09, re-measured 2026-09-10: no shipped entry changes under NFC — 0 of the 1735 strings in the twelve set-valued fields of `Lexicon.default()` (the standing pin `tests/v2/test_lexicon.py::test_every_shipped_entry_is_already_nfc` walks those twelve plus both halves of the five `capitalization_exceptions` pairs, 1745 strings, and asserts the same). RECOMPUTE: fold every string member of those fields through `unicodedata.normalize("NFC", w)` and count `!= w`. - **The reach is not one script, and the Latin part of it was nobody's ask.** 310 of those 1735 entries are non-ASCII and 158 DECOMPOSE under NFD — and they divide by script in a way worth having in front of you, measured 2026-09-10: 117 HANGUL (every hangul syllable has a canonical decomposition, which is #322's own half — `씨`, `님`, `박사님`, `선생님`), 18 LATIN (`señor`, `née`, `attaché`, `fräulein`, `chargé`, `rozená` …), 13 ARABIC (`أبو`, `الأستاذة` …), 6 BENGALI and 4 CYRILLIC (`урождённая`, `й`). So NFD Latin input matches now too, which no issue asked for — measured against `d37b8ec`: `NFD("Señor Juan Garcia")` read given `Señor`, middle `Juan` and now reads title `Señor`, given `Juan`; `NFD("Attaché John Smith")` moves the same way; `NFD("Maria Kowalska née Nowak")` went from middle `Kowalska née`, family `Nowak` to family `Kowalska`, maiden `Nowak`. RECOMPUTE: count the entries `w` of those twelve fields where `unicodedata.normalize("NFD", w) != w`, and key the count by the first letter's Unicode name. The argument outlives the digits and is the SHAPE of the division rather than its size: the fix was reached for on a hangul gap, and every other composable script in the vocabulary rode along with it, so a decomposed export from a macOS-origin system was missing entries in five scripts and not in the one corner the issue named. - **The stop set is script-agnostic at the lookup fold too, and the same unasked-for Latin reach exists for the three wider stops.** The edge strip in `_lexicon._normalize` takes any of the four `FULL_STOPS` off any word before a vocabulary lookup or a particle match, Latin and Cyrillic word forms included, so a Latin or Cyrillic word wearing the fullwidth, ideographic or halfwidth stop now reaches vocabulary and particle membership that only the ASCII period reached before — the same unasked-for reach the bullet above records for NFD, on a different axis of the fold. MEASURED 2026-09-10, against `d37b8ec`: `Dr。 John Smith` reads title `Dr。` where it read given `Dr。`; `Проф。 Иванов` reads title `Проф。` where it read given `Проф。`; `Beethoven, Ludwig van.` reads family `van. Beethoven` where it read family `Beethoven` alone. RECOMPUTE: parse the three strings on this tree and on `d37b8ec` and read `title` alongside the name fields. - **NFKC was DECLINED, not deferred, and the proposal it declines is #322's own.** The issue asked for "an NFKC fold for vocabulary lookup only" on the argument that it "would address all three" of its mechanisms — one change with one risk profile instead of three patches. Measured, it addresses two and a half: `unicodedata.normalize("NFKC", ".")` is `"."` and `NFKC("。")` is `"。"`, but `NFKC("。")` is `"。"` — the ideographic stop is not a compatibility character and no normalization folds it to a period, so `씨。`, one of the three spellings the issue opens with, would still have missed the entry and the character would still have had to be listed. And NFKC at the storage fold would REWRITE stored vocabulary (compatibility folds change ligatures, widths and superscripts), which is the one thing `_normalize`'s `lower()`-not-`casefold()` decision already refused. Listing four characters answers the question NFKC only half answers, and answers it without touching what a caller authored. RECOMPUTE: `unicodedata.normalize("NFKC", c)` for each of the four. - **#323 turned out broader than it was filed.** The issue named the honorific peel. Making the classification fold read through an edge stop moved three readers of the `None` it used to return, and only one of them was the peel's neighbour test: the SURNAME SITE stepped past the family name onto the given name (`양. 지훈` cut 지훈 in half — given `양.`, middle `지`, family `훈` — and now reads given `지훈`, family `양.`), the ORDER RULE fell back to positional (`양 지훈.` lost family-first and now keeps it; `田中 太郎.` likewise), and the segmenter's neighbour precondition missed a writer-drawn boundary (`山田太郎 田中.` consulted a pluggable segmenter on `山田太郎` as if it stood alone, and is blocked now). The issue weighed two candidate fixes and chose neither: teaching the surname site to consult `is_suffix_strict` beside `effective_script`, or making the classification tolerant of a trailing period, which it called the broader one and expected to interact with #322. The broader one shipped, and the narrow one would have reached the filed name alone — `김.` is no suffix vocabulary, so `김. 민준` sits outside it entirely. - **The surname-site amendment was found by measurement during execution, not designed.** With the classification fold alone, `김. 민준` read given `김`, middle `.`, family `민준`: the token classified as hangul, the site matched `김` against its own head, and what was left over — the stop — BECAME the remainder. So the site matches on `text.rstrip(FULL_STOPS)` and cuts the core, a head being a prefix, which makes the offset that cuts the core cut the text; the stop rides with the remainder (`김민준.` divides as `김` + `민준.`, `김. 민준` as family `김.` plus given `민준`). `rstrip`, not `strip`: a LEADING stop would break the prefix argument. The reading is recorded in `ebf64db`'s comment at the site. - **Two readings JOINED the bundle, approved in session on 2026-09-09.** Neither was filed. FIRST, the glued-period honorific (rules/W3): a stop on the honorific's own word stood between the listed tail and the token's end, so nothing peeled and `田中さん.` and `김민준씨.` read as titles. The peel now matches the tail on the core and cuts BEFORE it, so both divide where their stop-less spellings do and the stop rides with the honorific. The move was free because W3 had recorded that reading as measured on 2026-09-05 and pinned by nothing, its own words being that neither string is a case row or a corpus line, so no row pinned those two readings; both are case rows now, tolerated. SECOND, H2's opening-abbreviation shape on a CJK word: `田中.` read title and now reads family `田中.`, `田中. 太郎` reads family `田中.`, given `太郎`. Han is what WITNESSES this one, and hangul cannot: hangul segmentation runs first, so `김민준.` never reaches the shape test and divides as `김` + `민준.` by the surname site instead. Latin and Cyrillic are untouched (`Smith. John` still reads title `Smith.`, `Проф. Иванов` title `Проф.`), and the veto is contains-any, so `Kim김. Smith` is refused a title too — deliberately: a word carrying a script with no abbreviations is not wearing an abbreviation's period. - **Recorded limits, all five measured 2026-09-10 on this tree — and the segmenter one is a limit the review round CLOSED, kept in this bullet because this is where the claim stood. The halfwidth-kana one was CLOSED on 2026-10-03 by #594; see the CLOSED note that ends the halfwidth passage below, and decisions/W4.** HALFWIDTH KANA sat outside the script table, so it read by the LATIN shapes at both period sites (`is_initial("ラ.")` was `True`, and `タナカ. John` read title `タナカ.` where `タナカ. John` reads given `タナカ.`), recorded as a normalization question rather than a full-stop one; CLOSED 2026-10-03 by #594, which widened the table (decisions/W4 carries why not the fold) and pinned both readings (`ja_halfwidth_katakana_opener_with_a_period_is_a_name`, `ja_halfwidth_katakana_lone_name_with_a_period_is_not_a_title`, `test_is_initial_script_repertoire`). A PLUGGABLE SEGMENTER receives the CORE, the token with its trailing stops removed — the same string the vocabulary match reads, the surname site handing it `core` rather than `text` (corrected 2026-09-10, in the review round after this entry was first written: the site handed over the raw token, which this bullet recorded as a limit, and the fix costs nothing because a head is a prefix — every offset reported against the core cuts `text` in the same place, so the stops ride with the last piece and no answer of the segmenter's can make a stop a piece of its own). The shipped `ja` pack's `_wholly_japanese` therefore ACCEPTS `山田太郎.` now, where the raw token failed its repertoire test: measured 2026-09-10 with `namedivider-python` installed (`uv run --extra ja`), `parser_for(locales.JA, segmenter=locales.ja_segmenter())` reads `山田太郎.` as family 山田, given `太郎.` with a SEGMENTATION report scoring 0.44, where `d37b8ec` read title `山田太郎.` and the un-corrected branch read family `山田太郎.`; the already-divided `山田 太郎.` reads family 山田, given `太郎.` on this tree and given 山, middle 田, family `太郎.` at `d37b8ec`. Pinned by `test_a_consulted_segmenter_receives_the_core_without_the_stop`. A LEADING stop keeps the token out of the SURNAME site only, because the classification fold that gates it rstrips as it does: `.김민준` classifies as no script, never becomes a surname site and stays one whole word, reading given `.김민준` exactly as `d37b8ec` reads it (corrected 2026-09-10 in the same round: the fold stripped both edges when this entry was written, which moved the ROLE to family and — with a segmenter configured — offered the token to it raw, an answer of offset 1 dividing it into the stop and the name; the fold now matches the sites, and hiding such a token is the no-split rules/W1 already accepts). It does NOT keep the token out of the HONORIFIC PEEL, which consults no script at all — the tail alone is its license (rules/W2's Background) — so a leading stop does not defeat it: `.김민준씨` peels to `.김민준` and `씨` exactly as `김민준씨` does, pinned by `test_the_peel_reads_the_trailing_stop_only`. A BRACKETED CJK CREDENTIAL degrades: rules/S1's escape in `_extract` calls a clause suffix-shaped when it ends in an ASCII period, so `(김민준.) John Smith` is unwrapped and the H2 veto then leaves `김민준.` as name text the surname site divides — given 김, middle `민준. John`, family Smith, where 2.2.0 and `d37b8ec` read title `김민준.` — pinned as the tolerated `ko_name_with_a_period_in_a_bracketed_credential`. And a caller-authored NFD surname entry is composed at ingest, so raw NFD input then finds no entry and the name goes unsplit; pinned by `test_an_nfd_authored_entry_is_stored_composed`. - **Pickle compatibility, decided 2026-09-10 (Derek): a release-note line, not a forgiving load.** `Lexicon.__setstate__` re-runs `_normalize` over the loaded state and rejects any entry the fold changes, so a `Lexicon` pickled by 2.1.x or 2.2.x whose caller-added entries the widened fold now touches (a non-ASCII entry written with a wide stop such as `씨。`, or authored in NFD) raises `ValueError: incompatible Lexicon pickle: entries are not normalized (titles: 씨。); this state was not written by this version of nameparser` — a message that is wrong about provenance for exactly these pickles, which a released version did write. Measured 2026-09-10 (recompute: take `Lexicon.default().add(titles={"씨"}).__getstate__()`, add `씨。` to its `titles`, and load it into `Lexicon.__new__(Lexicon)`). The shipped vocabulary is unaffected (zero of 1735 strings change under NFC, and no shipped entry carries a wide stop). Two remedies were weighed: a release-note line with the fix a caller applies (rebuild the `Lexicon` from its source rather than unpickle it), or a load path that accepts state whose only drift is one fold pass away, re-folds it and warns naming the entries. The note was chosen: the load guard was designed to refuse rewritten data (the guarded-raise design #3-0-reevaluations records as right regardless of the in-a-minor friction) rather than become a fourth place caller data is corrected without a word, the population is caller-added CJK entries pickled across a minor, and a rebuild is the documented way to carry a `Lexicon` across versions. The 2.3.0 release note carries the line; `tests/v2/test_lexicon.py::test_unpickling_rejects_unnormalized_entries` is the pin of the refusal itself. - **CORRECTIONS this entry makes to entries above it.** #indic-honorifics' "Abbreviation marks and periods" bullet said `_normalize` "lowercases and strips edge whitespace and ASCII periods only — no NFC, no NFKC, no casefold anywhere in that module"; it now composes NFC for a non-ASCII word and strips all four stops. The VISARGA ARGUMENT still holds and holds for the same reason: ঃ U+0983 has no canonical decomposition, so NFC leaves it exactly where it was — recompute with that bullet's own one-liner, `Lexicon.default().add(titles={"মোঃ"}).titles & {"মোঃ"}`, still non-empty. #W1's 2026-07-29 §1a bullet said "segmentation MATCHING stays raw" and called the classification fold "the one deliberate exception"; matching is now folded too, so what stays raw is SEGMENTATION — the surname site's membership test and the peel's tail slice, which index the token's own text — and the folds are two, not one. #cjk-comma-demotion's 2026-09-05 five-parse bullet is amended in place: `田中さん 太郎.` no longer flips the order (the period is invisible to the classification, so it reads family `田中さん`, given `太郎.` as its no-period twin does), and `田中さん.` and `김민준씨.` no longer read as titles, so that bullet's summary — that the ONLY one of the five where a period changes nothing is the one where it sits on a word the split-off steps past — is superseded: the period changes nothing in four of the five now, and `田中さん 様.` is joined rather than alone. #initials-repertoire's principle bullet restated the veto phonologically ("morphemes or syllables"), which `_policy._NO_INITIALS`' own comment forbids in as many words — Devanagari is an abugida and Arabic an abjad, neither has letters in that sense and both abbreviate — so the bullet now states the CLDR criterion the constant actually uses. - **What moved, measured 2026-09-10 and re-measured the same day after the review round**, against `d37b8ec` over every `tools/differential/corpus*.jsonl` name (recipe, the shape detector included, in PR #522): SEVENTEEN names move, all of them the bundle's own new rows in `corpus_cjk_tolerated.jsonl`; of the 22 corpus names of the bundle's shape (an edge full stop glued to a word carrying a classified character) the five that predate the bundle do not move, nor do the 20 pre-bundle names carrying an ASCII period anywhere in a CJK-bearing name; the leading-stop `.김민준씨.` moves too (given `.김민준`, suffix `씨.`) but sits in no corpus, pinned only by the stage tests for its two edges. `田中. 太郎` stays there with the rest, though it is what WITNESSES rules/H2's Accepted clause added here: `tools/differential/compare.py`'s tier comment records the 2026-09-05 precedent for exactly this collision — where a demoted file holds a text a rules.md example line names, the EXAMPLE LINE moves into the tolerated rule and the row is not promoted, because marking the row alone leaves the name enforced and documented as demoted. So the clause's CJK reading is carried in W3's tolerated example block, H2's own block keeps the Latin control `Smith. John`, and all four #323 shape rows stay `tolerated`. - **The invariant the rules imply, run over two inputs (AGENTS.md axis 11), measured 2026-09-10** (PR #522): a CJK name written with an edge full stop must divide as its stop-less spelling does, and 30 of the 34 corpus names carrying a classified character and an edge stop agree; of the four that do not, `毛 泽东 Dr.` and `田中さん V.` are LATIN words wearing a Latin period, which the veto is scoped not to touch, `(김민준.) John Smith` is the bracketed degradation the limits bullet records, and `이, J.씨.` is the comparison's own artifact, a strip over field values not telling a word-final stop from the interior one of `J.`. - **Parse cost, measured 2026-09-10 on py3.11** against `d37b8ec` (`tools/perf/call_count.py`; PR #522): the reference name is unchanged (`parse=409.00 facade=446.00`, `Dr.` being listed and taken before the shape test), an H2-shaped opener pays three to five frames for the veto (`Xyz. John Smith` facade 304 → 308), and `김민준씨.` goes facade 209 → 397 because the peel now runs a whole segmentation path where the parse used to bail to a title. derek73-python-nameparser-456b024/docs/design/decisions/comma-suffix-arc.md000066400000000000000000000163301527303271700265560ustar00rootroot00000000000000# comma-suffix-arc — #291/#296/#316 (2026-07-26 → 2026-08-01) #291 was filed 2026-07-26 out of the 2.0 vocabulary cleanup, with its decline-by-measurement evidence in the issue body; "z domu" folded into it 2026-07-27 (see Excluded, MAIDEN_MARKERS). Intent for #291 and #296 is settled by the approved bundle spec; #296 (with #325) SHIPPED 2026-08-23 as the bundle's first three commits, with one deviation from the audit table recorded at decisions/C8 ('md' keeps dual membership — bare 'Md' is Muhammad; reversed 2026-10-23, #853, decisions/indic-honorifics); #291 was UNSHIPPED then, carried as a deviates: marker on C1 (#291 closed 2026-08-24; the marker came off with #564 in 2.4). The arc's bookkeeping: #291/#296 moved milestone v2.1 → v2.2 on 2026-08-01 (with #289/#293); the 2026-07-30 design doc was amended in place 2026-08-01 (A1–A6); its durable content is HERE rather than in the gitignored doc; #316 (trailing titles) was filed the same day, carrying the esq cleanup as a Related section. ## Standing - The multi-word suffix entries v1 shipped unmatchable were removed in 2.0 rather than split into single-word entries, which would cost `Smith, A.P.` its given initials and `John Leed` and `Mary Nicet` their family names, all three reading as before today — 2026-07-26 #291. - A multi-word credential is reached from its component words by the run predicate, so the shipped vocabulary carries no phrase entries and a caller adds a missing component word to a `Lexicon`: `John Smith, MD PhD` → suffix 'MD PhD', `John Smith, PSM I` → suffix 'PSM I'. `SUFFIX_PHRASES` and segment-level matching are declined, and amendment A6's glued-peel question with them, there being no phrase unit for the peel to step over — 2026-08-23 #291. - Multi-word vocabulary merges where the render tag is applied, not where the role is assigned, since the suffix view comma-joins suffix words not tagged `joined`: `John Smith, LEED AP` renders suffix 'LEED AP' — 2026-07-26 #291. - An unlisted abbreviation ending a name gets no structural fallback, the corpora being blind to it, and reads as a name word (`John Smith Xyz.` → family 'Xyz.'); written as two or more dotted chunks it is the exception, S9's shape class (`John Smith X.Y.Z.` → suffix 'X.Y.Z.') — 2026-07-26 #291. Not standing: plan amendment 1's repair, carried out by #296's first commit — 2026-08-01. Elsewhere: the audit table's dispositions as shipped, `md` kept a dual (until #853) and `ms` and `sa` kept out of the ambiguous set (`sa` until 2026-10-25, #857, when it left the vocabulary) — decisions/C8's 2026-08-23 #296 and 2026-08-23 #428 Standing lines, the second also holding the repair's condition, no name word; the trailing slot read by vocabulary, a period-marked title word there a title (`John Smith Prof.` → title 'Prof.') — decisions/H5's 2026-09-08 #316 entry; the dotted shape class — decisions/S9's 2026-09-14 (Derek) entry headed #516's DOTTED HALF IS A SWITCH. Open: none in this file. ## Log - 2026-07-26 #291/#296/#316 (its audit table's `ms` and `sa` joining the ambiguous set superseded by decisions/C8's 2026-08-23 (review of PR #428, four reviewers) entry (`sa` left the vocabulary instead on 2026-10-25, #857, decisions/C8); its trailing-abbreviation decline superseded for the dotted spelling by decisions/S9's 2026-09-14 (Derek) entry headed #516's DOTTED HALF IS A SWITCH) — THE COMMA-SUFFIX ARC, 2026-07-26 → 2026-08-01: the paragraph, the plan amendment and the Declined entries below. The approved 19-word TITLES∩suffix audit table (2026-07-30/08-01, intent for the unshipped bundle): jr, junior, phd, md, do and se DROP from TITLES (se: Structural Engineer is the PE/SE post-nominal pair, no prenominal convention — v1 residue); dr drops from SUFFIX_WORDS and sra from the suffix sets (v1 residue); sr, lt, cpl, cpt, cpo, csm, sgm, ra and vc KEEP dual membership (position decides); ms and sa keep dual membership AND join the ambiguous set (the periods gate). Derek's framing thesis for the whole table: v1's sets encoded where the v1 PARSER needed words to be, not where words can occur. Amendment A6: once SUFFIX_PHRASES ships, the glued peel's token-level post-nominal test cannot step over a phrase segment ("김민준씨, LEED AP" will not peel) — #291 owns deciding that, with a case row either way. - 2026-08-01 (plan amendment 1, Derek's call: "ship + repair the merge") (its condition superseded by decisions/C8's 2026-08-23 #428 entry, which reads no name word where this says nothing but titles) — dropping dr/ms from the suffix vocabulary flips segment() to FAMILY_COMMA and degrades "John Smith, Dr." to family="John Smith" (mechanisms.md#VOCABULARY-FEEDS-STRUCTURE is this shape); the approved repair, ordered FIRST in the bundle so no commit introduces a regression a later one heals: assign segment 0 positionally when segment 1 is nothing but titles. - 2026-08-23 #291 — #291 CLOSED WORKING AS DESIGNED: the `Amended 2026-08-23` paragraph under the first Declined entry below. Declined: - Splitting the dead multi-word suffix entries into single-word entries (2026-07-26, the evidence in #291's body) — measured: "Smith, A.P." has the suffix steal the given initials; "John Leed" and "Mary Nicet" lose family names; and the period-gate escape is equivalent to removal because nobody writes "L.E.E.D.". This decline is why the seven removable entries were REMOVED in 2.0 rather than split, and it is the missing history behind C1's #291 marker. Amended 2026-08-23 (#291 closed working-as-designed): the decline stands as measured — splitting into single words does cost `Smith, A.P.`, `John Leed` and `Mary Nicet` — but the inference drawn from it did not. "A multi-word *entry* is inert" was read as "a multi-word *credential* is unreachable", and that is false: the run predicate `is_wholly_suffix` reassembles adjacent suffix tokens, so `parse("John Smith, MD PhD").suffix` has been `'MD PhD'` since 1.4.0, and `psm i`/`psm ii` — two of the seven entries called dead — parse today from their component words. The third option neither the issue nor the spec considered is to leave the shipped vocabulary alone and let callers add the component words to a `Lexicon`, which is what was chosen. Also declined with it: `SUFFIX_PHRASES` and segment-level matching (unnecessary once the run predicate is measured), and amendment A6's glued-peel question (no phrase-matching unit, so `_is_post_nominal`'s token-level test stays correct by construction). - The trailing-abbreviation structural fallback — with a measurement LIMIT rather than a measurement: the differential corpora structurally cannot evidence it, because they hold only names someone wrote down, and an unrecognized abbreviation is by definition outside the vocabulary. A green run there proves nothing (the reusable harness fact is in mechanisms.md's field notes). - SUFFIX_PHRASES matching in assignment — measured cost: it renders suffix="LEED, AP", because the suffix view comma-joins suffix words unless they carry the stable "joined" tag, which grouping applies within a piece and — since #436/#437, 2026-09-06 — post_rules applies between two post-nominals the writer did not comma-separate. The general form: multi-word vocabulary must merge where the render tag is applied, not where the role is assigned. derek73-python-nameparser-456b024/docs/design/decisions/comparison-surface.md000066400000000000000000000040051527303271700272110ustar00rootroot00000000000000# comparison-surface — why value equality died, and what replaced it ## Standing - Case-insensitive equality, equality with plain strings and hashability cannot all hold, so `HumanName` carries no value equality (`HumanName('John Smith') == HumanName('John Smith')` and `HumanName('John Smith') == 'John Smith'` are both False); comparison is `matches()` and `comparison_key()`, one casefolded component per field, the maiden name included — 2026-07 #223. - A str operand to `HumanName.matches()` is reparsed with that name's own configuration, while a `HumanName` operand is compared as already parsed, so two names built under different configurations can disagree with their strings (with `dean` added to a private `Constants` only, `h.matches('Dean John Smith')` is True and `h.matches(HumanName('Dean John Smith'))` False) — 2026-07-06. Open: none in this file. ## Log - 2026-07 (1.3.0 arc, #223/#224; executed in the v2 core — measured: HumanName == HumanName is False, matches() and comparison_key() live, the facade cites #223) — the trilemma: case-insensitive equality, equality-with-plain-strings, and hashability are mutually inconsistent (a hash cannot equal both hash("John Smith") and hash("john smith")), so 1.2.0's design was unfixable in place, not merely disliked. Additional costs of the old surface: equality ran through str(self) and so depended on mutable string_format, and maiden was invisible to == (absent from the default format). - 2026-07-06 — the cross-constants asymmetry, pinned: a str operand is reparsed with self.C; a HumanName operand is compared as already parsed. Declined: - Component-based __eq__/__hash__ — workable, considered, rejected: it still privileged one equality semantic for a domain that has none, and kept the mutate-while-hashed hazard. - Lenient matching (initials-compatible, Bob/Robert) — declined WITHOUT an issue, deliberately: matches() is exact-components by design. Someone will propose could_match() within a year; this entry is the resolved-as-no they should find. derek73-python-nameparser-456b024/docs/design/decisions/deviates-registry.md000066400000000000000000000022371527303271700270700ustar00rootroot00000000000000# deviates-registry — packs stay pure data (option C) ## Standing - A locale pack declares its deviations from the defaults as a module-level `DEVIATES` callable, acceptance metadata kept off `Locale`, whose value equality and pickling a callable field would break; `test_registry_is_the_pack_contract` fails unless every registered pack ships `DEVIATES` and a rotator list, which is what lets mechanisms.md#LOCALE-PACKS-PURE-DATA promise packs no code paths of their own. A field on `Locale` is to be revisited only if a runtime consumer of the declaration, such as an `explain()`, appears, and none has — 2026-07-18. Open: none in this file. ## Log - 2026-07-18 (d4aaafa; the DEVIATES design note) — a `deviates` field ON Locale was rejected: callables break value equality and pickle-by-reference, and DEVIATES is acceptance metadata, not runtime behavior (revisit only if a runtime consumer like explain() appears). The REGISTRY became the contract instead: test_registry_is_the_pack_contract fails structurally unless every registered pack ships DEVIATES and its rotator list. This is why LOCALE-PACKS-PURE-DATA can promise "no code paths of their own" and mean it. derek73-python-nameparser-456b024/docs/design/decisions/differential-ledger.md000066400000000000000000000270231527303271700273120ustar00rootroot00000000000000# differential-ledger — the differential ledger's arcs The differential ledgers were the classified gate 2.0 shipped behind: `tools/differential/compare.py` ran every corpus name through a released wheel and through the tree, and every name that read differently had to be claimed by a rule in one `tools/differential/expected_since_.toml` ledger per baseline (a `name_regex`, the `fields` it may move, an issue and a `why`), or the run failed. They proved what they were built to prove, that 2.0 changed nothing against 1.4 except the fixes the release log lists. As a standing check they made every later behavior change classify itself once per ledger, and the guards that policed the ledgers (`tests/v2/test_ledger_guards.py`, the contest pins, the recorded and watched diff rosters) outgrew them. #664 retired them for a committed snapshot on 2026-10-13. Archived 2026-10-19 under rule 3 of the decisions folder's entry conventions (#759): eleven arcs of entries, about 33,000 words, were cut to the tombstones below, every one superseded by the retirement. The full text is in git — `git show fe85bb3d:docs/design/decisions/differential-ledger.md` — and in the PRs each tombstone names. The retirement entry is the one decision in force and stands in full; the passages other entries cite stand as one line each, and three Declined entries about surviving code stand as written. ## Standing - `tools/differential/snapshot.jsonl` records every corpus name's reading, one line per (name, order): the seven role fields, the ambiguity kinds, `initials()`, the two case-repair views since #826 (decisions/R4), and what `HumanName` reads differently, under `facade`; `tests/v2/test_snapshot.py` fails on drift and prints the moves grouped by changed field, and a change that moves readings re-records, so the PR diff of the snapshot is the list of names it moved. Nothing is classified, and a reviewer can approve a large diff without reading it, the grouping being the mitigation; the ledgers, their guards and the contract/radar tiers are gone, trimming them having been declined — 2026-10-13 #664, a committed snapshot. - `compare.py` stays, unclassified: it runs a released wheel against the tree behind the version tell, the tree-side path check and a stripped `PYTHONPATH`, prints the snapshot's grouped report and exits 0. It answers how a release tagged before the snapshot read the corpus names, and the 1.4 promise is a question asked of it (`--baseline 1.4.0`), not a gate; for a later release `snapshot.py --since ` is the cheaper answer — 2026-10-13 #664, `compare.py` stays. - The CLDR agreement ratchet stays beside the snapshot, answering agreement with a corpus's stated fields rather than stability (`tests/v2/test_cldr_agreement.py`) — 2026-10-13 #664, the CLDR agreement ratchet. - `Policy`'s fields are annotated with what they store, not widened to every input the constructor accepts, since every reader would see the union; a `.pyi` stub typing `__init__` wide and the attributes narrow is a 3.0 candidate (decisions/3-0-reevaluations) — 2026-07-24 #332. - The tree side of a comparison must import this checkout's source package, `is_relative_to( / "nameparser")` and not anywhere under the repository, whose `.venv/`, `build/` and `dist/` can hold a released wheel; `compare.py` asks `snapshot.check_source` for it — 2026-07-24 #332. - `DEFAULT_BASELINE` rotates at every release as a step of the AGENTS.md release checklist, not as an issue — 2026-07-24 #332. Open: none in this file. ## Log ### differential-ledger, the arcs before retirement (2026-07-24 to 2026-09-05) - 2026-07-24 to 2026-08-07, the tooling arc (#332, #333, #334) — harvested 2026-08-15 from the release-arc session; measurements are that session's, spot-checked at landing. The two-corpus design (corpus_issues.jsonl beside the v1-test-bank corpus, since v1 test banks are blind to anything 2.0 added), ledger fields named by `Role`, and the sentinel-set over-match check (mechanisms.md#SENTINEL-SET-OVER-MATCH-CHECK). SUPERSEDED by 2026-10-13 #664 — except three of its Declined entries, below, which are about code and process that survive the ledgers. - 2026-08, the dormancy arc (#328–#376) — measurements are that session's, the machinery verified present at landing. `[[never]]` exclusions sorted ahead of the rules, so an exclusion only ever removes a name; a rule that claims nothing is dormant and is refused. SUPERSEDED by 2026-10-13 #664 — - 2026-08-27, the fields-only arc (#451) — `validate_rules` refuses a rule with `fields` and no `name_regex`, the one such rule having drifted from four names to seventeen with every guard green. SUPERSEDED by 2026-10-13 #664 — - 2026-09-01, the corpus-tier arc (#468/#469) — every corpus carries a tier, and only the contract tier can fail the run. SUPERSEDED by 2026-10-13 #664 — - 2026-09-01, the initials view (#484/#462) — `_initials` is a pseudo-field on both surfaces that enters a name's diff only when the seven roles and `_ambiguities` agree, so `initials()` is the one render view the gate compares and `capitalized()` stays invisible to it; it found #462 and the 2.0.0 per-word initials change, classified late in the 2.3.0 log. SUPERSEDED by 2026-10-13 #664 — the snapshot records `initials()` for every name. The 2.3.0 release log's per-word initials bullet points here; its evidence (the three clusters, 27 / 14 / 98 names, and the RECOMPUTE) is in the full text at fe85bb3d. - 2026-09-02, the rule-order arc (#382) — an order-decided contest between two rules must be declared (`precedes_narrower`), narrow-first being the declaration-free default. SUPERSEDED by 2026-10-13 #664 — - 2026-09-03, the recorded-shapes arc (#497) — the recorded diff shapes live in `compare._RECORDED_DIFFS`, where a run verifies them, and `_CROSS_RULE_WINNERS` keeps only the winner. SUPERSEDED by 2026-10-13 #664 — - 2026-09-05, the watched-shapes arc (#501) — a shape may be recorded without a winner, in a second roster (`compare._WATCHED_DIFFS`). SUPERSEDED by 2026-10-13 #664 — - 2026-09-05, the #501 adjudication — a contest row is owed wherever its boundary is argued, the ledger's own comments included. SUPERSEDED by 2026-10-13 #664 — - 2026-09-05, the #498 completeness check — the declared-contest check extends from nested `fields` to intersecting and equal ones. SUPERSEDED by 2026-10-13 #664 — - 2026-09-05, a pin names one rule (#508) — a `_CROSS_RULE_WINNERS` pin resolves to exactly one rule's issue and is compared by equality. SUPERSEDED by 2026-10-13 #664 — Cited elsewhere, kept as one line each: - The fields-only arc declined extending its rule to `'Donald mc'` (2026-08-27): `mc` is both particle and suffix vocabulary, so the comma-less name loses its family to `suffix` under rules/S2's Accepted clause, and P6's Accepted clause, which claimed the comma-less shapes keep their positional reading, was a rules defect, fixed 2026-09-07 under #454 (decisions/suffix-acronym-collisions). - The initials view added `initials()` alone, deliberately (2026-09-01 #484); `family_base` was considered as a second derived view and not added. Declined (the tooling arc's, harvested as above, kept because they are not about the ledgers): - Policy annotations widened to input unions (2026-08-05 #334) — five documented spellings fail mypy and every one has a type-clean equivalent; widening would make every READER see a union, and reading is the commoner operation. A .pyi stub typing __init__ wide and attributes narrow was deferred as a 3.0 candidate (needs a parallel signature list with its own sync test). - `_check_tree` as is_relative_to(REPO_ROOT) (2026-08-05 #332) — accepts .venv/, build/ and dist/ copies inside the repo; the invariant is "is the source package", so the predicate is is_relative_to(REPO_ROOT / "nameparser"). - An issue for rotating DEFAULT_BASELINE (2026-08-07) — it recurs every release; it lives in the AGENTS.md release checklist beside the VERSION bump instead (#333 must land first). ### differential-ledger, retired for a snapshot (2026-10-13, #664) The ledgers' last arc. They were built to show that 2.0 changed nothing against 1.4 except fixes the release log lists, and they did; as a standing check every later behavior change had to be classified once per ledger, and the guards policing the ledgers outgrew the ledgers. Decisions that landed: - 2026-10-13 #664 — **a committed snapshot replaces the classified gate.** `tools/differential/snapshot.jsonl` records every corpus name's reading (the seven role fields, ambiguity kinds, `initials()`, and the facade where it differs), one line per (name, order); `tests/v2/test_snapshot.py` fails on drift and prints the moves grouped by changed field. A change that moves readings re-records, and the PR diff of the snapshot is the list of names it moved. Nothing is classified: what a reviewer reads in the diff is what a ledger rule used to say in a regex. The six `expected_since_*.toml` ledgers and `tests/v2/test_ledger_guards.py` are deleted, and with them the contract/radar tiers, so the CJK corpus is one file again and the rules corpus harvests tolerated rules' examples too. - 2026-10-13 #664 — **`compare.py` stays, unclassified.** It keeps the released-wheel runner and its checks against comparing one tree with itself (the version tell, the tree-side path check, the stripped `PYTHONPATH`; mechanisms.md#VERSION-TELL, #GENERATED-SCRIPT-OUTSIDE-THE-WORKTREE), prints the same grouped report as the snapshot and exits 0. It answers "how did release X read these names" for a release tagged before the snapshot existed; after that, `snapshot.py --since ` is the cheaper answer. - 2026-10-13 #664 — **the CLDR agreement ratchet stays** (#654). It answers a different question, agreement with CLDR's stated fields rather than stability. Measured 2026-10-12, before the change, over the 40 first-parent commits ending at 04cc0814: 5,236 of 25,226 changed lines (21%) were ledger, `compare.py` or harness-test edits, and 21 of the 40 commits touched them. The share is uneven rather than steady: across the 29 of those commits that touch `nameparser/` it runs from 0% to 82%, median 11%, above 30% on 11 of them (#655: 236 of 448). Run against the code before and after #655, the snapshot reports five moved names, against that PR's 236 lines of ledger and guard edits; merging master afterwards, it reported #663's one new corpus name where #663 had added the same ten lines to each of six ledgers. RECOMPUTE (shares): `git log --first-parent -40 --format=%h 04cc0814`, and for each commit sum `git show --numstat` over every path except `*.jsonl` and `cldr_agreement.json`, the harness share over paths matching `expected_since|compare\.py|ledger_guards|test_differential|_differential_fixtures`; a commit "touches `nameparser/`" when any path starts with it. Accepted costs: - No forced explanation per moved name. A reviewer can approve a large snapshot diff without reading it; the grouping by changed fields is the mitigation. - No bound on what a change may reach. `_CORPUS_CLAIMS`, the dormancy checks and the contest pins existed only to police ledgers; the two mechanisms entries that described them (LEDGER-RULE-SEPARATION and CROSS-RULE-OUTCOME-PINS) are retired with them. - The 1.4 compatibility promise is a question asked when needed (`compare.py --baseline 1.4.0`), not a gate at every release. The facade is deleted in 3.0. Declined: - Trimming the ledgers rather than retiring them (2026-10-13). The radar tier (#468, 2026-09-01) was that attempt: it stopped new corpora from failing the run, but every contract name still needed a rule in every ledger, and the cost grew by one ledger per release. derek73-python-nameparser-456b024/docs/design/decisions/generational-words.md000066400000000000000000000370271527303271700272270ustar00rootroot00000000000000# generational-words — junior and senior in other languages (2026-10-14, #661) Closes #661. 31 spelled-out generational words join SUFFIX_WORDS, and one predicate moves with them (the run-start bullet below), so `Antonio Pavlović mlađi` reads family Pavlović, suffix mlađi, where 1.4.0 and 2.4.2 read middle Pavlović, family mlađi (checked against both wheels). The class is decided once — a word meaning junior/senior (or the Younger/the Elder) written spaced AFTER the name — and each word was then checked two ways. First, collisions: a suffix claim acts on the trailing word, so the risk is a borne name there, and `tools/name_usage.py` found none (US Census, SSA, Wikidata; nuorempi's one Wikidata bearer of each kind is noise, as the issue said). Second, usage, because the issue's list came partly from ilib's machine-translated test data: each word was found in the trailing spaced position in real titles on its own language's Wikipedia (2026-10-13, e.g. "Hans Holbein mlađi", "Плиний Младший", "Pieter Brueghel vanhempi", "बलबीर सिंह सीनियर"). The words carry `issue = 661` in their language files (`uv run python tools/vocab/query.py --issue 661`, decisions/vocabulary-model). Feminine forms ship beside the masculine wherever the language has them (mlađa, младшая, молодша, jaunesnioji); lt vyresnioji is the grammatical feminine with no titled bearer found, and ships on grammar. ru/uk and cs/sk were not in the issue and were added on the same evidence (Derek, 2026-10-14). ## Standing - A word meaning junior or senior (the Younger, the Elder) and written spaced AFTER the name is `SUFFIX_WORDS` vocabulary, each word vetted two ways before it ships: no borne name in the trailing position (`tools/name_usage.py`), and attested in that position in real titles on its own language's Wikipedia, except `vyresnioji`, the lt grammatical feminine with no titled bearer found, which ships on grammar; feminine forms ship beside the masculine wherever the language has them — 2026-10-14 #661. - The words carry `issue = 661` or `issue = 679` in their language files (`uv run python tools/vocab/query.py --issue 661`, `--issue 679`), and their spaced readings are live: `Pieter Brueghel vanhempi` reads suffix 'vanhempi', `Antonio Pavlović mlađi` family 'Pavlović', suffix 'mlađi' — 2026-10-14 #661; 2026-10-14 #679. - CLDR is corroboration and not a source: its `generation` field holds field-label translations, adjectives written before the name and abbreviations nobody writes after one, so a word is added on its own language's Wikipedia and the collision counts, never because CLDR has it — 2026-10-14 #661; 2026-10-14 #679. - A refused word carries `excluded = { generation = … }` with its reason in the file of the language whose reading is refused (`uv run python tools/vocab/query.py --excluded generation`) — 2026-10-14 #661; 2026-10-14 #679. - The words ship spaced only, `二世` excepted, so a hyphen-glued Jr. is no suffix: `Теодор Рузвелт-младши` reads family 'Рузвелт-младши' — 2026-10-14 #661. - `二世` is a generation word that peels spaced and glued, the one exception to spaced-only shipping: `陳怡君二世` reads family '陳怡君', suffix '二世', and its `ja.toml` and `zh.toml` entries carry `issue = 671` and `attached` with `issue = 707` — 2026-10-21 #762. - The generational words whose collisions are with OTHER languages written in the same script (fr `père`; the Latin abbreviations `ml.` and `st.`) ship in no default, that being the case a locale pack exists for: `Jiří Dienstbier ml.` reads family 'ml.', and no cs pack exists; cs `st.` is recorded as a reading off by default, for that pack — 2026-10-14 #679; 2026-10-25 #752. - fr `fils` is a generation reading gated by its family reading, its case and the count (rules/S11): `Alexandre Dumas fils` reads suffix 'fils' and `Marie Fils` family 'Fils', both reported — 2026-10-25 #752. Elsewhere: the run start that lets any unambiguous suffix word of two or more letters begin the credential run, whatever its script (`Antonio Pavlović mlađi Jones` reads suffix 'mlađi Jones', as does `Pieter Bruegel stariji Jones` with 'stariji Jones'), is decisions/S8's 2026-10-14 (Derek), #679 entry, which retracted this file's #602 run-start bullet; the glued peel of 二世 is decisions/W2's 2026-10-17 #707 entry. - pt `júnior`, the accented spelling of `junior`, ships beside it with `same_as = "jr"` and inherits its vetting and its accepted collisions rather than being re-vetted under the two tests above (Junior is a borne surname, 2,146 US Census bearers, where `senior` was refused for 3,864): `José Silva Júnior` reads family 'Silva', suffix 'Júnior', as `José Silva Junior` does — 2026-10-23 (Derek), #843. - The Brazilian agnomens `Filho`, `Neto`, `Netto`, `Sobrinho` and their feminines are generation readings of the `agnomen` kind, gated by the count alone (rules/S11): `Pedro Alves Filho` reads family 'Alves', suffix 'Filho', and `Ana Neto` keeps family 'Neto', both reported — 2026-10-25 #752. Open: #684 (locale packs for `Jiří Dienstbier ml.` and `st.`, the generational words that collide outside their language). The agnomens' abbreviations (#843) are decided in decisions/S11 (2026-10-27). ## Log - 2026-10-14 #661 (the run-start bullet superseded by #679, decisions/S8) — JUNIOR AND SENIOR IN OTHER LANGUAGES: the undated bullets below, through the measurement. - **CLDR is corroboration, not a source.** Its person-name samples write mlađi/млађи (hr, sr — on a woman's name too), младши/младша (bg), ifj. (hu) and जूनियर (hi), but its `generation` field also holds translations of the field LABEL rather than of the word: isizukulwane (zu), जेनेरेसन (ne), نسل (ps) and jẹnereṣọn (pcm) all mean "generation". A word is not added because CLDR has it. The wider roster (fils, yngri, الابن and the rest) is open at [#679](https://github.com/derek73/python-nameparser/issues/679), to be vetted word by word the same way. - **Excluded, each with its reason.** The refused words carry `excluded = { generation = … }` with the reason, in the file of the language whose reading is refused: `uv run python tools/vocab/query.py --excluded generation --issue 661` (kıdemli, sóisearach, sinsearach, ifj, id, млада, the CLDR label translation isizukulwane, and the abbreviations ml. and st. in the Latin-script languages and ст. in Russian; мл. shipped later, with #679). Óg and Mór, which Irish writes instead, are also inside given names (Seán Óg Ó hAilpín), so they are a separate decision and not taken here. - **Spaced only, and what that leaves.** bg, ru and uk write a modern Jr. hyphen-glued (Теодор Рузвелт-младши, Дональд Трамп-молодший), which no whole-token set reaches; for those three the gain is mainly the historical the Elder/the Younger names, written title case (Младший), which the lowercase fold matches. - **The #602 run start reads the letters' script, not ASCII (Derek chose the Latin-script test, 2026-10-14). SUPERSEDED the same day by #679 (decisions/S8): no script is refused now, so the split this bullet accepts is gone.** It had replaced `_pieces.starts_a_credential_run`'s `letters.isascii()`, which would have split `Pieter Bruegel stariji Jones` from `Antonio Pavlović mlađi Jones` by the `đ`, with a test that every letter's Unicode name is LATIN, accepting that Serbian then split by script. - **Measurement (2026-10-14).** Six corpus names move, all CLDR hr/sr renderings gaining suffix mlađi or млађи from a name field, and cldr_agreement records 14 changes, every one toward CLDR's fields; the run-start change moves no name the corpus held before (measured against the tree before PR #681; recipe in that PR). - **2026-10-14 #679 (its refusal of zh 二世 superseded by 2026-10-21 #762, below) — the rest of CLDR's roster, vetted word by word (Derek).** 76 more words join SUFFIX_WORDS (counted against master's set, none removed): the words carrying `issue = 679` (`uv run python tools/vocab/query.py --issue 679`), 30-odd languages. Vetting as above, run by three research passes over each language's Wikipedia (langlinks of the Jr./Sr./the Younger/the Elder articles, then the language's own search) and `tools/name_usage.py`. CLDR's `generation` field proved unreliable in about a third of languages, beyond the field-label translations: an adjective written BEFORE the name (ta இளைய, kn ಕಿರಿ, si බාල, az Kiçik, uz Kichik, tk kiçi, ba кесе, ky Кенже, kk кіші), an abbreviation nobody writes after a name (mr ज्यु., pa ਜੂ., kok धा.), a word that means "second" (chr ᏔᎵᏁ, hi द्वितीय, zh 二世 — regnal, and glued), or a word no Wikipedia uses where another is standard. Where the language's Wikipedia writes a different word after names, that word ships instead of CLDR's: lo ຈູເນຍ for ຮຸ່ນລູກ, ti ጁንየር (and am ጁኒየር) for ማንጁስ, or ଜୁନିଅର/ଜୁନିୟର୍ for ଜେଆର୍, ta இளையவர் and ஜூனியர், si ජූනියර්, mk помладиот for јуниор. EXCLUDED, each with its reason in the file of the language whose reading is refused: `uv run python tools/vocab/query.py --excluded generation --issue 679` (he סניור, borne as a surname, ja シニア beside it, English `senior`, the abbreviations, and the CLDR forms a given name or the initial shape collides with). Left out unnamed: the CLDR forms that are label translations or unattested (cv, chr, ak, blo, kok-Latin, kk-Arabic). PACK-SHAPED, NOT SHIPPED: fr fils/père and the Latin abbreviations ml./st. (cs, sk, sl, hr) are real and standard, but their collisions are with OTHER languages written in the same script — `Marie Fils` (767 US Census bearers of Fils) and `Jiří Dienstbier ml.` beside ML initials and St. as Saint — which is the case a locale pack exists for (the #604 `nic` precedent). No fr or cs pack exists; proposing them is open at [#684](https://github.com/derek73/python-nameparser/issues/684). The Cyrillic мл. does ship: ru, bg and sr all write it after names and nothing collides with it in Cyrillic, so a pack would protect nobody; ст. is barely attested after a name and stays out. Neither existing pack takes anything: tr and az put the junior word BEFORE the name or in a parenthesis, and Turkish baba collides inside Turkish. Measured 2026-10-14 against the tree before PR #685: 16 corpus names move, all CLDR renderings gaining their generation word as the suffix out of a name field, and cldr_agreement's default view records 4 suffix fields newly agreeing, no regression, and 40 still-wrong fields changing. The Burmese sample glues the Burmese comma ၊ (U+104A) to ဂျူနီယာ and is not reached, the tokenizer not splitting on it. - 2026-10-21 #762 (Derek) — 二世 IS A GENERATION WORD, SPACED AND GLUED. "yea i think 二世 is fine as a generational word that also peels." #679 left zh 二世 out of the roster as "regnal, and glued"; #671 (2026-10-17, decisions/W2) then added it to `SUFFIX_WORDS` as the generational numeral, spaced (a `generation` reading since #737), and #707 (2026-10-17, decisions/W2) let it peel glued, and the reading stands: the `ja.toml` and `zh.toml` entries carry `issue = 671` and `attached` with `issue = 707`. Found when the Standing block for this file was written (#803), where W2's "does not reverse" sentence about #679 contradicted the vocabulary. SUPERSEDES: 2026-10-14 #679's refusal of zh 二世 only; its other refusals stand. - **2026-10-23 #843 (Derek) — JÚNIOR SHIPS; THE AGNOMENS WAIT.** "Suffix, Júnior only for now." `júnior` joins `pt.toml` as a generation reading with `same_as = "jr"`: the standard Portuguese spelling, which `José Silva Júnior` read as family 'Júnior', middle 'Silva' in 1.4.0 and 2.5.0 (checked against both wheels) while `José Silva Junior` read suffix 'Junior'. Its collisions are `junior`'s, already accepted: a given name (Wikidata 37+ as Júnior, 43,182+ US SSA births as Junior), where the suffix claim does not act (`Júnior Silva` keeps given 'Júnior'), and family bearers (Wikidata 49+, US Census 2,146 as JUNIOR), which a suffix word costs its surname wherever it ends a name (`Pedro Júnior` reads given 'Pedro', suffix 'Júnior', as `Pedro Junior` does), counts from `tools/name_usage.py` on 2026-10-23. In the local corpus `tse-donors` (fetched 2026-10-22), 1,071 of its 854,531 records write it anywhere in the name (`tools/name_usage.py`), 991 distinct filed names end in it, and 983 records end in it, in either case, where the tax registry's name for the same donor ends `JUNIOR` (last words compared after `str.upper()`). The accented spelling reads as the unaccented one in all 120 pairs of a grid of 20 shapes (comma shapes, an empty head, a title, a particle, a trailing title, a nickname, a maiden clause, a credential and a name word behind it, both one-case spellings) under all three orders, composed and decomposed, comparing fields, ambiguity kinds and `initials()`; with `júnior` removed from the lexicon (`Lexicon.default().remove(suffix_words={'júnior'})`), 108 of the 120 pairs differ, and the 12 that do not hold Júnior where no suffix reading reaches it (`Júnior Silva`, and `Júnior, José`, before a family comma). Measured 2026-10-23 on `tse-donors` against the tree before this change (`snapshot.py --local tse-donors`, baseline recorded with `--tree`): 993 of 848,351 filed names move, 990 ending in Júnior and gaining it as the suffix out of the family name (3 of them two-word names, `RONALDO JÚNIOR`, left with no family as `RONALDO JUNIOR` is), and 3 with Júnior inside the name, where it starts a credential run (decisions/S8) that takes the surnames behind it, `JORGE ANTÔNIO JÚNIOR BARBOSA SILVA` reading family 'ANTÔNIO', suffix 'JÚNIOR BARBOSA SILVA' as the JUNIOR spelling already did; and `local_corpora.py pairs tse-donors --agreement` stops listing JÚNIOR / JUNIOR (866 accents-only pairs read with two surnames before, none after). The agnomens (distinct filed names ending in them: `Filho` 10,979, `Neto` 8,439, `Sobrinho` 955, `Netto` 432) stay family names for now. They are legally part of the surname, and `Neto` is a borne surname and given name alone (Wikidata 276+ family, 12+ given), which a suffix reading would leave with no family, as `Pedro Junior` has none. No exclusion records them, because nothing was refused: the question is open on #843. - **2026-10-25 #752 — THE AGNOMENS AND `fils` ARE GATED GENERATION READINGS (phase 2h).** SUPERSEDES: 2026-10-23 #843's agnomen half, which kept the agnomens family names, and 2026-10-14 #679's refusal of `fils` as pack-shaped. The 2026-10-23 #752 entry in decisions/vocabulary-model gave each generation reading a gate by its kind, and the record, with its measurements, is decisions/S11. `fils` collides inside French, a generation word and a French surname, so no French pack could settle it, which is the premise of #684 the design reversed: its `generation` reading now stands beside its `family` reading, and the gate reads the name around it. The agnomens are not part of the surname: an agnomen marks its bearer against a relative of the same name, as Júnior does, and its name readings (dated counts in `pt.toml` and `pt-BR.toml`, `netto` there being the spelling of Brazil's older orthography) keep a two-word name's family. cs `st.` keeps no exclusion: it is a `generation` reading written `default = false`, recorded for a Czech pack, since nothing in its writing tells it from Saint; hr, sk and sl keep theirs, as do `ml.` and `père`. derek73-python-nameparser-456b024/docs/design/decisions/given-name-titles.md000066400000000000000000000011131527303271700267360ustar00rootroot00000000000000# given-name-titles — deliberately unvalidated Declined (rc1 arc; the full argument is AGENTS.md's gotcha): - Validating given_name_titles against titles, twice: the whole-entry check rejected legitimate multi-word entries; the per-word check rejected "sir and dame" (a conjunction inside a title run is itself a TITLE token, so the key is matchable while its middle word lives in conjunctions). No static relation over the vocabulary sets decides reachability, and an unreachable entry is inert — each guard cost a working configuration to forbid a condition that costs nothing. derek73-python-nameparser-456b024/docs/design/decisions/indic-honorifics.md000066400000000000000000000542031527303271700266450ustar00rootroot00000000000000# indic-honorifics — the renunciate class and the Indic honorific vocabulary (2026-09-06, #346/#344/#343) Closes #346, #344 and #343 as one bundle: each CLASS is decided once, and each script's membership in it is then argued word by word, so the three lists overlap without matching. No parser code moves in it. Four existing forks carry the whole thing — the renunciate fold (rules/H1), the spaced trailing suffix path, title vocabulary beating the one-letter-plus-period initial shape, and the unvalidated given_name_titles — and all four were confirmed on the pre-bundle tree with a throwaway override before any wordlist was edited — the recipe is `Parser(lexicon=Lexicon.default().add(given_name_titles={...}, titles={...}, suffix_words={...})).parse(text)`, and the fourth fork is the one a reader will not expect: `Lexicon` deliberately does not validate `given_name_titles` against `titles` (see #given-name-titles), so an entry in the given-name set alone is never recognized as a title at all. In the shipped data that is free, TITLES being GIVEN_NAME_TITLES | {...}. ## Standing - The renunciate class — given-name titles for a tradition that drops the surname — is decided once, by how the title ADDRESSES and by surname retention, and each script's membership is argued word by word on those two criteria, never on symmetry between the lists: `Swami Vivekananda` reads title 'Swami', given 'Vivekananda', and `Lama Zopa` folds where `लामा जोपा` and `লামা জোপা` do not — 2026-09-06 #346. - Native-script renunciate titles with no Latin twin ship alone: `संत कबीर` folds while `Sant Kabir` does not, Latin `sant` being a Punjabi given name and an `excluded` entry in hi.toml — 2026-09-06 #346. - `rabbi` and `imam` stay out of the class, addressing by title and keeping surnames (`Rabbi Cohen` reads family 'Cohen'), and `venerable` stays a plain title, its traditions splitting on surname retention, so neither answer is right for the single-name case and it is deliberately not an `excluded` entry — 2026-09-06 #346. - The class's accepted cost is a real given name that is also a title: `Guru Dutt` reads title 'Guru', given 'Dutt' — 2026-09-06 #346. - `rinpoche`, जी, साहब, साहिब, साहेब, महाराज and সাহেব, বাবু, মহারাজ ship as suffix words and spaced only, none a glued honorific, on the evidence recorded for जी alone (`Mohan Sharma जी` reads suffix 'जी', `Mohan Sharmaजी` keeps family 'Sharmaजी'); the Tibetan -la particle needs no entry — 2026-09-06 #346. - Bengali বাবু is trailing and Devanagari बाबू leading, different codepoints that cannot interact (`অমল বাবু` reads suffix 'বাবু'), and a sweep harmonizing the two argues with their wordlist comments — 2026-09-06 #346. - মোঃ/মো and মোসাঃ/মোছাঃ are titles, the functional home since `given` must stay the name the person is addressed by (`মোঃ আবদুল করিম` reads given 'আবদুল'), and Latin `mst` is not in the default, its refusal an `excluded` entry in bn.toml — 2026-09-06 #346. - An abbreviation carrying a mark is spelled with it, and the Bengali ones ship as the visarga form and the bare stem while the Devanagari ones ship as the bare stem alone, the dotted form listed in neither script — 2026-09-06 #346. - Title vocabulary beats the one-letter-plus-period initial shape, measured and pinned by rows and not a rule: `ড. মুহাম্মদ ইউনূস` reads title 'ড.' while `র. কে. নারায়ণ`, with no entry behind it, keeps its initials — 2026-09-06 #346. - An unlisted abugida honorific stays a name word, and rules/H2's executable example of it is `প্রকৌশলী. Sen`, so whoever ships প্রকৌশলী moves the example first; the Tier 4 candidates in the Indic scripts (transliterated military ranks, Advocate, Engineer, Marhum, the kinship forms, मां/माता/মা, the hafiz/syed/shahid forms) are skipped pending a corpus check and not declined — 2026-09-06 #346. - Latin `md` is a credential and no title: a leading `Md` is the given name it abbreviates (`Md Abdul Karim` → given 'Md', middle 'Abdul'; `Mr Md Karim Uddin` → title 'Mr', given 'Md', middle 'Karim'), at two accepted costs, a particle behind it starting the family (`Md Abu Sayed` → family 'Abu Sayed') and the given part of a family comma it opens, titles in front or not, reading as credentials (`Uddin, Md Karim` and `Uddin, Mr Md Karim` → suffix 'Md Karim'); the period-marked `Md.` is still a title by rules/H2's leading-abbreviation shape (`Md. Abdul Karim` → title 'Md.'), and the Bengali মোঃ/মো entries stay titles — 2026-10-23 #853. - The period-marked `Md.` stays a title by rules/H2's leading-abbreviation shape, an accepted cost: `Md. Karim Uddin` reads title 'Md.', given 'Karim', and `Mr Md. Karim Uddin` title 'Mr Md.' — 2026-10-27 (Derek), #853. Elsewhere: the bundle's class-level refusals are recorded under decisions/vocabulary-exclusions, not here: the Latin forms of the Indic titles belonging to the opt-in packs of #345 (2026-09-06, #344), the native spelling shipping alone where a transliteration collides with real names (sri/shri, sant, pandit, mst), the ban on the spaced trailing set becoming glued honorifics, and the per-word title refusals (ঠাকুর/ठाकुर, कुमारी/কুমারী, बेगम/বেগম, শেখ/शेख, आचार्य, राजा/रानी), each an `excluded` entry (`uv run python tools/vocab/query.py --excluded title`). Open: #514 (whether the Tibetan prefix titles Tulku, Khenpo, Geshe, Kyabje and Jetsun fold like `Lama Zopa`); #345 (hi and bn locale packs for Latin-transliterated Indic honorifics). ## Log - 2026-09-06 #346/#344/#343 (its Latin `md` kept a title superseded by #853, below) — THE RENUNCIATE CLASS AND THE INDIC HONORIFIC VOCABULARY: the bullets below. - **The renunciate class (#346).** What is decided once is the CLASS — renunciate given-name titles — against rules.md's H Background, which already carried the two criteria: membership in the given-name-title list follows HOW THE TITLE ADDRESSES, and whether an empty family is correct output follows SURNAME RETENTION. Renunciation answers both at once, so "Swami Vivekananda" reads given Vivekananda, family "". Each script's MEMBERSHIP is its own list, and the three overlap rather than coincide: Latin swami/guru/baba/lama (moved out of the TITLES-only block), Devanagari स्वामी/गुरु/बाबा/संत, Bengali স্বামী/শ্রীল/গুরু/বাবা. lama is a Tibetan title whose native script is Tibetan, and the Hindi and Bengali borrowings लामा/লামা are rare enough that no entry was argued for either — "Lama Zopa" folds, "लामा जोपा" and "লামা জোপা" do not. संत (Sant) and শ্রীল (Srila) run the other way: renunciate titles native to those scripts with NO Latin entry, — Latin sant collides with a real given name (its `excluded` entry in nameparser/vocab/hi.toml) and srila has no transliteration in use — "संत कबीर" folds, "Sant Kabir" does not. A sweep "harmonizing" the three lists must therefore argue each word on the two criteria, never on symmetry. rabbi and imam address by TITLE rather than by given name, so they fail the first criterion and stay out; the second agrees, those traditions keeping surnames. "Rabbi Cohen" keeps family Cohen and is pinned as the control beside the Swami row. The four Latin entries were already TITLES vocabulary, so the promotion changes only which field the one name word lands in, and C-i's position test was not re-run for them; the known leading-position collision is Guru Dutt, whose given name is Guru — family went from Dutt to "" — accepted as the pre-existing cost of guru being a title at all, and pinned by the row renunciate_title_swallows_a_real_given_name. - **venerable is left in TITLES only, and is deliberately NOT an Excluded entry.** The traditions split on surname retention — Buddhist monastics drop the family name, Anglican archdeacons and Catholic candidates keep it — so neither answer is right for the single-name case. TITLES has no ambiguous subset and no AmbiguityKind, so, as for king in #v1-xfail-triage and roz in the maiden markers, the only two expressions available are ship and do not ship; this is the third thing, a membership that is right and an OUTPUT that is undecidable, and it stays where it is. - **Rinpoche is postpositional** ("Sogyal Rinpoche", "Lama Zopa Rinpoche"), so it joins SUFFIX_WORDS and no title set. The Tibetan -la particle is vocative and glued and needs no entry. The Tibetan PREFIX titles (Tulku, Khenpo, Geshe, Kyabje, Jetsun) are out of this bundle — each re-asks the surname-retention question for a different tradition — and are deferred to a follow-up issue (#514). - **The trailing set ships spaced only.** rinpoche, जी/साहब/साहिब/साहेब/महाराज and সাহেব/বাবু/মহারাজ are SUFFIX_WORDS entries and none is a honorific_tails entry. The standing prohibition (now in decisions/vocabulary-exclusions; corrected 2026-10-21, #762, from "below", since #713 split the file) is what generalizes: it was recorded for जी alone and now covers the set, on the same evidence and with the same accepted cost. - **Bengali বাবু is trailing where Devanagari बाबू is leading** ("অমল বাবু" against a leading बाबू). Different codepoints, so the two cannot interact; the divergence is stated in both wordlist comments so that a sweep "harmonizing" them has to argue with the data module rather than discover the split afterwards. - **The Latin md history, corrected.** md entered TITLES in 8cd3123 (2015-09-05, #32) as the medical degree written before the name, alongside do, which #296's audit later took out (#vocabulary-collisions). The Muhammad reading is a 2026 reinterpretation, recorded in the case row audit_md_leading_stays_a_title (renamed audit_md_leading_is_the_given_name by #853, which reverses its reading; pointer corrected 2026-10-23); both readings agree on the field, which is why the entry survived #296's audit as the one deviation from that audit's table. Recorded here so the record stops implying md was added for Muhammad. - **মোঃ/মো and মোসাঃ/মোছাঃ go into TITLES**, mirroring Latin md. Semantically they are religious name prefixes rather than titles. TITLES is the FUNCTIONAL home because `given` must stay the name the person is addressed by — মোঃ আবদুল করিম is called আবদুল করিম — and the parser has no name-prefix field. The bound-given-name set is the wrong home for the same kind of reason it is the right one for Arabic abdul: abdul is an inseparable half of one name, মোঃ is detachable. Latin Mst waits for the opt-in pack (#345); the most already in TITLES is the English "Most Rev." word and is unrelated. - **Abbreviation marks and periods.** An entry carrying an abbreviation mark is spelled with it: the Bengali visarga ঃ (U+0983) is a spacing combining mark (Unicode category Mc, not a letter and not punctuation), and `_lexicon._normalize` lowercases, composes NFC for a non-ASCII word and strips edge whitespace and the four full stops of `FULL_STOPS` — no NFKC, no casefold anywhere in that module — so the mark is simply never touched and মোঃ reaches the lexicon and matches intact (corrected 2026-09-10, #cjk-full-stops: the fold was ASCII periods only and no NFC when this was written; the visarga argument is unchanged, ঃ having no canonical decomposition, so NFC leaves it exactly where it was). Recompute with `Lexicon.default().add(titles={"মোঃ"}).titles & {"মোঃ"}`, which is non-empty iff the mark survived, and `unicodedata.category("ঃ")`, which is `Mc`. Period spellings (মো., মোসা., ডা., प्रो., पं.) match the bare stem through the same edge-period strip, so every BENGALI abbreviation carrying the mark ships as two entries, the visarga form and the bare stem (মোঃ and মো, মোসাঃ and মোসা, মোছাঃ and মোছা, মিঃ and মি), while the Devanagari abbreviations carry no such mark and ship as the bare stem alone (प्रो, पं, प्रा, डा); the dotted form is never listed in either script. - **Vocabulary beats the initial reading.** A one-letter-plus-period Bengali abbreviation is is_initial-shaped and the title entry wins: "ড. মুহাম্মদ ইউনূস" reads title ড., given মুহাম্মদ, family ইউনূস, while "র. কে. নারায়ণ" — real initials with no entry behind them — is unchanged. #343 verified the same fork at 2.1.0; it is re-verified here on the shipped set. This is MEASURED behavior pinned by rows, not a normative rule — no rule in rules.md states that title vocabulary outranks the one-letter-plus-period initial shape, rules/H2 speaking only to the bare-initial and the shape-versus-suffix cases — so a future shape veto must re-check "ড. মুহাম্মদ ইউনূস" before it lands. - **rules/H2's Accepted clause keeps its claim and loses its example.** The clause says an unlisted abugida honorific stays a name word, because the shape rule cannot see an unbroken run of letters through combining vowel signs; that is still true. Its example used প্রফেসর, which this bundle lists, so the example is now "প্রকৌশলী. Sen" → given="প্রকৌশলী." — Engineer, a loanword deliberately not shipped. The example is a contract-tier name in corpus_rules.jsonl, so the swap landed in the Bengali commit rather than in the records commit; leaving it would have exited the gate 1 there. - **Not recorded as excluded:** the Tier 4 candidates — transliterated military ranks, Advocate, Engineer, Marhum, and the kinship forms — are skipped pending a corpus check, not declined, and मां/माता/মা and the hafiz/syed/shahid forms are left alone the same way. An Excluded entry is a standing prohibition, and none of these has been argued to that standard. One of them carries a duty anyway: প্রকৌশলী (Engineer) is rules/H2's executable example of an unlisted abugida honorific, so whoever ships it must move the example to another unlisted word first, or the doc test and the contract-tier gate fail together. - **Measurement (2026-09-06).** Four corpus names move, all under the renunciate fold, all in one direction (the family empties and the given fills): Swami Vivekananda, Guru Nanak, Baba Ramdev and Lama Zopa, every one from the radar-tier corpus_issues.jsonl. Recompute by parsing every name in the tools/differential/corpus*.jsonl glob twice — once with the shipped lexicon, once with `Lexicon.default().remove(given_name_titles={"swami","guru","baba","lama"})` — and diffing the seven name fields. The multi-name spellings are the boundary and do not move: Swami Vivekananda Saraswati and Guru Gobind Singh keep families Saraswati and Singh, rules/H1's fold requiring exactly one name word. The Devanagari and Bengali additions move no corpus name at all, the corpora holding no name written in either script apart from rules.md's own H2 example, which this bundle rewrote. The gate's intentional counts rose by four at every baseline; read today's from the `corpus:` line of `uv run python tools/differential/compare.py --baseline X`. - 2026-10-23 (Derek), #853 — LATIN MD IS A CREDENTIAL AND NO TITLE: A LEADING MD IS THE GIVEN NAME IT ABBREVIATES. SUPERSEDES: the Latin md half of the 2026-09-06 #346/#344/#343 bundle (its Standing line and the "md history, corrected" bullet's conclusion that both readings agree on the field) decisions/C8's 2026-08-23 #296/#325 entry's `md` dual; and, in part, decisions/C13's 2026-10-04 (Derek), #603 entry, which kept `John Smith, MD Jones` and the `MD - PhD - FACS` delimiter rows at their 2.3 reading because `MD` was a dual, and a dual opens nothing. The bundle had kept `md` a title on the ground that `given` holds the name a person is addressed by, and `Md. Karim Uddin` is addressed as Karim; the filers who write these names put `Md` in the given name. The `psc-individuals` local corpus (UK people with significant control, the 1-in-8 sample of the 2026-10-22 snapshot, #849) holds 1,300 names whose forename box opens with `Md`: 1,246 typed it there alone (37 of them as `Md.`), most with the rest of the given name in the middle box, and 54 typed it with more of the given name; across the whole sample, 38 names typed `Md` as the title box. Scored against the boxes, the title reading put the forename box in `given` for none of the 1,300 and left `given` empty for 94; the credential reading puts it there for 1,207 and leaves 3 empty. A bound given name like `abdul` (given `Md Karim`) was the third reading measured and declined: it matched the forename box 152 times, and it would make `Md` bind to the next word where the spelled-out `Mohammad Karim Uddin` (given 'Mohammad', middle 'Karim') does not. With the title gone, `Md` reads as `Mohammad` reads in a name with no comma; after a family comma the two part (cost 2 below). `md` keeps its credential reading, so `John Smith MD` and `John Smith, MD` are unchanged, and its `excluded` academic entry in en.toml carries the reason. WHAT MOVES, measured 2026-10-23 on this tree against the same tree with `Lexicon.default().add(titles={"md"})` as the comparator (the title reading restored and nothing else), over every name holding the word `md` in the local corpora fetched that day (`tools/differential/local_corpora.py`; a fetch of another day moves the counts): psc-individuals 1,283 moved, 1,280 of them `Md` from title to given; ctgov-officials 417 moved, 379 from title to suffix, almost all the shape `John Smith, MD Jones`, whose `MD` the title reading had taken for the given part's title and whose whole name before the comma it had read as the family, plus 21 from title to given, 15 of them a Bangladeshi `Md`, two a degree written first (`MD John Smith`-shaped) and four no person's name at all; ukec-individuals 4, all to given; fec-individuals 10. 16 entries of the committed corpora move (`snapshot.corpus_entries()` over this tree's `tools/differential/corpus*.jsonl`, each entry under its own order, the seven name fields compared, the same comparator), 5 of them fictional CLDR renderings that write `MD` as a prefix title. ACCEPTED COSTS (Derek). (1) A particle behind `Md` starts the family: `Md Abu Sayed` reads given 'Md', family 'Abu Sayed', where the title reading left `Abu` the leading name word and read given 'Abu', family 'Sayed'. 18 of the 1,300 lose the family name the title reading gave them this way, each with `Abu`, `Al` or `Bin`, the filer's surname box holding the last word alone. It is the spelled-out name's reading (`Mohammad Abu Sayed` reads the same), so the gap is P2's and P5's for any given name before `abu`, not Md's. (2) The given part of a family comma that `Md` opens reads as a credential run (rules/C13), and so does one with only titles in front of it, the run starting behind titles alone: `Uddin, Md Karim` reads family 'Uddin', suffix 'Md Karim' with a `suffix-or-name` report, where the title reading gave given 'Karim', and `Uddin, Mr Md Karim` reads title 'Mr', suffix 'Md Karim', where it gave title 'Mr Md', given 'Karim'. 9 of the 10 FEC moves are this shape, FEC writing `FAMILY, GIVEN`. Marking `md` ambiguous (`may_be_name`) would fix it, measured: `Uddin, Md Karim` reads given 'Md', and every `John Smith MD` then reports `suffix-or-name`, so it was declined for the commonest credential there is. NOT CHANGED: the period-marked `Md.` is still a title by rules/H2's leading-abbreviation shape (37 of the 1,300 PSC names write it so), which is #853's question 3 and not this entry's; and the Bengali মোঃ/মো and মোসাঃ/মোছাঃ entries stay titles, no filed data reaching Bengali script. The case rows and rule examples that used `MD` as their title/suffix dual (rules/H3, H4, S7 and C13; decisions/H3, H4, H6, S7 and C9's Standing lines; rules/C9, C11, C12 and H6; the cases.py dual rows) are respelled with `RA`, which reads as `MD` did at 1.4.0, 2.3.0 and the parent; their dated log entries keep `MD` as written. RECIPE for the figures above. PSC: in `~/.cache/nameparser-tools/local/psc-individuals/raw.jsonl`, the records whose `forename` field, split on whitespace, opens with a word that is `md` once lowercased and stripped of edge periods (1,300); "alone" is a one-word forename, `Md.` one ending in a period; the title-box count is over every record, its `title` folded the same way; a given name matches the box where `given` and `forename` agree lowercased with whitespace runs collapsed; a family is lost where the comparator's `family` equals the `surname` field lowercased and the tree's does not. Local corpora moves: every record of each `corpus.jsonl` matching `(^|[\s,])md\.?([\s,]|$)` case-insensitively, a move being any difference in the seven name fields between the comparator and the tree; "title to suffix" (or "to given") is `md`, periods removed, a word of the comparator's `title` and of the tree's `suffix` (or `given`). - 2026-10-27 (Derek), #853 — THE PERIOD-MARKED MD. STAYS A TITLE, AN ACCEPTED COST: #853's question 3, left open by the 2026-10-23 entry above, decided. `Mr Md. Karim Uddin` reads title 'Mr Md.', given 'Karim', family 'Uddin', and `Md. Karim Uddin` title 'Md.', as rules/H2's shape reads any unlisted period-marked word opening the given part; `md` is not given the yield a given name written short has (`Lexicon.given_name_abbreviations`, which `Ma.` takes since #789). Derek: "Md. is an accepted cost: record it as decided and close #853". The evidence he weighed: 37 of the 1,300 UK PSC filers who open the forename box with `Md` write the period (the 2026-10-23 entry's recipe), and in nepal-candidates 221 of the 313 romanized names opening with `Md` write it so, all 221 reading it as a title (Derek's comment on #853, 2026-10-24). With it, and #752 phase 2j's given readings (decisions/H9), every question #853 asked is answered, and the issue closes with the `sheikh` batch. derek73-python-nameparser-456b024/docs/design/decisions/initials-repertoire.md000066400000000000000000000050321527303271700274040ustar00rootroot00000000000000# initials-repertoire — is_initial across scripts (#320) ## Standing - Whether a script has initials is decided from CLDR's locale-authored name patterns and not from the script's typology, so Han, Hangul, Hiragana and Katakana are the scripts with none (`ja` and `ko` author 29 and 32 patterns and use an initial in none, where `ru` uses one in 7 of 39), listed by enumeration in `_policy._NO_INITIALS` so that a new Script member forces a decision and never inherits an answer — 2026-08-02 #320. - A character of one of those scripts with a period is no initial, so `김민준 씨.` reads suffix '씨.' and `김민준, 씨.` family '김민준', suffix '씨.', while Cyrillic, Greek, Arabic and Hebrew initials stay initials (`Іван Й. Петренко` reads middle 'Й.') — 2026-08-02 #320. - `is_initial` is not narrowed to `[A-Za-z]`, a narrowing declined on the measurement that `Й.` would regress to the #267 Ukrainian-conjunction misreading and the `initial` tag would be stripped from every non-Latin initial — 2026-08-02 #320. Open: none in this file. ## Log - 2026-08-02 #320 — IS_INITIAL ACROSS SCRIPTS: the principle bullet below and the Declined line after it, dated by the commit that recorded the CLDR criterion (373846b8). - The principle: decided per script from CLDR's LOCALE-AUTHORED name patterns rather than from the script's typology — ja and ko author 29 and 32 patterns and use an initial in none of them, while ru uses one in 7 of 39 (`_policy._NO_INITIALS`' comment carries the counting recipe and the trap: `initialPattern` alone reports a period convention for locales that have none). Corrected 2026-09-10, #cjk-full-stops: this read "alphabetic-vs-CJK, not Latin-vs-non-Latin — Han, kana and hangul characters are morphemes or syllables, so a single one cannot stand in for a name word", which is the phonological restatement that comment forbids in as many words — Devanagari is an abugida and Arabic an abjad, neither has letters in that sense and both abbreviate, so a rule keyed on typology would put them here and their initials are real. _NO_INITIALS is ENUMERATED per script rather than derived from the script ranges, so a new Script member forces a decision instead of silently deciding Thai has no initials. Declined: - Narrowing is_initial to [A-Za-z] — measured: "Й." regresses to the #267 Ukrainian-conjunction misreading (the exact regression 2.1.0's release log claims to prevent), and the stable "initial" tag silently strips from every Cyrillic, Greek, Arabic and Hebrew initial, invisibly to the fields. derek73-python-nameparser-456b024/docs/design/decisions/legacy-rule-numbers.md000066400000000000000000000010151527303271700272710ustar00rootroot00000000000000# legacy-rule-numbers — the old docstring numbering Before rules.md, the post_rules stage docstring numbered its rules locally, and historical issue comments (#359, #364, #365, #367) use those numbers. The mapping: rule 1 → H1, rule 1b → P1, rule 2 → O1, rule 3 → O2, rule 4 → O3. Older still: bare "#11"-style citations from v1 comments live in the GOOGLE CODE namespace, not GitHub — GC issue 11 is decisions/P3's provenance — so a v1-era number that doesn't fit its GitHub issue is probably a GC number. derek73-python-nameparser-456b024/docs/design/decisions/ma-do.md000066400000000000000000000132361527303271700244140ustar00rootroot00000000000000# ma-do — ambiguous acronyms by decision ## Standing - `ma` and `do` are members of the ambiguous acronym set (`suffix_acronyms_ambiguous`: acronym readings on a word with a `given` or `family` reading, decisions/vocabulary-model 2026-10-25, `uv run python tools/vocab/query.py --word ma`), kept there because both are common surnames; the set has grown past these two since (decisions/suffix-acronym-collisions), and the marking is still what lets `Jack Ma` read family 'Ma' with `suffix-or-name` reported, while `John Smith MA` reads suffix — 2026-07-17. - In a name written in more than one case, a member in capitals leans credential and is taken with nothing to spare (`Jack MA`, `Jack DO` → given 'Jack', suffix 'MA'/'DO'), where `Jack Ma` keeps family 'Ma'; a deliberate break from v1, which read `Jack MA` as family 'MA' — 2026-09-15 #289. - A member written with its periods, one after each letter, counts unambiguously (`Jack M.A.`, `Jack D.O.` → suffix, `given-or-family` reported when only the given name remains), and one trailing period is no such spelling (`Jack Ma.` → family 'Ma.') — 2026-07-17. - A parenthesized bare `(MA)` or `(DO)` reads as a nickname, no longer escaping to suffix as in 1.x, and reports `suffix-or-nickname` — 2026-07-17. - After a family comma `do` is spelling-dependent, being the one member of the set that is also particle vocabulary: `Doe, John DO` reads suffix `DO`, while `Doe, John Do`, `Doe, John do` and `DOE, JOHN DO` attach it to the family by P6 (`Doe, John Do` → family 'Do Doe', `particle-or-given` reported); `ma` is no particle, so `Smith, John Ma` keeps middle 'Ma' and `Smith, John MA` reads suffix — 2026-09-18 #531. Comma-less under `FAMILY_FIRST`, only the Title-case spelling attaches (`Doe John Do` → given 'John', family 'Do Doe', with `particle-or-given` besides `suffix-or-name`), while `Doe John do` and `DOE JOHN DO` read suffix, so the comma and the comma-less writing disagree for `do` (rules/P6, decisions/two-input-invariants). Elsewhere: the rest of the case reading — Title-case `Ma` and `Do` leaning surname, an all-lower member or a one-case name taking the positional count (`ANH DO` → family 'DO', `JOHN SMITH MA` → suffix) — is decisions/S6's 2026-09-14 #289 + #516 entry and decisions/S4's 2026-09-15 (spec review) entry, and two shapes stand outside the case reading altogether: a particle run in front takes `do` whatever its case (`Anh van DO` → family 'van DO', reporting nothing; rules/P2, decisions/S5's 2026-09-18 (Derek), #531 — CAPITALS DECIDE FOR `do` entry), and an unambiguous credential in front of a member speaks for it (`John Smith PhD Ma` → suffix 'PhD Ma'; decisions/S7's 2026-09-27 (Derek), #544 — A CREDENTIAL RUN KEEPS ITS AMBIGUOUS MEMBERS entry); the count that goes first at the first slot after a comma, a suffix comma included (`Smith, MA` → suffix, `Smith, Ma` → given, `John Smith, Ma` → suffix), is decisions/S6's 2026-09-14 (Derek) and 2026-10-20 (Derek), #761 entries; the pairing that gives `do` its capitals-only credential reading after a family comma, and its accepted cost, is decisions/S5's 2026-09-18 (Derek), #531 — CAPITALS DECIDE FOR `do` entry; the escape that reads a bracketed member as a nickname is decisions/suffix-acronym-collisions' S1 line; the periods gate is rules/S3's since 2026-10-19; whether a word earns the ambiguous marking is decisions/vocabulary-collisions' C-i and decisions/suffix-acronym-collisions', with `do`'s departure from TITLES recorded in the former and further credentials marked ambiguous in the latter (2026-10-21 #796). Elsewhere: after the name, a member whose writing settles it is the suffix, not the nickname (`John Smith (MA)`, `John Smith (M.A)`; `JOHN SMITH (MA)` and `John Smith (Ma)` keep the nickname) — decisions/S13's 2026-10-26 #810 entry, which supersedes the line above in part. Open: #792 (S1 and S8: whether a bracketed generation ends the credential run, `W. Russell (Jr.) Withers`), the neighbour of the parenthesized line above, which it does not move: `John Smith (MA) Withers` reads nickname 'MA', family 'Withers'. ## Log - 2026-07-17 (M12, Derek-approved) (its `Jack Ma` sentence superseded in part by #289 below, for the all-caps spelling; its parenthesized side effect superseded in part by decisions/S13's 2026-10-26 #810 entry, for a member after the name whose writing settles it) — ma and do joined the ambiguous acronym set because both are common surnames; the two-word "Jack Ma" is kept intact by S2's words-to-spare guard, while the periods gate governs the dotted spellings ("M.A." counts unambiguously). Documented side effect: parenthesized bare "(MA)"/"(DO)" no longer escape to suffix as in 1.x. - 2026-09-15 #289 — AMENDS the sentence above for one spelling and leaves it standing for the other. "The two-word 'Jack Ma' is kept intact by S2's words-to-spare guard" is still true of `Jack Ma`, and false of `Jack MA`: an all-caps member of the ambiguous set inside a mixed-case name is now read as a credential with nothing to spare, so `Jack MA` gives given 'Jack', suffix 'MA' — v1's reading was last 'MA', so this is a deliberate parity break and not a restoration. The periods gate is untouched and still governs the dotted spellings. The two ledger comments that quote this sentence carry the same amendment. See decisions/S6. - 2026-09-18 #531 — AND `do` IS NOW SPELLING-DEPENDENT AFTER A FAMILY COMMA, which neither sentence above anticipated: it is the one member of this set that is also particle vocabulary, so `Doe, John DO` reads suffix `DO` (capitals, mixed-case name) while every other spelling attaches to the family under rules/P6. The pairing and its accepted cost are in decisions/S5's 2026-09-18 `do` bullet. derek73-python-nameparser-456b024/docs/design/decisions/nfd-shapes.md000066400000000000000000000201551527303271700254450ustar00rootroot00000000000000# nfd-shapes — shape tests read letters, and a decomposed letter is one letter ## Standing - A letter in the letter-shape regexes is `_vocab._LETTER` (a letter plus the general combining blocks), and the caps predicates compose a word not already NFC before `isalpha()` and count letters with `composed_len`, so a name typed decomposed reads as its composed twin and keeps the encoding written: decomposed `Dr. inž. Janez Novak` → title 'Dr. inž.', `Janez Novak, ŽŠČ` → suffix 'ŽŠČ', `Janez Novak, Ž.Š.` → given 'Ž.Š.', family 'Janez Novak' — 2026-10-12 #652. - The class is not widened to every script's marks (Indic vowel signs, Arabic and Hebrew points), rules/H2's Accepted clause recording that an abugida abbreviation never reads as a title by shape — 2026-10-12 #652. - The initial shape and the #320 script veto are asked of the composed spelling and the single-letter width tests count composed letters, the public v1 `initial` regex left as written: decomposed `É. y É. Smith` → given 'É.', middle 'y É.', `John й Smith` → given 'John', middle 'й', family 'Smith', `김. 민준` → family '김.', given '민준', and `김민준, 씨.` → suffix '씨.'; a connective with no precomposed form is two letters (`q́` added to `conjunctions` reads `John q́ Smith` as given 'John q́ Smith') — 2026-10-13 #665. - An unspaced decomposed hangul name is still not divided, so `김민준. 이서연` reads by encoding (decomposed: family '김민준.', given '이서연') — 2026-10-13 #665. Not standing: the KNOWN LIMITS of the undated bullet under 2026-10-12 #652 (the decomposed initial and the decomposed `й`), closed by 2026-10-13 #665. Open: none in this file. ## Log - 2026-10-12 (#652) — a shape regex spelled a letter `[^\W\d_]`, and `\w` does not match a combining mark, so a name typed in NFD (`inž.` as i, n, z, U+030C) ended its word at the z: `Dr. inž. Janez Novak` made `inž.` the given name, and `Janez Novak, ŽŠČ` reversed because `str.isalpha()` refuses the caps shape. Vocabulary was never affected (`_normalize` NFC-folds); only the shape path was. The bug is as old as 1.4.0, whose `period_abbreviation` had the same class, so this is a fix and not a restoration. DECIDED: a letter in the four letter-shape regexes is `_vocab._LETTER` (letter plus the general combining blocks), `config`'s `period_abbreviation` carries the same class, and the caps predicates NFC-compose a word that is not already NFC before asking `isalpha()`, and the two-letter pair test counts letters rather than code points (`composed_len`; the first version of the fix split `John Smith, MÁ` the other way, found in review). The tests as written (`str.isalpha()`, `unicodedata.is_normalized`) are C-level, so a word already composed pays no frame: `Smith, José` 193 and `Janez Novak, Žiga` 237 frames before and after, default and EVERYWHERE (measured 2026-10-12, py3.11). Output keeps the encoding written; only the reading is made encoding-independent. MEASURED MOVES the release log cannot see, against the released 2.4.0: NFD `Janez Novak, ŽŠČ` went from given `ŽŠČ` (reversed) to suffix `ŽŠČ`, and NFD `Janez Novak, Ž.Š.` went from suffix `Ž.Š.` to given `Ž.Š.`, family `Janez Novak` — the composed twin's reading, which is C5's (paired initials are a name), so the second moves AWAY from the suffix reading and toward parity. Neither is in a corpus. - DECLINED to widen the class to every script's marks: Indic vowel signs and Arabic and Hebrew points are marks too, but rules/H2's Accepted clause already records that an abugida abbreviation never reads as a title by shape, and a general `\p{M}` would change that without a measurement. KNOWN LIMITS, left open (#665): `_INITIAL` / `_render._INITIAL` / `config` `initial` (`\w\.`) are v1-verbatim and public, and an NFD single initial (`É.`) is not initial-shaped there. No role or `initials()` result differs for a lone initial (`Janez Ž. Novak`, 17 shapes × 3 orders), but one does beside a connective: NFC `É. y É. Smith` reads given `É.`, middle `y É.`, NFD reads given `É. y É.`. The same width problem makes the decomposed Cyrillic conjunction `й` (two code points) miss the single-letter tests at `_classify.py` (`len(token.text) == 1`) and `_pieces.py` (`len(text) == 1`): NFD `John й Smith` reads family `John й Smith`. Both predate #652 and read identically on 2.4.0 and master; neither is in a corpus. - 2026-10-13 #665 — DONE, closing both KNOWN LIMITS above. The initial shape is asked of the composed spelling: `_vocab.is_initial_shaped` NFC-composes a non-ASCII word before `_INITIAL`, as `_render` already did for its two copies (#542). Composing was chosen over widening the class because the pattern is the public v1 `initial` regex, pinned verbatim by `test_regex_sync`, and because it gives parity BY CONSTRUCTION — a decomposed word reads as the word it composes to, and a letter-plus-mark with no precomposed form has no composed twin to disagree with, so it is left as the pattern reads it — and likewise the width tests count composed code points, so a caller's one-letter connective with no precomposed form (`q́`, added to `conjunctions`) is two letters to them and joins as a longer connective does (`John q́ Smith` reads given `John q́ Smith`), as rules/P3's "composes to one character" states. The three single-letter width tests (`_classify`'s connective fork and its one-case report, `_pieces`' three-word carve-out) count letters: the classify pair asks `composed_len` only for non-ASCII text that the vocabulary membership has already admitted and the C-level `len(...) == 1` has declined, and `_pieces` composes in C before `isalpha()`. ASCII text pays no frame and the width tests cost composed text nothing (`Dr. Juan Q. Xavier de la Vega III` 366, `Smith, José` 194, `Juan y Eva Garcia` 254, `John й Smith` 255 composed, all equal before and after); the veto below costs one frame per non-ASCII word that reaches it, its matcher being called through a wrapper (`É. y É. Smith` 305 → 307, `Janez Ž. Novak` 237 → 238, `田中. 太郎` 207 → 208; measured 2026-10-13, py3.11, all composed). Kept rather than composing inline at its four call sites: a rare shape no guard counts, against four copies of the one step whose omission this entry is about (AGENTS.md's frame rule, question 4). Decomposed `John й Smith` reads given John, middle й, family Smith, where 2.3.0 through 2.4.1 read the whole name as the given name and reported given-or-family (the KNOWN LIMITS bullet above says family; measured on 2.4.0, 2.4.1 and f9b2b81c it was given, and this bullet corrects it) — a regression of 2.3.0, whose NFC lookup fold (#cjk-full-stops) let the decomposed й match the conjunction vocabulary while the width tests still counted two code points; 1.4.0 through 2.2.0 never matched it and read it as middle й, so this restores their reading. Decomposed `É. y É. Smith` reads given É., middle `y É.`, where every release from 1.4.0 read given `É. y É.`, so that half is a fix. Both checked on the 1.4.0, 2.0.0, 2.2.0, 2.3.0, 2.4.0 and 2.4.1 wheels (2026-10-13). The #320 veto beside the shape is asked of the composed spelling too (`_vocab.in_initialless_script`, ASCII answering before the matcher): decomposed hangul is conjoining jamo, which no `_NO_INITIALS` range holds, so composing the shape alone made a decomposed `씨.` an initial — `김민준, 씨.` read title `씨.` — which the first version of this fix did, caught by `test_suffix_lookup_reads_every_full_stop`. The same veto read on jamo had long let a decomposed hangul word with a period take the leading-abbreviation shape (#H2): `김. 민준` read title `김.` decomposed in every release from 1.4.0 (checked on the 1.4.0, 2.0.0, 2.2.0, 2.3.0 and 2.4.0 wheels), and reads family `김.`, given `민준` as composed now; rules/H2's veto clause states it and carries the decomposed example. An UNSPACED decomposed hangul name is still not split (docs/usage.rst's documented exception), so `김민준. 이서연` keeps differing by encoding. The guard is `tests/v2/test_parser.py`'s twin test, which now compares ambiguity kinds and `initials()` as well as roles; its docstring records which rows each of the four sites holds. derek73-python-nameparser-456b024/docs/design/decisions/normalization-fold.md000066400000000000000000000040371527303271700272260ustar00rootroot00000000000000# normalization-fold — lower(), not casefold(); comparison folds harder ## Standing - Vocabulary storage folds with `lower()`, not `casefold()`, keeping authored spellings at the pinned cost that `GROSSFÜRST Hans Müller` reads given 'GROSSFÜRST' where `Großfürst Hans Müller` reads title 'Großfürst'; comparison (`comparison_key()`, `matches()`) folds with `casefold()`, so `Hans Strauß` matches `Hans Strauss` — 2026-07-17/19. - The fold reaches a fixed point, and anything built on it converges: `_title_key` drops the words that fold away rather than keeping an empty slot — 2026-07. Elsewhere: the fold also composes a non-ASCII word to NFC and strips the CJK full stops since 2.3.0, NFKC declined, decisions/cjk-full-stops' 2026-09-10 #322/#323 line. Open: none in this file. ## Log - 2026-07-17/19 — vocabulary storage normalizes with lower(), NOT casefold(): casefold mutated stored spellings (κος→κοσ, großfürst→grossfürst) while lower() keeps authored forms; the accepted cost, pinned deliberately, is that ASCII-SS GROSSFÜRST no longer matches. The paired half: comparison_key()/matches() use casefold() ON PURPOSE — comparison is the one surface that wants aggressive folding; this is a release-log-classified 2.0 CHANGE from 1.4's lower() (ß and final-sigma forms compare equal only since the v2 core; v1.4 has no casefold anywhere). The 1.x line was symmetric BY DESIGN — lower() everywhere, chosen for symmetry with the parser's lc() convention — so the storage-vs-comparison split is a v2 decision moving away from a real, deliberate alternative, not a fix of an oversight. Do not "fix" it symmetric: symmetric is what 1.x was. - 2026-07 (rc1 arc) — the fold must reach a FIXED POINT, and anything built on it must converge too: _title_key joined per-word folds and kept empty slots, so given_name_titles with a foldable word stored a key match-time could never rebuild — silently inert, and pickle round-trips then rejected the state. Shipped in the rc, caught in review, fixed by dropping words that fold away. derek73-python-nameparser-456b024/docs/design/decisions/parse-cost.md000066400000000000000000000514071527303271700255010ustar00rootroot00000000000000# parse-cost — what a parse is allowed to cost The 2026-08-31 entry's numbers were measured that date, on py3.11 wherever no other interpreter stands beside them (this said "every number below is a py3.11 measurement of 2026-08-31" until 2026-10-22, when that entry and later ones had long quoted other interpreters and dates), recomputable with `uv run python tools/perf/call_count.py` (add `--modules`, `--stages`, or `--against `). That harness is in the tree BECAUSE of this entry: its first draft took figures across several sessions with throwaway scripts on whichever interpreter was to hand, and published a module table that spliced one interpreter's before-column onto another's after-column. Four of its eight module numbers were wrong, its four stage numbers were 2x (2000-parse totals labelled per 1000, which its own arithmetic contradicted — four stages summing past the whole parse), and it blamed the wrong PR. Quote nothing here without the interpreter beside it. ## Standing - The benchmark bounds the function calls a parse makes, not its seconds, as a band of ±2% around a row per interpreter (`_CALL_BASELINE` and `_BAND` in `tests/v2/test_benchmark.py`), so a drop trips it as a rise does, and an interpreter with no row fails with its own number; a 5s wall-clock backstop over 1000 parses at both entry points stays for what frame counts cannot see, a backtracking regex and C-level work — 2026-08-31 #475. - Raising or lowering a row is a decision, appended here with the interpreter and the harness invocation, every number coming from `tools/perf/call_count.py` and quoted with its interpreter — 2026-08-31 #475. - The rows stand at `parse` 307 and `facade` 344 on 3.11, 288 and 325 on 3.12 to 3.15, as the last move left them, and `call_count.py` reads 307/344 on 3.11 (2026-10-22) — 2026-10-10 #625. - The 64-link pin, `_LINK_BASELINE`, stands at 2,478 on 3.11 and 2,459 on 3.12 to 3.15 under the same band — 2026-10-07 #619. - Collapsing `post_rules`' three role-index scans into one pass, 6 calls on 3.11, was tried and reverted on a timing harness that could not see it, and is left to reconsider against the band; the three scans stand — 2026-08-31 #475. - The stages copy their state with `_state.copy_with`, a field copy, rather than `dataclasses.replace`, for `WorkToken`, `PendingAmbiguity` and `ParseState` only, and mypy sees it as `replace` imported under that name, keeping the dataclass plugin's checks at every call site — 2026-09-26 #546. - The field copy is guarded by a list of conditions a class must meet (`_state._copy_refusals`), a tripwire for a realistic edit to those three classes and not a proof against any class; `_UNGUARDED_EFFECT` records what each refused shape would build, and each condition is the only refusal of some row — 2026-09-29 #561. - Group asks its piece predicates by direct call, with no one-line closure between, and skips the rootname count and the shared connective loop where its tag walk finds no connective free to join — 2026-10-06 #617; 2026-10-07 #619. Not standing: #475's pointer to #424's peel walks as where to recover the cycle's growth, taken up by #614's single read of the trailing run — 2026-08-31 #475, 2026-10-07 #614. Open: none in this file. ## Log - 2026-08-31 #475 (its rows superseded by 2026-09-26 #546) — the benchmark bounds FUNCTION CALLS per parse, not seconds, as a per-interpreter BAND of ±2%. A loose 5s wall-clock backstop stays over BOTH entry points for what frame counts cannot see: a compiled regex that starts backtracking emits no `call` event, and neither does C-level work. (A quadratic inside a comprehension or generator IS visible — `call` fires once per generator resume.) WHY THE CLOCK HAD TO GO. The 1.0s bound failed four times across #466 and #474, always on CI's 3.12 job, always between 1.01s and 1.08s, with master re-running green each time. Three local methods disagreed with CI and with each other: uninstrumented ~1% branch-over-parent (inside the parent's own spread), coverage-instrumented indistinguishable, and the whole benchmark file under `--cov` made the branch FASTER. The same harness measured `origin/master` at 94-96ms and again at 89-92ms twenty minutes later. A check that cannot separate a 1% change from a busy runner does not fail safe; it fails expensively. WHY A BAND AND NOT A CEILING, which is the correction the review forced. A ceiling with headroom is ANOTHER threshold that happens to break — the failure this replaced. The first draft set 470 against a 408 baseline, and measured, that catches nothing smaller than +61 calls on 3.11 and +84 on 3.12: it would have missed the very regression it was built for on four of five interpreters. The cycle's +67 arrived across a dozen PRs at roughly five calls each, and no ceiling loose enough to be safe can see five. A ±2% band trips at +5, verified by injection. A DROP trips it too, which is intended: an unexplained fall is as much a signal as a rise. PER INTERPRETER, because the count is not machine-independent — 410 on 3.11, 388 on 3.12, 406 on 3.13-3.15 for `parse`. PEP 709 inlined the comprehension frames 3.11 counts and 3.13 added others back. The first draft claimed "the same number on any machine" and took its baseline on 3.11 by luck: had it been taken on 3.12, the 3.11 job would have gone red with nothing in the file explaining why. A version with no row fails loudly with its own number rather than passing unguarded. WHAT THE OLD BOUND HAD CAUGHT, which is the substantive half. Measured `--against v2.1.0`: `parse` 343 → 410 and `HumanName` 380 → 447, **+67 calls per parse**; by stage, per 1000 parses, `group` 13.5 → 22.2ms, `classify` 11.6 → 13.5, `assign` 11.1 → 13.0, `post_rules` 5.2 → 8.0. The bound was not too tight. The parser had grown into it, and the clock could not say so with enough confidence to act on. WHERE THE CALLS WENT, per parse, v2.1.0 → here: `_group.py` 94 → 84, `_pieces.py` 0 → 51, `_post_rules.py` 16 → 33, `_vocab.py` 26 → 32, `_assign.py` 21 → 18, `_classify.py` 14 → 16. AND WHICH PR SPENT THEM, which the first draft got backwards. It named #439's predicate extraction as "the largest contributor" and built a trade-off on it. Measured across its own merge boundary, #439 cost **zero**: 404 → 404, `_group` shedding 51 exactly as `_pieces` gained them. A pure relocation, and `mechanisms.md#ONE-PREDICATE-PER-QUESTION` says as much — the sharing between `group` and `assign` predated the module. The real largest single contributor is **#424 at +23** (381 → 404), the PR that made group's chain and walk mirror assign's; then #404 at +12, with #361, #367 and #434 at +8 each. So the entry's old conclusion — that recovering the cost means reintroducing the drift #439 removed — deterred work on a PR that spent nothing while +23 went unexamined. Recovering it, if ever wanted, starts at #424's peel walks. ONE REPAIR WAS TRIED AND REVERTED: collapsing `post_rules`' three separate role-index scans into one pass. It saves **6 calls per parse** on 3.11 (the first draft said one, which is not even reachable — `_idx` is a one-line list comprehension, so each removed call frees two frames). Reverted because it was measured against a noisy timing harness that showed nothing; at 6 calls it is 9% of the cycle's growth and worth reconsidering against the band, which can now see it. RAISING OR LOWERING A ROW IS A DECISION, not a maintenance chore. Append here with the interpreter and the harness invocation, as above. - 2026-09-26 #546 (its four conditions superseded by 2026-09-29 #561, below; its rows superseded by 2026-10-06 #617, and its link pin by 2026-10-04 #601, below) -- the stages copy their state with `_state.copy_with` instead of `dataclasses.replace`, and every row moves DOWN. `dataclasses.replace` walks `fields()` and calls `__init__` on each copy, three frames on 3.11 and 3.12 and four from 3.13 where `copy_with` is one. One parse of the reference name makes 18 copies: six of the state, one each from tokenize, segment, classify, group, assign and post_rules, and twelve of single tokens, six each from classify and assign (`extract_delimited` returns the state unchanged when there is no delimiter, and `script_segment` returns early on ASCII input). That is the whole of the drop, 18 x 2 = 36 frames on 3.11 and 3.12 and 18 x 3 = 54 from 3.13 (recompute: wrap `copy_with` with a counter in the eight stage modules and parse the reference name). A field copy builds what `replace` builds for a dataclass that is decorated itself rather than inheriting the decoration, keeps the generated `__init__`, and has no `__post_init__` and no `init=False` field. `_copyable_fields` checks exactly those four, and `_COPY_FIELDS` runs it over `WorkToken`, `PendingAmbiguity` and `ParseState` at import, so a class that stops qualifying fails there and `copy_with` copies nothing else. `test_the_guard_refuses_a_class_a_field_copy_would_get_wrong` records, for each refused shape, what `replace` builds and what an unguarded copy would build instead. To mypy, `copy_with` is `from dataclasses import replace as copy_with`, which keeps the dataclass plugin's keyword and type checks at every call site; an assignment (`copy_with = dataclasses.replace`) would not, since the plugin keys on the callee's full name (measured: a misspelled field and a wrong-typed value both pass through the assignment and both fail through the import). Measured 2026-09-26 with `uv run python tools/perf/call_count.py --against e0f1a2f`, each row on its own interpreter, parse/facade: 3.11 406/443 → 370/407, 3.12 384/421 → 348/385, 3.13, 3.14 and 3.15 402/439 → 348/385. The rows drop by 40 and 58 rather than 36 and 54 because e0f1a2f already read 4 under every row, inside the band, and the new rows are set to what the harness reads now. `_LINK_BASELINE`'s 64-link clause reads 2587 → 2301 on 3.11. By stage (`--stages`, py3.11, ms per 1000 parses of the reference name): group 19.6 → 17.3, classify 13.4 → 9.8, assign 11.1 → 8.0, tokenize 8.1 → 6.6, post_rules 7.9 → 6.5, segment 2.6 → 1.7. BEHAVIOR IDENTICAL: the differential gate's report at all five baselines matches e0f1a2f's line for line apart from the path header. - 2026-09-29 #561 -- SUPERSEDED IN PART: the 2026-09-26 bullet above, whose four conditions ("decorated itself", "keeps the generated `__init__`", no `__post_init__`, no `init=False` field) were not sufficient. `replace` builds through `obj.__class__(...)`, and a field copy skips every hook that call runs; four shapes met all four conditions and still parted: a validating `__new__`, a validating `__setattr__` on a non-frozen dataclass and a validating metaclass `__call__`, each giving `ValueError` from `replace` where an unguarded field copy built `value=-1`, and an `InitVar` with no default, which `replace` demands (a `ValueError` through 3.12, a `TypeError` from 3.13) and a field copy never sees. #561's first tightening (80554266) found only the first three, and by swapping the class's OWN `__dataclass_params__` for an inherited lookup it also let through an `__init__` borrowed from another dataclass under that dataclass's name, which the 2026-09-26 guard had refused; review caught both. So the guard is now a list of conditions a class must MEET (`_state._copy_refusals`, one reason string per unmet condition): decorated as a dataclass itself, frozen, an `__init__` generated for it (compiled from `` and named `.__init__`) that takes exactly its `init` fields, the default metaclass, no `__new__` above `object`, no `__post_init__`, no `init=False` field. It is a TRIPWIRE for a realistic edit to the three pipeline classes and says so, not a proof against any class: a forged `__qualname__` on a borrowed `__init__` in a class that is also decorated itself, or a `__class__` property, still passes, and `copy_with` copies nothing but `WorkToken`, `PendingAmbiguity` and `ParseState`, which meet every condition on 3.11 to 3.15. `_UNGUARDED_EFFECT` records for each row what `replace` builds, what an unguarded copy builds, and which conditions refuse it; `test_every_guard_condition_alone_refuses_a_recorded_shape` asserts that each condition is the only refusal of some row, so dropping one fails a test rather than contradicting this paragraph (checked by removing the `__new__` condition in a scratch copy: two failures). Call counts unchanged, the guard running at import only. - 2026-10-10 #624 (its rows superseded by 2026-10-10 #625) -- every row moves DOWN by 4: `parse` 308 → 304 on 3.11 and 289 → 285 on 3.12-3.15, `facade` 345 → 341 and 326 → 322, each measured on its own interpreter with `tools/perf/call_count.py` in isolated copies of 8e6e524a (#631's merge) and of the change, the import asserted (8e6e524a read exactly the recorded rows). `--modules` on 3.11: `_pieces.py` 51 → 49 and `_group.py` 25 → 23 -- group's leading-position scan, a generator asking `is_leading_title` per piece, gave way to `leading_titles`, which it already called, and `chain_lead`, which asks `is_prefix_piece` only of the titles. - 2026-10-10 #625 -- every row moves UP by 3: `parse` 304 → 307 on 3.11 and 285 → 288 on 3.12-3.15, `facade` 341 → 344 and 322 → 325, each measured on its own interpreter with `tools/perf/call_count.py` in isolated copies of 505d6dec (#632's merge) and of the change, the import asserted (505d6dec read exactly the recorded rows). `--modules` on 3.11: `_pieces.py` 49 → 52, `_group.py` 23 → 23. The reference name holds a particle run (`de la`), so it pays the merge's call and one more `merge_pieces` and `joined_tags`, the chain then merging `de la` with `Vega` where it had merged the three words at once; the first cut asked the title run and the lead in the merge and again in the read, +11, and group now asks the title run once and hands both on. Every name of three or more words read at the trailing site pays the call, +1 (`John Q. Smith` 202 → 203 on 3.11, warm); a name with a particle run pays the 3 (`Juan de la Vega` 230 → 233). - 2026-10-04 #601 (its pin superseded by 2026-10-06 #617) -- THE LINK PIN IS RE-POINTED FROM THE MAIDEN CLAUSE TO THE NAME, 2,301 → 2,678 on py3.11. #601 retired the clause's link exception, so the clause shape the pin had measured reached `_between_name_words` 0 times; the same 64-link run written in the name (`Jane Puig i … Soler`) reaches it once per link, and a per-link regression moves it by 64 against a 2% band of 53.6. Recorded 2026-10-22 for #762 from commit 1037c87c and `tests/v2/test_benchmark.py`'s comment beside the pin, the move having landed with no entry here. - 2026-10-06 #617 (its rows and link pin superseded by 2026-10-07 #619) -- every row moves DOWN by 10: `parse` 374 → 364 on 3.11 and 353 → 343 on 3.12-3.15, `facade` 411 → 401 and 390 → 380, each measured on its own interpreter with `PYTHONPATH= pythonX.Y tools/perf/call_count.py` against 83f4e914 (every row had sat inside its band before, the 3.12-3.15 ones at +5 over a 348 baseline). `--modules` puts all ten in group and the piece layer (`_group.py` + `_pieces.py` 142 → 132, every other module unchanged): P3's connective joins moved out of `_group_segment` into `_pieces.join_connectives`, which asks `is_conj_piece`, `is_title_piece` and `is_prefix_piece` directly where the loops had asked through one-line closures over them, two frames per test becoming one. The link pin moves with it, 2,678 → 2,479 for the 64-link name on 3.11 (`_LINK_BASELINE`), and there for two reasons, counted per function in #617's review: the closures (`conj` 133 frames, `title` and `prefix` one each) and the single-letter test, which had joined each connective's text through a generator (65 frames, one per link, more than the pin's whole band), less the one `join_connectives` frame. A deliberate move, not drift: the change was a refactor (decisions/P3, 2026-10-06). The reference rows hold no comma; a comma part holding a connective now pays the shared loop (`John Smith, Mr. and Mrs.` 262 → 269 on 3.11, warm), and one holding none skips it and costs what it did (`Smith, John` 161), the review having found the first cut charging every family-comma parse two frames per word of the part. THEN 39 MORE on every row, in the branch's /simplify pass: group ran the rootname count (about five frames a piece, its only reader the carve-out) and the shared loop over every segment of three or more pieces, connective or not, and over the corpora and the case table under three orders 0 of the 3,153 group calls with no connective left free changed a piece. Group now skips both where its frozen-set walk, which already reads every piece's `conjunction` tag, finds no connective free, as `_comma._reading_pieces` does: `parse` 364 → 325 on 3.11 and 343 → 304 on 3.12-3.15, `facade` 401 → 362 and 380 → 341, `_group.py` + `_pieces.py` 132 → 93, measured on each interpreter as above; the link pin does not move, every link in it joining. Per name on 3.11, warm: the Vega name 436 → 386, `Smith, John Quincy Adams Bob` 326 → 297, `Smith, John` and `John Smith, Mr. and Mrs.` unchanged at 161 and 269. No output moved over the #617 grids (decisions/P3). - 2026-10-07 #619 (its rows superseded by 2026-10-07 #614; #619 added 2026-10-22, the head having named no issue of its own: commit 343e268c landed through #619's merge, adf6da88) -- every row moves DOWN by 8 more: `_group_segment`'s last three one-line predicate closures, `prefix` over `is_prefix_piece`, `suffix` over `is_suffix_piece` and `marker` over `_is_maiden_marker_piece`, are inlined as direct calls, finishing what #617 did for `title`, `conj` and `merge`. Each closure was a frame of its own ahead of the predicate's, and the chain's two inner scans ask `prefix` once per piece they pass, so the cost grows with particle pieces (frame-budget question 1 in AGENTS.md). Measured with `PYTHONSAFEPATH=1 PYTHONPATH= pythonX.Y tools/perf/call_count.py`, each row on its own interpreter, parse/facade against 0c54662b: 3.11 325/362 → 317/354, 3.12, 3.13, 3.14 and 3.15 304/341 → 296/333. `--modules` on 3.11 puts all eight in `_group.py` (34 → 26), every other module unchanged. The whole of the drop is the closures, counted per function on 0c54662b (a profile hook counting `call` events whose code is named `prefix`, `suffix` or `marker` in `_group.py`): the reference name asked `prefix` 7 times and `suffix` once, `Juan` + `de la Vega ` × 8 + `Smith` asked 40 and 9 and reads 937 → 888 on 3.11 (919 → 870 on 3.12-3.15), and the 64-link name asked `prefix` once, so `_LINK_BASELINE` moves 2,479 → 2,478 on 3.11 and gains rows for 3.12-3.15 at 2,459 (2,460 on 0c54662b), each measured on its own interpreter and the first the pin has had beyond 3.11; `marker` is reached only by P5's bound-join decline and the reference name never asks it. BEHAVIOR IDENTICAL: the full suite passes, and the differential gate's report at all five baselines matches 0c54662b's line for line apart from the path header. - 2026-10-08 #620 (its rows superseded by 2026-10-10 #624) -- no row moves: `parse` 308 on 3.11 and 289 on 3.12-3.15, `facade` 345 and 326, measured as the #614 bullet below on 4278693d (#621's merge) and on the change alike. The 3.12-3.15 pin read 290/327 until this date: #614's /simplify took one frame off those interpreters (289/326 on 4278693d, measured 2026-10-08) and the pin stayed, inside its band, so the #614 bullet's 290 is the pin's number, not that tree's. `--modules` on 3.11 unchanged. The merged copy and its per-word exclusion test are gone, and `_chain_units` asks `chain_run_end` per particle unit and `_weighed` per word behind one; the chain itself calls `chain_run_end` with the particle test inline where it had called `is_prefix_piece` per piece, which pays for the rest. decisions/S2, this date. - 2026-10-07 #614 (its rows superseded by 2026-10-08 #620 on 3.12-3.15 and by 2026-10-10 #624 on 3.11) -- every row moves DOWN again, on top of the closure inlining above: `parse` 317 → 308 on 3.11 and 296 → 290 on 3.12-3.15, `facade` 354 → 345 and 333 → 327, each measured on its own interpreter with `PYTHONSAFEPATH=1 PYTHONPATH= pythonX.Y /tools/perf/call_count.py` against adf6da88 (#619's merge), the import asserted. `--modules` on 3.11: `_pieces.py` 59 → 51, `_group.py` 26 → 25, `_assign.py` 9 → 10, every other module unchanged (`_vocab.py` read 37 until #614's /simplify inlined the one roman test the counting view asks per word, 2026-10-07). S2's trailing run is read once between P3's joins and the particle chain, so the chain's stop and its re-ask, the reserve's two views and assign's own reading collapse into one, and the counting view it reads through is one call on every segment that reaches the read, and its unit walk (`_counted_as_one`) runs only on a segment holding a particle past its first piece; the link pin reads 2,478 → 2,482, inside its band, and stays. A segment of fewer than three pieces never reaches the read and costs what it did (`John Smith` 152, `Smith, John` 161, `John Smith, PhD` 206 on 3.11, warm). decisions/S2, this date. derek73-python-nameparser-456b024/docs/design/decisions/phd-merge.md000066400000000000000000000205301527303271700252620ustar00rootroot00000000000000# phd-merge — the "Ph. D." split ## Standing - `Ph. D.` tokenizes as two words and is merged back by vocabulary, v1's fix_phd, into one suffix piece, so a trailing `Ph. D.` reads as a trailing `PhD` does (`John Smith Ph. D.` → suffix 'Ph. D.') and the merged piece is a suffix mid-name too (`John Ph. D. Smith` → suffix 'Ph. D.', where `John PhD Smith` keeps middle 'PhD') — 2026-07 PR #288. - The merge declines at the first TOKEN of the input, a suffix never opening the string (rules/S2): `Ph. D. Van Johnson` reads title 'Ph.', given 'D.', family 'Van Johnson', 1.4.0's reading, while a quoted clause before it leaves it merging (`"Bob" Ph. D. John Smith` → suffix 'Ph. D.') and a run opening the part after a family comma merges too (`Smith, Ph. D. Jr.` → suffix 'Ph. D. Jr.') — 2026-08-31 #371 - Accepted: a title before the pair keeps the credential reading, so `Sir Ph. D. Van Johnson` reads given 'Van Johnson' with an empty family, 1.4.0 parity — 2026-08-31 #371 Elsewhere: the merged pair starts the credential's run in a main segment as `PhD` does, from where it stands though the walk leaves it out (`John Smith Ph. D. Jones` → suffix 'Ph. D. Jones', reported; `John Smith Ph. D. MEng` → suffix 'Ph. D. MEng'), and every other credential split across a space merges by the same predicate as a member of the ambiguous class, which the count decides, while this pair stays the unambiguous credential it was and outranks a pair overlapping it (`Jane Doe Dr. Ph. D.` → title 'Dr.'), decisions/S12's 2026-10-25 #752 entry; the empty family belongs to a title run that addresses by the given name (`Sir`), so `Dr. Ph. D. Van Johnson` reads family 'Van Johnson', decisions/H1's 2026-09-08 #489 entry; `Parser.revise(suffix="Ph. D.")` renders 'Ph. D.', the merge still not firing in a field value but rules/R1's entry pass joining the pair, decisions/R1's 2026-09-06 #511 entry. Open: none in this file. ## Log - 2026-07 (v2 core, PR #288; recorded plan deviation #1 of the core plan) (its spellings reading alike after a name superseded in part by #544, decisions/S7: the merged pair was outside the no-comma company walk, so `John Smith Ph. D. MEng` kept family 'MEng' where `John Smith PhD MEng` read suffix 'PhD MEng'; alike again since #752's phase 2e, decisions/S12, the pair starting the credential's run) — "Ph. D." tokenizes as two words and is merged back by vocabulary (v1 fix_phd), so the spaced and unspaced spellings read alike at the end of a name. (Corrected 2026-10-22, #762: this said they read alike everywhere, which never held mid-name or at the head, at 1.4.0 and 2.0.0 as on the tree: mid-name the merged piece is a suffix and a bare `PhD` a name word (`John Ph. D. Smith` reads suffix 'Ph. D.', `John PhD Smith` middle 'PhD'), and at the head `Ph. D. Van Johnson` read title 'Ph.' at 1.4.0 and suffix 'Ph. D.' at 2.0.0 where `Ph.D. Van Johnson` read given 'Ph.D.'.) (The head reading is #371's, below: the merge now declines there and `Ph. D. Van Johnson` reads title 'Ph.' where `Ph.D. Van Johnson` reads given 'Ph.D.'.) - 2026-08-31 #371 (the merge declines at the head) (its revise() rendering superseded in part by #511, decisions/R1) — a suffix never BEGINS a name: position outranks the vocabulary match, and `rules/S2` now states it. The merge is what made a leading credential possible at all — every other suffix-shaped word standing first already falls out as a title (H2's abbreviation clause, or TITLES membership: `Jr.`, `MD`, `Esq.`, `Sr.`) or as a name word (`PhD`, `III`), so the `Ph.`/`D.` pair was the only shape that reached the defect. It reached it by emptying the family: `Ph. D. Van Johnson` read given 'Van Johnson' with no surname at all, which is #371's report. THIS RESTORES 1.4.0 RATHER THAN DEVIATING FROM IT — on three of the four names, and the fourth is named below. v1's fix_phd healed the pair by regex on the raw string, `\s(ph\.?\s+d\.?)`, whose leading `\s` made a head match impossible while it fired everywhere else, mid-name as readily as trailing. (A first draft of this entry said "only where it TRAILED", which is false in both directions and would license narrowing the merge to the trailing position — `John Ph. D. Smith` reads suffix 'Ph. D.' at 1.4.0 and here.) At the head v1 split it — measured on the released wheel, `Ph. D. Van Johnson` gives title 'Ph.', first 'D.', last 'Van Johnson', which is exactly what this change gives. So `expected_since_1.4.0.toml` LOSES its fix(leading-credential) rule — and READ THE DIAGNOSIS, not just the error: the gate reported it SHADOWED, not reverted. `compare.py` separates the two ("shadowed -- an earlier rule claimed every diff it would have" versus "reverted -- matches no diffing name") because the fixes differ. Three of its four names became parity and the fourth, `Ph. D., Jr.`, still diverges under `fix(comma-precomma-family)`, which sits above it. #426 is the precedent for dropping a shadowed rule rather than keeping it. A first draft of this entry generalized EXPLAINED NOTHING into "how a parity restoration announces itself", which would teach the next maintainer to delete a shadowed rule whose behavior is still diverging and the 2.0.0 and 2.1.0 ledgers GAIN one, claiming the same four names, the same five roles and the same digest. What shipped in 2.0 was a regression from applying the merge unconditionally, not a reading anyone chose. OPENING THE NAME IS NOT OPENING A PIECE LIST, and the first draft got that wrong: a credential run legitimately opens segment 1 after a family comma (`Smith, Ph. D. Jr.`), which C1 reads as a listing rather than a name beginning. Testing `k == 0` alone broke fifteen tests and took the gate red at all three baselines. The caller passes the distinction in, since `_group_segment` cannot see which segment it holds. ACCEPTED, and found by review rather than by design: a TITLE before the pair keeps the credential reading, because `k` is a piece index and a title is a piece. `Sir Ph. D. Van Johnson` still reads given 'Van Johnson' with an empty family — #371's own symptom, surviving one word to the left. It is 1.4.0 parity, and the rule states it as a boundary rather than deviating: "the first piece of the name" cannot be computed before this merge, since H2's abbreviation test is true of `Ph.` itself, so a scan that stepped over titles would step over the very piece being judged. rules/S2 therefore says OPENS THE STRING, which is what the code holds and what v1 held. THE FIRST DRAFT GOT "THE HEAD" WRONG IN THE OTHER DIRECTION, and the corpus could not see it. Testing the PIECE index alone declined the merge for `"Bob" Ph. D. John Smith`: `extract_delimited` removes a quoted or bracketed clause before grouping, so the pair reaches the merge at `k == 0` with a word standing before it in the INPUT. v1 merges there (its regex needed only a preceding space), and 112 measured names broke parity — every one opening with a quote or a bracket, and not one of them in any corpus. The test is now `a[0] == 0`, the first TOKEN, which is v1's boundary exactly. ACCEPTED: `Parser.revise(suffix="Ph. D.")` now renders 'Ph., D.'. revise() runs a full sub-parse of the string it is given, and a field value has no head for a head-position rule to consult; a second draft carved that out by requiring a name to displace, and the carve-out made the head reading depend on what FOLLOWED it — appending a maiden clause changed whether 'Ph.' was a title, which `test_a_maiden_clause_changes_nothing_else` caught. Dropping the carve-out removed the inconsistency with it. The merge exists for a credential someone TYPED after a name. SUPERSEDED 2026-09-06 by #511 in the half that took a caller who writes the spaced form into the suffix field at their word (Derek's call, 2026-08-31, reversed by his call of 2026-09-06): the MERGE is a head-position rule and still does not fire in a field value, but rules/R1's entry pass now runs inside `revise()` and joins the pair for the reason it joins `MD` and `PhD`, so `Parser.revise(suffix="Ph. D.")` renders 'Ph. D.' (decisions/R1, 2026-09-06 #511). What the leading `Ph.` becomes is H2's business, not this rule's: an abbreviation before a name is almost always a title, which is the same clause that reads `Esq. van Gogh` as title 'Esq.' — so the pair reads title 'Ph.', given 'D.'. The issue proposed given 'Ph.', middle 'D.'; H2 claims the first word before the positional read sees it, and the two answers differ only in which field holds `Ph.`. derek73-python-nameparser-456b024/docs/design/decisions/removed-v1-surface.md000066400000000000000000000025571527303271700270360ustar00rootroot00000000000000# removed-v1-surface - empty_attribute_default: removed in 2.0 (#255; deprecated in 1.4 per the bridge discipline). Origin #44 (2016): a DB-NULL convenience whose first answer — `name.title or None` — became the migration path. The in-band-signaling bug that sealed it (#254): the 2016 `.replace('None','')` scrub could not tell interpolated None from name text, so "Nonez Smith" rendered "z Smith" — the fix shape, worth keeping as a one-liner, is SUBSTITUTE BEFORE FORMAT, never scrub after. Two removal cautions shaped the implementation: tombstone-not-deletion (a plain deleted attribute would make assignment silently accepted-and-ignored, the #241 failure family) and readers-not-just-writers (#44's own thread advised asserting against the attribute, so reads existed downstream). The tombstone and pickle-tolerant load die with the shim. (An earlier 3-0-reevaluations bullet said "left untyped, typeable in 3.0" — promoted from a memory that was already three days stale when written; nothing is left to type.) - bytes input: removed in 2.0 (#245; decode-first DeprecationWarnings shipped 1.3.0 per the bridge discipline); the shipped TypeError carries the decode hint. - no_vowels: removed in 2.0 (#268, filed 2026-07-07, closed 2026-07-28) — never consulted by any parser version, ASCII-only; the facade carries no replacement because there was nothing to replace. derek73-python-nameparser-456b024/docs/design/decisions/render-default.md000066400000000000000000000017771527303271700263270ustar00rootroot00000000000000# render-default — the default view's format ## Standing - The default spec is '{title} {given} "{nickname}" {middle} {family} ({maiden}) {suffix}', and a parenthesized maiden re-parsing as a nickname is its accepted cost (`Jane (nee Jones) Smith` renders 'Jane Smith (Jones)', which reads back with nickname 'Jones'); a née template that round-tripped losslessly was declined on presentation grounds — 2026-07-18. Elsewhere: the v1 facade's own default `string_format`, which omits the maiden name, so `str()` of a `HumanName` cannot show it — decisions/R5's 2026-08-29 WHAT THE OVERRIDE DOES AND DOES NOT PROMISE entry. Open: none in this file. ## Log - 2026-07-18 (bf1141c) — the default spec is '{title} {given} "{nickname}" {middle} {family} ({maiden}) {suffix}'. Declined the same day: a née-template maiden ('née {maiden}') that round-tripped LOSSLESSLY (née is a maiden marker), rejected on presentation grounds. Accepted cost of the shipped form: a parenthesized maiden re-parses as a nickname. derek73-python-nameparser-456b024/docs/design/decisions/review-agent-single-source.md000066400000000000000000000040551527303271700305700ustar00rootroot00000000000000# review-agent-single-source — the review axes live in one file ## Standing - `.claude/agents/` and `.claude/commands/` are tracked, and the design-docs reviewer points at the review axes in `docs/design/AGENTS.md` rather than restating them; a duplicate that carries no independent content is deleted, not kept in sync — 2026-08-31 #473. - A test pinning the agent's axis headings against the AGENTS.md list was declined: it would have caught one of the three drifts that earned this entry, the other two changing body prose under intact headings — 2026-08-31 #473. Open: none in this file. ## Log - 2026-08-31 (#473) — `.claude/agents/design-docs-reviewer.md` restated AGENTS.md's review axes and, being untracked, drifted three ways against the file it implements: axis 10 never arrived, axis 4's outward half never arrived, and and the corpus-population caution kept a wording AGENTS.md itself records as corrected. That third one carried a second defect neither copy could show: BOTH quoted a corpus measurement that decisions/P6 had already superseded, so the endorsed wording was stale too. AGENTS.md's caution is rewritten in this change to carry the lesson without a standing count. The drift was one-directional — every correction landed in the tracked, reviewed, diffable file and none reached the copy. `.claude/agents/` and `.claude/commands/` are now tracked, and the agent points at AGENTS.md rather than restating it. The general form: when a duplicate carries no independent content, delete it; a duplicate is only worth keeping when it says something its source does not. Declined: a test pinning the agent's axis headings against AGENTS.md's list, which is what #473 proposed. Heading comparison would have caught the missing axis 10 and NEITHER of the other two drifts — both changed body prose and left headings intact — so the test detects a third of the defect while licensing the arrangement that produced all of it. Same shape as the `STABLE_TAGS` twin flagged in `nameparser/_types.py`, and the same remedy applies there if it is ever revisited. derek73-python-nameparser-456b024/docs/design/decisions/salutation-titles.md000066400000000000000000000362361527303271700271110ustar00rootroot00000000000000# salutation-titles — salutations, offices and ranks for the languages TITLES had none for (2026-10-04, #606) Closes #606 (#662 later added owura and bapak, its bullet below, and #847 cllr). No parser code moves: 41 words join TITLES and one, knt (Knight, beside kt), joins SUFFIX_ACRONYMS with a `Knt` case mask. The class is decided once — a word that addresses or ranks a person and is written in FRONT of the name — and each word is then argued on #vocabulary-collisions C-i, the position test, because TITLES has no ambiguous subset and the only two answers available are ship and do not ship. The per-word lists, grouped by language, are the titles carrying `issue = 606` (`uv run python tools/vocab/query.py --issue 606 --kind title`; one line per file a word is in; the #606 block of nameparser/config/titles.py until 2026-10-18, then TOML comments in nameparser/vocab/und.toml, and since 2026-10-19 (#750) the salutations' entries in their language files, and the offices', the noble Counts' and knight's too since the second part of #750 the same day). ## Standing - A word that addresses or ranks a person and is written in FRONT of the name ships as a title unless it is borne as an ordinary name in the leading position, the position test of vocabulary-collisions' C-i, TITLES having no ambiguous subset to mark a collision in, so the only answers are ship and do not ship — 2026-10-04 #606. - One exception to C-i, for TITLES alone: a word borne as a name only RARELY as the first word of one ships on the precedent of `graf` (`herra`, `batoni`, `hrabia`, `kreivi` and the rest of the graf class, each with its counts on its title reading's `note`), and no word in the particle or suffix sets, and no later title, takes it without its own counts — 2026-10-04 #606. - The cost falls on a name that BEGINS with such a word: `Greve Anna` reads title 'Greve', family 'Anna' and `Herra Wijaya` title 'Herra', family 'Wijaya'; a declared `FAMILY_FIRST` does not help (`Hrabia Anna` reads title 'Hrabia' under it); the family comma keeps the word as the family where no name word stands behind it (`Hrabia, Anna` reads given 'Anna', family 'Hrabia', and so does `Hrabia Jr., Anna`), while a name word behind it makes it the title by H6 (`Hrabia Nowak, Anna` reads title 'Hrabia', family 'Nowak'); a name that ends in the word keeps it (`Gladys Knight`, `Knight, Gladys` read family 'Knight'), unless a period follows it (`Gladys Knight.` reads title 'Knight.', family 'Gladys', by H5, accepted rather than prevented) — 2026-10-04 #606. - Words judged a COMMON leading-position name stay out (`thiru`, `puan`, `pan`, `rodina`, and `ông` and its pair `bà`), and words with no evidence either way are not shipped (`gróf`, `cik`, `paron`, `tikin`, `janab`), each an `excluded` entry in its language's file (`uv run python tools/vocab/query.py --excluded title --issue 606`) — 2026-10-04 #606. - Accents are not folded, so `paní` and `härra` match only their accented spellings (`Paní Nováková` reads title 'Paní', `Pani Anna` reads given 'Pani'); folding the lexicon would turn both into collisions, and whoever proposes it re-argues the entry — 2026-10-04 #606. - `gravos` was dropped, not excluded, since nothing showed it is the Greek word for Count; `κόμης`, a Greek surname written first in official lists, is neither added nor excluded, and adding it is a decision still to make — 2026-10-04 #606. - `knt` (Knight, beside `kt`) is a suffix acronym with a `Knt` case mask: `Mary Knt` reads suffix 'Knt' — 2026-10-04 #606. - `cllr` (Councillor, British, written bare) is a title, on counts under 100 US Census bearers and none found as a name elsewhere — 2026-10-22 #847. - `owura` (Akan) and `bapak` (Indonesian) are titles, on counts under 100 US Census bearers each and none found on Wikidata, and `malam` (Hausa) and `bay` (Turkish) are excluded, each an `excluded` entry with its counts — 2026-10-17 #662. - The Italian abbreviated salutations and letter-heading honorifics whose letters after the full stop are the word's own ending ship as titles with `cased` masks: `sig.ra`, `sig.na`, `dott.ssa`, `prof.ssa`, `gent.mo`, `gent.ma`, `gent.le`, `ill.mo`, `ill.ma`, `ch.mo`, `ch.ma`, `preg.mo`, `preg.ma` (`Dott.ssa Anna Bianchi` reads title 'Dott.ssa', given 'Anna'; `SIG.RA MARIA ROSSI` repairs to 'Sig.ra Maria Rossi'); the bare `sig` and `ing` are excluded as given names, their period-marked spellings already titles by H2's shape, except `Ing.` after a full name and a comma, which is the ambiguous credential decisions/suffix-acronym-collisions lists (#848) — 2026-10-22 #840. Elsewhere: `justice` and `marshal`, excluded here as common leading-position names, are titles only inside a title phrase since 2026-10-25 and 2026-10-26 (`Chief Justice John Roberts` reads title 'Chief Justice' and `Field Marshal Bernard Montgomery` title 'Field Marshal', `Justice Smith` and `Marshal Yanda` the given name) — decisions/H9's 2026-10-25 #752, phase 2i entry; decisions/H9's 2026-10-26 #752, phase 2i (Derek) entry. ## Log - 2026-10-04 #606 (its family-comma remedy superseded in part by #701, decisions/H6) — SALUTATIONS, OFFICES AND RANKS FOR THE LANGUAGES TITLES HAD NONE FOR: the undated bullets below, through the measurement. - **The graf class, decided (Derek, 2026-10-04).** A word borne as a name, but RARELY as the first word of one, ships anyway, on the precedent of graf, which shipped though Steffi Graf exists. This is a judgment on each word's incidence in the leading position — the position C-i asks about — and not a rule, and it is an EXCEPTION to C-i rather than an application of it: C-i is an iff with no threshold, and herra, batoni, hrabia and kreivi were measured as borne in that position, so C-i says do not ship them (TITLES having no ambiguous subset to mark them in). What the exception adds is a weighing C-i does not make, a small measured incidence against the benefit of the title reading, and it is scoped to TITLES and to the words this entry names. It licenses nothing in the particle or suffix sets, and a later TITLES word that wants it is argued on its own counts. The title claim acts only in front, so most bearers are untouched in the forms they write: `Gladys Knight` and `Knight, Gladys` keep family Knight. The cost falls on a name that BEGINS with the word, the cost `Graf Steffi` already paid: `Greve Anna` reads title Greve, family Anna, and a given name is lost the same way, `Herra Wijaya` reading title Herra, family Wijaya. Declaring `FAMILY_FIRST` does not help, since the title claim is read before the order (`Hrabia Anna` reads title Hrabia under it); the family comma does. Two further limits, both pre-existing: a trailing period makes the word the title by rules/H5 (`Gladys Knight.` reads title `Knight.`, as `Mary Jane King.` does), and every word this entry adds widens that rule's reach, H5 claiming any listed title behind a period. Accepted rather than prevented, and stated at rules/H1's Accepted clause. The issue proposed the class for three words — greve (Scandinavian Count), graaf (Dutch Count), knight. The spot-check the issue asked for (forebears.io, 2026-10-04, worldwide counts rounded) found ten more of its "no known collision" words borne, and Derek extended the decision to all of them rather than excluding them: herra, batoni, ponas, counsel, ponia, the Filipino ginoo, ginang and binibini, and two whose registers often write the surname first, hrabia and kreivi. Each word's counts are on the `note` of its title reading in one of its files (herra's in fi.toml), a note beginning "borne" (`uv run python tools/vocab/query.py --issue 606 --kind title`; this bullet held them until 2026-10-19, #750). Those last two, and the given-name uses of herra and batoni, are the leading-position bearers this class accepts, and were decided on with their counts in hand. The line is NOT reproducible from this record, and that is said rather than hidden: the accepted side was counted, while the excluded side — thiru, puan, pan, rodina, ông/bà — was judged from general knowledge of each word, as #606 drafted it, with no count taken (the count source refused further lookups the same day). Each was judged a COMMON leading-position name, against counts in the hundreds on the accepted side; gróf was excluded for want of any evidence at all. Each refusal is an `excluded` entry in its language's file (`uv run python tools/vocab/query.py --excluded title --issue 606`). Whoever decides κόμης or the next salutation should count the excluded words first, and is free to find the line in a different place. Those notes are where a sweep meets the decision as it meets the word (the `# borne` comment in titles.py until 2026-10-18). - **Accents are not folded, and two words depend on it.** `_lexicon._normalize` lowercases and composes NFC and does nothing else to letters, so paní and härra cannot match Pani or Harra (the counts are on their notes). Accent-folding the lexicon would turn both into collisions; whoever proposes it re-argues this entry. frú depends on nothing: the unaccented fru (Swedish/Danish Mrs) already ships, so the Cameroonian surname Fru (its count on fru's note in sv.toml) already reads as a title in `Fru Anna` and did before this entry; that pre-existing collision is recorded here rather than argued, being no part of #606. - **gravos was dropped, not excluded.** The issue listed it as the Latin-script Greek for Count; the spot-check could not confirm it is the Greek word at all, which is κόμης (kómis). Nothing was shown, so there is nothing to keep out — it is recorded here so that a later sweep starts from that rather than from the issue's table. κόμης itself is NOT added and NOT excluded: it is a Greek surname (~420 bearers in Greece, official lists writing the surname first), which by its counts sits inside the class above beside hrabia, and it was left out of this change for having no use in current Greek address rather than for its collision. Adding it is a decision still to make. - **Measurement (2026-10-04).** One corpus name moves, `Greve Anna`, given Greve to title Greve: the rules/H1 example this entry added, and the only one of its three examples that moves. Recompute: take the 41 titles carrying `issue = 606` (`uv run python tools/vocab/query.py --issue 606 --kind title`, one line per file a word is in; the #606 block of titles.py until 2026-10-18) — parse every distinct name in the tools/differential/corpus*.jsonl glob (1,518 on the day) with the shipped parser and with `Lexicon.default().remove(titles=, suffix_acronyms={"knt"})`, and diff `as_dict()`. Before the rules.md examples were added, the same diff moved nothing over the 1,809 corpus lines of the tree before #604 merged, gravos still in the word list. The old readings the release-log bullet quotes were checked against the 1.4.0 and 2.3.0 wheels. - **2026-10-17 #662 — owura and bapak join TITLES; malam and bay are excluded.** Akan and Indonesian Mr., found in the CLDR corpus (`Owura Bertram…`, `Bapak Dwi Putro bin Adinata MP` read the salutation as the given name). Counts from `tools/name_usage.py` on 2026-10-17: owura and bapak each under 100 US Census bearers and none found on Wikidata (a zero proves nothing, per the tool). The two exclusions are `excluded` entries in ha.toml and tr.toml, with their counts (`uv run python tools/vocab/query.py --issue 662`). Measurement: two corpus names move, both in corpus_cldr.jsonl and both toward CLDR's fields (`Owura Bertram Wilberforce Henry Robert Wooster MP` and `Bapak Dwi Putro bin Adinata MP`, salutation from given to title); recompute with `uv run python tools/differential/snapshot.py --since `. The release-log reading was checked against the 1.4.0, 2.0.0 and 2.4.2 wheels. - **2026-10-22 #840 — the Italian abbreviated salutations join TITLES with case masks; sig and ing are excluded.** #838 made case repair write each full-stop chunk of a word as a word, so an unlisted `SIG.RA` repaired to 'Sig.Ra' where every release gave 'Sig.ra', and the remedy is a listed casing. Thirteen forms whose letters after the full stop are the word's own ending: `sig.ra` and `sig.na` (salutations, spellings of `signora` and `signorina`), `dott.ssa` and `prof.ssa` (academic), and the letter-heading styles `gent.mo`, `gent.ma`, `gent.le`, `ill.mo`, `ill.ma`, `ch.mo`, `ch.ma`, `preg.mo`, `preg.ma`, each with a `cased` mask. Listing them also settles the parse, which had read them by accident: `Sig.ra` and `Prof.ssa` were titles through a piece their joined word matched, and `Dott.ssa Anna Bianchi` read given 'Dott.ssa', middle 'Anna' on 1.4.0 and 2.5.0 (wheels, measured 2026-10-22); it reads title 'Dott.ssa', given 'Anna' now, under all three orders and every comma shape. None of the thirteen spells a name, each being an Italian abbreviation, so C-i's position test finds no bearer to weigh (a word with an interior full stop can be one in general: `J.R. Smith` reads given 'J.R.', rules/H2; the nearest shadow found is joined initials spelling `ch.ma`, `CH.MA ROSSI` now reading title 'CH.MA', with no bearer found); the committed corpora hold none (the snapshot moved only rules/R4's own `SIG.RA` row), and the class rests on standard Italian usage rather than a count. The bare `sig` (US SSA 50+ male, Wikidata 11+ given) and `ing` (nam_dict band 5 in Sweden, Wikidata 10+ given, US Census 1,814 family) are excluded with their counts (tools/name_usage.py, 2026-10-22); `Sig.` and `Ing.` already read as titles by H2's shape. A cost the `ing` exclusion does not decide: ClinicalTrials.gov officials write a bare `Ing` AFTER a comma, as a credential (52 of 536,981 write it, 43 after a comma, the same tool run), and 9 of the 28 distinct names holding it put it in the given field (`Viera Sevcikova, Ing` reads given 'Ing', family 'Viera Sevcikova'), where the dotted `Mario Rossi, Ing.` reads title 'Ing.'. That is a suffix question, a credential reading of `ing` beside the comma, and a title exclusion neither causes nor settles it. A trailing `Anna Bianchi Dott.ssa` keeps 'Dott.ssa' as the family, since H5 reads a trailing title only when it ends in a full stop, as `John Smith Sir` keeps 'Sir'; after a comma (`Maria Rossi, Sig.ra`) it is the title. - **2026-10-22 #847 — `cllr` joins TITLES as `councillor` abbreviated.** British style writes it without the period, so `Cllr John Smith` read given 'Cllr', middle 'John' where `Cllr. John Smith` read title by shape. `tools/name_usage.py Cllr`: US Census surnames under 100, SSA under 5 births a year, not in nam_dict (a selection, so a miss proves little), and 0 on Wikidata as given or family (a lower bound: a zero proves nothing, per the tool), so no bearer was found as a name, and a five-letter vowelless abbreviation is not a plausible given name. It opens 508 of 15,214 names in `ukec-individuals` (cache data date 2026-10-23T03:45:09Z, same corpus sha as the first fetch of the issue), all bare; with `cllr` removed from `Lexicon.default()` the same 508 `as_dict()` readings differ and no others, 505 of them also moving the old middle into given. `Duke` and `Earl` stay out as borne names, by the caution above: they open 8 and 7 of those names (`name_usage.py` counts 8 and 15 names that write either anywhere). The accepted cost is `John Smith Cllr.`, whose period-marked trailing `Cllr.` was family and is now a title (rules/H5). derek73-python-nameparser-456b024/docs/design/decisions/script-table-placement.md000066400000000000000000000020241527303271700277470ustar00rootroot00000000000000# script-table-placement — a recorded reversal ## Standing - The script range tables live in `_policy`, not `_vocab` as #271 documented, because packs cannot import the pipeline, and a pack's compiled pattern is held in a closure rather than at module level — 2026-07-29. - U+3006 〆 is in the HAN span, beyond UAX #24, for the Japanese surnames it appears in: `〆木 太郎` → family '〆木', given '太郎' — 2026-07-29. Open: none in this file. ## Log - 2026-07-29 (script-ranges relocation) — REVERSES #271's documented choice ("tables deliberately live in _vocab"): the tables moved to _policy because packs cannot import the pipeline, evidenced by two hand copies with sync tests and a blocked 16–30× compiled-regex optimization (a module-level re.Pattern in a pack would flip its classification, hence the closure-held-pattern convention). Rider: U+3006 〆 joined the HAN span on a justification deliberately beyond UAX #24 — it is Script=Common but appears solely in Japanese surnames (〆木, 〆谷, 〆野). derek73-python-nameparser-456b024/docs/design/decisions/suffix-acronym-collisions.md000066400000000000000000001630141527303271700305450ustar00rootroot00000000000000# suffix-acronym-collisions — the trailing-position collision class, decided (2026-09-07, #342/#454) Closes #342 (a wordlist question) and #454 (a rules.md question) together, because they are the same question asked of two different words and answering one without the other would leave the criterion half-stated. No parser code moves in either. Two existing forks carry the whole thing and both were confirmed on the pre-bundle tree with a throwaway override before any wordlist was edited: `Parser(lexicon=Lexicon.default().remove(suffix_acronyms={"rai","cha"}).add(suffix_acronyms_ambiguous={"ba"})).parse(text)`. ## Standing - A suffix acronym that is also a name borne in the trailing position gets one of three answers, by how common each reading is there: the name reading dominating removes the word from the vocabulary (a caller who needs it adds it back), rough balance marks it ambiguous, and the credential dominating leaves it unambiguous; length is a correlate, not the test — 2026-09-07 #342. - `rai`, `cha` and `mai` are out of the suffix acronyms, their trailing name readings being far more common than the credentials, and the cost is accepted: `John Smith RAI` and `John Smith MAI` read the letters as the family name — 2026-09-07 #342; 2026-10-16 #687. - `gc` is out of the suffix acronyms, the Nepali surname G.C. (Gharti Chhetri) being far more common at the end of a name than the credential GC, and the cost is accepted: `John Smith GC` reads the letters as the family name, and `John Smith G.C.` the paired initials rules/S3 leaves to the name — 2026-10-24 #864. - `ba`, `meng`, `lac`, `si` and `chi` are marked ambiguous rather than removed, so each is read as the ambiguous class is, by the case it is written in, the words to spare and the comma (rules/S6, S4 and C4): `John Smith SI` keeps the credential and reports the fork, while `John Smith Si`, `Anna Ba` and `Wang, Meng` read the surname or the given name, reported — 2026-09-07 #342; 2026-09-25 #540; 2026-10-21 #791. - `meng` and `lac` carry no case mask, since a mask applies in every role and would re-spell the surnames the marking keeps (`wang meng` would render `Wang MEng`) — 2026-09-25 #540. - `ded` is not marked: it is borne as a given name, which is not the position the suffix claim acts on — 2026-09-25 #540. - A marked member written in brackets or quotes reads as a nickname, S1's escape declining an ambiguous member (`John Smith (MEng)` → nickname 'MEng'), an accepted cost — 2026-09-25 #540; 2026-10-21. - Under the default order, a marked member that reads as a name is taken into the family by a particle in front of it, with the words between (`John de Smith Chi` → family 'de Smith Chi', where `John de Smith CHI` keeps suffix 'CHI'), an accepted cost — 2026-10-21 #791; 2026-10-21. - 33 more credential readings, listed by `uv run python tools/vocab/query.py --issue 796` and `se` and `om` among them, are marked ambiguous rather than removed: 30 because the surname reading is substantial (US Census 2010 bearers ≥200, or Wikidata family bearers ≥20), a threshold that is a judgement and not a rule the data sets — 2026-10-21. - Where both counts are small, the surname count is weighed against the NPPES count of US healthcare providers writing the word, and the word is marked where the surname reading is the larger (`capa`, `ams`); a provider count is evidence that a credential is live, and a zero says nothing about a credential outside healthcare, so `ei`, whose credential is not a healthcare one, rests on the default under uncertainty instead — 2026-10-21. - Held out of the marking: `am`, `sa` (left every vocabulary set on 2026-10-25, #857, decisions/C8) and `pla`, whose marking regressed CLDR names; `edd`, which would read `EdD` as the family whether marked or removed; `ra`, also a title; `mba`, whose credential reading dominates; and `cap` and `aas`, whose provider counts are as large as their surname counts — 2026-10-21. - 36 credentials that read as the given name after a comma because nothing listed them (`John Smith, OTR/L` → given 'OTR/L') are added, `pa` the one marked ambiguous (Census 739 family, SSA 1,343 given); `pi`, Principal Investigator and a surname, is refused — 2026-10-21 #797. - `ing`, the engineering degree of Czech, Slovak, Austrian and Romanian names, is a credential marked ambiguous (Census 1,814 family; a given name in Sweden), so `Viera Sevcikova, Ing` reads suffix 'Ing' where it reversed and `John Ing` keeps the family name; the period-marked `Ing.` after a full name and a comma moves with it from title to suffix, unlike `Mgr.` and `MUDr.`, and the capitals `ING.` and the dotted initials `I.N.G.` read as `MA.` and `M.A.` do, a name spelled in the initials lost where they stand last (`John I.N.G.`), both accepted costs — 2026-10-23 #848. - `mc` and `vd` stay unambiguous suffix acronyms (#454 is by design): neither is a borne name, and a bare trailing one reads as a post-nominal, leaving no family name (`Donald mc`, `Smith vd`); `Mc Donald` reads family 'Mc Donald' because `mc` is a never-given particle — 2026-09-07 #342. - `esq` is a suffix word and not an acronym, a contraction rather than an initialism, so no word is both a suffix acronym and a suffix word — 2026-09-08 #316. - The Wikipedia footnote marks `cva[22]`, `gaee[14]` and `np[18]` are out of the suffix acronyms, and a list imported from a wiki is checked for reference marks before it lands — 2026-10-18. Elsewhere: after the name, a marked member written in capitals in a name of mixed case or with a period inside it is the suffix, not the nickname (`John Smith (MENG)`, `John Smith (M.Eng)`) — decisions/S13's 2026-10-26 #810 entry. Elsewhere: the case lean that decides the capitals forms above is decisions/S6's 2026-09-14 #289 and 2026-09-14 (Derek) entries; the chunked dotted spelling passing the period gate (`Wang M.Eng.` → suffix) and the credential run that ends in a member keeping its suffix comma (`John Smith, PhD MEng` → suffix 'PhD MEng'), with the run a member leads keeping the cost (`John Smith MEng PhD` → family 'MEng'), are decisions/S7's 2026-09-27 (Derek), #544 entry headed A CREDENTIAL RUN KEEPS ITS AMBIGUOUS MEMBERS, which this file's 2026-09-27 (#544) entry indexes; the shape-plus-position heuristic this file parked shipped as the two switches of decisions/S9's 2026-09-14 (Derek) entries, the all-caps one reading the comma position by default since its 2026-10-01 (Derek), #564 entry; the roman-numeral accident's narrow retirement is decisions/S3's 2026-09-15 (Derek) entry, which this file's 2026-09-15 twin indexes; and `sa` was a title and suffix dual that position decided by #296's audit (decisions/comma-suffix-arc's audit table, whose `sa` row moving it into the ambiguous set decisions/C8's 2026-08-23 (review of PR #428, four reviewers) entry declined), held out of the marking again by the 2026-10-21 entry, and has been in neither set since decisions/C8's 2026-10-25 #857 entry. Not standing: the #454 bullet's note that the `fix(suffix-routing)` ledger rule stays, the ledgers having been retired for the snapshot (decisions/differential-ledger, #664), and the repair of rules/P6's Accepted clause recorded in the "What #454 got right" bullet, carried out in the same change — 2026-09-07 #342. Elsewhere: since phase 2a the ambiguous marking above is derived from a word's name reading rather than written as a flag, and no membership above moved — decisions/vocabulary-model's 2026-10-25 #752 — A CLASH WITH A NAME IS A NAME READING entry (phase 2a). ## Log - 2026-09-07 #342/#454 (the parts reversed later are noted on the bullets they stand in: the accepted cost's `Ahmad Jayadi, CHA` and ba's `Smith, BA`, the roman-numeral accident, the parking lot, and the se and om half of the not-decided bullet; superseded as each note says) — THE TRAILING-POSITION COLLISION CLASS, DECIDED: the undated bullets below, through the 2026-09-07 measurement, except the mai and esq bullets, which date themselves and are indexed by the next two lines. - 2026-09-08 #316/#489 — ESQ LEFT SUFFIX_ACRONYMS: the esq bullet below, decided in the #316/#489 bundle on the date its text gives. - 2026-10-16 #687 — MAI LEFT SUFFIX_ACRONYMS: the mai bullet below, decided on the date its text gives. - 2026-10-24 #864 — GC LEFT SUFFIX_ACRONYMS: the gc bullet below, decided on the date its text gives. - **The criterion, and it is a comparison of RELATIVE FREQUENCY.** #vocabulary-collisions C-i decides whether a word is borne as an ordinary name in the position the vocabulary claim acts on, and answers ship or mark. This entry adds the third answer and says when to reach for it. Being borne at all is only C-i's entry ticket; what chooses among the three answers is how common the word is as a borne name in the TRAILING position weighed against how common it is as a credential. Three outcomes. The NAME reading dominating removes the entry from the vocabulary altogether — a caller who needs it adds it back, which is the answer this file already gives for every locale-specific vocabulary — and that is rai and cha, far more common as surnames than RAI and CHA are as credentials. Rough balance earns the ambiguous marking, both readings being genuinely live so that a flag is the honest output, and that is ba, about as common a surname as BA is a credential. The CREDENTIAL dominating leaves the entry unambiguous, which is where the great majority of the set sits. LENGTH is a CORRELATE and not the test: a short acronym is more often a common credential and more often a name, which is why the four entries carrying the marking before this one — ma, do, ed, jd — are all two letters, and reading that as the rule is what this bullet exists to stop. - **rai and cha left SUFFIX_ACRONYMS.** RAI is "RETA Authorized Instructor"; CHA is Certified Hotel Administrator or Certified Healthcare Auditor. Rai is a common surname across Hindi- and Bengali-speaking regions and Cha is the Korean 차. Both entries arrived in the 2019-12-11 bulk wikipedia post-nominal import (af5bdab) and neither was reviewed against a surname. Measured 2026-09-07: "Aishwarya Rai" reads family Rai where 2.0.0 through 2.2.0 read suffix Rai with no family name at all; "Lala Lajpat Rai" reads middle Lajpat, family Rai; "Kim Cha" reads family Cha. This SUPERSEDES the #vocabulary-collisions sentence "rai — Rai IS a borne surname, so it earns the marking rather than moving", which has a dated supersession note there rather than a rewrite; the sentence was right that Rai is borne and wrong about which of the three answers that earns. - **The accepted cost, stated rather than discovered.** (Its `Ahmad Jayadi, CHA` reading superseded by decisions/S9's 2026-10-01 #564 entry: capitals after the comma read suffix 'CHA' by default.) "John Smith RAI" reads family RAI with Smith a middle name, and "Ahmad Jayadi, CHA" reads given CHA, family "Ahmad Jayadi" — with no credential after the comma, C1 reads an ordinary family comma. Both are case rows, so the reversal is one measurement away for anyone who wants it back. The judgment behind accepting it: a wrong credential reading destroys a real person's surname on every record they appear in, and a missed credential reading leaves the letters in a name field where a human can still see them. - **The roman-numeral accident, recorded because it looks like a survival and is not.** (Superseded by the 2026-09-15 (#289/#516) entry below that retires it, and its `John Smith C.H.A.` half by decisions/S9's dotted shape, which reads that spelling as a suffix.) "John Smith R.A.I." still reads suffix R.A.I. after the removal — but by ACCIDENT, not by vocabulary: rules/S3 splits a token with interior periods on those periods and reads the chunks (`_pipeline._vocab.period_joined_vocab`), and the last chunk `i` is a Roman numeral in SUFFIX_WORDS, so "John Smith X.Y.I." and "John Smith R.A.V." read suffix while "John Smith R.A.X." does not — measured 2026-09-07, `period_joined_vocab('R.A.I.')` returning "suffix" and the chunks being r, a, i, never `ra`. "John Smith C.H.A." shows the other half: it read suffix BEFORE the removal, the lookup fold stripping the periods to reach the cha entry, and reads family C.H.A. after. Nothing is pinned on the accident; it is evidence for the parking lot below. - **ba joined SUFFIX_ACRONYMS_AMBIGUOUS.** (Its `Smith, BA` reading superseded by decisions/S6's 2026-09-14 (Derek) entry: the capitals lean reads suffix 'BA' there.) BA is a common credential and Ba a real surname (Vietnamese; Senegalese Fula) — the ma/Ma shape exactly. Measured: "Anna Ba" reads family Ba with a suffix-or-name flag (1.4.0's reading plus the flag), "John Smith BA" keeps suffix BA and is newly flagged, "John Smith B.A." is an unflagged suffix through the period gate, and "Smith, BA" reads given BA — the "Smith, Ed" cost, already Accepted under S2 and not reopened here. No corpus name carries a trailing ba, so nothing moves in the differential. - **mai left SUFFIX_ACRONYMS (2026-10-16, #687).** The relative-frequency criterion above, asked of a third word and answered as rai and cha were. MAI is "Member, Appraisal Institute", a specialized US real-estate designation; Mai is a Vietnamese given and family name, a Chinese surname (麥) and a Japanese given name. `tools/name_usage.py Mai`: US Census 2010 17,445 family-name bearers (rank 2,075), SSA 5,697+ given-name bearers, Wikidata 822+ family and 458+ given. The entry arrived in the 2019-12-11 bulk Wikipedia post-nominal import (af5bdab, #93) and was never reviewed. Measured: `Tran Thi Mai` under `FAMILY_FIRST_GIVEN_LAST` read suffix `Mai` with no given name (it now reads given `Mai`, middle `Thi`, family `Tran`), and `Nguyen Mai` read suffix `Mai` under every order (family `Mai` by default, 1.4.0's reading). The accepted cost is `John Smith MAI`, which reads middle `Smith`, family `MAI`, as `John Smith RAI` does; `Smith, John, MAI` still reads suffix (reported `comma-structure`) and `John Smith M.A.I.` still reads suffix by the dotted shape (rules/S3). A caller who needs it back adds it: `Lexicon.default().add(suffix_acronyms={"mai"})`. Marking it ambiguous, the `ba` outcome, was the alternative and was not taken: a trailing `Mai` is a name far more often than a credential. One corpus name moves, `Tran Thi Mai` under `FAMILY_FIRST` (snapshot, 2026-10-16). - **gc left SUFFIX_ACRONYMS (2026-10-24, #864).** The relative-frequency criterion above, asked of a fourth word. G.C. is a Nepali surname written as two dotted initials (Gharti Chhetri): 85 of 126,981 name pairs in Nepal's 2079 BS local election candidates write it (`tools/name_usage.py gc`, the nepal-candidates row, measured 2026-10-24), 80 of them at the end of the name, against 239 of 4,824,544 NPPES providers who write GC as a credential (the August 2026 file; the George Cross is among its expansions) and 10 of 536,981 ClinicalTrials.gov officials, none after a comma. Listed, it took the family from every name it ended: `Hari G.C.` read suffix 'G.C.' and no family name in 2.0.0 through 2.5.0 (1.4.0 read last 'G.C.'), and `Hari Bahadur G.C.` family 'Bahadur' in 1.4.0 through 2.5.0. Marking it ambiguous, the `ba` outcome, would have moved nothing for the dotted spelling, which rules/S3's period gate counts as unambiguous (`Jack M.A.` → suffix), so removal is the only answer that reaches the names, and it is the answer the criterion gives besides. Unlisted, `G.C.` is the paired initials rules/S3 leaves to the name (decisions/S3's 2026-10-24 #864 entry) and `GC` an unlisted pair of capitals: `Hari G.C.` and `Hari Bahadur G.C.` read family 'G.C.', reported. The word carries the reason as an exclusion (`uv run python tools/vocab/query.py --issue 864`), so adding it back is a build error that quotes it; a caller who needs it adds it to a lexicon: `Lexicon.default().add(suffix_acronyms={"gc"})`. `dc` (Doctor of Chiropractic) was weighed beside it and stays: 99,172 NPPES providers write it against 21 Nepal name pairs ending in D.C., so those 21 keep the cost. - **#454 is BY DESIGN, and the words are mc and vd.** mc has no vowel and is not a borne name — which reverses #342's comment, where mc is listed among the entries "borne as surnames" and is the one member of that list its model-recall hedge does not cover; #360 measured it: mc and ste are contractions of Mac and Sainte, borne in neither position, which is why both left the ambiguous particle half. The Scottish prefix never detaches from the name it belongs to, so "Donald Mc" is not a name anyone writes and its suffix reading costs nothing real. vd needs a surname after it, so a bare trailing vd is the decoration. Neither joins the ambiguous subset and the `fix(suffix-routing)` ledger rule — a two-token name ending in a credential acronym keeps it in `suffix` — stays. What #454's example actually turns on is a different membership: "Mc Donald" reads family "Mc Donald" and "John van Mc" reads family "van Mc" because mc is a never-given PARTICLE (#360), which is why the trailing bare shape is the only one at issue. - **What #454 got right, and it is a rules.md defect that is now fixed.** rules/P6's Accepted clause said that under the default order a comma-less name keeps its positional reading, with "Jong Anke de" → family "de" as its only example. Measured, that holds for de and for do and fails for exactly the two words that are in BOTH the particle vocabulary and the UNAMBIGUOUS suffix vocabulary: "Donald mc" and "Smith vd" lose the family entirely to `suffix`. The clause is repaired to say so, and the two places that already conceded it in prose — the `'Donald mc'` bullet of #differential-ledger's fields-only arc and the corresponding comment in expected_since_1.4.0.toml — are amended from "recorded here rather than fixed" to point at the repair. No example line moves: the clause's own example is de-shaped and stays true, which is why no test caught the defect and why regenerating corpus_rules.jsonl is a no-op here. Recompute the membership with `L = Parser().lexicon; sorted(L.particles & (L.suffix_acronyms - L.suffix_acronyms_ambiguous))`, which gives ['mc', 'vd'] on 2026-09-07, and `sorted(L.particles & L.suffix_acronyms_ambiguous)`, which gives ['do']. - **Parking lot: the shape-plus-position credential heuristic.** (Superseded by the 2026-09-15 (#289/#516) entry below that closes it.) #490's idea without the "collides with vocabulary" part, and what would recover "John Smith RAI" without any wordlist at all. Two shapes: an ALL-CAPS acronym standing in the suffix position of a MIXED-CASE name ("John Smith XYZ", "John Smith, XYZ"), where the case contrast is the signal; and a DOTTED acronym in that position regardless of case ("John Smith X.Y.Z.", "john smith x.y.z."), where the periods are. Measured 2026-09-07, none of the four is read as a suffix today — the bare form gives family XYZ, the comma form given XYZ with family "John Smith", and the second two family X.Y.Z. — while the roman-numeral accident above reads three dotted forms as suffixes on a fork that is about generations, not credentials. That contrast is the evidence the dotted shape is unhandled: the parser already produces the wanted answer for X.Y.I. and R.A.V. by coincidence and the unwanted one for R.A.X. A follow-up issue carries it; nothing in this entry depends on it. - **esq left SUFFIX_ACRONYMS (2026-09-08, the #316/#489 bundle).** (Its `John Smith E.S.Q.` measurement superseded by decisions/S9's dotted shape, by which that spelling reads suffix again.) The same criterion asked of a word the frequency argument cannot reach: does the entry describe the WORD or the machinery. Esquire is a contraction, not an initialism, so the initialism set was the wrong home for it whatever its frequency — this is the criterion's third kind of answer, and it is the one rai and cha did not need. The entry arrived with the same 2019-12-11 bulk import (af5bdab, #93) that brought rai and cha and was never reviewed either; its only unique coverage was the multi-dot spelling `E.S.Q.`, the SUFFIX_WORDS membership carrying every single-token spelling. Measured over the corpus glob: one name moves, `John Smith E.S.Q.`, from suffix to family, at every baseline. What the removal buys is the invariant this entry's neighbours could not have: `SUFFIX_ACRONYMS ∩ SUFFIX_WORDS == ∅` is asserted at import (a vocabulary build check since 2026-10-18: one suffix kind per word, each an acronym or a word; decisions/vocabulary-model), esq having been the only member of that intersection and the reason the assert could not exist. Recompute the intersection with `L = Parser().lexicon; sorted(L.suffix_acronyms & L.suffix_words)`, which gives `[]` on 2026-09-08. The standing keep-out is the Excluded (SUFFIX_ACRONYMS — esq) block in decisions/vocabulary-exclusions. - **Not decided here:** (Its se and om half superseded by the 2026-10-21 entry below, which marks both ambiguous; its sa half by decisions/C8's 2026-10-25 #857 entry, which removes sa from the vocabulary instead.) sa stays where #296's audit put it (title and suffix dual, position decides); se and om stay unambiguous, there being no surname evidence worth standing behind and OM being the Order of Merit; the S2 comma-form boundary ("Smith, Ed" → given Ed) is not reopened. #342's own comment lists sa, se and om beside ba and cha as model recall rather than corpus-attested, and per the #360 lesson they need a human before they move. - **Measurement (2026-09-07).** Five corpus names move, every one from the radar-tier corpus_issues.jsonl and with one cause (Aishwarya Rai, Lala Lajpat Rai, John Smith RAI, John Smith, RAI, Ahmad Jayadi, CHA), measured over the tools/differential/corpus*.jsonl glob against the shipped lexicon with the two entries restored (`Lexicon.default().add(suffix_acronyms={"rai","cha"})`); the ba step moves nothing, and Aishwarya Rai is 1.4.0's own reading (PR #515). - **2026-09-15 (#289/#516) (its `John Smith, XYZ` reading superseded by decisions/S9's 2026-10-01 #564 entry, which reads it suffix by default) — the parking lot is CLOSED, and this entry's "measured 2026-09-07, none of the four is read as a suffix today" sentence is superseded rather than rewritten.** The shape-plus-position heuristic this entry parked is what shipped: the DOTTED shape as `Policy.unlisted_dotted_suffixes`, default on, and the ALL-CAPS shape as `Policy.unlisted_caps_suffixes`, default off. Of the four names the bullet measured, `John Smith X.Y.Z.` and `john smith x.y.z.` now read suffix at the default, and `John Smith XYZ` / `John Smith, XYZ` read suffix only with the opt-in switch. See decisions/S9 for the whole decision and for why the two halves have different defaults. - **2026-09-15 (#289/#516) — the roman-numeral accident is RETIRED, and here is what narrowly means.** This entry's bullet recorded the accident as evidence rather than as behavior anyone relied on. It now fires no longer where every chunk the vocabulary matches is a single ASCII character — measured, `{i, v, 2}` — so `John Smith R.A.I.` and `John Smith X.Y.I.` reach the shape class instead and read by position, which gives the same fields for the first and 1.4.0's family reading for `Jack X.Y.I.`. `John Smith C.H.A.`, the bullet's other half, is unaffected: nothing claimed it before or after. The WIDE retirement was measured and rejected; decisions/S3 records the three names that pay for it. - **2026-09-25 (#540) (its acceptance of the bare `Wang M.Eng.` reading, and its costs for a credential run ending in a member, superseded by 2026-09-27 #544) — meng and lac joined SUFFIX_ACRONYMS_AMBIGUOUS (Derek: the marking over removal, and no masks).** #vocabulary-collisions C-i asked at the position the suffix claim acts on, the last word of a name: Meng (孟) is a common Chinese surname and given name, and Lac (Lạc) is a Vietnamese given name — the trailing word in native order, `Nguyễn Văn Lạc` — and a French surname; both are borne in that slot (`wang meng`, `tran lac`, `nguyen van lac`). MEng (Master of Engineering) and LAc (Licensed Acupuncturist) are live credentials, and neither reading is rare enough beside the other to give it the word — the rough balance this entry's criterion marks (where it is uncertain, C-i's default is the marking too). They are the set's first entries longer than two letters, which is the LENGTH clause of the criterion bullet applied, not an exception to it. Neither was checked against a surname when it arrived: `lac` with the af5bdab import (2019-12-11, #93), `meng` with 3e14ea20 (2026-07-01, the German/Dutch title and degree batch, first shipped in 1.3.0). No rule changes: rules/S2 reads the two words exactly as it reads `ma` and `ba`. Measured 2026-09-25 on this tree, the parent 120033b5 and the five released wheels (each wheel run as the Gotchas in AGENTS.md prescribe, from a directory outside the checkout under `PYTHONSAFEPATH=1`, asserting `nameparser.__file__`). THE FIX: `wang meng`, `li meng` and `tran lac` read given + family with a `suffix-or-name` report, where every release from 2.0.0 read suffix and no family name — reporting nothing at 2.0.0 through 2.2.0, and `given-or-family` at 2.3.0 and the parent, the one-word-name fork, which was the wrong fork — and 1.4.0 read the family, unflagged. The family-comma spelling returns to 1.4.0 with them, `Wang, Meng` reading given `Meng`, family `Wang` where 2.0.0 through 2.3.0 read suffix `Meng`; and under `FAMILY_FIRST` the bare `Wang Meng` reads given `Meng` where 2.0.0 through 2.3.0 and the parent read family `Wang`, suffix `Meng`, so the comma spelling under the default order and the bare one under `FAMILY_FIRST` still agree with each other. WITH WORDS TO SPARE the credential stays: `john smith meng`, `JOHN SMITH MENG` and `nguyen van lac` keep their suffix, now reported, and so does `john smith m.eng.`, which S2's period gate (rules/S3's since 2026-10-19) does not settle, that gate counting one period after each letter. DECIDED (Derek, 2026-09-25): the bare `Wang M.Eng.` reading — family `M.Eng.`, nothing to spare — is accepted and recorded rather than widening the period gate to cover a chunked dotted spelling; S2's period gate counts a member as unambiguous only when it is written one period per letter (`M.A.`), and MEng and LAc are the first members whose conventional dotted spelling is chunked instead. `John Smith M.Eng.` keeps the suffix by the words-to-spare count, unaffected. THE ACCEPTED COST is the one `ma` and `ba` already carry, and it falls on the conventional spellings: in a name written in more than one case S2 reads a member written neither in capitals nor all lower as the name even with words to spare, and MEng and LAc are written exactly that way. `john smith MEng` reads middle `smith`, family `MEng`, where every release from 1.4.0 read suffix `MEng`; `John Smith Meng` and `John Smith LAc` move the same way; and the Title-case `Nguyen Van Lac` reads family `Van Lac` where every release read family `Van`, suffix `Lac` (for this Vietnamese name the gain, the mechanism being the cost's) — only the all-lower `nguyen van lac` keeps its suffix, one case saying nothing. A CREDENTIAL RUN ending in one of them pays the same walk-stopping cost: `Mary Jones PhD MEng` reads middle `Jones PhD`, family `MEng`, and `Jane Doe MS LAc` reads middle `Doe MS`, family `LAc`, where every release read the whole run as a suffix (`PhD, MEng` and `MS, LAc` through 2.2, `PhD MEng` and `MS LAc` in 2.3); `John Smith MEng PhD` reads middle `Smith`, family `MEng`, suffix `PhD`, the unambiguous credential behind the declined pick still peeled; the mechanism is S2's own — the case signal costs a genuine suffix standing behind a name-leaning acronym, the walk stopping at the declined pick rather than continuing past it — pinned by `tests/v2/cases.py`'s `a_declined_ambiguous_pick_stops_the_walk` and, for the family-comma shape, `a_declined_member_ends_the_trailing_run` (`Doe, John MA Ma`). A QUIETER COST sits behind a suffix comma: once the run's last word is a bare ambiguous member, the run is no longer wholly suffix-shaped, so C1 reads the comma as a family comma instead and the run's own words fall into the usual post-comma name slots — `John Smith, PhD MEng` reads given `PhD`, middle `MEng`, family `John Smith`, and `john smith, phd meng` reads given `phd`, family `john smith`, suffix `meng` (the count then peels `meng` with a word to spare); neither case form saves it, `JOHN SMITH, PHD MENG` reading the same way. A title-listed word standing in front of the run reads as a TITLE instead, with no report: `Jane Doe, MS LAc` reads title `MS`, given `LAc`, family `Jane Doe`, unflagged, where every release read suffix `MS LAc`. The path is not new: `john smith, phd ma` and `Jane Doe, MS Ma` already read this way, `ma` having been ambiguous all along. Pinned by `tests/v2/cases.py`'s `comma_credential_run_ending_in_meng_re_reads_the_comma` and `comma_lower_credential_run_ending_in_meng_re_reads_the_comma`. The comma re-read (with its silent title-dual case) and whether a listed member's chunked dotted spelling should pass the period gate are [#544](https://github.com/derek73/python-nameparser/issues/544)'s questions. After a comma, `Smith, MEng` reads given `MEng` (1.4.0's reading; 2.0.0 through 2.3.0 read suffix) — and so does the all-lower `Smith, meng`, a restored reading rather than a cost — and `Smith, John MEng` middle `MEng`, pinned as case rows (`Smith, meng`, `Smith, MEng`, `Smith, John MEng`) and classified by the `fix(#540)` rules, while `John Smith, MEng` keeps the suffix on C1's name-word count (rules/C4 since #728); bracketed, `John Smith (MEng)` reads nickname `MEng` with a `suffix-or-nickname` report, S1's escape declining an ambiguous member, where every release read suffix `MEng`. `John Smith MENG` keeps the credential on the capitals lean. The judgment is the one behind rai and cha: a missed credential leaves the letters in a name field where a human can still see them, and a wrong credential reading destroys a surname on every record it appears in. edd and ded are NOT marked: both are borne as GIVEN names (`Edd Smith`, `Ded Gjo Luli`), which is not the position the suffix claim acts on, and no measurement shows either losing a family name — a trailing bearer turning up reopens them. Recomputed with #vocabulary-collisions' recipe on 2026-09-25: suffix_acronyms 608 (unmoved by this change) with 7 ambiguous, 5 before this change (particles 70 with 37 and titles 758, unmoved). - **Declined 2026-09-25 (#540), with measurement: removing the two words, and masking them.** REMOVAL, the rai/cha answer, measured with `Parser(lexicon=Lexicon.default().remove(suffix_acronyms={"meng", "lac"}, suffix_acronyms_ambiguous={"meng", "lac"}))`: `john smith meng` reads middle `smith`, family `meng`, and `JOHN SMITH MENG` family `MENG`, while `john smith m.eng.` stays a suffix by its dotted shape (S3), reported. Removal loses the bare credential outright where the marking keeps it with words to spare, and C-i's default under uncertainty is the marking. MASKS `meng → MEng` and `lac → LAc`, measured with both added to a private lexicon on top of the marking: the exceptions map is role-free (#R4, 2026-09-23), so `wang meng` renders `Wang MEng`, `tran lac` `Tran LAc` and `meng li` `MEng Li` — the mask re-spells the very surnames the marking restores. A suffix-gated mask would reverse the 2026-09-23 role-free decision for two words and is not taken. Without one, the default path renders `wang meng` as `Wang Meng` (the parent gave `Wang MENG`, the credential clause writing a suffix in capitals), `tran lac` as `Tran Lac`, `john smith meng` as `John Smith MENG` and `nguyen van lac` as `Nguyen Van LAC` by the acronym clause, and keeps a writer's `MEng` as written (#R5, 2026-09-24) — `john smith MEng` gives `john smith MEng`, and `John Smith Meng` under `force=True`, the word being the family name there. Pinned by `tests/v2/test_render.py::test_a_listed_acronym_that_is_a_name_word_gets_no_mask`, whose recorded negative control carries the two masks. - **Measurement (2026-09-25, #540).** No corpus name written before this change carries a bare trailing `meng` or `lac` (the `tools/differential/corpus*.jsonl` glob at 120033b5), so every mover at every baseline is one of this change's own case rows, classified by `fix(#540)` rules (PR #543). - **2026-09-27 (#544) — #540's two pending questions are answered, and the #540 bullet stands as it landed.** The chunked dotted spelling passes S2's period gate (rules/S3's since 2026-10-19) — `Wang M.Eng.` reads suffix 'M.Eng.' as `Wang M.A.` does, and as 2.0.0 through 2.3.0 read it — and the comma is kept: a credential run after a full name that ends in a member is the credential run by C1's name-word count (rules/C4 since #728) (`John Smith, PhD MEng` suffix 'PhD MEng'; `Jane Doe, MS LAc` suffix 'MS LAc' where #540 left title 'MS', given 'LAc', at the cost, accepted in decisions/S7, of `John Smith, Ms Ma` reading suffix 'Ms Ma'). The walk-stopping cost that bullet lists for a run ENDING in a member goes with them, by S2's company clause: `Mary Jones PhD MEng` and `Jane Doe MS LAc` read the whole run as a suffix again. Where the member LEADS the run nothing speaks for it, so `John Smith MEng PhD` keeps its cost. The `fix(#540)` ledger rules for the comma run and the dotted spelling are retired. `fix(#544)` rules classify the comma-run names; `Wang M.Eng.`, whose roles every release from 2.0.0 read, is classified by `feat(#449)`'s given-or-family report rule at 2.0.0 through 2.2.0 and by the dotted `fix(suffix-routing)` rule beside `Jack M.A.` at 1.4.0, and differs from 2.3.0 not at all. The case rows the #540 bullet cites were renamed with the readings they pin: `a_declined_ambiguous_pick_stops_the_walk` is `a_credential_in_front_anchors_a_declined_pick` (`abdul Smith Jr Ma`, suffix 'Jr Ma'), and `comma_credential_run_ending_in_meng_re_reads_the_comma` and `comma_lower_credential_run_ending_in_meng_re_reads_the_comma` are `comma_credential_run_ending_in_meng_keeps_the_suffix_comma` and `comma_lower_credential_run_ending_in_meng_keeps_the_suffix_comma`. The decision and its measurements are decisions/S7's #544 entry. - **2026-10-18 — cva[22], gaee[14] and np[18] left SUFFIX_ACRONYMS: Wikipedia footnote marks, not credentials.** All three arrived with the `af5bdab` bulk import (2019-12-11), the bracketed number being the source page's reference mark copied along with the acronym; the 3.0 vocabulary spec's migration spike found them. They matched only a token written with the mark itself, so `John Smith NP[18]` read suffix `NP[18]` and now reads family `NP[18]`, and nothing else moves: `np` and `cva` have clean entries of their own, and `gaee` never had one (`John Smith GAEE` reads family `GAEE` before and after). Whether GAEE should be added as a clean entry is a separate vetting question, not taken here. A bulk import from a wiki page carries its reference marks: check a new list for brackets before it lands. Recompute that no vocabulary entry holds one: `uv run python -c "from nameparser import Lexicon; L = Lexicon.default(); print(sorted(w for f in ('titles', 'suffix_acronyms', 'suffix_words', 'particles', 'conjunctions', 'bound_given_names', 'maiden_markers') for w in getattr(L, f) if set(w) & set('[]')))"`. - **2026-10-21 — 33 credential readings marked ambiguous (C-i's default), not removed.** The 33 readings are listed by `uv run python tools/vocab/query.py --issue 796`; each carries its Census, Wikidata and SSA counts with an `as_of` date, and an NPPES note. The candidate screen found 58 unflagged credentials with census or Wikidata surname evidence. The batch keeps the words whose surname reading is substantial (US Census 2010 bearers ≥200, or Wikidata family bearers ≥20). That threshold stood in for the credential half of C-i while no provider count existed. Since #798 the NPPES count (US healthcare providers writing the word, August 2026 file) measures that half, with one direction only: a count is evidence a credential is live, while a zero says nothing about a non-healthcare credential. Revised rule, dated 2026-10-21: when both counts are small, compare them and mark where the surname reading is the larger. Words that the rule re-adds: `capa` (surname 175 census / 7 Wikidata family, NPPES 11) and `ams` (135 / 2, NPPES 5). `ei` (127 / 3, NPPES 12; also a Korean given name in nam_dict) rests on C-i's default under uncertainty alone, because EI (usually Engineer Intern) is not a healthcare credential, so NPPES says nothing about it. Pinned by the `robert_capa_is_a_surname` and `all_caps_capa_is_still_a_credential` case rows. The census lookup folds a spelling to its letters (`name_usage.py`), so `pa-c` was counted as the surname `PAC`; it is dropped, since no `Pa-c` surname is measured. The threshold is a judgement, not a rule the data sets, and the held-out words below are the ones it cannot place. Dropped for weak surname evidence (14): `abd`, `ceh`, `fala`, `ches`, `cas`, `cia`, `res`, `obe`, `facha`, `ame`, `usa`, `aia`, `iso`, `rid`. Several of these are recognizable designations, which the threshold does not itself test. Dropped after the NPPES check (US healthcare providers, a live-credential count): `cap` (759 providers against 865 census bearers) and `aas` (528 against 238), where the credential reading is at least as large as the surname reading. Held out (`sa` removed from the vocabulary after all on 2026-10-25, #857, decisions/C8; its `Khun Sa` regression no longer holds, the name reading family 'Sa' now): `am`, `sa`, `pla` (CLDR regressions: Gaelic `am post`, Burmese `Khun Sa`, Spanish `Hernández Pla`); `edd` (`John Smith EdD` reads `EdD` as family under either option); `ra` (also a title: `John Smith Ra.` reads title `Ra.` with no flag); `mba` (its credential reading is the dominant one). `pe` is kept on surname evidence (582 census, 305 Wikidata family) although it is also a designation. Removal, measured on the 53-word set, is the worse answer on real designations: `john smith mba` reads `mba` as family, and `John Smith, MBA` reads `MBA` as a given name, where the flag keeps the suffix and reports the fork. Measured on the tree (30 words): the corpus names that move are re-recorded in `snapshot.jsonl`, with the agreement check unchanged (`Maheen Farooqi, ACA`, `Pèrs, Ciro di`), with 0 agreement regression lines across the CLDR, GND, Wikidata, native and conventions corpora. Every batch member moves bracketed, double-quoted and single-quoted credentials to nickname (rules/S1 is silent on that coupling; decisions/ma-do records it for ma and do). A single trailing period (`John Smith Caro.`) reads family under the flag too. The written case decides: UPPER moves nothing, per S6 before S4. - **Order dependence (measured 2026-10-21, #796).** Recipe as in the #791 entry below (corrected 2026-10-21: this said "above", the entry standing after this one), over the 33 words: 9 templates × 2 name pairs × 3 casings (Title, UPPER, lower) = 1,782 cells per order, marked tree (`Lexicon.default()`) against the comparator `Lexicon.default().remove(suffix_acronyms_ambiguous=…33 words…)`. Moves: `GIVEN_FIRST` 1,122 (Title 594, UPPER 66, lower 462); `FAMILY_FIRST` 1,056 (Title 594, lower 462, UPPER 0); `FAMILY_FIRST_GIVEN_LAST` 1,056 (the same split as `FAMILY_FIRST`). The family-first orders move nothing in UPPER, the case signal (rules/S6) deciding there. Credential inside a multi-word family name (the `John de Smith Chi` class, which these templates do not contain), probed separately with `John de Smith W`, `John de W` and `John van der Smith W` in Title and lower casing, 198 cells: `GIVEN_FIRST` 99 moves, every one reading the credential inside the family (`John de Smith Caro` reads family `de Smith Caro`, with no suffix); `FAMILY_FIRST` and `FAMILY_FIRST_GIVEN_LAST` 99 moves with no credential inside a multi-word family. This is the surname reading the marking is for, and it is the accepted cost of a particle before the word under `GIVEN_FIRST`; it is not checked against 1.4.0 here. - **2026-10-21 (#797) — 36 credentials added where the comma path read them as the given name; the vocabulary half only.** `John Smith, MBChB` gave given 'MBChB' and family 'John Smith' because no list held the word and no shape reads it: C6's caps shape needs an all-capitals word, C5 reads an unlisted two-capital word as paired initials on purpose, and a hyphen or slash puts a word outside the caps shape. Whether an unlisted hyphen- or slash-joined run of capitals should count by shape stays open in #797 beside S9; this entry is the words. **Candidates** came from two sources, and only tokens that reverse `John Smith, X` today were kept. NPPES (`name_usage.py --rank 600 --unlisted`, August 2026 file): at least 2,000 providers, about rank 150, below which the list is fragments of split fields (`BC`, `PC`, `AA`). ClinicalTrials.gov study officials (`local_corpora.py tally ctgov-officials`, data 2026-10-21): at least 100 officials writing the token after a comma as typed. The cutoffs are a judgement; a mixed-case credential with no name evidence costs nothing to add below them, and 17 sit there at 30 to 73 officials (`MCh`, `FRCPath`, `MRCPsych`, `BPharm`, `MEd` and `AuD` among them). **Added, plain** (no Census 2010, SSA or Wikidata bearer above each source's floor, or a credential count far above the name count): `mbchb`, `mbbch`, `dmsc`, `dmsci`, `scd`, `dphil`, `mmed`, `mmsc`, `mphil`, `mhsc`, `scm`, `mdphd`, `dm`, `mb`, `bs`, `ld` (SSA given 45 against 11,703 providers), and 19 joined spellings (`ccc-slp`, `ccc/slp`, `otr/l`, `fnp-c`, `fnp-bc`, `np-c`, `pmhnp-bc`, `cota/l`, `lcsw-c`, `cf-slp`, `agacnp-bc`, `aprn-cnp`, `ccc-a`, `lcsw-r`, `mt-bc`, `cpnp-pc`, `lisw-s`, `whnp-bc`, `rpa-c`). A joined spelling is matched as one token like the listed `pa-c` (`John Smith, OTR/L` and `John Smith OTR/L` both read suffix, checked with a lexicon holding it), so listing them needs no shape rule. **Marked ambiguous**: `pa`, whose surname reading (Census 739, SSA 1,343 given, Wikidata 36 given) is over #796's threshold of 200 and whose NPPES count is 54,128: `John Smith, PA` keeps the credential and reports the fork, `John Pa` reads family 'Pa'. **Refused**: `pi`, Principal Investigator (102 officials after a comma, 8 NPPES providers), a role and not a credential, and a surname (Census 1,006, Wikidata 634 family), with an `excluded` entry carrying the reason. **Held out, not decided here**: `bc` (board certified) and `pc` (professional corporation), which are not credentials; `aa` (1,917 providers, under the cutoff); `pu-ph`, a French hospital-university post; the split spellings (`M. D.`, `Pharm. D.`, `MB ChB`, `MD; PhD`, `MD-PhD`, `MD/PhD`), a Phase 2 shape question; and the non-English titles after a comma (`Pr`, `Profesor`), issue 804. **Case**: the twelve mixed-case ones (`mbchb`, `mbbch`, `dphil`, `scd`, `dmsc`, `dmsci`, `mmed`, `mphil`, `mhsc`, `scm`, `mmsc`, `mdphd`) carry a `cased` mask, as `pharmd` does, so `capitalized()` writes `MBChB` and not `MBCHB`; the joined spellings carry none, since their case repair (`fnp-c` → `Fnp-C`, as the listed `pa-c` already gave `Pa-C`) is a rule question outside this entry (corrected 2026-10-22, #762: wrong — `pa-c` gave `Pa-C` before this change and `PA-C` after it, this entry listing `pa`); pinned by `test_a_mixed_case_credential_listed_for_797_repairs_to_its_mask`. **Accepted cost**: a person whose initials are written `DM`, `MB`, `BS` or `LD` as the whole given name after a comma (`Smith, DM`) now reads the letters as a credential with no given name, as `MS` and `MA` already do. **Measured** (2026-10-21, tree against master fd5ef886; snapshot.jsonl re-recorded, five snapshot entries move, four of them held only by corpus_cldr.jsonl): `snapshot.py --local ctgov-officials`, recorded from the base checkout with `--record --tree` first, moves 5,899 of 352,104 readings; of those, 3,607 move a batch word from given, middle or family into the suffix (`mbchb` 1,122, `mb` 590, `bs` 332, `scd` 306, `dmsc` 237, `dm` 231, `mbbch` 201, `dphil` 184, `mmed` 158, `mdphd` 125, `mphil` 96, the rest under 50), and 1,589 move only the reported ambiguities. The CLDR agreement check moves 8 field disagreements across 3 renderings (2 now agree in full, 1 improved but still disagrees on given_names). The figures are a dated snapshot of one fetch, and the remaining moved readings carry a listed word in a changed part of speech too (a title-to-suffix move for `mdphd` among them) or a punctuated spelling the token count does not match. **Recompute**: `uv run python tools/vocab/query.py --issue 797` lists the entries (the 36 additions and the `pi` exclusion on 2026-10-21); record the local baseline from a master checkout, check the branch, and count the moved readings whose old given, middle or family held a listed word as a token and whose new suffix does. Pinned by the `comma_mixed_case_credential_is_a_suffix`, `comma_slash_joined_credential_is_a_suffix`, `one_word_head_two_capital_credential`, `empty_head_joined_credential`, `comma_pa_keeps_its_suffix_and_reports_the_fork` and `john_pa_is_a_family_name` case rows. - **2026-10-21 (#791) — si and chi joined SUFFIX_ACRONYMS_AMBIGUOUS, the ba/meng answer.** Both entries arrived in the 2019-12-11 bulk import (af5bdab, #93) with no expansion, and neither was checked against a surname. C-i's criterion, asked of the trailing position: Si is a common surname (`tools/name_usage.py Si`, measured 2026-10-21: US Census 2010 1,166 family-name bearers, rank 22,158; Wikidata 275+ family, 124+ given) and a Vietnamese and Chinese given name; Chi is more common still (Census 2010 7,273, rank 4,843; Wikidata 219+ family, 123+ given) and a Vietnamese given name. No credential expansion is recorded for either, so the surname side is the only one measured. The marking is C-i's default under uncertainty, and it does not destroy a surname on a guess: with a full name in front, the credential reading survives by the words-to-spare count (`John Smith SI` reads suffix `SI`, flagged), and a bare or comma-form one reads the surname or the given name with the fork reported. Removal, the rai/cha answer, was the alternative and was not taken. The criterion weighs name frequency against credential frequency, and no credential expansion is recorded for either word, so the measurement that would reverse this is an expansion for SI or CHI showing a credential reading as common as the surname; until then C-i's default under uncertainty decides. The case rows are `bare_si_is_a_surname`, `bare_chi_is_a_surname`, `comma_si_is_a_given_name`, `comma_chi_is_a_given_name`, `capital_si_is_a_suffix_when_a_family_name_remains`, `comma_capital_si_is_a_suffix`, `dotted_si_is_a_suffix_without_a_flag`, `title_case_si_is_a_surname_with_words_to_spare`, `period_si_after_a_full_name_is_a_surname` and `credential_run_led_by_a_declined_chi`, and the review's `empty_head_si_is_a_given_name`, `empty_head_caps_si_is_a_given_name`, `nickname_head_si_is_a_given_name`, `capital_si_after_a_surname_is_a_suffix`, `comma_capital_si_is_a_suffix`, `family_first_bare_si_is_a_given_name` and `family_first_comma_si_is_a_given_name` (`tests/v2/cases.py`, #791 block), which pin the fork, not the wordlist. **Precedence, stated because the words-to-spare count is not the only guard.** In a name written in more than one case, a Title-case member is the name even with words to spare: S6 (the case signal at the suffix slot) decides before S4 (the count), so `John Smith Si` reads family `Si` and only the all-capital `John Smith SI` keeps the credential. The same holds for a trailing period: one period is the abbreviation shape any word wears (rules/S3), so `John Smith Si.` reads family `Si.`, the way `John Smith Ma.` does, and the dotted `John Smith S.I.` (one period per letter) stays a suffix with no flag. **Accepted costs, measured on this tree, the pre-change tree (the marking removed from the ambiguous set) and the 1.4.0 and 2.0.0 and 2.5.0 wheels.** `John Smith Si` and `John Smith Si.` give family `Si`/`Si.` with middle `Smith`, where 1.4.0 gave suffix. `John Smith Chi PhD` gives family `Chi`, suffix `PhD`, the walk stopping at the declined pick as #540 records for a declined member; 1.4.0 gave suffix `Chi, PhD`. `John Smith (Chi)` gives nickname `Chi` under the suffix-or-nickname report, S1's escape declining an ambiguous member; 1.4.0 gave suffix `Chi`. `Smith, John Chi` gives middle `Chi`, flagged; 1.4.0 and the pre-change tree both gave suffix `Chi`. `John, Si.` reads title `Si.`, family `John` (flagged): 2.0.0 read the same, 1.4.0 read title `Si.` with first `John`, and the pre-change tree read suffix `Si.`, so this one moves back toward older readings. `John CHI, PhD` gives given `John`, suffix `CHI, PhD`, flagged, where 1.4.0 gave last `CHI`; this is the one-word-before-a-comma shape `John MA, PhD` already takes, and the caps lean reads the same way. Three moves go back to 1.4.0's reading and are not costs: `Smith, Chi Jr.` gives given `Chi`, suffix `Jr.` (the pre-change tree and 2.5.0 read suffix `Chi Jr.`); `Wang, Si Ming` gives given `Si`, middle `Ming` (the pre-change tree read suffix `Si Ming`); and `Smith, Chi, PhD` gives given `Chi`, suffix `PhD`. `Jane Doe, PhD Chi` is unchanged and flagged. **Order dependence (measured 2026-10-21).** Recipe, re-runnable: the marked tree is `Parser(policy=Policy(name_order=O))`; the comparator is the same parser with `Lexicon.default().remove(suffix_acronyms_ambiguous={"si", "chi"})`, which restores the pre-change readings. Each cell parses one text and counts it when the two `as_dict()` results differ. The texts: 9 templates (`{f} {w}`, `{f} {m} {w}`, `{f}, {w}`, `{f} {w} PhD`, `{f} {w}, PhD`, `{f}, {w}, PhD`, `{f} {w} Jr`, `Dr. {f} {w}`, `{f} {m} {w} Jr.`) × 2 name pairs (`John` with `Smith` as the middle, `Lu` with `Wei`) × 8 credentials (`Si`, `SI`, `Chi`, `CHI`, `si`, `chi`, `Si.`, `Chi.`), 144 cells per order, over `GIVEN_FIRST`, `FAMILY_FIRST` and `FAMILY_FIRST_GIVEN_LAST`. Counts: 104, 100 and 100. Under the family-first orders the moved word takes the given name (`John Si` reads given `Si`, family `John`, where it read suffix `Si`): 76 of 100 moves under `FAMILY_FIRST` and 92 of 100 under `FAMILY_FIRST_GIVEN_LAST`, the rest the middle name or the title. Under `GIVEN_FIRST` most moves take the family name (76 of 104), 16 take the given name, 8 the title, and 4 stay suffix. No moved word is dropped; each lands in an existing field. A different grid (a second reviewer's, with other names and templates) gives other totals, so these counts hold for this recipe only. The review also found a default-order move this grid does not contain: `John de Smith Chi` reads given `John`, family `de Smith Chi`, where the pre-change tree read family `de Smith` and suffix `Chi`. The particle chain takes the credential into the family name, since it reads everything after the particle as family; this is the one shape in this entry where the credential ends up inside a family name rather than beside it. **Measurement.** No real corpus name holds `si` or `chi` as a word (`tools/differential/corpus*.jsonl`), so the snapshot has no move to read: the four rows the change adds to `corpus_shapes.jsonl` (`Luo Si`, `Min Chi`, `Wang, Si`, `Smith, Chi`) are new entries, not moves, and they are the only `si` or `chi` names the snapshot holds. The CLDR agreement record did not move. The JA view of `cldr_agreement.py` is unmeasured locally (namedivider is absent here) and reads only names this change does not touch; CI's ja-extra job runs it. **Known report mismatch, not fixed here.** (Fixed by #793 the same day, in the entry below.) `John, Si.` reports `suffix-or-name` with the text "read as a credential" while its token lands in the title field. Measured 2026-10-21: `John, Ma.`, `John, Ba.`, `John, Meng.` and `John, Lac.` read title with the same `suffix-or-name` report, so this is the existing marked-member class; the report for `Meng.` and `Lac.` carries the same wording as for `Si.`, `Ma.` and `Ba.` (checked 2026-10-21 in review). The field-naming test (`test_a_report_names_the_field_its_word_lands_in`) cannot take the `John, Si.` row until the wording is fixed. Filed as #793. - **2026-10-21 (#793) — the comma report names the field its word was bound to.** The listed-member report after a family comma (`_comma.decide`'s binding, rules/C11) said "read as a credential" whatever role the binding gave the word, and the listing form binds a member the reading declines as a title, so `John, Si.`, `John, Ma.`, `John, Ba.`, `John, Meng.` and `John, Lac.` were told 'read as a credential' of a word in the title field. The report now says "read as a title" there and "read as a credential" where the word is bound as a suffix (`John, MA`). No reading moves: the title reading itself is the comma-family class `Smith, Dr.` reads in (1.4.0 read title `Si.` with first `John`). Measured on a grid of 7 heads (empty, `John`, `(Bob)`, `John Smith`, `de la Vega`, `Ortega y Gasset`, `Smith Jr.`) × 10 spellings (`Si.`, `Ma.`, `Ba.`, `Meng.`, `Lac.`, `Chi.`, `Si`, `MA`, `M.A.`, `SI.`) × 7 tails (none, ` Jr.`, ` PhD`, ` Dr.`, ` Jane`, ` Ma.`, ` V`) × the three comma spacings × the three orders, checking every report's field claim against `test_cases.py`'s `_FIELD_CLAIM`: 1,476 of 4,140 claims named the wrong field before, 0 after. `John, Si.` and `John, Ma.` are case rows the field test now checks. - **2026-10-23 (#848) — `ing` is an ambiguous credential.** ClinicalTrials.gov officials write the bare `Ing` after a comma as a credential (52 of 536,981 write it, 43 after a comma; `tools/name_usage.py`, data 2026-10-21), and while it was unlisted the comma read it as the given name: `Viera Sevcikova, Ing` gave given 'Ing', family 'Viera Sevcikova', on 1.4.0 and 2.5.0 alike (wheels, measured 2026-10-23). #840 had refused it as a title, since a title claims the word in front of a name and `Ing` is borne there (`Ing Smith`); a credential claims it only at the end, and it is marked ambiguous on the 200-bearer threshold of the 2026-10-21 entries (US Census 2010 1,814 family, Wikidata 13+ family and 10+ given, nam_dict band 5 in Sweden; NPPES 0, the degree being outside US healthcare). So `John Ing` and `Wang Ing` keep the family name and `HO, ING` the given name, each now reporting the fork. It carries the `cased` mask `Ing`, which is also the name's own title case, so `capitalized()` recases no word differently (`JOHN ING` repairs to 'John Ing'), though a word the change moves between fields moves the rendering's order, as any role move does (`john smith, ing`: 'Ing John Smith' → 'John Smith Ing'); `capitalized(force=True)`, which recases every word, now writes the credential in capitals as 'Ing' too (`John Smith, ING` → 'John Smith Ing', where it kept 'ING'). **Accepted cost**: the period-marked `Ing.` is the listed credential too, an acronym being matched with its periods removed, so after a part of two or more name words and a comma it moves from title to suffix (`Viera Sevcikova, Ing.` → suffix 'Ing.'; 2.5.0 gave title 'Ing.', 2.0.0 title 'Ing.' with family 'Viera Sevcikova'), while `Mgr.`, `MUDr.`, `Bc.` and `Dipl.-Ing.` stay titles there. Written in Title case or lower case, after a one-word or empty head, with a given name behind it, or beside another title with no comma between, it stays a title and now reports the fork (`Novak, Ing.`, `, Ing.`, `Novak, Ing. Jan`, `Jan Novak, Ing. Mgr.` → title, each with a suffix-or-name report naming the title). Written in capitals in a name that is not, `ING.` leans to the credential as `MA.` and `PA.` do (rules/S6): it is the suffix after a one-word head and beside another title too (`Novak, ING.` → suffix 'ING.'; `Jan Novak, ING. Mgr.` → title 'Mgr.', suffix 'ING.'), and stays a title only with a given name behind it (`Novak, ING. Jan`) or in a name written wholly in capitals (`NOVAK, ING.`, `JAN NOVAK, ING. MGR.`). Czech writes all of them before the name, so title is the field Czech usage puts `Ing.` in, and a comma-separated list holding two is split across fields (`Martin Hulin, Ing., Mgr., PhD.` → title 'Mgr.', suffix 'Ing., PhD.'). The dotted initials `I.N.G.` are read as the credential with no report, as the dotted spellings of the other ambiguous members are, so a name spelled in them is lost wherever they stand last: `Smith, I.N.G.` gives suffix 'I.N.G.' and no given name (master: given 'I.N.G.', reported), `John I.N.G.` no family name, `Mary I.N.G., PhD` family 'Mary', and `John Smith nee I.N.G.` middle 'Smith', family 'nee', suffix 'I.N.G.' (master: family 'Smith', maiden 'I.N.G.'); `John M.A.`, `Mary M.A., PhD` and `John Smith nee M.A.` read the same way already. The `Ing.` move is accepted (Derek, 2026-10-23) because given and family stay right in every name it moves, where the bare form's reversal had swapped them, and keeping the dotted spelling a title would need a rule that an ambiguous credential's period-marked spelling reads as a title after a comma, machinery for one word. The `I.N.G.` cost is accepted too (Derek, 2026-10-23), as the dotted-spelling class `M.A.` already pays. **Out of scope**: `Jana Bartáková, Ing et PhD` (a connective inside the credential run) and `Jiří Cihlář, Prof Ing` (a credential behind a title), which read as before. **Measured** (2026-10-23, tree against master 332f24a8, `snapshot.py --local ctgov-officials` recorded from the base tree with `--record --tree .` first): 22 of 352,104 readings move; 7 bare-`Ing` names stop reversing (`Daniela Ponce, Ing`, `R Wang, Ing, PhD`, `Julian V Kool van Langenberghe, ing`), 10 dotted names move `Ing.` from title to suffix with given and family unchanged, and 5 gain only the `suffix-or-name` report (`Jeffrey J Ing, MD`, which keeps family 'Ing'). The same run over `fec-individuals`, `psc-individuals` and `tse-donors` moves 1, 13 and 0 readings, each only gaining that report. The committed corpora hold no `Ing` after a comma, so `snapshot.jsonl` does not move. **Recompute**: `uv run python tools/vocab/query.py --issue 848` lists the entry; record the local baseline from a master checkout, check the branch, and read the moves. Pinned by the `comma_ing_is_a_credential`, `john_ing_is_a_family_name`, `family_comma_ing_is_a_given_name`, `comma_dotted_ing_beside_a_title_splits`, `one_word_head_dotted_ing_stays_a_title`, `one_word_head_capitals_ing_is_a_credential` and `comma_dotted_initials_ing_are_a_credential` case rows. derek73-python-nameparser-456b024/docs/design/decisions/suffix-field-composition.md000066400000000000000000000064731527303271700303520ustar00rootroot00000000000000# suffix-field-composition — three kinds of thing in one field ## Standing - `suffix` stays one field holding generational words, credentials and postnominal honorifics; the composition is recorded, with the command that re-counts it, instead of splitting the field — 2026-08-16 #326. Elsewhere: generation and credential are separate kinds in the vocabulary, though they still compile into the one field, decisions/vocabulary-model's 2026-10-15/16 entry (item 3). Open: [#326](https://github.com/derek73/python-nameparser/issues/326) — whether `suffix` splits into generation and credentials; whether the honorifics belong in `suffix` at all is deliberately undecided here. ## Log - 2026-08-16 (suffix keystone; #326) — measured composition of suffix_words (40 entries): 11 generational (i, ii, iii, iv, v, jr, jnr, sr, snr, junior, 2), 5 neither (dr, esq, esquire, ret, vet), and 24 POSTNOMINAL HONORIFICS — 20 CJK (さん, さま, くん, ちゃん, 様, 殿, 氏, 先生, 博士, 教授, 女士, 小姐, 씨, 양, 군, 님, 박사, 박사님, 교수님, 선생님) and 4 Hebrew (ז"ל, ז״ל, שליט"א, שליט״א). The honorifics are the LARGEST group. - Decision: do NOT split the field. Record the composition; #296, #291, #325 and #289 proceed on their own terms rather than waiting on it. - Why #326 cannot be taken at face value: it argues the split is tractable because "the vocabulary is already split — suffix_acronyms is credentials, entirely; suffix_words is generational plus a handful". That was true when written (2026-08-02); the 2.1.0 East Asian work landed 2026-08-07 and put the 24 honorifics in the same set. CLDR's two buckets do not cover what the set now holds, so "adopt CLDR's model" is not available as the cheap answer. The issue's table is corrected on the issue. - The unexamined question this leaves: whether the honorific bucket belongs in `suffix` at all. rules/W3 already argues an honorific is "no part of the name on either side" — the same language the H section uses for prenominal titles — so the conflation with PhD may be worse than the generation/credentials one #326 was filed about. Deliberately not decided here. - Recorded as a documentation failure mode, not just a fact: `suffix` started generational, absorbed credentials, then absorbed postnominal honorifics, each step locally reasonable and none recorded as a widening of the field's MEANING. rules/S2's Background still calls it "two different things", which was accurate when written. The rules doc pinned the behavior faithfully; what slipped is the field's definition, which no rule states because no rule owns it. Field definitions need the same discipline rule statements get. - The 11/5/24 split above is this entry's own version of the hazard it describes: a quoted vocabulary composition, dated, exactly as #326 quoted one. A date does not stop rot — #326 carried one too — so before relying on the split, check the set still looks like it: uv run python -c "from nameparser import Parser; print(sorted(Parser().lexicon.suffix_words))" What is durable here is the SHAPE of the finding — three kinds of thing, the honorifics the largest — not the three integers. A test asserting the counts is deliberately not the answer: that is the constant-content pattern, and it would fail on every legitimate vocabulary addition. derek73-python-nameparser-456b024/docs/design/decisions/two-input-invariants.md000066400000000000000000000105721527303271700275410ustar00rootroot00000000000000# two-input-invariants — checking a rule against something that is not the code ## Standing - A rule is checked against an invariant relating two DIFFERENT inputs, which consults no rule statement, so it can find a rule that is wrong (docs/design/AGENTS.md's axis 11) — 2026-08-31 #473. - The worked invariant: `Family, Given Particle` under the default order and `Family Given Particle` under FAMILY_FIRST parse alike for every particle but `do`, `mc` and `vd`, the particle-and-suffix words, the families and givens being drawn from outside the particle vocabulary; `tests/v2/test_order_correspondence.py` holds it, asserting the disagreeing set is exactly those three — 2026-08-31 #473. Elsewhere: why #473's "0 of 216 → 216 of 216" figure cannot be trusted is decisions/P6's. Open: none in this file. ## Log - 2026-08-31 (#473; earned by #461 and #466) — AGENTS.md axis 11. Axes 1–10 compare a document to another document, to its own examples, or to the implementation, so all ten can only find code that disagrees with a rule; none can find a rule that is wrong. #461 was backed out and #466 closed unmerged for exactly that reason, a month apart, each after a review round that found real defects and missed the premise beneath them. An invariant relating two DIFFERENT inputs consults no rule statement and is not blind that way. - The worked invariant: for a name written `Family Given Particle`, the family-comma writing under the default order and the comma-less writing under FAMILY_FIRST must parse alike. Measured 2026-08-31 on 9f1d46e, comparing the non-empty role fields over `nameparser.config.particles.PARTICLES` × six families × six givens: it holds on all 36 pairs for 67 of the 70 particles, and fails on all 36 for `do`, `mc` and `vd` — precisely the words that are also suffix vocabulary, which rules/P6's Accepted consequence already names as the contested memberships. The invariant found that set from behavior alone, having read no rule. - Recompute: for each P in PARTICLES compare the non-empty role fields of `Parser().parse(f"{F}, {G} {P}")` against `Parser(policy=Policy(name_order=FAMILY_FIRST)).parse(f"{F} {G} {P}")` across any spread of families and givens that are not THEMSELVES particle vocabulary. That restriction is load-bearing, not hygiene: with F or G drawn from PARTICLES the correspondence fails for every particle, because the spread word joins the particle run and the invariant is then measuring a different shape (`Doe, Van de` gives given "Van" middle "de"; `Doe Van de` under FAMILY_FIRST gives given "Van de"). Measured, that is 0 of 70 rather than 67 of 70, so a reviewer who picks a particle word as a family — a natural choice when probing particle behavior — would report P6 broken. The argument survives the digits moving: what carries it is that the failures partition onto the particle-and-suffix words, not how many pairs there are. Declined: #473's "0 of 216 → 216 of 216" framing — but not re-argued here, because decisions/P6 already records that number's provenance (a script slicing `sorted(particles)[:14]`, which excluded exactly do, mc and vd), already names it axis 2 in its purest form, and already closes "Recompute over the WHOLE vocabulary, never a sample." Cite that entry for why the figure cannot be trusted; a second account of it in this file is the duplication decisions/review-agent-single-source was written to stop. #473's companion "72 of 216 — the ambiguous-particle half" measures a DIFFERENT partition of the vocabulary from the one above and must not be read as agreeing with it: the ambiguous particles are the 37 that are not never-given, and decisions/P6 records that #466 failed because a never-given test at the new site would have excluded all 37, `van` among them — not because of the three particle-and-suffix words. Two partitions, two different failures. Resolved: #469's invariant landed as `tests/v2/test_order_correspondence.py` — a generated parametrized sweep over a small deliberately-forked vocabulary (never-given/ambiguous/multi-token particles, an ASCII and a non-ASCII family, doubled middles), plus a second, non-parametrized test sweeping the entire shipped particle vocabulary (`nameparser.config.particles.PARTICLES`) in the minimal pair and asserting the disagreeing set is exactly `{do, mc, vd}` — the negative control this entry's Recompute bullet describes, now executable rather than a recipe to rerun by hand. derek73-python-nameparser-456b024/docs/design/decisions/v1-xfail-triage.md000066400000000000000000000320571527303271700263210ustar00rootroot00000000000000# v1-xfail-triage — the eight inherited aspirations, four retired and four kept (three of those eight since fixed, 2026-09-08 and 2026-09-23) The reconciled v1-style banks (`tests/test_*.py`) carried eight `@pytest.mark.xfail` tests inherited from v1, each an aspiration nobody had judged since. `xfail_strict = true`, so all eight were live claims that 2.0 still does not do the thing — but a bare marker says only that, never whether the thing is WANTED, and that is the gap this triage closes. After it, every surviving marker cites an issue and every retired one is a deliberate pin of current behavior. The parses quoted below are this entry's evidence and its own recompute: uv run python -c "from nameparser import HumanName; [print(repr(s), repr(HumanName(s))) for s in ('Maier, Amy I, Jr.', 'Dr King Jr', 'King, Dr Jr', 'Ahmad ben Husain', \"The Right Hon. the President of the Queen's Bench Division\")]" ## Standing - A trailing `I` before a further comma stays a middle initial, the commoner reading: `Maier, Amy I, Jr.` reads middle 'I', suffix 'Jr.', where `Maier, Amy I` reads suffix 'I' (rules/C9) — 2026-09-01 (Derek's triage) - `Dr King Jr` reads title 'Dr', family 'King', suffix 'Jr', and `King, Dr Jr` reads the same fields, with no report since `king` became a given name outside a title phrase (`Dr Pope Jr` keeps H3's floor and its report) — 2026-09-01 (Derek's triage); 2026-10-26 #752, phase 2j - `ben` stays out of the particles, colliding with the given name Ben where the particle claim acts: `Ahmad ben Husain` reads middle 'ben', family 'Husain' — 2026-09-01 (Derek's triage) - An input that is all titles takes its last title word as the name, a convention accepted rather than defended: `The Right Hon. the President of the Queen's Bench Division` reads family 'Division' — 2026-09-01 (Derek's triage) - The two surviving markers are #490's, cited by issue: `E.T. Smith` and `U.S. District Judge Marc Thomas Treadwell`, dotted initials against dotted vocabulary; #489's `Her Majesty Queen Elizabeth` (given 'Elizabeth') was fixed on 2026-09-08 — 2026-09-01 - `juan garcia III` repairs to `Juan Garcia III` and its test is an ordinary pin — 2026-09-23 #492. - #485's `xfail:` corpus-label prefix is not adopted: a surviving marker carries its issue number, and a decided reading moving is a classified change — 2026-09-01 #485. Elsewhere: the `title-or-name` report on the word the title peel leaves standing (`Dr Pope Jr`, `The Right Hon. … Division`) is decisions/H4's 2026-09-07 #491 entry, which reached `Dr King Jr` from decisions/H3's 2026-09-08 #316 floor until `king` left the title vocabulary on 2026-10-26. Open: [#490](https://github.com/derek73/python-nameparser/issues/490) — dotted initials against dotted title and credential vocabulary. ## Log - 2026-09-01 (Derek's triage) — NOT FIXED, four. The aspiration is rejected, the marker is gone, and the test now pins what the parser does with the grounds recorded beside it so nobody re-derives the rejected proposal: - `Maier, Amy I, Jr.` — v1 wanted suffix "I, Jr."; it reads given Amy, middle I, family Maier, suffix "Jr.". A middle initial `I` is far more common than an ordinal I borne without a Sr./Jr.-style companion, so where an explicit suffix comma has already named the suffix, the trailing I stays a middle initial. That is #vocabulary-collisions' commonality reasoning applied to a SHAPE rather than to a word — the criterion is written per-word, and the extension is named here rather than smuggled in. **This amends rules/C1 (rules/C9 since #728) in the same diff.** C1's one-character clause said a bare single letter behind a name word is the generation, full stop, which predicts exactly the suffix "I, Jr." being rejected here. The discriminator the parser has always applied and C1 omitted is the FURTHER COMMA: `Maier, Amy I` reads suffix 'I' while `Maier, Amy I, Jr.` reads middle 'I', and `Smith, John V` against `Smith, John V, Jr.` is the same pair on a name C1 already exemplifies — all four measured. So the rule was incomplete, not the behavior: a rules.md defect rather than a parser one, which is the call this file already made for `'Donald mc'` in the fields-only arc entry (2026-08-27, #451). The qualifier is normative prose carrying no example line of its own; C1's Accepted block (rules/C9's since #728) records why and names the bank pair as its executable witness. - `Dr King Jr` — **FIXED 2026-09-08 (the #316/#489 bundle), and this is the one bullet of the four that moved out of NOT FIXED.** v1 wanted title 'Dr', family 'King', suffix 'Jr', and that is what the parse gives; the leading title peel now leaves a name word a suffix cannot be (#H3). The bullet is amended in place rather than moved, because the half it DECIDED is untouched and is still the reason the shape exists at all. What follows is the 2026-09-01 record, with the reading it describes corrected where the fix falsified it. It read title 'Dr King', family 'Jr' then. `king` stays in the titles vocabulary: it is there for the addressing forms ("King Charles"), and taking it out to serve the surname reading trades a common use for a rarer one, which is the direction #vocabulary-collisions cuts. TITLES has no ambiguous subset and no AmbiguityKind, so — as with MAIDEN_MARKERS and `roz` — the only two expressions of the criterion available here are ship and do not ship; #348 is the open work that would give this set a third answer. The comma format is the road to the surname reading, and is now pinned alongside rather than left as prose — `King, Dr Jr` reads title 'Dr', family 'King', suffix 'Jr'. The test cites [#27](https://github.com/derek73/python-nameparser/issues/27), which is closed; this is the decision it never got. Two halves, and only one was decided. DECIDED: `king` stays in TITLES, which is unchanged and is what still puts `King` inside the title run in the first place. RECORDED, not endorsed: what becomes of the leftover `Jr`. rules/S2 predicts suffix 'Jr' with an empty family — its Accepted clause consumes an unambiguous suffix even when nothing is left to be the family (`Smith Jr.` → family "") — but once the title chain had taken `Dr King`, H1 claimed the one remaining word and it read family 'Jr', suffix ''. `Dr Smith Jr` isolated the cause: family 'Smith', suffix 'Jr', exactly as S2 states. S2 carried a descriptive note saying so. This entry then said that a future change moving `Dr King Jr` toward S2's prediction is an IMPROVEMENT and updates the pin — that change is #H3's peel floor and it landed 2026-09-08. The reading is now title 'Dr', family 'King', suffix 'Jr': the run gives back its last word where everything behind it is post-nominal and that word is not itself suffix vocabulary, so `Dr King Jr` reads like `Dr Smith Jr` rather than against it, and S2's note is replaced by the argument it asked for. The pin in `tests/test_suffixes.py::test_king` moved with it, by decision rather than by drift, and `king` being title vocabulary now buys the row a `title-or-name` report (#H4) rather than a different reading. - `Ahmad ben Husain` — v1 wanted family "ben Husain"; it reads given Ahmad, middle ben, family Husain. Already decided in v0.2.5, when `ben` came out of the prefixes, and for the reason that still holds: `ben` collides with the given name Ben, in the position the particle claim would act on — `Ahmad Ben Husain` reads middle 'Ben' today, which is exactly the token a forward-joining particle claim would take. That is C-i's position test, and it keeps `ben` out. Recorded a second time as a standing keep-out where a wordlist sweep meets it, because a keep-out that lives only in a triage entry is one the next Arabic/Hebrew patronymic-particle sweep never reads: first in the particle-line Excluded block in decisions/vocabulary-exclusions, and since #749 as data, an `excluded` entry on `ben` in nameparser/vocab/ar.toml (`uv run python tools/vocab/query.py --word ben`), so the sweep meets it as a build error. Worth naming as a failure mode of its own — the marker was an aspiration that outlived its own resolution, and nothing in a bare xfail says which of the eight were like that. - `The Right Hon. the President of the Queen's Bench Division` — v1 wanted the whole string as one title; it reads title "The Right Hon. the President of the Queen's Bench", family 'Division'. This is a name parser, not a title parser: handed an input that is all titles it assumes the last title-word is the name. Accepted as convention rather than defended as correct — and since 2026-09-07 the guess is no longer silent: rules/H4 states the convention and the parse reports `title-or-name`, which is what [#491](https://github.com/derek73/python-nameparser/issues/491) asked for and is a report rather than a change of reading. See decisions/H4. - 2026-09-01 — FIX CANDIDATES, four. Each carries its issue, so `pytest -rx` names the work instead of listing anonymous aspirations. Two markers remain, both #490's (re-counted 2026-09-23, #492); #489 was fixed 2026-09-08 and #492 on 2026-09-23 and their markers are gone, which is why this line no longer says the marker stays: - [#489](https://github.com/derek73/python-nameparser/issues/489) — **FIXED 2026-09-08 (the #316/#489 bundle).** `Her Majesty Queen Elizabeth` should address by given name (`tests/test_conjunctions.py::test_conjunction_in_an_address_with_a_first_name_title`), and it does: a title RUN addresses as its LAST title does, `queen` being a given-name title (#H1). The xfail marker is removed and the test is an ordinary passing pin. - [#490](https://github.com/derek73/python-nameparser/issues/490) — `E.T. Smith` (`tests/test_conjunctions.py::test_two_initials_conflict_with_conjunction`) and `U.S. District Judge Marc Thomas Treadwell` (`tests/test_titles.py::test_chained_title_first_name_title_is_initials`). One issue for two tests deliberately: each test's own comment names the other's shape as what blocks a fix — dotted initials against dotted title and credential vocabulary — so they are one question, and fixing either alone is what has failed before. - [#492](https://github.com/derek73/python-nameparser/issues/492) — `capitalize()` leaves `juan garcia III` lowercase (`tests/test_capitalization.py::test_capitalization_exception_for_already_capitalized_III_KNOWN_FAILURE`). The `_KNOWN_FAILURE` suffix is kept: it is still true, and beside the annotated reason it reads as redundant rather than misleading. - 2026-09-23 [#492](https://github.com/derek73/python-nameparser/issues/492) — FIXED (the #459/#492/#478 bundle): `juan garcia III` repairs to `Juan Garcia III`, R5's gate leaving the suffixes out (decisions/R5, 2026-09-23). The marker is gone and the test is renamed `tests/test_capitalization.py::test_capitalization_exception_for_already_capitalized_III`, the `_KNOWN_FAILURE` suffix the bullet above kept being no longer true. Two markers remain, both #490's, and the marker counts above — this entry's heading, the FIX CANDIDATES line and the #485 bullet — were amended in place on this date, following the #489 fix's precedent. `tools/differential/corpus.jsonl` still carries the old test name as a label: that file is v1's own suite harvested at a pinned ref (tools/differential/README.md), so the label names where the row came from and is left as it is. - 2026-09-01 #485 — #485'S `xfail:` LABEL PREFIX IS NOT ADOPTED: the bullet below, written in this triage. - [#485](https://github.com/derek73/python-nameparser/issues/485) is superseded. It proposed prefixing corpus labels with `xfail:` so a radar diff on a name like `Dr King Jr` reads as a known-bad parse improving rather than a regression. The premise was that a v1 xfail marker is a usable triage signal; after this triage it is not, because the marker no longer tracks the disposition — `Dr King Jr` is now a PIN, so a radar diff on it means a decided reading moved, which is precisely the signal the prefix would have suppressed. The four that remain carry issue numbers, which is the same information in a place that cannot go stale against the pinned historical ref. The 2026-09-08 fix STRENGTHENS this rather than unsettling it, and the argument is worth stating because the fix looks at first like the counterexample: `Dr King Jr` moved, so a diff on it is exactly what #485's prefix predicted — but it moved by DECISION, with a ledger rule and a rewritten pin, which is a classified change and not a known-bad parse quietly improving. Had the prefix shipped, that diff would have read as the aspiration finally coming true and nobody would have been asked to classify it. Two markers remain, both #490's (re-counted 2026-09-23, #492), and they still carry issue numbers. - 2026-10-26 #752, phase 2j (Derek) — `king` LEAVES THE TITLE VOCABULARY. SUPERSEDES: in 2026-09-01 (Derek's triage), "`king` stays in TITLES for the addressing forms". `king` gains a `given` reading and is a title only inside a title phrase (decisions/H9's entry of this date); `Dr King Jr` and `King, Dr Jr` keep the fields this triage pinned, the first losing its `title-or-name` report, and the triage's tests and examples that used `king` as title vocabulary use `pope` now, which reads as `king` did on master in every one of them. derek73-python-nameparser-456b024/docs/design/decisions/vocabulary-collisions.md000066400000000000000000000336771527303271700277550ustar00rootroot00000000000000# vocabulary-collisions — when a word earns the ambiguous marking The mechanism shipped twice before anyone wrote down its criterion. Sizes as of 2.2.0dev: particles 67 with 39 ambiguous (58%, PARTICLE_OR_GIVEN); suffix_acronyms 613 with 4 ambiguous (0.65%, SUFFIX_OR_NAME); titles 711 with no ambiguous subset and no AmbiguityKind at all. Those counts are evidence from the decision date, not live facts — they drift with every vocabulary addition. The argument does not depend on the digits (it depends on the two shares differing by orders of magnitude), but recompute before quoting them: uv run python -c "from nameparser import Parser; L=Parser().lexicon; print({s: len(getattr(L,s)) for s in ('particles','particles_ambiguous','suffix_words','suffix_acronyms','suffix_acronyms_ambiguous','titles')})" Recomputed 2026-09-07 with that recipe, after this section's own #342 decision landed: particles 70 with 37 ambiguous, suffix_acronyms 610 with 5 ambiguous, titles 758. All THREE of the snapshot's figures were audited against the released 2.2.0 wheel, not the acronym one alone — run the recipe under a PEP 723 script pinning nameparser==2.2.0, from a directory outside the checkout so the tree cannot shadow the wheel, and it reports particles 70 with 37 ambiguous, suffix_acronyms 612 and titles 706. So the snapshot's particles and its titles were off at their own date exactly as its 613 was, and the one that matters to a reader subtracting the two rows is the particles: they have NOT moved since 2.2.0, where "67 with 39" against today's "70 with 37" reads as three additions and two demotions that never happened. What did move: rai and cha left the acronyms and ba joined the ambiguous subset (#suffix-acronym-collisions), and the titles gained 52 over the wheel. The snapshot above stays as written, being evidence from its own date; the argument survives either set of digits, since the two shares still differ by orders of magnitude. ## Standing - C-i: whether a word is marked ambiguous turns on whether it is borne as an ordinary name in the position the vocabulary claim acts on, a bearer elsewhere not counting (`Bimal De` leaves `de` never-given, the surname being trailing), and under uncertainty it is marked ambiguous; for the particles being borne there earns the marking, while for the suffix acronyms it only opens the question, which relative frequency answers by removal, marking or leaving the word (decisions/suffix-acronym-collisions) — 2026-08-16 #348/#360/#342/#385; 2026-08-17. - C-i applies to particles, suffix acronyms and titles alike, with one exception for titles alone: words measured as borne in the leading position at small incidence ship as titles (`herra`, `batoni`, `hrabia`, `kreivi`), and the exception reaches no other set or word without its own counts — 2026-08-16 #348/#360/#342/#385; 2026-10-04 #606. - The gap between the ambiguous shares of the particles and of the suffix acronyms is base rate, not inconsistency: most particles are short words that double as names and most credential acronyms are not, so the shares are not to be harmonized — 2026-08-16 #348/#360/#342/#385. - C-ii: where two sets claim a word and neither reading is a name, precedence is a frequency judgement recorded per word; `vd`, a never-given particle and a credential acronym, takes the Dutch van der reading where a family is named (`Berg, Jan vd` → family 'vd Berg') — 2026-08-16 #348/#360/#342/#385. - `mc`, the other word of both the particle and the unambiguous suffix vocabulary, inherits `vd`'s answer through rules/P6's statement for the shape without being weighed itself (`Berg, Jan mc` → family 'mc Berg'), a cost #454 accepted; `do`, in the ambiguous suffix half, is outside that precedence and joins the family by P6's ordinary attachment (`Berg, Jan do` → family 'do Berg'), except in capitals, where S6 reads it as the credential (`Berg, Jan DO` → suffix 'DO') — 2026-08-16 #348/#360/#342/#385. - `ó` and `ní` are never-given particles (`Liam Ó Murchú` and `Ó Murchú` → family 'Ó Murchú'); `ua`, `binti` and `binte` are ambiguous, `ua` for want of a thorough search and `binti` and `binte` because Binti is a Swahili given name (`Binti Jua` → given 'Binti', reported) — 2026-10-04 #604. Elsewhere: #454's acceptance of `mc`'s inherited answer is decisions/suffix-acronym-collisions' 2026-09-07 #342/#454 bundle; for P3's count, a word placed to join counts as a connective whatever other vocabulary holds it, decisions/P3's 2026-09-20 #397 entry answering a C-ii question in its THE COUNT REACHES THE CLASS paragraph, scoped to that count; `mc` and `ste` left the ambiguous half of the particles under #360 (decisions/P1); `do` left TITLES in #296's audit, so `Do Quang Minh` reads given 'Do' and reports the fork, and the overlap of TITLES with the ambiguous particles, `freiherr` and `st` since, is kept load-bearing by the particle-line note in decisions/vocabulary-exclusions; `rai` left the suffix acronyms rather than earning the marking, and the relative-frequency criterion that chooses between removal and the marking is decisions/suffix-acronym-collisions'. Not standing: the C-i correction's call to re-judge `lo` and `el` as candidate particles, which #360 took up, judging `lo` ambiguous and leaving `el` needing judgement; neither was added to the particles — 2026-08-17. Elsewhere: C-i reaches the titles through a `given` reading, which makes a title word a title only inside a title phrase (rules/H9; the vocabulary is decisions/vocabulary-model's 2026-10-25 #752, phase 2i entry); where the word reads as a name, which forks are reported, #348's question, is decisions/H9's 2026-10-26 #752, phase 2i (Derek) and phase 2i, the report's reach (Derek) entries, a word no listed phrase holds and one after a comma reporting nothing; since phase 2a the particles' and suffix acronyms' ambiguous markings are derived from name readings too (decisions/vocabulary-model's 2026-10-25 #752 — A CLASH WITH A NAME IS A NAME READING entry). Open: #913 (the title words still read as titles that are also borne as names). ## Log - 2026-08-16 #348/#360/#342/#385 (C-i's reach for the suffix acronyms and the Applications bullet's rai clause superseded, as the notes on those bullets say) — THE COLLISION KEYSTONE: the base-rate entry that follows and the undated C-i, C-ii, "The concrete demonstration", "Applications" and "C-ii's per-word framing" bullets after it; C-i's position qualifier is the 2026-08-17 entry, and the demonstration bullet was rewritten for #296's audit. - 2026-08-16 (collision keystone; #348, #360, #342, #385) — the 58%-vs-0.65% gap is BASE RATE, not disagreement. Both sets apply the same test; most particles are short words that double as names (van, bin, le, do, bar, mac) while most credential acronyms are not (abpp, acp). Recorded because the gap reads as an inconsistency and is not one — a reviewer who "harmonizes" the two shares will break one of them. - **C-i, vocabulary vs. name.** (For the suffix acronyms, the "iff" superseded by decisions/suffix-acronym-collisions' 2026-09-07 #342/#454 bundle: being borne only opens the question, and relative frequency chooses removal, marking or leaving the word.) A word belongs in its set's ambiguous subset iff it is borne as an ordinary name IN THE POSITION THE VOCABULARY CLAIM ACTS ON. Existence of a bearer anywhere is not the test, and the first draft of this criterion (recorded 2026-08-16, corrected 2026-08-17) got that wrong. Under uncertainty, default to AMBIGUOUS. This generalizes the evidence standard already stated in NON_GIVEN_NAME_PARTICLES' docstring — a wrong unambiguous claim misparses a real person, a wrong ambiguous marking only adds a flag — from "which set" to "which subset". Applies uniformly to particles, suffix_acronyms and titles. - 2026-10-04 #606 — C-i has ONE recorded exception, for TITLES only: #salutation-titles ships words measured as borne in the leading position at small incidence (herra, batoni, hrabia, kreivi), a weighing C-i itself does not make. It extends to no other set and no other word without its own counts. - **C-ii, vocabulary vs. vocabulary.** Where two sets claim a word and NEITHER reading is a name, precedence is a frequency judgment recorded per word. vd is the live case: never-given particle AND credential acronym (the British Volunteer Decoration), neither of them a name. Decision: the Dutch van der reading, as the more common. That is what unblocks #380, whose trailing-orphan half is a separate decision recorded under its own rule. - 2026-08-17 — C-i CORRECTED: the position qualifier. Writing the per-word records for the never-given particles falsified the first draft on the set's most load-bearing member. "De" is a borne Bengali and Odia surname (parse("Bimal De").family == "De"), so C-i as first written said mark `de` AMBIGUOUS -- which would break "de la Vega" and every leading-particle reading. Nothing had tested the criterion against `de`, and the error pointed at the safe side, so it would never have announced itself as a misparse. The qualifier the existing set was already obeying: P1 acts on the LEADING position or a lone particle in the GIVEN role, so what matters is whether the naming use occupies THAT position. van Vietnamese Văn in given position collides ambiguous bar Bar Refaeli, given position collides ambiguous do Đỗ leads a surname collides ambiguous de "De" is a TRAILING surname no clash never-given Consequence, and the reason this is not merely tidier: `das` was called ambiguous on #360 under the old reading because Das is a borne Bengali surname. Measured, "Anjali Das" and "Bimal Das" are unchanged by never-given `das` (the surname is trailing, the rule acts leading) while "Maria das Neves" GAINS its particle -- family="Neves" today, family="das Neves" with it. The old reading would have declined a fix. `lo` and `el` need re-judging on the same axis; `Lo` does lead in romanized Chinese ("Lo Wei"), so it may genuinely collide where `das` does not. Corrected on #360, which carried the wrong table publicly. - The concrete demonstration is "do", which three vocabularies claimed — titles, particles/ambiguous, suffix_acronyms/ambiguous. Two mark it ambiguous; the third, TITLES, was the one that actually decided "Do Quang Minh" (title="Do", given="Quang") and reported nothing — until #296's audit (2026-08-23) took it out of TITLES, since when the name reads given "Do" and reports the fork. Same word behind #385's "Anh Do". - Applications, each still its own work: #360 (mc, ste — neither is a borne given name, so both leave the ambiguous half); #342 (rai — Rai IS a borne surname, so it earns the marking rather than moving); #385 (do — resolved at decisions/R2). (#342's clause — "rai — Rai IS a borne surname, so it earns the marking rather than moving" — is SUPERSEDED 2026-09-07: Rai is borne, and what that earned was removal from the vocabulary rather than the marking. See #suffix-acronym-collisions for the relative-frequency criterion that decides between the two.) - C-ii's per-word framing versus a rule stated for a SHAPE: (Corrected 2026-10-21: `do` was already in the ambiguous suffix half when this was written, so the precedence decides nothing for it, as rules/P6 says; only `mc` inherits `vd`'s answer, which #454 accepted.) measured 2026-08-16, the words that are both particle and suffix vocabulary are vd, do and mc — three, not the one this criterion adjudicated. rules/P6 states its precedence for the shape, so do and mc inherit vd's answer without being weighed. Recorded rather than papered over: stating a per-word judgement as a general clause is how an unexamined word acquires a decision, and the two are named here so the next reader knows which one was actually argued. - 2026-10-04 #604 — five particles, judged by C-i one at a time. NEVER-GIVEN: `ó` (Irish "descendant of") and `ní` ("daughter of") — grammatical patronymic particles, no given-name use in the position P1 acts on, and the measured gain is the leading and mid-name surname both (`Liam Ó Murchú` and `Ó Murchú` read family `Ó Murchú`, where today Ó was a middle initial or the given name). AMBIGUOUS: `ua`, the older spelling of `ó` — no given-name use was found, but the search was thinner than for `ó`/`ní`, and C-i says default to ambiguous under uncertainty. Measured 2026-10-04, the two markings agree mid-name (`Seán Ua Buachalla` reads family `Ua Buachalla` either way) and differ in three places. Where `Ua` opens the name: the leading `Ua Buachalla` keeps given `Ua` and reports particle-or-given rather than becoming surname-only, and `Ua Buachalla Seán` under FAMILY_FIRST keeps family `Ua`, given `Buachalla` and reports. Where it opens the part after a family comma (P1's after-comma clause): `Seán, Ua Buachalla` reads given `Ua`, middle `Buachalla`, family `Seán`, where never-given would give family `Seán Ua Buachalla`. And where it ends the name (P6): `Seán, Seán Ua`, and `Seán Seán Ua` under FAMILY_FIRST, report particle-or-given only while `ua` is ambiguous, the fields being the same. Recompute: parse each with `Lexicon.default()` against `Lexicon.default().remove(particles_ambiguous={'ua'})`. The cost is small because `Ua` is the rarer spelling, but it is not leading-only, which matters to anyone re-pricing the marking under #360. `binti` and its Singapore spelling `binte` (Malay "daughter of") — ambiguous like `bin`, Binti being a Swahili given name ("daughter"); `Ina binti Navalamar` reads family `binti Navalamar`, `Binti Jua` keeps given `Binti` and reports. The one-letter `ó` needed a rule of its own against the initial it spells: rules/P7, decisions/P7. - Caution when applying C-i to the particle set: TITLES ∩ ambiguous — {do, freiherr, st} until #296's audit took 'do' out of TITLES (2026-08-23; `Do Quang Minh` reads given 'Do' with the fork now), {freiherr, st} since — is load-bearing, per the particle-line Excluded note in decisions/vocabulary-exclusions. Emptying it makes the particle-or-given emitter dead code. derek73-python-nameparser-456b024/docs/design/decisions/vocabulary-exclusions.md000066400000000000000000000727061527303271700277670ustar00rootroot00000000000000# vocabulary-exclusions — words kept out of a vocabulary set, and contested memberships The `Excluded:` and `Open:` blocks keyed by a vocabulary set rather than a rule (the entry conventions in README.md). Until #713 they sat in decisions.md after whichever entry happened to precede them, so the split filed them into the P3, W2, P5 and R4 entries; they moved here together, in their old order, so a wordlist editor finds every set's prohibitions in one place. Since 2026-10-18 a word-level refusal can be data instead — an `excluded` key on the word's entry in nameparser/vocab/*.toml (decisions/vocabulary-model) — and a block shrinks to its class-level part as its words move (the README's exception to append-only); the honorific_tails block below was the first, the particle block the second (#749), the two title blocks the third (#750), and the case-mask and single-letter-connective refusals and two of the maiden-marker ones (`roz`, `f.`) the fourth (#751). `uv run python tools/vocab/query.py --excluded ` lists the moved ones, a refused property by its name (`--excluded may_be_initial`). Excluded (Lexicon.conjunctions_ambiguous, the conjunction readings marked `may_be_initial` — an entry here reads as an INITIAL in a name written wholly in one case): - The per-word refusals are data since #751 (2026-10-20): `y` in es.toml — the commonest Hispanic double-surname link and this library's oldest fixture, "Velasquez y Garcia" — and the Cyrillic `и`, `і` and `й` in ru.toml and uk.toml, whose joining #267 blessed, each carry `excluded = { may_be_initial = … }` on their conjunction reading with the reason (2026-09-13, #383/#479). `uv run python tools/vocab/query.py --excluded may_be_initial` lists them; a contributor who marks one meets the reason as a build error. - `و` and any other caseless letter — inert rather than harmful, and excluded so nobody adds one believing it does something: the fork requires a token whose `upper()` and `lower()` differ, so a caseless member is never consulted (2026-09-13). - Multi-letter entries (`and`, `та`, `και`) and `&` — the fork tests a single CHARACTER, so an entry longer than one letter is silently inert. The vocabulary build refuses `may_be_initial` on anything but a cased single letter (tools/vocab/build.py; an import-time assert in the conjunctions module until 2026-10-18) (2026-09-13). Excluded (Lexicon.conjunctions — the conjunction readings): - `a` — the Māori link in `Te Awanui-a-Rangi` is the one hyphen-interior connective the #478 rule (rules/R4) leaves capitalized under force, `Te Awanui-A-Rangi Black`, and it must not be fixed by adding `a` here: a spaced `a` is an initial (`John A Smith`) or a name word elsewhere, and a connective entry would join or lowercase it in every one of those names. The non-reach is recorded under R4 with that name as its witness (2026-09-23, #478). Open (Lexicon.conjunctions — contested membership; the issue is canonical): none. The block held one entry, [#397](https://github.com/derek73/python-nameparser/issues/397) — whether Catalan `i` belongs in the conjunctions at all — answered 2026-09-20 by decisions/P3's bullet of that date: it does, and it joins `conjunctions_ambiguous` in the same change, which was never the contested half. The heading stays with nothing under it on purpose, the same reason an empty ledger roster section is a statement and a missing one is nobody having looked. Excluded (the never-given / ambiguous particle line, a particle's `given` reading, or `may_be_given = false` where it has none — #360 owns the vocabulary question): - Only 9 of the ambiguous members were individually justified at #360's audit (2026-08); the rest sit there by the conservative default (ambiguous unless argued never-given). #604 (2026-10-04) argued three more on entry — ua, binti, binte — under vocabulary-collisions. - The audit's scope, as the module comment above the ambiguous particles recorded it until #749 turned the comments into fields: #360 examined "the 12 that appear leading in tools/differential's corpora plus a few more", and left the rest bare rather than record an absence of knowledge as a judgment. Examined is wider than the 9 justified above. - The per-word reasons are data since #749 (2026-10-19, decisions/vocabulary-model): each member's `note` on its particle reading, in the file of each language whose names it occurs in, and each word kept out (`ben`, `nic`, unaccented `o`, `bt`, `bte`, and the native-script `ال` and `בר`) an `excluded` entry with its reason. `uv run python tools/vocab/query.py --kind particle` lists the members (each word once per file it is in), `--issue 360 --kind particle` the five never-given words #360 added or moved (das, las, los, mc, ste), `--issue 604 --kind particle` #604's five (the never-given ó and ní beside the three above), and `--excluded particle` the refusals; the nine ambiguous members #360 justified carry a `note` but no `issue`, so no query isolates them; a contributor who re-adds a refused word meets the recorded reason as a build error. - Encoding rationale (#293, predating #360's membership questions): the data layer stores the NEVER-GIVEN set and derives the ambiguous one, because that is safe-by-default for new particles — a one-place addition — and the v1 shim translates by one-directional complement. And the constants are FROZEN specifically to kill the cached-Lexicon.default()-vs-fresh- Constants desync that runtime module-constant mutation caused. The data-layer half is superseded since 2026-10-18 (decisions/vocabulary-model): from then every particle reading stated `may_be_given`, true or false, so neither side was a default, and since 2026-10-25 a particle may be given exactly where its word has a `given` reading, every other particle reading stating `may_be_given = false`, which still leaves neither side a default; the freeze half stands. - Load-bearing dependency: TITLES ∩ ambiguous — {do, freiherr, st} until #296's audit, {freiherr, st} since — is what keeps the particle-or-given ambiguity emitter reachable at all; moving them all would leave the shipped vocabulary unable to reach it, which is why test_the_chained_emitter_is_reachable_by_construction (tests/v2/test_parser.py) pins reachability by construction rather than by shipped vocabulary — an empty intersection fails no test and does not mean the emitter is unreachable. Open (contested vocabulary memberships — the rule is right, the word's set is questioned; the issue is canonical): none. The block held three entries and all three were answered inside a fortnight — #346, #343 and #344 together on 2026-09-06 (see #indic-honorifics) and #342 on 2026-09-07 (see #suffix-acronym-collisions). The heading stays with nothing under it on purpose, the same reason an empty ledger roster section is a statement and a missing one is nobody having looked. sa, se and om (all three since decided, sa on 2026-10-25, #857) are named as the next candidates by #342's own comment and carry no issue, so they are not entries here. Excluded (SUFFIX_ACRONYMS — esq, removed 2026-09-08, #316/#489 bundle): - esq is OUT of SUFFIX_ACRONYMS and must not be put back by a sweep that finds "Esq." parsing and assumes the acronym set is what carries it. The SUFFIX_WORDS membership carries every single-token spelling — Esq, Esq., ESQ, esq — and stops being inert with the acronym entry gone. What the acronym entry uniquely covered is the multi-dot spelling, so "John Smith E.S.Q." reads family E.S.Q. where every release since 1.4.0 read suffix; that is the whole of the cost and it is a spelling nobody writes. The criterion is #suffix-acronym-collisions', asked of the WORD rather than of the frequency: does the entry describe the word or the machinery. Esquire is a contraction, not an initialism, so the initialism set was never its home; it arrived in the 2019-12-11 bulk Wikipedia post-nominal import (af5bdab, #93) and was never reviewed. This SUPERSEDES the dual-membership entry that stood here from the 2.2 cycle, which called the acronym membership load-bearing and the word membership inert and concluded that the singleton "is why the two sets cannot carry a disjointness assert". The assert now exists — `not (SUFFIX_ACRONYMS & SUFFIX_WORDS)` in suffixes.py's guard block, a vocabulary build check since 2026-10-18 (one suffix kind per word, each an acronym or a word; decisions/vocabulary-model) — and it is what the removal buys: the two sets normalize differently (the word test strips edge periods, the acronym test strips all of them), so a word in both is matched by two rules and which one fired is unreadable from outside. AGENTS.md's esq gotcha, the two defending comment blocks in suffixes.py, the `# NOT asserted:` note and the `suffix_acronym_multidot_spelling` case row all retire with it; a row pinning `John Smith E.S.Q.` → family replaces the last of those. Measured over the corpus glob as it stood at the change (1123 distinct names, 1263 rows; the docs commit that follows adds this bundle's rules.md examples and makes it 1136 and 1284): exactly one moves, `John Smith E.S.Q.` itself, and it diffs at every baseline. Classified a behavior change, a 2.x parity break, on all four ledgers. Excluded (SUFFIX_ACRONYMS — ph, removed 2026-09-13, #459/#521): - ph is OUT of SUFFIX_ACRONYMS and must not be put back by a sweep that finds "Ph. D." parsing and assumes the acronym set is what carries it. The split spelling is merged by group's own rule (`split_pair` in `_pipeline/_vocab.py`, which asks two patterns, v1's fix_phd; see #phd-merge, whose "merged back by vocabulary" means that regex stage and not a lexicon lookup), which never consults acronym membership, and the single-token spellings `phd`/`Ph.D.` are the `phd` entry's. The fragment arrived in the 2019-12-11 bulk Wikipedia post-nominal import (af5bdab, #93) and was never reviewed. It did two things. One was to let the merged `Ph. D.` token pass the acronym test on its first piece, which nothing read until #459's all-caps repair did — with `ph` in the set that repair read `john smith ph. d.` as `John Smith PH. D.` on the default path, not only under force, and that is what forced the removal. The other, found by the docs review of the fix-up (2026-09-22) and not at the removal, was to read a bare trailing `Ph.` — no `D.` behind it — as a credential: `John Smith Ph.` gave family Smith, suffix Ph. on every release from 1.4.0 through 2.3.0 (wheels measured), and out of the set it falls to the positional read, middle Smith, family Ph., suffix empty; `Smith, John Ph.` moves the same way, to middle Ph. The cost is that CLASS and not one spelling, measured 2026-09-22 on HEAD and on the five wheels with `Lexicon.default().add(suffix_acronyms={"ph"})` as the comparator: the acronym test strips periods, so the undotted `John Smith Ph`/`PH`/`ph` move identically; inside a credential run `Ph.` no longer joins it, so `John Smith Ph. MD` reads family Ph., suffix MD, and `John Smith MD Ph.` reads middle `Smith MD`, family Ph., the real credential leaving the suffix field with the fragment; `John Smith Ph. Jr.` keeps only the Jr.; and the comma form `Smith, Ph.` reads title Ph. where 2.2.0 and 2.3.0 read suffix. `John Smith P.H.` does not move, the by-shape class reading it either way. Every shape in the class is a bare `ph` with no `D.` behind it, which nobody writes — the same criterion as esq's, asked of the machinery rather than a surname: the entry described the merge, not a word. Classified a behavior change, a 2.x parity break, on all five ledgers (`change(suffix-acronym-collisions) ph leaves the acronym set`), with the cases.py row `removed_ph_fragment_leaves_a_bare_trailing_ph_a_name_word` written to carry the plain shape; the ledger rule is literal to that one name, so a future row on any other shape of the class arrives unexplained at every baseline and is classified by widening that rule, not filed as a regression. No corpus name had any shape of the class, which is why the removal measured as 0 role movers at review time (2026-09-11, 1143 names), the corpus-blindness AGENTS.md warns of. Under `capitalized()` the 13 `PH. D.` shapes the repair had moved under `force=True` go back to `Ph. D.`; `john smith phd` and `ph.d.` still give `Ph.D.`. Excluded (BOUND_GIVEN_NAMES): - mohamad — a standalone given name in its own right; binding it would eat the middle name. - abd — ADDED 2026-08-18, see decisions/P5's entry of that date; the exclusion stood on two reasons and only one of them survived. The collision with the post-nominal "ABD" turned out to cost nothing (position decides at both ends). The other reason stands as a known limit: the three-token spelling "abd al rahman" is still not joined, `al` being a particle that chains forward, so only the two-token "abd X" and the hyphenated "abd al-Rahman X" join. Excluded (DEFAULT_NICKNAME_DELIMITERS): - Curly single quotes ('‘','’') are deliberately absent from the default pairs: U+2019 is the typographic apostrophe (O'Connor in curly type), so shipping the pair would eat real names. #273's own proposal excluded them; pinned by the curly_apostrophe_stays_literal case. A sweeper "completing the typographic set" ships the regression. Excluded (given-name titles and post-nominals — the 2026-07-19 transliteration deferrals, each living in a data-module comment until now): - The title refusals are data since #750 (2026-10-19): Arabic bare سيد/شيخ/أمير/سلطان and the abbreviation د. in ar.toml, Greek bare κ in el.toml, Latin sri/shri, sant and pandit in hi.toml, and Latin mst in bn.toml, each an `excluded` entry with its reason (`uv run python tools/vocab/query.py --excluded title`). Two class-level rules stand behind them. A transliteration collides with real names where the native spelling cannot, so the native spelling ships alone (sri/shri, sant, pandit, mst). The bare Arabic سيد/شيخ/أمير/سلطان are given names; of their article forms only الشيخ ships, and السيد, الأمير and السلطان were never vetted, so the refusal decides nothing about them. And a single-letter abbreviation is the initial trap: the edge-period fold leaves bare د or κ, which matches an initial, so `Κ. Παπαδόπουλος` would read title `Κ.` with an empty given, and Latin titles hold no bare single letter for the same reason. That is decided per word, not per script: the Bengali ড ships, vocabulary beating the initial reading on purpose (decisions/indic-honorifics), and pays exactly that cost (`ড. রহমান` reads an empty given). - Ottoman post-nominals باشا/بك/أفندي — surname collisions. - Hebrew bare רב (an ordinary word, "many") and בר (Bar is a common modern Israeli given name) — deferred, #269's territory. - The Latin forms of the Indic titles belong, if anywhere, to the opt-in packs of #345 (2026-09-06, #344): TITLES has no ambiguous subset to express a partial answer in. Excluded (multi-word vocabulary entries — a set outside #M2's two exceptions matches one written word, so a multi-word entry there is silently inert and warns at configuration; given_name_titles and, since #434, maiden_markers are the exceptions, and they match phrases by different mechanisms): - Eight entries shipped unmatchable from 2013 to 2.0. chargé d'affaires was SPLIT into the chainable chargé + d'affaires, plus unaccented charge (the attaché/attache precedent, Derek's call); leed ap, nicet i–iv and psm i/ii were REMOVED rather than split, on the name-swallowing measurements recorded in #291 (see the comma-suffix-arc Declined entry). The multi-word UserWarning is this story's enforcement. Excluded (Lexicon.honorific_tails — a glued tail peels only if it could never end a name): - The per-word refusals are data since 2026-10-18 (decisions/vocabulary-model): each word refused the glued peel carries `excluded = { attached = "" }` on the reading it keeps, in its language's file — 양, 군, 氏, 博士, 殿, 部長/部长 and the Indic spaced set — and 君 and bare 선생/교수, refused the honorific reading in either position, carry a word-level `excluded` instead. `uv run python tools/vocab/query.py --excluded attached` lists the first kind with their reasons, `--excluded honorific` the second. A contributor who adds a refused `attached` meets the recorded reason as a build error. - The class-level half stays here. None of the spaced trailing set the 2026-09-06 Indic bundle shipped (जी, साहब, साहिब, साहेब, महाराज, সাহেব, বাবু, মহারাজ and Latin rinpoche) may become a honorific_tails entry. The evidence is जी's and it carries the set: Banerjee, Mukherjee and Chatterjee end in the substring (बनर्जी/मुखर्जी/चटर्जी), and glued peeling strands a fragment on a bare virama (बनर् + जी). The 殿 criterion, in a non-CJK script — which also shows the criterion is not CJK-specific. The accepted cost is that गांधीजी stays unpeeled, and it is the right trade: an unrecognized honorific costs one field, a strand cuts a real family name in two. Both shapes are pinned in tests/v2/test_locales.py. See #indic-honorifics. Excluded (TITLES): - The per-word refusals are data since #750 (2026-10-19): each word refused a title reading — the Indic ঠাকুর/ठाकुर, कुमारी/কুমারী, बेगम/বেগম, शेख/শেখ, आचार्य and राजा/रानी (#343, #344), and #606's ông/bà, thiru, puan, pan, rodina, gróf, cik, paron, tikin, Latin janab, marshal and justice, #662's malam (Derek's decline) and bay, #660's shehu, and #789's wm (2026-10-22) — is an `excluded` entry in its language's file with its reason, and with its counts as name readings where they were taken. `uv run python tools/vocab/query.py --excluded title` lists them. A contributor who adds one back meets the reason as a build error. - shehu's counts do not settle a graf-class weighing, and must not be read as if they did: its one leading-position figure, the Wikidata given-name count on its entry, is a lower bound (Shagari's own entry has no given-name statement), and its other counts are family-name bearers, a position C-i does not ask about. The evidence that Shehu leads a name is not a count. It is Shagari himself, and CLDR's Hausa sample data, whose six renderings carrying the word all file it as the given name (`grep '"given": "Shehu"' tools/differential/cldr_fields.jsonl`). It arrived with an early bulk import (1a51555f) and was never put to C-i before #660. Whoever re-argues it counts Hausa given-name use first; the accepted cost is the honorific use, `Shehu Abubakar` reading given Shehu as any untitled name does, and the reading belongs in an opt-in pack if anyone asks for it. malam is the opposite case: CLDR's Hausa data files Malam as the title (`grep '"title": "Malam"' tools/differential/cldr_fields.jsonl`), so its refusal is a decline on the counts, not a shehu-style removal. - The class-level half of the Indic refusals stays here: the divergence on शेख/শেখ is deliberate and is the sri/shri case run backwards — the Latin sheikh cluster SHIPS as given-name titles for the Arabic addressing form, while the Indic spellings name the family. Since 2026-10-27 the Latin spellings but `shaykh` are titles only inside a title phrase (decisions/H9), so at the head of a name `Shekh Ahmed` and `शेख अहमद` both read the word as the given name, and the divergence stands only where a style, a modifier or `of` makes the Latin word a title, or a period does at the front by rules/H2's shape (`Sheikh. Ahmed` reads title 'Sheikh.', `शेख. अहमद` given 'शेख.'). shehu's removal moved three CLDR corpus names (`Shehu Muhammed`, `Muhammed, Shehu`, `Modibbo, Shehu Bello`), all three now agreeing with CLDR on every role (moves: `uv run python tools/differential/snapshot.py --since v2.4.1`). - The trailing-position rule that must NOT be adopted, and 2026-09-08 says what the line is: TITLES holds hundreds of words in no suffix set, at least nineteen of them ordinary English surnames (king, judge, bishop, baron, sheriff, ...), so a blanket "vocabulary outranks position in the trailing slot" reading would turn "Mary Jane King" into title="King" with the family name gone. The leading half of this argument is AGENTS.md's "Titles permanently shadow first names" gotcha, whose example is Dean; this is the trailing half, and it shadows the family name rather than the given (#316). What #316 settled is that the prohibition is on BARE words: "Mary Jane King" still reads family King, while "John Smith Prof." reads title Prof. **The second half of that argument is RETRACTED 2026-09-09**, in review of the docs commit: "a period-marked trailing word being one nobody writes as a surname" claimed the period keeps the collision out of the trailing slot, and it does not. Measured on the branch tree — "Mary Jane King." reads title "King.", given Mary, family Jane, the family name shadowed exactly as the bare reading would have shadowed it, and "John Smith Judge." reads title "Judge." The reach is the vocabulary rather than a handful: of the 746 titles in no suffix set, 627 read as a trailing title once a period is written behind them, and every one of the 119 that do not is kept out by the abbreviation SHAPE — a digit, a hyphen, an apostrophe, or a script whose letters carry combining marks (rules/H2 records that half) — no plain ASCII-letter title missing at all. So king, judge, bishop, baron and sheriff ARE reachable in the trailing slot; what the prohibition above buys is the bare spelling and nothing more. The period is a WRITING convention the rule can read, not evidence about the word, and reading it is an ACCEPTED COST under the premise that the input is a name (rules.md H Background): someone who ends a name with a period-marked word has written an abbreviation. rules/H5 carries it as an Accepted clause with "Mary Jane King." as the executable example. (Since 2026-10-26 `king` is a title only inside a title phrase, decisions/H9, #752 phase 2j: "Mary Jane King." reads family "King." and the surnames this paragraph names are pope, judge, bishop, baron and sheriff, rules/H5's example "Mary Jane Pope.".) Recompute the reach with `L = Parser().lexicon; ws = [w for w in L.titles - L.suffix_acronyms - L.suffix_words if " " not in w]; len([w for w in ws if parse("Mary Jane %s." % w).title])` against `len(ws)` — 627 of 746 on 2026-09-09 — the digits move as the vocabulary grows and neither argument does. See #H5. The bare prohibition has one exception since #835 (decisions/H8): a listed salutation ending the given part after a family comma is the title, the comma having marked the surname's place and the salutations borne as names being excluded. Excluded (Policy.script_orders defaults): Script.KATAKANA is deliberately absent — a pure-katakana token is predominantly a transcribed foreign name kept in its source order, so nothing defaults on it (rule W4's boundary). Noted 2026-08-15: of the three Script-keyed axes, this is the one with no force-a-decision guard (mechanisms.md#FORCE-A-DECISION-TABLE), so a new Script member silently gets no order. Excluded (MAIDEN_MARKERS, the birth_marker readings): - Polish "z domu" — the exclusion is LIFTED and the phrase SHIPS; see the INCLUDED entry below for the argument. It was provisional from the day it was written, 2026-07-27, pending the multi-token matching decision — first tracked in #291, repointed to #434 on 2026-09-07 per Derek's amendment on #342, markers having no run predicate, so the multi-token question does not dissolve there the way the suffix one did — and #434 shipped whole-phrase marker matching on 2026-08-26, which is what lifted it. Kept rather than deleted because a provisional exclusion that was answered reads, once deleted, as an exclusion nobody ever considered. - Contrast entry — unaccented "nee" SHIPS as a marker despite #274 flagging "is nee safe as a default (it's also a rare surname)" as open; the question resolved silently with the shipped set. Recorded here because the included risky member deserves its analysis as much as the excluded ones; M1's (Nee) boundary covers only the enclosure path, not this marker path. - "born" — never shipped: a release-log drafting invention, caught by the 2.0 milestone audit and corrected (5ccf9f3). Recorded so nobody "restores" it; if ever proposed for real, Max Born is the counterexample to analyze. - Scandinavian "f." — collides with the initial F.; only the full participles (født/fødd/född) are safe. Data since #751 (2026-10-20): `f` in da.toml carries `excluded = { birth_marker = … }`. Czech masculine "rozený" awaits the same vetting, on its own merits rather than following the feminine form in. - Polish "z domu" — INCLUDED 2026-08-26 as the set's first phrase entry, and the contrast to draw against the roz removal below rather than against any included word. Its words fail C-i separately and it passes as a pair; the argument is in the #434 entry under #M2. The point to carry away is that the entry below could not have been rescued the same way: roz is a whole marker as written, so there is no longer unit to move the claim onto. - Czech/Slovak "roz." — SHIPPED through 2.1 and removed 2026-08-26 (Derek's call). Roz is an ordinary English diminutive of Rosalind, and matching is whole-token, case-folded and period-insensitive, so "Roz", "roz" and "roz." are one string to this set. The collision is not theoretical and it is not new: on the bare path, which predates the 2.2 work entirely, "Rosalind Roz Smith" read maiden "Smith" and NO family name at all, and "Rosalind Roz Jones Smith" read maiden "Jones Smith", because M2 hands the marker every word after it. Grounded in #vocabulary-collisions C-i — a word belongs in its set's ambiguous subset iff it is borne as an ordinary name IN THE POSITION THE VOCABULARY CLAIM ACTS ON, and under uncertainty default to ambiguous. Roz is borne exactly there: the word after a given name, which is the only position M2's claim acts on. What C-i cannot do for this set is the marking: MAIDEN_MARKERS has no ambiguous subset, and no code path consults one, so the only two expressions of C-i available here are ship and do not ship. That is why the remedy is removal rather than an ambiguous marking, and it is worth knowing before someone proposes marking the next collision instead. The full participle rozená stays and is unambiguous; a Czech caller who needs the abbreviation adds it to their own Lexicon, which is the same answer this section gives for every locale-specific vocabulary. The cost is accepted and stated: "Anna Nováková roz. Svobodová" now reads middle "Nováková roz.", family "Svobodová" — which is how 1.4.0 read it — and "Rosalind Roz Smith" is back to 1.4.0's given/middle/family. Pinned by tests/v2/cases.py::diminutive_that_was_a_marker_keeps_the_family and ::full_participle_marker_still_consumes. The refusal is data since #751 (2026-10-20): `roz` in cs.toml carries `excluded = { birth_marker = … }` with its reason, measured again that day (`--excluded birth_marker`). Excluded (CAPITALIZATION_EXCEPTIONS — meng, edd, lac, ded, left out of the 2026-09-24 masks, #459): - A listed credential borne as a name gets no mask while the map stays role-free: a mask applies in every role, so it re-spells the name wherever the word is one. MEng, EdD, LAc and DEd are conventionally mixed-case credentials and listed acronyms, and each is borne as a given name or surname (Meng, Edd, Lac, Ded). A writer who spells one mixed-case keeps it (decisions/R5, 2026-09-24); a caller who wants the mask adds it to a private lexicon. The per-word refusals are data since #751 (2026-10-20): each word carries `excluded = { cased = … }` on its credential reading in en.toml, its reason the rendering measured with the mask added to a private lexicon (`MENG LI` giving `MEng Li`, 2026-09-24; `lac`'s on 2026-09-25), and `uv run python tools/vocab/query.py --excluded cased` lists them; a contributor who adds one of the masks meets the reason as a build error. Where `meng` and `lac` stand in SUFFIX_ACRONYMS itself, a #vocabulary-collisions C-i question this map does not decide, is #suffix-acronym-collisions' #540 entry. `lac`, `edd` and `ded` arrived with the Wikipedia post-nominals import (af5bdab, 2019-12-11, #93) and `meng` with 3e14ea20 (2026-07-01, the German/Dutch title and degree batch). Excluded (CAPITALIZATION_EXCEPTIONS — md, ii, iii, iv, removed 2026-09-23, #459): - `md`, `ii`, `iii` and `iv` are OUT of the exceptions map and must not be put back by a sweep that finds one of them title-cased somewhere. As a SUFFIX each is written in capitals with no entry — `md` by the acronym clause, the numerals by the numeral clause — and in any other role each repairs as the parse read it by decision (decisions/R4's item-4 bullet), so an entry would only bring back the role-free capitals that bullet retired: `iv smith` → `IV Smith`, `Md Abdul Karim` → `MD Abdul Karim`. A caller who wants them back adds them to a private lexicon, as masks. Excluded (locales.ZH surnames, nameparser/locales/zh.py — the two-character entries): - Married double surnames (王李, 張李, 陳李, 李林, 張陳 and the rest of the Taiwan file's two-character rows that join two single surnames). The longest match runs first, so 李林 would cut 李林伟 into 李林 + 伟, a real Chinese name read wrong where today it reads right (2026-10-18, #695). 張廖, 張簡 and 范姜 are not this class: they are clan compounds borne as one surname, and ship. - Japanese surnames and transcriptions in the same file (佐藤, 鈴木, 馬丁). The pack divides Chinese names; a Japanese surname belongs to `locales.JA`'s segmenter (2026-10-18, #695). derek73-python-nameparser-456b024/docs/design/decisions/vocabulary-model.md000066400000000000000000003212541527303271700266660ustar00rootroot00000000000000# vocabulary-model — each word described by its readings (2026-10-15 → 2026-10-18) The vocabulary was a set of base sets plus marker subsets (`particles` and `particles_ambiguous`, `suffix_acronyms` and `suffix_acronyms_ambiguous`, `suffix_words` and `honorific_tails`, `titles` and `given_name_titles`, `conjunctions` and `conjunctions_ambiguous`), and the pairs did not work as a model. "Ambiguous" meant three different things by field (may be a given name, may be a surname, may be an initial), and two of the markers were not ambiguities at all. The direction flipped between layers: config/ marked the never-given particles while `Lexicon` marks the may-be-given ones. Every marked entry was written twice and the checks on the pairs were uneven (a `Lexicon` raise for some, an import-time assert for others, nothing for `conjunctions_ambiguous`). A clash between categories (`do` as a particle and as a credential) was recorded nowhere, visible only because two sets happened to overlap, and nothing told an author whether an overlap they created was intended (`titles ∩ particles`, the title/suffix duals) or forbidden (acronyms ∩ words). Language packs need to say which language a word belongs to, and sets cannot. ## Standing - The vocabulary redesign comes before adding many packs: it is designed for 3.0, and what is safe is pulled forward in four phases, (1) the data format and a compiler, snapshot-neutral, (2) derived ambiguity with the gate on every reading kind, (3) pack semantics, (4) the rules menu, order and shapes, and the output model — 2026-10-15/16. - Each word is described by its readings (word → reading → properties), each kind belonging to one category that decides the compiled field it feeds; detailed kinds are recorded even where the kinds of a category behave alike, generation and credential being separate kinds among the suffix category's six (`generation`, `credential`, `honorific`, `professional_honorific`, `status`, `courtesy`) — 2026-10-15/16; 2026-10-18 #737. - 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 is to settle their one-case fork, which no shipped pack does yet — 2026-10-15/16. - A patronymic field is to come in 3.0, 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. - Gender is recorded only where a word gives a definitive clue: a feminine form or one half of a fixed pair, never an unmarked noun — 2026-10-15/16; 2026-10-19 #750, title kinds. - Title-phrase modifiers count as titles only beside a head title, at the head of the name only and never in a comma part (`Wing Lau` → given 'Wing', `John Smith, Vice` → title 'Vice'), save a modifier that may be a given name, which the head's test reads in every part (`Lau, Wing Kai` → given 'Wing'), rules/H9 — 2026-10-15/16; 2026-10-19 #750, title kinds; 2026-10-23 #752; 2026-10-25 #752, phase 2i; 2026-10-26 #752, phase 2j, `wing`. - Name readings are hand-declared, and only on a word some language file claims as vocabulary or refuses, so the vocabulary carries no surname list (the segmentation lists, Korean and the ZH pack's, are outside the per-word model) — 2026-10-15/16; 2026-10-19. - Bearer counts are structured provenance (source and count), never used to compute a reading — 2026-10-15/16. - Phase 2 is to put vocabulary, declared name and shape readings in one pool, derive `may_be_given` and `may_be_name` from declared name readings (a count-free one carrying a `note`, `ld` left ungated, `may_be_initial` kept), and decide every clash by one gate of four steps, floor, form, structure and count, asked at the sites that decide today; the reading taken is to surface as `Token.reading` in 3.0 — 2026-10-23 #752. The reading not taken surfaces as `Ambiguity.alternative` since 2.7, without the vocabulary's languages (decisions/A1) — 2026-10-26 #752, phase 2m. - Every numeral rule asks one predicate, `_vocab.reads_as_generation`, the vocabulary's own numerals read inside it; a numeral of two or more letters is the generation wherever the trailing run holds it, while a one-letter one before a credential stays the name word decisions/S10's #721 entry made it — 2026-10-25 #752, phase 2f. - A credential written split across a space is one word, a member of the ambiguous class that the count decides, and the Ph./D. pair starts S8's run from where it stands (decisions/S12) — 2026-10-25 #752, phase 2e. - A listed credential that is also a name, written in capitals in a name of mixed case, is settled as the credential by its writing: it reports nothing and starts the credential's run where no name word stands behind it with no family comma (2026-10-26, Derek), and keeps its other readings, the clash settled at the sites that choose a reading rather than by retagging the word (decisions/S6) — 2026-10-25 #752, phase 2g, #785. - A member of the ambiguous class alone in brackets or quotes after the name is the suffix where its writing settles it, with a period inside it or in capitals in a name of mixed case, the case being the name's own words' (rules/S13, decisions/S13) — 2026-10-26 #752, phase 2l. - Phase 2's title batches go in order of lone-head breakage in PSC, `buddha`, `siddha` and `gyani` in the default gate and `guru` left to a `ne` pack — 2026-10-24 #752. - `Dean Smith` reads given `Dean`. The contested-head gate covers `dean`, `justice` and `major`, and a title that is also a given name gets a declared given reading one word at a time, in measured batches — 2026-10-15/16; 2026-10-23 #752; 2026-10-25 #752, phase 2i. - Order and shape are separate: a language supplies only a default order, and the shapes (where titles, suffixes, particles and commas sit) belong to each order and live in the core; vocabulary readings are to settle a clash and name readings only to keep the gate on — 2026-10-15/16. - `cased` replaces `capitalization_exceptions` and, with `same_as`, a canonical spelling, and a reading's `native` spelling is for surfacing; `acronym` is a reading property replacing the acronym/word split, one suffix kind per word; `expands` records the meaning an entry was added for; `addresses` takes `family` and `given`, `full` and `any` being refused until a later phase — 2026-10-15/16; 2026-10-18 #737. - The vocabulary is per-word TOML in `nameparser/vocab/.toml`, one file per BCP 47 tag plus `und` and `mul`, at `schema = 1` (a compiler refuses a newer one), every reading a table and no `language` key; `tools/vocab/build.py` compiles it into a committed `nameparser/config/_compiled.py`, the parser never reads the TOML, and parsing it at import was declined — 2026-10-18 #737. - The marker sets are explicit properties on readings (`may_be_initial`, `attached`, `addresses`, `after_given`, `cased`), and `after_given` stands on a salutation reading only (decisions/H8) — 2026-10-18 #737; 2026-10-24 #835. - The two markers that record a clash with a name are derived from name readings: an acronym suffix reading beside a `given` or `family` reading is an ambiguous acronym, a particle reading beside a `given` reading a particle that may be given, wherever in the files each reading sits. The build refuses `may_be_name` and `may_be_given = true`, naming the reading to write; a particle whose word has no `given` reading still states `may_be_given = false`, never defaulted; a name reading may stand without a count, with a `note` saying why; and a count kept as evidence against marking such a word goes in the clashing reading's `note` (`ld`). `may_be_initial` is not derived — 2026-10-25 #752, A CLASH WITH A NAME IS A NAME READING. A third marker is derived the same way: a suffix word reading beside a `given` or `family` reading is a suffix word that may be a name (`SUFFIX_WORDS_AMBIGUOUS`), and an `agnomen` reading needs a name reading on its word — 2026-10-25 #752, phase 2h. - Exclusions are data: an `excluded` key refuses a kind, a category or a behavioral property, in the file of the language whose reading it refuses, and the build refuses the excluded thing in any file, quoting the reason — 2026-10-18 #737. - A per-word fact that moves into the TOML is replaced in decisions/ by a query that finds it (decisions/README.md, edit kind 4) — 2026-10-18 #737. - The script is read from the word, not the file: a script subtag is only for a difference the text cannot show — 2026-10-18 #737. - The names later phases will use (`prefer`, `strict`, the position and attachment keys) are build errors naming their phase, not inert data; locale-pack vocabulary and the Korean surname list stay in Python — 2026-10-18 #737. `bare_only`, `leading_only`, `trailing_only` and `separators` are phase 4's; `default = false` records a reading that compiles to nothing, for a pack to turn on, and no exclusion refuses it (Spanish `h`, German `i` for the dotted regnal ordinal, Czech `st`) — 2026-10-25 #752, phase 2h. `attaches` names the title kinds, or the title words, a title reading stands before in a title phrase, compiled into the phrases it makes — 2026-10-25 #752, phase 2i; 2026-10-26 #752, phase 2i (Derek). - A title reading beside a `given` reading, on a word with no particle reading, derives `TITLES_AMBIGUOUS` instead of `TITLES`: a title only inside a title phrase (rules/H9); a salutation's given-name counts kept as evidence for H8 sit in its `note`, marking nothing — 2026-10-25 #752, phase 2i. - Every language file is in build-checked order (kind, then the `same_as` path from its root head, then the codepoints of the text), the check naming the entry to move, running last, and refusing a `same_as` loop of any length; `und.toml` is exempt — 2026-10-19 #747; 2026-10-21 #762. - The particles take eight kinds beside the generic one, `expands` carrying a particle's gloss and `note` its vetting, and the particle keep-outs are exclusions — 2026-10-19 #749. - The titles take ten kinds beside the generic one, `academic` and `occupation` divided by function rather than subject, and the title keep-outs are exclusions — 2026-10-19 #750, title kinds; 2026-10-19 #750, the rest of the titles. - `wm` is no title: William's abbreviation reads as the given name it is (`Wm Smith` → given 'Wm'), and its title reading is refused in en.toml — 2026-10-22 #789. The dotted `Wm.` opening the given part reads given too, by the reading below — 2026-10-26 #789. - A given name written short is a `given` reading whose `same_as` is the full name, normalized and naming no entry, a `family` reading taking none; the word compiles into `GIVEN_NAME_ABBREVIATIONS` (`Lexicon.given_name_abbreviations`), which outranks rules/H2's shape and nothing else (decisions/H2). The traditional English abbreviations in en.toml (`chas`, `wm`) and the Filipino `ma` and `jo` in fil.toml carry one — 2026-10-23 #752; 2026-10-26 #789, phase 2k. - A particle or salutation reading with no attested use in a person's name is refused rather than kept, its counts as name readings: `bon` and `dí` (und.toml) and `heer` (nl.toml) are no particles, and the plural salutations `herren` (de.toml) and `heren` (nl.toml) no titles, a plural never standing before one person's name — 2026-10-27 #766. - Abbreviations and full words spelled alike across European languages (`dr`, `prof`, `doctor`, `professor`, `docent`) are in `mul.toml`; a credential goes to a file only on evidence that names in that language carry it, so none is in `mul.toml` — 2026-10-19 #750, the rest of the titles; 2026-10-20 #751, the credentials. - `und.toml` holds only `bon` and `dí`, of unrecorded origin, their particle readings refused (below) and their name readings kept; every other word, the credentials, conjunctions, bound given names and birth markers included, is in a language file or in `mul.toml` — 2026-10-19 #749; 2026-10-20 #751, the conjunctions. Not standing: the plans the classifications carried out, phase 1's mechanical conversion of the unclassified categories into `und.toml` (2026-10-18 #737) and the shared readings left in place until their categories were classified (2026-10-19 #749), both done by 2026-10-20 #751, the conjunctions; and #747's reason for exempting `und.toml`, its entries keeping the Python lists' order for its vetting comments to point along (2026-10-19 #747), overtaken once it held two entries and no such comment. Open: #913 (the title words phase 2j left, read as titles though borne as names), #753 (phase 3, language packs), #754 (phase 4, order, shapes and the output model). ## Log - 2026-10-21 #762 (Derek) — `und.toml` STAYS EXEMPT FROM CHECK 10 BECAUSE IT HOLDS NO LANGUAGE'S WORDS, the ground nameparser/vocab/README.md gives. The 2026-10-19 #747 entry exempted it so its entries could keep the Python lists' order that its vetting comments pointed along; #749 to #751 moved every word out but `bon` and `dí` and its per-entry comments with them, so that reason no longer holds, and tools/vocab/build.py's `UNORDERED` comment now gives this one. No compiled set or check moves. - **2026-10-15/16 — the model (Derek).** Decided over a brainstorm whose notes are gitignored; the durable decisions are these. 1. The vocabulary redesign comes before adding many packs; it is designed for 3.0 and whatever is safe is pulled forward. 2. Each word is described by its READINGS (word → reading → properties), replacing the base-plus-marker pairs. 3. Detailed kinds are recorded during the migration even where every kind of a category behaves alike at first; generation and credential are separate kinds (#326). 4. A language file means "the words the parser might encounter in names in that language" — what occurs in names and records written in it, including particles common there through immigration — not words of that language's etymology. 5. The default loads every language and keeps working for names of any language, as today; choosing a language narrows it. 6. Packs prefer and strict packs restrict: a pack settles only the clashes where its language has a VOCABULARY reading, and a strict variant also drops other languages' name-level readings. 7. `e`/`y`/`i` stay in the default, at least to start; choosing a language settles the one-case fork. 8. A patronymic field belongs in 3.0. 9. Gender is recorded only where a word gives a definitive clue. 10. Title-phrase modifiers count as titles only next to a head title (parked, not filed). (Unparked and narrowed to the head of the name, 2026-10-23 #752, below.) 11. Name readings are hand-declared, and only for words some language file claims as vocabulary, so nobody maintains surname lists. (Widened 2026-10-19, below: or refuses.) 12. Bearer counts are structured provenance (source + count), never used to compute a reading: two data sets deciding one reading would make the decision unclear. A new census flags changed names for review. 13. `Dean Smith` reads given `Dean`, as today. 14. Order and shape are separate: a language supplies only a default order; the shapes (where titles, suffixes, particles and commas sit) belong to each order and live in the core. 15. Vocabulary readings settle a clash; name readings only keep the gate on. 16. The contested-head rule covers only words outside `titles` today (`dean`, `justice`, `major`); a title that is also a given name today is decided one word at a time, like the graf class (decisions/salutation-titles). 17. `cased` and `same_as` replace a "canonical" spelling, and `cased` replaces `capitalization_exceptions`; both default to the word, and a reading's `native` spelling is for surfacing. 18. `acronym` is a reading property, replacing the `suffix_acronyms`/`suffix_words` split; `expands` records the meaning an entry was added for, not every meaning. 19. `addresses_given` generalizes to `addresses = family | given | full | any`. 20. A pack's preferred reading can outrank a reading derived by a SHAPE rule (period abbreviation, initials, capitals), not only a vocabulary reading. (Applied in the default too, 2026-10-23 #752, below: a declared `given` reading outranks the leading-abbreviation title's shape.) Phasing, fixed then: (1) the data format and a compiler to today's sets, snapshot-neutral; (2) derived ambiguity, with the gate extended to every reading kind — the step that changes behavior, with its own design; (3) pack semantics, needing 1 and 2; (4) the rules menu, order and shapes, and the output model. - **2026-10-18 #737 — phase 1 shipped: per-word TOML compiled to today's sets (approved by Derek, 2026-10-18, all eight of the spec's recommendations).** (`may_be_name` and `may_be_given = true` as written flags, `may_be_given` stated on every particle, and a property exclusion of `may_be_given` superseded by 2026-10-25 #752, which derives the two markers as this entry's flags bullet planned.) No parse, render or public name moves; it is a re-encoding plus the build checks the encoding makes possible. - **The format.** `nameparser/vocab/.toml`, one file per BCP 47 tag in canonical case (`pt-BR`, `zh-Hant`), plus `und` for words not yet assigned a language and `mul` for words shared on purpose (the Roman numerals). `schema = 1` is the only other top-level key: a new kind or property is additive and does not bump it, a change in what a key means does, and a compiler refuses a newer schema. Each `[[word]]` has a `text`, already in `_normalize` form, and zero or more readings keyed by KIND, each a TABLE (`generation = {}`, never `= true`) so a property can be added to any reading later. The file name is the language; there is no `language` key to drift from it. - **Kinds as keys, with generic category keys.** Each kind belongs to one category, and the category decides which compiled field a reading feeds. A category key is itself a kind (`title = {}`, a title not yet classified), so classifying a word later is a data edit that moves no compiled set. Kind names are unique across categories. Phase 1 classifies the SUFFIX category fully — `generation`, `credential`, `honorific`, `professional_honorific`, `status`, `courtesy` — and converts every other category mechanically into `und.toml` with its generic kind and today's flags, so the TOML is the single source from the first PR. One suffix kind per word replaces the import-time assert that the acronym and word sets are disjoint. - **Flags now, derivation in phase 2.** The marker sets became explicit properties — `may_be_given` on particles, `may_be_name` on acronym credentials, `may_be_initial` on conjunctions, `attached = { side = "after" }` for the glued peel, `addresses = "given"`, `cased` — rather than being derived from declared name readings, which would have meant declaring a given reading for `te`, `tho` and `du` that no count supports. (Corrected 2026-10-25, #752: each has a US SSA given-name count, `te` 10, `tho` 52 and `du` 27 births summed over all years, so the premise was wrong when written; the figures are on their given readings.) Measured for the later swap: deriving both ambiguity markers from name readings reproduces them exactly if the readings are declared on exactly the flagged words, because no word sits in one marker set while needing to be absent from the other (`do` is in both; `mc` and `vd`, particles and credentials, in neither, consistent with #454). Recompute: `L = Lexicon.default(); L.particles_ambiguous & (L.suffix_acronyms - L.suffix_acronyms_ambiguous)` and `L.suffix_acronyms_ambiguous & (L.particles - L.particles_ambiguous)`, both empty. - **`may_be_given` is stated on every particle, never defaulted.** Found while converting, not in the spec: the old encoding stored the NEVER-GIVEN particles and derived the rest, so a new particle was ambiguous unless argued never-given — #293's safe-by-default and decisions/vocabulary-collisions C-i's "under uncertainty, ambiguous". A flag with a default would have picked one side of that silently (a bare `particle = {}` reading as never-given is the unsafe side), so the build refuses a particle reading without it. - **Exclusions are data.** `excluded` refuses a kind or category at word level, or a behavioral property at reading level (`honorific = { excluded = { attached = "…" } }`), with a reason. It lives in the file of the language whose reading it refuses, and the build refuses the excluded thing in ANY file, quoting the reason, so a contributor adding a refused reading meets the record of why. An excluded kind refuses itself whatever its properties, and every other kind compiling to the same set, so a generic `suffix = {}` cannot put back a refused `generation` (found in the PR review); the name kinds, which compile to nothing, refuse only themselves. A refused property is refused any value other than the one it takes unwritten on a reading of the excluding kind (false for the `may_be_*` flags, true for a credential's `acronym`, so a particle can still state `may_be_given = false` beside an exclusion of it), on any reading of its category — and `cased`, compiled once per word, on every reading of the word. The pilot carried the generational-words refusals, rai/cha/mai and the glued-peel refusals; the README states the exception that lets those sentences here become queries. - **Script is read from the word, not the file**, reversing the notes' premise that Serbian Latin and Cyrillic need separate files: `sr.toml` holds `mlađi` and `млађи` side by side. A script subtag is for what the text cannot tell apart, which in practice is Simplified and Traditional Han. - **A build step, not a parse at import.** tools/vocab/build.py compiles the files into a committed nameparser/config/_compiled.py, one sorted entry per line, and the public constants stay where they were as `NAME = _compiled.NAME` assignments with their docstrings (autodoc's attribute scan needs an assignment) and the deprecated-alias bridge. tests/v2/test_vocab.py holds the committed module to a fresh compile and runs the checks with warnings as errors, the snapshot.jsonl pattern (mechanisms.md#COMPILED-DATA-COMMITTED). The TOML ships as package data for tools and later phases, and the parser never reads it. - **The comments moved with their words.** The suffix words' per-word facts became fields (`note`, `issue`, `same_as`, `expands`, `excluded`); the class-level parts of the vetting notes were checked against the decisions entries that already hold them, and what had no home went to decisions/W2's entry of this date. In the mechanically converted categories the block comments stand as TOML comments above the entry they stood above, in source order, and inline comments became reading `note`s; classifying those categories is the later work that turns them into fields. - **Measurement (2026-10-18, Python 3.11.17).** `Lexicon.default()` and all thirteen raw constants equal the pre-change tree's, compared against values pickled from master at 6803a261; `tools/differential/snapshot.py` reports "matches the snapshot". Negative control: dropping `may_be_name` from `ma` moves 120 snapshot entries and makes `Lexicon.default()` unequal, and editing the TOML without rebuilding fails the staleness test with the refresh command. `import nameparser` measured 48.5–48.8 ms median against master's 49.4 ms (25 fresh processes each, two runs), so the generated module costs nothing visible; the spike measured parsing the TOML at import at about +18 ms on a 48 ms import, and the TOML grows with descriptive data the compiled module never carries. - **Declined, with evidence.** PARSING THE TOML AT IMPORT: +18 ms net on every import (spike, 2026-10-18), and it could not be made lazy on 2.x without making the config modules and the shim's `CONSTANTS` lazy too. DERIVING THE MARKERS NOW: see the flags bullet. A `peelable` BOOLEAN: `attached` is the general property from the start, a table so its other side and separators can be added. A `language` KEY in the file: a second source of truth for the file name. - **Phases 2–4 are rejected by the compiler, not accepted as inert data**: `addresses = "full"`/`"any"`, `attached` on a side other than after, and the names later phases will use (`default`, `prefer`, `strict`, `bare_only`, `leading_only`, `trailing_only`, `attaches`, `separators`) are build errors naming their phase, so nothing reads as working that does not. Locale-pack vocabulary and the Korean surname list stay in Python: surnames are a segmentation list, outside the per-word model, and packs move in phase 3. - **Recompute the composition** rather than quoting it: `uv run python tools/vocab/query.py --kind generation --count` (readings, one per file a word is in), `--kind suffix`, `--excluded generation`, `--issue 661`. - **2026-10-19 — decision 11 widened to words a file refuses (Derek).** A name reading may also sit on a word whose vocabulary reading some file refuses: the reading is the evidence the refusal rests on, as `fils`'s `family` reading in fr.toml backs its excluded `generation`. The build already allowed this (its soft warning fires on a name reading only where the word has neither a vocabulary reading nor an exclusion), so the decision now matches the shipped data; #758's review found the mismatch. - **2026-10-19 #749 — the particles classified by language and kind.** (Check 5's agreement on `may_be_given` and the unexamined-word convention keyed on `may_be_given = true` superseded by 2026-10-25 #752, which derives the marker from a `given` reading and keys the convention on that reading.) No compiled set moves: `tools/vocab/build.py --check` reports the committed module current, and `tools/differential/snapshot.py` matches. Each particle reading left `und.toml` for the file of every language whose names it occurs in (decision 4), so one word may sit in several (`de` in five); the readings it shares with other categories (`do`, `mc` and `vd` as credentials, `abu` and the Arabic أبو/ابو as bound given names, `freiherr` and `st` as titles) stay where they were until those categories are classified. Eight particle kinds join the generic one: `preposition`, `article`, `patronymic`, `teknonymic`, `clan`, `rank`, `saint` and `alias`, defined in nameparser/vocab/README.md. The `clan`/`patronymic` line is drawn for decision 8's patronymic field: a patronymic is followed by a parent's name, a clan prefix by a family name the lineage shares, so the Irish Ó and Ní and the Gaelic Mac are clans although each began as "descendant/son/daughter of". - **One word, a kind per language, one flag.** A particle may take a different kind in each file (`de` is a preposition in French and an article in Dutch), so check 5 now compares the readings of a CATEGORY rather than of a kind: two kinds of one word disagreeing on `may_be_given` would otherwise have compiled silently to the false. The suffix category is unaffected, check 4 allowing it one kind per word. - **`expands` carries the meaning, `note` the vetting.** A particle's gloss in its language ("of the, fem. pl.") is its `expands`, so `note` keeps the meaning the comments gave it: a `may_be_given = true` word whose particle readings carry no `note` in any file, or whose note says so, has not been examined (the "bare entry" convention of the old module comments). A word's note sits on one of its files, not repeated in each, so the test is per word: `mac` is examined though only en.toml holds its note. Descriptive `gender` is recorded on the markers that name the bearer's sex (decision 9), among them `bin`/`binti`, `ibn`/`bint`, `ó`/`ní` and `freiherr`/`freiherrin` (`uv run python tools/vocab/query.py --kind particle` shows each). - **The keep-outs became exclusions**, in the file of the language whose reading they refuse: `ben` and `ال` in ar.toml, `בר` in he.toml, `nic` and unaccented `o` in ga.toml, `bt` and `bte` in ms.toml (`uv run python tools/vocab/query.py --excluded particle`). decisions/vocabulary-exclusions' particle block keeps its class-level bullets and points at the query for the rest. - **Two words keep the generic kind in `und.toml`**: `bon` and `dí`, in the prefix list since the earliest history (2011 and 2012) with no record of which names they come from, and with no particle use found. Both are borne as names (the counts are on their entries); removing them moves a compiled set, so it is left to its own change. - **Recompute** rather than quoting: `uv run python tools/vocab/query.py --kind particle --count` (readings, one per file a word is in), `--kind clan`, `--file nl --kind particle`. - **2026-10-19 #747 — a build-checked word order (check 10).** No compiled set moves: the 41 language files that were out of order on cc030b9c were sorted (an entry moving with any comment above it), `tools/vocab/build.py --check` reports the committed module current, and `tools/differential/snapshot.py` matches. The order, stated in nameparser/vocab/README.md's Word order section, is kind in `CATEGORY`'s order, then the word's `same_as` path from its root head, then text, so the spellings of one word sit together under it before #750 moves the titles out of `und.toml`. - **Checked, not rewritten.** `tomllib` only reads, and a comment above an entry belongs to it, which no TOML reader can see; a rewriter would have to round-trip the files' comments. No checked file holds one on 2026-10-19 (every per-entry comment is in `und.toml`), but a word leaving `und.toml` before its category is classified carries its vetting comment with it. So the build refuses a file out of order, naming the entry to move (the first outside a longest run already in order, so a word appended at the end is named rather than its neighbours) and its place against that run, so following the messages takes the fewest moves (#767's second review found the place first given beside a neighbour itself out of place, which took up to 22 moves where 10 would do). - **Run last.** Check 10 runs after every other check, so a contributor adding a refused word to a language file meets the exclusion's reason (check 6) rather than an order error, the precedence check 6 is placed first for; the first version ran it at the end of loading, ahead of check 6, and the review of #767 found an excluded word appended to en.toml reported as out of order. - **The same_as path, not the direct target, and the file's own path.** Keyed by the direct target, a spelling of a spelling sorts apart from the word it spells: ar.toml's `ed-` (same_as `el-`, itself same_as `al-`) would follow `ul-`. The path keeps each spelling under its own head inside the root's group, and it follows only the file's own entries, so a word that is a head in one file and a spelling in another does not make one file's order depend on the other (#767's review found the first version, which followed a map taking each word's first file, accepting `jnr` before `jr` in en.toml once de.toml made `jr` a spelling). - **No same_as loop, over every same_as written.** Check 7 refuses only a same_as naming its own word. Check 10 refuses any longer loop, in every file, `und.toml` included, over every same_as of every reading: no check holds a word to one head across files or kinds, and the first version, which walked one head per word, passed a loop running through a second file's or a second kind's same_as. - **Codepoint order of the normalized text.** Defined for every script and every machine, and no one language's collation; the cost is that within a script a precomposed accented letter follows `z` (`aḍ-` after `az-`), and that a traditional and a simplified spelling with no `same_as` between them sort apart (`課長` and `课长` did, until #790 linked the pairs in `zh.toml`). - **A refusal sorts under the kind it refuses.** Forty-six language-file entries on 20b595b2 (`senior`, `fils`, the Slavic `ml` and `st`) hold no vocabulary reading, only an exclusion and sometimes the name reading that is its evidence; they sort beside the words they were refused as. (Corrected 2026-10-19 in #750: the figure was dated "2026-10-19", which #750's title refusals made ambiguous the same day, and the next bullet quoted a und.toml comment that #750 moved out.) - **`und.toml` is exempt.** Its entries keep the Python lists' order, which its vetting comments still point along (a neighbour "above" or "below"); each category's classification moves its words out, into files that are checked. - **2026-10-19 #750 — title kinds, and the salutations and given-addressing titles classified (part 1 of #750; kinds approved by Derek in chat, 2026-10-19).** No compiled set moves: `tools/vocab/build.py --check` reports the committed module current, and `tools/differential/snapshot.py` matches. Ten title kinds join the generic one, defined in nameparser/vocab/README.md: `salutation`, `style`, `academic`, `religious`, `nobility`, `military`, `office`, `occupation`, `kinship` and `modifier`. Two lines are drawn on purpose. `academic` and `occupation` divide by FUNCTION, not subject: a form of address (`Dr`, the Egyptian `مهندس`) against a job described before a name (`chemist`, `novelist`), so the descriptive words, the collision-prone half of the old bulk import, sit in one kind any later gate can name. `modifier` is decision 10's parked class (`vice`, `grand`, `his`, `1st`), recorded as data now and behaving as every title does until that rule is designed. A household kind was proposed and folded into `occupation` (it would have held about five words). - **The hard cases, decided with the kinds.** `prince`, `princess`, `sir` and `dame` are `nobility`, the last two as chivalric ranks, and `lord`, `lady` and `knight` will be when their kinds are assigned (done in part 2, the same day); `master` is a `salutation`, the style for a boy that is why it addresses by the given name; `father`, `mother`, `brother` and `sister` are `religious`, the use a title makes of them, while `aunt` and `uncle` are `kinship`; the sheikh cluster is a `style`, used for rulers, elders and scholars alike; `chief` will be `nobility` and `deputy` an `office`, not modifiers, since each stands alone as a title ("Chief Obafemi Awolowo"); the Bengali মোঃ and মোসাঃ are salutations, keeping the note that মোঃ abbreviates a name. - **Gender only on a marked word** (decision 9): a feminine form or one half of a fixed pair (`mr` and `mrs`, `king` and `queen`), and `mx` as `"n"`; an unmarked noun (`actor`, `pope`, `doctor`) records none. - **Files.** Each reading went to the file of every language whose names it occurs in, a Latin transliteration to its source language's file as #749 put the Latin `bin` in ar.toml (`swami` in hi.toml and bn.toml, the sheikh cluster in ar.toml, `cheikh` in fr.toml too). Six files are new: ak, et, id, ro, sw and vi. The few abbreviations shared across European languages (`dr`, `prof`) will go to `mul.toml` (done in part 2, the same day) with the rest of their kind; no salutation qualified, each language writing its own. - **The title keep-outs became exclusions**, in the file of the language whose reading they refuse, with their counts as name readings where counts were taken (malam, bay, shehu): `uv run python tools/vocab/query.py --excluded title`. The per-word counts in decisions/salutation-titles moved onto the `note` of each word's title reading, and the prose points at the query. The reasons the given-addressing comments gave for leaving `lord`, `lady`, `venerable`, `rabbi` and `imam` out of GIVEN_NAME_TITLES are notes on those words in und.toml until their kinds are assigned (they moved with them in part 2, the same day). - **Recompute** rather than quoting: `uv run python tools/vocab/query.py --kind salutation --count`, `--kind title --file und --count` (the titles still in `und.toml`, all of them generic until part 2 emptied it the same day, leaving one generic title, `wm`, in en.toml; `--kind title` names the whole category, so no query counts the generic kind alone), `--excluded title`. - **2026-10-19 #750 — the rest of the titles classified (part 2 of #750)** (its `wm` decision superseded by 2026-10-22 #789, its `md` academic reading by #853, decisions/indic-honorifics, and its measurement corrected in place). No compiled set moves: `tools/vocab/build.py --check` reports the committed module current, and `tools/differential/snapshot.py` matches. The 644 titles still generic in `und.toml` on e943665e took their kinds, all but `wm` (below), and left for the files of the languages whose names they occur in, so `und.toml` holds no title. Part 1's hard cases are applied as decided there: `lord`, `lady`, `knight` and `chief` are `nobility`, `deputy` an `office`, and `dr` and `prof` are in mul.toml. - **English holds most of them**: the bulk import's occupations, the US military ranks and their abbreviations, the offices, and the modifiers. `modifier` takes what is a title only inside a phrase: the qualifiers of ranks and offices (`vice`, `rear`, `petty`, `field`), the domain words of the descriptive titles (`baseball`, `software`, `tax`), the ordinals, and the court words of #9's list of judges' titles (`designated`, `docket`, `pro`, `edmi`). - **Address, not subject, decides `academic`.** Besides the doctors and professors of every script, it takes the English words used to address a professional (`advocate`, `attorney`, `engineer`, `nurse`, and `md` as the medical degree written before a name), the German abbreviations written before a name (`ra`, Rechtsanwalt; `wp`, Wirtschaftsprüfer; `pd` and `priv.-doz`; `dipl.-ing`), and the Devanagari `पंडित` and `पं`, a scholar's title, as part 1's refusal of the Latin `pandit` already keyed it. - **`wm` keeps the generic kind**, in en.toml: it is William's abbreviation, made a title for #68 so that `Wm. Scott Magargee III` would not read given `Wm.`, and no title kind describes it. Removing it moves a parse, though only of the bare spelling: `Wm Smith` reads title `Wm` with the word listed and given `Wm` without it, while the period-abbreviation shape rule takes `Wm.` either way (measured 2026-10-19 with `Lexicon.default().remove(titles={'wm'})`). So it is left to its own change. (Corrected 2026-10-24, #789: the measurement was wrong on its date. It took the off-switch parse at the head of the name only; a trailing `Wm.`, rules/H5's since 2.3.0, and a `Wm.` before a family comma, rules/H6's since 2.5.0, read vocabulary and move with the bare spelling.) - **mul.toml** takes `docent`, `doctor` and `professor` beside `dr` and `prof`: full words spelled alike in several European languages, listed once rather than in a file per language. - **A transliteration goes to its source language**, as in part 1: the Islamic titles to ar.toml (`imam`, `mufti`, `mahdi`, `murshid`, `hajji`, `vizier`), or to fa.toml where the Latin spelling is the Persian form (`ayatollah`, `akhoond`, `mullah`, `pir`); `rabbi` to he.toml and `rebbe` to yi.toml; `maharajah`, `maharani` and `subedar` to hi.toml; `giani` and `gyani` to pa.toml; `tsar` and `tsarina` to ru.toml; `effendi` to tr.toml; `vardapet` to hy.toml. The Buddhist, Jain and Zoroastrian titles (`arhat`, `bodhisattva`, `buddha`, `siddha`, `tirthankar`, `saoshyant`) are in en.toml: no source-language name is written with these Latin spellings, which are English loanwords. Four files are new: bin (Edo, `edohen` and `ekegbian`), ff (Fula, `lamido`), mi (Māori, `rangatira`) and yi. - **Gender** follows part 1's rule. The man's title of a noble pair is marked (`count`, `baron`, `graf`) as `king` is, and so is a `-man` word whose `-woman` pair the list holds (`businessman`); a descriptive noun beside its `-ess` form marks only the feminine (`actress`, `heiress`). `sultan` records none, Ottoman usage having given it to imperial women too (its note in tr.toml). - **Comments became fields.** The #269, #343 and #344 block comments over the native-script titles are notes on one entry of each group (the Hebrew quote-boundary probe on `ד"ר`, the Bengali doctor split on `ড`); `duke`'s one-line keep-out is an exclusion in en.toml with its counts; the notes part 1 left on `lord`, `lady`, `venerable`, `rabbi` and `imam` moved with them. The readings `und.toml` shares with a moved title, all credentials (`cpl`, `cpo`, `cpt`, `csm`, `lt`, `md`, `ra`, `sa`, `sgm`, `vc`, and `ms` from part 1), sit in a section of their own, as #749's did. - **Recompute** rather than quoting: `uv run python tools/vocab/query.py --kind title --file und --count`, `--file en --kind modifier --count`, `--file mul`. - **2026-10-20 #751 — the credentials given their languages (part 1 of #751).** No compiled set moves: `tools/vocab/build.py --check` reports the committed module current, and `tools/differential/snapshot.py` matches. The 605 credential readings in `und.toml` on a0edfeda left it, so `und.toml` holds no suffix reading, and the three credential refusals (`rai`, `cha`, `mai`) went with them to en.toml. - **English holds all but seven.** 505 of the 605 came from one import (af5bdab, 2019, #93), the United States post-nominal letters of two Wikipedia lists, and most of the rest are British and Commonwealth honours and degrees, from the list's first versions and #7; these, and the degrees below, are in en.toml. Recompute the provenance with `git blame -w 6803a261 -- nameparser/config/suffixes.py`, the last commit before the TOML. - **German names hold seven, alone.** #18 (3e14ea20) added the German parliamentary memberships (`mdb`, `mdl`, `mda`, `mdbb`, `mdep`, `mdhb`) and `stb` (Steuerberater) for German names, German by what they mean; they are in de.toml alone, with `expands`. The degrees #18 wrote AFTER a name are not there (`dipl.-ing`, written before one, is a de.toml title since #750). Its test data writes BA, BSc, M.A., M.Sc., M.Eng., B. Eng., MBA and LL.M. beside one name, John Meyer, and #18 itself calls them international degrees rather than German names; 3e14ea20 added `ba`, `bsc` and `meng` from those rows only because the list lacked them, `ma`, `msc`, `mba` and `llm` being listed already. So those seven credentials are in en.toml alone (B. Eng. is in no file). A word goes to a file on evidence that names in that language carry it, and that is why no credential went to mul.toml either: the Bologna degrees are written after names in most European languages, but nothing in hand shows which. - **Comments became fields.** und.toml's block over the ambiguous credentials was class-level — the criterion is decisions/suffix-acronym-collisions' and decisions/vocabulary-collisions' C-i, the reading rules/S4 and S6, the bracket escape rules/S1 — and is gone, its per-word facts already notes on `ms`, `sa`, `se`, `om`, `mc`, `vd`, `meng` and `lac`; `ba` gained the note that earned its marking. The block over the case masks became `excluded = { cased = … }` on `meng`, `edd`, `lac` and `ded`, each with the rendering a mask would cost (decisions/vocabulary-exclusions' CAPITALIZATION_EXCEPTIONS block keeps the class-level part), and the credential half of `abd`'s comment became `expands` and a `note` on its en.toml reading. - **`mc` sorts as a credential now.** An entry sorts under its earliest kind and `credential` precedes `clan`, so en.toml's `mc` left `mac`'s side for the credential block, its `same_as` kept. - **Recompute** rather than quoting: `uv run python tools/vocab/query.py --kind credential --file en --count`, `--kind credential --file de`, `--kind suffix --file und --count`, `--excluded cased`. - **2026-10-20 #751 — the conjunctions, bound given names and birth markers given their languages (part 2 of #751).** No compiled set moves: `tools/vocab/build.py --check` reports the committed module current, and `tools/differential/snapshot.py` matches. The 44 entries still in `und.toml` on 85ac4167 other than `bon` and `dí` left it, so `und.toml` holds only those two particles of unrecorded origin, and the comment at its top says why (#766 asks whether they stay at all). - **Each went where its own record put it.** The conjunctions follow the #269 and #397 issues and the names their comments gave: `and`, `of` and `the` to en.toml, `et` to fr.toml, `und` to de.toml, `y` to es.toml, `e` to pt.toml and it.toml, `i` to ca.toml and pl.toml, `и` to ru.toml and bg.toml, `і`, `й` and `та` to uk.toml, `και` to el.toml, `و` to ar.toml, and `&` to mul.toml, a symbol no language owns. The bound given names, Latin transliterations and Arabic script alike, went to ar.toml, as #749 and #750 sent a transliteration to its source language; `abu`, `أبو` and `ابو` join the teknonymic readings already there. The birth markers follow the old module docstring (#274): `née`, `né` and the unaccented `nee` to fr.toml, which the docstring calls French, and to en.toml, English writing them as French does; `geb` and `geborene` to de.toml and `geboren` to nl.toml, which the docstring calls Dutch; `rozená` to cs.toml; `født` to da.toml and nb.toml, the Nynorsk `fødd` to nn.toml and `född` to sv.toml; the five Russian spellings to ru.toml; `z domu` to pl.toml (#434); `旧姓` to ja.toml (#309). Two files are new: ca and nn. - **Comments became fields.** The block over the single-letter connectives (#383, #479, #397) was class-level, held by decisions/P8 and rules/P3, and is gone; its per-word facts are notes on `e` and `i`, and its keep-outs, `y` and the Cyrillic `и`, `і` and `й`, are `excluded = { may_be_initial = … }` (decisions/vocabulary-exclusions' conjunctions_ambiguous block keeps the class-level part). The #269 blocks over the Cyrillic, Greek and Arabic connectives and the Arabic-script bound given names became an `issue` on each and notes on `і`, `و` and `عبد`; `abd`'s comment became its note in ar.toml, beside the credential note part 1 left in en.toml. Spellings carry `same_as` where one word has several (`abdel` and `abdal` of `abdul`, `abou` of `abu`, `й` of `і`, `nee` of `née`, the е spellings of the ё ones), and gender is recorded where the form marks it (`née` and `né`, `geborene`, `rozená`, the Russian participles). - **Two keep-outs became data that had been prose.** The Scandinavian abbreviation `f.`, which collides with the initial F., is an exclusion in da.toml. `roz`, removed from the birth markers on 2026-08-26, is an exclusion in cs.toml with its reason, measured again: with it listed, `Rosalind Roz Smith` reads maiden 'Smith' and takes Rosalind for the family, where the 2026-08-26 record says it left no family name at all. - **Recompute** rather than quoting: `uv run python tools/vocab/query.py --file und`, `--kind conjunction`, `--kind bound_given --file ar --count`, `--kind birth_marker`, `--excluded may_be_initial`, `--excluded birth_marker`. - **2026-10-22 #789 (Derek) — `wm` LEAVES THE TITLES.** SUPERSEDES: the `wm` bullet of 2026-10-19 #750, the rest of the titles. #68 asked that `Wm. Scott Magargee III` not read given name `Wm.`, and `wm` was listed as a title for it in 0.5.5. The request was mistaken: `Wm` is William written short, and a name it opens is a man named William, which a reader of the name would not hesitate over. The case came back from the FEC donor list (`tools/differential/local_corpora.py`, fec-individuals), where bare `WM` opened the given part of 69 names (re-measured 2026-10-24 on 08d130ed), every one read as a title with the given name taken from the next word or left empty. The title reading is refused in en.toml with that reason, so a contributor who adds it back meets it as a build error. - **The dotted spelling is split by position, not by spelling.** rules/H2's shape reads an unlisted period-marked abbreviation opening the given part as a title, so `Wm. Smith` keeps title 'Wm.' and `Smith, Wm.` too; making `Wm.`, `Chas.`, `Geo.`, `Ma.` or `Jo.` a given name there is a reading that outranks a shape rule, phase 2's question (#752, D14(b) of its spec), and tests/v2/cases.py pins today's `Wm. Smith` so that change shows. Where VOCABULARY reads the word, the dotted spelling moves with the bare one: a trailing `Wm.` was rules/H5's title (`John Smith Wm.` → family 'Wm.', `Smith, John Wm.` → middle 'Wm.', the readings of 1.4.0 through 2.2.0), and a `Wm.` opening the part before a family comma was rules/H6's (`Wm. Smith, John` → family 'Wm. Smith', the reading of 1.4.0 through 2.4.0). The 2026-10-19 bullet said removal moves only the bare spelling, and is corrected in place. - **Measured** against 08d130ed (`snapshot.py --local` with the base recorded by `--tree`): no committed corpus holds the token `wm`, so the snapshot and the CLDR, GND and Wikidata agreement views could not move and did not; psc-individuals' field agreements do not move either. fec-individuals moves 77 of 1,893,964 names: 69 with bare `WM` opening the given part, 3 with bare `WM` behind another title in it, 3 with a trailing `WM.` and 2 with `WM.` before the family comma. ctgov-officials moves 1 of 352,104, `Wm` behind a title before a family comma. - **2026-10-24 #835 — `after_given`, the salutations that may end a given part.** A behavioral title property, allowed on a `salutation` reading only (a build check, since an office or rank word is borne as a middle name), compiles into `AFTER_GIVEN_TITLES` and `Lexicon.after_given_titles`, read by rules/H8. A salutation borne as a name refuses it with an exclusion carrying its counts. The rule is keyed on a property rather than on the kind, which would make a kind compile into a behavior where every other behavior is a property; the vetting and the measurement are decisions/H8's. Adding the field changes `Lexicon`'s pickle layout, as 2.1 and 2.3 did. - **Recompute** rather than quoting: `uv run python tools/vocab/query.py --kind salutation`, `--excluded after_given`. - **2026-10-23 #752 (Derek) — PHASE 2'S DESIGN IS AGREED: READINGS FROM THREE SOURCES IN ONE POOL, THE TWO NAME MARKERS DERIVED, AND ONE GATE OF FOUR STEPS (FLOOR, FORM, STRUCTURE, COUNT) ASKED AT THE SITES THAT DECIDE TODAY.** Derek agreed every recommendation of the design but the one on `;` (2026-10-23); that one and two points the re-measurement raised he decided on 2026-10-24 (the 2026-10-24 #752 entry: the `;` change, the title order and #867/#869's words). The design overview is docs/design/vocabulary-model-rfc.md's Phase 2 section; this entry is the record. Measurements are dated snapshots: each was taken with a spike, a throwaway patch of switches over master `638685b4` (2026-10-24, Python 3.11.17; an earlier round on `48ed448d`), which is not kept, so a figure here is evidence for the decision and each sub-phase PR re-measures on its own base. The measures, main one first: the PSC register's fielded names (`uv run python tools/differential/local_agreement.py psc-individuals`, 1,039,171 records scored, 1,004,775 agreeing on every field on `638685b4`), counted as records fixed, broken and changed; the committed corpora (`snapshot.py`); the CLDR and GND agreement records (`cldr_agreement.py`); the local snapshots of ctgov-officials, tse-donors and fec-individuals (`snapshot.py --local`); and spelling consistency, how many persons' two spellings go from reading differently to reading alike and back (mechanisms.md#PAIRED-SPELLINGS-PER-CHANGE). Local-corpus figures are counts and token shapes: `Aa` a word in Title case, `AA` in capitals, `A.` a dotted letter. 1. **A name reading may be declared without a count, with a required `note` saying why.** Deriving the markers needs a `given` reading on 38 of the 40 may-be-given particles and a `given` or `family` reading on 7 of the 44 may-be-name credentials (`ba`, `do`, `ed`, `jd`, `lac`, `ma`, `meng`), and 12 of those particles carry no vetting note at all (`aan`, `aen`, `bat`, `da`, `dal`, `den`, `santa`, `tho`, `thoe`, `vande`, `vander`, `vel`). The note keeps the vocab README's unexamined test working. Recompute: `uv run python -c "import tomllib,glob; from nameparser import Lexicon as X; L=X.default(); n={w['text'] for f in glob.glob('nameparser/vocab/*.toml') for w in tomllib.load(open(f,'rb'))['word'] if 'given' in w or 'family' in w}; print(sorted(L.suffix_acronyms_ambiguous-n), len(L.particles_ambiguous-n))"`. 2. **`ld` keeps no name reading; its count moves into the credential reading's `note`, and it stays ungated.** Its `given` reading records the evidence for NOT marking it (45 SSA births against 11,703 NPPES providers writing the credential, #797); a name reading that would turn the gate on contradicts decision 15's "name readings only keep the gate on" when the evidence is against the gate. Literal derivation would have moved 0 committed entries, 3 ctgov and 5 FEC readings, nothing in PSC. 3. **A particle with no given reading still states `may_be_given = false`, and `may_be_given = true` is refused with a message naming the `given` reading to write instead**, so a new particle cannot fall to the unsafe side silently (#293's safe-by-default; decisions/vocabulary-collisions C-i). 4. **"One gate for six rules" is four clashes and two floors.** The title run's floor (rules/H3) and bound given names (rules/P5) reach every member of their category and read only a count; no second reading is weighed, so they are step 1, unchanged. The acronym gate's nine decision sites stay nine, each keeping the order it reads its cues in (C4 the count before the case, S6 the case before the count); what becomes one is the predicate each asks. 5. **Joined credentials land: a word joined by `-` or `/` whose every chunk is listed and none ambiguous reads as a credential, and an unlisted word of capitals so joined, with a chunk of two or more letters, joins the ambiguous class after a comma, gated by C6's case contrast.** The stricter chunk test is because a hyphen also joins names (`Smith-Jones`). Together they moved 1,782 distinct ctgov readings (1,254 and 759, overlapping on 231), nothing in PSC, the committed corpora or the agreement records; FEC is one case, so the contrast never admits a word there. 6. **A split credential merges only where its first chunk has two or more letters, and the merged piece is a member of the ambiguous class, decided by C4's count and S4's words to spare; it starts S8's run (#827), landing after the numeral predicate answers the numeral before a credential.** FEC decided the opening letter: it writes a middle initial before a trailing salutation (`SMITH, JOHN A. MS.`), and `A. MS.` folds to the listed `ams` (merging any pair ending its part, but for two single letters, moved 5,122 FEC names, `A. MS.` 2,246 of them). A two-letter opening chunk needs the count: the 30 FEC merges it made all stood behind a one-word family (`SAM E.` → `same`), against 351 of 352 ctgov merges behind two or more name words. Accepted cost: `M. D.` and `M D` stay unmerged. Left to the split-credential PR: rules/S8 refuses a member of the ambiguous class as a run start, so how a merged piece can be both is that PR's to state (today's `Ph. D.` merge is unambiguous, and S8's #827 row covers it). (Carried out, and the run start stated, by the 2026-10-25 #752, phase 2e entry below, decisions/S12. Corrected there too: `M. Sc.` opens with a single letter as well and stays unmerged, so the tally's figure of officials still reversing left it out.) 7. **A word taken into the credential's run by position alone (`John Smith PhD Jones`) gets no new ambiguity kind.** It is not a clash between two readings; the surfaced reading's source tells it from a word with a declared name reading (item 13). This answers #785's fourth question; its first three (case drops the report and starts the run, the one-case row keeps its report) land through the pool (carried out by phase 2g, the 2026-10-25 entry below), since retagging the word was measured to erase its other readings (`Doe, John DO` became family `DO Doe` once `do` stopped being an ambiguous acronym, its particle reading meeting P6). 8. (narrowed by the 2026-10-25 #752, phase 2f entry: in the given part after a family comma a one-letter numeral the vocabulary lists stays the suffix, and a one-letter numeral before a credential stays a name word) **Every numeral rule asks one predicate, answering for `i` to `x` the way S10's trailing fork does (shape, the count for one letter, an initial before it, the period, the case), with three readings stated:** a lone letter ending the given part ahead of a further comma is the initial (#819; `Smith, John PSM I, Jr.` the accepted cost), a lone letter in a given part after a family comma stays the initial, and a Title-case numeral in a name of mixed case leans to the name. It answers #784 (`John Smith VI MD` reads family `VI` where `III` starts the run) and #774. 9. **Generation readings are gated by their kind's cue, and a reading with none is `default = false`, compiled to nothing by default and turned on by a pack in phase 3.** `fils` is gated by case and the count. The Brazilian agnomens `Filho`, `Filha`, `Neto`, `Neta`, `Netto`, `Sobrinho` and `Sobrinha` become gated generation readings in `pt.toml` (`pt-BR` where only Brazil writes one), with the count as their only cue: they are written in the case of the name around them. In tse-donors, 405 filed/registry pairs differ only by a trailing agnomen; master gives both spellings the same last family word in 1, and a gate emulated as an ambiguous acronym, so carrying S6's Title-case lean beside the count, in all 405, keeping every PSC surname (`Ana Neto`) and FEC given name (`SMITH, NETO`) the plain reading broke, by the count and the lean. The count-only gate the design chooses was not measured: it is to be measured before 2h lands. They are not part of the surname: a surname is what a family shares, and an agnomen marks the bearer against a relative of the same name, as `Júnior` does. Spanish `H.` (*hijo*), German regnal `I.` and Czech `st.` are `default = false`. When 2h lands it replaces decisions/generational-words' Standing line that the agnomens stay family names. 10. **Decision 10 is unparked at the head of the name only: a modifier is a title only beside a head title there, never in a comma part,** after `resident` and `rt` move to `occupation` and `pro` to `academic`. At the head it fixed 177 PSC records and broke 7; in a comma part it reverses names whose part after the comma is the person's role (247 of 367 ctgov comma-part moves, the part being exactly `Resident` 120 times and `resident` 50, counted as the family taking master's given name). 11. **A contested title's phrase partner is a modifier or a style before it, `of` after it, or a head title after it that the word `attaches` to;** a salutation is never one, and nor is a title before the word that is neither modifier nor style (#853). `attaches` is a property of a title reading naming the title kinds it qualifies, `chief` attaching to `office` keeping `Chief Justice`. 12. **#853 lands first, as one rule: a title word with a declared given reading, standing after another title that is not its partner, is the given name. Given readings are then declared word by word, each batch its own measured PR;** `lama`, `lord`, `sir`, `lady`, `hon`, `baron` and `professor` stay titles, since PSC's filers type them in the title box; `title-or-name` is reported only where the rule decided against a declared name reading. Measured with nine words out of `titles` (`sheikh`, `shaikh`, `wing`, `prince`, `princess`, `sultan`, `sultana`, `mahdi`, `king`): the word after another title fixed 644 PSC records and broke none; alone at the head it fixed 152 and set 24 aside (title-box filings the scorer stops scoring once the word is no longer listed). That run took the words out of `titles`; the #853 rule is to be measured on its own before it lands. The recommended order, by forename against title-box filings in PSC, was `sultan`, `mahdi`, `sultana`, `wing`, `sheikh`/`shaikh`, `princess`, `prince`, `king`; it and two additions were re-decided in the 2026-10-24 #752 entry. `md` was settled outside phase 2 by #860. 13. **The reading not taken surfaces as `Ambiguity.alternative` in a 2.x minor, with the pickle guard widened to accept a field added later and every narrow-guard skew still refused; `Token.reading` waits for 3.0.** The parser holds tags, not readings, so a token's reading needs a per-word reading index compiled for parse time, the first parse-time use of the TOML's descriptive data, and phase 1's import cost has to be re-taken. 14. **Abbreviated given names outrank the leading-abbreviation title (#789's second half): `given` readings with `same_as` the full name, the English set, `ma` and `jo` in `fil.toml`, and the dotted `wm.`.** `ma` and `jo` fixed 86 PSC records (`Mrs Ma. Liza …` filings) and broke none; the English set moved 2 FEC names and nothing else. It changes rules/H2's own example, `Smith, Ma. John` → given `Ma.`. This is decision 20 applied in the default; decision 15, that name readings only keep the gate on, is about a clash between vocabulary readings that a pack settles, not a name reading against a shape reading. The first half, bare `wm` out of the titles, is Derek's 2026-10-22 split (the 2026-10-22 #789 entry), shipped in #873. 15. **The reserved names: `attaches` and `default` gain their meaning in phase 2; `bare_only`, `leading_only`, `trailing_only` and `separators` stay reserved,** their messages naming the phase that needs them: the gate's slots carry position, rules/P7 settles `bare_only` by shape, and `separators` belongs with `attached`'s other side in phase 4. 16. **The bracket escape reads the derived clash (#810): a bracketed member of the ambiguous class escapes the nickname reading where its writing settles it, with interior periods, or in capitals in a name of mixed case, the case asked of the whole name.** `(Ed)` stays a nickname in every case, and so does content standing inside the name rather than after it (`Jean 'JD' Smith`, whose `JD` an escape asking the word's own case moved to the middle name). Recorded in rules/S1 when 2l lands. 17. **PSC's fielded names are the main measure for every phase-2 behavior PR, which reports fixed, broken and changed records beside the committed snapshot,** since it is the one corpus whose filers separated the fields; spelling consistency is read beside it, never instead. The rules: rules/S8 carries #827's row, which S8's present wording already covers. Every other behavior settled here changes a rule's statement (rules/H2's own example, rules/S1's Except for the ambiguous class, the numeral wording of rules/S8 and rules/S10, and the front of the name), so its rules amendment lands with its sub-phase PR under the same-PR amendment rule rather than as `deviates:` rows now. Declined, with evidence: - THE RFC'S PARTNER WORDING, "after another title word or a modifier, before a phrase link or before a head title": with `dean`, `justice` and `major` given readings it broke 1,314 PSC records (`Mr Dean Smith` → title `Mr Dean`). - ANY TITLE AFTER THE WORD AS A PARTNER: 99 of the 106 FEC names it moved were a given name and its trailing salutation read as a phrase (`SMITH, DEAN MR.`), so only a head the word `attaches` to is kept (item 11). - LISTING `vi` TO `x` AS SUFFIX WORDS: 152 FEC moves, about 130 wrong (`SMITH, JOHN X`, a middle initial read as a generation), and S10's report silenced on every spelling. - RETAGGING A CAPITALS CREDENTIAL AS UNAMBIGUOUS (#785): it erased the word's other readings (item 7). - THE AGNOMENS AS PLAIN GENERATION READINGS: 32 PSC records broken, `Ana Neto` among them. - MERGING ANY ADJACENT PAIR INTO A CREDENTIAL: 5,890 FEC moves, the middle initial before a salutation (item 6). - DECISION 10 IN A COMMA PART: it reverses the names whose comma part is the role (item 10). - **2026-10-24 #752 (Derek) — THE `;` CHANGE IS A 2.x CHANGE OUTSIDE PHASE 2, AND THE TITLE BATCHES GO IN ORDER OF LONE-HEAD BREAKAGE, WITH `buddha`, `siddha` AND `gyani` IN THE DEFAULT GATE AND `guru` LEFT TO A `ne` PACK.** - **`;`.** Derek decided to investigate adding `;` to the default suffix delimiters and making a trailing delimiter count, as a 2.x change outside phase 2. It shipped as #871: of the 494 ctgov names holding a `;`, 443 moved, 438 of them a name read reversed (its given name taken from after the comma) now read with its given name from before it, and nothing moved in PSC, FEC or tse. - **The order.** Derek: "use lone-head breakage". Ranked from the nine-word measurement without `md` (the 2026-10-23 #752 entry's item 12), fewest title-box filings set aside first, ties by more fixed: `wing` (25 fixed, 0 set aside); `mahdi` with `bard` (19, 0; `bard` 1, 0); `buddha`, `siddha`, `gyani`; `king` (12, 0; its cost is the royals the committed corpora hold, `King Henry`, which PSC does not); `sultana` (8, 0); `sultan` (19, 1); `shaikh` (7, 1); `princess` (10, 4); `prince` (25, 6); `sheikh` (27, 12). `sheikh` and `shaikh` are separate batches. It replaces the order by forename against title-box filings recorded in the 2026-10-23 #752 entry's item 12. - **#867 and #869's words.** `bard` (an occupation title from the 2014 list; the Norwegian given name `Bård`) joins `mahdi`: with both out of `titles`, PSC 79 fixed, 0 broken; FEC 20 names read right; committed 0. Derek agreed that `buddha`, `siddha` and `gyani` go in the default gate, which nepal-candidates measured at 120 romanized/Devanagari pairs brought to agree (172 names open with the four words) at no committed, PSC or FEC cost. `guru` goes to a `ne` pack in phase 3, beside #870's `श्री`: its title reading is #346's renunciate class, its Devanagari twin `गुरु` must move with it (44 nepal pairs go apart when only the Latin spelling moves), and the committed corpora's `Guru Nanak` and `Guru Gobind Singh` read it as a title. - **2026-10-24 #872 — THE RE-MEASUREMENT ON `638685b4` AND THE PRS THAT MERGED AFTER IT ADD THE PAIRED-INITIALS PREDICATE TO THE 2026-10-23 #752 ENTRY'S SPLIT CREDENTIALS, CHANGING NO AGREED DECISION.** - **Paired initials.** #872 made the split-credential judgment for the joined spelling (`A.B.`, two dotted single letters in a script with capitals, outside the slot right after a comma), so the split merge asks #872's `is_paired_initials` for that case once its inline script condition is lifted into a predicate that classify and the split merge share. C5's comma reading keeps the bare predicate: `John Smith, พ.บ.` reads given `พ.บ.` today, and moving it would change that. - Marking `sa` possibly a name, #857's first proposal and measured in the same re-measurement, was declined for its removal: decisions/C8's 2026-10-25 #857 entry. - **2026-10-25 #752 — A CLASH WITH A NAME IS A NAME READING: phase 2a derives the two name-clash markers, carrying out items 1 to 3 of the 2026-10-23 #752 entry.** SUPERSEDES: in 2026-10-18 #737, the flags bullet, the stated-on-every-particle bullet and the Exclusions bullet's `may_be_given = false` beside an exclusion of it (only behavioral properties may be excluded, and `may_be_given` is now a stated one); in 2026-10-19 #749, the `may_be_given` agreement and the unexamined-word convention keyed on `may_be_given = true`, re-keyed on the given reading. No compiled set moves: `tools/vocab/build.py` rebuilds `nameparser/config/_compiled.py` byte for byte. `SUFFIX_ACRONYMS_AMBIGUOUS` is now the words with an acronym suffix reading and a `given` or `family` reading, and the particles outside `NON_GIVEN_NAME_PARTICLES` the words with a particle reading and a `given` reading, each wherever in the files the two readings sit (build check 11). The model's reason (decision 15, name readings keep the gate on) is unchanged; what changes is that the reading, not a flag beside it, is what the build reads, so a clash cannot be marked without the evidence that it is one, nor carry evidence and stay unmarked. - **The readings written.** Every flagged word that lacked one got a name reading: the seven credentials `ba`, `do`, `ed`, `jd`, `lac`, `ma` and `meng`, and the 38 flagged particles other than `bon` and `dí`, `do` among them, so 44 words and 49 readings (a credential with both a Census and an SSA count got both). The counts are `tools/name_usage.py`'s, taken 2026-10-25 and recorded as each reading's `as_of`. - **A name reading may be declared without a count, with a required `note` saying why.** Derivation needs a `given` reading on every flagged particle, and some are borne as given names in no source the tool reads; refusing a count-less reading would have forced each of those to the never-given side, against decisions/vocabulary-collisions C-i's "under uncertainty, ambiguous". Eight particle words have no count in any source (`aen`, `bat`, `binte`, `freiherr`, `freiherrin`, `thoe`, `ua`, `vande`); each keeps its may-be-given reading on decisions/vocabulary-collisions C-i's default or, for `binte`, beside `binti` (#604), with a required `note` saying so. The build refuses a count-less name reading with no `note`. The unexamined-word test of nameparser/vocab/README.md keeps its shape, keyed on the given reading: a word whose particle readings carry no `note` is unexamined, whatever counts its given reading carries, since those were taken for the derivation and not read against the particle position. - **`ld` keeps its count in its credential reading's `note` and has no name reading, so it stays unmarked.** Its `given` reading recorded the evidence for NOT marking it (45 SSA births against 11,703 NPPES providers), which derivation would have read as a mark. The count moved into the credential reading's `note`, so a name reading that keeps the gate off, contradicting decision 15, never exists. Declined: deriving it literally, which moves 3 ClinicalTrials.gov and 5 FEC names, measured 2026-10-25 by writing the given reading back on this change; psc-individuals holds no record writing it, so it cannot move there. - **A particle with no given reading still states `may_be_given = false`, required,** so a new particle never reaches the never-given side unremarked, the reason the property had no default in phase 1; `may_be_given = true` and `may_be_name` (either value) are refused with a message naming the reading to write, and `false` beside a `given` reading is refused as contradicting it. Check 9 now asks for a `given` reading on a bound given name that is also a particle. `may_be_given` is a STATED property in the README's table: held to the readings, compiled to nothing by itself. - **`may_be_initial` stays a property.** Its clash is with the initial, a shape every cased single letter has, and which letters a tradition writes both ways is a judgment about the letter, not a name anyone bears. - **Measured (2026-10-25, against 632685c8):** `tools/differential/snapshot.py` matches; `cldr_agreement.py` matches every record; `snapshot.py --local` for ctgov-officials, fec-individuals and tse-donors, recorded from the base with `--tree`, matches; `local_agreement.py psc-individuals` matches the record. Negative control: dropping `jd`'s given reading takes it out of `SUFFIX_ACRONYMS_AMBIGUOUS`, and dropping `van`'s, with `may_be_given = false` written, puts it in `NON_GIVEN_NAME_PARTICLES` (tests/v2/test_vocab.py). - **Recompute** rather than quoting: `uv run python tools/vocab/query.py --kind given --count`; the markers' sizes, `uv run python -c "from nameparser.config import suffixes, particles; print(len(suffixes.SUFFIX_ACRONYMS_AMBIGUOUS), len(particles.PARTICLES - particles.NON_GIVEN_NAME_PARTICLES))"`. - **2026-10-25 #752 — one predicate per question (neutral).** Three questions that #752's later changes widen had their answers copied between sites, and each now has a shared predicate in `_pipeline/_vocab.py` for the widening to land in (mechanisms.md#ONE-PREDICATE-PER-QUESTION). Two are whole: every site asks them. The numeral's is not yet: the bullet below names the sites found still recognizing a numeral on their own. No reading moves. - **`split_pair(first, second)`**, the split credential (`Ph.` `D.`, decisions/phd-merge): group's merge, `_comma._pieces` and `is_wholly_suffix` each held their own copy of the two-pattern test. The test runs per adjacent pair, so each caller asks `SPLIT_PAIR_OPENS`, the predicate's own first-word pattern, C-level before the call, and a name holding no `Ph.` pays no frame. The split-credential change widens both together (item 6 of the 2026-10-23 #752 entry above). - **`reads_as_generation(text, preceding, one_case)`**, the numeral: `is_trailing_numeral_suffix` renamed, the predicate every numeral rule is to ask (item 8 of the 2026-10-23 #752 entry above, #784, #774, #819). Today it is the fork's SHAPE half alone. rules/C12's evidence asks a broader question beside its call: whether the word ending the part before the comma is any suffix word, by the suffix-word tag (`John Smith Jr., AD`, `John Smith Esq., AD`), or reads as a generation by the predicate (`John Smith VI, AD`); the tag stays at the call site because `Jr.` and `Esq.` are no numerals. The listed numerals belong in the predicate too, or each numeral rule tests the vocabulary itself again, the hand copy #656's first C12 draft was (it asked the suffix-word tag and missed `VI`). The shape test already takes the listed `ii` to `iv`, so reading the vocabulary inside it adds only the one-letter `i` and `v`, and that moves readings, which this PR may not: answered yes on a grid of 3,927 readings (17 heads, 11 numerals, 7 tails, three orders; the second review, 2026-10-25 on 30dc7aac), `i` and `v` moved 375 readings at the peel's fork, 348 of them in their fields (`John J. V` from middle 'J.', family 'V' to family 'J.', suffix 'V', with a new report), and 144 reports at `reads_as_a_reported_numeral` with no field moving (`John J. V MD` gains a `suffix-or-name`), the ones those sites meet carrying the `initial` tag (`I`, `V`, and their one-case spellings; a lower-case `v` in a name of mixed case carries none, and the peel takes it as a suffix word). Adding `ii` to `iv` alone moved nothing. That half is left to the change that has every numeral rule ask the predicate. Until it lands (it did: the 2026-10-25 #752, phase 2f entry below, which asks the predicate at every site listed here, the peel taking `ii` to `iv` by the suffix test, which the predicate answers alike), these sites recognize a numeral on their own, each keeping the answer it had (found 2026-10-25 by two reviews and a grep of `_pipeline/` for the numeral pattern, the suffix-word tag and the suffix predicates; a list found by search, not a proof that no other site exists): the predicate's own missing `i` and `v`, above; rules/S8's run start asks the vocabulary alone (`John Smith III MD` → suffix 'III MD', `John Smith VI MD` → family 'VI', #784); rules/C1's listing form asks the suffix-word tag (`John Smith III, M.J.` keeps suffix 'III' where `John Smith VI, M.J.` and `John Smith V, M.J.` read family 'John Smith VI' and 'John Smith V', #774); the peel takes `ii` to `iv` through `is_suffix_piece` before its fork asks the predicate; rules/C9's walk over the given part after a family comma asks `is_suffix_lenient`, the vocabulary alone (`Smith, John V` → suffix 'V', `Smith, John VI` and `Smith, John X` → middle, where `John Smith VI` reads suffix 'VI'); and after a suffix word in that part a listed one-letter numeral is taken by its tags (`_pieces._numeral_behind_the_initial_veto`), so `Smith, John PhD V` reads suffix 'PhD V' with no report where `Smith, John PhD VI` reports `suffix-or-name`. - **`is_cased_paired_initials(text)`**, rules/S3's paired initials in a script with capitals: classify asked the script half inline (#872, decisions/S3). This is the lift the 2026-10-24 #872 entry above names, for classify and the split merge to share. One overlap is left to the split-credential PR to state: item 6 refuses a split credential whose first chunk is a single letter, so for a split `A. B.` that length test answers before this predicate is asked. (Stated by the phase 2e entry below, decisions/S12: the length test refuses every other opening letter, and two dotted single letters are asked of this predicate, merging only in a script without capitals.) rules/C5's comma reading keeps the bare `is_paired_initials`, since after a comma a caseless pair reads as a name too (`John Smith, พ.บ.` → given 'พ.บ.'), and moving C5 onto this predicate would change that reading. - **Measured neutral** against the branch, first on 632685c8 and again on 2dd0414b once phase 2a had merged: a grid of 20,364 texts (22 heads covering no comma, a whole name, one word and nothing before a comma, a nickname-only head, a maiden clause, a family comma with a title-particle head and a quoted clause before the pair; 33 spellings including `MA`, `M.D.`, `X.Y.Z.`, `XYZ`, `Ph. D.` and its case variants, `V`, `VI`, `III`, `XI`, `M.J.`, `K.C.` and `พ.บ.`; each text also in lower and upper case), under all three orders, read identically (fields, ambiguity kinds and details, token roles and tags, `initials()`, `capitalized()`), and the same harness reported 45,871, 18, 3,974 and 2,986 differing readings with `split_pair`, C12's call to the numeral predicate, the cased half and the peel's call to it each knocked out. The old and new predicates gave the same answer on 19,769 direct calls. The committed snapshot, every CLDR, GND, Wikidata and conventions agreement record, psc-individuals' agreement record and the ctgov-officials, fec-individuals and tse-donors local snapshots (each recorded from the base with `--tree`) all match, on both bases. psc-individuals' record was itself stale against 632685c8 (3 regressions, 5 progress, 5 changed before this change), so it was re-recorded from each base first. - **Frames**, counted with `sys.setprofile` on both trees: `_CALL_BASELINE`'s reference name and `Smith, John` cost what they did (305 and 158). A word matching `SPLIT_PAIR_OPENS` (`Ph`, `ph.`) with a word after it pays one frame at each site that tests it, whether or not a `D.` follows (`John Smith Ph. D.` 233 → 234, `Smith, John Ph. D.` 239 → 241; the review counted `ph ph ph ph ph ph` 282 → 286), so the cost grows only with the number of such words. C12's evidence keeps its tag test ahead of the call, so it costs what it did (`John Smith Jr., AD` 372). That is a rare shape that no guard counts. - **2026-10-25 #752, phase 2f — EVERY NUMERAL RULE ASKS ONE PREDICATE.** (Its expectation that the split-credential sub-phase keep `John Smith V Ph. D.` as suffix 'V Ph. D.' superseded by the phase 2e entry below, decisions/S12, which reads it as `John Smith V PhD` reads, family 'V'.) Carries out item 8 of the 2026-10-23 #752 entry above (#784, #774, #819), and the list of sites in the 2026-10-25 one-predicate entry. `_vocab.reads_as_generation(text, tags, preceding, one_case)` answers for every spelling from `i` to `x` as S10's trailing fork does: the shape on the word without its trailing periods; a single letter written with its period an initial (rules/T5); a numeral the vocabulary lists in two or more letters (`ii` to `iv`, read from the token's `vocab:suffix` tag, not copied at a call site) the generation whatever stands around it; for the rest, an initial in front a name word, and a Title-case numeral in a name of mixed case declined, which `numeral_leans_to_the_name` tells apart so the decline is reported. `preceding` is None where a slot asks no initial in front, the given part after a family comma, where the predicate reads a one-letter numeral by the vocabulary. The count for one letter stays the caller's, which alone knows where the name's words are. - **The sites.** The trailing peel asks it of a numeral anywhere in the run, behind the suffix test, so `John Smith VI MD` reads suffix 'VI MD' as `III MD` does (#784); rules/S8's run start asks it of a numeral of two or more letters (`John Smith VI Jones` → suffix 'VI Jones'); rules/C12's evidence asks it with the token's tags, its broader suffix-word test still beside the call; C1's listing form asks it of a numeral ending the family part, with S10's count over that part's pieces (`John Smith V, M.J.` and `John Smith VI, M.J.` keep the suffix, #774); the given part's walk asks it of a numeral of two or more letters (`Smith, John VI` → suffix 'VI') and of the bare one-letter numeral its last-piece test reads, and a one-letter numeral behind a suffix word in a part of suffix words asks it too; the maiden take and `tail_reading` reach it through the peel, `trailing_candidates` with no case (its superset); the report on a numeral a post-nominal follows asks it, with the Title-case decline. A numeral of two or more letters read by its shape alone reports `suffix-or-name` wherever it is taken, the run start and the given part included. - **The three readings agreed on 2026-10-23.** #819: a lone letter ending the given part ahead of a further comma is the middle initial, the credential run in front stopping short of it (decisions/C9's 2026-10-25 #819 entry; `Smith, John PSM I, Jr.` the accepted cost). FEC's constraint: in that part a one-letter numeral is the generation only where listed, so `SMITH, JOHN X` keeps middle 'X'. A Title-case numeral in a mixed-case name leans to the name (`Nguyen Van Vi` → family 'Van Vi', reported); a numeral the vocabulary lists in two or more letters is not leaned (`John Smith Iii` keeps suffix 'Iii'), being unambiguous vocabulary as an unambiguous credential is. - **One reading kept rather than widened: a one-letter numeral before a credential stays a name word** (decisions/S10's #721 entry, `John Smith V PhD` → family 'V', reported). Reading it with the count instead moved 6 tse-donors names, every one wrong: a lone `V` or `I` behind a surname or a particle before `JR` or `JUNIOR` (shape `Aa A Aa de V JUNIOR` read family 'de', suffix 'V JUNIOR'), where the letter abbreviates a surname; and 3 committed names (`John Smith V PhD`, `Jane Doe nee Smith V PhD`, `Jane Doe nee Smith V MA`). A split credential behind such a numeral (`John Smith V Ph. D.`) is left to the split-credential sub-phase (item 6), which must keep its suffix 'V Ph. D.' by its own rule. - **Measured** (2026-10-25, against master 2af8da8e, each local baseline re-recorded from that checkout with `--tree` immediately before its check, since the shared cache's `snapshot.jsonl` is overwritten by any session measuring in parallel): the committed snapshot moves 6 names, every one intended: the three GND regnal rows (`Georg VI.`, `Konstantin VII.`, `Bonifatius VIII.`, family → suffix), `Smith VI, AD` (family 'Smith VI' → 'Smith', suffix 'VI'), and #774's `John Smith V, M.J.` and `John Smith VI, M.J.`. GND agreement goes from 618 to 621 of 801 on family and 779 to 782 on suffix, three progress rows each, and every other agreement record matches. ctgov-officials moves 1 reading (a job title ending in a level before a comma, `Aa Aa I, A` → suffix 'I', reported); fec-individuals 22, each a numeral ending the given part or the family part (`A, A VI` → suffix 'VI'; `A VI, A` → suffix 'VI'; `A A V, A` → suffix 'V'), about 14 read as generations and about 8 a middle name or a fragment read as one, all reported; tse-donors none. psc-individuals (1,039,171 records scored): 3 fixed, all Vietnamese names ending in the surname `Vi` that the Title-case lean keeps a name; 1 broken, a Title-case `Viii` written as the bearer's generation, which the lean reads as a name (the lean's cost, rules/S10); 1 changed, a record whose title box the scorer sets aside. Spike figures above (listing `vi` to `x`: 152 FEC moves) are the 2026-10-23 entry's and were not re-taken. - **Recompute:** `uv run python tools/differential/snapshot.py` on the parent of this change's merge with its code, and for each local corpus `snapshot.py --local --record --tree ` then `snapshot.py --local ` from this tree, back to back; `uv run python tools/differential/local_agreement.py psc-individuals` the same way, `--list N` printing the moved records locally. - **2026-10-25 #752 — phase 2c: a credential joined by a hyphen or a slash reads as one where every chunk counts as a suffix as written**, carrying out the listed half of item 5 of the 2026-10-23 #752 entry. `John Smith, MD/PhD` reads suffix 'MD/PhD' where it read given 'MD/PhD', family 'John Smith'; a bare member of the ambiguous class is no chunk the test counts (`John Smith, MA/PhD` keeps its reading). The record, with what was measured and what was left unread, is decisions/S3's entry of this date (rules/S3). - **2026-10-25 #752 — phase 2d: an unlisted word of capitals joined by a hyphen or a slash is the caps shape**, carrying out the unlisted half of item 5 of the 2026-10-23 #752 entry (#797's half A). `John Smith, PU-PH` reads suffix 'PU-PH', reported, where it read given 'PU-PH', family 'John Smith'; C6's case contrast gates it after a comma as it gates the unjoined word. The record is decisions/S9's entry of this date (rules/S9, rules/C6). - **2026-10-25 #752, phase 2e — A CREDENTIAL SPLIT ACROSS A SPACE IS ONE WORD, A MEMBER OF THE AMBIGUOUS CLASS THE COUNT DECIDES**, carrying out item 6 of the 2026-10-23 #752 entry and #827's reading (A). `John Smith, Pharm D` reads suffix 'Pharm D' where it read given 'Pharm', middle 'D', family 'John Smith'; `John Smith Ph. D. Jones` reads suffix 'Ph. D. Jones' as `John Smith PhD Jones` does; `SMITH, SAM E.` keeps given 'SAM', the count declining the pair. The record, with what was measured, is decisions/S12's entry of this date (rules/S12). - **2026-10-25 #752, phase 2h — GENERATION READINGS ARE GATED BY KIND, AND `default = false` HAS ITS MEANING.** Carries out item 9 of the 2026-10-23 #752 entry above and its reserved-names item. SUPERSEDES: in 2026-10-18 #737, `default` as a reserved name. Three changes, each one the design's: - A suffix word reading beside a name reading derives `SUFFIX_WORDS_AMBIGUOUS` (`Lexicon.suffix_words_ambiguous`), gated as an ambiguous acronym is, by the words to spare, at the same sites; the kind says what the writing means. A new kind, `agnomen`, records the Brazilian agnomens (`filho`, `filha`, `neto`, `neta`, `sobrinho`, `sobrinha` in `pt.toml`, `netto` in `pt-BR.toml`), whose writing never leans and which `AGNOMENS` (`Lexicon.agnomens`) lists; build check 11 refuses an agnomen with no name reading, since one would take the family name from every two-word name it ends. `fils` takes a `generation` reading beside its family reading, its exclusion gone. No shipped suffix word had a name reading before, so nothing else moves. The rule is rules/S11 and the record, with what was measured and what was accepted, decisions/S11. - `default = false` is a behavioral property of every category but `name`: the reading is held to the per-entry checks and compiles into no set and no marker, and checks 4, 5, 6 and 9 do not weigh it, so it may stand beside a live reading of the word in another file and beside another language's exclusion of it. `default = true` is refused, being every reading's unwritten value. The first three are the readings with no cue: Spanish `h` (*hijo*) and the German regnal ordinal on `i`, each written with the period that makes a one-letter word an initial (rules/T5), and Czech `st` (*starší*), whose `cs.toml` exclusion it replaces. No reading moves. - The other reserved names keep their reservation and name the phase that needs them: `attaches` phase 2 (its front-of-the-name work), `prefer` and `strict` phase 3, and `bare_only`, `leading_only`, `trailing_only` and `separators` phase 4, the gate's slots carrying position and shape settling a bare letter (rules/P7). Adding the two fields changes `Lexicon`'s pickle layout, as 2.6 did. - **2026-10-25 #752, phase 2g — CAPITALS SETTLE A LISTED CREDENTIAL**, carrying out item 7 of the 2026-10-23 #752 entry above (#785's first three questions). In a name written in more than one case a listed member of the ambiguous class written in capitals reports nothing where it read as the credential (`John Smith MA`, `John Smith, MA`, `Doe, John DO`), and it starts the credential's run as its dotted spelling does (`John Smith MA Jones` → suffix 'MA Jones'). The word keeps its tags, so the clash settled is the one with the name reading alone: `Doe, John DO` keeps family 'Doe', the particle reading still refusing the run start, and `Jane Doe nee MA` keeps its maiden name and its report. A name in one case keeps its report (`JOHN SMITH MA`). The record, with what was measured and the accepted cost, is decisions/S6's entry of this date (rules/S6, S8). - **2026-10-25 #752, phase 2i — A TITLE THAT MAY BE A GIVEN NAME IS DERIVED, AND `attaches` HAS ITS MEANING.** Carries out items 10 to 12 and the `attaches` half of item 15 of the 2026-10-23 #752 entry; the rule and its measurement are decisions/H9's entry of this date (rules/H9). What changes in the vocabulary: - A title reading beside a `given` reading derives a fourth marker, `TITLES_AMBIGUOUS` (`Lexicon.titles_ambiguous`), and the word compiles into it INSTEAD of `TITLES`, so a caller who lists it as a title has it read as one everywhere, the 1.4 recipe (`c.titles.add("dean")`). A title that is also a particle keeps its given reading for the particle's marker and stays in `TITLES` (`st`, `freiherr`). A `family` reading alone beside a title marks nothing, as before. - Thirteen salutations carried a `given` reading recorded on 2026-10-24 as H8's evidence (#835), which the derivation would have read as the clash (`Mr Smith` → given 'Mr'): `bwana`, `goodman`, `gospodin`, `herr`, `master`, `mister`, `mr`, `señora`, `señorita`, `signora`, `sra`, `श्री` and `মোঃ`. Their counts move into the salutation reading's `note`, the README's convention for counts kept as evidence against marking (item 2's `ld`). No compiled set moves for them. Recompute: `uv run python tools/vocab/query.py --kind salutation`. - `attaches`, a behavioral title property, is a non-empty list of title kinds, held to one value per word across files (check 5), and compiles into `TITLE_PHRASES` (`Lexicon.title_phrases`): the two-word phrases the word makes with every title word of those kinds. `chief` attaches to office and military, `major` to military. Two more sets compile from the kinds: `TITLE_MODIFIERS`, the words every title reading of which is a modifier, and `TITLE_STYLES`. - Reclassified, as item 10 asked and as the build found: `resident` and `rt` to occupation, `pro` to academic, `d'affaires` and `member` to office (decisions/H9). - `dean`, `justice` and `major` gain an office or military reading beside a dated `given` one; `justice`'s exclusion (#606) is replaced by the reading. The batches of 2j declare the rest. Adding the four fields changes `Lexicon`'s pickle layout, as phase 2h did. - **2026-10-26 #752, phase 2i (Derek) — `attaches` MAY NAME A TITLE WORD AS WELL AS A KIND, AND `grace` AND `marshal` JOIN THE CONTESTED TITLES.** Amends the `attaches` bullet of the 2026-10-25 #752, phase 2i entry: a kind reaches every word that later joins it, and once `marshal` became a contested military word the military kind made `Sgt Marshal Smith` a title phrase. A list entry that is no kind must be a word with a title reading (a build check, its mutation in tests/v2/test_vocab.py). `major` attaches to `general` and `sergeant` and `sgt` to `major`. `grace` (style) and `marshal` (military, its #606 exclusion replaced) carry dated `given` readings. The measurement and Derek's answers are decisions/H9's entry of this date. - **2026-10-28 #913, `chancellor`, and `judge` kept a title (Derek) — A #913 TITLE BATCH: `chancellor` has a dated `given` reading**, US SSA 2,648 births and Census rank 7,974 as a surname, so it is a title only inside a title phrase. `Chancellor Smith` and `Smith, Chancellor` read given 'Chancellor'; a chancellor written with the bare title does too (`Chancellor Merkel`), accepted by Derek on 2026-10-28. `Vice Chancellor`, `Chancellor of the Exchequer` and, through new `attaches` data, `Lord Chancellor`, `Deputy Chancellor` and `Pro Chancellor` keep the title; the same data makes `Lord Justice`, `Lady Justice` and `Magistrate Judge` one title. `judge` was proposed in the same batch and kept a title by Derek on 2026-10-28, the counts against moving it (13 FEC judges filing `FAMILY, JUDGE …` against 2 fixes) recorded in the note on its title reading. The measurement is decisions/H9's entry of this date. - **2026-10-27 #913, royal title phrases (Derek) — `king`, `queen`, `prince` and `princess` CARRY `attaches`, `consort` IS A NEW MODIFIER AND `royal` HAS A DATED `given` READING.** `king` and `prince` attach to `consort`, `queen` to `consort` and `mother`, `princess` to `consort` and `royal`; `consort` (no bearers found) and `royal` address by the given name, so `Queen Consort Camilla` and `Princess Royal Anne` read the phrase as the title and the given name behind it. `royal`'s given reading keeps `BALES, ROYAL` the listing form's given name. The bare royal words now report `title-or-name`, a listed phrase holding them, accepted by Derek on 2026-10-27. The measurement and the rule change are decisions/H9's entry of this date. - **2026-10-27 #913, `queen` (Derek) — A #913 TITLE BATCH: `queen` has a dated `given` reading**, US SSA 16,123 births and Census rank 1,945 as a surname, so it is a title only inside a title phrase. `Queen Latifah`, `Ms Queen Smith` and `Smith, Queen` read given 'Queen' and the word behind it as the family; a monarch written with the bare title does too (`Queen Elizabeth II`), the class accepted for `king`, accepted by Derek on 2026-10-27. `Her Majesty Queen Elizabeth` and `Queen of England` keep the title. `Mary II, Dame` replaces `Elizabeth II, Queen` as rules/H1's example. The measurement is decisions/H9's entry of this date. - **2026-10-27 #913, `deacon`, `burgess` and `ranger` (Derek) — A #913 TITLE BATCH: each has a dated `given` reading** on its en.toml entry, so each is a title only inside a title phrase. `Deacon Jones`, `Burgess Meredith` and `Ranger Smith` read the given name, and `Burgess Smith, John` keeps the surname whole; a cleric written with the bare title or behind `Rev.` does too (`The Rev. Deacon John Smith`), the religious class accepted for `buddha` and `gyani`, accepted by Derek on 2026-10-27. `Deacon of the Church Jane Smith` keeps the title. Derek chose the three, with `marquis` and `marquise` (#923), from #913's ranking on 2026-10-27. The measurement is decisions/H9's entry of this date. - **2026-10-27 #913, `marquis` and `marquise` (Derek) — THE FIRST OF #913's TITLE BATCHES: both have a dated `given` reading** on their en.toml entries, US SSA 25,041 and 10,995 births, so each is a title only inside a title phrase. `Marquis Johnson`, `Mr Marquis Smith` and `Johnson, Marquise` read the given name; a noble written with the bare title does too (`Marquis de Lafayette`), the class accepted for `king` and `prince`, accepted by Derek on 2026-10-27. `Marquis of Queensberry` keeps the title. Derek chose the two words from #913's ranking on 2026-10-27. The measurement is decisions/H9's entry of this date. - **2026-10-27 #766 — FIVE UNATTESTED READINGS REFUSED: `bon`, `dí` and `heer` are no particles, `herren` and `heren` no titles.** Each reading is replaced by an exclusion quoting the evidence, and each word keeps or gains its name readings with dated counts (`heer`: Census 2010 surname 2,007, Wikidata family 113, beside its SSA given count; `herren` and `heren` re-counted unchanged). None had a recorded source: `bon` and `dí` came with the 2011 and 2012 prefix lists and #749 found no particle use; `heer`'s only source was #18's `Johan de heer Van Kampen`, two real names run together; `herren` and `heren` came unvetted with #99's honorifics, and a plural addresses several people. `PARTICLES` loses three words and `TITLES` two, `heren` leaving `AFTER_GIVEN_TITLES` with it. As name words they read as any other: `Marie Bon Durand` → middle 'Bon', family 'Durand' (was family 'Bon Durand'); `Herren Hans` → given 'Herren' (was title 'Herren'); `Doe, Jan Heren` → middle 'Heren', 1.4.0's reading, where 2.6.0 read title 'Heren' (decisions/H8); and a leading `Bon` or `Heer` no longer reports `particle-or-given`. Measured 2026-10-27 against 6ae585f3 with `tools/differential/measure.py`: no committed corpus name moves (`tests/v2/test_snapshot.py`, the CLDR and GND records). The local corpora moved 79 entries (ctgov-officials 14, fec-individuals 9, jrc-names 30, psc-individuals 20, tse-donors 6; nepal-candidates, tw-donors and ukec-individuals none), of four shapes: a surname of a real particle and the word (`de Heer`, `le Bon`, `van Bon`), whose base is now the word, so `initials()` no longer counts the particle and case repair writes the particle as it writes any other; the word between the given name and the surname, now a middle name, where psc-individuals' filers typed it (4 records now agree with their boxes, none newly disagree); a leading `Bon` or `Heer` given name losing its `particle-or-given` report; and `Herren` or `Heren` opening a name, now the given name. The accepted cost is a compound surname written with `Bon` as its own first word (`Bon Valsassina`, the stage name `Bon Jovi`), now middle 'Bon', as any double surname reads by position (`Policy(two_family_names=True)` is the opt-in). jrc-names reads 5 more persons' spellings alike. Recompute: `uv run python tools/differential/measure.py --base `, and the refusals with `uv run python tools/vocab/query.py --excluded particle` and `--excluded salutation`. `und.toml` keeps both words, so it is not empty. - **2026-10-27 #752, phase 2j, `sheikh` and its spellings (Derek) — THE NINTH AND LAST TITLE BATCH: `sheikh`, `sheik`, `shekh`, `shaik` and `cheikh` have dated `given` readings**, so each is a title only inside a title phrase; `shaykh` stays a title. `Sheikh Ahmed Khan`, `Mr Sheikh Ahmed` and `Cheikh Anta Diop` read the given name; a ruler written with the bare title does too (`Sheikh Mohammed bin Rashid`), the cost Derek accepted on 2026-10-27. With it every word item 12's measurement named has its reading. The measurement is decisions/H9's entry of this date. - **2026-10-27 #752, phase 2j, `prince` and `princess` (Derek) — THE EIGHTH TITLE BATCH: both have a dated `given` reading**, so each is a title only inside a title phrase. `Prince Fielder`, `Mr Prince Smith` and `Smith, Princess` read the given name; a royal written with the bare title does too (`Prince Harry`), the class accepted for `king`, `sultan` and `shaikh`, accepted by Derek on 2026-10-27. `His Royal Highness Prince Harry` and `Prince of Wales` keep the title. The measurement is decisions/H9's entry of this date. - **2026-10-26 #752, phase 2j, `shaikh` (Derek) — THE SEVENTH TITLE BATCH: `shaikh` has a dated `given` reading**, so the style is a title only inside a title phrase. `Shaikh Ahmed Khan` and `Mr Shaikh Ahmed` read given 'Shaikh'; a ruler written with the bare title does too (`Shaikh Hamad bin Isa`), the class accepted for `king` and `sultan`, the larger share of rulers Derek accepted on 2026-10-27. `sheikh` keeps its own batch. The measurement is decisions/H9's entry of this date. - **2026-10-26 #752, phase 2j, `sultana` and `sultan` (Derek) — THE FIFTH AND SIXTH TITLE BATCHES, IN ONE PR: both have dated `given` readings**, `sultan`'s on its tr.toml entry, so each is a title only inside a title phrase. `Sultana Begum` and `Sultan Khan` read the given name; a sultan written with the bare title does too (`Sultan Hassanal Bolkiah`), the cost accepted for `king`. Derek combined the two batches on 2026-10-26. The measurement is decisions/H9's entry of this date. - **2026-10-26 #752, phase 2j, `king` (Derek) — THE FOURTH TITLE BATCH: `king` has a dated `given` reading**, US SSA 37,890 births and Census rank 34 as a surname, so it is a title only inside a title phrase. `King Curtis` and `Mr King Smith` read given 'King'; a monarch written with the bare title does too (`King Henry`), the cost Derek accepted on 2026-10-26. `pope` replaces `king` as the worked example of a title that is also a surname wherever the rules and tests used it. The measurement is decisions/H9's entry of this date. - **2026-10-26 #752, phase 2j, `buddha`, `siddha` and `gyani` (#869) — THE THIRD TITLE BATCH: each has a declared `given` reading**, `siddha`'s and `gyani`'s without a count, which their notes explain: the first halves of Nepali compound given names, which no source outside nepal-candidates counts. `Buddha Bahadur Tamang` reads given 'Buddha', middle 'Bahadur'; `guru` stays a title for a `ne` pack (the 2026-10-24 #752 (Derek) entry). The measurement is decisions/H9's entry of this date. - **2026-10-26 #752, phase 2j, `mahdi` and `bard` (#867) — THE SECOND TITLE BATCH: both words have a dated `given` reading.** `Mahdi Karimi`, `Mr Mahdi Karimi`, `Karimi, Mahdi` and `Bard Larsen` read the given name, where 1.4.0 through 2.6.0 read a title; no listed phrase holds either word, so nothing is reported. The measurement is decisions/H9's entry of this date. - **2026-10-26 #752, phase 2j, `wing` — THE FIRST TITLE BATCH: `wing` HAS A DATED `given` READING.** The order is the 2026-10-24 #752 (Derek) entry's. Decision 10 had already read `wing` as a name at the head of the name, so the reading reaches the parts decision 10 left as listed, after a comma and ending the name: `Lau, Wing Kai` reads given 'Wing', and `John Smith Wing.` family 'Wing.'. The rule and the measurement are decisions/H9's entry of this date. - **2026-10-26 (Derek), #785 — PHASE 2g NARROWED: a capitals member with a name word behind it, and no family comma, does not start the credential's run**, the name word being evidence of initials; `Maria Ana MA Silva`, `Jean Pierre MA DUPONT` and `Mary Jane MA King` keep the readings 1.4.0 through 2.6.0 gave, and `John Smith MA Jones` keeping family 'Jones' is the cost. The record is decisions/S6's entry of this date. - **2026-10-26 #789, #752, phase 2k — A GIVEN NAME WRITTEN SHORT IS A `given` READING WITH `same_as`, AND IT OUTRANKS rules/H2's SHAPE.** Carries out item 14 of the 2026-10-23 #752 entry above and the second half of #789; the first half, bare `wm` out of the titles, is the 2026-10-22 #789 entry. SUPERSEDES: in the README's property table, `same_as` as a property of every category but `name`. What changes in the vocabulary: - A `given` reading may carry `same_as`, the full given name the word abbreviates (`chas` is `charles`). On a name reading it names a name, not an entry, so check 7 asks only that it be normalized and not the word itself, and a `family` reading is refused one. The reading compiles the word into a new set, `GIVEN_NAME_ABBREVIATIONS` (`Lexicon.given_name_abbreviations`, re-exported from `nameparser.config.given_names`), so an entry holding only such a reading is not the inert name reading the build warns of. No `Constants` attribute reaches it, and the shim passes it whole: a v1 caller who lists the word as a title has it read as one, the leading peel asking the title vocabulary before the shape. - The set's one reader is rules/H2's shape test, which declines a word in it, so the vocabulary reading outranks the shape-derived one, decision 20 applied in the default. It is not a derived marker: no second vocabulary reading is weighed, only the shape's. `ma`, which already had given, family and credential readings in en.toml, takes its abbreviation reading in fil.toml, and the credential's ambiguous marker is unchanged, its name readings already deriving it. `jo` had no reading before, and its `given` reading derives nothing. - The words, with their counts on each entry: `benj`, `chas`, `edw`, `geo`, `jas`, `jno`, `richd`, `robt`, `saml`, `thos` and `wm` in en.toml (the last keeping its refused title, whose reason now names this reading), `jo` and `ma` in fil.toml. What moved, the vetting and the grid are decisions/H2's entry of this date. Recompute the list: `uv run python -c "from nameparser import Lexicon; print(sorted(Lexicon.default().given_name_abbreviations))"`. Adding the field changes `Lexicon`'s pickle layout, as phases 2h and 2i did. - **2026-10-26 #752, phase 2l — THE BRACKET ESCAPE READS THE DERIVED CLASH**, carrying out item 16 of the 2026-10-23 #752 entry above (#810). A member of the ambiguous class, the class 2a derives from name readings, alone in brackets or quotes after the name is the suffix where its writing settles it (`John Smith (MA)`, `John Smith (M.A)` → suffix), read in one pass, and stays the nickname otherwise (`John Smith (Ed)`, `JOHN SMITH (MA)`, `Jeffrey (JD) Bricken`). It is recorded as rules/S13, a rule of its own rather than the amendment to S1 item 16 planned, the position it reads being evidence S1's signal does not read; the writing is S3's and S6's evidence, which S13 cites. The record, the measurements and what was declined are decisions/S13's 2026-10-26 #810 entry. - **2026-10-26 #752, phase 2m — THE READING NOT TAKEN SURFACES AS `Ambiguity.alternative`, WITHOUT ITS LANGUAGES**, carrying out item 13 of the 2026-10-23 #752 entry above and item 7's source with it: a word the credential's run took by position alone reports its name reading's source as `position`, a fourth source beside the three the item named, and a word with a declared name reading reports `declared`. The pickle guard was widened as item 13 asked, every skew the narrow guard refused still refused. The reading's languages were left out: the per-word index costs about 0.4 ms of a 19.5 ms import and describes the default vocabulary, not a pack's or a caller's lexicon, so they wait for 3.0 with `Token.reading`. The record, the measurements and what was declined are decisions/A1's 2026-10-26 #752 entry. derek73-python-nameparser-456b024/docs/design/mechanisms.md000066400000000000000000002542531527303271700235740ustar00rootroot00000000000000# Mechanisms How this codebase solves recurring problem shapes. Before proposing a design, check whether an entry already fits — the catalog is keyed by problem shape, because that is what you have in hand when searching. Entry format: an `## UPPER-KEBAB-SLUG — one-line slogan` heading, then **Problem shape** (the situation that should trigger recall), **Contract statement** (one or two sentences stating what the mechanism promises — the citable line), **How it works**, **Lives in**, and **Reach for it when** (the tell-tale sign you should be using this instead of inventing something). Each label opens its own paragraph, and that is load-bearing: tests/v2/test_doc_citations.py reads a Contract statement to the end of its paragraph, so whatever shares the paragraph becomes quotable. An entry carries its contract and pointers; how an instance was found belongs in the decisions entry that decided it, or in the Field notes below when it is a trap rather than a decision. The Contract statement is citable under the same discipline as rules: a committed comment making a mechanism claim — including any claim about which stage or layer does something — cites the slug and quotes a verbatim excerpt (`# mechanisms.md#SPANS: ...`), checked by the citation-integrity test. Free-prose restatements of mechanism claims are how one wrong sentence has shipped six times. This catalog converts discovery into a one-time cost — it does not remove discovery. What nobody knows yet still has to be found the hard way, once; the promise is that found things stay found. ## SPANS — position is identity, text is not **Problem shape.** A later stage needs to refer to "that word." **Contract statement.** Every token carries its (start, end) character span in the original string, and stages refer to tokens by index, never by searching for matching text. **How it works.** v1 re-found pieces by value, so a name with a repeated word could rewrite the wrong occurrence (#100 and relatives); a position cannot be confused with a look-alike. Every parsed part is an exact slice of the input (rule T1's Background). **Lives in.** nameparser/_types.py (Span), threaded through every _pipeline/ stage. **Reach for it when.** New code is about to do `if token.text == ...` to LOCATE rather than to classify. ## FOLDED_TAG — reorder at render time, not parse time **Problem shape.** A rule wants words to RENDER in a different order than they sit in the string. **Contract statement.** Tokens never move: a rule that needs different rendering order tags the token, and the rendering views consult the tag — EVERY view that renders the affected role, ordering folded tokens first, not the field views alone. **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/_facade.py (`_list_tokens_for`, which prepends the carriers the same way — it is the ONE walk behind both the v1 `*_list` views and the facade's initials since #528, and `_list_for` is the string view built on it and holds no ordering of its own, so a reader who follows this roster to the string builder lands one level below the partition), 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. **Reach for it when.** A new rule needs "X renders before Y" and you are tempted to swap tokens. Don't swap. Tag. ## VOCAB-TAGS — the vocabulary layer speaks once **Problem shape.** A later stage needs to know what the vocabulary knew about a word. **Contract statement.** classify tags every token with what the vocabulary knows about it, and later stages test tags — they never re-look a word up. **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), and nameparser/_facade.py, whose `_token_is_conjunction` joined them in #528 — the v1 layer is a tag consumer too now, so a sweep that stops at `_render.py` is one module short. **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. ## PIECES — joining structure survives assignment **Problem shape.** A rule needs to know how words were JOINED (chained titles, particle groups), not just what roles they got. **Contract statement.** group records the joining structure as pieces — runs of token indices per segment — and that structure remains readable after roles are assigned. **How it works.** Roles alone lose the grouping ("who chained with whom"); #359's fix made the particle fold read the opening PIECE rather than the assigned role, which is what makes rule P1 hold under every name_order. **Lives in.** nameparser/_pipeline/_group.py (producer), _pipeline/_state.py (ParseState.pieces), _post_rules.py (reader). **Reach for it when.** A rule keyed on assigned roles behaves differently under different name_order values — the stable thing to read is usually the structure. Known limit, found by #395: the structure survives ASSIGNMENT, not group's own later merges. The prefix chain absorbs a joined piece into a longer one, so a rule reading pieces after that point can no longer see the join — see UNIT-PARTITION. ## UNIT-PARTITION — count the units the joining rules built **Problem shape.** A rule counts "one name word", but the input holds words that another rule has already joined into one name — and the joining structure it would read has been merged away. **Contract statement.** Three rules build multi-word units: a particle chain (P2), a conjunction join (P3), and a bound given-name pair (P5). A rule that counts name words counts those units, and takes each whole or not at all — with one stated exception, rules/C3's count of the words before a comma, which counts particle surnames alone (decisions/C3, #575). **How it works.** group builds each join as a piece, but its own prefix chain then merges the joined piece into a longer one, so PIECES no longer carries the boundary — the units are rebuilt from the tags the vocabulary layer left (`particle`, `conjunction`, `vocab:bound-given`). The rebuild is RECURSIVE: what a conjunction or a bound word joins is the next UNIT, not the next word, and absorbing a single token instead strands a particle severed from the words it chains. Note the two joins arrive here for opposite reasons — the conjunction join was built and then swallowed, while the bound-given join was never built at all (P5 joins only at the first non-title piece), so restoring piece boundaries in group would fix the first and silently split the second. **Lives in.** nameparser/_pipeline/_vocab.py (`unit_ends`, the walk, shared since #575) and its two readers: _post_rules.py (`_units`, over classify's tags, for P1's fold) and rules/C3's counts before a comma: `_comma._whole_name`, over classify's tags (`surname_unit_facts`) since #613 moved the decision after classify, and `_vocab.name_word_count`, which builds its facts from the vocabulary (`surname_unit_tags`, held to classify's tags by an agreement test) -- a view script_segment needed while it asked a hand copy of C1 before classify ran, until #613's /simplify had it ask `_comma.decide` itself on a classified copy. A fourth walk is S2's read (#614): `_pieces._chain_units` marks each piece by the unit P2's chain WILL put it in -- opening a unit, or a name word the chain joins -- asking the chain's own `chain_run_end`, which `_group`'s chain calls too; a word the reading weighs (`_weighed`) keeps a unit of its own, as a position always counted it. The particles straight behind each other are merged ahead of the read, into the piece the chain would open its run with (`_pieces.merge_particle_runs`, #625), so a particle run is one piece the read takes whole or not at all, and no mark stands for a particle inside it. The trailing peel's words to spare and #602's run start count in those marks, the peel stops at a word the chain joins, and everything else -- the numeral fork's look at the word in front, the H5 chain, the run's title test -- reads the pieces as written. It runs before the chain joins name words to a particle run, which is why it cannot read the merged tags `unit_ends` walks; #614 had rebuilt the chain as a merged COPY instead, whose three defects in one review -- words the reading weighs hidden inside a unit, indices drifting from the pieces' -- were each patched in that copy, and #620 replaced it with the marks (decisions/S2). C3 counts particle chains only, reaching one word as the fold does: a bound pair builds a given name, and whether P3 joins a connective depends on the whole name the count is helping to decide, so neither is one surname before a comma (`_vocab.SURNAME_UNIT_TAGS`, decisions/C3). rules/P1 and rules/C3 are the counting rules, P2/P3/P5 the joining ones. **Reach for it when.** A rule says "one name word" and the input can contain a join — enumerate the joining rules out of the rules rather than the ones you remember. ## MARK-DONT-STRIP — record the decision, keep the fact **Problem shape.** A stage concludes that a vocabulary fact is not operative for one occurrence — a particle that joins nothing, a middle that will render as family. **Contract statement.** It MARKS the token with a namespaced marker rather than removing the vocabulary tag: the tag says what the word IS, the marker says what was decided about it, and consumers ask for the pair. The cost, which has to be paid in the docs rather than avoided: where a stable tag was documented with a RECIPE — `particle` combined with Role.FAMILY "for actual family particles" — the marker makes the recipe diverge from the view it reproduced, so the recipe's documentation has to say so. **How it works.** Stripping is tempting because every downstream consumer becomes correct for free, with no predicate to change; it pays for that by destroying the record, and asymmetrically. `particle` is the only tag all but two of the never-given particles carry, so stripping it leaves nothing on the token, while every ambiguous one still carries `vocab:particle-ambiguous` (the two exceptions, `mc` and `vd`, carry `vocab:suffix` — the same dual membership P6's S2 precedence turns on) — the half where a later fork is most interesting is the half that loses its evidence, and ParsedName has no lexicon to re-derive from. Marking also leaves a POSITIVE predicate: "this token used to have a tag" is not testable, `UNJOINED_TAG in tags` is, which is what an ambiguity emitter needs (#405). And `particle` is documented stable API meaning "a word from the particle vocabulary wherever it lands", so removing it would be a breaking change as well as a lossy one. **Lives in.** nameparser/_types.py — FOLDED_TAG (O3's fold, P6's attachment), UNJOINED_TAG (R2's standalone particle) and UNJOINED_CONJUNCTION_TAG (R3's connective with nothing in its part to join, #461), all namespaced and internal, qualifying stable tags that stay put. **Reach for it when.** A rule decides a word is not doing the job its vocabulary implies. ## STRUCTURE-GATES — comma shape as an explicit state **Problem shape.** A rule should fire only under one comma convention. **Contract statement.** The comma structure (NO_COMMA, FAMILY_COMMA, SUFFIX_COMMA) is decided once and every later stage gates on that single decision rather than re-inspecting commas. segment parts the string and hands every comma form over as FAMILY_COMMA; group decides it at its head, once classify has tagged the words (#613), and script_segment, which runs between and reads the handover, asks its own narrower question where it needs more (`_postnominal_behind_a_whole_name`); classify, the other stage between, reads no structure since #613. **Lives in.** nameparser/_pipeline/_state.py (Structure), _pipeline/_segment.py (the parts), _pipeline/_comma.py (the one decider, called by group). **Reach for it when.** New code is about to count commas. ## TWO-LAYER-ASSIGN — vocabulary claims, position takes the rest **Problem shape.** Where should a new "recognize X" behavior live? **Contract statement.** A vocabulary layer first claims words for what they ARE, wherever they sit; a positional layer then reads every unclaimed word by where it STANDS. Every rule belongs to exactly one layer — with one named exception: the leading-abbreviation shape (rule H2) fires before the suffix vocabulary is consulted, so "Esq. Smith" reads title, not suffix. **How it works.** The two layers compose without ordering bugs because the positional layer never overrides a vocabulary claim (rule O4 is the positional layer's contract). The exception is still ONE, re-checked 2026-09-08 against the trailing title run (rule H5), which reads title vocabulary in the trailing slot and is NOT a second one: it runs after the suffix peel rather than before it, so a post-nominal keeps its claim — `John Smith Esq.` reads suffix and `John Smith Prof. Jr.` reads suffix `Jr.`, both measured. That rule is also the worked answer to the Reach-for-it line below: it wanted a word's identity and its position at once and was split by SLOT, the front asking about shape (H2) and the back about vocabulary (H5), two questions with two criteria rather than one rule fighting both layers. **Lives in.** _classify/_group (vocabulary side), _assign (positional side). **Reach for it when.** A proposed rule wants a word's identity AND its position at once — split it, or it will fight both layers. ## READ-WITHOUT-THEN-BIND — read the name without the construct, then fix that reading **Problem shape.** An optional construct sits inside a name — a maiden clause, a trailing title — and the requirement is that writing it changes nothing else about the name. Checked after the fact, that invariant has to be defended at every place the construct can end, and each defense has to model what later stages would do to the words it gives up. **Contract statement.** Read the name as if the construct were not written, using the rule that reads that position, not the whole parse. The construct takes only what that reading leaves. Where later stages could still move what the reading decided, give those words their roles at the take so no later join can reach them. The invariant then holds by construction and is not a property each stop must defend. **How it works.** M2 before #601 scanned forward, stopped at suffix-looking words, and kept a release check asking at each stop whether the name left standing would read what was given up as post-nominals. That check modelled the P2 chain, the P5 bound join and P6's attachment, and #548 was it failing at the one stop that never asked. #601 replaced the stops and the check with the clause-free reading plus the bind: a net 689 lines out of `_group.py` (+210/−899 in the #601 commit), and #535's grids went from 15,586 and 8,713 invariant violations to 0 (prototype measurement, 2026-10-03, #601's comments). The first half already appears twice. H5's trailing title is TRANSPARENT: "what stands once the chain is taken reads exactly as it would read written without the title, plus the title". P3 and P8 ask the word count and the one-case question of the name's OWN words, so a clause beside the name changes neither. M2 is the first to bind as well, because its construct sits in front of the run it reads and the joins come later. C1 is the second (#613, rules/C7): the part after the comma is read once, after classify and before any join, by the reading a part holding no name word gets, and a postnominal part's roles are set there. segment had decided the structure before the tags existed, so it modelled group's particle chain (#562's pair) and assign's reading of a run the capitals settle, and assign re-decided #296's positional read with a second count; all three went. S2 is the third (#614): the trailing run of a main segment is read once, between P3's connective joins and the particle chain, and split off, so the joins that had re-asked the peel before and after themselves -- the chain's stop and its rollback, the bound-given reserve's two views -- answer from that one reading, and assign places the run off it. Where the read sits is the lesson. Joins the run does not depend on but whose units it counts come FIRST: read before P3's connectives, the H5 chain took the second title of a bare `Mr. and Mrs.` and left 'and' as the family name, and a joined pair counted as two words (#614's review). Joins the run does depend on come after, and the read counts what they will build instead -- the particle chain's run and the words it joins as one unit, marked on the pieces rather than built, with every word the reading weighs still a word of its own: #614 counted through a merged copy, whose review found it hiding a shape-only roman numeral and a title inside a credential run, each patched there, until #620 counted the chain's units over the pieces as written (decisions/S2). Two limits, both measured on #601. Read with the TRAILING rule, not the full parse: a head the full parse reads differently (H1's lone title, a P5 join) would otherwise move where the construct ends. And binding means the run can read differently from the same words with no construct (`JANE Q. DOE GEB. LE DO DO` gives suffix `DO DO`, while the full parse of `JANE Q. DOE DO DO` chains them into the family), which rules/M2 accepts in so many words. **Lives in.** nameparser/_pipeline/_group.py (`_maiden_take`: the view, `tail_reading` over it, and the role-tagged pieces `group()` applies); nameparser/_pipeline/_pieces.py (`tail_reading`, H5's chain); the P3 own-word test in `_group.py`; nameparser/_pipeline/_comma.py (C1's read-and-bind); `_pieces.read_trailing_run` and `_chain_units`, and the read after P3's joins in `_group._group_segment` (S2's). **Reach for it when.** A rule is growing stops, exceptions or a release check whose job is to keep one construct from disturbing the rest of the name, and above all when that check has started to model another stage. Ask what the name reads as without the construct, and make that the rule. ## MERGE-THEN-SETTLE — merge what a count decides, and split back what it declines **Problem shape.** Two words may be one unit, and whether they are is a question only a later reading answers: a count of the words around them, made by a rule that reads pieces. Merged too early, a pair the reading declines is read as one piece where it should be two, and the merge hides a word that would have answered something by itself; left unmerged, the reading never sees the unit at all. **Contract statement.** Merge the candidate into one piece, tagged as a candidate, and let the rule that decides read it. Where the rule takes it, keep the piece; where it declines, split it back into the words it is written as before anything else reads them, so a declined candidate reads exactly as the words read with no merge. Where the deciding reading is one pass over a whole part, read the part again unmerged rather than unpicking the merged reading. **How it works.** rules/S12's split credential (#752, phase 2e): classify tags each pair `_vocab.split_pair` admits, group merges it as a member of the ambiguous class, S2's trailing read decides it by S4's count, and `_group._settle_splits` splits back every merged pair the read left in front of the run before the particle chain or the bound join can see it. In the given part after a family comma, where the deciding reading is assign's walk, the pair is merged ahead of group's joins and the joins treat it as the credential it may be: the particle chain stops in front of it and P5's bound join declines it. Moving the merge after the joins instead, so that none could take it, let the chain take its first word (`Smith, John van Pharm D` read middle 'van Pharm D'): a candidate merged later than the readers it should stop is no candidate to them. After a comma `_comma.decide` reads the part with the pairs merged and, where that reading does not take one as a credential, reads the part again with none merged: the first version kept the merged reading there, and the pair hid an `MB` that had opened the part as a credential (C13), reversing 15 ClinicalTrials.gov names (`Aa Aa, MB ChB BAO` read given 'MB'). The settled outcome is either the merge's reading or the as-written one, never a third, which is what lets a measurement read every move as a pair the count took. **Lives in.** nameparser/_pipeline/_classify.py (the pair tag); nameparser/_pipeline/_group.py (the merge, `_settle_splits`, and P5's decline of a split piece); nameparser/_pipeline/_comma.py (`decide`'s retry, `_pieces(splits=...)`). **Reach for it when.** A unit of several words is decided by a count or a position the merge itself changes, and the safe reading of a declined unit is the reading its words already had. ## LICENSED-BY-NEIGHBOR — a structural word is structural only beside the right neighbor **Problem shape.** A word in a structural vocabulary — a marker announcing a maiden name, a connective joining surnames — turns up where what it would announce makes no sense: behind a title alone, behind a credential, at the end of a name. Designing a reading for each such shape is endless, and the shapes are mostly junk. **Contract statement.** Make the vocabulary claim conditional on the neighbor: the word is structural only where the word beside it has the role the structure needs. Anywhere else it is an ordinary word and goes to the positional layer (TWO-LAYER-ASSIGN), with no reading designed for the junk. **How it works.** M2 (#601): a marker counts only behind a name word of the part holding the family name, not behind the leading title run, an unambiguous suffix word or a connective. So `Dr. nee Smith` gives first `nee`, last `Smith`, and `Jane Doe PhD nee Smith` gives suffix `PhD nee Smith`, neither designed. P3 is the same shape for the connectives that are also generational vocabulary: `i` joins only where "a name word stands on each side of it". The condition must be checked against real names, not just against the vocabulary. #601's first cut also refused a marker behind a particle and broke `Mai Le née Nguyen` and `Anh Do née Tran` — Le, Do and Van are particle vocabulary and among the commonest Vietnamese surnames. **Lives in.** nameparser/_pipeline/_group.py (`_maiden_take`'s head rule; `_between_name_words` for P3). **Reach for it when.** A structural word is producing bad readings in positions its structure does not fit. Make its claim depend on the neighbor before adding a reading or a stop for each position. ## STATE-OFFSET-CHANNELS — early facts ride the state **Problem shape.** A fact known during tokenization matters to a much later stage. **Contract statement.** A pre-token fact is recorded as offsets on the ParseState (comma_offsets, interpunct_offsets) and consulted later by position — or by presence alone where the fact is name-level, as both interpunct consumers do, or as a single boolean for the whole name, as `ParseState.one_case` is (#289/#516: the written-case fact three later sites read — the trailing suffix slot, the post-comma given slot, and the tail-segment reading — none of which may disagree about it) — rather than re-derived from text. **How it works.** The offsets survive every intermediate stage untouched; #298's transcription marker rides this channel from tokenize to order resolution (rules T3/W4). `one_case` differs from the offset channels in WHO writes it: it is computed once by whichever of segment and classify needs it first (classify always, segment only where a comma form could turn it on), recorded as `None` for "not asked yet", and never recomputed once set (decisions/S6). **Lives in.** nameparser/_pipeline/_state.py, produced in _tokenize (the offsets) or in segment/classify (`one_case`). **Reach for it when.** You are about to re-scan the original string in a late stage to rediscover something tokenize already knew. ## PIPELINE-STAGE-CONTRACTS — the ownership map **Problem shape.** "Which stage does X?" — asked before attributing behavior in prose, comments, or fixes. **Contract statement.** Each stage's docstring header declares what it consumes, produces and reads, and ParseState's docstring holds the cross-stage map, pinned by tests/v2/pipeline/test_state.py. **How it works.** A claim about which stage or layer does something is CHECKABLE — `parse(s).tokens` prints every token's role and tags — so check it before writing it; one plausible attribution sentence once shipped six times wrong (AGENTS.md's stage-attribution note). **Lives in.** nameparser/_pipeline/_state.py and every stage header. **Reach for it when.** Writing any sentence of the form "X happens before Y sees it." ## ONE-PREDICATE-PER-QUESTION — one predicate answers it, and every other site calls that **Problem shape.** Two stages need the same answer about the same input, and the one that does not own the decision is about to test for it. **Contract statement.** Where two sites ask the same question, exactly one predicate answers it and every other site calls that one — never a condition written to match it. The predicate belongs to the QUESTION, not to whichever stage decides: it may sit in a leaf both stages import, and for the leading-title test it must, since the deciding stage is assign and group cannot import assign. Stage order is this mechanism's limit: where the reader comes AFTER the decider, record the answer on the state instead — `ParseState.order` is that shape, "Recorded rather than recomputed downstream, because the two can differ" — which is unavailable whenever the EARLIER stage is the one asking. **How it works.** A hand-written mirror agrees with its original only until one of them moves, and the drift is invisible in both directions: each site keeps passing its own tests while they disagree about an input neither covers. Every instance below was found as a defect before it was found as a pattern. The destination follows the LAYER, not the topic: a predicate over token text goes to `_vocab`, one over pieces and tags to `_pieces`, both leaves the stages sit on. The piece layer got its own module only in #439; until then those predicates collected in `_group`, the one place both stages could reach, so the sharing between group and assign predates the module (decisions/parse-cost measured #439 as a pure relocation). The cost is a second evaluation of the same predicate, measured for #429 at 1.2–2.2% of a family-comma parse and 0% of every other — the right price there over plumbing a state field the two sites would not otherwise share. Folding two copies into one, take from each caller only what genuinely differs between them, and ask whether a parameter one caller had been filling with a guard is a question the other caller must answer too (#617, decisions/P3). The census of which stage calls which predicate, and how each instance was found, was kept here until #759 and is in git history (`git show fe85bb3d:docs/design/mechanisms.md`); the decisions entries carry each instance's argument. **Lives in.** nameparser/_pipeline/_vocab.py over token text: `is_wholly_suffix` (#319, decisions/W2), `reads_as_generation` (#401/#421 as `is_trailing_numeral_suffix`, decisions/P5; since #752 the predicate every rule weighing a numeral as the generation asks, the vocabulary's own numerals read inside it; C4's run refuses a one-letter numeral by its shape alone, `is_single_letter_numeral`, a question about the shape of an initial and not a generation, decisions/vocabulary-model), `split_pair` (#752, the split credential: `Ph.` `D.` once, which group's merge, `_comma._pieces` and `is_wholly_suffix` each tested with their own copy; since phase 2e every pair rules/S12 reads, asked by classify alone behind a C-level shape, length and fold test and written as a tag that group and `_comma._pieces` read, `is_wholly_suffix` asking `SPLIT_PAIR_OPENS` for the Ph./D. answer alone; decisions/phd-merge, decisions/S12), `is_cased_paired_initials` (#752, rules/S3's paired initials in a script with capitals, which classify asked inline; C5 asks the bare `is_paired_initials`, decisions/S3), `maiden_marker_run` (#434, decisions/M2), `delimiter_cores` (#436/#437, decisions/R1), `in_initialless_script` (#322/#323, decisions/cjk-full-stops), `caps_shape_candidate` (#516, decisions/S9). nameparser/_pipeline/_pieces.py over pieces and tags: `is_suffix_piece`, `leading_titles` and `is_leading_title` (#424), `is_title_piece`, `peel_walk`, `trailing_start`, `tail_reading` (the peel-and-chain fixed point that answers where the name pieces end, decisions/H5), `segment_suffix_reading` (#430, decisions/R1), `particle_tail` and `trailing_candidates` (#610, decisions/M2), `join_connectives` (#617, decisions/P3). nameparser/_pipeline/_post_rules.py: `suffix_entries` (#511, decisions/R1), the one instance in a stage rather than a leaf, being a pass over a whole ParseState. tests/v2/test_layering.py holds each module's contract, and a piece predicate growing a dependency on a STAGE shows up there as a widened entry. **Reach for it when.** You are about to write a condition that mirrors, matches or "does what X does" — or you find a comment saying one does. Grep for the other site's predicate and call it instead. ## RENDER-HONORS-THE-PARSE — the parse decides it, the views honor it **Problem shape.** A render view needs a fact the parse already settled — whether a word is the conjunction or an initial, whether a particle is acting as one, which word renders first. **Contract statement.** The parse decides it; the render views honor those decisions and never re-evaluate them. Two directions break that: a view RE-DERIVES the answer from the text, keeping its own copy of a pipeline predicate, or a view honors the record and then OVERRIDES it, readmitting what the decision excluded. Known limit: a token the parse never saw carries no decision to honor, so a view falls back to the vocabulary — the tell is `UNCLASSIFIED_TAG`, which `ParsedName.replace()` stamps when it splices raw text into a field and the facade's v1 pickle load stamps when it rebuilds a name from `*_list` strings. **How it works.** This is the CONSUMER-side rule over the producer-side entries — VOCAB-TAGS records what the vocabulary knew, MARK-DONT-STRIP what a stage decided about it, FOLDED_TAG what order to render in — and a view reads what they recorded. It sits where ONE-PREDICATE-PER-QUESTION's stated limit leaves off: a render view always comes AFTER the decider, so the answer is recorded on the TOKEN, no view being able to see a ParseState. The fallback's tell is the mark and nothing else: not untaggedness, since an ordinary parsed name word carries no tags, and not `span is None`, since `Parser.revise()` builds span-less tokens whose tags it keeps on purpose. A view can fall back only if it is HANDED a vocabulary — `capitalized(lexicon=...)`, and since #528 the facade's `HumanName.initials()` — and the core `ParsedName.initials()`, handed none, never guesses one (decisions/R4). Within a view that can fall back, the fallback is drawn per QUESTION (rules/R4's Accepted clause). Both directions have shipped as defects and both are closed: re-deriving in case repair (#458, decisions/R4) and in `initials()`'s written-order walk (#408, decisions/R3), overriding in `initials()`'s readmission of an all-particle part's connective, closed by settling what rules/R3 says rather than by making the view louder (#461, decisions/R2 and decisions/R3). Two divergences stand by decision: the facade's fallback can initial a spliced field differently from a parse of the same text (decisions/R3, 2026-09-13 #528), and case repair's PARTICLE conjunct keys on the lexicon it is handed rather than the `particle` tag (decisions/R4's NOT DONE bullet). The case history this entry carried until #759 is in git history (`git show fe85bb3d:docs/design/mechanisms.md`). **Lives in.** nameparser/_render.py (`capitalized`/`_cap_word`, `_reads_as_conjunction`, and `initials`, the CORE view, which honors tags and never falls back), nameparser/_types.py (`_text_for` and `UNCLASSIFIED_TAG`, with the `ParsedName.replace()` producer beside it), and nameparser/_facade.py, on this list twice and for opposite reasons: the v1 pickle load is the SECOND producer of the mark — a change that follows the list into `_types.py` alone leaves it behind, which is the site test_a_restored_pickle_keeps_v1_conjunction_repair protects — and since #528 `_token_is_conjunction`/`_process_initial` are a CONSUMER, reading the tag and calling `_render._reads_as_conjunction` for the mark's own tokens. **Reach for it when.** A view is about to consult a Lexicon, a regex or an exception list about a word the parse already saw — or a view and a field disagree about the same parse, which is the shape #408 was (`parse("der, y van")` gave family `van der` and initials `y. d. v.`; decisions/R3). ## CLAUSE-CONTENT-OVERRULES-DELIMITER — content wins **Problem shape.** A bracketed clause should be treated as something other than what its delimiter pair says. **Contract statement.** extract may inspect a clause's content against the lexicon and, when it matches, mask only the two delimiter spans so the inner content rejoins the main token stream for ordinary downstream parsing. **How it works.** "Andrew Perkins (MBA)" is not a nickname (rule S1): the parens are masked away and MBA is classified by the normal machinery — reusing the downstream path, so the delimited and bare forms cannot drift. **Lives in.** nameparser/_pipeline/_extract.py (_suffix_shaped and the inner-span branch); and nameparser/_pipeline/_post_rules.py (`_settle_delimited_members`), the one site where content overrules the delimiter WITHOUT rejoining the bare path: a member of the ambiguous class settled by its writing after the name (rules/S13) is the suffix directly, because a dotted member's bare reading would differ (`M.A` reads as initials bare), so it is decided once the parse's roles are read rather than unmasked here (decisions/S13). **Reach for it when.** About to add a second code path that duplicates what the bare form already does — #335's fix is this shape (a _maiden_marked sibling predicate). Not when the bare reading is the wrong answer for the clause: that is S13's shape. ## CURATED-VOCABULARY-ALTERNATION — the config already splits them **Problem shape.** Two string shapes collide — the same written form means two different things — and no predicate separates them. **Contract statement.** A curated vocabulary that OMITS the ambiguous entries is itself the separator: membership is the license, and the per-entry vetting reasons live beside the set. **How it works.** GLUED_HONORIFICS is the exemplar (rule W2): 씨 peels because it can never end a name; 양 stays out because 김지양 is a given name. The set, not a regex, draws the line. **Lives in.** nameparser/vocab/*.toml, where each word the peel may take carries `attached`. A word refused only the glued position carries `excluded = { attached = … }` on the reading it keeps (`uv run python tools/vocab/query.py --excluded attached`), and a word refused the honorific reading in both positions, such as 君 or bare 선생, carries a word-level `excluded` (`--excluded honorific`), each with its reason. **Reach for it when.** Arguing that "no regex can separate X from Y" — check whether a config set already splits them by listing one side. The second half of the pattern: to USE the set in a regex, hand-copy the alternation and pin the copy against the set (tests/v2/test_regex_sync.py holds the pipeline's copies). ## RECORDED-ROSTERS — record the answer, don't re-derive it **Problem shape.** A guard needs to know what the answer WAS, so it can detect the answer changing. **Contract statement.** Store the measured answer as literal data (a roster) and compare against it; never re-derive the expectation from the same inputs the check reads, because a derivation from the same data always agrees with itself. Known limit, and it is what the contract statement does NOT cover: a roster row can hold a recorded INPUT as well as a recorded expectation, and only the expectation half is asserted. The test to apply to a recorded input is not "is it checked" but "would the consumer answer DIFFERENTLY if it were wrong". The differential ledgers' rosters were this entry's worked cases (`_CROSS_RULE_WINNERS` carried wrong recorded shapes that agreed with themselves until #497 moved them where a run measured them), and were retired with the ledgers in #664; their measurements are in git, at the commit decisions/differential-ledger names. **How it works.** The purest case is a recorded expectation whose only consumer is the check that compares it, re-derived by nothing: tools/differential/snapshot.jsonl records every corpus name's reading, and tests/v2/test_snapshot.py compares the tree against it. **Lives in.** tools/differential/snapshot.jsonl, tests/v2/test_facade_cases.py (_CORE_ONLY_IDS), tests/v2/test_benchmark.py (_CALL_BASELINE), tests/v2/rules_budget.json (each rule's recorded size). **Reach for it when.** Writing a check whose expected value is computed by the code under test, or a comment that enumerates ids/counts — make it data the suite asserts. And when a roster row carries anything the check CONSUMES rather than compares, ask what measures that half. ## COMPILED-DATA-COMMITTED — compile the data at build time, hold the artifact to a fresh compile **Problem shape.** Data written for people (rich, commented, one fact per word) is the wrong thing for a runtime to parse on every import, and a generated runtime form can drift from its source. **Contract statement.** Compile the source into a committed, generated module that the runtime imports, and test that the committed module is byte-identical to a fresh compile, failing with the command that refreshes it; run the compiler's checks over the shipped source in the same test, with warnings as errors, and give each check a mutation it must refuse. **How it works.** The parser imports nameparser/config/_compiled.py, frozenset literals one entry per line so a one-word change is a one-line diff; tools/vocab/build.py writes it from nameparser/vocab/*.toml; tests/v2/test_vocab.py compiles in memory and compares. The source can grow descriptive data (notes, counts, expansions) that compiles to nothing and costs the runtime nothing. Cost: a data change touches two files and needs the tool run, which the staleness test turns from a silent drift into a CI failure carrying the fix. **Lives in.** tools/vocab/build.py, tests/v2/test_vocab.py (decisions/vocabulary-model). **Reach for it when.** A data file the runtime would otherwise parse, or a hand-kept Python copy of data that has a better home — and not for a measured expectation, which is RECORDED-ROSTERS. ## CANONICAL-VOCABULARY-AT-THE-BOUNDARY — one vocabulary at the comparison **Problem shape.** Two surfaces name the same concept differently (first/last vs given/family), and a matcher needs to compare across them. **Contract statement.** Canonicalize at the point of comparison: every compared surface's output is converted to one vocabulary before matching, and the canonical choice is the one the codebase already derives everywhere else. **How it works.** Teaching the matcher both vocabularies doubles every comparison silently; canonicalizing means a second surface joins without new keys. The snapshot records the facade's v1 fields under Role's names, and compare.py's 1.4.0 rows do the same, so a 1.4 reading and a 2.x one compare field by field. **Lives in.** tools/differential/snapshot.py (row, V1_FOR). **Reach for it when.** A second surface joins an existing matcher — without this, every existing comparison silently stops matching the new surface, which looks like added coverage. ## VOCABULARY-OVERLAP-AS-PRECONDITION — assert the intersection you stand on **Problem shape.** A test's input depends on two config sets intersecting (a word that is both a title and a particle), and a vocabulary edit could quietly unground it. **Contract statement.** Assert the intersection as a precondition and distinguish the two failure modes: word moved (pick another member; the message prints what is left) versus intersection empty (the code under test is unreachable — delete it rather than repointing the test). **Lives in.** tests/v2/test_parser.py (test_the_chained_emitter_is_still_reachable). **Reach for it when.** Any config-coupled fixture — a test input chosen because of what a vocabulary happens to contain. ## VOCABULARY-FEEDS-STRUCTURE — a wordlist edit can move the comma decision **Problem shape.** Editing suffix vocabulary looks field-local, but the comma-structure decision (rule C1) reads that vocabulary, so a removal can flip which segment is the family name. **Contract statement.** Suffix-set membership is an input to the structure decision: removing a word from the suffix vocabulary can change a suffix-comma name into a listing-form name, relocating the family. **How it works.** Measured in the comma-suffix arc: dropping "dr" from the suffix vocabulary flips "John Smith, Dr." from family="Smith" suffix="Dr." to family="John Smith" — three individually correct documented facts composing into a family-name loss. **Lives in.** nameparser/_pipeline/_comma.py (the C1 decision, since #613) reading the suffix sets through _vocab.is_wholly_suffix. **Reach for it when.** Editing suffix membership for any word that occurs after a comma in real data — check the structure flip, not just the field. ## WARN-AT-THE-CALLER — walk out of the library, don't count frames **Problem shape.** A warning should point at the caller's code, but the entry depth varies (constructor, add(), unpickle, dataclasses.replace, the shim snapshot), so any fixed stacklevel lands on library internals for most paths. **Contract statement.** The warner walks the stack outward until the first frame outside the library's own modules and warns there, instead of counting frames. **Lives in.** nameparser/_lexicon.py (_warn_dead_entry), and the related but distinct per-read-location choice is in the #293/#354 bridge (decisions/3-0-reevaluations). First measured instance of the hazard: the delegated bytes-deprecation warnings shipped with fixed stacklevel=2 and needed the follow-up fix (0044073). **Reach for it when.** Adding any warning reachable through more than one public entry point. ## LEGACY-STATE-SIGNATURE — subtract legacy defaults only as a complete set **Problem shape.** A retired default rides in on old pickles, but a user may have deliberately re-added one of the same entries. **Contract statement.** Known-dead legacy defaults are subtracted from restored state only when the state carries ALL of them — the complete pre-retirement signature — so real legacy blobs clean up silently while a deliberate single re-add survives round-trips. **Lives in.** nameparser/_lexicon.py / _config_shim.py (_LEGACY_DEAD_ENTRIES). **Reach for it when.** Retiring any default vocabulary entry that existing pickles may carry. ## AMBIGUITY-AT-THE-DECISION-SITE — emit where the branch is taken **Problem shape.** An ambiguity report should fire exactly when the parse chose between live readings — no more, no less — and the choosing happens in code, not in vocabulary. **Contract statement.** Emit at the site that takes the branch, not where an ambiguous tag sits; when a fork's two branches are decided in different stages, EVERY deciding stage carries an emitter; and a branch that runs but changes nothing is not a decision and must not report. The decision is the site's, but a FIELD the report names is not: a later rule can still move the word, so the emitter leaves the field to assemble (`PendingAmbiguity.field_tail`), which words it from the final role (#626). Segment's report on a part past the second comma names no field at all, since that part's words can land in two (#629). The reading NOT taken is named at the same site (`PendingAmbiguity.alternative`, #752 phase 2m), and where its field is a later stage's to know, the emitter defers it to assign or assemble rather than guess it (`_state.Deferred`). **How it works.** PARTICLE_OR_GIVEN fires from assignment for a lone leading particle and from grouping when a title shifts it off the front — for two years only the first site emitted. And keying on "the code got here" instead of "the outcome differed" once reported a fork for all ambiguous particles on "Dr. Van Jr.". **Lives in.** The AmbiguityKind emitters across _pipeline/ (rule A1 is the observable contract). **Reach for it when.** Adding any ambiguous vocabulary or any new fork — count the deciding sites, then count the emitters. ## MAKE-WRONG-STATES-UNREPRESENTABLE — the house meta-pattern **Problem shape.** A convention keeps being violated no matter how clearly it is written down. **Contract statement.** Convert the consistency problem into a referential-integrity problem: make the wrong state impossible to express, or mechanically checked, rather than merely documented. **How it works.** Three instances built this documentation system: _CORE_ONLY_IDS replaced an enumerating comment; verbatim-excerpt citations replaced paraphrase; no-boundary: markers replaced silent omission. The executable examples in the rules are the same move at document scale. **Lives in.** tests/v2/test_doc_citations.py, tests/v2/test_rules_doc.py, and every recorded roster. **Reach for it when.** Tempted to write "remember to keep X in sync with Y." ## LOCALE-PACKS-PURE-DATA — packs configure, they never compute **Problem shape.** Language-specific behavior needs a home that cannot drift from the core. **Contract statement.** A locale pack is pure data — a Policy patch and Lexicon ADDITIONS, add-only: a pack unions vocabulary in and can never remove a base entry — applied by parser_for; packs contain no code paths of their own, and each pack's docstring declares its deviations from the defaults. **How it works.** What is policy for every language (an order constant) is policy, not pack data; packs are lowercase modules exposing uppercase constants; a pack error is wrapped with the pack's code (rule D2). Pure data means a pack can be audited by reading it — and add-only means a bad DEFAULT can never be routed around by a pack, which is why #342-class fixes must change shipped vocabulary. **Lives in.** nameparser/locales/ (packs), nameparser/_parser.py (parser_for, the one applier). **Reach for it when.** A language fix wants an if-statement — make it vocabulary or policy in a pack instead. ## FACADE-CONTRACT — HumanName wraps the core; 1.4-warning-free code keeps working **Problem shape.** Where does v1-compatibility behavior live, and what may it do? **Contract statement.** HumanName is a mutable facade over the immutable core: code that runs warning-free on 1.4 keeps working through 2.x with identical results EXCEPT release-log-classified fixes, verified for 2.0 by the differential ledger and since #664 visible against 1.4.0 through `tools/differential/compare.py --baseline 1.4.0` (the RFC's compatibility promise carries the same carve-out) — via validating setters, dirty-tracked re-parses, and pickle round-trips — and the facade never calls the v1 parsing hooks it still carries. "Warning-free" describes the code's 1.4 behavior, not a promise it STAYS warning-free: 2.x adds deliberate warnings (the multi-word-vocabulary and field-assignment ones, and from 2.8 a `DeprecationWarning` on every use 3.0 removes, decisions/3-0-reevaluations), each release-log-classified, always with identical results. **Lives in.** nameparser/_facade.py; v1 import paths preserved by nameparser/parser.py and nameparser/config/. **Reach for it when.** A core change needs a v1-visible behavior — the facade, not the core, is where parity lives (and cite decisions/3-0-reevaluations when parity is the only reason). ## CONFIG-SHIM-SNAPSHOT — v1 config mutations reach the core by snapshot **Problem shape.** v1 code mutates CONSTANTS at runtime; the core is immutable. **Contract statement.** nameparser.config re-exports mutable managers whose contents are converted to immutable core objects through a dirty-tracked snapshot: mutations mark the snapshot stale, and the next parse rebuilds it, so v1 mutation semantics survive over an immutable core. **Lives in.** nameparser/_config_shim.py, nameparser/config/. **Reach for it when.** Wiring any new v1 config surface — it must go through the snapshot, never sideways into a Lexicon. ## Verification shapes How to measure in this codebase without fooling yourself. The inert-measurement class — checks that run, print plausible results, and measure nothing — has recurred double-digit times; these shapes are its known antidotes. Convention (AGENTS.md): guard tests SHOULD carry a RECORDED negative control, the `_UNGUARDED_EFFECT` shape (tests/v2/pipeline/test_state.py) — the answer with the guard off, stored as data. Honest limit, precisely drawn: these reduce the inert-measurement class. Mutation testing reaches part of the wrong-predicate class too — mutating the thing a guard watches exposes a guard that never depended on it (the #329 survivors: deleting the tag check left the suite green while every multi-word maiden clause lost its first word, after adversarial review had passed that code twice) — but a predicate wrong in a way the fixture happens to satisfy still needs adversarial review. ### VERSION-TELL — know who answered **Contract statement.** A subprocess that speaks for a pinned version writes its __version__ and __file__ as its first output line, and the caller aborts before comparing anything if either half disagrees with what was requested. Both halves are load-bearing: an editable install reports the tree's version (agreement proves nothing when tree and baseline share a number); a genuine wheel at the wrong version passes any path check. Lives in tools/differential/compare.py (_check_tell). ### GENERATED-SCRIPT-OUTSIDE-THE-WORKTREE — escape the shadow **Contract statement.** A worker that must run under a pinned dependency is rendered to a temp directory outside the worktree and spawned by absolute path, so sys.path[0] contains no copy of the package and the inline pin is genuine. Sentinel substitution (@@VERSION@@), not str.format — the worker body is mostly literal braces. Lives in tools/differential/compare.py (_worker_source, _run_worker). ### SENTINEL-SET-OVER-MATCH-CHECK — catching match-everything **Contract statement.** A user-supplied pattern is rejected as over-matching by probing it against a small set of inputs sharing no script, vocabulary or punctuation; matching all of them means it targets no behavior family. Measured: `.`, `.+`, `\b` and `[\s\S]` all decline the empty string — the naive probe — and still match every corpus name. Lived in tools/differential/compare.py (_SENTINELS) until the ledgers it checked were retired (#664); the shape stands for any user-supplied pattern. ### VOCABULARY-EXERCISES-FORKS — pin the branch, not the wordlist **Contract statement.** A vocabulary set a caller configures is data; the code that reads it is what has behavior, so a case row earns its place by exercising a FORK — a branch the parser takes for one INPUT and not another — never by covering one more member. The unit is the input, not the member, because most forks are keyed on the shape of the surrounding name rather than on which word appeared. Find them by reading the sites that consult the set and enumerating the branches they take, and expect that enumeration to come back empty: `maiden_markers` is read at exactly two sites (`_classify.py`'s tagging and `_extract.py`'s `_maiden_marked`), both plain whole-token membership after `_normalize`, and every shipped member parses to one structural class in bare, bracketed and lone-bracketed shapes with the marker text normalised out — zero member forks. What earns the maiden rows is clause SHAPE: marker first against marker later, a word after the marker against none, a marker a separator divides from the name against one glued to it. Two adjacent things are NOT forks of this set and do not want rows: the substring-versus-token split is a question tools/differential/README.md's coverage recipe asks wider than the parser does, and edge-period normalization lives in `_lexicon._normalize`, applying to every token of every vocabulary, so it discriminates spellings rather than members. Only "a member borne as an ordinary name" is member-keyed (decisions/vocabulary-collisions), and it has its own rows. The reduction does NOT carry across to delimiter pairs, and the difference is the line to hold: a pair the library SHIPS is caller-visible behavior, and vocabulary a caller configures is data — deleting `("«", "»")` from the defaults reddens three tests, so cases.py's one-row-per-shipped-pair convention is load-bearing and this criterion is not permission to delete those rows. Within the pairs the code does fork, on whether open and close are the same character (N2) and again inside that class, where `WORD_INTERNAL_DELIMITERS` carves the apostrophe out: `Jane Smith"` reports UNBALANCED_DELIMITER and `Jane Smith'` reports nothing. ASCII against fullwidth is not one of them — swapping `( )` for `( )` across every corpus name holding a paren changes no field and no ambiguity kind. A row may also earn its place a second way, with no fork behind it: pinning a name a release note advertises BY NAME, which is a promise to a reader rather than a branch in the parser. It follows that "N of M entries have no test" is not a finding; it reads as one because it carries a number, and it inflates with every entry added, which is the tell that it measures the data. The finding is "this fork has no test". State the argument without present-tense counts over a drifting wordlist — see docs/design/AGENTS.md — and where a count is genuinely wanted, ship the one-liner instead: ``` uv run python -c " from nameparser import parse from nameparser.config.maiden_markers import MAIDEN_MARKERS shapes = ['Jane Smith {m} Jones', 'Jane Smith ({m} Jones)', 'Jane Smith ({m})'] cls = {} for m in sorted(MAIDEN_MARKERS): sig = tuple(tuple((f, (getattr(parse(s.format(m=m)), f) or '').replace(m, '')) for f in ('given', 'middle', 'family', 'suffix', 'nickname', 'maiden')) for s in shapes) cls.setdefault(sig, []).append(m) print(len(MAIDEN_MARKERS), 'members ->', len(cls), 'structural classes')" ``` ### FORCE-A-DECISION-TABLE — no silent defaults on growth **Contract statement.** Where adding an enum member or a file must not silently inherit a default, a local table's key set is asserted equal to the population, so growth fails the suite until someone decides — against a local table, not the constant under test. Exemplar: tests/v2/pipeline/test_vocab.py's per-script initials check; reused for the pack registry (test_registry_is_the_pack_contract: every registered pack must ship DEVIATES and its rotator list or the suite fails structurally — decisions/deviates-registry). Known gap it exposes: DEFAULT_SCRIPT_ORDERS has no such guard. ### SELF-EXPIRING-GUARD — a decline keyed to a measured defect **Contract statement.** A workaround keyed to a third-party library's measured defect carries a canary test that pins the defect itself, so the workaround cannot outlive its reason: when the library fixes it, the canary fails and the decline gets revisited rather than fossilizing. Exemplar: tests/v2/test_locales.py's namedivider shime canary pins 0.4.x cutting 〆木太郎 at offset 1 with confidence 1.0, guarding the adapter's 〆 decline (#303 arc); its comment says to revisit the decline, not delete the test. ### SIMULATE-EVERY-OPTION — price the option space, not the chosen option **Contract statement.** Where a fix has more than one plausible shape, every candidate is measured before one is chosen — simulated in-process against the real pipeline rather than implemented — and the choice cites the resulting table. **How it works.** The seams already exist, so simulating is cheap: `Parser(lexicon=dataclasses.replace(Lexicon.default(), ...))` for a vocabulary question, a pytest plugin recompiling one stage for a code question, a monkeypatched predicate for a guard. What the table buys is the discovery that the candidates differ in ways argument does not reveal — #400's obvious fix, adding `abd` to the wordlist, provably changed nothing on the common three-word shape, and the two vocabulary alternatives each silently lost a credential spelling; the winner was a fifth option the wordlist framing had hidden. The counterexample is the same PR's ledger `fields`, widened to include `suffix` on one reviewer's finding without pricing what else the widening claimed: it took `{family, suffix}` from a neighbouring rule and two further shapes from UNEXPLAINED, diffs in which the rule's own subject does not move, and was reverted. One table would have shown that before the commit rather than after. **Reach for it when.** A review names a defect and the fix looks obvious — that is when the option space is least examined, and the obvious fix is the candidate whose cost nobody has priced. Not a mechanism with a home module; the technique IS the seams above, which is why it sits here rather than in the catalog. ### TWO-ELEMENT-GROUPS — a partition's mutation check needs something to permute **Problem shape.** A test pins an ordering, a sort, a dedup or a partition, and its fixture gives one of the affected groups a single element. **Contract statement.** Every group a test claims to cover carries at least TWO elements, because on a one-element group a partition is the identity — the assertion holds whether the code runs or not, and the mutation that should expose it passes. **How it works.** This is the inert-measurement class arriving through the FIXTURE rather than through the predicate, and it hides behind a mutation check that looks conclusive: #408's `initials()` partitions three roles, and its first test carried two GIVEN tokens and one MIDDLE, and its second carried two of each and no FAMILY token at all — a group of ZERO is the identity as surely as a group of one, and looks even less like a hole. Scoping the partition to FAMILY failed that test, which read as the arms being pinned — but skipping the partition for MIDDLE alone passed the entire suite (measured 2026-08-30, in review, at 6128 passed), because `initials("{middle}")` over one token is the same string either way. The tell is that the mutation which fails is not the mutation the test's docstring promises: a per-role claim wants a per-role mutation, one arm at a time, and only then does the fixture's arity show. **Reach for it when.** A test asserts on the ORDER or MEMBERSHIP of a rendered group and the fixture was written to be small. ### SHADOWED-NEGATIVE-ROWS — a new check can answer for an old row **Problem shape.** A change adds a check to a validator, a constructor or a loader that already has negative rows — rows that build a bad value and assert it is refused — and the new check refuses those rows too, whether it runs before their own checks or after them. **Contract statement.** After adding such a check, every existing negative row is re-run with its OWN check removed; a row that still passes is being refused by the new check instead, and its fixture is rebuilt to satisfy the new check, so that each row is refused by the one check it names. **How it works.** This is the inert-measurement class arriving through a SECOND REFUSAL: with a row's own check gone, the row still raises, the suite stays green, and the assertion reads as correct, because a refusal is all it asks for. #798 gave the NPPES credential tally (tools/name_usage.py) a consistency check, that every stored spelling choice names a token. Four rows testing the token values replaced the tokens with a single key and kept the stored choices, which still named the token they had dropped, so the new check refused all four as well, though it runs after the value checks there. The mutation run found it: deleting each of the three value checks those rows guard left the suite green (3 of that run's 38 mutants missed). The fix replaced the stored choices along with the tokens in each row, with a comment above the parametrize saying why. Distinct from TWO-ELEMENT-GROUPS, where the fixture is too small to exercise the code, and from the root AGENTS.md's widened check that quietly narrows: here the old check still works and its test has stopped reaching it. **Reach for it when.** The check you are adding relates two fields — a cross-field consistency check is the usual cause, since a row editing one field breaks it without meaning to. ### NAME-DATA-JOBS — the job decides where a source's names live **Problem shape.** A new source of names turns up (a registry, a dataset, a dump) and the question is what to build for it: a committed corpus, a column in a tool, a script that writes vocabulary. **Contract statement.** A name source does one or more of five jobs — WATCH, SCORE, COUNT, DERIVE and SURVEY — and the job decides where its data lives. Only WATCH and SCORE in CI need the names in the repository, so a source whose names cannot be committed still does COUNT and SURVEY, WATCH and SCORE outside CI, and DERIVE where its terms let a derived list ship. **How it works.** WATCH asks whether a change moved a name: a committed corpus, `snapshot.jsonl` and a test that fails, or a local corpus read with `snapshot.py --local`, which no CI run sees. SCORE asks whether the parser agrees with someone who knows the fields: a committed fields file and agreement record, read by `cldr_agreement.py` from the repository alone, or a local corpus whose records carry the fields their filers, or the bearers themselves, typed, with its record kept in the cache beside it (`local_agreement.py`). The local score tells a change's author what moved among real filed names, by field, and gives agreement by group and how often each disagreement shape occurs. It cannot guard anything: no CI run and no reviewer sees it, and no test fails when it moves (the tool itself exits 1). Its truth is the filer's, so a box filled wrongly (a job typed as a title) reads as a miss unless the scorer sets it aside, and the scorer has to say what it sets aside; where the bearer typed the boxes, a miss is a disagreement with the person, though one who typed a family-first name into the given box reads as a miss too. COUNT asks how one word is used: a cached source behind a row of `tools/name_usage.py`, with only the counts cited. DERIVE asks what a shipped list should hold: a cached source and a script writing the vocabulary (`tools/zh_surnames.py`). SURVEY asks how people write whole names, and how OFTEN they write each shape: a cached local corpus (`tools/differential/local_corpora.py`). A second axis cuts across the jobs: whether the names are real or invented, labelled with fields or bare text, chosen or unselected. Every committed corpus is CHOSEN: invented to fill fields (CLDR, the conventions table, the rules examples), cataloged (GND), filtered to names whose every word aligns with a stated part (both Wikidata corpora, a filter that drops credentials and typos), or picked by someone testing or reporting a case (v1's test names in `corpus.jsonl`, the case-table rows in `corpus_cjk.jsonl` and `corpus_shapes.jsonl`, and the strings reporters typed into `corpus_issues.jsonl`). The last group does hold hand-typed shapes — a job title in the name (`Bob Jones, author`), a credential before the comma (`Washington Jr. MD, Franklin`) — but one instance of each, as chosen. What SURVEY adds is the unselected population and its frequencies: ClinicalTrials.gov's study officials (local corpus `ctgov-officials`, measured 2026-10-21) are typed into a free-text field by sponsors' staff, so they show how often each shape occurs, and hold some no committed corpus does, such as `MBBS,FCPS` with no space. The FEC's individual contributors (local corpus `fec-individuals`, measured 2026-10-22) are unselected too, but mostly a filer's software writing a form's fields in a fixed order, so nearly all of its 1.89 million names share one shape (capitals, family, comma, given) and the hand-typed part is what goes inside the fields (a credential run or a suffix after a second comma): the population for that shape's variants, and blind to case. The UK Electoral Commission's individual donors (local corpus `ukec-individuals`, measured 2026-10-22) are the opposite shape: names as a donee's return gave them, mixed case and in spoken order, `parse()` reading a title in 8,219 of 15,214 (`Mr`, `Lord`, `Sir`) and only 2 holding a comma. Brazil's individual donors to candidates (local corpus `tse-donors`, measured 2026-10-22) are the FEC's comma-less counterpart: 848,351 filed names, nearly all in capitals and in spoken order, full of particles and agnomens (`DE`, `DA`, `DOS`; `FILHO`, `NETO`, `JUNIOR`), each beside the tax registry's name for the same donor, so a typed form can be read against its reference form (205,925 of 854,531 pairs differ). A source that pairs two spellings of one person scores without fields: the parse of each spelling should find the same surname, so where the spellings differ only in what cannot move a surname (accents, case), a disagreement is the parser reading one name two ways, found with no one labelling either (`local_corpora.py pairs --agreement`; on `tse-donors` on 2026-10-22, 1,304 of 43,552 accents-only pairs, 1,121 of them from `JÚNIOR`/`JUNIOR` and `SÁ`/`SA`). Read it per shape: a pair that leaves a word out can change the surname for real. JRC-Names (local corpus `jrc-names`, measured 2026-10-24) widens the pair to a group: one person's spellings harvested from news text across scripts, 218,822 persons with two or more. Words in two scripts cannot be compared, so `local_corpora.py groups` compares the readings' structure instead (title, suffix, given, family, the family's length and where it stands), counting persons. Read it per pair of scripts, since a structure can differ by convention: a script read family first stands its family elsewhere, and a writing variant moves the reading for real (the Arabic comma, katakana with and without `・`). Companies House's people with significant control (local corpus `psc-individuals`, measured 2026-10-22) are unselected and labelled at once: each filer types the name into four boxes (title, forename, middle name, surname) and states a nationality, so the corpus is the one real population a local SCORE reads by nationality. It is filed through a form's boxes, so it holds almost no comma (838 of 1,040,708 sampled records) or credential. ORCID's researchers (local corpus `orcid-researchers`, measured 2026-10-27) are labelled by the bearer rather than a filer: each holder typed their own name into two boxes (given names, family name), from 250 countries. Two things keep it from being a SURVEY. It keeps at most 20,000 records a country, so it shows which shapes each country holds and how the parser reads them, never how often a shape occurs across countries. And its country is where a holder works or lives, most often taken from an employment, so a country's row is its institutions' researchers, not its nationals. It is the SCORE to read for a language pack: 80.5% of its 1,831,533 records agreed on every field, as few as 13.4% in Peru, and the other 17 countries of at least 1,000 records under half were Brazil and 16 Spanish-speaking ones (Argentina read 80.3%); in the two commonest disagreement shapes, 249,526 of the 357,517 records that disagreed, a two-word family box was read as one word (measured on master at 598167f4). PatentsView's inventors (local corpus `patentsview-inventors`, measured 2026-10-28) are labelled by a professional filer, the applicant's attorney, and carry the number of people who filed under each name in each country, kept uncapped in the extract: the one fielded source that counts the people under each name, so a word's frequency among a country's US-patent inventors (not its population) can be read from it. Its agreement reads beside ORCID's as how a name is written against how its bearer types it: 99.8% in Japan, 99.5% in China and 99.4% in Korea, where ORCID read 95.5%, 84.5% and 92.4%, while Spain reads about as low in both (45.6% here, 48.0% in ORCID). One source may do several jobs, each through its own code: Wikidata does COUNT live and WATCH and SCORE through its builders. A live source has no fixed file to pin, so its cached copy is the pin, carrying the source's own data date and a sha256 checked on every load (a truncated file otherwise reads as a run of false zeros, #775). **Reach for it when.** A source is proposed, before choosing between a builder and a tool column; and when a corpus would hold real people's names nobody vetted for publication, which is the case for a local corpus rather than a committed one. ### PAIRED-SPELLINGS-PER-CHANGE — count both directions, and compare whole fields **Problem shape.** A change is measured over a source that holds two or more spellings of one person (tse-donors' filed and registry names, nepal-candidates' romanization and Devanagari, a JRC-Names group), and the question is whether it makes the parser read one person one way. **Contract statement.** Per change, count the persons whose spellings go from reading differently to reading alike AND the persons who go the other way, never a net figure; compare the readings on what the two spellings can share (whole given and family fields with accents folded and an initial matching the word it begins, or, across scripts, the structure alone); and read the "now apart" moves by shape before calling any a regression. **How it works.** Agreement is not correctness, so this is a second measure beside a SCORE (mechanisms.md#NAME-DATA-JOBS), never in place of one. Its trap is a comparison that agreed for the wrong reason. #752's first version compared the family's LAST WORD as written, and gating the Brazilian agnomens counted 1,043 tse pairs "now apart" (2026-10-24): on master both spellings had agreed on `FILHO` as the family, and once the agnomen read as a suffix the comparison reached the real surname, which the two spellings write differently. Comparing whole fields after folding cut the agnomen gate's apart count to 304, and reading those by shape showed what was left: a word differing between the spellings, an initial, a length difference, and 47 differing only by accent or case, which point at the one real defect: the spike asked a case cue the design does not. Across scripts the same measure found what no single-script corpus could: taking `Guru` out of the titles pushed 44 Nepali pairs apart because its Devanagari twin stayed a title. **Reach for it when.** A change moves a word between fields in a population that holds the same names spelled twice; and when a vocabulary word has a twin in another script, which a pair source shows and a single-script snapshot cannot. ### Field notes — the traps themselves - Test a function INSIDE a stage on the inputs its caller hands it, not on the stage's finished output. #610's first sweep test built pieces by running `group` whole and handed them to the maiden take's candidate function, but the take runs before any join, so the test read `de VI Prof.` as one piece where production hands it three: a guard checking a structure production never passes in, green for the wrong reason. Review moved the oracle to the take itself by wrapping it (tests/v2/pipeline/test_pieces.py), and recorded the old loop's misses there as the negative control (432), which is what shows the moved harness can still see a difference. - Enumerate the rules that BUILD a structure; do not recall them. #395's unit walk was written three times in one PR — P2's chain missing, then the conjunction and bound-given branches absorbing one token where the particle branch absorbed a unit, then the suffix stop — and each miss came from listing the joining rules from memory instead of reading them out of the rules. Both later misses reproduced the very defect the first fix had just removed, mirrored. - Widening a predicate widens nothing its callers filter out first. A predicate on a hot path often sits behind a C-level pre-check at each call site, a necessary half of it inlined so a word that cannot pass pays no frame (`isalpha()` and `isupper()` ahead of `caps_shape_candidate`), and a widened predicate behind an unwidened pre-check is unreachable: #752's joined capitals (`PU-PH`) failed `isalpha()` at all three call sites, and the 2026-10-23 measurement of the change first moved nothing. Before measuring a widened predicate, grep its call sites for the pre-check and widen each with it, and hold one input the change must move. - Prefer monkeypatching a module-level helper to re-exec'ing a module: a caller looks a module global up at CALL time, so replacing `_units` or `_name_positions` reaches it and none of the traps below apply. Re-exec is only needed for logic INLINE in a function, and "the thing I want to mutate is not a module-level helper" is a hint that it could be one. - Re-exec'ing a module redefines the classes that module DEFINES, not the ones it imports — the exec re-runs its own import statements, which resolve through sys.modules. So `is` comparisons break selectively: re-exec'ing `_group.py` yields a second `BoundJoin` (defined there) while `Structure` (imported) stays identical, and `bound_join is not BoundJoin.DISABLED` is then always true, joining a family comma's own segment and reporting 3,150 phantom movers. Rebind what the module defines (`mod.__dict__["BoundJoin"] = real.BoundJoin`) and assert the identity rather than assuming it either way. - `_pipeline.STAGES` holds function OBJECTS captured at import, so re-exec'ing a stage module leaves the pipeline running the old function and every result is a false negative. Rewire `STAGES` and assert the new function is in it. - Either route: hold one input the mutation MUST move and one it must NOT, and check both before believing a count. Every trap here produces confident numbers rather than an error. - Assert which tree you imported, on BOTH sides of a comparison. `python -c` puts CWD on sys.path; a script's own directory holds no nameparser in tools/differential/, so a stray PYTHONPATH outranks the editable install — measured: 89 diffs became 0, exit 0, both tell halves passing, because both sides had become the shadow. - Release notes describe intent; TAGS are ground truth. Before trusting a changelog cross-reference about what shipped where, run `git diff -- ` — the "1.2.1 pickle shim" claim recurred twice from a changelog line describing a thing that never shipped. - Never pipe a check's output when its exit code matters. Under zsh, `snapshot.py | tail` makes `$?` tail's status. Redirect to a file and read the file. - Mutation-test a new guard before believing it, and mutate the thing the guard watches — a survivor usually means the fixture satisfies the invariant for free. A guard over a HAND COPY of a pipeline set has two such holes, and #744's review found both in one test of tools/vocab/build.py's check 2, which copies the tokenizer's sets because the tool cannot import the package it writes. Pinning a copy to its source sees only the classes someone copied: the first test pinned `DIVIDERS` alone, and emoji, which the tokenizer strips under `Policy.strip_emoji`, had no copy and no check, so `dr★` compiled and the test passed. Its replacement swept every codepoint `_tokenize._ignorable` drops but accepted any refusal short of a TOML error, so the space in `dr x` passed because the phrase-title check refused it, and a class check 2 never learned would pass too wherever a later check happened to refuse its test text. Drive the sweep from the pipeline's own predicate, match the refusal by the checked rule's own messages, and record the control (hiding the emoji check behind its pinned constant fails the sweep: `test_every_character_the_tokenizer_ignores_is_refused` in tests/v2/test_vocab.py, which now leaves the space out, a phrase's word separator). The predicate reaches only what it decides, though: the interpunct between East Asian characters (rules/T3) is divided by context in `_tokenize_region`, never by `_ignorable`, so hiding that check passes the sweep (measured in #746's review, 2026-10-19). What holds it is its `MUTATIONS` row (`text-with-an-east-asian-interpunct`, input `先·生`); the `EAST_ASIAN_RANGES` and `INTERPUNCT` pins hold only the copies, and stay green with the check gone. Name such a class where the guard stands, and the test that holds it, rather than let the sweep imply it. - A claim about what a vocabulary entry DOES is a claim about the parse WITHOUT it, so take that parse (`Parser(lexicon=Lexicon.default().remove(titles={...}))`) and compare: the parse with the entry present shows only that the output is consistent with it. #773's first note on `wm` said that removing it would move `Wm. Smith`, read off the parse with the word listed; with the word removed, `Wm. Smith` read exactly as before, rules/H2's leading-abbreviation title then claiming `Wm.` either way. The corrected note then said only the bare `Wm Smith` moves, and that was wrong too (#789, 2026-10-24): it took the off-switch parse at the head of the name only, and a trailing `Wm.` (rules/H5) and a `Wm.` before a family comma (rules/H6), which read vocabulary rather than shape, moved as well. A shape rule shadows an entry only in the spelling and the slot it reads, so take the off-switch parse for each spelling, bare and period-marked, in each position a rule reads the word in. - Verify the restore, not just the mutation: diff against the pre-mutation copy; do not trust the harness's own restore report. - Run all the gates, not the ones you remember: ruff runs before mypy and pytest in CI, and each has caught what the others passed. - Purge __pycache__ between same-length source mutations; stale bytecode makes a changed file measure as unchanged. - After NARROWING a rule, check the receiver: the names a narrowed rule sheds land on a neighbour, and nothing guarantees the neighbour's prose describes what it inherited — #375 fixed an over-claiming rule and relocated the bug onto its neighbour. Ask also what the old behavior was CONCEALING: #379's attachment removed the input a test used to build an all-particle middle name (#402), and #400's reserve fix exposed the dual-membership count shape #397 names -- twice in one session a fix's real yield was a defect it stopped hiding. - A check that is quiet about a name is not evidence that the baseline read it the same. #603's first draft wrote that 1.4.0 read a title past the second comma as a title because no 1.4.0 name moved unexplained, when the 1.4.0 ledger's broad `fix(comma-family)` rule was claiming each one; the released wheel read suffix 'Jr., Attorney General'. Ask the wheel (`PYTHONSAFEPATH=1 uv run --isolated --no-project --with nameparser==X`, from outside the worktree, or `tools/differential/compare.py --baseline X`) before writing what a release did. - A skip is indistinguishable from "correctly declined": pytest turns an empty parametrize into a skip, and a filter that widens its own skip set cannot fail. After changing any selection shape, verify the guard still REACHES the code it watches — assert the selected set is non-empty, or force-a-decision on its size. - A differential corpus cannot evidence behavior keyed to OUT-of-vocabulary shapes: it holds only names someone wrote down, and an unrecognized word is by definition outside the vocabulary — a green run over the corpus proves nothing about such a rule. - The corpus can be near-blind to a WRITING CONVENTION even where the vocabulary is well covered, and a small honest count then reads as a small blast radius. Measured for rules/P6: of 782 corpus names, 245 carry a comma, TWO of those end in a particle, and ONE of the two clears the words-to-spare guard and actually moves — so the Dutch trailing-tussenvoegsel listing the rule exists for is essentially unsampled. Before reporting "N names move", report the size of the population that COULD move; when that is ~1, the number is evidence about the corpus. This note's first wording said "exactly ONE ends in a particle", conflating the population with the movers — the very error it exists to prevent, committed inside the correction. Caught by re-running the count against AGENTS.md's axis 1 rather than by re-reading it. - An invariant judged on the part a JOIN PRODUCED is satisfied by whatever the join absorbed. #397's both-sides condition was stated as "a name word stands on each side of the link", and the invariant written from that sentence walks the joined part and asks whether a non-connective word stands on each side of the letter — which a swallowed credential answers yes to, being that word. `Josep Lluis Carod i III` read family 'Carod i III' and PASSED it. The merge has already destroyed the evidence, so the question cannot be re-asked downstream at all. Judge on the PRE-join classes, or against a second parse with the rule turned off — the off-switch parse puts the credential in `suffix`, and "no word the off-switch reading calls a suffix lands inside a joined name part" caught it on 639 parses of the shipped grid where the first invariant caught none (960 over the full cross product, a dated snapshot before the grid was trimmed by shape). The same pairing is worth reaching for whenever a rule MERGES tokens. - A detector that re-implements a rule's grouping will get the grouping wrong. Derive the boundary from the same vocabulary the rule reads, not from the half you happen to be thinking about: walking a particle run over the NEVER-GIVEN set alone (the rule chains through ANY particle) split "de la Vega" after "de la" and reported 50 false movers for #364, where the true count is one. Both wrong answers were plausible and printed cleanly. - Guard the whole family, parametrize over it: a defect on one of N parallel entry points hides behind a per-example test — three times in one session (a guard on one class of two, a decode hint on 3 of 5 entry points, a sync roster missing 4 copies) — and a {class}×{field}×{bad-value} parametrization is what caught each. - A growth guard needs calibration, not just existence: benchmark guards that compare n vs 4n catch the quadratic the absolute-time tests are blind to, but calibrate against the WEAKEST signal you must detect and confirm a planted regression fails across repeated runs, not once — a stochastic check "verified" on one sample verifies nothing. - A frame BUDGET pinned on one reference name cannot see algorithmic blowup; a frame RATIO over two input sizes can. #531's first implementation had a trailing walk re-enter the predicate that owns it, so a run of k ambiguous credentials cost 2**k — `'Doe, John ' + 'MA '*24` took about 6s, wall clock, where the parent commit took 0.24ms (two runs of that one measurement, 5.8s and 5.9s, which is the spread the guard below quotes them at) — while every absolute figure beside the change stayed correct and unmoved: `tools/perf/call_count.py`'s 412/449 on its reference name, and +0 frames on a comma name carrying no class member. The budget is a claim about ONE input and the defect is a claim about a family of them, so no budget is the instrument. The guard that catches it counts frames at 8 members against 16 and bounds THAT ratio (`tests/v2/test_benchmark.py::test_a_trailing_credential_run_does_not_cost_exponentially`): 2,347 → 394,671 frames unfixed, 168x, against 865 → 1,497 and 1.73x fixed, with the bound at 6x. ONE pair does not see every curve, though, and the fix round measured how badly: at 2× the input the per-member linear work swamps a quadratic, so the per-member memo the first fix shipped — a genuine quadratic — reads 2.08x at that pair, inside any bound this one can carry. Separating those needs 4× the input, where the same memo reads 7.42x against the linear 3.53x, so the guard carries a second pair (16 against 64) and asserts the cheap one FIRST: an exponential does not return from a run of 64. Frames rather than a clock for two reasons — the recursion IS frame entries, so the count is the defect rather than a proxy, and the input length that separates an exponential from a linear on a timing test does not finish. It needs the reachability assertion a `_POLICY_SHAPES` row carries, for the same reason: the walk under measurement runs only while every unit still reads as a credential, and a precedence change that routes them elsewhere leaves a green test measuring a walk that no longer happens. - Mind the optional-extra environment split: a local venv's incidental namedivider makes `if available` branches run PRESENT locally and ABSENT in CI, so a locally-green suite proves nothing about the no-extra path (this broke #337's first landing). Run the decisive check in both states or gate the example. derek73-python-nameparser-456b024/docs/design/nameparser-2.0-rfc.md000066400000000000000000000332011527303271700246330ustar00rootroot00000000000000# RFC: nameparser 2.0 — a new core API Status: **open for feedback** — no deadline. Implementation may proceed in parallel; this document will be amended (as commits to this PR) as implementation teaches us things. Comment on specific lines here, or on the discussion issue (#284) for general reactions. ## The short version, and the promise nameparser 2.0 introduces a new, immutable core API and keeps the existing one working: ```python from nameparser import parse name = parse("Dr. Juan Q. Xavier de la Vega III (Doc Vega)") name.given # "Juan" name.family # "de la Vega" name.family_base # "Vega" name.title # "Dr." ``` **The compatibility promise:** code that runs warning-free on nameparser 1.4 keeps running on every 2.x release, with identical results, getting at most `DeprecationWarning`s. `HumanName` continues to exist as a compatibility layer over the new core. The exceptions to "identical results" are (a) parse-output changes that are deliberate bug fixes, each documented in the release log with its issue, and (b) three narrow edge cases listed in the Migration section. Removal of the old API happens in 3.0, not 2.x, and only after warnings have named every replacement. If you only use `HumanName` and never customize configuration, 2.0 should be a non-event for you. The rest of this document is for everyone else — and for anyone who wants to push on the new design while it is still cheap to change. ## Why a new core Fifteen years of issues point at the same architectural limits: - **`HumanName` is four things in one mutable class** — parser, state, vocabulary predicates, and formatter. Every feature grows all four at once (it is currently ~1,600 lines), and mutation semantics are inconsistent: assigning `full_name` re-parses, assigning `last` does not, `capitalize()` mutates in place. - **Parse state is `list[str]`**, so position information is lost and re-derived by value lookups. That is the structural root cause of a whole family of prefix-join bugs (#100 and relatives): when the same string appears twice, the lookup answers the wrong question. - **Configuration is a shared mutable module global** (`CONSTANTS`). Most of `nameparser.config`'s ~850 lines exist to make shared mutation survivable (custom set managers, descriptors, cache invalidation), and it still causes test pollution and cross-thread surprises. - **Equality is broken by design** (#223): case-insensitive equality, equality with plain strings, and hashability are mutually inconsistent promises. 1.3.0 deprecated `==`/`hash()` and shipped `matches()`/`comparison_key()`. - **The roadmap needs structure v1 can't cheaply provide**: family-first name order (#270), per-dataset configuration without global mutation, and honest reporting of genuinely ambiguous parses. The rules-based approach is not changing. nameparser's niche is deterministic, auditable, zero-dependency parsing — the same input always parses the same way. The rewrite reorganizes *how the rules run*, not what the library is. ## The new API ### Parsing produces an immutable value `parse()` returns a `ParsedName`: a frozen dataclass. There is no mutation anywhere in the new API — editing is `replace()`, which returns a new value: ```python name = parse("John Smith") name2 = name.replace(given="Jane") # new object; `name` unchanged ``` `parse()` is **total over `str`**: any string — empty, emoji, garbage — returns a `ParsedName` rather than raising. Content problems are represented in the result; the only exceptions are type errors (`bytes` raises `TypeError` — decode first). Equality is strict structural equality, and the semantic comparisons are explicit and separate — `comparison_key()` for dedup/sorting and `matches()` for component-wise case-insensitive comparison — resolving the #223 trilemma by not conflating the three meanings of "equal". ### Two access layers: strings for the 95% case, tokens for the rest The string properties behave like v1's: always `str`, `""` when empty. Underneath, `ParsedName` carries the actual parse: a tuple of `Token`s, each with its **span** (position in the original string), its role, and tags recording *how it was classified*: ```python name = parse("Juan de la Vega") name.tokens_for(Role.FAMILY) # (Token('de' @5:7 FAMILY {particle}), # Token('la' @8:10 FAMILY {particle}), # Token('Vega' @11:15 FAMILY),) ``` This is the structural fix for the value-lookup bug family: pipeline stages address tokens by index, so "which 'de' did you mean?" cannot arise. It is also the debuggability story — "why is 'Van' in my family name?" is answered by the token's tags, and `repr(name)` prints a compact component-per-line breakdown like v1's. Derived views (`family_base`, `family_particles`, `surnames`, `given_names`) are pure filters over tokens — they can never disagree with the parse. ### `given` and `family`, not `first` and `last` The v1 field names describe Western *position* — they date from before the parser even supported comma formats, when position was all there was. They read wrongly the moment name order varies: under family-first order, `name.last` would return the first word. 2.0 adopts the names the standards world already uses (HTML autocomplete `given-name`/`family-name`, vCard, Unicode CLDR): - `first` → `given`, `last` → `family` - `last_base`/`last_prefixes` → `family_base`/`family_particles` - config: `prefixes` → `particles` (every web standard uses "prefix" for honorifics like "Mr." — v1's usage collides; the linguistics term for van/de la is *particle*) - `title` and `suffix` **stay** — they have ecosystem gravity, and the positional taxonomy ("pre-nominal"/"post-nominal") is documented rather than spelled into identifiers. - `maiden` is a **new** field — a birth surname introduced by a marker like `née` or `geb.`, which v1 folded into `middle`/`last` (#274). `HumanName` keeps every v1 spelling, including `string_format` keys, so existing code is unaffected; only new-API adopters see the new names. ### Configuration: `Lexicon` (vocabulary) and `Policy` (behavior) v1's `Constants` mixes three kinds of thing; 2.0 splits them by what varies together: - **`Lexicon`** — vocabulary data (titles, suffixes, particles, conjunctions, …). Immutable; composable by union; varies by *language*. - **`Policy`** — behavior switches (`name_order`, patronymic rules, delimiter pairs, …). Immutable; varies by *data source*. - **Rendering parameters** — `string_format`, initials style, capitalization — move to render-time arguments; they vary by *output destination* and are no longer configuration at all. ```python from nameparser import Parser, Lexicon, Policy, FAMILY_FIRST parser = Parser( lexicon=Lexicon.default().add(titles={"dra"}), policy=Policy(name_order=FAMILY_FIRST), ) parser.parse("Wang Xiuying").family # "Wang" ``` A `Parser` is immutable, thread-safe, and does all its compilation once at construction — build it at startup, share it everywhere. There is no shared mutable configuration in the new API, which retires the entire test-pollution/config-war problem class. `name_order` is an order-spec tuple (`FAMILY_FIRST = (Role.FAMILY, Role.GIVEN, Role.MIDDLE)`) rather than a boolean, because Vietnamese order — family, middle, *then* given — needs a third arrangement (#270). ### Honesty about ambiguity Some parses are genuinely undecidable by rules — "Kelly Michael" could be either order; in "Van Johnson", "Van" is a Dutch particle *and* a common given name. v1 silently picks a reading. 2.0 picks the same reading **and says so**: ```python parse("Van Johnson").ambiguities # (Ambiguity(PARTICLE_OR_GIVEN, "leading 'Van' may be a family-name # particle; read as a given name", tokens=(...)),) ``` `Ambiguity.kind` values are stable API you can filter on. This is also groundwork: a future `parse_all()` returning ranked alternative readings, and `explain()` returning a stage-by-stage parse trace, are designed for (the pipeline supports both) but deliberately not in 2.0. ### Locales: opt-in packs, never auto-detection Language-specific behavior cannot be auto-detected — "Tham Jun Hoe" is token-for-token indistinguishable from a Western three-token name, and "Ali" is Arabic or Italian. So locale support is explicit: ```python from nameparser import parser_for, locales parser_for(locales.RU).parse("Ivanova Anna Sergeevna").given # "Anna" ``` A locale pack is pure data: a vocabulary fragment plus a partial policy patch, folded in at parser construction. 2.0.0 ships `RU` and `TR_AZ` (formalizing the patronymic support added in 1.3.0); Vietnamese and Japanese (via a `nameparser[ja]` extra with a pluggable segmenter) are designed and staged for later 2.x minors. Vocabulary that cannot misfire on other languages' names (e.g. Cyrillic honorifics) goes in the default lexicon instead of packs. *Amended for 2.1 (#271):* the Chinese/Korean work landed, and split along that last line rather than shipping as one pack. Korean surnames and family-first order for native-script CJK cannot misfire on other languages' names, so both became defaults; only the Chinese surname list needed a pack (`ZH`), because it would corrupt the Japanese names written in the same characters. The never-auto-detect rule holds as written for a name's *language*; its **script** is a different signal, and where the script alone settles a convention, behavior may key on it. ## Migration: what actually happens to existing code **`HumanName` in 2.0** is a wrapper over the new core with v1's exact mutation semantics, field spellings, and formatting attributes. The v1 test suite runs against it as the compatibility gate. v1 pickles load throughout 2.x. **`CONSTANTS` keeps working** — mutations are honored exactly (the facade rebuilds its config snapshot when the shared config changes) and emit a `DeprecationWarning` pointing at the replacements. Mutating a *private* `Constants` instance does not warn. **Already-scheduled removals land as warned** (every one was deprecated in 1.3.0 or 1.4 first): `==`/`hash()`, bytes input, slice item access, `empty_attribute_default`, `constants=None`, silent config-typo fallbacks, legacy pickle blobs, `add_with_encoding()`. Python floor becomes 3.11; the last dependency is dropped. **The three narrow behavior exceptions** (everything else identical): 1. `*_list` attributes return snapshots — in-place `name.first_list.append(...)` no longer mutates parse state (assignment still does). 2. Subclasses overriding parsing hooks (`parse_pieces`, `is_title`, …) are detected and warned at construction: the facade delegates to the new core, so such overrides are no longer called (#280). If you do this, we want to hear what your override does — that is a feature request we would rather absorb than break. 3. Assigning to `CONSTANTS.regexes` raises with a pointer to the named `Policy` flag that replaces it. Custom regex injection has no equivalent in the new model; nothing is silently ignored. **Timeline:** 2.0.0's only *new* warning is on shared-`CONSTANTS` mutation. A warning on `HumanName` construction itself comes in a later 2.x minor, once the new API has proven out — and 3.0 (which removes the facade) does not ship before that warning has been out in a release. (It ships in 2.8, the last 2.x feature release: decisions/3-0-reevaluations, 2026-10-28.) ## Alternatives considered and rejected - **Auto-detecting name language/order** — impossible in principle, not just hard; see the Locales section. - **A statistical/ML core** — determinism and auditability are the point of this library; the token/ambiguity model leaves room for optional escalation later without changing the core. - **Keeping `first`/`last`** — see the naming section; the facade keeps them for v1 code, which serves the familiarity constituency without wiring position-based names into the new API. - **A boolean `family_name_first`** — cannot express Vietnamese; the order-spec tuple can (#270). - **Renaming `title`/`suffix` to `pre_nominal`/`post_nominal`** — more precise, but no ecosystem uses those as field names; precision lives in the docs instead. - **Vocabulary entries as records with per-word metadata** — rejected under "no vocabulary data without a consuming rule": a flag the parser doesn't read is a promise it doesn't keep. Cross-category words are handled by dual set membership (closed classes) or the two `_ambiguous` sets (closed-vs-open), each of which has a consuming rule. - **A user-replaceable regex escape hatch** — replaced by named policy flags; anything not expressible through them is a feature request we want to see. ## Feedback we are specifically looking for No deadline — comment whenever, on lines here or on the discussion issue. The questions where answers would most change things: 1. **Do you compare `HumanName` objects with `==` (especially against plain strings), or use them in sets/dicts?** 2.0 changes this to object identity (warned since 1.3.0; `matches()`/`comparison_key()` are the replacements). This is the one silent behavior change in the release. 2. **Do you subclass `HumanName` and override parsing methods?** Tell us what your override accomplishes — we would rather absorb it as a feature than break it silently (#280). 3. **Do you mutate `CONSTANTS` after startup** (not just at import time)? Anything beyond startup-time customization we should know about? 4. **Do you assign custom regexes to `CONSTANTS.regexes`?** What for? Each real use case either becomes a named policy flag or stays broken — we would like the list to be complete before 2.0. 5. **`given`/`family` instead of `first`/`last`** in the new API: does this help or annoy you? 6. **Is there anything you build on top of nameparser** (wrappers, pipelines) where the compatibility promise as stated would still break you? derek73-python-nameparser-456b024/docs/design/rules/000077500000000000000000000000001527303271700222425ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/rules/A-ambiguity-and-tie-breaking.md000066400000000000000000000005121527303271700300310ustar00rootroot00000000000000## Ambiguity & tie-breaking (A) Background: some name strings are genuinely ambiguous — the same written shape carries two readings ("Van Johnson": given name or particle?), or the text's structure is malformed. Parsing never fails and never silently discards; it completes on the best reading and says what it was unsure of. derek73-python-nameparser-456b024/docs/design/rules/A1-ambiguity-report.md000066400000000000000000000036371527303271700263370ustar00rootroot00000000000000# A1 — the ambiguity report **When:** the text's structure or a word's reading is genuinely uncertain, as in "Van Johnson". **Then:** the parse completes on the best reading and carries an ambiguity report naming the doubt, as parsing never fails on any input. - **A report names the reading the parse took**, so a report whose fork the rest of the parse then resolved to NEITHER branch is withdrawn rather than carried beside a reading it contradicts: a letter both a connective and an initial (P8), read as the generation it also spells, is neither of the two the connective-or-initial fork offered, and only that fork's report goes — the generation's own stands. ``` "JOHN QUINCY SMITH I" → ambiguities=("suffix-or-name",) ``` - **A report that names the field its word was read into names the field the word holds in the result**, whatever rule moved it after the report was made. - **A report of a fork carries the reading the parse did not take**: its kind, its source, and the field the word would land in with every other word read as the parse read it, save a reading no field could hold, a connective with no word before it to join. **Because:** a caller can only act on doubt that is reported. ``` "Van Johnson" → ambiguities=("particle-or-given",) "Jane „JD Smith" → ambiguities=("unbalanced-delimiter",) "John Smith, MD, Bart" → ambiguities=("comma-structure",) "John Smith" → ambiguities=() · boundary ``` **Accepted:** - The one exception to totality is a user-supplied segmenter's own error, which propagates — a user-code error is not a content error. (Needs the optional extra to demonstrate, so no example line.) --- **Signal:** none of its own: the forks the other rules call, and whether the parse kept a reading either offered **history:** decisions/A1 · **implemented:** nameparser/_pipeline/_assemble.py, nameparser/_pipeline/_state.py derek73-python-nameparser-456b024/docs/design/rules/A2-empty-name.md000066400000000000000000000013351527303271700251020ustar00rootroot00000000000000# A2 — the empty name **When:** an input holds no name content, no letter or digit, as in ".,". **Then:** a name with no name content parses to the empty name — every field empty, false as a boolean — while ambiguities born from its punctuation survive on the empty result. **Because:** an input with no name content names nobody, and saying so beats inventing fields from punctuation. ``` ".," → family="" "(" → ambiguities=("unbalanced-delimiter",) "John . Smith" → family="Smith" · boundary ``` --- **Signal:** count: no name content, no letter or digit, in the input **history:** decisions/A2 · **implemented:** nameparser/_pipeline/_assemble.py derek73-python-nameparser-456b024/docs/design/rules/C-commas-and-structure.md000066400000000000000000000011451527303271700270220ustar00rootroot00000000000000## Commas & structure (C) Background: a comma in a name signals one of two conventions — the listing form "Family, Given" or trailing credentials "Name, PhD" — and which is meant can only be judged from what stands after the first comma. Recognizing a credential run is by nature a vocabulary judgment, so this is the one structural decision that consults the suffix word lists. Which characters COUNT as the comma is part of the rule: the Arabic comma (U+060C) and the fullwidth comma (U+FF0C) both signal the listing form, while the ideographic comma (U+3001) is not a name-structure comma at all (#265). derek73-python-nameparser-456b024/docs/design/rules/C1-suffix-comma-decision.md000066400000000000000000000100531527303271700272150ustar00rootroot00000000000000# C1 — the suffix-comma decision **When:** a name holds a comma, as in "Smith, John" or "John Smith, PhD". **Then:** the name reads as trailing suffixes when the part after the first comma is entirely suffix words and more than one word precedes the comma, and otherwise as the listing form, the part before the comma being the family name, less a title run opening it (H6), unless the part after it fixes no family boundary (C8). - **Only the part after the first comma decides**, but for a suffix word ending a whole name before it (C12). - **How the words before the comma are counted** is C3's. - **The ambiguous credential class**, counted by name words (C4), with paired initials (C5) and an unlisted all-caps word (C6) read there by rules of their own. - **A part read as postnominal** is read before any join (C7). - **The listing form's part after the comma** is still read for what it is (C8), and a credential opening it makes it the postnominal part (C13). - **A one-character suffix word inside a comma part** is read by what stands before it (C9). - **A suffix delimiter**, the semicolon, a hyphen or slash standing alone, or one the policy declares, parts the suffix words on each side of it as a comma would (C10). - **What the decision reports** is C11's. **Because:** a credential run after the comma means the name is in natural order with suffixes appended; anything else after the comma means the listing form. ``` "Smith, John" → family="Smith" "سلمان، محمد" → family="سلمان" "田中、太郎" → family="" "John Smith, PhD" → suffix="PhD" "John Smith, V." → suffix="V." "Smith, PhD" → family="Smith" · boundary "John Smith, Jones" → family="John Smith" ``` **Precedence:** - **A suffix word written like an initial** ("John Smith, V."): by default a recognized suffix word counts even written like an initial ("V."); under the strict comma-suffix switch C14 governs, and the word is vetoed. - **The count against written case** ("Smith, MA", "Smith Jr., Ma"): C4 counts the name words before the comma first and asks written case (S6) second, only where the count has left the word a name. - **Paired initials against the count** ("García Márquez, G.J."): C5 is the stated exception to C4's count, since two words before the comma may be one surname written in two. - **An unlisted all-caps word** ("John Smith, XYZ"): C6 decides whether it joins C4's class at all, only where the name carries a case contrast, and once it has joined, C4's count reads it. - **A generation ending the name before the comma against paired initials after it** ("John Smith Jr., M.J."): written dotted, the initials keep C5's reading and are the given name, the generation staying the suffix; written undotted as two capitals, they are C12's credential, read as the credential they look like. A numeral ending the family part is read as the comma-less name reads it (S10), so the roman numerals S10 reads by their shape stay the suffix as the listed ones do. ``` "John Smith Jr., M.J." → given="M.J." "John Smith Jr., MJ" → suffix="Jr., MJ" "John Smith V, M.J." → suffix="V" "John Smith VI, M.J." → suffix="VI" ``` **Accepted:** - The vocabulary question this rule asks — is the part after the comma nothing but suffix words — is asked a second time, without the word-count condition, to decide whether a glued East Asian honorific standing before a family comma comes off. That composition is tolerated input rather than contract — a comma between a family name and a given name is no part of CJK writing — so the precedence is stated at W3, with its examples, and not here. It is also the one consequence of this question Latin script cannot witness, nothing there being glued to the end of a name — which is why this bullet carries no example of its own. --- **Signal:** hub of C3, C4, C5, C6, C7, C8, C9, C10, C11, C12, C13 **history:** decisions/C1 · **implemented:** nameparser/_pipeline/_assign.py, nameparser/_pipeline/_comma.py derek73-python-nameparser-456b024/docs/design/rules/C10-suffix-delimiter-parts-a-suffix-run.md000066400000000000000000000164331527303271700320450ustar00rootroot00000000000000# C10 — a suffix delimiter parts a suffix run **When:** a suffix delimiter, the semicolon, a hyphen or slash, or one the policy declares, stands after the first comma, alone in a trailing suffix part, as in "John Smith, MD - PhD", or glued to a word, as in "John Smith, MD; PhD". **Then:** a suffix delimiter parts the suffix words on each side of it as a comma would: the words on each side of it read exactly as the parts of the same text written with a comma in its place, so no join, maiden clause or connective reaches across it. - **The semicolon, the hyphen and the slash are delimiters by default**, beside the comma: a delimiter the policy declares is one more, and no policy removes them, as none removes the comma. - **A built-in hyphen or slash parts only standing alone**: glued to a word or inside one it is the word's own, as in the particle "al-" or the joined credential "MD/PhD", so only the semicolon and a delimiter the policy declares are read glued. - **A delimiter standing alone is dropped**, unless it stands alone as the whole of its part. - **A delimiter glued to a suffix word parts it from the next as a comma glued there would**: the word keeps the delimiter in its text and reads as it would without it, so the part after the first comma reads as it does with a space in the delimiter's place, its word joining no word after the delimiter. Only a delimiter written without a letter or digit is glued: one that is itself a word parts only standing alone, a word holding its letters being another word ("Stand" under " and "). - **Only a trailing part is parted by a delimiter standing alone**: in the part before the first comma, or in the given part of the listing form (C8), such a delimiter is a word (the third Accepted bullet). **Because:** a writer who separates suffixes with a semicolon, a spaced hyphen or a spaced slash, or a caller who declares a separator, means it to separate suffixes as a comma does, and the simplest reading that honours that is one where it is a comma there. ``` "John Smith, MD; PhD" → suffix="MD; PhD" "John Smith, MD;PhD" → suffix="MD;PhD" "John Smith, M.D.; Ph.D." → family="Smith" "Smith, MD; PhD" → suffix="MD; PhD" "John Smith, MA; PhD" → suffix="MA; PhD" "John Smith, Dr; MD" → title="Dr;" "John Smith, MD - PhD" → suffix="MD, PhD" "John Smith, MD / PhD" → suffix="MD, PhD" "John Smith, Dr. / MD" → given="John" "John Smith, MD - Chief of Surgery" → suffix="MD" "Smith, John, MD - PhD" → suffix="MD, PhD" "John Smith - Jones" → family="Jones" · boundary "Smith, John - Paul" → middle="- Paul" · boundary "John Smith, MD – PhD" → suffix="MD – PhD" · boundary "Smith, John, PhD - and MD" → suffix="PhD, and MD" "Smith, John, Puig - i Soler" → suffix="Puig, i Soler" "Smith, John; MD" → given="John;" · boundary "Smith, RN - CRNA" → suffix="RN - CRNA" · boundary ``` **Except:** - **A glued word written like an initial** is the given name wherever its space twin's is, behind one name word as C1 reads it and under the strict comma-suffix switch (C14). ``` "Smith, V; MD" → given="V;" "John Smith, V; MD" strict-comma-suffixes → given="V;" ``` **Precedence:** - **A glued part behind a head that is not a whole name** ("Smith, MD; PhD"): the part reads as its space twin does, the credential opening it making it the postnominal part (C13), so C10 decides nothing there that C1 does not. **Accepted:** - A particle with a delimiter glued after it joins nothing, backward included: the particle reads as no particle, so a trailing one is no tussenvoegsel of the family the comma names, as every release from 1.4.0 read it, where its space twin attaches it (P6). ``` "Berg, Jan van; MD" → middle="van;" "Berg, de; Smith" → given="de;" ``` - A maiden marker in a part a delimiter standing alone separates takes no clause. The delimiter costs nothing here: a marker in a part after a suffix comma is an ordinary word with or without a core (M2), so 'Smith, John, MD née Jones Smith' takes none either. Releases 2.0 through 2.3 read past the delimiter and took the clause. ``` "Smith, John, MD - née Jones Smith" → suffix="MD, née Jones Smith" ``` - A delimiter core the policy names (T1), or a built-in hyphen or slash, is a word here, not structure — v1 applied a declared delimiter to the suffix-comma form alone, and that limitation is kept as parity, while a built-in core, which no release read as a delimiter, is kept a word there so that every delimiter follows one rule: the core in 'Smith, RN - CRNA' is a word. Since #603 it is a word of the run the credential opens, so the part reads suffix "RN - CRNA" where 2.0 through 2.3, like v1, read given "RN". ``` "Smith, RN - CRNA" → suffix="RN - CRNA" ``` - A delimiter standing alone behind a whole name renders as a comma, being dropped, while a glued one stays in the word it is glued to: the suffix view writes 'MD, PhD' for "John Smith, MD ; PhD" and 'MD; PhD' for "John Smith, MD; PhD". Behind one name word, or none, a semicolon standing alone is a word of the run the credential opens, as a declared core is there, one rule for every delimiter. ``` "John Smith, MD ; PhD" → suffix="MD, PhD" "Smith, MD ; PhD" → suffix="MD ; PhD" ``` - A built-in hyphen or slash glued to the end of a credential, or of a word read by its capitals, leaves the word read as written, so the name reads as every release from 1.4.0 read it, where its space twin parts the suffix: the hyphen is the particle's own in "al-", and a rule that read a glued hyphen as a delimiter would read that particle as none. ``` "John Smith, MD- PhD" → given="MD-" "John Smith, MD/ PhD" → given="MD/" "John Smith, XYZ- QRST" → given="XYZ-" ``` - A glued word read by its letters rather than its vocabulary is not read as the word without it: a credential written as two period-marked pieces with the delimiter glued to the second, and a one-letter numeral ending the given part, keep the readings 2.0 through 2.5 gave them where their space twins read a suffix. ``` "John Smith, Ph. D.; MD" → given="D.;" "Smith, John V;" → middle="V;" ``` - A connective beside a title joins the title run across a delimiter standing alone, built in or declared, as it joins a title run anywhere: the part reads as one title where its comma twin reads a title and a suffix, so a connective is the one joining word that reaches across the delimiter. "Prof. - of Surgery" reads well as one title; "Dr. - and" does not, and no corpus name holds the shape. ``` "John Smith, Prof. - of Surgery" → title="Prof. of Surgery" "John Smith, Dr. / and MD" → title="Dr. and MD" ``` --- **Signal:** separator: the semicolon, a hyphen or slash standing alone, or a delimiter the caller declared, after the first comma **history:** decisions/C10 · **implemented:** nameparser/_pipeline/_classify.py, nameparser/_pipeline/_comma.py, nameparser/_pipeline/_group.py, nameparser/_pipeline/_segment.py, nameparser/_pipeline/_vocab.py derek73-python-nameparser-456b024/docs/design/rules/C11-what-the-comma-decision-reports.md000066400000000000000000000062431527303271700312150ustar00rootroot00000000000000# C11 — what the comma decision reports **When:** the comma's decision (C1) reads the part after the comma, either way, as in "John Smith, PhD Jones". **Then:** a decision either way at the comma C1 reads is reported; for a run of words the decision is the flip to the credential run, reported once over the whole part. - **Paired initials the comma keeps as the given name (C5) report where they open the part**; behind a title they do not, the comma's report reaching only the first word after it ('Smith, Ms G.J.'). ``` "John Smith, A.B." → ambiguities=("suffix-or-name",) ``` - **A run the count leaves in the listing form reports as S7 reads the words in it**: a word of the class read as the credential because a credential in front speaks for it reports, and one its own capitals made the credential does not, wherever it stands in the part (S6). So a run whose every such word is written in capitals reads whole in silence ('Doe, MA PhD', 'John Smith, MA MA', 'John Smith, PhD MA'), as does a part read as titles before a lone given name ('Smith, Ms RA Ma'). ``` "Doe, MA PhD" → ambiguities=() "Doe, Ma PhD" → ambiguities=("suffix-or-name",) "John Smith, PhD DO DO" → ambiguities=() · boundary ``` - **It is one of TWO places the comma's own decision is reported**, the other being the word trailing the given part after it (S5), which is a second decision about a second word and never the same fork twice; an attachment decided after a family comma (P6) reports on its own. - **C2's comma-structure flag reports what the parse could not recognize**, not a fork it called. **Because:** the comma's reading is a call the parse made, and a reader who could have taken the words the other way is told that it was made (A1). ``` "John Smith, PhD Jones" → ambiguities=("suffix-or-name",) "JOHN SMITH, VD MA" → ambiguities=("suffix-or-name",) "John Smith, XYZ" → ambiguities=("suffix-or-name",) ``` **Except:** - **A decision that the capitals of every listed word of the class in the part settled is not reported**, since the writing left the reader nothing to weigh (S6); in a name written in one case the same part reports. ``` "John Smith, MA" → ambiguities=() "JOHN SMITH, MA" → ambiguities=("suffix-or-name",) ``` - **A flip in which no listed word of the ambiguous credential class (C4) takes part is the exception and is made in silence**, since only paired initials among such words are ever taken for a name, and the flip reaches them only where something else in the part has said otherwise — unless what said so is nothing but other paired initials: each of those is a word a reader takes for a name, and that flip is reported. ``` "John Smith, Jr vd" → ambiguities=() "John Smith, Jr do" → ambiguities=("suffix-or-name",) "John Smith, X.Y.Z." → ambiguities=() "De La Cruz, M.J. K.L." → ambiguities=("suffix-or-name", "suffix-or-name") ``` --- **Signal:** none of its own: it reports the calls C1 makes and says which words carry the report **history:** decisions/C11 · **implemented:** nameparser/_pipeline/_comma.py C12-suffix-word-ending-the-name-before-the-comma.md000066400000000000000000000101151527303271700333510ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/rules# C12 — a suffix word ending the name before the comma **When:** the part before the first comma ends in a suffix word — a generation such as Jr. or II, Esq., or an honorific such as Rinpoche, not an acronym — or in a roman numeral the trailing fork reads as the generation (S10), as in "John Smith II, AD". **Then:** where the words in front of it are a whole name and the name carries the case contrast C6 asks for, the part after the comma is the credential run and the comma a suffix comma. - **A whole name is counted by C3's count**, in which a title in front is a word, but for a one-letter numeral, which S10 counts without it. ``` "Dr. Smith Jr., AD" → suffix="Jr., AD" "Smith Jr., John" → given="John" · boundary "Jane nee Smith Jr., AD" → given="AD" · boundary "Smith VI, AD" → given="AD" · boundary "Smith V, AD" → given="AD" · boundary "Dr. Smith V, AD" → given="AD" · boundary "VI, AD" → given="AD" · boundary "John J VI, AD" → given="AD" · boundary ``` - **In that part a title reads as a title**, whether one word or a run of title words a connective joins (P3, C7), a dual inside such a run included, and any other word, a title and suffix dual among them, as a suffix. ``` "John Smith Jr., AD RA" → suffix="Jr., AD RA" "John Smith Jr., Sir AD" → title="Sir" "John Smith Jr., Sir AD" → suffix="Jr., AD" "John Smith Jr., Mr. and Mrs. AD" → title="Mr. and Mrs." "John Smith Jr., Mr. and Mrs. AD" → suffix="Jr., AD" "John Smith Jr., AD Sr. and Mr." → title="Sr. and Mr." ``` **Because:** a generation belongs to the name it ends, so a name written in natural order carries it before the comma and its credentials after ('John J Ingram III, MD'), as it does Esq. or a post-nominal honorific. A listing whose surname takes a generation puts it there too, but a given name follows it, and a given name is written as a name. ``` "John Smith II, AD" → given="John" "John Smith II, AD" → suffix="II, AD" "John Smith Jr., þingmaður" → suffix="Jr., þingmaður" "John Smith Jr., MBChB PhD" → suffix="Jr., MBChB PhD" "John Smith VI, AD" → suffix="VI, AD" "John Smith V, AD" → suffix="V, AD" "John Smith II, AD" → capitalized_forced="John Smith II, AD" "John Smith II, AD" → ambiguities=("suffix-or-name",) "John Smith, AD" → given="AD" · boundary ``` **Except:** - **A generation word that is also a name** (S11) is no evidence here, since it may be the surname the family comma names: the part before the comma ends in a name word. ``` "Silva Neto, Pedro" → family="Silva Neto" "Pedro Alves Neto, AD" → given="AD" · boundary ``` - **A word of it written as a name, or an initial, keeps the listing form.** ``` "Lloret Palol II, Josep Antoni" → given="Josep" · boundary "Garcia Lopez Jr., Juan" → given="Juan" · boundary "John Smith Jr., J." → given="J." · boundary "Smith Jones Jr., J.A." → given="J.A." · boundary ``` **Precedence:** - **Paired initials after a generation** ("John Smith Jr., M.J."): written dotted, they keep C5's reading and are the given name, the generation staying the suffix; undotted as two capitals, they are this rule's credential (C1, where the roman numerals S10 reads stay the suffix as the listed ones do). **Accepted:** - Initials written without periods after the generation read as the credential they look like, a word of two capitals being both; written with them they are initials, as C5 reads them. ``` "Lloret Palol II, JA" → suffix="II, JA" ``` - A name written in one case, or in a script without case, has no contrast to read and keeps the listing form, as C6's unlisted all-caps word does. ``` "JOHN SMITH JR, AD" → given="AD" · boundary ``` --- **Signal:** neighbor: a generation or honorific word ending the part before the comma **history:** decisions/C12 · **implemented:** nameparser/_pipeline/_comma.py C13-credential-opening-the-part-after-the-comma.md000066400000000000000000000040021527303271700332650ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/rules# C13 — a credential opening the part after the comma **When:** the first suffix word of the part after a family comma, with only titles in front of it, is one that starts S8's run, which S8 bounds (the split 'Ph. D.' among them), as in "John Smith, PhD Jones". **Then:** a credential opening the part after a family comma makes it the postnominal part however it goes on, every word after it reading as that run reads, a title word as a title and any other word as a suffix. - **The comma has then fixed nothing**, as with a part of no name word (C8), and a part before it holding one name word is the family ('John Smith, PhD Jones', 'Doe, PhD Jones'). ``` "John Smith, PhD Jones" → given="John" "John Smith, PhD Jones" → suffix="PhD Jones" "Doe, PhD Jones" → suffix="PhD Jones" "John Smith, Ph. D. Jones" → suffix="Ph. D. Jones" "Steven Hardman, RN - CRNA" → given="Steven" ``` **Because:** a credential is written after the name it belongs to, so one standing first in the part after a family comma says the part is that name's postnominals and not its given name; a word that is also a title says nothing of the kind, being the listing form's title as often as a credential. **Except:** - **A word of both the title and the suffix vocabulary opens nothing, and nor does a credential behind one**: in front of a given name it is the listing form's title ('Smith, Ms Jane'), and no count of the words before the comma tells a surname of two words from a given name and a family name. ``` "John Smith, RA Jones" → given="Jones" · boundary "Smith, Ms Jane" → title="Ms" · boundary ``` **Precedence:** - **Under the strict comma-suffix switch** ("Smith, PSM I."): the opened run reads as it does without the switch, taking in the word the switch vetoes; C14 states that veto and this exception to it. --- **Signal:** neighbor: a credential opening the part after a family comma **history:** decisions/C13 · **implemented:** nameparser/_pipeline/_comma.py C14-strict-mode-a-suffix-word-written-as-an-initial.md000066400000000000000000000034371527303271700341020ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/rules# C14 — strict mode: a suffix word written as an initial **When:** a caller turns the strict comma-suffix switch on, and a suffix word written as an initial reaches the comma's decision (C1) in the part after the comma, as in "John Smith, V.". **Then:** strict mode vetoes a word read as an initial, so the word is no suffix word to the comma's decision and a part holding it reads as the listing form's given part (C1). - **In a name written in one case a bare lower-case letter is vetoed as well**, as its capital is (T4). ``` "john smith, v" strict-comma-suffixes → family="john smith" ``` - **A part after the comma that is nothing but suffix words stops being a credential run at such a word** (C9); longer suffix words are not in question. - **The given part's walk after a family comma does not consult the switch**: a one-character suffix word ending the given part reads there as C9 reads it under the default. **Because:** one letter, with a period or without, is how an initial is written, and it is also how a one-letter roman numeral is written. By default a recognized suffix word counts at the comma whatever its shape (C1). A caller whose data writes initials after the comma turns the switch on, and the shape decides. ``` "John Smith, V." strict-comma-suffixes → family="John Smith" "Smith, John V" strict-comma-suffixes → suffix="V" · boundary ``` **Except:** - **A credential opening the part** (C13): the veto refuses the word the suffix reading, not the run, so the run the credential opens takes it in as it takes every word after it. ``` "Smith, PSM I." strict-comma-suffixes → suffix="PSM I." · boundary ``` --- **Signal:** none in the name: the strict comma-suffix switch **history:** decisions/C14 · **implemented:** nameparser/_pipeline/_pieces.py derek73-python-nameparser-456b024/docs/design/rules/C15-comma-with-nothing-before-it.md000066400000000000000000000060461527303271700305030ustar00rootroot00000000000000# C15 — a comma with nothing before it **When:** a name opens with a comma that has nothing written before it, as in ", John Smith". **Then:** the comma is a family comma (C1) naming no surname, and the part after it reads as the given part after a family comma reads, with no surname supplied for it. **Because:** a comma with nothing before it is how a listing writes a record whose surname field is empty, as CSV data writes one. The writer marked where the surname goes and left it empty, so the parser supplies none, and a floor that keeps a family word does not apply, the input naming no family to lose. Read this way the shape has no reading of its own: the part reads as it reads behind any surname. ``` ", John Smith" → given="John" ", John Smith" → middle="Smith" ", John Smith" → family="" ", John Smith Jr." → suffix="Jr." ", John (Bob) Smith" → nickname="Bob" ", PhD" → suffix="PhD" ", Jr." → suffix="Jr." ", Sir John" → given="John" ``` **Except:** - **A title and one name word** (H1): the comma names no surname, so it does not decide the field of that word, or of a bound pair standing for it, and the title's lone name word reads as the family. ``` ", Dr. John" → family="John" · boundary ", Jane Mrs" → family="Jane" ", Dr. abdul Rahman" → family="abdul Rahman" ``` **Precedence:** - **A particle ending the name** (P6) attaches to the family after this comma as after any family comma, and with no surname written the family is that particle alone: `, Jane van` reads family `van`. P6 wins because the particle is in the part after the comma, which this rule reads as any given part is read, and it wins over H1's exception too, leaving H1 a given word and a family rather than one name word. ``` ", Jane van" → family="van" ", Sir Jones vd" → family="vd" ", Dr. Jones vd" → given="Jones" ", Dr. Jones vd" → family="vd" ``` **Accepted:** - A writer with a stray comma in front of a whole name gets the listing reading rather than the name's: `, John Smith` reads given `John`, middle `Smith`, where `John Smith` reads family `Smith`. The shape's one source is an empty surname field, and dropping the comma would contradict it. ``` ", John Smith" → family="" "John Smith" → family="Smith" ``` - The surname's case is evidence the empty head does not have. A word of the ambiguous credential class written alone in capitals reads as a name, the name being written wholly in one case (S6), where a surname written in mixed case before the comma makes the same word the credential. ``` ", MA" → given="MA" "SMITH, MA" → given="MA" "Smith, MA" → suffix="MA" ``` --- **Signal:** comma: nothing written before the first comma **history:** decisions/C15 · **implemented:** nameparser/_pipeline/_comma.py derek73-python-nameparser-456b024/docs/design/rules/C2-parts-past-the-second-comma.md000066400000000000000000000117631527303271700302550ustar00rootroot00000000000000# C2 — parts past the second comma **When:** a name holds a part past the second comma, as in "John Smith, MD, Bart". **Then:** parts beyond the second are consumed as suffixes either way, except that a word of the title vocabulary there that is not also suffix vocabulary is a title — a word that may also be a given name (rules/H9's `titles_ambiguous`) is not title vocabulary here outside a title phrase, so bare it stays a suffix, while a phrase is a title. - **A part past the second comma holds no name word**, so the surnames H5 keeps a bare trailing title word from misreading are not there to protect, and a piece holding a suffix word, a title/suffix dual among them, stays the postnominal the slot makes it (#603). ``` "John Smith, MD, Ms" → suffix="MD, Ms" · boundary ``` - **A non-empty extra part that is not entirely suffix words is flagged as a structural ambiguity rather than rejected** — parsing never fails on content — unless its words are title words and suffix words, one title word at least, a word whose period-joined chunks read as a title ('Lt.Gov.') counting as one. That is asked word by word, so a part a connective joins into one title ('Secretary of State') keeps the flag. ``` "Eric H. Holder, Jr., Attorney General" → title="Attorney General" "Eric H. Holder, Jr., Attorney General" → ambiguities=() "Eric H. Holder, Jr., Secretary of State" → ambiguities=("comma-structure",) · boundary "John Smith, Jr., Lt.Gov." → ambiguities=() ``` - **An empty part between doubled commas is consumed silently.** ``` "John Smith, MD,, Jr." → suffix="MD, Jr." · boundary ``` - **A part the parse reads as a credential run by some route other than the suffix vocabulary is recognized and is not flagged**: a run of ambiguous acronyms whose written case leans credential (S6), and a run every word of which is an unlisted DOTTED word the position reads as a credential (S3). This is the one place the ambiguous class QUIETS a report rather than adding one, and it is narrow in the two ways the Except bullets pin. ``` "John Smith, MD, R.A.I." → suffix="MD, R.A.I." "John Smith, MD, R.A.I." → ambiguities=() "John Smith, MD, R.A.I." unlisted_dotted_suffixes-off → ambiguities=("comma-structure",) "Steven Hardman, MD, DO, DDS" → ambiguities=() ``` - **A part the parse consumes wholly as suffixes raises no report that a word of it was read as a name**, whether it is the part after a suffix comma (C1) or a part beyond the second: nothing in it is read as one, a maiden marker there being an ordinary word (M2), and no particle chain (P2) is made in it, so a title word behind a particle run there is a title like any other, and the part reports neither a particle chained onto a name word nor an acronym taken into the name. ``` "John Smith, Jr., van der Berg Prof." → title="Prof." "John Smith, Jr., van der Berg Prof." → suffix="Jr., van der Berg" ``` - **What such a part reports is its own** — C11's report of the decision at its comma (the flip, or the first word after the comma), S8's and S7's reports on each word a credential in front of it reads as a suffix although it may be a name word (a name word the run absorbs, a member the company decides), and this rule's flag. **Because:** text beyond the recognized comma parts should be taken in without silent guessing. ``` "John Smith, MD, Bart" → suffix="MD, Bart" "Smith, John, Prof." → title="Prof." ``` **Except:** - **A member whose written case does not lean CREDENTIAL keeps the flag**, whether the name is written in one case so that nothing leans at all, or the member is written the way a name is written. ``` "John Smith, MD, Ma" → ambiguities=("comma-structure",) · boundary "STEVEN HARDMAN, MD, DO, DDS" → ambiguities=("comma-structure",) · boundary ``` - **S9's other by-shape half, the unlisted all-caps word, does not reach here in any setting**: the shape a tail segment is recognized by is the dotted one alone. ``` "John Smith, MD, XYZ" unlisted_caps_suffixes-everywhere → ambiguities=("comma-structure",) ``` **Accepted:** - The no-name-reading bullet's REPORT half carries no example line of its own. What it moves is a report with no field beside it, and an example line would promise that report alone; its title half is witnessed by the two `John Smith, Jr., van der Berg Prof.` lines under it, and its report half's executable witnesses are the case rows a_credential_run_after_the_comma_reports_no_chain_fork and a_part_past_the_second_reports_no_particle_fork in tests/v2/cases.py, read beside the two rows the chain still reports on outside a tail: the_chain_reports_the_acronym_it_takes and titled_particle_chain_survives_a_title_that_is_also_a_particle. --- **Signal:** position: a part past the second comma **history:** decisions/C7, decisions/C8, decisions/C11, decisions/S6, decisions/S9 · **implemented:** nameparser/_pipeline/_segment.py, nameparser/_pipeline/_assign.py, nameparser/_pipeline/_group.py derek73-python-nameparser-456b024/docs/design/rules/C3-counting-the-words-before-the-comma.md000066400000000000000000000062331527303271700317030ustar00rootroot00000000000000# C3 — counting the words before the comma **When:** the comma's decision (C1) counts the words before the comma, as in "De La Cruz, Ed". **Then:** a particle run and the one name word it attaches to are one word, the reach P1's fold counts rather than the whole of P2's chain, so 'De La Cruz, Ed' reads as 'Royce, Ed' does and 'van der Berg, PhD' as 'Berg, PhD'. - **Where that surname is the whole part, the comma settles P1's fork** for a leading particle that could be a given name, being the evidence that the part is a surname. ``` "Van Buren, Ed" → family="Van Buren" ``` - **A word after the one the particle attaches to is a second word**, and a suffix word ends the particle's reach. ``` "John van Buren, Ed" → suffix="Ed" · boundary ``` - **A word that is also title vocabulary is not a particle to this count where it opens the part**, reading there as the title it also is (H1); inside a surname it chains ('de St Pierre, Ed' reads given 'Ed'). - **A connective join and a bound given-name pair are not one word here**: whether P3 joins a connective depends on the words of the whole name, which this count is part of deciding, and a bound given name builds a given name rather than a surname (P5) — a word that is a particle as well counts as the particle ('Abu Bakar, Ed' reads given 'Ed'). ``` "Ortega y Gasset, Dr." → given="Ortega" "Ortega y Gasset, Ed" → suffix="Ed" · boundary "abdul Salam, Ed" → suffix="Ed" · boundary ``` - **A title in front is a word to the count an unambiguous credential takes**, so 'Dr. van der Berg, PhD' reads as its part does alone. **Because:** the listing form puts a surname before the comma, and a particle surname is one surname. ``` "De La Cruz, M.J. K.L." → given="M.J." "De La Cruz, Ed" → given="Ed" "van der Berg, MA" → family="van der Berg" "van der Berg, MA" → suffix="MA" "van der Berg, PhD" → family="van der Berg" ``` **Accepted:** - A title in front of a particle surname, before the credential class's word, reads the listing form. The surname counts once and the title is no name word, so the count reads the listing form, and the listing form peels a title before the comma (H6) unless the word is a particle too, as 'Freiherr' is, which stays in the family. For 'Freiherr' that reading is the right one: since 1919 a former noble title is part of the legal surname, written between the given name and the particle, so 'Freiherr von Berg' is the family name in 'Freiherr von Berg, Ed', as 2.0 through 2.3 read it. A rank that is no particle peels as a title like any other ('Graf Lambsdorff, Otto' reads title 'Graf'), though the same law makes it part of the surname too; #709 asks whether it should stay. Before an unambiguous credential the same words keep the title apart ('Freiherr von Berg, PhD'), that count taking the title as a word; both are readings of one name. ``` "Freiherr von Berg, Ed" → family="Freiherr von Berg" ``` --- **Signal:** count: the words before the comma, a particle surname counting as one **history:** decisions/C3 · **implemented:** nameparser/_pipeline/_comma.py derek73-python-nameparser-456b024/docs/design/rules/C4-ambiguous-credential-class-at-the-comma.md000066400000000000000000000102501527303271700325060ustar00rootroot00000000000000# C4 — the ambiguous credential class at the comma **When:** a word of the ambiguous credential class — a bare acronym the vocabulary marks as also an ordinary name, a word admitted to the class by shape, which S3 defines and bounds, and a credential split across a space, which S12 defines — stands in the part after the comma the decision (C1) reads, as in "John Smith, Ma". **Then:** the count before the comma is of NAME words rather than of words, and two or more of them read the part after the comma as the credential run, whatever case the name is written in. - **A generation word that is also a name (S11) is a word of this class here**, its count read the same way. ``` "Pedro Alves, Neto" → suffix="Neto" "Silva, Neto" → given="Neto" · boundary ``` - **The count goes FIRST and written case is asked only after it**: where two name words stand before the comma the part after it is the credential run however that part is written, and only where the count leaves the word a name — one name word before the comma — is the case read, capitals in a mixed-case name making it the credential there too (S6). ``` "John Smith, MA" → suffix="MA" "John Smith, Ma" → suffix="Ma" "Smith, MA" → suffix="MA" "Smith, Ma" → given="Ma" "John Smith, Ed" → suffix="Ed" "Davis Royce, Ed" → suffix="Ed" "Royce, Ed" → given="Ed" · boundary "Smith Jr., MA" → suffix="Jr., MA" "Smith Jr., Ma" → given="Ma" · boundary ``` - **The same count reads a part of two or more words as the credential run when every word of it is a suffix word or a word of this class**, at least one of them of this class, and none of them a single-letter roman numeral, in any case: one letter is the shape a middle initial is written in, and S3 retires single-character matches for the same reason, while a multi-letter numeral or generational word stands in a run like any suffix word ('John Smith, III Ma'). ``` "John Smith, PhD MEng" → suffix="PhD MEng" "John Smith, Ed Ma" → suffix="Ed Ma" "Jane Doe, MS LAc" → suffix="MS LAc" "Smith, PhD MEng" → family="Smith" · boundary "John Smith, PhD DO DO" → suffix="PhD DO DO" ``` - **A word's particle reading does not take it out of the run**: a word of both the particle and the unambiguous suffix vocabulary is a suffix word there too ('John Smith, vd Ma'). Run membership asks what the word is, and S7's company asks whether it speaks for the word behind it, which a particle never does; the run S7 says such a word ends is the company's, not this count's. ``` "John Smith, PhD vd DO" → suffix="PhD vd DO" "John Smith, vd Ma" → suffix="vd Ma" "JOHN SMITH, VD MA" → suffix="VD MA" ``` - **A word of both the title and the suffix vocabulary opening a part the count reads as a run counts as a suffix word there**, the name before the comma being complete, except as the titles of paired initials (C5). - **Where every word of this class in the part is a LISTED word written in capitals in a mixed-case name**, the writing has already made each of them the credential (S6), and the part reads as the credential run on that evidence rather than on the count; a word of the class by shape alone carries no such lean, so a part holding one is read by the count. **Because:** two name words before the comma are read as a whole name, so for a word that is both a credential and a name the count decides before the writing does. ``` "John Smith, X.Y.Z." → suffix="X.Y.Z." "John Smith, X.Y.Z." unlisted_dotted_suffixes-off → given="X.Y.Z." ``` **Precedence:** - **Paired initials** ("García Márquez, G.J."): C5 wins; paired initials are the exception to the count. - **An unlisted all-caps word** ("MÜLLER WEIß, HANS"): C6 decides whether it is a word of this class here at all, which it is only where the name carries a case contrast. --- **Signal:** count: name words before the comma, for a word of the ambiguous class after it **history:** decisions/C4 · **implemented:** nameparser/_pipeline/_comma.py derek73-python-nameparser-456b024/docs/design/rules/C5-paired-initials-after-the-comma.md000066400000000000000000000063531527303271700310650ustar00rootroot00000000000000# C5 — paired initials after the comma **When:** paired initials — two single letters joined by a period, with or without a period after the second ('M.J.', 'M.J') — stand in the part after the comma, as in "García Márquez, G.J.". **Then:** paired initials are the exception to the ambiguous credential class's count (C4): the one dotted shape a person's own initials are written in, they are read as the given name after the comma. - **Undotted, where the name carries the case contrast C6 asks for**, an unlisted word of two capitals after the comma is the paired initials' shape and is decided at this comma exactly as they are ('García Márquez, MJ' and 'García Márquez, MJ PhD' keep given 'MJ'). ``` "García Márquez, MJ" → given="MJ" · boundary "García Márquez, MJ PhD" → given="MJ" · boundary ``` **Because:** two words before the comma may be one surname written in two ('García Márquez'), and the count cannot tell them from a given name and a family name. ``` "Smith, A.B." → given="A.B." · boundary "García Márquez, G.J." → given="G.J." "García Márquez, G.J." → family="García Márquez" "John Smith, A.B." → given="A.B." "García Márquez, G.J" → given="G.J" ``` **Except:** - **Only an unambiguous suffix word in front of them that is not also title vocabulary, or another word the class admits by its dotted shape standing in the same part, makes them the credential run.** A word of this class in front of them speaks for nothing, as S7's company has it, and where nothing but words of both the suffix and the title vocabulary stands in front of them, those are the titles of the given part the initials open, as S7 reads them there. ``` "John Smith, PhD X.Y." → suffix="PhD X.Y." "De La Cruz, M.J. PhD" → given="M.J." · boundary "García Márquez, Ms G.J." → given="G.J." · boundary "García Márquez, Ed G.J." → given="Ed" · boundary "John Smith, A.B. Ph.D." → given="A.B." · boundary ``` - **Behind two or more name words, paired initials that are not the only shape word in the part are a credential** however little else speaks for them, since no one writes a person's initials as two dotted groups. ``` "García Márquez, MJ JK" → suffix="MJ JK" "John Smith, X.Y. P.Q." → suffix="X.Y. P.Q." ``` **Precedence:** - **A generation ending the name before the comma** ("John Smith Jr., M.J."): dotted, the paired initials keep this rule's reading and are the given name, the generation staying the suffix; undotted as two capitals, they are the credential C12 reads (C1, where a roman numeral S10 reads stays the suffix as a listed generation does). **Accepted:** - Three or more initials run together with periods behind a surname of two words read as a credential. Initials are conventionally written apart ('J. R. R.'), and a token of three or more single letters run together is far likelier a credential than a given name and a middle name written as one word. ``` "García Márquez, G.J.R." → suffix="G.J.R." ``` --- **Signal:** shape: paired initials after the comma, dotted or as two undotted capitals **history:** decisions/C5 · **implemented:** nameparser/_pipeline/_comma.py C6-unlisted-all-caps-word-needs-the-names-case-contrast.md000066400000000000000000000037241527303271700347720ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/rules# C6 — an unlisted all-caps word needs the name's case contrast **When:** an unlisted word written in capitals stands in the part after the comma, as in "John Smith, XYZ". **Then:** an unlisted all-caps word joins the ambiguous credential class (C4, S9) in the part after the comma only where the name carries the contrast: one of its own words before the comma written the way a name is written in mixed case. - **A word written the way a name is** holds a capital with its last cased letter lowercase — read after composing the word, passing over every non-letter and caseless letter and any lowercase letter whose capital is not a single letter, as ß's is not — with no period and not claimed by the vocabulary as a title, particle, connective, credential or generation. ``` "MÜLLER WEIß, HANS" → given="HANS" · boundary ``` - **A surname written in capitals ends in a capital whatever is glued in front of it, and a word written wholly in lowercase holds none**, so such a record keeps its given name beside its lowercase particles, titles and clauses ("GISCARD d'ESTAING, VALÉRY", 'LLOYD FitzGERALD, RONALD'), and a name written wholly in lowercase reads the listing form. **Because:** capitals are a credential's writing only against a name written otherwise; a name written in capitals, or wholly in lowercase, says nothing about any word in it. ``` "John Smith, XYZ" → suffix="XYZ" "John Smith, XYZ" unlisted_caps_suffixes-off → given="XYZ" "John Smith, LEED AP" → suffix="LEED AP" "Smith, XYZ" → given="XYZ" · boundary "John Smith, PhD XYZ" → suffix="PhD XYZ" "John Smith, PU-PH" → suffix="PU-PH" "JOHN SMITH, PU-PH" → given="PU-PH" · boundary "Smith, PU-PH" → given="PU-PH" · boundary ``` --- **Signal:** case contrast: a word before the comma written in mixed case the way a name is **history:** decisions/C6 · **implemented:** nameparser/_pipeline/_comma.py derek73-python-nameparser-456b024/docs/design/rules/C7-postnominal-part-is-read-before-any-join.md000066400000000000000000000023561527303271700326540ustar00rootroot00000000000000# C7 — a postnominal part is read before any join **When:** the comma's decision (C1) reads a part as postnominal — the credential run, titles beside suffixes, or titles alone — as in "John Smith, Mr. and Mrs.". **Then:** such a part is read before any join, as its words stand, and that reading is final. - **The one join made inside it is a connective binding title words into one title** (P3: 'Mr. and Mrs.', a connective run included: 'Minister of the Interior'). ``` "John Smith, Mr. and Mrs." → given="John" "John Smith, Mr. and Mrs." → title="Mr. and Mrs." "Smith, Chancellor of the Exchequer MP" → suffix="MP" ``` - **No particle run, connective join or bound given name reaches into it.** - **The listing form's given part keeps every join** (P2, P3, P5, P6). **Because:** the comma's decision reads the part after it once, and a join made afterwards could only read the same words differently from the decision. --- no-boundary: the listing form's given part, which keeps every join, is what P5's own example parses ('Salam, abd Allah'). **Signal:** a sequence, not a signal of its own: a part C1 reads as postnominal is read before any join **history:** decisions/C7 · **implemented:** nameparser/_pipeline/_comma.py derek73-python-nameparser-456b024/docs/design/rules/C8-listing-forms-part-after-the-comma.md000066400000000000000000000041321527303271700315440ustar00rootroot00000000000000# C8 — the listing form's part after the comma **When:** the comma's decision (C1) reads the listing form, as in "Smith, PhD". **Then:** in the listing form the part after the comma is still read for what it is, and a name word in it makes it the given name, with titles before it and suffixes after. - **A part that is nothing but suffix words is the credential run and reads as suffixes, whole** — the slot after a family comma is postnominal position, so the vocabulary's verdict comes before any title reading of the same word. ``` "Smith, PhD" → suffix="PhD" "Smith, Jr." → suffix="Jr." "Smith, Sr." → suffix="Sr." "Smith, Ph. D. Jr." → suffix="Ph. D. Jr." "Smith, MD PhD" → suffix="MD PhD" ``` - **A part that holds no name word at all, titles and suffixes only, fixes no family boundary**, so a part before the comma with more than one name word keeps its positional read, order and all. ``` "Smith, Dr." → title="Dr." "Smith, Dr. Jr." → suffix="Jr." "John Smith, Mr." → given="John" "John Smith, Mr." → family="Smith" "John Smith, Mr. Jr." → given="John" "Smith Jr., Mr." → family="Smith" · boundary ``` **Because:** a comma fixes a family boundary only where a name word stands after it to be the given name; a part of titles and suffixes alone names nobody. **Precedence:** - **A credential opening the part after the comma** ("Doe, PhD Jones"): C13 governs, and the part is postnominal however it goes on, not the given name. **Accepted:** - A word of both the title and the unambiguous suffix vocabulary reads as the postnominal after a family comma in every spelling, the honorific's too — position decides for the duals, and the slot is postnominal. ``` "Smith, Ms." → suffix="Ms." "Smith, Ms. Jane" → title="Ms." ``` --- **Signal:** neighbor: whether the part after a family comma holds a name word **history:** decisions/C8 · **implemented:** nameparser/_pipeline/_comma.py C9-one-character-suffix-word-inside-a-comma-part.md000066400000000000000000000053401527303271700334730ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/rules# C9 — a one-character suffix word inside a comma part **When:** a one-character suffix word — the only kind a reader could take for an initial — stands inside a comma part, as in "Smith, John V". **Then:** it is read by what stands before it: behind another suffix it is describing that suffix and continues the run, written with a period or without, an initial being no shape anyone writes there, and behind a name word it is the generation only when written bare, a period marking it the abbreviation of a name and so a middle initial (T5). - **Both branches then ask that the generation slot still be open**: where a further comma has already named the suffix (C2), a single letter ending the GIVEN part has no generation left to be, and stays a middle initial — with a period or without, and whether a name word or another suffix word stands before it. So a lone letter ending the given part ahead of a further comma is the middle initial, whatever stands before it, a credential run in front of it stopping short of it, where the letter is written as an initial (T5). ``` "Smith, John V, Jr." → middle="V" "Smith, John PhD I, Jr." → middle="I" "Smith, John PhD I., Jr." → middle="I." "Smith, John PhD I, Jr." → suffix="PhD, Jr." "Smith, John PhD É, Jr." → suffix="PhD É, Jr." · boundary ``` - **Only the given part is touched**: a part after the comma that is nothing but suffix words is the credential run, and a letter in it continues that run up to the further comma. - **Longer suffix words are not in question either way**, a numeral of two or more letters being read as S10 reads one in this part. **Because:** one letter is the shape a middle initial is written in, so only what stands before it says whether it is a generation. ``` "Smith, PSM I" → suffix="PSM I" "Smith, PSM I." → suffix="PSM I." "Smith, John V." → middle="V." "Smith, John PhD I." → suffix="PhD I." "Smith, John V" → suffix="V" · boundary ``` **Precedence:** - **Under the strict comma-suffix switch** ("Smith, RA I"): C14 governs a part after the comma that is nothing but suffix words; the given part's walk does not consult the switch. **Accepted:** - A letter that is a level of the credential in front of it, written ahead of a further comma, is read as the middle initial, the further comma having named the suffix: the certification level is split from its credential. No corpus holds the shape (#819, measured 2026-10-25). ``` "Smith, John PSM I, Jr." → middle="I" ``` --- **Signal:** neighbor: what stands before a one-character suffix word inside a comma part **history:** decisions/C9 · **implemented:** nameparser/_pipeline/_assign.py derek73-python-nameparser-456b024/docs/design/rules/D-construction-and-configuration-diagnostics.md000066400000000000000000000004111527303271700334050ustar00rootroot00000000000000## Construction & configuration diagnostics (D) Background: configuration mistakes are reported when they are made — at construction — not when a name happens to hit them; and a diagnostic that hands the reader code must hand code that works and type-checks. derek73-python-nameparser-456b024/docs/design/rules/D1-segmenterless-activation-warning.md000066400000000000000000000017441527303271700315160ustar00rootroot00000000000000# D1 — the segmenterless-activation warning **When:** a parser is built that activates division for a script no surname vocabulary it holds covers and no segmenter divides. **Then:** constructing a parser that activates division for scripts with no covering surnames and no segmenter warns at construction, naming the dead scripts and each way out. **Because:** a parser whose activated scripts nothing can divide behaves like a working parser minus a feature, silently — the one misconfiguration a caller cannot see in output. ``` [segmenterless-ja] → warns="deactivate with Policy(segment_scripts=frozenset())" ``` --- no-boundary: any covering surname vocabulary, configured segmenter, or deactivation silences it — the default parser and the zh pack never warn, which every other rule's examples exercise. **Signal:** none in the name: the configuration's activated scripts against its surnames and segmenter **history:** decisions/D1 · **implemented:** nameparser/_parser.py derek73-python-nameparser-456b024/docs/design/rules/D2-construction-raises-parse-never-does.md000066400000000000000000000016111527303271700322210ustar00rootroot00000000000000# D2 — construction raises, parse never does **When:** a configuration is built from a value it cannot accept. **Then:** configuration validation raises at construction with the offending field and value named. - **A locale pack names itself:** applying a locale pack wraps any such error with the locale's code, so a stacked configuration names which layer broke. **Because:** whatever a name contains, parsing answers. Only a broken configuration may raise, and it must name the field. ``` [bad-name-order] → raises="name_order elements must be Role members" [bad-order-none] → raises="name_order must be an iterable" ``` --- no-boundary: the non-raising side is every other rule's examples; parse() itself is total (A1). **Signal:** none in the name: the configuration's values, at construction **history:** decisions/D2 · **implemented:** nameparser/_policy.py, nameparser/_parser.py derek73-python-nameparser-456b024/docs/design/rules/H-titles-and-honorifics.md000066400000000000000000000043031527303271700271560ustar00rootroot00000000000000## Titles & honorifics (H) Background: an honorific title precedes a name and is not itself part of it; it addresses or ranks the person. Most titles address by surname ("Mr. Johnson"), but a few — knighthoods, some clerical and courtesy titles — address by given name ("Sir John"). The library keeps a vocabulary of titles and, separately, of these given-name titles. A period-marked word is claimed by SHAPE at the front and by VOCABULARY at the back. At the front an unlisted abbreviation is a title (H2), the shape outranking the vocabulary save a given name written short; at the back there is no shape rule, so only a listed title word chains into the title (H5) and an unlisted abbreviation stays a name word. A BARE title word at the back is a name word too, TITLES holding ordinary surnames — pope, judge, bishop — which is what bars the blanket vocabulary-wins answer (#316, decided 2026-09-08); the one exception is a listed salutation ending the given part after a family comma, the comma having marked the surname's place (H8). Some title words need a partner to be titles at all: a title that is also borne as a given name, and a word that only qualifies a head title, are titles only inside a title phrase (H9), so "Dean Smith" and "Wing Lau" read given names where "Dean of Students" and "Wing Commander" read titles. An input the title peel eats down to one last title-vocabulary word is H4's, and what it does with that word is a convention rather than a reading of the vocabulary. Two criteria govern two different questions here. Membership in the given-name-title list follows HOW THE TITLE ADDRESSES: a title that precedes and addresses by the given name belongs (Sir, Sheikh, the Arabic honorifics الدكتور/الشيخ — which qualify even though those traditions fully retain family names). Whether an EMPTY FAMILY is correct output is the separate question, governed by surname retention: renunciation abolishes the surname, so for Swami, Guru, Baba or Lama family="" is right (#346), while rabbi and imam traditions keep surnames — "Rabbi Cohen" addresses by title and keeps family "Cohen". Conflating the two criteria either ejects the Arabic entries or sweeps in titles that break "Rabbi Cohen". derek73-python-nameparser-456b024/docs/design/rules/H1-title-and-one-name-word.md000066400000000000000000000146271527303271700273730ustar00rootroot00000000000000# H1 — a title and one name word **When:** a title or a run of titles stands beside exactly one name word, as in "Mr. Johnson". **Then:** a title followed by exactly one name word makes that word the family name, whatever suffix, nickname or maiden name stands beside it, unless the title is a given-name title, which keeps it the given name; a run of several titles addresses as its last title does, and where a run stands before the one name word it is the run that addresses, a run standing behind it deciding that word's field only when none stands before. - **A run written behind the one name word** is the same form of address written on the other side of it, so where no title run stands in front of that word it decides the same reading. Where one does stand in front, that run addresses: a title written behind the name is the name plus that title (H5), and adding it cannot change how the words in front of the name are read. ``` "Smith Sir." → given="Smith" "Sir John Prof." → given="John" "Dr. Smith Sir." → family="Smith" "Dr. Smith II, Sir" family-first → family="Smith" · boundary ``` - **Which side a title is on is decided by the name word** and by nothing else standing beside it, so a run written around a nickname is one run: the name Dr. 'Smitty' Sir John reads as Dr. Sir John does. - **Neither a comma with nothing but titles after it nor a declared order decides the field.** A comma with nothing but titles after it names no given part, so it says nothing about which field the word before it fills: a comma with no given name after it does not decide the field, nor does a declared order, which places only a word nothing else decided (O5), and the title decides there as it does with no comma. ``` "Mary II, Dame" → given="Mary" "John III, Sir" → given="John" "Anne, Swami" → given="Anne" "Sir John" family-first → given="John" "John III, Dr." → family="John" · boundary ``` - **Nor does a comma with nothing before it**, which names no surname (C15): the title run decides the field of the one name word after the comma, the family unless the run addresses by given name. ``` ", Dr. John" → family="John" ", Sir John" → given="John" ``` **Because:** a title normally addresses by surname, so a title followed by a single name word usually names the family; but a given-name title addresses by given name. Several titles written together are one form of address, and the one that does the addressing is the last — the words in front of it rank the person rather than name them. What stands beside the name word — a suffix, a nickname, a maiden name — does not make the name any longer, so it does not decide this reading. ``` "Mr. Johnson" → family="Johnson" "Mrs. Garcia" → family="Garcia" "Dr. Smith née Jones" → family="Smith" "Her Majesty Queen Elizabeth" → given="Elizabeth" "Dr. Sir John" → given="John" "His Excellency Lord Duncan" → family="Duncan" "Her Royal Highness Princess Anne" → given="Anne" "Sir John" → given="John" · boundary ``` **Except:** - **A particle surname a comma or P1's fold settled keeps the family.** A particle surname standing alone before the comma stays the family, the comma being the evidence that the part is a surname (C3), and so does a never-given particle with a word behind it and no comma (P1's fold). ``` "van der Berg, Sir" → family="van der Berg" · boundary "van Gogh, Sir" → family="van Gogh" · boundary "Sir de Mesnil" family-first → family="de Mesnil" ``` **Accepted:** - A given-name title plus one name word leaves the family empty — the input names no family, and inventing one would be worse. A run whose last title is a given-name title reads the same way, `queen` doing the addressing where `her majesty` only ranks. ``` "Sir John" → family="" "Her Majesty Queen Elizabeth" → family="" "Smith Sir." → family="" ``` - A listing record whose given field holds only a given-name title reads that title's reading, so `Smith, Sir` (a row with the given name missing) reads given `Smith` with the family empty, as `Sir Smith` does: the input names one word and one title, and the title is the only evidence there is. 1.4.0 read the word before the comma as the last name in these rows (`Mary II, Dame`, `John III, Sir`, `Smith, Sir`) but for `Anne, Swami`, which it read as first `Anne`. An ambiguous particle no comma settled, or a bare one, is a word the order placed, and reads given. ``` "Smith, Sir" → family="" "Sir Abu Bakar" family-first → given="Abu Bakar" "Sir de" family-first → given="de" "St Sir de Mesnil" family-first → given="de Mesnil" ``` - A title word in the run that no vocabulary knows does not change what the run addresses by, the last word being the one asked — `His Excellency Lord Duncan` reads family `Duncan` because `lord` is not a given-name title, not because the run is long. `lord` and `lady` stay out of that list by decision (#519): each addresses by given name only as a courtesy style for children of the senior ranks (`Lord Peter`, `Lady Diana`) and by title or surname for every peer and every wife (`Lord Byron`, `Lady Thatcher`), and the text does not say which the bearer is, which the list cannot express; `prince` and `princess` are in it. - A title word that is also borne as a name, though rarely as the first word of one (`graf`, `greve`, `herra`), is the title in front of a name, so a name that BEGINS with it -- a surname written first without a comma, or a given name -- gives that word to the title, under a declared family-first order too. Written last without a period, or alone before a family comma, it is the name; a period behind it makes it the title by H5, as for `Mary Jane Pope.` (#606), and a name word behind it before a family comma makes it the title by H6. ``` "Greve Smith, Anna" → title="Greve" "Greve Anna" → family="Anna" "Anna Greve" → family="Greve" "Greve, Anna" → family="Greve" ``` --- **Signal:** count: exactly one name word beside a title run, read by the run's addressing title **history:** decisions/H1 · **implemented:** nameparser/_pipeline/_post_rules.py derek73-python-nameparser-456b024/docs/design/rules/H2-leading-abbreviation-title.md000066400000000000000000000133731527303271700302270ustar00rootroot00000000000000# H2 — the leading-abbreviation title **When:** a period-marked word opens the part of the name that carries the given name, as in "Rev. John Smith". **Then:** an abbreviation opening the part of the name that carries the given name — the whole name, or the part after a family comma — reads as a title even when unlisted, provided it is an unbroken run of two or more letters ending in its one period, the shape outranking vocabulary where it fires, so that a period-marked opening word is a title even when the word is suffix vocabulary, save a credential opening the part after a family comma (C13) and a given name written short. - **A single-letter initial does not** (T5), and neither does anything with interior periods, hyphens or digits. ``` "J. Smith" → given="J." · boundary "J.R. Smith" → given="J.R." · boundary ``` - **A letter written decomposed is the same letter.** An accented Latin, Greek or Cyrillic letter written as a base letter plus a combining mark (NFD) is the same letter as the composed one, for this shape and for the dotted and all-caps credential shapes C5, C6, S9 and S3 read (#652), so such a name parses the same in either encoding and the output keeps the one written. ``` "Dr. inž. Janez Novak" → title="Dr. inž." ``` **Because:** before a name, an abbreviation is almost always a title — "Rev.", "Ing.", "Mag." — and no vocabulary can list every profession's abbreviations in every language. ``` "Rev. John Smith" → title="Rev." "Xyz. John Smith" → title="Xyz." "Smith, Major. John" → title="Major." "Esq. Smith" → title="Esq." ``` **Except:** - **After a family comma, a part that is nothing but suffix words** is the credential run (C8), which the abbreviation does not open: the vocabulary decides, and "Esq." is the postnominal it is. ``` "Smith, Esq." → suffix="Esq." ``` **Precedence:** - **A credential opening the part after a family comma** ("Smith, Esq. John"): C13 wins, and the part is postnominal: a suffix word that starts S8's run makes the part its postnominals however it goes on. The shape still reads as a title there an abbreviation that starts no such run — an unlisted one, or a listed suffix word C13 does not open with, such as a word of the ambiguous class. ``` "Smith, Esq. John" → suffix="Esq. John" "Smith, Insp. John" → title="Insp." "Smith, Do. John" → title="Do." ``` - **A given name written short** ("Chas. Smith", "Ma. Cristina Reyes"): an abbreviation the vocabulary reads as a given name written short is that given name, outranking the shape — traditional English abbreviations such as Chas. and Wm., and the Filipino Ma. (María) and Jo. (José) — so the name reads as it would with the full given name in that place, behind a title too ("Mrs Ma. Liza Cruz"), and C4's count of the name words before a comma, which asks this shape, counts it as one; its period still keeps it out of the case contrast C6 reads, as an initial's does ("Chas. SMITH, XYZ" reads as "J. SMITH, XYZ"). Only the shape yields: a word listed as a title is still one, and written bare, or anywhere but the head of the part that carries the given name, the word reads as it would unlisted, so "Ma" keeps its surname and credential readings. ``` "Chas. Smith" → given="Chas." "Smith, Chas." → given="Chas." "Smith, Ma. John" → given="Ma." "Mrs Ma. Liza Cruz" → given="Ma." "Wm. H. Smith III" → given="Wm." "Chas. Smith, Ed" → suffix="Ed" "John Smith Ma." → family="Ma." "Smith, John Ma" → middle="Ma" ``` - **A connective after the abbreviation** (P3): the join takes it with the word after the connective before the shape is asked of it, so the two read as two names joined. ``` "Xyz. and Mary Smith" → given="Xyz. and Mary" ``` **Accepted:** - The shape is only recognizable as an unbroken run of letters, so an abbreviation in a script whose letters carry combining vowel signs (Bengali, Devanagari) never reads as a title by shape — an unlisted abugida honorific stays a name word, and only vocabulary can recognize it — which is what #343 and #344 supply for Bengali and Devanagari. ``` "প্রকৌশলী. Sen" → given="প্রকৌশলী." ``` - The shape reads a Latin convention, and a script with no initials has no period abbreviations either, so a period-marked opening word carrying a Han, kana or hangul character — as the script table classifies them, halfwidth katakana included since #594 — is a name word and never a title by shape (#323; decisions/cjk-full-stops) — the same veto that keeps 씨. from reading as an initial. The Latin reading is unchanged, and W3's example block carries the CJK reading this clause vetoes into. The veto reads the composed spelling, so hangul typed decomposed, as conjoining jamo the script table does not hold, is hangul to it too (#665); the second line below is typed that way. ``` "Smith. John" → title="Smith." "김. 민준" → title="" ``` - Before a family comma the shape does not fire: the titles there are the vocabulary's alone (H6), so an unlisted abbreviation opening that part stays in the family. ``` "Xyz. Smith, John" → family="Xyz. Smith" ``` --- **Signal:** shape: an unlisted word of two or more letters ending in its one period, opening the given-name part, which a given name written short declines **history:** decisions/H2 · **implemented:** nameparser/_pipeline/_assign.py, nameparser/_pipeline/_classify.py, nameparser/_pipeline/_pieces.py, nameparser/_pipeline/_vocab.py derek73-python-nameparser-456b024/docs/design/rules/H3-title-runs-floor.md000066400000000000000000000044211527303271700262620ustar00rootroot00000000000000# H3 — the title run's floor **When:** title words stand in a name, as in "Asst. Vice Chancellor John Smith". **Then:** successive title words at the start of the part carrying the given name chain into one title; a title word elsewhere in the name does not. - **The run leaves one NAME word standing**, and a word of the suffix vocabulary is not that word: where everything behind the run is post-nominal, the run gives its last word back to the name, provided that word stands alone and is not itself suffix vocabulary. ``` "Dr Pope Jr" → family="Pope" "Dr Pope Jr" → suffix="Jr" "RA DDS" → family="DDS" ``` **Because:** compound titles are written as a run of title words, connectives included; a title word standing inside the name is just a name word. And a title addresses somebody, so the run leaves somebody there to address: a post-nominal is written about a name rather than being one, so it cannot be the name the run left. ``` "Asst. Vice Chancellor John Smith" → title="Asst. Vice Chancellor" "Marquess of Bath" → title="Marquess of Bath" "Morse, Det. Insp. Jane" → title="Det. Insp." "John Doctor Smith" → middle="Doctor" · boundary ``` **Accepted:** - Before a family comma the run is H6's, which stops where nothing but post-nominals follows for the reason this one gives a word back, and always leaves the family one word, whatever that word is. ``` "Dr. Jr., John" → family="Dr." ``` - The word given back must stand alone, so a title written as one joined unit stays whole and the post-nominal behind it is the name — turning the unit into a name and leaving no title at all is the worse of the two readings. ``` "Prince of Wales Jr" → title="Prince of Wales" ``` - Where the run's whole content is the word given back, no title is left to make the reading H1's, so the word stands as the name and the parse reports the doubt (H4). ``` "Dr Jr" → given="Dr" "Dr Jr" → ambiguities=("title-or-name",) ``` --- **Signal:** neighbor: title words in succession at the head of the given-name part, and what stands behind them **history:** decisions/H3 · **implemented:** nameparser/_pipeline/_pieces.py H4-input-that-is-nothing-but-vocabulary-still-has-to-name-somebody.md000066400000000000000000000103201527303271700371340ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/rules# H4 — an input that is nothing but vocabulary still has to name somebody **When:** an input holds no name word but vocabulary, title or post-nominal, as in "Lord Chancellor". **Then:** an input whose only remaining name word after the title peel is itself title vocabulary reads that word as the name by convention and reports `title-or-name`; an input whose every word is post-nominal vocabulary reads its first word as a name and reports `suffix-or-name`. - **A single title word** reads as a title with no name beside it and reports nothing: the peel took the whole string, so no word was left standing to be read as a name and no reading was chosen. ``` "Dr." → title="Dr." · boundary "Dr." → ambiguities=() ``` - **ONE site takes both halves** — the assignment that places the lone name word — so neither report depends on the declared order. Under the default order H1 retags the title-vocabulary word from given to family afterwards; under a declared family-first order the assignment places it in the family directly and H1 never runs. Same reading either way, so the same kind is reported either way. - **Where H7 meets this rule**: a name unit of more than one word carrying title vocabulary reports `title-or-name` too, by H7, the fork there being whether the word inside the unit is a title. **Because:** this is a name parser, not a title parser. Handed a string the title peel eats down to one last word which is itself title vocabulary, it still has to name somebody, and that word is the only candidate there is — a guess fixed in advance, so the same input reads the same way every time, not a claim that the word is a surname. The same reasoning covers a string that is nothing but post-nominal vocabulary: something has to be the name. ``` "Lord Chancellor" → family="Chancellor" "Lord Chancellor" → ambiguities=("title-or-name",) "The Right Hon. the President of the Queen's Bench Division" → family="Division" "The Right Hon. the President of the Queen's Bench Division" → ambiguities=("title-or-name",) "Dr. Pope" → ambiguities=("title-or-name",) "Dr. Pope MD" → ambiguities=("title-or-name",) "Rinpoche" → ambiguities=("suffix-or-name",) "QC MP" → given="QC" "Dr. Smith" → ambiguities=() ``` **Except:** - **A title in front takes the input out of the suffix half**, which is reached only where no title was peeled first. What it does NOT do is take the input out of the rule: the title run leaves a name word standing (H3), and where that word is title vocabulary the TITLE half claims it — `Dr. Pope MD` reports `title-or-name` on `Pope`, while `RA DDS` reports nothing, `DDS` being no title. ``` "RA DDS" → ambiguities=() ``` **Precedence:** - **A dotted lone credential** ("Jr."): `Jr.` is post-nominal vocabulary and still reads as a title, H2's opening-abbreviation shape outranking the vocabulary where it fires — so the suffix half never sees a dotted lone credential. A credential that is also a given name written short is that given name instead, by H2's own precedence ("Ma."). ``` "Jr." → title="Jr." "Jr." → ambiguities=() "Ma." → given="Ma." ``` **Accepted:** - The suffix half is scoped to names no script order placed, so a lone CJK honorific written by itself — さん, 씨, 선생님 — reports nothing. The same shape, read through the glued-honorific rules (W2) and the script's own order, and left to the arc that revisits those readings. That silence is a SCOPE on the script rule having placed the name, not a claim about the honorific: a caller who declares no script orders at all gets the report on the same input, since nothing then resolves the order and the word reaches the carve-out as any other lone credential does. The pair is pinned in the case table rather than here, a policy that empties the script table having no example spelling in this document. --- **Signal:** count: no name word left but vocabulary, title or post-nominal **history:** decisions/H4 · **implemented:** nameparser/_pipeline/_assign.py derek73-python-nameparser-456b024/docs/design/rules/H5-trailing-title-run.md000066400000000000000000000145421527303271700265760ustar00rootroot00000000000000# H5 — the trailing title run **When:** a period-marked word stands at the end of a name, behind any trailing suffix run, as in "John Smith Prof.". **Then:** after the trailing suffix run has been taken, successive single words that wear the abbreviation shape and are title vocabulary chain into the title from the end, leaving one name word standing; a bare title word there is a name word, and an unlisted abbreviation there is a name word. - **The title is transparent to the suffix reading**: where two or more name words stand, what stands once the chain is taken reads exactly as it would read written without the title, plus the title. Where the chain leaves ONE name word and no title run stands in front of it, there is no second reading for it to be transparent to, and the title behind that word decides its field as a title in front of it would (H1). Where a run DOES stand in front, that run addresses and the chained title only joins the title field, so the transparency holds for the one-word name too. ``` "Smith Prof." → family="Smith" "Dr. John Smith Prof." → title="Dr. Prof." ``` - **Input order.** The trailing title words join the title field in input order after any leading run, because the title view reads the tokens in the order they were written. ``` "Dr. John Smith Prof." → family="Smith" ``` - **A title among the post-nominals.** Transparency is what makes the suffix reading proof against a title standing among the post-nominals — a title AHEAD of a suffix word is taken all the same, and the suffix reading is then taken over what stands, not over what stood. ``` "John Smith Prof. Jr." → suffix="Jr." "John Smith Jr. Prof." → suffix="Jr." "John Prof. MA" → suffix="MA" "JOHN PROF. MA" → family="MA" ``` - **What reports.** No fork is reported. Under the premise that the input is a name, a period-marked word the title vocabulary knows is not a reading a reader would hesitate over — where the doubt is real it is the word left STANDING that carries it, which is H4's report and not this rule's. **Because:** a word abbreviated with a period at the END of a name is standing where a post-nominal stands, and the parts of a name that get abbreviated are the ones outside it. There is no shape rule there — H2's belongs to the front slot — so only a word the vocabulary knows as a title is one, and a bare title word is a name word, TITLES holding ordinary surnames. ``` "John Smith Prof." → title="Prof." "John Smith Prof." → family="Smith" "John Smith Prof. Dr." → title="Prof. Dr." "Smith, John Prof." → title="Prof." "John Smith Sir" → family="Sir" "Mary Jane Pope" → family="Pope" "John Smith Esq." → suffix="Esq." "John Smith Xyz." → family="Xyz." · boundary ``` **Precedence:** - **A particle chain in front of a trailing title** ("John van der Berg Prof."): the title chain is read together with the trailing suffix run (S2), once the connectives have joined (P3) and before the particle chain is made, so a particle chain (P2) stops in front of a trailing title rather than taking it into the family name. A title word a connective joined into a longer unit is no single word for the title chain to take. ``` "John van der Berg Prof." → title="Prof." "John van der Berg Prof." → family="van der Berg" ``` - **After a family comma** ("Smith, John van der Berg Prof."): the given part's particle chain stops in front of the trailing run of suffixes and titles, read as the comma-less name reads it with the bare salutations H8 adds, so there too a trailing title is the title and P6's attachment reads the particle in front of it as though the title were absent. ``` "Smith, John van der Berg Prof." → title="Prof." "Smith, John van der Berg Prof." → middle="van der Berg" "Smith, Jan van Prof." → family="van Smith" ``` - **A maiden clause in front of a trailing title** ("Mary Smith née Jones Prof."): a maiden clause ends where the run the end of the name reads without it begins (M2), so where a trailing rule reads the part a trailing title ends the clause and is a title. ``` "Mary Smith née Jones Prof." → title="Prof." ``` - **A bare salutation after a family comma** (H8): the bare title word this rule leaves a name word is the title where it is a listed salutation ending the given part after a family comma, the family being fixed, and the chain takes it there as it takes a period-marked title word. ``` "Smith, Jane Mrs" → title="Mrs" ``` - **A bound given-name word beside a trailing title** (P5): what the title chain leaves is also what counts as a name word to spare (P5). A trailing title word is not one, so a bound given-name word behind one joins exactly as it joins with the title absent — and lands in the same field, the run in FRONT of the joined pair being the run H1 asks about. Said of the comma-less writing, which is where the title chain runs before the reserve is read. After a family comma the reserve reads no peel, but the join declines a trailing title word the title chain takes (P5), so the title is read there too, save the shapes P5's Accepted clause records, as the segment written with an ordinary given name reads it. ``` "Berg, abdul Prof." → title="Prof." ``` **Accepted:** - The reach is the whole title vocabulary, the ordinary surnames in it included. TITLES holds pope, judge and bishop, and a period written behind one of them is enough to make it the title and take it out of the name: `Mary Jane Pope.` reads title `Pope.` with family `Jane`, where the bare spelling keeps family `Pope`. That is a cost accepted under the premise that the input is a name (H Background), not a case this rule prevents — the period is a writing convention rather than evidence about the word, so every title whose spelling wears the abbreviation shape is reachable this way, and the BARE spelling is what the trailing slot is protected from. ``` "Mary Jane Pope." → title="Pope." "Mary Jane Pope." → family="Jane" ``` --- **Signal:** shape: period-marked title vocabulary at the end of the name, behind the trailing suffix run **history:** decisions/H5 · **implemented:** nameparser/_pipeline/_assign.py, nameparser/_pipeline/_group.py, nameparser/_pipeline/_pieces.py derek73-python-nameparser-456b024/docs/design/rules/H6-title-before-a-family-comma.md000066400000000000000000000072261527303271700302160ustar00rootroot00000000000000# H6 — a title before a family comma **When:** a word of the title vocabulary opens the part before a family comma, as in "Dr. Smith, John". **Then:** a run of title words opening the part before a family comma is the title, joining the title field in input order with any title after the comma, and the comma decides the family whatever the order declared, so the reading is the same under every name_order. - **The run admits a title word only as vocabulary knows it**, never by H2's shape, so an abbreviated word opening a surname that the vocabulary does not list stays in the family. **Because:** the listing form moves the surname to the front and leaves everything else where it was written, so a title written ahead of the name ends up ahead of the surname — `Dr. Smith, John` is `Dr. John Smith` indexed. The comma names the family, and a title is no part of it. But the slot in front of a family comma is a surname's slot as well, and a surname can open with an abbreviated word (`Sta. Maria`, `Sto. Domingo`), so only the vocabulary claims a word there, never H2's shape, and a word that is a particle as well as a title opens a surname rather than ranking a person. ``` "Dr. Smith, John" → title="Dr." "Dr. Smith, John" → family="Smith" "Prof. Dr. Brühl, Ada" → title="Prof. Dr." "Dr. de la Vega, Juan" → family="de la Vega" "Sir Smith, John" → given="John" "Prof. Smith, Dr. John" → title="Prof. Dr." "Rev. Sr. Smith, Mary" → title="Rev. Sr." "Dr. Mc Donald, John" → family="Mc Donald" "Prof. Smith, John, PhD" → family="Smith" "Sta. Maria, Juan" → family="Sta. Maria" · boundary ``` **Except:** - **The run stops at a word that is also a particle.** 'Freiherr' and 'St' are particles as well as titles, so they stay in the family in either spelling, keeping 'St Pierre' whole and 'Freiherr von Berg' as C3 reads it. A title in front of a particle still peels. ``` "St Pierre, Ed" → family="St Pierre" · boundary "Dr. St. Pierre, Ed" → family="St. Pierre" · boundary "Freiherr von Berg, Ed" → family="Freiherr von Berg" "Dr. Freiherr von Berg, Ed" → title="Dr." ``` - **The run stops at a word H9 reads out of the title**: a modifier with no head title behind it, and a title that may be a given name outside a title phrase, open the surname. ``` "Wing Smith, John" → family="Wing Smith" · boundary "Dean Smith, John" → family="Dean Smith" ``` - **The run stops where nothing but post-nominals stands behind it** (H3). ``` "RA PhD Jr., John" → family="RA" · boundary ``` - **The run always leaves the family at least one word.** ``` "Dr., John" → family="Dr." · boundary "Dr. Pope, John" → family="Pope" · boundary ``` **Accepted:** - A title word that is also an ordinary surname (pope, judge, bishop, greve) opening a surname of two words is read as the title, as it is in front of a name with no comma (H1). A double-barrelled surname that begins with such a word loses it. ``` "Pope Smith, John" → title="Pope" ``` - What keeps a word in the family is the particle stop and nothing else. German ranks the title vocabulary lists, other than 'Freiherr', are titles only, so they peel, though since 1919 a rank is part of the legal surname for all of them alike; that is the vocabulary's distinction, not this rule's. ``` "Graf Lambsdorff, Otto" → title="Graf" ``` --- **Signal:** neighbor: title vocabulary opening the part before a family comma **history:** decisions/H6 · **implemented:** nameparser/_pipeline/_assign.py derek73-python-nameparser-456b024/docs/design/rules/H7-joined-name-unit-carrying-title-vocabulary.md000066400000000000000000000061631527303271700333110ustar00rootroot00000000000000# H7 — a joined name unit carrying title vocabulary **When:** the one name unit a name leaves standing is more than one word and carries title vocabulary, as in "John of Bishop". **Then:** one name UNIT of more than one word carrying title vocabulary reports `title-or-name`, the fork there being whether the title word inside the unit is a title at all. - **A join or a particle chain.** Usually a join (P3), and a particle chain (P4) is the same shape and reports too — `St van Bishop` reads family "van Bishop" with `bishop` title vocabulary inside it. ``` "John of Bishop" → ambiguities=("title-or-name",) "St van Bishop" → family="van Bishop" "St van Bishop" → ambiguities=("particle-or-given", "title-or-name") ``` - **The reach is every such join.** The clause reaches EVERY join whose non-leading member is TITLES vocabulary, not the one word that prompted it: `Smith and Pope`, `John and Pope`, `Smith and Bishop` and `John of Judge` all report it, as `John of Bishop` does. ``` "Smith and Pope" → ambiguities=("title-or-name",) "John of Judge" → ambiguities=("title-or-name",) ``` - **Behind a peeled title.** A join OPENING the name with a title word is a title run (H3) instead. Behind a peeled title the join is not: a title needs a following piece and the join is the last one, so `Attorney General of Minnesota` and `Deputy Secretary of State` keep `Attorney` and `Deputy` as the title and report the rest — the two corpus names the clause reached on 2026-09-08, and `General` or `Secretary` being a title is exactly the fork. ``` "Attorney General of Minnesota" → title="Attorney" "Attorney General of Minnesota" → ambiguities=("title-or-name",) ``` - **Nothing in front of the join silences the clause**, a title, a maiden name or a claimed word each deciding which FIELD the unit takes and none of them whether the word inside it is a title. The other measured inputs are pinned in the case table rather than here. ``` "Dr. Smith and Bishop" → ambiguities=("title-or-name",) ``` - **A unit of one word is H4's**: where the title peel leaves one word standing and that word is title vocabulary, H4's title half reports the same kind. **Because:** a word joined into a name unit may be a second name that happens to be title vocabulary or a title written inside the name, and nothing in the writing says which. The doubt is whether the word is a title, not which field the unit takes. ``` "Prince of Wales" → title="Prince of Wales" · boundary "Prince of Wales" → ambiguities=() ``` **Accepted:** - That reach is the pope/judge/bishop collision set met inside a join, and the report is the honest answer there rather than a misfire — a second surname that is also title vocabulary is exactly the doubt the kind names, and those words stay in the vocabulary for the addressing forms, which is decisions/vocabulary-collisions' call. --- **Signal:** count: one name unit left standing, more than one word, holding title vocabulary **history:** decisions/H7 · **implemented:** nameparser/_pipeline/_assign.py derek73-python-nameparser-456b024/docs/design/rules/H8-salutation-ending-the-given-part.md000066400000000000000000000115751527303271700313270ustar00rootroot00000000000000# H8 — a salutation ending the given part **When:** a family comma divides the name, and the given part after it ends in a word whose title reading is a salutation, written bare or with a stop at its edge that H5's abbreviation shape does not take (a doubled stop, as in "Mr.."), with a name word in front of it, as in "Doe, Jane Mrs". **Then:** the word joins the title field, and the given part reads as it would without it. - **It reads as the period-marked spelling reads.** The salutation chains into the trailing title run (H5) as a period-marked title word does, so the bare and the period-marked spellings read alike in every comma shape and under every name order: behind a post-nominal or in front of one, and behind another title. ``` "Doe, John Mr Jr" → title="Mr" "Doe, John Mr Jr" → suffix="Jr" "Doe, John Jr Mr" → title="Mr" "Doe, Jane Mrs, PhD" → title="Mrs" "Doe, Jane Mr Mrs" → title="Mr Mrs" "Doe, Jane Mr.." → title="Mr.." ``` **Because:** H5 keeps a bare title word ending a name as a name word because the title vocabulary holds ordinary surnames, and after a family comma that reason is gone, the comma having marked the surname's place. What is left is the middle-name reading, and a salutation is a form of address, not a rank or office someone could be named after. Filers' software writes this order in volume (`DOE, JOHN Q MR`), the period as often left off as written. ``` "Doe, Jane Mrs" → title="Mrs" "Doe, Jane Mrs" → given="Jane" "DOE, JOHN Q MR" → middle="Q" "van Doe, Jane Mrs" → family="van Doe" "Doe, Mrs" → family="Doe" · boundary "Jane Doe Mrs" → family="Mrs" · boundary ``` **Except:** - **A salutation that is also borne as a name stays a name word.** A middle name may be a given name or a surname, so the vocabulary marks only the salutations borne as neither, and goodman, master, herr and fru are left out with their counts, as is a non-Latin salutation whose romanization is borne where that script is written (बाबू, Babu). ``` "Doe, John Goodman" → middle="Goodman" "Doe, John Master" → middle="Master" "Doe, Jane Fru" → middle="Fru" "शर्मा, राम बाबू" → middle="बाबू" ``` - **A title of any other kind stays a name word.** Office, rank and religious words are borne as middle names, and an academic title is not a salutation, so Dr written bare there stays one too. ``` "Doe, John Pope" → middle="Pope" "Doe, Jane Dr" → middle="Dr" ``` - **A title/suffix dual stays with the suffix run** (S2): Ms and Sr are post-nominals as well, and the suffix reading takes them before any title reading is asked. ``` "Doe, Jane Ms" → suffix="Ms" ``` **Precedence:** - **A bare title word ending the name** (H5): H5 reads one as a name word, and after a family comma a salutation is this rule's, so H5's chain takes it as it takes a period-marked title. ``` "Doe, Jane Mrs" → middle="" ``` - **A bound given-name word in front of the salutation** (P5): the join declines a salutation this rule reads, as it declines a trailing title H5 takes, so the bound word stands alone as the given name. ``` "Doe, abdul Mrs" → given="abdul" ``` - **A particle in front of the salutation** (P6): the attachment reads the particle as though the salutation were absent, as it does in front of a period-marked title. ``` "Doe, Jane van Mrs" → family="van Doe" ``` **Accepted:** - A salutation that is someone's middle name, after a family comma, reads as the title. Miss is the nearest case the vocabulary keeps, borne as a surname by about 160 people in the US Census 2010. ``` "Doe, Jane Miss" → title="Miss" ``` - With nothing before the comma, the one given word the salutation leaves reads as a title's lone name word reads (H1, C15's one exception), the family, as the period-marked spelling has read it since H5 (2.3.0, #316); written alone, that word is the given name. ``` ", Jane Mrs" → family="Jane" ", Jane" → given="Jane" ``` - The comma-less writing keeps the salutation a name word under a declared family-first order, though the declared order fixes the surname as a comma does: the rule reads the comma written in the name, not the order declared for it, so the two writings decisions/two-input-invariants pairs read this salutation differently. ``` "Doe Jane Mrs" family-first → middle="Mrs" ``` --- **Signal:** comma: a family comma dividing the name, with listed salutation vocabulary ending the given part **history:** decisions/H8 · **implemented:** nameparser/_pipeline/_assign.py, nameparser/_pipeline/_classify.py, nameparser/_pipeline/_pieces.py derek73-python-nameparser-456b024/docs/design/rules/H9-title-phrase.md000066400000000000000000000250621527303271700254500ustar00rootroot00000000000000# H9 — the title phrase **When:** a title word that is also borne as a given name, or a word that only qualifies a head title, stands where a title would be read, as in "Dean Smith" or "Wing Lau". **Then:** the word is a title only inside a title phrase, and elsewhere a name word. - **A title that may be a given name** has its phrase partner in a modifier or a style before it that is a title there, in a title before it that attaches to it, in a title after it that it attaches to, or in `of` after it. No other title before it is one, a salutation least of all, and no other title after it: a donor list writes the salutation after the given name. ``` "Dean of Students Jane Smith" → title="Dean of Students" "Associate Dean Smith" → title="Associate Dean" "Major General John Smith" → title="Major General" "Chief Justice John Roberts" → title="Chief Justice" "The Hon. Justice Mary Smith" → title="The Hon. Justice" "His Grace John Smith" → title="His Grace" "Field Marshal Bernard Montgomery" → title="Field Marshal" "Sergeant Major John Smith" → title="Sergeant Major" "Lord Chancellor John Smith" → title="Lord Chancellor" "Mr Dean Smith" → given="Dean" "SMITH, DEAN MR." → given="DEAN" "General Major John Smith" → given="Major" "Grace Dean Smith" → given="Grace" "Sgt Marshal Smith" → given="Marshal" "Mr Mahdi Karimi" → given="Mahdi" "Buddha Bahadur Tamang" → given="Buddha" "King Curtis" → given="King" "Sultana Begum" → given="Sultana" "Mr Shaikh Ahmed" → given="Shaikh" "Ms Princess Smith" → given="Princess" "Marquis Johnson" → given="Marquis" "Mr Deacon Smith" → given="Deacon" "Ms Queen Smith" → given="Queen" "Chancellor Smith" → given="Chancellor" "Cheikh Anta Diop" → given="Cheikh" "Queen Mother Elizabeth" → title="Queen Mother" ``` - **A modifier in the leading run of titles before the first comma** has its phrase in a head title after it in that run, in a title before it that attaches to it, in `of` after it, or in the object of `of` or `the` there. A modifier outside that run, ending the name or after a comma, is read as any title is, save one that may be a given name, which is read everywhere as it is in that run. ``` "Wing Commander Lau" → title="Wing Commander" "Princess Royal Anne" → title="Princess Royal" "Prince Consort Albert" → title="Prince Consort" "Mr Wing Lau" → given="Wing" "Mr. and Mrs. Wing Lau" → given="Wing" "President of the United States Barack Obama" → title="President of the United States" "Mother Superior of the Convent Mary Smith" → title="Mother Superior of the Convent" "Jane Smith Vice." → title="Vice." "John Smith, Vice" → title="Vice" "Lau, Wing" → given="Wing" "Lau, Wing Commander" → title="Wing Commander" "John Smith Wing." → family="Wing." ``` - **A title that may be a given name, read as a name word where it is the first name word after any titles and no comma has marked the given name,** is reported as `title-or-name` where a phrase the vocabulary lists holds it; inside a phrase, later in the name, or in the given part after a family comma, it is not. A word no listed phrase holds is a title only behind a style or a modifier, or before `of`, the partners every such word shares, so standing alone it is no title a reader would hesitate over. ``` "Dean Smith" → ambiguities=("title-or-name",) "Marshal Yanda" → ambiguities=("title-or-name",) "Mr Dean Smith" → ambiguities=("title-or-name",) "Dean Smith, MD" → ambiguities=("title-or-name",) "Prince Fielder" → ambiguities=("title-or-name",) "Dr. King" → ambiguities=("title-or-name",) "Chancellor Smith" → ambiguities=("title-or-name",) "Grace Smith" → ambiguities=() "Bard Larsen" → ambiguities=() "GRACE SMITH" → ambiguities=() "SMITH, DEAN MR." → ambiguities=() ", Dean" → ambiguities=() "Major General John Smith" → ambiguities=() "John Dean Smith" → ambiguities=() ``` **Because:** a title addresses the name after it, so the front of a name has no count to read, and what tells the person "Dean Smith" from the office in "Dean of Students" is the phrase the word stands in. A word that only qualifies a head title, as wing qualifies commander, says nothing about the person on its own. After a comma the part is read as written there, as decision 10 measured: a lone title word in a part after the name is the person's role, and reading it as a name reversed the name, save a word also borne as a given name, which the name data measured so far writes there as the listing form's given name, or at the end as a surname, rather than a role. ``` "Dean Smith" → given="Dean" "Major Smith" → given="Major" "Wing Lau" → given="Wing" "Mr Smith" → title="Mr" · boundary "Wing Commander Lau" → ambiguities=() · boundary ``` **Precedence:** - **A modifier that may be a given name** is read by the modifier's test in every part, its run being its phrase, not by the partners of a title that may be a given name: a style before it is no partner of itself. ``` "Hon. Wing Lau" → given="Wing" ``` - **A period-marked word in the leading run of titles** (H2): the abbreviation shape makes it a title whatever the vocabulary says, save a given name written short or a word a connective joins (below), so the phrase test never reaches it and nothing is reported, at the front of the name or behind another title; a style that may be a given name, made a title by the shape alone, is no partner of the word behind it, which is then read and reported as at the front, where a modifier is (`Wing. Dean Smith` → title 'Wing. Dean'). ``` "Dean. Smith" → title="Dean." "Wing. Lau" → title="Wing." "Mr Buddha. Tamang" → title="Mr Buddha." "Shaikh. Sultan Khan" → given="Sultan" ``` - **A title run** (H3, H4): a word this rule reads as a name word is no title word to the run, so the run stops before it and does not count it. ``` "Mr Dean Smith" → title="Mr" "Dean" → given="Dean" ``` - **A connective after the word** (P3): only `of` makes a phrase of the join, so a word read as a name word before `and` joins the next word as two names join, and so does one written with its period, the join taking it before H2's shape is asked. ``` "Dean and Mary Smith" → given="Dean and Mary" "Dean. and Mary Smith" → given="Dean. and Mary" ``` - **A title that may be a given name which a caller lists in `titles`** is a title wherever it stands, since that is what listing it asks; a modifier is read by this rule however it is listed, and leaving its word out of the modifiers turns the rule off for it. **Accepted:** - A judge styled with a salutation reads the salutation as the title and the word as the given name, since a salutation is never a partner; the people who write a salutation before the word file it as their forename. ``` "Mr Justice Smith" → given="Justice" ``` - A modifier whose head is not a listed title loses the title, as with duke, which the vocabulary refuses, and so does a modifier written after a head that does not attach to it, with no `of` behind it. ``` "Grand Duke of Tuscany" → given="Grand" "Her Royal Highness Princess Grand Anne" → given="Grand" ``` - In the given part after a family comma a modifier that is not also a given name is read as listed, so the listing form and the written order of one name read it differently, the cost decision 10 took for the parts where a lone title word is a role. ``` "Hudson, Rock" → title="Rock" ``` - A phrase that is the whole given part after a family comma leaves the given name empty, as a lone title there does. ``` "Smith, Sgt Major" → title="Sgt Major" ``` - A modifier that may be a given name keeps the title wherever its run's test finds a phrase it does not form, in the object of `the` behind a style, and before a suffix whose period H2's shape reads as a head. ``` "The Hon. Wing Lau" → title="The Hon. Wing" "John Smith Wing. Jr." → title="Wing." ``` - An epithet that is also a given name, written with its article before the name or after a comma, reads as a name word, the article joining it; no name data at hand writes either shape. ``` "The Mahdi Muhammad Ahmad" → given="The Mahdi" "William Shakespeare, the Bard" → given="the Bard" ``` - A religious title that also opens given names, in its own tradition or another, written bare, reads as a name word before a name in every tradition, behind any title but a style, a modifier or one that attaches to it; the name data at hand writes the title there almost never. ``` "Gyani Zail Singh" → given="Gyani" "Lord Buddha Tamang" → given="Buddha" "The Rev. Deacon John Smith" → given="Deacon" ``` - A ruler, a royal or a noble written with the bare title in front reads it as a name word, the given name in the default order, a style before it or `of` after it keeping the title; the record data at hand writes the word there almost only as a given name, and rulers, royals and nobles that way mostly in news text. ``` "King Henry" → given="King" "Sultan Hassanal Bolkiah" → given="Sultan" "Shaikh Hamad bin Isa" → given="Shaikh" "Prince Harry" → given="Prince" "Sheikh Mohammed bin Rashid" → given="Sheikh" "Marquis de Lafayette" → given="Marquis" "Queen Elizabeth II" → given="Queen" "His Majesty King Charles" → title="His Majesty King" "Prince of Wales" → title="Prince of Wales" ``` - Once a partner has made the word a title, a coordinating join behind it takes the next word into the title too. ``` "Chief Dean and Mary Smith" → title="Chief Dean and Mary" ``` --- **Signal:** neighbor: the titles and connectives beside a listed title word in its run of titles **history:** decisions/H9 · **implemented:** nameparser/_pipeline/_classify.py, nameparser/_pipeline/_vocab.py, nameparser/_pipeline/_assign.py derek73-python-nameparser-456b024/docs/design/rules/M-maiden-names.md000066400000000000000000000043231527303271700253160ustar00rootroot00000000000000## Maiden names (M) Background: a maiden name is written beside the current name, set off by a marker or by enclosure. Markers are attested across French née/né and the unaccented nee English writing uses for both, German geb./geborene, Dutch geboren, Czech/Slovak rozená (the abbreviation roz. shipped through 2.1 and was removed in 2.2 -- it collides with the English diminutive Roz, and a caller who needs it adds it to their own Lexicon), Scandinavian født/fødd/född, Russian урожд. and its full participles урождённая/урождённый (the participles in both the ё and е spellings, which case normalization does not fold), Japanese 旧姓, and Polish z domu — both grammatical genders where attested. Every one of these is a marker wherever it stands, the unaccented nee included: an enclosure holding a word past it reads as the maiden name and the marker is dropped, so a configured pair reading "(Nee Jones)" gives maiden Jones and not Nee Jones. A marker need not be one word. z domu is two, and a phrase marker is recognized only whole and only where its words stand together: neither of its words is a marker standing alone, and neither is the pair once a bracketed clause or a comma divides them. That is what makes shipping it safe where shipping its words would not be — z is an ordinary Polish preposition, and a name that merely contains one keeps its family name. Japanese more often writes the marker with a fullwidth colon (旧姓:佐藤), which is no separator, so marker and name arrive as a single word. Which enclosures mean "maiden" rather than "nickname" is a caller convention, so the maiden reading of a delimiter pair is opt-in — except where the clause announces itself. A clause of two words or more led by a marker word has said which convention it means, and reads as the maiden name inside a nickname pair as well (M3) — unless its content is suffix-shaped, which S1 takes ahead of both. A lone marker has said nothing, and neither has one the colon spelling above glues to the name. A marker also says something about the name standing beside it: it announces a surname the bearer no longer uses, and that is only worth writing where there is a current one to tell it apart from (M4). derek73-python-nameparser-456b024/docs/design/rules/M1-delimited-maiden-names.md000066400000000000000000000040331527303271700273330ustar00rootroot00000000000000# M1 — delimited maiden names **When:** a delimiter pair the caller configured for maiden names encloses a clause, as in "Jane Smith (née Jones)". **Then:** its enclosed clause reads as the maiden name, and a leading recognized marker is dropped where the clause holds a word past it. - **Clauses are independent:** two enclosures read as one maiden name, each dropping or keeping its own marker. ``` "Jane Smith (Nee) (Jones)" maiden-parens → maiden="Nee Jones" ``` - **Configuring the pair is what this rule needs for a clause that does not announce itself** — markerless content, and a lone marker alike, whether that marker is one word or several. A clause holding a word past its marker reads as the maiden name inside a nickname pair as well (M3), the suffix-shaped content S1 takes excepted there as it is here. - **A clause of nothing but its marker keeps its words**, which may themselves be a surname (Nee). ``` "Jane Smith (Nee)" maiden-parens → maiden="Nee" · boundary "Maria Kowalska (z domu)" maiden-parens → maiden="z domu" · boundary ``` **Because:** an enclosure the caller has declared to mean maiden holds the former family name. A recognized marker word inside it marks the clause and is not itself part of the name. ``` "Jane Smith (née Jones)" maiden-parens → maiden="Jones" ``` **Except:** - **A marker followed by a comma is no marker** (M3), and neither is a phrase marker the writer divided with a comma (M2), so the clause keeps all its words. ``` "Maria Kowalska (z, domu Nowak)" maiden-parens → maiden="z, domu Nowak" · boundary ``` **Precedence:** - **Suffix-shaped content** ("Andrew Perkins (MBA)"): S1 takes it first, and the clause is no maiden name. ``` "Andrew Perkins (MBA)" maiden-parens → suffix="MBA" · boundary ``` - **A pair configured for both maiden and nickname names** reads maiden: this rule wins over N1. --- **Signal:** neighbor **history:** decisions/M1 · **implemented:** nameparser/_pipeline/_extract.py, nameparser/_pipeline/_group.py derek73-python-nameparser-456b024/docs/design/rules/M2-maiden-marker-rule.md000066400000000000000000000222141527303271700265220ustar00rootroot00000000000000# M2 — the maiden-marker rule **When:** a recognized maiden marker stands as a word in a name, as in "Jane Smith née Jones". **Then:** a marker that counts takes the words after it as the maiden name, and is itself dropped. - **Where a marker counts.** A recognized maiden marker counts only in the part of the name that holds the family name — the whole name where no comma divides it or before a suffix comma (C1), the part before a family comma — and only with a name word straight before it. A word of the leading title run is no name word there, a lone title included; neither is a word of the unambiguous suffix vocabulary, unless it is the first word of its part, a bare suffix word never opening the string (S2), nor a connective (P3). A particle is one: with nothing after it to attach to, it is no particle there. ``` "Doe nee Smith, Jane" → maiden="Smith" "Mai Le née Nguyen" → maiden="Nguyen" "abd née Jones" → maiden="Jones" "Jane van der Berg née Jones" → maiden="Jones" ``` - **It takes them up to the trailing run of post-nominals and titles that the end of the name reads as if the clause were not written** — the trailing rules (S2, S10, H5) asked over the words before the marker and the post-nominal or title words that end it, before any join — and always takes the first word after it, the marker having announced a name. The run so found ends the name: its words are the suffixes and titles that reading makes them, and no join reaches into it. ``` "Maria López née García Pérez" → maiden="García Pérez" "Jane Smith née Jones PhD" → suffix="PhD" "Jane Doe née Jones Smith V" → maiden="Jones Smith" "Jane Doe née Jones Smith V" → suffix="V" "John née Jones Smith V" → maiden="Jones Smith V" · boundary "Jane Doe nee Smith MA" → maiden="Smith" "Jane Doe nee Smith MA" → suffix="MA" "Jane Doe nee Smith Ma" → maiden="Smith Ma" · boundary "Jane Doe nee MA" → maiden="MA" · boundary "Jane Doe nee MA Smith" → maiden="MA Smith" · boundary "John née Jones Smith Ma" → maiden="Jones Smith Ma" "John nee Prof. ba MA" → maiden="Prof. ba" · boundary "Jane Doe nee Puig i Soler" → maiden="Puig i Soler" "Jane Doe nee Puig i" → maiden="Puig" · boundary "Jane Doe nee Puig i" → suffix="i" · boundary "Jane Doe nee Smith Prof." → maiden="Smith" "Jane Doe nee Smith Prof." → title="Prof." "Jane Doe nee Smith MA Prof." → suffix="MA" "Jane Doe nee Smith Prof. MA" → maiden="Smith" "Jane Doe nee Smith V Prof." → suffix="V" "Jane Doe nee Smith VI Prof." → suffix="VI" "Jane Doe nee Pope." → maiden="Pope." · boundary "Jane Doe nee Prof. Dr." → maiden="Prof." · boundary "Jane van der Berg nee Smith Prof." → title="Prof." "Jane van der Berg nee Prof. Pope. MA" → maiden="Prof." · boundary "Jane Doe nee Smith PhD Smith" → maiden="Smith" "Jane Doe nee Smith PhD Smith" → suffix="PhD Smith" "Jane van der Berg née y Jones" → maiden="y Jones" ``` - **What reports.** Where a trailing rule reads the part, a member of the ambiguous class (S4) that ends the maiden name is reported where the clause keeps it, whatever its case, and one in the run is reported where the run is read unless its capitals settled it (S6), so no word is reported twice and none the writing left open goes unreported. ``` "Jane Doe nee MA" → ambiguities=("suffix-or-name",) · boundary "Jane Doe nee Smith MA" → ambiguities=() "Jane Doe nee Smith Ma JD" → ambiguities=("suffix-or-name",) ``` - **A marker may be more than one word**, and is then recognized only whole and only where its words stand together: its own first word standing without the rest is not that marker, and neither is one the writer divided from the rest by a bracketed clause or by a comma. Whether the first word takes anything on its own is a separate question with a separate answer — it does exactly when it is a recognized marker in its own right, and the longer entry wins wherever both could match. ``` "Maria Kowalska z domu Nowak" → maiden="Nowak" "Anna z Nowak" → family="Nowak" · boundary "Anna z (domu) Nowak" → family="Nowak" · boundary ``` - **A marker taken this way also bounds a particle join arriving from its left** (P2), so the family name's particles stop at the marker instead of absorbing it; a marker left as a word bounds nothing. Where the bound leaves a family of nothing but particles, they are not in particle position and read as ordinary words (R2). ``` "Jane de la née Jones" → maiden="Jones" "Jane de la née Jones" → family="de la" ``` **Because:** a maiden marker announces that what follows it is the former family name; the marker is an announcement, not a name. ``` "Jane Smith née Jones" → maiden="Jones" ``` **Except:** - **In the given part after a family comma, in a part after a suffix comma, and behind a title, a suffix word or a connective, the marker is an ordinary word**; a name standing somewhere before it is not enough. ``` "Doe, Jane nee Smith" → maiden="" "Doe, Jane nee Smith" → middle="nee Smith" "Jane Smith, née Jones" → maiden="" "Smith, John, PhD née Jones" → maiden="" "Jane Doe PhD nee Smith" → maiden="" "Dr. Jr née Jones" → maiden="" · boundary "Jane and née Jones" → maiden="" "Dr. nee Smith PhD Prof." → maiden="" · boundary "Dr. nee Smith PhD Prof." → family="Smith" · boundary ``` - **A marker with nothing after it, or nothing before it, is just a word.** ``` "Jones née" → family="née" · boundary "née Jones" → family="Jones" · boundary "Jane van der Berg née" → family="van der Berg née" ``` - **A suffix word straight after the marker announces nothing**, and the marker is then a word. ``` "Jane née Jr y Jones" → maiden="" "Jane van der Berg née PhD" → family="van der Berg née" ``` - **Before a family comma no trailing rule reads the part**, so the clause ends at the first suffix word, and the words from it on read as that part reads them. No rule reads a member of the ambiguous class there either, and the member the clause keeps there is reported nowhere. ``` "Doe nee Smith Jr. Prof., Jane" → family="Doe Prof." · boundary ``` **Precedence:** - **A marker inside a delimited clause** ("Jane Doe (nee Smith MA)"): delimiters outrank every reading inside them. The whole span is the maiden name whatever its last word is, and whether or not the pair is a configured maiden delimiter (M1, M3): the writer drew the boundary, so no fork is called and nothing is reported. A word the writer left OUTSIDE the span is outside the clause and reads as it would anywhere else. Bracketed content ending in a period is the exception, because it never reaches this rule as a delimited clause: a trailing period makes the content suffix-shaped (M3 states this), so the brackets are dropped (S1) and the clause is read as if written bare. ``` "Jane Doe (nee Smith MA)" → maiden="Smith MA" "Jane Doe (nee Smith Ma)" → maiden="Smith Ma" "Jane Doe (nee Smith) MA" → suffix="MA" ``` **Accepted:** - The fullwidth-colon spelling arrives as one word, so the marker inside it goes unrecognized; #317 tracks whether it should peel. ``` "山田 花子 旧姓:佐藤" → maiden="" ``` - A given name with no current surname beside a maiden name reads by M4's lone-word rule, the lone name word before the marker being the family. A writer giving a given name with its birth name writes the clause delimited. ``` "Jane née Jones Smith" → maiden="Jones Smith" · boundary ``` - The run is what the trailing rules read, before any join — not the whole reading of the clause-free name. Where a particle chain would take the words of the run in that name, the run keeps them suffixes and titles. ``` "Jane Doe nee Smith DO DO" → suffix="DO DO" · boundary ``` - The first word after the marker is the maiden name whatever it is unless it is a suffix word, and a lone roman numeral is not one — it is shaped like an initial — so it is the maiden name where the clause-free name would read it as the suffix. ``` "Jane Smith née V" → maiden="V" · boundary ``` - Bracket content ending in a period is suffix-shaped (M3), so the brackets are dropped (S1) and the clause is read as if written bare — the delimiter precedence does not reach it. ``` "Jane Doe (nee Smith Prof.)" → title="Prof." ``` - A marker behind a credential is an ordinary word, and where the credential's run (S8) starts it takes the marker in with everything after it. ``` "Jane Doe Jr. nee Smith" → suffix="Jr. nee Smith" · boundary "Jones Jr. née Smith" → maiden="" · boundary ``` --- **Signal:** neighbor **history:** decisions/M2 · **implemented:** nameparser/_pipeline/_group.py, nameparser/_pipeline/_pieces.py derek73-python-nameparser-456b024/docs/design/rules/M3-marker-led-clause.md000066400000000000000000000053421527303271700263420ustar00rootroot00000000000000# M3 — the marker-led clause **When:** a bracketed clause opens with a recognized maiden marker, as in "Jane Smith (née Jones)". **Then:** a bracketed clause whose content opens with a recognized marker and carries a word after it reads as the maiden name, whichever bucket the enclosing pair sits in, the marker itself dropped as M1 drops it. - **Being keyed on the content rather than on the pair, this reaches a nickname pair's clause too** (N1), and M1's independence then governs what it produces: where a marker-led clause stands beside another marker-led clause, both read as maiden and join into one maiden name, leaving no nickname. **Because:** an enclosure says nothing about whether it means maiden, but a recognized marker word inside it does. The clause announces itself, so the caller does not have to declare the pair. ``` "Jane Smith (née Jones)" → maiden="Jones" "Jane (née Jones) Smith" → family="Smith" "Maria Kowalska (z domu Nowak)" → maiden="Nowak" "Jane Smith (née V)" → maiden="V" ``` **Except:** - **A marker with no word after it is just a word in brackets**, and so is a marker no separator divides from the name, the fullwidth-colon spelling M2 records. ``` "Jane Smith (née)" → nickname="née" · boundary "Maria Kowalska (z domu)" → nickname="z domu" · boundary ``` - **A marker followed by a comma is no marker**, whether the comma stands alone or is glued to either word — here and in M1's drop alike, so a clause the caller declared maiden keeps that marker. ``` "Jane (née , Jones) Smith" → nickname="née , Jones" · boundary "Jane (née ,Jones) Smith" maiden-parens → maiden="née ,Jones" · boundary ``` **Precedence:** - **A pair already configured for maiden names** ("Jane Smith (née Jones)" under a maiden pair): M1 governs and this adds nothing. - **Suffix-shaped content** ("Jane Smith (née Jr.)"): S1 takes it first, and the clause is no maiden name. ``` "Jane Smith (née Jr.)" → suffix="Jr." ``` **Accepted:** - The word taken after the marker is not tested for being a name word, so unlike M2's bare take this one does not stop at a suffix word — the same two words read one way bracketed and another way bare. This does not contradict the S1 example under Precedence: S1 asks whether the WHOLE clause is suffix-shaped, which the trailing period makes true of the one and false of the other, so only the PhD clause reaches this rule. ``` "Jane Smith (née PhD)" → maiden="PhD" "Jane Smith née PhD" → suffix="PhD" · boundary ``` --- **Signal:** neighbor **history:** decisions/M3 · **implemented:** nameparser/_pipeline/_extract.py, nameparser/_pipeline/_group.py, nameparser/_pipeline/_vocab.py M4-maiden-marker-makes-the-lone-name-word-the-family.md000066400000000000000000000047011527303271700342340ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/rules# M4 — a maiden marker makes the lone name word the family **When:** a maiden name has been read out of a name and one name word is left beside it, as in "Smith née Jones". **Then:** a maiden name standing beside exactly one name word makes that word the family name, whatever suffix or nickname stands beside it, an annotation beside the name being no part of the name, which is how H1 reads the same shape. - **How the maiden name was written does not enter into it:** a marker taking the words after it (M2), a marker-led clause (M3), and a clause in a pair the caller declared to mean maiden (M1) announce the same thing, and the last of those carries no marker at all. ``` "Smith (née Jones)" → family="Smith" "Smith (Jones)" maiden-parens → family="Smith" ``` - **Where the maiden name stood does not matter, only what is left beside it:** a marker inside the name takes the rest of it (M2), and one name word left that way is one name word. ``` "Jane née Jones Smith" → family="Jane" ``` - **A nickname holds nothing off:** where N3 has already named the family this rule finds nothing left to move, and where N3 declined — its count does not set a suffix aside — this rule names it. **Because:** a maiden name is a FORMER family name, and nobody announces the surname they used to carry where there is no surname beside it to tell it apart from. So the one name word left standing is the surname the bearer uses now, and writing the clause at all is what rules out the given-name reading O5's convention would otherwise leave it with. ``` "Smith née Jones" → family="Smith" "Smith née Jones PhD" → family="Smith" ``` **Except:** - **A word the vocabulary has claimed as a given name keeps that reading, and so does a word read as an initial** (T4, T5), which is nobody's family name. Both are beyond the rule's reach for one reason: it changes what POSITION would have decided, so it cannot overrule what a word already IS (mechanisms.md#TWO-LAYER-ASSIGN). ``` "abd née Jones" → given="abd" · boundary "J. née Jones Smith V" → given="J." · boundary "j nee smith" → given="j" · boundary ``` **Precedence:** - **A name carrying a TITLE** is H1's rather than this rule's, H1's given-name-title carve-out included, which keeps the word a given name. --- **Signal:** count **history:** decisions/M4 · **implemented:** nameparser/_pipeline/_post_rules.py derek73-python-nameparser-456b024/docs/design/rules/N-nicknames-and-quoted-names.md000066400000000000000000000006621527303271700300730ustar00rootroot00000000000000## Nicknames & quoted names (N) Background: a nickname is written beside the formal name, set off by quotes or brackets. Quotation conventions vary by language („…“, «…», “…”), several share characters — one convention's closer is another's opener — and the straight apostrophe doubles as a quotation mark and as a letter-like mark inside names (O'Connor). Which pairs delimit nicknames is caller configuration. derek73-python-nameparser-456b024/docs/design/rules/N1-default-delimiter-pairs.md000066400000000000000000000020111527303271700275460ustar00rootroot00000000000000# N1 — the default delimiter pairs **When:** a configured nickname delimiter pair encloses a clause beside a name, as in "Andrew (Andy) Perkins". **Then:** a clause enclosed by a configured nickname delimiter pair reads as the nickname and is lifted out of the name. - **A comma inside the clause is kept as written:** the comma divides nothing there, though the dots T2 and T3 name still divide a clause as they divide a name. ``` "Jane (Janey, Jo) Smith" → nickname="Janey, Jo" ``` - **An empty enclosure is simply dropped**, there being no clause to read. **Because:** a quoted or bracketed clause beside a name is an informal alias, not part of the name. ``` "Andrew (Andy) Perkins" → nickname="Andy" "Jean 'JD' Smith" → nickname="JD" "John 「Jack」 Smith" → nickname="Jack" "Anna () Smith" → nickname="" · boundary ``` --- **Signal:** neighbor **history:** decisions/N1 · **implemented:** nameparser/_pipeline/_extract.py, nameparser/_pipeline/_tokenize.py N2-same-character-quotes-and-shared-character-conventions.md000066400000000000000000000022241527303271700354600ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/rules# N2 — same-character quotes and shared-character conventions **When:** a quote character stands in a name where it could open or close a clause, either because its open and close are the same character or because two conventions share it, as in "Sean O'Connor". **Then:** a quote whose open and close are the same character opens only at a word start and closes only at a word end, so an apostrophe inside or at the end of a word is literal. - **Between conventions that share a character, position in the text decides:** the leftmost valid opener wins. ``` "Hans „Erster“ und “Zweiter” Müller" → nickname="Erster Zweiter" ``` - **A dangling-open report is suppressed where its character sits inside another pair's successful match**, being literal content there rather than an imbalance (A1 depends on that filter). **Because:** only a mark standing at word boundaries is quoting; anywhere else it is part of the word. ``` "Sean O'Connor" → family="O'Connor" "Mari' Aube'" → family="Aube'" · boundary ``` --- **Signal:** position **history:** decisions/N2 · **implemented:** nameparser/_pipeline/_extract.py derek73-python-nameparser-456b024/docs/design/rules/N3-lone-word-nickname-rule.md000066400000000000000000000035361527303271700275070ustar00rootroot00000000000000# N3 — the lone-word nickname rule **When:** a name is written as a nickname beside its name words, as in "'Smitty' Jones". **Then:** a name that is only a nickname and one name word reads that word as the family name. - **With two or more name words the ordinary positional reading applies.** ``` "'Smitty' John Jones" → given="John" · boundary ``` **Because:** a person set down as a nickname plus one name word is being identified by surname. ``` "'Smitty' Jones" → family="Jones" ``` **Accepted:** - The count does not set suffixes aside, so a nickname plus one name word plus a suffix reads the name word as given and leaves the family empty. ``` "'Smitty' Jones Jr." → family="" ``` - A title counts against the count too, but H1 then reads the title-plus-one-word name that is left, so the family is named after all — unless the title is a given-name title, which keeps the word in `given` and leaves no family, exactly as it does anywhere else. ``` "'Smitty' Dr. Jones" → family="Jones" "'Smitty' Sir John" → given="John" ``` - A marker-led clause is a maiden clause and not a nickname one (M3), so this rule does not reach a name written that way, and the one name word keeps the reading the bare spelling gives it. That reading is the family name all the same, M4 reaching both spellings from the other side. The two rules agree on THIS name and not in general: M4 counts name words alone, so where a suffix stands beside them it fires and this rule declines. The first Accepted bullet reads a bare nickname-plus-word-plus-suffix name as given with no family, and the same name carrying a maiden clause reads family instead. ``` "Smith (née Jones)" → family="Smith" ``` --- **Signal:** count **history:** decisions/N3 · **implemented:** nameparser/_pipeline/_assign.py derek73-python-nameparser-456b024/docs/design/rules/O-name-order.md000066400000000000000000000043051527303271700250130ustar00rootroot00000000000000## Name order (O) Background: written name order varies by convention: given-first (the library's default reading), family-first, and family-first with the given name last (Vietnamese, where the person is called by the last element, given names are frequently two syllables — the given_names view stays correct wherever the internal boundary falls — and quốc ngữ is Latin script, so no native-script signal exists at all). The order is declared by the caller or a locale pack, never detected — but a few conventions leave a recognizable trace in the name itself. Patronymics are one: East Slavic names carry a father's-name derivative with distinctive endings between given and family, and Turkic names use a standalone marker word ("oglu" son-of, "qizi" daughter-of) after the father's name. Where such a trace is present and unambiguous, an opted-in parser can restore the intended reading from a family-first listing. A declared order is a property of the DATA SOURCE rather than of any one string: the caller sets it to match how their records are written, and it governs what no vocabulary and no script license has already claimed (O4). A declared FAMILY-FIRST order outranks what the parser could infer from the shape of a particular name; the given-first reading is the parser's DEFAULT rather than a declaration it can tell apart from one, and the traces above are what refine it (O1, O2). And the declaration yields where a name's own script settles the order instead (W4, where a name written wholly in an East Asian script reads family-first whatever order the caller declared). Two consequences run through this document. Under the default given-first order a string opening with a never-given particle is a surname whose given name is simply absent, so the fold takes the rest of it (P1). Under a declared family-first order the caller has already said that what follows the family is not more surname, unless they have also said the family holds two names (O6) — so the fold stops there, and the rotations below, whose whole job is to RESTORE the default reading from a family-first listing, have nothing left to restore. A shape neither order settles is the family comma's job, and the parser does not guess at it. derek73-python-nameparser-456b024/docs/design/rules/O1-east-slavic-rotation.md000066400000000000000000000045031527303271700271130ustar00rootroot00000000000000# O1 — East Slavic rotation **When:** East Slavic patronymic handling is active, the default given-first order stands, and the last name word of a name with no comma carries a patronymic ending, as in "Сидоров Иван Петрович". **Then:** a name of exactly three name words — titles, suffixes and nicknames aside — whose last name word carries a patronymic ending and whose middle name word does not reads as family-first: the words are restored to given, patronymic, family. - **A middle word that also carries the ending blocks the reading**, because the surname itself may be patronymic-derived. ``` "Иван Петрович Абрамович" [ru] → family="Абрамович" · boundary ``` - **The restoration is the default order's work**: it recovers the given-first reading a family-first listing hides, so where the caller has DECLARED a family-first order there is nothing left to restore and position decides. **Because:** an East Slavic name written family-first still shows its patronymic — the distinctive ending identifies which word is the patronymic, and the patronymic sits next to the given name. ``` "Сидоров Иван Петрович" [ru] → family="Сидоров" "Sidorov Ivan Petrovich Jr." [ru] → family="Sidorov" ``` **Accepted:** - Under a declared family-first order the readings part on natural-order input, and the declaration wins. A family-first listing reads the same either way under FAMILY_FIRST — the first example in the fence is that same parse with FAMILY_FIRST declared, and under FAMILY_FIRST_GIVEN_LAST the listing's given and middle swap, so only the family is invariant — while with East Slavic handling active and FAMILY_FIRST declared the natural-order Иван Петрович Сидоров reads family Иван, given Петрович, middle Сидоров. That is the caller's declaration being honored on input they said was written family-first, not a defect (#384). No registered example annotation combines a pack with an order, so the parse is pinned in tests/v2/test_locales.py instead, and decisions/O1 records why options 2 and 3 were declined. --- **Signal:** shape: a patronymic ending on the last of exactly three name words, and not on the middle one **history:** decisions/O1 · **implemented:** nameparser/_pipeline/_post_rules.py derek73-python-nameparser-456b024/docs/design/rules/O2-turkic-rotation.md000066400000000000000000000030001527303271700261710ustar00rootroot00000000000000# O2 — Turkic rotation **When:** Turkic patronymic handling is active, the default given-first order stands, and the last name word of a name with no comma is a standalone patronymic marker, as in "Ali Ahmad Vali oglu". **Then:** a name of exactly four name words — titles, suffixes and nicknames aside — ending in a standalone patronymic marker reads family-first: the first name word is the family name, and the marker stays beside the father's name in the middle. - **The scope is O1's**: the restoration recovers the default reading, so a declared family-first order stands in its place. **Because:** a Turkic patronymic marker is a separate word that follows the father's name; a four-word name ending in one is a family-first listing. ``` "Ali Ahmad Vali oglu" [tr_az] → family="Ali" "Ali Ahmad Vali oglu Jr." [tr_az] → family="Ali" ``` **Accepted:** - Any other count of name words keeps its positional reading, even when that leaves the marker itself in a name field. ``` "Ali Ahmad oglu" [tr_az] → family="oglu" · boundary ``` - The order scope has this rule's own consequence reached by a second route — with Turkic handling active and FAMILY_FIRST_GIVEN_LAST declared, Ali Ahmad Vali oglu reads given oglu, the marker standing in a name field because the declaration put the name's last word there (#384). --- **Signal:** neighbor: a standalone patronymic marker ending a name of exactly four name words **history:** decisions/O2 · **implemented:** nameparser/_pipeline/_post_rules.py derek73-python-nameparser-456b024/docs/design/rules/O3-compound-family-names-written-unmarked.md000066400000000000000000000012251527303271700325450ustar00rootroot00000000000000# O3 — compound family names written unmarked **When:** compound-family handling is active, as in "Hassan Mohamad Ali". **Then:** every middle word joins the family name and is rendered before it; no word is a middle name. **Because:** several traditions write compound family names unmarked, so that every word after the given name belongs to the family name. ``` "Hassan Mohamad Ali" middle_as_family → family="Mohamad Ali" "Hassan Mohamad Ali" → family="Ali" · boundary ``` --- **Signal:** none in the name: a caller switch, which joins every middle word to the family **implemented:** nameparser/_pipeline/_post_rules.py derek73-python-nameparser-456b024/docs/design/rules/O4-positional-assignment-and-declared-order.md000066400000000000000000000021051527303271700330030ustar00rootroot00000000000000# O4 — positional assignment and declared order **When:** a name word no vocabulary has claimed stands in a name, as in "Mary Beth Smith". **Then:** words no vocabulary has claimed read by position. - **In the default given-first order** the first name word is the given name, the last is the family name, and everything between is middle names. - **In a family-first order** the first name word is the family; in family-first-given-last the given name comes from the end, the middles from between. **Because:** what no vocabulary claims can only be read by where it stands, under the order the caller declared. ``` "Mary Beth Smith" → middle="Beth" "Garcia Juan Carlos" family-first → family="Garcia" "Nguyễn Thị Minh Khai" family-first-given-last → given="Khai" ``` --- no-boundary: this is the default reading every other rule carves exceptions from; its boundaries are the other rules. **Signal:** position: where a word no vocabulary claimed stands, under the declared order **history:** decisions/O4 · **implemented:** nameparser/_pipeline/_assign.py O5-lone-name-words-convention-and-reporting-it.md000066400000000000000000000101551527303271700333400ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/rules# O5 — the lone name word's convention, and reporting it **When:** a name has exactly one name word, as in "Smith". **Then:** a name of one name word that nothing else has decided reads that word as the given name under the default given-first order, and as the family name under a declared family-first one, and carries a `given-or-family` ambiguity naming the field the convention chose. - **That is a convention rather than a determination** — the same input has to read the same way every time, so one of two equally consistent readings is fixed in advance — and it is not evidence about the word. - **The report is exactly as narrow as the convention**, so every rule named in Except silences it where it fires, and a family comma — a comma with a name segment after it — silences it too. - **A comma with nothing after it names no family and silences nothing**, so `سلمان،` and `Smith,` report where `Smith, Andrew` does not. - **A word with no letter or digit in it is no name word** and reports nothing (A2). **Because:** O4 reads a name by comparing where its words stand, and a name of exactly one name word gives it nothing to compare — the first name word is also the last, so the rule that decides every longer name is silent here. Nothing in such an input says whether the word is a given name or a family name: both readings fit it equally well, and the library still has to report one. ``` "Smith" → given="Smith" "Garcia" family-first → family="Garcia" "Andrew" → ambiguities=("given-or-family",) "Garcia" family-first → ambiguities=("given-or-family",) "Juan & Garcia" → ambiguities=("given-or-family",) "Smith Jr." → ambiguities=("given-or-family",) "'Smitty' Jones Jr." → given="Jones" "'Smitty' Jones Jr." → ambiguities=("given-or-family",) ``` **Except:** - **A title makes the word the family name (H1)**, except a given-name title, which addresses by given name. A comma with only a TITLE after it names no family, but the title still decides the field wherever it stands, so `John III, Dr.` is silent for H1's reason and not the comma's. ``` "Mr. Johnson" → family="Johnson" · boundary "Dr. Smith" → ambiguities=() · boundary "Sir John" → given="John" "Sir John" → ambiguities=() ``` - **Where the one name unit carries title vocabulary** in a word beside the one it places, or is itself title vocabulary left standing by the title peel, the doubt reported is `title-or-name` instead, which H4 and H7 state — so a title silences THIS kind, not every report at the site. ``` "John of Bishop" → ambiguities=("title-or-name",) ``` - **A script whose own convention settles the order (W4)** decides the word's field, and the report is silenced. ``` "毛泽东" → family="毛泽东" "毛泽东" → ambiguities=() ``` - **A nickname beside the word also makes it the family name (N3)**, except where a suffix stands beside them too, which its count does not set aside, so `'Smitty' Jones Jr.` keeps the convention's reading and its report. ``` "'Smitty' Jones" → family="Jones" · boundary ``` - **A maiden name beside the word also makes it the family name (M4)**, except where the vocabulary or the word's own shape has claimed the word already. ``` "Smith née Jones" → family="Smith" · boundary "Smith née Jones" → ambiguities=() ``` - **The word's own claim decides it**: a particle, a bound given name, an initial (T5, T4). ``` "abd née Jones" → given="abd" "abd née Jones" → ambiguities=() "j" → ambiguities=() ``` **Precedence:** - **Against every rule that DOES decide such a name**: that rule wins, and each of them carves out a case it declines to decide — where the convention is what is left. --- **Signal:** count: one name word that nothing else decided **history:** decisions/O5 · **implemented:** nameparser/_pipeline/_assign.py derek73-python-nameparser-456b024/docs/design/rules/O6-two-family-names.md000066400000000000000000000172671527303271700262540ustar00rootroot00000000000000# O6 — two family names **When:** two-family-name handling is active and a name with no family comma has three or more name words, as in "Gabriel García Márquez". **Then:** the family name takes the name word beside it, so the last two name words are the family — the first two under a family-first order. - **Under the default given-first order** the middle word standing right before the family joins it, and any middles before that stay middles. ``` "Gabriel García Márquez" two_family_names → family="García Márquez" "Ana María García López" two_family_names → middle="María" ``` - **Under a family-first order** the word after the family joins it, and what is left is laid out as the positions after a family, as P1's family-first fold lays out its own remainder. ``` "García Márquez Gabriel" family-first+two_family_names → given="Gabriel" "García Márquez Ana María" family-first-given-last+two_family_names → given="María" ``` - **A name word is counted as P1's fold counts one**, so a particle run counts with the word it attaches to: "de la Vega" is one word and takes the word before it. ``` "Juan Pérez de la Vega" two_family_names → family="Pérez de la Vega" ``` - **Titles, suffixes and nicknames are no name words**, and a comma that sets off only suffixes leaves the name before it to this rule. ``` "Gabriel García Márquez Jr." two_family_names → family="García Márquez" "Dr. Ana García López" two_family_names → family="García López" "Gabriel García Márquez, PhD" two_family_names → family="García Márquez" "Ana García (Anita) López" two_family_names → family="García López" ``` **Because:** across Spain and most of Latin America a person carries two family names, the father's and then the mother's, and writes both after the given names. Given-first, that name is the same string shape as a name with a middle name, so no rule can read it from the words; the caller who knows the data is written that way says so. ``` "Gabriel García Márquez" → family="Márquez" · boundary "Ana García" two_family_names → family="García" · boundary ``` **Except:** - **A family that already holds two words keeps what it has**: words a connective joins count as their words, a hyphen joins two words without making them one, and a particle chain counts the words it chained. ``` "José María Ortega y Gasset" two_family_names → family="Ortega y Gasset" "Ana María García-López" two_family_names → family="García-López" "Ana María García–López" two_family_names → family="García–López" "Ana María García-López" two_family_names → family="García-López" "Ana María de la Vega Rodríguez" two_family_names → family="de la Vega Rodríguez" ``` - **A family comma decides by itself**, so the part before it is the whole family. ``` "López, Ana García" two_family_names → family="López" "García López, Ana" two_family_names → family="García López" ``` - **A family comma with nothing before it still names no family** (C15), and the name after it reads as the default reads it. ``` ", Ana García López" two_family_names → family="" ``` - **What is taken must be one name word a family name could be**: a part counting as two words (a hyphenated given name, a bound given-name pair, P5) stays where it is, and so do an initial written alone, which writes a given name, and a mark or number with no letter in it. A particle chain (P2) is never split, so where taking it would leave no given name the rule declines. ``` "Ana María-José López" two_family_names → family="López" "Ana abdul Rahman López" two_family_names → family="López" "Ana M. García" two_family_names → family="García" "Ana María García - López" two_family_names → family="López" "Ana María 3 López" two_family_names → family="López" "Juan de la Vega Rodríguez" family-first+two_family_names → family="Juan" ``` - **An order a name's script set is not a declared one** (W4), and no script it keys writes two family names as separate words, so a name its script reads in an order other than the declared `name_order` is left alone. ``` "王 小 明" two_family_names → family="王" "王 小 明" family-first-given-last+two_family_names → family="王" ``` **Precedence:** - **Against compound-family handling** ("Ana María García López" with both switches on): this rule reads first and O3 then folds whatever middles are left, so under the given-first order the two together read as O3 alone. ``` "Ana María García López" two_family_names+middle_as_family → family="María García López" ``` - **Against a patronymic rotation** ("Ivanov Ivan Ivanovich" with the East Slavic rotation on): where the rotation fires it wins, because it already decided which word is the family, and this rule finds no middle standing before that family to take. Where it does not fire, this rule reads the name as any other (Accepted). - **Against P1's family-first reach** ("de Mesnil Jean Paul" under a family-first order): this rule wins and the family takes one name word past P1's one, because the switch is the caller saying the family holds two. ``` "de Mesnil Jean Paul" family-first+two_family_names → family="de Mesnil Jean" ``` - **Against a particle attached at the end of a family-first name** ("Beethoven Ludwig Maria van"): P6 attaches the particle first and this rule then takes the word after the family, leaving the particle where P6 put it. ``` "Beethoven Ludwig Maria van" family-first+two_family_names → family="van Beethoven Ludwig" ``` **Accepted:** - A name with one family name behind two given names loses its second given name to the family, which is the shape the switch cannot tell from the one it is for: most Argentine and English names of three words or more read wrong under it, and so do Portuguese and Brazilian names whose writer gave the last family name alone as the family. That is why it is off by default and a caller turns it on only for data written this way. ``` "Juan Carlos Pérez" two_family_names → family="Carlos Pérez" ``` - A patronymic is a name word like any other here, a patronymic rule on or off, so where no rotation fired it can join the family. ``` "Petrov Ivan Sergeevich" family-first+two_family_names → family="Petrov Ivan" ``` - A lone particle the declared order left as the family counts as one word, R2 reading it as an ordinary name word, so the word after it joins. ``` "van Gogh Ana María" family-first+two_family_names → family="van Gogh" ``` - Where a name's script order and the declared one agree, nothing tells the two apart, so under a declared family-first order a spaced Han or Hangul name is read like any other. ``` "王 小 明" family-first+two_family_names → family="王 小" ``` - Initials written run together carry no initial's shape, and neither does an abbreviated given name, so either is taken. ``` "Ana J.R. García" two_family_names → family="J.R. García" ``` - The rule counts words, not languages, so a particle surname of any origin takes the word before it. ``` "Ana María van der Berg" two_family_names → family="María van der Berg" ``` - The rule reports nothing: the caller declared the convention, as a declared order is followed without a report, and a report on most names of three words or more would teach the caller to ignore the field. --- **Signal:** none in the name: a caller switch, the family's words and the taken word's shape read as written to see whether the family already holds two **history:** decisions/O6 · **implemented:** nameparser/_pipeline/_post_rules.py derek73-python-nameparser-456b024/docs/design/rules/P-particles-and-surname-prefixes.md000066400000000000000000000011401527303271700307760ustar00rootroot00000000000000## Particles & surname prefixes (P) Background: particles ("de", "la", "van", "von", "bin") link forward to a surname and are written as part of it. Some are never anyone's given name; others ("Van", "Bin") are ordinary given names in some cultures, so the vocabulary distinguishes never-given particles from ambiguous ones, and only the never-given ones license special treatment. A separate small vocabulary binds forward to a GIVEN name instead: words like "abdul" that are not complete given names alone (P5). Which particles fall on which side of the never-given line is its own open question (#360). derek73-python-nameparser-456b024/docs/design/rules/P1-lone-particle-fold.md000066400000000000000000000127141527303271700265270ustar00rootroot00000000000000# P1 — lone particle fold **When:** a never-given particle opens the name and has another name word behind it to attach to, as in "de Mesnil". **Then:** the particle run and the name words it takes are family, under every name order; how many it takes depends on the order or a family comma (below). - **The run is every particle in sequence**, never-given and ambiguous alike: "de la Vega" is one group, not "de" plus a separate "la Vega". An ambiguous particle keeps whatever reading its position gives it. ``` "de la Vega" → family="de la Vega" "de los Santos" → family="de los Santos" ``` - **A title does not move the opening position** (P4). ``` "Sir de Mesnil" → family="de Mesnil" ``` - **Under the default order the run takes the rest of the name.** That is a reading rather than a gap in one: a never-given particle marks where a surname BEGINS, surnames are routinely several words, and no declaration has said this one ends before the string does. So `de Mesnil Jean` reports the whole string as the family, which is 1.4.0's reach as well, by design (decisions/P1, #471). ``` "de Mesnil Jean" → family="de Mesnil Jean" ``` - **Under a family-first order the run takes exactly ONE name word**: declaring that order asserts that what follows the family is not more surname. One name word means one UNIT — a particle chain (P2), a conjunction join (P3) or a bound given-name pair (P5) is taken whole or not at all. Two-family-name handling (O6), switched on, takes one name word more where the unit taken is one word. What is left over is not read by O4's rule for a whole name, which would make the first leftover a second family name; it is laid out as the positions AFTER the family in the declared order, the family slot being already filled. ``` "de Mesnil Jean" family-first → family="de Mesnil" "de Mesnil Jean" family-first → given="Jean" "de la Vega y Santos Juan" family-first → family="de la Vega y Santos" "ibn Awf abdul Rahman" family-first → given="abdul Rahman" "de la Cruz Juan Carlos" family-first-given-last → given="Carlos" ``` - **After a family comma the run takes the rest of that part, whatever order is declared**: the comma has already fixed the surname, so there is no positional read left for an order to narrow. ``` "Smith, de Mesnil Jean" family-first → family="Smith de Mesnil Jean" ``` **Because:** a particle OPENING a name has the whole rest of the name to link forward to, so it is doing a particle's work and the piece it heads is a surname written out in full. That is evidence about the writing, not about the word, which is why no declared order contradicts it. ``` "Mc Donald" → family="Mc Donald" "van Gogh" → given="van" · boundary ``` **Precedence:** - **Against a declared order** ("de Mesnil" under a family-first order): the particle claims the FAMILY rather than a given name under every order, because this is evidence about how the name is written, and a declared order governs only what no vocabulary has claimed (O4) — the same precedence the script license takes in W4. **Accepted:** - The default-order reach has two faces, and the second is a real cost. A surname-only string is read right, which is what the reach is for; a family-first listing read under the default order is read wrong, all of it landing in the family. The remedy is the declaration or the comma — declare a family-first order, which gives family `de la Cruz`, given `Juan`, middle `Carlos`, or write the family comma. What NEITHER order settles is the comma's alone: `de la Family Family Given` and `de la Family Given Given` are the same string shape, and no reading of the words tells them apart. ``` "de la Torre Vega" → family="de la Torre Vega" "de la Cruz Juan Carlos" → family="de la Cruz Juan Carlos" ``` - A bare "de" has nothing to fold into, so it is a lone name word and reads as the declared order reads one (O5): the given name under the default order, the family name under a family-first one. ``` "de" → given="de" "de" family-first → family="de" ``` - Stopping the run leaves a MIDDLE where the fold never left one before, so O3 has something to claim that it could not reach until now. The family it then reports is discontiguous in the input — words 1-3 plus word 5 — and renders the folded word first, which is R1's order, not this rule's doing. ``` "de la Cruz Juan Carlos" family-first+middle_as_family → family="Carlos de la Cruz" ``` - Only the OPENING position is this rule's subject. A particle standing where a family-first order puts the given name is not folded: that slot holds what the caller DECLARED to be the given name, and the never-given vocabulary supplies a reading where position leaves the question open rather than vetoing one position has already given. A particle ending the name is P6's, and only where it landed in a MIDDLE. A particle chain standing inside the name is grouped normally (P2) and positioned by the declared order, so a family-first reading may report it as the given name; what the vocabulary forbids is the bare particle reading as a given name, not any name part that begins with one. ``` "Mesnil de" family-first → given="de" "Juan de la Vega" family-first → family="Juan" ``` --- **Signal:** position **history:** decisions/P1 · **implemented:** nameparser/_pipeline/_post_rules.py derek73-python-nameparser-456b024/docs/design/rules/P2-particles-join-forward.md000066400000000000000000000107541527303271700274370ustar00rootroot00000000000000# P2 — particles join forward **When:** a particle stands inside a name with name words after it, as in "John van der Berg". **Then:** a particle joins the words after it into one name part, the join running until the next particle starts a group of its own, a trailing suffix begins, a maiden marker takes the words after it (M2), or the name ends; the final group reads as the family name, and earlier groups read by position. - **The chain begins wherever the name begins**, and a preceding title does not move that point (P4). ``` "Dr. John van der Berg" → family="van der Berg" ``` - **Where a trailing suffix begins** is the trailing run S2 reads, once, before the chain is made and counting the chain's own run as the one name word it will be, so the chain cannot change what it counts: a trailing roman numeral S10 reads as the generation, or a bare acronym with words to spare, ends the chain as a suffix word does, and so does a bare ambiguous acronym written in capitals in a mixed-case name (S6), which needs no words to spare. In the given part after a family comma such a member ends the chain wherever it stands, since the run S8 starts behind the given word takes it and every word behind it; elsewhere a member the trailing read does not take stays the chain's. ``` "Smith, Freiherr von Berg MA Jones" → given="von Berg" "Freiherr von Berg MA Ma" → family="von Berg MA Ma" · boundary "John van der Berg PhD" → family="van der Berg" "John van der Berg V" → family="van der Berg" "John van der Berg V" → suffix="V" "john van der berg ma" → suffix="ma" "John van der J. V" → family="van der J. V" · boundary "Freiherr von Berg MA" → family="von Berg" "Freiherr von Berg MA" → suffix="MA" "Freiherr von Richthofen V" → family="von Richthofen V" · boundary ``` - **The same acronym written Title-case in a mixed-case name ends nothing** and joins the chain as any name word does, words to spare or not, and the fork the chain called is reported there exactly as it is where no particle stands (S4). ``` "John van der Berg Ma" → family="van der Berg Ma" "John van der Berg Ma" → ambiguities=("suffix-or-name",) ``` - **Where P1's fold has claimed the opening, the fold decides the family instead** — and may take only PART of the final group, since it counts name words and the group is one part. ``` "de la Cruz Juan Carlos" family-first → family="de la Cruz" ``` **Because:** a particle is written as part of the surname it precedes, and a title stands outside the name entirely. ``` "John van der Berg" → family="van der Berg" "John van der Berg Smith" → family="van der Berg Smith" "Vincent van Gogh van Beethoven" → middle="van Gogh" "Juan de" → family="de" · boundary "John van der Berg née Jones" → family="van der Berg" ``` **Accepted:** - A particle of the unambiguous suffix vocabulary too (vd, mc) is a suffix piece to the peel, so where it opens the trailing run the chain stops before it as before any suffix word, and the peel takes it; where it continues a prefix run, the run takes it as a particle, as P6 reads it after a comma — and there the chain reports nothing, the particle reading being P6's fork rather than S4's. ``` "John Smith Mc V" → suffix="Mc V" "John van Mc" → family="van Mc" ``` - The same carve-out covers `do`, the one word that is both a particle and an AMBIGUOUS credential acronym: where a particle run takes it, the word is the run's and the chain reports nothing, whatever case it is written in — so `Anh van Do` is silent where `Anh Do`, with no particle standing, reports. A word that is both belongs to the particle run and to P6's fork, not to S4's. ``` "anh van do" → family="van do" · boundary "anh van do" → ambiguities=() · boundary "Anh van Do" → family="van Do" · boundary "Anh van Do" → ambiguities=() · boundary ``` - A caller wanting the combined double-surname reading (#132's ask) has it as the surnames view rather than the family field. ``` "Vincent van Gogh van Beethoven" → surnames="van Gogh van Beethoven" ``` --- **Signal:** neighbor: the words following a particle, up to the next particle, suffix run or marker **history:** decisions/P2 · **implemented:** nameparser/_pipeline/_group.py, nameparser/_pipeline/_pieces.py, nameparser/_pipeline/_post_rules.py derek73-python-nameparser-456b024/docs/design/rules/P3-connectives.md000066400000000000000000000112701527303271700253650ustar00rootroot00000000000000# P3 — connectives **When:** a recognized connective stands between name words, as in "Juan y Eva Garcia". **Then:** a recognized connective joins its neighbors into one name part, connective runs included — except a single-letter connective in a three-word name, which stays a name word, and a single-letter connective that reads as an initial instead (P8), which never joins. - **The joined part is ONE name word wherever another rule counts them**, so a rule taking "one name word" takes the whole join and never half of it — except C3's count of the words before a comma, which counts particle surnames alone and states why. H1 is the counting rule that shows the one-word clause today: a title plus the join reads the whole join as the family, where the same two words unjoined are two name words and H1 does not fire. P1's leading run is the second (#395): its run takes the "Vega y Santos" join whole or stops before it. ``` "Mr. Jack and Jill" → family="Jack and Jill" "Mr. Jack Jill" → given="Jack" ``` - **The three-word carve-out is about the written FORM, not the word**: it counts letters, so a symbol connective joins at any length, and it reaches every single-letter connective the vocabulary holds — Catalan i and Cyrillic и/і/й and Arabic و as well as y and e. A letter written as a base letter plus a combining mark (NFD) that composes to one character is one letter to every count here and in P8, and a period-marked initial written that way is one initial (#665), so the last two examples below, both typed decomposed, read as their composed twins. ``` "Juan y Garcia" → middle="y" · boundary "Juan & Garcia" → given="Juan & Garcia" "Josep i Rovira" → middle="i" "John й Smith" → middle="й" "É. y É. Smith" → given="É." ``` - **A connective counts as a name word wherever this rule counts them, whatever else the vocabulary says the word is, where it is placed to join.** A word can be a connective and a generation at once — the Catalan link is also the roman numeral one — and counting it as the generation would let a connective raise the bar for its own join, so the shortest linked name, which is the commonest one, would be the only one that failed to link; while counting it as a connective where it joins nothing lowers the bar for a different connective's join instead. ``` "Josep Carod i Rovira" → family="Carod i Rovira" "Carod i Rovira, Josep" → family="Carod i Rovira" ``` - **A connective that is also generational vocabulary joins only where a name word stands on each side of it** — a word the rest of the parse reads as a name word rather than as a generation, a credential or an honorific, looked for past any run of connectives standing between. A connective with nothing to its right is connecting nothing, and a word of that vocabulary ending a name, or standing before the credential a name ends with, is the generation it also spells wherever S10 reads one there. ``` "John Quincy Smith i" → suffix="i" "Carod i" → suffix="i" "Josep Lluis Carod i III" → suffix="i III" "Carod y de Rovira i" → middle="y" ``` - **A separator the caller declared, standing in a trailing suffix part, ends the search there as a comma would**, so a connective never joins across it (C10); anywhere else it is a word. - **Both questions asked of a name here and in P8 — how many words it has, and whether it is written in one case — are asked of the name's OWN words**: a maiden marker taken as one, and the words it takes (M2), are not among them, and neither is a delimited clause (N1, M1). So a clause beside the name changes neither whether the connective joins nor how a letter in the name reads, and a letter inside such a clause is the clause's word, read by T4 and not by P8. The two questions part at one point: a marker the pass declines and leaves as a word (M2) is a word, and counts toward the three; whether its case is asked is P8's to say. ``` "Juan y Garcia née Jones" → middle="y" ``` **Because:** connective words ("y", "of the") bind name words into one name part; but a single letter in a short name is more likely an initial than a connective. ``` "Juan y Eva Garcia" → given="Juan y Eva" "Juan and Garcia" → given="Juan and Garcia" ``` --- **Signal:** neighbor: a connective between name words, a single-letter one in a three-word name staying a name word **history:** decisions/P3 · **implemented:** nameparser/_pipeline/_assign.py, nameparser/_pipeline/_comma.py, nameparser/_pipeline/_group.py, nameparser/_pipeline/_pieces.py, nameparser/_pipeline/_post_rules.py derek73-python-nameparser-456b024/docs/design/rules/P4-particle-at-the-front-of-the-name.md000066400000000000000000000032741527303271700312620ustar00rootroot00000000000000# P4 — a particle at the front of the name **When:** a particle stands in the name's leading position, as in "Van Johnson". **Then:** a particle in the name's leading position chains nothing: the words stay separate, and any surname reading the name gets comes from the fold (P1) or from position (O4), never from a join. - **Chaining nothing is why a title before a leading particle changes nothing** (the title is not a name word), and why "Van Johnson" is a given-name reading at all. ``` "Sir de Mesnil" → pieces=[["Sir"], ["de"], ["Mesnil"]] ``` - **An unlisted abbreviation before the particle is as transparent as a listed title**, since assign reads it as one (H2). ``` "Xyz. van Johnson" → given="van" ``` **Because:** a particle links forward from inside a name; at the very front there is no name yet to be inside. ``` "Van Johnson" → given="Van" "John van der Berg" → pieces=[["John"], ["van", "der", "Berg"]] · boundary ``` **Except:** - **A word in the leading titles that is both a title and a particle puts the name's leading position on itself**, so the particle behind it is inside a name ('Freiherr von Berg'); of several such words the last does, so no particle chains inside the titles (#624). The titles are those written, before H3 gives the last one back to the name, so each such word reads as a title unless H3 gives it back. ``` "Freiherr St John Smith" → title="Freiherr St" "Freiherr St John Smith" → given="John" ``` --- **Signal:** position: a particle in the name's leading position **history:** decisions/P2 · **implemented:** nameparser/_pipeline/_group.py, nameparser/_pipeline/_pieces.py derek73-python-nameparser-456b024/docs/design/rules/P5-bound-given-names.md000066400000000000000000000171471527303271700263760ustar00rootroot00000000000000# P5 — bound given names **When:** a recognized bound given-name word stands in a name with a word after it, as in "abdul salam ahmed salem". **Then:** a recognized bound given-name word joins the word after it into one given name, where the name has a name word to spare or a family comma or a given-name title has lifted that need (below). - **It needs a name word to spare**, so two name words alone do not join — the second is the family name — except after a family comma, where the family is already fixed, or after a given-name title, which asserts that a given name follows (H1): there the two words join and the name has no family — two name WORDS, so neither a particle chain (P2), which is the family name, nor a suffix word is joined. A run of several titles is read as H1 reads it, by the title that does the addressing, which is its last. ``` "abd Allah" → given="abd" "Abu Bakar" → given="Abu" · boundary "Dr. abdul salam" → family="salam" · boundary "Salam, abd Allah" → given="abd Allah" "Sir abdul salam" → given="abdul salam" "Sir abdul salam Jr" → given="abdul salam" "Sir abdul van der Berg" → family="van der Berg" · boundary "Sir abdul Jr Smith" → given="abdul" · boundary ``` - **What there is to spare is what the name's trailing reading leaves**: the trailing suffix run (S2) and the trailing title run (H5), each read over what the other leaves until neither takes anything more, are read once, before the join is tried, and the name words they leave are the words to spare. A trailing roman numeral the run takes (S10), or a bare acronym it takes, or a trailing title word the run takes, is no word to spare; a bare ambiguous acronym is a name word wherever the peel does not take it. The join joins two name words into one and, read after the trailing run, changes no suffix reading: a word the run left as a name word stays one, whatever the join makes of the word in front of it. ``` "abdul Smith V" → family="Smith" "abdul Smith V" → suffix="V" "abdul Smith Jr V" → family="Smith" "abdul Smith Jr Ma" → given="abdul" "abdul Smith Jr Ma" → suffix="Jr Ma" "abdul Smith Ma" → given="abdul Smith" "abdul Smith Berg Ma" → middle="Berg" · boundary ``` - **The marker and the words it will take are not among the words to spare**: they leave the name, so counting them asks the question about a name that will not exist. A marker left as a word that a particle join (P2) has already taken travels with that join. ``` "abd Berg née Jones" → family="Berg" "abd Allah Smith née Jones" → given="abd Allah" "abd née Jones" → given="abd" ``` - **After a family comma the family is fixed and the joined pair is the given whatever follows**, so the reserve there reads no peel: the join stands whenever the word after the bound word is a name word. ``` "Berg, abdul Prof. Ali" → given="abdul Prof." · boundary ``` - **A title word standing in the name is a name word (H3) and joins like one, and so is a particle the chain has not taken (P2)**: the pair is a given name whatever tag the word carried. ``` "abdul Sir Smith Berg" → given="abdul Sir" "Berg, abdul van" → given="abdul van" ``` **Because:** some given-name words are incomplete alone — "abdul" is a bound form that the next word completes. ``` "abdul salam ahmed salem" → given="abdul salam" "mohamad ali smith" → given="mohamad" · boundary "abdul V Smith" → given="abdul V" · boundary ``` **Except:** - **The join never absorbs a marker standing as a word of its own** — a marker is not a name word (M2) — nor a word of the unambiguous suffix vocabulary (S2), wherever position will then place it, nor, after a family comma, a member of the ambiguous class its capitals settle as the credential in a name of mixed case (S6), which the given part's own reading then takes, nor a credential split across a space (S12) that the given part after a family comma holds, nor a title word a trailing rule reads as a title: one the trailing title run (H5) takes from the given part after a family comma, read as the comma-less name reads it with the bare salutations H8 adds, or any title word in a part past the second comma (C2). ``` "Berg, abdul MA" → suffix="MA" "Berg, abdul ma" → given="abdul ma" "Abdul MA Smith" → given="Abdul MA" · boundary "abdul Jr Smith Berg" → given="abdul" "abdul Jr Smith Berg" → middle="Jr Smith" "abdul Ph. D. Smith Berg" → suffix="Ph. D." "Rahman, Abdul MB ChB" → suffix="MB ChB" "Berg, abdul Prof." → given="abdul" "John Smith, Jr., abdul Dr. Smith" → title="Dr." "Berg, abd née Jones" → given="abd" · boundary ``` - **A particle the chain has not taken that is of the unambiguous suffix vocabulary too (vd, mc)** is declined as the suffix piece it is, though such a particle otherwise joins. ``` "Berg, abdul vd" → family="vd Berg" ``` **Precedence:** - **A bound word that is also a particle** ("Abu Bakar Salim"): where the join fires, it is read as the bound word: the join outranks the leading-position reading (P4) and no fork is reported; where the reserve blocks the join, P4's reading and its fork stand. ``` "Sir Abu Bakar" → given="Abu Bakar" "Abu Bakar Salim" → given="Abu Bakar" ``` - **A word that is BOTH bound-given and suffix vocabulary** ("abd Allah Smith", "Smith, Abd"): position decides and both readings survive: leading, it is the bound word; trailing, it is the suffix (S2). In the given slot after a family comma the suffix reading wins. ``` "abd Allah Smith" → given="abd Allah" "Smith, Abd" → suffix="Abd" ``` - **A trailing particle after a family comma** ("Berg, abdul van"): the join runs before the trailing particle's attachment (P6) sees the name. **Accepted:** - After a family comma the join stands though, unjoined, assign would read the word it takes as the suffix — the family is fixed there and the pair is the given; and a bare ambiguous acronym the peel does not take joins as any name word does. ``` "Berg, abdul V" → given="abdul V" "abdul Ma Smith" → given="abdul Ma" ``` - A given-name title plus a bound pair leaves the family empty, as H1 accepts for "Sir John" — the input names no family — under a family-first order too: the join is order-blind, and H1 moves the pair that order placed in the family back to the given name, as it does "Sir John" there. ``` "Sir abdul salam" → family="" "Sir abdul salam" family-first → given="abdul salam" "Sir abdul salam" family-first → family="" "Sir abdul salam" family-first-given-last → given="abdul salam" ``` - The given part after a family comma reads the words behind a title by its own walk, and where that walk reads them otherwise than the comma-less name does, the join is declined and no title is read — V before a third comma part stays a name word there, and a title word that is also a particle is attached by P6 (`Berg, abdul Prof. V, Jr.` reads middle 'Prof. V'; `Berg, abdul St.` reads family 'St. Berg'), each as the same name with an ordinary given word reads. --- **Signal:** neighbor: a bound given-name word and the name word after it, with a word to spare **history:** decisions/P5 · **implemented:** nameparser/_pipeline/_group.py, nameparser/_pipeline/_post_rules.py derek73-python-nameparser-456b024/docs/design/rules/P6-trailing-orphan-particle.md000066400000000000000000000274421527303271700277570ustar00rootroot00000000000000# P6 — the trailing orphan particle **When:** a particle ends the name — trailing words that hold no name behind it aside (below) — as in "Beethoven, Ludwig van". **Then:** where something has already named the family and left the particle in a position that means nothing — after a family comma, or in the middle of a FAMILY_FIRST reading — a particle ending the name attaches to that family name and is written before it. - **A post-nominal does not end the name for this purpose**: it is written BEHIND the particle in this listing, so the run is found by looking past trailing words that hold no name — unless such a word is itself particle vocabulary, which makes it part of the run rather than something to look past. ``` "Berg Jan de Jr." family-first → family="de Berg" "Berg Jan de Jr." family-first → suffix="Jr." ``` - **A particle an index form writes apart with its elision or its hyphen still on it is a particle like any other here**, where the vocabulary lists that spelling — the elided "d'" and the Arabic article in its plain and assimilated spellings, "al-" among them — and of the never-given kind, since written apart the word is the article and nothing else. It joins with a space like every particle; written glued to the family ("d'Ivoi") it is one word and this rule never sees it. ``` "Ivoi, Paul d'" → family="d' Ivoi" "Rashid, Harun al-" → family="al- Rashid" "Zaʿīm, Ḥusnī az-" → family="az- Zaʿīm" ``` - **Without a comma, a declared family-first order has named the family in the same way** and the attachment fires there too — but only where the run ENDS the name and stands in a MIDDLE — the one position that means nothing for it, middles being further given names, which a particle is not. Those two together name the order without asking it: the default order ends with the FAMILY and FAMILY_FIRST_GIVEN_LAST with the GIVEN name, so only FAMILY_FIRST can put a name's last word in a middle at all. Ending the name is not implied by the slot and has to be said. Nothing is re-laid-out either — under the order this can reach, the roles run family, given, middle, middle…, so dropping a trailing middle leaves every other piece where it was; that is a property of FAMILY_FIRST alone, and it is the second thing the two tests secure. ``` "Jong Anke de" family-first → family="de Jong" "Jong Anke de" family-first → given="Anke" "Ménil Christophe de" family-first → family="de Ménil" "Ménil Christophe de" family-first → given="Christophe" "Beethoven Ludwig van" family-first → family="van Beethoven" "Ménil Christophe de" family-first-given-last → given="de" "Ménil de" family-first → given="de" · boundary ``` - **Nothing is asked about the WORD**, and that is what lets one rule read both traditions: the same vocabulary that spells the Dutch tussenvoegsel spells the Vietnamese given name, and the declared order separates them where a comma cannot — one order, not both, as an Accepted bullet below records. - **A family comma with nothing before it** (C15) names no surname but marks where one goes, so the attachment fires there as after any family comma and the particle is the family alone. It fires ahead of H1, which then finds a given word beside a family rather than one name word. ``` ", Jane van" → family="van" ", Dr. Jones vd" → given="Jones" ", Dr. Jones vd" → family="vd" ``` - **Where nothing has named the family, the particle is read where it stands**, and a particle CAN be a given name there: the never-given vocabulary supplies the reading position leaves open, and forbids nothing (parse("de") reports given "de", and so does every other word in that set). - **What reports.** After a family comma, a particle that is also of the AMBIGUOUS credential class (`do`), where neither S6's capitals nor S7's company nor a run a settled member starts reaches it (below), attaches, and the report gives it this rule's particle fork rather than S4's credential one. The fork is reported, in the kind naming the reading this attachment OVERRODE — what the run was read as before it fired, not what its words are. Where the run was read as a post-nominal, which is the S2 reading this rule outranks, the report is suffix-or-name. Otherwise, where the run holds an ambiguous particle, the reading overridden is that word as a name word, and the report is particle-or-given. Where the attachment overrode neither — a never-given particle read as a name word, which it could never have been — nothing was decided and nothing is reported. So after a family comma `do`, which sits in the AMBIGUOUS acronym half and was already read as a name word, reports particle-or-given while vd and mc report suffix-or-name, and a run of several words read as name words reports on its ambiguous member if it has one and otherwise reports nothing, whatever its words could have been read as standing alone. Both are pinned in tests/v2/cases.py. ``` "Nguyen, Thi Van" → ambiguities=("particle-or-given",) "Berg, Jan vd" → ambiguities=("suffix-or-name",) "Jong, Piet de" → ambiguities=() · boundary "Beethoven Ludwig van" family-first → ambiguities=("particle-or-given",) "Jong Anke de" family-first → ambiguities=() · boundary ``` **Because:** a particle ending the name has nothing to link forward to, so it is not doing a particle's work there. What it is doing instead is decided by what the WRITING says, not by the word: a family already named, with the particle left where its position means nothing, is how Dutch and Flemish names are listed ("Beethoven, Ludwig van", the tussenvoegsel trailing the given name but belonging to the surname). ``` "Jong, Anke de" → family="de Jong" "Beethoven, Ludwig van" → family="van Beethoven" "Berg, Jan van der" → family="van der Berg" "Vega, Juan de la" → family_particles="de la" "Beethoven, Ludwig van" → family_base="Beethoven" "Beethoven, Ludwig van" → family_particles="van" "Maria Luisa y de la Cruz" → family="la Cruz" · boundary "de Anke van y" family-first-given-last → middle="van" · boundary ``` **Except:** - **After a family comma, a name whose only given word is the particle keeps it**: at least one given word must remain (the words-to-spare test S4 applies to ambiguous suffixes). ``` "Nguyen, Van" → given="Van" · boundary ``` - **Without a comma, the family must hold a base of its own**, since a family that is all particles is not a family written beside anything (R2). ``` "van Berg Jan de" family-first → family="van" · boundary ``` **Precedence:** - **After a family comma, a word that is BOTH a particle and suffix vocabulary** ("Berg, Jan vd"): this attachment outranks the suffix reading (S2): a trailing abbreviation after a family comma is the tussenvoegsel far more often than the decoration it collides with. ``` "Berg, Jan vd" → family="vd Berg" ``` - **Where the capitals speak** ("Doe, John DO"): a word of the AMBIGUOUS credential class, written in capitals in a name written in more than one case, reads as the credential (S6) and this attachment stands down — unless a particle stands immediately in front of it, the two being one particle run by then, which this rule takes whole. ``` "Doe, John DO" → suffix="DO" · boundary "Doe, John Do" → family="Do Doe" "Doe, John van DO" → family="van DO Doe" ``` - **S7's company** (an ambiguous credential behind an unambiguous one): such a word standing behind an unambiguous credential in one run of suffix words reads as the credential in any spelling and any case, reported as S4's credential fork unless its own capitals settled it (S6). - **A run a settled member starts** ("Doe, John MA do"): a member of the ambiguous class its capitals settle starts the credential's run (S8), and a lone member of the class behind it is the run's, as behind an unambiguous credential, so this attachment stands down. ``` "Doe, John MA do" → family="Doe" "Doe, John ma do" → family="do Doe" ``` - **P9's run** (a lone word of both vocabularies inside a run of post-nominals): a lone word of both the particle and the UNAMBIGUOUS suffix vocabulary standing inside a run read as post-nominals keeps that reading, and this attachment stands down. **Accepted:** - In a name written wholly in one case, with nothing in front of the word to speak for it, the two readings cannot be told apart and the particle keeps it, which is right about a Portuguese record and wrong about a credential; the report is how a caller finds the second. ``` "SMITH, JOHN DO" → family="DO SMITH" ``` - An ambiguous particle attaches on the same terms as a never-given one, so a Vietnamese name written in this listing loses its given name — but only in the UNACCENTED transliteration. Vân carries a diacritic and is not particle vocabulary, so the correctly spelled name never reaches this rule. It is the ASCII spelling that collides, and there the two traditions write the same string. ``` "Nguyen, Thi Van" → family="Van Nguyen" "Nguyễn, Thị Vân" → family="Nguyễn" ``` - The colliding spelling has a format that reads correctly, and it is ONE order, not both — FAMILY_FIRST_GIVEN_LAST, which is the order this name is written in; its reading under FAMILY_FIRST is wrong by construction and pins nothing worth keeping (decisions/P6, #470). ``` "Nguyen Thi Van" family-first-given-last → family="Nguyen" "Nguyen Thi Van" family-first-given-last → given="Van" ``` - Under the DEFAULT order a comma-less name's written shape is not settled — "Jong Anke de" may be a misformatted listing, and a bare "Jong de" may be a given name beside a particle — so nothing there names the family, the positional reading stands, and the same input reports family "de" here. That is the reading for a particle the UNAMBIGUOUS suffix vocabulary does not also claim, which is all but two of them. Where the word is both a particle and UNAMBIGUOUS suffix vocabulary — vd and mc — nothing has named the family, so the attachment above never fires and S2 takes the trailing word as a post-nominal, even though that leaves no family name at all: a bare "Donald mc" reports suffix "mc" with an empty family, and so does "Smith vd". The comma is the contrast, and it is where the precedence stated above acts, "Berg, Jan vd" reading family "vd Berg". `do` follows `de` rather than `mc`, sitting in the AMBIGUOUS acronym half, where S4's words-to-spare test leaves a two-word name its family. ``` "Jong Anke de" → family="de" ``` - The precedence over S2 is stated for the shape, so it sweeps in every word that is both particle and suffix vocabulary — today vd, do and mc. Only vd's reading was weighed; mc inherits it, which is the shape's cost and was decided rather than tracked — #454 closed by design 2026-09-07 (decisions/suffix-acronym-collisions). `do` sits in the AMBIGUOUS acronym half and was already read as a name word there, so the precedence decides nothing for it. - A bound given word ahead of the trailing particle takes it as its pair first (P5), so the attachment never sees it — unless the particle is of the unambiguous suffix vocabulary too (vd, mc), which the join declines and the attachment then takes. ``` "Berg, abdul van" → given="abdul van" "Berg, abdul vd" → family="vd Berg" ``` --- **Signal:** position: a particle ending the name where a family comma or a family-first reading marked the family's place **history:** decisions/P6 · **implemented:** nameparser/_pipeline/_assign.py, nameparser/_pipeline/_pieces.py, nameparser/_pipeline/_post_rules.py P7-one-letter-particle-against-the-initial-it-spells.md000066400000000000000000000030731527303271700344160ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/rules# P7 — a one-letter particle against the initial it spells **When:** a one-letter word the particle vocabulary holds stands in a name, as in "Juan Ó. Pérez". **Then:** a one-letter word written with its period is an initial and not a particle, whatever particle the same letter spells, the period being the shape T5 reads as an initial; written bare, it is the particle. - **It is an initial in every position**, so it neither opens a surname (P1), nor joins a chain (P2), nor attaches to the family behind a comma (P6). ``` "J. Ó. Pérez" → middle="Ó." "Ó. Pérez" → given="Ó." "Pérez, Juan Ó." → middle="Ó." ``` **Because:** a one-letter particle is spelled with the same letter as an initial, and the period is what tells them apart. An initial is an abbreviation and is written with one; a particle is a whole word and is not. The Irish Ó is never written with a period, while Ó. is the initial of Óscar, Ólafur or Ólöf. ``` "Juan Ó. Pérez" → middle="Ó." "Juan Ó Pérez" → family="Ó Pérez" · boundary ``` **Accepted:** - An accented initial written WITHOUT its period reads as the particle. Such initials normally carry the period, and the bare letter is exactly how the particle is written. ``` "Juan Ó Pérez" → middle="" ``` --- **Signal:** shape: a one-letter word the particle vocabulary holds, read by whether T5 makes it an initial **history:** decisions/P7 · **implemented:** nameparser/_lexicon.py, nameparser/_pipeline/_classify.py P8-single-letter-connective-against-the-initial-it-spells.md000066400000000000000000000130301527303271700354430ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/rules# P8 — a single-letter connective against the initial it spells **When:** a single-letter connective stands among the name's words, as in "Jose E Maria Santos". **Then:** 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 written wholly in one case, where nothing says so — where the letter is one the vocabulary marks as reading both ways. - **Read as an initial, it is no connective and never joins (P3).** ``` "Jose E Maria Santos" → middle="E Maria" "jose e maria santos" → middle="e maria" "john e smith" → middle="e" ``` - **What reports.** A letter the vocabulary marks as reading both ways, read as an initial in a name written wholly in one case, is a call that could have gone the other way, and is reported — unless no name word of its part stands to its right, nothing at all or only post-nominals and titles, where it connects nothing and there is no other way for the call to go: it is not reported, and where S10 reads it as a numeral S10's report is the one. ``` "jose e maria santos" → ambiguities=("conjunction-or-initial",) "carod i" → ambiguities=("suffix-or-name",) · boundary "jose e phd" → ambiguities=() · boundary "smith, john e phd" → ambiguities=() · boundary "john van e phd" → ambiguities=() · boundary ``` - **Case is asked of the name's own words**, the span P3 states, and not this rule's alone: wherever another rule asks the case question — the suffix slot (S6) and the post-comma slot (C4, C6) — it is asked of these same words, so the answer is taken once and read where each of them stands. - **The initial reading counts letters**, as P3's carve-out does, and asks one more question of them: a letter with no case at all (و) can be written against nothing, so it never reads as an initial. A Cyrillic capital has case but is not the shape an initial is written in — Cyrillic abbreviates with a dotted letter — so in a name of more than one case it joins, the reading #267 blessed, and in a name of one case it takes the vocabulary's answer like any other cased letter. ``` "Хосе И Мария Сантос" → given="Хосе И Мария" ``` - **Which single letters a tradition actually wants joined differs by language**, and the marked set is where that answer lives: "y" is the commonest Hispanic compound and stays out of it, while "e" and Catalan "i" are both common bare initials and are the two entries shipped. A caller with Portuguese data removes "e"; a caller with Catalan or Polish data removes "i"; a caller with Dutch data adds the letter "y" to it. - **The initial reading is visible beyond the fields**, on the two derived views: parse("john e smith").initials() gives "j. e. s." and .capitalized() gives "John E Smith", where the connective reading gave "j. s." and "John e Smith" — a connective contributing no initial (R3) and keeping its lowercase (R4), where an initial does neither. The v1 facade's initials() reads the same parse, so HumanName("john e smith").initials() is also "j. e. s." (decisions/P8, #528). **Because:** where a name is written in more than one case, a bare Latin capital standing alone is how an initial is marked and a bare lowercase letter is how it is not. A name written wholly in one case marks nothing, so there the vocabulary says which letters read both ways. ``` "Jose e Maria Santos" → given="Jose e Maria" "JUAN GARCIA Y LOPEZ" → family="GARCIA Y LOPEZ" "juan garcia y lopez" → family="garcia y lopez" ``` **Accepted:** - Two 1.4.0 parity breaks, one in each direction. A bare capital in a name written wholly in upper case no longer reads as an initial, so "JUAN GARCIA Y LOPEZ" joins where 1.4.0 and 2.0–2.3 read middle "GARCIA Y"; and a marked lowercase letter in a name written wholly in lower case no longer joins, so "jose e maria santos" reads middle "e maria" where they read given "jose e maria". That is #383 answered with "bless" for the mixed-case half — a bare Latin capital among mixed case still reads as an initial, the shape the veto always tested, which is why #267's Cyrillic reading is untouched — and answered with evidence for the one-case half, where the vocabulary decides. - A maiden marker's run and every word after it are outside the name's own words from the moment classify tags the marker, and they stay outside whether or not the marker pass later declines the marker and leaves it a word (M2). A declined marker therefore does two things at once. It still does not count toward the case class, which shows when it is the only differently-cased token, so "JUAN Y GARCIA née" joins where "JUAN Y GARCIA née Jones" keeps "Y" a name word. And it leaves any connective standing after it to T4 rather than to the one-case fork, so a letter there reads as the initial its capital is, in both spellings, where the fork would have joined it: both "DR. NÉE JUAN Y LOPEZ" and "dr. née juan y lopez" read middle "JUAN Y" with the letter an initial. A clause's words are read by T4 the same way, so such a letter takes the same tag in the two spellings. Accepted rather than repaired: classify cannot know what group will decline, and a clause's words are read by the clause's own rules. --- **Signal:** case contrast: a single-letter connective's case against the name's own words **history:** decisions/P8 · **implemented:** nameparser/_pipeline/_assemble.py, nameparser/_pipeline/_classify.py P9-particle-that-is-also-a-suffix-word-beside-a-family-comma.md000066400000000000000000000055301527303271700356150ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/rules# P9 — a particle that is also a suffix word, beside a family comma **When:** a word of both the particle and the suffix vocabulary stands on either side of a family comma, as in "SMITH VD MA, JOHN" or "DOE, JANE PHD VD MA". **Then:** its neighbors decide its reading and the case it is written in does not: before the comma it heads the name word behind it, and after the comma a lone word of both the particle and the UNAMBIGUOUS suffix vocabulary inside a run read as post-nominals keeps the post-nominal reading. - **In the part before a family comma**, which the comma named the family, a particle that is also suffix vocabulary heads the name word behind it, whatever case the name is written in, and is not read as a suffix standing between two family words. ``` "SMITH VD MA, JOHN" → family="SMITH VD MA" ``` - **A lone letter behind it reads as it does in the same part without the particle**: a capital is a name word ('SMITH VD V, JOHN' as 'SMITH V, JOHN'), a lowercase letter in a name of mixed case the numeral ('Smith vd v, John' as 'Smith v, John'), and a name written in one case reads as its capitals (T4). - **In the part after a family comma**, where P6 attaches a particle ending the name to the family, a lone word of both the particle and the UNAMBIGUOUS suffix vocabulary standing INSIDE a run read as post-nominals — one in front of it and another behind, a credential or a generation, whatever made each one, and a title between it and the one in front transparent (H5) — is not the tussenvoegsel: that stands right behind the given name, so a post-nominal between them says the word is not one. It does not end the name, and keeps the post-nominal reading in any case, reported as S4's credential fork. ``` "DOE, JANE PHD VD MA" → suffix="PHD VD MA" "doe, jane phd vd ma" → suffix="phd vd ma" "DOE, JANE MA VD PHD" → suffix="MA VD PHD" ``` **Because:** a word of both the particle and the suffix vocabulary is a surname particle where it stands among name words and a post-nominal where it stands among post-nominals, and both readings are written in every case; beside a family comma, which has already named the family, its neighbors tell which it is and the case it is written in does not. **Except:** - **Before the comma, with a suffix word or nothing behind it**, it reads as the suffix it is there. ``` "SMITH VD, JOHN" → suffix="VD" · boundary ``` - **After the comma, with nothing behind it**, it ends the name and P6 attaches it, and two such words side by side are one particle run, which P6 takes whole. ``` "DOE, JANE PHD VD" → family="VD DOE" · boundary ``` --- **Signal:** neighbor: the words beside a particle that is also suffix vocabulary, on either side of a family comma **history:** decisions/P9 · **implemented:** nameparser/_pipeline/_assign.py derek73-python-nameparser-456b024/docs/design/rules/R-rendering-and-views.md000066400000000000000000000006451527303271700266400ustar00rootroot00000000000000## Rendering & views (R) Background: parsing produces words with roles; every string a caller reads is assembled from those words on request. Nothing about rendering changes the parse, and nothing about reading a field mutates anything. The default view renders '{title} {given} "{nickname}" {middle} {family} ({maiden}) {suffix}' — the choice and its declined née-template alternative are decisions/render-default. derek73-python-nameparser-456b024/docs/design/rules/R1-fields-are-views-of-the-parsed-words.md000066400000000000000000000050231527303271700320020ustar00rootroot00000000000000# R1 — fields are views of the parsed words **When:** a field of a parsed name is read, as in "Dr. Juan Q. Xavier de la Vega III". **Then:** every field is a view computed from the parsed words at read time, joining its words in written order — except folded family words — O3's fold and, since #379, P6's attached tussenvoegsel — which render before the rest of the family wherever they stood in the string. - **Words are separated as the writer separated them.** The suffix view is the one place this is visible, because it is the only field that can hold parts the writer comma-separated: a run of post-nominals written with spaces renders with spaces, and one written with commas keeps them. - **The separator is the comma the writer typed**, read off the words' positions once their roles are known; the semicolon, a hyphen or slash standing alone and a delimiter the configuration names are separators too where C10 drops them, and so is any name word standing between two post-nominals. **Because:** a field is a way of reading the parse, not a stored string. ``` "Dr. Juan Q. Xavier de la Vega III" → family="de la Vega" "Hassan, Mohamad Ahmad Ali" middle_as_family → family="Ahmad Ali Hassan" "Hassan, Mohamad Ahmad Ali" → family="Hassan" · boundary "Smith, MD PhD" → suffix="MD PhD" "John Smith MD PhD" → suffix="MD PhD" "John Smith, MD, Bart" → suffix="MD, Bart" "Smith, John, PhD - i Soler" → suffix="PhD, i Soler" ``` **Accepted:** - A suffix value handed to revise() derives its entries the same way, from the value's own commas, so a name's rendered suffix revises back to itself wherever the value's words read as the whole name read them — a glued CJK honorific peels off an initial in a bare value where the whole name kept it glued, one corpus name in the measurement decisions/R1 records. A delimiter standing alone, built in or one the configuration names, parts a value only where the value's own words, read as a name, give it a tail segment for the core to be dropped on; a run of post-nominals has none, with or without a comma of its own, so there the delimiter stays a word of the run — write a comma at the boundary instead. (The shape needs a field revised after the parse, so no example line.) --- **Signal:** none in the name: the parsed words, their roles and the separators the writer typed between them **history:** decisions/R1 · **implemented:** nameparser/_parser.py, nameparser/_pipeline/_post_rules.py, nameparser/_types.py derek73-python-nameparser-456b024/docs/design/rules/R2-all-particles-family-base-divergence.md000066400000000000000000000043771527303271700321140ustar00rootroot00000000000000# R2 — the all-particles family_base divergence **When:** the family name of a parsed name is read with its particles set apart, as in "Dr. Juan Q. Xavier de la Vega III". **Then:** the family name splits into further views: the base (the family without its leading particles) and the particles themselves. **Because:** callers need the surname with and without its particles — sorting wants "Vega", display wants "de la Vega". ``` "Dr. Juan Q. Xavier de la Vega III" → family_base="Vega" "Dr. Juan Q. Xavier de la Vega III" → family_particles="de la" "Juan de la Vega" → family_base="Vega" · boundary "Juan de la Vega" → family_particles="de la" "Sean O'Connor" → family_base="O'Connor" ``` **Except:** - **A name part whose every word is particle vocabulary** is a part where none of them is doing a particle's work — nothing joins them to a name — so they read as ordinary name words: they anchor the base and leave the particles view. "Every word", not "standing alone": a two-particle run has neither word alone and both are name words there. Position decides that, not vocabulary; whether the word is borne as a surname somewhere does not enter into it. ``` "Anh Do" → family_base="Do" "Juan van der" → family_base="van der" "Juan van der" → family_particles="" ``` **Accepted:** - The invariant the all-particles exception exists to hold: a non-empty family always has a non-empty base. A particle needs a base to attach to, so a family that is all particles is a family whose words are not acting as particles. ``` "Del Toro" family-first → family_base="Del" ``` - The all-particles test runs after every rule that moves a token between parts, so O3's fold decides it too — a middle folded into the family can leave the family all particles, or can give a trailing particle the name word it was missing. ``` "Anh Van Do" middle_as_family → family_base="Van Do" "Nguyen, Van Le" middle_as_family → family_particles="Le" ``` --- **Signal:** neighbor: a family part whose every word is particle vocabulary **history:** decisions/R2 · **implemented:** nameparser/_types.py, nameparser/_pipeline/_post_rules.py, nameparser/_facade.py derek73-python-nameparser-456b024/docs/design/rules/R3-initials-honor-the-fold.md000066400000000000000000000136401527303271700275110ustar00rootroot00000000000000# R3 — initials honor the fold **When:** initials are asked of a parsed name, as in "Dr. Juan Q. Xavier de la Vega III". **Then:** initials take the first letter of each given, middle, and base family word; titles, suffixes, particles and nicknames contribute nothing. - **A word is a particle only where its position makes it one**, not because the vocabulary lists it: where it stands as a name word it is a name word and initials. ``` "Anh Do" → initials="A. D." "Nguyen, Van Le" → initials="V. L. N." ``` - **A connective contributes nothing where it is joining** (P3). A part holding another name word for it to join — the part's working particles set aside — is a part where the connective is doing a connective's work and is no name word of its own. This is one rule for every group: a connective written among given names is a connective there too and contributes nothing where it joins. The question is asked of the WHOLE PART, never of a word count: a part of two words where one is the connective is still a part where it joins — the base of "Jon Dough and" is "Dough and" and its initials are "J. D." — and a part of three is no different. A connective with nothing in its part to join initials. A part holding nothing else is a part where it is joining nothing, and there it initials like any other name word, agreeing with the base. The part's working particles are set aside here too, so the "y" of "Juan de y" initials. ``` "John and Jane Smith" → initials="J. J. S." "Duke of Edinburgh" → initials="D. E." "Juan Velasquez y Garcia" → initials="J. V. G." "Jon Dough and" → initials="J. D." "Juan de y" → initials="J. y." ``` - **The first letter is the one the word's composed spelling begins with**, written in the form the writer used: a letter written as a base letter followed by its combining accent initials whole, and so does a syllable written as its separate parts, wherever the two compose to one character; a mark that never composes with its letter (a nukta after ज, whose precomposed U+095B is excluded from composition) is left out of the initial, while the precomposed letter keeps it. ``` "émile zola" → initials="e\u0301. z." "マイケル ジャクソン" → initials="マ. シ\u3099." ``` - **Each group initials in the order its field reads**: written order, except folded family words (O3, P6), which initial before the rest of the family exactly as they render before it (R1). Which words a group contributes is this rule's question, and the order they contribute in is the field's; the field and this view never disagree about order, and can differ about membership — the family field of "Dr. Juan Q. Xavier de la Vega III" is de la Vega, where the initials take its base — so the field settles order and nothing else. ``` "Hassan, Mohamad Ahmad Ali" middle_as_family → initials="M. A. A. H." ``` **Because:** initials abbreviate the person's name words. Titles, suffixes, particles and nicknames are not name words. ``` "Dr. Juan Q. Xavier de la Vega III" → initials="J. Q. X. V." "Sean O'Connor" → initials="S. O." · boundary ``` **Except:** - **A part whose every word is a particle initials those particles**: they are not acting as particles there but are the part's base (R2), and initial like any other name word, so a family that is all particles contributes its words rather than nothing. ``` "Juan van der" → initials="J. v. d." ``` **Accepted:** - This rule reads a part the parser read. A field set as raw text after the parse carries no reading, and this view is handed no vocabulary to supply one — it takes a format spec and two separators and nothing else — so every word of such a field initials, particles and conjunctions alike: a family set that way to "de la vega" gives "j. d. l. v." where parsing the same name gives "j. v.". Case repair IS handed a vocabulary, so it falls back for the one question a word can answer on its own, and R4 says which. Revising the field through the parser classifies it and matches the parse in both of the parsed name's views. (The shape needs a field edited after the parse, so no example line.) - The v1 facade's HumanName.initials() is a second view of this question and IS handed a vocabulary: it reads the parse's connective reading wherever a word is backed by a parsed token, and falls back to the vocabulary as case repair does wherever a word is not — a field set as raw text, or a name restored from a v1 pickle or copied through the same state hooks. The particle question is never asked of the parse on that view: every word, backed or spliced alike, is looked up in the vocabulary, so a family spliced to "de la vega" initials "j. v." on that view against "j. d. l. v." on the parsed name's own. The two views agree on WHICH WORDS initial in a parsed name; what still differs there is GROUPING, the facade initialing a joined run as one element, so "Ph. D., John" gives a run-together "J. P D." on that view against "J. P. D." on this one. (Both shapes need a field edited after the parse or a rendering the other view does not have, so no example line.) - The given group's answer is a 1.4.0 parity break, and a second one in the other direction sits beside it. A connective among given names contributed an initial from 1.4.0 until this rule reached it, so "John and Jane Smith" gives "J. J. S." where every release through 2.3 gave "J. a. J. S." and 1.4.0 gave the run-together "J a J. S."; and a connective holding a part alone now contributes one, so "محمد و علي" gives "م. و. ع." on both views, which is 1.4.0's own answer restored. --- **Signal:** none in the name: the parsed given, middle and base family words, and the connectives joining them **history:** decisions/R3 · **implemented:** nameparser/_render.py, nameparser/_facade.py, nameparser/_pipeline/_post_rules.py derek73-python-nameparser-456b024/docs/design/rules/R4-case-repair-reads-the-unjoined-mark.md000066400000000000000000000347101527303271700316620ustar00rootroot00000000000000# R4 — case repair reads the unjoined mark **When:** case repair is asked of a parsed name, as in "juan mcdonald". **Then:** case repair returns a repaired copy and never mutates the parse, and where it acts at all — R5 decides where — the copy honors the casing a vocabulary entry records (PhD, BSc) and the Mac/Mc convention (McDonald), not only ordinary word-by-word casing. - **Repair changes case and nothing else**: the repaired word is the word as written, recased. A word is read whole however its letters are encoded: a letter written as a base letter followed by its combining accent is repaired as the same letter written whole, and the repaired word keeps the encoding the writer used. ``` "josé garcía" → capitalized="Jose\u0301 Garci\u0301a" "JOSÉ GARCÍA" → capitalized="Jose\u0301 Garci\u0301a" ``` - **Each run of letters a full stop divides is written as a word of its own**, so run-together initials keep a capital apiece. A word that opens with a modifier letter that has no case — the ayn and hamza of romanized Arabic, the Hawaiian okina — takes its capital on the letter after that mark, as a word opening with an apostrophe does. A digit is not passed over that way: it stands first in its word. ``` "j.r.r. tolkien" → capitalized="J.R.R. Tolkien" "A.B. Vajpayee" → capitalized_forced="A.B. Vajpayee" "AHMAD IBN-ʿALĪ" → capitalized="Ahmad ibn-ʿAlī" "Ahmad al-ʿAbbas" → capitalized_forced="Ahmad al-ʿAbbas" "ʻIOLANI KAMAKA" → capitalized="ʻIolani Kamaka" "LT.GOV. JOHN DOE" → capitalized="Lt.Gov. John Doe" "lt.gov john doe" → capitalized="Lt.Gov John Doe" "st.john smith" → capitalized="St.John Smith" "J.Smith" → capitalized_forced="J.Smith" "J.MCDONALD SMITH" → capitalized="J.McDonald Smith" "m.ʿali smith" → capitalized="M.ʿAli Smith" "É.J. DUPONT" → capitalized="É.J. Dupont" "john 3rd smith" → capitalized="John 3rd Smith" · boundary ``` - **A vocabulary entry records casing as a mask** — its word's letters, each in the case it takes — and repair lays it over the word as the writer punctuated it, so the one entry for phd repairs phd to PhD and ph.d. to Ph.D.; the mask applies wherever the word stands, whatever the parse read it as. A run of two or more letters beside a full stop takes the mask's case letter by letter and is never an initial. ``` "john smith phd" → capitalized="John Smith PhD" "john smith ph.d." → capitalized="John Smith Ph.D." "JOHN SMITH PH.D." → capitalized="John Smith Ph.D." "john smith ph. d." → capitalized="John Smith Ph. D." "john smith bsc" → capitalized="John Smith BSc" "john smith psyd" → capitalized="John Smith PsyD" "SIG.RA MARIA ROSSI" → capitalized="Sig.ra Maria Rossi" "dott.ssa anna bianchi" → capitalized="Dott.ssa Anna Bianchi" "Dr. med. univ. Margit Popp, MSc" → capitalized_forced="Dr. Med. Univ. Margit Popp MSc" "MSc Dr. med. univ." → capitalized_forced="MSc Dr. Med. Univ." ``` - **A particle is written in lowercase**, but a particle the vocabulary records a casing for takes that casing, the entry being how the word is written wherever it stands. ``` "SEÁN Ó MURCHÚ" → capitalized="Seán Ó Murchú" "IVOI, PAUL D'" → capitalized="Paul d' Ivoi" "zaʿīm, ḥusnī az-" → capitalized="Ḥusnī az- Zaʿīm" "jacobus henricus van 't hoff" → capitalized="Jacobus Henricus van 't Hoff" "maria da-silva" → capitalized="Maria da-Silva" "ina binti navalamar" → capitalized="Ina binti Navalamar" · boundary "juan de la vega" → capitalized="Juan de la Vega" · boundary ``` - **A connective the parse placed among the name words** keeps its lowercase wherever it stands there, including inside a part whose other words the unjoined mark has turned into ordinary name words. That is this rule's own reading and not a borrowing from R3: a connective that initials because it joins nothing is still not written the way a name is written, while a generation is written the way a generation is written. The GENERATION is what the generation exception (Except) turns on and not the field: a connective the suffix field merely holds, which the suffix vocabulary does not know, was read as no generation and keeps its lowercase there like any other connective — the third part of a comma form is the shape that puts one there. ``` "Smith, John, and" → capitalized_forced="John Smith and" "Doe, Jane, and Jr." → capitalized_forced="Jane Doe and Jr." ``` - **A name already written the way repair would write it comes back unchanged**, measured by repair's own conventions rather than by the bearer's. A spelling written in a single case is repaired even where its bearer meant it, because nothing in the text marks it as a choice; where the text does mark one, R5 defers to it. ``` "Juan McDonald" → capitalized_forced="Juan McDonald" ``` - **A credential acronym the exceptions map does not carry is an initialism**, so a single-case word the parse put in the suffix role from the acronym vocabulary (written whole across any hyphen or slash it holds, `OTR/L`, not as two name words), or read as a credential by its dotted shape alone (S3) or by its capitals (S9), repairs to its all-caps spelling rather than a title-cased one, and that repair outranks the Mac/Mc convention where a word fits both (MCSE, not McSe). A roman numeral the parse put in the suffix role is written in capitals the way a generation is written, whether or not the vocabulary lists it (VI as well as III). A suffix word that is neither an acronym, a numeral nor an exceptions-map entry -- the generational `jr`, `sr` -- keeps its title case. ``` "john smith md" → capitalized="John Smith MD" "john smith m.d." → capitalized="John Smith M.D." "john smith iii." → capitalized="John Smith III." "john smith x.y.z." → capitalized="John Smith X.Y.Z." "John Smith R.A.I." → capitalized_forced="John Smith R.A.I." "john doe q.c." → capitalized="John Doe Q.C." "john smith ed" → capitalized="John Smith ED" "john smith vi" → capitalized="John Smith VI" "john smith ix" → capitalized="John Smith IX" "john smith iii" → capitalized="John Smith III" "john smith, v" → capitalized="John Smith V" "john smith mba" → capitalized="John Smith MBA" "john smith mcse" → capitalized="John Smith MCSE" "john smith, otr/l" → capitalized="John Smith OTR/L" "JOHN SMITH, FNP-BC" → capitalized="John Smith FNP-BC" "john smith pa-c" → capitalized="John Smith PA-C" "john smith jr" → capitalized="John Smith Jr" · boundary "John Smith, otr/l" → capitalized="John Smith otr/l" · boundary "John Smith, otr/l" → capitalized_forced="John Smith OTR/L" · boundary "john smith-jones" → capitalized="John Smith-Jones" · boundary "jean-luc picard" → capitalized="Jean-Luc Picard" · boundary "dipl.-ing john smith" → capitalized="Dipl.-Ing John Smith" · boundary "Md Abdul Karim" → capitalized_forced="Md Abdul Karim" ``` - **Inside a hyphenated word, a part that is connective vocabulary with a worded part on each side of it keeps its lowercase**, the hyphens being the writer joining the name around it (Ortega-y-Gasset); at either end of the hyphenated word it is repaired as ordinary name text. A single letter marked with a period is read as an initial there, as the parse reads it, never as the connective. A bare letter is not: the hyphen is the writer's join, so the interior connective is read as one even in a name written wholly in one case, where the same letter spaced reads as an initial if the vocabulary marks it as reading both ways (P8). ``` "jose ortega-y-gasset" → capitalized="Jose Ortega-y-Gasset" "JOSE ORTEGA-Y-GASSET" → capitalized="Jose Ortega-y-Gasset" "maria silva-e-sousa" → capitalized="Maria Silva-e-Sousa" "Jose Ortega-Y-Gasset" → capitalized_forced="Jose Ortega-y-Gasset" "juan e-f smith" → capitalized="Juan E-F Smith" · boundary "juan y-garcia" → capitalized="Juan Y-Garcia" · boundary "juan y-garcia-lopez" → capitalized="Juan Y-Garcia-Lopez" · boundary "j.-e.-p. dupont" → capitalized_forced="J.-E.-P. Dupont" · boundary ``` **Because:** case repair is a display concern, applied only on request and never destructively. ``` "juan mcdonald" → capitalized="Juan McDonald" "donovan mcnabb-smith" → capitalized="Donovan McNabb-Smith" ``` **Except:** - **A part whose every word is particle vocabulary** is repaired as ordinary name words, since none of them is doing a particle's work there (R2). ``` "ANH DO" → capitalized="Anh Do" "anh van do" → capitalized="Anh Van Do" ``` - **A connective the parse read as the generation it also spells** is not a connective of this name at all, and is repaired as the generation it was read as. ``` "John Quincy Smith i" → capitalized_forced="John Quincy Smith I" "Carod i" → capitalized_forced="Carod I" ``` - **A single letter split off alone beside a full stop is written as an initial** and takes capitals whatever the mask says there — but only where the mask itself writes that letter inside a run of two or more letters: the writer split a chunk the mask keeps together, so the split-off letter is an initial. A run is of LETTERS only, so a digit ends one exactly as a full stop does. Where the mask writes the letter as a run of one too, there is no split to repair, and the mask's own case stands. So ph.d. is Ph.D. under the phd mask, and p.h.d. — three initials, not the mask's one run 'PhD' — is P.H.D.; but h.c. under a caller's own h.c mask, which spells each letter alone, stays h.c. ``` "john smith p.h.d." → capitalized="John Smith P.H.D." ``` - **A word of the acronym or numeral class that parsed as an ordinary name word** repairs as that name word unless the exceptions map carries it (qc mp gives Qc MP, phd smith gives PhD Smith): those two classes read the role and the mask does not. ``` "qc mp" → capitalized="Qc MP" · boundary "Jack XY.ZW." → capitalized_forced="Jack Xy.Zw." · boundary "john smith xi" → capitalized="John Smith Xi" · boundary "john smith, vi" → capitalized="Vi John Smith" · boundary ``` **Accepted:** - An abbreviation the vocabulary does not list, whose letters after a full stop are the word's own ending rather than a word, is written as two words: the shape that divides run-together initials and joined words cannot tell an ending from either, and only a listed casing can. ``` "DUT.RA MARIA SILVA" → capitalized="Dut.Ra Maria Silva" · boundary ``` - A name written in one case whose hyphenated bare initials spell a connective pays for the hyphen clause — its interior letter is lowered as the connective though the writer meant an initial, where the same letters spaced keep their capitals. The hyphen is the writer's join, and reading the one shape both ways would need the case evidence a one-case name does not carry. ``` "J-E-P DUPONT" → capitalized="J-e-P Dupont" · boundary ``` - The all-particle clause reaches a part the parser read. A field spliced in as raw text after the parse carries no reading of its own, so a family set that way to "de la" stays lowercase where those same two words parsed from a name are repaired to "De La". The acronym repair is the contrast: it asks the role and the vocabulary and not a reading, so a suffix spliced in as "mba" is repaired to "MBA" exactly as a parsed one is. That is the boundary between splicing text into a field and revising a field through the parser — revise() classifies the value, so the repair follows it — rather than a gap between them. It is also a limit and not a regression: a spliced-in family was repaired exactly this way before the clause existed. (The shape needs a field edited after the parse, so no example line.) - The boundary is drawn per question rather than per field: the conjunction carve-out reaches a word the parse read as a conjunction, and where the parse read nothing at all it reaches what the vocabulary says. A word of that vocabulary standing at either end of a longer written word is not a conjunction there, because the hyphen clause reaches only a worded part with a worded part on each side, and an edge part is outside that reach — but a field spliced in as raw text was read by nobody, so repair asks the vocabulary and a family set to "de y" keeps its "y" lowercase. Whether a word is the conjunction or an initial is a property of the word, which a vocabulary can answer; whether a particle is acting as a particle is a property of the whole part, which the parse settles and records; re-deriving it needs a reading on every word of the part, and a spliced field has none on any, so that half falls through to particle treatment and the spliced "de la" boundary of the bullet before stands. Initials are the contrast worth knowing, and R3 states it — but ask which initials view, because the two answer oppositely. The parsed name's own view is handed no vocabulary at all, so it falls back on neither question and a spliced field's every word initials. The v1 facade's view IS handed one: the connective question falls back to the helper this rule uses, while the particle question is never asked of the parse in this view at all — a live vocabulary lookup for every word, backed or spliced alike — so a family spliced to "de la vega" initials "j. v." there against the parsed view's "j. d. l. v.". revise() classifies the value and crosses both questions, in both of the parsed name's views: a middle revised to "e-f" repairs to "E-F" as the parsed name does, where splicing the same text in gives "e-F". --- **Signal:** shape: a word's own spelling: its recorded casing, the Mac/Mc convention and its punctuation **history:** decisions/R4 · **implemented:** nameparser/_render.py, nameparser/_lexicon.py derek73-python-nameparser-456b024/docs/design/rules/R5-case-repair-gate.md000066400000000000000000000047231527303271700261670ustar00rootroot00000000000000# R5 — the case-repair gate **When:** case repair (R4) is asked of a parsed name, as in "Shirley Maclaine". **Then:** case repair acts only on a name written entirely in one case, and a name whose words outside the suffix are written in more than one case is kept as it was written, whether that casing is right not entering into it, unless repair was asked for anyway. - **The suffixes are left out of that test**: a suffix says nothing about how the writer cased the name — III, PhD, Jr. are written that way whatever the name's case — so the test reads every word the parse did not put in the suffix role. ``` "juan garcia III" → capitalized="Juan Garcia III" "juan garcia PhD" → capitalized="Juan Garcia PhD" "JUAN GARCIA Jr." → capitalized="Juan Garcia Jr." "Juan garcia III" → capitalized="Juan garcia III" · boundary ``` - **A title is not left out**, and a cased title still holds repair back. ``` "dr. juan garcia III" → capitalized="Dr. Juan Garcia III" "Dr. juan garcia" → capitalized="Dr. juan garcia" · boundary ``` - **The same test is then asked of each suffix on its own**: a suffix written in more than one case is the writer's spelling and is kept as written where repair was not forced; one written in a single case is repaired like any other. ``` "john smith EdD" → capitalized="John Smith EdD" "juan garcia Iii" → capitalized="Juan Garcia Iii" · boundary "john smith EdD" → capitalized_forced="John Smith EDD" ``` **Because:** mixed case is evidence that the writer cased the name deliberately, and a repair cannot tell a deliberate spelling from a mistaken one. A name written wholly in one case leaves the repair no such evidence to read either way — the writer who always writes lowercase is indistinguishable from the writer who could not be bothered — so repair proceeds there, which is a choice about how to act absent evidence rather than a claim that nothing can be lost by it. ``` "juan mcdonald" → capitalized="Juan McDonald" "SHIRLEY MACLAINE" → capitalized="Shirley MacLaine" "Shirley Maclaine" → capitalized="Shirley Maclaine" "Shirley Maclaine" → capitalized_forced="Shirley MacLaine" · boundary ``` --- **Signal:** case contrast: whether the words outside the suffix are written in one case **history:** decisions/R5 · **implemented:** nameparser/_render.py derek73-python-nameparser-456b024/docs/design/rules/README.md000066400000000000000000000637541527303271700235400ustar00rootroot00000000000000# Parsing rules This document is NORMATIVE, not descriptive: the rules state how names are written and what should happen when they are parsed, grounded in how people understand names — not in what the parser currently does. The parser implements these rules. Where it does not yet, the gap is a tracked deviation (`deviates:` marker below), not a counterexample. Statements are implementation-free: no stage names, no function names, no regexes. Authority, scoped: `tests/v2/cases.py` pins CURRENT behavior; this document states INTENDED behavior. A mismatch between them must be classified, never defaulted: either the rule is wrong (fix it here) or the parser is wrong (the example takes a `deviates:` marker and an issue). Where this document is silent, the behavior is pinned-but-undocumented — an extraction gap to close, not a specification, and not license to change the behavior. Rule IDs are stable forever: never renumbered, never reused. A retired rule's file leaves this folder, and its decisions file records the retirement on a line of its own opening `Retired:` (decisions/README.md); `tests/v2/test_design_docs.py::test_no_rule_reuses_a_retired_id` refuses a rule file carrying an ID a decisions file records that way. Cross-references use the form `decisions/P2` / `mechanisms.md#SPANS`; a bare ID is never a citation. The citation-integrity test checks that every rule ID written in a rule's quotable prose (every field, below) exists — not that the two rules really meet, and not which of them wins, which a Precedence bullet states. `implemented:` and `tracked:` are checked too: every rule carries exactly one. `implemented:` names the modules honoring the rule, checked against the modules that cite it; `tracked:` names the issues that would ship a rule nothing implements yet, so a wholly-aspirational rule cannot sit here untracked, and a shipped rule cannot keep a stale tracking pointer. ## The skeleton Every rule is written in the skeleton format (#761). The block format the rules were first written in — a rule opening `ID. Rationale:`, its statement indented four spaces, its examples six, its pointer line naming `interacts:` — is retired, and the parser refuses a rule written in it rather than skip it. A rule records an agreement about one situation: when this is in the written name, it means this and is handled this way, because of this — and handling it that way means accepting these other readings. The skeleton writes it in that order, so the head reads aloud as one sentence (When, Then, Because), each place the head stops being true is a bullet of its own carrying its own example, and the technical alignment sits in a footer. A rule file opens with its heading, `# ID — name`, and the rule runs to the next markdown heading (a `#` run and a space, so a prose line opening `#761` is not one); it holds these fields, each a bold label at the start of a line, in this order, each at most once: - `**When:**` (required) — the situation in the written name, with one quoted example where the rule reads a name. - `**Tolerated:**` — a tolerated rule's reason (see the tolerated rule below), standing right after When. - `**Then:**` (required) — what results, including what the writing means where the rule interprets it. It is ONE paragraph of ONE sentence. Detail of the result — how far it reaches under each order, a hub's map — goes in a `- ` bullet list directly under it. - `**Because:**` (required) — why, in three sentences at most. - `**Except:**` — one bullet per place where Then stops being true, opening with a bold lead, each carrying its own fenced example: an exception without its case is a clause nobody can check. This is where the `· boundary` examples usually stand. An input outside When is no exception, even one marking the rule's boundary: it goes in the rule's main fence with its `· boundary` marker, and the sentence it proves goes where its claim belongs, in Then or Accepted. - `**Precedence:**` — one bullet per contested input: the input, the rule that wins it, and why. Naming a rule anywhere else says only that the two meet; this says which wins. - `**Accepted:**` — one bullet per reading taken as the rule's cost, with an example where an input string can show it. A field that does not apply is left out, never filled for the sake of the shape. When, Tolerated, Then and Because hold prose: the text starts on the label line and is not hard-wrapped (root AGENTS.md, "Markdown line width"), and When, Tolerated and Then are one paragraph each. Except, Precedence and Accepted hold only a bullet list, nothing on the label line. A bullet is `- ` and its text at the start of a line (a `* ` or `+ ` marker, a numbered item, a hyphen glued to its text, or a bullet with no text is refused); it continues on lines indented two spaces, and a fence indented two spaces under it holds that bullet's examples. A fence at the start of a line holds examples for the rule as a whole, usually right after Because. A fence is a bare ``` line, no language tag (a ``` line carrying one, or a `~~~` fence, is refused), and holds at least one example. Every non-blank line inside a fence is an example line in the grammar below, and an example line anywhere outside a fence is refused rather than read as prose: a line, the text on a label's line or a bullet's text that opens with a quote or a bracket, or any line holding an arrow and an assertion (` → family=`, quoted or not) outside a backtick span. So no prose may open with a quote or a bracket, and prose writing about the grammar puts an assertion in backticks. A nested list, or a heading, inside a bullet is not part of the grammar. Precedence and Accepted bullets may carry examples and need not; the rule as a whole still needs a `· boundary` example or a `no-boundary:` waiver. A line holding exactly `---` ends the fields. Below it stand only the `**Signal:**` line, the pointer line directly under Signal with no blank line between — `**history:** decisions/ · **implemented:** , `, or `**tracked:** #N` in place of `implemented:`, every part one of those keys with a value and none repeated — and an optional `no-boundary: ` waiver, which may stand above the Signal line or below the pointer line. A Signal or `no-boundary:` line above the `---`, in any spelling, is refused: read as prose, it would leave the rule's signal or waiver unset. What a citation may quote is every field's prose, bold markers stripped: When, Tolerated, Then and its bullets, Because, and the Except, Precedence and Accepted bullets — never a fence or the footer. Sentences are counted with quoted text and backtick spans removed, `e.g.` and `i.e.` ending none, a sentence ending at `.`, `?` or `!` followed by a space and a capital, except a period after an initial (`V. Smith`) or after Dr, Mr, Mrs, Ms, St, Prof, Jr or Sr. The count misses a sentence that opens with anything but a capital, or that ends in an initial or one of those abbreviations. The parser refuses, naming the rule and the line: a label spelled any other way than `**Label:**` (`When:`, `**When**:`), an unknown label, a field out of order or repeated, a missing or empty When, Then or Because, an empty Tolerated, a fence with a language tag or a `~~~` fence, an example line outside a fence (or any line, label text or bullet text there opening with a quote or a bracket, or holding an arrow and an assertion outside a backtick span), a When, Tolerated or Then of more than one paragraph, a Then of more than one sentence, a Because of more than three sentences, a malformed or empty bullet, a nested list (in any marker, `- `, `* `, `+ ` or a number) or a heading in a bullet's continuation lines, an Except, Precedence or Accepted label with no bullets under it, an Except bullet with no example, an empty fence, an unclosed fence, a line outside any field, a Signal or `no-boundary:` line above the `---`, `interacts:` or any unknown or repeated part on the pointer line, and below the `---` anything but those three lines. Outside a rule, only a section file's `## Name (X)` heading, each letter once, and its one `Background:` paragraph may stand, and anything else is refused: a subheading in a rule file, a fence in a section file, and either one glued to a Background paragraph, which may wrap onto further lines of prose but not onto a line opening, at its start, with a fence, a heading, a field label or a list item (of numbered items only one numbered 1, which is all CommonMark lets interrupt a paragraph). An example in a Background, glued or on its first line, is refused as anywhere outside a fence, and so is a section heading with no Background. A rule in the retired block format is refused too: a line opening `ID. ` at the start of a line, wherever it stands, inside a field included. D2, as a skeleton: ````markdown # D2 — construction raises, parse never does **When:** a configuration is built from a value it cannot accept. **Then:** configuration validation raises at construction with the offending field and value named. - **A locale pack names itself:** applying a locale pack wraps any such error with the locale's code, so a stacked configuration names which layer broke. **Because:** whatever a name contains, parsing answers. Only a broken configuration may raise, and it must name the field. ``` [bad-name-order] → raises="name_order elements must be Role members" [bad-order-none] → raises="name_order must be an iterable" ``` --- no-boundary: the non-raising side is every other rule's examples; parse() itself is total (A1). **Signal:** none in the name: the configuration's values, at construction **history:** decisions/D2 · **implemented:** nameparser/_policy.py, nameparser/_parser.py ```` D2 has no exception. The bullet fields look like this, drawn from S10's examples: ````markdown **Except:** - **One letter needs a word to spare**, as a bare ambiguous acronym does (S4): as the second of two name words it is the family name. ``` "John V" → family="V" · boundary ``` **Precedence:** - **Before a suffix comma** ("John Smith V, PhD"): the part before the comma reads as the same name written without it (C1). **Accepted:** - A one-letter numeral with a credential behind it is the family name, and the reading is reported. ``` "John Smith V PhD" → family="V" ``` ```` `tests/v2/rules_skeleton.py` is the format's executable definition, and `tests/v2/test_rules_skeleton.py` holds its refusals. ## Examples, signals and size Every example line is EXECUTABLE. The grammar (its executable definition is `tests/v2/rules_doc.py`, which `tests/v2/rules_skeleton.py` asks for each line in a rule's fences; `tests/v2/test_rules_doc.py` runs every line): "INPUT" [annotation] → field="value" [· boundary] [deviates: #N (today: field="value")] An `annotation` names a policy, locale (`[ru]`), or extras gate (`[ja+segmenter]`) in the registry beside the test. `· boundary` marks the non-firing example every rule must carry: an input shaped like the rule's subject where its effect does NOT occur. That is usually the rule's OWN stated exception — H1's given-name title, P3's single-letter carve-out — not an input the rule never reaches, so the exception is executable rather than merely asserted. Or the rule declares `no-boundary: ` instead, so skipping the boundary is a recorded decision. `deviates:` states the INTENDED output on the example line while the marker records TODAY's output and the tracking issue; the runner asserts today's output strictly, so a parser change that closes the gap fails the suite until the marker is removed in the same PR. `grep -r deviates: docs/design/rules/` is the deviation backlog (deviations from statable rules — coverage gaps are a separate, larger category no grep can see, and contested vocabulary memberships a third, tracked as Open blocks keyed to the vocabulary set in decisions/). Every rule also states its signal — what in the written name it reads, for example a count, a case contrast, a shape, a neighbor, a position or a script, or `none in the name: ` for a rule that reads configuration or the parsed words. A rule writes it in the footer as a tag, `**Signal:** shape`, optionally followed by a colon and a short description (`**Signal:** none in the name: the configuration's values, at construction`), since its When already describes the situation. The tags the rules use are shape, count, case contrast, position, neighbor, script, comma, edge full stop, character, separator and none in the name. Two kinds of rule carry none of them: A1 and C11, reading only the calls other rules make, write `none of its own`, and C7, which orders other rules' reads, writes `a sequence, not a signal of its own`. The line is one line, never wrapped, standing directly above the rule's pointer line, and the parser refuses one anywhere else, since a continuation line would be read as prose. It is metadata, not statement: a citation cannot quote it. A hub — a decision weighing several signals, each stated by a rule of its own — ends its line `hub of C3, C4, …`, after any signal it reads itself (`**Signal:** ; hub of C3, C4`), and the doc tests hold the rules a hub names to its map, both ways. The map is the bullet list under Then: every rule the hub names is named by some bullet, every rule a bullet names is on the hub's list, and every bullet names at least one, so one bullet may name several. A line naming two signals joins them with ` + `: that rule is a split candidate under docs/design/AGENTS.md's one rule per piece of evidence, and `grep -lE '^\*\*Signal:\*\*.* \+ ' docs/design/rules/*.md` lists them. A condition the rule cites to the rule stating it stays off the line — S10 cites T4 for the lower-case numeral that is no initial — and a gate on the name's size (exactly three name words, a word to spare, as S10's one-letter numeral needs) is part of the signal it gates rather than a second one. Each rule's size is recorded, as a guard rather than a cap (mechanisms.md#RECORDED-ROSTERS). `tests/v2/rules_budget.json` holds each rule's measures, read from its fields: the words of every quotable field but the Accepted bullets (When, Tolerated, Then and its bullets, Because, Except and Precedence), Then's words with its bullets, Because's sentences, the count of Except, Precedence and Accepted bullets, and the Accepted bullets' words. `tests/v2/test_rules_budget.py` fails when a rule moves against its record, shrinking included, so the record follows a rule down. A rule that has to grow re-records with `uv run python -m tests.v2.rules_budget --record`, and the PR says so: the record's diff is where a reviewer sees the growth and asks what the new words read. Only the skeleton's two sentence limits are caps. One class of rule stands outside the normative reading and declares it, in a `**Tolerated:**` field right after When. Such a rule covers input that no writing convention produces. W3 is the case: its subject is Chinese, Japanese and Korean names, and none of those writing systems puts a comma between the family name and the given name, so `김, 민준` is a shape nobody writes. The parser accepts any string, so it still has to read such input somehow; the rule describes what it does with it, best-effort, instead of promising it. Its statement is still precedence-bearing and its examples are still executable and still run, but they illustrate rather than promise, and the behavior is changeable without notice. W3 is the only such rule today (the 2026-09-01 comma demotion). The marker's unit is the whole rule, so a clause that reaches past the tolerated shape does not belong in a marked rule — say it in a normative one. The marker changes what the rule promises, not what is watched: a marked rule's examples are in `tools/differential/corpus_rules.jsonl` like any other, so the snapshot shows when their reading moves (#664). ## Glossary Terms the rules use without defining them each time, each with the rule that defines or counts it. - **name word** — a word of the person's name itself: titles, suffixes, nicknames and particles acting as particles are not name words (R3). How name words are COUNTED is each rule's own, and there is no single count: C3, counting the words before a comma, takes a particle run and the one name word it attaches to as one word (P1's reach, not P2's whole chain) and counts a connective join and a bound given-name pair as their words; S8 counts P2's whole chain as one name word; P1's family-first fold takes one unit, a particle chain (P2), a conjunction join (P3) or a bound given-name pair (P5) whole; O6 counts a family's words by P1's reach, a connective join and a hyphenated word counting as their words. - **words to spare** — enough name words that reading one as a suffix still leaves the name its family word. As the second of two name words a bare ambiguous acronym has none to spare and stays the family name (S4); S10's one-letter numeral needs the same. - **credential run** — a run of words read as post-nominals: a comma part made wholly of suffix words (C1, C8), or the run an unambiguous credential starts after the name core, taking every later word of its part (S8). - **listing form** — the family name, a comma, then the given name, as in "Smith, John" (C1). - **the fold** — P1's: a never-given particle opening the name claims the family for itself, the particles after it and the name words it attaches to (P1). O3's fold, which joins every middle word to the family under compound-family handling, is a different one; R1's folded family words, which render before the rest of the family, are O3's folded middle words and the trailing particle P6 attaches (its tussenvoegsel). ## Not in scope - **Language detection.** The parser never infers a language from Latin-script text: transliteration destroys the signal ("Ali", "Van", "Bin" each belong to several languages with conflicting readings). Language-specific behavior is opt-in configuration. Script-conditional behavior exists only where the script itself settles the convention (see the W section). - **Grammatical inflection.** Names inflect in many languages (vocative, genitive); this library neither produces nor consumes inflected forms. CLDR personNames draws the same line. - **Validation.** Deciding whether a string IS a person's name is not parsing; `parse()` is total over strings and never rejects input. - **Vocabulary completeness.** No shipped wordlist parses every name, and none is meant to. The promise is that every TYPE of vocabulary has a mechanism — markers, particles, titles, suffixes, bound given names — and that callers configure the members their data needs. A missing entry is a configuration answer, not a defect, and what the tests owe is one exercise per behavioral fork rather than one per entry (mechanisms.md#VOCABULARY-EXERCISES-FORKS). - **Comparison.** matches()/comparison_key() are a value-API surface, not parsing; their design record is decisions/comparison-surface. ## Index ## [Titles & honorifics (H)](H-titles-and-honorifics.md) - [H1 — a title and one name word](H1-title-and-one-name-word.md) - [H2 — the leading-abbreviation title](H2-leading-abbreviation-title.md) - [H3 — the title run's floor](H3-title-runs-floor.md) - [H4 — an input that is nothing but vocabulary still has to name somebody](H4-input-that-is-nothing-but-vocabulary-still-has-to-name-somebody.md) - [H5 — the trailing title run](H5-trailing-title-run.md) - [H6 — a title before a family comma](H6-title-before-a-family-comma.md) - [H7 — a joined name unit carrying title vocabulary](H7-joined-name-unit-carrying-title-vocabulary.md) - [H8 — a salutation ending the given part](H8-salutation-ending-the-given-part.md) - [H9 — the title phrase](H9-title-phrase.md) ## [Particles & surname prefixes (P)](P-particles-and-surname-prefixes.md) - [P1 — lone particle fold](P1-lone-particle-fold.md) - [P2 — particles join forward](P2-particles-join-forward.md) - [P3 — connectives](P3-connectives.md) - [P4 — a particle at the front of the name](P4-particle-at-the-front-of-the-name.md) - [P5 — bound given names](P5-bound-given-names.md) - [P6 — the trailing orphan particle](P6-trailing-orphan-particle.md) - [P7 — a one-letter particle against the initial it spells](P7-one-letter-particle-against-the-initial-it-spells.md) - [P8 — a single-letter connective against the initial it spells](P8-single-letter-connective-against-the-initial-it-spells.md) - [P9 — a particle that is also a suffix word, beside a family comma](P9-particle-that-is-also-a-suffix-word-beside-a-family-comma.md) ## [Suffixes: generational & credentials (S)](S-suffixes-generational-and-credentials.md) - [S1 — a bracketed credential is not a nickname](S1-bracketed-credential-is-not-a-nickname.md) - [S2 — a suffix word ends a name and never opens the string](S2-suffix-word-ends-a-name-and-never-opens-the-string.md) - [S3 — credentials written in joined chunks](S3-credentials-written-in-joined-chunks.md) - [S4 — the ambiguous acronym needs words to spare](S4-ambiguous-acronym-needs-words-to-spare.md) - [S5 — the given part's trailing slot after a family comma](S5-given-parts-trailing-slot-after-a-family-comma.md) - [S6 — the case signal at the suffix slot](S6-case-signal-at-the-suffix-slot.md) - [S7 — the company of an unambiguous credential](S7-company-of-an-unambiguous-credential.md) - [S8 — the credential's trailing run](S8-credentials-trailing-run.md) - [S9 — unlisted words that join the ambiguous class by shape](S9-unlisted-words-that-join-the-ambiguous-class-by-shape.md) - [S10 — the trailing roman numeral](S10-trailing-roman-numeral.md) - [S12 — a credential split across a space](S12-credential-split-across-a-space.md) - [S11 — a suffix word that is also a name](S11-suffix-word-that-is-also-a-name.md) - [S13 — a bracketed member settled by its writing](S13-bracketed-member-settled-by-its-writing.md) ## [Nicknames & quoted names (N)](N-nicknames-and-quoted-names.md) - [N1 — the default delimiter pairs](N1-default-delimiter-pairs.md) - [N2 — same-character quotes and shared-character conventions](N2-same-character-quotes-and-shared-character-conventions.md) - [N3 — the lone-word nickname rule](N3-lone-word-nickname-rule.md) ## [Maiden names (M)](M-maiden-names.md) - [M1 — delimited maiden names](M1-delimited-maiden-names.md) - [M2 — the maiden-marker rule](M2-maiden-marker-rule.md) - [M3 — the marker-led clause](M3-marker-led-clause.md) - [M4 — a maiden marker makes the lone name word the family](M4-maiden-marker-makes-the-lone-name-word-the-family.md) ## [Commas & structure (C)](C-commas-and-structure.md) - [C1 — the suffix-comma decision](C1-suffix-comma-decision.md) - [C2 — parts past the second comma](C2-parts-past-the-second-comma.md) - [C3 — counting the words before the comma](C3-counting-the-words-before-the-comma.md) - [C4 — the ambiguous credential class at the comma](C4-ambiguous-credential-class-at-the-comma.md) - [C5 — paired initials after the comma](C5-paired-initials-after-the-comma.md) - [C6 — an unlisted all-caps word needs the name's case contrast](C6-unlisted-all-caps-word-needs-the-names-case-contrast.md) - [C7 — a postnominal part is read before any join](C7-postnominal-part-is-read-before-any-join.md) - [C8 — the listing form's part after the comma](C8-listing-forms-part-after-the-comma.md) - [C9 — a one-character suffix word inside a comma part](C9-one-character-suffix-word-inside-a-comma-part.md) - [C10 — a suffix delimiter parts a suffix run](C10-suffix-delimiter-parts-a-suffix-run.md) - [C11 — what the comma decision reports](C11-what-the-comma-decision-reports.md) - [C12 — a suffix word ending the name before the comma](C12-suffix-word-ending-the-name-before-the-comma.md) - [C13 — a credential opening the part after the comma](C13-credential-opening-the-part-after-the-comma.md) - [C14 — strict mode: a suffix word written as an initial](C14-strict-mode-a-suffix-word-written-as-an-initial.md) - [C15 — a comma with nothing before it](C15-comma-with-nothing-before-it.md) ## [Name order (O)](O-name-order.md) - [O1 — East Slavic rotation](O1-east-slavic-rotation.md) - [O2 — Turkic rotation](O2-turkic-rotation.md) - [O3 — compound family names written unmarked](O3-compound-family-names-written-unmarked.md) - [O4 — positional assignment and declared order](O4-positional-assignment-and-declared-order.md) - [O5 — the lone name word's convention, and reporting it](O5-lone-name-words-convention-and-reporting-it.md) - [O6 — two family names](O6-two-family-names.md) ## [Scripts & writing systems (W)](W-scripts-and-writing-systems.md) - [W1 — unspaced CJK division](W1-unspaced-cjk-division.md) - [W2 — the glued-honorific peel](W2-glued-honorific-peel.md) - [W3 — the writer's divisions are respected](W3-writers-divisions-are-respected.md) - [W4 — script-scoped order](W4-script-scoped-order.md) ## [Tokens, initials & punctuation (T)](T-tokens-initials-and-punctuation.md) - [T1 — separators, not joiners](T1-separators-not-joiners.md) - [T2 — the katakana middle dot divides like a space](T2-katakana-middle-dot-divides-like-a-space.md) - [T3 — the interpunct's flank guard](T3-interpuncts-flank-guard.md) - [T4 — the bare letter as an initial](T4-bare-letter-as-an-initial.md) - [T5 — the written shape of an initial](T5-written-shape-of-an-initial.md) ## [Ambiguity & tie-breaking (A)](A-ambiguity-and-tie-breaking.md) - [A1 — the ambiguity report](A1-ambiguity-report.md) - [A2 — the empty name](A2-empty-name.md) ## [Rendering & views (R)](R-rendering-and-views.md) - [R1 — fields are views of the parsed words](R1-fields-are-views-of-the-parsed-words.md) - [R2 — the all-particles family_base divergence](R2-all-particles-family-base-divergence.md) - [R3 — initials honor the fold](R3-initials-honor-the-fold.md) - [R4 — case repair reads the unjoined mark](R4-case-repair-reads-the-unjoined-mark.md) - [R5 — the case-repair gate](R5-case-repair-gate.md) ## [Construction & configuration diagnostics (D)](D-construction-and-configuration-diagnostics.md) - [D1 — the segmenterless-activation warning](D1-segmenterless-activation-warning.md) - [D2 — construction raises, parse never does](D2-construction-raises-parse-never-does.md) derek73-python-nameparser-456b024/docs/design/rules/S-suffixes-generational-and-credentials.md000066400000000000000000000027551527303271700323320ustar00rootroot00000000000000## Suffixes: generational & credentials (S) Background: what follows a name is one of two different things — generational suffixes (Jr., III), which attach to the name itself, and credentials (PhD, MD, MBA), which are earned attachments. The suffix sets match one written word at a time: a multi-word entry there can never match anything and is warned about at configuration. Only three sets are exempt from that rule -- given_name_titles, maiden_markers since #434, and title_phrases since #894 -- and none is a suffix set, so within the suffix vocabulary the one-word rule is absolute. That limit is on STORAGE, not on the shape a name may have: adjacent suffix tokens are reassembled after matching (`_vocab.is_wholly_suffix`), so a multi-word credential is reachable as its component words -- `John Smith, MD PhD` has read suffix `MD PhD` since 1.4.0 -- and a caller reaches an unshipped one by adding the words it is made of rather than the phrase (#433). The eight multi-word entries that shipped dead for years span the suffix sets and the titles alike and are the Excluded story in decisions/vocabulary-exclusions. CLDR personNames keeps them as separate fields (`generation`, `credentials`) and formats them differently; this library currently reports both in one `suffix` field, a merge #326 examines. The vocabulary is largely split already: a generational word list and a credential acronym list, plus a short list of acronyms that are also ordinary names (MA, BA) and so are AMBIGUOUS as bare words. derek73-python-nameparser-456b024/docs/design/rules/S1-bracketed-credential-is-not-a-nickname.md000066400000000000000000000053221527303271700323130ustar00rootroot00000000000000# S1 — a bracketed credential is not a nickname **When:** a clause enclosed in brackets or quotes stands in a name, as in "Andrew Perkins (MBA)". **Then:** an enclosed clause whose content is suffix-shaped is not a nickname: the brackets or quotes are dropped and the content reads exactly as if written bare. **Because:** brackets and quotes set off more than nicknames — credentials are routinely written parenthesized after a name, and a credential is recognizable by its form. ``` "Andrew Perkins (MBA)" → suffix="MBA" "Andrew Perkins «MBA»" → suffix="MBA" "Andrew Perkins (Andy)" → nickname="Andy" · boundary ``` **Except:** - **A clause of two or more words** is not suffix-shaped, whatever it holds, unless an ASCII period stands last before its closer: suffix-shaped is asked of the whole clause, so a run of credentials keeps its brackets or quotes and reads as its pair reads it, a nickname in a nickname pair. ``` "John Smith (PhD MBA)" → nickname="PhD MBA" "John Smith (PhD MBA)" → ambiguities=() "John Smith (MA PhD)" → ambiguities=("suffix-or-nickname",) "John Smith (MBA PhD.)" → suffix="MBA PhD." · boundary ``` - **A clause that is one member of the ambiguous class (S4)** is not suffix-shaped, so in a nickname pair it stays a nickname, and the fork is reported, as it is for such a member anywhere in a nickname: the word is also a name, so the brackets may hold the name the person goes by, an accepted cost (decisions/suffix-acronym-collisions). A credential standing in front of the brackets does not speak for it (S7), and a final period makes the clause suffix-shaped, a member's included. ``` "John Smith (Ma)" → nickname="Ma" "John Smith (Ma)" → ambiguities=("suffix-or-nickname",) "John Smith PhD (Ma)" → nickname="Ma" "John Smith (M.A.)" → suffix="M.A." · boundary "John Smith (MA.)" → suffix="MA." · boundary ``` **Precedence:** - **A member after the name whose writing settles it** ("John Smith (MA)", "John Smith (M.A)"): S13 reads it as the suffix, and the nickname reading above is left to a member it does not settle. - **A bracketed clause led by a maiden marker** ("Jane Smith (née Jr.)"): suffix-shaped content is taken here first, and the clause is no maiden name (M3), so M2's delimiter precedence never reaches it. - **A clause M3 reads as a maiden name** ("Jane Smith (née Jones)"): its content is not suffix-shaped, so this rule leaves it to M3; the marker-led clause that is suffix-shaped is the bullet above. --- **Signal:** shape: suffix-shaped content inside brackets or quotes **history:** decisions/S1 · **implemented:** nameparser/_pipeline/_extract.py derek73-python-nameparser-456b024/docs/design/rules/S10-trailing-roman-numeral.md000066400000000000000000000200271527303271700275120ustar00rootroot00000000000000# S10 — the trailing roman numeral **When:** a roman numeral stands in a name's trailing run — ending the name, or with other suffix words behind it — as in "John Smith III" or "John Smith VI MD". **Then:** the numeral reads as the generation: the numerals the vocabulary lists, and those up to X it does not, by their shape alone. - **Wherever it stands in the run, in two or more letters.** A numeral of two or more letters with a credential or another suffix word behind it reads as one ending the name does, so every such spelling joins the run as the listed ones always have. ``` "John Smith VI MD" → suffix="VI MD" "John Smith III MD" → suffix="III MD" "Jane Doe nee Smith VI PhD" → suffix="VI PhD" ``` - **A numeral of two or more letters keeps its reading written with a period**, as the German ordinal writes one, the listed numerals reading so too. ``` "Georg VI." → suffix="VI." "Konstantin VII." → suffix="VII." ``` - **What reports.** A numeral of one letter reports whichever way the words-to-spare count reads it: either reading is reported, as either reading of an ambiguous acronym is. A numeral read by its shape alone reports too; a numeral the vocabulary lists in two or more letters is not in question and reports nothing. ``` "John Smith V" → ambiguities=("suffix-or-name",) "John V" → ambiguities=("suffix-or-name",) "John Smith VI" → ambiguities=("suffix-or-name",) "John Smith VI MD" → ambiguities=("suffix-or-name",) "John Smith III" → ambiguities=() ``` - **Written case settles the fork one way:** a numeral written in lower case in a name of mixed case is the generation and reports nothing, being no initial there (T4). ``` "John Smith vi" → ambiguities=() ``` - **And leans it the other:** a Title-case numeral in a name of mixed case leans to the name, as a Title-case member of the ambiguous class does (S6), and reports, the fork having weighed it. ``` "Nguyen Van Vi" → family="Van Vi" "Nguyen Van Vi" → ambiguities=("suffix-or-name",) "John Smith Vi" → family="Vi" ``` - **A name written wholly in one case says nothing about any letter in it** (T4). ``` "JOHN V" → family="V" "john v" → family="v" "john smith v" → ambiguities=("suffix-or-name",) "john smith i" → ambiguities=("suffix-or-name",) "JOHN SMITH VI" → suffix="VI" ``` - **A marked letter (P8) that is such a numeral reports this way and not as a connective.** ``` "john i" → family="i" "john i" → ambiguities=("suffix-or-name",) "john i dr." → ambiguities=("suffix-or-name",) ``` **Because:** a roman numeral ending a name is its generation, and the post-nominals written after the generation do not change what it is. But one letter is also how an initial is written. A name of two words has no word to spare for it. ``` "John Smith V" → suffix="V" "John W. Ingram V" → suffix="V" "John W Ingram V" → suffix="V" "John Ingram V" → suffix="V" "John Smith VI" → suffix="VI" "John III" → suffix="III" ``` **Except:** - **A numeral of one letter needs words to spare**, as a bare ambiguous acronym does (S4): as the second of two name words it is the family name, a title in front being no word to spare. ``` "John V" → family="V" "Malcolm X" → family="X" "Dr Smith V" → family="V" "John V, Dr." → family="V" ``` - **An initial in front makes it a name word too.** When the word in front of the numeral is written as an initial, the numeral is a name word, unless the vocabulary lists it in two or more letters; in a name written wholly in one case a bare letter in front is written as one (T4). ``` "John J. V" → family="V" · boundary "John J. VI" → family="VI" · boundary "John J. III" → suffix="III" "john j v" → family="v" ``` - **A single letter written with a period is an initial** (T5). ``` "John Smith V." → family="V." · boundary ``` - **A numeral past X is a name word.** ``` "John Smith XI" → family="XI" · boundary "John Smith XI MD" → family="XI" · boundary ``` **Precedence:** - **Before a suffix comma** ("John Smith V, PhD"): a numeral ending the part before the comma is read by this rule, and C12 says when it makes the comma a suffix comma. Written case is asked of the whole name, the credential after the comma included, so a part written in lower case before a capitalized credential belongs to a name of mixed case: its lower-case numeral is the generation and reports nothing, and a bare lower-case letter in front of it is no initial (T4). ``` "john smith v, PhD" → ambiguities=() "john j v, PhD" → family="j" ``` - **Before a family comma** ("John Smith V, M.J."): a numeral ending the family part is read by this rule, its words to spare counted in that part (C1). Only a generation its shape alone reads reports there; a numeral the comma leaves in the family name is silent, the comma having named the family. ``` "Smith VI, John" → suffix="VI" "Smith VI, John" → ambiguities=("suffix-or-name",) "Smith V, John" → family="Smith V" "Smith V, John" → ambiguities=() "Smith Vi, John" → ambiguities=() ``` - **In the given part after a family comma** ("Smith, John VI"): the given part is not counted, the comma having settled the count there (S5), and no initial in front is asked. A numeral of two or more letters reads as the generation, reporting where its shape alone reads it, save a Title-case one in a mixed-case name, which stays a name word in silence; a one-letter numeral is the generation only where the vocabulary lists it and it ends the part (C9), so an unlisted one stays the middle initial. ``` "Smith, John VI" → suffix="VI" "Smith, John VI MD" → suffix="VI MD" "Smith, John V" → suffix="V" "Smith, John X" → middle="X" "Smith, John V MD" → middle="V" "Smith, John Vi" → middle="Vi" "Smith, John Vi" → ambiguities=() ``` **Accepted:** - A numeral of one letter with another suffix word behind it is not the last word of the name and is read as any word there is, so a one-letter numeral before a credential is the family name, and the reading is reported, as either reading of the fork is, whether the credential is written as one word or split across two (S12). A lone letter there abbreviates a surname as often as it numbers a generation. ``` "John Smith V PhD" → family="V" "John Smith V PhD" → ambiguities=("suffix-or-name",) "John Smith V Ph. D." → family="V" "John Smith V Ph. D." → ambiguities=("suffix-or-name",) "john i phd" → ambiguities=("suffix-or-name",) "Jane Doe nee Smith V PhD" → ambiguities=("suffix-or-name",) ``` - A generation written in Title case in a mixed-case name reads as a name word, the lean that keeps a surname such as 'Vi' a name taking it too. ``` "John Smith Viii" → family="Viii" ``` - A numeral the vocabulary lists in two or more letters is not weighed: an initial in front does not make it a name word, nor does Title case lean it to the name, the vocabulary settling it as it settles any unambiguous suffix word. No one writes 'III' as an initial or bears 'Ii' as a name. ``` "John Smith Iii" → suffix="Iii" ``` --- **Signal:** shape **history:** decisions/S10 · **implemented:** nameparser/_pipeline/_assign.py, nameparser/_pipeline/_group.py, nameparser/_pipeline/_pieces.py, nameparser/_pipeline/_vocab.py derek73-python-nameparser-456b024/docs/design/rules/S11-suffix-word-that-is-also-a-name.md000066400000000000000000000125761527303271700310550ustar00rootroot00000000000000# S11 — a suffix word that is also a name **When:** a generation word that is also borne as a name, or a spelling of one, stands where a suffix may stand, as in "Pedro Alves Neto" or "Alexandre Dumas fils". **Then:** it reads as the generation only where the name has words to spare, as a bare ambiguous acronym does (S4), and at the slots that report, either reading carries the ambiguity flag. - **The writing is read by S6**, and for a generation word such as French `fils` it speaks at the other end of the case: lower case in a name of mixed case is the generation, Title case leans to the name, capitals say nothing. The floor holds first: lower case settles it only where the name keeps a family word, so two name words keep the family, and at the first slot after a comma the count of name words before it decides alone (C4). An agnomen, the Portuguese `Filho`, `Neto`, `Sobrinho` and their feminines, is written in the case of the name around it, so its writing never speaks and the count decides alone; so is each abbreviation of one that Brazilian filings write (`FH`, `Fo`, `Nt`, `Sobr`), a spelling of the word it shortens that takes the word's name reading with it, borne or not. - **Only name words in front of it count**: a word there that the trailing run takes, a suffix word, a roman numeral read as the generation (S10), a member of the ambiguous class its writing makes a suffix (S6) or one such a word carries in front of it (S7), is no word to spare, and a suffix word there is no company either, as it is for an acronym (S7). - **After a comma** the word is read as a member of the ambiguous class is: by the count of the name words before the comma at the first slot after it (C4), and by its writing alone where it ends the given part after a family comma (S5). **Because:** an agnomen marks its bearer against a relative of the same name, as Junior does, so it ends the name the way a generation does and is not part of the surname the family shares. But `Neto`, `Fils` and `Neta` are borne as names too, and a name of two words has no word to spare for a generation. Case tells a French surname from its generation word, and it cannot tell an agnomen, which takes the case of whatever it is written beside. ``` "Pedro Alves Neto" → family="Alves" "Pedro Alves Neto" → suffix="Neto" "Pedro Alves Neto" → ambiguities=("suffix-or-name",) "PEDRO DA SILVA FILHO" → suffix="FILHO" "JOSE DA SILVA FH" → suffix="FH" "Pedro Alves Sobr." → suffix="Sobr." "Pedro Alves Neto, PhD" → suffix="Neto, PhD" "Pedro Alves Neto Júnior" → suffix="Neto Júnior" "Alexandre Dumas fils" → suffix="fils" "Alves, Pedro Neto" → suffix="Neto" "Ana Neto" → family="Neto" · boundary "Ana Neto" → ambiguities=("suffix-or-name",) · boundary "PEDRO FILHO" → family="FILHO" · boundary "Ana Fo" → family="Fo" · boundary "Silva, Neto" → given="Neto" · boundary "Marie Fils" → family="Fils" · boundary "Pedro Junior Filho" → family="Filho" · boundary ", Dr. fils" → family="fils" · boundary "Dumas, fils" → given="fils" · boundary "PEDRO JUNIOR FILHO" → family="FILHO" · boundary "Pedro VI Filho" → family="Filho" · boundary "Pedro MA Filho" → family="Filho" · boundary "Pedro Junior Ma Filho" → family="Filho" · boundary "Pedro Alves Junior Filho" → suffix="Junior Filho" ``` **Except:** - **Glued behind an initial** with a period between, the word is part of that one word and is weighed only by the shape the joined word wears (S3), as it was before the word was listed: the name word it is with no word to spare, the dotted credential shape with words to spare. ``` "Pedro M.Filho" → family="M.Filho" "Pedro Alves M.Filho" → suffix="M.Filho" ``` **Precedence:** - **Written case in a mixed-case name** ("Marie Fils", "Dumas fils"): a generation word's Title case outranks the count, as an acronym's does (S6), while its lower case gives way to the floor, a name keeping its family word; an agnomen has no case to weigh. ``` "Jean Marie Fils" → family="Fils" "Dumas fils" → family="fils" "Pedro Junior fils" → family="fils" "Pedro Alves NETO" → suffix="NETO" ``` **Accepted:** - A name word standing straight before it counts as a name word whatever it is, so an initial there is left as the family name and a particle there as a family name of its own, as with an ambiguous acronym. An initial before an agnomen most often abbreviates the surname it follows. ``` "Pedro J. Neto" → family="J." "Pedro de Neto" → family="de" ``` - A three-word name whose last word is a surname that is also an agnomen loses that surname to the suffix, reported, as `Ruth Ann Neta` does. A register whose filer typed the agnomen alone in the surname box disagrees with the reading, and one whose filer typed it after the surname before it agrees. ``` "Ruth Ann Neta" → suffix="Neta" ``` --- **Signal:** count: name words to spare beside a generation word that is also a name **history:** decisions/S11 · **implemented:** nameparser/_pipeline/_classify.py, nameparser/_pipeline/_pieces.py, nameparser/_pipeline/_vocab.py derek73-python-nameparser-456b024/docs/design/rules/S12-credential-split-across-a-space.md000066400000000000000000000154371527303271700312030ustar00rootroot00000000000000# S12 — a credential split across a space **When:** a credential stands written as two words, as in "John Smith Pharm. D.". **Then:** a credential written split across a space reads as one word where its two words are short chunks whose letters together spell unambiguous suffix vocabulary and the two are not both suffix vocabulary on their own, as a member of the ambiguous class (S4) that the count decides as it decides a bare acronym, so that where the count declines it, the pair is the two words it is written as. - **Which pairs.** Each word is one to five letters, with or without its period, and the first has two or more: a single letter opening the pair is a middle initial far more often than the start of a credential. A particle or a connective written without a period is no chunk of one either, since it joins name words, which is what it is doing beside one, and for the same reason a pair a connective borders is no credential. ``` "John Smith Pharm. D." → suffix="Pharm. D." "John Smith Pharm D" → suffix="Pharm D" "John Smith Psy. D." → suffix="Psy. D." "John Smith M. D." → family="D." · boundary "Jane Doe de D Junior" → family="de D" · boundary "John Smith Pharm D and Mary" → family="D and Mary" · boundary ``` - **Case repair** writes the pair as it writes the one-word spelling, the letters of the two words cased as their fold is, and a pair written in two cases is the writer's spelling as the one-word spelling would be (R5). ``` "john smith pharm d" → capitalized="John Smith Pharm D" "john smith, PHARM D" → capitalized="John Smith Pharm D" ``` - **The count decides.** At the end of a name it needs the words to spare S4 asks of a bare member; after a comma, the name words C4 counts before the comma; ending the given part after a family comma, it reads as S5 reads a member there, its writing saying nothing else. ``` "John Pharm D" → family="D" · boundary "John Smith, Pharm D" → suffix="Pharm D" "Smith, Pharm D" → given="Pharm" · boundary "Smith, John Pharm D" → suffix="Pharm D" ``` - **The run.** Behind two name words where no comma divides the name, or after the given word in the part after a family comma, the pair starts the credential's run (S8) as its one-word spelling does, and a name word the run absorbs reports. ``` "John Smith Pharm D Jones" → suffix="Pharm D Jones" "Smith, John Pharm D Jones" → suffix="Pharm D Jones" ``` - **What reports.** Where the count takes the pair, the reading is reported, as either reading of a member is (S4), its words being a given name and an initial as often as a credential's chunks; where the count declines it, the two words read as they read unmerged and report what they reported. After a comma the pair reports only where the comma's own report does not already cover the part (C11), and a part past the second comma reports as C2 says. ``` "John Smith Pharm D" → ambiguities=("suffix-or-name",) "John Smith, Pharm D" → ambiguities=("suffix-or-name",) "JOHN SMITH SAM E." → ambiguities=("suffix-or-name",) "SMITH, SAM E." → ambiguities=() · boundary ``` **Because:** a credential is often typed with a space between its parts, and its letters together are what the vocabulary lists. But two short words are also how a given name and its initial are written, so the pair buys only the ambiguous reading, and the count decides it. ``` "John Smith MB ChB" → suffix="MB ChB" "SMITH, SAM E." → given="SAM" · boundary "SMITH, SAM E." → middle="E." · boundary ``` **Except:** - **The Ph./D. pair is an unambiguous credential**, merged wherever it stands but the head of the string and read as a suffix there without the count (decisions/phd-merge), so the count does not decide it; where it overlaps another pair, it is the pair that merges. ``` "John Ph. D." → suffix="Ph. D." "Jane Doe Dr. Ph. D." → title="Dr." "Jane Doe Dr. Ph. D." → suffix="Ph. D." ``` - **Two dotted single letters** in a script that writes capitals are paired initials, which S3 reads as the name word they are written as, and never merge, nor do they in a script that writes no abbreviations, which wears no abbreviation's periods (S3); in any other script without capitals they merge where their letters spell listed vocabulary, which no default entry does. ``` "John Smith J. R." → family="R." · boundary "John Smith 博. 士." → family="士." · boundary ``` - **The part before a family comma** reads no member of the class as a suffix, so no pair merges there. ``` "Smith Pharm D, John" → family="Smith Pharm D" · boundary ``` **Precedence:** - **The head of the string** ("Pharm D John Smith"): a suffix never opens the string (S2), so no pair merges there. ``` "Pharm D John Smith" → given="Pharm" ``` - **A one-letter numeral in front** ("John Smith V Pharm D"): it is the name word S10 reads before any credential, one word or split. ``` "John Smith V Pharm D" → family="V" ``` - **Written case** ("Jack PHARM D"): the pair carries no case of its own for S6 to read, its two words being two spellings, so the count alone decides it, as it decides a member admitted by shape. ``` "Jack PHARM D" → family="D" ``` - **The given part's report** ("Smith, John Pharm D"): S5 reads the pair as it reads a member ending the given part, and the pair reports there as this rule says it does. ``` "Smith, John Pharm D" → ambiguities=("suffix-or-name",) ``` **Accepted:** - A split credential opening with a single letter reads as the two words it is written as, and after a comma behind a whole name the name is reversed around it: `M. D.`, `M D` and `M. Sc.` are written so. ``` "John Smith, M. D." → given="M." "John Smith, M. D." → family="John Smith" ``` - A given name and its initial whose letters spell a listed credential read as the credential, reported, behind two name words where no comma divides the name, and ending the given part after a family comma: the count, or the given part's reading, takes them as it takes the credential they spell. ``` "JOHN SMITH SAM E." → suffix="SAM E." "Smith, John Sam E." → suffix="Sam E." "JOHN SMITH SAM E." → capitalized="John Smith SAM E." ``` --- **Signal:** shape: two short words whose letters together spell a listed credential, decided by the count **history:** decisions/S12 · **implemented:** nameparser/_pipeline/_assign.py, nameparser/_pipeline/_classify.py, nameparser/_pipeline/_comma.py, nameparser/_pipeline/_group.py, nameparser/_pipeline/_pieces.py, nameparser/_pipeline/_vocab.py derek73-python-nameparser-456b024/docs/design/rules/S13-bracketed-member-settled-by-its-writing.md000066400000000000000000000122621527303271700326540ustar00rootroot00000000000000# S13 — a bracketed member settled by its writing **When:** a clause that is one member of the ambiguous class (S4) stands in brackets or quotes after the name, with no name word after it, though a suffix, a title or a maiden clause may follow however each is written, as in "John Smith (MA)". **Then:** where the member's writing settles it as the credential, the way the rules that read the writing say a credential is written, it is no nickname but the suffix, and the clash is not reported. - **Written with a period inside it**, whatever case the name is in: the periods are how a credential is written and a name is not (S3). - **Written in capitals in a name written in more than one case**: the case is asked of the name's own words and never of the clause's, so capitals settle nothing in a name written wholly in one case, and Title case leans to the name (S6). - **It is the suffix, not the word read as if written bare** (S1): the brackets have already set it apart from the name's words, and bare, two letters each followed by a period but the last read as initials (S3). The rest of the name is read as beside a nickname, which is where it can differ from a bracketed unambiguous credential in the same place, its twin, as the Accepted bullets record. **Because:** after the name, brackets hold either a credential or the name the person goes by, and the writing tells the two apart where the count of words cannot: credentials are written in capitals and with their periods, and names are not. Inside the name the same word let out of the brackets would read as a middle name, where the brackets more likely hold the name the person goes by. So only the clause after the name is settled. ``` "John Smith (MA)" → suffix="MA" "John Smith (MA)" → ambiguities=() "John Smith (M.A)" → suffix="M.A" "JOHN SMITH (M.A)" → suffix="M.A" "Smith, John (MA)" → suffix="MA" "John Smith PhD (MA)" → suffix="PhD MA" "John Smith (MA), PhD" → suffix="MA, PhD" "John Smith (MA) Ph. D." → suffix="MA Ph. D." "John Smith (MA) Dr." → title="Dr." "John Smith (MA) Dr." → suffix="MA" "John Smith «MA»" → suffix="MA" "Jack (M.A)" → suffix="M.A" "John Smith 「MA」" → suffix="MA" "John Smith (Ed)" → nickname="Ed" · boundary ``` **Except:** - **A member in a name written in one case, or not in capitals**, is settled only by a period inside it: its case says nothing (S6), and it stays the nickname S1 reads. ``` "JOHN SMITH (MA)" → nickname="MA" "JOHN SMITH (MA)" → ambiguities=("suffix-or-nickname",) "john smith (MA)" → nickname="MA" "John Smith (Ma)" → nickname="Ma" ``` - **A member standing inside the name or before it** stays the nickname, however it is written, and so does one in a name with no name word of its own, or one with a word after it the parse reads as a name word, a stray full stop included. ``` "Jeffrey (JD) Bricken" → nickname="JD" "JEFFREY (JD) BRICKEN" → nickname="JD" "Jean 'JD' Smith" → nickname="JD" "Jean (M.A) Smith" → nickname="M.A" "Jean (M.A) Smith" → ambiguities=("suffix-or-nickname",) "Smith (MA), John" → nickname="MA" "(MA) John Smith" → nickname="MA" "Dr. (MA)" → nickname="MA" "(M.A)" → nickname="M.A" "John Smith (MA)." → nickname="MA" "John Smith (MA) Ma" → nickname="MA" ``` **Precedence:** - **Two letters each followed by a period but the last** ("John Smith (J.D)"): this rule reads the credential where S3 reads the same word bare as paired initials ("John Smith J.D" gives family 'J.D'), the brackets having already set it apart from the name's words. **Accepted:** - A lone name word beside a settled member stays the family name it is beside a nickname (N3), where beside its twin, "(PhD)", it is the given name, reported. ``` "Jack (MA)" → family="Jack" "Jack (MA)" → ambiguities=() ``` - A maiden clause after a settled member stays the maiden name, where after its twin the credential's trailing run takes it (S8). ``` "John Smith (MA) née Jones" → maiden="Jones" ``` - The case is asked once, of the name's own words without any clause, so a member in capitals beside a settled one in a name otherwise written in one case stays the nickname, where its twin, joining the name's words, makes the name mixed and settles the other too. ``` "JOHN SMITH (m.a) (MA)" → nickname="MA" "JOHN SMITH (m.a) (MA)" → suffix="m.a" ``` - A part past the second comma is weighed without the member, so what stands beside it decides whether the part reports, and the report names that alone, where its twin is weighed as a credential of the part. ``` "Smith, John, X.Y.Z. (MA)" → ambiguities=() "Smith, John, Bob (MA)" → ambiguities=("comma-structure",) ``` --- **Signal:** position: a member of the ambiguous class alone in brackets or quotes after the name; hub of S1, S3, S6 **history:** decisions/S13 · **implemented:** nameparser/_pipeline/_post_rules.py S2-suffix-word-ends-a-name-and-never-opens-the-string.md000066400000000000000000000154001527303271700344100ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/rules# S2 — a suffix word ends a name and never opens the string **When:** a word of the suffix vocabulary stands in a name, as in "John Smith PhD". **Then:** a trailing word of the suffix vocabulary reads as a suffix — generational forms and credential acronyms alike — and a suffix never OPENS THE STRING, the run's words being read by rules of their own. - **A word whose period-separated chunks the vocabulary knows, an ambiguous acronym written with its periods, or an unlisted dotted word** is read by S3. - **A bare ambiguous acronym** is read by S4, S5, S6 and S7. - **A word joining that class by its shape** is read by S9. - **A credential with more name text behind it** is read by S8. - **A roman numeral** is read by S10. - **A generation word that is also a name** (`fils`, the agnomen `Neto`) is read by S11. **Because:** generational suffixes and credentials are recognized by vocabulary, and are written after the name they belong to. ``` "John Smith Jr." → suffix="Jr." "Antonio Pavlović mlađi" → family="Pavlović" "Antonio Pavlović mlađi" → suffix="mlađi" "José Silva Júnior" → suffix="Júnior" "Pedro Alves Filho" → suffix="Filho" "Pedro Filho" → family="Filho" · boundary "Агриппина Младшая" → suffix="Младшая" "جورج بوش الابن" → suffix="الابن" "ロバート・ダウニー・ジュニア" → suffix="ジュニア" "佐藤恵子 国会議員" → suffix="国会議員" "陳子媚 二世" → suffix="二世" "Питер Брейгель мл." → suffix="мл." "Alexandre Dumas fils" → suffix="fils" "Marie Fils" → family="Fils" · boundary "Jiří Dienstbier ml." → family="ml." · boundary "Pavlović, Antonio mlađi" → suffix="mlađi" "John Smith PhD" → suffix="PhD" ``` **Precedence:** - **A suffix-shaped word opening the string** ("Ph. D. Van Johnson"): position outranks the vocabulary match, so a suffix-shaped word written before anything else reads as whatever its position and shape make it — a title where it wears the abbreviation shape (H2), an ordinary name word otherwise — and never as a credential belonging to a name that has not been written yet. The string, not the name: a title, a nickname or a bracketed clause standing first leaves the credential reading intact, which is the ONE place this document says "opening" and does not mean what P4 and the H Background mean by it. ``` "Ph. D. Van Johnson" → family="Van Johnson" "Ph. D. Van Johnson" → title="Ph." "II Van Johnson" → given="II" · boundary "Stariji Bruegel" → given="Stariji" · boundary "Sir Ph. D. Van Johnson" → suffix="Ph. D." · boundary ``` - **A family comma** ("Smith, Ph. D. Jr.", "Ph. D., John"): a family comma is the other boundary: the comma has already named the family, so a credential run may open the part after it (C13), and the part before it is that family, less a title run opening it (H6). ``` "Smith, Ph. D. Jr." → suffix="Ph. D. Jr." "Ph. D., John" → family="Ph. D." · boundary ``` - **The connectives, the particle chain and the bound given-name join** ("John van der Berg MA"): the run at a name's trailing slot is read ONCE, once the connectives have joined (P3) and before the particle chain (P2) and the bound given-name join (P5) are made, counting a particle run and the name words the chain will take with it as the one name word it will make of them — the words both particles and suffix vocabulary standing straight behind a particle among them, while a word the reading weighs counts as a word of its own wherever it stands; inside a credential run a word stands on its own, a title word there still a title — and neither join reaches a word the run took. - **A title run in front of a trailing suffix word** ("Dr Pope Jr"): a title run leaves one NAME word standing and a post-nominal cannot be it (H3), so `Dr Pope Jr` reads family `Pope`, suffix `Jr` exactly as this rule states. - **Written case and the count, at the bare ambiguous acronym** ("Jack MA"): written case outranks the count in a mixed-case name everywhere but the first slot after a comma, where the count decides first (S6, C4); the given part's trailing slot after a family comma is read without the count (S5); and an unambiguous credential in front outranks both (S7). - **An ambiguous acronym written with its periods** ("Jack M.A."): it counts unambiguously, and is not the bare acronym S4 counts words to spare for (S3). - **A credential's run against its company** ("John Smith PhD Prof. Ma"): a title standing between the credential and the member, and a member a particle chain (P2) had taken, are the credential's run's (S8), where the company could not reach them (S7); so are a one-letter numeral and a name word, which end the company but not the run. A connective between the credential and a word after it is joined to the credential first (P3), so no run starts, except in a part a credential opens after a comma (C13); one further in is the run's. **Accepted:** - A title before the credential keeps it a credential, and behind a title run that addresses by the given name (H1) the name loses its surname exactly as it did before the clause that a suffix never opens the string — `Sir Ph. D. Van Johnson` reads given `Van Johnson` with an empty family. That is 1.4.0's reading, and that clause cannot reach it: the first piece of the NAME is not computable before this decision, since the abbreviation test H2 uses is true of `Ph.` itself, so a scan that stepped over titles would step over the very piece being judged. - An unambiguous suffix is consumed even when that leaves no family name at all. ``` "Smith Jr." → family="" ``` - What the title run's floor (H3) cannot reach stays descriptive and is small: where the run's whole content is the word given back, no title is left to name anybody, so the word stands as the name rather than as the family — `Dr Jr` reads given `Dr`, suffix `Jr`, reporting `title-or-name` (H4), and `Sir Jr` reads by the same branches — given `Sir`, suffix `Jr`, the same kind reported — the run being empty by then and no branch reading `vocab:given-title`. The vocabulary half is decided: `pope` is title vocabulary, as `king` was until it became a given name outside a title phrase (decisions/v1-xfail-triage, decisions/H9). ``` "Dr Jr" → suffix="Jr" ``` --- **Signal:** position: a suffix word ending the name, never opening the string; hub of S3, S4, S5, S6, S7, S8, S9, S10, S11 **history:** decisions/S2 · **implemented:** nameparser/_pipeline/_classify.py, nameparser/_pipeline/_group.py, nameparser/_pipeline/_pieces.py derek73-python-nameparser-456b024/docs/design/rules/S3-credentials-written-in-joined-chunks.md000066400000000000000000000257421527303271700322130ustar00rootroot00000000000000# S3 — credentials written in joined chunks **When:** a word written in chunks, joined by interior periods or by a hyphen or a slash, stands in a name, as in "John Smith Msc.Ed.". **Then:** a word with interior periods reads as a suffix when any of its period-separated chunks is suffix vocabulary, a word joined by a hyphen or a slash when every one of its chunks is, and a word of two or more period-separated chunks that no vocabulary claims is read by POSITION instead, as a bare ambiguous acronym is (S4). - **An ambiguous acronym written with its periods**, one after each letter or one after each of two or more letter chunks, counts unambiguously; a single trailing period is the abbreviation shape any word can wear and does not. Written so, it is not the bare acronym S4 counts words to spare for: the periods are how a credential is written and a name is not. ``` "John Smith M.A." → suffix="M.A." "Wang M.Eng." → suffix="M.Eng." ``` - **A word no vocabulary claims, read by position**, is a credential where the name has words to spare, a name word where it does not, either reading reported at the slots S4 reports at, and the same at a comma — which means the FIRST piece after a family comma, the word trailing the given part after one, the word ending a maiden marker's clause (M2), and the part before a SUFFIX comma. The part before a FAMILY comma never reports, the comma having already named it the family. ``` "John Smith J.u.n.i.o.r." → suffix="J.u.n.i.o.r." "John Smith Q.W.E.R.T." → suffix="Q.W.E.R.T." "John Smith X.Y.Z." → suffix="X.Y.Z." "Doe, John X.Y.Z." → suffix="X.Y.Z." "Jane Doe nee Smith X.Y.Z." → suffix="X.Y.Z." ``` - **What the position reading reads.** Case says nothing here — the periods are the evidence — and three shapes are outside it: a single trailing period is not this shape at all, a chunk that is not wholly alphabetic is no acronym letter, and a word carrying a script that writes no abbreviations is not wearing an abbreviation's periods (H2 refuses the same word for the same reason). ``` "john smith x.y.z." → suffix="x.y.z." "John Smith 1.4" → family="1.4" · boundary ``` - **A word joined by a hyphen or a slash** is read by a stricter test than the periods' any: a word whose chunks are joined by a hyphen or a slash reads as a suffix when every chunk counts as a suffix as written, which is to say listed and not a bare member of the ambiguous class (S4), a chunk's own periods counting as they do in a word of its own. One chunk that does not count leaves the word as it would be without this bullet, and a joiner standing alone or glued to one side joins nothing, as do the dash characters other than the hyphen-minus and a split credential's halves (`MD/Ph. D.`). ``` "John Smith, MD/PhD" → suffix="MD/PhD" "John Smith MD-PhD" → suffix="MD-PhD" "Smith, John MD/PhD" → suffix="MD/PhD" ", MD/PhD" → suffix="MD/PhD" "John Smith, M.D./Ph.D." → suffix="M.D./Ph.D." "john smith, ms-pt" → suffix="ms-pt" "John Smith, PhD-Md" → suffix="PhD-Md" "John Smith, md-phd" → suffix="md-phd" "John Smith-Jones, MD-PhD" → family="Smith-Jones" "John Smith-Jones" → family="Smith-Jones" · boundary "Smith, Mary-Jane" → given="Mary-Jane" · boundary "John Smith MD-Jones" → family="MD-Jones" · boundary ``` **Because:** credentials are often written run together, with periods between their parts or a hyphen or a slash between two of them, and the chunks are what carry the vocabulary. A word of several chunks that no vocabulary knows is still written the way a credential is written. An acronym that is also an ordinary name is unmistakably a credential when its periods are written. ``` "John Smith Msc.Ed." → suffix="Msc.Ed." "Doe, John Msc.Ed." → suffix="Msc.Ed." "John Smith Msc.Ed." unlisted_dotted_suffixes-off → suffix="Msc.Ed." "Jack Ma." → family="Ma." · boundary "John Smith Xyz." → family="Xyz." · boundary "Ram Bahadur PhD B.K." → suffix="PhD B.K." "สมชาย ใจดี พ.บ." → suffix="พ.บ." ``` **Except:** - **A word joined by a hyphen and written the way a hyphenated name is, its first chunk in Title case and every other in Title case or lower case, is a name word**, whatever its chunks are listed as: that is how a hyphenated given name is written (`Ae-Ra`, `Ae-ra`, the Korean syllables `ae` and `ra` both being listed credentials), and a credential's chunks are written otherwise. A chunk with a capital past its first letter (`PhD`), a chunk in capitals, a word opening in lower case and a slash anywhere in the word, a slash joining no name, leave the word to the joined reading's every-chunk test, and a word holding an interior period is read by its periods first. ``` "Kim, Ae-Ra" → given="Ae-Ra" "Shin Ae-ra" → family="Ae-ra" "John Smith, Md-Rn" → given="Md-Rn" ``` - **Every chunk the vocabulary matches is a single ASCII character**, the roman numerals and the lone digit the vocabulary lists, which are about generations rather than credentials: those chunks do not make the word a suffix. ``` "Jack X.Y.I." → family="X.Y.I." · boundary "Jack I-V" → family="I-V" · boundary "Jack I.-V." → family="I.-V." · boundary "John Smith, MS-V" → suffix="MS-V" ``` - **Paired initials are the exception**, as C5 defines them, in a script that writes capitals: where S4 would count words to spare for them they are the name word they are written as, as the switch off reads every such word, and the fork is still reported. Two dotted single letters is how initials are written in such a script, and how some surnames are (Nepali B.K., K.C.), while a script without capitals writes its abbreviations that way, degrees among them; behind an unambiguous suffix word the pair joins the credential run that word starts (S8). ``` "Ram Bahadur B.K." → family="B.K." "Ram Bahadur B.K." → middle="Bahadur" "Sita Kumari K.C" → family="K.C" "RAM BAHADUR B.K." → family="B.K." "Bahadur, Ram B.K." → middle="B.K." "Ram Bahadur B.K., PhD" → family="B.K." "Jane Doe nee Smith B.K." → maiden="Smith B.K." ``` - **The position reading is a caller switch, ON by default**, and the example lines name it; turned off, such a word is name material and the chunk rule still decides the rest. ``` "John Smith X.Y.Z." unlisted_dotted_suffixes-off → family="X.Y.Z." ``` **Precedence:** - **A joined word in capitals with a chunk the stricter test refuses** ("John Smith, MA/PHD"): S9's caps shape reads it, as it reads any unlisted word written in capitals, so after a comma where the name carries C6's case contrast it joins the ambiguous class, where the same word with a lowercase letter stays the name word this rule leaves it. ``` "John Smith, MA/PHD" → suffix="MA/PHD" ``` - **An ambiguous credential between an unambiguous one and paired initials** ("Jane Doe PhD MA R.T."): the unambiguous word starts the run and the run takes both, the pair included (S8). - **An ambiguous credential its capitals settle, in front of paired initials** ("Jane Doe MA R.T."): it starts the run as an unambiguous one does, and the run takes the pair (S6, S8). ``` "Jane Doe MA R.T." → suffix="MA R.T." ``` - **Paired initials at the first slot after a comma** ("García Márquez, G.J."): they read as the given name unless something in the part speaks for them (C5). - **A flip to the credential run at the first slot after a comma** ("John Smith, X.Y.Z."): where no listed member of the class takes part it is not reported, unless paired initials speak only for each other (C11). **Accepted:** - A joined credential with a chunk that is a bare member of the ambiguous class, written with a lowercase letter, keeps the reading it had before joined chunks were read, where a name in front of a comma is reversed: the hyphen joins names too, and a name word joined to a listed credential is not one the stricter test may take. ``` "John Smith, MA/PhD" → given="MA/PhD" "John Smith, MA/PhD" → family="John Smith" ``` - A Korean given name romanized with a hyphen between two syllables the vocabulary lists as unambiguous credentials, written in capitals or opening in lower case, reads as the credential, unreported after a comma: a name in capitals has no Title case for the writing to show, and `ae` and `ra` are such credentials. ``` "KIM, AE-RA" → suffix="AE-RA" "Kim, AE-RA" → suffix="AE-RA" "kim, ae-ra" → suffix="ae-ra" ``` - A credential whose chunks are all typed in Title case reads as the name word its writing shows, since the writing is the cue and it cannot tell the two apart; after a comma the name is reversed around it. ``` "John Smith, Phd-Md" → given="Phd-Md" ``` - An unlisted credential of two letters written with its periods behind a full name reads as the family name, and the reading is reported: King's Counsel, which no vocabulary lists, is the case. ``` "John Smith K.C." → family="K.C." ``` - Paired initials behind an ambiguous credential that no unambiguous suffix word stands in front of, a listed one or one admitted by its shape (this rule's dotted words, S9's capitals where the caller admits them), and whose own writing does not settle it (S6), end the name and take the credential into it: the reading 1.4.0 through 2.3.0 gave, where a run of credentials is the likelier writing. ``` "Jane Doe Ma R.T." → family="R.T." "Jane Doe Ma R.T." → middle="Doe Ma" "JANE DOE MA R.T." → family="R.T." ``` - The initialless-script clause carries no example line of its own. Every input that exercises it composes a script that writes no abbreviations with a period that only a Latin convention writes — `John Smith 田.中.` is the shape — and a composed form no writing system produces is tolerated input rather than contract, which an example of a normative rule would promise. So the witnesses are the tolerated row tests/v2/cases.py's an_initialless_script_glued_into_periods_is_not_this_shape and the CJK assertions in tests/v2/pipeline/test_vocab.py's test_period_joined_vocab_retires_the_single_character_chunk. W3 states the same precedence for the same reason. --- **Signal:** shape: a word in chunks joined by interior periods, a hyphen or a slash, read by its chunks or as the acronym its letters spell, for paired initials whether its script writes capitals, and for a hyphen-joined word whether it is written as a hyphenated name is **history:** decisions/S3 · **implemented:** nameparser/_pipeline/_classify.py, nameparser/_pipeline/_comma.py, nameparser/_pipeline/_pieces.py, nameparser/_pipeline/_vocab.py derek73-python-nameparser-456b024/docs/design/rules/S4-ambiguous-acronym-needs-words-to-spare.md000066400000000000000000000043241527303271700324740ustar00rootroot00000000000000# S4 — the ambiguous acronym needs words to spare **When:** a bare acronym that is also an ordinary name (MA, Ma) stands at a slot where a suffix may stand, as in "John Ma". **Then:** a BARE ambiguous acronym is consumed only when the name has words to spare — as the second of two words it stays the family name — and at the slots that report, either reading carries the ambiguity flag, unless the word's writing has settled it (S6). - **The slots that report** are the trailing slot of a name, the first slot after a family comma, the trailing slot of the GIVEN part after that comma, and the trailing slot of a maiden marker's clause (M2). A part beyond the second comma is not one of them; what such a part does report is C2's to say. - **A word this document says is READ at one of those slots is not always a word one of them reports**: where a reading moves a word out of the slot that asked about it, what reports is the slot it lands in, and that may be none — M2 states the case where a clause does it. **Because:** an acronym that is also an ordinary name is given up to the credential reading by position only where the name keeps a word for its family. A name of two words has none to spare. ``` "John Ma" → family="Ma" · boundary "wang meng" → family="meng" · boundary "tran lac" → family="lac" "john smith meng" → suffix="meng" "JOHN SMITH MA" → suffix="MA" "Nguyen Van Lac" → family="Van Lac" ``` **Precedence:** - **The given part's trailing slot after a family comma** ("Doe, John MA"): the count is one piece of evidence among several, and the given part's trailing slot after a family comma is read without it (S5). - **Written case in a mixed-case name** ("Jack MA"): written case outranks the count everywhere but the first slot after a comma, where the count decides first (S6, C4). - **An unambiguous credential in front** ("John Smith PhD MEng"): it outranks both the count and the case (S7). --- **Signal:** count: name words to spare beside a bare ambiguous acronym **history:** decisions/S4 · **implemented:** nameparser/_pipeline/_classify.py, nameparser/_pipeline/_group.py, nameparser/_pipeline/_pieces.py S5-given-parts-trailing-slot-after-a-family-comma.md000066400000000000000000000125531527303271700337130ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/rules# S5 — the given part's trailing slot after a family comma **When:** a member of the ambiguous class (S4) stands in the GIVEN part after a family comma, as in "Doe, John MA". **Then:** after a family comma, a member of the ambiguous class (S4) ending the GIVEN part is read as the comma-less spelling reads a word ending the name, and the count is not what decides it there. - **A generation word that is also a name (S11) is a member here too**: an agnomen's writing never keeps it a name, and a generation word's Title case does, as S6 reads it. ``` "Alves, Pedro Neto" → suffix="Neto" "Alves, Pedro Fils" → middle="Fils" · boundary ``` - **In the given part, the writing decides**: the comma has already named the family and the first name word after it is the given name, so the words to spare are there by construction and the count says nothing; a word that ENDS that part reads as the credential unless its WRITING says otherwise, and a word that does not end it is never asked. ``` "Doe, John MA" → suffix="MA" "Doe, John DO" → suffix="DO" "Doe, John Ma" → middle="Ma" · boundary ``` - **How far "ending the given part" reaches.** It reaches past the credentials behind it and past a trailing title, which is transparent to this reading as it is to the rest of the trailing reading (H5); a name word behind the word ends the reach, and so does a PARTICLE the suffix vocabulary does not also claim as an unambiguous suffix word (`do` is in its ambiguous half): it belongs to the family the comma already named and is taken there by a rule that runs after this reading is made (P6), so `Doe, John Ed do` keeps its middle name. A word its capitals settle in a name of mixed case is not reached that way: it starts the credential's run, which reads every word behind it as a suffix (S8), so `Doe, John MA Smith` and `Doe, John MA do` read as `Doe, John PhD Smith` and `Doe, John PhD do` do. A particle the suffix vocabulary DOES claim is looked past like any other credential, so `Doe, John MA vd` reads suffix `MA`, with `vd` attaching behind it. Where the reach ends, the word is an ordinary middle name, read in silence. ``` "Doe, John Ma Smith" → middle="Ma Smith" · boundary "Doe, John MA Smith" → suffix="MA Smith" "Doe, John MA do" → suffix="MA do" ``` - **What reports.** Every word this slot does read reports the fork whichever way it went, unless its capitals settled it (S6), so a run of members all read as credentials reports once for each, as the same words do without the comma — while a member the writing keeps as a name stops the reading there, and whatever stands in front of it is name text, asked nothing and reporting nothing. ``` "DOE, JOHN MA JD" → ambiguities=("suffix-or-name", "suffix-or-name") "Doe, John MA JD" → ambiguities=() "Doe, John ma Ma" → middle="ma Ma" · boundary "Doe, John ma Ma" → ambiguities=("suffix-or-name",) · boundary ``` **Because:** a family comma has already named the family and the given name, so at the end of the given part the words-to-spare count has nothing to say, and only the writing can. **Except:** - **The first word after the comma is a never-given particle**, which is no given name and needs the next name word to attach to (P1): that word is the surname the particle heads, not a word to spare, so it reads as a name unless its capitals in a mixed-case name say credential, as they do with no words to spare anywhere (S6): `SMITH, VD MA` reads as `Smith, vd Ma` does, `Smith, de MA` keeps suffix `MA`, and `SMITH, VAN MA`, whose particle can be a given name, keeps it and reads suffix `MA`. ``` "SMITH, VD MA" → family="SMITH VD MA" "smith, de ma" → family="smith de ma" "SMITH, VD DE LA MA" → family="SMITH VD DE LA MA" ``` - **A particle in FRONT of the word** takes it out of this slot as well, and takes the word behind it too: where what follows the particle reads as a name rather than a credential the two are one name, so neither word is asked and neither reports — `Doe, John van Ma` reads middle `van Ma` and `Doe, John DO Ed` middle `DO Ed`, where `Doe, John van MA` reads suffix `MA` and `Doe, John DO` alone reads the credential, both settled by their capitals (S6). ``` "Doe, John van Ma" → middle="van Ma" · boundary "Doe, John van Ma" → ambiguities=() · boundary "Doe, John DO Ed" → middle="DO Ed" · boundary "Doe, John DO Ed" → ambiguities=() · boundary ``` **Precedence:** - **The member of the class that is particle vocabulary as well, standing alone at this slot** ("Doe, John Do"): P6 decides it: the capitals take it as the credential, as does an unambiguous credential in front of it (S7) or a member its capitals settle, which starts the credential's run (S8), and every other spelling attaches to the family, reported there as P6's fork rather than as this one. Behind another particle it does not stand alone — the two are one particle run by then — and the run attaches whatever the capitals say (P6). ``` "SMITH, JOHN DO" → family="DO SMITH" · boundary ``` --- **Signal:** position: the word ending the given part after a family comma **history:** decisions/S5 · **implemented:** nameparser/_pipeline/_assign.py derek73-python-nameparser-456b024/docs/design/rules/S6-case-signal-at-the-suffix-slot.md000066400000000000000000000114131527303271700307010ustar00rootroot00000000000000# S6 — the case signal at the suffix slot **When:** a member of the ambiguous class (S4) stands where a suffix may stand, as in "Jack MA". **Then:** written case is evidence beside the count (S4), and it speaks only in a name written in more than one case: there a member of the ambiguous set written in capitals reads as the credential even with no words to spare, and one written in any other cased form that is not wholly lower reads as the name even with words to spare. - **A clash settled by the writing is not reported**: a listed member its capitals read as the credential is no longer a word that might be either, as one written with its periods is not (S3), so no `suffix-or-name` report names it at any slot S4, S5 or C4 reads it. Only the clash with the name reading is settled: the word keeps its other readings, so a particle reading (`DO`) answers P6 as before, and a member's capitals start the credential's trailing run as a credential's do, where S8 says they do (S8). ``` "John Smith MA" → ambiguities=() "Jack MA" → ambiguities=("given-or-family",) "John Smith, MA" → ambiguities=() "Doe, John DO" → family="Doe" "Doe, John DO" → ambiguities=() "John Smith Ma" → ambiguities=("suffix-or-name",) ``` - **A name written wholly in one case says nothing about any word in it**, and the count decides alone and reports as S4 says; so does a script with no case to write in. The case asked of a word in a maiden clause or between brackets is the whole name's, the clause and the brackets being no name of their own. ``` "JACK MA" → family="MA" · boundary "JOHN SMITH MA" → ambiguities=("suffix-or-name",) ``` - **At the trailing slot of the given part** after a family comma, the comma has already settled the count, so the writing is the only evidence there is (S5). - **A generation word that is also a name (S11) is written the other way at one end**: its lower case in a name of mixed case is the generation wherever the name keeps a family word, never with none to spare (S11), its capitals say nothing, a surname being often written in them, and its Title case leans to the name as an acronym's does. An agnomen's writing says nothing at all, since it takes the case of the name around it. ``` "Dumas, Alexandre fils" → suffix="fils" "Dumas fils" → family="fils" · boundary "Marie FILS" → family="FILS" "Jean Marie Fils" → family="Fils" "Dumas, Alexandre Fils" → middle="Fils" "Pedro Alves Neto" → suffix="Neto" ``` **Because:** in a name written in more than one case, the writer's capitals are evidence the count does not have: credentials are written in capitals and names are not. A name written in one case carries no such evidence. ``` "Jack MA" → suffix="MA" "Jack Ma" → family="Ma" "John Smith Ma" → family="Ma" "john smith MEng" → family="MEng" ``` **Except:** - **Where something before the writing keeps the word a name, it reports as before**: with nothing in front of a comma C4's count leaves the word after it the given name, the case being read only behind one name word (C4), and the first word after a maiden marker is the maiden name the marker announced (M2), whatever case either is written in. ``` ", MA Smith" → given="MA" · boundary ", MA Smith" → ambiguities=("suffix-or-name",) · boundary "Jane Doe nee MA" → ambiguities=("suffix-or-name",) · boundary ``` **Precedence:** - **The first slot after a comma** ("Smith, Ma", "John Smith, Ma"): this evidence is SECOND there: the count of name words before the comma decides there first (C4), and the case is read only where that count leaves the word a name. ``` "Smith, MA" → suffix="MA" "Smith, Ma" → given="Ma" ``` - **A part of listed members written in capitals, at the first slot after a comma** ("Smith, MA MA"): C4 states the one exception: a part of two or more suffix words and words of this class, every word of the class listed and written in capitals in a mixed-case name, is read on its capitals before the count is asked, as the credential run behind one name word as behind two ('Smith, MA MA' reads suffix 'MA MA'), and the writing having settled every word of the class in it, the part reports nothing (C11). --- **Signal:** case contrast: capitals on a word of the ambiguous class in a name written in more than one case **history:** decisions/S6 · **implemented:** nameparser/_pipeline/_assign.py, nameparser/_pipeline/_comma.py, nameparser/_pipeline/_pieces.py, nameparser/_pipeline/_vocab.py, nameparser/_render.py derek73-python-nameparser-456b024/docs/design/rules/S7-company-of-an-unambiguous-credential.md000066400000000000000000000121641527303271700321670ustar00rootroot00000000000000# S7 — the company of an unambiguous credential **When:** a member of the ambiguous class (S4) stands behind an unambiguous credential, as in "John Smith PhD MEng". **Then:** a member of the ambiguous set standing BEHIND an unambiguous credential in one run of suffix words — members of the class between them passing the run on — reads as the credential whatever its writing and whatever the count, at every trailing slot S4 names: the degree in front says what the run is. - **Only a credential in front speaks; one behind the member says nothing about it.** - **What ends the run.** A connective (P3), a particle (P2) — a word of both the particle and the suffix vocabulary heads the family name behind it — and a single-letter roman numeral, in any case — one letter, the shape a bare middle initial is written in, where a multi-letter one such as 'III' speaks — speak for nothing and end the run; so does any name word. A particle of this class standing behind a credential is still spoken for: the exclusion is of the word in front. Where a credential's run starts (S8), a one-letter numeral and a name word behind the credential end the company but not the run ('John Smith PhD V Ma' and 'John Smith PhD Jones Ma' read wholly as suffix). A connective standing between the credential and a word after it is joined to the credential first (P3), so no run starts ('John Smith PhD y Ma' reads family 'PhD y Ma', 'Smith, John PhD and Ma' middle 'PhD and Ma'), except in a part a credential opens after a comma (C13), which is suffix to its end ('Smith, PhD and Ma'); a connective further in is the run's ('John Smith PhD Jones and Ma' reads suffix 'PhD Jones and Ma'), and a letter that is also a roman numeral, such as a mixed-case 'i', reads as the numeral, as a letter P8 reads as an initial is no connective at all. ``` "doe, jane v phd do" → suffix="phd do" ``` - **A maiden marker's clause.** The company does not reach across a maiden marker's clause (M2), whose name words stand between. - **What reports.** A member the company decides reports the fork as a counted pick does, wherever it stands: at the trailing slots S4 names, and in a part after a family comma that the company leaves holding no name word, which reads wholly as the credential run. A member its own capitals already made the credential reports nothing (S6). ``` "Smith, PhD Ma" → ambiguities=("suffix-or-name",) ``` **Because:** post-nominals are written as a run, and an unambiguous degree in front of an ambiguous member says what the run is. ``` "John Smith PhD MEng" → suffix="PhD MEng" "John Smith PhD Ed Ma" → suffix="PhD Ed Ma" "Wang Ma PhD" → family="Ma" · boundary "Smith, PhD MEng" → suffix="PhD MEng" "Smith, PhD Ma" → suffix="PhD Ma" "Doe, Jane PhD MEng" → suffix="PhD MEng" ``` **Except:** - **A generation word that is also a name (S11) keeps no company**: a suffix word in front of it is no evidence, and its own count, which looks past that word, decides. ``` "Pedro PhD Neto" → family="Neto" · boundary "Pedro Junior Filho" → family="Filho" · boundary ``` - **A word of both the title and the suffix vocabulary standing in the given part's leading title run** is a title there and speaks for nothing, and no credential behind it speaks for a word of this class in that part: the title reading makes the next word the given name, and from there the part reads as any given part does, the given part's own company included. A part holding no such word, or only words whose capitals decide them, reads as it did. Anywhere else such a word speaks like any suffix word. ``` "Smith, Ms Ma" → given="Ma" · boundary "Smith, RA PhD Ma" → given="PhD" · boundary ``` **Precedence:** - **Against the count and the case** ("John Smith PhD Ed Ma"): company is evidence that outranks both the count (S4) and the case (S6). - **At the trailing slot of the given part, against the attachment** ("Doe, John PhD Do"): this company outranks P6's attachment, as the capitals do. **Accepted:** - A name-leaning member with nothing in front of it to speak for it ends the name, words to spare or not, and whatever stands in front of it is name text. Where a credential does stand in front, the company decides instead. A title standing between the credential and the member, a member a particle chain (P2) had taken, and a credential written split across two words with no comma after the name kept it out of reach until the credential's run (S8) took all three in: the rows a_title_between_degree_and_member_keeps_it_a_name and a_member_the_particle_chain_took_is_out_of_reach keep their ids and now pin the run, and a_split_degree_in_front_starts_the_run pins the third. ``` "Jack Wei Ma" → family="Ma" "Jack Wei Ma" → ambiguities=("suffix-or-name",) "abdul Smith Jr Ma" → suffix="Jr Ma" ``` --- **Signal:** neighbor: an unambiguous credential in front, in one run of suffix words **history:** decisions/S7 · **implemented:** nameparser/_pipeline/_comma.py, nameparser/_pipeline/_pieces.py derek73-python-nameparser-456b024/docs/design/rules/S8-credentials-trailing-run.md000066400000000000000000000136001527303271700277620ustar00rootroot00000000000000# S8 — the credential's trailing run **When:** a credential stands after the name core with more words behind it in its part, as in "John Smith PhD Jones". **Then:** a credential after the name core starts a run to the end of its part (#602): every later word of that part reads as a suffix, except a title word, which reads as a title; a word that may also be a given name (rules/H9) is a title word here only inside a title phrase. - **Where the run starts.** An unambiguous suffix word of two or more letters, in any script, standing after two name words where no comma divides the name or before a suffix comma, or after the given word in the part after a family comma, makes every later word of that part a suffix. So does a roman numeral of two or more letters that reads as the generation (S10), whether the vocabulary lists it or its shape alone reads it, and so does a listed member of the ambiguous class its writing settles as the credential, written in capitals in a name of mixed case (S6) — after the given word in the part after a family comma wherever it stands, and elsewhere not where a name word stands behind it: a particle (a particle run or a joined surname counting by its first word), a word in a script with no case, or a word in Title case or in capitals that is no credential, which makes the capitals as likely the writer's own initials (`Maria Ana MA Silva`), or a family name written in capitals, as a credential. ``` "John Smith VI Jones" → suffix="VI Jones" "Smith, John MA Jones" → suffix="MA Jones" "John Smith MA JD Jones" → suffix="MA JD Jones" "Maria Ana MA Silva" → family="Silva" "Jean Pierre MA DUPONT" → family="DUPONT" "Mary Jane MA Pope" → family="Pope" "Maria Ana MA de la Vega" → family="de la Vega" "Maria Ana MA Ortega y Gasset" → family="Ortega y Gasset" "Smith, John VI Jones" → suffix="VI Jones" "Smith, John PhD Jones" → suffix="PhD Jones" "John Smith PhD Prof. Ma" → suffix="PhD Ma" "Eric H. Holder Jr. Attorney General" → title="Attorney General" "John Smith Ph. D. Jones" → suffix="Ph. D. Jones" ``` - **How the name words are counted.** A lone particle is not a name word for the count, 'de Mesnil' being one surname, unless the credential stands right behind it, leaving it nothing to join: then it counts as a name word of its own ('John von PhD Jones'). A particle run with the words the chain joins to it is one name word, 'der la' and 'la Smith Secretary' each counting once (P2). ``` "John von PhD Jones" → family="von" "John von PhD Jones" → suffix="PhD Jones" ``` - **What is a credential here.** A title word never starts the run, and neither does a member of the ambiguous class its writing leaves unsettled, a single letter, a connective, a word that is also particle or bound given-name vocabulary, the capitals of `DO` included: a credential is the writer's mark that the post-nominals have begun, and those carry no such mark. An honorific or a generational word carries it as a credential does, whatever its script. ``` "Mary Jane Pope Smith" → family="Smith" "Josep Carod i Rovira" → family="Carod i Rovira" "Mohamed Ali Abd Allah" → family="Allah" "John Smith Ma Jones" → family="Jones" "JOHN SMITH MA JONES" → family="JONES" "John Smith DO Jones" → family="DO Jones" "Antonio Pavlović mlađi Jones" → suffix="mlađi Jones" "Pieter Bruegel stariji Jones" → suffix="stariji Jones" "Ана Хорват старији Џонс" → suffix="старији Џонс" "Гай Плиний Старший Иванов" → suffix="Старший Иванов" "राम शर्मा साहब मोहन" → suffix="साहब मोहन" ``` - **What reports.** A name word the run absorbs is reported `suffix-or-name`. ``` "John Smith PhD Jones" → ambiguities=("suffix-or-name",) "राम शर्मा साहब मोहन" → ambiguities=("suffix-or-name",) ``` **Because:** a credential is the writer's mark that the post-nominals have begun, so what follows it in its part is post-nominal too. ``` "John Smith PhD Jones" → suffix="PhD Jones" "John PhD Smith" → family="Smith" · boundary ``` **Except:** - **After a family comma, the particles ending the given part**, behind any post-nominals, are not the run's: P6 attaches them to the family — other than a lone member of the ambiguous class, which reads as the credential. ``` "Smith, John PhD de Jr." → family="de Smith" · boundary ``` **Precedence:** - **A credential split across a space** ("John Smith Pharm D Jones"): it is a member of the ambiguous class and starts the run all the same, behind the two name words its own count asks for (S12). ``` "John Smith Pharm D Jones" → suffix="Pharm D Jones" ``` - **An ambiguous acronym written with its periods** ("John Smith M.A. Jones"): writing the periods makes it unambiguously a credential (S3), so it is no member of the ambiguous class here and starts the run, as its capitals in a name of mixed case make it start it (S6). **Accepted:** - With no family comma, a credential written in capitals with a name word behind it is read as a name word, where the same credential written with its periods starts the run: a name with its credential between its given names and its family name keeps the family name behind the credential, the capitals being read as initials as likely as a credential. ``` "John Smith MA Jones" → family="Jones" "John Smith MA Jones" → middle="Smith MA" "John Smith M.A. Jones" → suffix="M.A. Jones" ``` --- **Signal:** neighbor: an unambiguous credential after the name core, and the rest of its part **history:** decisions/S8 · **implemented:** nameparser/_pipeline/_assign.py, nameparser/_pipeline/_pieces.py S9-unlisted-words-that-join-the-ambiguous-class-by-shape.md000066400000000000000000000072771527303271700352510ustar00rootroot00000000000000derek73-python-nameparser-456b024/docs/design/rules# S9 — unlisted words that join the ambiguous class by shape **When:** an unlisted word written in capitals or in dotted chunks stands where a suffix may stand, as in "John Smith XYZ". **Then:** an unlisted word joins the ambiguous class (S4) by SHAPE, in the positions each shape is admitted: the dotted shape by default, the caps shape by default in one position and elsewhere where the caller asks for it. - **The two shapes.** Two or more period-separated chunks is one such shape, admitted by default (S3); an unlisted all-caps alphabetic word of two or more letters, standing in a suffix position of a mixed-case name and belonging to no wordlist, is the other, and an unlisted word of capitals whose letter chunks are joined by a hyphen or a slash, one of them of two or more letters, is that shape too, admitted where it is. ``` "John Smith, PU-PH" → suffix="PU-PH" "John Smith, MS-OMPT" → suffix="MS-OMPT" "John Smith, PU/PH, Jr." → suffix="PU/PH, Jr." "John Smith, A-B" → given="A-B" · boundary "John Smith, Pu-Ph" → given="Pu-Ph" · boundary "Jean Pierre DUPONT-MORETTI" unlisted_caps_suffixes-everywhere → suffix="DUPONT-MORETTI" ``` - **Where the caps shape is admitted by default.** By default that second shape is admitted in one position only, the part after a comma with two or more name words before it (C4, C6): French and Korean records write the SURNAME in capitals, and that convention puts the capitals at the end of a name or before a comma, never after a comma behind a full name. ``` "John Smith XYZ" → family="XYZ" · boundary ``` - **Everywhere else, under the caller setting.** Everywhere else it is admitted only under the caller setting the example lines below name, being a surname there as often as a credential, which only the caller can know; the cost of that setting is that a three-word name gives up its family name to the acronym, while a two-word name keeps it — there are no words to spare there, so the class is considered and declined and only the fork is reported. ``` "John Smith XYZ" unlisted_caps_suffixes-everywhere → suffix="XYZ" "Jean DUPONT" unlisted_caps_suffixes-everywhere → family="DUPONT" · boundary "Jean Pierre DUPONT" unlisted_caps_suffixes-everywhere → suffix="DUPONT" "Jean Pierre DUPONT" unlisted_caps_suffixes-everywhere → family="Pierre" ``` **Because:** no wordlist holds every credential, and a word's shape can be written the way a credential is written. But a shape is also how some names are written, so it buys the ambiguous reading, never the unambiguous one. **Precedence:** - **Paired initials at the first slot after a comma** ("García Márquez, G.J."): the dotted shape does not take every promise of the class with it, and the comma's decision reads them as the given name unless something speaks for them (C5). - **Paired initials at a trailing slot** ("Ram Bahadur B.K."): in a script that writes capitals, the dotted shape does not admit them to the class where the words-to-spare count would read them, and they are the name word they are written as unless an unambiguous suffix word in front starts a credential run (S3). - **A flip to the credential run at the first slot after a comma** ("John Smith, X.Y.Z."): the dotted shape does not take this promise either: the comma's decision makes it in silence where no listed member takes part, unless paired initials speak only for each other (C11). --- **Signal:** shape: an unlisted word written in capitals or in dotted chunks, standing in a suffix position **history:** decisions/S9 · **implemented:** nameparser/_pipeline/_assign.py, nameparser/_pipeline/_classify.py, nameparser/_pipeline/_vocab.py derek73-python-nameparser-456b024/docs/design/rules/T-tokens-initials-and-punctuation.md000066400000000000000000000007701527303271700312150ustar00rootroot00000000000000## Tokens, initials & punctuation (T) Background: every parsed name part is an exact piece of the input, located by its position — nothing rewrites the text before parsing. Some punctuation is a name divider only by convention of a particular writing system: Japanese writes name parts with a middle dot between them (マイケル・ジャクソン, 姓・名), while U+00B7 is both the Chinese divider for transcribed foreign names and the Catalan punt volat interior to legitimate words (Gal·la). derek73-python-nameparser-456b024/docs/design/rules/T1-separators-not-joiners.md000066400000000000000000000033031527303271700274750ustar00rootroot00000000000000# T1 — separators, not joiners **When:** a name holds whitespace or an ignorable character, one that carries no name content, such as an emoji, an invisible directionality control, a zero-width space or a byte-order mark, as in "John😀Smith". **Then:** the name splits at each of them, so an ignorable character separates its neighbors and never joins them. **Because:** a character that carries no name content stands between words, not inside them. ``` "John😀Smith" → family="Smith" "​王小明" → family="王小明" "​Dr. John Smith" → title="Dr." ``` **Except:** - **Where the caller opts to keep emoji or directionality controls**, the name does not split at the ones kept; a zero-width space and a byte-order mark have no such opt-out. ``` "John😀Smith" keep-emoji → given="John😀Smith" · boundary ``` **Accepted:** - A zero-width joiner or non-joiner stays inside the word even at its edge, where it joins nothing, since between letters it decides how they join. ``` "Antonio ‌Negri" → family="‌Negri" ``` - A zero-width space or byte-order mark does not count as a space where a quote or a maiden marker is recognized, as the katakana middle dot does not (rules/T2): a marker glued to its name by one leaves the clause a nickname. ``` "Jane (née​Jones) Smith" → nickname="née Jones" ``` - A zero-width space after a hyphen, a line-break hint in typeset text, divides the hyphenated name. ``` "Mary-​Jane Smith" → given="Mary-" ``` --- **Signal:** character: whitespace, and characters that carry no name content **history:** decisions/T1 · **implemented:** nameparser/_pipeline/_tokenize.py derek73-python-nameparser-456b024/docs/design/rules/T2-katakana-middle-dot-divides-like-a-space.md000066400000000000000000000013061527303271700325200ustar00rootroot00000000000000# T2 — the katakana middle dot divides like a space **When:** a name holds the katakana middle dot or its halfwidth twin, as in "マイケル・ジャクソン". **Then:** the katakana middle dot and its halfwidth twin divide a name like whitespace, always. **Because:** the katakana middle dot exists to divide name parts and appears in no native name. ``` "マイケル・ジャクソン" → given="マイケル" "高橋・一郎" → family="高橋" ``` --- no-boundary: the separation is unconditional; the context-sensitive interpunct is T3's subject. **Signal:** character: the katakana middle dot or its halfwidth twin **implemented:** nameparser/_pipeline/_tokenize.py derek73-python-nameparser-456b024/docs/design/rules/T3-interpuncts-flank-guard.md000066400000000000000000000012711527303271700276200ustar00rootroot00000000000000# T3 — the interpunct's flank guard **When:** a name holds the interpunct U+00B7, as in "威廉·莎士比亚". **Then:** the interpunct divides a name only between two characters of a classified East Asian script; anywhere else it is part of the word. **Because:** U+00B7 is two marks in one codepoint — the Chinese 间隔号 dividing a transcribed foreign name, and the Catalan punt volat interior to words. ``` "威廉·莎士比亚" → family="莎士比亚" "Gal·la Serra" → given="Gal·la" · boundary ``` --- **Signal:** neighbor: the characters flanking an interpunct **history:** decisions/T3 · **implemented:** nameparser/_pipeline/_tokenize.py derek73-python-nameparser-456b024/docs/design/rules/T4-bare-letter-as-an-initial.md000066400000000000000000000062641527303271700277130ustar00rootroot00000000000000# T4 — the bare letter as an initial **When:** a bare letter written in lower case stands in a name, as in "j nee smith". **Then:** in a name written wholly in one case, which says nothing about any letter in it, a bare letter is an initial wherever its capital would be, and in a name of mixed case a bare lower-case letter is no initial. - **In a name written wholly in one case, every rule that reads an initial differently from a name word reads the lower-case spelling as it reads the capitals**: M4 leaves an initial its given-name reading, O5 reports no convention for one, P3's count passes over one, S10 reads a numeral written as one as the capital numeral it is, and a suffix test that vetoes an initial vetoes it — C14's strict mode, and the scan-back of W2's peel. - **A letter whose capital is not an initial by T5** — é, ж — is no initial in either spelling. ``` "é nee smith" → family="é" · boundary ``` - **For a letter among the name's own words, the case question is asked of those words**, as P8 asks it, so a clause beside the name changes nothing about the name's own letters: a lone letter before a maiden name in capitals is still a name written in one case. ``` "j nee Smith" → given="j" ``` - **A letter outside the name's own words** — in a maiden clause, or past a marker the pass declines (M2) — asks the case of the whole name, the clause included, since the own words' case says nothing of how the clause beside them was written. ``` "dr. née juan y lopez" → middle="juan y" "DR. NÉE JUAN y LOPEZ" → family="JUAN y LOPEZ" · boundary ``` **Because:** a bare letter written in lower case beside capitals is no initial, since nobody writes an initial that way. A name written wholly in one case marks nothing, so its two spellings — all lower case and all capitals — are one name, and read alike. ``` "J NEE SMITH" → given="J" "j nee smith" → given="j" "j" → ambiguities=() "jose j y lopez" → middle="j y" "smith, j y lopez" → given="j" "john j v" → family="v" "Jose j y Lopez" → family="j y Lopez" · boundary ``` **Precedence:** - **A single-letter connective among the name's own words** ("jose e lopez") is P8's question and not this one's: there the vocabulary decides, and the letter it does not mark reads as a connective in both spellings. ``` "jose e lopez" → ambiguities=("conjunction-or-initial",) "jose y lopez" → middle="y" ``` **Accepted:** - The numeral ending a maiden clause is M2's question, and the take asks the letter in front of it by its own writing alone, so a clause written in lower case can end otherwise than its capitals. Nobody writes an initial straight after a maiden marker, so the shape this reaches is not one names take. ``` "john smith nee x x" → suffix="x" "JOHN SMITH NEE X X" → maiden="X X" ``` --- **Signal:** case contrast: a bare letter's case against the name's own words **history:** decisions/T4 · **implemented:** nameparser/_pipeline/_classify.py, nameparser/_pipeline/_vocab.py derek73-python-nameparser-456b024/docs/design/rules/T5-written-shape-of-an-initial.md000066400000000000000000000036511527303271700302760ustar00rootroot00000000000000# T5 — the written shape of an initial **When:** a word of one letter stands in a name, as in "J. née Smith". **Then:** a one-letter word written with its period is an initial, in a script that writes initials, and so is a bare unaccented Latin capital, A to Z. - **A bare capital of any other letter** — an accented Latin one, a Cyrillic or Greek one — is not that shape and is a name word; written with its period it is an initial like any other. ``` "Ж. née Smith" → given="Ж." "Ж née Smith" → family="Ж" · boundary "É née Smith" → family="É" · boundary ``` - **Every rule that reads an initial differently from a name word reads this shape**, M4, O5, P7, C9, S10 and H2 among them; a bare lower-case letter is T4's question. **Because:** one letter standing for a name word is how an initial is written, and the writing marks it: by a period after the letter, where the script abbreviates that way, or, in Latin script, by a plain capital standing alone. ``` "J. née Smith" → given="J." "J née Smith" → given="J" ``` **Accepted:** - The period test reads one word character rather than one letter, as v1's did, so a digit written with its period is an initial too. Nobody writes a digit as an initial, so the shape it reaches is not one names take. ``` "1. née Smith" → given="1." ``` - A generation written as one letter and its period, the Spanish H. for hijo or a German regnal I., is that initial too, since nothing in its writing tells the two apart: the vocabulary records both readings off by default, for a language pack to turn on. ``` "Juan Pérez H." → family="H." "Wilhelm I." → family="I." ``` --- **Signal:** shape: a one-letter word's period, or a bare unaccented Latin capital **history:** decisions/T5 · **implemented:** nameparser/_pipeline/_vocab.py derek73-python-nameparser-456b024/docs/design/rules/W-scripts-and-writing-systems.md000066400000000000000000000075731527303271700304210ustar00rootroot00000000000000## Scripts & writing systems (W) Background: script-conditional behavior is permitted exactly where the writing system itself — not statistics about it — settles the convention; a language can never be inferred from Latin-script text, because transliteration destroys the signal. The facts this section builds on: Chinese and Japanese both write the family name first in native script, so the script settles the order without knowing the language. Hangul is written by exactly one language and Korean family names are a small closed census set. Han text does not identify its language — a Chinese surname list would divide Japanese 高橋一郎 as 高 + 橋一郎 — which is why Han division is opt-in and there is no Korean pack to opt into. Hiragana never transcribes a foreign name (transcriptions are katakana alone), so kanji-plus-kana is a Japanese name in Japanese order, while wholly-katakana may be a transcribed foreign name already in given-first order or a Japanese name's reading written family-first, and nothing in the script says which. Katakana has a halfwidth form (タロウ for タロウ), the only kana systems without kanji could store, and legacy data still carries it: it is katakana, read the same way. A voicing mark typed as its own character after a kana — the dakuten or handakuten after a kana with no precomposed voiced form, as in ア゙イ, or the spacing ゛ and ゜ — voices that kana and belongs to neither syllabary, so it never changes the script of the word it stands in. A mark standing anywhere else, first in a word or after a character that is not kana, voices nothing, and what the parser makes of such a word is not promised. Real Chinese text is unspaced (毛泽东); the spaced 毛 泽东 is an artifact. A fuller narrative lives in docs/usage.rst's East Asian section. One fact carries its own consequence: none of the three writing systems marks the family name with a comma — position in the written form is what identifies it, so a comma standing between the family name and the given name is a listing convention carried in from elsewhere rather than a form the script produces. That is why the rule reading one (W3) is tolerated rather than normative. CLDR's own locale data says the same where a contrary convention would have had to appear: across its ko, zh and ja personName patterns not one of the 126 pattern strings carries a comma of any width, the surname-first referring patterns separating surname from given by a single space, and the only comma in reach belongs to the locale-neutral root's sorting format — a list-ordering format, which ko and zh override comma-free and ja all but one inherited slot (decisions/cjk-comma-demotion carries the pull verbatim, with its URLs, its commit and its date). A full stop of any width — the ASCII period, the fullwidth ., the ideographic 。 and its halfwidth 。 — glued after a script-written word is punctuation and not part of the word: it is invisible to the script reading and to the vocabulary, and it stays in the text on the word it arrived with, because no East Asian script writes an initial or an abbreviation with a period (#322, #323; decisions/cjk-full-stops). A stop glued BEFORE the word is punctuation to the vocabulary lookup, which folds both edges away, so .씨 is still the honorific. The classification fold that feeds the two division sites reads the trailing edge only, so a word wearing a leading stop is given no script at all and never becomes a surname site: .김민준 stays one whole word, given, no script rule reaching it. The honorific peel (W2) is not gated by that fold — the tail alone is its license — so it reads such a token regardless of a leading stop, and its own trailing-edge fold is what decides there: .김민준씨 peels to .김민준 and 씨, and .김민준씨. peels to .김민준 and 씨. (tests/v2/pipeline/test_script_segment.py's test_the_peel_reads_the_trailing_stop_only). derek73-python-nameparser-456b024/docs/design/rules/W1-unspaced-cjk-division.md000066400000000000000000000036551527303271700272530ustar00rootroot00000000000000# W1 — unspaced CJK division **When:** an undivided word stands in the family position of a name written in a script whose division is active, as in "남궁민수". **Then:** an undivided word in the family position of a name written in an activated script divides after a recognized surname, the longest recognized surname first; where the vocabulary recognizes nothing, an optional segmenter may divide instead, and with neither the word stays whole rather than divide in a wrong place. - **Korean division is active by default; Han division is opt-in.** - **A segmenter** — unlike the vocabulary, which ignores spacing but not the interpunct (the Except below) — is consulted only for a name whose written form is wholly undivided, a spaced honorific counting as a written division (decisions/W3 records what that trade keeps and costs). **Because:** hangul is monoglot Korean and its surnames are a closed census set, so an unspaced hangul name divides at a certain point; Han carries no such certainty by default. ``` "김민준" → family="김" "남궁민수" → family="남궁" "남궁민수 지훈" → family="남궁" "毛泽东" → family="毛泽东" · boundary "毛泽东" [zh] → family="毛" "高橋一郎" [zh] → family="高" "柯洁" [zh] → family="柯" "钟离小凤" [zh] → family="钟离" "张简" [zh] → family="张简" ``` **Except:** - **A name the interpunct divides** is already divided in the sense that matters — division stands down entirely there, vocabulary and segmenter alike (#298; decisions/T3). ``` "安东尼·陈志明" [zh] → family="陈志明" ``` --- **Signal:** script: an undivided word in the family position, in a script division is active for **history:** decisions/W1 · **implemented:** nameparser/_pipeline/_script_segment.py derek73-python-nameparser-456b024/docs/design/rules/W2-glued-honorific-peel.md000066400000000000000000000053131527303271700270550ustar00rootroot00000000000000# W2 — the glued-honorific peel **When:** an East Asian honorific is glued directly onto the end of a name, as in "田中さん". **Then:** a listed honorific glued to the end of the name's last name word splits off once and reads as a suffix. - **A part that is not name text — a post-nominal word standing on its own — is never the name's end**: the split-off steps past it to the name word behind, and never dissects it. ``` "김민준 박사님" → suffix="박사님" "선생님" → family="선생님" · boundary ``` **Because:** a glued word peels off only if it could never itself end a name, so the listed vocabulary carries its own license and needs no other gate. ``` "田中さん" → suffix="さん" "김민준씨" → suffix="씨" "马丁·路德·金씨" → suffix="씨" "田中 社長" → suffix="社長" "田中社長" → suffix="社長" "陳怡君二世" → suffix="二世" "김지양" → suffix="" · boundary "王君" → family="王君" · boundary ``` **Accepted:** - 部長 and its simplified spelling 部长 are vocabulary in the spaced and after-comma positions only, and no peel splits them off a glued name: 田中部長 keeps the word in the name, because 長 alone is a borne given name and 阿部長 can be 阿部 + 長. The other credential, office and generational words of #671 (議員, 国会議員, 太平紳士, 医学博士, 二世, 社長, 課長 and their variant spellings) met the glued bar when measured against real names (#707) and peel. The Han segmenter's own division is not a peel and exposes 部長 regardless: under the Chinese pack a listed surname followed by it divides there and the word reads as a suffix, so 李部長 reads family 李, suffix 部長, as 王博士 already does. ``` "田中部長" → family="田中部長" · boundary ``` - A longer office or degree word that ends in a listed tail is split at it, whether glued or spaced, because the tail is matched at the end of the token and the longer word is not listed: 衆議院議員, 副社長, 代表取締役社長 and 獣医学博士 leave their head (衆議院, 副, 代表取締役, 獣) in the name. Before the tail was listed the whole word stayed in the name, which was no more right (#707). ``` "田中 副社長" → given="副" "岸田文雄衆議院議員" → family="岸田文雄衆議院" ``` --- **Signal:** neighbor: a listed honorific glued to the end of the name's last name word **history:** decisions/W2 · **implemented:** nameparser/_pipeline/_script_segment.py derek73-python-nameparser-456b024/docs/design/rules/W3-writers-divisions-are-respected.md000066400000000000000000000110621527303271700313000ustar00rootroot00000000000000# W3 — the writer's divisions are respected **When:** a name in an East Asian script is written in a listing form: with a comma, as in "남궁민수, 지훈", or with the punctuation such a listing carries in with it, such as an edge full stop. **Tolerated:** none of the East Asian writing systems declares a family name with a comma (the W section's Background), and native CJK writing puts no edge full stop of any width on a name word, so every input this rule reads is a listing convention wrapped around a name whose own script has already arranged it. What follows describes what the parser does with such input; it does not promise it: the examples carrying a comma or a period illustrate current behavior, changeable without notice, while "남궁민수", carrying neither, is W1's claim, which is normative. **Then:** under a family comma the pre-comma text is the family by declaration and never divides, and the post-comma side is given text with no family to find. - **Which side of the comma the honorific split-off (W2) takes from** is decided by the SHAPE of the part after the comma, not by which of C1's two readings that part selects — a one-word family reads the listing form either way. A part that is nothing but suffix words is not the name's end: it is declined as the site, and the split-off falls back to the part before the comma and takes the honorific there, provided that part offers a site of its own. Any other part — a title, a name word — IS the name's end, and a glued honorific before the comma stays glued. ``` "田中さん, Dr." → family="田中さん" "田中さん, PhD" → suffix="さん, PhD" ``` - **The vocabulary question is C1's own**, asked without C1's word-count condition: the two differ in what else they require, not in what they ask of the words — the written-case evidence S6 reads included, so a post-comma acronym the case leans credential declines the side it stands on here exactly as it reads as the credential there, and the honorific comes off the part before the comma as it does behind a settled credential. - **A period the listing leaves behind** is not what licenses the step past a post-nominal word, and it is not ignored either. The step is W2's, taken on the vocabulary alone and taken with no punctuation anywhere in the input; a period on a SEPARATE post-nominal word rides along into the suffix and moves no division (田中さん 様. divides where 田中さん 様 does). ``` "田中さん 様." → suffix="さん 様." ``` - **A period glued to the honorific's OWN word rides with the honorific**: the split-off matches the listed tail through it and cuts before it, so 田中さん. and 김민준씨. divide where 田中さん and 김민준씨 do. ``` "김민준씨." → suffix="씨." ``` - **A period-marked Han opener divides as its stop-less spelling does**, 田中. 太郎 reading family-first — the reading H2's Accepted clause vetoes a title into, carried here because an edge full stop makes it tolerated input like the rest of this rule. ``` "田中. 太郎" → family="田中." ``` **Because:** a family name declared by a comma is the writer's own division, and re-dividing it would invent a boundary nobody drew. ``` "남궁민수" → family="남궁" "지훈, 남궁민수" → given="남궁민수" "남궁민수, 지훈" → family="남궁민수" · boundary ``` **Except:** - **Behind a part C1 reads as postnominal there is no family comma**, and the name before it divides as it would with no comma ('마틴 킹, MA' as '마틴 킹'). The division, and with it the honorific split-off, follows C1's own reading of the comma, taken on the name as written before the division ('Dr 김민준, MA' divides as 'Dr 김민준' does). ``` "Kim김민준씨, MA" → suffix="씨, MA" ``` **Precedence:** - **A title run opening the pre-comma text** ("Dr. 김민준, 지훈"): the title run is H6's title, not family text. - **Under the strict comma-suffix switch** ("田中さん, V."): the vocabulary question is asked with C14's veto, so a suffix word written as an initial after the comma is name text, that part is the name's end, and the honorific before the comma stays glued. - **The honorific split-off against the comma** ("田中さん, PhD"): only the honorific split-off (W2) crosses the comma, an honorific being no part of the name on either side. --- **Signal:** comma + edge full stop **history:** decisions/W3 · **implemented:** nameparser/_pipeline/_script_segment.py derek73-python-nameparser-456b024/docs/design/rules/W4-script-scoped-order.md000066400000000000000000000036621527303271700267530ustar00rootroot00000000000000# W4 — script-scoped order **When:** a name is written wholly in one East Asian script, or in the kana-licensed Japanese repertoire, as in "山田 太郎". **Then:** a name written wholly in one East Asian script, or in the kana-licensed Japanese repertoire, reads family-first whatever order the caller declared; a wholly-katakana name keeps the declared order. - **A name the katakana middle dot divides still reads by the script license**: that divider (T2) carries no transcription signal. ``` "威廉・莎士比亚" → family="威廉" ``` **Because:** Chinese, Japanese and Korean all write the family name first in native script — the script settles the order without knowing the language — while a wholly-katakana name may be a transcribed foreign name already in its source order or a Japanese reading written family-first, so its script settles nothing. ``` "김 민준" → family="김" "山田 太郎" → family="山田" "高橋 みなみ" → family="高橋" "山田 タロウ" → family="山田" "マイケル ジャクソン" → given="マイケル" · boundary "ヤマダ タロウ" → given="ヤマダ" · boundary "ア゙イ タロウ" → given="ア\u3099イ" · boundary ``` **Except:** - **A name the interpunct divides** keeps the declared order — the divider itself marks a transcription (T3) — so the override stands down there, and the name reads as a wholly-katakana one does: in its written order by default, reversed by a declared family-first order like any other name. ``` "毛·泽东" → given="毛" "威廉·莎士比亚" family-first → family="威廉" ``` --- **Signal:** script: a name written wholly in one East Asian script, or in the kana-licensed repertoire **history:** decisions/W4 · **implemented:** nameparser/_pipeline/_assign.py derek73-python-nameparser-456b024/docs/design/vocabulary-model-rfc.md000066400000000000000000000777671527303271700254770ustar00rootroot00000000000000# RFC: the vocabulary model and language packs Status: **design overview**, written 2026-10-19 after phase 1 shipped. It says where the vocabulary model is going and why, phase by phase. It is not the record: each decision is logged in [decisions/vocabulary-model](decisions/vocabulary-model.md) when it is made, and where the two differ, that entry is right and this document is stale. Each later phase gets its own design when it starts, under its issue; this overview is amended as they land. Work is tracked in #747. For editing the vocabulary as it is today, read [nameparser/vocab/README.md](../../nameparser/vocab/README.md) instead. ## The short version nameparser recognizes words by vocabulary: titles, suffixes, particles, conjunctions, bound given names, maiden markers. Until 2.6 that vocabulary was a set of base sets plus marker subsets (`particles` and `particles_ambiguous`, `suffix_acronyms` and `suffix_acronyms_ambiguous`, ...). The model replaces the sets with a description of each **word** by its **readings**: what the word can be in a name, and the properties of each reading. ```toml [[word]] text = "do" family = { us_census_2010 = 30301, wikidata = 100, as_of = 2026-10-25 } given = { us_ssa = 11, wikidata = 9, as_of = 2026-10-25, note = "male births only; Wikidata is a lower bound" } credential = { expands = "Doctor of Osteopathic Medicine" } [word.preposition] expands = "of the, masc. sg." ``` `do` is a credential and a particle, and Vietnamese Đỗ makes it a surname too: its name readings, with the counts they rest on, are what mark both clashes (an entry spread over two files in the shipped data, `en.toml` and `pt.toml`). Under the sets that clash was visible only as an overlap between two of them; as readings it is written on the word. Once every word says which language's names it occurs in, choosing a language can settle the clashes that exist only because the default serves every language. Four phases, in a fixed order: | Phase | What | Changes parsing? | State | |---|---|---|---| | 1 | The per-word TOML format, compiled to today's sets | no | shipped in 2.6 (#737, #744); #749–#751 gave the words in `und.toml` their languages, leaving two particles of unrecorded origin | | 2 | Ambiguity derived from clashing readings; the ambiguity gate extended to every reading kind | yes, the first phase that does | #752, design agreed 2026-10-23; 2a, the two name-clash markers derived, landed without moving a parse (decisions/vocabulary-model, 2026-10-25) | | 3 | Language packs that prefer or restrict their language | yes, for callers who choose one | #753, needs 1 and 2 | | 4 | Order and shape; the rules a pack can choose; the output model | yes | #754, mostly independent of 2 and 3 | Two commitments hold through all four. **The default loads every language and keeps working for names of any language, as today**; choosing a language narrows it (decision 5 in decisions/vocabulary-model). And **languages own vocabulary and defaults, the core owns structure**: a pack configures rules the core implements, and never ships code (mechanisms.md#LOCALE-PACKS-PURE-DATA). ## Why readings, not sets The sets failed as a model in five ways (decisions/vocabulary-model's preamble has the detail): - "Ambiguous" meant three things by field: may be a given name (particles), may be a surname (acronyms), may be an initial (conjunctions). Two other marker sets (`honorific_tails`, `given_name_titles`) were not ambiguities at all. - The direction flipped between layers: `config/` listed the never-given particles, `Lexicon` the may-be-given ones. - Every marked word was written twice, and the checks on the pairs were uneven. - A clash between categories was recorded nowhere, and nothing told an author whether an overlap they created was intended (`titles ∩ particles` holds `freiherr` and `st` on purpose) or forbidden (a post-nominal both an acronym and a word). - A set cannot say which language a word belongs to, which is what a language pack needs. A reading fixes each of these by construction: a property cannot exist without its reading, so the subset checks disappear; a clash is a word with two readings; and the file a word sits in is its language. ## Phase 1: the format (shipped) Phase 1 re-encoded the vocabulary and changed no parse. One TOML file per BCP 47 tag holds the words found in names written in that language (`und` for words not yet assigned one, `mul` for words shared on purpose); each `[[word]]` lists its readings keyed by kind; `tools/vocab/build.py` compiles the files into today's sets, and the parser never reads the TOML. The suffix category is classified into six kinds (generation, credential, honorific, professional honorific, status, courtesy); every other category carries its generic kind until its classification issue lands. Exclusions are data: a refused reading is recorded on the word with its reason, and the build refuses it in any file, quoting that reason. Two choices in phase 1 set up phase 2 and are worth knowing before reading it: - **The marker sets were explicit flags** (`may_be_given`, `may_be_name`, `may_be_initial`), not derived from name readings, recording past curation rather than evidence. Phase 2a (2026-10-25) derived the two that record a clash with a name, `may_be_name` and `may_be_given`, from name readings, each with dated counts or a `note` where no source counts the word (`te`, `tho` and `du`, once thought to have none, have SSA counts), keeping `may_be_initial` as a property (decisions/vocabulary-model). - **Names a later phase will use are build errors** (phase 1 reserved `default`, `prefer`, `strict`, `bare_only`, `leading_only`, `trailing_only`, `attaches`, `separators`, and the values `addresses = "full"` or `"any"`), so nothing reads as working before it does. Phase 2h (2026-10-25) gave `default = false` its meaning, a reading recorded that compiles to nothing, and named the phase each of the others waits for; phase 2i gave `attaches` its meaning, the phrase partner below. The reference for the format is nameparser/vocab/README.md; the decisions behind it, with what was declined and measured, are decisions/vocabulary-model's 2026-10-18 entry. ## Phase 2: derived ambiguity and a general gate Phase 2 is the first phase that changes how names are parsed. Its design was agreed on 2026-10-23 (#752); the decisions, the evidence for each and the options declined are decisions/vocabulary-model's entry of that date. What follows is the design as agreed. Every sub-phase below had landed by 2026-10-27, when #752 closed; the title words 2j did not reach are #913's. ### Three sources of readings Every clash the parser decides is between readings of one token, and phase 2 puts them in one pool: - **Vocabulary readings**, the TOML kinds: titles, suffixes, particles, conjunctions, bound given names, birth markers. - **Declared name readings**, `given` and `family`, written by hand on a word some file claims or refuses (decision 11), with dated counts as provenance that never compute a reading (decision 12). Nobody maintains surname lists. - **Shape readings**, which today are rules standing around the vocabulary: the initial ([rules/T4](rules/T4-bare-letter-as-an-initial.md), a bare letter as an initial; [rules/T5](rules/T5-written-shape-of-an-initial.md), the written shape of an initial), the leading abbreviation title ([rules/H2](rules/H2-leading-abbreviation-title.md)), the dotted acronym ([rules/S3](rules/S3-credentials-written-in-joined-chunks.md)), the capitals acronym ([rules/S9](rules/S9-unlisted-words-that-join-the-ambiguous-class-by-shape.md), unlisted words that join the ambiguous class by shape), the roman numeral past the listed ones ([rules/S10](rules/S10-trailing-roman-numeral.md)), paired initials ([rules/C5](rules/C5-paired-initials-after-the-comma.md), paired initials after the comma), and the joined and split spellings of a credential that phase 2 adds. Shape readings join the pool because of decision 20, which lets a pack's preferred reading outrank a shape-derived one, and which phase 2 applies in the default as well (an abbreviated given name over the leading-abbreviation title, below): a reading can outrank a shape-derived one only if the shape's answer is a reading the gate weighs, not a verdict already written into a tag. Settling a clash by rewriting a tag also erases the word's other readings: retagging a capitals `DO` as an unambiguous credential let its particle reading take it into the family (`Doe, John DO`), which is why the case cue for credentials (#785) waited for the pool. Phase 2g (2026-10-25) settles it where each site chooses a reading, the word keeping its tags ([rules/S6](rules/S6-case-signal-at-the-suffix-slot.md)). ### Ambiguity from readings A **clash** is a token whose readings would put it in different fields. The two markers that are clashes with a NAME become derived: - an acronym that may be a name is a word with a credential reading and a `given` or `family` reading; - a particle that may be a given name is a word with a particle reading and a `given` reading. The swap is snapshot-neutral by construction once the missing name readings are written, because no word sits in one marker set while needing to stay out of the other (decisions/vocabulary-model, 2026-10-18, with the recompute). The missing readings are declared on the flagged words, a few of them without a count, which a required `note` then explains. After the swap the build refuses the flags themselves, naming the reading to write instead, and a particle with no given reading still states `may_be_given = false`, so a new particle cannot fall to the unsafe side silently. `may_be_initial` is not derived. Its clash is with the initial, a shape reading every cased single letter has, and which letters a tradition writes both ways is a judgment about the letter, not a name anyone bears: `e` and `i` may be read as an initial in a name written in one case, `y` may not. ### One gate, four steps Every clash is decided by one ordered question, asked at each site that reaches it today: 1. **Floor.** The name keeps a word to address: a title run gives its last word back ([rules/H3](rules/H3-title-runs-floor.md), the title run's floor), the second of two name words stays the family ([rules/S4](rules/S4-ambiguous-acronym-needs-words-to-spare.md), [rules/S10](rules/S10-trailing-roman-numeral.md)), and a bound given name keeps a family word in reserve ([rules/P5](rules/P5-bound-given-names.md)). 2. **Form.** A cue the reading's kind declares, read off the word's writing, settles the clash, and a clash settled by form is not reported. 3. **Structure.** The slot's own rule, in the order each slot reads it today: the ambiguous class at the comma ([rules/C4](rules/C4-ambiguous-credential-class-at-the-comma.md), the ambiguous credential class at the comma), the company of an unambiguous credential ([rules/S7](rules/S7-company-of-an-unambiguous-credential.md)), the credential's trailing run ([rules/S8](rules/S8-credentials-trailing-run.md)), a particle's leading position ([rules/P4](rules/P4-particle-at-the-front-of-the-name.md)), and the title phrase at the front of the name. 4. **Count.** Words to spare (S4, S10). A clash not settled at step 2 is reported at the decision site (mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE), in the kind the slot reports today. **Where one gate does not reproduce the six rules.** H3 and P5 weigh no second reading: they reach every member of their category and read only a count, so they stay floors, step 1, and "one gate for six" means four clashes and two floors. The ambiguous acronym is reached at nine sites, each reading its cues in its own order, and that order is behavior, so the nine sites stay; what becomes one is the predicate each asks (mechanisms.md#ONE-PREDICATE-PER-QUESTION). Everything else reproduces once the name readings are declared, except `ld`, whose name reading is moved into its credential reading's `note` so that it stays ungated. ### What each kind reads | Reading kind | Clashes with | Form cues (step 2) | Then | |---|---|---|---| | credential, acronym | a name reading | its periods; capitals in a name of mixed case, asked of the whole name; Title case leans to the name; the joined (`MD/PhD`) and split (`Pharm. D.`) spellings | the comma count, the company, the run, the count | | generation, whole word (`fils`) | a family reading | lower case in a name of mixed case settles the generation; Title case leans to the name | the count | | generation, agnomen (`Filho`, `Neto`) | a family or given reading | none: an agnomen is written in the case of the name around it | the count | | generation, roman numeral | the initial, for one letter; a name word | a period makes it an initial; lower case in a name of mixed case settles the generation; Title case leans to the name; an initial before it makes it a name word | the count | | generation, initial-shaped (`H.` for *hijo*, regnal `I.`) | the initial | none: written with its period it is exactly an initial | no cue: `default = false`, for a pack | | particle | a given reading | none: `Van` and `van` are both written | the leading position, the lone particle's fold | | conjunction, one letter | the initial | the name's case ([rules/P8](rules/P8-single-letter-connective-against-the-initial-it-spells.md)); in one case, `may_be_initial` | — | | title at the front | a given or family reading | none | the phrase partner, the title before it | | bound given name | none (a floor) | — | — | A reading whose writing offers no cue is one the gate could only guess, so it is `default = false`: recorded in the TOML, compiled to nothing by default, turned on by a pack in phase 3. Spanish `H.`, German regnal `I.` and Czech `st.` are the first. **The numerals ask one predicate.** `i` to `v` are vocabulary and `vi` to `x` are shape, and until 2f some rules asked only the vocabulary, so `John Smith III MD` and `John Smith VI MD` read differently (#784, #774). One predicate answers for every spelling the way the trailing numeral rule does (shape, the count for one letter, an initial in front, the period, the case), and every rule that asks either recognizer asks it. Listing `vi` to `x` instead was measured and declined: a middle initial `X` would read as a generation. **`fils` and the agnomens show what kind-specific cues buy.** `fils` is gated by its case and the count, so `Alexandre Dumas fils` reads suffix `fils` while `Marie Fils` keeps her family name, reported. The Brazilian agnomens are gated by the count alone: case would say nothing, and the plain generation reading breaks real surnames (`Ana Neto`) and given names. Phase 2h landed both on 2026-10-25, the floor holding before the case cue (`Dumas fils` keeps its family name, Derek) ([rules/S11](rules/S11-suffix-word-that-is-also-a-name.md), a suffix word that is also a name), with `default = false` for the three readings with no cue. **A credential's spelling is a form cue.** A credential joined by `-` or `/` is to read as one when every chunk is listed and none is ambiguous, since a hyphen also joins names (`Smith-Jones`); an unlisted word of capitals joined that way is to join the ambiguous class after a comma, gated by the name's case contrast. `John Smith, MD/PhD` read given `MD/PhD` until phase 2c (2026-10-25) read the listed half: a joined word whose every chunk counts as a suffix as written ([rules/S3](rules/S3-credentials-written-in-joined-chunks.md)). A credential split across a space (`Pharm. D.`, generalizing the `Ph. D.` merge) is to merge only where its first chunk has two or more letters, the merged piece a member of the ambiguous class, decided by the comma count and words to spare: an opening single letter is a middle initial (`SMITH, JOHN A. MS.` holds an initial and a salutation, not a credential `AMS`). #872 made the same judgment for the joined spelling (`A.B.`), in scripts with capitals and outside the slot right after a comma. The merged piece is also to start the credential's run (#827), once the numeral predicate answers the numeral before a credential; since [rules/S8](rules/S8-credentials-trailing-run.md) refuses a member of the ambiguous class as a run start, how the two combine is the split-credential PR's to state (today's `Ph. D.` merge is unambiguous, and S8 already covers it). Phase 2e (2026-10-25) did both ([rules/S12](rules/S12-credential-split-across-a-space.md)): `John Smith, Pharm D` reads suffix `Pharm D`, and the split pair is the one member of the class that starts the run, behind the two name words its own count asks for. ### The front of the name The front of a name has no words-to-spare cue: a title addresses the name after it. What it has is the contested-head rule. **A title word that also has a name reading is a title only inside a title phrase:** - **The title before it is not a partner.** A contested word after another title is the given name (`Mr Sultan Khan`, `Ms Princess Ada Obi`, #853), unless that title is its partner; `Mr Sultan Khan` read title `Mr Sultan` until phase 2j gave `sultan` its given reading. - **Its partner** is a modifier or a style before it (`Her Royal Highness Princess Anne`), `of` after it (`Dean of Students`), or a head title after it that the word `attaches` to (`Major General`). A salutation is never a partner (`SMITH, DEAN MR.` is a given name and a salutation). - **Modifiers** (decision 10, unparked) are titles only beside a head title, at the head of the name only, never in a comma part, where a lone title word is the person's role; a modifier that is also a given name (`wing`, phase 2j) is read by that test in every part. Phase 2i carried this out as [rules/H9](rules/H9-title-phrase.md) (the title phrase), with `of` after a modifier as a partner too (`Member of Parliament`) and a modifier in a connective's object (`President of the United States`); a title that may be a given name compiles out of `titles`, so one a caller lists there is a title everywhere. Which title words get a given reading is decided one word at a time (decision 16), in measured batches, and `Dean Smith` keeps reading given `Dean` (decision 13). A title that only ever stands in front stays a title (`lord`, `sir`, `lady`), and a word that opens names in one tradition but is a title class in another (`guru`, with its Devanagari twin) waits for a language pack. Where the rule reads a contested title as a name, the parse reports `title-or-name`. **Abbreviated given names** use the same pool from the other side (#789): a `given` reading with `same_as` the full name (`chas` → Charles, Filipino `ma` → María) is to outrank the leading-abbreviation title rule's shape, so that `Ma. Cristina Santos Reyes` reads given `Ma.` where it read title `Ma.`. Phase 2k (2026-10-26) did this: the reading compiles into `GIVEN_NAME_ABBREVIATIONS`, which [rules/H2](rules/H2-leading-abbreviation-title.md)'s shape declines, and nothing else reads it. ### Brackets A bracketed or quoted credential is to escape the nickname reading where its writing settles it (#810): with interior periods, or in capitals in a name of mixed case, the case asked of the whole name. `(Ed)` stays a nickname, and so does content standing inside the name rather than after it (`Jean 'JD' Smith`). Phase 2l carried this out as [rules/S13](rules/S13-bracketed-member-settled-by-its-writing.md) (a bracketed member settled by its writing) rather than as an amendment to [rules/S1](rules/S1-bracketed-credential-is-not-a-nickname.md), since the position it reads is a signal S1 does not: `John Smith (MA)` and `John Smith (M.A)` read suffix, and `JOHN SMITH (MA)` and `Jeffrey (JD) Bricken` keep the nickname. The writing is read as the rules that read it say, S3 for the periods and S6 for the capitals in a name of mixed case, and S13's own signal is the position. A settled member is the suffix, decided in one pass, rather than the word read bare, since bare, `M.A` reads as initials; where the rest of the name then reads otherwise than beside a bracketed unambiguous credential (`Jack (MA)` keeps family 'Jack' where `Jack (PhD)` reads given 'Jack'), S13 records the difference as accepted. ### Readings on the parse A caller should be able to see the reading behind a parse. **An `Ambiguity` carries the reading the parse did not take** (`Ambiguity.alternative`: its kind, the field it would have put the token in, and whether it came from the vocabulary, a declared name or a shape), and **a token carries the reading it did** (`Token.reading`), so `Nguyen Van Do`, which forks nothing, can still expose `Đỗ` without a report nobody would act on. A word the credential's run takes by position alone is told from a word with a declared name reading by that source, so it needs no new ambiguity kind. `Ambiguity.alternative` shipped in 2.7 (decisions/A1, 2026-10-26 #752): a `Reading` of a kind, the field the word would land in with every other word read as the parse read it, and a source, which is `vocabulary`, `declared`, `shape` or `position`, the fourth being the reading of an ordinary word nothing lists, which is what a word the credential's run took by position alone has. The pickle guard was widened to accept a field added later. A `languages` field, naming the files that hold a reading, was weighed and left out: it needs the per-word reading index, which costs about 0.4 ms of a 19.5 ms import and could not describe a pack's or a caller's lexicon, so it waits with `Token.reading` for 3.0. ### The phase 1 reserved names Phase 2 gives two of them meaning: `attaches`, the phrase partner above, a property of a title reading naming the title kinds it qualifies; and `default = false`, for readings with no cue. `leading_only` and `trailing_only` are not needed, the gate's slots carrying position; `bare_only` is settled by shape already ([rules/P7](rules/P7-one-letter-particle-against-the-initial-it-spells.md), a one-letter particle against the initial it spells); `separators` belongs with `attached` and its other side, phase 4's glued words. Those four stay reserved, their messages naming the phase that needs them. ### The order of work Each sub-phase is one PR, measured on its own base: the committed snapshot read by shape, the PSC register's fielded names scored for fixed, broken and changed records (`local_agreement.py psc-individuals`, the main measure, since its filers separated the fields), the local snapshots it reaches, and the rules it moves amended in the same diff. | Sub-phase | What | Needs | |---|---|---| | 2a | the derived markers: the name readings declared, the flags refused | — (neutral) | | 2b | one predicate per question: the split-credential merge, the numeral, paired initials in a script with capitals | — (neutral) | | 2c | joined credentials, every chunk listed | 2b | | 2d | joined unlisted capitals after a comma | 2b | | 2e | split credentials, and the merged piece starting the run (#827) | 2b, 2f | | 2f | every numeral rule asks one predicate (#784, #774, #819) | 2b | | 2g | capitals settle a listed credential (#785), through the pool | 2a, 2b | | 2h | generation readings gated by kind: `fils`, the agnomens, `default = false` | 2b | | 2i | the front of the name: the phrase partner, the contested heads, #853's word after a title, modifiers at the head | 2b | | 2j onward | titles that are names, one batch per word or group, in the agreed order; the words left are #913's | 2i | | 2k | abbreviated given names over the leading-abbreviation title (#789) | 2b | | 2l | the bracket escape reads the derived clash (#810) | 2a, 2b | | 2m | surfacing, `Ambiguity.alternative` | the rest | 2a and 2b are independent of each other. The sub-phase that gives a reserved name its meaning updates nameparser/vocab/README.md's reserved-names table with it: 2h for `default`, 2i for `attaches`. Measured changes that belonged outside phase 2 shipped as 2.x PRs: bare `wm` (#873), the `;` between credentials (#871), two dotted single letters as a surname (#872) and `sa` out of the vocabulary (#879). ## Phase 3: packs that prefer or restrict A pack is a choice of languages. Two strengths (decision 6): - **prefer** settles a clash where the chosen language has a vocabulary reading: the word keeps that language's readings and loses other languages' vocabulary readings. Under Portuguese, `do` is a particle only. - **strict** also drops other languages' name readings. It is for single-origin data. The asymmetry is deliberate (decision 15): **vocabulary readings settle a clash; name readings only keep the gate on.** `do` shows why. Its Vietnamese reading is a name only (Đỗ). If name readings settled clashes, preferring Vietnamese would drop the credential reading, and `John Smith DO`, a suffix today, would read `DO` as the family name. Under the rule, Vietnamese holds no vocabulary reading of `do`, so preferring it settles nothing: the credential and particle readings stay, gated as in the default. Consequences that follow from the definitions: - **Order does not matter.** Prefer and strict are sets of languages, so combining packs is commutative. - **Two chosen languages that each hold a vocabulary reading both keep it**, and the word stays gated, as in the default. - **Non-Latin vocabulary stays on under any choice**, because it selects itself by script (`씨`, `さん`, Cyrillic titles). Almost every clash between languages is within one script: `do`, `e`, `van` are all Latin. - **Whether a word needs a pack depends on where its clash is.** `do` clashes between languages (a Portuguese particle, an English credential, a Vietnamese surname), so a pack settles it. `fils` clashes within French, a generation word and a French surname, so no pack settles it and the phase 2 gate does the work. This reverses the premise of #684, which proposes a French pack to read `fils` as a suffix: under decisions 6 and 15, a French pack keeps both of French's readings, and `Marie Fils` would still need the gate to keep her family name. **A pack can outrank a shape rule, not only a vocabulary reading** (decision 20). Shape rules (period abbreviation, initials, capitals) emit readings into the same pool as vocabulary from phase 2 on, rather than final answers, so a pack's preference can act on them. Phase 2 is to settle one such clash in the default, since every language loads there: Filipino `Ma.`, which abbreviates María, is to get a `given` reading in `fil.toml` that outranks the leading period-abbreviation rule (`Ma. Cristina Santos Reyes`, title `Ma.` until phase 2k gave it that reading on 2026-10-26); a pack's part is to prefer such readings where the default leaves both standing. **What moves in phase 3:** the locale packs' vocabulary, still in Python today, becomes language files; `default = false`, given its meaning in phase 2, marks the readings a pack turns on, for words too risky even gated; and the reserved names `prefer` and `strict` gain their meaning. How a tag filters (does `pt` match `pt-BR`?) and how a third-party language file joins the pool and the snapshot are open under #753. ## Phase 4: order, shape and the output model ### Order is not shape Decision 14 separates two things a "language" was going to carry: - **Order** says which position holds which role. It is a small closed set (`GIVEN_FIRST`, `FAMILY_FIRST`, `FAMILY_FIRST_GIVEN_LAST`, plus the order a script implies) and a claim about how the data was written. A language supplies only a default (Hungarian and Vietnamese are family-first in Latin script), and the caller overrides it. - **Shape** says where the non-name parts sit around the name: titles, suffixes, particles, commas. Shapes belong to an order, not to a language, so they live in the core. CLDR's person-name data shows why. Its surname-first renderings put the title between the family and the given name more often than in any other position (counted 2026-10-16 in the CLDR corpus, across about fifty locales including `en`, `de` and `pt`), so it is a shape of `FAMILY_FIRST` everywhere, not a convention of some language. Today it is misread (measured 2026-10-19): | Input under `FAMILY_FIRST` | Today | |---|---| | `Brühl Dr. Ada` | family `Brühl`, given `Dr.` | | `Brühl Prof. Dr. Ada Cornelia` | family `Brühl`, given `Prof.`, middle `Dr. Ada Cornelia` | The plan is to keep order a few permutations and make shapes first-class, each order accepting a listed set of them. The shape inventory (#469) is the start, and it doubles as the test grid that nameparser/AGENTS.md's comma-shape and `name_order` sweeps describe. The related comma bug, a title before a family comma, was fixed in 2.x ([rules/H6](rules/H6-title-before-a-family-comma.md), a title before a family comma). ### The rules a pack can choose A language file will hold a `[rules]` table that turns on rules implemented in the core: a default order, patronymic and married-name endings, a surname span, a compound given name. A new rule kind is a core change with a mechanisms.md entry, never code in a pack; the one code hook stays the segmenter. The existing packs fit: ZH and JA turn on segmentation, and RU and TR_AZ become ending rules, whose deviations could then be computed rather than hand-copied. Measured gaps each rule would close (2026-10-19): | Rule | Input | Today | |---|---|---| | surname span | `Gabriel García Márquez` | middle `García`, family `Márquez` by default; family `García Márquez` under the opt-in `Policy(two_family_names=True)` | | compound given name (pt) | `Maria do Carmo Silva` | given `Maria`, family `do Carmo Silva` | | married-name ending (hu) | `Kovács Jánosné` | given `Kovács`, family `Jánosné` | | digraph initials (cs) | `Petr Chmelař` | initials `P. C.` | The surname span has shipped ahead of the packs, as an opt-in `Policy` field rather than a pack, since the convention is not one language's ([rules/O6](rules/O6-two-family-names.md), two family names, #875 and #878; decisions/O6 has why it is not `locales.ES`). A pack can later switch it on in its `[rules]`, as it will a default order. The real-name corpora agree on the priority. In the GND and Wikidata corpora, Spanish and Latin American double surnames written given-first disagreed with the source on 315 of 390 names, while the same kind of names in index form, where the comma settles it, disagreed on 0 of 60 (counted 2026-10-16 on master `4375b01b`, from `gnd_agreement.json` and `wikidata_agreement.json`): the gap is order and span, not vocabulary. ### The output model Phase 4 also decides what a parse returns, aligned with CLDR's person-name fields, which gives ground truth for free: - **A second surname** (`surname2`) for Spanish, Catalan and Galician names, which CLDR's samples carry and nameparser has no field for. - **A patronymic field** (decision 8), holding only a living patronymic. Its main value to callers is that `family` can be empty for the cultures that have no family name: `Soffía Karlsdóttir` reads family `Karlsdóttir` and `Mahathir bin Mohamad` family `bin Mohamad` today, where a letter addressed to "Mr. {family}" is wrong. Whether the field is `patronymic` or a neutral list of parent names, which a matronymic also fits, is open under #754. - **Credentials separate from generation suffixes** (#326), and a decision on whether honorifics after a name get a field or stay a reading kind inside `suffix`. - **What `middle` means** when a given name is several words (#704). - **Gender**, recorded only where a word gives a definitive clue (decision 9): reported with its evidence, never inferred from given names. ## The first packs Which packs come first once phase 3 exists is tracked in #747, not here. The surname-span measurement above is one input, but it was taken over corpora built to hold second surnames, so it shows that the gap exists, not how it ranks against the others. ## Where each fact lives The design notes these phases came from are working notes and are not kept. What lasts goes to one home each: | Fact | Home | |---|---| | A single word: what it stands for, its counts, a refused reading | its TOML entry | | When any word may join a class (the collision criterion) | decisions/vocabulary-collisions and its neighbours | | A decision about the model, when it is made | decisions/vocabulary-model | | How the parser reads a shape the design settles | rules/, with `tracked:` or `deviates:` until it ships | | A language's conventions | that language's file and `[rules]`, plus a rule for each rule kind a pack adds | | An open question | its issue, linked from a decisions entry, never restated | | Where the model is going | this document | derek73-python-nameparser-456b024/docs/index.rst000066400000000000000000000022071527303271700215010ustar00rootroot00000000000000.. Nameparser documentation master file, created by sphinx-quickstart on Fri May 16 01:29:58 2014. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. Python Human Name Parser ======================== Version |release| nameparser parses human names into seven fields — title, given, middle, family, suffix, nickname, maiden. Results are immutable, configuration is composable (:class:`~nameparser.Lexicon` for vocabulary, :class:`~nameparser.Policy` for behavior), and locale packs are opt-in. Requires Python 3.11+. .. doctest:: >>> from nameparser import parse >>> name = parse("Dr. Juan Q. Xavier de la Vega III") >>> name.given, name.family ('Juan', 'de la Vega') >>> name.render("{family}, {given}") 'de la Vega, Juan' Coming from 1.x? :doc:`migrate`. .. toctree:: :maxdepth: 2 usage concepts customize locales migrate modules release_log resources contributing Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` **GitHub Project**: https://github.com/derek73/python-nameparser derek73-python-nameparser-456b024/docs/locales.rst000066400000000000000000000441621527303271700220220ustar00rootroot00000000000000Locale packs ============ A locale pack is an opt-in bundle of policy — and, when a naming tradition needs it, vocabulary — for one specific pattern: East Slavic patronymics, Turkic patronymic markers, and so on. Packs apply only when you ask for one by name, and every pack makes the same promise: it never changes a name outside the shapes it declares. Read that precisely — a shape is a pattern, not a language, so a pack can act on a name from a tradition it was not written for. The warning under `Using a pack`_ shows what that costs. What works without a pack -------------------------- Most international names need no pack at all. Besides Latin, the default vocabulary covers Cyrillic, Greek, Arabic, Hebrew, Devanagari and Bengali, and the East Asian scripts described below. Honorifics, conjunctions and name particles written in them are recognized out of the box, as far as each script's vocabulary reaches: each of those scripts but katakana ships honorifics, while conjunctions and particles reach fewer — Cyrillic and Greek ship conjunctions and no particle, Hebrew particles and no conjunction, and Devanagari, Bengali, Han, Hangul and hiragana honorifics only. .. doctest:: >>> from nameparser import parse >>> shaikh = parse("الشيخ محمد بن سلمان") >>> shaikh.title, shaikh.given, shaikh.family ('الشيخ', 'محمد', 'بن سلمان') >>> parse("عبد الرحمن محمد").given 'عبد الرحمن' Adding a word the defaults hold back ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Native-script entries are safe to enable by default precisely because they cannot collide with Latin-script names. That rules out the reverse: transliterations like ``sri``/``shri`` are *not* in the default vocabulary, because they are also ordinary given names in Latin script. The same conservatism holds back a handful of native entries that collide within their own script — bare ``سيد`` and ``شيخ`` (common given names), Hebrew ``רב`` (an ordinary word), the Arabic ``د.`` abbreviation (it would swallow initials). If your data is homogeneous enough that a collision can't occur, the reasoning behind the default doesn't apply to you — add the entry: .. doctest:: >>> from nameparser import Lexicon, Parser >>> sayyid_lex = Lexicon.default().add( ... titles={"سيد"}, given_name_titles={"سيد"}) >>> sayyid = Parser(lexicon=sayyid_lex).parse("سيد محمد") >>> sayyid.title, sayyid.given ('سيد', 'محمد') Both fields, because ``given_name_titles`` is a marker over ``titles`` rather than a separate vocabulary: ``titles`` makes the word a title at all, and listing it in ``given_name_titles`` says the honorific precedes the *given* name — as Arabic ones do — so the word after it isn't read as a family name. Listing it in ``given_name_titles`` alone does nothing, and says nothing: the word is never read as a title, so the entry is never consulted. East Asian names need no pack ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Two East Asian behaviors are on by default for the same reason, except that what selects them is the *script* rather than the word (:ref:`east-asian-defaults` covers turning them off). The background (covered fully under :ref:`east-asian-names`): Chinese, Japanese, and Korean names put the family name first in native script and are usually written with no space between the parts. Both defaults follow from facts the script alone establishes. A name written wholly in Han or Hangul — or in kanji and kana other than katakana alone, which only Japanese produces — is assigned family-first, because every language written in those scripts orders names that way; no language guess is involved. An unspaced hangul name is additionally split into surname and given name, because hangul is written by nothing but Korean and Korean surnames are a closed census set that ships as default vocabulary. Splitting an unspaced *Han* name is the one behavior the script cannot license — the same characters could be a Chinese or a Japanese name, and a Chinese surname list splits Japanese names in the wrong place — so it is not a default: the ``zh`` and ``ja`` packs below turn it on for data whose language you can declare. A pack is for something different: a *structural* rule, like reordering a patronymic, that vocabulary alone can't express. Using a pack ------------ :func:`~nameparser.parser_for` folds one or more packs onto a base :class:`~nameparser.Parser` (the module default, unless you pass ``base=``). Here the Russian pack reads "Сидоров Иван Петрович" (Sidorov Ivan Petrovich — surname/given/patronymic order) the way a formal Russian document intends: .. doctest:: >>> from nameparser import locales, parser_for >>> ru = parser_for(locales.RU) >>> ru.parse("Сидоров Иван Петрович").given 'Иван' Stacking packs ~~~~~~~~~~~~~~ Pass more than one pack and they fold together in order: .. doctest:: >>> both = parser_for(locales.RU, locales.TR_AZ) >>> sorted(rule.name for rule in both.policy.patronymic_rules) ['EAST_SLAVIC', 'TURKIC'] How each part combines: - **Lexicons union**: a pack's words are added to the base's, never removed. - **Set-valued policy fields union** the same way — the fields declared set-valued in :class:`~nameparser.PolicyPatch`: ``patronymic_rules``, ``segment_scripts`` and the delimiter fields. - **Every other, scalar field is later-wins.** If two packs with different codes set the same scalar field — even to the same value — the later one wins and a ``UserWarning`` is raised; a pack overrides the base parser's value silently. Finding packs by code ~~~~~~~~~~~~~~~~~~~~~ Find what's shipped with :func:`~nameparser.locales.available`, and look one up dynamically by its lowercase code with :func:`~nameparser.locales.get`: .. doctest:: >>> locales.available() ('ja', 'ru', 'tr_az', 'zh') >>> locales.get("ru") is locales.RU True The command line accepts the same codes: ``python -m nameparser --locale ru --json "Сидоров Иван Петрович"`` applies the pack before parsing, equivalent to ``parser_for(locales.get("ru"))``. Shipped packs ~~~~~~~~~~~~~ .. list-table:: :header-rows: 1 :widths: 15 85 * - Code - Turns on * - ``ja`` - Japanese segmentation — activates division for unspaced Japanese names, which needs a segmenter to act: install ``nameparser[ja]`` and pass ``parser_for(locales.JA, segmenter=locales.ja_segmenter())`` (``山田太郎`` → family ``山田``, given ``太郎``). The pack ships no surname list, because no list divides a kanji name, and no order: a Japanese name already reads family-first by default. * - ``ru`` - East Slavic patronymic order — detects the formal 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. * - ``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``). * - ``zh`` - Chinese surname segmentation — splits an unspaced Han name into surname and given name (``毛泽东`` → family ``毛``, given ``泽东``) against the surname list the pack ships. It sets no name order: native-script Han already reads family-first without a pack. The list holds the commonest surnames of the mainland and Taiwan, in both scripts, and the compound surnames still borne; a name whose surname is not on it stays whole. A Japanese name read with it can split after its first character (``安藤一郎`` → family ``安``), so use ``ja`` for Japanese data. ``ja``, ``ru`` and ``tr_az`` are policy-only — they carry no vocabulary of their own. ``zh`` is both halves at once: a surname list, plus the one policy field that turns segmentation on for the script it covers. See :ref:`config-containers` for how that split (language vocabulary vs. behavior) is drawn, and `Contributing a pack to nameparser`_ for which half a new naming rule belongs in. .. warning:: A pack declares a name *shape*, not a language, and it cannot tell whose name it is looking at. Any surname that happens to end in a patronymic suffix matches the East Slavic rule, including names that are not East Slavic at all: .. doctest:: >>> ru = parser_for(locales.RU) >>> name = ru.parse("David Michael Abramovich") >>> name.given, name.family ('Michael', 'David') The default parser reads that as given ``David``, family ``Abramovich``. This is the trade the pack asks you to make, and it is why packs are opt-in rather than automatic: enable one only for data that is predominantly in the order it detects. If your input mixes traditions, parse the subsets separately with different parsers rather than enabling a pack over all of it. A declared family-first ``name_order`` stands down both patronymic rotations (``ru`` and ``tr_az``) instead of competing with them. Under ``FAMILY_FIRST`` the two never disagree on a name the rule matches: that 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. .. _segmenter-contract: Segmenters ----------- ``ja`` is policy-only in a second sense: it turns division on for Japanese text without supplying anything to divide with. That job goes to a **segmenter**, which is passed to :func:`~nameparser.parser_for` rather than carried by the pack — a :class:`~nameparser.Locale` is pure data, and a third-party callable is neither pure nor data. :func:`~nameparser.locales.ja_segmenter` is the shipped one; writing your own is worth it for any script whose divisions you know better than a surname list does. A :data:`~nameparser.Segmenter` is any callable taking a token's text and returning a :class:`~nameparser.Segmentation` — the interior offsets to cut at, plus how confident you are — or ``None`` to decline, leaving the token whole. Declining is the load-bearing half of the contract, because ``segment_scripts`` unions across packs: your segmenter may be offered a token of any activated script, not only the ones its own pack turned on. It is asked only where the surname list could not divide an unspaced token, and not where the name is already divided — by a second word in an East Asian script (the nakaguro ・ counts as a space), a family comma or a 间隔号. A word in any other script beside the token — Latin (``"Dr. 高橋一郎"``), Cyrillic — divides nothing, so the segmenter is still asked. Halfwidth katakana counts as katakana, so ``"高橋一郎 タロウ"`` is already divided. Recognize the text you can actually read and return ``None`` for the rest, rather than answering for a script you never meant to handle. A segmenter is your code, so its failures do not stay inside the parse: its own exceptions propagate out of ``parse()``, and an answer of the wrong type or a cut outside the token raises ``TypeError`` or ``ValueError``. The pack half of that arrangement carries no words at all, which makes it the shortest kind of pack there is: .. doctest:: >>> from nameparser import Lexicon, Locale, PolicyPatch, Script >>> script_pack = Locale(code="myscript", lexicon=Lexicon.empty(), ... policy=PolicyPatch( ... segment_scripts=frozenset({Script.HAN}))) ``nameparser/locales/ja.py`` is the shipped example of exactly that shape: activation is the pack's entire contribution. Applied without a segmenter it divides nothing, and building the parser emits a ``UserWarning`` naming the scripts that can never divide and the argument to pass. Creating your own Locale ------------------------- You don't need to touch nameparser's registry to use your own pack — :class:`~nameparser.Locale` is a plain, constructible value: ``Locale(code=..., lexicon=..., policy=PolicyPatch(...))``. A :class:`~nameparser.PolicyPatch` is a :class:`~nameparser.Policy`-shaped patch: every field defaults to :data:`~nameparser.UNSET` (leave it alone) instead of to a concrete value, so a pack only ever states what it changes. A patch can also be applied directly, without a pack — see :meth:`Policy.patched() `. .. doctest:: >>> from nameparser import Lexicon, Locale, PolicyPatch, parser_for >>> kapitan_lex = Lexicon.empty().add(titles={"kapitan"}) >>> corp_pack = Locale(code="mycorp", lexicon=kapitan_lex, ... policy=PolicyPatch(middle_as_family=True)) >>> kapitan = parser_for(corp_pack).parse("Kapitan Anna Maria Schmidt") >>> kapitan.title, kapitan.given, kapitan.family ('Kapitan', 'Anna', 'Maria Schmidt') That pack does two things at once: the :class:`~nameparser.Lexicon` fragment teaches the parser that ``kapitan`` is a title, and the :class:`~nameparser.PolicyPatch` turns on ``middle_as_family`` so any remaining given-position words after the first fold into ``family`` instead of ``middle`` — compare this to the default parser's reading of the same string, which has no title and splits ``given='Kapitan'``, ``middle='Anna Maria'``, ``family='Schmidt'``. The lexicon fragment is validated on its own ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The ``policy`` half is a patch, but the ``lexicon`` half is not. A pack's :class:`~nameparser.Lexicon` is a complete value in its own right and is validated on its own, before it is unioned onto the base — so a fragment listing a word in ``particles_ambiguous``, ``suffix_acronyms_ambiguous``, ``honorific_tails`` or ``suffix_words_ambiguous`` must also list it in the set that field marks (``particles``, ``suffix_acronyms``, ``suffix_words``). A fragment holding ``particles_ambiguous={"van"}`` without ``particles={"van"}`` raises ``ValueError`` before any union happens. ``given_name_titles``, ``conjunctions_ambiguous``, ``after_given_titles`` and ``agnomens`` are not checked this way, and can mark a word only the base holds. ``zh`` is the shipped worked example: its ``Lexicon(surnames=...)`` has to satisfy every ``Lexicon`` rule standing alone, before anything unions it onto the base. Contributing a pack to nameparser ---------------------------------- Shipping a pack in nameparser itself (rather than keeping it local to your own code) means meeting the in-repo contract, checked mechanically by ``tests/v2/test_locales.py``: #. Add a registry entry in ``nameparser/locales/__init__.py`` — a ``"CODE": ("module.path", "ATTR")`` row in ``_REGISTRY``, so the pack loads lazily on first access (importing ``nameparser.locales`` never imports pack modules). #. Declare a module-level ``DEVIATES(name)`` predicate: given a name string, return whether *this pack alone* might parse it differently from the default parser. Over-declaring is safe; under-declaring is not — when in doubt, ``DEVIATES`` should say yes. A pack whose scope is a *script* builds the predicate with ``_script_matcher`` from ``nameparser/_policy.py``, the way ``zh`` and ``ja`` do — never by compiling its own character ranges: the factory's docstring explains how the pack-contract test enforces this. #. Add a rotator list to ``tests/v2/test_locales.py``. Every pack needs one, but what it has to contain follows from how the pack declares its scope: - **By marker regex** (``ru``, ``tr_az``): at least one name exercising every alternation branch of every regex it defines — ``test_rotators_cover_every_marker_branch`` fails until each branch is hit. - **By codepoint range** (``zh``, ``ja``): there are no branches to sweep, so the pack drops out of that test and its rotators carry the same weight by hand — the unspaced names the pack must split, one per shape of the vocabulary it ships: single surname, compound surname, and any spelling variant it means to cover. - **With no vocabulary at all**: list the shapes its *segmenter* must divide instead, and mark the rotator tests to skip when the optional dependency is absent, so the contract tests still run everywhere. #. Keep the non-interference gate green over the shared corpus plus your rotators: every name the packed parser parses differently from the default must be one your ``DEVIATES`` predicate flags — no silent, undeclared side effects on names outside the pack's stated scope. #. Decide which layer the vocabulary belongs in, if the pack carries any. Vocabulary that is *self-selecting* — able to match only text of the tradition it came from, the way a hangul surname can only ever match hangul — is default-safe, and belongs in the default lexicon (``nameparser/vocab/*.toml``; a segmentation surname list such as the Korean one is a field of the default ``Lexicon``, ``Lexicon.default().surnames``) rather than in a pack: a pack nobody knows to ask for is vocabulary nobody gets. Vocabulary that *declares a language its script does not* — a Chinese surname list, which silently mangles the Japanese names written in the same characters — belongs in the pack, where asking for it is the declaration. ``ja``, ``ru`` and ``tr_az`` need no vocabulary at all and ship an empty :class:`~nameparser.Lexicon`; ``nameparser/locales/zh.py`` is the template for one that does. #. Curate vocabulary conservatively, the same rule as :ref:`ambiguous-words`: when you're unsure whether a word or a marker belongs, leave it out. ``nameparser/locales/ru.py`` is the reference implementation for a policy-only pack, ``nameparser/locales/zh.py`` for one that carries vocabulary, and ``nameparser/locales/ja.py`` for one whose whole contribution is turning a stage on. Packs still in progress are tracked in issue `#345 `_ (``hi`` and ``bn``). derek73-python-nameparser-456b024/docs/migrate.rst000066400000000000000000001375371527303271700220410ustar00rootroot00000000000000Migrating from HumanName ========================= ``HumanName`` and ``CONSTANTS`` keep working in 2.0 — same imports, same attributes, same mutation API (``name.C.titles.add(...)``, ``name.first = "..."``, and so on). Upgrading to 2.0 and migrating your code to the new :class:`~nameparser.Parser`/:class:`~nameparser.Lexicon`/ :class:`~nameparser.Policy` API are two separate decisions — you can do the former today and the latter whenever it's convenient. The compatibility layer (``HumanName``, ``Constants`` and the shared ``CONSTANTS``) is removed in 3.0, and 2.8 warns wherever your code touches it: see `Deprecated in 2.8`_. 2.8.1 adds warnings for three more things 3.0 changes, among them the vocabulary data modules in ``nameparser.config`` (``titles``, ``particles`` and the rest), and 2.8.2 adds the last of them, ``surnames``: see `Deprecated in 2.8.1`_. The full 1.x documentation remains the canonical reference for ``HumanName`` and stays online as the 1.4.0 build on readthedocs: https://nameparser.readthedocs.io/en/v1.4.0/ This page exists for the other direction: translating a v1 customization or a v1-shaped comparison into the 2.0 API, one row per old name. Deprecated in 2.8 ----------------- nameparser 2.8 is the last 2.x feature release. From 2.8 on, the uses 3.0 removes emit a ``DeprecationWarning`` at the line in your code that makes them, on every call rather than once per process: .. list-table:: :header-rows: 1 :widths: 40 60 * - What warns - Replacement * - ``HumanName(...)``, including a subclass's construction - ``nameparser.parse(text)``, or ``Parser(lexicon=..., policy=...).parse(text)``; the fields are ``given`` and ``family`` where ``HumanName`` has ``first`` and ``last``. See the `Attribute map`_ * - ``Constants(...)`` and ``constants.copy()`` - A ``Parser`` configured with a ``Lexicon`` and a ``Policy``; the `Config map`_ has each setting, and `Deprecated in 2.8.1`_ what 3.0 changes in ``Lexicon`` * - Mutating the shared ``CONSTANTS`` (warned since 2.0) - The same ``Parser``; a private ``Constants`` is no longer a way out, since it goes in 3.0 too * - Loading a pickled ``HumanName`` - Store ``parse(text).as_dict()`` or the original string * - Loading a pickled ``Constants`` - Pickle the ``Parser`` instead * - Reading ``SetManager``, ``TupleManager`` or ``RegexTupleManager`` from ``nameparser.config`` - ``Lexicon`` fields take plain sets; see `Deprecated in 2.8.1`_ for what 3.0 changes in them * - Reading ``lc`` from ``nameparser.util`` - ``value.lower().strip(".")`` Importing ``nameparser``, ``nameparser.parser`` or ``HumanName`` stays quiet, and so does ``from nameparser import *``; the warning comes when the code runs. ``from nameparser.config import *`` does warn, once for each manager it imports, and so does ``from nameparser.util import *``. One use does not warn: *reading* the shared ``CONSTANTS`` (``"dr" in CONSTANTS.titles``, or ``copy.copy(CONSTANTS)``), which stays quiet so that importing ``nameparser`` does. Search your code for ``CONSTANTS`` as well. A ``DeprecationWarning`` is hidden by default outside ``__main__``, so run your tests to see the rest, or turn them into errors to find each site: .. code-block:: bash python -W error::DeprecationWarning -m pytest The 2.0 API's names carry into 3.0: ``parse``, ``Parser``, ``Lexicon``, ``Policy``, ``Locale`` and ``ParsedName``. Their signatures gain additions only, save ``Lexicon``'s set fields, which 2.8.1 deprecates. Separately, the output model changes in 3.0; details with the 3.0 alphas. .. _deprecated-2-8-1: Deprecated in 2.8.1 ------------------- 2.8.0 gave no warning for three 3.0 changes, and 2.8.1 adds one for each, 2.8.2 adding ``nameparser.config.surnames`` to the second: a ``DeprecationWarning`` at the line in your code that makes the call, naming what 3.0 does instead and linking the `3.0 migration guide `__. The 3.0 spellings do not run on 2.x, so the old spelling is still the one to write here; filter the warning by message if it is in your way until you upgrade. The one exception is the ``Lexicon`` field ``surnames``, which 3.0 keeps (the module of that name it makes private): ``lexicon | Lexicon(surnames=frozenset({"kim"}))`` and ``dataclasses.replace(lexicon, surnames=...)`` run quietly on 2.8.1 and in 3.0 alike, and the warning on ``add()`` and ``remove()`` with ``surnames`` spells the call. .. list-table:: :header-rows: 1 :widths: 40 60 * - What warns - In 3.0 * - A ``Lexicon`` set field written through ``add()``, ``remove()``, ``Lexicon(...)`` or ``dataclasses.replace()`` - Refused with a ``TypeError`` naming the readings that replace the call for the words it names; the 3.0 guide's "Lexicon fields → readings" table has a row for each field. ``surnames`` and ``capitalization_exceptions`` stay fields, so only ``add()`` and ``remove()`` with ``surnames`` warn * - A constant of ``nameparser.config.titles``, ``suffixes``, ``particles``, ``conjunctions``, ``bound_given_names``, ``given_names``, ``maiden_markers`` or ``capitalization`` - Removed with its module. ``Lexicon.words(kind=...)`` lists a kind's words, and ``Lexicon.remove(kind=..., every=True)`` removes a whole kind * - ``nameparser.config.surnames.KOREAN_SURNAMES`` (since 2.8.2) - Private in 3.0. ``Lexicon.default().surnames`` holds the same words, and reads quietly on 2.8.2 and in 3.0 alike * - Anything in ``nameparser.config.regexes`` - Removed with no replacement: the parser has not read it since 2.0, so copy a pattern you reuse Reading a ``Lexicon`` field (``lexicon.titles``) does not warn, though 3.0 refuses it as well: the parser reads those fields for every word, 35 times for ``John Smith``, and a property in their place cost 4% to 11% of a parse's time (measured 2026-11-02, before any warning code). Search your code for reads of the fields. Importing the modules does not warn either; reading a constant from one does. Before you upgrade ------------------ What breaks without a warning ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ What 2.0 removes is the batch of deprecations 1.3 and 1.4 announced. If your test suite runs clean on 1.4 under ``python -W error::DeprecationWarning``, it will run on 2.0.0 — with the exceptions below, which 1.4 never warned about. The first three raise the first time you hit them; the last only warns, so read it carefully: * ``CONSTANTS.regexes. = ...`` raises ``TypeError``. This includes ``CONSTANTS.regexes.bidi = False``, the opt-out 1.3.1 recommended for keeping bidirectional marks; the 2.0 spellings are ``Policy(strip_bidi=False)`` and ``Policy(strip_emoji=False)`` * assigning a ``*_list`` attribute (``name.first_list = [...]``) raises ``AttributeError`` — the lists are read-only snapshots in 2.0 * positional ``Constants(...)`` construction raises ``TypeError``; the constructor is keyword-only * a subclass overriding a v1 parsing hook (``pre_process``, ``parse_pieces``, ``is_title``, and the rest) gets a ``DeprecationWarning`` at construction naming the hooks it overrode, because 2.0 delegates parsing to the core parser and never calls them Later 2.x releases add breaks of their own, none of which 1.4 warned about either: * editing a default word list in place — ``TITLES.add("dean")`` — raises ``AttributeError`` since 2.2, when the vocabulary sets became ``frozenset`` (see `Default word lists are frozen`_) * reading a renamed config constant (``prefixes.PREFIXES`` and the rest) emits a ``DeprecationWarning`` since 2.2, which fails a suite run under ``-W error::DeprecationWarning`` * a ``capitalization_exceptions`` value that is not a case mask of its key — ``{"md": "Doc"}`` — raises ``ValueError`` at the first parse since 2.4 * a ``Constants`` entry no word can match — empty, only full stops, or holding whitespace no word carries, in a vocabulary set or as a ``capitalization_exceptions`` key — is dropped with a ``UserWarning`` naming it, since 2.4 * a ``Lexicon``, ``Policy`` or ``Parser`` pickled by 2.5 or earlier raises ``ValueError`` on load since 2.6, each having gained a field; re-pickle after upgrading. A ``Lexicon``, ``Parser`` or locale pack pickled by 2.6 raises the same way since 2.7 A silent change: comparing with ``==`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ One removal changes results without saying anything. ``HumanName`` no longer defines ``__eq__``, so ``name == "John Smith"`` is now ``False`` where 1.x returned ``True``. That one *did* warn on 1.4, but nothing will tell you on 2.0. If you compare names anywhere, grep for ``==`` before upgrading and move to ``matches()`` — see `Comparison`_. Re-pickle before you upgrade ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ One step has to happen *before* you upgrade, because the fix is only available on the version you're leaving: a ``Constants`` pickle written by nameparser 1.2.x or earlier must be re-pickled under 1.3 or 1.4. 2.0 refuses to load one, and by then the code that could rewrite it is gone. Replacements for the warned removals ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ If your suite did *not* run clean, these are the replacements for the warned removals: .. list-table:: :header-rows: 1 :widths: 45 55 * - 1.4 spelling - 2.0 replacement * - ``HumanName(b"...")``, ``manager.add(b"...")`` - Decode first: ``HumanName(raw.decode("utf-8"))`` * - ``SetManager.add_with_encoding(b"...")`` - ``add()``, on already-decoded ``str`` * - ``manager()`` (calling a set manager) - ``set(manager)``, or iterate it directly * - ``manager.remove(missing)`` (was tolerant) - ``discard()`` to ignore missing; ``remove()`` now raises ``KeyError`` like ``set.remove`` * - ``HumanName(..., constants=None)`` - ``Constants()`` for library defaults, or ``CONSTANTS.copy()`` for a private snapshot of the current shared config (both deprecated since 2.8: see `Deprecated in 2.8`_) * - ``name['first'] = value`` - ``name.first = value`` * - ``CONSTANTS.regexes.typo`` (returned ``EMPTY_REGEX``) - ``.get("typo")`` for intentional soft access; attribute access now raises ``AttributeError``. The same applies to ``capitalization_exceptions``, whose error also lists the known keys * - ``CONSTANTS.empty_attribute_default`` - Gone; empty fields are always ``''`` Finally, if you want to see the difference on your own data rather than ours, ``tools/differential/compare.py`` in the source repository diffs any released version's parses against the source tree over the corpora beside it, or over a file of your own names (``--corpus``). It is development tooling, not part of the installed package — see its README. Attribute map ------------- ``HumanName``'s seven fields and their aggregates map onto :class:`~nameparser.ParsedName` like this: .. list-table:: :header-rows: 1 :widths: 30 40 30 * - ``HumanName`` - ``ParsedName`` - Note * - ``title`` - ``title`` - * - ``first`` - ``given`` - * - ``middle`` - ``middle`` - * - ``last`` - ``family`` - * - ``suffix`` - ``suffix`` - * - ``nickname`` - ``nickname`` - * - ``maiden`` - ``maiden`` - New field (added 1.3) * - ``title_list``, ``first_list``, ``middle_list``, ``last_list``, ``suffix_list``, ``nickname_list``, ``maiden_list`` - ``tokens_for(Role.TITLE)``, ``tokens_for(Role.GIVEN)``, ... - Returns the raw :class:`~nameparser.Token` tuple for that role; read ``.text`` off each token for the string a ``_list`` attribute gave you * - ``given_names`` / ``given_names_list`` - ``given_names`` - Unchanged name; ``middle`` folded into ``first`` * - ``surnames`` / ``surnames_list`` - ``surnames`` - Unchanged name; ``middle`` folded into ``last`` * - ``last_base`` / ``last_base_list`` - ``family_base`` - The surname with leading particles split off * - ``last_prefixes`` / ``last_prefixes_list`` - ``family_particles`` - The particles ``family_base`` was split from (e.g. ``"de la"``) * - ``string_format`` - ``render(spec)`` - A per-call argument now, not stored config — see :ref:`rendering-arguments` * - ``initials_format``, ``initials_delimiter``, ``initials_separator`` - ``initials(spec, delimiter, separator)`` - Same three knobs, now call-site arguments to :meth:`~nameparser.ParsedName.initials` — see :ref:`rendering-arguments` * - ``suffix_delimiter`` - ``Policy(extra_suffix_delimiters=frozenset({...}))`` - Now a ``Policy`` set field, so several delimiters can be active; passing the old scalar through raises (:ref:`suffix-delimiters`) * - ``capitalize(force=...)`` - ``capitalized(force=...)`` - :meth:`~nameparser.ParsedName.capitalized` returns a new value rather than mutating in place. Its optional first argument takes a :class:`~nameparser.Lexicon`, if you need custom capitalization exceptions * - ``full_name``, ``original`` - ``original`` - The text as passed in * - ``name["first"]``, ``as_dict()`` - ``as_dict()["given"]`` - The keys are the 2.0 field names * - ``iter(name)``, ``len(name)`` - ``[v for v in name.as_dict().values() if v]`` - The non-empty fields, in field order * - ``name.first = "Jane"`` - ``Parser().revise(name, given="Jane")`` - Returns a new value, the replacement read by the parser; ``name.replace(given="Jane")`` splices the text in unread * - ``HumanName(first="Juan", last="de la Vega")`` - ``Parser().revise(parse(""), given="Juan", family="de la Vega")`` - Builds a name from its fields * - ``C``, ``has_own_config``, ``parse_full_name()`` - none - A ``ParsedName`` holds no config. The ``Parser`` that read it does; to read the name again with other config, call ``parser.parse(name.original)`` * - ``collapse_whitespace()`` - none - ``parse()`` collapses whitespace itself ``suffix_delimiter`` moved as a *set*, not just under a new name: passing the old scalar through (``extra_suffix_delimiters=" - "``) raises, rather than silently registering three one-character delimiters. Side by side: .. doctest:: >>> from nameparser import HumanName, parse, Role >>> hn = HumanName("Dr. Juan Q. Xavier de la Vega III") >>> n = parse("Dr. Juan Q. Xavier de la Vega III") >>> hn.title == n.title, hn.first == n.given, hn.last == n.family (True, True, True) >>> hn.first_list == [t.text for t in n.tokens_for(Role.GIVEN)] True >>> hn.given_names == n.given_names, hn.surnames == n.surnames (True, True) >>> hn.last_base == n.family_base, hn.last_prefixes == n.family_particles (True, True) Editing a field, and building a name from fields: .. doctest:: >>> from nameparser import Parser >>> Parser().revise(n, given="Jane").given 'Jane' >>> built = Parser().revise(parse(""), given="Juan", family="de la Vega") >>> built.family, built.family_particles ('de la Vega', 'de la') Config map ---------- Vocabulary sets → Lexicon ~~~~~~~~~~~~~~~~~~~~~~~~~ ``CONSTANTS``' vocabulary sets map onto :class:`~nameparser.Lexicon` fields: .. list-table:: :header-rows: 1 :widths: 30 40 30 * - ``CONSTANTS`` - ``Lexicon`` - Note * - ``titles`` - ``titles`` - * - ``first_name_titles`` - ``given_name_titles`` - * - ``suffix_acronyms`` - ``suffix_acronyms`` - * - ``suffix_not_acronyms`` - ``suffix_words`` - * - ``suffix_acronyms_ambiguous`` - ``suffix_acronyms_ambiguous`` - * - ``prefixes`` - ``particles`` - * - ``non_first_name_prefixes`` - ``particles_ambiguous`` - **Flipped** — see the warning below * - ``conjunctions`` - ``conjunctions`` - * - ``bound_first_names`` - ``bound_given_names`` - * - ``capitalization_exceptions`` - ``capitalization_exceptions`` - Pair-valued, so not ``add()``/``remove()``; extend the defaults with ``dataclasses.replace()`` as :ref:`case-exceptions` shows. Since 2.4 a value is a case mask, so ``{"md": "M.D."}`` repairs ``md`` to ``MD`` Some ``Lexicon`` fields have no ``CONSTANTS`` attribute at all — ``conjunctions_ambiguous``, ``maiden_markers``, ``surnames``, ``honorific_tails``, ``after_given_titles``, ``suffix_words_ambiguous``, ``agnomens`` and ``given_name_abbreviations`` — so they are reached through the 2.0 API only. .. warning:: ``non_first_name_prefixes`` and ``particles_ambiguous`` mark **complementary** sets, not the same set under a new name. ``non_first_name_prefixes`` lists particles that are *never* read as a given name; ``particles_ambiguous`` lists the particles that *may* be read as one. The same holds for the config constant behind it (renamed in 2.2; see `Renamed word lists (2.2)`_ below): ``particles.NON_GIVEN_NAME_PARTICLES`` (1.x ``prefixes.NON_FIRST_NAME_PREFIXES``) marks the never-given set, so it is the complement of ``particles_ambiguous`` too, however much the 2.2 names now suggest otherwise. Translating a customization means flipping the set: ``particles_ambiguous = lexicon.particles - constants.non_first_name_prefixes``. Copying ``non_first_name_prefixes`` straight into ``particles_ambiguous`` silently inverts which particles are allowed to double as a given name. Renamed word lists (2.2) ~~~~~~~~~~~~~~~~~~~~~~~~ The vocabulary that feeds both columns is exposed by ``nameparser.config`` (compiled since 2.6 from ``nameparser/vocab/*.toml``, all but the Korean surname list), and in 2.2 its module and constant names moved to the vocabulary the ``Lexicon`` column speaks — particles, bound given names, given-name titles, suffix words. Terminology only; one of the four kept 1.x's *meaning* while its ``Lexicon`` counterpart marks the opposite set, so read the caveat under the table before pairing them up. If you import the default word lists directly — to read one, extend one, or copy one into your own configuration — four vocabularies moved: .. list-table:: :header-rows: 1 :widths: 50 50 * - 1.x name - 2.2 name * - ``nameparser.config.prefixes`` - :mod:`nameparser.config.particles` * - ``prefixes.PREFIXES`` - ``particles.PARTICLES`` * - ``prefixes.NON_FIRST_NAME_PREFIXES`` - ``particles.NON_GIVEN_NAME_PARTICLES`` * - ``nameparser.config.bound_first_names`` - :mod:`nameparser.config.bound_given_names` * - ``bound_first_names.BOUND_FIRST_NAMES`` - ``bound_given_names.BOUND_GIVEN_NAMES`` * - ``titles.FIRST_NAME_TITLES`` - ``titles.GIVEN_NAME_TITLES`` * - ``suffixes.SUFFIX_NOT_ACRONYMS`` - ``suffixes.SUFFIX_WORDS`` The caveat is on the third row. ``NON_GIVEN_NAME_PARTICLES`` is ``NON_FIRST_NAME_PREFIXES`` renamed and nothing else — same members, same *never a given name* meaning. It is **not** the constant behind ``Lexicon.particles_ambiguous``, which is that field's complement, even though the two now sound as though they belong together. Pairing this table's third row with the field-mapping table above and concluding that ``NON_GIVEN_NAME_PARTICLES`` is what ``particles_ambiguous`` holds is exactly the inversion the flip warning above exists to prevent. Every row still resolves, and the old names are removed in 3.0. The two module rows are import paths and nothing more: importing ``nameparser.config.prefixes`` or ``nameparser.config.bound_first_names`` still works and says nothing, because the modules are now empty shims. It is reading a *constant* that reports — by attribute access, by ``from ... import``, and by ``from ... import *`` alike. The read emits a ``DeprecationWarning`` naming the module and constant to move to, then returns the constant from its new home. The warning fires once per line that reads a retired name, not once per process, so a repeated read of the same import stays quiet while a second import somewhere else in your code reports for itself. To find your own uses, raise ``DeprecationWarning`` — which Python hides by default outside ``__main__``, so an untouched run of a library that reads these names on import shows nothing:: python -W error::DeprecationWarning -c "import yourapp" That stops at the first one, with a traceback whose last frame outside nameparser is the line to edit. Swap ``error`` for ``default`` to print them all and keep going. Only the data layer moved: the ``CONSTANTS`` attribute names in the field-mapping table above are v1 facade surface and are unaffected, so ``constants.prefixes``, ``constants.non_first_name_prefixes``, ``constants.bound_first_names``, ``constants.first_name_titles`` and ``constants.suffix_not_acronyms`` keep their 1.x spelling for as long as the facade exists. Default word lists are frozen ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Every vocabulary *set* in ``nameparser.config`` is also a ``frozenset`` as of 2.2 — the renamed ones and the rest. Every set, that is; the one mapping constant is untouched, and there is a note on it below. The freeze retires one 1.x idiom outright: ``TITLES.add("dean")`` — editing a default word list in place — now raises ``AttributeError`` at the line that writes it, rather than changing some parses and not others some distance away. It was never a dependable way to change a default, because the two config layers read the module constants at different moments. ``Lexicon.default()`` is cached and reads them exactly once, at its first call; a v1 ``Constants`` copies them at every construction; and the shared ``CONSTANTS`` singleton is one such copy, taken at import. An edit landing *after* the first parse therefore reached only a freshly built ``Constants`` — neither ``parse()``, whose lexicon was already built, nor the shared ``CONSTANTS``, which predated the edit. An edit landing *before* any parse reached ``Lexicon.default()``, and so ``parse()``, and a fresh ``Constants`` — but still never the shared ``CONSTANTS``. Whether an edit reached a given parse thus depended on which config objects the program had already built, and one program could hold two disagreeing defaults with nothing to say so. ``CAPITALIZATION_EXCEPTIONS`` is the constant the freeze left out. It is a mapping rather than a set, and it is still a plain mutable ``dict`` — ``CAPITALIZATION_EXCEPTIONS["dclinpsy"] = "DClinPsy"`` runs on 2.2 and raises nothing. Everything just said about split defaults still applies to it, unchanged and measured on 2.2 and again on 2.6: an edit after the first parse reaches a freshly built ``Constants``, and neither ``Lexicon.default()`` nor the shared ``CONSTANTS``. The advice below is the same advice — configure the object, with ``constants.capitalization_exceptions["dclinpsy"] = "DClinPsy"`` on a private ``Constants``, or for the 2.0 API a ``dataclasses.replace()`` that extends the default pairs rather than replacing them (:ref:`case-exceptions`). Configure the objects instead, which both APIs have always supported and neither the freeze nor the rename affects. For ``HumanName``, build a private ``Constants`` and pass it: .. doctest:: >>> from nameparser import HumanName >>> from nameparser.config import Constants >>> HumanName("Dean Smith").first # a title only in a phrase 'Dean' >>> dean_constants = Constants() >>> _ = dean_constants.titles.add("dean") # returns the set >>> HumanName("Dean Smith", constants=dean_constants).title 'Dean' For the 2.0 API, extend the default lexicon and hand it to a parser: .. doctest:: >>> from nameparser import Lexicon, Parser >>> dean_parser = Parser(lexicon=Lexicon.default().add(titles={"dean"})) >>> dean_parser.parse("Dean Smith").title 'Dean' Mutating the shared ``CONSTANTS`` singleton still works and still reaches every ``HumanName`` that reads it, but it warns: it is deprecated along with the rest of the v1 facade and goes away in 3.0. Since 2.8 a private ``Constants`` warns at construction too, so new code should build the ``Parser``. See :doc:`customize` for the full set of knobs on each. Behavior and render settings → Policy ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Behavior and render scalars map onto :class:`~nameparser.Policy` (or a rendering argument, where the 2.0 equivalent isn't config at all): .. list-table:: :header-rows: 1 :widths: 30 40 30 * - ``CONSTANTS`` - 2.0 equivalent - Note * - ``patronymic_name_order`` - ``Policy(patronymic_rules=frozenset({PatronymicRule.EAST_SLAVIC, PatronymicRule.TURKIC}))`` - v1's single flag enabled both detectors at once; pick one rule (or a locale pack, see :doc:`locales`) if you only want one tradition * - ``middle_name_as_last`` - ``Policy.middle_as_family`` - * - ``nickname_delimiters`` - ``Policy.nickname_delimiters`` - Was a dict of named sentinels; now a plain ``frozenset`` of ``(open, close)`` pairs. Both APIs gained the #273 typographic defaults (smart quotes, guillemets, CJK brackets, ...) in 2.0 (:ref:`brackets`) * - ``maiden_delimiters`` - ``Policy.maiden_delimiters`` - Same shape change as ``nickname_delimiters``. Precedence differs: in the 2.0 API a pair listed here wins over ``nickname_delimiters``; through the 1.x facade a pair in both buckets keeps parsing as ``nickname`` (v1 behavior; :ref:`brackets`) * - ``regexes.bidi`` - ``Policy.strip_bidi`` - ``regexes.bidi = False`` becomes ``Policy(strip_bidi=False)`` (:ref:`strip-flags`) * - ``regexes.emoji`` - ``Policy.strip_emoji`` - ``regexes.emoji = False`` becomes ``Policy(strip_emoji=False)`` (:ref:`strip-flags`) * - ``force_mixed_case_capitalization`` - ``capitalized(force=True)`` - The stored default is gone; pass ``force`` at the call site. v1's ``capitalize(force=True)`` already worked that way — this constant only supplied the default for calls that omitted it * - ``capitalize_name`` - *(no equivalent)* - 2.0 never capitalizes automatically during ``parse()``; call ``.capitalized()`` explicitly on the result instead Every other ``regexes.*`` entry (``word``, ``spaces``, and the rest of the compiled-pattern proxy) has no 2.0 replacement — parsing behavior is configured entirely through named ``Policy`` fields now, not by handing the parser a regex. Comparison ---------- ``HumanName.__eq__``/``__hash__`` were deprecated in 1.3.0 and are gone in 2.0's core API; use ``matches()`` for "is this the same name?" and ``comparison_key()`` for dedup, dict keys, and sorting — both exist on ``HumanName`` and on :class:`~nameparser.ParsedName` with the same behavior: .. doctest:: >>> from nameparser import HumanName, parse >>> hn = HumanName("de la Vega, Juan") >>> n = parse("de la Vega, Juan") >>> hn.matches("Juan de la Vega"), n.matches("Juan de la Vega") (True, True) >>> hn.comparison_key() == n.comparison_key() True One behavior changed underneath both methods: components now fold with ``str.casefold()`` instead of ``str.lower()``, so more Unicode case-pairs compare equal than did under 1.4. The change is strictly more permissive — anything 1.4 matched still matches: .. doctest:: >>> parse("Anna STRASSE").matches("Anna Straße") True Behavior changes ----------------- Beyond the API surface mapped above, a handful of parse *outputs* differ between 1.4 and 2.x for specific input shapes. The full list, with reasoning, is in each 2.x section of :doc:`release_log`. Changed in 2.0 ~~~~~~~~~~~~~~ These are the shapes worth grepping your own fixtures for, because a recognized suffix or title now stays in its own field instead of landing in ``first``/``last``: .. doctest:: >>> HumanName("Andrews, M.D.").last, HumanName("Andrews, M.D.").suffix ('Andrews', 'M.D.') >>> HumanName("Johnson PhD").first, HumanName("Johnson PhD").suffix ('Johnson', 'PhD') Under 1.4 those read ``first="M.D."``/``last="Andrews"`` and ``first="Johnson"``/``last="PhD"`` respectively. Two more to check for: a maiden marker now fills the ``maiden`` field rather than being folded into ``middle``/``last`` (``"Jane Smith née Jones"``), and with a custom suffix delimiter configured, a no-space delimiter group renders whole (``"RN/CRNA"``) where 1.x split it (``"RN, CRNA"``) — the role assignment is identical, only the rendered string differs. East Asian names (2.1) ~~~~~~~~~~~~~~~~~~~~~~ 2.1 adds three more, and unlike most of the 2.0 API these do reach ``HumanName``: a name written in East Asian script is read family-first, an unspaced Korean name is split into surname and given name, and the katakana middle dot separates tokens the way a space does (:ref:`east-asian-names` explains the conventions). .. doctest:: >>> HumanName("毛 泽东").last '毛' >>> HumanName("김민준").last, HumanName("김민준").first ('김', '민준') 1.4 read the first as ``first="毛"``/``last="泽东"``, and left the second whole in ``first``. The Korean one also changes what the name *renders* as — ``str(HumanName("김민준"))`` was ``"김민준"`` and is now ``"민준 김"``, because the split inserts a token boundary that the default format then writes given-name-first. A third shape changes even though nothing splits it. 1.4 routed a lone token to ``first`` whatever it was, so an unspaced Chinese or Japanese name landed there entire; 2.1 reads it as native-script CJK and puts it in ``last`` instead. Nothing is segmented — Han splitting is opt-in through a locale pack and ``HumanName`` cannot apply one — but the field the whole string arrives in is different: .. doctest:: >>> HumanName("毛泽东").last, HumanName("毛泽东").first ('毛泽东', '') >>> HumanName("山田太郎").last, HumanName("山田太郎").first ('山田太郎', '') Both were ``first`` under 1.4. If you feed unspaced CJK through ``HumanName`` and read ``first``, that is the change most likely to reach you, and it is silent — the string is intact, just in the other field. Japanese kana carries the same order rule, which widens both shapes past the wholly-Han text described above. A name that mixes kanji with hiragana or katakana is read family-first, and a lone kana-bearing token moves from ``first`` to ``last`` exactly as ``毛泽东`` does: .. doctest:: >>> HumanName("高橋 みなみ").last, HumanName("高橋 みなみ").first ('高橋', 'みなみ') >>> HumanName("山田 エミ").last '山田' >>> HumanName("高橋みなみ").last, HumanName("高橋みなみ").first ('高橋みなみ', '') 1.4 read the spaced ones as ``first="高橋"``/``last="みなみ"`` and ``first="山田"``/``last="エミ"``, and put the unspaced one whole in ``first``. The spaced shapes change what the name renders as too: ``str(HumanName("高橋 みなみ"))`` was ``"高橋 みなみ"`` and is now ``"みなみ 高橋"``. A name written *wholly* in katakana is deliberately left alone — it may be a transcribed foreign name already in given-first order or a Japanese name's reading written family-first, and the script cannot tell which (see :ref:`east-asian-names` to opt in) — so ``HumanName("マイケル ジャクソン")`` reads ``first="マイケル"``/``last="ジャクソン"`` on both versions. One more shape changes for a different reason: the katakana middle dot ``・``, which divides the parts of such a transcription, is now a token separator rather than an ordinary character. 1.4 saw one token and put it in ``first``; 2.1 sees two: .. doctest:: >>> HumanName("マイケル・ジャクソン").first 'マイケル' >>> HumanName("マイケル・ジャクソン").last 'ジャクソン' >>> HumanName("高橋・一郎").last, HumanName("高橋・一郎").first ('高橋', '一郎') The two divide the same way and land in opposite fields, because the katakana pair keeps its source order while the kanji pair takes the family-first rule. Separating also changes the rendered string, the same way the Korean split does: the dot comes back as a space, so ``str(HumanName("マイケル・ジャクソン"))`` was ``"マイケル・ジャクソン"`` and is now ``"マイケル ジャクソン"``. That reaches delimited content too — the nickname in ``"山田 太郎 (マイケル・ジャクソン)"`` was ``"マイケル・ジャクソン"`` under 1.4 and is ``"マイケル ジャクソン"`` now. Turning the East Asian readings off ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ``Constants`` has no switch for any of this — the v1 configuration surface is frozen for 2.x — so the way out is the 2.0 API (:ref:`east-asian-defaults`): ``Parser(policy=Policy(script_orders=(), segment_scripts=frozenset()))`` restores 1.4's reading of every shape above that turns on order or splitting. The middle dots are the exception: both the katakana dot and the Chinese interpunct ``·`` (U+00B7, dividing a transcription like ``威廉·莎士比亚``, and a separator only between classified-script characters — each side judged on its own — where the katakana dot is unconditional) are decided in tokenization rather than by policy, so a name written with either still divides at the dot, and still renders with a space, whatever those two fields are set to. Changed in 2.3 through 2.8 ~~~~~~~~~~~~~~~~~~~~~~~~~~ Later releases moved more 1.4 readings, and these reach ``HumanName`` as well; 2.1 and 2.2 read every one of them as 1.4 did. The release log's 2.3 to 2.8 sections carry the reasoning; these are the shapes to grep your fixtures for, with what 1.4 read: - ``"Her Majesty Queen Elizabeth"``, ``"His Royal Highness Prince Harry"`` (2.3) — a lone name after a title that addresses by given name is the given name: 1.4 read last ``Elizabeth`` and ``Harry``. 2.3 matched a run of titles by its last one (``Her Majesty Queen``, ``Rev Sir``) and added titles that address that way (``Prince``, ``Princess``, ``Swami``, ``Guru`` and others). Other titles still leave the name the family name (``"Dr Harry"``). Since 2.7 a bare ``Prince`` or ``Princess`` is a given name (below); behind a style, as here, it is still a title. - ``"Dr King Jr"`` (2.3) — 1.4 read title ``Dr King``, last ``Jr``. - ``"John Smith Prof."`` (2.3) — a trailing period-marked title: 1.4 read last ``Prof.``, middle ``Smith``. - ``"Jack MA"`` (2.4) — an ambiguous acronym in capitals in a mixed-case name is a credential: 1.4 read last ``MA``. ``"Jack Ma"`` and ``"JACK MA"`` are unchanged. - ``"jose e maria santos"`` (2.4) — ``e`` in a name written wholly in one case is an initial (``y`` still joins): 1.4 read first ``jose e maria``. - ``"van der Berg, PhD"`` (2.4) — 1.4 read first ``van``, last ``der Berg``. - ``"Josep Carod i Rovira"`` (2.4) — ``i`` links two surnames in a mixed-case name: 1.4 read middle ``Carod i``, last ``Rovira``. - ``"Jane Smith N.C.C."`` (2.4) — an unlisted dotted credential after a full name: 1.4 read last ``N.C.C.``, middle ``Smith``. ``"Jack X.Y.Z."`` is unchanged. - ``"Jane Smith, LEED AP"`` (2.4) — an unlisted all-caps credential after a comma: 1.4 read first ``LEED``, middle ``AP``, last ``Jane Smith``. - ``"Eric H. Holder Jr. Attorney General"`` and ``"Eric H. Holder, Jr., Attorney General"`` (2.4) — a title after a credential is a title: 1.4 read middle ``H. Holder Jr. Attorney``, last ``General``, and suffix ``Jr., Attorney General``. - ``"Steven Hardman, RN - CRNA"`` (2.4) — a credential after a comma starts a run of credentials, and since 2.7 the hyphen standing alone parts it as a comma (suffix ``RN, CRNA``): 1.4 read first ``RN``, middle ``-``, last ``Steven Hardman``, suffix ``CRNA``. - ``"John van der Berg Prof."`` (2.4) — 1.4 read last ``van der Berg Prof.``. - ``"Liam Ó Murchú"`` (2.4) — the Irish particles ``Ó``, ``Ní`` and ``Ua`` join the surname: 1.4 read middle ``Ó``, last ``Murchú``. - ``"Herra Väinö Johansson"`` (2.4) — salutations in Finnish, Icelandic, Malay and other languages are titles: 1.4 read first ``Herra``, middle ``Väinö``. - ``"山田 タロウ"`` (2.4) — halfwidth katakana reads as katakana: 1.4 read first ``山田``, last ``タロウ``. - ``capitalize()`` on ``"john smith md"``, ``"john smith phd"`` and ``"JANE DOE MBA"`` (2.4) — 1.4 wrote ``M.D.``, ``Ph.D.`` and ``Mba``; credentials are now written the usual way (``MD``, ``PhD``, ``MBA``). - ``initials()`` on ``"john e smith"`` (2.4) — 1.4 gave ``j. s.``; a one-letter connective read as an initial now contributes one. - ``"Dr. Smith, John"`` (2.5) — a listed title before a family comma is a title: 1.4 read last ``Dr. Smith``. ``"Gov Smith"`` (2.5) — ``Gov`` is a title: 1.4 read first ``Gov``. - ``"Antonio Pavlović mlađi"`` (2.5) — junior and senior in 45 languages are suffixes: 1.4 read middle ``Pavlović``, last ``mlađi``. - ``"John Smith II, AD"`` (2.5) — a credential after a generational suffix and a comma: 1.4 read first ``AD``, last ``John Smith``. - ``"John III, Sir"`` and ``"Smith, Sir"`` (2.5) — the name before a comma and a given-name title is the given name: 1.4 read last ``John`` and ``Smith``. - ``"Ivoi, Paul d'"`` (2.5) — a particle after the given name joins the surname: 1.4 read middle ``d'``. - ``"jose j y lopez"`` (2.5) — a lone letter in a one-case name reads as in capitals: 1.4 read last ``j y lopez``. - ``"Bapak Dwi Putro"`` (2.5) — the salutations ``bapak`` and ``owura`` are titles: 1.4 read first ``Bapak``, middle ``Dwi``. - ``capitalize()`` on ``"jacobus henricus van 't hoff"`` (2.5) — 1.4 wrote ``van 'T Hoff``. - ``"Maria de Sa"`` and ``"John Smith SA"`` (2.6) — ``Sa`` is no longer a credential: 1.4 read last ``de``, suffix ``Sa``, and suffix ``SA``. - ``"Cllr John Smith"`` and ``"Dott.ssa Anna Bianchi"`` (2.6) — ``Cllr`` and the Italian abbreviated salutations are titles: 1.4 read first ``Cllr`` and ``Dott.ssa``, middle ``John`` and ``Anna``. - ``"José Silva Júnior"`` (2.6) — 1.4 read middle ``Silva``, last ``Júnior``. - ``"John Smith, MD; PhD"`` (2.6) — a semicolon between post-nominals reads as a comma: 1.4 read first ``MD;``, last ``John Smith``. - ``"Mr Md Karim Uddin"`` and ``"Wm Smith"`` (2.6) — ``Md`` and ``Wm`` are the given names they abbreviate: 1.4 read title ``Mr Md`` and ``Wm``. - ``"Doe, Jane Mrs"`` (2.6) — a salutation ending the given name after a family comma is the title: 1.4 read middle ``Mrs``. - A zero-width space or byte-order mark (2.6) divides words as a space does: 1.4 kept it in the word beside it, so ``"\u200bDr. John Smith"`` read first ``Dr.``. - ``capitalize()`` on ``"j.r.r. tolkien"`` (2.6) — 1.4 wrote ``J.r.r.``; repair now capitalizes after a full stop or an ayn. - ``"King Curtis"``, ``"Prince Fielder"``, ``"Sultan Khan"`` and ``"Sheikh Ahmed Khan"`` (2.7) — a title that is also a given name is a title only inside a title phrase or behind a style (``"His Majesty King Henry"``), so a bare one opening the name is the first name: 1.4 read title ``King``, ``Prince``, ``Sultan`` and ``Sheikh``. The same holds for ``Princess``, ``Sultana``, ``Shaikh``, ``Sheik``, ``Shekh``, ``Shaik``, ``Cheikh``, ``Mahdi``, ``Bard``, ``Buddha``, ``Siddha``, ``Gyani`` and ``Wing``, and for a lone qualifier such as ``Grand``. ``Constants().titles`` still lists these words, so adding one there changes nothing; the 2.0 API's ``Lexicon.default().add(titles={"king"})`` makes one a title everywhere. - ``"Chief Justice John Roberts"`` and ``"Major General John Smith"`` (2.7) — a title phrase is one title: 1.4 read title ``Chief``, first ``Justice``, and first ``Major``, middle ``General John``. - ``"Chas. Smith"`` and ``"Ma. Cristina Reyes"`` (2.7) — a given name written short is the first name: 1.4 read title ``Chas.`` and ``Ma.``. - ``"John Smith VI MD"`` and ``"Smith, John VI"`` (2.7) — the numerals ``VI`` to ``X`` are suffixes as ``III`` is: 1.4 read last ``VI``, suffix ``MD``, and middle ``VI``. - ``"John Smith, MD/PhD"`` and ``"John Smith, Pharm D"`` (2.7) — a credential joined by a hyphen or slash, or split by a space, after a comma is a suffix: 1.4 read first ``MD/PhD``, and first ``Pharm``, middle ``D``, each with last ``John Smith``. - ``"John Smith Ph. D. Jones"`` (2.7) — 1.4 read last ``Jones``, suffix ``Ph. D.``. - ``"Smith, John MA Jones"`` (2.7) — a listed credential in capitals in a mixed-case name starts the suffix: 1.4 read middle ``Jones``, suffix ``MA``. - ``"Pedro Alves Filho"`` and ``"Alexandre Dumas fils"`` (2.7) — the Brazilian agnomens and ``fils`` are suffixes where the name has a word to spare: 1.4 read middle ``Alves`` and ``Dumas``, last ``Filho`` and ``fils``. - ``"Andrew Perkins (JD)"`` (2.7) — a bracketed credential its writing settles is a suffix: 1.4 read nickname ``JD``. - ``"Jane Smith, Dr. / MD"`` (2.7) — a hyphen or slash standing alone between post-nominals reads as a comma: 1.4 read first ``/``, last ``Jane Smith``. - ``"Marie Bon Durand"`` and ``"Herren Hans"`` (2.7) — ``Bon``, ``Dí`` and ``Heer`` are no longer particles, nor ``Herren`` and ``Heren`` titles: 1.4 read last ``Bon Durand`` and title ``Herren``. - ``"Marquis Johnson"`` and ``"Johnson, Marquise"`` (2.8) — ``Marquis`` and ``Marquise`` are titles only inside a title phrase, as ``King`` is since 2.7 (``"Marquis of Queensberry"`` keeps the title): 1.4 read title ``Marquis`` and ``Marquise``. - ``"Deacon Jones"``, ``"Burgess Meredith"`` and ``"Ranger Smith"`` (2.8) — ``Deacon``, ``Burgess`` and ``Ranger`` are titles only inside a title phrase, as ``King`` is since 2.7 (``"Deacon of the Church Jane Smith"`` keeps the title): 1.4 read title ``Deacon``, ``Burgess`` and ``Ranger``. - ``"Queen Latifah"`` and ``"Elizabeth II, Queen"`` (2.8) — ``Queen`` is a title only inside a title phrase or behind a style (``"Her Majesty Queen Elizabeth"``), as ``King`` is since 2.7: 1.4 read title ``Queen``, first ``Latifah``, and last ``Elizabeth``. - ``"Prince Consort Albert"``, ``"Princess Royal Anne"`` and ``"Bales, Royal"`` (2.8) — the royal title phrases are one title, and ``Royal`` after a family comma is the first name: 1.4 read title ``Prince``, first ``Consort``; title ``Princess Royal``, last ``Anne``; and title ``Royal``, first ``Bales``. - ``"Chancellor Smith"`` and ``"Lord Justice Leveson"`` (2.8) — ``Chancellor`` is a title only inside a title phrase (``"Lord Chancellor John Smith"``), and ``Lord Justice`` and ``Lady Justice`` are one title: 1.4 read title ``Chancellor``, and title ``Lord``, first ``Justice``. .. doctest:: >>> HumanName("Jack MA").suffix 'MA' >>> HumanName("Her Majesty Queen Elizabeth").first 'Elizabeth' >>> HumanName("His Royal Highness Prince Harry").first 'Harry' >>> king = HumanName("Dr King Jr") >>> king.title, king.last, king.suffix ('Dr', 'King', 'Jr') >>> HumanName("John Smith Prof.").title 'Prof.' >>> HumanName("jose e maria santos").middle 'e maria' >>> HumanName("van der Berg, PhD").last 'van der Berg' >>> HumanName("Josep Carod i Rovira").last 'Carod i Rovira' >>> HumanName("Jane Smith N.C.C.").suffix 'N.C.C.' >>> HumanName("Jane Smith, LEED AP").suffix 'LEED AP' >>> holder = HumanName("Eric H. Holder Jr. Attorney General") >>> holder.title, holder.last, holder.suffix ('Attorney General', 'Holder', 'Jr.') >>> HumanName("Eric H. Holder, Jr., Attorney General").title 'Attorney General' >>> HumanName("Steven Hardman, RN - CRNA").suffix 'RN, CRNA' >>> HumanName("John van der Berg Prof.").last 'van der Berg' >>> HumanName("Liam Ó Murchú").last 'Ó Murchú' >>> HumanName("Herra Väinö Johansson").title 'Herra' >>> HumanName("山田 タロウ").last '山田' >>> md = HumanName("john smith md") >>> md.capitalize(force=True) >>> str(md) 'John Smith MD' >>> phd = HumanName("john smith phd") >>> phd.capitalize() >>> str(phd) 'John Smith PhD' >>> mba = HumanName("JANE DOE MBA") >>> mba.capitalize() >>> str(mba) 'Jane Doe MBA' >>> HumanName("john e smith").initials() 'j. e. s.' >>> dr = HumanName("Dr. Smith, John") >>> dr.title, dr.first, dr.last ('Dr.', 'John', 'Smith') >>> HumanName("Gov Smith").title 'Gov' >>> HumanName("Antonio Pavlović mlađi").suffix 'mlađi' >>> HumanName("John Smith II, AD").suffix 'II, AD' >>> HumanName("John III, Sir").first 'John' >>> HumanName("Smith, Sir").first 'Smith' >>> HumanName("Ivoi, Paul d'").last "d' Ivoi" >>> HumanName("jose j y lopez").middle 'j y' >>> HumanName("Bapak Dwi Putro").title 'Bapak' >>> vant = HumanName("jacobus henricus van 't hoff") >>> vant.capitalize(force=True) >>> str(vant) "Jacobus Henricus van 't Hoff" >>> HumanName("Maria de Sa").last 'de Sa' >>> HumanName("John Smith SA").last 'SA' >>> HumanName("Cllr John Smith").title 'Cllr' >>> HumanName("Dott.ssa Anna Bianchi").title 'Dott.ssa' >>> HumanName("José Silva Júnior").suffix 'Júnior' >>> HumanName("John Smith, MD; PhD").suffix 'MD; PhD' >>> md_karim = HumanName("Mr Md Karim Uddin") >>> md_karim.title, md_karim.first ('Mr', 'Md') >>> HumanName("Wm Smith").first 'Wm' >>> HumanName("Doe, Jane Mrs").title 'Mrs' >>> HumanName("\u200bDr. John Smith").title 'Dr.' >>> tolkien = HumanName("j.r.r. tolkien") >>> tolkien.capitalize() >>> str(tolkien) 'J.R.R. Tolkien' >>> [HumanName(n).first for n in ("King Curtis", "Prince Fielder", ... "Princess Diana", "Sultan Khan", "Sultana Begum", ... "Sheikh Ahmed Khan", "Shaikh Ahmed Khan", "Sheik Ahmed", ... "Shekh Ahmed", "Shaik Ahmed", "Cheikh Anta Diop", ... "Mahdi Karimi", "Bard Larsen", "Buddha Bahadur Tamang", ... "Siddha Raj Joshi", "Gyani Maya Gurung", "Wing Lau")] ... # doctest: +NORMALIZE_WHITESPACE ['King', 'Prince', 'Princess', 'Sultan', 'Sultana', 'Sheikh', 'Shaikh', 'Sheik', 'Shekh', 'Shaik', 'Cheikh', 'Mahdi', 'Bard', 'Buddha', 'Siddha', 'Gyani', 'Wing'] >>> HumanName("His Majesty King Henry").title 'His Majesty King' >>> HumanName("Chief Justice John Roberts").title 'Chief Justice' >>> HumanName("Major General John Smith").title 'Major General' >>> HumanName("Chas. Smith").first, HumanName("Ma. Cristina Reyes").first ('Chas.', 'Ma.') >>> HumanName("John Smith VI MD").suffix, HumanName("Smith, John VI").suffix ('VI MD', 'VI') >>> HumanName("John Smith, MD/PhD").suffix 'MD/PhD' >>> HumanName("John Smith, Pharm D").suffix 'Pharm D' >>> HumanName("John Smith Ph. D. Jones").suffix 'Ph. D. Jones' >>> HumanName("Smith, John MA Jones").suffix 'MA Jones' >>> HumanName("Pedro Alves Filho").suffix 'Filho' >>> HumanName("Alexandre Dumas fils").last 'Dumas' >>> HumanName("Andrew Perkins (JD)").suffix 'JD' >>> HumanName("Jane Smith, Dr. / MD").first 'Jane' >>> HumanName("Marie Bon Durand").last 'Durand' >>> HumanName("Herren Hans").first 'Herren' >>> HumanName("Marquis Johnson").first, HumanName("Johnson, Marquise").first ('Marquis', 'Marquise') >>> HumanName("Marquis of Queensberry").title 'Marquis of Queensberry' >>> [HumanName(n).first for n in ("Deacon Jones", "Burgess Meredith", ... "Ranger Smith")] ['Deacon', 'Burgess', 'Ranger'] >>> HumanName("Deacon of the Church Jane Smith").title 'Deacon of the Church' >>> HumanName("Queen Latifah").first, HumanName("Elizabeth II, Queen").first ('Queen', 'Queen') >>> consort = HumanName("Prince Consort Albert") >>> consort.title, consort.first ('Prince Consort', 'Albert') >>> HumanName("Princess Royal Anne").first, HumanName("Bales, Royal").first ('Anne', 'Royal') >>> HumanName("Chancellor Smith").first 'Chancellor' >>> HumanName("Lord Chancellor John Smith").title 'Lord Chancellor' >>> HumanName("Lord Justice Leveson").title 'Lord Justice' derek73-python-nameparser-456b024/docs/modules.rst000066400000000000000000000203741527303271700220470ustar00rootroot00000000000000API reference ============= The 2.0 API ----------- Parsing ~~~~~~~ .. autofunction:: nameparser.parse .. autoclass:: nameparser.Parser :members: .. autofunction:: nameparser.parser_for .. autoclass:: nameparser.Segmentation :members: .. py:data:: nameparser.Segmenter :value: Callable[[str], Segmentation | None] The type of the optional ``Parser(segmenter=...)`` hook: a callable given one token's text, returning a :class:`~nameparser.Segmentation` that divides it, or ``None`` to decline and leave it whole. An alias, not a class — any callable of that shape qualifies, and nothing needs to be subclassed or registered. It is consulted only for tokens whose script is listed in :attr:`Policy.segment_scripts `, and only where the surname vocabulary declined first; see :ref:`segmenter-contract` for what a segmenter owes its caller. :func:`~nameparser.locales.ja_segmenter` is the shipped implementation. Results ~~~~~~~ .. autoclass:: nameparser.ParsedName :members: .. autoclass:: nameparser.Token :members: .. py:data:: nameparser.STABLE_TAGS :value: frozenset({"particle", "conjunction", "initial", "joined"}) The four :attr:`Token.tags ` values that are stable API: ``particle`` (a word from the particle vocabulary, "de"/"van", wherever it lands — combine with ``Role.FAMILY`` for actual family particles), ``conjunction`` (a joining word, "and"/"y"), ``initial`` (a word READ as an initial — initial-shaped in a script that HAS initials, "J." or "А.", never "씨.", a marked single-letter connective in a name written in one case, rules/P8, or any other bare letter in a name written in one case where its capital would be one, "j" as "J", rules/T4), and ``joined`` (a continuation of the token before it — within one merged piece the tag is role-blind and every view joins the pair with a space, so it renders "Ph. D." as one piece in whatever role it lands — ``Ph. D., Jr.`` gives ``last_list`` ``['Ph. D.']`` — and since #436 it also spans the pieces of one SUFFIX entry, the run of post-nominals the writer wrote without a comma — "MD PhD" — which is the half the suffix view alone reads and joins with a space instead of ", "). Every other tag is namespaced (``vocab:...``) and unstable — never match against those. .. autoclass:: nameparser.Span :members: .. autoclass:: nameparser.Role :members: .. autoclass:: nameparser.Ambiguity :members: .. autoclass:: nameparser.AmbiguityKind :members: .. autoclass:: nameparser.Reading :members: .. autoclass:: nameparser.ReadingKind :members: .. autoclass:: nameparser.ReadingSource :members: Configuration ~~~~~~~~~~~~~ .. autoclass:: nameparser.Lexicon :members: .. autoclass:: nameparser.Policy :members: .. autoclass:: nameparser.PolicyPatch :members: .. py:data:: nameparser.UNSET Sentinel meaning "this patch does not set this field" — the default of every :class:`~nameparser.PolicyPatch` field, distinguishable from every real value including ``None`` and ``False``. You rarely need it: omit a field instead of passing it. Import it to test whether a patch sets a field (``patch.name_order is UNSET``) or to leave a field conditionally unset when building patches programmatically. .. autoclass:: nameparser.PatronymicRule :members: .. autoclass:: nameparser.CapsSuffixes :members: .. autoclass:: nameparser.Script :members: .. _name-order-constants: Name-order constants ^^^^^^^^^^^^^^^^^^^^ The three valid values for ``Policy(name_order=...)``. ``name_order`` is deliberately restricted to these exported constants — an arbitrary tuple of :class:`~nameparser.Role` members raises ``ValueError``, because only these three orders have defined assignment semantics. .. py:data:: nameparser.GIVEN_FIRST :value: (Role.GIVEN, Role.MIDDLE, Role.FAMILY) Western order (the default): the first word of positional input is the given name, the last is the family name, everything between is middle. .. py:data:: nameparser.FAMILY_FIRST :value: (Role.FAMILY, Role.GIVEN, Role.MIDDLE) Family name first, given name second, remaining words middle — e.g. Hungarian, or East Asian order. .. py:data:: nameparser.FAMILY_FIRST_GIVEN_LAST :value: (Role.FAMILY, Role.MIDDLE, Role.GIVEN) Family name first, given name *last*, the words between middle — e.g. Vietnamese full-name order. .. py:data:: nameparser.DEFAULT_SCRIPT_ORDERS :value: ((Script.HAN, FAMILY_FIRST), (Script.HANGUL, FAMILY_FIRST), (Script.HIRAGANA, FAMILY_FIRST)) The default :attr:`~nameparser.Policy.script_orders` table: a name written wholly in Han or Hangul reads family-first, and so does a Japanese name in kanji and kana other than katakana alone, which resolves to the ``HIRAGANA`` entry whichever of the two syllabaries it actually uses — that member is the license's carrier key, not a claim about the characters present. A name written wholly in katakana has no entry on purpose and stays positional. A matching entry in this table takes precedence over ``name_order``, including a ``name_order`` you set explicitly — ``name_order`` governs only the names no entry matches. The values are drawn from the same three constants above, and the same restriction applies. Build on it for additive customization — ``script_orders=(*DEFAULT_SCRIPT_ORDERS, (Script.HAN, GIVEN_FIRST))``, where a later entry for a script REPLACES an earlier one, so appending is how you override — and pass ``script_orders=()`` to opt out entirely and get the purely positional read back. Latin-script and mixed-script names are never affected either way. Delimiter defaults ^^^^^^^^^^^^^^^^^^ .. py:data:: nameparser.DEFAULT_NICKNAME_DELIMITERS :value: frozenset({("'", "'"), ('"', '"'), ("(", ")"), ("“", "”"), ("„", "“"), ("”", "”"), ("«", "»"), ("»", "«"), ("「", "」"), ("『", "』"), ("「", "」"), ("(", ")")}) The default :attr:`~nameparser.Policy.nickname_delimiters` set: straight quotes and parentheses plus the typographic conventions — smart quotes, German/Polish low-high quotes, Swedish right-right quotes, guillemets in both directions, CJK corner brackets, and fullwidth parentheses (#273), plus the halfwidth corner brackets ``「」`` that legacy JIS X 0201 data carries (#597). Curly *single* quotes are deliberately absent: U+2019 is the typographic apostrophe ("O’Connor"). Build on the constant for additive customizations, e.g. ``nickname_delimiters=DEFAULT_NICKNAME_DELIMITERS | {("⦅", "⦆")}``; to *reroute* a pair to ``maiden``, just list it in :attr:`~nameparser.Policy.maiden_delimiters` (it is dropped from the effective nickname set automatically). Locales ~~~~~~~ .. autoclass:: nameparser.Locale :members: .. automodule:: nameparser.locales :members: get, available .. autofunction:: nameparser.locales.ja_segmenter 1.x compatibility layer ------------------------ .. note:: ``HumanName`` and ``nameparser.config``'s ``Constants``, ``CONSTANTS`` and managers are the 1.x API, kept working through 2.x and removed in 3.0; since 2.8 constructing them, loading them from a pickle or changing the shared ``CONSTANTS`` emits a ``DeprecationWarning``. New code should use the 2.0 API above; see :doc:`migrate`. HumanName.parser ~~~~~~~~~~~~~~~~ .. py:module:: nameparser.parser .. py:class:: HumanName :noindex: .. autoclass:: HumanName :members: :special-members: __eq__, __init__ HumanName.config ~~~~~~~~~~~~~~~~ .. automodule:: nameparser.config :members: HumanName.config Defaults ~~~~~~~~~~~~~~~~~~~~~~~~~~ .. automodule:: nameparser.config.titles :members: .. automodule:: nameparser.config.suffixes :members: .. automodule:: nameparser.config.particles :members: .. automodule:: nameparser.config.bound_given_names :members: .. automodule:: nameparser.config.given_names :members: .. automodule:: nameparser.config.conjunctions :members: .. automodule:: nameparser.config.maiden_markers :members: .. automodule:: nameparser.config.surnames :members: .. automodule:: nameparser.config.capitalization :members: .. automodule:: nameparser.config.regexes :members: derek73-python-nameparser-456b024/docs/release_log.rst000066400000000000000000004565621527303271700226740ustar00rootroot00000000000000Release Log =========== * 2.8.2 - November 4, 2026 **Deprecations** - **Deprecate nameparser.config.surnames, which 3.0 makes private.** ``from nameparser.config.surnames import KOREAN_SURNAMES`` now emits a ``DeprecationWarning`` naming ``Lexicon.default().surnames``, which holds the same words in 2.8.2 and 3.0. :issue:`1007` * 2.8.1 - November 2, 2026 nameparser 2.8.1 adds warnings for three 3.0 changes 2.8.0 did not announce: writing a ``Lexicon`` set field, reading a vocabulary constant in ``nameparser.config``, and reading ``nameparser.config.regexes``. Reading a ``Lexicon`` field (``lexicon.titles``) does not warn, though 3.0 refuses that too. Code that runs with warnings as errors (``-W error``, or pytest's ``filterwarnings = error``) will fail where it does any of the three. **Deprecations** - **Deprecate configuring Lexicon through its set fields, which 3.0 makes internal.** ``Lexicon.default().add(titles={"dean"})`` now emits a ``DeprecationWarning`` naming the field and the migration guide's row for it; 3.0 refuses the call with a ``TypeError`` naming the readings that replace it. ``remove()``, ``Lexicon(...)`` and ``dataclasses.replace()`` with a set field warn too, and ``add()`` or ``remove()`` with ``surnames``. :issue:`754` - **Deprecate the vocabulary constants in nameparser.config, which 3.0 removes.** ``from nameparser.config.titles import TITLES`` now emits a ``DeprecationWarning`` naming ``Lexicon.default().words(kind="title")``, which lists every title in 3.0. ``nameparser.config.surnames`` stays. :issue:`1007` - **Deprecate nameparser.config.regexes, which 3.0 removes without a replacement.** Reading ``REGEXES`` now emits a ``DeprecationWarning``; the parser has not read it since 2.0. :issue:`1008` * 2.8.0 - October 28, 2026 **nameparser 2.8** is the last 2.x feature release. It warns about everything 3.0 removes, chiefly ``HumanName`` and the rest of the 1.x compatibility layer, so that code can move to ``parse()`` while 2.x still runs it. It also carries on 2.7's reading of titles that are also given names: a title only inside a title phrase. After 2.8 the 2.x line takes fixes only, and new work goes into 3.0. Highlights: - **HumanName and Constants warn that 3.0 removes them.** ``HumanName("Dr. Juan de la Vega")`` emits a ``DeprecationWarning``; ``parse("Dr. Juan de la Vega")`` reads the same name, with given ``Juan`` and family ``de la Vega``. - **More given names that are also titles read as names.** ``Queen Latifah`` and ``Marquis Johnson`` give given ``Queen`` and ``Marquis``, where 2.7 read both as titles; ``Her Majesty Queen Elizabeth`` and ``Marquis of Queensberry`` keep the title. - **Royal and judicial title phrases read as one title.** ``Prince Consort Albert`` gives title ``Prince Consort``, given ``Albert``, and ``Lord Justice Leveson`` title ``Lord Justice``, where 2.7 gave given ``Prince`` and given ``Justice``; ``Prince Fielder`` keeps the given name ``Prince``. **Deprecations** - **Deprecate HumanName, Constants and the rest of the 1.x compatibility layer, which 3.0 removes.** ``HumanName("Dr. Juan de la Vega")`` now emits a ``DeprecationWarning``; ``parse("Dr. Juan de la Vega")`` reads the same name, with given ``Juan`` and family ``de la Vega`` where ``HumanName`` has first and last. Constructing a ``Constants``, loading a pickled ``HumanName`` or ``Constants``, and reading ``nameparser.config.SetManager`` or ``nameparser.util.lc`` warn too, each naming its replacement; ``python -W error::DeprecationWarning`` finds every site except a read of the shared ``CONSTANTS``. :issue:`925` **Behavior Changes** - **Fix given names that are also titles, such as Queen, Marquis and Deacon, reading as titles.** ``Queen Latifah`` gives given ``Queen``, family ``Latifah``, where 1.4.0 through 2.7.0 gave title ``Queen``; ``Marquis``, ``Marquise``, ``Deacon``, ``Burgess`` and ``Ranger`` read the same, behind another title or after a family comma too (``The Rev. Deacon John Smith``, ``Smith, Queen``). A holder written with the bare title reads it as a given name too (``Queen Elizabeth II`` gives given ``Queen``, family ``Elizabeth``); ``of`` or a style keeps the title (``Marquis of Queensberry``, ``Her Majesty Queen Elizabeth``). ``Queen`` and ``Burgess`` opening the name or behind another title also report a ``title-or-name`` ambiguity (``Burgess Meredith``). :issue:`913` - **Fix the given name Chancellor reading as a title, and read Lord Justice as one title.** ``Chancellor Smith`` gives given ``Chancellor``, family ``Smith``, where 1.4.0 through 2.7.0 gave title ``Chancellor``; ``Smith, Chancellor`` reads the same. A bare ``Chancellor`` opening the name reports a ``title-or-name`` ambiguity (``Chancellor Merkel``). A modifier, ``of`` or ``Lord`` keeps the title (``Vice Chancellor Jane Smith``, ``Chancellor of the Exchequer``, ``Lord Chancellor John Smith``). ``Lord Justice Leveson`` and ``Lady Justice Arden`` give title ``Lord Justice`` and ``Lady Justice``, where 1.4.0 through 2.7.0 gave title ``Lord`` and ``Lady``, given ``Justice``. :issue:`913` - **Read the royal title phrases Prince Consort, Princess Royal and the other consorts as one title.** ``Prince Consort Albert`` gives title ``Prince Consort``, given ``Albert``, where 2.7.0 gave given ``Prince``, middle ``Consort``, and 1.4.0 through 2.6.0 title ``Prince``, given ``Consort``; the other consorts read the same, and ``Princess Royal Anne`` gives given ``Anne``, where 2.7.0 gave given ``Princess``. ``Royal`` after a family comma is the given name (``Bales, Royal``, where 1.4.0 through 2.7.0 gave title ``Royal``). A bare ``King``, ``Queen``, ``Prince``, ``Princess`` or ``Royal`` opening the name or behind another title now reports a ``title-or-name`` ambiguity (``Prince Fielder``, ``Dr. King``). :issue:`913` * 2.7.0 - October 27, 2026 **nameparser 2.7** is the first release working towards the new language and vocabulary model described in the `vocabulary model RFC `__. It introduces more nuanced handling of titles and particles, adjusts the default vocabulary to better align with example name corpora, and surfaces alternative name readings in the published API. Highlights: - **Titles that are also given names read as titles only inside a title phrase.** ``Chief Justice John Roberts`` gives title ``Chief Justice``, given ``John``, where 2.6 gave title ``Chief``, given ``Justice``; outside a phrase the word is a name, so ``Justice Smith`` gives given ``Justice``. - **Credentials joined by a hyphen or a slash, or split by a space, read as one credential.** ``John Smith, MD/PhD`` gives suffix ``MD/PhD``, where 2.6 gave given ``MD/PhD``, family ``John Smith``. - **Given names written short read as given names.** ``Chas. Smith`` and ``Ma. Cristina Reyes`` give the given name ``Chas.`` and ``Ma.``, where 2.6 read both as titles. - **An ambiguity report names the reading the parse did not take.** ``Ambiguity.alternative`` says what the word would have been and why. **Additions** - **Add Ambiguity.alternative, the reading a report's parse did not take.** ``parse("JOHN SMITH MA").ambiguities[0].alternative`` is ``Reading('name' as 'family', 'declared')``: read as a name, ``MA`` would be the family name. ``Reading``, ``ReadingKind`` and ``ReadingSource`` are exported from ``nameparser``; the field is not compared, and a ``ParsedName`` pickled by 2.6 still loads. :issue:`752` **Behavior Changes** - **Read a title that is also a given name as a title only inside a title phrase.** ``Chief Justice John Roberts`` gives title ``Chief Justice``, given ``John``, where 1.4.0 through 2.6.0 gave title ``Chief``, given ``Justice``. Outside a phrase the word is a name, and ``Dean Smith`` now reports a ``title-or-name`` ambiguity. ``Lexicon.default().add(titles={"dean"})`` still makes a word a title everywhere; for ``King`` and the other words 2.6.0 listed as titles, a v1 ``Constants`` cannot. :issue:`752`, :issue:`853` - **Fix given names that are also titles, such as King, Prince and Sheikh, reading as titles.** ``King Curtis`` gives given ``King``, family ``Curtis``, where 1.4.0 through 2.6.0 gave title ``King``; ``Prince``, ``Princess``, ``Sultan``, ``Sultana``, ``Sheikh`` and its spellings, ``Mahdi``, ``Bard``, ``Wing``, ``Buddha``, ``Siddha`` and ``Gyani`` read the same, and so does a lone ``Vice`` or ``Grand`` opening the name. A style or a title phrase keeps the title (``His Majesty King Henry``, ``Prince of Wales``), and after a full name and a comma a bare given name among them reverses the name, as ``Dean`` does (``John Smith, Wing`` gives given ``Wing``). :issue:`752`, :issue:`348`, :issue:`853`, :issue:`867`, :issue:`869` - **Read Chas., Wm. and the other given names written short as given names rather than titles.** ``Chas. Smith`` and ``Ma. Cristina Reyes`` give the given name ``Chas.`` and ``Ma.``, where 1.4.0 through 2.6.0 gave title ``Chas.`` and ``Ma.``. An unlisted abbreviation in front is still a title (``Insp. Jane Morse``); ``Lexicon.given_name_abbreviations`` lists the words. :issue:`789`, :issue:`752` - **Fix Bon, Dí and Heer joining the next word into the family name, and Herren and Heren reading as titles.** ``Marie Bon Durand`` gives middle ``Bon``, family ``Durand``, where 1.4.0 through 2.6.0 gave family ``Bon Durand``, and ``Herren Hans`` gives given ``Herren``, where they gave title ``Herren``. :issue:`766` - **Read a credential joined by a hyphen or a slash as a suffix.** ``John Smith, MD/PhD`` gives given ``John``, family ``Smith``, suffix ``MD/PhD``, where 1.4.0 through 2.6.0 gave given ``MD/PhD``, family ``John Smith``. Each part must be a listed credential that is not also a name, so ``Smith-Jones`` is untouched and ``John Smith, MA/PhD`` still reverses; an unlisted one in capitals after a comma behind a mixed-case name reads as a credential too (``John Smith, PU-PH``), and so can a hyphenated name written in capitals (``KIM, AE-RA`` gives suffix ``AE-RA``). :issue:`752`, :issue:`797` - **Read a credential written with a space inside it, such as Pharm D, as one credential.** ``John Smith, Pharm D`` gives given ``John``, family ``Smith``, suffix ``Pharm D``, where 1.4.0 through 2.6.0 gave given ``Pharm``, middle ``D``, family ``John Smith``. It counts only where the name has words to spare, so ``Smith, Pharm D`` still gives given ``Pharm``. :issue:`752` - **Read a credential that is also a name, written in capitals in a mixed-case name, as settled.** ``Smith, John MA Jones`` gives suffix ``MA Jones``, where 2.0.0 through 2.6.0 gave middle ``MA Jones``, and ``John Smith MA`` no longer reports a ``suffix-or-name`` ambiguity. A name written in one case still reports (``JOHN SMITH MA``). :issue:`785`, :issue:`752` - **Read a bracketed credential that is also a name as a suffix where its writing settles it.** ``Andrew Perkins (JD)`` gives suffix ``JD``, where 1.4.0 through 2.6.0 gave nickname ``JD``, and ``John Smith (MA)`` gives suffix ``MA`` again, as 1.4.0 did. Only capitals in a mixed-case name or a period inside the word settle it, so ``John Smith (Ed)`` and ``JOHN SMITH (MA)`` keep the nickname and report a ``suffix-or-nickname`` ambiguity. :issue:`810`, :issue:`752` - **Read a hyphen or a slash standing alone between post-nominals as a comma.** ``Jane Smith, Dr. / MD`` gives given ``Jane``, family ``Smith``, where 1.4.0 through 2.6.0 gave given ``/``, family ``Jane Smith``. It counts only after a full name and its comma, or after a second comma, and only with a space on each side, so ``Smith - Jones, John`` and ``John Smith, MD- PhD`` read as before. :issue:`889`, :issue:`156` - **Read the Brazilian agnomens and French fils as suffixes where the name has a word to spare.** ``Pedro Alves Filho`` gives family ``Alves``, suffix ``Filho``, where 1.4.0 through 2.6.0 gave middle ``Alves``, family ``Filho``; ``fils``, ``Neto``, ``Sobrinho``, their feminines and the abbreviations Brazilian filings write for them (``FH``, ``NT``) read the same. Each is a surname too, so the parse reports a ``suffix-or-name`` ambiguity, and a name of two words keeps it as the family name (``Ana Neto``). :issue:`843`, :issue:`752` - **Fix roman numerals VI to X reading as names where III reads as a suffix.** ``John Smith VI MD`` gives suffix ``VI MD`` and ``Smith, John VI`` suffix ``VI``, as ``III`` does, where 1.4.0 through 2.6.0 gave family ``VI`` and middle ``VI``. A one-letter numeral before a credential is still a name (``John Smith V PhD``), and so is a Title-case ``Vi`` in a mixed-case name (``Nguyen Van Vi`` gives family ``Van Vi``, where 2.2.0 through 2.6.0 gave suffix ``Vi``). :issue:`784`, :issue:`774` - **Fix a lone letter before a further comma reading as part of a credential.** ``Smith, John PhD I, Jr.`` gives middle ``I``, suffix ``PhD, Jr.``, as 1.4.0 through 2.3.0 did, where 2.4.0 through 2.6.0 gave suffix ``PhD I, Jr.``. :issue:`819` - **Fix a split Ph. D. in the middle of a name stopping short of the words after it.** ``John Smith Ph. D. Jones`` gives suffix ``Ph. D. Jones``, as ``John Smith PhD Jones`` does, where 1.4.0 through 2.6.0 gave family ``Jones``, suffix ``Ph. D.``; ``John Smith V Ph. D.`` gives family ``V``, as ``John Smith V PhD`` does, where 1.4.0 through 2.6.0 read ``V`` as a suffix. :issue:`827` - **A Lexicon, Parser or locale pack pickled by an earlier release no longer loads.** Loading one raises ``ValueError`` naming the new fields (``suffix_words_ambiguous``, ``agnomens``, ``titles_ambiguous``, ``title_modifiers``, ``title_styles``, ``title_phrases``, ``given_name_abbreviations``); re-pickle after upgrading. ``ParsedName`` and ``HumanName`` pickles still load. :issue:`752` * 2.6.0 - October 25, 2026 nameparser 2.6 ships the default vocabulary as per-word data files, adds an opt-in reading for names with two family names, and fixes words read as a credential or a title that are part of the name. Highlights: - **An opt-in reading for names with two family names.** ``Policy(two_family_names=True)`` reads ``Gabriel García Márquez`` with family ``García Márquez``. - **Surnames that are also credentials read as surnames.** ``Luo Si`` and ``Maria de Sa`` give family ``Si`` and ``de Sa``, where 2.5 read ``Si`` and ``Sa`` as credentials. - **Wm and Md read as the given names they abbreviate.** ``Wm Smith`` and ``Mr Md Karim Uddin`` give first ``Wm`` and ``Md``, where 2.5 read both as titles. - **More credentials after a comma read as suffixes.** ``John Smith, MBChB`` gives suffix ``MBChB``, where 2.5 gave first ``MBChB``, last ``John Smith``. **Additions** - **Ship the default vocabulary as per-word data files.** The word lists in ``nameparser.config`` are now compiled from ``nameparser/vocab/*.toml``, one file per language, which record what each word can be read as and why a word was left out; the re-encoding itself changes no word list and no parse. :issue:`737` - **Add a switch for names with two family names.** ``Parser(policy=Policy(two_family_names=True))`` reads ``Gabriel García Márquez`` as given ``Gabriel``, family ``García Márquez``, where the default reads middle ``García``, family ``Márquez``. Off by default: under it a name with one family name behind two given names loses one to the family (``Juan Carlos Pérez`` gives family ``Carlos Pérez``). :issue:`754` **Behavior Changes** - **Fix credentials that are also surnames reading as credentials.** ``Luo Si`` and ``Robert Capa`` give a family name, where 2.0.0 through 2.5.0 gave suffix ``Si`` or ``Capa`` with no family name. A Title-case word after a full name takes the family name, as ``Ma`` does; the all-caps form stays a credential (``John Smith CAPA`` keeps suffix ``CAPA``). :issue:`791`, :issue:`796` - **Fix Sa reading as a credential or a title.** ``Maria de Sa`` and ``MARIA DE SA`` give family ``de Sa`` and ``DE SA``, and ``Joao Sa`` gives family ``Sa``, where 1.4.0 through 2.5.0 gave suffix ``Sa`` (``Joao Sa``: 2.0.0 through 2.5.0); ``Mrs Sa Li`` gives given ``Sa``, where it read ``Sa`` as a second title. ``SA`` is no longer a credential: ``John Smith SA`` gives family ``SA`` and ``John Smith, SA`` gives given ``SA``, family ``John Smith``, where 1.4.0 through 2.5.0 gave suffix ``SA``; a caller who needs it adds it to their own lexicon. :issue:`857` - **Fix more credentials after a comma reading as the given name.** ``John Smith, MBChB`` and ``John Smith, OTR/L`` give suffix ``MBChB`` and ``OTR/L``, where 2.0.0 through 2.5.0 gave given ``MBChB`` and family ``John Smith``; 37 degrees and certifications are listed, and ``PA`` and ``Ing`` are marked ambiguous (``John Pa`` and ``John Ing`` keep the family name). ``Ing.`` after a full name and a comma gives suffix ``Ing.``, where 2.5.0 gave title ``Ing.``. An unlisted hyphen- or slash-joined credential still reverses. :issue:`797`, :issue:`848` - **Fix case repair writing a joined credential like a hyphenated name.** ``john smith, kchs/dchs`` repairs to ``John Smith KCHS/DCHS``, where 2.0.0 through 2.5.0 gave ``Kchs/Dchs``; a listed acronym joined by a hyphen or slash is written in capitals as one word, and a casing the caller records for it wins. Hyphenated names such as ``Smith-Jones`` keep their per-part casing. :issue:`808` - **Add Cllr as a title.** ``Cllr John Smith`` gives title ``Cllr``, first ``John``, last ``Smith``, where 1.4.0 through 2.5.0 gave first ``Cllr``, middle ``John``. A bare ``Cllr`` after the name is still a name word. :issue:`847` - **Fix case repair lowering a capital after a full stop or an ayn.** ``j.r.r. tolkien`` repairs to ``J.R.R. Tolkien`` and ``AHMAD IBN-ʿALĪ`` to ``Ahmad ibn-ʿAlī``, where 1.4.0 through 2.5.0 gave ``J.r.r.`` and ``ʿalī``. A joined word is written the same way (``st.john`` gives ``St.John``), so an unlisted abbreviation's ending is capitalized too: ``dr.philol.`` gives ``Dr.Philol.``. :issue:`838` - **Add the Italian abbreviated salutations such as Dott.ssa and Sig.na as titles.** ``Dott.ssa Anna Bianchi`` gives title ``Dott.ssa``, first ``Anna``, where 1.4.0 through 2.5.0 gave first ``Dott.ssa``, middle ``Anna``. A bare ``Sig`` or ``Ing`` before a name is still read as a first name. :issue:`840` - **Add Júnior as a suffix.** ``José Silva Júnior`` gives last ``Silva``, suffix ``Júnior``, as ``José Silva Junior`` does, where 1.4.0 through 2.5.0 gave middle ``Silva``, last ``Júnior``. ``Filho``, ``Neto`` and ``Sobrinho`` still read as the last name. :issue:`843` - **Read a semicolon between post-nominals as a comma.** ``John Smith, MD; PhD`` gives first ``John``, last ``Smith``, suffix ``MD; PhD``, where 1.4.0 through 2.5.0 gave first ``MD;``, last ``John Smith``. A delimiter set in ``extra_suffix_delimiters`` counts the same way when glued to a suffix word or written between two (``MD/PhD``), and a semicolon before the first comma (``John Smith MD; PhD``) is still read as part of a word. :issue:`871` - **Read a leading Md as the given name it abbreviates, Muhammad, rather than a title.** ``Mr Md Karim Uddin`` gives title ``Mr``, first ``Md``, middle ``Karim``, where 1.4.0 through 2.5.0 gave title ``Mr Md``, first ``Karim``; ``John Smith, MD Jones`` gives last ``Smith``, suffix ``MD Jones`` where it gave title ``MD``, last ``John Smith``. ``MD`` after a name is still the degree, and ``Md.`` with a full stop is still a title. One limit: ``Md`` opening the part after a family comma reads as a credential, so ``Uddin, Md Karim`` gives suffix ``Md Karim`` where it gave title ``Md``, first ``Karim``. :issue:`853` - **Read Wm as the given name it abbreviates, William, rather than a title.** ``Wm Smith`` gives first ``Wm``, last ``Smith``, where 1.4.0 through 2.5.0 gave title ``Wm`` with no first name. ``Wm.`` with a full stop opening the name is still a title. :issue:`789` - **Fix a surname written as two dotted initials reading as a credential.** ``Ram Bahadur B.K.`` gives middle ``Bahadur``, last ``B.K.``, where 2.4.0 through 2.5.0 gave last ``Bahadur``, suffix ``B.K.``, restoring 2.3.0's reading, and ``Hari G.C.`` gives last ``G.C.``, where 2.0.0 through 2.5.0 gave suffix ``G.C.`` with no last name. Three or more dotted letters after a full name are still a credential (``John Smith X.Y.Z.``), and so is a listed one (``John Smith M.A.``). :issue:`864` - **Read a salutation ending the first name after a family comma as the title.** ``Doe, Jane Mrs`` gives title ``Mrs``, first ``Jane``, last ``Doe``, where 1.4.0 through 2.5.0 gave middle ``Mrs``; ``Doe, Jane Mrs.`` has read as the title since 2.3.0. A salutation also borne as a name, such as ``Goodman`` or ``Herr``, is still read as a middle name, and so is any other title, such as ``King`` or ``Dr``; ``Lexicon.after_given_titles`` lists the salutations. :issue:`835` - **A Lexicon, Policy or Parser pickled by an earlier release no longer loads.** Loading one raises ``ValueError`` naming the new field (``after_given_titles`` or ``two_family_names``); re-pickle after upgrading. ``ParsedName`` and ``HumanName`` pickles still load. :issue:`754`, :issue:`835` - **Fix a zero-width space or byte-order mark sticking to the word beside it.** ``Dr. John Smith`` with an invisible zero-width space in front gives title ``Dr.``, first ``John``, where 1.4.0 through 2.5.0 gave first ``Dr.`` (the character kept), middle ``John``. The two now divide the words of a name as a space does, with no setting to turn that off, though not inside quotes or a ``(née ...)`` clause; the zero-width joiner and non-joiner still stay inside the word. :issue:`851` * 2.5.0 - October 19, 2026 nameparser 2.5 reads more of the shapes real listings write. Highlights: - **Junior and senior in 45 languages.** ``Antonio Pavlović mlađi`` gives suffix ``mlađi``, where 2.4 read it as the last name. - **A title before a family comma is a title.** ``Dr. Smith, John`` gives title ``Dr.``, last ``Smith``, where 2.4 gave last ``Dr. Smith``. - **A lone first name followed by V, I or X keeps its last name again.** ``Malcolm X`` gives last ``X``, as 1.4.0 did. - **Chinese and Japanese credential and office words.** ``田中社長`` gives family ``田中``, suffix ``社長``, and ``locales.ZH`` knows more surnames, compound ones included. **Behavior Changes** - **Add junior and senior in other languages as suffixes.** ``Antonio Pavlović mlađi`` gives last ``Pavlović``, suffix ``mlađi``, where 1.4.0 through 2.4.2 gave middle ``Pavlović``, last ``mlađi``. The words come from 45 languages and are read when written as a separate word after the name. :issue:`661`, :issue:`679` - **Fix a title written before a family comma reading as part of the family name.** ``Dr. Smith, John`` gives title ``Dr.``, last ``Smith``, where 1.4.0 through 2.4.2 gave last ``Dr. Smith``. Only listed titles are read there, so ``Xyz. Smith, John`` keeps its family, and a title that is also a surname particle, such as ``St`` or ``Freiherr``, stays in the surname. :issue:`701` - **Add Gov as a title.** ``Gov Smith`` gives title ``Gov``, last ``Smith``, where 1.4.0 through 2.4.2 gave first ``Gov``, and ``Lt. Gov. Smith, John`` gives title ``Lt. Gov.`` with the change above. :issue:`701` - **Fix a capital V, I or X after a lone first name being read as a generational suffix, leaving no last name.** ``John V`` gives first ``John``, last ``V``, and ``Malcolm X`` last ``X``, where 2.0.0 through 2.4.2 gave suffix ``V`` and ``X`` with no last name, restoring 1.4.0's reading. A title is no word to spare, so ``Dr. Smith V`` gives first ``Smith``, last ``V`` (1.4.0's reading too) where 2.0.0 through 2.4.2 gave last ``Smith``, suffix ``V``; a name with a word to spare keeps the suffix, and ``John Smith V`` still gives suffix ``V``. A name written all in lower case reads the same way (``john v`` gives last ``v``). :issue:`718`, :issue:`721` - **Fix a credential after a generational suffix and a comma reversing the name.** ``John Smith II, AD`` gives first ``John``, last ``Smith``, suffix ``II, AD``, where 1.4.0 through 2.4.2 gave first ``AD``, last ``John Smith``. A given name after the comma keeps the last-name-first reading (``Garcia Lopez Jr., Juan``), and so does a name written all in capitals. :issue:`656` - **Fix a given-name title after a comma making the name before it the last name.** ``Elizabeth II, Queen`` gives first ``Elizabeth``, suffix ``II``, where 1.4.0 through 2.4.2 gave last ``Elizabeth``, as ``Queen Elizabeth II`` reads. A title that addresses by surname is unchanged (``John III, Dr.`` gives last ``John``), and ``Smith, Sir`` now gives first ``Smith``. Under a family-first ``name_order``, ``Sir John`` gives first ``John`` rather than last. :issue:`720` - **Fix a surname particle such as d' or al- reading as a middle name when a listing puts it after the given name.** ``Ivoi, Paul d'`` gives last ``d' Ivoi``, where 1.4.0 through 2.4.2 gave middle ``d'``. The Arabic article reads the same in its plain and assimilated spellings (``al-``, ``az-``, ``aš-``). A particle written joined to the surname, as in ``Paul d'Ivoi``, is unchanged. :issue:`700` - **Fix case repair capitalizing the Dutch particle 't.** ``jacobus henricus van 't hoff`` repairs to ``Jacobus Henricus van 't Hoff``, where 1.4.0 through 2.4.2 gave ``van 'T Hoff``. :issue:`700` - **Add Chinese and Japanese credential, office and generational words as suffixes.** ``彭德威 议员`` under ``locales.ZH`` gives family ``彭``, given ``德威``, suffix ``议员``, where 2.1 through 2.4.2 gave middle ``议员``; ``田中社長`` gives family ``田中``, suffix ``社長``, where 2.1 through 2.4.2 gave family ``田中社長``. Read when written as a separate word, after a comma, or glued to the end of the name, except that 部長 and 部长 are not split off a glued name (``田中部長`` is unchanged) unless ``locales.ZH`` divides it at a listed surname (``李部長``). :issue:`671`, :issue:`707` - **Extend the Chinese surname list in locales.ZH past the census top 100, and add the compound surnames it was missing.** ``柯洁`` under ``locales.ZH`` gives family ``柯``, given ``洁``, where 2.1.0 through 2.4.2 gave family ``柯洁``; ``钟离小凤`` gives family ``钟离`` where they gave ``钟``. A Japanese name read under ``locales.ZH`` splits after its first character more often (``安藤一郎`` gives family ``安``), so use ``locales.JA`` for Japanese names. :issue:`695`, :issue:`657` - **Remove mai from the default post-nominal acronyms, so a trailing Mai keeps the name.** ``Tran Thi Mai`` read in Vietnamese order (``FAMILY_FIRST_GIVEN_LAST``) gives given ``Mai``, middle ``Thi``, family ``Tran``, where 2.0.0 through 2.4.2 gave suffix ``Mai`` and no given name; ``Nguyen Mai`` gives family ``Mai``, as 1.4.0 did. The cost is that ``John Smith MAI`` gives last ``MAI`` instead of suffix; ``Smith, John, MAI`` and ``John Smith M.A.I.`` still read as credentials. A caller who needs it back adds it: ``Lexicon.default().add(suffix_acronyms={"mai"})``. See the ``suffix-acronym-collisions`` entry of ``docs/design/decisions/`` (:issue:`687`) - **Add the Akan and Indonesian salutations owura and bapak as titles.** ``Bapak Dwi Putro`` gives title ``Bapak``, first ``Dwi``, last ``Putro``, where 1.4.0 through 2.4.2 gave first ``Bapak``, middle ``Dwi``. :issue:`662` - **Fix a single letter in a name written all in lower case reading differently from the same name in capitals.** ``jose j y lopez`` gives first ``jose``, middle ``j y``, last ``lopez``, as ``JOSE J Y LOPEZ`` does, where 1.4.0 through 2.4.2 gave last ``j y lopez``. A lone letter before a maiden marker stays the first name (``j nee smith`` as ``J NEE SMITH``), ``田中さん v`` reads as ``田中さん V`` does, and ``Policy(lenient_comma_suffixes=False)`` reads ``john smith, v`` as it reads ``JOHN SMITH, V``. :issue:`723`, :issue:`727` * 2.4.2 - October 13, 2026 nameparser 2.4.2 fixes two readings: a name typed with decomposed accents or hangul, and the Hausa given name Shehu. **Behavior Changes** - **Fix a name typed with decomposed (NFD) accents or hangul reading differently from its composed spelling.** ``John й Smith`` typed with ``й`` as ``и`` plus a combining breve gives middle ``й``, where 2.3.0 through 2.4.1 read the whole name as the first name, restoring 1.4.0's reading. :issue:`665` - **Fix the Hausa given name Shehu being read as a title.** ``Shehu Shagari`` gives first ``Shehu``, last ``Shagari``, where 1.4.0 through 2.4.1 gave title ``Shehu`` and no first name. :issue:`660` * 2.4.1 - October 13, 2026 nameparser 2.4.1 fixes two readings: an accented abbreviation written in decomposed form, and a comma inside a nickname. **Behavior Changes** - **Fix a decomposed (NFD) accented letter ending its word early.** ``Dr. inž. Janez Novak`` gives title ``Dr. inž.`` when the ``ž`` is written as ``z`` plus a combining caron, where 1.4.0 and 2.0 through 2.4 made ``inž.`` the given name; ``Janez Novak, ŽŠČ`` likewise gives suffix ``ŽŠČ``, where 2.4 reversed it. :issue:`652` - **Fix a comma inside a nickname being dropped.** ``Jane (Janey, Jo) Smith`` gives nickname ``Janey, Jo``, where 2.0 through 2.4 gave ``Janey Jo``, restoring 1.4.0's reading. :issue:`653` * 2.4.0 - October 11, 2026 nameparser 2.4 reads post-nominal credentials much more reliably, recognizes more naming conventions, and writes credentials the usual way when it repairs case. Highlights: - **Capitals tell a credential from a surname.** ``Jack MA`` is Jack with an MA and ``Jack Ma`` is the surname Ma, where 2.3 read both as the surname. Credentials the word lists don't carry, such as ``LEED AP`` and ``N.C.C.``, are read too. - **Credentials after a name stay together.** ``Jane Smith, PhD MA`` gives suffix ``PhD MA``, where 2.3 read ``PhD`` as her first name, and ``Eric H. Holder Jr. Attorney General`` gives suffix ``Jr.`` and title ``Attorney General``. - **More naming conventions.** The Catalan surname link (``Josep Carod i Rovira``), the Irish particles (``Liam Ó Murchú``), salutations in Finnish, Icelandic, Malay and other languages (``Herra Väinö Johansson``), and Japanese names in halfwidth katakana (``山田 タロウ``). - **Case repair writes credentials the usual way.** ``john smith phd`` capitalizes to ``John Smith PhD`` and ``JANE DOE MBA`` to ``Jane Doe MBA``, where 2.3 gave ``Ph.D.`` and ``Mba``. - **More ambiguity reports, each naming the field its word ends up in.** - **Python 3.15 support.** **Behavior Changes** - **Fix a credential acronym that is also a surname being read by position alone.** Capitals now decide in a mixed-case name: ``Jack MA`` gives suffix ``MA`` while ``Jack Ma`` keeps last ``Ma``, where 2.0 through 2.3 read both as the last name. A name written wholly in one case keeps its old reading. See :ref:`credentials-also-surnames`. :issue:`289` - **New Policy field unlisted_dotted_suffixes, on by default: a dotted acronym nobody has listed is read by position.** ``Jane Smith N.C.C.`` now gives suffix ``N.C.C.``, where 2.3.0 gave last ``N.C.C.``, while a lone ``Jack X.Y.Z.`` keeps its surname. A dotted version string is no longer a suffix: ``John Smith 1.4.2`` gives last ``1.4.2``. See :ref:`dotted-credentials`. :issue:`516` - **New Policy field unlisted_caps_suffixes: an unlisted all-caps word after a comma reads as a credential by default, and elsewhere on request.** ``Jane Smith, LEED AP`` now gives suffix ``LEED AP``, where 2.3.0 gave first ``LEED``, middle ``AP``, last ``Jane Smith``. The cost is a given name written in capitals after a two-word surname: ``García Márquez, GABRIEL`` gives suffix ``GABRIEL``; ``CapsSuffixes.OFF`` keeps the old reading. See :ref:`all-caps-credentials`. :issue:`516`, :issue:`564` - **A credential after the name now starts a run of suffixes and titles to the end of its part, with or without a comma.** ``Eric H. Holder Jr. Attorney General`` gives title ``Attorney General``, suffix ``Jr.``, where 2.3.0 gave middle ``H. Holder Jr. Attorney``, last ``General``, and ``Steven Hardman, RN - CRNA`` gives suffix ``RN - CRNA``, where 2.3.0 read ``RN`` as the first name. :issue:`602`, :issue:`603`, :issue:`613` - **Fix a credential run after a comma losing the acronyms in it that are also names or particles.** ``Jane Smith, PhD MA`` gives suffix ``PhD MA``, where 2.3.0 gave first ``PhD``, middle ``MA``, last ``Jane Smith``. :issue:`544`, :issue:`562` - **Fix a credential ending the given part of a family-comma listing being read as a middle name.** ``Doe, John MA`` gives suffix ``MA``, where 2.0 through 2.3 gave middle ``MA``, restoring 1.4.0's reading; ``Doe, John Ma``, written the way a name is, keeps middle ``Ma``. :issue:`531` - **Fix a bare trailing Meng or Lac being read as a credential and losing the family name.** ``wang meng`` gives last ``meng`` and reports a suffix-or-name ambiguity, where 2.0.0 through 2.3.0 gave suffix ``meng`` and no last name. The cost falls on the degree spellings: ``John Smith MEng`` gives last ``MEng``. See :ref:`credentials-also-surnames`. :issue:`540` - **Fix a surname particle that is also a credential (vd, mc) being split away from the surname or the credentials around it.** ``SMITH VD MA, JOHN`` gives last ``SMITH VD MA``, where 2.2 and 2.3 gave last ``SMITH MA``, suffix ``VD``. :issue:`573` - **Fix a particle surname before a comma being split when a credential follows it.** ``van der Berg, PhD`` now gives last ``van der Berg``, suffix ``PhD``; 1.4.0 through 2.3.0 gave first ``van``, last ``der Berg``. :issue:`575` - **Fix a trailing title being taken into a surname that begins with a particle.** ``John van der Berg Prof.`` gives title ``Prof.``, last ``van der Berg``, where 1.4.0 through 2.3.0 took ``Prof.`` into the last name. See :ref:`titles at the end of a name `. :issue:`614`, :issue:`623`, :issue:`637` - **Change a title word in a part after a second comma to read as a title.** ``Eric H. Holder, Jr., Attorney General`` gives title ``Attorney General``, suffix ``Jr.``, where 1.4.0 through 2.3.0 gave suffix ``Jr., Attorney General``. :issue:`603`, :issue:`629`, :issue:`636`, :issue:`640` - **Add the Catalan and Polish surname link i.** ``Josep Carod i Rovira`` gives last ``Carod i Rovira``, where 1.4.0 through 2.3.0 gave middle ``Carod i``, last ``Rovira``. In a name written wholly in one case the letter reads as an initial and is reported, so ``JOSEP CAROD I ROVIRA`` keeps its old fields; remove ``i`` from ``Lexicon.conjunctions_ambiguous`` to join it there too. See :ref:`connectives-also-initials`. :issue:`397`, :issue:`538` - **Fix a one-letter connective joining a name written wholly in one case.** ``jose e maria santos`` gives first ``jose``, middle ``e maria``, last ``santos``, where 1.4.0 through 2.3.0 gave first ``jose e maria``, and reports the call. Mixed-case names are unchanged: ``Jose e Maria Santos`` still gives first ``Jose e Maria``. See :ref:`connectives-also-initials`. :issue:`383`, :issue:`479` - **Fix the Irish particles Ó, Ní and Ua and the Malay binti being read as a middle name.** ``Liam Ó Murchú`` gives last ``Ó Murchú``, where 1.4.0 through 2.3.0 gave middle ``Ó``, last ``Murchú``. Your own ``capitalization_exceptions`` entries now outrank the lowercase case repair gives a particle. See :ref:`case-exceptions`. :issue:`604` - **Fix salutations in Finnish, Estonian, Romanian, Croatian/Serbian, Icelandic, Czech, Lithuanian, Malay, Filipino and other languages being read as a first name.** ``Herra Väinö Johansson`` now gives title ``Herra``, first ``Väinö``; 1.4.0 through 2.3.0 gave first ``Herra``, middle ``Väinö``. :issue:`606` - **Change where a maiden marker takes a maiden name: only behind a surname, and only up to the credentials and titles the name ends with.** ``Jane Doe nee Smith MA`` gives maiden ``Smith``, suffix ``MA``, where 2.0 through 2.3 gave maiden ``Smith MA``. After a family comma the marker is an ordinary word again, as in 1.4.0: ``Doe, Jane nee Smith`` gives middle ``nee Smith``. See :ref:`nicknames-and-maiden-names`. :issue:`601` - **Fix a declared suffix delimiter being taken into a joined name part instead of separating suffixes.** With ``suffix_delimiter=" - "``, ``Smith, John, PhD - and MD`` gives suffix ``PhD, and MD``, 1.4.0's reading, where 2.0 through 2.3 gave ``PhD - and MD``. See :ref:`suffix-delimiters`. :issue:`549` - **Report more of the parser's credential-or-name decisions, and name the right field in each report.** ``parse("Smith, MA").ambiguities`` now names ``suffix-or-name``, as every decision at an ambiguous credential does, before or after a comma. A report now names the field its word ends up in rather than the one it was first placed in, and a part read wholly as suffixes no longer reports a particle in it as a name. See :ref:`parser-guesses`. :issue:`289`, :issue:`544`, :issue:`626` - **Change case repair to write credentials the way they are usually written.** ``john smith phd`` now capitalizes to ``John Smith PhD``, ``JANE DOE MBA`` to ``Jane Doe MBA``, ``jane smith r.d.n.`` to ``Jane Smith R.D.N.`` and ``john smith vi`` to ``John Smith VI``, where 2.3.0 gave ``Ph.D.``, ``Mba``, ``R.d.n.`` and ``Vi``. A ``capitalization_exceptions`` value is now a case mask over its key's letters, and one that spells anything else (``{"jr": "Junior"}``) raises ``ValueError``. See :ref:`case-exceptions`. :issue:`459` - **Fix case repair treating a suffix written in capitals as evidence that the whole name was cased on purpose.** ``juan garcia III`` now capitalizes to ``Juan Garcia III``, where every release returned it untouched. :issue:`492` - **Fix case repair capitalizing the connective inside a hyphenated compound surname.** ``jose ortega-y-gasset`` capitalizes to ``Jose Ortega-y-Gasset`` again, fixing a 2.2 regression that gave ``Ortega-Y-Gasset``. :issue:`478` - **Fix initials and case repair on a name typed with decomposed accents (NFD).** Decomposed ``josé garcía`` now capitalizes to ``José García``, where every release gave ``José GarcíA``, and decomposed ``émile zola`` initials ``é. z.``, where every release dropped the accent. :issue:`542`, :issue:`585` - **Fix initials disagreeing with the parse about one-letter connectives.** ``HumanName("john e smith").initials()`` gives ``j. e. s.``, where earlier releases gave ``j. s.``, and ``John and Jane Smith`` gives ``J. J. S.``, where 2.0 through 2.3 also gave ``and`` an initial. A v1 subclass overriding ``_process_initial`` must now accept and pass on a ``tokens`` keyword. :issue:`461`, :issue:`528` - **Fix a Japanese name written in halfwidth katakana being read given-first.** ``山田 タロウ`` gives last ``山田``, first ``タロウ``, as ``山田 タロウ`` does, where every release gave the reverse. See :ref:`east-asian-names`. :issue:`594` - **Fix a katakana name typed with a separate voicing mark being read family-first.** ``ア゙イ タロウ`` gives first ``ア゙イ``, last ``タロウ``, as ``アイ タロウ`` does, where 2.1 through 2.3 gave the reverse. :issue:`596` - **Fix halfwidth corner brackets not being read as a nickname.** ``山田 「タロー」 タロウ`` gives nickname ``タロー``, where every release kept the bracketed word in the name. See :ref:`brackets`. :issue:`597` - **Fix a v1 Constants entry with stray whitespace, or that is only a CJK full stop, raising or changing the parse.** Such an entry (``c.titles.add(" dean ")``, ``c.titles.add("。")``) is now ignored with a ``UserWarning`` naming it, as 1.4.0 ignored it, where 2.0 through 2.3 raised ``ValueError`` or applied it. :issue:`541`, :issue:`582` - **Fix three rare input shapes that cost quadratic time.** A long given part after a family comma, a name ending in many alternating credentials and titles, and many leading titles with many surname particles now cost linear time. Speed on ordinary names is unchanged, and no parse output changes. :issue:`546`, :issue:`553`, :issue:`558`, :issue:`559` **Additions** - **Add Python 3.15 support.** :issue:`259` - **Add Lexicon.conjunctions_ambiguous, the one-letter connectives that read as initials in a one-case name.** It holds ``e`` and ``i`` by default; Portuguese data can remove ``e`` with ``Lexicon.default().remove(conjunctions_ambiguous={"e"})``. See :ref:`connectives-also-initials`. :issue:`383`, :issue:`479` - **Add AmbiguityKind.CONJUNCTION_OR_INITIAL,** reported when a one-letter connective in a name written wholly in one case is read as an initial. :issue:`383`, :issue:`479` - **Remove ph from the default post-nominal acronyms.** ``Ph. D.`` and ``PhD`` parse as before, but a bare ``John Smith Ph.`` now gives last ``Ph.``; add it back with ``Lexicon.default().add(suffix_acronyms={"ph"})``. :issue:`459` * 2.3.0 - September 12, 2026 nameparser 2.3 is parsing fixes and new honorific vocabulary; nothing in the API is removed or renamed. The fixes cluster around post-nominals and titles. A space-separated run of post-nominals keeps the spacing the writer typed, and the acronyms that are also surnames -- Rai, Cha, Ba -- no longer take a name's family name. A run of titles addresses by its last, and a trailing abbreviated title reads as a title. CJK names and honorifics written with a full stop of any width now parse. The additions are renunciate and royal given-name titles, Devanagari and the first Bengali honorifics, and two ``AmbiguityKind`` members that report a reading nothing in the name decided. One incompatibility: a ``Lexicon`` pickled by 2.1.x or 2.2.x with a caller-added wide-stop or NFD entry no longer loads; the full-stop bullet below has the remedy. **Behavior Changes** - **Fix HumanName.initials() dropping a middle- or family-group initial that is also a one-letter conjunction.** ``HumanName("Scott E. Werner").initials()`` gives ``S. E. W.`` again where 2.0.0 through 2.2.0 gave ``S. W.``; ``Juan Y. Garcia`` and a bare ASCII capital ``John E Smith`` likewise. v1 excluded initial-shaped words from its conjunction test and the 2.0 facade had not; ``parse(...).initials()`` was already right and is unchanged. A bare lowercase ``john e smith`` still reads the ``e`` as the connective. See the ``R3`` entry of ``docs/design/decisions/`` (closes :issue:`462`) - **Record a 2.0.0 change to HumanName.initials() that no release note had classified:** since 2.0.0 the facade initials each WORD of a name part, where 1.4.0 initialed a joined run as one group -- ``HumanName("Juan Velasquez y Garcia").initials()`` is ``J. V. G.`` and was ``J. V G.``; ``Abdul Salam Hassan`` is ``A. S. H.`` and was ``A S. H.``. Nothing changes in 2.3.0; the differential gate now compares ``initials()`` (:issue:`484`) and this is what it found. See the ``differential-ledger, the initials view`` entry of ``docs/design/decisions/`` - **Fix a space-separated run of post-nominals rendering with a comma the writer never typed.** ``HumanName("John Smith MD PhD").suffix`` is ``MD PhD`` and was ``MD, PhD`` at every release since 1.4.0; ``Kenneth Clarke QC MP`` gives ``QC MP``, and the CJK honorific runs (``김민준 박사 씨``) follow the same rule. This is a deliberate deviation from 1.4.0, and the v1-parity suite is re-pinned to match. The comma forms are unchanged -- ``HumanName("Smith, MD, PhD").suffix`` is still ``MD, PhD`` -- because the separator is now the comma the writer typed; a configured suffix delimiter still parts a run, as does a name word standing between two post-nominals. Round-tripping is fixed for these runs, which the 2.2.0 note below recorded as broken: ``str(HumanName("Smith, MD PhD"))`` is ``Smith MD PhD`` and re-parses to suffix ``MD PhD``. ``str()`` is still a rendering rather than a canonical form. See the ``R1`` entry of ``docs/design/decisions/`` (closes :issue:`436`, closes :issue:`437`) - **Fix Parser.revise() splitting a space-separated suffix value into comma-separated entries.** ``Parser().revise(n, suffix="MD PhD").suffix`` is ``MD PhD`` and was ``MD, PhD``. A suffix value's entries are now derived from the value's own commas by the rule a whole name uses -- a comma parts two credentials and a space joins them -- so ``revise(n, suffix="MD, PhD")`` is still two entries, and ``revise(n, suffix="Ph. D.")`` renders ``Ph. D.`` where the 2.2.0 note below accepted ``Ph., D.``. One limit: a delimiter configured through ``extra_suffix_delimiters`` parts a value only where the value's own words read as a name with a tail segment, so in a run of post-nominals it stays a word; write a comma at the boundary instead. ``ParsedName.replace()`` is unchanged. See the ``R1`` entry of ``docs/design/decisions/`` (closes :issue:`511`) - **Remove rai and cha from the default post-nominal acronyms, so a trailing Rai or CHA keeps the family name.** ``HumanName("Aishwarya Rai")`` gives last ``Rai``, where 2.0.0 through 2.2.0 gave suffix ``Rai`` and no last name at all -- 1.4.0's reading, restored. The cost is that a genuine credential written after a full name is no longer recognized: ``John Smith RAI`` gives last ``RAI``, and ``John Smith, RAI`` gives first ``RAI``, last ``John Smith``. A caller who needs either back adds it: ``Lexicon.default().add(suffix_acronyms={"cha"})``. See the ``suffix-acronym-collisions`` entry of ``docs/design/decisions/`` (closes :issue:`342`) - **Mark ba as an acronym that is also an ordinary name, so a bare trailing Ba keeps the family name.** ``HumanName("Anna Ba")`` gives last ``Ba`` and reports a suffix-or-name ambiguity, where 2.0.0 through 2.2.0 gave suffix ``Ba`` and no last name. The spaced full-name form keeps the credential reading -- ``John Smith BA`` still gives suffix ``BA``, now flagged -- and the dotted ``John Smith B.A.`` is an unflagged suffix. The comma forms move, which is the marking's cost: ``John Smith, BA`` gives first ``BA``, last ``John Smith``, as ``Smith, Ed`` already did for the other ambiguous acronyms. Write ``B.A.`` to keep the credential reading. Ba is a real surname in Vietnamese and Senegalese Fula, the ``ma``/``Ma`` shape exactly (:issue:`342`) - **Fix a title run addressing by its first title rather than its last.** ``HumanName("Her Majesty Queen Elizabeth")`` gives first ``Elizabeth`` with an empty last name, where every release since 1.4.0 gave last ``Elizabeth``. Several titles written together are one form of address and the one that does the addressing is the last, so the run is now matched whole or by its last word: ``Reverend Mother Teresa``, ``Dr. Sir John`` and ``Sir Sheikh abdul rahman`` move the same way. A run whose last word addresses by surname does not move: ``His Excellency Lord Duncan`` still gives last ``Duncan``, ``lord`` not being a given-name title. A caller's multi-word entry still matches as a phrase. See the ``H1`` entry of ``docs/design/decisions/`` (closes :issue:`489`) - **Fix the leading title peel taking a name word and leaving a post-nominal to be the name.** ``HumanName("Dr King Jr")`` gives title ``Dr``, last ``King``, suffix ``Jr``, where every release since 1.4.0 gave title ``Dr King``, last ``Jr`` and no suffix at all; ``Dr. King MD`` moves the same way, and both now read as the comma spelling ``King, Dr Jr`` always has. A name that is nothing but titles or nothing but post-nominals is untouched (``Marquess of Bath``, ``MD DDS``), and so is a title written as one joined unit: ``Prince of Wales Jr`` keeps title ``Prince of Wales``. Where the only word left is the title itself, ``Dr Jr`` gives first ``Dr``, suffix ``Jr`` and reports a title-or-name ambiguity. See the ``H3`` entry of ``docs/design/decisions/`` - **Fix a trailing abbreviated title reading as a name word.** ``HumanName("John Smith Prof.")`` gives title ``Prof.``, first ``John``, last ``Smith``, where every release since 1.4.0 gave last ``Prof.`` and lost the surname; ``John Smith Dr.``, ``John Smith Rev.`` and ``Andrew Perkins (Mgr.)`` move the same way, and the comma form ``Smith, John Prof.`` now agrees with the bare one where it gave middle ``Prof.``. A run chains from the end (``John Smith Prof. Dr.`` gives title ``Prof. Dr.``), a leading title keeps its place (``Dr. John Smith Prof.`` gives title ``Dr. Prof.``), and a post-nominal is read through it (``John Smith Jr. Prof.`` gives suffix ``Jr.``). Only a listed title word wearing the abbreviation period is claimed: an unlisted abbreviation (``John Smith Xyz.``), a bare title word (``John Smith Sir``) and a post-nominal (``John Smith Esq.``) do not move. The reach is the whole title vocabulary, ordinary surnames in it included, so ``Mary Jane King.`` gives title ``King.`` where the bare ``Mary Jane King`` keeps last ``King`` -- accepted rather than prevented, the period being evidence the bare spelling never gives. The same argument holds in a native script: ``毛 泽东 Dr.`` gives title ``Dr.``, first ``泽东``, last ``毛``, where 2.2.0 gave last ``Dr.``. See the ``H5`` entry of ``docs/design/decisions/`` (closes :issue:`316`) - **Remove esq from the default post-nominal acronyms, and assert the two post-nominal sets disjoint.** ``HumanName("John Smith E.S.Q.")`` gives middle ``Smith``, last ``E.S.Q.``, where every release since 1.4.0 gave suffix ``E.S.Q.``; ``Esq``, ``Esq.``, ``ESQ`` and ``esq`` are unchanged, the post-nominal word list carrying every single-token spelling. Esquire is a contraction rather than an initialism, and it was the one word in both post-nominal sets, which can now assert they do not overlap. A caller who needs the dotted spelling back adds it: ``Lexicon.default().add(suffix_acronyms={"esq"})``. See the ``suffix-acronym-collisions`` entry of ``docs/design/decisions/`` - **Fix the East Slavic and Turkic patronymic rotations overriding a declared family-first name order.** With ``patronymic_rules`` opted in and ``Policy(name_order=FAMILY_FIRST)``, ``Мицкевич Адам Юзеф`` gave last ``Адам`` through 2.2.0 and now gives last ``Мицкевич`` -- the reading the declaration asks for -- and ``oglu Ahmad Vali Ali`` with Turkic handling gave last ``Ahmad`` and now ``oglu``. The rotations exist to restore the given-first reading a family-first listing hides, so under a declared family-first order the declaration decides. See the ``O1`` entry of ``docs/design/decisions/`` (closes :issue:`384`) - **Fix CJK honorifics and names written with a full stop of any width.** ``HumanName("김민준 씨.").suffix`` is ``씨.`` (the fullwidth stop a Japanese or Chinese IME produces by default), with last ``김`` and first ``민준``, where every release since 2.1.0 gave first ``김``, middle ``민준``, last ``씨.`` and no suffix; the ideographic ``씨。`` and halfwidth ``씨。`` spellings move the same way. The vocabulary lookup now composes NFC, so a decomposed ``Señor`` or ``née`` from macOS-origin data is recognized too. A period glued to a name word no longer breaks the name: ``양. 지훈`` gives last ``양.``, first ``지훈`` where 2.1.0 through 2.2.0 gave first ``양.``, middle ``지``, last ``훈``, and ``김민준씨.`` peels its honorific as the stop-less spelling does. The period stays on the word it was written with; nothing is rewritten. A Latin name written with ASCII periods is untouched (``Smith. John`` still reads title ``Smith.``), but a Latin or Cyrillic word wearing one of the three wider stops now reaches the vocabulary: ``Dr。 John Smith`` reads title ``Dr。`` where it read first ``Dr。``. This retires the 2.2.0 note below that read *period* strictly. One incompatibility, by decision: a ``Lexicon`` pickled by 2.1.x or 2.2.x that carries a caller-added entry the widened fold now changes -- a non-ASCII entry written with a fullwidth or ideographic stop, or in NFD -- no longer loads (``ValueError: incompatible Lexicon pickle: entries are not normalized``); the shipped vocabulary is unaffected, and the remedy is to rebuild the ``Lexicon`` from its source rather than unpickle it. See the ``cjk-full-stops`` entry of ``docs/design/decisions/`` (closes :issue:`322`, closes :issue:`323`) **Additions** - **Add the renunciate titles to the given-name title list, so a renunciate's one name is a given name.** ``HumanName("Swami Vivekananda")`` gives first ``Vivekananda`` with an empty last name, where every release since 1.4.0 gave last ``Vivekananda``; ``Guru Nanak``, ``Baba Ramdev`` and ``Lama Zopa`` move the same way, and so do the Devanagari and Bengali spellings added below. Two name words behind the title are unchanged -- ``Swami Vivekananda Saraswati`` keeps last ``Saraswati`` -- and a surname-retaining title is untouched: ``Rabbi Cohen`` still gives last ``Cohen``. ``venerable`` is deliberately not in the list, the traditions using it splitting on whether the family name survives. See the ``indic-honorifics`` entry of ``docs/design/decisions/`` (closes :issue:`346`) - **Add prince and princess to the given-name title list, so a royal's one name is a given name.** ``HumanName("Prince Harry")`` gives first ``Harry`` with an empty last name, where every release since 1.4.0 gave last ``Harry``; ``Princess Anne`` and ``Her Royal Highness Princess Anne`` move the same way. Two name words behind the title are unchanged -- ``Prince Harry Windsor`` keeps last ``Windsor`` -- and so is ``Prince of Wales Jr``, the joined title addressing by its last word. ``lord`` and ``lady`` are deliberately NOT in the list: they address by given name only as a courtesy style (``Lord Peter``, ``Lady Diana``) and by surname for every peer and every wife (``Lord Byron``, ``Lady Thatcher``), which the list cannot express. The given-name collision is untouched: ``Prince Fielder`` now gives first ``Fielder`` (:issue:`348`). See the ``H1`` entry of ``docs/design/decisions/`` (closes :issue:`519`) - **Add trailing honorifics as post-nominal vocabulary:** Latin ``rinpoche``, Devanagari ``जी``, ``साहब``, ``साहिब``, ``साहेब``, ``महाराज``, and Bengali ``সাহেব``, ``বাবু``, ``মহারাজ``. ``HumanName("Lama Zopa Rinpoche")`` reads title ``Lama``, first ``Zopa``, suffix ``Rinpoche``; ``नरेन्द्र मोदी जी`` reads last ``मोदी``, suffix ``जी``. They are recognized SPACED only and are deliberately absent from ``Lexicon.honorific_tails``: Banerjee, Mukherjee and Chatterjee end in the ``जी`` substring (``बनर्जी``, ``मुखर्जी``, ``चटर्जी``), so a glued peel would cut a real family name in two, and ``गांधीजी`` staying unpeeled is the accepted cost. Bengali ``বাবু`` is trailing where Devanagari ``बाबू`` is a leading title (:issue:`344`, :issue:`343`) - **Add Devanagari honorifics (#344):** ``डॉक्टर``, ``डा``, ``प्रो``, ``प्रोफेसर``, ``प्राध्यापक``, ``प्रा``, ``पंडित``, ``पं``, ``सरदार``, ``सुश्री``, ``श्रीयुत``, ``श्रीमान``, ``सौ``, ``बाबू``, ``महात्मा``, ``न्यायमूर्ति``, ``मौलाना``, ``जनाब`` and ``महाराजा`` as titles, beside the ``श्री``/``श्रीमती``/``डॉ`` that shipped in 2.1.0, and ``स्वामी``, ``गुरु``, ``बाबा``, ``संत`` as given-name titles. ``डॉक्टर शर्मा`` reads title ``डॉक्टर``, last ``शर्मा``; ``स्वामी विवेकानंद`` reads first ``विवेकानंद`` with no last name. Dotted spellings (``प्रो.``, ``पं.``) match the same entries. Excluded under the collision rule: ``कुमारी`` (Kumari is a given and a family name), ``बेगम``, ``शेख``, ``आचार्य``, ``राजा``/``रानी`` and ``ठाकुर``, all borne as ordinary names (closes :issue:`344`) - **Add Bengali honorifics -- the first Bengali vocabulary in the default lexicon (#343):** ``ড``, ``ডঃ``, ``ডক্টর``, ``ডাঃ``, ``ডা``, ``ডাক্তার``, ``শ্রী``, ``শ্রীমতী``, ``জনাব``, ``অধ্যাপক``, ``প্রফেসর``, ``বিচারপতি``, ``মাওলানা``, ``মুফতি``, ``আলহাজ্ব``, ``আলহাজ``, ``মিঃ``, ``মি``, ``মিসেস``, ``মোঃ``, ``মো``, ``মোসাঃ``, ``মোসা``, ``মোছাঃ`` and ``মোছা`` as titles, and ``স্বামী``, ``শ্রীল``, ``গুরু``, ``বাবা`` as given-name titles. ``ড. মুহাম্মদ ইউনূস`` reads title ``ড.``, first ``মুহাম্মদ``, last ``ইউনূস`` -- the vocabulary beats the initial reading -- while real initials are untouched: ``র. কে. নারায়ণ`` is unchanged. ``মোঃ আবদুল করিম`` reads title ``মোঃ``, first ``আবদুল``, last ``করিম``, the mirror of Latin ``Md``; the visarga spelling and the ``মো.`` period spelling both match, and the women's ``মোসাঃ``/``মোসা.`` rides the same pair of entries. ``ঠাকুর`` stays out, being Tagore. Latin transliterations (``Sri``, ``Pandit``, ``Mst``) are not added -- they collide with real given names where the native scripts cannot -- and belong to the opt-in packs of :issue:`345` (closes :issue:`343`) - **Add AmbiguityKind.GIVEN_OR_FAMILY, reported when a name of one name word had nothing to decide which field it is:** ``parse("Andrew")`` still gives given ``Andrew`` and now says that field was a convention rather than a reading -- the library picks the given name under the default order and the family name under a declared family-first one, and ``detail`` names the field it picked. ``parse("Smith Jr.")`` reports it too, the suffix being peeled first. A name something DID decide stays silent -- ``"Dr. Smith"``, ``"Smith, Andrew"``, ``"abdul"`` (bound given-name vocabulary), ``"J."`` (an initial's shape) -- and so does ``"毛泽东"``, where the writing system settles the order. No field moves anywhere. See the ``O5`` entry of ``docs/design/decisions/`` (closes :issue:`449`) - **Add AmbiguityKind.TITLE_OR_NAME, reported when an input that is nothing but honorifics had its last word read as the name:** ``parse("Lord Chancellor")`` still gives title ``Lord``, family ``Chancellor``, and now says so -- this is a name parser, not a title parser, so handed a string with no name in it, it reads the last title word as one. ``"His Holiness"`` and ``"Dr. King"`` move the same way, ``king`` being title vocabulary. A title with an ordinary word behind it is silent (``"Dr. Smith"``, ``"King Charles"``), and so is a lone title word: ``parse("Dr.")`` is a title with no name beside it. The same convention on the post-nominal vocabulary reports the existing suffix-or-name: ``parse("Rinpoche")`` gives given ``Rinpoche`` and flags it, as does ``"QC MP"``. No field moves for this change; ``Dr King Jr``, ``Dr. King MD`` and ``Dr Jr`` gain the report from the title-peel fix above. See the ``H4`` entry of ``docs/design/decisions/`` (closes :issue:`491`) **Documentation** - **Document the family-first and East Asian input shapes beside the three Latin ones.** The input-shapes list in ``usage.rst`` grows from three forms to seven: forms 4 and 5 for a declared ``FAMILY_FIRST`` or ``FAMILY_FIRST_GIVEN_LAST`` order, and forms 6 and 7 for the native East Asian arrangements the script carries on its own. A comma or a Latin wrapper around a CJK name is named as tolerated input -- parsed best-effort, its handling changeable without notice -- and the ``customize.rst`` correspondence between forms 2 and 4 is written out. Behavior is unchanged (:issue:`469`) * 2.2.0 - August 31, 2026 nameparser 2.2 is a rename plus about thirty parsing fixes. The ``nameparser.config`` word lists were still named for v1's fields — ``PREFIXES``, ``BOUND_FIRST_NAMES``, ``FIRST_NAME_TITLES`` — while the ``Lexicon`` they feed has used particles and given names since 2.0. They now agree, and the lists are frozen, which retires editing one in place as a way to change a default. The rename itself changes no parse. The fixes cluster around surname particles, largely what a declared ``name_order`` means for Latin-script names, which this release settles; then maiden-name clauses, Arabic bound given names, and credentials after a comma. Most reach the default name order, and a bullet says so where its change is family-first only. Each names the shapes it moves, and the issue it closes carries the measurement. What breaks is code that *writes* to a default word list. Code that imports one by its 1.x name has until 3.0. **Breaking Changes** - **Add docs/design/ contributor documentation:** ``rules.md`` (the parser's normative rules, with executable examples), ``decisions.md`` (the decision record) and ``mechanisms.md`` (the solution-pattern catalog). New tests execute every documented example and verify every code citation - **Change every vocabulary set in nameparser.config to a frozenset.** Editing one in place -- ``TITLES.add("dean")``, the old way of changing a global default -- now raises ``AttributeError`` at the line that writes it. To change the defaults for ``HumanName``, build a private ``Constants`` and pass it (``c = Constants(); c.titles.add("dean"); HumanName(name, constants=c)``); for the 2.0 API, build a lexicon (``Parser(lexicon=Lexicon.default().add(titles={"dean"}))``). Mutating the shared ``CONSTANTS`` still works, but warns and goes away in 3.0. ``CAPITALIZATION_EXCEPTIONS`` is a mapping, not a set, and is unchanged. See :doc:`migrate` and :doc:`customize` (:issue:`293`) **Behavior Changes** - **Fix a title changing how the name behind it is read.** ``"Dr. Van Johnson"`` gave family ``Van Johnson`` with no given name, and ``"Sir Van Johnson"`` gave given ``Van Johnson`` with no family at all; both now read given ``Van``, family ``Johnson`` -- the reading the untitled ``"Van Johnson"`` has always had. A leading word that is *both* a title and a particle is unchanged: ``"St John Smith"``, ``"Do John Smith"`` and ``"Freiherr von Richthofen"`` keep their readings. See the ``P2`` entry of ``docs/design/decisions/`` (closes :issue:`367`) - **Fix a given-name title keeping a bound given name from joining the word after it.** ``"Sheik abdul salam"`` read given ``abdul``, family ``salam``, and now reads given ``abdul salam`` with an empty family, as ``"Sir John"`` does; ``"الشيخ عبد الله"`` reads given ``عبد الله``. A title that addresses by family is unchanged (``"Dr. abdul salam"``). This also restores ``"Sheik Abu Bakar"`` to given ``Abu Bakar``, which the fix above had regressed, and drops the ``PARTICLE_OR_GIVEN`` ambiguity that name reported through 2.1 (closes :issue:`369`) - **Fix a bound given name swallowing the family name before a single-letter generational suffix.** ``"abdul Smith V"`` read given ``abdul Smith`` with no family, where ``"abdul Smith II"`` and ``"abdul Smith Jr"`` read correctly; it now reads given ``abdul``, family ``Smith``, suffix ``V``, and so do ``I`` and ``X``, for every bound given-name word. A suffix word before the numeral no longer hides it (``"abdul Smith Jr V"`` reads family ``Smith``). Shipped since 1.x: 1.4.0 read first ``abdul Smith``, last ``V`` (closes :issue:`401`) - **Fix a bound given name joining a suffix as "the word after it".** ``"abdul Jr Smith Berg"`` read given ``abdul Jr`` and now reads given ``abdul``, middle ``Jr Smith``, where ``"John Jr Smith Berg"`` puts it. Where the suffix was a split credential the bound word joined *into* it -- ``"abdul Ph. D. Smith Berg"`` read suffix ``abdul Ph. D.``, a 2.0 regression -- and now reads given ``abdul``, middle ``Smith``, suffix ``Ph. D.`` (closes :issue:`421`) - **Fix a bound given name joining past a credential that the suffix rule then takes, leaving no family.** ``"abdul Smith Jr Ma"`` read given ``abdul Smith`` with no family and now reads family ``Smith``, suffix ``Jr, Ma``, as ``"John Smith Jr Ma"`` does; ``"abdul Smith Ma"`` reads family ``Smith``, suffix ``Ma``. Both as 1.4.0 read them. ``"abdul Smith Berg Ma"`` keeps its join, and ``"Berg, abdul Sir"`` still reads given ``abdul Sir`` (closes :issue:`425`) - **Remove the Czech/Slovak abbreviation roz. from the default maiden markers.** Marker matching is case-folded and period-insensitive, so ``Roz`` -- the diminutive of Rosalind -- was the same string as the marker, and a marker takes every word after it: ``"Rosalind Roz Smith"`` read maiden ``Smith`` with **no family name at all**. It and ``"Rosalind Roz Jones Smith"`` now read as 1.4.0 read them. The full participle is untouched -- ``"Anna Nováková rozená Svobodová"`` still reads maiden ``Svobodová`` -- and a caller who wants the abbreviation back adds it to their own lexicon: ``Parser(lexicon=Lexicon.default().add(maiden_markers={"roz"}))`` (found in :issue:`335`'s review) - **Add the Polish maiden marker z domu to the default vocabulary, and let a maiden_markers entry be more than one word.** ``"Maria Kowalska z domu Nowak"`` now reads family ``Kowalska``, maiden ``Nowak``, where every earlier version read the marker as part of the name (1.4.0: middle ``Kowalska z domu``, family ``Nowak``). The bracketed spelling moves with it. ``maiden_markers`` and ``given_name_titles`` are now the two fields exempt from the multi-word warning. See :doc:`customize` (:issue:`434`) - **Fix a bracketed maiden clause reading as a nickname because its brackets were not declared.** ``"Jane Smith nee Jones"`` gave maiden ``Jones`` while ``"Jane Smith (née Jones)"`` gave nickname ``née Jones``; the bracketed spelling now reads family ``Smith``, maiden ``Jones`` too, and so does the Japanese ``"山田 花子(旧姓 佐藤)"``, which needed ``Policy(maiden_delimiters=...)`` through 2.1. Every delimiter pair the parser ships moves the same way, quotes included. An interior clause no longer eats the name behind it (``"Jane (née Jones) Smith"`` keeps family ``Smith``), and two clauses beside each other each keep their own role (``"Jane "Janey" Smith (née Jones)"`` reads nickname ``Janey``, maiden ``Jones``). A clause with no marker in it is still a nickname, which is what ``Policy(maiden_delimiters=...)`` remains for. This reaches ``HumanName`` (closes :issue:`335`) - **Fix a particle chain and a maiden name taking a trailing generational numeral as a name word.** ``"John van der Berg V"`` read family ``van der Berg V`` and ``"John née Jones Smith V"`` read maiden ``Jones Smith V``, where ``"John Smith V"`` reads suffix ``V``; both now stop before the numeral, for ``I`` and ``X`` alike. A word before the numeral that is an initial keeps its reading (``"John van der J. V"``). The chain also stops before a bare credential with words to spare -- ``"John van der Berg Ma"`` reads suffix ``Ma``, as 1.4.0 did -- and no longer swallows the given name behind an unlisted abbreviation: ``"Xyz. van Johnson"`` and ``"Esq. van Gogh"`` read given ``van`` (closes :issue:`424`) - **Fix a name losing its given/family split when a comma is followed only by an honorific.** ``"John Smith, Mr."`` returned the whole of ``"John Smith"`` as the family name and now gives given ``John``, family ``Smith``, title ``Mr.``: it is ``"Mr. John Smith"`` with the honorific moved to the end, and marks no surname boundary. A comma followed by an actual name still fixes the family (``"John Smith, Jones"``), and a single pre-comma piece has no split to keep (``"Smith, Dr."`` is unchanged). The pre-comma name now also picks up the declared name order -- ``"de Mesnil Jean, Dr."`` keeps family ``de Mesnil`` under a family-first order -- and the particle-or-given ambiguity report (``"Van Johnson, Mr."``) - **Fix pure postnominals being claimed as titles:** ``jr``, ``junior``, ``phd``, ``do`` and ``se`` have left the default ``titles`` vocabulary, and ``dr``/``sra`` have left the suffix vocabulary they never belonged in, so ``"Smith, PhD"`` gives suffix rather than title ``PhD``. Twelve words are genuine duals and keep both memberships, with position deciding -- ``"Lt. Smith"`` is a title, ``"Smith, LT"`` a postnominal, bare ``Md`` before a name the Bengali and South Asian abbreviation of Muhammad, ``MD`` after it the degree. The cost is in leading position, where a dropped word now reads as a name: ``"PhD Smith"`` gives given ``PhD``, which is what makes ``"Do Nguyen"`` parse as the Vietnamese name it is. ``dr`` and ``sra`` also stop being recognized in trailing position, so ``"John Smith Dr."`` gives family ``Dr.``. An ambiguous credential acronym (``ma``, ``ed``, ``jd``, ``do``) counts as a suffix only when written with its periods, so ``"Jack Ma."`` keeps family ``Ma.`` as 1.4.0 read it. Routing a trailing title word to ``title`` is a separate open question (:issue:`316`) - **Fix a credential run after a one-word family comma reading as a title or a given name.** ``"Smith, Jr."`` and ``"Smith, PhD"`` now give suffix ``Jr.``/``PhD`` where they gave title, and ``"Smith, Ph. D. Jr."`` gives suffix ``Ph. D. Jr.`` where the split credential landed in the given name -- a regression from 1.4.0. The position right after a family comma is postnominal position. Vocabulary still decides which words qualify (``"Smith, Dr."`` keeps title ``Dr.``), the leading readings are untouched (``"Sr. Garcia"`` is still title ``Sr.``), and a name word in the run makes it the given-and-suffix reading it always had (``"Smith, John Jr."``) (closes :issue:`296`, closes :issue:`325`) - **Fix a space-separated credential run after a family comma rendering with a comma the name never had.** ``"Smith, MD PhD"`` gives suffix ``MD PhD`` where it gave ``MD, PhD``, and ``"Smith, CBE MC"``, ``"Smith, BSc MBA"`` and ``"Smith, Dr. MD PhD"`` the same. The roles are unchanged; only the rendered string carried the extra comma. This reaches any family comma whose following segment holds no name word, not only a one-word family, so ``"John Smith, Jr. III"`` gives suffix ``Jr. III`` -- also what 1.4.0 gave. A run written with commas keeps them (``"Smith, MD, PhD"``), and a name written without a comma is unaffected and still renders its run comma-joined, so re-parsing ``str()`` output does not reproduce the run (closes :issue:`429`) - **Fix a one-character suffix word after a comma being read by the wrong neighbour.** ``"Smith, PSM I"`` gives suffix ``PSM I`` where it gave given ``PSM`` and suffix ``I``, and ``"Smith, John V."`` gives middle ``V.`` where it gave suffix ``V.``. Inside a comma part a suffix word short enough to be mistaken for an initial -- ``I``, ``V`` and ``2`` in the shipped vocabulary -- is read by what stands before it: behind a credential it describes that credential (``PSM I`` is Professional Scrum Master level I), and behind a name a period marks an abbreviation and so a middle initial. A numeral written bare after a name is still the generation it looks like (``"Smith, John V"`` is suffix ``V``), and a name with no comma is untouched (closes :issue:`430`, closes :issue:`432`) - **Fix a name opening with a particle that is never a given name being split at the particle under a family-first name order.** ``"de Mesnil"`` read as family ``de``, given ``Mesnil`` and ``"de la Vega"`` as family ``de``, given ``la Vega``; each is now the whole surname, as it has always been in the default order, under ``FAMILY_FIRST`` and ``FAMILY_FIRST_GIVEN_LAST`` alike. A word that can never be a given name leaves ``name_order`` nothing to decide. Standing alone is the whole of it: ``"Juan de la Vega"`` under ``FAMILY_FIRST`` still reports given ``de la Vega``. A leading particle that *may* be a given name is genuinely order-dependent and is untouched, so ``"van Gogh"`` still reads family ``van``, given ``Gogh`` under both family-first orders (closes :issue:`359`) - **Fix a family name made only of particle words reporting no base, so the surname vanished from family_base and from the initials.** ``parse("Anh Do")`` gave family ``Do`` with ``family_base`` ``''`` and initials ``A.``, and under ``Policy(name_order=FAMILY_FIRST)`` ``"Del Toro"`` gave family ``Del`` the same way. A particle standing alone in a name part is not doing a particle's work there and now reads as an ordinary name word: ``"Anh Do"`` is base ``Do``, initials ``A. D.``; ``"Juan van der"`` is base ``van der``, initials ``J. v. d.``; ``"Nguyen, Van Le"`` initials ``V. L. N.`` where the middle name used to be dropped. The parse fields themselves do not move -- only the derived views and the initials. See the ``R2`` entry of ``docs/design/decisions/`` (closes :issue:`385`, closes :issue:`402`) - **Fix case repair lowercasing the words of a family name made only of particle words, where every other view already reads them as ordinary name words.** ``HumanName("ANH DO").capitalize()`` gives ``Anh Do`` where it gave ``Anh do``, and ``"anh van do"`` gives ``Anh Van Do``. This DIFFERS FROM 1.4.0 deliberately and does not restore it: 1.4.0 returned ``Anh do``. The accepted cost is that a family which is nothing but particles capitalizes too, so ``"juan van der"`` gives ``Juan Van Der``. A conjunction is untouched (``"der, y van"`` gives ``y Van Der``), and where the particles DO join a name word nothing changes (``"juan de la vega"`` still gives ``Juan de la Vega``) (closes :issue:`407`) - **Change case repair to read the parser's own conjunction tag instead of re-deciding, from the word's spelling, whether a word is a conjunction or an initial.** Two spellings of one name disagreed because of it: ``"jose ortega-y-gasset"`` capitalized to ``Jose Ortega-y-Gasset`` while ``"JOSE ORTEGA-Y-GASSET"`` gave ``Jose Ortega-Y-Gasset``; both give ``Jose Ortega-Y-Gasset`` now, a hyphenated token being one word to the parse whatever it contains. The spaced spelling is untouched and still repairs to ``Jose Ortega y Gasset``. A field assigned after the parse was never classified, so repair asks the vocabulary there -- today's vocabulary, which is narrower than 1.4.0 parity: ``h.last = "хосе и мария сантос"`` gives ``Хосе И Мария Сантос`` on 1.4.0 and ``Хосе и Мария Сантос`` here. One reading changes for hand-built ``Token``\ s in the 2.0 API: an untagged token whose text is conjunction vocabulary now capitalizes as an ordinary name word. See the ``R4`` entry of ``docs/design/decisions/`` (closes :issue:`458`) - **Change the parse-cost benchmark to bound function calls per parse rather than wall-clock seconds.** The old one-second bound failed four times on CI while the same code re-ran green on master; frame counts do not move under load. The bound is a per-interpreter band of ±2%, with a loose five-second backstop for what frame counts cannot see. Recomputable with ``uv run python tools/perf/call_count.py --against v2.1.0``; the counts and the per-PR attribution are in the ``parse-cost`` entry of ``docs/design/decisions/``. No user-visible behavior changes (closes :issue:`475`) - **Fix a name that opens with a spaced Ph. D. losing its surname.** ``parse("Ph. D. Van Johnson")`` read given ``Van Johnson`` with an empty ``family`` and suffix ``Ph. D.``; it now reads title ``Ph.``, given ``D.``, family ``Van Johnson``. A suffix never begins a name, and the split credential was the only shape that reached the defect. A family comma still opens a listing rather than a name (``"John Smith Ph. D."`` and ``"Smith, Ph. D. Jr."`` keep their suffixes), and "the head" means the head of the string rather than of the name, so ``"Sir Ph. D. Van Johnson"`` is unchanged. This RESTORES 1.4.0. One accepted consequence: ``Parser.revise(suffix="Ph. D.")`` renders ``Ph., D.`` (closes :issue:`371`) - **Fix a trailing surname particle being stranded as a standalone middle name under a family-first name order.** Under ``Policy(name_order=FAMILY_FIRST)`` the same listing written with a comma reads it as part of the surname: ``"Jong Anke de"`` gave family ``Jong`` with ``de`` left as a middle name and now gives family ``de Jong``, given ``Anke`` -- the answer ``parse("Jong, Anke de")`` has always given. ``FAMILY_FIRST`` is the only order that puts a trailing piece in a middle; ``FAMILY_FIRST_GIVEN_LAST`` puts it in the given slot, so ``"Nguyen Thi Van"`` under that order still reads given ``Van``. ``"Beethoven Ludwig van"`` under ``FAMILY_FIRST`` now gives family ``van Beethoven``. A particle standing alone in the given slot is no longer folded into the family either, so ``"Ménil de"`` reports given ``de``. Nothing moves under the DEFAULT name order. See :doc:`customize` for what declaring an order settles, and the ``P6`` entry of ``docs/design/decisions/`` for the reasoning (closes :issue:`467`) - **Fix initials() ordering a name differently from the fields of the same parse.** Two rules fold words into the family and render them ahead of it -- ``Policy(middle_as_family=True)`` and the tussenvoegsel attachment after a family comma -- and the ``family`` field honored the fold where ``initials()`` did not: ``parse("der, y van")`` gave family ``van der`` but initials ``y. d. v.``, and now gives ``y. v. d.``. Under ``middle_as_family`` this RESTORES v1, that option being ``middle_name_as_last``'s successor: ``"Doe, Dr. John A."`` gives ``J. A. D.`` again where 2.0 through 2.2 gave ``J. D. A.``. ``HumanName.initials()`` was already right and is unchanged. See the ``R3`` entry of ``docs/design/decisions/`` (closes :issue:`408`) - **Fix a tussenvoegsel attached to the family name after a comma deciding a genuinely uncertain reading and reporting nothing.** ``"Van Johnson"`` reports a ``PARTICLE_OR_GIVEN`` ambiguity -- ``Van`` is a Dutch particle and a Vietnamese given name -- while ``"Nguyen, Thi Van"`` picked the same word the same way, silently. The attachment now reports the fork it decides: ``"Nguyen, Thi Van"``, ``"Berg, Jan van der"`` and ``"Vega, Juan de la"`` each gain a ``PARTICLE_OR_GIVEN``, while a particle already read as a post-nominal reports ``SUFFIX_OR_NAME`` instead (``"Berg, Jan vd"``). Worth knowing before you filter on this: ``"Beethoven, Ludwig van"`` -- read exactly right -- now carries a report too, nothing in the input separating it from ``"Nguyen, Thi Van"``. ``ambiguities`` is the only value that grows (closes :issue:`405`) - **Fix a tussenvoegsel after a family comma being parsed as a middle name.** Dutch and Belgian alphabetized listings move the particle behind the given name -- ``"Beethoven, Ludwig van"`` is how ``"Ludwig van Beethoven"`` is filed -- and it was read as a middle name rather than as part of the surname: ``"Beethoven, Ludwig van"`` gave middle ``van``, last ``Beethoven``, and ``"Berg, Jan van der"`` gave middle ``van der``. Those now read family ``van Beethoven`` and ``van der Berg``. Two guards bound it: a name whose only given word is the particle keeps it (``"Nguyen, Van"`` still reads given ``Van``), and where the word is BOTH particle and suffix vocabulary the attachment wins, so ``"Berg, Jan vd"`` reads family ``vd Berg`` where 1.4.0 and 2.1 alike gave suffix ``vd`` -- as does ``mc``. (closes :issue:`379`, closes :issue:`380`) - **Add abd to BOUND_GIVEN_NAMES, so the spellings that write the article as its own word join like the others do:** ``"abd Allah Smith"`` was given ``abd``, middle ``Allah`` and is now given ``abd Allah``. ``abdul``, ``abdel`` and ``abdal`` were already there, and the Arabic-script ``عبد`` has covered the same word since 2.0, so only the Latin spelling was short. The word is also the postnominal ABD ("All But Dissertation") and stays in ``SUFFIX_ACRONYMS``: position tells the two readings apart, so ``"Jane Smith ABD"``, ``"Jane Smith, ABD"`` and ``"Jane Smith A.B.D."`` all still read the credential as a suffix (:issue:`400`) - **Change how far a leading never-given particle takes the surname when a family-first name_order is declared.** ``Policy(name_order=FAMILY_FIRST)`` read ``"de Mesnil Jean"`` as family ``de Mesnil Jean`` -- the whole name -- and now reads family ``de Mesnil``, given ``Jean``. The default order is unchanged, deliberately: with no order declared nothing marks where the surname ends, and a particle followed by several words really can be all surname (``von Bergen Wessels``); a caller who means family ``de la Vega`` plus given ``Juan`` there writes the comma. The stop cannot land inside a conjunction-joined run or a bound given-name pair: ``"de la Vega y Santos Juan"`` reads family ``de la Vega y Santos``, ``"ibn Awf abdul Rahman"`` given ``abdul Rahman``. Where two or more words are left over, the two family-first orders differ from each other for the first time: ``"de la Cruz Juan Carlos"`` reads given ``Juan``, middle ``Carlos`` under ``FAMILY_FIRST`` and the reverse under ``FAMILY_FIRST_GIVEN_LAST``. See :doc:`customize`, and the ``P1`` entry of ``docs/design/decisions/`` for the reasoning (closes :issue:`395`) - **Change the detail text of a PARTICLE_OR_GIVEN ambiguity to name the role the leading particle was actually given.** It said "read as a given name" under every ``name_order``, which is false under ``Policy(name_order=FAMILY_FIRST)`` -- there ``"Van Johnson"`` reads family ``Van``, given ``Johnson``, and the report described the reading not taken. It now ends "read as a family name" in that case. The ``kind`` is unchanged and stays ``PARTICLE_OR_GIVEN``; only the human-readable text moved, and default-order output is identical (:issue:`355`) - **Fix a maiden name being lost when a particle stood in front of the marker.** ``"Ursula Leyen geb. Albrecht"`` reported maiden ``Albrecht`` correctly, but ``"Ursula von der Leyen geb. Albrecht"`` -- the same words one particle chain apart -- gave family ``von der Leyen geb. Albrecht`` and no maiden name at all, as did ``"Jane van der Berg née Jones"``. A suffix already stopped the particle chain; a marker now does too, so those read family ``von der Leyen`` maiden ``Albrecht`` and family ``van der Berg`` maiden ``Jones``. Under a family-first order ``"de la Cruz née Vega"`` now reads family ``de la Cruz``, maiden ``Vega``. Two limits remain, both recorded in ``rules/M2``: a conjunction join and a bound given-name join each still absorb a marker first (closes :issue:`399`) - **Move mc and ste into the never-given half of the particle vocabulary, and add los, las and das.** The Spanish and Portuguese articles were absent from it entirely. A never-given particle opening a name folds into the family (``rules/P1``) instead of being read as a given name, so ``"Mc Donald"`` was first ``Mc``, last ``Donald`` and is now last ``Mc Donald``; ``"Ste Marie"``, ``"Los Santos"``, ``"Las Casas"`` and ``"Das Silva"`` move the same way, and ``"Mc Donald Smith"`` becomes last ``Mc Donald Smith``. The ``PARTICLE_OR_GIVEN`` ambiguity goes with it for ``mc`` and ``ste``. ``los``, ``las`` and ``das`` were not particles at all, so for those three the ordinary particle join fires from a non-leading position too: ``"Maria das Neves"`` is now last ``das Neves``. (closes :issue:`360`) - **Fix a bound given-name join leaving no family name when the name also carries a maiden clause, and stop the join absorbing the marker itself.** ``"abdul Berg née Jones"`` read given ``abdul Berg`` with an EMPTY family, where ``"abdul Berg"`` alone reads given ``abdul``, family ``Berg``; it now reads given ``abdul``, family ``Berg``, maiden ``Jones``. The join also declines when the piece it would absorb is a marker, so ``"van der Berg, abdul née Jones"`` reads given ``abdul``, family ``van der Berg``, maiden ``Jones`` where it read given ``abdul née``. Where the bound word is ALSO suffix vocabulary, a declining join after a family comma leaves the post-nominal reading: ``"Berg, abd née Jones"`` reads family ``Berg``, suffix ``abd``, maiden ``Jones``, as ``"Berg, abd"`` alone always has (closes :issue:`411`) - **Fix a maiden clause changing how the rest of the name is read, and a connective join keeping the marker in the surname.** The grouping rules that count a name's words counted the maiden clause too: ``"juan y garcia"`` reads given ``juan``, middle ``y``, family ``garcia``, while ``"juan y garcia nee jones"`` read given ``juan y garcia`` with NO family name at all. A name of two or more name words now reads as it reads without its maiden clause, plus the maiden name. The connective join used to merge the marker into a multi-word piece, so ``"Jane van der Berg née y Jones"`` kept family ``van der Berg née y Jones`` and now reads family ``van der Berg``, maiden ``y Jones``. Two limits: a bound given-name word still never joins onto a marker standing as a word of its own (``"Berg, abdul née PhD"``), and a suffix-vocabulary word inside the maiden name stops the marker, so ``"Jane née Jr y Jones"`` now reads family ``Jr y Jones`` with no maiden name (closes :issue:`412`, closes :issue:`417`, closes :issue:`418`) - **Fix a title-plus-surname name losing its family name whenever anything stood beside it.** ``"Dr. Smith"`` reads family ``Smith``, but ``"Dr. Smith née Jones"`` read given ``Smith`` with no family at all, and so did ``"Dr. Smith PhD"`` and ``"Dr. "Smitty" Smith"``. A suffix, a nickname and a maiden name each stand beside the name rather than in it, and the rule now counts name words alone: those read family ``Smith`` with maiden ``Jones``, suffix ``PhD`` and nickname ``Smitty`` respectively, and ``"Freiherr von Richthofen geb. Albrecht"`` reads family ``von Richthofen``, maiden ``Albrecht``. A given-name title still names no family: ``"Sir John née Jones"`` keeps given ``John``, exactly as ``"Sir John"`` does. One name moves where the nickname LEADS: ``"'Smitty' Dr. Jones"`` reads family ``Jones`` where it read given (closes :issue:`410`) - **Fix a name that is a surname and a maiden clause reporting no family at all.** ``"Smith née Jones"`` read given ``Smith`` with an empty ``family``; it now reads family ``Smith``, maiden ``Jones``. A maiden marker announces a FORMER surname, which only means something beside a current one, so the lone name word left standing is the surname in use now. Every spelling of the shape moves, including a bracket pair you declared to mean maiden: under ``Policy(maiden_delimiters=frozenset({("(", ")")}))``, ``"Smith (Jones)"`` reads family ``Smith``, maiden ``Jones``. ``"Smith (née Jones)"`` RESTORES 1.4.0, which read family ``Smith`` with the clause as a nickname. Two shapes deliberately do NOT move: a word the vocabulary claims as a given name (``"abd née Jones"``) and a word written as an initial (``"J. née Jones Smith V"``) -- this rule changes what POSITION decided and does not reach what a word already is. **If you have code that reads the lone name word beside a maiden clause out of given, this is the release where it moves to family** (closes :issue:`445`) - **Change what a star import of the two 2.2 vocabulary modules binds.** ``nameparser.config.particles`` and ``nameparser.config.bound_given_names`` now declare ``__all__``, which their 1.x shims already did, so ``from ... import *`` binds their vocabulary alone -- it also bound the ``assert_normalized`` invariant helper, and from ``particles`` the ``BOUND_GIVEN_NAMES`` it imports only for a disjointness check. Importing a constant by name is unaffected, and no parse changes (:issue:`356`) **Deprecations** - Rename the four vocabularies whose 1.x names described the fields they feed in v1's words, so the data layer matches the ``Lexicon``: .. list-table:: :header-rows: 1 :widths: 50 50 * - 1.x name - 2.2 name * - ``nameparser.config.prefixes`` - :mod:`nameparser.config.particles` * - ``prefixes.PREFIXES`` - ``particles.PARTICLES`` * - ``prefixes.NON_FIRST_NAME_PREFIXES`` - ``particles.NON_GIVEN_NAME_PARTICLES`` * - ``nameparser.config.bound_first_names`` - :mod:`nameparser.config.bound_given_names` * - ``bound_first_names.BOUND_FIRST_NAMES`` - ``bound_given_names.BOUND_GIVEN_NAMES`` * - ``titles.FIRST_NAME_TITLES`` - ``titles.GIVEN_NAME_TITLES`` * - ``suffixes.SUFFIX_NOT_ACRONYMS`` - ``suffixes.SUFFIX_WORDS`` Every row above still resolves and is removed in 3.0. The two module rows are import paths: importing them still works and says nothing, since both modules are now empty shims. Reading a *constant* -- by attribute access, by ``from ... import``, or by ``from ... import *`` -- emits a ``DeprecationWarning`` naming the module and constant to move to, once per line that reads it rather than once per process, so every place you have to edit is reported rather than only whichever one ran first. ``python -W error::DeprecationWarning -c "import yourapp"`` surfaces them; Python hides ``DeprecationWarning`` outside ``__main__``. The ``CONSTANTS`` attribute names (``prefixes``, ``non_first_name_prefixes``, ``bound_first_names``, ``first_name_titles``, ``suffix_not_acronyms``) are v1 facade surface and are unchanged. See :doc:`migrate` (:issue:`293`) * 2.1.0 - August 7, 2026 nameparser 2.1 makes East Asian names work without configuration. A name written wholly in Han or hangul, or in kanji with kana, is read family-first. An unspaced Korean name is split against the census surname list. CJK honorifics are recognized whether they are spaced or written against the name. The two conventions that need you to declare a language, Han segmentation and kana-aware division, ship as the opt-in ``locales.ZH`` and ``locales.JA`` packs. See :ref:`east-asian-names` for how it fits together, and :doc:`customize` for the switches that turn it off. Most of this is **default-on**, deliberately: wherever nameparser acts unasked, the script itself settles the convention and no language detection is involved. Latin-script names are unaffected. To restore 2.0's reading of CJK text, use ``Parser(policy=Policy( script_orders=(), segment_scripts=frozenset()))``. **East Asian name support** - **Add the Chinese locale pack locales.ZH:** opt-in Han segmentation for unspaced names like ``毛泽东``, with the surname vocabulary it needs. A pack rather than a default because a Chinese surname list corrupts Japanese names written in the same characters (``高橋一郎`` would split ``高`` + ``橋一郎``). Japanese data goes through ``locales.JA`` instead. See :doc:`locales` (:issue:`271`) - **Add Lexicon.surnames, Policy.script_orders, Policy.segment_scripts, the Script enum and the DEFAULT_SCRIPT_ORDERS constant to the public API.** This is the first behavior nameparser keys on the script a name is written in, allowed only where the script itself settles a convention and never as a proxy for guessing the language. See :doc:`modules` (:issue:`271`) - **Add Lexicon.honorific_tails, the vocabulary the glued-honorific peel matches:** entries that may be split off the end of a name token. It is a narrower set than the spaced honorific vocabulary, since a glued tail has no token boundary to lean on, and every entry must also be a ``suffix_words`` entry. Extend both in one call, or adding to ``honorific_tails`` alone raises ``ValueError``. See :doc:`customize` (:issue:`308`) - **Add AmbiguityKind.SEGMENTATION, reported when a surname split had a vocabulary-supported alternative:** ``"남궁민수"`` is 남궁 + 민수 by the compound surname but 남 + 궁민수 by the single-syllable one, and longest-match had to pick. A name with only one possible split reports nothing (:issue:`271`) - **Add the Japanese locale pack locales.JA and the segmenter factory locales.ja_segmenter(), which together divide an unspaced Japanese name:** ``parser_for(locales.JA, segmenter=locales.ja_segmenter())`` reads ``山田太郎`` as family ``山田``, given ``太郎``. They are separate because no surname list can do this job, so the pack activates the stage and a third-party divider performs it. ``ja_segmenter()`` wraps `namedivider-python `_, installed with the new ``nameparser[ja]`` extra; the core stays dependency-free. ``locales.available()`` is now ``('ja', 'ru', 'tr_az', 'zh')``. See :doc:`locales` (closes :issue:`272`) - **Add Segmentation and the Segmenter type alias to the public API, plus the keyword-only Parser(segmenter=...) hook:** any callable from a token's text to a ``Segmentation`` or to ``None`` to decline. It is consulted only for scripts in ``Policy.segment_scripts``, and only where the surname vocabulary declined first. Note what that ordering means when packs are stacked: a Japanese name opening on a listed Chinese surname never reaches the segmenter, so ``高橋一郎`` still splits ``高`` + ``橋一郎``. The packs are alternatives, one per corpus. See :ref:`segmenter-contract` (:issue:`272`) - **Add a construction-time UserWarning when a parser activates segmentation for scripts nothing can divide.** ``parser_for(locales.JA)`` without ``segmenter=`` used to build a parser that behaved like a working one minus the feature, silently. It now names the dead scripts and the call to pass. Any configured segmenter or covering surname vocabulary silences it, so the default parser and the ``zh`` pack never warn. A from-scratch lexicon with no hangul surnames warns under the default policy, with ``Policy(segment_scripts=frozenset())`` offered as the deactivation. See :ref:`east-asian-names` - **Add the Script members HIRAGANA and KATAKANA.** Two members rather than one ``KANA`` because the parser treats them differently: hiragana never transcribes a foreign name, while a wholly-katakana name usually is one (:issue:`272`) **Breaking Changes** - **Change the pickle compatibility of Policy and Lexicon:** the new fields change the field layout their guarded ``__setstate__`` checks, so a pickle written by 2.0.0 raises ``ValueError`` naming the missing fields instead of loading. Re-pickle after upgrading. ``HumanName`` pickles are unaffected, since the facade pickles v1-shaped component state rather than these objects - **Change the pickle compatibility of Parser the same way, for the new segmenter field.** Re-pickle after upgrading. A ``Parser`` carrying a segmenter pickles only if that segmenter does, which a module-level function does and a closure or lambda does not (:issue:`272`) - **Change one thing about parse totality:** ``parse()`` still never raises on any input, but a user-supplied ``Parser(segmenter=...)`` runs inside the parse and its own exceptions propagate rather than being absorbed. A failure there is a bug in your callable, not a fact about the name (:issue:`272`) **Behavior Changes** - **Fix names written wholly in Han or hangul parsing given-first.** Native-script CJK now reads family-first through the new ``Policy.script_orders`` table, so ``"毛 泽东"`` gives family ``毛`` where 1.x gave family ``泽东``. A single unspaced token moves the same way, which is the easiest form of this to miss: ``"毛泽东"`` renders identically whichever field holds it, while the spaced form shows the change (``str(HumanName("毛 泽东"))`` is now ``"泽东 毛"``). No language detection is involved, and an explicit comma still wins. **Default-on**, and it reaches ``HumanName``. See :ref:`east-asian-names` (closes :issue:`271`) - **Fix unspaced Korean names not splitting.** The census surname list now ships as default vocabulary with hangul segmentation on by default, so ``"김민준"`` parses family ``김``, given ``민준`` where 1.x returned the whole string as ``first``. Rendering follows the split. Nothing but Korean is written in hangul and its surnames are a closed set, which is what makes this safe as a default rather than a pack. **Default-on**. See :doc:`customize` for the two switches (:issue:`271`) - **Fix Japanese names carrying kana parsing given-first.** The family-first rule extends to any name whose characters stay within kanji and kana while carrying at least one kana character, so ``"高橋 みなみ"`` gives family ``高橋``. The reasoning is the one hangul already uses: hiragana never transcribes a foreign name, and a transcription is kana alone, so kanji-plus-kana is a Japanese name in Japanese order. A name written **wholly in katakana** is excluded and stays positional, being predominantly a transcribed foreign name. **Default-on**. See :ref:`east-asian-names` (:issue:`272`) - **Fix names containing 〆 (U+3006, the shime mark opening Japanese surnames like 〆木) parsing given-first.** The script classifier now counts it as Han, so these names take the family-first reading like any other wholly-Han name (:issue:`303`) - **Fix the katakana middle dot ・ (U+30FB, and its halfwidth twin U+FF65) being read as part of a name rather than as a divider.** It now separates tokens exactly as a space does, so ``"マイケル・ジャクソン"`` gives given ``マイケル``, family ``ジャクソン`` where 1.x left the whole string in ``first``. Native Japanese names never contain this character, so the separation is unconditional and the policy opt-outs do **not** cover it. Rendering returns the dot as a space (:issue:`272`) - **Fix 间隔号-divided transcriptions parsing as one unsplit token.** U+00B7, the interpunct Chinese text divides a transcribed foreign name with, is now a token separator between characters of a classified script, and a name it divides keeps its source order and is never segmented. It divides only between classified-script characters, so the Catalan punt volat in ``Gal·la`` is untouched. The Japanese nakaguro is deliberately not a transcription marker, so ``高橋・一郎`` keeps its family-first reading (:issue:`298`) - **Fix spaced CJK postnominal honorifics parsing as name parts.** 씨, 박사, 선생님, 교수님, 군, 양, 先生, 女士, 小姐, 博士, 教授, 様 and 氏 now route to ``suffix``, so ``王小明 先生`` reads family ``王小明`` where the family-first default had made 先生 the given name. See :ref:`east-asian-names` (closes :issue:`307`) - **Fix glued CJK honorifics parsing as part of the name.** ``田中さん``, ``김민준씨`` and ``王小明先生`` now split the honorific off the end of the name into ``suffix``. Previously it stayed in the name: ``田中さん`` was entirely the family name, and ``김민준씨`` gave given 민준씨. The peel runs before the name is split or ordered, so ``김민준씨`` still divides into family 김, given 민준. Only entries that can never end a name peel; 양, 군, 氏, 博士 and 殿 are recognized in their spaced form only, since 김지양 is a given name and some ninety Japanese surnames end in 殿. **Default-on**, and a lone family name written with a glued honorific now divides where it did not. See :ref:`east-asian-names` for the full set and :doc:`customize` for the off-switch (closes :issue:`308`) - **Fix a comma or a 间隔号 stopping the glued-honorific peel.** ``김, 민준씨`` now gives family 김, given 민준, suffix 씨, the same as the spaced ``김 민준씨``, and likewise ``田中, 太郎さん``. Previously each left the honorific inside the name. Both marks say where a name divides into surname and given, and an honorific is not part of the name in either reading. What a comma does instead is say which runs to look in: the two around a family comma. Anything past those is out of reach, so ``김, 민준 지훈씨`` peels while ``김, 민준, 지훈씨`` does not. **Default-on**. See :ref:`east-asian-names` (closes :issue:`312`) - **Fix a glued honorific staying inside the name when the whole post-comma remainder is a credential.** ``田中さん, V.`` and ``田中さん, Ph. D.`` now give up さん to ``suffix`` the way ``田中さん, PhD`` already did. The peel had been taking the post-comma run for name text on the strength of the comma alone, walking into the credentials and abandoning the peel there. That run is now tested with the same rule that decides the comma structure, and declined where it is credentials, provided the part before the comma offers a peel site of its own. Where the credential itself lands is still the comma's business and still differs by spelling. **Default-on** (closes :issue:`319`) - **Fix an ASCII period after a CJK honorific stopping it being recognized.** ``씨.``, ``様.``, ``氏.``, ``님.``, ``군.``, ``양.`` and ``殿.`` now route to ``suffix`` like their periodless spellings, where the trailing period had left them inside the name. The cause was v1's initial regex, whose ``\w`` is Unicode-aware and matched a hangul syllable or Han ideograph as readily as a letter; a veto written for Latin was being asked of scripts it was never about. Alphabets keep their initials untouched, and ``"А. С. Пушкин"`` is unaffected. Read *period* strictly: only the ASCII full stop is covered, so ``"김민준 씨."`` written with the fullwidth stop still reads the honorific as the family name (superseded in 2.3.0, above). **Default-on** (:issue:`320`) - **Fix NFD-decomposed input missing the East Asian defaults entirely.** Script classification now normalizes to NFC before deciding, so a Korean or Japanese name typed on macOS, where decomposed text is routine, gets the same order rule as its composed twin. Segmentation matching deliberately stays raw, so an unspaced NFD hangul name is ordered correctly but not split, rather than being split in the wrong place. One gotcha: parse output preserves the encoding it was given, so for NFD input ``name.family == "김"`` is ``False`` even though it is the same name. Compare NFC-normalized text when comparing across encodings. See :ref:`east-asian-names` (:issue:`272`) - **Fix the Ukrainian conjunction й not joining the pieces around it.** It is the euphonic alternate of ``і``, chosen by the surrounding sounds rather than by meaning, so real Ukrainian data carries both spellings. ``"Олесь й Олена Коваленки"`` now gives given ``"Олесь й Олена"`` where the ``й`` previously landed in ``middle``. Same treatment as the ``и``/``і`` entries added in 2.0.0: the conjunction joins only once the name has enough pieces, and a punctuated initial still wins, so ``"Й. Сліпий"`` is unaffected. Raised in a comment on :issue:`267` - **Add the Japanese maiden-name marker 旧姓 to the default vocabulary.** ``"山田花子 旧姓 佐藤"`` now gives family ``山田花子`` and maiden ``佐藤``, where 1.4.0 left the marker in the name. It sits beside the Cyrillic ``урожд.`` and German ``geb.`` entries rather than in ``locales.JA``, on the rule that admitted those: a native-script marker cannot collide with a Latin-script name, so it is safe as a default. Matching is whole-token, so the marker has to *be* a token, which for Japanese means a space or a configured delimiter must divide it from the name. The fullwidth colon does not, so ``"山田(旧姓:佐藤)"`` still returns maiden ``"旧姓:佐藤"``; that one wants the head-peel :issue:`317` tracks. **Default-on**. See :doc:`customize` (:issue:`309`) - **Fix a maiden marker inside bracketed content staying in the maiden value.** Where a delimiter pair is routed to ``maiden`` by ``Policy(maiden_delimiters=...)``, a marker at the head of the clause is now dropped the way it always has been in the bare form, so ``"Jane Smith (née Jones)"`` gives maiden ``Jones``, the same answer as the unbracketed spelling. Each clause loses its own leading marker, and only where the clause holds more than one token, so ``"Jane Smith (Nee) (Jones)"`` still gives maiden ``Nee Jones`` (``Nee`` is a real surname). Scope before you count on it: ``Policy.maiden_delimiters`` is **empty by default**, so under the default policy brackets route to ``nickname`` and none of this applies. See :doc:`customize` (closes :issue:`329`) **Documentation** - **Correct the documented scope of the period-abbreviation title rule.** It was described as applying to "a leading word", which was never true of any comma form: the rule runs at the front of the part that carries the given name, which after a family comma is the part *after* the comma, so ``"Morse, Det. Insp. Jane"`` gives title ``Det. Insp.``. Behavior is unchanged and matches 1.4.0; only the description was wrong * 2.0.0 - July 27, 2026 Two release candidates preceded this release (rc1 on 2026-07-23, rc2 on 2026-07-26). Please report anything the migration missed on `issue #284 `_. The notes below describe 2.0.0 as a whole. nameparser 2.0 adds a new parsing API alongside ``HumanName``. ``parse()`` returns an immutable ``ParsedName`` whose seven fields are named for what they are (``given``, ``family``) rather than where they sit in a Western name, configured by two frozen value objects -- a ``Lexicon`` of vocabulary and a ``Policy`` of behavior -- instead of a mutable global. ``HumanName`` keeps working: it is now a compatibility facade over the same pipeline, and it stays through 2.x. The removals below are the deprecations announced in 1.3.0 and 1.4.0 coming due; if your code runs warning-free on 1.4.0, most of them will not affect you. See :doc:`migrate` for the field-by-field map. **The 2.0 API** - **Add parse(text), returning an immutable ParsedName with seven fields** -- ``title``, ``given``, ``middle``, ``family``, ``suffix``, ``nickname``, ``maiden`` -- plus the derived views ``given_names``, ``surnames``, ``family_base`` and ``family_particles``. Parsing is a pure function of the text, a ``Lexicon`` and a ``Policy``: nothing global is consulted and nothing is mutated - **Add Lexicon, the frozen vocabulary object.** ``Lexicon.default()`` is the shipped vocabulary and ``Lexicon.empty()`` is a blank one; ``add(**entries)``, ``remove(**entries)`` and field-wise ``|`` all return new instances. Its fields are ``titles``, ``given_name_titles``, ``suffix_acronyms``, ``suffix_words``, ``suffix_acronyms_ambiguous``, ``particles``, ``particles_ambiguous``, ``conjunctions``, ``bound_given_names``, ``maiden_markers`` and ``capitalization_exceptions`` - Add ``Policy``, the frozen behavior object: ``name_order``, ``patronymic_rules``, ``middle_as_family``, ``nickname_delimiters``, ``maiden_delimiters``, ``extra_suffix_delimiters``, ``lenient_comma_suffixes``, ``strip_emoji`` and ``strip_bidi`` - Add ``Parser``, a reusable parser bound to a lexicon and policy (``Parser(lexicon=..., policy=...).parse(text)``), and ``parser_for(*locales, base=None)``, which folds locale packs onto a base parser - Add ``name_order`` and the constants ``GIVEN_FIRST``, ``FAMILY_FIRST`` and ``FAMILY_FIRST_GIVEN_LAST``, so a family-first name can be parsed as written rather than reordered by hand (the configuration half of :issue:`270`; the locale packs below complete it) - Add ``PatronymicRule`` with the members ``EAST_SLAVIC`` and ``TURKIC``. v1's single ``patronymic_name_order`` flag enabled both detectors at once; ``Policy(patronymic_rules=...)`` lets you enable either one alone - **Add PolicyPatch and the UNSET sentinel for partial policy deltas that compose** -- set-valued fields union, scalar fields override with later winning. This is the mechanism locale packs are built from, and ``UNSET`` is only needed when you must distinguish "not set" from a real ``False`` or ``None`` - **Add Token, Span and Role:** every field is backed by tokens carrying exact ``(start, end)`` offsets into the original string, reachable with ``tokens_for(Role.GIVEN)``. This replaces v1's ``*_list`` attributes and makes it possible to highlight or re-slice the input the parse came from. ``Role`` is a ``StrEnum``, so members compare and stringify as their field names (``Role.GIVEN == "given"``), matching ``AmbiguityKind``; ``tokens_for()`` accepts a role's string name too, and raises ``ValueError`` for an unknown role - Add ``STABLE_TAGS`` to the public API: the four documented ``Token.tags`` values (``particle``, ``conjunction``, ``initial``, ``joined``) - Add ``Policy.patched(patch)``, applying a ``PolicyPatch`` directly without wrapping it in a locale pack - **Add Parser.matches(a, b) and Parser.capitalized(name):** the ``ParsedName`` methods of the same names fall back to the default configuration for str/omitted arguments, which is silently wrong for names parsed with a custom ``Parser`` - Add ``Parser.revise(name, **fields)``: ``ParsedName.replace()`` with the replacement text classified by the parser's vocabulary, so particle/initial/suffix-join behavior survives the edit - **Add Ambiguity and the AmbiguityKind enum, so a parse reports what it had to guess at instead of guessing silently.** The kinds emitted today are ``particle-or-given``, ``suffix-or-name``, ``suffix-or-nickname``, ``unbalanced-delimiter`` and ``comma-structure``; ``order`` is reserved and not yet emitted. The two suffix kinds cover the post-nominals that are also ordinary words: ``"John Smith MA"`` reports that ``MA`` was read as a credential rather than a surname, and ``"JEFFREY (JD) BRICKEN"`` that the delimited ``JD`` was read as a nickname rather than a suffix. A reading the vocabulary settles on its own — ``"John Smith M.A."``, ``"Andrew Perkins (MBA)"`` — is not a guess and reports nothing - **Add ParsedName output and comparison methods:** ``render(spec)``, ``initials()``, ``capitalized()`` (which returns a new value rather than mutating in place), ``as_dict()`` (whose ``include_empty`` flag is keyword-only, unlike ``HumanName.as_dict()``'s), ``replace(**fields)``, ``matches()`` and ``comparison_key()`` - Add ``Locale``, the public pack type. Writing your own needs no registration -- construct a ``Locale`` and pass it to ``parser_for()`` - Ship a fully typed public API (PEP 561): the core modules are checked under strict mypy settings, and nameparser 2.0 has no runtime dependencies **Breaking Changes** - Raise the minimum Python to 3.11 and drop the last runtime dependency, ``typing_extensions`` (:issue:`257`). Python 3.10 is no longer supported - **Remove HumanName.__eq__ and __hash__ (deprecated in 1.3.0, #223):** instances now compare and hash by identity, so ``HumanName("John Smith") == "John Smith"`` is ``False`` where 1.x returned ``True``. **This changes result silently rather than raising** -- it is the one removal that can pass unnoticed into production. Use ``matches()`` to ask whether two names are the same, and ``comparison_key()`` as a dict key or sort key - **Remove every v1 parsing hook and subclass extension point:** ``pre_process``, ``post_process``, ``parse_pieces``, ``parse_nicknames``, ``join_on_conjunctions``, the ``is_*`` predicates, ``cap_word``/``cap_piece``, ``handle_firstnames``, ``fix_phd`` and the rest. A subclass that overrides one gets a ``DeprecationWarning`` at construction naming the hooks, because the facade delegates to the core ``Parser`` and never calls them (closes :issue:`280`). Customize through ``Lexicon``/``Policy`` instead - **Remove regex configuration:** ``CONSTANTS.regexes`` is now a read-only proxy. Reads still work, but ``CONSTANTS.regexes.bidi = False``, item assignment and ``Constants(regexes=...)`` all raise ``TypeError``. **If you followed 1.3.1's advice to keep bidi marks with** ``CONSTANTS.regexes.bidi = False``, that opt-out is now ``Policy(strip_bidi=False)`` on the 2.0 API; the same applies to ``regexes.emoji`` and ``Policy(strip_emoji=False)`` - **Remove bytes input and the encoding argument (#245):** passing ``bytes`` to ``HumanName`` or to a set manager raises ``TypeError`` with a decode hint, and ``SetManager.add_with_encoding()`` and ``DEFAULT_ENCODING`` are gone. Decode first, then use ``add()`` - **Remove SetManager.__call__ (#243); iterate the manager or call set(manager).** ``remove()`` of a missing member now raises ``KeyError`` like ``set.remove`` -- ``discard()`` is the ignore-missing form -- and the set operators ``|``, ``&``, ``-`` and ``^`` return a plain ``set`` - **Remove HumanName slice access and item assignment (#258):** ``name[1:-3]`` and ``name['first'] = value`` raise ``TypeError``. String-key reads (``name['first']``) and iteration are unchanged; assign fields as plain attributes - Remove ``Constants.empty_attribute_default`` (:issue:`255`): empty fields are always ``''``. Assigning it raises ``AttributeError``; a pickle carrying the key still loads, with the value ignored - Remove ``constants=None`` (:issue:`261`): both ``HumanName(..., constants=None)`` and ``hn.C = None`` raise ``TypeError``. Use ``Constants()`` for library defaults or ``CONSTANTS.copy()`` for a private snapshot - **Remove silent unknown-key access on the mapping managers (#256):** ``CONSTANTS.capitalization_exceptions.typo`` and ``CONSTANTS.regexes.typo`` raise ``AttributeError`` naming the miss, instead of returning ``None``/``EMPTY_REGEX`` (``capitalization_exceptions`` also lists the known keys). ``.get()`` remains available on both for intentional soft access - Remove support for ``Constants`` pickles written by nameparser 1.2.x or earlier (:issue:`279`): loading one raises ``ValueError`` telling you to re-pickle under 1.3/1.4 first - Remove the dead ``regexes.no_vowels`` pattern (:issue:`268`) and the ``Constants.suffixes_prefixes_titles`` cached union; neither was read by the parser - Change ``HumanName``'s ``*_list`` attributes to read-only properties: they remain readable snapshots, but ``name.first_list = [...]`` now raises ``AttributeError`` - Change the ``Constants`` constructor to keyword-only; positional construction no longer works **Behavior Changes** - **Recognize maiden-name markers** -- ``née``, ``nee``, ``geb.``, ``roz.`` and the Scandinavian participle forms -- and route the following name to the new ``maiden`` field. 1.x folded them into ``middle``/``last``, so ``"Jane Smith née Jones"`` parsed as ``middle="Smith née"`` (closes :issue:`274`) - **Recognize typographic nickname delimiters by default in both APIs (closes #273):** smart quotes (``“Jack”``), German and Polish low-high quotes (``„Hansi“``), Swedish right-right quotes (``”Ann”``), guillemets in either direction (``«Petit»``, ``»Hansi«``), CJK corner brackets (``「タロ」``, ``『ハナ』``) and fullwidth parentheses. In 1.x these leaked into ``middle`` as literal text. Curly *single* quotes stay excluded, because U+2019 is the apostrophe in "O’Connor" - **Fix the pre-comma piece being routed to first when everything after the comma is a suffix or title:** ``"Andrews, M.D."`` now reads family ``Andrews`` / suffix ``M.D.`` where 1.x read given ``M.D.`` / family ``Andrews``, and ``"Smith, Dr."`` moves ``Smith`` from ``first`` to ``family`` (the title was already correct in 1.x). The piece before a comma is definitionally the family name - **Fix a lone recognized trailing suffix with no comma being routed to first/last:** ``"Johnson PhD"`` and ``"Mr. Johnson PhD"`` now keep the suffix in ``suffix`` - **Fix a split "Ph. D." credential being read as two tokens; it now classifies as one suffix, replacing v1's fix_phd hook.** This now holds wherever the credential sits: 1.x healed the pair only when it trailed, so ``"Ph. D. John Smith"`` parsed as title ``Ph.`` / given ``D.`` with the real given name pushed to ``middle``; it now reads given ``John``, family ``Smith``, suffix ``Ph. D.`` - **Fix chargé d'affaires:** shipped as one unmatchable ``TITLES`` entry since it was added, it is now two chainable entries (``chargé``, ``d'affaires``), so the title is recognized; the unaccented spelling ``charge`` ships too, like ``attaché``/``attache`` - Remove seven multi-word ``SUFFIX_ACRONYMS`` entries (``leed ap``, ``nicet i``–``nicet iv``, ``psm i``, ``psm ii``) that could never match in any release; splitting them would swallow real names ("John Leed", "Smith, A.P."), so they are dropped instead - **Add a UserWarning when a multi-word entry is stored in a per-word Lexicon field or as a capitalization_exceptions key** -- such entries can never match - **Parse an input with no alphanumeric character to an empty name in both APIs.** 1.x kept pure punctuation as a name part, so ``"."`` gave ``first="."`` and ``bool()`` was ``True``; 2.0 empties it, keeping ``bool(parse(x))`` an honest "did I get a name?" test. The check is Unicode-aware, so names in any script are unaffected; only inputs that are entirely punctuation or symbols (``"."``, ``"- -"``) change. Junk embedded in a name with real content -- the stray dot in ``"John . Smith"`` -- is still kept, since that parse is already truthy - **Fold a leading never-given particle into the family name.** Note that ``Lexicon.particles_ambiguous`` is the **complement** of v1's ``non_first_name_prefixes``, not a rename -- it lists the particles that *may* double as a given name, where v1 listed the ones that may not. Copying a v1 customization across without inverting it silently reverses the behavior; see :doc:`migrate` - **Add ma and do to suffix_acronyms_ambiguous, the set of post-nominals that are also ordinary surnames.** An entry there is read as a credential when the name can spare it — written with periods (``"John Smith M.A."``), or when removing it still leaves a given *and* a family name (``"John Smith MA"`` → suffix ``MA``). With only two pieces to go around, the surname reading wins instead: ``"Jack Ma"`` and ``"Anh Do"`` keep their family names. As a side effect, a parenthesized or quoted ``"(MA)"``/``"(DO)"`` now falls through to nickname parsing rather than escaping to ``suffix``, since inside delimiters the nickname reading is the plausible one - **Change comparison_key() and matches() in both APIs to fold with str.casefold() where 1.4 used str.lower(), so Unicode case pairs compare equal** -- ``"STRASSE"`` matches ``"Straße"``, and a Greek final sigma matches its regular form. This is strictly more permissive: anything 1.4 matched still matches. Vocabulary normalization deliberately still uses ``lower()``, for v1 parity - **Change the 2.0 API's default render()/str() spec to show every non-empty field:** ``'{title} {given} "{nickname}" {middle} {family} ({maiden}) {suffix}'``. The quoted nickname round-trips exactly; the parenthesized maiden re-parses as a nickname, a deliberate choice of presentation over lossless round-trip -- use ``née {maiden}`` in a custom spec if you need it to survive a reparse. ``HumanName`` keeps v1's own ``string_format`` default, unchanged - **Change delimiter-overlap precedence in the 2.0 API:** a pair listed in ``Policy.maiden_delimiters`` is dropped from the effective nickname set, so ``Policy(maiden_delimiters={("(", ")")})`` alone routes parenthesized content to ``maiden``. The default nickname set is exported as ``DEFAULT_NICKNAME_DELIMITERS``. ``HumanName`` keeps v1's nickname-wins precedence, so no existing behavior changes - **Change suffix-delimiter rendering when a custom suffix delimiter is configured:** for ``suffix_delimiter="/"`` and ``"John Smith, RN/CRNA"``, 1.x split the token and rendered ``suffix="RN, CRNA"`` where 2.0 keeps it whole as ``"RN/CRNA"``. Role assignment is unchanged; only rendering differs - **Correct a long-standing typo in the shipped vocabulary that 1.x carried:** ``actor`` and ``television`` had trailing spaces in ``TITLES``, so ``"actor" in TITLES`` was ``False``. **Parse output is unchanged** -- the parser normalizes entries on ingest, which is exactly what let the typo go unnoticed -- but code that tests membership against the exported constants directly will see corrected results. The data modules now assert their invariants at import time, alongside the ones ``prefixes.py`` already checked; entries must be stored lowercase and whitespace-free, so this class of typo now fails the build **International name support** - **Add nameparser.locales with the first two packs:** ``locales.RU`` (East Slavic patronymic order) and ``locales.TR_AZ`` (Turkic patronymic markers). Packs are pure data folded in at ``parser_for(locales.RU)``, they compose (``parser_for(locales.RU, locales.TR_AZ)`` unions the rules), and they are never auto-detected -- there is no reliable way to detect a name's language from the name alone. ``locales.available()`` and ``locales.get(code)`` look packs up by code; loading is lazy, so importing the package imports no pack (completes :issue:`270`; :issue:`271`/#272/#146 stay staged for 2.x) - **Add non-Latin vocabulary to the default lexicon (#269):** Cyrillic, Greek, Arabic and Hebrew titles, conjunctions and name particles. Native-script entries cannot collide with Latin-script names, which is what makes them safe to enable by default. Deferred pending vetting: the Cyrillic ``мл``/``ст`` suffixes and the bare Greek ``κ`` title, which collides with the initial-plus-surname shape. Behavior note: ``محمد بن سلمان`` now chains ``بن`` onto the family name where 1.x read it as a middle name - **Add Arabic-script bound given names (#269):** ``عبد``, the kunya pair ``أبو``/``ابو``, and ``أم``/``ام`` join the following word into the given name exactly as their transliterations (``abdul``, ``abu``, ``umm``) always did, so ``عبد الرحمن محمد`` parses given ``عبد الرحمن``, family ``محمد`` where 1.x split it into given plus middle - **Add Arabic honorific titles and the conjunction و (#269):** the doctor, professor, hajj, sheikha and engineer forms (``الدكتور``/``الدكتورة``/``دكتور``/``دكتورة``, ``الأستاذ``/``الأستاذة``/``أستاذ``/``أستاذة``, ``الحاج``/``الحاجة``, ``الشيخة``, ``مهندس``) as given-name titles, since Arabic honorifics precede the given name like ``الشيخ``. Deferred under the collision rule: bare ``سيد``/``شيخ``/``أمير``/``سلطان`` (all common given names), the ``د.`` abbreviation (bare ``د`` would swallow initials), and the Ottoman post-nominals ``باشا``/``بك``/``أفندي`` (which survive as family names) - **Add Hebrew honorifics and post-nominals, and Devanagari titles (#269):** the Israeli honorifics ``גברת``, ``פרופ'``/``פרופ׳``, ``פרופסור``, ``עו"ד``/``עו״ד`` and ``הרב`` as titles; ``ז"ל``/``ז״ל`` and ``שליט"א``/``שליט״א`` as suffixes, in both gershayim spellings; and Devanagari ``श्री``, ``श्रीमती`` and ``डॉ``. Latin ``sri``/``shri`` were deliberately not added, because they collide with real given names where the native script cannot. Deferred: bare ``רב`` (an ordinary word meaning "many") and ``בר`` as a particle (Bar is a common modern given name) **Compatibility layer** - **Reimplement HumanName as a facade over the 2.0 pipeline, and Constants as a shim resolving to a (Lexicon, Policy) snapshot with a shared parser cache.** Fields, aggregates, mutation through ``name.C.titles.add(...)``, rendering defaults, ``capitalize()``, ``matches()``, ``comparison_key()``, iteration, ``as_dict()`` and pickling are all preserved, and ``nameparser.parser`` and ``nameparser.config`` remain importable. The compatibility layer ships through 2.x and is removed in 3.0 - **Note that CONSTANTS.capitalize_name and force_mixed_case_capitalization are still honored through the facade, but the 2.0 API never capitalizes during parse()** -- call ``capitalized()`` when you want it - Add ``Role`` members (and their string values ``given``/``family``) as valid ``HumanName`` subscript keys: ``hn[Role.GIVEN]`` returns ``hn.first`` - **Add a UserWarning when assigning HumanName.given or .family:** the facade spells those attributes ``first``/``last``, so the assignment creates an inert stray attribute while the parse keeps the old value. The assignment still happens (v1-legal ad-hoc attributes keep working); the warning names the v1 spelling to use **Command line** - **Rewrite python -m nameparser over the 2.0 API with a real argument parser.** It prints the parse plus its capitalized form and initials by default, takes ``--json`` to emit the fields as JSON (``python -m nameparser --json "Doe, John"``), takes ``--locale CODE`` to apply a pack, and exits with a usage message on bad arguments **Documentation** - **Rewrite the documentation new-API-first:** a new front page and README, ``usage.rst`` as a tour of the 2.0 API, a principle-first ``customize.rst``, a reference split into the 2.0 API and the compatibility layer, and new pages for :doc:`concepts`, :doc:`locales` and :doc:`migrate` -- the last carrying full attribute and configuration maps from v1 names to 2.0 names (:issue:`262`). The 1.x documentation remains online as the readthedocs ``stable`` build Parsing changes were checked against a 652-name differential corpus -- names harvested from the v1 test banks, plus names reported in the issue tracker -- and everything not listed above parses identically between 1.4.0 and 2.0. The harness lives in ``tools/differential/`` in the source repository (it is development tooling, not part of the installed package) -- see its README to reproduce the comparison against your own names. **Changed since 2.0.0rc1** (for anyone who tested the release candidate) - ``Role`` became a ``StrEnum``; ``str(Role.GIVEN)`` is now ``"given"`` - ``ParsedName.tokens_for()`` raises ``ValueError`` for unknown roles instead of returning no tokens; it also accepts role-name strings - ``ParsedName.as_dict()``'s ``include_empty`` is keyword-only - ``HumanName`` subscripting accepts ``Role`` members - Assigning ``HumanName.given``/``.family`` warns (the facade spells them ``first``/``last``; the assignment was and remains an inert stray attribute) - The eight multi-word vocabulary entries that could never match were repaired (``chargé d'affaires`` split; seven credential acronyms removed), and storing a new multi-word entry now warns; those eight are dropped silently, not warned about, when a restored ``Constants`` pickle carries all eight of them (the pre-2.0 signature) - ``PolicyPatch``'s repr shows only the fields a patch sets, instead of all nine with UNSET sentinels - New since rc1: ``STABLE_TAGS``, ``Policy.patched()``, ``Parser.matches()``, ``Parser.capitalized()``, and ``Parser.revise()`` -- see the API section above * 1.4.0 - July 12, 2026 - **Add Constants.copy(), a detached deep copy that preserves the source instance's current customizations (unlike Constants(), which always starts from library defaults)** -- useful as ``CONSTANTS.copy()`` for a private snapshot of the shared config (:issue:`260`) - **Deprecate passing constants=None to HumanName (or assigning hn.C = None):** it silently builds a fresh ``Constants()``, discarding any customizations the caller may have expected to carry over from the shared ``CONSTANTS``. Emits ``DeprecationWarning``; will raise ``TypeError`` in 2.0. Use ``constants=Constants()`` for fresh library defaults or ``constants=CONSTANTS.copy()`` for a private snapshot instead (closes :issue:`260`) - **Deprecate assigning Constants.empty_attribute_default for removal in 2.0 (#255):** once ``None`` support goes, the only legal value left is the default ``''``, so a dial with one position isn't configuration. Emits ``DeprecationWarning``; reading the attribute is unaffected - **Deprecate unknown-key attribute access on TupleManager/RegexTupleManager (CONSTANTS.regexes.typo, CONSTANTS.capitalization_exceptions.typo, etc.) for removal in 2.0 (#256):** a misspelled or omitted key currently degrades silently (``None``/``EMPTY_REGEX``) with no traceback pointing at the typo. Emits ``DeprecationWarning`` naming the miss and the known keys; will raise ``AttributeError`` in 2.0. ``.get()`` remains available for intentional soft access - **Deprecate HumanName slice access (name[1:-3]) and item assignment (name['first'] = value) for removal in 2.0 (#258):** field access by position has no real use case, and item assignment duplicates plain attribute assignment. Both emit ``DeprecationWarning``; string-key access (``name['first']``) is unaffected - **Deprecate SetManager.add_with_encoding() itself for removal in 2.0 (#245), regardless of argument type:** use ``add()`` instead (decoding bytes first). Previously only the ``bytes`` path warned; the ``str`` path was silent even though the whole method goes away - **Deprecate loading a legacy-format Constants pickle (written by nameparser <= 1.2.x, before the 1.3.0 pickle fix) for removal in 2.0 (#279):** ``__setstate__``'s migration shim currently skips the stale computed-property key silently. Emits ``DeprecationWarning`` once per call telling users to re-pickle; will raise ``ValueError`` in 2.0 - Fix the ``"Lastname, Firstname"`` comma format not being recognized when the input uses the Arabic comma ``،`` (U+060C, the standard comma in Arabic/Persian/Urdu text) or the fullwidth CJK comma ``,`` (U+FF0C) instead of the ASCII comma: both variants now also split the format and no longer leak into the parsed output (closes :issue:`265`) * 1.3.1 - July 11, 2026 - Fix invisible Unicode bidirectional control characters (LRM/RLM/ALM, the embedding/override marks, and the isolates U+2066–U+2069) surviving parsing and sticking to ``first``/``last``/etc., so a copy-pasted right-to-left name silently failed equality and dedup. They are now stripped in preprocessing like emoji; disable via ``CONSTANTS.regexes.bidi = False`` (closes :issue:`266`) - **Fix str() corrupting name text containing the substring "None" when empty_attribute_default is None (e.g. "Nonez Smith" rendered as "z Smith"):** empty attributes are now substituted as ``''`` before the format string is applied, instead of scrubbing the interpolated ``"None"`` from the output afterward (closes :issue:`254`) * 1.3.0 - July 5, 2026 **Breaking Changes & Deprecations** - **Deprecate HumanName.__eq__ and __hash__ for removal in 2.0 (#223):** the current design's three promises — case-insensitive equality, equality with plain strings, and hashability — are mutually inconsistent (equal objects can hash differently), equality depends on ``string_format``, and ``maiden`` is invisible to it. Both now emit ``DeprecationWarning`` naming the replacement; behavior is otherwise unchanged until 2.0 (closes :issue:`224`) - **Deprecate bytes input for removal in 2.0 (#245):** passing ``bytes`` to ``HumanName``/``full_name`` or to ``SetManager.add()``/``add_with_encoding()`` now emits ``DeprecationWarning`` — decode first, e.g. ``value.decode('utf-8')``. The ``encoding`` constructor argument is deprecated with it - **Deprecate SetManager.__call__ for removal in 2.0 (#243):** calling a manager returns the raw underlying set, so mutating the result bypasses normalization and cache invalidation; iterate the manager or copy with ``set(manager)`` instead - **Add SetManager.discard(), and deprecate remove() of a missing member (#243):** it currently does nothing but will raise ``KeyError`` in 2.0, matching ``set.remove``; use ``discard()`` for intentional ignore-missing removal. Removing present members is unchanged and does not warn - **Fix HumanName acting as its own iterator with a stored cursor:** breaking out of a loop, iterating in nested loops, or calling ``len(name)`` mid-loop corrupted subsequent iteration; ``iter(name)`` now returns a fresh independent iterator each time. ``next(name)`` on the instance itself (undocumented) now raises ``TypeError`` — call ``next(iter(name))`` instead (closes :issue:`225`) - **Remove the vestigial unparsable attribute:** the guard that was meant to set it has been unreachable since 2013 (v0.2.9), so it has reported ``False`` for every parsed name for over a decade; check ``len(name) == 0`` to detect an empty parse - Remove ``__ne__``; Python 3 derives ``!=`` from ``__eq__`` automatically - Change internal initials helper ``__process_initial__`` to ``_process_initial``: double-underscore-both-sides names are reserved for Python special methods; subclasses overriding the old name must rename their override - Change ``REGEXES`` from a ``set`` of ``(name, pattern)`` tuples to a ``dict``, so a duplicate name is a visible overwrite in the source instead of a nondeterministic winner at import time; code iterating ``REGEXES`` directly now gets keys instead of pairs — use ``.items()`` (:issue:`227`) - Change ``CAPITALIZATION_EXCEPTIONS`` from a tuple of ``(key, value)`` tuples to a ``dict``; code iterating it directly now gets keys instead of pairs — use ``.items()`` (:issue:`233`) **Behavior Changes (affect existing parse output)** - Add ``bound_first_names`` set to ``Constants``; bound Arabic given-name prefixes (``abdul``, ``abu``, etc.) now join forward to form a single first name (e.g. ``"abdul salam ahmed salem"`` → ``first="abdul salam"``, ``middle="ahmed"``, ``last="salem"``). Disable via ``CONSTANTS.bound_first_names.clear()``. **Default-on: changes parsing output for names with these prefixes.** (:issue:`150`) - Treat an unrecognized, multi-letter token ending in a period in the leading title run (before the first name is set), e.g. ``"Major."``, as a ``title`` instead of a ``first`` name; internal-period abbreviations (``"E.T."``) and single-letter initials (``"J."``) are unaffected. **Default-on: changes parsing of names with a leading unknown period-abbreviation** (closes :issue:`109`) - **Fix parsing writing back into the Constants it reads (usually the shared module-level CONSTANTS):** pieces derived while parsing a name — period-joined titles/suffixes like ``"Lt.Gov."`` and conjunction-joined pieces like ``"Mr. and Mrs."`` or ``"von und zu"`` — are now tracked per parse instead of being permanently ``add()``-ed to the config, so parse results no longer depend on which names were parsed earlier in the process and parsing no longer mutates shared state across threads - Fix ``__hash__`` to lowercase the name like ``__eq__`` does, so equal ``HumanName`` instances hash equal and behave correctly in sets and dicts **New comparison methods** - **Add matches() and comparison_key() for explicit name comparison:** ``matches()`` compares parsed components case-insensitively (parsing ``str`` arguments first, so ``name.matches("Smith, John")`` and ``name.matches("John Smith")`` both match) and ``comparison_key()`` returns a hashable tuple of the seven components for dedup, dict keys, and sorting (:issue:`224`) **New name fields** - Add a first-class ``maiden`` field and ``maiden_delimiters`` to ``Constants``, so a delimiter (e.g. parenthesis) can be routed to ``maiden`` instead of ``nickname`` for alternate/maiden surnames, e.g. ``"Baker (Johnson), Jenny"`` (closes :issue:`22`) - Add ``given_names`` (and ``given_names_list``) attribute as aggregate of first and middle names, mirroring ``surnames`` (closes :issue:`157`) - Add ``last_base``, ``last_prefixes`` (and ``_list`` variants) for splitting last-name prefix particles (tussenvoegsels) from the core surname (:issue:`130`, :issue:`132`) **New customization options** - Add ``initials_separator`` to ``Constants`` and ``HumanName`` to control spacing between consecutive initials within a name group (:issue:`171`) - Add ``suffix_delimiter`` to ``Constants`` and ``HumanName`` for parsing suffixes separated by arbitrary delimiters, e.g. ``"RN - CRNA"`` (:issue:`156`) - Add ``nickname_delimiters`` to ``Constants`` for registering additional nickname-delimiter regex patterns at runtime, without subclassing (closes :issue:`110`, :issue:`112`) - Add ``suffix_acronyms_ambiguous`` to ``Constants`` for acronym suffixes that also read as given-name nicknames (e.g. ``"JD"``, ``"Ed"``), used when disambiguating parenthesized/quoted content (:issue:`111`) **International name support** - Add ``patronymic_name_order`` flag to ``Constants`` and ``HumanName`` for opt-in detection and reordering of Russian formal-order names (Surname GivenName Patronymic) (:issue:`85`) - Add Turkic (Azerbaijani/Central-Asian) patronymic detection to ``patronymic_name_order``, rotating the reversed 4-token formal shape (``Surname GivenName PatronymicRoot Marker``, e.g. ``oglu``/``qizi``) into Western order (:issue:`185`) - Add ``middle_name_as_last`` flag to ``Constants`` and ``HumanName`` for opt-in folding of middle names into the last name, for naming systems with no middle-name concept (e.g. Arabic patronymic chaining) (:issue:`133`) - **Add non_first_name_prefixes to Constants:** a leading particle that is never a first name (e.g. ``"de Mesnil"``, ``"dos Santos"``) now parses as a surname with an empty first name, instead of treating the particle as the first name (closes :issue:`121`) - Add international honorifics to ``TITLES`` (:issue:`187`) - Add German/Austrian nobility and ecclesiastical titles to ``TITLES`` (closes :issue:`101`) - Add German/Dutch last-name prefixes and title/degree suffixes; fix ``join_on_conjunctions()`` to register multi-word prefix chains (e.g. ``"von und zu"``) as prefixes, mirroring existing title handling (closes :issue:`18`) **Parsing fixes** - Fix suffix boundary lookup for prefixed last names with a title before and after (e.g. ``"dr Vincent van Gogh dr"`` producing a corrupted middle name) (closes :issue:`100`) - **Fix a repeated prefix word in a prefix chain (e.g. "Juan de la de la Vega") silently dropping the earlier occurrence in join_on_conjunctions():** value-based ``pieces.index(prefix)`` lookups re-found the wrong occurrence once the list had already been mutated by prior joins; prefix positions are now tracked positionally instead of re-derived by value (closes :issue:`208`) - Fix a trailing suffix being silently dropped after an empty comma segment, e.g. ``"Doe, John,, Jr."`` losing the ``"Jr."`` - Fix degenerate comma input (a bare ``","`` or an empty comma segment, e.g. ``"Doe,, Jr."``, ``"John Doe, Jr.,,"``) leaving an empty-string member in ``first_list``, ``last_list``, or ``suffix_list``; whitespace-only tokens assigned via the setters are dropped the same way - Fix suffix-shaped parenthesized/quoted content (e.g. ``"(Ret)"``, ``"(MBA)"``) being misclassified as a nickname instead of a suffix (closes :issue:`111`) - Fix single-character symbol conjunctions (e.g. ``"&"``, ``"/"``) being ignored in short names (:issue:`173`) - Fix recognition of single-letter roman numeral suffixes (e.g. ``"I"``, ``"V"``) in suffix-comma format (closes :issue:`136`) - Fix recognition of trailing ``suffix_not_acronyms`` (e.g. ``"Jr."``) in lastname-comma format (closes :issue:`144`) - Fix missing comma between ``'msc'`` and ``'mscmsm'`` in ``suffix_acronyms``, which silently concatenated them into a bogus ``'mscmscmsm'`` entry (:issue:`111`) - Fix ``'apn aprn'`` split into separate ``suffix_acronyms`` entries so each is recognized independently (closes :issue:`155`) **Formatting and output fixes** - Fix ``IndexError`` in ``initials()``/``initials_list()`` when a ``*_list`` attribute was assigned directly with an element containing unnormalized whitespace (e.g. ``name.middle_list = ['Q R']``), bypassing the parser's whitespace normalization (closes :issue:`232`) - **Fix initials() emitting a stray empty initial (e.g. "J. . V.")** -- or raising ``TypeError`` when ``empty_attribute_default`` is ``None`` -- for name parts with no initialable words, e.g. a prefix-only middle name like ``"de la"`` - Fix capitalization of suffix acronyms written with dots, e.g. ``"M.D."`` (closes :issue:`141`) - Fix extra whitespace before punctuation in ``str()`` output when a ``string_format`` field is empty (closes :issue:`139`) - Fix spurious leading space in surnames and empty token in suffix list after ``capitalize()`` with an empty middle or suffix (:issue:`164`) **API correctness and cleanup** - Fix the five non-cached-union ``SetManager``-backed ``Constants`` attributes (``first_name_titles``, ``conjunctions``, ``bound_first_names``, ``non_first_name_prefixes``, ``suffix_acronyms_ambiguous``) accepting non-``SetManager`` assignment silently (e.g. ``constants.conjunctions = 'and'``), degrading membership checks into substring tests with no error; assignment now raises ``TypeError`` like the four cached-union attributes already did (closes :issue:`241`) - Fix ``HumanName.C`` accepting an invalid ``constants`` value on post-construction assignment (e.g. ``hn.C = 'garbage'``), bypassing the constructor's validation and failing later with an unrelated ``AttributeError``; ``C`` is now a property that validates on assignment too (closes :issue:`239`) - Fix ``TupleManager`` (and ``RegexTupleManager``) accepting a bare string/bytes argument (raising a cryptic ``dict``-internals ``ValueError``) or an iterable of 2-character strings (silently shredding each into a key/value pair, e.g. ``Constants(capitalization_exceptions=['ii'])`` becoming ``{'i': 'i'}``); both now raise ``TypeError`` with a clear message (closes :issue:`242`) - Fix ``SetManager.__contains__`` being the one operation that didn't normalize (lowercase, strip leading/trailing periods) its operand, so e.g. ``'Dr.' in constants.titles`` could return ``False`` even though the title was correctly configured; membership checks now normalize like ``add()``/``remove()``/the constructor/the set operators (closes :issue:`244`) - Fix a bare string passed to a set-backed ``Constants`` argument (e.g. ``Constants(titles='dr')``), to ``SetManager``, or as a ``SetManager`` set-operator operand (e.g. ``constants.titles |= 'esq'``) being silently split into single characters, replacing or polluting the set and producing wrong parses with no error; it now raises ``TypeError`` with the suggested fix — wrap strings in a list, decode ``bytes`` first (closes :issue:`238`) - **Fix SetManager set operators and the constructor skipping the lowercase/strip-edge-periods normalization that add() applies:** ``constants.titles |= ['Esq.']`` kept a raw ``'Esq.'`` the parser's lookups could never match, ``titles & ['Dr.']`` missed ``'dr'``, and ``Constants(titles=[...])`` stored raw elements that silently never matched; elements and operands are now normalized everywhere, and non-``str`` elements (``bytes``, ``None``, numbers) raise ``TypeError`` instead of crashing cryptically or being coerced - **Fix the constants constructor argument silently discarding Constants subclass instances:** the exact-type check replaced them with fresh defaults, throwing away the caller's configuration. Subclass instances are now used as given; anything that is neither ``None`` nor a ``Constants`` instance now raises ``TypeError`` instead of being silently swapped for defaults (closes :issue:`226`) - Fix ``Constants`` customizations, singleton identity, and ``TupleManager`` subclass being lost across ``pickle``/``deepcopy`` round-trips (:issue:`167`, :issue:`168`, :issue:`169`) - Fix ``is_rootname()`` returning stale results after ``add()``/``remove()`` on ``titles``, ``prefixes``, ``suffix_acronyms``, or ``suffix_not_acronyms`` (:issue:`166`) - Fix the library logger calling ``setLevel(logging.ERROR)`` on import, which silently discarded log records regardless of an application's own logging configuration; the logger now leaves its level at ``NOTSET`` and lets the application control verbosity (closes :issue:`228`) - **Minor internal cleanups:** drop a dead length check in the initials helper, simplify double-wrapped ``len(list(...))`` calls, and other small parser tidy-ups with no behavior change (closes :issue:`229`) - Change ``Constants.__repr__`` to report collection sizes and non-default scalar config, replacing the uninformative ```` (:issue:`221`) * 1.2.1 - June 19, 2026 - Fix ``initials()`` interpolating the literal ``None`` for empty name parts when ``empty_attribute_default = None`` (e.g. ``"J. None D."``); empty parts now render as an empty string and a fully-empty result returns ``empty_attribute_default`` - Add ``python -m nameparser "Name String"`` command-line helper that prints a parsed name - Reorganize the test suite from a single ``tests.py`` into a ``tests/`` pytest package * 1.2.0 - June 11, 2026 - Drop Python 2 and Python < 3.10 support; Python 3.10–3.14 now required - Add type hints and type declarations (PEP 561 ``py.typed`` marker) - Migrate build tooling to ``pyproject.toml``, drop ``setup.py`` - Remove dead Python 2 compatibility shims (``ENCODING`` constant, ``next()`` aliases) - Modernize CI: uv-based workflow, trusted publishing to PyPI, Dependabot * 1.1.3 - September 20, 2023 - Fix case when we have two same prefixes in the name (:issue:`147`) * 1.1.2 - November 13, 2022 - Add support for attributes in constructor (:issue:`140`) - Make HumanName instances hashable (:issue:`138`) - Update repr for names with single quotes (:issue:`137`) * 1.1.1 - January 28, 2022 - Fix bug in is_suffix handling of lists (:issue:`129`) * 1.1.0 - January 3, 2022 - Add initials support (:issue:`128`) - Add more titles and prefixes (:issue:`120`, :issue:`127`, :issue:`128`, :issue:`119`) * 1.0.6 - February 8, 2020 - Fix Python 3.8 syntax error (:issue:`104`) * 1.0.5 - Dec 12, 2019 - Fix suffix parsing bug in comma parts (:issue:`98`) - Fix deprecation warning on Python 3.7 (:issue:`94`) - Improved capitalization support of mixed case names (:issue:`90`) - Remove "elder" from titles (:issue:`96`) - Add post-nominal list from Wikipedia to suffixes (:issue:`93`) * 1.0.4 - June 26, 2019 - Better nickname handling of multiple single quotes (:issue:`86`) - full_name attribute now returns formatted string output instead of original string (:issue:`87`) * 1.0.3 - April 18, 2019 - fix sys.stdin usage when stdin doesn't exist (:issue:`82`) - support for escaping log entry arguments (:issue:`84`) * 1.0.2 - Oct 26, 2018 - Fix handling of only nickname and last name (:issue:`78`) * 1.0.1 - August 30, 2018 - Fix overzealous regex for "Ph. D." (:issue:`43`) - Add `surnames` attribute as aggregate of middle and last names * 1.0.0 - August 30, 2018 - Fix support for nicknames in single quotes (:issue:`74`) - Change prefix handling to support prefixes on first names (:issue:`60`) - Fix prefix capitalization when not part of lastname (:issue:`70`) - Handle erroneous space in "Ph. D." (:issue:`43`) * 0.5.8 - August 19, 2018 - Add "Junior" to suffixes (:issue:`76`) - Add "dra" and "srta" to titles (:issue:`77`) * 0.5.7 - June 16, 2018 - Fix doc link (:issue:`73`) - Fix handling of "do" and "dos" Portuguese prefixes (:issue:`71`, :issue:`72`) * 0.5.6 - January 15, 2018 - Fix python version check (:issue:`64`) * 0.5.5 - January 10, 2018 - Support J.D. as suffix and Wm. as title * 0.5.4 - December 10, 2017 - Add Dr to suffixes (:issue:`62`) - Add the full set of Italian derivatives from "di" (:issue:`59`) - Add parameter to specify the encoding of strings added to constants, use 'UTF-8' as fallback (:issue:`67`) - Fix handling of names composed entirely of conjunctions (:issue:`66`) * 0.5.3 - June 27, 2017 - Remove emojis from initial string by default with option to include emojis (:issue:`58`) * 0.5.2 - March 19, 2017 - Added names scrapped from VIAF data, thanks daryanypl (:issue:`57`) * 0.5.1 - August 12, 2016 - Fix error for names that end with conjunction (:issue:`54`) * 0.5.0 - August 4, 2016 - Refactor join_on_conjunctions(), fix :issue:`53` * 0.4.1 - July 25, 2016 - Remove "bishop" from titles because it also could be a first name - Fix handling of lastname prefixes with periods, e.g. "Jane St. John" (:issue:`50`) * 0.4.0 - June 2, 2016 - Remove "CONSTANTS.suffixes", replaced by "suffix_acronyms" and "suffix_not_acronyms" (:issue:`49`) - Add "du" to prefixes - Add "sheikh" variations to titles - Add parameter to force capitalization of mixed case strings * 0.3.16 - March 24, 2016 - Clarify LGPL licence version (:issue:`47`) - Skip pickle tests if pickle not installed (:issue:`48`) * 0.3.15 - March 21, 2016 - Fix string format when `empty_attribute_default = None` (:issue:`45`) - Include tests in release source tarball (:issue:`46`) * 0.3.14 - March 18, 2016 - Add `CONSTANTS.empty_attribute_default` to customize value returned for empty attributes (:issue:`44`) * 0.3.13 - March 14, 2016 - Improve string format handling (:issue:`41`) * 0.3.12 - March 13, 2016 - Fix first name clash with suffixes (:issue:`42`) - Fix encoding of constants added via the python shell - Add "MSC" to suffixes, fix :issue:`41` * 0.3.11 - October 17, 2015 - Fix bug capitalization exceptions (:issue:`39`) * 0.3.10 - September 19, 2015 - Fix encoding of byte strings on python 2.x (:issue:`37`) * 0.3.9 - September 5, 2015 - Separate suffixes that are acronyms to handle periods differently, fixes :issue:`29`, :issue:`21` - Don't find titles after first name is filled, fixes (:issue:`27`) - Add "chair" titles (:issue:`37`) * 0.3.8 - September 2, 2015 - Use regex to check for roman numerals at end of name (:issue:`36`) - Add DVM to suffixes * 0.3.7 - August 30, 2015 - Speed improvement, 3x faster - Make HumanName instances pickleable * 0.3.6 - August 6, 2015 - Fix strings that start with conjunctions (:issue:`20`) - handle assigning lists of names to a name attribute - support dictionary-like assignment of name attributes * 0.3.5 - August 4, 2015 - Fix handling of string encoding in python 2.x (:issue:`34`) - Add support for dictionary key access, e.g. name['first'] - add 'santa' to prefixes, add 'cpa', 'csm', 'phr', 'pmp' to suffixes (:issue:`35`) - Fix prefixes before multi-part last names (:issue:`23`) - Fix capitalization bug (:issue:`30`) * 0.3.4 - March 1, 2015 - Fix :issue:`24`, handle first name also a prefix - Fix :issue:`26`, last name comma format when lastname is also a title * 0.3.3 - Aug 4, 2014 - Allow suffixes to be chained (:issue:`8`) - Handle trailing suffix in last name comma format (:issue:`3`). Removes support for titles with periods but no spaces in them, e.g. "Lt.Gen.". (:issue:`21`) * 0.3.2 - July 16, 2014 - Retain original string in "original" attribute. - Collapse white space when using custom string format. - Fix :issue:`19`, single comma name format may have trailing suffix * 0.3.1 - July 5, 2014 - Fix Pypi package, include new config module. * 0.3.0 - July 4, 2014 - Refactor configuration to simplify modifications to constants (backwards incompatible) - use unicode_literals to simplify Python 2 & 3 support. - Generate documentation using sphinx and host on readthedocs. * 0.2.10 - May 6, 2014 - **If name is only a title and one part, assume it's a last name instead of a first name, with exceptions for some titles like 'Sir'.** (`#7 `_). - Add some judicial and other common titles. (:issue:`9`) * 0.2.9 - Apr 1, 2014 - Add a new nickname attribute containing anything in parenthesis or double quotes (`Issue 33 `_). * 0.2.8 - Oct 25, 2013 - Add support for Python 3.3+. Thanks to @corbinbs. * 0.2.7 - Feb 13, 2013 - Fix bug with multiple conjunctions in title - add legal and crown titles * 0.2.6 - Feb 12, 2013 - Fix python 2.6 import error on logging.NullHandler * 0.2.5 - Feb 11, 2013 - Set logging handler to NullHandler - Remove 'ben' from PREFIXES because it's more common as a name than a prefix. - Deprecate BlankHumanNameError. Do not raise exceptions if full_name is empty string. * 0.2.4 - Feb 10, 2013 - Adjust logging, don't set basicConfig. Fix `Issue 10 `_ and `Issue 26 `_. - Fix handling of single lower case initials that are also conjunctions, e.g. "john e smith". Re `Issue 11 `_. - Fix handling of initials with no space separation, e.g. "E.T. Jones". Fix :issue:`11`. - Do not remove period from first name, when present. - Remove 'e' from PREFIXES because it is handled as a conjunction. - Python 2.7+ required to run the tests. Mark known failures. - tests/test.py can now take an optional name argument that will return repr() for that name. * 0.2.3 - Fix overzealous "Mac" regex * 0.2.2 - Fix parsing error * 0.2.0 - Significant refactor of parsing logic. Handle conjunctions and prefixes before parsing into attribute buckets. - Support attribute overriding by assignment. - Support multiple titles. - Lowercase titles constants to fix bug with comparison. - Move documentation to README.rst, add release log. * 0.1.4 - Use set() in constants for improved speed. setuptools compatibility - sketerpot * 0.1.3 - Add capitalization feature - twotwo * 0.1.2 - Add slice support derek73-python-nameparser-456b024/docs/requirements.txt000066400000000000000000000000141527303271700231160ustar00rootroot00000000000000Sphinx furo derek73-python-nameparser-456b024/docs/resources.rst000066400000000000000000000020141527303271700224000ustar00rootroot00000000000000Naming Practices and Resources ============================== * US_Census_Surname_Data_2000_ * US_Social_Security_Administration_Baby_Names_Index_ * Naming_practice_guide_UK_2006_ * Wikipedia_Anthroponymy_ * Wikipedia_Naming_conventions_ * Wikipedia_List_Of_Titles_ * Tussenvoegsel_ * Family_Name_Affixes_ .. _US_Census_Surname_Data_2000: https://www.census.gov/data/developers/data-sets/surnames/2000.html .. _US_Social_Security_Administration_Baby_Names_Index: https://www.ssa.gov/oact/babynames/limits.html .. _Naming_practice_guide_UK_2006: https://www.fbiic.gov/public/2008/nov/Naming_practice_guide_UK_2006.pdf .. _Wikipedia_Anthroponymy: https://en.wikipedia.org/wiki/Anthroponymy .. _Wikipedia_Naming_conventions: http://en.wikipedia.org/wiki/Wikipedia:Naming_conventions_(people) .. _Wikipedia_List_Of_Titles: https://en.wikipedia.org/wiki/Title .. _Tussenvoegsel: https://en.wikipedia.org/wiki/Tussenvoegsel .. _Family_Name_Affixes : https://en.wikipedia.org/wiki/List_of_family_name_affixes derek73-python-nameparser-456b024/docs/usage.rst000066400000000000000000001431411527303271700215010ustar00rootroot00000000000000Using the parser ================ Requires Python 3.11+. ``pip install nameparser`` Parse a name ------------ .. doctest:: >>> from nameparser import parse >>> name = parse("Dr. Juan Q. Xavier de la Vega III") >>> name.given, name.family ('Juan', 'de la Vega') >>> name.title, name.middle, name.suffix ('Dr.', 'Q. Xavier', 'III') A parsed name has seven fields: ``title``, ``given``, ``middle``, ``family``, ``suffix``, ``nickname``, and ``maiden``. Parsing never raises; unparseable input yields a :class:`~nameparser.ParsedName` with empty fields plus any ``ambiguities`` the parser noticed along the way (see `When the parser had to guess`_ below, and :doc:`concepts` for why they exist). A name with no fields set is falsy, which is how you tell "nothing parsed" from "parsed to something": .. doctest:: >>> bool(parse("")), bool(parse(" ")), bool(parse("John")) (False, False, True) Input shapes ------------- Three arrangements are understood by default, two more when you declare a family-first order, and two more by how they are written. Understood by default ~~~~~~~~~~~~~~~~~~~~~ Every piece of each is optional: 1. ``Title Given "Nickname" Middle Middle Family Suffix`` 2. ``[Title] Family [Suffix], Title Given (Nickname) Middle Middle[,] Suffix [, Suffix or Title]`` 3. ``Title Given Middle Family [Suffix], Suffix or Title [, Suffix or Title]`` A title written with a period (``Prof.``) is also read at the end of a name in forms 1 and 3 and at the end of the given name in form 2; written bare there (``Prof``), it is a name word, because many title words are also surnames — unless a credential stands before it, which starts a run of post-nominals and titles (``John Smith PhD Prof`` reads title ``Prof``; see :ref:`trailing-titles`), or, at the end of the given name in form 2, it is a listed salutation such as ``Mrs`` (``Doe, Jane Mrs`` reads title ``Mrs`` since 2.6; ``Lexicon.after_given_titles`` lists them). The last two differ in what the comma is doing. In form 2 it separates the family name from the rest, so the family name comes first; in form 3 it only sets off suffixes and titles, and the name before it is still given-then-family: .. doctest:: >>> parse("de la Vega, Juan Q. Xavier III").family # form 2 'de la Vega' >>> parse("Doe Jr., John").suffix # form 2, suffix before the comma 'Jr.' >>> parse("John Doe, Jr.").family # form 3 'Doe' A suffix ending a name of two or more words (a title counts) before the comma reads as form 3 even when what follows is a credential the vocabulary does not know, since a generation is written there in natural order. A word after the comma written as a name, or an initial, keeps form 2 (since 2.5): .. doctest:: >>> parse("John Smith II, AD").suffix 'II, AD' >>> parse("Garcia Lopez Jr., Juan").given 'Juan' A part after the comma in form 3, or after a further comma in either form, holds suffixes and titles, a word there that is a title rather than a suffix being read as the title (after a further comma, since 2.4): .. doctest:: >>> parse("Eric H. Holder, Attorney General").title 'Attorney General' >>> parse("Eric H. Holder, Jr., Attorney General").title 'Attorney General' Family-first forms you declare ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Two more arrangements apply only when ``name_order`` declares family-first input — common outside Europe; see :ref:`name-order`: 4. ``Title Family Given Middle Middle [Particle] [, Suffix]`` (``FAMILY_FIRST``) 5. ``Title Family Middle Middle Given [, Suffix]`` (``FAMILY_FIRST_GIVEN_LAST``) A trailing particle earns a slot in form 4 alone because it is displaced from the family name it belongs to; form 5's trailing word is the given name by the caller's declaration, so there is nothing there to reinterpret. Forms the script carries ~~~~~~~~~~~~~~~~~~~~~~~~ Two more arrangements are native East Asian forms. The first needs no ``name_order`` at all — the script itself carries the reading — and the second is read the way the default order reads it, its own written order: 6. ``Family Given [Honorific]`` 7. ``Given[·Given]·Family / katakana transcription (source order)`` Form 6 is the native family-first arrangement written in Han or Hangul — spaced or unspaced, with the honorific spaced or glued and landing in ``suffix``. It has no title slot and no comma, because native CJK writing has neither convention. Form 7 is a transcription listing — Han divided by the 间隔号, or katakana joined by the nakaguro — never segmented, and kept in the order it was written as long as ``name_order`` is the default: the script settles no order for a transcription, so a declared ``FAMILY_FIRST`` reverses it as it reverses a Latin name. A comma or a Latin wrapper around a CJK name — a listing comma, a Latin honorific or credential set beside it — is tolerated input: parsed best-effort, its handling changeable without notice. Forms 6 and 7, and how a Latin wrapper around either is handled, are covered in full under `East Asian names`_ below. Words that attach to their neighbors -------------------------------------- Input shapes tell you where the fields sit. This tells you which words merge into one field instead of standing alone — between them, that is most of what decides a parse. Most words stand alone. A few pull in a neighbor, and each kind pulls into one particular field — so this table is also a map of which fields get built by attachment rather than by position. Titles and suffixes attach only to *adjacent words of their own kind* (a run of titles chains into one, but a title never swallows a plain name); the rest reach forward to pull in the next word, whatever it is. Follow a row's name to its full vocabulary set: .. list-table:: :header-rows: 1 :widths: 22 26 12 40 * - Words - Attach to - Field - Example * - :mod:`Titles ` - adjacent titles - ``title`` - ``Asst. Vice Chancellor John`` → ``Asst. Vice Chancellor`` * - :mod:`Suffixes ` - adjacent suffixes - ``suffix`` - ``John Smith PhD MD`` → ``PhD MD`` * - :mod:`Bound given names ` - the following word - ``given`` - ``abdul salam ahmed`` → ``abdul salam`` * - :mod:`Particles ` - the following surname - ``family`` - ``Juan de la Vega`` → ``de la Vega`` * - :mod:`Maiden markers ` - the following name - ``maiden`` - ``Jane Smith née Jones`` → ``Jones`` * - :mod:`Conjunctions ` - the words on both sides - any - ``John and Jane Smith`` → ``John and Jane`` Conjunctions are the exception the last row names: they take the field of whatever sits on both sides, so the same ``and`` pulls two given names together as easily as two surnames: .. doctest:: >>> parse("John and Jane Smith").given 'John and Jane' >>> parse("Juan de la Vega y Rodriguez").family 'de la Vega y Rodriguez' One shipped vocabulary works the other way round and so is not in the table above: the Korean surnames (``Lexicon.default().surnames``) *split* a word instead of merging two, and is covered under `East Asian names`_. :doc:`customize` covers how to change which words are in each of these sets. A particle at the start of a name ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Position matters in exactly one place: a particle standing on its own at the start of a name. It has no surname to attach to yet, so what decides the reading is whether it is one that can double as a given name: the particle either becomes the given name or turns the whole name into a surname. Only the first of those is ``name_order``'s question — see :ref:`order-vocabulary-first`, and read the given name below as the default given-first order's — since a particle that can never be a given name is the surname whatever order you declare: .. doctest:: >>> parse("van Gogh").given # 'van' can be a given name 'van' >>> parse("de Mesnil").given # 'de' cannot '' >>> parse("de Mesnil").family 'de Mesnil' A comma gets there first. It names the surname outright, so a particle opening that surname has nothing left to decide and the part after the comma is the given name: .. doctest:: >>> parse("de Mesnil, Juan").given 'Juan' :ref:`ambiguous-words` covers changing which particles may double as given names. .. _east-asian-names: East Asian names ----------------- A Chinese, Japanese, or Korean name written in its own script puts the family name first: 毛泽东 is MAO Zedong, 山田太郎 is YAMADA Taro, 김민준 is KIM Minjun. The family name is short — one Han character or one hangul syllable, occasionally two — and comes from a small closed set, while given names are open-ended. And in native writing the parts are usually not separated at all: the whole name is one unbroken run of characters. A parser therefore has two distinct jobs here: assign family and given to the right fields, and, when the name arrives as a single token, find the boundary inside it. Which name comes first ~~~~~~~~~~~~~~~~~~~~~~ Field assignment is automatic. A name written wholly in Han characters or hangul is assigned family-first, because every language written in those scripts orders names that way — Chinese and Japanese share little else, but they agree on this — so the assignment requires no knowledge of which language the name is in: .. doctest:: >>> parse("毛 泽东").family '毛' >>> parse("山田 太郎").family '山田' Splitting an unspaced name ~~~~~~~~~~~~~~~~~~~~~~~~~~ Splitting an unspaced name is also automatic, but only for Korean. Hangul is written by exactly one language, and Korean family names are limited to a small closed set: the census surname list is part of the default vocabulary, and the longest listed surname at the start of an unspaced hangul token becomes the family name: .. doctest:: >>> minjun = parse("김민준") >>> minjun.family, minjun.given ('김', '민준') The same split is not automatic for Han text, because there the script does not identify the language: 高橋一郎 is a Japanese name whose family name is 高橋, but 高 alone is a common Chinese surname, so a Chinese surname list would split it in the wrong place. Declaring the language is up to you. When you know the data is Chinese, apply the ``zh`` locale pack, which carries the surname list the split needs: .. doctest:: >>> from nameparser import locales, parser_for >>> parser_for(locales.ZH).parse("毛泽东").family '毛' Transcribed foreign names ~~~~~~~~~~~~~~~~~~~~~~~~~ Chinese also has a transcription convention, and it is written in the punctuation: a foreign name transcribed into Han characters keeps its source order and divides its parts with the 间隔号, the interpunct ``·`` (U+00B7) — 威廉·莎士比亚 is William Shakespeare, given name first. The dot itself is the marker, so nameparser reads U+00B7 as a token separator when it sits between classified-script characters (each side judged on its own — a hangul character beside a Han one qualifies), and a dot anywhere in the name reads the whole name as a transcription listing: it keeps the declared order — the order it was written in, under the default — and is never segmented — the role pure katakana plays for Japanese transcriptions, played here by the divider instead of the script: .. doctest:: >>> shakespeare = parse("威廉·莎士比亚") >>> shakespeare.given, shakespeare.family ('威廉', '莎士比亚') Because only classified characters on both sides make it a divider, the same codepoint interior to a Latin-script name — the Catalan punt volat in ``Gal·la`` — is untouched, and a dot with a classified character on just one side (``王·Smith``) stays part of the word undivided: .. doctest:: >>> parse("Gal·la Puig").given 'Gal·la' >>> parse("王·Smith").given '王·Smith' The Japanese middle dot ・ (covered next) is a different mark carrying a different convention, and keeps its own reading. Japanese ~~~~~~~~~ Japanese writes a name in three scripts at once. Family names and most given names are kanji — the same characters Chinese uses — but a given name is often written in one of the two kana syllabaries instead: hiragana (高橋みなみ) or katakana (山田エミ). The two syllabaries carry different information about whose name it is. Hiragana never transcribes a foreign name, so a name that holds hiragana, alone or beside kanji or katakana, is a Japanese person's name, written in Japanese order. Katakana is ambiguous: native given names use it, but katakana is also how Japanese text writes a *foreign* name — マイケル・ジャクソン is Michael Jackson — and a transcription keeps the source language's order, given name first, its parts divided by the middle dot ・ (the nakaguro, U+30FB) rather than by a space. Katakana also has a halfwidth form (タロウ for タロウ), which older systems that could not store kanji used for every name, Japanese or foreign; bank, payroll and CSV exports still carry it. nameparser reads halfwidth katakana exactly as it reads the full-width form. What happens automatically ^^^^^^^^^^^^^^^^^^^^^^^^^^ Two behaviors follow from that without any configuration. A name whose characters stay within kanji and kana, carry at least one kana, and are not katakana alone is assigned family-first, like any other native-script East Asian name: it cannot be Chinese, and it is not a transcription, because a transcription is written in katakana alone. .. doctest:: >>> minami = parse("高橋 みなみ") >>> minami.family, minami.given ('高橋', 'みなみ') >>> parse("山田 エミ").family '山田' >>> parse("山田 タロウ").family '山田' And the middle dot separates tokens the way a space does, so a transcribed foreign name divides into its parts — which, being wholly katakana, keep the order they were written in: .. doctest:: >>> michael = parse("マイケル・ジャクソン") >>> michael.given, michael.family ('マイケル', 'ジャクソン') Dividing an unspaced name: the segmenter ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Dividing an *unspaced* Japanese name is a separate matter, and one no surname list can settle: family and given names draw on the same kanji, both sides run one to four characters, and the reading rather than the spelling decides most divisions. nameparser therefore takes a **segmenter** — a callable from token text to a division — and ships a factory wrapping `namedivider-python `_, an optional dependency installed with the ``ja`` extra: .. code-block:: console $ pip install "nameparser[ja]" .. code-block:: python from nameparser import locales, parser_for parser = parser_for(locales.JA, segmenter=locales.ja_segmenter()) parser.parse("山田太郎").family # '山田' Both halves are required, and they do different jobs: the ``ja`` pack activates division for Japanese text, and the segmenter performs it. ``ja_segmenter()`` wraps namedivider's ``BasicNameDivider``, which reads data bundled in the installed package; ``ja_segmenter(gbdt=True)`` selects its gradient-boosted divider instead, which is more accurate and downloads its model and surname files from the network on first use, worth knowing before deploying it somewhere sandboxed or air-gapped. Forgetting the segmenter is loud: building the parser emits a ``UserWarning`` naming the scripts that could never divide and the call to pass, because the misconfigured parser would otherwise behave exactly like a working one minus the feature. The same check guards any configuration whose activated scripts nothing can serve. A from-scratch lexicon with no hangul surnames warns under the default policy, and ``Policy(segment_scripts=frozenset())`` is the deactivation the message offers. Decomposed text ^^^^^^^^^^^^^^^ Korean and Japanese text is sometimes stored decomposed, with a syllable held as its separate jamo rather than as one codepoint. macOS filenames are the common source. Everything above works on decomposed input: script classification normalizes to NFC before deciding, so a decomposed name gets the same order rule as its composed twin. Vocabulary lookup does the same before matching a word against titles, honorifics and the rest, so a decomposed ``Señor`` or ``née`` — macOS-origin data again — is recognized as readily as its composed spelling. Case repair reads a decomposed accent as part of its letter, so ``capitalized()`` gives a decomposed ``josé garcía`` the same ``José García`` as the composed spelling, still decomposed. Splitting is the exception. An unspaced decomposed hangul name is ordered correctly but not split, because surname matching runs against the text as given and a decomposed name matches no entry in the census list. That is the deliberate choice: being unsplit is recoverable, whereas splitting in the wrong place is not. One consequence is worth stating outright, because it looks like a bug. Parse output preserves the encoding it was given, so a field from a decomposed name is decomposed too, and comparing it against a composed literal fails even where the parse was correct:: decomposed = unicodedata.normalize("NFD", "김 민준") parse(decomposed).family == "김" # False unicodedata.normalize("NFC", parse(decomposed).family) # '김' Normalize both sides before comparing across encodings. Boundaries ~~~~~~~~~~~ Several boundaries apply to all of the above. When the script rules don't apply ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Romanized names ("Kim Min-jun", "Yamada Taro") are Latin script and follow the ordinary positional rules. Order genuinely varies in romanized data, so nothing script-based applies. A name written wholly in katakana stays positional for the reason given above, pack or no pack: it may be a transcription, already in the order it should be read in, or a Japanese name's reading written family-first — a furigana field, or legacy halfwidth data — and the script cannot tell the two apart. If you know your katakana names are Japanese, map katakana to family-first yourself: .. doctest:: >>> from nameparser import (DEFAULT_SCRIPT_ORDERS, FAMILY_FIRST, Parser, ... Policy, Script) >>> parse("ヤマダ タロウ").family 'タロウ' >>> kana_family_first = Parser(policy=Policy(script_orders=( ... *DEFAULT_SCRIPT_ORDERS, (Script.KATAKANA, FAMILY_FIRST)))) >>> kana_family_first.parse("ヤマダ タロウ").family 'ヤマダ' This reaches full-width katakana too, transcriptions included (マイケル・ジャクソン would read family マイケル), which is why it is yours to choose rather than a default. ``name_order=FAMILY_FIRST`` would also work, but it reverses every name, Latin ones included. A Han transcription written with a space instead of the 间隔号 (威廉 莎士比亚) carries nothing to distinguish it from a native two-token name, and keeps the family-first reading. The dot is the marker, and without it there is no signal. The same holds for a transcription typed with the Japanese middle dot (威廉・莎士比亚): each dot carries its own script's convention, the nakaguro's is Japanese roster formatting rather than transcription, so only the Chinese dot stands the family-first reading down. Honorifics come off first ^^^^^^^^^^^^^^^^^^^^^^^^^ Honorifics and degrees follow a CJK name, and both the spaced and the glued forms are recognized as suffixes: .. doctest:: >>> parse("王小明 先生").suffix '先生' >>> glued = parse("김민준씨") >>> glued.family, glued.given, glued.suffix ('김', '민준', '씨') The honorific is split off the end of the last token of the name before the name is split or ordered, so those rules see the name without it. That is why ``김민준씨`` still divides into family 김 and given 민준, and why a configured Japanese segmenter is handed 山田太郎 rather than 山田太郎様. The stop can be any width: the fullwidth ``.`` a Japanese or Chinese input method produces by default, the ideographic ``。`` and the halfwidth ``。`` all reach the honorific vocabulary as an ASCII period does, so ``김민준 씨.`` gives suffix ``씨.``, family 김, given 민준. A period glued to an ordinary name word, not an honorific, is likewise left where it was written rather than breaking the segmentation that follows it: .. doctest:: >>> parse("양. 지훈").family, parse("양. 지훈").given ('양.', '지훈') Commas and Latin wrappers around a CJK name ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ A comma or a Latin credential set wrapped around a CJK name is tolerated input rather than contract: no native CJK writing uses either convention, so nameparser reads it best-effort and the handling can change without notice. Today: - **A comma names the family and stops the split.** ``남궁민수, 지훈`` reads family ``남궁민수`` whole, where the bare ``남궁민수`` alone would split into family ``남궁`` and given ``민수``. - **A glued honorific peels off before the comma** when what follows it is nothing but suffix words, as in ``田中さん, PhD`` (suffix ``さん, PhD``). - **It stays glued** when the comma is followed by a title or another name word, as in ``田中さん, Dr.`` (family ``田中さん``). - **Credentials after the comma** land in ``given`` or ``suffix`` by spelling, and ``田中さん, V.`` and ``田中さん, Ph. D.`` give up さん exactly as ``田中さん, PhD`` does. ``Policy(lenient_comma_suffixes=False)`` reads ``V.`` as name text instead, and さん then stays glued (family ``田中さん``, given ``V.``). Spacing, and where the name divides ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Where a segmenter divides the name, the two spellings part company. A spaced honorific is a token boundary the writer typed, and the segmenter is asked only where an undivided name divides, so anything standing beside the name calls it off, honorific or not. Under the Japanese pack ``佐藤 氏`` keeps family 佐藤, where bare ``佐藤`` would have been divided 佐 + 藤. That is a conservative reading rather than a principled one. A spaced honorific cannot be told apart from a spaced given name by position, and treating it as one keeps four real surnames whole (``佐藤 氏``, ``田中 様``, ``鈴木 先生``, ``中村 教授``) at the price of one division it declines to make (``山田太郎 様``). A glued honorific carries no boundary at all, its writer having drawn none anywhere, so ``田中さん`` divides the way bare ``田中`` does, into family 田 and given 中, with さん in ``suffix``. Writing the honorific spaced is therefore also how you ask for a family name to be kept whole on this path, short of declining the pack. Where the VOCABULARY divides the name the two spellings never part company, because the peel hands the same remainder to the same surname match either way: .. doctest:: >>> parse("김민준 씨").family, parse("김민준씨").family ('김', '김') Under the Chinese pack ``王小明 先生`` and ``王小明先生`` both give family 王, given 小明 and suffix 先生 for the same reason. Spacing the honorific is no lever there, and for Korean data there is no pack to decline either, hangul segmentation being on by default. Which honorifics peel when glued ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The glued reading is deliberately narrower than the spaced one. A spaced honorific sits behind a boundary its writer drew; a glued one has only itself to go on, so a word peels off the end of a name only if it could never BE the end of a name. 씨, 님, 박사, 박사님, 선생님, 교수님, さん, さま, くん, ちゃん, 様, 先生, 教授, 女士 and 小姐 qualify. 양, 군, 氏, 博士 and 殿 do not, because 김지양 is a given name, 田中博士 is Tanaka Hiroshi as readily as Doctor Tanaka, and some ninety Japanese surnames end in 殿 (鵜殿, 真殿). Those stay recognized in their spaced form, where position settles what the glued form leaves ambiguous. 君 is recognized in neither form, since 王君 is a complete Chinese name, though its kana spelling くん peels. Exactly one honorific peels off a token, and the entries are whole honorifics rather than parts: ``김민준박사님`` gives up 박사님 entire, not 님 with 박사 left behind. Credential and office words (議員 legislator, 医学博士 doctor of medicine, 社長 company president, 二世 the second) peel when glued as well: they are not known to end a name. A longer office word that ends in one of them is split at it, so 田中 副社長 (vice-president) gives the given name 副 and the suffix 社長. 部長 (department head) is the exception and is not peeled when glued, because 長 alone is a given name, so 阿部長 can be the surname 阿部 and the given name 長: .. doctest:: >>> shacho = parse("田中社長") >>> shacho.family, shacho.suffix ('田中', '社長') >>> parse("田中部長").family '田中部長' When a division was a judgment call ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ A division the parser had to choose is reported rather than hidden. When an unspaced name has more than one vocabulary-supported split, the longest surname wins and the parse records the decision as an ``AmbiguityKind.SEGMENTATION``, described under `When the parser had to guess`_. ``남궁민수`` is 남궁 + 민수 by the two-syllable surname but 남 + 궁민수 by the single-syllable one: .. doctest:: >>> parse("남궁민수").ambiguities (Ambiguity('segmentation': '남궁'/'민수'),) A name with only one possible split reports nothing. A segmenter's answer is reported on the same kind whenever its confidence falls below the stage's floor, naming the division and the score in the report's ``detail``:: "'山田太郎' splits as '山田' + '太郎' on a segmenter answer scoring 0.44, under the 0.9 confidence floor" With namedivider that line separates its two kinds of answer. A division it states as a rule, such as the kanji-to-kana boundary in 高橋みなみ, scores 1.0 and reports nothing; a division read off kanji statistics scores far below the floor and always reports. Read the report as a statement about the *kind* of answer, not as a measure of how likely this particular one is to be wrong. A lone two-character kanji name divides one character to each side, on namedivider's rule for that length. A name that short carries no evidence of where its own boundary falls, and one character each way is the presumption Japanese practice makes. That is an accepted presumption rather than a measurement, so a two-character token is the shape to check first if a division looks wrong. When a segmenter misbehaves ^^^^^^^^^^^^^^^^^^^^^^^^^^^ A segmenter that answers outside the token it was given, with a cut at or past the end of the text, has violated the protocol, and the parse says so rather than hiding it. It raises ``ValueError`` naming the offending offset and the token's length, the way an answer of the wrong type raises ``TypeError``. Declining silently would leave an off-by-one segmenter invisible: every answer it gave would vanish and the name would merely look undivided. The shipped ``ja_segmenter()`` does decline, returning ``None`` and leaving the token whole, for the cases that are *not* protocol violations: text outside the Japanese repertoire, text too short to divide, an answer that fails to reconstruct its input, and a score outside [0, 1]. Exceptions are the one thing that does *not* stay inside the parse: a segmenter is your code, so its errors propagate rather than being absorbed as content errors. The command line takes the pack but not the segmenter: ``python -m nameparser --locale ja`` has no way to attach one, so it activates nothing by itself. :doc:`customize` covers turning any of these behaviors off. Aggregate views ---------------- .. doctest:: >>> name.given_names # given + middle 'Juan Q. Xavier' >>> name.family_base, name.family_particles # family, split apart ('Vega', 'de la') ``surnames`` (``middle`` + ``family``) is the mirror-image aggregate. The plural is the tell: ``given`` and ``family`` are single fields, while ``given_names`` and ``surnames`` roll several fields together — the same sense in which a passport form asks for your "given names" as one blank that can hold more than one word. ``family_base`` is the one to sort on. Dutch and Belgian directories file a name under the base surname and ignore the *tussenvoegsel* — the ``van``, ``de`` or ``van der`` in front of it — and the same convention applies wherever particles are common: .. doctest:: >>> names = [parse(s) for s in ... ["Vincent van Gogh", "Juan de la Vega", "John Smith"]] >>> [n.family_base for n in sorted(names, key=lambda n: n.family_base.lower())] ['Gogh', 'Smith', 'Vega'] Sorting on ``family`` instead files those under "d", "S" and "v", which is the problem the split exists to solve: .. doctest:: >>> [n.family for n in sorted(names, key=lambda n: n.family.lower())] ['de la Vega', 'Smith', 'van Gogh'] Dicts and strings ------------------ .. doctest:: >>> name.as_dict(include_empty=False) {'title': 'Dr.', 'given': 'Juan', 'middle': 'Q. Xavier', 'family': 'de la Vega', 'suffix': 'III'} >>> str(name) 'Dr. Juan Q. Xavier de la Vega III' >>> name.render("{family}, {given}") 'de la Vega, Juan' >>> name.initials() 'J. Q. X. V.' Fixing case ----------- .. doctest:: >>> str(parse("juan de la vega").capitalized()) 'Juan de la Vega' The no-argument form above uses the DEFAULT lexicon; for a name parsed with a custom :class:`~nameparser.Parser`, call :meth:`Parser.capitalized() ` so the parser's own vocabulary decides the exceptions. .. _nicknames-and-maiden-names: Nicknames and maiden names ---------------------------- .. doctest:: >>> parse("Jonathan 'Jack' Kennedy").nickname 'Jack' >>> parse("Jane Smith née Jones").maiden 'Jones' Both fields appear in the default ``str()`` rendering — the nickname quoted after the given name, the maiden name parenthesized after the family name: .. doctest:: >>> str(parse("Jane (Janie) Smith née Jones")) 'Jane "Janie" Smith (Jones)' .. _maiden-roundtrip: Surviving a reparse ~~~~~~~~~~~~~~~~~~~ The default ``str()`` rendering is built for display, and the two fields differ in what survives it. The quoted nickname reparses as a nickname; the parenthesized maiden name reparses as a *nickname* too, so a parse-render-reparse round trip silently loses it: .. doctest:: >>> parse('Jane "Janie" Smith (Jones)').maiden '' >>> parse('Jane "Janie" Smith (Jones)').nickname 'Janie Jones' If the rendered string has to survive a reparse — storing names as text and reading them back, for instance — render the marker explicitly instead of relying on the default: .. doctest:: >>> name = parse("Jane Smith née Jones") >>> text = name.render("{given} {family} née {maiden}") >>> text 'Jane Smith née Jones' >>> parse(text).maiden 'Jones' What bracketed content reads as ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Delimited content is not always a nickname. If it opens with a marker word and has a word after it, the clause is a maiden name inside any configured delimiter pair, with nothing else configured — the clause has said which convention it means, so you do not have to: .. doctest:: >>> parse("Jane Smith (née Jones)").maiden 'Jones' >>> parse('Jane Smith "née Jones"').maiden 'Jones' >>> parse("Jane (née Jones) Smith").family 'Smith' A marker with no name after it is just a word in brackets, and a clause with no marker at all stays a nickname — the parenthesized birth surname is a real convention, but nothing in the clause says so, and only you can declare that with ``maiden_delimiters`` (see :ref:`brackets`): .. doctest:: >>> parse("Jane Smith (née)").nickname 'née' >>> parse("Cherice J. (Johnson) Williams").nickname 'Johnson' If what's inside is a known suffix, or simply ends in a period, it is read as a suffix instead — that reading is taken before the maiden one, and parenthesized credentials and retired ranks are far more common than parenthesized nicknames that happen to be credentials: .. doctest:: >>> parse("Andrew Perkins (MBA)").suffix 'MBA' >>> parse("Andrew Perkins (Ret.)").suffix 'Ret.' The exception to that exception is an *ambiguous* acronym — one that is also a plausible name or set of initials. Standing alone inside delimiters, it keeps the nickname reading unless the way it is written settles it after the name: in capitals in a name written in mixed case, or with a period inside it. A name written all in capitals says nothing about the word, and inside the name the delimiters are read as a nickname however it is written: .. doctest:: >>> parse("Andrew Perkins (JD)").suffix 'JD' >>> parse("Andrew Perkins (Jd)").nickname 'Jd' >>> parse("JEFFREY (JD) BRICKEN").nickname 'JD' ``JD`` is in ``suffix_acronyms_ambiguous``; see :ref:`ambiguous-words` for what that field marks and how to add to it. :ref:`brackets` covers the full order these readings are tried in, and how to configure the delimiter pairs. .. _abbreviated-titles: Titles you didn't configure ----------------------------- A trailing period marks an abbreviation, and the parts of a name that get abbreviated are mostly the ones standing outside it — titles and post-nominals; the exception, a given name written short, is below. So a word ending in a period, in no vocabulary list at all, is read as a title when it stands where a title stands: at the front of the part that carries the given name. That is what lets unfamiliar ranks, honorifics and abbreviations work without configuring anything: .. doctest:: >>> parse("Insp. Jane Morse").title 'Insp.' >>> parse("Det. Insp. Jane Morse").title # chains 'Det. Insp.' Neither ``det`` nor ``insp`` is in the shipped vocabulary; the periods are doing all the work. The part that carries the given name is not always the front of the string. After a family comma it is the part *after* the comma, and the rule applies there in exactly the same way: .. doctest:: >>> parse("Morse, Det. Insp. Jane").title 'Det. Insp.' Where the inference stops ~~~~~~~~~~~~~~~~~~~~~~~~~ The rule is bounded so it doesn't swallow ordinary names: - **Single initials** are left alone (``J.``). - **Abbreviations with interior periods** are left alone (``E.T.``). - **Only the leading run** is read this way: the same word after the given name is a middle name. - **A word carrying a Han, kana or hangul character** is a name word even with a period, since those scripts write no abbreviation with one (``"田中. 太郎"`` has family ``田中.``). - **Before a family comma** the inference does not run, since that slot holds a surname too: ``Xyz. Smith, John`` keeps family ``Xyz. Smith``. A listed title is still read there, so ``Dr. Smith, John`` gives title ``Dr.``. - **A given name written short** is that given name (since 2.7): the traditional English abbreviations such as ``Chas.``, ``Geo.`` and ``Wm.``, and the Filipino ``Ma.`` (María) and ``Jo.`` (José). ``Lexicon.given_name_abbreviations`` lists them; written bare, or anywhere else in the name, each word reads as it would unlisted. .. doctest:: >>> parse("Chas. Smith").given 'Chas.' >>> parse("Mrs Ma. Liza Cruz").given 'Ma.' >>> parse("J. Smith").given 'J.' >>> parse("E.T. Jones").given 'E.T.' >>> parse("Jane Insp. Morse").middle 'Insp.' >>> parse("Xyz. Smith, John").family 'Xyz. Smith' Because this is structural rather than vocabulary-driven, emptying ``titles`` does not switch it off; see :ref:`title-detection-off`. .. _trailing-titles: At the end of a name ~~~~~~~~~~~~~~~~~~~~ Only the leading slot INFERS. At the back of a name a word ending in a single period is read by vocabulary alone: a word the parser already knows as a post-nominal or as a title reads as one, and an unfamiliar abbreviation stays a name word. A word of two or more period-separated chunks is the exception, read as a credential by its shape (see :ref:`dotted-credentials`). .. doctest:: >>> parse("John Smith Prof.").title 'Prof.' >>> parse("John Smith Xyz.").family 'Xyz.' >>> parse("Jane Smith N.C.C.").suffix 'N.C.C.' That asymmetry is deliberate. The title vocabulary holds hundreds of words that are ordinary surnames — ``pope``, ``judge``, ``bishop`` — so inferring a trailing title from the shape alone would cost real names their family field. The period is what separates the two: a trailing title word written *without* one is a name word, so ``parse("Mary Jane Pope").family`` is ``'Pope'``. A credential in front changes that. It starts a run of post-nominals and titles that lasts to the end of the name, so a bare title word behind it is a title: .. doctest:: >>> john_prof = parse("John Smith PhD Prof") >>> (john_prof.title, john_prof.suffix) ('Prof', 'PhD') Comparing names ---------------- ``==`` is strict value equality — two :class:`~nameparser.ParsedName` instances are equal only if every field matches exactly. For "is this the same name, allowing for order and case?" use ``matches()`` or ``comparison_key()`` instead. ``matches()`` parses a str argument with the DEFAULT parser; to compare against a string using a custom :class:`~nameparser.Parser`'s vocabulary, call :meth:`Parser.matches() `. .. doctest:: >>> parse("de la Vega, Juan").matches("Juan de la Vega") True >>> parse("JUAN DE LA VEGA").comparison_key() == parse("Juan de la Vega").comparison_key() True .. _parser-guesses: When the parser had to guess ----------------------------- Some names have no single correct reading. A leading ``Van`` could be a given name — it really is for the actor Van Johnson — or the start of a family name, as it is for President Van Buren. Both are the same shape, so no rule can tell them apart. 2.0 takes the more likely reading and *records* the choice on ``ambiguities`` rather than deciding silently: .. doctest:: >>> name = parse("Van Buren") >>> name.given, name.family ('Van', 'Buren') >>> for a in name.ambiguities: ... print(a.kind.value, "-", a.detail) particle-or-given - leading 'Van' may be a family-name particle; read as a given name :class:`~nameparser.AmbiguityKind` members are their own string values, so branching on a kind needs no import: .. doctest:: >>> name.ambiguities[0].kind == "particle-or-given" True >>> [t.text for t in name.ambiguities[0].tokens] ['Van'] Most names report none. A non-empty ``ambiguities`` is a useful signal for routing a record to human review instead of trusting it silently. .. _credentials-also-surnames: Credentials that are also surnames ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The post-nominals that double as ordinary surnames report an ambiguity the same way. Which reading a bare one gets depends on what the writing says: - **Capitals** in a name written in more than one case lean to the credential, even with no words to spare (``Jack MA``). They settle it, as periods do, so nothing is reported. - **Any other cased spelling** that is not wholly lowercase leans to the surname, even with words to spare (``Jack Ma``, ``John Smith Ma``). - **No signal** — all lowercase, or a name written wholly in one case — leaves it to the count: the credential with two or more words before it, not counting a title or a nickname, the surname otherwise. Where the writing leaves the choice to the parser, the choice is recorded: .. doctest:: >>> parse("JOHN SMITH MA").suffix 'MA' >>> [a.kind.value for a in parse("JOHN SMITH MA").ambiguities] ['suffix-or-name'] >>> parse("John Smith MA").ambiguities () >>> parse("Jack MA").suffix 'MA' >>> parse("Jack Ma").family 'Ma' >>> parse("John Smith Ma").family 'Ma' :ref:`ambiguous-words` has the full rules, including how a comma changes them. Two ``Policy`` switches extend the same class to words the vocabulary does not hold. ``unlisted_dotted_suffixes`` (on by default) reads a token of two or more period-separated chunks the same way — ``parse("John Smith X.Y.Z.").suffix`` is ``'X.Y.Z.'`` — and ``unlisted_caps_suffixes`` does the same for an unlisted all-caps word, by default only after a comma behind a full name, where an all-caps surname is never written — ``parse("John Smith, XYZ").suffix`` is ``'XYZ'`` — and at the end of a name only on request, since an all-caps surname is written there. See :ref:`unlisted-credentials` for both. A reading the vocabulary settles on its own is not a guess and reports nothing — periods make ``M.A.`` unambiguously a credential: .. doctest:: >>> parse("John Smith M.A.").ambiguities () Titles that are also given names ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ A title that is also borne as a given name, such as ``Dean``, ``King`` or ``Major``, is a title only inside a title phrase (since 2.7), and otherwise a name word. Where it opens the name and a phrase could have held it, the choice is recorded as ``title-or-name``: .. doctest:: >>> parse("Dean of Students Jane Smith").title 'Dean of Students' >>> dean_smith = parse("Dean Smith") >>> dean_smith.given, [a.kind.value for a in dean_smith.ambiguities] ('Dean', ['title-or-name']) :ref:`titles-also-given-names` covers the phrases and how to read such a word as a title everywhere. .. _reading-not-taken: The reading it did not take ~~~~~~~~~~~~~~~~~~~~~~~~~~~ Since 2.7 a report of a fork also says what the other reading was. ``Ambiguity.alternative`` is a :class:`~nameparser.Reading` with three parts: - **kind** — what the word would have been read as: a ``name``, an ``initial``, a ``credential``, a ``generation``, a ``particle``, a ``title`` and so on (:class:`~nameparser.ReadingKind`). - **role** — the field the word would have landed in, with every other word of the name read as the parse read it. - **source** — why that reading was there at all (:class:`~nameparser.ReadingSource`): ``vocabulary`` for a listed word, ``declared`` for a word the vocabulary also lists as a name, ``shape`` for a roman numeral, dotted letters or a one-letter initial, and ``position`` for an ordinary word nothing lists, which is a name only by where it stands. The source is what tells two reports of the same kind apart. ``MA`` is listed as a surname as well as a credential, so its name reading is declared; ``Jones`` behind a credential is taken into the credential run by position alone: .. doctest:: >>> for text in ("JOHN SMITH MA", "John Smith PhD Jones", "Van Buren"): ... alt = parse(text).ambiguities[0].alternative ... print(f"{text}: {alt.kind.value} as {alt.role.value} ({alt.source.value})") JOHN SMITH MA: name as family (declared) John Smith PhD Jones: name as family (position) Van Buren: particle as family (vocabulary) A report of a shape rather than a fork — ``comma-structure``, ``unbalanced-delimiter``, ``segmentation`` — carries no alternative, and neither does one whose other reading no field could hold: a connective letter opening the name (``E SMITH JONES``) has no word before it to join. Check for ``None`` before reading one. The alternative is not compared: two names that differ only in it are equal, as before 2.7, and a ``ParsedName`` pickled by an earlier release loads with no alternative on its reports. Tokens and spans ------------------ The seven fields are the convenient view. Underneath, each one is backed by tokens carrying their exact offsets into the original string, so you can always get back to the text a field came from: .. doctest:: >>> name = parse("Juan de la Vega") >>> for tok in name.tokens: ... print(f"{tok.text!r:8} {tok.role.value:8} {tuple(tok.span)}") 'Juan' given (0, 4) 'de' family (5, 7) 'la' family (8, 10) 'Vega' family (11, 15) >>> tok = name.tokens[1] >>> name.original[tok.span.start:tok.span.end] 'de' ``tokens_for()`` narrows that to a single role: .. doctest:: >>> from nameparser import Role >>> [t.text for t in name.tokens_for(Role.FAMILY)] ['de', 'la', 'Vega'] A role's string name works too: ``name.tokens_for("family")``. Correcting a parse -------------------- :class:`~nameparser.ParsedName` is immutable, so a correction is a new value. There are two ways to make one: ``replace()`` splices the new text in as written, and :meth:`Parser.revise() ` reads it the way a parse would. Splicing a value in with ``replace()`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ``replace()`` returns a copy with the given fields changed. Untouched fields keep their tokens (and ``original`` is preserved), with one deliberate exception: an ambiguity that pointed into a replaced field is dropped — correcting the field that was flagged clears the flag, while correcting an unrelated field keeps it. .. doctest:: >>> name = parse("Juan de la Vega") >>> corrected = name.replace(title="Dr.") >>> str(corrected) 'Dr. Juan de la Vega' >>> name.title '' >>> flagged = parse("Van Buren") >>> flagged.replace(given="Martin").ambiguities () >>> [a.kind.value for a in flagged.replace(family="Harrison").ambiguities] ['particle-or-given'] What a spliced value loses ~~~~~~~~~~~~~~~~~~~~~~~~~~ ``replace()`` splits values on whitespace into plain, untagged tokens — the vocabulary knowledge a parse would have about the new text is not there. A token the parse never saw carries no decision to honor, so each view falls back on what it can answer without one: - :meth:`~nameparser.ParsedName.capitalized` is handed a vocabulary, so it asks that whether a word is a conjunction or an initial, which a word answers on its own. Its other special cases never needed a reading: an exceptions-map mask applies wherever its word stands, and a listed credential acronym or a roman numeral is written in capitals wherever the field is the suffix, so ``replace(suffix="mba")`` repairs to ``MBA`` and ``suffix="vi"`` to ``VI``. A credential recognised only by its dotted shape is the exception, since the shape is something the parse records. Particles are a second exception: whether a particle is acting as one is a fact about the whole part, and no word of a spliced field carries a reading to derive it from, so a family set to ``de la`` stays lowercase where the same words parsed are repaired to ``De La``. - :meth:`~nameparser.ParsedName.initials` takes no vocabulary at all, so every word of a spliced field contributes an initial. - ``family_particles`` and ``family_base`` are properties on the parsed name, which holds no vocabulary of its own either: ``family_particles`` empties and ``family_base`` takes the whole field. Of the parsed name's own views, ``capitalized()`` is the only one handed a vocabulary. (The v1 :class:`~nameparser.parser.HumanName` facade's ``initials()`` is handed one too, and asks it whether a spliced word is a conjunction or a particle, so a spliced ``de la`` gives no initials there; it is not a method of the parsed name.) .. doctest:: >>> name.family_particles 'de la' >>> replaced = name.replace(family="de la Vega Smith") >>> replaced.family_particles '' >>> replaced.family_base 'de la Vega Smith' >>> replaced.initials() 'J. d. l. V. S.' >>> name.replace(family="de la").capitalized(force=True).family 'de la' Reading a value with ``Parser.revise()`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ :meth:`Parser.revise() ` is the same operation with each value classified by the parser's vocabulary, so the correction behaves like a fresh parse of the corrected name (which also means delimiters and marker words in the value are consumed as they would be in a parse): .. doctest:: >>> from nameparser import Parser >>> parser = Parser() >>> revised = parser.revise(name, family="de la Vega Smith") >>> revised.family_particles 'de la' >>> revised.initials() 'J. V. S.' ``revise()`` has two siblings on :class:`~nameparser.Parser`: :meth:`Parser.matches() ` and :meth:`Parser.capitalized() `. Those two matter when you have built a custom parser — the :class:`~nameparser.ParsedName` methods of the same names fall back to the *default* configuration for str or omitted arguments. Command line ------------ ``python -m nameparser`` parses one name and prints what it found. It is the quickest way to see how a particular string comes out — worth reaching for when a name parsed unexpectedly and you want to check a variation of it, or when trying a locale pack before wiring one into code: :: $ python -m nameparser "dr. juan de la vega iii" Parsed: Capitalized: Initials: j. v. ``--json`` prints the fields as a single line instead, which is the form to pipe somewhere: :: $ python -m nameparser --json "Doe, John" {"title": "", "given": "John", "middle": "", "family": "Doe", "suffix": "", "nickname": "", "maiden": ""} Add ``--locale`` to parse with a locale pack (for example ``--locale ru``); see :doc:`locales`. Where next ---------- * :doc:`concepts` — how the parser works * :doc:`customize` — your own vocabulary and behavior * :doc:`locales` — locale packs * :doc:`migrate` — coming from 1.x ``HumanName`` derek73-python-nameparser-456b024/nameparser/000077500000000000000000000000001527303271700210445ustar00rootroot00000000000000derek73-python-nameparser-456b024/nameparser/AGENTS.md000066400000000000000000000447531527303271700223640ustar00rootroot00000000000000# Working in nameparser/ Loaded when a session reads or edits anything under `nameparser/`: the pipeline, the vocabulary TOML, the config shim. It holds the sweeps every behavior change runs before it ships — comma shapes, then `name_order`, then spellings, then the encoding — here rather than in the root AGENTS.md because they bind every change to parsing behavior, and the spelling sweep binds a vocabulary-only change as much as a code change. The encoding sweep is the exception there: the vocabulary path NFC-folds, so a TOML-only change does not need it. The root AGENTS.md carries the 2.0 conventions; `nameparser/_pipeline/AGENTS.md` carries the ambiguity-report rules. ## Sweep the forms a change can reach: comma shapes, then `name_order` ### Four comma shapes No comma, a FULL name before the comma, a ONE-WORD name before the comma and NOTHING before the comma are four paths, and the last two are the misses — #429, #430 and #432 are all the one-word path disagreeing with the full-name path on inputs the full-name path reads correctly, and #613's PR review found `, PhD` and `, Jr.` (an empty surname field, as CSV data writes it) read as given and title by a decision that returned early on an empty head, after two review rounds whose every grid had a name before the comma. Put a nickname-only head (`(Bob), PhD`) in every comma grid. The empty head no longer needs a row of its own in a grid that compares fields: rules/C15 reads it as a family comma naming an empty family, and `test_an_empty_head_reads_as_its_family_comma_twin` (tests/v2/test_properties.py) holds `, X` to `Smith, X` less the surname, fields and ambiguity kinds, over a grid of given parts, H1's title-and-one-name-word reading the one exception (#892). A change that breaks it gets a generic fix or an entry in decisions/C15, never code for the empty head alone; widen the test's grid with the change's own shapes instead. ### Counting the one-word path Count the one-word path the way C3 counts, which is neither tokens nor pieces: a particle run and the one word it attaches to are one word (#575), a connective join and a bound given-name pair count as their words (#613, so `Ortega y Gasset, Dr.` is a whole name), and a suffix beside the surname counts as no name word at all. #603's review found the suffix half (`Vega y Lopez Jr., PhD Jones` lost its family where `Vega y Lopez, PhD Jones` kept it); #613 then reversed the connective half, and both now read given 'Vega', middle 'y', family 'Lopez' — so test a particle surname, a connective surname and a surname with a suffix, each before a comma. ### Orders For orders the sweep already exists (`tests/v2/test_cases.py` runs every row under all three) but carries one assertion, R2's family partition, so it checks nothing a new change moves; coverage there has been vacuous before (PR #394's review found the suite passed with `name_order` discarded from grouping). Parse your change's names in each comma shape and each order, and read the ones you did not predict. ### Reading a grid's moves Read a generated grid's moves by SHAPE, not by corpus membership: #614's first design passed the suite, the five gates and a fingerprint of every corpus name, because its grid moves were sorted into 'corpus name moved' and 'garbage', and the review found the moves that mattered among the 'garbage' -- a bare salutation (`Mr. and Mrs.` lost its connective to the family name) and a name with one trailing word (`Freiherr von Berg X.Y.Z.`). Pull out the head-plus-one-word slice of the grid and a salutation with nothing after it, and read every move there. ### Heads and title runs #614's second review found two gaps. The first was a shape: no grid put TWO words that are both a title and a particle at the head (`Freiherr St van Berg MA`, where an index into the pieces was applied to a merged view of them -- `TITLES ∩ particles_ambiguous` is `{freiherr, st}`, so put both in every head list, each in its bare and its period-marked spelling -- #637's grid had `St` and `Freiherr` alone and missed that `St.` and `Freiherr.` are declined by the bound join and then attached by P6). #701's review added one: put a title/suffix dual (`Ms`, `Sr.`, `Cpl`) SECOND in a title run, behind an ordinary title. A stop that asks only "is this word suffix vocabulary" passes every row that opens with the dual and fails `Rev. Sr. Smith, Mary`; it was found by comparing `T Smith, John` against `T John Smith` over every title word, and a comma-versus-comma-less comparison is the cheap check for any rule that moves a word between the two slots. ### A report's field The second was in what the grids compared: they compared ambiguity KINDS, so a report telling the caller the wrong field (`Freiherr von Berg Ma`, 'read as a given name' of a word in the family) passed every pin (#614). So compare each report's `detail` against the field its tokens land in -- since #626 `test_a_report_names_the_field_its_word_lands_in` (tests/v2/test_cases.py) does it over every non-locale case row under all three orders (a row carrying its own policy only as declared), for the phrasings its `_FIELD_CLAIMS` lists -- so a row with the shape is the test only where the report's wording is listed: a new emitter's wording goes in that list, and segment's report on a part past the second comma names no field (#629). #626's own first grid then missed an emitter for want of one comma shape (`, Ma Dr.`: an empty head, an ambiguous word, a trailing title, which H1 moves to the family): a trailing title belongs in every empty-head comma grid too. C15's invariant holds that shape's fields and kinds but never a report's detail, so a grid checking details keeps the empty head (`, Sir Jones vd` reports 'vd' joining "the family the comma named", a comma C15 says names none). ### Exclusion lists and shared predicates #620 found that an exclusion list in code being replaced is a set of rules, not noise -- #614's merged copy excluded every word the trailing read weighs from its units, the first #620 prototype dropped the list as a symptom of the copy, and its review found the list had been keeping positional counting's rule that a member in front counts as a word (`Freiherr von Berg MA X.Y.Z.` lost its suffix). Before deleting one, mutate each entry out and read what moves. The converse holds for replacing a hand copy WITH the shared predicate: it is a behavior change wherever the two disagreed, and #735's /simplify shipped one as "no reading moves" because the suite and the snapshot were its only check (`St Sir de Mesnil` family-first, a token test replaced by P1's piece test); diff the two predicates' answers over a grid before calling the swap a refactor. ## Sweep the spellings: a class reached by vocabulary is also reached by SHAPE A test that uses only the listed spelling of a word class walks only the vocabulary path, and the shape path is a separate branch that can be missing while every test passes. When a change touches a word class, test one spelling from each path. Each subsection below is a class where a path was missed, with the PR that found it; add a new class here when a review finds one (root AGENTS.md's Workflow section says how). ### Roman numerals Roman numerals are the sharpest case: `ii`, `iii` and `iv` are suffix vocabulary; `i` and `v` are vocabulary too but initial-shaped, so `is_suffix_piece` vetoes them and only the numeral fork reads them; and `vi` through `x` are in no list at all, read by the fork's shape test alone (checked 2026-10-04 against `Lexicon.default()`) -- which stops at `x`: `_vocab._ROMAN` matches I to X, so `John Smith XI` reads `XI` as a name word (re-checked 2026-10-07, #614's /simplify, which found a docstring claiming "any length"). And the fork counts a ONE-letter numeral besides, since #718 (rules/S10): it is the suffix only where the name has a word to spare, so `John V` and `John Smith V` take different branches of one fork, a one-case name being counted like capitals whatever the letter -- since #721 a one-case one-letter numeral carries the `initial` tag its capital does, and since #723 every one-case bare letter does (rules/T4), and a bare letter in front of the numeral is initial-shaped as its capital is, so `john v` reads as `JOHN V` does over `test_a_one_case_name_reads_as_its_capitals`'s grid (comma shapes, orders, a maiden clause, a particle chain, a bare letter in front, a trailing title, a non-ASCII letter in front: 315 of its then 1,872 pairs disagreed at 2bfa9533, before #721, and none after). Its first version claimed "every shape" with no bare letter in front and no trailing title in the grid, and #721's reviews found both broken; a grid's claim is the grid -- put a head-plus-numeral row beside every words-to-spare row. So a change that handles `V` and `III` can still miss `VI` — #610 found exactly that, the maiden take's candidate loop admitting a shape-only numeral in one position where the peel reads it in two (`Jane Doe nee Smith VI Prof.` kept maiden `Smith VI` while `V` and `III` worked). #656's sweep found the same class from the other side: C12 takes the word ending the name before a comma as EVIDENCE, and its first draft asked the suffix-word tag, so `John Smith VI, AD` missed the rule `III` reached, and then counted the whole part, so `Smith VI, AD` fired with `VI` as its own name word and `Smith V, AD` with a `V` that S10 reads as the family. A rule that weighs a numeral as a generation must ask the predicate that READS it as one (the trailing fork, `_vocab.reads_as_generation`, and its words-to-spare count), not the vocabulary that lists some of its spellings. ### Credentials and titles The same split runs through the credentials (`MA` listed, `X.Y.Z.` by the dotted shape, `XYZ` by the caps shape, `MD/PhD` by its listed chunks and `PU-PH` by the caps shape's joined spelling, #752, each with its joiner glued, spaced and glued to one side, which are three token streams -- and a new chunk reading inherits the old one's exceptions, which #752's first draft did not (`Jack I-V` lost its family to the listed numerals, and the fix's length test counted a chunk's period, so `Jack I.-V.` did too, found by the second review), and `Ph. D.` merged by group into one flagged piece the peel's walk never holds -- #603 found #602's run-start predicate refusing it too, so `Smith, John Ph. D. Jones` kept middle 'Jones' where `Smith, John PhD Jones` read suffix 'PhD Jones') and the titles (`Prof.` period-marked and read at the end of a name, `Prof` bare and a name word there, and `Mr. and Mrs.` a run P3 joins, carrying a piece tag a one-word vocabulary test never sees -- PR #735's review found C12's hand copy of C13's title test (C8's until its 2026-10-19 split) reading `John Smith Jr., Mr. and Mrs. AD` as all suffix; ask C13's predicates in C13's order, `not is_suffix_piece` then `is_title_piece`, not the token tags, since `is_title_piece` alone reads the duals `Ms` and `Sr.` as titles). ### A word a rule only asks ABOUT Testing one spelling from each path holds for a word a rule only asks ABOUT, too: #810's first draft asked whether any word after a bracketed credential lacked the suffix TAG, and kept `John Smith (MA) VI`, `(MA) Ph. D.` and `(MA) Dr.` nicknames while `(MA) III` and `(MA) PhD` escaped; asking the role the parse read for the following word answers for every spelling. ### A separator's spacing The same holds for a separator's SPACING: a comma (or any punctuation) can stand alone, glue to the word before it or glue to the word after, and each is a different token stream. #653's first fix swept `(née, Jones)` and missed `(née , Jones)`, where the kept comma became a word after the marker and M3 read the clause as maiden ', Jones'; its review found it. Put all three spacings in any grid around a separator. And a separator glued to a word hides the word from every vocabulary test, not only the one being changed: the semicolon delimiter's first draft read a glued word's stem in C1's run test alone and put 365 of ClinicalTrials.gov's reversed names right, missing the ones C13, C4 and C6 read off a word's TAGS (`MBBS;`, `MA;`); tagging the stem put 412 right, and reading it again in the comma decision's shape tests, which read a word's text rather than its tags (`MBBS;`), 431 (decisions/C10, 2026-10-24). A rule over a glued spelling reads the stem everywhere the word is read, its tags and every test of its text, and is swept against the space twin -- with a declared word delimiter too, which a substring test finds inside other words ('Stand' under ' and ', the review of the same change). And a mark that separates standing alone can be a word's own letter glued: #889's first build made the hyphen a built-in delimiter whole, as the semicolon is, and cut the article `al-` off `al- Rashid` (rules/P6's example caught it), so the built-in hyphen and slash part only standing alone (decisions/C10, 2026-10-27). Before giving a mark a glued reading, look for the words that spell it at an end (`al-`, `d'`) or inside (`MD/PhD`). And making a mark built in empties every test that DECLARED it to exercise the declared path: after #889 the tests declaring ` - ` passed with the declaration turned off, about thirty of them, until the review moved them to ` | `. When a configured value becomes a default, make the configuration inert in a scratch copy and run the suite, and move every test that still passes to a value the default does not hold. ### A full stop inside a word A full stop INSIDE a word has four spellings of its own: initials run together (`J.R.R.`), joined words each closed by a stop (`Lt.Gov.`, `Msc.Ed.`), a joined word or name closed by none (`St.John`, `J.Smith`), and an abbreviation's own ending, closed or not (Italian `Sig.ra`, Latvian `Dr.philol.`). Shape tells the last from none of the others, so a rule over the shape decides which it costs; #838 found the ending only through the snapshot's CLDR row `Dut.ra Prof.ra`, and its first narrowing traded `St.John` for `Sig.ra` while the snapshot listed the `Dr.philol.` move among moves read as intended, until the design-docs review named it. Initials run together are also how some SURNAMES are written (Nepali `B.K.`, `K.C.`), and a caseless script writes its abbreviations, degrees included, as dotted letters (Thai `พ.บ.`): #864 found S3's position reading taking the first from 2.4.0 on, visible only once #866's Nepal candidates gave the corpora a population that writes them, and its first fix moved CLDR's Thai degrees into the name. A rule over dotted letters reads both before it ships. ### Capitals between two name words Capitals between two name words have the same second reading: some writers put their own initials there (`Maria Ana MA Silva`), so a rule that reads a word's capitals as a credential reads the initials too; #785 met it only in ctgov's field moves, no grid having held the shape, and Derek chose the initials (rules/S8: a name word behind capitals keeps them a name word where no family comma stands). ### Adjacent pairs of words A predicate over ADJACENT PAIRS of words meets two shapes no grid of one word holds: an overlapping pair and a joining word beside an initial. #752's split-credential test (phase 2e) read `Dr. Ph.` in `Jane Doe Dr. Ph. D.` as the listed `drph`, leaving `D.` a word of its own (found by the maiden take's candidate grid), and a particle beside a surname written short as the listed `ded` (`DE D JUNIOR`, found only in tse-donors' readings), so put a title, a particle and a connective beside each pair spelling, and a pair overlapping another, in the grid. ## Sweep the encoding: a letter class written `[^\W\d_]` or tested with `isalpha()` is blind to a combining mark Python's `\w` and `isalpha()` do not match Mn, so a letter written as base plus accent (NFD) ends a word early; the vocabulary path is immune because `_normalize` NFC-folds, the shape path is not. ### The two families #652 found #652: `Dr. inž. Janez Novak` read `inž.` as the given name when the ž was decomposed, and `Janez Novak, ŽŠČ` reversed, in two families that share the blind spot — the letter-shape regexes (`period_abbreviation`, `_DOTTED`, `_CHUNKED`, `_PAIRED_INITIALS`), now spelled with `_vocab._LETTER` (a letter plus the general combining blocks; `test_regex_sync` compares the two `_MARKS` strings and pins `period_abbreviation` against config's copy), and the caps predicates, which NFC-compose a non-ASCII word before `isalpha()` (`is_caps_word`, behind C-level `isalpha()`/`is_normalized` tests, so already-composed text pays no frame — measured 2026-10-12, `Smith, José` was 193 frames before and after; the first version called it for every non-ASCII word and cost one frame each, and no guard saw it because the reference name is ASCII). ### Widths and the initial shape A width test must count letters, not code points (`composed_len`): admitting a decomposed word to the caps set put `John Smith, MÁ` on the pair exception's wrong side, and the review's grid found it where the rows (all three letters long) did not — put a two-letter word in every caps grid. The initial shape (`_INITIAL`, `\w\.`, v1-verbatim and public) is not widened but asked of the COMPOSED word, in `is_initial_shaped` and in `_render`'s two copies; and the single-letter connective tests count composed letters, since a decomposed `й` is two code points (#665, where `É. y É. Smith` and `John й Smith` read differently decomposed). ### Compose the WHOLE predicate Compose the WHOLE predicate, not one half: `is_initial` is a shape ANDed with the #320 script veto, and composing the shape alone made a decomposed `씨.` an initial, since decomposed hangul is conjoining jamo outside every script range (#665's first version; `in_initialless_script` composes too now). ### What is deliberately not covered Not covered, deliberately: the marks only some scripts write (Indic vowel signs, Arabic and Hebrew points), declined in decisions/nfd-shapes because widening the class would move readings nobody had measured; rules/H2's Accepted clause records the one consequence it names, that a period-marked abbreviation in Bengali or Devanagari never reads as a title by shape. ### The guards The guard is `test_a_decomposed_name_renders_as_its_composed_twin` over the CLDR corpus plus `test_a_decomposed_abbreviation_reads_as_its_composed_twin` (rows × three orders × EVERYWHERE); the corpus guard reaches a shape only if some corpus name does, which is how the caps shape was missed until review, so when adding a shape rule add a row there. Four of #652's rows and two of #665's pass with every fix reverted (measured) and are parity pins, and the test compares ambiguity kinds and `initials()` too, since #665's `John й Smith` differed in its report; `_DOTTED` has no observable input and is pinned by a direct call. derek73-python-nameparser-456b024/nameparser/__init__.py000066400000000000000000000026161527303271700231620ustar00rootroot00000000000000from nameparser._version import VERSION as VERSION from nameparser._version import __version__ as __version__ from nameparser.parser import HumanName as HumanName __author__ = "Derek Gulbranson" __author_email__ = 'derek73@gmail.com' __license__ = "LGPL" __url__ = "https://github.com/derek73/python-nameparser" from nameparser._lexicon import Lexicon from nameparser._locale import Locale from nameparser._parser import Parser, parse, parser_for from nameparser._policy import ( DEFAULT_NICKNAME_DELIMITERS, DEFAULT_SCRIPT_ORDERS, FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST, GIVEN_FIRST, UNSET, CapsSuffixes, PatronymicRule, Policy, PolicyPatch, Script, ) from nameparser._types import ( STABLE_TAGS, Ambiguity, AmbiguityKind, ParsedName, Reading, ReadingKind, ReadingSource, Role, Segmentation, Segmenter, Span, Token, ) __all__ = [ # v1 (compatibility layer) "HumanName", # v2 core "Span", "Role", "Token", "Ambiguity", "AmbiguityKind", "Reading", "ReadingKind", "ReadingSource", "ParsedName", "STABLE_TAGS", "Segmentation", "Segmenter", "Lexicon", "Policy", "PolicyPatch", "PatronymicRule", "Script", "UNSET", "CapsSuffixes", "GIVEN_FIRST", "FAMILY_FIRST", "FAMILY_FIRST_GIVEN_LAST", "DEFAULT_NICKNAME_DELIMITERS", "DEFAULT_SCRIPT_ORDERS", "Locale", "Parser", "parse", "parser_for", ] derek73-python-nameparser-456b024/nameparser/__main__.py000066400000000000000000000032511527303271700231370ustar00rootroot00000000000000"""Command-line debug helper over the 2.0 API. python -m nameparser "Dr. Juan Q. Xavier de la Vega III" python -m nameparser --json "Doe, John" """ import argparse import json from nameparser import parse def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser( prog="nameparser", description="Parse a personal name.") ap.add_argument("name", help="the name string to parse") ap.add_argument("--json", action="store_true", help="print the component dict as JSON") ap.add_argument("--locale", metavar="CODE", help="parse with a locale pack (e.g. 'ru'); see " "nameparser.locales") args = ap.parse_args(argv) # `is not None`, not truthiness: --locale "" must reach get() and # exit 2 listing the codes, not silently fall back to the default if args.locale is not None: from nameparser import locales, parser_for try: parser = parser_for(locales.get(args.locale)) except KeyError as exc: ap.error(exc.args[0]) # exits 2, message to stderr n = parser.parse(args.name) else: n = parse(args.name) if args.json: print(json.dumps(n.as_dict(), ensure_ascii=False)) return 0 # Label each section: the two reprs are byte-identical for input # that is already correctly cased, so without labels there is no # telling which is the parse and which is the capitalized view. print("Parsed:") print(repr(n)) print("Capitalized:") print(repr(n.capitalized())) print("Initials:", n.initials()) return 0 if __name__ == "__main__": raise SystemExit(main()) derek73-python-nameparser-456b024/nameparser/_config_shim.py000066400000000000000000002222321527303271700240450ustar00rootroot00000000000000"""v1 ``Constants`` compatibility shim over Lexicon/Policy (mechanisms.md#CONFIG-SHIM-SNAPSHOT). ``nameparser.config`` re-exports these names from the swap commit onward; the whole module is deleted in 3.0 with the facade. Layering: facade layer -- may import anything public; here that's ``nameparser.util`` for ``lc()`` (as ``_lc``, the name that does not warn), ``nameparser.config.regexes`` for the read-only regexes proxy's underlying compiled patterns, and ``nameparser._lexicon``/``_policy``/``_parser`` for the ``_snapshot()`` translation and the shared parser cache. All ``nameparser.config.`` imports below are deferred (function-local), never module-level: ``nameparser/config/__init__.py`` itself imports ``CONSTANTS``/``Constants``/etc. from this module, so a module-level ``from nameparser.config.regexes import REGEXES`` here would need the ``nameparser.config`` package's ``__init__.py`` to run to completion first -- which needs this module to already be fully initialized. Importing the data submodules lazily, on first use, breaks that cycle. """ from __future__ import annotations import copy import functools import re import sys import warnings from collections.abc import ( Callable, ItemsView, Iterable, Iterator, KeysView, Mapping, Set, ValuesView, ) from types import FrameType from typing import NamedTuple, Self from nameparser._lexicon import ( Lexicon, _MaskValueError, _normalize, _title_key, _warn_dead_entry, ) from nameparser._parser import Parser from nameparser._policy import PatronymicRule, Policy from nameparser.util import _lc as lc #: The eight multi-word entries the pre-2.0 DEFAULT vocabulary shipped. #: Provably inert in every release (they can never match; see the #: 2.0.0 release log), so dropping them from a restored legacy pickle #: changes no parse -- and keeps the multi-word warning from firing #: eight times, with wrong advice, at library-internal lines, on the #: first parse after a supported 1.3/1.4 pickle upgrade. #: #: Gated on ALL EIGHT being present across the two fields -- the #: signature of a pre-2.0 blob, which froze the complete shipped set. #: A round-trip of 2.0-era state that carries FEWER than all eight #: keeps them (a user who deliberately re-added one or two does not #: match the signature, and .copy() never subtracts either). The trade: #: a 2.0 user who re-adds ALL eight exact strings is indistinguishable #: from a legacy blob and loses them on the next unpickle. _LEGACY_DEAD_ENTRIES = { "titles": frozenset({"chargé d'affaires"}), "suffix_acronyms": frozenset({ "leed ap", "nicet i", "nicet ii", "nicet iii", "nicet iv", "psm i", "psm ii"}), } def _reject_bare_str_or_bytes(value: object, expected: str) -> None: # A bare string is an iterable of its characters, so e.g. SetManager('dr') # would silently shred it into {'d', 'r'} instead of raising -- shared by # SetManager's constructor/operands (#238/#241) and TupleManager's # constructor (#242). Ported from v1's `_reject_bare_str_or_bytes`. if isinstance(value, bytes): raise TypeError( f"expected {expected}, got a single bytes; decode it first: " f"{value!r}.decode()" ) if isinstance(value, str): raise TypeError( f"expected {expected}, got a single str; wrap it in a list: " f"[{value!r}]" ) def _lc_validated(s: object) -> str: # Validates and lc()-normalizes a single element -- shared by bulk # iterable normalization (constructor/operands) and add()'s per-string # loop, so every path raises the same #238/#245-shaped TypeError instead # of lc() crashing cryptically (bytes) or silently transmuting (None). if isinstance(s, bytes): raise TypeError( f"expected a str, got bytes; decode it first: {s!r}.decode()" ) if not isinstance(s, str): raise TypeError(f"expected a str, got {type(s).__name__}: {s!r}") return lc(s) def _normalize_iterable_of_strings( elements: object, expected: str = "an iterable of strings") -> set[str]: # a SetManager's elements were already validated/normalized when it was # built, so copy them instead of re-validating (v1's fast path) if isinstance(elements, SetManager): return set(elements._elements) _reject_bare_str_or_bytes(elements, expected) return {_lc_validated(s) for s in elements} # type: ignore[attr-defined] class SetManager: """v1 ``SetManager`` surface over a plain set of ``lc()``-normalized strings. Mutations call ``_on_change`` (the owning Constants' generation bump, wired by the facade). ``__call__`` and the missing-member-tolerant ``remove()`` are gone per the #243 schedule (warned 1.3.0, removed 2.0): ``remove()`` of a missing member raises ``KeyError``, matching ``set.remove``. """ _elements: set[str] _on_change: Callable[[], None] | None def __init__(self, elements: Iterable[str] = (), _on_change: Callable[[], None] | None = None, _field: str | None = None) -> None: # _field carries a Constants field name (e.g. "titles") through to # the bare-str/bytes guard's message, when this SetManager is being # built on behalf of a named Constants field (constructor kwarg or # __setattr__ auto-wrap); a direct SetManager(...) call gets the # generic v1 message instead. expected = f"{_field} to be an iterable of strings" if _field \ else "an iterable of strings" self._elements = _normalize_iterable_of_strings(elements, expected) self._on_change = _on_change def _changed(self) -> None: if self._on_change is not None: self._on_change() def add(self, *strings: str) -> SetManager: """Add the normalized string arguments to the set. Returns ``self`` for chaining.""" # notify only on real change (v1 parity): a no-op add must not # bump the owner's generation changed = False try: for s in strings: normalized = _lc_validated(s) # TypeError on bytes (#245) if normalized not in self._elements: self._elements.add(normalized) changed = True finally: # a TypeError mid-list still leaves earlier additions # applied, so the owner must hear about them or its cache # goes stale (same rule as remove() below) if changed: self._changed() return self def remove(self, *strings: str) -> SetManager: """Remove the normalized string arguments from the set. Raises ``KeyError`` if any argument is not a member. Returns ``self`` for chaining.""" changed = False try: for s in strings: # _lc_validated: bytes get the same decode-hint TypeError # as add() (#245) normalized = _lc_validated(s) self._elements.remove(normalized) # KeyError on missing (#243) changed = True finally: # a KeyError mid-list still leaves earlier removals applied, # so the owner must hear about them or its cache goes stale if changed: self._changed() return self def discard(self, *strings: str) -> SetManager: """Remove the normalized string arguments from the set if present; missing members are ignored, like ``set.discard``. Returns ``self`` for chaining.""" changed = False for s in strings: normalized = _lc_validated(s) # bytes decode hint (#245) if normalized in self._elements: self._elements.discard(normalized) changed = True if changed: self._changed() return self def clear(self) -> SetManager: """Remove all entries from the set. Returns ``self`` for chaining.""" if self._elements: self._elements.clear() self._changed() return self def __contains__(self, item: object) -> bool: return isinstance(item, str) and lc(item) in self._elements def __iter__(self) -> Iterator[str]: return iter(self._elements) def __len__(self) -> int: return len(self._elements) def __eq__(self, other: object) -> bool: if isinstance(other, SetManager): return self._elements == other._elements if isinstance(other, (set, frozenset)): return self._elements == other return NotImplemented __hash__ = None # type: ignore[assignment] # mutable; v1 parity # -- set operators: accept ANY iterable (v1.3 normalize-everywhere) ----- # A bare str/bytes operand raises TypeError via _normalize_iterable_of_ # strings rather than iterating its characters (#238/#241); everything # else (list, generator, set, another SetManager, ...) is normalized # through lc() before the plain set op runs. def __or__(self, other: object) -> set[str]: return self._elements | _normalize_iterable_of_strings(other) __ror__ = __or__ def __and__(self, other: object) -> set[str]: return self._elements & _normalize_iterable_of_strings(other) __rand__ = __and__ def __sub__(self, other: object) -> set[str]: return self._elements - _normalize_iterable_of_strings(other) def __rsub__(self, other: object) -> set[str]: return _normalize_iterable_of_strings(other) - self._elements def __xor__(self, other: object) -> set[str]: return self._elements ^ _normalize_iterable_of_strings(other) __rxor__ = __xor__ # symmetric difference is commutative, like v1 # -- comparisons: v1 subclassed collections.abc.Set, whose __le__/__lt__/ # __ge__/__gt__ mixins only accept another Set-registered operand (set, # frozenset, or another Set subclass) -- NOT an arbitrary iterable like # list, unlike the operators above. Mirrored here since this SetManager # doesn't itself subclass the ABC. def __le__(self, other: object) -> bool: if not isinstance(other, (SetManager, Set)): return NotImplemented if len(self) > len(other): return False return all(elem in other for elem in self) def __lt__(self, other: object) -> bool: if not isinstance(other, (SetManager, Set)): return NotImplemented return len(self) < len(other) and self.__le__(other) def __ge__(self, other: object) -> bool: if not isinstance(other, (SetManager, Set)): return NotImplemented if len(self) < len(other): return False return all(elem in self for elem in other) def __gt__(self, other: object) -> bool: if not isinstance(other, (SetManager, Set)): return NotImplemented return len(self) > len(other) and self.__ge__(other) def __repr__(self) -> str: # Sorted so repr is stable across runs -- set() iteration order # depends on per-process string hash randomization. elements = ", ".join(repr(e) for e in sorted(self._elements)) return f"SetManager({{{elements}}})" if self._elements else "SetManager(set())" # -- pickle interop with v1 blobs --------------------------------------- def __getstate__(self) -> dict[str, object]: return {"_elements": set(self._elements)} def __setstate__(self, state: dict[str, object]) -> None: # v1 SetManager stored its set under `elements` (plain __dict__ # pickling); the shim stores `_elements`. Accept both, so a # v1.3/1.4 Constants blob's embedded managers unpickle straight # into shim instances. Re-normalize: nothing guarantees an # incoming blob's elements passed through lc(). elements: Iterable[str] = state.get( # type: ignore[assignment] "_elements", state.get("elements", ())) self._elements = {lc(e) for e in elements} self._on_change = None # rewired by the owning Constants def _validated_mapping_args(args: tuple[object, ...]) -> tuple[object, ...]: """The #242 constructor guard, shared by TupleManager and _DelimiterManager: a bare str/bytes silently shreds into a garbage mapping (dict's own error), and an iterable of short strings silently splits each one into a (key, value) pair -- ported from v1's TupleManager.__init__ guard.""" if not args: return args arg = args[0] _reject_bare_str_or_bytes( arg, "a mapping or iterable of (key, value) pairs") if not isinstance(arg, Mapping): checked = [] for item in arg: # type: ignore[attr-defined] if isinstance(item, (str, bytes)): kind = "bytes" if isinstance(item, bytes) else "str" raise TypeError( f"expected (key, value) pairs, got a {kind} " f"element {item!r}; a 2-character string " "silently splits into a key and a value" ) checked.append(item) args = (checked, *args[1:]) return args class TupleManager(dict[str, object]): """v1 ``TupleManager``: a dict with dot-notation access. Backs ``capitalization_exceptions``. Unknown-key attribute access raises ``AttributeError`` naming the key (#256, warned 1.4, enforced 2.0 -- the v1 ``DeprecationWarning`` is gone, this shim only speaks 2.0). Mutations call ``_on_change`` (the owning Constants' generation bump, wired by the facade). """ _on_change: Callable[[], None] | None def __init__(self, *args: object, _on_change: Callable[[], None] | None = None, **kwargs: object) -> None: args = _validated_mapping_args(args) super().__init__(*args, **kwargs) self._on_change = _on_change def _changed(self) -> None: if self._on_change is not None: self._on_change() def __getattr__(self, name: str) -> object: # Only reached for a missing attribute -- real instance attrs # (_on_change) and dict methods (keys, get, ...) resolve first # without ever hitting this. Dunder/underscore probes (pickling, # copy.deepcopy, IPython's _repr_html_) are never config keys. if name.startswith("_"): raise AttributeError(name) try: return self[name] except KeyError: # #256: name the known keys, like v1's 1.4 deprecation warning # did -- this shim only speaks 2.0, so what was a warning there # is a hard AttributeError here. raise AttributeError( f"{name!r} is not a known key " f"({', '.join(sorted(self))}); use .get() for intentional " "soft access." ) from None def __setattr__(self, name: str, value: object) -> None: # v1 parity: dunder probes (typing's __orig_class__, etc.) and this # shim's own _on_change hook get real object-attribute storage; # every other name -- including single-underscore ones, per v1 -- # routes to the dict so `t.mcdonald = 'x'` and `t['mcdonald'] = 'x'` # are the same operation. if name == "_on_change" or (name.startswith("__") and name.endswith("__")): object.__setattr__(self, name, value) else: self[name] = value def __delattr__(self, name: str) -> None: if name == "_on_change" or (name.startswith("__") and name.endswith("__")): object.__delattr__(self, name) else: del self[name] def __setitem__(self, key: str, value: object) -> None: super().__setitem__(key, value) self._changed() def __delitem__(self, key: str) -> None: super().__delitem__(key) self._changed() def pop(self, *args: object) -> object: # bump only on a real removal -- pop(key, default) on a missing # key is a no-op read, not a mutation, same rule as SetManager's # no-op add() present = bool(args) and args[0] in self result = super().pop(*args) # type: ignore[call-overload] if present: self._changed() return result def popitem(self) -> tuple[str, object]: result = super().popitem() # KeyError when empty: no bump self._changed() return result def clear(self) -> None: had_items = bool(self) super().clear() if had_items: # clearing an empty dict is a no-op, not a change self._changed() def update(self, *args: object, **kwargs: object) -> None: # dict.update's C path skips a subclass __setitem__; route every # item through it so subclass validation (_DelimiterManager's # sentinel rule) and the owner notification hold here too for key, value in dict(*args, **kwargs).items(): self[key] = value def setdefault(self, key: str, default: object = None) -> object: if key in self: return self[key] # existing key: a read, not a mutation self[key] = default # validated + notifying path return default # in-place |= must validate/notify like update; dict's C path would # skip both. mypy flags any non-overloaded __ior__ as inconsistent # with dict.__or__'s overloads -- the runtime behavior is the plain # dict |= contract, so the ignore is about typeshed shape only. def __ior__(self, other: object) -> Self: # type: ignore[override, misc] self.update(other) return self # -- pickle interop ------------------------------------------------- def __reduce__(self) -> tuple[type[TupleManager], tuple[()], dict[str, object]]: return (type(self), (), dict(self)) def __setstate__(self, state: dict[str, object]) -> None: # routes through __setitem__ (validated for _DelimiterManager); # _on_change is still None here, so no spurious bumps self.update(state) self._on_change = None # rewired by the owning Constants #: The named delimiter buckets, translated to the ``Policy`` #: (open, close) pairs they stand for. The first three are #: v1's; the rest are the #273 typographic conventions plus #597's #: halfwidth corner brackets, named so the v1 keyed idioms #: (pop/move/del) work on them like the originals. #: Keep in sync with DEFAULT_NICKNAME_DELIMITERS in _policy.py (pinned #: by the default-Constants equality test). _SENTINEL_PAIRS = { "quoted_word": ("'", "'"), "double_quotes": ('"', '"'), "parenthesis": ("(", ")"), "smart_double_quotes": ("“", "”"), "low_high_quotes": ("„", "“"), "right_double_quotes": ("”", "”"), "guillemets": ("«", "»"), "reversed_guillemets": ("»", "«"), "corner_brackets": ("「", "」"), "white_corner_brackets": ("『", "』"), "halfwidth_corner_brackets": ("「", "」"), "fullwidth_parenthesis": ("(", ")"), } #: derived, so the manager's accepted keys and _snapshot()'s #: translation table can never drift apart _DELIMITER_SENTINELS = tuple(_SENTINEL_PAIRS) class RegexTupleManager(TupleManager): # pickle-compat: do NOT delete """Pickle-compat alias only: v1.4's ``Constants.regexes`` field was a ``nameparser.config.RegexTupleManager`` instance (a ``TupleManager`` subclass whose ``__getattr__`` fell back to ``EMPTY_REGEX`` for an unknown key). Unpickling a v1.4 blob resolves and constructs this class -- via ``TupleManager.__reduce__``'s ``(type(self), (), state)`` -- before ``Constants.__setstate__`` runs, so the name must exist here even though the shim's ``Constants._snapshot()`` never reads it: ``regexes`` is a read-only ``_RegexesProxy`` in 2.0 (see above), and ``Constants.__setstate__`` below deliberately ignores an incoming ``regexes`` key rather than restoring it from this reconstructed (and otherwise unused) instance. """ class _DelimiterManager(TupleManager): """v1 ``nickname_delimiters``/``maiden_delimiters`` bucket. In 2.0 only the named sentinels in ``_DELIMITER_SENTINELS`` exist (the v1 trio plus the typographic pairs, #273 and #597) -- assigning any other key raises so a caller reaches for a custom-delimiter Policy kwarg instead of a dict entry that silently does nothing. ``pop()``/ ``__setitem__``/``__delitem__`` stay open (inherited) for the documented bucket-move idiom, e.g. ``maiden_delimiters['parenthesis'] = nickname_delimiters.pop('parenthesis')``. """ def __init__(self, *args: object, _on_change: Callable[[], None] | None = None, **kwargs: object) -> None: # dict's C-level __init__ never calls a subclass __setitem__, so # collect and validate the initial items here -- BEFORE any item # lands -- or the sentinel rule silently misses the constructor. # The parent's #242 guard runs first: bare str/bytes gets the # friendly TypeError, not dict's cryptic one. args = _validated_mapping_args(args) items: dict[str, object] = dict(*args, **kwargs) for key in items: self._reject_non_sentinel(key) super().__init__(items, _on_change=_on_change) @staticmethod def _reject_non_sentinel(key: str) -> None: if key not in _DELIMITER_SENTINELS: raise TypeError( f"2.0 delimiter managers accept only the named sentinels " f"{_DELIMITER_SENTINELS}; for custom delimiter pairs use " f"Policy(nickname_delimiters=...) / maiden_delimiters" ) def __setitem__(self, key: str, value: object) -> None: self._reject_non_sentinel(key) super().__setitem__(key, value) # update/setdefault/|= inherit TupleManager's routing through # __setitem__, so they validate (and notify) for free class _RegexesProxy: """Read-only view over the v1 compiled patterns (``nameparser.config.regexes.REGEXES``). Reads keep working -- ``CONSTANTS.regexes.word`` stays informational -- but 2.0 configures parsing behavior through named ``Policy`` flags, not by mutating a regex, so any attribute *or* item assignment raises ``TypeError`` (the shim's uniform read-only rule). """ @staticmethod def _regexes() -> Mapping[str, object]: # deferred import: see the module docstring's note on why every # nameparser.config. import in this file is lazy from nameparser.config.regexes import _REGEXES return _REGEXES #: dict methods this read-only proxy deliberately does not carry. #: Without this, __getattr__ reports them as missing regexes and #: sends the reader hunting for a vocabulary entry. _DICT_ONLY = frozenset({ "pop", "popitem", "setdefault", "update", "clear", "fromkeys", }) def __getattr__(self, name: str) -> object: if name.startswith("_"): raise AttributeError(name) if name in _RegexesProxy._DICT_ONLY: raise AttributeError( f"{name!r} is not supported on CONSTANTS.regexes: it is a " f"read-only view in 2.0, and parsing behavior is configured " f"through named Policy flags" ) try: return self._regexes()[name] except KeyError: raise AttributeError(f"no regex named {name!r}") from None def __getitem__(self, name: str) -> object: return self._regexes()[name] def __contains__(self, name: object) -> bool: return name in self._regexes() def __iter__(self) -> Iterator[str]: return iter(self._regexes()) def keys(self) -> KeysView[str]: return self._regexes().keys() # Defined explicitly because __getattr__ would otherwise claim each # of these names as a regex lookup and raise "no regex named # 'items'". The sibling managers inherit the whole surface from # dict; this proxy has to spell out the read half it supports. # #256's deprecation text promised .get() on both managers. def get(self, name: str, default: object = None) -> object: return self._regexes().get(name, default) def items(self) -> ItemsView[str, object]: return self._regexes().items() def values(self) -> ValuesView[object]: return self._regexes().values() def __len__(self) -> int: return len(self._regexes()) def copy(self) -> dict[str, object]: # v1's regexes was a dict subclass, so copy() returned a plain # mutable dict. Mutating the copy never affected parsing there # either, so this is a faithful read-only carry-over. return dict(self._regexes()) def __setattr__(self, name: str, value: object) -> None: self._raise_readonly(name) def __setitem__(self, name: str, value: object) -> None: self._raise_readonly(name) @staticmethod def _raise_readonly(name: str) -> None: # bidi/emoji are the two regexes v1 code toggled directly # (`CONSTANTS.regexes.bidi = False`) to opt out of stripping; # point those two at their named Policy replacement, everything # else gets the generic pointer. hints = { "bidi": "use Policy(strip_bidi=False) to keep bidi marks", "emoji": "use Policy(strip_emoji=False) to keep emoji", } hint = hints.get( name, "parsing behavior is configured through named Policy " "flags in 2.0; if none fits, open an issue") raise TypeError( f"assigning CONSTANTS.regexes.{name} is not supported in " f"2.0: {hint}" ) _SET_FIELDS = ( "prefixes", "suffix_acronyms", "suffix_not_acronyms", "suffix_acronyms_ambiguous", "titles", "first_name_titles", "conjunctions", "bound_first_names", "non_first_name_prefixes", ) _MANAGER_FIELDS = _SET_FIELDS + ( "capitalization_exceptions", "nickname_delimiters", "maiden_delimiters", ) def _v1_matchable(entry: str) -> bool: """Should the shim translate this config entry at all? v1 compared ``lc()`` of a parsed piece, and a piece comes from a whitespace split, re-joined only with single spaces, so it is never empty and never holds edge whitespace, a whitespace run or a non-space whitespace character. An entry failing the whitespace test matched nothing in 1.x; translating it either starts matching (Lexicon strips the whitespace) or trips a Lexicon invariant the set algebra in ``_build_snapshot`` could not see (#541). ``lc`` first, for a ``capitalization_exceptions`` key, which TupleManager stores as written; set entries are already ``lc()``-folded. The one exception, and the one departure from 1.4.0: an entry Lexicon folds to empty, which since 2.3.0 (FULL_STOPS, #322) includes a lone CJK full stop. 1.4.0 through 2.2.0 could match a token made of that full stop, but since 2.3.0 Lexicon's fold empties both the entry and that token's lookup key, so translating it only raises (#582).""" folded = lc(entry) return (bool(folded) and folded == " ".join(folded.split()) and bool(_normalize(folded))) # Two entries of nameparser 1.4.0's own shipped TITLES carry a trailing # space, so 1.4.0 never matched them ("Actor John Smith" -> first # "Actor"). A restored 1.4 pickle carries them and the user never wrote # them: they are dropped like any unmatchable entry but not named in the # warning. The 2.x data module spells them without the space. Not the # same roster as _LEGACY_DEAD_ENTRIES, on purpose: that one is subtracted # in __setstate__, only when the whole pre-2.0 set is present, so a user's # own re-added entry survives; this one is skipped at snapshot time from # any source, and nothing else about it depends on how the entry got here. _V14_SHIPPED_UNMATCHABLE = frozenset({ ("titles", "actor "), ("titles", "television "), }) def _warn_unmatchable(dropped: list[tuple[str, str]]) -> None: listed = ", ".join(f"{field}: {entry!r}" for field, entry in dropped) remedy = "; ".join( f"del constants.capitalization_exceptions[{entry!r}]" if field == "capitalization_exceptions" else f"constants.{field}.remove({entry!r})" for field, entry in dropped) _warn_dead_entry( f"ignoring Constants entries that match no name word (each is " f"empty, only full stops and spaces, or holds whitespace no name word " f"carries): {listed}. " f"Add the stripped word if one was meant, and remove these: " f"{remedy}") #: v1's Constants.__repr__ field order (#221) -- kept as its own tuple #: rather than reusing _SET_FIELDS, whose order differs (v1 lists #: suffix_acronyms_ambiguous last, not fourth). _REPR_COLLECTION_ATTRS = ( "prefixes", "suffix_acronyms", "suffix_not_acronyms", "titles", "first_name_titles", "conjunctions", "bound_first_names", "non_first_name_prefixes", "suffix_acronyms_ambiguous", ) #: v1's repr scalar order, minus empty_attribute_default -- removed in 2.0 #: (#255), so there's no such attribute on this shim's Constants to show. _REPR_SCALAR_ATTRS = ( "string_format", "initials_format", "initials_delimiter", "initials_separator", "suffix_delimiter", "capitalize_name", "force_mixed_case_capitalization", "patronymic_name_order", "middle_name_as_last", ) _SCALAR_DEFAULTS: dict[str, object] = { "patronymic_name_order": False, "middle_name_as_last": False, "capitalize_name": False, "force_mixed_case_capitalization": False, "string_format": "{title} {first} {middle} {last} {suffix} ({nickname})", "initials_format": "{first} {middle} {last}", "initials_delimiter": ".", "initials_separator": " ", "suffix_delimiter": None, } # distinguishes "attribute not set yet" from any real scalar value # (None is a legitimate value for string_format/suffix_delimiter) _UNSET = object() # distinguishes "kwarg not passed to Constants()" (use library defaults) # from any real value a caller might pass, including a falsy one like "" _UNSET_KWARG = object() #: The migration guide every 2.8 removal message points at. MIGRATION_GUIDE = "https://nameparser.readthedocs.io/en/latest/migrate.html" # The 2.8 removal messages (decisions/3-0-reevaluations, 2026-10-28). # Each names code that type-checks and runs, pinned by # tests/v2/test_deprecations.py, and each opens with a fixed phrase # pyproject's filterwarnings ignores by message. _SHARED_MUTATION_MESSAGE = ( "mutating the shared CONSTANTS singleton is deprecated and will be " "removed in 3.0 with HumanName and Constants; configure a Parser " "instead, e.g. Parser(lexicon=Lexicon.default().add(titles={'dean'})). " f"See {MIGRATION_GUIDE}" ) CONSTANTS_DEPRECATION = ( "Constants is deprecated and will be removed in 3.0 with HumanName; " "configure a Parser instead, e.g. " "Parser(lexicon=Lexicon.default().add(titles={'dean'})). " f"See {MIGRATION_GUIDE}" ) CONSTANTS_PICKLE_DEPRECATION = ( "Constants pickles stop loading in 3.0, when Constants is removed; " "pickle a Parser instead, e.g. " "Parser(lexicon=Lexicon.default().add(titles={'dean'})). " f"See {MIGRATION_GUIDE}" ) #: one message per manager class, keyed by name, for #: nameparser.config.__getattr__ MANAGER_DEPRECATIONS = { name: ( f"nameparser.config.{name} is deprecated and will be removed in " f"3.0 with Constants; Lexicon fields take plain sets, e.g. " f"Lexicon.default().add(titles={{'dean'}}). See {MIGRATION_GUIDE}" ) for name in ("SetManager", "TupleManager", "RegexTupleManager") } def _quiet_setstate(new: object, state: dict[str, object], base: type, message: str) -> None: """Restore a copy through its class's own ``__setstate__``. ``copy.copy``/``copy.deepcopy`` reconstruct through ``__setstate__``, which since 2.8 warns that a pickle was loaded, and a copy is no load. ``base``'s own hook restores privately; a subclass's override is called, as it was before 2.8, with only the load warning filtered, by message. The filter is taken only then: entering ``catch_warnings`` clears every module's warnings registry, so on every copy it would re-show warnings the caller's filters show once per line. A copy of the shared ``CONSTANTS`` stays quiet, as before: a ``HumanName`` built on the copy still warns. """ if type(new).__setstate__ is base.__setstate__: # type: ignore[attr-defined] new._restore(state) # type: ignore[attr-defined] return with warnings.catch_warnings(): warnings.filterwarnings("ignore", message=re.escape(message), category=DeprecationWarning) new.__setstate__(state) # type: ignore[attr-defined] def _default_vocab() -> dict[str, frozenset[str]]: # the compiled vocabulary, read from _compiled: the public modules' # constants warn at a caller's read since 2.8.1 (#1007) # (same rule as Lexicon.default()). from nameparser.config._compiled import BOUND_GIVEN_NAMES from nameparser.config._compiled import CONJUNCTIONS from nameparser.config._compiled import ( NON_GIVEN_NAME_PARTICLES, PARTICLES, ) from nameparser.config._compiled import ( SUFFIX_ACRONYMS, SUFFIX_ACRONYMS_AMBIGUOUS, SUFFIX_WORDS, ) from nameparser.config._titles_2_6_0 import TITLES_2_6_0 from nameparser.config._compiled import ( GIVEN_NAME_TITLES, TITLES, TITLES_AMBIGUOUS) return { "prefixes": PARTICLES, "suffix_acronyms": SUFFIX_ACRONYMS, "suffix_not_acronyms": SUFFIX_WORDS, "suffix_acronyms_ambiguous": SUFFIX_ACRONYMS_AMBIGUOUS, # 2.6.0's titles, less any word that is no title now: one a # later release made a title only inside a title phrase # (rules/H9) stays a v1 title, so remove() and `in` answer as # they did; _build_snapshot reads it by the phrase "titles": TITLES | (TITLES_2_6_0 & TITLES_AMBIGUOUS), "first_name_titles": GIVEN_NAME_TITLES, "conjunctions": CONJUNCTIONS, "bound_first_names": BOUND_GIVEN_NAMES, "non_first_name_prefixes": NON_GIVEN_NAME_PARTICLES, } class _RenderDefaults(NamedTuple): """v1 scalar rendering knobs that have no home on ``Policy`` (mechanisms.md#CONFIG-SHIM-SNAPSHOT): ``__str__``/initials formatting and capitalization stay per-Constants defaults, layered onto a shared ``Parser`` by the facade (nameparser/_facade.py) rather than folded into the cache key.""" string_format: str | None initials_format: str initials_delimiter: str initials_separator: str suffix_delimiter: str | None capitalize_name: bool force_mixed_case_capitalization: bool @functools.lru_cache(maxsize=64) def _cached_parser(lexicon: Lexicon, policy: Policy) -> Parser: # keyed on hashable value objects: shared across every facade whose # Constants resolve to the same snapshot # (mechanisms.md#CONFIG-SHIM-SNAPSHOT) return Parser(lexicon=lexicon, policy=policy) class Constants: """v1 ``Constants`` shim: a mutable container whose state resolves to a frozen ``(Lexicon, Policy, _RenderDefaults)`` snapshot via ``_snapshot()``. ``_generation`` increments on every mutation; facades compare it against a cached value to decide whether their snapshot is stale (dirty-tracking -- the facade side lives in nameparser/_facade.py). The module-level ``CONSTANTS`` singleton (below) has ``_shared`` flipped to ``True``: any mutation reached through it emits ``DeprecationWarning`` pointing at ``Lexicon``/``Policy``. Since 2.8 constructing a ``Constants``, copying one with ``copy()`` and loading one from a pickle warn too, since the whole class goes in 3.0; the singleton is built without the warning, so importing ``nameparser`` stays quiet. """ _shared = False # the CONSTANTS singleton flips this to True _generation: int prefixes: SetManager suffix_acronyms: SetManager suffix_not_acronyms: SetManager suffix_acronyms_ambiguous: SetManager titles: SetManager first_name_titles: SetManager conjunctions: SetManager bound_first_names: SetManager non_first_name_prefixes: SetManager capitalization_exceptions: TupleManager nickname_delimiters: _DelimiterManager maiden_delimiters: _DelimiterManager regexes: _RegexesProxy patronymic_name_order: bool middle_name_as_last: bool capitalize_name: bool force_mixed_case_capitalization: bool string_format: str | None initials_format: str initials_delimiter: str initials_separator: str suffix_delimiter: str | None def __init__( self, *, prefixes: Iterable[str] | object = _UNSET_KWARG, suffix_acronyms: Iterable[str] | object = _UNSET_KWARG, suffix_not_acronyms: Iterable[str] | object = _UNSET_KWARG, suffix_acronyms_ambiguous: Iterable[str] | object = _UNSET_KWARG, titles: Iterable[str] | object = _UNSET_KWARG, first_name_titles: Iterable[str] | object = _UNSET_KWARG, conjunctions: Iterable[str] | object = _UNSET_KWARG, bound_first_names: Iterable[str] | object = _UNSET_KWARG, non_first_name_prefixes: Iterable[str] | object = _UNSET_KWARG, capitalization_exceptions: Mapping[str, str] | Iterable[tuple[str, str]] | object = _UNSET_KWARG, regexes: object = _UNSET_KWARG, patronymic_name_order: bool = False, middle_name_as_last: bool = False, ) -> None: # every construction warns, at the caller's line; the warnings # registry, not a flag here, dedups repeats from one line # (decisions/3-0-reevaluations, 2026-10-28) warnings.warn(CONSTANTS_DEPRECATION, DeprecationWarning, stacklevel=2) self._init( prefixes=prefixes, suffix_acronyms=suffix_acronyms, suffix_not_acronyms=suffix_not_acronyms, suffix_acronyms_ambiguous=suffix_acronyms_ambiguous, titles=titles, first_name_titles=first_name_titles, conjunctions=conjunctions, bound_first_names=bound_first_names, non_first_name_prefixes=non_first_name_prefixes, capitalization_exceptions=capitalization_exceptions, regexes=regexes, patronymic_name_order=patronymic_name_order, middle_name_as_last=middle_name_as_last) def _init( self, *, prefixes: Iterable[str] | object = _UNSET_KWARG, suffix_acronyms: Iterable[str] | object = _UNSET_KWARG, suffix_not_acronyms: Iterable[str] | object = _UNSET_KWARG, suffix_acronyms_ambiguous: Iterable[str] | object = _UNSET_KWARG, titles: Iterable[str] | object = _UNSET_KWARG, first_name_titles: Iterable[str] | object = _UNSET_KWARG, conjunctions: Iterable[str] | object = _UNSET_KWARG, bound_first_names: Iterable[str] | object = _UNSET_KWARG, non_first_name_prefixes: Iterable[str] | object = _UNSET_KWARG, capitalization_exceptions: Mapping[str, str] | Iterable[tuple[str, str]] | object = _UNSET_KWARG, regexes: object = _UNSET_KWARG, patronymic_name_order: bool = False, middle_name_as_last: bool = False, ) -> None: # v1.4 parity constructor kwargs (#238/#242/#244 hardening); the # signature is spelled out rather than **kwargs so an unknown # keyword raises Python's own TypeError with no help from here. # `regexes` is the one deliberate 2.0 divergence: v1.4 accepted it # (RegexTupleManager(regexes)), but constructor injection is # assignment by another door, and __setattr__ above already # forbids `c.regexes = ...` post-construction -- the uniform 2.0 # rule is that parsing behavior is configured through Policy, not # by handing Constants a compiled-pattern mapping either way. if regexes is not _UNSET_KWARG: raise TypeError( "Constants(regexes=...) is not supported in 2.0; parsing " "behavior is configured through named Policy flags, not " "by constructing Constants with a regex mapping. See the " "migration guide." ) overrides = { "prefixes": prefixes, "suffix_acronyms": suffix_acronyms, "suffix_not_acronyms": suffix_not_acronyms, "suffix_acronyms_ambiguous": suffix_acronyms_ambiguous, "titles": titles, "first_name_titles": first_name_titles, "conjunctions": conjunctions, "bound_first_names": bound_first_names, "non_first_name_prefixes": non_first_name_prefixes, } vocab = _default_vocab() object.__setattr__(self, "_generation", 0) for name in _SET_FIELDS: value = overrides[name] if value is _UNSET_KWARG: value = vocab[name] # a caller-supplied value REPLACES that field's default # vocabulary wholesale (v1 parity); SetManager itself validates/ # normalizes and rejects a bare str/bytes (#238/#241), naming # this field in the message via _field= object.__setattr__( self, name, SetManager(value, _on_change=self._bump, _field=name)) # type: ignore[arg-type] if capitalization_exceptions is _UNSET_KWARG: from nameparser.config._compiled import ( CAPITALIZATION_EXCEPTIONS, ) capitalization_exceptions = CAPITALIZATION_EXCEPTIONS object.__setattr__(self, "capitalization_exceptions", TupleManager( capitalization_exceptions, # type: ignore[arg-type] _on_change=self._bump)) object.__setattr__(self, "nickname_delimiters", _DelimiterManager( {name: name for name in _DELIMITER_SENTINELS}, _on_change=self._bump)) object.__setattr__(self, "maiden_delimiters", _DelimiterManager( _on_change=self._bump)) object.__setattr__(self, "regexes", _RegexesProxy()) for name, value in _SCALAR_DEFAULTS.items(): object.__setattr__(self, name, value) # the two behavior bools were v1.4 constructor kwargs too # (docs/customize.rst doctests use them); truthiness matches v1, # storage goes through the plain scalar slot if patronymic_name_order: object.__setattr__(self, "patronymic_name_order", True) if middle_name_as_last: object.__setattr__(self, "middle_name_as_last", True) def _invalidate_pst(self) -> None: """Pickle-compat alias only, never called at runtime: v1's four cached-union ``SetManager`` fields (``prefixes``, ``suffix_acronyms``, ``suffix_not_acronyms``, ``titles``) stored their ``_on_change`` as the bound method ``Constants._invalidate_pst``. A pickled bound method serializes as a back-reference to its ``__self__`` (this same ``Constants`` instance, mid-unpickle) plus the method NAME -- reconstructed via ``getattr(constants_obj, '_invalidate_pst')`` before ``Constants.__setstate__`` ever runs (same two-phase-unpickling story as ``RegexTupleManager`` above). Without this name, loading a v1.4 blob raises ``AttributeError`` looking up the method. ``SetManager.__setstate__`` (shim) never restores ``_on_change`` from pickled state regardless -- it always resets to ``None`` and is rewired by ``Constants.__setstate__`` below -- so this bound method value is reconstructed only to satisfy the pickle format; it is discarded immediately and never invoked. """ def _bump(self) -> None: # A fixed stacklevel lands on the caller only for one call depth: # a scalar arrives through __setattr__, a set's add() through # _changed(), a dict's update() and |= through __setitem__ per # key. Walk out of this module instead, as _lexicon's # _warn_dead_entry does, so the warning names the caller's line; # attributed here, Python's default filters hid it. if self._shared: level = 2 frame: FrameType | None = sys._getframe(1) while (frame is not None and frame.f_globals.get("__name__") == __name__): frame, level = frame.f_back, level + 1 warnings.warn(_SHARED_MUTATION_MESSAGE, DeprecationWarning, stacklevel=level) object.__setattr__(self, "_generation", self._generation + 1) def __setattr__(self, name: str, value: object) -> None: if name == "_shared": # the flag the whole shared-mutation DeprecationWarning # mechanism hinges on: only the module-level singleton flip # (object.__setattr__ below) may set it raise AttributeError( "Constants._shared is read-only; it marks the module " "singleton and is set once at import" ) if name == "empty_attribute_default": raise AttributeError( "empty_attribute_default was removed in 2.0 (#255): " "empty attributes are always ''" ) if name == "regexes": raise TypeError( "replacing CONSTANTS.regexes is not supported in 2.0; " "parsing behavior is configured through named Policy " "flags" ) if name in _SET_FIELDS: # v1 allowed wholesale reassignment (c.titles = {...}); same # bare-str/bytes guard as the constructor kwarg path (#238/#241) value = SetManager( value, _on_change=self._bump, _field=name) # type: ignore[arg-type] elif name == "capitalization_exceptions": value = TupleManager(value, _on_change=self._bump) # type: ignore[arg-type] elif name in ("nickname_delimiters", "maiden_delimiters"): value = _DelimiterManager(value, _on_change=self._bump) # type: ignore[arg-type] elif name in _SCALAR_DEFAULTS and \ getattr(self, name, _UNSET) == value: # no-op scalar assignment: managers already suppress no-op # mutations, so re-assigning the current scalar value must # not bump the generation (or warn on the shared singleton) # either. __init__ writes via object.__setattr__, so this # only runs on real user assignments -- _UNSET never # actually matches, it just keeps a not-yet-set attribute # from raising here. Manager-field reassignment above stays # an unconditional bump: comparing manager contents isn't # worth it. object.__setattr__(self, name, value) return object.__setattr__(self, name, value) if name in _MANAGER_FIELDS or name in _SCALAR_DEFAULTS: self._bump() def copy(self) -> Constants: """Independent copy (#260), subclass-preserving. Divergence from v1 (which deepcopied __dict__): attributes OUTSIDE the known field surface -- e.g. ad-hoc names stashed on the instance -- are not carried by copy() or pickling; only the enumerated fields survive. Warns since 2.8, as construction does: a copy is a new private ``Constants``, and ``CONSTANTS.copy()`` builds one without calling the constructor.""" # #260 warnings.warn(CONSTANTS_DEPRECATION, DeprecationWarning, stacklevel=2) return self._copy() def __copy__(self) -> Constants: new = object.__new__(type(self)) _quiet_setstate(new, self.__getstate__(), Constants, CONSTANTS_PICKLE_DEPRECATION) return new def __deepcopy__(self, memo: dict[int, object]) -> Constants: new = object.__new__(type(self)) memo[id(self)] = new _quiet_setstate(new, copy.deepcopy(self.__getstate__(), memo), Constants, CONSTANTS_PICKLE_DEPRECATION) return new def _copy(self) -> Constants: # An independent instance with its own generation counter and # its own manager callbacks -- not a shared-state alias like a # naive attribute-for-attribute copy would produce. v1's copy() # was `copy.deepcopy(self)`, which builds the new object via # `type(self).__new__(type(self))` -- NOT by calling `type(self)()` # -- so a Constants subclass copies as itself without its __init__ # running (and without needing to satisfy whatever signature that # __init__ might require). Mirrored here with an explicit __new__ # bypass rather than type(self)(). new = object.__new__(type(self)) object.__setattr__(new, "_generation", 0) for name in _SET_FIELDS: object.__setattr__( new, name, SetManager(getattr(self, name), _on_change=new._bump)) object.__setattr__(new, "capitalization_exceptions", TupleManager( dict(self.capitalization_exceptions), _on_change=new._bump)) for bucket in ("nickname_delimiters", "maiden_delimiters"): object.__setattr__(new, bucket, _DelimiterManager( dict(getattr(self, bucket)), _on_change=new._bump)) object.__setattr__(new, "regexes", _RegexesProxy()) for name in _SCALAR_DEFAULTS: object.__setattr__(new, name, getattr(self, name)) return new def __repr__(self) -> str: # #221 # Collections (some with hundreds of entries, e.g. titles/prefixes) # are summarized as counts rather than dumped in full, like v1. # Scalars are only shown when they differ from the library default # -- _SCALAR_DEFAULTS stands in for v1's `getattr(type(self), name)` # class-level default, since this shim's scalar defaults are # instance attributes set in __init__, not class attributes. lines = [f" {name}: {len(getattr(self, name))}" for name in _REPR_COLLECTION_ATTRS] lines += [ f" {name}: {value!r}" for name in _REPR_SCALAR_ATTRS if (value := getattr(self, name)) != _SCALAR_DEFAULTS[name] ] return "" # -- snapshot ----------------------------------------------------------- def _snapshot(self) -> tuple[Lexicon, Policy, _RenderDefaults]: # generation-keyed cache: rebuilding the Lexicon re-normalizes # ~1400 vocabulary entries (~185us) -- the same dirty-tracking # the facade uses, applied one level up. A pure read either way # (no bump, no warning). cached = getattr(self, "_snapshot_cache", None) if cached is not None and cached[0] == self._generation: return cached[1] # type: ignore[no-any-return] snapshot = self._build_snapshot() object.__setattr__( self, "_snapshot_cache", (self._generation, snapshot)) return snapshot def _build_snapshot(self) -> tuple[Lexicon, Policy, _RenderDefaults]: """Resolve this v1-shaped, mutable Constants into the frozen 2.0 value objects it corresponds to. A pure read: no generation bump, no deprecation warning even on the shared singleton -- only direct attribute mutation is on the 3.0 removal path. """ from nameparser.config._compiled import CONJUNCTIONS_AMBIGUOUS from nameparser.config._compiled import GIVEN_NAME_ABBREVIATIONS from nameparser.config._compiled import MAIDEN_MARKERS from nameparser.config._compiled import ( AGNOMENS, GLUED_HONORIFICS, SUFFIX_WORDS_AMBIGUOUS) from nameparser.config._ko_surnames import KOREAN_SURNAMES from nameparser.config._titles_2_6_0 import TITLES_2_6_0 from nameparser.config._compiled import ( AFTER_GIVEN_TITLES, TITLE_MODIFIERS, TITLE_PHRASES, TITLE_STYLES, TITLES_AMBIGUOUS) # Every set field and every capitalization_exceptions key passes # _v1_matchable BEFORE any set algebra below: an entry v1 could # never match, or one Lexicon folds to empty (#582), is dropped # and named in one warning (#541). The survivors are passed RAW, # as Lexicon folds each field on its own; only the two cross-set # computations below compare folded spellings. dropped: list[tuple[str, str]] = [] def matchable(field: str) -> frozenset[str]: kept = [] for entry in sorted(getattr(self, field)): if _v1_matchable(entry): kept.append(entry) elif (field, entry) not in _V14_SHIPPED_UNMATCHABLE: dropped.append((field, entry)) return frozenset(kept) acronyms = matchable("suffix_acronyms") particles = matchable("prefixes") conjunctions = matchable("conjunctions") bound = matchable("bound_first_names") titles = matchable("titles") # A title 2.6.0 listed that a later release made a title only # inside a title phrase (rules/H9) is in v1's default `titles`, # where 2.6.0 had it, so remove(), discard() and `in` answer as # they did (decisions/H9, 2026-10-26). Kept, it is read as the # default reads it, by the phrase; removed, it is no title at # all, as removing it made it in 2.6.0. The one 2.6.0 reading # with no v1 spelling left is the word as a title everywhere: # v1 can say "a title" or "not a title", and "a title" now means # the phrase reading. A word 2.6.0 never listed (`dean`) is not # in `moved`, so adding it is still a title everywhere, the 1.4 # recipe the phrase test honours below. Compared folded, as the # two cross-set computations below are (#582), so a spelling # Lexicon folds onto a moved word (`king。`) is read as it is. moved = TITLES_2_6_0 & TITLES_AMBIGUOUS kept_moved = frozenset(w for w in titles if _normalize(w) in moved) phrase_only = ((TITLES_AMBIGUOUS - moved) | kept_moved if titles else frozenset()) # ...and the three intersections with the phrase vocabulary read # the same folded spellings, or a folded moved word kept its # phrase reading but lost its modifier one (`wing。`) any_title = frozenset(_normalize(w) for w in titles | phrase_only) first_name_titles = matchable("first_name_titles") non_given = matchable("non_first_name_prefixes") ambiguous_acronyms = matchable("suffix_acronyms_ambiguous") & acronyms # Drop any ambiguous acronym from the word set rather than the # other way round. Lexicon forbids the overlap because the word # branch bypasses the period gate, and adding an ambiguous # acronym to suffix_not_acronyms is INERT in v1 anyway: # is_suffix already accepts it via the acronym branch, and # reserve_last keeps it as the surname. So ignoring the # addition reproduces v1 ("Jack Ma" keeps last='Ma'), where # dropping it from the AMBIGUOUS set instead ungated the word # and lost the family name -- a silent misparse worse than the # raise it avoided. # Folded here, and only here (#582): Lexicon folds each field on # its own but checks this disjointness AFTER its fold, so the # shim must compare folded spellings exactly where Lexicon # re-checks. Folding anything else would change readings on # configs that never raised. ambiguous_folded = {_normalize(a) for a in ambiguous_acronyms} suffix_words = frozenset( w for w in matchable("suffix_not_acronyms") if _normalize(w) not in ambiguous_folded) # Likewise folded only here (#582): Lexicon checks bound_given_names # & particles against particles_ambiguous after its own fold. bound_folded = {_normalize(b) for b in bound} bound_particles = frozenset( p for p in particles if _normalize(p) in bound_folded) capitalization_exceptions: list[tuple[str, object]] = [] for key, value in sorted(self.capitalization_exceptions.items()): if _v1_matchable(key): capitalization_exceptions.append((key, value)) else: dropped.append(("capitalization_exceptions", key)) if dropped: _warn_unmatchable(dropped) # keep in sync with _lexicon._default_lexicon() (pinned by # tests/v2/test_config_shim.py::test_snapshot_field_translation) # A capitalization_exceptions value that does not spell its key # is the one DECIDED exception to this method's never-raise # rule (#459, decisions/R4 and #3-0-reevaluations). # _normpairs raises _MaskValueError for exactly that shape, and # the except clause re-spells its offered fix for a v1 caller. # Caught by TYPE, so every other Lexicon error passes through # unchanged. Only the three SUBSET checks (particles_ambiguous # inside particles, suffix_acronyms_ambiguous inside # suffix_acronyms, honorific_tails inside suffix_words) are # satisfied BY CONSTRUCTION: each is built below as an # intersection or union with its OWN base -- particles_ambiguous # from `particles` itself, honorific_tails from `suffix_words` # itself -- so the image of a subset relation under any # element-wise normalization is still a subset, whatever the # raw spelling. The CONTRADICTION check (bound_given_names & # particles inside particles_ambiguous) and the GATE-BYPASS check # (suffix_acronyms_ambiguous disjoint from suffix_words) do NOT # have that property -- each compares two sets built by SEPARATE # set arithmetic on the shim's own strings. They hold here # because the entries are filtered by _v1_matchable and the two # cross-set computations (suffix_words, bound_particles) compare # _normalize'd spellings, which are the ones Lexicon compares; # every other field is passed raw, as Lexicon folds it itself # (#541, #582). # What else reaches it is a non-str value's TypeError -- a raise # v1 also had, later, at capitalize(). try: lexicon = Lexicon( titles=titles - kept_moved, # TRANSLATE, do not filter. The two versions build the same # lookup key differently: v1 joins the raw title run and # then applies lc(), which strips only the whole string's # edge periods, so an interior word keeps its own ("lt. # col"). v2 normalizes each token and then joins ("lt col"). # Re-folding per word converts a v1 entry into the v2 # spelling; filtering instead dropped every multi-word # honorific containing an abbreviation or a conjunction and # silently swapped given and family. # Entries that match no name word were dropped above # (_v1_matchable); since that includes every entry # Lexicon folds to empty, no key built here is empty. given_name_titles=frozenset( _title_key(e.split()) for e in first_name_titles), suffix_acronyms=acronyms, suffix_words=suffix_words, # Intersect with acronyms: Lexicon enforces ambiguous <= # acronyms; v1 behaves the same when an acronym is deleted # but its ambiguous entry lingers (the entry stops # mattering). suffix_acronyms_ambiguous=ambiguous_acronyms, particles=particles, # complement translation: v1 marks the never-given subset; # v2 marks the may-be-given subset. The trailing union keeps # a config v1 accepted: the vocabulary build refuses a # shipped word in both NON_GIVEN_NAME_PARTICLES and # BOUND_GIVEN_NAMES, but nothing stops a caller adding one at # runtime, and v1 then lets the bound rule win (leading "dos # Santos Silva" parses first="dos Santos"). Treating such a # word as may-be-given reproduces that rather than raising. # # KNOWN DEVIATION, pinned by # test_bound_never_given_prefix_deviates_on_two_pieces: v1's # join has a reserve_last guard, so with only two pieces it # does NOT fire and the word stays never-given ("dos Santos" # -> last="dos Santos"). Promotion here is unconditional, so # that case reads given="dos", family="Santos". v1's rule is # piece-count dependent and a static vocabulary set cannot # express it; the alternative is raising on a config v1 # accepted, which is worse. Only reachable via a runtime # config the shipped data forbids. particles_ambiguous=( particles - non_given) | bound_particles, conjunctions=conjunctions, # no v1 manager of its own: the ambiguous-connective # subset is 2.4 behavior (#383/#479), so it rides in the # snapshot only. Lexicon does NOT check this pair -- unlike # honorific_tails against suffix_words below -- so the # intersection is a provable no-op, kept only for # `_snapshot() == Lexicon.default()` legibility; the v1 # knob (deleting the conjunction) turns the marking off # through the fork's own base-vocabulary test rather than # through this intersection. conjunctions_ambiguous=CONJUNCTIONS_AMBIGUOUS & conjunctions, bound_given_names=bound, # likewise no v1 manager: an abbreviated given name # reading over rules/H2's shape is 2.7 behavior (#789), # so the set rides in the snapshot only, passed whole. # No v1 knob reaches it, and none needs to: a word a v1 # caller ADDS to `titles` is a title wherever it stands, # since the leading peel asks the title vocabulary before # it weighs the shape, as it did in 1.4 # (`c.titles.add("chas")`) given_name_abbreviations=GIVEN_NAME_ABBREVIATIONS, # v1 Constants has no manager for these (#274 is 2.0 # behavior); the data module is the only source maiden_markers=MAIDEN_MARKERS, # likewise no v1 manager: the unspaced-name segmentation # vocabulary is 2.0 behavior (#271), so it rides in the # snapshot only -- v1's Constants surface stays frozen. surnames=KOREAN_SURNAMES, # likewise no v1 manager: the glued-honorific tail set is # 2.1 behavior (#308), so it rides in the snapshot only. # Intersect with the word set: Lexicon enforces tails <= # suffix_words, and v1 semantics are that deleting a suffix # word turns the behavior off -- a lingering tail simply # stops mattering, the same rule ambiguous_acronyms gets # against suffix_acronyms above. honorific_tails=GLUED_HONORIFICS & suffix_words, # likewise no v1 manager: the salutations ending a given # part are 2.6 behavior (#835), so they ride in the # snapshot only, and the one v1 knob that reaches them is # deleting the title. Lexicon does not check this pair # (classify reads a member only where it is a title too), # so the intersection is kept for `_snapshot() == # Lexicon.default()` legibility, as conjunctions_ambiguous'. after_given_titles=AFTER_GIVEN_TITLES & titles, # likewise no v1 manager: a suffix word that is also a # name is 2.7 behavior (#752), so it rides in the # snapshot only. Intersect with the word set, as the # tails are: Lexicon enforces the subset, and deleting # the suffix word is the one v1 knob, which turns the # reading off. agnomens is consulted only inside it, so # its intersection is kept for legibility alone. # A v1 caller who added such a word to suffix_acronyms # had it read as a suffix through the acronym branch, # so the word leaves the gate rather than raising at # Lexicon's word-gate check (folded, as the two # cross-set computations above are). suffix_words_ambiguous=frozenset( w for w in SUFFIX_WORDS_AMBIGUOUS & suffix_words if _normalize(w) not in { _normalize(a) for a in acronyms - ambiguous_acronyms}), agnomens=AGNOMENS & SUFFIX_WORDS_AMBIGUOUS & suffix_words, # likewise no v1 manager: the title phrase (rules/H9) is # 2.7 behavior (#752), so its sets ride in the snapshot # only. A title that may be a given name is not in # TITLES, so v1's `titles` holds one only where 2.6.0 # listed it (`moved`, above, read by the phrase), and one # 2.6.0 never listed that a v1 caller ADDS to `titles` is # a title wherever it stands, as it was in 1.4 # (`c.titles.add("dean")`, the documented recipe): the # phrase test reads a word of titles_ambiguous only where # it is not in titles. The # knob that reaches the other three is deleting a title, # which turns its modifier, style or phrase reading off # with it; Lexicon checks none of them, so the # intersections are kept for legibility, as # after_given_titles' is. # ...save where the caller emptied `titles`, v1's way to # turn title detection off (customize.rst). A caller who # REPLACES the list keeps the phrase reading of the words # 2.6.0 never listed: v1 has no knob for them, and what a # replaced list means for words it never held is not # something it says. A moved word reads by whether the # replaced list holds it, as any v1 title does titles_ambiguous=phrase_only, title_modifiers=TITLE_MODIFIERS & any_title, title_styles=TITLE_STYLES & any_title, title_phrases=frozenset( p for p in TITLE_PHRASES if all(w in any_title for w in p.split(" "))), # TupleManager is dict[str, object] (v1 parity: values were # never statically str-typed); every real entry is a str, # same assumption _DelimiterManager's sentinel lookup makes. # NOT translated: see the comment above the try: block. capitalization_exceptions=tuple(capitalization_exceptions), # type: ignore[arg-type] ) except _MaskValueError as e: # A v1-ONLY message built from the fields, not `{e}` -- # the 2.0 exception's own message offers the 2.0 # `capitalization_exceptions=((...),)` constructor spelling, # which no v1 Constants caller can paste. raise ValueError( f"capitalization_exceptions value {e.value!r} for key " f"{e.key!r} does not spell the key's letters and " f"digits: a value is a case mask, the key's own " f"letters and digits recased -- write " f"constants.capitalization_exceptions[{e.key!r}] = " f"{e.offered!r}" ) from e rules = frozenset({PatronymicRule.EAST_SLAVIC, PatronymicRule.TURKIC}) \ if self.patronymic_name_order else frozenset() policy = Policy( patronymic_rules=rules, middle_as_family=self.middle_name_as_last, nickname_delimiters=frozenset( _SENTINEL_PAIRS[k] for k in self.nickname_delimiters), # v1 precedence: a pair in BOTH v1 buckets parses as nickname. # Policy itself resolves overlap the other way (maiden wins), # so pre-subtract here to keep the facade at v1 behavior. maiden_delimiters=frozenset( _SENTINEL_PAIRS[k] for k in self.maiden_delimiters if k not in self.nickname_delimiters), # suffix_delimiter is a _RenderDefaults-only field here; the # facade layers it onto extra_suffix_delimiters per # instance -- _snapshot() itself stays pure translation ) defaults = _RenderDefaults( self.string_format, self.initials_format, self.initials_delimiter, self.initials_separator, self.suffix_delimiter, self.capitalize_name, self.force_mixed_case_capitalization) return lexicon, policy, defaults # -- pickle ----------------------------------------------------------- def __getstate__(self) -> dict[str, object]: state: dict[str, object] = {} for name in _SET_FIELDS: state[name] = set(getattr(self, name)) state["capitalization_exceptions"] = dict( self.capitalization_exceptions) state["nickname_delimiters"] = dict(self.nickname_delimiters) state["maiden_delimiters"] = dict(self.maiden_delimiters) for name in _SCALAR_DEFAULTS: state[name] = getattr(self, name) return state def __setstate__(self, state: dict[str, object]) -> None: if "suffixes_prefixes_titles" in state: # pre-1.3.0 blob: its dir()-sweep __getstate__ captured this # computed property. The 1.4 DeprecationWarning promised # ValueError in 2.0 (#279). raise ValueError( "this pickle was written by nameparser <= 1.2.x (#279); " "re-pickle under 1.3/1.4 to migrate, or re-create the " "configuration. See " "https://github.com/derek73/python-nameparser/issues/279" ) # Accepts BOTH shapes with a single overlay: the shim's own # state and v1.3/1.4 state (public field names -> manager/ # scalar values) share every key that matters, so no shape # marker is needed. empty_attribute_default is accepted and # DROPPED (#255: empty is always '' in 2.0). state = {k: v for k, v in state.items() if k != "empty_attribute_default"} warnings.warn(CONSTANTS_PICKLE_DEPRECATION, DeprecationWarning, stacklevel=2) self._restore(state) def _restore(self, state: dict[str, object]) -> None: # defaults, then overlay. A subclass's own __init__ runs here as # it did before 2.8; its super().__init__() would warn that a # Constants was constructed, which a restore is not if type(self).__init__ is Constants.__init__: self._init() else: with warnings.catch_warnings(): warnings.filterwarnings( "ignore", message=re.escape(CONSTANTS_DEPRECATION), category=DeprecationWarning) self.__init__() # type: ignore[misc] # (managers re-wrapped below so _on_change points at THIS # instance, not whatever produced the incoming state) managers: dict[str, SetManager] = {} for name in _SET_FIELDS: if name in state: managers[name] = SetManager( state[name], _on_change=self._bump) # type: ignore[arg-type] # SetManager normalized on construction, so the frozen 1.3/1.4 # vocabulary's dead entries are matchable in their normalized # spelling here. Subtract only when ALL EIGHT are present -- # the pre-2.0 signature; a 2.0 user who re-added one or two # keeps them through a round-trip (see _LEGACY_DEAD_ENTRIES). legacy = all( name in managers and entry in managers[name] for name, entries in _LEGACY_DEAD_ENTRIES.items() for entry in entries ) for name, manager in managers.items(): if legacy: # Reach past the public discard() deliberately: this is # part of restoring the state, not a mutation of it, and # must not bump the generation of an instance that is # still being built. manager._elements -= _LEGACY_DEAD_ENTRIES.get( name, frozenset()) object.__setattr__(self, name, manager) if "capitalization_exceptions" in state: object.__setattr__( self, "capitalization_exceptions", TupleManager( state["capitalization_exceptions"], # type: ignore[arg-type] _on_change=self._bump)) for bucket in ("nickname_delimiters", "maiden_delimiters"): if bucket in state: object.__setattr__(self, bucket, _DelimiterManager( state[bucket], _on_change=self._bump)) # type: ignore[arg-type] for name in _SCALAR_DEFAULTS: if name in state: object.__setattr__(self, name, state[name]) # built past __init__, so importing nameparser never warns CONSTANTS = object.__new__(Constants) CONSTANTS._init() object.__setattr__(CONSTANTS, "_shared", True) derek73-python-nameparser-456b024/nameparser/_facade.py000066400000000000000000001320631527303271700227650ustar00rootroot00000000000000"""The 2.0 ``HumanName`` facade (mechanisms.md#FACADE-CONTRACT): a mutable wrapper over a frozen ParsedName, delegating parsing to the core Parser resolved from the bound Constants shim. Keeps every v1 spelling. Deleted in 3.0. Layering: facade layer -- may import anything public plus _render. """ from __future__ import annotations import copy import dataclasses import warnings from collections.abc import Iterator, Mapping from typing import Any # Import order matters here -- breaks a real import cycle. nameparser. # config's package __init__ re-exports CONSTANTS/Constants/etc. from # _config_shim (the v1 nameparser.config.Constants compat path), while # _config_shim's own default CONSTANTS singleton needs nameparser.config's # DATA submodules (titles, prefixes, ...), imported lazily -- see # _config_shim.py's module docstring. If _config_shim were the first of # the two ever touched, building its CONSTANTS would need to import # nameparser.config, whose __init__ would in turn need _config_shim's # (not-yet-built) CONSTANTS: ImportError. Importing the config package # here first lets its __init__ run to completion; when IT then imports # _config_shim to build the default CONSTANTS, nameparser.config is # already registered in sys.modules, so its data-submodule imports # resolve directly instead of re-entering (and failing on) its own # still-executing __init__. import nameparser.config # noqa: F401 import nameparser._render as _render from nameparser._config_shim import (CONSTANTS, MIGRATION_GUIDE, Constants, _cached_parser, _quiet_setstate) from nameparser._lexicon import _normalize, _spells_an_initial from nameparser._parser import Parser from nameparser._types import (FOLDED_TAG, UNCLASSIFIED_TAG, UNJOINED_CONJUNCTION_TAG, ParsedName, Role, Token) _V2_FIELD = {"first": "given", "last": "family"} # v1 name -> v2 name _V1_SPELLING = {v2: v1 for v1, v2 in _V2_FIELD.items()} # derived from Role: declaration order IS the canonical field order # (never restated), rendered in the v1 spellings _MEMBERS = tuple(_V1_SPELLING.get(r.value, r.value) for r in Role) #: v1 parsing hooks the facade never calls #: (mechanisms.md#FACADE-CONTRACT / #280). _V1_HOOKS = ( "pre_process", "post_process", "parse_full_name", "parse_pieces", "parse_nicknames", "join_on_conjunctions", "squash_emoji", "handle_firstnames", "handle_middle_name_as_last", "is_title", "is_conjunction", "is_prefix", "is_roman_numeral", "is_suffix", "is_suffix_lenient", "is_an_initial", ) # Module-level mutable state (sanctioned exception, AGENTS.md "One # sanctioned global"): strong-references every distinct HumanName # subclass for process lifetime so the hook warning fires once per # class. Fine in practice -- subclasses are statically defined, and the # whole module is deleted with the facade layer in 3.0. _WARNED_SUBCLASSES: set[type] = set() # The 2.8 removal messages (decisions/3-0-reevaluations, 2026-10-28); # the code each offers is pinned by tests/v2/test_deprecations.py. HUMANNAME_DEPRECATION = ( "HumanName is deprecated and will be removed in 3.0; use " "nameparser.parse(text), whose fields are given and family where " f"HumanName has first and last. See {MIGRATION_GUIDE}" ) HUMANNAME_PICKLE_DEPRECATION = ( "HumanName pickles stop loading in 3.0, when HumanName is removed; " "store parse(text).as_dict() or the original string instead. " f"See {MIGRATION_GUIDE}" ) def _empty_parsed() -> ParsedName: return ParsedName(original="", tokens=(), ambiguities=()) class HumanName: """v1 ``HumanName`` facade: a mutable wrapper over a frozen ``ParsedName``, delegating all parsing to the core ``Parser`` resolved from the bound ``Constants`` shim (dirty-tracked via its ``_generation``). Keeps every v1 spelling (``first``/``last`` over the core ``given``/``family``); the v1 parsing hooks are never called (#280). Deprecated since 2.8, warning at every construction, and deleted with the facade layer in 3.0. """ def __init__( self, full_name: str = "", constants: Constants | None = CONSTANTS, string_format: str | None = None, initials_format: str | None = None, initials_delimiter: str | None = None, initials_separator: str | None = None, suffix_delimiter: str | None = None, first: str | list[str] | None = None, middle: str | list[str] | None = None, last: str | list[str] | None = None, title: str | list[str] | None = None, suffix: str | list[str] | None = None, nickname: str | list[str] | None = None, maiden: str | list[str] | None = None, ) -> None: if constants is None: raise TypeError( "constants=None was removed in 2.0 (#261): pass a " "Constants instance, or use the new Parser/Lexicon/" "Policy API for per-call configuration" ) if not isinstance(constants, Constants): raise TypeError( f"constants must be a Constants instance, got {constants!r}" ) # every construction warns, at the caller's line (a subclass's # super().__init__() is its author's line); never once per # process, so a dependency's first HumanName cannot consume the # warning meant for the caller's own warnings.warn(HUMANNAME_DEPRECATION, DeprecationWarning, stacklevel=2) self._warn_overridden_hooks() self._C = constants self._snapshot_gen = -1 # forces first resolve _, _, defaults = constants._snapshot() self.string_format = (string_format if string_format is not None else defaults.string_format) self.initials_format = (initials_format if initials_format is not None else defaults.initials_format) self.initials_delimiter = ( initials_delimiter if initials_delimiter is not None else defaults.initials_delimiter) self.initials_separator = ( initials_separator if initials_separator is not None else defaults.initials_separator) # These five assignments route through the validating properties # below. The suffix_delimiter setter resets _snapshot_gen to -1 # on every assignment (including this one, harmlessly -- it's # already -1 above), so reassigning it post-construction (e.g. # n.suffix_delimiter = " - ") correctly forces the next # _resolve() to rebuild the Policy with the new delimiter. self.suffix_delimiter = (suffix_delimiter if suffix_delimiter is not None else defaults.suffix_delimiter) self._full_name = "" self._parsed = _empty_parsed() if first or middle or last or title or suffix or nickname or maiden: # These route through the field-setter properties (None # clears the field); no full-string parse, full_name stays "". self.first = first self.middle = middle self.last = last self.title = title self.suffix = suffix self.nickname = nickname self.maiden = maiden else: self._apply_full_name(full_name) @classmethod def _warn_overridden_hooks(cls) -> None: if cls is HumanName or cls in _WARNED_SUBCLASSES: return overridden = [h for h in _V1_HOOKS if getattr(cls, h, None) is not getattr( HumanName, h, None)] _WARNED_SUBCLASSES.add(cls) if overridden: warnings.warn( f"{cls.__name__} overrides v1 parsing hooks " f"({', '.join(overridden)}) that the 2.0 facade never " f"calls; parsing is delegated to the core Parser. " f"Migrate to the Lexicon/Policy API; HumanName is " f"removed in 3.0. See " f"https://github.com/derek73/python-nameparser/issues/280", DeprecationWarning, stacklevel=3) # -- render defaults ----------------------------------------------------- # One-line validating setters (mechanisms.md#FACADE-CONTRACT): # assigning a non-str (or, for # the two fields that allow it, non-str-non-None) raises TypeError at # assignment time instead of failing later inside .format(). @property def string_format(self) -> str | None: return self._string_format @string_format.setter def string_format(self, value: str | None) -> None: if value is not None and not isinstance(value, str): raise TypeError( f"string_format must be a str or None, got {value!r}") self._string_format = value @property def initials_format(self) -> str: return self._initials_format @initials_format.setter def initials_format(self, value: str) -> None: if not isinstance(value, str): raise TypeError( f"initials_format must be a str, got {value!r}") self._initials_format = value @property def initials_delimiter(self) -> str: return self._initials_delimiter @initials_delimiter.setter def initials_delimiter(self, value: str) -> None: if not isinstance(value, str): raise TypeError( f"initials_delimiter must be a str, got {value!r}") self._initials_delimiter = value @property def initials_separator(self) -> str: return self._initials_separator @initials_separator.setter def initials_separator(self, value: str) -> None: if not isinstance(value, str): raise TypeError( f"initials_separator must be a str, got {value!r}") self._initials_separator = value @property def suffix_delimiter(self) -> str | None: return self._suffix_delimiter @suffix_delimiter.setter def suffix_delimiter(self, value: str | None) -> None: if value is not None and not isinstance(value, str): raise TypeError( f"suffix_delimiter must be a str or None, got {value!r}") self._suffix_delimiter = value # Invalidate the cached Policy: _resolve() layers suffix_delimiter # onto extra_suffix_delimiters, so a stale snapshot would keep # parsing against the old delimiter. self._snapshot_gen = -1 # -- config / parsing --------------------------------------------------- def _resolve(self) -> Parser: """Dirty-tracked parser resolution (mechanisms.md#CONFIG-SHIM-SNAPSHOT): rebuild the snapshot only when the bound Constants' generation moved.""" gen = self._C._generation if self._snapshot_gen != gen: lexicon, policy, _ = self._C._snapshot() if self.suffix_delimiter: policy = dataclasses.replace( policy, extra_suffix_delimiters=frozenset( {self.suffix_delimiter})) self._lexicon, self._policy = lexicon, policy self._parser = _cached_parser(lexicon, policy) self._snapshot_gen = gen # the fast path is a plain attribute return: hashing the two # value objects for the lru lookup is the whole fast-path cost return self._parser def parse_full_name(self) -> None: """Re-parse the stored ``full_name`` (v1's documented re-parse trigger, docs/customize.rst): mutate ``name.C`` then call this to force a re-parse without reassigning ``full_name``. The v1 parsing INTERNALS this name evokes live in the core ``Parser``, not here; a subclass overriding this method still triggers the #280 hook-override warning, and full_name assignment never consults it.""" self._apply_full_name(self._full_name) def _apply_full_name(self, value: str) -> None: if isinstance(value, bytes): raise TypeError( "bytes input was removed in 2.0 (#245): decode first, " "e.g. HumanName(raw.decode('utf-8'))" ) if not isinstance(value, str): raise TypeError(f"full_name must be a str, got {value!r}") # parse FIRST: if snapshot resolution raises, the instance must # not be left with a new full_name over the old parsed fields parsed = self._resolve().parse(value) self._full_name = value self._parsed = parsed if self._C.capitalize_name: self.capitalize() # v1 parser.py:1653 parity def capitalize(self, force: bool | None = None) -> None: """Re-capitalize the current parse against the bound lexicon. force=None reads the bound Constants' render default (force_mixed_case_capitalization); the core's capitalized() implements the one-case gate (rules/R5, suffixes left out since #492) -- not re-implemented here.""" self._resolve() if force is None: force = self._C.force_mixed_case_capitalization self._parsed = self._parsed.capitalized(self._lexicon, force=force) @property def full_name(self) -> str: return self._full_name @full_name.setter def full_name(self, value: str) -> None: self._apply_full_name(value) @property def original(self) -> str: return self._parsed.original or self._full_name @property def C(self) -> Constants: return self._C @C.setter def C(self, constants: Constants | None) -> None: # v1.4 closed #239 by making C a validating setter that ONLY # stores the new value -- no re-parse (checked against v1.4: # `git show 2d5d8c2:nameparser/parser.py` lines ~204-206, the C # setter body is exactly `self._C = self._validate_constants(...)`). # A caller who wants the new config reflected must still trigger # a re-parse, e.g. via parse_full_name() or a full_name # reassignment -- matched here rather than re-parsing eagerly. if constants is None: raise TypeError( "assigning constants=None to C was removed in 2.0 (#261): " "pass a Constants instance, or use the new Parser/Lexicon/" "Policy API for per-call configuration" ) if not isinstance(constants, Constants): raise TypeError( f"constants must be a Constants instance, got {constants!r}" ) self._C = constants self._snapshot_gen = -1 # invalidate: next _resolve() rebuilds @property def has_own_config(self) -> bool: """True when this instance is not using the shared module-level CONSTANTS.""" return self._C is not CONSTANTS # -- fields --------------------------------------------------------- def _get_field(self, member: str) -> str: return getattr(self._parsed, _V2_FIELD.get(member, member)) def _set_field(self, member: str, value: str | list[str] | None) -> None: if value is None: joined = "" elif isinstance(value, list): for element in value: if not isinstance(element, str): raise TypeError( f"name parts must be strings, got {element!r}") joined = " ".join(value) elif isinstance(value, str): joined = value else: raise TypeError( f"{member} must be a str, list, or None, got {value!r}") # v1 setters stay on replace(): revise()'s vocabulary tags would # change v1 parity self._parsed = self._parsed.replace( **{_V2_FIELD.get(member, member): joined}) def _list_tokens_for(self, member: str) -> list[tuple[Token, ...]]: # A "joined" continuation token ("Ph." + "D.") belongs to its # predecessor's part, matching v1's fix_phd (suffix_list had ONE # "Ph. D." element). ParsedName._text_for heals only the suffix # string view (the ", " join); the facade list view heals for # every role -- a continuation is never its own list element. role = Role(_V2_FIELD.get(member, member)) parts: list[list[Token]] = [] folded: list[list[Token]] = [] for tok in self._parsed.tokens_for(role): if "joined" in tok.tags and parts: parts[-1].append(tok) elif FOLDED_TAG in tok.tags: # middle_as_family fold: v1 PREPENDED middle_list to # last_list -- keep the list view consistent with the # string view (_text_for orders folded-first too) folded.append([tok]) else: parts.append([tok]) return [tuple(group) for group in folded + parts] def _list_for(self, member: str) -> list[str]: # The STRING view of the walk above, which is the v1 `*_list` # shape. Two views off one walk rather than two walks: the # initials view needs each element's backing tokens (#528) and # every other reader needs its text, and a second walk could # drift from this one on exactly the element boundaries the # comment above exists to hold. Not on the parse path -- # measured 2026-09-13, HumanName(name) never reaches it -- so # the tuple building is off the benchmarked budget # (tests/v2/test_benchmark.py budgets parse() and HumanName()). return [" ".join(tok.text for tok in group) for group in self._list_tokens_for(member)] @property def title(self) -> str: return self._get_field("title") @title.setter def title(self, value: str | list[str] | None) -> None: self._set_field("title", value) @property def title_list(self) -> list[str]: return self._list_for("title") @property def first(self) -> str: return self._get_field("first") @first.setter def first(self, value: str | list[str] | None) -> None: self._set_field("first", value) @property def first_list(self) -> list[str]: return self._list_for("first") @property def middle(self) -> str: return self._get_field("middle") @middle.setter def middle(self, value: str | list[str] | None) -> None: self._set_field("middle", value) @property def middle_list(self) -> list[str]: return self._list_for("middle") @property def last(self) -> str: return self._get_field("last") @last.setter def last(self, value: str | list[str] | None) -> None: self._set_field("last", value) @property def last_list(self) -> list[str]: return self._list_for("last") @property def suffix(self) -> str: return self._get_field("suffix") @suffix.setter def suffix(self, value: str | list[str] | None) -> None: self._set_field("suffix", value) @property def suffix_list(self) -> list[str]: return self._list_for("suffix") @property def nickname(self) -> str: return self._get_field("nickname") @nickname.setter def nickname(self, value: str | list[str] | None) -> None: self._set_field("nickname", value) @property def nickname_list(self) -> list[str]: return self._list_for("nickname") @property def maiden(self) -> str: return self._get_field("maiden") @maiden.setter def maiden(self, value: str | list[str] | None) -> None: self._set_field("maiden", value) @property def maiden_list(self) -> list[str]: return self._list_for("maiden") # -- derived views ---------------------------------------------------- @property def surnames_list(self) -> list[str]: return self.middle_list + self.last_list @property def surnames(self) -> str: return " ".join(self.surnames_list) @property def given_names_list(self) -> list[str]: return self.first_list + self.middle_list @property def given_names(self) -> str: return " ".join(self.given_names_list) def _is_particle(self, text: str) -> bool: self._resolve() n = _normalize(text) return (n in self._lexicon.particles and not _spells_an_initial(n, text)) def _token_is_conjunction(self, tok: Token) -> bool: # #528: the PARSE's answer, not the vocabulary's. A token the # parser classified carries its reading in its tags, which is # the source the core's initials() has always read # (mechanisms.md#RENDER-HONORS-THE-PARSE), so a bare capital # 'Y' in a one-case name contributes no initial where its tag # says connective, and a one-case 'e' contributes one where its # tag says initial. Before #528 this was computed from the raw # word instead -- "in the conjunctions set AND NOT # _render._INITIAL" (v1's is_conjunction, restored by #462) -- # a shape test standing in for a tag, which stopped agreeing # with the parse the moment #383/#479 gave the classifier a # fork the shape cannot see. # # UNCLASSIFIED_TAG is the one case with no parse to honor: the # words were spliced into a field as raw text, by `hn.middle = # ...` (ParsedName.replace) or restored via __setstate__ -- a # pickle load, or a copy.copy/copy.deepcopy, which go through # the same state hooks. They carry no reading, so the vocabulary # answers -- the same fallback _render._cap_word takes for the # same tokens and through the same helper, which is why the # helper is imported rather than the predicate rewritten # (decisions/R4 for why one question and not two: whether a # word is a connective is a fact the word can answer alone, # whether a particle is acting as one is a fact about the part). # # _resolve() first, as _is_particle above does: an unpickled or # copied instance has no _lexicon until resolved -- and neither # does a keyword-constructed one (`HumanName(first=..., middle= # "y", last=...)`), which never runs the full-string parse path # other callers rely on to have called _resolve() already. # LOAD-BEARING for exactly those caller shapes: do not delete # this call as dead just because most callers arrive resolved. self._resolve() if UNCLASSIFIED_TAG in tok.tags: return _render._reads_as_conjunction(tok.text, self._lexicon) # #461: a connective with nothing in its part to join is not # acting as one. The parse decided that and marked the token, # and this reads the same mark the core's initials() reads, so # the two views cannot disagree about it. return ("conjunction" in tok.tags and UNJOINED_CONJUNCTION_TAG not in tok.tags) def _split_last(self) -> tuple[list[str], list[str]]: # rules/R2: "a name part whose every word is particle # vocabulary is a part where none of them is doing a # particle's work" -- the all-particle guard # below is this rule, and predates its statement: v1 assumed a # family name does not consist entirely of particles, e.g. the # surname "Do" which also appears in PARTICLES. v1 # parser.py _split_last otherwise verbatim, vocabulary lookup # at ACCESS time so assigned last names split too. words = " ".join(self.last_list).split() i = 0 while i < len(words) and self._is_particle(words[i]): i += 1 if i == len(words): return [], words return words[:i], words[i:] @property def last_prefixes_list(self) -> list[str]: return self._split_last()[0] @property def last_prefixes(self) -> str: return " ".join(self._split_last()[0]) @property def last_base_list(self) -> list[str]: return self._split_last()[1] @property def last_base(self) -> str: return " ".join(self._split_last()[1]) # -- initials ------------------------------------------------------------- def _process_initial(self, name_part: str, firstname: bool = False, tokens: tuple[Token, ...] | None = None) -> str: # after v1 parser.py:427, not verbatim: particles and # conjunctions are filtered from initials unless the part is a # first name. # # TWO WAYS IN. `tokens` is the part's backing tokens, which # _initials_lists always has and passes; `name_part` is v1's # signature, kept because subclasses and tests call this # directly with a string (tests/test_initials.py), and there # the words come from splitting it. `tokens` supersedes # `name_part` entirely when given -- the words are the tokens' # own text rather than a re-split of the joined element, so a # two-word element and its tokens cannot fall out of step. # STATED BREAK: _initials_lists always calls with `tokens=`, so # a subclass overriding with v1's two-argument signature # (name_part, firstname=False) now raises TypeError the first # time initials() runs, rather than being silently skipped. The # alternative -- a string wrapper kept over a token core -- # would make such an override silently ineffective instead, # which hides the override rather than breaking it loudly. # Such an override must ACCEPT `tokens` AND FORWARD it: # super()._process_initial(name_part, firstname, tokens=tokens). # That is still the only way to RECEIVE #528's fix. Widening # the signature alone -- `**kwargs`, or a `tokens=None` the # super() call drops -- does not raise and does not go silent # either: `name_part` on this path is the group's own text # (Derek, 2026-09-14), so the override takes the STRING path # below and keeps working, just without the fix -- the # PRE-#528 answer, computed from the vocabulary fallback # instead of the parse (measured 2026-09-14: # `WidensOnly("john e smith").initials()` is "j. s.", where a # forwarding override and the library itself give "j. e. s."). # Real text was chosen over the empty placeholder precisely so # an override that ignores the keyword behaves as it did # before the upgrade, rather than going quietly blank. # # An overridden PUBLIC `first_list`/`middle_list`/`last_list` # property lands here the same way: _initials_lists (above) # detects the override and calls this method for that member # with no `tokens=` at all, so it takes the STRING path below # regardless of what `tokens=` a WidensOnly-style override # might otherwise forward -- the override supplies strings, # not tokens, so there is nothing to forward. Same degradation, # same pre-#528 vocabulary answer, for the same reason: no # tokens exist to read a parse's tag from. # # Particles are NOT decided per token: _is_particle stays a # live vocabulary lookup, as _render._cap_word keeps it -- # rules/R4 draws this boundary per question, not per field. self._resolve() if tokens is None: # STRING PATH. split() rather than split(" ") because # split(" ") yields '' between repeated spaces and # `word[0]` below would raise IndexError on it (#232). v1 # stated the reason as `*_list` attributes bypassing # whitespace normalization, which no longer holds -- the # `*_list` properties are read-only in 2.x, and assignment # through `hn.middle = ...` normalizes -- but a doubled # space in a bare string handed directly to this method # (`_process_initial(name_part, ...)` with no `tokens=`) # still reaches here. words: tuple[str, ...] = tuple(name_part.split()) # No parse read this text, so every word takes the same # fallback _token_is_conjunction takes for a spliced one. conjunctions: tuple[bool, ...] = tuple( _render._reads_as_conjunction(word, self._lexicon) for word in words) else: words = tuple(tok.text for tok in tokens) conjunctions = tuple(self._token_is_conjunction(tok) for tok in tokens) initials = [] for word, conjunction in zip(words, conjunctions, strict=True): # #461: the conjunction filter reaches EVERY group; only # the particle filter is exempted for the given group. if not conjunction and (firstname or not self._is_particle(word)): initials.append(_render._first_letter(word)) if len(initials) > 0: return self.initials_separator.join(initials) # Return '' (never empty_attribute_default, which may be None) # when a part has no initialable words. group_initials below # decides what that means: one such element among others is # dropped (`Alex van Johnson`'s `van`); a group that yields # nothing AND is wholly particles initials its words; and a # group that yields nothing for any other reason is still # dropped. # # No group of a PARSED name reaches that third case any more, # and #461 is why: any word that is neither a particle nor a # connective initials, so a group reaching it is all particles # and connectives; not being wholly particles it holds a # connective; and that connective's part holds nothing but # particles and connectives for it to join, so the mark # readmits it and the group yields it. Measured 2026-09-20, # zero such groups over 95,119 names -- every corpus and case # text plus the review's generated grid -- where "Vega, Santa # de y" was the example until #461 and now initials 'S. y. V.'. # # What still reaches it is the paths with no parse to read, # where a connective is answered from the vocabulary and # carries no mark: HumanName(first="Santa", middle="de y", # last="Vega").initials() gives 'S. V.', the pre-#461 answer, # and so does a subclass overriding middle_list with the same # words (tests/v2/test_facade.py pins both). return "" def _initials_lists(self) -> tuple[list[str], list[str], list[str]]: """Initials for the first, middle and last name groups. Parts that yield no initials are dropped rather than kept as empty strings -- except a part that is wholly PARTICLES, whose words initial as ordinary name words since #404, so the prefix-only middle name "de la" is no longer an example of the dropping. Each group is walked as TOKENS rather than as the strings of the `*_list` view (#528), so every word carries the reading the parse gave it; the elements are the list view's own, folded first and continuations merged, because one walk builds both. A member whose PUBLIC `first_list`/`middle_list`/`last_list` property is overridden is the one exception: `_list_tokens_for` (the private token walk this method otherwise uses) does not consult that override, so honoring it means reading the override's own strings instead -- the pre-#528 walk, degraded to the vocabulary fallback exactly as a widen-only `_process_initial` override is (STATED BREAK, below): the override supplies strings, not tokens, so there is nothing to forward even if it accepted `tokens=`. """ def all_particle_guard(got: list[str], words: list[str]) -> list[str]: if got or not words or not all(self._is_particle(w) for w in words): return got # rules/R3: "A part whose every word is a particle initials # those particles: they are not acting as particles there" # -- the Except bullet this quote opens; nothing survived # the filter, so the whole group is particles # (decisions/R3). The # facade's twin of the core's # UNJOINED_TAG. NOT pinned against it by the case runners, # which compare the seven role fields only (Case carries # no initials column); the covering test is # tests/test_initials.py::test_initials_middle_name_all_prefixes, # and tools/differential/snapshot.jsonl records initials() # on both surfaces for every corpus name. _split_last # already applies the same guard to the base, which is why # last_base was never empty here. return [_render._first_letter(w) for w in words] def group_initials(groups: list[tuple[Token, ...]], firstname: bool = False) -> list[str]: got = [i for i in ( self._process_initial( " ".join(tok.text for tok in group), firstname=firstname, tokens=group) for group in groups) if i] words = [tok.text for group in groups for tok in group] return all_particle_guard(got, words) def group_initials_from_list(names: list[str], firstname: bool = False) -> list[str]: # PRE-#528 walk (`git show 338daf7:nameparser/_facade.py`), # reproduced exactly: no tokens exist to walk here, only # the override's own strings, so each element goes through # `_process_initial` with no `tokens=` -- the STRING path, # answered from the vocabulary rather than the parse. got = [i for i in (self._process_initial(n, firstname=firstname) for n in names if n) if i] words = [w for n in names if n for w in n.split()] return all_particle_guard(got, words) def initials_for(member: str, firstname: bool) -> list[str]: # One class-attribute identity check per member (cheap: no # parse involved) decides which walk honors that member's # public property. overridden = (getattr(type(self), f"{member}_list") is not getattr(HumanName, f"{member}_list")) if overridden: return group_initials_from_list( getattr(self, f"{member}_list"), firstname) return group_initials(self._list_tokens_for(member), firstname) return (initials_for("first", True), initials_for("middle", False), initials_for("last", False)) def initials_list(self) -> list[str]: first, middle, last = self._initials_lists() return first + middle + last def initials(self) -> str: first, middle, last = self._initials_lists() joiner = self.initials_delimiter + self.initials_separator def group(items: list[str]) -> str: return joiner.join(items) + self.initials_delimiter \ if items else "" # A fully-empty result renders as "" -- the v1 fallback to # C.empty_attribute_default (which may be None) is dropped per # #255. _s = self.initials_format.format( first=group(first), middle=group(middle), last=group(last)) return self.collapse_whitespace(_s) # -- comparison ----------------------------------------------------------- def matches(self, other: str | HumanName) -> bool: """Component-wise case-insensitive comparison (v1 parity); a str argument is parsed with this instance's resolved parser.""" if not isinstance(other, (str, HumanName)): # pre-check so the error names the facade type a caller # actually passed HumanName.matches(), not the core # ParsedName it delegates to below raise TypeError( f"matches() takes a str or HumanName, got {other!r}") target = other._parsed if isinstance(other, HumanName) else other return self._parsed.matches(target, parser=self._resolve()) def comparison_key(self) -> tuple[str, ...]: """One casefolded component per field in canonical order -- the v1 replacement for ==/hash (#223); see ParsedName.comparison_key.""" return self._parsed.comparison_key() # -- dunders ------------------------------------------------------------ def collapse_whitespace(self, string: str) -> str: # v1 parser.py:976 verbatim, over _render's regexes (the #254 # collapse owns them; this public method keeps v1's narrower # two-step contract for initials() and direct callers) string = _render._SPACES.sub(" ", string.strip()) if string and _render._COMMA_CHAR.fullmatch(string[-1]): string = string[:-1] return string def __str__(self) -> str: if self.string_format is not None: rendered = self.string_format.format( **{k: v or "" for k, v in self.as_dict().items()}) # the full #254 collapse is _render._collapse -- one owner # for the cleanup chain the v1 __str__ spelled inline return _render._collapse(rendered) return " ".join(self) def __repr__(self) -> str: attrs = ( f" title: {self.title or ''!r}\n" f" first: {self.first or ''!r}\n" f" middle: {self.middle or ''!r}\n" f" last: {self.last or ''!r}\n" f" suffix: {self.suffix or ''!r}\n" f" nickname: {self.nickname or ''!r}\n" f" maiden: {self.maiden or ''!r}" ) return f"<{self.__class__.__name__} : [\n{attrs}\n]>" def __iter__(self) -> Iterator[str]: return (value for member in _MEMBERS if (value := getattr(self, member))) def __len__(self) -> int: return sum(1 for member in _MEMBERS if getattr(self, member)) def __getitem__(self, key: str) -> str: if isinstance(key, slice): raise TypeError( "slicing a HumanName was removed in 2.0 (#258); access " "the named attributes instead" ) # Role is a StrEnum, so Role members (and the plain 'given'/ # 'family' strings) reach here too -- translate to the v1 # spelling the facade actually exposes as attributes. return getattr(self, _V1_SPELLING.get(key, key)) def __setattr__(self, name: str, value: object) -> None: # "given"/"family" are the 2.0 spellings of first/last; the # facade has no such attributes, so plain assignment creates a # stray instance attribute while the parse (and .first/.last) # keeps the old value -- a silently forked name. Warn but # still set: ad-hoc attribute stashing is a legal v1 pattern, # so any code that worked keeps working. Only these two names # warn -- the other five 2.0 field names are real properties # whose setters work, and Role members reach here as their # string values (StrEnum). if name in _V1_SPELLING: warnings.warn( f"assigning HumanName.{name} creates an inert attribute; " f"the parse is unchanged -- use .{_V1_SPELLING[name]} " f"(the v1 spelling) to update the name", UserWarning, stacklevel=2) super().__setattr__(name, value) def as_dict(self, include_empty: bool = True) -> dict[str, str]: """The seven v1-named components as a dict; include_empty=False drops empty fields.""" d = {member: getattr(self, member) for member in _MEMBERS} if include_empty: return d return {k: v for k, v in d.items() if v} # -- pickle (v1-shaped state; one path for 1.4 and 2.x blobs) ----------- def __getstate__(self) -> dict[str, Any]: # The emitted key set matches v1.4's pickle shape (minus # encoding/_had_comma/_derived_*, which are v1-internal and # ignored on read), so one __setstate__ path serves both eras. state: dict[str, Any] = { "_full_name": self._full_name, "original": self.original, "C": None if self._C is CONSTANTS else self._C, "string_format": self.string_format, "initials_format": self.initials_format, "initials_delimiter": self.initials_delimiter, "initials_separator": self.initials_separator, "suffix_delimiter": self.suffix_delimiter, } for member in _MEMBERS: state[f"{member}_list"] = getattr(self, f"{member}_list") return state def __setstate__(self, state: dict[str, Any]) -> None: # unpickling skips __init__, so the construction warning never # reaches a pickle; this one does, at the pickle.loads line warnings.warn(HUMANNAME_PICKLE_DEPRECATION, DeprecationWarning, stacklevel=2) self._restore(state) def __copy__(self) -> HumanName: # copy.copy/deepcopy go through the state hooks, a subclass's # included, and a copy is no pickle load (_quiet_setstate) new = object.__new__(type(self)) _quiet_setstate(new, self.__getstate__(), HumanName, HUMANNAME_PICKLE_DEPRECATION) return new def __deepcopy__(self, memo: dict[int, object]) -> HumanName: new = object.__new__(type(self)) memo[id(self)] = new _quiet_setstate(new, copy.deepcopy(self.__getstate__(), memo), HumanName, HUMANNAME_PICKLE_DEPRECATION) return new def _restore(self, state: dict[str, Any]) -> None: c = state.get("C") self._C = CONSTANTS if c is None else c self._snapshot_gen = -1 defaults = self._C._snapshot()[2] self._string_format = state.get("string_format", defaults.string_format) self._initials_format = state.get("initials_format", defaults.initials_format) self._initials_delimiter = state.get("initials_delimiter", defaults.initials_delimiter) self._initials_separator = state.get("initials_separator", defaults.initials_separator) self._suffix_delimiter = state.get("suffix_delimiter", defaults.suffix_delimiter) self._full_name = state.get("_full_name", "") # Components come back exactly as pickled # (mechanisms.md#FACADE-CONTRACT): synthetic # tokens, never a re-parse. Build them per *_list ENTRY rather # than from one joined string -- an entry may hold several words # ("Ph. D.", "Q.C. M.P."), and re-splitting the joined string on # whitespace would promote each word to its own entry, which the # suffix view then renders comma-separated ("Ph., D."). Marking # continuation words "joined" is the inverse of _list_tokens_for's # heal, so list -> pickle -> list is the identity v1 gave us. tokens: list[Token] = [] for member in _MEMBERS: role = Role(_V2_FIELD.get(member, member)) entries = state.get(f"{member}_list") or [] # Everything here is iterable but shreds differently: a str # yields characters ("John" -> first "J o h n"), a Mapping # yields keys only, bytes yields ints. v1 stored lists, so # this only guards foreign or hand-built state -- but it # names the field at the load site instead of failing # opaquely later, or not at all. if isinstance(entries, (str, bytes, Mapping)): raise TypeError( f"{member}_list must be a list of strings, not " f"{type(entries).__name__} ({entries!r}); this " f"pickle was not written by nameparser" ) for entry in entries: if not isinstance(entry, str): raise TypeError( f"{member}_list entries must be strings, got " f"{entry!r}; this pickle was not written by " f"nameparser" ) for position, word in enumerate(entry.split()): # UNCLASSIFIED_TAG for the same reason replace() # stamps it: a pickle carries the *_list STRINGS # and no tags, so nothing here was read by a parse # and case repair must ask the vocabulary rather # than read an absent conjunction tag. Without it a # restored "juan ortega y gasset" repairs to # "Ortega Y Gasset", which is neither v1's answer # nor the same name's unpickled one. tags = {UNCLASSIFIED_TAG} if position: tags.add("joined") tokens.append(Token(word, None, role, frozenset(tags))) self._parsed = ParsedName( original=str(state.get("original", "")), tokens=tuple(tokens)) derek73-python-nameparser-456b024/nameparser/_lexicon.py000066400000000000000000002034441527303271700232250ustar00rootroot00000000000000"""Immutable vocabulary configuration for the 2.0 API. Layering: may import nameparser.config DATA modules (the imports in _default_lexicon() are the authoritative list) as the single source of vocabulary during 2.x -- never nameparser.config itself, never nameparser.parser. Enforced by tests/v2/test_layering.py. """ from __future__ import annotations import dataclasses import functools import sys import unicodedata import warnings from collections.abc import Iterable, Iterator, Mapping, Set from dataclasses import dataclass, field from types import FrameType, MappingProxyType from typing import cast #: Vocabulary set fields, in declaration order. add()/remove() operate #: on exactly these and reject capitalization_exceptions (its entries #: are pairs -- use dataclasses.replace); __or__ unions these AND merges #: capitalization_exceptions right-biased. _VOCAB_FIELDS = ( "titles", "given_name_titles", "suffix_acronyms", "suffix_words", "suffix_acronyms_ambiguous", "particles", "particles_ambiguous", "conjunctions", "conjunctions_ambiguous", "bound_given_names", "given_name_abbreviations", "maiden_markers", "surnames", "honorific_tails", "after_given_titles", "suffix_words_ambiguous", "agnomens", "titles_ambiguous", "title_modifiers", "title_styles", "title_phrases", ) #: (marker, base, why) triples. Each marker QUALIFIES how entries of #: its base vocabulary are read and carries no vocabulary of its own, #: so an entry outside the base is a configuration mistake -- but the #: mistake differs per pair, and the reason is recorded here rather #: than generalized, because an orphan is NOT simply inert. Nor is the #: qualification one-directional: the first two NARROW their base (an #: entry is read as vocabulary in fewer places), while honorific_tails #: WIDENS it, granting a suffix word the glued position on top of the #: whole-token match every suffix word already gets. #: #: * particles_ambiguous: _assign keys on the tag alone, so an orphan #: makes the parse emit a spurious particle-or-given ambiguity. #: * suffix_acronyms_ambiguous: _vocab returns True on the ambiguous #: set before testing suffix_acronyms, so an orphan silently turns a #: word into a period-gated suffix. #: * honorific_tails: script_segment peels the tail into its own token #: before classify ever runs, so an orphan splits the name and leaves #: the fragment stranded inside it -- worse than not peeling at all. #: Its base is deliberately NARROWER than what actually claims the #: peeled piece: suffix_as_written ORs suffix_words with the #: non-ambiguous suffix_acronyms, so a tail listed only as an acronym #: would classify fine yet is rejected here. Accepted, and a decision #: rather than an oversight -- the three-term predicate is easy to #: get wrong in the dangerous direction (an ambiguous acronym admitted #: as a tail would peel a period-gated word off a real name), and #: nothing needs the acronym half: the shipped tails are CJK #: honorifics, which are words. #: The shipped vocabulary holds the relation by construction: its #: build puts a word in GLUED_HONORIFICS only through an `attached` #: property on a word reading (tools/vocab/build.py). The check here #: is the only thing that holds a caller's own. #: #: Three fields are deliberately NOT here and have no check of their own. #: given_name_titles -- see the note in __post_init__ for why every #: attempt at one rejected working configurations. conjunctions_ambiguous #: -- an orphan is never consulted because both the classify fork and #: its ambiguity emitter require the base entry as well, so an orphan #: decides nothing, and AGENTS.md's invariants rule guards harm, not #: no-ops. after_given_titles -- the same: classify tags a word from it #: only where the word is in titles too, so an orphan is never read. #: agnomens -- the same: classify reads it only for a word already in #: suffix_words_ambiguous, so an orphan decides nothing. #: title_modifiers, title_styles and title_phrases -- the same: the #: title phrase test (rules/H9) reads them only for words that are #: titles, so an orphan decides nothing. titles_ambiguous is no subset #: at all: its words are titles only inside a phrase, so a caller lists #: one there without listing it in titles, and one in both is a title #: everywhere. _SUBSET_FIELDS = ( ("particles_ambiguous", "particles", "an orphan emits a spurious particle-or-given ambiguity"), ("suffix_acronyms_ambiguous", "suffix_acronyms", "an orphan silently becomes a period-gated suffix"), ("honorific_tails", "suffix_words", "an orphan splits the name and leaves the tail inside it"), ("suffix_words_ambiguous", "suffix_words", "an orphan silently becomes a suffix gated by the count"), ) #: The fields whose entries may be PHRASES: stored space-joined and #: folded per word (_title_key), and exempt from the dead-entry warning #: below. Every other field is matched one word at a time, where a #: multi-word entry can never match. #: #: The two are not the same mechanism, and the difference is why the #: exemption is a list rather than a rule. A given_name_titles run is #: identified per word FIRST -- 'lt' and 'col' are each title #: vocabulary -- and only then looked up, as the whole run's key or as #: its LAST word's (_run_addresses_by_given, #489). The whole-run arm #: is the one a phrase entry is matched by; the last-word arm is a #: single word and cannot reach a phrase, so storage stays what it was #: (the two are asked together, not in precedence). A maiden marker #: phrase has no such per-word foothold: 'z' and 'domu' are not markers #: individually, and adding them separately (which this warning used to #: advise) reads 'Maria Kowalska z domu Nowak' as maiden 'domu Nowak' #: and strips 'Anna z Nowak' of its family name. So markers are matched #: by a genuine multi-token lookahead instead -- #: _pipeline._vocab.maiden_marker_run, longest first -- and only the #: STORAGE rule is shared with titles. _PHRASE_FIELDS = ("given_name_titles", "maiden_markers", "title_phrases") #: Every character the parser reads as a full stop at a WORD'S EDGE #: (#322/#323): the ASCII period, the fullwidth FULL STOP U+FF0E, the #: IDEOGRAPHIC FULL STOP U+3002 and its halfwidth form U+FF61. One #: string, four readers -- the lookup fold below, _vocab's script #: classification fold, _script_segment's surname site and honorific #: tail match -- so that "which characters are a period" is answered #: once; the bundle's fifth site, _pieces' opening-abbreviation shape, #: reads no stop set at all but the no-initials repertoire #: (_policy._NO_INITIALS) that licenses every reader here. NFKC is #: NOT a substitute for listing them: it folds U+FF0E to '.' and #: U+FF61 to U+3002, and leaves U+3002 as it is. #: Not every period test reads this set, and the ones that do not are #: named so the claim is checkable: the interior-period shapes -- the #: dotted acronym 'M.A.' and the split 'Ph. D.' (_vocab) -- and FOUR #: ASCII-only edge tests on Latin shapes, across THREE modules -- the #: initial 'J.' (_vocab), the bracketed credential '(Mgr.)' and the #: word-internal apostrophe rule (_extract), and the trailing #: middle-initial carve-out 'V.' (_assign) -- stay ASCII. That is a #: fact about those TESTS and not about this set's reach: the fold #: below is script-agnostic, so a LATIN word wearing a wide stop #: reaches vocabulary the ASCII veto would have taken it out of. #: 'V。' folds to 'v' and does not end in the ASCII period the #: trailing-suffix carve-out tests (_assign, not is_initial), so #: it is roman five -- suffix -- where 'V.' stays a middle #: initial through that same carve-out; 'Jr。' simply reaches its #: entry, and 'Dr。' its title. #: decisions/cjk-full-stops carries the measurements. FULL_STOPS = "..。。" def _normalize(word: str) -> str: """Lowercase, NFC-compose, strip whitespace and EDGE full stops (FULL_STOPS, not the ASCII period alone -- #322). Interior periods survive on purpose: 'J.R.' must not collapse to 'jr' and hit the periodless vocabulary (v1 parity, pinned live 2026-07-17). Suffix-ACRONYM membership alone uses the period-free form (see _vocab.suffix_as_written), mirroring v1's is_suffix, which removed periods only for the acronym test. lower(), NOT casefold(): casefold's caseless-matching folds mutate the stored vocabulary itself -- 'κος' becomes the misspelling 'κοσ' (final sigma flattened) and 'großfürst' becomes 'grossfürst' -- while lower() applies Unicode SpecialCasing contextually and keeps both as authored. This function is the single fold for storage AND match-time lookups, so matching stays symmetric either way; lower() is what v1's lc() used, preserving which cross-spellings match. NFC, since #322, and only for a non-ASCII word (ASCII is already NFC, and this runs per token on the parse path -- decisions/parse-cost). NFD hangul decomposes to jamo and missed every vocabulary entry; composing at lookup AND at storage keeps the two symmetric, and no shipped entry changes under it (measured 2026-09-09: fold every string member of every Lexicon.default() field through NFC and count the changes; zero of 1735). This is the same NFC composition script classification already applies (_vocab._normalized_for_script); token text is never rewritten. Strips to a FIXED POINT. A single strip().strip(FULL_STOPS) leaves stops-around-whitespace half done ('. a .' -> ' a '), so a value that is re-normalized later -- on unpickle, or by a second add() -- would change under its owner. v1 never re-normalized, so this only matters now that storage and match-time share one fold.""" word = word.lower() if not word.isascii(): word = unicodedata.normalize("NFC", word) while True: stripped = word.strip().strip(FULL_STOPS) if stripped == word: return word word = stripped # rules/P7: "A one-letter word written with its period is an # initial and not a particle" def _spells_an_initial(n: str, text: str) -> bool: """Whether `text` is ONE letter written with its period ('Ó.', 'j.'), which makes it an initial whatever particle the letter also spells (#604). `n` is `_normalize(text)`, folded once by the caller. Every particle test asks this AFTER its membership hit, so a word no particle set holds never pays the frame. It lives here rather than in _pipeline._vocab because the two views that re-read the particle vocabulary over a parsed name -- _render's case repair and the facade's initials and last-name split -- must ask it too, and neither may import the pipeline. The ASCII period only, as `_vocab.is_initial` takes it: a CJK full stop marks no initial ('Ó。' is the particle). The other half of is_initial, the scripts that write no initials (#320), is NOT asked -- its script table is _policy's, which this module may not import -- so a caller's one-character particle in such a script, written with a period, is vetoed where is_initial would call it no initial (decisions/P7). No shipped particle is one character of those scripts.""" return len(n) == 1 and text[-1] == "." def _fold_words(words: Iterable[str]) -> list[str]: """The words of a title run, folded for storage and lookup. A multi-word title is matched as one key ('lt col'), so the fold has to run per word -- _normalize on the whole phrase would leave interior periods. Words that fold away are DROPPED, not kept as empty. Keeping the gap makes the key non-idempotent -- 'lt .' would store 'lt ', which match time can never build: a run CAN carry a lone '.' (the conjunction merge puts one there), but the fold drops the empty word, so the key is 'lt' and the stored entry is inert. Storage re-runs this fold on unpickle and on every dataclasses.replace, so a value that changes under a second pass is one Lexicon later rejects as "not written by this version". _normalize converges for the same reason; so must anything built on top of it. A LIST, so that _run_addresses_by_given's last-word arm can be the last word of this fold rather than a re-split of the joined key -- "the last word of the FOLDED key" is then structural, and the two arms read one fold between them. map/filter rather than a comprehension: both are C calls where a comprehension is a Python frame on 3.11, and this runs on the parse path for every name carrying a title run (decisions/parse-cost). """ return list(filter(None, map(_normalize, words))) def _title_key(words: Iterable[str]) -> str: """The given_name_titles lookup key for a run of title words: the folded words, space-joined. Defined once because it is built at match time (_run_addresses_by_given, which is how post_rules' H1 and group's P5 licence both reach it) and at translation time (_config_shim's first_name_titles), and a divergence between them fails silently: the entry simply stops matching.""" return " ".join(_fold_words(words)) def _run_addresses_by_given(words: Iterable[str], vocabulary: Set[str]) -> bool: """Whether a run of title words addresses by the GIVEN name. Two sites ask it and they must agree -- post_rules for H1, group for the P5 licence -- because a run read two ways is a rule contradicting itself (decisions/P5, the 2026-08-22 #369 entry). One predicate, so they cannot drift. The WHOLE run's key, or that key's LAST word (#489): a run is written as several titles and addresses the way its final one does, so 'Her Majesty Queen' addresses by given name because 'queen' does. The two arms are asked of one key and neither is the other's fallback -- the `or` short-circuits but decides nothing, since for a one-word run the key IS its last word. Both arms read one _fold_words list, so the last word is the last word of the FOLDED key by construction rather than by agreement, and a run token that folds away cannot empty that arm: the conjunction merge can put a lone '.' in the run ('Sir and . John'), and the fold drops it. What the drop leaves as the last word can then be the CONJUNCTION -- 'Sir and . John' keys 'sir and' and reads family 'John', where 'Sir and Dame John' keys 'sir and dame' and reads given. Harmless on the shipped vocabulary, which holds no entry ending in a connective, and a caller who stored one would be asking for it (measured 2026-09-09). The whole-run arm is what keeps a caller's multi-word phrase entry working: 'lt col' is stored as one key and matched as one run. Over the SHIPPED vocabulary it is dead -- every shipped entry is a single word (asserted in test_lexicon.py), so it can only ever match a one-word run, which is a run the last-word arm reads the same way. H2's unlisted abbreviations ride in the run. One can never match as the last-word key, being in no vocabulary by definition -- written as the inputs that produce those runs, 'Xyz. Sir John' keys 'xyz sir', matches on 'sir' and reads given 'John', while 'Sir Xyz. John' keys 'sir xyz', matches on neither arm and reads family 'John'. It CAN sit inside a whole-run key that matches, because given_name_titles is deliberately not validated against titles: a caller who stores 'sir xyz' makes 'Sir Xyz. John' read given. The vocabulary is passed in rather than read off a default: a caller's own Lexicon is the one that has to be consulted, and this module is where Lexicon is defined.""" folded = _fold_words(words) if not folded: # every word folded away, so there is no key and no last word: # the joined spelling built "" here, which matched nothing return False return " ".join(folded) in vocabulary or folded[-1] in vocabulary def _reject_buffer(value: object, label: str, plural: str) -> None: """Binary sequences iterate to INTS, so every downstream entry check reports a byte value -- "must be strings, got 100" for b'dr', where 100 is 'd'. That names neither the cause nor the fix. v1 shipped a decode hint for this (#238), and parse() and the facade both carry one, so no config entry point should be the odd one out. """ if isinstance(value, (bytes, bytearray, memoryview)): raise TypeError( f"{label} must be an iterable of {plural}, not " f"{type(value).__name__} -- decode first, e.g. " f"raw.decode('utf-8')" ) def _warn_dead_entry(message: str) -> None: # A fixed stacklevel always lands on library internals: the call # depth differs per entry point (Lexicon(), add(), unpickle, # dataclasses.replace, and the v1 shim's lazy snapshot -- built on # the first parse after a Constants mutation, several facade frames # below the user's own add()). Walk out of this module (and # dataclasses' replace frames, and the facade layer that builds # lexicons on the caller's behalf) so the warning points at the # caller's own line. level = 2 frame: FrameType | None = sys._getframe(1) while frame is not None and frame.f_globals.get("__name__") in ( __name__, "dataclasses", "nameparser._config_shim", "nameparser._facade"): frame, level = frame.f_back, level + 1 warnings.warn(message, UserWarning, stacklevel=level) #: 2.8.1 (#754): the 3.0 migration guide's table of what each set field #: becomes. 3.0 configures a lexicon by reading -- what a word is -- and #: refuses these fields with a TypeError naming the readings for the #: words given, a translation this release does not attempt: it depends #: on the word's other readings, so the guide's row and 3.0's error are #: the sources. _FIELDS_GUIDE = ("https://nameparser.readthedocs.io/en/latest/migrate.html" "#lexicon-fields-readings") #: modules whose frames sit between a caller and a Lexicon's #: construction: this one (add, remove, __or__, __init__), #: dataclasses.replace and copy.replace _CONSTRUCTION_MODULES = (__name__, "dataclasses", "copy") def _outside_caller( construction: bool, ) -> tuple[int, Mapping[str, object] | None, str] | None: """Where a set-field warning belongs: the stacklevel, counted from the function that calls warnings.warn (itself called from the method this asks for), of the first frame outside this module, dataclasses and copy, with the locals of a dataclasses.replace frame passed on the way and the replace the caller called (``copy.replace()`` where a copy frame was passed, else ``dataclasses.replace()``); or None where the library itself made the call -- that frame inside nameparser (Lexicon.default(), the locale packs, parser_for, the v1 facade), or, for a construction, a method of this module building the lexicon: add() and remove() warn for themselves, and __or__ is 3.0's own spelling. """ level = 3 frame: FrameType | None = sys._getframe(2) replacing: Mapping[str, object] | None = None replace_call = "dataclasses.replace()" while frame is not None: module = frame.f_globals.get("__name__") if module not in _CONSTRUCTION_MODULES: break if module == "copy": replace_call = "copy.replace()" code = frame.f_code.co_name if (construction and module == __name__ and code not in ("__init__", "__post_init__")): return None if module == "dataclasses" and code in ("replace", "_replace"): replacing = frame.f_locals frame, level = frame.f_back, level + 1 caller = "" if frame is None else frame.f_globals.get("__name__", "") if caller == "nameparser" or caller.startswith("nameparser."): return None return level, replacing, replace_call def _surnames_code(op: str, words: frozenset[str]) -> str: """Code doing add() or remove()'s surnames edit the 3.0 way, which runs quietly on 2.8.1 too; prose for a long list, whose code would swamp the message.""" if len(words) > 5: return (f"join a Lexicon holding those {len(words)} surnames to it " f"with |" if op == "add" else f"subtract those {len(words)} surnames from lexicon.surnames " f"in dataclasses.replace()") listed = "{" + ", ".join(map(repr, sorted(words))) + "}" return (f"write lexicon | Lexicon(surnames=frozenset({listed}))" if op == "add" else f"write dataclasses.replace(lexicon, " f"surnames=lexicon.surnames - {listed})") def _warn_set_fields(level: int, spelling: str, fields: Iterable[str], surnames: str = "") -> None: """Warn at `level` (from _outside_caller) that `spelling`, a call, writes `fields`, naming each and the guide row that replaces it. surnames gets a sentence of its own, ending on `surnames`, the code for the edit (_surnames_code): 3.0 keeps the field, and only add() and remove() stop taking it.""" fields = list(fields) internal = [name for name in fields if name != "surnames"] parts = [] if internal: parts.append( f"{spelling} writes {', '.join(internal)}, which 3.0 makes " f"internal and refuses with a TypeError naming the readings " f"that replace it for the words given; the guide's \"Lexicon " f"fields → readings\" table has a row for each field") if "surnames" in fields: parts.append( f"3.0 keeps surnames a field, but {spelling} no longer takes " f"it: {surnames}, which runs on 2.8.1 too") warnings.warn( f"Lexicon's set fields are deprecated (#754): {'; and '.join(parts)}. " f"See {_FIELDS_GUIDE}", DeprecationWarning, stacklevel=level) def _normset( entries: Iterable[str], field_name: str, warn: bool = True, ) -> frozenset[str]: # Reject a bare str before iterating: iterating "dr" would silently # yield the single characters {'d', 'r'} -- the set(str) footgun on # the primary customization surface. if isinstance(entries, str): raise TypeError( f"Lexicon.{field_name} must be an iterable of strings, " f"not a bare string" ) _reject_buffer(entries, f"Lexicon.{field_name}", "strings") # A Mapping would silently contribute only its keys; a dict here # almost always means the caller confused this field with # capitalization_exceptions. if isinstance(entries, Mapping): raise TypeError( f"Lexicon.{field_name} must be an iterable of strings, not a " f"mapping (only capitalization_exceptions holds key->value pairs)" ) try: items = tuple(entries) # materialize once; entries may be a generator except TypeError: raise TypeError( f"Lexicon.{field_name} must be an iterable of strings, " f"got {entries!r}" ) from None normalized = set() for w in items: if not isinstance(w, str): raise TypeError( f"Lexicon.{field_name} entries must be strings, got {w!r}" ) # A phrase field's entries are matched as a multi-word run, so # they fold per word: stored as the same key the match site # builds, or 'lt. col' (and 'z. domu') would be kept verbatim # and never match anything -- a silent no-op on the config # surface. Every other field holds single words, where the two # folds agree. See _PHRASE_FIELDS for how the two differ once # stored. n = _title_key(w.split()) if field_name in _PHRASE_FIELDS \ else _normalize(w) # "." or "" is a data bug (stray split artifact, empty CSV # cell); dropping it silently would also let a data-module typo # vanish instead of failing CI. if not n: raise ValueError( f"Lexicon.{field_name} entry {w!r} normalizes to empty " f"(lowercase + strip full stops/whitespace leaves nothing)" ) # Every field outside _PHRASE_FIELDS is matched one word at a # time, so a multi-word entry can never match -- the library # itself shipped eight such dead entries for years (repaired # 2026-07-26). Warn, never raise: an inert entry produces # nothing, and the given_name_titles precedent says a raise # here costs working configurations (see __post_init__). # warn=False is _edit()'s pass (both ops): add() warns via the # new instance's __post_init__; remove() stores nothing, so # warning there would name entries the caller is trying to get # RID of, with "split it" advice that makes no sense for a # no-op. Both invariants are indifferent to WHICH fields are # exempt: the exemption only decides whether a warning exists to # be emitted once or suppressed. if (warn and field_name not in _PHRASE_FIELDS # interior whitespace test; split() covers all Unicode # whitespace and n != "".join(n.split())): _warn_dead_entry( f"Lexicon.{field_name} entries are matched one word at " f"a time; multi-word entry {w!r} can never match. " f"Split it into separate entries") normalized.add(n) return frozenset(normalized) def _alnum(text: str) -> str: return "".join(c for c in text if c.isalnum()) def _offered_mask(normalized_key: str) -> str: """The working value a mismatched-mask error offers (carried to the v1 shim's re-raise on _MaskValueError, so both name one fix): the key upper-cased, or the key unchanged -- an identity mask, always legal -- where .upper() changes the letter count ('straße' -> 'STRASSE') and would fail the check if pasted back in.""" upper = normalized_key.upper() return upper if _alnum(_normalize(upper)) == _alnum( normalized_key) else normalized_key class _MaskValueError(ValueError): """_normpairs' error for a value that does not spell its key. Built in the UnicodeDecodeError idiom -- structure in, message out: `key`, `normalized_key` and `value` are the offending inputs; `offered` (the v1 shim's re-spelled fix, and the v2 one quoted in the message below) is computed from them, not carried separately. __reduce__ is explicit because the inherited one re-calls __init__ with self.args, which holds the message alone, not the three fields __init__ needs -- and a ProcessPoolExecutor pickles a worker's exception to deliver it, surfacing a construction failure as BrokenProcessPool. Passing self.__dict__ as reduce's third element (restored via a plain __dict__.update, there being no __setstate__ here) carries along anything added after construction -- e.g. add_note() -- that reconstructing from the three fields alone would not. (An exception, so _types.py's frozen-dataclass pickle guards do not apply.)""" def __init__(self, key: str, normalized_key: str, value: str) -> None: self.key = key self.normalized_key = normalized_key self.value = value self.offered = _offered_mask(normalized_key) super().__init__( f"capitalization_exceptions value {value!r} for key {key!r} " f"does not spell the key's letters and digits: a value " f"is a case mask, the key's own letters and digits " f"recased -- e.g. " f"capitalization_exceptions=(({normalized_key!r}, " f"{self.offered!r}),)") def __reduce__( self, ) -> tuple[type[_MaskValueError], tuple[str, str, str], dict[str, object]]: return (type(self), (self.key, self.normalized_key, self.value), self.__dict__) def _normpairs( raw: Mapping[str, str] | Iterable[tuple[str, str]], ) -> tuple[tuple[str, str], ...]: """Canonicalize capitalization_exceptions input: _normset's sibling for the one pair-valued field. Dedupes on the NORMALIZED key so the tuple and the derived map always agree ("PHD" and "phd" collide after normalization; "Ph.D." does not, the fold keeping interior periods, so it is a key of its own, "ph.d"); last occurrence wins, matching dict semantics and the right-bias rule used elsewhere.""" if isinstance(raw, str): raise TypeError( "capitalization_exceptions must be a mapping or an " "iterable of (key, value) pairs, not a bare string" ) _reject_buffer(raw, "capitalization_exceptions", "(key, value) pairs") pairs = raw.items() if isinstance(raw, Mapping) else raw try: pairs = iter(pairs) except TypeError: raise TypeError( "capitalization_exceptions must be a mapping or an " f"iterable of (key, value) pairs, got {raw!r}" ) from None deduped: dict[str, str] = {} for entry in pairs: # A 2-char str entry would unpack "ab" into ("a", "b") # silently, so reject str outright; other mis-shapes would # otherwise surface as bare unpack errors. if isinstance(entry, str): raise TypeError( f"capitalization_exceptions entries must be " f"(key, value) pairs, got {entry!r}" ) try: k, v = entry except (TypeError, ValueError): raise TypeError( f"capitalization_exceptions entries must be " f"(key, value) pairs, got {entry!r}" ) from None if not isinstance(k, str) or not isinstance(v, str): raise TypeError( f"capitalization_exceptions entries must be " f"str -> str, got {k!r}: {v!r}" ) normalized_key = _normalize(k) if not normalized_key: raise ValueError( f"capitalization_exceptions key {k!r} normalizes to " f"empty (lowercase + strip full stops/whitespace leaves " f"nothing)" ) # Stored NFC-composed, case kept (unicodedata, not _normalize): # one stored spelling per value, whichever form the caller # wrote. _apply_mask reads both forms alike since #542 # (_render._beside looks past combining marks); before it, a # decomposed letter read as a base letter split off beside a # non-alpha combining mark. The mask check below # passes either spelling, _normalize composing both of its # sides. `written` keeps the caller's own spelling -- composed # or not -- for the mismatch error below: a decomposed value # (e.g. 'e' + a combining acute) and its NFC form print # identically to a reader's eye but are different str objects, # so echoing the composed spelling back would show the caller # a value they did not write. written = v v = unicodedata.normalize("NFC", v) # capitalized() looks words up one at a time (the _WORD regex # never yields spaces), so a multi-word key is unreachable -- # checked FIRST, ahead of the mask check below, because an # invariant guards harm and a value that can never be read # does no harm no matter what it says (AGENTS.md: "a check # guards harm, not no-ops"). Raising the mask error for such a # key -- as a naive mask-first order does -- would refuse a # config that was always going to be a no-op. # interior whitespace test; split() covers all Unicode whitespace if normalized_key != "".join(normalized_key.split()): _warn_dead_entry( f"capitalization_exceptions keys are matched one word " f"at a time; multi-word key {k!r} can never match. " f"Split it into per-word entries") deduped[normalized_key] = v continue # A value is a case MASK (#459): the key's own letters and # digits recased, compared through the key's own fold. Its # punctuation marks which letters are joined into one run # (_render._apply_mask reads that) and is never written into # the word. A raise, not a warning: a mismatched value used to # be SUBSTITUTED for the word, and rules/R4: "Repair # changes case and nothing else", so there is no reading of # one that repair can honor. if _alnum(_normalize(v)) != _alnum(normalized_key): # key is the RAW key, not normalized_key: the v1 shim's # hint must overwrite the offending dict entry, which is # stored under the raw spelling -- writing the normalized # spelling would add a second entry and still raise # (measured with {"PHD": "Junior"}: the shim's suggested # constants.capitalization_exceptions['PHD'] = ... has to # match the key already there, not 'phd'). `written`, not # `v`: the caller's own spelling, not the NFC-composed one. raise _MaskValueError(k, normalized_key, written) deduped[normalized_key] = v return tuple(sorted(deduped.items())) @dataclass(frozen=True, slots=True) class Lexicon: """The vocabulary a parser matches against: which words are titles, particles, suffixes, and so on. Immutable and hashable. Start from :meth:`default` (the shipped vocabulary) or :meth:`empty`, derive variants with :meth:`add` / :meth:`remove` / ``|`` (union), and pass the result to ``Parser(lexicon=...)``. Entries are normalized at construction -- lowercased, NFC-composed, edge full stops stripped -- so matching is case-insensitive. Vocabulary entries are single words -- a multi-word entry warns at construction and can never match. Three fields are exempt, and they differ in HOW they match: ``given_name_titles`` is looked up against the run of words the parse has ALREADY read as titles -- the whole run space-joined, or that run's last word -- ``maiden_markers`` is matched by lookahead, longest first, over words that need not be markers on their own (``"z domu"``), and ``title_phrases`` holds two-word phrases, stored space-joined and matched against two adjacent title words (``"chief justice"``). Field docs below show examples, not full contents; inspect any field's shipped vocabulary directly, e.g. ``Lexicon.default().conjunctions``. Deprecated since 2.8.1: 3.0 makes the set fields internal and configures vocabulary by reading, so writing one here, through ``add``, ``remove`` or ``dataclasses.replace`` warns. ``surnames`` and ``capitalization_exceptions`` stay fields in 3.0, so passing them here or to ``dataclasses.replace`` is quiet, but ``add`` and ``remove`` with ``surnames`` warn, since 3.0's take readings only; see the migration guide's "Deprecated in 2.8.1".""" #: Pre-nominal titles ("dr", "sir", "capt", ...). Full default #: list: :data:`~nameparser.config.titles.TITLES`. titles: frozenset[str] = frozenset() #: Titles whose single name word, before or after them, reads as a #: GIVEN name ("sir", "sister", ...) rather than a family name. Full #: default list: :data:`~nameparser.config.titles.GIVEN_NAME_TITLES`. given_name_titles: frozenset[str] = frozenset() #: Post-nominal acronym suffixes, matched with or without periods #: ("phd" matches "PhD" and "Ph.D."). Full default list: #: :data:`~nameparser.config.suffixes.SUFFIX_ACRONYMS`. suffix_acronyms: frozenset[str] = frozenset() #: Post-nominal word suffixes ("jr", "esquire", "iii", ...). Full #: default list: #: :data:`~nameparser.config.suffixes.SUFFIX_WORDS`. suffix_words: frozenset[str] = frozenset() #: Subset of suffix_acronyms whose bare forms are also common #: surnames ("ma", "do"). Written with periods one is a suffix #: outright; written bare, capitals decide in a mixed-case name #: ("Jack MA" is the credential, "Jack Ma" keeps his family name) #: and otherwise the word counts as a suffix only where a name #: word is left beside it ("John Smith ma"), rules/S6 and rules/S4. Full #: default list: #: :data:`~nameparser.config.suffixes.SUFFIX_ACRONYMS_AMBIGUOUS`. suffix_acronyms_ambiguous: frozenset[str] = frozenset() #: Family-name particles that chain onto the following piece #: ("van", "de", "bin", ...). Full default list: #: :data:`~nameparser.config.particles.PARTICLES`. particles: frozenset[str] = frozenset() #: Subset of particles that can also BE a given name ("Van #: Johnson", but also "Van Buren"). Membership decides nothing #: about chaining: the prefix chain skips the name's first piece #: and never consults this set, so it leaves a leading particle a #: piece of its own whether listed or not -- "de Mesnil" groups #: into two pieces exactly as "van Gogh" does, and since #367 the #: NAME in "Dr. de Mesnil" and "Dr. Van Johnson" groups into those #: same two pieces behind the title piece, a title not being #: part of the name it precedes. What membership #: decides is what becomes of that piece afterwards. Under ANY #: ``name_order`` a member records a particle-or-given ambiguity #: and a non-member records none, and a non-member is additionally #: folded back into the family name once roles exist WHERE IT OPENS #: THE NAME, so the whole name is the surname ("de Mesnil" -- a bare "de", with nothing to #: fold into, is left alone). That fold is order-independent too #: (#359) -- not because the word could not be a given name, which #: it can where position forces it (``parse("de")`` reports given #: "de"), but because a particle OPENING the name has the rest of #: the name to join forward to, and that evidence is positional #: rather than vocabulary, so no declared order contradicts it. Which field a MEMBER's piece #: lands in is ``name_order``'s question, not this set's. #: No constant of its own -- the default derives #: as particles minus #: :data:`~nameparser.config.particles.NON_GIVEN_NAME_PARTICLES` #: (which marks the opposite, never-given subset). particles_ambiguous: frozenset[str] = frozenset() #: Words or characters that join surrounding pieces into one #: ("and", "&", "y", "и", ...). Full default list: #: :data:`~nameparser.config.conjunctions.CONJUNCTIONS`. conjunctions: frozenset[str] = frozenset() #: Subset of conjunctions that read as an INITIAL rather than a #: connective in a name written wholly in one case ("e": "jose e #: maria santos" reads middle "e maria", where "juan garcia y #: lopez" joins because "y" is not a member). Mixed-case input is #: decided by the writing instead and never consults this set, and #: neither does a caseless letter (Arabic و), which has no case to #: read. A member additionally reports #: :attr:`~nameparser.AmbiguityKind.CONJUNCTION_OR_INITIAL` where a #: name word of its part follows it (with none, it connects #: nothing); a non-member reports nothing, its reading not being in #: doubt. #: Full default list: #: :data:`~nameparser.config.conjunctions.CONJUNCTIONS_AMBIGUOUS`. #: Entries need not also be in ``conjunctions``; one that is not is #: simply never consulted, so it is inert rather than an error. conjunctions_ambiguous: frozenset[str] = frozenset() #: Given-name prefixes that bind to the following word to form one #: given name ("abdul" -> "Abdul Salam"); never standalone names. #: Full default list: #: :data:`~nameparser.config.bound_given_names.BOUND_GIVEN_NAMES`. bound_given_names: frozenset[str] = frozenset() #: Given names written short ("chas" for Charles, "wm" for William, #: Filipino "ma" for María). Period-marked and opening the part of #: the name that carries the given name, an entry reads as that #: given name, where an unlisted abbreviation there reads as a #: title by its shape (rules/H2): "Chas. Smith" reads given #: "Chas.", "Xyz. Smith" title "Xyz.". An entry also in ``titles`` #: is a title there, which is what listing it as one asks; anywhere #: else an entry is read as it would be unlisted. Full default #: list: #: :data:`~nameparser.config.given_names.GIVEN_NAME_ABBREVIATIONS`. given_name_abbreviations: frozenset[str] = frozenset() #: Marker words introducing a birth surname, routed to the maiden #: field ("née", "geb.", "rozená", ...). An entry may be a PHRASE #: ("z domu"): entries are matched by lookahead, longest first, so #: a phrase wins where it matches and a word entry that starts one #: still matches on its own everywhere else. A phrase matches only #: where its words stand together -- a bracketed clause or a comma #: between them ends the run. Full default list: #: :data:`~nameparser.config.maiden_markers.MAIDEN_MARKERS`. maiden_markers: frozenset[str] = frozenset() #: Family names for the unspaced-name segmentation stage (#271), #: matched longest-first against the start of the FIRST token #: written wholly in a script :attr:`Policy.segment_scripts #: ` activates. The default #: carries the Korean census list #: (``nameparser/config/_ko_surnames.py``); Chinese #: surnames ship in locales.ZH because Han segmentation is opt-in. surnames: frozenset[str] = frozenset() #: Honorifics, and credential, office and generational words, that #: may be peeled off the END of a name token (#308, #707), matched longest-first: 田中さん splits into 田中 and さん #: before the tokens are classified. Every entry must also be a #: :attr:`suffix_words` entry -- the peeled tail is claimed by #: suffix classification like any other post-nominal. Deliberately #: NOT gated on :attr:`Policy.segment_scripts #: ` (unlike :attr:`surnames` #: above): 田中さん peels under the default policy, where HAN is in #: no activation set, because a tail entry carries its own license #: to fire. Entries are matched against the RAW token text, and #: only within a name containing a non-ASCII character, so an ASCII #: or mixed-case entry is at best conditionally active -- a ``"Jr"`` #: entry is stored ``"jr"`` and matches only lowercase text. The #: field is effectively CJK-scoped in 2.1, which is what the shipped #: vocabulary is. Full default list: #: :data:`~nameparser.config.suffixes.GLUED_HONORIFICS`. honorific_tails: frozenset[str] = frozenset() #: Salutations that read as a title where one ENDS the given part #: after a family comma, written bare: "Doe, Jane Mrs" reads title #: "Mrs" where an office or rank word there stays a middle name #: ("Doe, John Pope"), rules/H8. A period-marked title word reads as #: a title there whether listed here or not (rules/H5). Entries need #: not also be in ``titles``; one that is not is never consulted. #: Full default list: #: :data:`~nameparser.config.titles.AFTER_GIVEN_TITLES`. after_given_titles: frozenset[str] = frozenset() #: Subset of suffix_words that are also borne as names ("fils", #: "neto"). Read as a suffix only where the name has words to #: spare, as an ambiguous acronym is: "Pedro Alves Neto" ends in a #: suffix, "Ana Neto" keeps her family name, and either reading is #: reported (rules/S11). Written in Title case in a name of mixed #: case it leans to the name even with words to spare, and in lower #: case it is the suffix after a comma that names the family, #: unless it is also in ``agnomens``. Full default list: #: :data:`~nameparser.config.suffixes.SUFFIX_WORDS_AMBIGUOUS`. suffix_words_ambiguous: frozenset[str] = frozenset() #: Subset of suffix_words_ambiguous whose writing says nothing: #: an agnomen ("filho", "neto") is written in the case of the name #: around it, so only the words to spare decide (rules/S11). An #: entry outside suffix_words_ambiguous is never consulted. Full #: default list: :data:`~nameparser.config.suffixes.AGNOMENS`. agnomens: frozenset[str] = frozenset() #: Titles that may also be a given name ("dean", "justice", #: "major"), each read as a title only inside a title phrase: after a #: modifier, a style, or a title it stands in ``title_phrases`` #: behind; or before "of" or a title it stands in ``title_phrases`` #: in front of. Elsewhere it is a name word, and where it opens the #: name the parse reports ``title-or-name`` if a phrase of #: ``title_phrases`` the parse can read holds it (rules/H9), so "Grace Smith", which no #: phrase holds, reports nothing, and emptying ``title_phrases`` #: silences the report: "Dean Smith" #: and "Mr Major Smith" read given "Dean" and "Major", "Dean of #: Students Smith" and "Major General Smith" a title. An entry also #: in ``titles`` is a title wherever it stands, which is what listing #: it there asks, so the default lists these words here alone. Full #: default list: #: :data:`~nameparser.config.titles.TITLES_AMBIGUOUS`. titles_ambiguous: frozenset[str] = frozenset() #: Titles that only qualify a head title ("vice", "wing", "grand"). #: At the head of the name one is a title only with a head title #: behind it in its run of titles, so "Wing Commander Lau" keeps the #: title where "Wing Lau" reads "Wing" as the given name; after a #: comma it is read as any title is (rules/H9), save one also in #: ``titles_ambiguous``, which is read everywhere as at the head. One #: standing before a word of ``titles_ambiguous`` puts that word in #: a title phrase. Entries in neither ``titles`` nor #: ``titles_ambiguous`` are never consulted. Full default #: list: :data:`~nameparser.config.titles.TITLE_MODIFIERS`. title_modifiers: frozenset[str] = frozenset() #: Honorific styles ("hon", "excellency", "highness"): one standing #: before a word of ``titles_ambiguous`` puts that word in a title #: phrase, as in "The Hon. Justice Smith" (rules/H9). Entries in #: neither ``titles`` nor ``titles_ambiguous`` are never consulted. #: Full default list: #: :data:`~nameparser.config.titles.TITLE_STYLES`. title_styles: frozenset[str] = frozenset() #: Two-word title phrases, a title and the title it qualifies #: ("chief justice", "major general"), stored space-joined: a word #: of ``titles_ambiguous`` on either side of one is in a title #: phrase (rules/H9). A phrase is consulted only if it is two #: words, each in ``titles`` or ``titles_ambiguous``. Full default #: list: #: :data:`~nameparser.config.titles.TITLE_PHRASES`. title_phrases: frozenset[str] = frozenset() #: Lowercase word -> case mask used by capitalized(): the word's #: own letters and digits, each in the case it takes ("phd" -> #: "PhD"), laid over the word as written, so the one entry #: repairs "ph.d." to "Ph.D.". The mask's own punctuation marks #: where its letters are joined into one run versus split apart, #: and is never written into the word -- repair keeps the writer's #: own punctuation. A value that does not spell the key's letters #: and digits raises ValueError. Pair-valued: change it with #: dataclasses.replace(), not add()/remove(); read it as a mapping #: via capitalization_exceptions_map. Full default mapping: #: :data:`~nameparser.config.capitalization.CAPITALIZATION_EXCEPTIONS`. # Canonical storage: sorted tuple of (key, value) pairs. The # constructor tolerates any Mapping (or pair iterable) at runtime and # canonicalizes here; this closes the caller-aliasing hole and keeps # Lexicon hashable. Read via capitalization_exceptions_map. capitalization_exceptions: tuple[tuple[str, str], ...] = () _cap_map: Mapping[str, str] = field( init=False, repr=False, compare=False, hash=False, default_factory=lambda: MappingProxyType({})) def __post_init__(self) -> None: self._validate() # 2.8.1 (#754): a caller's construction or dataclasses.replace() # with a set field 3.0 refuses. Not from __setstate__, which runs # _validate alone: loading a pickle writes no field. where = _outside_caller(construction=True) if where is not None: self._warn_constructed(*where) def _warn_constructed(self, level: int, replacing: Mapping[str, object] | None, replace_call: str) -> None: # surnames is never named here: 3.0 accepts it in Lexicon() and # dataclasses.replace(), as it does capitalization_exceptions internal = [name for name in _VOCAB_FIELDS if name != "surnames"] if replacing is None: # a field left at its default is empty, so a non-empty one # was passed written = [name for name in internal if getattr(self, name)] spelling = "Lexicon()" else: # dataclasses.replace() names the instance `obj`, and 3.13's # dataclasses._replace (behind copy.replace) `self`; a field # not passed is filled in with the instance's own value old = replacing.get("obj", replacing.get("self")) changes = replacing.get("changes") if not isinstance(old, Lexicon) or not isinstance(changes, Mapping): return written = [name for name in internal if name in changes and changes[name] is not getattr(old, name)] spelling = replace_call if written: # one more frame than __post_init__ itself _warn_set_fields(level + 1, spelling, written) def _validate(self) -> None: for name in _VOCAB_FIELDS: object.__setattr__(self, name, _normset(getattr(self, name), name)) canonical = _normpairs(self.capitalization_exceptions) object.__setattr__(self, "capitalization_exceptions", canonical) object.__setattr__(self, "_cap_map", MappingProxyType(dict(canonical))) for marker, base, why in _SUBSET_FIELDS: orphans = getattr(self, marker) - getattr(self, base) if orphans: raise ValueError( f"{marker} marks a subset of {base}; " f"not in {base}: {', '.join(sorted(orphans))} " f"({why}). Add them to {base}, or drop them from " f"{marker}" ) # NOT validated: given_name_titles against titles. The lookup # key is the space-joined run of Role.TITLE tokens, built by the # parse rather than drawn from this vocabulary, and a # conjunction inside a run is itself tagged Role.TITLE -- so # "sir and dame" is a matchable key whose middle word is in # conjunctions, not titles. A whole-entry check rejected # multi-word entries; a per-word check rejected that one. An # unreachable entry here is inert: nothing consults it, nothing # misparses. That is the cheap failure, and guarding it proved # the expensive one -- three working configurations broken # across two attempts. Do not add a third. # # The v2 form of the vocabulary build's check that a bound given # name which is also a particle may be given (tools/vocab/build.py, # check 9). The build guards the shipped data; this guards # vocabulary a caller supplies. contradictory = ( self.bound_given_names & self.particles) - self.particles_ambiguous if contradictory: raise ValueError( f"bound_given_names entries that are also particles must be " f"in particles_ambiguous; not in particles_ambiguous: " f"{', '.join(sorted(contradictory))}. A particle that never " f"starts a given name cannot also bind one — add them to " f"particles_ambiguous, or drop them from bound_given_names" ) # suffix_as_written ORs the acronym and word branches, so a word # membership bypasses the period gate the ambiguous set exists # to impose: listing 'ma' in suffix_words would make "Jack Ma" # read as a suffix and lose the family name. gate_bypassed = self.suffix_acronyms_ambiguous & self.suffix_words if gate_bypassed: raise ValueError( f"an ambiguous suffix acronym must not also be a suffix " f"word; in both: {', '.join(sorted(gate_bypassed))}. The " f"word branch matches without periods, which bypasses the " f"period gate suffix_acronyms_ambiguous exists to impose" ) # the mirror of the check above: suffix_as_written ORs the # unambiguous acronym branch too, so a gated suffix word listed # there is claimed as a suffix with no count ('Ana Neto' would # lose her family name, rules/S11) word_gate_bypassed = self.suffix_words_ambiguous & ( self.suffix_acronyms - self.suffix_acronyms_ambiguous) if word_gate_bypassed: raise ValueError( f"an ambiguous suffix word must not also be an unambiguous " f"suffix acronym; in both: " f"{', '.join(sorted(word_gate_bypassed))}. The acronym " f"branch matches it outright, which bypasses the count " f"suffix_words_ambiguous exists to impose; drop it from " f"suffix_acronyms or from suffix_words_ambiguous" ) # -- constructors ---------------------------------------------------- @classmethod def empty(cls) -> Lexicon: return cls() @classmethod def default(cls) -> Lexicon: return _default_lexicon() # -- dunders ---------------------------------------------------------- def _deltas_from(self, baseline: Lexicon) -> list[tuple[str, int, int]]: deltas = [] for name in _VOCAB_FIELDS + ("capitalization_exceptions",): mine = set(getattr(self, name)) theirs = set(getattr(baseline, name)) added, removed = len(mine - theirs), len(theirs - mine) if added or removed: deltas.append((name, added, removed)) return deltas def __repr__(self) -> str: # Bounded: renders only which fields deviate from the nearer of # the two named constructors and by how many entries -- never the # entries themselves (design rule, see nameparser._types module # docstring). Diffing empty()-built lexicons against default() # would tell the wrong story ("default minus the entire # default vocabulary"). if self == Lexicon.default(): return "Lexicon(default)" if self == Lexicon.empty(): return "Lexicon(empty)" candidates = [(label, self._deltas_from(baseline)) for label, baseline in (("default", Lexicon.default()), ("empty", Lexicon.empty()))] label, deltas = min( candidates, key=lambda c: sum(a + r for _, a, r in c[1])) rendered = ", ".join( name + ": " + "".join( part for part, n in ((f"+{a}", a), (f"-{r}", r)) if n) for name, a, r in deltas) return f"Lexicon({label} + {rendered})" def __getstate__(self) -> dict[str, object]: # _cap_map is a MappingProxyType, which pickle rejects; ship every # other slot and rebuild the proxy from the canonical tuple on load. return {f.name: getattr(self, f.name) for f in dataclasses.fields(self) if f.name != "_cap_map"} def __setstate__(self, state: dict[str, object]) -> None: # Fail at the unpickle site if the state comes from a different # Lexicon field layout (version skew) -- silently loading it # would defer the failure to some distant attribute read. # Message kept in sync with _types._guarded_setstate by design # (layering keeps this module import-free of _types). Unlike # that guard it admits no field added later (`ADDED_IN`, 2.7): # every Lexicon field is still required, so a release adding one # breaks older Lexicon pickles until this learns the marker. expected = {f.name for f in dataclasses.fields(Lexicon)} - {"_cap_map"} if set(state) != expected: missing = ", ".join(sorted(expected - set(state))) or "none" unexpected = ", ".join(sorted(set(state) - expected)) or "none" raise ValueError( f"incompatible Lexicon pickle: missing fields: {missing}; " f"unexpected fields: {unexpected}" ) for name, value in state.items(): object.__setattr__(self, name, value) # Re-run construction validation rather than trusting the blob. # The layout check above catches SHAPE skew; this catches # CONTENT skew, which is likelier -- particles_ambiguous flipped # meaning between v1's never-given set and v2's may-be-given set # without changing its name, so an old pickle would otherwise # load a semantically inverted lexicon in silence. Rebuilds # _cap_map for free, which this used to do by hand. self._validate() # __post_init__ also normalizes, and quietly accepting a # rewritten value would make this a place where caller data # changes without a word. A pickle this library wrote is always # normalized already (_normalize converges), so a difference # here means the state came from somewhere else -- say so while # the offending entries can still be named. # One pass per field, in _VOCAB_FIELDS declaration order. cast is # safe: __post_init__ just ran _normset over each of these and # raised for anything that was not an iterable of str. drifted = [] for name in _VOCAB_FIELDS: lost = frozenset( cast("Iterable[str]", state[name])) - getattr(self, name) if lost: drifted.append(f"{name}: {', '.join(sorted(lost))}") # the pair field too, or ten fields raise and the eleventh is # quietly re-canonicalized (keys normalized, sorted, deduped) given_pairs = cast("Iterable[tuple[str, str]]", state["capitalization_exceptions"]) if tuple(given_pairs) != self.capitalization_exceptions: drifted.append("capitalization_exceptions") if drifted: raise ValueError( "incompatible Lexicon pickle: entries are not normalized " f"({'; '.join(drifted)}); this state was not written by " "this version of nameparser" ) def __or__(self, other: Lexicon) -> Lexicon: if not isinstance(other, Lexicon): return NotImplemented updates: dict[str, object] = { name: getattr(self, name) | getattr(other, name) for name in _VOCAB_FIELDS } # right-biased on key conflicts, mirroring later-wins for scalars merged = dict(self._cap_map) | dict(other._cap_map) updates["capitalization_exceptions"] = tuple(sorted(merged.items())) return dataclasses.replace(self, **updates) # type: ignore[arg-type] # -- properties ------------------------------------------------------- @property def capitalization_exceptions_map(self) -> Mapping[str, str]: return self._cap_map # -- editing ---------------------------------------------------------- def _edit(self, op: str, entries: Mapping[str, Iterable[str]]) -> Lexicon: updates: dict[str, frozenset[str]] = {} for name, words in entries.items(): if name == "capitalization_exceptions": raise TypeError( "capitalization_exceptions holds key->value pairs; " "use dataclasses.replace(lexicon, " "capitalization_exceptions=(*lexicon." "capitalization_exceptions, ('key', 'KEY'))) " "instead of " f"{op}()" ) if name not in _VOCAB_FIELDS: raise TypeError( f"unknown Lexicon field {name!r}; valid fields: " f"{', '.join(_VOCAB_FIELDS)}" ) current: frozenset[str] = getattr(self, name) # warn=False: this pass only computes the new set membership, # never stores it directly. add() still warns exactly once, # from the replaced instance's own __post_init__ -> _normset; # remove() never reaches __post_init__ with the dead entry # (it is subtracted out here), so it stays silent -- correct, # since a removal stores nothing a warning could be about. # That silence covers the entry BEING REMOVED only: a # different multi-word entry still stored re-warns from the # derived instance's __post_init__, since the warning is # per-construction by design. normalized = _normset(words, name, warn=False) updates[name] = (current | normalized if op == "add" else current - normalized) # mypy's dataclasses.replace() typing checks a **dict's single # value type against every field's type (it can't see which keys # are actually present behind the unpack), so a homogeneous # frozenset[str] dict is flagged against the tuple/Mapping-typed # capitalization_exceptions/_cap_map fields even though this dict # never contains those keys (guarded above). return dataclasses.replace(self, **updates) # type: ignore[arg-type] def add(self, **entries: Iterable[str]) -> Lexicon: """A copy with each named field's words added. Deprecated since 2.8.1: 3.0's ``add`` takes readings, not fields.""" return self._edit_warning("add", entries) def remove(self, **entries: Iterable[str]) -> Lexicon: """A copy with each named field's words removed. Deprecated since 2.8.1: 3.0's ``remove`` takes readings, not fields.""" return self._edit_warning("remove", entries) def _edit_warning(self, op: str, entries: Mapping[str, Iterable[str]]) -> Lexicon: # 2.8.1 (#754): 3.0's add() and remove() take readings, and # refuse every field keyword, surnames included. The warning # follows a successful edit, so a bad value raises its own # error first. An iterator of surnames is read once here, so the # warning can spell the edit and _edit still sees the words. named = entries.get("surnames") if isinstance(named, Iterator): entries = {**entries, "surnames": tuple(named)} edited = self._edit(op, entries) if entries: where = _outside_caller(construction=False) if where is not None: code = ("" if "surnames" not in entries else _surnames_code( op, _normset(entries["surnames"], "surnames", warn=False))) _warn_set_fields(where[0], f"{op}()", entries, code) return edited @functools.cache def _default_lexicon() -> Lexicon: # The compiled vocabulary, read from _compiled rather than the public # nameparser.config modules, whose constants warn at a caller's read # since 2.8.1 (#1007), and surnames.py's since 2.8.2. from nameparser.config._compiled import BOUND_GIVEN_NAMES from nameparser.config._compiled import GIVEN_NAME_ABBREVIATIONS from nameparser.config._compiled import CAPITALIZATION_EXCEPTIONS from nameparser.config._compiled import ( CONJUNCTIONS, CONJUNCTIONS_AMBIGUOUS) from nameparser.config._compiled import MAIDEN_MARKERS from nameparser.config._compiled import NON_GIVEN_NAME_PARTICLES, PARTICLES from nameparser.config._compiled import ( AGNOMENS, GLUED_HONORIFICS, SUFFIX_ACRONYMS, SUFFIX_ACRONYMS_AMBIGUOUS, SUFFIX_WORDS, SUFFIX_WORDS_AMBIGUOUS, ) from nameparser.config._ko_surnames import KOREAN_SURNAMES from nameparser.config._compiled import ( AFTER_GIVEN_TITLES, GIVEN_NAME_TITLES, TITLE_MODIFIERS, TITLE_PHRASES, TITLE_STYLES, TITLES, TITLES_AMBIGUOUS) # every vocabulary constant is a frozenset since #293, so each one # feeds its strictly-typed frozenset[str] field as it stands -- and # this cache reading them ONCE is the reason they are frozen. A # mutated module set always reached a freshly built Constants, and # reached this Lexicon only when the edit landed before the first # call; after it, the cache was already built and the same edit was # invisible here. Which of the two a program got was not something # the code doing the mutating could see. # keep in sync with _config_shim.Constants._snapshot() (pinned by the # default-Constants equality test in tests/v2/test_config_shim.py) return Lexicon( titles=TITLES, given_name_titles=GIVEN_NAME_TITLES, suffix_acronyms=SUFFIX_ACRONYMS, suffix_words=SUFFIX_WORDS, suffix_acronyms_ambiguous=SUFFIX_ACRONYMS_AMBIGUOUS, particles=PARTICLES, # FLIPPED from v1: v1 marks the never-given subset; v2 marks the # may-be-given subset (migration: complement translation). particles_ambiguous=PARTICLES - NON_GIVEN_NAME_PARTICLES, conjunctions=CONJUNCTIONS, conjunctions_ambiguous=CONJUNCTIONS_AMBIGUOUS, bound_given_names=BOUND_GIVEN_NAMES, given_name_abbreviations=GIVEN_NAME_ABBREVIATIONS, maiden_markers=MAIDEN_MARKERS, surnames=KOREAN_SURNAMES, honorific_tails=GLUED_HONORIFICS, after_given_titles=AFTER_GIVEN_TITLES, suffix_words_ambiguous=SUFFIX_WORDS_AMBIGUOUS, agnomens=AGNOMENS, titles_ambiguous=TITLES_AMBIGUOUS, title_modifiers=TITLE_MODIFIERS, title_styles=TITLE_STYLES, title_phrases=TITLE_PHRASES, # pass canonical pair-tuples so this strictly-typed call site never # feeds a Mapping to the tuple-annotated field; __post_init__ # still tolerates a Mapping at runtime for interactive use capitalization_exceptions=tuple(sorted(CAPITALIZATION_EXCEPTIONS.items())), ) derek73-python-nameparser-456b024/nameparser/_locale.py000066400000000000000000000065071527303271700230240ustar00rootroot00000000000000"""The Locale type: a named delta over (Lexicon, Policy). A Locale dissolves at parser construction (parser_for, a later plan): lexicon fragments union onto the base, the PolicyPatch folds via apply_patch. Packs are pure data; they have no privileged capabilities. Layering: imports _lexicon and _policy only (enforced by tests/v2/test_layering.py). """ from __future__ import annotations import dataclasses import re from dataclasses import dataclass from nameparser._lexicon import Lexicon from nameparser._policy import UNSET, PolicyPatch from nameparser._types import _guarded_getstate, _guarded_setstate @dataclass(frozen=True, slots=True) class Locale: """A named, shareable bundle of vocabulary and behavior for a naming tradition: a lexicon fragment plus a policy patch, applied together by :func:`nameparser.parser_for`. The packs shipped with nameparser live in :mod:`nameparser.locales`; building your own needs no registration -- construct one and pass it to ``parser_for``. See the :doc:`locale packs guide ` for using, creating, and contributing packs.""" #: Identifier, lowercase ``[a-z0-9_]+`` (e.g. "ru", "tr_az"). code: str #: Vocabulary ADDED to the base parser's lexicon (unioned; a pack #: never removes base vocabulary). lexicon: Lexicon #: Behavior changes folded onto the base policy (set-valued fields #: union; scalars override, later pack wins). policy: PolicyPatch = PolicyPatch() # in the class body so @dataclass(slots=True) keeps them __getstate__ = _guarded_getstate __setstate__ = _guarded_setstate def __post_init__(self) -> None: if not isinstance(self.code, str): raise TypeError( f"Locale.code must be a str, got {self.code!r}" ) if not self.code.strip(): raise ValueError( f"Locale.code must be a non-empty string, got {self.code!r}" ) if self.code != self.code.lower(): raise ValueError( f"Locale.code must be lowercase, got {self.code!r}" ) # Codes are registry keys (parser_for, third-party packs): every # accepted character is supported forever, so pin the charset # while relaxing later is still compatible. One separator only -- # allowing '-' too would make tr-az and tr_az distinct keys. if not re.fullmatch(r"[a-z0-9_]+", self.code): raise ValueError( f"Locale.code must match [a-z0-9_]+, got {self.code!r}" ) if not isinstance(self.lexicon, Lexicon): raise TypeError( f"Locale.lexicon must be a Lexicon, got {self.lexicon!r}" ) if not isinstance(self.policy, PolicyPatch): raise TypeError( f"Locale.policy must be a PolicyPatch, got {self.policy!r}" ) def __repr__(self) -> str: # Bounded: shows the code and which Policy fields the patch sets, # never the Lexicon contents or the patched values themselves # (design rule, see nameparser._types module docstring). patched = [f.name for f in dataclasses.fields(self.policy) if getattr(self.policy, f.name) is not UNSET] suffix = f": {', '.join(patched)}" if patched else "" return f"Locale({self.code!r}{suffix})" derek73-python-nameparser-456b024/nameparser/_parser.py000066400000000000000000000462421527303271700230610ustar00rootroot00000000000000"""Parser and the module-level parse() for the 2.0 API. Layering: sits on _types/_lexicon/_policy/_locale/_pipeline; never imports _render or the v1 facade (enforced by tests/v2/test_layering.py). _default_parser is THE one sanctioned module-level global: a functools.cache'd frozen Parser over default config. """ from __future__ import annotations import dataclasses import functools import warnings from dataclasses import dataclass, field from nameparser._lexicon import Lexicon from nameparser._locale import Locale from nameparser._pipeline import run from nameparser._pipeline._assemble import assemble from nameparser._pipeline._post_rules import suffix_entries from nameparser._pipeline._state import ParseState from nameparser._pipeline._vocab import _SCRIPT_MATCHERS from nameparser._policy import UNSET, Policy, PolicyPatch, _Unset, apply_patch from nameparser._types import ( FOLDED_TAG, ParsedName, Role, Segmenter, Token, _guarded_getstate, _guarded_setstate, _validated_field_strings, ) @dataclass(frozen=True, slots=True) class Parser: """A configured name parser: a :class:`Lexicon` (vocabulary) plus a :class:`Policy` (behavior), both defaulted when omitted. Build one when you need non-default configuration, build it once, and call :meth:`parse` many times -- it is immutable and thread-safe. An optional keyword-only ``segmenter`` (a :data:`~nameparser.Segmenter`) plugs in outside knowledge of where an unspaced CJK token divides -- Japanese kanji names, which no bundled list can settle. It is consulted only for a token the segmentation stage gates in and the vocabulary DECLINES, so a locale pack's surnames always win where they match; returning None declines in turn and the token stays whole. Two promises narrow when one is supplied (the first is rules/A1's Accepted bullet "The one exception to totality"): parse-totality gains its one exception -- an exception raised by the segmenter propagates, because a user-supplied callable's own error is a user-code error, not a content error -- and this Parser pickles only if its segmenter does (a module-level function pickles; a lambda or closure does not). With no segmenter, both promises hold unconditionally: all validity checking happens at construction, so a Parser that constructs successfully cannot fail at parse time on any str content. (The None field defaults resolve in __post_init__; after construction lexicon and policy are always non-None -- the annotations state the steady-state truth, hence the assignment ignores on the defaults.)""" lexicon: Lexicon = None # type: ignore[assignment] # None -> default() policy: Policy = None # type: ignore[assignment] # None -> Policy() #: An optional hook supplying outside knowledge of where an unspaced #: token divides -- see the class docstring; None leaves such tokens #: whole. Keyword-only, so the reserved growth stays additive: #: positional construction keeps its two-argument shape. segmenter: Segmenter | None = field(default=None, kw_only=True) # in the class body so @dataclass(slots=True) keeps them __getstate__ = _guarded_getstate __setstate__ = _guarded_setstate def __post_init__(self) -> None: if self.lexicon is None: object.__setattr__(self, "lexicon", Lexicon.default()) elif not isinstance(self.lexicon, Lexicon): raise TypeError( f"lexicon must be a Lexicon or None, got {self.lexicon!r}") if self.policy is None: object.__setattr__(self, "policy", Policy()) elif not isinstance(self.policy, Policy): raise TypeError( f"policy must be a Policy or None, got {self.policy!r}") if self.segmenter is not None and not callable(self.segmenter): raise TypeError( f"segmenter must be callable or None, got {self.segmenter!r}") # A configuration gap that used to be silent (#272's API, made # loud before 2.1.0): segment_scripts can activate a script # that neither the vocabulary nor a segmenter can ever divide # -- the JA pack's whole shape, when its segmenter is # forgotten. The parser then behaves identically to a working # one minus the feature, which reads as "not working" with no # signal why. Statically decidable here, so say it here; a # warning rather than an error because the inert pack is a # pinned, deliberate property (a JA registration must be safe # without the extra), and warnings are filterable by the rare # caller who wants exactly that. if self.segmenter is None: uncovered = sorted( script.value for script in self.policy.segment_scripts if not any(_SCRIPT_MATCHERS[script](entry) for entry in self.lexicon.surnames)) if uncovered: names = ", ".join(uncovered) one = len(uncovered) == 1 # the ja hint only where a Japanese script is among the # dead ones -- a hangul-only gap (a from-scratch # lexicon under the default policy) has different # remedies, and pointing it at ja_segmenter would be a # non sequitur ja_hint = ( " For Japanese, pass " "segmenter=locales.ja_segmenter() (install with: " "pip install 'nameparser[ja]')." if {"han", "hiragana", "katakana"} & set(uncovered) else "") warnings.warn( f"Policy.segment_scripts activates {names} but the " f"vocabulary has no surnames in " f"{'that script' if one else 'those scripts'} " f"and no segmenter is configured: unspaced names " f"written in {'it' if one else 'them'} will never " f"divide. Supply covering surnames, pass a " f"segmenter, or deactivate with " f"Policy(segment_scripts=frozenset()).{ja_hint}", UserWarning, stacklevel=3) def __repr__(self) -> str: # composes the two bounded component reprs; the # segmenter shows by name, and only when one is set, so the # default Parser's repr is unchanged seg = "" if self.segmenter is not None: # never repr() the callable itself: a partial reprs its # bound arguments and a callable instance its address, both # unbounded -- the class name is the bounded fallback name = (getattr(self.segmenter, "__qualname__", None) or type(self.segmenter).__name__) seg = f", segmenter={name}" return f"Parser({self.lexicon!r}, {self.policy!r}{seg})" def parse(self, text: str) -> ParsedName: """Parse one name string into a :class:`ParsedName`. Never raises on string content (unparseable input yields empty fields plus ambiguities); non-str raises TypeError eagerly, with a decode hint for bytes (bytes support ended with 1.x). The one exception to that totality is a configured ``segmenter``, whose own exceptions propagate (see the class docstring).""" if isinstance(text, bytes): raise TypeError( "parse() takes str, not bytes -- decode first, e.g. " "raw.decode('utf-8')") if not isinstance(text, str): raise TypeError(f"parse() takes str, got {text!r}") state = ParseState(original=text, lexicon=self.lexicon, policy=self.policy, segmenter=self.segmenter) return assemble(run(state)) # -- editing ---------------------------------------------------------- def revise(self, name: ParsedName, **fields: str) -> ParsedName: """:meth:`ParsedName.replace` with this parser's vocabulary: each value is tokenized and classified by a full sub-parse, so the stable tags survive and the tag-driven views (family_particles, initials(), the suffix join, and since #407 capitalized()) behave as if the text had been parsed. The value is classified ON ITS OWN, though -- a word whose reading depends on surrounding context may classify differently than it would in place, and a glued CJK honorific the whole name kept on an initial may peel in the value. The sub-parse's role choices and ambiguities are discarded -- every harvested token takes the named field's role -- and its structural behavior applies: delimiter characters do not become tokens, and a maiden marker is consumed as in parsing -- mid-value always, and leading a DELIMITED value under a policy routing that pair to maiden, where "(née Jones)" revises to "Jones" while the bare "née Jones" keeps its marker, a leading marker in an undelimited value being no marker at all (#329). A suffix value's ENTRY structure is derived from the value's own commas after the role is forced, by the rule a whole name uses: a comma parts two credentials and a space joins them, so ``revise(n, suffix="MD PhD")`` is one entry and a name's rendered suffix revises back to itself wherever the value's words read as the whole name read them -- the honorific peel above was the one corpus exception of 368 suffix-bearing names, 2026-09-06 (#511). A second came with #601/#602: a suffix holding a maiden marker the whole name left a word, whose value read on its own takes the clause and drops the marker ('Jane Doe PhD nee Smith' renders 'PhD nee Smith', which revises to 'PhD Smith'), three corpus names on 2026-10-04. A delimiter the policy names through ``extra_suffix_delimiters`` parts a value only where the value's own words, read as a name, give it a tail segment for the core to be dropped on; a run of post-nominals has none, with or without a comma of its own (``"MD PhD - FACS"`` and ``"MD, PhD - FACS"`` both keep the dash as a word), so write a comma at the boundary you want rather than the delimiter. Tokens are synthetic (span=None); original is unchanged; a value with no name content (empty, whitespace, or punctuation only) clears the field; ambiguities referencing replaced tokens are dropped.""" if not isinstance(name, ParsedName): raise TypeError(f"revise() takes a ParsedName, got {name!r}") replaced = _validated_field_strings(fields) harvested: dict[Role, tuple[Token, ...]] = {} for role, value in replaced.items(): # the same construction parse() makes, spelled twice # rather than through a helper: routing parse() through # one cost a frame per parse on the hot path (py3.11, # 2026-09-06: 415 calls/name against 414 without it, in a # 402-418 band), the same trade _mark_suffix_entries # refused, one row over state = run(ParseState(original=value, lexicon=self.lexicon, policy=self.policy, segmenter=self.segmenter)) dropped = set(state.dropped) # Force the role BEFORE the entry pass: it keys on # Role.SUFFIX, and a bare value's sub-parse reads its words # as a name ('RA PhD' is a title and a family there), so # inside the sub-parse it joined nothing. The sub-parse's # tags are KEPT, "joined" included: a within-piece mark # (the Ph. D. merge) is role-blind and right for every # role, and a clear was measured and backed out -- # decisions/R1 (2026-09-06 #511) carries the rest: the # between-piece mark that rides onto a non-suffix role, and # which views read it. Dropped tokens keep their role: the # pass filters them by index and assemble omits them. forced = tuple( tok if i in dropped else dataclasses.replace(tok, role=role) for i, tok in enumerate(state.tokens)) # rules/R1: "a run of post-nominals written with spaces # renders with spaces, and one written with commas keeps # them" -- the same pass post_rules runs last, over the # value's own comma offsets, so the harvest carries exactly # the entry structure the value's commas describe. Run for # every role: for a non-suffix role it is a no-op, kept # unconditional as the simpler contract, revise() being off # the call-count band. entried = suffix_entries( dataclasses.replace(state, tokens=forced)) harvested[role] = tuple( Token(t.text, None, role, t.tags - {FOLDED_TAG}) for t in assemble(entried).tokens) return name._with_field_tokens(harvested) # -- comparison ------------------------------------------------------- def matches(self, a: str | ParsedName, b: str | ParsedName) -> bool: """Component-wise case-insensitive comparison of two names, parsing str arguments with THIS parser. :meth:`ParsedName.matches` parses its str argument with the DEFAULT parser instead -- for names parsed with a custom Parser, use this method.""" if isinstance(a, str): a = self.parse(a) elif not isinstance(a, ParsedName): raise TypeError(f"matches() takes str or ParsedName, got {a!r}") if isinstance(b, str): b = self.parse(b) elif not isinstance(b, ParsedName): raise TypeError(f"matches() takes str or ParsedName, got {b!r}") return a.comparison_key() == b.comparison_key() # -- rendering delegates ---------------------------------------------- def capitalized(self, name: ParsedName, *, force: bool = False) -> ParsedName: """:meth:`ParsedName.capitalized` under THIS parser's lexicon. The no-argument form of that method uses the DEFAULT lexicon -- for names parsed with a custom Parser, use this method.""" if not isinstance(name, ParsedName): raise TypeError(f"capitalized() takes a ParsedName, got {name!r}") return name.capitalized(self.lexicon, force=force) @functools.cache def _default_parser() -> Parser: return Parser() def parse(text: str) -> ParsedName: """Parse a name with the default configuration and return a :class:`ParsedName`. Equivalent to ``Parser().parse(text)``; build your own :class:`Parser` (or use :func:`parser_for`) for custom vocabulary or behavior. Never raises on string content.""" return _default_parser().parse(text) def parser_for(*locales: Locale, base: Parser | None = None, segmenter: Segmenter | None | _Unset = UNSET) -> Parser: """Lexicon fragments unioned left-to-right onto base's; policy patches applied left-to-right (later wins; set-valued fields union per the patch metadata). Validation errors raised while applying a pack are wrapped with that pack's identity (rule D2) -- PolicyPatch validates lazily, so with stacked packs the raw error would otherwise point at nothing. Two packs setting the same SCALAR field is a declared conflict: UserWarning, later wins. A ``segmenter`` is passed straight through to the built Parser -- ``parser_for(locales.JA, segmenter=locales.ja_segmenter())`` is how a pack and a segmenter combine, since packs are pure data and cannot supply one. The argument has THREE states, the same :data:`~nameparser.UNSET` spelling a PolicyPatch field uses, because None is a meaningful value here and not an absence: omitted (UNSET) carries base's segmenter through unchanged; a callable OVERRIDES base's (later wins, the rule scalar policy fields follow); and an explicit ``None`` CLEARS base's, which is how you derive an unsegmented parser from a segmented one without rebuilding its lexicon and policy by hand.""" if base is not None and not isinstance(base, Parser): raise TypeError(f"base must be a Parser or None, got {base!r}") for loc in locales: if not isinstance(loc, Locale): raise TypeError(f"parser_for() takes Locale packs, got {loc!r}") lexicon = base.lexicon if base is not None else Lexicon.default() policy = base.policy if base is not None else Policy() # Resolved here rather than at the return because the return builds # a FRESH Parser: any field not listed there silently takes its # default, and a dropped segmenter would be invisible. UNSET, not # None, is what "not given" means -- None is the CLEAR request, and # collapsing the two would make an explicit # parser_for(..., segmenter=None) silently inherit the very # segmenter it was asked to drop. if segmenter is UNSET: segmenter = base.segmenter if base is not None else None scalar_setters: dict[str, str] = {} for loc in locales: for f in dataclasses.fields(PolicyPatch): if f.metadata.get("compose") == "union": continue if getattr(loc.policy, f.name) is UNSET: continue if f.name in scalar_setters and scalar_setters[f.name] != loc.code: warnings.warn( f"locale {loc.code!r} overrides scalar policy field " f"{f.name!r} already set by locale " f"{scalar_setters[f.name]!r}; later wins", UserWarning, stacklevel=2) scalar_setters[f.name] = loc.code try: lexicon = lexicon | loc.lexicon policy = apply_patch(policy, loc.policy) except (TypeError, ValueError) as exc: # safe: every raise in the apply path (Policy.__post_init__, # Lexicon.__or__, apply_patch) is a PLAIN TypeError/ValueError -- # a subclass with extra mandatory args would break this rewrap raise type(exc)( f"while applying locale {loc.code!r}: {exc}") from exc # rules/D1: "constructing a parser that activates division for # scripts with no covering surnames and no segmenter warns at # construction, naming the dead scripts and each way out" # (history: decisions/D1) # rules/D2: "applying a locale pack wraps any such error with # the locale's code, so a stacked configuration names which layer # broke" (history: decisions/D2) # Construction warnings (the segmenterless-activation check in # Parser.__post_init__) re-emit from THIS frame: its stacklevel is # sized for direct Parser(...) construction, and through this # function's extra frame the default single-line rendering would # point into the library instead of at the caller -- the exact # call the message tells them to change. with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") built = Parser(lexicon=lexicon, policy=policy, segmenter=segmenter) for w in caught: warnings.warn(w.message, stacklevel=2) return built derek73-python-nameparser-456b024/nameparser/_pipeline/000077500000000000000000000000001527303271700230105ustar00rootroot00000000000000derek73-python-nameparser-456b024/nameparser/_pipeline/AGENTS.md000066400000000000000000000242041527303271700243150ustar00rootroot00000000000000# Working in nameparser/_pipeline/ Loaded when a session reads or edits a pipeline stage. The root AGENTS.md carries the 2.0 conventions every stage follows (one stage per module, the `Consumes:`/`Produces:`/`Reads:` header, the frame budget, checkable stage-attribution claims); this file carries what only work inside the stages needs. The contract behind the ambiguity rules below is mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE, and rules/A1 is its observable side. ## A new inner loop needs a scaling guard A loop that grows with the input -- per token, per member, per pair -- needs a row in `tests/v2/test_benchmark.py`, whose module docstring says which table; the absolute-time tests there use constant-size input and are blind to a complexity regression. ## Ambiguity reports ### Emit at the decision site An `Ambiguity` records a fork the parse had to call, not a token that sits in an ambiguous vocabulary. Emit where the branch is taken — the trailing-suffix peel (read once in `_group` for a main segment since #614, its picks reported by `_assign` once the roles they took are known, except a pick the particle chain takes into the name, which the chain reports and the read then gives up), the delimiter escape's follow-up in `post_rules` — never by scanning for a `vocab:*-ambiguous` tag. The same tagged token is a genuine fork in one position and unremarkable in another (`do` mid-name in "Joao da Silva do Amaral de Souza" chooses nothing). **A branch that runs but changes nothing is not a decision either** -- the prefix chain's `merge_pieces(pieces, ptags, k, j)` executes even when `j == k + 1`, folding a piece into itself, and keying on "the code got here" reported a fork for all ambiguous particles on "Do Van Jr." (`Dr.` when that was written, before #367 made a plain title transparent and put the shape out of the loop's reach entirely), where the particle stayed a lone leading name piece — the GIVEN name under the default order, the family name under `FAMILY_FIRST` — and `_assign` reported the same token again. Check that the branch actually claimed something (`j > k + 1`, or a run of particles group merged into the piece ahead of S2's read, which the chain then claims nothing past, #625) before recording. ### Structure settles some forks before they arise That is why `PARTICLE_OR_GIVEN` is not emitted on the `FAMILY_COMMA` path's WHOLLY-FAMILY read -- the comma fixed which piece is the family -- and `SUFFIX_OR_NAME` is not emitted for "Ma, Jack". A tail segment is the same case from the other side: assign reads it as suffixes, a maiden marker there included since #601, and since #603 a title word past the second comma as a title (rules/C2, so `Freiherr` below reads title) (`Jane Doe, PhD, Jr nee van Ma` reads suffix `PhD, Jr nee van Ma`, the marker an ordinary word, rules/M2), so group makes no particle chain there at all (#636; its two emitters had been handed no report list there), after either comma — `John Smith, Jr., Freiherr von Richthofen` leaves `von` unchained and reports only `comma-structure` (rules/C2, 2026-09-28). Read that scope narrowly: the comma settles nothing about a particle trailing the given name, so P6's attachment decides that fork on the same path and reports it (#405; in `post_rules` until #613, in `_assign` since, right after the walk that reads the given part), in the kind naming the reading it OVERRODE, which is the reading assign made and not the word's vocabulary: `SUFFIX_OR_NAME` where assign had read the run as a post-nominal (`vd`, `mc`), else `PARTICLE_OR_GIVEN` where the run holds an ambiguous particle (`van`, and `do`, which is in the suffix vocabulary too but in its AMBIGUOUS half, so no credential reading was overridden), else silence. ### The field a report names is assemble's The decision site also has the token index and the detail text in hand, which the tag scan would have to reconstruct -- all but the FIELD a report names, which a rule in post_rules can still change (H1's move behind a title, P1's family-first fold, P6's attachment, and under opt-in policies the patronymic rotations, O6's two family names and `middle_as_family`'s fold): an emitter naming one passes the rest of its sentence as `field_tail` and assemble words the field from the final role (#626, where `Kim Min Do` under `FAMILY_FIRST` was told 'Do' was a middle name with 'Do' in the family). ### A fork decided in several stages needs an emitter in each **If a fork's two branches are taken in DIFFERENT stages, every one of them needs the emitter** -- `PARTICLE_OR_GIVEN` is decided in `_assign` when the ambiguous particle stays a lone leading piece, in `_group` when something shifts it off the name's leading piece and the prefix chain claims it, and wherever P6's attachment takes a trailing particle into the family -- in `_assign` after a family comma (since #613, and behind an empty part before the comma since #892), in `post_rules` at the end of a name read family-first (#467) -- so every one of them reports; for two years only the first did. What can still do the shifting is narrow, and #367 is why: a plain title no longer can (`Dr. Van Johnson` reads as `Van Johnson` does and reports from `_assign`), so the `_group` emitter needs a word that is BOTH a title and a particle — measured, `TITLES ∩ particles_ambiguous` is `{freiherr, st}` in the default vocabulary (`do` left TITLES in #296's audit; the particle-line block in decisions/vocabulary-exclusions records the before and after), plus any overlap a caller's config creates — standing ahead of the chained particle as the LEADING NAME word. Titles may precede it, so `Dr. St van Johnson` reaches the emitter and `St van Johnson` does too; a given name may not, so `Jan Freiherr von Richthofen` does not reach it while `Freiherr von Richthofen` and `Dr. Freiherr von Richthofen` do. Two shapes that look like they should reach it and do NOT, both measured by stepping `STAGES` and watching where `ambiguities` grows: `Dr. Do van Johnson` and `Do St Johnson` report from `assign`, not `group`, because `do` is no longer a title and so stays the leading name piece assign reports on — a both-vocabulary word CHAINED (`Jan St Johnson`) reports nothing at all. When checking whether that emitter is dead, a both-vocabulary word in the leading name position is the thing to look for, and the answer is that it is not dead. The stage-ownership map in `tests/v2/pipeline/test_state.py` must list `ambiguities` for each such stage, and it passes vacuously until a case row exercises the path, so add the row too. Report BOTH directions of a two-way fork — "John Smith MA" (read as a suffix) and "Jack MA" (read as the family name) are equally guesses. ### 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). 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. `test_an_alternative_names_the_field_the_other_reading_gives` (tests/v2/test_cases.py) checks every row's report under all three orders, a name reading by re-parsing with a plain stand-in word, so a new emitter that leaves it unset fails there. The case rows are not enough for a change to how a name reading's field is named: it models the parse the word did not get, so every rule that would have read that parse is a shape to model (a chain, P1's fold, P5's two licences), and two reviews of #752 phase 2m found seven shapes no row held, the second on the first's fixes, by running the same oracle over every one- to three-word run of a 21-word pool (particles, bound given names, numerals, credentials, titles, a marker) in six comma shapes under nine policies; add a found shape to `_REVIEW_SHAPES`. The oracle cannot reach a report whose stand-in is read by the same decision (a credential run's, a one-case connective's, where `Q` is an initial too); those are rows of `_EXPECTED_ALTERNATIVES` beside it, which also pins each reading's kind and source, and a third review of the same PR found a connective inside a joined piece unnamed in a third of one corpus's connective reports for want of such a row. And work a report does must not walk the name: the given-name title test, asked once per report, made three `_SHAPES` rows quadratic (`credential_run` 13.9x for 4x the input). ### Testing an emitter Every kind needs a trigger in `tests/v2/test_contracts.py::_AMBIGUITY_TRIGGERS` (an explicit `None`, strict-xfail, while reserved), and case-table rows pin expected kinds exactly, so a new emitter shows up in both immediately. **Pin the decision, not the vocabulary**: the only titled-particle test used an UNAMBIGUOUS particle, so it walked the right code path and proved nothing about the branch under test -- two criticals passed 1539 tests. A row contrasting the two readings ("John Smith V" against "John Smith B") is what makes an emitter's absence meaningful. **Folding one site of a rule into another moves every input the two sites' GUARDS disagree on, not only the shape the fold was for.** Compare the guards line by line before measuring: #916 removed P6's empty-head site in post_rules for assign's comma site and measured its moves on C15's grid, which held a title-and-`vd` class only; the review found a second class off the grid (`, de PhD vd`), the comma site's P1 guard (`is_lone_never_given_particle`) being one the removed site never had. derek73-python-nameparser-456b024/nameparser/_pipeline/__init__.py000066400000000000000000000026541527303271700251300ustar00rootroot00000000000000"""The 2.0 parse pipeline: pure stages folded over ParseState. Stage names and ParseState are NOT public API. The stage list is data; runners other than the plain fold (explain(), parse_all()) arrive in 2.x minors without changing any stage signature. Layering: imports only nameparser._pipeline.* (enforced by tests/v2/test_layering.py). """ from __future__ import annotations from collections.abc import Callable from nameparser._pipeline._assign import assign from nameparser._pipeline._classify import classify from nameparser._pipeline._extract import extract_delimited from nameparser._pipeline._group import group from nameparser._pipeline._post_rules import post_rules from nameparser._pipeline._script_segment import script_segment from nameparser._pipeline._segment import segment from nameparser._pipeline._state import ParseState from nameparser._pipeline._tokenize import tokenize #: The full eight-stage fold. STAGES: tuple[Callable[[ParseState], ParseState], ...] = ( extract_delimited, tokenize, segment, script_segment, classify, group, assign, post_rules, ) def run(state: ParseState) -> ParseState: """Fold the stages over the initial state. Pure: each stage returns a new ParseState; parse is total over str input, so no stage may raise on the *content* of a name (malformed policy/config is a separate, allowed failure mode).""" for stage in STAGES: state = stage(state) return state derek73-python-nameparser-456b024/nameparser/_pipeline/_assemble.py000066400000000000000000000223261527303271700253210ustar00rootroot00000000000000"""Not a stage: converts the final ParseState into a public ParsedName. Consumes: tokens (all roles set), dropped, ambiguities (by index; one carrying ``field_tail`` is worded from its word's final role, #626), and segments, for the part a withdrawn connective report's letter ends (rules/P8, #721). Produces: a validated ParsedName -- the constructor re-checks every invariant (span order/bounds, ambiguity subset), so a pipeline bug that would produce an invalid result dies HERE, not in a renderer three layers away. Structural tokens (dropped maiden markers) are omitted, like delimiter characters. A main-stream token that somehow reaches here with no role takes Role.GIVEN -- parse must never raise on the *content* of a name, so the fallback is deliberately boring rather than an exception. This should never fire in practice (assign/group must set a role on every main-stream token); it exists as a last-resort safety net against a pipeline bug, not as sanctioned behavior for real input -- see test_assemble_falls_back_to_given_for_unassigned_role in tests/v2/pipeline/test_assemble.py. """ from __future__ import annotations from nameparser._pipeline._state import ( FIELD_READING, NAME_ROLES, Beside, Deferred, ParseState, PendingAmbiguity, ) from nameparser._types import ( FOLDED_TAG, Ambiguity, AmbiguityKind, ParsedName, Reading, Role, Token, ) def _alternative(pending: PendingAmbiguity, materialized: tuple[Token, ...], final: dict[int, Token], middle_as_family: bool) -> Reading | None: """The public Reading of a report's PendingReading, its field named from the final roles where the emitter deferred it (O5's other primary field) and folded as `middle_as_family` folds every middle (rules/O6's neighbour, the policy's own fold).""" alt = pending.alternative if alt is None or not materialized: return None role = alt.role if role is Deferred.OTHER_PRIMARY: role = (Role.FAMILY if materialized[0].role is Role.GIVEN else Role.GIVEN) elif isinstance(role, Beside) and role.index in final: front = final[role.index] role = (role.unfolded if FOLDED_TAG in front.tags and role.unfolded is not None else front.role) elif isinstance(role, (Deferred, Beside)): # assign found no name position to name it from return None if middle_as_family and role is Role.MIDDLE: role = Role.FAMILY return Reading(alt.kind, role, alt.source) # rules/A2: "a name with no name content parses to the empty # name — every field empty, false as a boolean — while ambiguities # born from its punctuation survive on the empty result" # (history: decisions/A2) def assemble(state: ParseState) -> ParsedName: dropped = set(state.dropped) final: dict[int, Token] = {} for i, t in enumerate(state.tokens): if i in dropped: continue role = t.role if t.role is not None else Role.GIVEN final[i] = Token(t.text, t.span, role, t.tags) # 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 # honest "did I get a name?" check. isalnum() is Unicode-aware, so # every real name in any script has content and only pure # punctuation/symbols empty out. (Embedded junk in a name with # content -- 'John . Smith' -- is left alone: that parse is truthy, # so bool() is not misled.) # # This read "anywhere" until #329, and the two said the same thing # for as long as nothing could take a content-bearing token away. # Dropping a maiden marker inside a delimited clause can, and # "(née —)" under Policy(maiden_delimiters=...) is the input where # the readings part: the marker is the clause's only alnum token, # so once it goes structural the em dash is all that survives and # the whole parse empties -- where 1.4.0 and pre-#329 both gave # maiden 'née —'. Deliberate, not fallout: a dropped marker is # structural like a delimiter character, and brackets plus a marker # plus a dash name no one, exactly as "(-)" already named no one. # A guard keyed on what ELSE is in the clause would be a different # rule, and would leave maiden holding marker-plus-punctuation. # Pinned by cases.py's maiden_marker_delimited_content_free and, # for the other side of it -- the same clause inside a name, where # the em dash survives as the maiden value because Jane Smith # carries the content -- ..._content_free_in_a_name beside it. # # The TOKENS go, not the diagnostics: this drops the name, and # "was the input malformed?" is the one question still worth # answering about it -- most of all here, where there is no parse # left to infer it from. parse('(') keeps its unbalanced-delimiter # report, pointing at no token because no token survived. contentless = not any( c.isalnum() for t in final.values() for c in t.text) if contentless: final = {} ambiguities = [] segment_of: dict[int, tuple[int, ...]] | None = None for pending in state.ambiguities: materialized = tuple(final[i] for i in pending.indices if i in final) if pending.indices and not materialized and not contentless: # every referent was dropped: the ambiguity describes # nothing that survives assembly. Born-empty ambiguities # (unbalanced delimiters) are token-independent and kept. # Not so when the whole name was emptied just above -- the # referents did not lose a contest, they were discarded # wholesale, and the report still describes the input. continue # rules/A1: "a report names the reading the parse took, so # a report whose fork the rest of the parse then resolved to # NEITHER branch is withdrawn rather than carried beside a # reading it contradicts". classify's connective-or-initial # fork offers exactly two readings and its detail says which # it took ("it is read as an initial"); a generation and an # honorific are neither. So where the parse goes on to role # that letter SUFFIX or TITLE the report is false on its # face, and 'JOHN QUINCY SMITH I' carried it beside a # suffix-or-name saying the same token reads as a # generational suffix (#397 second review). 'i' is the first # word that is both a marked connective and suffix # vocabulary, so no parse before this cycle could reach the # shape. # # WITHDRAWN HERE and not emitted later, which is the narrow # reading of mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE # rather than an exception to it: "Emit at the site that # takes the branch, not where an ambiguous tag sits" governs # the EMISSION, classify still owns the fork, and what this # drops is a report whose subject the parse went on to read # as something else. Here is the one place that knows -- # roles are final and nothing downstream moves them -- and # the loop already withdraws a report whose referents did not # survive, just above. if (pending.kind is AmbiguityKind.CONJUNCTION_OR_INITIAL and any(t.role in (Role.SUFFIX, Role.TITLE) for t in materialized)): continue # rules/P8: "unless no name word of its part stands to its # right" -- nothing after the letter in its segment, or only # suffixes, titles, a maiden clause or a nickname: it connects # nothing, so the report goes (#721). Asked here for the reason # above: the roles are final. Where the letter is also a # numeral the fork read, its own report (rules/S10) is the one # left, so one token never carries both. Built only for a # connective report, so other names pay nothing. if pending.kind is AmbiguityKind.CONJUNCTION_OR_INITIAL: if segment_of is None: segment_of = {j: seg for seg in state.segments for j in seg} i = pending.indices[0] if not any(j > i and j in final and final[j].role in NAME_ROLES for j in segment_of.get(i, ())): continue detail = pending.detail if pending.field_tail is not None: # A report naming a field, in a name emptied above (no # emitter of one reaches that today: every referent is a # name word): no field is left to name, so it goes rather # than ship the sentence's first half. if not materialized: continue detail = (f"{detail}{FIELD_READING[materialized[0].role]}" f"{pending.field_tail}") ambiguities.append(Ambiguity( pending.kind, detail, materialized, _alternative(pending, materialized, final, state.policy.middle_as_family))) return ParsedName(original=state.original, tokens=tuple(final.values()), ambiguities=tuple(ambiguities)) derek73-python-nameparser-456b024/nameparser/_pipeline/_assign.py000066400000000000000000003271271527303271700250210ustar00rootroot00000000000000"""Stage: assign. Consumes: pieces + piece_tags (grouped), segments, structure, tokens, one_case, and tail_reads -- rules/S2's trailing run as group read it once, after P3's joins and before the particle chain (#614), which the 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). 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 in the Han/Hiragana/Katakana repertoire the #272 kana license shares across pieces); token/piece tags; Lexicon only through tags already applied by classify (plus the leading-title period rule), and Lexicon.given_name_titles, which an ambiguity's alternative asks for rules/P5's given-name-title licence. Implements rules H2, H4, H5, H6, H7, H8, N3, O4, O5, S10, W4, and P6 after a family comma, of docs/design/rules/, each cited at its code below. Ports v1's assignment loops. NO_COMMA (per name_order): leading title pieces chain while no given-position name has been seen (a title needs a following piece, unless the whole name is one title); then positional assignment per name_order with the trailing-suffix rule: the piece from which everything after is a strict suffix is the last name-position piece, the rest are suffixes. Behind that peel a trailing run of period-marked title words chains into the title from the end, leaving one name piece standing; where the run TAKES something the peel was only provisional and runs again over the pieces with the titled ones spliced out, the two alternating until the run takes nothing, so a trailing title is transparent to the suffix reading however many titles are written (_pieces.tail_reading). Where the run takes nothing -- almost every name -- the first peel is the only one and its answer stands. The v1 single-name+nickname rule lives here (decisions/N3): a nonempty nickname beside exactly one piece in total puts that piece in FAMILY. FAMILY_COMMA: segment 0 FAMILY but for a leading run of listed titles (rules/H6, #701; v1 kept them in the family) and the post-nominals behind its first family piece (SUFFIX, 'Smith Jr., John'). A part after the comma that holds no name word, or that a credential opens (#603), is not read here: group's head binds its SUFFIX and TITLE roles before any join (`_comma.decide`, #613), making the comma a suffix comma behind a whole name ('John Smith, Dr.', 'John Smith, PhD Jones') and leaving the family comma behind one name word ('Smith, Jr.', 'Smith, PhD Jones'), where segment 1 then reaches this stage with no pieces. A segment 1 that does reach it gets leading titles, then the same trailing title run over the pieces this segment does not read as suffixes ('Smith, John Prof.'), then given, then middles with those suffix-reading pieces to suffix -- one predicate for both -- and #602's run after the given word ('Smith, John PhD Jones'); segments 2+ are suffixes but for their title words (#603), lenient -- segment already flagged the ones that are neither COMMA_STRUCTURE. SUFFIX_COMMA: segment 0 as NO_COMMA; segment 1 arrives bound by group, and segments 2+ are read as after a family comma. Emits PARTICLE_OR_GIVEN when the leading name piece is a lone particles_ambiguous token with more pieces following ("Van Johnson", and since #367 "Dr. Van Johnson" too, a title no longer displacing the particle out of that position) -- whatever role name_order assigns -- and, since #613, at P6's attachment after a family comma where the run holds an ambiguous particle ("Beethoven, Ludwig van"). Emits SUFFIX_OR_NAME at these sites: the trailing roman numeral, taken or declined (since #718 for want of words to spare, since #752 for its Title case), a numeral ending the family part before a family comma, one of two or more letters ending a given part's walk, each ambiguous acronym the trailing peel had to resolve, the bare-suffix carve-out where an input that is nothing but post-nominal vocabulary gets its first word made into the name (H4's suffix half, #491), -- since #289 -- the FAMILY-COMMA path's own read of the first post-comma piece as the given name, -- since #531 -- the class member ENDING that path's given part, which the first-piece emitter could never reach, -- since #602 -- each name word the credential run absorbs, in the no-comma name and in the given part after a family comma, and -- since #613 -- P6's attachment after a family comma, where it declines a post-nominal reading or stands down inside a credential run. Further emitters of the same kind live in `_comma.py` (called at group's head: the comma's own decision, #544's anchored members and #603's absorbed words) and `_group.py`; they are not assign's and are not counted here. And at the one site that places a LONE name word, GIVEN_OR_FAMILY for the field the convention picked (O5, #449) and TITLE_OR_NAME for the two shapes where the doubt is whether a word is a title instead (H4 and H7, #491): the peel leaving one title-vocabulary word standing, and a joined unit carrying title vocabulary. """ from __future__ import annotations from collections.abc import Sequence, Set from typing import NamedTuple 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, 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, ) from nameparser._pipeline._pieces import ( split_credential_report, anchor_in_reach, credential_at_the_given_slot, given_slot_anchors, _NOT_A_RUN_START, has_name_content, is_lone_never_given_particle, Peel, TailRead, is_suffix_piece, is_title_piece, leading_titles, particle_tail, listed_lean, peel_walk, settled_by_case, starts_a_credential_run, tail_reading, trailing_titles, ) from nameparser._pipeline._state import ( AMBIGUOUS_ACRONYM_TAG, NAME_ROLES, TITLE_AMBIGUOUS_TAG, ParseState, Beside, Deferred, PendingAmbiguity, PendingReading, Structure, WorkToken, _AMBIGUOUS_CREDENTIAL_TAGS, _NEVER_FLIPPED, copy_with, ) from nameparser._policy import Policy, Script from nameparser._types import (FOLDED_TAG, SHAPE_ACRONYM_TAG, AmbiguityKind, ReadingKind, ReadingSource, Role) def _set_roles(tokens: list[WorkToken], piece: tuple[int, ...], role: Role) -> None: for i in piece: tokens[i] = copy_with(tokens[i], role=role) def _name_pieces(state: ParseState, tokens: Sequence[WorkToken], seg_idx: int) -> list[tuple[int, ...]]: """The pieces of segment `seg_idx` the parse read as name words.""" return [q for q in state.pieces[seg_idx] if tokens[q[0]].role in NAME_ROLES] def _slot_field(state: ParseState, seg_idx: int, pos: int, count: int, order: tuple[Role, Role, Role] | None) -> Role: """The field position `pos` of `count` name words takes in segment `seg_idx`, every other word read as the parse read it: the family before a family comma, the given part's given or middle name after one, and otherwise the order the main segment's read set.""" if state.structure is Structure.FAMILY_COMMA: return (Role.FAMILY if seg_idx == 0 else Role.GIVEN if pos == 0 else Role.MIDDLE) assert order is not None # the main segment's read sets it return _name_positions(order, count)[pos] def _name_alt(token: WorkToken, role: Role, front: Sequence[int] | None, tokens: Sequence[WorkToken], first: bool, licensed: bool) -> PendingReading: """The name reading of a word read as something else, landing in `role` unless the name piece `front` it stands behind would take it in, so that it lands where that piece lands: a particle chain runs to the end of the part's name words (rules/P2), and a lone particle stands unchained only for want of a word behind it, unless it opens the part, where it never chains (rules/P4); and a bound given name opening the part joins the word after it where it is `licensed`, a family comma having fixed the family or a given-name title standing ahead (rules/P5), since the word would otherwise be the family word the join reserves; and a never-given particle opening the part folds it into the family (rules/P1). The front's field is named at assemble, where P6 may since have attached it to the family, which it would not have done with a word behind it (`Beside`).""" alt = name_reading(token, role) if front is None: return alt tags = tokens[front[0]].tags # rules/P1's fold: a never-given particle opening the part takes the # word behind it into the family, whatever the order and the part if first and is_lone_never_given_particle(front, tokens): return copy_with(alt, role=Role.FAMILY) # a piece opening the part with a bound given name is P5's join, # not a chain, though `abu` is a particle too: the word stands apart bound = first and "vocab:bound-given" in tags if ("particle" in tags and (len(front) > 1 or not first) and not bound or licensed and bound and len(front) == 1): # a front P6 has already attached held `role`'s field before it return copy_with(alt, role=Beside( front[0], role if FOLDED_TAG in tags else tokens[front[0]].role)) return alt def _absorbed(piece: Sequence[int], tokens: Sequence[WorkToken], alt: PendingReading | None) -> PendingAmbiguity: """rules/S8's report for a name word #602's run absorbs, one spelling for the no-comma and the given-part run. `alt` is its name reading, whose field the caller's positional read knows (None where the caller fills it in once it does).""" text = " ".join(tokens[i].text for i in piece) return PendingAmbiguity( AmbiguityKind.SUFFIX_OR_NAME, f"{text!r} follows a credential, so it reads as part of the " f"suffix run; it may be a name word", tuple(piece), alternative=alt) #: Tags that say the word's own reading was claimed before position #: could speak, so O5's convention decided nothing. Built from M4's #: `_NEVER_FLIPPED` pair rather than respelling it: the two sets answer #: different questions -- may M4 retag the word, and did anything #: decide the field -- and share the pair because a word vocabulary #: claimed as a given name, or wrote as an initial, answers both. The #: third, `particle`, is here alone because a lone particle's reading #: is P4's. None is a predicate this emitter owns: they are read off #: the tags classify already recorded (mechanisms.md#TWO-LAYER-ASSIGN). _WORD_ALREADY_CLAIMED = _NEVER_FLIPPED | frozenset({"particle"}) # rules/H2: "an abbreviation opening the part of the name that # carries the given name — the whole name, or the part after a # family comma — reads as a title even when unlisted" -- the count is # _pieces.leading_titles since #424 (the chain's leading position is # read off the same run, _pieces.chain_lead, #624); the roles are set # here. def _peel_leading_titles(pieces: tuple[tuple[int, ...], ...], ptags: tuple[frozenset[str], ...], tokens: list[WorkToken], ambiguities: list[PendingAmbiguity] | None, lexicon: Lexicon) -> int: """Assign TITLE to the leading title pieces and return the first non-title index. `ambiguities` is None where a family comma has already marked the part as the given name, so no fork is left to report.""" n = leading_titles(pieces, ptags, tokens) for k in range(n): _set_roles(tokens, pieces[k], Role.TITLE) # rules/H9: "read as a name word where it is the first name word # after any titles" -- the peel stops at it because classify read it # out of the title, so it is the first piece the peel leaves # ('Dean Smith', 'Mr Dean Smith'); and rules/H9: "a word no listed # phrase holds is a title only behind a style or a modifier, or # before `of`", so standing alone it reports nothing ('Grace Smith') if (ambiguities is not None and n < len(pieces) and len(pieces[n]) == 1 and TITLE_AMBIGUOUS_TAG in tokens[pieces[n][0]].tags and "vocab:title" not in tokens[pieces[n][0]].tags and in_a_title_phrase(tokens[pieces[n][0]].text, lexicon)): i = pieces[n][0] ambiguities.append(PendingAmbiguity( AmbiguityKind.TITLE_OR_NAME, f"{tokens[i].text!r} is a title that may also be a given name, " f"and stands in no title phrase; read as ", (i,), field_tail=" rather than a title", alternative=TITLE_READING)) return n class EffectiveOrder(NamedTuple): """What _effective_order made of a name's scripts. `order` is the order the positional read uses; `by_script` says a script_orders entry RESOLVED it, which is not the same as `order` happening to equal the declared name_order -- under a declared family-first order a Han name's W4 entry and the declaration agree, and only this flag distinguishes the script rule DECIDING the reading from the caller's declaration standing unopposed. O5's convention report reads it (#449).""" order: tuple[Role, Role, Role] by_script: bool # rules/W4: "a name written wholly in one East Asian script, or in # the kana-licensed Japanese repertoire, reads family-first whatever # order the caller declared; a wholly-katakana name keeps the declared # order" (history: decisions/W4) def _effective_order(policy: Policy, pieces: Sequence[tuple[int, ...]], name_pieces: Sequence[int], tokens: list[WorkToken], *, dot_divided: bool) -> EffectiveOrder: """script_orders resolution (#271): when every name piece is written wholly in ONE script that has an entry, that script's order governs the positional read; anything else -- Latin, mixed scripts, no entry -- falls back to name_order. A 间隔号-divided name (`dot_divided`, #298) suppresses the whole lookup first: the dot marks a transcription -- playing the role pure katakana plays in the kana license, orthography naming the convention -- so the license yields to name_order. Piece-level, after title/suffix peeling: 'Dr. 毛泽东' is a wholly-Han NAME under a Latin title. Kana-licensed tokens (高橋みなみ, #272) resolve to HIRAGANA the same way a wholly-Han or wholly-Hangul token resolves to its own script -- and so does a kana-licensed NAME split across separately single-script PIECES ('高橋 みなみ', Han piece plus Hiragana piece): resolve_script_set generalizes the license from one token's characters to the whole found-script set below, which is why Han+Hangul ('毛 김') still declines even though both individually read family-first -- the license is specific to the Han/Hiragana/Katakana repertoire, not "the entries happen to agree". Naming note, since the two are easy to conflate: THIS function resolves the ORDER for a whole name; `_vocab.effective_script` resolves the SCRIPT for a single token. This function calls that one per token below. Takes the segment's pieces and WHICH of them the name kept, the shape every piece-layer predicate takes: a caller holding those indices had to build a second list of the same pieces to hand over otherwise, which on 3.11 is a comprehension frame on every parse (decisions/parse-cost). Returns an EffectiveOrder: the order triple, and `by_script` set only on the one path where an entry answered. Every fallback below is a script rule DECLINING, and reports it as such. """ declared = EffectiveOrder(policy.name_order, by_script=False) # #298 transcription marker -- see the docstring; codepoint-scoped # (only U+00B7 records; decisions/T3) if dot_divided: return declared if not policy.script_orders: return declared # Collect every token's script rather than comparing pairwise as # tokens are seen: the kana license needs the WHOLE set (a Han # piece and a Hiragana piece only license together, never one at a # time), so resolution is deferred to resolve_script_set below. found: set[Script] = set() for piece_idx in name_pieces: for i in pieces[piece_idx]: script = effective_script(tokens[i].text) if script is None: # Latin, mixed, or a script with no entry: never a key return declared found.add(script) resolved = resolve_script_set(found) if resolved is None: # e.g. Han+Hangul: two scripts, neither the kana license's # Han/Hiragana/Katakana repertoire -- no single tradition return declared for script, order in policy.script_orders: if script is resolved: return EffectiveOrder(order, by_script=True) # the resolved script has no entry: the declaration stands, and # nothing about the writing system decided the reading return declared # rules/O4: "words no vocabulary has claimed read by position. In # the default given-first order the first name word is the given name, # the last is the family name, and everything between is middle names" def _name_positions(order: tuple[Role, Role, Role], count: int) -> list[Role]: """Roles for `count` name pieces (titles/suffixes already peeled), per name_order. GIVEN_FIRST: given, middles..., family. FAMILY_FIRST: family, given, middles... FAMILY_FIRST_GIVEN_LAST: family, middles..., given. One piece takes order[0]'s role; two pieces take order[0] and the other primary.""" first, second = order[0], order[1] # rules/O5: "a name of one name word that nothing else has # decided reads that word as the given name under the default # given-first order, and as the family name under a declared # family-first one" -- a convention, not a determination: O4 has # no positions to compare at one word, so this line is where the # library picks one of two equally consistent readings and picks # it the same way every time. The rules that DO decide such a # name (H1, N3, M4) run after this and retag. if count == 1: return [first] if first is Role.GIVEN: # GIVEN_FIRST return ([Role.GIVEN] + [Role.MIDDLE] * (count - 2) + [Role.FAMILY]) if second is Role.GIVEN: # FAMILY_FIRST return ([Role.FAMILY, Role.GIVEN] + [Role.MIDDLE] * (count - 2)) return ([Role.FAMILY] + [Role.MIDDLE] * (count - 2) # F_F_GIVEN_LAST + [Role.GIVEN]) def _placed(read: TailRead, rest: list[int], pieces: tuple[tuple[int, ...], ...], ) -> tuple[list[int], tuple[int, ...], Peel]: """`tail_reading`'s three answers, taken off the run group read at its head (#614) rather than read again over the joined pieces: the walk with the name pieces first and the run's suffixes after them, the pieces the H5 chain took, and the read's peel with `names` and `run_titles` renumbered onto these pieces. Group split the run off before its joins and put it back after them unchanged, so each of its pieces is whole here and a membership test on its first token places it.""" names: list[int] = [] suffixes: list[int] = [] titled: list[int] = [] run_titles: list[int] = [] for k in rest: first = pieces[k][0] if first not in read.tail: names.append(k) elif first in read.titles: titled.append(k) else: suffixes.append(k) if first in read.run_titles: run_titles.append(k) peel = read.peel count = len(names) names.extend(suffixes) # built, not `_replace`d: that is two frames on every read return names, tuple(titled), Peel( count, peel.numerals, peel.picks, peel.anchors, tuple(run_titles), peel.absorbed) def _assign_main(seg_idx: int, state: ParseState, tokens: list[WorkToken], ambiguities: list[PendingAmbiguity], ) -> tuple[Role, Role, Role] | None: """Returns the order the positional read used, for ParseState.order -- None on every path that returns before resolving one.""" pieces = state.pieces[seg_idx] ptags = state.piece_tags[seg_idx] n = _peel_leading_titles(pieces, ptags, tokens, ambiguities, state.lexicon) if n == len(pieces): return None # group-flagged suffix pieces (the ph-d merge) are suffixes at ANY # position -- v1's fix_phd extracted the credential from the string # before parsing, so position never mattered (PR review I3). # Walked rather than collected first: the list was never read # again, and on 3.11 a comprehension is a frame of its own, which # is one frame back against the one the H5 reading below costs. A # 3.11 FACT and not a portable one: PEP 709 inlines comprehensions # from 3.12, where this tree's reference parse costs 395 written # either way (measured 2026-09-09, against 416 and 417 on 3.11). # 3.11 is the interpreter the band is quoted for, so 3.11 is what # the shape is chosen on (decisions/parse-cost). for k in range(n, len(pieces)): if "suffix" in ptags[k]: _set_roles(tokens, pieces[k], Role.SUFFIX) rest = peel_walk(n, ptags) if not rest: return None # rules/N3: "a name that is only a nickname and one name word # reads that word as the family name" (history: decisions/N3) # -- v1's p_len == 1 counted # the WHOLE segment before any title peeling -- 'Xyz. (Bud) Smith' # has two pieces, so the title peel wins and this rule declines; # H1 then names Smith the family (rules/N3's title bullet) # The nickname scan sits LAST in the test: it is a generator, which # every interpreter resumes once per token (seven call events on # the reference name), and only a one-piece segment ever reads its # answer. Hoisting it to the top of the read, where it once stood, # is what put 3.12-3.15 one call over the band (decisions/parse-cost). if (len(pieces) == 1 and len(rest) == 1 and any(t.role is Role.NICKNAME for t in tokens)): _set_roles(tokens, pieces[rest[0]], Role.FAMILY) return None # rules/S2's trailing peel and rules/H5's title chain, read # together to their fixed point by _pieces.tail_reading -- one # function since the /simplify round, shared with the bound-given # reserve (P5), which must count the name words this leaves. # # rules/H5: "successive single words that wear the abbreviation # shape and are title vocabulary chain into the title from the end, # leaving one name word standing" -- the titles are set BEFORE # _name_positions, so the shortened list is what the positional # read and the script test both see (a trailing Latin title must # not make a wholly-CJK name look mixed-script, the same reason the # leading peel runs first). Read ahead of the bare-suffix carve-out # because with no name piece left there is nothing for the chain to # read: its floor keeps 0 pieces of an empty list, so the branch # below is reached exactly as before. # # Every bare ambiguous acronym the FINAL peel had to resolve is one # coin-flip each, in either direction, so the report collects # rather than overwrites. Deferred to after assignment because what # a pick is declined as depends on the role it took, and which role # "not peeled" means depends on name_order; the field itself is # worded by assemble (#626). (The roman-numeral fork's TAKEN branch # needs no such deferral and is reported here; its declined branch # is a pick, #718, and is reported with the others.) # # Read once in group, after P3's connective joins and ahead of the # particle chain, on a main segment of three or more pieces whose # run is one block at its end (#614): the run is placed off that # reading. Where the read left name pieces and # group's joins then left none past the leading titles -- a join # that made the name a title ('Freiherr von Berg Dr. and Ed. # Prof.') -- the reading is taken here over what stands, as it was # taken everywhere before #614, so the name keeps a word. The same # procedure and not always the same answer: the run group read # split off was kept out of group's joins, so what stands differs # from what stood before #614 (that name read family 'von Berg Dr. # and Ed. Prof.' then). read = (state.tail_reads[seg_idx] if seg_idx < len(state.tail_reads) else None) if read is not None: placed = _placed(read, rest, pieces) if read.peel.names and not placed[2].names: read = None else: rest, titled_tail, peeled = placed if read is None: rest, titled_tail, peeled = tail_reading(rest, pieces, ptags, tokens, state.one_case) for piece_idx in titled_tail: _set_roles(tokens, pieces[piece_idx], Role.TITLE) # rules/S8: "except a title word, which reads as a title" -- the # credential run (#602) is placed here, so a name word it absorbed # is reported here too for piece_idx in peeled.run_titles: _set_roles(tokens, pieces[piece_idx], Role.TITLE) # the name reading of a word the run or the numeral fork took lands # where one more name word after the name's would: its field is # filled in below, once the positional read has an order trailing_alts = len(ambiguities) for piece in peeled.absorbed: ambiguities.append(_absorbed(piece, tokens, None)) for numeral in peeled.numerals: # a single letter is a name part unless it happens to be a # roman numeral -- and V/X/I are ordinary middle initials, so # taking it as a suffix is a call, not a fact; a numeral read # by its shape alone is in no wordlist, and may be a name word text = tokens[numeral[0]].text ambiguities.append(PendingAmbiguity( AmbiguityKind.SUFFIX_OR_NAME, f"{text!r} is a roman numeral, so it reads as a " f"generational suffix; " + ("any other single letter there would be a middle initial" if len(text.rstrip(".")) == 1 else "it may be a name word"), numeral)) name_pieces, suffix_pieces = rest[:peeled.names], rest[peeled.names:] if peeled.run_titles: suffix_pieces = [q for q in suffix_pieces if q not in peeled.run_titles] if peeled.names == 0: # everything suffix-shaped after titles: first one is the name name_pieces, suffix_pieces = suffix_pieces[:1], suffix_pieces[1:] # AFTER the whole tail reading, and load-bearing: the script test # sees the NAME pieces only, so a Latin title or suffix ('Dr. 毛 # 泽东', '毛 泽东, PhD') cannot make a wholly-CJK name look # mixed-script. resolved = _effective_order(state.policy, pieces, name_pieces, tokens, dot_divided=bool(state.interpunct_offsets)) order = resolved.order roles = _name_positions(order, len(name_pieces)) for pos, piece_idx in enumerate(name_pieces): _set_roles(tokens, pieces[piece_idx], roles[pos]) for piece_idx in suffix_pieces: _set_roles(tokens, pieces[piece_idx], Role.SUFFIX) def as_a_name(i: int) -> PendingReading: # every other word read as the parse reads it -- the carve-out's # name word too -- the name word the reading declines would be # the last of one more than the name's; asked per report, so a # name that makes no report here pays nothing front = (pieces[name_pieces[-1]] if name_pieces and i not in pieces[name_pieces[-1]] else None) # rules/P5's other licence, a given-name title ahead, asked of # the leading run through the predicate group's join asks, so # the two cannot disagree about what one run asserts; asked # only where the bound branch could take it, so a report per # word of a long run never walks the name (the scaling test) licensed = (front is not None and len(name_pieces) == 1 and "vocab:bound-given" in tokens[front[0]].tags and _run_addresses_by_given( (tokens[j].text for k in range(name_pieces[0]) for j in pieces[k]), state.lexicon.given_name_titles)) return _name_alt(tokens[i], _slot_field(state, seg_idx, len(name_pieces), len(name_pieces) + 1, order), front, tokens, len(name_pieces) == 1, licensed) for r in range(trailing_alts, len(ambiguities)): report = ambiguities[r] ambiguities[r] = copy_with( report, alternative=as_a_name(report.indices[0])) # Both emitters below turn on the PEEL's count, so neither can # reach a name that kept two name pieces, and the two scans are # nested under the count rather than run beside it: they cost the # call budget on every parse otherwise (decisions/parse-cost). if peeled.names <= 1: head = pieces[name_pieces[0]] # Both conventions here turn on a lone name word, so both # report at the site that places one # (mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE), and one # predicate carries what says nothing else decided the FIELD: # a title (segment 0's own peel, or one a family comma left in # the next segment -- either is a Role.TITLE by now, and # either makes the reading H1's), a maiden name (M4's), or a # script order convention. `resolved.by_script` rather than a # comparison against name_order: a declared family-first order # agreeing with a Han name's entry is agreement, not # authorship, and for a lone CJK honorific ('さん', '씨') it # scopes W2's reading out rather than excusing it (#271/#308). titled = any(t.role is Role.TITLE for t in tokens) field_undecided = ( not resolved.by_script and not titled and not any(t.role is Role.MAIDEN for t in tokens)) # rules/H4: "an input whose every word is post-nominal # vocabulary reads its first word as a name and reports # `suffix-or-name`" (history: decisions/H4) -- only the # word made into a name reports, and no name piece survived # the peel, so the carve-out above made the first post-nominal # the name. Its field is worded by assemble from the final role # (`PendingAmbiguity.field_tail`, #626). if peeled.names == 0 and field_undecided: text = " ".join(tokens[i].text for i in head) ambiguities.append(PendingAmbiguity( AmbiguityKind.SUFFIX_OR_NAME, f"{text!r} is post-nominal vocabulary with no name word " f"beside it; read as ", tuple(head), field_tail=" rather than a post-nominal, nothing else " "being left to be the name", alternative=suffix_reading(tokens[head[0]]))) # One name piece off the peel: the convention placed a lone # name word. A suffix beside it is not a decision -- 'Smith # Jr.' and "'Smitty' Jones Jr." ARE this convention -- and the # count comes off the peel rather than off name_pieces, which # is what keeps the carve-out above out of these branches. # Title vocabulary in the unit makes the doubt H4's or H7's (is # the WORD a title), anything else O5's (which FIELD it took), # so only the second reads `field_undecided`. if peeled.names == 1 and not resolved.by_script: # rules/H4: "an input whose only remaining name word # after the title peel is itself title vocabulary reads # that word as the name by convention and reports # `title-or-name`" (history: decisions/H4), and # rules/H7: "one name UNIT of more than one word carrying # title vocabulary reports `title-or-name`" (history: # decisions/H7, decisions/H4 #518) -- one branch, its detail # naming a field only in H7's shape ('John of Bishop', # 'Attorney General of Minnesota'), where the unit is more # than the title word. # A LONE title word never reaches here ('Dr.', 'Prince of # Wales'): the leading-title peel took the whole name. if any("vocab:title" in tokens[i].tags for i in head): text = " ".join(tokens[i].text for i in head) lone = len(head) == 1 ambiguities.append(PendingAmbiguity( AmbiguityKind.TITLE_OR_NAME, f"{text!r} is title vocabulary and the only name word " f"the title peel left standing; read as the name by " f"convention rather than as more title" if lone else f"{text!r} is the only name unit and joins title " f"vocabulary to a name word; read as ", tuple(head), field_tail=None if lone else " by convention", alternative=TITLE_READING)) # rules/O5: "a name of one name word that nothing else has # decided reads that word as the given name under the default # given-first order, and as the family name under a declared # family-first one" (history: decisions/O5). Past # `field_undecided`, two clauses of O5's own: the word's # own reading may have claimed it, and A2's content test # -- a piece with no alphanumeric character is no name # word, so parse("(") keeps its unbalanced-delimiter # report and gains nothing here. elif (field_undecided and all(tokens[i].tags.isdisjoint(_WORD_ALREADY_CLAIMED) for i in head) and any(c.isalnum() for i in head for c in tokens[i].text)): text = " ".join(tokens[i].text for i in head) ambiguities.append(PendingAmbiguity( AmbiguityKind.GIVEN_OR_FAMILY, f"{text!r} is the only name word and nothing else " f"decides it; read as ", tuple(head), field_tail=" by convention, which follows the read " "order", # the other primary field, named from the final # role by assemble alternative=PendingReading(ReadingKind.NAME, ReadingSource.POSITION, Deferred.OTHER_PRIMARY))) # rules/S10: "a one-letter numeral before a credential is the # family name, and the reading is reported" -- the part's last name # word with nothing but the trailing run behind it (#721, Derek: # 'John Smith V PhD' keeps family 'V' and reports, as 'john i, phd' # does). Decided here, where the trailing read has said what that # run is # `_ROMAN` first, C-level, so an ordinary name word pays no frame if (len(name_pieces) >= 2 and len(last := pieces[name_pieces[-1]]) == 1 and _ROMAN.match((word := tokens[last[0]].text).rstrip(".")) is not None # the trailing run, or the Ph./D. pair, which the walk # leaves out, behind it and (suffix_pieces or titled_tail or name_pieces[-1] + 1 < len(pieces) and "suffix" in ptags[name_pieces[-1] + 1]) and tuple(last) not in peeled.picks and reads_as_a_reported_numeral( word, tokens[last[0]].tags, tokens[pieces[name_pieces[-2]][0]].text, state.one_case)): ambiguities.append(PendingAmbiguity( AmbiguityKind.SUFFIX_OR_NAME, f"{word!r} is a roman numeral, but a post-nominal or title " f"follows it; read as ", tuple(last), field_tail=" rather than a generational suffix", alternative=suffix_reading(tokens[last[0]]))) for piece in peeled.picks: # every pick is in rest, so the loops above just gave it a # role; whether it was read as a suffix or a name settles what # it was declined as, and which field assemble names (#626) token = tokens[piece[0]] assert token.role is not None # rules/S10: "either reading is reported" -- the numeral fork's # declined pick (#718), which carries neither credential tag # (the acronym fork's picks carry one) and is never a suffix if token.tags.isdisjoint(_AMBIGUOUS_CREDENTIAL_TAGS): # a pick a credential run in front then took is the run's # absorbed name word (rules/S8); otherwise it is declined # for want of words to spare (one letter) or for its Title # case (two or more, rules/S10's lean) if token.role is Role.SUFFIX: ambiguities.append(_absorbed(piece, tokens, as_a_name(piece[0]))) continue why = ("is a roman numeral, but the name has no other word " "to spare for it" if len(token.text.rstrip(".")) == 1 else "is a roman numeral written in Title case, which " "leans to the name") ambiguities.append(PendingAmbiguity( AmbiguityKind.SUFFIX_OR_NAME, f"{token.text!r} {why}; read as ", piece, field_tail=" rather than a generational suffix", alternative=suffix_reading(token))) continue declined = ("a name part" if token.role is Role.SUFFIX else "a post-nominal") # rules/S9: "An unlisted word joins the ambiguous class (S4) by # SHAPE" (and rules/S3); such a word is in no # wordlist and may be written with its periods, so the listed # member's wording would misdescribe it on both counts (#563) what = ("is shaped like a post-nominal but listed in no " "vocabulary, so it may be an ordinary name" if SHAPE_ACRONYM_TAG in token.tags else "written without periods is both a post-nominal " "and an ordinary name") ambiguities.append(PendingAmbiguity( AmbiguityKind.SUFFIX_OR_NAME, f"{token.text!r} {what}; read as ", piece, field_tail=f" rather than {declined}", alternative=(as_a_name(piece[0]) if token.role is Role.SUFFIX else suffix_reading(token)))) # leading ambiguous particle read as a name (#121 surfaced) if name_pieces: head = pieces[name_pieces[0]] if (len(head) == 1 and len(name_pieces) > 1 and "vocab:particle-ambiguous" in tokens[head[0]].tags): # the field is assemble's to word from the final role # (`PendingAmbiguity.field_tail`, #626), never assumed given: # `order` above is _effective_order's answer, not # necessarily name_order's, and a later rule may still move # the word ('Van Ivan Petrovich' under the East Slavic rule # rotates 'Van' into the family) ambiguities.append(PendingAmbiguity( 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)) return order def _family_part_numeral(word: WorkToken, preceding: str, names: int, one_case: bool | None) -> Role | None: """The role of a roman numeral ending the family part before a family comma, behind `names` pieces of that part, as the comma-less name reads one ending it (rules/S10), or None where the numeral's predicate reads no numeral there (an initial in front, a letter with its period): the suffix where it reads as the generation, a one-letter numeral needing two name words in front of it, and the family name where that count fails or its Title case leans to the name.""" if reads_as_generation(word.text, word.tags, preceding, one_case): if (names < 2 and len(word.text) == 1 and not numeral_settled_by_case(word.text, one_case)): return Role.FAMILY return Role.SUFFIX if numeral_leans_to_the_name(word.text, word.tags, preceding, one_case): return Role.FAMILY return None def _reads_as_a_trailing_suffix(piece: Sequence[int], prev_piece: Sequence[int], prev_ptags: Set[str], tokens: Sequence[WorkToken], lexicon: Lexicon, one_case: bool | None) -> bool: """The lenient tail test for a trailing one-token piece after a family comma, and #432's carve-out from it. A word that could be a middle initial, written with the period that marks an abbreviation, is name material -- so 'Smith, John V.' is middle 'V.' where 'Smith, John V' stays suffix 'V' (v1 parity, #144). Only behind a NAME word: behind a suffix the credential run owns it, and 'Smith, John PhD I.' keeps suffix 'PhD, I.'. The period is the whole carve-out. is_initial_shaped would be redundant beside it: the caller only consults this where is_suffix_piece said no, and a lenient single token it refuses is one carrying the `initial` tag, so the shape is already implied. reads_as_generation is asked with no word in front (`preceding` None): its initial-in-front refusal is a no-comma rule and the opposite of this path's v1 parity -- 'Chang, Andy C I' is first Andy, middle C, suffix I, and asking it with the word in front made the numeral a middle. The #401/#421 entry under decisions/P5 records the same fork declining to transfer to this walk under LENIENT. """ # rules/C9: "behind a name word it is the generation only when # written bare, a period marking it the abbreviation of a name and # so a middle initial" tok = tokens[piece[0]] text = tok.text if text.endswith(".") and not is_suffix_piece( prev_piece, prev_ptags, tokens): return False # the numeral's predicate, which in this slot reads a bare # one-letter numeral by the vocabulary ('Smith, John X' keeps # middle 'X'); `_ROMAN` first, C-level if len(text) == 1 and _ROMAN.match(text) is not None: return reads_as_generation(text, tok.tags, None, one_case) return is_suffix_lenient(text, lexicon) def _inside_a_credential_run(seg: Sequence[Sequence[int]], tokens: list[WorkToken], given_at: int, k: int, end: int, ambiguities: list[PendingAmbiguity]) -> bool: """rules/P9 (P6's third exception) for the run `seg[k:end]`: a run assign read as post-nominals, with a credential read in front of it and another behind, stands INSIDE the credential run assign read whole rather than ending the name, so it keeps that reading -- 'DOE, JANE PHD VD MA' reads suffix 'PHD VD MA' where the attachment had pulled VD out of the middle of it (#573). Behind as well as in front: with nothing behind it the word ends the name, and 'Doe, Jane PhD vd' keeps family 'vd Doe'. A title between it and the post-nominal in front is transparent, as it is to every trailing reading (H5): 'DOE, JANE PHD PROF. VD MA' reads as 'DOE, JANE PHD VD MA' does. The run is never empty: the caller asks only where `k < end`, and over an empty range the test below would pass vacuously and report a fork on the word behind (#613's review, ', Jane PhD do MA'). (Behind it no title is skipped: a title there leaves the word a name in assign, so nothing reaches here.) The test is the ROLES assign gave, not S7's company query: the question is whether assign read the run whole, and a member in front that the writing or a count made the credential ('DOE, JANE MA VD PHD') says so as plainly as a degree does. So does a generation, in front or behind ('Berg, Jan PhD vd Jr.'): the tussenvoegsel P6 is about stands right behind the given name, and a post-nominal between them says this word is not one. The run's own role is what keeps a plain particle out ('Doe, Jane PhD de PhD', whose 'de' is a name word, not a post-nominal), and the given name in front is what keeps 'Doe, Jane vd PhD' attaching. Reported as S4's credential fork, at the site that declines the attachment.""" def suffix_read(q: int) -> bool: return all(tokens[i].role is Role.SUFFIX for i in seg[q]) def is_title(q: int) -> bool: return all(tokens[i].role is Role.TITLE for i in seg[q]) if not all(suffix_read(q) for q in range(k, end)): return False front = k - 1 while front > given_at and is_title(front): front -= 1 if (front <= given_at or end == len(seg) or not suffix_read(front) or not suffix_read(end)): return False run = seg[k] text = " ".join(tokens[i].text for i in run) ambiguities.append(PendingAmbiguity( AmbiguityKind.SUFFIX_OR_NAME, 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)) return True def _attach_particle_tail(pieces: Sequence[Sequence[int]], tokens: list[WorkToken], given_at: int, k: int, end: int, ambiguities: list[PendingAmbiguity]) -> None: """rules/P6's attachment after a family comma: the particle run `pieces[k:end]` ending the given part joins the family the comma named and is written before it -- the Dutch alphabetized listing, "Beethoven, Ludwig van" being how "Ludwig van Beethoven" is filed. The caller has found the run (`particle_tail`) and checked that a given word stands ahead of it and that P1's fold will not take the part; this declines only where the run stands inside a credential run the walk read whole (`_inside_a_credential_run`, the third exception). Keyed on the tokens' VOCABULARY, not their roles, which is what gives the attachment its stated precedence over S2: `vd`, `mc` and `do` are the three words in both vocabularies; the walk reads a trailing `vd` or `mc` as a post-nominal, so those two need the override, and `do` is in the AMBIGUOUS acronym half, which already leaves it a name word, so it attaches by the plain rule. After a family comma the tussenvoegsel is the commoner reading. The words-to-spare guard is a piece test, not a count: every trailing piece that is wholly particles attaches, and the run must leave a GIVEN word ahead of it, so "Nguyen, Van" keeps its only given word. Only the DEGENERATE Vietnamese listing is protected by that -- "Nguyen, Thi Van" has a given word to spare, so `Van` attaches and the given name is lost. rules/P6 records why that is accepted. mechanisms.md#FOLDED_TAG does the rest: tokens never move, so the family view reads the tag and renders these before the base. Decided in assign since #613, in the pass that read the given part; until then post_rules took it a stage later, and assign held the tail back from #602's run by predicting it.""" if _inside_a_credential_run(pieces, tokens, given_at, k, end, ambiguities): return run = [i for piece in pieces[k:end] for i in piece] # mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE: "Emit at # the site that takes the branch, not where an ambiguous # tag sits" -- this attachment is where the fork is # decided, so the report is raised here, at the attachment, # rather than by the given-part walk's own emitters, which # read the run before the attachment decides it (#405). # # Two arms, keyed on what the attachment OVERRODE rather # than on what the words are, so each names a branch the # parse actually weighed. `declined_suffix` reads the role # the walk gave the run, which is why both lists are built # BEFORE the re-roling loop below overwrites it. # # ORDERED, not asserted disjoint. The two cannot both hold # under the shipped vocabulary -- `vd` and `mc` are the # only words in both the particle and the unambiguous # suffix vocabularies, and neither is ambiguous particle # vocabulary -- but a caller's Lexicon may put one word in # both, and rules/A1 says parsing never fails on any # input, so this decides instead of raising. (Measured: an # assert here DID fire on `Berg, Jan zz` under a Lexicon # adding `zz` to particles_ambiguous and suffix_acronyms.) # The suffix arm wins because it names the reading the # parse actually took: assign had read the word as a # post-nominal, so the name-word reading was never on the # table for the attachment to decline. # # Both details name the ATTACHMENT and stop there. What # P6 decides is that the run joins the family the comma # named instead of standing on its own; how the joined # words then READ is R2's call, taken by the UNJOINED_TAG # loop at the end of post_rules. The two can disagree: # `de la, Jan van` attaches `van` and leaves an # all-particle family, which R2 marks, so every other view # -- `family_base`, the initials, case repair -- reads # those words as ordinary name words ('Jan Van De La' # forced). A detail promising "read as the family's # particle" would contradict all three. ambiguous = [i for i in run if "vocab:particle-ambiguous" in tokens[i].tags] declined_suffix = [i for i in run if tokens[i].role is Role.SUFFIX] text = " ".join(tokens[i].text for i in run) if declined_suffix: ambiguities.append(PendingAmbiguity( AmbiguityKind.SUFFIX_OR_NAME, f"{text!r} written without periods is both a " f"post-nominal and a family-name particle; after a " f"family comma it joins the family the comma named " f"rather than standing as a post-nominal", tuple(run), alternative=suffix_reading(tokens[run[0]]))) elif ambiguous: word = tokens[ambiguous[0]].text walked = tokens[run[0]].role assert walked is not None # the given part's walk roled the run ambiguities.append(PendingAmbiguity( AmbiguityKind.PARTICLE_OR_GIVEN, f"{word!r} is both a family-name particle and an " f"ordinary given name; after a family comma " f"{text!r} joins the family the comma named " f"rather than standing as a name word of its own", tuple(run), # where the given part's walk placed it, before this moves # it: a name word, or a title the walk read off its period # ('Doe, Jane nee Smith St.'); a name word is taken in by # the piece in front where that piece would take it alternative=(TITLE_READING if tokens[run[0]].role is Role.TITLE else _name_alt( tokens[ambiguous[0]], walked, pieces[k - 1] if k - 1 >= given_at and tokens[pieces[k - 1][0]].role in NAME_ROLES else None, tokens, k - 1 == given_at, True)))) for i in run: tokens[i] = copy_with( tokens[i], role=Role.FAMILY, tags=tokens[i].tags | {FOLDED_TAG}) def _split_name_alt(state: ParseState, tokens: Sequence[WorkToken], seg_idx: int, piece: Sequence[int], order: tuple[Role, Role, Role] | None) -> PendingReading: """The name reading of a split credential assign placed, every other word read as the parse read it: the family before a family comma, the given part's given or middle name after one, and otherwise the last of the part's name words -- or wherever the name piece in front of it would take it in (`_name_alt`).""" names = [q for q in _name_pieces(state, tokens, seg_idx) if q[0] < piece[0]] role = _slot_field(state, seg_idx, len(names), len(names) + 1, order) return _name_alt(tokens[piece[0]], role, names[-1] if names else None, tokens, len(names) == 1, state.structure is Structure.FAMILY_COMMA) def _resolve_deferred(state: ParseState, tokens: Sequence[WorkToken], ambiguities: list[PendingAmbiguity], order: tuple[Role, Role, Role] | None) -> None: """Name the field of each alternative classify or group deferred, from the positions just read: a connective's join takes in the name word on each side, so where those stand in two pieces the joined unit takes the place of the first, and where both are inside the letter's own piece, or the join would leave the name one word, the letter keeps its own field (named at assemble); a chained particle stands as a name word of its own in front of its piece, one more unit. Every other word keeps its reading, so the field is the one the positional read gives that place: the family before a family comma, the given part's given or middle name after one, and the declared order's otherwise. A referent outside every name position is left deferred, and surfaces as no alternative.""" for r, report in enumerate(ambiguities): alt = report.alternative if (alt is None or alt.role not in (Deferred.JOINED, Deferred.UNCHAINED) or not report.indices): continue i = report.indices[0] for seg_idx in range(len(state.pieces)): names = _name_pieces(state, tokens, seg_idx) at = next((k for k, q in enumerate(names) if i in q), None) if at is not None: break else: continue if alt.role is Deferred.UNCHAINED: pos, count = at, len(names) + 1 else: # the join takes in the name word on each side: a word of # the connective's own piece, or the piece beside it. One # inside its piece joins nothing the piece did not hold, so # it lands where its piece lands; one opening or ending the # name has no word to join on that side, and no field piece = names[at] k = piece.index(i) first = at - 1 if k == 0 else at last = at + 1 if k == len(piece) - 1 else at if first < 0 or last >= len(names): continue pos, count = first, len(names) - (last - first) # rules/P3: "a single-letter connective in a three-word name" # stays a name word, so a join that would leave the name one # word is no join, and the letter keeps its own field; a # family comma's surname is a word of the name the given # part's count does not see if first == last or ( count == 1 and state.structure is not Structure.FAMILY_COMMA): ambiguities[r] = copy_with(report, alternative=copy_with( alt, role=Beside(i))) continue ambiguities[r] = copy_with(report, alternative=copy_with( alt, role=_slot_field(state, seg_idx, pos, count, order))) def assign(state: ParseState) -> ParseState: tokens = list(state.tokens) ambiguities = list(state.ambiguities) if not state.segments: return state order: tuple[Role, Role, Role] | None = None if state.structure is Structure.NO_COMMA: order = _assign_main(0, state, tokens, ambiguities) tail = len(state.segments) elif state.structure is Structure.SUFFIX_COMMA: order = _assign_main(0, state, tokens, ambiguities) tail = 1 else: # FAMILY_COMMA # PARTICLE_OR_GIVEN is deliberately not emitted on the # wholly-family read: after a comma that fixed the family, a # leading given-position particle is not meaningfully # ambiguous, and script_orders is not consulted for the parallel # reason. Scoped, not silent: the comma fixed WHICH PIECE is the # family and said nothing about a particle trailing the given # name, so P6's attachment after the given-part walk below # decides that fork and reports it there, at the site that # takes the branch (#405). Behind a whole name, a comma followed # by no name word is not this path since #613: `_comma.decide` # makes it the suffix comma, read positionally by the branch # above, which emits and consults both as the comma-less name # does -- and group's chain emitter with them, the comma being # decided before the chain runs. Behind one name word it is # this path, with segment 1 already bound ('Smith, Jr.'). # v1: "lastname part may have suffixes in it" -- the first # piece is always the family even if suffix-shaped; any later # strict-suffix piece goes to SUFFIX per piece ('Smith Jr., # John' -> family=Smith, suffix=Jr.) fam_pieces = state.pieces[0] fam_tags = state.piece_tags[0] # The part after the comma reaches here as the given part, or # bound and with no pieces left: group's `_comma.decide` binds a # postnominal one before any join (#613, rules/C7), so this # path is the listing form's, and the positional read #296 gave # a comma followed by no name word behind two name words is # decide's suffix comma now, read by the NO_COMMA branch above. # rules/C11's report, scoped to the ambiguous credential # class: this is the given-name half of the comma's OWN # decision (listing or credential run), where the writing left # the fork open. `_comma.decide` reports the credential half, # where it binds the part, and P6's attachment fork reports # from `_attach_particle_tail` above ("Berg, Jan vd") -- a # different fork. Each half is reported where its branch is # taken, so this DECISION is never reported twice; a second ambiguous # token elsewhere in the name is a second fork and reports on # its own (#289, mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE). # # The report tracks the FORK BEING CONSULTED, not the lean -- # exactly as the trailing slot has always done (`Jack MA` # reported before #289 too, even where the pick was declined # for want of words to spare). So membership alone gates it: # a caseless script or an all-lower spelling still called this # fork and read it positionally. # # The gate reads EITHER tag (`_AMBIGUOUS_CREDENTIAL_TAGS`, the # same pair `_group`'s chain emitter asks), and the shape one # is what reaches a by-shape member -- under EITHER 2.4 # switch, the dotted and the caps alike, since classify writes # it from both branches. It reaches one with the dotted switch # OFF as well: classify writes the shape tag whether or not # the switch admits the token to the class, which is what lets # a declined fork be REPORTED without being taken ('Smith, # A.B.' under `unlisted_dotted_suffixes=False` reports and # keeps its given). An earlier wording said the class reaches # a by-shape member "once `Policy.unlisted_dotted_suffixes` # admits it", which is true of the CLASS and false of this # report. # # Read off the FIRST post-comma piece only -- on this path it # is the given name, `_comma.decide` having read the part and # found a name word in it, and it is the one piece the lean # can reach at one word before the comma. So `"Smith, MA PhD"` reports ONCE, for 'MA' alone: # 'PhD' is settled vocabulary and carries neither tag, and # even a second CLASS member there would not be read here # (test_assign.py asserts the count). # # The #531 emitter at the far end of this branch counts # differently, and the difference is the slot rather than a # second policy: it reports once per member of the trailing # run it reads, so 'Doe, John MA JD' reports TWICE, matching # the comma-less 'John Smith MA JD'. A member the writing # keeps as a name stops that run, which is why # 'Doe, John MA Ma' reports once and for 'Ma' alone -- 'MA' # then has a name word behind it and is never asked # (rules/S5). if state.pieces[1] and len(state.pieces[1][0]) == 1: i = state.pieces[1][0][0] if not tokens[i].tags.isdisjoint(_AMBIGUOUS_CREDENTIAL_TAGS): # the field is assemble's to word: with nothing before # the comma and a title after the word, H1 moves it to # the family (', Ma Dr.', #626's review) ambiguities.append(PendingAmbiguity( AmbiguityKind.SUFFIX_OR_NAME, f"{tokens[i].text!r} after the comma is also an " f"ordinary name word; read as ", (i,), field_tail="", # the comma's other branch: the part a credential run alternative=suffix_reading(tokens[i]))) # Segment 1 is read before segment 0's family pass. It # consumes piece tags and text only -- nothing segment 0's # read writes. (A title standing after the comma behind a whole # name, 'John V, Dr.', is bound at group's head since #613 and # never reaches this path.) if len(state.segments) > 1: pieces = state.pieces[1] ptags = state.piece_tags[1] # Both are the walk's, filled once the title run is found. titled_idx: frozenset[int] = frozenset() walkable: frozenset[int] = frozenset() #: Where the trailing suffix run starts, for the #531 #: report below: `trailing_floor`'s answer, read once on #: the first member the loop meets and -1 until then (no #: piece index can be negative, and the loop's own floor is #: n + 1). Once is enough for the same reason one number is #: enough inside that walk: the question is MONOTONE, the #: loop below ascends, and a later member can only ask for #: LESS of the walk than the first one did. run_floor = -1 def previous_kept(m: int, titled: frozenset[int]) -> int: """The piece before `m` that the H5 chain did NOT take. Both readings this segment needs are that one: the piece the lenient tail test measures against, and -- asked of one past the end -- where the segment's name ENDS, since a title the chain took is not where a name ends (#144). One walk for both, so 'as if the titled pieces were absent' cannot come to mean two things. DEFENSIVE, and measured inert: over 191,146 generated inputs the skip fired on 153 of the 140,227 walks the lenient test's `prev` asked for, and deleting it changed no parse among them (2026-09-09, on the shape this replaced, where the name's END walked past the same pieces in a copy of this loop). Kept because "as if the titled pieces were absent" is the rule the predicate below implements, and a caller's vocabulary reaches shapes the sweep's word list does not -- an inert branch is cheaper than a rule with a hole in it. """ m -= 1 while m in titled: m -= 1 return m #: State of the #531 walk below, per `titled` value: how #: far down the trailing suffix run has been walked, and #: whether that walk has SETTLED (it stopped on a piece #: that refuses, so no lower piece can end the given part #: either and the refusal is never re-asked). #: #: The key carries `titled` for the same reason the #: predicate takes it as a parameter: the two passes ask #: about the same pieces with different ones spliced out. #: It CANNOT go stale. Everything the recorded verdict #: rests on is tags and text -- `is_suffix_piece` reads #: ptags and token tags, `_reads_as_a_trailing_suffix` #: reads text plus `is_suffix_piece`, `listed_lean` reads #: tags, text and `state.one_case` -- and none of them #: reads `.role`, verified by reading all three #: (2026-09-19). The one thing this segment's code rewrites #: between the two passes is the role, through `_set_roles`, #: which is a `copy_with(role=...)` and leaves #: text and tags identical. floors: dict[frozenset[int], tuple[int, bool]] = {} #: #544's anchors, per `titled` value like `floors` and for #: the same reason: `given_slot_anchors` over the pieces the #: chain kept, computed once, the first time a member's own #: writing declines, so a run of members is read in one #: forward pass rather than one look-behind per member. anchor_memo: dict[frozenset[int], list[bool]] = {} def anchored(m: int, titled: frozenset[int]) -> bool: memo = anchor_memo.get(titled) if memo is None: # nothing in front the pass could read as an # anchor: the ordinary 'Smith, John Ma' stops here. # Asked only before the pass exists -- once it # does it answers in one lookup, where the reach # test walks back through the run if not anchor_in_reach(range(m - 1, -1, -1), pieces, ptags, tokens, titled): return False # from past the leading title run, which `n` already # counts: this closure is reached only from the walk # below `_peel_leading_titles` sets it on memo = given_slot_anchors(pieces, ptags, tokens, n, skip=titled) anchor_memo[titled] = memo return memo[m] def trailing_floor(m: int, titled: frozenset[int]) -> int: """Where the trailing suffix run starts, walked as far down as `m` needs it: `m >= trailing_floor(m, titled)` is exactly "every kept piece behind `m` reads as a suffix", which is what ENDING the given part means (rules/S5, #531). ONE walk per `titled` value, shared by the predicate below and by that rule's report at the foot of this segment, and the reason both are LINEAR in the run's length. The question is MONOTONE -- a piece ends the given part whenever the piece behind it does -- so a single descent answers for every member, each piece read at most once. Asked member by member instead, the reading is recursive (a member ends the given part iff everything kept behind it reads as a suffix, and a piece behind it is a member asking the same of its own tail): a walk per member, which cost O(run**2) with the per-member memo this replaced and 2**run without one. Measured on `'Doe, John ' + 'MA '*k`, k doubling from 200: 2.1/4.1/8.3/17.1ms here, against 8.6/31.4/120/463 with the memo and 1.5/3.2/7.2/17.3 at cc78c960, where no such walk existed at all (2026-09-19). `low` descends only to `m`, so what comes back is a floor FOR `m` rather than the run's own first piece whenever the run reaches past it; that is all either caller asks, and stopping there is what keeps the member's own frame count where it was. `final` carries the other half: a piece that refuses settles the floor for everything in front of it. Re-entrant by construction, and it has to be: the descent asks the predicate below about a piece that is itself often a member, which asks this back. `floors` names the piece under test BEFORE that call, so the re-entrant reading is "this piece ends the given part", which is what the descent has just established of it. """ entry = floors.get(titled) if entry is None: # previous_kept() of one past the end, spelled out # here rather than called: the frame budget again, # this walk being asked of every family-comma name # with a member in the given part, and the skip is # two lines. Keep the two in step. low = len(pieces) - 1 while low in titled: low -= 1 entry = (low, False) floors[titled] = entry low, final = entry while not final and low > m: if reads_as_a_suffix(low, titled): low -= 1 while low in titled: low -= 1 else: final = True floors[titled] = (low, final) return low #: where the particle run opening the given part ends, #: per `titled` value -- `attaches_the_lead`'s one walk lead_run_ends: dict[frozenset[int], int] = {} def attaches_the_lead(m: int, titled: frozenset[int]) -> bool: """Is piece `m` the name word a never-given particle opening the given part attaches to? post_rules' P1 fold takes such a particle forward into the family and needs a name word for it to attach to, so the given name the #531 slot below counts on is not there, and the first word past the particle run is the surname it heads rather than a word to spare ('SMITH, VD MA' reads as 'Smith, vd Ma' does, #573). Only where the member's writing says nothing: capitals in a mixed-case name still make it the credential ('Smith, de MA' keeps suffix 'MA'), as they do with no words to spare anywhere (rules/S5). Asked only of a class member the slot would otherwise take.""" if not is_lone_never_given_particle(pieces[n], tokens): return False if listed_lean(tokens[pieces[m][0]], state.one_case) == "credential": return False # The member is the particle run's word when it is the # first kept piece past that run. Where the run ends is # one answer per `titled` value, walked once and kept, # so a run of members costs one walk rather than one # each (the second review measured the per-member scan # quadratic on 'SMITH, VD ' + 'DE '*k + 'MA '*k). Every # token, not the piece's tags: a chain of particles # ('DE LA') is one piece whose tags say nothing of it # ('SMITH, VD DE LA MA'). run_end = lead_run_ends.get(titled) if run_end is None: run_end = n + 1 while run_end < len(pieces) and ( run_end in titled or all("particle" in tokens[i].tags for i in pieces[run_end])): run_end += 1 lead_run_ends[titled] = run_end return m <= run_end def reads_as_a_suffix(m: int, titled: frozenset[int]) -> bool: """Does this segment's walk read piece `m` as a suffix? Asked twice, and by one predicate rather than by two conditions written to match (mechanisms.md#ONE-PREDICATE-PER-QUESTION): once to find the pieces the H5 title chain must not reach past, and once by the walk order below, which is the site that places them. `titled` is a PARAMETER because the two passes hand it different values -- an empty set on the first, the chain's own pieces on the second, which is what makes that second reading the one 'as if the titled pieces were absent'. A closure over the caller's local said the same thing, but only by WHEN it was rebound. """ if is_suffix_piece(pieces[m], ptags[m], tokens): return True # rules/S10: "A numeral of two or more letters reads as # the generation" in the given part, which asks no # initial in front -- 'Smith, John VI' as 'Smith, John # III' (#752); `_ROMAN` first, C-level if (len(pieces[m]) == 1 and len(stem := (tok := tokens[pieces[m][0]]).text .rstrip(".")) >= 2 and _ROMAN.match(stem) is not None): return reads_as_generation(tok.text, tok.tags, None, state.one_case) # rules/S5: "a word that ENDS that part reads as the credential # unless its WRITING says otherwise" -- the given part's # trailing slot (#531). # INLINE, and that is the frame budget talking rather # than taste: the candidates pass asks this closure once # per piece of every family-comma segment, so a helper # call would cost a frame on every non-member piece and # a generator expression would cost its own on 3.11. # Membership is therefore a bare `in` on tags already # in hand, after a `len` -- 'Doe, John Q.' and # 'Smith, John V' measure +0 with this shape and +1 # with a helper (2026-09-18). # # That "once per piece" is the NON-MEMBER cost, and # only it. A member is asked a second time by # trailing_floor()'s descent above, and twice is the # whole of it: the descent takes each piece once and # the candidates pass asks each piece once, which is what # makes the run linear. What the slot costs, measured # against cc78c960: 'Smith, John', 'Doe, John Q.', # 'Smith, John V', 'Smith, MA' and 'Berg, Jan vd' are # all +0 frames, while 'Doe, John MA' is +6 and 'Doe, # John MA PhD' +23 -- the member's descent, its lean, # and the report below (2026-09-19). # # The comma has already named the family and the first # piece after it is the given name, so the words to # spare S4's count asks about are there by # construction and the count says nothing at this # slot. What is left is the writing, which is the same # evidence the comma-less spelling of the same name # reads. #144's two-segment restriction below is NOT # inherited: it exists because a trailing 'V' before a # third comma part is likely a middle initial, and a # class member is not initial-shaped while a # credential list behind it makes the credential # reading likelier rather than less. piece = pieces[m] # rules/S12: a split credential reads "as a member of the # ambiguous class", here read as the class is with no # writing to lean on and in silence, as group merges it # only behind the given word if len(piece) == 2 and "split" in ptags[m]: return (m >= trailing_floor(m, titled) and not attaches_the_lead(m, titled)) if len(piece) == 1: tok = tokens[piece[0]] if AMBIGUOUS_ACRONYM_TAG in tok.tags: # 'ending the given part' reaches past the # credentials behind it and past a trailing # title, which trailing_floor() skips the way # previous_kept() does -- so 'Doe, John MA # Prof.' and 'Doe, John Prof. MA' land on one # answer without a second notion of trailing. # A name word behind the member ends the # reach, and the member is an ordinary middle # name read in silence. if (m >= trailing_floor(m, titled) and not attaches_the_lead(m, titled)): # A member that is ALSO particle # vocabulary reads as the credential only # on a POSITIVE credential lean: P6's # attachment outranks this reading in # every other spelling, and 'Doe, John do' # leans nothing, so the positional reading # would take it -- the wrong answer there, # not merely a stray report # (decisions/S5, 2026-09-18). # # A FUNCTION since #533 rather than a # condition written here (mechanisms.md # #ONE-PREDICATE-PER-QUESTION). The call # costs one frame PER MEMBER asked at this # slot, not one per name: against # 2f57ff21, 'Doe, John MA' is 310 -> 311 # and 'Doe, John MA Ma MA', which asks # four times, 439 -> 443, while a name # with no member here never reaches it # ('Smith, John' 206, 'MA JD' 185, both # unchanged). Measured 2026-09-19 per # `Parser.parse`; Derek took that trade # deliberately. # #544: an unambiguous credential in front # of it in the same run anchors it -- asked # through a thunk, so only a member the # writing declines pays for the pass if credential_at_the_given_slot( tok, state.one_case, lambda: anchored(m, titled)): return True prev = previous_kept(m, titled) # trailing piece of a two-part name is unambiguously # positioned: v1 accepts the lenient test there # ('Smith, John V' -> suffix='V', #144); with a third # comma part the trailing token is more likely a middle # initial, so strict only return (m == previous_kept(len(pieces), titled) and len(state.segments) == 2 and len(pieces[m]) == 1 and _reads_as_a_trailing_suffix( pieces[m], pieces[prev], ptags[prev], tokens, state.lexicon, state.one_case)) # rules/C1: "anything else after the comma means the # listing form" -- and this walk reads it. A segment of no # name word never reaches here: it is `_comma.decide`'s, read piece by piece and bound at # group's head (#613, rules/C8), the suffix verdict # before the title reading of the same word (#296, #325). # A name word in the segment makes it v1's walk ('Smith, # John Jr.'). # rules/H9: "no comma has marked the given name" -- here the # family comma has, so the peel reports nothing n = _peel_leading_titles(pieces, ptags, tokens, None, state.lexicon) # rules/H5: "the title is TRANSPARENT to the suffix # reading: where two or more name words stand, what # stands once the chain is taken reads exactly as it # would read written without the title, plus the title" # -- the trailing title run, on this walk # too. A name word in segment 1 is what keeps # `_comma.decide` from reading the segment as the # postnominal part, so 'Smith, John Prof.' had no route # to title at all and read middle 'Prof.' at every # baseline; the 'Smith, Dr.' family of rows are decide's # doing, a different mechanism. # # The candidates are the pieces this walk would NOT # read as a suffix, which is this path's answer to the # peel the no-comma path runs first -- 'Smith, John # Prof. Jr.' must reach `Prof.` past the postnominal # behind it, and 'Smith, John Prof. V' past the # numeral the lenient tail test claims (#144), which # is why the filter is the walk's own predicate and # not the strict suffix test alone. Piece `n` is # always the given below, whatever that predicate # would say of it, so it is always a candidate. That # `k == n` is LOAD-BEARING, not defensive: it is what # the walk's floor stands on when the given piece # itself reads as a suffix, and dropping it leaves # 'Smith, II Mr. V' a middle 'Mr.' where the title is # (24 inputs of that shape move, of 191,146 generated, # measured 2026-09-09). # # `candidates` is the ORDER, which `trailing_titles` and # its slice read; everything after asks MEMBERSHIP, # once per piece, and a list or tuple answers that by # scanning, so the walk was quadratic in the part's # length (#553). Hence the two sets. A tuple keying # `floors` or `anchor_memo` above would also re-hash # its whole length at every lookup, where a frozenset # caches its hash. candidates = [k for k in range(n, len(pieces)) if k == n or not reads_as_a_suffix(k, frozenset())] # rules/H8: "the word joins the title field, and the given # part reads as it would without it" -- a bare salutation # chains here as a period-marked title does kept = trailing_titles(candidates, pieces, ptags, tokens, salutations=True) walkable = frozenset(candidates) titled_idx = frozenset(candidates[kept:]) for k in titled_idx: _set_roles(tokens, pieces[k], Role.TITLE) # v1 walk order: the first non-title piece is ALWAYS the # given, before any suffix check -- 'Smith, V. Jones' keeps # first='V.'. Two 2.x deviations are `_comma.decide`'s # reading now (#613), so the walk here never meets # either: a last piece # that is unambiguously suffix-shaped is a suffix, where v1 # made it the given ('Andrews, M.D.', 'Smith, Dr. Jr.'; # classified fix(comma-family)), a segment whose only # non-title piece is a suffix piece holding no name word; # and a credential opening the segment makes it the # postnominal part whatever follows ('Hardman, RN - CRNA' # kept first='RN' until #603). if n < len(pieces): _set_roles(tokens, pieces[n], Role.GIVEN) # The chain's floor leaves a name piece standing, so the # given above is never one of the pieces it took. What the # chain DOES move is where this segment's name ends, which # the lenient tail test turns on (#144) -- so where it took # something the question is re-asked with the pieces it # left. Where it took nothing, `walkable` already IS this # predicate's answer for every piece: the first pass's own # memo, not a second spelling of the question, and it # cannot have gone stale because nothing the chain does # moved the end of the name. # rules/S8: "or after the given word in the part after a # family comma" -- the given part's own credential run # (#602), the comma having fixed the family. The tag tests are inline ahead of the # predicate, as in `_pieces.credential_run`, so a name word # pays no frame: this loop runs on every family-comma name. sticky_from = len(pieces) for m in range(n + 1, len(pieces)): tags = tokens[pieces[m][0]].tags if (m not in titled_idx and ("suffix" in ptags[m] or "split" in ptags[m] or (("vocab:suffix" in tags or AMBIGUOUS_ACRONYM_TAG in tags or _ROMAN.match(tokens[pieces[m][0]].text .rstrip(".")) is not None) and tags.isdisjoint(_NOT_A_RUN_START))) and starts_a_credential_run(pieces[m], ptags[m], tokens, None, state.one_case)): sticky_from = m break # rules/P6: "a particle ending the name attaches to that # family name" -- decided here, in the pass that reads the # given part, rather than predicted for post_rules to take # a stage later (#613). The tail is `pieces[k6:end6]`, found # by `particle_tail` ONCE, at the moment the walk reaches # #602's run (or, with no run, once the walk has placed # every piece): the pieces in front of the run hold the roles # this walk gave them and the run's own words hold none # yet, which is the state in which the walk's answer equals # the one it would give over the finished roles -- the run # reads every word it keeps as a suffix or a title, neither # a name role, and a class member in it as the credential, # where the walk's #531 stop wants it. The tail's words are # left to the walk, not the run: absorbing them reported a # suffix reading the attachment then overrode ('Smith, John # PhD de', 'Smith, John PhD de Jr.'). A particle the walk # does not end on -- one with a credential behind it and # another particle past that, 'Smith, John PhD de PhD van' # -- stays in the run, as does a lone member of the # ambiguous credential class (`do`), which the run reads as # the credential. k6 = end6 = len(pieces) def as_a_middle(m: int, token: WorkToken) -> PendingReading: # the name reading of a word the walk read as a suffix: # a middle name, or taken in by the name word in front front = pieces[m - 1] return _name_alt( token, Role.MIDDLE, front if tokens[front[0]].role in NAME_ROLES else None, tokens, m - 1 == n, True) for m in range(n + 1, len(pieces)): if m == sticky_from: k6, end6 = particle_tail(pieces, tokens) if m in titled_idx: continue suffix_here = (reads_as_a_suffix(m, titled_idx) if titled_idx else m not in walkable) # rules/S10: "A numeral read by its shape alone reports # too" -- one of two or more letters the walk reads as # the generation; `_ROMAN` first, C-level if (suffix_here and len(pieces[m]) == 1 and len(stem := (tok := tokens[pieces[m][0]]).text .rstrip(".")) >= 2 and _ROMAN.match(stem) is not None and not is_listed_numeral(tok.text, tok.tags) and not numeral_settled_by_case(tok.text, state.one_case)): ambiguities.append(PendingAmbiguity( AmbiguityKind.SUFFIX_OR_NAME, f"{tok.text!r} is a roman numeral, so it reads as " f"a generational suffix; it may be a name word", (pieces[m][0],), # a name word the walk keeps past the given # name is a middle one alternative=as_a_middle(m, tok))) # rules/C9: "a lone letter ending the given part ahead of # a further comma is the middle initial, whatever stands # before it" -- the further comma has named the suffix, # so the credential run in front stops short of it # (#819); `len` first, C-level if (m > sticky_from and len(state.segments) > 2 and len(pieces[m]) == 1 and len(tokens[pieces[m][0]].text.rstrip(".")) == 1 and m == previous_kept(len(pieces), titled_idx) and is_initial_shaped( tokens[pieces[m][0]].text.upper())): _set_roles(tokens, pieces[m], Role.MIDDLE) continue if m >= sticky_from and not k6 <= m < end6: # inside the run: a title word reads as a title, # every other word as a suffix, and a word the walk # would have kept as a name is reported, here where # the run absorbs it if (m > sticky_from and not is_suffix_piece(pieces[m], ptags[m], tokens) and is_title_piece(pieces[m], ptags[m], tokens)): _set_roles(tokens, pieces[m], Role.TITLE) continue piece = pieces[m] if (len(piece) == 1 and not tokens[piece[0]].tags.isdisjoint( _AMBIGUOUS_CREDENTIAL_TAGS)): # a class member inside the run is still the # fork #531's report names, now read as the # credential, as #544's anchor reads one -- # unless its capitals settled it (rules/S6) if not settled_by_case(tokens[piece[0]], state.one_case): ambiguities.append(PendingAmbiguity( AmbiguityKind.SUFFIX_OR_NAME, f"{tokens[piece[0]].text!r} ending the given " f"part is also an ordinary name word; read " f"as a credential", (piece[0],), alternative=as_a_middle( m, tokens[piece[0]]))) # the run's own first word is the credential that # starts it, a split one ('Pharm D') included elif (not suffix_here and m != sticky_from and has_name_content(piece, tokens)): ambiguities.append( _absorbed(piece, tokens, as_a_middle(m, tokens[piece[0]]))) _set_roles(tokens, piece, Role.SUFFIX) continue _set_roles(tokens, pieces[m], Role.SUFFIX if suffix_here else Role.MIDDLE) # #531's report, one of this module's SUFFIX_OR_NAME # sites (the header lists them). The gate reads EITHER tag, as the # first-post-comma emitter's does: classify writes the # shape tag whether or not the dotted switch admits # the token, which is what lets a declined fork be # reported without being taken. # # The report tracks the FORK CONSULTED, not the lean # (#289's rule), so a member the writing kept as a # name reports too -- 'Doe, John Ma' stays a middle # name and says so. It reports only in the TRAILING # RUN: with a name word behind it no fork was # consulted, and AGENTS.md's "a kind is worth adding # only if a reader would hesitate too" is why that # must stay silent rather than why it happens to (the # sentence is the 2.0-conventions section's, not # rules/A1's, which this cited until 2026-09-19). # # The `do` carve-out is a REPORT carve-out on top of # the reading one above: a particle-tagged member this # walk did not itself take is P6's fork, and P6 # reports it in its own kind, which is # mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE read # strictly. Without it 'Doe, John do' reported both # kinds. piece = pieces[m] if (len(piece) == 1 and not tokens[piece[0]].tags.isdisjoint( _AMBIGUOUS_CREDENTIAL_TAGS) and (suffix_here or "particle" not in tokens[piece[0]].tags) # rules/S6: "a clash settled by the writing is # not reported" and not (suffix_here and settled_by_case( tokens[piece[0]], state.one_case))): # The SAME floor the predicate measures members # against, so this report has no walk of its own to # regress: it had one, and on a run the predicate's # memo had already made quadratic the report was # CUBIC, because its per-member walk re-scanned # `walkable` -- a list -- at every step. Measured # then on `'Doe, ' + 'John '*r + 'MA '*r`, r # doubling from 100: 5.8/28/175/1229ms, 4.9x then # 6.2x then 7.0x per doubling and heading for the # 8x a cubic gives, against 1.8/3.7/8.0ms here # (2026-09-19). Nothing guarded it: the scan was a # C-level `in` over a list and emitted no frame, so # the frame-ratio test in tests/v2/test_benchmark.py # was structurally blind to it, and the clock-based # shapes beside it repeat ONE unit where this cost # needs a name holding two runs (`_PREFIXED_SHAPES` # adds a prefix but still repeats one run, so it # does not reach this either). What keeps it gone # is the structure: one walk, read by both callers, # so a second would have to be written on purpose. # # Read once for the whole loop, `run_floor` being # where the FIRST member's walk stopped and the # floor for every later member too: if the walk # reached that member, nothing behind it refuses # and no later member can be refused either; if it # stopped short, it stopped at the LAST piece that # refuses, which is exactly what a later member's # own walk would have found (2026-09-19). if run_floor < 0: run_floor = trailing_floor(m, titled_idx) if m >= run_floor: i2 = piece[0] ambiguities.append(PendingAmbiguity( AmbiguityKind.SUFFIX_OR_NAME, f"{tokens[i2].text!r} ending the given " f"part is also an ordinary name word; " f"read as " f"{'a credential' if suffix_here else 'a name'}", (i2,), alternative=( as_a_middle(m, tokens[i2]) if suffix_here else suffix_reading(tokens[i2])))) if sticky_from == len(pieces): k6, end6 = particle_tail(pieces, tokens) # Behind every family comma, one with nothing before it # too, which then makes the particle the whole family # (', Dr. Jones vd'; decisions/C15, #892). GIVEN ahead of # the tail -- rules/P6: "at least one given word must # remain" -- the given is piece `n`, so a tail starting # there leaves none ('Nguyen, Van'). # And not where a never-given particle opens the part: P1's # fold takes every given and middle word into the family in # post_rules, so no given word remains for this one either # ('Smith, de Mesnil van' keeps 'van' where the fold puts # it, not hoisted in front of 'Smith', the 2026-08-18 # defect); with no name word behind it to fold, the tail # stays the suffix, behind an empty head as behind its twin # (', de PhD vd' as 'Smith, de PhD vd', #892). if (n < k6 < end6 and not is_lone_never_given_particle(pieces[n], tokens)): _attach_particle_tail(pieces, tokens, n, k6, end6, ambiguities) # rules/H6: "A run of title words opening the part before a # family comma is the title" -- by vocabulary only, never H2's # shape, leaving one piece for the family, and stopping at a # word that is a particle too ('St Pierre', 'Freiherr von # Berg'), or where everything behind it is post-nominal, which # leaves no family word to address -- H3's concern, by a # stricter test than H3's one-word give-back, so this is not # `leading_titles` ('RA PhD Jr., John' keeps family 'RA', where # 'RA PhD Jr. John' reads title 'RA'). A dual is a title wherever # a name word follows it ('Rev. Sr. Smith, Mary'). # `is_title_piece` is H5's vocabulary read, so the two slots that # refuse the shape agree on what a title word is. lead = 0 floor = len(fam_pieces) # the length test and the tag test first, both C-level: a part # no title opens ('Smith, John', 'Smith Jones, John') pays no # frame. The tags are the two arms `is_title_piece` answers yes # from, so the predicate stays the one that answers if (floor > 1 and ("title" in fam_tags[0] or "vocab:title" in tokens[fam_pieces[0][0]].tags) and is_title_piece(fam_pieces[0], fam_tags[0], tokens)): # where the trailing post-nominal run starts: walked once, # and only for a part a title opens while floor > 1 and is_suffix_piece(fam_pieces[floor - 1], fam_tags[floor - 1], tokens): floor -= 1 # the first piece is a title, asked above while (lead + 1 < floor and (lead == 0 or is_title_piece( fam_pieces[lead], fam_tags[lead], tokens)) and "particle" not in tokens[fam_pieces[lead][0]].tags): _set_roles(tokens, fam_pieces[lead], Role.TITLE) lead += 1 # rules/P9: "a particle that is also suffix vocabulary heads # the name word behind it" -- with a name piece behind it in # this part (vd, mc), it heads that name, as P2's join does, # and is not peeled from between two family words # (#573: 'SMITH VD MA, JOHN', where the uniform case left the # chain stopped before VD; 'SMITH VD JR, JOHN', a suffix word # behind it, keeps suffix 'VD JR'). Read here because only # this family pass needs it: a comma followed by no name # word behind a whole name is group's suffix comma since #613, # and the NO_COMMA read takes segment 0, trailing run and all # ('Berg de MA, Prof.'). The first piece the title run leaves # is the family's first and is family whatever it is, as piece # 0 is with no title ('Dr. PhD Smith, John' as 'PhD Smith, John'). # A piece at or past the floor is already known to be a suffix # with nothing but suffixes behind it, so it is not asked again. for k in range(lead, len(fam_pieces)): piece = fam_pieces[k] if k >= floor: _set_roles(tokens, piece, Role.SUFFIX) # rules/C1: "a numeral ending the family part is read as the # comma-less name reads it" -- the numeral's predicate, a # one-letter numeral with two name words in front of it # (rules/S10), so 'John Smith V, M.J.' keeps suffix 'V' as # 'John Smith III, M.J.' keeps 'III' (#774). `_ROMAN` # first, C-level, so a family word pays no frame elif (k > lead and len(piece) == 1 and _ROMAN.match( (word := tokens[piece[0]]).text.rstrip(".")) is not None and all(is_suffix_piece(fam_pieces[j], fam_tags[j], tokens) for j in range(k + 1, len(fam_pieces))) and (numeral := _family_part_numeral( word, tokens[fam_pieces[k - 1][0]].text, k - lead, state.one_case)) is not None): _set_roles(tokens, piece, numeral) # reported where taken: a name word here is the family # name the comma has just named, which nobody misreads if (numeral is Role.SUFFIX and not is_listed_numeral(word.text, word.tags) and not numeral_settled_by_case(word.text, state.one_case)): ambiguities.append(PendingAmbiguity( AmbiguityKind.SUFFIX_OR_NAME, f"{word.text!r} ending the family part is a roman " f"numeral, so it reads as a generational suffix; " f"it may be a name word", tuple(piece), alternative=name_reading(word, Role.FAMILY))) elif (k > lead and is_suffix_piece(piece, fam_tags[k], tokens) and not (k + 1 < len(fam_pieces) and "particle" in tokens[piece[0]].tags and not is_suffix_piece( fam_pieces[k + 1], fam_tags[k + 1], tokens))): _set_roles(tokens, piece, Role.SUFFIX) else: _set_roles(tokens, piece, Role.FAMILY) tail = 2 # rules/C2: segments past the structure's name segments are # consumed as suffixes, except "a word of the title vocabulary there # that is not also suffix vocabulary is a title" (#603) -- a part # past the second comma holds no name word, so the surnames H5 # keeps a bare trailing title word from misreading are not there to # protect ('Eric H. Holder, Jr., Attorney General'). A # piece holding a suffix word stays the postnominal the slot makes # it, a dual alone as after a family comma (C8) and a dual group's # connective join took into a title ('PhD - and MD') alike. for seg_idx in range(tail, len(state.segments)): for piece, ptags_ in zip(state.pieces[seg_idx], state.piece_tags[seg_idx]): # the tag tests inline ahead of the predicate: every tail # past the name segments runs this ('John Smith, Jr., PhD'), # the part after a suffix comma being bound by group, and the call # would be its frame. Both arms are needed -- a connective # join tags a piece a title though its first token is none # ('and Secretary of State') _set_roles(tokens, piece, Role.TITLE if (("title" in ptags_ or "vocab:title" in tokens[piece[0]].tags) and is_title_piece(piece, ptags_, tokens) and not any("vocab:suffix" in tokens[i].tags for i in piece)) else Role.SUFFIX) # rules/S12: "where the count takes the pair, the reading is reported" -- at # the end of a name, at the end of the given part, as a run start, # here where its roles are final (the part a comma's decision binds reports in `_comma`, a # pair a maiden clause releases in group). A loop over the piece # tags, so a name with no pair pays no frame for it. for seg_idx in range(min(tail, len(state.segments))): for piece, ptags_ in zip(state.pieces[seg_idx], state.piece_tags[seg_idx]): if ("split" in ptags_ and len(piece) == 2 and tokens[piece[0]].role is Role.SUFFIX and tokens[piece[1]].role is Role.SUFFIX): ambiguities.append(split_credential_report( piece, tokens, _split_name_alt(state, tokens, seg_idx, piece, order))) # the reports earlier stages left a field for: only a name that # has one pays the walk if state.ambiguities: _resolve_deferred(state, tokens, ambiguities, order) return copy_with(state, tokens=tuple(tokens), order=order, ambiguities=tuple(ambiguities)) derek73-python-nameparser-456b024/nameparser/_pipeline/_classify.py000066400000000000000000000775211527303271700253520ustar00rootroot00000000000000"""Stage: classify. Consumes: tokens, comma_offsets (with token roles, the two halves of the structural-boundary test the marker pass applies -- see _vocab.tag_marker_runs), and one_case where an earlier stage recorded it -- segment writes it lazily where C2's flag on a part past the second comma asks for it (#289/#516, #613), so this read is the fallback for every other name. Produces: tokens with vocabulary tags added (text/span/role unchanged), plus ambiguities (CONJUNCTION_OR_INITIAL) and one_case -- whether the name's own words are written in one case, recorded for the later stages that read it (#289/#516) and left alone where an earlier stage already asked. Reads: every Lexicon vocabulary field except surnames and honorific_tails, which script_segment consumes upstream; and, since 2.4, Policy.unlisted_dotted_suffixes and Policy.unlisted_caps_suffixes, which decide whether an UNLISTED dotted or all-caps token joins the ambiguous credential class by SHAPE (#516) -- the all-caps half here only under CapsSuffixes.EVERYWHERE, the default's part after a comma being tagged by `_comma.decide` when group binds it (#613); and Policy.extra_suffix_delimiters, with the built-in semicolon (_vocab.glued_cores), so a word after the first comma with a delimiter glued to it is tagged as the word without it (rules/C10). is_initial also consults the _policy module's _NO_INITIALS constant. It is named apart from the fields above because it is not CONFIGURATION -- no Lexicon or Policy carries it and no caller can change it -- and not because it decides nothing: the tags this stage writes do vary by it ('씨.' is not tagged `initial`, which is the whole of #320). Tags emitted -- stable (API): "particle", "conjunction", "initial"; namespaced (unstable): "vocab:title", "vocab:given-title", "vocab:after-given-title", "vocab:title-ambiguous", "vocab:suffix", "vocab:suffix-word", "vocab:suffix-ambiguous", "vocab:suffix-word-ambiguous", "vocab:agnomen", "vocab:particle-ambiguous", "vocab:bound-given", "vocab:given-abbreviation", "vocab:maiden-marker", "vocab:maiden-marker-cont"; in a namespace of its own, "shape:acronym"; and in another, "split:credential" and "split:member", written on the first word of a credential split across a space (`_vocab.split_pair`) to name the piece the pair merges into: the Ph./D. pair's unambiguous suffix, or any other pair's member of the ambiguous class (rules/S12). "vocab:maiden-marker" tags the HEAD of a maiden marker, which is a whole marker whenever the marker is one word; the continuation tag carries the rest of a PHRASE marker ("z domu"), so a site asking "does a marker start here" reads the same tag it always did and a site asking "where does it end" walks the continuations. "vocab:suffix" means "counts as a suffix as written": unambiguous suffix vocabulary, or an ambiguous acronym written with periods -- at the TAG level 'M.A.' gets "vocab:suffix" while 'Ma' gets only "vocab:suffix-ambiguous"; what assign then does with a trailing ambiguous tag is rules S4 to S7 (the words-to-spare guard, the case, the company) and their Accepted consequences. The initial veto is assign's job, not classify's: 'V' carries both "vocab:suffix" and "initial". "shape:acronym" is the one tag in the shape: namespace and it records WHERE a class claim came from rather than what the vocabulary holds: an unlisted token the writing makes credential-shaped. It rides beside "vocab:suffix-ambiguous" where a Policy switch admits the token to that class, and stands alone where the switch is off, which is what lets the fork be reported without being taken. """ from __future__ import annotations import unicodedata from nameparser._lexicon import _normalize, _spells_an_initial from nameparser._policy import CapsSuffixes from nameparser._pipeline._state import ( AGNOMEN_TAG, AMBIGUOUS_ACRONYM_TAG, AMBIGUOUS_WORD_TAG, GIVEN_ABBREVIATION_TAG, SHAPE_ACRONYM_TAG, SPLIT_CREDENTIAL_TAG, SPLIT_MEMBER_TAG, TITLE_AMBIGUOUS_TAG, ParseState, Deferred, PendingAmbiguity, PendingReading, WorkToken, copy_with, ) from nameparser._types import AmbiguityKind, ReadingKind, ReadingSource 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, period_joined_vocab, split_pair, suffix_as_written, tag_marker_runs, title_readings, ) from nameparser._pipeline._pieces import own_words # rules/S2: "a trailing word of the suffix vocabulary reads as a # suffix — generational forms and credential acronyms alike" -- and # rules/S3: "an ambiguous acronym written with its periods, one after # each letter or one after each of two or more letter chunks, counts # unambiguously; a single trailing period is the abbreviation shape # any word can wear and does not" -- and rules/S4: "a # bare ambiguous acronym is consumed only when the name has words to # spare" def _tags_for(token: WorkToken, n: str, state: ParseState, marker_tag: str | None, one_case_own: bool, one_case: bool, letter_one_case: bool) -> frozenset[str]: """`n` is _normalize(token.text), folded once by the caller and shared with the marker pass; `marker_tag` is what that pass decided for this token, or None. The marker DECISION is entirely `_vocab.tag_marker_runs`'; only the writing happens here, so the two tokens of a phrase are built once rather than replaced twice. `one_case_own` is true when the name's OWN words are written in one case AND this token is one of the name's own words -- a maiden clause and any delimited (nickname) content are not, so the fork never reads them either (rules/P3): a clause's words are not the name's own words, and appending one must not change how THIS token reads. `one_case` is the bare NAME-level fact alone -- P3's own-words span is not this question's business. #516's caps branch reads THIS, not `one_case_own`: a maiden clause's own words are outside `one_case_own`'s span by construction (`i < clause_at` fails for every one of them), so a token past the clause cut reads `one_case_own` as False regardless of whether the WHOLE name is written in one case -- 'JOHN SMITH NEE' flipped 'NEE' to a credential reading with the switch on, one case and all, because `not one_case_own` was true for it purely from being past the clause cut, never from the name's own writing (#516 review round, a second reviewer's finding). `single_letter_connective` below keeps `one_case_own`: that fork is genuinely about the OWN-WORDS span, and reads a clause's word as no evidence on purpose. `letter_one_case` is rules/T4's question for THIS token: the own-words fact for one of the name's own words, and for a word outside them -- a clause's, or past a declined marker -- the fact about the WHOLE name, since the own words' case says nothing about how the clause beside them was written: 'y' in 'MARIA GARCIA NEE LOPEZ y FERNANDEZ' is a lower-case letter among capitals, which T4 reads as no initial (#723's review).""" lex = state.lexicon tags = set(token.tags) if marker_tag is not None: tags.add(marker_tag) if n in lex.titles: tags.add("vocab:title") if n in lex.given_name_titles: tags.add("vocab:given-title") # rules/H8: "the word joins the title field" -- tagged only where the # word is a title too, so an entry outside `titles` is inert if n in lex.after_given_titles and n in lex.titles: tags.add("vocab:after-given-title") if suffix_as_written(n, token.text, lex): tags.add("vocab:suffix") if n in lex.suffix_words_ambiguous: # rules/S11: "it reads as the generation only where the name has # words to spare" -- the ambiguous class's tag, so every site # that weighs a member weighs this one, and the cue tags that # tell `_pieces.listed_lean` how its writing leans tags.add(AMBIGUOUS_ACRONYM_TAG) tags.add(AMBIGUOUS_WORD_TAG) if n in lex.agnomens: tags.add(AGNOMEN_TAG) elif n in lex.suffix_words: tags.add("vocab:suffix-word") if n in lex.suffix_acronyms_ambiguous: tags.add(AMBIGUOUS_ACRONYM_TAG) # rules/P7: "A one-letter word written with its period is an # initial and not a particle" -- both particle tags, so 'Ó.' is # neither half of the vocabulary if n in lex.particles and not _spells_an_initial(n, token.text): tags.add("particle") if n in lex.particles_ambiguous: tags.add("vocab:particle-ambiguous") # 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 # written wholly in one case, where nothing says so — where the # letter is one the vocabulary marks as reading both ways" # (#383/#479; history: decisions/P8) # one letter as the writer sees it: a decomposed 'й' is two code # points (#665), so non-ASCII text is counted composed, after the # C-level tests have declined everything else single_letter_connective = (n in lex.conjunctions and token.text.upper() != token.text.lower() and (len(token.text) == 1 or not token.text.isascii() and composed_len(token.text) == 1)) if single_letter_connective and one_case_own: # No case evidence, so the vocabulary decides. No namespaced # tag beside it: the emitted ambiguity IS the record of the # decision (mechanisms.md#MARK-DONT-STRIP is satisfied by the # report) -- mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE says # emit where the branch is taken, not where an ambiguous tag # sits, and a vocab: tag records MEMBERSHIP, not the branch # taken, so it is the wrong shape of record here. if n in lex.conjunctions_ambiguous: tags.add("initial") else: # a bare capital y joins here, where mixed case vetoes it tags.add("conjunction") else: # the mixed-case rule, unchanged. v1's is_conjunction excludes # initials: 'e.' in 'john e. smith' is a middle initial, not # the Spanish conjunction 'e' # rules/T4: "in a name written wholly in one case, which says # nothing about any letter in it, a bare letter is an initial # wherever its capital would be" -- so every reader of the tag # after classify reads 'j' as it reads 'J' (#723; the roman # numerals first, #721). Readers that run before the tag # exists, or ask the word's text rather than its tag -- the # strict suffix test, which the honorific peel and # `lenient_comma_suffixes=False` ask -- take the same fact as # an argument (#727). `letter_one_case` is the own-words fact # for an own word and the whole name's for a clause's # (docstring). The capital is asked of the # bare pattern at C level, no frame per one-letter word, and # needs no script test: no capital `_INITIAL` matches is in a # script without initials. 'é' and 'ж' stay as they were, # their capitals being no initial either. A clause's letter # asks the whole name's case (`letter_one_case`, docstring) initial = is_initial(token.text) or ( letter_one_case and len(token.text) == 1 and _INITIAL.fullmatch(token.text.upper()) is not None) if n in lex.conjunctions and not initial: tags.add("conjunction") if initial: tags.add("initial") if n in lex.bound_given_names: tags.add("vocab:bound-given") # rules/H2: "an abbreviation the vocabulary reads as a given name # written short is that given name" -- the reading H2's shape test # weighs (`_pieces.is_leading_title`); a membership, so any spelling # carries it and only that test acts on it if n in lex.given_name_abbreviations: tags.add(GIVEN_ABBREVIATION_TAG) # maiden markers are NOT tagged here: an entry may be a phrase whose # words are not markers on their own, and this function sees one # token with no neighbours. `_vocab.tag_marker_runs` does the whole # field, single words included, so there is one place that decides # it (mechanisms.md#ONE-PREDICATE-PER-QUESTION). # The block a WHOLE-TOKEN vocabulary match skips, and four # readings inside it, in precedence order. The first two are v1's # period-joined derivation (parse_pieces): a token with a period # not at the end, ANY of whose period chunks is a title, is a # title as a whole ('Lt.Gov.', and by the ANY rule 'Mr.Smith'); # else ANY suffix chunk makes it a suffix ('JD.CPA'). Title wins # (v1's continue). The third and fourth are 2.4's by-shape halves # (#516; rules/S9: "joins the ambiguous class (S4) by SHAPE, in # the positions each shape is admitted") and reach only a # token NO vocabulary claimed: a dotted token of two or more # alphabetic chunks, and -- where its switch is on -- an unlisted # all-caps word. They are an `elif` chain # with the dotted one first, so a dotted token never reaches the # caps test and the two shapes stay disjoint. # # Both by-shape branches carry SHAPE_ACRONYM_TAG BESIDE the # membership tag rather than instead of it: `vocab:` records # membership (the roster above) and the peel reads membership, # while the shape tag records that the claim came from the # WRITING. The dotted branch writes it even with its switch off, # which is what lets a declined fork be reported without being # taken. `role is None` guards both: a token already carrying a # role is delimited content, decided by extract's escape and never # at the trailing slot -- 'Bridge (A.B)' is the control that # proves the guard load-bearing (without it the nickname reading # of 'A.B' gains a spurious SUFFIX_OR_NICKNAME report in post_rules), and # 'Bridge (1.4)' cannot exercise it, a digit chunk never reaching # the shape verdict at all (period_joined_vocab's alphabetic # gate). if "vocab:title" not in tags and "vocab:suffix" not in tags: derived = period_joined_vocab(token.text, lex) if derived == "title": tags.add("vocab:title") elif derived == "suffix": tags.add("vocab:suffix") elif (derived == "shape" and token.role is None and n not in lex.suffix_acronyms_ambiguous): # This branch and `_vocab.ambiguous_class_candidate` ask # the SAME question twice, of necessity -- `segment` runs # before `classify` and has no tags to read yet -- kept # from drifting by `test_classify. # test_ambiguous_class_candidate_agrees_with_the_tag` # rather than by this sentence alone. # # `n not in suffix_acronyms_ambiguous` is the third guard, # and it is about a LISTED member rather than a role: a # caller may list a dotted entry ('a.b'), which the whole- # token membership test above matches while # `suffix_as_written`'s period-free acronym lookup ('ab') # misses, so the chunk view reached here and called the # word by-shape -- silencing `_pieces.listed_lean`, which # declines wherever SHAPE_ACRONYM_TAG rides, and costing # the caller's own listing its case lean ('Jack A.B.' read # family where 'Jack MA' reads suffix). One frozenset # lookup on a branch only a dotted token reaches; the # shipped ambiguous vocabulary carries no periods, so # nothing default changes. tags.add(SHAPE_ACRONYM_TAG) # rules/S3: "Paired initials are the exception, as C5 defines # them, in a script that writes capitals" -- the shape # tag alone, as with the switch off, so the trailing slots # read them as a name word and report the fork; the comma # restores the class tag where C5 reads them # (decisions/S3 (#864)); `is_cased_paired_initials` asks the # script half ('พ.บ.' is no initial) if (state.policy.unlisted_dotted_suffixes and not is_cased_paired_initials(token.text)): tags.add(AMBIGUOUS_ACRONYM_TAG) elif (state.policy.unlisted_caps_suffixes is CapsSuffixes.EVERYWHERE and token.role is None # what the predicate would decline anyway, asked first in # C: a listed member ('John Smith, MA'), and a word that # is not alphabetic capitals ('John Smith, Ph. D.') and n not in lex.suffix_acronyms_ambiguous and (token.text.isupper() and (token.text.isalpha() or "-" in token.text or "/" in token.text) or not unicodedata.is_normalized("NFC", token.text)) and caps_shape_candidate(token.text, lex, state.policy, one_case)): # #516's all-caps half: an unlisted word written in capitals # inside a mixed-case name. EVERYWHERE tags it in every # slot; the default admits it only in the part after a # comma, which `_comma.decide` reads after this stage and # tags there when it binds the part (#564, #613), so the # reading carries the same marks under either setting -- # case repair keeps 'XYZ' in 'John Smith, XYZ' # (rules/R4) rather than title-casing a credential the # default admitted. # The setting comes FIRST, then C-level checks of what the # predicate would decline, so the default never calls it # and EVERYWHERE calls it only for an unlisted all-caps # word (that is why this half is a call where the dotted branch # above stays inline: the dotted caller has no such cheap # first conjunct to hide behind). The predicate's own # docstring carries the whole-vocabulary roster and what # each measured entry would have cost unfixed. tags.add(SHAPE_ACRONYM_TAG) tags.add(AMBIGUOUS_ACRONYM_TAG) return frozenset(tags) #: The tags of a word that joins the word after it, which a delimiter #: glued to the word stands between (rules/C10). A particle's are among #: them though it also attaches backward (rules/P6): kept, it chained #: forward across the delimiter ('Berg, de; Smith' read family 'Berg de; #: Smith'), and blocking the forward joins alone would guard every site #: that reads the tag (decisions/C10). _JOINING_TAGS = frozenset({"particle", "conjunction", "vocab:particle-ambiguous", "vocab:bound-given"}) #: What a word read out of the title gives up (rules/H9): the title tag #: and the two that qualify one, which are read only beside it. _TITLE_TAGS = frozenset({"vocab:title", "vocab:given-title", "vocab:after-given-title"}) def classify(state: ParseState) -> ParseState: # One fold per token, shared by the marker pass and the vocabulary # tags -- the shape suffix_as_written already asks for ("n is # _normalize(text), passed in so callers normalize once"). folded = [_normalize(t.text) for t in state.tokens] marker_tags = tag_marker_runs(state.tokens, state.comma_offsets, state.lexicon.maiden_markers, folded) # rules/P3 says a maiden marker, taken as one, and the words it # takes, are not among the name's own words -- so the span and its # clause cut are _pieces.own_words', shared with the site that # needs the same answer two stages earlier (#289/#516). The marker # map goes with it: this stage has already decided which tokens # are run HEADS, so the helper reads that decision rather than # walking the texts again, and classify's answer is the one it was # before the helper existed. own, clause_at = own_words(state.tokens, state.comma_offsets, state.lexicon.maiden_markers, marker_tags) # ONE fact per parse, and it is recorded now (ParseState.one_case): # segment writes it first where C2's flag on a tail part asks, and # a fact two stages decide apart is what recording it prevents # (decisions/S6). one_case = state.one_case if one_case is None: one_case = is_one_case(own) # The fork itself must not read a clause's words either, so the # `one_case and ...` argument below repeats `own`'s membership test # per token, and the fork and its emitter then agree with the case # class they consult. No extra frame -- it is one more boolean in a # comprehension that already walks every token. # rules/T4 asks a word outside the own words about the WHOLE # name's case (`_tags_for`'s `letter_one_case`). Where every token # is an own word the two facts are one, so only a name with a # clause pays the second test's frame. whole_one_case = one_case and ( len(own) == len(state.tokens) or is_one_case([t.text for t in state.tokens])) # rules/C10: "the word keeps the delimiter in its text and reads as # it would without it" -- tagged as the word without it ('MD;' as # 'MD'), so every reading that asks a word's vocabulary asks the # stem's; only where the delimiter "stands after the first comma", # a semicolon before it being a word (decisions/C10, D15) readers = list(state.tokens) stemmed: set[int] = set() if state.comma_offsets: policy_cores = state.policy.extra_suffix_delimiters cores = (BUILTIN_GLUED_DELIMITERS if not policy_cores else glued_cores(policy_cores)) first = state.comma_offsets[0] for i, t in enumerate(state.tokens): text = t.text if t.span.start < first: continue for core in cores: if core in text: stem = glued_stem(text, cores) if stem is not None: readers[i] = copy_with(t, text=stem) folded[i] = _normalize(stem) stemmed.add(i) break tokens = tuple( copy_with( t, tags=_tags_for(readers[i], folded[i], state, marker_tags.get(i), one_case_own=one_case and i < clause_at and t.role is None, one_case=one_case, letter_one_case=whole_one_case or ( one_case and i < clause_at and t.role is None))) for i, t in enumerate(state.tokens)) # A word with a delimiter glued to it joins no neighbour across the # delimiter, so the stem's joining tags stay off it: 'y;' is no # connective to the word after it (rules/C10: "no join, maiden # clause or connective reaches across it") if stemmed: tokens = tuple( copy_with(t, tags=t.tags - _JOINING_TAGS) if i in stemmed else t for i, t in enumerate(tokens)) # rules/H9: "the word is a title only inside a title phrase, and # elsewhere a name word" -- the phrase test (_vocab.title_readings) # asked per part, of the words carrying no role, and only of a part # holding one, so that a name with neither pays no frame. A word read out of the title keeps no # title tag, and every later stage reads it as the name word it is; # a contested word keeps TITLE_AMBIGUOUS_TAG either way, for the # report where it opens the name (assign) lex = state.lexicon if not (lex.titles_ambiguous.isdisjoint(folded) and lex.title_modifiers.isdisjoint(folded)): written = list(tokens) for at, seg in enumerate(state.segments): own_part = [i for i in seg if written[i].role is None] norms = [folded[i] for i in own_part] if (lex.titles_ambiguous.isdisjoint(norms) and lex.title_modifiers.isdisjoint(norms)): continue readings = title_readings( [readers[i].text for i in own_part], norms, lex, head=at == 0, titled=["vocab:title" in written[i].tags for i in own_part]) for i, title in zip(own_part, readings): tags = written[i].tags n = folded[i] if n not in lex.titles_ambiguous and n not in lex.title_modifiers: continue if n in lex.titles_ambiguous and n not in lex.titles: tags = tags | {TITLE_AMBIGUOUS_TAG} if title: # in a title phrase: a title (its given-name title # tag, if any, `_tags_for` wrote already) tags = tags | {"vocab:title"} if not title: tags = tags - _TITLE_TAGS if tags != written[i].tags: written[i] = copy_with(written[i], tags=tags) tokens = tuple(written) # rules/S12: "a credential written split across a space reads as one # word" -- each adjacent pair of a segment asked once, here, and # the answer written on its first word for group's merge and the # comma's reading of its part (`_vocab.split_pair`). The shape and # the fold's membership are asked first, in C, so an ordinary pair # of words pays no frame; a token carrying a role is delimited # content, which no pair reaches. split: dict[int, str] | None = None acronyms = state.lexicon.suffix_acronyms words = state.lexicon.suffix_words for seg in state.segments: for j in range(len(seg) - 1): a, b = tokens[seg[j]], tokens[seg[j + 1]] if (SPLIT_CHUNK.fullmatch(a.text) is not None and SPLIT_CHUNK.fullmatch(b.text) is not None and a.role is None and b.role is None # the opening letter, the length a superset of the # predicate's count of composed letters ('i i' folds # to the listed 'ii' and opens with one) and (len(a.text.rstrip(".")) >= 2 or a.text.endswith(".") and b.text.endswith(".") and (len(b.text) == 2 or not b.text.isascii())) # the Ph./D. pair whatever the vocabulary, as v1's # fix_phd merged it; any other only where its words # are not two suffix words, two credentials already, # and its fold is listed, composed in C where it is # not ASCII and (SPLIT_PAIR_OPENS.fullmatch(a.text) is not None or not ("vocab:suffix" in a.tags and "vocab:suffix" in b.tags) and ((fold := (a.text + b.text).replace(".", "") .lower()) in acronyms or fold in words or not fold.isascii() and ((nfc := unicodedata.normalize( "NFC", fold)) in acronyms or nfc in words))) and (kind := split_pair(a.text, b.text, state.lexicon)) is not None # a member pair a connective borders is no credential: # the connective joins the words beside it and (kind == "suffix" or ( j == 0 or "conjunction" not in tokens[seg[j - 1]].tags) and (j + 2 >= len(seg) or "conjunction" not in tokens[seg[j + 2]].tags)) # the Ph./D. pair outranks a pair overlapping it: # 'Dr. Ph. D.' is no 'Dr. Ph.' ('drph') and a 'D.' and not (kind == "split" and j + 2 < len(seg) and split_pair(b.text, tokens[seg[j + 2]].text, state.lexicon) == "suffix")): if split is None: split = {} split[seg[j]] = (SPLIT_CREDENTIAL_TAG if kind == "suffix" else SPLIT_MEMBER_TAG) if split is not None: # a loop, not a generator, which is a frame per token resumed written = list(tokens) for i, tag in split.items(): written[i] = copy_with(written[i], tags=written[i].tags | {tag}) tokens = tuple(written) ambiguities = list(state.ambiguities) for i, token in enumerate(tokens): # #383/#479: narrows the fork's own "initial" tag with the same # inputs the fork used, rather than re-deciding from scratch. # Only the casedness test is inherited from the tag -- # `is_initial(token.text)` also tags a bare capital "initial" # in the fork's else branch, so the tag alone does not tell # this apart from that. # # Of the two clauses beside it, one is load-bearing and one is # not. `conjunctions_ambiguous` is IMPLIED by the others for a # fresh parse -- the contrapositive of what it looks like: # `is_initial` matches an ASCII capital only, so a bare # LOWERCASE letter is tagged "initial" only by the fork's # branch ("jose e maria santos" tags a lowercase e), for a # conjunctions_ambiguous member, or by the one-case letter # (#721, #723), which never tags an own word that is a # single-letter connective (the fork's branch takes those # first). So for a letter of EITHER case in `conjunctions`, # "initial" plus len 1 implies membership. It is kept only because `_tags_for` # starts from `set(token.tags)`, so # this clause is what keeps the emitter honest if a runner ever # hands classify tokens it did not build; no such path exists # today. `conjunctions` is the clause doing real work: it keeps # an orphan marker (a conjunctions_ambiguous entry no longer in # conjunctions) inert rather than reported (decisions/P8). # `i < clause_at and token.role is None` mirrors the fork's own # "own words" test above, for the same reason (rules/P3): a # clause's words were never eligible for the fork, so they must # never be eligible to report either. Emitted at the decision # site (mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE), per # token -- 'e and e e' emits twice; assemble withdraws the # report on a letter no name word follows (rules/P8, #721). if (one_case and "initial" in token.tags and folded[i] in state.lexicon.conjunctions_ambiguous and folded[i] in state.lexicon.conjunctions and i < clause_at and token.role is None and (len(token.text) == 1 or not token.text.isascii() and composed_len(token.text) == 1)): ambiguities.append(PendingAmbiguity( AmbiguityKind.CONJUNCTION_OR_INITIAL, f"{token.text!r} is both a connective and an initial; " f"the name is written in one case, so nothing marks " f"which, and it is read as an initial", (i,), # the field of the join it would make is assign's to # name, once the name has positions alternative=PendingReading(ReadingKind.CONJUNCTION, ReadingSource.VOCABULARY, Deferred.JOINED))) # The write rides the replace this stage already makes, so # recording the fact costs no frame of its own. return copy_with(state, tokens=tokens, ambiguities=tuple(ambiguities), one_case=one_case) derek73-python-nameparser-456b024/nameparser/_pipeline/_comma.py000066400000000000000000001077771527303271700246400ustar00rootroot00000000000000"""Not a stage: rules/C1's decision, made by group at its head. The comma's structure is decided ONCE, after classify has tagged every word and before group joins any of them (#613): the part after the first comma is read by `_pieces.segment_suffix_reading`, the one reading a part that holds no name word gets, with the words C4's count licenses as credentials behind a whole name. Where it reads as the postnominal part its roles are set here, so no join in group and no walk in assign can reach them, and the comma is a suffix comma when the part before it is a whole name; otherwise the family comma `segment` handed over stands, and assign reads the listing form. Deciding in `segment`, before the tags existed, meant predicting what later stages would do -- group's particle chain (#562's pair), assign's reading of a run the capitals settle -- and assign re-deciding #296's positional read with a second count; reading once and binding is mechanisms.md#READ-WITHOUT-THEN-BIND. Reads: Lexicon suffix, title, particle and ambiguous-class vocabulary through the tags classify wrote and through _vocab.name_word_count and _vocab.is_wholly_suffix; every Lexicon wordlist through _vocab.caps_shape_candidate (to know an all-caps word is unlisted) and _vocab.claimed_as_non_name (the name's contrast); Lexicon .maiden_markers through _pieces.own_words, for the name's contrast; Policy.lenient_comma_suffixes, Policy.extra_suffix_delimiters, Policy.unlisted_caps_suffixes and Policy.unlisted_dotted_suffixes. """ from __future__ import annotations import unicodedata from collections.abc import Sequence from nameparser._lexicon import Lexicon, _normalize from nameparser._pipeline._pieces import ( split_credential_report, has_name_content, is_suffix_piece, is_title_piece, join_connectives, listed_lean, own_words, segment_suffix_reading, settled_by_case, ) from nameparser._pipeline._state import ( AMBIGUOUS_ACRONYM_TAG, AMBIGUOUS_WORD_TAG, SHAPE_ACRONYM_TAG, SPLIT_CREDENTIAL_TAG, SPLIT_MEMBER_TAG, ParseState, PendingAmbiguity, PendingReading, Structure, WorkToken, copy_with, ) from nameparser._pipeline._vocab import ( ROMAN, caps_shape_candidate, claimed_as_non_name, composed_len, delimiter_cores, glued_cores, glued_stem, glues, is_caps_word, is_paired_initials, is_single_letter_numeral, is_wholly_suffix, name_reading, name_word_count, reads_as_generation, suffix_reading, surname_unit_facts, unit_ends, written_as_a_name, ) from nameparser._policy import CapsSuffixes from nameparser._types import AmbiguityKind, Role def _pieces(seg: Sequence[int], tokens: Sequence[WorkToken], splits: bool = True, ) -> tuple[list[list[int]], list[set[str]]]: """One piece per token but a split credential (`split_pair`, read off the tag classify wrote on its first word), which group's first merge builds (group alone declines a pair at the head of the string, rules/S2) -- the part as it stands before any join: the Ph./D. pair tagged "suffix", any other "split", a member of the ambiguous class for rules/C4's count, unless `splits` is False. Every piece a fresh list, as `merge_pieces` requires.""" pieces: list[list[int]] = [] ptags: list[set[str]] = [] k = 0 while k < len(seg): a = seg[k] tags = tokens[a].tags if k + 1 < len(seg) and (SPLIT_CREDENTIAL_TAG in tags or splits and SPLIT_MEMBER_TAG in tags): pieces.append([a, seg[k + 1]]) ptags.append({"suffix" if SPLIT_CREDENTIAL_TAG in tags else "split"}) k += 2 else: pieces.append([a]) ptags.append(set()) k += 1 return pieces, ptags def _reading_pieces(part: Sequence[int], tokens: Sequence[WorkToken], splits: bool = True, ) -> tuple[list[list[int]], list[set[str]]]: """The part as the reading takes it: `_pieces`, then the one join it can hold.""" pieces, ptags = _pieces(part, tokens, splits) # The one join a postnominal part can hold, made here so the reading # sees it: rules/P3's connective joining its neighbours where the # neighbour it takes its kind from is a title, which makes the # joined part a title ('Mr. and Mrs.', 'Secretary of State', 'Dr. # and'), a run of connectives merging first as it does in the name # ('Minister of the Interior'). A LONE single-letter connective is # left as it stands, P3 or P8 reading it as a name word or an initial in # short names; one beside another connective is a member of their # run and joins with it ('Mr. y and Mrs.'). Any other join is not # made here: where no credential opens the part, a name word in it # makes it the given part; where one does, the run #603 opens takes # the connective as a word and reports it ('John Smith, PhD and # Dr.'). Group's own joins, the one loop since #617: its copy here # merged no run and joined 'Minister of the' alone. # # rules/P3: "A connective that is also generational vocabulary # joins only where a name word stands on each side of it" -- and in # a part read for credentials none does, so every such connective # is frozen here, where group freezes only the ones it finds without # a name word beside them ('Rovira, Dr. i i' keeps suffix 'i i' # rather than run-merging the pair into the title, #617); a # neighbour's join may still take one in ('Smith, Dr. and i' reads # title 'Dr. and i'), as `join_connectives` says. # # The same walk says whether there is anything to join, and a part # with no connective left free -- nearly every part -- skips the # shared loop, whose predicate calls cost two frames per word # ('Smith, John' paid two; #617's review). frozen: set[int] = set() free = False for i in part: tags = tokens[i].tags if "conjunction" in tags: if "vocab:suffix" in tags: frozen.add(i) else: free = True if free: join_connectives(pieces, ptags, tokens, letter_stays=True, titles_only=True, frozen=frozen) return pieces, ptags def _unclaimed_name_word(text: str, lexicon: Lexicon) -> bool: """A word written as a name and claimed by no wordlist as anything else -- #564's contrast word and rules/C12's veto, one question. The caller tests for a period first, in C: a word with one is an abbreviation or an initial, not a name word.""" return (written_as_a_name(text) and not claimed_as_non_name(_normalize(text), lexicon)) def _name_contrast(state: ParseState, seg0: Sequence[int], clause_at: int | None = None) -> bool: """#564 (Derek): the contrast the caps shape needs is the NAME's, and the name carries it only if one of its own words before the comma (no maiden clause, no delimited content, as `own_words` defines them) is written the way a name is written in mixed case -- a capital in it and its last letter lowercase (`written_as_a_name`) -- and is not claimed as a title, particle, connective, credential or generation (`claimed_as_non_name`); a word with a period is an abbreviation or an initial, not one. A lone capital ('de GAULLE C'), a lowercase-only word and a surname written in capitals with anything glued in front ("d'ESTAING", 'FitzGERALD', 'McDONALD') all fail it, so such a record keeps its given name beside its lowercase particles, clauses and titles and beside a mixed-case credential ('LLOYD WEBBER, ANDREW PhD'). A part with no lowercase at all is settled in one C-level comparison. `clause_at` is `own_words`' clause index where the caller already holds it.""" before = "".join([state.tokens[i].text for i in seg0]) if before == before.upper(): return False if clause_at is None: _, clause_at = own_words(state.tokens, state.comma_offsets, state.lexicon.maiden_markers) lex = state.lexicon return any( i < clause_at and (tok := state.tokens[i]).role is None and "." not in tok.text and _unclaimed_name_word(tok.text, lex) for i in seg0) def _whole_name(seg0: Sequence[int], tokens: Sequence[WorkToken]) -> bool: """rules/C3's count before the comma for an unambiguous credential, read off classify's tags (the ambiguous class's count of NAME words, which leaves titles out, is `_vocab.name_word_count`'s, asked in `decide`): two or more units (a particle run and the name word it attaches to one unit, a connective join not, #575) holding a word that is not a suffix word -- a title counts, being a word to the count an unambiguous credential takes.""" # rules/C3: "a particle run and the one name word it attaches to are # one word" # the part before a family comma merges no member pair (rules/S12) pieces, ptags = _pieces(seg0, tokens, False) named: set[int] = set() for piece, tags in zip(pieces, ptags): if not is_suffix_piece(piece, tags, tokens): named.update(piece) if len(named) < 2: return False idx = list(seg0) names = 0 start = 0 for end in unit_ends([surname_unit_facts(tokens[i].tags, k == 0) for k, i in enumerate(idx)], chain=False): if not named.isdisjoint(idx[start:end]): names += 1 if names > 1: return True start = end return False def _behind_a_generation(state: ParseState, seg0: Sequence[int], p1: Sequence[list[int]], pt1: Sequence[set[str]], tokens: Sequence[WorkToken], contrast: bool | None, ) -> tuple[bool, ...] | None: """rules/C12's reading of the part after the comma, or None where the listing form stands: behind a whole name ending in a suffix word, or in a numeral the trailing fork reads as the generation, the part is the credential run, its titles (a word or a run a connective joins) reading as titles, unless a word of it is written as a name. The caller has tested the tag, or the numeral shape with `ROMAN`, as a cheap filter. The part's veto is asked first, being the cheap question and the one a listing with a given name answers ('Garcia Lopez Jr., Juan'). `contrast` is C6's answer where `decide` has already asked it, else None.""" # two name words and the generation at least: exact, since # `_whole_name` needs two units in front ('Smith VI, AD') if len(seg0) < 3: return None lex = state.lexicon reading: list[bool] = [] for p, pt in zip(p1, pt1): # a title reads as a title, a word or a run a connective joins # ('Mr. and Mrs.', a dual inside it included); a lone dual of # the title and the suffix vocabulary is the suffix here, the # slot being postnominal ('John Smith Jr., AD RA'): C13's two # predicates, asked in C13's order title = (not is_suffix_piece(p, pt, tokens) and is_title_piece(p, pt, tokens)) for i in p: tok = tokens[i] # rules/C12: "a word of it written as a name, or an # initial, keeps the listing form" -- unlisted, and a word # with a period is an abbreviation, as for the contrast; # paired initials are initials, as C5 keeps them ('J.A.') if "initial" in tok.tags or ( is_paired_initials(tok.text) if "." in tok.text else _unclaimed_name_word(tok.text, lex)): return None reading.append(not title) last = tokens[seg0[-1]].text # a numeral by shape alone (VI to X) is the generation the trailing # fork reads, as the listed ones are (#610's class: 'VI' beside 'V' # and 'III'), and not one it reads as an initial's neighbour # ('John J VI, AD'). C12's evidence is broader than a generation: # any suffix word ('Jr.', 'Esq.') counts, by its tag if ("vocab:suffix-word" not in tokens[seg0[-1]].tags and not reads_as_generation( last, tokens[seg0[-1]].tags, tokens[seg0[-2]].text, state.one_case)): return None # the generation set aside, the name's own words in front of it are # a whole name: 'Smith VI' is one word and a numeral, as 'Smith III' # is, and a maiden clause is no word of the name ('Jane nee Smith # Jr., AD' reads as 'Jane Jr., AD'; own_words, as for the contrast) _, clause_at = own_words(state.tokens, state.comma_offsets, lex.maiden_markers) front = [i for i in seg0[:-1] if i < clause_at and tokens[i].role is None] if not _whole_name(front, tokens): return None # a one-letter numeral is the generation only where the name has a # word to spare for it, a title being none (rules/S10), and is no # evidence where the name reads it as a name word ('Dr. Smith V, # AD', whose title is a word to the count above but none here) if (len(last.rstrip(".")) == 1 and is_single_letter_numeral(last) and name_word_count([tokens[i].text for i in front], lex, state.policy, letters_one_case=state.one_case) < 2): return None if contrast is None: contrast = _name_contrast(state, seg0, clause_at) if not contrast: return None return tuple(reading) def _given_part(token: WorkToken) -> PendingReading: """The name reading of a word this decision bound as a credential: the comma's other branch makes the part the given part, and with every other word of it read as the decision read it, the word is its given name (rules/C1's listing form).""" return name_reading(token, Role.GIVEN) def decide(state: ParseState, splits: bool | None = True) -> ParseState: """The structure of a comma form, and the postnominal part bound. Returns `state` unchanged where the listing form stands. `splits` is for the retry below alone: None reads the part with each split credential merged and asks nothing further, False with none merged.""" if (state.structure is not Structure.FAMILY_COMMA or len(state.segments) < 2): return state # rules/S12: "where the count declines it, the pair is the two words # it is written as" -- the part read with each split credential # merged, and read again as written where the reading did not take # one as a credential, so a declined pair reads as it reads unmerged # (an 'MB' it hid opens the part again, rules/C13). A loop, and the # retry a call of this function only where a pair stands, so a part # holding none pays no frame for it. if splits: heads: list[int] = [] for i in state.segments[1]: if SPLIT_MEMBER_TAG in state.tokens[i].tags: heads.append(i) if heads: out = decide(state, None) if all(out.tokens[i].role is Role.SUFFIX for i in heads): return out return decide(state, False) seg0, seg1 = state.segments[0], state.segments[1] # Nothing after the comma: nothing to read. An EMPTY part BEFORE it # is read on, being no whole name (`_whole_name` answers no): # rules/C15: "the part after it reads as the given part after a # family comma reads" -- so ', PhD' binds its credential as # 'Smith, PhD' does; decisions/C15 (#892), decisions/C1 (#613) if not seg1: return state tokens = list(state.tokens) lex, pol = state.lexicon, state.policy # rules/C10: "a suffix delimiter parts the suffix words on each side # of it as a comma would" -- behind a whole name its cores are # no word of the reading, and leave as group drops them in a tail; # behind one name word the core is a word, v1 having applied the # delimiter to the suffix-comma form alone (C10's core-is-a-word # Accepted entry) cores = delimiter_cores(pol.extra_suffix_delimiters) inner = glued_cores(pol.extra_suffix_delimiters) whole: bool | None = None core_idx: list[int] = [] # a loop, and the head counted only where a core stands alone: the # built-in ';', '-' and '/' leave `cores` non-empty for every # policy (D15, #889) if len(seg1) > 1: for i in seg1: if tokens[i].text in cores: core_idx.append(i) if core_idx: whole = _whole_name(seg0, tokens) if not whole: core_idx = [] # a set for the membership test: a list scanned per token was # quadratic in the part (#613's PR review, 'MD / '*k under '/') core_set = frozenset(core_idx) part = [i for i in seg1 if i not in core_set] if core_idx else list(seg1) if not part: return state if pol.unlisted_dotted_suffixes: # rules/S3: "Paired initials are the exception [...] where S4 # would count words to spare for them they are the name word # they are written as" -- classify holds the class tag back from # them, and at this comma C5 reads them instead (S3's Precedence # bullet), so they rejoin the class for C5's count. Shape-tagged # and untagged with the switch on is exactly that word # (classify's dotted branch), asked of the tags in C # (decisions/S3 (#864)). for i in part: tags = tokens[i].tags if (SHAPE_ACRONYM_TAG in tags and AMBIGUOUS_ACRONYM_TAG not in tags): tokens[i] = copy_with( tokens[i], tags=tags | {AMBIGUOUS_ACRONYM_TAG}) p1, pt1 = _reading_pieces(part, tokens, splits is not False) anchored: list[int] = [] absorbed: list[int] = [] # A core in the part, standing alone or glued to a word or inside # one: either keeps the part from the listing form's early return, # and only a glued one reads behind any head, a core standing alone # being a word behind one name word (C10's Accepted bullet). The # shape tests below read a word with one glued at an end as the word # without it, as classify tags it ('MBBS;' as 'MBBS'). A loop, so # the listing form pays no frame for it. cored = glued = False texts: dict[int, str] = {} for i in part: text = texts[i] = tokens[i].text here = False if text in cores: cored = True else: for core in inner: if core in text: cored = True if glues(core): here = glued = True if here: stem = glued_stem(text, inner) if stem is not None: texts[i] = stem # The words of the class in the part, and the unlisted all-caps # words the caps shape could admit: only a part holding one asks # the count, so 'Smith, John' and 'John Smith, PhD' never build it. # A split credential is a word of the class too (rules/S3), its # piece tag the test. maybe = [p[0] for p, pt in zip(p1, pt1) if "split" in pt or len(p) == 1 and (AMBIGUOUS_ACRONYM_TAG in (tags := tokens[p[0]].tags) or ("vocab:suffix" not in tags and (texts[p[0]].isupper() and (texts[p[0]].isalpha() # the joined spelling ('PU-PH'), the # predicate's other half (rules/S9) or "-" in texts[p[0]] or "/" in texts[p[0]]) or not unicodedata.is_normalized( "NFC", texts[p[0]]) and is_caps_word(texts[p[0]]))))] # rules/C8: "a part that is nothing but suffix words is the # credential run and reads as suffixes, whole" -- the reading below. # The part is read ONCE: here where it holds no word of the class, # after the count below where it does, with the words the count # licensed (a read here and a second after licensing threw the first # away, #613's /simplify). A part holding a word no vocabulary # claims as a suffix, and none of the class, is the given part: the # listing form stands. (a declared delimiter's core may sit inside a # word, 'RN/CRNA', which only the lenient fallback below reads) reading: tuple[bool, ...] | None = None # rules/C12's evidence, filtered by the tag and a C-level match so # the listing form pays no frame for it ('Smith, John' never calls # the helper) generation = bool(seg0) and ( "vocab:suffix-word" in tokens[seg0[-1]].tags or ROMAN.match(tokens[seg0[-1]].text.rstrip(".")) is not None) behind_generation = listing = False contrast: bool | None = None if not maybe: reading = segment_suffix_reading( p1, pt1, tokens, pol.lenient_comma_suffixes, state.one_case, anchored, absorbed) # a word no vocabulary claims as a suffix: the listing form, # unless C12 reads it below, which skips C1's fallback for it listing = reading is None and not all( "suffix" in t or "vocab:suffix" in tokens[p[0]].tags for p, t in zip(p1, pt1)) # a part holding a delimiter core ('MD;', 'MD ; PhD') is left # to C1's run test below, which reads a core alone as no word # and a word with one glued as the word without it (rules/C10) if cored: listing = False if listing and not generation: return state names = 0 caps: set[int] = set() licensed: set[int] = set() run = False if maybe: names = name_word_count([tokens[i].text for i in seg0], lex, pol, letters_one_case=state.one_case) if names >= 2: # rules/C6: "An unlisted all-caps word joins the ambiguous # credential class [...] only where the name carries the contrast" for i in maybe: text = texts[i] if SHAPE_ACRONYM_TAG in tokens[i].tags: # tagged by classify (EVERYWHERE); the dotted shape has # a period if "." not in text: caps.add(i) continue if (AMBIGUOUS_ACRONYM_TAG not in tokens[i].tags and pol.unlisted_caps_suffixes is not CapsSuffixes.OFF and caps_shape_candidate(text, lex, pol, one_case=False)): if contrast is None: contrast = _name_contrast(state, seg0) # a two-capital word is how initials are written: the # paired-initials rule below discards one nothing speaks # for, a lone one included ('John Smith, XY') if contrast: caps.add(i) # rules/C4: "The same count reads a part of two or more words # as the credential run when every word of it is a suffix word # or a word of this class", none of them a single-letter numeral. # the words not of the class are asked as C1's suffix test asks # them, the lenient word test by default ('Ma B.') others: list[str] = [] for p, pt in zip(p1, pt1): if ("split" in pt or len(p) == 1 and (AMBIGUOUS_ACRONYM_TAG in tokens[p[0]].tags or p[0] in caps)): licensed.add(p[0]) else: # a loop, not a comprehension: a listcomp is a frame # per piece on 3.11 for i in p: others.append(tokens[i].text) run = (bool(licensed) and (not others or is_wholly_suffix( others, lex, pol, letters_one_case=state.one_case)) and not any(len(p) == 1 and is_single_letter_numeral(texts[p[0]]) for p in p1)) if not run: licensed.clear() else: # rules/C5: "Paired initials are the exception to the [...] # count" -- "Only an unambiguous suffix word in front of # them that is not also title vocabulary, or another word # the class admits by its dotted shape standing in the same # part, makes them the credential run" shape = {i for i in licensed if i in caps or SHAPE_ACRONYM_TAG in tokens[i].tags} # Where the first such suffix word stands, found once and # only when a pair asks: a pair is spoken for exactly when # it stands behind that word, so one scan answers every # pair. Asked per pair, the scan re-walked every word in # front of each one, quadratic in the part ('John Smith, ' + # 'RA '*k + 'G.J. '*k, the duals never ending it; #563's # shape, back in #613 and caught in review) speaker = -1 for k, p in enumerate(p1): i = p[0] if len(p) != 1 or i not in licensed: continue text = texts[i] if not (is_paired_initials(text) or (i in caps and composed_len(text) == 2)): continue if speaker < 0: speaker = next( (j for j in range(len(p1)) if is_suffix_piece(p1[j], pt1[j], tokens) and not is_title_piece(p1[j], pt1[j], tokens)), len(p1)) # another shape word than this one: no set built per # pair (a copy of `shape` each time was quadratic in C) if not (speaker < k or len(shape) > 1 or (shape and i not in shape)): licensed.discard(i) caps.discard(i) if maybe: reading = segment_suffix_reading( p1, pt1, tokens, pol.lenient_comma_suffixes, state.one_case, anchored, absorbed, licensed, head_named=names >= 2) if not listing: if whole is None: whole = _whole_name(seg0, tokens) # rules/C1: "By default a recognized suffix word counts even # written like an initial" -- behind a whole name ('John Smith, V.') # rules/C10: "A delimiter glued to a suffix word parts it from # the next as a comma glued there would" -- the part "reads as # it does with a space in the delimiter's place", behind any # head, so 'Smith, MD; PhD' reads as 'Smith, MD PhD' does; behind # one that is not a whole name by the strict test, where "A # glued word written like an initial is the given name" # (decisions/C10, D15) if reading is None and (whole or glued) and is_wholly_suffix( [tokens[i].text for i in part], lex, pol, one_case=None, letters_one_case=state.one_case, strict=not whole): reading = (True,) * len(p1) # the words of the part C12 reads as suffixes, which its report # names: a title in the part, a word or a joined run, is no # credential it took taken: list[int] = [] if reading is None and generation: reading = _behind_a_generation(state, seg0, p1, pt1, tokens, contrast) if reading is not None: behind_generation = True for p, suffix in zip(p1, reading): if not suffix: continue for i in p: taken.append(i) text = texts[i] # #564's marks on an unlisted all-caps word, as the # caps shape writes them, so case repair keeps 'AD' # (rules/R4); the C-level filter `maybe` uses first if ("vocab:suffix" not in tokens[i].tags and (text.isupper() and (text.isalpha() or "-" in text or "/" in text) or not unicodedata.is_normalized( "NFC", text) and is_caps_word(text))): caps.add(i) if reading is None: return state # rules/C7: "that reading is final" -- the part is the postnominal # part, bound here whatever stands before the comma. The comma is a # suffix comma behind a whole name; behind one name word it stays # the family comma naming the family. ambiguities = list(state.ambiguities) for k, piece in enumerate(p1): role = Role.SUFFIX if reading[k] else Role.TITLE for i in piece: tags = tokens[i].tags if i in caps: # #564: the caps shape's marks, as classify writes them # EVERYWHERE, so case repair keeps 'XYZ' (rules/R4) tags = tags | {SHAPE_ACRONYM_TAG, AMBIGUOUS_ACRONYM_TAG} tokens[i] = copy_with(tokens[i], role=role, tags=tags) # rules/C11: "A decision either way at the comma C1 reads is reported; # for a run of words the decision is the flip to the credential # run, reported once over the whole part" -- its Then, which this # implements, with rules/C11's Then bullet "A run the count leaves # in the listing form reports as S7 reads the words in it" and # rules/C11's Except bullet "A flip in which no listed word of the # ambiguous credential class (C4) takes part is the exception and # is made in silence" -- "unless what said so is nothing but other # paired initials", the clause that bullet ends on # (decisions/C11 (#544)). A run whose every class word is listed and settled by # its capitals is read on that evidence, not flipped by the count. # A caps word bound above carries the class tags now, so membership # is the tag alone; a part with no word of the class has none, and # skips the bookkeeping (three list comprehensions, a frame each on # 3.11, on every bound part) members: list[int] = [] listed: list[int] = [] settled = pair_only = False if maybe: members = [p[0] for p in p1 if len(p) == 1 and AMBIGUOUS_ACRONYM_TAG in tokens[p[0]].tags] listed = [i for i in members if SHAPE_ACRONYM_TAG not in tokens[i].tags] settled = (bool(members) and len(listed) == len(members) and all(listed_lean(tokens[i], state.one_case) == "credential" and (names >= 2 or AMBIGUOUS_WORD_TAG not in tokens[i].tags) for i in listed) # rules/S6: "a clash settled by the writing is not # reported" -- a lone word only where it is an # acronym its capitals settle and (len(p1) > 1 or settled_by_case(tokens[listed[0]], state.one_case))) member_set = frozenset(members) pairs = [k for k, p in enumerate(p1) if p[0] in member_set and (is_paired_initials(texts[p[0]]) or (p[0] in caps and composed_len(texts[p[0]]) == 2))] # spoken for by each other only: every class word a pair, and # only titles among the other words in front of the first pair_only = (len(pairs) >= 2 and len(pairs) == len(members) and all(p[0] in member_set or is_title_piece(p, pt, tokens) for p, pt in zip(p1[:pairs[0]], pt1))) part_reported = behind_generation if behind_generation: # rules/C11: "A decision either way at the comma C1 reads is # reported" -- here the suffix word before the comma decided it, # not C4's count, which C12 does not consult ambiguities.append(PendingAmbiguity( AmbiguityKind.SUFFIX_OR_NAME, f"{' '.join(tokens[i].text for i in taken)!r} after the comma " f"follows {tokens[seg0[-1]].text!r}, so it is read as a " f"credential run; it may be a name", tuple(taken), alternative=_given_part(tokens[taken[0]]))) elif whole and run and ((listed and not settled) or caps or pair_only): part_reported = True if len(part) == 1: what = (f"{tokens[part[0]].text!r} after the comma is also an " f"ordinary name word") else: what = (f"{' '.join(tokens[i].text for i in part)!r} after the " f"comma holds a word that is also an ordinary name word") ambiguities.append(PendingAmbiguity( AmbiguityKind.SUFFIX_OR_NAME, f"{what}; the part before the comma holds {names} name words, " f"so it is read as a credential run", tuple(part), alternative=_given_part(tokens[part[0]]))) elif (len(p1[0]) == 1 and p1[0][0] in listed and not (reading[0] and settled_by_case(tokens[p1[0][0]], state.one_case))): # the first word after the comma reports as it always does, # naming the role bound above: the listing form binds a word # the reading declines as a title ('John, Si.', #793), and no # later rule moves a word of the emptied part; one its capitals # settled as the credential does not i = p1[0][0] field = "a credential" if reading[0] else "a title" ambiguities.append(PendingAmbiguity( AmbiguityKind.SUFFIX_OR_NAME, f"{tokens[i].text!r} after the comma is also an ordinary name " f"word; read as {field}", (i,), # the fork is credential or name: a word the reading # declined is bound as a title, and its other branch is the # credential alternative=(_given_part(tokens[i]) if reading[0] else suffix_reading(tokens[i])))) # rules/S7: "A member the company decides reports the fork as a # counted pick does" -- reported where it decided: a member the # anchor read as a credential after its own writing declined (#544) for k in anchored: i = p1[k][0] ambiguities.append(PendingAmbiguity( AmbiguityKind.SUFFIX_OR_NAME, f"{tokens[i].text!r} behind a credential after the comma is " f"also an ordinary name word; read as a credential", (i,), alternative=_given_part(tokens[i]))) # rules/C13: "A credential opening the part after a family comma # makes it the postnominal part however it goes on" -- each word it takes that # would have made it the given part, as #602's run reports them for k in absorbed: # a split credential the run takes reports itself, below if has_name_content(p1[k], tokens) and "split" not in pt1[k]: text = " ".join(tokens[i].text for i in p1[k]) ambiguities.append(PendingAmbiguity( AmbiguityKind.SUFFIX_OR_NAME, f"{text!r} follows a credential, so it reads as part of the " f"suffix run; it may be a name word", tuple(p1[k]), alternative=_given_part(tokens[p1[k][0]]))) # rules/S12: "where the count takes the pair, the reading is reported" # -- C4's count took it, unless the flip it took part in reported # the part if not part_reported: for k, piece in enumerate(p1): if "split" in pt1[k] and reading[k]: ambiguities.append( split_credential_report(piece, tokens, _given_part(tokens[piece[0]]))) # Every word of the part is bound above or dropped as a core, so the # segment hands group nothing to join -- emptied here, where the # binding is, as #601's take removes what it binds, rather than # filtered out again in group (#613's /simplify) return copy_with(state, tokens=tuple(tokens), segments=state.segments[:1] + ((),) + state.segments[2:], structure=(Structure.SUFFIX_COMMA if whole or behind_generation else Structure.FAMILY_COMMA), ambiguities=tuple(ambiguities), dropped=tuple(state.dropped) + tuple(core_idx)) derek73-python-nameparser-456b024/nameparser/_pipeline/_extract.py000066400000000000000000000351201527303271700251740ustar00rootroot00000000000000"""Stage: extract_delimited. Consumes: ParseState.original. Produces: extracted (role + inner span per delimited region), masked (full regions incl. delimiter chars, skipped by tokenize), UNBALANCED_DELIMITER ambiguities for opens with no close. A Role.MAIDEN region is the WHOLE inner span, marker included (all of it, a phrase marker being several words) -- nothing here strips one. classify tags a marker inside it like any other token, and group drops it from a multi-token clause (#329). A region reaches that role two ways: the pair that matched sits in Policy.maiden_delimiters (M1), or the content itself opens with a marker word (M3), which reassigns the role after the match and so is the one thing here that a bucket alone does not decide. Reads: Policy.nickname_delimiters, Policy.maiden_delimiters, Lexicon.maiden_markers, and Lexicon.suffix_words / suffix_words_ambiguous / suffix_acronyms / suffix_acronyms_ambiguous through _suffix_shaped. Implements rules N1, N2, S1, M1 and M3 of docs/design/rules/ (the #273 matching mechanism); each is cited at its code below. One scan mechanic worth stating up front: matching is one left-to-right pass, no nesting, and delimiter characters inside a matched region are literal content for every other pair. Bucket precedence is NOT decided here: Policy canonicalizes overlap away before parsing, so the two buckets are always disjoint by the time this stage runs. The nickname-before-maiden candidate order below is only a same-position tie-break for exotic configs where two pairs share an OPEN character. """ from __future__ import annotations import bisect import functools from nameparser._lexicon import Lexicon, _normalize from nameparser._pipeline._state import ( COMMA_CHARS, ParseState, PendingAmbiguity, copy_with, ) from nameparser._pipeline._vocab import ( comma_follows_marker, maiden_marker_run, ) from nameparser._types import AmbiguityKind, Role, Span # rules/S1: "an enclosed clause whose content is suffix-shaped is # not a nickname: the brackets or quotes are dropped and the content # reads exactly as if written bare" def _suffix_shaped(content: str, lexicon: Lexicon) -> bool: """v1 parse_nicknames' escape (parser.py:1125-1141): an unambiguous suffix_words member (edge-normalized), an unambiguous acronym (period-free form), or anything ending in a period. No initial veto -- v1 deliberately skipped it here.""" stripped = _normalize(content) acronym = stripped.replace(".", "") return ((stripped in lexicon.suffix_words and stripped not in lexicon.suffix_words_ambiguous) or (acronym in lexicon.suffix_acronyms and acronym not in lexicon.suffix_acronyms_ambiguous) or content.endswith(".")) # rules/M3: "a bracketed clause whose content opens with a # recognized marker and carries a word after it reads as the # maiden name, whichever bucket the enclosing pair sits in" def _maiden_marked(content: str, lexicon: Lexicon) -> bool: """The clause says 'maiden' out loud, so the caller does not have to say it in Policy. Requires a word AFTER the marker: a lone marker in brackets is a word in brackets, and M1 deliberately keeps a one-word clause's word (it may be the surname Nee). A PHRASE marker is one marker, so the word after is the word after the whole run -- '(z domu Nowak)' has one, '(z domu)' has none. The word after is not tested for anything, which is rules/M3's Accepted bullet "The word taken after the marker is not tested" (decisions/M3) and the reason a bracketed '(née PhD)' reads maiden 'PhD' where the bare 'née PhD' gives M2 a suffix. This stage runs before tokenize, so it calls maiden_marker_run itself rather than reading the tags classify will set. The two questions are deliberately not the same one: this splits on WHITESPACE, so a marker the writer glued to punctuation is not one here ('née,') -- nor to classify, since #653 keeps a clause's comma on its word. A nakaguro still divides the two: the tokenizer splits '旧姓・田中' at it (rules/T2) and classify tags '旧姓', which is what keeps _group's Role.MAIDEN filter reachable (cases.py's marker_divided_by_a_nakaguro_keeps_the_clause_a_nickname). Sharing the PREDICATE does not merge the two questions -- each hands it a different sequence of words.""" words = content.split() # A one-word clause cannot satisfy the condition whatever the # vocabulary says -- `run > 0 and 1 > run` is unsatisfiable -- so # refuse it before any fold or lookup, as the pre-#434 words[0] # test did for free. if len(words) < 2: return False run = maiden_marker_run(words, lexicon.maiden_markers) return (run > 0 and len(words) > run and not comma_follows_marker(words[run])) # rules/N2: "a quote whose open and close are the same character # opens only at a word start and closes only at a word end, so an # apostrophe inside or at the end of a word is literal" def _open_ok(text: str, i: int) -> bool: return i == 0 or text[i - 1].isspace() def _close_ok(text: str, j: int, width: int) -> bool: k = j + width return k >= len(text) or text[k].isspace() or text[k] in COMMA_CHARS # Delimiters that also occur INSIDE and at the end of real name parts, # so a dangling one is literal rather than unbalanced. Only the straight # apostrophe qualifies: quotes do not appear inside names, so the same # position with a quote genuinely is ambiguous. #273 dropped the curly # apostrophe from the defaults outright for this reason; the straight # one has to stay a delimiter (v1's quoted_word), so it is carved out # here instead. Deliberately not widened to a configured delimiter set. WORD_INTERNAL_DELIMITERS = frozenset({"'"}) def _word_internal(text: str, j: int, close: str) -> bool: """A word-internal delimiter directly after a word character is part of the word, not a dangling close -- "Mari' Aube'", "Ali Baba'".""" return (close in WORD_INTERNAL_DELIMITERS and j > 0 and (text[j - 1].isalnum() or text[j - 1] == ".")) def _overlaps(span: Span, taken: list[Span], starts: list[int]) -> bool: """`taken` sorted and non-overlapping (both hold by construction), `starts` its start offsets. Bisect rather than scan: the closer sweep tests one span per delimiter character found, so a linear probe is quadratic in the number of matched pairs (400 pairs spent 5.4ms here, against 2.5ms before the sweep existed). Same idiom, and the same reason, as the origin resolution in _tokenize.""" i = bisect.bisect_right(starts, span.start) - 1 # the only candidates are the last span starting at or before us and # its successor -- anything earlier ends before it, anything later # starts after us for k in (i, i + 1): if 0 <= k < len(taken) and span.start < taken[k].end and ( taken[k].start < span.end): return True return False @functools.lru_cache(maxsize=128) def _delimiter_chars( nickname_pairs: frozenset[tuple[str, str]], maiden_pairs: frozenset[tuple[str, str]], ) -> frozenset[str]: """Every character appearing in any configured delimiter, cached on the (hashable) policy frozensets: the common no-delimiter name pays one isdisjoint() instead of a per-pair scan.""" return frozenset( ch for pairs in (nickname_pairs, maiden_pairs) for pair in pairs for part in pair for ch in part ) def _unmatched(open_: str, offset: int) -> tuple[int, PendingAmbiguity]: return (offset, PendingAmbiguity( AmbiguityKind.UNBALANCED_DELIMITER, f"unmatched {open_!r} at offset {offset}; treated as literal text", origin=offset, )) # rules/N1: "a clause enclosed by a configured nickname delimiter # pair reads as the nickname and is lifted out of the name" -- Then, # and the empty enclosure it drops; its punctuation is tokenize's half # of the rule (decisions/N1) # rules/M1: "its enclosed clause reads as the maiden name" -- Then, # the pair being one the caller configured for maiden names; the # marker drop is group's half (decisions/M1) def extract_delimited(state: ParseState) -> ParseState: text = state.original policy = state.policy if _delimiter_chars(policy.nickname_delimiters, policy.maiden_delimiters).isdisjoint(text): return state # Candidate order matters only as a same-position tie-break (see # module docstring); the scan itself is position-driven. order = tuple( (role, open_, close) for role, pairs in ((Role.NICKNAME, policy.nickname_delimiters), (Role.MAIDEN, policy.maiden_delimiters)) for open_, close in sorted(pairs) ) extracted: list[tuple[Role, Span]] = [] masked: list[Span] = [] # candidates, not final: each carries the offset of the unmatched # open so ones consumed by a later match can be filtered at the end unbalanced: list[tuple[int, PendingAmbiguity]] = [] # per-candidate cursor cache: next boundary-valid open at or after # the position it was computed for. find() calls only ever move # forward, keeping the whole scan linear in len(text) per pair. cursors: dict[tuple[Role, str, str], int] = {} exhausted: set[tuple[Role, str, str]] = set() pos = 0 while pos < len(text): best: tuple[int, Role, str, str] | None = None for key in order: if key in exhausted: continue _, open_, close = key i = cursors.get(key, -2) if i != -1 and i < pos: i = text.find(open_, pos) while (i != -1 and open_ == close and not _open_ok(text, i)): i = text.find(open_, i + 1) cursors[key] = i if i != -1 and (best is None or i < best[0]): best = (i, *key) if best is None: break i, role, open_, close = best j = text.find(close, i + len(open_)) while (open_ == close and j != -1 and not _close_ok(text, j, len(close))): j = text.find(close, j + 1) if j == -1: # No (boundary-valid) close exists anywhere to the right -- # the walk above ran to end of text -- so every remaining # open of this pair is unmatched too. Record them all in # one forward pass and retire the pair; other pairs keep # scanning from the same position. unbalanced.append(_unmatched(open_, i)) scan = i + len(open_) while (k := text.find(open_, scan)) != -1: if open_ != close or _open_ok(text, k): unbalanced.append(_unmatched(open_, k)) scan = k + 1 exhausted.add((role, open_, close)) continue inner = Span(i + len(open_), j) if inner.start < inner.end and _suffix_shaped( text[inner.start:inner.end], state.lexicon): # v1 parse_nicknames: suffix-shaped delimited content is # left IN PLACE (undelimited) for normal downstream parsing # -- 'Andrew Perkins (MBA)' keeps MBA a suffix, not a # nickname. Spans index the original (anti-#100), so the v2 # spelling masks only the two delimiter spans and lets the # inner content join the main token stream. masked.append(Span(i, i + len(open_))) masked.append(Span(j, j + len(close))) else: if inner.start < inner.end: # M3 upgrades a nickname clause; a configured maiden # pair is M1's and is left alone. The role test is # False whenever a maiden pair matched, but it cannot # change the OUTCOME, and no test can catch its # removal: `order` above holds exactly two roles, so a # role that is not NICKNAME is already MAIDEN and the # assignment would be a no-op either way. It is kept # for the day `order` gains a third bucket, when it # becomes the difference between M3 claiming that # bucket's clauses and leaving them. Measured # 2026-08-26: dropping it leaves the suite and all # three gates green. if (role is Role.NICKNAME and _maiden_marked( text[inner.start:inner.end], state.lexicon)): role = Role.MAIDEN extracted.append((role, inner)) masked.append(Span(i, j + len(close))) # position-driven scanning makes overlapping matches # impossible by construction: every later open is found at or # after this match's end pos = j + len(close) extracted.sort(key=lambda pair: pair[1]) masked.sort() # An unmatched-open candidate whose character was consumed by a # later successful match (the bulk pass above runs ahead of the # main scan) is literal content there, not a dangling delimiter. # offsets already claimed as unbalanced, by an open above or by a # close in the sweep below -- either way, do not report one twice reported = {offset for offset, _ in unbalanced} mask_starts = [s.start for s in masked] ambiguities = [ a for offset, a in unbalanced if not _overlaps(Span(offset, offset + 1), masked, mask_starts)] # The scan above is opener-driven: it searches for an open and then # looks rightward for its close, so a close with no open to its # LEFT is never in its search space. Sweep for those separately -- # they signal the same malformed input, and the kind's contract has # always covered them ("opened without closing, or closed without # opening"). Same boundary test as the matched path, which is what # keeps the apostrophe in "O'connor" out of it. # Distinct closes only: the defaults list '”' twice (from both # ('“','”') and ('”','”')), and a repeat can only rediscover # offsets the first pass already handled. for close in sorted({c for _, _, c in order}): start = 0 while (j := text.find(close, start)) != -1: start = j + 1 if (j in reported or not _close_ok(text, j, len(close)) or _word_internal(text, j, close) or _overlaps(Span(j, j + len(close)), masked, mask_starts)): continue reported.add(j) ambiguities.append(_unmatched(close, j)[1]) return copy_with( state, extracted=tuple(extracted), masked=tuple(masked), ambiguities=state.ambiguities + tuple(ambiguities)) derek73-python-nameparser-456b024/nameparser/_pipeline/_group.py000066400000000000000000002447261527303271700246740ustar00rootroot00000000000000"""Stage: group. Consumes: tokens (classified), segments, structure, one_case, extracted (the role + inner span per delimited region, for the #329 pass below; post_rules reads it too, for S13); and, through `_comma.decide`, comma_offsets (the own-words span of the name's case contrast) and dropped, which decide extends. Produces: structure -- its head decides a comma form once the words are tagged (rules/C1, #613, `_comma.decide`), binding the SUFFIX and TITLE roles of a postnominal part after the comma, emptying its segment and dropping its delimiter cores before any join, plus the SUFFIX_OR_NAME ambiguities that decision reports, those the particle chain and a maiden clause report on a trailing member they take into the name (an ambiguous acronym, or since #718 a declined numeral), and the caps shape's acronym tags on a bound all-caps word (#564); pieces + piece_tags per segment (runs of token indices -- tokens are NEVER joined into strings: the anti-#100 invariant); maiden tail tokens get role=MAIDEN, and the trailing run a maiden take gives up gets its SUFFIX and TITLE roles here (#601), so no join can reach it; marker tokens land in dropped; and tail_reads, rules/S2's trailing run of each main segment read ONCE, after P3's connective joins and before the particle chain and the bound-given join, and split off so neither reaches it (#614), which assign places and reports rather than peeling again. Reads: token tags (from classify), Lexicon.given_name_titles (the P5 licence, #369) and Policy.extra_suffix_delimiters, whose delimiter-core tokens part a tail segment as a comma would and are dropped (v1 suffix_delimiter parity, #549), as the built-in cores' are (rules/C10, #889); and, through `_comma.decide`, what that module's own header lists (Policy.lenient_comma_suffixes and Policy.unlisted_caps_suffixes among them). The v1 "derived titles/prefixes" registration becomes piece_tags entries -- per-parse state that dissolves with the state (v1 kept per-parse sets for the same reason). Implements rules P2, P3, P4 and M2, S2's trailing read (#614), C1's decision through _comma, and the group half of M1 (#329: the marker dropped inside EXTRACTED maiden content, which M2's pieces walk cannot reach because extract's content never enters pieces); each is cited at its code below. Also implements rule P5 (cited below at the bound-given join) and ports the "Ph. D."-split merge (v1 fix_phd; decisions/phd-merge). The piece-level predicates moved to _pieces in #439 -- the S2 trailing peel, the leading-title and title-piece tests, the suffix-piece test, the no-name-segment test. Most are shared with assign; trailing_start is group's alone and travelled because the shared ones call it (is_title_piece was too, until #602 and #603 gave assign calls of its own). They had collected here by import direction rather than by topic (assign imported group and could not be imported back), which is the accumulation mechanisms.md#ONE-PREDICATE-PER-QUESTION describes; group imports them back like any other caller, and still does the work H3 and S2 describe with them. is_prefix_piece and is_conj_piece followed in #617, with the merge and rules/P3's connective joins (join_connectives), when the comma decision's own copy of those joins was folded into them. What remains defined here is group's own: _is_rootname and _is_maiden_marker_piece. """ from __future__ import annotations import bisect from collections.abc import Iterable, Sequence, Set from enum import IntEnum from typing import assert_never from nameparser._lexicon import _run_addresses_by_given from nameparser._pipeline._pieces import ( split_credential_report, Peel, TailRead, chain_lead, chain_run_end, is_conj_piece, is_leading_title, is_prefix_piece, is_suffix_piece, is_title_piece, join_connectives, joined_tags, leading_titles, merge_particle_runs, merge_pieces, peel_walk, read_trailing_run, settled_by_case, tail_reading, trailing_candidates, trailing_run_start, trailing_start, trailing_start_past_titles, ) from nameparser._pipeline._state import ( AMBIGUOUS_ACRONYM_TAG, SPLIT_CREDENTIAL_TAG, SPLIT_MEMBER_TAG, ParseState, Deferred, PendingAmbiguity, Structure, WorkToken, _AMBIGUOUS_CREDENTIAL_TAGS, copy_with, ) import nameparser._pipeline._comma as _comma from nameparser._pipeline._vocab import ( comma_follows_marker, delimiter_cores, name_reading, reads_as_a_reported_numeral, suffix_reading, ) from nameparser._types import AmbiguityKind, Role # the split credential is `_vocab.split_pair`, which classify asks of # every adjacent pair and writes on the pair's first word, and which # is_wholly_suffix asks to merge the same Ph./D. pair, as # `_comma._pieces` reads the tag -- so that predicate's callers read # what this stage merges: `_comma.decide`'s reading of the part after # the comma, segment's C2 flag on a tail part, and script_segment's # decline of a wholly-suffix post-comma run, all of which see the # merged reading Piece = list[int] #: What the marker pass took out of a segment: the marker's TOKEN #: indices (more than one where the entry is a phrase, 'z domu'), and #: every other piece it took with the role that piece takes -- the #: maiden name's Role.MAIDEN, and the trailing run's SUFFIX or TITLE #: (#601). `_group_segment` produces it. MaidenTake = tuple[Piece, list[tuple[Piece, Role]]] #: What `_maiden_take` answers with, one index space further out: PIECE #: indices into its own `pieces` argument. `_group_segment` resolves #: them to the token indices MaidenTake declares, which is the only #: reason the two are different types rather than one name used twice. MaidenIndices = tuple[list[int], list[tuple[int, Role]]] class ClauseSite(IntEnum): """Where a maiden clause stands, which decides whether a marker counts there at all and which rule reads the end of the part (rules/M2, #601). A marker counts only in the part holding the family name, so GIVEN_SLOT and TAIL are statements, not defaults: a marker there is an ordinary word. FAMILY_PART is the part before a family comma, where a marker counts but no trailing rule reads the words after the clause. A CLOSED set: `_maiden_take` dispatches on it exhaustively, ONCE (`typing.assert_never`). group() is the one place (structure, segment index) is mapped onto it, and tests/v2/pipeline/ test_group.py's `test_the_site_is_pinned_to_the_structure_it_is_ read_from` pins that mapping -- by watching the call, not by restating it -- along with this enum's size. """ FAMILY_PART = 0 # FAMILY_COMMA segment 0: a marker counts, nothing trails TRAILING = 1 # NO_COMMA, SUFFIX_COMMA segment 0: S2 peel + H5 chain GIVEN_SLOT = 2 # FAMILY_COMMA segment 1: a marker is an ordinary word TAIL = 3 # every tail segment: a marker is an ordinary word class BoundJoin(IntEnum): """v1 _join_bound_first_name's reserve_last, as the three states it actually has. IntEnum: the value IS the number of name pieces assign's peel must leave in the JOINED view for the join to stand (#425), so the >= comparison below reads unchanged. Post-comma the reserve runs no peel -- the pair alone is that one piece; the decline reads H5's trailing run only where a title word follows the bound word (#637) -- and DISABLED is a mode, never compared: as a threshold 0 would join everything, which is why the block is entered on identity first.""" DISABLED = 0 # the FAMILY_COMMA family segment (v1 never joined it) LENIENT = 1 # FAMILY_COMMA's post-comma segment (reserve_last=False) STRICT = 2 # main segments (reserve_last=True: keep a family piece) # rules/M2: "A marker that counts takes the words after it as the # maiden name, and is itself dropped" -- where a marker counts, and up # to what, are `_maiden_take`'s (history: decisions/M2) # # A marker piece is a LONE marker -- M2's When ("a recognized maiden # marker stands as a word in a name"). The consumer runs before every join but the Ph. D. merge # (see _group_segment), so a marker inside a wider piece is one the # consumer left there: declined ("Jane van der Berg née" reads family # 'van der Berg née'), or never examined (a leading marker, or a second # marker behind the span it took). Matching inside joined pieces would # re-read those. # # With the pass ahead of the joins no default-vocabulary input reaches # the lone-piece half through the one caller left that sees joined # pieces (P5's marker decline, on `fk + 1`): a marker-headed wider # piece needs a connective right after a declined marker, and a # connective after a marker is a word the consumer takes. Measured at # #420's review -- # dropping `len(piece) == 1` leaves the suite and a 337k-name sweep # identical -- so it stays as the rule's definition, not as a guard a # pin holds. def _is_maiden_marker_piece(piece: Sequence[int], tokens: Sequence[WorkToken]) -> bool: return (len(piece) == 1 and "vocab:maiden-marker" in tokens[piece[0]].tags) # mechanisms.md#ONE-PREDICATE-PER-QUESTION: "where the reader comes # AFTER the decider, record the answer on the state instead" -- which is # what the marker tags are. classify owns the phrase lookahead; group, # running later, reads what it wrote. def marker_run_length(following: Iterable[Set[str]]) -> int: """How many tokens a marker run spans, given the tag sets of what FOLLOWS its head, in order: 1 plus the leading continuations. One walk for both places group asks -- over pieces below and over token indices in the clause drop at the end of this module, 700 lines apart and differing only in index space. `following` is consumed lazily and only until the run ends, so a caller passes a generator over the rest of its sequence rather than materializing one.""" run = 1 for tags in following: if "vocab:maiden-marker-cont" not in tags: break run += 1 return run def _marker_run_pieces(pieces: Sequence[Sequence[int]], tokens: Sequence[WorkToken], m: int) -> int: """How many pieces the marker run at pieces[m] spans: 1 for a single-word marker, more for a phrase entry ('z domu'). classify already decided where the run ends and recorded it on the tokens -- "vocab:maiden-marker" on the head, "vocab:maiden-marker-cont" on the rest. Each continuation is the NEXT piece, and that holds because classify REFUSES to tag a run whose tokens are not structurally contiguous. It is not a property of this walk, and the reasons a token can be absent between two pieces are wider than they look. No join has run yet, so a piece is one token. A tail segment is cut at its delimiter cores and grouped part by part (#549), and a core between two marker words would be a token between them, which classify would not have tagged as a run. And `pieces` comes from a SEGMENT, or a part of one, and segment keeps only the tokens no stage has given a role, bucketed by the commas before them -- so a run half inside a bracketed clause, or split across a structure comma, is one no segment holds whole. Walking cont tags without classify's refusal read a proper PREFIX of the phrase as the whole marker, and 'Anna z (domu) Nowak' lost its given name to a bare preposition. Any future stage that removes a token from a segment owes this the same refusal. What classify guarantees is exactly the main stream's half of that and no more: a run of ROLE-LESS tokens stays inside one segment. It can also tag a run across two adjacent clauses of the same role, which this walk never sees because a role-bearing token is in no segment at all -- see _vocab.tag_marker_runs, which states the limit. """ return marker_run_length( tokens[pieces[k][0]].tags for k in range(m + 1, len(pieces))) def _maiden_take(pieces: Sequence[Sequence[int]], ptags: Sequence[Set[str]], tokens: Sequence[WorkToken], one_case: bool | None, site: ClauseSite, ambiguities: list[PendingAmbiguity], ) -> MaidenIndices | None: """The PIECE indices the marker pass removes, split the way MaidenIndices declares them: the marker's pieces (one, or several for a phrase entry like 'z domu'), then the maiden name's and the trailing run's, each with the role it takes. None where the marker is an ordinary word. Piece indices into `pieces`, not the TOKEN indices `_group_segment` builds out of them. Three questions, in order (rules/M2, #601): * WHERE A MARKER COUNTS. Only in the part holding the family name -- never in the given part after a family comma, never in a tail part -- and only with a name word straight before it: not a word of the leading title run (H3's give-back included, so 'Lord Chancellor née Jones' counts; a lone title is a title), not a suffix word unless it opens the name (S2), not a connective. A particle counts: with nothing after it to attach to it is no particle there ('Mai Le née Nguyen'). * WHAT IT TAKES. Every word after it up to the trailing run the name reads as if the clause were not written, and always the first word after it. The run is read over the VIEW -- the head plus the vocabulary-shaped tail, and anything from the first credential that starts a run (S8, #602) -- by `tail_reading`, so the count of words to spare is the clause-free name's. Before a family comma no trailing rule reads the part, and the family part takes a suffix word from anywhere in it: the clause stops at the first one and hands the rest back unbound. * THE RUN IS BOUND. Where a trailing rule reads the part, the take consumes the run and returns the role the reading gave each piece, so no join below this pass can reach into it. Computed before any join (the Ph. D. merge aside), as the marker pass always has been (#420): the joins see a name without the clause in it. """ if site is ClauseSite.GIVEN_SLOT or site is ClauseSite.TAIL: return None m = next((v for v in range(1, len(pieces)) if _is_maiden_marker_piece(pieces[v], tokens)), None) if m is None: return None # the marker may be a phrase, in which case it is several pieces lo = m + _marker_run_pieces(pieces, tokens, m) if lo >= len(pieces): return None before = m - 1 lead = (1 if m == 1 and is_title_piece(pieces[0], ptags[0], tokens) else leading_titles(pieces[:m], ptags[:m], tokens)) if (before < lead or (before > lead and is_suffix_piece(pieces[before], ptags[before], tokens)) or is_conj_piece(pieces[before], ptags[before], tokens)): return None # a suffix word straight after the marker: nothing was announced if is_suffix_piece(pieces[lo], ptags[lo], tokens): return None if site is ClauseSite.FAMILY_PART: end = next((j for j in range(lo + 1, len(pieces)) if "suffix" in ptags[j] or is_suffix_piece(pieces[j], ptags[j], tokens)), len(pieces)) return list(range(m, lo)), [(j, Role.MAIDEN) for j in range(lo, end)] if site is not ClauseSite.TRAILING: assert_never(site) c = trailing_candidates(lo, pieces, ptags, tokens) end = len(pieces) released: list[tuple[int, Role]] = [] # the view's picks, for the kept member below (#718) picked: tuple[tuple[int, ...], ...] = () # nothing behind the clause that a trailing rule could take: the # view would be the head alone, and the whole rest is the clause if c < len(pieces): left = list(range(m)) + list(range(c, len(pieces))) view = [pieces[i] for i in left] vtags = [ptags[i] for i in left] vrest, vchained, vpeel = tail_reading( peel_walk(leading_titles(view, vtags, tokens), vtags), view, vtags, tokens, one_case) picked = vpeel.picks titles = set(vchained) | set(vpeel.run_titles) in_run = titles | set(vrest[vpeel.names:]) | { q for q in range(m, len(view)) if "suffix" in vtags[q]} e = len(view) while e - 1 >= m and (e - 1) in in_run: e -= 1 end = c + (e - m) released = [(j, Role.TITLE if m + (j - c) in titles else Role.SUFFIX) for j in range(end, len(pieces))] # rules/S12: "where the count takes the pair, the reading is reported" -- # the clause-free reading took it, and the take binds it here for j, role in released: if "split" in ptags[j] and role is Role.SUFFIX: ambiguities.append( split_credential_report( pieces[j], tokens, name_reading(tokens[pieces[j][0]], Role.MAIDEN))) # mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE: "Emit at the # site that takes the branch, not where an ambiguous tag sits" # -- the take consumed the run, so the forks the reading called # on it are reported here, where assign will never see them consumed = {i for j in range(end, len(pieces)) for i in pieces[j]} for piece in (*vpeel.picks, *vpeel.absorbed, *vpeel.numerals): if piece[0] in consumed: ambiguities.append(PendingAmbiguity( AmbiguityKind.SUFFIX_OR_NAME, f"{tokens[piece[0]].text!r} after the maiden name " f"reads as a suffix; it may be a name word", tuple(piece), # a name word there ends the clause's maiden name alternative=name_reading(tokens[piece[0]], Role.MAIDEN))) # ...and the member the clause KEEPS as its last word: the reading # left it a name word, which is a fork too last = pieces[end - 1] word = tokens[last[0]] # a one-word member: a split credential the clause keeps is one the # count declined, which reports nothing (rules/S12), and its first # word is not the clause's last if len(last) == 1 and not word.tags.isdisjoint(_AMBIGUOUS_CREDENTIAL_TAGS): ambiguities.append(PendingAmbiguity( AmbiguityKind.SUFFIX_OR_NAME, f"{word.text!r} ending the maiden name is also a " f"post-nominal; the maiden marker's clause keeps it rather " f"than reading it as one", tuple(last), alternative=suffix_reading(word))) elif tuple(last) in picked: # rules/S10: "either reading is reported" -- a numeral the read # declined, for want of words to spare or for its Title case, # which the clause keeps why = ("but the name has no other word to spare for it" if len(word.text.rstrip(".")) == 1 else "but written in Title case it leans to the name") ambiguities.append(PendingAmbiguity( AmbiguityKind.SUFFIX_OR_NAME, f"{word.text!r} ending the maiden name is a roman numeral, " f"{why}; the maiden marker's clause keeps it rather than " f"reading it as a generational suffix", tuple(last), alternative=suffix_reading(word))) elif (released and len(last) == 1 and end - 2 >= lo and reads_as_a_reported_numeral( word.text, word.tags, tokens[pieces[end - 2][0]].text, one_case)): # rules/S10: "a one-letter numeral before a credential is the # family name, and the reading is reported" -- the clause's # twin of assign's report (#721's review): the clause keeps the # numeral its released post-nominal stands behind ambiguities.append(PendingAmbiguity( AmbiguityKind.SUFFIX_OR_NAME, f"{word.text!r} ending the maiden name is a roman numeral, " f"but a post-nominal follows it; the maiden marker's clause " f"keeps it rather than reading it as a generational suffix", tuple(last), alternative=suffix_reading(word))) return (list(range(m, lo)), [(j, Role.MAIDEN) for j in range(lo, end)] + released) #: What `_between_name_words` reads instead of walking: two arrays over #: the segment's pieces, giving for each index the nearest piece on #: its left and on its right that is NOT a connective -- `-1` and #: `len(pieces)` where the run reaches the end. Built by #: `_run_neighbours`, and an ALIAS rather than a NamedTuple because a #: NamedTuple's __new__ is a frame of its own (decisions/parse-cost). _Beside = tuple[list[int], list[int]] def _run_neighbours(pieces: Sequence[Sequence[int]], ptags: Sequence[Set[str]], tokens: Sequence[WorkToken]) -> _Beside: """The nearest piece that is not a connective, on each side of every index. EVERY MEMBER OF ONE RUN HAS THE SAME ANSWER, which is the whole of the fix: `_between_name_words` used to walk the run itself, so a name holding a run of n connectives walked it n times and the stage went quadratic in the run's length -- measured 2026-09-20, `"Josep " + "i " * n + "Rovira"` grew 3.8x per doubling against the 2.0x every other shape holds, 59ms at n=800. Two linear passes answer the same question once for the whole segment. `tests/v2/test_benchmark.py`'s `link_run` shape is the guard. `is_conj_piece` is asked ONCE per piece, into a list the second pass then reads: asking it in both passes would double the calls, and it is the only per-piece call this builder makes. Recorded in the FIRST pass rather than by a comprehension of its own -- which is a code object on 3.11 and a bytecode saving here rather than a frame one, measured: the profile hook emits no call event for it. WHAT IT COSTS A SHORT NAME, because answering for the whole segment is not free where the walk would have stopped at once: this call, plus `is_conj_piece` for the pieces the walk never reached. Re-measured 2026-09-21 on py3.11 against b9ed1429, the whole pair through `tests/v2/test_benchmark.py`'s own `_frames_for` shape -- `Josep Carod i Rovira` 311 -> 313 frames (one call and two more `is_conj_piece` over its four pieces, less the frame the fold below saved) and `Jane Doe nee Puig i Soler` 315 -> 319. An O(1) rise per link-bearing name against an unbounded saving: the same name with a run of 64 links goes 6,741 -> 2,587. (The pair first written here read 304 -> 307 and 307 -> 312, with 6,741 -> 2,652 for the run of 64. Re-measured 2026-09-22 on py3.11, WHICH OF THOSE REPRODUCE is: the two short-name pairs, neither of them; the run-of-64 pair, its left half only -- b9ed1429 reads 6,741 exactly, while 6048eb5d, the tree the 2,652 was taken on, reads 2,651 here. So the interpreter splice ran through a single arrow, which is what the table `tools/perf/call_count.py`'s own docstring warns about. Every figure above is one interpreter, stated.) `tools/perf/call_count.py` is unmoved (parse=406.00, facade=443.00) -- its reference name carries no link -- and so are `John Smith`, `Smith, John`, `Juan Garcia y Lopez` and `Jane Doe nee Smith`, none of which reaches this at all. Called only where a generational connective was found in the segment (the `frozen` loop), so no ordinary name pays for it at all. Dropping either pass's `not` fails test_a_connective_piece_counts_toward_the_carve_outs_total (mutation-checked 2026-09-20; how the two arrays are READ is checked in `_between_name_words`, which reads them). """ n = len(pieces) left = [-1] * n right = [n] * n conj = [False] * n prev = -1 for k in range(n): left[k] = prev is_conj = is_conj_piece(pieces[k], ptags[k], tokens) conj[k] = is_conj if not is_conj: prev = k nxt = n for k in range(n - 1, -1, -1): right[k] = nxt if not conj[k]: nxt = k return left, right def _is_rootname(piece: Sequence[int], ptags: Set[str], tokens: Sequence[WorkToken]) -> bool: if len(piece) == 1 and "initial" in tokens[piece[0]].tags: return False # rules/P3: "A connective counts as a name word wherever this # rule counts them, whatever else the vocabulary says the word is" # (#397). The order against the `initial` refusal above decides # nothing, and what makes that safe lives in classify rather than # here: for a single letter it writes `initial` or `conjunction` # and never both, so a one-case `I`/`i` arrives with no conjunction # tag whichever test runs first -- measured, hoisting this arm # above the refusal moves no field, report or initial on any # corpus name under three name orders, and no test. INLINE rather # than a call to is_conj_piece: this runs once per piece of every # name (frame budget). if ("conjunction" in ptags or (len(piece) == 1 and "conjunction" in tokens[piece[0]].tags)): return True return not (is_title_piece(piece, ptags, tokens) or is_prefix_piece(piece, ptags, tokens) or is_suffix_piece(piece, ptags, tokens)) # rules/P3: "a word the rest of the parse reads as a name word # rather than as a generation, a credential or an honorific, looked # for past any run of connectives standing between. A connective with # nothing to its right is connecting nothing, and a word of that # vocabulary ending a name, or standing before the credential a name # ends with, is the generation it also spells" (#397) -- the WHOLE # clause, its second sentence included, because that sentence is what # this predicate answering `False` means. Reading it as a generation # is the CALLER's half: `_group_segment`'s `frozen` set is where a # connective this refuses is placed as the generation. # `Sequence[Sequence[int]]` rather than `Sequence[Piece]`: this reads a # piece and never edits one (widened when the maiden walk was a # second caller, before #601). def _between_name_words(k: int, lo: int, hi: int, pieces: Sequence[Sequence[int]], ptags: Sequence[Set[str]], tokens: Sequence[WorkToken], beside: _Beside) -> bool: """Whether such a word stands on EACH side of the connective piece at `k`. Both sides in one call because neither caller ever wants one: a connective is placed to join only where a name word stands on both sides of it, so the two answers were always ANDed at the call site and the left one short-circuits the right either way. One frame per generational connective rather than two, which is the whole of the saving -- the left arm below is the old left call and the right arm the old right one, unchanged (#397 follow-up). `lo` and `hi` bound the name's own words: assign peels the pieces below `lo` as its leading titles and those from `hi` up as its trailing suffix run, so a piece outside that span is a credential or an honorific however it is spelled, and the numeral or bare acronym the peel takes ('i V', 'i MA') is inside `hi` by construction rather than by a second reading of the vocabulary (mechanisms.md#ONE-PREDICATE-PER-QUESTION). Inside the span the suffix and title tests still run, because neither bound reaches a credential or an honorific standing in the MIDDLE of a name ('Josep Jr. i Rovira', 'Josep Dr. i Rovira'): the peel walks from the end and stops at the first name word, the title run from the front. The answer steps over connectives because a RUN of them joins as one ('Carod i y Rovira'), so the word this rule is about is the first one past the run -- and where the run runs out ('Juan i e') there is no name word on that side at all. `beside` is where that stepping already happened: `_run_neighbours` walked every run once for the whole segment, so this reads an index rather than walking to it. A SENTINEL OUT OF RANGE is how "the run ran out" arrives -- -1 on the left, `len(pieces)` on the right -- and each bound test below turns it into False, exactly as the walk did when it ran here and stopped at the same place. `lo` is never negative and `hi` never past `len(pieces)`, so neither sentinel can pass the bound, and the piece tests are never asked about an index that is not one. Mutation-checked 2026-09-21, every arm of both sides by a NAMED test. Reading the LEFT array for the right arm too fails test_a_connective_with_nothing_to_its_right_does_not_join; the RIGHT array for the left arm too fails test_a_leading_title_on_the_left_is_no_name_word. The bounds, WIDENED to the whole segment rather than dropped -- dropping the right one indexes past the pieces on the sentinel and raises instead of failing a test -- fail test_the_marker_is_not_the_name_word_on_the_links_left (left) and test_the_right_hand_test_reads_the_peel_not_the_suffix_piece (right). Either piece test on the LEFT fails test_a_credential_or_honorific_mid_name_is_no_name_word_either; either on the RIGHT fails test_a_credential_or_honorific_mid_name_on_the_right_too, which is the row the fold asked for: while this was two per-side calls a mutation hit both sides at once and the left-hand rows covered for the right-hand ones, whose own rows stand at the END of the name where `hi` refuses them first. SWAPPING the two arrays outright is an EQUIVALENT mutant and no test fails: the two arms are ANDed, so which array answers which side is not a question the conjunction can see. """ left = beside[0][k] if not (lo <= left < hi and not is_suffix_piece(pieces[left], ptags[left], tokens) and not is_title_piece(pieces[left], ptags[left], tokens)): return False right = beside[1][k] return (lo <= right < hi and not is_suffix_piece(pieces[right], ptags[right], tokens) and not is_title_piece(pieces[right], ptags[right], tokens)) # rules/S12: "where the count declines it, the pair is the two words # it is written as" -- after the trailing read, which is where the # count is asked in a main segment def _settle_splits(pieces: list[Piece], ptags: list[set[str]]) -> None: """Split every merged split credential left in `pieces` back into its two words, in place: a pair the trailing read declined reads as it reads unmerged. One a connective join took into a wider piece keeps the join and loses the tag (the only shape that reaches it is a pair beside a connective, 'Pharm D and').""" k = 0 while k < len(pieces): if "split" in ptags[k]: if len(pieces[k]) == 2 and ptags[k] == {"split"}: a, b = pieces[k] pieces[k:k + 1] = [[a], [b]] ptags[k:k + 1] = [set(), set()] k += 2 continue ptags[k] = ptags[k] - {"split"} k += 1 def _group_segment(seg: tuple[int, ...], additional: int, tokens: Sequence[WorkToken], bound_join: BoundJoin = BoundJoin.STRICT, ambiguities: list[PendingAmbiguity] | None = None, given_name_titles: Set[str] = frozenset(), opens_the_name: bool = False, *, one_case: bool | None, site: ClauseSite, reads: list[TailRead | None] | None = None, ) -> tuple[list[Piece], list[set[str]], MaidenTake | None]: pieces: list[Piece] = [[i] for i in seg] ptags: list[set[str]] = [set() for _ in seg] # Out-parameter, same shape as _assign_main: forks are reported # where they are decided. A caller that passes None (or a throwaway # list) suppresses reporting -- see group() for when that applies. if ambiguities is None: ambiguities = [] # The maiden take reports on this same channel: it reports only at # the TRAILING site, which group() never suppresses. # ph-d merge first: "Ph." "D." adjacent -> one suffix piece # (decisions/phd-merge; v1 fix_phd did this by regex on the # raw string) # A suffix never BEGINS a name: position outranks the vocabulary # match (#371). This merge is what makes a leading "Ph." "D." a # credential at all -- every other suffix-shaped word standing # first already falls out as a title (H2's abbreviation clause, or # TITLES membership) or as a name word, so the pair is the only # shape that reaches the defect, and it reached it by emptying the # family: "Ph. D. Van Johnson" read given 'Van Johnson' with no # surname at all. # # WHERE THE INPUT BEGINS, and that is v1's own boundary rather # than a new one: fix_phd was a regex requiring a preceding space # (`\s(ph\.?\s+d\.?)`), so it could never fire at the head of the # string and fired everywhere else -- mid-name as readily as # trailing. Three tests, because "the head" has three meanings # here and only one of them is v1's: # `k == 0` the first PIECE, which is not the first word -- # extract_delimited removes a bracketed or quoted # clause before segment runs, so `"Bob" Ph. D. John # Smith` reaches this with the pair at k == 0 and a # word standing before it in the input. v1 merges # there; declining broke parity on 112 measured # names, every one opening with a quote or bracket. # `a[0] == 0` the first TOKEN, which is v1's boundary. # seg_idx 0 and not after a family comma: a credential run # legitimately opens segment 1 ("Smith, Ph. D. # Jr.", C8's credential run in the listing form), and segment 0 before a # comma is the family the comma named, where v1 # merges too ("Ph. D., John" reads last 'Ph. D.'). # # A leading TITLE is therefore NOT stepped over, unlike the #367 # scan below, and the two disagree on purpose: that scan asks # where the NAME begins, this asks where the STRING does. `Sir # Ph. D. Van Johnson` keeps suffix 'Ph. D.' with an empty family, # which is #371's symptom surviving one word to the left -- 1.4.0 # parity, stated as a boundary in rules/S2 rather than fixed # here, because "the first piece of the name" cannot be computed # before this merge: `is_leading_title` is true of `Ph.` itself, # so the scan would step over the very piece being judged. # # Every other split credential ('Pharm. D.', `_vocab.split_pair`'s # "split" answer) merges under the same head test as a member of # the ambiguous class, where a rule reads such a member: in a main # segment, where S2's read below decides it by the count, kept # whole where the read takes it and split back into its two words # where it does not (`_settle_splits`), so a declined pair reads # exactly as the two words read; and in the given part after a # family comma behind the given word, where S5's walk reads it, # merged ahead of the joins so the particle chain stops in front of # it and P5's bound join declines it as it declines a suffix word, # and a pair declined there is middle-name text whole or split. # (A connective beside a pair keeps it from merging at all, in # classify.) # Not in the part before a family comma, which reads no member as # a suffix ('Smith MA, John'). Classify tagged each pair's first # word, so the test here is a tag. splits = False given_from: int | None = None k = 0 while k < len(pieces) - 1: a, b = pieces[k], pieces[k + 1] tags = tokens[a[0]].tags if (len(a) == 1 and len(b) == 1 and not (opens_the_name and k == 0 and a[0] == 0)): if SPLIT_CREDENTIAL_TAG in tags: merge_pieces(pieces, ptags, k, k + 2, add={"suffix"}) continue if SPLIT_MEMBER_TAG in tags: if site is ClauseSite.TRAILING: merge_pieces(pieces, ptags, k, k + 2, add={"split"}) splits = True continue if site is ClauseSite.GIVEN_SLOT: if given_from is None: given_from = leading_titles(pieces, ptags, tokens) if k > given_from: merge_pieces(pieces, ptags, k, k + 2, add={"split"}) continue k += 1 # rules/M2: "A marker that counts takes the words after it as # the maiden name, and is itself dropped" (history: # decisions/M2) -- the marker pass (#274), and it runs # BEFORE every join below. Each join rule asks a question about the # name -- how many words it has (P3's carve-out, P5's reserve), # what the word after a particle or a bound word is -- and the # marker and the maiden name are not part of that name: they leave. # Asked while they were still pieces, the count answered for a name # that would not exist ('juan y garcia nee jones' counted five, 'y' # joined, and the family was empty once the clause left: #418), and # a join could absorb the marker before the pass looked for a lone # one ('Jane van der Berg née y Jones' kept the marker in the # family: #412). Removing the pieces first makes both impossible by # construction, with no stop on the chain (#399, #417) and no # exclusion in the reserve (#411) left to keep in step. # # The tokens are not touched here: this function reads them and # returns what it took, and group() records the drop and the roles. taken: MaidenTake | None = None take = _maiden_take(pieces, ptags, tokens, one_case, site, ambiguities) if take is not None: marker_ks, removed = take taken = ([i for k in marker_ks for i in pieces[k]], [(pieces[k], role) for k, role in removed]) for k in reversed(marker_ks + [k for k, _ in removed]): del pieces[k] del ptags[k] # S2's trailing run, read once below once the connectives have # joined (#614) read: TailRead | None = None tail_pieces: list[Piece] = [] tail_ptags: list[set[str]] = [] # too few pieces for the read: a split credential has no words to # spare (rules/S4) if splits and len(pieces) + additional < 3: _settle_splits(pieces, ptags) if len(pieces) + additional >= 3: # rules/P3: "A connective that is also generational # vocabulary joins only where a name word stands on each side # of it" (#397). `frozen` holds the TOKEN index of every such # connective missing a name word on one side: it is the # generation it also spells, so no join of its own reaches it, # it may not merge into a run, and it does not count toward # the carve-out total. A token index because the joins below # move piece indices. A neighbour's join can still absorb it: # `join_connectives`' neighbour-join loop skips a frozen piece # only as the join's subject. # # Asked here, of the pieces as classify left them and of the # neighbours' class, because after a merge the absorbed suffix # IS the word on the right. No length test: the clause names a # class, not a spelling, while the three-word carve-out below # stays single-letter because its own sentence says so. Only a # connective of the suffix vocabulary reaches the body, so a # name with none pays no call for this test. decisions/P3. frozen: set[int] = set() # whether any connective is left free to join: a segment with # none skips the rootname count and the shared loop, both of # which then do nothing (0 of 3,153 such calls changed a piece # over the corpora and case table, #617's /simplify), as # `_comma._reading_pieces` skips it for the part after a comma free = False lo = hi = -1 beside: _Beside = ([], []) for k, piece in enumerate(pieces): tok = tokens[piece[0]] if len(piece) != 1 or "conjunction" not in tok.tags: continue if "vocab:suffix" not in tok.tags: free = True continue # The bounds and the run memo, computed once per segment # and only where such a connective was found, so the cost # is the link's own and no ordinary name pays any of it. if hi < 0: lo = leading_titles(pieces, ptags, tokens) # H5's reading and not the peel over the pieces as # WRITTEN: a title standing behind the suffix run # hides it from `trailing_start`, which then answers # `len(pieces)` and hands this loop a credential as # the name word on the link's right. 'John Quincy # Adams i MA Prof.' joined to family 'Adams i MA' with # no report at all, where 'John Quincy Adams i MA' -- # the same name, one title shorter -- reads family # 'Adams', suffix 'i MA' and reports the acronym # (#397 second review). hi = trailing_start_past_titles(lo, pieces, ptags, tokens, one_case=one_case) # ONE ANSWER PER RUN: every member of a contiguous run # of connectives has the same nearest name word on # each side, and asking per member walked the run once # per member -- quadratic in its length, 3.8x per # doubling measured at `b9ed1429`. beside = _run_neighbours(pieces, ptags, tokens) if _between_name_words(k, lo, hi, pieces, ptags, tokens, beside): free = True else: frozen.add(piece[0]) if free: total = sum(_is_rootname(p, t, tokens) for p, t in zip(pieces, ptags) if p[0] not in frozen) + additional # The threshold of P3's carve-out ("except a single-letter # connective in a three-word name, which stays a name word") # reads the ROOTNAME count, and since #397 a connective counts # ITSELF toward that count WHERE IT IS JOINING, so a connective # that is also suffix vocabulary no longer raises the bar for # its own join and no longer lowers it for an unrelated one # ("Carod y Rovira i" counted the trailing generation and let # the `y` join). join_connectives(pieces, ptags, tokens, letter_stays=total < 4, titles_only=False, frozen=frozen) # rules/S2's trailing run is read ONCE, here -- after P3's # connective joins, which do not depend on it and make the # units it counts, and before the particle chain and the # bound-given join, which do -- and bound (#614): the run is # split off, the chain and the join group the name in front of # it, and assign places the run off this reading. Group had # asked the peel before and after those joins -- the chain's # stop and its re-ask after its own merges, the reserve's two # views -- because they change the pieces the count reads; the # read counts the units the chain will build instead # (_pieces.read_trailing_run), so there is nothing to re-ask, # and no join takes a word the run took ('John van der Berg # Prof.' keeps its title). The main segments only (no comma, # or the part before a suffix comma); a family comma's parts, a # part past a comma and a segment of fewer than three pieces -- # which no join reaches anyway -- read as before, and so does a # run holding a name piece (_pieces.read_trailing_run). # The particles straight behind each other are merged first, # into the one piece the chain would open its run with, so the # read counts and absorbs the run as that piece (#625); # `premerged` keeps which runs the merge made, for the chain's # report below. premerged: Set[int] = frozenset() if site is ClauseSite.TRAILING: n = leading_titles(pieces, ptags, tokens) premerged, lead = merge_particle_runs(pieces, ptags, tokens, n) found = read_trailing_run(pieces, ptags, tokens, one_case, n, lead) if found is not None: read, start = found tail_pieces, tail_ptags = pieces[start:], ptags[start:] del pieces[start:] del ptags[start:] # the read has decided every split credential: one it took # left with the run, and one still here is two name words if splits: _settle_splits(pieces, ptags) # rules/S2: "a trailing word of the suffix vocabulary reads as a # suffix" -- on a main segment the run read above decides it # and is split off already; elsewhere the chain stops where # assign's peel begins (`trailing_run_start`, trailing titles # included, in the given part after a family comma; else # `trailing_start`; the H5 paragraph below explains the # titles). decisions/P2 (#424). # rules/P2: "a particle joins the words after it into one name # part, the join running until the next particle starts a group of # its own, a trailing suffix begins" -- and on to the maiden marker # (M2) or the name's end; the final group reads as the family name, # earlier groups by position. (history: decisions/P2) # rules/P4: "a particle in the name's leading position chains # nothing: the words stay separate" (history: decisions/P2) # # "Leading" is the first piece of the NAME, past assign's own # title run (`leading_titles`), unless a word in that run is # also a particle: then the last such word leads in the run's # place (`chain_lead`, decisions/P2 (#624)). Computed once, # before the loop: every merge below starts past this index, so # none can move it. Suffix pieces are deliberately not skipped # when finding the leading piece (decisions/P2). A maiden # marker still here is one the consumer declined, an ordinary # word to the chain. decisions/M2 (#417). name_start = leading_titles(pieces, ptags, tokens) leading = chain_lead(pieces, ptags, tokens, name_start) # rules/P2: "a trailing suffix begins" -- where it begins # is read by assign's peel over the pieces as they stand # (#424), once per segment and kept as a length from the end, # which the chain's merges ahead of it do not move -- except # where a particle that is suffix vocabulary too (vd, mc, do) # starts the run: the prefix run below takes it as a particle, # as P6 reads it after a comma, and 'John van Mc' keeps family # 'van Mc' (every baseline's reading). A suffix WORD stops the # chain wherever it stands, and the trailing # run -- the numeral, or the bare acronym with words to spare # -- stops it where the suffix-piece test alone did not ('John # van der Berg V' read family 'van der Berg V'). The chain # takes both forks, and where no run was read ahead of it it # asks again after its merges whether the acronym still has # the pieces the fork counted (below). # the run read above is already split off (#614), so the chain # stops at the end of what is left. # rules/H5: "the title is TRANSPARENT to the suffix reading" # -- in the given part after a family comma too, where assign's # walk reads the trailing title run, so the chain stops before # the run of suffixes and titles `tail_reading` takes there # rather than before the suffixes alone, and does not carry a # trailing title into the particle's name ('Smith, John van der # Berg Prof.' reads title 'Prof.' as 'Smith, John Prof.' does, # #623). The bound matters only where a particle stands past the # chain's lead, so it is not asked otherwise: the chain then # merges nothing (the test is `is_prefix_piece`'s, inline). # rules/C2: "a part past the second comma holds no name word" # -- assign reads such a part wholly as suffixes and its title # words as titles, so the chain builds no name unit there and is # not run, and a title word behind a particle run stays a word # C2 can read ('John Smith, Jr., van der Berg Prof.', #636). The # part after a suffix comma reaches no chain either: `_comma. # decide` binds it and empties its segment before any join # (#613). chains = False if read is None and site is not ClauseSite.TAIL: for k in range(leading + 1, len(pieces)): if ("prefix" in ptags[k] or len(pieces[k]) == 1 and "particle" in tokens[pieces[k][0]].tags): chains = True break given_slot = site is ClauseSite.GIVEN_SLOT tail = (0 if not chains else len(pieces) - ( trailing_run_start(name_start, pieces, ptags, tokens, one_case) if given_slot else trailing_start(name_start, pieces, ptags, tokens, one_case=one_case))) # the members the chain reports below, which the read's picks # then give up (#614) took: set[tuple[int, ...]] = set() def chain(tail: int) -> None: # `pieces[:titled]` are known to be leading titles. A merge # from k to j changes only indices from k on, and k only # grows, so a piece behind k is final and its answer can # be kept: the # cursor makes the all-titles-ahead test below one walk per # chain rather than one per chain site (#559). titled = 0 k = 0 while k < len(pieces): if k <= leading or not is_prefix_piece(pieces[k], ptags[k], tokens): k += 1 continue j = chain_run_end(k, pieces, ptags, tokens, len(pieces) - tail, one_case if given_slot else None) # The other half of PARTICLE_OR_GIVEN. _assign reports the # fork when an ambiguous particle stays a lone leading piece # ("Van Johnson" -> given under the default order, family # under FAMILY_FIRST); the chain here takes the opposite # branch when the particle is not the name's leading piece. # A fork whose two sides are decided in different stages # needs an emitter in each. # # Narrow, and #367 is why. `titled == k` says every # piece ahead of this one is a title, and the loop # skipped every piece up to `leading`, so `leading` is # STRICTLY before k -- one of those titles, and, being # before the title run's end, a prefix as well # (`chain_lead`): a word in both vocabularies (`st`, # `freiherr` by default, or any overlap a caller # configures). A plain title alone can no longer put a # particle off the name's leading piece; it is stepped # over and _assign reports the fork instead. # # What that leaves is wider than one shape: any number of # plain title pieces, then a piece in BOTH vocabularies, # then any number of further titles, then the ambiguous # particle whose chain claims something. "Freiherr von # Richthofen" is the canonical spelling and the one # tests/v2/cases.py and tests/v2/test_parser.py lead with, # but "St Van Johnson", "Do St Johnson" (the chained # particle itself in both vocabularies) and "Dr. Do van # Johnson" (a plain title AHEAD of the both-vocabulary # word) all reach here too. What none of them can do is # dispense with the both-vocabulary WORD. The conjunction # merge is the only other way a piece acquires `title` or # `prefix`, and it cannot manufacture the pair: it derives # from ONE neighbor, which is the left one whenever there # is a left one, and its right operands are always fresh # pieces (the loop runs left to right, so nothing to the # right has been merged yet). Both tags therefore have to # come from the piece it extends, which bottoms out at a # lone token in both vocabularies. # # j > k + 1 is what makes this a DECISION rather than a # shape: when the next piece is a suffix the inner scan # never advances, the merge from k to k+1 folds a piece # into itself, and the particle stays a lone leading # piece -- nothing # was chained, and _assign reports that case instead. # Without this the two emitters both fire on the same token. # A run merged ahead of the read (`premerged`, #625) has # claimed the particles behind it already, so its chain # is a decision though `j == k + 1` ('Freiherr von der'). # (Tag test first: it is a set lookup and almost no name # has an ambiguous particle, while is_leading_title is a # call per piece.) if ((j > k + 1 or pieces[k][0] in premerged) and "vocab:particle-ambiguous" in tokens[pieces[k][0]].tags): while titled < k and is_leading_title( pieces[titled], ptags[titled], tokens): titled += 1 if titled == k: i = pieces[k][0] ambiguities.append(PendingAmbiguity( AmbiguityKind.PARTICLE_OR_GIVEN, f"{tokens[i].text!r} was chained onto the " f"following name piece; it is also a given " f"name in other names", (i,), # where it would stand unchained is # assign's to name alternative=name_reading(tokens[i], Deferred.UNCHAINED))) # rules/S4: "A BARE ambiguous acronym is consumed # only when the name has words to spare — as the second # of two words it stays the family name — and at the # slots that report, either reading carries the # ambiguity flag" # # The chain's half of SUFFIX_OR_NAME: once the chain # takes the acronym, the lone piece assign reports on # is gone, so the fork reports here # (mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE). It is # the DECLINED reading: the scan stops at # `len(pieces) - tail`, before every piece the peel # took, and a re-peel truncates the first pass's # appends with the pieces. `j > k + 1` is its floor # too, and `not is_prefix_piece(last, ...)` leaves a # word in both vocabularies ending a PARTICLE run to # P6's fork; it costs a frame, so it is tested last. A # member S2's read picked is reported here and leaves # the read's picks. decisions/S6 (#289), decisions/S2 # (#614). last = pieces[j - 1] # A numeral S2's read declined (rules/S10, #718) is not # reported here: it stays one of the read's picks, and # assign words it from the role it ends in ('Freiherr # von Richthofen V', 'a family name'), the chain taking # nothing the read had not already given up if (j > k + 1 and len(last) == 1 and not tokens[last[0]].tags.isdisjoint( _AMBIGUOUS_CREDENTIAL_TAGS) and not is_prefix_piece(last, ptags[j - 1], tokens)): ambiguities.append(PendingAmbiguity( AmbiguityKind.SUFFIX_OR_NAME, f"{tokens[last[0]].text!r} is both a post-nominal " f"and an ordinary name; the particle chain took " f"it into the name rather than reading it as a " f"post-nominal", tuple(last), alternative=suffix_reading(tokens[last[0]]))) took.add(tuple(last)) merge_pieces(pieces, ptags, k, j, drop={"prefix"}) k += 1 # Where the run was read above (#614) it is split off and # `tail` is 0, so none of what follows runs, as it does not # where no particle stands past the lead (#623); it is the path # for the segments that read no run above -- a family comma's # two name parts and a run with a name piece inside it (a part past the # second comma makes no chain, #636) -- and the given part # reaches it still # ('Smith, Freiherr van Berg X.Y.Z.', the row that holds it). # # The peel was read over the pieces as they stand, and the # chain's own merges can change what it counts: behind a word # in both the title and particle vocabularies the scan above # stops where assign's title peel does not (P4, #367), so the # chain takes the name's first word, and the acronym the fork # counted with three pieces meets assign with two -- 'Freiherr # von Berg Ma' read given 'von Berg', family 'Ma' (1.4.0's # reading; the reviews found it behind the claim that the # merges leave the count alone). So the peel is asked again # over the pieces the chain leaves, and where it no longer # takes what the chain stopped before, the chain runs again # without that stop: what assign will not peel is a name word, # and the chain takes it. The numeral cannot flip (a chain # group is never initial-shaped), so the second run is the # acronym's alone, and rare; the snapshot is one copy per # segment with a trailing run, linear like the rest. if tail: kept = ([list(q) for q in pieces], [set(t) for t in ptags], len(ambiguities)) chain(tail) start = leading_titles(pieces, ptags, tokens) left = len(pieces) - ( trailing_run_start(start, pieces, ptags, tokens, one_case) if given_slot else trailing_start(start, pieces, ptags, tokens, one_case=one_case)) if left < tail: pieces[:], ptags[:] = kept[0], kept[1] del ambiguities[kept[2]:] chain(left) elif site is not ClauseSite.TAIL: chain(0) if read is not None and took: # built, not `_replace`d: two frames a call peel = read.peel read = TailRead(read.tail, read.titles, read.run_titles, Peel( peel.names, peel.numerals, tuple([p for p in peel.picks if p not in took]), peel.anchors, peel.run_titles, peel.absorbed)) # rules/P5: "a recognized bound given-name word joins the # word after it into one given name" (history: decisions/P5) # -- bound given names: the first non-title piece joins the next # ONCE (pairwise, v1 parity: 'Salem, Abdul Rahman Ahmed' keeps # Ahmed a middle name). BoundJoin encodes v1's reserve_last. # "the first non-title piece" by assign's count (#424): group's # title test does not see H2's unlisted abbreviations, and # 'Xyz. abdul John Smith' joined nothing where 'Dr. abdul John # Smith' read given 'abdul John'. fk = leading_titles(pieces, ptags, tokens) if (bound_join is not BoundJoin.DISABLED and fk + 1 < len(pieces) and len(pieces[fk]) == 1 and "vocab:bound-given" in tokens[pieces[fk][0]].tags): # P5 joins the bound word to "the word after it", and a # marker is not a name word -- it is the announcement that # another name follows. The only marker left by now is one # the consumer declined (nothing after it, or nothing but # a suffix), and the join must still not take it: 'Berg, # abdul nee PhD' read given 'abdul nee', and 'Berg, abdul # nee' clears the LENIENT reserve the same way. Measured # rather than assumed -- dropping this undid #411 on # exactly that row. Nor is a suffix piece (#421) -- # rules/P5: "nor a word of the unambiguous suffix # vocabulary (S2), wherever position will then place it" # -- and declining it is also what keeps merge_pieces' tag # union from making the joined piece a suffix piece. # rules/P5: "nor a title word a trailing rule reads as a # title" -- after a family comma one H5's trailing run takes # from the given part, read as the comma-less name reads it, # plus the bare salutations H8 adds after a family comma # (`salutations=True`), # which the LENIENT join reads no peel for and so asks here, # only where a title word follows ('Berg, abdul Prof.', # #637; where the given part's own walk then reads the words # behind it otherwise, the title stays a name word, which # rules/P5 accepts); in a part past the second # comma any title word, which C2 reads as a title ('John # Smith, Jr., abdul Dr. Smith', #640). A title word standing # in the name is a name word and joins ('abdul Sir Smith # Berg', 'Berg, abdul Prof. Ali'). # A credential split across a space (rules/S12) is declined # as its one-word spelling is: merged in the given part # before this join, so the particle chain stops in front of # it too ('Rahman, Abdul MB ChB', 'Smith, John van Pharm D') # After a family comma a member its capitals settle as the # credential (rules/S6) is declined as a suffix word is: the # join would read it as the given name's second word, where # the given part's trailing slot or the run S8 starts takes # it ('Smith, Abdul MA'); with no comma nothing would, and # the join stands ('Abdul MA Smith') nxt, nxt_tags = pieces[fk + 1], ptags[fk + 1] if (_is_maiden_marker_piece(nxt, tokens) or is_suffix_piece(nxt, nxt_tags, tokens) or "split" in nxt_tags or (bound_join is BoundJoin.LENIENT and len(nxt) == 1 and AMBIGUOUS_ACRONYM_TAG in tokens[nxt[0]].tags and settled_by_case(tokens[nxt[0]], one_case)) or ((site is ClauseSite.TAIL or bound_join is BoundJoin.LENIENT) and is_title_piece(nxt, nxt_tags, tokens) and (site is ClauseSite.TAIL or fk + 1 in tail_reading( peel_walk(fk, ptags), pieces, ptags, tokens, one_case, salutations=True)[1]))): pass elif bound_join is BoundJoin.LENIENT: # post-comma the family is fixed and the pair is the # given whatever follows, so no peel is read # (decisions/P5, #423), save the title test above merge_pieces(pieces, ptags, fk, fk + 2, drop={"title"}) else: # rules/P5: "the trailing suffix run (S2) and the # trailing title run (H5), each read over what the other # leaves until neither takes anything more, are read # once, before the join is tried, and the name words they # leave are the words to spare" (history: decisions/P5). # That reading is the one above, after P3's joins and # before the chain (#614): the run is # split off, every piece left is a name piece, and the # join leaves one fewer. Nor can the join move a suffix # -- rules/P5: "a word the run left as a name word # stays one, whatever the join makes of the word in # front of it" -- since nothing in group reads the run # again after it. # # Where the run was not read above (a run with a name # piece inside it, _pieces.read_trailing_run), the # reserve still reads both views, as it did everywhere # before #614: the join tried on the pieces as it would # leave them, the reading assign runs over each (the # ONE function, _pieces.tail_reading), the suffixes # compared as the peeled pieces themselves -- 'abdul # Smith V' peels the V unjoined and nothing joined (no # word to spare there, rules/S10), 'abdul Smith Ma' # peels the acronym unjoined and keeps it joined. if read is not None: same_suffixes = True chained = False names_after = len(peel_walk(fk, ptags)) - 1 else: rest, chain_took, before = tail_reading( peel_walk(fk, ptags), pieces, ptags, tokens, one_case) view, view_tags = list(pieces), list(ptags) view[fk:fk + 2] = [pieces[fk] + pieces[fk + 1]] view_tags[fk:fk + 2] = [joined_tags(ptags, fk, fk + 2, drop={"title"})] view_rest, _, after = tail_reading( peel_walk(fk, view_tags), view, view_tags, tokens, one_case) same_suffixes = ( [tuple(view[j]) for j in view_rest[after.names:]] == [tuple(pieces[j]) for j in rest[before.names:]]) chained = fk + 1 in chain_took names_after = after.names # rules/P5: "a trailing roman numeral the run takes # (S10), or a bare acronym it takes, or a trailing title # word the run takes, is no word to spare" -- and the join joins # two NAME words, so a piece the chain takes is no more # joinable than a marker or a suffix piece is: 'Sir # abdul Prof.' reads title 'Sir Prof.', given 'abdul'. # Read off the UNJOINED view, the one that still has # the title as a piece of its own -- the join would # swallow it, and a swallowed title is a title the # joined view can no longer see. The suffix comparison # alone said this while the counts were subtractions, # by leaving the chained piece in the tail it compared; # under the shared reading the chain takes it out of # both views, so the rule is asked as the rule. # A given-name title ahead of the bound word asserts # that a given name follows -- the assertion H1 reads # when it keeps "Sir John" a given name -- so behind # one there is no family to spare (#369). Asked of the # title run through the ONE predicate post_rules asks # for H1, so the two rules cannot disagree about what # one run asserts; what it reads is the whole run's key # or that key's LAST word (#489). And it is the same # RUN on both sides: `range(fk)` is the pieces AHEAD of # the bound word, and H1 asks its own question of the # leading run too (_post_rules._addressing_run). They # did disagree for one commit -- H1 keyed every TITLE # token, so the trailing title in 'Sir abdul rahman # Prof.' joined this run and flipped the join's own # premise, handing the licensed pair to the family. # Reading the leading run at both sites is what makes # that unreachable rather than merely unlikely: a # trailing title is behind the word, and neither site # can see it. What H2's unlisted abbreviations do # inside such a run is the predicate's own business, # and its docstring is where they are worked through. # The licence lifts the reserve for two name # WORDS: the piece the join would take must be one word # -- a particle chain is the family name P2 built ('Sir # abdul van der Berg' keeps family 'van der Berg'). licensed = (fk > 0 and len(pieces[fk + 1]) == 1 and _run_addresses_by_given( (tokens[i].text for k in range(fk) for i in pieces[k]), given_name_titles)) reserve = BoundJoin.LENIENT if licensed else BoundJoin.STRICT if (not chained and same_suffixes and names_after >= reserve): # the pair is a given name whatever tag the word # carried (rules/P5); joined_tags says why the # title tag is dropped. Pinned in test_group.py. merge_pieces(pieces, ptags, fk, fk + 2, drop={"title"}) if reads is not None: reads.append(read) pieces.extend(tail_pieces) ptags.extend(tail_ptags) return pieces, ptags, taken def group(state: ParseState) -> ParseState: # rules/C1's decision, read once now that classify has tagged # the words and before any join below can reach the part after the # comma (#613); a postnominal part comes back bound, its segment # emptied (a name with no comma asks nothing) if state.structure is Structure.FAMILY_COMMA: state = _comma.decide(state) tokens = list(state.tokens) dropped = list(state.dropped) ambiguities = list(state.ambiguities) all_pieces: list[tuple[tuple[int, ...], ...]] = [] all_ptags: list[tuple[frozenset[str], ...]] = [] all_reads: list[TailRead | None] = [] # v1 parity: additional_parts_count=1 applies only to FAMILY_COMMA # parts; the SUFFIX_COMMA pre-comma segment gets 0. additional = 1 if state.structure is Structure.FAMILY_COMMA else 0 # v1 expand_suffix_delimiter parity (#206): tail segments (wholly # consumed as suffixes by assign) are cut at their delimiter-core # tokens, which land in `dropped` as the maiden marker does; the # built-in cores, which no v1 release read, cut them too (#889) cores = delimiter_cores(state.policy.extra_suffix_delimiters) tail_start = {Structure.SUFFIX_COMMA: 1, Structure.FAMILY_COMMA: 2}.get(state.structure) family_comma = state.structure is Structure.FAMILY_COMMA for seg_idx, seg in enumerate(state.segments): if family_comma: bound_join = (BoundJoin.LENIENT if seg_idx == 1 else BoundJoin.DISABLED) else: bound_join = BoundJoin.STRICT # Suppressed after a family comma for the same reason _assign # suppresses it there: the family name is already fixed, so # there is no fork left to report. A tail segment needs no # suppression: group makes no particle chain there (#636) and # takes no maiden clause (rules/M2, #601), so nothing in group # reports in one. rules/C2: "a part the parse consumes wholly # as suffixes raises no report that a word of it was read as a # name" tail = tail_start is not None and seg_idx >= tail_start # #601: where a maiden clause would stand, off the three facts # already in hand here -- which decides whether a marker counts # at all and which rule reads the end of the part. if tail: site = ClauseSite.TAIL elif family_comma: site = (ClauseSite.GIVEN_SLOT if seg_idx == 1 else ClauseSite.FAMILY_PART) else: site = ClauseSite.TRAILING # rules/C10: "a suffix delimiter parts the suffix words on each # side of it as a comma would" (#549): the segment is # grouped as the parts between its cores, each read exactly as # the comma twin's own segment would be, and the cores are # dropped (v1 expand_suffix_delimiter parity, #206), where # post_rules' entry pass reads them back as entry boundaries. # No join or link search reaches across a core, because none # of them ever sees one, and a maiden marker in a tail part is # an ordinary word (#601). A one-token segment that # is its core keeps it (v1 expand() splits within a part, never # erases a lone part); a segment of several cores and nothing # else drops them all, as the #206 drop did. # Accumulated in a list and frozen once per part: a tuple # extended per token is quadratic in the part's length, C-level # work no frame guard sees (#553's class; review of #549). # Measured 2026-10-03, py3.11, 'Smith, John, ' + 'PhD ' * n + # '- MD' under a ' - ' delimiter: 4.7x for n 4,000 -> 16,000 as # written, the default policy's own 4.5x, where the tuple read # 8.0x. No clock guard: _PREFIXED_SHAPES parses with the default # policy and _POLICY_SHAPES repeats a unit with no prefix, and a # tail needs both, so a row would mean a table of its own. parts: list[tuple[int, ...]] = [seg] if tail and cores and len(seg) > 1: parts = [] current: list[int] = [] for i in seg: if tokens[i].text in cores: dropped.append(i) if current: parts.append(tuple(current)) current = [] else: current.append(i) if current: parts.append(tuple(current)) pieces = [] ptags = [] seg_reads: list[TailRead | None] = [] for part in parts: part_pieces, part_ptags, taken = _group_segment( part, additional, tokens, bound_join, None if family_comma else ambiguities, state.lexicon.given_name_titles, opens_the_name=(seg_idx == 0 and not family_comma), one_case=state.one_case, site=site, reads=seg_reads) pieces.extend(part_pieces) ptags.extend(part_ptags) # the marker is dropped and the maiden name's tokens become # MAIDEN (#274); which pieces those are was settled in # _group_segment, before the joins if taken is not None: marker_piece, removed_pieces = taken dropped.extend(marker_piece) # the maiden name, and the trailing run the take # consumed with the roles the clause-free name's # reading gave it (#601) for piece, role in removed_pieces: for i in piece: tokens[i] = copy_with(tokens[i], role=role) # Group used to decide the suffix ENTRY, marking a continuation # token "joined" between pieces off segment SHAPE -- `tail` by # index, ORed since #429 with segment_suffix_reading's # per-piece content verdict, gated per piece and sticky across # an interleaved title. post_rules derives that from the # commas the writer typed instead (#436/#437, rules/R1), # which is what the input answers directly. The stickiness # survives by construction rather than by code: # 'Smith, MD Dr. PhD' has no comma between MD and PhD and a # title between them renders elsewhere, so they join, while # 'Smith Jr., Mr. Jr.' has the writer's own comma and they do # not. # continuation tokens of a suffix-merged piece (the ph-d merge) # carry the stable "joined" tag: the suffix string view joins # SUFFIX tokens with ", ", and the tag lets it heal the split for piece, piece_tags_ in zip(pieces, ptags): if "suffix" in piece_tags_ and len(piece) > 1: for i in piece[1:]: tokens[i] = copy_with( tokens[i], tags=tokens[i].tags | {"joined"}) all_pieces.append(tuple(tuple(p) for p in pieces)) all_ptags.append(tuple(frozenset(t) for t in ptags)) all_reads.append(seg_reads[0] if len(seg_reads) == 1 else None) # rules/M1: "a leading recognized marker is dropped where the # clause holds a word past it" and "a clause of nothing but its # marker keeps its words" — Then's marker drop and the Then bullet # on a marker-only clause, for a marker inside EXTRACTED maiden # content (decisions/M1, #329). Classify tags # such a marker like any other token -- what the #274 rule above # lacks is not the TAG but the token: extract claims a delimited # clause and tokenize gives its tokens Role.MAIDEN up front, so # segment (main stream = role is None) leaves them out of every # segment, they never enter `pieces`, and a rule that walks pieces # cannot reach them. # # Scoped to the CLAUSE, via state.extracted (one role + inner span # per delimited region), rather than to a maiden token's # neighbours. Both reasons are load-bearing: # * Role.MAIDEN is not proof of extraction -- the #274 rule above # sets it too, on the bare form, earlier in this same function. # A neighbour test would fire there and eat the 'Nee' out of # "Jane Smith nee Nee Jones". Keying on extracted spans puts # the bare path out of reach by construction. # * Separate clauses are separate content. In "(Nee) (Jones)" the # two land as one contiguous run of maiden tokens, so only the # clause bound keeps the lone "(Nee)" intact. # Drop the clause's LEADING MARKER only when the clause holds a # word past it: `Nee` is a real surname (Irish Ni/Nee, and a # Chinese romanization), so a one-token "(Nee)" is a maiden name, # not a marker. The marker and no more, whatever the clause holds # past it: cases.py's maiden_marker_delimited_three_token_clause is # the row that bounds this in both directions, every other # delimited row having a two-token clause where the two readings # agree. The marker is one token for a word entry and the whole run # for a phrase one ('z domu'), which is why the loop below counts # continuation tags rather than assuming one. # Spans index the original string by the anti-#100 # invariant, and script_segment only ever splits a token into # sub-slices, so containment stays exact. # # Bisect rather than scan the token list per clause: that is # quadratic in the number of delimited pairs, and "(a) " * 3200 -- # 4x test_benchmark's base, NOT a doubling -- measured a 14.1x cost # against the 4.1x the same shape holds under a policy with no # maiden_delimiters. The control is what says which unit a ratio is # in: linear is ~4 for 4x the input and ~2 for a doubling, so a 4.1x # control cannot be per doubling. Re-measured 2026-08-03 at 11.2x # against 4.2x (3.2x against 2.1x per doubling) -- the separation # replicates, the exact ratio moves with the runner. Same idiom, # and the same reason, as _extract._overlaps and _tokenize's origin # resolution. test_benchmark's maiden_pairs shape is the guard. if any(role is Role.MAIDEN for role, _ in state.extracted): starts = [t.span.start for t in tokens] for role, clause in state.extracted: if role is not Role.MAIDEN: continue # first token starting at or after the clause opens; tokens # are span-sorted and group never reorders or resizes them first = bisect.bisect_left(starts, clause.start) if (first >= len(tokens) or "vocab:maiden-marker" not in tokens[first].tags): continue # How many tokens the marker is: one, or the whole run of a # phrase entry, as classify recorded it. Same walk the # piece test above makes, in token-index space. run = marker_run_length( tokens[k].tags for k in range(first + 1, len(tokens))) # Testing the end of the token AFTER the run proves the run # AND that token are all inside: tokens do not overlap and # are index-ordered, so every earlier one ends no later, # and bisect already put first.start at or after # clause.start. That is also the "more than the marker" # test, since the tokens inside a clause are contiguous in # index order -- a clause holding nothing but its marker # keeps its words, exactly as a one-token "(Nee)" does. # rules/M3: "a marker followed by a comma is no marker" # -- asked of the same word extract's M3 test asks it of if (first + run < len(tokens) and tokens[first + run].span.end <= clause.end and not comma_follows_marker(tokens[first + run].text)): dropped.extend(range(first, first + run)) return copy_with( state, tokens=tuple(tokens), pieces=tuple(all_pieces), piece_tags=tuple(all_ptags), dropped=tuple(dropped), tail_reads=tuple(all_reads), ambiguities=tuple(ambiguities)) derek73-python-nameparser-456b024/nameparser/_pipeline/_pieces.py000066400000000000000000003520601527303271700247770ustar00rootroot00000000000000"""Shared piece-level predicates for pipeline stages. How a PIECE reads -- its tokens, plus the tags classify wrote on them and the tags group derived for the piece -- where _vocab answers how a WORD reads from text alone. Both are consulted by more than one stage; the split is by what the question takes, not by which stage happens to ask (mechanisms.md#ONE-PREDICATE-PER-QUESTION). _vocab points here from its own side: "Text-level tests used by more than one stage; piece-level ones live in _pieces, the sibling layer over tokens-plus-tags." own_words (#289/#516) answers over the whole token STREAM rather than one piece -- pieces do not exist yet at the stages that call it -- but the question is still piece-shaped, not word-shaped: it reads token ROLE, which _vocab's text-level tests never take. Before this module those predicates lived in _group, not because grouping owned them but because assign imported group and could not be imported back, so group was the only place both stages could reach. They arrived there that way across three PRs -- #424 brought is_leading_title, leading_titles and trailing_start, #425 the peel (peel_walk, peel_trailing), #429 the no-name-segment test that #430 turned into segment_suffix_reading. is_title_piece and is_suffix_piece are older than any of that: they were group's from its first commit, and travel because the others call them. The import that forced all of it is the one #439 removed: assign no longer names _group at all. What still holds is the rule that replaced it, and tests/v2/test_layering.py is where it is written down -- a piece predicate may not depend on a stage, in either direction. The S2 trailing peel travels as the unit the decisions describe -- peel_walk, peel_trailing and trailing_start together -- though only the first two cross a stage boundary. trailing_titles joins them because it reads what that peel left: the two answer one question between them, where the tail of a name stops being the name -- and tail_reading is that one question, running them against each other to their fixed point for the two stages that must not disagree about the answer. Layering: imports _state and _vocab only; segment, script_segment, classify, group, assign, post_rules and _comma import it (segment, script_segment and classify for the #289/#516 own-words span alone) -- and neither of the two it imports imports it back. Naming follows _vocab's: inside an already-private module the leading underscore marks module-PRIVATE, so the names other stages call are bare and only the internals keep it (_PERIOD_ABBREV here). Getting that backwards -- which this module did until the underscores came off -- costs a reader the one cheap way to tell a shared predicate from a helper. """ from __future__ import annotations import unicodedata from collections.abc import ( Callable, Container, Iterable, Mapping, Sequence, Set) from typing import NamedTuple from nameparser._pipeline._state import ( AGNOMEN_TAG, AMBIGUOUS_ACRONYM_TAG, AMBIGUOUS_WORD_TAG, GIVEN_ABBREVIATION_TAG, NAME_ROLES, SHAPE_ACRONYM_TAG, SUFFIX_OR_UNREAD, AmbiguityKind, PendingAmbiguity, PendingReading, WorkToken, ) from nameparser._pipeline._vocab import ( _PERIOD_ABBREV, _ROMAN, Lean, ambiguous_lean, in_initialless_script, is_listed_numeral, is_single_letter_numeral, numeral_leans_to_the_name, numeral_settled_by_case, reads_as_generation, tag_marker_runs, ) # rules/P3: "both questions asked of a name here and in P8 — how # many words it has, and whether it is written in one case — are # asked of the name's OWN words: a maiden marker taken as one, and the # words it takes (M2), are not among them, and neither is a delimited # clause (N1, M1)" (history: decisions/P3) def own_words(tokens: Sequence[WorkToken], comma_offsets: Sequence[int], markers: frozenset[str], marker_tags: Mapping[int, str] | None = None, ) -> tuple[list[str], int]: """The name's OWN word texts and the index the maiden clause starts at -- one span for the two stages that ask about it (#289/#516). Own words are the role-less tokens before the clause: a delimited clause's tokens arrive from extract with a role already set, and everything from a maiden marker on is the clause. Appending a clause to a name must not change how a word in the name reads. `marker_tags` is the map `_vocab.tag_marker_runs` already built, index -> "vocab:maiden-marker"/"...-cont"; a caller that has it (classify) hands it over and pays no second walk. A caller that runs BEFORE those tags exist (segment) omits it, and this function calls `tag_marker_runs` itself to build the SAME map classify would -- not an approximation of it, which is what a from-scratch text walk (this module's earlier `first_marker_head`) could disagree with on a name where a marker's head opens an entry but no run completes ('z' of 'z domu'): measured, 'ANNA z Nowak, MD' flipped the recorded one-case verdict under that approximation (decisions/P3). Sharing the exact function instead makes the two paths agree by construction, not by corpus luck. Takes tokens/comma_offsets/markers rather than a whole ParseState: both call sites have all three already, and passing them lets this function sit beside the piece predicates rather than in _vocab (mechanisms.md#ONE-PREDICATE-PER-QUESTION; the _post_rules.suffix_entries precedent, AGENTS.md's named exception) -- it answers with the SPAN, where `tag_marker_runs` answers only which tokens open a run. `marker_tags`' keys must arrive in index order for the walk below to find the SMALLEST head in one pass: `tag_marker_runs` walks its tokens left to right, so the first head it records is already the smallest, and a caller building its own map must preserve that order too. A plain tuple, not a NamedTuple: measured 2026-09-17, wrapping this in a `NamedTuple` (this module's `Peel` is one) cost the reference name one more frame (413.00 vs the 412.00 band this commit must hold) -- a NamedTuple's `__new__` is itself a call, where a bare tuple literal is not. `Peel` can afford the frame because assign builds one only where the trailing peel actually ran; `own_words` returns on every parse. """ if marker_tags is None: marker_tags = tag_marker_runs(tokens, comma_offsets, markers) clause_at = len(tokens) for i, tag in marker_tags.items(): if tag == "vocab:maiden-marker" and tokens[i].role is None: clause_at = i break return ([t.text for t in tokens[:clause_at] if t.role is None], clause_at) # rules/H3: "successive title words at the start of the part # carrying the given name chain into one title; a title word # elsewhere in the name does not" def is_title_piece(piece: Sequence[int], ptags: Set[str], tokens: Sequence[WorkToken]) -> bool: if "title" in ptags: return True return len(piece) == 1 and "vocab:title" in tokens[piece[0]].tags # rules/P4: "a particle in the name's leading position chains # nothing" -- WHERE that position is, asked once, by group's chain and # the trailing read's unit count (#624) def chain_lead(pieces: Sequence[Sequence[int]], ptags: Sequence[Set[str]], tokens: Sequence[WorkToken], n: int) -> int: """The particle chain's leading position, given `n`, the end of the leading title run (`leading_titles`): the last title in the run that is also a particle, else the first piece past the run. A title is no name word, so the first piece past the run leads; a word in both vocabularies ('Freiherr', 'St') leads in its stead and puts the particle behind it inside a name ('Freiherr von Berg', 'St van Johnson'), and of several such words the last leads, all being titles ('Freiherr St John Smith' as 'Dr. St John Smith'). No particle stands between the lead and the run's end, so the chain opens no unit inside the titles. The run is read as walked, before H3's give-back: a title the run hands back to the name because only suffix words follow it is still a title to the chain, so the particle behind it is the name's leading piece and chains nothing (P4: 'Dr. Mc Mc' keeps 'Mc' and 'Mc' apart, as before #624). `leading_titles` stops at a piece that is itself a leading title only where it gave that piece back, before a suffix piece (the tags it tests inline first), or at the segment's last piece, which a title may not be unless it is the whole segment -- the `n + 1 < len(pieces)` test rules that one out. The same tags go first here, so a name with no suffix behind its titles pays no frame for the test.""" if (n + 1 < len(pieces) and ("suffix" in ptags[n + 1] or "vocab:suffix" in tokens[pieces[n + 1][0]].tags) and is_leading_title(pieces[n], ptags[n], tokens)): n += 1 for k in range(n - 1, -1, -1): if is_prefix_piece(pieces[k], ptags[k], tokens): return k return n # A particle, or a piece a join made one: what group's prefix chain # (at its loop in _group_segment) chains and stops at, what P3's join # derives a prefix from, and what group's rootname count and leading # scan step past. def is_prefix_piece(piece: Sequence[int], ptags: Set[str], tokens: Sequence[WorkToken]) -> bool: if "prefix" in ptags: return True return len(piece) == 1 and "particle" in tokens[piece[0]].tags # rules/P3: "a recognized connective joins its neighbors into one # name part, connective runs included — except a single-letter # connective in a three-word name, which stays a name word, and a # single-letter connective that reads as an initial instead (P8), # which never joins" (history: decisions/P3) def is_conj_piece(piece: Sequence[int], ptags: Set[str], tokens: Sequence[WorkToken]) -> bool: if "conjunction" in ptags: return True return len(piece) == 1 and "conjunction" in tokens[piece[0]].tags def joined_tags(ptags: Sequence[Set[str]], lo: int, hi: int, add: Set[str] = frozenset(), drop: Set[str] = frozenset()) -> set[str]: # the ONE definition of a merged piece's tags: merge_pieces applies # it, and P5's reserve reads it to model the join it is # weighing (#425) -- so the view cannot drift from the merge. # A merged piece inherits every part's tags, so a site whose # product is not what its parts were drops what no longer # applies: the particle chain drops `prefix`, the bound join # `title` (a derived title tag on the pair would have assign # peel the given name as a leading title). return (set().union(*ptags[lo:hi]) | add) - drop def merge_pieces(pieces: list[list[int]], ptags: list[set[str]], lo: int, hi: int, add: Set[str] = frozenset(), drop: Set[str] = frozenset()) -> None: # pieces/ptags are parallel arrays; every merge must update # both in lockstep. # # Extend the first piece IN PLACE rather than rebuilding the # merged list. The obvious spelling -- # pieces[lo:hi] = [[i for p in pieces[lo:hi] for i in p]] # -- re-flattens everything accumulated so far on every call, so # a chain that merges into the same piece n times copies # 1+2+...+n and the stage goes quadratic in the length of the # chain. A conjunction run ("and " * n) does exactly that: it # measured 2.4x-2.9x per doubling against the 2.0x every other # shape holds. No piece list is aliased outside this function # (each caller builds every piece as a fresh list -- group's # [i], _comma's _pieces -- and reads pieces[k] only before a # merge), so mutating is safe; verified # identical token/role/tag/span/ambiguity output over 54,877 # names. tests/v2/test_benchmark.py's "and " shape is the guard. # # Every call site passes lo < hi, and this REQUIRES it: with # lo >= hi the slice assignment would insert rather than # replace, putting a second reference to pieces[lo] into the # array, and the next merge to touch either index would extend # the same list twice. The old rebuild-a-fresh-list spelling # was harmless there. Keep the bound if you add a caller. combined = pieces[lo] for piece in pieces[lo + 1:hi]: combined.extend(piece) pieces[lo:hi] = [combined] ptags[lo:hi] = [joined_tags(ptags, lo, hi, add, drop)] def join_connectives(pieces: list[list[int]], ptags: list[set[str]], tokens: Sequence[WorkToken], *, letter_stays: bool, titles_only: bool, frozen: Set[int] = frozenset()) -> None: """rules/P3's connective joins, for both parts that make them: group's name segments, and the part after a comma, which `_comma.decide` reads before group joins anything (#617, where the two had been copies that differed). Contiguous connectives merge into one first (v1: "of the"); then each connective joins its neighbours, taking its kind from the one on its left, or from the one on its right where it opens the part. `letter_stays` is the single-letter carve-out's answer for this part, and it reaches a LONE single-letter connective only: one beside another connective merges into their run first and joins with it. Group reads it from the name's rootname count; the comma part, which a title joins and a name word never does, always keeps the lone letter. `titles_only` is the comma part's: a join is made only where the neighbour is a title, and makes the joined piece one ('Mr. and Mrs.') with no prefix kind, nothing in that part being a particle chain; any other connective there is left a word, the reading deciding what it is. Otherwise every join is made and takes the neighbour's title and prefix kinds. `frozen` holds the TOKEN index of each connective that is to make no join or run of its own -- group's, the generational ones with no name word on one side; the comma part's, every generational one -- though a neighbour's join may still take one in.""" # contiguous conjunction runs merge first # # `pieces[k][0] in frozen` and not `frozen.isdisjoint(...)`: # the piece this loop extends GROWS with every merge, so a # test over its tokens costs 1+2+...+n and the stage goes # quadratic in the length of a connective run -- measured, # 'and ' x3200 took 41.8ms against 21.7ms, 6.2x per 4x input # where the shape reads 4.1x, and tests/v2/test_benchmark.py's # "and " shape is the guard that caught it. Reading the first # token alone is exact rather than an approximation: a frozen # piece is one token and this branch never merges it, and this # loop finishes before the join below can take one into a # neighbour's piece, so a piece holding a frozen token here IS # that token. k = 0 while k < len(pieces) - 1: if (is_conj_piece(pieces[k], ptags[k], tokens) and is_conj_piece(pieces[k + 1], ptags[k + 1], tokens) and pieces[k][0] not in frozen and pieces[k + 1][0] not in frozen): merge_pieces(pieces, ptags, k, k + 2, add={"conjunction"}) else: k += 1 # each conjunction joins its neighbors, rules/P3: "except a # single-letter connective in a three-word name, which stays a # name word" (v1's Google Code issue 11 carve-out, the # "john e smith" bug). k = 0 while k < len(pieces): # first token again, and here it is exact for the second # reason as well: the piece a join produces is left BEHIND # `k`, so no merged piece is ever tested twice. if (not is_conj_piece(pieces[k], ptags[k], tokens) or pieces[k][0] in frozen): k += 1 continue if letter_stays and len(pieces[k]) == 1: text = tokens[pieces[k][0]].text if not text.isascii(): # a decomposed 'й' is one letter in two code points # (#665); compose it, as the lookup does text = unicodedata.normalize("NFC", text) if len(text) == 1 and text.isalpha(): k += 1 continue start = max(0, k - 1) end = min(len(pieces), k + 2) neighbor = start if start < k else end - 1 derived = set() if is_title_piece(pieces[neighbor], ptags[neighbor], tokens): derived.add("title") elif titles_only: k += 1 continue if (not titles_only and is_prefix_piece(pieces[neighbor], ptags[neighbor], tokens)): derived.add("prefix") merge_pieces(pieces, ptags, start, end, add=derived) k = start + 1 # _PERIOD_ABBREV: imported from _vocab, not redefined here (#289/#516, # quality-review finding) -- _vocab.name_word_count needed the SAME # shape test is_leading_title asks (_vocab.is_title_shaped), and # layering only allows the move in that direction (_pieces may import # _vocab; _vocab may not import _pieces). tests/v2/test_regex_sync.py # still reaches it as `_pieces._PERIOD_ABBREV` -- an import binds the # same name here, so the sync test's target did not move. Out of # assign since #424 and in the piece layer since #439: the test is # assign's, and the chain's leading position (`chain_lead`) and the # trailing-run walk must start where assign starts. # rules/H2: "an abbreviation opening the part of the name that # carries the given name — the whole name, or the part after a # family comma — reads as a title even when unlisted" # (history: decisions/H2) def is_leading_title(piece: Sequence[int], ptags: Set[str], tokens: Sequence[WorkToken]) -> bool: if is_title_piece(piece, ptags, tokens): return True if len(piece) != 1: return False text = tokens[piece[0]].text # INLINED rather than calling _vocab.is_title_shaped, which asks # the exact same question (#289/#516, quality-review finding: the # two must not drift, and did once -- name_word_count's own # vocabulary-only title test read 'Xyz.' as a name word where this # predicate reads it as a title, and the disagreement flipped a # comma structure `Dr. Smith, Ed`'s LISTED spelling did not). # THIS is the one home for the number, `is_title_shaped` pointing # here rather than restating it: routing this hot path through the # shared function costs one frame per call (`is_leading_title` # runs on every leading piece of every parse, unlike # name_word_count's comma-only path), moving the reference name # from 412/449 to 417/454 -- five calls on `Dr. Juan de la Vega # III`, recomputable with `uv run python # tools/perf/call_count.py`. Kept as two spellings of ONE test # instead -- if you touch one, touch both, and # `test_is_title_shaped_and_is_leading_title_agree` (this module's # own test file) checks it over the union of both predicates' # example tables rather than leaving it to a sentence. return (bool(_PERIOD_ABBREV.match(text)) and (text.isascii() or not in_initialless_script(text)) # rules/H2's precedence: a given name written short outranks # the shape ('Chas.', #789) -- classify's tag, a C-level test and GIVEN_ABBREVIATION_TAG not in tokens[piece[0]].tags) def leading_titles(pieces: Sequence[Sequence[int]], ptags: Sequence[Set[str]], tokens: Sequence[WorkToken]) -> int: """How many leading pieces assign peels as titles: the first non-title index. A title needs a following piece, unless the whole segment is one title (v1 parity). And the run gives back its last piece when that piece is a name candidate: where everything behind the run is suffix pieces, the run gives back its last piece, when that piece is one word and is not itself suffix vocabulary (rules/H3, decisions/H3 -- the block at the floor below carries the examples of each half, and the ordering its two inline tag reads were measured on). One definition, read by assign (which sets the roles), by the chain's trailing-run walk, and by `chain_lead`, which finds the chain's leading position inside this run for group's chain and the trailing read alike (P4, #367, #424, #624).""" n = 0 while n < len(pieces): if ((n + 1 < len(pieces) or len(pieces) == 1) and is_leading_title(pieces[n], ptags[n], tokens)): n += 1 continue break # rules/H3: "where everything behind the run is post-nominal, # the run gives its last word back to the name, provided that word # stands alone and is not itself suffix vocabulary" # # ONE WORD, because a joined unit led by a title is a title run and # handing it back would lose the title: 'Prince of Wales Jr' reads # title 'Prince of Wales', family 'Jr', not given 'Prince of Wales' # with no title at all. # # Two residuals. A run whose last word IS suffix vocabulary is not # given back, so 'Dr Pope RA PhD' still reads title 'Dr Pope RA', # family 'PhD'. And the floor asks is_suffix_piece, which vetoes a # bare initial-shaped numeral, so 'Dr Pope V' keeps the whole run # as the title and reads the numeral as the name -- given 'V', # 'pope' being a given-name title and the run's last word, where # 'Dr Smith V' reads family 'V' (a title being no word to spare # for a one-letter numeral, #718). That numeral fork is outside # this floor (decisions/H3). # # The two inline tag reads are the cheapest NECESSARY condition for # the piece behind the run to be a suffix piece at all -- # is_suffix_piece cannot answer yes without one of them -- so the # ordinary titled name, whose next piece is no kind of suffix, # leaves this branch without entering a frame. Measured on the # plan's ordering rather than the shape below: asking the # authoritative predicate first cost 8 calls per parse of the # reference name (leading_titles runs four times), against a band # with room for two (decisions/parse-cost). is_suffix_piece # stays the predicate that ANSWERS, here and in the walk. if (n and n < len(pieces) and ("suffix" in ptags[n] or "vocab:suffix" in tokens[pieces[n][0]].tags) and len(pieces[n - 1]) == 1 and not is_suffix_piece(pieces[n - 1], ptags[n - 1], tokens)): for k in range(n, len(pieces)): if not is_suffix_piece(pieces[k], ptags[k], tokens): break else: # nothing behind the run but suffix pieces n -= 1 return n def is_suffix_piece(piece: Sequence[int], ptags: Set[str], tokens: Sequence[WorkToken]) -> bool: if "suffix" in ptags: return True if len(piece) != 1: return False tags = tokens[piece[0]].tags return "vocab:suffix" in tags and "initial" not in tags # rules/S8: "A title word never starts the run, and neither does a # member of the ambiguous class its writing leaves unsettled, a single # letter, a connective" -- what # starts #602's credential run, one predicate for the trailing peel and # the given-part walk (mechanisms.md#ONE-PREDICATE-PER-QUESTION) def starts_a_credential_run(piece: Sequence[int], ptags: Set[str], tokens: Sequence[WorkToken], preceding: str | None = None, one_case: bool | None = None) -> bool: """Whether one piece starts #602's run: an unambiguous suffix word of two or more letters, in any script -- a credential acronym, a generational word or an honorific -- that no other vocabulary claims as a name. `is_suffix_piece` already refuses the ambiguous class and an initial-shaped letter. Refused here: any single letter (a lowercase `i` or `v` mid-name is an initial or a connective far more often than a numeral, and carries no `initial` tag), a connective, a word that is also bound given-name or particle vocabulary (`abd` heads `Abd Allah`; `vd`, `mc`). No script is refused: an honorific starts the run as a credential does (#679, decisions/S8), so `Ана Хорват старији Џонс` reads as `Ana Horvat stariji Jones` does. The split credential group merges ('Ph. D.', the one piece carrying the "suffix" piece tag) starts it as its one-token spelling 'PhD' does: a run that started for one spelling of a word and not the other is the vocabulary-against-shape split nameparser/AGENTS.md's spelling sweep names (#603 found it, 'Smith, John Ph. D. Jones' keeping middle 'Jones' where 'Smith, John PhD Jones' reads suffix 'PhD Jones'). The no-comma spelling is not reached: `run_start` asks only of the pieces `peel_walk` keeps, and the walk drops the merged piece, so 'John Smith Ph. D. Jones' still reads family 'Jones'. A roman numeral of two or more letters starts it where `_vocab.reads_as_generation` reads it as the generation, with `preceding` and `one_case` that predicate's (#784): 'John Smith VI Jones' as 'John Smith III Jones'. """ if "suffix" in ptags or "split" in ptags: # a split credential alone: a connective join keeps the tag on # the wider piece ('Ph. D. and Mary'), which starts nothing, as # 'PhD and Mary' does not return len(piece) == 2 if len(piece) != 1: return False tok = tokens[piece[0]] if not tok.tags.isdisjoint(_NOT_A_RUN_START): return False # rules/S8: "a roman numeral of two or more letters that reads as # the generation" -- asked of the numeral's predicate, which the # vocabulary's own numerals answer too stem = tok.text.rstrip(".") if len(stem) >= 2 and _ROMAN.match(stem) is not None: return reads_as_generation(tok.text, tok.tags, preceding, one_case) # rules/S8: "and so does a listed member of the ambiguous class its # writing settles as the credential" -- the capitals mark the # post-nominals as periods do; the particle test above still # refuses 'DO' if AMBIGUOUS_ACRONYM_TAG in tok.tags: return (settled_by_case(tok, one_case) and sum(map(str.isalpha, tok.text)) >= 2) if not is_suffix_piece(piece, ptags, tokens): return False # C-level, no generator frame per character letters = "".join(filter(str.isalpha, tok.text.lower())) return len(letters) >= 2 #: tags of suffix words another vocabulary claims as a name word or a #: joiner, which therefore never start #602's run _NOT_A_RUN_START = frozenset({"conjunction", "particle", "vocab:bound-given"}) def split_credential_report(piece: Sequence[int], tokens: Sequence[WorkToken], alternative: PendingReading) -> PendingAmbiguity: """rules/S12's report on a split credential the count took, one spelling for the three sites that take one (assign's placement, the comma's binding, the maiden clause's release). `alternative` is the words' name reading, whose field each site knows.""" text = " ".join(tokens[i].text for i in piece) return PendingAmbiguity( AmbiguityKind.SUFFIX_OR_NAME, f"{text!r} is two words whose letters spell a credential, so it " f"reads as a credential; they may be name words", tuple(piece), alternative=alternative) def is_lone_never_given_particle(piece: Sequence[int], tokens: Sequence[WorkToken]) -> bool: """A piece that is one never-given particle (rules/P1's fold site). One predicate, asked by the fold in post_rules and by two sites in assign that predict the fold will take the particle forward: the given slot, which then leaves no given name in front of a member (#573), and P6's attachment after a family comma, which then leaves no given word for the tail to stand behind (#613) -- copies would drift silently, each site's own tests still passing (mechanisms.md#ONE-PREDICATE-PER-QUESTION).""" return (len(piece) == 1 and "particle" in tokens[piece[0]].tags and "vocab:particle-ambiguous" not in tokens[piece[0]].tags) def _numeral_behind_the_initial_veto(piece: Sequence[int], tokens: Sequence[WorkToken]) -> bool: """Suffix vocabulary that is_suffix_piece refuses because it is also initial-shaped: a ONE-CHARACTER entry, bare or with a period. Named for the shape rather than enumerated, because the shape is what the code tests and the enumeration goes stale -- in the shipped lexicon it reaches i, v and 2, and NOT x or ix (roman, but not suffix vocabulary) nor ii/iii/iv (suffix vocabulary, but two characters, so never initial-shaped and never vetoed in the first place). A caller adding a one-character suffix in a script that has initials extends it. The veto is right where such a word could be a middle initial, and wrong where it is describing the suffix in front of it, which is the only place this is asked from. The len(piece) != 1 guard is defensive: a merged multi-token piece carries "suffix" in ptags, so is_suffix_piece claims it one branch earlier and no reachable input arrives here with one. """ if len(piece) != 1: return False # A one-letter numeral here is the numeral's predicate's yes by # construction: in a comma part it reads one by the vocabulary, and # the tag below is that vocabulary (#752), so it is not asked again. tags = tokens[piece[0]].tags return "vocab:suffix" in tags and "initial" in tags def _anchors(piece: Sequence[int], tokens: Sequence[WorkToken]) -> bool: """Whether a suffix piece may ANCHOR an ambiguous member behind it (#544): not a connective, not a particle, and not a single-letter roman numeral. A connective anchors nothing because between two name words it is a link -- the generational 'i' is also Catalan's 'i' (rules/P3) -- and 'Jane Doe nee Puig i Ma' must keep its clause. A particle anchors nothing for the same reason from the other side: it joins the name word behind it (rules/P2), so a word that is both particle and suffix vocabulary ('vd', 'mc') standing in front of a member is the head of a family name, not a credential list -- 'Smith vd Ma, John' keeps family 'Smith vd Ma' and 'Jan vd Ma' family 'vd Ma'. A particle MEMBER is still anchored by a credential in front of it ('doe, jane v phd do'); the exclusion is of the anchor only. A single-letter roman numeral, in any case ('V', 'v', 'I.'), anchors nothing for its SHAPE: one letter is the shape a middle initial is written in ('V' can be one), and S3 retired single-character vocabulary matches for the same reason. Not because a numeral is no credential -- a multi-letter suffix word ('III', 'Jr') anchors like any other suffix piece (`is_single_letter_numeral`). A title/suffix DUAL ('ms', 'ra', 'sr') does anchor, except in the given part's leading title run, where it stands in title position ('Smith, Ms Ma' is Ms. Ma Smith); that exclusion is the callers', which start their walks past the leading title run or, in `segment_suffix_reading`, read no anchor at all in a part whose leading title run holds one. Asked only of a piece `is_suffix_piece` accepted, and only once a member's own writing has declined, so an ordinary name never pays for it.""" if len(piece) != 1: return True tok = tokens[piece[0]] return ("conjunction" not in tok.tags and "particle" not in tok.tags and not is_single_letter_numeral(tok.text)) # rules/S7: "Only a credential in FRONT speaks; one behind the # member says nothing about it." #544: an ambiguous member standing # BEHIND an unambiguous credential in one run is described by that # credential's company, not by its own writing -- 'PhD MEng' is a list of degrees whatever case 'MEng' # is written in. ONE forward pass per question, so a run of members # is read in linear time: a look-behind per member is quadratic in # the run, which test_benchmark's frame-ratio guard is built to catch. def credential_anchors(order: Sequence[int], pieces: Sequence[Sequence[int]], ptags: Sequence[Set[str]], tokens: Sequence[WorkToken], first_kept: bool = True) -> list[bool]: """For each position of `order` (piece indices in text order), whether a lone listed member standing there is ANCHORED: the contiguous run of pieces in FRONT of it that are suffix pieces or lone ambiguous members ends in -- counting back past the members -- a suffix piece that may anchor (`_anchors`). Any other piece ends the run. The anchor is in front only: 'Wang Ma PhD' leaves 'Ma' unanchored. The caller decides where the walk starts, and starts it past the leading title run, where a title/suffix dual reads as a title ('Smith, RA MA Ma' keeps its middle name). Where `order` holds the name part, its own leading position is the name the reserve keeps -- `rest[0]` at the no-comma peel, the given part's own first piece at the given-slot call sites -- so ITS writing never anchors what stands behind it, whatever vocabulary it carries: 'PhD Ma' keeps the count's reading, family 'Ma', rather than reading as a credential list headed by the given name itself. `first_kept` False is the one caller with no reserve, `segment_suffix_reading`, asking whether a comma part is a credential run at all: there the first piece is a run member like any other ('Smith, PhD MEng').""" out: list[bool] = [] anchor = False for pos, idx in enumerate(order): out.append(anchor) if pos == 0 and first_kept: continue piece = pieces[idx] if is_suffix_piece(piece, ptags[idx], tokens): # a suffix piece that may not anchor still ENDS the run it # would have anchored: 'PhD v Ma' is not a credential list # through the numeral anchor = _anchors(piece, tokens) elif not (len(piece) == 1 and AMBIGUOUS_ACRONYM_TAG in tokens[piece[0]].tags): anchor = False return out def given_slot_anchors(pieces: Sequence[Sequence[int]], ptags: Sequence[Set[str]], tokens: Sequence[WorkToken], start: int, end: int | None = None, skip: Container[int] = ()) -> list[bool]: """`credential_anchors` for the given part's slot, indexed by PIECE rather than by position: the pass over `start` to `end`, `skip` spliced out, and False before `start`, from `end` on and at every skipped piece. `start` is where the leading title run ends, so a title/suffix dual standing in it anchors nothing ('Smith, RA MA Ma'), and the piece at `start` is the given name the reserve keeps. The one home of that query for assign's given slot.""" stop = len(pieces) if end is None else end order: Sequence[int] = range(start, stop) if skip: order = [q for q in order if q not in skip] out = [False] * len(pieces) for q, anchored in zip(order, credential_anchors(order, pieces, ptags, tokens)): out[q] = anchored return out def anchor_in_reach(back: Iterable[int], pieces: Sequence[Sequence[int]], ptags: Sequence[Set[str]], tokens: Sequence[WorkToken], skip: Container[int] = ()) -> bool: """False where `credential_anchors` must answer False for the member standing just behind `back` -- the piece indices in front of it, nearest first, `skip` spliced out: past the lone members in front, the first other piece is no suffix piece, or there is none. True means only "ask the pass". Exact in that direction because the test is weaker than the pass's: a suffix piece carries "suffix" in its ptags or is one token carrying "vocab:suffix" (`is_suffix_piece`), so a piece failing both can neither anchor nor carry a run on. Read from tags alone, with no frame per piece, so the ordinary name ending in a Title-case member ('John Smith Ma', 'Doe, John Q. Ma') spends one frame here rather than the pass's one per piece. Callers pass the positions the pass itself would read or a superset reaching further back: the walk meets any anchor the pass would find before it reaches past the pass's start, so reaching further can cost a pass that finds nothing and never a reading.""" for q in back: if q in skip: continue piece = pieces[q] if "suffix" in ptags[q]: return True if len(piece) != 1: return False tags = tokens[piece[0]].tags if "vocab:suffix" in tags: return True if AMBIGUOUS_ACRONYM_TAG not in tags: return False return False def segment_suffix_reading(pieces: Sequence[Sequence[int]], ptags: Sequence[Set[str]], tokens: Sequence[WorkToken], lenient: bool, one_case: bool | None, anchored: list[int] | None = None, absorbed: list[int] | None = None, licensed: Container[int] = (), head_named: bool = True, # rules/S11 ) -> tuple[bool, ...] | None: """How each piece of a no-name segment reads: True a suffix, False a title. None when the segment holds a name word and so is not a credential run at all. `one_case` admits #289's credential lean: an ALL-CAPS member of the ambiguous set inside a mixed-case name is a credential in this slot even with one word before the comma, because the writing is evidence the count does not have ('Smith, MA' -> family 'Smith', suffix 'MA'). Only the LEAN reaches here on its own: a token admitted to the class by SHAPE takes the count instead, which `_comma.decide` takes and hands in as `licensed`, below ('Smith, A.B.' -> given 'A.B.', one name word being no count). A listed member ANCHORED by an unambiguous credential in front of it in the same run reads as a credential too, whatever its writing (#544, `credential_anchors`): 'Smith, PhD MEng' is family 'Smith' with two degrees. A title/suffix dual standing in the part's LEADING TITLE RUN -- every piece ahead of it a title or another such dual -- is a title there, and in a part that holds one this reading anchors nothing at all, not by the dual and not by a later credential: the dual makes the next name-position word the given name, and reading the part wholly as credentials would stack the anchor's guess on the dual's ('Smith, Ms Ma', 'Smith, Ms RA Ma' and 'Smith, RA PhD Ma' keep a given name, 'Smith, Prof. RA Ma' too). The walk then reads the part, and its given slot's company starts past that given name ('Smith, RA PhD Jr Ma' reads suffix 'Jr Ma'). A plain title ahead of the credential does not stop this reading ('Smith, Dr. PhD LAc' reads title 'Dr.', suffix 'PhD LAc'). `anchored`, when the caller passes a list, receives the index of every piece the anchor read as a credential after its own writing declined -- the picks the caller reports, since this decides them (mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE). A member whose own capitals lean credential is not among them. Meaningful only when the answer is not None: a segment the walk abandons part-way may have appended to it first. ONE reader since #613: `_comma.decide`, which reads the part once at group's head and binds the roles this returns, so the decision that the part holds no name word and the roles its words get cannot disagree. Until then it answered for two readers in _assign.py, the no-name gate and the router, and #429 shipped the inverse of its own fix by deriving that agreement twice (mechanisms.md#ONE-PREDICATE-PER-QUESTION); it answered for a third until #436, group's one-entry join. `licensed` holds the pieces rules/C4's count has licensed as credentials -- ambiguous-class words in a credential run behind two name words -- and each reads as a suffix whatever its writing or company. The initial veto (`_vocab.is_initial`) keeps a roman numeral out of a suffix reading, which is right after a NAME word (rules/C9's one-character suffix word, read by what stands before it): 'Smith, John V.' is a middle initial (#432). After a SUFFIX word the numeral is describing that suffix -- 'PSM I' is Professional Scrum Master level I -- so the run continues through it, period included, an initial there being no shape anyone writes (#430). A title resets that: what follows a bare title is not continuing a credential. A part OPENED by a credential is the postnominal part however it goes on (#603, rules/C13): where an unambiguous suffix word that starts #602's run (`starts_a_credential_run`) is the first suffix word of the part, with only titles in front of it (a title/suffix dual among them ends the chance: a credential behind one opens nothing), every later word is read as #602's run reads the given part -- a title word as a title, anything else as a suffix -- so 'John Smith, PhD Jones' and 'Smith, PhD Jones' read suffix 'PhD Jones'. A word of the title vocabulary as well opens nothing (#603, Derek): in front of a given name it is the listing form's title ('Smith, Ms Jane'), and no count of the words before the comma can tell a two-word surname from a given name and a family name, so none decides it. `absorbed`, when passed, receives the index of every piece the opened part takes that would otherwise have made this None -- the caller reports them, as #602's run reports the name words it takes. None covers both ways a segment can fail to be a run: a name word anywhere in it (in a part no credential opened), and no pieces at all ('Doe,, Jr.', which holds no title to read by). `lenient` is Policy.lenient_comma_suffixes, and only the numeral continuation consults it. A part after the comma that is nothing but suffix words, rules/C14: "stops being a credential run at such a word" -- such a word being a suffix word written as an initial. Under strict the numeral gets no suffix reading behind a suffix word, so a part no credential opened is no run and the reading is None ('Smith, RA I'); a part a credential opened (C13) still takes the word in through `opened` below and hands it to `absorbed` ('Smith, PSM I.'), which implements rules/C14's Except bullet "A credential opening the part". The walk reads the token's `initial` tag, so a one-case lowercase letter is vetoed as its capital is (rules/T4). Reading no policy here silently overrode the one knob a caller sets to prevent exactly this (#430). History: decisions/C14 (#761), decisions/C13 (#603). The FAMILY_COMMA rule that segment 0 is the family name rests on the writer having said where the family name ends. A comma followed by no name word said no such thing -- 'John Smith, Dr.' is 'Dr. John Smith' with the honorific moved -- so the pre-comma name keeps its positional read instead of being merged (through `_comma.decide`'s suffix comma, behind a whole name). Uses the same is_leading_title predicate the peel does, period-abbreviation inference included, so the two cannot disagree about what a title is; a mixed run like 'Smith, Dr. Jr.' is a title and a postnominal, each read where it stands, never a title run 'Dr. Jr.'. """ if not pieces: return None out: list[bool] = [] # #544: `credential_anchors` over the whole part, computed the # first time a member's writing declines with a suffix piece in # reach in front of it (`anchor_in_reach`, asked only while the # pass does not exist yet). A member opening the part has nothing # in front and asks neither ('Smith, Ed', 'Smith, Ma John'); a # name word in front returns None before the member is reached; # and a title in front ends the reach test, so 'Smith, Dr. Ma' # pays that one test and no pass. anchors: list[bool] | None = None # Whether every piece so far stands in the part's leading title # run (titles, and title/suffix duals), and whether a dual has # stood there: once one has, nothing in the part anchors. Tracked # here rather than read off `leading_titles`, whose period-shape # inference counts a suffix like 'Esq.' into the run ('Smith, Esq. # RA Ma' keeps its anchored 'Ma' only by this walk's reading). leading = True dual_led = False # #603: the first suffix word of the leading run, which may open # the part unless a dual stood ahead of it; asked whether it does # only once a piece reaches the title and name branches below, so a # part of nothing but suffix words ('Smith, Jr.', 'Smith, PhD MA') # never pays for the question opener: tuple[Sequence[int], Set[str]] | None = None opened = False for piece, tags in zip(pieces, ptags): # the verdict just recorded IS "stands behind a suffix" -- keeping # a separate flag meant maintaining that equality by hand at three # sites, and a fourth branch that appended without assigning would # have diverged silently after_suffix = bool(out) and out[-1] if is_suffix_piece(piece, tags, tokens): if (leading and len(piece) == 1 and "vocab:title" in tokens[piece[0]].tags): dual_led = True else: if leading and not dual_led: opener = (piece, tags) leading = False out.append(True) continue member = (len(piece) == 1 and AMBIGUOUS_ACRONYM_TAG in tokens[piece[0]].tags) # rules/S11: "The floor holds first" -- at the first slot after # a comma a generation word's writing adds nothing to the count # C4 reads first, `head_named` being that count's two name # words before the comma (', Dr. fils' and 'Sir Alexandre, fils' # keep a family word) if member and listed_lean(tokens[piece[0]], one_case) \ == "credential" and ( head_named or AMBIGUOUS_WORD_TAG not in tokens[piece[0]].tags): leading = False out.append(True) continue # A word rules/C4's count licenses as a credential: the # caller (`_comma.decide`) counted two name words before the # comma and found the part a credential run, which settles the # ambiguous word here before S7's anchors are asked (#613) if piece[0] in licensed and (len(piece) == 1 or "split" in tags): leading = False out.append(True) continue if (member and not dual_led and SHAPE_ACRONYM_TAG not in tokens[piece[0]].tags): if anchors is None and out and anchor_in_reach( range(len(out) - 1, -1, -1), pieces, ptags, tokens): anchors = credential_anchors(range(len(pieces)), pieces, ptags, tokens, first_kept=False) if anchors is not None and anchors[len(out)]: leading = False if anchored is not None: anchored.append(len(out)) out.append(True) continue if (lenient and after_suffix and _numeral_behind_the_initial_veto(piece, tokens)): leading = False out.append(True) continue if opener is not None: # asked once: cleared, as it is set at most once opened = starts_a_credential_run(opener[0], opener[1], tokens) opener = None if opened: # #602's run: a title word reads as a title, anything else # as a suffix -- vocabulary only, as in the given part if is_title_piece(piece, tags, tokens): out.append(False) else: if absorbed is not None: absorbed.append(len(out)) out.append(True) elif is_leading_title(piece, tags, tokens): out.append(False) else: return None return tuple(out) class Peel(NamedTuple): """What assign's trailing peel made of a walk. `names` is a count of positions in the caller's `rest`: rest[:names] are the name pieces and rest[names:] the suffixes. The other two are pieces -- token-index tuples, as PendingAmbiguity wants them -- and each is one token long: `numerals` are the pieces the roman-numeral fork took as a call, each reported, in peel order (none for a numeral its lower case settled or the vocabulary lists in two or more letters, which is taken unrecorded; a one-letter one is always the walk's last piece, one of two or more letters anywhere in its trailing run, #752), `picks` the bare ambiguous acronyms the peel had to resolve, in peel order, either way (the last may sit at rest[names - 1]), and a numeral the fork declined, a one-letter one for want of words to spare (#718), always the walk's last piece, or one of two or more letters for its Title case (#752), anywhere in the trailing run, and a name unless a credential run in front of it takes it. `anchors` is `peel_trailing`'s working state for `tail_reading` alone -- the walk's `credential_anchors` pass over `rest` where a member needed one, handed to the next pass of the fixed point (`peel_trailing`'s `start`). No caller reads it, and the Peel `tail_reading` returns carries None.""" names: int numerals: tuple[tuple[int, ...], ...] picks: tuple[tuple[int, ...], ...] anchors: list[bool] | None #: #602 (`credential_run`): the pieces in rest[names:] that are #: title words inside the credential run, and the name pieces the #: run absorbed, which assign reports run_titles: tuple[int, ...] = () absorbed: tuple[tuple[int, ...], ...] = () # rules/S2: "a trailing word of the suffix vocabulary reads as a # suffix — generational forms and credential acronyms alike" -- and # rules/S3: "an ambiguous acronym written with its periods, one after # each letter or one after each of two or more letter chunks, counts # unambiguously; a single trailing period is the abbreviation shape # any word can wear and does not" -- and rules/S4: "a BARE ambiguous acronym is consumed only when # the name has words to spare" # (v1's are_suffixes tail rule, with the roman-numeral special) def peel_walk(start: int, ptags: Sequence[Set[str]]) -> list[int]: """The indices peel_trailing walks: `start` to the segment's end, minus the group-flagged credential pieces (the Ph. D. merge), which assign reads as suffixes at any position. Built here and nowhere else, so the walk's input cannot drift between assign and the group sites that read it: the numeral fork is a last-piece test that reads the piece before as rest[k - 2], which holds only over this list.""" return [j for j in range(start, len(ptags)) if "suffix" not in ptags[j]] def trailing_start(start: int, pieces: Sequence[Sequence[int]], ptags: Sequence[Set[str]], tokens: Sequence[WorkToken], *, one_case: bool | None) -> int: """Where assign's trailing suffix run begins, read over the pieces as they stand from `start`: the index of the first piece the S2 peel takes, or len(pieces) when it takes none (#424). What P2's chain stops before where S2's run was not read once ahead of it (#614: the part before a family comma, a part past the second comma, a run holding a name piece; the given part after a family comma stops at `trailing_run_start` since #623), and what M2's walk stops before where no trailing rule reads the clause -- each had asked "is this a suffix?" with the suffix-piece test, which vetoes a bare 'V' as an initial (the #401 question), and so took a trailing numeral, or a bare acronym with words to spare, into the family or the maiden name. Both forks, always, and #602's credential run on top (`credential_run`), so P2's chain stops where the run starts. Readers that also need H5's title chain read the same pieces through `tail_reading`, which runs this pair's peel and the chain to a fixed point -- the maiden take among them (#601). So this function's answer is the whole answer only where no trailing title chain also reads the pieces.""" rest = peel_walk(start, ptags) names = run_start(rest, peel_trailing(rest, pieces, ptags, tokens, one_case).names, pieces, ptags, tokens, one_case=one_case) return rest[names] if names < len(rest) else len(pieces) # #289/#516: the "listed member, not by-shape" test both # peel_trailing and segment_suffix_reading ask before reading the # lean -- shared here so the two cannot drift on what counts # (quality-review finding: it was spelled twice, once per site, # before this). Those two test "vocab:suffix-ambiguous" in tags # INLINE, before calling this, rather than leaving that cheap check to # this function's own body: measured, a caller whose `elif` reaches # this on every piece (segment_suffix_reading's does, one per # family-comma segment 1, member or not) pays one frame for the call # regardless of what is inside it, and the inline pre-check is what # keeps a non-member piece ("Smith, John"'s "John") from ever making # the call at all. # # A further caller since #533 -- credential_at_the_given_slot just # below -- deliberately does NOT pre-check: it owns #531's reading # and leaves membership to its own callers (its docstring says so), # and the frame argument holds transitively because its caller asks # inline -- `AMBIGUOUS_ACRONYM_TAG in tok.tags` after a # `len(piece) == 1` at assign's given-part trailing slot. So no # non-member piece reaches this function down that route either. def listed_lean(token: WorkToken, one_case: bool | None) -> Lean | None: """`ambiguous_lean` for a LISTED bare-ambiguous token, or None if the token is not tagged a listed member, is admitted by SHAPE instead (`SHAPE_ACRONYM_TAG`, a switch's doing, not the writing's), or there is no case fact to ask at all.""" if (one_case is None or AMBIGUOUS_ACRONYM_TAG not in token.tags or SHAPE_ACRONYM_TAG in token.tags): return None # rules/S11: "An agnomen, the Portuguese `Filho`, `Neto`, `Sobrinho` # and their feminines, is written in the case of the name around it, # so its writing never speaks" -- the cue is what the member is if AMBIGUOUS_WORD_TAG in token.tags: return ambiguous_lean(token.text, one_case, "none" if AGNOMEN_TAG in token.tags else "word") return ambiguous_lean(token.text, one_case) # rules/S6: "a clash settled by the writing is not reported" -- the # gate's form step for a listed acronym, asked where a reading is # chosen, so the word keeps its tags and with them its other readings # (history: decisions/S6 (#785)) def settled_by_case(token: WorkToken, one_case: bool | None) -> bool: """Whether the writing settles a LISTED ambiguous acronym as the credential: written in capitals in a name of mixed case (`listed_lean`). Only the clash between the credential and the name reading is settled; the word stays a member of the class, so a particle reading ('DO') or the place a maiden clause gives its first word still answers as before. A suffix word that is also a name ('fils') is S11's and is not asked here.""" return (AMBIGUOUS_WORD_TAG not in token.tags and listed_lean(token, one_case) == "credential") def credential_at_the_given_slot( token: WorkToken, one_case: bool | None, anchored: Callable[[], bool] | None = None) -> bool: """#531's reading of a class MEMBER ending the given part after a family comma: the credential unless the writing says otherwise. The caller decides membership and that the piece ends that part. The words to spare are there by construction at that slot, so the count says nothing and only the lean does; a member that is also particle vocabulary reads as the credential on a POSITIVE lean alone, P6's attachment keeping every other spelling. Or where the member is ANCHORED (#544): an unambiguous credential in front of it in the same run (`credential_anchors`) makes it the credential whatever its writing says, a particle member included -- a degree in front outranks P6's attachment, as the capitals do ('doe, jane v phd do' reads suffix 'v phd do'). Only the caller knows the run, so it computes the anchor; `anchored` is a THUNK, asked last, so a member the writing already settles never pays for the walk. Called from assign's walk over the given part, as one function rather than a condition written at the site (mechanisms.md#ONE-PREDICATE-PER-QUESTION). It is a text-and-tags question, which is what puts it in this module rather than beside any caller. The membership half of that contract is CHECKED rather than trusted, because getting it wrong is silent: all three of `listed_lean`'s None reasons fall through to the `particle` test below, so a non-member handed in by mistake is answered True -- "read it as the credential" -- for a word the class never admitted. An assert rather than a raise or a branch: it enters no Python frame (measured -- 'Doe, John MA' stays at 311), it states the contract where a reader of the function body meets it, and under -O it is exactly the code that was here before. """ assert AMBIGUOUS_ACRONYM_TAG in token.tags, ( f"credential_at_the_given_slot is #531's reading of a LISTED " f"class member; {token.text!r} carries {sorted(token.tags)} " f"and is not one. The caller decides membership -- test " f"AMBIGUOUS_ACRONYM_TAG before calling") lean = listed_lean(token, one_case) if lean == "credential" or (lean is None and "particle" not in token.tags): return True return anchored is not None and anchored() #: `_chain_units`' marks: a piece opening a name unit, and a name word #: the chain joins to the run in front of it OPENS, JOINED = 1, 0 def second_unit(rest: Sequence[int], units: Sequence[int]) -> int: """The position in `rest` of the piece opening its second name unit, `len(rest)` where there is none: `rest[:m]` holds two units exactly when this is below `m`. The first piece always counts as a unit's, being where the name the walk reads starts.""" for i in range(1, len(rest)): if units[rest[i]] == OPENS: return i return len(rest) def _kept_in_front(rest: Sequence[int], front: int, pieces: Sequence[Sequence[int]], ptags: Sequence[Set[str]], tokens: Sequence[WorkToken], one_case: bool | None) -> int: """The position in `rest` of the last word the trailing walk keeps at or before `front`, which stands in front of a generation word that is also a name, so that word's count looks past what the walk takes there (rules/S11, #890); -1 where it keeps none. Taken by itself: a suffix piece, a numeral of two or more letters read as the generation ('Pedro VI Filho'), a member of the ambiguous class its writing makes a suffix ('Pedro MA Filho'). Taken when carried: a bare member of the class behind any of those, the members between passing the run on, as rules/S7's company reads it ('Pedro Junior Ma Filho'). One pass, so a carried run costs a step a member.""" members: int | None = None # where an uncarried run of members began j = front while j >= 0: piece = pieces[rest[j]] if is_suffix_piece(piece, ptags[rest[j]], tokens): members = None # a carrier: the run behind it is taken j -= 1 continue if len(piece) != 1: break tok = tokens[piece[0]] stem = tok.text.rstrip(".") if ((len(stem) > 1 and _ROMAN.match(stem) is not None and reads_as_generation( tok.text, tok.tags, tokens[pieces[rest[j - 1]][0]].text if j >= 1 else None, one_case)) or (AMBIGUOUS_ACRONYM_TAG in tok.tags and listed_lean(tok, one_case) == "credential")): members = None j -= 1 continue # a bare member that leans nowhere is taken only if something in # front carries the run; an agnomen carries nothing and is kept if (AMBIGUOUS_ACRONYM_TAG not in tok.tags or AMBIGUOUS_WORD_TAG in tok.tags): break if members is None: members = j j -= 1 return j if members is None else members def peel_trailing(rest: Sequence[int], pieces: Sequence[Sequence[int]], ptags: Sequence[Set[str]], tokens: Sequence[WorkToken], one_case: bool | None, start: int | None = None, anchors: list[bool] | None = None, units: Sequence[int] | None = None, second: int = 1, count_numeral: bool = True) -> Peel: """The S2 trailing peel over `rest`, a peel_walk list. In the piece layer rather than in assign because group's bound-given reserve asks the same question of the view the join would leave (#425): one walk, so the reserve and the assignment cannot drift. Pure -- the ambiguities are returned for assign to report, in the order it always reported them. `one_case` is ParseState.one_case, the recorded fact: None means nobody asked, which is every caller that has no state to ask with, and reads as "no lean" -- rules/S4's count alone, the behavior of every release before this one. `start` and `anchors` resume a walk rather than begin one: the walk picks up at `rest[start - 1]`, as though the pieces from `start` on were already peeled, with `anchors` the pass an earlier walk built (or None). Only `tail_reading` passes them, and its docstring states when that answers what a fresh walk would. The returned picks and numerals are the resumed walk's own; a one-letter numeral is never read, `start` standing short of the walk's last piece. `second` is where the second name unit opens in `rest` (`second_unit`), the caller's to find once; positions are units where `units` is None, and it is 1. `units`, where given, marks each piece by the unit the chain will put it in (`_chain_units`, #620): the words to spare are counted in units, so a particle chain counts as the one name word P2 makes of it, and a word the chain joins to a run without the reading weighing it is a name word the walk stops at. The numeral fork still reads the piece in front of the numeral as written: an initial there keeps it a name word whatever unit the initial is in ('John van der J. V', rules/P2). `count_numeral` False takes a one-letter numeral without asking for words to spare: the given part after a family comma is read without the count, and its chain bound must not ask it (#718). Every numeral is weighed by `_vocab.reads_as_generation`, the one predicate every numeral rule asks (#752). """ picks: list[tuple[int, ...]] = [] numerals: list[tuple[int, ...]] = [] # where the second name unit opens in `rest`, found once a member # asks; positions are units where `units` is None k = len(rest) if start is None else start while k > 0: piece = pieces[rest[k - 1]] # a word the chain joins to a run, and the reading does not # weigh, is a name word if units is not None and units[rest[k - 1]] != OPENS: break if is_suffix_piece(piece, ptags[rest[k - 1]], tokens): k -= 1 continue # rules/S10: "the numeral reads as the generation" -- every # numeral the suffix test above refused, the listed ones it took # being the predicate's yes too. One of two or more letters is # read anywhere in the trailing run ('John Smith VI MD' as 'John # Smith III MD', #784); one of one letter only where it ends the # walk, since with a credential behind it it is the name word # S10's Accepted clause keeps (#721). `_ROMAN` on the stem first, # C-level, so a name word pays no frame. A one-letter numeral # "needs words to spare, as a bare ambiguous acronym does" -- # the acronym fork's own count, below, so 'John V' keeps family # 'V' (#718). Declined, it is a pick, since "either reading is # reported", and no branch below can take a numeral if (k >= 2 and len(piece) == 1 and _ROMAN.match(text := tokens[piece[0]].text.rstrip(".")) is not None and text and (len(text) > 1 or k == len(rest) # the walk leaves out the Ph./D. pair, which is the # credential behind the numeral all the same and not (rest[k - 1] + 1 < len(ptags) and "suffix" in ptags[rest[k - 1] + 1]))): tok = tokens[piece[0]] if reads_as_generation(tok.text, tok.tags, tokens[pieces[rest[k - 2]][0]].text, one_case): # rules/S10: "a numeral written in lower case in a name # of mixed case is the generation and reports nothing" # -- decided here once: taken as any suffix word is, and # not recorded as the fork's call, so no reporter meets # it if numeral_settled_by_case(tok.text, one_case): k -= 1 continue if (len(text) > 1 or not count_numeral or k - 2 >= second): numerals.append(tuple(piece)) k -= 1 continue picks.append(tuple(piece)) break # rules/S10: "a Title-case numeral in a name of mixed case # leans to the name" -- weighed and declined, so reported if numeral_leans_to_the_name( tok.text, tok.tags, tokens[pieces[rest[k - 2]][0]].text, one_case): picks.append(tuple(piece)) break # A bare ambiguous acronym ("MA", not "M.A.") is a credential # only when peeling it still leaves a given AND a family name. # With two pieces, "one of them is a credential" is the less # likely reading, so it stays the family name -- "Jack MA" is a # person, "John Smith MA" is a person with a degree. This is # v1's reserve_last narrowed to the ambiguous set: 2.0 # deliberately peels UNambiguous suffixes even when nothing is # left ("Smith PhD" -> suffix, a classified fix), because there # the vocabulary is not in doubt. bare_ambiguous = (len(piece) == 1 and AMBIGUOUS_ACRONYM_TAG in tokens[piece[0]].tags) # rules/S12: a split credential reads "as a member of the # ambiguous class" -- read by the count alone, its writing carrying no # case to lean on, and in silence: its other reading is two # name words, which the count already refuses it behind one if len(piece) == 2 and "split" in ptags[rest[k - 1]]: if k >= 2 and k - 2 >= second: k -= 1 continue break # #516, switch off: the writing still makes this token # credential-SHAPED, and the parser is choosing the name # reading over that one -- the fork the caller asked to be # told about. Reported here, unconsumed, rather than folded # into `bare_ambiguous` above: with the switch off the token # never carries "vocab:suffix-ambiguous" (classify's own # gate), so `bare_ambiguous` is already False and this is the # ONLY place the report can be recorded. Switch ON, the token # carries BOTH tags, so `not bare_ambiguous` is what stands # this branch down and lets the consuming branch below take # it; the two are not exclusive. if (not bare_ambiguous and k >= 2 and len(piece) == 1 and SHAPE_ACRONYM_TAG in tokens[piece[0]].tags): picks.append(tuple(piece)) break # #289: written case is evidence the count does not have, and # it overrides the count in BOTH directions -- an all-caps # member of a mixed-case name is taken with nothing to spare # ("Jack MA"), a Title-case one is declined with plenty # ("John Smith Ma"). The lean is the LISTED set's alone: a # token admitted to this class by SHAPE carries no writing # convention to read, so it takes the count (decisions/S6). # # k < 2 means it is the only piece left, which is not the fork # this reports -- and not a floor the lean moves: the walk # starts after the leading title run, so one piece behind a # title ("Mr MA") is exactly this case and must stay a name. # The lean is computed only past this floor -- membership, # then the floor, then the count-or-lean, in that order, with # nothing computed a step earlier could discard. if bare_ambiguous and k >= 2: # rules/S6: "a clash settled by the writing is not # reported" -- taken as any suffix word is, and no pick if settled_by_case(tokens[piece[0]], one_case): k -= 1 continue picks.append(tuple(piece)) lean = listed_lean(tokens[piece[0]], one_case) # peeling still leaves given + family, or the writing says # to peel anyway # rules/S11: "only where the name has words to spare" -- a # generation word counts the NAME words in front of it, so # what the walk takes there ('Pedro Junior Filho', 'Pedro VI # Filho', 'Pedro Junior Ma Filho') is not counted as the word # spared and then peeled as well (#890). One scan to the # last word the walk keeps, so a run costs a step a word front = k - 2 if AMBIGUOUS_WORD_TAG in tokens[piece[0]].tags: front = _kept_in_front(rest, front, pieces, ptags, tokens, one_case) # rules/S11: "the floor holds first" -- a generation word's # case may settle it as the suffix only where the name keeps # a family word ('Pedro Junior fils' keeps family 'fils', # Derek, #890); an acronym's capitals take it with none gated = AMBIGUOUS_WORD_TAG in tokens[piece[0]].tags if (lean == "credential" and (not gated or front >= second) or (lean is None and front >= second)): k -= 1 continue # #544: or an unambiguous credential stands IN FRONT of it # in the same run -- 'John Smith PhD MEng' is a list of # degrees, not a family name 'MEng'. Computed once per # walk, and only when a member has declined and a suffix # piece is in reach, so an ordinary name never pays for # it. Still a pick: the fork is reported as a counted # member's is. # # `rest[k - 2:0:-1]` is what stands in front of the member # down to, not including, `rest[0]`, the name the reserve # keeps, which never anchors (`credential_anchors`). So a # member at k == 2 finds nothing in reach. A by-shape member # (a lean of None) gets here at k == 2, or, counted in the # chain's units (#620), behind a run the chain joins into # one word ('Freiherr von Berg G.J.', at k == 3), where # what it finds in reach is that run's words. # The pass, once built, answers first: asked of every # member, the reach test would walk back through the run # each time, one look-behind per member. # A generation word that is also a name has no company to # keep (rules/S7's credentials): a suffix word in front of # it is no evidence, its count above having looked past it if AMBIGUOUS_WORD_TAG in tokens[piece[0]].tags: break if anchors is None and anchor_in_reach( rest[k - 2:0:-1], pieces, ptags, tokens): anchors = credential_anchors(rest, pieces, ptags, tokens) if anchors is not None and anchors[k - 1]: k -= 1 continue break return Peel(k, tuple(numerals), tuple(picks), anchors) def behind_the_merged_credential(rest: Sequence[int], p: int, ptags: Sequence[Set[str]]) -> bool: """Whether the Ph./D. pair stands right in front of `rest[p]`, left out of the walk (`peel_walk`) though it is a credential there all the same -- the test `run_start` asks inline, its hot loop paying no frame for it (#827).""" return (p > 0 and rest[p] - rest[p - 1] == 2 and "suffix" in ptags[rest[p] - 1]) def _name_word_behind(piece: Sequence[int], ptags: Set[str], tokens: Sequence[WorkToken], one_case: bool | None) -> bool: """Whether the piece behind a member its capitals settle reads as a word of the name rather than a further credential, asked of its first word, since group has merged particle runs and connective joins by then ('de la Vega', 'Ortega y Gasset'): a particle, a word of a script with no case, or a cased word not written in lower case (Title case or capitals, title vocabulary included), that is no suffix word and no member its own capitals settle. A credential merged across a space ('Pharm D', 'Ph. D.') is no name word. Only `run_start` asks it, of a settled member.""" if "suffix" in ptags or "split" in ptags: return False tok = tokens[piece[0]] if "particle" in tok.tags: return True text = tok.text if text.islower(): return False return ("vocab:suffix" not in tok.tags and SHAPE_ACRONYM_TAG not in tok.tags and not (AMBIGUOUS_ACRONYM_TAG in tok.tags and settled_by_case(tok, one_case))) # rules/S8: "A credential after the name core starts a run to the # end of its part" -- applied to a finished peel (#602), so every # reader of the trailing run (assign, P5's reserve, P2's chain stop, # the maiden take) sees one answer. def run_start(rest: Sequence[int], names: int, pieces: Sequence[Sequence[int]], ptags: Sequence[Set[str]], tokens: Sequence[WorkToken], units: Sequence[int] | None = None, one_case: bool | None = None, initials_cue: bool = True) -> int: """Where #602's run starts in `rest[:names]`: the position of the first credential that starts one (`starts_a_credential_run`) with two name pieces in front of it, or `names` where none does -- with no comma the family has to exist first. A lone particle does not count toward the two, 'de Mesnil' being one surname, unless the run starts right behind it (#627). The position alone, for readers that need only where the name ends (`trailing_start`); `credential_run` builds the rest of the answer. The tag tests are INLINE ahead of the predicate, so a name word -- every piece of an ordinary name -- and a connective or particle never pay the predicate's frame: this runs on every parse, once per piece, and a clause holding a run of links ('Puig i i i ... Soler') otherwise paid two frames per link (decisions/parse-cost). The predicate repeats the refusal; the inline copy is only the cheap half of the same question. Under three names no run can start, two core pieces having to stand in front of it. `units` (`_chain_units`, #620) counts over pieces the chain has not joined yet: a word the chain will join to the run in front of it is no name word of its own, and a particle opening such a run is the one the run makes ('der la', merged into one piece ahead of the read, one surname).""" if names < 3: return names core = 0 lone = False for p in range(names): q = rest[p] tags = tokens[pieces[q][0]].tags if core + lone >= 2: # the Ph./D. pair standing in front of this piece starts the # run there, though the walk leaves it out (#827): 'John # Smith Ph. D. Jones' as 'John Smith PhD Jones' # (`behind_the_merged_credential`, inline) if (q - rest[p - 1] == 2 and "suffix" in ptags[q - 1] and starts_a_credential_run(pieces[q - 1], ptags[q - 1], tokens)): return p if (("vocab:suffix" in tags or "split" in ptags[q] or AMBIGUOUS_ACRONYM_TAG in tags or _ROMAN.match(tokens[pieces[q][0]].text.rstrip(".")) is not None) and tags.isdisjoint(_NOT_A_RUN_START) # p >= 2 here: two name words stand in front and starts_a_credential_run( pieces[q], ptags[q], tokens, tokens[pieces[rest[p - 1]][0]].text, one_case) # rules/S8: "not where a name word stands behind it" # -- a settled member followed by one is the # writer's initials as likely as a credential and not (initials_cue and p + 1 < len(rest) # the Ph./D. pair behind it is a credential # the walk leaves out (#827) and not behind_the_merged_credential( rest, p + 1, ptags) and AMBIGUOUS_ACRONYM_TAG in tags and settled_by_case(tokens[pieces[q][0]], one_case) and _name_word_behind(pieces[rest[p + 1]], ptags[rest[p + 1]], tokens, one_case))): return p # a lone particle is not yet a name: 'de Mesnil' is one surname # -- unless it opens a chain run, which is then the surname # (with no `units` every piece opens its own), or the run # starts right behind it, leaving it nothing to join: then it # counts as a name word of its own, as 'van der' and 'von und # zu' do (#627, 'John von PhD Jones') if units is None or units[q] == OPENS: lone = (len(pieces[q]) == 1 and "particle" in tags and (units is None or q + 1 == len(units) or units[q + 1] == OPENS)) core += not lone return names # rules/P6: "a particle ending the name attaches to that family # name" -- WHICH particles, asked once, by assign's given-part walk, # which both leaves them out of #602's run and attaches them (#613; # #610 had first made the run and post_rules' attachment one walk) def particle_tail(seg: Sequence[Sequence[int]], tokens: Sequence[WorkToken]) -> tuple[int, int]: """`seg[lo:hi]`, the run of wholly-particle pieces P6 attaches: back from the end past pieces that hold no name word and are not themselves particles -- a post-nominal is written BEHIND the particle in this listing -- then back over particle pieces, stopping at a lone member of the ambiguous credential class read as the credential (#531: the capitals or a degree made it one, so it is not the tussenvoegsel). The class-member stop keys on the VOCABULARY tag and not on the suffix role alone, which is what gives P6's attachment precedence over S2: `vd` and `mc` are unambiguous suffix vocabulary, also particles, also suffix-roled, and the tag is what keeps them inside the run, while the role alone would have stood the attachment down for #531's member too ('Doe, John DO' read family 'DO Doe' with the condition absent, verified when #531 landed). A trailing piece that IS particle vocabulary ends the first walk rather than being looked past, since `vd` arrives suffix-roled and is the run. Read off ROLES, asked at the moment assign's walk reaches #602's run (or, with no run, once the walk has placed every piece): every word in front of the run holds the role the walk gave it, and the run's words hold none yet -- no name role, and a class member not yet read as anything, the run being what will read it as the credential. That is why the answer equals the one this would give over the finished roles, which is what the attachment needs. `lo == hi` where there is no such run. The single-token tests are inline (the common piece is one token), so the walk pays no frame per piece; it runs on every family-comma parse.""" hi = len(seg) while hi > 0: piece = seg[hi - 1] if (all("particle" in tokens[i].tags for i in piece) if len(piece) > 1 else "particle" in tokens[piece[0]].tags): break if (any(tokens[i].role in NAME_ROLES for i in piece) if len(piece) > 1 else tokens[piece[0]].role in NAME_ROLES): break hi -= 1 lo = hi while lo > 0: piece = seg[lo - 1] if len(piece) == 1: tok = tokens[piece[0]] if "particle" not in tok.tags or ( AMBIGUOUS_ACRONYM_TAG in tok.tags and tok.role in SUFFIX_OR_UNREAD): break elif not all("particle" in tokens[i].tags for i in piece): break lo -= 1 return lo, hi def has_name_content(piece: Sequence[int], tokens: Sequence[WorkToken]) -> bool: """Whether a piece holds a letter or digit: a piece with none is no name word (rules/A2), so a run that takes it in absorbs no name and reports nothing. C-level, no frame per character.""" return any(map(str.isalnum, "".join([tokens[i].text for i in piece]))) def credential_run(rest: Sequence[int], peel: Peel, p: int, pieces: Sequence[Sequence[int]], ptags: Sequence[Set[str]], tokens: Sequence[WorkToken], one_case: bool | None = None) -> Peel: """`peel` with its name count cut back to `p`, where #602's run starts (`run_start`, which the caller asks first so that a name with no run pays one frame, not two). Every word from the credential on is then in the suffix run, except a title word, which is recorded in `run_titles` for assign to read as a title; a name word the run absorbs is recorded in `absorbed` for assign to report -- not a word with no letter or digit in it, and not a member the peel already reported as a pick. One pass, the suffix test first: a credential, the common word in a run, leaves both lists without the title test's frame.""" run_titles: list[int] = [] absorbed: list[tuple[int, ...]] = [] # a numeral starting the run is read as the generation, which a # shape alone makes a call, reported as the peel's are (rules/S10) numerals = peel.numerals # where the Ph./D. pair the walk leaves out starts the run # (`run_start`), rest[p] is the first word behind it, read like the # rest; else rest[p] is the credential first = p if behind_the_merged_credential(rest, p, ptags) else p + 1 start = pieces[rest[p]] if (first > p and len(start) == 1 and _ROMAN.match( (tok := tokens[start[0]]).text.rstrip(".")) is not None and not is_listed_numeral(tok.text, tok.tags) and not numeral_settled_by_case(tok.text, one_case)): numerals = (tuple(start), *numerals) for q in rest[first:peel.names]: if is_suffix_piece(pieces[q], ptags[q], tokens): continue if is_title_piece(pieces[q], ptags[q], tokens): run_titles.append(q) continue piece = tuple(pieces[q]) if piece not in peel.picks and has_name_content(piece, tokens): absorbed.append(piece) return Peel(p, numerals, peel.picks, peel.anchors, tuple(run_titles), tuple(absorbed)) # rules/H5: "only a word the vocabulary knows as a title is one, # and a bare title word is a name word" # -- the trailing run's own predicate. NOT is_leading_title: # that predicate carries H2's unlisted-abbreviation inference, which is # the LEADING slot's shape rule and has no trailing counterpart, so # with it 'John Smith Xyz.' would lose its family name to a title # (decisions/H5). The vocabulary read is is_title_piece's, shared # with the leading run so the two cannot disagree about what a title # WORD is while disagreeing, deliberately, about what a title SHAPE is. def trailing_titles(rest: Sequence[int], pieces: Sequence[Sequence[int]], ptags: Sequence[Set[str]], tokens: Sequence[WorkToken], end: int | None = None, salutations: bool = False) -> int: """How many pieces of `rest` the trailing title chain LEAVES standing: `rest[:kept]` are the name pieces and `rest[kept:]` the title words the chain took (period-marked, or under `salutations` a bare salutation), in piece order. Counted the way `peel_trailing` counts, so the two answers compose without arithmetic at the call site. `rest` is the caller's NAME pieces: on the no-comma path what the S2 peel left, after a family comma the segment's pieces that the segment's own suffix reading does not claim, and in `tail_reading` the leftovers of whichever peel is current. `end`, where given, reads `rest[:end]` without the copy, for `tail_reading`'s passes. `salutations` is the given part after a family comma asking, where a bare salutation the vocabulary marks `after_given` chains as well (rules/H8). Floor: the first position of `rest` is never taken, so one name piece stands and a name is never all title. An empty `rest` returns 0, which is what leaves assign's bare-suffix carve-out reached exactly as before. ONE WORD per piece, the same gate the leading peel's give-back uses: a joined unit is not the shape this reads, and the tokens of one are not each a title word. Every parse with a name word to place enters this frame -- assign asks the question here rather than answering a cheaper version of it inline (mechanisms.md#ONE-PREDICATE-PER-QUESTION) -- so what it costs an ordinary name is one frame and one regex match. The exceptions return before it: a segment that is all title, and a comma part read wholly as a credential run, have no name piece to hand this (52 of the 1289 corpus parses, measured 2026-09-09 -- 'Coach', 'Lord of the Universe', 'Smith, Jr.', 'MD, PHD'). The shape test runs BEFORE the vocabulary one to keep it at that: _PERIOD_ABBREV is a compiled regex (a C call, no Python frame) where is_title_piece is a call, and almost no name ends in a period-marked word, so the ordinary parse pays the one match and stops (decisions/parse-cost). """ k = len(rest) if end is None else end while k > 1: idx = rest[k - 1] piece = pieces[idx] # no #323 veto on the shape here, unlike is_leading_title's: # the shape is ANDed with is_title_piece, so the word is listed # vocabulary, and a listed CJK title wearing a stop should read # as a title. if len(piece) == 1 and ( (_PERIOD_ABBREV.match(tokens[piece[0]].text) and is_title_piece(piece, ptags[idx], tokens)) # rules/H8: "the given part after it ends in a word whose # title reading is a salutation" -- the salutation chains # as a period-marked title does ("It reads as the # period-marked spelling reads"); a tag test, so a name # word pays no frame or (salutations and "vocab:after-given-title" in tokens[piece[0]].tags)): k -= 1 continue break return k # rules/H5: "successive single words that wear the abbreviation # shape and are title vocabulary chain into the title from the end" # -- one word's half of that, for a caller asking it of a piece the # chain did not walk to: the maiden take, choosing the words the # clause-free view is read over (#601). `trailing_titles` asks the same three tests # INLINE, in the same order, for the frame budget its docstring # states; keep the two in step. def is_trailing_title_word(piece: Sequence[int], ptags: Set[str], tokens: Sequence[WorkToken]) -> bool: """Whether one piece is a word H5's trailing chain takes: a lone token wearing the abbreviation shape that the title vocabulary lists. Position is the caller's question -- this answers only what the word is.""" return (len(piece) == 1 and _PERIOD_ABBREV.match(tokens[piece[0]].text) is not None and is_title_piece(piece, ptags, tokens)) #: lone-word tags `trailing_candidates` admits on: the ambiguous class, #: listed or by shape, and any suffix word -- an initial-shaped one #: included, which `is_suffix_piece` vetoes and the numeral fork reads _TRAILING_WORD_TAGS = frozenset({AMBIGUOUS_ACRONYM_TAG, SHAPE_ACRONYM_TAG, "vocab:suffix"}) # rules/M2: "It takes them up to the trailing run of post-nominals # and titles that the end of the name reads as if the clause were not # written" -- which words of a clause might be in that run, written # beside the peel and the chain whose admissions it has to cover (#610) def trailing_candidates(lo: int, pieces: Sequence[Sequence[int]], ptags: Sequence[Set[str]], tokens: Sequence[WorkToken]) -> int: """The first of the pieces after `lo` that `tail_reading` might read into the trailing run: `pieces[c:]`, a SUPERSET of what it takes, for the maiden take to build its clause-free view over and let `tail_reading` decide. `pieces[lo]` itself is never a candidate. Back from the end over every piece the peel or the chain admits on its own terms -- a suffix piece, a member of the ambiguous class, an initial-shaped suffix word, a period-marked title word -- and over a roman numeral the numeral's predicate admits, asked with no case so it stays a superset: one of two or more letters wherever it stands, the peel reading it anywhere in the run (#752), and one of one letter where nothing stands behind it but such title words and group-flagged suffix pieces: the chain takes the titles first and the walk never holds the flagged pieces ('Ph. D.'), so either way that numeral is the walk's last piece and the fork reads it. Then back to the first credential that starts #602's run, whose words the run takes whatever they are. A BARE title word is no candidate: H5's chain does not take one, and in the view it would only inflate the count of words to spare. The superset is pinned by tests/v2/pipeline/test_pieces.py over a generated grid against `tail_reading` itself, so a new admission in the peel or the chain fails there until this covers it.""" c = len(pieces) titles_behind = True while c - 1 > lo: k = c - 1 piece = pieces[k] title = is_trailing_title_word(piece, ptags[k], tokens) if not (title or "suffix" in ptags[k] or "split" in ptags[k] or is_suffix_piece(piece, ptags[k], tokens) or (len(piece) == 1 and ( not tokens[piece[0]].tags.isdisjoint(_TRAILING_WORD_TAGS) or ((titles_behind or len(tokens[piece[0]].text.rstrip(".")) > 1) and reads_as_generation( tokens[piece[0]].text, tokens[piece[0]].tags, tokens[pieces[k - 1][0]].text, None))))): break # a group-flagged suffix piece is outside the peel's walk # altogether (`peel_walk` drops it), so it is behind the # numeral the way a chained title is titles_behind = titles_behind and (title or "suffix" in ptags[k]) c = k # the tag test is the predicate's own necessary half, inline so a # name word pays no frame return next((j for j in range(lo + 1, c) if ("suffix" in ptags[j] or "split" in ptags[j] or "vocab:suffix" in tokens[pieces[j][0]].tags or _ROMAN.match(tokens[pieces[j][0]].text.rstrip(".")) is not None) and starts_a_credential_run( pieces[j], ptags[j], tokens, tokens[pieces[j - 1][0]].text) # a member its capitals settle, asked with the case as # mixed so the superset holds whatever the name's case or AMBIGUOUS_ACRONYM_TAG in tokens[pieces[j][0]].tags and starts_a_credential_run(pieces[j], ptags[j], tokens, None, False)), c) # rules/H5: "the title is TRANSPARENT to the suffix reading: where # two or more name words stand, what stands once the chain is taken # reads exactly as it would read written without the title, plus the # title" def tail_reading(rest: list[int], pieces: Sequence[Sequence[int]], ptags: Sequence[Set[str]], tokens: Sequence[WorkToken], one_case: bool | None, units: Sequence[int] | None = None, count_numeral: bool = True, salutations: bool = False, initials_cue: bool = True, ) -> tuple[list[int], tuple[int, ...], Peel]: """The S2 peel and the H5 chain read together to a FIXED POINT: peel, chain, splice the chained pieces out, peel again over what is left -- the name pieces the chain kept, then the pieces the peel had taken, in original order -- until the chain takes nothing. Where it takes nothing on the first pass, which is almost every name, that first peel is the answer and the loop costs one comparison. Returns the walk with the chained pieces spliced out, the pieces the chain took, and the FINAL peel -- whose numeral fork and ambiguous picks are the ones assign reports. The walk is partitioned by that peel exactly as a caller partitions its own: `rest[:peel.names]` the name pieces, `rest[peel.names:]` the suffixes. A bare tuple rather than a named one because a NamedTuple's __new__ is a frame of its own on every parse (decisions/parse-cost), and `_group_segment` returns its three the same way. Transparency is what the fixed point buys: 'X Prof. Y' reads exactly as 'X Y' reads plus the title, however many titles are written and wherever the peel then stops. Iterating ONCE reads a second title only half way -- 'John Prof. MA Prof.' un-peeled the acronym and re-exposed the first title, reading family 'Prof.' with suffix 'MA' where 'John Prof. MA' read family 'MA' (written in one case today: since #289 capitals in a mixed-case name take the acronym whatever the count, so 'john prof. ma prof.' and 'john prof. ma' are the pair that still reads family 'ma'). One function for assign's placement, group's bound-given reserve (P5), which must count the name words assign will leave, and the maiden take (rules/M2), which must end the clause where the reading of the name will begin -- and since #614, for a main segment, the one read group takes between P3's joins and the particle chain (`read_trailing_run`), which assign and the reserve take their answer from instead. Deriving that agreement twice is what left the two disagreeing at S4's bare-ambiguous reserve: 'abdul rahman MA' declined the join and 'abdul rahman MA Prof.' took it (mechanisms.md#ONE-PREDICATE-PER-QUESTION). `rest` is a peel_walk list and is not mutated -- the splice rebinds this local -- so a caller's own reference still names the walk it built. Every caller reads the one returned here instead, which is the one the final peel partitions. The chain's floor is `trailing_titles`' own, one name piece left standing. #602's credential run is applied to the final peel on the way out (`credential_run`), so every reader sees the run where it starts. Linear in the walk (#558). Re-peeling the whole walk every pass re-read each suffix the passes before had already peeled, so a name ending 'MA Prof. MA Prof. ...' cost the square of its tail. A pass instead RESUMES the walk where the splice left it, over `rest` as written: the pieces in front of the splice are the ones a fresh walk would meet, and `credential_anchors` reads each position off the pieces in front of it, so the carried pass answers for them too. What a fresh walk reads differently is the position COUNT of the pieces it already peeled, which the splice lowers, and two tests of the walk read it. The acronym fork's words to spare: a member whose count falls below three can decline where it was taken -- 'john prof. ma ma prof.' is exactly that, keeping family 'ma' only because the second pass walks afresh -- so a pass resumes only with two or more pieces in front of the splice, every peeled piece then counting three or more. And the numeral fork, which reads the walk's last piece against the one before it: a pass resumes only with two or more peeled pieces behind the splice, so that pair is the one the first walk read. Elsewhere -- a splice near the front of the walk, or before two pieces have been peeled -- the pass walks afresh over the spliced pieces. The fuzz that checked this against the re-peeling loop, the three conditions each shown load-bearing by a weakened copy failing it, is recorded on the PR closing #558. `count_numeral` is `peel_trailing`'s, passed to every pass, and `salutations` is `trailing_titles`', passed to every chain. The splice itself is never materialized in between: the runs are collected back to front and joined once. """ # where the second name unit opens, found once for the read: a # pass keeps `rest[:hi]`, so it holds while it stands in front of # the splice, and each pass rescanning for it from the front cost # the square of a run behind a long joined surname (#620's # /simplify, 'Freiherr von Berg Berg ... MA Dr. MA Dr. ...') second = 1 if units is None else second_unit(rest, units) peeled = peel_trailing(rest, pieces, ptags, tokens, one_case, units=units, second=second, count_numeral=count_numeral) kept = trailing_titles(rest, pieces, ptags, tokens, end=peeled.names, salutations=salutations) if kept == peeled.names: p = run_start(rest, peeled.names, pieces, ptags, tokens, units, one_case, initials_cue) return rest, (), (peeled if p == peeled.names else credential_run( rest, peeled, p, pieces, ptags, tokens, one_case)) # `rest[:hi]` stands as written; `behind` holds the peeled runs # the splices left after it, and `titled` the chained ones, each # back to front -- a pass's run goes in FRONT of what the pass # before it took hi = len(rest) behind: list[list[int]] = [] behind_count = 0 titled: list[list[int]] = [] picks = list(peeled.picks) numerals = list(peeled.numerals) while kept < peeled.names: names = peeled.names titled.append(rest[kept:names]) behind.append(rest[names:hi]) behind_count += hi - names # the walk stopped AT the chain's last title, and a fresh walk # will not meet that piece: drop the pick it made there if picks and picks[-1] == tuple(pieces[rest[names - 1]]): picks.pop() hi = kept # Counted in units (#620) the position test still answers: a # splice lowers a count only by taking out titles that open # units of their own, and with fewer than two units in front of # it every piece there is in the first piece's chain run, which # the titles behind it join -- they count nothing, so nothing # the walk peeled loses a word to spare (fuzzed over 372,330 # names against a units test here: no difference) if kept >= 2 and behind_count >= 2: peeled = peel_trailing(rest, pieces, ptags, tokens, one_case, start=hi, anchors=peeled.anchors, units=units, second=second, count_numeral=count_numeral) picks.extend(peeled.picks) numerals.extend(peeled.numerals) else: rest = rest[:hi] + [j for run in reversed(behind) for j in run] if units is not None and second >= hi: second = second_unit(rest, units) hi = len(rest) behind, behind_count = [], 0 peeled = peel_trailing(rest, pieces, ptags, tokens, one_case, units=units, second=second, count_numeral=count_numeral) picks = list(peeled.picks) numerals = list(peeled.numerals) kept = trailing_titles(rest, pieces, ptags, tokens, end=peeled.names, salutations=salutations) if behind: rest = rest[:hi] + [j for run in reversed(behind) for j in run] final = Peel(peeled.names, tuple(numerals), tuple(picks), None) p = run_start(rest, final.names, pieces, ptags, tokens, units, one_case, initials_cue) return (rest, tuple(j for run in reversed(titled) for j in run), final if p == final.names else credential_run( rest, final, p, pieces, ptags, tokens, one_case)) class TailRead(NamedTuple): """S2's trailing run as group reads it ONCE, after P3's connective joins and before the particle chain and the bound-given join (#614), handed to assign so neither join can move it and assign reads no second time -- save where group's joins left no name word in front of the run, and assign reads the name again so it keeps one ('Prince of Wales Jr'). Token indices rather than piece indices, since group's joins renumber the pieces in front of the run: `tail` holds every token the run took, `titles` those the H5 chain took and `run_titles` the title words inside #602's run (both inside `tail`), and `peel` the final peel, whose numeral fork, bare ambiguous picks and absorbed name words are what assign reports; its `names` counts the name pieces as the read left them.""" tail: frozenset[int] titles: frozenset[int] run_titles: frozenset[int] peel: Peel # rules/P2: "A particle joins the words after it into one name part, # the join running until the next particle starts a group of its own" # -- how far, asked by the chain and by S2's read alike (#620), so the # units the read counts are the ones the chain then builds def chain_run_end(k: int, pieces: Sequence[Sequence[int]], ptags: Sequence[Set[str]], tokens: Sequence[WorkToken], end: int, one_case: bool | None = None) -> int: """Where the chain run opened by the particle `pieces[k]` ends: past the particles straight behind it, then past the name words up to `end`, a suffix piece, a member its capitals settle as the credential where the caller passes `one_case` (ParseState's), or the next particle. `pieces[k:j]` is the one name unit P2 makes of them; `j == k + 1` where it makes none. Only the given part after a family comma passes `one_case`: there the member starts S8's run behind the given word whatever stands in front, so the chain leaves it to the run. Elsewhere the trailing read's `end` stops the chain at a member the read takes, and a stop at one it declines would strand it as a name word between the particle run and the words behind it (#785's review). The particle test is `is_prefix_piece`'s definition written inline: the walk runs per piece of every chained name (AGENTS.md's frame budget, question 1), and so is the member tag ahead of the settled test.""" count = len(pieces) j = k + 1 while j < count and ("prefix" in ptags[j] or len(pieces[j]) == 1 and "particle" in tokens[pieces[j][0]].tags): j += 1 while (j < end and "prefix" not in ptags[j] and not (len(pieces[j]) == 1 and "particle" in tokens[pieces[j][0]].tags) and not is_suffix_piece(pieces[j], ptags[j], tokens) # rules/S6: "a clash settled by the writing is not reported" # -- and the chain does not take the settled credential in and not (len(pieces[j]) == 1 and AMBIGUOUS_ACRONYM_TAG in tokens[pieces[j][0]].tags and settled_by_case(tokens[pieces[j][0]], one_case))): j += 1 return j # rules/P2: "A particle joins the words after it into one name part" # -- the particles straight behind it first, merged ahead of S2's read # so the read counts and absorbs the run as the one piece the chain # makes of it (#625) def merge_particle_runs(pieces: list[list[int]], ptags: list[set[str]], tokens: Sequence[WorkToken], n: int) -> tuple[set[int], int | None]: """Merge each run of two or more adjacent particles past the chain's leading position into one piece, the one `chain_run_end` would open its run with ('van der', 'van mc'). The merged piece is a particle still (`prefix`), and no title: a P3 join can carry the tag in from a word in both vocabularies ('St und Do'), and the merged run would then read as a leading title. `n` is the end of the leading title run (`leading_titles`), read before anything merges. Returns the first token of each merged piece -- the chain's PARTICLE_OR_GIVEN report keys on a run that claimed something past its particle, which a merged run has though `chain_run_end` then claims nothing more -- and the leading position (`chain_lead`) where it asked it, else None. The particle test is `is_prefix_piece`'s definition written inline, and the leading position is asked only where two particles stand together past the first piece (AGENTS.md's frame budget, question 1).""" prefix: list[bool] = [] for k in range(len(pieces)): prefix.append("prefix" in ptags[k] or len(pieces[k]) == 1 and "particle" in tokens[pieces[k][0]].tags) merged: set[int] = set() k = 1 while k + 1 < len(prefix) and not (prefix[k] and prefix[k + 1]): k += 1 if k + 1 >= len(prefix): return merged, None lead = chain_lead(pieces, ptags, tokens, n) k = lead + 1 while k < len(prefix): if prefix[k]: q = k + 1 while q < len(prefix) and prefix[q]: q += 1 if q > k + 1: merged.add(pieces[k][0]) merge_pieces(pieces, ptags, k, q, add={"prefix"}, drop={"title"}) del prefix[k + 1:q] k += 1 return merged, lead def _chain_units(pieces: Sequence[Sequence[int]], ptags: Sequence[Set[str]], tokens: Sequence[WorkToken], n: int, lead: int | None = None) -> list[int] | None: """The name units P2's chain will make of `pieces`, as a mark per piece -- OPENS where a piece opens a unit, and JOINED where the chain will join a name word to the run in front of it and the reading does not weigh the word (`_weighed`, which keeps a word of its own what a position always counted as one). The particles straight behind each other are one piece already (`merge_particle_runs`, #625), so a particle run is one unit and a dual inside it ('van mc', 'von vd') no word the read can take. `n` is the end of the leading title run, and the chain starts past its leading position (`chain_lead`, the one answer group's chain takes too, #624; `lead` where the caller has asked it already), which opens no unit inside the titles: the name starts at `n`. Nothing else is merged: the read counts with the flags and reads every other piece as written, so no word it weighs is hidden inside a unit (#620; the merged copy #614 read through hid a title inside a credential run and a shape-only numeral, and its indices drifted from the pieces'). None where no particle stands past the first piece, every piece then its own unit.""" count = len(pieces) prefix = [("prefix" in ptags[k] or len(pieces[k]) == 1 and "particle" in tokens[pieces[k][0]].tags) for k in range(count)] if not any(prefix[1:]): return None units = [OPENS] * count k = (chain_lead(pieces, ptags, tokens, n) if lead is None else lead) + 1 while k < count: if prefix[k]: j = chain_run_end(k, pieces, ptags, tokens, count) q = k + 1 # a split credential is weighed as the class it is a # member of (rules/S12) while (q < j and "split" not in ptags[q] and not _weighed(pieces[q], tokens)): units[q] = JOINED q += 1 k = j continue k += 1 return units def _weighed(piece: Sequence[int], tokens: Sequence[WorkToken]) -> bool: """Whether a word the chain will join is one the trailing reading WEIGHS, and so counts as a word of its own, as a position always counted it: a suffix word of either kind, the ambiguous class, a word in it by shape, an initial, a roman numeral by shape, or a period-marked title word. A word the chain joins and the reading weighs is a word the reading may yet take ('Freiherr von Berg MA X.Y.Z.' keeps suffix 'MA X.Y.Z.'), so it ends the plain run the count folds into the particle (#620's review). A connective join is one name word, P3's own count. The tests inline: this asks once per word behind a particle run.""" if len(piece) > 1: return False tok = tokens[piece[0]] return (not tok.tags.isdisjoint(_WEIGHED_TAGS) or _ROMAN.match(stem := tok.text.rstrip(".")) is not None and stem != "" or "vocab:title" in tok.tags and _PERIOD_ABBREV.match(tok.text) is not None) _WEIGHED_TAGS = _TRAILING_WORD_TAGS | {"initial"} def read_trailing_run(pieces: Sequence[Sequence[int]], ptags: Sequence[Set[str]], tokens: Sequence[WorkToken], one_case: bool | None, n: int, lead: int | None = None, ) -> tuple[TailRead, int] | None: """`tail_reading` read once over a segment's pieces as group holds them after rules/P3's connective joins and before the particle chain (#614), counting name words by the units the chain will make (`_chain_units`, #620): a `TailRead`, and the index in `pieces` of the first piece of the run, which runs to the end. None where there is nothing to read (no name piece past the leading titles), and where the run is not one block at the end of `pieces`: a title the H5 chain takes standing in front of a name word the peel declined ('John Dr. G.J.'), the one shape whose run holds a name piece -- there group joins as before #614 and assign reads for itself. A segment the read takes nothing from reads as a run of no pieces, at the end. `n` is the end of the leading title run (`leading_titles`) and `lead` the chain's leading position where the caller has asked it, both read before `merge_particle_runs` merged the pieces: the merge starts past the lead and drops the title tag, so it moves the lead never, and the title run only where H3 had given a title back before a run of particles that are suffix vocabulary, which the read takes as written: 'Dr. mc Jr mc mc' reads family 'mc', suffix 'Jr mc mc', where the run read after the merge would leave 'mc Jr mc mc' in the family.""" if n == len(pieces): return None units = _chain_units(pieces, ptags, tokens, n, lead) rest, titled, peel = tail_reading(peel_walk(n, ptags), pieces, ptags, tokens, one_case, units) tail: set[int] = set() for k in rest[peel.names:]: tail.update(pieces[k]) titles: set[int] = set() for k in titled: titles.update(pieces[k]) tail |= titles run_titles: set[int] = set() for k in peel.run_titles: run_titles.update(pieces[k]) start = len(pieces) for k, piece in enumerate(pieces): if piece[0] in tail: start = k break for k in range(start, len(pieces)): if pieces[k][0] not in tail and "suffix" not in ptags[k]: return None return TailRead(frozenset(tail), frozenset(titles), frozenset(run_titles), peel), start def trailing_run_start(start: int, pieces: Sequence[Sequence[int]], ptags: Sequence[Set[str]], tokens: Sequence[WorkToken], one_case: bool | None) -> int: """Where the run `tail_reading` takes over `pieces[start:]` begins, its trailing titles included: the first index of a piece the peel or the H5 chain took, `len(pieces)` where they take none. The bound for a reading that takes the trailing titles as well as the suffixes, as assign's walk does in the given part after a family comma (#623), where `trailing_start` sees only the suffixes and `trailing_start_past_titles` leaves the titles inside the bound. The chain takes a bare salutation too, as that walk does (rules/H8).""" # rules/S4: "the given part's trailing slot after a family comma is # read without it (S5)" -- the numeral half of the count, which # assign's walk there never asks either, so the chain this bounds # stops where that walk will read the suffix (#718's review); the # acronym half is still counted here, as before #718 rest, titled, peel = tail_reading(peel_walk(start, ptags), pieces, ptags, tokens, one_case, count_numeral=False, salutations=True, initials_cue=False) first = len(pieces) for k in rest[peel.names:]: first = min(first, k) for k in titled: first = min(first, k) return first # rules/H5: "the title is TRANSPARENT to the suffix reading: where # two or more name words stand, what stands once the chain is taken # reads exactly as it would read written without the title, plus the # title" def trailing_start_past_titles(start: int, pieces: Sequence[Sequence[int]], ptags: Sequence[Set[str]], tokens: Sequence[WorkToken], *, one_case: bool | None) -> int: """`trailing_start` read through H5's chain: where assign's trailing suffix run begins once a trailing TITLE has stopped hiding it. `trailing_start` reads the pieces as WRITTEN, so a title standing behind the suffix run makes the peel take nothing and the answer is `len(pieces)` -- the reading assign itself has not had since H5, because assign runs the peel and the chain to their fixed point instead (`tail_reading`). A caller using that answer as the right bound of the NAME is told a credential is a name word: 'John Quincy Adams i MA Prof.' read family 'Adams i MA' where 'John Quincy Adams i MA' reads family 'Adams' and suffix 'i MA' (#397 second review). Every caller that bounds the name wants this one; `trailing_start` stays for the callers that count a trailing run of the pieces as they stand. The returned index bounds the name from the right, and the trailing titles the chain spliced out are not under it: they end the segment, so they stand at or past the first suffix piece whenever there is one. Where the peel takes nothing even past the chain this returns `len(pieces)` as `trailing_start` does, and a trailing title is then inside the bound and refused by the title test the callers already run beside it. """ rest, _titled, peeled = tail_reading(peel_walk(start, ptags), pieces, ptags, tokens, one_case) return rest[peeled.names] if peeled.names < len(rest) else len(pieces) derek73-python-nameparser-456b024/nameparser/_pipeline/_post_rules.py000066400000000000000000001271201527303271700257230ustar00rootroot00000000000000"""Stage: post_rules. Consumes: tokens (roles assigned), plus pieces and structure -- the particle fold reads the opening piece of segment 0, or of segment 1 under a family comma (#359). structure was always read here, for the rotation gate. Also comma_offsets and dropped, which R1's entry pass below reads to find the separators the writer typed (#436/#437), and extracted and one_case, which S13's pass reads (#810). Produces: tokens with roles adjusted by the post rules (among them the 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 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. Reads: Policy.patronymic_rules, Policy.two_family_names, Policy.name_order (O6 stands down where the script's order differs from the declared one), Policy.middle_as_family, Policy.extra_suffix_delimiters (R1's entry pass, for the delimiter cores group drops); Lexicon.given_name_titles, and Lexicon.suffix_acronyms_ambiguous (a dotted member in a clause, which carries no tag there, S13). Implements rules S13, H1, M4, P1, P6 (its no-comma site), O1, O2, O6, O3 and R1 of docs/design/rules/; each is cited at its code below, and H1/P1/O1/O2's history lives in docs/design/decisions/. `suffix_entries` is the R1 entry pass as a state-in/state-out function, for Parser.revise to run over a forced-role sub-parse (#511); `post_rules` runs the same worker last. """ from __future__ import annotations import bisect import re from nameparser._lexicon import _normalize, _run_addresses_by_given from nameparser._pipeline._assign import ( _name_alt, _name_pieces, _name_positions, ) from nameparser._pipeline._pieces import ( is_lone_never_given_particle, settled_by_case, ) from nameparser._pipeline._state import ( AMBIGUOUS_ACRONYM_TAG, NAME_ROLES, ParseState, PendingAmbiguity, PendingReading, Structure, WorkToken, _NEVER_FLIPPED, comma_bucket, copy_with, ) from nameparser._pipeline._vocab import ( delimiter_cores, 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 #: the name and the suffix, so a run of #: post-nominals is not parted by them -- 'Smith, MD Dr. PhD', #: 'Smith, MD "Doc" PhD' and 'Smith, MD nee Jones PhD' are each one #: entry. GIVEN/MIDDLE/FAMILY are name words and part it. _RENDERS_ELSEWHERE = frozenset({Role.TITLE, Role.NICKNAME, Role.MAIDEN}) def _rotations_apply(state: ParseState) -> bool: # Both patronymic rotations RESTORE the given-first reading a # family-first listing hides, so rules/O1's scope clause holds # them to the default order: a caller who declared family-first has # already said what the rotation would infer, and position decides # (decisions/O1, the 2026-09-07 entry on #384). `state.order`, # not policy.name_order, for the reason the P1 fold gives -- a # script_orders entry can override the policy, and the roles the # rotations read are the ones assign actually made. None means # assign positioned nothing: a family comma (which the NO_COMMA # test already excludes) or an early return with no name piece to # position, so there is no declaration to defer to and the # rotation's own shape test decides. return state.structure is Structure.NO_COMMA and ( state.order is None or state.order[0] is Role.GIVEN) def _mark_suffix_entries(tokens: list[WorkToken], state: ParseState) -> None: # In place over the caller's token list, as every other post rule # writes; suffix_entries() below is the state wrapper, and # decisions/R1 (#511) records why the pass has two spellings. # Reads dropped, comma_offsets and the policy off `state`, which # post_rules does not change, so the un-rebuilt state is current. # # rules/R1: "a run of post-nominals written with spaces renders # with spaces, and one written with commas keeps them" # # Two consecutive SUFFIX tokens are one entry iff they share a # comma bucket and nothing between them parts the run: a name word # or a dropped delimiter core parts it; a token whose role is in # _RENDERS_ELSEWHERE, or a dropped token that is not a core (the # marker of a bracketed maiden clause, 'Smith, MD (nee Jones) # PhD'), does not. comma_bucket and delimiter_cores # are segment's and group's own answers, called rather than copied # (mechanisms.md#ONE-PREDICATE-PER-QUESTION). decisions/R1 # (#436/#437). # # After assign, because it keys on Role.SUFFIX; last in post_rules, # after S13's pass, the one rule here that writes SUFFIX (#810), and # by convention otherwise, since no other writes SUFFIX or a # _RENDERS_ELSEWHERE role. Recorded as a tag because a span-less # token (unpickled, or built by replace()) leaves the render # nothing to scan (mechanisms.md#MARK-DONT-STRIP). Every WorkToken # has a span, so `span.start` is read unguarded, and the # `i not in dropped` filter is belt-and-braces: a dropped token # never holds SUFFIX, and the filter keeps `suffixes` and the # `parted` scan agreeing on what a dropped index is. dropped = set(state.dropped) cores = delimiter_cores(state.policy.extra_suffix_delimiters) suffixes = [i for i, tok in enumerate(tokens) if tok.role is Role.SUFFIX and i not in dropped] for previous, current in zip(suffixes, suffixes[1:]): same_part = (comma_bucket(tokens[previous].span.start, state.comma_offsets) == comma_bucket(tokens[current].span.start, state.comma_offsets)) parted = any( tokens[between].text in cores if between in dropped else tokens[between].role not in _RENDERS_ELSEWHERE for between in range(previous + 1, current)) if same_part and not parted: tokens[current] = copy_with( tokens[current], tags=tokens[current].tags | {"joined"}) def suffix_entries(state: ParseState) -> ParseState: """rules/R1's entry pass over a whole state, wrapping _mark_suffix_entries: tag a SUFFIX token "joined" when it continues the entry of the SUFFIX token before it. post_rules runs the worker last, over the roles assign settled; this wrapper exists for Parser.revise (#511), which runs it over a SUB-PARSE of a field value whose every non-dropped token it has forced to the named role, so a suffix value derives its entries from its own commas by the rule a whole name uses. The pass ADDS the tag and never removes one; a caller forcing roles keeps what the sub-parse marked, a within-piece mark being role-blind (decisions/R1, the #436 DECLINED list) and every between-piece mark on a suffix value being one this pass sets again. Reads comma_offsets, dropped and Policy.extra_suffix_delimiters; writes the "joined" tag and nothing else.""" tokens = list(state.tokens) _mark_suffix_entries(tokens, state) return copy_with(state, tokens=tuple(tokens)) def _idx(tokens: list[WorkToken], role: Role) -> list[int]: return [i for i, t in enumerate(tokens) if t.role is role] def _leading_name_piece(state: ParseState, tokens: list[WorkToken]) -> tuple[int, ...]: """The piece that OPENS the name, whatever role name_order gave it: the first piece holding a GIVEN, MIDDLE or FAMILY token, in the segment the positional read governs. Every piece holding none of those is walked past -- title and suffix pieces, but NICKNAME and MAIDEN as well, and anything assign left unroled -- and any number of them, not only a single leading title. The segment is 0, except under a family comma that fixed the surname, where the name continues in segment 1 -- assign records no order there. A family comma followed by no name word fixed nothing, and assign reads segment 0 positionally and records the order (#296's bundle), so the name is segment 0 again. Empty on either of two exits: that segment does not exist, or none of its pieces holds a name token.""" seg = (1 if state.structure is Structure.FAMILY_COMMA and state.order is None else 0) if seg >= len(state.pieces): return () for piece in state.pieces[seg]: if any(tokens[i].role in NAME_ROLES for i in piece): return piece return () def _retag(tokens: list[WorkToken], i: int, role: Role) -> None: tokens[i] = copy_with(tokens[i], role=role) # rules/P2: "a particle joins the words after it into one name # part, the join running until the next particle starts a group of # its own, a trailing suffix begins" # rules/P3: "the joined part is ONE name word wherever another # rule counts them" # rules/P5: "a recognized bound given-name word joins the word # after it into one given name" def _units(tokens: list[WorkToken], idx: list[int]) -> list[list[int]]: """`idx` split into the units other rules COUNT: one name word each, except where another rule has already made several words one name. Three do -- a particle and the words it chains, a conjunction-joined run, and a bound given-name word with the word it completes -- so `van der Berg` and `abdul Rahman` are each one unit and the fold cannot leave half of either behind. Read off the TAGS rather than the pieces, for two different reasons. A conjunction join grouping DID build and the prefix chain then swallowed: "de la Vega y Santos Juan" reaches this as [de][la Vega y Santos Juan], the ambiguous particle having chained forward over the join, so the join's own boundary is gone. (The leading particle keeps its piece -- it has to, or the fold's lone-piece site test would not fire at all.) The bound-given join grouping never built at all: P5 joins only where the bound word is the first non-title piece, and at a fold site the first piece is the particle -- "ibn Awf abdul Rahman" reaches here as four separate pieces. The tag is the only witness in both cases. The particle chain is what keeps this partition agreeing with the one `assign` reads off pieces: strip the folded run and `assign` gives the same tail one role, so the fold must not hand its words out separately.""" units: list[list[int]] = [] i = 0 for end in unit_ends([tokens[k].tags for k in idx]): units.append(list(idx[i:end])) i = end return units def _fold_reach(tokens: list[WorkToken], name_idx: list[int]) -> int: """How many of `name_idx` the fold takes: the particle run, plus the one name word it attaches to -- one UNIT, so a conjunction join goes whole ("de la Vega y Santos Juan" keeps Vega y Santos together). All of them when the name is nothing but particles.""" i = 0 while i < len(name_idx) and "particle" in tokens[name_idx[i]].tags: i += 1 if i == len(name_idx): return i return i + len(_units(tokens, name_idx[i:])[0]) #: The hyphens and the dashes that join two family names in one written #: word (rules/O6): hyphen-minus, hyphen, non-breaking hyphen, figure #: dash, en dash, minus sign (a word processor's substitute), and the #: small and fullwidth hyphen-minus. The em dashes are punctuation #: between words, not joiners, and are left out. _HYPHENS = re.compile("[-\u2010\u2011\u2012\u2013\u2212\ufe63\uff0d]") def _family_word_count(tokens: list[WorkToken], family: list[int]) -> int: """The family's name words as rules/O6 counts them: a particle run counts with the word it attaches to, a connective counts as nothing and a hyphen joins words without making them one, so "de la Vega" is one word and "Ortega y Gasset" and "García-López" two. A family of nothing but particles counts each of them, R2 reading them as ordinary name words.""" words = sum(len([part for part in _HYPHENS.split(tokens[i].text) if part]) for i in family if "particle" not in tokens[i].tags and "conjunction" not in tokens[i].tags) return words or len(family) def _take_second_family_name(state: ParseState, tokens: list[WorkToken]) -> None: # rules/O6: "the family name takes the name word beside it, so # the last two name words are the family" -- the first two under # a family-first order. In place, as every post rule writes. # History: decisions/O6 (#754). # An order a name's script set (W4) other than the declared one is # not the caller's declaration, and no script it keys writes two # family names as separate words. Where the two agree nothing here # tells them apart, and the rule reads the name (rules/O6 Accepted). if (state.structure is Structure.FAMILY_COMMA or state.order is None or state.order != state.policy.name_order): return family = _idx(tokens, Role.FAMILY) others = sorted(_idx(tokens, Role.GIVEN) + _idx(tokens, Role.MIDDLE)) if not family or _family_word_count(tokens, family) != 1: return # The rest in the units assign read them as, so a particle chain # (P2) or a bound given-name pair (P5) moves whole or not at all. units = _units(tokens, others) if len(units) < 2: return take = units[-1] if state.order[0] is Role.GIVEN else units[0] # What is taken must be ONE name word, counted as the family is, so # a hyphenated given name or a bound given-name pair (P5) stays put; # and a word a family name could be: not an initial, which writes a # given name (one written alone; "J.R." is taken), nor a lone dash # or other mark with no letter in it. if _family_word_count(tokens, take) != 1: return if len(take) == 1 and "initial" in tokens[take[0]].tags: return if not any(ch.isalpha() for i in take for ch in tokens[i].text): return if state.order[0] is Role.GIVEN: # given, middles..., family: the middle unit standing right # before the family joins it, and text order already renders # it first, so no role around it moves. A rotation that left # the name in another shape fails this test. if take[-1] > family[0] or any( tokens[i].role is not Role.MIDDLE for i in take): return for i in take: _retag(tokens, i, Role.FAMILY) return # family first: the unit after the family joins it, and what is # left is a shorter name of the same order, laid out as the P1 # fold above lays its remainder -- one family already placed. A # particle P6 attached at the far end is family already and keeps # its place. if family[0] > units[0][0]: return for i in units[0]: _retag(tokens, i, Role.FAMILY) for unit, role in zip(units[1:], _name_positions( state.order, len(units))[1:]): for i in unit: _retag(tokens, i, role) def _addressing_run(titles: list[int], name_word: int) -> list[int]: """The title run H1 asks about: the LEADING one where one stands, else the whole (trailing) run -- rules/H1 -- the run standing BEFORE the one name word being the run that addresses, and a run standing behind it deciding that word's field only when none stands before. Every title token is in the TITLE role by the time this runs, both ends of `Sir John Prof.` among them, so `titles` is not a run -- keeping the two ends apart is what makes a trailing title TRANSPARENT (rules/H5): `Sir John Prof.` is `Sir John` plus a title, and reading both ends as one run keyed 'sir prof' made adding the title flip the name word's field (#489, #316). The split is at the NAME WORD, not at the first token of another role, and the difference is a nickname or a maiden name written among the titles. H1's own rationale says what stands beside the name word "does not make the name any longer, so it does not decide this reading", and that has to hold for WHICH run addresses as well as for how many words the name has: `Dr. 'Smitty' Sir John` is one run written around a nickname and reads given 'John' as `Dr. Sir John` does, while `'Smitty' Dr. Jones Sir.` keeps family 'Jones' as `Dr. Jones Sir.` does. Splitting on the first non-title token got both wrong (measured 2026-09-09). Called only from inside H1's guard, after the role counts have short-circuited, so a name with both a given and a family never builds this list; `name_word` is the first GIVEN, or the first FAMILY in the direction #720 added. The given guard has proved it is the only name word there is; the family guard proves only that no given or middle stands beside it, which is where a comma or a declared order leaves one name word.""" return [i for i in titles if i < name_word] or titles def _family_settled(state: ParseState, tokens: list[WorkToken], families: list[int]) -> bool: """Whether something other than the order placed this lone family word, so H1's family -> given direction leaves it (#720). After a comma a particle in it is settled -- rules/C3's "the comma settles P1's fork for a leading particle that could be a given name" ('van Gogh, Sir'); without one, P1's fold below takes a never-given particle opening the name with a word behind it ('Sir de Mesnil'), asked by the fold's own predicate on the same piece. An ambiguous particle nothing settled, or a bare one with nothing to fold, is a word the order placed ('Sir Van', 'Sir Abu Bakar' and 'Sir de' under FAMILY_FIRST; 'Sir von und zu', whose never-given 'zu' does not open it).""" if state.structure is Structure.FAMILY_COMMA: return any("particle" in tokens[i].tags for i in families) return len(families) > 1 and is_lone_never_given_particle( _leading_name_piece(state, tokens), tokens) # rules/S13: "where the member's writing settles it as the credential, # the way the rules that read the writing say a credential is written, # it is no nickname but the suffix" -- Then and its first two bullets, # with the When's "no name word after it"; # extract's S1 escape has already sent every suffix-shaped clause # through (decisions/S13 (#810)) def _settle_delimited_members(state: ParseState, tokens: list[WorkToken], ambiguities: list[PendingAmbiguity]) -> None: """A nickname that is the whole of its clause and a member of the ambiguous class becomes the suffix where its writing settles it and it stands after the last name word; any other tagged member stays the nickname and the fork is reported, a dotted member left a nickname inside the name included. Asked here, first in this stage, because only once roles are read is it known whether a name word follows: a suffix, a title or a maiden clause after the clause is no name word, whatever spelling put it there. Reported here, where the escape is decided, rather than in extract, which has no token index to point at. A dotted member carries no tag in a clause (classify tags a shape only outside one), so it is found by its text.""" last_name = -1 alone: set[int] = set() for i, t in enumerate(tokens): if t.role in NAME_ROLES: last_name = i starts = [t.span.start for t in tokens] for _, clause in state.extracted: lo = bisect.bisect_left(starts, clause.start) # the whole clause's text, as extract's escape reads a clause: # a character tokenize drops beside the member (an emoji, a # zero-width space) leaves it no member alone if (bisect.bisect_left(starts, clause.end) == lo + 1 and state.original[clause.start:clause.end].strip() == tokens[lo].text): alone.add(lo) lex = state.lexicon for i, token in enumerate(tokens): if token.role is not Role.NICKNAME: continue # a period inside the word ('M.A'); one ending it is extract's # escape dotted = ("." in token.text.strip(".") and _normalize(token.text).replace(".", "") in lex.suffix_acronyms_ambiguous) # after the name: behind its last name word, and a name word # there is, so a clause in a name of no word of its own # ('Dr. (MA)') is not settled if 0 <= last_name < i and i in alone and ( dotted or settled_by_case(token, state.one_case)): tokens[i] = copy_with(token, role=Role.SUFFIX) elif dotted or AMBIGUOUS_ACRONYM_TAG in token.tags: ambiguities.append(PendingAmbiguity( AmbiguityKind.SUFFIX_OR_NICKNAME, f"delimited {token.text!r} is also a post-nominal; read " f"as a nickname rather than a suffix", (i,), alternative=suffix_reading(token))) def _unattached(state: ParseState, tokens: list[WorkToken], word: WorkToken, start: int) -> PendingReading: """The name reading of the particle run P6 attaches at the end of a name read family-first, which starts at token `start`: the field the positional read gave it, or the field of the name piece in front where that piece would take a name word in (`_name_alt`).""" role = tokens[start].role assert role is not None # assign roled every name word # the run ends a name read family-first, so a name word stands in # front of it front, first = next( ((piece, k == 0) for seg_idx in range(len(state.pieces)) for k, piece in enumerate(_name_pieces(state, tokens, seg_idx)) if start - 1 in piece), (None, False)) return _name_alt(word, role, front, tokens, first, False) def post_rules(state: ParseState) -> ParseState: tokens = list(state.tokens) ambiguities = list(state.ambiguities) if state.extracted: _settle_delimited_members(state, tokens, ambiguities) titles = _idx(tokens, Role.TITLE) givens = _idx(tokens, Role.GIVEN) middles = _idx(tokens, Role.MIDDLE) families = _idx(tokens, Role.FAMILY) # rules/H1: "a title followed by exactly one name word makes # that word the family name, whatever suffix, nickname or maiden # name stands beside it, unless the title is a given-name title, # which keeps it the given name; a run of several titles addresses # as its last title does" -- counting suffix, nickname and maiden # as further name words is what emptied the family (#410) # (known gap: the guard tests which roles are unoccupied, it does # not count units -- decisions/H1) (v1 handle_firstnames) # # rules/H1: "a run of several titles addresses as its last # title does, and where a run stands BEFORE the one name word it # is the run that addresses, a run standing behind it deciding # that word's field only when none stands before" -- #489. WHICH # run that is, and why the two ends of a name are not one, are # _addressing_run's; its docstring carries the history. if (titles and givens and not middles and not families and not _run_addresses_by_given( (tokens[i].text for i in _addressing_run(titles, givens[0])), state.lexicon.given_name_titles)): for i in givens: _retag(tokens, i, Role.FAMILY) # every rule below reads these lists; recompute after any # retag so no guard can inspect a name that has already # moved -- a stale index list is the bug shape #359 fixed givens = _idx(tokens, Role.GIVEN) middles = _idx(tokens, Role.MIDDLE) families = _idx(tokens, Role.FAMILY) # rules/H1: "a comma with no given name after it does not decide # the field, nor does a declared order" -- the other direction, for # the shapes assign reads as the family before H1 can see them # (#720): a family comma with no name word after it and one before # it, and the one name word of a name read family-first. The comma # named no given part, and the order places a word nothing else # decided (O5), so the title decides as it does in 'Sir John' # ('John III, Sir' reads as 'Sir John III'), unless # something else settled the word (_family_settled). elif (titles and families and not givens and not middles and not _family_settled(state, tokens, families) and _run_addresses_by_given( (tokens[i].text for i in _addressing_run(titles, families[0])), state.lexicon.given_name_titles)): for i in families: _retag(tokens, i, Role.GIVEN) # recomputed for H1's reason, stated above givens = _idx(tokens, Role.GIVEN) families = _idx(tokens, Role.FAMILY) # rules/M4: "a maiden name standing beside exactly one name # word makes that word the family name, whatever suffix or # nickname stands beside it" (#445) -- the MAIDEN role, not a # marker: M1's configured pair produces one with no marker # anywhere ("Smith (Jones)" under maiden_delimiters), and the # rationale carries over unchanged, so the rule is keyed on the # maiden name as the statement is. # # H1's sibling, and placed under it so the interaction the rule # states is decidable by reading: where H1 fired, `givens` is # empty here and this cannot fire; where H1 declined because the # title addresses by given name, `titles` is what keeps this rule # off the same word. A titled name is H1's at both outcomes. # # Sibling EXCEPT in what it counts, and that is a known gap # rather than a boundary: this guard counts GIVEN tokens where # H1 counts nothing (it tests which roles are unoccupied), so a # name word another rule joined counts as several here and the # rule declines -- `Dr. Dean of Chemistry` reads family, while # `Dean of Chemistry née Jones` keeps given 'Dean of Chemistry'. # rules/P3 says a joined part is one name word wherever # another rule counts them, so the two disagree; widening moves # no corpus name and is not this change's to make # (decisions/M4). # rules/M4: "a word the vocabulary has claimed as a given name # keeps that reading, and so does a word read as an initial" # -- read off the tags classify already recorded rather than a # predicate of this rule's own, because this rule changes what # POSITION decided and must not reach what a word IS # (mechanisms.md#TWO-LAYER-ASSIGN). if (not titles and len(givens) == 1 and not middles and not families and any(t.role is Role.MAIDEN for t in tokens) and not (_NEVER_FLIPPED & tokens[givens[0]].tags)): _retag(tokens, givens[0], Role.FAMILY) # recomputed for H1's reason, stated at H1: a stale index list # is the bug shape #359 fixed givens = _idx(tokens, Role.GIVEN) middles = _idx(tokens, Role.MIDDLE) families = _idx(tokens, Role.FAMILY) # rules/P1: "the particle run and the name words it takes are # family, under every name order" -- its Then clause, for a # never-given particle opening the name; how many words the run # takes is set by the order or a family comma, in P1's bullets and # below (v1 handle_non_first_name_prefix; decisions/P1) # How far the fold reaches depends on the order the name was READ # under (#395; decisions/P1, 2026-08-17): declaring a # family-first order asserts that what follows the family is not # more surname, which is the very question of where the run stops. # Under that declaration the run takes its own particles and ONE # name word; under the default order it keeps taking the rest of # the name, nothing having marked where the surname ends. Note what # actually holds the family-comma shape back, since it is NOT # that test -- "Smith, de Mesnil Jean" DOES fire the leading site, # segment 1 opening with the particle. It keeps the old reach # because assign records no order after a family comma, the comma # having already fixed the surname, so `order is None` here. # Anything that later gives that path an order turns the # narrowing on for it. # ONE site, the opening piece. A particle standing in the GIVEN # position was a second site until #467, and it was the parser # overriding a declaration rather than reading one: under a # family-first order that slot holds what the caller SAID is the # given name, and the never-given vocabulary supplies a default # where position leaves the question open, not a veto over an # answer position already gave. So "Menil de" under either # family-first order reports given "de" -- P6 takes the trailing # particle only where it landed in a MIDDLE, which means nothing. # Code-local: a lone PIECE is the test, so a particle group # already chained forward is not a lone particle, and rule H1 # above cannot be what produces the fold's family reading -- its # given -> family branch is gated on `not families`, and its family # -> given branch leaves the word this fold takes, asking the same # predicate (_family_settled). lead = _leading_name_piece(state, tokens) lead_fires = is_lone_never_given_particle(lead, tokens) if len(givens) + len(middles) + len(families) > 1 and lead_fires: order = state.order if lead_fires and order is not None and order[0] is Role.FAMILY: # `state.order`, not policy.name_order: a script_orders # entry can put the family first under a given-first # policy, and the roles below have to match the read # assign actually made. name_idx = sorted(givens + middles + families) cut = _fold_reach(tokens, name_idx) for i in name_idx[:cut]: _retag(tokens, i, Role.FAMILY) # What is left is a shorter name of the same order: one # family already placed, so drop that slot and lay the # rest out as _name_positions would for n + 1 pieces. rest = _units(tokens, name_idx[cut:]) for unit, role in zip(rest, _name_positions( order, len(rest) + 1)[1:]): for i in unit: _retag(tokens, i, role) else: for i in givens + middles: _retag(tokens, i, Role.FAMILY) # downstream rules key on the role counts: recompute givens = _idx(tokens, Role.GIVEN) middles = _idx(tokens, Role.MIDDLE) families = _idx(tokens, Role.FAMILY) # v1 gates both rotations on `not self._had_comma`; the # middle_as_family fold below runs comma or not (v1 order: # patronymics first, then handle_middle_name_as_last) rules = state.policy.patronymic_rules rotations_apply = _rotations_apply(state) # rules/O1: "a name of exactly three name words — titles, # suffixes and nicknames aside — whose last name word carries a # patronymic ending and whose middle name word does not reads as # family-first" (history: decisions/O1) if rotations_apply and PatronymicRule.EAST_SLAVIC in rules and \ 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)): g, m, f = givens[0], middles[0], families[0] _retag(tokens, m, Role.GIVEN) _retag(tokens, f, Role.MIDDLE) _retag(tokens, g, Role.FAMILY) # rules/O2: "a name of exactly four name words — titles, # suffixes and nicknames aside — ending in a standalone # patronymic marker reads family-first: the first name word is # the family name" (history: decisions/O2) 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): g, m1, m2, f = givens[0], middles[0], middles[1], families[0] _retag(tokens, m1, Role.GIVEN) _retag(tokens, m2, Role.MIDDLE) _retag(tokens, f, Role.MIDDLE) _retag(tokens, g, Role.FAMILY) # rules/P6: "a particle ending the name attaches to that family # name and is written before it" -- the no-comma site (#467). # A comma is not the only thing that names the family; a declared # family-first order does too, and the same listing is written # both ways ("Jong, Anke de" and "Jong Anke de" under # FAMILY_FIRST both give family "de Jong"). # # Keyed on the SLOT the run landed in, not on its vocabulary. # MIDDLE is the one position that means nothing here: middles are # further given names, and a particle is not one. Only # FAMILY_FIRST puts a trailing piece there -- # FAMILY_FIRST_GIVEN_LAST puts it in GIVEN, where the caller's own # declaration says it IS the given name, and P1's site above no # longer overrides that either. # # Which is why no vocabulary test appears here and none is wanted. # The comma path needs one because a comma cannot separate the # Dutch reading from the Vietnamese; the declared ORDER can, when # the caller declares the right one for the name: "Beethoven # Ludwig van" under FAMILY_FIRST gives family "van Beethoven" # though `van` is ambiguous vocabulary, and "Nguyen Thi Van" under # FAMILY_FIRST_GIVEN_LAST keeps given "Van" though the same word # is in the same set. (Under the WRONG order each loses: the # Vietnamese name read as FAMILY_FIRST gives family "Van Nguyen". # rules/P6 records that as accepted -- it is one order, not # both.) A never-given test here would have excluded every # ambiguous particle -- von, di, da, del, le and `van` itself, # the flagship word of the listing this rule is named for. # # NO RE-LAYOUT, and that is a property of ONE order rather than a # simplification: under FAMILY_FIRST the roles run family, given, # middle, middle..., so dropping a trailing MIDDLE leaves every # other piece's role untouched. It is not true of # FAMILY_FIRST_GIVEN_LAST (family, middle..., given) and not true # of the default order, which is why this site tests the order # rather than trusting the slot to imply it -- review found the # first draft firing on 52 default-order names, where a # conjunction stops a particle's forward chain and leaves it # standing in a middle ("Maria Luisa y de la Cruz"). An earlier # draft still (#466) removed a piece and re-laid the leftover out, # which lost a given name outright on "van Berg Jan de" and # promoted a post-nominal into the given slot on # "Berg Jan Jr. de"; neither is reachable without a re-layout. # # The family must still hold a base of its own -- R2's invariant, # that a non-empty family has a non-empty base, not a ban on # particle-before-particle. Without it "van Berg Jan de" reports # family 'de van', whose words R2 reads as ordinary name words, # and no rule reorders those. It does NOT stop a second particle # joining a family that merely OPENS with one: "de Mesnil Jan de" # gives 'de de Mesnil', which keeps its base and is accepted. mids = _idx(tokens, Role.MIDDLE) # O1 and O2 above retag between roles WITHOUT recomputing, so this # is the first consumer of a stale list -- the bug shape #359 fixed, # and every other retagging block in this stage recomputes for it. families = _idx(tokens, Role.FAMILY) names = _idx(tokens, Role.GIVEN) + mids + families if mids and families and state.order is not None: run: list[int] = [] i = len(mids) while i and "particle" in tokens[mids[i - 1]].tags: i -= 1 run.append(mids[i]) # ENDING the name is the rule's own word, and it is the whole # test: the trailing MIDDLE is not always the trailing NAME # word. Under FAMILY_FIRST_GIVEN_LAST the given name stands # behind the middles, and under the default order the family # does, so in both a middle that ends the name is impossible # and this declines without asking which order was declared. # Only FAMILY_FIRST can put a name's last word in a middle. # # A draft tested the SLOT alone and fired on 52 default-order # names, where a conjunction stops a particle's forward chain # and leaves it standing in a middle ("Maria Luisa y de la # Cruz"), and on 366 FAMILY_FIRST_GIVEN_LAST middles with the # given name still behind them. A later draft tested the order # as well; measured over 542,592 generated parses, that second # test never decides anything this one has not already decided, # so it is not here. trailing = bool(run) and max(run) == max(names) if trailing and any("particle" not in tokens[j].tags for j in families): # mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE: "Emit at # the site that takes the branch, not where an ambiguous # tag sits". decisions/P6 (#405) settled that this # attachment reports the fork it decides, and the state it # fixed is the one this site would otherwise recreate -- # `Beethoven, Ludwig van` reporting while `Beethoven Ludwig # van` under FAMILY_FIRST decides the identical fork in # silence. ONE arm here where the comma path has two: the # suffix reading stands without a comma, so no post-nominal # reading is ever overridden for this site to report. ambiguous = [j for j in run if "vocab:particle-ambiguous" in tokens[j].tags] if ambiguous: word = tokens[ambiguous[0]].text text = " ".join(tokens[j].text for j in sorted(run)) ambiguities.append(PendingAmbiguity( AmbiguityKind.PARTICLE_OR_GIVEN, f"{word!r} is both a family-name particle and an " f"ordinary given name; ending a name read " f"family-first, {text!r} joins the family that " f"order named rather than standing as a name word " f"of its own", tuple(sorted(run)), # the field the positional read gave it, which this # attachment moves, unless the piece in front would # take a name word in alternative=_unattached(state, tokens, tokens[ambiguous[0]], min(run)))) for j in run: tokens[j] = copy_with( tokens[j], role=Role.FAMILY, tags=tokens[j].tags | {FOLDED_TAG}) # recomputed for H1's reason, stated at H1: a stale index # list is the bug shape #359 fixed givens = _idx(tokens, Role.GIVEN) middles = _idx(tokens, Role.MIDDLE) families = _idx(tokens, Role.FAMILY) if state.policy.two_family_names: _take_second_family_name(state, tokens) # rules/O3: "every middle word joins the family name and is # rendered before it" (v1 handle_middle_name_as_last). v1 # PREPENDED middle_list to last_list; mechanisms.md#FOLDED_TAG: # "tokens never move: a rule that needs different rendering order # tags the token, and the rendering views consult the tag" if state.policy.middle_as_family: for i in _idx(tokens, Role.MIDDLE): tokens[i] = copy_with( tokens[i], role=Role.FAMILY, tags=tokens[i].tags | {FOLDED_TAG}) # rules/R2: "a name part whose every word is particle # vocabulary is a part where none of them is doing a particle's # work — nothing joins them to a name — so they read as ordinary # name words" # # Last of the rules that MOVE a token, because every rule above # can still move one between parts: # P1's fold, P6's attachment and O3's fold all rewrite roles, and # this reads the roles they settle on. # # Marked, not untagged: `particle` is stable API and says the word # IS particle vocabulary wherever it lands, which stays true. # # All three roles for uniformity with the rule, not because all # three are observable: no view filters tags on GIVEN (initials # exempt that role outright), so restricting this loop to MIDDLE # and FAMILY moves 0 of 4,506 parses. The GIVEN arm is marked so a # future view reading the mark gets a consistent answer. # # rules/R3: "A part holding nothing else is a part where it is # joining nothing, and there it initials like any other name word" # -- #461's mark, decided HERE for the same reason R2's is: the # question is about the PART, and a view that re-derives a # part-level fact is how initials() and family_base came apart. # # ONE walk decides both marks -- a plain loop, not two generator # expressions: this runs per role on every parse and a genexp # costs a frame of its own (frame budget). An EMPTY role needs no # `continue` ahead of the walk, which is inert on one: measured # 2026-09-20, py3.11, one survived the whole suite and moved # neither frame count (parse=406.00 facade=443.00 either way). # `bool(part)` says the same thing about the R2 arm as # `_types._remarked`'s twin of this walk does. for role in (Role.GIVEN, Role.MIDDLE, Role.FAMILY): part = _idx(tokens, role) all_particle = bool(part) conj: list[int] = [] others = 0 for i in part: tags = tokens[i].tags is_conj = "conjunction" in tags if is_conj: conj.append(i) if "particle" not in tags: all_particle = False if not is_conj: others += 1 if all_particle: for i in part: tokens[i] = copy_with( tokens[i], tags=tokens[i].tags | {UNJOINED_TAG}) elif conj and not others: # #461: nothing here for the connective to join. The `elif` # is deliberate -- where the part is all-particle R2's mark # already readmits every word, a word that is BOTH particle # and connective included, which is what keeps a caller's # `add(particles={"y"})` readings unchanged. for i in conj: tokens[i] = copy_with( tokens[i], tags=tokens[i].tags | {UNJOINED_CONJUNCTION_TAG}) _mark_suffix_entries(tokens, state) return copy_with(state, tokens=tuple(tokens), ambiguities=tuple(ambiguities)) derek73-python-nameparser-456b024/nameparser/_pipeline/_script_segment.py000066400000000000000000001261461527303271700265610ustar00rootroot00000000000000"""Stage: script_segment (#271, #272, #308, #312). Consumes: tokens, segments, structure, interpunct_offsets, segmenter, one_case where segment recorded it, and comma_offsets, for the own-words span `_one_case` asks the fact over where segment did not (#613; the answer is not recorded, classify recording its own). Produces: tokens, by two independent splits into sub-slices -- a listed honorific peeled off the END of the name's last non-post-nominal token, in whichever of the name's runs that falls (the tail token also carries this module's _PEELED_TAG), and the first activated-script token of the name segment split into n+1 -- segments (index runs remapped past the insertions), ambiguities (indices likewise remapped, plus a SEGMENTATION report when more than one split was vocabulary-supported, or when a segmenter's answer scored under the confidence floor). Reads: Policy.segment_scripts, Lexicon.surnames, Lexicon.honorific_tails, ParseState.segmenter, Lexicon.maiden_markers DIRECTLY (the own-words span `_one_case` takes, _pieces.own_words), everything classify and `_comma.decide` read, through `_postnominal_behind_a_whole_name` (the comma's own decision, asked of a classified copy of the state, #613), and Lexicon suffix vocabulary through TWO predicates, which are NOT one another's singular and plural. _vocab.is_suffix_strict asks whether a single token is a post-nominal, initial veto included (the peel's scan-back and the surname site). _vocab.is_wholly_suffix asks C1's suffix test of a whole RUN -- the one `_comma.decide` and segment's C2 flag ask -- through the POLICY-selected token test plus period_joined_vocab, delimiter handling and the Ph./D. merge -- so the run predicate says yes both to tokens the token predicate VETOES ("V.", "V", "I") and to tokens it never sees as suffixes at all ("Msc.Ed.", "J.씨", which reach it through period_joined_vocab). The initial-shaped words are the class #319 was ABOUT, not the whole of the disagreement, and the extra routes listed above are where the rest of it comes from; test_is_wholly_suffix_is_not_the_plural_of_is_post_nominal pins the "V." half. The run predicate is how the peel declines a run that is not name text (#319), but only where the name's own run offers a site, since a glued honorific is itself part of what makes a run read as suffix-shaped; it owns two further Policy fields (lenient_comma_suffixes picks the strict or lenient token test -- it flips "田中さん, V." -- and extra_suffix_delimiters both counts a bare delimiter-core token as a suffix and splits a token on a core, the split being the half that flips "田中さん, Jr./V." under {"/"} and the bare core the half that flips "田中さん, /"). Implements rules W1 (the vocabulary/segmenter division), W2 (the glued-honorific peel) and W3 (the writer's divisions are respected) of docs/design/rules/, cited at their code below; the decision chain (#308, #312, #319, the vetting bars, the measured spaced-honorific trade) is decisions/W1, #W2 and #W3. Both splits make sub-slices of one token, rewriting nothing -- spans still index the original exactly, so the anti-#100 invariant holds by construction. Both also match on the token's CORE, its text.rstrip(FULL_STOPS) (#323) -- rstrip and never strip, because both offsets are measured from the text's START, so the core has to stay a prefix of the text for the offset the match yields to land where the match did. The peel runs FIRST, so suffix classification can claim the tail and the surname match or segmenter consult sees the name rather than name-plus-honorific; its ASCII bail sits above everything here, so a caller-added LATIN tail fires only on a name carrying at least one non-ASCII character (see the bail's own comment, and honorific_tails' field note). """ from __future__ import annotations import functools from collections.abc import Callable, Sequence from nameparser._lexicon import FULL_STOPS import nameparser._pipeline._comma as _comma from nameparser._pipeline._classify import classify from nameparser._pipeline._state import ( ParseState, PendingAmbiguity, Structure, WorkToken, copy_with, ) from nameparser._pipeline._vocab import ( effective_script, is_one_case, is_suffix_strict, is_wholly_suffix, ) from nameparser._pipeline._pieces import own_words from nameparser._types import AmbiguityKind, Segmentation, Span #: Marks the tail token the peel below MANUFACTURED, so the segmenter's #: neighbour test can tell it from a token somebody wrote. Namespaced, #: therefore unstable provenance rather than API (_types.STABLE_TAGS is #: the whole stable set, and FOLDED_TAG is the precedent for a #: structural marker carrying this prefix); it is vocabulary-derived #: besides, since honorific_tails is what licensed the split. Emitter #: and reader are both in this module, so unlike FOLDED_TAG it needs no #: home in _types. _PEELED_TAG = "vocab:peeled-honorific" #: Segmenter answers scoring below this attach a SEGMENTATION report #: (amendment 2026-07-29 section 3). Kept at the drafted 0.9 after #: measuring namedivider 0.4.1 over 112 names (#272 Task 5), which #: found a distribution the amendment did not anticipate: the scores #: are BIMODAL, not clustered near 1. A rule-based division (the kana #: boundary in 高橋みなみ, or a two-character name) scores exactly 1.0; #: a kanji-statistics division scores a softmax over the candidate cut #: positions, observed in 0.23-0.68 and driven more by name LENGTH #: (median 0.61 at three characters, 0.37 at five -- the softmax is #: over len-1 candidates) than by correctness: the wrong answers in #: the sample scored 0.32/0.60/0.60/0.61, straddling the correct #: median of 0.52. So no floor INSIDE that band separates error from #: success, and the only cut the data supports is between a stated #: certainty and a statistical guess -- which is what section 3's #: epistemic argument asks for anyway. 0.9 sits in the empty gap #: (0.68 to 1.0), far from both modes, and reads as "confident" for a #: third-party segmenter with a calibrated score too. Consequence, #: pinned by tests and stated so it can be checked: every #: STATISTICALLY divided name carries a SEGMENTATION report, and every #: RULE-divided one -- the kana boundary, a two-character name, #: namedivider's specific-name rules -- carries none. #: namedivider scores its own two-character rule 1.0, so this floor #: keeps that division silent -- see locales/ja.py for why the #: presumption is accepted as stated. #: Not configurable in 2.x (YAGNI). _SEGMENTER_CONFIDENCE_FLOOR = 0.9 def _remap(run: tuple[int, ...], split_at: int, added: int) -> tuple[int, ...]: """One segment run after token `split_at` split into `added` + 1 pieces: the extra pieces join the run immediately after it, and every later index shifts by as many.""" out: list[int] = [] for j in run: if j == split_at: out.extend(range(split_at, split_at + added + 1)) else: out.append(j + added if j > split_at else j) return tuple(out) def _pieces(text: str, splits: tuple[int, ...]) -> tuple[str, ...]: """`text` cut at every offset in `splits`: n offsets, n+1 pieces.""" cuts = (0, *splits, len(text)) return tuple(text[a:b] for a, b in zip(cuts, cuts[1:])) def _split(state: ParseState, i: int, splits: tuple[int, ...], detail: str | None, tail_tag: str | None = None) -> ParseState: """Cut token `i` at every offset in `splits`, recording `detail` as a SEGMENTATION report when there is one, and adding `tail_tag` to the LAST piece when there is one of those. The offsets arrive non-empty, ascending and interior whatever chose them. From a segmenter: non-empty is the caller's own check, strictly ascending with each >= 1 is Segmentation.__post_init__'s, and the last offset is < len(text) -- checked by the caller. From the vocabulary: the single offset is >= 1 and < len(text) by the range(cap, 0, -1) construction and its len-1 cap. The ONE split path: the vocabulary hit is the single-offset case and the segmenter's answer the general one, so neither can drift from the other's index arithmetic. `tail_tag` is part of keeping it one path: the piece is tagged HERE, where it is built, rather than by the caller, which would have to re-derive where its own tail landed after handing the index arithmetic off. Only the peel passes one, and the piece it wants is the last by construction -- the honorific it cut off the end; the surname path passes nothing and the default leaves it exactly as it was.""" token = state.tokens[i] base = token.span.start parts: list[WorkToken] = [] start = 0 for piece in _pieces(token.text, splits): end = start + len(piece) parts.append(copy_with( token, text=piece, span=Span(base + start, base + end))) start = end if tail_tag is not None: parts[-1] = copy_with( parts[-1], tags=parts[-1].tags | {tail_tag}) added = len(splits) tokens = state.tokens[:i] + tuple(parts) + state.tokens[i + 1:] # Every index the earlier stages recorded is now stale past the # split point: the segment runs group's own iteration rests on, and # the ambiguities from extract_delimited (resolved to indices by # tokenize) and segment. An ambiguity ON the split token keeps # pointing at the head. segments = tuple(_remap(run, i, added) for run in state.segments) ambiguities = tuple( copy_with(a, indices=tuple( j + added if j > i else j for j in a.indices)) for a in state.ambiguities) if detail is not None: ambiguities += (PendingAmbiguity( AmbiguityKind.SEGMENTATION, detail, tuple(range(i, i + added + 1))),) return copy_with(state, tokens=tokens, segments=segments, ambiguities=ambiguities) @functools.lru_cache(maxsize=16) def _longest_entry(entries: frozenset[str]) -> int: """The longest entry in a vocabulary, cached per-vocabulary rather than recomputed per parse (Lexicon is frozen and slotted, so it cannot carry a cached_property of its own). The frozenset is hashable and a process holds only a handful of distinct vocabularies -- the default one, plus one per constructed pack parser -- so maxsize=16 bounds pathological many-lexicon churn without ever evicting in normal use. Keyed by the frozenset VALUE, not by (lexicon, field): the two callers pass surnames and honorific_tails, but every lexicon that leaves KOREAN_SURNAMES alone shares one entry, so the count is distinct SETS rather than lexicons times fields. Callers must pass a NON-EMPTY vocabulary: max() of an empty set raises, and both call sites sit under a match guard that cannot reach it with one.""" return max(map(len, entries)) def _is_post_nominal(state: ParseState, i: int) -> bool: """Whether token `i` is post-nominal VOCABULARY. Two of this stage's decisions ask it and neither is about script: an honorific is neither a surname SITE nor the token a glued honorific hangs off (#308). It answers a VOCABULARY question, not a positional one, and the two callers do not spend the answer the same way. In TRAILING position vocabulary and position agree, so the peel site steps over a True and goes on scanning. In LEADING position they disagree -- 양 is the family name there, whatever the suffix set says -- so the surname site reads a True as an ANSWER and declines, rather than as a token to step past. STRICT, not lenient: the initial veto applies, so "V." is not a post-nominal here though bare "v" is a suffix word. That agrees with what classify does with the same token downstream -- "V." is a middle initial -- and the difference is reachable under the default lexicon: "田中さん V." stops its scan-back at the initial and does not peel, while "田中さん II" steps over II and does. That pair is the case table's ja_honorific_glued_before_an_initial and ja_honorific_glued_before_a_roman_suffix, added because the clause could NAME the discriminating input while swapping in is_suffix_lenient still failed no test -- the shape a "unify the two suffix predicates" refactor would have walked straight past. Suffixes only, deliberately. Should a CJK entry ever join titles, the surname site would want it excluded while the peel site must not: a title never trails, so widening the peel's scan-back would move it off the real last name token. Split the predicate then, not before.""" # rules/T4, #727: a bare lowercase letter in a name written in one # case is vetoed as its capital is; the case fact is asked only of # such a letter, so a CJK honorific (no case) never asks it text = state.tokens[i].text return is_suffix_strict(text, state.lexicon, len(text) == 1 and text.islower() and _one_case(state)) # rules/W2: "A part that is not name text — a post-nominal word # standing on its own — is never the name's end: the split-off steps # past it to the name word behind, and never dissects it." (history: # decisions/W2) # The scan-back below is that clause, and it needs no punctuation to # fire. '김민준 박사님' steps past 박사님 to 김민준, finds no listed # tail there and returns None -- so 박사님 is left whole for suffix # classification rather than cut into 박사 + 님, which is what the # same input gives when the step is removed. '선생님' is post-nominal # entire with no name word behind it, so the scan yields no site at # all and the token stays whole. Both are corpus names and # rules/W2 example lines; decisions/cjk-comma-demotion # carries the forced-predicate measurement behind them. def _peel_site(state: ParseState, flat: Sequence[int], tails: frozenset[str]) -> tuple[int, int] | None: """Where a peel would land in the token run `flat`: the index of the token to cut and the OFFSET to cut it at (the listed tail, and any full stops riding behind it, are what comes off the end), or None where that run offers no peel. Two callers, one answer, which is the point of naming it. The peel itself asks it once, of the runs it decided to scan. The gate above that decision asks it of segments[0] ALONE, to find out whether declining the second run would cost the only site. Sharing the scan with the peel rather than approximating it there is what makes the gate's answer mean what it says: a gate that merely looked for a token ENDING in a listed tail would count a token that is a tail entire, which the scan-back skips as a site -- "선생님, J.씨" would decline on a site the peel then cannot use, and lose the peel in exactly the way the gate exists to prevent. (Only the lone-token shape of that divergence is reachable from the gate, and the gate's own other conjunct is what makes that so: SUFFIX_COMMA wants BOTH a suffix-shaped second run and more than one word before the comma, and the gate is asked only where the first of those is already true -- so a run this call ever sees under FAMILY_COMMA failed on the second, and segments[0] holds at most one token. The word-count alone would not say that: "Dr 김민준, 지훈" has two words before the comma and is FAMILY_COMMA.) Callers must pass a NON-EMPTY `tails` -- _longest_entry's precondition, which the stage's own early return supplies.""" i = next((j for j in reversed(flat) if not _is_post_nominal(state, j)), None) if i is None: # nothing but post-nominals, or no tokens at all return None text = state.tokens[i].text # The tail is matched on the token's core (#323): a stop glued after # the honorific ('김민준씨.', '田中さん.') stands between the listed # tail and the token's end and used to defeat the match. What the # text read instead depended on the stop: the ASCII spelling went to # a title downstream (H2's shape is ASCII-period-only), while a # fullwidth or ideographic stop left the whole text a lone name word # ('田中さん。' read given). The cut lands BEFORE the tail, so the # stops ride with the honorific piece and the token text is never # rewritten. TRAILING only: a leading stop is not between the name # and its honorific, and the offset returned below is the core's # length less the tail's, one subtraction. core = text.rstrip(FULL_STOPS) # range/cap construction identical to the surname match below, and # for the same two reasons: longest-first, and a len-1 cap that # makes the offset interior by construction (_split's contract). An # empty or one-character core caps below 1 and the loop is empty. cap = min(_longest_entry(tails), len(core) - 1) for length in range(cap, 0, -1): if core[-length:] in tails: return i, len(core) - length return None # rules/W2: "a listed honorific glued to the end of the name's # last name word splits off once and reads as a suffix." (history: # decisions/W2) # That the peel also reaches ACROSS a family comma is stated at # rules/W3 instead, which is a tolerated rule since the # 2026-09-01 comma demotion -- the crossing is what the parser does # today, not something W2 promises. W3 also carries the reading of a # period a listing leaves behind (decisions/cjk-comma-demotion, # amended by decisions/cjk-full-stops): a period on a SEPARATE # post-nominal word rides into the suffix and moves nothing ('様.' # is post-nominal-strict and the scan steps past it as it steps past # '様'), and since #323 a period glued to the honorific's OWN token # rides with the honorific too -- _peel_site matches the tail through # the trailing stop and cuts before it, so '田中さん.' divides where # '田中さん' does, and '김민준씨.' where '김민준씨' does (both are # case rows now; before 2026-09-10 they read as a title, measured # 2026-09-05 and pinned by nothing, and for one commit of the #323 # branch the surname site read '김민준씨.' as 김 + 민준씨.). Neither # reading is a promise; the step past the post-nominal word itself # is W2's, above, and is. def _one_case(state: ParseState) -> bool: """ParseState.one_case, asked here where no earlier stage did, as classify asks it: segment records the fact only where C2's flag asked for it (#613).""" if state.one_case is not None: return state.one_case return is_one_case(own_words(state.tokens, state.comma_offsets, state.lexicon.maiden_markers)[0]) def _postnominal_behind_a_whole_name(state: ParseState) -> bool: """Whether the part after the comma is the postnominal part of a whole name, so the part before it is a name and not a surname -- rules/C1's question, which group decides once the words are tagged (#613). This stage runs first, so it asks THAT decision, on the name as written: `_comma.decide` over a classified copy of the state, which is the one implementation of the question. A hand copy asked the vocabulary here until #613's /simplify pass, and was patched four times in review for drifting from decide (the ambiguous class's count, the numeral refusal, a suffix before the comma, the split 'Ph. D.'), each drift dividing a name decide then read as a family comma, or leaving whole one it read as a suffix comma. The copy costs a classify pass and a decision, so the callers ask only where the answer can change something: the peel once it has found a site, the division once there is a token it could divide (Latin and accented-Latin comma names never pay it).""" return (_comma.decide(classify(state)).structure is Structure.SUFFIX_COMMA) def _peel_honorific_tail(state: ParseState, postnominal: Callable[[], bool]) -> ParseState: """#308: split a listed honorific off the END of the name's last NON-POST-NOMINAL token -- 田中さん -> 田中 + さん -- and let the existing machinery do the rest. Suffix classification claims the tail downstream (every honorific_tails entry is a suffix word too, enforced by Lexicon), and the segmentation half below then sees the remainder rather than the glued whole, so 김민준씨 splits 김 + 민준 and a configured segmenter is handed 山田太郎 rather than 山田太郎様. Scanning back over post-nominals rather than taking the last token outright does three things at once. An unrelated trailing suffix cannot hide the peel site, so "김민준씨 Jr." answers as the comma-written "Dr 김민준씨, Jr." does -- one name, two spellings, one parse. A token that IS a tail (씨, さん, and the nested 선생님, which the cap alone would peel to 선생 + 님) is skipped as a site and stays whole: every tail is a suffix word by the Lexicon invariant, which is what makes that guard hold, and the cap below only keeps the offset interior for _split. And the scan answers None rather than indexing anything, which two reachable inputs need: a name that is nothing but post-nominals ("씨"), and one whose name runs are empty (", , 씨" scopes to two empty ones). Nothing here rests on a structural gate landing first, then, which is what let #312 move both of the stage's gates below it. WHICH tokens are scanned is a separate decision, and the one a reader is likeliest to undo: the NAME's segment runs, flattened, and never state.tokens or every segment. The alternatives agree except where extract_delimited has already claimed a token or a comma has closed the name, both of which this stage can still see -- so "김민준씨 (Jimmy)" and "Dr 김민준씨, V." are the inputs that tell the three apart. See the note at the scan itself. That last token is the last of the NAME, which reaches into a maiden clause: maiden tokens are still main-stream here (extract_delimited has masked only bracketed content), so "김민준 née 박씨" peels 씨 off the MAIDEN name 박씨 and hands it to the person's suffix list -- "née Ms. Park". Intended rather than incidental in that direction: the honorific is the reader's regardless of which of her names it was glued to, and a name-final honorific is exactly what this peels. Since #312 that reach extends to FAMILY_COMMA along with the rest of the crossing -- "김, 민준 née 박씨" now routes 씨 to suffix, where before #312 the stage returned at the family-comma gate above the peel and left it in maiden "박씨". The other direction is a LIMIT, stated rather than fixed: a maiden clause pushes the site off the person's own name, so "김민준씨 née 박" does NOT peel and gives given "민준씨" -- the original bug, intact behind a marker. "김민준씨 née 박씨" shows both at once, two identical honorifics of which only the maiden's is routed. Chasing the marker into the site scan is scope creep for an uncommon input, and it could not be done here anyway: classify has not run, so the marker tokens carry no tag this stage could read. Longest-first, and ONE peel: a remainder that itself ends in a listed tail is accepted rather than chased. No SHIPPED input witnesses that any more -- 박사님 was the last one, and adding it is what removed the case: 김민준박사님 now gives up 박사님 entire, since longest-first reaches the whole honorific. The pin needs a tail whose remainder ends in a DIFFERENT tail, the two not themselves a listed entry, and no pair in the shipped vocabulary has that shape; the stage test test_one_peel_never_a_stack carries it on a synthetic lexicon, which is now its only witness. No script precondition on the remainder either, since the tail alone is the license: Andersonさん peels. Emits no ambiguity, unlike the surname fork below, though longest-first does CHOOSE here too -- 김선생님 gives 선생님 where 님 also matches. The difference is what the runner-up is: a second matching surname is a competing READING of the name, which a caller may prefer, while a shorter tail leaves a remainder that is not a name at all (김선생), so there is nothing to adjudicate.""" tails = state.lexicon.honorific_tails if not tails: return state # The NAME's runs: more than segments[0], never every segment. A # FAMILY comma splits the name across two runs and the honorific # glues to either side (#312); every other structure keeps the # name whole in segments[0], a trailing post-nominal under # NO_COMMA included, which the scan-back steps over. Flattening # SEGMENTS rather than state.tokens keeps the site off extracted # nickname and maiden content. # # rules/W3: "A part that is nothing but suffix words is not the # name's end: it is declined as the site, and the split-off falls # back to the part before the comma and takes the honorific there, # provided that part offers a site of its own." # The structure does not say whether segments[1] is name text, so # the run predicate is asked (`is_wholly_suffix`, not the plural of # `_is_post_nominal`, which vetoes an initial-shaped 'V.' and never # sees period_joined_vocab's 'J.씨'), with the case fact and policy # `_comma.decide` reads it with; an empty run is in # scope and adds no site. The second condition is there because # the predicate is circular at this site alone: a glued honorific # is itself suffix evidence. decisions/W2 (#319). site = _peel_site(state, state.segments[0], tails) if state.structure is Structure.FAMILY_COMMA: # Under a family comma both runs may be scanned. The comma's # decision is asked only where it can matter -- where scanning # one run and scanning both find different sites (#613's # /simplify: it is a classify pass and a decision) both = _peel_site(state, [j for seg in state.segments[:2] for j in seg], tails) if both != site and not postnominal(): second = [state.tokens[j].text for j in state.segments[1]] # `one_case` is passed for the reason the field exists: the # credential lean is part of what "wholly suffix" MEANS since # #289, and a stage that asks the question without it gets a # different answer from `_comma.decide`, which reads the same # run with the fact in hand at group's head. Left out, 'Kim김민준씨, MA' read # 'MA' as name material, declined nothing, and scanned the # second run -- where the site is 'MA' itself, which carries no # listed tail -- so the person's own 씨 went unpeeled (family # 'Kim김민준씨') while 'Kim김민준씨, PhD' peeled it, one name in # two spellings parsed two ways (review round, #289/#516). # `segment` records the fact only where C2's flag asks for it, # so `_one_case` asks it here for every other name (#613). # the site test first: it is the cheaper half, and false # for every name whose part before the comma holds no glued # honorific, which spares those the case fact # one fact, asked for the lean and for the strict test's # initial veto alike (rules/T4, #727) if not (site is not None and is_wholly_suffix(second, state.lexicon, state.policy, one_case=(case := _one_case(state)), letters_one_case=case)): site = both if site is None: return state i, offset = site # The tail carries a tag because this stage MANUFACTURED it. The # segmenter's neighbour test below needs to tell it from a token # somebody wrote, and no vocabulary question can: the two spellings # put the same word in the same place, and only the provenance # differs. return _split(state, i, (offset,), None, tail_tag=_PEELED_TAG) def _split_candidate(state: ParseState) -> int | None: """The token `_split_surname_site` would try to divide, or None: asked on its own so a caller can learn cheaply that there is nothing to divide before asking anything costlier (#613's /simplify).""" scripts = state.policy.segment_scripts # an empty VOCABULARY deliberately does not bail here -- see below if not scripts: return None # segments[0] is the NAME part under both remaining structures # (everything, under NO_COMMA); later segments are suffixes. Its # members are main-stream token indices by construction, so # extracted nickname/maiden content is unreachable from here. For # this site that is merely tidy -- the first script-written token # is the same either way. It is the PEEL above that reads this run # load-bearingly, and in the two structures that reach here it # reads exactly this one, only backwards; the argument lives at # that scan. i = next((i for i in state.segments[0] if effective_script(state.tokens[i].text) in scripts), None) # A post-nominal in the surname's own position is not a site to # skip past but an answer: a surname LEADS, so if the leading # script-written token is an honorific there is no surname here to # find. Scanning ON would reach the given name, which is exactly # what the first-token rule exists to prevent -- 지 is a listed # surname, so "양 지훈" (양 is a surname AND a shipped honorific) # would have its own given name split in half. Declining also # covers the token the peel above manufactures, which is the first # and only script-written one in "Anderson선생님". if i is None or _is_post_nominal(state, i): return None # A token that IS a surname never splits: a bare "남궁" must not # become 남 + 궁 just because the single-syllable surname also # matches -- there is nothing to split off, and a lone token's # role is the order resolution's call, stop or no stop. Asked here, # with the core the split reads (#323), so the comma gate in front # learns it before asking the comma's decision (#613's /simplify) if state.tokens[i].text.rstrip(FULL_STOPS) in state.lexicon.surnames: return None return i def _split_surname_site(state: ParseState) -> ParseState: """The stage's other split: the first activated-script token of the name part is matched longest-first against Lexicon.surnames, and a hit splits it in two; where the vocabulary declines, an optional Parser(segmenter=...) is consulted instead. A sibling of _peel_honorific_tail rather than a continuation of it. The two answer different questions -- this one asks where a name divides into surname and given, the peel asks whether a token ends in a word that can never end a name -- which is why they carry different gates: the FAMILY comma and the 间隔号 gate this half alone (#312), and segment_scripts below gates it alone too. That is also why the stage entry below interleaves gates with its two calls rather than running one cascade.""" i = _split_candidate(state) if i is None: return state token = state.tokens[i] text = token.text surnames = state.lexicon.surnames # The vocabulary match reads the token's CORE (#323). Without it # the stop WAS the remainder: '김.' matched its own head and became # 김 + '.'; the stop rides with the remainder instead, so '김민준.' # divides as 김 + '민준.'. A leading stop never reaches THIS site -- # the classification fold in front rstrips too # (_vocab._normalized_for_script) and hides such a token before it # arrives -- while the peel above has no such gate in front of it # and its own rstrip is what does the work there. core = text.rstrip(FULL_STOPS) # Longest-first (compound-before-single falls out of it), capped # so the remainder is never empty. Direct membership, no # _normalize: the script gate admits only CJK text, and a CJK # entry's stored form is its NFC composition with no case or # edge-stop change (#322) -- so an entry AUTHORED in NFD is # composed on the way in, raw NFD input matches nothing here, and # the name goes unsplit, the no-split NFD degradation that # decisions/W1's 2026-07-29 NFD bullet (ja amendment 1a) accepts. An empty vocabulary skips the match rather # than bailing the stage (_longest_entry's max() has nothing to # take): a surname-less lexicon declines every token, which is # exactly the condition the segmenter is consulted on, so an early # bail would make a configured segmenter silently inert under # Lexicon.empty() -- the JA pack's own shape. matches: list[int] = [] if surnames: cap = min(_longest_entry(surnames), len(core) - 1) matches = [length for length in range(cap, 0, -1) if core[:length] in surnames] if matches: take = matches[0] detail = None if len(matches) > 1: # more than one vocabulary-supported split: longest-first # DECIDED a fork, and the deciding stage records it. A # single-match split chose nothing, so it stays silent -- # a dictionary certainty and the statistical guess below # are different epistemic states, and these two emission # rules say so. chosen = " + ".join(map(repr, _pieces(text, (take,)))) other = " + ".join(map(repr, _pieces(text, (matches[1],)))) detail = (f"{text!r} splits as {chosen} on the longest " f"surname; {other} also reads") return _split(state, i, (take,), detail) if state.segmenter is None: return state # the first activated-script token decides # The segmenter's precondition, which the vocabulary has no twin of: # it is asked where an UNDIVIDED name divides, so it may only be # shown a token that is the whole name. Where the name part carries # a second script-written token the writer already drew this # stage's missing boundary -- "山田 太郎" is divided, and dividing # its family again yields 山 + 田 + 太郎 (namedivider answers for # any string, and scores a two-character one 1.0 by rule, so no # confidence check would catch it). The neighbour counts whatever # script it is written in, ACTIVATED or not -- effective_script is # merely non-None -- because a katakana or hangul neighbour is a # boundary its writer drew just as deliberately as a Han one: under # the JA pack "山田太郎 マイケル" declines, though katakana is in no # activation set. A Latin title or suffix is NOT such a boundary: # it says nothing about where the CJK name splits, so "Dr 小明日, # Jr." still reaches the segmenter. Vocabulary keeps its own rule # -- a listed surname is a certainty about that exact string, # whoever else stands beside it. # The one neighbour that does not count is the one this stage # MANUFACTURED (#308): a glued 山田太郎様 has no writer-drawn # boundary anywhere, so the 様 the peel just cut off cannot be read # as one -- the precondition must see what the writer wrote, which # was a single undivided token. A SPACED 様 does count, and the # reason is weaker than "its writer drew that boundary and chose # to write 山田太郎 as a unit": in "山田太郎 様" the unit the writer # drew is the WHOLE NAME, honorific and all, so that story is false # for the very input it describes. What the code relies on is that # by POSITION a spaced honorific is indistinguishable from a spaced # name element, so the test conservatively counts it. Measured, the # trade is worth it: counting them keeps 佐藤 氏, 田中 様, 鈴木 先生 # and 中村 教授 whole -- all four divide bare under the JA pack (佐 # + 藤, 田 + 中, 鈴 + 木, 中 + 村) -- and costs the single division # 山田太郎 様. Four real surnames against one. # Provenance, not vocabulary: the two spellings put the same word # in the same place, and asking the suffix set instead cannot # separate them. if any(j != i and effective_script(state.tokens[j].text) is not None and _PEELED_TAG not in state.tokens[j].tags for j in state.segments[0]): return state # No try/except around the call: rules/A1's Accepted clause # ("a user-supplied segmenter's own error, which propagates"). The # two checks below are that same doctrine, curated, and they are # where the line this module draws is easiest to state: # a PROTOCOL VIOLATION BY THE SEGMENTER AUTHOR RAISES, while an # ADAPTER'S DEFENSE AGAINST ITS LIBRARY DECLINES. Both checks here # are the first kind -- a wrong answer TYPE and an answer indexing # past the token it was handed are stage-detectable bugs in # user-supplied code, inside the declared totality exception, so # they get the same treatment the callable's own exceptions get. # locales/ja.py is the second kind: its repertoire, length, # reconstruction and score guards all return None, because what # they defend against is namedivider answering a question nobody # asked it, which is a fact about the CONTENT, not a broken # protocol. Bounded like every message here: the type's NAME, never # its contents. # The CORE, not the raw token (#323): handed the raw token, a # segmenter answering len-1 would make the stop a piece of its own # ('田中太郎' + '。'), where against the core the trailing stops # ride with the last piece -- '山田太郎.' answered at 2 divides as # 山田 + '太郎.'. answer = state.segmenter(core) if answer is not None and not isinstance(answer, Segmentation): # a duck-typed answer carrying a .splits of its own would # otherwise wander into the split path and surface as a # ValueError naming Token, pointing the reader at nameparser's # insides instead of at their segmenter raise TypeError( f"segmenter must return Segmentation or None, got " f"{type(answer).__name__}") if answer is None or not answer.splits: return state # declined, or confidently one token # splits[-1] is the max -- Segmentation enforced ascending. The # upper bound is the half Segmentation cannot check, since it never # sees the text; an offset at or past the end would make an empty # piece. Declining silently here (as this did before the review) # made an off-by-one segmenter undebuggable: every answer it gave # vanished, and the parse merely looked unsegmented. # The bound is the CORE's length, the string the segmenter was # actually handed: an offset it could not have derived from its own # input is the same author bug whether or not `text` is longer. if answer.splits[-1] >= len(core): raise ValueError( f"segmenter returned splits beyond the token: last offset " f"{answer.splits[-1]}, the segmenter was given " f"{len(core)} characters") conf = answer.confidence detail = None if conf is not None and conf < _SEGMENTER_CONFIDENCE_FLOOR: reading = " + ".join(map(repr, _pieces(text, answer.splits))) detail = (f"{text!r} splits as {reading} on a segmenter answer " f"scoring {conf:.2f}, under the " f"{_SEGMENTER_CONFIDENCE_FLOOR} confidence floor") return _split(state, i, answer.splits, detail) # rules/W1: "an undivided word in the family position of a name # written in an activated script divides after a recognized surname, # the longest recognized surname first; where the vocabulary # recognizes nothing, an optional segmenter may divide instead" # rules/W3: "under a family comma the pre-comma text is the # family by declaration and never divides, and the post-comma side # is given text with no family to find" (history: decisions/W3) def script_segment(state: ParseState) -> ParseState: if state.original.isascii(): # spans index the original exactly (the anti-#100 invariant), # so an ASCII original has only ASCII tokens: nothing here is # in any script's ranges. It also short-circuits the PEEL, # which has no script gate of its own -- so a caller-configured # ASCII tail never fires, and one non-ASCII character anywhere # in the name switches it on. Correct for the CJK vocabulary # that ships, stated because honorific_tails is public: see # that field's own note. Latin orthography SPACES its # post-nominals ("John Smith PhD"), so the glued position this # peel exists for is a CJK one to begin with. What Latin does # glue is PREnominal and period-joined (Mr.Smith, Lt.Gov.), # which _vocab.period_joined_vocab classifies as one token # rather than splitting -- a different mechanism, and no peel # site either way. A glued Latin POST-nominal is spelled the # same way, so it reaches that same mechanism rather than # nothing: period_joined_vocab reads "Smith.Jr." as a suffix # ('jr' is suffix vocabulary only since #296 took it out of # TITLES), and position decides whether that reading lands -- # "John Anderson Smith.Jr." gives suffix "Smith.Jr.", while # "Smith.Jr. Anderson" gives given "Smith.Jr.", family # "Anderson" (measured 2026-10-02). Whatever it decides, this # bail is what settles the question here: it # returns above all of it, so no ASCII input reaches the peel. return state if not state.segments: return state # #312: the peel runs in front of both gates below, because both # answer where a name divides into surname and given, and the peel # does not ask that. It asks whether a token ends in a word that # can never end a name, and a comma or a dot elsewhere in the # string does not change the answer. Placing it above the block # also keeps a future segmentation gate from silently capturing # it: a new gate lands with its siblings, below. # The comma's decision, asked lazily: the peel asks it only once it # has a site, the division below only once there is a token to # divide (#613's /simplify), each on the state it acts on. state = _peel_honorific_tail( state, lambda: _postnominal_behind_a_whole_name(state)) if (state.structure is Structure.FAMILY_COMMA and (_split_candidate(state) is None or not _postnominal_behind_a_whole_name(state))): return state # the comma already drew the SURNAME boundary if state.interpunct_offsets: # #298: a 间隔号-divided name is a transcription -- its pieces # are syllable groups, not surname+given, so neither the # vocabulary nor the segmenter applies (codepoint-scoped: the # nakaguro records nothing and gates nothing, spec decision 5). # State-global like the FAMILY_COMMA gate above: a marker # anywhere in the name reads the WHOLE name as a transcription # listing, so even an un-dotted hangul token beside a dotted # one stays whole. Scoped to the surname split since #312: an # honorific glued to a transcription is still an honorific. return state return _split_surname_site(state) derek73-python-nameparser-456b024/nameparser/_pipeline/_segment.py000066400000000000000000000300421527303271700251620ustar00rootroot00000000000000"""Stage: segment. Consumes: tokens (role-None main stream), comma_offsets, one_case (where an earlier stage recorded it). Produces: segments (runs of main-token indices; interior segments may be EMPTY -- doubled commas keep their structural position), structure -- NO_COMMA, or FAMILY_COMMA for every comma form: whether the part after the first comma is the postnominal part is decided at the head of group once classify has tagged the words (#613, `_comma.decide`), which may set SUFFIX_COMMA -- one_case where C2's flag asked for it, and COMMA_STRUCTURE ambiguities for unrecognized extra segments (a part of title and suffix words is recognized, #603). Reads: for C2's flag on a part past the second comma only: Lexicon suffix vocabulary and Policy through _vocab.is_wholly_suffix (Policy.lenient_comma_suffixes picks the lenient or strict token test; Policy.extra_suffix_delimiters makes a delimiter-core token transparent, as the built-in cores are), with the credential lean the lazy case gate supplies (Lexicon.maiden_markers DIRECTLY, for the own-words span _pieces.own_words takes); Policy.unlisted_dotted_suffixes through _vocab.ambiguous_class_candidate and _vocab.ambiguous_class_member, for a part of by-shape credentials; and Lexicon.titles DIRECTLY, with _vocab.period_joined_vocab for a period-joined title, and Policy.extra_suffix_delimiters through _vocab.glued_cores for a title with a delimiter glued to it (rules/C10), for a part of titles and suffixes (#603). Implements rule C2 of docs/design/rules/, and C1's segmentation; C1's decision is `_comma.decide`'s. History in decisions/C1. """ from __future__ import annotations from nameparser._lexicon import _normalize from nameparser._pipeline._pieces import own_words from nameparser._pipeline._state import ( ParseState, PendingAmbiguity, Structure, comma_bucket, copy_with, ) from nameparser._pipeline._vocab import ( ambiguous_class_candidate, ambiguous_class_member, glued_cores, glued_stem, period_joined_vocab, is_one_case, is_wholly_suffix, ) from nameparser._types import AmbiguityKind def segment(state: ParseState) -> ParseState: main = [i for i, t in enumerate(state.tokens) if t.role is None] if not main: return copy_with(state, segments=(), structure=Structure.NO_COMMA) if not state.comma_offsets: return copy_with(state, segments=(tuple(main),), structure=Structure.NO_COMMA) buckets: list[list[int]] = [[] for _ in range(len(state.comma_offsets) + 1)] for i in main: # _state.comma_bucket, not a local bisect: classify asks the # same question of the same offsets to keep a marker run inside # one segment, and the two must be one expression rather than # two that agree buckets[comma_bucket(state.tokens[i].span.start, state.comma_offsets)].append(i) groups = [tuple(b) for b in buckets] # v1 strips exactly ONE trailing comma as cosmetic (parser.py's # collapse_whitespace); every other empty bucket is STRUCTURAL and # keeps its position -- in 'Doe,, Jr.' the given segment is empty, # so 'Jr.' stays a tail suffix instead of masquerading as a lone # post-comma title (v1 parity, pinned live 2026-07-16) if len(groups) > 1 and not groups[-1]: groups.pop() if len(groups) <= 1: segs = tuple(groups) if groups and groups[0] else (tuple(main),) return copy_with(state, segments=segs, structure=Structure.NO_COMMA) # The case fact, asked LAZILY: here only C2's flag on a part past # the second comma turns on it. A name that never asks pays nothing; # classify asks for itself later where this did not # (decisions/S6, and #429's precedent for paying a predicate # twice rather than plumbing a field two sites would not otherwise # share). one_case = state.one_case def case_class() -> bool: nonlocal one_case if one_case is None: one_case = is_one_case(own_words( state.tokens, state.comma_offsets, state.lexicon.maiden_markers)[0]) return one_case # The texts are built inline rather than through a helper # (measured, #289/#516's eager-gate fix round): on 3.11 a list # comprehension IS a frame # (PEP 709 inlines it only from 3.12 on; see # tools/perf/call_count.py's docstring), so wrapping it in another # call doubles the cost of every tail segment asked. # # `case` is ParseState.one_case: None is the case-FREE reading, # which is what every caller on this path wants, and the tail # walk below passes `case_class()` for the leaning one. One # closure with a default rather than two whose bodies differ only # in that argument. # The strict test also reads the case fact, for its initial veto # (rules/T4, #727): asked only under that opt-in policy, so the # default pays nothing for it. def suffixy(seg: tuple[int, ...], case: bool | None = None) -> bool: return is_wholly_suffix( [state.tokens[i].text for i in seg], state.lexicon, state.policy, one_case=case, letters_one_case=(None if state.policy.lenient_comma_suffixes else case_class())) def class_run(seg: tuple[int, ...]) -> bool: # Every token of the run joins the ambiguous credential class # BY SHAPE -- a candidate that is not a LISTED member, which is # the one half `is_wholly_suffix` cannot see (its own docstring # says so, and the blindness is what keeps a by-shape token out # of C1's whole-name suffix fallback in `_comma.decide`). A tail # segment is consumed as suffix either way, so what this decides is only # whether the parse says it did not RECOGNIZE the segment -- and # a run the parse itself reads as a credential run by shape is # recognized. Without it, the narrow roman retirement # (rules/S3) moved 'R.A.I.', 'X.Y.I.' and 'J.u.n.i.o.r.' out # of the vocabulary verdict and into the shape class, and every # one of them gained a COMMA_STRUCTURE flag in a third segment # that 2.3 did not raise -- a report about the parser's own new # reading rather than about the name (review round, #289/#516). # # The LISTED half is excluded rather than folded in: a listed # member reaches this reading through the LEAN (the leaning # `suffixy` call below), and that is the whole of what quiets # it -- 'STEVEN HARDMAN, MD, DO, DDS' is written in one case, # leans nothing, and keeps its flag, the recorded negative # control for the lean's own effect here. Admitting membership # alone would silence it and leave the control measuring # nothing. # # Policy-sensitive by construction: with # `unlisted_dotted_suffixes` off the token is name material, is # no candidate, and the flag stands. return all(ambiguous_class_candidate(state.tokens[i].text, state.lexicon, state.policy) and not ambiguous_class_member(state.tokens[i].text, state.lexicon) for i in seg) def titles_and_suffixes(seg: tuple[int, ...]) -> bool: # rules/C2: "a word of the title vocabulary there that is not # also suffix vocabulary is a title" -- at least one title # word, and every other word a suffix word # period_joined_vocab for 'Lt.Gov.', one token that classify # (not yet run) tags a title by this same call, and that assign # then reads as one; a word without a period cannot be one # rules/C10: "the word keeps the delimiter in its text and reads # as it would without it" -- a title with one glued ('Dr;') # reads as the title, as classify tags it (decisions/C10) titles = state.lexicon.titles cores = glued_cores(state.policy.extra_suffix_delimiters) rest = tuple([i for i in seg if _normalize(t := state.tokens[i].text) not in titles and ("." not in t or period_joined_vocab(t, state.lexicon) != "title") # the pre-check keeps glued_stem's frame off a # word that holds no core (its docstring's terms) and not ((";" in t or len(cores) > 1) and (g := glued_stem(t, cores)) and _normalize(g) in titles)]) return len(rest) < len(seg) and (not rest or suffixy(rest)) # rules/C1's decision is not this stage's since #613: every comma # form leaves here as the FAMILY comma -- the listing form, what a # comma means until the part after it has been read -- and group # decides at its head, once classify has tagged every word, whether # that part is the postnominal part (`_comma.decide`). Deciding here # meant predicting the later stages: group's particle chain (#562's # pair) and assign's reading of a run the capitals settle, with a # second count in assign besides. What stays here is the # segmentation and C2's flag on a part past the second comma. ambiguities = list(state.ambiguities) # rules/C2: "a non-empty extra part that is not entirely suffix # words is flagged as a structural ambiguity rather than rejected" # -- parts[2:] are consumed as suffixes either way, their title # words as titles (#603), so a tail segment that is neither all # suffix words nor title words beside suffix words gets the # COMMA_STRUCTURE flag, not a structure veto. The lean reaches this reading too: # a tail of leaning credentials is a credential run, which is the # one place this design quiets a report rather than adding one. for seg in groups[2:]: # empty segments are consumed silently (v1 skips them without # comment); only non-empty non-suffix tails get flagged. # The case-FREE `suffixy(seg)` first: by construction the # leaning call can only ADD a disjunct to it # (`is_wholly_suffix`'s credential-lean branch), never remove # one -- so a seg that already reads wholly suffix case-free # reads so case-aware too, and the case fact is worth forcing # only when the case-free answer was False (measured # regression: the leaning call forced the fact for every tail # segment, suffix or not). # `class_run` last, for the same lazy reason and one step # further out: it is the only one of the three that walks the # by-shape class, and it is asked only of a run BOTH suffix # readings have already declined. # #603: a part assign reads as titles and suffixes is # recognized too -- word by word, off the title vocabulary # (classify has not run yet), so a part holding a connective ('Secretary of State') keeps the # flag: knowing that group's title chain takes it would be a # model of group, the shape mechanisms.md's # READ-WITHOUT-THEN-BIND exists to remove. Last, as the # rarest reading. if (seg and not suffixy(seg) and not suffixy(seg, case_class()) and not class_run(seg) and not titles_and_suffixes(seg)): texts_joined = " ".join([state.tokens[i].text for i in seg]) # Names no field: since #603 a title word in the part reads # as a title, so 'consumed as suffix' had told # 'John Smith, Jr., Dr. Bart' its 'Dr.' was a suffix, and # the fields themselves say where each word went (#629). ambiguities.append(PendingAmbiguity( AmbiguityKind.COMMA_STRUCTURE, f"segment {texts_joined!r} beyond the recognized comma " f"structures; read best-effort", tuple(seg))) return copy_with(state, segments=tuple(groups), structure=Structure.FAMILY_COMMA, ambiguities=tuple(ambiguities), one_case=one_case) derek73-python-nameparser-456b024/nameparser/_pipeline/_state.py000066400000000000000000000524531527303271700246520ustar00rootroot00000000000000"""Internal pipeline state: WorkToken and ParseState. WorkTokens are pipeline-internal (no validation -- the tokenizer is the only producer) and are addressed BY INDEX in every stage: pieces and segments are runs of token indices, never joined strings, so value-based lookup (v1's #100 family) is structurally impossible. Layering: imports _types, _lexicon, _policy only (enforced by tests/v2/test_layering.py). """ from __future__ import annotations import bisect import dataclasses from collections.abc import Mapping, Sequence from dataclasses import dataclass from enum import Enum, auto from types import MappingProxyType from typing import TYPE_CHECKING, TypeVar from nameparser._lexicon import Lexicon from nameparser._policy import Policy from nameparser._types import AmbiguityKind, Segmenter, Span # re-exported for _vocab, which layering keeps off _types from nameparser._types import ReadingKind as ReadingKind from nameparser._types import ReadingSource as ReadingSource from nameparser._types import Role as Role from nameparser._types import SHAPE_ACRONYM_TAG as SHAPE_ACRONYM_TAG from nameparser._types import SPLIT_CREDENTIAL_TAG as SPLIT_CREDENTIAL_TAG from nameparser._types import SPLIT_MEMBER_TAG as SPLIT_MEMBER_TAG if TYPE_CHECKING: from nameparser._pipeline._pieces import TailRead # The comma characters (ASCII/Arabic/fullwidth, #265). Shared here so # tokenize (separators/segmentation) and extract (close-quote # boundaries) cannot drift apart. COMMA_CHARS = frozenset({",", "\u060c", "\uff0c"}) def comma_bucket(start: int, comma_offsets: Sequence[int]) -> int: """Which comma-delimited part of the name a token starting at `start` falls in: the number of commas before it. Shared here for the reason COMMA_CHARS is, and the sharing is load-bearing in the same way. segment BUILDS the segments with this (comma_offsets is sorted and no offset ever equals a token start, so bisect_left counts the commas before the token); classify asks it to decide whether two tokens could be in one segment, which is half of what stops a maiden marker run from spanning a boundary no segment holds. Two tokens agree here iff segment would put them in one segment, and that is an identity rather than a resemblance only while both sides ask this function. """ return bisect.bisect_left(comma_offsets, start) @dataclass(frozen=True, slots=True) class WorkToken: """One tokenized word. role stays None until assign; extracted nickname/maiden tokens arrive with their role pre-set. text is always the exact original slice (tokenize is the sole producer; the anti-#100 invariant depends on it).""" text: str span: Span tags: frozenset[str] = frozenset() role: Role | None = None #: M4's two carve-outs, as the tags classify recorded them: a bound #: given-name word is vocabulary claiming the word as a given name, #: and `initial` is the initial reading. Neither is a predicate M4 owns. #: Shared here beside WorkToken.tags for the reason COMMA_CHARS is: #: assign's `_WORD_ALREADY_CLAIMED` is built from this pair, and the #: two stages must not drift (post_rules imports _assign, so _assign #: cannot reach the other way). _NEVER_FLIPPED = frozenset({"vocab:bound-given", "initial"}) # SHAPE_ACRONYM_TAG is defined and explained in _types (its #: block # above the constant, not restated here -- defined there so _render # can read it too); re-exported here beside the membership half below # because the stages read both halves from one module. #: The MEMBERSHIP half of the same class: classify's tag for a token #: the ambiguous credential vocabulary claims, by listing #: (`Lexicon.suffix_acronyms_ambiguous`) or -- where a Policy switch #: admits the by-shape half -- beside `SHAPE_ACRONYM_TAG`. Beside that #: constant and for its reason: the string was spelled out at eight #: sites across four modules, each of them a place for a typo to pass #: silently, since a tag that is never written is simply a tag no #: reader ever finds. AMBIGUOUS_ACRONYM_TAG = "vocab:suffix-ambiguous" #: Beside `AMBIGUOUS_ACRONYM_TAG` on a suffix WORD that is also a name #: (`Lexicon.suffix_words_ambiguous`, rules/S11): the class is the #: same, and so is every site that weighs it, but the writing leans the #: other way -- lower case in a mixed-case name is the suffix, where an #: acronym's capitals are (`_vocab.ambiguous_lean`). `AGNOMEN_TAG` #: rides beside both on an agnomen, whose writing leans nowhere. AMBIGUOUS_WORD_TAG = "vocab:suffix-word-ambiguous" AGNOMEN_TAG = "vocab:agnomen" #: 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 #: the fork to report where the word opens the name. TITLE_AMBIGUOUS_TAG = "vocab:title-ambiguous" #: On a word of `Lexicon.given_name_abbreviations`, a given name written #: short ('Chas.', 'Ma.'): rules/H2's shape test declines a word #: carrying it, so the given reading outranks the shape-derived title #: (`_pieces.is_leading_title`, `_vocab.is_title_shaped`). GIVEN_ABBREVIATION_TAG = "vocab:given-abbreviation" #: EITHER way a token joins the ambiguous credential class -- the #: vocabulary's claim and the writing's. The two emitters that report #: a fork the peel called and declined ask exactly this: `_group`'s #: prefix chain (a listed member the case lean read as a name, 'John #: van der Berg Ma'; a by-shape member the count left standing, #: 'Freiherr von Berg X.Y.I.') and `_assign`'s family-comma slot. One #: constant beside the two above and for their reason -- the pair was #: spelled two ways, a frozenset here and an `or` of two `in` tests #: there -- and a frozenset so each test is one `isdisjoint`, a C call #: with no Python frame, on a branch every chained name reaches. _AMBIGUOUS_CREDENTIAL_TAGS = frozenset( {AMBIGUOUS_ACRONYM_TAG, SHAPE_ACRONYM_TAG}) # SPLIT_CREDENTIAL_TAG and SPLIT_MEMBER_TAG are defined and explained in # _types beside SHAPE_ACRONYM_TAG, case repair reading them too. #: The roles that make a piece a name word (rules/P6's walk), and #: the roles of a word not yet read as one: a suffix, or nothing yet #: -- what `_pieces.particle_tail` reads, asked by assign's given-part #: walk when it reaches the credential run, whose words are unread. NAME_ROLES = (Role.GIVEN, Role.MIDDLE, Role.FAMILY) SUFFIX_OR_UNREAD = (Role.SUFFIX, None) class Structure(Enum): """The comma structure: segment writes NO_COMMA or FAMILY_COMMA, and group's head decides a comma form once classify has tagged the words, which may make it SUFFIX_COMMA (#613, `_comma.decide`).""" NO_COMMA = auto() FAMILY_COMMA = auto() # "Family, Given ..." (v1 lastname-comma) SUFFIX_COMMA = auto() # "Given Family, Suffix ..." @dataclass(frozen=True, slots=True) # rules/A1: "the parse completes on the best reading and carries an # ambiguity report naming the doubt, as parsing never fails on any # input" -- its Then clause: this is the report's mid-pipeline record, # by token index (decisions/A1) class PendingAmbiguity: """An ambiguity recorded mid-pipeline by token INDEX; assemble materializes real Ambiguity objects over the final tokens. ``origin`` is for the one stage that runs BEFORE tokens exist: extract_delimited knows only a character offset, so it records that and tokenize resolves it to the containing token's index. Stages after tokenize set ``indices`` directly and leave ``origin`` None. ``field_tail`` is for a report that names the field its word was read into. post_rules may still move the word (H1's move behind a title, P1's family-first fold, P6's attachment, and under opt-in policies the patronymic rotations, O6's two family names and `middle_as_family`'s fold), so the emitter writes ``detail`` up to the field and the rest of its sentence here -- ``""`` where the field ends the sentence, which is why assemble tests ``is not None`` -- and assemble joins them around the FIELD_READING of the first referent's final role, the one place that knows it (#626). Until then ``detail`` is a fragment. """ kind: AmbiguityKind detail: str indices: tuple[int, ...] = () origin: int | None = None field_tail: str | None = None alternative: PendingReading | None = None class Deferred(Enum): """A PendingReading field its emitter cannot name yet.""" #: O5's: a lone name word read as one of the two primary fields #: would be read as the other, named from its FINAL role by #: assemble, since H1 or a rotation may still move it after the #: emitter (#626's reason for ``field_tail``). OTHER_PRIMARY = auto() #: classify's connective: the letter read as a connective would #: join the name words on either side into one, whose field assign #: names once it has positions -- the positional read's, where the #: join would merge two pieces, and the letter's own piece's #: (`Beside`), where both words are already in it. JOINED = auto() #: group's particle chain: the particle it chained would stand as a #: name word of its own in front of the piece, named by assign. UNCHAINED = auto() @dataclass(frozen=True, slots=True) class Beside: """A PendingReading field named from another token's: the reading lands where the token at `index` lands, named by assemble from that token's final role -- a word the name piece in front would take in (a particle chain, which runs to the end of the name words, or a bound given name), or a connective whose join stays inside its own piece. Where P6 then attached that token to the family (`FOLDED_TAG`), which it would not have done with a word behind it, `unfolded`, the field the word held when the report was made, stands instead.""" index: int unfolded: Role | None = None @dataclass(frozen=True, slots=True) class PendingReading: """The reading a report's decision did not take, as its emitter knows it; assemble turns it into the public Reading. ``role`` is the field the first referent would hold under that reading, with every other word of the name read as the parse read it, a `Deferred` form for a stage that runs before the field is known, or `Beside` for a field another token's final role names. A JOINED or UNCHAINED form assign leaves unresolved -- a referent that ends outside every name position -- surfaces as no alternative rather than a guess. Assemble also folds a MIDDLE into the family under ``middle_as_family``, the one later rule that moves a field whatever word stands in it.""" kind: ReadingKind source: ReadingSource role: Role | Deferred | Beside #: How a report names the field a word was read into, by final role. FIELD_READING: Mapping[Role, str] = MappingProxyType({ Role.TITLE: "a title", Role.GIVEN: "a given name", Role.MIDDLE: "a middle name", Role.FAMILY: "a family name", Role.SUFFIX: "a suffix", Role.NICKNAME: "a nickname", Role.MAIDEN: "a maiden name"}) # every role a referent can hold, or a new one is a KeyError at the # first parse whose report lands in it assert FIELD_READING.keys() == set(Role) @dataclass(frozen=True, slots=True) class ParseState: """Carried through the stage fold. Frozen; stages return copies via copy_with. Fields are filled progressively: extract_delimited -> extracted/masked; tokenize -> tokens (span- sorted)/comma_offsets/interpunct_offsets (the 间隔号 offsets the order and segmentation decisions consult, #298; the nakaguro separators record NOTHING); segment -> segments/structure/one_case (lazily, only where C2's flag asks for it -- #289/#516, #613); script_segment -> tokens and segments again (the one stage that changes the token COUNT: an unspaced CJK token splits into n+1 pieces, still as sub-slices of the original, and every later index in the segment runs shifts by n); classify -> token tags AND one_case; group -> structure (its head decides a comma form, #613) AND the SUFFIX/TITLE roles of a postnominal part after the comma, whose segment it empties so no join reaches it, then pieces/piece_tags/dropped AND maiden token roles, plus the 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); post_rules -> roles again (a delimited member S13 settles becomes the suffix, #810), 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 -- extract (resolved to a token index by tokenize), segment, script_segment, classify, group, assign, and post_rules -- since a fork whose branches are taken in different stages needs an emitter in each. Post-group, segments may retain indices of dropped tokens -- assign iterates pieces, never segments. This ownership map is pinned by tests/v2/pipeline/test_state.py. segmenter belongs to no stage: like original/lexicon/policy it is passed in at construction by Parser.parse and only ever READ (by script_segment, for a token the vocabulary declined).""" original: str lexicon: Lexicon policy: Policy #: The optional Parser(segmenter=...) hook; None = not configured. segmenter: Segmenter | None = None extracted: tuple[tuple[Role, Span], ...] = () masked: tuple[Span, ...] = () tokens: tuple[WorkToken, ...] = () comma_offsets: tuple[int, ...] = () interpunct_offsets: tuple[int, ...] = () segments: tuple[tuple[int, ...], ...] = () structure: Structure = Structure.NO_COMMA # pieces[s][p] = run of token indices: piece p of segment s. # piece_tags[s][p] = derived flags for that piece ("title", "prefix", # "suffix", "conjunction") set by group's joins. pieces: tuple[tuple[tuple[int, ...], ...], ...] = () piece_tags: tuple[tuple[frozenset[str], ...], ...] = () dropped: tuple[int, ...] = () # structural tokens (maiden markers) #: rules/S2's trailing run per segment, as group read it ONCE #: after P3's joins and before the particle chain (#614), for #: assign to place and report #: rather than peel again; None where group read none -- a family #: comma's parts, a part past a comma, a segment of fewer than #: three pieces, a run with a name piece inside it -- and assign #: reads those for itself, as it does a name group's joins left #: without a name word. Aligned with `pieces`. tail_reads: tuple[TailRead | None, ...] = () #: The order `assign` actually READ the name under -- name_order, #: or the script_orders entry that overrode it. None wherever no #: positional read happened: after a family comma (which fixes the #: family, so assign consults no order at all), and on every early #: return in `_assign_main`, where a segment holds no name piece #: to position. Recorded rather than recomputed downstream, #: because the two can differ and a post_rules rule keyed on #: `policy.name_order` would then disagree with the roles assign #: already wrote (#395). Reaching that divergence needs a custom #: lexicon -- every shipped particle is Latin, and Latin has no #: script_orders entry -- which is why the test for it builds its #: own (test_post_rules.py). order: tuple[Role, Role, Role] | None = None #: Whether the name's OWN words are written wholly in one case -- #: all upper or all lower alike -- and so carry no case EVIDENCE #: about any word in them (rules/P3's own-words span, #: _pieces.own_words). None means NOT ASKED YET: the fact is #: computed by whichever of segment and classify needs it first, #: segment only when C2's flag on a part past the second comma asks #: for it, so a reader between the two stages sees None and must #: not guess -- script_segment asks for itself there (#613). #: Recorded rather than recomputed, the way `order` above is: the #: trailing suffix slot, the post-comma slot, the tail-segment #: reading, the prefix chain's own tail measure (_group) and the #: glued-honorific peel's decline of a post-comma run #: (_script_segment) all consult it and must not disagree #: (#289/#516, decisions/S6). Five stages read it in all -- #: segment, script_segment, classify, group and assign -- plus the #: two predicate layers they read it through (_pieces, _vocab). #: A fact segment records SURVIVES script_segment, the one stage #: that changes the token count, and survives it unrecomputed #: because splitting a token cannot change the answer: the verdict #: is `is_one_case` over the name's own words JOINED, and a split #: only moves a space into a string whose upper/lower comparison #: ignores spaces entirely -- '김민준씨' and '김민준 씨' fold alike. #: So the field is carried through rather than invalidated, and #: script_segment reads it where segment recorded it (its suffix-run #: predicate takes the lean), asking for itself only where segment #: did not, without recording the answer (`_one_case`). one_case: bool | None = None ambiguities: tuple[PendingAmbiguity, ...] = () def _copy_refusals(cls: type) -> list[str]: """Why a field copy of `cls` might not build what `dataclasses.replace` builds, one reason per unmet condition. `replace` calls `obj.__class__(...)`, and every hook that call can run -- a metaclass `__call__`, a `__new__`, the `__init__` and any `__setattr__` it goes through, a `__post_init__`, an `InitVar` it demands for want of a default -- is one a field copy skips. These conditions are a TRIPWIRE for a realistic edit to the pipeline's three classes, not a proof against any class at all: a forged `__qualname__` on a borrowed `__init__` combined with its own decoration, or a `__class__` property, is out of scope, and `copy_with` copies nothing but those three anyway.""" reasons = [] params = cls.__dict__.get("__dataclass_params__") if params is None: reasons.append("not decorated as a dataclass itself") elif not params.frozen: reasons.append("not frozen") init = cls.__dict__.get("__init__") # dataclasses compiles the __init__ it generates from a string and # names it after the class; one written in a class body carries its # source file, and one borrowed from another class keeps that # class's name. if (init is None or init.__code__.co_filename != "" or init.__qualname__ != f"{cls.__qualname__}.__init__"): reasons.append("__init__ not generated for it") init = None fields = dataclasses.fields(cls) if init is not None: # an InitVar is an __init__ parameter that is not a field code = init.__code__ taken = code.co_argcount + code.co_kwonlyargcount - 1 if taken != sum(f.init for f in fields): reasons.append("__init__ does not take exactly its init fields") if type(cls) is not type: reasons.append("built by a metaclass") if any("__new__" in vars(k) for k in cls.__mro__[:-1]): reasons.append("defines __new__") if hasattr(cls, "__post_init__"): reasons.append("defines __post_init__") if not all(f.init for f in fields): reasons.append("has an init=False field") return reasons def _copyable_fields(cls: type) -> tuple[str, ...]: """The fields `copy_with` carries for `cls`, or TypeError naming every condition of `_copy_refusals` it fails.""" reasons = _copy_refusals(cls) if reasons: raise TypeError( f"copy_with cannot copy {cls.__name__}: it needs a frozen " "dataclass whose generated __init__ only assigns its fields " f"({'; '.join(reasons)})") return tuple(f.name for f in dataclasses.fields(cls)) #: Built once, at import: a pipeline class that stops qualifying fails #: here rather than in a parse, and `copy_with` copies nothing else. _COPY_FIELDS: Mapping[type, tuple[str, ...]] = MappingProxyType({ cls: _copyable_fields(cls) for cls in (WorkToken, PendingAmbiguity, PendingReading, ParseState)}) _T = TypeVar("_T") if TYPE_CHECKING: # mypy's dataclass plugin checks replace's keywords against the # class, which a `**changes: object` signature would not. from dataclasses import replace as copy_with else: def copy_with(obj: _T, /, **changes: object) -> _T: """`dataclasses.replace` for the pipeline's own dataclasses, without its per-call cost: a direct field copy, which for the classes in `_COPY_FIELDS` builds the same object (decisions/parse-cost has the measurement).""" cls = type(obj) names = _COPY_FIELDS.get(cls) if names is None: raise TypeError( f"copy_with copies only the pipeline's own dataclasses, " f"not {cls.__name__}") new = object.__new__(cls) for name in names: value = changes.pop(name) if name in changes else getattr(obj, name) object.__setattr__(new, name, value) if changes: raise TypeError(f"{cls.__name__} has no field named " f"{', '.join(sorted(changes))}") return new derek73-python-nameparser-456b024/nameparser/_pipeline/_tokenize.py000066400000000000000000000235151527303271700253570ustar00rootroot00000000000000"""Stage: tokenize. Consumes: original, masked (regions to skip), extracted (regions that tokenize with a pre-set role). Produces: tokens (span-sorted WorkTokens; text always == original slice), comma_offsets (segmentation points of the main stream; a comma there is never a token, one inside an extracted region is word text), interpunct_offsets (间隔号 transcription markers, #298; never tokens). Reads: Policy.strip_emoji, Policy.strip_bidi. Implements rules T1, T2 and T3 of docs/design/rules/, cited at their code below. There is NO text-rewriting normalize stage: all three are character-classification rules, so spans always index the original exactly as given (v1 contrast: decisions/T1). """ from __future__ import annotations import bisect import re from nameparser._pipeline._state import ( COMMA_CHARS, ParseState, WorkToken, copy_with, ) from nameparser._policy import Policy, _SCRIPT_RANGES, _script_matcher from nameparser._types import Role, Span # Ported from v1 (nameparser/config/regexes.py, "emoji" and "bidi") -- # layering forbids importing the config package here, so the tables are # duplicated by design with this provenance note. When editing, keep # both copies in sync (regexes.py builds its public re_emoji from the # SAME codepoint pairs). Integer ranges, not a regex character class: # the per-char test needs no regex, and CodeQL's py/overly-large-range # false-positives on literal astral ranges (surrogate decomposition). _EMOJI_RANGES = ((0x1F300, 0x1F64F), (0x1F680, 0x1F6FF), (0x2600, 0x26FF), (0x2700, 0x27BF)) _BIDI = re.compile('[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]+') # rules/T2: "the katakana middle dot and its halfwidth twin divide # a name like whitespace, always." They record no offset: the # nakaguro also divides kanji roster pairs (高橋・一郎, read # family-first by the script license, #272), so it is not a # transcription marker; only the Chinese 间隔号 is (#298). _NAME_DOT_SEPARATORS = frozenset({"\u30FB", "\uFF65"}) # rules/T1: "a zero-width space and a byte-order mark have no such # opt-out" -- the Except clause: they separate under every policy, # carrying nothing a caller would keep. Not here: U+200C and U+200D, # which decide how the letters around them join, the word joiner # U+2060, which means no break, and the soft hyphen U+00AD, which # marks a break inside one word (decisions/T1, #851). _INVISIBLE_SEPARATORS = frozenset({"\u200B", "\uFEFF"}) _INTERPUNCT = "\u00B7" # rules/T3: "the interpunct divides a name only between two # characters of a classified East Asian script; anywhere else it is # part of the word" (history: decisions/T3). Per-CHAR classifier # for the flank guard: a one-char string is wholly-classified iff the # character is. Context-sensitivity is why this lives in # _tokenize_region (where the index exists) and not in _ignorable. _classified_char = _script_matcher(*_SCRIPT_RANGES, whole=True) def _is_emoji(ch: str) -> bool: cp = ord(ch) return any(lo <= cp <= hi for lo, hi in _EMOJI_RANGES) # rules/T1: "an ignorable character separates its neighbors and # never joins them" (v1 contrast: decisions/T1) def _stripped(ch: str, policy: Policy) -> bool: """True when the strip policy removes `ch` from the token stream. The ONE definition of that set, shared by _ignorable and _flank: a character the strip policy removes must not occupy a flank position either, so a new strip class added here stays transparent to the interpunct guard by construction. The separators (whitespace, the name dots, the zero-width space and the byte-order mark) are not in this set and do occupy one.""" if policy.strip_bidi and _BIDI.match(ch): return True return bool(policy.strip_emoji and _is_emoji(ch)) def _flank(text: str, indices: range, policy: Policy) -> str | None: """The nearest flank character the strip policy would keep, or None if the range exhausts. Stripped invisibles are TRANSPARENT here: an RTL document quoting a transcription puts U+200F beside the dot in visually identical text. Whitespace and the other separators stay guard-defeating: a dot beside a space is not between characters.""" for i in indices: ch = text[i] if not _stripped(ch, policy): return ch return None def _ignorable(ch: str, state: ParseState) -> bool: if ch.isspace(): return True if ch.isascii(): # both strip classes and the unconditional separators are # entirely non-ASCII (bidi >= U+061C, emoji >= U+2600, the # invisibles >= U+200B, dots >= U+30FB): skip the checks below # for every ASCII letter return False # unconditional, like whitespace -- not policy-gated, so this sits # ahead of the policy check rather than in _tokenize_region beside # COMMA_CHARS (commas RECORD an offset; these must not -- only the # 间隔号 marks a transcription and records, #298, see # _NAME_DOT_SEPARATORS above). The only load-bearing constraint is # "after the isascii fast path" (all four are non-ASCII). if ch in _NAME_DOT_SEPARATORS or ch in _INVISIBLE_SEPARATORS: return True return _stripped(ch, state.policy) def _tokenize_region(state: ParseState, start: int, end: int, role: Role | None, record_offsets: bool, tokens: list[WorkToken], commas: list[int], interpuncts: list[int]) -> None: text = state.original tok_start: int | None = None for i in range(start, end): ch = text[i] # rules/N1: "a comma inside the clause is kept as written: the # comma divides nothing there" -- the Then bullet on a comma: a # comma separates only where it records a segmentation offset, # the main stream; inside an extracted region it is the clause's # text, like ';' (decisions/N1, #653) is_separator = ((record_offsets and ch in COMMA_CHARS) or _ignorable(ch, state)) if ch == _INTERPUNCT: # Region-local flanks: a B7 at a region edge stays token # text, and the bound also stops a custom delimiter's # classified edge character from acting as a flank across # a mask seam. The scan reads raw text like segmentation # matching does (#272's stance): NFD hangul degrades to # no-split, never wrong-split -- classification # NFC-normalizes but the guard does not. left = _flank(text, range(i - 1, start - 1, -1), state.policy) if left is not None and _classified_char(left): right = _flank(text, range(i + 1, end), state.policy) is_separator = (right is not None and _classified_char(right)) if is_separator: if tok_start is not None: tokens.append(WorkToken(text[tok_start:i], Span(tok_start, i), role=role)) tok_start = None if record_offsets: if ch in COMMA_CHARS: commas.append(i) elif ch == _INTERPUNCT: interpuncts.append(i) continue if tok_start is None: tok_start = i if tok_start is not None: tokens.append(WorkToken(text[tok_start:end], Span(tok_start, end), role=role)) def tokenize(state: ParseState) -> ParseState: tokens: list[WorkToken] = [] commas: list[int] = [] interpuncts: list[int] = [] # main stream: everything outside masked regions boundaries = [0] for m in state.masked: boundaries.extend((m.start, m.end)) boundaries.append(len(state.original)) for start, end in zip(boundaries[::2], boundaries[1::2]): _tokenize_region(state, start, end, None, True, tokens, commas, interpuncts) # extracted regions: pre-set role; a comma is word text there and an # interpunct a mere separator, neither recording an offset for role, inner in state.extracted: _tokenize_region(state, inner.start, inner.end, role, False, tokens, commas, interpuncts) tokens.sort(key=lambda t: t.span) # extract_delimited runs before tokens exist, so its ambiguities # carry a character offset instead of an index. Resolve them now # that the stray character has landed in a token ('"Nick', 'Smith)') # -- without this the ambiguity is locatable only by parsing the # offset back out of its detail string. An offset inside a masked # region belongs to no token; those keep an empty tuple, which the # kind's contract already allows. # Bisect rather than rescan: the closer sweep can emit one # ambiguity per delimiter character, so a linear scan per ambiguity # is quadratic on pathological input (') ' * 1600 spent 180ms here, # against 9ms before the sweep existed). Spans are non-overlapping # and sorted just above, so the candidate is the last token whose # start is <= the offset. # Nothing to resolve on the overwhelmingly common no-ambiguity # parse, and building starts + the closure is not free. ambiguities = state.ambiguities if any(a.origin is not None for a in ambiguities): starts = [t.span.start for t in tokens] def _containing(offset: int) -> tuple[int, ...]: i = bisect.bisect_right(starts, offset) - 1 if i >= 0 and offset < tokens[i].span.end: return (i,) return () ambiguities = tuple( a if a.origin is None else copy_with(a, indices=_containing(a.origin)) for a in ambiguities) return copy_with(state, tokens=tuple(tokens), comma_offsets=tuple(sorted(commas)), interpunct_offsets=tuple(sorted(interpuncts)), ambiguities=ambiguities) derek73-python-nameparser-456b024/nameparser/_pipeline/_vocab.py000066400000000000000000003343641527303271700246300ustar00rootroot00000000000000"""Shared vocabulary predicates for pipeline stages. Text-level tests used by more than one stage; piece-level ones live in _pieces, the sibling layer over tokens-plus-tags. All take normalized-or-raw text explicitly and no state, with the departures named below. is_wholly_suffix departs from that shape twice, deliberately. It is RUN-level rather than text-level, because the question it answers is genuinely about a run: the Ph./D. merge spans two tokens, so no per-token predicate composed with all() can express it. And it takes the Policy OBJECT, where delimiter_cores takes a pre-extracted frozenset so its caller hands in one field rather than the config -- is_wholly_suffix needs TWO policy fields (lenient_comma_suffixes and extra_suffix_delimiters), and threading both past every caller costs more than the config parameter saves. Still no state: Policy is frozen config, not pipeline state. maiden_marker_run is run-level for the first of those reasons and not the second: a maiden marker may be a PHRASE ('z domu'), so how far one reaches is a question about a run of words that no per-word membership test can answer, and the vocabulary reaches it as a plain frozenset field like every other predicate here. tag_marker_runs is the third departure, and the reason this module also imports _state (WorkToken, comma_bucket): deciding which tokens open a maiden-marker RUN needs each token's role and span, not its text alone, because a run must not cross a role change or a comma. It moved here from classify (#289/#516) so _pieces.own_words -- which may import only _state and _vocab (tests/v2/test_layering.py) -- can call the SAME function classify does rather than approximating it with a text-only walk. Two callers building a map from the same tokens with the same function cannot disagree, which a from-scratch approximation could (measured: 'ANNA z Nowak, MD' flips the one-case verdict when the approximation and the real run-completion test disagree on whether 'z' opens a run that completes). suffix_reading, name_reading and the two reading constants are the fourth: they answer no question about a word but BUILD the reading a report did not take (#752 phase 2m), from a token's text and tags, which is why they import _state's PendingReading and the public enums it re-exports. They sit here because every stage that reports a fork builds one, and the tags they read are this module's vocabulary. Layering: imports _lexicon, _policy, and _pipeline._state (WorkToken, comma_bucket, PendingReading -- read-only, never a whole ParseState). """ from __future__ import annotations import functools import re import unicodedata from collections.abc import Callable, Iterable, Sequence, Set from typing import Literal from nameparser._lexicon import ( FULL_STOPS, Lexicon, _VOCAB_FIELDS, _normalize, _spells_an_initial, ) from nameparser._policy import (CapsSuffixes, Policy, Script, _JA_SCRIPTS, _NO_INITIALS, _SCRIPT_RANGES, _script_matcher) from nameparser._pipeline._state import ( AGNOMEN_TAG, AMBIGUOUS_ACRONYM_TAG, AMBIGUOUS_WORD_TAG, COMMA_CHARS, Deferred, GIVEN_ABBREVIATION_TAG, SHAPE_ACRONYM_TAG, TITLE_AMBIGUOUS_TAG, PendingReading, ReadingKind, ReadingSource, Role, WorkToken, comma_bucket, ) # Ported verbatim from v1 (nameparser/config/regexes.py "initial") minus # its empty-string alternative -- WorkToken text is never empty. Kept in # sync by hand; layering forbids importing the config package here. # "Verbatim" is a promise about the PATTERN, not about the predicate: # since #320 is_initial is this SHAPE test ANDed with a repertoire test # (in_initialless_script, below), so _INITIAL.fullmatch(text) and # is_initial(text) are no longer the same question -- '씨.' answers yes # to the first and no to the second. Call is_initial; the bare pattern # is not the thing to ask. The narrowing lives in the predicate # precisely so this copy can stay exactly as verbatim as it ever was # -- REGEXES["initial"] is public v1 API and cannot narrow, and the # only difference between the two remains the empty alternative noted # above (config's `?`), which test_regex_sync splices back in. _INITIAL = re.compile(r"^(\w\.|[A-Z])$") # Ported verbatim from v1 (nameparser/config/regexes.py # "period_not_at_end") -- layering forbids the config import; keep in # sync by hand. _PERIOD_NOT_AT_END = re.compile(r".*\..+$", re.I) # The fix_phd credential pair ('Ph.' + 'D.' as adjacent tokens), # `split_pair`'s first answer, below. _PH = re.compile(r"^ph\.?$", re.IGNORECASE) _D = re.compile(r"^d\.?$", re.IGNORECASE) #: `split_pair`'s necessary half on its FIRST word for the Ph./D. #: answer alone, asked C-level by `is_wholly_suffix` ahead of the #: call, the one caller that asks only that answer and has no tags to #: read. The same compiled object the predicate asks, so the two #: cannot drift. SPLIT_PAIR_OPENS = _PH # The codepoint table lives in _policy beside Script -- one copy # importable from the pipeline and the locale packs alike; everything # here DERIVES from it. (single_script's sweep was first written # per-char on the _EMOJI_RANGES precedent in _tokenize.py, on the # theory that a range test needs no regex; measured at token scale the # compiled regex wins by 3-9x, and by roughly 70x on long tokens.) # Derived, never hand-written -- even the class construction goes # through the shared factory: one wholly-of predicate per script, in # the table's key order -- the FIRST-covering-entry rule _classify # documents. _SCRIPT_MATCHERS: dict[Script, Callable[[str], bool]] = { script: _script_matcher(script, whole=True) for script in _SCRIPT_RANGES } # The whole-token matcher over _policy's _JA_SCRIPTS union, backing # effective_script's kana license. _wholly_ja = _script_matcher(*_JA_SCRIPTS, whole=True) # The four kana voicing marks U+3099-U+309C (#596). None belongs to # either syllabary -- each voices the kana it follows -- but the script # table classifies by block and all four sit in the HIRAGANA one, so a # mark NFC cannot compose (ア + U+3099) made a katakana token read as # katakana plus hiragana and took the kana license. # _normalized_for_script drops every mark standing after a KANA, so the # mark takes its base's script. After anything else -- hangul, Han, # Latin, or nothing at all -- the mark is not voicing a kana and keeps # the table's answer: deleting it there would hand 김゙민준 a script the # surname site then divides on the RAW text, cutting the mark off its # base and leaving it to open the given name. _VOICING_MARKS = frozenset("\u3099\u309a\u309b\u309c") # Whole-text kana test for one character: the base a voicing mark may # be dropped after. _is_kana = _script_matcher(Script.HIRAGANA, Script.KATAKANA, whole=True) def _drop_kana_voicing_marks(text: str) -> str: """`text` without the voicing marks that follow a kana (a run of them included); every other mark kept. Called only on text holding a mark, so names without one never pay for the walk.""" kept: list[str] = [] after_kana = False for ch in text: if ch in _VOICING_MARKS: if after_kana: continue else: after_kana = _is_kana(ch) kept.append(ch) return "".join(kept) # The repertoire half of is_initial (_policy._NO_INITIALS), kept apart # from _INITIAL's SHAPE half so the pattern itself stays v1-verbatim # and its three copies stay pinned by tests/v2/test_regex_sync.py. # contains-any, not whole=True: the shape half has already admitted the # trailing period, so the text reaching here is '씨.' rather than '씨' # and a wholly-of match would be False for every case this exists for. # The second caller, is_title_shaped below, admits two or more # characters, so contains-any there means one CJK character anywhere # vetoes the whole word -- 'Kim김.' is refused as a title along with # '田中.' -- and that is deliberate: a word carrying a script with no # abbreviations is not wearing an abbreviation's period. _has_initialless_char = _script_matcher(*_NO_INITIALS, whole=False) def in_initialless_script(text: str) -> bool: """Whether TEXT holds a character of a script that writes no initials (#320), asked of the composed spelling: a decomposed hangul syllable is conjoining jamo, which no _NO_INITIALS range holds, so '씨.' typed decomposed escaped the veto every shape test beside it reads composed (#665). ASCII answers without the matcher's frame, as the callers' own `isascii()` gates do.""" return (not text.isascii() and _has_initialless_char(unicodedata.normalize("NFC", text))) # H2's own shape, text-level: an unlisted period-marked abbreviation, # two or more letters, one trailing period. Moved here from _pieces # (#289/#516, quality-review finding) so _vocab.name_word_count can # ask the SAME question _pieces.is_leading_title asks -- measured # divergence before the move: 'Dr. Smith, Ed' (LISTED title 'Dr.') and # 'Xyz. Smith, Ed' (UNLISTED, H2-shaped) both read family 'Dr. # Smith'/'Xyz. Smith', given 'Ed' at the peel (since #701 the listed # 'Dr.' then peels into the title, rules/H6), because is_leading_title # reads H2's shape and a bare `folded in lexicon.titles` lookup does # not -- but name_word_count's OWN count disagreed: 'Xyz.' counted as # a name word where 'Dr.' did not, so 'Xyz. Smith, Ed' alone flipped # the comma structure to a credential run # (mechanisms.md#ONE-PREDICATE-PER-QUESTION). # # The shape shared with config's "period_abbreviation" (layering # forbids the config import; keep in sync by hand -- the pattern TEXT # is what tests/v2/test_regex_sync.py compares, reaching this object # through `_pieces._PERIOD_ABBREV`, an IMPORT of this one, not a second # definition -- the sync test's target name did not move). It is # composed from `_LETTER` below, so the text is v1's plus the marks. # `_LETTER` is the letter every shape regex in this module spells # (`_PERIOD_ABBREV`, `_DOTTED`, `_CHUNKED`, `_PAIRED_INITIALS`): one # letter (a word character that is not a digit or underscore) plus the # combining marks written after it (#652). `inž.` typed as i, n, z, # U+030C, '.' is the same word as the composed one, and `\w` alone ends # it at the z. `_MARKS` is the general combining blocks -- what NFD of # a Latin, Greek or Cyrillic letter produces -- and not the marks that # only some scripts write (Indic vowel signs, Arabic and Hebrew # points); rules/H2's Accepted bullet "The shape is only recognizable # as an unbroken run of letters" owns that limit (decisions/H2). _MARKS = '[\u0300-\u036f\u1ab0-\u1aff\u1dc0-\u1dff\u20d0-\u20ff\ufe20-\ufe2f]' _LETTER = r'[^\W\d_]' + _MARKS + '*' _PERIOD_ABBREV = re.compile(r'^(?:' + _LETTER + r'){2,}\.$') # rules/H2: "an abbreviation opening the part of the name that # carries the given name — the whole name, or the part after a # family comma — reads as a title even when unlisted" # (history: decisions/H2) def is_title_shaped(text: str, lexicon: Lexicon) -> bool: """Whether TEXT reads as a title by H2's shape -- the LISTED half being a separate lookup at each caller's own site (is_title_piece/lexicon.titles), and the one vocabulary read here the given name written short, which outranks the shape (rules/H2, #789: 'Chas.' is no title). `name_word_count` and `title_readings` below are its callers; `_pieces.is_leading_title` asks the same question but keeps this body inline, its path being hot (every leading piece of every parse) where this one is cold (comma names only) -- the measured figure lives at that inline copy and is not restated here, two homes for one measurement being how they come to disagree. Touch one, touch both: `test_pieces.test_is_title_shaped_and_is_leading_title_agree` runs both over the union of the two example tables, so the agreement is checked rather than asserted in prose (mechanisms.md#ONE-PREDICATE-PER-QUESTION). The shape reads a Latin convention: a period marks an abbreviation. Scripts with no initials have no period abbreviations either (_policy._NO_INITIALS, the #320 veto is_initial carries), so a CJK word wearing a period is a name word, not a title -- a lone '田中.' is the family name (#323). `_PERIOD_ABBREV` stays ASCII-period only: a word wearing '。' never matches it, and the veto is what makes the ASCII spelling agree. ASCII text can carry no _NO_INITIALS character (every range sits above U+3000), so the C-level test declines before the regex search runs -- four frames per unlisted-abbreviation opener per parse, this running four times per piece. """ return (bool(_PERIOD_ABBREV.match(text)) and (text.isascii() or not in_initialless_script(text)) and _normalize(text) not in lexicon.given_name_abbreviations) def is_initial_shaped(text: str) -> bool: """v1's is_an_initial verbatim: the SHAPE half alone -- one word character plus a period, or a bare ASCII capital. Callers asking whether a token is STRUCTURALLY part of an initial run want this; callers asking whether it can really stand in for a name want is_initial (#320). The two answers differ only inside _NO_INITIALS scripts, where '씨.' is initial-SHAPED but is not an initial -- see assign's roman-numeral fork, the shape caller, for what picking the wrong one costs. Asked of the composed spelling, as `_render` asks it (#542): `\\w` does not match a combining mark, so a decomposed 'É.' is three characters to the pattern and no initial (#665). Composing rather than widening keeps the pattern v1-verbatim, and both calls are C-level, so the predicate costs no frame it did not before.""" if not text.isascii(): text = unicodedata.normalize("NFC", text) return bool(_INITIAL.fullmatch(text)) # 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 # rules/S10: "the numeral reads as the generation: the numerals the # vocabulary lists, and those up to X it does not, by their shape # alone" -- its Then clause, the `_ROMAN` test; and rules/S10's Except # bullet "An initial in front makes it a name word too", the two # is_initial_shaped tests on `preceding`. The words-to-spare count, # rules/S10's Except bullet "A numeral of one letter needs words to # spare", is the callers'. decisions/S10 (#718, #721) # mechanisms.md#ONE-PREDICATE-PER-QUESTION ("exactly one predicate answers it and every other site calls that one") # -- the numeral's predicate, which every numeral rule asks # (decisions/vocabulary-model, #752; #784, #774, #819) def reads_as_generation(text: str, tags: Set[str], preceding: str | None, one_case: bool | None) -> bool: """Whether a word reads as the generation: S10's roman-numeral fork, asked by every rule that weighs a numeral (#752). For a one-letter numeral a necessary condition only: the words-to-spare count is the caller's, which alone knows where the name's words are (rules/S10, #718). A numeral is I to X by shape, written with its periods or without ('VI.', the German ordinal), save that a single letter written with its period is an initial (rules/T5). The vocabulary's own numerals of two or more letters ('ii' to 'iv', `tags` carrying `vocab:suffix`) are the generation whatever stands around them, the vocabulary settling them; the rest ('vi' to 'x', and the one-letter 'i' and 'v') are weighed: - an initial in front makes the numeral a name word: `preceding` is the first token of the piece in front, read for layout, and None where the slot never asks (the given part after a family comma, where 'Chang, Andy C I' reads suffix 'I', v1's reading); - a Title-case numeral in a name of mixed case leans to the name, as a Title-case member of the ambiguous class does (`numeral_leans_to_the_name`). In the given part after a family comma (`preceding` None) a one-letter numeral is the generation only where the vocabulary lists it, rules/C9's "one-character suffix word": no count is asked there (S5), and an unlisted letter is the middle initial ('SMITH, JOHN X'). is_initial_shaped, not is_initial: this asks whether the preceding piece looks like part of an initial run, which is a question about layout, and #320 narrowed the tag to initials that can really stand in for a name. Reading the tag here made '씨.' stop suppressing the fork and cost 'John 씨. V' its family name. `one_case` is ParseState.one_case: in a name written in one case the word in front is read as its capital would be, so a bare 'j' is initial-shaped as 'J' is (rules/S10, #721's review: 'john j v' reads as 'JOHN J V'), while 'é', 'ж' or an uncased word, whose capitals are not, stay as they were. None (the maiden take's superset view passes it) asks neither case question, which admits more and so stays a superset.""" stem = text.rstrip(".") if not stem or _ROMAN.match(stem) is None: return False if len(stem) == 1: # rules/T5: "A one-letter word written with its period is an # initial" if len(text) > 1: return False if preceding is None: return "vocab:suffix" in tags elif "vocab:suffix" in tags: return True elif one_case is False and stem.istitle(): return False return (preceding is None or not (is_initial_shaped(preceding) or one_case and is_initial_shaped(preceding.upper()))) # rules/S10: "a Title-case numeral in a name of mixed case leans to the # name" -- the fork weighed it and the writing declined it, so it is # reported as a declined member of the ambiguous class is (#752) def numeral_leans_to_the_name(text: str, tags: Set[str], preceding: str | None, one_case: bool | None) -> bool: """Whether `reads_as_generation` declined a numeral for its Title case alone: two or more letters, unlisted, in a name of mixed case, and no initial in front (which makes it a name word unweighed).""" return (one_case is False and text.rstrip(".").istitle() and len(text.rstrip(".")) >= 2 and "vocab:suffix" not in tags and reads_as_generation(text, tags, preceding, None)) def is_listed_numeral(text: str, tags: Set[str]) -> bool: """A numeral the vocabulary lists in two or more letters ('III'): not in question, so its reading is never reported (rules/S10).""" stem = text.rstrip(".") return (len(stem) >= 2 and "vocab:suffix" in tags and _ROMAN.match(stem) is not None) # rules/S10: "a numeral written in lower case in a name of mixed case is # the generation and reports nothing" -- the one writing that settles # the fork, since nobody writes an initial in lower case beside # capitals. Asked by the walk, which takes such a numeral as any suffix # word and records no call, and by the report on a numeral before a # credential (`reads_as_a_reported_numeral`). def numeral_settled_by_case(text: str, one_case: bool | None) -> bool: """Whether a numeral the trailing fork reads is lower case in a name written in more than one case. `one_case` is ParseState.one_case; None (nobody asked) settles nothing.""" return one_case is False and text.islower() # rules/S10: "either reading is reported" -- the one question the two # reports on a numeral a post-nominal follows ask (assign's, and the # maiden take's for a numeral its clause keeps): the fork would read # this numeral, and its writing does not settle it (#721) def reads_as_a_reported_numeral(text: str, tags: Set[str], preceding: str, one_case: bool | None) -> bool: """Whether a numeral the walk left a name word, with `preceding` in front of it, is one the fork would weigh and so reports: one it reads as the generation, or declines for its Title case.""" return ((reads_as_generation(text, tags, preceding, one_case) or numeral_leans_to_the_name(text, tags, preceding, one_case)) and not numeral_settled_by_case(text, one_case)) # #544: a single-letter roman numeral ('V', 'v', 'I.'), in any case, # is excluded for its SHAPE -- one letter is how a middle initial is # written, and rules/S3 retires single-character matches for the # same reason -- not for being a generation: a multi-letter suffix # word ('III', 'Jr') anchors and runs like any other. So it neither # anchors an ambiguous member behind it nor counts toward C4's # multi-word credential run. It still peels exactly as before; this # answers only those two questions. def is_single_letter_numeral(text: str) -> bool: """One letter, optionally followed by periods, that is a roman numeral ('V', 'v', 'I.', 'X').""" letters = text.rstrip(".") return len(letters) == 1 and _ROMAN.match(letters) is not None # rules/T5: "A one-letter word written with its period is an initial, # in a script that writes initials, and so is a bare unaccented Latin # capital, A to Z" def is_initial(text: str) -> bool: """'A.' / 'j.' / bare capital -- v1's is_an_initial, narrowed to scripts that HAVE initials (#320). v1's \\w is Unicode-aware and matched CJK too, which made period-written CJK honorifics ('씨.') fail is_suffix_strict -- the veto in _is_suffix_strict_n, NOT the vocabulary: suffix_as_written has no veto, so classify tagged '씨.' 'vocab:suffix' either way, and is_suffix_lenient took it either way too. Downstream of that one strict-test No, the glued honorific in a name carrying such a token went unpeeled ('田中さん 様.').""" return is_initial_shaped(text) and not in_initialless_script(text) def is_one_case(texts: Sequence[str]) -> bool: """Whether a name is written wholly in ONE case -- all upper or all lower alike -- and so carries no case EVIDENCE about any letter in it (rules/P8, #383/#479). The caller passes the name's OWN words: a maiden marker's clause and any delimited (nickname) content are not among them, and appending one must not flip the reading of words that did not change. Mirrors the SHAPE of the comparison the R5 gate in `_render.capitalized` makes, not its SPAN: R5 joins every token but the suffixes, nickname and maiden content included, while classify's caller hands in only the name's own words (rules/P3's own-words doctrine, see above) -- so a clause-bearing name can be one-case to this function and mixed to R5 (measured: `'JUAN GARCIA Y LOPEZ née Jones'` is one case here, mixed there), and a name with a cased suffix mixed here and one-case there (`'john e jones III'`). Not shared by import today -- render is a layer this module does not reach into -- and #492 left the two spans apart by decision (decisions/R5, "THE R5 / PARSER SPLIT"). `Sequence`, not `Iterable`: the caller passes a list it already built rather than a fresh generator, so `is_one_case` costs one profiler frame per parse rather than one per token (#475). A CASELESS script answers True, harmlessly: `'محمد و علي'.upper()` is the string itself, so the comparison holds. Its callers are classify, segment (only where C2's flag on a tail part asks) and script_segment's `_one_case`; the first two record the answer as `ParseState.one_case`, and script_segment does not; the decisions that read it each also require the token they judge to be CASED: classify's connective-or-initial fork asks for a token whose own `upper()` and `lower()` differ, `ambiguous_lean` -- which the suffix readers in `_pieces`, `_group` and `_assign` reach through `listed_lean` -- returns no lean for a caseless token, and `caps_shape_candidate` requires `isupper()`. A caseless letter is none of those, so a caseless name never reaches a decision this gates, and "one case" is the honest verdict for text that has only one. """ joined = " ".join(texts) return joined in (joined.upper(), joined.lower()) #: Which way the WRITING leans for a member of the ambiguous #: credential class; None is no lean at all. Named so the two #: predicates that answer it -- `ambiguous_lean` here and #: `_pieces.listed_lean`, which wraps it -- carry the same three-value #: type rather than a bare `str`. Under mypy's `strict_equality` that #: makes a misspelled comparison (`== "credentail"`) an error at the #: two call sites that branch on the answer, where a `str` return #: leaves it silently False forever -- which is what the value is FOR: #: `_pieces.peel_trailing` peels or declines on it. #: `period_joined_vocab`'s own verdict is spelled inline for the same #: reason; it has one caller shape and no wrapper to keep in step. Lean = Literal["credential", "name"] #: How a member's writing leans, by what the member is (rules/S11): #: an acronym's capitals are the credential, a suffix word's lower #: case is the suffix, and an agnomen's writing says nothing. Cue = Literal["acronym", "word", "none"] # #289's credential lean, rules/S6: "Written case is evidence beside # the count (S4), and it speaks only in a name written in more than # one case" -- the WRITING evidence that decides ahead of the # words-to-spare count (history: decisions/S6). def ambiguous_lean(text: str, one_case: bool, cue: Cue = "acronym") -> Lean | None: """Which way the WRITING leans for a member of the ambiguous credential set: "credential", "name", or None for no lean at all. None is the fall-through to rules/S4's count, and it is the answer for three inputs. A name written wholly in one case says nothing about any word in it. A member written wholly in lower case says nothing either -- lower is how most of a mixed-case name is written, so it is not a contrast. And a CASELESS token ('씨', '毛') can be written against nothing, so neither test fires and the count decides, which is what keeps this rule out of a caseless script by construction. `one_case` is the recorded fact (ParseState.one_case), taken over the name's OWN words -- handed in rather than recomputed, because three sites read this and a fact two of them derived apart is the bug the field exists to prevent (mechanisms.md#ONE-PREDICATE-PER-QUESTION). The caller decides MEMBERSHIP: this answers only about the writing. Periods do not disturb either test, '.' having no case, so 'MA.' leans as 'MA' does -- the lean reads CASE, not periods, and the dotted spelling is the vocabulary's own question (suffix_as_written). `cue` is what the member is (rules/S11). A suffix WORD that is also a name ("word", 'fils') leans the other way at one end: its lower case in a mixed-case name is the suffix ("credential", the suffix reading), its capitals say nothing (a surname is often written so), and Title case leans to the name as an acronym's does. An agnomen ("none", 'Neto') is written in the case of the name around it, so its writing never leans. """ if one_case or cue == "none": return None if text.upper() == text.lower(): # caseless: no contrast to read return None if text.isupper(): return "credential" if cue == "acronym" else None if text.islower(): return "credential" if cue == "word" else None return "name" def lexicon_cue(n: str, lexicon: Lexicon) -> Cue: """The `ambiguous_lean` cue of the folded member `n`, read off the lexicon by a caller that has no tags to read (`is_wholly_suffix`); `_pieces.listed_lean` reads the same answer off classify's tags.""" if n in lexicon.suffix_words_ambiguous: return "none" if n in lexicon.agnomens else "word" return "acronym" _DOTTED = re.compile(r"(?:" + _LETTER + r"\.)+") # #544: the CHUNKED spelling -- two or more runs of letters, each # closed by a period ('M.Eng.', 'L.Ac.'). Both callers compare the # period-free letters against the listed member, so the chunks must # spell it; one chunk ('Ma.', 'Ed.') is the single trailing period and # stays outside the gate. _CHUNKED = re.compile(r"(?:(?:" + _LETTER + r")+\.){2,}") def _dotted(text: str) -> bool: """Written with its periods: one after each letter ('M.A.', 'J.D.'), the acronym's own spelling, or -- since #544 -- two or more letter chunks each closed by a period ('M.Eng.', 'L.Ac.'), the spelling a member with a lower-case tail is written in. A single trailing period ('Ma.', 'Ed.', 'Ms.') is the abbreviation shape any word can wear -- the honorific's, a name's -- and is not the gate's "written with periods" (rules/S3). Until #296's review the gate was "any period", and 'Smith, Ms.' passed it as the degree.""" return (_DOTTED.fullmatch(text) is not None or _CHUNKED.fullmatch(text) is not None) # #563: exactly two single letters, each closed by a period -- the way # a given and a middle initial are written run together ('M.J.', # 'G.J.'). The trailing period is optional, as `period_joined_vocab`'s # shape verdict lets it be. _PAIRED_INITIALS = re.compile(_LETTER + r"\." + _LETTER + r"\.?") def is_paired_initials(text: str) -> bool: """Two single letters run together with periods ('M.J.'): the one unlisted dotted shape a person's own initials take, so after a comma it is a name until something in front of it, or another dotted word beside it, says it is a credential (rules/C5). Three or more letters ('X.Y.Z.') and any longer chunk ('B.Tech.') are outside it.""" return _PAIRED_INITIALS.fullmatch(text) is not None # rules/S3: "Paired initials are the exception, as C5 defines them, in a # script that writes capitals" -- the script half of that clause, which # classify asked inline until #752 (decisions/S3 (#864), # decisions/vocabulary-model (#752)). C5's comma reading asks the bare # `is_paired_initials`: after a comma a caseless pair reads as a name # too ('John Smith, พ.บ.' given 'พ.บ.'), and moving it here would change # that reading. def is_cased_paired_initials(text: str) -> bool: """Paired initials (`is_paired_initials`) in a script that writes capitals: a text with a case mapping, so 'M.J.' and 'm.j.' but not Thai 'พ.บ.', which is how that script writes an abbreviation. The same compiled pattern as `is_paired_initials`, asked here rather than through it so a dotted token pays one frame, not two.""" return (_PAIRED_INITIALS.fullmatch(text) is not None and text.upper() != text.lower()) # rules/S12: "a credential written split across a space reads as one # word" -- one chunk of up to five letters, with or without its period # ('Pharm.', 'D', 'Sc.'), the shape both words of a split credential # take. The C-level half classify asks of every adjacent pair before # the fold is looked up. SPLIT_CHUNK = re.compile(r"(?:" + _LETTER + r"){1,5}\.?") # mechanisms.md#ONE-PREDICATE-PER-QUESTION ("exactly one predicate answers it and every other site calls that one") # -- the split credential's one test, asked by classify, which tags # the pair for group's merge and `_comma._pieces`, and by # `is_wholly_suffix` for the Ph./D. answer. decisions/phd-merge, # decisions/vocabulary-model (#752) def split_pair(first: str, second: str, lexicon: Lexicon) -> str | None: """Whether two adjacent words are one credential written split across a space, and so read as one unit -- answered with the PIECE TAG the merged pair carries, or None. "suffix": 'Ph.' then 'D.', with or without their periods, in any case -- v1's fix_phd, merged as an unambiguous suffix wherever it stands but the head of the string (decisions/phd-merge). "split": any other pair of short chunks (`SPLIT_CHUNK`) whose period-free concatenation is unambiguous suffix vocabulary while the two are not both suffix vocabulary on their own ('Pharm. D.', 'Pharm D', 'MB ChB' where 'chb' is unlisted) and neither is a particle or a connective, which join name words. The merged piece is a member of the ambiguous class, which the count decides (rules/S4, rules/C4). Its first chunk has two or more letters: a single letter opening the pair is a middle initial far more often (FEC writes 'SMITH, JOHN G. MR.', and 'G. MR.' folds to the listed 'gmr'), so 'M. Sc.' and 'M. D.' stay two words. Two dotted single letters are the one opening letter asked further, and asked of #872's predicate rather than a copy: in a script with capitals they are paired initials (`is_cased_paired_initials`), the reading rules/S3 gives them joined, so only a caseless script's pair can merge, and not one in a script that writes no abbreviations (Han, hangul, kana: '博. 士.' is no dotted '博士'), which leaves no default entry such a fold. Where the merge may happen (never at the head of the string, rules/S2; a member pair only in a main segment and in the part after a comma) is the caller's question, not this one's.""" if _PH.fullmatch(first) is not None and _D.fullmatch(second) is not None: return "suffix" if (SPLIT_CHUNK.fullmatch(first) is None or SPLIT_CHUNK.fullmatch(second) is None): return None a, b = first.rstrip("."), second.rstrip(".") if composed_len(a) < 2 and ( composed_len(b) != 1 or not first.endswith(".") or not second.endswith(".") or is_cased_paired_initials(first + second) # a script that writes no abbreviations is not wearing an # abbreviation's periods, as rules/S3 reads it joined or in_initialless_script(first + second)): return None fold = _normalize(a + b) if (fold in lexicon.suffix_acronyms_ambiguous or not (fold in lexicon.suffix_acronyms or fold in lexicon.suffix_words)): return None na, nb = _normalize(a), _normalize(b) if ((na in lexicon.suffix_acronyms or na in lexicon.suffix_words) and (nb in lexicon.suffix_acronyms or nb in lexicon.suffix_words)): return None # a particle or a connective joins name words, which is what it is # doing beside one ('DE A JUNIOR', the A a surname written short); # written with its period a letter is an initial, not the connective # it spells ('SAM E.', rules/T5) for written, n in ((first, na), (second, nb)): if not written.endswith(".") and (n in lexicon.particles or n in lexicon.conjunctions): return None return "split" def suffix_as_written(n: str, text: str, lexicon: Lexicon) -> bool: """Counts as a suffix as written, with NO initial veto (the veto differs by caller): unambiguous suffix vocabulary, or an ambiguous acronym written with periods ('M.A.' yes, 'Ma' no). `n` is _normalize(text), passed in so callers normalize once. Single source for classify's "vocab:suffix" tag and the segment/ assign predicates. The ambiguous subset is EXCLUDED from the plain membership test: in the real data suffix_acronyms_ambiguous is a subset of suffix_acronyms, and without the exclusion the period gate is dead code (bare 'Ed'/'Jd' would silently become suffixes). """ # acronyms may be written with periods ('M.B.A.'): the ACRONYM # membership alone uses the period-free form (v1's is_suffix # removed periods only for the suffix_acronyms test); suffix WORDS # match on the plain normalized form a = n.replace(".", "") if a in lexicon.suffix_acronyms_ambiguous and _dotted(text): return True if ((a in lexicon.suffix_acronyms and a not in lexicon.suffix_acronyms_ambiguous) or (n in lexicon.suffix_words and n not in lexicon.suffix_words_ambiguous)): return True # the joiners asked in C first, so a word holding neither pays no # frame return ("-" in n or "/" in n) and _joined_credential(text, lexicon) #: What joins the chunks of a joined credential ('MD/PhD', 'MD-PhD'). _JOINERS = re.compile(r"[-/]") # rules/S3: "a word whose chunks are joined by a hyphen or a slash # reads as a suffix when every chunk counts as a suffix as written" -- # the stricter test, every chunk rather than any, because a hyphen also # joins names (decisions/S3 (#752)) def _joined_credential(text: str, lexicon: Lexicon) -> bool: """Whether TEXT is two or more chunks joined by '-' or '/', each non-empty and each counting as a suffix as written: listed, and not a bare member of the ambiguous class, and not every one a single ASCII character, and -- joined by a hyphen -- not written as a hyphenated name is ('MD/PhD', 'M.A.-Ph.D.', 'PhD-Md', 'md-phd', but not 'MA/PhD', 'Smith-Jones', 'I-V', 'Ae-Ra', 'Ae-ra' or 'MD/'). A chunk holds no joiner, so the recursion is one level deep. A loop rather than `all()` over a generator, which would cost a frame per chunk: a hyphenated name ('Smith-Jones') stops at its first chunk.""" # the caller found a joiner, so there are two chunks at least chunks = _JOINERS.split(text) whole = False # a chunk that is not a single ASCII character # every chunk so far written as a name's; a slash joins no name, so # a word holding one takes no cue named = "/" not in text for k, c in enumerate(chunks): if not (c and suffix_as_written(_normalize(c), c, lexicon)): return False # its periods are not the chunk's ('I.' is 'i'), as in a word # split at its periods bare = c.replace(".", "") whole = whole or len(bare) > 1 or not bare.isascii() # the first chunk in Title case, a later one in Title case or # lower case ('Ae-Ra', 'Ae-ra') named = named and ( len(bare) > 1 and bare[0].isupper() and bare[1:].islower() or k > 0 and bare.islower()) # rules/S3: "Every chunk the vocabulary matches is a single ASCII # character" -- the numerals and the digit, which are about # generations, make no joined word a suffix either ('Jack I-V'); # rules/S3: "a word joined by a hyphen and written the way a # hyphenated name is, its first chunk in Title case and every other # in Title case or lower case, is a name word" -- where a # credential's chunks are not (decisions/S3 (#752)) return whole and not named def _is_suffix_strict_n(n: str, text: str, lexicon: Lexicon, one_case: bool | None = None) -> bool: # rules/T4: "in a name written wholly in one case, which says # nothing about any letter in it, a bare letter is an initial # wherever its capital would be" -- so the veto takes 'v' where it # takes 'V' (#727). Lower case first, at C level: a capital is # is_initial's already, and only a caller with the fact passes it if is_initial(text) or ( one_case and len(text) == 1 and text.islower() and _INITIAL.fullmatch(text.upper()) is not None): # period-written ambiguous acronyms are exempt from the veto return _dotted(text) and \ n.replace(".", "") in lexicon.suffix_acronyms_ambiguous return suffix_as_written(n, text, lexicon) def is_suffix_strict(text: str, lexicon: Lexicon, one_case: bool | None = None) -> bool: """v1's is_suffix: suffix_as_written with the initial veto ('V.' in 'John V. Smith' is a middle initial, not roman five). `one_case` is whether the name's own words are written in one case (ParseState.one_case): where they are, a bare lowercase letter is the initial its capital is (rules/T4) and is vetoed as its capital is. None or False is the veto by the writing alone, which is v1's.""" return _is_suffix_strict_n(_normalize(text), text, lexicon, one_case) def is_suffix_lenient(text: str, lexicon: Lexicon) -> bool: """v1's is_suffix_lenient: suffix_words accepted unconditionally, bypassing the initial veto -- only safe in unambiguous positions (after a comma).""" n = _normalize(text) return (n in lexicon.suffix_words and n not in lexicon.suffix_words_ambiguous) \ or _is_suffix_strict_n(n, text, lexicon) #: The suffix delimiters every policy has, beside the comma. rules/C10: #: "The semicolon, the hyphen and the slash are delimiters by default" #: -- "no policy removes them, as none removes the comma", so a #: policy's extra_suffix_delimiters add to them. decisions/C10 (D15, #: #889). BUILTIN_SUFFIX_DELIMITERS = frozenset({";", "-", "/"}) #: The built-in delimiters read glued to a word or inside one. rules/C10: #: "a built-in hyphen or slash parts only standing alone" -- glued, it #: is the word's own ('al-', 'MD/PhD'). decisions/C10 (#889). BUILTIN_GLUED_DELIMITERS = frozenset({";"}) def delimiter_cores(policy_delimiters: frozenset[str]) -> frozenset[str]: """The built-in suffix delimiters with the configured ones, surrounding whitespace stripped: ' - ' -> '-', the cores that part a trailing suffix part STANDING ALONE. Whitespace-padded delimiters surface as standalone tokens; the stripped core is what tokenize produced.""" if not policy_delimiters: return BUILTIN_SUFFIX_DELIMITERS return BUILTIN_SUFFIX_DELIMITERS | frozenset( d.strip() for d in policy_delimiters if d.strip()) def glued_cores(policy_delimiters: frozenset[str]) -> frozenset[str]: """The cores read glued to one end of a word or inside one: the semicolon and every configured core, never a built-in hyphen or slash that the caller did not declare (BUILTIN_GLUED_DELIMITERS). A configured core that is itself a word (' and ') is in the set: `glued_stem`'s callers ask `glues` before reading it glued, while `is_wholly_suffix`'s in-word split does not ('MDandPhD').""" if not policy_delimiters: return BUILTIN_GLUED_DELIMITERS return BUILTIN_GLUED_DELIMITERS | frozenset( d.strip() for d in policy_delimiters if d.strip()) # rules/C10: "the word keeps the delimiter in its text and reads as it # would without it" -- the word, for every site that reads one: the # tags classify writes, the comma decision's shape tests, C2's flag # and the run test below (decisions/C10). def glued_stem(text: str, cores: frozenset[str]) -> str | None: """The word a delimiter core glued to ONE end of `text` leaves ('MD;' -> 'MD', ';PhD' -> 'PhD', 'MD-PhD;' -> 'MD-PhD' under '-'), or None: no core at an end of the text, a core standing alone, one inside a word only ('MD;PhD', which splits_into_suffixes reads), or a core that is itself a word (see glues). Every core is tried, in sorted order, so the answer never depends on a set's iteration order (the second review of #871: 'MD/PhD; MBA' under '/' read two ways by hash seed). Callers test `core in text` inline first, so a word with no core pays no frame.""" for core in sorted(cores): if (core in text and glues(core) and text.count(core) == 1 and len(text) > len(core)): if text.endswith(core): return text[:-len(core)] if text.startswith(core): return text[len(core):] return None def glues(core: str) -> bool: """Whether a delimiter core can be GLUED to a word: one written without a letter or digit (';', '/', '-'). A core that is a word (' and ' under a declared policy) is found inside other words by the substring test ('Stand'), so it parts only standing alone (rules/C10's glued bullet).""" return not any(ch.isalnum() for ch in core) def splits_into_suffixes(text: str, cores: frozenset[str], lexicon: Lexicon) -> bool: """v1 expand_suffix_delimiter parity for delimiters WITHOUT whitespace ('RN/CRNA' with '/'): the token counts as a suffix when some core splits it into >=2 non-empty parts that are all suffixes. A core glued to ONE word ('MD;') is is_wholly_suffix's to read, by the test it gives that word alone (D15). The token text is never rewritten (anti-#100): it takes Role.SUFFIX whole, which renders 'RN/CRNA' where v1 rendered 'RN, CRNA' -- the documented divergence, release-log classified.""" for core in cores: if core in text: parts = [part for part in text.split(core) if part] if len(parts) >= 2 and all( is_suffix_lenient(part, lexicon) for part in parts): return True return False # rules/S3: "a word with interior periods reads as a suffix when # any of its period-separated chunks is suffix vocabulary" -- # "every chunk the vocabulary matches is a single ASCII character, # the roman numerals and the lone digit the vocabulary lists, which # are about generations rather than credentials". The chunk rule and # its single-character exception (decisions/S3). def period_joined_vocab( text: str, lexicon: Lexicon, ) -> Literal["title", "suffix", "shape"] | None: """v1's parse_pieces derivation for interior-period tokens ('Lt.Gov.', 'Msc.Ed.', and by the ANY rule 'Mr.Smith'): ANY title chunk makes the token a title (checked first, v1's continue); else ANY suffix chunk makes it a suffix. Chunk-level suffix membership is v1's is_suffix: bare ambiguous acronyms COUNT ('Msc.Ed.' derives via 'ed') -- the ambiguous period-gate applies to whole tokens only -- while a suffix word that is also a name does not ('M.Filho', rules/S11). Returns "title", "suffix", "shape", or None. "shape" is the third verdict and it is the absence of a claim worth acting on (#516): two or more chunks, EVERY one of them alphabetic, and nothing the vocabulary matches except -- possibly -- chunks that are a SINGLE ASCII CHARACTER. That exception is the roman-numeral accident retired narrowly: measured 2026-09-15 the one-character suffix vocabulary is {'2', 'i', 'v'} plus the glued CJK honorific tails, so 'John Smith R.A.I.' was reading as a generational suffix off the chunk 'i'. CHARACTER rather than LETTER because '2' is a digit and is in the set; ASCII because '씨' is the single character that must KEEP its claim ('J.씨'). A multi-character match still reads as it always did, so 'Msc.Ed.', 'JD.CPA' and 'Lt.Gov.' are untouched -- the WIDE retirement, where any chunk match yields to the shape, was measured and rejected: it costs 'Doe, John Msc.Ed.' a real credential and re-routes the honorific peel of '김민준씨, J.씨' for nothing this design wants (decisions/S3). The ALPHABETIC requirement is a SEPARATE, narrower gate on the shape verdict alone (#516 review round, decided by the orchestrator): a bare digit chunk is not an acronym letter by any reading, so 'Smith, 1.4' and 'John Smith 1.4' do not join the class by shape -- the only PROTECTED digit control this design had was the delimited 'Bridge (1.4)', and 'Smith, 1.4' and its no-comma twin were the gap that control did not cover, since delimited content is excluded by a different mechanism (extract's escape) and digits reach THIS detector, never that one. '.isalpha()' is asked of EVERY chunk, not just the matched ones, so a mixed token like 'X.Y.2.' is not shape-admitted either -- one non-letter chunk is enough to say the writing is not spelling an acronym. Chunks the vocabulary itself matches are already alphabetic in the shipped lexicon, so this narrows only the previously-unclaimed "shape" answer, never the "suffix" one. The INITIALLESS-SCRIPT guard is a second, independent narrowing of the same verdict, on the same reasoning `is_title_shaped` already gives its own period-abbreviation inference (#323): a script with no period abbreviations at all has nothing for interior periods to ABBREVIATE, so a CJK word glued into period-separated single characters is not spelling an acronym either -- 'John Smith 田.中.' and '김 민준 이.박.' stayed family at every release before this gate existed, and would otherwise have joined the ambiguous class by shape for the first time (measured regression, #516 review round). What a "shape" verdict MEANS is the caller's question, not this one's: classify writes the tag, and Policy.unlisted_dotted_suffixes decides whether it is class membership. """ if not _PERIOD_NOT_AT_END.match(text): return None chunks = [_normalize(c) for c in text.split(".") if c] if any(c in lexicon.titles for c in chunks): return "title" # rules/S11: "the word is part of that one word and is weighed only # by the shape the joined word wears" -- no chunk claim for a # suffix word that is also a name ('M.Filho') matched = [c for c in chunks if c in lexicon.suffix_acronyms or (c in lexicon.suffix_words and c not in lexicon.suffix_words_ambiguous)] if matched and not all(len(c) == 1 and c.isascii() for c in matched): return "suffix" if (len(chunks) >= 2 and all(c.isalpha() for c in chunks) and (text.isascii() or not in_initialless_script(text))): return "shape" return None def ambiguous_class_member(text: str, lexicon: Lexicon) -> bool: """Whether TEXT is a member of the LISTED ambiguous credential class, ignoring case and policy entirely (#289/#516). The case-INDEPENDENT half of the comma form's own candidate test (`ambiguous_class_candidate`, below, which ALSO admits a by-shape member where Policy allows it) and of `is_wholly_suffix`'s credential-lean disjunct: membership by vocabulary never needs the case fact, only the lean does. That split is what lets the comma form's lazy gate ask membership FIRST and pay for `one_case` only where this says yes (measured regression, #289/#516: forcing the fact first ran `own_words` -> `tag_marker_runs` for every single-token-after-the-comma name, none of them able to reach the class at all). Membership is the listed set, bare: a whole-token vocabulary match is not in this class at all, being settled ('M.A.', 'Ph.D.', 'A.B.C.'). Cheaper than `suffix_as_written(n, text, lexicon) or ...` would be here, and provably the same answer for UNDOTTED text: the Lexicon invariant that an ambiguous acronym is never also a suffix WORD (`__post_init__`'s gate_bypassed check) and is never counted without periods once it IS one (`suffix_as_written`'s own exclusion) together kill that predicate's two disjuncts once text is known to hold no period. One frame (`_normalize`) on the common path that never reaches the class, where the general predicate cost at least two (mechanisms.md#ONE-PREDICATE-PER-QUESTION's cost clause). The '.' gate here is DELIBERATELY STRICTER than S2's own dotted-form test (`_dotted`, a period after EACH letter or after each of two or more letter chunks): '.' anywhere excludes membership, so a single TRAILING period ('MA.', 'Ed.') is excluded here even though the LEAN still reads it as the bare acronym's case ('Smith, MA.' -> suffix 'MA.', measured). Not a contradiction: the two questions this answers -- the comma-form CANDIDATE test below and the credential-lean disjunct -- are never asked of those shapes, the TAG path (`vocab:suffix-ambiguous`, read directly by the trailing peel and the post-comma slot) and H2's leading-title shape test already carrying them where they need to go. """ if "." in text: return False n = _normalize(text) return (n in lexicon.suffix_acronyms_ambiguous or n in lexicon.suffix_words_ambiguous) # #516's all-caps half, ONE PREDICATE for the shape test and its # WHOLE-VOCABULARY exclusion, shared by the three sites that each # needed the identical question answered (classify's tag emission, # the comma decision's caps test -- `_segment.py`'s run test until #613 # moved the decision to `_comma.decide` -- and this module's own unit # tests), where it had been spelled three times over (quality-review # finding). The usual objection to sharing -- a call costing every # default-policy parse a frame it cannot use -- does not apply: the # trailing position is behind `CapsSuffixes.EVERYWHERE` (classify's # first conjunct), and the comma decision, which IS on by default, # asks C-level `isalpha()`/`isupper()` and the suffix tag first, so a # comma name with no unlisted all-caps word never reaches it. def caps_shape_candidate(text: str, lexicon: Lexicon, policy: Policy, one_case: bool | None) -> bool: """Whether TEXT is an UNLISTED all-caps credential candidate: two or more alphabetic characters, or letter chunks joined by '-' or '/' (`_joined_capitals`, #752), no period (a period fails `isalpha()` and the joined pattern outright, which is what excludes the dotted spelling here for free -- no separate '.' test needed), written in a name `one_case` says is mixed (`one_case is False`; `None`, "not established", declines exactly as if the name were one case). UNLISTED means in NO wordlist at all, not merely "no whole-token suffix vocabulary", and the roster is `_lexicon._VOCAB_FIELDS` itself rather than a list written out here -- checked directly against the lexicon rather than through a caller's tags (classify is deciding the very tag a caller would read, and `_comma.decide` asks of words classify left untagged). A hand-written roster is a second place to remember, and it had already gone wrong: it named eleven of the thirteen fields, leaving `surnames` and `honorific_tails` out, so `Lexicon.default().add(surnames={"dupont"})` still read `Jean Pierre DUPONT` as suffix `DUPONT` -- a caller listing a word as a SURNAME and getting it read as a credential is this switch's own worst failure, arriving through the one wordlist that says "this is a family name" (review round, #289/#516; `honorific_tails` was already excluded transitively, being a subset of `suffix_words` by Lexicon invariant, and joins the roster for completeness rather than for a behavior change). Measured, #516 review rounds: particles, ambiguous particles, conjunctions, bound-given heads, a maiden marker ('NEE'/'GEB') and a title all join the shape by capitalization alone if their field is left unchecked. `suffix_acronyms_ambiguous` is in the roster, which also makes this predicate stand in for `ambiguous_class_member` wherever a caller needs "and not already a LISTED member" (undotted text's only path into that function is the identical membership test); its callers still test the listed set first, in C (classify's `n not in suffix_acronyms_ambiguous`, `_comma.decide`'s AMBIGUOUS_ACRONYM_TAG test), so a listed member never pays for the call. """ if (policy.unlisted_caps_suffixes is CapsSuffixes.OFF or one_case is not False): return False if not text.isascii(): text = unicodedata.normalize("NFC", text) if not (len(text) >= 2 and text.isalpha() and text.isupper() or _joined_capitals(text)): return False return not in_any_wordlist(_normalize(text), lexicon) #: A word of letter chunks joined by '-' or '/' ('PU-PH', 'MD/MBA'). _JOINED_LETTERS = re.compile( r'(?:' + _LETTER + r')+(?:[-/](?:' + _LETTER + r')+)+') # rules/S9: "an unlisted word of capitals whose letter chunks are joined # by a hyphen or a slash, one of them of two or more letters, is that # shape too" -- the caps shape's joined spelling (decisions/S9 (#752)) def _joined_capitals(text: str) -> bool: """Whether TEXT is capitals in letter chunks joined by '-' or '/', one chunk of two or more letters ('PU-PH', 'MS-OMPT', not 'A-B', 'MD/' or 'PU-PH2'). Its callers test `isupper()` and a joiner in C first, so a word holding neither pays no frame.""" if not (text.isupper() and _JOINED_LETTERS.fullmatch(text)): return False for chunk in _JOINERS.split(text): if composed_len(chunk) >= 2: return True return False def is_caps_word(text: str) -> bool: """Whether TEXT is alphabetic capitals once NFC-composed: `isalpha()` is False for a combining mark, so the decomposed spelling of 'ŽŠČ' would otherwise not be a caps word (#652). Callers test the text as written inline first, in C, and call this only for text that is not NFC (`unicodedata.is_normalized`, also C), so composed text pays no frame.""" composed = unicodedata.normalize("NFC", text) return composed.isalpha() and composed.isupper() def composed_len(text: str) -> int: """The length of TEXT in letters as the writer sees them: code points once NFC-composed. A caps word admitted decomposed (#652) is longer than its composed twin, and the two-letter pair test (`John Smith, MÁ`) counts letters.""" return len(text if text.isascii() else unicodedata.normalize("NFC", text)) def in_any_wordlist(n: str, lexicon: Lexicon) -> bool: """Whether the folded word `n` is in ANY of the lexicon's wordlists (`_lexicon._VOCAB_FIELDS`, the whole roster but two): the caps shape's "unlisted" (`caps_shape_candidate`), where a word a caller listed even as a SURNAME must never become a credential. The three left out: `title_phrases`, whose entries are two words and so never one; `titles_ambiguous`, a title that may be a given name, which outside a title phrase is read as the unlisted word it is there (rules/H9: "and elsewhere a name word") -- a phrase is two words and a comma part read by this shape is one; and `given_name_abbreviations`, a given name written short (#789), whose one reader is H2's shape, so anywhere else its word reads as the unlisted word it is ('John Smith, JAS' keeps suffix 'JAS').""" for field in _LISTED_FIELDS: if n in getattr(lexicon, field): return True return False _LISTED_FIELDS = tuple(f for f in _VOCAB_FIELDS if f not in ("title_phrases", "titles_ambiguous", "given_name_abbreviations")) #: The wordlists that claim a word as something OTHER than name text. #: `surnames` and `bound_given_names` are left out: they say a word IS #: a name word ('Kim', 'Abdul'), the opposite claim; and so is #: `titles_ambiguous`, a title that may be a given name (rules/H9), with #: `title_phrases`, whose two-word entries never match one word; and so #: is `given_name_abbreviations`, a given name written short (#789), left #: out of `_LISTED_FIELDS` above. _NON_NAME_FIELDS = tuple(f for f in _LISTED_FIELDS if f not in ("surnames", "bound_given_names")) def written_as_a_name(text: str) -> bool: """Whether TEXT is written the way a name is written in mixed case: it holds a capital (or titlecase letter) and its last LETTER is lowercase -- #564's test for the name's case contrast (Derek). 'Smith', 'DiCaprio', 'IJzerman', 'al-Rashid', "d'Estaing", 'McDonald', 'Džokić' pass. A surname written in capitals fails whatever is glued in front of it ("d'ESTAING", 'al-ASSAD', 'McDONALD', 'FitzGERALD', 'DeVITO', 'St-PIERRE'), and so do a lone capital and a lowercase-only word. The letter read is the last one that carries case evidence, not the last character. The word is composed first (NFC), so a name typed with decomposed accents reads as its composed spelling ('André' ends in 'é', not in the combining accent). Passed over, as no evidence of case: every non-letter ("Jones'", 'Smith2', 'Smith)'), every caseless letter ("Jonesʼ" with U+02BC, 'Wafāʾ', 'Smith李'), and a lowercase letter whose capital is not a single character ('ß' -> 'SS', 'ĸ' with none): 'WEIß' and 'KAĸ' are written in capitals, 'Weiß' is not. One call and no generator, so a word costs one frame.""" word = unicodedata.normalize("NFC", text) i = len(word) while i: i -= 1 ch = word[i] if not (ch.isupper() or ch.islower() or ch.istitle()): continue # a non-letter or a caseless letter upper = ch.upper() if ch.islower() and (upper == ch or len(upper) != 1): continue # the capital is asked here, of the word that has a letter to # read; one with none falls through below ('李', '2', 'ß') return ch.islower() and text != text.lower() return False def claimed_as_non_name(n: str, lexicon: Lexicon) -> bool: """Whether a wordlist claims the folded word `n` as a title, particle, connective, credential, generation, maiden marker or honorific -- not name text. #564's `_comma._name_contrast` asks it: such a word does not supply the name's case contrast. A different question from `in_any_wordlist`'s, which a surname list must also answer yes to.""" for field in _NON_NAME_FIELDS: if n in getattr(lexicon, field): return True return False # The candidate test for a comma part read before classify # (rules/C4, decisions/S9): segment's C2 flag on a tail part. def ambiguous_class_candidate(text: str, lexicon: Lexicon, policy: Policy) -> bool: """Whether TEXT is a CANDIDATE for the ambiguous credential class as segment's C2 flag asks it of a part past the second comma, before classify has tagged anything (`_segment.py`'s `class_run`; the comma's own decision, `_comma.decide`, reads classify's tags instead, #613): the LISTED half (`ambiguous_class_member`, case-free) OR, where Policy admits it, the SHAPE an unlisted dotted token wears (`period_joined_vocab`'s third verdict, #516). Case-free throughout, and the CAPS half of the class is deliberately not here: its real site is `_comma.decide`, which calls `caps_shape_candidate` directly because the caps shape needs the name's contrast. A second route through here existed briefly, behind an optional `one_case` parameter no production caller ever passed -- segment had nothing to hand in at the single-token test it then made -- so the branch answered False for every name the library parsed and only the unit tests reached it (review-round finding, #289/#516). A dead second route is a place for the two to disagree, not a convenience. A period anywhere is the gate for even ASKING the shape question, checked before the shape's own two calls: `period_joined_vocab` already declines a period-free text, but a Python-level call is not free and a comma name's post-comma part usually has no period ("Smith, John") -- measured regression, #516 review round, fixed by hoisting the same cheap substring test `ambiguous_class_member` already makes for its own reason. Where a period IS present, `suffix_as_written` runs only after `period_joined_vocab` says "shape": a dotted whole-token match with no single-chunk vocabulary hit of its own ('A.B.C.', via 'abc') would otherwise read "shape" from this function's chunk-level view alone, oblivious to the WHOLE-token match `suffix_as_written` already settled -- the same precedence classify's own tag order gives it (`vocab:suffix` is set before `period_joined_vocab` is even consulted). A LISTED member spelled with its periods is excluded from the shape branch by the same test classify's own shape branch makes: a caller who puts a dotted entry in `suffix_acronyms_ambiguous` has said the word is a listed member of this class, and reading it by shape instead loses the case lean the listing asks for (review-round finding, #289/#516 -- `Jack A.B.` with 'a.b' listed). It cannot change the answer for the shipped vocabulary, whose ambiguous entries carry no period at all. This function and classify's tag emission (`_tags_for`'s `derived == "shape"` branch) still ask the SAME question twice, of necessity: segment's C2 flag runs before `classify` and has no tags to read yet, so the two stages cannot share the call. Kept from drifting by `test_classify.test_ambiguous_class_candidate_agrees_with_the_tag`, which asks both of the same texts, rather than by a sentence alone. """ if ambiguous_class_member(text, lexicon): return True if "." in text: # `_normalize` stays behind the shape verdict, where it always # was: it is a call, and a dotted post-comma token that is not # acronym-shaped at all ('Jr.') must not pay for it. if not (policy.unlisted_dotted_suffixes and period_joined_vocab(text, lexicon) == "shape"): return False n = _normalize(text) return (n not in lexicon.suffix_acronyms_ambiguous and not suffix_as_written(n, text, lexicon)) return False # mechanisms.md#UNIT-PARTITION: "A rule that counts name words counts # those units, and takes each whole or not at all" -- C3's count before # a comma being the stated exception (`SURNAME_UNIT_TAGS`). The walk is shared # by readers that hold the facts in different forms: post_rules and # `_comma._whole_name` read classify's TAGS, while `name_word_count` # builds the same tag names from the vocabulary # (`surname_unit_tags`). One walk over tag sets, so the two cannot # disagree about where a unit ends (mechanisms.md#ONE-PREDICATE-PER- # QUESTION) -- only, at worst, about a token's facts, which # test_classify's agreement test pins. def unit_ends(tags: Sequence[Set[str]], chain: bool = True) -> list[int]: """The END (one past the last index) of each unit of `tags`, in order, partitioning `range(len(tags))`: one name word each, except where another rule has already made several words one name -- a particle and the words it chains (P2), a conjunction-joined run (P3), and a bound given-name word with the word it completes (P5). Each element is the tag set of one token, read for "particle", "conjunction", "vocab:bound-given" and "vocab:suffix". `chain` picks how far a particle reaches. True is P2's chain: the particle run and every word after it up to the next stop ("van der Berg Smith" is one unit). False is P1's fold reach: the particle run and the ONE unit it attaches to, so "de Mesnil Jean" is two -- the count rules/C3 needs before a comma, where under a family-first order that part is family 'de Mesnil', given 'Jean' (#575). RECURSIVE, and that is the whole point: what a conjunction or a bound given-name word joins is the next UNIT, not the next word. Absorbing a single index instead strands a particle at the end of the unit, severed from the words it chains -- "de la Vega y la Vega" cut between `la` and `Vega`, reporting family "de la Vega y la", which is the same defect as a bare particle opening the given name, mirrored.""" n = len(tags) def end_of(i: int) -> int: if "particle" in tags[i] and not chain: j = i + 1 while j < n and "particle" in tags[j]: j += 1 end = (end_of(j) if j < n and "conjunction" not in tags[j] and "vocab:suffix" not in tags[j] else j) elif "particle" in tags[i]: j = i while j + 1 < n and "particle" in tags[j + 1]: j += 1 # ... then the words it joins, stopping where the next # particle starts a group of its own, at a suffix word (the # stop _group's chain uses), or at a conjunction, which the # shared loop below joins to the whole unit after it rather # than to the one word after it. while (j + 1 < n and "particle" not in tags[j + 1] and "conjunction" not in tags[j + 1] and "vocab:suffix" not in tags[j + 1]): j += 1 end = j + 1 else: end = i + 1 if "vocab:bound-given" in tags[i] and end < n: end = end_of(end) while end + 1 < n and "conjunction" in tags[end]: end = end_of(end + 1) return end ends: list[int] = [] i = 0 while i < n: i = end_of(i) ends.append(i) return ends #: The facts rules/C3's count before a comma reads (#575): a #: particle starts a surname unit, and a suffix word stops it. A word #: that is ALSO title vocabulary ('Freiherr', 'St') LEADING the part is #: not a particle here: in front of a name it reads as the title it #: also is (rules/H1), so 'Freiherr von Berg, PhD' keeps its title, #: while inside a surname it chains as P1 and P2 chain it ('de St #: Pierre' is one surname). A bound given-name head that is also a #: particle ('Abu') stays a particle: 'Abu Bakar, Ed' is a surname #: before the comma, as 2.0 through 2.3 read it. Neither of the other #: two joins `unit_ends` knows is read. A bound #: given-name pair builds a GIVEN name, and P5 gives up a family word #: where the name has no other ('abdul Salam' alone is given 'abdul', #: family 'Salam'), so before a comma it is not one surname. Whether #: P3 joins a single-letter connective depends on the words of the #: whole name -- 'Carod i Rovira, Josep' joins as four words where #: 'Ortega y Gasset' alone, three words, does not -- and the count is #: part of deciding what the whole name is, so a count taken before #: group could reach P3's answer only by copying P3. script_segment #: and `name_word_count` build these facts from the vocabulary #: (`surname_unit_tags`); `_comma._whole_name` derives them from #: classify's tags (`surname_unit_facts`), so the counts read one set #: of facts. SURNAME_UNIT_TAGS = frozenset({"particle", "vocab:suffix"}) _PARTICLE_ONLY = frozenset({"particle"}) _SUFFIX_ONLY = frozenset({"vocab:suffix"}) _NO_TAGS: frozenset[str] = frozenset() def surname_unit_facts(tags: Set[str], leading: bool) -> frozenset[str]: """`SURNAME_UNIT_TAGS` for one token, from classify's tags: the reading `_comma._whole_name`'s count before a comma takes. `leading` is whether the token opens the part.""" particle = ("particle" in tags and not (leading and "vocab:title" in tags)) if "vocab:suffix" in tags: return SURNAME_UNIT_TAGS if particle else _SUFFIX_ONLY return _PARTICLE_ONLY if particle else _NO_TAGS def surname_unit_tags(text: str, lexicon: Lexicon, leading: bool) -> frozenset[str]: """`surname_unit_facts` for one token from the vocabulary alone -- the vocabulary's view of classify's tags, for a count taken before classify runs (script_segment's) or without its tags (`name_word_count`), with classify's own tests: particle and title membership, `suffix_as_written`, and the period-joined derivation. Kept from drifting by test_classify.test_surname_unit_tags_agree_with_classify.""" n = _normalize(text) # classify's whole-token test, then its period-joined derivation # for a token no whole-token title or suffix claimed ('JD.CPA'), # asked only of a token with a period, which it needs suffix = suffix_as_written(n, text, lexicon) or ( "." in text and n not in lexicon.titles and period_joined_vocab(text, lexicon) == "suffix") particle = n in lexicon.particles and not ( (leading and n in lexicon.titles) or _spells_an_initial(n, text)) if suffix: return SURNAME_UNIT_TAGS if particle else _SUFFIX_ONLY return _PARTICLE_ONLY if particle else _NO_TAGS #: The connective after a title that may be a given name which makes #: the two a title phrase, 'Dean of Students' (rules/H9). Coordinating #: connectives are not: 'Dean and Mary Smith' is two people. _PHRASE_LINKS = frozenset({"of"}) #: The connectives whose object a modifier after them is in, 'of the #: United States', 'The Grand Master'; 'and' joins two titles instead. _OBJECT_LINKS = frozenset({"of", "the"}) # rules/H9: "a word no listed phrase holds is a title only behind a # style or a modifier, or before `of`" -- whether the word heads a title # of itself, asked of the phrases `attaches` compiled rather than of a # property of its own def in_a_title_phrase(text: str, lexicon: Lexicon) -> bool: """Whether a phrase of `lexicon.title_phrases` that classify can read holds `text`.""" return _normalize(text) in _phrase_words( lexicon.title_phrases, lexicon.titles, lexicon.titles_ambiguous) @functools.lru_cache(maxsize=16) def _phrase_words(phrases: frozenset[str], titles: frozenset[str], contested: frozenset[str]) -> frozenset[str]: """The words of the phrases classify can read: two words, each a title or a title that may be a given name, as its pair test asks. A phrase it never consults makes no word report.""" listed = titles | contested pairs = (p.split(" ") for p in phrases) return frozenset(w for ws in pairs if len(ws) == 2 and all(w in listed for w in ws) for w in ws) # rules/H9: "the word is a title only inside a title phrase, and # elsewhere a name word" -- both halves, the partners of a title that may # be a given name and of a modifier at the head of the name, each its # bullet's (decisions/H9 #752) def title_readings(texts: Sequence[str], folded: Sequence[str], lexicon: Lexicon, *, head: bool, titled: Sequence[bool] | None = None) -> list[bool]: """Which of these words, one part of the name in order, read as title words. Where none of them is a title that may be a given name or a modifier, the listed titles answer alone, which every caller asks first, in C, so that a name with neither pays no frame. A word of `titles_ambiguous` that is not also in `titles` (where a caller listing it makes it a title everywhere) is a title only beside a partner: a modifier, a style, or a title it stands behind in `title_phrases`, before it; or 'of', or a title it stands in front of in `title_phrases`, after it. A salutation is never one, nor any other title before it ('Mr Dean Smith'), nor a title after it that it does not attach to ('SMITH, DEAN MR.', a given name and the salutation a donor list writes after it). Its neighbours are asked as LISTED, so 'Vice Dean' reads 'Vice' as the partner of 'Dean' whatever the modifier test below makes of 'Vice', save a word before it that is itself a title that may be a given name, which is a partner only where its own phrase made it a title ('Grace Dean Smith' reads 'Grace' as the given name, and 'Dean' with it). Where `head` is true -- the part before the first comma -- a word of `title_modifiers` is a title only with a head title behind it in its run of titles: any title that is not a modifier, or a word H2's shape reads as one, before 'of' ('Mother Superior of the Convent'), behind a title that attaches to it in `title_phrases` ('Princess Royal', #913), or in the object of 'of' or 'the' there (decision 10 of decisions/vocabulary-model). Only the leading run is the head: a modifier outside it is read as listed, one ending the name being H5's. A word in both sets takes the modifier test in every part, that run being its phrase ('Lau, Wing Kai' reads 'Wing' as the given name). After a comma any other modifier is read as listed: a part there that is a lone title word is the person's role ('John Smith, Resident'), and reading it as a name would reverse the name (decisions/H9). `texts` are the words as written, for the shape test; `folded` their `_normalize`d forms; `titled` which of them a caller already reads as titles, where that is more than `titles` lists (classify's period-joined `Lt.Gov.`), else membership in `titles`. Only a word of the two sets is ever read out of the title.""" contested = lexicon.titles_ambiguous modifiers = lexicon.title_modifiers titles = lexicon.titles # a word in titles is a title wherever it stands, whatever else it # is listed as; one of titles_ambiguous alone is listed as a title # to its neighbours and read by its phrase reading = [n in titles or n in contested for n in folded] if titled is not None: reading = [r or t for r, t in zip(reading, titled)] listed = list(reading) phrases = lexicon.title_phrases last = len(folded) - 1 for i, n in enumerate(folded): if n in titles or n not in contested or n in modifiers: continue prev = folded[i - 1] if i else None nxt = folded[i + 1] if i < last else None reading[i] = bool( # the word before as it was READ, left to right: a title that # may be a given name and stands in no phrase is no partner # ('Grace Dean Smith') (prev is not None and reading[i - 1] and (prev in modifiers or prev in lexicon.title_styles or f"{prev} {n}" in phrases)) or (nxt is not None and ((nxt in _PHRASE_LINKS and nxt in lexicon.conjunctions) or (listed[i + 1] and f"{n} {nxt}" in phrases)))) # left to right, whether the run so far has passed 'of' or 'the': a # modifier after it is in the object of a phrase ('President of the # United States', 'the Queen's Bench'), which no head need follow. A # coordinating connective opens no object: 'Mr. and Mrs. Wing Lau' # is two salutations and a given name linked = [False] * len(folded) conjunctions = lexicon.conjunctions for i in range(1, len(folded)): if reading[i - 1] or folded[i - 1] in conjunctions: linked[i] = linked[i - 1] or folded[i - 1] in _OBJECT_LINKS # the head of the name is its LEADING run of titles and connectives: # a modifier ending the name is H5's trailing run, read as listed, # save one that may be a given name, which the loop below reads # wherever it stands lead = 0 if head: while lead < len(folded) and (reading[lead] or folded[lead] in conjunctions or is_title_shaped(texts[lead], lexicon)): lead += 1 # right to left, so each modifier knows whether a head title stands # behind it before the run is broken; a connective carries the run behind = False for i in range(last, -1, -1): n = folded[i] if (n in modifiers and reading[i] and ((head and i < lead) or (n in contested and n not in titles))): reading[i] = behind or linked[i] or ( i < last and folded[i + 1] in _PHRASE_LINKS and folded[i + 1] in conjunctions) or ( # a title before it that attaches to it ('Princess Royal') i > 0 and reading[i - 1] and f"{folded[i - 1]} {n}" in phrases) behind = reading[i] elif reading[i] or (head and is_title_shaped(texts[i], lexicon)): behind = True elif n not in conjunctions: behind = False return reading def name_word_count(texts: Sequence[str], lexicon: Lexicon, policy: Policy, letters_one_case: bool | None = None, ) -> int: """How many of these texts are NAME words -- not suffix vocabulary, not title vocabulary. rules/C4's count for the ambiguous class, and it is of names rather than of words because 'Smith Jr., MA' is two tokens and one name: counting tokens there flips the structure and hands the family to `given`, which no reading of that string wants (decisions/S6). The suffix half asks the POLICY-selected predicate, the same one is_wholly_suffix asks, so the two agree about what a suffix word is; the title half asks BOTH the listed lookup and H2's shape test (`is_title_shaped`) -- a bare `lexicon.titles` lookup here read 'Xyz.' as a name word where the leading peel reads it as a title, and that divergence is recorded once, at `is_title_shaped` itself (mechanisms.md#ONE-PREDICATE-PER-QUESTION). It counts UNITS holding a name word, not tokens (#575, mechanisms.md#UNIT-PARTITION): a particle chain (P2) builds one family name, so 'De La Cruz' is one name word and 'De La Cruz, Ed' reads as 'Royce, Ed' does. Why the other two joins are not read here: `SURNAME_UNIT_TAGS`. `letters_one_case` is `is_wholly_suffix`'s, for the same reason. """ lenient = policy.lenient_comma_suffixes # One pass: each token's name-word verdict, and whether any token # is a particle. With none, every token is a unit of its own and # the count is a sum, so the commonest comma names never build the # units -- and pay no frame the token loop did not already pay. names: list[bool] = [] has_particle = False # map, not a comprehension, which is a frame of its own on 3.11 folded = list(map(_normalize, texts)) # rules/H9: "the word is a title only inside a title phrase" -- the # part before the comma is the head of the name, read as classify # reads it titled = (None if lexicon.titles_ambiguous.isdisjoint(folded) and lexicon.title_modifiers.isdisjoint(folded) else title_readings(texts, folded, lexicon, head=True)) for i, n in enumerate(folded): text = texts[i] title = n in lexicon.titles if titled is None else titled[i] if n in lexicon.particles: has_particle = True suffix = (is_suffix_lenient(text, lexicon) if lenient else is_suffix_strict(text, lexicon, letters_one_case)) names.append(not (suffix or title or is_title_shaped(text, lexicon))) if not has_particle: return sum(names) count = 0 start = 0 for end in unit_ends([surname_unit_tags(t, lexicon, i == 0) for i, t in enumerate(texts)], chain=False): if any(names[start:end]): count += 1 start = end return count def is_wholly_suffix(texts: Sequence[str], lexicon: Lexicon, policy: Policy, one_case: bool | None = None, letters_one_case: bool | None = None, strict: bool = False) -> bool: """Every token in a RUN counts as a suffix -- C1's suffix test of a run, shared by `_comma.decide`, segment's C2 flag on a tail part and the peel (#319). NOT the plural of _script_segment._is_post_nominal, which asks is_suffix_strict per token. This asks the POLICY-selected predicate (lenient by default), plus period_joined_vocab, delimiter transparency and the Ph./D. merge. 'V.' is the input that tells them apart: it satisfies this predicate but is not a post-nominal -- and reading one for the other IS the #319 bug. An EMPTY run is False, not vacuously True: v1's suffix-comma detection fails on an empty parts[1] ('John Smith,, MD' is a family-comma parse). The 'wholly' idiom agrees -- _script_matcher's whole=True requires non-empty too -- which is why the name is that one rather than all_suffixes, where Python's all([]) would promise the opposite. An adjacent Ph./D. pair counts as ONE unit (v1's fix_phd extracted the credential pre-parse, so 'Smith, Ph. D.' read as suffix-comma): `split_pair`'s "suffix" answer, which group's merge and `_comma._pieces` read too. Its "split" answer ('Pharm D') does not count here: that pair is a member of the ambiguous class, which no run test takes by itself, as it takes no bare 'Ma' (rules/C4's count reads it instead). `one_case` is ParseState.one_case, and it admits the LEAN: a bare ambiguous acronym written in capitals inside a mixed-case name is a credential here, so 'Steven Hardman, MD, DO, DDS' reads its third segment as the credential run it is (#289). None -- the default, and what every caller with no state to ask has -- reads as no lean and is this predicate's behavior in every release before 2.4. `letters_one_case` is the same fact asked a different question, and the two are kept apart because a caller that wants no lean still wants the letters read by T4: under `lenient_comma_suffixes=False` the strict test vetoes an initial, and in a name written in one case a bare lowercase letter is one ('john smith, v' reads as 'JOHN SMITH, V', #727). The lenient test takes a suffix word whatever its shape, so the default never reads it. Neither Policy.unlisted_dotted_suffixes NOR Policy.unlisted_caps_suffixes reaches this predicate: admitting a by-shape member here unconditionally (an earlier version of this docstring described exactly that, for the dotted half alone) bypassed both the lean AND the NAME-word count, and combined with C1's then TOKEN-count disjunct in segment (deleted in #613, when the decision moved to `_comma.decide`) it flipped 'Smith Jr., A.B.' to given 'Smith', suffix 'Jr., A.B.' with a self-contradicting report ("holds 1 name words, so it is read as a credential run") -- proved by mutation testing to be otherwise unreached: nothing but this predicate's own two unit tests depended on it, and 'John Smith, X.Y.Z.' flipped correctly through the NAME-word count alone (#516 review round). Neither by-shape class, dotted or caps, reaches the comma form through this predicate at all -- only through classify's shape tags and `_comma.decide`'s caps test (#613). A delimiter core glued to one word ('MD;', ';PhD') leaves the word read by the test it would get without the core (D15, rules/C10), so 'MD;' counts where 'MD' does. `strict` asks the strict token test whatever the policy says: `_comma.decide` passes it behind a head that is not a whole name, where C8 reads 'Smith, V MD' with the initial veto, so that 'Smith, V; MD' reads as its space twin. """ if not texts: return False lenient = policy.lenient_comma_suffixes and not strict # v1 expand_suffix_delimiter parity (#206): a configured delimiter # is TRANSPARENT in the all-suffix tests -- v1 split the part string # on the delimiter before checking, so the delimiter never counted; # the built-in cores are transparent the same way (rules/C10) cores = delimiter_cores(policy.extra_suffix_delimiters) inner = glued_cores(policy.extra_suffix_delimiters) def counts_as_suffix(text: str) -> bool: if (one_case is not None and ambiguous_class_member(text, lexicon) and ambiguous_lean(text, one_case, lexicon_cue( _normalize(text), lexicon)) == "credential"): return True if ((is_suffix_lenient(text, lexicon) if lenient else is_suffix_strict(text, lexicon, letters_one_case)) or period_joined_vocab(text, lexicon) == "suffix"): return True # the split only where a core stands in the word: the built-in # ';' makes `inner` non-empty for every policy, and a call per # word would be a frame on every name word a run test asks for core in inner: if core in text: stem = glued_stem(text, inner) if stem is not None: return counts_as_suffix(stem) return splits_into_suffixes(text, inner, lexicon) return False merged = list(texts) k = 0 while k < len(merged) - 1: if (SPLIT_PAIR_OPENS.fullmatch(merged[k]) and split_pair(merged[k], merged[k + 1], lexicon) == "suffix"): merged[k:k + 2] = ["phd"] else: k += 1 return all(t in cores or counts_as_suffix(t) for t in merged) # The two derived views of a marker vocabulary, cached per-vocabulary # on _script_segment._longest_entry's precedent -- same function shape # (a scalar derived from a vocabulary frozenset), same key space, and # its reasoning for maxsize=16 carries over verbatim: a process holds # the default vocabulary plus one per constructed pack parser, so 16 # bounds many-lexicon churn without ever evicting in normal use. # (NOT _extract._delimiter_chars' precedent, which these once cited: # that one is consulted once per parse, so it says nothing about a # lookup on the per-token path.) Keying on the frozenset costs a # cached hash, not a sweep of its contents. @functools.lru_cache(maxsize=16) def _longest_marker(markers: frozenset[str]) -> int: """How many words the longest entry of `markers` spans -- the lookahead bound, computed from the vocabulary rather than fixed at a literal so a caller's four-word entry works and an all-single-word set leaves the common path at one lookup. Entries are stored space-joined with single separators, so the space count IS the word count.""" return max((entry.count(" ") + 1 for entry in markers), default=0) @functools.lru_cache(maxsize=16) def _marker_heads(markers: frozenset[str]) -> frozenset[str]: """The first word of every entry -- the set a run can possibly open with. Entries are already stored per-word folded, so an entry's first word is the same fold the lookup builds.""" return frozenset(entry.split(" ", 1)[0] for entry in markers) def maiden_marker_head(n: str, markers: frozenset[str]) -> bool: """Could a maiden marker run START here? `n` is _normalize(word), passed in so callers fold once -- suffix_as_written's shape exactly ("`n` is `_normalize(text)`, passed in so callers normalize once"), and with no raw-text sibling for the same reason it has none: every caller is on the per-token path and has the fold in hand already. A SUPERSET test. True means only that some entry opens with this word, never that a run matches -- maiden_marker_run is the answer, and it calls this function, so the two cannot drift. Exported because a caller scanning a whole token stream needs to know whether assembling a candidate sequence is worth doing at all, and for almost every token it is not: _classify's pass would otherwise walk structural boundaries once per token to build a lookahead the predicate discards on this very test. """ return n in _marker_heads(markers) def maiden_marker_run(words: Sequence[str], markers: frozenset[str]) -> int: """How many of `words` a maiden marker claims, longest first; 0 for none. Phrases are stored space-joined and per-word normalized (_title_key's storage rule), so the key is rebuilt the same way here -- normalizing the joined phrase instead would leave interior periods. `words` must be words that stand TOGETHER -- one clause's, or one segment's. This answers only what the vocabulary says about the sequence it is handed; whether a sequence is a sequence is the caller's, and classify's contiguity rule is where that is decided for the token stream. Longest first, so a caller configuring both 'geb' and 'geb von' gets the phrase where it matches and the bare word everywhere else. The one answer to "does a marker start here, and where does it end": the stages that can call it do (classify over token texts, extract over a clause's whitespace words), and the stage that runs after classify reads the tags classify recorded instead (mechanisms.md#ONE-PREDICATE-PER-QUESTION). """ # Fast path first: no entry opens with this word, so no length can # match. It cannot hide a match -- every key the loop builds opens # with _normalize(words[0]) unless that word folds away, and a # folded-away first word fails the n-word test below for every # n > 1 and is not a stored entry for n == 1. if not words: return 0 head = _normalize(words[0]) if not maiden_marker_head(head, markers): return 0 cap = min(len(words), _longest_marker(markers)) # Fold each word ONCE, then key from a prefix. _title_key(words[:n]) # per candidate length re-folds the whole prefix every time, which # is quadratic in cap and makes a one-word hit cost more than a # two-word one -- the longest key is always built and discarded # first, and all but one shipped entry is a single word. The join # below IS _title_key's body over pre-folded words (per-word fold, # empties dropped, space-joined); test_vocab pins that the two # agree, since this is a copy of a fold whose definition lives in # _lexicon. folded = [head] + [_normalize(w) for w in words[1:cap]] for n in range(cap, 0, -1): key = " ".join(filter(None, folded[:n])) # a word that folds away is DROPPED from the key, so 'née' # followed by a lone '.' would key as 'née' and a one-word # marker would claim the period as part of its run. n words in, # n words out: anything else is not this key's n-word phrase. if key.count(" ") == n - 1 and key in markers: return n return 0 # rules/M3: "a marker followed by a comma is no marker" def comma_follows_marker(word: str) -> bool: """Whether `word`, the one standing after a marker run inside a delimited clause, opens with a comma -- standing alone (' , ') or glued to the name after it (' ,Jones'). A comma glued to the marker itself ('née,') needs no test: that word is no marker to begin with, the vocabulary storing no comma. The one predicate for extract's M3 test over whitespace words and group's M1 drop over tokens, which since #653 hold the same comma (mechanisms.md#ONE-PREDICATE-PER-QUESTION). The comma need not be the word's first character, only the first that is a comma or a letter or digit. A whitespace word can open with what the tokenizer drops or divides at -- a bidi mark, an emoji, a nakaguro -- so '\u200f, Jones' is a comma-led WORD only past its mark, while the token group asks about is ',' already; reading past the rest makes the two sites agree by construction (#653's second review).""" for ch in word: if ch in COMMA_CHARS: return True if ch.isalnum(): return False return False def tag_marker_runs(tokens: Sequence[WorkToken], comma_offsets: Sequence[int], markers: frozenset[str], folded: Sequence[str] | None = None) -> dict[int, str]: """Which tokens are maiden marker runs: index -> "vocab:maiden-marker" for a run's head, "vocab:maiden-marker-cont" for the rest. Returns the decision rather than rewriting the tokens: a caller that writes tags into the one pass that builds them (classify) consults this map rather than deciding a run twice, so a marker token is never replaced twice; a caller that never writes tags at all (own_words) reads the same map for its span instead. Moved here from classify (#289/#516) so a caller outside classify -- _pieces.own_words, when it has no marker map yet -- can build the SAME map classify would, rather than approximating it with a text-only head test. `_pieces.py` may import only _state and _vocab (tests/v2/test_layering.py), so the one shared function both sites call has to live here, in the layer both can reach. `folded` is _normalize per token; classify has already built it for the vocabulary-tag pass and hands it over so this pass costs no second fold, and a caller with only tokens (own_words' self- built path) omits it and pays the fold here instead -- paid only on that path, never on classify's, so the reference name's frame count is unchanged (#289/#516). The one sequence pass in this stage, and it has to be one: a marker entry may be a PHRASE whose words are not markers individually ('z', 'domu'), so no per-token membership test can find it. Left to right, longest first at each position, then skip past what the run claimed -- a second marker cannot start inside the first. This is where the tag is DECIDED for the stages that read it afterwards. group runs later and asks its questions of these tags rather than re-deriving the run (the recorded-answer half of mechanisms.md#ONE-PREDICATE-PER-QUESTION); extract runs EARLIER, before tokens exist, so it calls the predicate itself over the clause's whitespace words. A tagged run is structurally contiguous, and the test is one-directional: a role change IS a clause edge, so no run spans one, but not every clause edge is a role change -- two ADJACENT clauses of the same role are indistinguishable here, and 'Jane (z) (domu) Jones' does tag a run across them. Both consumers refuse that run for reasons of their own (the piece walk never sees role-bearing tokens at all; the clause drop is scoped to one clause's span), so no reading depends on it today, and the claim this pass can honestly make is the weaker one. What it does guarantee is what _group._marker_run_pieces needs: a run inside the MAIN stream stays inside one segment. Without it this pass walked the whole span-sorted stream while group walked one segment -- _segment keeps only role-less tokens and buckets them by the commas before them -- so a run half inside a bracketed clause was tagged whole and consumed as a proper PREFIX of itself, and 'Anna z (domu) Nowak' read family 'Anna', maiden 'Nowak': the bare preposition eating the name, which is the exact damage the phrase entry exists to prevent. Refusing to tag such a run is the fix; truncating it instead would hand M2 the same wrong prefix one word shorter. """ # the lookahead the vocabulary actually needs; 0 for an empty set, # which skips the pass entirely cap = _longest_marker(markers) if not cap: return {} if folded is None: folded = [_normalize(t.text) for t in tokens] n_tokens = len(tokens) # Deferred, not computed up front: only the contiguity walk reads # it, only a phrase vocabulary runs that walk, and only at a token # that opens an entry -- so a single-word vocabulary, and a # phrase vocabulary over a name holding no marker, never pay the # sweep at all. buckets: list[int] | None = None tags: dict[int, str] = {} i = 0 while i < n_tokens: # The predicate's own head test first, over the fold the caller # already has: almost no token opens any entry, and for those # there is nothing to assemble. Same function maiden_marker_run # consults, so a token skipped here is one it would refuse. if not maiden_marker_head(folded[i], markers): i += 1 continue # Bound the lookahead at the first structural boundary, so the # predicate is asked over the words that could form one run and # answers longest-first WITHIN them -- a two-word entry refused # at a clause edge still leaves a one-word entry starting there # free to match. limit = 1 if cap > 1: if buckets is None: buckets = [comma_bucket(t.span.start, comma_offsets) for t in tokens] role, bucket = tokens[i].role, buckets[i] while (limit < cap and i + limit < n_tokens and tokens[i + limit].role is role and buckets[i + limit] == bucket): limit += 1 run = maiden_marker_run( [tokens[k].text for k in range(i, i + limit)], markers) if not run: i += 1 continue tags[i] = "vocab:maiden-marker" for k in range(i + 1, i + run): tags[k] = "vocab:maiden-marker-cont" i += run return tags def _normalized_for_script(text: str) -> str | None: """The guard AND the two normalizations single_script and effective_script's license path both need, single-sourced so they cannot drift: trailing full stops are dropped (FULL_STOPS, #323), then None for the two shapes neither ever classifies (nothing left, and the common all-ASCII Latin token -- skipped before normalizing, since ASCII is already NFC and every _SCRIPT_RANGES entry is non-ASCII regardless), else an NFC-normalized copy. Trailing stops, not raw: a period glued to a script-written token ('양.', '太郎.') is not a character of any script, so classifying raw text handed the token no script at all, and three readers spent that None -- the surname site stepped past the family name onto the given name ('양. 지훈' cut 지훈 in half), the order rule fell back to positional ('양 지훈.' lost family-first), and the segmenter's neighbour precondition missed a writer-drawn boundary ('山田太郎 田中.' consulted the segmenter on 山田太郎 as if it stood alone). The scripts this classifies -- every _SCRIPT_RANGES entry, which today coincide with _policy._NO_INITIALS (#320), a coincidence _policy says a new member must not inherit -- have no initials and no period abbreviations, so a stop on such a token carries no information about the word; ASCII text is stripped too, but the guard below returns None for it regardless, so 'Smith.' never classifies. TRAILING only, matching the surname site's own rstrip in _script_segment (the same arithmetic, not a shared gate -- this fold decides only whether the surname site, the order rule and the segmenter ever see the token): a leading stop is not a shape any script writes before a name word, and HIDING such a token from those three readers -- no script, so no surname site, which is what the tree before #323 did -- is safer than admitting it. Admitted, '.김민준' classifies as hangul, becomes a surname site, is declined by the head match (which rstrips) and falls through to a configured segmenter, which answering offset 1 divides it into the stop and the name. The peel is not gated by this fold; its own rstrip carries a leading-stop token, see _script_segment. The vocabulary fold alone reads BOTH edges (_lexicon._normalize): '.씨' is still the honorific, and a lookup divides nothing. NFC, not raw: NFD input decomposes precomposed katakana onto a base character plus a COMBINING mark (U+3099/U+309A, which sit in the HIRAGANA block, not katakana's), so classifying raw NFD text can hand a pure-katakana token the kana license by accident; NFD also decomposes Hangul syllables onto bare jamo (U+1100-U+11FF), entirely outside the HANGUL range, so raw NFD Korean input misses the shipped family-first order rule rather than merely misfiring. Normalizing first fixes both. Voicing marks, last: NFC composes a mark only where a precomposed kana exists (カ + U+3099 is ガ), so ア + U+3099, ン + U+3099 and every spacing mark (U+309B/U+309C) survive it, and all four marks sit in the HIRAGANA block. A mark voices the kana it follows and belongs to neither syllabary, so each one standing after a kana is deleted from the copy (_drop_kana_voicing_marks) and the token takes its base's script: ア゙イ is katakana, not kana-licensed Japanese (#596). A mark after anything else stays, and stays in the hiragana block, as it always has. Classification-only and read-only, like the rest: the returned copy is never what gets tokenized, so token text and spans stay exactly what the caller wrote. Vocabulary MATCHING composes NFC too, since #322 (_lexicon._normalize folds every lookup and every stored entry the same way), so an NFD suffix word reaches its NFC entry. What stays raw is SEGMENTATION -- the surname site's direct membership test and the peel's tail slice index the token's own text -- where NFD degrades to no-split, never to a wrong split (decisions/W1, the 2026-07-29 ja amendment). """ text = text.rstrip(FULL_STOPS) if not text or text.isascii(): return None composed = unicodedata.normalize("NFC", text) if _VOICING_MARKS.isdisjoint(composed): return composed return _drop_kana_voicing_marks(composed) def _classify(normalized: str) -> Script | None: """The FIRST _SCRIPT_MATCHERS entry covering all of `normalized` (already NFC, via _normalized_for_script), else None. Shared by both public classifiers so each of them normalizes exactly once.""" for script, matcher in _SCRIPT_MATCHERS.items(): if matcher(normalized): return script return None def single_script(text: str) -> Script | None: """The one Script whose ranges cover EVERY char of `text`, else None (mixed-script text has no well-defined convention to apply; the caller falls back to the positional default). Classifies an NFC-normalized copy of `text` -- see _normalized_for_script. Callers wanting the kana-mixed license (a kanji+kana composite resolving to HIRAGANA) want effective_script, not this function.""" normalized = _normalized_for_script(text) if normalized is None: return None return _classify(normalized) def effective_script(text: str) -> Script | None: """single_script, extended by the kana license (#272 amendment): a MIXED token wholly within Han∪hiragana∪katakana is Japanese -- it necessarily contains kana (pure Han is not mixed), cannot be Chinese, and is not a foreign transcription (those are katakana-only: マイケル has no kanji, but さくらエミ -- hiragana plus katakana -- is kana-only AND licensed) -- and resolves to the HIRAGANA carrier entry. Pure-katakana stays KATAKANA (single_script's answer): a lone katakana token may be a transcribed foreign name or a Japanese reading, and the script cannot say which, so nothing defaults on it. Halfwidth kana is katakana by the table (#594), so 山田タロウ is licensed too.""" # None for both shapes _wholly_ja could never match anyway (empty # text, or all-ASCII text): real work, not a leftover "if text" # guard, since the ASCII case is one a bare emptiness check would # let through. The single normalized copy then serves both the # single-script answer and the license below. normalized = _normalized_for_script(text) if normalized is None: return None script = _classify(normalized) if script is not None: return script if _wholly_ja(normalized): return Script.HIRAGANA return None def resolve_script_set(scripts: Iterable[Script]) -> Script | None: """Generalizes effective_script's kana license from one token's CHARACTERS to a whole name's PIECES (#272): `scripts` is the effective_script of every name token, already resolved individually -- '高橋' (Han) and 'みなみ' (Hiragana) are two separately single-script pieces (split by a space, not mixed within one token), but together are exactly the repertoire effective_script licenses inside a single token (高橋みなみ). A single distinct script is returned as-is (the ordinary case, including a lone wholly-katakana name, which callers key with no table entry); more than one collapses to the HIRAGANA carrier when confined to Han/Hiragana/Katakana, the same set effective_script's license tests; any other mix (Han+Hangul, or no scripts at all -- an empty `scripts`) returns None -- the caller's cue to fall back to the positional default, exactly like effective_script's own None. A non-None result reports what was FOUND, not that a license fired: callers wanting to know whether the kana license specifically was the reason must compare the result against a specific Script (e.g. `is Script.HIRAGANA`), not just its truthiness -- a lone wholly-Han name also returns non-None here, licensing nothing.""" found = frozenset(scripts) if len(found) <= 1: return next(iter(found), None) if found.issubset(_JA_SCRIPTS): return Script.HIRAGANA return None # -- the reading a decision did not take (Ambiguity.alternative, #752 # phase 2m). Each report names it from what its own site knows: the # word's tags say which reading the vocabulary or a shape gave it, and # the emitter says which field (decisions/vocabulary-model, 2026-10-26) #: Tags that say the vocabulary declares the word a name as well as a #: word of another category: the derived markers (decisions/ #: vocabulary-model, 2026-10-25), and a given name written short. The #: ambiguous class's tag is also written by SHAPE (rules/S9), which #: `name_reading` reads off the shape tag beside it. _DECLARED_NAME_TAGS = frozenset({ AMBIGUOUS_ACRONYM_TAG, AMBIGUOUS_WORD_TAG, "vocab:particle-ambiguous", TITLE_AMBIGUOUS_TAG, GIVEN_ABBREVIATION_TAG}) def suffix_reading(token: WorkToken) -> PendingReading: """The suffix reading of a word a decision read as a name: a generation or agnomen by its listed word, a numeral by its shape unless listed, another listed post-nominal word as a suffix, and otherwise a credential, listed or shaped.""" tags = token.tags if AGNOMEN_TAG in tags: return PendingReading(ReadingKind.AGNOMEN, ReadingSource.VOCABULARY, Role.SUFFIX) if AMBIGUOUS_WORD_TAG in tags: return PendingReading(ReadingKind.GENERATION, ReadingSource.VOCABULARY, Role.SUFFIX) if _ROMAN.match(token.text.rstrip(".")) is not None: listed = "vocab:suffix" in tags or "vocab:suffix-word" in tags return PendingReading( ReadingKind.GENERATION, ReadingSource.VOCABULARY if listed else ReadingSource.SHAPE, Role.SUFFIX) if "vocab:suffix-word" in tags: return PendingReading(ReadingKind.POST_NOMINAL, ReadingSource.VOCABULARY, Role.SUFFIX) return PendingReading( ReadingKind.CREDENTIAL, ReadingSource.SHAPE if SHAPE_ACRONYM_TAG in tags else ReadingSource.VOCABULARY, Role.SUFFIX) 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 initial-shaped, otherwise a name word, declared by the vocabulary where a derived marker says so and standing by position where nothing does.""" text = token.text # C-level for an ASCII word, so a report pays no frame for the test if (len(text.rstrip(".")) == 1 or _INITIAL.fullmatch(text) if text.isascii() else composed_len(text.rstrip(".")) == 1 or is_initial_shaped(text)): return PendingReading(ReadingKind.INITIAL, ReadingSource.SHAPE, role) # classify writes the ambiguous class's tag on a word admitted by # its shape too, so the shape tag says no list declared it declared = (not token.tags.isdisjoint(_DECLARED_NAME_TAGS) and SHAPE_ACRONYM_TAG not in token.tags) return PendingReading( ReadingKind.NAME, ReadingSource.DECLARED if declared else ReadingSource.POSITION, role) #: The particle reading of a word read as a name: a particle joins the #: words after it, the last group reading as the family (rules/P2). PARTICLE_READING = PendingReading(ReadingKind.PARTICLE, ReadingSource.VOCABULARY, Role.FAMILY) #: The title reading of a word read as a name. TITLE_READING = PendingReading(ReadingKind.TITLE, ReadingSource.VOCABULARY, Role.TITLE) derek73-python-nameparser-456b024/nameparser/_policy.py000066400000000000000000001632441527303271700230660ustar00rootroot00000000000000"""Immutable behavior configuration for the 2.0 API. Layering: imports nameparser._types only (enforced by tests/v2/test_layering.py). """ from __future__ import annotations import dataclasses import re from collections.abc import Callable, Iterable, Mapping from dataclasses import dataclass, field from enum import Enum, StrEnum, auto from typing import Any from nameparser._types import ( Role, _coerce_enum, _guarded_getstate, _guarded_setstate, ) class PatronymicRule(StrEnum): """Stable rule names (API); implementations live in the pipeline. Enable via ``Policy(patronymic_rules={...})`` or, more commonly, a locale pack (:mod:`nameparser.locales`).""" #: East Slavic formal order: "Sidorov Ivan Petrovich" #: (family, given, patronymic) is detected by the patronymic #: ending and reordered. 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. TURKIC = "turkic" class CapsSuffixes(StrEnum): """Where ``Policy.unlisted_caps_suffixes`` reads an unlisted all-caps word as a credential (#516, #564).""" #: Nowhere: such a word is name material in every position, as #: 2.3 read it. OFF = "off" #: Only after a comma, behind two or more name words ("John Smith, #: XYZ" gives suffix ``XYZ``). The default: the all-caps SURNAME #: convention ("Jean DUPONT", "DUPONT, Jean") never writes the #: capitals there. AFTER_COMMA = "after-comma" #: After a comma and ending a name with no comma ("John Smith XYZ" #: gives suffix ``XYZ``), where the all-caps surname convention #: does write them -- "Jean Pierre DUPONT" then gives family #: ``Pierre``, suffix ``DUPONT``. EVERYWHERE = "everywhere" class Script(StrEnum): """Writing systems the parser can key SCRIPT-CONDITIONAL behavior on: per-script name order (``Policy.script_orders``) and unspaced-name segmentation (``Policy.segment_scripts``). The rule that admits these (amendment 2026-07-27): script-conditional behavior only where the script itself determines the convention -- Latin-script input is never affected. The codepoint table backing these members is internal.""" #: Chinese Hanzi -- and Japanese Kanji: a pure-Han string cannot #: say which language it is, which is fine for ORDER (both write #: family-first natively) and exactly why Han SEGMENTATION is #: opt-in, per language: locales.ZH brings the Chinese surname #: list, locales.JA activates the same stage for a pluggable #: segmenter to divide kanji names with. HAN = "han" #: Korean Hangul (precomposed syllables). Unambiguously Korean. HANGUL = "hangul" #: Japanese hiragana. Never transcribes foreign names, so a mixed #: kanji+kana token (高橋みなみ) is Japanese and resolves HERE -- #: this member is the carrier key in script_orders/segment_scripts. HIRAGANA = "hiragana" #: Japanese katakana, full-width and halfwidth (タロウ) alike. A #: PURE-katakana token may be a transcribed foreign name in its #: original order (マイケル) or a Japanese name's reading written #: family-first (ヤマダ タロウ, and legacy halfwidth data), and the #: script cannot say which, so no default behavior keys on this #: member; it exists so the classifier can name what it #: deliberately declines. A caller who knows their katakana is #: Japanese maps it to FAMILY_FIRST in script_orders. KATAKANA = "katakana" # Codepoint ranges per Script (#271). This integer table is the single # source of truth for what a script covers; every matcher DERIVES from # it -- _pipeline/_vocab.py compiles its per-script patterns from it, # and it is importable from the pipeline and the locale packs alike, # so the packs' predicates build on it too, through _script_matcher # below (the table lives here rather than in the pipeline because # packs must not import the pipeline). # HAN: the ideographic iteration mark U+3005 and the shime mark # U+3006, the URO plus Extension A, the compatibility block, and the # supplementary-plane block # (Ext B-I + CJK Compat Ideographs Supplement, 0x20000-0x323AF) -- # rare surnames are the biggest real source of supplementary-plane # hanzi in personal names (e.g. 𠮷田's 𠮷, U+20BB7), so leaving them # out silently mis-orders those names; unassigned gaps inside the span # are harmless, since no real name contains an unassigned codepoint. # U+3005 々 is the block-vs-Script case, running the OPPOSITE way to # U+30FB below: 々 already IS Script=Han under UAX #24 (Scripts.txt # reads `3005 ; Han`), but it sits in CJK Symbols and Punctuation, # outside every CJK ideograph block this table spans -- so a # singleton entry was what a BLOCK table needed to reach a character # the Script property would have classified correctly for free. It # earns the reach: 々 repeats the preceding kanji and appears only # inside Han-written names -- 佐々木 (Sasaki, a top-20 Japanese # surname), 野々村, 奈々. Omitting it made 佐々木 a mixed-script token: # the name reversed and never gated into segmentation. # U+3006 〆 (the shime mark) extends that singleton to a two-codepoint # span on a DIFFERENT justification: unlike 々, 〆 is Script=Common # under UAX #24, so this is the table deliberately reaching PAST the # Script property, not around a block boundary -- justified because # within personal names 〆 appears solely in Japanese surnames (〆木 # Shimeki, 〆谷 Shimetani, 〆野) -- its other uses (the envelope # closing mark, 〆切) never reach a name parser -- and it appears in # no other script's names. # HANGUL: precomposed syllables only -- modern Korean # text never writes names as bare jamo. # HIRAGANA/KATAKANA (#272): the two kana blocks, each in full. There # IS a supplementary-plane kana repertoire (Kana Supplement, Kana # Extended-A/B, Small Kana Extension, U+1AFF0-U+1B16F, a few hundred # assigned codepoints -- no exact count here, it moves with the # Unicode version) but none of it is WORTH chasing the way Han's astral # block is: those codepoints are hentaigana and other archaic/ # phonetic-extension forms no modern Japanese name uses, unlike # supplementary Han, which real surnames genuinely need. The Katakana # Phonetic Extensions block (U+31F0-U+31FF, 16 small katakana for Ainu # transcription) is excluded for the same reason -- no modern Japanese # personal name uses them. Halfwidth kana (U+FF65-U+FF9F, #594) IS # katakana here: legacy bank, payroll and CSV data written for JIS X # 0201 systems spells names in it, and 山田 タロウ must read as 山田 タロウ # does. Classifying it is a range, not a fold. rules/T's Background # says "nothing rewrites the text before parsing", which rules out a # fold at tokenize, and an NFKC fold at classification would reach far # past the kana (fullwidth Latin, ㈱), and when #594 decided it a # voicing mark with no precomposed form to join (ア゙) folded to the # combining U+3099, which is in the HIRAGANA block -- so the folded # token took the kana license and a wholly-katakana name turned # family-first (decisions/W4; #596 has since given every voicing # mark after a kana its base's script, see below). The span takes # in the halfwidth nakaguro U+FF65, which tokenize turns into a separator, # on the same direct-call grounds as U+30FB below, and the voicing # marks U+FF9E/U+FF9F: they are spacing characters (category Lm) that # NFC never composes into their base, there being no precomposed # halfwidth voiced kana, so a span ending at U+FF9D would leave ダ # unclassified. It stops short # of U+FF61-U+FF64, the halfwidth CJK punctuation, whose U+FF61 is a # full stop (_lexicon.FULL_STOPS). # This table classifies by Unicode BLOCK, not the UAX #24 Script # property: U+30A0, U+30FB (the middle dot), and U+30FC (the # prolonged sound mark) all carry Script=Common under UAX #24, and the # four kana voicing marks U+3099-U+309C split two and two -- U+3099 # and U+309A are the COMBINING forms (Script=Inherited), U+309B and # U+309C the spacing ones (Script=Common) -- yet every one of them is # needed here, and block membership, not the Script property, is what # puts them in range. Those four are in range only for a mark that # follows no kana: a mark voices the kana it follows, in either # syllabary, so classification deletes every mark that follows a # kana and the token takes its base's script (#596, # _vocab._normalized_for_script) -- ア゙イ is katakana, not katakana # plus hiragana. The katakana block's upper end (U+30FF) takes in # the middle dot U+30FB, kept rather than carved out for a smaller # reason than it looks: tokenize (#272 Task 2b) turns U+30FB into a # token separator, so no real parse shows the classifier a string # containing one. It is kept so that a DIRECT whole-string call -- # effective_script (_pipeline/_vocab.py) on "マイケル・ジャクソン", which # the unit tests (tests/v2/pipeline/test_vocab.py) make -- still # classifies instead of returning None. The ranges below must # stay mutually disjoint: single_script (_pipeline/_vocab.py) returns # the FIRST covering entry (dict iteration order), so an overlapping # future script would make the result order-dependent instead of # well-defined. _SCRIPT_RANGES: dict[Script, tuple[tuple[int, int], ...]] = { Script.HAN: ((0x3005, 0x3006), (0x3400, 0x4DBF), (0x4E00, 0x9FFF), (0xF900, 0xFAFF), (0x20000, 0x323AF)), Script.HANGUL: ((0xAC00, 0xD7A3),), Script.HIRAGANA: ((0x3040, 0x309F),), Script.KATAKANA: ((0x30A0, 0x30FF), (0xFF65, 0xFF9F)), } #: The Japanese repertoire: the three scripts Japanese names draw on. #: The kana license (_pipeline/_vocab.py's effective_script), the ja #: pack's DEVIATES, and the segmenter adapter's repertoire guard all #: quantify over this one union (HANGUL simply omitted). _JA_SCRIPTS = (Script.HAN, Script.HIRAGANA, Script.KATAKANA) #: Scripts whose characters cannot BE an initial. The criterion is #: orthographic CONVENTION, not what a character is: does the writing #: tradition abbreviate a given name to ONE character plus a period, #: the way "J." stands in for "John"? Han, hangul and kana have no #: such convention, so a lone punctuated 씨/様/김 is not a shortened #: name and the veto has nothing to veto there. Do not restate that #: phonologically ("letters, not syllables") -- Devanagari is an #: abugida and Arabic an abjad, neither has letters in that sense, and #: both abbreviate, so should Script.CYRILLIC or Script.DEVANAGARI #: ever be added neither belongs here; their initials are real and #: pinned as such ("А. С. Пушкин", "م. الفارسي"). #: #: Enumerated rather than derived from _SCRIPT_RANGES' keys: the #: Script enum admits a member so that SOME behavior may key on it #: (see Script), on assorted grounds -- KATAKANA is in it so the #: classifier can name what it deliberately declines, and neither #: DEFAULT_SCRIPT_ORDERS nor segment_scripts' default mentions it. #: Membership therefore settles nothing about abbreviation: the four #: coinciding today is what has been implemented, not a property of #: the enum, and a new member must not inherit this answer. #: #: Decide it from CLDR rather than from the script's typology: count #: the LOCALE-AUTHORED namePattern entries in common/main/.xml's #: personNames block that produce an initial. ja and ko author 29 and #: 32 patterns and use one in none of them; ru uses initials in 7 of 39 #: ("{given-initial} {given2-initial} {surname}"); zh abbreviates but #: overrides initialPattern to "{0}", no period, which is why the #: period is part of the test above. Do NOT read initialPattern alone #: -- root defaults it to "{0}." and nearly every locale inherits it, #: so it reports a period convention for locales that have none. #: Measured 2026-08-02: th is 0 of 20, so Thai (#317) belongs here #: once it earns a member. _NO_INITIALS = (Script.HAN, Script.HANGUL, Script.HIRAGANA, Script.KATAKANA) def _script_matcher(*scripts: Script, whole: bool = False) -> Callable[[str], bool]: """A predicate over strings, compiled once from the union of the named scripts' spans in _SCRIPT_RANGES. whole=False: True when the string CONTAINS any such character -- DEVIATES' contract, where over-declaring is the gate's safe direction. whole=True: True when the string is non-empty and consists WHOLLY of such characters -- the ja adapter's repertoire guard and _vocab's script classifiers. Meant to be called at MODULE scope: "compiled once" is per matcher, and each call compiles a fresh pattern. The compiled pattern lives in the closure ON PURPOSE, and the compilation lives HERE rather than in a pack- local closure: tests/v2/test_locales.py classifies any pack module holding a module-level re.Pattern as a marker pack needing rotator branch coverage, and its registry gate goes further -- a pack that so much as IMPORTS re without exposing such a pattern fails "imports re but exposes no module-level pattern" -- so a range-declaring pack must not import re at all; predicates built here keep the packs invisible to that sweep by construction (and spare _vocab's derived matchers a declaration row in tests/v2/test_regex_sync.py's completeness sweep, which scans the pipeline modules (plus _render) for private module-level patterns).""" if not scripts: raise ValueError("_script_matcher needs at least one Script") cls = "".join(f"\\U{lo:08x}-\\U{hi:08x}" for script in scripts for lo, hi in _SCRIPT_RANGES[script]) # one pattern serves both modes: fullmatch of [cls]+ is wholly-of, # and search over [cls]+ is exactly contains-any pattern = re.compile(f"[{cls}]+") match = pattern.fullmatch if whole else pattern.search def matcher(text: str) -> bool: return match(text) is not None return matcher # Order-spec constants (#270). Each reads as its contents because roles # are named given/family, not first/last. #: Western order (the default): the first word of positional input is #: the given name, the last is the family name, everything between is #: middle. One of the three valid ``Policy(name_order=...)`` values. GIVEN_FIRST = (Role.GIVEN, Role.MIDDLE, Role.FAMILY) #: Family name first, given name second, remaining words middle #: (e.g. Hungarian, or East Asian order). One of the three valid #: ``Policy(name_order=...)`` values. FAMILY_FIRST = (Role.FAMILY, Role.GIVEN, Role.MIDDLE) #: Family name first, given name LAST, words between middle #: (e.g. Vietnamese full-name order). One of the three valid #: ``Policy(name_order=...)`` values. FAMILY_FIRST_GIVEN_LAST = (Role.FAMILY, Role.MIDDLE, Role.GIVEN) _ORDER_CONSTANT_NAMES: dict[tuple[Role, ...], str] = { GIVEN_FIRST: "GIVEN_FIRST", FAMILY_FIRST: "FAMILY_FIRST", FAMILY_FIRST_GIVEN_LAST: "FAMILY_FIRST_GIVEN_LAST", } def _order_repr(value: tuple[Role, ...]) -> str: # Unreachable via Policy's constructor (its __post_init__ restricts # name_order to the three named orders) but REACHABLE via # PolicyPatch, which defers name_order validation to apply time by # design -- value may hold non-Role, even unhashable, elements. A # value smuggled in through __setstate__ (which validates layout, # not values) can also be a non-tuple container or not iterable at # all. repr must never raise, so the named-lookup path is taken # only for a TUPLE whose every element is confirmed a Role; # everything else renders via repr(value). (The annotation states # the Policy-side truth; the PolicyPatch call site passes # getattr-Any.) if isinstance(value, tuple) and all(isinstance(r, Role) for r in value): named = _ORDER_CONSTANT_NAMES.get(value) if named is not None: return named return "(" + ", ".join(r.name for r in value) + ")" return repr(value) # Single source for the migration hint raised by both Policy and # PolicyPatch when patronymic_rules gets a non-iterable (True is the # likeliest wrong value -- v1's flag was a bool that enabled BOTH rules). _PATRONYMIC_MIGRATION_HINT = ( "v1's patronymic_name_order=True enabled both rules -- " "patronymic_rules={PatronymicRule.EAST_SLAVIC, " "PatronymicRule.TURKIC} (or pick one via " "parser_for(locales.RU) / locales.TR_AZ)" ) #: Policy.script_orders' default: wholly-Han, wholly-Hangul, and #: kana-licensed names read family-first. Public and named so opting #: out or extending reads against a documented value (the #: DEFAULT_NICKNAME_DELIMITERS precedent). The HAN entry is safe #: WITHOUT knowing Chinese from Japanese: both write family-first in #: native script -- the languages differ, the convention doesn't. #: HIRAGANA joins by the same rule as HANGUL (the kana license, #: amendment 2026-07-29): a mixed Han-and-kana token cannot be #: Chinese (it contains kana) and is not a foreign transcription #: (transcriptions are katakana-only), so it is Japanese, written #: family-first -- another default change in a minor, release-log- #: classified fix, #294's mechanism. KATAKANA is deliberately absent: #: a PURE-katakana token may be a transcribed foreign name kept in its #: source (usually given-first) order, or a Japanese reading written #: family-first -- legacy halfwidth data (#594) spells Japanese names in #: katakana too -- and the script cannot tell them apart, so nothing should #: default on it. Canonical form: sorted (Script, order) pairs, #: matching the field's storage. DEFAULT_SCRIPT_ORDERS: tuple[ tuple[Script, tuple[Role, Role, Role]], ...] = ( (Script.HAN, FAMILY_FIRST), (Script.HANGUL, FAMILY_FIRST), (Script.HIRAGANA, FAMILY_FIRST), ) #: Policy.nickname_delimiters' default. Public and named so #: customizations read as set math against a documented value -- e.g. #: ``DEFAULT_NICKNAME_DELIMITERS | {("⦅", "⦆")}`` -- instead of a #: rebuilt literal the user had to go discover. The v1 trio (straight #: quotes + parentheses) plus the typographic conventions (#273): #: smart quotes, low-high and right-right quotes, guillemets both #: directions, CJK corner brackets (full-width and the halfwidth #: 「」 legacy JIS X 0201 data carries, #597), fullwidth parentheses. #: Curly SINGLE quotes are deliberately absent: U+2019 is the #: typographic apostrophe ("O’Connor"). DEFAULT_NICKNAME_DELIMITERS = frozenset({ ("'", "'"), ('"', '"'), ("(", ")"), # v1 trio ("“", "”"), # smart quotes (en, zh) ("„", "“"), # low-high (de, pl, cs, hu) ("”", "”"), # right-right (sv, fi) ("«", "»"), # guillemets (fr, ru, it, el) ("»", "«"), # reversed guillemets (de alt) ("「", "」"), ("『", "』"), # CJK corner brackets (ja) ("「", "」"), # halfwidth corner brackets ("(", ")"), # fullwidth parentheses (CJK) }) def _reject_bare_string_order(value: object, field_name: str) -> None: # tuple("gmf") would be ("g", "m", "f") -- catch the bare string # with the same TypeError every other iterable field raises. # Single-sourced: called from Policy AND PolicyPatch __post_init__. # field_name is REQUIRED, with no name_order default: script_orders' # values obey the same rule (via _validated_order), and a defaulted # caller that forgot to pass it would silently name the wrong field. if isinstance(value, str): raise TypeError( f"{field_name} must be an iterable of three Roles, " f"not a bare string: {value!r}" ) # A {Role: position} dict iterates to the right three Roles in the # right order, so it would be accepted -- harmlessly today, since # the result is checked against the three exported orders anyway. # Guarded regardless: "iterating this yields something plausible # but not what you wrote" is one bug class, and leaving one field # out of it is how the PolicyPatch hole happened. if isinstance(value, Mapping): raise TypeError( f"{field_name} must be an iterable of three Roles, not a " f"mapping: {value!r}" ) if isinstance(value, (bytes, bytearray, memoryview)): raise TypeError( f"{field_name} must be an iterable of three Roles, not " f"{type(value).__name__} -- decode first, e.g. " f"raw.decode('utf-8')" ) def _reject_str_and_mapping(value: object, field_name: str) -> None: """The two shapes that iterate into something plausible but wrong. A bare string yields its characters (and '' yields nothing at all, so it silently stored an empty set); a Mapping yields only its keys. Both used to be accepted here, storing a value the caller never wrote. Lexicon._normset rejects the same two by name -- the wording is deliberately parallel, since a caller who hits one field's guard should recognize the other's. """ if isinstance(value, str): raise TypeError( f"{field_name} must be an iterable, not a bare string: " f"{value!r}" ) # bytes iterate to ints, so the entry check would report a byte # value and name neither the cause nor the fix; same decode hint # Lexicon and parse() give. if isinstance(value, (bytes, bytearray, memoryview)): raise TypeError( f"{field_name} must be an iterable of strings, not " f"{type(value).__name__} -- decode first, e.g. " f"raw.decode('utf-8')" ) if isinstance(value, Mapping): # Name the way out, not just the harm. "contributes only its # keys" is no help for the two mappings people actually pass: # {} (meant as an empty set -- Python's oldest trap, and the # keys story does not apply because there are none), and an # {open: close} dict, whose keys are only half the pair. raise TypeError( f"{field_name} must be an iterable, not a mapping: " f"{value!r}. A mapping yields only its keys -- write " f"frozenset() for an empty set, or .items() if this is an " f"{{open: close}} pair mapping" ) def _require_iterable(value: Iterable[Any], field_name: str, expected: str = "an iterable") -> Iterable[Any]: """Probe a value's iterability and return its iterator, raising a TypeError naming the field if it has none. `expected` carries the field's own phrasing ("a mapping of Script to order"); the default suits every plain iterable field. Probing with iter() is what makes the message possible: a non-iterable (an int, a bool, ...) is named here, matching the treatment patronymic_rules already gets, instead of a bare "'int' object is not iterable" surfacing from whatever tuple()/frozenset() happens to run first. It is ALSO the reason callers consume the returned iterator OUTSIDE any try of their own: an exception raised inside a caller's generator while it is being consumed is the caller's own error, and must propagate untouched rather than be rewritten as a shape complaint about the field. """ try: return iter(value) except TypeError: raise TypeError( f"{field_name} must be {expected}, got {value!r}" ) from None def _validated_order(value: Iterable[Any], field_name: str) -> tuple[Role, Role, Role]: """name_order's element/permutation check, single-sourced so script_orders values obey the identical rule (only the three exported orders have implemented assignment semantics).""" _reject_bare_string_order(value, field_name) order = tuple(_require_iterable(value, field_name)) # Sole rejection point for plain-string tuples: Role is a StrEnum, # so the named-order membership check below compares EQUAL for # ("given", "middle", "family") -- do not remove this loop as # redundant. for element in order: if not isinstance(element, Role): raise TypeError( f"{field_name} elements must be Role members, " f"got {element!r}" ) # Only the three exported orders have implemented assignment # semantics; the unnamed permutations would silently misassign. # Pre-2.0 strictness is free -- relaxing later is compatible. if order not in (GIVEN_FIRST, FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST): raise ValueError( f"{field_name} must be one of the exported orders, got " f"{order!r}; use GIVEN_FIRST, FAMILY_FIRST, or " f"FAMILY_FIRST_GIVEN_LAST" ) return order # type: ignore[return-value] # length checked above def _validated_script(key: object) -> Script: """Coerce one Script key, with the message every script-keyed field shares. Single-sourced deliberately: script_orders and the script-keyed fields that follow it must not each grow their own wording for the same lookup.""" try: # Enum lookup accepts ANY value at runtime and answers a # non-member with ValueError -- which is the contract here, and # the taxonomy's rule for a failed enum lookup whatever the # input type was (stdlib EnumType precedent). return Script(key) # type: ignore[arg-type] except ValueError: valid = ", ".join(v.value for v in Script) raise ValueError( f"unknown script {key!r}; valid scripts: {valid}" ) from None def _validated_script_orders( value: object) -> tuple[tuple[Script, tuple[Role, Role, Role]], ...]: """Policy.script_orders' whole check, from raw input to canonical storage. script_orders is the one MAPPING-shaped field, so the guards read inverted from every other one here: a Mapping is what the caller SHOULD pass, and a bare string is the shape that would otherwise iterate into plausible-looking garbage.""" if isinstance(value, str): raise TypeError( f"script_orders must be a mapping of Script to order, " f"not a bare string: {value!r}" ) if isinstance(value, (bytes, bytearray, memoryview)): raise TypeError( f"script_orders must be a mapping of Script to order, " f"not {type(value).__name__} -- decode first, " f"e.g. raw.decode('utf-8')" ) raw = value.items() if isinstance(value, Mapping) else value raw_iter = _require_iterable( raw, "script_orders", # type: ignore[arg-type] "a mapping of Script to order") canonical: dict[Script, tuple[Role, Role, Role]] = {} for entry in raw_iter: try: key, order = entry except (TypeError, ValueError): raise TypeError( f"script_orders entries must be (Script, order) " f"pairs, got {entry!r}" ) from None canonical[_validated_script(key)] = _validated_order( order, "script_orders") # Sorted pairs, not a dict: Policy is hashable, and two # differently-written but equivalent tables must converge (the # capitalization_exceptions precedent). return tuple(sorted(canonical.items())) def _validated_segment_scripts(value: object) -> frozenset[Script]: """segment_scripts' check: an iterable of Script members (or their string values), coerced via _validated_script so the unknown-script wording stays single-sourced.""" _reject_str_and_mapping(value, "segment_scripts") script_iter = _require_iterable( value, "segment_scripts", # type: ignore[arg-type] "an iterable of Script members") return frozenset(_validated_script(s) for s in script_iter) def _canonical_script_pair(pair: Iterable[Any]) -> tuple[Any, ...]: """Hashability-only canonicalization of one PolicyPatch script_orders entry: tuple-ize the pair AND the order value inside it. Shallow was not enough -- a {Script: [Role, ...]} patch stored the list, and hash() then raised far from the construction site, the same failure name_order's canonicalization exists to prevent. A malformed entry is still tuple-ized (that is the hashability floor); its CONTENTS are left exactly as written so Policy quotes the caller's own value when it raises at apply time.""" out = tuple(pair) if len(out) != 2: return out # not a (Script, order) pair key, value = out # str/bytes are iterable, so tuple() would shred exactly the two # values whose deferred errors ("not a bare string", the decode # hint) need to quote what the caller wrote. if isinstance(value, (str, bytes, bytearray, memoryview)): return out try: return (key, tuple(value)) except TypeError: return out # non-iterable order value def _canonical_patch_script_orders(value: object) -> object: """Canonicalize a PolicyPatch.script_orders value for hashability without validating it: malformed shapes are stored so Policy can quote them at apply time; a caller-generator's own exception propagates from the UNGUARDED materialization below (deferring is impossible once a one-shot iterator is consumed).""" # Excluded HERE rather than delegated: a string's elements are # themselves tuple-izable, so no TypeError ever fires to signal # "leave this alone" -- "han" would shred to (("h",), ("a",), # ("n",)) and Policy's bare-string message would have nothing left # to quote. bytes shred the same way, into ints. if isinstance(value, (str, bytes, bytearray, memoryview)): return value # deferred whole: Policy's guards quote it # Any, not object: a patch defers validation, so anything a caller # wrote can arrive here, and the probe below is precisely the # runtime question mypy has no way to answer statically. pairs: Any = value.items() if isinstance(value, Mapping) else value try: pairs_iter = iter(pairs) # probe only except TypeError: return value # non-iterable: defer to apply items = tuple(pairs_iter) # UNGUARDED: caller errors propagate try: return tuple(map(_canonical_script_pair, items)) except TypeError: return items # malformed entry: materialized, so # Policy can still re-iterate + quote @dataclass(frozen=True, slots=True) class Policy: """The behavior switches a parser runs with: name order, patronymic rules, delimiter routing, input scrubbing. Immutable and hashable; every field has a safe default, so construct with only what you change -- ``Policy(maiden_delimiters=frozenset({("(", ")")}))`` -- and pass the result to ``Parser(policy=...)``.""" #: How positional (no-comma) input maps onto given/middle/family. #: Valid values are exactly the three exported #: :ref:`name-order constants ` -- #: GIVEN_FIRST (the default), FAMILY_FIRST, and #: FAMILY_FIRST_GIVEN_LAST; any other tuple of Roles raises #: ValueError. Ignored when a comma separates family from given: #: "Thomas, John" puts the family name first no matter which words #: could otherwise be either ("Thomas" and "John" both work as #: given or family names). A comma that only sets off suffixes #: ("John Smith, Jr.") leaves name_order governing the name part. name_order: tuple[Role, Role, Role] = GIVEN_FIRST #: Per-script overrides of name_order (#271), consulted when every #: name piece is written wholly in one script, or in the #: Han/Hiragana/Katakana repertoire the #272 kana license shares #: across pieces: {Script: order} (constructor accepts a mapping; #: stored as sorted pairs). The default reads wholly-Han/Hangul #: names, and kana-licensed Japanese names, family-first -- see #: :data:`~nameparser.DEFAULT_SCRIPT_ORDERS`. Opt out with #: ``script_orders=()``. Latin-script and mixed-script input is #: never affected. Like name_order, ignored where a comma already #: decides the family name. script_orders: tuple[tuple[Script, tuple[Role, Role, Role]], ...] = ( DEFAULT_SCRIPT_ORDERS) #: Scripts for which the unspaced-name segmentation stage is #: active (#271): the first token written wholly in an activated #: script is split by longest surname match against #: :attr:`Lexicon.surnames `, and, #: where that vocabulary declines, by a #: :data:`~nameparser.Segmenter` if one was given to the parser #: (#272). Default: {Script.HANGUL} -- hangul is unambiguously #: Korean and Korean surnames are a closed default-shipped set. #: Han is NOT default: a zh surname list corrupts Japanese names #: (高橋一郎 must not split as 高+橋一郎), so it's opt-in via #: locales.ZH for Chinese and locales.JA -- which activates #: Script.HIRAGANA alongside it, the kana license's carrier key -- #: for Japanese. #: Opt out with ``segment_scripts=frozenset()``; note a PolicyPatch unions #: rather than replaces, so a pack can only add scripts, never #: disable one. segment_scripts: frozenset[Script] = frozenset({Script.HANGUL}) #: Opt-in detectors that reorder patronymic-shaped names #: (EAST_SLAVIC, TURKIC); usually set via a locale pack. A rotation #: restores the given-first reading a family-first listing hides, so #: under a declared FAMILY_FIRST or FAMILY_FIRST_GIVEN_LAST name_order #: it stands down and the declaration decides. patronymic_rules: frozenset[PatronymicRule] = frozenset() #: Folds middle into family instead of splitting them (v1's #: middle_name_as_last) -- for data where unrecognized interior #: words are surname parts, not middle names: multi-part surnames #: like Spanish/Portuguese dual surnames ("Gabriel García Márquez" #: -> family "García Márquez" instead of middle "García"). middle_as_family: bool = False # v1's middle_name_as_last #: (open, close) pairs whose enclosed content becomes the nickname #: field. Defaults to #: :data:`~nameparser.DEFAULT_NICKNAME_DELIMITERS` (#273). nickname_delimiters: frozenset[tuple[str, str]] = DEFAULT_NICKNAME_DELIMITERS #: (open, close) pairs whose enclosed content becomes the maiden #: field instead; a pair listed here is dropped from the effective #: nickname set (maiden wins, see __post_init__), so #: maiden_delimiters=frozenset({("(", ")")}) is the whole recipe (#274). #: Set this for a clause that says nothing about itself, which is #: two kinds and not one: content with no marker word in it, and a #: LONE marker word. Since #335 a clause that opens with a marker #: word AND has a word after it reads as the maiden name whatever #: pair encloses it, so "Jane Smith (née Jones)" needs no #: configuration -- unless the content is suffix-shaped, which is #: taken first, the brackets dropped and the content read as if #: written bare. A maiden_markers entry opening the enclosed #: content is dropped from the value -- all of it, an entry being #: allowed to span more than one word ("z domu") -- but only where #: a word stands past it: a clause of nothing but its marker keeps #: its words, since a lone "(Nee)" is a maiden NAME and not a #: marker (#329). maiden_delimiters: frozenset[tuple[str, str]] = frozenset() #: Additional separators that split suffix groups (e.g. " | " for #: "Jane Smith, RN | CRNA"). Additive only: the comma, the #: semicolon, and a hyphen or slash standing alone split suffix #: groups after a whole name and cannot be replaced -- comma handling is #: structural (the same comma reading that parses "Family, Given" #: input), not a configurable delimiter, the semicolon is built in #: since 2.6 and the spaced hyphen and slash since 2.7 (rules/C10). #: A declared separator glued to a suffix word after the first comma #: counts too, as the semicolon does: "John Smith, MD; PhD" reads #: suffix "MD; PhD", the word keeping its separator. The built-in #: hyphen and slash do not count glued, being a word's own there #: ("al-", "MD/PhD"); declaring " - " makes the hyphen count glued. extra_suffix_delimiters: frozenset[str] = frozenset() #: Governs "Family, Suffix"-shaped input where the suffix word is #: also initial-shaped (a single letter, bare or period-written -- #: of the default vocabulary that means the roman numerals "I" and #: "V"): "John Smith, V" reads as John Smith the fifth when True #: (the default, v1 behavior); False reads "V" as a given-name #: initial instead (family "John Smith", given "V"), and "john #: smith, v" the same way, a name written in one case reading as #: its capitals (rules/T4). Multi-letter suffixes ("III", "MD") #: parse the same either way. lenient_comma_suffixes: bool = True #: Excludes emoji from tokenization: they appear in no token, #: field, or rendered view. The original string keeps them (input #: is never modified -- spans stay true). strip_emoji: bool = True #: Excludes bidirectional control characters from tokenization: #: they appear in no token, field, or rendered view; the original #: string keeps them. strip_bidi: bool = True # =False replaces v1's opt-out CONSTANTS.regexes.bidi = False # -- fields added after 2.3 ------------------------------------------ # Policy is not kw_only, so a field's POSITION is API: a caller # writing Policy(GIVEN_FIRST, ..., True, False) binds by position. # The two 2.4 switches below first landed beside # lenient_comma_suffixes, which re-bound every positional argument # from `strip_emoji` on; they are appended here instead so 2.3's # eleven positions keep their meaning # (tests/v2/test_policy.py::test_the_2_3_positional_fields_did_not_move). # A field added in a later cycle goes at the END for the same # reason, whatever it is about. #: Reads an UNLISTED token of two or more period-separated chunks #: as a credential where the position allows it: "John Smith #: X.Y.Z." gives suffix ``X.Y.Z.`` and "Jack X.Y.Z." keeps family #: ``X.Y.Z.``, the same words-to-spare rule a listed ambiguous #: acronym takes, and either reading is reported. Case is #: irrelevant here -- the periods are the signal, so #: "john smith x.y.z." reads as the mixed-case spelling does. #: Whole-token vocabulary still wins ("M.A.", "Ph.D.", "A.B.C."), #: and a single trailing period is not this shape ("John Smith #: Xyz." keeps family ``Xyz.``), and nor are paired initials, two #: single letters each followed by a period in a script with #: capitals ("Ram Bahadur B.K." keeps family ``B.K.``) except #: right after a comma, where they are the given name unless #: something in the part speaks for them. ``False`` reads such a token as #: name material everywhere, as 2.3 did for a token no chunk #: claimed; the roman-chunk retirement (rules/S3) is not #: behind this switch, and still reports the fork. unlisted_dotted_suffixes: bool = True #: Where an UNLISTED all-caps word of two or more letters, with no #: period in it, reads as a credential (:class:`CapsSuffixes`). The #: name must contrast it: a word of the name holding a capital whose #: last letter is lowercase ("Smith", "DiCaprio") that the #: vocabulary does not claim as a title, particle #: or credential -- a record written wholly in capitals or wholly in #: lowercase keeps every word a name word. A listed member keeps its #: own case lean in every setting ("Jack MA" gives suffix ``MA``), #: and the roman-numeral fork claims a bare numeral first ("Jack #: VI" is unaffected). #: #: ``AFTER_COMMA``, the default, reads it only in the part after a #: comma with two or more name words before it: "John Smith, XYZ" #: gives suffix ``XYZ``, while "Smith, XYZ" keeps given ``XYZ`` (one #: name word) and "García Márquez, MJ" keeps given ``MJ`` -- a lone #: two-letter word is how a person's initials are written, and two #: words before the comma may be one surname, the case #563 decides #: for "M.J." too. The all-caps SURNAME convention ("Jean DUPONT", #: "DUPONT, Jean") writes the capitals at the end of a name or #: before a comma, never after one behind a full name, which is why #: this position is on by default and the others are not. #: #: ``EVERYWHERE`` adds the end of a name with no comma, the #: convention's own position: "John Smith XYZ" gives suffix ``XYZ``, #: and "Jean Pierre DUPONT" gives family ``Pierre``, suffix #: ``DUPONT`` -- the cost, a swallowed family name being the worse #: failure. The two-word "Jean DUPONT" and "Minjun KIM" keep their #: family even then (one word before the credential is never #: enough), but gain the fork's report. ``OFF`` reads no such word #: as a credential anywhere and reports nothing, as 2.3 did. A digit #: disqualifies the token and a single capital stays an initial. #: ``isupper()`` is script-agnostic, so the same holds in any script #: with a case contrast ("Иван ИВАНОВ", "Jean ÉCOLE"). unlisted_caps_suffixes: CapsSuffixes = CapsSuffixes.AFTER_COMMA #: Reads the last two name words of a name as the family, for data #: where a person carries two family names and writes both #: (rules/O6): "Gabriel García Márquez" gives given ``Gabriel``, #: family ``García Márquez``, and "Ana María García López" gives #: middle ``María``, family ``García López``. A particle counts with #: the word it attaches to ("Juan Pérez de la Vega" gives family #: ``Pérez de la Vega``), a family already holding two words keeps #: what it has ("José María Ortega y Gasset", "Ana María #: García-López", "Ana María de la Vega Rodríguez"), titles and #: suffixes count as no name word, and a name of two name words is #: unchanged. The word taken must be one name word a family name #: could be, so an initial written alone ("Ana M. García"), a #: hyphenated given name ("Ana María-José López") and a bound #: given-name pair stay where they are. Under a family-first #: name_order the FIRST two name words are the family; a name its #: script reads in an order other than name_order (a spaced Han or #: Hangul name under the default) is left alone, though under a #: declared FAMILY_FIRST, which the script agrees with, it moves. A #: family comma decides by itself, so "García López, Ana" is #: unaffected. A name with one family name behind two given names #: loses one to the family ("Juan Carlos Pérez" gives family #: ``Carlos Pérez``), which is most English and Argentine names of #: three words or more and many Portuguese and Brazilian ones as #: filed: switched on for a mixed population it costs more names #: than it mends, so it is off by default and reports nothing. Runs #: before middle_as_family, which then folds whatever middles are #: left. two_family_names: bool = False # in the class body so @dataclass(slots=True) keeps them __getstate__ = _guarded_getstate __setstate__ = _guarded_setstate # rules/D2: "configuration validation raises at construction # with the offending field and value named" def __post_init__(self) -> None: object.__setattr__( self, "name_order", _validated_order(self.name_order, "name_order")) object.__setattr__( self, "script_orders", _validated_script_orders(self.script_orders)) object.__setattr__( self, "segment_scripts", _validated_segment_scripts(self.segment_scripts)) _reject_str_and_mapping(self.patronymic_rules, "patronymic_rules") # Probe with iter() rather than wrapping tuple(): non-iterables # (True especially -- v1's patronymic_name_order was a bool flag, # so it's the likeliest wrong value here) get the migration- # pointing message, while an exception raised inside a caller's # generator still propagates untouched from the tuple() below # instead of being rewritten. Only the enum lookup itself gets # the unknown-rule message, naming the offender. try: rule_iter = iter(self.patronymic_rules) except TypeError: raise TypeError( f"patronymic_rules must be an iterable of PatronymicRule " f"names, got {self.patronymic_rules!r}; " f"{_PATRONYMIC_MIGRATION_HINT}" ) from None items = tuple(rule_iter) rules = set() for r in items: try: rules.add(PatronymicRule(r)) except ValueError: valid = ", ".join(v.value for v in PatronymicRule) raise ValueError( f"unknown patronymic rule {r!r}; valid rules: {valid}" ) from None object.__setattr__(self, "patronymic_rules", frozenset(rules)) for pairs_name in ("nickname_delimiters", "maiden_delimiters"): _reject_str_and_mapping(getattr(self, pairs_name), pairs_name) pairs = tuple(_require_iterable(getattr(self, pairs_name), pairs_name)) for pair in pairs: if (not isinstance(pair, tuple) or len(pair) != 2 or not all(isinstance(s, str) for s in pair)): raise TypeError( f"{pairs_name} entries must be (open, close) tuples " f"of strings, got {pair!r}" ) if not all(pair): raise ValueError( f"{pairs_name} entries must be pairs of non-empty " f"strings, got {pair!r}" ) object.__setattr__(self, pairs_name, frozenset(pairs)) # Maiden wins: a pair can route to exactly one field, and listing # it in maiden_delimiters is the specific intent, so the effective # nickname set drops it. Canonicalization, not validation (the # name_order coercion precedent): differently-written but # equivalent Policies converge to equal values. The v1 facade # keeps v1's nickname-wins precedence via a pre-subtraction in # _config_shim's snapshot instead. object.__setattr__( self, "nickname_delimiters", self.nickname_delimiters - self.maiden_delimiters) _reject_str_and_mapping(self.extra_suffix_delimiters, "extra_suffix_delimiters") delimiters = tuple(_require_iterable( self.extra_suffix_delimiters, "extra_suffix_delimiters")) for d in delimiters: if not isinstance(d, str): raise TypeError( f"extra_suffix_delimiters entries must be strings, " f"got {d!r}" ) if not d: raise ValueError( "extra_suffix_delimiters entries must be non-empty strings" ) object.__setattr__( self, "extra_suffix_delimiters", frozenset(delimiters) ) # Truthy strings ("no", "false") would silently invert the # caller's intent downstream; bools are the one field kind the # coercing checks above can't cover. # # The roster is DERIVED from the dataclass rather than written # out: a hand-written list is a second place to remember, and # the twin loop in tests/v2/test_policy.py proved it -- it still # named 2.3's four flags after 2.4 added two, so the new # switches shipped with no "must be a bool" coverage while the # library validated them. `bool` is the whole test: every # bool-annotated Policy field is a flag, and the annotation is # a plain string here because of `from __future__ import # annotations` (AGENTS.md's guard-the-whole-family rule). for flag in _BOOL_FIELDS: value = getattr(self, flag) if not isinstance(value, bool): raise TypeError( f"{flag} must be a bool, got {value!r}" ) # A bool is the likeliest wrong value -- the sibling # unlisted_dotted_suffixes is one, and `True` is an `int` the # enum would read as no member -- so name every member. caps = self.unlisted_caps_suffixes if isinstance(caps, bool): raise TypeError( f"unlisted_caps_suffixes must be a CapsSuffixes, got " f"{caps!r}; use CapsSuffixes.OFF, CapsSuffixes.AFTER_COMMA " f"(the default) or CapsSuffixes.EVERYWHERE") object.__setattr__(self, "unlisted_caps_suffixes", _coerce_enum( caps, CapsSuffixes, "unlisted_caps_suffixes value", "values")) def __repr__(self) -> str: # Bounded: only fields that deviate from the default are shown # (design rule, see nameparser._types module docstring). parts = [] for f in dataclasses.fields(self): value = getattr(self, f.name) if value == f.default: continue if f.name == "name_order": parts.append(f"name_order={_order_repr(value)}") else: parts.append(f"{f.name}={value!r}") return f"Policy({', '.join(parts)})" # -- editing ------------------------------------------------------ def patched(self, patch: PolicyPatch) -> Policy: """Fold a :class:`PolicyPatch` onto this Policy and return the combined Policy. Set-valued fields union with the patch's; scalar fields are overridden by the patch; UNSET fields are left alone. Patch VALUES are validated here (Policy's constructor re-runs on the result), not at patch construction -- see PolicyPatch. The maiden-wins canonicalization applies to the combined result exactly as if it had been constructed directly.""" if not isinstance(patch, PolicyPatch): raise TypeError(f"patched() takes a PolicyPatch, got {patch!r}") return apply_patch(self, patch) #: Every bool-valued Policy field, read off the dataclass rather than #: listed: `__post_init__`'s bool check sweeps this, so a flag added to #: the class above is validated the day it lands and cannot ship #: unchecked the way `unlisted_dotted_suffixes` and the then-bool #: `unlisted_caps_suffixes` did. `from __future__ import annotations` #: makes every annotation a string, so the comparison is against the #: SPELLING "bool" -- which is also what a reader of the class body #: sees, and a field annotated any other way is not a plain flag. _BOOL_FIELDS: tuple[str, ...] = tuple( f.name for f in dataclasses.fields(Policy) if f.type == "bool") class _Unset(Enum): UNSET = auto() #: Sentinel for "this patch does not set this field" (picklable enum #: member, distinguishable from every real value including None/False). UNSET = _Unset.UNSET _UNION = {"compose": "union"} # field metadata: set-valued -> union @dataclass(frozen=True, slots=True) class PolicyPatch: """A partial Policy: one field per Policy field, all defaulting to UNSET. Composition per field is DECLARED via metadata -- set-valued fields union, scalars override (later wins). Kept in lockstep with Policy by the parity test in tests/v2/test_policy.py. Values are validated when the patch is applied (Policy's constructor re-runs), not at patch construction. """ name_order: tuple[Role, Role, Role] | _Unset = UNSET #: Composes as a SCALAR (override, not merge) -- deliberate: nothing #: shipped patches it today, so the simpler rule is the one to #: defend; revisit if a pack ever needs to add one script's entry #: without restating the rest. script_orders: tuple[ tuple[Script, tuple[Role, Role, Role]], ...] | _Unset = UNSET segment_scripts: frozenset[Script] | _Unset = field( default=UNSET, metadata=_UNION) patronymic_rules: frozenset[PatronymicRule] | _Unset = field( default=UNSET, metadata=_UNION) middle_as_family: bool | _Unset = UNSET nickname_delimiters: frozenset[tuple[str, str]] | _Unset = field( default=UNSET, metadata=_UNION) maiden_delimiters: frozenset[tuple[str, str]] | _Unset = field( default=UNSET, metadata=_UNION) extra_suffix_delimiters: frozenset[str] | _Unset = field( default=UNSET, metadata=_UNION) lenient_comma_suffixes: bool | _Unset = UNSET strip_emoji: bool | _Unset = UNSET strip_bidi: bool | _Unset = UNSET # Appended, in Policy's order and for Policy's reason -- this class # is positional too, and the parity test holds the two field # sequences equal, so a field inserted on one side would have to be # inserted on the other and both would re-bind together. unlisted_dotted_suffixes: bool | _Unset = UNSET unlisted_caps_suffixes: CapsSuffixes | _Unset = UNSET two_family_names: bool | _Unset = UNSET # in the class body so @dataclass(slots=True) keeps them __getstate__ = _guarded_getstate __setstate__ = _guarded_setstate def __post_init__(self) -> None: # Canonicalize (but do NOT validate) collection fields so a patch # built from a set/list literal is hashable and unions cleanly in # apply_patch. name_order needs the same treatment: Policy would # coerce a list at apply time, but the patch itself (and any # Locale holding it) must already be hashable. if self.name_order is not UNSET: _reject_bare_string_order(self.name_order, "name_order") object.__setattr__(self, "name_order", tuple(self.name_order)) # Same reason for script_orders, one level deeper: a patch built # from a {Script: order} dict (or a list of pairs) must already # be hashable, since a Locale holds it -- and hashable all the # way down, hence _canonical_script_pair. Validation still # belongs to Policy at apply time, so a shape the canonicalizer # cannot digest is left for it to report; the one-shot and # malformed-entry cases are _canonical_patch_script_orders'. if self.script_orders is not UNSET: object.__setattr__( self, "script_orders", _canonical_patch_script_orders(self.script_orders)) for f in dataclasses.fields(self): if f.metadata.get("compose") != "union": continue value = getattr(self, f.name) if value is UNSET: continue # Shared with Policy, not re-implemented: this used to be an # inline copy of the bare-string half only, so the mapping # half never reached a patch -- and frozenset() below # destroys the evidence, leaving nothing for Policy to catch # at apply time. A Locale pack ships one of these. _reject_str_and_mapping(value, f.name) # same iter() probe as Policy: curated message for # non-iterables (with the v1-flag hint where it applies), # caller-generator exceptions propagate from frozenset() try: iter(value) except TypeError: hint = ("; " + _PATRONYMIC_MIGRATION_HINT if f.name == "patronymic_rules" else "") raise TypeError( f"{f.name} must be an iterable, got {value!r}{hint}" ) from None object.__setattr__(self, f.name, frozenset(value)) # middle_as_family, lenient_comma_suffixes, # unlisted_dotted_suffixes, unlisted_caps_suffixes, strip_emoji, # strip_bidi and two_family_names are scalar (compose="override") # fields and DELIBERATELY get no type check here, unlike # name_order and the union fields above: a # PolicyPatch(strip_emoji="off") is constructible, and only raises # once apply_patch runs Policy.__post_init__'s bool check. This is # the one place the module's eager-validation ethos doesn't apply # -- see the class docstring ("Values are validated when the patch # is applied... not at patch construction"). def __repr__(self) -> str: # Bounded: only fields the patch actually sets are shown; UNSET # fields are omitted (design rule, see nameparser._types module # docstring -- the sibling of Policy's deviation-only repr). parts = [] for f in dataclasses.fields(self): value = getattr(self, f.name) if value is UNSET: continue if f.name == "name_order": parts.append(f"name_order={_order_repr(value)}") else: parts.append(f"{f.name}={value!r}") return f"PolicyPatch({', '.join(parts)})" def apply_patch(policy: Policy, patch: PolicyPatch) -> Policy: """Fold a PolicyPatch onto a Policy. Policy.__post_init__ re-runs via dataclasses.replace, so patched values are revalidated for free -- including the maiden-wins canonicalization: a patch that adds a maiden pair removes that pair from the base's effective nickname set, exactly as if the combined Policy had been constructed directly. Intended (decided 2026-07-19): maiden_delimiters membership IS the routing decision, whoever contributes it.""" updates: dict[str, object] = {} for f in dataclasses.fields(PolicyPatch): value = getattr(patch, f.name) if value is UNSET: continue if f.metadata.get("compose") == "union": value = getattr(policy, f.name) | value updates[f.name] = value if not updates: return policy # Known mypy limitation with **dict-unpacked replace; see the full # explanation at Lexicon._edit in _lexicon.py. return dataclasses.replace(policy, **updates) # type: ignore[arg-type] derek73-python-nameparser-456b024/nameparser/_render.py000066400000000000000000001270001527303271700230340ustar00rootroot00000000000000"""Rendering for the 2.0 API: ParsedName -> display strings. Layering: imports nameparser._types, and nameparser._lexicon for Lexicon.default() (capitalized() with lexicon=None), _normalize and FULL_STOPS (enforced by tests/v2/test_layering.py). Parsing code never imports this module; ParsedName's rendering methods delegate here via call-time imports. Malformed str.format specs beyond unknown keys (positional fields, bad conversions) surface the raw str.format error; only unknown KEYS get the enriched KeyError. """ from __future__ import annotations import re import unicodedata from collections.abc import Callable from nameparser._lexicon import (FULL_STOPS, Lexicon, _normalize, _spells_an_initial) from nameparser._types import (FOLDED_TAG, SHAPE_ACRONYM_TAG, SPLIT_CREDENTIAL_TAG, SPLIT_MEMBER_TAG, UNCLASSIFIED_TAG, UNJOINED_CONJUNCTION_TAG, UNJOINED_TAG, Ambiguity, ParsedName, Role, Token) _SPACES = re.compile(r"\s+") _SPACE_BEFORE_COMMA = re.compile(r"\s+,") _COMMA_CHAR = re.compile(r"[,،,]") # ASCII, Arabic, fullwidth _MAC = re.compile(r"^(ma?c)(\w{2,})", re.IGNORECASE) _WORD = re.compile(r"(\w|\.)+") _NAME_RUN = re.compile(r"\w+") #: 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") _RENDER_KEYS = tuple(r.value for r in Role) + _DERIVED_VIEWS #: str.format keys initials() accepts: the three name-bearing roles. _INITIALS_KEYS = (Role.GIVEN.value, Role.MIDDLE.value, Role.FAMILY.value) #: Tags whose tokens contribute no initial outside the given group -- #: unless the token also carries UNJOINED_TAG, i.e. the whole part is #: particles, in which case they are the part's only words and do #: contribute (rules/R3, #404). The mark readmits a token carrying #: EITHER tag: a conjunction with nothing to join is not acting as a #: conjunction any more than a particle with nothing to join is acting #: as a particle, so it is a name word of the part like the rest. #: Not STABLE_TAGS -- that also contains "initial", which must contribute. _SKIP_TAGS = frozenset({"particle", "conjunction"}) #: The given group's own skip set (rules/R3, #461): a connective #: contributes no initial in ANY group where it is joining words, so #: the given group no longer exempts it -- "one rule for every group". #: The particle exemption stays: a given-group particle is a name word #: there, which is what the whole-group exemption was for. _SKIP_TAGS_GIVEN = frozenset({"conjunction"}) #: Either unjoined mark readmits the word it sits on. _UNJOINED_MARKS = frozenset({UNJOINED_TAG, UNJOINED_CONJUNCTION_TAG}) # Ported verbatim from v1 (nameparser/config/regexes.py "initial", minus # the empty alternative) -- layering forbids importing the pipeline here; # keep in sync with _pipeline/_vocab.py by hand. # Its one reader is _reads_as_conjunction below, and that reader only # ever sees a bare string with no token attached to it -- a spliced # field (replace()), restored state (__setstate__: a pickle load, # copy.copy, or copy.deepcopy), a direct string call, or -- since # 84d9000 -- a widen-only _process_initial override that drops the # token it was handed on its way to the string path. The first two # never had a token to begin with: for anything the parser classified # AND the caller passed the token along, the tag is the answer and # this pattern is not asked. The last two might have been classified # and the reader cannot tell -- it only knows no token was passed. # So the two copies no longer decide the same question about the same # token -- _vocab's says what the parse decided, this one says what it # WOULD have decided about text handed over with no token -- which is # why they must keep answering alike, and why test_regex_sync pins the # patterns against each other and against config. # Deliberately NOT composed with _vocab's repertoire test (#320): # layering forbids the import. The divergence is reachable only for a # caller-added CJK conjunction spliced into a field, since no shipped # vocabulary carries one, and it costs nothing there: CJK is caseless, # so the carve-out's lower() and the fall-through's title case return # the same string. Since #528 this pattern is read by more than case # repair: through _reads_as_conjunction below, whose callers are case # repair's spliced field and the v1 facade's initials view, the latter # in three shapes -- a token carrying UNCLASSIFIED_TAG # (_facade._token_is_conjunction), a direct _process_initial call with # no tokens at all, and, since 84d9000, a widen-only _process_initial # override that drops a token the parse DID classify. For initials the # CJK divergence would decide whether such a spliced, dropped or # never-parsed connective contributes a letter rather than which case # it renders in -- still unreachable from any shipped vocabulary, and # still not worth the import layering forbids. _INITIAL = re.compile(r"^(\w\.|[A-Z])$") #: The hyphen clause's initial test (_cap_text, #478): _INITIAL's #: period alternative alone. The bare-capital half would exempt a #: capital connective on its CASE -- 'Y' in 'JOSE ORTEGA-Y-GASSET' #: must lower -- the reading #458 removed. Pinned as that alternative #: by tests/v2/test_regex_sync.py. _DOTTED_INITIAL = re.compile(r"^\w\.$") # v1 regexes.py "roman_numeral", the pipeline's _vocab._ROMAN copied # by hand (layering, as for _INITIAL above) and pinned by # tests/v2/test_regex_sync.py. Read only by _cap_word's numeral # clause, which asks how a word the parse already put in the suffix # role is WRITTEN, never whether it is a suffix # (mechanisms.md#RENDER-HONORS-THE-PARSE). _ROMAN = re.compile(r'^(X|IX|IV|V?I{0,3})$', re.I) def _reads_as_conjunction(word: str, lex: Lexicon) -> bool: """v1's is_conjunction, asked only where the CALLER supplies no token. A token the parse classified carries its reading in its tags, and the library's own token path -- _facade._token_is_conjunction -- consults that tag first and never reaches here for a token it holds. This function is reached only where there is no token to consult: a field spliced in as raw text (replace()) or restored state (__setstate__: a pickle load, copy.copy, or copy.deepcopy), both carrying UNCLASSIFIED_TAG; a direct call with no parse behind it; or, since 84d9000, a widen-only _process_initial override that drops the token it was handed on its way to the string path -- text the parse DID classify, whose reading the override chose not to forward. This function cannot tell any of those apart from one another; it can only give the answer the parser would have given from the word alone, the initial carve-out included ('E.' assigned to middle is an initial, not the Italian conjunction). What it cannot give is an answer the parse reached by looking at the whole NAME -- rules/P8's one-case fork is the live example -- which is why it is the fallback and the tags are the rule. """ # the shape on the composed spelling, as _normalize composes the # lookup: a decomposed 'й.' is the initial its composed twin is (#542) return bool(_normalize(word) in lex.conjunctions and not _INITIAL.fullmatch(unicodedata.normalize("NFC", word))) def _collapse(rendered: str) -> str: """The #254 collapse: empty fields substitute '' and every artifact of that is removed -- dangling empty-nickname wrappers, space runs, space-before-comma, one trailing comma character (any script), leading/trailing ', ' debris.""" rendered = (rendered.replace(" ()", "") .replace(" ''", "") .replace(' ""', "")) rendered = _SPACE_BEFORE_COMMA.sub(",", rendered) rendered = _SPACES.sub(" ", rendered.strip()) if rendered and _COMMA_CHAR.fullmatch(rendered[-1]): rendered = rendered[:-1] return rendered.strip(", ") def _format_spec(spec: str, values: dict[str, str], noun: str, keys: tuple[str, ...]) -> str: """Shared tail of render()/initials(): fill the spec, enrich unknown-KEY errors with the valid key list, collapse.""" if not isinstance(spec, str): raise TypeError(f"spec must be a str, got {spec!r}") try: rendered = spec.format(**values) except KeyError as exc: raise KeyError( f"unknown {noun} field {exc.args[0]!r}; valid fields: " f"{', '.join(keys)}" ) from None return _collapse(rendered) def render(name: ParsedName, spec: str) -> str: """Fill the str.format spec from the seven role fields and the derived views (empty fields substitute ''), then apply the #254 collapse. Unknown keys raise KeyError naming the valid fields.""" values = {key: getattr(name, key) for key in _RENDER_KEYS} return _format_spec(spec, values, "render", _RENDER_KEYS) # rules/R3: "initials take the first letter of each given, middle, # and base family word; titles, suffixes, particles and nicknames # contribute nothing" def _first_letter(text: str) -> str: """The initial of `text`: its first character as its composed (NFC) spelling reads it, written in the form `text` was written (#585). That is the shortest start of `text` that composes to the composed text's first character -- 'e' + U+0301 for a decomposed 'é', and the two or three jamo of a decomposed hangul syllable, which are letters rather than marks -- or, where a mark composing with nothing stands inside that start, the character decomposed (even where the writer composed part of it). A text whose first code point holds a whole character gives text[0], as it always did -- a composed text, and one opening with a character Unicode excludes from composition -- so a mark that composes with nothing (a Bengali vowel sign) stays out of the initial in either form. Raises IndexError on an empty `text`, as text[0] does.""" if text.isascii(): return text[0] first = unicodedata.normalize("NFC", text)[0] end = 1 while not unicodedata.normalize("NFC", text[:end]).startswith(first): end += 1 # end == 1: one code point already holds the first letter, kept # as written even where NFC would split it (U+0958, excluded from # composition, is 'क' + a nukta composed; it keeps its nukta here) if end == 1 or unicodedata.normalize("NFC", text[:end]) == first: return text[:end] # A mark that composes with nothing stands, in canonical order, # between the letter and a mark that does ('o' + U+0331 + U+0301, # where the composed spelling is 'ó' + U+0331), so no start of the # text composes to `first` alone: write `first` decomposed, the # form the writer used for it. return unicodedata.normalize("NFD", first) def initials(name: ParsedName, spec: str, delimiter: str, separator: str) -> str: """First letter of each contributing token per group, v1 semantics: delimiter follows each initial, separator sits between initials within a group. Each group is ordered the way its FIELD is ordered -- written order, except folded words, which initial before the rest of the group (#408). A token tagged conjunction contributes no initial in ANY group, and one tagged particle contributes none in middle/family (given-group particles always contribute); either unjoined mark readmits the word it sits on, so the words of an all-particle part and a connective with nothing in its part to join both count; tags come from the pipeline -- hand-built untagged tokens all contribute, and so do the words of a field spliced in by replace(), which the parse never read. This view takes NO lexicon, so it has none to fall back to for that text: `replace(family='de la vega')` initials every word of that field where the same name parsed gives 'j. v.' (rules/R3's Accepted bullet "This rule reads a part the parser read", the limit this view keeps; decisions/R4 for why the fallback was tried and dropped -- #464 is the crossing that would make it answerable). Valid spec keys: given, middle, family.""" if not isinstance(delimiter, str): raise TypeError(f"delimiter must be a str, got {delimiter!r}") if not isinstance(separator, str): raise TypeError(f"separator must be a str, got {separator!r}") values: dict[str, str] = {} for key in _INITIALS_KEYS: role = Role(key) tokens = name.tokens_for(role) skip = _SKIP_TAGS_GIVEN if role is Role.GIVEN else _SKIP_TAGS tokens = tuple(t for t in tokens if not (skip & t.tags) or _UNJOINED_MARKS & t.tags) # mechanisms.md#FOLDED_TAG: "a rule that needs different # rendering order tags the token, and the rendering views # consult the tag" -- this is a rendering view, so it reads # the tag the same way _types._text_for does, and for the same # reason: the fold is an ORDER the parse recorded, not one the # view is free to take again # (mechanisms.md#RENDER-HONORS-THE-PARSE: "the parse decides # it; the render views honor those decisions and never # re-evaluate them"). Applied to every role this view renders, # exactly as _text_for applies it -- the pipeline puts the tag # on FAMILY tokens alone today, so GIVEN and MIDDLE are # uniformity with the mechanism rather than reachable # behavior; a producer that ever folds into another part would # otherwise reopen #408 there. tokens = (tuple(t for t in tokens if FOLDED_TAG in t.tags) + tuple(t for t in tokens if FOLDED_TAG not in t.tags)) values[key] = separator.join( _first_letter(t.text) + delimiter for t in tokens) return _format_spec(spec, values, "initials", _INITIALS_KEYS) def _beside(text: str, at: int, step: int) -> str | None: """The character beside text[at], one `step` (-1 or 1) away past any combining marks, or None at the edge of `text`. A mark belongs to the letter before it, so a decomposed letter's neighbours are its composed twin's (#542).""" at += step while 0 <= at < len(text) and unicodedata.category(text[at])[0] == "M": at += step return text[at] if 0 <= at < len(text) else None def _letter_run_ge2(text: str) -> list[bool]: """One flag per alphanumeric character of `text`, in order: True where it is a LETTER with a letter immediately before or after it (_beside). Any other character -- a full stop, a space, a digit -- ends a run, and a digit's own flag is always False.""" def letter(c: str | None) -> bool: return c is not None and c.isalpha() return [c.isalpha() and (letter(_beside(text, i, -1)) or letter(_beside(text, i, 1))) for i, c in enumerate(text) if c.isalnum()] # rules/R4: "the writer split a chunk the mask keeps together, so # the split-off letter is an initial" -- the one override below. def _apply_mask(word: str, mask: str) -> str | None: """rules/R4's mask: `word` with each letter or digit recased to the case of the mask's alphanumeric in the same position, every other character kept where the writer put it ('ph.d.' under 'PhD' is 'Ph.D.'). None where the two alphanumeric counts differ -- rare, since the key is the word folded, but a word in decomposed hangul spells one syllable in two letters and still finds its one-letter key -- and the caller falls through rather than guess. The override cited above: a letter standing alone beside a full stop (any of FULL_STOPS, though through capitalized() only the ASCII period reaches here, _WORD splitting a token at any other; beside as _beside reads it, past combining marks) is written upper wherever the mask writes it inside a run of two or more letters (_letter_run_ge2). Casing goes through the whole word (word.lower()/word.upper()) when both keep its length, since per-character casing is context-free (a Greek final sigma needs its neighbours). A mask letter that is not lowercase reads as upper, so a titlecase letter (Lt) is written upper -- a known limit.""" mask_chars = [c for c in mask if c.isalnum()] if len(mask_chars) != sum(1 for c in word if c.isalnum()): return None mask_run = _letter_run_ge2(mask) word_run = _letter_run_ge2(word) lowered, uppered = word.lower(), word.upper() same_length = len(lowered) == len(uppered) == len(word) out: list[str] = [] at = 0 for i, c in enumerate(word): if not c.isalnum(): out.append(c) continue upper = not mask_chars[at].islower() or ( mask_run[at] and c.isalpha() and not word_run[at] and any(c is not None and c in FULL_STOPS for c in (_beside(word, i, -1), _beside(word, i, 1)))) at += 1 if same_length: out.append(uppered[i] if upper else lowered[i]) else: out.append(c.upper() if upper else c.lower()) return "".join(out) def _lowers_as_particle(normalized: str, word: str, role: Role, tags: frozenset[str], lex: Lexicon) -> bool: """_cap_word's particle arm: `word` (folded to `normalized`) is a particle doing a particle's work, so it is written in lowercase. Shared with _cap_text, which asks it of each whole token before splitting the token into words, so a marked one ("d'") is asked as written rather than by its stem (#700).""" return (normalized in lex.particles and not _spells_an_initial(normalized, word) and role in (Role.MIDDLE, Role.FAMILY) and UNJOINED_TAG not in tags # rules/R4: "a particle the vocabulary records a # casing for takes that casing" -- the mask below # outranks the particle's lowercase (#604) and normalized not in lex.capitalization_exceptions_map) def _cap_word(word: str, role: Role, tags: frozenset[str], lex: Lexicon) -> str: # Clause order: the particle/connective arm, then the exceptions # map as a mask, then the acronym clause (listed or by shape), # then the numeral clause, then Mac/Mc and title case per # full-stop chunk (_title_chunks; v1's was str.capitalize() on the # whole word). v1's cap_word had the first, second, fifth and a # plainer sixth in this order; # the mask ahead of the acronym clause is what gives bsc 'BSc' # though bsc is a listed acronym too. normalized = _normalize(word) # rules/R4: "a part whose every word is particle vocabulary is # repaired as ordinary name words, since none of them is doing a # particle's work there" -- UNJOINED_TAG is that mark (#407). # Only the PARTICLE conjunct is gated on it, and that is the rule # rather than an omission -- rules/R4: "A CONNECTIVE the parse # placed among the name words keeps its lowercase wherever it # stands there, including inside a part whose other words the # unjoined mark has turned into ordinary name words" -- so a # conjunction keeps conjunction treatment even inside a part the # mark has turned into ordinary name words. # The `generation` guard below is the other half of that sentence: # a word this vocabulary holds can ALSO be the generation it # spells ('i' is the Catalan link and the roman numeral), and # where the parse read the generation the token still carries the # `conjunction` tag classify gave it -- so without the guard, # `parse("John Quincy Smith i").capitalized(force=True)` gave # 'John Quincy Smith i' where every release through 2.3 gave # 'John Quincy Smith I' (#397 review). Such a token is repaired # as the suffix it was read as, which is the rest of R4's # sentence: "one the parse read as the generation it also spells # is not a connective of this name at all". # BOTH HALVES, and the role alone is not enough -- the role says # where the word landed and the vocabulary says whether landing # there made it a generation. A plain connective can land in the # suffix field without being generational vocabulary at all (a # third comma part: `Smith, John, and`), and on the role test # alone every one of them was repaired as a name word -- # 'John Smith And' where 1.4.0, 2.0 through 2.3 and the parent # commit all gave 'John Smith and', and 'John Smith De, Y' for a # field spliced to suffix='de y' where R4's own Accepted # paragraph says the vocabulary answers and the 'y' keeps its # lowercase (#397 second review). `vocab:suffix` is classify's # record of the vocabulary half, so the pair reads two decisions # the parse already made and re-derives neither # (mechanisms.md#RENDER-HONORS-THE-PARSE). # It guards the whole test rather than the two conjunction arms # alone, which reads as the wider claim and is not one: the # particle arm asks for role MIDDLE or FAMILY, so a SUFFIX-roled # token can never reach it either way. # No SHIPPED name witnesses the difference: `particles` and # `conjunctions` are disjoint in the default vocabulary and in # every locale pack, so no shipped conjunction can sit in an # all-particle part and carry the mark. That is a property of the # shipped DATA, not an invariant -- both sets are public, # configurable API, and a caller's Lexicon may put one word in # both, the way _pipeline/_post_rules.py's arms allow for. Measured: # under `Lexicon.default().add(particles={'y'})`, `anh y van` has # an all-particle family whose `y` carries both tags and the mark, # and gives 'Anh y Van'; gating this conjunct too would give # 'Anh Y Van'. That is pinned by test_repair_keeps_a_conjunction_ # lowercase_in_a_particle_part -- until which gating it passed the # whole suite. # initials() does NOT match this carve-out, and since #461 that # is a DECIDED disagreement rather than a recorded one: a # connective that initials because it joins nothing is still not # written the way a name is written, which is the sentence quoted # above and this rule's own reason rather than a borrowing from # R3. Under that same lexicon `Anh y Van` repairs to 'Anh y Van' # and initials 'A. y. V.' -- the two views agreeing on this row # because R2's mark readmits the word for both -- while # `parse("Juan de y")` repairs to 'Juan de y' and initials # 'J. y.', where they part. Pinned by # test_initials_readmits_a_conjunction_in_a_particle_part and # test_repair_keeps_a_lone_connective_lowercase_where_it_initials. # That conjunct reads the TAG, not the word (#458). classify takes # the conjunction-versus-initial decision once, over the whole # token -- v1's is_conjunction excludes initials, so 'E.' in # 'Scott E. Werner' is an initial and is never tagged (pinned live # 2026-07-17) -- and a view honors that decision rather than # taking it again from the spelling # (mechanisms.md#RENDER-HONORS-THE-PARSE: "the render views honor # those decisions and never re-evaluate them"), the tags being # classify's record of it (mechanisms.md#VOCAB-TAGS: "later stages # test tags"). Asking again was not even the same question: # the copy of the initial pattern that stood here was the SHAPE # half alone, and it re-decided per WORD of a token's text, so # 'juan e-f smith' capitalized to 'Juan e-F Smith'. # mechanisms.md#RENDER-HONORS-THE-PARSE: "a token the parse never # saw carries no decision to honor, so a view falls back to the # vocabulary" -- _reads_as_conjunction above, which is v1's # predicate applied over TODAY's vocabulary rather than 1.4.0's. # That is the honest claim and it is narrower than parity: the two # vocabularies differ, so an assigned field can repair differently # from 1.4.0 without this predicate differing at all. Measured on # the released wheel: `h.last = "хосе и мария сантос"` gives # 'Хосе И Мария Сантос' on 1.4.0 and 'Хосе и Мария Сантос' here, # the Cyrillic `и` being a 2.x conjunction and not a 1.4.0 one; # `h.last = "de la vega"` gives 'de la Vega' there and here. # The mark, not the SPAN, is what says the text was never read: # Parser.revise() also builds span-less tokens, from a sub-parse # whose tags it keeps on purpose, and keying this on `span is None` # overrode them -- `revise(middle='e-f')` repaired to 'e-F' where # the same words parsed gave 'E-F' (#463 review). # rules/S6: "its lower case in a name of mixed case is the generation # wherever the name keeps a family word" -- a generation word that is also a # name, in the suffix role, repairs to the one writing that keeps # that reading; Title case would lean it back to the name. An # agnomen takes no case cue and title-cases as any word does. if (role is Role.SUFFIX and normalized in lex.suffix_words_ambiguous and normalized not in lex.agnomens and normalized not in lex.capitalization_exceptions_map): return word.lower() generation = role is Role.SUFFIX and "vocab:suffix" in tags if not generation and ( _lowers_as_particle(normalized, word, role, tags, lex) or "conjunction" in tags or (UNCLASSIFIED_TAG in tags and _reads_as_conjunction(word, lex))): return word.lower() # v1 cap_word tries the edge-stripped form, then the period-free # form ('Ph.D.' -> 'ph.d' -> 'phd' hits the exceptions map). The # value found is a MASK, not a replacement (#459): it recases the # word as the writer punctuated it, so 'Ph.D.' under 'PhD' stays # 'Ph.D.' and 'phd' becomes 'PhD'. v1 substituted the value, which # is how 'md' became 'M.D.' and 'iii.' lost its period; # rules/R4: "Repair changes case and nothing else". Role-free, # as the map always was: an entry is the caller saying how a word # is written wherever it stands. undotted = normalized.replace(".", "") for key in (normalized, undotted): mask = lex.capitalization_exceptions_map.get(key) if mask is not None: masked = _apply_mask(word, mask) if masked is not None: return masked # A credential acronym the exceptions map doesn't carry (mba, jd, # qc, and md since #459 took it out of the map) is an initialism, # not a word to title-case: a one-case name repairs to the # acronym's caps instead of 'Mba' (#459). So is a word classify # admitted to the credential class by its dotted SHAPE alone # (SHAPE_ACRONYM_TAG; rules/S3's unlisted 'x.y.z.'), which has # no vocabulary entry to be listed in. The mask above is asked # first, which is what keeps a conventionally mixed-case acronym as # it is written (bsc -> BSc) though it is listed here too. Gated # on the SUFFIX role so a word that is a family name only happens # to be in the vocabulary (anh van do) still repairs as an ordinary # name word -- #459's given-role half, decided: repair follows the # role the parse chose ('qc mp' -> 'Qc MP'). if role is Role.SUFFIX and ( undotted in lex.suffix_acronyms or SHAPE_ACRONYM_TAG in tags): return word.upper() # rules/R4: "A roman numeral the parse put in the suffix role is # written in capitals" whether or not the vocabulary lists it: 'vi' # through 'x' carry no vocabulary tag and title-cased to 'Vi'/'Ix' # until #459, while 'ii'/'iii'/'iv' rode the exceptions map, which is # why they left it. Suffix-gated for the acronym clause's reason # -- 'Vi' is a given name -- and it is also what writes a # generational 'i' the connective arm's `generation` guard let # through ('Carod i' forced -> 'Carod I'). if role is Role.SUFFIX and _ROMAN.match(normalized): return word.upper() return _title_chunks(word) # rules/R4: "Each run of letters a full stop divides is written as a # word of its own" and "A word that opens with a modifier letter that # has no case" "takes its capital on the letter after that mark"; "A # digit is not passed over that way". This is the fall-through: the # Mac/Mc convention and title case, for a word no clause above # claimed, asked of each chunk. decisions/R4 (#838). def _title_chunks(word: str) -> str: """`word` repaired chunk by chunk between its full stops, so a chunk after one gets what a whole word gets ('j.mcdonald' is 'J.McDonald').""" return ".".join(_title_chunk(chunk) for chunk in word.split(".")) def _title_chunk(chunk: str) -> str: """`chunk` title-cased from its first character that is not a modifier letter (category Lm), which also holds the apostrophe 'ʼn' upper-cases into, so 'ʼNa' is a fixpoint of repair. The Mac/Mc prefix is decided on the composed spelling, so a chunk typed decomposed (NFD) is read as its composed twin is: 'mac' + 'e' + U+0301 + 'e' fails the regex's \\w{2,} at the mark (#542). The name run after the prefix is then capitalized as written, running on through its marks the way _sub_words does.""" lead = _past_modifiers(chunk, 0) mac = _MAC.match(unicodedata.normalize("NFC", chunk[lead:])) if not mac: return chunk[:lead] + chunk[lead:].capitalize() cut = end = lead + len(mac.group(1)) while run := _NAME_RUN.match(chunk, end): end = _past_marks(chunk, run.end()) at = _past_modifiers(chunk, cut) return (chunk[:lead] + chunk[lead:cut].capitalize() + chunk[cut:at] + chunk[at:end].capitalize() + chunk[end:]) def _past_modifiers(text: str, at: int) -> int: """`at` moved past the modifier letters (category Lm) at it.""" while at < len(text) and unicodedata.category(text[at]) == "Lm": at += 1 return at def _past_marks(text: str, end: int) -> int: """`end` moved past the combining marks (category M) at it.""" while end < len(text) and unicodedata.category(text[end])[0] == "M": end += 1 return end def _sub_words(cap: Callable[[str], str], text: str) -> str: """`text` with each word replaced by cap(word): what _WORD.sub(cap, text) does, except that a word runs on through the combining marks after it (#542). A combining mark is category M, which \\w does not match, so a name typed decomposed (NFD) -- 'garci' + U+0301 + 'a' -- would otherwise be two words, and the 'a' capitalized alone is 'A'. A mark heads no word of its own: one with no word before it stays outside every word, since a word starting with it would keep its letters lowercase under the fall-through, which passes over a modifier letter (Lm) and not a mark (M).""" spans: list[list[int]] = [] for match in _WORD.finditer(text): start, end = match.start(), _past_marks(text, match.end()) if spans and spans[-1][1] == start: spans[-1][1] = end else: spans.append([start, end]) out: list[str] = [] at = 0 for start, end in spans: out += (text[at:start], cap(text[start:end])) at = end return "".join(out) + text[at:] def _joined_whole(text: str, role: Role, lex: Lexicon) -> str | None: """`text` repaired as ONE word where it holds a hyphen or slash and the vocabulary records it whole: by its recorded casing (a mask, in any role, as _cap_word applies it), else in capitals as a listed acronym credential in the suffix role ('pa-c', 'otr/l'). None where neither holds and the per-word walk takes the token (#808).""" if "-" not in text and "/" not in text: return None normalized = _normalize(text) undotted = normalized.replace(".", "") for key in (normalized, undotted): mask = lex.capitalization_exceptions_map.get(key) if mask is not None: masked = _apply_mask(text, mask) if masked is not None: return masked if role is Role.SUFFIX and undotted in lex.suffix_acronyms: return text.upper() return None def _cap_text(text: str, role: Role, tags: frozenset[str], lex: Lexicon) -> str: # word-by-word within the token text: hyphenated names capitalize # both sides ("macdole-eisenhower" -> "MacDole-Eisenhower"). The # per-word walk is also why an UNCLASSIFIED token gets the # vocabulary asked per word: the parse would have made one token # per word of that text, so this is the granularity its answer # would have had. def cap(word: str) -> str: return _cap_word(word, role, tags, lex) # A particle written with a mark ("d'", "az-", "'t", #700) is one # vocabulary word that the per-word walk below would split at the # mark, asking the particle test of its stem ('d', 'az', 't') # instead -- so rules/R4's "a particle is written in lowercase" # is asked of the whole token first. Only the lowercase is taken # here; wherever the arm declines (an all-particle part, a role # other than middle or family, an initial's spelling, a recorded # casing) the token goes to the walk exactly as before. A wholly # alphabetic token is one word to the walk, which asks _cap_word this # same arm of it, so only a token the walk could split pays here. if (not text.isalpha() and _lowers_as_particle(_normalize(text), text, role, tags, lex)): return text.lower() # rules/R4: "A credential acronym the exceptions map does not carry # is an initialism" -- written whole when a hyphen or slash joins # it, since the per-word walk below would capitalize 'otr/l' as the # two name words 'Otr/L'; and a recorded casing is laid over the # whole token for the same reason. decisions/R4 (#808). if not text.isalpha() and ( whole := _joined_whole(text, role, lex)) is not None: return whole if "-" not in text: return _sub_words(cap, text) parts = text.split("-") # A "named" part needs an alphanumeric, not just a _WORD match: # _WORD also matches a run of bare periods (or underscores), so a # part holding only punctuation -- the family TOKEN of 'jose # .-y-garcia' is '.-y-garcia', which splits to ['.', 'y', 'garcia'] # -- is not a worded neighbour and must not count as one. named = [at for at, part in enumerate(parts) if any(c.isalnum() for c in part)] if len(named) < 3: return _sub_words(cap, text) # rules/R4: "Inside a hyphenated word, a part that is # connective vocabulary with a worded part on each side of it # keeps its lowercase" (#478). Decided here, not in _cap_word, # whose word has lost its neighbours. Only a part holding an # alphanumeric is a neighbour: an empty one is skipped # ('garcia--y-lopez' still lowers its 'y') and supplies none # ('md-phd-'). An EDGE part stays ordinary name text ('juan e-f # smith' keeps 'E-F'), so no word is re-read as connective or # initial by its case (#458). A single letter with a period is an # initial, as classify reads it, never the connective # ('j.-e.-p. dupont' keeps 'E.'); the multi-letter 'und.' in # 'hans smith-und.-jones' still lowers. first, last = named[0], named[-1] return "-".join( part.lower() if (first < at < last and not _DOTTED_INITIAL.fullmatch( unicodedata.normalize("NFC", part)) and _normalize(part) in lex.conjunctions) else _sub_words(cap, part) for at, part in enumerate(parts)) def _split_pairs(tokens: tuple[Token, ...], repaired: tuple[Token, ...], lex: Lexicon, force: bool) -> tuple[Token, ...]: """`repaired` with each split credential group merged and read as a suffix -- classify tags its first word, and the merge marks its second `joined` -- recased as the one-word spelling is: the exceptions map's mask laid over the two words as written ('PHARM D' under 'PharmD' is 'Pharm D', 'MB CHB' under 'MBChB' is 'MB ChB'), else capitals for a listed acronym. Unforced, a pair the gate keeps as written is the pair written in more than one case, asked of the two words together as the one-word spelling is asked ('pharm D' as 'pharmD'), and it is left as written.""" out = list(repaired) i = 0 while i < len(tokens) - 1: a, b = tokens[i], tokens[i + 1] if ((SPLIT_MEMBER_TAG not in a.tags and SPLIT_CREDENTIAL_TAG not in a.tags) or "joined" not in b.tags or a.role is not Role.SUFFIX or b.role is not Role.SUFFIX): i += 1 continue joined = a.text + b.text if not force and not _in_one_case(joined): out[i], out[i + 1] = a, b i += 2 continue fold = _normalize(joined.replace(".", "")) mask = lex.capitalization_exceptions_map.get(fold) cased = _apply_mask(joined, mask) if mask is not None else None if cased is None and fold in lex.suffix_acronyms: cased = joined.upper() # a case mapping that changes the length ('ß') has no place to # cut the pair back into its two words if cased is not None and len(cased) == len(joined): cut = len(a.text) out[i] = Token(cased[:cut], a.span, a.role, a.tags) out[i + 1] = Token(cased[cut:], b.span, b.role, b.tags) i += 2 return tuple(out) def _in_one_case(text: str) -> bool: """R5's test: `text` is written all upper or all lower (a caseless text is both, and so passes).""" return text in (text.upper(), text.lower()) # rules/R4: "case repair returns a repaired copy and never mutates # the parse" def capitalized(name: ParsedName, lexicon: Lexicon | None, *, force: bool) -> ParsedName: """Case-fixing transform -> new ParsedName, same spans, new token texts. Gate: only a name whose words outside the suffix are written in one case is touched unless force=True; the gate reads the joined texts of every token not roled SUFFIX (#492) -- not render() output, so it stays decoupled from spec formatting and the #254 collapse. The same one-case test is then asked of each SUFFIX token on its own, and unless force=True one written in more than one case is kept as written -- 'EdD', 'B.Tech.', and the garbled 'Iii' alike -- while one written in a single case is repaired like any other token. Repair changes case and nothing else (see the rules/R4 citation in _cap_word): an exceptions-map value is a mask recasing the word as written (#459), never a replacement. The repair reads token TAGS as well as texts: a part whose every word is particle vocabulary is repaired as ordinary name words, and the mark saying so comes from the pipeline, as does the reading that a word is a conjunction rather than an initial. A token carrying UNCLASSIFIED_TAG -- replace() splices those in, and so does the facade's v1 pickle load -- was never read: the vocabulary answers the per-word conjunction question for it, and the per-part particle question is left to plain particle treatment, since re-deriving the part answer needs a tag on every word of the part and these have none. A family set that way to 'de la' stays 'de la' where the same words parsed give 'De La'; one set to 'de y' keeps the 'y' lowercase, as the parse does and as 1.4.0 did. Parser.revise() is the edit that classifies the value, and gives 'De La': the boundary of rules/R4's Accepted bullet "The all-particle clause reaches a part" (decisions/R4). Idempotent: every _cap_word rule, and the hyphen rule in _cap_text, is a fixpoint on its own output, so a repaired name comes back unchanged whether or not the gate admits it again (a name whose non-suffix words are caseless, 'Kim Minjun' in hangul with a 'phd', is admitted every time) -- except where a mask's own per-character casing fallback lengthens a letter (decisions/R4's Unicode boundary): 'ß' upper is 'SS', a different LETTER SEQUENCE rather than one recased letter, so the repaired word's own folded spelling ('a.ss') no longer matches the exceptions map's key ('a.ß'), and a second forced pass over that output cannot find the entry the first pass did. Plain title case does not lengthen that way: 'ʼn' (a single letter) upper-cases to 'ʼN', whose apostrophe is a modifier letter the fall-through passes over, so 'ʼNa' comes back as 'ʼNa'. A letter that upper-cases to a base letter plus a COMBINING MARK ('ǰ' to 'J' + a combining caron) is not such a case since #542: a word runs on through its marks (_sub_words), so 'J̌o' is one word on the second pass and comes back unchanged. A name typed decomposed (NFD) repairs as its composed twin does and keeps the form it was typed in: the splitter and the mask read a mark as part of the letter before it, and the shape tests (Mac/Mc, the initial tests) ask about the composed spelling.""" if lexicon is not None and not isinstance(lexicon, Lexicon): # eager, before the gate: a garbage argument must not become a # silent no-op on mixed-case input or a deep AttributeError raise TypeError(f"lexicon must be a Lexicon or None, got {lexicon!r}") lex = Lexicon.default() if lexicon is None else lexicon # rules/R5: "case repair acts only on a name written entirely # in one case" -- and "the suffixes are left out of that test": a # credential or a generation written the way one is written # ('III', 'PhD', 'Jr.') says nothing about how the writer cased # the NAME (#492). Titles stay in because title repair is not yet # trusted to act on a cased title (decisions/R5 names the three # titles that showed why). gate = " ".join(t.text for t in name.tokens if t.role is not Role.SUFFIX) if not force and not _in_one_case(gate): return name # rules/R5: "a suffix written in more than one case is the # writer's spelling and is kept as written where repair was not # forced" -- the gate's own test, asked of each suffix token. The # gate leaves the suffixes out because a cased one says nothing # about the NAME; read to its end, that also means repair has no # business re-spelling it ('EdD' stays 'EdD', where the acronym # clause would write 'EDD'). The cost is the garbled spelling kept # with the deliberate one ('Iii' stays 'Iii'); force repairs both. new_tokens = tuple( t if (not force and t.role is Role.SUFFIX and not _in_one_case(t.text)) else Token(_cap_text(t.text, t.role, t.tags, lex), t.span, t.role, t.tags) for t in name.tokens) # rules/R4: "Repair changes case and nothing else" -- and a # credential split across a space (rules/S12) is repaired as its # one-word twin is, the pair's fold asked once; a loop over the # tags first, so a name with no such pair pays no frame for it for t in name.tokens: if SPLIT_MEMBER_TAG in t.tags or SPLIT_CREDENTIAL_TAG in t.tags: new_tokens = _split_pairs(name.tokens, new_tokens, lex, force) break # equal tokens (possible only for synthetic span=None duplicates) # collapse to one mapping entry -- benign: the rebuilt ambiguity # references an equal token, so the subset invariant still holds replacement = dict(zip(name.tokens, new_tokens)) new_ambiguities = tuple( Ambiguity(a.kind, a.detail, tuple(replacement[t] for t in a.tokens), a.alternative) for a in name.ambiguities) return ParsedName(original=name.original, tokens=new_tokens, ambiguities=new_ambiguities) derek73-python-nameparser-456b024/nameparser/_types.py000066400000000000000000002010641527303271700227240ustar00rootroot00000000000000"""Core value types for the 2.0 API. Layering (enforced by tests/v2/test_layering.py): this module imports nothing from nameparser at module level -- it is the bottom of the module-import dependency graph. The rendering delegates import _render and matches() imports _parser at call time; TYPE_CHECKING-only imports supply the Lexicon/Parser annotations. Repr policy (applies to every v2 type's __repr__, across this module and _lexicon.py/_policy.py/_locale.py): bounded output only. No repr may scale with vocabulary size -- collections render as counts or deltas, never contents. """ from __future__ import annotations import dataclasses from collections.abc import Callable, Mapping from dataclasses import dataclass, field from enum import Enum, StrEnum from typing import TYPE_CHECKING, NamedTuple, NoReturn, TypeVar if TYPE_CHECKING: from nameparser._lexicon import Lexicon from nameparser._parser import Parser class Role(StrEnum): """The seven fields of a parsed name, one per :class:`Token`. Declaration order is the canonical field order everywhere (``as_dict()``, ``comparison_key()``, rendering). A StrEnum, like :class:`AmbiguityKind`: members ARE their string values, so ``token.role == "given"`` compares directly and ``str(Role.GIVEN) == "given"``. Members order as strings, so ``sorted()`` yields alphabetical order -- iterate ``Role`` itself for the canonical order.""" # Declaration order IS the canonical field order: # every listing of the seven fields anywhere derives from this. #: Pre-nominal titles and honorifics ("Dr.", "Sir", "Capt."). TITLE = "title" #: The given (first) name, or its initial. GIVEN = "given" #: Names between given and family -- middle names or initials. MIDDLE = "middle" #: The family (last) name, including any particles ("de la Vega"). FAMILY = "family" #: Post-nominal pieces ("III", "Jr.", "PhD"). SUFFIX = "suffix" #: Delimited nickname content ("Jonathan 'Jack' Kennedy" -> "Jack"). NICKNAME = "nickname" #: A birth surname, from a marker word ("Jane Smith née Jones" -> #: "Jones") or a delimiter pair routed via Policy.maiden_delimiters. MAIDEN = "maiden" class Span(NamedTuple): """Where a :class:`Token` came from: a character range into :attr:`ParsedName.original` such that ``original[start:end]`` is the token's source text (``end`` exclusive). A plain two-int NamedTuple; ``None`` in :attr:`Token.span` marks a synthetic token with no source position.""" #: First character index (0-based). start: int #: One past the last character index. end: int def __add__(self, other: object) -> NoReturn: # type: ignore[override] # Inherited tuple + would concatenate two spans into a 4-tuple. # There is deliberately NO covering-span operation: grouping is # index-run based (the anti-#100 invariant: never resolve by # joining text back together) and never merges spans. raise TypeError( "Span does not support +; tuple concatenation is not a " "covering span" ) #: The four :attr:`Token.tags` values that are stable API. #: "particle" marks a word from the particle vocabulary ("de", "van") #: wherever it lands -- including a given-name "Van" -- so combine it #: with Role.FAMILY to get particle-vocabulary family words -- but #: NOT to reproduce `family_particles`, which since #404 also consults #: UNJOINED_TAG and excludes a particle standing alone in its part #: ("Anh Do" has a particle-tagged family word and no family #: particles); "conjunction" a joining word ("and", "y"); "initial" a #: word READ as an initial -- initial-shaped in a script that HAS #: initials ("J." or "А.", never "씨.", #320), or a marked #: single-letter connective in a name written in one case #: (rules/P8, see CONJUNCTION_OR_INITIAL), or any other bare letter #: in a name whose own words are written in one case, where its #: capital would be one ("j" as "J", never "é", rules/T4); #: "joined" a continuation of the token before it -- within one #: merged piece the tag is role-blind and every view joins the pair #: with a space ("Ph." + "D."; 'Ph. D., Jr.' gives last_list #: ['Ph. D.']), and since #436 it also spans the pieces of one SUFFIX #: entry, the run of post-nominals the writer wrote without a comma #: ("MD PhD"), which is the half the suffix view alone reads and #: joins with a space instead of ", ". Every other tag is namespaced #: ("vocab:...") and is unstable debugging provenance -- never match #: against those. #: This prose is the hand-maintained twin of docs/modules.rst's #: STABLE_TAGS block; nothing pins the two against each other (the #: test only compares the frozenset), so edit both or neither. STABLE_TAGS = frozenset({"particle", "conjunction", "initial", "joined"}) #: A name part whose every word is particle vocabulary is a part #: where none of them is doing a particle's work -- nothing joins them #: to a name -- so FOUR views read them as ordinary name words: they #: anchor `family_base`, drop out of `family_particles`, contribute #: initials (rules/R2), and capitalize like any other name word #: rather than being lowercased as particles (#407). #: MARKED rather than untagged: `particle` is stable API and says the #: word IS particle vocabulary wherever it lands, which stays true, and #: keeping it leaves a later rule free to report the fork this decides. UNJOINED_TAG = "vocab:unjoined-particle" #: A name part holding no word a connective could join -- once the #: part's WORKING particles are set aside -- is a part where the #: connective is not doing a connective's work, so it initials like an #: ordinary name word, agreeing with `family_base` (rules/R3, #: #461). The twin of UNJOINED_TAG above, computed in the same pass #: and for the same reason: the fact is about a PART, so it is decided #: once where the parts are settled rather than re-derived by each #: view -- which is how `initials()` and `family_base` came apart. #: A SEPARATE marker rather than a widening of UNJOINED_TAG, because #: that one is also read by family_particles/family_base and by #: _cap_word, and none of those three may move. UNJOINED_CONJUNCTION_TAG = "vocab:unjoined-conjunction" #: The one sanctioned view-reorder marker (namespaced = unstable API). #: Tokens cannot reorder (span order is validated), so a role fold that #: must render BEFORE the role's original tokens tags them with this; #: _text_for, _render.initials and the facade lists all prepend the #: carriers. Single-sourced here so the emitter (_pipeline/_post_rules) #: and the consumers cannot drift -- initials() was the consumer that #: did drift, walking written order until #408. FOLDED_TAG = "vocab:folded-middle" #: Raw text spliced into a field after the parse, which no parse ever #: read: `ParsedName.replace()` stamps it, and so does the facade's #: v1 pickle load, which rebuilds a name from `*_list` strings alone. #: A view that is HANDED a vocabulary falls back to it for a token #: carrying this -- there is no decision to honor -- and reads every #: other token by its tags (mechanisms.md#RENDER-HONORS-THE-PARSE). #: `capitalized(lexicon=...)` is the one such view; `initials()` takes #: no lexicon and so honors tags alone, marked or not. #: The absence of a SPAN is not that signal and was tried as one: a #: span-less token is SYNTHETIC, which `Parser.revise()` also builds, #: from a full sub-parse whose tags it deliberately keeps. Keying the #: fallback on the span overrode exactly those tags. Absence of TAGS is #: not the signal either -- an ordinary parsed name word carries none. UNCLASSIFIED_TAG = "vocab:unclassified" #: The by-shape half of #289/#516's ambiguous credential class: the #: tag classify writes on a token it admits to the credential reading #: by its SHAPE rather than by listed vocabulary (`Policy. #: unlisted_dotted_suffixes` is the first emitter, `Policy. #: unlisted_caps_suffixes` at `CapsSuffixes.EVERYWHERE` the second, #: classify writing the tag from both branches). Defined here, at the bottom of the graph, because #: a render view reads it as well as the pipeline: case repair writes #: such a suffix in capitals as it does a listed acronym (#459), and #: _render may not import _pipeline. One constant, not a string #: literal at each site, because the readers that must tell a #: by-shape member apart from a listed one -- `_pieces.peel_trailing` #: and `_pieces.listed_lean`, which `segment_suffix_reading` asks #: through, so the reading it decides is second-hand -- cannot afford #: to spell it several ways and have one of them typo silently past #: the others. `_pipeline/_state.py` re-exports it beside the #: membership half, since the stages read both halves from one module. SHAPE_ACRONYM_TAG = "shape:acronym" #: The tags classify writes on the FIRST word of a credential written #: split across a space (`_pipeline._vocab.split_pair`), naming the #: piece tag the pair merges with: SPLIT_CREDENTIAL_TAG the Ph./D. pair, #: merged as an unambiguous suffix ("suffix"), and SPLIT_MEMBER_TAG any #: other, merged as a member of the ambiguous class ("split", rules/S12). #: Written by classify because it is the one stage that sees every #: adjacent pair with the lexicon in hand, so group's merge and #: `_comma._pieces` read the decision off a tag, C-level, rather than #: each asking the vocabulary of every pair; and defined here, at the #: bottom of the graph, because case repair reads them too, repairing #: a split credential in the suffix as its one-word twin. SPLIT_CREDENTIAL_TAG = "split:credential" SPLIT_MEMBER_TAG = "split:member" #: The one-element tag set its two producers stamp, built once. _UNCLASSIFIED = frozenset({UNCLASSIFIED_TAG}) _E = TypeVar("_E", bound=Enum) def _coerce_enum(value: object, enum_cls: type[_E], noun: str, plural: str) -> _E: """Coerce value to enum_cls, or raise the enriched ValueError listing every valid member (enum lookups stay ValueError for any input -- stdlib EnumType precedent, see AGENTS.md's taxonomy rule).""" if isinstance(value, enum_cls): return value try: return enum_cls(value) except ValueError: valid = ", ".join(str(m.value) for m in enum_cls) raise ValueError( f"unknown {noun} {value!r}; valid {plural}: {valid}" ) from None # Pickle support shared by the frozen slots dataclasses: fail at the # LOAD site when a pickle's field layout does not match this version of # the class (version skew) -- silently loading would defer the failure # to a distant attribute read. Values are deliberately NOT re-validated: # pickle is not a security boundary (arbitrary pickles can execute code # anyway), and canonical state only comes from a validated instance. # These are ASSIGNED IN EACH CLASS BODY (not inherited from a mixin): # @dataclass(slots=True) regenerates the class and installs its own # pickle methods unless __getstate__/__setstate__ are in the class's # own __dict__. Lexicon duplicates this logic by design (its slots also # carry a rebuilt mappingproxy) -- layering keeps _lexicon import-free # of _types. def _guarded_getstate(self: object) -> dict[str, object]: fields = dataclasses.fields(self) # type: ignore[arg-type] return {f.name: getattr(self, f.name) for f in fields} #: The metadata key a field added after a type first shipped carries, #: naming the release that added it. Such a field must have a default, #: and it is the ONE skew `_guarded_setstate` accepts: a pickle written #: before the release lacks it, and loads with the default (2.7, #752). #: A field without the key -- every field a type shipped with -- is #: still required, default or not, so a pickle missing `Token.tags` is #: refused as it always was. ADDED_IN = "nameparser.added_in" def _guarded_setstate(self: object, state: dict[str, object]) -> None: fields = dataclasses.fields(self) # type: ignore[arg-type] expected = {f.name for f in fields} added = {f.name: f for f in fields if ADDED_IN in f.metadata and f.default is not dataclasses.MISSING} missing_set = expected - set(state) - set(added) if missing_set or not set(state) <= expected: missing = ", ".join(sorted(missing_set)) or "none" unexpected = ", ".join(sorted(set(state) - expected)) or "none" raise ValueError( f"incompatible {type(self).__name__} pickle: missing " f"fields: {missing}; unexpected fields: {unexpected}" ) for name, f in added.items(): if name not in state: object.__setattr__(self, name, f.default) for name, value in state.items(): object.__setattr__(self, name, value) @dataclass(frozen=True, slots=True) class Token: """One classified word of a parsed name: its text, where it came from, which field it belongs to, and how it was classified. Read tokens off :attr:`ParsedName.tokens` or :meth:`ParsedName.tokens_for`; you only construct one directly when hand-building a :class:`ParsedName`.""" #: The word exactly as written in the input (never empty). text: str #: Position in ParsedName.original; None marks a synthetic token #: (e.g. introduced by replace()) with no source position. span: Span | None #: The field this token belongs to. role: Role #: Classification labels. Exactly the four members of #: :data:`~nameparser.STABLE_TAGS` ("particle", "conjunction", #: "initial", "joined") are API; namespaced tags like "vocab:..." #: are unstable debugging provenance -- never match against them. tags: frozenset[str] = frozenset() # in the class body so @dataclass(slots=True) keeps them __getstate__ = _guarded_getstate __setstate__ = _guarded_setstate def __post_init__(self) -> None: if not isinstance(self.text, str): raise TypeError( f"Token.text must be a str, got {self.text!r}" ) if not self.text: raise ValueError("Token.text must be a non-empty string") object.__setattr__( self, "role", _coerce_enum(self.role, Role, "Role", "roles")) if self.span is not None: if not ( isinstance(self.span, tuple) and len(self.span) == 2 # bool is an int subclass: (False, True) is a comparison # result leaking into a coordinate slot, not a span and all(isinstance(v, int) and not isinstance(v, bool) for v in self.span) ): raise TypeError( f"invalid span {self.span!r}: expected a (start, end) " "pair of ints or None" ) start, end = self.span if start < 0 or end < start: raise ValueError( f"invalid span ({start}, {end}): need 0 <= start <= end" ) object.__setattr__(self, "span", Span(start, end)) # The same guards _normset applies to Lexicon vocabulary: a bare # string would become its character set, a mapping would silently # contribute only its keys. if isinstance(self.tags, str): raise TypeError( "Token.tags must be an iterable of strings, " "not a bare string" ) if isinstance(self.tags, Mapping): raise TypeError( "Token.tags must be an iterable of strings, not a mapping" ) tags = frozenset(self.tags) for tag in tags: if not isinstance(tag, str): raise TypeError( f"Token.tags must contain only strings, got {tag!r}" ) object.__setattr__(self, "tags", tags) def __repr__(self) -> str: # Bounded output: a single token's text/span/role/tags, never # scales with vocabulary size (design rule -- see module docstring). where = (f"@{self.span.start}:{self.span.end}" if self.span is not None else "@synthetic") tags = f" {{{', '.join(sorted(self.tags))}}}" if self.tags else "" return f"Token({self.text!r} {where} {self.role.name}{tags})" @dataclass(frozen=True, slots=True) class Segmentation: """A segmenter's answer for one unspaced token: the interior offsets to split at (each offset begins a new piece, so ``Segmentation((2,))`` cuts a three-character token into ``token[:2]`` and ``token[2:]``; strictly ascending, each >= 1 -- an index protocol, so a segmenter physically cannot invent, drop, or rewrite characters) and an optional confidence in [0, 1]. ``Segmentation(())`` means "confidently one token" -- distinct from returning None, which DECLINES ("I don't know"). The upper bound (< len(token)) is the half this class cannot check, never having seen the text; the consuming stage checks it and RAISES ``ValueError`` on a violation, the same call it makes on an answer of the wrong type -- both are protocol bugs in the segmenter, not facts about the name.""" #: Interior character offsets to split at, ascending. splits: tuple[int, ...] #: How sure the segmenter is, or None for "no opinion". confidence: float | None = None # in the class body so @dataclass(slots=True) keeps them __getstate__ = _guarded_getstate __setstate__ = _guarded_setstate def __post_init__(self) -> None: # Both guards Token.tags carries, for the same two reasons and # a third of this field's own: a bare string is iterable, so # Segmentation("") would sail through as "confidently one # token" -- an opinion nobody stated -- and Segmentation("23") # would fail one character deep naming '2' rather than the # argument. A mapping would silently contribute only its keys, # and a bare int would surface as an uncurated "not iterable" # from the tuple() below. if isinstance(self.splits, str): raise TypeError( "Segmentation.splits must be an iterable of integers, " "not a bare string" ) if isinstance(self.splits, Mapping): raise TypeError( "Segmentation.splits must be an iterable of integers, " "not a mapping" ) try: iter(self.splits) except TypeError: raise TypeError( f"Segmentation.splits must be an iterable of integers, " f"got {self.splits!r}" ) from None # OUTSIDE the try on purpose: iter() cannot run a generator's # body, but tuple() can, and a TypeError raised in there is the # caller's own bug -- relabeling it "must be an iterable" would # send the reader to the wrong place entirely. splits = tuple(self.splits) for offset in splits: # bool is an int subclass: True as an offset is a comparison # result leaking into an index slot, not a split point if isinstance(offset, bool) or not isinstance(offset, int): raise TypeError( f"Segmentation.splits must be integers, got {offset!r}") if offset < 1: raise ValueError( f"Segmentation.splits must be interior offsets " f"(each >= 1), got {offset}") if any(b <= a for a, b in zip(splits, splits[1:])): raise ValueError( f"Segmentation.splits must be strictly ascending, " f"got {splits!r}") object.__setattr__(self, "splits", splits) conf = self.confidence if conf is not None: if isinstance(conf, bool) or not isinstance(conf, (int, float)): raise TypeError( f"Segmentation.confidence must be a float or None, " f"got {conf!r}") # stored as given, not coerced to float: an int 1 is a valid # confidence and the range check is what the callers rely on if not 0.0 <= conf <= 1.0: raise ValueError( f"Segmentation.confidence must be within [0, 1], " f"got {conf!r}") #: The segmenter hook's shape: token text in, :class:`Segmentation` out, #: None to decline. Plug one in via ``Parser(segmenter=...)``. Segmenter = Callable[[str], Segmentation | None] class AmbiguityKind(StrEnum): """The stable vocabulary of :class:`Ambiguity` kinds. A StrEnum: members ARE their string values, so ``kind == "particle-or-given"`` compares directly. New kinds may be added in minor releases; existing values never change meaning. A kind names a FORK THE PARSE HAD TO CALL, not a word that could be read two ways, except where a member says otherwise: the same token elsewhere in a name may present no choice at all and is then reported by nothing. COMMA_STRUCTURE and UNBALANCED_DELIMITER are the two that say otherwise -- each reports a shape the parse could not recognize rather than a fork it chose between, and each says so on its own member below. Reporting is also partial -- a kind listed here is not necessarily emitted everywhere its fork occurs (the comma's structure decision reports no reading by design, except where it decides a member of the ambiguous credential class, where since 2.4 that decision is reported either way -- #289; an attachment decided AFTER a family comma is a separate fork and has reported on its own since 2.3, e.g. "Berg, Jan vd"; the two structural kinds above were never covered by either silence), and coverage grows over releases. A non-empty tuple is a signal to act on; an empty one is not a guarantee of certainty.""" #: Reserved: the name's field order itself is uncertain (e.g. a #: two-word name under a non-default name_order). Not yet emitted; #: planned for 2.x. A lone name word is GIVEN_OR_FAMILY's, not #: this. ORDER = "order" #: Delimited content is an ambiguous suffix acronym, so it reads #: plausibly as either a post-nominal or a nickname -- "JEFFREY #: (JD) BRICKEN" keeps the nickname reading, where the #: unambiguous "(MBA)" escapes to suffix on vocabulary alone, and #: "Andrew Perkins (JD)" on its capitals, which settle it. SUFFIX_OR_NICKNAME = "suffix-or-nickname" #: A trailing word reads plausibly as either a post-nominal or an #: ordinary name part. Covers an ambiguous acronym written without #: periods ("JOHN SMITH MA" takes MA as a credential because a #: family name remains; "JACK MA", written wholly in one case, keeps #: it as the name because none would, while in a mixed-case name #: the capitals decide outright -- "Jack MA" is the credential and #: "Jack Ma" the name, rules/S4 and rules/S6) and a trailing roman #: numeral, which is a suffix where any #: other single letter would be a name ("John Smith V" vs "John #: Smith B"). It also covers a family-comma listing, where a #: trailing abbreviation that is both a post-nominal and a surname #: particle joins the surname the comma already named: "Berg, Jan #: vd" takes ``vd`` for *van der* and declines the decoration. #: Which name part was declined depends on position and #: ``name_order``, so ``detail`` names it rather than the kind. #: Since 2.7 a listed member its capitals settle in a mixed-case #: name is not reported at any slot below, as one written with its #: periods is not ("John Smith MA", "Doe, John DO", "John Smith, #: MA" are silent, rules/S6, #785), so the examples below that #: report are written in one case, in lower case or in Title case. #: The same doubt covers an input that is nothing BUT post-nominal #: vocabulary ("Rinpoche", "QC MP"): with no name word beside it #: the first post-nominal is read as the name, because something #: has to be one, and only that word reports. #: WHERE it is emitted is narrower than where the doubt exists, #: and this is the boundary rather than an omission to be read #: past. The emitters cover the trailing slot of a name, the FIRST #: PIECE after a family comma -- that piece and no further -- the #: trailing slot of that listing's GIVEN part, and the trailing #: slot of a maiden marker's clause. A part beyond the second #: comma is not among them, so "Smith, John, MA" is silent; what #: such a part does report is rules/C2's to say. #: Since 2.4 the given part's trailing slot reports whichever way #: it read the word: "DOE, JOHN MA" reads suffix ``MA`` and says #: so, "Doe, John Ma" keeps middle ``Ma`` and says so too. Not in #: EVERY direction, though: where the member is also a particle #: and the particle rule keeps it, that rule reports at its own #: site and this kind stays out of the way -- "Doe, John do" gives #: family ``do Doe`` and one ``PARTICLE_OR_GIVEN``, never two #: reports of one word. #: Since #544 C4's name-word count flips, reported once (C11), over a #: whole RUN after the comma ("John Smith, Ed Ma" reads suffix #: ``Ed Ma``), and a member an unambiguous credential in front of #: it speaks for (rules/S7) is a pick like a counted one, #: reported where it was decided: by the peel, by the given part's #: trailing slot, or by the reading of a part after a family comma #: that the company leaves holding no name word ("John Smith PhD #: MEng", "Doe, Jane PhD MEng" and "Smith, PhD MEng" each report #: ``MEng``). A member its own capitals already made the #: credential reports nothing, so "Smith, PhD MA" and "John #: Smith, PhD MA" read suffix ``PhD MA`` in silence. #: Since 2.4 a maiden marker's clause reports at ITS trailing slot #: too, in both directions: "Jane Doe nee Smith ma" gives maiden #: ``Smith`` with suffix ``ma`` and says so, "Jane Doe nee Smith #: Ma" keeps maiden ``Smith Ma`` and says so too, and a member the #: clause keeps because it is the only word after the marker #: ("Jane Doe nee MA") reports as well. Where no trailing rule #: reads the clause's tail, nothing was decided and nothing #: reports: a clause in the FAMILY segment of a comma listing #: ("Smith nee Jones MA, Jane" keeps maiden ``Jones MA``) is #: silent. After any other comma a marker is an ordinary word and #: makes no clause at all (rules/M2). #: FOUR positions stay silent, and all four are boundaries rather #: than omissions. The NO-READER clause just named is the first of #: them, and the three that follow are about the member's own #: surroundings. Second: a member with something BEHIND it that #: the trailing reading does not take was never a fork -- "Doe, #: John ma Smith" reads middle ``ma Smith``, the ordinary reading, #: and nothing consulted the class, and inside a clause the same #: holds of a name word ("Jane Doe nee MA Smith" keeps maiden #: ``MA Smith``), a bare trailing title among them ("Jane Doe nee #: Smith ma Prof" keeps maiden ``Smith ma Prof``, where the #: period-marked "Jane Doe nee Smith ma Prof." reads title #: ``Prof.`` and suffix ``ma`` and reports). A member its capitals #: settle is no such member: it starts the credential's run #: (rules/S8), so "Doe, John MA Smith" reads suffix ``MA Smith``. #: Third -- pre-existing, and untouched by 2.4 -- a member with no #: name word IN FRONT of it is not at this #: slot either, because the slot is the end of a given part and #: there is none: a TITLE or a POST-NOMINAL took that position -- #: either one leaves the segment with no name word for the slot to #: be the end of. "Doe, Dr. MA" gives #: suffix ``MA`` and "Doe, Mr. MA PhD" suffix ``MA PhD``, the #: credential-run gate reading those segments whole; "Doe, Dr. Ma" #: reaches the walk instead and makes ``Ma`` the given name #: itself, which the walk starts above. The first-piece emitter #: does not cover for it, reading the piece that stands #: immediately after the comma and nothing behind that piece: #: "Doe, MA Smith" reports its ``MA``, and "Doe, Dr. MA Smith" -- #: the same member, one title in front of it -- reads given ``MA`` #: in silence. A maiden marker cannot reach this position: after #: a family comma it is an ordinary word (rules/M2), so "Doe, #: Dr. nee Smith ma" reads given ``nee``, middle ``Smith`` and #: suffix ``ma`` -- and reports, the ``ma`` ending a given part #: like any other. #: And fourth -- pre-existing and untouched by 2.4 as well -- #: a JOIN beside the member can #: take it out of this slot, from either side. Where a chain has #: swallowed the member into one piece there is no lone member to #: ask about, and the member may as well HEAD that piece as trail #: it: "Doe, John van Ma" reads middle ``van Ma`` in silence, and #: so does "Doe, John DO Ed", where the member is itself the #: particle the chain runs on and the name word behind it joins #: the piece -- though "Doe, John DO" alone reads the credential, #: its capitals settling it. The particle chain is the commonest #: joiner but not the only one: the bound-given join takes a member #: into its pair the same way, so "Berg, abdul ma" reads given #: ``abdul ma`` in silence; neither joins a member its capitals #: settle ("Berg, abdul MA" reads suffix ``MA``). Where a particle the suffix vocabulary does not #: also claim as an unambiguous word stands BEHIND it, the given part ends at #: that particle as this walk reads it, and the attachment that #: moves the particle to the family runs a stage too late to #: re-open the question -- so "Doe, John ma do" keeps middle #: ``ma`` beside family ``do Doe``, reporting only the #: attachment's own ``PARTICLE_OR_GIVEN``. Neither half is a rule #: about particles as such, and the caps spellings show it: "Doe, #: John van MA" reads family ``van Doe``, suffix ``MA``, reporting #: the particle's fork alone, and "Doe, John MA vd" reads suffix #: ``MA`` past a ``vd`` the suffix vocabulary claims outright. #: All four POSITIONS above are silent -- those last two names #: are the boundary each one stops at, not instances of it. #: A DELIMITED maiden clause is quiet for a different reason and #: is not one of the four. Where a recognized marker stands #: inside a delimited span the whole span is the maiden name, #: whatever its last word is and whether or not the pair is a #: configured maiden delimiter: "Jane Doe (nee Smith MA)" and #: "Jane Doe (nee Smith Ma)" both give maiden ``Smith MA`` / #: ``Smith Ma`` and report nothing. The writer drew the boundary, #: so no fork was available to decline -- a settled position #: rather than a fork left un-asked. The boundary cuts both ways: #: "Jane Doe (nee Smith) ma" gives suffix ``ma`` and reports, the #: member standing outside the span. SUFFIX_OR_NAME = "suffix-or-name" #: An input the title peel eats down to one last word which is #: itself title vocabulary still has to name somebody, so that #: word is read as the name -- "Lord Chancellor" gives family #: "Chancellor". A convention, not evidence: this is a name parser #: rather than a title parser, and handed a string whose every #: remaining word is a title the alternative is a title with no #: name at all. ``detail`` names that word. The report is made #: before the rule that moves the word between fields, so the peel #: shape reports under either order and names no field; the join #: shape's ``detail`` names the field it was placed in. The peel #: shape points at one token, the join shape at the whole unit. #: A LONE title word reports nothing: "Dr." reads as a title #: standing by itself, the peel left no word to be read as a #: name, and no fork was taken. #: One name word that is a JOIN carrying title vocabulary reports #: this kind too ("John of Bishop"): the unit is read as a name, #: and whether the title word inside it is a title is the fork. #: Not a word listed only as a title ("Baron"), whose collision #: with a name is a question about the VOCABULARY and not this kind. #: A title that may also be a given name ("Dean", rules/H9) reports #: it too, where it opens the name outside a title phrase and no #: comma has marked the given name: "Dean Smith" gives given "Dean". #: Only a word some phrase of ``Lexicon.title_phrases`` holds #: reports, so "Grace Smith" does not. TITLE_OR_NAME = "title-or-name" #: An ambiguous particle is either a particle or a name in its own #: right -- "Van Johnson" is the actor's given name, a bare #: "Van Buren" the presidential surname, and the two-word shape #: cannot distinguish them. Three #: shapes report this kind, decided in different stages, and #: ``detail`` is what tells them apart. A particle left standing #: alone chained nothing and was assigned a role, which ``detail`` #: names ("read as a given name") -- that role is whatever #: assignment gave it, so it follows ``name_order`` and any #: ``script_orders`` entry, which is why the kind cannot name it. #: A particle that something ahead of it shifted off the front of #: the name was instead claimed by the prefix chain, and ``detail`` #: says that and names no field at all: grouping runs before roles #: exist, so that text is the same under every order. Since #367 a #: plain title is not such a thing -- "Dr. Van Johnson" reads as #: the untitled "Van Johnson" does and takes the first shape -- #: and what remains is a leading word that is both a title and a #: particle, so it stays a name piece and the particle behind it is #: genuinely not leading ("Freiherr von Richthofen"). #: The third shape is at the TAIL rather than the head: a family #: comma names the surname, and a particle written behind the given #: name after it -- "Beethoven, Ludwig van", the Dutch and Flemish #: filing convention -- is attached to that surname, over its #: reading as an ordinary name word. The comma settles which piece #: is the family and nothing about this, so the fork is real and #: ``detail`` names the word it turned on. PARTICLE_OR_GIVEN = "particle-or-given" #: A single-letter connective in a name written wholly in ONE case #: -- all upper or all lower alike -- where the writing therefore #: says nothing about which reading was meant. The letter is read as #: an INITIAL and this reports the fork: "jose e maria santos" gives #: middle "e maria" and "JOSE E MARIA SANTOS" middle "E MARIA", #: both flagged. Only a letter the vocabulary marks both ways #: reports -- ``Lexicon.conjunctions_ambiguous``, "e" and "i" by #: default -- because a letter outside it is not in doubt: "JUAN GARCIA Y #: LOPEZ" joins into family "GARCIA Y LOPEZ" and reports nothing, #: as does the Cyrillic "ХОСЕ И МАРИЯ САНТОС" -- whose join lands #: in GIVEN "ХОСЕ И МАРИЯ" rather than FAMILY, but reports nothing #: all the same. #: Four things never reach the fork. MIXED-case input decides on #: the writing instead, so "Jose E Maria Santos" reads the capital #: as an initial and "John e Smith" the lowercase letter as the #: connective, neither reporting. A CASELESS letter has no case to #: read, so Arabic "محمد و علي" keeps its connective silently. A #: MULTI-letter connective ("and", "та", "και") or a symbol ("&") is #: no initial's shape at any casing. And a letter inside a maiden or #: delimited clause never reaches the fork either (the own-words #: doctrine, rules/P3): appending " née Jones" or a nickname #: changes no report, because the clause's words were never #: eligible for it. #: A letter with no name word after it in its part -- nothing #: before a comma, a clause or the end, or only post-nominals and #: titles, as in "JOSE E", "rovira, i" or "jose e phd" -- connects #: nothing, so it is read as an initial and not reported. Where #: such a letter is a roman numeral at the end of the name the #: numeral fork reports it instead (SUFFIX_OR_NAME: "HENRY I", #: "john i phd"); one ending a comma part reports nothing #: ("rovira, i"). #: ``detail`` names the token, the kind naming neither the field nor #: the letter: which field the reading lands in follows the name's #: shape and its ``name_order``, the PARTICLE_OR_GIVEN precedent. CONJUNCTION_OR_INITIAL = "conjunction-or-initial" #: A name of one name word that nothing else decided had to be read #: as one field or the other, and both readings fit it equally well #: -- "Andrew", "Smith". The convention picks the given name under #: the default order and the family name under a declared #: family-first one, the same way every time, so ``detail`` names #: the field the convention chose rather than the kind naming it: #: the same reason PARTICLE_OR_GIVEN cannot. A name something DID #: decide reports nothing: a title, a maiden name, a family comma #: that names a family, a script whose own convention settles the #: order, or the vocabulary claiming the word (a particle, a bound #: given name, an initial's shape) each settle the reading, and a #: settled reading is not a fork. A nickname or a suffix standing #: BESIDE the one name word does not settle it -- "'Smitty' Jones #: Jr." and "Smith Jr." both report. GIVEN_OR_FAMILY = "given-or-family" #: A nickname/maiden delimiter opened without closing (or closed #: without opening); the text was kept as literal name content, so #: the tokens are the one the stray character ended up inside. #: NOT a fork the parse called: it reports a shape the parse could #: not recognize, which is the carve-out this enum's own docstring #: names for this member and for COMMA_STRUCTURE. That docstring #: said "each says so on its own member below" while only #: COMMA_STRUCTURE's did; this sentence is the other half #: (2026-09-18). #: Two cases leave that tuple empty: a character that lands in no #: token at all (inside a masked region), and an input with no #: alphanumeric content anywhere, which parses to an empty name -- #: the report survives because "was this malformed?" is the only #: question left, but there is no token for it to point at. #: ``parse("(")`` is the second case, not an exotic one. UNBALANCED_DELIMITER = "unbalanced-delimiter" #: More comma-separated segments than any recognized name shape; #: the parse is best-effort over the extra segments. COMMA_STRUCTURE = "comma-structure" #: A division of an unspaced CJK token that the parse had to #: choose, from either of the two things that can divide one. #: A VOCABULARY fork: more than one surname-supported split #: existed ("夏侯惇" was taken as 夏侯 + 惇, while 夏 + 侯惇 also #: matched), longest-match picked, and ``detail`` names both #: readings (#271). Or a SEGMENTER answer scoring under the #: stage's confidence floor: only one reading was offered, but the #: score says it was a statistical guess rather than a stated #: certainty, and ``detail`` names the pieces and the score #: (#272). Either way it points at ALL the tokens the division #: produced -- two for a vocabulary split, n+1 for a segmenter #: answer cutting n times. SEGMENTATION = "segmentation" class ReadingKind(StrEnum): """What a :class:`Reading` reads a word as. A StrEnum, like :class:`AmbiguityKind`: members ARE their string values. New kinds may be added in minor releases; existing values never change meaning. The kinds are the parse's, not the vocabulary's: a reading a decision weighed is named by the category the decision asked about, so ``"credential"`` covers a word listed as one and a word only shaped like one, which :attr:`Reading.source` tells apart.""" #: A post-nominal credential or other acronym suffix ("MA", "X.Y.Z."). CREDENTIAL = "credential" #: A generational suffix: a roman numeral, or a word such as "fils". GENERATION = "generation" #: An agnomen, a family word that is also a suffix ("Filho", "Neto"). AGNOMEN = "agnomen" #: A listed post-nominal word of another kind ("Rinpoche", "Esq."). POST_NOMINAL = "post-nominal" #: A title ("Dean", "Prince"). The one value shared with a #: :class:`Role`, on purpose: a title reading is the title field's, #: so ``ReadingKind.TITLE == Role.TITLE`` says something true. TITLE = "title" #: A family-name particle ("van", "do"). PARTICLE = "particle" #: A connective joining the words beside it ("e", "i"). CONJUNCTION = "conjunction" #: An initial standing for a name word ("V" in "John V Smith"). INITIAL = "initial" #: An ordinary name word, given or family. NAME = "name" class ReadingSource(StrEnum): """Where a :class:`Reading` comes from. A StrEnum; new sources may be added in minor releases.""" #: The vocabulary lists the word in this reading's category ("MA" #: as a credential, "van" as a particle). VOCABULARY = "vocabulary" #: The vocabulary declares the word a name as well as listing it in #: another category: "Ma" as a surname, "Dean" as a given name. DECLARED = "declared" #: The word's shape, not a list: a roman numeral, letters run #: together with periods ("X.Y.Z."), a word in capitals. SHAPE = "shape" #: Nothing lists or shapes the word into this reading; it is the #: reading any word has where it stands, an ordinary name word. A #: word a credential run took by position alone ("Jones" in "John #: Smith PhD Jones") has this as its alternative, where "Ma" in #: "JOHN SMITH MA" has :attr:`DECLARED`. POSITION = "position" @dataclass(frozen=True, slots=True) class Reading: """One way of reading a word, as a decision of the parse weighed it. :attr:`Ambiguity.alternative` holds the reading the parse did NOT take, so a caller who would have read the name the other way knows which field the word would have gone to and why the reading was available at all.""" #: What the word is read as. kind: ReadingKind #: The field the word would land in under this reading, with every #: other word of the name read as the parse read it. role: Role #: Where the reading comes from: a listed word, a declared name, a #: shape, or position alone. source: ReadingSource # in the class body so @dataclass(slots=True) keeps them __getstate__ = _guarded_getstate __setstate__ = _guarded_setstate def __post_init__(self) -> None: # the members the parser passes need no coercion, and a report # pays no frame for it (decisions/parse-cost) if not isinstance(self.kind, ReadingKind): object.__setattr__( self, "kind", _coerce_enum(self.kind, ReadingKind, "ReadingKind", "kinds")) if not isinstance(self.role, Role): object.__setattr__( self, "role", _coerce_enum(self.role, Role, "Role", "roles")) if not isinstance(self.source, ReadingSource): object.__setattr__( self, "source", _coerce_enum(self.source, ReadingSource, "ReadingSource", "sources")) def __repr__(self) -> str: return (f"Reading({self.kind.value!r} as {self.role.value!r}, " f"{self.source.value!r})") @dataclass(frozen=True, slots=True) class Ambiguity: """A call the parser made that could legitimately have gone the other way, surfaced on :attr:`ParsedName.ambiguities` instead of silently guessed away. The parse still commits to one reading -- an Ambiguity is a flag for review, not an error.""" #: Which known ambiguity shape this is (stable API values). kind: AmbiguityKind #: Human-readable specifics of this occurrence (wording unstable). detail: str #: The tokens involved -- always a value-equal subset of the owning #: ParsedName's tokens (checked with ==, not identity: two distinct #: Token instances with identical text/span/role/tags satisfy this); #: may be empty (e.g. unbalanced-delimiter). tokens: tuple[Token, ...] #: The reading the parse did not take: what the tokens would have #: been read as, and the field they would have landed in, had the #: call gone the other way (the first token's field, for a report on #: more than one). None where the kind reports no fork between two #: readings of a word (comma-structure, unbalanced-delimiter, #: segmentation; ``order`` is reserved), and where the parse cannot #: place the other reading in any field: a connective letter #: opening its part ("E JOHN SMITH"), with no word before it to #: join, or a chained particle whose piece ends in a title a #: connective's join made ("Freiherr von Berg Dr. and Ed. Prof."). #: Not compared: two #: ambiguities equal without it, as before 2.7, and #: :meth:`ParsedName.comparison_key` ignores it. alternative: Reading | None = field( default=None, compare=False, metadata={ADDED_IN: "2.7"}) # in the class body so @dataclass(slots=True) keeps them __getstate__ = _guarded_getstate __setstate__ = _guarded_setstate def __post_init__(self) -> None: object.__setattr__( self, "kind", _coerce_enum(self.kind, AmbiguityKind, "AmbiguityKind", "kinds")) if (self.alternative is not None and not isinstance(self.alternative, Reading)): raise TypeError( f"Ambiguity.alternative must be a Reading or None, got " f"{self.alternative!r}" ) if not isinstance(self.detail, str): raise TypeError( f"Ambiguity.detail must be a str, got {self.detail!r}" ) if not self.detail: raise ValueError("Ambiguity.detail must be a non-empty string") toks = tuple(self.tokens) for tok in toks: if not isinstance(tok, Token): raise TypeError( f"Ambiguity.tokens must contain only Token instances, " f"got {tok!r}" ) object.__setattr__(self, "tokens", toks) def __repr__(self) -> str: texts = "/".join(repr(t.text) for t in self.tokens) return f"Ambiguity({self.kind.value!r}: {texts})" def _validated_field_strings(fields: dict[str, str]) -> dict[Role, str]: """Shared by ParsedName.replace and Parser.revise: validate a **fields mapping of role names to replacement strings and key it by Role. TypeErrors match replace()'s historical wording.""" by_value = {role.value: role for role in Role} for key, value in fields.items(): if key not in by_value: raise TypeError( f"unknown field {key!r}; expected one of " f"{', '.join(by_value)}" ) if not isinstance(value, str): raise TypeError( f"field {key!r} must be a str, got {value!r}" ) return {by_value[k]: v for k, v in fields.items()} def _remarked(tokens: list[Token]) -> tuple[Token, ...]: """Both unjoined marks recomputed over an edited token list. Each mark says a word stands in a part with nothing for it to join -- a particle alone among particles (UNJOINED_TAG), a connective among connectives and working particles (UNJOINED_CONJUNCTION_TAG) -- which is a fact about the part rather than the word, so an edit that re-roles tokens invalidates it in both directions: replace()/revise() splice a sub-parse's tokens into one field, and a word marked alone there can land beside a name word (stale mark) while an unmarked one can end up alone (missing mark). Parser.revise strips FOLDED_TAG for the same reason; these are RECOMPUTED rather than stripped, because absent is only correct for half the cases. The two predicates mirror the pipeline's single walk, including its precedence: where the part is ALL particles the first mark already readmits every word of it, a word that is both particle and connective included, so the second is not written there. """ out = list(tokens) for role in (Role.GIVEN, Role.MIDDLE, Role.FAMILY): part = [i for i, t in enumerate(out) if t.role is role] alone = bool(part) and all("particle" in out[i].tags for i in part) # `bool(part)` on both, and DEFENSIVE and measured inert on # this one (2026-09-20, #397 review question 4): an EMPTY part # satisfies `all` and `not any` alike, so without the guard # this reads True there and says a mark is due on a part with # no tokens to put it on. Nothing can see the difference -- # `lone_conj` is read only inside the `for i in part` below, # which has no members. Control: drop it and the whole suite # is green, which is why it is stated as a reader's guard # rather than pinned by a test. lone_conj = bool(part) and not alone and not any( "conjunction" not in out[i].tags and "particle" not in out[i].tags for i in part) for i in part: tags = out[i].tags if alone and UNJOINED_TAG not in tags: out[i] = dataclasses.replace(out[i], tags=tags | {UNJOINED_TAG}) elif not alone and UNJOINED_TAG in tags: out[i] = dataclasses.replace(out[i], tags=tags - {UNJOINED_TAG}) # re-read: the arm above may have replaced the token tags = out[i].tags mark = lone_conj and "conjunction" in tags if mark and UNJOINED_CONJUNCTION_TAG not in tags: out[i] = dataclasses.replace( out[i], tags=tags | {UNJOINED_CONJUNCTION_TAG}) elif not mark and UNJOINED_CONJUNCTION_TAG in tags: out[i] = dataclasses.replace( out[i], tags=tags - {UNJOINED_CONJUNCTION_TAG}) return tuple(out) @dataclass(frozen=True, slots=True) class ParsedName: """The immutable result of parsing one name string. Read the seven fields as strings (``.given``, ``.family``, ...); inspect structure through :attr:`tokens` / :meth:`tokens_for`; correct a parse with :meth:`replace` (returns a new value; Parser.revise is the tag-preserving form); produce output with :meth:`render`, :meth:`initials`, :meth:`capitalized`, or ``str()``. Constructor-enforced invariants: spans ascending, non-overlapping, in bounds of `original`; every Ambiguity's tokens are a value-equal subset of `tokens` (see Ambiguity.tokens). Provenance semantics (text == original[span] for parser-produced names) are documented, not enforced -- transforms like replace() legitimately break them. """ #: The input string exactly as passed to parse(). original: str #: Every classified token, in document order. tokens: tuple[Token, ...] #: Judgment calls that could have gone the other way; empty for #: most names (see Ambiguity). ambiguities: tuple[Ambiguity, ...] = () # in the class body so @dataclass(slots=True) keeps them __getstate__ = _guarded_getstate __setstate__ = _guarded_setstate def __post_init__(self) -> None: if not isinstance(self.original, str): raise TypeError( f"ParsedName.original must be a str, got {self.original!r}" ) object.__setattr__(self, "tokens", tuple(self.tokens)) object.__setattr__(self, "ambiguities", tuple(self.ambiguities)) for tok in self.tokens: if not isinstance(tok, Token): raise TypeError( f"ParsedName.tokens must contain only Token instances, " f"got {tok!r}" ) for amb in self.ambiguities: if not isinstance(amb, Ambiguity): raise TypeError( f"ParsedName.ambiguities must contain only Ambiguity " f"instances, got {amb!r}" ) prev_end = 0 for tok in self.tokens: if tok.span is None: continue if tok.span.end > len(self.original): raise ValueError( f"token {tok.text!r} span {tuple(tok.span)} is out of " f"bounds for original of length {len(self.original)}" ) if tok.span.start < prev_end: raise ValueError( f"token spans must be ascending and non-overlapping; " f"token {tok.text!r} at {tuple(tok.span)} begins before " f"offset {prev_end}" ) prev_end = tok.span.end # Hash once rather than rescanning the tuple per referenced # token: a name can carry an ambiguity per token (a string of # stray delimiters does), and the linear form made construction # quadratic in their product. Set membership uses the same value # equality the tuple scan did -- Token is frozen and hashable. if self.ambiguities: known = set(self.tokens) for amb in self.ambiguities: for tok in amb.tokens: # membership is by Token's value equality, not # identity: this only guarantees a value-equal token # exists in self.tokens, not that `tok` IS one of # those objects. if tok not in known: raise ValueError( f"Ambiguity token {tok.text!r} is not a " f"subset of this ParsedName's tokens" ) def __bool__(self) -> bool: return bool(self.tokens) def __str__(self) -> str: return self.render() def __repr__(self) -> str: # 4-space indent, matching HumanName's repr (v1 style) lines = [] for role in Role: text = self._text_for(role) if text: lines.append(f" {role.value}: {text!r}") if self.ambiguities: items = [ f"{a.kind.value}: {'/'.join(t.text for t in a.tokens)}" if a.tokens else a.kind.value for a in self.ambiguities ] lines.append(f" ambiguities: {items!r}") body = "\n".join(lines) return f"" if lines else "" # -- string views (canonical order = Role declaration order) -------- def _text_for(self, *roles: Role, tag: str | None = None, without_tag: str | None = None, unless_tag: str | None = None) -> str: suffix_join = roles == (Role.SUFFIX,) parts: list[str] = [] folded: list[str] = [] for tok in self.tokens: if tok.role not in roles: continue # A token carrying `unless_tag` is read as though it did # not carry `tag`/`without_tag` at all -- so it is EXCLUDED # by a `tag=` filter and INCLUDED by a `without_tag=` one, # which is how an unjoined particle anchors the base and # leaves the particles view. waived = unless_tag is not None and unless_tag in tok.tags if tag is not None and (tag not in tok.tags or waived): continue if without_tag is not None and without_tag in tok.tags \ and not waived: continue # "joined" (stable tag) marks a continuation of the previous # token ("Ph." + "D."): attach with a space so the suffix # view's ", " join does not split one credential in two if suffix_join and "joined" in tok.tags and parts: parts[-1] += " " + tok.text elif FOLDED_TAG in tok.tags: # middle_as_family fold: v1 PREPENDED middle_list to # last_list; spans cannot reorder, so the view does folded.append(tok.text) else: parts.append(tok.text) return (", " if suffix_join else " ").join(folded + parts) @property def title(self) -> str: return self._text_for(Role.TITLE) @property def given(self) -> str: return self._text_for(Role.GIVEN) @property def middle(self) -> str: return self._text_for(Role.MIDDLE) @property # rules/R1: "every field is a view computed from the parsed # words at read time, joining its words in written order — except # folded family words" (O3's fold and P6's tussenvoegsel, which # render before the rest of the family) def family(self) -> str: return self._text_for(Role.FAMILY) @property def suffix(self) -> str: return self._text_for(Role.SUFFIX) @property def nickname(self) -> str: return self._text_for(Role.NICKNAME) @property def maiden(self) -> str: return self._text_for(Role.MAIDEN) # -- derived views (filters over roles + STABLE tags only) ---------- @property # rules/R2: "the family name splits into further views: the # base (the family without its leading particles) and the # particles themselves" def family_particles(self) -> str: return self._text_for(Role.FAMILY, tag="particle", unless_tag=UNJOINED_TAG) @property def family_base(self) -> str: return self._text_for(Role.FAMILY, without_tag="particle", unless_tag=UNJOINED_TAG) @property def surnames(self) -> str: return self._text_for(Role.MIDDLE, Role.FAMILY) @property def given_names(self) -> str: return self._text_for(Role.GIVEN, Role.MIDDLE) # -- structured access ---------------------------------------------- def tokens_for(self, role: Role | str) -> tuple[Token, ...]: """The tokens of one field, in document order. Takes a Role member or its string value; anything else raises ValueError naming the valid roles.""" role = _coerce_enum(role, Role, "Role", "roles") return tuple(t for t in self.tokens if t.role is role) def as_dict(self, *, include_empty: bool = True) -> dict[str, str]: # _text_for handles the suffix ", "-join (single-role SUFFIX call) d = {role.value: self._text_for(role) for role in Role} if not include_empty: d = {k: v for k, v in d.items() if v} return d # -- editing ---------------------------------------------------------- def replace(self, **fields: str) -> ParsedName: """Return a new ParsedName with the named fields re-tokenized as synthetic tokens (span=None). Whitespace-splits each value; an empty value clears the field. original is unchanged (provenance). Ambiguities referencing replaced tokens are dropped. Replacement tokens carry no STABLE tag, so tag-driven views degrade: family_particles empties, particles regain their initials, and a multi-word suffix is comma-joined. Parser.revise() is the tag-preserving alternative. They do carry UNCLASSIFIED_TAG, which says the text was never read rather than that it was read and found plain: a view holding a vocabulary falls back to it for these and to the tags for everything else. """ replaced = _validated_field_strings(fields) synthetic = { role: tuple(Token(word, None, role, _UNCLASSIFIED) for word in value.split()) for role, value in replaced.items() } return self._with_field_tokens(synthetic) def _with_field_tokens( self, replaced: Mapping[Role, tuple[Token, ...]], ) -> ParsedName: """Shared tail of replace()/Parser.revise(): splice each role's replacement tokens in at the role's first position (appended in canonical order when the role had no tokens); drop ambiguities whose referents were replaced.""" # Private contract, made self-enforcing: a token filed under a # key that is not its own role is the one way this shared tail # could build a semantically wrong ParsedName (the splice keys # on the mapping, the views key on the token). for role, toks in replaced.items(): for tok in toks: if tok.role is not role: raise ValueError( f"replacement token {tok.text!r} has role " f"{tok.role.value}, not {role.value}" ) new_tokens: list[Token] = [] emitted: set[Role] = set() for tok in self.tokens: if tok.role in replaced: if tok.role not in emitted: new_tokens.extend(replaced[tok.role]) emitted.add(tok.role) continue new_tokens.append(tok) for role in Role: if role in replaced and role not in emitted: new_tokens.extend(replaced[role]) kept = tuple( amb for amb in self.ambiguities if all(t in new_tokens for t in amb.tokens) ) return ParsedName(self.original, _remarked(new_tokens), kept) # -- comparison ------------------------------------------------------- def comparison_key(self) -> tuple[str, ...]: """One casefolded component per Role, in canonical order, for dedup, dict keys, and sorting. The semantic layer; __eq__ stays strict. """ return tuple(self._text_for(role).casefold() for role in Role) def matches(self, other: str | ParsedName, *, parser: Parser | None = None) -> bool: """Component-wise case-insensitive comparison (the semantic layer; __eq__ stays strict). A str argument is parsed with `parser`, or with the DEFAULT parser when None -- if this name came from a custom Parser, pass that parser (or use Parser.matches); otherwise the comparison silently runs under the wrong configuration.""" if isinstance(other, str): import nameparser._parser as _parser active = parser if parser is not None else _parser._default_parser() other = active.parse(other) if not isinstance(other, ParsedName): raise TypeError( f"matches() takes a str or ParsedName, got {other!r}") return self.comparison_key() == other.comparison_key() # -- rendering delegates ---------------------------------------------- # One-line delegation to nameparser._render: parsing code physically # cannot import formatting logic (layering rule, enforced by # tests/v2/test_layering.py), so these import at call time -- module # level stays internal-import-free. def render( self, spec: str = ('{title} {given} "{nickname}" {middle} {family} ' "({maiden}) {suffix}"), ) -> str: """Fill the str.format spec from the seven role fields and the derived views; empty fields collapse (#254), including the default spec's decorations (empty '""' and '()' wrappers). The default shows every non-empty field: the nickname quoted after the given name, the maiden name parenthesized after the family name. Unknown keys raise KeyError naming the valid fields.""" import nameparser._render as _render return _render.render(self, spec) def initials(self, spec: str = "{given} {middle} {family}", delimiter: str = ".", separator: str = " ") -> str: """Initials per group; v1's initials_format/_delimiter/_separator become call-site arguments instead of Config-wide settings. Each group is ordered the way its own field is ordered, so a name whose middle words fold into the family initials them before the family's own words, as `family` renders them. Valid spec keys: given, middle, family.""" import nameparser._render as _render return _render.initials(self, spec, delimiter, separator) def capitalized(self, lexicon: Lexicon | None = None, *, force: bool = False) -> ParsedName: """Case-fixing transform -> new ParsedName, same spans, new token texts. Needs a lexicon for capitalization_exceptions and particle rules; None uses the DEFAULT lexicon -- if this name came from a custom Parser, pass its lexicon or use Parser.capitalized. force=False preserves a name whose words outside the suffix are written in more than one case, and in a name it does repair keeps a suffix written in more than one case as written ('EdD'), repairing only one written in a single case (rules/R5). Idempotent.""" import nameparser._render as _render return _render.capitalized(self, lexicon, force=force) derek73-python-nameparser-456b024/nameparser/_version.py000066400000000000000000000017631527303271700232510ustar00rootroot00000000000000#: Release version parts (major, minor, micro). VERSION stays a pure #: numeric tuple so `nameparser.VERSION >= (2, 0, 0)` keeps working; the #: pre-release segment lives separately. #: #: Bumped when a cycle OPENS, not when it ships, so the tree says what #: it is building. Note the cost of the tuple being purely numeric: it #: cannot carry the dev marker, so `VERSION >= (2, 8, 1)` is already #: true here while 2.8.1 is unreleased. Compare `__version__` instead #: where that distinction matters. VERSION = (2, 8, 2) #: PEP 440 pre-release/dev segment appended to the numeric version, or #: "" for a final release. Joined WITHOUT a dot ("2.0.0rc1", not #: "2.0.0.rc1"); setuptools reads __version__ as the package version. #: #: "dev" through the cycle, cleared at release. It normalizes to #: 2.8.1.dev0, which sorts above 2.8.0 and BELOW 2.8.1, so an install #: from the 2.x branch can never masquerade as the release it precedes. PRE_RELEASE = "" __version__ = ".".join(map(str, VERSION)) + PRE_RELEASE derek73-python-nameparser-456b024/nameparser/config/000077500000000000000000000000001527303271700223115ustar00rootroot00000000000000derek73-python-nameparser-456b024/nameparser/config/__init__.py000066400000000000000000000103561527303271700244270ustar00rootroot00000000000000"""v1 import-path preservation (mechanisms.md#CONFIG-SHIM-SNAPSHOT): the Constants shim lives in nameparser._config_shim. Two unrelated things share this package. The names re-exported below -- ``Constants``, ``CONSTANTS``, ``SetManager``, ``TupleManager``, ``RegexTupleManager`` -- are v1 compatibility surface, and go with the rest of the facade in 3.0. The vocabulary data modules beside them (:mod:`~nameparser.config.titles`, :mod:`~nameparser.config.particles`, ...) hold the word lists the 2.x :class:`~nameparser.Lexicon` is built from, named for its fields since 2.2 (#293); since 2.6 each (all but ``surnames.py``) re-exports sets compiled from the per-word TOML files in ``nameparser/vocab/`` (#737). 3.0 removes them too, and makes ``surnames`` private; since 2.8.1 reading one of their constants warns, naming what 3.0 offers in its place (#1007), as reading ``nameparser.config.regexes`` does (#1008), and since 2.8.2 reading ``surnames.KOREAN_SURNAMES`` does. ``RegexTupleManager`` is served from the shim purely for pickle compatibility (through ``__getattr__`` since 2.8, so loading such a blob warns here too): a v1.4 ``Constants`` blob's ``regexes`` field was pickled as ``nameparser.config.RegexTupleManager(...)``, and unpickling resolves and reconstructs that nested object before ``Constants.__setstate__`` ever runs. Without this name, loading such a blob raises ``AttributeError`` looking up the class, not a clean compatibility failure. """ # Maintainer note, deliberately outside the docstring: 3.0 removes the # five shim names below and the vocabulary DATA modules, surnames made # private (#1007, #1008, superseding decisions/3-0-reevaluations's # 2026-10-28 entry, which kept the modules); the modules warn per # constant since 2.8.1, surnames since 2.8.2. Constants and CONSTANTS are imported eagerly -- Constants # warns at construction, the CONSTANTS singleton when mutated -- while # the three managers resolve through __getattr__ so that each caller's # read warns, at the caller's line, and nameparser's own reads (from # _config_shim directly) stay quiet. The bridge follows # config/_deprecated.py's alias_getattr: the hooks sit in the `else` of # a TYPE_CHECKING guard, since a module __getattr__ mypy can see turns # off its missing-attribute checking for the whole module, and __dir__ # lists the managers, or autodoc's member scan drops them. import sys as _sys import warnings as _warnings from typing import TYPE_CHECKING import nameparser._config_shim as _config_shim from nameparser._config_shim import CONSTANTS as CONSTANTS from nameparser._config_shim import Constants as Constants if TYPE_CHECKING: from nameparser._config_shim import RegexTupleManager as RegexTupleManager from nameparser._config_shim import SetManager as SetManager from nameparser._config_shim import TupleManager as TupleManager else: def __getattr__(name: str) -> object: # PEP 562. Per read, never cached into globals, so every # reading site warns (why _deprecated.py never caches either). # A v1.4 Constants pickle names nameparser.config. # RegexTupleManager and SetManager, so loading one warns here as # well as in Constants.__setstate__ -- correct, since 3.0 # refuses that blob. message = _config_shim.MANAGER_DEPRECATIONS.get(name) if message is None: raise AttributeError( f"module {__name__!r} has no attribute {name!r}") # `from nameparser.config import SetManager` reads the name # twice: importlib's _handle_fromlist probes a PACKAGE's # fromlist with hasattr() first, then the import statement # reads it. Warning on the probe too would report one import # twice. if _sys._getframe(1).f_code.co_name != "_handle_fromlist": _warnings.warn(message, DeprecationWarning, stacklevel=2) return getattr(_config_shim, name) def __dir__() -> list[str]: # the union, as _deprecated.py's __dir__ explains return sorted(set(globals()) | set(_config_shim.MANAGER_DEPRECATIONS)) # a star import reads __all__ alone, so listing the managers routes # each through __getattr__ and warns, rather than dropping them unwarned __all__ = ["CONSTANTS", "Constants", "RegexTupleManager", "SetManager", "TupleManager"] derek73-python-nameparser-456b024/nameparser/config/_compiled.py000066400000000000000000001162171527303271700246260ustar00rootroot00000000000000"""The compiled vocabulary. GENERATED from nameparser/vocab/*.toml by tools/vocab/build.py -- do not edit; edit the TOML and run uv run python tools/vocab/build.py The public names are re-exported, with their documentation, by the nameparser.config modules (titles, particles, suffixes, ...). """ GIVEN_NAME_TITLES: frozenset[str] = frozenset({ 'aunt', 'auntie', 'baba', 'brother', 'cheikh', 'consort', 'dame', 'father', 'guru', 'king', 'lama', 'maid', 'master', 'mother', 'pope', 'prince', 'princess', 'queen', 'royal', 'shaik', 'shaikh', 'shayk', 'shaykh', 'sheik', 'sheikh', 'shekh', 'sir', 'sister', 'swami', 'uncle', 'أستاذ', 'أستاذة', 'الأستاذ', 'الأستاذة', 'الحاج', 'الحاجة', 'الدكتور', 'الدكتورة', 'الشيخ', 'الشيخة', 'دكتور', 'دكتورة', 'مهندس', 'गुरु', 'बाबा', 'संत', 'स्वामी', 'গুরু', 'বাবা', 'শ্রীল', 'স্বামী', }) TITLES: frozenset[str] = frozenset({ '10th', '1lt', '1sgt', '1st', '1stlt', '1stsgt', '2lt', '2nd', '2ndlt', '3rd', '4th', '5th', '6th', '7th', '8th', '9th', 'a1c', 'ab', 'abbess', 'abbot', 'abolitionist', 'academic', 'acolyte', 'activist', 'actor', 'actress', 'adept', 'adjutant', 'adm', 'administrator', 'admiral', 'advertising', 'adviser', 'advocate', 'air', 'akhoond', 'alderman', 'almoner', 'ambassador', 'amn', 'analytics', 'anarchist', 'animator', 'anthropologist', 'appellate', 'apprentice', 'arbitrator', 'archbishop', 'archdeacon', 'archdruid', 'archduchess', 'archduke', 'archeologist', 'architect', 'arhat', 'army', 'arranger', 'assistant', 'assoc', 'associate', 'asst', 'astronomer', 'attache', 'attaché', 'attorney', 'aunt', 'auntie', 'author', 'award-winning', 'ayatollah', 'baba', 'bailiff', 'ballet', 'bandleader', 'banker', 'banner', 'bapak', 'baron', 'baroness', 'barrister', 'baseball', 'batoni', 'bearer', 'behavioral', 'bench', 'bg', 'bgen', 'biblical', 'bibliographer', 'binibini', 'biochemist', 'biographer', 'biologist', 'bishop', 'blessed', 'blogger', 'blues', 'bodhisattva', 'bookseller', 'botanist', 'bp', 'brigadier', 'briggen', 'british', 'broadcaster', 'brother', 'burlesque', 'business', 'businessman', 'businesswoman', 'bwana', 'canon', 'capt', 'captain', 'cardinal', 'cartographer', 'cartoonist', 'catholicos', 'ccmsgt', 'cdr', 'celebrity', 'ceo', 'cfo', 'ch.ma', 'ch.mo', 'chair', 'chairs', 'chaplain', 'charge', 'chargé', 'chef', 'chemist', 'chief', 'chieftain', 'choreographer', 'civil', 'classical', 'clergyman', 'clerk', 'cllr', 'cmsaf', 'cmsgt', 'co-chair', 'co-chairs', 'co-founder', 'coach', 'col', 'collector', 'colonel', 'comedian', 'comedienne', 'comic', 'commander', 'commander-in-chief', 'commissioner', 'commodore', 'composer', 'compositeur', 'comptroller', 'computer', 'comtesse', 'conductor', 'consort', 'consultant', 'controller', 'corporal', 'corporate', 'correspondent', 'councillor', 'counsel', 'counselor', 'count', 'countess', 'courtier', 'cpl', 'cpo', 'cpt', 'credit', 'criminal', 'criminologist', 'critic', 'csm', 'curator', 'customs', 'cwo-2', 'cwo-3', 'cwo-4', 'cwo-5', 'cwo2', 'cwo3', 'cwo4', 'cwo5', 'cyclist', "d'affaires", 'dame', 'dancer', 'dcn', 'delegate', 'deputy', 'designated', 'designer', 'detective', 'developer', 'dhr', 'dipl.-ing', 'diplomat', 'dir', 'director', 'discovery', 'dissident', 'district', 'division', 'doamna', 'doamnă', 'docent', 'docket', 'doctor', 'domnișoara', 'domnul', 'dott.ssa', 'doyen', 'dpty', 'dr', 'dra', 'dramatist', 'druid', 'drummer', 'duchesse', 'dutchess', 'ecologist', 'economist', 'editor', 'edler', 'edmi', 'edohen', 'educator', 'effendi', 'ekegbian', 'elerunwon', 'eminence', 'emperor', 'empress', 'encik', 'engineer', 'english', 'ens', 'entertainer', 'entrepreneur', 'envoy', 'erzbischof', 'essayist', 'evangelist', 'excellency', 'excellent', 'exec', 'executive', 'expert', 'fadm', 'familie', 'family', 'father', 'federal', 'fh-prof', 'field', 'film', 'financial', 'first', 'flag', 'flying', 'foreign', 'forester', 'founder', 'fr', 'frau', 'freifrau', 'freiherr', 'friar', 'frk', 'fru', 'fräulein', 'fröken', 'frøken', 'frú', 'fürst', 'fürsterzbischof', 'gaf', 'gen', 'general', 'generalissimo', 'gent.le', 'gent.ma', 'gent.mo', 'gentiluomo', 'giani', 'ginang', 'ginoo', 'goodman', 'goodwife', 'gospodin', 'gospođa', 'gospođica', 'gov', 'governor', 'graaf', 'graf', 'grafas', 'grafi', 'grand', 'greifi', 'greve', 'group', 'großfürst', 'gräfin', 'guitarist', 'guru', 'gysgt', 'hajji', 'headman', 'heir', 'heiress', 'her', 'hereditary', 'herr', 'herra', 'herrn', 'herzog', 'high', 'highness', 'his', 'historian', 'historicus', 'historien', 'holiness', 'hon', 'honorable', 'honourable', 'host', 'hr', 'hrabia', 'härra', 'ill.ma', 'ill.mo', 'illustrator', 'imam', 'industrialist', 'information', 'instructor', 'intelligence', 'intendant', 'inventor', 'investigator', 'investor', 'journalist', 'journeyman', 'judge', 'judicial', 'juffrouw', 'jurist', 'kalbatoni', 'keyboardist', "king's", 'kingdom', 'knight', 'knowledge', 'krahv', 'kreivi', 'lady', 'lama', 'lamido', 'law', 'lawyer', 'lcdr', 'lcpl', 'leader', 'lecturer', 'legal', 'librarian', 'lieutenant', 'linguist', 'literary', 'lord', 'lt', 'ltc', 'ltcol', 'ltg', 'ltgen', 'ltjg', 'lyricist', 'madam', 'madame', 'mademoiselle', 'mag', 'mag-judge', 'mag/judge', 'magistrate', 'magistrate-judge', 'magnate', 'maharajah', 'maharani', 'maid', 'maj', 'majesty', 'majgen', 'manager', 'marcher', 'marchess', 'marchioness', 'marketing', 'marquess', 'master', 'mathematician', 'mathematics', 'matriarch', 'mayor', 'mcpo', 'mcpoc', 'mcpon', 'me', 'member', 'memoirist', 'meneer', 'merchant', 'met', 'metropolitan', 'mevr', 'mevrouw', 'mevrouwe', 'mg', 'mgr', 'mgysgt', 'military', 'minister', 'miss', 'misses', 'missionary', 'mister', 'mlle', 'mme', 'mobster', 'model', 'monk', 'monseigneur', 'monsieur', 'monsignor', 'most', 'mother', 'mountaineer', 'mpco-cg', 'mr', 'mrs', 'ms', 'msg', 'msgt', 'mufti', 'mullah', 'municipal', 'murshid', 'musician', 'musicologist', 'mx', 'mystery', 'nanny', 'narrator', 'national', 'naturalist', 'navy', 'neiti', 'neuroscientist', 'novelist', 'nurse', 'obstetritian', 'officer', 'opera', 'operating', 'ornithologist', 'owura', 'painter', 'paleontologist', 'paní', 'pastor', 'patriarch', 'pd', 'pediatrician', 'personality', 'petty', 'pfc', 'pharaoh', 'philantropist', 'philosopher', 'photographer', 'physician', 'physicist', 'pianist', 'pilot', 'pioneer', 'pir', 'player', 'playwright', 'po1', 'po2', 'po3', 'poet', 'police', 'political', 'politician', 'ponas', 'ponia', 'pope', 'prefect', 'preg.ma', 'preg.mo', 'preili', 'prelate', 'premier', 'pres', 'presbyter', 'president', 'presiding', 'priest', 'priestess', 'primate', 'prime', 'prin', 'principal', 'printer', 'printmaker', 'prinz', 'prior', 'priv.-doz', 'private', 'pro', 'producer', 'prof', 'prof.ssa', 'professor', 'proua', 'provost', 'pslc', 'psychiatrist', 'psychologist', 'publisher', 'pursuivant', 'pv2', 'pvt', "queen's", 'ra', 'rabbi', 'radio', 'radm', 'rangatira', 'rdml', 'rear', 'rebbe', 'registrar', 'reichsgraf', 'rep', 'representative', 'researcher', 'resident', 'rev', 'revenue', 'reverend', 'right', 'risk', 'ritter', 'rock', 'rouva', 'rt', 'sailor', 'saint', 'sainte', 'saoshyant', 'satirist', 'scholar', 'schoolmaster', 'scientist', 'scpo', 'screenwriter', 'secretary', 'security', 'seigneur', 'senator', 'senhor', 'senhora', 'senhorita', 'senior', 'senior-judge', 'sergeant', 'servant', 'señor', 'señora', 'señores', 'señorita', 'señoritas', 'sfc', 'sgm', 'sgt', 'sgtmaj', 'sgtmajmc', 'shayk', 'shaykh', 'sheriff', 'sig.na', 'sig.ra', 'signor', 'signora', 'signore', 'signorina', 'singer', 'singer-songwriter', 'sir', 'sister', 'slečna', 'sma', 'smsgt', 'sn', 'soccer', 'social', 'sociologist', 'software', 'soldier', 'solicitor', 'soprano', 'spc', 'speaker', 'special', 'sr', 'sra', 'sres', 'srta', 'srtas', 'ssg', 'ssgt', 'st', 'staff', 'state', 'states', 'strategy', 'subaltern', 'subedar', 'suffragist', 'superior', 'supreme', 'surgeon', 'swami', 'swordbearer', 'sysselmann', 'tax', 'teacher', 'technical', 'technologist', 'television', 'tenor', 'theater', 'theatre', 'theologian', 'theorist', 'timi', 'tirthankar', 'translator', 'travel', 'treasurer', 'tsar', 'tsarina', 'tsgt', 'uk', 'uncle', 'ungfrú', 'united', 'univ.prof', 'us', 'vadm', 'vardapet', 'vc', 'venerable', 'verderer', 'vicar', 'vice', 'viscount', 'vizier', 'vocalist', 'voice', 'vrouwe', 'warden', 'warrant', 'wo-1', 'wo1', 'wo2', 'wo3', 'wo4', 'wo5', 'woodman', 'wp', 'writer', 'zoologist', 'δρ', 'κα', 'καθ', 'κος', 'акад', 'г-жа', 'г-н', 'д-р', 'пан', 'пані', 'проф', "גב'", 'גברת', 'גב׳', 'ד"ר', 'ד״ר', 'הרב', 'מר', 'עו"ד', 'עו״ד', "פרופ'", 'פרופסור', 'פרופ׳', 'أستاذ', 'أستاذة', 'الأستاذ', 'الأستاذة', 'الحاج', 'الحاجة', 'الدكتور', 'الدكتورة', 'الشيخ', 'الشيخة', 'دكتور', 'دكتورة', 'مهندس', 'गुरु', 'जनाब', 'डा', 'डॉ', 'डॉक्टर', 'न्यायमूर्ति', 'पं', 'पंडित', 'प्रा', 'प्राध्यापक', 'प्रो', 'प्रोफेसर', 'बाबा', 'बाबू', 'महात्मा', 'महाराजा', 'मौलाना', 'श्री', 'श्रीमती', 'श्रीमान', 'श्रीयुत', 'संत', 'सरदार', 'सुश्री', 'सौ', 'स्वामी', 'অধ্যাপক', 'আলহাজ', 'আলহাজ্ব', 'গুরু', 'জনাব', 'ড', 'ডঃ', 'ডক্টর', 'ডা', 'ডাঃ', 'ডাক্তার', 'প্রফেসর', 'বাবা', 'বিচারপতি', 'মাওলানা', 'মি', 'মিঃ', 'মিসেস', 'মুফতি', 'মো', 'মোঃ', 'মোছা', 'মোছাঃ', 'মোসা', 'মোসাঃ', 'শ্রী', 'শ্রীমতী', 'শ্রীল', 'স্বামী', }) AFTER_GIVEN_TITLES: frozenset[str] = frozenset({ 'bapak', 'batoni', 'binibini', 'bwana', 'dhr', 'doamna', 'doamnă', 'domnișoara', 'domnul', 'encik', 'familie', 'frau', 'frk', 'fräulein', 'fröken', 'frøken', 'ginang', 'ginoo', 'goodwife', 'gospodin', 'gospođa', 'gospođica', 'herrn', 'juffrouw', 'kalbatoni', 'madam', 'madame', 'mademoiselle', 'meneer', 'mevr', 'mevrouw', 'mevrouwe', 'miss', 'misses', 'mlle', 'mme', 'monsieur', 'mr', 'mrs', 'mx', 'neiti', 'owura', 'ponas', 'ponia', 'preili', 'proua', 'rouva', 'senhor', 'senhora', 'senhorita', 'señores', 'señorita', 'señoritas', 'sig.na', 'sig.ra', 'signora', 'signorina', 'slečna', 'sra', 'sres', 'srta', 'srtas', 'ungfrú', 'κα', 'κος', 'г-жа', 'г-н', 'пані', "גב'", 'גברת', 'גב׳', 'מר', 'जनाब', 'श्रीमती', 'श्रीमान', 'श्रीयुत', 'सुश्री', 'सौ', 'জনাব', 'মি', 'মিঃ', 'মিসেস', 'মোছা', 'মোছাঃ', 'মোসা', 'মোসাঃ', 'শ্রীমতী', }) TITLES_AMBIGUOUS: frozenset[str] = frozenset({ 'bard', 'buddha', 'burgess', 'chancellor', 'cheikh', 'deacon', 'dean', 'grace', 'gyani', 'justice', 'king', 'mahdi', 'major', 'marquis', 'marquise', 'marshal', 'prince', 'princess', 'queen', 'ranger', 'royal', 'shaik', 'shaikh', 'sheik', 'sheikh', 'shekh', 'siddha', 'sultan', 'sultana', 'wing', }) TITLE_MODIFIERS: frozenset[str] = frozenset({ '10th', '1st', '2nd', '3rd', '4th', '5th', '6th', '7th', '8th', '9th', 'advertising', 'air', 'analytics', 'appellate', 'army', 'assistant', 'assoc', 'associate', 'asst', 'award-winning', 'ballet', 'banner', 'baseball', 'bearer', 'behavioral', 'bench', 'biblical', 'blues', 'british', 'burlesque', 'business', 'civil', 'classical', 'computer', 'consort', 'corporate', 'credit', 'criminal', 'customs', 'designated', 'discovery', 'district', 'division', 'docket', 'edmi', 'english', 'excellent', 'exec', 'executive', 'family', 'federal', 'field', 'film', 'financial', 'first', 'flag', 'flying', 'foreign', 'grand', 'group', 'her', 'hereditary', 'high', 'his', 'information', 'intelligence', 'judicial', "king's", 'kingdom', 'knowledge', 'law', 'legal', 'literary', 'marcher', 'marketing', 'mathematics', 'military', 'most', 'municipal', 'mystery', 'national', 'navy', 'opera', 'operating', 'petty', 'police', 'political', 'presiding', 'prime', "queen's", 'radio', 'rear', 'revenue', 'right', 'risk', 'rock', 'royal', 'security', 'senior', 'soccer', 'social', 'software', 'special', 'staff', 'state', 'states', 'strategy', 'superior', 'supreme', 'tax', 'technical', 'television', 'theater', 'theatre', 'travel', 'uk', 'united', 'us', 'vice', 'voice', 'warrant', 'wing', }) TITLE_STYLES: frozenset[str] = frozenset({ 'ch.ma', 'ch.mo', 'cheikh', 'doyen', 'effendi', 'eminence', 'excellency', 'gent.le', 'gent.ma', 'gent.mo', 'grace', 'hajji', 'highness', 'holiness', 'hon', 'honorable', 'honourable', 'ill.ma', 'ill.mo', 'majesty', 'preg.ma', 'preg.mo', 'shaik', 'shaikh', 'shayk', 'shaykh', 'sheik', 'sheikh', 'shekh', 'الشيخ', 'الشيخة', 'महात्मा', 'सरदार', 'আলহাজ', 'আলহাজ্ব', }) TITLE_PHRASES: frozenset[str] = frozenset({ 'chief 1lt', 'chief 1sgt', 'chief 1stlt', 'chief 1stsgt', 'chief 2lt', 'chief 2ndlt', 'chief a1c', 'chief ab', 'chief adjutant', 'chief adm', 'chief administrator', 'chief admiral', 'chief alderman', 'chief almoner', 'chief ambassador', 'chief amn', 'chief arbitrator', 'chief attache', 'chief attaché', 'chief bailiff', 'chief bg', 'chief bgen', 'chief brigadier', 'chief briggen', 'chief burgess', 'chief capt', 'chief captain', 'chief ccmsgt', 'chief cdr', 'chief ceo', 'chief cfo', 'chief chair', 'chief chairs', 'chief chancellor', 'chief charge', 'chief chargé', 'chief clerk', 'chief cllr', 'chief cmsaf', 'chief cmsgt', 'chief co-chair', 'chief co-chairs', 'chief col', 'chief colonel', 'chief commander', 'chief commander-in-chief', 'chief commissioner', 'chief commodore', 'chief comptroller', 'chief controller', 'chief corporal', 'chief councillor', 'chief counsel', 'chief courtier', 'chief cpl', 'chief cpo', 'chief cpt', 'chief csm', 'chief cwo-2', 'chief cwo-3', 'chief cwo-4', 'chief cwo-5', 'chief cwo2', 'chief cwo3', 'chief cwo4', 'chief cwo5', "chief d'affaires", 'chief dean', 'chief delegate', 'chief deputy', 'chief detective', 'chief dir', 'chief director', 'chief dpty', 'chief ens', 'chief envoy', 'chief fadm', 'chief gaf', 'chief gen', 'chief general', 'chief generalissimo', 'chief gov', 'chief governor', 'chief gysgt', 'chief headman', 'chief intendant', 'chief judge', 'chief justice', 'chief lcdr', 'chief lcpl', 'chief leader', 'chief lieutenant', 'chief lt', 'chief ltc', 'chief ltcol', 'chief ltg', 'chief ltgen', 'chief ltjg', 'chief mag', 'chief mag-judge', 'chief mag/judge', 'chief magistrate', 'chief magistrate-judge', 'chief maj', 'chief majgen', 'chief major', 'chief marshal', 'chief mayor', 'chief mcpo', 'chief mcpoc', 'chief mcpon', 'chief member', 'chief mg', 'chief mgysgt', 'chief military', 'chief minister', 'chief mpco-cg', 'chief msg', 'chief msgt', 'chief officer', 'chief pfc', 'chief po1', 'chief po2', 'chief po3', 'chief prefect', 'chief premier', 'chief pres', 'chief president', 'chief prin', 'chief principal', 'chief private', 'chief provost', 'chief pslc', 'chief pursuivant', 'chief pv2', 'chief pvt', 'chief radm', 'chief rdml', 'chief registrar', 'chief rep', 'chief representative', 'chief scpo', 'chief secretary', 'chief senator', 'chief senior-judge', 'chief sergeant', 'chief sfc', 'chief sgm', 'chief sgt', 'chief sgtmaj', 'chief sgtmajmc', 'chief sheriff', 'chief sma', 'chief smsgt', 'chief sn', 'chief spc', 'chief speaker', 'chief ssg', 'chief ssgt', 'chief subaltern', 'chief subedar', 'chief swordbearer', 'chief sysselmann', 'chief treasurer', 'chief tsgt', 'chief vadm', 'chief vc', 'chief verderer', 'chief vizier', 'chief warden', 'chief wo-1', 'chief wo1', 'chief wo2', 'chief wo3', 'chief wo4', 'chief wo5', 'chief न्यायमूर्ति', 'chief বিচারপতি', 'deputy administrator', 'deputy alderman', 'deputy almoner', 'deputy ambassador', 'deputy arbitrator', 'deputy attache', 'deputy attaché', 'deputy bailiff', 'deputy burgess', 'deputy ceo', 'deputy cfo', 'deputy chair', 'deputy chairs', 'deputy chancellor', 'deputy charge', 'deputy chargé', 'deputy clerk', 'deputy cllr', 'deputy co-chair', 'deputy co-chairs', 'deputy commissioner', 'deputy comptroller', 'deputy controller', 'deputy councillor', 'deputy counsel', 'deputy courtier', "deputy d'affaires", 'deputy dean', 'deputy delegate', 'deputy dir', 'deputy director', 'deputy dpty', 'deputy envoy', 'deputy gov', 'deputy governor', 'deputy headman', 'deputy intendant', 'deputy judge', 'deputy justice', 'deputy leader', 'deputy mag', 'deputy mag-judge', 'deputy mag/judge', 'deputy magistrate', 'deputy magistrate-judge', 'deputy mayor', 'deputy member', 'deputy minister', 'deputy prefect', 'deputy premier', 'deputy pres', 'deputy president', 'deputy prin', 'deputy principal', 'deputy provost', 'deputy pslc', 'deputy pursuivant', 'deputy registrar', 'deputy rep', 'deputy representative', 'deputy secretary', 'deputy senator', 'deputy senior-judge', 'deputy sheriff', 'deputy speaker', 'deputy swordbearer', 'deputy sysselmann', 'deputy treasurer', 'deputy vc', 'deputy verderer', 'deputy vizier', 'deputy warden', 'deputy न्यायमूर्ति', 'deputy বিচারপতি', 'king consort', 'lady justice', 'lord chancellor', 'lord justice', 'magistrate judge', 'major general', 'prince consort', 'princess consort', 'princess royal', 'pro chancellor', 'queen consort', 'queen mother', 'sergeant major', 'sgt major', }) NON_GIVEN_NAME_PARTICLES: frozenset[str] = frozenset({ "'t", 'ad-', 'adh-', 'af', 'al-', 'an-', 'ar-', 'as-', 'ash-', 'at-', 'ath-', 'auf', 'av', 'az-', 'aš-', 'aḍ-', 'aḏ-', 'aṣ-', 'aṭ-', 'aṯ-', 'aẓ-', 'bint', "d'", 'das', 'de', "de'", 'degli', 'dei', "dell'", 'delle', 'delli', 'dello', 'dell’', 'dem', 'der', 'de’', 'dos', 'd’', 'ed-', 'edh-', 'el-', 'en-', 'er-', 'es-', 'esh-', 'et-', 'eth-', 'ez-', 'het', 'ibn', "l'", 'las', 'los', 'l’', 'mc', 'ní', 'op', 'ste', 'ter', 'ul-', 'vd', 'vom', 'zu', 'ó', 'בן', 'בת', 'آل', 'ابن', 'بن', 'بنت', '’t', }) PARTICLES: frozenset[str] = frozenset({ "'t", 'aan', 'abu', 'ad-', 'adh-', 'aen', 'af', 'al', 'al-', 'an-', 'ar-', 'as-', 'ash-', 'at-', 'ath-', 'auf', 'av', 'az-', 'aš-', 'aḍ-', 'aḏ-', 'aṣ-', 'aṭ-', 'aṯ-', 'aẓ-', 'bar', 'bat', 'bin', 'bint', 'binte', 'binti', "d'", 'da', 'dal', 'das', 'de', "de'", 'degli', 'dei', 'del', 'dela', "dell'", 'della', 'delle', 'delli', 'dello', 'dell’', 'dem', 'den', 'der', 'de’', 'di', 'do', 'dos', 'du', 'd’', 'ed-', 'edh-', 'el-', 'en-', 'er-', 'es-', 'esh-', 'et-', 'eth-', 'ez-', 'freiherr', 'freiherrin', 'het', 'ibn', "l'", 'la', 'las', 'le', 'los', 'l’', 'mac', 'mc', 'ní', 'op', 'san', 'santa', 'st', 'ste', 'te', 'ter', 'tho', 'thoe', 'ua', 'ul-', 'van', 'vande', 'vander', 'vd', 'vel', 'vom', 'von', 'zu', 'ó', 'בן', 'בת', 'آل', 'أبو', 'ابن', 'ابو', 'بن', 'بنت', '’t', }) CONJUNCTIONS: frozenset[str] = frozenset({ '&', 'and', 'e', 'et', 'i', 'of', 'the', 'und', 'y', 'και', 'и', 'й', 'та', 'і', 'و', }) CONJUNCTIONS_AMBIGUOUS: frozenset[str] = frozenset({ 'e', 'i', }) BOUND_GIVEN_NAMES: frozenset[str] = frozenset({ 'abd', 'abdal', 'abdel', 'abdul', 'abou', 'abu', 'umm', 'أبو', 'أم', 'ابو', 'ام', 'عبد', }) GIVEN_NAME_ABBREVIATIONS: frozenset[str] = frozenset({ 'benj', 'chas', 'edw', 'geo', 'jas', 'jno', 'jo', 'ma', 'richd', 'robt', 'saml', 'thos', 'wm', }) MAIDEN_MARKERS: frozenset[str] = frozenset({ 'född', 'fødd', 'født', 'geb', 'geboren', 'geborene', 'nee', 'né', 'née', 'rozená', 'z domu', 'урожд', 'урожденная', 'урожденный', 'урождённая', 'урождённый', '旧姓', }) SUFFIX_WORDS: frozenset[str] = frozenset({ '2', 'eldri', 'esq', 'esquire', 'fh', 'filh', 'filha', 'filho', 'fils', 'fo', 'i', 'ii', 'iii', 'iv', 'jaunesnioji', 'jaunesnysis', 'jaunākais', 'jaunākā', 'jnr', 'jr', 'junior', 'júnior', 'mladšia', 'mladší', 'mlajša', 'mlajši', 'mlađa', 'mlađi', 'net', 'neta', 'neto', 'netto', 'nt', 'nuorempi', 'ret', 'rinpoche', 'snr', 'sob', 'sobr', 'sobrinha', 'sobrinho', 'sr', 'starejša', 'starejši', 'starija', 'stariji', 'staršia', 'starší', 'v', 'vanhempi', 'vecākais', 'vecākā', 'vet', 'vyresnioji', 'vyresnysis', 'yngri', 'ƙarami', 'σίνιορ', 'τζούνιορ', 'малодшая', 'малодшы', 'мл', 'млади', 'младша', 'младшая', 'младши', 'младший', 'млађа', 'млађи', 'молодша', 'молодший', 'помладиот', 'постариот', 'стари', 'старија', 'старији', 'старша', 'старшая', 'старши', 'старший', 'старэйшая', 'старэйшы', 'կրտսեր', "ג'וניור", 'ג׳וניור', 'ג’וניור', 'האב', 'הבן', 'ז"ל', 'ז״ל', 'שליט"א', 'שליט״א', 'الأب', 'الإبن', 'الابن', 'جونئیر', 'جونيئر', 'جونيور', 'جونیئر', 'جونیور', 'سينيور', 'سینئر', 'سینیئر', 'سینیور', 'जी', 'जुनियर', 'जूनियर', 'ज्युनियर', 'ज्यूनियर', 'महाराज', 'साहब', 'साहिब', 'साहेब', 'सीनियर', 'কনিষ্ঠ', 'জুনিয়র', 'জুনিয়ৰ', 'জ্যেষ্ঠ', 'বাবু', 'মহারাজ', 'সাহেব', 'সিনিয়র', 'ਜੂਨੀਅਰ', 'ਸੀਨੀਅਰ', 'જુનિયર', 'જૂનિયર', 'સિનિયર', 'ଜୁନିଅର', 'ଜୁନିୟର୍', 'இளையவர்', 'ஜூனியர்', 'மூத்தவர்', 'జూనియర్', 'సీనియర్', 'ಜೂನಿಯರ್', 'ജൂനിയർ', 'സീനിയർ', 'ජූනියර්', 'จูเนียร์', 'ซีเนียร์', 'ຈູເນຍ', 'ဂျူနီယာ', 'ၵျူႇၼီႇယႃႇ', 'უმცროსი', 'უფროსი', 'ጁኒየር', 'ጁንየር', 'ជូនៀរ', 'くん', 'さま', 'さん', 'ちゃん', 'ジュニア', '二世', '先生', '医学博士', '博士', '国会議員', '國會議員', '太平紳士', '太平绅士', '女士', '小姐', '教授', '様', '殿', '氏', '社長', '社长', '課長', '議員', '议员', '课长', '部長', '部长', '醫學博士', '교수님', '군', '님', '박사', '박사님', '선생님', '시니어', '씨', '양', '주니어', }) GLUED_HONORIFICS: frozenset[str] = frozenset({ 'くん', 'さま', 'さん', 'ちゃん', '二世', '先生', '医学博士', '国会議員', '國會議員', '太平紳士', '太平绅士', '女士', '小姐', '教授', '様', '社長', '社长', '課長', '議員', '议员', '课长', '醫學博士', '교수님', '님', '박사', '박사님', '선생님', '씨', }) SUFFIX_WORDS_AMBIGUOUS: frozenset[str] = frozenset({ 'fh', 'filh', 'filha', 'filho', 'fils', 'fo', 'net', 'neta', 'neto', 'netto', 'nt', 'sob', 'sobr', 'sobrinha', 'sobrinho', }) AGNOMENS: frozenset[str] = frozenset({ 'fh', 'filh', 'filha', 'filho', 'fo', 'net', 'neta', 'neto', 'netto', 'nt', 'sob', 'sobr', 'sobrinha', 'sobrinho', }) SUFFIX_ACRONYMS_AMBIGUOUS: frozenset[str] = frozenset({ 'aba', 'aca', 'ace', 'acha', 'ala', 'alp', 'ams', 'arm', 'asa', 'asp', 'ba', 'bem', 'caha', 'capa', 'capp', 'caro', 'cea', 'cela', 'cera', 'che', 'chi', 'ciro', 'csi', 'cua', 'do', 'ea', 'ed', 'ei', 'els', 'erd', 'faia', 'fic', 'ing', 'jd', 'lac', 'ma', 'meng', 'obi', 'oca', 'om', 'pa', 'pe', 'se', 'si', }) SUFFIX_ACRONYMS: frozenset[str] = frozenset({ '8-vsb', 'aas', 'aba', 'abc', 'abd', 'abpp', 'abr', 'aca', 'acas', 'ace', 'acha', 'acp', 'ae', 'aem', 'afasma', 'afc', 'afm', 'agacnp-bc', 'agsf', 'aia', 'aicp', 'ala', 'alc', 'alp', 'am', 'amd', 'ame', 'amieee', 'ams', 'aphr', 'apn', 'apr', 'aprn', 'aprn-cnp', 'apss', 'aqp', 'arm', 'arrc', 'asa', 'asc', 'asid', 'asla', 'asp', 'atc', 'awb', 'ba', 'bca', 'bcl', 'bcss', 'bds', 'bem', 'bls-i', 'bn', 'bpe', 'bpi', 'bpt', 'bs', 'bsc', 'bt', 'btcs', 'bts', 'cacts', 'cae', 'caha', 'caia', 'cams', 'cap', 'capa', 'capm', 'capp', 'caps', 'caro', 'cas', 'casp', 'cb', 'cbe', 'cbm', 'cbne', 'cbnt', 'cbp', 'cbrte', 'cbs', 'cbsp', 'cbt', 'cbte', 'cbv', 'cca', 'ccc', 'ccc-a', 'ccc-slp', 'ccc/slp', 'ccca', 'cccm', 'cce', 'cchp', 'ccie', 'ccim', 'cciso', 'ccm', 'ccmt', 'ccna', 'ccnp', 'ccp', 'ccp-c', 'ccpr', 'ccs', 'ccufc', 'cd', 'cdal', 'cdfm', 'cdmp', 'cds', 'cdt', 'cea', 'ceas', 'cebs', 'ceds', 'ceh', 'cela', 'cem', 'cep', 'cera', 'cet', 'cf-slp', 'cfa', 'cfc', 'cfcc', 'cfce', 'cfcm', 'cfe', 'cfeds', 'cfi', 'cfm', 'cfp', 'cfps', 'cfr', 'cfre', 'cga', 'cgap', 'cgb', 'cgc', 'cgfm', 'cgfo', 'cgm', 'cgma', 'cgp', 'cgr', 'cgsp', 'ch', 'chba', 'chdm', 'che', 'ches', 'chfc', 'chi', 'chmc', 'chmm', 'chp', 'chpa', 'chpe', 'chpln', 'chpse', 'chrm', 'chsc', 'chse', 'chse-a', 'chsos', 'chss', 'cht', 'cia', 'cic', 'cie', 'cig', 'cip', 'cipm', 'cips', 'ciro', 'cisa', 'cism', 'cissp', 'cla', 'clsd', 'cltd', 'clu', 'cm', 'cma', 'cmas', 'cmc', 'cmfo', 'cmg', 'cmp', 'cms', 'cmsp', 'cmt', 'cna', 'cnm', 'cnp', 'cota/l', 'cp', 'cp-c', 'cpa', 'cpacc', 'cpbe', 'cpcm', 'cpcu', 'cpe', 'cpfa', 'cpfo', 'cpg', 'cph', 'cpht', 'cpim', 'cpl', 'cplp', 'cpm', 'cpnp-pc', 'cpo', 'cpp', 'cppm', 'cprc', 'cpre', 'cprp', 'cpsc', 'cpsi', 'cpss', 'cpt', 'cpwa', 'crde', 'crisc', 'crma', 'crme', 'crna', 'cro', 'crp', 'crt', 'crtt', 'csa', 'csbe', 'csc', 'cscp', 'cscu', 'csep', 'csi', 'csm', 'csp', 'cspo', 'csre', 'csrte', 'csslp', 'cssm', 'cst', 'cste', 'ctbs', 'ctfa', 'cto', 'ctp', 'cts', 'cua', 'cusp', 'cva', 'cvo', 'cvp', 'cvrs', 'cwap', 'cwb', 'cwdp', 'cwep', 'cwna', 'cwne', 'cwp', 'cwsp', 'cxa', 'cyds', 'cysa', 'dabfm', 'dabvlm', 'dacvim', 'dbe', 'dc', 'dcb', 'dcm', 'dcmg', 'dcvo', 'dd', 'dds', 'ded', 'dep', 'dfc', 'dfm', 'diplac', 'diplom', 'djur', 'dm', 'dma', 'dmd', 'dmin', 'dmsc', 'dmsci', 'dnp', 'do', 'dphil', 'dpm', 'dpt', 'drb', 'drmp', 'drph', 'dsc', 'dsm', 'dso', 'dss', 'dtr', 'dvep', 'dvm', 'ea', 'ed', 'edd', 'ei', 'eit', 'els', 'emd', 'emt-b', 'emt-i/85', 'emt-i/99', 'emt-p', 'enp', 'erd', 'evp', 'faafp', 'faan', 'faap', 'fac-c', 'facc', 'facd', 'facem', 'facep', 'facha', 'facofp', 'facog', 'facp', 'facph', 'facs', 'faia', 'faicp', 'fala', 'fashp', 'fasid', 'fasla', 'fasma', 'faspen', 'fca', 'fcas', 'fcela', 'fd', 'fec', 'fhames', 'fic', 'ficf', 'fieee', 'fmp', 'fmva', 'fnp-bc', 'fnp-c', 'fnss', 'fp&a', 'fp-c', 'fpc', 'frm', 'fsa', 'fsdp', 'fws', 'gba', 'gbe', 'gcb', 'gchs', 'gcie', 'gcmg', 'gcsi', 'gcvo', 'gisp', 'git', 'gm', 'gmb', 'gmr', 'gphr', 'gri', 'grp', 'gsmieee', 'hccp', 'hrs', 'iaccp', 'iaee', 'iccm-d', 'iccm-f', 'idsm', 'ifgict', 'ing', 'iom', 'ipep', 'ipm', 'iso', 'issp-csp', 'issp-sa', 'itil', 'jd', 'jp', 'kbe', 'kcb', 'kchs/dchs', 'kcie', 'kcmg', 'kcsi', 'kcvo', 'kg', 'khs/dhs', 'knt', 'kp', 'kt', 'lac', 'lcmt', 'lcpc', 'lcsw', 'lcsw-c', 'lcsw-r', 'ld', 'lg', 'lisw-s', 'litk', 'litl', 'litp', 'llm', 'lm', 'lmsw', 'lmt', 'lp', 'lpa', 'lpc', 'lpn', 'lpss', 'lsi', 'lsit', 'lt', 'lvn', 'lvo', 'lvt', 'ma', 'maaa', 'mb', 'mba', 'mbbch', 'mbchb', 'mbe', 'mbs', 'mc', 'mcct', 'mcdba', 'mches', 'mcm', 'mcp', 'mcpd', 'mcsa', 'mcsd', 'mcse', 'mct', 'md', 'mda', 'mdb', 'mdbb', 'mdep', 'mdhb', 'mdiv', 'mdl', 'mdphd', 'mem', 'meng', 'mfa', 'mhsc', 'micp', 'mieee', 'mirm', 'mle', 'mls', 'mlse', 'mlt', 'mm', 'mmad', 'mmas', 'mmed', 'mmsc', 'mnaa', 'mnae', 'mp', 'mpa', 'mph', 'mphil', 'mpse', 'mra', 'ms', 'msa', 'msc', 'mscmsm', 'msm', 'mt', 'mt-bc', 'mts', 'mvo', 'nbc-his', 'nbcch', 'nbcch-ps', 'nbcdch', 'nbcdch-ps', 'nbcfch', 'nbcfch-ps', 'nbct', 'ncarb', 'nccp', 'ncidq', 'ncps', 'ncso', 'ncto', 'nd', 'ndtr', 'nmd', 'np', 'np-c', 'nraemt', 'nremr', 'nremt', 'nrp', 'obe', 'obi', 'oca', 'ocm', 'ocp', 'od', 'om', 'oscp', 'ot', 'otr/l', 'pa', 'pa-c', 'pcc', 'pci', 'pe', 'pfmp', 'pg', 'pgmp', 'pharmd', 'phc', 'phd', 'phr', 'phrca', 'pla', 'pls', 'pmc', 'pmhnp-bc', 'pmi-acp', 'pmp', 'pp', 'pps', 'prm', 'psm', 'psp', 'psyd', 'pt', 'pta', 'qam', 'qc', 'qcsw', 'qfsm', 'qgm', 'qpm', 'qsd', 'qsp', 'ra', 'rba', 'rci', 'rcp', 'rd', 'rdcs', 'rdh', 'rdms', 'rdn', 'res', 'rfp', 'rhca', 'rid', 'rls', 'rmsks', 'rn', 'rp', 'rpa', 'rpa-c', 'rph', 'rpl', 'rrc', 'rrt', 'rrt-accs', 'rrt-nps', 'rrt-sds', 'rtrp', 'rvm', 'rvt', 'same', 'sasm', 'sccp', 'scd', 'scm', 'scmp', 'se', 'secb', 'sfp', 'sgm', 'shrm-cp', 'shrm-scp', 'si', 'siie', 'smieee', 'sphr', 'sscp', 'stb', 'stmieee', 'tbr-ct', 'td', 'thd', 'thm', 'ud', 'usa', 'usaf', 'usar', 'uscg', 'usmc', 'usn', 'usnr', 'uxc', 'uxmc', 'vc', 'vcp', 'vd', 'vrd', 'whnp-bc', }) CAPITALIZATION_EXCEPTIONS: dict[str, str] = { 'bsc': 'BSc', 'bt': 'Bt', 'ch.ma': 'Ch.ma', 'ch.mo': 'Ch.mo', 'chfc': 'ChFC', 'cpht': 'CPhT', 'diplac': 'DiplAc', 'dmin': 'DMin', 'dmsc': 'DMSc', 'dmsci': 'DMSci', 'dott.ssa': 'Dott.ssa', 'dphil': 'DPhil', 'drph': 'DrPH', 'dsc': 'DSc', 'gent.le': 'Gent.le', 'gent.ma': 'Gent.ma', 'gent.mo': 'Gent.mo', 'ill.ma': 'Ill.ma', 'ill.mo': 'Ill.mo', 'ing': 'Ing', 'knt': 'Knt', 'kt': 'Kt', 'mbbch': 'MBBCh', 'mbchb': 'MBChB', 'mdiv': 'MDiv', 'mdphd': 'MDPhD', 'mhsc': 'MHSc', 'mmed': 'MMed', 'mmsc': 'MMSc', 'mphil': 'MPhil', 'msc': 'MSc', 'ní': 'Ní', 'pharmd': 'PharmD', 'phc': 'PhC', 'phd': 'PhD', 'preg.ma': 'Preg.ma', 'preg.mo': 'Preg.mo', 'prof.ssa': 'Prof.ssa', 'psyd': 'PsyD', 'rph': 'RPh', 'scd': 'ScD', 'scm': 'ScM', 'sig.na': 'Sig.na', 'sig.ra': 'Sig.ra', 'thd': 'ThD', 'thm': 'ThM', 'ua': 'Ua', 'ó': 'Ó', } derek73-python-nameparser-456b024/nameparser/config/_deprecated.py000066400000000000000000000253101527303271700251230ustar00rootroot00000000000000"""The 1.x vocabulary names, served from their 2.2 homes. The 2.0 API named its concepts for what they are -- particles, bound given names, given-name titles, suffix words -- while the data modules kept the 1.x names a little longer. #293 moved all four to match. A 1.x name resolves to its 2.2 constant, warns at the line that read it, and names the path to migrate to; the whole layer goes away in 3.0 with the rest of the v1 facade. Two of the four moved module and all: prefixes -> particles and bound_first_names -> bound_given_names, whose old modules are now data-free shims that are nothing but a docstring and an alias table. The other two renamed a constant in place, so titles.py and suffixes.py carry their alias table at the bottom of the file, beside their data. Same PEP 562 hook as nameparser/locales/__init__.py, but deliberately without that module's write-back: a retired name stays served by ``__getattr__`` for the life of the process, so every read reaches the warning. Suppressing the repeats is the warnings module's own job, and it does it per LOCATION -- ``__warningregistry__`` lives in the READING module's globals and is keyed on (text, category, lineno). That is the granularity the advice is written at: one line that reads a retired name is told once however often it runs, and a second line, in that file or another, is told for itself. Caching the resolved value into the module globals instead would silence every reader after the first, and the first is whoever imported earliest -- routinely a dependency, whose author is not the person who has to edit anything. PEP 562 defines the hook for attribute ACCESS and nothing else, which is why every alias-bearing module also carries an ``__all__`` naming its retired names: ``from x import *`` reads ``__all__``, or failing that the module ``__dict__``, and consults ``__getattr__`` in neither case. Without the list a star import binds no retired name and issues no diagnostic. See the note at the ``__all__`` in prefixes.py. """ from __future__ import annotations import importlib import sys import warnings from collections.abc import Callable, Mapping from typing import Any _MESSAGE = ( "{module}.{old} is deprecated since 2.2 and will be removed in 3.0; " "use {new_module}.{new} instead." ) #: 3.0's migration guide, which every 2.8.1 removal message points at: #: Read the Docs' "latest" builds master, the 3.0 cycle. MIGRATION_GUIDE = "https://nameparser.readthedocs.io/en/latest/migrate.html" # 2.8.1: the live names of a module 3.0 deletes (#1007, #1008). Every # message opens "nameparser.config.", names the removal and closes on # the guide; pyproject's filterwarnings ignores the family by message, # and tests/v2/test_removals_2_8_1.py asserts each one. _REMOVED_MESSAGE = ( "{module}.{name} is deprecated and will be removed in 3.0 ({issue}); " "{replacement}. See {guide}" ) def words_of(args: str, which: str = "") -> str: """The replacement clause for a vocabulary constant: the 3.0 call listing a kind's words (``Lexicon.words``, #1007). Without `which` the call lists exactly the set's words over the default vocabulary (checked against master, 2026-11-02); with it the call lists more or other words, and `which` says what the set held instead.""" call = f"in 3.0, Lexicon.default().words({args})" if not which: return f"{call} lists the same words" return (f"{call} lists every word of that kind, where this set held " f"{which}; Lexicon.default().readings_of(word) says what one " f"word is") def removal_message(module: str, name: str) -> str | None: """The 2.8.1 removal message for ``module.name``, or ``None`` where 3.0 keeps the name. Reads the module's hook, so it warns nothing.""" hook = vars(importlib.import_module(module)).get("__getattr__") removed: Mapping[str, str] = getattr(hook, "removed_messages", {}) return removed.get(name) def _quiet_value(module: str, name: str) -> Any: # noqa: ANN401 loaded = importlib.import_module(module) hook = vars(loaded).get("__getattr__") held: Mapping[str, Any] = getattr(hook, "removed_values", {}) return held[name] if name in held else getattr(loaded, name) def alias_getattr( module: str, aliases: Mapping[str, tuple[str, str]], removed: Mapping[str, str] | None = None, namespace: dict[str, Any] | None = None, issue: str = "#1007", ) -> tuple[Callable[[str], Any], Callable[[], list[str]]]: """Build the ``__getattr__``/``__dir__`` pair for a module carrying deprecated vocabulary names. ``aliases`` maps each old attribute name to the ``(module, name)`` it now lives at. Assign the result at module level, in the ``else`` of a ``TYPE_CHECKING`` guard that declares the same names:: if TYPE_CHECKING: OLD_NAME: frozenset[str] # 1.x alias, removed in 3.0 (#293) else: __getattr__, __dir__ = alias_getattr(__name__, {...}) (a placeholder rather than a real retired name, for the reason the ``stacklevel`` comment below gives) The guard is load-bearing. mypy honors an assigned module ``__getattr__`` (PEP 484's convention for one) and thereafter answers EVERY missing attribute of that module from its return type, so a bare assignment turns off missing-attribute checking for the whole module. On titles.py and suffixes.py, which keep their live constants and are still imported from, that cost real checking: ``from nameparser.config.titles import TITLE`` type- checked clean. Keeping the assignment out of the type checker's view restores it, and the declarations in the other branch type each retired name as the ``frozenset[str]`` it is rather than ``Any``. The package ships ``py.typed``, so both reach callers. Runtime is untouched -- ``TYPE_CHECKING`` is False, so only the ``else`` ever runs -- and the two branches delete together in 3.0. Which leaves the ``Any`` return below typing nothing outside this module: mypy reads no module ``__getattr__`` for the alias-bearing modules any more, and does not analyze the ``else`` branch it is assigned in. It stays ``Any`` as what ``getattr`` itself returns. ``removed`` (2.8.1) maps each LIVE name 3.0 deletes to the clause naming its 3.0 replacement, and ``namespace`` is the module's ``globals()``: each such name is popped out of it into the hook, so the module keeps its assignment statement -- which is where autodoc finds the constant's documentation -- while every read reaches ``__getattr__`` and warns at the reader's line. The library reads these values from ``nameparser.config._compiled`` (and regexes.py's ``_REGEXES``), never through here, so only a caller's read warns. """ removed = dict(removed or {}) values: dict[str, Any] = {} if removed: if namespace is None: raise TypeError("alias_getattr: removed names need namespace=") values = {name: namespace.pop(name) for name in removed} messages = { name: _REMOVED_MESSAGE.format( module=module, name=name, issue=issue, replacement=replacement, guide=MIGRATION_GUIDE) for name, replacement in removed.items()} def __getattr__(name: str) -> Any: # noqa: ANN401 if name in values: # 2: the reading frame, as for an alias below warnings.warn(messages[name], DeprecationWarning, stacklevel=2) return values[name] target = aliases.get(name) if target is None: raise AttributeError(f"module {module!r} has no attribute {name!r}") new_module, new_name = target # resolved BEFORE warning, so a mistyped alias target fails as # a ModuleNotFoundError or an AttributeError from here rather # than first advising the reader to move to a path that does # not exist. Quietly: a target 3.0 removes too is held by its # module's hook, whose own warning would name this file. value = _quiet_value(new_module, new_name) message = _MESSAGE.format( module=module, old=name, new_module=new_module, new=new_name) # one warning saying so, rather than advice to move to a name # that warns in its turn also = removal_message(new_module, new_name) if also is not None: message += " That name is removed in 3.0 too: " + also warnings.warn( message, DeprecationWarning, # 2: the frame that touched the name -- for the # `from nameparser.config.prefixes import ...` form, the # importing module, which is the place that has to be # edited. Which name it imports does not matter here, and # spelling one out would put a retired name in a file that # serves no single vocabulary (tests/v2/test_config_aliases # ::test_no_internal_code_reads_a_retired_vocabulary_name) stacklevel=2, ) return value def __dir__() -> list[str]: # UNION, not just the aliases: a module __dir__ REPLACES the # default listing rather than adding to it, so dropping the # module's own globals here would take the live constants out # of tab completion and every getattr-free member scan -- # autodoc's included. Pinned by test_config_aliases # ::test_dir_lists_the_live_names_as_well_as_the_retired_ones. # # .get, not [...]: a module dropped from sys.modules -- a test # that reloads the package, a plugin teardown -- would otherwise # make dir() raise KeyError, which is not among the things dir() # may do to a caller. The aliases are held in the closure and # are still nameable, so they are what is left to list (#356). live = sys.modules.get(module) names = set(vars(live)) if live is not None else set() return sorted(names | set(aliases) | set(values)) # The table itself, reachable without tripping a warning. __all__ is # hand-written per module (it must stay in SOURCE order for autodoc, # which this function cannot know), so the two lists are maintained # separately and a row added to only one of them is the failure # fc46a9b closed for the other direction: a table row missing from # __all__ is silently dropped by `from x import *` with no warning # and no AttributeError. test_config_aliases # ::test_every_alias_table_row_reaches_star_import cross-checks them. __getattr__.deprecated_aliases = dict(aliases) # type: ignore[attr-defined] # the 2.8.1 removals, read by removal_message and _quiet_value __getattr__.removed_messages = messages # type: ignore[attr-defined] __getattr__.removed_values = values # type: ignore[attr-defined] return __getattr__, __dir__ derek73-python-nameparser-456b024/nameparser/config/_invariants.py000066400000000000000000000035321527303271700252030ustar00rootroot00000000000000"""Import-time hygiene checks for vocabulary data still written in Python. Since the word lists moved to nameparser/vocab/*.toml, whose compiler (tools/vocab/build.py) refuses an unnormalized spelling itself, the one constant this module still checks is ``_ko_surnames.KOREAN_SURNAMES``, a segmentation list outside the per-word model. A constant is looked up in normalized form, so a stray capital or a surrounding space makes an entry unreachable by a direct membership test -- ``'김 ' in KOREAN_SURNAMES`` would be False -- even though the parser's own ingest (:func:`nameparser._lexicon._normalize`) normalizes it away and papers over the typo. Checking at import turns a silently-inert entry into an immediate failure. Deliberately a weaker fold than ``_normalize``, which also NFC-composes and strips edge full stops. Importing ``_lexicon`` here would work (its config imports sit inside ``_default_lexicon``), but a data module's hygiene should not depend on the parser's own fold. It asserts that an entry is stored lowercase, edge-clean and, where it is a phrase, single-spaced. """ from __future__ import annotations from collections.abc import Iterable def assert_normalized(name: str, entries: Iterable[str]) -> None: """Assert every entry is stored lowercase, edge-clean, and -- where it is a phrase -- single-spaced.""" # " ".join(w.split()) rather than w.strip(): it subsumes the strip # and additionally collapses interior runs, which is what a phrase # entry made worth checking. For a single word the two are # identical, so no existing constant's verdict changes. offenders = sorted(w for w in entries if w != " ".join(w.lower().split())) assert not offenders, ( f"{name} entries must be stored lowercase, without edge " f"whitespace, and single-spaced if a phrase; " f"offending: {offenders}" ) derek73-python-nameparser-456b024/nameparser/config/_ko_surnames.py000066400000000000000000000045001527303271700253470ustar00rootroot00000000000000from nameparser.config._invariants import assert_normalized # Born a frozenset (#293's convention: a mutable module constant would # silently desync the cached ``Lexicon.default()`` from the shim's # per-construction copies). # # Single-syllable surnames in census rank order, 10 per row; the cut is # the top ~94 -- append new entries at the tail, do not alphabetize # (rank order is the only in-file record of where the coverage floor # sits). KOREAN_SURNAMES = frozenset({ "김", "이", "박", "최", "정", "강", "조", "윤", "장", "임", "한", "오", "서", "신", "권", "황", "안", "송", "전", "홍", "유", "고", "문", "양", "손", "배", "백", "허", "남", "심", "노", "하", "곽", "성", "차", "주", "우", "구", "민", "류", "나", "진", "지", "엄", "채", "원", "천", "방", "공", "현", "함", "변", "염", "여", "추", "도", "소", "석", "선", "설", "마", "길", "연", "위", "표", "명", "기", "반", "왕", "금", "옥", "육", "인", "맹", "제", "모", "탁", "국", "은", "편", "용", "예", "경", "봉", "사", "부", "가", "복", "태", "목", "형", "계", "피", "두", # the two-syllable surnames in current use (census-complete); # longest-first matching splits 남궁민수 as 남궁+민수, not 남+궁민수 "남궁", "황보", "제갈", "사공", "선우", "서문", "독고", "동방", "망절", }) """ Korean surnames (#271), used by the 2.0 API's unspaced-name segmentation (``Lexicon.default().surnames``): a hangul token like "김민준" splits into surname + given name by longest match. This ships as DEFAULT vocabulary because it is self-selecting -- a hangul entry can only ever match hangul text -- and hangul text is unambiguously Korean. Chinese surnames deliberately live in ``nameparser.locales.zh`` instead (Han segmentation is opt-in; a zh list corrupts Japanese kanji names). Source: the 2015 South Korean census surname tables -- the most common single-syllable surnames (Kim/Lee/Park alone cover ~45% of the population) plus the two-syllable surnames in current use. A coverage floor, not the complete census roster: extend with ``Lexicon.default().add(surnames={...})``. Consumed by the 2.0 parser's default lexicon. The 1.x parser does not read this module. """ assert_normalized("KOREAN_SURNAMES", KOREAN_SURNAMES) derek73-python-nameparser-456b024/nameparser/config/_titles_2_6_0.py000066400000000000000000000314641527303271700252230ustar00rootroot00000000000000"""The title list nameparser 2.6.0 shipped, frozen: the v1 default ``Constants().titles`` the facade translates from (decisions/H9, 2026-10-26). Never edited, and deleted with the facade in 3.0. A word a later release moved out of TITLES into TITLES_AMBIGUOUS, a title only inside a title phrase (rules/H9), is still a v1 title, so a v1 caller can test, iterate and remove it as 2.6.0 let them; ``Constants._build_snapshot`` sends it to ``titles_ambiguous`` while the caller keeps it. Generated from the 2.6.0 wheel's ``nameparser.config.titles.TITLES``. """ TITLES_2_6_0: frozenset[str] = frozenset({ '10th', '1lt', '1sgt', '1st', '1stlt', '1stsgt', '2lt', '2nd', '2ndlt', '3rd', '4th', '5th', '6th', '7th', '8th', '9th', 'a1c', 'ab', 'abbess', 'abbot', 'abolitionist', 'academic', 'acolyte', 'activist', 'actor', 'actress', 'adept', 'adjutant', 'adm', 'administrator', 'admiral', 'advertising', 'adviser', 'advocate', 'air', 'akhoond', 'alderman', 'almoner', 'ambassador', 'amn', 'analytics', 'anarchist', 'animator', 'anthropologist', 'appellate', 'apprentice', 'arbitrator', 'archbishop', 'archdeacon', 'archdruid', 'archduchess', 'archduke', 'archeologist', 'architect', 'arhat', 'army', 'arranger', 'assistant', 'assoc', 'associate', 'asst', 'astronomer', 'attache', 'attaché', 'attorney', 'aunt', 'auntie', 'author', 'award-winning', 'ayatollah', 'baba', 'bailiff', 'ballet', 'bandleader', 'banker', 'banner', 'bapak', 'bard', 'baron', 'baroness', 'barrister', 'baseball', 'batoni', 'bearer', 'behavioral', 'bench', 'bg', 'bgen', 'biblical', 'bibliographer', 'binibini', 'biochemist', 'biographer', 'biologist', 'bishop', 'blessed', 'blogger', 'blues', 'bodhisattva', 'bookseller', 'botanist', 'bp', 'brigadier', 'briggen', 'british', 'broadcaster', 'brother', 'buddha', 'burgess', 'burlesque', 'business', 'businessman', 'businesswoman', 'bwana', 'canon', 'capt', 'captain', 'cardinal', 'cartographer', 'cartoonist', 'catholicos', 'ccmsgt', 'cdr', 'celebrity', 'ceo', 'cfo', 'ch.ma', 'ch.mo', 'chair', 'chairs', 'chancellor', 'chaplain', 'charge', 'chargé', 'chef', 'cheikh', 'chemist', 'chief', 'chieftain', 'choreographer', 'civil', 'classical', 'clergyman', 'clerk', 'cllr', 'cmsaf', 'cmsgt', 'co-chair', 'co-chairs', 'co-founder', 'coach', 'col', 'collector', 'colonel', 'comedian', 'comedienne', 'comic', 'commander', 'commander-in-chief', 'commissioner', 'commodore', 'composer', 'compositeur', 'comptroller', 'computer', 'comtesse', 'conductor', 'consultant', 'controller', 'corporal', 'corporate', 'correspondent', 'councillor', 'counsel', 'counselor', 'count', 'countess', 'courtier', 'cpl', 'cpo', 'cpt', 'credit', 'criminal', 'criminologist', 'critic', 'csm', 'curator', 'customs', 'cwo-2', 'cwo-3', 'cwo-4', 'cwo-5', 'cwo2', 'cwo3', 'cwo4', 'cwo5', 'cyclist', "d'affaires", 'dame', 'dancer', 'dcn', 'deacon', 'delegate', 'deputy', 'designated', 'designer', 'detective', 'developer', 'dhr', 'dipl.-ing', 'diplomat', 'dir', 'director', 'discovery', 'dissident', 'district', 'division', 'doamna', 'doamnă', 'docent', 'docket', 'doctor', 'domnișoara', 'domnul', 'dott.ssa', 'doyen', 'dpty', 'dr', 'dra', 'dramatist', 'druid', 'drummer', 'duchesse', 'dutchess', 'ecologist', 'economist', 'editor', 'edler', 'edmi', 'edohen', 'educator', 'effendi', 'ekegbian', 'elerunwon', 'eminence', 'emperor', 'empress', 'encik', 'engineer', 'english', 'ens', 'entertainer', 'entrepreneur', 'envoy', 'erzbischof', 'essayist', 'evangelist', 'excellency', 'excellent', 'exec', 'executive', 'expert', 'fadm', 'familie', 'family', 'father', 'federal', 'fh-prof', 'field', 'film', 'financial', 'first', 'flag', 'flying', 'foreign', 'forester', 'founder', 'fr', 'frau', 'freifrau', 'freiherr', 'friar', 'frk', 'fru', 'fräulein', 'fröken', 'frøken', 'frú', 'fürst', 'fürsterzbischof', 'gaf', 'gen', 'general', 'generalissimo', 'gent.le', 'gent.ma', 'gent.mo', 'gentiluomo', 'giani', 'ginang', 'ginoo', 'goodman', 'goodwife', 'gospodin', 'gospođa', 'gospođica', 'gov', 'governor', 'graaf', 'graf', 'grafas', 'grafi', 'grand', 'greifi', 'greve', 'group', 'großfürst', 'gräfin', 'guitarist', 'guru', 'gyani', 'gysgt', 'hajji', 'headman', 'heir', 'heiress', 'her', 'hereditary', 'heren', 'herr', 'herra', 'herren', 'herrn', 'herzog', 'high', 'highness', 'his', 'historian', 'historicus', 'historien', 'holiness', 'hon', 'honorable', 'honourable', 'host', 'hr', 'hrabia', 'härra', 'ill.ma', 'ill.mo', 'illustrator', 'imam', 'industrialist', 'information', 'instructor', 'intelligence', 'intendant', 'inventor', 'investigator', 'investor', 'journalist', 'journeyman', 'judge', 'judicial', 'juffrouw', 'jurist', 'kalbatoni', 'keyboardist', 'king', "king's", 'kingdom', 'knight', 'knowledge', 'krahv', 'kreivi', 'lady', 'lama', 'lamido', 'law', 'lawyer', 'lcdr', 'lcpl', 'leader', 'lecturer', 'legal', 'librarian', 'lieutenant', 'linguist', 'literary', 'lord', 'lt', 'ltc', 'ltcol', 'ltg', 'ltgen', 'ltjg', 'lyricist', 'madam', 'madame', 'mademoiselle', 'mag', 'mag-judge', 'mag/judge', 'magistrate', 'magistrate-judge', 'magnate', 'maharajah', 'maharani', 'mahdi', 'maid', 'maj', 'majesty', 'majgen', 'manager', 'marcher', 'marchess', 'marchioness', 'marketing', 'marquess', 'marquis', 'marquise', 'master', 'mathematician', 'mathematics', 'matriarch', 'mayor', 'mcpo', 'mcpoc', 'mcpon', 'me', 'member', 'memoirist', 'meneer', 'merchant', 'met', 'metropolitan', 'mevr', 'mevrouw', 'mevrouwe', 'mg', 'mgr', 'mgysgt', 'military', 'minister', 'miss', 'misses', 'missionary', 'mister', 'mlle', 'mme', 'mobster', 'model', 'monk', 'monseigneur', 'monsieur', 'monsignor', 'most', 'mother', 'mountaineer', 'mpco-cg', 'mr', 'mrs', 'ms', 'msg', 'msgt', 'mufti', 'mullah', 'municipal', 'murshid', 'musician', 'musicologist', 'mx', 'mystery', 'nanny', 'narrator', 'national', 'naturalist', 'navy', 'neiti', 'neuroscientist', 'novelist', 'nurse', 'obstetritian', 'officer', 'opera', 'operating', 'ornithologist', 'owura', 'painter', 'paleontologist', 'paní', 'pastor', 'patriarch', 'pd', 'pediatrician', 'personality', 'petty', 'pfc', 'pharaoh', 'philantropist', 'philosopher', 'photographer', 'physician', 'physicist', 'pianist', 'pilot', 'pioneer', 'pir', 'player', 'playwright', 'po1', 'po2', 'po3', 'poet', 'police', 'political', 'politician', 'ponas', 'ponia', 'pope', 'prefect', 'preg.ma', 'preg.mo', 'preili', 'prelate', 'premier', 'pres', 'presbyter', 'president', 'presiding', 'priest', 'priestess', 'primate', 'prime', 'prin', 'prince', 'princess', 'principal', 'printer', 'printmaker', 'prinz', 'prior', 'priv.-doz', 'private', 'pro', 'producer', 'prof', 'prof.ssa', 'professor', 'proua', 'provost', 'pslc', 'psychiatrist', 'psychologist', 'publisher', 'pursuivant', 'pv2', 'pvt', 'queen', "queen's", 'ra', 'rabbi', 'radio', 'radm', 'rangatira', 'ranger', 'rdml', 'rear', 'rebbe', 'registrar', 'reichsgraf', 'rep', 'representative', 'researcher', 'resident', 'rev', 'revenue', 'reverend', 'right', 'risk', 'ritter', 'rock', 'rouva', 'royal', 'rt', 'sailor', 'saint', 'sainte', 'saoshyant', 'satirist', 'scholar', 'schoolmaster', 'scientist', 'scpo', 'screenwriter', 'secretary', 'security', 'seigneur', 'senator', 'senhor', 'senhora', 'senhorita', 'senior', 'senior-judge', 'sergeant', 'servant', 'señor', 'señora', 'señores', 'señorita', 'señoritas', 'sfc', 'sgm', 'sgt', 'sgtmaj', 'sgtmajmc', 'shaik', 'shaikh', 'shayk', 'shaykh', 'sheik', 'sheikh', 'shekh', 'sheriff', 'siddha', 'sig.na', 'sig.ra', 'signor', 'signora', 'signore', 'signorina', 'singer', 'singer-songwriter', 'sir', 'sister', 'slečna', 'sma', 'smsgt', 'sn', 'soccer', 'social', 'sociologist', 'software', 'soldier', 'solicitor', 'soprano', 'spc', 'speaker', 'special', 'sr', 'sra', 'sres', 'srta', 'srtas', 'ssg', 'ssgt', 'st', 'staff', 'state', 'states', 'strategy', 'subaltern', 'subedar', 'suffragist', 'sultan', 'sultana', 'superior', 'supreme', 'surgeon', 'swami', 'swordbearer', 'sysselmann', 'tax', 'teacher', 'technical', 'technologist', 'television', 'tenor', 'theater', 'theatre', 'theologian', 'theorist', 'timi', 'tirthankar', 'translator', 'travel', 'treasurer', 'tsar', 'tsarina', 'tsgt', 'uk', 'uncle', 'ungfrú', 'united', 'univ.prof', 'us', 'vadm', 'vardapet', 'vc', 'venerable', 'verderer', 'vicar', 'vice', 'viscount', 'vizier', 'vocalist', 'voice', 'vrouwe', 'warden', 'warrant', 'wing', 'wo-1', 'wo1', 'wo2', 'wo3', 'wo4', 'wo5', 'woodman', 'wp', 'writer', 'zoologist', 'δρ', 'κα', 'καθ', 'κος', 'акад', 'г-жа', 'г-н', 'д-р', 'пан', 'пані', 'проф', "גב'", 'גברת', 'גב׳', 'ד"ר', 'ד״ר', 'הרב', 'מר', 'עו"ד', 'עו״ד', "פרופ'", 'פרופסור', 'פרופ׳', 'أستاذ', 'أستاذة', 'الأستاذ', 'الأستاذة', 'الحاج', 'الحاجة', 'الدكتور', 'الدكتورة', 'الشيخ', 'الشيخة', 'دكتور', 'دكتورة', 'مهندس', 'गुरु', 'जनाब', 'डा', 'डॉ', 'डॉक्टर', 'न्यायमूर्ति', 'पं', 'पंडित', 'प्रा', 'प्राध्यापक', 'प्रो', 'प्रोफेसर', 'बाबा', 'बाबू', 'महात्मा', 'महाराजा', 'मौलाना', 'श्री', 'श्रीमती', 'श्रीमान', 'श्रीयुत', 'संत', 'सरदार', 'सुश्री', 'सौ', 'स्वामी', 'অধ্যাপক', 'আলহাজ', 'আলহাজ্ব', 'গুরু', 'জনাব', 'ড', 'ডঃ', 'ডক্টর', 'ডা', 'ডাঃ', 'ডাক্তার', 'প্রফেসর', 'বাবা', 'বিচারপতি', 'মাওলানা', 'মি', 'মিঃ', 'মিসেস', 'মুফতি', 'মো', 'মোঃ', 'মোছা', 'মোছাঃ', 'মোসা', 'মোসাঃ', 'শ্রী', 'শ্রীমতী', 'শ্রীল', 'স্বামী', }) derek73-python-nameparser-456b024/nameparser/config/bound_first_names.py000066400000000000000000000016511527303271700263670ustar00rootroot00000000000000"""Deprecated alias module: the bound given-name vocabulary moved to :mod:`nameparser.config.bound_given_names` in 2.2 (#293), where the constant name matches the :class:`~nameparser.Lexicon` field it feeds. Reading a name from here warns and returns the constant from its new home; this module is deleted in 3.0. """ from typing import TYPE_CHECKING from nameparser.config._deprecated import alias_getattr # Declared for the type checker, served by __getattr__ at runtime -- # see the note in prefixes.py and alias_getattr's docstring. if TYPE_CHECKING: BOUND_FIRST_NAMES: frozenset[str] else: __getattr__, __dir__ = alias_getattr(__name__, { "BOUND_FIRST_NAMES": ( "nameparser.config.bound_given_names", "BOUND_GIVEN_NAMES"), }) # Star imports read __all__ and never the module __getattr__ -- see the # note in prefixes.py for what that cost before this line existed. __all__ = ["BOUND_FIRST_NAMES"] derek73-python-nameparser-456b024/nameparser/config/bound_given_names.py000066400000000000000000000042051527303271700263460ustar00rootroot00000000000000# The words, and the vetting notes that stood beside them here, live in # nameparser/vocab/*.toml; tools/vocab/build.py # compiles them into _compiled.py and runs the checks that were # import-time asserts here (decisions/vocabulary-model). from nameparser.config import _compiled #: Bound Arabic given-name prefixes that attach to the following word to #: form one given name (e.g. "abdul salam smith" → given name "abdul #: salam"). They are never standalone names. The join is a group-stage #: rule on the FIRST non-title piece, so it is not about roles -- it #: fires whatever name_order later assigns. It reserves a piece for what #: follows: three pieces that are neither title nor suffix in a main #: segment -- counting the bound word's OWN piece even where that word is #: also suffix vocabulary, since it is the piece the rule has claimed #: rather than one left to spare -- which is why two-word "abdul salam" #: stays given "abdul" plus #: family "salam"; only two after a family comma, where the family name #: is already fixed ("salam, abdul rahman" → given "abdul rahman"). #: Mirrors :py:data:`~nameparser.config.particles.PARTICLES`, which #: chains onto the piece that follows it. BOUND_GIVEN_NAMES: frozenset[str] = _compiled.BOUND_GIVEN_NAMES # Star imports read __all__ and never the module __getattr__ -- see the # note in prefixes.py. Without it `_compiled`, imported only to re-export # the constant, is bound by a star import as though it were vocabulary # (#356). __all__ = ["BOUND_GIVEN_NAMES"] # 2.8.1: 3.0 deletes this module (#1007), so each constant is served by # __getattr__ and warns at the line that reads it, naming what 3.0 # offers in its place (alias_getattr's docstring says how the # assignment above stays documented). The library reads _compiled, never # this module. Hidden from mypy as alias_getattr explains; goes in 3.0. from typing import TYPE_CHECKING # noqa: E402 from nameparser.config._deprecated import alias_getattr, words_of # noqa: E402 if not TYPE_CHECKING: __getattr__, __dir__ = alias_getattr(__name__, {}, removed={ "BOUND_GIVEN_NAMES": words_of('kind="bound_given"'), }, namespace=globals()) derek73-python-nameparser-456b024/nameparser/config/capitalization.py000066400000000000000000000071671527303271700257110ustar00rootroot00000000000000# The words, and the vetting notes that stood beside them here, live in # nameparser/vocab/*.toml: a mask is the # `cased` property of the word's reading, one per word, and # tools/vocab/build.py refuses one that does not spell its word's letters # and digits (#459) before Lexicon ever sees it. Still a plain dict, the # one vocabulary constant not frozen (AGENTS.md's vocabulary and # configuration-layer section, "Neither dict was frozen"). from nameparser.config import _compiled CAPITALIZATION_EXCEPTIONS = _compiled.CAPITALIZATION_EXCEPTIONS """ Words whose case ``str.capitalize()`` gets wrong, each mapped to a case MASK: the key's own letters and digits, each in the case it should take. Case repair lays the mask over the word as it was written and keeps every other character where the writer put it, so ``'phd': 'PhD'`` repairs ``phd`` to ``PhD`` and ``ph.d.`` to ``Ph.D.``. The mask's own punctuation marks where its letters are joined into one run versus split apart, and is never written into the word -- repair keeps the writer's own punctuation. A value that does not spell its key's letters and digits is a ``ValueError`` when a ``Lexicon`` is built from it. An acronym the suffix vocabulary already lists, written plainly or in dotted form, and a roman numeral need no entry: case repair writes a suffix of either kind in capitals by itself. The map carries most of the listed acronyms whose conventional spelling is NOT all capitals (``DSc``, ``PsyD``, ``PharmD``), so ``psyd`` repairs to ``PsyD`` rather than ``PSYD``. The exception is an acronym that is also a name word (``meng``, ``edd``): a mask applies in every role, so it gets none, and ``john smith edd`` repairs to ``EDD`` (decisions/vocabulary-exclusions, CAPITALIZATION_EXCEPTIONS -- meng, edd, lac, ded). A suffix the writer spelled in more than one case (``EdD``) needs no entry either: repair keeps it as written unless forced. A caller's OWN acronym -- one absent from ``suffix_acronyms`` -- depends on how it is written. Written PLAINLY (``dclinpsy``) it parses as an ordinary name word and repairs as one (``Dclinpsy``). Written DOTTED (``d.clinpsy.``) it is a suffix by shape alone, with no vocabulary entry needed to read it as one, and repairs in all capitals the same as a listed acronym does (``D.CLINPSY.``). Either way, a caller who wants a specific spelling needs a ``suffix_acronyms`` entry (``Lexicon.add(suffix_acronyms={...})`` in the 2.0 API, ``constants.suffix_acronyms.add(...)`` in the v1 one) or a mask of its own. The map also carries the Irish patronymic particles ``ó``, ``ní`` and ``ua`` (#604): case repair writes a particle in lowercase (``de la Vega``), Irish writes these capitalized (``Liam Ó Murchú``), and a mask outranks the particle lowercase (rules/R4). """ # 2.8.1: 3.0 deletes this module (#1007), so each constant is served by # __getattr__ and warns at the line that reads it, naming what 3.0 # offers in its place (alias_getattr's docstring says how the # assignment above stays documented). The library reads _compiled, never # this module. Hidden from mypy as alias_getattr explains; goes in 3.0. from typing import TYPE_CHECKING # noqa: E402 from nameparser.config._deprecated import alias_getattr # noqa: E402 if not TYPE_CHECKING: __getattr__, __dir__ = alias_getattr(__name__, {}, removed={ "CAPITALIZATION_EXCEPTIONS": "in 3.0, read Lexicon.default().capitalization_exceptions, which stays a field", }, namespace=globals()) # Star imports read __all__ and never the module __getattr__ -- see the # note in prefixes.py -- so without it a star import would bind none # of the names served above, silently. __all__ = ["CAPITALIZATION_EXCEPTIONS"] derek73-python-nameparser-456b024/nameparser/config/conjunctions.py000066400000000000000000000042101527303271700253740ustar00rootroot00000000000000# The words, and the vetting notes that stood beside them here, live in # nameparser/vocab/*.toml: a conjunction reading with may_be_initial = true is # what puts a word in CONJUNCTIONS_AMBIGUOUS, so that set is a subset by # construction, and tools/vocab/build.py refuses the flag on anything but # a CASED single letter -- the fork reads only a cased one-letter token, # so a caseless letter (Arabic و) or a longer entry would be inert. That # guards the SHIPPED data only: the pair is deliberately absent from # Lexicon's subset checks, so a CALLER'S orphan is never rejected -- it # is inert instead, the classify fork and its emitter both requiring the # base entry before they read the marker (see decisions/P8, the # 2026-09-13 entries). from nameparser.config import _compiled CONJUNCTIONS = _compiled.CONJUNCTIONS """ Pieces that should join to their neighboring pieces, e.g. "and", "y" and "&". "of" and "the" are also include to facilitate joining multiple titles, e.g. "President of the United States". """ CONJUNCTIONS_AMBIGUOUS = _compiled.CONJUNCTIONS_AMBIGUOUS """ Single-letter entries of :data:`CONJUNCTIONS` that read as an initial, not a connective, in a name written wholly in one case. """ # 2.8.1: 3.0 deletes this module (#1007), so each constant is served by # __getattr__ and warns at the line that reads it, naming what 3.0 # offers in its place (alias_getattr's docstring says how the # assignment above stays documented). The library reads _compiled, never # this module. Hidden from mypy as alias_getattr explains; goes in 3.0. from typing import TYPE_CHECKING # noqa: E402 from nameparser.config._deprecated import alias_getattr, words_of # noqa: E402 if not TYPE_CHECKING: __getattr__, __dir__ = alias_getattr(__name__, {}, removed={ "CONJUNCTIONS": words_of('kind="conjunction"'), "CONJUNCTIONS_AMBIGUOUS": words_of('kind="conjunction", may_be_initial=True'), }, namespace=globals()) # Star imports read __all__ and never the module __getattr__ -- see the # note in prefixes.py -- so without it a star import would bind none # of the names served above, silently. __all__ = ["CONJUNCTIONS", "CONJUNCTIONS_AMBIGUOUS"] derek73-python-nameparser-456b024/nameparser/config/given_names.py000066400000000000000000000031631527303271700251610ustar00rootroot00000000000000# The words, and the vetting notes that stand beside them, live in # nameparser/vocab/*.toml; tools/vocab/build.py compiles them into # _compiled.py (decisions/vocabulary-model). from nameparser.config import _compiled #: Given names written short ("chas" for Charles, "wm" for William, #: Filipino "ma" for María): a word with a ``given`` reading whose #: ``same_as`` is the full name. Period-marked and opening the part of #: the name that carries the given name, one reads as that given name, #: where an unlisted abbreviation there reads as a title by its shape #: alone ("Chas. Smith" reads given "Chas.", "Xyz. Smith" title #: "Xyz."). A listed title outranks it, and anywhere else the word is #: read as it would be unlisted. GIVEN_NAME_ABBREVIATIONS: frozenset[str] = _compiled.GIVEN_NAME_ABBREVIATIONS # Star imports read __all__ and never the module __getattr__ -- see the # note in prefixes.py. __all__ = ["GIVEN_NAME_ABBREVIATIONS"] # 2.8.1: 3.0 deletes this module (#1007), so each constant is served by # __getattr__ and warns at the line that reads it, naming what 3.0 # offers in its place (alias_getattr's docstring says how the # assignment above stays documented). The library reads _compiled, never # this module. Hidden from mypy as alias_getattr explains; goes in 3.0. from typing import TYPE_CHECKING # noqa: E402 from nameparser.config._deprecated import alias_getattr, words_of # noqa: E402 if not TYPE_CHECKING: __getattr__, __dir__ = alias_getattr(__name__, {}, removed={ "GIVEN_NAME_ABBREVIATIONS": words_of('kind="given"', "the given names whose reading carries same_as"), }, namespace=globals()) derek73-python-nameparser-456b024/nameparser/config/maiden_markers.py000066400000000000000000000147531527303271700256560ustar00rootroot00000000000000# The words live in nameparser/vocab/*.toml, each with a birth_marker # reading -- one of the two kinds that may be a phrase ("z domu"). # tools/vocab/build.py compiles them into _compiled.py and runs the checks # that were import-time asserts here (decisions/vocabulary-model). from nameparser.config import _compiled MAIDEN_MARKERS = _compiled.MAIDEN_MARKERS """ Markers that introduce a birth surname, e.g. "Jane Smith née Jones" (#274). French née/né/nee, German geb./geborene, Dutch geboren, Czech/Slovak rozená, Danish/Norwegian født (Nynorsk fødd), Swedish född, Russian урожд./урождённая/урождённый (both ё and е spellings — case normalization does not fold them, and running text routinely writes е), and Polish "z domu" (#434). Both grammatical genders are listed where #274 or review attested them (née/né, урождённая/урождённый); Czech masculine rozený awaits the same vetting, and would be vetted on its own merits rather than following the feminine form in — the abbreviation roz. shared by both genders is deliberately absent, below. Entries are stored normalized: lowercase, no periods. An entry may be a PHRASE, and "z domu" ("of the house") is the first one shipped. Phrases are stored space-joined and folded per word, and matched by lookahead, longest first, over a run of whole tokens that stand TOGETHER -- a phrase divided by a bracketed clause or a comma is not that phrase, so "Anna z (domu) Nowak" keeps its family name just as "Anna z Nowak" does. That mechanism is what makes this entry safe where its words are not: z is an ordinary Polish preposition and domu an ordinary noun, and the whole-phrase claim never fires on either alone. Splitting a phrase into its words is what the library used to advise (the dead-entry warning, which no longer fires for this field), and it produced exactly the misreadings the phrase avoids: maiden "domu Nowak" for "Maria Kowalska z domu Nowak", and no family name at all for "Anna z Nowak". A caller who added z and domu separately should remove both and let the shipped entry work. Japanese 旧姓 is here rather than in locales.JA, on the rule that admitted the Cyrillic entries: a native-script marker cannot collide with a Latin-script name, and matching is whole-token, so it is safe as a default. (Whole-token is still the rule for a phrase entry: it claims a run of whole tokens, one per word, and no part of one.) Neither character appears in any shipped surname, title, suffix, conjunction, particle or bound-given vocabulary. locales.JA is for what needs the my-data-is-Japanese declaration -- segmentation, where a pure-Han string cannot say which language wrote it -- and this needs none, since it can only ever match Han text. Matching being whole-token, the marker has to BE a token -- which for Japanese means something has to divide it from the name it marks. A space does, and so does a delimiter: extract masks the whole bracketed region, delimiter characters included, before tokenize runs, so a bracket bounds a token exactly as a space does and "山田(旧姓 佐藤)" needs no space in front of 旧姓 at all. The bare "山田花子 旧姓 佐藤" and the bracketed "山田 花子(旧姓 佐藤)" alike give maiden 佐藤, and since #335 the bracketed form needs no configuration to do it: the fullwidth pair is a NICKNAME delimiter by default, and rules/M3 reads a clause that opens with a marker word AND carries a word after it as the maiden name, whichever bucket its pair sits in. The second word is part of the condition, not a detail of it -- a lone "(旧姓)" is a word in brackets and stays a nickname. #329, which drops the marker from inside the clause, is what makes the value 佐藤 rather than "旧姓 佐藤". Declaring the pair in Policy(maiden_delimiters=...) reaches the same reading by M1's path. What divides nothing is the fullwidth colon that the form Japanese more often writes puts after the marker: "山田(旧姓:佐藤)" under Policy(maiden_delimiters=...) still yields maiden "旧姓:佐藤" with the marker and its colon attached. Not because delimited content escapes classification -- classify tags a marker wherever it is a token -- but because : is no separator tokenize knows, so marker and name arrive as ONE token and there is nothing to drop. M3 does not reach that form either, and by its own test rather than by tokenize's: it splits the clause on WHITESPACE, and "旧姓:佐藤" is one whitespace word, so there is no marker word for the clause to open with and by default it stays a nickname. The two tests agree here and are deliberately not the same test -- see decisions/M3. The wholly unspaced "山田花子(旧姓佐藤)" reads as one token for the same reason. Peeling a marker off the head of a token is #317's job. Consumed by the 2.0 parser's default lexicon. The 1.x parser does not read this module. Deliberately absent: the Scandinavian abbreviation "f." (collides with the initial "F." — only the full participles are safe), and the Czech/Slovak abbreviation "roz." (shipped through 2.1 and removed in 2.2). Roz is an ordinary English diminutive of Rosalind, and matching is whole-token, case-folded and period-insensitive, so "Roz", "roz" and "roz." are one string to this set: with it shipped, "Rosalind Roz Smith" read maiden "Smith" and NO family name at all. The collision is in the position the claim acts on, which is the test decisions/vocabulary-collisions states, and this set has no ambiguous subset to express a partial answer in — see decisions/vocabulary-exclusions, Excluded (MAIDEN_MARKERS). The full participle rozená stays, being unambiguous; a caller who needs the abbreviation adds it to their own Lexicon. """ # 2.8.1: 3.0 deletes this module (#1007), so each constant is served by # __getattr__ and warns at the line that reads it, naming what 3.0 # offers in its place (alias_getattr's docstring says how the # assignment above stays documented). The library reads _compiled, never # this module. Hidden from mypy as alias_getattr explains; goes in 3.0. from typing import TYPE_CHECKING # noqa: E402 from nameparser.config._deprecated import alias_getattr, words_of # noqa: E402 if not TYPE_CHECKING: __getattr__, __dir__ = alias_getattr(__name__, {}, removed={ "MAIDEN_MARKERS": words_of('kind="birth_marker"'), }, namespace=globals()) # Star imports read __all__ and never the module __getattr__ -- see the # note in prefixes.py -- so without it a star import would bind none # of the names served above, silently. __all__ = ["MAIDEN_MARKERS"] derek73-python-nameparser-456b024/nameparser/config/particles.py000066400000000000000000000162201527303271700246520ustar00rootroot00000000000000# The words, and the vetting notes that stood beside them here, live in # nameparser/vocab/*.toml: a particle whose word carries a given reading # is the ambiguous half, and every other particle reading states # may_be_given = false, the never-given half, so the two partition # PARTICLES by construction. tools/vocab/build.py compiles them into # _compiled.py and runs the checks that were import-time asserts here -- # a bound given name that is also a particle must be one that may be # given, and an elided particle ships in both apostrophes (#700) # (decisions/vocabulary-model). from nameparser.config import _compiled #: The sub-set of :py:data:`PARTICLES` that no culture uses as a standalone #: given name. Where one of these stands ALONE as the piece opening a name, that #: name has no given name -- the whole thing is a surname (e.g. "de Mesnil" #: -> family name "de Mesnil") -- and that reading holds under EVERY #: ``name_order`` (#359). It is not scoped to the default order the way the #: rest of the positional read is: what settles it is that a particle #: opening the name has the rest of the name to JOIN FORWARD TO, which #: is evidence about the writing rather than about the word, so #: ``Policy(name_order=FAMILY_FIRST)`` reads "de Mesnil" as the family #: name too. Membership here does NOT mean the word can never be a #: given name -- where position forces the reading it is one, and every #: member of this set reports given "de"-style for a bare parse. What is asked about is the #: opening *piece*, not the first word of the string: a particle that has #: already chained onto the word behind it is part of that piece rather #: than standing alone. #: OPENING the name is the whole of that rule's subject (rules/P1; #: the pre-2.2 docstrings called it rule 1b). A member standing alone #: where a declared order puts the GIVEN name was a second site until #: #467, and is not one now: that slot holds what the caller declared, #: so ``Policy(name_order=FAMILY_FIRST)`` reads "Mesnil de" as family #: "Mesnil", given "de". A member ENDING the name under a family-first #: order is rules/P6's, which joins it to the family. A bare "de" stays as it is. Where a chain is #: reported as the given name anyway it is because the member is no #: longer standing alone: under ``Policy(name_order=FAMILY_FIRST)`` the #: given position of "Juan de la Vega" holds the whole three-token #: chain, so the rule declines and given "de la Vega" stands. A title #: in front is NOT such a case -- since #367 a title is transparent to #: the leading-particle exception, so "Sir de Mesnil" leaves "de" a lone #: piece and reads family "de Mesnil", exactly as the untitled form does. #: Membership also decides the ambiguity report -- see #: :py:data:`PARTICLES` below. #: Curated to exclude anything that can be a given name in some culture #: (`al`, `van`, `von`, `della`, `di`, `del`, `da`, `vander`, ...) and #: anything that is also a bound given-name particle (`abu`). When unsure, #: leave a word out: a missing member just means that name is not #: auto-fixed, whereas a wrong member misparses a real person. Must stay a #: subset of :py:data:`PARTICLES` and disjoint from #: :py:data:`~nameparser.config.bound_given_names.BOUND_GIVEN_NAMES`. NON_GIVEN_NAME_PARTICLES = _compiled.NON_GIVEN_NAME_PARTICLES #: Family-name particles: a particle joins to the piece that follows it #: to make one new piece, and particles chain, e.g. "von der" and #: "de la". A particle in a non-leading position also pulls the pieces #: after it into the same one, up to the next particle run or suffix, #: which is how a multi-word name piece is recognized. Where that piece #: lands is a later question: in "pennie von bergen wessels MD", "von" #: joins each following piece until the suffix "MD", giving the family #: name "von bergen wessels", while the same chaining in "Smith, Juan #: de la Cruz" gives the middle name "de la Cruz". A leading #: particle is the exception and chains nothing: the chain skips the #: first piece of the NAME, its membership in this set or in #: :py:data:`NON_GIVEN_NAME_PARTICLES` never entering into it. Leading #: is read off the name rather than off the input (#367): a title is #: not part of the name, so it is stepped over and "Dr. Van Johnson" #: reads as the untitled "Van Johnson" does. One kind of word is not #: stepped over, and this is the one place a title's vocabulary and #: this one interact -- ``st`` and ``freiherr`` are each BOTH a title #: and a particle (``do`` was too, until #296), so either could be the name's own #: first piece and stops the scan, leaving a particle behind it #: non-leading and free to chain ("Freiherr von Richthofen"). #: Where the pieces then land is again a later #: question, and this is where membership decides something: a leading #: :py:data:`NON_GIVEN_NAME_PARTICLES` member makes the whole name a #: family name ("de la Vega") under every ``name_order``, because a word #: that is never a given name leaves the order nothing to place. A #: leading particle OUTSIDE that set could be either, so there #: ``name_order`` decides after all: the default given-first order reads #: it as the given name ("Van Johnson"), while either family-first order #: splits the same chains-nothing grouping the other way round ("Van #: Johnson" -> family "Van", given "Johnson"). #: What membership decides under ANY of the three orders is also the #: report: a leading particle outside #: :py:data:`NON_GIVEN_NAME_PARTICLES` records a particle-or-given #: ambiguity for the reading not taken, and one inside it records none. #: #: Both sets are compiled from the same particle readings, the never-given #: ones being those whose word has no given reading, which is what guarantees every #: :py:data:`NON_GIVEN_NAME_PARTICLES` member is also a particle (and #: still joins forward), with no drift. PARTICLES = _compiled.PARTICLES # Star imports read __all__ and never the module __getattr__ -- see the # note in prefixes.py. This module has no retired name of its own to # serve; what __all__ buys here is the other half of that note's point, # which the shim modules got in #354 and the destinations did not: # without it a star import binds every module-level name, so a helper # imported here lands in the caller's namespace as though it were # vocabulary (#356) -- `_compiled` now, as `assert_normalized` and # BOUND_GIVEN_NAMES did before the vocabulary moved to TOML. # Source order, not alphabetical -- see the note in suffixes.py. __all__ = ["NON_GIVEN_NAME_PARTICLES", "PARTICLES"] # 2.8.1: 3.0 deletes this module (#1007), so each constant is served by # __getattr__ and warns at the line that reads it, naming what 3.0 # offers in its place (alias_getattr's docstring says how the # assignment above stays documented). The library reads _compiled, never # this module. Hidden from mypy as alias_getattr explains; goes in 3.0. from typing import TYPE_CHECKING # noqa: E402 from nameparser.config._deprecated import alias_getattr, words_of # noqa: E402 if not TYPE_CHECKING: __getattr__, __dir__ = alias_getattr(__name__, {}, removed={ "NON_GIVEN_NAME_PARTICLES": words_of('kind="particle"', "the particles with no given reading"), "PARTICLES": words_of('kind="particle"'), }, namespace=globals()) derek73-python-nameparser-456b024/nameparser/config/prefixes.py000066400000000000000000000034321527303271700245120ustar00rootroot00000000000000"""Deprecated alias module: the particle vocabulary moved to :mod:`nameparser.config.particles` in 2.2 (#293), where the constant names match the :class:`~nameparser.Lexicon` fields they feed. Reading a name from here warns and returns the constant from its new home; this module is deleted in 3.0. """ from typing import TYPE_CHECKING from nameparser.config._deprecated import alias_getattr # Declared for the type checker, served by __getattr__ at runtime; see # alias_getattr's docstring for why the assignment has to be hidden # from mypy. Both branches go in 3.0. if TYPE_CHECKING: PREFIXES: frozenset[str] NON_FIRST_NAME_PREFIXES: frozenset[str] else: __getattr__, __dir__ = alias_getattr(__name__, { "PREFIXES": ("nameparser.config.particles", "PARTICLES"), "NON_FIRST_NAME_PREFIXES": ( "nameparser.config.particles", "NON_GIVEN_NAME_PARTICLES"), }) # `from nameparser.config.prefixes import *` consults __all__ and NOTHING # else -- a module __getattr__ is invisible to it (PEP 562 defines the # hook for attribute access; star imports without __all__ read the # module's __dict__ directly). Without this, the one 1.x import form the # bridge did not cover failed in the mode the bridge exists to prevent: # no warning, no AttributeError, just a NameError later at an unrelated # line -- plus `alias_getattr` bound into the caller's namespace. Listing # the retired names here routes each through __getattr__, so a star # import warns per name exactly as an attribute read does. # # No F822 suppression: the retired names are declared above, in the # TYPE_CHECKING branch, so ruff sees them bound. Deleting that branch # in 3.0 without deleting this list is then an error rather than a # silently-suppressed one. __all__ = ["NON_FIRST_NAME_PREFIXES", "PREFIXES"] derek73-python-nameparser-456b024/nameparser/config/regexes.py000066400000000000000000000101471527303271700243300ustar00rootroot00000000000000import re # emoji ranges from https://stackoverflow.com/questions/26568722/remove-unicode-emoji-using-re-in-python # Built from codepoint pairs rather than a literal character class: # the compiled pattern is identical, but CodeQL's py/overly-large-range # false-positives on literal astral ranges (surrogate decomposition). _EMOJI_RANGES = ((0x1F300, 0x1F64F), (0x1F680, 0x1F6FF), (0x2600, 0x26FF), (0x2700, 0x27BF)) re_emoji = re.compile( '[' + ''.join(f'{chr(lo)}-{chr(hi)}' for lo, hi in _EMOJI_RANGES) + ']+') # Invisible bidirectional formatting characters: ALM, LRM, RLM, the # embedding/override marks (LRE/RLE/PDF/LRO/RLO) and the isolates # (LRI/RLI/FSI/PDI). Copy-pasted RTL names often carry these; they render # as nothing but stick to name parts and break equality/lookups. re_bidi = re.compile('[\u061C\u200E\u200F\u202A-\u202E\u2066-\u2069]+') # Combining marks (Unicode Mn) are not \w, so a letter written as a base # plus a mark (NFD `inž.`) must carry them explicitly or the shape # rules read a decomposed name differently from its composed twin # (#652). Same class as _pipeline/_vocab.py's _MARKS; test_regex_sync # compares the two class strings and pins `period_abbreviation` # against `_vocab`'s copy. _MARKS = '[\u0300-\u036f\u1ab0-\u1aff\u1dc0-\u1dff\u20d0-\u20ff\ufe20-\ufe2f]' EMPTY_REGEX = re.compile('') REGEXES = { "spaces": re.compile(r"\s+"), "word": re.compile(r"(\w|\.)+"), "mac": re.compile(r'^(ma?c)(\w{2,})', re.I), "initial": re.compile(r'^(\w\.|[A-Z])?$'), "quoted_word": re.compile(r'(? (module name, module attribute). Codes are the #: lowercase module names; attribute constants are uppercase. _REGISTRY = { "JA": ("nameparser.locales.ja", "JA"), "RU": ("nameparser.locales.ru", "RU"), "TR_AZ": ("nameparser.locales.tr_az", "TR_AZ"), "ZH": ("nameparser.locales.zh", "ZH"), } def __getattr__(name: str) -> Locale: entry = _REGISTRY.get(name) if entry is None: raise AttributeError( f"module {__name__!r} has no locale {name!r}; available: " f"{', '.join(sorted(_REGISTRY))}" ) module_name, attr = entry locale: Locale = getattr(importlib.import_module(module_name), attr) # cache: next access skips __getattr__. Benign race under free # threading: two threads racing here both import the same module # object (import machinery serializes/dedupes that) and assign the # same singleton Locale back to the same name, so the last write # wins with no observable difference. globals()[name] = locale return locale def __dir__() -> list[str]: return sorted(set(globals()) | set(_REGISTRY)) def available() -> tuple[str, ...]: """The registered locale codes, lowercase, sorted.""" return tuple(sorted(attr.lower() for attr in _REGISTRY)) def ja_segmenter(*, gbdt: bool = False) -> Segmenter: """The Japanese segmenter factory (#272), needing the optional ``pip install nameparser[ja]``; see :mod:`nameparser.locales.ja` for what it wraps, what it declines, and its licensing. A real function rather than a _REGISTRY entry, because the registry resolves LOCALES: __getattr__ is annotated to return one, so routing a callable through it would type every pack access as "Locale or Segmenter" for no gain. Laziness is preserved here instead -- the pack module (and with it the optional dependency) loads on the call, not on importing this package.""" from nameparser.locales.ja import ja_segmenter as factory return factory(gbdt=gbdt) def get(code: str) -> Locale: """Dynamic lookup by code ('ru'); raises KeyError listing the available codes.""" attr = code.upper() if isinstance(code, str) else code if not isinstance(code, str) or attr not in _REGISTRY: raise KeyError( f"unknown locale code {code!r}; available: " f"{', '.join(available())}" ) return __getattr__(attr) derek73-python-nameparser-456b024/nameparser/locales/ja.py000066400000000000000000000302121527303271700234300ustar00rootroot00000000000000"""The Japanese locale pack (#272): activates segmentation for the Japanese repertoire -- Han and the kana-licensed composites that resolve to HIRAGANA -- so that an unspaced 山田太郎 can be divided at all. Applying this pack IS the "my data is Japanese" declaration, the same role locales.ZH plays for Chinese: a pure-Han string cannot say which language it is written in (林 is Lin or Hayashi), so neither activation may be a default. Unlike zh, this pack ships NO vocabulary and NO order: * No vocabulary, because no surname list divides Japanese names. Family and given names draw on the same kanji, both sides run one to four characters, and the reading -- not the spelling -- is what settles most splits. That is what the pluggable segmenter is for, and it is why this pack alone is inert: it activates a stage that then has nothing to segment with. * No order, because a kana-bearing Japanese name already reads family-first by default (decisions/W4, the kana license: hiragana identifies native Japanese as certainly as hangul identifies Korean), and wholly Han names have read family-first since the 2026-07-27 amendment. Pure KATAKANA is deliberately outside both the order rule and the activation set above -- katakana is how Japanese writes FOREIGN names (マイケル・ジャクソン), in their original given-first order, and a Japanese name's katakana reading (ヤマダ タロウ, or the halfwidth ヤマダ タロウ of legacy data, #594) cannot be told from one by its script. Data sources: none. The pack carries no vocabulary of its own, so there is no list here to cite, extend or keep current -- the data that divides a Japanese name belongs to the segmenter, and namedivider's sources and their terms are set out under Licensing below. The segmenter is a FACTORY, ``ja_segmenter()``, not pack data: a Locale is pure data that dissolves into a Parser at construction, and a third-party callable is neither pure nor data. Combining the two is explicit -- ``parser_for(locales.JA, segmenter=locales.ja_segmenter())`` -- which also keeps the optional dependency out of every import path that merely touches this module. Licensing (the optional dependency is never bundled; nameparser's own LGPL is untouched): namedivider-python's source and its GBDT model are MIT. Its surname data -- ``family_name_repository.pickle``, sourced from Myoji-Yurai.net -- carries custom terms permitting commercial and non-commercial use *for dividing names*, which is the only use any caller of this module can put it to. Those terms bind only the opt-in ``ja_segmenter(gbdt=True)`` path, which is what loads that file; the default BasicNameDivider reads namedivider's own bundled kanji.csv and never touches it. Its BERT model for katakana division is CC-BY-SA and is NOT used (katakana division is out of scope; decisions/W1's kana-gating bullet). Declared deviations (mechanisms.md#LOCALE-PACKS-PURE-DATA): the pack sets one union policy field, self-selecting by script, so it can only change names containing characters of the Japanese repertoire -- DEVIATES below declares exactly that, over-declaring within the repertoire (a name also needs an unspaced token and a segmenter to actually change, but declaring every repertoire-bearing name is the safe direction, zh's precedent). """ from __future__ import annotations from typing import TYPE_CHECKING from nameparser._lexicon import Lexicon from nameparser._locale import Locale from nameparser._policy import (PolicyPatch, Script, _JA_SCRIPTS, _script_matcher) from nameparser._types import Segmentation if TYPE_CHECKING: from nameparser._types import Segmenter JA = Locale( code="ja", lexicon=Lexicon.empty(), policy=PolicyPatch( segment_scripts=frozenset({Script.HAN, Script.HIRAGANA})), ) # Both compiled by _policy's factory from the shared codepoint table # and its _JA_SCRIPTS union, so the pack carries no spans of its own # to drift out of sync: the contains-any form backs DEVIATES, the # whole-token form is the adapter's repertoire guard. Both match # KATAKANA even though the pack never ACTIVATES it: a katakana-bearing # token can still be a kana-licensed composite the pack acts on # (山田エミ) -- DEVIATES must declare it, and the adapter must not # decline it. A pure-katakana token never reaches THE ADAPTER: # KATAKANA is in no activation set, so the stage's own gate stops it # before the segmenter is consulted. DEVIATES still declares one, # and must: it is a predicate over any name at all, called before # any gate runs, and over-declaring is its safe direction by design. _has_japanese = _script_matcher(*_JA_SCRIPTS) _wholly_japanese = _script_matcher(*_JA_SCRIPTS, whole=True) #: How far outside [0, 1] a namedivider score may stray and still count #: as float noise around a real answer rather than a broken divider -- #: see the adapter's own comment for why it is float32-sized. _SCORE_EPSILON = 1e-6 def DEVIATES(name: str) -> bool: """True when this pack may parse `name` differently from the default parser (the declared-deviation predicate the non-interference gate consumes): any name bearing a repertoire character -- see the repertoire note above for why katakana is declared and why over-declaring is safe.""" return _has_japanese(name) def ja_segmenter(*, gbdt: bool = False) -> Segmenter: """A :data:`~nameparser.Segmenter` wrapping namedivider-python, installed with ``pip install nameparser[ja]``: parser = parser_for(locales.JA, segmenter=locales.ja_segmenter()) Wraps ``BasicNameDivider`` (99.3% accurate, ~4k names/sec, pure Python) by default, which needs only data bundled in the installed package; ``gbdt=True`` selects ``GBDTNameDivider`` (99.9%), which asks more of the environment on two counts. It runs namedivider's gradient-boosted model through lightgbm -- a hard dependency of namedivider-python, so the extra installs it, but its wheel does not bundle the native OpenMP runtime it loads at import (a missing ``libomp`` surfaces as lightgbm's own OSError naming the library it could not find). And the first such call DOWNLOADS its model and surname files from the network into ``~/.cache/namedivider-python`` (namedivider prints its own progress to stdout doing so), which matters for offline, sandboxed or air-gapped deployments. The adapter declines -- returns None, leaving the token whole -- for text outside the Japanese repertoire, for text bearing the shime mark 〆 (namedivider 0.4.x's rule path cuts it in the wrong place), for text too short to divide, for any answer that fails to reconstruct its input, and for any score outside [0, 1] by more than float noise (a divider scoring 87.0 is broken, and its division is worth no more than its score). An answer that puts the whole token on one side comes back as "confidently one token" rather than a decline: the divider read it and found nothing to cut, which is not the same as having no opinion. Answers arrive with namedivider's own confidence, which the parsing stage compares against its floor to decide whether to report a SEGMENTATION ambiguity: namedivider scores a rule-based division (the kana boundary in 高橋みなみ, or a two-character name) 1.0 and a kanji-statistics division well below that, so every statistically divided name carries the report and every rule-divided one carries none. The two-character rule is the case to know about: dividing 原恵 one character each way is a presumption Japanese practice makes, not a measurement (usage.rst says so), yet the 1.0 keeps it silent. That is namedivider's own stated certainty, taken as given here; ``DividedName.algorithm`` names which rule answered and is the hook if presumption-reporting is ever wanted. """ try: import namedivider except ImportError as exc: raise ImportError( "the Japanese segmenter needs namedivider-python -- " "install it with: pip install nameparser[ja]" ) from exc divider = (namedivider.GBDTNameDivider() if gbdt else namedivider.BasicNameDivider()) def segment(text: str) -> Segmentation | None: # namedivider RAISES below two characters ("Name length needs # at least 2 chars"), and a segmenter's exceptions propagate by # contract, so a one-character token -- routine in spaced input # like "林 太郎" -- must be declined here, not passed on. if len(text) < 2: return None # segment_scripts UNIONS under pack composition, so this can # receive tokens of any other activated script (hangul, under # the default policy). Decline anything outside the Japanese # repertoire rather than hand it to namedivider, which would # answer for it regardless -- the same union DEVIATES declares. if not _wholly_japanese(text): return None # namedivider 0.4.x has no knowledge of the shime mark: its # rule path cuts 〆木太郎 to 〆 | 木太郎 at confidence 1.0 # (measured -- wrong for every attested 〆 surname: 〆木, 〆谷, # 〆野), so a wrong family would arrive with no SEGMENTATION # report. Decline instead: the family-first ORDER fix for 〆 # stands regardless, and division can return when a divider # knows the mark. The blanket check is deliberately broader # than that rule-path failure: mid-token 〆 (山〆太郎) comes # back 山 | 〆太郎 at 0.375 on the kanji_feature path # (measured), a low-confidence answer that WOULD have carried # a SEGMENTATION report -- swept in anyway, because decline is # the safe direction and mid-token 〆 is vanishingly rare. if "〆" in text: return None result = divider.divide_name(text) if result.family + result.given != text: return None # defensive: never trust reconstruction # float(): the score is a numpy scalar on the statistical path. # Two different failures wear the same shape here, and they get # different answers. Float error at the edge of namedivider's # softmax is arithmetic noise around a real answer, so an # epsilon either side of [0, 1] is tolerated and clamped -- # Segmentation enforces the range and would otherwise raise # mid-parse. A score OUTSIDE that epsilon is not noise: a # divider reporting 87.0 confidence is broken, and its division # is worth nothing either. Declining keeps the parse safe AND # observable by absence -- an unconditional clamp mapped 87.0 # to 1.0, i.e. "certain", silencing the SEGMENTATION report at # exactly the point of maximum brokenness. NaN takes the same # exit for free: every comparison against it is False, so it # fails both bounds below and declines. # # _SCORE_EPSILON is sized for FLOAT32, not float64: numpy # softmax output is commonly float32, whose eps is ~1.2e-7, and # namedivider's rule-based answers score exactly 1.0 -- so a # float64-sized 1e-9 band would decline a correct rule-based # division that came back a single ulp high (1.0000001 is # exactly that). A few float32 ulps is still five orders of # magnitude below any score a broken divider would produce, so # nothing is bought by tightening it. score = float(result.score) if not -_SCORE_EPSILON <= score <= 1.0 + _SCORE_EPSILON: return None confidence = min(1.0, max(0.0, score)) # Defensive, the reconstruction check's twin: no namedivider # 0.4.x path can return an empty side (every answer, rule or # statistical, is built by slicing the input at an interior # offset). If one ever did, it would mean "I read this as # undivided" -- an empty Segmentation, which is a stated # opinion, and not the None that declines to have one. if not result.family or not result.given: return Segmentation((), confidence=confidence) return Segmentation((len(result.family),), confidence=confidence) return segment derek73-python-nameparser-456b024/nameparser/locales/ru.py000066400000000000000000000051551527303271700234740ustar00rootroot00000000000000"""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). Cyrillic titles/conjunctions are default-lexicon vocabulary (#269), not pack data (mechanisms.md#LOCALE-PACKS-PURE-DATA, the sorting rule). Data sources: the v1.3.0 patronymic rule (PR #154 discussion and the east-slavic test bank); no external lists -- the pack itself carries no vocabulary. Declared deviations (mechanisms.md#LOCALE-PACKS-PURE-DATA): applying this pack changes only NO_COMMA names whose final token carries an East Slavic patronymic ending while the middle token does not -- DEVIATES(name) below is the machine-readable declaration the non-interference gate checks. """ from __future__ import annotations import re from nameparser._lexicon import Lexicon from nameparser._locale import Locale from nameparser._policy import PatronymicRule, PolicyPatch RU = Locale( code="ru", lexicon=Lexicon.empty(), policy=PolicyPatch( patronymic_rules=frozenset({PatronymicRule.EAST_SLAVIC})), ) # Ported by hand from nameparser/_pipeline/_post_rules.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 # both alternations byte-identical to those two patterns. _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) def DEVIATES(name: str) -> bool: """True when this pack may parse `name` differently from the default parser (the declared-deviation predicate the non-interference gate consumes).""" # Scan per token: the rule fires on the family-POSITION token, not # the name's final characters, so a whole-string search would miss # 'Ivan Petr Sidorovich Jr.' (a suffix after the ending -> the $ # anchor never lands: under-declaration, the unsafe direction for # the gate). Per-token scanning instead OVER-declares (e.g. # 'Sidorovich Anna' matches though the rule's 1+1+1 shape never # fires) -- the safe direction: DEVIATES may claim more than the # rule changes, never less. return any(_EAST_SLAVIC.search(tok) or _EAST_SLAVIC_CYR.search(tok) for tok in name.split()) derek73-python-nameparser-456b024/nameparser/locales/tr_az.py000066400000000000000000000040151527303271700241570ustar00rootroot00000000000000"""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). Data sources: the v1 Turkic patronymic rule (issue #185 and its test bank, tests/test_turkic_patronymic_order.py); the pack carries no vocabulary. Declared deviations (mechanisms.md#LOCALE-PACKS-PURE-DATA): applying this pack changes only NO_COMMA names where some token is a standalone Turkic patronymic marker -- DEVIATES(name) below is the machine-readable declaration the non-interference gate checks. """ from __future__ import annotations import re from nameparser._lexicon import Lexicon from nameparser._locale import Locale from nameparser._policy import PatronymicRule, PolicyPatch TR_AZ = Locale( code="tr_az", lexicon=Lexicon.empty(), policy=PolicyPatch( patronymic_rules=frozenset({PatronymicRule.TURKIC})), ) # Ported by hand from nameparser/_pipeline/_post_rules.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 # alternations byte-identical to those two patterns. Note both are # fullmatch-shaped (^...$): the rule tests a single WHOLE token, so # DEVIATES below scans per-token rather than searching the whole name. _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 DEVIATES(name: str) -> bool: """True when this pack may parse `name` differently from the default parser (the declared-deviation predicate the non-interference gate consumes).""" return any(_TURKIC.match(tok) or _TURKIC_CYR.match(tok) for tok in name.split()) derek73-python-nameparser-456b024/nameparser/locales/zh.py000066400000000000000000000235531527303271700234710ustar00rootroot00000000000000"""The Chinese locale pack (#271): activates Han segmentation and ships the surname vocabulary for it. Deliberately does NOT set any order -- Policy.script_orders already reads wholly-Han names family-first by default (amendment 2026-07-27); segmentation is the one Han behavior that must stay opt-in, because a pure-Han string cannot say whether it is Chinese or Japanese (林 is Lin or Hayashi) and a zh surname list corrupts Japanese names (高橋一郎 would split 高 + 橋一郎). Applying this pack IS the "my data is Chinese" declaration. Japanese segmentation is locales.JA's job instead: that pack activates the same stage for the Japanese repertoire and defers the division itself to a pluggable segmenter, since no surname list divides a kanji name. Data sources: single-character surnames are the top-100 of the 2020 PRC census, plus the union of two rankings' top 300 (#695): Taiwan's Ministry of the Interior surname ranking (內政部戶政司, 2023, used under the Open Government Data License, version 1) and Wikidata's Chinese family names ranked by bearers (CC0). tools/zh_surnames.py derives that block and writes it below; do not edit it by hand. Traditional-script variant forms are included for names written in Taiwan/Hong Kong/overseas orthography; two-character compounds are hand-curated (see the note above _COMPOUND), never generated, since a married double surname like 李林 would cut 李林伟 into 李林 + 伟. A coverage floor, not a census roster: extend by giving parser_for a base whose lexicon already carries the additions -- ``parser_for(locales.ZH, base=Parser(lexicon=Lexicon.default().add( surnames={...})))``. (Adding to the finished parser's lexicon instead is a silent no-op: Lexicon is frozen, so ``.add`` returns a NEW lexicon that no parser is holding.) Declared deviations (mechanisms.md#LOCALE-PACKS-PURE-DATA): the pack adds vocabulary and one union policy field, both self-selecting by script, so it can only change names containing Han characters -- DEVIATES below declares exactly that (over-declaring within the script: only segmentation can actually change a parse -- an unspaced surname match, or a ``Parser(segmenter=...)`` answer where the vocabulary declines -- but declaring every Han-bearing name is the safe direction). """ from __future__ import annotations from nameparser._lexicon import Lexicon from nameparser._locale import Locale from nameparser._policy import PolicyPatch, Script, _script_matcher # 2020 census top-100 plus the annotated additions below, simplified # forms, census rank order, 10 per row -- append new entries at the # tail, do not alphabetize (rank order is the only in-file record of # where the coverage floor sits). Two entries are NOT census rows and # are called out so the rank-order record stays honest: # # * 阎 sits off-rank beside 闫 rather than at the tail, because the two # are a reading pair, not a rank neighbour: 闫 is the census row and # 阎 is the distinct surname sharing its reading (Yan). Keeping them # adjacent is worth the one break in rank order. # * 萧 is at the tail: the mainland census records this surname under # the merged 肖 (rank 33 above), but 萧 stays a distinct simplified # spelling in current use and both circulate. The traditional form # 蕭 ships either way, below. _SINGLE = ( "王", "李", "张", "刘", "陈", "杨", "黄", "赵", "吴", "周", "徐", "孙", "马", "朱", "胡", "郭", "何", "林", "高", "罗", "郑", "梁", "谢", "宋", "唐", "许", "韩", "邓", "冯", "曹", "彭", "曾", "肖", "田", "董", "潘", "袁", "蔡", "蒋", "余", "于", "杜", "叶", "程", "魏", "苏", "吕", "丁", "任", "卢", "姚", "沈", "钟", "姜", "崔", "谭", "陆", "范", "汪", "廖", "石", "金", "韦", "贾", "夏", "傅", "方", "邹", "熊", "白", "孟", "秦", "邱", "侯", "江", "尹", "薛", "闫", "阎", "段", "雷", "龙", "黎", "史", "陶", "贺", "毛", "郝", "顾", "龚", "邵", "万", "覃", "武", "钱", "戴", "严", "莫", "孔", "向", "常", "萧", ) # Traditional-script forms of the above where the glyph differs. 蕭 is # the traditional spelling of both 萧 and the merged 肖 (see the note # above); every other row is a plain 1:1 variant. _SINGLE_TRADITIONAL = ( "張", "劉", "陳", "楊", "黃", "趙", "吳", "孫", "馬", "羅", "鄭", "謝", "許", "韓", "鄧", "馮", "蕭", "葉", "蘇", "呂", "盧", "鍾", "譚", "陸", "韋", "賈", "鄒", "閆", "閻", "龍", "賀", "顧", "龔", "萬", "錢", "嚴", "蔣", ) # Both rankings' top 300 past the census rows, in combined rank order # (each surname's better rank of the two), both scripts. # -- BEGIN generated by tools/zh_surnames.py; edit the tool, not this block -- _SINGLE_RANKED = ( "洪", "賴", "赖", "庄", "莊", "简", "簡", "鐘", "锺", "游", "詹", "施", "顏", "颜", "柯", "翁", "阮", "卓", "裴", "释", "釋", "俞", "温", "溫", "蓝", "藍", "古", "章", "紀", "纪", "连", "連", "欧", "歐", "汤", "湯", "康", "尤", "巫", "塗", "涂", "柳", "元", "窦", "竇", "童", "凃", "伍", "倪", "丘", "駱", "骆", "葛", "樊", "甘", "官", "邢", "鮑", "鲍", "牛", "文", "祝", "粘", "饒", "饶", "左", "闕", "阙", "乔", "喬", "凌", "淩", "齊", "齐", "舒", "姬", "翟", "辛", "华", "華", "辜", "魯", "鲁", "盛", "成", "易", "殷", "池", "耿", "褚", "卫", "衛", "衞", "麥", "麦", "路", "毕", "畢", "庞", "龐", "焦", "管", "关", "關", "包", "虞", "項", "项", "梅", "聂", "聶", "利", "房", "全", "佘", "花", "費", "费", "安", "喻", "霍", "申", "穆", "应", "應", "瞿", "蒲", "柴", "谷", "臧", "季", "練", "练", "苗", "边", "邊", "邬", "鄔", "查", "阳", "陽", "楼", "樓", "岳", "符", "尚", "解", "屠", "繆", "缪", "宗", "屈", "乐", "樂", "单", "單", "曲", "篮", "籃", "閔", "闵", "滕", "戚", "靳", "留", "桂", "鄞", "諸", "诸", "卜", "厐", "龎", "車", "车", "艾", "郎", "封", "鹿", "𡍼", "岑", "商", "时", "時", "买", "買", "卞", "祁", "力", "景", "云", "雲", "郁", "吉", "席", "茅", "仇", "巴", "牟", "荆", "荊", "司", "晏", "甄", "幸", "來", "来", "寇", "晁", "諶", "谌", "邝", "鄺", "南", "储", "儲", "沙", "談", "谈", "栾", "欒", "狄", "奚", "宫", "宮", "榮", "荣", "敬", "刁", "桑", "权", "權", "和", "师", "師", "支", "松", "佟", "柏", "宁", "寧", "甯", "祖", "冉", "麻", "隋", "冷", "風", "风", "子", "聞", "闻", "鄂", "髙", "浦", "仲", "裘", "燕", "庾", "湛", "迟", "遲", "东", "東", "米", "劳", "勞", "匡", "昌", "党", "兰", "蘭", "阴", "陰", "娄", "婁", "敖", "惠", "戈", "羊", "伊", "盖", "蓋", "茹", "強", "强", "平", "丛", "叢", "蒙", "羋", "芈", "桓", "区", "區", "源", "偕", "栗", "初", "恽", "阿", "兵", "冀", "計", "计", "索", "明", "蔺", "藺", "危", "鞠", "廉", "茆", "雍", "蹇", "苻", "宣", "盘", "盤", "𨶒", "厉", "厲", "机", "归", "歸", ) # -- END generated -- # Two-character surnames, simplified. The first 34 rows are the # Hundred Family Surnames compounds still in modern use; the rows after # them (#695) admit a compound surname -- never a married double # surname (王李) or a Japanese one -- only where Wikidata or the Taiwan # ranking attests a bearer, and only where no Wikidata bearer of its # first character alone has a name opening with it (成公 is left out: # 成公亮 is 成 + 公亮), unless the compound's living bearers in the # Taiwan file outnumber those readings by orders of magnitude (張簡: # 9,162 bearers against 9 people named 張 + 簡; 东野, with none # against 2, is left out). A listed compound wins over its first # character in every name that opens with it (张简明 is 张简 + 明, and # 张简 alone stays whole); that cost and the measurements are in # decisions/W1. _COMPOUND = ( "欧阳", "司马", "诸葛", "上官", "夏侯", "皇甫", "尉迟", "公孙", "长孙", "慕容", "司徒", "司空", "令狐", "宇文", "东方", "独孤", "南宫", "西门", "澹台", "淳于", "单于", "申屠", "公羊", "仲孙", "轩辕", "呼延", "端木", "百里", "东郭", "闻人", "拓跋", "万俟", "夹谷", "太史", "钟离", "鲜于", "赫连", "贺兰", "相里", "叔孙", "孟孙", "季孙", "士孙", "羊舌", "濮阳", "陆费", "梁丘", "公西", "公冶", "乐正", "东里", "张简", "范姜", "张廖", "第五", "颛孙", "段干", "闾丘", "左丘", ) # Traditional forms of the compounds where any glyph differs. _COMPOUND_TRADITIONAL = ( "歐陽", "司馬", "諸葛", "尉遲", "公孫", "長孫", "東方", "獨孤", "南宮", "西門", "澹臺", "單于", "仲孫", "軒轅", "東郭", "聞人", "萬俟", "夾谷", "鍾離", "鮮于", "赫連", "賀蘭", "叔孫", "孟孫", "季孫", "士孫", "濮陽", "陸費", "樂正", "東里", "張簡", "張廖", "顓孫", "閭丘", ) # Private: ZH.lexicon.surnames is the supported way to read this, and # publishing a name later is compatible where un-publishing is not. _SURNAMES = frozenset( _SINGLE + _SINGLE_TRADITIONAL + _SINGLE_RANKED + _COMPOUND + _COMPOUND_TRADITIONAL) ZH = Locale( code="zh", lexicon=Lexicon(surnames=_SURNAMES), policy=PolicyPatch(segment_scripts=frozenset({Script.HAN})), ) # Compiled by _policy's factory from the shared codepoint table, so # the pack carries no Han spans of its own to drift out of sync. _has_han = _script_matcher(Script.HAN) def DEVIATES(name: str) -> bool: """True when this pack may parse `name` differently from the default parser (the declared-deviation predicate the non-interference gate consumes): any Han-bearing name -- see the module docstring's declared-deviations note for why over-declaring is safe.""" return _has_han(name) derek73-python-nameparser-456b024/nameparser/parser.py000066400000000000000000000003501527303271700227100ustar00rootroot00000000000000"""v1 import-path preservation (mechanisms.md#FACADE-CONTRACT): the 2.0 HumanName facade lives in nameparser._facade. This module is deleted in 3.0. """ from nameparser._facade import HumanName as HumanName __all__ = ["HumanName"] derek73-python-nameparser-456b024/nameparser/py.typed000066400000000000000000000000001527303271700225310ustar00rootroot00000000000000derek73-python-nameparser-456b024/nameparser/util.py000066400000000000000000000031541527303271700223760ustar00rootroot00000000000000"""v1 normalization, kept for the 1.x compatibility layer. Removed in 3.0 with the rest of that layer; reading ``lc`` from here warns since 2.8. The 2.0 core does NOT use this: :func:`nameparser._lexicon._normalize` is its fold, which NFC-composes and strips edge full stops to a fixed point where ``lc()`` strips ASCII periods once. """ import warnings as _warnings from typing import TYPE_CHECKING #: the message ``lc`` warns with; tests and pyproject's filterwarnings #: match it _LC_DEPRECATION = ( "nameparser.util.lc is deprecated and will be removed in 3.0 with the " "1.x compatibility layer; use value.lower().strip('.') instead" ) def _lc(value: str) -> str: """Lowercase and strip leading/trailing periods to normalize for comparison.""" if not value: return '' return value.lower().strip('.') # config/_deprecated.py's alias_getattr shape: the hooks hidden from the # type checker, which would otherwise answer every missing attribute of # this module from __getattr__'s return type if TYPE_CHECKING: lc = _lc else: def __getattr__(name: str) -> object: # PEP 562, so the shim's own import of _lc stays quiet while # every caller's read of lc warns, per read rather than once per # process if name == "lc": _warnings.warn(_LC_DEPRECATION, DeprecationWarning, stacklevel=2) return _lc raise AttributeError(f"module {__name__!r} has no attribute {name!r}") def __dir__() -> list[str]: return sorted(set(globals()) | {"lc"}) # lc is not in the module's globals, so a star import finds it only here __all__ = ["lc"] derek73-python-nameparser-456b024/nameparser/vocab/000077500000000000000000000000001527303271700221365ustar00rootroot00000000000000derek73-python-nameparser-456b024/nameparser/vocab/AGENTS.md000066400000000000000000000205401527303271700234420ustar00rootroot00000000000000# Adding a word to nameparser/vocab/ Loaded when a session reads a vocabulary TOML file. The steps for adding a word are in `nameparser/vocab/README.md`, the reference for anyone editing these files; this file holds the traps those steps have run into, filed under the README step each belongs to. Each names the PR that found it. ## Step 1: its other readings First check the word's OTHER readings: `uv run python tools/vocab/query.py --word ` lists every reading and exclusion of it in every file, which is where the overlaps between categories now show. Real overlaps exist: `st`/`freiherr` ∈ `PARTICLES` ∩ `TITLES`, and `do`/`mc`/`vd` ∈ `PARTICLES` ∩ `SUFFIX_ACRONYMS`; `abd` = "ABD" ∈ `SUFFIX_ACRONYMS`; `abu` ∈ `PARTICLES` ∩ `BOUND_GIVEN_NAMES` (position-dependent: leading token → given-name join, mid-name → family-name join). Usually position-dependent and harmless, but can force a guard or an exclusion (the `last_base` all-particles guard; `abd`, kept out of `BOUND_GIVEN_NAMES` until decisions/P5 found the credential collision cost nothing at either end). ## Step 2: real names, not memory Then check the word against real names, not memory: `uv run python tools/name_usage.py ` counts its bearers as a given name and as a family name (US Census surnames, SSA given names, Wikidata with countries of citizenship), and gives its given-name frequency band per country from Jörg Michael's `nam_dict.txt` (54 countries and an "other countries" column, GFDL, downloaded into the cache and never committed) — the one source that sees a given name outside the US and Wikidata's lower bound, such as Korean `In` (band A in Korea) or Vietnamese `Bay` (6 in Vietnam). A band is a frequency class, not a count, and the dictionary is a selection, so a miss there (`Shehu`) proves nothing; and because a truncated source reads as a run of false zeros, a pinned download is checked against its sha256 on every load from the cache, never against a floor on what it holds — #775's first fix refused fewer than 40,000 names and passed a `nam_dict.txt` missing only its last line (`Zyta`). Pass a name as it is written (`McDonald`): Wikidata labels match exactly, and no rule rebuilds an internal capital from `mcdonald`. A word in a non-Latin script is counted by its romanization too, since the Census, SSA and nam_dict hold only Latin names, and that count weighs only where its bearers write the script: #835 vetted its salutations by their own spellings and its review found `बाबू` flagged while `babu` is a US surname (1,496) and an Indian given-name element (Ram Babu), where the romanization of `মি`, `Mi`, counts Korean and Chinese bearers who never write Bengali. It reports and does not decide — read the counts against the position the set's claim acts on. For a suffix it also has the other side: `--fetch-nppes` counts the US healthcare providers who write the word as a credential (NPPES, the 4.8 million credentialed providers in the pinned August 2026 file, so blind to law, accounting and honours), and `--rank N --unlisted` lists the commonest credentials the vocabulary misses, with where each lands in `John Smith, X` -- the list #797 came from. Where a local corpus is fetched (`tools/differential/local_corpora.py`, mechanisms.md#NAME-DATA-JOBS), a row says how many of its records (for ClinicalTrials.gov, officials) write the word, and how many after a comma: ClinicalTrials.gov's study officials show where a credential actually stands in a name as typed. ## Step 2, for a glued word: does any name, or any longer word, end in it? A word that would peel GLUED (`GLUED_HONORIFICS`) asks a different question, whether any name ENDS in it, and for a CJK word the tool answers that too: an ends-with row from a cached index of Wikidata's CJK name labels (built on first use; 33 minutes on 2026-10-17), plus the names the word ends across the family/given join (阿部 + 長 for 部長). Read the tool's hits by item before counting them, because labels include transcriptions and mislabels: #707's one hit for 二世 was 厄德二世, the given name Odo with a regnal numeral in its Chinese label, borne by 109 Western people named Odo. Then ask a third question the tool cannot: does a common longer word end in it? The peel matches the end of a token, spaced or glued, so a listed tail splits every unlisted compound that ends in it — #707 vetted 議員, 社長 and 医学博士 against names and its review found 衆議院議員, 副社長 and 獣医学博士 cut at the tail (`田中 副社長` → given 副); no corpus held a compound office word until #707's rules examples added two, so the snapshot was silent about the class. List the common compounds whole or record the split as a cost. ## Step 3: the predicates that read a member's spelling Check the predicates that test a member's SPELLING rather than its membership: they encode what the set's members happened to look like when they were written. `_pieces.starts_a_credential_run` implemented the credential run's "Latin" (rules/S8 now, part of S2 until #682 split it) as `isascii()`, exact until #661 added `mlađi`, which would have read `mlađi Jones` differently from `stariji Jones`; the design-docs review caught it, after a first draft had credited the test to the wrong clause of S2 and proposed deleting it on a mutation that moved nothing because nothing pinned it. (#679 then dropped the script test on purpose, after measuring what it protected.) Before adding the first member of a new script, or the first accented one, grep the set's consumers for `isascii`, `isalpha` and script tests, and read the rule each one implements. The same goes for the first member holding PUNCTUATION: #700 added `d'` and `az-`, the parse read them right and every snapshot move was an intended one, and `capitalized()` still wrote `D' Ivoi`, because case repair splits a token at its apostrophe or hyphen and asks the vocabulary of the stem. The snapshot records both case views since #826, but only for the names the corpora hold, so parse such a member's names through `capitalized()` and `capitalized(force=True)` in each case before calling it done. ## Step 4: record the vetting on the word Record what the vetting found ON the word, not in a comment or a decisions sentence: `note`, `issue`, a `given`/`family` reading with dated counts, and for a word you decided to keep out an `excluded` entry with its reason, so the next contributor who adds it meets the reason as a build error (decisions/vocabulary-model). A name reading is not only provenance on a word that has a credential, particle or title reading: it IS the clash, and derives the marker (an ambiguous acronym, a particle that may be given, since 2026-10-25, a generation or agnomen that may be a name, rules/S11, since phase 2h the same day, so given-name counts recorded on `junior` would gate it, and a title that may be a given name, rules/H9, since phase 2i), so counts kept as evidence AGAINST marking such a word go in the clashing reading's `note` instead, as `ld`'s do. ## Step 6: the user docs' doctests The user docs use real words as examples of an UNLISTED one, and listing the word breaks the example: #797 listed `dphil`, and docs/customize.rst's capitalization example (`parse("jane smith dphil").capitalized()` giving `Dphil`) failed in all five CI build jobs while `uv run pytest` passed, because `sphinx-build -b doctest docs` is a separate step. Run it after adding a word, and grep `docs/*.rst` for the word; the example now uses `dclinpsy`, a credential left unlisted on purpose, so list it only after moving the example. ## When a word's reading moves, or a new derivation reads old readings A word whose reading moves may be the worked example of the class it is leaving: `king` was the rules' and tests' title-that-is-also-a-surname across 35 files when #901 gave it a given reading, and five tests went vacuous before the review found them. Grep rules, decisions Standing lines, the RFCs, AGENTS.md, docs/*.rst, code comments and tests for the word before measuring, and replace it with a stand-in checked to read exactly as the word read on master in every example (`pope` there). A new derivation reads readings other work recorded for another purpose, so list what it would mark before shipping it: phase 2i's first build found thirteen salutations whose given readings #835 had kept as H8's evidence (`mr` among them, so `Mr Smith` would have read given 'Mr'), and moved their counts into notes. derek73-python-nameparser-456b024/nameparser/vocab/README.md000066400000000000000000001131511527303271700234170ustar00rootroot00000000000000# The vocabulary files This folder holds the parser's default vocabulary: the titles, suffixes, particles, conjunctions, bound given names and maiden markers it recognizes, and the case of the words it capitalizes specially, written one word at a time. It is the place to edit when a word should be recognized, should stop being recognized, or should be recognized differently. The parser never reads these files. `tools/vocab/build.py` compiles them into `nameparser/config/_compiled.py`, a committed module of sorted `frozenset` literals and one `dict` (`CAPITALIZATION_EXCEPTIONS`), and the `nameparser.config` modules re-export them under their public names (`TITLES`, `PARTICLES`, ...). So an edit here does nothing until you rebuild, and `tests/v2/test_vocab.py` fails, naming the command, when the committed module and the files disagree. Why the model looks this way, and what each decision cost, is recorded in [decisions/vocabulary-model](../../docs/design/decisions/vocabulary-model.md). Where it is going (phases 2 to 4) is in [the vocabulary model RFC](../../docs/design/vocabulary-model-rfc.md). ## What a file means A file is named for a BCP 47 language tag in canonical case: `fr.toml`, `pt-BR.toml`, `zh-Hant.toml`. It holds **the words the parser might meet in names written in that language**, which is what occurs in names and records written in it, including particles common there through immigration. It is not a list of words of that language's etymology. Two tags are not languages: - **`und`** (undetermined) holds the words no language file fits: `bon` and `dí`, particles of unrecorded origin until #766 refused their particle readings, now name readings and those exclusions (since 2026-10-20, when #751 finished what #749 and #750 began, giving every other word its languages). The comment at the top of the file says why they are there; a word whose language is found moves to that language's file. - **`mul`** (multiple) holds words shared on purpose, such as the Roman numerals. The script is read from the word, not from the file: `sr.toml` holds `mlađi` and `млађи` side by side. A script subtag (`zh-Hant`) is for a difference the text cannot show. The default vocabulary loads every file, so which file a word is in changes nothing about how a name is parsed today. A word may be in more than one file; check 5 of the build makes each category of it read the same in every file, every behavioral property agreeing whatever kind each file gives it. ## How an entry is built Each file has `schema = 1` at the top, then one `[[word]]` table per word. An entry has a `text` and its **readings**: what the word can be read as, each keyed by its kind and each a table, even when empty. ```toml [[word]] text = "van" given = { us_ssa = 31194, wikidata = 282, as_of = 2026-10-25, note = "US SSA births summed over both sexes; Wikidata is a lower bound; nam_dict band 13 in Vietnam, 2 in U.S.A." } [word.preposition] expands = "of, from" note = "Vietnamese Văn, and Van Johnson -- the canonical particle-or-given ambiguity this whole marker exists for" ``` `van`, in `nl.toml`, has two readings: a particle of the kind `preposition`, with two descriptive properties (`expands` and `note`, compiled to nothing), and a `given` name reading with the counts it was declared on. The two together make `van` a particle that may be a given name, a [derived marker](#derived-markers): `van Gogh` keeps `van` as the given name and reports the fork. ```toml [[word]] text = "do" family = { us_census_2010 = 30301, wikidata = 100, as_of = 2026-10-25 } given = { us_ssa = 11, wikidata = 9, as_of = 2026-10-25, note = "male births only; Wikidata is a lower bound; nam_dict band 8 in Korea, 6 in Vietnam, 1 in the Netherlands" } credential = { expands = "Doctor of Osteopathic Medicine" } ``` ```toml [[word]] text = "do" [word.preposition] expands = "of the, masc. sg." note = "Vietnamese Đỗ leads a great many surnames" ``` `do` has readings in three categories: a credential (an American degree, in `en.toml`), a particle (a Portuguese preposition, in `pt.toml`) and two name readings, so it is in both `SUFFIX_ACRONYMS` and `PARTICLES`; which one applies is decided by where the word stands. A word's readings are where clashes between categories are recorded: before this format, such a clash was visible only as two sets that happened to overlap. Its name readings make both clashes with a name: the credential may be a name and the particle may be given, each a [derived marker](#derived-markers), though the name readings sit in only one of its files. ```toml [[word]] text = "père" [word.excluded] generation = { reason = "pack-shaped: collides outside French", issue = [679, 684] } ``` `père` ("father", the French word for the Elder) is deliberately not a suffix. It has no vocabulary reading at all, only an exclusion refusing the `generation` reading with the reason. Anyone who adds `generation = {}` to `père` in any file gets a build error that quotes that reason. Its son, `fils`, was refused the same way until 2026-10-25; it now has the `generation` reading beside its `family` reading, which gates it by the name around it ([derived markers](#derived-markers)), so `Marie Fils` keeps her family name. ```toml [[word]] text = "殿" [word.honorific] issue = [307, 308] note = "formal, official/rank-flavored; spaced is safe because a 殿-surnamed person's name LEADS and the suffix gate is trailing-only" [word.honorific.excluded] attached = "some ninety Japanese surnames end in it, 鵜殿 (Udono) and 真殿 (Madono) with four-figure populations, so a peel would cut a family name in two" ``` `殿` is an honorific read when written apart from the name, and an exclusion on the reading refuses one property, `attached`: it is never split off the end of a name it is glued to. Two rules about the `text`: - It is written already normalized: lowercase, NFC, no edge periods, single-spaced. The build refuses any other spelling and prints the one to write. - It is one word, except for a `birth_marker` or a title with `addresses = "given"`, the two readings matched as a whole run (`z domu`). ## Word order Every file but `und.toml` lists its words in one order, and the build refuses a file that does not (check 10), naming the entry to move and where it belongs: 1. **By kind**, in the order of the table under [Kinds](#kinds), which lists the categories in turn and each category's kinds together. A word with several readings sorts under its first kind in that order. A word whose only vocabulary reading is a refusal (`père`, `duke`) sorts under the kind it refuses, a refused category counting as its first kind, and a name reading places a word only when it has nothing else. 2. **Then by the word it is a spelling of**: a reading's `same_as`, or the word's own text when it has none. A head comes first and its spellings follow it, so `jr` is followed by `jnr` and `junior`, and `sr` by `snr`. A spelling that has spellings of its own heads a group inside its head's: in `ar.toml`, `el-` follows the `a…-` spellings of `al-` with its own assimilated forms after it, and `ul-` comes last. Only the file's own entries are followed, so a word that is a head in one file and a spelling in another is placed in each file by that file alone. 3. **Then by the word's text.** Text is compared by codepoint. The texts are already normalized, so this is the order of the normalized text, whatever the script: Latin before Cyrillic before Arabic, and within a script an accented letter after `z` (`aḍ-` follows `az-` and `aš-`). It is defined for every script and the same on every machine, and it is no one language's collation. A traditional and a simplified spelling with no `same_as` between them sort apart. In `zh.toml` each pair is linked with the Simplified spelling as the `same_as` of the Traditional one, so the Traditional form heads the group (`课长` is `same_as = "課長"`): the Traditional form is the base orthography, and the Simplified one is its derived twin. The build checks the order and never rewrites a file, because a comment above an entry belongs to it and must move with it, which a TOML reader cannot see. When the build says an entry belongs somewhere else, move it with its comment. The order is checked last, so any other refusal (an exclusion's reason, a `same_as` naming no word) is reported first. The same check refuses a `same_as` chain that loops, in every file. `und.toml` is exempt, holding no language's words; a word leaving it for a language file takes its place in that file's order. ## Kinds A reading's key is its **kind**. The table lists the kinds in their canonical order, which is the [word order](#word-order)'s first key and `CATEGORY`'s order in `tools/vocab/build.py`. Each kind belongs to one **category**, and the category decides which compiled set the reading feeds. For title, suffix, particle and conjunction the category's own name is also a kind (`title = {}`: a title not yet classified), so classifying a word later is a data edit that moves no compiled set. The one exception: a generic `suffix = {}` classified as `credential` moves from `SUFFIX_WORDS` to `SUFFIX_ACRONYMS`, because a credential's `acronym` is true unwritten; write `acronym = false` to keep it where it was. The suffix and particle categories are classified (two particles of unrecorded origin, `bon` and `dí`, keep the generic kind in `und.toml`); so are the titles (#750); the others carry their generic kind. | Kind | Category | Compiled into | |---|---|---| | `title` | `title` | `TITLES` | | `salutation` | `title` | `TITLES` | | `style` | `title` | `TITLES`, `TITLE_STYLES` | | `academic` | `title` | `TITLES` | | `religious` | `title` | `TITLES` | | `nobility` | `title` | `TITLES` | | `military` | `title` | `TITLES` | | `office` | `title` | `TITLES` | | `occupation` | `title` | `TITLES` | | `kinship` | `title` | `TITLES` | | `modifier` | `title` | `TITLES`, `TITLE_MODIFIERS` | | `suffix` | `suffix` | `SUFFIX_WORDS` | | `generation` | `suffix` | `SUFFIX_WORDS` | | `agnomen` | `suffix` | `SUFFIX_WORDS`, `SUFFIX_WORDS_AMBIGUOUS`, `AGNOMENS` | | `credential` | `suffix` | `SUFFIX_ACRONYMS` | | `honorific` | `suffix` | `SUFFIX_WORDS` | | `professional_honorific` | `suffix` | `SUFFIX_WORDS` | | `status` | `suffix` | `SUFFIX_WORDS` | | `courtesy` | `suffix` | `SUFFIX_WORDS` | | `particle` | `particle` | `PARTICLES` | | `preposition` | `particle` | `PARTICLES` | | `article` | `particle` | `PARTICLES` | | `patronymic` | `particle` | `PARTICLES` | | `teknonymic` | `particle` | `PARTICLES` | | `clan` | `particle` | `PARTICLES` | | `rank` | `particle` | `PARTICLES` | | `saint` | `particle` | `PARTICLES` | | `alias` | `particle` | `PARTICLES` | | `conjunction` | `conjunction` | `CONJUNCTIONS` | | `bound_given` | `bound_given` | `BOUND_GIVEN_NAMES` | | `birth_marker` | `relation` | `MAIDEN_MARKERS` | | `given` | `name` | nothing | | `family` | `name` | nothing | A suffix reading goes to `SUFFIX_ACRONYMS` when its `acronym` property is true and to `SUFFIX_WORDS` otherwise; the column shows where each kind goes with `acronym` unwritten. A word has at most one suffix kind (check 4). A `given` reading carrying `same_as` goes to `GIVEN_NAME_ABBREVIATIONS`, a given name written short (see [Name readings](#name-readings)). `generation` is a word that numbers or ranks its bearer among relatives of the same name, written after the name: junior and senior and their translations, `fils`, the roman numerals the vocabulary lists. `agnomen` is the Brazilian and Portuguese kind of it (`filho`, `neto`, `sobrinho` and their feminines): it is written in the case of the name around it, so its writing settles nothing, and an agnomen reading needs a name reading on its word (check 11), which gates it by the count alone ([rules/S11](../../docs/design/rules/S11-suffix-word-that-is-also-a-name.md), a suffix word that is also a name). The column shows an agnomen with that name reading. The title kinds say what the title is: - `salutation`: a language's everyday form of address, its "Mr" and "Mrs" (`mr`, `mrs`, `mx`, `herr`, `señora`, `г-н`, `श्री`), and the English `master` for a boy; - `style`: an honorific style or dignity rather than an office (`hon`, `excellency`, `highness`, `sheikh`); - `academic`: an academic or professional form of ADDRESS (`dr`, `prof`, `docent`, the Egyptian `مهندس`, engineer); - `religious`: a clergy, monastic, saintly or religious-scholar title (`rev`, `rabbi`, `swami`, `saint`), and a kinship word used as one (`father`, `sister`); - `nobility`: a royal, noble, chiefly or chivalric rank (`king`, `prince`, `graf`, `sultan`, `sir`, `dame`, `knight`); - `military`: a military or police rank and its abbreviations (`gen`, `capt`, `sgt`); - `office`: a public, judicial, civic, corporate or court office held (`president`, `judge`, `mayor`, `ceo`); - `occupation`: a job DESCRIBED before a name, as bylines and captions write it, household service included (`actor`, `novelist`, `guitarist`, `maid`); - `kinship`: a family word written as a title (`aunt`, `uncle`); - `modifier`: a word that is a title only inside a phrase with a head title (`vice`, `grand`, `assistant`, `his`, `1st`), decision 10 of [decisions/vocabulary-model](../../docs/design/decisions/vocabulary-model.md). A word whose every title reading is a modifier compiles into `TITLE_MODIFIERS` too, and in the leading run of titles before the first comma it is a title only with a head title after it in that run, before `of` (`Mother Superior of the Convent`), or inside the object of `of` or `the` there (`President of the United States`); ending the name, or after a comma, it is read as any title is ([rules/H9](../../docs/design/rules/H9-title-phrase.md), the title phrase), save a modifier with a `given` reading (`wing`), which that test reads in every part. A word that qualifies the head it stands behind is no modifier in this sense: `d'affaires`, after `chargé`, is an `office`. A word written in a part after a comma as the person's role is not one either: `resident`, `rt` and `pro` were reclassified for that (2026-10-25). A `style` compiles into `TITLE_STYLES`, the one other kind with a set of its own: a style or a modifier before a title that may be a given name puts it in a title phrase (`The Hon. Justice Smith`). `academic` and `occupation` are told apart by what the word does, not its subject: `Dr` addresses the person, `chemist` describes them. A title is written with one kind per file, as a particle is (a convention, not a check), and check 5 holds every title reading of a word to the same `addresses` whatever its kind. `gender` is recorded, as each title is classified, on one marked for one sex (a feminine form, `actress`, `señora`, or one half of a fixed pair, `mr` and `mrs`, `king` and `queen`), and on `mx` as `"n"`; an unmarked noun (`actor`, `doctor`) records none. The particle kinds say what the word does in the names of the file's language: - `preposition`: "of", "from", "at", alone or contracted with the article after it (`de`, `van`, `von`, `du`, `del`, `vom`, `ter`); - `article`: a definite article standing alone or elided (`la`, `der`, `het`, `'t`, `l'`, the Arabic `al-` and its assimilated spellings); - `patronymic`: "son of" or "daughter of", where the word after it is a parent's name (`bin`, `binti`, `ibn`, `بن`, `בן`); - `teknonymic`: "father of", naming the bearer by a child (`abu`, `أبو`); - `clan`: a lineage prefix, where the word after it is a family name shared by the lineage rather than a parent's name (`mac`, `mc`, `ó`, `ní`, `آل`); - `rank`: a former rank written inside the surname (`freiherr`, `freiherrin`); - `saint`: "Saint" opening a surname (`san`, `santa`, `st`, `ste`); - `alias`: a word joining two surnames of one person (`vel`). Unlike the suffix kinds, one word may take a different particle kind in each language: `de` is a preposition in `fr.toml` and an article in `nl.toml`. Whether a particle may be given is the word's, not the reading's: its `given` reading, in any file, decides it for every particle reading (check 11). On a particle reading, `expands` is the word's meaning in that language, and `note` is reserved for vetting. A never-given particle may rest on its kind and `expands` alone: one attested bearer settles the may-be-given side, but no amount of searching settles absence, so what is recorded is what the word IS (an article, a preposition) rather than a failed search. And a word with a `given` reading whose particle readings carry no `note` in any file sits on the conservative default and nobody has examined it, and so does one whose note says so, as `heer`'s does: counts on the given reading were taken for phase 2's derivation (2026-10-25), not read against the particle position. The test is per word, because a word's note sits in one of its files, not in each: `mac` is examined though only `en.toml` holds its note. **Classifying a category adds kinds to every check that tests it.** While a category has only its generic kind, a check that asks for the category and one that asks for the generic kind give the same answer, so a test row written in the generic kind cannot tell them apart. When a category gets its first fine kinds, give every check on that category a row in `tests/v2/test_vocab.py` written in a fine kind, and run each row against the check keyed on the kind to see it fail. #749's review found four particle checks pinned only on the generic `particle` kind once the shipped data used it for two words: the required `may_be_given`, an exclusion refusing a sibling kind, the bound given name that is a never-given particle, and the elided particle's two spellings. Each passed every test when keyed on the kind, which would have let a `preposition = {}` with no `may_be_given` compile silently into the never-given set. The `name` kinds compile to nothing on their own. They record that a word is borne as a given or family name, with counts (see [Bearer counts](#bearer-counts)), as evidence for a reading or an exclusion, and a `given` or `family` reading beside an acronym reading, or a `given` reading beside a particle reading derives a marker (see [Derived markers](#derived-markers)). ## Properties A property is **behavioral** when it compiles into a set and so changes how names are parsed, **stated** when it compiles to nothing but the build holds it to the word's readings, and **descriptive** when it is checked and recorded but compiled to nothing, save `same_as` on a `given` reading, which compiles the word into `GIVEN_NAME_ABBREVIATIONS` (see [Name readings](#name-readings)). Each is allowed only on the categories listed; a `name` reading takes its own keys (see [Name readings](#name-readings)). Two of the sets are not properties at all but [derived markers](#derived-markers), read off a word's name readings. | Property | Type | Applies to | Unwritten | Effect | |---|---|---|---|---| | `addresses` | behavioral | `title` | `"family"` | `"given"` adds the word to `GIVEN_NAME_TITLES`: `Sir John` reads `John` as the given name ([rules/H1](../../docs/design/rules/H1-title-and-one-name-word.md), a title and one name word) | | `attaches` | behavioral | `title` | none | `TITLE_PHRASES`: the title kinds, or the title words, this word stands before in a title phrase, a list (`chief = { attaches = ["office", "military"] }`, `sergeant = { attaches = ["major"] }`, a word where a kind would reach too far), compiled into the two-word phrases it makes with each title word of those kinds and each word named (`chief justice`, `sergeant major`); a title that may be a given name on either side of one is a title there ([rules/H9](../../docs/design/rules/H9-title-phrase.md), the title phrase) | | `after_given` | behavioral | `title` | false | `AFTER_GIVEN_TITLES`, on a `salutation` reading only: written bare at the end of the given part after a family comma, the word is the title, as in `Doe, Jane Mrs` ([rules/H8](../../docs/design/rules/H8-salutation-ending-the-given-part.md), a salutation ending the given part). A salutation borne as a given name or a surname is left out with an exclusion of the property, its counts beside it (`goodman`, `herr`), since a middle name may be either | | `acronym` | behavioral | `suffix` | true for `credential`, false for every other kind | true: `SUFFIX_ACRONYMS`, matched with every period removed (`M.D.` is `md`); false: `SUFFIX_WORDS` | | `attached` | behavioral | `suffix` | absent | `GLUED_HONORIFICS`, on a word reading only: `{ side = "after" }` splits the word off the end of a name token, as in `田中さん` ([rules/W2](../../docs/design/rules/W2-glued-honorific-peel.md), the glued-honorific peel) | | `may_be_given` | stated | `particle` | none: required where the word has no `given` reading | `false` is required where the word has no `given` reading in any file, and that absence, not the property, is what puts the word in `NON_GIVEN_NAME_PARTICLES`: a particle opening the name starts the family name ([rules/P1](../../docs/design/rules/P1-lone-particle-fold.md), the lone particle fold). `true` is refused, naming the `given` reading to write instead (check 11) | | `may_be_initial` | behavioral | `conjunction` | false | `CONJUNCTIONS_AMBIGUOUS`, on a cased single letter only: read as an initial in a name written in one case, as in `JOHN E SMITH` ([rules/P8](../../docs/design/rules/P8-single-letter-connective-against-the-initial-it-spells.md), a single-letter connective against the initial it spells) | | `default` | behavioral | `title`, `suffix`, `particle`, `conjunction`, `bound_given`, `relation` | true | `false` keeps the reading out of every compiled set, `SUFFIX_WORDS` included for a generation: recorded and checked as a reading, for a pack to turn on in phase 3, where its writing offers the default no cue (`h`, Spanish *hijo*, written `H.`, exactly as an initial is). An exclusion does not refuse it, and it cannot read two ways beside the word's live readings (checks 4 to 6); a name reading beside it marks nothing. `true` is refused: it is what every reading is unwritten | | `cased` | behavioral | `title`, `suffix`, `particle`, `conjunction`, `bound_given`, `relation` | the word as written | `CAPITALIZATION_EXCEPTIONS`: the case `capitalized()` gives the word (`PhD`), a mask over its letters and digits, never a replacement (#459) | | `note` | descriptive | every category, a `name` reading included | | free text: why the reading is as it is | | `issue` | descriptive | every category but `name` | | an issue number, or a list of them | | `decided` | descriptive | every category but `name` | | a TOML date | | `expands` | descriptive | every category but `name` | | the meaning the entry was added for, not every meaning | | `same_as` | descriptive | every category | | another word of the same category that this one is a spelling of (`jnr` is `jr`); on a `given` reading, the full given name the word abbreviates, which compiles the word into `GIVEN_NAME_ABBREVIATIONS` (see [Name readings](#name-readings)) | | `native` | descriptive | every category, a `name` reading included | | a native-script spelling | | `gender` | descriptive | every category, a `name` reading included | | `"f"`, `"m"` or `"n"`, only where the word gives a definitive clue | Two of these need more than their row: - **`may_be_given` has no default.** The format before this one listed the never-given particles and treated every other particle as possibly a given name, so a new particle was ambiguous unless argued otherwise ([decisions/vocabulary-collisions](../../docs/design/decisions/vocabulary-collisions.md), C-i). A default here would pick one side of that silently, so the build refuses a particle reading that neither states `may_be_given = false` nor has a `given` reading on its word. When unsure, write the `given` reading, with a `note` if no source counts it. - **`cased` belongs to the word**, not the reading: one mask per word, whichever reading carries it. ### Derived markers Four sets mark a clash with a name, and all four are derived: a word is in one when it carries the clashing reading and a name reading, in any of its files (check 11, since 2026-10-25, [decisions/vocabulary-model](../../docs/design/decisions/vocabulary-model.md)). Phase 1 wrote two of them as flags, `may_be_name` and `may_be_given = true`, which the build now refuses, naming the reading to write; the third, a suffix word that is also a name, arrived with phase 2h, and the fourth, a title that may be a given name, with phase 2i. | Marker | Readings | Compiled into | Effect | |---|---|---|---| | possibly a name, a word | a `generation` or `agnomen` reading and a `given` or `family` reading; a name reading beside any other suffix word kind is refused (check 11), no gate reading that clash | `SUFFIX_WORDS_AMBIGUOUS` | a suffix word that is also a name (`fils`, `neto`) is read as a suffix only where the name has words to spare, as an ambiguous acronym is: `Alexandre Dumas fils` and `Pedro Alves Neto` end in a suffix, `Marie Fils` and `Ana Neto` in a family name, either way reported ([rules/S11](../../docs/design/rules/S11-suffix-word-that-is-also-a-name.md)). A generation's Title case in a name of mixed case leans it to the name, and its lower case settles it as the suffix wherever the name keeps a family word; an agnomen's writing settles nothing | | possibly a name | an acronym `suffix` reading and a `given` or `family` reading | `SUFFIX_ACRONYMS_AMBIGUOUS` | an acronym that is also a name (`ma`) is read as a suffix only where its spelling or the name around it says so: `John Smith MA` and `John Smith M.A.` end in a suffix, `John Smith Ma` in a family name ([rules/S4](../../docs/design/rules/S4-ambiguous-acronym-needs-words-to-spare.md), the ambiguous acronym needs words to spare; [rules/S6](../../docs/design/rules/S6-case-signal-at-the-suffix-slot.md), the case signal at the suffix slot) | | possibly a name, a title | a title reading and a `given` reading, on a word with no particle reading | `TITLES_AMBIGUOUS`, and kept out of `TITLES` | a title that may be a given name (`dean`, `justice`, `major`) is a title only inside a title phrase: after a modifier, a style or a title it stands behind in a phrase `attaches` makes, or before `of` or a title it attaches to. Elsewhere it is a name word, reported where it opens the name if a phrase `attaches` makes holds it (`grace`, held by none, is a title only behind a style or a modifier and reports nothing): `Dean of Students` and `Major General Smith` read the title, `Dean Smith` and `Mr Major Smith` the given name ([rules/H9](../../docs/design/rules/H9-title-phrase.md), the title phrase). A title that is also a particle (`st`, `freiherr`) is left to the particle rules, its `given` reading deriving the particle's marker alone. A salutation's given-name counts kept as evidence for `after_given` sit in the salutation reading's `note` (`mr`, `herr`), so they mark nothing | | possibly given | a `particle` reading and a `given` reading | kept out of `NON_GIVEN_NAME_PARTICLES` | a particle opening the name stays a given name there (`van Gogh`, given `van`) and the fork is reported, rather than starting the family name ([rules/P1](../../docs/design/rules/P1-lone-particle-fold.md), the lone particle fold) | Before phase 2h (2026-10-25) a word reading of the suffix category beside a name reading marked nothing, and no shipped word had one. The marker is the clash, not the counts: a count recorded for a word that should stay unmarked goes in the clashing reading's `note`, as `ld`'s does, since a name reading would mark it. `may_be_initial` is not derived: its clash is with the initial, a shape every cased single letter has, and which letters a tradition writes both ways is a judgment about the letter, not a name anyone bears. ### Name readings A `given` or `family` reading takes its own keys: | Key | Meaning | |---|---| | `us_census_2010` | bearers as a surname in the 2010 US Census | | `us_ssa` | births with this given name in US Social Security data, all years summed; say in `note` which sex, or that both are added | | `wikidata` | people in Wikidata with this as their given or family name, a lower bound | | `as_of` | the date the counts were taken, required beside any count | | `native` | a native-script spelling | | `note` | free text | | `gender` | `"f"`, `"m"` or `"n"` | | `same_as` | on a `given` reading only: the full given name the word abbreviates, normalized (`chas` is `charles`), which puts the word in `GIVEN_NAME_ABBREVIATIONS`: written with its period at the head of the part of the name that carries the given name, the word is that given name, where an unlisted abbreviation there is a title by its shape ([rules/H2](../../docs/design/rules/H2-leading-abbreviation-title.md), the leading-abbreviation title), so `Chas. Smith` reads given `Chas.`. Anywhere else the reading acts on nothing. A name is no word of the vocabulary, so this `same_as`, unlike a reading's, names no entry, and an entry holding only such a reading is not inert | A name reading may stand without a count, as a particle kept possibly given under C-i's default with no source counting it does, but it then needs a `note` saying why it is declared (check 3). ## Exclusions An exclusion records that a reading was considered and refused, with the reason, so that the next person to propose it meets the reason as a build error instead of rediscovering the evidence. - **A refused reading** is a word-level table: `[word.excluded.generation]` with a `reason`, or `excluded = { generation = "the reason" }`. It refuses that kind in every file, and also every other kind that compiles into the same set, so a generic `suffix = {}` cannot put back a refused `generation`. A category name refuses the whole category (`excluded = { suffix = "..." }`). A `name` kind refuses only itself. - **A refused property** sits on a reading the word keeps: `[word.honorific.excluded]` with `attached = "the reason"`. It refuses any value of that property other than the one it takes unwritten, on any reading of the category; a refused `cased`, which belongs to the word, on any reading of the word. The value is a reason string, or a table with `reason` and optionally `issue`, `decided` and `note`. The exclusion goes in the file of the language whose reading it refuses. An entry may hold exclusions and no vocabulary reading, as `père` does. An entry with no reading and no exclusion is refused as inert, and one holding only a name reading is a warning, which the test suite makes an error, unless that reading is a `given` reading carrying `same_as`, which compiles. To see what has been refused, and why: `uv run python tools/vocab/query.py --excluded generation`. ## Bearer counts Counts come from `tools/name_usage.py`, which reports how often a word is borne as a given name and as a family name in four name sources: 2010 US Census surnames (listed from 100 bearers), US Social Security given names (a name-sex pair is listed for a year from 5 births, reported per sex), Jörg Michael's nam_dict (a given-name frequency band per country, not a count) and Wikidata (live, with countries of citizenship, and a lower bound because many people have no name statement). For a word holding a Han, kana or hangul letter it adds a row of the Wikidata names that END in the word, which is the question for a glued honorific. With `--fetch-nppes` it also counts the US healthcare providers who write the word as a credential (NPPES), the other side of a suffix that is also a name; `--rank N` lists the commonest credentials with their name counts and how a comma name ending in each one parses. Its docstring says what each number can and cannot show. Record a count on the word as a `given` or `family` reading, with the date you took it in `as_of`. A source's count is left out when it is below that source's floor, never written as 0. A suffix reading has no count field yet, so a credential count goes in the reading's `note`, with its date and source. Counts are provenance: they are the evidence a decision was made on, and nothing computes a reading from them. The reading itself is not inert where it clashes, as a `given` or `family` reading beside an acronym reading, or a `given` reading beside a particle reading does, marking the clash, so counts kept there as evidence AGAINST marking go in the clashing reading's `note` (`ld`). ## Adding a word 1. **Look up the word's other readings.** `uv run python tools/vocab/query.py --word ` lists every reading and exclusion of it in every file. An exclusion there is a decision already made; a reading in another category is a clash you are about to create, which is fine when position settles it (`do`) and needs an argument when it does not. 2. **Check it against real names, not memory.** `uv run python tools/name_usage.py ` gives the counts (add `--fetch-nppes` for a suffix, to see how often it is written as a credential), and [decisions/vocabulary-collisions](../../docs/design/decisions/vocabulary-collisions.md) is the criterion they are read against: a word that is a real name in the position the reading acts on needs a name reading with those counts, which marks the clash (see [Derived markers](#derived-markers)), or should stay out. `nameparser/vocab/AGENTS.md` lists the traps that have bitten before, filed under these steps. 3. **Check how it is written.** The entry's `text` is the folded spelling (`mbchb`), and `capitalized()` writes an acronym credential in capitals unless the word has a `cased` mask, so one that people write in mixed case (`MBChB`, `DPhil`, `ScD`) needs the mask: `cased = "MBChB"`. Nothing in the build can see this, since no source it reads records case. The ClinicalTrials.gov row of `name_usage.py` lists the spellings as typed ("written MBChB 1,442, MBCHB 72, MbChB 28"), once that local corpus is fetched (`uv run python tools/differential/local_corpora.py fetch ctgov-officials`). Where a mask would cost a name its rendering, refuse it instead, with the reason, as `edd` does (`'edd smith'` would render `'EdD Smith'`). #806 added twelve mixed-case credentials without their masks, and review caught it. 4. **Write the entry** in the file of the language whose names it occurs in (`und.toml` when that is not known), in its place in the [word order](#word-order), with what the checks found on it: `note`, `issue`, a dated name reading, a `cased` mask, or an `excluded` entry if you decided against it. 5. **Rebuild**: `uv run python tools/vocab/build.py`. It refuses what it can detect, naming the word and, where one file is at fault, the file, and rewrites `nameparser/config/_compiled.py`. Commit both. 6. **Run the tests**: `uv run pytest tests/v2/test_vocab.py` for the build, then the full suite, and read what `tests/v2/test_snapshot.py` reports as moved (the root `AGENTS.md`, "Validating a new parsing rule"). `uv run python tools/differential/measure.py --base ` runs that check and the CLDR one against the branch's committed records, and every local-corpus check against the base checkout, in one command, writing one summary (`tools/differential/README.md`, "Measuring a change"). For a word whose reading acts on a name position, such as a title that is also a given name, `uv run python tools/vocab/word_report.py ... --base ` reads it both ways in one run: the word in a grid of shapes (a head, behind a title, after a family comma, after a full name and a comma, past a second comma, trailing with a period, inside a title phrase), each in three cases and under the three name orders, printing the rows the base reads differently; how the names in every fetched local corpus that open with the word, or open the part after the first comma with it, read it (title, given or other, counts only, with the corpora it could not read named); and `name_usage.py`'s rows. Without `--base` it prints every text, one line where the three orders agree and one block where the three cases read alike once lowercased; with it, what moved, folded the same way. `--rank` takes a list of words, the triage for a batch, which needs no change to the tree. It counts the corpus names that read each word as the title at either position, which holds real titles too, and beside them the `psc-individuals` filings opening with the word by the box the filer typed it into: the title box (a real title, what the move costs) and the forename box (a given name the title takes, what it gives back). The words are ordered by the forename box's count, printed beside its share of the two as a fraction (`164/165`), so a word three filers typed as a forename does not outrank one 164 did. On 2026-10-27 `sir` read as the title in 495 names but was filed 277 times as a title and 8 as a forename, while `sheikh`'s 13 title-box filings were the 13 agreement records #912 moved away from agreeing. `uv run python tools/vocab/build.py --check` exits 1 when the committed module is stale and 2 when a file fails a check. The checks themselves are numbered in `build.py`'s docstring. ## Names reserved for later phases These names will mean something in a later phase. Writing one now as a key, in a file, an entry or a reading, is a build error naming its phase, so nothing reads as working before it does. | Name | Phase | |---|---| | `prefer` | 3 | | `strict` | 3 | | `bare_only` | 4 | | `leading_only` | 4 | | `trailing_only` | 4 | | `separators` | 4 | Phase 2 gave `default` and `attaches` their meaning (2026-10-25, see [Properties](#properties)). The gate's slots carry position and shape already settles a bare letter ([rules/P7](../../docs/design/rules/P7-one-letter-particle-against-the-initial-it-spells.md), a one-letter particle against the initial it spells), so `leading_only`, `trailing_only` and `bare_only` wait for phase 4's shapes, as `separators` waits for its glued words. Two property values are refused for the same reason, with a message saying a later phase: `addresses = "full"` and `"any"`, and `attached` with a `side` other than `"after"`. The locale packs' vocabulary (`nameparser/locales/`) and the Korean surname list (`nameparser/config/_ko_surnames.py`, read as `Lexicon.default().surnames`) are not in this folder: the surnames are a segmentation list, outside the per-word model, and the packs move here in phase 3. derek73-python-nameparser-456b024/nameparser/vocab/ak.toml000066400000000000000000000003061527303271700234250ustar00rootroot00000000000000schema = 1 [[word]] text = "owura" salutation = { issue = 662, gender = "m", note = "under 100 US Census bearers and none found on Wikidata (tools/name_usage.py, 2026-10-17)", after_given = true } derek73-python-nameparser-456b024/nameparser/vocab/am.toml000066400000000000000000000002051527303271700234250ustar00rootroot00000000000000schema = 1 [[word]] text = "ጁኒየር" generation = { issue = 679 } [[word]] text = "ጁንየር" generation = { issue = 679 } derek73-python-nameparser-456b024/nameparser/vocab/ar.toml000066400000000000000000000420521527303271700234400ustar00rootroot00000000000000schema = 1 [[word]] text = "hajji" style = { expands = "one who has made the Hajj" } [[word]] text = "sheikh" style = { addresses = "given", gender = "m", issue = 752, decided = 2026-10-27, note = "a title only inside a title phrase, its given reading the clash (rules/H9): 'His Highness Sheikh Mohammed' and 'Sheikh of Kuwait' read the title, 'Sheikh Ahmed Khan' and 'Mr Sheikh Ahmed' the given name, unreported, since no listed phrase holds it" } given = { us_ssa = 436, wikidata = 21, as_of = 2026-10-27, note = "US SSA: male and female births added; Wikidata is a lower bound (Bangladesh 3, India 2); UK PSC filers put it first in the forename box 174 times and in the title box 19 (2026-10-27)" } family = { us_census_2010 = 6462, wikidata = 60, as_of = 2026-10-27, note = "US Census rank 5,384, asian/PI 71.62%" } [[word]] text = "cheikh" style = { addresses = "given", gender = "m", same_as = "sheikh", expands = "the French transliteration, 'Cheikh Anta Diop'" } [[word]] text = "shaik" style = { addresses = "given", gender = "m", same_as = "sheikh", issue = 752, decided = 2026-10-27, note = "a title only inside a title phrase, its given reading the clash (rules/H9): read as 'sheikh' is" } given = { us_ssa = 10, as_of = 2026-10-27, note = "US SSA births; UK PSC filers put it first in the forename box 14 times and never in the title box (2026-10-27)" } family = { us_census_2010 = 1053, wikidata = 14, as_of = 2026-10-27, note = "US Census asian/PI 93.73%; mostly a South Indian surname" } [[word]] text = "shaikh" style = { addresses = "given", gender = "m", same_as = "sheikh", issue = 752, decided = 2026-10-26, note = "a title only inside a title phrase, its given reading the clash (rules/H9): 'Shaikh Ahmed' and 'Mr Shaikh Ahmed' read the given name, unreported, since no listed phrase holds it" } given = { us_ssa = 41, as_of = 2026-10-26, note = "US SSA: male births; Wikidata holds no given-name statement for it, a lower bound" } family = { us_census_2010 = 6063, wikidata = 49, as_of = 2026-10-26, note = "Census: asian/PI 89.61%; Wikidata: India 7, Pakistan 5" } [[word]] text = "shayk" style = { addresses = "given", gender = "m", same_as = "sheikh" } [[word]] text = "shaykh" style = { addresses = "given", gender = "m", same_as = "sheikh" } [[word]] text = "sheik" style = { addresses = "given", gender = "m", same_as = "sheikh", issue = 752, decided = 2026-10-27, note = "a title only inside a title phrase, its given reading the clash (rules/H9): read as 'sheikh' is" } given = { us_ssa = 39, as_of = 2026-10-27, note = "US SSA births; UK PSC filers put it first in the forename box 23 times and in the title box once (2026-10-27)" } family = { us_census_2010 = 401, wikidata = 4, as_of = 2026-10-27 } [[word]] text = "shekh" style = { addresses = "given", gender = "m", same_as = "sheikh", issue = 752, decided = 2026-10-27, note = "a title only inside a title phrase, its given reading the clash (rules/H9): read as 'sheikh' is" } given = { note = "no US or Wikidata count; UK PSC filers put it first in the forename box 6 times and in the title box once, and 378 of Nepal's 2022 local candidates write it, its own romanization of the Devanagari (2026-10-27)" } [[word]] text = "الشيخ" style = { addresses = "given", gender = "m", issue = 269, expands = "the sheikh", note = "the native spelling of the Latin sheikh cluster, the same convention ('Sheikh Mohammed')" } [[word]] text = "الشيخة" style = { addresses = "given", gender = "f", issue = 269, expands = "the sheikha" } [[word]] text = "سيد" excluded = { style = { reason = "Sayyid is a common given name; the honorific is not shipped bare", issue = 269 } } [[word]] text = "شيخ" excluded = { style = { reason = "bare, it collides with the common female given name Shaikha; الشيخ, with the article, ships", issue = 269 } } [[word]] text = "أستاذ" academic = { addresses = "given", gender = "m", issue = 269, expands = "professor" } [[word]] text = "أستاذة" academic = { addresses = "given", gender = "f", issue = 269, expands = "professor" } [[word]] text = "الأستاذ" academic = { addresses = "given", gender = "m", issue = 269, expands = "the professor; also an honorific, Mr." } [[word]] text = "الأستاذة" academic = { addresses = "given", gender = "f", issue = 269, expands = "the professor; also an honorific, Mrs." } [[word]] text = "الدكتور" academic = { addresses = "given", gender = "m", issue = 269, expands = "the doctor", note = "#269, for the group: Arabic honorifics precede the GIVEN name; the article forms are never given names, nor are the bare doctor, professor and engineer forms" } [[word]] text = "الدكتورة" academic = { addresses = "given", gender = "f", issue = 269, expands = "the doctor" } [[word]] text = "د" excluded = { academic = { reason = "the abbreviation د.: the edge-period fold leaves bare د, a single letter that would swallow initials (the bare-κ trap)", issue = 269 } } [[word]] text = "دكتور" academic = { addresses = "given", gender = "m", issue = 269, expands = "doctor" } [[word]] text = "دكتورة" academic = { addresses = "given", gender = "f", issue = 269, expands = "doctor" } [[word]] text = "مهندس" academic = { addresses = "given", gender = "m", issue = 269, expands = "engineer", note = "a genuine title in Egyptian usage" } [[word]] text = "imam" religious = { note = "addresses by the TITLE, not the given name, and the tradition keeps surnames, so it stays out of GIVEN_NAME_TITLES (#346)" } [[word]] text = "mahdi" religious = { issue = 867, decided = 2026-10-26, note = "a messianic title, written as an epithet with the article or after a name ('the Mahdi', 'al-Mahdi'), not as an honorific before a person's own name; a title only inside a title phrase, so the epithet with its article before a name or after a comma reads as the given name (rules/H9's Accepted clause), its given reading the clash (rules/H9): 'Mahdi Karimi' and 'Mr Mahdi Karimi' read the given name, unreported, since no listed phrase holds it" } given = { us_ssa = 2398, wikidata = 141, as_of = 2026-10-26, note = "US SSA: male births; Wikidata is a lower bound (Iran 43, Iraq 9); nam_dict band 5 in Arabia/Persia" } family = { us_census_2010 = 1241, wikidata = 13, as_of = 2026-10-26 } [[word]] text = "mufti" religious = {} [[word]] text = "murshid" religious = { expands = "a Sufi guide" } [[word]] text = "الحاج" religious = { addresses = "given", gender = "m", issue = 269, expands = "the hajji, one who has made the hajj" } [[word]] text = "الحاجة" religious = { addresses = "given", gender = "f", issue = 269, expands = "the hajja, one who has made the hajj" } [[word]] text = "sultan" nobility = {} [[word]] text = "sultana" nobility = { gender = "f", issue = 752, decided = 2026-10-26, note = "a title only inside a title phrase, its given reading the clash (rules/H9): 'Sultana Begum' reads the given name, unreported, since no listed phrase holds it" } given = { us_ssa = 275, wikidata = 19, as_of = 2026-10-26, note = "US SSA: female births; Wikidata is a lower bound (Bangladesh 5); nam_dict band 3 in Romania and Arabia/Persia" } family = { us_census_2010 = 3471, wikidata = 34, as_of = 2026-10-26 } [[word]] text = "أمير" excluded = { nobility = { reason = "Amir is a common given name", issue = 269 } } [[word]] text = "سلطان" excluded = { nobility = { reason = "Sultan is a common given name", issue = 269 } } [[word]] text = "vizier" office = {} [[word]] text = "الأب" generation = { issue = 679, note = "the father" } [[word]] text = "الإبن" generation = { issue = 679, note = "the son" } [[word]] text = "الابن" generation = { issue = 679, note = "the son" } [[word]] text = "جونيور" generation = { issue = 679 } [[word]] text = "سينيور" generation = { issue = 679 } [[word]] text = "al" given = { us_ssa = 17334, wikidata = 1602, as_of = 2026-10-25, note = "US SSA births summed over both sexes; Wikidata is a lower bound; nam_dict band 2 in Great Britain, 2 in U.S.A., 2 in Arabia/Persia, 1 in the Netherlands" } [word.article] expands = "the" note = "Al Gore: 'Al' is an ordinary English given name. Also the Arabic definite article, which is the particle reading" [[word]] text = "al-" [word.article] may_be_given = false issue = 700 expands = "the" note = "the Arabic article as an index form writes it, hyphen attached and set behind the given name (\"Rashid, Harun al-\", \"Zaʿīm, Ḥusnī az-\"). Written apart with its hyphen the word is the article and nothing else, so it and its assimilated spellings are never-given where the bare 'al' (Al Gore) may be given. A closed list rather than a shape rule, by decision on #700; ASCII hyphen-minus only" [[word]] text = "ad-" [word.article] may_be_given = false issue = 700 expands = "the, assimilated before د" same_as = "al-" [[word]] text = "adh-" [word.article] may_be_given = false issue = 700 expands = "the, assimilated before ذ" same_as = "al-" [[word]] text = "an-" [word.article] may_be_given = false issue = 700 expands = "the, assimilated before ن" same_as = "al-" [[word]] text = "ar-" [word.article] may_be_given = false issue = 700 expands = "the, assimilated before ر" same_as = "al-" [[word]] text = "as-" [word.article] may_be_given = false issue = 700 expands = "the, assimilated before س" same_as = "al-" [[word]] text = "ash-" [word.article] may_be_given = false issue = 700 expands = "the, assimilated before ش" same_as = "al-" [[word]] text = "at-" [word.article] may_be_given = false issue = 700 expands = "the, assimilated before ت" same_as = "al-" [[word]] text = "ath-" [word.article] may_be_given = false issue = 700 expands = "the, assimilated before ث" same_as = "al-" [[word]] text = "az-" [word.article] may_be_given = false issue = 700 expands = "the, assimilated before ز" same_as = "al-" [[word]] text = "aš-" [word.article] may_be_given = false issue = 700 expands = "the, assimilated before ش, DMG" same_as = "al-" [[word]] text = "aḍ-" [word.article] may_be_given = false issue = 700 expands = "the, assimilated before ض, DMG" same_as = "al-" [[word]] text = "aḏ-" [word.article] may_be_given = false issue = 700 expands = "the, assimilated before ذ, DMG" same_as = "al-" [[word]] text = "aṣ-" [word.article] may_be_given = false issue = 700 expands = "the, assimilated before ص, DMG" same_as = "al-" [[word]] text = "aṭ-" [word.article] may_be_given = false issue = 700 expands = "the, assimilated before ط, DMG" same_as = "al-" [[word]] text = "aṯ-" [word.article] may_be_given = false issue = 700 expands = "the, assimilated before ث, DMG" same_as = "al-" [[word]] text = "aẓ-" [word.article] may_be_given = false issue = 700 expands = "the, assimilated before ظ, DMG" same_as = "al-" [[word]] text = "el-" article = { may_be_given = false, issue = 700, expands = "the, Egyptian spelling", same_as = "al-" } [[word]] text = "ed-" [word.article] may_be_given = false issue = 700 expands = "the, Egyptian spelling assimilated before د" same_as = "el-" [[word]] text = "edh-" [word.article] may_be_given = false issue = 700 expands = "the, Egyptian spelling assimilated before ذ" same_as = "el-" [[word]] text = "en-" [word.article] may_be_given = false issue = 700 expands = "the, Egyptian spelling assimilated before ن" same_as = "el-" [[word]] text = "er-" [word.article] may_be_given = false issue = 700 expands = "the, Egyptian spelling assimilated before ر" same_as = "el-" [[word]] text = "es-" [word.article] may_be_given = false issue = 700 expands = "the, Egyptian spelling assimilated before س" same_as = "el-" [[word]] text = "esh-" [word.article] may_be_given = false issue = 700 expands = "the, Egyptian spelling assimilated before ش" same_as = "el-" [[word]] text = "et-" [word.article] may_be_given = false issue = 700 expands = "the, Egyptian spelling assimilated before ت" same_as = "el-" [[word]] text = "eth-" [word.article] may_be_given = false issue = 700 expands = "the, Egyptian spelling assimilated before ث" same_as = "el-" [[word]] text = "ez-" [word.article] may_be_given = false issue = 700 expands = "the, Egyptian spelling assimilated before ز" same_as = "el-" [[word]] text = "ul-" [word.article] may_be_given = false issue = 700 expands = "the, after a case ending: \"Zia ul-Haq\"" same_as = "al-" [[word]] text = "ال" [word.excluded.article] reason = "normally attached to the word it determines, and too ambiguous standing alone (#269)" issue = 269 [[word]] text = "ben" [word.excluded.patronymic] reason = "Ben is a common given name standing exactly where a forward-joining particle claim acts: \"Ahmad Ben Husain\" reads middle 'Ben', and admitting it would take that token into the family. Removed in v0.2.5 and re-affirmed 2026-09-01 (decisions/v1-xfail-triage); a caller who needs the patronymic reading adds it to their own Lexicon. The pressure to re-add is real and recurring: 'Ahmad ben Husain' wants family 'ben Husain', the v1 suite carried that as an xfail for years, and a sweep of Arabic and Hebrew patronymic particles is exactly how it comes back" [[word]] text = "bin" given = { us_ssa = 11, wikidata = 541, as_of = 2026-10-25, note = "male births only; Wikidata is a lower bound; nam_dict band 8 in China, 6 in Korea, 1 in Japan" } [word.patronymic] expands = "son of" gender = "m" note = "Kept ambiguous deliberately: #269 judged the Latin transliteration separately from the native-script بن, which IS never-given" [[word]] text = "bint" patronymic = { may_be_given = false, expands = "daughter of; native-script بنت", gender = "f" } [[word]] text = "ibn" patronymic = { may_be_given = false, expands = "son of; native-script ابن", gender = "m" } [[word]] text = "ابن" [word.patronymic] may_be_given = false issue = 269 expands = "son of: ibn, alternate spelling" gender = "m" note = "never a bare given name, like بن; mirrors Latin 'ibn'" [[word]] text = "بن" [word.patronymic] may_be_given = false issue = 269 expands = "son of: bin, ibn" gender = "m" note = "Never a bare given name. Latin 'bin' may be given; that judgment is unchanged by adding the Arabic-script form. Unlike their Latin transliterations, the native-script particles have no collision with an unrelated Latin given name, so each is judged on its own semantics rather than mirrored blindly." [[word]] text = "بنت" [word.patronymic] may_be_given = false issue = 269 expands = "daughter of: bint" gender = "f" note = "never a bare given name, like بن; mirrors Latin 'bint'" [[word]] text = "abu" given = { us_ssa = 468, wikidata = 51, as_of = 2026-10-25, note = "male births only; Wikidata is a lower bound; nam_dict band 3 in Arabia/Persia" } [word.teknonymic] issue = 269 expands = "father of" gender = "m" note = "\"Abu Bakr\" -- Abu reads as the given name; also a bound given-name word, so it joins forward (P5)" [word.bound_given] [[word]] text = "أبو" given = { wikidata = 48, as_of = 2026-10-25, note = "Wikidata is a lower bound" } [word.teknonymic] issue = 269 expands = "father of: abu, hamza spelling" gender = "m" note = "Left ambiguous like its Latin transliteration 'abu': \"Abu Bakr\" reads Abu as a given name" [word.bound_given] issue = 269 [[word]] text = "ابو" given = { wikidata = 33, as_of = 2026-10-25, note = "Wikidata is a lower bound" } [word.teknonymic] issue = 269 expands = "father of: abu, hamza-less spelling" same_as = "أبو" gender = "m" note = "left ambiguous as أبو is, both spellings vetted together (#269)" [word.bound_given] issue = 269 same_as = "أبو" [[word]] text = "آل" [word.clan] may_be_given = false issue = 269 expands = "family of: \"Al Saud\"" note = "Distinct from the article ال, excluded in this file; a clan prefix, never a bare given name." [[word]] text = "و" [word.conjunction] issue = 269 note = "formal script attaches و to the following word (وفاطمة), so a standalone و is informal spacing, common in real data; single-character, so the single-letter carve-out protects short names" [[word]] text = "abd" [word.bound_given] expands = "servant of" note = "the bare transliteration, which abdul/abdel/abdal do not match: 'abd Allah Smith' and 'Abd al-Rahman Smith' read given 'abd Allah' and 'Abd al-Rahman'; the three-token 'abd al rahman smith' is not joined, al being a particle that chains forward. Also the credential ABD in en.toml; position decides at the two ends but not in a family comma's given slot (decisions/P5)" [[word]] text = "abdul" bound_given = { expands = "servant of the" } [[word]] text = "abdal" bound_given = { expands = "servant of the", same_as = "abdul" } [[word]] text = "abdel" bound_given = { expands = "servant of the", same_as = "abdul" } [[word]] text = "abou" bound_given = { expands = "father of", same_as = "abu" } [[word]] text = "umm" bound_given = { expands = "mother of" } [[word]] text = "أم" bound_given = { note = "\"umm\" (mother of), hamza spelling", issue = 269 } [[word]] text = "ام" bound_given = { note = "\"umm\", hamza-less spelling", issue = 269, same_as = "أم" } [[word]] text = "عبد" [word.bound_given] note = "\"abd\" (servant of): the script writes Abdul Rahman as two words, عبد + الرحمن, the article attaching to the word after it, so عبد alone covers the abdul/abdel/abdal spellings -- عبد الرحمن -> given \"عبد الرحمن\"" issue = 269 derek73-python-nameparser-456b024/nameparser/vocab/as.toml000066400000000000000000000005341527303271700234400ustar00rootroot00000000000000schema = 1 [[word]] text = "ক" [word.excluded.generation] reason = "the abbreviation ক. is the single-letter initial shape" issue = 679 [[word]] text = "কনিষ্ঠ" generation = { issue = 679 } [[word]] text = "জুনিয়ৰ" generation = { issue = 679 } [[word]] text = "জ্যেষ্ঠ" generation = { issue = 679 } derek73-python-nameparser-456b024/nameparser/vocab/be.toml000066400000000000000000000005661527303271700234300ustar00rootroot00000000000000schema = 1 [[word]] text = "мал" [word.excluded.generation] reason = "мал. is also \"figure\"" issue = 679 [[word]] text = "малодшая" generation = { issue = 679 } [[word]] text = "малодшы" generation = { issue = 679 } [[word]] text = "старэйшая" generation = { issue = 679 } [[word]] text = "старэйшы" generation = { issue = 679 } derek73-python-nameparser-456b024/nameparser/vocab/bg.toml000066400000000000000000000023111527303271700234200ustar00rootroot00000000000000schema = 1 [[word]] text = "г-жа" salutation = { gender = "f", issue = 269, expands = "госпожа, Mrs.", after_given = true } [[word]] text = "г-н" salutation = { gender = "m", issue = 269, expands = "господин, Mr.", after_given = true } [[word]] text = "акад" academic = { issue = 269, expands = "academician" } [[word]] text = "д-р" academic = { issue = 269, expands = "doctor" } [[word]] text = "проф" academic = { issue = 269, expands = "professor" } [[word]] text = "мл" [word.generation] issue = 679 note = "мл.; #679 found nothing it collides with in Cyrillic" [[word]] text = "млада" [word.excluded.generation] reason = "Mlada is a borne given name, and under a family-first order the given name is the trailing word" issue = 661 [[word]] text = "млади" generation = { issue = 661, note = "the Younger" } [[word]] text = "младша" generation = { issue = 661 } [[word]] text = "младши" generation = { issue = 661 } [[word]] text = "стари" generation = { issue = 661, note = "the Elder" } [[word]] text = "старша" generation = { issue = 661 } [[word]] text = "старши" generation = { issue = 661 } [[word]] text = "и" conjunction = {} derek73-python-nameparser-456b024/nameparser/vocab/bin.toml000066400000000000000000000002621527303271700236030ustar00rootroot00000000000000schema = 1 [[word]] text = "edohen" nobility = { expands = "a Benin palace chieftaincy title" } [[word]] text = "ekegbian" nobility = { expands = "a Benin chieftaincy title" } derek73-python-nameparser-456b024/nameparser/vocab/bn.toml000066400000000000000000000160131527303271700234330ustar00rootroot00000000000000schema = 1 [[word]] text = "mst" excluded = { salutation = { reason = "the transliteration of মোসাঃ collides with real names where the native script cannot", issue = 343 } } [[word]] text = "কুমারী" excluded = { salutation = { reason = "Kumari is a given name and a family name in this script, so the leading claim fails C-i (decisions/vocabulary-collisions)", issue = 343, decided = 2026-09-06 } } [[word]] text = "জনাব" salutation = { gender = "m", issue = 343, expands = "Janab, Mr.", after_given = true } [[word]] text = "বেগম" excluded = { salutation = { reason = "Begum is a trailing name element far more often than a leading title", issue = 343, decided = 2026-09-06 } } [[word]] text = "মিঃ" salutation = { gender = "m", issue = 343, expands = "Mr., borrowed", after_given = true } [[word]] text = "মি" salutation = { gender = "m", issue = 343, same_as = "মিঃ", expands = "the period spelling মি.", after_given = true } [[word]] text = "মিসেস" salutation = { gender = "f", issue = 343, expands = "Mrs., borrowed", after_given = true } [[word]] text = "মোঃ" salutation = { gender = "m", issue = 343, expands = "Md., Mohammad", note = "a name PREFIX, not a title semantically; TITLES is the functional home because `given` must stay the name the person is addressed by (decisions/indic-honorifics); borne as a given name: Wikidata 37 people (2026-10-24); kept here, not as a `given` reading, which would make the title one only inside a title phrase (rules/H9)", excluded = { after_given = "abbreviates Mohammad, a given name, and is read as one where it leads (#853)" } } [[word]] text = "মো" salutation = { gender = "m", issue = 343, same_as = "মোঃ", expands = "the period spelling মো.", excluded = { after_given = "abbreviates Mohammad, a given name, as মোঃ does (#853)" } } [[word]] text = "মোসাঃ" salutation = { gender = "f", issue = 343, expands = "Mst., Mosammat, the women's counterpart of মোঃ", after_given = true } [[word]] text = "মোছাঃ" salutation = { gender = "f", issue = 343, same_as = "মোসাঃ", expands = "the ছ spelling", after_given = true } [[word]] text = "মোছা" salutation = { gender = "f", issue = 343, same_as = "মোছাঃ", expands = "the period spelling মোছা.", after_given = true } [[word]] text = "মোসা" salutation = { gender = "f", issue = 343, same_as = "মোসাঃ", expands = "the period spelling মোসা.", after_given = true } [[word]] text = "শ্রী" salutation = { gender = "m", issue = 343, expands = "Shri, Mr.", excluded = { after_given = "also an element of given names (Sri, Shri), so where it ends the given part it may be one" } } [[word]] text = "শ্রীমতী" salutation = { gender = "f", issue = 343, expands = "Shrimati, Mrs.", after_given = true } [[word]] text = "আলহাজ্ব" style = { issue = 343, expands = "Alhaj" } [[word]] text = "আলহাজ" style = { issue = 343, same_as = "আলহাজ্ব", expands = "Alhaj, the spelling without the ব-phala" } [[word]] text = "ঠাকুর" excluded = { style = { reason = "a genuine Bengali honorific (lord, master) that is also Tagore, the surname", issue = 343 } } [[word]] text = "শেখ" excluded = { style = { reason = "a clan name and a family name ('শেখ হাসিনা'); the Latin sheikh cluster ships for the Arabic addressing form, while the Indic spellings name the family", issue = 343, decided = 2026-09-06 } } [[word]] text = "অধ্যাপক" academic = { issue = 343, expands = "professor" } [[word]] text = "ড" academic = { issue = 343, expands = "Dr., the PhD's", note = "the doctor abbreviations split by profession, ড./ডঃ the PhD's and ডাঃ/ডা. the Bangladeshi physician's; a period spelling matches its bare stem through the edge-period normalization, so each stem is listed once, and the visarga ঃ (U+0983, a spacing combining mark) reaches the lexicon intact; no Latin twins, for the reason the sri/shri exclusions in hi.toml give" } [[word]] text = "ডঃ" academic = { issue = 343, same_as = "ড", expands = "Dr., the PhD's, visarga spelling" } [[word]] text = "ডক্টর" academic = { issue = 343, expands = "doctor, full form" } [[word]] text = "ডা" academic = { issue = 343, expands = "Dr., the physician's" } [[word]] text = "ডাঃ" academic = { issue = 343, same_as = "ডা", expands = "Dr., the physician's, visarga spelling" } [[word]] text = "ডাক্তার" academic = { issue = 343, expands = "doctor, full form (physician)" } [[word]] text = "প্রফেসর" academic = { issue = 343, expands = "professor, borrowed form" } [[word]] text = "baba" religious = { addresses = "given", issue = 346 } [[word]] text = "guru" religious = { addresses = "given", issue = 346 } [[word]] text = "swami" religious = { addresses = "given", issue = 346 } [[word]] text = "গুরু" religious = { addresses = "given", issue = 343, expands = "Guru" } [[word]] text = "বাবা" religious = { addresses = "given", issue = 343, expands = "Baba" } [[word]] text = "মাওলানা" religious = { issue = 343, expands = "Maulana" } [[word]] text = "মুফতি" religious = { issue = 343, expands = "Mufti", note = "the Latin mufti ships too, in ar.toml" } [[word]] text = "শ্রীল" religious = { addresses = "given", issue = 343, expands = "Srila, the Vaishnava honorific (শ্রীল প্রভুপাদ)", note = "no Latin twin; unprefixed শ্রী is the civil salutation" } [[word]] text = "স্বামী" religious = { addresses = "given", issue = 343, expands = "Swami" } [[word]] text = "বিচারপতি" office = { issue = 343, expands = "Justice" } [[word]] text = "জুনিয়র" generation = { issue = 679 } [[word]] text = "সিনিয়র" generation = { issue = 679 } [[word]] text = "বাবু" [word.honorific] issue = [346, 344, 343] note = "Babu, TRAILING (অমল বাবু), while Devanagari बाबू is a LEADING title (#344); the split is real and must not be harmonized" [word.honorific.excluded] attached = "जी ends बनर्जी, मुखर्जी, चटर्जी, so a glued peel would strand a bare virama (बनर् + जी); the 殿 criterion in a non-CJK script (decisions/indic-honorifics)" [[word]] text = "সাহেব" [word.honorific] issue = [346, 344, 343] note = "Saheb (রহমান সাহেব)" [word.honorific.excluded] attached = "जी ends बनर्जी, मुखर्जी, चटर्जी, so a glued peel would strand a bare virama (बनर् + जी); the 殿 criterion in a non-CJK script (decisions/indic-honorifics)" [[word]] text = "মহারাজ" [word.courtesy] issue = [346, 344, 343] note = "Maharaj" [word.courtesy.excluded] attached = "जी ends बनर्जी, मुखर्जी, चटर्जी, so a glued peel would strand a bare virama (बनर् + जी); the 殿 criterion in a non-CJK script (decisions/indic-honorifics)" derek73-python-nameparser-456b024/nameparser/vocab/bo.toml000066400000000000000000000010541527303271700234330ustar00rootroot00000000000000schema = 1 [[word]] text = "lama" religious = { addresses = "given", issue = 346, note = "#346's renunciate class (baba, guru, lama, swami): it addresses by the GIVEN name, and the tradition renounces the surname, which is what makes the empty family right for 'Swami Vivekananda' (rules/H Background)" } [[word]] text = "rinpoche" [word.courtesy] issue = [346, 344, 343] note = "postpositional (Sogyal Rinpoche, Lama Zopa Rinpoche)" [word.courtesy.excluded] attached = "spaced-only for the reason the Indic trailing set is (decisions/indic-honorifics)" derek73-python-nameparser-456b024/nameparser/vocab/bs.toml000066400000000000000000000007741527303271700234470ustar00rootroot00000000000000schema = 1 [[word]] text = "gospodin" salutation = { issue = 606, gender = "m", after_given = true } [[word]] text = "gospođa" salutation = { issue = 606, gender = "f", after_given = true } [[word]] text = "gospođica" salutation = { issue = 606, gender = "f", after_given = true } [[word]] text = "mlađa" generation = { issue = 661 } [[word]] text = "mlađi" generation = { issue = 661 } [[word]] text = "starija" generation = { issue = 661 } [[word]] text = "stariji" generation = { issue = 661 } derek73-python-nameparser-456b024/nameparser/vocab/ca.toml000066400000000000000000000004761527303271700234250ustar00rootroot00000000000000schema = 1 [[word]] text = "i" [word.conjunction] may_be_initial = true issue = [397, 721] note = "the Catalan and Polish surname link (Carod i Rovira, Kowalski i Nowak); a bare I initial is as common as a bare E, so marked like e. Also the Roman numeral I, so it joins only with a name word on each side (rules/P3)" derek73-python-nameparser-456b024/nameparser/vocab/cs.toml000066400000000000000000000041611527303271700234420ustar00rootroot00000000000000schema = 1 [[word]] text = "pan" excluded = { salutation = { reason = "Czech and Polish Mr., and the Chinese surname Pan, which leads; the accented paní ships", issue = 606, decided = 2026-10-04 } } [[word]] text = "paní" salutation = { issue = 606, gender = "f", note = "keeps its accent, so the name Pani (~76,000 surnames and ~65,000 given names, India and Indonesia) is untouched, and the unaccented pani is not shipped; the lexicon folds no accent (decisions/salutation-titles)", excluded = { after_given = "spelled PANI, a surname (Census 2010), and middle names are drawn from surnames" } } family = { us_census_2010 = 403, as_of = 2026-10-24, note = "counted as PANI, the Census writing no diacritics" } [[word]] text = "rodina" excluded = { salutation = { reason = "Czech 'family', and a Russian surname, often written surname-first; familie ships", issue = 606, decided = 2026-10-04 } } [[word]] text = "slečna" salutation = { issue = 606, gender = "f", after_given = true } [[word]] text = "ml" [word.excluded.generation] reason = "pack-shaped: real and standard, but collides with ML initials in other languages written in Latin script" issue = [661, 679, 684] [[word]] text = "mladší" generation = { issue = 661 } [[word]] text = "st" generation = { default = false, expands = "starší, the Elder: Jiří Dienstbier st.", issue = [661, 679, 684, 752], note = "real and standard, but St. is Saint in other languages written in Latin script, and nothing in its writing tells the two apart, so no default reads it; a Czech pack turns it on" } [[word]] text = "starší" generation = { issue = 661 } [[word]] text = "roz" [word.excluded.birth_marker] reason = "Roz, the English diminutive of Rosalind, stands exactly where a marker acts: with it listed, 'Rosalind Roz Smith' reads maiden 'Smith' and takes the given name Rosalind for the family (measured 2026-10-20), a marker taking every word after it; the full rozená stays" decided = 2026-08-26 [[word]] text = "rozená" [word.birth_marker] issue = 274 gender = "f" note = "the masculine rozený awaits its own vetting; the abbreviation roz., shared by both, is refused beside it" derek73-python-nameparser-456b024/nameparser/vocab/da.toml000066400000000000000000000015661527303271700234270ustar00rootroot00000000000000schema = 1 [[word]] text = "familie" salutation = { issue = 606, expands = "the family addressed, 'Familie Hansen'", after_given = true } [[word]] text = "fru" salutation = { gender = "f" } [[word]] text = "frøken" salutation = { gender = "f", after_given = true } [[word]] text = "frk" salutation = { gender = "f", same_as = "frøken", after_given = true } [[word]] text = "herr" salutation = { gender = "m" } [[word]] text = "hr" salutation = { gender = "m", same_as = "herr" } [[word]] text = "greve" nobility = { gender = "m", expands = "count", issue = 606 } [[word]] text = "af" preposition = { may_be_given = false, expands = "of, nobiliary" } [[word]] text = "f" excluded = { birth_marker = "the abbreviation of født collides with the initial F., so only the full participles født, fødd and född are safe" } [[word]] text = "født" birth_marker = { issue = 274 } derek73-python-nameparser-456b024/nameparser/vocab/de.toml000066400000000000000000000151271527303271700234310ustar00rootroot00000000000000schema = 1 [[word]] text = "familie" salutation = { issue = 606, expands = "the family addressed, 'Familie Hansen'", after_given = true } [[word]] text = "frau" salutation = { gender = "f", after_given = true } family = { wikidata = 42, as_of = 2026-10-24, note = "kept for after_given (decisions/H8)" } [[word]] text = "fräulein" salutation = { gender = "f", after_given = true } [[word]] text = "herr" salutation = { gender = "m", excluded = { after_given = "a surname (Census 2010 HERR), and middle names are drawn from surnames" }, note = "borne as a given name: Wikidata 3 people (2026-10-24); kept here, not as a `given` reading, which would make the title one only inside a title phrase (rules/H9)" } family = { us_census_2010 = 11980, wikidata = 135, as_of = 2026-10-24 } [[word]] text = "herrn" salutation = { gender = "m", same_as = "herr", expands = "Herr, inflected (an Herrn Müller)", after_given = true } [[word]] text = "hr" salutation = { gender = "m", same_as = "herr", excluded = { after_given = "ending a given part in US data, more likely two initials run together than the abbreviation of Herr: one of three such fec-individuals names has a woman's given name (2026-10-24)" } } [[word]] text = "herren" family = { us_census_2010 = 4466, wikidata = 37, as_of = 2026-10-27, note = "Wikidata bearers by citizenship: Switzerland 9, the United States 5, Germany 4" } excluded = { salutation = { reason = "the plural of Herr: a plural addresses several people (Herren Müller und Schmidt) and never stands before one person's name, so as a title it only took the first word of a surname-first name (Herren Hans read title 'Herren', family 'Hans'); borne as a surname by more than any word shipped under the graf class, decisions/salutation-titles (the counts on its family reading)", issue = 766 } } [[word]] text = "dipl.-ing" academic = { expands = "Diplom-Ingenieur" } [[word]] text = "fh-prof" academic = { expands = "Fachhochschul-Professor" } [[word]] text = "priv.-doz" academic = { expands = "Privatdozent" } [[word]] text = "pd" academic = { same_as = "priv.-doz" } [[word]] text = "ra" academic = { expands = "Rechtsanwalt, the address of a lawyer, 'RA Müller'" } [[word]] text = "univ.prof" academic = { expands = "Universitätsprofessor" } [[word]] text = "wp" academic = { expands = "Wirtschaftsprüfer, the address of an auditor" } [[word]] text = "erzbischof" religious = { expands = "archbishop" } [[word]] text = "fürsterzbischof" religious = { expands = "prince-archbishop" } [[word]] text = "baron" nobility = { gender = "m" } [[word]] text = "edler" nobility = { gender = "m", expands = "the lowest German noble rank, 'Edler von'" } [[word]] text = "freifrau" nobility = { gender = "f", expands = "baroness" } [[word]] text = "freiherr" given = { note = "carries the particle's may-be-given reading, the C-i default (the particle's note argues the chain, not a given name); no source counts it as a given name (2026-10-25)" } nobility = { gender = "m", expands = "baron" } [word.rank] issue = 18 expands = "baron" gender = "m" note = "Since 1919 a former noble title is part of the legal surname, written before the particle (\"Karl-Theodor Freiherr von und zu Guttenberg\"), so it chains like one. Also in TITLES, load-bearing" [[word]] text = "fürst" nobility = { gender = "m", expands = "prince" } [[word]] text = "graf" nobility = { gender = "m", expands = "count" } [[word]] text = "großfürst" nobility = { gender = "m", expands = "grand prince, grand duke" } [[word]] text = "gräfin" nobility = { gender = "f", expands = "countess" } [[word]] text = "herzog" nobility = { gender = "m", expands = "duke" } [[word]] text = "prinz" nobility = { gender = "m", expands = "prince" } [[word]] text = "reichsgraf" nobility = { gender = "m", expands = "imperial count" } [[word]] text = "ritter" nobility = { gender = "m", expands = "knight" } [[word]] text = "i" generation = { default = false, expands = "the regnal ordinal written with its period: Wilhelm I.", issue = 752, note = "the default reads I as a numeral only bare (mul.toml); written with its period it is exactly how an initial is written (rules/T5), so no default reads the ordinal; a German pack turns it on" } [[word]] text = "mda" credential = { issue = 18, expands = "Mitglied des Abgeordnetenhauses" } [[word]] text = "mdb" credential = { issue = 18, expands = "Mitglied des Bundestages" } [[word]] text = "mdbb" credential = { issue = 18, expands = "Mitglied der Bremischen Bürgerschaft" } [[word]] text = "mdep" credential = { issue = 18, expands = "Mitglied des Europäischen Parlaments" } [[word]] text = "mdhb" credential = { issue = 18, expands = "Mitglied der Hamburgischen Bürgerschaft" } [[word]] text = "mdl" credential = { issue = 18, expands = "Mitglied des Landtages" } [[word]] text = "stb" credential = { issue = 18, expands = "Steuerberater, tax adviser" } [[word]] text = "auf" preposition = { may_be_given = false, issue = 18, expands = "upon: \"auf der Heide\"" } [[word]] text = "tho" given = { us_ssa = 52, as_of = 2026-10-25, note = "US SSA births summed over both sexes; nam_dict band 8 in Vietnam" } preposition = { issue = 18, expands = "to, Low German zu" } [[word]] text = "vom" preposition = { may_be_given = false, expands = "from the, von dem contracted" } [[word]] text = "von" given = { us_ssa = 6011, wikidata = 46, as_of = 2026-10-25, note = "US SSA births summed over both sexes; Wikidata is a lower bound" } [word.preposition] expands = "of, from" note = "Von Miller: a real given name, so this stays ambiguous even though \"von Braun\" is misparsed today. The case that shows a word can look like a pure particle, BE misparsed as one, and still have a bearer" [[word]] text = "zu" preposition = { may_be_given = false, expands = "at, to; nobiliary: \"zu Guttenberg\"" } [[word]] text = "dem" article = { may_be_given = false, issue = 18, expands = "the, dative" } [[word]] text = "den" given = { us_ssa = 17, wikidata = 5, as_of = 2026-10-25, note = "male births only; Wikidata is a lower bound" } article = { expands = "the: \"von den Steinen\"" } [[word]] text = "der" article = { may_be_given = false, expands = "the: \"von der Leyen\"" } [[word]] text = "freiherrin" given = { note = "carries the particle's may-be-given reading, as freiherr does; no source counts it as a given name (2026-10-25)" } [word.rank] issue = 18 expands = "baroness" gender = "f" note = "the feminine of freiherr, written and chained the same way (see its note); unlike freiherr it is not in TITLES" [[word]] text = "und" conjunction = {} [[word]] text = "geb" birth_marker = { issue = 274 } [[word]] text = "geborene" birth_marker = { issue = 274, gender = "f" } derek73-python-nameparser-456b024/nameparser/vocab/el.toml000066400000000000000000000015741527303271700234420ustar00rootroot00000000000000schema = 1 [[word]] text = "κ" excluded = { salutation = { reason = "κ. (Mr.) has the initial-and-surname shape: 'Κ. Παπαδόπουλος' would read title 'Κ.' with an empty given, the edge-period fold making the entry match the initial; Latin titles have no bare single letter for the same reason", issue = 269 } } [[word]] text = "κα" salutation = { gender = "f", issue = 269, expands = "κυρία, Mrs.", after_given = true } [[word]] text = "κος" salutation = { gender = "m", issue = 269, expands = "κύριος, Mr.", after_given = true } [[word]] text = "δρ" academic = { issue = 269, expands = "doctor" } [[word]] text = "καθ" academic = { issue = 269, expands = "professor" } [[word]] text = "σίνιορ" generation = { issue = 679 } [[word]] text = "τζούνιορ" generation = { issue = 679 } [[word]] text = "και" conjunction = { issue = 269 } derek73-python-nameparser-456b024/nameparser/vocab/en.toml000066400000000000000000003205021527303271700234370ustar00rootroot00000000000000schema = 1 [[word]] text = "wm" excluded = { title = { reason = "William, abbreviated, and no title of any kind: #68 asked that 'Wm. Scott Magargee III' not read given name 'Wm.', a request judged mistaken, since Wm is the given name; a bare 'WM' opened the given part 69 times in the FEC donor list (re-measured 2026-10-24 on 08d130ed), every one then read as a title. The dotted 'Wm.' opening the given part reads given too, by the given reading below, which outranks the leading period-abbreviation shape rule (#789, phase 2k of #752)", issue = [68, 789], decided = 2026-10-22 } } given = { us_ssa = 2736, as_of = 2026-10-26, same_as = "william", note = "William written short; US SSA births summed over both sexes (F 86, M 2,650); Wikidata labels no one Wm" } [[word]] text = "goodman" salutation = { gender = "m", expands = "the archaic Mr., 'Goodman Brown'", excluded = { after_given = "a common surname (Benny Goodman), and middle names are drawn from surnames" }, note = "borne as a given name: US SSA 22 births, Wikidata 11 people (2026-10-24); kept here, not as a `given` reading, which would make the title one only inside a title phrase (rules/H9)" } family = { us_census_2010 = 73136, wikidata = 743, as_of = 2026-10-24 } [[word]] text = "goodwife" salutation = { gender = "f", expands = "the archaic Mrs., 'Goodwife Proctor'", after_given = true } [[word]] text = "madam" salutation = { gender = "f", after_given = true } [[word]] text = "madame" salutation = { gender = "f", after_given = true } [[word]] text = "master" salutation = { addresses = "given", gender = "m", expands = "the style for a boy, 'Master Harry'", excluded = { after_given = "a surname and a given name, borne as a middle name either way" }, note = "borne as a given name: US SSA 476 births, Wikidata 2 people (2026-10-24); kept here, not as a `given` reading, which would make the title one only inside a title phrase (rules/H9)" } family = { us_census_2010 = 2735, wikidata = 61, as_of = 2026-10-24 } [[word]] text = "miss" salutation = { gender = "f", after_given = true } family = { us_census_2010 = 164, wikidata = 4, as_of = 2026-10-24, note = "kept for after_given (decisions/H8): a middle name of this spelling is the accepted cost" } [[word]] text = "misses" salutation = { gender = "f", expands = "the plural of miss, 'the Misses Smith'", after_given = true } [[word]] text = "mr" salutation = { gender = "m", after_given = true, note = "borne as a given name: US SSA 5 births (2026-10-24); kept for after_given (decisions/H8); kept here, not as a `given` reading, which would make the title one only inside a title phrase (rules/H9)" } [[word]] text = "mister" salutation = { gender = "m", same_as = "mr", excluded = { after_given = "a given name and a surname, borne as a middle name either way" }, note = "borne as a given name: US SSA 746 births (2026-10-24); kept here, not as a `given` reading, which would make the title one only inside a title phrase (rules/H9)" } family = { us_census_2010 = 1211, wikidata = 2, as_of = 2026-10-24 } [[word]] text = "mrs" salutation = { gender = "f", after_given = true } [[word]] text = "ms" salutation = { gender = "f" } [word.credential] note = "NOT marked as possibly a name (no name reading), though #296's audit table put it there for the leading-title collision: the gate is position-blind and the collision is not (gated, 'John Smith, MS' read title 'MS'); a title/credential dual that position decides (decisions/C8)" [[word]] text = "mx" salutation = { gender = "n", after_given = true } [[word]] text = "doyen" style = {} [[word]] text = "eminence" style = {} [[word]] text = "excellency" style = {} [[word]] text = "grace" style = { issue = 752, decided = 2026-10-26, expands = "'His Grace', a duke's or an archbishop's style", note = "a title only inside a title phrase, its given reading the clash (rules/H9): 'His Grace John Smith' reads the title, 'Grace Smith' the given name, unreported, since no listed phrase holds it" } given = { us_ssa = 536340, wikidata = 3908, as_of = 2026-10-26, note = "US SSA: female and male births added" } family = { us_census_2010 = 28098, wikidata = 311, as_of = 2026-10-26 } [[word]] text = "highness" style = {} [[word]] text = "holiness" style = {} [[word]] text = "honorable" style = {} [[word]] text = "hon" style = { same_as = "honorable", note = "kept though Hon is a given name: the judges' Hon. was judged the commoner reading" } [[word]] text = "honourable" style = { same_as = "honorable" } [[word]] text = "majesty" style = {} [[word]] text = "advocate" academic = { expands = "the address of an advocate, 'Adv. Smith' in South Africa and India" } [[word]] text = "attorney" academic = { expands = "the address of a lawyer, 'Atty. Cruz'" } [[word]] text = "engineer" academic = { expands = "the address of an engineer, 'Engr. Cruz' in the Philippines and Nigeria" } [[word]] text = "nurse" academic = { expands = "the address of a nurse, 'Nurse Smith'" } [[word]] text = "pro" academic = { issue = 9, attaches = ["chancellor"], expands = "Pro Se Law Clerk", note = "attaches: Pro Chancellor (rules/H9, #913); academic rather than modifier since 2026-10-25 (#752): a part after a comma written 'Pro' is the person's role, which decision 10's modifier rule would read out of the title (decisions/vocabulary-model)" } [[word]] text = "abbess" religious = { gender = "f" } [[word]] text = "abbot" religious = { gender = "m" } [[word]] text = "acolyte" religious = {} [[word]] text = "adept" religious = {} [[word]] text = "archbishop" religious = {} [[word]] text = "archdeacon" religious = {} [[word]] text = "archdruid" religious = {} [[word]] text = "arhat" religious = {} [[word]] text = "bishop" religious = {} [[word]] text = "bp" religious = { same_as = "bishop" } [[word]] text = "blessed" religious = { expands = "beatified, 'Blessed Teresa'" } [[word]] text = "bodhisattva" religious = {} [[word]] text = "brother" religious = { addresses = "given", gender = "m", expands = "a member of a religious order, 'Brother Roger'" } [[word]] text = "buddha" religious = { issue = 869, decided = 2026-10-26, note = "a title only inside a title phrase, its given reading the clash (rules/H9): 'Buddha Bahadur Tamang' reads the given name, unreported, since no listed phrase holds it" } given = { us_ssa = 6, wikidata = 4, as_of = 2026-10-26, note = "US SSA: male births; Wikidata is a lower bound; the first half of Nepali compound given names (Buddha Bahadur, Buddha Maya): 83 of nepal-candidates' 126,981 pairs write it, opening the name in 79" } [[word]] text = "canon" religious = {} [[word]] text = "cardinal" religious = {} [[word]] text = "catholicos" religious = {} [[word]] text = "chaplain" religious = {} [[word]] text = "deacon" religious = { issue = 913, decided = 2026-10-27, note = "a title only inside a title phrase, its given reading the clash (rules/H9): 'Deacon Jones' and 'Mr Deacon Smith' read the given name, unreported, since no listed phrase holds it" } given = { us_ssa = 11570, wikidata = 37, as_of = 2026-10-27, note = "US SSA: male births; Wikidata is a lower bound (United States 16); UK PSC filers put it first in the title box once and never in the forename box (2026-10-27)" } family = { us_census_2010 = 3322, wikidata = 287, as_of = 2026-10-27, note = "US Census rank 9,756; Wikidata United Kingdom 31" } [[word]] text = "dcn" religious = { same_as = "deacon" } [[word]] text = "druid" religious = {} [[word]] text = "evangelist" religious = {} [[word]] text = "father" religious = { addresses = "given", gender = "m", expands = "a priest, 'Father Brown'" } [[word]] text = "fr" religious = { expands = "Father, Friar" } [[word]] text = "friar" religious = {} [[word]] text = "metropolitan" religious = {} [[word]] text = "met" religious = { same_as = "metropolitan" } [[word]] text = "mgr" religious = { expands = "Monsignor, Monseigneur" } [[word]] text = "monk" religious = {} [[word]] text = "monsignor" religious = {} [[word]] text = "mother" religious = { addresses = "given", gender = "f", expands = "a mother superior, 'Mother Teresa'" } [[word]] text = "pastor" religious = {} [[word]] text = "patriarch" religious = {} [[word]] text = "pope" religious = { addresses = "given" } [[word]] text = "prelate" religious = {} [[word]] text = "presbyter" religious = {} [[word]] text = "priest" religious = {} [[word]] text = "priestess" religious = { gender = "f" } [[word]] text = "primate" religious = {} [[word]] text = "prior" religious = {} [[word]] text = "reverend" religious = {} [[word]] text = "rev" religious = { same_as = "reverend" } [[word]] text = "saint" religious = {} [[word]] text = "saoshyant" religious = {} [[word]] text = "siddha" religious = { issue = 869, decided = 2026-10-26, note = "a title only inside a title phrase, its given reading the clash (rules/H9): 'Siddha Raj Joshi' reads the given name, unreported, since no listed phrase holds it" } given = { note = "no US SSA year reaches 5 births and Wikidata counts no one; declared as the first half of Nepali compound given names (Siddha Raj): 12 of nepal-candidates' 126,981 pairs write it, each opening the name (2026-10-26)" } [[word]] text = "sister" religious = { addresses = "given", gender = "f", expands = "a nun or religious sister, 'Sister Mary'" } [[word]] text = "st" given = { us_ssa = 24, as_of = 2026-10-25, note = "male births only" } religious = { expands = "Saint, abbreviated" } [word.saint] expands = "Saint, abbreviated" note = "Also in TITLES and consumed there before particle logic runs, so moving it between the may-be-given and never-given halves changes nothing; kept for the load-bearing TITLES intersection" [[word]] text = "tirthankar" religious = {} [[word]] text = "venerable" religious = { note = "stays out of GIVEN_NAME_TITLES: its traditions split on surname retention (Buddhist monastics drop the family name, Anglican archdeacons keep it), and addresses cannot say 'sometimes' (#346)" } [[word]] text = "vicar" religious = {} [[word]] text = "archduchess" nobility = { gender = "f" } [[word]] text = "archduke" nobility = { gender = "m" } [[word]] text = "baron" nobility = { gender = "m" } [[word]] text = "baroness" nobility = { gender = "f" } [[word]] text = "chief" nobility = { expands = "a chief of a people or clan, 'Chief Obafemi Awolowo'", attaches = ["office", "military"], issue = 752, note = "attaches: Chief Justice, Chief Clerk (rules/H9)" } [[word]] text = "chieftain" nobility = {} [[word]] text = "count" nobility = { gender = "m" } [[word]] text = "countess" nobility = { gender = "f" } [[word]] text = "dame" nobility = { addresses = "given", gender = "f", expands = "the female knighthood, 'Dame Judi Dench'" } [[word]] text = "duke" given = { us_ssa = 11595, wikidata = 151, as_of = 2026-10-19, note = "male births only" } family = { us_census_2010 = 35053, wikidata = 315, as_of = 2026-10-19 } excluded = { nobility = "a common given name (Duke Kahanamoku), and a surname besides" } [[word]] text = "dutchess" nobility = { gender = "f", note = "a misspelling of duchess, shipped as written; duchess itself is not in the vocabulary" } [[word]] text = "emperor" nobility = { gender = "m" } [[word]] text = "empress" nobility = { gender = "f" } [[word]] text = "heir" nobility = {} [[word]] text = "heiress" nobility = { gender = "f" } [[word]] text = "king" nobility = { attaches = ["consort"], addresses = "given", gender = "m", issue = 752, decided = 2026-10-26, note = "a title only inside a title phrase, its given reading the clash (rules/H9): 'His Majesty King Charles' and 'King Consort John' read the title, 'King Curtis' and 'Mr King Smith' the given name, reported title-or-name since 'King Consort' holds it (#913)" } given = { us_ssa = 37890, wikidata = 178, as_of = 2026-10-26, note = "US SSA: male and female births added; Wikidata is a lower bound (United States 92); nam_dict band 1 in the U.S.A. and China" } family = { us_census_2010 = 465422, wikidata = 4554, as_of = 2026-10-26, note = "US Census rank 34" } [[word]] text = "knight" nobility = { gender = "m", expands = "the rank the post-nominal knt names", issue = 606, note = "borne (Gladys Knight, who keeps family Knight in the forms she writes); one of the three words #606 proposed the graf class for" } [[word]] text = "lady" nobility = { gender = "f", attaches = ["justice"], note = "attaches: Lady Justice (rules/H9, #913); stays out of GIVEN_NAME_TITLES, decided: the given-name form ('Lady Diana') is a courtesy style for the daughters of the senior ranks alone, while every wife takes the title or surname ('Lady Thatcher'), and the text does not carry the rank (#519)" } [[word]] text = "lord" nobility = { gender = "m", attaches = ["chancellor", "justice"], note = "attaches: Lord Chancellor, Lord Justice (rules/H9, #913); stays out of GIVEN_NAME_TITLES, decided: the given-name form ('Lord Peter') is a courtesy style for the children of the senior ranks alone, while every peer takes the title or surname ('Lord Byron'), and the text does not carry the rank (#519)" } [[word]] text = "marchess" nobility = { gender = "f" } [[word]] text = "marchioness" nobility = { gender = "f" } [[word]] text = "marquess" nobility = { gender = "m" } [[word]] text = "marquis" nobility = { gender = "m", issue = 913, decided = 2026-10-27, note = "a title only inside a title phrase, its given reading the clash (rules/H9): 'Marquis Johnson' and 'Mr Marquis Smith' read the given name, unreported, since no listed phrase holds it; 'Marquis of Queensberry' keeps the title" } given = { us_ssa = 25041, wikidata = 81, as_of = 2026-10-27, note = "US SSA: male and female births added (M 24,567); Wikidata is a lower bound (United States 48); nam_dict band 1 in the U.S.A.; UK PSC filers never put it first in the title box (2026-10-27)" } family = { us_census_2010 = 8737, wikidata = 171, as_of = 2026-10-27, note = "US Census rank 4,065; Wikidata Canada 41, France 27" } [[word]] text = "marquise" nobility = { gender = "f", issue = 913, decided = 2026-10-27, note = "a title only inside a title phrase, its given reading the clash (rules/H9): 'Marquise Johnson' reads the given name, unreported, since no listed phrase holds it; 'Marquise de Pompadour' reads it too, 'of' being English only" } given = { us_ssa = 10995, wikidata = 30, as_of = 2026-10-27, note = "US SSA: male and female births added (M 10,603, F 392); Wikidata is a lower bound (United States 13, France 7); nam_dict band 1 in the U.S.A.; UK PSC filers put it first in the forename box once and never in the title box (2026-10-27)" } family = { us_census_2010 = 106, wikidata = 1, as_of = 2026-10-27, note = "US Census rank 153,769" } [[word]] text = "pharaoh" nobility = {} [[word]] text = "prince" nobility = { attaches = ["consort"], addresses = "given", gender = "m", issue = 752, decided = 2026-10-27, note = "a title only inside a title phrase, its given reading the clash (rules/H9): 'His Royal Highness Prince Harry', 'Prince Consort Albert' and 'Prince of Wales' read the title, 'Prince Fielder' and 'Mr Prince Smith' the given name, reported title-or-name since 'Prince Consort' holds it (#913); inside a phrase it addresses by the GIVEN name (#519)" } given = { us_ssa = 24151, wikidata = 204, as_of = 2026-10-27, note = "US SSA: male and female births added; Wikidata is a lower bound (United States 53, Ghana 19, Nigeria 10); nam_dict band 1 in the U.S.A. and India/Sri Lanka" } family = { us_census_2010 = 47168, wikidata = 525, as_of = 2026-10-27, note = "US Census rank 743" } [[word]] text = "princess" nobility = { attaches = ["consort", "royal"], addresses = "given", gender = "f", issue = 752, decided = 2026-10-27, note = "a title only inside a title phrase, its given reading the clash (rules/H9): 'Her Royal Highness Princess Anne' and 'Princess Royal Anne' read the title, 'Princess Smith' and 'Ms Princess Smith' the given name, reported title-or-name since 'Princess Royal' holds it (#913); inside a phrase it addresses by the GIVEN name (#519)" } given = { us_ssa = 15982, wikidata = 29, as_of = 2026-10-27, note = "US SSA: female and male births added; Wikidata is a lower bound (Nigeria 6, Ghana 3); nam_dict band 1 in the U.S.A." } family = { wikidata = 1, as_of = 2026-10-27, note = "US Census: under 100 bearers, not listed" } [[word]] text = "queen" nobility = { attaches = ["consort", "mother"], addresses = "given", gender = "f", issue = 913, decided = 2026-10-27, note = "a title only inside a title phrase, its given reading the clash (rules/H9): 'Her Majesty Queen Elizabeth', 'Queen Mother Elizabeth' and 'Queen Consort Camilla' read the title, 'Queen Latifah' and 'Ms Queen Smith' the given name, reported title-or-name since the phrases attaches makes hold it (#913); inside a phrase it addresses by the GIVEN name" } given = { us_ssa = 16123, wikidata = 25, as_of = 2026-10-27, note = "US SSA: female and male births added (F 16,092); Wikidata is a lower bound (United States 8, Nigeria 5); nam_dict band 1 in the U.S.A.; UK PSC filers put it first in the forename box 3 times and never in the title box (2026-10-27)" } family = { us_census_2010 = 18557, wikidata = 66, as_of = 2026-10-27, note = "US Census rank 1,945" } [[word]] text = "sir" nobility = { addresses = "given", gender = "m", expands = "a knight or baronet, 'Sir John'" } [[word]] text = "viscount" nobility = { gender = "m" } [[word]] text = "1lt" military = { expands = "First Lieutenant" } [[word]] text = "1stlt" military = { same_as = "1lt" } [[word]] text = "1sgt" military = { expands = "First Sergeant" } [[word]] text = "1stsgt" military = { same_as = "1sgt" } [[word]] text = "2lt" military = { expands = "Second Lieutenant" } [[word]] text = "2ndlt" military = { same_as = "2lt" } [[word]] text = "a1c" military = { expands = "Airman First Class" } [[word]] text = "ab" military = { expands = "Airman Basic" } [[word]] text = "adjutant" military = {} [[word]] text = "admiral" military = {} [[word]] text = "adm" military = { same_as = "admiral" } [[word]] text = "amn" military = { expands = "Airman" } [[word]] text = "bg" military = { expands = "Brigadier General" } [[word]] text = "bgen" military = { same_as = "bg" } [[word]] text = "briggen" military = { same_as = "bg" } [[word]] text = "brigadier" military = {} [[word]] text = "captain" military = {} [[word]] text = "capt" military = { same_as = "captain" } [[word]] text = "cpt" military = { same_as = "captain" } credential = {} [[word]] text = "ccmsgt" military = { expands = "Command Chief Master Sergeant" } [[word]] text = "cmsaf" military = { expands = "Chief Master Sergeant of the Air Force" } [[word]] text = "cmsgt" military = { expands = "Chief Master Sergeant" } [[word]] text = "colonel" military = {} [[word]] text = "col" military = { same_as = "colonel" } [[word]] text = "commander" military = {} [[word]] text = "cdr" military = { same_as = "commander" } [[word]] text = "commander-in-chief" military = {} [[word]] text = "commodore" military = {} [[word]] text = "corporal" military = {} [[word]] text = "cpl" military = { same_as = "corporal" } credential = {} [[word]] text = "cpo" military = { expands = "Chief Petty Officer" } credential = {} [[word]] text = "csm" military = { expands = "Command Sergeant Major" } credential = {} [[word]] text = "cwo2" military = { expands = "Chief Warrant Officer 2" } [[word]] text = "cwo-2" military = { same_as = "cwo2" } [[word]] text = "cwo3" military = { expands = "Chief Warrant Officer 3" } [[word]] text = "cwo-3" military = { same_as = "cwo3" } [[word]] text = "cwo4" military = { expands = "Chief Warrant Officer 4" } [[word]] text = "cwo-4" military = { same_as = "cwo4" } [[word]] text = "cwo5" military = { expands = "Chief Warrant Officer 5" } [[word]] text = "cwo-5" military = { same_as = "cwo5" } [[word]] text = "detective" military = { expands = "the police rank, 'Detective Smith'" } [[word]] text = "ens" military = { expands = "Ensign" } [[word]] text = "fadm" military = { expands = "Fleet Admiral" } [[word]] text = "gaf" military = { expands = "General of the Air Force" } [[word]] text = "general" military = {} [[word]] text = "gen" military = { same_as = "general" } [[word]] text = "generalissimo" military = {} [[word]] text = "gysgt" military = { expands = "Gunnery Sergeant" } [[word]] text = "lcdr" military = { expands = "Lieutenant Commander" } [[word]] text = "lcpl" military = { expands = "Lance Corporal" } [[word]] text = "lieutenant" military = {} [[word]] text = "lt" military = { same_as = "lieutenant" } credential = {} [[word]] text = "ltc" military = { expands = "Lieutenant Colonel" } [[word]] text = "ltcol" military = { same_as = "ltc" } [[word]] text = "ltg" military = { expands = "Lieutenant General" } [[word]] text = "ltgen" military = { same_as = "ltg" } [[word]] text = "ltjg" military = { expands = "Lieutenant (junior grade)" } [[word]] text = "maj" military = { expands = "Major" } [[word]] text = "major" military = { issue = 752, decided = 2026-10-25, attaches = ["general"], note = "a title only inside a title phrase, its given reading the clash (rules/H9): 'Major General Smith' reads the title, 'Major Smith' and 'Mr Major Smith' the given name; attaches: Major General" } given = { us_ssa = 21749, wikidata = 65, as_of = 2026-10-25, note = "US SSA: female and male births added" } family = { us_census_2010 = 20977, wikidata = 400, as_of = 2026-10-25 } [[word]] text = "marshal" military = { issue = 752, decided = 2026-10-26, note = "a title only inside a title phrase, its given reading the clash (rules/H9): 'Field Marshal Bernard Montgomery' reads the title, 'Marshal Yanda' the given name; until 2026-10-26 excluded as a given name (#606)" } given = { us_ssa = 5559, wikidata = 28, as_of = 2026-10-26, note = "US SSA: female and male births added" } family = { wikidata = 34, as_of = 2026-10-26 } [[word]] text = "mcpo" military = { expands = "Master Chief Petty Officer" } [[word]] text = "mcpoc" military = { expands = "Master Chief Petty Officer of the Coast Guard" } [[word]] text = "mcpon" military = { expands = "Master Chief Petty Officer of the Navy" } [[word]] text = "mg" military = { expands = "Major General" } [[word]] text = "majgen" military = { same_as = "mg" } [[word]] text = "mgysgt" military = { expands = "Master Gunnery Sergeant" } [[word]] text = "mpco-cg" military = { note = "as shipped; the rank is MCPOCG, Master Chief Petty Officer of the Coast Guard, already listed as mcpoc" } [[word]] text = "msg" military = { expands = "Master Sergeant" } [[word]] text = "msgt" military = { same_as = "msg" } [[word]] text = "officer" military = { expands = "the police rank, 'Officer Smith'" } [[word]] text = "pfc" military = { expands = "Private First Class" } [[word]] text = "po1" military = { expands = "Petty Officer First Class" } [[word]] text = "po2" military = { expands = "Petty Officer Second Class" } [[word]] text = "po3" military = { expands = "Petty Officer Third Class" } [[word]] text = "private" military = {} [[word]] text = "pvt" military = { same_as = "private" } [[word]] text = "pv2" military = { expands = "Private (E-2)" } [[word]] text = "radm" military = { expands = "Rear Admiral" } [[word]] text = "rdml" military = { same_as = "radm" } [[word]] text = "sa" family = { us_census_2010 = 1600, wikidata = 182, as_of = 2026-10-24, note = "The Census writes no diacritics, so its 1,600 is Sa and Sá together (37.88% Asian/PI, so much of it is Korean, Chinese or Vietnamese Sa); Wikidata is a lower bound: 110 as Sá (Portugal, Brazil) plus 72 as Sa" } given = { us_ssa = 73, wikidata = 2, as_of = 2026-10-24, note = "US SSA births summed over both sexes; Wikidata is a lower bound; nam_dict band 5 (banded in 3 countries: Korea, Vietnam, China)" } excluded = { military = { reason = "Seaman Apprentice, with no bearer in any corpus: every name read with SA as its title in the committed and local corpora is a person whose given name is Sa (Sa Liu, Mohamad Sa Haichal) or the Arabic Sa ad (Sa ad Hariri), or whose Portuguese surname Sá is written without its accent (Ricardo Sa Pinto); as a title it also took the slot after a family comma and ahead of a name", issue = 857, decided = 2026-10-25 }, credential = { reason = "no person in the corpora writes it: the corpora's SA and S.A. suffixes are companies (Galderma S.A.), the South African qualifier in FCP(SA), and the initials S.A. typed without stops (MANN, S.A.); the Portuguese surname Sá written without its accent (Maria de Sa) is the reading to protect, and as a credential it needed the may_be_name flag, which left a capitals name ending in SA a suffix (MARIA DE SA). NPPES lists 167 US healthcare providers who write it, unread by item", issue = 857, decided = 2026-10-25 } } [[word]] text = "scpo" military = { expands = "Senior Chief Petty Officer" } [[word]] text = "sergeant" military = { attaches = ["major"], issue = 752, decided = 2026-10-26, note = "attaches: Sergeant Major (rules/H9); a word, not the military kind, which made Sgt Marshal Smith a title" } [[word]] text = "sgt" military = { attaches = ["major"], issue = 752, decided = 2026-10-26, note = "attaches: Sergeant Major (rules/H9); a word, not the military kind, which made Sgt Marshal Smith a title", same_as = "sergeant" } [[word]] text = "sfc" military = { expands = "Sergeant First Class" } [[word]] text = "sgm" military = { expands = "Sergeant Major" } credential = {} [[word]] text = "sgtmaj" military = { same_as = "sgm" } [[word]] text = "sgtmajmc" military = { expands = "Sergeant Major of the Marine Corps" } [[word]] text = "sma" military = { expands = "Sergeant Major of the Army" } [[word]] text = "smsgt" military = { expands = "Senior Master Sergeant" } [[word]] text = "sn" military = { expands = "Seaman" } [[word]] text = "spc" military = { expands = "Specialist" } [[word]] text = "ssg" military = { expands = "Staff Sergeant" } [[word]] text = "ssgt" military = { same_as = "ssg" } [[word]] text = "subaltern" military = {} [[word]] text = "tsgt" military = { expands = "Technical Sergeant" } [[word]] text = "vadm" military = { expands = "Vice Admiral" } [[word]] text = "wo1" military = { expands = "Warrant Officer 1" } [[word]] text = "wo-1" military = { same_as = "wo1" } [[word]] text = "wo2" military = { expands = "Warrant Officer 2" } [[word]] text = "wo3" military = { expands = "Warrant Officer 3" } [[word]] text = "wo4" military = { expands = "Warrant Officer 4" } [[word]] text = "wo5" military = { expands = "Warrant Officer 5" } [[word]] text = "administrator" office = { issue = 606, note = "one of #606's English offices, completing runs like 'Police Commissioner'" } [[word]] text = "alderman" office = {} [[word]] text = "almoner" office = {} [[word]] text = "ambassador" office = {} [[word]] text = "arbitrator" office = {} [[word]] text = "attaché" office = {} [[word]] text = "attache" office = { same_as = "attaché" } [[word]] text = "bailiff" office = {} [[word]] text = "burgess" office = { issue = 913, decided = 2026-10-27, note = "a title only inside a title phrase, its given reading the clash (rules/H9): 'Burgess Meredith' reads the given name, reported title-or-name since 'chief' attaches to an office ('Chief Burgess'); 'Burgess, John' keeps the surname" } given = { us_ssa = 1089, wikidata = 34, as_of = 2026-10-27, note = "US SSA: male births; Wikidata is a lower bound (United States 14)" } family = { us_census_2010 = 73931, wikidata = 882, as_of = 2026-10-27, note = "US Census rank 444" } [[word]] text = "ceo" office = {} [[word]] text = "cfo" office = {} [[word]] text = "chair" office = {} [[word]] text = "chairs" office = { same_as = "chair" } [[word]] text = "chancellor" office = { issue = 913, decided = 2026-10-28, note = "a title only inside a title phrase, its given reading the clash (rules/H9): 'Lord Chancellor Smith' and 'Vice Chancellor Jones' read the title, 'Chancellor Smith' the given name" } given = { us_ssa = 2648, wikidata = 13, as_of = 2026-10-28, note = "US SSA: male births 2,643, female 5; Wikidata is a lower bound (United States 3)" } family = { us_census_2010 = 4147, wikidata = 77, as_of = 2026-10-28, note = "US Census rank 7,974; Wikidata United Kingdom 11, United States 8" } [[word]] text = "chargé" office = { expands = "chargé d'affaires, the head of a mission below an ambassador" } [[word]] text = "charge" office = { same_as = "chargé", note = "unaccented 'chargé', like attaché/'attache'" } [[word]] text = "clerk" office = {} [[word]] text = "co-chair" office = {} [[word]] text = "co-chairs" office = { same_as = "co-chair" } [[word]] text = "commissioner" office = { issue = 606, note = "one of #606's English offices, completing runs like 'Police Commissioner'" } [[word]] text = "comptroller" office = {} [[word]] text = "controller" office = {} [[word]] text = "councillor" office = {} [[word]] text = "cllr" office = { same_as = "councillor", issue = 847, note = "British abbreviation, written without the period; opens 508 of 15,214 names in ukec-individuals (2026-10-23), none borne as a name in any source" } [[word]] text = "counsel" office = { issue = 606, note = "borne: ~730 surnames (Australia, the US, England), forebears.io, worldwide, rounded, 2026-10-04; shipped under the graf class (decisions/salutation-titles)" } [[word]] text = "courtier" office = {} [[word]] text = "d'affaires" office = { expands = "the second word of chargé d'affaires", issue = 752, note = "office rather than modifier since 2026-10-25: it stands behind its head, where decision 10's modifier rule asks for a head title after a modifier (rules/H9)" } [[word]] text = "dean" office = { issue = 752, decided = 2026-10-25, note = "a title only inside a title phrase, its given reading the clash (rules/H9): 'Dean of Students' and 'Associate Dean Smith' read the title, 'Dean Smith' the given name (decision 13 of decisions/vocabulary-model)" } given = { us_ssa = 195844, wikidata = 2246, as_of = 2026-10-25, note = "US SSA: female and male births added" } family = { us_census_2010 = 114030, wikidata = 1128, as_of = 2026-10-25 } [[word]] text = "delegate" office = {} [[word]] text = "deputy" office = { attaches = ["office"], issue = 913, note = "attaches: Deputy Chancellor, Deputy Judge (rules/H9), as chief does" } [[word]] text = "dpty" office = { same_as = "deputy" } [[word]] text = "director" office = {} [[word]] text = "dir" office = { same_as = "director" } [[word]] text = "envoy" office = {} [[word]] text = "governor" office = {} [[word]] text = "gov" office = { same_as = "governor" } [[word]] text = "headman" office = {} [[word]] text = "intendant" office = {} [[word]] text = "judge" office = { issue = 913, note = "kept a title, decided 2026-10-28 (decisions/H9): US SSA 3,826 births (all male), Census 10,453 (rank 3,414), Wikidata 7 given and 133 family, but in PSC 0 filers put it in the forename box and 0 in the title box, and in FEC 13 judges file it before their given name (FAMILY, JUDGE GIVEN, or alone after the comma) and would lose the title, against 2 fixes in all the local corpora (one ORCID, one PSC forename). The given count stands here rather than as a given reading, which would make it a title only inside a title phrase (rules/H9)" } [[word]] text = "justice" office = { issue = 752, decided = 2026-10-25, note = "a title only inside a title phrase, its given reading the clash (rules/H9): 'Chief Justice John Roberts' and 'The Hon. Justice Smith' read the title, 'Justice Smith' the given name; until 2026-10-25 excluded as a given name (#606), the cost being 'Chief Justice' read as title Chief, given Justice" } given = { us_ssa = 38674, wikidata = 85, as_of = 2026-10-25, note = "US SSA: female and male births added" } family = { us_census_2010 = 32480, wikidata = 100, as_of = 2026-10-25 } [[word]] text = "leader" office = {} [[word]] text = "magistrate" office = { attaches = ["judge"], issue = 913, note = "attaches: Magistrate Judge, the US federal office (rules/H9)" } [[word]] text = "mag" office = { same_as = "magistrate" } [[word]] text = "magistrate-judge" office = {} [[word]] text = "mag-judge" office = { same_as = "magistrate-judge" } [[word]] text = "mag/judge" office = { same_as = "magistrate-judge" } [[word]] text = "mayor" office = {} [[word]] text = "member" office = { issue = 752, note = "office rather than modifier since 2026-10-25: the head noun of the phrases it stands in, 'Designated Member' and 'Member of Parliament', which decision 10's modifier rule would read out of the title (rules/H9)" } [[word]] text = "minister" office = {} [[word]] text = "prefect" office = {} [[word]] text = "premier" office = {} [[word]] text = "president" office = {} [[word]] text = "pres" office = { same_as = "president" } [[word]] text = "principal" office = {} [[word]] text = "prin" office = { same_as = "principal" } [[word]] text = "provost" office = {} [[word]] text = "pslc" office = { issue = 9, expands = "Pro Se Law Clerk" } [[word]] text = "pursuivant" office = {} [[word]] text = "registrar" office = {} [[word]] text = "representative" office = {} [[word]] text = "rep" office = { same_as = "representative" } [[word]] text = "secretary" office = {} [[word]] text = "senator" office = {} [[word]] text = "senior-judge" office = {} [[word]] text = "sheriff" office = {} [[word]] text = "speaker" office = {} [[word]] text = "swordbearer" office = {} [[word]] text = "treasurer" office = {} [[word]] text = "vc" office = { expands = "Vice-Chancellor" } credential = {} [[word]] text = "verderer" office = {} [[word]] text = "warden" office = {} [[word]] text = "abolitionist" occupation = {} [[word]] text = "academic" occupation = {} [[word]] text = "activist" occupation = {} [[word]] text = "actor" occupation = {} [[word]] text = "actress" occupation = { gender = "f" } [[word]] text = "adviser" occupation = {} [[word]] text = "anarchist" occupation = {} [[word]] text = "animator" occupation = {} [[word]] text = "anthropologist" occupation = {} [[word]] text = "apprentice" occupation = {} [[word]] text = "archeologist" occupation = {} [[word]] text = "architect" occupation = {} [[word]] text = "arranger" occupation = {} [[word]] text = "astronomer" occupation = {} [[word]] text = "author" occupation = {} [[word]] text = "bandleader" occupation = {} [[word]] text = "banker" occupation = {} [[word]] text = "bard" occupation = { issue = 867, decided = 2026-10-26, note = "a title only inside a title phrase, its given reading the clash (rules/H9): 'Bard Larsen' reads the given name, unreported, since no listed phrase holds it" } given = { us_ssa = 218, wikidata = 10, as_of = 2026-10-26, note = "mostly the Norwegian Bård with its accent dropped: US SSA folds it to BARD (male births); Wikidata is a lower bound, and holds 107 more spelled 'Bård' (Norway 87); nam_dict band 5 in Norway as 'Bård'" } family = { us_census_2010 = 5953, wikidata = 100, as_of = 2026-10-26 } [[word]] text = "barrister" occupation = {} [[word]] text = "bibliographer" occupation = {} [[word]] text = "biochemist" occupation = {} [[word]] text = "biographer" occupation = {} [[word]] text = "biologist" occupation = {} [[word]] text = "blogger" occupation = {} [[word]] text = "bookseller" occupation = {} [[word]] text = "botanist" occupation = {} [[word]] text = "broadcaster" occupation = {} [[word]] text = "businessman" occupation = { gender = "m" } [[word]] text = "businesswoman" occupation = { gender = "f" } [[word]] text = "cartographer" occupation = {} [[word]] text = "cartoonist" occupation = {} [[word]] text = "celebrity" occupation = {} [[word]] text = "chef" occupation = {} [[word]] text = "chemist" occupation = {} [[word]] text = "choreographer" occupation = {} [[word]] text = "clergyman" occupation = {} [[word]] text = "co-founder" occupation = {} [[word]] text = "coach" occupation = {} [[word]] text = "collector" occupation = {} [[word]] text = "comedian" occupation = {} [[word]] text = "comedienne" occupation = { gender = "f" } [[word]] text = "comic" occupation = {} [[word]] text = "composer" occupation = {} [[word]] text = "conductor" occupation = {} [[word]] text = "consultant" occupation = {} [[word]] text = "correspondent" occupation = {} [[word]] text = "counselor" occupation = {} [[word]] text = "criminologist" occupation = {} [[word]] text = "critic" occupation = {} [[word]] text = "curator" occupation = {} [[word]] text = "cyclist" occupation = {} [[word]] text = "dancer" occupation = {} [[word]] text = "designer" occupation = {} [[word]] text = "developer" occupation = {} [[word]] text = "diplomat" occupation = {} [[word]] text = "dissident" occupation = {} [[word]] text = "dramatist" occupation = {} [[word]] text = "drummer" occupation = {} [[word]] text = "ecologist" occupation = {} [[word]] text = "economist" occupation = {} [[word]] text = "editor" occupation = {} [[word]] text = "educator" occupation = {} [[word]] text = "entertainer" occupation = {} [[word]] text = "entrepreneur" occupation = {} [[word]] text = "essayist" occupation = {} [[word]] text = "expert" occupation = {} [[word]] text = "forester" occupation = {} [[word]] text = "founder" occupation = {} [[word]] text = "guitarist" occupation = {} [[word]] text = "historian" occupation = {} [[word]] text = "host" occupation = {} [[word]] text = "illustrator" occupation = {} [[word]] text = "industrialist" occupation = {} [[word]] text = "instructor" occupation = {} [[word]] text = "inventor" occupation = {} [[word]] text = "investigator" occupation = {} [[word]] text = "investor" occupation = {} [[word]] text = "journalist" occupation = {} [[word]] text = "journeyman" occupation = {} [[word]] text = "jurist" occupation = {} [[word]] text = "keyboardist" occupation = {} [[word]] text = "lawyer" occupation = {} [[word]] text = "lecturer" occupation = {} [[word]] text = "librarian" occupation = {} [[word]] text = "linguist" occupation = {} [[word]] text = "lyricist" occupation = {} [[word]] text = "magnate" occupation = {} [[word]] text = "maid" occupation = { addresses = "given", gender = "f", expands = "'Maid Marian'" } [[word]] text = "manager" occupation = {} [[word]] text = "mathematician" occupation = {} [[word]] text = "memoirist" occupation = {} [[word]] text = "merchant" occupation = {} [[word]] text = "missionary" occupation = {} [[word]] text = "mobster" occupation = {} [[word]] text = "model" occupation = {} [[word]] text = "mountaineer" occupation = {} [[word]] text = "musician" occupation = {} [[word]] text = "musicologist" occupation = {} [[word]] text = "nanny" occupation = {} [[word]] text = "narrator" occupation = {} [[word]] text = "naturalist" occupation = {} [[word]] text = "neuroscientist" occupation = {} [[word]] text = "novelist" occupation = {} [[word]] text = "obstetritian" occupation = { note = "a misspelling of obstetrician, shipped as written" } [[word]] text = "ornithologist" occupation = {} [[word]] text = "painter" occupation = {} [[word]] text = "paleontologist" occupation = {} [[word]] text = "pediatrician" occupation = {} [[word]] text = "personality" occupation = {} [[word]] text = "philantropist" occupation = { note = "a misspelling of philanthropist, shipped as written" } [[word]] text = "philosopher" occupation = {} [[word]] text = "photographer" occupation = {} [[word]] text = "physician" occupation = {} [[word]] text = "physicist" occupation = {} [[word]] text = "pianist" occupation = {} [[word]] text = "pilot" occupation = {} [[word]] text = "pioneer" occupation = {} [[word]] text = "player" occupation = {} [[word]] text = "playwright" occupation = {} [[word]] text = "poet" occupation = {} [[word]] text = "politician" occupation = {} [[word]] text = "printer" occupation = {} [[word]] text = "printmaker" occupation = {} [[word]] text = "producer" occupation = {} [[word]] text = "psychiatrist" occupation = {} [[word]] text = "psychologist" occupation = {} [[word]] text = "publisher" occupation = {} [[word]] text = "ranger" occupation = { issue = 913, decided = 2026-10-27, note = "a title only inside a title phrase, its given reading the clash (rules/H9): 'Ranger Smith' reads the given name, unreported, since no listed phrase holds it" } given = { us_ssa = 1518, as_of = 2026-10-27, note = "US SSA: male births; Wikidata lists no bearer, a lower bound" } family = { us_census_2010 = 2504, wikidata = 48, as_of = 2026-10-27, note = "US Census rank 12,417" } [[word]] text = "researcher" occupation = {} [[word]] text = "resident" occupation = { issue = 752, note = "occupation rather than modifier since 2026-10-25: a part after a comma written 'Resident' is the person's role, which decision 10's modifier rule would read out of the title (decisions/vocabulary-model)" } [[word]] text = "rt" occupation = { issue = 752, expands = "Right, abbreviated ('Rt Hon'); also a respiratory therapist", note = "occupation rather than modifier since 2026-10-25: a part after a comma written 'RT' is the person's role, which decision 10's modifier rule would read out of the title (decisions/vocabulary-model)" } [[word]] text = "sailor" occupation = {} [[word]] text = "satirist" occupation = {} [[word]] text = "scholar" occupation = {} [[word]] text = "schoolmaster" occupation = {} [[word]] text = "scientist" occupation = {} [[word]] text = "screenwriter" occupation = {} [[word]] text = "servant" occupation = {} [[word]] text = "singer" occupation = {} [[word]] text = "singer-songwriter" occupation = {} [[word]] text = "sociologist" occupation = {} [[word]] text = "soldier" occupation = {} [[word]] text = "solicitor" occupation = {} [[word]] text = "soprano" occupation = {} [[word]] text = "suffragist" occupation = {} [[word]] text = "surgeon" occupation = {} [[word]] text = "teacher" occupation = {} [[word]] text = "technologist" occupation = {} [[word]] text = "tenor" occupation = {} [[word]] text = "theologian" occupation = {} [[word]] text = "theorist" occupation = {} [[word]] text = "translator" occupation = {} [[word]] text = "vocalist" occupation = {} [[word]] text = "woodman" occupation = {} [[word]] text = "writer" occupation = {} [[word]] text = "zoologist" occupation = {} [[word]] text = "aunt" kinship = { addresses = "given", gender = "f" } [[word]] text = "auntie" kinship = { addresses = "given", gender = "f", same_as = "aunt" } [[word]] text = "matriarch" kinship = { gender = "f" } [[word]] text = "uncle" kinship = { addresses = "given", gender = "m" } [[word]] text = "10th" modifier = {} [[word]] text = "1st" modifier = {} [[word]] text = "2nd" modifier = {} [[word]] text = "3rd" modifier = {} [[word]] text = "4th" modifier = {} [[word]] text = "5th" modifier = {} [[word]] text = "6th" modifier = {} [[word]] text = "7th" modifier = {} [[word]] text = "8th" modifier = {} [[word]] text = "9th" modifier = {} [[word]] text = "advertising" modifier = {} [[word]] text = "air" modifier = {} [[word]] text = "analytics" modifier = {} [[word]] text = "appellate" modifier = {} [[word]] text = "army" modifier = {} [[word]] text = "assistant" modifier = {} [[word]] text = "asst" modifier = { same_as = "assistant" } [[word]] text = "associate" modifier = {} [[word]] text = "assoc" modifier = { same_as = "associate" } [[word]] text = "award-winning" modifier = {} [[word]] text = "ballet" modifier = {} [[word]] text = "banner" modifier = {} [[word]] text = "baseball" modifier = {} [[word]] text = "bearer" modifier = { expands = "Standard Bearer, Sword Bearer" } [[word]] text = "behavioral" modifier = {} [[word]] text = "bench" modifier = {} [[word]] text = "biblical" modifier = {} [[word]] text = "blues" modifier = {} [[word]] text = "british" modifier = {} [[word]] text = "burlesque" modifier = {} [[word]] text = "business" modifier = {} [[word]] text = "civil" modifier = {} [[word]] text = "classical" modifier = {} [[word]] text = "computer" modifier = {} [[word]] text = "consort" modifier = { addresses = "given", issue = 913, decided = 2026-10-27, note = "the spouse of a monarch, read only in a title phrase after the royal title it qualifies (King Consort, Queen Consort, Prince Consort, Princess Consort; rules/H9); no US Census or SSA bearers, and Wikidata lists none" } [[word]] text = "corporate" modifier = {} [[word]] text = "credit" modifier = {} [[word]] text = "criminal" modifier = {} [[word]] text = "customs" modifier = {} [[word]] text = "designated" modifier = { issue = 9, expands = "Designated Judge" } [[word]] text = "discovery" modifier = { issue = 9 } [[word]] text = "district" modifier = {} [[word]] text = "division" modifier = {} [[word]] text = "docket" modifier = { issue = 9, expands = "Docket Clerk" } [[word]] text = "edmi" modifier = { issue = 9, note = "from #9's list of federal judges' titles; its meaning is not recorded there" } [[word]] text = "english" modifier = {} [[word]] text = "excellent" modifier = { expands = "'His Most Excellent Majesty'" } [[word]] text = "executive" modifier = {} [[word]] text = "exec" modifier = { same_as = "executive" } [[word]] text = "family" modifier = { issue = 9, note = "added with #9's judicial titles (0c2596c7); the phrase it was for is not recorded" } [[word]] text = "federal" modifier = {} [[word]] text = "field" modifier = {} [[word]] text = "film" modifier = {} [[word]] text = "financial" modifier = {} [[word]] text = "first" modifier = {} [[word]] text = "flag" modifier = {} [[word]] text = "flying" modifier = {} [[word]] text = "foreign" modifier = {} [[word]] text = "grand" modifier = {} [[word]] text = "group" modifier = {} [[word]] text = "her" modifier = { gender = "f", expands = "'Her Majesty'" } [[word]] text = "hereditary" modifier = {} [[word]] text = "high" modifier = {} [[word]] text = "his" modifier = { gender = "m", expands = "'His Holiness'" } [[word]] text = "information" modifier = {} [[word]] text = "intelligence" modifier = {} [[word]] text = "judicial" modifier = {} [[word]] text = "king's" modifier = { expands = "King's Counsel" } [[word]] text = "kingdom" modifier = {} [[word]] text = "knowledge" modifier = {} [[word]] text = "law" modifier = {} [[word]] text = "legal" modifier = {} [[word]] text = "literary" modifier = {} [[word]] text = "marcher" modifier = { expands = "Marcher Lord" } [[word]] text = "marketing" modifier = {} [[word]] text = "mathematics" modifier = {} [[word]] text = "military" modifier = {} [[word]] text = "most" modifier = { expands = "'Most Reverend'" } [[word]] text = "municipal" modifier = {} [[word]] text = "mystery" modifier = {} [[word]] text = "national" modifier = {} [[word]] text = "navy" modifier = {} [[word]] text = "opera" modifier = {} [[word]] text = "operating" modifier = {} [[word]] text = "petty" modifier = {} [[word]] text = "police" modifier = {} [[word]] text = "political" modifier = {} [[word]] text = "presiding" modifier = {} [[word]] text = "prime" modifier = {} [[word]] text = "queen's" modifier = { expands = "Queen's Counsel" } [[word]] text = "radio" modifier = {} [[word]] text = "rear" modifier = {} [[word]] text = "revenue" modifier = {} [[word]] text = "right" modifier = { expands = "'Right Honourable', 'Right Reverend'" } [[word]] text = "risk" modifier = {} [[word]] text = "rock" modifier = {} [[word]] text = "royal" modifier = { addresses = "given", issue = 913, decided = 2026-10-27, note = "a title only beside a head title (rules/H9), its given reading the clash: 'His Royal Highness' and 'Princess Royal Anne' read the title, addressing by the given name, 'Royal Smith' and 'Smith, Royal' the given name" } given = { us_ssa = 24394, wikidata = 136, as_of = 2026-10-27, note = "US SSA: male and female births added (M 20,072); Wikidata is a lower bound (United States 79); nam_dict band 1 in Great Britain and the U.S.A." } family = { us_census_2010 = 15755, wikidata = 65, as_of = 2026-10-27, note = "US Census rank 2,319" } [[word]] text = "security" modifier = {} [[word]] text = "senior" modifier = {} [word.excluded] generation = { reason = "Senior is a surname (3,864 US Census bearers)", issue = 679 } [[word]] text = "soccer" modifier = {} [[word]] text = "social" modifier = {} [[word]] text = "software" modifier = {} [[word]] text = "special" modifier = {} [[word]] text = "staff" modifier = {} [[word]] text = "state" modifier = {} [[word]] text = "states" modifier = {} [[word]] text = "strategy" modifier = {} [[word]] text = "superior" modifier = { expands = "'Mother Superior'" } [[word]] text = "supreme" modifier = {} [[word]] text = "tax" modifier = {} [[word]] text = "technical" modifier = {} [[word]] text = "television" modifier = {} [[word]] text = "theater" modifier = {} [[word]] text = "theatre" modifier = { same_as = "theater" } [[word]] text = "travel" modifier = {} [[word]] text = "uk" modifier = {} [[word]] text = "united" modifier = {} [[word]] text = "us" modifier = {} [[word]] text = "vice" modifier = {} [[word]] text = "voice" modifier = {} [[word]] text = "warrant" modifier = {} [[word]] text = "wing" modifier = { issue = 752, decided = 2026-10-26, note = "a title only beside a head title (rules/H9), its given reading the clash: 'Wing Commander Lau' reads the title, 'Wing Lau' and 'Lau, Wing Kai' the given name, unreported, since no listed phrase holds it" } given = { us_ssa = 188, wikidata = 17, as_of = 2026-10-26, note = "US SSA: female and male births added; Wikidata is a lower bound; nam_dict band 1 in China" } family = { us_census_2010 = 12614, wikidata = 218, as_of = 2026-10-26 } [[word]] text = "jr" generation = {} [[word]] text = "jnr" generation = { same_as = "jr" } [[word]] text = "junior" generation = { same_as = "jr" } [[word]] text = "sr" generation = {} [[word]] text = "snr" generation = { same_as = "sr" } [[word]] text = "8-vsb" credential = {} [[word]] text = "aas" credential = {} [[word]] text = "aba" family = { us_census_2010 = 231, wikidata = 18, as_of = 2026-10-21 } given = { us_ssa = 32, wikidata = 40, as_of = 2026-10-21, note = "US SSA births summed over both sexes; Wikidata is a lower bound" } credential = { note = "NPPES 183 US healthcare providers write it (healthcare only, August 2026 file). Marked on C-i: the surname reading is substantial (Census 2010 or Wikidata).", issue = 796 } [[word]] text = "abc" credential = {} [[word]] text = "abd" [word.credential] expands = "All But Dissertation" note = "also the Latin transliteration of the Arabic bound given name abd; position decides at the two ends but not in a family comma's given slot, where 'Smith, Abd' reads suffix 'Abd' (decisions/P5)" [[word]] text = "abpp" credential = {} [[word]] text = "abr" credential = {} [[word]] text = "aca" family = { us_census_2010 = 375, as_of = 2026-10-21 } credential = { note = "NPPES 141 US healthcare providers write it (healthcare only, August 2026 file). Marked on C-i: the surname reading is substantial (Census 2010 or Wikidata).", issue = 796 } [[word]] text = "acas" credential = {} [[word]] text = "ace" family = { us_census_2010 = 1199, wikidata = 8, as_of = 2026-10-21 } given = { us_ssa = 26483, wikidata = 45, as_of = 2026-10-21, note = "US SSA births summed over both sexes; Wikidata is a lower bound" } credential = { note = "NPPES 9 US healthcare providers write it (healthcare only, August 2026 file). Marked on C-i: the surname reading is substantial (Census 2010 or Wikidata).", issue = 796 } [[word]] text = "acha" family = { us_census_2010 = 477, wikidata = 26, as_of = 2026-10-21 } credential = { note = "NPPES 1 US healthcare provider writes it (healthcare only, August 2026 file). Marked on C-i: the surname reading is substantial (Census 2010 or Wikidata).", issue = 796 } [[word]] text = "acp" credential = {} [[word]] text = "ae" credential = {} [[word]] text = "aem" credential = {} [[word]] text = "afasma" credential = {} [[word]] text = "afc" credential = {} [[word]] text = "afm" credential = {} [[word]] text = "agacnp-bc" credential = { expands = "Adult-Gerontology Acute Care Nurse Practitioner, Board Certified", note = "NPPES 4,421 US healthcare providers (August 2026 file); 2 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21) write it; no Census 2010 (floor 100), SSA (floor 5 a year) or Wikidata bearer", issue = 797 } [[word]] text = "agsf" credential = {} [[word]] text = "aia" credential = {} [[word]] text = "aicp" credential = {} [[word]] text = "ala" family = { us_census_2010 = 743, wikidata = 14, as_of = 2026-10-21 } given = { us_ssa = 943, wikidata = 163, as_of = 2026-10-21, note = "US SSA births summed over both sexes; Wikidata is a lower bound" } credential = { note = "NPPES 3 US healthcare providers write it (healthcare only, August 2026 file). Marked on C-i: the surname reading is substantial (Census 2010 or Wikidata).", issue = 796 } [[word]] text = "alc" credential = {} [[word]] text = "alp" family = { us_census_2010 = 116, wikidata = 55, as_of = 2026-10-21 } given = { us_ssa = 467, wikidata = 75, as_of = 2026-10-21, note = "US SSA births summed over both sexes; Wikidata is a lower bound" } credential = { note = "NPPES 0 US healthcare providers write it (healthcare only, August 2026 file). Marked on C-i: the surname reading is substantial (Census 2010 or Wikidata).", issue = 796 } [[word]] text = "am" credential = {} [[word]] text = "amd" credential = {} [[word]] text = "ame" credential = {} [[word]] text = "amieee" credential = {} [[word]] text = "ams" family = { us_census_2010 = 135, wikidata = 2, as_of = 2026-10-21 } credential = { note = "NPPES 5 US healthcare providers write it (healthcare only, August 2026 file). Surname reading is the larger of the two small counts (#796's revised rule).", issue = 796 } [[word]] text = "aphr" credential = {} [[word]] text = "apn" credential = {} [[word]] text = "apr" credential = {} [[word]] text = "aprn" credential = {} [[word]] text = "aprn-cnp" credential = { expands = "Advanced Practice Registered Nurse, Certified Nurse Practitioner", note = "NPPES 3,944 US healthcare providers (August 2026 file); 2 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21) write it; no Census 2010 (floor 100), SSA (floor 5 a year) or Wikidata bearer", issue = 797 } [[word]] text = "apss" credential = {} [[word]] text = "aqp" credential = {} [[word]] text = "arm" family = { us_census_2010 = 283, wikidata = 8, as_of = 2026-10-21 } credential = { note = "NPPES 1 US healthcare provider writes it (healthcare only, August 2026 file). Marked on C-i: the surname reading is substantial (Census 2010 or Wikidata).", issue = 796 } [[word]] text = "arrc" credential = {} [[word]] text = "asa" family = { us_census_2010 = 508, wikidata = 5, as_of = 2026-10-21 } given = { us_ssa = 23067, wikidata = 283, as_of = 2026-10-21, note = "US SSA births summed over both sexes; Wikidata is a lower bound" } credential = { note = "NPPES 3 US healthcare providers write it (healthcare only, August 2026 file). Marked on C-i: the surname reading is substantial (Census 2010 or Wikidata).", issue = 796 } [[word]] text = "asc" credential = {} [[word]] text = "asid" credential = {} [[word]] text = "asla" credential = {} [[word]] text = "asp" family = { us_census_2010 = 733, wikidata = 63, as_of = 2026-10-21 } credential = { note = "NPPES 1 US healthcare provider writes it (healthcare only, August 2026 file). Marked on C-i: the surname reading is substantial (Census 2010 or Wikidata).", issue = 796 } [[word]] text = "atc" credential = {} [[word]] text = "awb" credential = {} [[word]] text = "ba" family = { us_census_2010 = 2451, wikidata = 117, as_of = 2026-10-25 } given = { us_ssa = 5, wikidata = 59, as_of = 2026-10-25, note = "male births only; Wikidata is a lower bound; nam_dict band 9 in Vietnam" } [word.credential] expands = "Bachelor of Arts" issue = 342 note = "Ba is a real surname (Vietnamese; Senegalese Fula) about as common as BA is a credential, the ma/Ma shape" [[word]] text = "bca" credential = {} [[word]] text = "bcl" credential = {} [[word]] text = "bcss" credential = {} [[word]] text = "bds" credential = {} [[word]] text = "bem" family = { us_census_2010 = 339, wikidata = 29, as_of = 2026-10-21 } credential = { note = "NPPES 0 US healthcare providers write it (healthcare only, August 2026 file). Marked on C-i: the surname reading is substantial (Census 2010 or Wikidata).", issue = 796 } [[word]] text = "bls-i" credential = {} [[word]] text = "bn" credential = {} [[word]] text = "bpe" credential = {} [[word]] text = "bpi" credential = {} [[word]] text = "bpt" credential = {} [[word]] text = "bs" credential = { expands = "Bachelor of Science", note = "NPPES 18,880 US healthcare providers (August 2026 file); 449 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21) write it; no Census 2010 (floor 100), SSA (floor 5 a year) or Wikidata bearer", issue = 797 } [[word]] text = "bsc" credential = { cased = "BSc" } [[word]] text = "bt" credential = { cased = "Bt" } [[word]] text = "btcs" credential = {} [[word]] text = "bts" credential = {} [[word]] text = "cacts" credential = {} [[word]] text = "cae" credential = {} [[word]] text = "caha" family = { us_census_2010 = 116, wikidata = 28, as_of = 2026-10-21 } credential = { note = "NPPES 1 US healthcare provider writes it (healthcare only, August 2026 file). Marked on C-i: the surname reading is substantial (Census 2010 or Wikidata).", issue = 796 } [[word]] text = "caia" credential = {} [[word]] text = "cams" credential = {} [[word]] text = "cap" credential = {} [[word]] text = "capa" family = { us_census_2010 = 175, wikidata = 7, as_of = 2026-10-21 } credential = { note = "NPPES 11 US healthcare providers write it (healthcare only, August 2026 file). Surname reading is the larger of the two small counts (#796's revised rule).", issue = 796 } [[word]] text = "capm" credential = {} [[word]] text = "capp" family = { us_census_2010 = 1588, wikidata = 13, as_of = 2026-10-21 } given = { us_ssa = 5, as_of = 2026-10-21, note = "US SSA births summed over both sexes; Wikidata is a lower bound" } credential = { note = "NPPES 7 US healthcare providers write it (healthcare only, August 2026 file). Marked on C-i: the surname reading is substantial (Census 2010 or Wikidata).", issue = 796 } [[word]] text = "caps" credential = {} [[word]] text = "caro" family = { us_census_2010 = 10064, wikidata = 236, as_of = 2026-10-21 } given = { us_ssa = 274, wikidata = 50, as_of = 2026-10-21, note = "US SSA births summed over both sexes; Wikidata is a lower bound" } credential = { note = "NPPES 0 US healthcare providers write it (healthcare only, August 2026 file). Marked on C-i: the surname reading is substantial (Census 2010 or Wikidata).", issue = 796 } [[word]] text = "cas" credential = {} [[word]] text = "casp" credential = {} [[word]] text = "cb" credential = {} [[word]] text = "cbe" credential = {} [[word]] text = "cbm" credential = {} [[word]] text = "cbne" credential = {} [[word]] text = "cbnt" credential = {} [[word]] text = "cbp" credential = {} [[word]] text = "cbrte" credential = {} [[word]] text = "cbs" credential = {} [[word]] text = "cbsp" credential = {} [[word]] text = "cbt" credential = {} [[word]] text = "cbte" credential = {} [[word]] text = "cbv" credential = {} [[word]] text = "cca" credential = {} [[word]] text = "ccc" credential = {} [[word]] text = "ccc-a" credential = { expands = "Certificate of Clinical Competence in Audiology", note = "NPPES 3,581 US healthcare providers (August 2026 file); 3 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21) write it; no Census 2010 (floor 100), SSA (floor 5 a year) or Wikidata bearer", issue = 797 } [[word]] text = "ccc-slp" credential = { expands = "Certificate of Clinical Competence in Speech-Language Pathology", note = "NPPES 68,726 US healthcare providers (August 2026 file); 50 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21) write it; no Census 2010 (floor 100), SSA (floor 5 a year) or Wikidata bearer", issue = 797 } [[word]] text = "ccc/slp" credential = { same_as = "ccc-slp", expands = "Certificate of Clinical Competence in Speech-Language Pathology", note = "NPPES 4,039 US healthcare providers (August 2026 file); 0 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21) write it; no Census 2010 (floor 100), SSA (floor 5 a year) or Wikidata bearer", issue = 797 } [[word]] text = "ccca" credential = {} [[word]] text = "cccm" credential = {} [[word]] text = "cce" credential = {} [[word]] text = "cchp" credential = {} [[word]] text = "ccie" credential = {} [[word]] text = "ccim" credential = {} [[word]] text = "cciso" credential = {} [[word]] text = "ccm" credential = {} [[word]] text = "ccmt" credential = {} [[word]] text = "ccna" credential = {} [[word]] text = "ccnp" credential = {} [[word]] text = "ccp" credential = {} [[word]] text = "ccp-c" credential = {} [[word]] text = "ccpr" credential = {} [[word]] text = "ccs" credential = {} [[word]] text = "ccufc" credential = {} [[word]] text = "cd" credential = {} [[word]] text = "cdal" credential = {} [[word]] text = "cdfm" credential = {} [[word]] text = "cdmp" credential = {} [[word]] text = "cds" credential = {} [[word]] text = "cdt" credential = {} [[word]] text = "cea" family = { us_census_2010 = 1275, wikidata = 21, as_of = 2026-10-21 } credential = { note = "NPPES 13 US healthcare providers write it (healthcare only, August 2026 file). Marked on C-i: the surname reading is substantial (Census 2010 or Wikidata).", issue = 796 } [[word]] text = "ceas" credential = {} [[word]] text = "cebs" credential = {} [[word]] text = "ceds" credential = {} [[word]] text = "ceh" credential = {} [[word]] text = "cela" family = { us_census_2010 = 637, wikidata = 18, as_of = 2026-10-21 } given = { us_ssa = 90, as_of = 2026-10-21, note = "US SSA births summed over both sexes; Wikidata is a lower bound" } credential = { note = "NPPES 1 US healthcare provider writes it (healthcare only, August 2026 file). Marked on C-i: the surname reading is substantial (Census 2010 or Wikidata).", issue = 796 } [[word]] text = "cem" credential = {} [[word]] text = "cep" credential = {} [[word]] text = "cera" family = { us_census_2010 = 1224, wikidata = 18, as_of = 2026-10-21 } given = { us_ssa = 1410, as_of = 2026-10-21, note = "US SSA births summed over both sexes; Wikidata is a lower bound" } credential = { note = "NPPES 0 US healthcare providers write it (healthcare only, August 2026 file). Marked on C-i: the surname reading is substantial (Census 2010 or Wikidata).", issue = 796 } [[word]] text = "cet" credential = {} [[word]] text = "cf-slp" credential = { expands = "Clinical Fellow in Speech-Language Pathology", note = "NPPES 5,570 US healthcare providers (August 2026 file); 0 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21) write it; no Census 2010 (floor 100), SSA (floor 5 a year) or Wikidata bearer", issue = 797 } [[word]] text = "cfa" credential = {} [[word]] text = "cfc" credential = {} [[word]] text = "cfcc" credential = {} [[word]] text = "cfce" credential = {} [[word]] text = "cfcm" credential = {} [[word]] text = "cfe" credential = {} [[word]] text = "cfeds" credential = {} [[word]] text = "cfi" credential = {} [[word]] text = "cfm" credential = {} [[word]] text = "cfp" credential = {} [[word]] text = "cfps" credential = {} [[word]] text = "cfr" credential = {} [[word]] text = "cfre" credential = {} [[word]] text = "cga" credential = {} [[word]] text = "cgap" credential = {} [[word]] text = "cgb" credential = {} [[word]] text = "cgc" credential = {} [[word]] text = "cgfm" credential = {} [[word]] text = "cgfo" credential = {} [[word]] text = "cgm" credential = {} [[word]] text = "cgma" credential = {} [[word]] text = "cgp" credential = {} [[word]] text = "cgr" credential = {} [[word]] text = "cgsp" credential = {} [[word]] text = "ch" credential = {} [[word]] text = "cha" [word.excluded.credential] reason = "Cha, the Korean 차, dominates CHA (Certified Hotel Administrator): removed 2026-09-07 by the relative-frequency criterion (decisions/suffix-acronym-collisions)" issue = 342 [[word]] text = "chba" credential = {} [[word]] text = "chdm" credential = {} [[word]] text = "che" family = { us_census_2010 = 2393, wikidata = 5936, as_of = 2026-10-21 } given = { us_ssa = 2469, wikidata = 48, as_of = 2026-10-21, note = "US SSA births summed over both sexes; Wikidata is a lower bound" } credential = { note = "NPPES 16 US healthcare providers write it (healthcare only, August 2026 file). Marked on C-i: the surname reading is substantial (Census 2010 or Wikidata).", issue = 796 } [[word]] text = "ches" credential = {} [[word]] text = "chfc" credential = { cased = "ChFC" } [[word]] text = "chi" family = { us_census_2010 = 7273, wikidata = 219, as_of = 2026-10-21 } given = { us_ssa = 1326, wikidata = 123, as_of = 2026-10-21, note = "US SSA births summed over both sexes; Wikidata is a lower bound" } [word.credential] issue = 791 note = "Chi is a common surname (Census 2010: 7,273 family-name bearers, rank 4,843; Wikidata 219+ family) and a Vietnamese given name; measured 2026-10-21 by tools/name_usage.py. No expansion is recorded: the 2019 bulk import (af5bdab, #93) carried none" [[word]] text = "chmc" credential = {} [[word]] text = "chmm" credential = {} [[word]] text = "chp" credential = {} [[word]] text = "chpa" credential = {} [[word]] text = "chpe" credential = {} [[word]] text = "chpln" credential = {} [[word]] text = "chpse" credential = {} [[word]] text = "chrm" credential = {} [[word]] text = "chsc" credential = {} [[word]] text = "chse" credential = {} [[word]] text = "chse-a" credential = {} [[word]] text = "chsos" credential = {} [[word]] text = "chss" credential = {} [[word]] text = "cht" credential = {} [[word]] text = "cia" credential = {} [[word]] text = "cic" credential = {} [[word]] text = "cie" credential = {} [[word]] text = "cig" credential = {} [[word]] text = "cip" credential = {} [[word]] text = "cipm" credential = {} [[word]] text = "cips" credential = {} [[word]] text = "ciro" family = { us_census_2010 = 265, wikidata = 6, as_of = 2026-10-21 } given = { us_ssa = 2587, wikidata = 409, as_of = 2026-10-21, note = "US SSA births summed over both sexes; Wikidata is a lower bound" } credential = { note = "NPPES 0 US healthcare providers write it (healthcare only, August 2026 file). Marked on C-i: the surname reading is substantial (Census 2010 or Wikidata).", issue = 796 } [[word]] text = "cisa" credential = {} [[word]] text = "cism" credential = {} [[word]] text = "cissp" credential = {} [[word]] text = "cla" credential = {} [[word]] text = "clsd" credential = {} [[word]] text = "cltd" credential = {} [[word]] text = "clu" credential = {} [[word]] text = "cm" credential = {} [[word]] text = "cma" credential = {} [[word]] text = "cmas" credential = {} [[word]] text = "cmc" credential = {} [[word]] text = "cmfo" credential = {} [[word]] text = "cmg" credential = {} [[word]] text = "cmp" credential = {} [[word]] text = "cms" credential = {} [[word]] text = "cmsp" credential = {} [[word]] text = "cmt" credential = {} [[word]] text = "cna" credential = {} [[word]] text = "cnm" credential = {} [[word]] text = "cnp" credential = {} [[word]] text = "cota/l" credential = { expands = "Certified Occupational Therapy Assistant, Licensed", note = "NPPES 11,917 US healthcare providers (August 2026 file); 0 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21) write it; no Census 2010 (floor 100), SSA (floor 5 a year) or Wikidata bearer", issue = 797 } [[word]] text = "cp" credential = {} [[word]] text = "cp-c" credential = {} [[word]] text = "cpa" credential = {} [[word]] text = "cpacc" credential = {} [[word]] text = "cpbe" credential = {} [[word]] text = "cpcm" credential = {} [[word]] text = "cpcu" credential = {} [[word]] text = "cpe" credential = {} [[word]] text = "cpfa" credential = {} [[word]] text = "cpfo" credential = {} [[word]] text = "cpg" credential = {} [[word]] text = "cph" credential = {} [[word]] text = "cpht" credential = { cased = "CPhT" } [[word]] text = "cpim" credential = {} [[word]] text = "cplp" credential = {} [[word]] text = "cpm" credential = {} [[word]] text = "cpnp-pc" credential = { expands = "Certified Pediatric Nurse Practitioner, Primary Care", note = "NPPES 2,697 US healthcare providers (August 2026 file); 6 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21) write it; no Census 2010 (floor 100), SSA (floor 5 a year) or Wikidata bearer", issue = 797 } [[word]] text = "cpp" credential = {} [[word]] text = "cppm" credential = {} [[word]] text = "cprc" credential = {} [[word]] text = "cpre" credential = {} [[word]] text = "cprp" credential = {} [[word]] text = "cpsc" credential = {} [[word]] text = "cpsi" credential = {} [[word]] text = "cpss" credential = {} [[word]] text = "cpwa" credential = {} [[word]] text = "crde" credential = {} [[word]] text = "crisc" credential = {} [[word]] text = "crma" credential = {} [[word]] text = "crme" credential = {} [[word]] text = "crna" credential = {} [[word]] text = "cro" credential = {} [[word]] text = "crp" credential = {} [[word]] text = "crt" credential = {} [[word]] text = "crtt" credential = {} [[word]] text = "csa" credential = {} [[word]] text = "csbe" credential = {} [[word]] text = "csc" credential = {} [[word]] text = "cscp" credential = {} [[word]] text = "cscu" credential = {} [[word]] text = "csep" credential = {} [[word]] text = "csi" family = { wikidata = 590, as_of = 2026-10-21 } credential = { note = "NPPES 95 US healthcare providers write it (healthcare only, August 2026 file). Surname evidence is Wikidata only (no Census bearers); marked on C-i.", issue = 796 } [[word]] text = "csp" credential = {} [[word]] text = "cspo" credential = {} [[word]] text = "csre" credential = {} [[word]] text = "csrte" credential = {} [[word]] text = "csslp" credential = {} [[word]] text = "cssm" credential = {} [[word]] text = "cst" credential = {} [[word]] text = "cste" credential = {} [[word]] text = "ctbs" credential = {} [[word]] text = "ctfa" credential = {} [[word]] text = "cto" credential = {} [[word]] text = "ctp" credential = {} [[word]] text = "cts" credential = {} [[word]] text = "cua" family = { us_census_2010 = 1082, wikidata = 3, as_of = 2026-10-21 } credential = { note = "NPPES 2 US healthcare providers write it (healthcare only, August 2026 file). Marked on C-i: the surname reading is substantial (Census 2010 or Wikidata).", issue = 796 } [[word]] text = "cusp" credential = {} [[word]] text = "cva" credential = {} [[word]] text = "cvo" credential = {} [[word]] text = "cvp" credential = {} [[word]] text = "cvrs" credential = {} [[word]] text = "cwap" credential = {} [[word]] text = "cwb" credential = {} [[word]] text = "cwdp" credential = {} [[word]] text = "cwep" credential = {} [[word]] text = "cwna" credential = {} [[word]] text = "cwne" credential = {} [[word]] text = "cwp" credential = {} [[word]] text = "cwsp" credential = {} [[word]] text = "cxa" credential = {} [[word]] text = "cyds" credential = {} [[word]] text = "cysa" credential = {} [[word]] text = "dabfm" credential = {} [[word]] text = "dabvlm" credential = {} [[word]] text = "dacvim" credential = {} [[word]] text = "dbe" credential = {} [[word]] text = "dc" credential = {} [[word]] text = "dcb" credential = {} [[word]] text = "dcm" credential = {} [[word]] text = "dcmg" credential = {} [[word]] text = "dcvo" credential = {} [[word]] text = "dd" credential = {} [[word]] text = "dds" credential = {} [[word]] text = "ded" [word.credential] [word.credential.excluded] cased = { reason = "a mask applies in every role, and Ded is an Albanian given name: 'ded gjo luli' would render 'DEd Gjo Luli' (measured 2026-09-24)", issue = 459 } [[word]] text = "dep" credential = {} [[word]] text = "dfc" credential = {} [[word]] text = "dfm" credential = {} [[word]] text = "diplac" credential = { cased = "DiplAc" } [[word]] text = "diplom" credential = {} [[word]] text = "djur" credential = {} [[word]] text = "dm" credential = { expands = "Doctor of Medicine", note = "NPPES 80 US healthcare providers (August 2026 file); 471 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21) write it; no Census 2010 (floor 100), SSA (floor 5 a year) or Wikidata bearer", issue = 797 } [[word]] text = "dma" credential = {} [[word]] text = "dmd" credential = {} [[word]] text = "dmin" credential = { cased = "DMin" } [[word]] text = "dmsc" credential = { cased = "DMSc", expands = "Doctor of Medical Science", note = "NPPES 221 US healthcare providers (August 2026 file); 674 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21) write it; no Census 2010 (floor 100), SSA (floor 5 a year) or Wikidata bearer", issue = 797 } [[word]] text = "dmsci" credential = { cased = "DMSci", expands = "Doctor of Medical Science", note = "107 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21), and no NPPES provider, write it; no Census 2010 (floor 100), SSA (floor 5 a year) or Wikidata bearer", issue = 797 } [[word]] text = "dnp" credential = {} [[word]] text = "do" family = { us_census_2010 = 30301, wikidata = 100, as_of = 2026-10-25 } given = { us_ssa = 11, wikidata = 9, as_of = 2026-10-25, note = "male births only; Wikidata is a lower bound; nam_dict band 8 in Korea, 6 in Vietnam, 1 in the Netherlands" } credential = { expands = "Doctor of Osteopathic Medicine" } [[word]] text = "dphil" credential = { cased = "DPhil", expands = "Doctor of Philosophy", note = "NPPES 72 US healthcare providers (August 2026 file); 296 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21) write it; no Census 2010 (floor 100), SSA (floor 5 a year) or Wikidata bearer", issue = 797 } [[word]] text = "dpm" credential = {} [[word]] text = "dpt" credential = {} [[word]] text = "drb" credential = {} [[word]] text = "drmp" credential = {} [[word]] text = "drph" credential = { cased = "DrPH" } [[word]] text = "dsc" credential = { cased = "DSc" } [[word]] text = "dsm" credential = {} [[word]] text = "dso" credential = {} [[word]] text = "dss" credential = {} [[word]] text = "dtr" credential = {} [[word]] text = "dvep" credential = {} [[word]] text = "dvm" credential = {} [[word]] text = "ea" family = { us_census_2010 = 496, wikidata = 2, as_of = 2026-10-21 } given = { us_ssa = 11, wikidata = 11, as_of = 2026-10-21, note = "US SSA births summed over both sexes; Wikidata is a lower bound" } credential = { note = "NPPES 2 US healthcare providers write it (healthcare only, August 2026 file). Marked on C-i: the surname reading is substantial (Census 2010 or Wikidata).", issue = 796 } [[word]] text = "ed" family = { us_census_2010 = 262, wikidata = 7, as_of = 2026-10-25 } given = { us_ssa = 26370, wikidata = 2924, as_of = 2026-10-25, note = "US SSA births summed over both sexes; Wikidata is a lower bound; nam_dict band 6 in the Netherlands, 3 in U.S.A., 2 in Great Britain" } credential = { expands = "Doctor of Education" } [[word]] text = "edd" [word.credential] [word.credential.excluded] cased = { reason = "a mask applies in every role, and Edd is borne as a given name: 'edd smith' would render 'EdD Smith' and 'EDD KIMBER' 'EdD Kimber' (measured 2026-09-24)", issue = 459 } [[word]] text = "ei" family = { us_census_2010 = 127, wikidata = 3, as_of = 2026-10-21 } given = { wikidata = 4, as_of = 2026-10-21 } credential = { note = "NPPES 12 US healthcare providers write it (healthcare only, August 2026 file). Not a healthcare credential (usually Engineer Intern): NPPES says nothing about it; marked under C-i's default under uncertainty.", issue = 796 } [[word]] text = "eit" credential = {} [[word]] text = "els" family = { us_census_2010 = 362, wikidata = 9, as_of = 2026-10-21 } given = { wikidata = 322, as_of = 2026-10-21 } credential = { note = "NPPES 1 US healthcare provider writes it (healthcare only, August 2026 file). Marked on C-i: the surname reading is substantial (Census 2010 or Wikidata).", issue = 796 } [[word]] text = "emd" credential = {} [[word]] text = "emt-b" credential = {} [[word]] text = "emt-i/85" credential = {} [[word]] text = "emt-i/99" credential = {} [[word]] text = "emt-p" credential = {} [[word]] text = "enp" credential = {} [[word]] text = "erd" family = { us_census_2010 = 222, wikidata = 1, as_of = 2026-10-21 } credential = { note = "NPPES 0 US healthcare providers write it (healthcare only, August 2026 file). Marked on C-i: the surname reading is substantial (Census 2010 or Wikidata).", issue = 796 } [[word]] text = "evp" credential = {} [[word]] text = "faafp" credential = {} [[word]] text = "faan" credential = {} [[word]] text = "faap" credential = {} [[word]] text = "fac-c" credential = {} [[word]] text = "facc" credential = {} [[word]] text = "facd" credential = {} [[word]] text = "facem" credential = {} [[word]] text = "facep" credential = {} [[word]] text = "facha" credential = {} [[word]] text = "facofp" credential = {} [[word]] text = "facog" credential = {} [[word]] text = "facp" credential = {} [[word]] text = "facph" credential = {} [[word]] text = "facs" credential = {} [[word]] text = "faia" family = { us_census_2010 = 271, wikidata = 7, as_of = 2026-10-21 } credential = { note = "NPPES 0 US healthcare providers write it (healthcare only, August 2026 file). Marked on C-i: the surname reading is substantial (Census 2010 or Wikidata).", issue = 796 } [[word]] text = "faicp" credential = {} [[word]] text = "fala" credential = {} [[word]] text = "fashp" credential = {} [[word]] text = "fasid" credential = {} [[word]] text = "fasla" credential = {} [[word]] text = "fasma" credential = {} [[word]] text = "faspen" credential = {} [[word]] text = "fca" credential = {} [[word]] text = "fcas" credential = {} [[word]] text = "fcela" credential = {} [[word]] text = "fd" credential = {} [[word]] text = "fec" credential = {} [[word]] text = "fhames" credential = {} [[word]] text = "fic" family = { wikidata = 22, as_of = 2026-10-21 } credential = { note = "NPPES 1 US healthcare provider writes it (healthcare only, August 2026 file). Surname evidence is Wikidata only (no Census bearers); marked on C-i.", issue = 796 } [[word]] text = "ficf" credential = {} [[word]] text = "fieee" credential = {} [[word]] text = "fmp" credential = {} [[word]] text = "fmva" credential = {} [[word]] text = "fnp-bc" credential = { expands = "Family Nurse Practitioner, Board Certified", note = "NPPES 26,133 US healthcare providers (August 2026 file); 29 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21) write it; no Census 2010 (floor 100), SSA (floor 5 a year) or Wikidata bearer", issue = 797 } [[word]] text = "fnp-c" credential = { expands = "Family Nurse Practitioner, Certified", note = "NPPES 43,585 US healthcare providers (August 2026 file); 12 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21) write it; no Census 2010 (floor 100), SSA (floor 5 a year) or Wikidata bearer", issue = 797 } [[word]] text = "fnss" credential = {} [[word]] text = "fp&a" credential = {} [[word]] text = "fp-c" credential = {} [[word]] text = "fpc" credential = {} [[word]] text = "frm" credential = {} [[word]] text = "fsa" credential = {} [[word]] text = "fsdp" credential = {} [[word]] text = "fws" credential = {} [[word]] text = "gba" credential = {} [[word]] text = "gbe" credential = {} [[word]] text = "gc" [word.excluded.credential] reason = "G.C. (Gharti Chhetri) is a Nepali surname written as two dotted initials, ending 80 of 126,981 name pairs in Nepal's 2079 BS local election candidates, where GC as a credential (the George Cross among its expansions) is written by 239 of 4,824,544 NPPES providers and no ClinicalTrials.gov official after a comma: removed 2026-10-24 by the relative-frequency criterion (decisions/suffix-acronym-collisions). Unlisted, G.C. reads as the paired initials rules/S3 leaves to the name, and GC as any unlisted capitals" issue = 864 [[word]] text = "gcb" credential = {} [[word]] text = "gchs" credential = {} [[word]] text = "gcie" credential = {} [[word]] text = "gcmg" credential = {} [[word]] text = "gcsi" credential = {} [[word]] text = "gcvo" credential = {} [[word]] text = "gisp" credential = {} [[word]] text = "git" credential = {} [[word]] text = "gm" credential = {} [[word]] text = "gmb" credential = {} [[word]] text = "gmr" credential = {} [[word]] text = "gphr" credential = {} [[word]] text = "gri" credential = {} [[word]] text = "grp" credential = {} [[word]] text = "gsmieee" credential = {} [[word]] text = "hccp" credential = {} [[word]] text = "hrs" credential = {} [[word]] text = "iaccp" credential = {} [[word]] text = "iaee" credential = {} [[word]] text = "iccm-d" credential = {} [[word]] text = "iccm-f" credential = {} [[word]] text = "idsm" credential = {} [[word]] text = "ifgict" credential = {} [[word]] text = "iom" credential = {} [[word]] text = "ipep" credential = {} [[word]] text = "ipm" credential = {} [[word]] text = "iso" credential = {} [[word]] text = "issp-csp" credential = {} [[word]] text = "issp-sa" credential = {} [[word]] text = "itil" credential = {} [[word]] text = "jd" given = { us_ssa = 1449, as_of = 2026-10-25, note = "US SSA births summed over both sexes" } credential = { expands = "Juris Doctor" } [[word]] text = "jp" credential = {} [[word]] text = "kbe" credential = {} [[word]] text = "kcb" credential = {} [[word]] text = "kchs/dchs" credential = {} [[word]] text = "kcie" credential = {} [[word]] text = "kcmg" credential = {} [[word]] text = "kcsi" credential = {} [[word]] text = "kcvo" credential = {} [[word]] text = "kg" credential = {} [[word]] text = "khs/dhs" credential = {} [[word]] text = "knt" credential = { expands = "Knight", issue = 606, note = "the older spelling of kt", cased = "Knt" } [[word]] text = "kp" credential = {} [[word]] text = "kt" credential = { cased = "Kt" } [[word]] text = "lac" family = { us_census_2010 = 696, wikidata = 3, as_of = 2026-10-25 } [word.credential] expands = "Licensed Acupuncturist" issue = 540 note = "Lac is a Vietnamese given name (the trailing word in native order, Nguyen Van Lac) and a French surname" [word.credential.excluded] cased = { reason = "a mask applies in every role, and Lac is a Vietnamese given name and a French surname: 'tran lac' would render 'Tran LAc' (measured 2026-09-25, #540)", issue = 459 } [[word]] text = "lcmt" credential = {} [[word]] text = "lcpc" credential = {} [[word]] text = "lcsw" credential = {} [[word]] text = "lcsw-c" credential = { expands = "Licensed Certified Social Worker-Clinical", note = "NPPES 6,745 US healthcare providers (August 2026 file); 1 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21) write it; no Census 2010 (floor 100), SSA (floor 5 a year) or Wikidata bearer", issue = 797 } [[word]] text = "lcsw-r" credential = { note = "NPPES 3,023 US healthcare providers (August 2026 file); 0 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21) write it; no Census 2010 (floor 100), SSA (floor 5 a year) or Wikidata bearer", issue = 797 } [[word]] text = "ld" credential = { note = "NPPES 11,703 US healthcare providers (August 2026 file); 45 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21) write it. Not marked: 45 US SSA births (both sexes summed, 2026-10-21) against 11,703 providers, the credential reading far the larger (C-i, 2026-10-21 rule). The given count stands here rather than as a given reading, which would mark the word (decisions/vocabulary-model, 2026-10-25)", issue = 797 } [[word]] text = "lg" credential = {} [[word]] text = "lisw-s" credential = { expands = "Licensed Independent Social Worker, Supervisor", note = "NPPES 2,537 US healthcare providers (August 2026 file); 0 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21) write it; no Census 2010 (floor 100), SSA (floor 5 a year) or Wikidata bearer", issue = 797 } [[word]] text = "litk" credential = {} [[word]] text = "litl" credential = {} [[word]] text = "litp" credential = {} [[word]] text = "llm" credential = {} [[word]] text = "lm" credential = {} [[word]] text = "lmsw" credential = {} [[word]] text = "lmt" credential = {} [[word]] text = "lp" credential = {} [[word]] text = "lpa" credential = {} [[word]] text = "lpc" credential = {} [[word]] text = "lpn" credential = {} [[word]] text = "lpss" credential = {} [[word]] text = "lsi" credential = {} [[word]] text = "lsit" credential = {} [[word]] text = "lvn" credential = {} [[word]] text = "lvo" credential = {} [[word]] text = "lvt" credential = {} [[word]] text = "ma" family = { us_census_2010 = 28452, wikidata = 4996, as_of = 2026-10-25 } given = { us_ssa = 631, wikidata = 17, as_of = 2026-10-25, note = "female births only; Wikidata is a lower bound; nam_dict band 1 in the Netherlands, 1 in China, 1 in Korea, 1 in Vietnam" } credential = { expands = "Master of Arts" } [[word]] text = "maaa" credential = {} [[word]] text = "mai" [word.excluded.credential] reason = "Mai, a Vietnamese given and family name, a Chinese surname (麥) and a Japanese given name, dominates MAI (Member, Appraisal Institute): removed 2026-10-16 by the relative-frequency criterion (decisions/suffix-acronym-collisions)" issue = 687 [[word]] text = "mb" credential = { expands = "Bachelor of Medicine", note = "NPPES 596 US healthcare providers (August 2026 file); 314 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21) write it; no Census 2010 (floor 100), SSA (floor 5 a year) or Wikidata bearer", issue = 797 } [[word]] text = "mba" credential = {} [[word]] text = "mbbch" credential = { cased = "MBBCh", expands = "Bachelor of Medicine, Bachelor of Surgery", note = "NPPES 813 US healthcare providers (August 2026 file); 190 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21) write it; no Census 2010 (floor 100), SSA (floor 5 a year) or Wikidata bearer", issue = 797 } [[word]] text = "mbchb" credential = { cased = "MBChB", expands = "Bachelor of Medicine, Bachelor of Surgery", note = "NPPES 568 US healthcare providers (August 2026 file); 1,154 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21) write it; no Census 2010 (floor 100), SSA (floor 5 a year) or Wikidata bearer", issue = 797 } [[word]] text = "mbe" credential = {} [[word]] text = "mbs" credential = {} [[word]] text = "mc" [word.clan] may_be_given = false issue = 360 expands = "son of, Mac contracted" same_as = "mac" note = "Unlike 'mac' (Mac Miller) it is not a standalone given name. Measured misparse: \"Mc Donald\" gave given='Mc'. Also in SUFFIX_ACRONYMS as Master of Ceremonies, but that is trailing position and unaffected -- \"John Smith MC\" still reads suffix (#360)." [word.credential] note = "NOT marked as possibly a name (no name reading) though also a particle: #454 closed by design, neither mc nor vd is a borne name (rules/P6's Accepted clause)" [[word]] text = "mcct" credential = {} [[word]] text = "mcdba" credential = {} [[word]] text = "mches" credential = {} [[word]] text = "mcm" credential = {} [[word]] text = "mcp" credential = {} [[word]] text = "mcpd" credential = {} [[word]] text = "mcsa" credential = {} [[word]] text = "mcsd" credential = {} [[word]] text = "mcse" credential = {} [[word]] text = "mct" credential = {} [[word]] text = "md" credential = {} excluded = { academic = { reason = "a leading Md abbreviates Muhammad, a given name: 1,246 of 1,300 UK PSC filers with Md first in the forename box typed it there alone (2026-10-23), and a title reading took it from them", issue = 853 } } [[word]] text = "mdiv" credential = { cased = "MDiv" } [[word]] text = "mdphd" credential = { cased = "MDPhD", expands = "Doctor of Medicine and Doctor of Philosophy", note = "NPPES 61 US healthcare providers (August 2026 file); 129 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21) write it; no Census 2010 (floor 100), SSA (floor 5 a year) or Wikidata bearer", issue = 797 } [[word]] text = "mem" credential = {} [[word]] text = "meng" family = { us_census_2010 = 5729, wikidata = 1319, as_of = 2026-10-25 } given = { us_ssa = 568, wikidata = 23, as_of = 2026-10-25, note = "US SSA births summed over both sexes; Wikidata is a lower bound; nam_dict band 6 in China" } [word.credential] expands = "Master of Engineering" issue = 540 note = "Meng is a Chinese surname and given name" [word.credential.excluded] cased = { reason = "a mask applies in every role, and Meng is borne as a given name and a surname: 'MENG LI' would render 'MEng Li' and 'meng wang' 'MEng Wang' (measured 2026-09-24)", issue = 459 } [[word]] text = "mfa" credential = {} [[word]] text = "mhsc" credential = { cased = "MHSc", expands = "Master of Health Science", note = "NPPES 80 US healthcare providers (August 2026 file); 104 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21) write it; no Census 2010 (floor 100), SSA (floor 5 a year) or Wikidata bearer", issue = 797 } [[word]] text = "micp" credential = {} [[word]] text = "mieee" credential = {} [[word]] text = "mirm" credential = {} [[word]] text = "mle" credential = {} [[word]] text = "mls" credential = {} [[word]] text = "mlse" credential = {} [[word]] text = "mlt" credential = {} [[word]] text = "mm" credential = {} [[word]] text = "mmad" credential = {} [[word]] text = "mmas" credential = {} [[word]] text = "mmed" credential = { cased = "MMed", expands = "Master of Medicine", note = "NPPES 25 US healthcare providers (August 2026 file); 189 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21) write it; no Census 2010 (floor 100), SSA (floor 5 a year) or Wikidata bearer", issue = 797 } [[word]] text = "mmsc" credential = { cased = "MMSc", expands = "Master of Medical Science", note = "NPPES 432 US healthcare providers (August 2026 file); 111 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21) write it; no Census 2010 (floor 100), SSA (floor 5 a year) or Wikidata bearer", issue = 797 } [[word]] text = "mnaa" credential = {} [[word]] text = "mnae" credential = {} [[word]] text = "mp" credential = {} [[word]] text = "mpa" credential = {} [[word]] text = "mph" credential = {} [[word]] text = "mphil" credential = { cased = "MPhil", expands = "Master of Philosophy", note = "NPPES 153 US healthcare providers (August 2026 file); 110 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21) write it; no Census 2010 (floor 100), SSA (floor 5 a year) or Wikidata bearer", issue = 797 } [[word]] text = "mpse" credential = {} [[word]] text = "mra" credential = {} [[word]] text = "msa" credential = {} [[word]] text = "msc" credential = { cased = "MSc" } [[word]] text = "mscmsm" credential = {} [[word]] text = "msm" credential = {} [[word]] text = "mt" credential = {} [[word]] text = "mt-bc" credential = { expands = "Music Therapist, Board Certified", note = "NPPES 2,984 US healthcare providers (August 2026 file); 29 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21) write it; no Census 2010 (floor 100), SSA (floor 5 a year) or Wikidata bearer", issue = 797 } [[word]] text = "mts" credential = {} [[word]] text = "mvo" credential = {} [[word]] text = "nbc-his" credential = {} [[word]] text = "nbcch" credential = {} [[word]] text = "nbcch-ps" credential = {} [[word]] text = "nbcdch" credential = {} [[word]] text = "nbcdch-ps" credential = {} [[word]] text = "nbcfch" credential = {} [[word]] text = "nbcfch-ps" credential = {} [[word]] text = "nbct" credential = {} [[word]] text = "ncarb" credential = {} [[word]] text = "nccp" credential = {} [[word]] text = "ncidq" credential = {} [[word]] text = "ncps" credential = {} [[word]] text = "ncso" credential = {} [[word]] text = "ncto" credential = {} [[word]] text = "nd" credential = {} [[word]] text = "ndtr" credential = {} [[word]] text = "nmd" credential = {} [[word]] text = "np" credential = {} [[word]] text = "np-c" credential = { expands = "Nurse Practitioner, Certified", note = "NPPES 15,578 US healthcare providers (August 2026 file); 5 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21) write it; no Census 2010 (floor 100), SSA (floor 5 a year) or Wikidata bearer", issue = 797 } [[word]] text = "nraemt" credential = {} [[word]] text = "nremr" credential = {} [[word]] text = "nremt" credential = {} [[word]] text = "nrp" credential = {} [[word]] text = "obe" credential = {} [[word]] text = "obi" family = { us_census_2010 = 1403, wikidata = 28, as_of = 2026-10-21 } given = { us_ssa = 299, wikidata = 17, as_of = 2026-10-21, note = "US SSA births summed over both sexes; Wikidata is a lower bound" } credential = { note = "NPPES 0 US healthcare providers write it (healthcare only, August 2026 file). Marked on C-i: the surname reading is substantial (Census 2010 or Wikidata).", issue = 796 } [[word]] text = "oca" family = { us_census_2010 = 344, wikidata = 2, as_of = 2026-10-21 } given = { us_ssa = 16, as_of = 2026-10-21, note = "US SSA births summed over both sexes; Wikidata is a lower bound" } credential = { note = "NPPES 0 US healthcare providers write it (healthcare only, August 2026 file). Marked on C-i: the surname reading is substantial (Census 2010 or Wikidata).", issue = 796 } [[word]] text = "ocm" credential = {} [[word]] text = "ocp" credential = {} [[word]] text = "od" credential = {} [[word]] text = "om" family = { us_census_2010 = 784, wikidata = 135, as_of = 2026-10-21 } given = { us_ssa = 2321, wikidata = 10, as_of = 2026-10-21, note = "US SSA births summed over both sexes; Wikidata is a lower bound" } credential = { note = "NPPES 66 US healthcare providers write it (healthcare only, August 2026 file). Marked on C-i: the surname reading is substantial (Census 2010 or Wikidata).", issue = 796 } [[word]] text = "oscp" credential = {} [[word]] text = "ot" credential = {} [[word]] text = "otr/l" credential = { expands = "Occupational Therapist Registered, Licensed", note = "NPPES 61,789 US healthcare providers (August 2026 file); 103 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21) write it; no Census 2010 (floor 100), SSA (floor 5 a year) or Wikidata bearer", issue = 797 } [[word]] text = "pa" family = { us_census_2010 = 739, as_of = 2026-10-21 } given = { us_ssa = 1343, wikidata = 36, as_of = 2026-10-21, note = "US SSA births summed over both sexes, almost all female; Wikidata is a lower bound" } credential = { expands = "Physician Assistant", note = "NPPES 54,128 US healthcare providers (August 2026 file); 23 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21) write it. Marked on C-i: the surname reading is substantial (Census 2010 bearers 739, over the 200 threshold)", issue = 797 } [[word]] text = "pa-c" credential = {} [[word]] text = "pcc" credential = {} [[word]] text = "pci" credential = {} [[word]] text = "pe" family = { us_census_2010 = 582, wikidata = 305, as_of = 2026-10-21 } credential = { note = "NPPES 44 US healthcare providers write it (healthcare only, August 2026 file). Marked on C-i: the surname reading is substantial (Census 2010 or Wikidata).", issue = 796 } [[word]] text = "pfmp" credential = {} [[word]] text = "pg" credential = {} [[word]] text = "pgmp" credential = {} [[word]] text = "pharmd" credential = { cased = "PharmD" } [[word]] text = "phc" credential = { cased = "PhC" } [[word]] text = "phd" credential = { expands = "Doctor of Philosophy", cased = "PhD" } [[word]] text = "phr" credential = {} [[word]] text = "phrca" credential = {} [[word]] text = "pi" family = { us_census_2010 = 1006, wikidata = 634, as_of = 2026-10-21, note = "Wikidata is a lower bound" } [word.excluded.credential] reason = "PI is Principal Investigator, a role and not a credential (102 ClinicalTrials.gov study officials write it after a comma, data 2026-10-21; 8 NPPES providers), and Pi is a surname (Census 2010 1,006 bearers, Wikidata 634 family): held out of #797 by the relative-frequency criterion (decisions/suffix-acronym-collisions)" issue = 797 [[word]] text = "pla" credential = {} [[word]] text = "pls" credential = {} [[word]] text = "pmc" credential = {} [[word]] text = "pmhnp-bc" credential = { expands = "Psychiatric-Mental Health Nurse Practitioner, Board Certified", note = "NPPES 13,952 US healthcare providers (August 2026 file); 2 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21) write it; no Census 2010 (floor 100), SSA (floor 5 a year) or Wikidata bearer", issue = 797 } [[word]] text = "pmi-acp" credential = {} [[word]] text = "pmp" credential = {} [[word]] text = "pp" credential = {} [[word]] text = "pps" credential = {} [[word]] text = "prm" credential = {} [[word]] text = "psm" credential = {} [[word]] text = "psp" credential = {} [[word]] text = "psyd" credential = { cased = "PsyD" } [[word]] text = "pt" credential = {} [[word]] text = "pta" credential = {} [[word]] text = "qam" credential = {} [[word]] text = "qc" credential = {} [[word]] text = "qcsw" credential = {} [[word]] text = "qfsm" credential = {} [[word]] text = "qgm" credential = {} [[word]] text = "qpm" credential = {} [[word]] text = "qsd" credential = {} [[word]] text = "qsp" credential = {} [[word]] text = "ra" credential = {} [[word]] text = "rai" [word.excluded.credential] reason = "Rai, a common surname across Hindi- and Bengali-speaking regions, dominates RAI (RETA Authorized Instructor): removed 2026-09-07 by the relative-frequency criterion (decisions/suffix-acronym-collisions)" issue = 342 [[word]] text = "rba" credential = {} [[word]] text = "rci" credential = {} [[word]] text = "rcp" credential = {} [[word]] text = "rd" credential = {} [[word]] text = "rdcs" credential = {} [[word]] text = "rdh" credential = {} [[word]] text = "rdms" credential = {} [[word]] text = "rdn" credential = {} [[word]] text = "res" credential = {} [[word]] text = "rfp" credential = {} [[word]] text = "rhca" credential = {} [[word]] text = "rid" credential = {} [[word]] text = "rls" credential = {} [[word]] text = "rmsks" credential = {} [[word]] text = "rn" credential = {} [[word]] text = "rp" credential = {} [[word]] text = "rpa" credential = {} [[word]] text = "rpa-c" credential = { expands = "Registered Physician Assistant, Certified", note = "NPPES 2,162 US healthcare providers (August 2026 file); 1 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21) write it; no Census 2010 (floor 100), SSA (floor 5 a year) or Wikidata bearer", issue = 797 } [[word]] text = "rph" credential = { cased = "RPh" } [[word]] text = "rpl" credential = {} [[word]] text = "rrc" credential = {} [[word]] text = "rrt" credential = {} [[word]] text = "rrt-accs" credential = {} [[word]] text = "rrt-nps" credential = {} [[word]] text = "rrt-sds" credential = {} [[word]] text = "rtrp" credential = {} [[word]] text = "rvm" credential = {} [[word]] text = "rvt" credential = {} [[word]] text = "same" credential = {} [[word]] text = "sasm" credential = {} [[word]] text = "sccp" credential = {} [[word]] text = "scd" credential = { cased = "ScD", expands = "Doctor of Science", note = "NPPES 145 US healthcare providers (August 2026 file); 593 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21) write it; no Census 2010 (floor 100), SSA (floor 5 a year) or Wikidata bearer", issue = 797 } [[word]] text = "scm" credential = { cased = "ScM", expands = "Master of Science", note = "NPPES 108 US healthcare providers (August 2026 file); 102 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21) write it; no Census 2010 (floor 100), SSA (floor 5 a year) or Wikidata bearer", issue = 797 } [[word]] text = "scmp" credential = {} [[word]] text = "se" family = { us_census_2010 = 384, wikidata = 1, as_of = 2026-10-21 } given = { us_ssa = 18, wikidata = 1, as_of = 2026-10-21, note = "US SSA births summed over both sexes; Wikidata is a lower bound" } credential = { note = "NPPES 18 US healthcare providers write it (healthcare only, August 2026 file). Marked on C-i: the surname reading is substantial (Census 2010 or Wikidata).", issue = 796 } [[word]] text = "secb" credential = {} [[word]] text = "sfp" credential = {} [[word]] text = "shrm-cp" credential = {} [[word]] text = "shrm-scp" credential = {} [[word]] text = "si" family = { us_census_2010 = 1166, wikidata = 275, as_of = 2026-10-21 } given = { us_ssa = 185, wikidata = 124, as_of = 2026-10-21, note = "US SSA births summed over both sexes; Wikidata is a lower bound" } [word.credential] issue = 791 note = "Si is a common surname (Census 2010: 1,166 family-name bearers; Wikidata 275+ family) and a Vietnamese and Chinese given name; measured 2026-10-21 by tools/name_usage.py. No expansion is recorded: the 2019 bulk import (af5bdab, #93) carried none" [[word]] text = "siie" credential = {} [[word]] text = "smieee" credential = {} [[word]] text = "sphr" credential = {} [[word]] text = "sscp" credential = {} [[word]] text = "stmieee" credential = {} [[word]] text = "tbr-ct" credential = {} [[word]] text = "td" credential = {} [[word]] text = "thd" credential = { cased = "ThD" } [[word]] text = "thm" credential = { cased = "ThM" } [[word]] text = "ud" credential = {} [[word]] text = "usa" credential = {} [[word]] text = "usaf" credential = {} [[word]] text = "usar" credential = {} [[word]] text = "uscg" credential = {} [[word]] text = "usmc" credential = {} [[word]] text = "usn" credential = {} [[word]] text = "usnr" credential = {} [[word]] text = "uxc" credential = {} [[word]] text = "uxmc" credential = {} [[word]] text = "vcp" credential = {} [[word]] text = "vd" [word.credential] note = "NOT marked as possibly a name (no name reading) though also a particle: #454 closed by design, neither mc nor vd is a borne name (rules/P6's Accepted clause)" [[word]] text = "vrd" credential = {} [[word]] text = "whnp-bc" credential = { expands = "Women's Health Nurse Practitioner, Board Certified", note = "NPPES 2,246 US healthcare providers (August 2026 file); 2 ClinicalTrials.gov study officials after a comma, as typed (data 2026-10-21) write it; no Census 2010 (floor 100), SSA (floor 5 a year) or Wikidata bearer", issue = 797 } [[word]] text = "ret" [word.status] issue = 111 note = "bare, not '(ret)': a parenthesized entry would match literally instead of going through nickname/suffix disambiguation, the #111 bug" [[word]] text = "vet" status = { issue = 111, note = "bare, not '(vet)', for the reason recorded on ret" } [[word]] text = "esq" courtesy = {} [[word]] text = "esquire" courtesy = { same_as = "esq" } [[word]] text = "mac" given = { us_ssa = 11455, wikidata = 178, as_of = 2026-10-25, note = "US SSA births summed over both sexes; Wikidata is a lower bound; nam_dict band 1 in Great Britain" } [word.clan] expands = "son of" note = "Mac Miller: a real given name. This is the case that shows #360's vowel heuristic was not the criterion -- 'mac' and 'mc' differ by attestation, not by shape" [[word]] text = "and" conjunction = {} [[word]] text = "of" conjunction = {} [[word]] text = "the" conjunction = {} [[word]] text = "né" birth_marker = { gender = "m" } [[word]] text = "née" birth_marker = { gender = "f" } [[word]] text = "nee" birth_marker = { issue = 274, same_as = "née", gender = "f", note = "née typed without its accent" } [[word]] text = "benj" given = { wikidata = 2, as_of = 2026-10-26, same_as = "benjamin", note = "Benjamin written short; no US SSA year reaches 5 births; Wikidata is a lower bound" } [[word]] text = "chas" given = { us_ssa = 3223, wikidata = 43, as_of = 2026-10-26, same_as = "charles", note = "Charles written short; US SSA births summed over both sexes (F 10, M 3,213); nam_dict band 1 in the U.S.A.; Wikidata is a lower bound" } [[word]] text = "edw" given = { us_ssa = 6, as_of = 2026-10-26, same_as = "edward", note = "Edward written short; US SSA male births only" } [[word]] text = "geo" given = { us_ssa = 1324, wikidata = 47, as_of = 2026-10-26, same_as = "george", note = "George written short; US SSA births summed over both sexes (F 16, M 1,308); also a given name of its own (Wikidata: Romania 8, Belgium 6, Italy 5; nam_dict band 1 in Italy, the Netherlands and Switzerland), a given name on either reading; Wikidata is a lower bound" } [[word]] text = "jas" given = { us_ssa = 124, wikidata = 1, as_of = 2026-10-26, same_as = "james", note = "James written short; US SSA births summed over both sexes (F 13, M 111); also a Punjabi given name (nam_dict band 2 in India/Sri Lanka), and a surname (US Census 2010 117 bearers, Wikidata 25), which a period-marked word opening the given part is not; Wikidata is a lower bound" } [[word]] text = "jno" given = { us_ssa = 11, as_of = 2026-10-26, same_as = "john", note = "John written short; US SSA male births only" } [[word]] text = "richd" given = { same_as = "richard", note = "Richard written short; no US SSA year reaches 5 births and Wikidata labels no one Richd: declared as the traditional English abbreviation, beside its set (#789)" } [[word]] text = "robt" given = { us_ssa = 468, as_of = 2026-10-26, same_as = "robert", note = "Robert written short; US SSA male births only" } [[word]] text = "saml" given = { same_as = "samuel", note = "Samuel written short; no US SSA year reaches 5 births and Wikidata labels no one Saml: declared as the traditional English abbreviation, beside its set (#789)" } [[word]] text = "thos" given = { us_ssa = 166, as_of = 2026-10-26, same_as = "thomas", note = "Thomas written short; US SSA male births only; Wikidata labels no one Thos as a given name" } derek73-python-nameparser-456b024/nameparser/vocab/es.toml000066400000000000000000000104551527303271700234470ustar00rootroot00000000000000schema = 1 [[word]] text = "señor" salutation = { gender = "m", excluded = { after_given = "spelled SENOR, a surname (Census 2010), and middle names are drawn from surnames" } } family = { us_census_2010 = 282, wikidata = 2, as_of = 2026-10-24, note = "the Census count is of SENOR, which writes no diacritics" } [[word]] text = "sr" salutation = { gender = "m", same_as = "señor" } [[word]] text = "señora" salutation = { gender = "f", excluded = { after_given = "a given name (US SSA, as Senora), borne as a middle name" }, note = "borne as a given name: US SSA 1,065 births (2026-10-24); counted as SENORA, the SSA writing no diacritics; kept here, not as a `given` reading, which would make the title one only inside a title phrase (rules/H9)" } [[word]] text = "sra" salutation = { gender = "f", same_as = "señora", after_given = true, note = "borne as a given name: US SSA 27 births (2026-10-24); kept here, not as a `given` reading, which would make the title one only inside a title phrase (rules/H9)" } family = { us_census_2010 = 149, as_of = 2026-10-24, note = "kept for after_given (decisions/H8)" } [[word]] text = "señores" salutation = { expands = "the plural of señor", after_given = true } [[word]] text = "sres" salutation = { same_as = "señores", after_given = true } [[word]] text = "señorita" salutation = { gender = "f", after_given = true, note = "borne as a given name: US SSA 6 births (2026-10-24); counted as SENORITA; kept for after_given (decisions/H8); kept here, not as a `given` reading, which would make the title one only inside a title phrase (rules/H9)" } [[word]] text = "srta" salutation = { gender = "f", same_as = "señorita", after_given = true } [[word]] text = "señoritas" salutation = { gender = "f", after_given = true } [[word]] text = "srtas" salutation = { gender = "f", same_as = "señoritas", after_given = true } [[word]] text = "dra" academic = { gender = "f", expands = "Doctora, Doutora" } [[word]] text = "h" generation = { default = false, expands = "hijo, the son: Juan Pérez H.", issue = 752, note = "written with its period, it is exactly how an initial is written (rules/T5), so no default reads it; a Spanish pack turns it on" } [[word]] text = "de" preposition = { may_be_given = false, expands = "of" } [[word]] text = "del" given = { us_ssa = 4970, wikidata = 150, as_of = 2026-10-25, note = "US SSA births summed over both sexes; Wikidata is a lower bound; nam_dict band 1 in Great Britain, 1 in U.S.A." } [word.preposition] expands = "of the" note = "BOTH readings attested: Del Shannon (given name) and del Toro (particle). The ambiguity flag is the answer" [[word]] text = "la" given = { us_ssa = 1756, wikidata = 37, as_of = 2026-10-25, note = "US SSA births summed over both sexes; Wikidata is a lower bound" } [word.article] expands = "the, fem. sg." note = "La Shawn, La Toya: a given-name element. The Romance article is the particle reading; its PLURALS ('las', 'los') are never-given, being no one's name" [[word]] text = "las" [word.article] may_be_given = false issue = 360 expands = "the, fem. pl." note = "Added #360: 'la' was here as AMBIGUOUS (La Shawn, La Toya) but the plurals are not name elements -- no bearer in any position." [[word]] text = "los" [word.article] may_be_given = false issue = [360, 390] expands = "the, masc. pl." note = "Same as 'las' -- and the pair is why the #390 fold narrowing regressed \"de los Santos\" to given='Santos', family='de los'." [[word]] text = "san" given = { us_ssa = 2502, wikidata = 54, as_of = 2026-10-25, note = "US SSA births summed over both sexes; Wikidata is a lower bound; nam_dict band 5 in Korea" } [word.saint] expands = "Saint: \"San Martín\"" note = "San San Chiou: a given-name element in Burmese and Chinese naming" [[word]] text = "santa" given = { us_ssa = 4410, wikidata = 38, as_of = 2026-10-25, note = "US SSA births summed over both sexes; Wikidata is a lower bound; nam_dict band 5 in Italy, 5 in Latvia, 1 in Swiss, 1 in Bulgaria" } saint = { expands = "Saint, fem." } [[word]] text = "y" [word.conjunction] [word.conjunction.excluded] may_be_initial = { reason = "the commonest Hispanic double-surname link and this library's oldest fixture, 'Velasquez y Garcia': marking it would read the link of every one-case Hispanic record as a middle initial; a caller who wants the Dutch reading adds it", issue = [383, 479] } derek73-python-nameparser-456b024/nameparser/vocab/et.toml000066400000000000000000000012741527303271700234470ustar00rootroot00000000000000schema = 1 [[word]] text = "härra" salutation = { issue = 606, gender = "m", note = "keeps its accent, so the surname Harra (~1,100) is untouched; the lexicon folds no accent (decisions/salutation-titles)", excluded = { after_given = "spelled HARRA, a surname (Census 2010), and middle names are drawn from surnames" } } family = { us_census_2010 = 237, as_of = 2026-10-24, note = "counted as HARRA, the Census writing no diacritics" } [[word]] text = "preili" salutation = { issue = 606, gender = "f", after_given = true } [[word]] text = "proua" salutation = { issue = 606, gender = "f", after_given = true } [[word]] text = "krahv" nobility = { gender = "m", expands = "count", issue = 606 } derek73-python-nameparser-456b024/nameparser/vocab/fa.toml000066400000000000000000000012511527303271700234200ustar00rootroot00000000000000schema = 1 [[word]] text = "akhoond" religious = { expands = "akhund, a Shia cleric" } [[word]] text = "ayatollah" religious = {} [[word]] text = "mullah" religious = {} [[word]] text = "pir" religious = { expands = "a Sufi master" } [[word]] text = "جونیور" generation = { issue = 679 } [[word]] text = "سینیور" generation = { issue = 679 } [[word]] text = "پدر" [word.excluded.generation] reason = "\"father\" without the article that marks ar الابن and he הבן as never a name" issue = 679 [[word]] text = "پسر" [word.excluded.generation] reason = "\"son\" without the article that marks ar الابن and he הבן as never a name" issue = 679 derek73-python-nameparser-456b024/nameparser/vocab/ff.toml000066400000000000000000000001161527303271700234240ustar00rootroot00000000000000schema = 1 [[word]] text = "lamido" nobility = { expands = "a Fulani emir" } derek73-python-nameparser-456b024/nameparser/vocab/fi.toml000066400000000000000000000020521527303271700234300ustar00rootroot00000000000000schema = 1 [[word]] text = "herra" salutation = { issue = 606, gender = "m", note = "borne: ~3,000 surnames (Costa Rica, Dominican Republic) and ~670 given-name uses (Indonesia, Philippines), forebears.io, worldwide, rounded, 2026-10-04; shipped under the graf class (decisions/salutation-titles), so 'Herra Wijaya' reads title Herra", excluded = { after_given = "a surname (Census 2010 HERRA), and middle names are drawn from surnames" } } family = { us_census_2010 = 638, as_of = 2026-10-24 } [[word]] text = "neiti" salutation = { issue = 606, gender = "f", after_given = true } [[word]] text = "rouva" salutation = { issue = 606, gender = "f", after_given = true } [[word]] text = "kreivi" nobility = { gender = "m", expands = "count", issue = 606, note = "borne: ~270 surnames (Finland, where registers often write the surname first), forebears.io, worldwide, rounded, 2026-10-04; shipped under the graf class (decisions/salutation-titles)" } [[word]] text = "nuorempi" generation = { issue = 661 } [[word]] text = "vanhempi" generation = { issue = 661 } derek73-python-nameparser-456b024/nameparser/vocab/fil.toml000066400000000000000000000036761527303271700236210ustar00rootroot00000000000000schema = 1 [[word]] text = "binibini" salutation = { issue = 606, gender = "f", note = "borne: ~500 surnames, in Philippine lists writing Surname, Given with a comma, forebears.io, worldwide, rounded, 2026-10-04; shipped under the graf class (decisions/salutation-titles)", after_given = true } [[word]] text = "ginang" salutation = { issue = 606, gender = "f", note = "borne: ~500 surnames, in Philippine lists writing Surname, Given with a comma, forebears.io, worldwide, rounded, 2026-10-04; shipped under the graf class (decisions/salutation-titles)", after_given = true } [[word]] text = "ginoo" salutation = { issue = 606, gender = "m", note = "borne: ~500 surnames, in Philippine lists writing Surname, Given with a comma, forebears.io, worldwide, rounded, 2026-10-04; shipped under the graf class (decisions/salutation-titles)", after_given = true } [[word]] text = "dela" given = { us_ssa = 565, as_of = 2026-10-25, note = "US SSA births summed over both sexes; nam_dict band 1 in Germany, 1 in Bulgaria" } [word.preposition] expands = "of the, de la contracted: \"Dela Cruz\"" note = "Dela is a short form of Adela, and an African name meaning saviour/redeemer -- so the Filipino surname particle is not the only reading" [[word]] text = "jo" given = { us_ssa = 180911, wikidata = 974, as_of = 2026-10-26, same_as = "josé", note = "José written short, in Philippine usage; the counts are of Jo, a given name of its own (US SSA births summed over both sexes, F 179,057, M 1,854; Wikidata a lower bound) and a Korean surname, so a period-marked Jo. opening the given part is a given name on every reading (#789)" } [[word]] text = "ma" given = { same_as = "maría", note = "María written short, in Philippine usage; no source counts the abbreviation apart from Ma, whose given, family and credential readings are en.toml's; it acts only where the leading period-abbreviation shape rule would have read a title, a period-marked word opening the given part (#789)" } derek73-python-nameparser-456b024/nameparser/vocab/fr.toml000066400000000000000000000114731527303271700234500ustar00rootroot00000000000000schema = 1 [[word]] text = "madame" salutation = { gender = "f", after_given = true } [[word]] text = "mme" salutation = { gender = "f", same_as = "madame", after_given = true } [[word]] text = "mademoiselle" salutation = { gender = "f", after_given = true } [[word]] text = "mlle" salutation = { gender = "f", same_as = "mademoiselle", after_given = true } [[word]] text = "monsieur" salutation = { gender = "m", after_given = true } [[word]] text = "cheikh" style = { addresses = "given", gender = "m", same_as = "sheikh", expands = "the French transliteration, 'Cheikh Anta Diop'", issue = 752, decided = 2026-10-27, note = "a title only inside a title phrase, its given reading the clash (rules/H9): 'Cheikh Anta Diop' reads the given name" } given = { us_ssa = 512, wikidata = 51, as_of = 2026-10-27, note = "Wikidata is a lower bound (Senegal 34, France 9); a common Senegalese given name; UK PSC filers put it first in the forename box 14 times and never in the title box (2026-10-27)" } family = { us_census_2010 = 112, wikidata = 14, as_of = 2026-10-27 } [[word]] text = "me" academic = { expands = "Maître, the address of a lawyer or notary" } [[word]] text = "mgr" religious = { expands = "Monsignor, Monseigneur" } [[word]] text = "monseigneur" religious = {} [[word]] text = "saint" religious = {} [[word]] text = "sainte" religious = { gender = "f" } [[word]] text = "st" religious = { expands = "Saint, abbreviated" } saint = { expands = "Saint, abbreviated" } [[word]] text = "baron" nobility = { gender = "m" } [[word]] text = "comtesse" nobility = { gender = "f" } [[word]] text = "duchesse" nobility = { gender = "f" } [[word]] text = "marquis" nobility = { gender = "m" } [[word]] text = "marquise" nobility = { gender = "f" } [[word]] text = "seigneur" nobility = {} [[word]] text = "attaché" office = {} [[word]] text = "chargé" office = { expands = "chargé d'affaires, the head of a mission below an ambassador" } [[word]] text = "d'affaires" office = { expands = "the second word of chargé d'affaires", issue = 752, note = "office rather than modifier since 2026-10-25: it stands behind its head, where decision 10's modifier rule asks for a head title after a modifier (rules/H9)" } [[word]] text = "compositeur" occupation = {} [[word]] text = "historien" occupation = {} [[word]] text = "fils" generation = { issue = [679, 752], expands = "son, the Younger: Alexandre Dumas fils", note = "gated by the family reading (rules/S11): Title case leans to the name, so Marie Fils keeps her family name, and lower case is the generation wherever the name keeps a family word" } family = { us_census_2010 = 767, wikidata = 5, as_of = 2026-10-16 } [[word]] text = "père" [word.excluded] generation = { reason = "pack-shaped: collides outside French", issue = [679, 684] } [[word]] text = "d'" [word.preposition] may_be_given = false issue = 700 expands = "of, elided de" note = "Written apart mostly where an index form moves it behind the given name (\"Ivoi, Paul d'\"); glued (\"d'Ivoi\") it stays one word." [[word]] text = "d’" [word.preposition] may_be_given = false issue = 700 expands = "the same, typographic apostrophe" same_as = "d'" [[word]] text = "de" [word.preposition] may_be_given = false expands = "of" note = "\"De\" IS a borne Bengali and Odia surname -- trailing, so it never reaches this rule. The case that forced C-i's positional qualifier." [[word]] text = "du" given = { us_ssa = 27, wikidata = 12, as_of = 2026-10-25, note = "male births only; Wikidata is a lower bound; nam_dict band 5 in Vietnam, 4 in Korea, 1 in China" } [word.preposition] expands = "of the, de le contracted" note = "Du is a Chinese surname (Du Fu), leading under a family-first reading" [[word]] text = "l'" article = { may_be_given = false, expands = "the, elided: \"L'Enfant\"" } [[word]] text = "l’" [word.article] may_be_given = false issue = 700 expands = "the same, typographic apostrophe" same_as = "l'" [[word]] text = "la" article = { expands = "the, fem. sg." } [[word]] text = "le" given = { us_ssa = 1653, wikidata = 72, as_of = 2026-10-25, note = "US SSA births summed over both sexes; Wikidata is a lower bound; nam_dict band 9 in Vietnam, 4 in China" } [word.article] expands = "the, masc. sg." note = "Lê is among the most common Vietnamese surnames and leads under a family-first reading" [[word]] text = "ste" [word.saint] may_be_given = false issue = 360 expands = "Sainte, contracted" note = "Has a vowel, which is why the criterion is \"abbreviation of a word that is never itself a name\" rather than the vowel shape #360 first proposed. Measured misparse: \"Ste Marie\" gave given='Ste'." [[word]] text = "et" conjunction = {} [[word]] text = "né" birth_marker = { issue = 274, gender = "m" } [[word]] text = "née" birth_marker = { issue = 274, gender = "f" } [[word]] text = "nee" birth_marker = { same_as = "née", gender = "f" } derek73-python-nameparser-456b024/nameparser/vocab/ga.toml000066400000000000000000000047011527303271700234240ustar00rootroot00000000000000schema = 1 [[word]] text = "sinsearach" [word.excluded.generation] reason = "a rank word, not a generation; Irish writes Óg and Mór" issue = 661 [[word]] text = "sóisearach" [word.excluded.generation] reason = "a rank word, not a generation; Irish writes Óg and Mór" issue = 661 [[word]] text = "mac" clan = { expands = "son of" } [[word]] text = "mc" clan = { may_be_given = false, issue = 360, expands = "son of, Mac contracted", same_as = "mac" } [[word]] text = "nic" [word.excluded.clan] reason = "the Irish \"daughter of\" beside 'ní', but also an abbreviation of Nicolas and Nicole and a given name of its own (Nic Cage): a leading Nic would be marked ambiguous at best, and its home is an opt-in Irish pack" issue = 604 [[word]] text = "ní" [word.clan] may_be_given = false issue = 604 expands = "daughter of: \"Sinéad Ní Mhurchú\"" gender = "f" cased = "Ní" note = "The female form beside 'ó'; never a given name (#604). Its sibling 'nic' is excluded, in this file. The mask is the word's own title case: Irish writes these capitalized, and a mask outranks the particle lowercase (rules/R4), so the dotted 'n.í.' repairs to 'N.Í.'." [[word]] text = "o" [word.excluded.clan] reason = "bare O is a common periodless initial, so a particle reading would turn the middle initial of 'Liam O Murchú', and of every 'John O Smith', into a surname; the anglicized form is glued (O'Neil) and needs no particle" issue = 604 [[word]] text = "ó" [word.clan] may_be_given = false issue = 604 expands = "descendant of: \"Liam Ó Murchú\"" gender = "m" cased = "Ó" note = "Never a given name (#604). The one ONE-LETTER particle, so the letter written with its period is still the initial it spells -- 'Juan Ó. Pérez' is Óscar, not a surname (rules/P7). Unaccented 'o' is excluded, in this file. The mask is the word's own title case, as on 'ní'." [[word]] text = "ua" given = { note = "C-i's default under uncertainty, as the particle's note says; no source counts it as a given name (2026-10-25)" } [word.clan] issue = 604 expands = "descendant of, the older spelling: \"Seán Ua Buachalla\"" same_as = "ó" cased = "Ua" note = "No given-name use found, but searched less thoroughly than 'ó' and 'ní', and C-i defaults to AMBIGUOUS under uncertainty -- so the leading \"Ua Buachalla\" keeps the given reading and reports it, while mid-name the chain is the same either way (#604). The mask is the word's own title case, as on 'ní': the dotted 'u.a.' repairs to 'U.A.'" derek73-python-nameparser-456b024/nameparser/vocab/gd.toml000066400000000000000000000002701527303271700234240ustar00rootroot00000000000000schema = 1 [[word]] text = "mac" clan = { expands = "son of" } [[word]] text = "mc" clan = { may_be_given = false, issue = 360, expands = "son of, Mac contracted", same_as = "mac" } derek73-python-nameparser-456b024/nameparser/vocab/gu.toml000066400000000000000000000003241527303271700234450ustar00rootroot00000000000000schema = 1 [[word]] text = "જુનિયર" generation = { issue = 679 } [[word]] text = "જૂનિયર" generation = { issue = 679 } [[word]] text = "સિનિયર" generation = { issue = 679 } derek73-python-nameparser-456b024/nameparser/vocab/ha.toml000066400000000000000000000022521527303271700234240ustar00rootroot00000000000000schema = 1 [[word]] text = "malam" given = { wikidata = 1, as_of = 2026-10-17, note = "Nigeria; a lower bound" } family = { us_census_2010 = 119, wikidata = 5, as_of = 2026-10-17 } excluded = { salutation = { reason = "Hausa teacher or Mr.: a decline on the counts, for the lower-bound given-name use, though CLDR's Hausa data files Malam as the title; whoever re-argues it counts Hausa given-name use first. The accepted cost is 'Malam Shehu Bello' reading given Malam", issue = 662, decided = 2026-10-17 } } [[word]] text = "shehu" given = { wikidata = 1, as_of = 2026-10-13, note = "Nigeria; a lower bound, Shagari's own entry having no given-name statement" } family = { us_census_2010 = 561, wikidata = 180, as_of = 2026-10-13, note = "Wikidata: Nigeria 20, Albania 11" } excluded = { style = { reason = "a common Hausa given name: the leading title claim took the given name of Shehu Shagari, who read title Shehu in every release from 1.4.0 through 2.4.1; CLDR's Hausa renderings file it as the given name. Removed 2026-10-13 (#660); not weighed under the graf class", issue = 660, decided = 2026-10-13 } } [[word]] text = "ƙarami" generation = { issue = 679, note = "the Younger" } derek73-python-nameparser-456b024/nameparser/vocab/he.toml000066400000000000000000000105771527303271700234410ustar00rootroot00000000000000schema = 1 [[word]] text = "גב'" salutation = { gender = "f", issue = 269, expands = "Mrs., the abbreviation", note = "the ASCII-quote spelling: the quote has no whitespace before it, so it opens no delimited span, and with no opener before it a dangling straight apostrophe is literal text (WORD_INTERNAL_DELIMITERS in _pipeline/_extract.py; probed 2026-07-17)", after_given = true } [[word]] text = "גב׳" salutation = { gender = "f", issue = 269, same_as = "גב'", expands = "the U+05F3 geresh spelling", after_given = true } [[word]] text = "גברת" salutation = { gender = "f", issue = 269, expands = "Mrs., Ms., the full form", after_given = true } [[word]] text = "מר" salutation = { gender = "m", issue = 269, expands = "Mr.", after_given = true } [[word]] text = "ד\"ר" academic = { issue = 269, expands = "doctor, ASCII quote", note = "the quote sits mid-word, with no whitespace before it and a letter after it, so it fails both extract_delimited's open and close boundary tests (_open_ok/_close_ok, probed 2026-07-17) and stays literal text; the gershayim spelling is no delimiter at all" } [[word]] text = "ד״ר" academic = { issue = 269, same_as = "ד\"ר", expands = "doctor, U+05F4 gershayim" } [[word]] text = "עו\"ד" academic = { issue = 269, expands = "advocate, ASCII quote" } [[word]] text = "עו״ד" academic = { issue = 269, same_as = "עו\"ד", expands = "advocate, U+05F4 gershayim" } [[word]] text = "פרופסור" academic = { issue = 269, expands = "professor, full form" } [[word]] text = "פרופ'" academic = { issue = 269, same_as = "פרופסור", expands = "professor abbreviation, ASCII apostrophe" } [[word]] text = "פרופ׳" academic = { issue = 269, same_as = "פרופסור", expands = "professor abbreviation, U+05F3 geresh" } [[word]] text = "rabbi" religious = { note = "addresses by the TITLE, not the given name ('Rabbi Cohen'), and the tradition keeps surnames, so it stays out of GIVEN_NAME_TITLES (#346)" } [[word]] text = "הרב" religious = { issue = 269, expands = "the rabbi", note = "the article form; bare רב is deferred under the collision rule, being also the ordinary word 'many'" } [[word]] text = "ג'וניור" generation = { issue = 679, note = "ASCII apostrophe" } [[word]] text = "ג׳וניור" generation = { issue = 679, note = "U+05F3 geresh" } [[word]] text = "ג’וניור" generation = { issue = 679, note = "U+2019 apostrophe" } [[word]] text = "האב" generation = { issue = 679, note = "the father" } [[word]] text = "הבן" generation = { issue = 679, note = "the son" } [[word]] text = "סניור" [word.excluded.generation] reason = "borne as a surname in Israel (אלי סניור, בוריס סניור)" issue = 679 [[word]] text = "ז\"ל" [word.status] issue = 269 note = "\"of blessed memory\" (deceased), ASCII quote; the mid-word quote is inert in extraction, like the ד\"ר title, and neither spelling is ever a name" [[word]] text = "ז״ל" status = { issue = 269, note = "the same, U+05F4 gershayim", same_as = "ז\"ל" } [[word]] text = "שליט\"א" courtesy = { issue = 269, note = "honorific for a living rabbi, ASCII quote" } [[word]] text = "שליט״א" courtesy = { issue = 269, note = "the same, U+05F4 gershayim", same_as = "שליט\"א" } [[word]] text = "bar" given = { us_ssa = 50, wikidata = 8, as_of = 2026-10-25, note = "US SSA births summed over both sexes; Wikidata is a lower bound" } [word.patronymic] expands = "son of, Aramaic: \"Bar-Lev\"" gender = "m" note = "Bar Refaeli; a common modern Israeli given name. The native-script בר is excluded, in this file" [[word]] text = "bat" given = { note = "declared on the conservative default (decisions/vocabulary-collisions C-i): not examined, and no source counts it as a given name (2026-10-25)" } patronymic = { expands = "daughter of; native-script בת", gender = "f" } [[word]] text = "בן" [word.patronymic] may_be_given = false issue = 269 expands = "son of: ben" gender = "m" note = "No Latin-script collision, and not a standalone given name in Hebrew usage." [[word]] text = "בר" [word.excluded.patronymic] reason = "Bar is a common modern Israeli given name, and the surname spelling is hyphenated anyway (Bar-Lev)" issue = 269 [[word]] text = "בת" [word.patronymic] may_be_given = false issue = 269 expands = "daughter of: bat" gender = "f" note = "no Latin-script collision, and not a standalone given name in Hebrew usage, as בן" derek73-python-nameparser-456b024/nameparser/vocab/hi.toml000066400000000000000000000207401527303271700234360ustar00rootroot00000000000000schema = 1 [[word]] text = "shri" excluded = { salutation = { reason = "the transliteration of श्री collides with real given names where the native script cannot" } } [[word]] text = "sri" excluded = { salutation = { reason = "a given name (Sri Mulyani); the native श्री cannot collide" } } [[word]] text = "कुमारी" excluded = { salutation = { reason = "Kumari is a given name and a family name in this script, so the leading claim fails C-i (decisions/vocabulary-collisions)", issue = 344, decided = 2026-09-06 } } [[word]] text = "जनाब" salutation = { gender = "m", issue = 344, expands = "Janab, Mr.", after_given = true } [[word]] text = "बाबू" salutation = { gender = "m", issue = 344, expands = "Babu", note = "leading in Hindi; the Bengali বাবু is trailing, a suffix word in bn.toml, a different codepoint sequence", excluded = { after_given = "romanized Babu, an element of compound given names in India (Ram Babu) and a surname" } } family = { us_census_2010 = 1496, wikidata = 37, as_of = 2026-10-24, note = "counted as BABU, the romanization; nam_dict ranks Babu a given name in India and Sri Lanka, band 3" } [[word]] text = "बेगम" excluded = { salutation = { reason = "Begum is a trailing name element far more often than a leading title", issue = 344, decided = 2026-09-06 } } [[word]] text = "श्री" salutation = { gender = "m", issue = 269, expands = "Shri, Mr.", note = "no Latin twin: Latin sri and shri are given names (their exclusions in hi.toml); borne as a given name: Wikidata 1 people (2026-10-24); kept here, not as a `given` reading, which would make the title one only inside a title phrase (rules/H9)", excluded = { after_given = "also an element of given names (Sri, Shri), so where it ends the given part it may be one" } } [[word]] text = "श्रीमती" salutation = { gender = "f", issue = 269, expands = "Shrimati, Mrs.", after_given = true } [[word]] text = "श्रीमान" salutation = { gender = "m", issue = 344, expands = "Shriman, Mr.", after_given = true } [[word]] text = "श्रीयुत" salutation = { gender = "m", issue = 344, expands = "Shriyut, Mr.", after_given = true } [[word]] text = "सुश्री" salutation = { gender = "f", issue = 344, expands = "Sushri, Ms.", after_given = true } [[word]] text = "ठाकुर" excluded = { style = { reason = "Tagore, the surname, as with the Bengali ঠাকুর", issue = 344, decided = 2026-09-06 } } [[word]] text = "महात्मा" style = { issue = 344, expands = "Mahatma", note = "civil, not renunciate: it addresses by surname ('महात्मा गांधी'), so TITLES only" } [[word]] text = "शेख" excluded = { style = { reason = "a clan name and a family name; the Latin sheikh cluster ships for the Arabic addressing form, while the Indic spellings name the family", issue = 344, decided = 2026-09-06 } } [[word]] text = "सरदार" style = { issue = 344, expands = "Sardar" } [[word]] text = "pandit" excluded = { academic = { reason = "a common surname (Vijaya Lakshmi Pandit); the native पंडित ships", issue = 344, decided = 2026-09-06 } } [[word]] text = "डा" academic = { issue = 344, expands = "Dr., the डा. abbreviation (Nepali and older Hindi)", note = "डा. matches through the edge-period normalization" } [[word]] text = "डॉ" academic = { issue = 269, expands = "Dr., abbreviated", note = "डॉ. matches through the edge-period normalization, as Latin Dr. does; no Latin twins, for the reason the sri/shri exclusions in hi.toml give" } [[word]] text = "डॉक्टर" academic = { issue = 344, expands = "doctor, full form" } [[word]] text = "पंडित" academic = { issue = 344, expands = "Pandit, a scholar's title" } [[word]] text = "पं" academic = { issue = 344, same_as = "पंडित", expands = "Pandit, abbreviated" } [[word]] text = "प्राध्यापक" academic = { issue = 344, expands = "professor, the Sanskritic form" } [[word]] text = "प्रो" academic = { issue = 344, expands = "Prof., abbreviated" } [[word]] text = "प्रोफेसर" academic = { issue = 344, expands = "professor, full form" } [[word]] text = "baba" religious = { addresses = "given", issue = 346, note = "#346's renunciate class (baba, guru, lama, swami): it addresses by the GIVEN name, and the tradition renounces the surname, which is what makes the empty family right for 'Swami Vivekananda' (rules/H Background)" } [[word]] text = "guru" religious = { addresses = "given", issue = 346, note = "#346's renunciate class (baba, guru, lama, swami): it addresses by the GIVEN name, and the tradition renounces the surname, which is what makes the empty family right for 'Swami Vivekananda' (rules/H Background)" } [[word]] text = "sant" excluded = { religious = { reason = "a Punjabi given name (Sant Singh); the native संत ships", issue = 344, decided = 2026-09-06 } } [[word]] text = "swami" religious = { addresses = "given", issue = 346, note = "#346's renunciate class (baba, guru, lama, swami): it addresses by the GIVEN name, and the tradition renounces the surname, which is what makes the empty family right for 'Swami Vivekananda' (rules/H Background)" } [[word]] text = "आचार्य" excluded = { religious = { reason = "Acharya is a Brahmin surname", issue = 344, decided = 2026-09-06 } } [[word]] text = "गुरु" religious = { addresses = "given", issue = 344, expands = "Guru" } [[word]] text = "बाबा" religious = { addresses = "given", issue = 344, expands = "Baba" } [[word]] text = "मौलाना" religious = { issue = 344, expands = "Maulana" } [[word]] text = "संत" religious = { addresses = "given", issue = 344, expands = "Sant", note = "no Latin twin: Latin sant is a Punjabi given name (its exclusion in this file)" } [[word]] text = "स्वामी" religious = { addresses = "given", issue = 344, expands = "Swami" } [[word]] text = "maharajah" nobility = { gender = "m" } [[word]] text = "maharani" nobility = { gender = "f" } [[word]] text = "महाराजा" nobility = { issue = 344, gender = "m", expands = "Maharaja", note = "LEADING; the trailing महाराज is a suffix word" } [[word]] text = "राजा" excluded = { nobility = { reason = "Raja is a common given name", issue = 344, decided = 2026-09-06 } } [[word]] text = "रानी" excluded = { nobility = { reason = "Rani is a common given name", issue = 344, decided = 2026-09-06 } } [[word]] text = "subedar" military = {} [[word]] text = "न्यायमूर्ति" office = { issue = 344, expands = "Justice", note = "#344's civil set ships in native script only, with no new Latin transliteration; the words it refused under the collision rule are exclusions in this file" } [[word]] text = "जुनियर" generation = { issue = 661, note = "junior, one of its two u spellings" } [[word]] text = "जूनियर" generation = { issue = 661, note = "junior, one of its two u spellings" } [[word]] text = "सीनियर" generation = { issue = 661 } [[word]] text = "जी" [word.honorific] issue = [346, 344, 343] note = "the universal respect particle (मोदी जी)" [word.honorific.excluded] attached = "जी ends बनर्जी, मुखर्जी, चटर्जी, so a glued peel would strand a bare virama (बनर् + जी); the 殿 criterion in a non-CJK script (decisions/indic-honorifics)" [[word]] text = "साहब" [word.honorific] issue = [346, 344, 343] note = "Sahib" [word.honorific.excluded] attached = "जी ends बनर्जी, मुखर्जी, चटर्जी, so a glued peel would strand a bare virama (बनर् + जी); the 殿 criterion in a non-CJK script (decisions/indic-honorifics)" [[word]] text = "साहिब" [word.honorific] issue = [346, 344, 343] note = "the Sahib spelling with the i-matra" [word.honorific.excluded] attached = "जी ends बनर्जी, मुखर्जी, चटर्जी, so a glued peel would strand a bare virama (बनर् + जी); the 殿 criterion in a non-CJK script (decisions/indic-honorifics)" [[word]] text = "महाराज" [word.courtesy] issue = [346, 344, 343] note = "Maharaj, trailing; the leading महाराजा is a title (#344)" [word.courtesy.excluded] attached = "जी ends बनर्जी, मुखर्जी, चटर्जी, so a glued peel would strand a bare virama (बनर् + जी); the 殿 criterion in a non-CJK script (decisions/indic-honorifics)" derek73-python-nameparser-456b024/nameparser/vocab/hr.toml000066400000000000000000000015511527303271700234460ustar00rootroot00000000000000schema = 1 [[word]] text = "gospodin" salutation = { issue = 606, gender = "m", after_given = true } [[word]] text = "gospođa" salutation = { issue = 606, gender = "f", after_given = true } [[word]] text = "gospođica" salutation = { issue = 606, gender = "f", after_given = true } [[word]] text = "ml" [word.excluded.generation] reason = "pack-shaped: real and standard, but collides with ML initials in other languages written in Latin script" issue = [661, 679, 684] [[word]] text = "mlađa" generation = { issue = 661 } [[word]] text = "mlađi" generation = { issue = 661 } [[word]] text = "st" [word.excluded.generation] reason = "pack-shaped: real and standard, but St. is Saint in other languages written in Latin script" issue = [661, 679, 684] [[word]] text = "starija" generation = { issue = 661 } [[word]] text = "stariji" generation = { issue = 661 } derek73-python-nameparser-456b024/nameparser/vocab/hu.toml000066400000000000000000000010461527303271700234500ustar00rootroot00000000000000schema = 1 [[word]] text = "gróf" excluded = { nobility = { reason = "Hungarian Count: possibly a surname in a surname-first culture; unverified, and under uncertainty the answer is do not ship", issue = 606, decided = 2026-10-04 } } [[word]] text = "id" [word.excluded.generation] reason = "written before the name, and a bare id would make any trailing ID a suffix" issue = 661 [[word]] text = "ifj" [word.excluded.generation] reason = "written BEFORE the name (ifj. Kovács János), which H2's shape rule already reads as a title" issue = 661 derek73-python-nameparser-456b024/nameparser/vocab/hy.toml000066400000000000000000000012021527303271700234460ustar00rootroot00000000000000schema = 1 [[word]] text = "paron" excluded = { salutation = { reason = "Armenian Mr. in Latin script: unverified, so not shipped", issue = 606, decided = 2026-10-04 } } [[word]] text = "tikin" excluded = { salutation = { reason = "Armenian Mrs. in Latin script: unverified, so not shipped", issue = 606, decided = 2026-10-04 } } [[word]] text = "vardapet" religious = { expands = "an Armenian celibate priest and doctor of the church" } [[word]] text = "ավագ" [word.excluded.generation] reason = "an Armenian given name, in lists that write the surname first" issue = 679 [[word]] text = "կրտսեր" generation = { issue = 679 } derek73-python-nameparser-456b024/nameparser/vocab/id.toml000066400000000000000000000003061527303271700234260ustar00rootroot00000000000000schema = 1 [[word]] text = "bapak" salutation = { issue = 662, gender = "m", note = "under 100 US Census bearers and none found on Wikidata (tools/name_usage.py, 2026-10-17)", after_given = true } derek73-python-nameparser-456b024/nameparser/vocab/is.toml000066400000000000000000000010601527303271700234430ustar00rootroot00000000000000schema = 1 [[word]] text = "frú" salutation = { issue = 606, gender = "f", excluded = { after_given = "spelled FRU, a Cameroonian surname, as fru is (sv.toml), and middle names are drawn from surnames" } } [[word]] text = "herra" salutation = { issue = 606, gender = "m" } [[word]] text = "ungfrú" salutation = { issue = 606, gender = "f", after_given = true } [[word]] text = "greifi" nobility = { gender = "m", expands = "count", issue = 606 } [[word]] text = "eldri" generation = { issue = 679 } [[word]] text = "yngri" generation = { issue = 679 } derek73-python-nameparser-456b024/nameparser/vocab/it.toml000066400000000000000000000133141527303271700234510ustar00rootroot00000000000000schema = 1 [[word]] text = "sig" excluded = { salutation = { reason = "Sig is borne as a given name (US SSA 50+ male, Wikidata 11+ given, Norway and the United States; the FEC contributors write 'SMITH, SIG' 16 times), measured 2026-10-22 with tools/name_usage.py; with its period, 'Sig.' already reads as a title by its shape (rules/H2)", issue = 840 } } [[word]] text = "signora" salutation = { gender = "f", after_given = true, note = "borne as a given name: US SSA 90 births (2026-10-24); kept for after_given (decisions/H8); kept here, not as a `given` reading, which would make the title one only inside a title phrase (rules/H9)" } [[word]] text = "sig.ra" salutation = { gender = "f", same_as = "signora", cased = "Sig.ra", issue = 840, after_given = true } [[word]] text = "signore" salutation = { gender = "m", excluded = { after_given = "a surname (Census 2010 SIGNORE), and middle names are drawn from surnames" } } family = { us_census_2010 = 895, wikidata = 17, as_of = 2026-10-24 } [[word]] text = "signor" salutation = { gender = "m", same_as = "signore", excluded = { after_given = "a surname (Census 2010 SIGNOR), and middle names are drawn from surnames" } } family = { us_census_2010 = 999, wikidata = 4, as_of = 2026-10-24 } [[word]] text = "signorina" salutation = { gender = "f", after_given = true } [[word]] text = "sig.na" salutation = { gender = "f", same_as = "signorina", cased = "Sig.na", issue = 840, after_given = true } [[word]] text = "ch.ma" style = { gender = "f", expands = "chiarissima, as a letter addresses a university professor", cased = "Ch.ma", issue = 840 } [[word]] text = "ch.mo" style = { gender = "m", expands = "chiarissimo, as a letter addresses a university professor", cased = "Ch.mo", issue = 840 } [[word]] text = "gent.le" style = { expands = "gentile, the commonest heading of a letter", cased = "Gent.le", issue = 840 } [[word]] text = "gent.ma" style = { gender = "f", expands = "gentilissima, as a letter is headed", cased = "Gent.ma", issue = 840 } [[word]] text = "gent.mo" style = { gender = "m", expands = "gentilissimo, as a letter is headed", cased = "Gent.mo", issue = 840 } [[word]] text = "ill.ma" style = { gender = "f", expands = "illustrissima", cased = "Ill.ma", issue = 840 } [[word]] text = "ill.mo" style = { gender = "m", expands = "illustrissimo", cased = "Ill.mo", issue = 840 } [[word]] text = "preg.ma" style = { gender = "f", expands = "pregiatissima, as a letter is headed", cased = "Preg.ma", issue = 840 } [[word]] text = "preg.mo" style = { gender = "m", expands = "pregiatissimo, as a letter is headed", cased = "Preg.mo", issue = 840 } [[word]] text = "dott.ssa" academic = { gender = "f", expands = "dottoressa", cased = "Dott.ssa", issue = 840 } [[word]] text = "prof.ssa" academic = { gender = "f", expands = "professoressa", cased = "Prof.ssa", issue = 840 } [[word]] text = "gentiluomo" nobility = { gender = "m", expands = "gentleman" } [[word]] text = "d'" preposition = { may_be_given = false, issue = 700, expands = "of, elided di" } [[word]] text = "d’" [word.preposition] may_be_given = false issue = 700 expands = "the same, typographic apostrophe" same_as = "d'" [[word]] text = "da" given = { us_ssa = 302, wikidata = 331, as_of = 2026-10-25, note = "US SSA births summed over both sexes; Wikidata is a lower bound; nam_dict band 5 in Korea, 3 in China" } preposition = { expands = "from, of" } [[word]] text = "dal" given = { us_ssa = 628, wikidata = 2, as_of = 2026-10-25, note = "male births only; Wikidata is a lower bound; nam_dict band 4 in Korea" } preposition = { expands = "from the" } [[word]] text = "de" preposition = { may_be_given = false, expands = "of" } [[word]] text = "de'" preposition = { may_be_given = false, expands = "of the, elided dei: \"de' Medici\"" } [[word]] text = "de’" [word.preposition] may_be_given = false issue = 700 expands = "the same, typographic apostrophe" same_as = "de'" [[word]] text = "degli" preposition = { may_be_given = false, expands = "of the, masc. pl." } [[word]] text = "dei" preposition = { may_be_given = false, expands = "of the, masc. pl." } [[word]] text = "del" preposition = { expands = "of the" } [[word]] text = "dell'" preposition = { may_be_given = false, expands = "of the, elided dello/della: \"dell'Acqua\"" } [[word]] text = "dell’" [word.preposition] may_be_given = false issue = 700 expands = "the same, typographic apostrophe" same_as = "dell'" [[word]] text = "della" given = { us_ssa = 70949, wikidata = 91, as_of = 2026-10-25, note = "US SSA births summed over both sexes; Wikidata is a lower bound; nam_dict band 3 in U.S.A., 1 in Great Britain" } [word.preposition] expands = "of the, fem. sg." note = "Della Reese: an ordinary given name" [[word]] text = "delle" preposition = { may_be_given = false, expands = "of the, fem. pl." } [[word]] text = "delli" preposition = { may_be_given = false, expands = "of the, regional variant of dei" } [[word]] text = "dello" preposition = { may_be_given = false, expands = "of the, masc. sg." } [[word]] text = "di" given = { us_ssa = 70, wikidata = 177, as_of = 2026-10-25, note = "US SSA births summed over both sexes; Wikidata is a lower bound; nam_dict band 5 in China, 1 in Great Britain, 1 in U.S.A." } [word.preposition] expands = "of" note = "BOTH: Di as a short form of Diana, and the Italian particle (DiCaprio)" [[word]] text = "l'" article = { may_be_given = false, expands = "the, elided" } [[word]] text = "l’" [word.article] may_be_given = false issue = 700 expands = "the same, typographic apostrophe" same_as = "l'" [[word]] text = "la" article = { expands = "the, fem. sg." } [[word]] text = "san" saint = { expands = "Saint" } [[word]] text = "santa" saint = { expands = "Saint, fem." } [[word]] text = "e" conjunction = { may_be_initial = true } derek73-python-nameparser-456b024/nameparser/vocab/ja.toml000066400000000000000000000101241527303271700234230ustar00rootroot00000000000000schema = 1 [[word]] text = "シニア" [word.excluded.generation] reason = "left out (Derek, 2026-10-14) beside its Hebrew twin; its only count is Wikidata's Senior surname label, 96 bearers, which measures the English surname" issue = 679 [[word]] text = "ジュニア" generation = { issue = 679 } [[word]] text = "二世" [word.generation] issue = 671 note = "the generational numeral \"II\"" [word.generation.attached] side = "after" issue = 707 note = "#707's one ends-with hit, 厄德二世, is the given name Odo with a regnal numeral in its label" [[word]] text = "医学博士" [word.credential] acronym = false issue = 671 note = "Doctor of Medicine" attached = { side = "after", issue = 707 } [[word]] text = "博士" [word.credential] acronym = false issue = [307, 308] note = "doctorate holder, shared Han; an attested Japanese given name (ひろし, Hiroshi), but the doctorate reading vastly dominates the spaced trailing position" excluded = { attached = "glued 田中博士 IS Tanaka Hiroshi, an attested given name" } [[word]] text = "くん" [word.honorific] issue = [307, 308] note = "the kana spelling of 君, which ships where 君 cannot: 王君 is a complete Chinese name, while hiragana is unavailable to Chinese" attached = { side = "after", issue = 308 } [[word]] text = "さん" [word.honorific] issue = [307, 308] note = "the everyday honorific, kana" attached = { side = "after", issue = 308 } [[word]] text = "ちゃん" [word.honorific] issue = [307, 308] note = "familiar/diminutive" attached = { side = "after", issue = 308 } [[word]] text = "様" [word.honorific] issue = [307, 308] note = "formal Mr./Ms. (the mail-addressing honorific)" attached = { side = "after", issue = 308 } [[word]] text = "さま" [word.honorific] issue = [307, 308] note = "the kana spelling of 様" same_as = "様" attached = { side = "after", issue = 308 } [[word]] text = "殿" [word.honorific] issue = [307, 308] note = "formal, official/rank-flavored; spaced is safe because a 殿-surnamed person's name LEADS and the suffix gate is trailing-only" [word.honorific.excluded] attached = "some ninety Japanese surnames end in it, 鵜殿 (Udono) and 真殿 (Madono) with four-figure populations, so a peel would cut a family name in two" [[word]] text = "氏" [word.honorific] issue = [307, 308] note = "news-style Mr. (田中氏); a rare Japanese surname reading exists, but a 氏-surnamed person writes it FIRST" excluded = { attached = "王氏 is a historical name form (\"the Wang woman\")" } [[word]] text = "先生" [word.professional_honorific] issue = [307, 308] note = "zh Mr. / ja teacher-master, an honorific in both" attached = { side = "after", issue = 308 } [[word]] text = "国会議員" [word.professional_honorific] issue = 671 note = "Diet member" attached = { side = "after", issue = 707 } [[word]] text = "教授" [word.professional_honorific] issue = [307, 308] note = "professor, shared Han" attached = { side = "after", issue = 308 } [[word]] text = "社長" [word.professional_honorific] issue = 671 note = "president (ja), society head (zh); a job title used as a form of address after a name, the same slot as 先生" attached = { side = "after", issue = 707 } [[word]] text = "課長" [word.professional_honorific] issue = 671 note = "section chief; a form of address after a name" attached = { side = "after", issue = 707 } [[word]] text = "議員" [word.professional_honorific] issue = 671 note = "legislator" attached = { side = "after", issue = 707 } [[word]] text = "部長" [word.professional_honorific] issue = 671 note = "department head (ja), minister (zh); a form of address after a name" [word.professional_honorific.excluded] attached = "長 alone is a borne given name, so 阿部長 can be 阿部 + 長 (#707). The measurement found no such bearer, as it found none for the words that shipped; the refusal rests on the argument, which a lower-bound zero does not refute, so a re-measure that again comes back zero is no reason to lift it" [[word]] text = "旧姓" [word.birth_marker] issue = 309 note = "native script, so it cannot collide with a Latin-script name, and it is in no other shipped vocabulary" derek73-python-nameparser-456b024/nameparser/vocab/ka.toml000066400000000000000000000011721527303271700234270ustar00rootroot00000000000000schema = 1 [[word]] text = "batoni" salutation = { issue = 606, gender = "m", note = "borne: ~1,100 surnames (the painter Pompeo Batoni) and ~300 given-name uses (Tanzania), forebears.io, worldwide, rounded, 2026-10-04; shipped under the graf class (decisions/salutation-titles)", after_given = true } [[word]] text = "kalbatoni" salutation = { issue = 606, gender = "f", after_given = true } [[word]] text = "grafi" nobility = { gender = "m", expands = "count, in Latin script", issue = 606 } [[word]] text = "უმცროსი" generation = { issue = 679 } [[word]] text = "უფროსი" generation = { issue = 679 } derek73-python-nameparser-456b024/nameparser/vocab/kk.toml000066400000000000000000000002011527303271700234310ustar00rootroot00000000000000schema = 1 [[word]] text = "джуниор" [word.excluded.generation] reason = "a given name or a club name there" issue = 679 derek73-python-nameparser-456b024/nameparser/vocab/km.toml000066400000000000000000000003011527303271700234340ustar00rootroot00000000000000schema = 1 [[word]] text = "ជូនៀ" [word.excluded.generation] reason = "a given name; ជូនៀរ ships" issue = 679 [[word]] text = "ជូនៀរ" generation = { issue = 679 } derek73-python-nameparser-456b024/nameparser/vocab/kn.toml000066400000000000000000000001211527303271700234350ustar00rootroot00000000000000schema = 1 [[word]] text = "ಜೂನಿಯರ್" generation = { issue = 679 } derek73-python-nameparser-456b024/nameparser/vocab/ko.toml000066400000000000000000000050621527303271700234470ustar00rootroot00000000000000schema = 1 [[word]] text = "시니어" generation = { issue = 679 } [[word]] text = "주니어" generation = { issue = 679 } [[word]] text = "박사" [word.credential] acronym = false issue = [307, 308] note = "doctorate holder (\"Dr.\"); ships bare as well as with -님 because it names a degree rather than a role" [word.credential.attached] side = "after" issue = 308 note = "safe glued where its Han twin 博士 is not: that collision is Japanese (博士 = ひろし)" [[word]] text = "교수" [word.excluded.honorific] reason = "reads as a common noun as readily as an address term; 교수님 ships" issue = 308 [[word]] text = "군" [word.honorific] issue = [307, 308] note = "young man (\"Master\"); as a single-syllable given name in final position vanishingly rare" excluded = { attached = "김지군 is a given name ending in it" } [[word]] text = "님" [word.honorific] issue = [307, 308] note = "the bare honorific of online/formal address; a single hangul syllable, the 양/군 risk class, but with no hanja reading it cannot sit inside a Sino-Korean given name" attached = { side = "after", issue = 308 } [[word]] text = "선생" [word.excluded.honorific] reason = "reads as a common noun as readily as an address term; 선생님 ships" issue = 308 [[word]] text = "씨" [word.honorific] issue = [307, 308] note = "Mr./Ms.; standardly spaced in Korean orthography" attached = { side = "after", issue = 308 } [[word]] text = "양" [word.honorific] issue = [307, 308] note = "young woman (\"Miss\"); also a top-tier surname (Yang), but a surname LEADS, so the trailing-only suffix gate never sees it there (양 미선 keeps family 양)" [word.honorific.excluded] attached = "김지양 is a given name ending in it, and 양 is a top-tier surname besides" [[word]] text = "교수님" [word.professional_honorific] issue = [307, 308] note = "professor (honorific form)" attached = { side = "after", issue = 308 } [[word]] text = "박사님" [word.professional_honorific] issue = [307, 308] note = "the same, honorific form; 선생님, 교수님 and 박사님 are the standard professional honorifics and ship together" [word.professional_honorific.attached] side = "after" issue = 308 note = "peels off 김민준박사님 whole rather than stranding 박사 in the given name" [[word]] text = "선생님" [word.professional_honorific] issue = [307, 308] note = "teacher/respected elder" [word.professional_honorific.attached] side = "after" issue = 308 note = "longest-first peels 선생님 off 김선생님 whole, rather than leaving 김선생 to segment into family 김 and given 선생" derek73-python-nameparser-456b024/nameparser/vocab/kok.toml000066400000000000000000000001571527303271700236220ustar00rootroot00000000000000schema = 1 [[word]] text = "जेआर" [word.excluded.generation] reason = "rare, and also JR" issue = 679 derek73-python-nameparser-456b024/nameparser/vocab/lo.toml000066400000000000000000000001131527303271700234400ustar00rootroot00000000000000schema = 1 [[word]] text = "ຈູເນຍ" generation = { issue = 679 } derek73-python-nameparser-456b024/nameparser/vocab/lt.toml000066400000000000000000000016411527303271700234540ustar00rootroot00000000000000schema = 1 [[word]] text = "ponas" salutation = { issue = 606, gender = "m", note = "borne: ~4,000 surnames (Iran), forebears.io, worldwide, rounded, 2026-10-04; shipped under the graf class (decisions/salutation-titles)", after_given = true } [[word]] text = "ponia" salutation = { issue = 606, gender = "f", note = "borne: ~560 surnames, forebears.io, worldwide, rounded, 2026-10-04; shipped under the graf class (decisions/salutation-titles)", after_given = true } [[word]] text = "grafas" nobility = { gender = "m", expands = "count", issue = 606 } [[word]] text = "jaun" [word.excluded.generation] reason = "the abbreviation jaun., refused with the full words shipping" issue = 679 [[word]] text = "jaunesnioji" generation = { issue = 661 } [[word]] text = "jaunesnysis" generation = { issue = 661 } [[word]] text = "vyresnioji" generation = { issue = 661 } [[word]] text = "vyresnysis" generation = { issue = 661 } derek73-python-nameparser-456b024/nameparser/vocab/lv.toml000066400000000000000000000003631527303271700234560ustar00rootroot00000000000000schema = 1 [[word]] text = "jaunākais" generation = { issue = 679 } [[word]] text = "jaunākā" generation = { issue = 679 } [[word]] text = "vecākais" generation = { issue = 679 } [[word]] text = "vecākā" generation = { issue = 679 } derek73-python-nameparser-456b024/nameparser/vocab/mi.toml000066400000000000000000000001221527303271700234330ustar00rootroot00000000000000schema = 1 [[word]] text = "rangatira" nobility = { expands = "a Māori chief" } derek73-python-nameparser-456b024/nameparser/vocab/mk.toml000066400000000000000000000004371527303271700234460ustar00rootroot00000000000000schema = 1 [[word]] text = "помладиот" generation = { issue = 679 } [[word]] text = "постариот" generation = { issue = 679 } [[word]] text = "јуниор" [word.excluded.generation] reason = "a given name or a club name there; помладиот ships" issue = 679 derek73-python-nameparser-456b024/nameparser/vocab/ml.toml000066400000000000000000000002211527303271700234360ustar00rootroot00000000000000schema = 1 [[word]] text = "ജൂനിയർ" generation = { issue = 679 } [[word]] text = "സീനിയർ" generation = { issue = 679 } derek73-python-nameparser-456b024/nameparser/vocab/mn.toml000066400000000000000000000002601527303271700234430ustar00rootroot00000000000000schema = 1 [[word]] text = "lama" religious = { addresses = "given", issue = 346 } [[word]] text = "жр" [word.excluded.generation] reason = "the initials ЖР" issue = 679 derek73-python-nameparser-456b024/nameparser/vocab/mr.toml000066400000000000000000000047711527303271700234620ustar00rootroot00000000000000schema = 1 [[word]] text = "श्री" salutation = { gender = "m", issue = 269, expands = "Shri, Mr." } [[word]] text = "श्रीमती" salutation = { gender = "f", issue = 269, expands = "Shrimati, Mrs.", after_given = true } [[word]] text = "सौ" salutation = { gender = "f", issue = 344, expands = "Sau., Mrs.", after_given = true } [[word]] text = "महात्मा" style = { issue = 344, expands = "Mahatma" } [[word]] text = "सरदार" style = { issue = 344, expands = "Sardar" } [[word]] text = "डॉ" academic = { issue = 269, expands = "Dr., abbreviated" } [[word]] text = "डॉक्टर" academic = { issue = 344, expands = "doctor, full form" } [[word]] text = "पंडित" academic = { issue = 344, expands = "Pandit, a scholar's title" } [[word]] text = "पं" academic = { issue = 344, same_as = "पंडित", expands = "Pandit, abbreviated" } [[word]] text = "प्रा" academic = { issue = 344, expands = "Prof., the Marathi abbreviation" } [[word]] text = "प्राध्यापक" academic = { issue = 344, expands = "professor, the Sanskritic form" } [[word]] text = "प्रो" academic = { issue = 344, expands = "Prof., abbreviated" } [[word]] text = "प्रोफेसर" academic = { issue = 344, expands = "professor, full form" } [[word]] text = "महाराजा" nobility = { issue = 344, gender = "m", expands = "Maharaja" } [[word]] text = "न्यायमूर्ति" office = { issue = 344, expands = "Justice" } [[word]] text = "क" [word.excluded.generation] reason = "the abbreviation क. is the single-letter initial shape" issue = 679 [[word]] text = "ज्युनियर" generation = { issue = 679 } [[word]] text = "ज्यूनियर" generation = { issue = 679 } [[word]] text = "जी" [word.honorific] issue = [346, 344, 343] note = "the universal respect particle (मोदी जी)" [word.honorific.excluded] attached = "जी ends बनर्जी, मुखर्जी, चटर्जी, so a glued peel would strand a bare virama (बनर् + जी); the 殿 criterion in a non-CJK script (decisions/indic-honorifics)" [[word]] text = "साहेब" [word.honorific] issue = [346, 344, 343] note = "the Marathi Saheb spelling" [word.honorific.excluded] attached = "जी ends बनर्जी, मुखर्जी, चटर्जी, so a glued peel would strand a bare virama (बनर् + जी); the 殿 criterion in a non-CJK script (decisions/indic-honorifics)" derek73-python-nameparser-456b024/nameparser/vocab/ms.toml000066400000000000000000000032061527303271700234530ustar00rootroot00000000000000schema = 1 [[word]] text = "cik" excluded = { salutation = { reason = "Malay Miss: unverified, so not shipped", issue = 606, decided = 2026-10-04 } } [[word]] text = "encik" salutation = { issue = 606, gender = "m", after_given = true } [[word]] text = "puan" excluded = { salutation = { reason = "Malay Mrs., and an Indonesian given name (Puan Maharani)", issue = 606, decided = 2026-10-04 } } [[word]] text = "sultan" nobility = {} [[word]] text = "bin" patronymic = { expands = "son of: \"Ahmad bin Ismail\"", gender = "m" } [[word]] text = "binti" given = { us_ssa = 54, as_of = 2026-10-25, note = "female births only" } [word.patronymic] issue = 604 expands = "daughter of: \"Ina binti Navalamar\"" gender = "f" note = "The female partner of 'bin'. Binti is also a given name (Swahili, \"daughter\"), so ambiguous like 'bin' (#604). The abbreviations 'bt' and 'bte' are excluded, in this file" [[word]] text = "binte" given = { note = "declared beside binti, as the particle reading is kept beside binti's (#604); no source counts it as a given name (2026-10-25)" } [word.patronymic] issue = 604 expands = "daughter of, Singapore spelling" same_as = "binti" gender = "f" note = "kept beside 'binti' (#604)" [[word]] text = "bt" [word.excluded.patronymic] reason = "the abbreviation of binti, not reviewed; and already a suffix acronym (Baronet) with a Bt case mask, so a particle reading would open a particle-suffix overlap (the class rules/P6 decides for vd, do and mc) and under R4's mask precedence capitalize the particle too" issue = 604 [[word]] text = "bte" [word.excluded.patronymic] reason = "the abbreviation of binte, not reviewed" issue = 604 derek73-python-nameparser-456b024/nameparser/vocab/mul.toml000066400000000000000000000036751527303271700236430ustar00rootroot00000000000000# Words deliberately shared across languages. schema = 1 [[word]] text = "docent" academic = { expands = "the academic rank, spelled alike in Dutch, Swedish, Danish, Norwegian, Polish and Czech" } [[word]] text = "doctor" academic = { expands = "spelled alike in English, Spanish, Catalan and Romanian" } [[word]] text = "dr" academic = { expands = "Doctor, abbreviated alike across the European languages" } [[word]] text = "prof" academic = { expands = "Professor, abbreviated alike across the European languages" } [[word]] text = "professor" academic = { expands = "spelled alike in English, German, Dutch, the Scandinavian languages and Portuguese" } [[word]] text = "2" generation = {} [[word]] text = "i" generation = {} [[word]] text = "ii" generation = {} [[word]] text = "iii" generation = {} [[word]] text = "iv" generation = {} [[word]] text = "v" generation = {} [[word]] text = "ing" family = { us_census_2010 = 1814, wikidata = 13, as_of = 2026-10-23 } given = { wikidata = 10, as_of = 2026-10-23, note = "nam_dict band 5 in Sweden; Wikidata is a lower bound" } credential = { cased = "Ing", expands = "Ingenieur, inženýr: the engineering degree of Czech, Slovak, Austrian and Romanian names", note = "52 of 536,981 ClinicalTrials.gov study officials write it, 43 after a comma (data 2026-10-21); NPPES 0, a credential outside US healthcare. Marked on C-i: the surname reading is substantial (Census 2010 bearers 1,814, over the 200 threshold)", issue = 848 } excluded = { academic = { reason = "Ing is borne as a given name (nam_dict band 5 in Sweden, Wikidata 10+ given) and as a surname (US Census 1,814), measured 2026-10-22 with tools/name_usage.py, and a title claims the word in front of a name ('Ing Smith'); with its period, 'Ing.' (Ingegnere, Ingenieur) already reads as a title by its shape (rules/H2). After a full name and a comma it reads as the ambiguous credential instead, #848", issue = 840 } } [[word]] text = "&" conjunction = {} derek73-python-nameparser-456b024/nameparser/vocab/my.toml000066400000000000000000000001211527303271700234520ustar00rootroot00000000000000schema = 1 [[word]] text = "ဂျူနီယာ" generation = { issue = 679 } derek73-python-nameparser-456b024/nameparser/vocab/nb.toml000066400000000000000000000015341527303271700234350ustar00rootroot00000000000000schema = 1 [[word]] text = "familie" salutation = { issue = 606, expands = "the family addressed, 'Familie Hansen'", after_given = true } [[word]] text = "fru" salutation = { gender = "f" } [[word]] text = "frøken" salutation = { gender = "f", after_given = true } [[word]] text = "frk" salutation = { gender = "f", same_as = "frøken", after_given = true } [[word]] text = "herr" salutation = { gender = "m" } [[word]] text = "hr" salutation = { gender = "m", same_as = "herr" } [[word]] text = "greve" nobility = { gender = "m", expands = "count", issue = 606 } [[word]] text = "sysselmann" office = { expands = "the governor of Svalbard" } [[word]] text = "af" preposition = { may_be_given = false, expands = "of, nobiliary" } [[word]] text = "av" preposition = { may_be_given = false, expands = "of" } [[word]] text = "født" birth_marker = {} derek73-python-nameparser-456b024/nameparser/vocab/ne.toml000066400000000000000000000003501527303271700234330ustar00rootroot00000000000000schema = 1 [[word]] text = "डा" academic = { issue = 344, expands = "Dr., the डा. abbreviation (Nepali and older Hindi)" } [[word]] text = "जेआर" [word.excluded.generation] reason = "rare, and also JR" issue = 679 derek73-python-nameparser-456b024/nameparser/vocab/nl.toml000066400000000000000000000122561527303271700234520ustar00rootroot00000000000000schema = 1 [[word]] text = "dhr" salutation = { gender = "m", expands = "de heer, Mr.", after_given = true } [[word]] text = "heren" family = { us_census_2010 = 123, wikidata = 1, as_of = 2026-10-27 } excluded = { salutation = { reason = "the plural of heer ('de heren'): a plural addresses several people and never stands before one person's name, so as a title it only took the first word of a surname-first name (Heren Jan read title 'Heren', family 'Jan'); borne as a surname (the counts on its family reading)", issue = 766 } } [[word]] text = "juffrouw" salutation = { issue = 606, gender = "f", after_given = true } [[word]] text = "meneer" salutation = { issue = 606, gender = "m", after_given = true } [[word]] text = "mevrouw" salutation = { gender = "f", after_given = true } [[word]] text = "mevr" salutation = { gender = "f", same_as = "mevrouw", after_given = true } [[word]] text = "mevrouwe" salutation = { gender = "f", same_as = "mevrouw", expands = "the archaic spelling of mevrouw", after_given = true } [[word]] text = "baron" nobility = { gender = "m" } [[word]] text = "graaf" nobility = { gender = "m", expands = "count", issue = 606, note = "borne; one of the three words #606 proposed the graf class for" } [[word]] text = "vrouwe" nobility = { gender = "f", expands = "lady, the style of a noblewoman" } [[word]] text = "historicus" occupation = {} [[word]] text = "heer" given = { us_ssa = 272, as_of = 2026-10-25, note = "female births only" } family = { us_census_2010 = 2007, wikidata = 113, as_of = 2026-10-27, note = "Wikidata bearers mostly Swiss and German" } [word.excluded.particle] reason = "a rank particle ('lord', the nobiliary 'heer van') added from #18's test \"Johan de heer Van Kampen\" (family 'de heer Van Kampen'), which appears to be two real names run together (Johannes de Heer, a Dutch Protestant figure, and the surname Van Kampen): the string occurs elsewhere only in another name parser's test data (hex.pm full_name_splitter 1.0.3). No real name supports the particle reading, and Heer is borne as a given name and as a surname (the counts on its name readings)" issue = 766 [[word]] text = "aan" given = { wikidata = 1, as_of = 2026-10-25, note = "Wikidata is a lower bound" } preposition = { issue = 18, expands = "on, at: \"aan den Toorn\"" } [[word]] text = "aen" given = { note = "declared on the conservative default (decisions/vocabulary-collisions C-i): not examined, and no source counts it as a given name (2026-10-25)" } [word.preposition] issue = 18 expands = "aan, archaic spelling: \"aen de Landerwer\"" [[word]] text = "op" preposition = { may_be_given = false, issue = 18, expands = "at, on: \"op den Berg\"" } [[word]] text = "te" given = { us_ssa = 10, wikidata = 7, as_of = 2026-10-25, note = "female births only; Wikidata is a lower bound; nam_dict band 3 in Korea" } [word.preposition] issue = 18 expands = "at: \"te Riele\"" note = "Te Awanui-a-Rangi: a Māori given-name element, and in the corpus. Looks like the Dutch preposition 'te', which is why attestation rather than etymology decides" [[word]] text = "ter" preposition = { may_be_given = false, expands = "at the, te der contracted: \"ter Horst\"" } [[word]] text = "tho" [word.preposition] issue = 18 expands = "to, Low German zu: \"tho Wopenreijss\"" [[word]] text = "thoe" given = { note = "declared on the conservative default (decisions/vocabulary-collisions C-i): not examined, and no source counts it as a given name (2026-10-25)" } preposition = { issue = 18, expands = "to, archaic: \"thoe Holthues\"" } [[word]] text = "van" given = { us_ssa = 31194, wikidata = 282, as_of = 2026-10-25, note = "US SSA births summed over both sexes; Wikidata is a lower bound; nam_dict band 13 in Vietnam, 2 in U.S.A." } [word.preposition] expands = "of, from" note = "Vietnamese Văn, and Van Johnson -- the canonical particle-or-given ambiguity this whole marker exists for" [[word]] text = "vande" given = { note = "declared on the conservative default (decisions/vocabulary-collisions C-i): not examined, and no source counts it as a given name (2026-10-25)" } preposition = { issue = 18, expands = "van de contracted: \"Vande Casteele\"" } [[word]] text = "vander" given = { us_ssa = 1828, wikidata = 18, as_of = 2026-10-25, note = "male births only; Wikidata is a lower bound" } preposition = { expands = "van der contracted" } [[word]] text = "vd" [word.preposition] may_be_given = false issue = 18 expands = "van der, abbreviated" note = "Also the British Volunteer Decoration, a suffix acronym: two non-name readings, so C-ii decides it on frequency and the Dutch one wins (decisions/vocabulary-collisions)." [[word]] text = "'t" article = { may_be_given = false, expands = "het, the: \"'t Hooft\"" } [[word]] text = "’t" [word.article] may_be_given = false issue = 700 expands = "the same, typographic apostrophe" same_as = "'t" [[word]] text = "de" article = { may_be_given = false, expands = "the: \"de Vries\"" } [[word]] text = "den" article = { expands = "the: \"van den Berg\"" } [[word]] text = "der" article = { may_be_given = false, expands = "the: \"van der Berg\"" } [[word]] text = "het" article = { may_be_given = false, issue = 18, expands = "the" } [[word]] text = "geboren" birth_marker = { issue = 274 } derek73-python-nameparser-456b024/nameparser/vocab/nn.toml000066400000000000000000000001031527303271700234400ustar00rootroot00000000000000schema = 1 [[word]] text = "fødd" birth_marker = { issue = 274 } derek73-python-nameparser-456b024/nameparser/vocab/or.toml000066400000000000000000000002241527303271700234510ustar00rootroot00000000000000schema = 1 [[word]] text = "ଜୁନିଅର" generation = { issue = 679 } [[word]] text = "ଜୁନିୟର୍" generation = { issue = 679 } derek73-python-nameparser-456b024/nameparser/vocab/pa.toml000066400000000000000000000023221527303271700234320ustar00rootroot00000000000000schema = 1 [[word]] text = "giani" religious = { expands = "a learned Sikh", native = "ਗਿਆਨੀ" } [[word]] text = "gyani" religious = { same_as = "giani", issue = 869, decided = 2026-10-26, note = "a title only inside a title phrase, its given reading the clash (rules/H9): 'Gyani Maya Gurung' reads the given name, unreported, since no listed phrase holds it; the Sikh title keeps its usual spelling giani" } given = { note = "no US SSA year reaches 5 births and Wikidata counts no one; declared as the first half of Nepali compound given names (Gyani Maya): 38 of nepal-candidates' 126,981 pairs write it, opening the name in 37 (2026-10-26)" } [[word]] text = "guru" religious = { addresses = "given", issue = 346 } [[word]] text = "ਜੂਨੀਅਰ" generation = { issue = 679 } [[word]] text = "ਸੀਨੀਅਰ" generation = { issue = 679 } [[word]] text = "साहिब" [word.honorific] issue = [346, 344, 343] note = "the Sahib spelling with the i-matra" [word.honorific.excluded] attached = "जी ends बनर्जी, मुखर्जी, चटर्जी, so a glued peel would strand a bare virama (बनर् + जी); the 殿 criterion in a non-CJK script (decisions/indic-honorifics)" derek73-python-nameparser-456b024/nameparser/vocab/pl.toml000066400000000000000000000016211527303271700234460ustar00rootroot00000000000000schema = 1 [[word]] text = "pan" excluded = { salutation = { reason = "Polish and Czech Mr., and the Chinese surname Pan, which leads", issue = 606, decided = 2026-10-04 } } [[word]] text = "hrabia" nobility = { gender = "m", expands = "count", issue = 606, note = "borne: ~750 surnames (Poland, where registers often write the surname first), forebears.io, worldwide, rounded, 2026-10-04; shipped under the graf class (decisions/salutation-titles)" } [[word]] text = "vel" given = { us_ssa = 24, wikidata = 6, as_of = 2026-10-25, note = "US SSA births summed over both sexes; Wikidata is a lower bound" } alias = { expands = "or, between two surnames: \"Kowalski vel Nowak\"" } [[word]] text = "i" conjunction = { may_be_initial = true } [[word]] text = "z domu" [word.birth_marker] issue = 434 expands = "of the house" note = "a phrase matched whole: z and domu alone are an ordinary preposition and noun" derek73-python-nameparser-456b024/nameparser/vocab/pt-BR.toml000066400000000000000000000101051527303271700237540ustar00rootroot00000000000000schema = 1 [[word]] text = "fil" excluded = { agnomen = { reason = "Filho written short in 10 tse-donors filed/registry pairs (2026-10-25), but a Slavic and Polish surname (US Census 220, Wikidata 6 with Poland and Ukraine) that a name of three words would lose to the suffix: the measurement against master moved 11 tse-donors names toward the registry and two UK PSC filers' surnames Fil away from the box they typed it in, one a regression, and a JRC-Names surname behind an initial", issue = [843, 752], decided = 2026-10-27 } } [[word]] text = "fh" agnomen = { same_as = "filho", gender = "m", issue = [843, 752], expands = "the son, abbreviated", note = "tse-donors (2026-10-25) has 230 filed/registry pairs writing FH where the registry writes FILHO; NPPES has no provider writing it as a credential" } family = { note = "no source counts it as a name; declared as filho's is, so that two words ending in it keep it as the family name" } [[word]] text = "filh" agnomen = { same_as = "filho", gender = "m", issue = [843, 752], expands = "the son, abbreviated", note = "tse-donors (2026-10-25) has 17 filed/registry pairs writing FILH where the registry writes FILHO" } family = { note = "no source counts it as a name; declared as filho's is, so that two words ending in it keep it as the family name" } [[word]] text = "fo" agnomen = { same_as = "filho", gender = "m", issue = [843, 752], expands = "the son, abbreviated", note = "tse-donors (2026-10-25) has 99 filed/registry pairs writing FO where the registry writes FILHO; NPPES has no provider writing it as a credential" } family = { us_census_2010 = 238, wikidata = 6, as_of = 2026-10-27, note = "Italian (Dario Fo) and Asian surname bearers; a name of two words keeps it as the family name, and a third word before it reads it as the agnomen, reported (rules/S11's Accepted clause)" } [[word]] text = "net" agnomen = { same_as = "neto", gender = "m", issue = [843, 752], expands = "the grandson, abbreviated", note = "tse-donors (2026-10-25) has 12 filed/registry pairs writing NET where the registry writes NETO; NPPES has no provider writing it as a credential" } family = { us_census_2010 = 118, wikidata = 2, as_of = 2026-10-27 } [[word]] text = "netto" agnomen = { same_as = "neto", gender = "m", issue = [843, 752], expands = "the grandson, in the spelling of Brazil's older orthography" } family = { us_census_2010 = 1243, wikidata = 30, as_of = 2026-10-25 } [[word]] text = "nt" agnomen = { same_as = "neto", gender = "m", issue = [843, 752], expands = "the grandson, abbreviated", note = "tse-donors (2026-10-25) has 122 filed/registry pairs writing NT where the registry writes NETO, and 6 more for NETTO; NPPES has 13 providers writing NT as a credential (2026-10-27), read as a suffix either way after a comma" } family = { note = "no source counts it as a name; declared as neto's is, so that two words ending in it keep it as the family name" } [[word]] text = "sb" excluded = { agnomen = { reason = "Sobrinho written short in 11 tse-donors filed/registry pairs (2026-10-25), but also S.B., the Bachelor of Science, written SB by 23 ClinicalTrials.gov officials (9 after a comma) and 2 NPPES providers (2026-10-27): listed as an agnomen it took the degree as Sobrinho's spelling and capitalized(force=True) recased it to Sb. A credential reading is the question to settle first", issue = [843, 752], decided = 2026-10-27 } } [[word]] text = "sob" agnomen = { same_as = "sobrinho", gender = "m", issue = [843, 752], expands = "the nephew, abbreviated", note = "tse-donors (2026-10-25) has 4 filed/registry pairs writing SOB where the registry writes SOBRINHO" } family = { note = "no source counts it as a name; declared as sobrinho's is, so that two words ending in it keep it as the family name" } [[word]] text = "sobr" agnomen = { same_as = "sobrinho", gender = "m", issue = [843, 752], expands = "the nephew, abbreviated", note = "tse-donors (2026-10-25) has 19 filed/registry pairs writing SOBR where the registry writes SOBRINHO" } family = { note = "no source counts it as a name; declared as sobrinho's is, so that two words ending in it keep it as the family name" } derek73-python-nameparser-456b024/nameparser/vocab/pt.toml000066400000000000000000000103461527303271700234620ustar00rootroot00000000000000schema = 1 [[word]] text = "senhor" salutation = { gender = "m", after_given = true } [[word]] text = "sr" salutation = { gender = "m", same_as = "senhor" } [[word]] text = "senhora" salutation = { gender = "f", after_given = true } [[word]] text = "sra" salutation = { gender = "f", same_as = "senhora", after_given = true } [[word]] text = "senhorita" salutation = { gender = "f", after_given = true } [[word]] text = "dra" academic = { gender = "f", expands = "Doctora, Doutora" } [[word]] text = "júnior" [word.generation] same_as = "jr" issue = 843 note = "the standard Portuguese spelling of Junior: tse-donors (2026-10-22) has 991 distinct filed names ending in it, 983 records ending in it where the tax registry's name ends JUNIOR. Its collisions are junior's: a given name (Wikidata 37+ as Júnior, 2026-10-23) where the suffix claim does not act, and 49+ Wikidata family bearers" [[word]] text = "filha" agnomen = { gender = "f", issue = [843, 752], expands = "the daughter", note = "the feminine of filho; tse-donors (2026-10-22) has 66 distinct filed names ending in it" } family = { note = "no source counts it as a name; declared as filho's is, so that two words ending in it keep it as the family name" } [[word]] text = "filho" agnomen = { gender = "m", issue = [843, 752], expands = "the son", note = "an agnomen marks its bearer against a relative of the same name, as Júnior does, and is written in the case of the name around it, so only the count gates it (rules/S11); tse-donors (2026-10-22) has 10,973 distinct filed names ending in it" } family = { us_census_2010 = 513, wikidata = 136, as_of = 2026-10-25 } [[word]] text = "neta" agnomen = { gender = "f", issue = [843, 752], expands = "the granddaughter", note = "the feminine of neto; tse-donors (2026-10-22) has 152 distinct filed names ending in it" } given = { us_ssa = 3226, wikidata = 31, as_of = 2026-10-25, note = "female births only; mostly the Hebrew given name Neta" } family = { us_census_2010 = 115, wikidata = 2, as_of = 2026-10-25 } [[word]] text = "neto" agnomen = { gender = "m", issue = [843, 752], expands = "the grandson", note = "an agnomen marks its bearer against a relative of the same name, as Júnior does, and is written in the case of the name around it, so only the count gates it (rules/S11); tse-donors (2026-10-22) has 8,434 distinct filed names ending in it" } given = { wikidata = 12, as_of = 2026-10-25 } family = { us_census_2010 = 1645, wikidata = 276, as_of = 2026-10-25 } [[word]] text = "sobrinha" agnomen = { gender = "f", issue = [843, 752], expands = "the niece", note = "the feminine of sobrinho; tse-donors (2026-10-22) has 5 distinct filed names ending in it" } family = { note = "no source counts it as a name; declared as sobrinho's is, so that two words ending in it keep it as the family name" } [[word]] text = "sobrinho" agnomen = { gender = "m", issue = [843, 752], expands = "the nephew", note = "an agnomen marks its bearer against a relative of the same name, as Júnior does, and is written in the case of the name around it, so only the count gates it (rules/S11); tse-donors (2026-10-22) has 955 distinct filed names ending in it" } family = { us_census_2010 = 166, wikidata = 18, as_of = 2026-10-25 } [[word]] text = "da" preposition = { expands = "of the, fem. sg." } [[word]] text = "das" [word.preposition] may_be_given = false issue = 360 expands = "of the, fem. pl." note = "Added #360: its own plural partner 'dos' was already here. \"Das\" IS a common Bengali surname, but a TRAILING one -- measured, \"Anjali Das\" and \"Bimal Das\" are untouched, while \"Maria das Neves\" gains the particle it loses today." [[word]] text = "de" preposition = { may_be_given = false, expands = "of" } [[word]] text = "do" [word.preposition] expands = "of the, masc. sg." note = "Vietnamese Đỗ leads a great many surnames" [[word]] text = "dos" preposition = { may_be_given = false, expands = "of the, masc. pl." } [[word]] text = "santa" saint = { expands = "Saint, fem." } [[word]] text = "e" [word.conjunction] may_be_initial = true issue = [383, 479] note = "a bare E initial is common (Edward, Elizabeth) and an 'e' between two surnames is rare outside couple listings, so a name written wholly in one case reads it as an initial and reports the fork (decisions/P8)" derek73-python-nameparser-456b024/nameparser/vocab/ro.toml000066400000000000000000000006611527303271700234560ustar00rootroot00000000000000schema = 1 [[word]] text = "doamnă" salutation = { issue = 606, gender = "f", after_given = true } [[word]] text = "doamna" salutation = { issue = 606, gender = "f", same_as = "doamnă", expands = "doamnă without the breve", after_given = true } [[word]] text = "domnișoara" salutation = { issue = 606, gender = "f", after_given = true } [[word]] text = "domnul" salutation = { issue = 606, gender = "m", after_given = true } derek73-python-nameparser-456b024/nameparser/vocab/ru.toml000066400000000000000000000043011527303271700234570ustar00rootroot00000000000000schema = 1 [[word]] text = "г-жа" salutation = { gender = "f", issue = 269, expands = "госпожа, Mrs.", after_given = true } [[word]] text = "г-н" salutation = { gender = "m", issue = 269, expands = "господин, Mr.", after_given = true } [[word]] text = "акад" academic = { issue = 269, expands = "academician" } [[word]] text = "д-р" academic = { issue = 269, expands = "doctor", note = "a plain title, the family name following it as after 'dr' (not GIVEN_NAME_TITLES: 'д-р Петров' families the surname just as 'Dr. Smith' does)" } [[word]] text = "проф" academic = { issue = 269, expands = "professor" } [[word]] text = "tsar" nobility = { gender = "m", expands = "the Russian emperor", native = "царь" } [[word]] text = "tsarina" nobility = { gender = "f", native = "царица" } [[word]] text = "мл" [word.generation] issue = 679 note = "мл.; #679 found nothing it collides with in Cyrillic" [[word]] text = "младшая" generation = { issue = 661 } [[word]] text = "младший" generation = { issue = 661 } [[word]] text = "ст" [word.excluded.generation] reason = "barely attested after a name, and a plausible false positive among two-letter Cyrillic abbreviations; deferred since #269" issue = [269, 661, 679] [[word]] text = "старшая" generation = { issue = 661 } [[word]] text = "старший" generation = { issue = 661 } [[word]] text = "и" [word.conjunction] issue = 269 [word.conjunction.excluded] may_be_initial = { reason = "#267 blessed the Cyrillic joining, and marking it would break that reading in the one-case spellings where nothing else decides", issue = [383, 479] } [[word]] text = "урожд" birth_marker = { issue = 274 } [[word]] text = "урождённая" birth_marker = { issue = 274, gender = "f" } [[word]] text = "урожденная" [word.birth_marker] issue = 274 gender = "f" same_as = "урождённая" note = "the е spelling: case normalization does not fold ё, and running text routinely writes е" [[word]] text = "урождённый" birth_marker = { issue = 274, gender = "m" } [[word]] text = "урожденный" birth_marker = { issue = 274, gender = "m", same_as = "урождённый" } derek73-python-nameparser-456b024/nameparser/vocab/sd.toml000066400000000000000000000004721527303271700234440ustar00rootroot00000000000000schema = 1 [[word]] text = "جونئیر" generation = { issue = 679 } [[word]] text = "جونيئر" generation = { issue = 679 } [[word]] text = "جونیئر" generation = { issue = 679 } [[word]] text = "سینئر" generation = { issue = 679 } [[word]] text = "سینیئر" generation = { issue = 679 } derek73-python-nameparser-456b024/nameparser/vocab/shn.toml000066400000000000000000000001321527303271700236170ustar00rootroot00000000000000schema = 1 [[word]] text = "ၵျူႇၼီႇယႃႇ" generation = { issue = 679 } derek73-python-nameparser-456b024/nameparser/vocab/si.toml000066400000000000000000000001211527303271700234400ustar00rootroot00000000000000schema = 1 [[word]] text = "ජූනියර්" generation = { issue = 679 } derek73-python-nameparser-456b024/nameparser/vocab/sk.toml000066400000000000000000000011341527303271700234470ustar00rootroot00000000000000schema = 1 [[word]] text = "ml" [word.excluded.generation] reason = "pack-shaped: real and standard, but collides with ML initials in other languages written in Latin script" issue = [661, 679, 684] [[word]] text = "mladšia" generation = { issue = 679 } [[word]] text = "mladší" generation = { issue = 661 } [[word]] text = "st" [word.excluded.generation] reason = "pack-shaped: real and standard, but St. is Saint in other languages written in Latin script" issue = [661, 679, 684] [[word]] text = "staršia" generation = { issue = 679 } [[word]] text = "starší" generation = { issue = 661 } derek73-python-nameparser-456b024/nameparser/vocab/sl.toml000066400000000000000000000011341527303271700234500ustar00rootroot00000000000000schema = 1 [[word]] text = "ml" [word.excluded.generation] reason = "pack-shaped: real and standard, but collides with ML initials in other languages written in Latin script" issue = [661, 679, 684] [[word]] text = "mlajša" generation = { issue = 679 } [[word]] text = "mlajši" generation = { issue = 679 } [[word]] text = "st" [word.excluded.generation] reason = "pack-shaped: real and standard, but St. is Saint in other languages written in Latin script" issue = [661, 679, 684] [[word]] text = "starejša" generation = { issue = 679 } [[word]] text = "starejši" generation = { issue = 679 } derek73-python-nameparser-456b024/nameparser/vocab/sr.toml000066400000000000000000000022201527303271700234530ustar00rootroot00000000000000schema = 1 [[word]] text = "gospodin" salutation = { issue = 606, gender = "m", after_given = true, note = "borne as a given name; nam_dict ranks Gospodin a Bulgarian given name, band 5 (2026-10-24); kept for after_given (decisions/H8): a Bulgarian middle name is a patronymic, Gospodinov; kept here, not as a `given` reading, which would make the title one only inside a title phrase (rules/H9)" } [[word]] text = "gospođa" salutation = { issue = 606, gender = "f", after_given = true } [[word]] text = "gospođica" salutation = { issue = 606, gender = "f", after_given = true } [[word]] text = "mlađa" generation = { issue = 661 } [[word]] text = "mlađi" generation = { issue = 661 } [[word]] text = "starija" generation = { issue = 661 } [[word]] text = "stariji" generation = { issue = 661 } [[word]] text = "мл" [word.generation] issue = 679 note = "мл.; #679 found nothing it collides with in Cyrillic" [[word]] text = "млађа" generation = { issue = 661 } [[word]] text = "млађи" generation = { issue = 661 } [[word]] text = "старија" generation = { issue = 661 } [[word]] text = "старији" generation = { issue = 661 } derek73-python-nameparser-456b024/nameparser/vocab/sv.toml000066400000000000000000000022151527303271700234630ustar00rootroot00000000000000schema = 1 [[word]] text = "fru" salutation = { gender = "f", note = "Fru is also a Cameroonian surname (~10,800, often written surname-first), so 'Fru Anna' reads title Fru; a collision older than #606, recorded in decisions/salutation-titles rather than argued", excluded = { after_given = "a Cameroonian surname (~10,800, the note above), and middle names are drawn from surnames" } } family = { us_census_2010 = 132, as_of = 2026-10-24 } [[word]] text = "fröken" salutation = { issue = 606, gender = "f", after_given = true } [[word]] text = "frk" salutation = { gender = "f", same_as = "fröken", after_given = true } [[word]] text = "herr" salutation = { gender = "m" } [[word]] text = "hr" salutation = { gender = "m", same_as = "herr" } [[word]] text = "greve" nobility = { gender = "m", expands = "count", issue = 606, note = "borne, so 'Greve Anna' reads title Greve; one of the three words #606 proposed the graf class for" } [[word]] text = "af" preposition = { may_be_given = false, expands = "of, nobiliary" } [[word]] text = "av" preposition = { may_be_given = false, expands = "of" } [[word]] text = "född" birth_marker = { issue = 274 } derek73-python-nameparser-456b024/nameparser/vocab/sw.toml000066400000000000000000000005611527303271700234660ustar00rootroot00000000000000schema = 1 [[word]] text = "bwana" salutation = { gender = "m", expands = "Mr., sir", after_given = true, note = "borne as a given name: US SSA 5 births (2026-10-24); kept here, not as a `given` reading, which would make the title one only inside a title phrase (rules/H9)" } family = { wikidata = 1, as_of = 2026-10-24, note = "kept for after_given (decisions/H8)" } derek73-python-nameparser-456b024/nameparser/vocab/ta.toml000066400000000000000000000005511527303271700234400ustar00rootroot00000000000000schema = 1 [[word]] text = "thiru" excluded = { salutation = { reason = "Tamil Mr., and a Tamil given name", issue = 606, decided = 2026-10-04 } } [[word]] text = "இளையவர்" generation = { issue = 679 } [[word]] text = "ஜூனியர்" generation = { issue = 679 } [[word]] text = "மூத்தவர்" generation = { issue = 679 } derek73-python-nameparser-456b024/nameparser/vocab/te.toml000066400000000000000000000002271527303271700234440ustar00rootroot00000000000000schema = 1 [[word]] text = "జూనియర్" generation = { issue = 679 } [[word]] text = "సీనియర్" generation = { issue = 679 } derek73-python-nameparser-456b024/nameparser/vocab/th.toml000066400000000000000000000002351527303271700234460ustar00rootroot00000000000000schema = 1 [[word]] text = "จูเนียร์" generation = { issue = 679 } [[word]] text = "ซีเนียร์" generation = { issue = 679 } derek73-python-nameparser-456b024/nameparser/vocab/ti.toml000066400000000000000000000002051527303271700234440ustar00rootroot00000000000000schema = 1 [[word]] text = "ጁኒየር" generation = { issue = 679 } [[word]] text = "ጁንየር" generation = { issue = 679 } derek73-python-nameparser-456b024/nameparser/vocab/tr.toml000066400000000000000000000026161527303271700234650ustar00rootroot00000000000000schema = 1 [[word]] text = "bay" family = { us_census_2010 = 6350, wikidata = 120, as_of = 2026-10-17, note = "11 of the Wikidata bearers Hungarian" } excluded = { salutation = { reason = "Turkish Mr., and a surname borne in Hungary, which writes the surname first, where the title claim acts, so it fails C-i", issue = 662, decided = 2026-10-17 } } [[word]] text = "effendi" style = { expands = "the Ottoman efendi, in its conventional Latin spelling" } [[word]] text = "sultan" nobility = { note = "no gender: Ottoman usage gave it to imperial women too, written after the name (Hürrem Sultan)", issue = 752, decided = 2026-10-26 } given = { us_ssa = 2396, wikidata = 151, as_of = 2026-10-26, note = "US SSA: male births; Wikidata is a lower bound (Turkey 28, Saudi Arabia 13); nam_dict band 7 in Turkey, 5 in Central Asia; a title only inside a title phrase (rules/H9): 'Sultan Khan' and 'Mr Sultan Khan' read the given name, unreported" } family = { us_census_2010 = 3363, wikidata = 106, as_of = 2026-10-26, note = "Wikidata: Ottoman Empire 26, the title written after the name, which reads as a name word" } [[word]] text = "baba" [word.excluded.generation] reason = "collides inside Turkish, and Turkish puts the junior word before the name" issue = 679 [[word]] text = "kıdemli" [word.excluded.generation] reason = "a rank word (Kıdemli onbaşı); Turkish writes Jr. or a parenthesis, (oğul)" issue = 661 derek73-python-nameparser-456b024/nameparser/vocab/uk.toml000066400000000000000000000030051527303271700234500ustar00rootroot00000000000000schema = 1 [[word]] text = "пан" salutation = { gender = "m", issue = 269, expands = "Mr.", excluded = { after_given = "a surname in Cyrillic (Wikidata), and middle names are drawn from surnames" } } family = { wikidata = 743, as_of = 2026-10-24 } [[word]] text = "пані" salutation = { gender = "f", issue = 269, expands = "Mrs.", after_given = true } [[word]] text = "акад" academic = { issue = 269, expands = "academician" } [[word]] text = "д-р" academic = { issue = 269, expands = "doctor" } [[word]] text = "проф" academic = { issue = 269, expands = "professor" } [[word]] text = "молодша" generation = { issue = 661 } [[word]] text = "молодший" generation = { issue = 661 } [[word]] text = "старша" generation = { issue = 661 } [[word]] text = "та" conjunction = { issue = 269 } [[word]] text = "і" [word.conjunction] issue = 269 note = "Ukrainian writes і and й for the same conjunction, alternating for euphony ('Олесь і Олена', 'Марія й Петро'), so real data carries both" [word.conjunction.excluded] may_be_initial = { reason = "#267 blessed the Cyrillic joining, and marking it would break that reading in the one-case spellings where nothing else decides", issue = [383, 479] } [[word]] text = "й" [word.conjunction] issue = 269 same_as = "і" [word.conjunction.excluded] may_be_initial = { reason = "#267 blessed the Cyrillic joining, and marking it would break that reading in the one-case spellings where nothing else decides", issue = [383, 479] } derek73-python-nameparser-456b024/nameparser/vocab/und.toml000066400000000000000000000025621527303271700236260ustar00rootroot00000000000000# Words no language file fits. Every other word of the vocabulary is in # the file of a language whose names it occurs in (#749, #750, #751). # # bon and dí were particles from the earliest history (bon in the 2011 # svn import, 45a15394; dí since fc2ea899, 2012), with no record of which # language's names they come from and no particle use found, so #749 # could give them neither a file nor a kind. #766 removed the particle # reading; each keeps its name readings and the exclusion that says why. schema = 1 [[word]] text = "bon" given = { us_ssa = 274, wikidata = 9, as_of = 2026-10-19, note = "male births only" } family = { us_census_2010 = 1123, wikidata = 102, as_of = 2026-10-19, note = "Wikidata bearers mostly French and Italian" } excluded = { particle = { reason = "no particle use found in any name, and it is borne as a given name and as a surname (the counts on its name readings), which the particle reading joined to the next word (Marie Bon Durand read family 'Bon Durand')", issue = 766 } } [[word]] text = "dí" given = { wikidata = 7, as_of = 2026-10-19, note = "5 of 7 PRC citizens; the US sources fold the accent to Di, so their counts are not this spelling's" } excluded = { particle = { reason = "no particle use found in any name; its Wikidata bearers are Chinese given names romanized Dí, which the particle reading joined to the next word", issue = 766 } } derek73-python-nameparser-456b024/nameparser/vocab/ur.toml000066400000000000000000000013061527303271700234610ustar00rootroot00000000000000schema = 1 [[word]] text = "janab" excluded = { salutation = { reason = "unverified, so not shipped: the Devanagari जनाब and Bengali জনাব ship, and the Latin spelling needs its own check (the sri/shri pattern)", issue = 606, decided = 2026-10-04 } } [[word]] text = "جونئیر" generation = { issue = 679 } [[word]] text = "جونيئر" generation = { issue = 679 } [[word]] text = "جونیئر" generation = { issue = 679 } [[word]] text = "سینئر" generation = { issue = 679 } [[word]] text = "سینیئر" generation = { issue = 679 } [[word]] text = "ul-" [word.article] may_be_given = false issue = 700 expands = "the, after a case ending: \"Zia ul-Haq\"" same_as = "al-" derek73-python-nameparser-456b024/nameparser/vocab/vi.toml000066400000000000000000000010661527303271700234540ustar00rootroot00000000000000schema = 1 [[word]] text = "bà" excluded = { salutation = { reason = "Vietnamese Mrs., refused as ông's pair without evidence of its own: #606 judged the excluded side from general knowledge, with no count taken (decisions/salutation-titles)", issue = 606, decided = 2026-10-04 } } [[word]] text = "ông" excluded = { salutation = { reason = "Ông is a Vietnamese surname and Vietnamese names lead with the surname, the position the title claim acts on; pinned by the case row salutation_that_leads_a_surname_stays_a_name", issue = 606, decided = 2026-10-04 } } derek73-python-nameparser-456b024/nameparser/vocab/yi.toml000066400000000000000000000000631527303271700234530ustar00rootroot00000000000000schema = 1 [[word]] text = "rebbe" religious = {} derek73-python-nameparser-456b024/nameparser/vocab/yo.toml000066400000000000000000000003701527303271700234620ustar00rootroot00000000000000schema = 1 [[word]] text = "elerunwon" nobility = { expands = "the ruler of Erunwon" } [[word]] text = "timi" nobility = { expands = "the ruler of Ede" } [[word]] text = "ajala" [word.excluded.generation] reason = "a Yoruba surname" issue = 679 derek73-python-nameparser-456b024/nameparser/vocab/zh.toml000066400000000000000000000075161527303271700234650ustar00rootroot00000000000000schema = 1 [[word]] text = "二世" [word.generation] issue = 671 note = "the generational numeral \"II\"" [word.generation.attached] side = "after" issue = 707 note = "#707's one ends-with hit, 厄德二世, is the given name Odo with a regnal numeral in its label" [[word]] text = "博士" [word.credential] acronym = false issue = [307, 308] note = "doctorate holder, shared Han; an attested Japanese given name (ひろし, Hiroshi), but the doctorate reading vastly dominates the spaced trailing position" excluded = { attached = "glued 田中博士 IS Tanaka Hiroshi, an attested given name" } [[word]] text = "醫學博士" [word.credential] acronym = false issue = 671 note = "Doctor of Medicine" attached = { side = "after", issue = 707 } [[word]] text = "医学博士" [word.credential] same_as = "醫學博士" acronym = false issue = 671 note = "Doctor of Medicine" attached = { side = "after", issue = 707 } [[word]] text = "君" [word.excluded.honorific] reason = "王君 is a complete Chinese name, 君 being a common given-name final; its kana spelling くん ships" issue = 308 [[word]] text = "女士" [word.honorific] issue = [307, 308] note = "Ms./Madam" attached = { side = "after", issue = 308 } [[word]] text = "小姐" [word.honorific] issue = [307, 308] note = "Miss" attached = { side = "after", issue = 308 } [[word]] text = "先生" [word.professional_honorific] issue = [307, 308] note = "zh Mr. / ja teacher-master, an honorific in both" attached = { side = "after", issue = 308 } [[word]] text = "國會議員" [word.professional_honorific] issue = 671 note = "Diet member" attached = { side = "after", issue = 707 } [[word]] text = "太平紳士" [word.professional_honorific] issue = 671 note = "Justice of the Peace (Hong Kong)" attached = { side = "after", issue = 707 } [[word]] text = "太平绅士" [word.professional_honorific] same_as = "太平紳士" issue = 671 note = "Justice of the Peace (Hong Kong)" attached = { side = "after", issue = 707 } [[word]] text = "教授" [word.professional_honorific] issue = [307, 308] note = "professor, shared Han" attached = { side = "after", issue = 308 } [[word]] text = "社長" [word.professional_honorific] issue = 671 note = "president (ja), society head (zh); a job title used as a form of address after a name, the same slot as 先生" attached = { side = "after", issue = 707 } [[word]] text = "社长" [word.professional_honorific] same_as = "社長" issue = 671 note = "simplified; 长 never occurs in Japanese text, so the entry touches no Japanese name" attached = { side = "after", issue = 707 } [[word]] text = "課長" [word.professional_honorific] issue = 671 note = "section chief; a form of address after a name" attached = { side = "after", issue = 707 } [[word]] text = "课长" [word.professional_honorific] same_as = "課長" issue = 671 note = "simplified twin of 課長" attached = { side = "after", issue = 707 } [[word]] text = "議員" [word.professional_honorific] issue = 671 note = "legislator" attached = { side = "after", issue = 707 } [[word]] text = "议员" [word.professional_honorific] same_as = "議員" issue = 671 note = "legislator" attached = { side = "after", issue = 707 } [[word]] text = "部長" [word.professional_honorific] issue = 671 note = "department head (ja), minister (zh); a form of address after a name" [word.professional_honorific.excluded] attached = "長 alone is a borne given name, so 阿部長 can be 阿部 + 長 (#707). The measurement found no such bearer, as it found none for the words that shipped; the refusal rests on the argument, which a lower-bound zero does not refute, so a re-measure that again comes back zero is no reason to lift it" [[word]] text = "部长" [word.professional_honorific] same_as = "部長" issue = 671 note = "simplified twin of 部長" excluded = { attached = "the simplified twin of 部長, declined with it (#707)" } derek73-python-nameparser-456b024/nameparser/vocab/zu.toml000066400000000000000000000002221527303271700234650ustar00rootroot00000000000000schema = 1 [[word]] text = "isizukulwane" [word.excluded] generation = { reason = "CLDR label translation: it means 'generation'", issue = 661 } derek73-python-nameparser-456b024/pyproject.toml000066400000000000000000000133301527303271700216230ustar00rootroot00000000000000[project] name = "nameparser" description = "A simple Python module for parsing human names into their individual components." readme = "README.rst" requires-python = ">=3.11" license = "LGPL-2.1-or-later" license-files = ["LICENSE", "AUTHORS"] authors = [{name = "Derek Gulbranson", email = "derek73@gmail.com"}] dynamic = ["version"] keywords = ["names", "parser"] classifiers = [ "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.15", "Development Status :: 5 - Production/Stable", "Natural Language :: English", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Text Processing :: Linguistic", ] dependencies = [] [project.optional-dependencies] # Japanese kanji-name segmentation (#272), wrapped by # locales.ja_segmenter(). Optional and never bundled: the core stays # zero-dependency. 0.4 is the floor its BasicNameDivider/DividedName # API dates from. ja = ["namedivider-python>=0.4"] [build-system] requires = ["setuptools (>=82.0.1)"] build-backend = "setuptools.build_meta" [tool.setuptools.dynamic] version = {attr = "nameparser._version.__version__"} [tool.setuptools.package-data] # The per-word vocabulary ships as data for tools and later phases to # read; the parser itself reads the compiled module, never the TOML. nameparser = ["py.typed", "vocab/*.toml"] [dependency-groups] dev = [ "pytest (>=8)", "dill (>=0.2.5)", "sphinx (>=8)", "furo", "mypy (>=2.1)", "ruff (>=0.15)", "pytest-timeout>=2.4.0", "pytest-cov>=6", "pytest-xdist>=3.6", "hypothesis", ] [tool.mypy] packages = ["nameparser", "tests"] [[tool.mypy.overrides]] module = [ "dill.*", # optional extras, unstubbed and absent from the dev environment: # the ja pack imports namedivider lazily, inside the factory "namedivider.*", ] ignore_missing_imports = true [[tool.mypy.overrides]] module = [ "nameparser._types", "nameparser._lexicon", "nameparser._policy", "nameparser._render", "nameparser._locale", "nameparser._pipeline.*", "nameparser._parser", ] disallow_untyped_defs = true disallow_incomplete_defs = true disallow_any_generics = true warn_return_any = true strict_equality = true # strict_equality for the WHOLE package rather than the core modules # above. A comparison between non-overlapping types is always False and # is always a bug, and the facade layer -- which the block above does # not name -- is exactly where a v1/v2 translation makes one likeliest. # Measured 2026-09-18: clean over nameparser/ as it stands, so this # costs nothing today and catches the next one. tests/ keep the default, # where the check has real hits (a test comparing an off-contract value # to a real one is doing its job). [[tool.mypy.overrides]] module = ["nameparser", "nameparser.*"] strict_equality = true [[tool.mypy.overrides]] module = ["tests.v2.*"] check_untyped_defs = true [tool.pytest.ini_options] testpaths = ["tests", "nameparser"] # Parallel by default: the suite is CPU-bound and took 110s serial # against 35s on 8 workers (measured 2026-10-23). worksteal because the # property grids are a few long tests among 23,000 short ones, and # fixed chunks left one worker running them alone. -n0 runs serially. addopts = "--doctest-modules -n auto --dist worksteal" markers = [ # deselected in a parallel run by tests/conftest.py; run them with # `uv run pytest -n0 -m serial` "serial: measures the clock, so other workers' load can fail it", ] python_classes = ["*Tests", "*TestCase"] # Fail if an xfail-marked test unexpectedly passes, so a fixed/regressed # expectation surfaces instead of silently turning into an xpass. xfail_strict = true # Unexpected warnings are failures: warnings are load-bearing API in 2.0 # (the multi-word-entry UserWarning, the facade deprecation warnings), and # without this a warning regression is invisible to the suite -- a real # bug (eight spurious warnings on restoring a 1.4 Constants pickle) # shipped to review this way. Tests that EXPECT a warning assert it with # pytest.warns; targeted ignores below need a comment justifying each. filterwarnings = [ "error", # 2.8's 3.0-removal warnings, ignored BY MESSAGE so any other # DeprecationWarning still fails the suite; tests/v2/test_deprecations.py # asserts each one, and the v1 suite exercises the facade on purpose "ignore:HumanName is deprecated and will be removed in 3.0:DeprecationWarning", "ignore:HumanName pickles stop loading in 3.0:DeprecationWarning", "ignore:Constants is deprecated and will be removed in 3.0:DeprecationWarning", "ignore:Constants pickles stop loading in 3.0:DeprecationWarning", "ignore:nameparser.config.(Set|Tuple|RegexTuple)Manager is deprecated:DeprecationWarning", "ignore:nameparser.util.lc is deprecated:DeprecationWarning", # 2.8.1's: the Lexicon set fields (#754) and the constants of the # nameparser.config modules 3.0 deletes (#1007, #1008), which the # suite reads throughout; tests/v2/test_removals_2_8_1.py asserts each "ignore:Lexicon's set fields are deprecated:DeprecationWarning", "ignore:nameparser\\.config\\.\\w+\\.\\w+ is deprecated and will be removed in 3\\.0 \\(#100[78]\\):DeprecationWarning", ] [tool.ruff.lint] extend-select = [ "ANN", # flake8-annotations "UP", # pyupgrade "B033", # duplicate set items: a frozenset drops them silently, so a bulk vocabulary import can carry them unseen ] ignore = [ "E74", # ambiguous-{variable,class,function}-name (I have trouble believing that these are real rules) ] derek73-python-nameparser-456b024/tests/000077500000000000000000000000001527303271700200515ustar00rootroot00000000000000derek73-python-nameparser-456b024/tests/__init__.py000066400000000000000000000000001527303271700221500ustar00rootroot00000000000000derek73-python-nameparser-456b024/tests/base.py000066400000000000000000000060151527303271700213370ustar00rootroot00000000000000from typing import Any, ClassVar, Generic, TypeVar from nameparser import HumanName from nameparser.config import Constants T = TypeVar('T') class HumanNameTestBase(Generic[T]): """Shared assert helpers for the parsing tests. Formerly subclassed unittest.TestCase. It is now a plain class so pytest can apply the parametrized dual-run fixture in conftest.py — parametrized fixtures do not apply to unittest.TestCase subclasses. The assert* methods are thin shims so existing test bodies move over unchanged. """ def m(self, actual: T, expected: T, hn: HumanName) -> None: """assertEqual with a better message. ``expected or ''`` used to fall back to ``hn.C.empty_attribute_default``, which could be '' or None depending on which of the (formerly dual-run) settings a test ran under. That setting was removed in 2.0 (#255): empty attributes are always ''. """ expected_ = expected or '' try: assert actual == expected_, f"{actual!r} != {expected!r} for {hn.original!r}\n{hn!r}" except UnicodeDecodeError: assert actual == expected_, f"actual={actual!r} != expected={expected_!r}" def assertEqual(self, first: object, second: object, msg: object = None) -> None: assert first == second, msg def assertTrue(self, expr: object, msg: object = None) -> None: assert expr, msg def assertFalse(self, expr: object, msg: object = None) -> None: assert not expr, msg def assertIn(self, member: object, container: object, msg: object = None) -> None: assert member in container, msg # type: ignore[operator] def assertNotEqual(self, first: object, second: object, msg: object = None) -> None: assert first != second, msg def assertNotIn(self, member: object, container: object, msg: object = None) -> None: assert member not in container, msg # type: ignore[operator] def assertIs(self, first: object, second: object, msg: object = None) -> None: assert first is second, msg or f"{first!r} is not {second!r}" def assertIsNot(self, first: object, second: object, msg: object = None) -> None: assert first is not second, msg or f"{first!r} is {second!r}" def assertIsNone(self, expr: object, msg: object = None) -> None: assert expr is None, msg or f"{expr!r} is not None" def assertIsNotNone(self, expr: object, msg: object = None) -> None: assert expr is not None, msg or "unexpectedly None" class FlaggedConstantsTestBase(HumanNameTestBase[T]): """Base for test classes that parse with a dedicated, flagged Constants. Subclasses set ``constants_kwargs``; each test method gets a fresh ``Constants(**constants_kwargs)`` via ``setup_method``, and ``hn()`` parses with it. """ constants_kwargs: ClassVar[dict[str, Any]] = {} def setup_method(self) -> None: self.C = Constants(**self.constants_kwargs) def hn(self, name: str) -> HumanName: return HumanName(name, constants=self.C) derek73-python-nameparser-456b024/tests/conftest.py000066400000000000000000000110441527303271700222500ustar00rootroot00000000000000import copy import warnings from collections.abc import Iterator import pytest from nameparser.config import CONSTANTS # Scalar (non-collection) config attributes that individual tests mutate on the # global CONSTANTS singleton. Several tests change these without restoring them; # the original suite only survived because unittest happens to run methods in # alphabetical order, so a later test reset the value. pytest runs in definition # order, so we snapshot and restore these around every test to keep tests # isolated regardless of order. # # empty_attribute_default is gone from this list: it was removed in 2.0 (#255, # see nameparser/_config_shim.py's Constants.__setattr__), so there is no # longer a second parsing path to snapshot/restore or dual-run against. Empty # attributes are always ''. _SCALAR_CONFIG_ATTRS = ( "string_format", "initials_format", "initials_delimiter", "initials_separator", "suffix_delimiter", "capitalize_name", "force_mixed_case_capitalization", ) # Collection config attributes (the SetManager / TupleManager constants). Tests # that customize the global CONSTANTS — e.g. adding or removing a title — mutate # these in place, so a shallow snapshot of the reference would not protect later # tests. We snapshot independent copies and restore them, making collection # mutations order-independent too. # # regexes is gone from this list: it is a read-only _RegexesProxy in 2.0 (set # once in Constants.__init__), and reassigning it raises TypeError, so it can # neither be mutated by a test nor restored by this fixture. _COLLECTION_CONFIG_ATTRS = ( "prefixes", "suffix_acronyms", "suffix_not_acronyms", "titles", "first_name_titles", "conjunctions", "bound_first_names", "non_first_name_prefixes", "capitalization_exceptions", "nickname_delimiters", "maiden_delimiters", ) def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: """Deselect the ``serial`` tests in a parallel run's workers. The clock-timed scaling guards in tests/v2/test_benchmark.py bound a ratio of two timings, and the other workers' load stretches the larger input's more than the smaller's: under -n auto the maiden_clause row read 6.4x against its 6.0 bound on a tree that passes serially (measured 2026-10-23). They run in their own -n0 pass instead, which CI makes and the summary line below asks for. """ if not hasattr(config, "workerinput"): return serial = [item for item in items if item.get_closest_marker("serial")] if serial: items[:] = [item for item in items if not item.get_closest_marker("serial")] config.hook.pytest_deselected(items=serial) def pytest_terminal_summary(terminalreporter: pytest.TerminalReporter, config: pytest.Config) -> None: # a deselection nobody is told about is a guard that stopped running if hasattr(config, "workerinput") or not getattr(config.option, "numprocesses", None): return terminalreporter.write_line( "serial tests (tests/v2/test_benchmark.py) do not run in parallel; " "run them with: uv run pytest -n0 -m serial", yellow=True) @pytest.fixture(autouse=True) def _isolate_constants() -> Iterator[None]: """Snapshot and restore the shared CONSTANTS singleton around every test. Formerly also drove a parametrized dual-run over empty_attribute_default settings (reproducing the original tests.py __main__ block, which ran the whole suite twice as a regression check that the three parsing code paths agreed). That setting no longer exists in 2.0 (#255: empty attributes are always ''), so only the isolation half of the original fixture remains. """ scalar_snapshot = {attr: getattr(CONSTANTS, attr) for attr in _SCALAR_CONFIG_ATTRS} collection_snapshot = { attr: copy.deepcopy(getattr(CONSTANTS, attr)) for attr in _COLLECTION_CONFIG_ATTRS } yield # Restoring through the shared singleton is itself a mutation and would # otherwise emit the shared-CONSTANTS DeprecationWarning (#262) on every # single test's teardown; this fixture is infrastructure; it is not # exercising that deprecation, so it's silenced narrowly here rather than # at every test. with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) for attr, value in scalar_snapshot.items(): setattr(CONSTANTS, attr, value) for attr, value in collection_snapshot.items(): setattr(CONSTANTS, attr, value) derek73-python-nameparser-456b024/tests/removals.py000066400000000000000000000026051527303271700222560ustar00rootroot00000000000000"""2.8's and 2.8.1's 3.0-removal warnings, for tests that install their own filters. pyproject's ``filterwarnings`` ignores each of these by message, but a ``warnings.catch_warnings()`` block that calls ``simplefilter`` replaces those filters, so a test recording or erroring on warnings around the facade calls :func:`ignore_removals` after its ``simplefilter`` to keep asserting what it asserted before 2.8. tests/v2/test_deprecations.py holds this tuple to pyproject's list and asserts every warning in it. """ import warnings REMOVAL_MESSAGES = ( "HumanName is deprecated and will be removed in 3.0", "HumanName pickles stop loading in 3.0", "Constants is deprecated and will be removed in 3.0", "Constants pickles stop loading in 3.0", "nameparser.config.(Set|Tuple|RegexTuple)Manager is deprecated", "nameparser.util.lc is deprecated", # 2.8.1's: the Lexicon set fields (#754) and the constants of the # nameparser.config modules 3.0 deletes (#1007, #1008) "Lexicon's set fields are deprecated", r"nameparser\.config\.\w+\.\w+ is deprecated and will be removed in " r"3\.0 \(#100[78]\)", ) def ignore_removals() -> None: """Ignore the removal warnings by message, ahead of the filters in place.""" for message in REMOVAL_MESSAGES: warnings.filterwarnings("ignore", message=message, category=DeprecationWarning) derek73-python-nameparser-456b024/tests/test_bound_given_names.py000066400000000000000000000204351527303271700251500ustar00rootroot00000000000000from nameparser import HumanName from tests.base import HumanNameTestBase class BoundGivenNamesTestCase(HumanNameTestBase): # The v1 is_bound_first_name predicate is gone with the other v1 parsing # hooks (#280); the vocabulary's behavior is pinned through the parsing # tests below. # --- no-comma: basic joining --- def test_no_comma_basic_join(self) -> None: hn = HumanName("abdul salam ahmed salem") self.m(hn.first, "abdul salam", hn) self.m(hn.middle, "ahmed", hn) self.m(hn.last, "salem", hn) def test_no_comma_three_tokens_no_middle(self) -> None: hn = HumanName("abdul salam salem") self.m(hn.first, "abdul salam", hn) self.m(hn.middle, "", hn) self.m(hn.last, "salem", hn) def test_no_comma_guard_two_tokens_no_join(self) -> None: """Guard: only last name remains after prefix → no join.""" hn = HumanName("abdul salam") self.m(hn.first, "abdul", hn) self.m(hn.last, "salam", hn) def test_no_comma_guard_suffix_not_swallowed(self) -> None: """Guard: prefix + one name + suffix — suffix must not become last.""" hn = HumanName("abdul salam jr") self.m(hn.first, "abdul", hn) self.m(hn.last, "salam", hn) self.m(hn.suffix, "jr", hn) # --- lastname-comma path --- def test_lastname_comma_join(self) -> None: hn = HumanName("salem, abdul salam") self.m(hn.first, "abdul salam", hn) self.m(hn.middle, "", hn) self.m(hn.last, "salem", hn) def test_lastname_comma_join_with_middle(self) -> None: hn = HumanName("salem, abdul salam ahmed") self.m(hn.first, "abdul salam", hn) self.m(hn.middle, "ahmed", hn) self.m(hn.last, "salem", hn) # --- interaction with titles --- def test_title_kept_prefix_joins(self) -> None: hn = HumanName("Dr. abdul salam ahmed salem") self.m(hn.title, "Dr.", hn) self.m(hn.first, "abdul salam", hn) self.m(hn.middle, "ahmed", hn) self.m(hn.last, "salem", hn) # --- interaction with last-name prefixes --- def test_abu_bakr_al_baghdadi(self) -> None: """abu joins forward as first-prefix; al joins forward as last-prefix.""" hn = HumanName("abu bakr al baghdadi") self.m(hn.first, "abu bakr", hn) self.m(hn.last, "al baghdadi", hn) # --- interaction with suffixes --- def test_suffix_kept_prefix_joins(self) -> None: hn = HumanName("abdul salam ahmed salem jr") self.m(hn.first, "abdul salam", hn) self.m(hn.middle, "ahmed", hn) self.m(hn.last, "salem", hn) self.m(hn.suffix, "jr", hn) # --- guard / no-op --- def test_mohamad_unchanged(self) -> None: """mohamad is deliberately not in bound_first_names.""" hn = HumanName("Mohamad Ali Khalil") self.m(hn.first, "Mohamad", hn) self.m(hn.middle, "Ali", hn) self.m(hn.last, "Khalil", hn) def test_single_token_already_joined_unchanged(self) -> None: """abdulsalam is one token — not in the set, no join.""" hn = HumanName("abdulsalam ahmed salem") self.m(hn.first, "abdulsalam", hn) self.m(hn.middle, "ahmed", hn) self.m(hn.last, "salem", hn) def test_prefix_alone_no_join(self) -> None: """Single-word name that is a prefix: nothing to join.""" hn = HumanName("abdul") self.m(hn.first, "abdul", hn) def test_lastname_comma_prefix_only_no_join(self) -> None: """Prefix as sole post-comma token: nothing to join.""" hn = HumanName("salem, abdul") self.m(hn.first, "abdul", hn) self.m(hn.last, "salem", hn) def test_mid_name_prefix_becomes_last_prefix(self) -> None: """abu in non-first position is handled as a last-name prefix, not first-name.""" hn = HumanName("ahmed abu bakr") self.m(hn.first, "ahmed", hn) self.m(hn.last, "abu bakr", hn) # --- suffix-comma path --- def test_suffix_comma_join(self) -> None: hn = HumanName("Abdul Salam Hassan, MD") self.m(hn.first, "Abdul Salam", hn) self.m(hn.middle, "", hn) self.m(hn.last, "Hassan", hn) self.m(hn.suffix, "MD", hn) def test_suffix_comma_join_with_middle(self) -> None: hn = HumanName("Abdul Salam Ahmed Salem, MD") self.m(hn.first, "Abdul Salam", hn) self.m(hn.middle, "Ahmed", hn) self.m(hn.last, "Salem", hn) self.m(hn.suffix, "MD", hn) def test_suffix_comma_guard_two_tokens_no_join(self) -> None: """Guard: only last name remains after prefix → no join, even with suffix comma.""" hn = HumanName("Abdul Salam, MD") self.m(hn.first, "Abdul", hn) self.m(hn.last, "Salam", hn) self.m(hn.suffix, "MD", hn) def test_suffix_comma_title_kept_prefix_joins(self) -> None: hn = HumanName("Dr. Abdul Salam Hassan, MD") self.m(hn.title, "Dr.", hn) self.m(hn.first, "Abdul Salam", hn) self.m(hn.last, "Hassan", hn) self.m(hn.suffix, "MD", hn) def test_suffix_comma_abu_bakr_al_baghdadi(self) -> None: """abu joins forward as first-prefix; al joins forward as last-prefix, even with suffix comma.""" hn = HumanName("Abu Bakr Al Baghdadi, MD") self.m(hn.first, "Abu Bakr", hn) self.m(hn.last, "Al Baghdadi", hn) self.m(hn.suffix, "MD", hn) # --- opt-out --- def test_opt_out_via_clear(self) -> None: """Clearing bound_first_names restores prior behavior.""" from nameparser.config import Constants c = Constants(bound_first_names=set()) hn = HumanName("abdul salam ahmed salem", constants=c) self.m(hn.first, "abdul", hn) self.m(hn.middle, "salam ahmed", hn) self.m(hn.last, "salem", hn) # --- 'abd', which is also the postnominal ABD --- def test_abd_joins_the_word_after_it(self) -> None: """The spelling that writes the article separately.""" hn = HumanName("abd Allah Smith") self.m(hn.first, "abd Allah", hn) self.m(hn.last, "Smith", hn) def test_abd_joins_after_a_family_comma(self) -> None: hn = HumanName("Smith, abd Allah") self.m(hn.first, "abd Allah", hn) self.m(hn.last, "Smith", hn) def test_abd_pairwise_like_abdul(self) -> None: """Joins ONCE: 'Rahman' pairs, 'Ahmed' stays a middle name.""" hn = HumanName("abd Rahman Ahmed Salem") self.m(hn.first, "abd Rahman", hn) self.m(hn.middle, "Ahmed", hn) self.m(hn.last, "Salem", hn) def test_abd_joins_with_a_suffix_present(self) -> None: """The line this fix changes is a SUFFIX count, so the shapes that carry a real suffix piece are the ones it governs.""" for text, suffix in (("abd Allah Smith jr", "jr"), ("abd Allah Smith PhD", "PhD"), ("abd Allah Smith, PhD", "PhD"), ("abd Allah Smith III", "III")): hn = HumanName(text) self.m(hn.first, "abd Allah", hn) self.m(hn.middle, "", hn) self.m(hn.last, "Smith", hn) self.m(hn.suffix, suffix, hn) def test_both_readings_of_abd_in_one_name(self) -> None: """Position tells them apart -- the claim the collision rests on, in a single string: bound given name in front, postnominal behind.""" hn = HumanName("abd Allah Smith ABD") self.m(hn.first, "abd Allah", hn) self.m(hn.last, "Smith", hn) self.m(hn.suffix, "ABD", hn) def test_abd_joins_behind_a_title(self) -> None: hn = HumanName("Dr. abd Allah Smith") self.m(hn.title, "Dr.", hn) self.m(hn.first, "abd Allah", hn) self.m(hn.last, "Smith", hn) def test_the_abd_postnominal_still_reads_as_a_suffix(self) -> None: """The collision: ABD is also All But Dissertation. Position tells the two apart, so neither reading had to be given up.""" for text in ("Jane Smith ABD", "Jane Smith, ABD", "Jane Smith A.B.D."): hn = HumanName(text) self.m(hn.first, "Jane", hn) self.m(hn.last, "Smith", hn) self.assertTrue(hn.suffix.upper().replace(".", "") == "ABD", f"{text}: suffix={hn.suffix!r}") derek73-python-nameparser-456b024/tests/test_brute_force.py000066400000000000000000000663041527303271700237720ustar00rootroot00000000000000from nameparser import HumanName from tests.base import HumanNameTestBase class HumanNameBruteForceTests(HumanNameTestBase): def test1(self) -> None: hn = HumanName("John Doe") self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) def test2(self) -> None: hn = HumanName("John Doe, Jr.") self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.suffix, "Jr.", hn) def test3(self) -> None: hn = HumanName("John Doe III") self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.suffix, "III", hn) def test4(self) -> None: hn = HumanName("Doe, John") self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) def test5(self) -> None: hn = HumanName("Doe, John, Jr.") self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.suffix, "Jr.", hn) def test6(self) -> None: hn = HumanName("Doe, John III") self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.suffix, "III", hn) def test7(self) -> None: hn = HumanName("John A. Doe") self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.middle, "A.", hn) def test8(self) -> None: hn = HumanName("John A. Doe, Jr") self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.middle, "A.", hn) self.m(hn.suffix, "Jr", hn) def test9(self) -> None: hn = HumanName("John A. Doe III") self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.middle, "A.", hn) self.m(hn.suffix, "III", hn) def test10(self) -> None: hn = HumanName("Doe, John A.") self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.middle, "A.", hn) def test11(self) -> None: hn = HumanName("Doe, John A., Jr.") self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.middle, "A.", hn) self.m(hn.suffix, "Jr.", hn) def test12(self) -> None: hn = HumanName("Doe, John A., III") self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.middle, "A.", hn) self.m(hn.suffix, "III", hn) def test13(self) -> None: hn = HumanName("John A. Kenneth Doe") self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.middle, "A. Kenneth", hn) def test14(self) -> None: hn = HumanName("John A. Kenneth Doe, Jr.") self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.middle, "A. Kenneth", hn) self.m(hn.suffix, "Jr.", hn) def test15(self) -> None: hn = HumanName("John A. Kenneth Doe III") self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.middle, "A. Kenneth", hn) self.m(hn.suffix, "III", hn) def test16(self) -> None: # "John." is an unrecognized period-abbreviation in the leading # title position of the lastname-comma format, so is_leading_title() # now treats it as a title rather than a first name (see #109). hn = HumanName("Doe, John. A. Kenneth") self.m(hn.title, "John.", hn) self.m(hn.first, "A.", hn) self.m(hn.last, "Doe", hn) self.m(hn.middle, "Kenneth", hn) def test17(self) -> None: # Same period-abbreviation-as-title behavior as test16 (see #109). hn = HumanName("Doe, John. A. Kenneth, Jr.") self.m(hn.title, "John.", hn) self.m(hn.first, "A.", hn) self.m(hn.last, "Doe", hn) self.m(hn.middle, "Kenneth", hn) self.m(hn.suffix, "Jr.", hn) def test18(self) -> None: # Same period-abbreviation-as-title behavior as test16 (see #109). hn = HumanName("Doe, John. A. Kenneth III") self.m(hn.title, "John.", hn) self.m(hn.first, "A.", hn) self.m(hn.last, "Doe", hn) self.m(hn.middle, "Kenneth", hn) self.m(hn.suffix, "III", hn) def test19(self) -> None: hn = HumanName("Dr. John Doe") self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.title, "Dr.", hn) def test20(self) -> None: hn = HumanName("Dr. John Doe, Jr.") self.m(hn.title, "Dr.", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.suffix, "Jr.", hn) def test21(self) -> None: hn = HumanName("Dr. John Doe III") self.m(hn.title, "Dr.", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.suffix, "III", hn) def test22(self) -> None: hn = HumanName("Doe, Dr. John") self.m(hn.title, "Dr.", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) def test23(self) -> None: hn = HumanName("Doe, Dr. John, Jr.") self.m(hn.title, "Dr.", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.suffix, "Jr.", hn) def test24(self) -> None: hn = HumanName("Doe, Dr. John III") self.m(hn.title, "Dr.", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.suffix, "III", hn) def test25(self) -> None: hn = HumanName("Dr. John A. Doe") self.m(hn.title, "Dr.", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.middle, "A.", hn) def test26(self) -> None: hn = HumanName("Dr. John A. Doe, Jr.") self.m(hn.title, "Dr.", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.middle, "A.", hn) self.m(hn.suffix, "Jr.", hn) def test27(self) -> None: hn = HumanName("Dr. John A. Doe III") self.m(hn.title, "Dr.", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.middle, "A.", hn) self.m(hn.suffix, "III", hn) def test28(self) -> None: hn = HumanName("Doe, Dr. John A.") self.m(hn.title, "Dr.", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.middle, "A.", hn) def test29(self) -> None: hn = HumanName("Doe, Dr. John A. Jr.") self.m(hn.title, "Dr.", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.middle, "A.", hn) self.m(hn.suffix, "Jr.", hn) def test30(self) -> None: hn = HumanName("Doe, Dr. John A. III") self.m(hn.title, "Dr.", hn) self.m(hn.middle, "A.", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.suffix, "III", hn) def test31(self) -> None: hn = HumanName("Dr. John A. Kenneth Doe") self.m(hn.title, "Dr.", hn) self.m(hn.middle, "A. Kenneth", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) def test32(self) -> None: hn = HumanName("Dr. John A. Kenneth Doe, Jr.") self.m(hn.title, "Dr.", hn) self.m(hn.middle, "A. Kenneth", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.suffix, "Jr.", hn) def test33(self) -> None: hn = HumanName("Al Arnold Gore, Jr.") self.m(hn.middle, "Arnold", hn) self.m(hn.first, "Al", hn) self.m(hn.last, "Gore", hn) self.m(hn.suffix, "Jr.", hn) def test34(self) -> None: hn = HumanName("Dr. John A. Kenneth Doe III") self.m(hn.title, "Dr.", hn) self.m(hn.middle, "A. Kenneth", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.suffix, "III", hn) def test35(self) -> None: hn = HumanName("Doe, Dr. John A. Kenneth") self.m(hn.title, "Dr.", hn) self.m(hn.middle, "A. Kenneth", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) def test36(self) -> None: hn = HumanName("Doe, Dr. John A. Kenneth Jr.") self.m(hn.title, "Dr.", hn) self.m(hn.middle, "A. Kenneth", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.suffix, "Jr.", hn) def test37(self) -> None: hn = HumanName("Doe, Dr. John A. Kenneth III") self.m(hn.title, "Dr.", hn) self.m(hn.middle, "A. Kenneth", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.suffix, "III", hn) def test38(self) -> None: hn = HumanName("Juan de la Vega") self.m(hn.first, "Juan", hn) self.m(hn.last, "de la Vega", hn) def test39(self) -> None: hn = HumanName("Juan de la Vega, Jr.") self.m(hn.first, "Juan", hn) self.m(hn.last, "de la Vega", hn) self.m(hn.suffix, "Jr.", hn) def test40(self) -> None: hn = HumanName("Juan de la Vega III") self.m(hn.first, "Juan", hn) self.m(hn.last, "de la Vega", hn) self.m(hn.suffix, "III", hn) def test41(self) -> None: hn = HumanName("de la Vega, Juan") self.m(hn.first, "Juan", hn) self.m(hn.last, "de la Vega", hn) def test42(self) -> None: hn = HumanName("de la Vega, Juan, Jr.") self.m(hn.first, "Juan", hn) self.m(hn.last, "de la Vega", hn) self.m(hn.suffix, "Jr.", hn) def test43(self) -> None: hn = HumanName("de la Vega, Juan III") self.m(hn.first, "Juan", hn) self.m(hn.last, "de la Vega", hn) self.m(hn.suffix, "III", hn) def test44(self) -> None: hn = HumanName("Juan Velasquez y Garcia") self.m(hn.first, "Juan", hn) self.m(hn.last, "Velasquez y Garcia", hn) def test45(self) -> None: hn = HumanName("Juan Velasquez y Garcia, Jr.") self.m(hn.first, "Juan", hn) self.m(hn.last, "Velasquez y Garcia", hn) self.m(hn.suffix, "Jr.", hn) def test46(self) -> None: hn = HumanName("Juan Velasquez y Garcia III") self.m(hn.first, "Juan", hn) self.m(hn.last, "Velasquez y Garcia", hn) self.m(hn.suffix, "III", hn) def test47(self) -> None: hn = HumanName("Velasquez y Garcia, Juan") self.m(hn.first, "Juan", hn) self.m(hn.last, "Velasquez y Garcia", hn) def test48(self) -> None: hn = HumanName("Velasquez y Garcia, Juan, Jr.") self.m(hn.first, "Juan", hn) self.m(hn.last, "Velasquez y Garcia", hn) self.m(hn.suffix, "Jr.", hn) def test49(self) -> None: hn = HumanName("Velasquez y Garcia, Juan III") self.m(hn.first, "Juan", hn) self.m(hn.last, "Velasquez y Garcia", hn) self.m(hn.suffix, "III", hn) def test50(self) -> None: hn = HumanName("Dr. Juan de la Vega") self.m(hn.title, "Dr.", hn) self.m(hn.first, "Juan", hn) self.m(hn.last, "de la Vega", hn) def test51(self) -> None: hn = HumanName("Dr. Juan de la Vega, Jr.") self.m(hn.title, "Dr.", hn) self.m(hn.first, "Juan", hn) self.m(hn.last, "de la Vega", hn) self.m(hn.suffix, "Jr.", hn) def test52(self) -> None: hn = HumanName("Dr. Juan de la Vega III") self.m(hn.title, "Dr.", hn) self.m(hn.first, "Juan", hn) self.m(hn.last, "de la Vega", hn) self.m(hn.suffix, "III", hn) def test53(self) -> None: hn = HumanName("de la Vega, Dr. Juan") self.m(hn.title, "Dr.", hn) self.m(hn.first, "Juan", hn) self.m(hn.last, "de la Vega", hn) def test54(self) -> None: hn = HumanName("de la Vega, Dr. Juan, Jr.") self.m(hn.title, "Dr.", hn) self.m(hn.first, "Juan", hn) self.m(hn.last, "de la Vega", hn) self.m(hn.suffix, "Jr.", hn) def test55(self) -> None: hn = HumanName("de la Vega, Dr. Juan III") self.m(hn.title, "Dr.", hn) self.m(hn.first, "Juan", hn) self.m(hn.last, "de la Vega", hn) self.m(hn.suffix, "III", hn) def test56(self) -> None: hn = HumanName("Dr. Juan Velasquez y Garcia") self.m(hn.title, "Dr.", hn) self.m(hn.first, "Juan", hn) self.m(hn.last, "Velasquez y Garcia", hn) def test57(self) -> None: hn = HumanName("Dr. Juan Velasquez y Garcia, Jr.") self.m(hn.title, "Dr.", hn) self.m(hn.first, "Juan", hn) self.m(hn.last, "Velasquez y Garcia", hn) self.m(hn.suffix, "Jr.", hn) def test58(self) -> None: hn = HumanName("Dr. Juan Velasquez y Garcia III") self.m(hn.title, "Dr.", hn) self.m(hn.first, "Juan", hn) self.m(hn.last, "Velasquez y Garcia", hn) self.m(hn.suffix, "III", hn) def test59(self) -> None: hn = HumanName("Velasquez y Garcia, Dr. Juan") self.m(hn.title, "Dr.", hn) self.m(hn.first, "Juan", hn) self.m(hn.last, "Velasquez y Garcia", hn) def test60(self) -> None: hn = HumanName("Velasquez y Garcia, Dr. Juan, Jr.") self.m(hn.title, "Dr.", hn) self.m(hn.first, "Juan", hn) self.m(hn.last, "Velasquez y Garcia", hn) self.m(hn.suffix, "Jr.", hn) def test61(self) -> None: hn = HumanName("Velasquez y Garcia, Dr. Juan III") self.m(hn.title, "Dr.", hn) self.m(hn.first, "Juan", hn) self.m(hn.last, "Velasquez y Garcia", hn) self.m(hn.suffix, "III", hn) def test62(self) -> None: hn = HumanName("Juan Q. de la Vega") self.m(hn.first, "Juan", hn) self.m(hn.middle, "Q.", hn) self.m(hn.last, "de la Vega", hn) def test63(self) -> None: hn = HumanName("Juan Q. de la Vega, Jr.") self.m(hn.first, "Juan", hn) self.m(hn.last, "de la Vega", hn) self.m(hn.middle, "Q.", hn) self.m(hn.suffix, "Jr.", hn) def test64(self) -> None: hn = HumanName("Juan Q. de la Vega III") self.m(hn.first, "Juan", hn) self.m(hn.middle, "Q.", hn) self.m(hn.last, "de la Vega", hn) self.m(hn.suffix, "III", hn) def test65(self) -> None: hn = HumanName("de la Vega, Juan Q.") self.m(hn.first, "Juan", hn) self.m(hn.middle, "Q.", hn) self.m(hn.last, "de la Vega", hn) def test66(self) -> None: hn = HumanName("de la Vega, Juan Q., Jr.") self.m(hn.first, "Juan", hn) self.m(hn.last, "de la Vega", hn) self.m(hn.middle, "Q.", hn) self.m(hn.suffix, "Jr.", hn) def test67(self) -> None: hn = HumanName("de la Vega, Juan Q. III") self.m(hn.first, "Juan", hn) self.m(hn.last, "de la Vega", hn) self.m(hn.middle, "Q.", hn) self.m(hn.suffix, "III", hn) def test68(self) -> None: hn = HumanName("Juan Q. Velasquez y Garcia") self.m(hn.middle, "Q.", hn) self.m(hn.first, "Juan", hn) self.m(hn.last, "Velasquez y Garcia", hn) def test69(self) -> None: hn = HumanName("Juan Q. Velasquez y Garcia, Jr.") self.m(hn.middle, "Q.", hn) self.m(hn.first, "Juan", hn) self.m(hn.last, "Velasquez y Garcia", hn) self.m(hn.suffix, "Jr.", hn) def test70(self) -> None: hn = HumanName("Juan Q. Velasquez y Garcia III") self.m(hn.middle, "Q.", hn) self.m(hn.first, "Juan", hn) self.m(hn.last, "Velasquez y Garcia", hn) self.m(hn.suffix, "III", hn) def test71(self) -> None: hn = HumanName("Velasquez y Garcia, Juan Q.") self.m(hn.middle, "Q.", hn) self.m(hn.first, "Juan", hn) self.m(hn.last, "Velasquez y Garcia", hn) def test72(self) -> None: hn = HumanName("Velasquez y Garcia, Juan Q., Jr.") self.m(hn.middle, "Q.", hn) self.m(hn.first, "Juan", hn) self.m(hn.last, "Velasquez y Garcia", hn) self.m(hn.suffix, "Jr.", hn) def test73(self) -> None: hn = HumanName("Velasquez y Garcia, Juan Q. III") self.m(hn.middle, "Q.", hn) self.m(hn.first, "Juan", hn) self.m(hn.last, "Velasquez y Garcia", hn) self.m(hn.suffix, "III", hn) def test74(self) -> None: hn = HumanName("Dr. Juan Q. de la Vega") self.m(hn.title, "Dr.", hn) self.m(hn.first, "Juan", hn) self.m(hn.middle, "Q.", hn) self.m(hn.last, "de la Vega", hn) def test75(self) -> None: hn = HumanName("Dr. Juan Q. de la Vega, Jr.") self.m(hn.first, "Juan", hn) self.m(hn.last, "de la Vega", hn) self.m(hn.middle, "Q.", hn) self.m(hn.title, "Dr.", hn) self.m(hn.suffix, "Jr.", hn) def test76(self) -> None: hn = HumanName("Dr. Juan Q. de la Vega III") self.m(hn.first, "Juan", hn) self.m(hn.last, "de la Vega", hn) self.m(hn.middle, "Q.", hn) self.m(hn.title, "Dr.", hn) self.m(hn.suffix, "III", hn) def test77(self) -> None: hn = HumanName("de la Vega, Dr. Juan Q.") self.m(hn.first, "Juan", hn) self.m(hn.middle, "Q.", hn) self.m(hn.last, "de la Vega", hn) self.m(hn.title, "Dr.", hn) def test78(self) -> None: hn = HumanName("de la Vega, Dr. Juan Q., Jr.") self.m(hn.first, "Juan", hn) self.m(hn.last, "de la Vega", hn) self.m(hn.middle, "Q.", hn) self.m(hn.suffix, "Jr.", hn) self.m(hn.title, "Dr.", hn) def test79(self) -> None: hn = HumanName("de la Vega, Dr. Juan Q. III") self.m(hn.first, "Juan", hn) self.m(hn.last, "de la Vega", hn) self.m(hn.middle, "Q.", hn) self.m(hn.suffix, "III", hn) self.m(hn.title, "Dr.", hn) def test80(self) -> None: hn = HumanName("Dr. Juan Q. Velasquez y Garcia") self.m(hn.title, "Dr.", hn) self.m(hn.middle, "Q.", hn) self.m(hn.first, "Juan", hn) self.m(hn.last, "Velasquez y Garcia", hn) def test81(self) -> None: hn = HumanName("Dr. Juan Q. Velasquez y Garcia, Jr.") self.m(hn.title, "Dr.", hn) self.m(hn.middle, "Q.", hn) self.m(hn.first, "Juan", hn) self.m(hn.last, "Velasquez y Garcia", hn) self.m(hn.suffix, "Jr.", hn) def test82(self) -> None: hn = HumanName("Dr. Juan Q. Velasquez y Garcia III") self.m(hn.middle, "Q.", hn) self.m(hn.title, "Dr.", hn) self.m(hn.first, "Juan", hn) self.m(hn.last, "Velasquez y Garcia", hn) self.m(hn.suffix, "III", hn) def test83(self) -> None: hn = HumanName("Velasquez y Garcia, Dr. Juan Q.") self.m(hn.title, "Dr.", hn) self.m(hn.middle, "Q.", hn) self.m(hn.first, "Juan", hn) self.m(hn.last, "Velasquez y Garcia", hn) def test84(self) -> None: hn = HumanName("Velasquez y Garcia, Dr. Juan Q., Jr.") self.m(hn.middle, "Q.", hn) self.m(hn.first, "Juan", hn) self.m(hn.title, "Dr.", hn) self.m(hn.last, "Velasquez y Garcia", hn) self.m(hn.suffix, "Jr.", hn) def test85(self) -> None: hn = HumanName("Velasquez y Garcia, Dr. Juan Q. III") self.m(hn.middle, "Q.", hn) self.m(hn.first, "Juan", hn) self.m(hn.title, "Dr.", hn) self.m(hn.last, "Velasquez y Garcia", hn) self.m(hn.suffix, "III", hn) def test86(self) -> None: hn = HumanName("Juan Q. Xavier de la Vega") self.m(hn.first, "Juan", hn) self.m(hn.middle, "Q. Xavier", hn) self.m(hn.last, "de la Vega", hn) def test87(self) -> None: hn = HumanName("Juan Q. Xavier de la Vega, Jr.") self.m(hn.first, "Juan", hn) self.m(hn.last, "de la Vega", hn) self.m(hn.middle, "Q. Xavier", hn) self.m(hn.suffix, "Jr.", hn) def test88(self) -> None: hn = HumanName("Juan Q. Xavier de la Vega III") self.m(hn.first, "Juan", hn) self.m(hn.last, "de la Vega", hn) self.m(hn.middle, "Q. Xavier", hn) self.m(hn.suffix, "III", hn) def test89(self) -> None: hn = HumanName("de la Vega, Juan Q. Xavier") self.m(hn.first, "Juan", hn) self.m(hn.middle, "Q. Xavier", hn) self.m(hn.last, "de la Vega", hn) def test90(self) -> None: hn = HumanName("de la Vega, Juan Q. Xavier, Jr.") self.m(hn.first, "Juan", hn) self.m(hn.last, "de la Vega", hn) self.m(hn.middle, "Q. Xavier", hn) self.m(hn.suffix, "Jr.", hn) def test91(self) -> None: hn = HumanName("de la Vega, Juan Q. Xavier III") self.m(hn.first, "Juan", hn) self.m(hn.last, "de la Vega", hn) self.m(hn.middle, "Q. Xavier", hn) self.m(hn.suffix, "III", hn) def test92(self) -> None: hn = HumanName("Dr. Juan Q. Xavier de la Vega") self.m(hn.first, "Juan", hn) self.m(hn.middle, "Q. Xavier", hn) self.m(hn.title, "Dr.", hn) self.m(hn.last, "de la Vega", hn) def test93(self) -> None: hn = HumanName("Dr. Juan Q. Xavier de la Vega, Jr.") self.m(hn.first, "Juan", hn) self.m(hn.last, "de la Vega", hn) self.m(hn.title, "Dr.", hn) self.m(hn.middle, "Q. Xavier", hn) self.m(hn.suffix, "Jr.", hn) def test94(self) -> None: hn = HumanName("Dr. Juan Q. Xavier de la Vega III") self.m(hn.first, "Juan", hn) self.m(hn.last, "de la Vega", hn) self.m(hn.title, "Dr.", hn) self.m(hn.middle, "Q. Xavier", hn) self.m(hn.suffix, "III", hn) def test95(self) -> None: hn = HumanName("de la Vega, Dr. Juan Q. Xavier") self.m(hn.first, "Juan", hn) self.m(hn.title, "Dr.", hn) self.m(hn.middle, "Q. Xavier", hn) self.m(hn.last, "de la Vega", hn) def test96(self) -> None: hn = HumanName("de la Vega, Dr. Juan Q. Xavier, Jr.") self.m(hn.first, "Juan", hn) self.m(hn.last, "de la Vega", hn) self.m(hn.title, "Dr.", hn) self.m(hn.middle, "Q. Xavier", hn) self.m(hn.suffix, "Jr.", hn) def test97(self) -> None: hn = HumanName("de la Vega, Dr. Juan Q. Xavier III") self.m(hn.first, "Juan", hn) self.m(hn.title, "Dr.", hn) self.m(hn.last, "de la Vega", hn) self.m(hn.middle, "Q. Xavier", hn) self.m(hn.suffix, "III", hn) def test98(self) -> None: hn = HumanName("Juan Q. Xavier Velasquez y Garcia") self.m(hn.middle, "Q. Xavier", hn) self.m(hn.first, "Juan", hn) self.m(hn.last, "Velasquez y Garcia", hn) def test99(self) -> None: hn = HumanName("Juan Q. Xavier Velasquez y Garcia, Jr.") self.m(hn.middle, "Q. Xavier", hn) self.m(hn.first, "Juan", hn) self.m(hn.last, "Velasquez y Garcia", hn) self.m(hn.suffix, "Jr.", hn) def test100(self) -> None: hn = HumanName("Juan Q. Xavier Velasquez y Garcia III") self.m(hn.middle, "Q. Xavier", hn) self.m(hn.first, "Juan", hn) self.m(hn.last, "Velasquez y Garcia", hn) self.m(hn.suffix, "III", hn) def test101(self) -> None: hn = HumanName("Velasquez y Garcia, Juan Q. Xavier") self.m(hn.middle, "Q. Xavier", hn) self.m(hn.first, "Juan", hn) self.m(hn.last, "Velasquez y Garcia", hn) def test102(self) -> None: hn = HumanName("Velasquez y Garcia, Juan Q. Xavier, Jr.") self.m(hn.middle, "Q. Xavier", hn) self.m(hn.first, "Juan", hn) self.m(hn.last, "Velasquez y Garcia", hn) self.m(hn.suffix, "Jr.", hn) def test103(self) -> None: hn = HumanName("Velasquez y Garcia, Juan Q. Xavier III") self.m(hn.middle, "Q. Xavier", hn) self.m(hn.first, "Juan", hn) self.m(hn.last, "Velasquez y Garcia", hn) self.m(hn.suffix, "III", hn) def test104(self) -> None: hn = HumanName("Dr. Juan Q. Xavier Velasquez y Garcia") self.m(hn.title, "Dr.", hn) self.m(hn.middle, "Q. Xavier", hn) self.m(hn.first, "Juan", hn) self.m(hn.last, "Velasquez y Garcia", hn) def test105(self) -> None: hn = HumanName("Dr. Juan Q. Xavier Velasquez y Garcia, Jr.") self.m(hn.middle, "Q. Xavier", hn) self.m(hn.first, "Juan", hn) self.m(hn.title, "Dr.", hn) self.m(hn.last, "Velasquez y Garcia", hn) self.m(hn.suffix, "Jr.", hn) def test106(self) -> None: hn = HumanName("Dr. Juan Q. Xavier Velasquez y Garcia III") self.m(hn.middle, "Q. Xavier", hn) self.m(hn.first, "Juan", hn) self.m(hn.title, "Dr.", hn) self.m(hn.last, "Velasquez y Garcia", hn) self.m(hn.suffix, "III", hn) def test107(self) -> None: hn = HumanName("Velasquez y Garcia, Dr. Juan Q. Xavier") self.m(hn.title, "Dr.", hn) self.m(hn.middle, "Q. Xavier", hn) self.m(hn.first, "Juan", hn) self.m(hn.last, "Velasquez y Garcia", hn) def test108(self) -> None: hn = HumanName("Velasquez y Garcia, Dr. Juan Q. Xavier, Jr.") self.m(hn.middle, "Q. Xavier", hn) self.m(hn.first, "Juan", hn) self.m(hn.title, "Dr.", hn) self.m(hn.last, "Velasquez y Garcia", hn) self.m(hn.suffix, "Jr.", hn) def test109(self) -> None: hn = HumanName("Velasquez y Garcia, Dr. Juan Q. Xavier III") self.m(hn.middle, "Q. Xavier", hn) self.m(hn.first, "Juan", hn) self.m(hn.title, "Dr.", hn) self.m(hn.last, "Velasquez y Garcia", hn) self.m(hn.suffix, "III", hn) def test110(self) -> None: hn = HumanName("John Doe, CLU, CFP, LUTC") self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.suffix, "CLU, CFP, LUTC", hn) def test111(self) -> None: hn = HumanName("John P. Doe, CLU, CFP, LUTC") self.m(hn.first, "John", hn) self.m(hn.middle, "P.", hn) self.m(hn.last, "Doe", hn) self.m(hn.suffix, "CLU, CFP, LUTC", hn) def test112(self) -> None: hn = HumanName("Dr. John P. Doe-Ray, CLU, CFP, LUTC") self.m(hn.first, "John", hn) self.m(hn.middle, "P.", hn) self.m(hn.last, "Doe-Ray", hn) self.m(hn.title, "Dr.", hn) self.m(hn.suffix, "CLU, CFP, LUTC", hn) def test113(self) -> None: hn = HumanName("Doe-Ray, Dr. John P., CLU, CFP, LUTC") self.m(hn.title, "Dr.", hn) self.m(hn.middle, "P.", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Doe-Ray", hn) self.m(hn.suffix, "CLU, CFP, LUTC", hn) def test115(self) -> None: hn = HumanName("Hon. Barrington P. Doe-Ray, Jr.") self.m(hn.title, "Hon.", hn) self.m(hn.middle, "P.", hn) self.m(hn.first, "Barrington", hn) self.m(hn.last, "Doe-Ray", hn) def test116(self) -> None: hn = HumanName("Doe-Ray, Hon. Barrington P. Jr., CFP, LUTC") self.m(hn.title, "Hon.", hn) self.m(hn.middle, "P.", hn) self.m(hn.first, "Barrington", hn) self.m(hn.last, "Doe-Ray", hn) self.m(hn.suffix, "Jr., CFP, LUTC", hn) def test117(self) -> None: hn = HumanName("Rt. Hon. Paul E. Mary") self.m(hn.title, "Rt. Hon.", hn) self.m(hn.first, "Paul", hn) self.m(hn.middle, "E.", hn) self.m(hn.last, "Mary", hn) def test119(self) -> None: hn = HumanName("Lord God Almighty") self.m(hn.title, "Lord", hn) self.m(hn.first, "God", hn) self.m(hn.last, "Almighty", hn) derek73-python-nameparser-456b024/tests/test_capitalization.py000066400000000000000000000671211527303271700245040ustar00rootroot00000000000000# pickle here round-trips an object this test just built; the library's # own pickle support is what is under test, and no foreign data is read. import pickle import pytest from nameparser import HumanName from nameparser.config import Constants from tests.base import HumanNameTestBase class HumanNameCapitalizationTestCase(HumanNameTestBase): def test_capitalization_exception_for_III(self) -> None: hn = HumanName('juan q. xavier velasquez y garcia iii') hn.capitalize() self.m(str(hn), 'Juan Q. Xavier Velasquez y Garcia III', hn) # A known failure since 2012 (the Google Code tracker's issue 22) # until #492: the one-case gate read the SUFFIX as evidence that # the writer cased the whole name, so 'III' held the lowercase # name back. The gate now leaves the suffixes out (rules/R5): # a generation written the way one is written says nothing about # how the name was cased. The name words still count, and so # does a title -- the two boundary rows. def test_capitalization_exception_for_already_capitalized_III( self, ) -> None: hn = HumanName('juan garcia III') hn.capitalize() self.m(str(hn), 'Juan Garcia III', hn) mixed = HumanName('Juan garcia III') mixed.capitalize() self.m(str(mixed), 'Juan garcia III', mixed) titled = HumanName('Dr. juan garcia') titled.capitalize() self.m(str(titled), 'Dr. juan garcia', titled) def test_capitalize_title(self) -> None: hn = HumanName('lt. gen. john a. kenneth doe iv') hn.capitalize() self.m(str(hn), 'Lt. Gen. John A. Kenneth Doe IV', hn) def test_capitalize_title_to_lower(self) -> None: hn = HumanName('LT. GEN. JOHN A. KENNETH DOE IV') hn.capitalize() self.m(str(hn), 'Lt. Gen. John A. Kenneth Doe IV', hn) # Capitalization with M(a)c and hyphenated names def test_capitalization_with_Mac_as_hyphenated_names(self) -> None: hn = HumanName('donovan mcnabb-smith') hn.capitalize() self.m(str(hn), 'Donovan McNabb-Smith', hn) def test_capitization_middle_initial_is_also_a_conjunction(self) -> None: hn = HumanName('scott e. werner') hn.capitalize() self.m(str(hn), 'Scott E. Werner', hn) def test_capitalize_empty_middle_produces_no_leading_space_in_surnames(self) -> None: # str.split(' ') on an empty string returns [''] rather than [], so an # absent middle produced a spurious token that leaked into surnames_list # and caused a leading space in the surnames property (' Doe' not 'Doe'). hn = HumanName('john doe') hn.capitalize() self.m(hn.surnames, 'Doe', hn) self.assertEqual(hn.middle_list, []) self.assertEqual(hn.surnames_list, ['Doe']) def test_capitalize_force_empty_middle_produces_no_leading_space_in_surnames(self) -> None: # Without force=True, capitalize() exits early for mixed-case names and # never reaches the split lines. Confirm the fix covers that path too. hn = HumanName('Jane Doe') hn.capitalize(force=True) self.m(hn.surnames, 'Doe', hn) self.assertEqual(hn.middle_list, []) def test_capitalize_empty_attributes_produce_no_spurious_tokens(self) -> None: # Confirm the fix extends beyond surnames: empty attribute lists are [] # not [''], and non-empty ones contain only real tokens. hn = HumanName('Jane Doe') hn.capitalize(force=True) self.assertEqual(hn.title_list, []) self.assertEqual(hn.first_list, ['Jane']) self.assertEqual(hn.last_list, ['Doe']) def test_capitalize_title_and_last_only_no_spurious_tokens(self) -> None: # title+last with no first or middle leaves first_list and middle_list # both empty. All-caps triggers capitalize() without force=True. hn = HumanName('DR DOE') hn.capitalize() self.assertEqual(hn.first_list, []) self.assertEqual(hn.middle_list, []) self.m(str(hn), 'Dr Doe', hn) def test_capitalize_empty_suffix_produces_no_spurious_tokens(self) -> None: # ''.split(', ') returns [''] just like ''.split(' ') did for the other # attributes — an absent suffix should produce suffix_list == [], not ['']. hn = HumanName('JOHN DOE') hn.capitalize() self.assertEqual(hn.suffix_list, []) def test_capitalize_single_suffix_still_works(self) -> None: hn = HumanName('JOHN DOE PHD') hn.capitalize() self.assertEqual(hn.suffix_list, ['PhD']) def test_capitalize_multiple_suffixes_still_split_correctly(self) -> None: hn = HumanName('JOHN DOE PHD MD') hn.capitalize() # The split this guards is capitalize() giving each word its own # repair rather than title-casing the run, and that is # untouched: PHD by the exceptions map's mask, MD by the acronym # clause since #459 took md out of the map. The two words are # ONE entry since #436 -- the writer spaced them, so they render # with a space -- and one entry is one suffix_list element. A # deliberate deviation from 1.4.0, which inserted a comma into a # run the writer had spaced, and gave 'Ph.D.', 'M.D.' besides. self.assertEqual(hn.suffix_list, ['PhD MD']) def test_capitalize_suffix_acronym_with_dots(self) -> None: # Suffixes already written with dots (e.g. "M.D.") keep them and # do not title-case to "M.d." (issue #141). Through 2.3 the # exceptions map gave this string by SUBSTITUTING 'M.D.'; since # #459 md is a listed acronym the acronym clause writes in # capitals, recasing the word as written. hn = HumanName('GREGORY HOUSE M.D.') hn.capitalize() self.assertEqual(hn.suffix, 'M.D.') # A credential acronym the exceptions map doesn't carry is an # initialism, so a one-case suffix repairs to all-caps instead of # title-case (issue #459). def test_capitalize_suffix_acronym_is_all_caps(self) -> None: for src, expect in [ ('JOHN SMITH MBA', 'John Smith MBA'), ('john smith jd', 'John Smith JD'), ('JOSE LUIS CPA', 'Jose Luis CPA'), ('john smith pmp', 'John Smith PMP'), ]: hn = HumanName(src) hn.capitalize() self.m(str(hn), expect, hn) # The exceptions map is asked before the all-caps acronym clause, # and since #459 it holds case MASKS: bsc and msc are listed # acronyms too, and the mask is what keeps them mixed-case where # the acronym clause would give 'BSC'. md left the map and reads # 'MD' by the acronym clause; 1.4.0 through 2.3.0 gave 'M.D.' and # 'Ph.D.', the map substituting its value for the word. def test_capitalize_exceptions_still_win_over_acronyms(self) -> None: for src, expect in [ ('john smith md', 'John Smith MD'), ('john smith phd', 'John Smith PhD'), ('john smith ph.d.', 'John Smith Ph.D.'), ('john smith bsc', 'John Smith BSc'), ('JOHN SMITH MSC', 'John Smith MSc'), # the conventionally mixed-case acronyms given masks on # 2026-09-24; without one, the acronym clause gave 'PSYD' ('john smith psyd', 'John Smith PsyD'), ('JOHN SMITH PHARMD', 'John Smith PharmD'), ]: hn = HumanName(src) hn.capitalize() self.m(str(hn), expect, hn) # #459: a capitalization_exceptions value is a case MASK -- the # key's own letters recased -- so a v1 Constants carrying any other # value raises at the first parse, where the snapshot builds the # Lexicon. A DECIDED exception to the shim's never-raise rule # (decisions/R4, and #3-0-reevaluations): 1.4.0 substituted # such a value for the word, repair now only recases, and no such # value was found in the tracker, the docs or any test. The raise # carries a v1-spelled hint: the 2.0 message's own offer # ("capitalization_exceptions=(('jr', 'JR'),)") is a Lexicon() # constructor call, and a v1 caller is looking at a TupleManager # assignment through Constants, not that constructor. def test_a_mismatched_exception_value_raises_at_the_first_parse( self, ) -> None: from nameparser._lexicon import _MaskValueError c = Constants(capitalization_exceptions={'jr': 'Junior'}) with pytest.raises(ValueError, match="does not spell the key's letters") \ as caught: HumanName('john smith jr', constants=c) assert "constants.capitalization_exceptions['jr'] = 'JR'" \ in str(caught.value) # The v1 message is built fresh from the fields, not from the # wrapped 2.0 exception's own text -- so it never carries the # 2.0 constructor spelling a v1 caller cannot paste. assert "Lexicon(" not in str(caught.value) assert "capitalization_exceptions=((" not in str(caught.value) assert isinstance(caught.value.__cause__, _MaskValueError) # pasted onto a fresh Constants, the offered fix works fixed = Constants() fixed.capitalization_exceptions['jr'] = 'JR' hn = HumanName('john smith jr', constants=fixed) hn.capitalize() self.m(str(hn), 'John Smith JR', hn) ok = Constants(capitalization_exceptions={'jr': 'JR'}) hn = HumanName('john smith jr', constants=ok) hn.capitalize() self.m(str(hn), 'John Smith JR', hn) # rules/R5 (#492): a suffix ASSIGNED to the facade after # construction carries UNCLASSIFIED_TAG (no `vocab:suffix` tag, no # roman-numeral shape read from a parse) and is still excluded from # the one-case gate -- the gate reads the ROLE, not the tag. def test_capitalize_leaves_an_assigned_suffix_out_of_the_gate( self, ) -> None: hn = HumanName('juan garcia') hn.suffix = 'III' hn.capitalize() self.m(str(hn), 'Juan Garcia III', hn) # A word in the acronym vocabulary that parses as a family name # still repairs as an ordinary name word, not an acronym (#459). def test_capitalize_family_name_in_acronym_vocab_stays_title_case(self) -> None: hn = HumanName('anh van do') hn.capitalize() self.m(str(hn), 'Anh Van Do', hn) # Leaving already-capitalized names alone def test_no_change_to_mixed_chase(self) -> None: hn = HumanName('Shirley Maclaine') hn.capitalize() self.m(str(hn), 'Shirley Maclaine', hn) def test_force_capitalization(self) -> None: hn = HumanName('Shirley Maclaine') hn.capitalize(force=True) self.m(str(hn), 'Shirley MacLaine', hn) def test_capitalize_diacritics(self) -> None: hn = HumanName('matthëus schmidt') hn.capitalize() self.m(str(hn), 'Matthëus Schmidt', hn) # http://code.google.com/p/python-nameparser/issues/detail?id=15 def test_downcasing_mac(self) -> None: hn = HumanName('RONALD MACDONALD') hn.capitalize() self.m(str(hn), 'Ronald MacDonald', hn) # http://code.google.com/p/python-nameparser/issues/detail?id=23 def test_downcasing_mc(self) -> None: hn = HumanName('RONALD MCDONALD') hn.capitalize() self.m(str(hn), 'Ronald McDonald', hn) def test_short_names_with_mac(self) -> None: hn = HumanName('mack johnson') hn.capitalize() self.m(str(hn), 'Mack Johnson', hn) def test_portuguese_prefixes(self) -> None: hn = HumanName("joao da silva do amaral de souza") hn.capitalize() self.m(str(hn), 'Joao da Silva do Amaral de Souza', hn) def test_capitalize_prefix_clash_on_first_name(self) -> None: hn = HumanName("van nguyen") hn.capitalize() self.m(str(hn), 'Van Nguyen', hn) # #407, rules/R4. The family is one word and that word is # particle vocabulary, so nothing joins it to a name and it is not # doing a particle's work: family_base and initials have read it as # an ordinary name word since #404, and case repair now agrees. # Deliberately the v1 facade, and deliberately single-case input -- # this MOVES v1-visible behavior (1.4.0 returns 'Anh do'), and the # mixed-case gate would return the input untouched (rules/R5). def test_capitalize_all_particle_family_is_a_name_word(self) -> None: hn = HumanName('ANH DO') hn.capitalize() self.m(str(hn), 'Anh Do', hn) # The reason the mark is read for the WHOLE PART rather than for a # particle standing alone: this family is 'van do', two particle # words and neither of them alone. A standing-alone rule would # capitalize the name above and leave this one lowercased, reading # the same surname two ways. def test_capitalize_all_particle_family_of_two_words(self) -> None: hn = HumanName('anh van do') hn.capitalize() self.m(str(hn), 'Anh Van Do', hn) # The recorded negative control for the two above (AGENTS.md's # guard-test convention): here 'de la' has 'vega' to join to, so # the particles are doing a particle's work and stay lowercase. # This FENCES the scope of #407 rather than pinning the fix -- it # passes both before and after the change, and fails only if the # new clause is widened to reach working particle runs. def test_capitalize_working_particle_stays_lowercase(self) -> None: hn = HumanName('juan de la vega') hn.capitalize() self.m(str(hn), 'Juan de la Vega', hn) # The corpus name that carries both halves of the predicate at # once, and the one the release-log bullet asserts: the family # `van der` is all particles and capitalizes, while the `y` keeps # its lowercase. This DOES pin the fix -- deleting the tag consult # gives 'y van der' -- but it is worth being exact about what it # does NOT pin, since a reviewer proposed it for that. It cannot # witness the conjunction conjunct being left ungated, and neither # can any other name parsed with the SHIPPED vocabulary: the mark # is applied to a part only where EVERY word in it carries # "particle" (_pipeline/_post_rules and _types._remarked alike), # and `particles` and `conjunctions` are disjoint in the default # lexicon and in all four locale packs, so no shipped conjunction # ever sits inside a marked part. That is a property of the # shipped DATA, not an invariant -- both sets are public API, and # `Lexicon.default().add(particles={'y'})` parses `anh y van` to # an all-particle family whose `y` carries the mark, giving # 'Anh y Van' where gating the conjunct too would give # 'Anh Y Van'. So the ungated conjunct decides something: it is # rules/R4's stated carve-out, matching R3's for initials, # rather than a no-op. The `y` here is a GIVEN-part word besides, # not a word of the all-particle family. def test_capitalize_all_particle_family_beside_a_conjunction(self) -> None: hn = HumanName('der, y van') hn.capitalize() self.m(str(hn), 'y Van Der', hn) # rules/R5's override, stated as a property rather than a # single row: mixed case is the writer making an explicit choice # and repair defers to it, so asking for repair REGARDLESS should # ignore the input's case entirely -- one name, one repaired # string, however it was written. # # Measured 2026-08-29, before the #383/#479 fork, on the # 1094-name corpus, because the promise is nearly true and the # exceptions are the whole story. On THIS surface -- # `HumanName.capitalize()` and `str()`, which is what the test # below uses -- forcing repair differs from uppercasing the input # and calling `capitalize()` for 62 of the 1094, and from # lowercasing it for 16, so UPPERCASE IS THE WORSE DIRECTION, not # the clean one. Nor are the misses merely parse-level: of the 62, # only 25 move a role, and the other 37 parse byte-identically and # differ inside the repair itself. Through the v2 core -- # `parse(n).capitalized(force=True)`, rendering all seven roles # -- the counts are 63 and 38, which is what decisions/R5 # states. The one name the facade cannot see is # `Jane van der Berg nee y Jones`, whose conjunction sits in the # MAIDEN name: `str(HumanName)` renders the default spec, and # that spec omits the field. Recompute by running both forms over # the corpus files deduped and diffing, on whichever surface # you name. The #383/#479 fork removes the one-letter-conjunction # mechanism behind the uppercase direction (below), and a # re-measurement on this tree REVERSES the headline: the uppercase # count collapses and the direction flips, uppercase now differing # on fewer names than lowercase rather than more. The dated # re-measurement and its recipe (with the actual counts) live # under decisions/R5. # # THROUGH 2.3.0 the mechanism was v1's initial carve-out, taken in # the PARSE since #458 and read off the tag by the repair: a word # of the conjunction vocabulary was not tagged one where it was # written initial-shaped, and initial-shaped meant one CAPITAL # letter, full stop -- uppercase a name and every one-letter # conjunction became an initial; lowercase one and a middle # initial `E` became the Italian conjunction. `Velasquez y Garcia, # Dr. Juan Q.` forced kept `y`; uppercased then repaired gave `Y` # (decisions/R5). # # #383/#479 (rules/P8, decisions/P8) removed "one CAPITAL # letter" as the whole test: a name written wholly in one case # carries no case evidence, so the vocabulary decides instead of # the shape. Only the letters `Lexicon.conjunctions_ambiguous` # marks (just 'e' today) still read as an initial in a one-case # name, upper or lower alike -- so a middle initial `E` lowercased # into a one-case name stays an initial rather than becoming the # connective. A plain conjunction like 'y' now joins in a one-case # name the same way whichever case it is written in. This worked # example # has no bare `E`, so only the uppercase half is gone for IT -- # measured, `HumanName('VELASQUEZ Y GARCIA, DR. JUAN Q.').capitalize()` # now agrees with the forced and lowercased forms, all three # giving `Dr. Juan Q. Velasquez y Garcia`; the lowercase half is # shown instead by `john e smith` # (tests/v2/test_render.py::test_capitalized_one_case_connective_that_reads_as_an_initial). # decisions/R5 carries the dated amendment for this. So the # property is pinned over names # carrying no single-letter word whose class case decides, and the # `conjunctions_ambiguous` exception is pinned beside it as data # rather than left to be rediscovered. def test_forcing_repair_ignores_the_case_it_was_given(self) -> None: for name in ('shirley maclaine', 'juan de la vega', 'anh van do', 'donovan mcnabb-smith', 'jane smith phd', 'lt. gen. john a. kenneth doe iv'): forced = HumanName(name) forced.capitalize(force=True) upper = HumanName(name.upper()) upper.capitalize() lower = HumanName(name.lower()) lower.capitalize() self.m(str(upper), str(forced), upper) self.m(str(lower), str(forced), lower) # This WAS the recorded exception to the property above through # 2.3, and the reason it was scoped rather than universal. The # #383/#479 fork (rules/P8, decisions/P8) reads a bare # capital single-letter conjunction in a one-case name as the # connective -- no case evidence says otherwise -- so # 'JUAN Y GARCIA' and 'juan y garcia' now repair to the same # string and the property above holds for them too. 1.4.0's # 'Juan Y Garcia' (measured on the released wheel) is the parity # break decisions/R5's 2026-09-13 amendment records; no ledger # compares case repair, which is why this test is the pin. # # The name is kept for the blame trail even though it now # overstates: what decides repair is the NAME's case class, not # the conjunction letter's own case -- 'y' reads the same way # whichever case it is itself written in, as long as the name # around it is one-case. def test_a_one_letter_conjunction_is_case_sensitive_to_repair(self) -> None: lowered = HumanName('juan y garcia') lowered.capitalize(force=True) self.m(str(lowered), 'Juan y Garcia', lowered) uppered = HumanName('JUAN Y GARCIA') uppered.capitalize() # #383/#479 fork, no longer inherited 1.4.0 behavior: 'JUAN Y # GARCIA' is written wholly in one case, so its bare capital # 'Y' carries no case evidence -- 'y' is not in # conjunctions_ambiguous, so it reads as the connective rather # than as an initial, and R4's carve-out lowercases it exactly # as it lowercases the all-lower spelling above (decisions/P8) self.m(str(uppered), 'Juan y Garcia', uppered) # mixed-case control: here the capital IS evidence, so 'Y' # reads as an initial exactly as it always has and repair # declines to touch it mixed = HumanName('Juan Y Garcia') mixed.capitalize() self.m(str(mixed), 'Juan Y Garcia', mixed) # The v1 parity this rests on, at the surface v1 users have. An # ASSIGNED field is spliced in as raw text and never classified, so # there is no tag to read and repair asks the vocabulary -- which is # what v1 always did, everywhere. Measured on the released 1.4.0 # wheel and on 2.1.0, all four: 'John Velasquez y Garcia', # 'John Smith y Jones', 'John E. Smith', 'John Smith-y'. Reading the # tag alone (no fallback) capitalizes every one of these conjunctions # -- the regression #458's review caught before it shipped. def test_an_assigned_field_keeps_v1_conjunction_repair(self) -> None: for field, value, want in ( ('last', 'velasquez y garcia', 'John Velasquez y Garcia'), ('last', 'smith y jones', 'John Smith y Jones'), # the initial carve-out: an assigned middle initial is # an initial, not the Italian conjunction ('middle', 'e.', 'John E. Smith'), # v1 asks per WORD of the assigned text, so the # conjunction ENDING a hyphenated word IS lowered here # -- the opposite of the parsed reading, where an edge # part is ordinary name text (pinned below), and the # difference is that one carries a reading. An # interior link is lowered on both paths since #478. ('last', 'smith-y', 'John Smith-y'), ('last', 'smith-y-jones', 'John Smith-y-Jones')): hn = HumanName('john smith') setattr(hn, field, value) hn.capitalize(force=True) self.m(str(hn), want, hn) # The other half of #458, and the half that MOVED: the decision is # the whole token's, so a conjunction word inside a longer token is # not one. `e` is the Italian conjunction and `e-f` is a middle # name; before #458 the repair re-ran the test over each word of a # token's text and gave 'Juan e-F Smith'. The uppercase spelling is # here to show the old answer was not even self-consistent: it read # `E` as initial-shaped and capitalized, so the same name repaired # to two different strings depending on how it was written. def test_a_conjunction_inside_a_longer_token_is_a_name_word(self) -> None: lowered = HumanName('juan e-f smith') lowered.capitalize(force=True) self.m(str(lowered), 'Juan E-F Smith', lowered) uppered = HumanName('JUAN E-F SMITH') uppered.capitalize() self.m(str(uppered), 'Juan E-F Smith', uppered) # #478: Ortega y Gasset is routinely hyphenated in catalogues, and # inside one hyphenated word a connective with a part on each side # keeps its lowercase -- the hyphens are the writer joining the name # around it, as the spaced connective does (rules/R4). This # reverses #458's answer for the INTERIOR position ('Jose # Ortega-Y-Gasset' at 2.2.0 and 2.3.0) and keeps it at the edges, # where a part has a neighbour on one side only and is ordinary # name text ('juan e-f smith' above, 'juan y-garcia' here). Both # one-case spellings now agree; 1.4.0 re-decided per word and gave # 'Jose Ortega-y-Gasset' lowered but 'Jose Ortega-Y-Gasset' upper # (measured on the released wheel), so the all-caps half is a # parity break, #479's one-case precedent. The SPACED form is the # contrast and was never in question -- there `y` is a token of its # own and the parse tags it the conjunction. def test_a_hyphenated_compound_surname_keeps_its_link_lowercase( self, ) -> None: lowered = HumanName('jose ortega-y-gasset') lowered.capitalize(force=True) self.m(str(lowered), 'Jose Ortega-y-Gasset', lowered) uppered = HumanName('JOSE ORTEGA-Y-GASSET') uppered.capitalize() self.m(str(uppered), 'Jose Ortega-y-Gasset', uppered) edge = HumanName('juan y-garcia') edge.capitalize() self.m(str(edge), 'Juan Y-Garcia', edge) spaced = HumanName('jose ortega y gasset') spaced.capitalize(force=True) self.m(str(spaced), 'Jose Ortega y Gasset', spaced) # The third producer of never-classified text, and the one that is # not an assignment: __getstate__ pickles the *_list STRINGS and # nothing else (mechanisms.md#FACADE-CONTRACT -- components come # back exactly as pickled, never re-parsed), so a load rebuilds # tokens with no tags on them. They are marked UNCLASSIFIED_TAG for # the same reason an assigned field is: reading the absent # conjunction tag as "not a conjunction" would repair a restored # 'juan ortega y gasset' to 'Juan Ortega Y Gasset', which is # neither 1.4.0's answer nor the same object's before pickling. def test_a_restored_pickle_keeps_v1_conjunction_repair(self) -> None: for text, want in (('juan ortega y gasset', 'Juan Ortega y Gasset'), ('john de la vega', 'John de la Vega'), ('juan y garcia', 'Juan y Garcia')): restored = pickle.loads(pickle.dumps(HumanName(text))) restored.capitalize(force=True) self.m(str(restored), want, restored) # One of a CLASS of names a pickle round trip changes, pinned so it # is not rediscovered as a bug. #458 moved the conjunction-versus- # initial decision into the parse, and a pickle carries no tags, so # the restored name is repaired the way 1.4.0 repaired everything # -- per word of the text, giving the Italian conjunction inside a # hyphenated middle name here. It is the pickle contract (strings # only, never a re-parse) meeting the tag read, not a defect in # either. 1.4.0 gave 'Juan e-F Smith' both ways. Since #383/#479 # the one-case fork widened this class: 'JUAN Y GARCIA' and 'john e # smith' also diverge on a round trip now, for the same reason and # through the same fallback, whether the reader is capitalize() or # (since #528) initials() -- pinned at # tests/v2/test_facade.py::test_initials_of_an_unpickled_or_copied_name_ask_the_vocabulary_too. def test_a_pickle_round_trip_loses_the_e_f_reading(self) -> None: direct = HumanName('juan e-f smith') direct.capitalize(force=True) self.m(str(direct), 'Juan E-F Smith', direct) restored = pickle.loads(pickle.dumps(HumanName('juan e-f smith'))) restored.capitalize(force=True) self.m(str(restored), 'Juan e-F Smith', restored) derek73-python-nameparser-456b024/tests/test_comma_variants.py000066400000000000000000000025671527303271700244770ustar00rootroot00000000000000from nameparser import HumanName from tests.base import HumanNameTestBase class HumanNameCommaVariantsTests(HumanNameTestBase): """Non-ASCII comma characters should split "Last, First" the same as ',' (#265).""" def test_arabic_comma_splits_lastname_format(self) -> None: hn = HumanName("سلمان، محمد") self.m(hn.first, "محمد", hn) self.m(hn.last, "سلمان", hn) def test_fullwidth_comma_splits_lastname_format(self) -> None: hn = HumanName("Smith,John") self.m(hn.first, "John", hn) self.m(hn.last, "Smith", hn) def test_arabic_comma_does_not_pollute_output(self) -> None: hn = HumanName("سلمان، محمد") self.assertNotIn("،", hn.last) self.assertNotIn("،", str(hn)) def test_trailing_arabic_comma_stripped(self) -> None: # matches ASCII behavior: a single word with a trailing comma has # nothing after the comma, so it's a bare name, not "Last," hn = HumanName("سلمان،") self.m(hn.first, "سلمان", hn) # The v1 test for a custom regexes dict omitting the 'commas' key (the # EMPTY_REGEX shatter guard) died with the mechanism: Constants(regexes= # ...) raises TypeError in 2.0 (deliberate divergence, migration spec # section 3 uniform rule) -- pinned in test_python_api.py's # test_override_regex_raises. derek73-python-nameparser-456b024/tests/test_conjunctions.py000066400000000000000000000266211527303271700242050ustar00rootroot00000000000000import pytest from nameparser import HumanName from tests.base import HumanNameTestBase class HumanNameConjunctionTestCase(HumanNameTestBase): # Last name with conjunction def test_last_name_with_conjunction(self) -> None: hn = HumanName('Jose Aznar y Lopez') self.m(hn.first, "Jose", hn) self.m(hn.last, "Aznar y Lopez", hn) def test_multiple_conjunctions(self) -> None: hn = HumanName("part1 of The part2 of the part3 and part4") self.m(hn.first, "part1 of The part2 of the part3 and part4", hn) def test_multiple_conjunctions2(self) -> None: hn = HumanName("part1 of and The part2 of the part3 And part4") self.m(hn.first, "part1 of and The part2 of the part3 And part4", hn) def test_ends_with_conjunction(self) -> None: hn = HumanName("Jon Dough and") self.m(hn.first, "Jon", hn) self.m(hn.last, "Dough and", hn) def test_ends_with_two_conjunctions(self) -> None: hn = HumanName("Jon Dough and of") self.m(hn.first, "Jon", hn) self.m(hn.last, "Dough and of", hn) def test_starts_with_conjunction(self) -> None: hn = HumanName("and Jon Dough") self.m(hn.first, "and Jon", hn) self.m(hn.last, "Dough", hn) def test_starts_with_two_conjunctions(self) -> None: hn = HumanName("the and Jon Dough") self.m(hn.first, "the and Jon", hn) self.m(hn.last, "Dough", hn) # Potential conjunction/prefix treated as initial (because uppercase) def test_uppercase_middle_initial_conflict_with_conjunction(self) -> None: hn = HumanName('John E Smith') self.m(hn.first, "John", hn) self.m(hn.middle, "E", hn) self.m(hn.last, "Smith", hn) def test_lowercase_middle_initial_with_period_conflict_with_conjunction(self) -> None: hn = HumanName('john e. smith') self.m(hn.first, "john", hn) self.m(hn.middle, "e.", hn) self.m(hn.last, "smith", hn) # The conjunction "e" can also be an initial def test_lowercase_first_initial_conflict_with_conjunction(self) -> None: hn = HumanName('e j smith') self.m(hn.first, "e", hn) self.m(hn.middle, "j", hn) self.m(hn.last, "smith", hn) def test_lowercase_middle_initial_conflict_with_conjunction(self) -> None: hn = HumanName('John e Smith') self.m(hn.first, "John", hn) self.m(hn.middle, "e", hn) self.m(hn.last, "Smith", hn) def test_lowercase_middle_initial_and_suffix_conflict_with_conjunction(self) -> None: hn = HumanName('John e Smith, III') self.m(hn.first, "John", hn) self.m(hn.middle, "e", hn) self.m(hn.last, "Smith", hn) self.m(hn.suffix, "III", hn) def test_lowercase_middle_initial_and_nocomma_suffix_conflict_with_conjunction(self) -> None: hn = HumanName('John e Smith III') self.m(hn.first, "John", hn) self.m(hn.middle, "e", hn) self.m(hn.last, "Smith", hn) self.m(hn.suffix, "III", hn) def test_lowercase_middle_initial_comma_lastname_and_suffix_conflict_with_conjunction(self) -> None: hn = HumanName('Smith, John e, III, Jr') self.m(hn.first, "John", hn) self.m(hn.middle, "e", hn) self.m(hn.last, "Smith", hn) self.m(hn.suffix, "III, Jr", hn) @pytest.mark.xfail(reason="#490") def test_two_initials_conflict_with_conjunction(self) -> None: # Supporting this seems to screw up titles with periods in them like M.B.A. hn = HumanName('E.T. Smith') self.m(hn.first, "E.", hn) self.m(hn.middle, "T.", hn) self.m(hn.last, "Smith", hn) def test_couples_names(self) -> None: hn = HumanName('John and Jane Smith') self.m(hn.first, "John and Jane", hn) self.m(hn.last, "Smith", hn) def test_couples_names_with_conjunction_lastname(self) -> None: hn = HumanName('John and Jane Aznar y Lopez') self.m(hn.first, "John and Jane", hn) self.m(hn.last, "Aznar y Lopez", hn) def test_couple_titles(self) -> None: hn = HumanName('Mr. and Mrs. John and Jane Smith') self.m(hn.title, "Mr. and Mrs.", hn) self.m(hn.first, "John and Jane", hn) self.m(hn.last, "Smith", hn) def test_couple_titles_ampersand_conjunction(self) -> None: # issue 151: single-char conjunctions in the conjunctions list should # be honored even when total_length < 4 hn = HumanName('Mr. & Mrs. John Smith') self.m(hn.title, "Mr. & Mrs.", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Smith", hn) def test_ampersand_conjunction_short_name_no_titles(self) -> None: # & is non-alpha so it should always be honored as a conjunction, # even when total_length < 4 (no titles to inflate the count) hn = HumanName('John & Jane') self.m(hn.first, "John & Jane", hn) def test_single_char_alpha_conjunction_still_treated_as_initial_when_short(self) -> None: # single-char alpha conjunctions (e, y) are still treated as initials # when total_length < 4; only non-alpha symbols like & bypass this guard hn = HumanName('John y Jane') self.m(hn.first, "John", hn) self.m(hn.middle, "y", hn) self.m(hn.last, "Jane", hn) def test_title_with_three_part_name_last_initial_is_suffix_uppercase_no_period(self) -> None: hn = HumanName("Pope John Alexander V") self.m(hn.title, "Pope", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Alexander", hn) self.m(hn.suffix, "V", hn) def test_four_name_parts_with_suffix_that_could_be_initial_lowercase_no_period(self) -> None: hn = HumanName("larry james edward johnson v") self.m(hn.first, "larry", hn) self.m(hn.middle, "james edward", hn) self.m(hn.last, "johnson", hn) self.m(hn.suffix, "v", hn) def test_four_name_parts_with_suffix_that_could_be_initial_uppercase_no_period(self) -> None: hn = HumanName("Larry James Johnson I") self.m(hn.first, "Larry", hn) self.m(hn.middle, "James", hn) self.m(hn.last, "Johnson", hn) self.m(hn.suffix, "I", hn) def test_roman_numeral_initials(self) -> None: hn = HumanName("Larry V I") self.m(hn.first, "Larry", hn) self.m(hn.middle, "V", hn) self.m(hn.last, "I", hn) self.m(hn.suffix, "", hn) def test_roman_numeral_suffix_not_in_suffix_list(self) -> None: # VI-X are not in the suffix word lists, so they reach the # is_roman_numeral(nxt) branch rather than are_suffixes() hn = HumanName("John Smith VI") self.m(hn.first, "John", hn) self.m(hn.last, "Smith", hn) self.m(hn.suffix, "VI", hn) # tests for Rev. title (Reverend) def test124(self) -> None: hn = HumanName("Rev. John A. Kenneth Doe") self.m(hn.title, "Rev.", hn) self.m(hn.middle, "A. Kenneth", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) def test125(self) -> None: hn = HumanName("Rev John A. Kenneth Doe") self.m(hn.title, "Rev", hn) self.m(hn.middle, "A. Kenneth", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) def test126(self) -> None: hn = HumanName("Doe, Rev. John A. Jr.") self.m(hn.title, "Rev.", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.middle, "A.", hn) self.m(hn.suffix, "Jr.", hn) def test127(self) -> None: hn = HumanName("Buca di Beppo") self.m(hn.first, "Buca", hn) self.m(hn.last, "di Beppo", hn) def test_le_as_last_name(self) -> None: hn = HumanName("Yin Le") self.m(hn.first, "Yin", hn) self.m(hn.last, "Le", hn) def test_le_as_last_name_with_middle_initial(self) -> None: hn = HumanName("Yin a Le") self.m(hn.first, "Yin", hn) self.m(hn.middle, "a", hn) self.m(hn.last, "Le", hn) def test_conjunction_in_an_address_with_a_title(self) -> None: hn = HumanName("His Excellency Lord Duncan") self.m(hn.title, "His Excellency Lord", hn) self.m(hn.last, "Duncan", hn) def test_conjunction_in_an_address_with_a_first_name_title(self) -> None: hn = HumanName("Her Majesty Queen Elizabeth") self.m(hn.title, "Her Majesty Queen", hn) # if you want to be technical, Queen is in GIVEN_NAME_TITLES self.m(hn.first, "Elizabeth", hn) def test_name_is_conjunctions(self) -> None: hn = HumanName("e and e") self.m(hn.first, "e and e", hn) def test_conjunction_bridges_prefix_chain(self) -> None: # "von" and "zu" are both prefixes, but "und" between them is only a # conjunction. join_on_conjunctions() merges "von und zu" into one # piece before the prefix-joining step runs, so without registering # that merged piece as a prefix too, it's stranded in the middle name # instead of joining to the last name. See German nobility styles # like "von und zu". hn = HumanName("Alois von und zu Liechtenstein") self.m(hn.first, "Alois", hn) self.m(hn.middle, "", hn) self.m(hn.last, "von und zu Liechtenstein", hn) def test_conjunction_bridges_prefix_chain_with_leading_title(self) -> None: # Same bridging, but with extra prefix words on both sides of the # conjunction and a leading title-like word ("Freiherrin") that is # itself a prefix, confirming the chain still joins fully into last. hn = HumanName("Annette Charlotte Freiherrin von und zu der Tann-Rathsamhausen") self.m(hn.first, "Annette", hn) self.m(hn.middle, "Charlotte", hn) self.m(hn.last, "Freiherrin von und zu der Tann-Rathsamhausen", hn) def test_conjunction_prefix_merge_at_start_stays_first_name(self) -> None: # Guards the i == 0 branch of the same fix: when the conjunction is # merged with a following prefix at the very start of the name, the # existing leading-prefix rule (a lone prefix opening the name is # treated as part of the first name, not joined to last) must still # apply to the merged piece. hn = HumanName("and van Buren") self.m(hn.first, "and van", hn) self.m(hn.last, "Buren", hn) def test_conjunction_bridges_word_that_is_both_title_and_prefix(self) -> None: # "freiherr" is registered as both a title and a prefix. When it sits # next to a conjunction, join_on_conjunctions() runs the is_title and # is_prefix checks independently (not elif), so the merged piece # ("freiherr und") is added to both constants sets. Confirms that # dual registration doesn't break the prefix-bridging into last. hn = HumanName("Fritz Freiherr und von Bar") self.m(hn.first, "Fritz", hn) self.m(hn.middle, "", hn) self.m(hn.last, "Freiherr und von Bar", hn) def test_conjunction_bridges_prefix_chain_with_multiple_conjunctions(self) -> None: # Two separate conjunctions ("und" appearing twice, not contiguous) # each bridge their own pair of adjacent prefixes, so both merges # must register as prefixes for the whole chain to join into last. hn = HumanName("Alois von und zu und von Liechtenstein") self.m(hn.first, "Alois", hn) self.m(hn.middle, "", hn) self.m(hn.last, "von und zu und von Liechtenstein", hn) derek73-python-nameparser-456b024/tests/test_constants.py000066400000000000000000001407271527303271700235110ustar00rootroot00000000000000import copy import pickle import re import warnings from typing import Any import pytest from nameparser import HumanName from nameparser.config import CONSTANTS, Constants, SetManager, TupleManager from nameparser.config.titles import TITLES from tests.base import HumanNameTestBase from tests.removals import ignore_removals class ConstantsCustomizationTests(HumanNameTestBase): def test_add_title(self) -> None: hn = HumanName("Te Awanui-a-Rangi Black", constants=Constants()) start_len = len(hn.C.titles) self.assertTrue(start_len > 0) hn.C.titles.add('te') self.assertEqual(start_len + 1, len(hn.C.titles)) hn.parse_full_name() self.m(hn.title, "Te", hn) self.m(hn.first, "Awanui-a-Rangi", hn) self.m(hn.last, "Black", hn) def test_constants_subclass_instance_is_used(self) -> None: class CustomConstants(Constants): pass c = CustomConstants() c.titles.add('bursar') hn = HumanName("Bursar Jane Smith", constants=c) self.assertIs(hn.C, c) self.m(hn.title, "Bursar", hn) self.m(hn.first, "Jane", hn) self.m(hn.last, "Smith", hn) def test_constants_invalid_type_raises_typeerror(self) -> None: with pytest.raises(TypeError, match="constants must be"): HumanName("John Doe", constants="not a Constants") # type: ignore[arg-type] def test_constants_class_instead_of_instance_raises_with_hint(self) -> None: # 2.0's message names the received class rather than v1's "did you # mean Constants()" hint; the class-not-instance mistake still fails # loudly at construction with pytest.raises(TypeError, match=r"constants must be a Constants instance"): HumanName("John Doe", constants=Constants) # type: ignore[arg-type] def test_assigning_invalid_constants_after_construction_raises(self) -> None: # #226 validated the constructor's `constants` argument, but `hn.C = ...` # bypassed it entirely: the bad value was accepted silently and only # surfaced far later, deep inside parsing, with no mention of `C` (#239) hn = HumanName("John Doe") with pytest.raises(TypeError, match="constants must be"): hn.C = "garbage" # type: ignore[assignment] def test_assigning_constants_class_after_construction_raises_with_hint(self) -> None: # same message note as the constructor variant above hn = HumanName("John Doe") with pytest.raises(TypeError, match=r"constants must be a Constants instance"): hn.C = Constants # type: ignore[assignment] def test_assigning_none_to_constants_raises(self) -> None: # constants=None was removed in 2.0 (#261, warned since 1.3.1): the v1 # silently-build-a-fresh-Constants fallback is gone from the C setter # too; pass Constants() or CONSTANTS.copy() explicitly hn = HumanName("John Doe") with pytest.raises(TypeError, match="261"): hn.C = None # type: ignore[assignment] def test_constants_bare_string_kwarg_raises_typeerror(self) -> None: # a bare string is an iterable of its characters, so set('dr') would # silently replace the default titles with {'d', 'r'} (#238); the # type system can't catch this because str satisfies Iterable[str] with pytest.raises(TypeError, match=r"wrap it in a list"): Constants(titles='dr') def test_set_manager_bare_string_raises_typeerror(self) -> None: with pytest.raises(TypeError, match=r"wrap it in a list"): SetManager('dr') def test_set_manager_bytes_raises_with_decode_hint(self) -> None: # a "wrap it in a list" hint would be a trap for bytes: [b'dr'] is # accepted but its elements never match parsed str tokens with pytest.raises(TypeError, match=r"decode it first"): SetManager(b'dr') # type: ignore[arg-type] def test_set_manager_bare_string_operand_raises_typeerror(self) -> None: # Set's mixin __or__/__and__ hand _from_iterable a generator, so the # constructor guard alone never sees a bare-string operand; without # an operand check, c.titles |= 'esq' silently adds 'e', 's', 'q' sm = SetManager(['dr', 'mr']) for op in (lambda: sm | 'abc', lambda: 'abc' | sm, lambda: sm & 'abc', lambda: 'abc' & sm, lambda: sm - 'abc', lambda: sm ^ 'abc'): with pytest.raises(TypeError, match=r"wrap it in a list"): op() def test_set_manager_operators_accept_lists(self) -> None: # end-to-end: an operator-built set wired back into Constants must # behave like an add()-built one, normalization included c = Constants() with pytest.raises(TypeError, match=r"wrap it in a list"): c.titles |= 'esq' # type: ignore[assignment] # |= produces a plain set that Constants.__setattr__ re-wraps in a # SetManager (2.0's auto-wrap; see the plain-iterable assignment # test below) -- mypy sees only the set-for-SetManager assignment c.titles |= ['Esq.'] # type: ignore[assignment] self.assertIn('esq', c.titles) hn = HumanName("Esq Jane Smith", constants=c) self.m(hn.title, "Esq", hn) def test_set_manager_operators_normalize_like_add(self) -> None: # add() lowercases and strips leading/trailing periods; without the # same normalization of operator operands, (titles | ['Esq.']) keeps # a raw 'Esq.', which the parser's lc()-based lookups can never match # — silently broken config, same failure family as the bare-string # shredding (#238). # Compared via set(...) rather than v1's .elements: the raw-set # accessor was internal machinery and is gone in 2.0 (#243 family); # 2.0 operators also return a plain set rather than a SetManager. sm = SetManager(['dr', 'mr']) self.assertEqual(set(sm | ['Esq.']), {'dr', 'mr', 'esq'}) self.assertEqual(set(['Esq.', 'Dr.'] | sm), {'dr', 'mr', 'esq'}) self.assertEqual(set(sm & ['Dr.']), {'dr'}) self.assertEqual(set(['Dr.'] & sm), {'dr'}) self.assertEqual(set(sm - ['Dr.']), {'mr'}) self.assertEqual(set(['Dr.', 'Esq.'] - sm), {'esq'}) self.assertEqual(set(sm ^ ['Dr.', 'Esq.']), {'mr', 'esq'}) # pins __rxor__ separately in case it ever stops aliasing __xor__ self.assertEqual(set(['Dr.', 'Esq.'] ^ sm), {'mr', 'esq'}) def test_set_manager_contains_normalizes_like_add(self) -> None: # add()/remove()/the constructor/the operators all normalize (lowercase, # strip leading/trailing periods) -- __contains__ was the lone holdout, # so `'Dr.' in c.titles` returned False even though every other # operation on the same value succeeded (#244) sm = SetManager(['dr', 'mr']) self.assertIn('Dr.', sm) self.assertIn('MR', sm) self.assertNotIn('Esq.', sm) def test_set_manager_le_uses_normalizing_contains(self) -> None: # the ABC comparison mixins route through __contains__, so an # un-normalized membership check would leak into subset/equality too sm = SetManager(['dr', 'mr']) self.assertTrue(sm <= SetManager(['dr', 'mr', 'esq'])) self.assertTrue({'Dr.', 'Mr.'} <= sm) def test_set_manager_rsub_is_order_sensitive(self) -> None: # __sub__ and __rsub__ are hand-written separately (subtraction # isn't commutative, unlike |/&/^), so a copy-paste operand swap # in __rsub__ would silently flip the result and nothing else # in this file would catch it sm = SetManager(['dr', 'mr']) self.assertEqual(set(['Dr.', 'Esq.'] - sm), {'esq'}) self.assertEqual(set(sm - ['Dr.', 'Esq.']), {'mr'}) def test_set_manager_constructor_normalizes_like_add(self) -> None: # without constructor normalization the operators misfire against # the exact spelling visibly stored in the set: & returns empty # and - silently no-ops sm = SetManager(['Dr.', 'MR']) self.assertEqual(set(sm), {'dr', 'mr'}) self.assertEqual(set(sm & ['Dr.']), {'dr'}) self.assertEqual(set(sm - ['Dr.']), {'mr'}) def test_constants_kwarg_elements_are_normalized(self) -> None: # Constants(titles=[...]) was the last silently-dead config path: # a raw 'Chemistry' element can never match the parser's lc() lookups c = Constants(titles=['chancellor', 'Chemistry']) hn = HumanName("Chemistry Jane Smith", constants=c) self.m(hn.title, "Chemistry", hn) def test_default_constants_construction_does_not_alias_defaults(self) -> None: # Constants() reuses the prebuilt _DEFAULT_TITLES snapshot via an # identity check instead of re-validating ~1,400 entries; if that # fast path ever returned the shared elements set instead of a # copy, mutating one Constants() instance would corrupt every # other instance's (and the module-level default's) titles c1 = Constants() c1.titles.add('zzz_should_not_leak') c2 = Constants() self.assertNotIn('zzz_should_not_leak', c2.titles) self.assertNotIn('zzz_should_not_leak', TITLES) def test_equal_but_not_identical_titles_list_still_validates(self) -> None: # the fast path in Constants.__init__ is an `is` check against the # raw TITLES object, not `==`; an equal-but-copied list must still # go through full normalization rather than accidentally matching c = Constants(titles=list(TITLES) + ['Extra.']) self.assertIn('extra', c.titles) def test_set_manager_non_str_elements_raise_typeerror(self) -> None: # lc() on junk elements either crashes context-free (bytes, int) or # silently transmutes None into '' — raise a curated error instead with pytest.raises(TypeError, match=r"decode it first"): SetManager([b'dr']) # type: ignore[list-item] with pytest.raises(TypeError, match=r"expected a str"): SetManager([None]) # type: ignore[list-item] with pytest.raises(TypeError, match=r"expected a str"): SetManager(['dr']) | [1] # type: ignore[list-item] def test_tuplemanager_bare_string_raises_typeerror(self) -> None: # dict(['ab', 'cd']) shreds each 2-char string into a key/value pair # silently -- and dict('ab') itself raises a cryptic "dictionary update # sequence element #0 has length 1; 2 is required" naming no argument # and suggesting no fix (#242) with pytest.raises(TypeError, match=r"wrap it in a list"): TupleManager('ab') # type: ignore[arg-type] def test_tuplemanager_bytes_raises_with_decode_hint(self) -> None: with pytest.raises(TypeError, match=r"decode it first"): TupleManager(b'ab') # type: ignore[arg-type] def test_tuplemanager_string_element_raises_typeerror(self) -> None: # the silent variant: an iterable of 2-character strings is a valid # dict-update sequence, so each one shreds into a key/value pair with pytest.raises(TypeError, match=r"key and a value"): TupleManager(['ab', 'cd']) # type: ignore[arg-type] def test_constants_capitalization_exceptions_string_elements_raise(self) -> None: with pytest.raises(TypeError, match=r"key and a value"): Constants(capitalization_exceptions=['ii']) # type: ignore[list-item] def test_tuplemanager_accepts_mapping_and_pairs(self) -> None: # the guard must not reject the two legitimate constructor shapes tm = TupleManager({'a': '1'}) self.assertEqual(tm.a, '1') tm2 = TupleManager([('b', '2')]) self.assertEqual(tm2.b, '2') def test_remove_title(self) -> None: hn = HumanName("Hon Solo", constants=Constants()) start_len = len(hn.C.titles) self.assertTrue(start_len > 0) hn.C.titles.remove('hon') self.assertEqual(start_len - 1, len(hn.C.titles)) hn.parse_full_name() self.m(hn.first, "Hon", hn) self.m(hn.last, "Solo", hn) def test_add_multiple_arguments(self) -> None: hn = HumanName("Assoc Dean of Chemistry Robert Johns", constants=Constants()) hn.C.titles.add('dean', 'Chemistry') hn.parse_full_name() self.m(hn.title, "Assoc Dean of Chemistry", hn) self.m(hn.first, "Robert", hn) self.m(hn.last, "Johns", hn) def test_instances_can_have_own_constants(self) -> None: hn = HumanName("", Constants()) hn2 = HumanName("") hn.C.titles.remove('hon') self.assertEqual('hon' in hn.C.titles, False) self.assertEqual(hn.has_own_config, True) self.assertEqual('hon' in hn2.C.titles, True) self.assertEqual(hn2.has_own_config, False) def test_can_change_global_constants(self) -> None: # This test exercises shared-CONSTANTS mutation specifically, which # 2.0 deprecates (removal 3.0; the message points at Lexicon/Policy # and private Constants); deprecated_call() pins the warning while # asserting the mutation is still honored. hn = HumanName("") hn2 = HumanName("") with pytest.deprecated_call(match="Lexicon"): hn.C.titles.remove('hon') self.assertEqual('hon' in hn.C.titles, False) self.assertEqual('hon' in hn2.C.titles, False) self.assertEqual(hn.has_own_config, False) self.assertEqual(hn2.has_own_config, False) # No manual cleanup needed: the autouse fixture in conftest.py snapshots # and restores the global CONSTANTS collections around every test. def test_custom_nickname_delimiter_raises(self) -> None: # Custom (non-sentinel) delimiter additions raise in 2.0 (deliberate # divergence, migration spec section 3 -- same uniform rule as # regexes); Policy(nickname_delimiters=...) is the replacement. The # #112 use case this test used to pin moved to the new API. hn = HumanName("") with pytest.raises(TypeError, match="Policy"): hn.C.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}') def test_remove_multiple_arguments(self) -> None: hn = HumanName("Ms Hon Solo", constants=Constants()) hn.C.titles.remove('hon', 'ms') hn.parse_full_name() self.m(hn.first, "Ms", hn) self.m(hn.middle, "Hon", hn) self.m(hn.last, "Solo", hn) def test_chain_multiple_arguments(self) -> None: hn = HumanName("Dean Ms Hon Solo", constants=Constants()) hn.C.titles.remove('hon', 'ms').add('dean') hn.parse_full_name() self.m(hn.title, "Dean", hn) self.m(hn.first, "Ms", hn) self.m(hn.middle, "Hon", hn) self.m(hn.last, "Solo", hn) def test_clear_removes_all_entries(self) -> None: hn = HumanName("Ms Hon Solo", constants=Constants()) hn.C.titles.clear() hn.parse_full_name() self.m(hn.first, "Ms", hn) self.m(hn.middle, "Hon", hn) self.m(hn.last, "Solo", hn) def test_empty_attribute_default_removed(self) -> None: # empty_attribute_default was removed in 2.0 (#255, warned since # 1.3.0): empty attributes are always ''. Assignment raises naming # the issue; the attribute no longer exists to read either. c = Constants() with pytest.raises(AttributeError, match="255"): c.empty_attribute_default = None # type: ignore[assignment] self.assertFalse(hasattr(c, 'empty_attribute_default')) hn = HumanName("") self.assertEqual(hn.first, '') self.assertEqual(hn.last, '') def test_add_with_encoding_removed(self) -> None: # add_with_encoding() was removed in 2.0 (#245/#263, warned in 1.4); # decode and use add() instead c = Constants() with pytest.raises(AttributeError, match="add_with_encoding"): c.titles.add_with_encoding(b'b\351ck', encoding='latin_1') # type: ignore[attr-defined] c.titles.add(b'b\351ck'.decode('latin_1')) self.assertIn('béck', c.titles) def test_set_manager_add_bytes_raises_with_decode_hint(self) -> None: # bytes elements were removed in 2.0 (#245, warned since 1.3.0) sm = SetManager(['dr']) with pytest.raises(TypeError, match="decode"): sm.add(b'esq') # type: ignore[arg-type] self.assertNotIn('esq', sm) def test_set_manager_add_str_does_not_warn(self) -> None: sm = SetManager(['dr']) with warnings.catch_warnings(): warnings.simplefilter("error") sm.add('esq') self.assertIn('esq', sm) def test_set_manager_call_removed(self) -> None: # __call__ handed out the raw underlying set, bypassing normalization # and change tracking; removed in 2.0 (#243, warned since 1.3.0) -- # iterate the manager or copy with set(manager) instead sm = SetManager(['dr']) with pytest.raises(TypeError, match="not callable"): sm() # type: ignore[operator] self.assertEqual(set(sm), {'dr'}) def test_set_manager_discard_ignores_missing_without_warning(self) -> None: sm = SetManager(['dr', 'mr']) with warnings.catch_warnings(): warnings.simplefilter("error") result = sm.discard('nope').discard('Dr.') # normalizes like remove() self.assertIs(result, sm) self.assertEqual(set(sm), {'mr'}) def test_set_manager_remove_missing_member_raises(self) -> None: # ignore-missing remove() became KeyError in 2.0 (#243, warned since # 1.3.0); discard() is the intentional ignore-missing spelling sm = SetManager(['dr']) with pytest.raises(KeyError): sm.remove('nope') self.assertEqual(set(sm), {'dr'}) # removing a present member stays silent with warnings.catch_warnings(): warnings.simplefilter("error") sm.remove('dr') self.assertEqual(len(sm), 0) def test_set_manager_remove_mixed_present_and_missing_in_one_call(self) -> None: # a single call mixing a present and a missing member raises for the # missing one (#243) but still applies the present removal, so # config state and change tracking don't silently diverge from what # was removed before the KeyError c = Constants() with pytest.raises(KeyError): c.titles.remove('hon', 'nope') self.assertNotIn('hon', c.titles) def test_set_manager_discard_mixed_present_and_missing_in_one_call(self) -> None: sm = SetManager(['dr', 'mr']) with warnings.catch_warnings(): warnings.simplefilter("error") result = sm.discard('dr', 'nope') self.assertIs(result, sm) self.assertEqual(set(sm), {'mr'}) def test_pickle_roundtrip_preserves_customizations(self) -> None: """A pickled Constants must restore its customized collections. Regression test: __setstate__ previously passed the whole state dict to __init__ as the `prefixes` argument, so every collection silently reverted to its module default on unpickling. """ c = Constants() c.titles.add('customtitle') c.prefixes.add('customprefix') c.titles.remove('hon') # (custom nickname delimiters raise in 2.0 -- see # test_custom_nickname_delimiter_raises -- so the delimiter leg of # this round-trip moved to the sentinel default below) # Safe: round-tripping a Constants the test just built, not untrusted data. restored = pickle.loads(pickle.dumps(c)) self.assertIn('customtitle', restored.titles) self.assertIn('customprefix', restored.prefixes) self.assertNotIn('hon', restored.titles) # The contributing collections must match the original exactly. self.assertEqual(set(restored.titles), set(c.titles)) self.assertEqual(set(restored.prefixes), set(c.prefixes)) # The collections must also keep their manager type, not just # contents (nickname_delimiters is the 2.0 delimiter manager, a # TupleManager subclass, so isinstance rather than exact type). self.assertEqual(type(restored.titles), SetManager) self.assertEqual(type(restored.prefixes), SetManager) self.assertIn('parenthesis', restored.nickname_delimiters) self.assertTrue(isinstance(restored.nickname_delimiters, TupleManager)) def test_pickle_roundtrip_preserves_instance_scalar_override(self) -> None: """An instance-level scalar override must survive a pickle round-trip. Exercised via string_format: the v1 vehicle for this test, empty_attribute_default, was removed in 2.0 (#255). """ c = Constants() c.string_format = "{last}" # Safe: round-tripping a Constants the test just built, not untrusted data. with warnings.catch_warnings(): warnings.simplefilter("error") ignore_removals() restored = pickle.loads(pickle.dumps(c)) self.assertEqual(restored.string_format, "{last}") def test_unpickle_legacy_state_raises(self) -> None: """Pre-1.3.0 pickles now raise (#279, warned since 1.3.0). Those blobs are recognizable by the computed ``suffixes_prefixes_titles`` property their dir()-sweep ``__getstate__`` captured; the 1.4 DeprecationWarning promised ValueError in 2.0, pointing at re-pickling under 1.3/1.4. """ legacy_state: dict[str, object] = { 'prefixes': {'van'}, 'titles': {'dr', 'legacytitle'}, 'suffixes_prefixes_titles': {'van', 'dr'}, } restored = Constants.__new__(Constants) with pytest.raises(ValueError, match="279"): restored.__setstate__(legacy_state) def test_setstate_without_legacy_keys_does_not_warn(self) -> None: c = Constants() c.titles.add('legacytitle') state = c.__getstate__() self.assertNotIn('suffixes_prefixes_titles', state) restored = Constants.__new__(Constants) with warnings.catch_warnings(): warnings.simplefilter("error") ignore_removals() restored.__setstate__(state) self.assertIn('legacytitle', restored.titles) def test_pickle_roundtrip_keeps_regexes_readable(self) -> None: """The regexes surface must survive a Constants pickle round-trip. In 2.0 ``regexes`` is a read-only proxy over the built-in patterns (not pickled state), so the v1 concern this test carried -- the RegexTupleManager subclass being downgraded by ``__reduce__`` -- no longer exists; what remains contractual is that reads keep working on the restored instance and unknown keys fail loudly (#256). """ c = Constants() # Safe: round-tripping a Constants the test just built, not untrusted data. restored = pickle.loads(pickle.dumps(c)) self.assertEqual(type(restored.regexes), type(c.regexes)) self.assertIsNotNone(restored.regexes.mac) with pytest.raises(AttributeError): # unknown-key access raises (#256) restored.regexes.does_not_exist def test_regexes_deepcopy_roundtrip(self) -> None: """copy.deepcopy of the regexes proxy must round-trip. The v1 bug this pinned: __getattr__ answered every unknown name -- including copy.deepcopy's __deepcopy__ probe -- with the EMPTY_REGEX default, so copy mistook a re.Pattern for a deep-copy hook. The 2.0 proxy must keep ignoring protocol probes. """ c = Constants() dup = copy.deepcopy(c.regexes) self.assertEqual(type(dup), type(c.regexes)) self.assertEqual(set(dup.keys()), set(c.regexes.keys())) # Unknown keys raise in 2.0 (#256) instead of returning EMPTY_REGEX. with pytest.raises(AttributeError): dup.does_not_exist def test_nickname_delimiters_deepcopy_roundtrip(self) -> None: """copy.deepcopy of nickname_delimiters must round-trip. Mirrors test_regexes_deepcopy_roundtrip on the delimiter manager (a TupleManager subclass in 2.0). Custom entries raise in 2.0, so the round-trip is exercised on the three built-in sentinels. """ c = Constants() dup = copy.deepcopy(c.nickname_delimiters) self.assertTrue(isinstance(dup, TupleManager)) self.assertEqual(dict(dup), dict(c.nickname_delimiters)) with pytest.raises(AttributeError): # unknown-key access raises (#256) dup.does_not_exist self.assertIsNotNone(dup.parenthesis) def test_maiden_delimiters_deepcopy_roundtrip(self) -> None: """copy.deepcopy of maiden_delimiters (empty by default) must round-trip.""" c = Constants() dup = copy.deepcopy(c.maiden_delimiters) self.assertTrue(isinstance(dup, TupleManager)) self.assertEqual(dict(dup), {}) with pytest.raises(AttributeError): # unknown-key access raises (#256) dup.does_not_exist def test_nickname_delimiters_default_builtins_resolve_live(self) -> None: # The three built-ins are stored as the *name* of a regexes entry # (a plain string), not a copied pattern, so overriding # self.C.regexes.parenthesis etc. keeps affecting nickname parsing -- # see test_overriding_builtin_regex_still_affects_nickname_parsing in # test_nicknames.py. c = Constants() entries = dict(c.nickname_delimiters) # v1 trio still present, still stored name-as-value for name in ('quoted_word', 'double_quotes', 'parenthesis'): self.assertEqual(entries[name], name) # A pre-#273 pickle restores as exactly its own three keys -- # __setstate__ REPLACES the bucket, never merges defaults in -- # so old configs don't silently gain the typographic pairs. legacy = Constants() state = legacy.__getstate__() state['nickname_delimiters'] = { 'quoted_word': 'quoted_word', 'double_quotes': 'double_quotes', 'parenthesis': 'parenthesis'} restored = Constants.__new__(Constants) restored.__setstate__(state) self.assertEqual(set(restored.nickname_delimiters), {'quoted_word', 'double_quotes', 'parenthesis'}) from nameparser import HumanName assert HumanName('John «Jack» Kennedy', constants=restored).nickname == '' assert HumanName('John (Jack) Kennedy', constants=restored).nickname == 'Jack' # 2.0 adds the #273 typographic sentinels alongside them, same # name-as-value scheme (full list pinned in tests/v2/ # test_config_shim.py against _SENTINEL_PAIRS) assert all(value == name for name, value in entries.items()) assert 'smart_double_quotes' in entries self.assertEqual(dict(c.maiden_delimiters), {}) def test_extra_nickname_delimiters_removed(self) -> None: c = Constants() self.assertFalse(hasattr(c, 'extra_nickname_delimiters')) def test_tuplemanager_setattr_delattr_ignore_dunder_names(self) -> None: """Regression test for the bug that motivated nickname_delimiters' __setattr__/__delattr__ dunder guard. Constructing a subscripted generic, e.g. TupleManager[re.Pattern[str] | str]({...}), makes typing's GenericAlias.__call__ set __orig_class__ on the new instance right after __init__ returns. Before the guard existed, __setattr__ was a bare dict.__setitem__ alias, so that assignment silently inserted a bogus '__orig_class__' entry into the dict itself, corrupting .values()/iteration for every TupleManager instance -- exactly what parse_nicknames() iterates over. This bit nickname_delimiters' construction (#22) before the guard was added. """ # The 2.0 TupleManager is a plain dict subclass, not Generic like # v1's -- but dict's inherited __class_getitem__ still makes the # subscription work at runtime, which is exactly the GenericAlias # __orig_class__ probe this test exists to exercise. tm = TupleManager[re.Pattern[str] | str]({'a': re.compile('x')}) # type: ignore[misc] self.assertNotIn('__orig_class__', tm) self.assertEqual(dict(tm), {'a': re.compile('x')}) # Dunder assignment/deletion still work as normal object attributes, # just routed around dict-backed storage. tm.__custom_dunder__ = 'probe' # type: ignore[attr-defined] self.assertEqual(tm.__custom_dunder__, 'probe') # type: ignore[attr-defined] self.assertNotIn('__custom_dunder__', tm) del tm.__custom_dunder__ # type: ignore[attr-defined] self.assertFalse(hasattr(tm, '__custom_dunder__')) def test_regextuplemanager_ignores_dunder_lookups(self) -> None: """Unknown dunder names report as absent, not as the EMPTY_REGEX default. Dunder names are Python's protocol probes (copy.deepcopy looks up __deepcopy__, inspect.unwrap looks up __wrapped__, ...), never config keys. Answering them with a regex breaks that machinery. """ c = Constants() sentinel = object() self.assertEqual(getattr(c.regexes, '__deepcopy__', sentinel), sentinel) # A normal (non-dunder) unknown key raises in 2.0 (#256, warned # since 1.4) instead of degrading to the EMPTY_REGEX default. with pytest.raises(AttributeError): c.regexes.unknown_key def test_tuplemanager_ignores_dunder_lookups(self) -> None: """Base TupleManager must report unknown dunder names as absent too. It returned None for any missing attribute, so `hasattr(tm, '__x__')` was always True — a landmine for any probe that does hasattr-then-call. Guarding dunders keeps the base consistent with RegexTupleManager. """ c = Constants() tm = c.capitalization_exceptions # a plain TupleManager sentinel = object() self.assertEqual(type(tm), TupleManager) self.assertFalse(hasattr(tm, '__deepcopy__')) self.assertEqual(getattr(tm, '__wrapped__', sentinel), sentinel) # A normal (non-dunder) unknown key raises in 2.0 (#256, warned # since 1.4) instead of returning the None default. with pytest.raises(AttributeError): tm.unknown_key def test_sunder_probe_reports_absent_without_deprecation_warning(self) -> None: # Single-underscore introspection probes (IPython/Jupyter's # _repr_html_, _ipython_canary_method_should_not_exist_, etc.) are # never config keys, just like dunders. In 2.0 they raise a plain # AttributeError -- the protocol-correct "absent" answer, so # hasattr-then-call probes work -- with no typo-flavored noise # (v1.4 returned the manager default silently instead). c = Constants() with warnings.catch_warnings(): warnings.simplefilter("error") self.assertFalse(hasattr(c.regexes, '_repr_html_')) self.assertFalse(hasattr(c.capitalization_exceptions, '_ipython_canary_method_should_not_exist_')) def test_tuplemanager_unknown_key_raises_naming_known_keys(self) -> None: # unknown-key attribute access raises in 2.0 (#256, warned since # 1.4): AttributeError naming the known keys, as the 1.4 warning # promised c = Constants() tm = c.capitalization_exceptions with pytest.raises(AttributeError, match="phd_typo") as excinfo: tm.phd_typo message = str(excinfo.value) for key in tm.keys(): self.assertIn(key, message) def test_tuplemanager_known_key_does_not_warn(self) -> None: c = Constants() with warnings.catch_warnings(): warnings.simplefilter("error") list(c.capitalization_exceptions.keys()) # sanity: has entries first_key = next(iter(c.capitalization_exceptions)) getattr(c.capitalization_exceptions, first_key) def test_regexes_unknown_key_raises(self) -> None: # the read-only regexes proxy raises on unknown keys too (#256); # unlike the tuple managers its message names only the missing key, # not the known-keys listing c = Constants() with pytest.raises(AttributeError, match="parenthesys"): c.regexes.parenthesys def test_regextuplemanager_known_key_does_not_warn(self) -> None: c = Constants() with warnings.catch_warnings(): warnings.simplefilter("error") c.regexes.mac def test_dunder_probe_does_not_emit_deprecation_warning(self) -> None: # dunder protocol probes must stay silent -- only real config typos warn c = Constants() sentinel = object() with warnings.catch_warnings(): warnings.simplefilter("error") self.assertEqual(getattr(c.regexes, '__deepcopy__', sentinel), sentinel) self.assertEqual(getattr(c.capitalization_exceptions, '__deepcopy__', sentinel), sentinel) # The v1 suffixes_prefixes_titles cached-union property and its # invalidation machinery (including the is_rootname predicate that read # it) are gone in 2.0 -- change tracking is the shim's generation # counter, covered in tests/v2/test_config_shim.py. The ~13 tests that # exercised cache priming/invalidation were deleted with it; the two # kept below re-pin their surviving contracts on public parsing surface. def test_pickle_roundtrip_rewires_change_tracking(self) -> None: """Mutations on a deserialized Constants must still reach the parser. The v1 version primed and re-read suffixes_prefixes_titles; that cache is gone, so this pins the same contract -- post-unpickle mutations are honored -- through an actual parse. """ c = Constants() # Safe: round-tripping a Constants the test just built, not untrusted data. restored = pickle.loads(pickle.dumps(c)) hn = HumanName("Posttitle Jane Smith", constants=restored) self.m(hn.title, "", hn) restored.titles.add('posttitle') hn.parse_full_name() self.m(hn.title, "Posttitle", hn) def test_replaced_manager_is_wired_for_change_tracking(self) -> None: """Wholesale manager replacement must wire the new manager's mutations. Covers the config-teardown path where a fresh SetManager is assigned directly (v1 pinned this via the suffixes_prefixes_titles cache; the 2.0 contract is that both the replacement and the new manager's own later mutations are honored by the next parse). """ c = Constants() hn = HumanName("Brandnewtitle Jane Smith", constants=c) self.m(hn.title, "", hn) c.titles = SetManager(['brandnewtitle']) hn.parse_full_name() # The replacement is reflected... self.m(hn.title, "Brandnewtitle", hn) # ...and the new manager's own mutations are tracked too, proving # the change callback was re-wired to the replacement. c.titles.add('secondtitle') hn.full_name = "Secondtitle Jane Smith" self.m(hn.title, "Secondtitle", hn) def test_tuplemanager_delattr_removes_dict_entry(self) -> None: """Deleting a non-dunder attribute must remove the dict entry. TupleManager routes non-dunder attribute deletion to ``del self[attr]`` (the mirror of its dot-notation __setattr__), so ``del tm.key`` and ``del tm['key']`` are the same operation. """ c = Constants() self.assertIn('phd', c.capitalization_exceptions) del c.capitalization_exceptions.phd # type: ignore[attr-defined] self.assertNotIn('phd', c.capitalization_exceptions) def test_assigning_iterable_to_set_attr_wraps_and_normalizes(self) -> None: """Assigning a plain iterable to a set field wraps it in a SetManager. 2.0 divergence from v1's guard: v1 demanded a pre-built SetManager (raising TypeError otherwise) because a bare collection would have broken its cache-invalidation wiring; the shim wraps the value itself (with full element validation and normalization), which protects the same invariant -- change tracking stays wired -- without the ceremony. Bare strings still raise (below), so #241's silent substring-test corruption stays impossible. """ c = Constants() c.titles = ['Mr.', 'ms'] # type: ignore[assignment] self.assertEqual(type(c.titles), SetManager) self.assertEqual(set(c.titles), {'mr', 'ms'}) for name in ('first_name_titles', 'conjunctions', 'bound_first_names', 'non_first_name_prefixes', 'suffix_acronyms_ambiguous'): setattr(c, name, ['X.']) self.assertEqual(type(getattr(c, name)), SetManager) self.assertIn('x', getattr(c, name)) def test_bare_string_assignment_to_conjunctions_raises(self) -> None: # the original #241 repro: 'and' assigned as a bare str would # silently degrade `piece.lower() in self.C.conjunctions` into a # substring test (or, wrapped naively, shred into {'a','n','d'}) c = Constants() with pytest.raises(TypeError, match='wrap it in a list'): c.conjunctions = 'and' # type: ignore[assignment] class ParsingDoesNotMutateConfigTests(HumanNameTestBase): """Parsing a name must never write back into the Constants it reads. The parser derives extra lookup entries while parsing (period-joined titles/suffixes like "Lt.Gov.", conjunction-joined pieces like "Mr. and Mrs." or "von und zu"). Those derivations are needed within the parse, but historically they were add()ed to ``self.C`` — by default the shared module-level CONSTANTS singleton — so parsing one name permanently changed how every later name in the process parsed: parse results depended on input order, and concurrent parsing raced on the shared sets. """ @staticmethod def _config_snapshot(constants: Constants) -> dict: """Snapshot every piece of configuration ``constants`` owns. Enumerated via ``Constants.__getstate__()`` — the canonical listing of an instance's own config (descriptor-backed names mapped to their public form, private caches like ``_pst`` excluded) — so a collection added to ``Constants`` later is watched automatically, with no attribute list to keep in sync. """ snap: dict[str, Any] = {} for attr, value in constants.__getstate__().items(): if isinstance(value, SetManager): snap[attr] = set(value) elif isinstance(value, TupleManager): snap[attr] = dict(value) else: snap[attr] = value # scalar override # Fail loud if the structural discovery ever stops seeing the sets # the parser historically leaked into. assert {'titles', 'prefixes', 'conjunctions', 'suffix_acronyms', 'suffix_not_acronyms'} <= set(snap), \ "config snapshot no longer covers the historically-mutated sets" return snap def _assert_config_unchanged(self, constants: Constants, before: dict, parsed: str) -> None: after = self._config_snapshot(constants) diffs = [] for attr in sorted(set(before) | set(after)): b, a = before.get(attr), after.get(attr) if b != a: if isinstance(b, set) and isinstance(a, set): diffs.append(f"{attr}: added {sorted(a - b)}, removed {sorted(b - a)}") else: diffs.append(f"{attr}: {b!r} -> {a!r}") self.assertEqual(diffs, [], f"parsing {parsed!r} changed the config: {diffs}") def _assert_parse_leaves_config_unchanged(self, name: str) -> HumanName: before = self._config_snapshot(CONSTANTS) hn = HumanName(name) self._assert_config_unchanged(CONSTANTS, before, name) return hn def test_period_joined_title_does_not_leak_into_titles(self) -> None: hn = self._assert_parse_leaves_config_unchanged("Lt.Gov. John Doe") # the within-parse derivation must still work self.m(hn.title, "Lt.Gov.", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) def test_period_joined_suffix_does_not_leak_into_suffixes(self) -> None: hn = self._assert_parse_leaves_config_unchanged("John Doe JD.CPA") self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.suffix, "JD.CPA", hn) def test_joined_conjunctions_do_not_leak_into_conjunctions(self) -> None: hn = self._assert_parse_leaves_config_unchanged("Louis of the Netherlands") self.m(hn.first, "Louis of the Netherlands", hn) def test_title_conjunction_join_does_not_leak_into_titles(self) -> None: hn = self._assert_parse_leaves_config_unchanged("Mr. and Mrs. John Smith") self.m(hn.title, "Mr. and Mrs.", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Smith", hn) def test_prefix_conjunction_join_does_not_leak_into_prefixes(self) -> None: hn = self._assert_parse_leaves_config_unchanged("Alois von und zu Liechtenstein") self.m(hn.first, "Alois", hn) self.m(hn.last, "von und zu Liechtenstein", hn) def test_instance_owned_constants_not_mutated_by_parsing(self) -> None: hn = HumanName("", constants=Constants()) before = self._config_snapshot(hn.C) hn.full_name = "Lt.Gov. John Doe" self._assert_config_unchanged(hn.C, before, "Lt.Gov. John Doe") self.m(hn.title, "Lt.Gov.", hn) def test_derivations_reset_between_parses_of_same_instance(self) -> None: # Re-assigning full_name re-parses; each parse must re-derive from a # clean slate and still resolve the period-joined title. hn = HumanName("Lt.Gov. John Doe") hn.full_name = "Lt.Gov. Jane Roe" self.m(hn.title, "Lt.Gov.", hn) self.m(hn.first, "Jane", hn) self.m(hn.last, "Roe", hn) class ConstantsReprTests(HumanNameTestBase): # The name lists live here rather than reading v1's # Constants._repr_collection_attrs/_repr_scalar_attrs class attributes, # which were internals and are gone in 2.0 (the shim keeps them as # module-level tuples). empty_attribute_default left the scalar list # with #255. collection_attrs = ( 'prefixes', 'suffix_acronyms', 'suffix_not_acronyms', 'titles', 'first_name_titles', 'conjunctions', 'bound_first_names', 'non_first_name_prefixes', 'suffix_acronyms_ambiguous', ) scalar_attrs = ( 'string_format', 'initials_format', 'initials_delimiter', 'initials_separator', 'suffix_delimiter', 'capitalize_name', 'force_mixed_case_capitalization', 'patronymic_name_order', 'middle_name_as_last', ) def test_repr_reports_actual_collection_sizes(self) -> None: c = Constants() repr_str = repr(c) for name in self.collection_attrs: self.assertIn(f"{name}: {len(getattr(c, name))}", repr_str) def test_repr_omits_scalars_at_default_value(self) -> None: c = Constants() repr_str = repr(c) for name in self.scalar_attrs: self.assertNotIn(name, repr_str) def test_repr_shows_scalar_override_via_constructor(self) -> None: c = Constants(middle_name_as_last=True) self.assertIn("middle_name_as_last: True", repr(c)) def test_repr_shows_scalar_override_via_assignment(self) -> None: # Most _repr_scalar_attrs (e.g. capitalize_name) aren't __init__ kwargs # at all -- they're only ever overridden by direct assignment. c = Constants() c.capitalize_name = True self.assertIn("capitalize_name: True", repr(c)) def test_repr_shows_multiple_simultaneous_scalar_overrides(self) -> None: c = Constants(patronymic_name_order=True) c.capitalize_name = True repr_str = repr(c) self.assertIn("patronymic_name_order: True", repr_str) self.assertIn("capitalize_name: True", repr_str) def test_repr_reflects_mutated_collection_size(self) -> None: c = Constants() before = len(c.titles) c.titles.add('a-brand-new-title-for-repr-test') self.assertIn(f"titles: {before + 1}", repr(c)) def test_repr_reports_empty_collection(self) -> None: c = Constants(titles=[]) self.assertIn("titles: 0", repr(c)) def test_repr_is_bracketed_multiline(self) -> None: repr_str = repr(Constants()) self.assertTrue(repr_str.startswith(" None: c = Constants() dup = c.copy() dup.titles.add('a-brand-new-title-for-copy-test') self.assertNotIn('a-brand-new-title-for-copy-test', c.titles) def test_copy_is_not_the_same_object(self) -> None: c = Constants() dup = c.copy() self.assertIsNot(dup, c) self.assertTrue(isinstance(dup, Constants)) def test_copy_preserves_subclass_type(self) -> None: # copy() is deepcopy-based (restored via __getstate__/__setstate__, # not by re-invoking type(self)(...)), so a naive reimplementation # could silently downgrade a subclass instance to plain Constants. class CustomConstants(Constants): pass c = CustomConstants() dup = c.copy() self.assertTrue(isinstance(dup, CustomConstants)) def test_copy_preserves_scalar_override_without_warning(self) -> None: # copy() restores saved state rather than replaying user # assignments, so it must carry scalar overrides across silently. # (The v1 vehicle for this test, empty_attribute_default, was # removed in 2.0 -- #255.) c = Constants() c.string_format = "{last}" with warnings.catch_warnings(): warnings.simplefilter("error") ignore_removals() dup = c.copy() self.assertEqual(dup.string_format, "{last}") def test_copy_snapshots_current_customizations(self) -> None: # Unlike Constants(), which always starts from library defaults, # .copy() preserves whatever customizations the original already has. c = Constants() c.titles.add('zephyrmark') dup = c.copy() self.assertIn('zephyrmark', dup.titles) # and stays a snapshot -- later mutation of the original doesn't leak in c.titles.remove('zephyrmark') self.assertIn('zephyrmark', dup.titles) def test_fresh_constants_does_not_include_source_customizations(self) -> None: # Contrast case for the snapshot test above: Constants() ignores # whatever CONSTANTS has been customized with. c = Constants() c.titles.add('zephyrmark') fresh = Constants() self.assertNotIn('zephyrmark', fresh.titles) class ConstantsNoneRemovalTests(HumanNameTestBase): """constants=None was removed in 2.0 (#261, warned since 1.3.1). The v1 fallback silently built a fresh Constants(), discarding any customizations the caller may have expected to carry over; 2.0 raises TypeError at every entry point instead. """ def test_explicit_none_raises_on_construction(self) -> None: with pytest.raises(TypeError, match="261"): HumanName("John Doe", constants=None) # type: ignore[arg-type] def test_explicit_none_raises_on_positional_argument(self) -> None: with pytest.raises(TypeError, match="261"): HumanName("John Doe", None) # type: ignore[arg-type] def test_explicit_none_raises_on_c_setter(self) -> None: hn = HumanName("John Doe") with pytest.raises(TypeError, match="261"): hn.C = None # type: ignore[assignment] def test_none_error_names_the_replacement(self) -> None: with pytest.raises(TypeError) as excinfo: HumanName("John Doe", constants=None) # type: ignore[arg-type] self.assertIn("Constants", str(excinfo.value)) def test_omitted_constants_argument_does_not_warn(self) -> None: with warnings.catch_warnings(): warnings.simplefilter("error") ignore_removals() HumanName("John Doe") def test_explicit_own_constants_instance_does_not_warn(self) -> None: with warnings.catch_warnings(): warnings.simplefilter("error") ignore_removals() HumanName("John Doe", constants=Constants()) derek73-python-nameparser-456b024/tests/test_east_slavic_patronymic_order.py000066400000000000000000000166021527303271700274240ustar00rootroot00000000000000import re from typing import cast from nameparser import HumanName from nameparser.config import Constants from tests.base import FlaggedConstantsTestBase, HumanNameTestBase # The 2.0 regexes proxy types its attributes as ``object`` (reads are # informational); the pattern-inspection tests below cast once here. _LATIN = cast('re.Pattern[str]', Constants().regexes.east_slavic_patronymic) _CYRILLIC = cast('re.Pattern[str]', Constants().regexes.east_slavic_patronymic_cyrillic) def test_latin_patronymic_matches() -> None: # One common suffix and one irregular — the integration tests cover the rest. assert _LATIN.search("Ivanovich") assert _LATIN.search("Ilyich") def test_latin_patronymic_rejects_non_patronymic() -> None: # EMPTY_REGEX (the default for missing keys) matches everything, # so this test is red until the real pattern is in place. assert not _LATIN.search("Smith") def test_latin_patronymic_end_anchored() -> None: # A surname ending in a patronymic suffix matches; the end-anchor does not # prevent this. The parser guard tests verify reordering is suppressed. assert _LATIN.search("Abramovich") def test_cyrillic_patronymic_matches() -> None: # One common suffix and one irregular. assert _CYRILLIC.search("Иванович") assert _CYRILLIC.search("ильич") def test_cyrillic_patronymic_matches_capitalized_irregular_forms() -> None: # The irregular forms (Ильич, Кузьмич, ...) are short enough that the # capitalized first letter falls within the matched suffix itself, unlike # the common suffixes (-ович, -евна, ...) where only the surname root is # capitalized. Case-insensitivity is required for these to match. assert _CYRILLIC.search("Ильич") assert _CYRILLIC.search("Кузьмич") assert _CYRILLIC.search("Лукич") assert _CYRILLIC.search("Фомич") assert _CYRILLIC.search("Фокич") def test_cyrillic_patronymic_rejects_non_patronymic() -> None: assert not _CYRILLIC.search("Иванов") class PatronymicNameOrderReorderTests(FlaggedConstantsTestBase): """Names that SHOULD be rotated when the flag is on.""" constants_kwargs = {"patronymic_name_order": True} def test_canonical_latin(self) -> None: n = self.hn("Ivanov Ivan Ivanovich") assert n.first == "Ivan" assert n.middle == "Ivanovich" assert n.last == "Ivanov" def test_sergeevich(self) -> None: n = self.hn("Zarubkin Alexander Sergeevich") assert n.first == "Alexander" assert n.middle == "Sergeevich" assert n.last == "Zarubkin" def test_hyphenated_surname(self) -> None: # A hyphenated surname counts as one token. n = self.hn("Blokin-Mechtalin Konstantin Yurievich") assert n.first == "Konstantin" assert n.middle == "Yurievich" assert n.last == "Blokin-Mechtalin" def test_surname_looks_like_patronymic(self) -> None: # "Petsevich" ends in -evich but is in the FIRST position. n = self.hn("Petsevich Sergey Vitalyevich") assert n.first == "Sergey" assert n.middle == "Vitalyevich" assert n.last == "Petsevich" def test_cyrillic(self) -> None: n = self.hn("Иванов Иван Иванович") assert n.first == "Иван" assert n.middle == "Иванович" assert n.last == "Иванов" def test_cyrillic_capitalized_irregular_form(self) -> None: # "Ильич" is short enough that the capitalized first letter falls # within the irregular suffix itself; requires case-insensitive match. n = self.hn("Иванов Иван Ильич") assert n.first == "Иван" assert n.middle == "Ильич" assert n.last == "Иванов" def test_title_preserved(self) -> None: n = self.hn("Dr. Ivanov Ivan Ivanovich") assert n.title == "Dr." assert n.first == "Ivan" assert n.middle == "Ivanovich" assert n.last == "Ivanov" def test_suffix_preserved(self) -> None: n = self.hn("Ivanov Ivan Ivanovich Jr.") assert n.first == "Ivan" assert n.middle == "Ivanovich" assert n.last == "Ivanov" assert n.suffix == "Jr." def test_western_patronymic_surname_reordered_when_flag_on(self) -> None: # Documented opt-in tradeoff: a Western name whose last token ends in a # patronymic suffix is reordered incorrectly. Not a bug to fix. n = self.hn("David Michael Abramovich") assert n.first == "Michael" assert n.middle == "Abramovich" assert n.last == "David" class PatronymicNameOrderGuardsTests(FlaggedConstantsTestBase): """Names that must NOT be reordered even when the flag is on.""" constants_kwargs = {"patronymic_name_order": True} def test_already_correct_order(self) -> None: # middle is patronymic → already in Western order, do not rotate n = self.hn("Ivan Ivanovich Ivanov") assert n.first == "Ivan" assert n.middle == "Ivanovich" assert n.last == "Ivanov" def test_middle_is_patronymic_surname_ends_ovich(self) -> None: # "Roman Arkadyevich Abramovich": middle IS patronymic → guard fires n = self.hn("Roman Arkadyevich Abramovich") assert n.first == "Roman" assert n.middle == "Arkadyevich" assert n.last == "Abramovich" def test_two_token_name(self) -> None: # 2-token: middle_list is empty → condition fails n = self.hn("Roman Abramovich") assert n.first == "Roman" assert n.last == "Abramovich" def test_no_patronymic(self) -> None: # Three tokens but no patronymic suffix on last → not reordered n = self.hn("Ivanov Ivan Petrov") assert n.first == "Ivanov" assert n.middle == "Ivan" assert n.last == "Petrov" def test_western_name_unchanged(self) -> None: n = self.hn("John Michael Smith") assert n.first == "John" assert n.middle == "Michael" assert n.last == "Smith" def test_comma_guard_last_first_pat(self) -> None: # "Ivanov, Ivan Ivanovich" — comma means the order was declared n = self.hn("Ivanov, Ivan Ivanovich") assert n.first == "Ivan" assert n.middle == "Ivanovich" assert n.last == "Ivanov" def test_comma_guard_patronymic_form_surname(self) -> None: # Without the comma guard this would wrongly rotate n = self.hn("Sergeevich, Ivan Petrov") assert n.last == "Sergeevich" class PatronymicNameOrderFlagOffTests(HumanNameTestBase): """With default Constants (flag=False) nothing changes.""" def test_canonical_unchanged(self) -> None: n = HumanName("Ivanov Ivan Ivanovich") assert n.first == "Ivanov" assert n.middle == "Ivan" assert n.last == "Ivanovich" class PatronymicNameOrderFlagTests(HumanNameTestBase): def test_default_is_false(self) -> None: C = Constants() assert C.patronymic_name_order is False def test_can_set_true_via_constructor(self) -> None: C = Constants(patronymic_name_order=True) assert C.patronymic_name_order is True def test_does_not_affect_other_instance(self) -> None: C1 = Constants(patronymic_name_order=True) C2 = Constants() assert C1.patronymic_name_order is True assert C2.patronymic_name_order is False derek73-python-nameparser-456b024/tests/test_first_name.py000066400000000000000000000106701527303271700236150ustar00rootroot00000000000000from nameparser import HumanName from tests.base import HumanNameTestBase class FirstNameHandlingTests(HumanNameTestBase): def test_first_name(self) -> None: hn = HumanName("Andrew") self.m(hn.first, "Andrew", hn) def test_assume_title_and_one_other_name_is_last_name(self) -> None: hn = HumanName("Rev Andrews") self.m(hn.title, "Rev", hn) self.m(hn.last, "Andrews", hn) def test_assume_suffix_title_and_one_other_name_is_last_name(self) -> None: # xfail in v1 (which parsed first='M.D.'); 2.0's family-comma # suffix peel routes the post-comma suffix piece to suffix and # keeps the pre-comma piece in last, so the long-desired # expectation now holds. 2.0: fix(comma-family/suffix-routing). hn = HumanName("Andrews, M.D.") self.m(hn.suffix, "M.D.", hn) self.m(hn.last, "Andrews", hn) def test_suffix_in_lastname_part_of_lastname_comma_format(self) -> None: hn = HumanName("Smith Jr., John") self.m(hn.last, "Smith", hn) self.m(hn.first, "John", hn) self.m(hn.suffix, "Jr.", hn) def test_sir_exception_to_first_name_rule(self) -> None: hn = HumanName("Sir Gerald") self.m(hn.title, "Sir", hn) self.m(hn.first, "Gerald", hn) def test_pope_exception_to_first_name_rule(self) -> None: hn = HumanName("Pope Francis") self.m(hn.title, "Pope", hn) self.m(hn.first, "Francis", hn) def test_king_is_a_first_name_outside_a_title_phrase(self) -> None: # #752 phase 2j: 'king' is a title only inside a title phrase hn = HumanName("King Henry") self.m(hn.first, "King", hn) self.m(hn.last, "Henry", hn) hn = HumanName("His Majesty King Henry") self.m(hn.title, "His Majesty King", hn) self.m(hn.first, "Henry", hn) def test_queen_is_a_first_name_outside_a_title_phrase(self) -> None: # #913: 'queen' is a title only inside a title phrase hn = HumanName("Queen Latifah") self.m(hn.first, "Queen", hn) self.m(hn.last, "Latifah", hn) hn = HumanName("Her Majesty Queen Elizabeth") self.m(hn.title, "Her Majesty Queen", hn) self.m(hn.first, "Elizabeth", hn) def test_dame_exception_to_first_name_rule(self) -> None: hn = HumanName("Dame Mary") self.m(hn.title, "Dame", hn) self.m(hn.first, "Mary", hn) def test_first_name_is_not_prefix_if_only_two_parts(self) -> None: """When there are only two parts, don't join prefixes or conjunctions""" hn = HumanName("Van Nguyen") self.m(hn.first, "Van", hn) self.m(hn.last, "Nguyen", hn) def test_first_name_is_not_prefix_if_only_two_parts_comma(self) -> None: hn = HumanName("Nguyen, Van") self.m(hn.first, "Van", hn) self.m(hn.last, "Nguyen", hn) def test_first_name_is_prefix_if_three_parts(self) -> None: """Fixed in 2.2 (#367) by making titles transparent to the leading-particle exception. This carried v1's ``xfail`` and the docstring "Not sure how to fix this without breaking Mr and Mrs" for the whole life of the 1.x line -- the exception was keyed on piece index 0, so any title displaced the particle and the prefix chain fired, giving last='Van Nguyen'. It is keyed on the first piece that is not a title OR is a particle -- not the naive "first piece that is not a title", which collapses 'St John Smith' into one given name and breaks tests/test_constants.py::ConstantsCustomizationTests ::test_add_title, 15 failures in all. The Mr/Mrs conclusion holds under either rule, which is why the Mr/Mrs shapes are asserted here rather than only asserted about: a bare 'Mr./Mrs. Surname' has no particle to displace, so nothing about it reaches the rule that moved. v1's concern was about the fix it had in mind, not about this one.""" hn = HumanName("Mr. Van Nguyen") self.m(hn.first, "Van", hn) self.m(hn.last, "Nguyen", hn) for text, last in [("Mr. Smith", "Smith"), ("Mrs. Smith", "Smith"), ("Mr. Nguyen", "Nguyen")]: titled = HumanName(text) self.m(titled.first, "", titled) self.m(titled.last, last, titled) titled = HumanName("Mr. John Smith") self.m(titled.first, "John", titled) self.m(titled.last, "Smith", titled) derek73-python-nameparser-456b024/tests/test_initials.py000066400000000000000000000350331527303271700233020ustar00rootroot00000000000000from nameparser import HumanName from nameparser.config import Constants from tests.base import HumanNameTestBase class InitialsTestCase(HumanNameTestBase): def test_initials(self) -> None: hn = HumanName("Andrew Boris Petersen") self.m(hn.initials(), "A. B. P.", hn) def test_initials_simple_name(self) -> None: hn = HumanName("John Doe") self.m(hn.initials(), "J. D.", hn) hn = HumanName("John Doe", initials_format="{first} {last}") self.m(hn.initials(), "J. D.", hn) hn = HumanName("John Doe", initials_format="{last}") self.m(hn.initials(), "D.", hn) hn = HumanName("John Doe", initials_format="{first}") self.m(hn.initials(), "J.", hn) hn = HumanName("John Doe", initials_format="{middle}") self.m(hn.initials(), "", hn) def test_initials_all_empty_renders_empty_string(self) -> None: # The v1 None display mode (and its literal-"None" interpolation # regressions) died with #255: a fully-empty result is now always # '', matching the first/last accessors. hn = HumanName("", constants=Constants()) self.assertEqual(hn.initials(), "") def test_initials_with_an_attached_tussenvoegsel(self) -> None: # "Vega, Juan de la" used to park "de la" in the middle name; #379 # attaches it to the family instead, so the initials come from # 'Juan' and the family BASE. The all-prefix MIDDLE this test # was written for now comes from "Nguyen, Van Le" below -- # whether such a middle should exist at all is #402. hn = HumanName("Vega, Juan de la") self.m(hn.middle, "", hn) self.m(hn.last, "de la Vega", hn) self.assertEqual(hn.initials_list(), ["J", "V"]) self.assertEqual(hn.initials(), "J. V.") def test_initials_middle_name_all_prefixes(self) -> None: # "Nguyen, Van Le" parses with middle name "Le", every word of # which is particle vocabulary. A particle standing alone in a # name part is not doing a particle's work there, so it # initials as an ordinary name word (rules/R2/#R3) rather # than being dropped -- this read "V. N." until #404, losing # the middle name entirely. hn = HumanName("Nguyen, Van Le") self.m(hn.middle, "Le", hn) self.assertEqual(hn.initials_list(), ["V", "L", "N"]) self.assertEqual(hn.initials(), "V. L. N.") def test_initials_still_drop_a_particle_beside_a_name(self) -> None: # The other half: where the part HAS a name word, the particle # is doing particle work and contributes nothing. hn = HumanName("Juan de la Vega") self.m(hn.last, "de la Vega", hn) self.assertEqual(hn.initials(), "J. V.") def test_initials_complex_name(self) -> None: hn = HumanName("Doe, John A. Kenneth, Jr.") self.m(hn.initials(), "J. A. K. D.", hn) def test_initials_format(self) -> None: hn = HumanName("Doe, John A. Kenneth, Jr.", initials_format="{first} {middle}") self.m(hn.initials(), "J. A. K.", hn) hn = HumanName("Doe, John A. Kenneth, Jr.", initials_format="{first} {last}") self.m(hn.initials(), "J. D.", hn) hn = HumanName("Doe, John A. Kenneth, Jr.", initials_format="{middle} {last}") self.m(hn.initials(), "A. K. D.", hn) hn = HumanName("Doe, John A. Kenneth, Jr.", initials_format="{first}, {last}") self.m(hn.initials(), "J., D.", hn) # The *_constants tests below ran on the shared CONSTANTS singleton in # v1; 2.0 deprecates shared mutation, so they use a private Constants # passed as constants= (the migration-guide idiom) -- the config # attribute under test is the same either way. def test_initials_format_constants(self) -> None: c = Constants() c.initials_format = "{first} {last}" hn = HumanName("Doe, John A. Kenneth, Jr.", constants=c) self.m(hn.initials(), "J. D.", hn) c.initials_format = "{first} {last}" hn = HumanName("Doe, John A. Kenneth, Jr.", constants=c) self.m(hn.initials(), "J. D.", hn) def test_initials_delimiter(self) -> None: hn = HumanName("Doe, John A. Kenneth, Jr.", initials_delimiter=";") self.m(hn.initials(), "J; A; K; D;", hn) def test_initials_delimiter_constants(self) -> None: c = Constants() c.initials_delimiter = ";" hn = HumanName("Doe, John A. Kenneth, Jr.", constants=c) self.m(hn.initials(), "J; A; K; D;", hn) def test_initials_list(self) -> None: hn = HumanName("Andrew Boris Petersen") self.m(hn.initials_list(), ["A", "B", "P"], hn) def test_initials_list_complex_name(self) -> None: hn = HumanName("Doe, John A. Kenneth, Jr.") self.m(hn.initials_list(), ["J", "A", "K", "D"], hn) def test_initials_with_prefix_firstname(self) -> None: hn = HumanName("Van Jeremy Johnson") self.m(hn.initials_list(), ["V", "J", "J"], hn) def test_initials_with_prefix(self) -> None: hn = HumanName("Alex van Johnson") self.m(hn.initials_list(), ["A", "J"], hn) def test_initials_delimiter_empty_string_kwarg(self) -> None: # Regression: initials_delimiter='' was silently ignored due to `or` defaulting hn = HumanName("Doe, John A.", initials_delimiter="") self.m(hn.initials(), "J A D", hn) def test_initials_format_empty_string_kwarg(self) -> None: # Regression: initials_format='' was silently ignored due to `or` defaulting hn = HumanName("Doe, John A.") hn2 = HumanName("Doe, John A.", initials_format="") self.assertNotEqual(hn.initials(), hn2.initials()) # "".format(...) returns ""; collapse_whitespace returns "" which falls through # to empty_attribute_default (may be "" or None depending on config variant). self.assertFalse(hn2.initials()) def test_initials_separator_kwarg(self) -> None: # initials_separator="" with initials_format="{first}{middle}{last}" gives # period-separated initials with no spaces — a common academic citation style hn = HumanName( "Doe, John A. Kenneth", initials_separator="", initials_format="{first}{middle}{last}", ) self.m(hn.initials(), "J.A.K.D.", hn) def test_initials_separator_custom_value(self) -> None: # Non-empty custom separator exercising _process_initial on a multi-word # token. "Van Berg" is a single name part whose two words produce two initials # joined by initials_separator. # This calls _process_initial directly with a bare string (tokens=None), # which is the v1 string path -- HumanName.initials() itself no longer # takes it (#528). See test_initials_separator_is_honored_on_the_live_ # token_path in tests/v2/test_render.py for the live token path's pin. hn = HumanName("", initials_separator="-", initials_delimiter=".") result = hn._process_initial("Van Berg", firstname=True) self.assertEqual(result, "V-B") def test_str_default_behavior_unchanged(self) -> None: # Regression guard for the `or` → `is not None` change in __str__: # the default path (no string_format kwarg) must still produce the expected string. hn = HumanName("John Doe") self.assertEqual(str(hn), "John Doe") def test_initials_with_doubled_space_in_list_element(self) -> None: # v1's #232 hole -- direct *_list assignment injecting unnormalized # elements that made initials raise IndexError -- is unreachable in # 2.0: *_list attributes are read-only snapshots (spec section 2 # exc. 1) and the field setter normalizes whitespace, splitting a # doubled-space element into clean members. hn = HumanName(first="John") hn.middle = ["Q R"] self.assertEqual(hn.middle_list, ["Q", "R"]) self.assertEqual(hn.initials_list(), ["J", "Q", "R"]) self.assertEqual(hn.initials(), "J. Q. R.") def test_constructor_first(self) -> None: hn = HumanName(first="TheName") self.m(hn.first, "TheName", hn) def test_constructor_middle(self) -> None: hn = HumanName(middle="TheName") self.m(hn.middle, "TheName", hn) def test_constructor_last(self) -> None: hn = HumanName(last="TheName") self.m(hn.last, "TheName", hn) def test_constructor_title(self) -> None: hn = HumanName(title="TheName") self.m(hn.title, "TheName", hn) def test_constructor_suffix(self) -> None: hn = HumanName(suffix="TheName") self.m(hn.suffix, "TheName", hn) def test_constructor_nickname(self) -> None: hn = HumanName(nickname="TheName") self.m(hn.nickname, "TheName", hn) def test_constructor_multiple(self) -> None: hn = HumanName(first="TheName", last="lastname", title="mytitle", full_name="donotparse") self.m(hn.first, "TheName", hn) self.m(hn.last, "lastname", hn) self.m(hn.title, "mytitle", hn) def test_initials_separator_kwarg_multiword_part(self) -> None: # Regression: initials_separator kwarg must flow into _process_initial # for multi-word name parts, not just into the initials() join calls. # Direct string call (tokens=None), the v1 path -- see the comment on # test_initials_separator_custom_value above. hn = HumanName("", initials_separator="") result = hn._process_initial("Van Berg", firstname=True) self.assertEqual(result, "VB") def test_string_format_empty_string_kwarg(self) -> None: # Regression: string_format='' was silently ignored due to `or` defaulting hn = HumanName("John Doe", string_format="") self.assertEqual(str(hn), "") def test_initials_separator_empty_multi_part_middle(self) -> None: # Full workflow from issue #152: empty delimiter + separator + compact format # gives fully concatenated initials with no spaces or punctuation. # Spaces between groups come from initials_format, so that must also be set. hn = HumanName( "Doe, John A. Kenneth", initials_delimiter="", initials_separator="", initials_format="{first}{middle}{last}", ) self.m(hn.initials(), "JAKD", hn) def test_initials_separator_constants_multi_part_middle(self) -> None: c = Constants() c.initials_delimiter = "" c.initials_separator = "" c.initials_format = "{first}{middle}{last}" hn = HumanName("Doe, John A. Kenneth", constants=c) self.m(hn.initials(), "JAKD", hn) def test_initials_separator_default_on_constants(self) -> None: # The shared singleton's default must be untouched by the private- # Constants tests above (and, in file order, by anything the autouse # fixture restores). from nameparser.config import CONSTANTS self.assertEqual(CONSTANTS.initials_separator, " ") def test_initials_keep_an_initial_shaped_conjunction_letter(self) -> None: # #462: v1's is_conjunction was "in the set AND NOT # is_an_initial", so a dotted or bare-capital E/Y is the # initial it looks like, not the Italian/Spanish connective. # The 2.0 facade lost that exclusion and gave 'S. W.'; # parse().initials() never had the bug. 1.4.0 parity, all four. for name, want in (("Scott E. Werner", "S. E. W."), ("John E Smith", "J. E. S."), ("Juan Y. Garcia", "J. Y. G."), # the letter is the WHOLE middle group here, # so before the fix the group yielded nothing # and was dropped outright: 'S. V.', a name # short of its middle rather than a letter ("Vega, Santa de Y", "S. Y. V."), ("Хосе И. Мария Сантос", "Х. И. М. С.")): hn = HumanName(name) self.m(hn.initials(), want, hn) def test_initials_drop_a_bare_non_ascii_conjunction_letter(self) -> None: # v1's bound, kept: _render._INITIAL is `^(\w\.|[A-Z])$`, so # its DOTTED half is Unicode-aware (the 'И.' above survives) # and its bare-capital half is ASCII-only. A bare Cyrillic 'И' # is therefore not initial-shaped, stays the connective, and # drops -- 'Х. М. С.' for a four-word name. #462 restored v1's # exclusion; it did not widen the shape v1 excluded on. hn = HumanName("Хосе Мария И Сантос") self.m(hn.initials(), "Х. М. С.", hn) def test_initials_follow_the_one_case_fork_on_both_letters(self) -> None: # Renamed from test_initials_still_drop_a_lowercase_conjunction # (#528): that name described only 'y''s half, which is the one # half this change does NOT move -- keeping it would have hidden # that 'e''s half now moves. The two halves of the fork, on the # facade. 'y' is not marked # as reading both ways, so in a name written wholly in one case # it is the connective and drops -- 1.4.0's answer, unmoved. # 'e' IS marked, so the same name shape reads it as an initial # and it contributes: 'j. s.' through 2.3.0, 'j. e. s.' since # #528 made this view read the parse's tags instead of # re-deriving from vocabulary and shape. The core's # parse(...).initials() has given 'j. e. s.' since #383/#479 # and the two agree now; decisions/P8 and decisions/R3 # carry the split and its closing. hn = HumanName("john e smith") self.m(hn.initials(), "j. e. s.", hn) # 'maria y lopez' moved with #461, not with the one-case fork: # its 'y' holds the middle part alone, so there is nothing # there for it to join and it initials like any other name # word, agreeing with the parse's own view. rules/R3 states # the rule for all three groups and decisions/R3 records # what it costs. The letter's TAG is unchanged -- a lowercase # 'y' outside the marked set is still the connective. hn = HumanName("maria y lopez") self.m(hn.initials(), "m. y. l.", hn) # These two are among #528's four movers, written once with # the letter mid-name and once at the end after a comma: both # give the same initials() answer by different routes. hn = HumanName("john e jones") self.m(hn.initials(), "j. e. j.", hn) hn = HumanName("jones, john e") self.m(hn.initials(), "j. e. j.", hn) derek73-python-nameparser-456b024/tests/test_middle_name_as_last.py000066400000000000000000000114721527303271700254330ustar00rootroot00000000000000from nameparser import HumanName from nameparser.config import Constants from tests.base import FlaggedConstantsTestBase, HumanNameTestBase class MiddleNameAsLastFlagTests(HumanNameTestBase): def test_default_is_false(self) -> None: C = Constants() assert C.middle_name_as_last is False def test_can_set_true_via_constructor(self) -> None: C = Constants(middle_name_as_last=True) assert C.middle_name_as_last is True def test_does_not_affect_other_instance(self) -> None: C1 = Constants(middle_name_as_last=True) C2 = Constants() assert C1.middle_name_as_last is True assert C2.middle_name_as_last is False class MiddleNameAsLastFoldTests(FlaggedConstantsTestBase): constants_kwargs = {"middle_name_as_last": True} def test_fold_no_comma(self) -> None: n = self.hn("Mohamad Ahmad Ali Hassan") self.m(n.first, "Mohamad", n) self.m(n.middle, "", n) self.m(n.last, "Ahmad Ali Hassan", n) def test_fold_comma_converges(self) -> None: no_comma = self.hn("Mohamad Ahmad Ali Hassan") comma = self.hn("Hassan, Mohamad Ahmad Ali") self.m(comma.first, no_comma.first, comma) self.m(comma.last, no_comma.last, comma) def test_title_and_suffix_preserved(self) -> None: n = self.hn("Dr. Mohamad Ahmad Hassan Jr") self.m(n.title, "Dr.", n) self.m(n.last, "Ahmad Hassan", n) self.m(n.suffix, "Jr", n) def test_suffix_preserved_comma_format(self) -> None: # Comma-delimited suffix takes a different code path than the # title/suffix no-comma case above; the fold must still apply. n = self.hn("Hassan, Mohamad Ahmad Ali, Jr.") self.m(n.first, "Mohamad", n) self.m(n.middle, "", n) self.m(n.last, "Ahmad Ali Hassan", n) self.m(n.suffix, "Jr.", n) def test_nickname_preserved(self) -> None: # Nicknames are stripped in pre_process(), before the fold runs. n = self.hn('Mohamad "Mo" Ahmad Ali Hassan') self.m(n.nickname, "Mo", n) self.m(n.middle, "", n) self.m(n.last, "Ahmad Ali Hassan", n) def test_no_middle_is_noop(self) -> None: n = self.hn("John Doe") self.m(n.first, "John", n) self.m(n.middle, "", n) self.m(n.last, "Doe", n) def test_single_token_is_noop(self) -> None: n = self.hn("Cher") self.m(n.first, "Cher", n) self.m(n.middle, "", n) self.m(n.last, "", n) def test_given_names_and_surnames_track_fold(self) -> None: n = self.hn("Mohamad Ahmad Ali Hassan") self.m(n.given_names, n.first, n) self.m(n.surnames, n.last, n) def test_last_prefixes_still_split_after_fold(self) -> None: # Unfolded this is first="Miguel", middle="da Silva do Amaral", # last="de Souza" (last_prefixes="de"). Folded, last_list becomes # ["da","Silva","do","Amaral","de","Souza"]; _split_last() strips # leading contiguous prefix words from the start, so only the # leading "da" is stripped ("Silva" is not a prefix, so scanning # stops there) — last_prefixes="da", not "de". n = self.hn("Miguel da Silva do Amaral de Souza") self.m(n.last_prefixes, "da", n) class MiddleNameAsLastFlagOffTests(HumanNameTestBase): def test_default_constants_unaffected(self) -> None: n = HumanName("Mohamad Ahmad Ali Hassan") self.m(n.middle, "Ahmad Ali", n) self.m(n.last, "Hassan", n) class MiddleNameAsLastWithPatronymicOrderTests(FlaggedConstantsTestBase): """Both localization flags on: patronymic reordering must settle first/middle/last before the fold collapses middle into last, per the design's stated ordering rationale (post_process() runs the patronymic hook before the middle_name_as_last hook).""" constants_kwargs = {"middle_name_as_last": True, "patronymic_name_order": True} def test_rotate_then_fold_no_comma(self) -> None: # patronymic_name_order rotates "Ivanov Petr Sergeyevich" to # first=Petr, middle=Sergeyevich, last=Ivanov; the fold then # collapses that settled middle into last. n = self.hn("Ivanov Petr Sergeyevich") self.m(n.first, "Petr", n) self.m(n.middle, "", n) self.m(n.last, "Sergeyevich Ivanov", n) def test_fold_applies_even_when_comma_suppresses_rotation(self) -> None: # A comma suppresses patronymic_name_order's rotation (_had_comma # guard), so middle stays "Sergeyevich" unrotated going into the # fold. The fold still absorbs it into last, producing the same # first/last as the no-comma case above via a different mechanism. n = self.hn("Ivanov, Petr Sergeyevich") self.m(n.first, "Petr", n) self.m(n.middle, "", n) self.m(n.last, "Sergeyevich Ivanov", n) derek73-python-nameparser-456b024/tests/test_nicknames.py000066400000000000000000000332221527303271700234340ustar00rootroot00000000000000import re import pytest from nameparser import HumanName from nameparser.config import Constants from tests.base import HumanNameTestBase class NicknameTestCase(HumanNameTestBase): # https://code.google.com/p/python-nameparser/issues/detail?id=33 def test_nickname_in_parenthesis(self) -> None: hn = HumanName("Benjamin (Ben) Franklin") self.m(hn.first, "Benjamin", hn) self.m(hn.middle, "", hn) self.m(hn.last, "Franklin", hn) self.m(hn.nickname, "Ben", hn) # https://github.com/derek73/python-nameparser/issues/112 def test_add_custom_nickname_delimiter_raises(self) -> None: # Custom (non-sentinel) delimiter additions raise in 2.0 (deliberate # divergence, migration spec section 3 -- same uniform rule as # regexes); Policy(nickname_delimiters=...) is the replacement. The # v1 tests for removing custom delimiters, registering several at # once, and the ambiguous-acronym carve-out through a custom # delimiter died with the mechanism. hn = HumanName("Benjamin {Ben} Franklin", constants=Constants()) # curly braces aren't a recognized delimiter by default self.m(hn.nickname, "", hn) with pytest.raises(TypeError, match="Policy"): hn.C.nickname_delimiters['curly_braces'] = re.compile(r'\{(.*?)\}') def test_overriding_builtin_regex_raises(self) -> None: # v1's other customization path -- replacing a regexes entry so the # built-in delimiter sentinels resolve to it live -- is the same # uniform 2.0 removal: any regexes assignment raises, pointing at # Policy. hn = HumanName("Benjamin [Ben] Franklin", constants=Constants()) self.m(hn.nickname, "", hn) with pytest.raises(TypeError, match="Policy"): hn.C.regexes['parenthesis'] = re.compile(r'\[(.*?)\]') def test_two_word_nickname_in_parenthesis(self) -> None: hn = HumanName("Benjamin (Big Ben) Franklin") self.m(hn.first, "Benjamin", hn) self.m(hn.middle, "", hn) self.m(hn.last, "Franklin", hn) self.m(hn.nickname, "Big Ben", hn) def test_two_words_in_quotes(self) -> None: hn = HumanName('Benjamin "Big Ben" Franklin') self.m(hn.first, "Benjamin", hn) self.m(hn.middle, "", hn) self.m(hn.last, "Franklin", hn) self.m(hn.nickname, "Big Ben", hn) def test_nickname_in_parenthesis_with_comma(self) -> None: hn = HumanName("Franklin, Benjamin (Ben)") self.m(hn.first, "Benjamin", hn) self.m(hn.middle, "", hn) self.m(hn.last, "Franklin", hn) self.m(hn.nickname, "Ben", hn) def test_nickname_in_parenthesis_with_comma_and_suffix(self) -> None: hn = HumanName("Franklin, Benjamin (Ben), Jr.") self.m(hn.first, "Benjamin", hn) self.m(hn.middle, "", hn) self.m(hn.last, "Franklin", hn) self.m(hn.suffix, "Jr.", hn) self.m(hn.nickname, "Ben", hn) def test_nickname_in_single_quotes(self) -> None: hn = HumanName("Benjamin 'Ben' Franklin") self.m(hn.first, "Benjamin", hn) self.m(hn.middle, "", hn) self.m(hn.last, "Franklin", hn) self.m(hn.nickname, "Ben", hn) def test_nickname_in_double_quotes(self) -> None: hn = HumanName("Benjamin \"Ben\" Franklin") self.m(hn.first, "Benjamin", hn) self.m(hn.middle, "", hn) self.m(hn.last, "Franklin", hn) self.m(hn.nickname, "Ben", hn) def test_single_quotes_on_first_name_not_treated_as_nickname(self) -> None: hn = HumanName("Brian Andrew O'connor") self.m(hn.first, "Brian", hn) self.m(hn.middle, "Andrew", hn) self.m(hn.last, "O'connor", hn) self.m(hn.nickname, "", hn) def test_single_quotes_on_both_name_not_treated_as_nickname(self) -> None: hn = HumanName("La'tanya O'connor") self.m(hn.first, "La'tanya", hn) self.m(hn.middle, "", hn) self.m(hn.last, "O'connor", hn) self.m(hn.nickname, "", hn) def test_single_quotes_on_end_of_last_name_not_treated_as_nickname(self) -> None: hn = HumanName("Mari' Aube'") self.m(hn.first, "Mari'", hn) self.m(hn.middle, "", hn) self.m(hn.last, "Aube'", hn) self.m(hn.nickname, "", hn) def test_okina_inside_name_not_treated_as_nickname(self) -> None: hn = HumanName("Harrieta Keōpūolani Nāhiʻenaʻena") self.m(hn.first, "Harrieta", hn) self.m(hn.middle, "Keōpūolani", hn) self.m(hn.last, "Nāhiʻenaʻena", hn) self.m(hn.nickname, "", hn) def test_single_quotes_not_treated_as_nickname_Hawaiian_example(self) -> None: hn = HumanName("Harietta Keopuolani Nahi'ena'ena") self.m(hn.first, "Harietta", hn) self.m(hn.middle, "Keopuolani", hn) self.m(hn.last, "Nahi'ena'ena", hn) self.m(hn.nickname, "", hn) def test_single_quotes_not_treated_as_nickname_Kenyan_example(self) -> None: hn = HumanName("Naomi Wambui Ng'ang'a") self.m(hn.first, "Naomi", hn) self.m(hn.middle, "Wambui", hn) self.m(hn.last, "Ng'ang'a", hn) self.m(hn.nickname, "", hn) def test_single_quotes_not_treated_as_nickname_Samoan_example(self) -> None: hn = HumanName("Va'apu'u Vitale") self.m(hn.first, "Va'apu'u", hn) self.m(hn.middle, "", hn) self.m(hn.last, "Vitale", hn) self.m(hn.nickname, "", hn) # http://code.google.com/p/python-nameparser/issues/detail?id=17 def test_parenthesis_are_removed_from_name(self) -> None: hn = HumanName("John Jones (Unknown)") self.m(hn.first, "John", hn) self.m(hn.last, "Jones", hn) # not testing the nicknames because we don't actually care # about Google Docs here def test_duplicate_parenthesis_are_removed_from_name(self) -> None: hn = HumanName("John Jones (Google Docs), Jr. (Unknown)") self.m(hn.first, "John", hn) self.m(hn.last, "Jones", hn) self.m(hn.suffix, "Jr.", hn) def test_nickname_and_last_name(self) -> None: hn = HumanName('"Rick" Edmonds') self.m(hn.first, "", hn) self.m(hn.last, "Edmonds", hn) self.m(hn.nickname, "Rick", hn) def test_nickname_and_last_name_with_title(self) -> None: hn = HumanName('Senator "Rick" Edmonds') self.m(hn.title, "Senator", hn) self.m(hn.first, "", hn) self.m(hn.last, "Edmonds", hn) self.m(hn.nickname, "Rick", hn) def test_ambiguous_suffix_acronym_in_parenthesis_stays_nickname(self) -> None: # JD is in SUFFIX_ACRONYMS_AMBIGUOUS: both a law-degree acronym and a # common given-name nickname. Existing behavior (nickname) must be # preserved -- see issue #111. hn = HumanName("JEFFREY (JD) BRICKEN") self.m(hn.nickname, "JD", hn) self.m(hn.suffix, "", hn) class MaidenNameTestCase(HumanNameTestBase): def test_maiden_assignment_and_property(self) -> None: hn = HumanName("Jenny Baker") hn.maiden = "Johnson" self.m(hn.maiden, "Johnson", hn) def test_maiden_defaults_empty(self) -> None: hn = HumanName("Jenny Baker") self.m(hn.maiden, "", hn) def test_maiden_key_always_in_as_dict(self) -> None: # empty attributes are always '' in 2.0 (#255) hn = HumanName("Bob Dole") self.assertEqual(hn.as_dict()['maiden'], '') self.assertNotIn('maiden', hn.as_dict(False)) def test_maiden_appears_in_as_dict_when_populated(self) -> None: hn = HumanName("Jenny Baker") hn.maiden = "Johnson" self.assertEqual(hn.as_dict()['maiden'], "Johnson") self.assertEqual(hn.as_dict(False)['maiden'], "Johnson") def test_maiden_appears_in_slice(self) -> None: # list(hn), not the deprecated hn[:] slice (#258) hn = HumanName("Jenny Baker") hn.maiden = "Johnson" self.assertIn("Johnson", list(hn)) def test_maiden_via_constructor_kwarg(self) -> None: hn = HumanName(first="Jenny", last="Baker", maiden="Johnson") self.m(hn.first, "Jenny", hn) self.m(hn.last, "Baker", hn) self.m(hn.maiden, "Johnson", hn) def test_maiden_name_in_parenthesis_with_comma(self) -> None: C = Constants() C.maiden_delimiters['parenthesis'] = C.nickname_delimiters.pop('parenthesis') hn = HumanName("Baker (Johnson), Jenny", constants=C) self.m(hn.first, "Jenny", hn) self.m(hn.last, "Baker", hn) self.m(hn.maiden, "Johnson", hn) def test_maiden_name_in_parenthesis_no_comma(self) -> None: C = Constants() C.maiden_delimiters['parenthesis'] = C.nickname_delimiters.pop('parenthesis') hn = HumanName("Jenny Baker (Johnson)", constants=C) self.m(hn.first, "Jenny", hn) self.m(hn.last, "Baker", hn) self.m(hn.maiden, "Johnson", hn) def test_quotes_still_nickname_when_parens_routed_to_maiden(self) -> None: C = Constants() C.maiden_delimiters['parenthesis'] = C.nickname_delimiters.pop('parenthesis') hn = HumanName('Jenny "JJ" Baker (Johnson)', constants=C) self.m(hn.first, "Jenny", hn) self.m(hn.last, "Baker", hn) self.m(hn.nickname, "JJ", hn) self.m(hn.maiden, "Johnson", hn) def test_maiden_off_by_default_parenthesis_still_routes_to_nickname(self) -> None: hn = HumanName("Baker (Johnson), Jenny") self.m(hn.first, "Jenny", hn) self.m(hn.last, "Baker", hn) self.m(hn.nickname, "Johnson", hn) self.m(hn.maiden, "", hn) def test_suffix_shaped_content_in_maiden_bucket_stays_in_place(self) -> None: # The #189 suffix-shaped carve-out in handle_match() applies # regardless of which bucket the delimiter routes to. C = Constants() C.maiden_delimiters['parenthesis'] = C.nickname_delimiters.pop('parenthesis') hn = HumanName("Baker (Jr.), Jenny", constants=C) self.m(hn.first, "Jenny", hn) self.m(hn.last, "Baker", hn) self.m(hn.suffix, "Jr.", hn) self.m(hn.maiden, "", hn) def test_marker_inside_maiden_parenthesis_is_consumed(self) -> None: # #329 through the v1 API, which is where the release log # promises it. The facade runner covers the same ground by # translating the shared case table, but it decides for itself # which rows it can express -- when that gate was wrong these # rows silently skipped, so a test that spells the v1 idiom out # directly is what keeps the promise pinned independently of it. # Same value as the bare "Jane Smith née Jones", which is the # agreement #329 was about. C = Constants() C.maiden_delimiters['parenthesis'] = C.nickname_delimiters.pop('parenthesis') hn = HumanName("Jane Smith (née Jones)", constants=C) self.m(hn.first, "Jane", hn) self.m(hn.last, "Smith", hn) self.m(hn.maiden, "Jones", hn) def test_unmarked_maiden_parenthesis_keeps_every_word(self) -> None: # The other side of the same rule: the drop is conditioned on # the first word being a marker, so ordinary two-word content # arrives whole. C = Constants() C.maiden_delimiters['parenthesis'] = C.nickname_delimiters.pop('parenthesis') hn = HumanName("Jane Smith (Mary Jones)", constants=C) self.m(hn.first, "Jane", hn) self.m(hn.last, "Smith", hn) self.m(hn.maiden, "Mary Jones", hn) def test_lone_marker_word_in_its_own_parenthesis_is_a_name(self) -> None: # `Nee` is a real surname (Irish Ní/Nee, and a Chinese # romanization), so a one-word clause is content whatever it # spells -- even with a second clause following that could read # as the name it marks. C = Constants() C.maiden_delimiters['parenthesis'] = C.nickname_delimiters.pop('parenthesis') hn = HumanName("Jane Smith (Nee) (Jones)", constants=C) self.m(hn.first, "Jane", hn) self.m(hn.last, "Smith", hn) self.m(hn.maiden, "Nee Jones", hn) def test_maiden_appears_in_as_dict_via_routing(self) -> None: C = Constants() C.maiden_delimiters['parenthesis'] = C.nickname_delimiters.pop('parenthesis') hn = HumanName("Baker (Johnson), Jenny", constants=C) self.assertEqual(hn.as_dict()['maiden'], "Johnson") def test_unresolvable_string_sentinel_raises(self) -> None: # A string value that doesn't name a real delimiter used to silently # fall back to EMPTY_REGEX in v1 (matching at every position and # corrupting parsing) until 1.3 made the parse raise ValueError. 2.0 # enforces the invariant one step earlier: the delimiter managers # accept only the named sentinels, so the typo fails loudly at # assignment instead of at the next parse. C = Constants() with pytest.raises(TypeError, match="sentinel"): C.nickname_delimiters['typo'] = 'parenthesus' def test_routing_same_delimiter_to_both_buckets_nickname_wins(self) -> None: # Misuse case: assigning the same key into both dicts instead of # moving it with pop() (as the docs instruct). nickname_delimiters is # processed first in parse_nicknames()'s bucket loop, so it consumes # the match via re.sub() before maiden_delimiters ever sees it -- # maiden stays empty. Pinning this precedence so it doesn't silently # change if the bucket processing order is ever reordered. C = Constants() C.maiden_delimiters['parenthesis'] = C.regexes['parenthesis'] hn = HumanName("Baker (Johnson), Jenny", constants=C) self.m(hn.nickname, "Johnson", hn) self.m(hn.maiden, "", hn) derek73-python-nameparser-456b024/tests/test_output_format.py000066400000000000000000000207571527303271700244050ustar00rootroot00000000000000import pytest from nameparser import HumanName from nameparser.config import Constants from tests.base import HumanNameTestBase class HumanNameOutputFormatTests(HumanNameTestBase): def test_formatting_init_argument(self) -> None: hn = HumanName("Rev John A. Kenneth Doe III (Kenny)", string_format="TEST1") self.assertEqual(str(hn), "TEST1") # The four *_constants_attribute tests below ran on the shared CONSTANTS # singleton in v1; 2.0 deprecates shared mutation, so they use a private # Constants passed as constants= (the migration-guide idiom) -- the # config attribute under test is the same either way. def test_formatting_constants_attribute(self) -> None: c = Constants() c.string_format = "TEST2" hn = HumanName("Rev John A. Kenneth Doe III (Kenny)", constants=c) self.assertEqual(str(hn), "TEST2") def test_capitalize_name_constants_attribute(self) -> None: c = Constants() c.capitalize_name = True hn = HumanName("bob v. de la macdole-eisenhower phd", constants=c) self.assertEqual(str(hn), "Bob V. de la MacDole-Eisenhower PhD") def test_force_mixed_case_capitalization_constants_attribute(self) -> None: c = Constants() c.force_mixed_case_capitalization = True hn = HumanName('Shirley Maclaine', constants=c) hn.capitalize() self.assertEqual(str(hn), "Shirley MacLaine") def test_capitalize_name_and_force_mixed_case_capitalization_constants_attributes(self) -> None: c = Constants() c.capitalize_name = True c.force_mixed_case_capitalization = True hn = HumanName('Shirley Maclaine', constants=c) self.assertEqual(str(hn), "Shirley MacLaine") def test_quote_nickname_formating(self) -> None: hn = HumanName("Rev John A. Kenneth Doe III (Kenny)") hn.string_format = "{title} {first} {middle} {last} {suffix} '{nickname}'" self.assertEqual(str(hn), "Rev John A. Kenneth Doe III 'Kenny'") hn.string_format = "{last}, {title} {first} {middle}, {suffix} '{nickname}'" self.assertEqual(str(hn), "Doe, Rev John A. Kenneth, III 'Kenny'") def test_formating_removing_keys_from_format_string(self) -> None: hn = HumanName("Rev John A. Kenneth Doe III (Kenny)") hn.string_format = "{title} {first} {middle} {last} {suffix} '{nickname}'" self.assertEqual(str(hn), "Rev John A. Kenneth Doe III 'Kenny'") hn.string_format = "{last}, {title} {first} {middle}, {suffix}" self.assertEqual(str(hn), "Doe, Rev John A. Kenneth, III") hn.string_format = "{last}, {title} {first} {middle}" self.assertEqual(str(hn), "Doe, Rev John A. Kenneth") hn.string_format = "{last}, {first} {middle}" self.assertEqual(str(hn), "Doe, John A. Kenneth") hn.string_format = "{last}, {first}" self.assertEqual(str(hn), "Doe, John") hn.string_format = "{first} {last}" self.assertEqual(str(hn), "John Doe") def test_formating_removing_pieces_from_name_buckets(self) -> None: hn = HumanName("Rev John A. Kenneth Doe III (Kenny)") hn.string_format = "{title} {first} {middle} {last} {suffix} '{nickname}'" self.assertEqual(str(hn), "Rev John A. Kenneth Doe III 'Kenny'") hn.string_format = "{title} {first} {middle} {last} {suffix}" self.assertEqual(str(hn), "Rev John A. Kenneth Doe III") hn.middle = '' self.assertEqual(str(hn), "Rev John Doe III") hn.suffix = '' self.assertEqual(str(hn), "Rev John Doe") hn.title = '' self.assertEqual(str(hn), "John Doe") def test_formating_of_nicknames_with_parenthesis(self) -> None: hn = HumanName("Rev John A. Kenneth Doe III (Kenny)") hn.string_format = "{title} {first} {middle} {last} {suffix} ({nickname})" self.assertEqual(str(hn), "Rev John A. Kenneth Doe III (Kenny)") hn.nickname = '' self.assertEqual(str(hn), "Rev John A. Kenneth Doe III") def test_formating_of_nicknames_with_single_quotes(self) -> None: hn = HumanName("Rev John A. Kenneth Doe III (Kenny)") hn.string_format = "{title} {first} {middle} {last} {suffix} '{nickname}'" self.assertEqual(str(hn), "Rev John A. Kenneth Doe III 'Kenny'") hn.nickname = '' self.assertEqual(str(hn), "Rev John A. Kenneth Doe III") def test_formating_of_nicknames_with_double_quotes(self) -> None: hn = HumanName("Rev John A. Kenneth Doe III (Kenny)") hn.string_format = "{title} {first} {middle} {last} {suffix} \"{nickname}\"" self.assertEqual(str(hn), "Rev John A. Kenneth Doe III \"Kenny\"") hn.nickname = '' self.assertEqual(str(hn), "Rev John A. Kenneth Doe III") def test_formating_of_nicknames_in_middle(self) -> None: hn = HumanName("Rev John A. Kenneth Doe III (Kenny)") hn.string_format = "{title} {first} ({nickname}) {middle} {last} {suffix}" self.assertEqual(str(hn), "Rev John (Kenny) A. Kenneth Doe III") hn.nickname = '' self.assertEqual(str(hn), "Rev John A. Kenneth Doe III") def test_name_containing_none_substring_survives_formatting(self) -> None: # Residue of the #254 regression: v1's None-mode __str__ once # scrubbed the literal string 'None' from formatted output. The # None mode is gone in 2.0 (#255), but real name text spelled # 'None'/'Nonez' must still never be scrubbed by formatting. self.assertEqual(str(HumanName("Nonez Smith")), "Nonez Smith") self.assertEqual(str(HumanName("None Smith")), "None Smith") def test_empty_field_drops_surrounding_whitespace(self) -> None: # issue #139: adjacent whitespace/punctuation should be dropped when a field is empty hn = HumanName("John Smith") hn.string_format = "{last} {suffix}, {first}" self.assertEqual(str(hn), "Smith, John") def test_empty_field_present_suffix_unaffected(self) -> None: hn = HumanName("John Smith Jr") hn.string_format = "{last} {suffix}, {first}" self.assertEqual(str(hn), "Smith Jr, John") def test_multiple_empty_fields_before_comma(self) -> None: hn = HumanName("John Smith") hn.string_format = "{title} {suffix}, {first} {last}" self.assertEqual(str(hn), "John Smith") def test_remove_emojis(self) -> None: hn = HumanName("Sam Smith 😊") self.m(hn.first, "Sam", hn) self.m(hn.last, "Smith", hn) self.assertEqual(str(hn), "Sam Smith") def test_keep_non_emojis(self) -> None: hn = HumanName("∫≜⩕ Smith 😊") self.m(hn.first, "∫≜⩕", hn) self.m(hn.last, "Smith", hn) self.assertEqual(str(hn), "∫≜⩕ Smith") def test_keep_emojis_opt_out_moved_to_policy(self) -> None: # v1's regexes.emoji = False opt-out is not supported in 2.0 # (deliberate divergence, migration spec section 3 uniform rule): # the assignment raises, and the error names the replacement, # Policy(strip_emoji=False). constants = Constants() with pytest.raises(TypeError, match="strip_emoji"): constants.regexes.emoji = False # type: ignore[assignment] def test_remove_bidi_control_chars(self) -> None: # LRM/RLM and friends ride along with copy-pasted names and stick to # the parts they surround. Covers every character in the bidi set. for mark in ("\u200e", "\u200f", "\u061c", "\u202a", "\u202b", "\u202c", "\u202d", "\u202e", "\u2066", "\u2067", "\u2068", "\u2069"): hn = HumanName(mark + "John" + mark + " Smith") self.m(hn.first, "John", hn) self.m(hn.last, "Smith", hn) def test_bidi_stripped_name_compares_equal(self) -> None: # The reported symptom: an invisible RLM around an RTL name makes the # parsed part fail equality against the clean string (issue #266). hn = HumanName("\u200fمحمد بن سلمان\u200f") self.assertEqual(hn.first, "محمد") def test_keep_bidi_opt_out_moved_to_policy(self) -> None: # v1's regexes.bidi = False opt-out is not supported in 2.0 # (deliberate divergence, migration spec section 3 uniform rule): # the assignment raises, and the error names the replacement, # Policy(strip_bidi=False). constants = Constants() with pytest.raises(TypeError, match="strip_bidi"): constants.regexes.bidi = False # type: ignore[assignment] derek73-python-nameparser-456b024/tests/test_particles.py000066400000000000000000000407541527303271700234620ustar00rootroot00000000000000import pytest from nameparser import HumanName from nameparser.config import CONSTANTS, Constants from nameparser.config.particles import NON_GIVEN_NAME_PARTICLES from tests.base import HumanNameTestBase class ParticlesTestCase(HumanNameTestBase): def test_prefix(self) -> None: hn = HumanName("Juan del Sur") self.m(hn.first, "Juan", hn) self.m(hn.last, "del Sur", hn) def test_prefix_with_period(self) -> None: hn = HumanName("Jill St. John") self.m(hn.first, "Jill", hn) self.m(hn.last, "St. John", hn) def test_prefix_before_two_part_last_name(self) -> None: hn = HumanName("pennie von bergen wessels") self.m(hn.first, "pennie", hn) self.m(hn.last, "von bergen wessels", hn) def test_prefix_is_first_name(self) -> None: hn = HumanName("Van Johnson") self.m(hn.first, "Van", hn) self.m(hn.last, "Johnson", hn) def test_prefix_is_first_name_with_middle_name(self) -> None: hn = HumanName("Van Jeremy Johnson") self.m(hn.first, "Van", hn) self.m(hn.middle, "Jeremy", hn) self.m(hn.last, "Johnson", hn) def test_prefix_before_two_part_last_name_with_suffix(self) -> None: hn = HumanName("pennie von bergen wessels III") self.m(hn.first, "pennie", hn) self.m(hn.last, "von bergen wessels", hn) self.m(hn.suffix, "III", hn) def test_prefix_before_two_part_last_name_with_acronym_suffix(self) -> None: hn = HumanName("pennie von bergen wessels M.D.") self.m(hn.first, "pennie", hn) self.m(hn.last, "von bergen wessels", hn) self.m(hn.suffix, "M.D.", hn) def test_title_before_and_after_prefixed_last_name(self) -> None: # Issue #100: a repeated title/suffix token before AND after a # prefixed last name used to corrupt the middle name into # " dr Vincent van" because the suffix-boundary lookup matched the # LEADING token instead of the trailing one. The token was "dr" # until #296's audit took 'dr' out of the suffix vocabulary (it # is not a postnominal); "lt" is both a title and a suffix, which # is the only property these three tests need. hn = HumanName("lt Vincent van Gogh lt") self.m(hn.title, "lt", hn) self.m(hn.first, "Vincent", hn) self.m(hn.middle, "", hn) self.m(hn.last, "van Gogh", hn) self.m(hn.suffix, "lt", hn) def test_suffix_token_collision_with_two_word_prefix(self) -> None: # Same fix as #100 but with a two-word prefix ("van der"). Exercises a # different iteration count through the prefix-joining loop. hn = HumanName("lt Vincent van der Gogh lt") self.m(hn.title, "lt", hn) self.m(hn.first, "Vincent", hn) self.m(hn.middle, "", hn) self.m(hn.last, "van der Gogh", hn) self.m(hn.suffix, "lt", hn) def test_title_before_and_after_prefixed_last_name_with_middle(self) -> None: # The pre-fix bug corrupted the middle field; verify it is not disturbed # when a genuine middle name is present alongside the repeated token. hn = HumanName("lt Vincent James van Gogh lt") self.m(hn.title, "lt", hn) self.m(hn.first, "Vincent", hn) self.m(hn.middle, "James", hn) self.m(hn.last, "van Gogh", hn) self.m(hn.suffix, "lt", hn) @pytest.mark.timeout(2) def test_many_repeated_prefixes_does_not_blow_up(self) -> None: # Issue #108: a name with a long run of repeated prefixes used to grow # the pieces list exponentially and exhaust memory. The 2-second timeout # enforces this locally and in CI — if the test hangs, an exponential # regression has been reintroduced. name = "Jan " + "van der " * 30 + "Berg" hn = HumanName(name) self.m(hn.first, "Jan", hn) self.assertIn("Berg", hn.last) def test_two_part_last_name_with_suffix_comma(self) -> None: hn = HumanName("pennie von bergen wessels, III") self.m(hn.first, "pennie", hn) self.m(hn.last, "von bergen wessels", hn) self.m(hn.suffix, "III", hn) def test_two_part_last_name_with_suffix(self) -> None: hn = HumanName("von bergen wessels, pennie III") self.m(hn.first, "pennie", hn) self.m(hn.last, "von bergen wessels", hn) self.m(hn.suffix, "III", hn) def test_last_name_two_part_last_name_with_two_suffixes(self) -> None: hn = HumanName("von bergen wessels MD, pennie III") self.m(hn.first, "pennie", hn) self.m(hn.last, "von bergen wessels", hn) self.m(hn.suffix, "MD, III", hn) def test_comma_two_part_last_name_with_acronym_suffix(self) -> None: hn = HumanName("von bergen wessels, pennie MD") self.m(hn.first, "pennie", hn) self.m(hn.last, "von bergen wessels", hn) self.m(hn.suffix, "MD", hn) def test_comma_two_part_last_name_with_suffix_in_first_part(self) -> None: # I'm kinda surprised this works, not really sure if this is a # realistic place for a suffix to be. hn = HumanName("von bergen wessels MD, pennie") self.m(hn.first, "pennie", hn) self.m(hn.last, "von bergen wessels", hn) self.m(hn.suffix, "MD", hn) def test_title_two_part_last_name_with_suffix_in_first_part(self) -> None: hn = HumanName("pennie von bergen wessels MD, III") self.m(hn.first, "pennie", hn) self.m(hn.last, "von bergen wessels", hn) self.m(hn.suffix, "MD, III", hn) def test_portuguese_dos(self) -> None: hn = HumanName("Rafael Sousa dos Anjos") self.m(hn.first, "Rafael", hn) self.m(hn.middle, "Sousa", hn) self.m(hn.last, "dos Anjos", hn) def test_portuguese_prefixes(self) -> None: hn = HumanName("Joao da Silva do Amaral de Souza") self.m(hn.first, "Joao", hn) self.m(hn.middle, "da Silva do Amaral", hn) self.m(hn.last, "de Souza", hn) def test_three_conjunctions(self) -> None: hn = HumanName("Dr. Juan Q. Xavier de la dos Vega III") self.m(hn.first, "Juan", hn) self.m(hn.last, "de la dos Vega", hn) self.m(hn.title, "Dr.", hn) self.m(hn.middle, "Q. Xavier", hn) self.m(hn.suffix, "III", hn) def test_lastname_three_conjunctions(self) -> None: hn = HumanName("de la dos Vega, Dr. Juan Q. Xavier III") self.m(hn.first, "Juan", hn) self.m(hn.last, "de la dos Vega", hn) self.m(hn.title, "Dr.", hn) self.m(hn.middle, "Q. Xavier", hn) self.m(hn.suffix, "III", hn) def test_comma_three_conjunctions(self) -> None: hn = HumanName("Dr. Juan Q. Xavier de la dos Vega, III") self.m(hn.first, "Juan", hn) self.m(hn.last, "de la dos Vega", hn) self.m(hn.title, "Dr.", hn) self.m(hn.middle, "Q. Xavier", hn) self.m(hn.suffix, "III", hn) # The subset-of-PARTICLES and disjoint-from-BOUND_GIVEN_NAMES invariants # hold by construction or by the vocabulary build's checks # (tools/vocab/build.py, tests/v2/test_vocab.py), so they are not # repeated as tests here. def test_non_given_name_particles_expected_members(self) -> None: # 'abu' is in PARTICLES but excluded (it is a bound_first_name); # 'von'/'van'/'della'/'di'/'del' are excluded (they can be first names). self.assertIn('de', NON_GIVEN_NAME_PARTICLES) self.assertIn('dos', NON_GIVEN_NAME_PARTICLES) self.assertNotIn('abu', NON_GIVEN_NAME_PARTICLES) self.assertNotIn('von', NON_GIVEN_NAME_PARTICLES) self.assertNotIn('van', NON_GIVEN_NAME_PARTICLES) self.assertNotIn('della', NON_GIVEN_NAME_PARTICLES) def test_constants_exposes_non_first_name_prefixes(self) -> None: self.assertEqual( set(CONSTANTS.non_first_name_prefixes), NON_GIVEN_NAME_PARTICLES) def test_non_first_name_prefixes_disjoint_from_titles(self) -> None: # A member that is also a title is consumed as a title before the fold # would run, making it inert (the st/ste footgun). Pin the invariant on # the live default sets. self.assertEqual( set(CONSTANTS.non_first_name_prefixes) & set(CONSTANTS.titles), set(), ) def test_non_first_name_prefixes_constructor_arg(self) -> None: c = Constants(non_first_name_prefixes={'zzz'}) self.assertEqual(set(c.non_first_name_prefixes), {'zzz'}) class LastNamePrefixSplitTestCase(HumanNameTestBase): def test_van_gogh_last_base(self) -> None: hn = HumanName("Vincent van Gogh") self.m(hn.last_base, "Gogh", hn) def test_van_gogh_last_prefixes(self) -> None: hn = HumanName("Vincent van Gogh") self.m(hn.last_prefixes, "van", hn) def test_van_gogh_last_base_list(self) -> None: hn = HumanName("Vincent van Gogh") self.m(hn.last_base_list, ["Gogh"], hn) def test_van_gogh_last_prefixes_list(self) -> None: hn = HumanName("Vincent van Gogh") self.m(hn.last_prefixes_list, ["van"], hn) def test_von_bergen_wessels(self) -> None: hn = HumanName("pennie von bergen wessels") self.m(hn.last_base, "bergen wessels", hn) self.m(hn.last_prefixes, "von", hn) self.m(hn.last_base_list, ["bergen", "wessels"], hn) self.m(hn.last_prefixes_list, ["von"], hn) def test_de_la_vega_multiword_prefix(self) -> None: hn = HumanName("Juan de la Vega") self.m(hn.last_base, "Vega", hn) self.m(hn.last_prefixes, "de la", hn) self.m(hn.last_prefixes_list, ["de", "la"], hn) def test_no_prefix(self) -> None: hn = HumanName("John Smith") self.m(hn.last_base, "Smith", hn) self.m(hn.last_prefixes, "", hn) # self.m() coerces [] via `expected or empty_attribute_default`; use assertEqual for empty lists self.assertEqual(hn.last_prefixes_list, []) def test_do_guard_surname_equals_prefix_word(self) -> None: # "Do" is in PARTICLES; without the guard last_base would be empty hn = HumanName("Anh Do") self.m(hn.last_base, "Do", hn) self.m(hn.last_prefixes, "", hn) def test_all_particles_guard(self) -> None: # Artificial case: last name whose every word is a prefix — must not strip hn = HumanName("Smith van der") # last="van der"; both words are prefixes — guard fires, base = full last self.m(hn.last_base, hn.last, hn) self.m(hn.last_prefixes, "", hn) def test_da_silva_title_plus_prefix(self) -> None: hn = HumanName("Dra. Andréia da Silva") self.m(hn.last_base, "Silva", hn) self.m(hn.last_prefixes, "da", hn) def test_empty_name(self) -> None: hn = HumanName() self.m(hn.last_base, "", hn) self.m(hn.last_prefixes, "", hn) # self.m() coerces [] via `expected or empty_attribute_default`; use assertEqual for empty lists self.assertEqual(hn.last_base_list, []) self.assertEqual(hn.last_prefixes_list, []) def test_case_insensitive_prefix_detection(self) -> None: hn = HumanName("VINCENT VAN GOGH") self.m(hn.last_prefixes, "VAN", hn) self.m(hn.last_base, "GOGH", hn) # --- targets: leading non-first-name prefix becomes the surname --- def test_leading_non_first_name_prefix_de(self) -> None: hn = HumanName("de Mesnil") self.m(hn.first, "", hn) self.m(hn.middle, "", hn) self.m(hn.last, "de Mesnil", hn) def test_leading_non_first_name_prefix_dos(self) -> None: hn = HumanName("dos Santos") self.m(hn.first, "", hn) self.m(hn.last, "dos Santos", hn) def test_leading_non_first_name_prefix_chain(self) -> None: hn = HumanName("de la Vega") self.m(hn.first, "", hn) self.m(hn.last, "de la Vega", hn) def test_leading_non_first_name_prefix_derived_props(self) -> None: hn = HumanName("de Mesnil") self.m(hn.last_prefixes, "de", hn) self.m(hn.last_base, "Mesnil", hn) def test_non_first_name_prefix_with_custom_title(self) -> None: # 'Gunny' is NOT a default title -> genuinely exercises the custom-title # path. Title is consumed first (first_list == ['']), so the fold does # not fire and the surname is already correct; asserts we don't corrupt # the title case. Runs on a private Constants (2.0 deprecates shared- # CONSTANTS mutation; the custom-title path under test is identical). c = Constants() c.titles.add('gunny') hn = HumanName("Gunny de Mesnil", constants=c) self.m(hn.title, "Gunny", hn) self.m(hn.first, "", hn) self.m(hn.last, "de Mesnil", hn) def test_repeated_prefix_chain_de_la(self) -> None: hn = HumanName("Juan de la de la Vega") self.m(hn.first, "Juan", hn) self.m(hn.last, "de la de la Vega", hn) def test_repeated_prefix_chain_van_der(self) -> None: hn = HumanName("Charles van der van der Berg") self.m(hn.first, "Charles", hn) self.m(hn.last, "van der van der Berg", hn) def test_triple_repeated_prefix_chain(self) -> None: # a stronger regression guard than the 2-repeat cases above: the # contiguous-prefix absorption loop should chain any number of # repeats, not just handle exactly two hn = HumanName("Juan de la de la de la Vega") self.m(hn.first, "Juan", hn) self.m(hn.last, "de la de la de la Vega", hn) def test_repeated_prefix_chain_followed_by_suffix(self) -> None: # the prefix-run absorption loop and the suffix-boundary loop share # the same index variable, so a repeated chain immediately # followed by a suffix is worth pinning down explicitly hn = HumanName("Juan de la de la Vega Jr.") self.m(hn.first, "Juan", hn) self.m(hn.last, "de la de la Vega", hn) self.m(hn.suffix, "Jr.", hn) # --- safety: excluded / ambiguous particles are unchanged --- def test_leading_von_is_unchanged(self) -> None: hn = HumanName("von Braun") self.m(hn.first, "von", hn) self.m(hn.last, "Braun", hn) def test_leading_van_is_unchanged(self) -> None: hn = HumanName("Van Johnson") self.m(hn.first, "Van", hn) self.m(hn.last, "Johnson", hn) def test_leading_della_is_unchanged(self) -> None: hn = HumanName("Della Reese") self.m(hn.first, "Della", hn) self.m(hn.last, "Reese", hn) def test_leading_di_is_unchanged(self) -> None: hn = HumanName("Di Caprio") self.m(hn.first, "Di", hn) self.m(hn.last, "Caprio", hn) def test_leading_del_is_unchanged(self) -> None: hn = HumanName("Del Toro") self.m(hn.first, "Del", hn) self.m(hn.last, "Toro", hn) def test_non_leading_prefix_is_unchanged(self) -> None: hn = HumanName("Jean de Mesnil") self.m(hn.first, "Jean", hn) self.m(hn.last, "de Mesnil", hn) # --- guard: bare particle with nothing to attach to --- def test_bare_non_first_name_prefix_guard(self) -> None: hn = HumanName("de") self.m(hn.first, "de", hn) self.m(hn.last, "", hn) # --- interactions with opt-in handlers that also run in post_process --- def test_leading_non_first_name_prefix_case_insensitive(self) -> None: hn = HumanName("DE MESNIL") self.m(hn.first, "", hn) self.m(hn.last, "DE MESNIL", hn) def test_leading_non_first_name_prefix_with_suffix(self) -> None: hn = HumanName("de Mesnil Jr.") self.m(hn.first, "", hn) self.m(hn.last, "de Mesnil", hn) self.m(hn.suffix, "Jr.", hn) def test_leading_non_first_name_prefix_with_patronymic_name_order(self) -> None: # The fold requires a single-token first_list; patronymic_name_order # only matters once first_list has more than one piece, so the fold # and this opt-in handler don't fight over the same input shape here. constants = Constants(patronymic_name_order=True) hn = HumanName("de Mesnil", constants=constants) self.m(hn.first, "", hn) self.m(hn.last, "de Mesnil", hn) def test_leading_non_first_name_prefix_with_middle_name_as_last(self) -> None: # handle_non_first_name_prefix runs first and empties middle_list, so # the later opt-in handle_middle_name_as_last has nothing left to do. constants = Constants(middle_name_as_last=True) hn = HumanName("de Mesnil Garcia", constants=constants) self.m(hn.first, "", hn) self.m(hn.last, "de Mesnil Garcia", hn) derek73-python-nameparser-456b024/tests/test_python_api.py000066400000000000000000000627001527303271700236410ustar00rootroot00000000000000import copy import pickle import re import warnings import pytest try: import dill except ImportError: dill = False # type: ignore[assignment] from nameparser import HumanName from nameparser.config import CONSTANTS, Constants, TupleManager from tests.base import HumanNameTestBase from tests.removals import ignore_removals class HumanNamePythonTests(HumanNameTestBase): def test_utf8(self) -> None: hn = HumanName("de la Véña, Jüan") self.m(hn.first, "Jüan", hn) self.m(hn.last, "de la Véña", hn) def test_string_output(self) -> None: hn = HumanName("de la Véña, Jüan") self.m(str(hn), "Jüan de la Véña", hn) def test_escaped_utf8_bytes(self) -> None: # bytes input was removed in 2.0 (#245, warned since 1.3.0): # decode first with pytest.raises(TypeError, match="decode"): HumanName(b'B\xc3\xb6ck, Gerald') # type: ignore[arg-type] hn = HumanName(b'B\xc3\xb6ck, Gerald'.decode('utf-8')) self.m(hn.first, "Gerald", hn) self.m(hn.last, "Böck", hn) def test_bytes_full_name_raises_with_decode_hint(self) -> None: # bytes input was removed in 2.0 (#245); both entry points raise with pytest.raises(TypeError, match="decode"): HumanName(b'John Smith') # type: ignore[arg-type] hn = HumanName("Jane Doe") with pytest.raises(TypeError, match="decode"): hn.full_name = b'John Smith' # type: ignore[assignment] # the failed assignment must not have corrupted the parse self.m(hn.first, "Jane", hn) def test_str_full_name_does_not_warn(self) -> None: with warnings.catch_warnings(): warnings.simplefilter("error") ignore_removals() hn = HumanName("John Smith") hn.full_name = "Jane Doe" self.m(hn.first, "Jane", hn) def test_len(self) -> None: hn = HumanName("Doe-Ray, Dr. John P., CLU, CFP, LUTC") self.m(len(hn), 5, hn) hn = HumanName("John Doe") self.m(len(hn), 2, hn) # empty input parses to an all-empty name; len == 0 is the # documented emptiness check (see usage.rst) self.assertEqual(len(HumanName("")), 0) def test_iteration_restarts_after_break(self) -> None: hn = HumanName("John Doe") for _ in hn: break # a plain loop, not list(hn): list() presizes via __len__, which # under the old shared-cursor implementation reset the cursor and # masked this bug collected = [] for part in hn: collected.append(part) self.assertEqual(collected, ["John", "Doe"]) def test_iterators_are_independent(self) -> None: hn = HumanName("John Doe") it1 = iter(hn) it2 = iter(hn) self.assertEqual(next(it1), "John") self.assertEqual(next(it2), "John") self.assertEqual(next(it1), "Doe") self.assertEqual(next(it2), "Doe") def test_len_during_iteration(self) -> None: hn = HumanName("John Doe") it = iter(hn) self.assertEqual(next(it), "John") # len() must count all members and leave the live iterator intact self.assertEqual(len(hn), 2) self.assertEqual(next(it), "Doe") def test_instance_is_not_its_own_iterator(self) -> None: # iterator state must never live on the instance; see release log # for the next(name) -> next(iter(name)) migration hn = HumanName("John Doe") with pytest.raises(TypeError): next(hn) # type: ignore[call-overload] def test_iterating_empty_name_yields_nothing(self) -> None: collected = [] for part in HumanName(""): collected.append(part) self.assertEqual(collected, []) @pytest.mark.skipif(not dill, reason="requires python-dill module to test pickling") def test_config_pickle(self) -> None: constants = Constants() self.assertTrue(dill.pickles(constants)) @pytest.mark.skipif(not dill, reason="requires python-dill module to test pickling") def test_name_instance_pickle(self) -> None: hn = HumanName("Title First Middle Middle Last, Jr.") self.assertTrue(dill.pickles(hn)) def test_name_instance_pickle_preserves_instance_config(self) -> None: """A HumanName carrying its own config must parse identically after a pickle round-trip. HumanName pickles its instance Constants (``.C``) through the default __dict__ path, so a broken Constants round-trip silently produced a differently-configured parser on the other side. """ # Passing None as the second argument gives this name its own Constants. hn = HumanName("Smith, Dr. John", Constants()) hn.C.titles.add('bursar') hn.parse_full_name() # Safe: round-tripping a HumanName the test just built, not untrusted data. restored = pickle.loads(pickle.dumps(hn)) self.assertIn('bursar', restored.C.titles) restored.full_name = "Bursar Jane Smith" self.assertEqual(restored.title, "Bursar") def test_name_instance_deepcopy(self) -> None: """copy.deepcopy of a HumanName must round-trip. HumanName has no custom copy hooks, so deepcopy recurses into its Constants (`.C`), and previously into `.C.regexes`, whose __getattr__ answered copy's __deepcopy__ probe with a re.Pattern — making deepcopy of *any* HumanName raise TypeError. """ hn = HumanName("Dr. John P. Doe-Ray, CLU") dup = copy.deepcopy(hn) self.assertEqual(str(dup), str(hn)) def test_name_instance_deepcopy_isolates_instance_config(self) -> None: """A deep-copied HumanName with its own config must be independent.""" hn = HumanName("Smith, Dr. John", Constants()) hn.C.titles.add('bursar') dup = copy.deepcopy(hn) dup.C.titles.add('marker') self.assertIn('bursar', dup.C.titles) self.assertNotIn('marker', hn.C.titles) def test_pickle_default_name_preserves_singleton_identity(self) -> None: """A default HumanName must re-attach to CONSTANTS after a pickle round-trip. Without __getstate__/__setstate__, pickle serializes .C by value, so the restored name gets a detached copy — has_own_config flips to True and every pickled default name carries a full Constants copy. """ hn = HumanName("John Doe") self.assertFalse(hn.has_own_config) self.assertIs(hn.C, CONSTANTS) # Safe: round-tripping an object we just built, not untrusted data. restored = pickle.loads(pickle.dumps(hn)) self.assertIs(restored.C, CONSTANTS) self.assertFalse(restored.has_own_config) self.assertEqual(str(restored), str(hn)) self.assertEqual(restored.first, hn.first) self.assertEqual(restored.last, hn.last) def test_pickle_instance_config_name_preserves_own_config(self) -> None: """A HumanName with its own Constants must not be collapsed onto CONSTANTS after pickle.""" hn = HumanName("Smith, Dr. John", Constants()) hn.C.titles.add('bursar') hn.parse_full_name() self.assertTrue(hn.has_own_config) self.assertIsNot(hn.C, CONSTANTS) # Safe: round-tripping a HumanName the test just built, not untrusted data. restored = pickle.loads(pickle.dumps(hn)) self.assertTrue(restored.has_own_config) self.assertIsNot(restored.C, CONSTANTS) self.assertIn('bursar', restored.C.titles) def test_shallow_copy_default_name_preserves_singleton_identity(self) -> None: """copy.copy of a default HumanName shares the CONSTANTS reference without hooks.""" hn = HumanName("John Doe") sc = copy.copy(hn) self.assertIs(sc.C, CONSTANTS) self.assertFalse(sc.has_own_config) def test_deepcopy_default_name_preserves_singleton_identity(self) -> None: """copy.deepcopy of a default HumanName must re-attach to CONSTANTS.""" hn = HumanName("John Doe") dup = copy.deepcopy(hn) self.assertIs(dup.C, CONSTANTS) self.assertFalse(dup.has_own_config) self.assertEqual(str(dup), str(hn)) self.assertEqual(dup.first, hn.first) self.assertEqual(dup.last, hn.last) def test_comparison(self) -> None: # == and hash reverted to object identity in 2.0 (#223, warned since # 1.3.0): distinct instances never compare equal, however they parse, # and strings never match. matches()/comparison_key() are the # replacements (covered below). hn1 = HumanName("Doe-Ray, Dr. John P., CLU, CFP, LUTC") hn2 = HumanName("Dr. John P. Doe-Ray, CLU, CFP, LUTC") self.assertTrue(hn1 == hn1) self.assertFalse(hn1 == hn2) self.assertIsNot(hn1, hn2) self.assertFalse(hn1 == "Dr. John P. Doe-Ray CLU, CFP, LUTC") self.assertTrue(hn1.matches(hn2)) # the 2.0 spelling of the old == hn1 = HumanName("Doe, Dr. John P., CLU, CFP, LUTC") hn2 = HumanName("Dr. John P. Doe-Ray, CLU, CFP, LUTC") self.assertTrue(not hn1 == hn2) self.assertTrue(not hn1 == 0) self.assertTrue(not hn1 == "test") self.assertTrue(not hn1 == ["test"]) self.assertTrue(not hn1 == {"test": hn2}) def test_assignment_to_full_name(self) -> None: hn = HumanName("John A. Kenneth Doe, Jr.") self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.middle, "A. Kenneth", hn) self.m(hn.suffix, "Jr.", hn) hn.full_name = "Juan Velasquez y Garcia III" self.m(hn.first, "Juan", hn) self.m(hn.last, "Velasquez y Garcia", hn) self.m(hn.suffix, "III", hn) def test_assignment_to_attribute(self) -> None: hn = HumanName("John A. Kenneth Doe, Jr.") hn.last = "de la Vega" self.m(hn.last, "de la Vega", hn) hn.title = "test" self.m(hn.title, "test", hn) hn.first = "test" self.m(hn.first, "test", hn) hn.middle = "test" self.m(hn.middle, "test", hn) hn.suffix = "test" self.m(hn.suffix, "test", hn) with pytest.raises(TypeError): hn.suffix = [['test']] # type: ignore[list-item] with pytest.raises(TypeError): hn.suffix = {"test": "test"} # type: ignore[assignment] def test_assign_list_to_attribute(self) -> None: hn = HumanName("John A. Kenneth Doe, Jr.") hn.title = ["test1", "test2"] self.m(hn.title, "test1 test2", hn) hn.first = ["test3", "test4"] self.m(hn.first, "test3 test4", hn) hn.middle = ["test5", "test6", "test7"] self.m(hn.middle, "test5 test6 test7", hn) hn.last = ["test8", "test9", "test10"] self.m(hn.last, "test8 test9 test10", hn) hn.suffix = ['test'] self.m(hn.suffix, "test", hn) def test_comparison_case_insensitive(self) -> None: # 2.0 identity semantics (#223): equivalently-parsed instances no # longer compare ==; matches() carries the case-insensitive # component comparison instead hn1 = HumanName("Doe-Ray, Dr. John P., CLU, CFP, LUTC") hn2 = HumanName("dr. john p. doe-Ray, CLU, CFP, LUTC") self.assertFalse(hn1 == hn2) self.assertIsNot(hn1, hn2) self.assertFalse(hn1 == "Dr. John P. Doe-ray clu, CFP, LUTC") self.assertTrue(hn1.matches(hn2)) self.assertTrue(hn1.matches("Dr. John P. Doe-ray clu, CFP, LUTC")) def test_hash_is_identity_based(self) -> None: # 2.0 identity semantics (#223): hash reverted to the object default, # consistent with identity ==; equal-parsing instances stay distinct # in sets/dicts. comparison_key() is the dedup replacement. hn1 = HumanName("Doe-Ray, Dr. John P., CLU, CFP, LUTC") hn2 = HumanName("dr. john p. doe-Ray, CLU, CFP, LUTC") self.assertEqual(hash(hn1), object.__hash__(hn1)) self.assertEqual(len({hn1, hn2}), 2) # strings no longer interoperate with HumanName in sets/dicts hn = HumanName("John Smith") self.assertNotEqual(hash(hn), hash("john smith")) self.assertNotIn("john smith", {hn}) self.assertEqual(hn1.comparison_key(), hn2.comparison_key()) def test_not_equal_operator(self) -> None: # != routes through the 2.0 identity __eq__ (#223): any two distinct # instances are unequal, however similar their parse self.assertTrue(HumanName("John Smith") != HumanName("Jane Smith")) self.assertTrue(HumanName("John Smith") != HumanName("john smith")) hn = HumanName("John Smith") self.assertFalse(hn != hn) def test_comparison_key_components(self) -> None: hn = HumanName("Dr. Juan Q. Xavier de la Vega III") self.assertEqual( hn.comparison_key(), ('dr.', 'juan', 'q. xavier', 'de la vega', 'iii', '', '')) def test_comparison_key_case_insensitive_across_formats(self) -> None: hn1 = HumanName("Dr. Juan Q. Xavier de la Vega III") hn2 = HumanName("de la vega, dr. juan Q. xavier III") self.assertEqual(hn1.comparison_key(), hn2.comparison_key()) def test_comparison_key_independent_of_string_format(self) -> None: # unlike ==, which compares str(self) and so changes meaning when # display config changes, the key is built from the parsed lists hn1 = HumanName("John Smith") hn2 = HumanName("John Smith", string_format="{last}") self.assertEqual(hn1.comparison_key(), hn2.comparison_key()) def test_comparison_key_includes_maiden(self) -> None: # maiden isn't in the default string_format, so == can't see it; # the key includes all seven members hn1 = HumanName(first="Jenny", last="Baker", maiden="Johnson") hn2 = HumanName(first="Jenny", last="Baker") self.assertNotEqual(hn1.comparison_key(), hn2.comparison_key()) def test_comparison_key_usable_for_dedup(self) -> None: names = [HumanName("John Smith"), HumanName("Smith, John"), HumanName("JOHN SMITH"), HumanName("Jane Smith")] unique = {n.comparison_key(): n for n in names} self.assertEqual(len(unique), 2) def test_matches_str_is_semantic_not_textual(self) -> None: # any written form of the same name matches, unlike == which only # matches strings that render exactly like str(self) hn = HumanName("Dr. Juan Q. Xavier de la Vega III") self.assertTrue(hn.matches("de la vega, dr. juan Q. xavier III")) self.assertTrue(hn.matches("Dr. Juan Q. Xavier de la Vega III")) self.assertFalse(hn.matches("Juan de la Vega")) def test_matches_humanname_operand(self) -> None: hn = HumanName("John Smith") self.assertTrue(hn.matches(HumanName("JOHN SMITH"))) self.assertFalse(hn.matches(HumanName("Jane Smith"))) def test_matches_parses_str_with_instance_constants(self) -> None: # the custom title must not be a default one ('chancellor' is!), or # this passes without the self.C parse path ever mattering c = Constants() c.titles.add('zephyrmark') self.assertNotIn('zephyrmark', CONSTANTS.titles) hn = HumanName("Zephyrmark Jane Smith", constants=c) # the str operand is parsed with self.C, so the custom title is # recognized in the comma form; parsed with the shared CONSTANTS, # 'zephyrmark' would land in first/middle and the keys would differ self.assertTrue(hn.matches("smith, zephyrmark jane")) def test_matches_humanname_operand_keeps_its_own_parse(self) -> None: # asymmetry, pinned deliberately: a str operand is reparsed with # self.C, but a HumanName operand is compared as already parsed -- # its own constants determined its components c = Constants() c.titles.add('zephyrmark') with_title = HumanName("Zephyrmark Jane Smith", constants=c) default_parse = HumanName("Zephyrmark Jane Smith") self.assertTrue(with_title.matches("Zephyrmark Jane Smith")) self.assertFalse(with_title.matches(default_parse)) def test_empty_parses_share_a_comparison_key(self) -> None: # documented caveat: empty/unparsable input collapses to the # all-empty key, so such names match each other and collide in # dedup; screen with len(name) == 0 first self.assertTrue(HumanName("").matches(HumanName(","))) self.assertEqual(HumanName("()").comparison_key(), HumanName("").comparison_key()) def test_matches_non_ascii_case_insensitive(self) -> None: hn = HumanName("JOSÉ GARCÍA") self.assertTrue(hn.matches("José García")) def test_matches_rejects_other_types(self) -> None: hn = HumanName("John Smith") for bad in (None, 42, b"John Smith", ["John Smith"]): with pytest.raises(TypeError, match="str or HumanName"): hn.matches(bad) # type: ignore[arg-type] def test_eq_and_hash_are_silent_in_2_0(self) -> None: # the 1.3.0/1.4 DeprecationWarnings on __eq__/__hash__ ended with the # 2.0 switch to identity semantics (#223): both are now warning-free hn1 = HumanName("John Smith") hn2 = HumanName("john smith") with warnings.catch_warnings(): warnings.simplefilter("error") self.assertFalse(hn1 == hn2) hash(hn1) def test_new_comparison_api_does_not_warn(self) -> None: # the replacements must be adoptable before 2.0 without tripping # -W error test suites; matches(str) parses, so this also covers # the parse path hn = HumanName("John Smith") with warnings.catch_warnings(): warnings.simplefilter("error") ignore_removals() hn.comparison_key() hn.matches("Smith, John") hn.matches(HumanName("John Smith")) def test_unparsable_attribute_removed(self) -> None: # Removed in 1.3.0: the guard that reported unparsable names was # unreachable, so the attribute was always False after any parse. self.assertFalse(hasattr(HumanName("John Smith"), "unparsable")) self.assertFalse(hasattr(HumanName(first="John"), "unparsable")) def test_str_fallback_without_string_format(self) -> None: # string_format=None falls back to joining the non-empty attributes hn = HumanName("Dr. John A. Doe, Jr.") hn.string_format = None self.assertEqual(str(hn), "Dr. John A. Doe Jr.") def test_repr_blank_name(self) -> None: hn = HumanName() self.assertIn("first: ''", repr(hn)) self.assertIn(hn.__class__.__name__, repr(hn)) def test_slice_removed(self) -> None: # slice access was removed in 2.0 (#258, warned since 1.4); iteration # and string-key access are the remaining spellings hn = HumanName("Doe-Ray, Dr. John P., CLU, CFP, LUTC") self.m(list(hn), ['Dr.', 'John', 'P.', 'Doe-Ray', 'CLU, CFP, LUTC'], hn) with pytest.raises(TypeError, match="258"): hn[1:] # type: ignore[index] with pytest.raises(TypeError, match="258"): hn[1:-3] # type: ignore[index] # string-key access is unaffected and stays silent with warnings.catch_warnings(): warnings.simplefilter("error") hn['first'] def test_getitem(self) -> None: hn = HumanName("Dr. John A. Kenneth Doe, Jr.") self.m(hn['title'], "Dr.", hn) self.m(hn['first'], "John", hn) self.m(hn['last'], "Doe", hn) self.m(hn['middle'], "A. Kenneth", hn) self.m(hn['suffix'], "Jr.", hn) def test_setitem_removed(self) -> None: # __setitem__ was removed in 2.0 (#258, warned since 1.4): item # assignment raises TypeError for every key, valid or not; plain # attribute assignment is the replacement hn = HumanName("Dr. John A. Kenneth Doe, Jr.") with pytest.raises(TypeError, match="item assignment"): hn['title'] = 'test' # type: ignore[index] with pytest.raises(TypeError, match="item assignment"): hn['bogus'] = 'value' # type: ignore[index] hn.first = 'Jane' # the replacement spelling self.m(hn.first, "Jane", hn) def test_conjunction_names(self) -> None: hn = HumanName("johnny y") self.m(hn.first, "johnny", hn) self.m(hn.last, "y", hn) def test_prefix_names(self) -> None: hn = HumanName("vai la") self.m(hn.first, "vai", hn) self.m(hn.last, "la", hn) def test_degenerate_comma_input_leaves_no_empty_pieces(self) -> None: # Regression: HumanName(',') (no-comma path after whitespace collapse) # and HumanName('Doe,, Jr.') (lastname-comma path) appended '' to # first_list — a silent empty member in the public *_list attributes. hn = HumanName(",") self.assertEqual(hn.first_list, []) self.assertEqual(hn.middle_list, []) self.assertEqual(hn.last_list, []) self.assertEqual(len(hn), 0) hn = HumanName("Doe,, Jr.") self.assertEqual(hn.first_list, []) self.m(hn.last, "Doe", hn) self.m(hn.suffix, "Jr.", hn) # empty parts[0] exercises the lastname_pieces call site: the empty # last-name segment must not become a member of last_list hn = HumanName(", John") self.assertEqual(hn.last_list, []) self.m(hn.first, "John", hn) def test_assignment_filters_empty_tokens(self) -> None: # parse_pieces() drops tokens that strip to nothing at every entry # point, including the setters: whitespace-only strings and empty # list members never become *_list members (they previously survived, # e.g. hn.first = ' ' left first == ' ' and middle 'a b' gained an # empty member from ['a', '', 'b']). hn = HumanName("John Doe") hn.first = " " self.assertEqual(hn.first_list, []) self.m(hn.first, "", hn) hn.middle = ["a", "", "b"] self.assertEqual(hn.middle_list, ["a", "b"]) self.m(hn.middle, "a b", hn) hn.last = ["", "Smith"] self.assertEqual(hn.last_list, ["Smith"]) def test_blank_name(self) -> None: hn = HumanName() self.m(hn.first, "", hn) self.m(hn.last, "", hn) def test_surnames_list_attribute(self) -> None: hn = HumanName("John Edgar Casey Williams III") self.m(hn.surnames_list, ["Edgar", "Casey", "Williams"], hn) def test_surnames_attribute(self) -> None: hn = HumanName("John Edgar Casey Williams III") self.m(hn.surnames, "Edgar Casey Williams", hn) def test_given_names_list_attribute(self) -> None: hn = HumanName("John Edgar Casey Williams III") self.m(hn.given_names_list, ["John", "Edgar", "Casey"], hn) def test_given_names_attribute(self) -> None: hn = HumanName("John Edgar Casey Williams III") self.m(hn.given_names, "John Edgar Casey", hn) def test_given_names_attribute_first_only(self) -> None: hn = HumanName("John Williams") self.m(hn.given_names_list, ["John"], hn) self.m(hn.given_names, "John", hn) def test_given_names_attribute_empty(self) -> None: # empty attributes are always '' in 2.0 (#255) hn = HumanName("Dr. Williams") self.m(hn.given_names, "", hn) def test_override_constants(self) -> None: C = Constants() hn = HumanName(constants=C) self.assertIs(hn.C, C) def test_override_regex_raises(self) -> None: # Custom regexes are not supported in 2.0 (deliberate divergence, # shim's uniform read-only rule): the constructor kwarg raises the # same TypeError as attribute assignment, pointing at named Policy # flags. Reads of the built-in patterns stay available. var = TupleManager([("spaces", re.compile(r"\s+")),]) with pytest.raises(TypeError, match="Policy"): Constants(regexes=var) # type: ignore[call-arg] def test_override_titles(self) -> None: var = ["abc","def"] C = Constants(titles=var) hn = HumanName(constants=C) self.assertTrue(sorted(hn.C.titles) == sorted(var)) def test_override_first_name_titles(self) -> None: var = ["abc","def"] C = Constants(first_name_titles=var) hn = HumanName(constants=C) self.assertTrue(sorted(hn.C.first_name_titles) == sorted(var)) def test_override_prefixes(self) -> None: var = ["abc","def"] C = Constants(prefixes=var) hn = HumanName(constants=C) self.assertTrue(sorted(hn.C.prefixes) == sorted(var)) def test_override_suffix_acronyms(self) -> None: var = ["abc","def"] C = Constants(suffix_acronyms=var) hn = HumanName(constants=C) self.assertTrue(sorted(hn.C.suffix_acronyms) == sorted(var)) def test_override_suffix_not_acronyms(self) -> None: var = ["abc","def"] C = Constants(suffix_not_acronyms=var) hn = HumanName(constants=C) self.assertTrue(sorted(hn.C.suffix_not_acronyms) == sorted(var)) def test_override_conjunctions(self) -> None: var = ["abc","def"] C = Constants(conjunctions=var) hn = HumanName(constants=C) self.assertTrue(sorted(hn.C.conjunctions) == sorted(var)) def test_override_capitalization_exceptions(self) -> None: var = TupleManager([("abc", "ABC")]) C = Constants(capitalization_exceptions=var) hn = HumanName(constants=C) self.assertTrue(hn.C.capitalization_exceptions == var) derek73-python-nameparser-456b024/tests/test_suffixes.py000066400000000000000000000612711527303271700233250ustar00rootroot00000000000000from nameparser import HumanName from tests.base import HumanNameTestBase class SuffixesTestCase(HumanNameTestBase): def test_suffix(self) -> None: hn = HumanName("Joe Franklin Jr") self.m(hn.first, "Joe", hn) self.m(hn.last, "Franklin", hn) self.m(hn.suffix, "Jr", hn) def test_suffix_with_periods(self) -> None: hn = HumanName("Joe Dentist D.D.S.") self.m(hn.first, "Joe", hn) self.m(hn.last, "Dentist", hn) self.m(hn.suffix, "D.D.S.", hn) def test_two_suffixes(self) -> None: hn = HumanName("Kenneth Clarke QC MP") self.m(hn.first, "Kenneth", hn) self.m(hn.last, "Clarke", hn) # The writer spaced "QC MP", so #436 renders it as written # (rules/R1). A deliberate deviation from 1.4.0, which added a # comma the original format did not have. self.m(hn.suffix, "QC MP", hn) def test_two_suffixes_lastname_comma_format(self) -> None: hn = HumanName("Washington Jr. MD, Franklin") self.m(hn.first, "Franklin", hn) self.m(hn.last, "Washington", hn) # The writer spaced "Jr. MD", so #436 renders it as written # (rules/R1). This name HAS a writer's comma, but it stands # between MD and Franklin -- it parts the family from the run, not # the run from itself. A deliberate deviation from 1.4.0, which # added a comma the original format did not have. self.m(hn.suffix, "Jr. MD", hn) def test_roman_numeral_v_suffix_comma_format(self) -> None: # suffix-comma position is unambiguous: 'V' must be a suffix, not a single-letter initial hn = HumanName("John W. Ingram, V") self.m(hn.first, "John", hn) self.m(hn.middle, "W.", hn) self.m(hn.last, "Ingram", hn) self.m(hn.suffix, "V", hn) def test_roman_numeral_i_suffix_comma_format(self) -> None: # 'I' has the same single-letter ambiguity as 'V' hn = HumanName("John W. Smith, I") self.m(hn.first, "John", hn) self.m(hn.middle, "W.", hn) self.m(hn.last, "Smith", hn) self.m(hn.suffix, "I", hn) def test_suffix_not_acronym_then_acronym_suffix_comma_format(self) -> None: # single-letter suffix_not_acronyms entry followed by an acronym suffix hn = HumanName("John Smith, V MD") self.m(hn.first, "John", hn) self.m(hn.last, "Smith", hn) self.m(hn.suffix, "V MD", hn) def test_two_suffix_not_acronyms_suffix_comma_format(self) -> None: hn = HumanName("John Smith, V Jr.") self.m(hn.first, "John", hn) self.m(hn.last, "Smith", hn) self.m(hn.suffix, "V Jr.", hn) def test_two_suffixes_suffix_comma_format(self) -> None: hn = HumanName("Franklin Washington, Jr. MD") self.m(hn.first, "Franklin", hn) self.m(hn.last, "Washington", hn) self.m(hn.suffix, "Jr. MD", hn) def test_suffix_containing_periods(self) -> None: hn = HumanName("Kenneth Clarke Q.C.") self.m(hn.first, "Kenneth", hn) self.m(hn.last, "Clarke", hn) self.m(hn.suffix, "Q.C.", hn) def test_suffix_containing_periods_lastname_comma_format(self) -> None: hn = HumanName("Clarke, Kenneth, Q.C. M.P.") self.m(hn.first, "Kenneth", hn) self.m(hn.last, "Clarke", hn) self.m(hn.suffix, "Q.C. M.P.", hn) def test_suffix_containing_periods_suffix_comma_format(self) -> None: hn = HumanName("Kenneth Clarke Q.C., M.P.") self.m(hn.first, "Kenneth", hn) self.m(hn.last, "Clarke", hn) self.m(hn.suffix, "Q.C., M.P.", hn) def test_suffix_with_single_comma_format(self) -> None: hn = HumanName("John Doe jr., MD") self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.suffix, "jr., MD", hn) def test_suffix_with_double_comma_format(self) -> None: hn = HumanName("Doe, John jr., MD") self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.suffix, "jr., MD", hn) def test_phd_with_erroneous_space(self) -> None: hn = HumanName("John Smith, Ph. D.") self.m(hn.first, "John", hn) self.m(hn.last, "Smith", hn) self.m(hn.suffix, "Ph. D.", hn) def test_phd_extracted_without_comma(self) -> None: hn = HumanName("John Smith Ph. D.") self.m(hn.first, "John", hn) self.m(hn.last, "Smith", hn) self.m(hn.suffix, "Ph. D.", hn) def test_phd_conflict(self) -> None: hn = HumanName("Adolph D") self.m(hn.first, "Adolph", hn) self.m(hn.last, "D", hn) # http://en.wikipedia.org/wiki/Ma_(surname) def test_potential_suffix_that_is_also_last_name(self) -> None: hn = HumanName("Jack Ma") self.m(hn.first, "Jack", hn) self.m(hn.last, "Ma", hn) def test_potential_suffix_that_is_also_last_name_comma(self) -> None: hn = HumanName("Ma, Jack") self.m(hn.first, "Jack", hn) self.m(hn.last, "Ma", hn) def test_potential_suffix_that_is_also_last_name_with_suffix(self) -> None: hn = HumanName("Jack Ma Jr") self.m(hn.first, "Jack", hn) self.m(hn.last, "Ma", hn) self.m(hn.suffix, "Jr", hn) def test_potential_suffix_that_is_also_last_name_with_suffix_comma(self) -> None: hn = HumanName("Ma III, Jack Jr") self.m(hn.first, "Jack", hn) self.m(hn.last, "Ma", hn) self.m(hn.suffix, "III, Jr", hn) # https://github.com/derek73/python-nameparser/issues/27 def test_king(self) -> None: # v1 aspired to read "Dr King Jr" as title 'Dr', family 'King', # suffix 'Jr', and shipped this as an xfail. # Two halves, and this pin means different things about each. # DECIDED 2026-09-01 (v1-xfail triage, decisions/v1-xfail-triage): # 'king' stays in the titles vocabulary for the addressing forms. # SUPERSEDED 2026-10-26 (#752 phase 2j): 'king' is a given name # outside a title phrase ("His Majesty King Charles" keeps the # title), so the title chain no longer reaches 'King' here and # this row reads as 'Dr Smith Jr' does; 'Dr Pope Jr' is the floor's # row now (tests/v2/cases.py). The comma format is pinned below as # the contrast. # WHAT BECOMES OF THE LEFTOVER 'Jr': the improvement this pin # anticipated, taken 2026-09-08 (#489, decisions/H3). The title # chain used to eat two words and leave 'Jr' to be the name, against # rules/S2, which reads a trailing suffix-vocabulary word as a # suffix -- and against 'Dr Smith Jr', which reads family 'Smith', # suffix 'Jr' exactly as S2 states. The leading peel then left a # name word a suffix cannot be: everything behind the run was a # suffix piece and 'King' was not one, so the run gave it back. # Every field below is what 'Dr Smith Jr' reads, as it was while # 'king' was title vocabulary; then the floor's 'title-or-name' # report on the word left standing was the difference, a 2.0 # surface this v1 facade test does not assert. hn = HumanName("Dr King Jr") self.m(hn.title, "Dr", hn) self.m(hn.first, "", hn) self.m(hn.middle, "", hn) self.m(hn.last, "King", hn) self.m(hn.suffix, "Jr", hn) def test_king_as_a_family_name_via_the_comma_format(self) -> None: # The contrast: writing the family first defeats the title chain # outright, and reads 'King' as the family without needing the peel # floor above. Unchanged by #489 -- both roads now arrive together. hn = HumanName("King, Dr Jr") self.m(hn.title, "Dr", hn) self.m(hn.last, "King", hn) self.m(hn.suffix, "Jr", hn) def test_multiple_letter_suffix_with_periods(self) -> None: hn = HumanName("John Doe Msc.Ed.") self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.suffix, "Msc.Ed.", hn) def test_suffix_with_periods_with_comma(self) -> None: hn = HumanName("John Doe, Msc.Ed.") self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.suffix, "Msc.Ed.", hn) def test_suffix_with_periods_with_lastname_comma(self) -> None: hn = HumanName("Doe, John Msc.Ed.") self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.suffix, "Msc.Ed.", hn) def test_roman_numeral_i_lastname_comma_format(self) -> None: # 'I' is in suffix_not_acronyms; trailing suffix_not_acronyms members in # "Lastname, First Middle Suffix" format (no comma-suffix segment) must # parse as suffix, not middle initial (issue #144) hn = HumanName("Maier, Amy Lauren I") self.m(hn.first, "Amy", hn) self.m(hn.middle, "Lauren", hn) self.m(hn.last, "Maier", hn) self.m(hn.suffix, "I", hn) def test_roman_numeral_v_lastname_comma_format(self) -> None: # 'V' shares the same suffix_not_acronyms ambiguity as 'I' (issue #144) hn = HumanName("Smith, John V") self.m(hn.first, "John", hn) self.m(hn.middle, "", hn) self.m(hn.last, "Smith", hn) self.m(hn.suffix, "V", hn) def test_roman_numeral_i_no_middle_lastname_comma_format(self) -> None: # no middle name: trailing 'I' must still be a suffix (issue #144) hn = HumanName("Smith, John I") self.m(hn.first, "John", hn) self.m(hn.middle, "", hn) self.m(hn.last, "Smith", hn) self.m(hn.suffix, "I", hn) def test_roman_numeral_i_after_single_initial_lastname_comma_format(self) -> None: # single-letter middle initial followed by trailing 'I' (reporter pattern, issue #144) hn = HumanName("Chang, Andy C I") self.m(hn.first, "Andy", hn) self.m(hn.middle, "C", hn) self.m(hn.last, "Chang", hn) self.m(hn.suffix, "I", hn) def test_roman_numeral_i_with_explicit_suffix_comma_stays_a_middle_initial(self) -> None: # v1 aspired to read the trailing 'I' as an ordinal joining the explicit # suffix ("I, Jr."), and shipped this as an xfail. # NOT FIXED, decided 2026-09-01 (v1-xfail triage, # decisions/v1-xfail-triage): a middle initial 'I' is far more common # than an ordinal I borne without a Sr./Jr.-style companion, so when an # explicit suffix comma has already named the suffix, the trailing 'I' # stays a middle initial. The commonality reasoning is the # vocabulary-collision criterion (decisions/vocabulary-collisions) # applied to a shape rather than to a word. # Contrast test_roman_numeral_i_after_single_initial_lastname_comma_format # above, where no explicit suffix comma is present and the 'I' does go to # suffix. # rules/C9 (then part of rules/C1) gained a qualifier with this triage: where a further # comma has already named the suffix, the trailing single letter has # no generation left to be. C9 carries no example line for it (see the # Accepted note there), so this pair of tests is its witness. hn = HumanName("Maier, Amy I, Jr.") self.m(hn.first, "Amy", hn) self.m(hn.middle, "I", hn) self.m(hn.last, "Maier", hn) self.m(hn.suffix, "Jr.", hn) def test_suffix_delimiter_default_on_constants(self) -> None: from nameparser.config import CONSTANTS self.assertIs(CONSTANTS.suffix_delimiter, None) def test_suffix_delimiter_kwarg_accepted(self) -> None: hn = HumanName("Steven Hardman, RN - CRNA", suffix_delimiter=" - ") self.assertEqual(hn.suffix_delimiter, " - ") def test_suffix_delimiter_basic(self) -> None: # ' | ', not ' - ': a hyphen standing alone is built in (#889) hn = HumanName("Steven Hardman, RN | CRNA", suffix_delimiter=" | ") self.m(hn.first, "Steven", hn) self.m(hn.last, "Hardman", hn) self.m(hn.suffix, "RN, CRNA", hn) def test_suffix_delimiter_multiple(self) -> None: # ' | ', not ' - ': a hyphen standing alone is built in (#889) hn = HumanName("John Doe, MD | PhD | FACS", suffix_delimiter=" | ") self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.suffix, "MD, PhD, FACS", hn) def test_suffix_delimiter_no_effect_without_comma(self) -> None: # suffix_delimiter only applies after the comma split; space-separated # suffixes already work via the no-comma parse path. Since #436 the # run renders with the space the writer typed, so the row shows the # knob is inert rather than showing a comma nobody asked for -- a # deliberate deviation from 1.4.0, which added that comma. hn = HumanName("John Doe MD PhD", suffix_delimiter=" - ") self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.suffix, "MD PhD", hn) def test_suffix_delimiter_constants_level(self) -> None: # ' | ', not ' - ': a hyphen standing alone is built in (#889) # ran on the shared CONSTANTS in v1; 2.0 deprecates shared mutation, # so the config-level (non-kwarg) path is exercised on a private # Constants passed as constants= from nameparser.config import Constants c = Constants() c.suffix_delimiter = " | " hn = HumanName("Steven Hardman, RN | CRNA", constants=c) self.m(hn.first, "Steven", hn) self.m(hn.last, "Hardman", hn) self.m(hn.suffix, "RN, CRNA", hn) def test_suffix_delimiter_none_by_default_known_limitation(self) -> None: # Without suffix_delimiter set, " - " is a word, not a separator. # Through 2.3 (and in v1) that broke parsing -- first 'RN', last # 'Steven Hardman', suffix 'CRNA' -- and this test pinned the # limitation. Since #603 a credential opening the part after the # comma makes it the suffix part (rules/C13), and since #889 a # hyphen standing alone after the first comma is a delimiter # every policy has (rules/C10), so the suffix parts at it as # #156 asked; the name of the test keeps the history. hn = HumanName("Steven Hardman, RN - CRNA") self.m(hn.first, "Steven", hn) self.m(hn.last, "Hardman", hn) self.m(hn.suffix, "RN, CRNA", hn) def test_suffix_delimiter_trailing_delimiter_ignored(self) -> None: # Trailing delimiter must not defeat suffix detection. Using a # non-whitespace-terminated delimiter so stripping doesn't consume it. hn = HumanName("John Doe, MD-PhD-", suffix_delimiter="-") self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) # 2.0: fix(suffix-delimiter-rendering) -- v1 split the token and # rendered 'MD, PhD'; v2 keeps a no-space delimiter-core token whole # (anti-#100), same rule as the pinned 'RN/CRNA' case row. self.m(hn.suffix, "MD-PhD-", hn) def test_suffix_delimiter_comma_space_is_noop(self) -> None: hn = HumanName("John Doe, MD, PhD", suffix_delimiter=", ") self.m(hn.suffix, "MD, PhD", hn) def test_suffix_delimiter_inverted_format_not_misparsed(self) -> None: # ' | ', not ' - ': a hyphen standing alone is built in (#889) # The delimiter only expands parts once they're identified as a # suffix group, so a hyphenated given name in inverted format isn't # mistaken for a suffix split. hn = HumanName("Doe, Mary | Kate, RN", suffix_delimiter=" | ") self.m(hn.first, "Mary", hn) self.m(hn.last, "Doe", hn) self.m(hn.suffix, "RN", hn) # "Kate" stays in the given-name segment rather than being pulled # into the suffix, since it's separated from "RN" by its own comma. # The bare "|" landing in middle is a pre-existing, delimiter- # independent quirk of tokenizing a lone core (reproducible with # suffix_delimiter unset), not something this fix is responsible for. self.m(hn.middle, "| Kate", hn) def test_suffix_delimiter_expands_each_comma_segment(self) -> None: # ' | ', not ' - ': a hyphen standing alone is built in (#889) # parts[1:] holds two separate comma segments here ("MD | PhD" and # "FACS"); each must be expanded on its own, not just the first. hn = HumanName("John Doe, MD | PhD, FACS", suffix_delimiter=" | ") self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.suffix, "MD, PhD, FACS", hn) def test_suffix_delimiter_detection_with_multi_word_side(self) -> None: # ' | ', not ' - ': a hyphen standing alone is built in (#889) # The suffix-comma detection check flattens on spaces after # expanding on the delimiter, so a multi-word token on one side of # the delimiter is still tokenized correctly. hn = HumanName("Doe, John, MD PhD | FACS Fellow", suffix_delimiter=" | ") self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.suffix, "MD PhD, FACS Fellow", hn) def test_suffix_delimiter_no_effect_when_not_suffix_comma(self) -> None: # When the comma format isn't recognized as suffix-comma (here the # last-name part is a single word), the delimiter must not affect # parsing at all: output should match the no-delimiter baseline. # ' | ', not ' - ': a hyphen standing alone is built in (#889) with_delim = HumanName("Smith, MD | PhD | FACS", suffix_delimiter=" | ") without_delim = HumanName("Smith, MD | PhD | FACS") self.assertEqual( (with_delim.first, with_delim.middle, with_delim.last, with_delim.suffix), (without_delim.first, without_delim.middle, without_delim.last, without_delim.suffix), ) def test_suffix_acronyms_ambiguous_is_customizable(self) -> None: from nameparser.config import Constants custom = Constants(suffix_acronyms_ambiguous=['xyz']) self.assertEqual(set(custom.suffix_acronyms_ambiguous), {'xyz'}) # Constructing without the kwarg still works and uses the module default. default = Constants() self.assertIn('jd', default.suffix_acronyms_ambiguous) def test_suffix_in_parenthesis_with_other_suffixes(self) -> None: hn = HumanName("Andrew Perkins, Jr., Col. (Ret)") self.m(hn.first, "Andrew", hn) self.m(hn.last, "Perkins", hn) self.assertIn("Ret", hn.suffix) self.m(hn.nickname, "", hn) def test_suffix_in_parenthesis_mid_name(self) -> None: # "Jr." is suffix-shaped, so parse_nicknames() no longer treats it as # a nickname. But it isn't in trailing position, and parse_full_name's # suffix detection only recognizes a trailing run of suffix-shaped # pieces -- so it lands wherever normal parsing would put a bare # mid-name "Jr." token, exactly as if the parens were never there # (verified: HumanName("Lon Jr. Williams") parses identically). # Known limitation: making this land in `suffix` would require # changing parse_full_name's suffix detection, out of scope here -- # issue #111 is specifically about the nickname misclassification. hn = HumanName("Lon (Jr.) Williams") self.m(hn.first, "Lon", hn) self.m(hn.middle, "Jr.", hn) self.m(hn.last, "Williams", hn) self.m(hn.suffix, "", hn) self.m(hn.nickname, "", hn) def test_suffix_in_parenthesis_with_period(self) -> None: # Same known limitation as above: "Ret." is mid-name (no comma), so # it's outside the trailing run parse_full_name's suffix detection # requires. It parses exactly as bare "Col. Ret. Smith" would: since # "Ret." is an unrecognized period-abbreviation appearing before the # first name is set, is_leading_title() treats it as a second title # token (see #109), joining "Col." into a single title string. hn = HumanName("Col. (Ret.) Smith") self.m(hn.title, "Col. Ret.", hn) self.m(hn.first, "", hn) self.m(hn.last, "Smith", hn) self.m(hn.suffix, "", hn) self.m(hn.nickname, "", hn) def test_acronym_suffix_in_parenthesis(self) -> None: hn = HumanName("Andrew Perkins (MBA)") self.m(hn.first, "Andrew", hn) self.m(hn.last, "Perkins", hn) self.m(hn.suffix, "MBA", hn) self.m(hn.nickname, "", hn) def test_acronym_suffix_with_internal_periods_in_parenthesis(self) -> None: # "M.D" has a non-trailing period between every letter -- unlike # is_suffix(), handle_match()'s suffix_acronyms check must also strip # internal periods (not just rely on the trailing content.endswith('.') # heuristic, which doesn't fire here since "M.D" has no trailing period). hn = HumanName("Andrew Perkins (M.D)") self.m(hn.first, "Andrew", hn) self.m(hn.last, "Perkins", hn) self.m(hn.suffix, "M.D", hn) self.m(hn.nickname, "", hn) def test_period_terminated_content_in_parenthesis_not_forced_either_way(self) -> None: # "Mgr." isn't in any suffix list, but it ends in a period, so the # period heuristic (rule 2) excludes it from nickname_list. It flows # into normal parsing instead of being force-classified as a suffix. hn = HumanName("Andrew Perkins (Mgr.)") self.m(hn.nickname, "", hn) self.m(hn.suffix, "", hn) def test_suffix_in_single_quotes(self) -> None: # handle_match() is shared across all three delimiter regexes, not # just parenthesis -- confirm suffix-shaped single-quoted content # routes the same way. hn = HumanName("Andrew Perkins 'MBA'") self.m(hn.first, "Andrew", hn) self.m(hn.last, "Perkins", hn) self.m(hn.suffix, "MBA", hn) self.m(hn.nickname, "", hn) def test_suffix_in_double_quotes(self) -> None: hn = HumanName('Andrew Perkins "MBA"') self.m(hn.first, "Andrew", hn) self.m(hn.last, "Perkins", hn) self.m(hn.suffix, "MBA", hn) self.m(hn.nickname, "", hn) def test_suffix_acronyms_ambiguous_custom_entry_stays_nickname(self) -> None: # A custom suffix_acronyms_ambiguous entry keeps a suffix_acronyms # member classified as a nickname instead of a suffix, confirming # the exception list -- not a hardcoded check -- drives the behavior. # Title case: capitals in a mixed-case name settle the member as # the credential (rules/S1, #810). from nameparser.config import Constants C = Constants( suffix_acronyms=['xyz'], suffix_acronyms_ambiguous=['xyz'], ) hn = HumanName("Andrew Perkins (Xyz)", constants=C) self.m(hn.nickname, "Xyz", hn) self.m(hn.suffix, "", hn) def test_suffix_acronyms_ambiguous_removal_routes_to_suffix(self) -> None: # Removing 'jd' from a custom suffix_acronyms_ambiguous flips Jd # from nickname to suffix. Uses a trailing-position name (unlike the # JEFFREY (JD) BRICKEN regression guard in test_nicknames.py) so # parse_full_name's trailing-run suffix detection actually picks it # up -- see the known mid-name limitation noted on the tests above. # Title case, which leaves the member a nickname while it is listed # (rules/S1, #810). from nameparser.config import Constants assert HumanName("Andrew Perkins (Jd)").nickname == "Jd" C = Constants(suffix_acronyms_ambiguous=[]) hn = HumanName("Andrew Perkins (Jd)", constants=C) self.m(hn.nickname, "", hn) self.m(hn.suffix, "Jd", hn) def test_empty_comma_segment_does_not_drop_following_suffix(self) -> None: # Regression: "Doe, John,, Jr." produced an empty third segment, and # the old `if parts[2]:` guard skipped every remaining segment -- # silently dropping the trailing suffix. Empty segments should be # skipped individually. hn = HumanName("Doe, John,, Jr.") self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.suffix, "Jr.", hn) # each empty segment is skipped individually; segments after a later # empty must survive too (guards against a break-instead-of-continue # regression that the single-empty case above would not catch) hn = HumanName("Doe, John,, Jr.,, III") self.m(hn.suffix, "Jr., III", hn) def test_suffix_comma_empty_segment_not_added_to_suffix_list(self) -> None: # Regression: in suffix-comma format ("John Doe, Jr.,," -- one # trailing comma survives collapse_whitespace), the unguarded # parts[1:] loop leaked '' into suffix_list via # expand_suffix_delimiter('') returning ['']. hn = HumanName("John Doe, Jr.,,") self.assertEqual(hn.suffix_list, ["Jr."]) self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) derek73-python-nameparser-456b024/tests/test_titles.py000066400000000000000000000374671527303271700230070ustar00rootroot00000000000000import pytest from nameparser import HumanName from tests.base import HumanNameTestBase class TitleTestCase(HumanNameTestBase): def test_last_name_is_also_title(self) -> None: hn = HumanName("Amy E Maid") self.m(hn.first, "Amy", hn) self.m(hn.middle, "E", hn) self.m(hn.last, "Maid", hn) def test_last_name_is_also_title_no_comma(self) -> None: hn = HumanName("Dr. Martin Luther King Jr.") self.m(hn.title, "Dr.", hn) self.m(hn.first, "Martin", hn) self.m(hn.middle, "Luther", hn) self.m(hn.last, "King", hn) self.m(hn.suffix, "Jr.", hn) def test_last_name_is_also_title_with_comma(self) -> None: hn = HumanName("Dr Martin Luther King, Jr.") self.m(hn.title, "Dr", hn) self.m(hn.first, "Martin", hn) self.m(hn.middle, "Luther", hn) self.m(hn.last, "King", hn) self.m(hn.suffix, "Jr.", hn) def test_leading_period_abbreviation_is_title(self) -> None: hn = HumanName("Major. Dona Smith") self.m(hn.title, "Major.", hn) self.m(hn.first, "Dona", hn) self.m(hn.last, "Smith", hn) def test_last_name_is_also_title3(self) -> None: hn = HumanName("John Pope") self.m(hn.first, "John", hn) self.m(hn.last, "Pope", hn) def test_title_with_conjunction(self) -> None: hn = HumanName("Secretary of State Hillary Clinton") self.m(hn.title, "Secretary of State", hn) self.m(hn.first, "Hillary", hn) self.m(hn.last, "Clinton", hn) def test_compound_title_with_conjunction(self) -> None: hn = HumanName("Cardinal Secretary of State Hillary Clinton") self.m(hn.title, "Cardinal Secretary of State", hn) self.m(hn.first, "Hillary", hn) self.m(hn.last, "Clinton", hn) def test_title_is_title(self) -> None: hn = HumanName("Coach") self.m(hn.title, "Coach", hn) # TODO: fix handling of U.S. -- 2.0 matches v1 here: lc()-style # normalization keeps 'u.s' out of the titles vocabulary, so the # chain never starts (an interim 2.0 build that stripped interior # periods made this pass, but that normalization wrongly turned # 'J.R.' into the title 'jr'; v1 parity won) @pytest.mark.xfail(reason="#490") def test_chained_title_first_name_title_is_initials(self) -> None: hn = HumanName("U.S. District Judge Marc Thomas Treadwell") self.m(hn.title, "U.S. District Judge", hn) self.m(hn.first, "Marc", hn) self.m(hn.middle, "Thomas", hn) self.m(hn.last, "Treadwell", hn) def test_conflict_with_chained_title_first_name_initial(self) -> None: hn = HumanName("U. S. Grant") self.m(hn.first, "U.", hn) self.m(hn.middle, "S.", hn) self.m(hn.last, "Grant", hn) def test_chained_title_first_name_initial_with_no_period(self) -> None: hn = HumanName("US Magistrate Judge T Michael Putnam") self.m(hn.title, "US Magistrate Judge", hn) self.m(hn.first, "T", hn) self.m(hn.middle, "Michael", hn) self.m(hn.last, "Putnam", hn) def test_chained_hyphenated_title(self) -> None: hn = HumanName("US Magistrate-Judge Elizabeth E Campbell") self.m(hn.title, "US Magistrate-Judge", hn) self.m(hn.first, "Elizabeth", hn) self.m(hn.middle, "E", hn) self.m(hn.last, "Campbell", hn) def test_chained_hyphenated_title_with_comma_suffix(self) -> None: hn = HumanName("Mag-Judge Harwell G Davis, III") self.m(hn.title, "Mag-Judge", hn) self.m(hn.first, "Harwell", hn) self.m(hn.middle, "G", hn) self.m(hn.last, "Davis", hn) self.m(hn.suffix, "III", hn) def test_title_multiple_titles_with_apostrophe_s(self) -> None: # v1 aspired to read the whole string as one title, and shipped this # as an xfail. # NOT FIXED, decided 2026-09-01 (v1-xfail triage, # decisions/v1-xfail-triage): this is a name parser, not a title # parser. Handed an input that is all titles, it assumes the last # title-word is the name, so 'Division' becomes the family. Accepted # convention rather than a defect; #491 tracks reporting the guess. hn = HumanName("The Right Hon. the President of the Queen's Bench Division") self.m(hn.title, "The Right Hon. the President of the Queen's Bench", hn) self.m(hn.first, "", hn) self.m(hn.middle, "", hn) self.m(hn.last, "Division", hn) self.m(hn.suffix, "", hn) def test_title_starts_with_conjunction(self) -> None: hn = HumanName("The Rt Hon John Jones") self.m(hn.title, "The Rt Hon", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Jones", hn) def test_conjunction_before_title(self) -> None: hn = HumanName('The Lord of the Universe') self.m(hn.title, "The Lord of the Universe", hn) def test_double_conjunction_on_title(self) -> None: hn = HumanName('Lord of the Universe') self.m(hn.title, "Lord of the Universe", hn) def test_triple_conjunction_on_title(self) -> None: hn = HumanName('Lord and of the Universe') self.m(hn.title, "Lord and of the Universe", hn) def test_multiple_conjunctions_on_multiple_titles(self) -> None: hn = HumanName('Lord of the Universe and Associate Supreme Queen of the World Lisa Simpson') self.m(hn.title, "Lord of the Universe and Associate Supreme Queen of the World", hn) self.m(hn.first, "Lisa", hn) self.m(hn.last, "Simpson", hn) def test_title_with_last_initial_is_suffix(self) -> None: hn = HumanName("Pope John V.") self.m(hn.title, "Pope", hn) self.m(hn.first, "John", hn) self.m(hn.last, "V.", hn) def test_initials_also_suffix(self) -> None: hn = HumanName("Smith, J.R.") self.m(hn.first, "J.R.", hn) # self.m(hn.middle, "R.", hn) self.m(hn.last, "Smith", hn) def test_two_title_parts_separated_by_periods(self) -> None: hn = HumanName("Lt.Gen. John A. Kenneth Doe IV") self.m(hn.title, "Lt.Gen.", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.middle, "A. Kenneth", hn) self.m(hn.suffix, "IV", hn) def test_two_part_title(self) -> None: hn = HumanName("Lt. Gen. John A. Kenneth Doe IV") self.m(hn.title, "Lt. Gen.", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.middle, "A. Kenneth", hn) self.m(hn.suffix, "IV", hn) def test_two_part_title_with_lastname_comma(self) -> None: hn = HumanName("Doe, Lt. Gen. John A. Kenneth IV") self.m(hn.title, "Lt. Gen.", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.middle, "A. Kenneth", hn) self.m(hn.suffix, "IV", hn) def test_two_part_title_with_suffix_comma(self) -> None: hn = HumanName("Lt. Gen. John A. Kenneth Doe, Jr.") self.m(hn.title, "Lt. Gen.", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.middle, "A. Kenneth", hn) self.m(hn.suffix, "Jr.", hn) def test_possible_conflict_with_middle_initial_that_could_be_suffix(self) -> None: hn = HumanName("Doe, Rev. John V, Jr.") self.m(hn.title, "Rev.", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.middle, "V", hn) self.m(hn.suffix, "Jr.", hn) def test_possible_conflict_with_suffix_that_could_be_initial(self) -> None: hn = HumanName("Doe, Rev. John A., V, Jr.") self.m(hn.title, "Rev.", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) self.m(hn.middle, "A.", hn) self.m(hn.suffix, "V, Jr.", hn) # 'ben' was removed from the particle set (then PREFIXES) in v0.2.5 def test_ben_is_not_a_particle(self) -> None: # v1 aspired to read 'ben' as a particle joining the family # ("ben Husain"), and shipped this as an xfail. # NOT FIXED, decided 2026-09-01 (v1-xfail triage, # decisions/v1-xfail-triage): the question was already settled in # v0.2.5, when 'ben' was removed from the prefixes. 'ben' collides with # the given name Ben, which is the same criterion # (decisions/vocabulary-collisions) that keeps it out today. # test_ben_as_first_name below is the collision it protects. hn = HumanName("Ahmad ben Husain") self.m(hn.title, "", hn) self.m(hn.first, "Ahmad", hn) self.m(hn.middle, "ben", hn) self.m(hn.last, "Husain", hn) self.m(hn.suffix, "", hn) def test_ben_as_first_name(self) -> None: hn = HumanName("Ben Johnson") self.m(hn.first, "Ben", hn) self.m(hn.last, "Johnson", hn) def test_ben_as_first_name_with_middle_name(self) -> None: hn = HumanName("Ben Alex Johnson") self.m(hn.first, "Ben", hn) self.m(hn.middle, "Alex", hn) self.m(hn.last, "Johnson", hn) def test_ben_as_middle_name(self) -> None: hn = HumanName("Alex Ben Johnson") self.m(hn.first, "Alex", hn) self.m(hn.middle, "Ben", hn) self.m(hn.last, "Johnson", hn) # http://code.google.com/p/python-nameparser/issues/detail?id=13 def test_last_name_also_prefix(self) -> None: hn = HumanName("Jane Doctor") self.m(hn.first, "Jane", hn) self.m(hn.last, "Doctor", hn) def test_title_with_periods(self) -> None: hn = HumanName("Lt.Gov. John Doe") self.m(hn.title, "Lt.Gov.", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) def test_title_with_periods_lastname_comma(self) -> None: hn = HumanName("Doe, Lt.Gov. John") self.m(hn.title, "Lt.Gov.", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Doe", hn) def test_title_with_periods_and_single_letter_middle_name(self) -> None: # A derived title ("Lt.Gov.") must be excluded from the rootname # count that join_on_conjunctions() uses for its single-letter # conjunction heuristic. If is_rootname() misses the derived titles, # the count reaches 4 and "e" is treated as a conjunction, joining # "juan e garcia" into a single last-name piece with no first name. hn = HumanName("Lt.Gov. juan e garcia") self.m(hn.title, "Lt.Gov.", hn) self.m(hn.first, "juan", hn) self.m(hn.middle, "e", hn) self.m(hn.last, "garcia", hn) def test_mac_with_spaces(self) -> None: hn = HumanName("Jane Mac Beth") self.m(hn.first, "Jane", hn) self.m(hn.last, "Mac Beth", hn) def test_mac_as_first_name(self) -> None: hn = HumanName("Mac Miller") self.m(hn.first, "Mac", hn) self.m(hn.last, "Miller", hn) def test_multiple_prefixes(self) -> None: hn = HumanName("Mike van der Velt") self.m(hn.first, "Mike", hn) self.m(hn.last, "van der Velt", hn) def test_2_same_prefixes_in_the_name(self) -> None: hh = HumanName("Vincent van Gogh van Beethoven") self.m(hh.first, "Vincent", hh) self.m(hh.middle, "van Gogh", hh) self.m(hh.last, "van Beethoven", hh) # Non-ASCII title normalization — confirm diacritic titles survive # the lowercase lookup path end-to-end. def test_señora_non_ascii_title(self) -> None: hn = HumanName("Señora María García") self.m(hn.title, "Señora", hn) self.m(hn.first, "María", hn) self.m(hn.last, "García", hn) def test_señora_lowercase_non_ascii_title(self) -> None: hn = HumanName("señora María García") self.m(hn.title, "señora", hn) self.m(hn.first, "María", hn) self.m(hn.last, "García", hn) def test_frøken_non_ascii_title(self) -> None: hn = HumanName("Frøken Jensen") self.m(hn.title, "Frøken", hn) self.m(hn.first, "", hn) self.m(hn.last, "Jensen", hn) def test_herr_title_not_first_name(self) -> None: hn = HumanName("Herr Schmidt") self.m(hn.title, "Herr", hn) self.m(hn.first, "", hn) self.m(hn.last, "Schmidt", hn) def test_herr_title_with_first_name(self) -> None: hn = HumanName("Herr Klaus Schmidt") self.m(hn.title, "Herr", hn) self.m(hn.first, "Klaus", hn) self.m(hn.last, "Schmidt", hn) def test_leading_period_abbreviation_suffix_comma(self) -> None: hn = HumanName("Major. John Smith, Jr.") self.m(hn.title, "Major.", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Smith", hn) self.m(hn.suffix, "Jr.", hn) def test_leading_period_abbreviation_lastname_comma(self) -> None: hn = HumanName("Smith, Major. John") self.m(hn.title, "Major.", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Smith", hn) def test_leading_period_abbreviation_unknown_word(self) -> None: hn = HumanName("Foo. John Smith") self.m(hn.title, "Foo.", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Smith", hn) def test_leading_period_abbreviation_chained(self) -> None: hn = HumanName("Foo. Xyz. John Smith") self.m(hn.title, "Foo. Xyz.", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Smith", hn) def test_leading_single_letter_initial_excluded(self) -> None: hn = HumanName("J. Smith") self.m(hn.first, "J.", hn) self.m(hn.last, "Smith", hn) self.m(hn.title, "", hn) def test_leading_internal_period_abbreviation_excluded(self) -> None: hn = HumanName("E.T. Smith") self.m(hn.first, "E.T.", hn) self.m(hn.last, "Smith", hn) self.m(hn.title, "", hn) def test_period_abbreviation_after_first_name_stays_middle(self) -> None: hn = HumanName("John Major. Smith") self.m(hn.first, "John", hn) self.m(hn.middle, "Major.", hn) self.m(hn.last, "Smith", hn) self.m(hn.title, "", hn) def test_known_title_with_period_still_a_title(self) -> None: hn = HumanName("Dr. John Smith") self.m(hn.title, "Dr.", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Smith", hn) def test_middle_initial_with_period_unaffected(self) -> None: hn = HumanName("John Q. Smith") self.m(hn.first, "John", hn) self.m(hn.middle, "Q.", hn) self.m(hn.last, "Smith", hn) def test_leading_period_abbreviation_excludes_digits(self) -> None: hn = HumanName("No1. John Smith") self.m(hn.title, "", hn) self.m(hn.first, "No1.", hn) self.m(hn.last, "Smith", hn) def test_leading_period_abbreviation_excludes_apostrophe(self) -> None: hn = HumanName("O'B. John Smith") self.m(hn.title, "", hn) self.m(hn.first, "O'B.", hn) self.m(hn.last, "Smith", hn) def test_leading_period_abbreviation_case_insensitive(self) -> None: hn = HumanName("xyz. John Smith") self.m(hn.title, "xyz.", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Smith", hn) def test_leading_period_abbreviation_with_nickname(self) -> None: hn = HumanName("Xyz. (Bud) Smith") self.m(hn.title, "Xyz.", hn) self.m(hn.first, "", hn) self.m(hn.last, "Smith", hn) self.m(hn.nickname, "Bud", hn) def test_charge_daffaires_chains_as_title(self) -> None: hn = HumanName("Chargé d'Affaires John Smith") self.m(hn.title, "Chargé d'Affaires", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Smith", hn) def test_unaccented_charge_daffaires_chains_as_title(self) -> None: # both spellings ship, like attaché/attache hn = HumanName("Charge d'Affaires John Smith") self.m(hn.title, "Charge d'Affaires", hn) self.m(hn.first, "John", hn) self.m(hn.last, "Smith", hn) derek73-python-nameparser-456b024/tests/test_turkic_patronymic_order.py000066400000000000000000000303061527303271700264250ustar00rootroot00000000000000import re from typing import cast from nameparser import HumanName from nameparser.config import Constants from tests.base import FlaggedConstantsTestBase, HumanNameTestBase # The 2.0 regexes proxy types its attributes as ``object`` (reads are # informational); the pattern-inspection tests below cast once here. _R = Constants().regexes _TURKIC = cast('re.Pattern[str]', _R.turkic_patronymic_marker) _TURKIC_CYR = cast('re.Pattern[str]', _R.turkic_patronymic_marker_cyrillic) _EAST_SLAVIC = cast('re.Pattern[str]', _R.east_slavic_patronymic) _EAST_SLAVIC_CYR = cast('re.Pattern[str]', _R.east_slavic_patronymic_cyrillic) def test_marker_is_whole_word_not_substring() -> None: # is_turkic_patronymic_marker() deliberately uses whole-word .match(), # not suffix .search() (unlike is_east_slavic_patronymic()) — pin this # so a future .match()->.search() slip doesn't silently start matching # surnames/given-names that merely contain a marker as a substring. assert not _TURKIC.match("ogluu") assert not _TURKIC.match("Bogluchik") assert not _TURKIC_CYR.match("оглуш") assert not _TURKIC_CYR.match("Оглуев") class TurkicPatronymicNameOrderReorderTests(FlaggedConstantsTestBase): """Names that SHOULD be rotated when the flag is on.""" constants_kwargs = {"patronymic_name_order": True} def test_oglu(self) -> None: n = self.hn("Aliyev Vusal Said oglu") assert n.first == "Vusal" assert n.middle == "Said oglu" assert n.last == "Aliyev" def test_oglu_native_orthography(self) -> None: n = self.hn("Aliyev Vusal Said oğlu") assert n.first == "Vusal" assert n.middle == "Said oğlu" assert n.last == "Aliyev" def test_ogly(self) -> None: n = self.hn("Aliyev Vusal Said ogly") assert n.first == "Vusal" assert n.middle == "Said ogly" assert n.last == "Aliyev" def test_ogli(self) -> None: n = self.hn("Aliyev Vusal Said ogli") assert n.first == "Vusal" assert n.middle == "Said ogli" assert n.last == "Aliyev" def test_ogli_uzbek_modifier_apostrophe(self) -> None: # U+02BB modifier letter turned comma, the official Uzbek orthography. n = self.hn("Yusupov Aziz Karim oʻgʻli") assert n.first == "Aziz" assert n.middle == "Karim oʻgʻli" assert n.last == "Yusupov" def test_ogli_straight_apostrophe(self) -> None: n = self.hn("Yusupov Aziz Karim o'g'li") assert n.first == "Aziz" assert n.middle == "Karim o'g'li" assert n.last == "Yusupov" def test_ogli_right_single_quote(self) -> None: # U+2019 right single quotation mark, common in informal data. n = self.hn("Yusupov Aziz Karim o’g’li") assert n.first == "Aziz" assert n.middle == "Karim o’g’li" assert n.last == "Yusupov" def test_qizi(self) -> None: n = self.hn("Aliyeva Mehriban Arif qizi") assert n.first == "Mehriban" assert n.middle == "Arif qizi" assert n.last == "Aliyeva" def test_qizi_native_orthography(self) -> None: n = self.hn("Aliyeva Mehriban Arif qızı") assert n.first == "Mehriban" assert n.middle == "Arif qızı" assert n.last == "Aliyeva" def test_kizi(self) -> None: n = self.hn("Yusupova Nodira Karim kizi") assert n.first == "Nodira" assert n.middle == "Karim kizi" assert n.last == "Yusupova" def test_kyzy(self) -> None: n = self.hn("Bekova Aigul Nurlan kyzy") assert n.first == "Aigul" assert n.middle == "Nurlan kyzy" assert n.last == "Bekova" def test_gyzy(self) -> None: n = self.hn("Annayeva Gozel Merdan gyzy") assert n.first == "Gozel" assert n.middle == "Merdan gyzy" assert n.last == "Annayeva" def test_uly(self) -> None: n = self.hn("Nazarbayev Nursultan Abish uly") assert n.first == "Nursultan" assert n.middle == "Abish uly" assert n.last == "Nazarbayev" def test_uulu(self) -> None: n = self.hn("Atambayev Almazbek Sharshenovich uulu") assert n.first == "Almazbek" assert n.middle == "Sharshenovich uulu" assert n.last == "Atambayev" def test_cyrillic_oglu(self) -> None: n = self.hn("Алиев Вусал Саид оглу") assert n.first == "Вусал" assert n.middle == "Саид оглу" assert n.last == "Алиев" def test_cyrillic_ogly(self) -> None: n = self.hn("Алиев Вусал Саид оглы") assert n.first == "Вусал" assert n.middle == "Саид оглы" assert n.last == "Алиев" def test_cyrillic_oglu_azerbaijani_native(self) -> None: n = self.hn("Алиев Вусал Саид оғлу") assert n.first == "Вусал" assert n.middle == "Саид оғлу" assert n.last == "Алиев" def test_cyrillic_ugli_uzbek_native(self) -> None: n = self.hn("Юсупов Азиз Карим ўғли") assert n.first == "Азиз" assert n.middle == "Карим ўғли" assert n.last == "Юсупов" def test_cyrillic_ugli_russian_rendering(self) -> None: n = self.hn("Юсупов Азиз Карим угли") assert n.first == "Азиз" assert n.middle == "Карим угли" assert n.last == "Юсупов" def test_cyrillic_kyzy(self) -> None: n = self.hn("Бекова Айгуль Нурлан кызы") assert n.first == "Айгуль" assert n.middle == "Нурлан кызы" assert n.last == "Бекова" def test_cyrillic_gyzy(self) -> None: n = self.hn("Аннаева Гозель Мердан гызы") assert n.first == "Гозель" assert n.middle == "Мердан гызы" assert n.last == "Аннаева" def test_cyrillic_qizi_uzbek(self) -> None: n = self.hn("Юсупова Нодира Карим қизи") assert n.first == "Нодира" assert n.middle == "Карим қизи" assert n.last == "Юсупова" def test_cyrillic_qyzy_kazakh_native(self) -> None: n = self.hn("Назарбаева Дана Абиш қызы") assert n.first == "Дана" assert n.middle == "Абиш қызы" assert n.last == "Назарбаева" def test_cyrillic_uly_russian_rendering(self) -> None: n = self.hn("Назарбаев Нурсултан Абиш улы") assert n.first == "Нурсултан" assert n.middle == "Абиш улы" assert n.last == "Назарбаев" def test_cyrillic_uly_kazakh_native(self) -> None: n = self.hn("Назарбаев Нурсултан Абиш ұлы") assert n.first == "Нурсултан" assert n.middle == "Абиш ұлы" assert n.last == "Назарбаев" def test_cyrillic_uulu(self) -> None: n = self.hn("Атамбаев Алмазбек Шаршенович уулу") assert n.first == "Алмазбек" assert n.middle == "Шаршенович уулу" assert n.last == "Атамбаев" def test_all_caps_latin(self) -> None: n = self.hn("ALIYEV VUSAL SAID OGLU") assert n.first == "VUSAL" assert n.middle == "SAID OGLU" assert n.last == "ALIYEV" def test_all_caps_cyrillic(self) -> None: n = self.hn("АЛИЕВ ВУСАЛ САИД ОГЛЫ") assert n.first == "ВУСАЛ" assert n.middle == "САИД ОГЛЫ" assert n.last == "АЛИЕВ" class TurkicPatronymicNameOrderGuardsTests(FlaggedConstantsTestBase): """Names that must NOT be reordered even when the flag is on.""" constants_kwargs = {"patronymic_name_order": True} def test_already_correct_natural_order(self) -> None: n = self.hn("Vusal Said oglu Aliyev") assert n.first == "Vusal" assert n.middle == "Said oglu" assert n.last == "Aliyev" def test_comma_guard(self) -> None: n = self.hn("Aliyev, Vusal Said oglu") assert n.first == "Vusal" assert n.middle == "Said oglu" assert n.last == "Aliyev" def test_three_token_no_surname(self) -> None: # No surname to rotate into place — parses as last=oglu, unchanged. n = self.hn("Vusal Said oglu") assert n.first == "Vusal" assert n.middle == "Said" assert n.last == "oglu" def test_five_token_extra_given_name(self) -> None: # Extra token before the patronymic phrase breaks the strict 4-token shape. n = self.hn("Aliyev Vusal Rza Said oglu") assert n.first == "Aliyev" assert n.middle == "Vusal Rza Said" assert n.last == "oglu" def test_ordinary_western_four_token_name(self) -> None: # Last word isn't a recognised marker → no rotation. n = self.hn("Smith John Michael Anderson") assert n.first == "Smith" assert n.middle == "John Michael" assert n.last == "Anderson" class TurkicPatronymicNameOrderFlagOffTests(HumanNameTestBase): """With default Constants (flag=False) nothing changes.""" def test_reversed_order_unchanged(self) -> None: n = HumanName("Aliyev Vusal Said oglu") assert n.first == "Aliyev" assert n.middle == "Vusal Said" assert n.last == "oglu" class PatronymicHandlerInteractionTests(FlaggedConstantsTestBase): """Both handlers run in sequence under the same flag; confirm no interference.""" constants_kwargs = {"patronymic_name_order": True} def test_east_slavic_shape_unaffected_by_turkic_handler(self) -> None: # If handle_turkic_patronymic_name_order() ran BEFORE the East-Slavic # handler could fire, middle_list would already be length 1 (not 2), # so the Turkic guard is a no-op here regardless of call order. n = self.hn("Ivanov Ivan Ivanovich") assert n.first == "Ivan" assert n.middle == "Ivanovich" assert n.last == "Ivanov" def test_turkic_shape_unaffected_by_east_slavic_handler(self) -> None: # East-Slavic's own guard requires middle_list of length 1; this # input has middle_list of length 2, so it's a genuine no-op rather # than a partial mutation before the Turkic handler runs. n = self.hn("Aliyev Vusal Said oglu") assert n.first == "Vusal" assert n.middle == "Said oglu" assert n.last == "Aliyev" def test_no_regex_collision_latin(self) -> None: east_slavic_examples = [ "Ivanovich", "Ivanovna", "Sergeevich", "Sergeevna", "Nikitichna", "Ilyich", "Kuzmich", "Lukich", "Fomich", "Fokich", ] turkic_examples = [ "oglu", "oğlu", "ogly", "ogli", "o'g'li", "qizi", "qızı", "kizi", "kyzy", "gyzy", "uly", "uulu", ] for word in east_slavic_examples: # Sanity check: each word actually matches its own family's # regex, so the non-collision assertion below is non-vacuous. assert _EAST_SLAVIC.search(word), word assert not _TURKIC.match(word), word for word in turkic_examples: assert _TURKIC.match(word), word assert not _EAST_SLAVIC.search(word), word def test_no_regex_collision_cyrillic(self) -> None: east_slavic_examples = [ "Иванович", "Ивановна", "Сергеевич", "Сергеевна", "Никитична", "Ильич", "Кузьмич", "Лукич", "Фомич", "Фокич", ] turkic_examples = [ "оглу", "оглы", "оғлу", "ўғли", "угли", "кызы", "гызы", "қызы", "қизи", "улы", "ұлы", "уулу", ] for word in east_slavic_examples: # Sanity check: each word actually matches its own family's # regex, so the non-collision assertion below is non-vacuous. assert _EAST_SLAVIC_CYR.search(word), word assert not _TURKIC_CYR.match(word), word for word in turkic_examples: assert _TURKIC_CYR.match(word), word assert not _EAST_SLAVIC_CYR.search(word), word derek73-python-nameparser-456b024/tests/test_variations.py000066400000000000000000000175501527303271700236510ustar00rootroot00000000000000from nameparser import HumanName from tests.base import HumanNameTestBase from types import SimpleNamespace TEST_NAMES = ( "John Doe", "John Doe, Jr.", "John Doe III", "Doe, John", "Doe, John, Jr.", "Doe, John III", "John A. Doe", "John A. Doe, Jr.", "John A. Doe III", "Doe, John A.", "Doe, John A., Jr.", "Doe, John A. III", "John A. Kenneth Doe", "John A. Kenneth Doe, Jr.", "John A. Kenneth Doe III", "Doe, John A. Kenneth", "Doe, John A. Kenneth, Jr.", "Doe, John A. Kenneth III", "Dr. John Doe", "Dr. John Doe, Jr.", "Dr. John Doe III", "Doe, Dr. John", "Doe, Dr. John, Jr.", "Doe, Dr. John III", "Dr. John A. Doe", "Dr. John A. Doe, Jr.", "Dr. John A. Doe III", "Doe, Dr. John A.", "Doe, Dr. John A. Jr.", "Doe, Dr. John A. III", "Dr. John A. Kenneth Doe", "Dr. John A. Kenneth Doe, Jr.", "Dr. John A. Kenneth Doe III", "Doe, Dr. John A. Kenneth", "Doe, Dr. John A. Kenneth Jr.", "Doe, Dr. John A. Kenneth III", "Juan de la Vega", "Juan de la Vega, Jr.", "Juan de la Vega III", "de la Vega, Juan", "de la Vega, Juan, Jr.", "de la Vega, Juan III", "Juan Velasquez y Garcia", "Juan Velasquez y Garcia, Jr.", "Juan Velasquez y Garcia III", "Velasquez y Garcia, Juan", "Velasquez y Garcia, Juan, Jr.", "Velasquez y Garcia, Juan III", "Dr. Juan de la Vega", "Dr. Juan de la Vega, Jr.", "Dr. Juan de la Vega III", "de la Vega, Dr. Juan", "de la Vega, Dr. Juan, Jr.", "de la Vega, Dr. Juan III", "Dr. Juan Velasquez y Garcia", "Dr. Juan Velasquez y Garcia, Jr.", "Dr. Juan Velasquez y Garcia III", "Velasquez y Garcia, Dr. Juan", "Velasquez y Garcia, Dr. Juan, Jr.", "Velasquez y Garcia, Dr. Juan III", "Juan Q. de la Vega", "Juan Q. de la Vega, Jr.", "Juan Q. de la Vega III", "de la Vega, Juan Q.", "de la Vega, Juan Q., Jr.", "de la Vega, Juan Q. III", "Juan Q. Velasquez y Garcia", "Juan Q. Velasquez y Garcia, Jr.", "Juan Q. Velasquez y Garcia III", "Velasquez y Garcia, Juan Q.", "Velasquez y Garcia, Juan Q., Jr.", "Velasquez y Garcia, Juan Q. III", "Dr. Juan Q. de la Vega", "Dr. Juan Q. de la Vega, Jr.", "Dr. Juan Q. de la Vega III", "de la Vega, Dr. Juan Q.", "de la Vega, Dr. Juan Q., Jr.", "de la Vega, Dr. Juan Q. III", "Dr. Juan Q. Velasquez y Garcia", "Dr. Juan Q. Velasquez y Garcia, Jr.", "Dr. Juan Q. Velasquez y Garcia III", "Velasquez y Garcia, Dr. Juan Q.", "Velasquez y Garcia, Dr. Juan Q., Jr.", "Velasquez y Garcia, Dr. Juan Q. III", "Juan Q. Xavier de la Vega", "Juan Q. Xavier de la Vega, Jr.", "Juan Q. Xavier de la Vega III", "de la Vega, Juan Q. Xavier", "de la Vega, Juan Q. Xavier, Jr.", "de la Vega, Juan Q. Xavier III", "Juan Q. Xavier Velasquez y Garcia", "Juan Q. Xavier Velasquez y Garcia, Jr.", "Juan Q. Xavier Velasquez y Garcia III", "Velasquez y Garcia, Juan Q. Xavier", "Velasquez y Garcia, Juan Q. Xavier, Jr.", "Velasquez y Garcia, Juan Q. Xavier III", "Dr. Juan Q. Xavier de la Vega", "Dr. Juan Q. Xavier de la Vega, Jr.", "Dr. Juan Q. Xavier de la Vega III", "de la Vega, Dr. Juan Q. Xavier", "de la Vega, Dr. Juan Q. Xavier, Jr.", "de la Vega, Dr. Juan Q. Xavier III", "Dr. Juan Q. Xavier Velasquez y Garcia", "Dr. Juan Q. Xavier Velasquez y Garcia, Jr.", "Dr. Juan Q. Xavier Velasquez y Garcia III", "Velasquez y Garcia, Dr. Juan Q. Xavier", "Velasquez y Garcia, Dr. Juan Q. Xavier, Jr.", "Velasquez y Garcia, Dr. Juan Q. Xavier III", "John Doe, CLU, CFP, LUTC", "John P. Doe, CLU, CFP, LUTC", "Dr. John P. Doe-Ray, CLU, CFP, LUTC", "Doe-Ray, Dr. John P., CLU, CFP, LUTC", "Hon. Barrington P. Doe-Ray, Jr.", "Doe-Ray, Hon. Barrington P. Jr.", "Doe-Ray, Hon. Barrington P. Jr., CFP, LUTC", "Jose Aznar y Lopez", "John E Smith", "John e Smith", "John and Jane Smith", "Rev. John A. Kenneth Doe", "Donovan McNabb-Smith", "Rev John A. Kenneth Doe", "Doe, Rev. John A. Jr.", "Buca di Beppo", "Lt. Gen. John A. Kenneth Doe, Jr.", "Doe, Lt. Gen. John A. Kenneth IV", "Lt. Gen. John A. Kenneth Doe IV", 'Mr. and Mrs. John Smith', 'John Jones (Google Docs)', 'john e jones', 'john e jones, III', 'jones, john e', 'E.T. Smith', 'E.T. Smith, II', 'Smith, E.T., Jr.', 'A.B. Vajpayee', 'Rt. Hon. Paul E. Mary', 'Maid Marion', 'Amy E. Maid', 'Jane Doctor', 'Doctor, Jane E.', 'dr. ben alex johnson III', 'Lord of the Universe and Supreme King of the World Lisa Simpson', 'Benjamin (Ben) Franklin', 'Benjamin "Ben" Franklin', "Brian O'connor", "Sir Gerald", "Magistrate Judge John F. Forster, Jr", # "Magistrate Judge Joaquin V.E. Manibusan, Jr", Intials seem to mess this up "Magistrate-Judge Elizabeth Todd Campbell", "Mag-Judge Harwell G Davis, III", "Mag. Judge Byron G. Cudmore", "Chief Judge J. Leon Holmes", "Chief Judge Sharon Lovelace Blackburn", "Judge James M. Moody", "Judge G. Thomas Eisele", # "Judge Callie V. S. Granade", "Judge C Lynwood Smith, Jr", "Senior Judge Charles R. Butler, Jr", "Senior Judge Harold D. Vietor", "Senior Judge Virgil Pittman", "Honorable Terry F. Moorer", "Honorable W. Harold Albritton, III", "Honorable Judge W. Harold Albritton, III", "Honorable Judge Terry F. Moorer", "Honorable Judge Susan Russ Walker", "Hon. Marian W. Payson", "Hon. Charles J. Siragusa", "US Magistrate Judge T Michael Putnam", "Designated Judge David A. Ezra", "Sr US District Judge Richard G Kopf", "U.S. District Judge Marc Thomas Treadwell", "Dra. Andréia da Silva", "Srta. Andréia da Silva", "Herr Klaus Schmidt", "Frau Anna Müller", "Monsieur Jean Dupont", "Señor Carlos García", "Señora María García", "Signor Marco Rossi", "Mevrouw Anna de Vries", ) class HumanNameVariationTests(HumanNameTestBase): # test automated variations of names in TEST_NAMES. # Helps test that the 3 code trees work the same TEST_NAMES = TEST_NAMES def test_variations_of_TEST_NAMES(self) -> None: for name in self.TEST_NAMES: hn = HumanName(name) if len(hn.suffix_list) > 1: d = SimpleNamespace(**hn.as_dict()) hn = HumanName(f"{d.title} {d.first} {d.middle} {d.last} {d.suffix}".split(',')[0]) # v1 forced empty_attribute_default='' here so the format # strings below got '' for empty parts; 2.0 removed the setting # (#255) and empty attributes are always '' -- nothing to force. d = SimpleNamespace(**hn.as_dict()) nocomma = HumanName(f"{d.title} {d.first} {d.middle} {d.last} {d.suffix}") lastnamecomma = HumanName(f"{d.last}, {d.title} {d.first} {d.middle} {d.suffix}") if d.suffix: suffixcomma = HumanName(f"{d.title} {d.first} {d.middle} {d.last}, {d.suffix}") if d.nickname: nocomma = HumanName(f"{d.title} {d.first} {d.middle} {d.last} {d.suffix} ({d.nickname})") lastnamecomma = HumanName(f"{d.last}, {d.title} {d.first} {d.middle} {d.suffix} ({d.nickname})") if d.suffix: suffixcomma = HumanName(f"{d.title} {d.first} {d.middle} {d.last}, {d.suffix} ({d.nickname})") # v1 iterated the private hn._members; 2.0's public equivalent # is the as_dict() key set (same seven fields) for attr in hn.as_dict(): self.m(getattr(hn, attr), getattr(nocomma, attr), hn) self.m(getattr(hn, attr), getattr(lastnamecomma, attr), hn) if hn.suffix: self.m(getattr(hn, attr), getattr(suffixcomma, attr), hn) derek73-python-nameparser-456b024/tests/v2/000077500000000000000000000000001527303271700204005ustar00rootroot00000000000000derek73-python-nameparser-456b024/tests/v2/__init__.py000066400000000000000000000000001527303271700224770ustar00rootroot00000000000000derek73-python-nameparser-456b024/tests/v2/_differential_fixtures.py000066400000000000000000000143721527303271700255050ustar00rootroot00000000000000"""Shared handles on tools/differential/: its corpora and its modules. Neither is importable the ordinary way: `tools/` is outside `testpaths` and is not a package, so its modules are loaded by path, and the corpora are data files. Not a conftest: these are constants and plain helpers, not fixtures in pytest's sense, and several modules want them by name. """ import importlib.util import json import shutil import sys import time import urllib.request from pathlib import Path from types import ModuleType from typing import Any import pytest _TOOLS = Path(__file__).parents[2] / "tools" / "differential" def _entry_name(raw: object) -> str: """A corpus line's name, whichever format the line uses: a bare JSON string or an object carrying "name".""" if isinstance(raw, str): return raw assert isinstance(raw, dict) and isinstance(raw.get("name"), str), raw return raw["name"] #: Corpus files _CORPUS_NAMES leaves out. corpus_cldr.jsonl #: (2026-10-12): about 1,900 fictional CLDR renderings whose ground #: truth the agreement ratchet in test_cldr_agreement.py enforces. The #: tests reading _CORPUS_NAMES carry counts measured over the names the #: corpora held before it arrived. _UNMEASURED_CORPORA = frozenset({"corpus_cldr.jsonl"}) #: Every corpus name, deduplicated, except the files in #: _UNMEASURED_CORPORA. _CORPUS_NAMES = sorted({ _entry_name(json.loads(line)) for path in sorted(_TOOLS.glob("corpus*.jsonl")) if path.name not in _UNMEASURED_CORPORA for line in path.read_text(encoding="utf-8").splitlines() if line.strip()}) def load_tool(stem: str) -> ModuleType: """A module from tools/differential/, loaded by path. `tools/` is outside testpaths, and adding it would run --doctest-modules over the corpus builders, so these are imported this way rather than made importable. Each main() is behind a __name__ guard; the one import-time effect is that a corpus builder appends its own directory to sys.path to find corpus_build.py (and local_corpora.py its local_core and local_ modules, which it imports under their bare names: a test reaches those with importlib.import_module, never a second load by path).""" spec = importlib.util.spec_from_file_location( f"differential_{stem}", _TOOLS / f"{stem}.py") assert spec is not None and spec.loader is not None, stem module = importlib.util.module_from_spec(spec) # Registered before it runs: @dataclass looks the module up in # sys.modules to check string annotations, and fails on a module # loaded by path that is not in it (local_corpora.py's dataclasses). sys.modules[spec.name] = module spec.loader.exec_module(module) return module class _Response: """What urllib.request.urlopen returns, as far as the build scripts read it.""" def __init__(self, body: bytes) -> None: self.body = body def __enter__(self) -> "_Response": return self def __exit__(self, *exc: object) -> None: return None def read(self) -> bytes: return self.body def own_cache(monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory) -> Path: """Point the tools' cache at a fresh scratch folder and return it: what each local-corpus test module's autouse fixture does, so that a test that forgets to set a cache, or a guard that stops keeping a test's corpus out of it, writes there rather than into the developer's real cache.""" path = tmp_path_factory.mktemp("cache") monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(path)) return path def plant_corpus(monkeypatch: pytest.MonkeyPatch, corpora: ModuleType, source: str, records: list[dict[str, str]], date: str, unit: str, detail: dict[str, Any] | None = None) -> None: """`records` fetched as `source`'s local corpus into the cache, with no network: through a Source carrying the real one's credit and corpus keys and nothing else, so that none of the real source's renew or check applies in this process. A child process loads the corpus under the real Source, whose check reads `detail`, the meta.json detail it pins, and which refuses a corpus of other keys.""" real = corpora.SOURCES[source] monkeypatch.setitem(corpora.SOURCES, source, corpora.Source( real.credit, unit, lambda: corpora.Fetched(list(records), date, dict(detail or {})), corpus_keys=real.corpus_keys)) corpora.fetch(source) def serve(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, body: bytes, *, requests: list[urllib.request.Request] | None = None, sleeps: list[float] | None = None) -> list[str]: """Answer every urlopen with `body` and skip every sleep, cache into `tmp_path`, and return the list each fetched URL is appended to: the build scripts' network, offline. `requests` and `sleeps`, when given, collect every Request sent and every sleep asked for.""" monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) monkeypatch.setattr(time, "sleep", (sleeps if sleeps is not None else []).append) fetched: list[str] = [] def urlopen(request: urllib.request.Request, timeout: int) -> _Response: fetched.append(request.full_url) if requests is not None: requests.append(request) return _Response(body) monkeypatch.setattr(urllib.request, "urlopen", urlopen) return fetched #: Appended to a copied package's __init__.py, a change every reading #: shows: each name is parsed with its words reversed. _PLANTED = """ _planted_parse = Parser.parse Parser.parse = lambda self, text: _planted_parse(self, " ".join(reversed(text.split()))) """ def checkout_copy(at: Path, *, planted: bool = False) -> Path: """A checkout at `at` holding a copy of this one's nameparser/ package, and `planted`, a change every reading shows: a base tree for --base, which a child process reads, so that a check across a planted copy proves the base was read from its own tree.""" shutil.copytree(_TOOLS.parents[1] / "nameparser", at / "nameparser", ignore=shutil.ignore_patterns("__pycache__")) if planted: with open(at / "nameparser" / "__init__.py", "a", encoding="utf-8") as f: f.write(_PLANTED) return at derek73-python-nameparser-456b024/tests/v2/cases.py000066400000000000000000033777611527303271700221000ustar00rootroot00000000000000"""THE shared behavior case table (the rules cite it as the pin of CURRENT behavior). Format is fixed here, in the first pipeline PR, and never per-PR: one Case per input, expected values for exactly the non-empty fields, optional Policy/Locale context, and a mandatory classification -- "parity" (matches v1.4.0, pinned live 2026-07-12) or "fix(#N)" / "fix()" (an intentional 2.0 behavior change, annotated with its issue or a design-decision slug). No silent expectation edits: changing a row means changing its classification. "UNDETERMINED" is a fourth value and a TEMPORARY one: a row added before anyone has run its input against 1.4.0 carries it until the comparison is made and it resolves to one of the three above. It is not a standing category, and nothing enforces its removal -- a row still wearing it is a row whose parity is simply unknown. The v1 suite's full corpus is extracted into this table by the migration plan (facade runner consumes the same rows); this file seeds it with the pinned battery. What earns a row is a FORK: a branch taken for one input and not another -- a rule's boundary, a precedence contest between two rules, a policy that changes the answer. A row demonstrating one more member of a vocabulary set pins nothing its other members do not, and one row per entry grows this table without narrowing what can break (mechanisms.md#VOCABULARY-EXERCISES-FORKS -- a restatement, and nothing checks it against the entry: test_doc_citations verifies an excerpt only where the reference is followed by a colon and a quoted span, which running prose like this one is not. Read the entry, not this paragraph, if the two ever disagree). """ from __future__ import annotations import unicodedata from dataclasses import dataclass from nameparser._policy import CapsSuffixes from nameparser import (FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST, GIVEN_FIRST, Policy) # Not in nameparser.__all__: _order_repr renders a name_order for an # error message, Script/_SCRIPT_RANGES/_script_matcher build the same # borrowed predicates build_cjk_corpus.py uses to find CJK text. from nameparser._policy import (PatronymicRule, Script, _SCRIPT_RANGES, _order_repr, _script_matcher) #: rules/O6's switch, which every row of its own carries (#754). _TWO = Policy(two_family_names=True) #: mirrors tools/differential/shapes.py's SHAPES keys; #: test_case_shape_ids_exist_in_the_inventory (test_corpora.py) #: holds the two equal, since this file cannot import tools/. _SHAPE_IDS = frozenset({1, 2, 3, 4, 5, 6, 7}) def _has_ascii_letter(text: str) -> bool: """True when text contains an ASCII a-z/A-Z letter. Shapes 6/7's purity check calls this ALONGSIDE a comma test and a residue test that takes every OTHER non-space ASCII character -- this function tests neither a comma nor a non-ASCII Latin letter on its own. The ASCII restriction is deliberate: a diacritic or a letter outside a-z/A-Z is not what a Latin WRAPPER around CJK text looks like in the corpus today (title/credential vocabulary is ASCII), and widening this is a call for whichever future row needs it.""" return any(c.isascii() and c.isalpha() for c in text) def _stray_ascii(text: str) -> str: """The distinct ASCII characters in text other than a space, sorted, as one string ("" when there are none). The residue arm of shapes 6/7's purity test: the comma and the ASCII letter keep messages of their own because each names a composed form with its own doctrine, and this takes everything else -- a period, a digit, a parenthesis. A space is the one ASCII character a pure CJK arrangement writes (rules/W4's '山田 太郎'), so it is the one exemption.""" return "".join(sorted({c for c in text if c.isascii() and c != " "})) #: Shape 7's other admission besides an explicit divider: a #: transcription written wholly in katakana with no dividing #: punctuation at all (e.g. "マイケルジャクソン" or the spaced #: "マイケル ジャクソン"). Built on the parser's own predicate -- #: _script_matcher(Script.KATAKANA, whole=True) -- rather than a #: hand-copied codepoint range, so the KATAKANA span lives in exactly #: one place (nameparser._policy._SCRIPT_RANGES). That table's choice, #: not this file's: since #594 it classifies halfwidth katakana #: (U+FF65-U+FF9F) as katakana too, so a wholly-halfwidth text is a #: shape-7 transcription here exactly as its full-width twin is. #: Applied to the text with whitespace stripped, so a spaced #: transcription still counts as wholly katakana; a whitespace-only #: string never reaches this predicate in practice, since the purity #: check's _has_cjk gate (a real classified codepoint) has already #: run by the time shape 7 consults it. _wholly_katakana = _script_matcher(Script.KATAKANA, whole=True) #: Whether a text carries a codepoint the parser's script table #: classifies -- built once, same idiom as build_cjk_corpus.py's #: module-scope `_has_cjk`. _has_cjk = _script_matcher(*_SCRIPT_RANGES) @dataclass(frozen=True) class Case: id: str text: str expect: dict[str, str] # field -> value; absent fields == "" policy: Policy | None = None locale: str | None = None # locale CODE (keeps this table # import-light); mutually exclusive # with policy classification: str = "parity" ambiguities: tuple[str, ...] = () # expected AmbiguityKind values notes: str = "" #: input-shape id from tools/differential/shapes.py (#468/#469). #: Tagging a row puts its text in corpus_shapes.jsonl (projected by #: build_shapes_corpus.py), read under the shape's name_order, so #: the snapshot watches it there. Optional: a row exercising a #: policy fork rather than an input shape stays untagged. shape: int | None = None #: Marks a row's text as TOLERATED input (2026-09-01 CJK demotion): #: read best-effort, rules/W3. This table still asserts its #: expectation, and a behavior change on a tolerated row still edits #: its classification. Mutually exclusive with `shape`. Every #: composed/wrapped CJK form (a comma listing, a Latin title or #: credential around a CJK name, since 2026-09-05 a trailing ASCII #: period on an honorific, and since #323 an edge full stop of any #: width on any CJK name word) is this table's ground for it, #: not shapes 6/7's. Restricted to CJK-bearing text (`_has_cjk`): #: it exists to demote composed/wrapped CJK forms specifically, and #: a Latin row asking for it is a smell until some future arc #: argues otherwise. Its text still goes to corpus_cjk.jsonl, so #: the snapshot shows when its reading moves. tolerated: bool = False def __post_init__(self) -> None: if self.policy is not None and self.locale is not None: raise ValueError( f"{self.id}: policy and locale are mutually exclusive") # A shape CARRIES its order (the 2026-09-01 corpus design), so # a tag that disagrees with the row's own policy would admit a # name to the corpus under an order the row never asserted. if self.shape is not None: if self.shape not in _SHAPE_IDS: raise ValueError(f"{self.id}: unknown shape {self.shape}") if self.shape in (6, 7): self._check_cjk_shape_purity() else: self._check_latin_shape_order() # tolerated is the opposite of a shape tag: a reviewed act # marking a composed/wrapped CJK form as best-effort input # rather than a supported shape. Checked regardless of which # branch above ran (or whether shape was tagged at all), so a # row cannot smuggle both declarations onto one text. if self.tolerated: if self.shape is not None: raise ValueError( f"{self.id}: tolerated is mutually exclusive with " f"shape; a tolerated row is the opposite of admitted") if not _has_cjk(self.text): raise ValueError( f"{self.id}: tolerated requires CJK text (a " f"classified codepoint _has_cjk recognizes); it " f"exists for the CJK comma demotion, and a Latin " f"row asking for it is a smell until some future " f"arc argues otherwise") def _check_latin_shape_order(self) -> None: """Shapes 1-5: the Latin-order arrangements, each implying a name_order the row's own policy (or its absence) must agree with, and each refusing CJK text outright.""" # Contract: only called from the `if self.shape is not None` # branch above -- restated here (not just implied by the call # site) because it also narrows the type for mypy, which # cannot see across the method boundary on its own. assert self.shape is not None # A locale carries an order too (script_orders), but as a # LOOKUP this table cannot see -- cases.py stays # import-light and stores only the locale CODE. Faking # "declared" as GIVEN_FIRST for a locale row would let a # tag validate against an order nobody here can name. if self.locale is not None: raise ValueError( f"{self.id}: a shape tag needs the row's own " f"policy; a locale carries an order this table " f"cannot see") # Shapes 1-5 are the LATIN-ORDER arrangements -- a title # slot, a comma listing, a suffix run -- and CJK text does # not instantiate one: the arrangement is what the shape id # names, so tagging a CJK string shape 1 asserts a form the # string does not have. The CJK arrangements are shapes 6/7 # (#469's now-settled third-shape question), where the same # text is admitted under a purity test of its own. (That # shapes 6/7 do double-admit into corpus_cjk.jsonl is fine # and deliberate -- the dedup collapses it; double admission # is not what this check is about.) # Order alone cannot stand in for this check -- # DEFAULT_SCRIPT_ORDERS forces HAN/HANGUL/HIRAGANA to # FAMILY_FIRST but leaves KATAKANA unmapped, so a pure- # katakana text can carry a GIVEN_FIRST name_order and # still be CJK ground, not a shape. if _has_cjk(self.text): raise ValueError( f"{self.id}: shape {self.shape} cannot tag CJK " f"text; that ground belongs to shapes 6/7 " f"(corpus_cjk.jsonl), not this shape") declared = (self.policy.name_order if self.policy is not None else GIVEN_FIRST) wanted = {4: FAMILY_FIRST, 5: FAMILY_FIRST_GIVEN_LAST}.get( self.shape, GIVEN_FIRST) if declared != wanted: if self.policy is not None: declared_desc = ( f"the row's policy declares {_order_repr(declared)}") else: declared_desc = ("this row declares no policy, so it " "is GIVEN_FIRST") raise ValueError( f"{self.id}: shape {self.shape} implies name_order " f"{_order_repr(wanted)}, but {declared_desc}; add " f"policy=Policy(name_order={_order_repr(wanted)}) or " f"drop the tag") def _check_cjk_shape_purity(self) -> None: """Shapes 6/7 (2026-09-01): the CJK arrangements, admitted wholly classified-script text only -- no ASCII character at all except the space between two name words (WIDENED 2026-09-05: the check read 'no comma, no Latin letter', which admitted the trailing-period honorifics '田中さん 様.' and '김민준 씨.' -- a listing artifact no writing system produces, and the same class as the forms it was already refusing). Every composed/wrapped form is tolerated=True's ground, not a shape tag's, so this REFUSES rather than requires a particular arrangement beyond that purity test (plus shape 7's divider/katakana requirement, and shape 6's interpunct refusal, below).""" # Contract: only called from the `if self.shape in (6, 7)` # branch above -- restated here (not just implied by the call # site) because it also narrows the type for mypy, which # cannot see across the method boundary on its own. assert self.shape in (6, 7) # A zh-pack row exercises a locale FORK (the segmenter, an # opt-in policy choice), not an input shape: the default- # policy reading of the same string is what the shape admits, # so shape 6/7 rows carry neither. (Nothing separately checks # self.policy here because shapes 6/7's order is None -- # there is no order for a policy to agree or disagree with -- # so a stray policy would silently do nothing; refusing both # together keeps the row's intent legible.) if self.policy is not None or self.locale is not None: raise ValueError( f"{self.id}: shape {self.shape} rows carry neither " f"policy nor locale; a zh-pack row exercises a locale " f"fork, not an input shape") if not _has_cjk(self.text): raise ValueError( f"{self.id}: shape {self.shape} requires a classified " f"codepoint (CJK text); {self.text!r} carries none") if "," in self.text: raise ValueError( f"{self.id}: shape {self.shape} refuses a comma; " f"composed comma forms belong under tolerated=True, " f"not a shape tag") if _has_ascii_letter(self.text): raise ValueError( f"{self.id}: shape {self.shape} refuses a Latin " f"letter; Latin-wrapped compositions belong under " f"tolerated=True, not a shape tag") # The residue, after the two forms with doctrine of their own: # a space is the only ASCII character a pure CJK arrangement # writes, so anything else ASCII came in with a convention # from elsewhere -- a trailing period, a digit, a bracket -- # and is a composed form whatever it is called. stray = _stray_ascii(self.text) if stray: raise ValueError( f"{self.id}: shape {self.shape} refuses the ASCII " f"{stray!r} ({self.text!r}); a space is the only " f"ASCII character a pure arrangement carries, and " f"composed forms belong under tolerated=True, not a " f"shape tag") # U+00B7 (间隔号) marks a name transcription, which keeps the # DECLARED order: rules/W4's Except bullet "A name the # interpunct divides", the clause shape 7's U+00B7 half is # checked against here, where shape 6 is checked against W4's family-first Then # (decisions/W4). The fullwidth nakaguro U+30FB # is NOT a transcription marker on its own: decisions/T3 scopes that reading # to the codepoint, so U+30FB on non-katakana text is an # ordinary Han/Hangul separator and the text reads family- # first (cases.py's own ja_nakaguro_han_takes_the_han_order, # '高橋・一郎', pins exactly this). A wholly-katakana text # DOES read as a transcription regardless of whether it # happens to contain U+30FB internally -- that admission comes # from being wholly katakana, not from the nakaguro -- so # _wholly_katakana already covers the katakana case and U+30FB # is not tested as a divider here at all. has_divider = "·" in self.text stripped = "".join(self.text.split()) is_transcription = has_divider or _wholly_katakana(stripped) if self.shape == 6: if is_transcription: raise ValueError( f"{self.id}: shape 6 refuses U+00B7 and wholly-" f"katakana text; that reads source order and " f"belongs to shape 7") else: if not is_transcription: raise ValueError( f"{self.id}: shape 7 requires U+00B7 or wholly-" f"katakana text; {self.text!r} has neither") _ES = Policy(patronymic_rules=frozenset({PatronymicRule.EAST_SLAVIC})) _TK = Policy(patronymic_rules=frozenset({PatronymicRule.TURKIC})) # " | ", not " - ": a hyphen standing alone is a built-in delimiter (#889), # so only an undeclared core shows the declared path. _SD = Policy(extra_suffix_delimiters=frozenset({" | "})) CASES: tuple[Case, ...] = ( Case("plain", "John Smith", {"given": "John", "family": "Smith"}, notes="shape 1's bare Given Family arrangement, the floor the " "other shape-1 rows vary from", shape=1), Case("family_comma", "Smith, John", {"given": "John", "family": "Smith"}, notes="shape 2's bare Family, Given arrangement", shape=2), Case("suffix_comma", "John Smith, PhD", {"given": "John", "family": "Smith", "suffix": "PhD"}, notes="shape 3's bare Given Family, Suffix arrangement", shape=3), Case("bound_given_pairwise_only", "Salem, Abdul Rahman Ahmed", {"given": "Abdul Rahman", "middle": "Ahmed", "family": "Salem"}, notes="the bound-given join is PAIRWISE (one merge, v1 " "parity): the third piece stays a middle name. Shape " "2's post-comma given slot, at the arity where the " "join stops", shape=2), Case("family_comma_three_part_trailing_strict", "Smith, John V, Jr.", {"given": "John", "middle": "V", "family": "Smith", "suffix": "Jr."}, notes="the lenient trailing test applies only to TWO-part " "names; a third comma part makes the trailing token a " "middle initial (v1 parity, pinned live 2026-07-17). " "Shape 2's trailing suffix WITH the optional comma " "written; family_comma_run_with_a_name_is_not_a_run is " "the spelling without it", shape=2), Case("triple_trailing_commas", "Doe,,,", {"family": "Doe"}, notes="one trailing comma is cosmetic; the rest are " "structural empties (pinned live 2026-07-17)"), Case("paren_suffix_word_escapes_nickname", "John Smith (Esq)", {"given": "John", "family": "Smith", "suffix": "Esq"}, notes="the suffix_words branch of the delimited-content " "escape (v1 parity, pinned live 2026-07-17)"), Case("the_removed_esq_spelling_returns_as_a_credential", "John Smith E.S.Q.", {"given": "John", "family": "Smith", "suffix": "E.S.Q."}, classification="fix(#516)", ambiguities=("suffix-or-name",), notes="1.4.0 RESTORED a SECOND time, by a different route " "than it lost it. 'esq' left SUFFIX_ACRONYMS 2026-09-08: " "Esquire is a contraction, not an initialism, the entry " "arrived in the 2019 bulk post-nominal import " "(af5bdab, #93), and the multi-dot spelling was its " "only unique coverage -- so for one bundle this read " "family 'E.S.Q.' (a deliberate 2.x parity break, " "decisions/suffix-acronym-collisions; same criterion " "as the rai/cha rows above, asked of the machinery " "instead of a surname: does the entry describe the " "WORD or the set's normalization; the bundle that " "carried the removal is #489/#316). #516's by-shape " "class reads it again now, with no wordlist entry at " "all: an unlisted dotted token of three single-letter " "chunks joins the ambiguous class by SHAPE and the " "words-to-spare count reads it as the credential it is", shape=1), Case("suffix_acronym_multidot_after_a_family_comma", "Smith, E.S.Q.", {"given": "E.S.Q.", "family": "Smith"}, classification="parity", ambiguities=("suffix-or-name",), notes="the other path the same removal moved, once -- with " "'esq' in SUFFIX_ACRONYMS the multi-dot spelling was a " "suffix piece, so the post-comma segment held no name " "word and read suffix 'E.S.Q.' (2.0.0 through 2.2.0); " "out of the set it briefly restored 1.4.0 instead, " "first 'E.S.Q.' / last 'Smith' (measured 2026-09-09). " "#516's by-shape class now reads the SAME single " "pre-comma word as a candidate for the ambiguous class " "-- one word is never enough to flip the structure " "(rules/C4), so the FIELDS stay at the 1.4.0 " "reading, but the class is now considered, and the " "consideration reports: the parity note above is about " "the reading, not about whether a fork was called", shape=2), Case("removed_ph_fragment_leaves_a_bare_trailing_ph_a_name_word", "John Smith Ph.", {"given": "John", "middle": "Smith", "family": "Ph."}, classification="fix(#459)", notes="the accepted cost of 'ph' leaving SUFFIX_ACRONYMS " "(#459/#521, 2026-09-13), pinned so the reversal is " "visible. The fragment let the merged 'Ph. D.' token pass " "the acronym test on its first piece, and #459's all-caps " "repair would have read 'john smith ph. d.' as " "'John Smith PH. D.'; the merge itself keys on " "_pipeline/_vocab.py's split_pair and never needed the " "entry. What the entry uniquely carried was a bare " "trailing 'Ph.' -- no 'D.' behind it -- as a credential, " "a spelling nobody writes; out of the set it falls to the " "positional read, family 'Ph.', where every release since " "1.4.0 read suffix. Same criterion as the esq rows above, " "asked of the machinery: the entry described the merge, " "not a word. decisions/vocabulary-exclusions, Excluded " "(SUFFIX_ACRONYMS -- ph)", shape=1), Case("suffix_word_esq_still_reads_as_a_suffix", "John Smith Esq", {"given": "John", "family": "Smith", "suffix": "Esq"}, notes="the other half of the row above, and what the removal " "rests on: the SUFFIX_WORDS membership carries every " "single-token spelling on its own, so it stops being " "inert rather than becoming dead. Deleting 'esq' there " "too is what this row refuses. 'Esquire' rides the " "same membership and is deliberately unpinned, being " "one more word in a set rather than a fork " "(mechanisms.md#VOCABULARY-EXERCISES-FORKS); the " "dotted 'John Smith Esq.', the comma form 'Smith, " "Esq.' and the leading 'Esq. Smith' each have a row of " "their own below"), Case("bound_given_whole_segment", "salem, abdul salam", {"given": "abdul salam", "family": "salem"}, notes="v1 joins bound given names freely in the post-comma " "segment (reserve_last=False, parser.py:1366) -- even " "when the join consumes the whole segment"), Case("middle_as_family_fold_order", "Hassan, Mohamad Ahmad Ali", {"given": "Mohamad", "family": "Ahmad Ali Hassan"}, policy=Policy(middle_as_family=True), notes="v1 PREPENDED middle_list to last_list; folded tokens " "carry vocab:folded-middle and every view of them orders " "them first (spans cannot reorder). This table has no " "initials column, so the half #408 moved is pinned in " "rules/R3 and tests/v2/test_render.py: initials were " "'M. H. A. A.' here and are 'M. A. A. H.' now, which is " "also 1.4.0's answer. Since #484 the differential compares " "initials() too, for names whose roles are identical -- " "but only under the policies it runs, and middle_as_family " "is not one, so this row stays pinned here"), # rules/O6, the two_family_names switch (#754): off by default, so # every row carries the policy, and none is v1-expressible. Case("two_family_names_takes_the_word_before_the_family", "Gabriel García Márquez", {"given": "Gabriel", "family": "García Márquez"}, policy=_TWO, classification="feat(#754)", notes="the default reads middle 'García', family 'Márquez'"), Case("two_family_names_leaves_the_middles_before_it", "Ana María García López", {"given": "Ana", "middle": "María", "family": "García López"}, policy=_TWO, classification="feat(#754)"), Case("two_family_names_counts_a_particle_with_its_word", "Juan Pérez de la Vega", {"given": "Juan", "family": "Pérez de la Vega"}, policy=_TWO, classification="feat(#754)"), Case("two_family_names_keeps_a_family_already_two_words_after_a_particle", "Ana María de la Vega Rodríguez", {"given": "Ana", "middle": "María", "family": "de la Vega Rodríguez"}, policy=_TWO, classification="feat(#754)", notes="P2's chain made 'de la Vega Rodríguez' one piece, and " "O6 counts its words, not its pieces"), Case("two_family_names_counts_a_connective_join_as_its_words", "José María Ortega y Gasset", {"given": "José", "middle": "María", "family": "Ortega y Gasset"}, policy=_TWO, classification="feat(#754)"), Case("two_family_names_counts_a_hyphenated_family_as_its_words", "Ana María García-López", {"given": "Ana", "middle": "María", "family": "García-López"}, policy=_TWO, classification="feat(#754)"), Case("two_family_names_counts_a_trailing_suffix_as_no_name_word", "Gabriel García Márquez Jr.", {"given": "Gabriel", "family": "García Márquez", "suffix": "Jr."}, policy=_TWO, classification="feat(#754)"), Case("two_family_names_counts_a_title_as_no_name_word", "Dr. Ana García López", {"title": "Dr.", "given": "Ana", "family": "García López"}, policy=_TWO, classification="feat(#754)"), Case("two_family_names_reads_the_name_before_a_suffix_comma", "Gabriel García Márquez, PhD", {"given": "Gabriel", "family": "García Márquez", "suffix": "PhD"}, policy=_TWO, classification="feat(#754)"), Case("two_family_names_leaves_a_family_comma_alone", "López, Ana García", {"given": "Ana", "middle": "García", "family": "López"}, policy=_TWO, classification="parity"), Case("two_family_names_leaves_a_two_word_name_alone", "Ana García", {"given": "Ana", "family": "García"}, policy=_TWO, classification="parity"), Case("two_family_names_takes_the_first_two_words_family_first", "García Márquez Gabriel", {"given": "Gabriel", "family": "García Márquez"}, policy=Policy(name_order=FAMILY_FIRST, two_family_names=True), classification="feat(#754)", notes="the remainder is laid out as P1's family-first fold " "lays its own: the positions after a family"), Case("two_family_names_takes_the_first_two_words_family_first_given_last", "García Márquez Ana María", {"given": "María", "middle": "Ana", "family": "García Márquez"}, policy=Policy(name_order=FAMILY_FIRST_GIVEN_LAST, two_family_names=True), classification="feat(#754)"), Case("two_family_names_runs_before_middle_as_family", "Ana María García López", {"given": "Ana", "family": "María García López"}, policy=Policy(two_family_names=True, middle_as_family=True), classification="feat(#754)", notes="given-first, the two switches together read as " "middle_as_family alone"), Case("two_family_names_counts_a_nickname_as_no_name_word", "Ana García (Anita) López", {"given": "Ana", "family": "García López", "nickname": "Anita"}, policy=_TWO, classification="feat(#754)"), Case("two_family_names_takes_a_word_before_a_particle_family", "Ana María van der Berg", {"given": "Ana", "family": "María van der Berg"}, policy=_TWO, classification="feat(#754)", notes="O6 counts words, not languages: 'van der Berg' is one " "family word, so the word before it joins"), Case("two_family_names_takes_the_word_after_a_family_first_fold", "de Mesnil Jean Paul", {"given": "Paul", "family": "de Mesnil Jean"}, policy=Policy(name_order=FAMILY_FIRST, two_family_names=True), classification="feat(#754)", notes="P1's family-first fold leaves family 'de Mesnil', given " "'Jean', middle 'Paul'; O6 then takes one unit more"), Case("two_family_names_moves_a_particle_chain_whole_or_not_at_all", "Juan de la Vega Rodríguez", {"given": "de la Vega Rodríguez", "family": "Juan"}, policy=Policy(name_order=FAMILY_FIRST, two_family_names=True), classification="parity", notes="the given name is one P2 chain; taking it whole would " "leave no given name, so O6 declines"), Case("two_family_names_keeps_the_particle_p6_attached", "Beethoven Ludwig Maria van", {"given": "Maria", "family": "van Beethoven Ludwig"}, ambiguities=("particle-or-given",), policy=Policy(name_order=FAMILY_FIRST, two_family_names=True), classification="feat(#754)", notes="P6 attached 'van' to the family at the far end; O6 takes " "the unit after the family and leaves the particle where " "P6 put it, rendered first"), Case("two_family_names_stands_down_where_a_rotation_reordered_the_name", "Ivanov Ivan Ivanovich", {"given": "Ivan", "middle": "Ivanovich", "family": "Ivanov"}, policy=Policy(patronymic_rules=frozenset( {PatronymicRule.EAST_SLAVIC}), two_family_names=True), classification="parity", notes="O1 made the first word the family; O6 asks for a middle " "right before the family and finds none"), Case("two_family_names_leaves_an_initial_before_the_family", "Ana M. García", {"given": "Ana", "middle": "M.", "family": "García"}, policy=_TWO, classification="parity", notes="#754 review: an initial writes a given name"), Case("two_family_names_leaves_a_hyphenated_given_name_before_the_family", "Ana María-José López", {"given": "Ana", "middle": "María-José", "family": "López"}, policy=_TWO, classification="parity", notes="#754 review: the hyphen's two words count against the " "taken word as they count in the family; the first draft " "read family 'María-José López'"), Case("two_family_names_leaves_a_bound_given_pair_before_the_family", "Ana abdul Rahman López", {"given": "Ana", "middle": "abdul Rahman", "family": "López"}, policy=_TWO, classification="parity", notes="#754 review: a P5 pair is one GIVEN name"), Case("two_family_names_counts_an_en_dash_as_a_hyphen", "Ana María García–López", {"given": "Ana", "middle": "María", "family": "García–López"}, policy=_TWO, classification="parity", notes="#754 review: the spelling twin of the hyphen row"), Case("two_family_names_counts_a_fullwidth_hyphen_as_a_hyphen", "Ana María García\uff0dLópez", {"given": "Ana", "middle": "María", "family": "García\uff0dLópez"}, policy=_TWO, classification="parity", notes="#875 review: the fullwidth twin of the hyphen row; it " "counted as one word and took 'María'"), Case("two_family_names_takes_no_spaced_dash", "Ana María García - López", {"given": "Ana", "middle": "María García -", "family": "López"}, policy=_TWO, classification="parity", notes="#754 review: the dash is no family name; the first " "draft read family '- López'"), Case("two_family_names_leaves_a_name_its_script_ordered", "王 小 明", {"given": "小", "middle": "明", "family": "王"}, policy=_TWO, classification="parity", notes="#754 review: W4's order is not the caller's declaration, " "and the first draft read family '王 小'"), Case("two_family_names_takes_run_together_initials", "Ana J.R. García", {"given": "Ana", "family": "J.R. García"}, policy=_TWO, classification="feat(#754)", notes="#754 second review: only an initial written alone is " "refused (rules/O6 Accepted)"), Case("two_family_names_takes_no_number", "Ana María 3 López", {"given": "Ana", "middle": "María 3", "family": "López"}, policy=_TWO, classification="parity", notes="#754 second review: the mark test asks for a letter"), Case("two_family_names_leaves_an_empty_head_comma_alone", ", Ana García López", {"given": "Ana", "middle": "García López"}, policy=_TWO, classification="parity", notes="#754 review: a family comma with nothing before it is " "still a family comma, and the switch reads it as the " "default does"), Case("two_family_names_reads_a_patronymic_family_first", "Petrov Ivan Sergeevich", {"given": "Sergeevich", "family": "Petrov Ivan"}, policy=Policy(name_order=FAMILY_FIRST, patronymic_rules=frozenset( {PatronymicRule.EAST_SLAVIC}), two_family_names=True), classification="feat(#754)", notes="#754 review: the rotation stands down under a declared " "family-first order, so nothing has decided the family " "and O6 reads the name as any other (rules/O6 Accepted)"), Case("two_family_names_reads_a_compound_given_name_as_a_family", "Juan Carlos Pérez", {"given": "Juan", "family": "Carlos Pérez"}, policy=_TWO, classification="feat(#754)", notes="rules/O6's accepted cost: one family name behind a " "two-word given name is the shape the switch breaks"), # MOVED by #289, not deleted: 'MA' is written in capitals inside a # mixed-case name, so it now leans CREDENTIAL and is taken with no # words to spare -- the reserve/count this row used to pin no # longer decides here, the written case does (decisions/S6). Case("ambiguous_surname_acronyms", "Jack MA", {"given": "Jack", "suffix": "MA"}, classification="fix(#289)", ambiguities=("given-or-family",), notes="the defect #289 reports: an all-caps member of the " "ambiguous set inside a mixed-case name is written the " "way a credential is written, so it is taken as one " "even though peeling it leaves no family -- what " "'Jack MD' has always done with an unambiguous one. " "'Jack' is then the only name word left, which is also " "what turns on GIVEN_OR_FAMILY. 1.4.0 read LAST 'MA' " "here (family 'MA'), so this row BREAKS 1.4.0 parity " "deliberately, in the direction the East Asian surname " "wants (decisions/S6) -- corrected 2026-09-17, F3 " "review finding: an earlier version of this note " "wrongly claimed the opposite"), Case("ambiguous_surname_acronym_with_suffix", "Jack MA Jr", {"given": "Jack", "suffix": "MA Jr"}, classification="fix(#289)", ambiguities=("given-or-family",), notes="'Jr' peels first as unambiguous vocabulary; 'MA' is " "then the last piece, and the caps lean takes it with " "no words to spare rather than leaving it the family " "the old reserve kept (decisions/S6) -- both suffix " "words render as one run"), Case("ambiguous_acronym_is_a_suffix_when_a_family_name_remains", "John Smith MA", {"given": "John", "family": "Smith", "suffix": "MA"}, notes="the other half of the same rule: three pieces means " "peeling 'MA' still leaves given+family, so the " "credential reading wins (v1 parity)"), # -- #342: rai and cha left SUFFIX_ACRONYMS. The criterion is in # decisions/suffix-acronym-collisions and it compares # FREQUENCIES: how common the word is as a borne name in the # trailing position against how common it is as a credential. # Rough balance earns the ambiguous marking (the members of # SUFFIX_ACRONYMS_AMBIGUOUS; the frozenset is the roster); # the name reading dominating REMOVES the entry, a caller adding # it back with Lexicon.default().add(suffix_acronyms={"cha"}); # the credential dominating leaves it unambiguous. Length is a # correlate, not the test. These rows pin # the FORK, not the entries (mechanisms.md#VOCABULARY-EXERCISES-FORKS). Case("bare_surname_is_not_a_credential", "Aishwarya Rai", {"given": "Aishwarya", "family": "Rai"}, classification="parity", notes="#342's own subject: 'rai' arrived in the 2019 bulk " "wikipedia post-nominal import and was never reviewed " "against the surname, so 2.0.0 through 2.2.0 read " "suffix 'Rai' with NO family name at all. 1.4.0 read " "family 'Rai' and this restores that reading, which is " "why it classifies parity rather than fix. Named " "in the release note"), Case("removed_credential_loses_its_suffix_reading", "John Smith RAI", {"given": "John", "middle": "Smith", "family": "RAI"}, classification="fix(#342)", notes="the accepted cost, pinned so the reversal is visible. " "RAI is a real if specialized credential and a full " "name in front of it used to be enough to read it as " "one; with the entry gone the all-caps shape carries " "no signal the parser reads, so the word families and " "'Smith' becomes a middle name. The shape-plus-position " "heuristic that would recover it is a parking-lot " "bullet of decisions/suffix-acronym-collisions"), Case("removed_credential_after_a_comma_reads_by_its_capitals", "Ahmad Jayadi, CHA", {"given": "Ahmad", "family": "Jayadi", "suffix": "CHA"}, classification="fix(#564)", ambiguities=("suffix-or-name",), notes="with 'cha' gone from the vocabulary (#342) the word is " "unlisted, and since #564 an unlisted all-caps word " "after a comma behind two name words is a credential by " "default (rules/C6, S9), so the comma is a suffix " "comma again and reports the call. 2.3.0 read given " "'CHA', family 'Ahmad Jayadi'; 'John Smith, RAI' moves " "with it"), Case("removed_credential_loses_the_dotted_spelling_too", "John Smith C.H.A.", {"given": "John", "family": "Smith", "suffix": "C.H.A."}, classification="fix(#516)", ambiguities=("suffix-or-name",), notes="for one bundle the removal reached the DOTTED spelling " "through S3's period fold, which strips the periods to " "reach the entry -- so with 'cha' gone this read family " "(the contrast was 'John Smith R.A.I.', which kept " "reading suffix as an accident of 'i' being a " "SUFFIX_WORDS numeral rather than a survival of the " "vocabulary, decisions/suffix-acronym-collisions). " "#516's by-shape class now reads BOTH the same way, by " "POSITION rather than by any chunk claim -- three " "single-letter chunks, none of them vocabulary, still " "join the ambiguous class by shape and the words-to-" "spare count reads this one a credential too", shape=1), # -- #687: 'mai' left SUFFIX_ACRONYMS by the same criterion. Mai is # a Vietnamese given and family name, a Chinese surname and a # Japanese given name; MAI (Member, Appraisal Institute) is a # specialized US credential. The name reading dominates, so the # entry is removed rather than marked. Case("trailing_given_name_mai_is_not_a_credential", "Tran Thi Mai", {"given": "Mai", "middle": "Thi", "family": "Tran"}, policy=Policy(name_order=FAMILY_FIRST_GIVEN_LAST), classification="fix(#687)", notes="every release read suffix 'Mai' here, so a Vietnamese " "name in its own declared order lost the name the " "person is addressed by. 1.4.0 has no name_order and " "read suffix 'Mai' for the default order too"), Case("bare_family_name_mai_keeps_the_family", "Nguyen Mai", {"given": "Nguyen", "family": "Mai"}, notes="1.4.0's reading, which 2.0.0 through 2.4.2 lost to the " "MAI entry"), Case("removed_mai_credential_loses_its_suffix_reading", "John Smith MAI", {"given": "John", "middle": "Smith", "family": "MAI"}, classification="fix(#687)", notes="the accepted cost, as 'John Smith RAI' above: with no " "comma or periods the all-caps shape carries no signal " "the parser reads. 'Smith, John, MAI' and 'John Smith " "M.A.I.' still read suffix, by the comma and the " "dotted shape"), # -- #342: 'ba' is the other half of the same decision. BA is a # common credential and Ba a real surname (Vietnamese; Senegalese # Fula), which is the ma/Ma shape exactly, so it takes the # marking rather than the removal. Two rows, the two halves of # S4's words-to-spare guard; the release note names both texts. Case("bare_ba_is_a_surname", "Anna Ba", {"given": "Anna", "family": "Ba"}, ambiguities=("suffix-or-name",), classification="parity", notes="the marking's point: with only two pieces, 'one of " "them is a credential' is the less likely reading, so " "S4's words-to-spare guard keeps the family name and " "the parse reports which reading it took. 2.0.0 " "through 2.2.0 read suffix 'Ba' with no family name; " "1.4.0 read family 'Ba' unflagged, so this row " "restores 1.4.0's roles and adds the flag"), # MOVED by #289, not deleted: 'BA' is written in capitals inside a # mixed-case name, so it now leans CREDENTIAL and the post-comma # slot takes it with one word to spare where the count alone would # not -- back to what 2.0.0 through 2.2.0 read, though for a # different reason (decisions/S6, Derek's own #289 comment: # positive evidence outranks position here). Case("comma_ambiguous_acronym_ba", "Smith, BA", {"family": "Smith", "suffix": "BA"}, classification="fix(#289)", notes="the marking's comma cost, and it is 'Smith, Ed' " "arriving for a second word: with 'ba' ambiguous, S4 " "declines the post-comma credential and C4 reads it as " "the given name. 2.0.0 through 2.2.0 read family " "'Smith', suffix 'BA'; 1.4.0 read given 'BA'. #289 moves " "this row again: the caps lean now reads 'Smith, BA' the " "way a business form is written"), Case("ba_is_a_suffix_when_a_family_name_remains", "John Smith BA", {"given": "John", "family": "Smith", "suffix": "BA"}, notes="the words-to-spare half: a full name in front leaves the " "credential reading available, so the role does NOT move; " "the flag the fork carried went with #785, the capitals " "settling the word (rules/S6). The twin of " "ambiguous_acronym_is_a_suffix_when_a_family_name_remains " "above"), # -- #791: 'si' and 'chi' take the marking 'ba' took. Both are common # surnames (Census 2010: Si 1,166, Chi 7,273) and credentials with no # recorded expansion, so the criterion marks rather than removes # (decisions/suffix-acronym-collisions, 2026-10-21). The bare and # comma forms pin the fork; the contrasting spellings pin that the # credential still wins with words to spare. Like the #540 rows # below, the bare and comma rows are shape-tagged so the snapshot sees them. Case("bare_si_is_a_surname", "Luo Si", {"given": "Luo", "family": "Si"}, ambiguities=("suffix-or-name",), notes="#791's subject: two pieces leave no family name if Si " "is a credential, so S4's words-to-spare guard keeps the " "surname and reports the fork. 2.0.0 and 2.5.0 read " "suffix 'Si' with no family name, 2.0.0 reporting nothing " "and 2.5.0 reporting given-or-family; 1.4.0 read family " "'Si' unflagged, so " "this row restores 1.4.0's roles and adds the flag", shape=1), Case("bare_chi_is_a_surname", "Min Chi", {"given": "Min", "family": "Chi"}, ambiguities=("suffix-or-name",), notes="the same fork for Chi, with the same history: suffix " "and no family at 2.0.0 and 2.5.0 (2.5.0 reporting " "given-or-family, 2.0.0 nothing), family 'Chi' at 1.4.0", shape=1), Case("comma_si_is_a_given_name", "Wang, Si", {"given": "Si", "family": "Wang"}, ambiguities=("suffix-or-name",), notes="the comma twin of bare_si_is_a_surname: 2.0.0 through " "2.5.0 read suffix 'Si' with no given name and reported " "nothing; 1.4.0 read given 'Si'", shape=2), Case("comma_chi_is_a_given_name", "Smith, Chi", {"given": "Chi", "family": "Smith"}, ambiguities=("suffix-or-name",), notes="the comma twin of bare_chi_is_a_surname, with the same " "history: suffix 'Chi' and no given name through 2.5.0; " "given 'Chi' at 1.4.0", shape=2), Case("capital_si_is_a_suffix_when_a_family_name_remains", "John Smith SI", {"given": "John", "family": "Smith", "suffix": "SI"}, notes="the words-to-spare half, and the capitals lean: SI is " "written in capitals inside a mixed-case name, so the " "credential reading is the stronger one and the role does " "NOT move; the flag it carried went with #785, the capitals " "settling the word (rules/S6). 1.4.0 read the same suffix"), Case("comma_capital_si_is_a_suffix", "John Smith, SI", {"given": "John", "family": "Smith", "suffix": "SI"}, notes="the comma spelling of the row above, which 1.4.0 read as " "suffix 'SI' too; it reports nothing since #785, the " "capitals settling it (rules/S6)"), Case("dotted_si_is_a_suffix_without_a_flag", "John Smith S.I.", {"given": "John", "family": "Smith", "suffix": "S.I."}, notes="the dotted shape (rules/S3) is the one spelling the " "marking leaves unflagged: one period after each letter " "is the credential's own writing, so the gate needs no " "count. 1.4.0 read the same suffix"), Case("title_case_si_is_a_surname_with_words_to_spare", "John Smith Si", {"middle": "Smith", "family": "Si", "given": "John"}, ambiguities=("suffix-or-name",), classification="fix(#791)", notes="the cost of the marking, and it is ma/Ma's: in a name " "written in more than one case a Title-case member is " "the name even with words to spare (rules/S6), so " "'Smith' drops to middle and 'Si' takes the family. " "2.0.0 through 2.5.0 read suffix 'Si' with family " "'Smith'; 1.4.0 read suffix 'Si' too, so this row moves " "a reading that every release read as a credential"), Case("period_si_after_a_full_name_is_a_surname", "John Smith Si.", {"given": "John", "middle": "Smith", "family": "Si."}, ambiguities=("suffix-or-name",), classification="fix(#791)", notes="one trailing period does not settle it: the abbreviation " "shape any word can wear (rules/S3), so the marked member " "takes the surname the way 'Ma.' does in 'John Smith Ma.'. " "1.4.0 read suffix 'Si.' with family 'Smith'; 2.0.0 and " "2.5.0 read the same suffix"), Case("credential_run_led_by_a_declined_chi", "John Smith Chi PhD", {"given": "John", "middle": "Smith", "family": "Chi", "suffix": "PhD"}, ambiguities=("suffix-or-name",), classification="fix(#791)", notes="the walk-stopping cost #540 records for a declined " "member, now for 'Chi': the pick is declined, so the " "walk stops there and the unambiguous PhD behind it is " "still peeled. 1.4.0 read suffix 'Chi, PhD' and family " "'Smith'"), # -- #796: 'capa' (Census 175, NPPES 11) and its fork. A surname with # no words to spare reads family; the all-caps credential with a # name in front keeps its suffix (rules/S6, the case signal). Case("robert_capa_is_a_surname", "Robert Capa", {"given": "Robert", "family": "Capa"}, ambiguities=("suffix-or-name",), classification="fix(#796)", notes="the surname fork: 'Capa' has no word to spare for a " "credential, so it is the family name and the fork is " "reported. Before #796 it read suffix 'Capa' with no " "family name"), Case("all_caps_capa_is_still_a_credential", "John Smith CAPA", {"given": "John", "family": "Smith", "suffix": "CAPA"}, classification="fix(#796)", notes="the contrasting spelling: an all-caps member of the " "ambiguous set in a name with words to spare keeps its " "credential reading (rules/S6), so the reading is pinned in " "both directions; since #785 it reports nothing, the " "capitals settling the word"), # -- #797: credentials the comma path read as the given name because # no list held them (NPPES and ClinicalTrials.gov counts, in the # entries' notes). Listed, they end the name; a bare ambiguous one # still reads family where the name has no word to spare. Case("comma_mixed_case_credential_is_a_suffix", "John Smith, MBChB", {"given": "John", "family": "Smith", "suffix": "MBChB"}, classification="fix(#797)", notes="a listed mixed-case credential no shape rule catches: " "1.4.0 and 2.5.0 read given 'MBChB', family 'John Smith'"), Case("comma_slash_joined_credential_is_a_suffix", "John Smith, OTR/L", {"given": "John", "family": "Smith", "suffix": "OTR/L"}, classification="fix(#797)", notes="a listed slash-joined credential, matched as one token " "like the listed PA-C; the shape that would read an " "unlisted one stays open in #797"), Case("one_word_head_two_capital_credential", "Smith, DM", {"family": "Smith", "suffix": "DM"}, classification="fix(#797)", notes="the one-word-head comma path (nameparser/AGENTS.md's comma sweep): " "a listed two-capital credential no longer reads as " "paired initials (given 'DM'), as MS and MA do not"), Case("empty_head_joined_credential", ", FNP-C", {"suffix": "FNP-C"}, classification="fix(#797)", notes="the empty-head comma path, with a hyphen-joined word"), Case("comma_pa_keeps_its_suffix_and_reports_the_fork", "JOHN SMITH, PA", {"given": "JOHN", "family": "SMITH", "suffix": "PA"}, classification="fix(#797)", ambiguities=("suffix-or-name",), notes="PA is ambiguous (Census 739 family, SSA 1,343 given), so " "the credential reading is reported as a fork. Written in " "one case since #785: 'John Smith, PA' reads the same " "suffix in silence, its capitals settling the word " "(rules/S6)"), Case("john_pa_is_a_family_name", "John Pa", {"given": "John", "family": "Pa"}, ambiguities=("suffix-or-name",), classification="fix(#797)", notes="the other direction of PA's fork: no word to spare, so " "the family name. Before #797 this read the same, with " "no fork reported"), # -- #848: Ing, the engineering degree of Czech, Slovak, Austrian # and Romanian names, is marked ambiguous as PA is, the surname # reading being substantial (Census 1,814). Case("comma_ing_is_a_credential", "Viera Sevcikova, Ing", {"given": "Viera", "family": "Sevcikova", "suffix": "Ing"}, ambiguities=("suffix-or-name",), classification="fix(#848)", notes="1.4.0 through 2.5.0 read the unlisted word as the given " "name after a family comma: given 'Ing', family 'Viera " "Sevcikova'"), Case("john_ing_is_a_family_name", "John Ing", {"given": "John", "family": "Ing"}, ambiguities=("suffix-or-name",), classification="fix(#848)", notes="the other direction of Ing's fork: no word to spare, so " "the family name, now reported"), Case("family_comma_ing_is_a_given_name", "HO, ING", {"given": "ING", "family": "HO"}, ambiguities=("suffix-or-name",), classification="fix(#848)", notes="a one-word head keeps the family comma, so the bearer of " "the Chinese given name keeps it, now reported"), Case("comma_dotted_ing_beside_a_title_splits", "Martin Hulin, Ing., Mgr., PhD.", {"given": "Martin", "family": "Hulin", "title": "Mgr.", "suffix": "Ing., PhD."}, ambiguities=("suffix-or-name",), classification="fix(#848)", notes="the accepted cost: Ing. after a full name and a comma " "is a credential now, where 2.5.0 read it as a title by its shape " "(rules/H2), as Mgr. still is. Czech writes both " "before the name, so the pair is split across two fields; " "given and family are right either way"), Case("one_word_head_dotted_ing_stays_a_title", "Novak, Ing.", {"family": "Novak", "title": "Ing."}, ambiguities=("suffix-or-name",), classification="fix(#848)", notes="the dotted move's boundary: after a one-word head the " "family comma stands and Ing. stays the title it was, now " "reporting the fork"), Case("one_word_head_capitals_ing_is_a_credential", "Novak, ING.", {"family": "Novak", "suffix": "ING."}, classification="fix(#848)", notes="the capitals lean (rules/S6): in a name not written " "wholly in capitals, ING. reads as MA. and PA. do, the " "credential even after a one-word head, where 'Novak, " "Ing.' keeps the title; master read title 'ING.'"), Case("comma_dotted_initials_ing_are_a_credential", "Smith, I.N.G.", {"family": "Smith", "suffix": "I.N.G."}, classification="fix(#848)", notes="the accepted cost: the dotted spelling of an ambiguous " "member reads as the credential with no report, as " "'Smith, M.A.' does; before #848 this read given 'I.N.G.', " "reported"), Case("empty_head_si_is_a_given_name", ", Si", {"given": "Si"}, ambiguities=("suffix-or-name",), notes="nameparser/AGENTS.md's empty-head comma grid: with no name before " "the comma the part is the given name, as 1.4.0 read it " "('first Si')"), Case("empty_head_caps_si_is_a_given_name", ", SI", {"given": "SI"}, ambiguities=("suffix-or-name",), notes="the capitals form of the empty-head row, read as the " "given name at 1.4.0 ('first SI')"), Case("nickname_head_si_is_a_given_name", "(Bob), Si", {"nickname": "Bob", "given": "Si"}, ambiguities=("suffix-or-name",), notes="the nickname-only head: the nickname is taken first and " "the part reads as the given name, as 1.4.0 read it " "(nickname 'Bob', first 'Si')"), Case("capital_si_after_a_surname_is_a_suffix", "Luo SI", {"given": "Luo", "suffix": "SI"}, ambiguities=("given-or-family",), classification="fix(#289)", notes="the caps lean (decisions/S6, #289): all-capitals SI is " "the credential even as the only word after a name, so " "the two-piece guard that keeps bare 'Luo Si' a surname " "does not reach it. 1.4.0 read family 'SI'"), Case("comma_capital_si_after_a_surname_is_a_suffix", "Wang, SI", {"family": "Wang", "suffix": "SI"}, classification="fix(#289)", notes="the comma form of the caps lean, as 'Smith, BA' reads " "(#289). 1.4.0 read given 'SI' and family 'Wang'"), Case("family_first_bare_si_is_a_given_name", "Luo Si", {"given": "Si", "family": "Luo"}, policy=Policy(name_order=FAMILY_FIRST), ambiguities=("suffix-or-name",), classification="fix(#791)", notes="the family-first roles of the bare row: the first word " "is the family name, so 'Si' is the given name. 1.4.0 has " "no name order to compare against"), Case("family_first_comma_si_is_a_given_name", "Wang, Si", {"given": "Si", "family": "Wang"}, policy=Policy(name_order=FAMILY_FIRST), ambiguities=("suffix-or-name",), classification="fix(#791)", notes="the comma form under family-first: the comma already " "fixes the family name, so the roles match the default " "order's"), # -- #793: a period-marked ambiguous member alone after a family comma # is bound as a title by the listing form (rules/C8), the class # 'Smith, Dr.' reads in, and its report names the title field; it # had said "read as a credential" of a word in the title. Case("comma_period_si_is_a_title", "John, Si.", {"title": "Si.", "family": "John"}, ambiguities=("suffix-or-name",), classification="fix(comma-family)", notes="#793's subject: the one-word head keeps the family and " "the period-marked 'Si.' is bound as a title, as 'Smith, " "Dr.' is; 1.4.0 read title 'Si.' with first 'John'. The " "report says 'read as a title' (test_cases.py's field " "sweep holds it), where it had said 'read as a credential'"), Case("comma_period_ma_is_a_given_name", "John, Ma.", {"given": "Ma.", "family": "John"}, ambiguities=("suffix-or-name",), classification="fix(#789)", notes="the same shape for the oldest marked member, which is " "also Filipino María written short: a given name written " "short outranks rules/H2's shape (#789), so 'Ma.' is the " "given name where 2.0.0 through 2.6.0 read it as H2's " "title, and 1.4.0 read title 'Ma.' with first 'John'"), # -- #793's comment (the #794 review): the #791 members in a suffix # run, behind a title, written all-lower, and where no report # belongs. These pin today's readings; none moved in #793. Case("comma_si_before_a_generation_is_a_given_name", "Smith, Si Jr.", {"given": "Si", "family": "Smith", "suffix": "Jr."}, ambiguities=("suffix-or-name",), notes="the suffix behind it does not make 'Si' a credential: " "the word after the comma is the given name, as 1.4.0 " "read it"), Case("si_ahead_of_a_generation_is_a_surname", "John Smith Si Jr.", {"given": "John", "middle": "Smith", "family": "Si", "suffix": "Jr."}, ambiguities=("suffix-or-name",), classification="fix(#791)", notes="title_case_si_is_a_surname_with_words_to_spare with a " "generation behind it: the peel takes 'Jr.' and declines " "the Title-case member (rules/S6). 1.4.0 read suffix " "'Si, Jr.' with family 'Smith'"), Case("comma_si_before_a_third_part_is_a_given_name", "Smith, Si, PhD", {"given": "Si", "family": "Smith", "suffix": "PhD"}, ambiguities=("suffix-or-name",), notes="a part past the second comma holds the credential, so " "the given part keeps 'Si', as 1.4.0 read it"), Case("title_head_si_is_a_surname", "Dr. Luo Si", {"title": "Dr.", "given": "Luo", "family": "Si"}, ambiguities=("suffix-or-name",), notes="a title in front is no word to spare (rules/P4), so the " "two-word fork of bare_si_is_a_surname is unchanged; " "1.4.0 read the same roles"), Case("title_head_si_behind_a_surname_shaped_given", "Dr. Smith Si", {"title": "Dr.", "given": "Smith", "family": "Si"}, ambiguities=("suffix-or-name",), notes="the same with a given name that is also a surname: " "position decides, as at 1.4.0"), Case("title_head_capital_si_is_a_suffix", "Dr. Luo SI", {"title": "Dr.", "family": "Luo", "suffix": "SI"}, classification="fix(#289)", notes="capital_si_after_a_surname_is_a_suffix behind a title: " "the caps lean takes SI, and H1 makes the one name word " "left the family. 1.4.0 read first 'Luo', last 'SI'"), Case("one_case_si_with_words_to_spare_is_a_suffix", "john smith si", {"given": "john", "family": "smith", "suffix": "si"}, ambiguities=("suffix-or-name",), notes="the twin of the all-lower meng rows: in a name written " "in one case the count decides, and two words are left " "to spare. 1.4.0 read the same suffix"), Case("leading_si_is_a_given_name_without_a_report", "Si Luo", {"given": "Si", "family": "Luo"}, notes="the absence contrast: the suffix vocabulary claims only " "the last word of a name, so a leading member is a given " "name and nothing is reported. 1.4.0 read the same"), Case("leading_chi_is_a_given_name_without_a_report", "Chi Smith", {"given": "Chi", "family": "Smith"}, notes="the same absence for 'chi'; 1.4.0 read the same"), Case("interior_si_is_a_middle_name_without_a_report", "Luo Si Smith", {"given": "Luo", "middle": "Si", "family": "Smith"}, notes="the interior position, where the member chooses nothing " "(mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE); 1.4.0 " "read the same"), # -- #540: 'meng' and 'lac' take the marking 'ba' took, on the same # criterion (decisions/suffix-acronym-collisions). MEng and LAc # are live credentials; Meng is a Chinese surname and given name, # Lac a Vietnamese given name (the trailing word in native order, # Nguyen Van Lac) and a French surname, and both stand in the one # slot the suffix vocabulary claims, the last word of a name. No rule # moves: these rows are rules/S4's forks read through two more # members, written all-lower so the count decides unless the # writing says otherwise, and shape-tagged so the snapshot sees # them -- no corpus name had either word. Case("bare_meng_is_a_surname", "wang meng", {"given": "wang", "family": "meng"}, ambiguities=("suffix-or-name",), notes="#540's subject: with only two pieces the credential " "reading would leave no family name, so S4's " "words-to-spare guard keeps it and reports the fork. " "2.0.0 through 2.3.0 read suffix 'meng' with no family " "(2.3.0 reporting given-or-family, the one-word-name " "fork, which was the wrong fork); 1.4.0 read family " "'meng' unflagged, so this row restores 1.4.0's roles " "and adds the flag. The twin of bare_ba_is_a_surname", shape=1), Case("bare_lac_is_a_surname", "tran lac", {"given": "tran", "family": "lac"}, ambiguities=("suffix-or-name",), notes="the same fork for the other word, with the same " "history: suffix 'lac' and no family from 2.0.0 " "through 2.3.0, family 'lac' at 1.4.0", shape=1), Case("meng_is_a_suffix_when_a_family_name_remains", "john smith meng", {"given": "john", "family": "smith", "suffix": "meng"}, ambiguities=("suffix-or-name",), notes="the words-to-spare half: a full name in front leaves " "the credential reading available, and a name written " "wholly in one case says nothing about any word in it, " "so the count decides. The role does NOT move -- every " "release read suffix 'meng' -- only the flag is new", shape=1), Case("lac_is_a_suffix_when_a_family_name_remains", "nguyen van lac", {"given": "nguyen", "family": "van", "suffix": "lac"}, ambiguities=("suffix-or-name",), notes="the words-to-spare half for 'lac', read by every " "release since 1.4.0 as it is here; only the flag is " "new. The Title-case 'Nguyen Van Lac' does NOT read " "this way: in a name written in more than one case a " "Title-case member is the name even with words to " "spare, so it gives family 'Van Lac' -- the same " "mechanism the next row pins for 'MEng', though for " "this Vietnamese name it is the gain: a person, not " "a credential", shape=1), Case("mixed_case_meng_after_a_full_name_reads_as_the_name", "john smith MEng", {"given": "john", "middle": "smith", "family": "MEng"}, classification="fix(#540)", ambiguities=("suffix-or-name",), notes="the marking's accepted cost, stated rather than " "discovered: in a name written in more than one case, " "S6 reads a member written neither in capitals nor all " "lower as the name even with words to spare, and MEng " "is conventionally written exactly that way. Every " "release from 1.4.0 read suffix 'MEng'. The twin of " "S6's 'John Smith Ma'; 'John Smith MENG' keeps the " "credential on the capitals lean", shape=1), Case("comma_mixed_case_meng_reads_as_a_middle_name", "Smith, John MEng", {"given": "John", "middle": "MEng", "family": "Smith"}, classification="fix(#540)", ambiguities=("suffix-or-name",), notes="the marking's comma cost: it is the same S6 case " "reading the row above pins, arriving after a family " "comma, where C4 has already settled the word count " "and the writing is the only evidence left. Every " "release from 1.4.0 read suffix 'MEng'. 'John Smith, " "MEng' keeps the suffix on C4's name-word count", shape=2), Case("comma_lone_meng_reads_as_the_given_name", "Smith, meng", {"given": "meng", "family": "Smith"}, ambiguities=("suffix-or-name",), notes="the marking's one-word-before-the-comma path, the " "reading 'Smith, Ma' gets, arriving for another " "member: with the word ambiguous, S4 declines the " "post-comma credential and, with the count leaving it " "a name, C4 reads it as the given name. 2.0.0 through " "2.3.0 read " "family 'Smith', suffix 'meng'; 1.4.0 read given " "'meng', so this row restores 1.4.0's reading and " "adds the flag", shape=2), Case("comma_lone_mixed_case_meng_reads_as_the_given_name", "Smith, MEng", {"given": "MEng", "family": "Smith"}, ambiguities=("suffix-or-name",), notes="the same path for the conventional spelling, which " "is the cost side of it: a writer who meant the " "credential gets given 'MEng' instead, where 'Smith, " "MENG' keeps the suffix on the capitals lean and " "'Smith, John MEng' " "(comma_mixed_case_meng_reads_as_a_middle_name) reads " "it as a middle name. 1.4.0 read given 'MEng' too, so " "this row also restores 1.4.0's reading", shape=2), Case("comma_credential_run_ending_in_meng_keeps_the_suffix_comma", "John Smith, PhD MEng", {"given": "John", "family": "Smith", "suffix": "PhD MEng"}, ambiguities=("suffix-or-name",), notes="C4's name-word count reads a RUN as it reads one word " "(#544): every word after the comma is suffix " "vocabulary or a class member, and two name words " "stand before it, so the part is the credential run " "the lone 'John Smith, MEng' already is, and the flip " "reports. 1.4.0 read the same suffix; #540 had re-read " "the comma as a family comma (given 'PhD', middle " "'MEng'), the cost #544 reverses", shape=3), Case("comma_lower_credential_run_ending_in_meng_keeps_the_suffix_comma", "john smith, phd meng", {"given": "john", "family": "smith", "suffix": "phd meng"}, ambiguities=("suffix-or-name",), notes="the same run in one case: the count decides " "whatever case the name is written in, so 'JOHN " "SMITH, PHD MENG' reads the suffix too. 1.4.0 read " "the same; #540 had read given 'phd', suffix 'meng'", shape=3), Case("title_case_lac_behind_a_particle_is_the_name", "Nguyen Van Lac", {"given": "Nguyen", "family": "Van Lac"}, classification="fix(#540)", ambiguities=("suffix-or-name",), notes="the mixed-case name's Title-case member leans " "'name' with words to spare (S6), the gain for a " "Vietnamese name; every release from 1.4.0 read " "family 'Van', suffix 'Lac'", shape=1), Case("the_accepted_cost_survives_a_credential_behind_it", "John Smith MEng PhD", {"given": "John", "middle": "Smith", "family": "MEng", "suffix": "PhD"}, classification="fix(#540)", ambiguities=("suffix-or-name",), notes="the declined pick stops the walk, and the " "unambiguous credential behind it is still peeled; " "every release read suffix 'MEng PhD' ('MEng, PhD' " "through 2.2)", shape=1), Case("dotted_meng_passes_the_period_gate", "Wang M.Eng.", {"given": "Wang", "suffix": "M.Eng."}, classification="fix(#544)", ambiguities=("given-or-family",), notes="S3's period gate counts a listed member written in " "two or more letter chunks, each closed by a period, " "as it counts one written with a period after each " "letter: 'M.Eng.' reads as 'M.A.' does, a suffix even " "with nothing to spare, and the one-word name reports " "the given-or-family fork 'Wang M.A.' reports. #540 " "had accepted family 'M.Eng.' pending this question; " "1.4.0 read the family too, by its two-piece rule, and " "2.3.0 the suffix. A single trailing period ('Wang " "Ma.') stays outside the gate", shape=1), Case("leading_meng_is_a_given_name", "meng li", {"given": "meng", "family": "li"}, notes="the control: the marking acts only at the slots S4 " "reports from, and a leading word is none of them. No " "report, and every release read it this way", shape=1), # ---- #544: a credential run keeps its ambiguous members --------- # C4's name-word count reads a RUN after the comma as it reads one # word, and S7's company clause lets an unambiguous credential IN # FRONT of a member speak for it at every trailing slot. The rows # below are the issue's names and the boundaries each half keeps. Case("comma_run_of_title_case_members_is_the_credential_run", "John Smith, Ed Ma", {"given": "John", "family": "Smith", "suffix": "Ed Ma"}, ambiguities=("suffix-or-name",), notes="C4's run rule with no unambiguous word in it: two " "members, both Title case, and two name words before " "the comma, so the count reads the part as the " "credential run and the flip reports once over the " "whole part. 1.4.0 read the same; 2.0.0 through 2.3.0 " "read given 'Ed', middle 'Ma'", shape=3), Case("comma_run_led_by_a_member_is_the_credential_run", "John Smith, MEng PhD", {"given": "John", "family": "Smith", "suffix": "MEng PhD"}, ambiguities=("suffix-or-name",), notes="a member OPENING the run: the count, not the company, " "decides after a comma, so the order of the words does " "not matter. 1.4.0 and 2.3.0 read the same; #540 had " "read given 'MEng', family 'John Smith'", shape=3), Case("comma_run_opened_by_a_dual_is_the_credential_run", "Jane Doe, MS LAc", {"given": "Jane", "family": "Doe", "suffix": "MS LAc"}, ambiguities=("suffix-or-name",), notes="the issue's headline: 'MS' is title and suffix " "vocabulary both, and opening the part after a FULL " "name it counts as the suffix word it is, as the " "legacy test already counts it in 'Jane Doe, MS PhD'. " "1.4.0 and 2.3.0 read the same; #540 had read title " "'MS', given 'LAc', family 'Jane Doe', silently", shape=3), Case("comma_run_opened_by_a_dual_before_a_meng", "John Smith, MD MEng", {"given": "John", "family": "Smith", "suffix": "MD MEng"}, ambiguities=("suffix-or-name",), notes="the same credential after a full name, before the other " "chunked member. 1.4.0 and 2.3.0 read the same suffix; " "#540 had read title 'MD', given 'MEng'", shape=3), Case("comma_run_in_one_case_is_the_credential_run", "john smith, md ma", {"given": "john", "family": "smith", "suffix": "md ma"}, ambiguities=("suffix-or-name",), notes="one case says nothing, and the count decides as it " "does in mixed case. 1.4.0 read the same; 2.3.0 read " "title 'md', given 'ma', family 'john smith', silently", shape=3), Case("comma_run_opened_by_an_honorific_dual_is_the_accepted_cost", "John Smith, Ms Ma", {"given": "John", "family": "Smith", "suffix": "Ms Ma"}, ambiguities=("suffix-or-name",), notes="the accepted cost of reading a dual opening the part " "as its suffix: 'Ms' is the honorific here, read as " "'John Smith, Ms' alone already reads it, and the flip " "reports so a caller can see the call (Derek, " "2026-09-27). 1.4.0 read the same; 2.3.0 read title " "'Ms', given 'Ma'. With ONE name word before the comma " "the dual is a title ('Smith, Ms Ma')", shape=3), Case("comma_run_the_capitals_already_settle_is_not_flipped", "John Smith, PhD MA", {"given": "John", "family": "Smith", "suffix": "PhD MA"}, notes="every member written in capitals in a mixed-case name " "leans credential, so the listing form already reads " "the part as the credential run and the run rule stands " "down: no flip, and no report the part did not already " "make. Pinned because the reading now rests on the " "family-comma path alone. 1.4.0 read the same; 2.3.0 " "read given 'PhD', middle 'MA'"), Case("comma_run_with_a_particle_chain_takes_the_count", "John Smith, PhD DO DO", {"given": "John", "family": "Smith", "suffix": "PhD DO DO"}, notes="#562 read the two 'DO's as the particle run group would " "chain, which no word's capitals settle, and flipped and " "reported the part. Since #613 the part is read and bound " "before any join, so no chain reaches it: each 'DO' leans " "credential by its capitals and the run reads whole in " "silence behind another credential (rules/C11). 1.4.0 " "read the same fields; 2.0.0 read title 'PhD', given 'DO " "DO', and 2.3.0 given 'PhD', family 'DO DO John Smith'", shape=3), Case("comma_run_chained_behind_a_suffix_particle_takes_the_count", "John Smith, PhD vd DO", {"given": "John", "family": "Smith", "suffix": "PhD vd DO"}, notes="#562's other row: 'vd' is particle and unambiguous suffix " "vocabulary, 'DO' a member leaning credential by its " "capitals. Read before any join since #613, the part is " "settled and silent, where #562 flipped and reported it " "for the chain group would make. 1.4.0 read the same; " "2.3.0 read given 'PhD', family 'vd DO John Smith'", shape=3), Case("comma_run_a_particle_pair_opens_is_flipped_and_reported", "John Smith, vd DO", {"given": "John", "family": "Smith", "suffix": "vd DO"}, notes="the row id is #562's, which flipped and reported this " "pair as a particle run the capitals could not settle. " "Since #613 nothing chains the part before it is read: " "'DO' leans credential, the run is settled and silent, " "and 'vd', the first word after the comma, is no member " "of the class to report. 2.3.0 read family 'John Smith " "vd DO'"), Case("comma_run_with_a_lone_trailing_particle_member_stays_settled", "John Smith, PhD DO", {"given": "John", "family": "Smith", "suffix": "PhD DO"}, notes="#562's boundary: one 'DO' beside no other particle " "is left to its capitals (S6), so the run stays settled " "and silent, as 'John Smith, PhD MA' is. Untagged: every " "2.x release misread it, and its fix is #289's (#530), " "not #562's"), Case("comma_run_by_shape_member_takes_the_count", "John Smith, X.Y.Z. MA", {"given": "John", "family": "Smith", "suffix": "X.Y.Z. MA"}, classification="fix(#544)", ambiguities=("suffix-or-name",), notes="the lean that lets a settled run stand down is the " "LISTED set's alone (S6): 'X.Y.Z.' joins the class by " "shape and carries no writing to read, so the run is " "the count's and flips though both words are in " "capitals. 1.4.0 read given 'X.Y.Z.', family 'John " "Smith', suffix 'MA'", shape=3), Case("a_degree_in_front_anchors_a_trailing_meng", "John Smith PhD MEng", {"given": "John", "family": "Smith", "suffix": "PhD MEng"}, classification="fix(#436/#437)", ambiguities=("suffix-or-name",), notes="S7's company clause: 'MEng' is written the way the " "name lean reads, but the unambiguous 'PhD' in front " "of it anchors it, so the peel takes both and reports " "the member it picked. The roles are 1.4.0's and " "2.3.0's; the classification records the suffix " "STRING, 1.4.0's 'PhD, MEng'. #540 had read middle " "'Smith PhD', family 'MEng'", shape=1), Case("a_degree_in_front_anchors_a_trailing_ma", "John Smith PhD Ma", {"given": "John", "family": "Smith", "suffix": "PhD Ma"}, classification="fix(#436/#437)", ambiguities=("suffix-or-name",), notes="the same company for a member marked since 2.0: the " "#289 lean had read middle 'Smith PhD', family 'Ma' " "(bisected to dfb31709). 1.4.0 and 2.3.0 read the " "roles; 1.4.0 wrote the suffix 'PhD, Ma'", shape=1), Case("the_anchor_passes_through_a_run_of_members", "John Smith PhD Ed Ma", {"given": "John", "family": "Smith", "suffix": "PhD Ed Ma"}, classification="fix(#436/#437)", ambiguities=("suffix-or-name", "suffix-or-name"), notes="a member behind a member behind the degree: the " "first passes the anchor on, and each reports as a " "pick. 1.4.0 and 2.3.0 read the roles; 1.4.0 wrote " "'PhD, Ed, Ma'", shape=1), Case("a_degree_behind_the_member_anchors_nothing", "Wang Ma PhD", {"given": "Wang", "family": "Ma", "suffix": "PhD"}, ambiguities=("suffix-or-name",), notes="the company is IN FRONT only: 'PhD' behind 'Ma' says " "nothing about it, so the Title-case member is the " "family name and the degree is peeled on its own. " "1.4.0 and 2.3.0 read the same roles", shape=1), Case("the_anchor_reads_inside_the_maiden_clause", "Jane Doe nee Smith PhD MEng", {"given": "Jane", "family": "Doe", "suffix": "PhD MEng", "maiden": "Smith"}, classification="fix(#544)", ambiguities=("suffix-or-name",), notes="the maiden walk reads the end of the clause through " "the same peel, so the anchored member ends it too and " "the clause keeps 'Smith'. 2.3.0 read the same; #540 " "had read middle 'Doe PhD', family 'MEng'. 1.4.0 had " "no maiden routing", shape=1), Case("a_title_between_degree_and_member_keeps_it_a_name", "John Smith PhD Prof. Ma", {"title": "Prof.", "given": "John", "family": "Smith", "suffix": "PhD Ma"}, classification="fix(#602)", ambiguities=("suffix-or-name",), notes="rules/S8 (#602): the credential after two name words " "starts a run to the end of the part, the title inside it " "reads as a title, and the member is in the run. The id " "names the reading #544 pinned (middle 'Smith PhD Prof.', " "family 'Ma'), which #602 retires; 2.3.0 read this same " "way, and 1.4.0 middle 'Smith PhD', last 'Prof.', suffix " "'Ma'"), # the split credential's periods are optional, each half on its own # (v1's fix_phd, `_vocab.split_pair`): every spelling is one suffix # piece, at the end of a name, behind a family comma and after a # suffix comma (#752's test review: no row held a periodless pair) Case("a_split_degree_without_its_periods_merges", "John Smith Ph D", {"given": "John", "family": "Smith", "suffix": "Ph D"}, classification="parity"), Case("a_split_degree_with_one_period_merges", "John Smith Ph. D", {"given": "John", "family": "Smith", "suffix": "Ph. D"}, classification="parity"), Case("a_split_degree_without_its_periods_merges_in_the_given_part", "Smith, John Ph D", {"given": "John", "family": "Smith", "suffix": "Ph D"}, classification="parity"), Case("a_split_degree_in_capitals_merges_after_a_suffix_comma", "John Smith, PH D", {"given": "John", "family": "Smith", "suffix": "PH D"}, classification="parity"), Case("a_split_degree_in_front_starts_the_run", "John Smith Ph. D. MEng", {"given": "John", "family": "Smith", "suffix": "Ph. D. MEng"}, classification="fix(#827)", ambiguities=("suffix-or-name",), notes="S8: the merged 'Ph. D.' starts the credential's " "run as its one-word spelling 'PhD' does, though the " "walk leaves it out, so the 'MEng' behind it is in the " "run and reported. Until #827 the member's own writing " "decided and it read family 'MEng'; 2.3.0 read suffix " "'Ph. D. MEng', and 1.4.0 'Ph. D., MEng'"), # #752, phase 2e: every other credential written split across a # space merges as a member of the ambiguous class, the count # deciding it (rules/S12); the controls are the spellings it # leaves split Case("a_split_credential_with_words_to_spare_is_the_suffix", "John Smith Pharm. D.", {"given": "John", "family": "Smith", "suffix": "Pharm. D."}, classification="fix(#752)", ambiguities=("suffix-or-name",), notes="S3: 'Pharm. D.' folds to the listed 'pharmd', " "and neither half is suffix vocabulary on its own, so " "the two words are one credential, read with the words " "to spare a bare member needs (S4) and reported as a " "member is. " "1.4.0 read middle 'Smith Pharm.', last 'D.'"), Case("a_split_credential_with_no_words_to_spare_is_two_words", "John Pharm D", {"given": "John", "middle": "Pharm", "family": "D"}, notes="S4's count: one name word in front leaves " "nothing to spare, so the pair is split back into the " "two words it is written as and reads as they read " "unmerged"), Case("a_split_credential_behind_a_whole_name_after_a_comma", "John Smith, Pharm D", {"given": "John", "family": "Smith", "suffix": "Pharm D"}, classification="fix(#752)", ambiguities=("suffix-or-name",), notes="C4's count: two name words before the comma make " "the part the credential run. 1.4.0 read first 'Pharm', " "middle 'D', last 'John Smith'"), Case("a_split_credential_behind_one_word_after_a_comma", "Smith, Pharm D", {"given": "Pharm", "middle": "D", "family": "Smith"}, notes="C4's count: one name word before the comma " "reads the part as the given part, where the pair is " "the given name's own words"), Case("a_given_name_and_initial_that_spell_a_credential", "SMITH, SAM E.", {"given": "SAM", "middle": "E.", "family": "SMITH"}, notes="C4's count is what keeps this a name: 'SAM E.' " "folds to the listed 'same', and FEC's donor list writes " "a given name and its initial so behind a one-word " "family (decisions/vocabulary-model, 2026-10-25 #752)"), Case("a_split_credential_opening_with_one_letter_stays_split", "John Smith M. Sc.", {"given": "John", "middle": "Smith M.", "family": "Sc."}, notes="S3's opening letter: a single letter opening the " "pair is a middle initial far more often ('SMITH, JOHN " "A. MS.' folds to 'ams'), so the pair never merges, the " "accepted cost being 'M. Sc.' and 'M. D.'"), Case("a_split_credential_ending_the_given_part", "Smith, John Pharm D", {"given": "John", "family": "Smith", "suffix": "Pharm D"}, classification="fix(#752)", ambiguities=("suffix-or-name",), notes="S5: a member ending the given part after a " "family comma reads as the credential where its writing " "says nothing else, and a split one says nothing. 1.4.0 " "read middle 'Pharm D'"), Case("a_split_credential_starts_the_run_in_the_given_part", "Smith, John Pharm D Jones", {"given": "John", "family": "Smith", "suffix": "Pharm D Jones"}, classification="fix(#752)", ambiguities=("suffix-or-name", "suffix-or-name"), notes="S8: the merged pair starts the run after the " "given word, as 'PharmD' does, and the name word it " "absorbs is reported. 1.4.0 read middle 'Pharm D Jones'"), Case("a_split_credential_starts_the_run_behind_two_name_words", "John Smith Pharm D Jones", {"given": "John", "family": "Smith", "suffix": "Pharm D Jones"}, classification="fix(#752)", ambiguities=("suffix-or-name", "suffix-or-name"), notes="S8: two name words in front, the words to spare " "S4 asks of the pair, start the run there. 1.4.0 read " "middle 'Smith Pharm D', last 'Jones'"), Case("a_split_credential_behind_a_particle_run_reads_as_a_shape_member", "St van Berg Pharm D", {"title": "St", "family": "van Berg Pharm D"}, ambiguities=("particle-or-given",), notes="S3: no writing to lean on, so the pair reads as a " "member admitted by shape does ('St van Berg X.Y.Z.' " "keeps its family): the particle chain is one name " "word, which leaves none to spare"), Case("a_split_credential_ends_a_maiden_clause", "Jane Doe nee Smith Pharm D", {"given": "Jane", "family": "Doe", "maiden": "Smith", "suffix": "Pharm D"}, classification="fix(#752)", ambiguities=("suffix-or-name",), notes="M2: the clause ends at the trailing run the " "clause-free name reads, which takes the pair as " "'Jane Doe Pharm D' does. 1.4.0 read middle 'Doe nee " "Smith Pharm', last 'D'"), Case("a_split_credential_reports_itself_not_an_absorbed_word", "John Smith MB ChB", {"given": "John", "family": "Smith", "suffix": "MB ChB"}, classification="fix(#752)", ambiguities=("suffix-or-name",), notes="'MB ChB' is one listed credential, 'mbchb', where 'mb' " "started the run and reported 'ChB' as a name word it " "absorbed; the pair reports itself instead, as a member " "the count took. 1.4.0 read middle 'Smith MB', last 'ChB'"), Case("a_split_credential_beside_a_connective_stays_two_words", "John Smith Pharm D and Mary", {"given": "John", "middle": "Smith Pharm", "family": "D and Mary"}, notes="S12: a connective joins the words beside it, so a pair " "it borders is no credential; merged, the join took the " "pair into a wider piece the count then read whole (the " "first version's 'Pharm D and Mary' suffix, found by " "review)"), Case("no_split_credential_merges_before_a_family_comma", "MB ChB, PhD", {"family": "MB ChB", "suffix": "PhD"}, classification="fix(#752)", notes="S12: the part before a family comma merges no member " "pair, so C3's count of it reads the two words as " "written, the tree's reading before #752 (the first " "version read given 'MB', family 'ChB'). 1.4.0 read " "first 'MB', last 'ChB'"), Case("a_split_credential_ending_a_maiden_clause_reports_nothing", "Jane Doe nee Ed. D.", {"given": "Jane", "family": "Doe", "maiden": "Ed. D."}, classification="fix(#752)", notes="S12: the clause keeps the pair, a pair the count " "declined, which reports nothing; its " "first word is a listed member, and the first version " "reported 'Ed.' as the word ending the clause, which " "'D.' is. 1.4.0 read middle 'Doe nee Ed.', last 'D.'"), Case("a_split_credential_stops_the_particle_chain_in_the_given_part", "Smith, John van Pharm D", {"given": "John", "family": "van Smith", "suffix": "Pharm D"}, classification="fix(#752)", ambiguities=("particle-or-given", "suffix-or-name"), notes="S12: merged ahead of group's joins, the pair stops the " "chain as 'PharmD' does, and P6 attaches the particle; " "merged after them (the design-docs review's first fix), " "the chain took 'Pharm' into the particle's run. 1.4.0 " "read middle 'van Pharm D'"), Case("a_bound_given_name_declines_a_split_credential", "Rahman, Abdul MB ChB", {"given": "Abdul", "family": "Rahman", "suffix": "MB ChB"}, classification="fix(#752)", ambiguities=("suffix-or-name",), notes="P5: the join declines the pair as it declines a suffix " "word; the first version joined it, given 'Abdul MB ChB', " "where master read suffix 'MB ChB'. 1.4.0 read first " "'Abdul MB', middle 'ChB'"), Case("a_split_credential_behind_an_opening_credential_reports_once", "Smith, MD Pharm D", {"family": "Smith", "suffix": "MD Pharm D"}, ambiguities=("suffix-or-name",), classification="fix(#752)", notes="S12: the pair the opening credential's run takes (C13) " "reports itself, once, as 'John Smith MD Pharm D' does; " "the review found it reported twice, as a word the run " "absorbed and as the pair. Master reported 'Pharm' and " "'D' as two absorbed words"), Case("the_split_degree_outranks_a_pair_overlapping_it", "Jane Doe Dr. Ph. D.", {"title": "Dr.", "given": "Jane", "family": "Doe", "suffix": "Ph. D."}, classification="fix(#752)", notes="'Dr. Ph.' folds to the listed 'drph', which would " "leave 'D.' a word of its own; the Ph./D. pair takes " "precedence, keeping the tree's reading before #752's " "widened pair test. 1.4.0 read suffix 'Ph. D., Dr.'"), Case("a_member_the_particle_chain_took_is_out_of_reach", "John Smith PhD Do Do", {"given": "John", "family": "Smith", "suffix": "PhD Do Do"}, classification="fix(#602)", ambiguities=("suffix-or-name",), notes="rules/S8 (#602): the credential starts the run and " "P2's chain stops where it starts, so the 'Do Do' the " "chain joined is absorbed and reported. The id names " "#544's Accepted limit, which #602 retires; 1.4.0 and " "2.3.0 read middle 'Smith PhD', family 'Do Do'"), # #602: a credential after the name core starts a run to the end of # its part (rules/S8). The movers, then the exclusions that keep # a run from starting -- each a negative control for the movers. Case("a_credential_starts_a_run_to_the_end_of_the_part", "John Smith PhD Jones", {"given": "John", "family": "Smith", "suffix": "PhD Jones"}, classification="fix(#602)", ambiguities=("suffix-or-name",), notes="the credential is the writer's mark that the " "post-nominals have begun; the name word behind it is " "absorbed and reported. 2.3.0 read middle 'Smith PhD', " "family 'Jones'"), Case("a_generational_word_starts_the_run_too", "John Smith Jr. Jones", {"given": "John", "family": "Smith", "suffix": "Jr. Jones"}, classification="fix(#602)", ambiguities=("suffix-or-name",), notes="generational words start the run as credentials do " "(decided on #602). 2.3.0 read middle 'Smith Jr.'"), Case("a_generational_word_in_another_language", "Antonio Pavlović mlađi", {"given": "Antonio", "family": "Pavlović", "suffix": "mlađi"}, classification="fix(#661)", notes="junior/senior words of other languages are suffix " "vocabulary. 1.4.0 and 2.4.2 read middle 'Pavlović', " "family 'mlađi'"), Case("a_generational_word_after_a_family_comma", "Pavlović, Antonio mlađi", {"given": "Antonio", "family": "Pavlović", "suffix": "mlađi"}, classification="fix(#661)", notes="1.4.0 and 2.4.2 read middle 'mlađi'"), Case("an_accented_spelling_of_junior", "José Silva Júnior", {"given": "José", "family": "Silva", "suffix": "Júnior"}, classification="fix(#843)", notes="the Portuguese spelling reads as 'José Silva Junior' " "does. 1.4.0 and 2.5.0 read middle 'Silva', family " "'Júnior'"), Case("an_agnomen_is_a_generational_suffix", "Pedro Alves Filho", {"given": "Pedro", "family": "Alves", "suffix": "Filho"}, classification="fix(#843)", ambiguities=("suffix-or-name",), notes="an agnomen marks its bearer against a relative of the same " "name, as Junior does (rules/S11). 1.4.0 and 2.6.0 read " "middle 'Alves', family 'Filho'"), Case("an_agnomen_with_no_word_to_spare", "Ana Neto", {"given": "Ana", "family": "Neto"}, classification="parity", ambiguities=("suffix-or-name",), notes="Neto is a surname too, and a name of two words has none to " "spare for the generation (rules/S11); the fork is reported"), Case("an_agnomen_in_capitals_with_no_word_to_spare", "PEDRO FILHO", {"given": "PEDRO", "family": "FILHO"}, classification="parity", ambiguities=("suffix-or-name",), notes="a name written in one case says nothing, and an agnomen's " "writing never does, so the count keeps the family"), Case("an_agnomen_after_a_particle_surname", "PEDRO DA SILVA FILHO", {"given": "PEDRO", "family": "DA SILVA", "suffix": "FILHO"}, classification="fix(#843)", ambiguities=("suffix-or-name",), notes="the particle surname is the family the agnomen follows. " "1.4.0 and 2.6.0 read family 'DA SILVA FILHO'"), Case("an_agnomen_before_a_suffix_comma", "Pedro Alves Neto, PhD", {"given": "Pedro", "family": "Alves", "suffix": "Neto, PhD"}, classification="fix(#843)", ambiguities=("suffix-or-name",), notes="the agnomen joins the credentials after it, as 'José Silva " "Júnior, PhD' does. 1.4.0 and 2.6.0 read middle 'Alves', " "family 'Neto'"), Case("an_agnomen_ending_the_given_part", "Alves, Pedro Neto", {"given": "Pedro", "family": "Alves", "suffix": "Neto"}, classification="fix(#843)", ambiguities=("suffix-or-name",), notes="the given part's last word is read by its writing alone " "(rules/S5), and an agnomen's never keeps it a name. 1.4.0 " "and 2.6.0 read middle 'Neto'"), Case("an_agnomen_alone_after_a_family_comma", "Silva, Neto", {"given": "Neto", "family": "Silva"}, classification="parity", ambiguities=("suffix-or-name",), notes="the first word after the comma is the given name, the count " "deciding first (rules/C4); FEC holds NETO as a given name"), Case("an_agnomen_in_capitals_in_a_mixed_case_name", "Pedro Alves NETO", {"given": "Pedro", "family": "Alves", "suffix": "NETO"}, classification="fix(#843)", ambiguities=("suffix-or-name",), notes="an agnomen's capitals say nothing, where an acronym's would " "be the credential, so the count decides. 1.4.0 and 2.6.0 " "read middle 'Alves', family 'NETO'"), Case("an_abbreviated_agnomen_after_a_particle_surname", "JOSE DA SILVA FH", {"given": "JOSE", "family": "DA SILVA", "suffix": "FH"}, classification="fix(#843)", ambiguities=("suffix-or-name",), notes="Brazilian filings abbreviate FILHO as FH, and the " "abbreviation is gated as the word it spells (rules/S11). " "1.4.0 and 2.6.0 read family 'DA SILVA FH'"), Case("an_abbreviated_agnomen_with_its_period", "Pedro Alves Sobr.", {"given": "Pedro", "family": "Alves", "suffix": "Sobr."}, classification="fix(#843)", ambiguities=("suffix-or-name",), notes="Sobr. abbreviates Sobrinho, and its period is edge " "punctuation a suffix word is matched without. 1.4.0 and " "2.6.0 read middle 'Alves', family 'Sobr.'"), Case("an_abbreviated_agnomen_with_no_word_to_spare", "Ana Fo", {"given": "Ana", "family": "Fo"}, classification="parity", ambiguities=("suffix-or-name",), notes="Fo is a surname too (Dario Fo), and a name of two words " "has none to spare for the agnomen (rules/S11)"), Case("an_agnomen_glued_behind_an_initial", "Pedro M.Filho", {"given": "Pedro", "family": "M.Filho"}, classification="parity", ambiguities=("suffix-or-name",), notes="no chunk claim for a suffix word that is also a name " "(rules/S11): the joined word is weighed by its dotted shape, " "as it was"), Case("a_generation_word_in_front_of_an_agnomen_is_no_word_to_spare", "Pedro Junior Filho", {"given": "Pedro", "middle": "Junior", "family": "Filho"}, classification="parity", ambiguities=("suffix-or-name",), notes="only name words in front of an agnomen count toward its " "words to spare, so the suffix word there spares nothing " "and keeps no company (rules/S11, #890)"), Case("an_accented_junior_in_front_of_an_agnomen", "Pedro Júnior Neto", {"given": "Pedro", "middle": "Júnior", "family": "Neto"}, classification="parity", ambiguities=("suffix-or-name",), notes="only name words in front of an agnomen count toward its " "words to spare, so the suffix word there spares nothing " "and keeps no company (rules/S11, #890)"), Case("an_abbreviated_junior_in_front_of_an_agnomen", "Pedro Jr. Filho", {"given": "Pedro", "middle": "Jr.", "family": "Filho"}, classification="parity", ambiguities=("suffix-or-name",), notes="only name words in front of an agnomen count toward its " "words to spare, so the suffix word there spares nothing " "and keeps no company (rules/S11, #890)"), Case("a_generation_word_in_front_of_an_agnomen_before_a_comma", "Pedro Junior Filho, MD", {"given": "Pedro", "middle": "Junior", "family": "Filho", "suffix": "MD"}, classification="parity", ambiguities=("suffix-or-name",), notes="only name words in front of an agnomen count toward its " "words to spare, so the suffix word there spares nothing " "and keeps no company (rules/S11, #890)"), Case("a_generation_word_in_front_of_an_agnomen_in_capitals", "PEDRO JUNIOR FILHO", {"given": "PEDRO", "middle": "JUNIOR", "family": "FILHO"}, classification="parity", ambiguities=("suffix-or-name",), notes="only name words in front of an agnomen count toward its " "words to spare, so the suffix word there spares nothing " "and keeps no company (rules/S11, #890)"), Case("a_numeral_in_front_of_an_agnomen_is_no_word_to_spare", "Pedro VI Filho", {"given": "Pedro", "middle": "VI", "family": "Filho"}, classification="parity", ambiguities=("suffix-or-name",), notes="what the trailing run would take in front of an agnomen " "is no word to spare for it, a numeral the fork reads or " "a member its writing makes a suffix (rules/S11, #890)"), Case("a_credential_in_capitals_in_front_of_an_agnomen_is_no_word_to_spare", "Pedro MA Filho", {"given": "Pedro", "middle": "MA", "family": "Filho"}, classification="parity", ambiguities=("suffix-or-name",), notes="what the trailing run would take in front of an agnomen " "is no word to spare for it, a numeral the fork reads or " "a member its writing makes a suffix (rules/S11, #890)"), Case("a_member_carried_in_front_of_an_agnomen_is_no_word_to_spare", "Pedro Junior Ma Filho", {"given": "Pedro", "middle": "Junior Ma", "family": "Filho"}, classification="parity", ambiguities=("suffix-or-name",), notes="a member a suffix word in front carries is what the " "trailing run takes, so it spares no word for the agnomen " "behind it (rules/S11, #890)"), Case("a_particle_carried_in_front_of_an_agnomen", "Pedro Junior do Filho", {"given": "Pedro", "middle": "Junior", "family": "do Filho"}, classification="parity", ambiguities=("suffix-or-name",), notes="a member a suffix word in front carries is what the " "trailing run takes, so it spares no word for the agnomen " "behind it (rules/S11, #890)"), Case("an_agnomen_behind_a_joined_surname", "Maria Gomes e Silva Filho", {"given": "Maria", "family": "Gomes e Silva", "suffix": "Filho"}, classification="fix(#843)", ambiguities=("suffix-or-name",), notes="a connective join in front is one name word, the " "agnomen's word to spare (rules/S11, #890). 1.4.0 and " "2.6.0 read family 'Filho'"), Case("fils_after_a_name", "Alexandre Dumas fils", {"given": "Alexandre", "family": "Dumas", "suffix": "fils"}, classification="fix(#752)", ambiguities=("suffix-or-name",), notes="fils is a generation word with a family reading, gated " "(rules/S11). 1.4.0 and 2.6.0 read middle 'Dumas', family " "'fils'"), Case("fils_in_title_case_is_a_name", "Marie Fils", {"given": "Marie", "family": "Fils"}, classification="parity", ambiguities=("suffix-or-name",), notes="Title case leans a generation word to the name (rules/S6); " "the fork is reported"), Case("fils_in_title_case_with_words_to_spare", "Jean Marie Fils", {"given": "Jean", "middle": "Marie", "family": "Fils"}, classification="parity", ambiguities=("suffix-or-name",), notes="the lean holds with words to spare too"), Case("fils_in_lower_case_with_no_word_to_spare", "Dumas fils", {"given": "Dumas", "family": "fils"}, classification="parity", ambiguities=("suffix-or-name",), notes="the floor holds before the case cue: two name words keep " "the family, lower case or not (rules/S11, Derek, #890)"), Case("fils_in_lower_case_behind_a_suffix_word", "Pedro Junior fils", {"given": "Pedro", "middle": "Junior", "family": "fils"}, classification="parity", ambiguities=("suffix-or-name",), notes="the suffix word in front is no word to spare, and the " "floor holds before the case cue (rules/S11, #890)"), Case("fils_in_lower_case_after_a_family_comma", "Dumas, Alexandre fils", {"given": "Alexandre", "family": "Dumas", "suffix": "fils"}, classification="fix(#752)", ambiguities=("suffix-or-name",), notes="the comma has named the family, so lower case settles the " "generation (rules/S5, S6). 1.4.0 and 2.6.0 read middle " "'fils'"), Case("fils_in_lower_case_after_an_empty_head", ", Dr. fils", {"title": "Dr.", "family": "fils"}, classification="parity", notes="no name before the comma, so no family is named and the " "floor holds before the case cue (rules/S11, #890)"), Case("fils_in_lower_case_after_one_name_word_and_a_comma", "Dumas, fils", {"given": "fils", "family": "Dumas"}, classification="parity", ambiguities=("suffix-or-name",), notes="at the first slot after a comma the count decides alone, " "lower case adding nothing to it (rules/S11, C4, #890)"), Case("fils_in_lower_case_after_a_joined_salutation", "Mr. and Mrs., fils", {"given": "fils", "family": "Mr. and Mrs."}, classification="parity", ambiguities=("suffix-or-name",), notes="the joined salutation is no family word for the case cue " "to rely on (rules/S11, #890)"), Case("a_one_letter_generation_is_off_by_default", "Juan Pérez H.", {"given": "Juan", "middle": "Pérez", "family": "H."}, classification="parity", notes="Spanish H. (hijo) is written as an initial is (rules/T5), so " "its generation reading is recorded off by default, for a " "pack"), Case("a_feminine_generational_word_in_cyrillic", "Агриппина Младшая", {"given": "Агриппина", "suffix": "Младшая"}, classification="fix(#661)", ambiguities=("given-or-family",), notes="the feminine form ships beside the masculine, and the " "title case of a historical name matches through the " "fold. 1.4.0 and 2.4.2 read family 'Младшая'"), Case("a_generational_word_in_devanagari", "बलबीर सिंह सीनियर", {"given": "बलबीर", "family": "सिंह", "suffix": "सीनियर"}, classification="fix(#661)", notes="1.4.0 and 2.4.2 read middle 'सिंह', family 'सीनियर'"), Case("a_generational_word_in_hangul_comes_off_before_the_order", "로버트 다우니 주니어", {"given": "다우니", "family": "로버트", "suffix": "주니어"}, classification="fix(#679)", notes="the suffix peels before the wholly-hangul name reads " "family-first by script (rules/W4), so the transcribed " "Robert is the family name as it was before; 2.4.2 read " "middle '주니어', and 1.4.0 family '주니어'"), Case("a_generational_word_after_a_nakaguro", "ロバート・ダウニー・ジュニア", {"given": "ロバート", "family": "ダウニー", "suffix": "ジュニア"}, classification="fix(#679)", notes="the nakaguro separates tokens, so the katakana Jr. is " "a word of its own; pure katakana keeps the positional " "order. 2.4.2 read middle 'ダウニー', family 'ジュニア'; " "1.4.0 read the whole text as the first name"), Case("a_title_inside_the_run_is_a_title", "Eric H. Holder Jr. Attorney General", {"title": "Attorney General", "given": "Eric", "middle": "H.", "family": "Holder", "suffix": "Jr."}, classification="fix(#602)", notes="the credential starts the run and the title words " "inside it read as titles, as the comma spelling 'Eric " "H. Holder, Jr. Attorney General' always read. 2.3.0 " "read middle 'H. Holder Jr. Attorney', family 'General'"), Case("the_given_part_reads_the_run", "Smith, John PhD Jones", {"given": "John", "family": "Smith", "suffix": "PhD Jones"}, classification="fix(#602)", ambiguities=("suffix-or-name",), notes="after a family comma one given word is the core. 2.3.0 " "read middle 'Jones', suffix 'PhD'"), Case("a_title_inside_the_given_parts_run_is_a_title", "Holder, Eric Jr. Attorney General", {"title": "Attorney General", "given": "Eric", "family": "Holder", "suffix": "Jr."}, classification="fix(#602)"), Case("a_title_word_does_not_start_the_run", "Mary Jane Pope Smith", {"given": "Mary", "middle": "Jane Pope", "family": "Smith"}, notes="746 title words are in no suffix set, many of them " "surnames (measured 2026-10-04), so a title word never " "starts the run: a title is read from the end of a name " "only behind a comma or with a period (H5)"), Case("a_joined_connective_is_no_run", "Josep Carod i Rovira", {"given": "Josep", "family": "Carod i Rovira"}, notes="'i' is a suffix word and a connective; a connective " "never starts the run"), Case("a_numeral_the_clause_free_name_reads_ends_the_clause", "John née Jones Smith VI", {"family": "John", "suffix": "VI", "maiden": "Jones Smith"}, ambiguities=("suffix-or-name",), notes="#601: the take finds a trailing numeral by the numeral " "fork's SHAPE test, which asks no vocabulary -- 'vi' is in " "no suffix list -- so the clause ends where 'John VI' would " "read the suffix. #601's prototype tested vocabulary only " "and kept 'VI' in the maiden name; a unit test under a " "reduced lexicon caught it"), Case("a_shape_numeral_behind_which_a_title_stands_ends_the_clause", "Jane Doe nee Smith VI Prof.", {"title": "Prof.", "given": "Jane", "family": "Doe", "suffix": "VI", "maiden": "Smith"}, ambiguities=("suffix-or-name",), notes="#610: the chain takes 'Prof.' first and leaves 'VI' the " "walk's last piece, so the numeral fork reads it, as in " "'John Smith VI Prof.'. The candidate loop #601 shipped " "admitted a shape numeral only as the clause's last word " "and kept maiden 'Smith VI'; sharing the candidates with " "the peel (`_pieces.trailing_candidates`) closed it"), Case("a_shape_numeral_before_a_merged_credential_ends_the_clause", "Jane Doe nee Smith VI Ph. D.", {"given": "Jane", "family": "Doe", "suffix": "VI Ph. D.", "maiden": "Smith"}, ambiguities=("suffix-or-name",), notes="#610: the merged 'Ph. D.' is group-flagged and outside the " "peel's walk, so 'VI' is the walk's last piece, as in 'Jane " "Doe VI Ph. D.'. A first draft of the #610 fix covered the " "title half only; the sweep test over `trailing_candidates` " "found this one"), Case("a_bound_given_word_is_no_run", "Mohamed Ali Abd Allah", {"given": "Mohamed", "middle": "Ali Abd", "family": "Allah"}, notes="'abd' is in the credential list (ABD) and heads 'Abd " "Allah'; a word another vocabulary claims as a name never " "starts the run"), Case("one_name_word_is_no_core", "John PhD Smith", {"given": "John", "middle": "PhD", "family": "Smith"}, notes="with no comma two name words must stand in front of the " "credential, the family having to exist first"), Case("a_particle_in_front_of_a_member_anchors_nothing", "Jan vd Ma", {"given": "Jan", "family": "vd Ma"}, classification="fix(#289)", ambiguities=("suffix-or-name",), notes="S7's company clause: 'vd' is particle AND suffix " "vocabulary, and a particle is the head of the family " "name behind it rather than a credential that could " "speak for 'Ma', so the Title-case member's own writing " "decides and the chain keeps it. 2.3.0 read suffix 'vd " "Ma', and 1.4.0 last 'vd', suffix 'Ma'"), Case("a_particle_in_front_of_a_member_anchors_nothing_before_a_comma", "Smith vd Ma, John", {"given": "John", "family": "Smith vd Ma"}, classification="fix(#289)", notes="the same exclusion in the part before a family comma, " "where a particle standing as the anchor split the " "family around a suffix 'vd'. 2.3.0 read family 'Smith " "Ma', suffix 'vd', and 1.4.0 last 'Smith', suffix 'vd, " "Ma'"), # #578: the mixed-case given part a credential and a particle both # end. Decided as it reads, silent: no code special-cases it. Case("a_particle_after_a_credential_heads_a_title_case_name", "Doe, Jane PhD vd Ma", {"given": "Jane", "family": "Doe", "suffix": "PhD vd Ma"}, classification="fix(#602)", ambiguities=("suffix-or-name",), notes="#602: the credential starts the given part's run, so " "'vd' and 'Ma' are in it, the member reported. Before " "#602, and the reading the id names: " "S5: a particle in front of a member takes it out of " "the given part's trailing slot, and where the word " "behind reads as a name the two are one name, asked " "nothing and reporting nothing -- 'vd' reads as 'van' " "does ('Doe, Jane PhD van Ma'). The capitalization is " "malformed either way; #578 declined both a suffix " "reading and a report. 2.3.0 read middle 'Ma', suffix " "'PhD vd'; 1.4.0 suffix 'PhD, vd, Ma'"), # #573: the three uniform-case shapes of the same word. Each split # came from one stage acting on a reading the next never took. Case("a_uniform_case_particle_before_a_comma_stays_in_the_family", "SMITH VD MA, JOHN", {"given": "JOHN", "family": "SMITH VD MA"}, classification="fix(#573)", notes="P9: in the part before a family comma a particle " "that is suffix vocabulary too heads the name word " "behind it, so the per-piece read no longer peels 'VD' " "from between two family words -- the uniform case had " "left the chain stopped before it. Reads as the " "mixed-case row above does. 2.2.0 and 2.3.0 read family " "'SMITH MA', suffix 'VD'; 2.0.0 and 2.1.0 read as here; " "1.4.0 last 'SMITH', suffix 'VD, MA'"), Case("a_comma_before_no_name_word_keeps_the_positional_read", "Berg de MA, Prof.", {"title": "Prof.", "given": "Berg", "family": "de", "suffix": "MA"}, classification="fix(comma-family)", notes="#573's boundary: a comma followed by no name word fixed no " "family, so the part before it reads positionally, its " "trailing credential included. A first draft of #573 " "skipped the chain's trailing stop for the whole family " "part and read family 'de MA'. The row pins the suffix " "(reported until #785, whose capitals settle it now, " "rules/S6); the all-particle family 'de' is the positional " "read's, older than #573, and not endorsed here"), Case("a_particle_suffix_word_between_credentials_stays_in_the_run", "DOE, JANE PHD VD MA", {"given": "JANE", "family": "DOE", "suffix": "PHD VD MA"}, classification="fix(#573)", ambiguities=("suffix-or-name", "suffix-or-name"), notes="P9 (P6's third exception): 'VD' has a credential in " "front of it and one behind, so it stands inside the " "run rather than ending the name, and P6's attachment " "stands down. Reports 'VD' (the attachment declined) " "and 'MA' (#531's slot). Master read family 'VD DOE', " "suffix 'PHD, MA'; 2.3.0 middle 'MA', suffix 'PHD VD'; " "1.4.0 suffix 'PHD, VD, MA'"), Case("a_member_read_as_a_credential_in_front_keeps_the_run", "DOE, JANE MA VD PHD", {"given": "JANE", "family": "DOE", "suffix": "MA VD PHD"}, classification="fix(#573)", ambiguities=("suffix-or-name", "suffix-or-name"), notes="P9 (P6's third exception) asks whether the run was read " "whole, and 'MA' read as a credential in front says so " "as a degree does"), Case("a_trailing_title_is_transparent_to_the_run", "DOE, JANE PHD PROF. VD MA", {"title": "PROF.", "given": "JANE", "family": "DOE", "suffix": "PHD VD MA"}, classification="fix(#573)", ambiguities=("suffix-or-name", "suffix-or-name"), notes="H5: a trailing title is transparent, so 'VD' stands " "between credentials as it does without the title"), Case("a_member_already_read_as_a_credential_leaves_no_run", "Doe, Jane PhD do MA", {"given": "Jane", "family": "Doe", "suffix": "PhD do MA"}, classification="fix(#436/#437)", ambiguities=("suffix-or-name",), notes="#573's boundary: 'do' is a member assign already read " "as the credential, so P6 finds no particle run to " "attach and the third exception has nothing to " "decline -- a draft that generalized it to a run of " "pieces reported here on an empty run"), Case("a_particle_suffix_word_ending_a_credential_run_still_attaches", "DOE, JANE PHD VD", {"given": "JANE", "family": "VD DOE", "suffix": "PHD"}, classification="fix(#380)", ambiguities=("suffix-or-name",), notes="#573's boundary: with nothing behind it 'VD' ends the " "name, and P6's attachment holds as for 'Berg, Jan vd'. " "2.2.0 and 2.3.0 read the same; 1.4.0 suffix 'PHD, VD'"), Case("a_never_given_particle_after_a_comma_takes_the_member", "SMITH, VD MA", {"family": "SMITH VD MA"}, classification="fix(#573)", ambiguities=("suffix-or-name",), notes="P1: a never-given particle opening the given " "part needs a name word to attach to, so the member " "behind it is that word rather than a credential with " "a given name to spare -- the fold then takes both, as " "for 'Smith, vd Ma'. Master read family 'SMITH VD', " "suffix 'MA'; 2.0.0 to 2.3.0 read as here; 1.4.0 last " "'VD SMITH', suffix 'MA'"), Case("a_particle_run_opening_the_given_part_takes_the_member", "SMITH, VD DE LA MA", {"family": "SMITH VD DE LA MA"}, classification="fix(#573)", ambiguities=("suffix-or-name",), notes="the run before the member is all particles, chained " "('DE LA') or not, so 'MA' is still the word they " "attach to"), Case("capitals_still_make_the_member_a_credential", "Smith, de MA", {"family": "Smith de", "suffix": "MA"}, classification="fix(comma-precomma-family)", notes="S6: in a mixed-case name the capitals speak even with " "no words to spare, so the never-given lead does not " "take 'MA' -- 'Smith, de Ma' does take it"), Case("an_ambiguous_particle_after_a_comma_keeps_the_given_name", "SMITH, VAN MA", {"given": "VAN", "family": "SMITH", "suffix": "MA"}, ambiguities=("suffix-or-name",), notes="#573's boundary: 'VAN' can be a given name, so the " "slot's given is there and the member behind it reads " "as the credential, as for 'SMITH, JOHN MA'. 1.4.0 " "reads the same"), # #575: rules/C3's count before the comma counts a particle # chain (P2) as ONE name word -- it builds one family name -- so a # particle surname reads as a one-word surname does ('Royce, Ed', # 'Berg, MA', 'Berg, PhD'). Case("a_particle_surname_before_a_comma_is_one_name_word", "De La Cruz, Ed", {"given": "Ed", "family": "De La Cruz"}, classification="fix(#575)", ambiguities=("suffix-or-name",), notes="the count read 'De La Cruz' as three name words and " "flipped to a credential comma, leaving no given name. " "2.3.0 read given 'Ed' too; 1.4.0 read suffix 'Ed'", shape=2), Case("a_lowercase_particle_surname_before_a_comma_is_one_name_word", "de la Cruz, Ma", {"given": "Ma", "family": "de la Cruz"}, classification="fix(#575)", ambiguities=("suffix-or-name",), notes="as 'Cruz, Ma' reads. 2.3.0 read given 'Ma' too; 1.4.0 " "read suffix 'Ma'", shape=2), Case("an_ambiguous_particle_chain_before_a_comma_stays_whole", "van der Berg, MA", {"family": "van der Berg", "suffix": "MA"}, classification="fix(#575)", notes="one name word before the comma, so C4 reads the case " "and capitals in a mixed-case name make 'MA' the " "credential, as 'Berg, MA' reads. The count had split " "the chain: given 'van', family 'der Berg'. 2.3.0 read " "given 'MA', family 'van der Berg'", shape=2), Case("a_leading_particle_that_could_be_a_given_name_is_settled_by_the_comma", "Van Buren, Ed", {"given": "Ed", "family": "Van Buren"}, classification="fix(#575)", ambiguities=("suffix-or-name",), notes="standing alone 'Van Buren' is P1's fork (given 'Van'); " "the listing form puts a surname before the comma, so " "the comma settles it. The count had read two name " "words: given 'Van', family 'Buren', suffix 'Ed'. " "2.3.0 read given 'Ed', family 'Van Buren'", shape=2), Case("a_connective_surname_is_not_one_name_word_before_a_comma", "Ortega y Gasset, Ed", {"given": "Ortega", "middle": "y", "family": "Gasset", "suffix": "Ed"}, ambiguities=("suffix-or-name",), notes="#575 boundary: only a particle chain counts as one name " "word. P3 leaves a single-letter connective in a " "three-word name a name word, so 'Ortega y Gasset' is " "three even standing alone (given 'Ortega', middle 'y'), " "and the count follows it. 2.3.0 read given 'Ed'", shape=3), Case("a_particle_surname_before_an_unambiguous_credential_stays_whole", "van der Berg, PhD", {"family": "van der Berg", "suffix": "PhD"}, classification="fix(#575)", notes="as 'Berg, PhD' reads. Every release split the chain on " "v1's token count: given 'van', family 'der Berg', " "suffix 'PhD'", shape=2), Case("a_particle_surname_after_a_given_name_is_still_a_full_name", "John van Buren, Ed", {"given": "John", "family": "van Buren", "suffix": "Ed"}, ambiguities=("suffix-or-name",), notes="#575 boundary: a given name and a particle surname are " "two name words, so the part after the comma is still " "the credential run", shape=3), Case("a_bound_given_pair_before_a_comma_counts_as_two_name_words", "abdul Salam, Ed", {"given": "abdul", "family": "Salam", "suffix": "Ed"}, ambiguities=("suffix-or-name",), notes="#575 boundary: a bound pair (P5) builds a GIVEN name and " "gives up a family word when the name has no other " "('abdul Salam' alone is given 'abdul', family 'Salam'), " "so it is not one surname before a comma", shape=3), Case("a_title_particle_before_a_comma_is_still_a_title", "Freiherr von Berg, PhD", {"title": "Freiherr", "family": "von Berg", "suffix": "PhD"}, ambiguities=("particle-or-given",), notes="#575 boundary: a word of both the title and the particle " "vocabulary is not a particle to the count before the " "comma, so it stays a title in front of the surname. The " "first draft of #575 counted 'Freiherr von Berg' as one " "surname and folded the title into the family"), Case("a_bound_given_particle_surname_before_a_comma_is_one_name_word", "Abu Bakar, Ed", {"given": "Ed", "family": "Abu Bakar"}, classification="fix(#575)", ambiguities=("suffix-or-name",), notes="'abu' is a bound given-name head and a particle; before " "a comma it is the particle, so 'Abu Bakar' is one " "surname, as 2.0 through 2.3 read it. 1.4.0 and this " "cycle's count read given 'Abu', family 'Bakar', suffix " "'Ed'. Untagged: its 1.4.0 diff is the rule's own"), Case("a_title_particle_inside_a_surname_chains", "de St Pierre, Ed", {"given": "Ed", "family": "de St Pierre"}, classification="fix(#575)", ambiguities=("suffix-or-name",), notes="#575: a title-particle stops being a particle only where " "it LEADS the part; inside a surname it chains, so 'de St " "Pierre' is one name word, as 2.3.0 read it. A draft that " "excluded 'St' in every position read family 'de St " "Pierre', suffix 'Ed', no given name"), Case("a_suffix_inside_the_part_stops_the_particle", "van Jr. Berg, Mr.", {"title": "Mr.", "given": "van", "middle": "Jr.", "family": "Berg"}, ambiguities=("particle-or-given",), notes="#575 boundary: the particle reaches no further than a " "suffix word, so 'van' and 'Berg' are two name words and " "the part keeps its positional read. A first draft " "walked past the suffix and read family 'van Berg'"), Case("a_title_in_front_keeps_the_particle_fork", "Dr. van der Berg, PhD", {"title": "Dr.", "given": "van", "family": "der Berg", "suffix": "PhD"}, ambiguities=("particle-or-given",), notes="#575 boundary: the count for an unambiguous credential " "counts the title as a word, so the comma reads as a " "credential comma and the part reads as it does alone, " "P1's fork and all. Unchanged"), # #575: C3's count before the comma (`_comma._whole_name`, which # decides the positional read after a comma followed by no name # word) applies the title-particle exclusion # by position too. These two pin that flag both ways -- a review # mutant fixing it to False or True passed every other test. Case("a_leading_title_particle_stays_a_title_before_a_title_only_comma", "St John, Dr.", {"title": "St Dr.", "family": "John"}, notes="#575 boundary: 'St' opens the part, so it is the title " "it also is and 'John' alone is the name: two words do " "not stand before the comma, the positional read holds. " "2.3.0's reading"), Case("an_inner_title_particle_chains_before_a_credential_only_comma", "von St Johann, PhD", {"family": "von St Johann", "suffix": "PhD"}, classification="fix(#575)", notes="#575: 'St' inside the surname is a particle, so 'von St " "Johann' is one name word and the family comma keeps it " "whole. 2.3.0 read given 'von'"), Case("the_anchor_does_not_reach_across_a_maiden_clause", "Jane Doe Jr. nee Smith Ma", {"given": "Jane", "family": "Doe", "suffix": "Jr. nee Smith Ma"}, shape=1, classification="fix(#601)", ambiguities=("suffix-or-name", "suffix-or-name", "suffix-or-name"), notes="#601: a marker behind a title or a suffix word is an ordinary " "word (rules/M2). Before #601: given 'Jane', family 'Doe', " "suffix 'Jr.', maiden 'Smith Ma'. As pinned then: rules/M2's " "boundary: the clause's name words stand between 'Jr.' and " "'Ma', so the credential in front speaks for nothing past the " "marker and the clause keeps its member, where 'Jane Doe Jr. " "Ma' reads suffix 'Jr. Ma'. Unchanged by #544; 2.3.0 read the " "same fields without the report, and 1.4.0 had no maiden " "routing"), Case("a_degree_after_a_one_word_family_anchors_the_member", "Smith, PhD MEng", {"family": "Smith", "suffix": "PhD MEng"}, classification="fix(#544)", ambiguities=("suffix-or-name",), notes="one name word before the comma, so C1 reads the " "listing form, and the part holds no name word once " "'PhD' anchors 'MEng': the credential run, whole, and " "'MEng' reports as the pick the anchor made, its own " "writing having declined. 2.3.0 read the same fields " "without the report; #540 had read given 'PhD', middle " "'MEng', reporting 'MEng', and 1.4.0 title 'PhD', first " "'MEng'", shape=2), Case("an_anchored_pick_after_a_one_word_family_reports", "Smith, PhD Ma", {"family": "Smith", "suffix": "PhD Ma"}, classification="fix(#544)", ambiguities=("suffix-or-name",), notes="the part is read wholly as credentials because 'PhD' " "speaks for the Title-case 'Ma' (S7's company), and the " "pick is reported where the anchor made it. 2.2.0 " "and 2.3.0 read given 'PhD', middle 'Ma'; 1.4.0 through " "2.1.0 title 'PhD', first 'Ma'. 'Smith, PhD MA' reads the " "same fields in silence, the capitals having decided " "the member", shape=2), Case("an_anchored_pick_before_a_trailing_title_reports", "John Smith, PhD Ma Prof.", {"title": "Prof.", "given": "John", "family": "Smith", "suffix": "PhD Ma"}, classification="fix(#544)", ambiguities=("suffix-or-name",), notes="the trailing title keeps C4's run test from flipping " "the comma, so `_comma.decide` reads the part wholly " "as credentials plus the title (H5) and makes it a " "suffix comma, 'John Smith' keeping its positional " "read, and the member the anchor decided reports " "there: one report for 'Ma', where the twin 'John Smith, PhD Ma' reports " "C11's flip once over the whole part. 2.3.0 read title " "'Prof.', given 'PhD', middle 'Ma', family 'John " "Smith', and 2.2.0 middle 'Ma Prof.'; 1.4.0 through " "2.1.0 title 'PhD', first 'Ma', middle 'Prof.'"), Case("an_anchored_pick_twin_without_the_title_reports_the_flip", "John Smith, PhD Ma", {"given": "John", "family": "Smith", "suffix": "PhD Ma"}, ambiguities=("suffix-or-name",), notes="C4's run rule, reporting once over the whole part; the " "pair to the row above. 1.4.0 read the same; 2.2.0 " "and 2.3.0 read given 'PhD', middle 'Ma', family 'John " "Smith', and 2.0.0 and 2.1.0 title 'PhD', given 'Ma'", shape=3), Case("a_degree_in_the_given_part_anchors_the_member", "Doe, Jane PhD MEng", {"given": "Jane", "family": "Doe", "suffix": "PhD MEng"}, classification="fix(#436/#437)", ambiguities=("suffix-or-name",), notes="the given part's trailing slot (#531) asks the " "company once the member's writing declines. 2.3.0 read " "the same fields; #540 had read middle 'MEng', and " "1.4.0 wrote the suffix 'PhD, MEng'", shape=2), Case("the_anchor_reads_inside_a_clause_after_a_comma", "Doe, Jane nee Smith PhD MEng", {"given": "Jane", "middle": "nee Smith", "family": "Doe", "suffix": "PhD MEng"}, shape=2, classification="fix(#601)", ambiguities=("suffix-or-name",), notes="#601: a marker in the given part after a family comma is an " "ordinary word (rules/M2). Before #601: given 'Jane', family " "'Doe', suffix 'PhD MEng', maiden 'Smith'. As pinned then: the " "maiden walk's given-slot reader asks the same company over the " "name the take would leave. 2.3.0 read the same; #540 had read " "middle 'MEng', and 1.4.0 had no maiden routing"), Case("the_clause_gives_up_a_member_a_later_suffix_anchors", "Doe, Jane nee Smith MA Jr Ed", {"given": "Jane", "middle": "nee Smith", "family": "Doe", "suffix": "MA Jr Ed"}, classification="fix(#601)", ambiguities=("suffix-or-name",), notes="#601: a marker in the given part after a family comma is an " "ordinary word (rules/M2). Before #601: given 'Jane', family " "'Doe', suffix 'MA Jr Ed', maiden 'Smith'. As pinned then: the " "release check the maiden walk asks at the member it stops on " "reads the span behind it the way assign's given slot does, " "anchors included: 'Jr' speaks for the Title-case 'Ed', so " "every word behind 'MA' reads as a suffix and the clause gives " "'MA' up. 2.3.0 read middle 'Ed', suffix 'Jr', maiden 'Smith " "MA', and 1.4.0 had no maiden routing"), Case("the_clause_gives_up_a_title_a_later_suffix_anchors_past", "Doe, Jane nee Smith Dr. Jr Ma", {"title": "Dr.", "given": "Jane", "middle": "nee Smith", "family": "Doe", "suffix": "Jr Ma"}, classification="fix(#601)", ambiguities=("suffix-or-name",), notes="#601: a marker in the given part after a family comma is an " "ordinary word (rules/M2). Before #601: title 'Dr.', given " "'Jane', family 'Doe', suffix 'Jr Ma', maiden 'Smith'. As " "pinned then: the same release check at a title the walk stops " "on: the given part's title chain runs from the end through " "'Ma' only because 'Jr' anchors it, and so reaches 'Dr.', which " "the clause gives up. 2.3.0 read middle 'Ma', suffix 'Jr', " "maiden 'Smith Dr.', and 1.4.0 had no maiden routing"), Case("a_dual_opening_the_given_part_is_a_title_there", "Smith, Ms Ma", {"title": "Ms", "given": "Ma", "family": "Smith"}, notes="the dual exclusion's scope: with ONE name word before " "the comma 'Ms' opens the given part, reads as the " "title there, and speaks for nothing, so 'Ma' stays the " "given name. 1.4.0 and 2.3.0 read the same", shape=2), Case("a_dual_opening_the_given_part_anchors_nothing", "Smith, RA MEng", {"title": "RA", "given": "MEng", "family": "Smith"}, notes="the same exclusion for the other chunked member. " "1.4.0 read the same; 2.3.0 read suffix 'RA MEng', " "the reading #540's marking moved"), Case("a_dual_opening_the_given_part_turns_the_anchor_off", "Smith, Ms RA Ma", {"title": "Ms RA", "given": "Ma", "family": "Smith"}, notes="a dual opening the given part is a title there, and a " "part whose leading title run holds one is anchored by " "nothing, the second dual standing in that same run. " "1.4.0 and 2.3.0 read the same", shape=2), Case("a_dual_opening_the_given_part_silences_a_later_degree", "Smith, RA PhD Ma", {"title": "RA", "given": "PhD", "middle": "Ma", "family": "Smith"}, classification="fix(#296)", ambiguities=("suffix-or-name",), notes="the title reading of the opening dual makes 'PhD' the " "given name, and reading the whole part as credentials " "instead would stack the anchor's guess on the dual's, " "so 'PhD' speaks for nothing here and 'Ma' ends the " "given part as a middle name, reported by the given " "slot. 2.2.0 and 2.3.0 read the same fields; 1.4.0 " "through 2.1.0 title 'RA PhD', first 'Ma', 'phd' being " "title vocabulary until #296", shape=2), Case("a_numeral_in_front_anchors_nothing", "smith, v ed", {"given": "v", "family": "smith", "suffix": "ed"}, ambiguities=("suffix-or-name",), notes="a single-letter roman numeral, in any case, speaks " "for nothing for its shape, one letter being how a " "middle initial is written (a multi-letter one such as " "'III' would speak): " "'ed' is read by " "the given slot's own rule. 1.4.0 read the same; 2.3.0 " "read middle 'ed'"), Case("an_anchored_particle_member_outranks_p6", "doe, jane v phd do", {"given": "jane", "middle": "v", "family": "doe", "suffix": "phd do"}, classification="fix(#436/#437)", ambiguities=("suffix-or-name",), notes="'do' is a member AND a particle, and written in one " "case it leans nothing, so P6's attachment would take " "it (as in 'NASCIMENTO, EDSON ARANTES DO'); the degree " "in front outranks the attachment as the capitals do " "(Derek, 2026-09-27). 1.4.0 read suffix 'v, phd, do' " "and 2.3.0 family 'do doe'. The 'v' is the one-case " "initial its capital is since #721, as 'DOE, JANE V " "PHD DO' reads middle 'V' (S10, the trailing roman " "numeral), where 1.4.0 and #718 read it in the suffix", shape=2), Case("by_design_trailing_mc_reads_as_a_credential", "Donald Mc", {"given": "Donald", "suffix": "Mc"}, classification="fix(suffix-routing)", ambiguities=("given-or-family",), notes="#454, dispositioned by design with this bundle. " "1.4.0 read last 'Mc'; fix(suffix-routing) -- a " "two-token name ending in a credential acronym keeps " "it in `suffix` -- is the divergence. This is the one " "row whose point is that it must NOT change. 'mc' is " "SUFFIX_ACRONYMS and PARTICLES both, and it is not a " "borne name: it has no vowel, and the Scottish prefix " "never detaches from the name it belongs to, so " "'Donald Mc' is not a name anyone writes and its " "suffix reading costs nothing real. rules/P6's " "attachment is scoped to shapes where something has " "already named the family, which this has not, so S2 " "takes the trailing word -- 'an unambiguous suffix is " "consumed even when that leaves no family name at " "all'. The neighbours that DO hold: 'Mc Donald' reads " "family 'Mc Donald' and 'John van Mc' family 'van " "Mc', both because 'mc' is a never-given particle " "(#360), which is the membership that actually bears " "on #454's example. See " "decisions/suffix-acronym-collisions. The peel " "leaves 'Donald' the only name word, so O5's " "convention places it and says so (#449): roles " "parity, the flag is #449's"), Case("ambiguous_acronym_suffix_with_middle", "John Q Smith MA", {"given": "John", "middle": "Q", "family": "Smith", "suffix": "MA"}), # #604: the one-letter particle against the initial it spells # (rules/P7). The period decides, in every position a particle # reading could otherwise claim the letter. Case("one_letter_particle_written_bare", "Juan Ó Pérez", {"given": "Juan", "family": "Ó Pérez"}, classification="fix(#604)", notes="the bare letter is the Irish particle and opens the " "surname; 1.4.0 read it as a middle initial. The " "contrast row for the two below"), Case("one_letter_particle_with_period_is_an_initial", "Juan Ó. Pérez", {"given": "Juan", "middle": "Ó.", "family": "Pérez"}, notes="Ó. is Óscar's initial: 'ó' in the particle vocabulary " "alone gave family 'Ó. Pérez', the particle match " "folding the period away (#604)"), Case("one_letter_particle_with_period_after_family_comma", "Pérez, Juan Ó.", {"given": "Juan", "middle": "Ó.", "family": "Pérez"}, notes="P6's site: without P7 the trailing 'particle' attached " "to the family behind the comma, family 'Ó. Pérez' " "(#604)"), Case("ambiguous_particle_ua_leading", "Ua Buachalla", {"given": "Ua", "family": "Buachalla"}, ambiguities=("particle-or-given",), notes="ua joined the AMBIGUOUS half, not the never-given one " "(#604, decisions/vocabulary-collisions): leading, " "it keeps the given reading and reports the fork, " "where never-given would have made the string " "surname-only"), Case("titled_ambiguous_particle_does_not_chain", "Dr. Van Johnson", {"title": "Dr.", "given": "Van", "family": "Johnson"}, classification="fix(#367)", ambiguities=("particle-or-given",), notes="reads exactly as the untitled 'Van Johnson' does, " "because a title is not part of the name and so cannot " "decide whether the NAME begins with a particle (#367). " "This row pinned the opposite until 2.2 -- title 'Dr.', " "family 'Van Johnson', the chain having fired because " "the title shifted Van off piece index 0 -- and cited " "v1 parity for it. Parity was real (1.4.0 gives last " "'Van Johnson') and still not the tiebreaker it looked " "like: this is the SAME shape as 'Mr. Van Nguyen', " "which v1 shipped as an xfail calling the reading " "wrong, so v1 pinned one shape both as correct and as " "broken. Resolved toward the xfail, which now passes " "(tests/test_first_name.py::" "test_first_name_is_prefix_if_three_parts). The fork is " "still reported, from assign rather than group -- the " "same place the untitled 'Van Johnson' reports it"), Case("titled_ambiguous_particle_keeps_its_middles", "Dr. Van Johnson Smith", {"title": "Dr.", "given": "Van", "middle": "Johnson", "family": "Smith"}, classification="fix(#367)", ambiguities=("particle-or-given",), notes="the release log names this shape and nothing asserted " "it. 1.4.0 gives title 'Dr.', last 'Van Johnson Smith'; " "un-chaining leaves three name pieces, so the middle " "appears where the whole thing used to be one surname"), Case("given_name_title_ambiguous_particle", "Sir Van Johnson", {"title": "Sir", "given": "Van", "family": "Johnson"}, classification="fix(#367)", ambiguities=("particle-or-given",), notes="a GIVEN-NAME title, which is the worse half of the bug " "#367 fixed: 1.4.0 and 2.1 alike gave first " "'Van Johnson' and no family name at all, the chain " "having fired and then been handed whole to `given`. " "Now identical to the untitled 'Van Johnson'"), Case("given_name_title_never_given_particle", "Sir de Mesnil", {"title": "Sir", "family": "de Mesnil"}, classification="fix(#367)", notes="the never-given half: `de` is not an ambiguous " "particle, so there is no fork to report and post_rules " "1b folds the name into the family. 1.4.0 and 2.1 gave " "first 'de Mesnil' with no family, because the chain " "left 1b nothing standing alone to fire on"), # #606: salutations in languages TITLES had none for, against a # salutation left out because it leads a real name. Case("salutation_in_a_new_language_is_a_title", "Herra Väinö Johansson", {"title": "Herra", "given": "Väinö", "family": "Johansson"}, classification="fix(#606)", notes="Finnish Mr; every release read given 'Herra', middle " "'Väinö'. The contrast row is the one below"), Case("salutation_that_leads_a_surname_stays_a_name", "Ông Văn Tùng", {"given": "Tùng", "middle": "Văn", "family": "Ông"}, policy=Policy(name_order=FAMILY_FIRST_GIVEN_LAST), notes="Vietnamese ông (Mr) is excluded from TITLES: Ông is a " "surname and Vietnamese names lead with the surname, " "the position the title claim acts on " "(decisions/salutation-titles)"), Case("office_completes_a_title_run", "Police Commissioner James Gordon", {"title": "Police Commissioner", "given": "James", "family": "Gordon"}, classification="fix(#606)", notes="'police' was a title and 'commissioner' was not, so the " "office read as the given name"), Case("surname_borne_title_claims_the_front", "Greve Anna", {"title": "Greve", "family": "Anna"}, classification="fix(#606)", notes="greve is a Scandinavian count AND a surname; shipped on " "the graf precedent, so the uncommaed family-first form " "loses its surname as 'Graf Steffi' does " "(rules/H1's Accepted clause). The two rows below are " "the readings it keeps"), Case("surname_borne_title_trailing_is_the_surname", "Anna Greve", {"given": "Anna", "family": "Greve"}, notes="the title claim acts only in front (#606)"), Case("surname_borne_title_before_a_comma_is_the_surname", "Greve, Anna", {"given": "Anna", "family": "Greve"}, notes="the family comma fixes the surname (#606)"), Case("salutation_in_indonesian", "Bapak Dwi Putro", {"title": "Bapak", "given": "Dwi", "family": "Putro"}, classification="fix(#662)", notes="bapak (Indonesian Mr.) was read as the given name " "(decisions/salutation-titles)"), Case("salutation_in_akan", "Owura Kwame Mensah", {"title": "Owura", "given": "Kwame", "family": "Mensah"}, classification="fix(#662)", notes="owura (Akan Mr.), likewise"), Case("akan_salutation_trailing_is_a_name_word", "Kwame Mensah Owura", {"given": "Kwame", "middle": "Mensah", "family": "Owura"}, notes="the title claim acts only in front (#662)"), Case("akan_salutation_before_a_comma_is_a_name_word", "Owura, Kwame", {"given": "Kwame", "family": "Owura"}, notes="the family comma fixes the surname (#662)"), Case("knt_is_a_post_nominal", "Sir John Smith Knt", {"title": "Sir", "given": "John", "family": "Smith", "suffix": "Knt"}, classification="fix(#606)", notes="knt (Knight) joined the suffix acronyms beside kt; " "every release read family 'Knt', middle 'Smith'"), Case("knt_after_a_comma_is_a_suffix", "John Smith, Knt.", {"given": "John", "family": "Smith", "suffix": "Knt."}, classification="fix(#606)", notes="every release read title 'Knt.', the period shape " "claiming an unlisted word in front of the given part"), Case("title_plus_one_word_with_maiden", "Dr. Smith née Jones", {"title": "Dr.", "family": "Smith", "maiden": "Jones"}, classification="fix(#410)", notes="H1's 'and nothing else' counted a maiden name as a " "further name word, so adding a maiden clause moved the " "surname into `given` and emptied `family`: 'Dr. Smith' " "reads family 'Smith' and 'Dr. Smith née Jones' read " "given 'Smith'. A maiden name is announced beside the " "name, not part of it. 1.4.0 had no maiden SUPPORT -- " "the field exists, but no default marker vocabulary " "routes to it -- and read first 'Smith', middle 'née', " "last 'Jones'"), Case("title_plus_one_word_with_maiden_particle_spelling", "Freiherr von Richthofen geb. Albrecht", {"title": "Freiherr", "family": "von Richthofen", "maiden": "Albrecht"}, classification="fix(#410)", ambiguities=("particle-or-given",), notes="the spelling #410 was found through: #399 stopped the " "particle chain at the marker, which routed the " "canonical title-and-particle shape into H1 for the " "first time and so into this bug. 1.4.0 read the whole " "tail as one surname, last 'von Richthofen geb. " "Albrecht'"), Case("given_name_title_plus_one_word_with_maiden", "Sir John née Jones", {"title": "Sir", "given": "John", "maiden": "Jones"}, classification="fix(#274)", notes="H1's carve-out is untouched by #410: a given-name " "title addresses by given name, so the one name word " "stays `given` and the family stays empty, exactly as " "'Sir John' reads, under either family-first order " "too since #720, H1 moving back a word assign placed " "in the family. The boundary of the widening, and " "the row that fails if the retag is made unconditional. " "The fix classification is #274's marker consumption, " "which is what makes this differ from 1.4.0 (first " "'John', middle 'née', last 'Jones')"), Case("title_plus_one_word_comma_suffix", "Dr. Pope, Jr.", {"title": "Dr.", "family": "Pope", "suffix": "Jr."}, ambiguities=("title-or-name",), classification="fix(#410)", notes="the most ordinary shape H1's widening touches, and " "the one the suite could least afford to leave " "unpinned: mutating H1 to decline on any name carrying " "a comma left all 5819 tests green. Nothing else sees " "the class -- the corpus-wide maiden-clause property " "test filters commas out of its parametrization " "(_clause_free_latin_corpus_names), and " "'Smith, Dr.' takes its family from the comma rule " "(C1), not from H1. 1.4.0 had the same empty family " "here (title 'Dr.', first 'Pope', last ''), so this " "row records a v1 bug fixed, not a v2 divergence. The " "`title-or-name` flag is H4's and not #410's: `pope` is " "title vocabulary and a suffix beside the word is not a " "shape that silences the convention"), Case("title_plus_one_word_multi_word_maiden", "Dr. Smith née Mary Jones", {"title": "Dr.", "family": "Smith", "maiden": "Mary Jones"}, classification="fix(#410)", notes="the maiden name at two words rather than one. H1 " "counts what stands in the NAME, so the arity of the " "clause beside it is irrelevant -- a rule that " "declined on a second maiden token would pass every " "single-word row above. 1.4.0 read first 'Smith', " "middle 'née Mary', last 'Jones'"), Case("given_name_title_plus_one_word_multi_word_maiden", "Sir John née Mary Jones", {"title": "Sir", "given": "John", "maiden": "Mary Jones"}, classification="fix(#410)", notes="the carve-out at the same arity: 'whatever maiden " "name stands beside it' has to leave the given-name " "title alone however long the clause is. Without this " "row the `whatever` is asserted only at one word. " "1.4.0 read first 'John', middle 'née Mary', last " "'Jones'"), Case("title_plus_one_word_two_suffixes", "Dr. Smith PhD Jr.", {"title": "Dr.", "family": "Smith", "suffix": "PhD Jr."}, classification="fix(#410)", notes="two suffix pieces, not one. The removed term asked " "whether ANY token carried a suffix role, so a guard " "rebuilt to decline on the SECOND one would look " "correct against every row that carries a single " "credential. 1.4.0 split them, reading first 'Smith', " "last 'PhD', suffix 'Jr.'. The comma between them was " "the shape machinery's artifact and never a reading " "anyone chose: the writer typed no comma, so #436 " "renders none (rules/R1)"), Case("title_plus_one_word_nickname_and_suffix", "Dr. (Bud) Smith Jr.", {"title": "Dr.", "family": "Smith", "suffix": "Jr.", "nickname": "Bud"}, classification="fix(#410)", notes="two DIFFERENT annotations at once, which is the " "combination the single-annotation rows cannot reach: " "a guard declining only where a nickname and a suffix " "are both present passes all of them. 1.4.0 read first " "'Smith', last 'Jr.' with nickname 'Bud' -- the " "credential taking the family slot"), # The smallest shape in which the two family-first orders can # disagree about this rule's leftovers: with one leftover both # send it to `given`, and two or more is what separates them (the # orders differ on plenty of names outside this rule -- 186 of the # 751 corpus names -- so the claim is about the fold, not about # the orders). Spanish because the listing is real: "Apellidos # Nombres" keeps the particle in place, where Dutch moves it # behind the given name ("Jong, Jan Pieter de", the tussenvoegsel # convention -- rule P6's subject, though P6's attachment is # deviates: #379, so that spelling does not yet parse the way P6 # describes). Added before #395 landed, when all three orders # still agreed, each taking the whole name into the family. Case("leading_never_given_particle_two_leftovers", "de la Cruz Juan Carlos", {"family": "de la Cruz Juan Carlos"}, classification="parity", notes="the DEFAULT order, which #395 leaves alone: with no " "order declared there is no evidence that 'Juan Carlos' " "is anything but more surname, and a particle followed " "by several words really can be all surname -- 'von " "Bergen Wessels' is one. (An earlier draft cited " "'pennie von bergen wessels' as the same SHAPE, which " "it is not: pennie is the given name there and von is " "ambiguous, so this fold cannot fire on it. See " "decisions/P1, 2026-08-17.) 1.4.0 gives last 'de la " "Cruz Juan Carlos' too, so this row must not move when " "#395 lands. #471 DECLINED 2026-09-07: the reach is " "the reading and not a gap in one -- a declared order " "is a property of the data source, so under the " "default order nothing has said this surname ends " "before the string does. See decisions/P1"), Case("leading_never_given_particle_two_leftovers_family_first", "de la Cruz Juan Carlos", {"family": "de la Cruz", "given": "Juan", "middle": "Carlos"}, policy=Policy(name_order=FAMILY_FIRST), classification="feat(#395)", notes="core-only: name_order has no v1 spelling. The run " "stops at 'Cruz' because the declared order says what " "follows the family is not more surname. It reaches " "'Cruz' THROUGH ambiguous 'la', which is the chain a " "stop keyed on never-given membership would break", shape=4), Case("leading_never_given_particle_two_leftovers_" "family_first_given_last", "de la Cruz Juan Carlos", {"family": "de la Cruz", "middle": "Juan", "given": "Carlos"}, policy=Policy(name_order=FAMILY_FIRST_GIVEN_LAST), classification="feat(#395)", notes="the row that makes the leftover DISTRIBUTION testable, " "and the divergence is here: FAMILY_FIRST reads 'Juan' " "as the given name, this order reads 'Carlos'. Nothing " "with fewer leftovers can tell the two apart. When " "PR #394 put the placing in grouping, its review found " "the whole suite passed with name_order discarded from " "it; on this branch the same mutation fails three " "tests, this row among them", shape=5), # The Dutch alphabetized listing: "Beethoven, Ludwig van" is how # "Ludwig van Beethoven" is filed, the tussenvoegsel moved behind # the given name but belonging to the surname (#379). Case("tussenvoegsel_after_family_comma", "Beethoven, Ludwig van", {"given": "Ludwig", "family": "van Beethoven"}, classification="fix(#379)", ambiguities=("particle-or-given",), notes="1.4.0 gives middle 'van', last 'Beethoven'. The " "particle attaches to the family the comma already " "named and renders before it, so the derived views " "move with it -- family_particles 'van', family_base " "'Beethoven', which is what #130 asked for. The " "textbook-correct Dutch listing reports the fork all " "the same (#405): the parser cannot tell it from " "'Nguyen, Thi Van', which is the same string shape. " "Shape 2's particle slot in the tussenvoegsel " "spelling, where the particle stands behind the given " "name rather than before the family", shape=2), Case("tussenvoegsel_multiword", "Berg, Jan van der", {"given": "Jan", "family": "van der Berg"}, classification="fix(#379)", ambiguities=("particle-or-given",), notes="the whole run attaches, not just its last word -- and " "one report covers the whole run, named for 'van', the " "word that is ambiguous vocabulary ('der' is never a " "given name)"), # #700: the two written forms P6 had no vocabulary for -- the # elided particle and the hyphen-attached Arabic article -- as # library catalogs file them, behind the given name. Case("an_elided_particle_after_a_family_comma", "Ivoi, Paul d'", {"given": "Paul", "family": "d' Ivoi"}, classification="fix(#700)", notes="1.4.0 and 2.4 gave middle \"d'\". A never-given " "particle, so the attachment reports nothing, as " "'Jong, Anke de' does. The family renders with a space, " "as every particle joins; the glued 'd'Ivoi' is one word " "and out of #700's scope"), Case("an_elided_particle_in_its_typographic_spelling", "Ivoi, Paul d’", {"given": "Paul", "family": "d’ Ivoi"}, classification="fix(#700)", notes="U+2019 is not folded to the ASCII apostrophe anywhere, " "so each elided entry is listed in both spellings. " "1.4.0 middle 'd’'"), Case("an_elided_particle_beside_a_suffix", "Ivoi, Paul d', Jr.", {"given": "Paul", "family": "d' Ivoi", "suffix": "Jr."}, classification="fix(#700)", notes="nameparser/AGENTS.md's comma sweep: a suffix in its own part leaves " "the given part's attachment alone. 1.4.0 middle \"d'\""), Case("an_elided_particle_beside_a_glued_comma", "Ivoi, Paul d',Jr.", {"given": "Paul", "family": "d' Ivoi", "suffix": "Jr."}, classification="fix(#700)", notes="nameparser/AGENTS.md's separator spacing: the comma " "glued to the " "apostrophe still ends the particle's token. 1.4.0 " "through 2.4.2 middle \"d'\""), Case("an_elided_particle_after_a_particle_surname", "van Ivoi, Paul d'", {"given": "Paul", "family": "d' van Ivoi"}, classification="fix(#700)", notes="a full name with its own particle before the comma; the " "attachment writes the trailing one in front of it. 1.4.0 " "through 2.4.2 middle \"d'\""), Case("an_elided_particle_after_an_empty_head", ", Paul d'", {"given": "Paul", "family": "d'"}, classification="fix(#700)", notes="a family comma naming no surname (rules/C15): P6 " "attaches the particle as behind any family comma, and it " "is the family alone, as ', Paul de' reads. 1.4.0 through " "2.4.2 middle \"d'\" with no family"), Case("an_elided_particle_in_its_typographic_spelling_t", "Hooft, Gerard ’t", {"given": "Gerard", "family": "’t Hooft"}, classification="fix(#700)", notes="the U+2019 twin of the Dutch 't, listed with the other " "elided twins. 1.4.0 through 2.4.2 middle '’t'"), Case("an_elided_particle_alone_after_a_family_comma", "Ivoi, d'", {"family": "Ivoi d'"}, classification="fix(#700)", notes="boundary: no given word remains to spare, so P6 does not " "attach; 'd'' being never-given now, the part reads as " "'Jong, de' has since 2.0, family 'Jong de'. 1.4.0 through " "2.4.2 gave given \"d'\", the word being no particle there"), Case("the_arabic_article_after_a_family_comma", "Rashid, Harun al-", {"given": "Harun", "family": "al- Rashid"}, classification="fix(#700)", notes="1.4.0 and 2.4 gave middle 'al-'. The hyphen is what makes " "'al-' never-given where the bare 'al' (Al Gore) is " "ambiguous, so no fork is reported"), Case("an_assimilated_arabic_article_after_a_family_comma", "Zaʿīm, Ḥusnī az-", {"given": "Ḥusnī", "family": "az- Zaʿīm"}, classification="fix(#700)", notes="the article before a sun letter, in DMG spelling as GND " "writes it. 1.4.0 middle 'az-'"), Case("an_elided_particle_ending_a_family_first_name", "Ivoi Paul d'", {"given": "Paul", "family": "d' Ivoi"}, policy=Policy(name_order=FAMILY_FIRST), classification="fix(#700)", notes="P6's family-first site reads the same vocabulary as its " "comma site, so the comma-less writing parses alike"), Case("an_elided_particle_written_apart_before_the_family", "Paul d' Ivoi", {"given": "Paul", "family": "d' Ivoi"}, classification="fix(#700)", notes="not #700's index form but the same vocabulary: the " "particle chains forward as 'de' does. 1.4.0 middle " "\"d'\""), Case("tussenvoegsel_outranks_the_suffix_reading", "Berg, Jan vd", {"given": "Jan", "family": "vd Berg"}, classification="fix(#380)", ambiguities=("suffix-or-name",), notes="'vd' is particle AND suffix vocabulary, and assign " "read the trailing one as a post-nominal (1.4.0 and " "2.1 alike gave suffix 'vd'). After a family comma the " "tussenvoegsel abbreviation is far more often the " "reading meant; P6 states that precedence over S2. " "The declined post-nominal is what the report names " "(#405), so the kind is suffix-or-name and not " "particle-or-given -- 'vd' is no given name in either " "reading. Also the control for #531's P6 condition, " "which declines a piece that is one token, roled SUFFIX " "and carries `vocab:suffix-ambiguous`: 'vd' is " "UNAMBIGUOUS suffix vocabulary, so it carries no such " "tag and stays inside P6's run -- byte-identical before " "and after #531. Narrowing that condition to the " "ambiguous tag is what buys it"), Case("a_trailing_particle_is_not_the_credential_runs_to_take", "Smith, John PhD de", {"given": "John", "family": "de Smith", "suffix": "PhD"}, classification="fix(#379)", notes="P6 over S8's given-part run (#602): a wholly-" "particle piece is left to the walk and P6 attaches it, " "so neither stage reports. The run had absorbed 'de' and " "reported it, and P6 then reported the attachment as a " "declined post-nominal -- two reports for a reading " "neither stage kept (code review of #601/#602, " "2026-10-04). 2.3.0 read this way; 1.4.0 middle 'de'"), Case("a_particle_between_credentials_is_not_the_runs_to_take", "Smith, John PhD de Jr.", {"given": "John", "family": "de Smith", "suffix": "PhD, Jr."}, classification="fix(#379)", notes="P6: the run it looks past the post-nominal to " "find is the same wholly-particle piece, so S8's run " "leaves it alone wherever it stands. 2.3.0 read this " "way; 1.4.0 middle 'de'"), Case("a_particle_p6_does_not_attach_stays_in_the_run", "Smith, John PhD de PhD van", {"given": "John", "family": "van Smith", "suffix": "PhD de PhD"}, classification="fix(#602)", ambiguities=("suffix-or-name", "particle-or-given"), notes="P6 attaches only the particles ending the part, so the " "run keeps 'de', a credential standing behind it and " "another particle past that, and reports it as a word it " "absorbed (fix-commit review of #601/#602, 2026-10-04). " "2.3.0 read middle 'de', family 'van Smith', suffix " "'PhD, PhD', the particle left a silent middle name " "between two credentials"), Case("a_trailing_particle_chain_keeps_its_own_report", "Smith, John PhD Jr. de la", {"given": "John", "family": "de la Smith", "suffix": "PhD Jr."}, classification="fix(#379)", ambiguities=("particle-or-given",), notes="P6: the attachment reports the ambiguous " "particle it overrode, as it did before #602, rather than " "a post-nominal the run had manufactured. 2.3.0 read this " "way; 1.4.0 middle 'de la', suffix 'PhD, Jr.'"), Case("tussenvoegsel_behind_a_post_nominal", "Berg, Jan van Jr.", {"given": "Jan", "family": "van Berg", "suffix": "Jr."}, classification="fix(#379)", ambiguities=("particle-or-given",), notes="the credential sits BEHIND the tussenvoegsel in this " "listing, so the run is found by walking past it. " "Without that walk the same name parsed two ways on " "whether a comma preceded the credential -- " "'Berg, Jan van, Jr.' attached and this one did not"), Case("tussenvoegsel_declines_with_no_given_word_left", "Smith, de Mesnil van", {"family": "Smith de Mesnil van"}, classification="fix(comma-precomma-family)", notes="P1's fold has already made all of segment 1 the " "family, so no GIVEN word remains and the " "attachment declines. Testing for any NAME role here " "instead would pass on family text P1 just produced, " "and hoist 'van' in front of a base it never preceded " "('van Smith de Mesnil'). NOT parity: 1.4.0 leaves no " "given name either, but renders last 'de Mesnil van " "Smith' -- it treats only the leading 'de' as a " "last-prefix and leaves 'van' inside the base. The " "structure agrees, the string does not"), Case("tussenvoegsel_behind_a_comma_post_nominal", "Berg, Jan van, Jr.", {"given": "Jan", "family": "van Berg", "suffix": "Jr."}, classification="fix(#379)", ambiguities=("particle-or-given",), notes="the credential in its own comma segment, which is the " "spelling the no-comma row is defined against -- both " "must read the same, and gating the rule on a two" "-segment name silently reverts this one"), Case("tussenvoegsel_behind_a_title", "Berg, Dr. Jan van", {"title": "Dr.", "given": "Jan", "family": "van Berg"}, classification="fix(#379)", ambiguities=("particle-or-given",), notes="the words-to-spare test asks whether ANY word ahead " "of the run holds a given role, not whether all of " "them do: the title does not, and the rule must still " "fire. decisions/P6 calls that guard load-bearing"), Case("tussenvoegsel_takes_the_vietnamese_reading", "Nguyen, Thi Van", {"given": "Thi", "family": "Van Nguyen"}, classification="fix(#379)", ambiguities=("particle-or-given",), notes="the accepted cost, pinned so it cannot move without " "someone deciding to move it: Nguyen Thi Van is " "family-middle-given, so the given name Van is lost " "here. The listing is identical to the Dutch one and " "nothing separates them. The comma-less " "FAMILY_FIRST_GIVEN_LAST spelling reads it correctly -- " "that ONE order, not family-first generally, which #467 " "made load-bearing by giving comma-less FAMILY_FIRST the " "same attachment (there it reads family Van Nguyen too). " "That surviving format is what makes the " "loss acceptable -- see rules/P6. Since #405 the " "loss is at least REPORTED: 'Van' is ambiguous " "vocabulary, so the attachment declines a live reading " "as a name word and says so"), Case("tussenvoegsel_report_names_the_reading_overridden", "Berg, Jan do", {"given": "Jan", "family": "do Berg"}, classification="fix(#379)", ambiguities=("particle-or-given",), notes="`do` is the third word in BOTH the particle and the " "suffix vocabularies, and it reports the OTHER kind " "from vd and mc. The report names what the attachment " "overrode, not what the word is: `do` sits in the " "AMBIGUOUS acronym half, which already left it a name " "word, so assign never read it as a post-nominal and " "there was no credential reading to decline (#405). A " "rule keyed on 'is also suffix vocabulary' would say " "suffix-or-name here and be wrong for the one word " "that most looks like it should"), Case("tussenvoegsel_multiword_run_reports_on_its_reading", "Berg, Jan de vd", {"given": "Jan", "family": "de vd Berg"}, classification="fix(#379)", notes="the same point from the other side: `vd` IS suffix " "vocabulary, but behind `de` the run is read as name " "words rather than as a post-nominal, so nothing was " "declined and nothing is reported -- where the lone " "`Berg, Jan vd` reports suffix-or-name. Neither word " "is ambiguous vocabulary either, so no arm fires. The " "arms turn on the READING assign made, which is the " "only thing that makes them a fork"), Case("tussenvoegsel_never_given_reports_nothing", "Jong, Piet de", {"given": "Piet", "family": "de Jong"}, classification="fix(#379)", notes="the reporting boundary (#405): 'de' is never-given " "particle vocabulary and is not suffix vocabulary " "either, so the attachment declines no reading the " "parse could have taken and stays SILENT. A single " "kind covering every attachment would assert " "'particle or given' about a word that is no given " "name in any reading"), Case("tussenvoegsel_needs_a_given_word_to_spare", "Nguyen, Van", {"given": "Van", "family": "Nguyen"}, classification="parity", notes="the words-to-spare boundary: the only given word IS " "the particle, so it stays a given name rather than " "leaving the name with none. Vietnamese Van is exactly " "the case that guard protects"), # A family made only of particle vocabulary. Position decides: # nothing joins these words to a name, so they are not acting as # particles and they anchor the base (rules/R2, #404). Case("all_particle_family_anchors_its_own_base", "Anh Do", {"given": "Anh", "family": "Do"}, classification="parity", ambiguities=("suffix-or-name",), notes="the ROLES are parity -- 1.4.0 gives first 'Anh', last " "'Do' too -- and the views are what #404 moved, which " "this table cannot assert (no initials or base column; " "rules/R2/#R3 carry those). Worth recording which " "way: 1.4.0's own _split_last guard kept last_base " "'Do', so the empty family_base was a 2.0 regression " "and this restores it. The initials half was broken in " "both: 'A.' at 1.4.0 and 2.1, 'A. D.' now"), Case("all_particle_family_multi_word", "Juan van der", {"given": "Juan", "family": "van der"}, classification="parity", notes="roles are parity again; R3's Accepted block used to " "pin the views the other way, reasoning that 'van der' " "has no borne name to anchor a base. Position trumps " "that -- neither word joins anything here, so both are " "name words. 1.4.0 also gave last_base 'van der'; the " "core's '' was the 2.0 regression"), Case("particle_beside_a_name_still_a_particle", "Juan de la Vega", {"given": "Juan", "family": "de la Vega"}, classification="parity", notes="the boundary the row above needs: here the particles " "DO join a name word, so they stay particles -- base " "'Vega', particles 'de la', initials 'J. V.'. Shape " "1's particle-bearing family slot", shape=1), Case("suffix_word_title_ambiguous_particle", "Jr. Van Johnson", {"title": "Jr.", "given": "Van", "family": "Johnson"}, classification="fix(#367)", ambiguities=("particle-or-given",), notes="a leading 'Jr.' classifies as a TITLE, not a suffix, " "which is why the transparency scan does not step over " "suffix pieces -- see tests/v2/pipeline/test_group.py::" "test_a_suffix_shaped_leading_piece_is_not_stepped_over. " "1.4.0 gives title 'Jr.', last 'Van Johnson'"), Case("titled_particle_chain_survives_a_title_that_is_also_a_particle", "Freiherr von Richthofen", {"title": "Freiherr", "family": "von Richthofen"}, ambiguities=("particle-or-given",), notes="#367's title transparency skips a piece that can ONLY " "be a title, never one that could be the name's own " "first piece. 'freiherr' is both a title and an " "ambiguous particle, so it stops the scan and stays the " "leading NAME piece; 'von' behind it is therefore " "non-leading and chains, exactly as before 2.2. This is " "also the CANONICAL shape reaching group's " "PARTICLE_OR_GIVEN emitter, not the only one -- 'St Van " "Johnson', 'Do St Johnson' and 'Dr. Do van Johnson' " "reach it too, the last with a plain title ahead of the " "both-vocabulary word; see tests/v2/test_parser.py -- " "and the class that a plain " "'first piece that is not a title' test broke: it " "skipped 'St'/'Do'/'Freiherr' and collapsed the " "untitled 'St John Smith' into one given name"), Case("a_part_past_the_second_reports_no_particle_fork", "John Smith, Jr., Freiherr von Richthofen", {"given": "John", "family": "Smith", "title": "Freiherr", "suffix": "Jr., von Richthofen"}, ambiguities=("comma-structure",), notes="the row above as a part past the second comma, which " "is consumed as suffixes but for its title words (C2): " "'von' ends a suffix, so the particle-or-given report " "that 'von' was chained onto a name piece named a " "reading the parse never makes, and since #636 group " "makes no chain in the part at all. Every 2.x release " "through 2.3.0 carried the report; the " "comma-structure flag is the part's own report. Since " "#603 'Freiherr', a title word, reads as the title it " "is there; until then the part read wholly as suffix " "'Jr., Freiherr von Richthofen'"), Case("titled_ambiguous_particle_no_op_chain", "St Van Jr.", {"title": "St", "family": "Van", "suffix": "Jr."}, notes="the piece after the particle is a suffix, so the chain " "scan never advances and the merge is a no-op -- nothing " "was chained, so there is no fork to report (the emitter " "fired here for all ambiguous particles, and _assign " "double-reported the same token). Spelled with 'St' " "since #296's audit took 'do' out of TITLES; before that " "with 'Do', and before 2.2 with 'Dr.': " "under #367 a plain title is transparent, so 'Dr. Van " "Jr.' leaves Van the leading name piece and the chain " "loop skips it without ever reaching the no-op. 'Do' is " "a title AND a particle, which stops the transparency " "scan, so the chain does fire on Van and the j > k + 1 " "guard is what declines it -- the same reading of the " "name, reached through the branch the row exists to pin. " "Not parity, and not #367's doing either: 1.4.0 reads " "'Do Van Jr.' as first 'Do Van', last 'Jr.', so the " "divergence is 2.0's suffix routing plus 'do' being a " "title -- both older than this row's respelling" " -- and since #410 the one name word left standing " "behind the title reads as the family, the suffix no " "longer counting as something else in the name", classification="fix"), Case("initial_shaped_not_conjunction", "john e. smith", {"given": "john", "middle": "e.", "family": "smith"}, notes="v1 is_conjunction excludes initials at classify too"), # #383/#479: a single-letter connective joins only on positive # evidence, and a name written wholly in one case has none. These # 17 rows pin the FORK, not the wordlist # (mechanisms.md#VOCABULARY-EXERCISES-FORKS). # # The 12 Latin e/y rows, plainly: 'e' at four words carries # one-case-lower ('jose e maria santos'), one-case-upper ('JOSE E # MARIA SANTOS'), mixed-lower where 'e' joins ('Jose e Maria # Santos'), and mixed-upper where 'E' is an initial ('Jose E Maria # Santos') -- four spellings, because within a mixed-case name the # letter's OWN case still decides which reading it gets. 'e' at # three words carries the same one-case-lower/one-case-upper/mixed- # lower trio ('john e smith' / 'JOHN E SMITH' / 'John e Smith'); # its mixed-upper twin ('John E Smith') is absent because it would # pin nothing these rows do not -- the three-word carve-out already # keeps 'e' a name word either way, exactly as # one_case_three_word_e_is_an_initial does. 'y' # carries one-case-upper and one-case-lower at four words ('JUAN # GARCIA Y LOPEZ' / 'juan garcia y lopez'), mixed-upper at four # words where 'Y' still vetoes ('Juan Garcia Y Lopez'), and one- # case-upper/one-case-lower at three words where the carve-out # already held ('JUAN Y GARCIA' / 'juan y garcia'). 'Juan Garcia y # Lopez' (mixed-lower, four words) is absent for the same reason as # 'John E Smith': it pins the same BRANCH the mixed-lower 'e' row # already demonstrates, a different JOIN though -- 'Garcia y Lopez' # into the family where 'Jose e Maria' joins into the given run. # 'Juan Y Garcia' (mixed-UPPER, three words) is absent because its # control lives beside the capitalize() pin instead, in # tests/test_capitalization.py::test_a_one_letter_conjunction_is_case_sensitive_to_repair; # 'Juan y Garcia' (mixed-lower, three words) is absent from this # table because rules/P3 carries it as its boundary example and # test_render.py pins its initials. # # The other five rows are three DIFFERENT reasons a row does not # move, not "three scripts that must not enter the fork": the # Cyrillic pair DOES enter the fork -- 'и' is cased conjunction # vocabulary and takes the non-member branch exactly as 'y' does, # joining without a report -- the Catalan pair's 'i' is Latin script # but is not conjunction vocabulary AT ALL, so it never reaches # `single_letter_connective` in the first place (a different, earlier exclusion # than Cyrillic's, and #397's before-picture); only the Arabic row # is genuinely caseless and so never enters the fork on that # ground. # # All 17 rows are shape-tagged so build_shapes_corpus.py projects # them into corpus_shapes.jsonl -- measured 2026-09-13, no corpus # file held a uniform-case name with a single-letter connective # beyond the eight already there, and none held a standalone و at # all, so without these the fork is invisible to the snapshot. Case("one_case_lower_e_reads_as_an_initial", "jose e maria santos", {"given": "jose", "middle": "e maria", "family": "santos"}, classification="fix(#479)", ambiguities=("conjunction-or-initial",), notes="#479 row 1, the defect. 1.4.0 and 2.0-2.3 alike read " "'e' as the connective and gave given 'jose e maria'. " "A lowercase letter in an all-lowercase name is no more " "evidence than an uppercase one in an all-uppercase " "name, so the vocabulary decides and 'e' is marked", shape=1), Case("one_case_upper_e_reads_as_an_initial", "JOSE E MARIA SANTOS", {"given": "JOSE", "middle": "E MARIA", "family": "SANTOS"}, ambiguities=("conjunction-or-initial",), notes="#479 row 4. The READING is unchanged from 1.4.0 and " "from 2.3 -- what is new is the report: an all-upper " "name's capital is not the evidence a mixed-case name's " "capital is, so the same fork was being called silently", shape=1), Case("one_case_e_opening_the_name_has_no_alternative", "E SMITH JONES", {"given": "E", "middle": "SMITH", "family": "JONES"}, ambiguities=("conjunction-or-initial",), notes="read as 1.4.0 reads it. The letter OPENS the name, so the " "connective reading has no word before it to join and no " "field to land in: the report carries no alternative " "(#752 phase 2m, rules/A1)", shape=1), Case("one_case_e_inside_a_joined_piece_lands_with_it", "MARIA DA SILVA E SOUZA", {"given": "MARIA", "family": "DA SILVA E SOUZA"}, ambiguities=("conjunction-or-initial",), notes="read as 1.4.0 reads it. The letter stands inside the " "piece the particle chain " "made, so the connective reading would join two words " "the piece already holds: its alternative is the " "piece's field, the family (#752 phase 2m, rules/A1)", shape=1), Case("mixed_case_lower_e_is_the_connective", "Jose e Maria Santos", {"given": "Jose e Maria", "family": "Santos"}, notes="the mixed-case control for the row above: here the " "lowercase letter IS evidence, because the rest of the " "name is not lowercase, so 'e' joins and nothing is in " "doubt", shape=1), Case("mixed_case_upper_e_is_an_initial", "Jose E Maria Santos", {"given": "Jose", "middle": "E Maria", "family": "Santos"}, notes="the other mixed-case control: a bare capital among " "mixed case is how an initial is written, unchanged " "since 1.4.0 and unreported", shape=1), Case("one_case_three_word_e_is_an_initial", "john e smith", {"given": "john", "middle": "e", "family": "smith"}, ambiguities=("conjunction-or-initial",), notes="P3's three-word carve-out is untouched -- 'e' stays a " "name word either way -- so the ROLES do not move and " "the visible change is the tag: ParsedName.initials() " "(the v2 view this table exercises) gives 'j. e. s.' " "where 2.3 gave 'j. s.', and capitalize() gives 'John E " "Smith' where 2.3 gave 'John e Smith', pinned in " "tests/v2/test_render.py. Measured 2026-09-13: the v1 " "facade's HumanName.initials() did not follow this fork " "that morning and still gave 'j. s.'; #528 closed the " "split the same day by making that view read the " "parse's tags, so both surfaces give 'j. e. s.' now and " "test_facade_initials_follow_the_one_case_fork in " "tests/v2/test_render.py pins the agreement where a " "test pinned the split. This table asserts roles", shape=1), Case("one_case_upper_three_word_e_is_an_initial", "JOHN E SMITH", {"given": "JOHN", "middle": "E", "family": "SMITH"}, ambiguities=("conjunction-or-initial",), notes="the all-upper spelling of the row above: same reading, " "and the report is what is new", shape=1), Case("mixed_case_three_word_e_is_the_connective", "John e Smith", {"given": "John", "middle": "e", "family": "Smith"}, notes="the mixed-case control at three words. The carve-out " "means the ROLE is the same as the row above; the " "difference is the tag, and so the initials -- 'J. S.' " "here against 'J. E. S.' for 'JOHN E SMITH'", shape=1), Case("one_case_upper_y_joins_as_a_bare_capital", "JUAN GARCIA Y LOPEZ", {"given": "JUAN", "family": "GARCIA Y LOPEZ"}, classification="fix(#383)", notes="#383 answered with 'bless', and this is the half that " "moves: 1.4.0 through 2.3 vetoed a bare Latin capital " "into an initial and gave middle 'GARCIA Y'. An " "all-upper name's capital is not evidence, 'y' is not " "marked as reading both ways, so it joins -- and " "reports nothing, because nothing about it is in doubt", shape=1), Case("one_case_lower_y_joins", "juan garcia y lopez", {"given": "juan", "family": "garcia y lopez"}, notes="the parity half of the pair above: a lowercase 'y' " "joined before this change and joins after it", shape=1), Case("mixed_case_upper_y_is_an_initial", "Juan Garcia Y Lopez", {"given": "Juan", "middle": "Garcia Y", "family": "Lopez"}, notes="the mixed-case control: here the capital IS evidence, " "so the bare 'Y' reads as an initial exactly as it " "always has, and the join does not happen", shape=1), Case("one_case_upper_y_keeps_the_three_word_carveout", "JUAN Y GARCIA", {"given": "JUAN", "middle": "Y", "family": "GARCIA"}, notes="the boundary between the two exceptions: 'Y' is now " "conjunction-tagged rather than initial-tagged, and the " "three-word carve-out still refuses the join, so the " "ROLE is unchanged. What moves is initials() -- " "ParsedName.initials() gives 'J. G.' where 2.3 gave " "'J. Y. G.', a conjunction contributing none " "(rules/R3) -- and the v1 facade's " "HumanName.initials() gave 'J. Y. G.' until #528 made " "it read the same tags on 2026-09-13. That is the " "split the 'john e smith' row records, closed on the " "same day and by the same change " "(test_facade_initials_follow_the_one_case_fork in " "tests/v2/test_render.py). It runs the other way here: " "there the facade GAINED a letter it had been " "dropping, and here it loses one it had been keeping. " "Which is why this row needs the lowercase twin below " "to be readable", shape=1), Case("one_case_lower_y_keeps_the_three_word_carveout", "juan y garcia", {"given": "juan", "middle": "y", "family": "garcia"}, notes="the twin of the row above, unchanged in every release: " "lowercase 'y' was already the connective at three " "words, and the carve-out already kept it a name word", shape=1), Case("one_case_upper_cyrillic_connective_joins", "ХОСЕ И МАРИЯ САНТОС", {"given": "ХОСЕ И МАРИЯ", "family": "САНТОС"}, classification="feat(#269)", notes="#267's blessing survives the #383 rewrite, and for a " "different reason than it had: 'И' joins because 'и' is " "not in conjunctions_ambiguous, not because the veto " "tested a LATIN shape. No report -- the reading is not " "in doubt. 1.4.0 has no Cyrillic conjunction vocabulary " "at all and reads 'И' as a middle word (first ХОСЕ, " "middle И МАРИЯ, last САНТОС -- measured on the 1.4.0 " "wheel); #269 is what joins it, and this PR only adds " "the one-case spellings to the roster #269 already " "ships", shape=1), Case("one_case_lower_cyrillic_connective_joins", "хосе и мария сантос", {"given": "хосе и мария", "family": "сантос"}, classification="feat(#269)", notes="the lowercase spelling of the row above; both cases " "read alike, which is the point of making the rule " "about evidence rather than about capitals. 1.4.0 reads " "'и' as a middle word here too (measured on the 1.4.0 " "wheel: first хосе, middle и мария, last сантос) for " "the same reason -- no Cyrillic conjunction vocabulary " "-- and #269 is what joins it", shape=1), Case("caseless_connective_never_enters_the_fork", "محمد و علي", {"given": "محمد", "middle": "و", "family": "علي"}, notes="Arabic و has no case at all, so the fork's cased-token " "test is false and today's rule stands whatever the " "name's case class is. The first standalone و in any " "corpus -- measured 2026-09-13, none held one -- and " "the three-word carve-out keeps it a name word, as it " "does for 'juan y garcia'. shape=1 measured accepted by " "Case.__post_init__ (2026-09-13): the row instantiates " "shape 1's given-first arrangement under the default " "order, exactly as the Cyrillic twins above do -- a " "shape tag asserts the ARRANGEMENT, not a script " "(tools/differential/shapes.py). Since #461 it is also " "the readmission read as a PARITY RESTORATION rather " "than a deviation: 'و' is the whole middle part, so it " "joins nothing, initials again on both views, and both " "give 'م. و. ع.' -- which is what 1.4.0 gave. Roles " "unmoved throughout, so the classification does not " "change; what moves is a view, and it moves back", shape=1), # #289: a bare ambiguous credential acronym is read by the # EVIDENCE the writing carries, and a name written in more than # one case carries some. These rows pin the FORK, not the wordlist # (mechanisms.md#VOCABULARY-EXERCISES-FORKS): the same two letters # in the same slot, spelled five ways, with the one-case controls # that must not move beside them. Case("caps_ambiguous_leans_credential", "Jack MA", {"given": "Jack", "suffix": "MA"}, classification="fix(#289)", ambiguities=("given-or-family",), notes="the defect. An all-caps member of the ambiguous set " "inside a mixed-case name is written the way a " "credential is written, so it is taken as one even " "though peeling it leaves no family -- which is what " "'Jack MD' has always done with an unambiguous one. " "1.4.0 read last 'MA' here, so this breaks v1 parity " "deliberately", shape=1), Case("caps_ambiguous_leans_credential_with_a_period", "Jack MA.", {"given": "Jack", "suffix": "MA."}, classification="fix(#289)", ambiguities=("given-or-family",), notes="the lean reads CASE, not periods: a single trailing " "period is the abbreviation shape any word can wear " "(rules/S2) and changes nothing here, where the " "capitals have already spoken", shape=1), Case("title_case_ambiguous_leans_surname_two_words", "Jack Ma", {"given": "Jack", "family": "Ma"}, ambiguities=("suffix-or-name",), notes="the contrast that makes the row above a fork rather " "than a wordlist entry: Title case is how a surname is " "written, and this row is unchanged in every release", shape=1), Case("one_case_upper_ambiguous_takes_the_count", "JACK MA", {"given": "JACK", "family": "MA"}, ambiguities=("suffix-or-name",), notes="the one-case control for 'Jack MA': all-caps against " "all-caps is no contrast at all, so nothing leans and " "rules/S4's words-to-spare count decides alone -- " "two pieces, so the acronym stays the family name", shape=1), Case("one_case_lower_ambiguous_takes_the_count", "jack ma", {"given": "jack", "family": "ma"}, ambiguities=("suffix-or-name",), notes="the lowercase spelling of the row above; both cases " "read alike, which is the point of asking about " "evidence rather than about capitals", shape=1), Case("title_case_ambiguous_leans_surname_with_words_to_spare", "John Smith Ma", {"given": "John", "middle": "Smith", "family": "Ma"}, classification="fix(#289)", ambiguities=("suffix-or-name",), notes="the lean's other direction, and the one that costs a " "credential rather than a surname: with words to spare " "the count read 'Ma' as a suffix, and a Title-case " "word beside 'John Smith' is how a surname is written. " "1.4.0 read suffix 'Ma', so this is a deliberate " "parity break in the direction the East Asian surname " "wants (decisions/S6)", shape=1), Case("one_case_upper_ambiguous_with_words_to_spare", "JOHN SMITH MA", {"given": "JOHN", "family": "SMITH", "suffix": "MA"}, ambiguities=("suffix-or-name",), notes="the one-case control for the row above: no evidence, " "so the count decides and three pieces make the " "acronym a credential, exactly as before 2.4", shape=1), Case("mixed_case_caps_ambiguous_with_words_to_spare", "john smith MA", {"given": "john", "family": "smith", "suffix": "MA"}, notes="the lean and the count AGREE here, which is why the " "row does not move: capitals against lowercase lean " "credential, and three pieces read credential anyway", shape=1), Case("one_case_ambiguous_surname_is_untouched", "ANH DO", {"given": "ANH", "family": "DO"}, ambiguities=("suffix-or-name",), notes="the name this whole design must not break: an " "all-caps Vietnamese surname in an all-caps name. One " "case, so nothing leans, and the count keeps the " "family -- the reading 2.0 shipped and 1.4.0 had", shape=1), Case("one_case_ambiguous_surname_behind_a_particle", "anh van do", {"given": "anh", "family": "van do"}, notes="its lowercase, particle-bearing twin: the particle " "chain takes 'do' as part of the surname before any of " "this is asked", shape=1), Case("a_title_and_one_ambiguous_word_is_still_a_name", "Mr MA", {"title": "Mr", "family": "MA"}, notes="the floor the lean does not move, measured " "2026-09-15: the trailing peel's walk starts after the " "leading title run, so one piece is all it sees and " "rules/S4's two-piece floor refuses it. A lean that " "reached below the floor would read this as a title " "with a credential and no name at all -- and nothing " "is reported, because no fork was taken", shape=1), Case("the_lean_reaches_the_post_comma_slot", "Smith, MA", {"family": "Smith", "suffix": "MA"}, classification="fix(#289)", notes="Derek's own reading of #289: positive evidence outranks " "position, so the credential lean fires with ONE word " "before the comma where the words-to-spare count would not. " "The first report of the comma's OWN decision rode with it " "until #785, which settles the capitals and drops it " "(rules/S6) -- C2's structural flag already reports on the " "comma path, but reports what the parse could not " "recognize, not a fork it called (rules/C11's report, " "scoped to this class; P6's attachment fork has reported on " "a family-comma path since 2.3, e.g. 'Berg, Jan vd')", shape=2), Case("the_surname_lean_keeps_the_post_comma_given", "Smith, Ma", {"given": "Ma", "family": "Smith"}, ambiguities=("suffix-or-name",), notes="the contrast: Title case leans surname, so the word " "stays the given name exactly as it did -- and the " "call is reported all the same, both directions of one " "fork being worth telling the caller about", shape=2), Case("the_comma_count_is_of_name_words", "Smith Jr., MA", {"family": "Smith", "suffix": "Jr., MA"}, classification="fix(#289)", notes="rules/C4's count for this class is of NAME words, " "not of words: two tokens and one name here, so the " "structure does not flip and the family survives. " "Counting tokens read it as given 'Smith' with suffix " "'Jr., MA' -- no reading of this string puts the " "family anywhere but where this row does " "(decisions/S6)", shape=2), Case("the_by_shape_class_never_reaches_is_wholly_suffix", "Smith Jr., A.B.", {"given": "A.B.", "family": "Smith", "suffix": "Jr."}, classification="parity", ambiguities=("suffix-or-name",), notes="FULL PARITY on the 1.4.0 wheel -- first 'A.B.', last " "'Smith', suffix 'Jr.'; only the report is new, like " "its report-only siblings above. Also the control that " "pins the review-round fix: an EARLIER version of " "`is_wholly_suffix` admitted a by-shape member " "unconditionally, and combined with C1's then " "TOKEN-count disjunct in segment (deleted in #613) that " "flipped this to given 'Smith' with a self-contradicting " "'holds 1 name words' report. The by-shape class reaches " "the comma form only through classify's shape tags, and " "`_comma.decide` declines it here exactly as the " "listed class does above -- one name word, so `A.B.` " "stays the given and `Jr.` its own suffix", shape=2), Case("two_name_words_read_the_listed_acronym_as_a_credential", "John Smith, MA", {"given": "John", "family": "Smith", "suffix": "MA"}, classification="fix(#289)", notes="1.4.0 RESTORED: v1 read first John, last Smith, " "suffix MA and 2.0 through 2.3 read given 'MA' with " "the whole name in `family`. The comma structure moves " "with the class, so this is the SUFFIX_COMMA form " "again", shape=3), Case("the_comma_count_reaches_the_listed_set_title_case", "John Smith, Ed", {"given": "John", "family": "Smith", "suffix": "Ed"}, classification="fix(#289)", ambiguities=("suffix-or-name",), notes="one rule for the whole ambiguous class rather than " "two: where case is silent -- 'Ed' leans SURNAME here, " "and the lean does not reach the structure -- the " "count of name words before the comma decides, and two " "of them make it a credential. 1.4.0 read suffix 'Ed' " "too, so this restores parity rather than opening " "distance from it (decisions/S4, 2026-09-15)", shape=3), Case("the_comma_count_reaches_the_listed_set_lower", "john smith, ma", {"given": "john", "family": "smith", "suffix": "ma"}, classification="fix(#289)", ambiguities=("suffix-or-name",), notes="the one-case spelling of the row above: no evidence " "either way, so the count decides and 1.4.0's suffix " "reading comes back", shape=3), Case("the_comma_count_reaches_the_listed_set_upper", "JOHN SMITH, MA", {"given": "JOHN", "family": "SMITH", "suffix": "MA"}, classification="fix(#289)", ambiguities=("suffix-or-name",), notes="the all-caps spelling, and the reason the count has " "to reach the listed set at all: an all-caps name " "carries no lean, and leaving it to the lean alone " "would read this one way and its mixed-case twin " "another. 1.4.0 parity restored", shape=3), Case("two_name_words_before_the_comma_at_two_words", "Davis Royce, Ed", {"given": "Davis", "family": "Royce", "suffix": "Ed"}, classification="fix(#289)", ambiguities=("suffix-or-name",), notes="the two-word twin of 'Royce, Ed' below, and the case " "row decisions/S4's 2026-09-15 entry (THE COUNT " "REACHES THE LISTED SET TOO) turns on -- no corpus " "name has this shape, so this row carries the whole " "weight of that decision", shape=3), Case("one_name_word_before_the_comma_is_never_enough", "Royce, Ed", {"given": "Ed", "family": "Royce"}, ambiguities=("suffix-or-name",), notes="the control: one name word before the comma leaves " "nothing to spare, 'Ed' leans surname anyway, and the " "row is unchanged in every release. The pair above and " "below it is what makes the count visible", shape=2), Case("a_credential_in_front_anchors_a_declined_pick", "abdul Smith Jr Ma", {"given": "abdul", "family": "Smith", "suffix": "Jr Ma"}, classification="fix(#436/#437)", ambiguities=("suffix-or-name",), notes="the cost #289 had accepted, reversed by #544: the " "surname lean declines 'Ma', but the unambiguous 'Jr' " "in FRONT of it anchors it, so the peel takes both and " "P5's reserve, seeing the family the join would take, " "declines the join. The roles are 1.4.0's and 2.3.0's; " "what the classification records is the suffix STRING, " "which 1.4.0 wrote 'Jr, Ma'. The walk still stops at a " "declined pick with nothing in front of it ('Jack Wei " "Ma')", shape=1), Case("a_tail_segment_of_leaning_credentials_is_a_run", "Steven Hardman, MD, DO, DDS", {"given": "Steven", "family": "Hardman", "suffix": "MD, DO, DDS"}, classification="fix(#289)", notes="the one place this design QUIETS a report rather than " "adding one: 'DO' is a listed ambiguous acronym " "written in capitals inside a mixed-case name, so the " "third comma segment is wholly suffix and rules/C2 " "stops flagging it. The FIELDS do not move -- a " "quieted flag is invisible to a reader sweeping for " "new reports, which is why it has a row of its own", shape=3), # Quality-review finding, 2026-09-17: "one name never reports # twice" was wrong as a blanket claim -- what is true is that one # DECISION never reports twice. Two DIFFERENT ambiguous acronyms # at two different forks each report once: the trailing peel over # the pre-comma part ('MA', credential lean, no words to spare) # and the comma structure's own flip ('Ed', two pre-comma name # words -- 'Smith' and 'MA' both count, since a bare undotted # ambiguous acronym is not suffix vocabulary to `name_word_count` # either). Case("two_different_forks_each_report_once", "Smith MA, Ed", {"given": "Smith", "suffix": "MA, Ed"}, classification="fix(#289)", ambiguities=("suffix-or-name", "given-or-family"), notes="'Smith MA' reads two name words, flipping the comma " "structure so 'Ed' joins the credential run; peeling 'Smith " "MA' positionally then peels 'MA' too (caps lean, no words " "to spare), leaving 'Smith' the only name word, which is " "also what turns on GIVEN_OR_FAMILY. Two reports since " "#785, the settled 'MA' reporting nothing (rules/S6), and " "none of them the same fork reporting twice"), Case("a_caseless_script_wrote_no_contrast", "毛泽东, MA", {"given": "MA", "family": "毛泽东"}, classification="fix(#289)", ambiguities=("suffix-or-name",), notes="the LEAN is inert by construction: is_one_case answers " "True for text that has only one case, so nothing leans " "and the count decides -- one word before the comma, so " "the acronym stays the given name, as in every release. " "But the REPORT is not lean-gated (F4/F5 review finding, " "2026-09-17): it tracks the fork being consulted, " "exactly as the trailing slot always has, so this comma " "form is read positionally and still reports which way " "it went -- an earlier round wrongly excluded this row", tolerated=True), # MEASURED, not the plan's prediction: unlike its single-token # Chinese twin above, '마틴 킹' is TWO whitespace tokens, so item # 5's name-word count -- uniform across the whole ambiguous class, # case silent or not -- sees two name words before the comma and # flips the structure exactly as 'JOHN SMITH, MA' does. The comma # form then reads segment 0 the way a NO_COMMA name would -- the # Hangul surname split running over '마틴 킹' positionally, since # script_segment asks the same name-word count before it (#613's # review: asking only the vocabulary there split nothing and read # family '마틴', given '킹' while the comma was still decided as a # credential run) -- # the same shape 'Smith 김민준씨, MA' shows in the spec's decision # table. Not a caseless-script exemption: item 5 answers "where " # case is silent" by the SAME count the rest of item 5 uses, and a # two-word Hangul name is silent about case but not about how many # name words it has. Case("a_caseless_script_wrote_no_contrast_hangul", "마틴 킹, MA", {"given": "틴", "middle": "킹", "family": "마", "suffix": "MA"}, classification="fix(#289)", ambiguities=("suffix-or-name",), notes="MEASURED 2026-09-17, not the spec's older prediction " "(given MA, family '마틴 킹', unchanged): dated before " "item 5's name-word-count extension was decided " "(2026-09-15) reached the listed set in ANY case. Two " "name words before the comma flip the structure here " "exactly as they do for 'JOHN SMITH, MA', and the flip " "precedes script_segment's Hangul surname split, which " "then runs over '마틴 킹' as if no comma had stood " "there at all", tolerated=True), # F2 review finding, 2026-09-17: the SAME mechanism reaches Japanese # too, by the same two-token count -- '田中 太郎' is two whitespace # tokens, not one, so it is not '毛泽东, MA's twin (a single token) # but '마틴 킹, MA's. 1.4.0 read both '마틴 킹, MA' and # '田中 太郎, MA' as suffix MA (measured on the wheel), so the # suffix half of this move is a PARITY RESTORATION, not a fresh # deviation -- only the pre-comma surname split (given '太郎', # family '田中') is #271/#272's pre-existing positional behavior, # unrelated to #289. Case("a_caseless_script_wrote_no_contrast_japanese", "田中 太郎, MA", {"given": "太郎", "family": "田中", "suffix": "MA"}, classification="fix(#289)", ambiguities=("suffix-or-name",), notes="MEASURED 2026-09-17: two name words before the comma " "flip the structure exactly as '마틴 킹, MA' and " "'JOHN SMITH, MA' do, and script_segment asks that " "count for itself (`_postnominal_behind_a_whole_name`, " "#613), so the family-first Han split runs over '田中 " "太郎' positionally. Docs/design's older " "'a caseless script is inert by construction' bullet " "is true of the LEAN alone; item 5's name-word count " "is orthogonal to case and reaches this row too", tolerated=True), # #516's dotted half: an UNLISTED token of two or more # period-separated chunks joins the ambiguous credential class BY # SHAPE, and the words-to-spare count then reads it. These rows # pin the FORK -- the same token in the two positions the count # separates, with the chunk-claim names that must NOT move beside # them (mechanisms.md#VOCABULARY-EXERCISES-FORKS). Case("unlisted_dotted_reads_by_position_with_words_to_spare", "John Smith X.Y.Z.", {"given": "John", "family": "Smith", "suffix": "X.Y.Z."}, classification="fix(#516)", ambiguities=("suffix-or-name",), notes="the periods are the signal and the position decides: " "three pieces, so the unlisted acronym is the " "credential. 1.4.0 read last 'X.Y.Z.', so this breaks " "parity deliberately -- the parking-lot entry of " "decisions/suffix-acronym-collisions is what it " "closes", shape=1), Case("unlisted_dotted_is_case_blind", "john smith x.y.z.", {"given": "john", "family": "smith", "suffix": "x.y.z."}, classification="fix(#516)", ambiguities=("suffix-or-name",), notes="the control that separates this switch from #289's " "lean: case is irrelevant here, the PERIODS being the " "signal, so the all-lower spelling reads as the " "mixed-case one does", shape=1), Case("unlisted_dotted_without_words_to_spare_is_the_family", "Jack X.Y.Z.", {"given": "Jack", "family": "X.Y.Z."}, ambiguities=("suffix-or-name",), notes="the other half of the count, and the row is unchanged " "in its FIELDS: two pieces, so the shape loses to the " "surname reading. What is new is the report -- the " "fork was always there and was called silently", shape=1), Case("unlisted_dotted_of_any_chunk_length", "John Smith B.Tech.", {"given": "John", "family": "Smith", "suffix": "B.Tech."}, classification="fix(#516)", ambiguities=("suffix-or-name",), notes="chunk LENGTH is not the test: two chunks, one of them " "four letters, and the Indian degree reads as the " "credential it is", shape=1), # #864: paired initials (two dotted single letters, a cased # script) are no word of the class at the trailing slots, so a # surname written that way (Nepali B.K., K.C.) keeps the family; # the fork is still reported. Each row has a twin that does move: # three dotted letters (the X.Y.Z. rows above), a listed credential # (D.C.), a caseless script's pair, a pair behind a suffix word. Case("paired_initials_ending_a_name_are_its_family", "Ram Bahadur B.K.", {"given": "Ram", "middle": "Bahadur", "family": "B.K."}, ambiguities=("suffix-or-name",), classification="fix(#864)", notes="2.3.0's reading (wheel); 2.4.0 through 2.5.0 read " "family 'Bahadur', suffix 'B.K.' by S3's words-to-spare " "count"), Case("paired_initials_without_their_final_period", "Sita Kumari K.C", {"given": "Sita", "middle": "Kumari", "family": "K.C"}, ambiguities=("suffix-or-name",), classification="fix(#864)", notes="the second period is optional, as C5 writes the shape"), Case("paired_initials_in_a_one_case_name", "RAM BAHADUR B.K.", {"given": "RAM", "middle": "BAHADUR", "family": "B.K."}, ambiguities=("suffix-or-name",), classification="fix(#864)", notes="case says nothing about the dotted shape (S3), so the " "capitals read as the mixed-case name does"), Case("paired_initials_before_a_suffix_comma", "Ram Bahadur B.K., PhD", {"given": "Ram", "middle": "Bahadur", "family": "B.K.", "suffix": "PhD"}, ambiguities=("suffix-or-name",), classification="fix(#864)", notes="the part before a suffix comma reads as the name " "written without it (C1): 2.4.0 through 2.5.0 read " "suffix 'B.K., PhD'"), Case("paired_initials_trailing_the_given_part", "Bahadur, Ram B.K.", {"given": "Ram", "middle": "B.K.", "family": "Bahadur"}, ambiguities=("suffix-or-name",), classification="fix(#864)", notes="after a family comma the pair is a middle name, 1.4.0's " "and 2.3.0's reading (wheels)"), Case("paired_initials_ending_a_maiden_clause", "Jane Doe nee Smith B.K.", {"given": "Jane", "family": "Doe", "maiden": "Smith B.K."}, ambiguities=("suffix-or-name",), classification="fix(#864)", notes="2.3.0's reading (wheel); 2.4.0 through 2.5.0 read " "suffix 'B.K.' at the clause's end (M2)"), Case("paired_initials_behind_a_suffix_word_join_the_run", "Ram Bahadur PhD B.K.", {"given": "Ram", "family": "Bahadur", "suffix": "PhD B.K."}, ambiguities=("suffix-or-name",), classification="fix(#516)", notes="the boundary: behind an unambiguous credential the " "pair joins the run as any dotted-shape word does, the " "reading the switch off gives too"), Case("paired_initials_behind_an_ambiguous_credential_end_the_name", "Jane Doe Ma R.T.", {"given": "Jane", "middle": "Doe Ma", "family": "R.T."}, ambiguities=("suffix-or-name",), classification="fix(#864)", notes="rules/S3's Accepted cost: only an unambiguous suffix " "word in front, or an ambiguous one its capitals settle, " "makes the pair the run's, so behind the Title-case Ma " "the pair ends the name and takes Ma in. Until #785 the " "row was written 'Jane Doe MA R.T.', which reads the " "capitals' run now (the row below). 1.4.0 read the " "same"), Case("a_capitals_credential_in_front_of_paired_initials_starts_the_run", "Jane Doe MA R.T.", {"given": "Jane", "family": "Doe", "suffix": "MA R.T."}, ambiguities=("suffix-or-name",), classification="fix(#785)", notes="S8: written in capitals in a name of mixed case, " "MA is settled as the credential (S6) and starts the run " "as PhD does ('Jane Doe PhD R.T.'), which takes the pair; " "the report is the pair's, MA's own fork being settled. " "1.4.0 through 2.3.0 and 2.6.0 read middle 'Doe MA', " "family 'R.T.'; 2.4.0 through 2.5.0 suffix 'MA R.T.'"), Case("a_listed_two_letter_credential_keeps_the_suffix", "Hari Bahadur D.C.", {"given": "Hari", "family": "Bahadur", "suffix": "D.C."}, notes="the boundary on the vocabulary side: Doctor of " "Chiropractic is listed, so the shape question is never " "asked (99,172 NPPES providers write it, decisions/S3)"), # #752 (phase 2c): a word joined by a hyphen or a slash is a # credential where every chunk counts as a suffix as written # (rules/S3). Each moved row's reading is 1.4.0's through 2.6.0's # otherwise (wheels, 2026-10-25), stated per row. Case("a_joined_credential_after_a_comma_keeps_the_name_in_order", "John Smith, MD/PhD", {"given": "John", "family": "Smith", "suffix": "MD/PhD"}, classification="fix(#752)", notes="1.4.0 through 2.6.0 read given 'MD/PhD', family 'John " "Smith', the name reversed around the credential"), Case("a_hyphen_joined_credential_ending_a_name", "John Smith MD-PhD", {"given": "John", "family": "Smith", "suffix": "MD-PhD"}, classification="fix(#752)", notes="1.4.0 through 2.6.0 read middle 'Smith', family " "'MD-PhD'"), Case("a_joined_credential_ending_the_given_part", "Smith, John MD/PhD", {"given": "John", "family": "Smith", "suffix": "MD/PhD"}, classification="fix(#752)", notes="1.4.0 through 2.6.0 read middle 'MD/PhD'"), Case("a_joined_credential_with_nothing_before_the_comma", ", MD/PhD", {"suffix": "MD/PhD"}, classification="fix(#752)", notes="the empty-head comma shape; 1.4.0 through 2.6.0 read " "given 'MD/PhD'"), Case("a_joined_credential_of_dotted_chunks", "John Smith, M.D./Ph.D.", {"given": "John", "family": "Smith", "suffix": "M.D./Ph.D."}, classification="fix(#752)", notes="a chunk counts as it does as a word of its own, periods " "and all; 1.4.0 through 2.6.0 read the name reversed"), Case("a_joined_credential_behind_a_hyphenated_surname", "John Smith-Jones, MD-PhD", {"given": "John", "family": "Smith-Jones", "suffix": "MD-PhD"}, classification="fix(#752)", notes="the two hyphens side by side: the surname's chunks are " "not listed, the credential's are; 1.4.0 through 2.6.0 " "read given 'MD-PhD', family 'John Smith-Jones'"), Case("a_hyphenated_given_name_in_title_case_stays_the_given_name", "Kim, Ae-Ra", {"given": "Ae-Ra", "family": "Kim"}, notes="pins rules/S3's name-writing cue: 'ae' and 'ra' are both " "listed credentials, so without the cue the joined " "reading takes the given name (as the spaced 'Kim, Ae " "Ra' already loses it to the two credentials)"), Case("a_hyphenated_given_name_with_a_lower_case_tail_stays_the_name", "Shin Ae-ra", {"given": "Shin", "family": "Ae-ra"}, notes="the standard romanization writes the second syllable in " "lower case, which the cue reads as a name too"), Case("a_joined_word_with_one_unlisted_chunk_is_a_name_word", "John Smith MD-Jones", {"given": "John", "middle": "Smith", "family": "MD-Jones"}, notes="the boundary: every chunk, not any, because a hyphen " "also joins names"), Case("a_joined_credential_with_a_bare_ambiguous_chunk_keeps_its_reading", "John Smith, MA/PhD", {"given": "MA/PhD", "family": "John Smith"}, notes="rules/S3's Accepted cost: a bare member of the ambiguous " "class is no chunk the stricter test counts"), # #752 (phase 2d): an unlisted word of capitals joined by a hyphen # or a slash is the caps shape (rules/S9), gated by the name's case # contrast after a comma (rules/C6) as the unjoined word is. 1.4.0 # through 2.6.0 read each moved row's word as the given name, the # name reversed around it (wheels, 2026-10-25). Case("joined_capitals_after_a_comma_join_the_class", "John Smith, PU-PH", {"given": "John", "family": "Smith", "suffix": "PU-PH"}, ambiguities=("suffix-or-name",), classification="fix(#752)", notes="the French hospital-university rank; 1.4.0 through " "2.6.0 read given 'PU-PH', family 'John Smith'"), Case("slash_joined_capitals_before_a_third_part", "John Smith, PU/PH, Jr.", {"given": "John", "family": "Smith", "suffix": "PU/PH, Jr."}, ambiguities=("suffix-or-name",), classification="fix(#752)", notes="1.4.0 through 2.6.0 read given 'PU/PH', suffix 'Jr.'"), Case("joined_capitals_in_a_one_case_name_stay_the_given_name", "JOHN SMITH, PU-PH", {"given": "PU-PH", "family": "JOHN SMITH"}, notes="the boundary: no case contrast, so the capitals say " "nothing (rules/C6)"), Case("joined_capitals_behind_one_name_word_stay_the_given_name", "Smith, PU-PH", {"given": "PU-PH", "family": "Smith"}, notes="the boundary: one name word before the comma leaves " "the part the given name (rules/C4)"), Case("joined_single_letters_are_no_caps_shape", "John Smith, A-B", {"given": "A-B", "family": "John Smith"}, notes="the boundary: a chunk of two or more letters is needed, " "single letters joined being how initials are written"), Case("paired_initials_in_a_caseless_script_keep_the_position_reading", "สมชาย ใจดี พ.บ.", {"given": "สมชาย", "family": "ใจดี", "suffix": "พ.บ."}, ambiguities=("suffix-or-name",), classification="fix(#516)", notes="the boundary on the script side: Thai writes its " "abbreviations as dotted letters, degrees among them " "(the MD here), and has no capitals to write initials " "in"), Case("gc_is_a_family_name_with_nothing_to_spare", "Hari G.C.", {"given": "Hari", "family": "G.C."}, ambiguities=("suffix-or-name",), classification="fix(#864)", notes="gc left the suffix acronyms (decisions/suffix-acronym-" "collisions): 1.4.0's reading; 2.0.0 through 2.5.0 read " "suffix 'G.C.' and no family"), Case("gc_is_a_family_name_with_words_to_spare", "Hari Bahadur G.C.", {"given": "Hari", "middle": "Bahadur", "family": "G.C."}, ambiguities=("suffix-or-name",), classification="fix(#864)", notes="unlisted, it reads as the paired initials it is spelled " "in; 1.4.0 through 2.5.0 read suffix 'G.C.'"), Case("paired_initials_under_the_switch_off_read_the_same", "Ram Bahadur B.K.", {"given": "Ram", "middle": "Bahadur", "family": "B.K."}, policy=Policy(unlisted_dotted_suffixes=False), ambiguities=("suffix-or-name",), notes="the pair reads as the switch off reads every dotted " "word, and that reading is unchanged"), Case("the_s3_boundary_example_moves", "John Smith Q.W.E.R.T.", {"given": "John", "family": "Smith", "suffix": "Q.W.E.R.T."}, classification="fix(#516)", ambiguities=("suffix-or-name",), notes="rules/S3's own boundary example, which said this " "shape stays a name. It moves, and the rule moves with " "it", shape=1), Case("the_roman_chunk_accident_retires_narrowly", "Jack X.Y.I.", {"given": "Jack", "family": "X.Y.I."}, classification="fix(#516)", ambiguities=("suffix-or-name",), notes="1.4.0 RESTORED. The chunk 'i' is a roman numeral in " "the suffix vocabulary, so this read as a generational " "suffix -- by accident, the fork being about " "generations and the word being nothing of the kind. " "Retired where every matched chunk is a single ASCII " "character -- the roster this retirement is scoped to " "is asserted, not just described, in " "test_vocab.test_period_joined_vocab_retires_the_single_" "character_chunk", shape=1), Case("a_retired_chunk_claim_still_reads_by_position", "John Smith R.A.I.", {"given": "John", "family": "Smith", "suffix": "R.A.I."}, ambiguities=("suffix-or-name",), notes="the pair to the row above, and the reason the " "retirement is safe: with words to spare the shape " "reads the same token the accident read, so the FIELDS " "do not move and only the cause does. The report is " "what makes the new cause visible", shape=1), Case("a_multi_letter_roman_chunk_also_reads_by_position", "John Smith J.u.n.i.o.r.", {"given": "John", "family": "Smith", "suffix": "J.u.n.i.o.r."}, ambiguities=("suffix-or-name",), notes="the six-chunk twin of the row above: no chunk here is " "vocabulary at all (this is not 'junior' the suffix " "word, glued letter by letter), so the shape read this " "one before the retirement existed and reads it the " "same way after -- the FIELDS never moved, only the " "report is new", shape=1), Case("the_comma_structure_moves_with_the_shape_class", "John Smith, X.Y.Z.", {"given": "John", "family": "Smith", "suffix": "X.Y.Z."}, classification="fix(#516)", notes="two NAME words before the comma read the part after " "it as the credential run, for the by-shape half " "exactly as for the listed one (rules/C4). Silent " "since #563: three letters run together have no name " "reading a reader would weigh", shape=3), Case("paired_initials_after_a_two_word_surname_stay_the_given", "García Márquez, G.J.", {"given": "G.J.", "family": "García Márquez"}, ambiguities=("suffix-or-name",), notes="#563: two words before the comma may be ONE surname, " "and two dotted letters are how a person's own initials " "are written, so the count gives way (rules/C5). " "2.4's first reading was given 'García', family " "'Márquez', suffix 'G.J.'; 1.4.0 and 2.3.0 read it as " "here, and only the report is new", shape=3), Case("paired_initials_after_a_full_name_are_still_a_fork", "John Smith, A.B.", {"given": "A.B.", "family": "John Smith"}, ambiguities=("suffix-or-name",), notes="#563: the same shape as the row above with a given " "name before the comma, which the parser cannot see. " "A.B. is also the degree, so the report is the point: " "the reading goes to the initials, and the fork is said", shape=3), Case("a_credential_behind_paired_initials_does_not_speak", "De La Cruz, M.J. PhD", {"given": "M.J.", "family": "De La Cruz", "suffix": "PhD"}, ambiguities=("suffix-or-name",), notes="#563, S7's company rule: only a credential IN FRONT " "speaks for a word, so the degree behind the initials " "leaves them the given name. Every release reads it " "this way; the report is new", shape=3), Case("a_credential_in_front_speaks_for_paired_initials", "John Smith, PhD X.Y.", {"given": "John", "family": "Smith", "suffix": "PhD X.Y."}, classification="fix(#563)", notes="the contrast to the row above: the degree in front " "makes the pair a credential, and a flip on a dotted " "word admitted by shape is silent (rules/C11). 2.3.0 " "read given 'PhD', middle 'X.Y.'", shape=3), Case("a_title_in_front_of_paired_initials_does_not_speak", "García Márquez, Ms G.J.", {"title": "Ms", "given": "G.J.", "family": "García Márquez"}, notes="#563 review round: 'Ms' is both title and suffix " "vocabulary, and in front of paired initials it is the " "title of the given part they open (rules/C5, as S7 " "reads a dual in that part's title run). The first fix " "let it speak and split the surname in silence. Every " "release reads it this way", shape=3), Case("a_credential_in_front_of_paired_initials_speaks", "García Márquez, PhD G.J.", {"given": "García", "family": "Márquez", "suffix": "PhD G.J."}, classification="fix(#563)", notes="the contrast to the row above: 'PhD' is no title, so " "it speaks for the pair and the part is the credential " "run, flipped in silence", shape=3), Case("a_second_dotted_word_speaks_for_paired_initials", "John Smith, X.Y. P.Q.", {"given": "John", "family": "Smith", "suffix": "X.Y. P.Q."}, classification="fix(#563)", ambiguities=("suffix-or-name",), notes="nobody writes initials as two dotted groups, so a " "second by-shape word makes the part the credential " "run (rules/C5) -- reported (C11), since here the pairs " "speak only for each other and each is a word a reader " "takes for a name. 1.4.0 read given 'X.Y.', middle " "'P.Q.'", shape=3), Case("two_pairs_behind_a_particle_surname_open_the_given_part", "De La Cruz, M.J. K.L.", {"given": "M.J.", "family": "De La Cruz", "middle": "K.L."}, classification="fix(#575)", ambiguities=("suffix-or-name", "suffix-or-name"), notes="'De La Cruz' is one name word (#575), so the count no " "longer reaches the part and it reads as 'Cruz, M.J. " "K.L.' does (Derek, 2026-10-01). #563 had read it as a " "flipped credential run with no given name. 'K.L.' then " "ends the given part, where paired initials are a name " "word since #864 (suffix 'K.L.' before it): 1.4.0's " "reading, given 'M.J.', middle 'K.L.'", shape=2), Case("a_class_member_in_front_of_paired_initials_does_not_speak", "García Márquez, Ed G.J.", {"given": "Ed", "family": "García Márquez", "middle": "G.J."}, classification="fix(#516)", ambiguities=("suffix-or-name", "suffix-or-name"), notes="#563 second review round: 'Ed' is a listed ambiguous " "member, and S7's company lets only an unambiguous " "credential speak, so the comma keeps the family. 'G.J.' " "then ends the given part, the slot #563 left alone and " "#864 settled: paired initials are a name word there, " "as in 'Doe, John R.T.', where they read suffix 'G.J.' " "before; both forks report. 1.4.0 and 2.3.0 read " "middle 'G.J.'", shape=3), Case("three_run_together_initials_behind_a_double_surname_read_as_a_credential", "García Márquez, G.J.R.", {"given": "García", "family": "Márquez", "suffix": "G.J.R."}, classification="fix(#516)", notes="the ACCEPTED cost of #563's line (rules/C5): " "initials are conventionally written apart ('J. R. " "R.'), so three letters run together read as a " "credential even where the name before the comma is " "one surname. 1.4.0 read given 'G.J.R.'", shape=3), Case("one_word_before_the_comma_keeps_the_given", "Smith, A.B.", {"given": "A.B.", "family": "Smith"}, ambiguities=("suffix-or-name",), notes="its control: one name word before the comma is never " "enough, so the dotted token stays the given name and " "the structure does not move. Unchanged in every " "release; the report is new", shape=2), Case("whole_token_vocabulary_wins_over_the_shape", "Smith, A.B.C.", {"family": "Smith", "suffix": "A.B.C."}, notes="'abc' IS a suffix acronym, so the whole-token lookup " "settles this before any shape reading and the count " "never runs. The boundary between #516's shape class " "and the vocabulary it does not touch", shape=2), Case("a_leading_dotted_run_is_untouched", "X.Y.Z. Smith", {"given": "X.Y.Z.", "family": "Smith"}, notes="rules/S2 already says a suffix never opens the " "string, and the shape class is read only by the " "trailing peel and the post-comma slot -- so every " "leading dotted run, 'J.R.R. Tolkien' included, is " "untouched by this switch", shape=1), Case("a_leading_dotted_run_of_three_chunks_is_untouched", "J.R.R. Tolkien", {"given": "J.R.R.", "family": "Tolkien"}, notes="the three-chunk twin of the row above: 'j', 'r' and " "'r' claim nothing either, and the leading slot never " "asks the shape class regardless", shape=1), Case("a_multi_character_chunk_claim_survives", "Doe, John Msc.Ed.", {"given": "John", "family": "Doe", "suffix": "Msc.Ed."}, notes="the narrow retirement's protected control: the chunk " "'ed' is two characters, so the chunk rule still fires " "and this real credential is not handed to the shape " "class. The WIDE retirement -- any chunk match yielding " "to the shape -- would have read 'Msc.Ed.' as a middle " "name, which is what rejected it (decisions/S3)", shape=2), Case("a_glued_honorific_chunk_is_not_ascii", "J.씨", {"given": "J.", "suffix": "씨"}, notes="the reason the retirement says ASCII and not just " "single-character: '씨' is one character and MUST keep " "its chunk claim, the honorific peel resting on it", tolerated=True), Case("a_delimited_dotted_token_keeps_its_clause_reading", "Bridge (1.4)", {"family": "Bridge", "nickname": "1.4"}, notes="delimited content is decided by the clause escape " "rather than at the trailing slot -- so the shape " "class is not offered to a token that already carries " "a role, and this row gains neither a reading nor a " "report. Cannot exercise the `token.role is None` " "guard on its own, though: a digit chunk never reaches " "the shape verdict at all (period_joined_vocab's own " "alphabetic gate) -- the row beside it is the guard's " "real control", shape=1), Case("the_role_guard_is_the_real_delimited_control", "Bridge (A.B)", {"family": "Bridge", "nickname": "A.B"}, notes="unlike the digit row above, 'A.B' WOULD reach the " "shape verdict were it not for classify's `token.role " "is None` guard -- without it this nickname gains " "SHAPE_ACRONYM_TAG and, with the switch on, " "'vocab:suffix-ambiguous', and classify's own nickname " "check misreports SUFFIX_OR_NICKNAME on a token the " "clause escape already decided (measured regression, " "#516 review round). This row is what actually pins " "the guard load-bearing", shape=1), Case("a_bare_digit_chunk_is_never_an_acronym_by_shape", "John Smith 1.4", {"given": "John", "middle": "Smith", "family": "1.4"}, notes="#516 review round: the shape gate requires every chunk " "to be ALPHABETIC, not merely unclaimed -- '1' and '4' " "are chunks nothing spells as letters, so this stays " "name material exactly as 'Bridge (1.4)' does, and " "unlike the delimited control this one was never inside " "a clause at all. Unchanged from dfb3170, and the gap " "the delimited control alone did not cover", shape=1), Case("a_bare_digit_chunk_after_the_comma_is_never_an_acronym", "John Smith, 1.4", {"given": "1.4", "family": "John Smith"}, notes="the comma-form twin of the row above: one word before " "the comma is never enough regardless, but the point " "this row pins is that the digit chunk never even " "becomes a CANDIDATE for the class -- no report, " "unchanged from dfb3170", shape=2), Case("a_leading_shape_token_never_reports_only_the_trailing_one", "J.A. K.D.", {"given": "J.A.", "family": "K.D."}, ambiguities=("suffix-or-name",), notes="'K.D.' is a considered pick -- the peel records it " "even though k == 2 declines to take it, so the FIELDS " "stay the two-piece positional read while the fork is " "reported, exactly as a declined LISTED pick always " "has been. 'J.A.' gets no such consideration: the peel " "walks from the END, so a LEADING shape-admitted token " "never reaches it and this reports exactly once, for " "'K.D.' alone", shape=1), Case("an_initialless_script_glued_into_periods_is_not_this_shape", "John Smith 田.中.", {"given": "John", "middle": "Smith", "family": "田.中."}, notes="the CJK control for #516's shape verdict, on the same " "reasoning is_title_shaped already gives H2's own " "period-abbreviation inference (#323): a script with " "no period abbreviations at all has nothing for " "interior periods to abbreviate, so this stays name " "material exactly as it did at dfb3170 -- no reading " "and no report", tolerated=True), # The whole-PR review round, 2026-09-18. Three groups: the fork # the prefix chain swallowed, the honorific peel that stopped # asking for the lean, and the tail segment that started flagging # its own new reading. Every row here is a REPORT moving, not a # role -- which is exactly why none of them had a row before. Case("the_chain_reports_the_acronym_it_takes", "John van der Berg Ma", {"given": "John", "family": "van der Berg Ma"}, classification="fix(#289)", ambiguities=("suffix-or-name",), notes="rules/S4 says either reading carries the flag, and " "this reading carried none. assign reports from the " "peel's picks and a pick reaches it only as a LONE " "piece, so once the prefix chain merged 'Ma' into the " "family piece the token assign would have reported on " "no longer existed. The roles are unchanged and were " "never in doubt -- 'Ma' is Title-case in a mixed-case " "name, so #289's lean declines it; what the review " "round restored is the report, emitted at the chain's " "own merge (mechanisms.md#AMBIGUITY-AT-THE-DECISION-" "SITE)", shape=1), Case("the_chain_reports_at_one_particle_too", "John de Ma", {"given": "John", "family": "de Ma"}, classification="fix(#289)", ambiguities=("suffix-or-name",), notes="the shortest spelling of the row above: one particle, " "one chained acronym. Kept beside it because the chain " "reaches the merge by a different route here -- 'de' " "takes the single following piece rather than a run -- " "and the emitter's `j > k + 1` floor is what both have " "to clear", shape=1), Case("a_title_behind_a_particle_run_past_the_second_comma", "John Smith, Jr., van der Berg Prof.", {"title": "Prof.", "given": "John", "family": "Smith", "suffix": "Jr., van der Berg"}, ambiguities=("comma-structure",), classification="fix(#636)", notes="a part past the second comma holds no name word " "(rules/C2), so group makes no particle chain in it " "and the title word behind 'van der' is a title as in " "'John Smith, Jr., Prof.'; the chain from 'der' had " "taken it into the suffix (1.4.0 and 2.3.0 read suffix " "'Jr., van der Berg Prof.')"), Case("a_title_behind_a_particle_run_after_a_family_comma_tail", "Smith, John, van der Berg Prof.", {"title": "Prof.", "given": "John", "family": "Smith", "suffix": "van der Berg"}, ambiguities=("comma-structure",), classification="fix(#636)", notes="the row above after a FAMILY comma: the part past the " "second comma holds no name word there either (rules/C2) " "and makes no chain (#636's review: a chain kept after a " "family comma alone passed every other test); 1.4.0 and " "2.3.0 read suffix 'van der Berg Prof.'"), Case("a_title_behind_a_particle_run_after_an_empty_head_tail", ",, van der Berg Prof.", {"title": "Prof.", "suffix": "van der Berg"}, ambiguities=("comma-structure",), classification="fix(#636)", notes="the empty head (nameparser/AGENTS.md's comma sweep): the part past " "the second comma makes no chain, so the title word is a " "title (rules/C2, #636); 1.4.0 and 2.3.0 read suffix " "'van der Berg Prof.'"), Case("a_credential_run_after_the_comma_reports_no_chain_fork", "John Smith, PhD Do Ma", {"given": "John", "family": "Smith", "suffix": "PhD Do Ma"}, ambiguities=("suffix-or-name",), notes="the row above behind a suffix comma: the part after it " "is the credential run C4 flips to, and assign reads a " "tail segment wholly as suffixes, so 'Ma' ends a suffix. " "The chain may not report taking it into the name, a " "reading the parse never makes (rules/C2), and since " "#613 it is not run in the part at all, decide binding the " "part before any join; the one report is " "C11's flip over the whole part. Carried a second, " "contradicting suffix-or-name on 'Ma' until 2026-09-28. " "1.4.0 read the same fields; 2.3.0 read given 'PhD', " "middle 'Do Ma', family 'John Smith'"), Case("the_chain_reports_the_by_shape_half_too", "John van der Berg X.Y.Z.", {"given": "John", "family": "van der Berg X.Y.Z."}, policy=Policy(unlisted_dotted_suffixes=False), ambiguities=("suffix-or-name",), classification="fix(#516)", notes="the BY-SHAPE half of the chain emitter's two-tag " "test, unpinned until the verification round " "(2026-09-18): with the listed tag alone, all 8624 " "tests still passed. The switch is what makes the " "half reachable -- at the default, classify writes " "BOTH tags, so the listed tag answers for this name " "too and the shape tag's absence is invisible. Off, " "only the shape tag is written, and dropping it from " "`_AMBIGUOUS_CREDENTIAL_TAGS` silences this row, " "'Freiherr von Berg X.Y.I.' and 'John van Berg A.B.' " "at once"), Case("a_particle_that_is_also_an_acronym_reports_nothing", "anh van mc", {"given": "anh", "family": "van mc"}, notes="the control the emitter above must NOT claim, and the " "reason it asks `prefix(j - 1)` last. 'mc' is a " "particle as well as suffix vocabulary, so the chain's " "PARTICLE run takes it -- P4's reading and P6's fork, " "not S2's -- and 1.4.0 read it this way in silence. " "'anh van do' is the shipped twin (its own row above) " "and this is its unambiguous-vocabulary sibling, which " "the peel never even considers", shape=1), Case("the_glued_honorific_peels_behind_a_leaning_credential", "Kim김민준씨, MA", {"family": "Kim김민준", "suffix": "씨, MA"}, classification="fix(#289)", notes="script_segment asked `is_wholly_suffix` of the " "post-comma run WITHOUT the case fact segment had just " "recorded, so 'MA' read as name material, the run was " "scanned, its only peel site was 'MA' itself -- which " "ends in no listed tail -- and the person's own 씨 went " "unpeeled (family 'Kim김민준씨'). 'Kim김민준씨, PhD' " "peeled all along, so one name in two credential " "spellings parsed two ways. Passing the fact is the " "whole fix (review round, 2026-09-18)", tolerated=True), Case("the_glued_honorific_peel_reads_the_lean_not_the_word", "Jo김민준씨, DO", {"family": "Jo김민준", "suffix": "씨, DO"}, classification="fix(#289)", notes="a second member of the ambiguous set behind the same " "comma, so the row above pins the mechanism rather " "than the word 'MA' " "(mechanisms.md#VOCABULARY-EXERCISES-FORKS is about " "not doing this per entry; 'DO' is here because it is " "ALSO particle vocabulary, which nothing else on this " "path exercises)", tolerated=True), Case("the_glued_honorific_peel_behind_a_title_and_a_lean", "Dr. 김민준씨, MA", {"title": "Dr.", "given": "민준", "family": "김", "suffix": "씨, MA"}, classification="fix(#289)", notes="since #613's /simplify the comma's own decision divides " "the name too, reading a suffix comma behind the title " "and the name word (b547c247: family '김민준', the " "division declined). " "The title is what makes this name MIXED-case at all " "-- Hangul is caseless, so '김민준씨, MA' on its own is " "one case, leans nothing and keeps today's reading " "(given 'MA', family '김민준씨', unpeeled). Two rows, " "one difference, and the difference is the case " "contrast rather than the honorific", tolerated=True), Case("a_tail_segment_of_by_shape_credentials_is_not_flagged", "John Smith, MD, R.A.I.", {"given": "John", "family": "Smith", "suffix": "MD, R.A.I."}, classification="fix(#516)", notes="rules/S3's narrow roman retirement moved 'R.A.I.' " "out of the vocabulary verdict and into the shape " "class, and segment's tail-segment test reads " "`is_wholly_suffix`, which is blind to that class by " "design -- so a third segment the parser itself reads " "as a credential run gained a COMMA_STRUCTURE flag 2.3 " "did not raise. A report about the parser's own new " "reading rather than about the name (review round, " "2026-09-18). Roles unchanged at every policy: a tail " "segment is consumed as suffix either way", shape=3), Case("the_by_shape_tail_segment_is_flagged_with_the_switch_off", "John Smith, MD, R.A.I.", {"given": "John", "family": "Smith", "suffix": "MD, R.A.I."}, policy=Policy(unlisted_dotted_suffixes=False), ambiguities=("comma-structure",), notes="the negative control for the row above, and the " "reason the test is policy-sensitive rather than a " "blanket quiet: with the switch off 'R.A.I.' is name " "material, is no candidate for the class, and the " "segment genuinely is beyond the recognized comma " "structures. Same fields, opposite report", classification="fix(#516)"), Case("a_listed_tail_segment_keeps_its_flag_where_nothing_leans", "John Smith, MD, Ma", {"given": "John", "family": "Smith", "suffix": "MD, Ma"}, ambiguities=("comma-structure",), notes="the other boundary of the same test: the quiet is the " "BY-SHAPE half's alone. A LISTED member reaches this " "reading through the case lean " "('Steven Hardman, MD, DO, DDS', " "test_segment.py's own negative control), and 'Ma' " "leans the other way, so the flag stands exactly as it " "did at 1f78bef. Folding membership into the test " "instead would have silenced the lean's own control", shape=3), Case("the_caps_shape_never_reaches_a_tail_segment", "John Smith, MD, XYZ", {"given": "John", "family": "Smith", "suffix": "MD, XYZ"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), ambiguities=("comma-structure",), classification="fix(#516)", notes="the SECOND way C2's quiet is narrow, and the half its " "examples did not pin until the verification round " "(2026-09-18). The shape a tail segment is recognized " "by is the DOTTED one alone, so turning the caps " "switch on does not quiet 'XYZ' here -- the flag is " "the same one the default policy raises. Folding " "`caps_shape_candidate` into the class run would make " "this row fail"), # #516's all-caps half. Its trailing slots are OPT-IN # (CapsSuffixes.EVERYWHERE); since #564 the default reads the part # after a comma behind two name words, which the all-caps surname # convention never writes in. The rows come in pairs: the same name # under the DEFAULT policy and under EVERYWHERE. The French and # Korean names are why the trailing slots stay off # (mechanisms.md#VOCABULARY-EXERCISES-FORKS -- each pair pins the # setting, not the words). A row that sets # the non-default policy carries no `shape=` tag: corpus_shapes.jsonl # (build_shapes_corpus.py) keys only on (shape, text), with no # policy of its own, so admitting one of these texts under a shape # id would have the snapshot read it under the DEFAULT policy -- # the wrong question for a row # whose point IS the non-default policy. Case("caps_surname_is_a_family_name_by_default", "Jean DUPONT", {"given": "Jean", "family": "DUPONT"}, notes="the writing convention the default protects: French " "records write the surname in capitals, and shape " "cannot tell that from a credential. 1.4.0's reading, " "2.3's reading, and the reading at this default", shape=1), Case("caps_surname_is_swallowed_with_the_switch_on", "Jean Pierre DUPONT", {"given": "Jean", "family": "Pierre", "suffix": "DUPONT"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), classification="fix(#516)", ambiguities=("suffix-or-name",), notes="the cost of the switch, pinned so nobody turns it on " "without meeting it: three name words, so the count " "reads the capitalised surname as a credential and the " "family becomes 'Pierre'. This row is the argument for " "the default being off (decisions/S9)"), # #516 review round (quality-review finding): the TWO-word shape # ('Jean DUPONT') is the row `_policy.py`'s own docstring needed # and did not have -- turning the switch on reports a genuine # candidate even where one word before the credential declines # the structure flip, matching 'the_caps_comma_count_declines_at_ # one_word' (the comma form's own twin of this exact guarantee). Case("caps_surname_reports_but_does_not_move_at_two_words", "Jean DUPONT", {"given": "Jean", "family": "DUPONT"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), ambiguities=("suffix-or-name",), notes="one name word is never enough to spend the credential " "reading, so the family stays 'DUPONT' -- but the fork " "was genuinely considered and declined, and reports so " "even though nothing moved"), Case("caps_surname_default_reading_at_three_words", "Jean Pierre DUPONT", {"given": "Jean", "middle": "Pierre", "family": "DUPONT"}, notes="the same name at the default, which is the half a " "reader of the row above needs: nothing moves and " "nothing is reported, so a caller who never sets the " "switch never meets that cost", shape=1), Case("caps_korean_surname_is_a_family_name_by_default", "Minjun KIM", {"given": "Minjun", "family": "KIM"}, notes="the second convention the default protects: Korean " "records write the family name in capitals to mark " "which of two words it is, which is the opposite of a " "credential", shape=1), Case("unlisted_caps_reads_by_position_with_the_switch_on", "John Smith XYZ", {"given": "John", "family": "Smith", "suffix": "XYZ"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), classification="fix(#516)", ambiguities=("suffix-or-name",), notes="what the switch buys: the shape reads, and the " "words-to-spare count decides it exactly as it decides " "the listed set"), Case("unlisted_caps_is_silent_at_the_default", "John Smith XYZ", {"given": "John", "middle": "Smith", "family": "XYZ"}, notes="the same name at the default: name material, and NO " "report -- the one place this design emits nothing " "where a fork could be said to exist, because the " "reading was never on offer (#516)", shape=1), # #564: the comma position is read by default # (CapsSuffixes.AFTER_COMMA); the trailing one stays opt-in. Case("an_all_caps_word_after_a_full_name_and_a_comma_is_a_credential", "John Smith, XYZ", {"given": "John", "family": "Smith", "suffix": "XYZ"}, classification="fix(#564)", ambiguities=("suffix-or-name",), notes="the all-caps SURNAME convention never writes the " "capitals after a comma behind a full name, so the " "default reads the comma position (rules/C6, S9). " "2.3.0 read given 'XYZ', family 'John Smith'"), Case("the_comma_caps_reading_can_be_switched_off", "John Smith, XYZ", {"given": "XYZ", "family": "John Smith"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.OFF), notes="#564: CapsSuffixes.OFF is 2.3.0's reading, and the one " "way to keep a given name written in capitals after a " "two-word surname ('García Márquez, GABRIEL')"), Case("a_lone_two_letter_caps_word_after_a_comma_is_the_given_name", "García Márquez, MJ", {"given": "MJ", "family": "García Márquez"}, notes="#564 boundary: two capitals alone are how initials are " "written undotted, and two words before the comma may " "be one surname -- the case #563 decides for 'M.J.'. No " "report: the comma position declines it outright"), Case("an_all_caps_record_keeps_its_given_name_beside_a_mixed_credential", "LLOYD WEBBER, ANDREW PhD", {"given": "ANDREW", "family": "LLOYD WEBBER", "suffix": "PhD"}, notes="#564 boundary: an all-caps word joins C4's run (C6) only where " "the NAME carries the contrast, a word of its own before " "the comma holding a capital and ending in lowercase " "-- here the only " "lowercase is the credential's own, and a record written " "wholly in capitals keeps its given name. A review-round " "draft let 'PhD' supply the contrast and read suffix " "'ANDREW PhD'"), Case("a_maiden_clause_supplies_no_contrast_for_the_caps_shape", "LLOYD WEBBER née Smith, ANDREW PhD", {"given": "ANDREW", "family": "LLOYD WEBBER", "suffix": "PhD", "maiden": "Smith"}, notes="#564 boundary: the contrast is the name's OWN words " "(own_words): a maiden clause's lowercase is not the " "name's, so the all-caps record keeps its given name. A " "review-round draft counted every pre-comma token and " "read suffix 'ANDREW PhD'"), Case("a_title_supplies_no_contrast_for_the_caps_shape", "Mr LLOYD WEBBER, ANDREW PhD", {"title": "Mr", "given": "ANDREW", "family": "LLOYD WEBBER", "suffix": "PhD"}, classification="fix(#701)", notes="#564 boundary: titles and particles are written in " "lowercase in a record that capitalizes its surname, so " "neither counts as the name's contrast and the given name " "stays. Until #701 the title read into the family, the " "listing form keeping the text before the comma whole"), Case("a_generation_supplies_no_contrast_for_the_caps_shape", "LLOYD WEBBER Jr., ANDREW", {"given": "ANDREW", "family": "LLOYD WEBBER", "suffix": "Jr."}, notes="#564 boundary: only the name's own words written as a " "name is (a capital, and a lowercase last letter), " "unclaimed by the vocabulary and unmarked by a period, " "supply the contrast; 'Jr.' is none of those, " "so the 'SURNAME Jr., GIVEN' record " "keeps its given name. A review-round draft excluded " "titles and particles only and read suffix 'Jr., ANDREW'"), Case("a_connective_supplies_no_contrast_for_the_caps_shape", "GARCÍA y LÓPEZ, ANDREW", {"given": "ANDREW", "family": "GARCÍA y LÓPEZ"}, notes="#564 boundary: a lowercase connective in a record that " "capitalizes its surnames is the record's convention, " "not the name's contrast"), Case("a_later_comma_part_supplies_no_contrast_for_the_caps_shape", "JOHN SMITH, ANDREW, Jr.", {"given": "ANDREW", "family": "JOHN SMITH", "suffix": "Jr."}, notes="#564 boundary: the contrast is read before the comma; " "a generation in a third part is not the name's. The " "whole-name case test of an earlier draft read suffix " "'ANDREW, Jr.'; master keeps given 'ANDREW' too"), Case("a_glued_particle_supplies_no_contrast_for_the_caps_shape", "GISCARD d'ESTAING, VALÉRY", {"given": "VALÉRY", "family": "GISCARD d'ESTAING"}, notes="#564 (Derek): the name carries the contrast only through " "a word holding a capital and ending in lowercase that no " "wordlist claims as non-name text. A surname written in " "capitals ends in a capital whatever is glued to it -- the " "French convention the trailing slot stays off for. A " "draft counting any lowercase letter read suffix " "'VALÉRY'"), Case("an_unlisted_lowercase_word_supplies_no_contrast_for_the_caps_shape", "LLOYD ap RHYS, DAFYDD", {"given": "DAFYDD", "family": "LLOYD ap RHYS"}, notes="#564 boundary: 'ap' is in no wordlist, but a word written " "wholly in lowercase never carries the contrast, so no " "list has to know it"), Case("an_all_lowercase_name_carries_no_contrast_for_the_caps_shape", "john smith, XYZ", {"given": "XYZ", "family": "john smith"}, notes="#564 (Derek): a lowercase-only name holds no capital, " "so the default keeps the " "listing form; the cost of the criterion, accepted with " "it"), Case("a_lone_capital_supplies_no_contrast_for_the_caps_shape", "de GAULLE C, CHARLES", {"given": "CHARLES", "family": "de GAULLE C"}, notes="#564 boundary: an undotted initial is a single capital, " "with no lowercase letter behind it. A draft testing " "`istitle()`, which accepts 'C', read suffix 'CHARLES' " "with no given name"), Case("a_glued_capitalized_prefix_supplies_no_contrast", "LLOYD FitzGERALD, RONALD", {"given": "RONALD", "family": "LLOYD FitzGERALD"}, notes="#564 (Derek): a surname written in capitals ends in a " "capital whatever is glued in front of it ('FitzGERALD', " "'McDONALD', 'DeVITO', 'St-PIERRE'), so the record keeps " "its given name. The capital-then-lowercase draft took " "'Fi' as the contrast and read suffix 'RONALD'"), Case("a_mc_prefix_on_a_capitalized_surname_supplies_no_contrast", "LLOYD McDONALD, RONALD", {"given": "RONALD", "family": "LLOYD McDONALD"}, notes="#564: 'McDONALD' ends in a capital, so no Mc/Mac case is " "needed; 'McDonald' ends lowercase and carries the " "contrast"), Case("an_interior_capital_carries_the_contrast", "DiCaprio LaBeouf, XYZ", {"given": "DiCaprio", "family": "LaBeouf", "suffix": "XYZ"}, classification="fix(#564)", ambiguities=("suffix-or-name",), notes="#564 (Derek): a capital anywhere in a word ending " "lowercase is the contrast, so an interior capital counts " "('DiCaprio', 'IJzerman', 'al-Rashid'). " "The `istitle()` draft read given 'XYZ' here"), Case("a_name_typed_with_decomposed_accents_carries_the_contrast", "Jose\u0301 Andre\u0301, XYZ", {"given": "Jose\u0301", "family": "Andre\u0301", "suffix": "XYZ"}, classification="fix(#564)", ambiguities=("suffix-or-name",), notes="#564: the contrast test reads a word's last LETTER after " "composing it, so 'André' typed with a combining accent " "ends in 'é' and reads as its composed spelling does. A " "draft reading the last character took the accent and " "kept given 'XYZ'"), Case("a_trailing_caseless_letter_is_no_evidence_against_the_contrast", "Asmāʾ Wafāʾ, XYZ", {"given": "Asmāʾ", "family": "Wafāʾ", "suffix": "XYZ"}, classification="fix(#564)", ambiguities=("suffix-or-name",), notes="#564: the contrast test reads the last letter that has " "case, passing over the caseless transliteration letter " "'ʾ' as it passes over an apostrophe. A draft stopping at " "it kept given 'XYZ'"), Case("a_capitalized_given_name_behind_a_two_word_surname_is_the_accepted_cost", "García Márquez, JUAN Jr.", {"given": "García", "family": "Márquez", "suffix": "JUAN Jr."}, classification="fix(#564)", ambiguities=("suffix-or-name",), notes="#564's accepted cost (Derek): a given name written in " "capitals behind a two-word surname without particles, " "in a mixed-case name, reads as the credential -- with a " "generation behind it as alone ('García Márquez, " "GABRIEL'). Reported, and CapsSuffixes.OFF keeps given " "'JUAN'"), Case("the_caps_comma_count_needs_two_name_words", "John Smith, XYZ", {"given": "John", "family": "Smith", "suffix": "XYZ"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), classification="fix(#516)", ambiguities=("suffix-or-name",), notes="the comma structure moves with this half too, on the " "same NAME-word count"), Case("the_caps_comma_count_declines_at_one_word", "Smith, XYZ", {"given": "XYZ", "family": "Smith"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), ambiguities=("suffix-or-name",), notes="its control, switch and all: one name word before the " "comma is never enough, so the capitalised word stays " "the given name"), Case("one_case_input_never_reaches_the_caps_switch", "JOHN SMITH XYZ", {"given": "JOHN", "middle": "SMITH", "family": "XYZ"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), notes="the one-case control, and it needs the switch ON to " "mean anything: capitals against capitals are no " "contrast, so the shape never fires and the row is " "unchanged with the switch either way"), Case("suffix_vocabulary_never_reaches_the_caps_switch", "John Smith MC", {"given": "John", "family": "Smith", "suffix": "MC"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), notes="UNLISTED is the load-bearing word: 'mc' is suffix " "vocabulary, so the whole-token lookup claims it before " "any shape reading and this row reads the same with " "the switch on or off"), Case("the_caps_comma_count_reaches_a_multi_word_run", "John Smith, LEED AP", {"given": "John", "family": "Smith", "suffix": "LEED AP"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), classification="fix(#516)", ambiguities=("suffix-or-name",), notes="the first time this class reaches the comma form as " "MORE than one token: 'LEED' and 'AP' are two separate " "all-caps words, and every token in the run must be a " "candidate for the run itself to be one. Since #564 the " "DEFAULT reads it the same way, the comma position being " "on by default, and rules/C1's (now rules/C6's) `deviates: #291` " "marker came off with that change"), Case("the_caps_comma_multi_word_run_declines_at_one_word", "Smith, LEED AP", {"given": "LEED", "family": "Smith", "suffix": "AP"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), classification="fix(#531)", ambiguities=("suffix-or-name", "suffix-or-name"), notes="the one-pre-comma-word twin of the row above, and the " "only corpus mover #531 found outside the two known " "names -- only behind a default-off switch, so no " "default reading is at stake (Derek's Q3, accepted " "2026-09-18). The STRUCTURE still does not flip: one " "name word before the comma is never enough, item 5's " "count doing its job on real data, so 'LEED' stays the " "given name. What moved is the TRAILING slot behind it " "-- before #531 'AP' was the middle name and the only " "report was assign's family-comma emitter reading the " "first post-comma piece's tag; now the given part's " "trailing slot takes 'AP' as the credential and reports " "its own decision. Two slots, two forks, two reports"), # #516 review round: LISTED members must keep #289's lean with # the switch on -- the caps branch must never ride SHAPE_ACRONYM_ # TAG beside a listed member's own membership tag, which is what # silenced `listed_lean` for these before the fix (F1, decided by # the reviewer; decisions/S9). Both rows are byte-identical to # their DEFAULT-policy siblings above (`caps_ambiguous_leans_ # credential`, `the_lean_reaches_the_post_comma_slot`) and carry # no `shape=` tag for the same reason every other policy-on row # here does not: corpus_shapes.jsonl keys on (shape, text) with no # policy of its own, so tagging one would have the snapshot read it # under the DEFAULT policy -- the # wrong question for a row whose point IS the non-default policy # (`Case.shape`'s own docstring). Case("caps_switch_does_not_silence_the_listed_lean", "Jack MA", {"given": "Jack", "suffix": "MA"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), ambiguities=("given-or-family",), notes="the switch must not touch a LISTED member's own " "#289 lean: identical to the default reading"), Case("caps_switch_does_not_silence_the_comma_lean", "Smith, MA", {"family": "Smith", "suffix": "MA"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), notes="the comma-form twin of the row above, same guarantee"), # #516 review round: UNLISTED means in no wordlist at all, not # merely "no whole-token suffix vocabulary" -- a capitalized # particle or particle phrase must not join the class either # (F1b, decided by the reviewer; decisions/S9's amendment). Case("caps_switch_does_not_claim_a_capitalized_particle", "John Smith DE", {"given": "John", "middle": "Smith", "family": "DE"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), notes="'de' is a particle, not merely absent from suffix " "vocabulary -- identical to the default reading with " "the switch on"), Case("caps_switch_does_not_claim_a_capitalized_particle_phrase", "John Smith, DE LA", {"family": "John Smith DE LA"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), notes="the multi-token twin: 'DE' and 'LA' are both " "particles, so the RUN test (#516's own 'LEED AP' " "shape) must decline them too -- identical to the " "default reading"), # #516 review round, F5: the spec's stated mechanism for why # 'Jack VI' does not move was wrong -- 'VI' DOES reach the caps # predicate and carries both tags with the switch on. It stays # unchanged because `_vocab.reads_as_generation` (the # roman-numeral fork) claims it downstream, before the lean/count # this switch adds is ever consulted -- unrelated to this switch, # and the reason these controls read identically on or off. Case("caps_switch_does_not_move_a_roman_numeral", "Jack VI", {"given": "Jack", "suffix": "VI"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), ambiguities=("suffix-or-name", "given-or-family"), notes="the roman-numeral fork claims 'VI' before this " "switch's lean/count is consulted -- identical to the " "default reading, on or off"), Case("caps_switch_does_not_move_a_roman_numeral_with_words_to_spare", "John Smith VI", {"given": "John", "family": "Smith", "suffix": "VI"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), ambiguities=("suffix-or-name",), notes="the words-to-spare twin of the row above, same " "mechanism, same guarantee"), Case("caps_switch_does_not_move_a_title_floor_control", "Mr XXX", {"title": "Mr", "family": "XXX"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), notes="one piece behind a title never reaches the peel's " "`k >= 2` floor -- identical to the default reading"), Case("caps_switch_does_not_reach_delimited_content", "Andrew Perkins (XYZ)", {"given": "Andrew", "family": "Perkins", "nickname": "XYZ"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), notes="delimited content is decided by the clause escape, " "never at the trailing slot -- identical to the " "default reading"), # #516 review round, F2: the multi-token run test is a property of # the CAPS class alone -- a run of pure LISTED members must keep # its EXISTING reading (`_pieces.segment_suffix_reading`'s own # per-piece walk, #289, unrelated to this switch and to its # structure-flip candidate test) rather than being swept into the # caps run test's `all()`. Identical to the default reading. Case("caps_switch_run_test_declines_a_pure_listed_run", "John Smith, Ed Ma", {"given": "John", "family": "Smith", "suffix": "Ed Ma"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), ambiguities=("suffix-or-name",), notes="'Ed' and 'Ma' are both LISTED ambiguous members, " "Title-case (leans NAME, #289) -- the caps run test " "must not admit a run the listed class already reads " "on its own, and it does not: what reads this part is " "C4's listed run (#544), which counts the two name " "words before the comma exactly as it does with the " "switch off, so the part is the credential run and " "the flip reports once. 1.4.0 read the same suffix; " "before #544 the part was the given name 'Ed', " "middle 'Ma'"), # #516 review round (second finding): the caps branch read # `one_case_own` -- true only for a token INSIDE the maiden # clause's own-words span -- where it needed the bare NAME-level # fact. 'NEE' opens the clause, so it sits OUTSIDE that span by # construction and read as False regardless of the whole name's # case, wrongly joining the shape class in a wholly one-case name. # `maiden_markers` is also the one wordlist the ten-list exclusion # first shipped without. Case("caps_switch_does_not_claim_a_one_case_maiden_marker", "JOHN SMITH NEE", {"given": "JOHN", "middle": "SMITH", "family": "NEE"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), notes="one case, and a maiden marker with no clause to open " "(nothing follows it) -- identical to the default " "reading either way"), Case("caps_switch_does_not_claim_a_mixed_case_maiden_marker", "John Smith NEE", {"given": "John", "middle": "Smith", "family": "NEE"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), notes="the mixed-case twin: 'NEE' is unlisted by the caps " "shape test's OWN membership check too, so this one " "was already declining before this fix -- pinned " "beside its one-case sibling for the same guarantee"), # The 2026-09-18 review round's unpinned branches: live code paths # that no row named, found by reading the diff rather than by a # failure. Nothing here moved; every value is measured. Case("caps_switch_reads_the_name_level_case_past_a_clause", "née JONES XYZ", {"given": "née", "middle": "JONES", "family": "XYZ"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), notes="the positive half of the two NEE rows above, and the " "row that fails if classify's caps branch is reverted " "to `one_case_own`: a marker OPENING the name leaves " "own_words EMPTY, so the name-level fact is one-case " "and nothing in the clause can join the shape class. " "Under `one_case_own` every token past the cut reads " "as mixed and 'XYZ' becomes a credential. " "test_classify.py monkeypatches the revert and asserts " "this very reading breaks"), Case("caps_one_case_comma_declines_a_single_token", "JOHN SMITH, XYZ", {"given": "XYZ", "family": "JOHN SMITH"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), notes="the comma control for the one-case gate: two name " "words before the comma would flip the structure for a " "LISTED member, but the caps class needs a case " "contrast to be a member at all, and a wholly " "upper-case name has none. No report either -- there " "was no fork to call"), Case("caps_one_case_comma_declines_a_run", "JOHN SMITH, LEED AP", {"given": "LEED", "middle": "AP", "family": "JOHN SMITH"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), notes="the multi-token twin: `_comma.decide` asks " "`caps_shape_candidate` of each word and the name's " "case contrast ONCE, so a one-case name declines the " "whole run rather than per token"), Case("caps_run_needs_every_token_not_any", "John Smith, LEED Jones", {"given": "LEED", "middle": "Jones", "family": "John Smith"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), classification="fix(#516)", ambiguities=("suffix-or-name",), notes="pins `all()` rather than `any()` in the all-caps run " "test: a name word in the part leaves it no caps run, so " "the structure stays the listing form, and no other " "route reaches it (the #544 run test breaks on the name " "word). The report is assign's post-comma one, fired on " "'LEED', which carries the shape tag from classify. Until " "#564 this row used 'John Smith, LEED BA', whose listed " "'BA' the run test now reads beside a caps member as one " "credential run (the row below)"), Case("a_caps_word_and_a_listed_credential_make_one_run", "John Smith, LEED BA", {"given": "John", "family": "Smith", "suffix": "LEED BA"}, classification="fix(#564)", ambiguities=("suffix-or-name",), notes="#564: an unlisted all-caps word is a member of C4's run " "by shape, so beside a listed credential it reads as the " "run it is -- as 'John Smith, PhD XYZ' and 'John Smith, " "XYZ Jr.' do. Until #564 the listing form read given " "'LEED' with the switch on, and more evidence for a " "credential produced a name reading"), Case("the_comma_count_counts_names_not_words_behind_a_title", "Mr Smith, Ma", {"title": "Mr", "given": "Ma", "family": "Smith"}, ambiguities=("suffix-or-name",), classification="fix(#701)", notes="MEASURED. name_word_count's TITLE arm: 'Mr' is title " "vocabulary, so the part before the comma holds ONE " "name word and the count declines the flip. Without " "that arm two tokens would read as two names and hand " "the family to `given`"), # rules/H6 (#701): a title opening the part before a family # comma. The rows sweep the spellings and comma neighbours the rule # reads; its own examples carry the plain shapes. Case("a_title_before_a_family_comma_peels_beside_a_nickname", "Dr. (Bob) Smith, John", {"title": "Dr.", "given": "John", "family": "Smith", "nickname": "Bob"}, classification="fix(#701)", notes="the nickname is extracted before the run is read, so " "it does not stand between the title and the family"), Case("a_title_before_a_family_comma_peels_ahead_of_a_maiden_clause", "Dr. Smith née Jones, Mary", {"title": "Dr.", "given": "Mary", "family": "Smith", "maiden": "Jones"}, classification="fix(#701)"), Case("a_title_before_a_family_comma_peels_ahead_of_a_shape_numeral", "Dr. Smith VI, John", {"title": "Dr.", "given": "John", "family": "Smith", "suffix": "VI"}, ambiguities=("suffix-or-name",), classification="fix(#774)", notes="VI is read by shape alone, and since #752 the part " "before a family comma reads a numeral ending it as the " "comma-less name does (C1), as it reads 'III'; reported, " "being in no wordlist. #701 to 2.6.0 kept family " "'Smith VI'"), Case("a_title_before_a_family_comma_stops_at_a_period_marked_dual", "Dr. Freiherr. von Berg, Ed", {"title": "Dr.", "given": "Ed", "family": "Freiherr. von Berg"}, ambiguities=("suffix-or-name",), classification="fix(#701)", notes="'Freiherr' is a particle as well as a title in either " "spelling, so the run stops at it; the bare spelling is " "rules/H6's example"), Case("a_period_marked_dual_opening_a_family_comma_stays_family", "Freiherr. von Berg, Ed", {"given": "Ed", "family": "Freiherr. von Berg"}, ambiguities=("suffix-or-name",)), Case("a_dual_second_in_a_title_run_before_a_family_comma_peels", "Hon. Ms Smith, Jane", {"title": "Hon. Ms", "given": "Jane", "family": "Smith"}, classification="fix(#701)", notes="'Ms' is suffix vocabulary too, and a name word follows " "it, so it is a title as in 'Hon. Ms Jane Smith'. The " "second #701 draft stopped in front of any suffix word " "and read family 'Hon. Smith', suffix 'Ms' (rules/H6's " "'Rev. Sr.' example; the design-docs review)"), Case("a_title_before_a_family_comma_stops_at_a_bare_saint", "Dr. St Pierre, Ed", {"title": "Dr.", "given": "Ed", "family": "St Pierre"}, ambiguities=("suffix-or-name",), classification="fix(#701)"), Case("a_listed_title_before_a_family_comma_peels_in_cyrillic", "г-н Вернер, Иван Асен", {"title": "г-н", "given": "Иван", "middle": "Асен", "family": "Вернер"}, classification="fix(#701)", notes="CLDR's bg index rendering: a listed title with no " "period, which H2's shape could never have read"), Case("a_title_before_a_family_comma_reads_the_same_family_first", "Dr. de Mesnil, Jean", {"title": "Dr.", "given": "Jean", "family": "de Mesnil"}, policy=Policy(name_order=FAMILY_FIRST), classification="fix(#701)", notes="the comma names the family, so no declared order moves " "the title or the particle surname behind it"), Case("a_joined_title_unit_before_a_family_comma_peels_whole", "Secretary of State Smith, John", {"title": "Secretary of State", "given": "John", "family": "Smith"}, classification="fix(#701)", notes="a connective join makes one title piece, which the run " "reads by its piece tag rather than its first word"), Case("a_joined_salutation_before_a_family_comma_peels_whole", "Mr. and Mrs. Smith, John", {"title": "Mr. and Mrs.", "given": "John", "family": "Smith"}, classification="fix(#701)"), Case("a_two_word_abbreviated_title_before_a_family_comma_peels_whole", "Lt. Gov. Smith, John", {"title": "Lt. Gov.", "given": "John", "family": "Smith"}, classification="fix(#701)", notes="the run is vocabulary-only, so before 'gov' was listed " "it stopped after 'Lt.' and read family 'Gov. Smith', " "where the comma-less name reads title 'Lt. Gov.' by " "H2's shape (the PR review of #701)"), Case("a_title_before_a_family_comma_reads_the_same_given_last", "Prof. Dr. Brühl, Ada", {"title": "Prof. Dr.", "given": "Ada", "family": "Brühl"}, policy=Policy(name_order=FAMILY_FIRST_GIVEN_LAST), classification="fix(#701)", notes="the second family-first order, beside the FAMILY_FIRST " "row: test_cases' three-order sweep checks R2's family " "partition only, so it would not see the title move"), Case("bare_gov_opening_a_name_is_a_title", "Gov Smith", {"title": "Gov", "family": "Smith"}, classification="fix(#701)", notes="'gov' joined TITLES in the #701 review, as the listed " "'governor' abbreviated; 1.4.0 through 2.4.2 read given " "'Gov'. 236 US Census surname bearers, so a surname " "written first loses it (decisions/H6)"), Case("bare_cllr_opening_a_name_is_a_title", "Cllr John Smith", {"title": "Cllr", "given": "John", "family": "Smith"}, classification="fix(#847)", notes="'cllr' is 'councillor' abbreviated, as 'gov' is " "'governor'; 1.4.0 through 2.5.0 read given 'Cllr', " "middle 'John'. The period-marked 'Cllr.' was already " "a title by shape, and a bare trailing 'Cllr' stays a " "name word (rules/H5)"), Case("the_comma_count_counts_names_not_words_behind_a_suffix", "Smith Jr, Ma", {"given": "Ma", "family": "Smith", "suffix": "Jr"}, ambiguities=("suffix-or-name",), notes="the SUFFIX arm of the same count, and the shape " "decisions/S6 names: 'Smith Jr., MA' is two tokens " "and one name. The Title-case spelling declines where " "the all-caps one flips, which is the pair rules/C4 " "states"), Case("strict_comma_reads_the_dotted_numeral_as_a_name_word", "Smith V., Ma", {"given": "Smith", "family": "V.", "suffix": "Ma"}, policy=Policy(lenient_comma_suffixes=False), ambiguities=("suffix-or-name",), notes="MEASURED, and a known rough edge recorded rather than " "repaired (decisions/S4, 2026-09-18). Under strict " "the initial-shaped 'V.' fails the suffix test, so " "name_word_count sees TWO name words before the comma, " "flips the structure, and the positional read of " "'Smith V.' gives given 'Smith', family 'V.'. " "Consistent with master: 'Smith V., PhD' reads the " "same way under the same knob, so this is the knob's " "own reading of 'V.' and not the credential class's"), Case("strict_comma_reads_the_bare_numeral_into_the_run", "Smith V, Ma", {"given": "Smith", "family": "V", "suffix": "Ma"}, policy=Policy(lenient_comma_suffixes=False), ambiguities=("suffix-or-name", "suffix-or-name"), notes="the period is the whole difference from the row " "above: bare 'V' is still initial-shaped and still " "counts as a name word here, so the structure flips " "the same way. Until #718 the trailing peel then took " "it as the roman numeral, leaving 'Smith' the only " "name word and reporting all three forks; a one-letter " "numeral now needs words to spare (S10, the trailing " "roman numeral), so 'V' is the family -- 1.4.0's " "reading field for field, which #718 restored -- and " "both forks, 'V' and 'Ma', report"), Case("strict_comma_reads_a_one_case_numeral_as_its_capital", "john smith, v", {"given": "v", "family": "john smith"}, policy=Policy(lenient_comma_suffixes=False), notes="the strict test vetoes an initial, and in a name " "written in one case a bare 'v' is one (T4), so this " "reads as 'JOHN SMITH, V' does under the same knob: " "the knob's 'V' as a given-name initial (#727; before, " "suffix 'v')"), Case("strict_comma_reports_a_one_case_numeral_in_a_third_part", "smith, john, i", {"given": "john", "family": "smith", "suffix": "i"}, policy=Policy(lenient_comma_suffixes=False), ambiguities=("comma-structure",), notes="the third part holds no word the strict test calls a " "suffix, so C2 reads it as suffix material and reports " "the structure, as for 'SMITH, JOHN, I' (#727; before, " "no report)"), Case("caps_run_declines_a_bound_given_head", "John Smith, ABDUL AP", {"given": "ABDUL AP", "family": "John Smith"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), notes="the exclusion end to end rather than at the " "predicate: 'ABDUL' is bound-given vocabulary, so the " "run is no caps run and the part after the comma is " "the given name it would be at the default"), Case("caps_run_declines_a_conjunction", "John Smith, AND AP", {"given": "AND AP", "family": "John Smith"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), notes="the same end to end for `conjunctions`, the row " "test_classify.py's predicate table names as the one " "that genuinely exercises that arm ('Y' declines at " "the two-character gate first)"), Case("caps_switch_leaves_a_capitalized_title_a_title", "John Smith, MR", {"title": "MR", "given": "John", "family": "Smith"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), notes="`titles` end to end: the post-comma part holds no " "name word, so C8's no-name-word clause keeps the " "pre-comma positional read and 'MR' is the title it " "is at the default"), Case("a_period_final_delimited_clause_joins_the_shape_class", "Andrew Perkins (X.Y.Z.)", {"given": "Andrew", "family": "Perkins", "suffix": "X.Y.Z."}, classification="fix(#516)", ambiguities=("suffix-or-name",), notes="MEASURED, and it NARROWS the note on " "caps_switch_does_not_reach_delimited_content: " "`_extract._suffix_shaped` releases period-final " "delimited content with role None, so it is ordinary " "trailing material by the time classify runs and the " "shape class claims it -- matching '(M.D)', which the " "escape already sent to `suffix`. What the " "`token.role is None` guard keeps out is content the " "escape did NOT release ('Bridge (A.B)', a nickname), " "which is a different population from 'delimited " "content' whole"), Case("a_leading_period_is_not_the_dotted_shape", "John Smith .XY", {"given": "John", "middle": "Smith", "family": ".XY"}, notes="`period_joined_vocab` splits on interior periods, so " "a LEADING one leaves a single chunk and there is no " "acronym shape to read -- name material, and no fork " "was called, so nothing is reported"), Case("a_caseless_name_word_leaves_the_count_to_decide", "毛泽东 MA", {"given": "毛泽东", "family": "MA"}, ambiguities=("suffix-or-name",), notes="the comma-less twin of the 毛泽东, MA row: one name " "word before the acronym, so the peel's two-piece " "floor declines it whatever the writing says, and the " "fork is reported all the same. Han is caseless, so " "the NAME is mixed-case (the all-caps 'MA' contrasts " "with nothing that has a case) -- the lean reads " "'credential' and the floor is what refuses it", tolerated=True), Case("a_digit_chunk_moves_the_trailing_slot_silently", "John Smith 1.4.2", {"given": "John", "middle": "Smith", "family": "1.4.2"}, classification="fix(#516)", notes="ACCEPTED and SILENT, recorded at decisions/S3. " "rules/S3's narrow retirement drops a dotted token " "whose every matched chunk is a single ASCII " "character, and the vocabulary's lone digit '2' is one " "of them -- so the chunk claim goes, and the shape " "class cannot take it either, its own gate wanting " "every chunk ALPHABETIC. A version string read as a " "credential was the same accident the retirement " "removes, so it moves suffix -> family at every " "policy with no report"), Case("a_digit_chunk_moves_the_post_comma_slot_silently", "Smith, 1.4.2", {"given": "1.4.2", "family": "Smith"}, classification="fix(#516)", notes="the comma twin: no candidate, so no flip and no " "report -- the part after the comma is simply the " "given name, where 2.3 read family 'Smith', suffix " "'1.4.2'"), Case("a_digit_chunk_moves_the_two_word_comma_slot_silently", "John Smith, 1.4.2", {"given": "1.4.2", "family": "John Smith"}, classification="fix(#516)", notes="and with TWO name words before the comma, where a " "real class member would flip the structure: '1.4.2' " "is no member, so the listing form stands and the " "whole pre-comma run is the family. The widest of the " "three silent moves"), Case("the_comma_flip_is_read_under_the_declared_order", "JOHN SMITH, MA", {"given": "SMITH", "family": "JOHN", "suffix": "MA"}, policy=Policy(name_order=FAMILY_FIRST), classification="fix(#289)", ambiguities=("suffix-or-name",), notes="MEASURED. The count flips the structure whatever the " "order -- it counts NAME words, which no order changes " "-- and the pre-comma run is then read positionally, " "so the declared order decides which of 'JOHN SMITH' " "is the family. The report's wording quotes the count, " "not a role, so it reads the same under every order. " "Written in one case since #785: in a name of mixed " "case the capitals settle 'MA' and the flip reports " "nothing (rules/S6)"), Case("two_caps_credentials_peel_as_a_run", "John MA XYZ", {"given": "John", "suffix": "MA XYZ"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), classification="fix(#516)", ambiguities=("given-or-family", "suffix-or-name"), notes="MEASURED, and the widest reading the switch reaches: the " "peel walks from the END, so 'XYZ' goes first (two pieces " "still stand behind it), and 'MA' then has 'John' alone " "behind it -- but its LISTED lean says credential, which " "needs no words to spare. The name loses its family name " "entirely and says so twice, the listed 'MA' settled by its " "capitals and silent since #785 (rules/S6). At the default " "it reads given John, middle MA, family XYZ, silently"), Case("the_caps_shape_is_script_agnostic_cyrillic", "Иван Петр ИВАНОВ", {"given": "Иван", "family": "Петр", "suffix": "ИВАНОВ"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), classification="fix(#516)", ambiguities=("suffix-or-name",), notes="the switch's docstring claims `isupper()` is " "script-agnostic, so the convention and the reason for " "the default are the same in any script with a case " "contrast. Measured rather than asserted in prose: a " "Cyrillic all-caps surname joins the class exactly as " "'Jean Pierre DUPONT' does, and is swallowed the same " "way"), Case("the_dotted_switch_off_still_reports_the_post_comma_fork", "Smith, A.B.", {"given": "A.B.", "family": "Smith"}, policy=Policy(unlisted_dotted_suffixes=False), ambiguities=("suffix-or-name",), notes="MEASURED. With the switch off the token never joins " "the class, so the STRUCTURE cannot flip -- but " "classify still writes SHAPE_ACRONYM_TAG (the fork was " "real and the parser declined it), and assign's " "post-comma report reads that tag directly. Exactly " "ONE report: the shape tag is on one piece and the " "reading is read off the first post-comma piece alone", classification="fix(#516)"), Case("the_dotted_switch_off_leaves_a_lone_token_to_the_convention", "A.B.", {"given": "A.B."}, policy=Policy(unlisted_dotted_suffixes=False), ambiguities=("given-or-family",), notes="one piece, so the peel's two-piece floor refuses the " "class before the switch is consulted at all, and what " "is left is O5's lone-name-word convention. GIVEN_OR_" "FAMILY and nothing else -- pinned because the shape " "tag is present and must NOT produce a second report " "where no fork was taken"), Case("the_dotted_switch_off_leaves_a_titled_token_a_name", "Dr. A.B.", {"title": "Dr.", "family": "A.B."}, policy=Policy(unlisted_dotted_suffixes=False), notes="the title floor: the peel's walk starts after the " "leading title run, so one piece is all it sees and " "the fork is never consulted -- no report at all, the " "same floor 'Mr MA' pins for the listed half"), Case("the_caps_shape_is_script_agnostic_accented", "Jean Pierre ÉCOLE", {"given": "Jean", "family": "Pierre", "suffix": "ÉCOLE"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), classification="fix(#516)", ambiguities=("suffix-or-name",), notes="the other half of the same claim: a non-ASCII LATIN " "letter. `isalpha()`/`isupper()` are Unicode-wide, so " "an accented capital is admitted -- the docstring's " "'Jean ÉCOLE' example, given the third word it needs " "to have words to spare"), Case("catalan_i_in_a_one_case_name_reads_as_an_initial_upper", "JOSEP CAROD I ROVIRA", {"given": "JOSEP", "middle": "CAROD I", "family": "ROVIRA"}, classification="fix(#397)", ambiguities=("conjunction-or-initial",), notes="the row that predicted its own move, and the move is " "the REPORT and not the reading: 'i' is connective " "vocabulary now and ships in conjunctions_ambiguous " "beside 'e', so a name written wholly in one case reads " "the bare capital as an initial -- which is what it " "already did by shape -- and now says so. Roles " "unchanged from 1.4.0 through 2.3.0; only the " "ambiguity is new. The id moved with the answer: it " "read 'is_not_connective_vocabulary', which this " "commit makes false, and nothing outside this table " "named it", shape=1), Case("catalan_i_in_a_one_case_name_reads_as_an_initial_lower", "josep carod i rovira", {"given": "josep", "middle": "carod i", "family": "rovira"}, classification="fix(#397)", ambiguities=("conjunction-or-initial",), notes="the lowercase twin, and the one that shows the fork is " "about CASE EVIDENCE and not about capitals: a name " "written wholly in lower case has nothing marking the " "letter either way, so the marked set decides and the " "letter reads as an initial. Roles unchanged at every " "release; the report is new. Compare 'Josep Carod i " "Rovira' below, which is written in more than one case " "and joins", shape=1), # ---- #397: the Catalan/Polish link ------------------------------ # The rule: a connective counts as a name word wherever P3's # carve-out counts them, whatever else the vocabulary says the word # is -- and a connective that is ALSO generational vocabulary joins # only where a name word stands on each side of it. 'i' is the one # shipped entry in both classes: the Catalan link and the roman # numeral I. Case("the_catalan_link_joins_two_surnames", "Josep Carod i Rovira", {"given": "Josep", "family": "Carod i Rovira"}, classification="fix(#397)", notes="the statement of the rule and the commonest shape. " "Four words, so the carve-out's count has to reach four " "-- and it does only because the link counts ITSELF, " "'i' being suffix vocabulary that _is_rootname used to " "refuse. 1.4.0 through 2.3.0 read middle 'Carod i', " "family 'Rovira', which is the link left unjoined one " "word short of what a Catalan reader wants", shape=1), Case("the_catalan_link_joins_with_a_middle_name_in_front", "Josep Lluis Carod i Rovira", {"given": "Josep", "middle": "Lluis", "family": "Carod i Rovira"}, classification="fix(#397)", notes="the same join with a real middle name beside it: the " "join takes 'Carod i Rovira' and leaves 'Lluis' where " "it was, rather than swallowing the whole tail. 1.4.0 " "read middle 'Lluis Carod i', family 'Rovira'", shape=1), Case("the_catalan_link_survives_a_family_comma", "Carod i Rovira, Josep", {"given": "Josep", "family": "Carod i Rovira"}, classification="fix(#397)", notes="the sharpest of the before-pictures, and the half the " "vocabulary alone does not fix: after a family comma " "the link was not merely unjoined, it was read as a " "GENERATION and the family lost it -- 1.4.0 through " "2.3.0 gave family 'Carod Rovira' with suffix 'i'. It " "is also the one new name here that lands in a comma " "rule's corpus claim", shape=2), Case("the_link_is_still_a_name_word_in_a_three_word_name", "Josep i Rovira", {"given": "Josep", "middle": "i", "family": "Rovira"}, ambiguities=(), notes="P3's own carve-out, and the BOUNDARY: three words, so " "the single-letter connective stays a name word and " "nothing joins. Roles identical at 1.4.0 and at every " "release since, which is what keeps this row parity -- " "and the two halves of this bundle cancel on it, since " "the readmission (#461) hands the letter back an " "initial that the vocabulary addition would otherwise " "have taken, so 'J. i. R.' holds across the bundle. " "Written in more than one case, so no fork is consulted " "and nothing reports", shape=1), Case("a_link_with_nothing_to_its_right_is_the_generation", "John Quincy Smith i", {"given": "John", "middle": "Quincy", "family": "Smith", "suffix": "i"}, notes="the BOUNDARY the both-sides condition exists for, in " "its simplest shape: there is no piece at all to the " "right of the link. Four name words, so the count no " "longer declines; drop the condition and this reads " "family 'Smith i', losing the generation (measured). A " "connective with nothing to its right is connecting " "nothing. Roles unchanged at every release, 1.4.0 " "included", shape=1), Case("a_link_before_a_generation_is_the_generation_too", "Josep Lluis Carod i III", {"given": "Josep", "middle": "Lluis", "family": "Carod", "suffix": "i III"}, classification="fix(#436/#437)", notes="the other half of the same boundary, and the one the " "first cut got wrong: there IS a piece to the right, " "so a condition asking about POSITION passed it and " "the family read 'Carod i III' -- a generational " "suffix silently swallowed into the surname, on 130 " "names of the review's grid and pinned by nothing. The " "question is the neighbour's CLASS: what the rest of " "the parse reads that word as. The four ROLES are " "1.4.0's and every release's; what the classification " "records is the suffix STRING, which 1.4.0 wrote " "'i, III' because it joined suffix entries with a " "comma and R1 derives them from the writer's commas " "now", shape=1), Case("the_neighbour_test_reads_the_peel_and_not_the_vocabulary", "Josep Lluis Carod i V", {"given": "Josep", "middle": "Lluis", "family": "Carod", "suffix": "i V"}, classification="fix(suffix-routing)", ambiguities=("suffix-or-name",), notes="why the condition asks assign's TRAILING-RUN walk " "rather than the suffix-piece test: a bare 'V' is " "suffix vocabulary written like an initial, so " "is_suffix_piece refuses it (the initial veto, " "_vocab.is_initial) and a class test spelled with that predicate " "alone would let the link swallow it. The peel takes " "it, so trailing_start puts it outside the name's own " "words and the link stays the generation -- carrying " "the fork report the swallowed reading had lost. 'MA' " "is the acronym half of the same shape. Roles " "unchanged from 2.0.0 through 2.3.0; 1.4.0 read middle " "'Lluis Carod', last 'i', suffix 'V', which is the " "two-token divergence 'Carod i' pins above and the " "same fix(suffix-routing) rule describes", shape=1), Case("the_link_joins_nothing_across_a_family_comma_either", "Rovira, Josep Carod i Jr.", {"given": "Josep", "middle": "Carod", "family": "Rovira", "suffix": "i Jr."}, classification="fix(#436/#437)", notes="the comma form of the row above, and it is a " "different code path rather than a second member of " "one: the link and the generation sit in segment 1, " "where the name words are counted without the family " "the comma already fixed. The first cut read middle " "'Carod i Jr.'. Roles are 1.4.0's here too, and the " "classification is the same suffix-string comma: " "1.4.0 wrote 'i, Jr.'", shape=2), Case("a_trailing_link_does_not_count_itself_for_another_join", "Carod y de Rovira i", {"given": "Carod", "middle": "y", "family": "de Rovira", "suffix": "i"}, notes="the COUNT half of the same rule, and the reason it " "cannot be stated for the join alone. The trailing 'i' " "joins nothing, so it is the generation -- and a " "generation is no name word, so it must not raise the " "three-word carve-out's total either. Counting it did: " "the total reached four, and an unrelated 'y' two " "pieces away joined on the strength of it, reading " "given 'Carod y de' with family 'Rovira'. Roles " "unchanged at every release", shape=1), Case("a_particle_on_the_right_is_a_name_side_neighbour", "Josep Carod i de Rovira", {"given": "Josep", "middle": "Carod i de", "family": "Rovira"}, classification="fix(#397)", notes="the boundary on the other side of the class test: a " "PARTICLE beside the link is a name word, not a " "credential, so the link joins and the particle chain " "then takes the surname. 1.4.0 through 2.3.0 read " "middle 'Carod i' with family 'de Rovira' -- the link " "unjoined, which is the whole defect #397 is about. " "Contrast 'Josep Lluis Jr. i Rovira', where the " "neighbour is a credential and the reading stands", shape=1), Case("a_link_ending_a_shorter_name_is_the_generation_too", "Josep Carod i", {"given": "Josep", "family": "Carod", "suffix": "i"}, notes="the same boundary one word shorter, and it is refused " "one gate EARLIER: three name words, so the count " "declines before the both-sides test is reached. The " "pair with the row above is what separates the two " "gates -- measured, delete the both-sides condition and " "this row still passes while its four-word twin does " "not. Roles unchanged at every release", shape=1), Case("the_two_word_link_keeps_the_generation_reading", "Carod i", {"given": "Carod", "suffix": "i"}, classification="fix(suffix-routing)", ambiguities=("given-or-family",), notes="an ACCEPTED COST, pinned so it has a witness in " "this table. Two pieces " "never reach the join loop at all, which needs three, " "so the name reads as a name plus a trailing " "generation and the family is empty. 1.4.0 read last " "'i' -- the same two-token divergence " "fix(suffix-routing) describes for a " "trailing roman numeral, and the same one 'Donald Mc' " "pins above for the acronym half. That reading also " "had a NOT-WANTED rule at 1.4.0 whose standing this " "bundle converts from 'unresolved' to 'decided': the " "cause is settled and the reading is kept", shape=1), Case("the_link_joins_under_a_trailing_generation_marker", "Josep Carod i Rovira III", {"given": "Josep", "family": "Carod i Rovira", "suffix": "III"}, classification="fix(#397)", notes="the CONTROL for the three rows above: a generation " "standing behind a name word leaves a name word on " "each side of the link, so the link joins and the " "generation keeps its own slot. Move it one word left " "-- 'Josep Lluis Carod i III' -- and the link has a " "credential on its right instead, joins nothing, and " "is the generation it also spells. That pair is the " "whole design, and the pair is why the condition asks " "the NEIGHBOUR's class rather than the link's own. " "1.4.0 read middle 'Carod i'", shape=1), Case("a_bare_capital_link_in_a_mixed_case_name_is_an_initial", "Josep Carod I Rovira", {"given": "Josep", "middle": "Carod I", "family": "Rovira"}, ambiguities=(), notes="the recorded negative control for the whole " "vocabulary addition, and the reason the `initial` test " "stays FIRST in the rootname count: a bare Latin " "capital in a name of more than one case is how an " "initial is written, so classify tags it `initial` and " "never `conjunction`, the count is unmoved and nothing " "joins. Identical at 1.4.0 and at every release since", shape=1), Case("a_connective_run_absorbs_a_name_with_no_other_name_word", "Henry i of England", {"given": "Henry i of England"}, classification="fix(#397)", ambiguities=("given-or-family",), notes="the ACCEPTED CONSEQUENCE of reaching the class, " "recorded rather than repaired: 'i' and 'of' are " "adjacent connectives, so they merge into one " "connective RUN and the run -- now counting itself -- " "joins the whole name into a single piece. Before this " "commit the same name read given 'Henry', family 'i of " "England'; 1.4.0 read the same. It is the shape 'Duke " "of Edinburgh' already has, which is why the report is " "the lone-name-word one. UNTAGGED on purpose: a " "second-order consequence is not a shape this table " "admits to the shapes corpus", ), Case("a_trailing_y_is_not_in_the_both_sides_class", "Juan Garcia Lopez y", {"given": "Juan", "middle": "Garcia", "family": "Lopez y"}, notes="the recorded negative control for the CLASS half of " "the both-sides test, and it must NOT move: 'y' is " "connective vocabulary but not generational " "vocabulary, so the condition never asks about it and " "today's reading stands. Drop the is_suffix_piece " "conjunct and this row reads family 'y' with 'Lopez' " "pushed into the middle -- measured, and no property " "invariant in this repo catches it. Identical at 1.4.0", shape=1), Case("a_trailing_y_behind_a_join_is_not_in_the_class_either", "Juan Garcia y Lopez y", {"given": "Juan", "family": "Garcia y Lopez y"}, notes="the same control with the letter appearing twice, one " "joining and one trailing: the interior 'y' joins on " "the position test and the trailing one is never asked " "about, so the whole run stays the family. Drop the " "class conjunct and the family collapses to 'y' with " "middle 'Garcia y Lopez' -- measured. Identical at " "1.4.0", shape=1), Case("a_link_inside_a_maiden_clause_stays_in_the_birth_name", "Jane Doe nee Puig i Soler", {"given": "Jane", "family": "Doe", "maiden": "Puig i Soler"}, classification="fix(#397)", notes="the shape the link fix turned from a truncation into " "a LEAK, and the reason it is fixed here rather than " "left for its own issue: the maiden walk has always " "stopped at the first suffix word after the marker, so " "2.0.0 through 2.3.0 read maiden 'Puig' and left 'i " "Soler' standing as middle 'Doe i', family 'Soler' -- " "a truncation, and the released words at least stayed " "out of the surname. With the link joining, the same " "two words became family 'Doe i Soler': a word of the " "BIRTH name inside the CURRENT one, which is the class " "of failure #424 and #533 exist to prevent. The 'y' " "spelling never had either reading, and the invariant " "in test_properties.py is that twin. 1.4.0 has no " "maiden support at all and read middle 'Doe nee Puig " "i', last 'Soler'", shape=1), Case("the_link_stays_in_the_birth_name_after_a_family_comma_too", "Doe, Jane nee Puig i Soler", {"given": "Jane", "middle": "nee Puig i Soler", "family": "Doe"}, shape=2, classification="fix(#601)", notes="#601: a marker in the given part after a family comma is an " "ordinary word (rules/M2). Before #601: given 'Jane', family " "'Doe', maiden 'Puig i Soler'. As pinned then: the comma form, " "and a different reader rather than a second member of one " "shape: segment 1 is read at the GIVEN slot, so the leak landed " "in given 'Jane i Soler' rather than in the family. 2.0.0 " "through 2.3.0 read maiden 'Puig' with middle 'Soler' and " "suffix 'i'. 1.4.0 read middle 'nee Puig Soler', suffix 'i'"), Case("a_link_with_nothing_on_its_right_still_ends_the_clause", "Jane Doe nee Puig i", {"given": "Jane", "family": "Doe", "suffix": "i", "maiden": "Puig"}, classification="fix(#274)", notes="the CONTROL for the two rows above and the half that " "must not move: a link joining nothing is the " "generation it also spells, so the clause ends at it " "exactly as it did. The pair is what says the " "exception is about PLACEMENT and not about the word. " "Unchanged from 2.0.0 through 2.3.0; 1.4.0 read middle " "'Doe nee', last 'Puig', suffix 'i', which is the " "maiden support #274 added", shape=1), Case("a_link_before_a_generation_ends_the_clause_too", "Jane Doe nee Puig i III", {"given": "Jane", "family": "Doe", "suffix": "i III", "maiden": "Puig"}, classification="fix(#436/#437)", notes="the second control, and the one that pins WHICH bound " "the exception reads: the right-hand name word is " "looked for below the trailing run assign's peel takes, " "so a generation there is outside the clause's own " "words and the link joins nothing. Widen that bound to " "the walk's own stop and this row reads maiden 'Puig i " "III' -- measured. 'i MA' is the acronym half of the " "same control and is not in this table: 'MA' carries " "no suffix tag for the piece test to refuse it by, so " "only the peel bound keeps it out. Unchanged from " "2.0.0 through 2.3.0; 1.4.0 wrote the suffix 'i, III' " "with a comma R1 no longer derives, and read last " "'Puig' for want of maiden support", shape=1), # ---- #397 second review: the one-case role movers -------------- # decisions/P3's 2026-09-20 bullet describes these four names # in prose and pinned none of them. They are where the marked # subset's riskiest movement is -- an ALL-LOWER name whose letter # the parent read as the GENERATION and this reads as an initial # -- and each one now reads as its ALL-CAPS twin already did, # which is the claim the prose makes and # test_properties.py::test_a_one_case_name_reads_the_same_in_ # either_case holds over the grid. Every reading below measured # 2026-09-20 on the released 1.4.0 and 2.3.0 wheels from a # throwaway environment. Case("a_one_case_link_after_a_family_comma_is_the_given_name", "rovira, i", {"given": "i", "family": "rovira"}, classification="fix(#397)", notes="written wholly in lower case, so the letter reads as " "an INITIAL and takes the given slot the comma leaves " "for it. 2.3.0 read family 'rovira' with suffix 'i'; " "1.4.0 read first 'i', which is this reading. The " "ALL-CAPS twin 'ROVIRA, I' gave first 'I' at BOTH of " "them and gives given 'I' here -- the two spellings " "disagreed at 2.3.0 and agree now. NO SHAPE TAG on " "any of these four: decisions/P3's one-case " "paragraph says of them that none is a corpus name, " "and admitting them to a corpus is a change of its " "own rather than this row's business. The cross-version readings " "above are what the corpus would have bought, " "measured on the wheels instead"), Case("a_one_case_link_before_a_generation_is_a_name_word", "john smith i jr", {"given": "john", "middle": "smith", "family": "i", "suffix": "jr"}, classification="fix(#397)", ambiguities=("suffix-or-name",), notes="the same movement with a generation behind it: the " "letter reads as an initial, so the peel stops before " "it and 'jr' alone is the suffix. 2.3.0 read family " "'smith' with suffix 'i jr' and 1.4.0 suffix 'i, jr'. " "The ALL-CAPS twin 'JOHN SMITH I JR' read middle " "'SMITH', last 'I', suffix 'JR' at both of them -- " "again the reading this row now gives. No shape " "tag, for the reason the row above gives. Since #721 " "the letter, with only 'jr' behind it, connects nothing " "(P8, the single-letter connective rule), so the report is the " "numeral's"), Case("a_one_case_link_in_a_comma_suffix_run_is_a_middle_initial", "maier, amy i, jr.", {"given": "amy", "middle": "i", "family": "maier", "suffix": "jr."}, classification="fix(#397)", notes="the third comma shape, and the one whose MIXED-CASE " "twin is already in this table: 'Maier, Amy I, Jr.' " "reads middle 'I' and does not move, the writing " "having decided the letter there. 2.3.0 and 1.4.0 " "both read suffix 'i, jr.' for the lower-case " "spelling while 'MAIER, AMY I, JR.' already read " "middle 'I' -- the disagreement this closes. No " "shape tag, for the reason the first of these rows " "gives"), Case("a_link_inside_a_maiden_clause_keeps_a_bare_credential", "Jane Doe nee Puig i Ma", {"given": "Jane", "family": "Doe", "maiden": "Puig i Ma"}, classification="fix(#397)", ambiguities=("suffix-or-name",), notes="the clause's link with a BARE ambiguous acronym " "behind it, on the shipped vocabulary rather than a " "built one. 'Ma' is a name word here -- mixed case, " "so the class member leans to the name -- and a name " "word on the link's right is what the exception " "wants, so the clause takes all three words and " "reports the acronym it kept. 2.3.0 read middle 'Doe " "i', family 'Ma', maiden 'Puig', handing two words of " "the birth name to the current one; 1.4.0 read middle " "'Doe nee', last 'Puig', suffix 'i, Ma'. The " "ALL-CAPS 'MA' is the other lean and is the control " "in test_properties.py rather than a row here. No " "shape tag, for the reason the first of these rows " "gives"), # ---- #461: a connective initials where it joins nothing -------- # The rule, one sentence for all three groups: a connective # contributes nothing where it is JOINING -- a part holding # another name word for it to join, the part's working particles # set aside -- and initials like any other name word where its # part holds nothing else. Asked of the whole PART, never of a # word count. These rows carry the ROLES, which this table's # expectations are; the initials each one is about are named in # its notes and asserted by rules/R3's example lines and by # tests/v2/test_render.py. Case("a_connective_among_given_names_stops_initialing", "John and Jane Smith", {"given": "John and Jane", "family": "Smith"}, classification="fix(#461)", notes="the name R3's retired paragraph said had four " "candidate answers, and this rule picks one of them by " "stating a rule rather than by choosing a value: the " "given part holds name words for 'and' to join, so it " "joins and contributes nothing. Roles unchanged at " "every release; initials go 'J. a. J. S.' -- 1.4.0's " "run-together 'J a J. S.' -- to 'J. J. S.'", shape=1), Case("a_title_run_read_as_a_name_loses_its_connective_too", "Duke of Edinburgh", {"given": "Duke of Edinburgh"}, classification="fix(#461)", ambiguities=("given-or-family",), notes="the same rule on the shape that makes it visible " "outside couple listings: the whole name joins into " "one given part, 'of' has name words on both sides of " "it, and 'D. o. E.' becomes 'D. E.'. The report is " "unchanged and is the one this row also pins", shape=1), Case("a_connective_holding_a_part_alone_initials", "Juan y Garcia", {"given": "Juan", "middle": "y", "family": "Garcia"}, classification="fix(#461)", notes="the other direction, and the commonest of the three " "shapes: P3's three-word carve-out leaves 'y' a name " "word in the middle part BY ITSELF, so there is " "nothing there for it to join and it initials -- 'J. " "G.' becomes 'J. y. G.'. Roles unchanged since 1.4.0. " "This is the half the design flagged and Derek took", shape=1), Case("a_lone_connective_in_the_family_initials_too", "Juan de y", {"given": "Juan", "family": "de y"}, classification="fix(#461)", notes="the row #461 was filed about, where the two views of " "one parse disagreed about one token: family_base was " "'y' and initials() was 'J.', so the base said the " "word is a name word and the initials said it is not. " "Now 'J. y.', and they agree. The part is not " "all-particle under the default vocabulary -- 'y' is " "no particle there -- so only the new mark readmits it", shape=1), Case("a_part_of_two_words_with_a_name_word_still_joins", "Jon Dough and", {"given": "Jon", "family": "Dough and"}, classification="parity", notes="the recorded negative control for the WHOLE-PART " "question, and the row that says the criterion is not " "a word count wearing a part's clothes: two words, one " "of them the connective, and the other is a name word " "for it to join -- so it joins, contributes nothing, " "and 'J. D.' is unchanged at every release including " "1.4.0. Read the part, never count it", shape=1), Case("a_working_particle_does_not_count_as_something_to_join", "Juan Velasquez y Garcia", {"given": "Juan", "family": "Velasquez y Garcia"}, classification="parity", notes="the control on the other side: the family holds two " "name words beside the connective, so it joins and " "'J. V. G.' is unchanged -- 1.4.0 gave the facade's " "run-together 'J. V G.' for its own reason (grouping, " "not membership). The clause about working particles " "is what this row does NOT exercise and the " "'Juan de y' row above does", shape=1), Case("a_joined_cyrillic_connective_stops_initialing", "Хосе И Мария Сантос", {"given": "Хосе И Мария", "family": "Сантос"}, classification="fix(#461)", notes="the non-Latin half of the losing direction, and it " "moves the OTHER way from the Arabic " "'caseless_connective_never_enters_the_fork' row above " "because the part differs and not the script: here the " "connective has given names on both sides of it, so it " "joins. 'Х. И. М. С.' becomes 'Х. М. С.'. A Cyrillic " "capital is not the shape an initial is written in " "(rules/P8), so the letter is the connective in " "every spelling of this name", shape=1), # ---- #531: the given part's trailing slot ---------------------- # The slot: after a family comma the comma has already named the # family and the first word after it is the given name, so a class # member ENDING the given part has words to spare by construction # and the count says nothing. The writing decides, exactly as it # does for the comma-less spelling of the same name. Case("the_given_parts_trailing_slot_reads_the_credential", "Doe, John MA", {"given": "John", "family": "Doe", "suffix": "MA"}, classification="fix(#531)", notes="1.4.0 RESTORED: v1 read suffix 'MA' and 2.0 through " "2.3 read middle 'MA' in silence. The comma fixed the " "family and the first post-comma word is the given " "name, so the words-to-spare count is satisfied by " "construction and the positional reading at this slot " "IS the credential. Same answer as the comma-less " "'John Doe MA', which is the whole point " "(decisions/S5)", shape=2), Case("the_given_parts_trailing_dotted_slot_reads_the_credential", "Doe, John X.Y.Z.", {"given": "John", "family": "Doe", "suffix": "X.Y.Z."}, classification="fix(#531)", ambiguities=("suffix-or-name",), notes="the BY-SHAPE half of the same slot, and the half that " "does NOT restore 1.4.0: v1 read middle 'X.Y.Z.' here " "and this becomes a suffix. `listed_lean` returns None " "wherever the shape tag rides, so a by-shape member " "never leans and falls to the positional reading, which " "at this slot is the credential -- matching the " "comma-less 'John Doe X.Y.Z.' and the 2.4 shape rule " "rather than v1. One name, one new divergence, accepted " "(decisions/S5)", shape=2), Case("the_given_parts_trailing_slot_in_all_caps", "DOE, JOHN MA", {"given": "JOHN", "family": "DOE", "suffix": "MA"}, classification="fix(#531)", ambiguities=("suffix-or-name",), notes="one case, so the lean is inert and the positional " "reading decides alone -- and at this slot it is the " "credential. 1.4.0 parity", shape=2), Case("the_given_parts_trailing_slot_in_all_lower", "doe, john ma", {"given": "john", "family": "doe", "suffix": "ma"}, classification="fix(#531)", ambiguities=("suffix-or-name",), notes="the other one-case spelling, same reason as its " "all-caps twin: nothing leans, the position decides. " "1.4.0 parity", shape=2), Case("the_trailing_slot_reads_every_listed_member", "Doe, John BA", {"given": "John", "family": "Doe", "suffix": "BA"}, classification="fix(#531)", notes="'ba' is another listed member (one of those " "decisions/suffix-acronym-collisions marked " "ambiguous rather than removing), so the slot is not a " "rule about 'ma' -- it is the whole class", shape=2), Case("the_trailing_slot_leaves_the_middle_initial_alone", "Doe, John Q. MA", {"given": "John", "middle": "Q.", "family": "Doe", "suffix": "MA"}, classification="fix(#531)", notes="the member leaves the given part and 'Q.' stays the " "middle initial it always was -- the slot reaches the " "trailing word, not the run in front of it. 1.4.0 " "parity on both fields", shape=2), Case("the_trailing_slot_joins_the_credential_run_behind_it", "Doe, John MA PhD", {"given": "John", "family": "Doe", "suffix": "MA PhD"}, classification="fix(#531)", notes="'ending the given part' REACHES PAST the credentials " "behind it, so 'MA' joins the run rather than being walled " "off by it. Rendered with a SPACE and not a comma: R1 " "derives suffix entries from the commas the WRITER typed " "(#436/#437), so only 'Doe, John MA, PhD' renders 'MA, " "PhD'. 1.4.0 wrote a comma in both. No report since #785: " "the capitals settle 'MA' (rules/S6), and 'PhD' is settled " "vocabulary carrying neither class tag", shape=2), Case("the_trailing_slot_reaches_past_a_generational_suffix", "Doe, John MA Jr", {"given": "John", "family": "Doe", "suffix": "MA Jr"}, classification="fix(#531)", notes="the run behind the member does not have to be " "credentials -- a generational suffix is a suffix " "piece and the walk reaches past it the same way", shape=2), Case("the_trailing_slot_is_a_run_not_a_position", "Doe, John PhD MA", {"given": "John", "family": "Doe", "suffix": "PhD MA"}, classification="fix(#531)", notes="the member stands LAST here and the settled " "credential in front of it is what used to strand it: " "2.0 through 2.3 read middle 'MA' with suffix 'PhD', " "which is a name word behind a post-nominal. One " "reading now, and it is 1.4.0's", shape=2), Case("the_trailing_slot_survives_a_third_comma_part", "Doe, John MA, PhD", {"given": "John", "family": "Doe", "suffix": "MA, PhD"}, classification="fix(#531)", notes="#144's two-segment restriction is NOT inherited by " "this branch. That restriction exists because a " "trailing 'V' before a third comma part is likely a " "middle initial; 'MA' is not initial-shaped, and a " "credential list behind it makes the credential " "reading MORE likely rather than less. The comma the " "writer typed is what renders here, which is why this " "row shows 'MA, PhD' where its spaced twin shows " "'MA PhD' (#436/#437)"), Case("two_members_in_the_trailing_run_report_twice", "DOE, JOHN MA JD", {"given": "JOHN", "family": "DOE", "suffix": "MA JD"}, classification="fix(#531)", ambiguities=("suffix-or-name", "suffix-or-name"), notes="one decision, one report; two members, two reports -- " "matching 'JOHN DOE MA JD', which reports twice. " "No token is ever reported twice: these are two " "tokens. Written in one case since #785: in a name of " "mixed case the capitals settle both and neither " "reports (rules/S6)", shape=2), # ---- the declined direction: the reading does NOT move and the # report is new. Derek's Q4, answered 'report both directions': # #530's rule is that the report tracks the FORK CONSULTED, not # the lean. Case("the_trailing_slot_declines_a_title_cased_member", "Doe, John Ma", {"given": "John", "middle": "Ma", "family": "Doe"}, classification="fix(#531)", ambiguities=("suffix-or-name",), notes="Title case in a mixed-case name is written the way a " "NAME is written, so the member stays a middle name -- " "and the fork was consulted, so it reports. The first " "of the rows where #531 adds a report without moving a " "field; 'Doe, Mary Jo Ma', 'Doe, John Ed' and 'Doe, " "John MA Ma' below are the others, each declining on " "the same signal. It is the same rule the comma-less " "'John Doe Ma' has followed since #289", shape=2), Case("the_trailing_slot_declines_behind_two_given_words", "Doe, Mary Jo Ma", {"given": "Mary", "middle": "Jo Ma", "family": "Doe"}, classification="fix(#531)", ambiguities=("suffix-or-name",), notes="the lean does not care how many given words stand in " "front -- Title case declines here exactly as it does " "with one. 1.4.0 read middle 'Jo', suffix 'Ma'; the " "case signal is what this release chose over that", shape=2), Case("the_trailing_slot_declines_a_title_cased_name_word", "Doe, John Ed", {"given": "John", "middle": "Ed", "family": "Doe"}, classification="fix(#531)", ambiguities=("suffix-or-name",), notes="'Ed' is a given name far more often than it is a " "doctorate, and Title case is how that is written. The " "report is how a caller finds the other reading", shape=2), Case("the_trailing_slot_declines_then_takes_the_next_member", "Doe, John Ma JD", {"given": "John", "middle": "Ma", "family": "Doe", "suffix": "JD"}, classification="fix(#531)", ambiguities=("suffix-or-name",), notes="two members, two decisions, and they go opposite ways. " "'JD' is all-caps and takes the credential, its capitals " "settling it in silence since #785 (rules/S6); 'Ma' is " "Title-cased and stays a name, which also ENDS the run, so " "nothing in front of it is reached"), Case("a_declined_member_ends_the_trailing_run", "Doe, John ma Ma", {"given": "John", "middle": "ma Ma", "family": "Doe"}, classification="fix(#531)", ambiguities=("suffix-or-name",), notes="the trailing member declines on its Title case, and " "the walk stops at the declined pick rather than " "continuing past it -- so the lower-case 'ma' in front " "is never asked and never reports. ONE report, for " "'Ma'. Identical in shape to rules/S7's accepted " "'Jack Wei Ma' clause. Until #785 the row was written " "'Doe, John MA Ma', whose capitals now start the " "credential's run (the row below). 1.4.0 read suffix " "'ma, Ma'", shape=2), Case("capitals_initials_before_the_family_name_stay_a_name", "Maria Ana MA Silva", {"given": "Maria", "middle": "Ana MA", "family": "Silva"}, notes="S8 (Derek, 2026-10-26, #785): with no family comma a " "name word behind a member its capitals settle keeps it " "from starting the credential's run -- here the writer's own initials in capitals between the given names and the family name. " "1.4.0 through 2.6.0 read the same"), Case("capitals_before_a_capitals_family_name_stay_a_name", "Jean Pierre MA DUPONT", {"given": "Jean", "middle": "Pierre MA", "family": "DUPONT"}, notes="S8 (Derek, 2026-10-26, #785): with no family comma a " "name word behind a member its capitals settle keeps it " "from starting the credential's run -- here a family name written in capitals by convention, which counts as the name word behind. " "1.4.0 through 2.6.0 read the same"), Case("capitals_before_a_title_word_surname_stay_a_name", "Mary Jane MA Pope", {"given": "Mary", "middle": "Jane MA", "family": "Pope"}, notes="S8 (Derek, 2026-10-26, #785): with no family comma a " "name word behind a member its capitals settle keeps it " "from starting the credential's run -- here a surname that is also title vocabulary, which inside the run would read as a title. " "1.4.0 through 2.6.0 read the same"), Case("capitals_before_a_particle_surname_stay_a_name", "Maria Ana MA de la Vega", {"given": "Maria", "middle": "Ana MA", "family": "de la Vega"}, notes="S8 (Derek, 2026-10-26, #785): a particle run the chain " "has merged counts as the name word behind by its first " "word; a first draft asked only one-word pieces and read " "suffix 'MA de la Vega'. 1.4.0 through 2.6.0 read the " "same as this row"), Case("capitals_before_a_joined_surname_stay_a_name", "Maria Ana MA Ortega y Gasset", {"given": "Maria", "middle": "Ana MA", "family": "Ortega y Gasset"}, notes="S8 (Derek, 2026-10-26, #785): a connective join counts " "by its first word, as the particle run above. 2.6.0 " "read the same as this row"), Case("capitals_before_a_split_phd_start_the_run", "John Smith MA Ph. D. Jones", {"given": "John", "family": "Smith", "suffix": "MA Ph. D. Jones"}, classification="fix(#785)", ambiguities=("suffix-or-name",), notes="S8: the Ph./D. pair behind the settled MA is a " "credential the walk leaves out, not the name word that " "would keep MA a name, so MA starts the run as 'John " "Smith M.A. Ph. D. Jones' reads; the first draft of the " "narrowing asked the next word the walk holds, 'Jones'. " "2.6.0 read middle 'Smith', family 'MA', suffix 'Ph. D. " "Jones'"), Case("capitals_credential_before_a_family_name_is_the_accepted_cost", "John Smith MA Jones", {"given": "John", "middle": "Smith MA", "family": "Jones"}, notes="S8 (Derek, 2026-10-26, #785): with no family comma a " "name word behind a member its capitals settle keeps it " "from starting the credential's run -- here the cost Accepted in S8, a credential written between the given names and the family name, where 'John Smith M.A. Jones' reads suffix 'M.A. Jones'. " "1.4.0 through 2.6.0 read the same"), Case("a_shape_admitted_numeral_keeps_its_run_under_the_caps_switch", "John Smith VI Ma", {"given": "John", "family": "Smith", "suffix": "VI Ma"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), classification="fix(#785)", ambiguities=("suffix-or-name", "suffix-or-name"), notes="S8: the caps switch writes the class tags on 'VI', " "which its numeral reading still starts the run with; " "the name word behind a member keeps only a member its " "capitals settle from starting it, and the first draft of " "#785's narrowing asked the tag instead, reading middle " "'Smith VI', family 'Ma' here. 2.6.0 read the same as " "this row"), Case("a_capitals_member_in_the_given_part_starts_the_run", "Doe, John MA Ma", {"given": "John", "family": "Doe", "suffix": "MA Ma"}, classification="fix(#785)", ambiguities=("suffix-or-name",), notes="S8: after a family comma a member its capitals " "settle starts the credential's run behind the given " "word, as 'Doe, John PhD Ma' does, and the run takes the " "Title-case Ma, reported as the member it is. 1.4.0 read " "suffix 'MA, Ma'; 2.0.0 through 2.6.0 middle 'MA Ma'", shape=2), Case("a_taken_member_behind_a_declined_one_still_reports", "Doe, John Ma MA", {"given": "John", "middle": "Ma", "family": "Doe", "suffix": "MA"}, classification="fix(#531)", ambiguities=("suffix-or-name",), notes="the mirror of the row above, and the pair is the " "argument: order in the string decides which member " "the run reaches. Here the trailing 'MA' is taken, the " "run then reaches 'Ma', which declines and ends it"), Case("a_third_member_behind_the_declined_one_stays_silent", "Doe, John ma Ma ma", {"given": "John", "middle": "ma Ma", "family": "Doe", "suffix": "ma"}, classification="fix(#531)", ambiguities=("suffix-or-name", "suffix-or-name"), notes="the run walked to its floor and then measured against " "it: the trailing 'ma' is taken, 'Ma' declines on its " "Title case and STOPS the run there, and the leading " "'ma' -- behind a piece this walk refused -- is an " "ordinary middle name, silent. TWO reports, for 'Ma' " "and the trailing 'ma'; the third member reaches no " "fork. The row that would catch a floor left over from " "an earlier member, since the three members ask the " "same walk three times and only the first two are " "above the floor. Written in lower case, which no " "writing settles, since #785: in capitals the members " "settle and start the credential's run (rules/S6, S8). " "1.4.0 read suffix 'ma, Ma, ma'"), # ---- H5: a trailing title is transparent to this reading ------ Case("a_trailing_title_is_transparent_to_the_slot", "Doe, John MA Prof.", {"title": "Prof.", "given": "John", "family": "Doe", "suffix": "MA"}, classification="fix(#531)", notes="the walk starts at previous_kept(), which is the H5 " "chain's own notion of where the name ends -- so " "'past a trailing title' costs no second definition " "of 'trailing'. 1.4.0 read middle 'Prof.', suffix " "'MA'; the title role is 2.x's own H5 and not at " "stake here"), Case("a_title_in_front_of_the_member_becomes_a_title", "Doe, John Prof. MA", {"title": "Prof.", "given": "John", "family": "Doe", "suffix": "MA"}, classification="fix(#531)", notes="MEASURED, and a SECOND field moves: today this is " "middle 'Prof. MA'. Once 'MA' leaves `walkable` the " "H5 trailing-title chain reaches 'Prof.' and takes " "it. That is H5's stated transparency working, and it " "lands this row on the same answer as its neighbour " "above rather than against it"), Case("a_leading_title_does_not_block_the_slot", "Doe, Dr. John MA", {"title": "Dr.", "given": "John", "family": "Doe", "suffix": "MA"}, classification="fix(#531)", notes="the leading title run is peeled before the walk " "starts, so the slot sees exactly what it sees " "without it. 1.4.0 parity", shape=2), Case("an_initial_given_name_does_not_block_the_slot", "Doe, J. MA", {"given": "J.", "family": "Doe", "suffix": "MA"}, classification="fix(#531)", notes="the first post-comma piece is the given name " "whatever its shape, so a one-letter given leaves the " "member at the trailing slot as any other would. " "1.4.0 parity", shape=2), Case("the_trailing_slot_reads_two_given_words_in_one_case", "DOE, MARY JO MA", {"given": "MARY", "middle": "JO", "family": "DOE", "suffix": "MA"}, classification="fix(#531)", ambiguities=("suffix-or-name",), notes="an ACCEPTED COST stated plainly: one case carries no " "contrast, so the positional reading takes the member " "and a record writing a middle name in capitals loses " "it. 1.4.0 read exactly this, and the comma-less form " "has carried the same cost since 2.0", shape=2), # ---- the negative controls: the reading does NOT move and # NOTHING is reported ----------------------------------------- Case("a_name_word_behind_the_member_ends_the_reach", "DOE, JOHN MA SMITH", {"given": "JOHN", "middle": "MA SMITH", "family": "DOE"}, classification="fix(comma-family)", notes="SILENT, and the silence is the point: " "a name word behind the member ends the trailing run, " "so the member is an ordinary middle name and no fork " "was consulted. AGENTS.md's 'a kind is worth adding " "only if a reader would hesitate too' is why this must " "stay silent rather than why it happens to -- that " "sentence is the 2.0-conventions section's, and this " "note cited rules/A1 for it until 2026-09-19. Written " "in one case since #785: in a name of mixed case the " "capitals settle the member, which then starts the " "credential's run (the row below). NOT 1.4.0 parity: v1 " "read middle 'SMITH', suffix 'MA', and 2.0.0 already " "read what this row reads. What moved was 2.0 reading " "the post-comma part's suffix vocabulary by POSITION, so " "the divergence files under the family-comma group " "rather than under the case signal", shape=2), Case("a_capitals_member_runs_over_the_name_word_behind_it", "Doe, John MA Smith", {"given": "John", "family": "Doe", "suffix": "MA Smith"}, classification="fix(#785)", ambiguities=("suffix-or-name",), notes="S8: the capitals settle MA (S6), and a settled " "member starts the credential's run as 'Doe, John PhD " "Smith' does, so the name word behind it is the run's, " "reported. 1.4.0 read middle 'Smith', suffix 'MA'; " "2.0.0 through 2.6.0 middle 'MA Smith'", shape=2), Case("a_multi_token_piece_never_reaches_the_slot", "Doe, John MA y", {"given": "John", "middle": "MA y", "family": "Doe"}, classification="fix(comma-family)", notes="the slot reads a PIECE, not a token: 'y' is a " "conjunction, so grouping joins 'MA y' into one piece " "and both len() tests -- the branch's and the report " "gate's -- decline it. The row that PINS them, and the " "one the particle rows above cannot be: delete the " "branch's test and the piece reads as a credential, " "suffix 'MA y'; delete the report gate's and 'MA' is " "reported where nothing was decided (both measured " "2026-09-19). 'Doe, John A.B. e' is the by-shape " "spelling of the same piece and moves with it, so it " "gets no row of its own. UNCHANGED by #531 -- 2.0.0 " "and 2.3.0 read this middle too, where 1.4.0 read " "middle 'y', suffix 'MA' -- and the divergence is " "'Doe, John MA Smith's, the post-comma part read by " "POSITION since 2.0", shape=2), Case("a_maiden_clause_takes_the_member_with_it", "Doe, Jane nee Smith MA", {"given": "Jane", "middle": "nee Smith", "family": "Doe", "suffix": "MA"}, shape=2, classification="fix(#601)", notes="#601: a marker in the given part after a family comma is an " "ordinary word (rules/M2). Before #601: given 'Jane', family " "'Doe', suffix 'MA', maiden 'Smith'. As pinned then: the row " "that named the silence, now naming the reading. The maiden " "marker no longer claims a trailing credential: the words it " "takes end where a trailing credential begins, and after a " "family comma the reader of what is left standing is the given " "part's own trailing slot (#531), which reads 'MA' as the " "credential. 1.4.0 had no maiden routing and read middle 'nee " "Smith', suffix 'MA', so the SUFFIX is 1.4.0 parity and the " "maiden field is not; 2.0.0 through 2.3.0 read maiden 'Smith " "MA' in silence (measured 2026-09-19). Keeping the id: it is " "the same question, answered the other way"), # ---- #533: the maiden clause's trailing credential ------------- # The rule: the words a maiden marker takes end where a trailing # credential begins, and a member of the ambiguous credential # class is one of those -- but only where the rule that reads the # name left standing reads it as the credential, both as written # and as the take would leave it. That is the same double question # the trailing roman numeral is asked, for the same reason: the # count of words to spare includes the very words the marker # removes. Case("a_trailing_credential_ends_the_maiden_clause", "Jane Doe nee Smith MA", {"given": "Jane", "family": "Doe", "suffix": "MA", "maiden": "Smith"}, classification="fix(#533)", notes="the comma-less spelling of the row above, and the " "statement of the rule. The peel over the pieces as " "they stand takes 'MA', and the peel over the view the " "take would leave ('Jane Doe MA') takes it too, so the " "clause stops before it. 1.4.0 read middle 'Doe nee', " "family 'Smith', suffix 'MA' -- the suffix restored, " "the maiden field new since #274", shape=1), Case("the_clause_keeps_the_member_its_writing_declines", "Jane Doe nee Smith Ma", {"given": "Jane", "family": "Doe", "maiden": "Smith Ma"}, classification="fix(#533)", ambiguities=("suffix-or-name",), notes="the other direction, and the one that carries most of " "this change's visible effect at the default: Title " "case in a mixed-case name is written the way a NAME " "is written, so the peel declines the member and the " "clause keeps it -- and the fork was consulted, so it " "reports. The reading is unchanged from 2.0.0 through " "2.3.0; only the report is new. 1.4.0 read suffix 'Ma'", shape=1), Case("the_all_caps_clause_reads_the_credential", "JANE DOE NEE SMITH MA", {"given": "JANE", "family": "DOE", "suffix": "MA", "maiden": "SMITH"}, classification="fix(#533)", ambiguities=("suffix-or-name",), notes="one case, so the lean is inert and the count decides " "alone -- and with 'Jane Doe' standing in front of the " "clause there are words to spare both as written and " "as the take would leave the name. 1.4.0 parity on the " "suffix", shape=1), Case("the_all_lower_clause_reads_the_credential", "jane doe nee smith ma", {"given": "jane", "family": "doe", "suffix": "ma", "maiden": "smith"}, classification="fix(#533)", ambiguities=("suffix-or-name",), notes="the other one-case spelling, same reason as its " "all-caps twin. 1.4.0 parity on the suffix", shape=1), Case("the_clause_keeps_a_member_standing_alone_after_the_marker", "Jane Doe nee MA", {"given": "Jane", "family": "Doe", "maiden": "MA"}, classification="fix(#533)", ambiguities=("suffix-or-name",), notes="the FLOOR, and it is deliberate that this differs " "from what a suffix word or a roman numeral gets in " "the same position ('Jane Smith nee PhD' and 'Jane " "Smith nee V' leave the marker standing as an ordinary " "word). The marker announces a NAME, and the rule " "gives a word up only where a maiden name is left " "standing; certain suffix vocabulary declines the " "marker, an ambiguous word is kept by the clause it " "ends. It reports all the same. 1.4.0 read family " "'nee', suffix 'MA', so this half restores nothing and " "does not try to", shape=1), Case("a_name_word_behind_the_member_leaves_the_clause_silent", "Jane Doe nee MA Smith", {"given": "Jane", "family": "Doe", "maiden": "MA Smith"}, classification="fix(#274)", ambiguities=(), notes="the recorded negative control for the emitter: the " "word the walk was asked about is the LAST piece of " "the maiden name, and a member with a name word behind " "it is never that piece. Nothing was consulted, so " "nothing reports -- which is what rules/A1's " "hesitating reader asks for rather than an accident of " "where the emitter sits. Unchanged from 2.0.0; 1.4.0 " "read middle 'Doe nee MA', family 'Smith'", shape=1), Case("the_clause_gives_up_the_whole_credential_run", "Jane Doe nee Smith MA PhD", {"given": "Jane", "family": "Doe", "suffix": "MA PhD", "maiden": "Smith"}, classification="fix(#533)", notes="the peel walks the run from the end, so 'PhD' is settled " "vocabulary and 'MA' is the member it reaches behind it, " "settled by its capitals and silent since #785 (rules/S6). " "Rendered with a SPACE: R1 derives suffix entries from the " "commas the WRITER typed (#436/#437), where 1.4.0 wrote " "'MA, PhD'. 2.0.0 through 2.3.0 read maiden 'Smith MA' with " "suffix 'PhD', which is the order-sensitivity the issue " "reported", shape=1), Case("the_credential_run_reads_the_same_written_the_other_way", "Jane Doe nee Smith PhD MA", {"given": "Jane", "family": "Doe", "suffix": "PhD MA", "maiden": "Smith"}, classification="fix(#289)", notes="the control the row above needs, and the whole point " "of the issue: this spelling already stopped at the " "suffix WORD and read both credentials, so whether the " "member was read at all used to depend on which side " "of 'PhD' the writer put it. Unchanged here -- the two " "orders now agree", shape=1), Case("the_declined_member_still_ends_the_clause_for_the_run", "Jane Doe nee Smith Ma JD", {"given": "Jane", "family": "Doe", "suffix": "JD", "maiden": "Smith Ma"}, classification="fix(#533)", ambiguities=("suffix-or-name",), notes="both halves in one name: 'JD' is taken by assign where it " "peels it, settled by its capitals and silent since #785 " "(rules/S6), and 'Ma' is declined by its writing and " "reported by the walk that kept it. ONE report, and never " "two for one word -- the maiden pieces are gone before the " "chain runs. 1.4.0 read suffix 'Ma, JD'", shape=1), Case("two_members_ending_the_clause_report_once_each", "Jane Doe nee Smith ma jd", {"given": "Jane", "family": "Doe", "suffix": "ma jd", "maiden": "Smith"}, classification="fix(#533)", ambiguities=("suffix-or-name", "suffix-or-name"), notes="the same pair with both members taken. The peel " "resolves each in turn and both are reported; the " "walk reports none, the last maiden piece being an " "ordinary name word. Written in lower case since #785: " "in capitals the members are settled and report nothing " "(rules/S6)", shape=1), Case("the_dotted_member_ends_the_clause", "John Smith nee Jones R.A.I.", {"given": "John", "family": "Smith", "suffix": "R.A.I.", "maiden": "Jones"}, classification="fix(#533)", ambiguities=("suffix-or-name",), notes="the name #530's close-out reported from the other " "side, and this row RESTORES 2.3.0 rather than " "changing it: at 2.3.0 'R.A.I.' carried vocab:suffix " "and the walk's suffix-piece test stopped at it, while " "#516 retagged it shape:acronym plus " "vocab:suffix-ambiguous, it stopped being a suffix " "piece, and the walk took it -- maiden 'Jones R.A.I.' " "on this tree, unrecorded because the name was in no " "corpus file. 1.4.0 read middle 'Smith nee', family " "'Jones', suffix 'R.A.I.' (all measured 2026-09-19)", shape=1), Case("the_dotted_member_is_kept_with_the_switch_off_and_reports", "John Smith nee Jones R.A.I.", {"given": "John", "family": "Smith", "maiden": "Jones R.A.I."}, policy=Policy(unlisted_dotted_suffixes=False), classification="fix(#533)", ambiguities=("suffix-or-name",), notes="with the switch off classify writes the SHAPE tag and " "the class does not admit the token, so the PEEL " "declines to consume it -- it records the word in " "`picks` and breaks, leaving nothing for the walk's " "own reading gate to be asked about -- and the clause " "keeps the word. The emitter's gate reads EITHER tag, " "as the chain emitter's does, so the declined fork is " "still reported. That asymmetry is the whole of the " "two gates' difference and is what this row pins. " "Measured 2026-09-19 by stepping the peel, not " "reasoned from the gate's text"), Case("the_caps_switch_reaches_the_clause", "Jane Doe nee Smith XYZ", {"given": "Jane", "family": "Doe", "suffix": "XYZ", "maiden": "Smith"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), classification="fix(#533)", ambiguities=("suffix-or-name",), notes="the opt-in class reaches this slot like any other, " "the switch being what admits the token to the " "ambiguous class in the first place"), Case("an_unlisted_word_is_no_member_of_the_class", "Jane Doe nee Smith XYZ", {"given": "Jane", "family": "Doe", "maiden": "Smith XYZ"}, classification="fix(#274)", ambiguities=(), notes="the default-policy control for the row above, and the " "recorded negative control for the membership gate: " "with the caps switch off 'XYZ' carries neither tag, " "so the walk never asks and the clause keeps it in " "silence. 1.4.0 read family 'XYZ'", shape=1), Case("the_peel_never_reaches_a_title_behind_the_member", "Jane Doe nee Smith MA Prof.", {"title": "Prof.", "given": "Jane", "family": "Doe", "suffix": "MA", "maiden": "Smith"}, classification="fix(#535)", notes="the H5 boundary #533 recorded, now closed: the walk reads " "the end of the name through the trailing-title chain as " "assign does (tail_reading), so 'Prof.' is a stop and no " "longer hides the member in front of it, which the " "credential stop then gives up as it does in 'Jane Doe nee " "Smith MA'. Silent since #785, the capitals settling 'MA' " "(rules/S6). 2.3.0 read maiden 'Smith MA Prof.'", shape=1), Case("a_title_in_front_of_the_member_is_the_other_spelling", "Jane Doe nee Smith Prof. MA", {"title": "Prof.", "given": "Jane", "family": "Doe", "suffix": "MA", "maiden": "Smith"}, classification="fix(#535)", notes="the pair is the finding, and it now AGREES: with the " "title read as the chain reads it, 'Prof. MA' and " "'MA Prof.' give one answer, as they do at the given " "part's trailing slot (#531's 'Doe, John MA Prof.' / " "'Doe, John Prof. MA') and in 'Jane Doe MA Prof.'. " "2.3.0 read maiden 'Smith Prof. MA', no suffix", shape=1), Case("a_trailing_title_ends_the_maiden_clause", "Jane Doe nee Smith Prof.", {"title": "Prof.", "given": "Jane", "family": "Doe", "maiden": "Smith"}, classification="fix(#535)", notes="rules/M2's title stop: a period-marked word the " "trailing title chain takes (H5) ends the clause, as " "'Jane Doe Prof.' reads the title. 2.3.0 read maiden " "'Smith Prof.'", shape=1), Case("a_title_behind_the_numeral_hides_it_no_longer", "Jane Doe nee Smith V Prof.", {"title": "Prof.", "given": "Jane", "family": "Doe", "suffix": "V", "maiden": "Smith"}, classification="fix(#535)", ambiguities=("suffix-or-name",), notes="#424's numeral stop through the title: reads as " "'Jane Doe nee Smith Prof. V' and as 'Jane Doe nee " "Smith V' plus the title. 2.3.0 read maiden 'Smith V " "Prof.'", shape=1), Case("a_declined_member_before_a_title_stays_and_reports", "Jane Doe nee Smith Ma Prof.", {"title": "Prof.", "given": "Jane", "family": "Doe", "maiden": "Smith Ma"}, classification="fix(#535)", ambiguities=("suffix-or-name",), notes="the writing declines 'Ma' as it does in 'Jane Doe nee " "Smith Ma', and the clause now ENDS on it, so M2's " "report of the member it keeps fires. 2.3.0 read maiden " "'Smith Ma Prof.' and reported nothing", shape=1), Case("a_trailing_title_after_a_family_comma_clause", "Doe, Jane nee Smith MA Prof.", {"title": "Prof.", "given": "Jane", "middle": "nee Smith", "family": "Doe", "suffix": "MA"}, shape=2, classification="fix(#601)", notes="#601: a marker in the given part after a family comma is an " "ordinary word (rules/M2). Before #601: title 'Prof.', given " "'Jane', family 'Doe', suffix 'MA', maiden 'Smith'. As pinned " "then: the given part's own slot reads the chain too ('Doe, " "Jane MA Prof.' gives title and suffix). 2.3.0 read maiden " "'Smith MA Prof.'"), Case("a_trailing_title_after_a_family_comma_stops_the_chain", "Smith, John van der Berg Prof.", {"title": "Prof.", "given": "John", "middle": "van der Berg", "family": "Smith"}, classification="fix(#623)", notes="the given part's particle chain stops in front of the " "trailing run its walk reads, titles included (rules/H5, " "#623), so the title is the title as in 'Smith, John " "Prof.' and 'John van der Berg Prof.'; 1.4.0 and 2.3.0 " "read middle 'van der Berg Prof.'"), Case("a_particle_before_a_trailing_title_after_a_comma_attaches", "Smith, Jan van Prof.", {"title": "Prof.", "given": "Jan", "family": "van Smith"}, ambiguities=("particle-or-given",), classification="fix(#623)", notes="P6 reads the given part as though the trailing title " "were absent, so 'van' attaches to the family as in " "'Smith, Jan van' (rules/H5, #P6; #623); 1.4.0 and " "2.3.0 read middle 'van Prof.'"), Case("a_title_particle_heading_the_given_part_keeps_the_bound", "Smith, Freiherr von Berg Prof.", {"title": "Freiherr Prof.", "given": "von Berg", "family": "Smith"}, classification="fix(#623)", notes="a word in both the title and particle vocabularies " "heads the given part and leads the chain (#624), so the " "particle behind it is the one the bound is asked for " "(#623's review: a scan from the name's start instead of " "the lead skipped it and left the chain unbounded); 1.4.0 " "and 2.3.0 read given 'von Berg Prof.'"), Case("a_joined_particle_phrase_asks_the_bound", "Smith, John von und zu Berg Prof.", {"title": "Prof.", "given": "John", "middle": "von und zu Berg", "family": "Smith"}, classification="fix(#623)", notes="P3 joins 'von und zu' into one piece tagged a particle, " "so it asks the bound as a lone particle does (#623's " "review: a test of single-token particles alone left the " "chain unbounded here); 1.4.0 and 2.3.0 read middle 'von " "und zu Berg Prof.'"), Case("a_trailing_title_after_an_empty_head_stops_the_chain", ", Jan van Prof.", {"title": "Prof.", "given": "Jan", "family": "van"}, ambiguities=("particle-or-given",), classification="fix(#623)", notes="the empty head (nameparser/AGENTS.md's comma sweep): the bound " "holds there too, and P6's attachment takes 'van' as the " "family, as behind any family comma (rules/C15); " "1.4.0 and 2.3.0 read middle 'van Prof.'"), Case("the_chain_runs_again_where_its_merges_free_the_acronym", "Smith, Freiherr van Berg X.Y.Z.", {"title": "Freiherr", "given": "van Berg X.Y.Z.", "family": "Smith"}, classification="parity", notes="group's re-ask after the chain (rules/P2): behind a " "word in both the title and particle vocabularies the " "chain's merges leave the dotted acronym no word to " "spare, so the chain runs again without its stop and " "takes it (without the re-ask: given 'van Berg', suffix " "'X.Y.Z.'); #623's fix review found it live and held by " "no test after #614 moved its documented case, " "'Freiherr von Berg Ma', to the read-once path"), Case("a_bare_title_word_after_a_comma_stays_in_the_chain", "Smith, John van der Berg Prof", {"given": "John", "middle": "van der Berg Prof", "family": "Smith"}, classification="parity", notes="the contrast to the row above: a bare title word ending " "a name is a name word (rules/H5), so the chain takes " "it (#623 moves only the period-marked spelling)"), Case("the_first_word_after_the_marker_stays_even_as_a_title", "Jane Doe nee Pope.", {"given": "Jane", "family": "Doe", "maiden": "Pope."}, classification="fix(#274)", notes="rules/M2's first-word floor covers the title stop: " "the marker announced a name, and 'Pope.' is a borne " "surname the title vocabulary also lists. Unchanged " "from 2.3.0"), Case("the_first_word_floor_holds_for_titles_too", "Jane Doe nee Prof. Dr.", {"title": "Dr.", "given": "Jane", "family": "Doe", "maiden": "Prof."}, classification="fix(#535)", notes="the floor is the title chain's own: the chain may not " "take the first word after the marker, so it takes 'Dr.' " "and stops at 'Prof.', which stays the maiden name, as " "'Jane Doe nee MA ba' keeps 'MA' and gives up 'ba'. 2.3.0 " "read maiden 'Prof. Dr.'"), Case("a_title_stop_that_leaves_no_name_word_is_no_stop", "Dr. nee Jones Smith Prof.", {"title": "Dr. Prof.", "given": "nee", "middle": "Jones", "family": "Smith"}, classification="fix(#601)", notes="#601: a marker behind a title or a suffix word is an ordinary " "word (rules/M2). Before #601: title 'Dr.', maiden 'Jones " "Smith Prof.'. As pinned then: the view check: the take would " "leave 'Dr. Prof.', where H5's chain has no name word to stand " "behind and 'Prof.' would be the family name, so the clause " "keeps it. Unchanged from 2.3.0"), Case("no_title_stop_before_a_family_comma", "Doe nee Smith Prof., Jane", {"given": "Jane", "family": "Doe", "maiden": "Smith Prof."}, notes="before a family comma no trailing rule reads these " "words ('Doe Prof., Jane' keeps family 'Doe Prof.'), so " "there is no chain to consult and the clause keeps the " "title. Unchanged from 2.3.0"), Case("a_particle_ahead_would_chain_the_released_title", "Jane van der Berg nee Smith Prof.", {"title": "Prof.", "given": "Jane", "family": "van der Berg", "maiden": "Smith"}, classification="fix(#601)", notes="#601: the clause ends at the clause-free name's trailing run, " "which the take consumes (rules/M2). Before #601: given " "'Jane', family 'van der Berg', maiden 'Smith Prof.'. As pinned " "then: P2's chain runs on over a trailing title (rules/H5's " "Accepted 'John van der Berg Prof.'), so releasing the title " "would put it in the family; the clause keeps it. Unchanged " "from 2.3.0"), Case("a_numeral_the_bound_join_would_take_stays_maiden", "Berg, abdul nee Smith V", {"given": "abdul", "middle": "nee Smith", "family": "Berg", "suffix": "V"}, classification="fix(#601)", notes="#601: a marker in the given part after a family comma is an " "ordinary word (rules/M2). Before #601: given 'abdul', " "family 'Berg', maiden 'Smith V'. As pinned then: the numeral " "stop now asks M2's join question too: released, the V was " "taken by the bound-given join after the comma (P5) and read " "given 'abdul V' at 2.3.0 -- a word of the birth name in the " "current one"), Case("a_period_final_bracket_reads_as_the_bare_clause", "Jane Doe (nee Smith Prof.)", {"title": "Prof.", "given": "Jane", "family": "Doe", "maiden": "Smith"}, classification="fix(#535)", notes="rules/M2 Accepted: bracket content ending in a " "period is not extracted, so the brackets drop and the " "clause is read bare -- and the bare clause now gives " "the title up. 2.3.0 read maiden 'Smith Prof.' for the " "same reason the bare form did"), Case("the_floor_keeps_the_first_word_out_of_the_chains_count", "Jane Doe nee Pope. ba", {"given": "Jane", "family": "Doe", "suffix": "ba", "maiden": "Pope."}, classification="fix(#533)", ambiguities=("suffix-or-name",), notes="2.3.0 read maiden 'Pope. ba' as it read 'Smith ba' -- " "no credential stop before #533; the floor keeps the " "first word in the chain-and-peel count, so the " "reading #533 gave stands"), Case("a_given_slot_numeral_with_a_credential_tail_stays", "Doe, Jane nee Smith V, PhD", {"given": "Jane", "middle": "nee Smith V", "family": "Doe", "suffix": "PhD"}, classification="fix(#601)", notes="#601: a marker in the given part after a family comma is an " "ordinary word (rules/M2). Before #601: given 'Jane', family " "'Doe', suffix 'PhD', maiden 'Smith V'. As pinned then: " "rules/M2's given-slot reader declines the numeral where a " "third comma part follows (#144's own condition, asked of the " "maiden clause too): the given part is not the LAST comma part, " "so the release is withdrawn and the clause keeps 'V'. 2.3.0 " "read middle 'V', maiden 'Smith', suffix 'PhD' -- an M2 " "violation predating #535"), Case("a_lone_numeral_before_a_credential_tail_stays_maiden", "Doe, Jane nee V, PhD", {"given": "Jane", "middle": "nee V", "family": "Doe", "suffix": "PhD"}, classification="fix(#601)", notes="#601: a marker in the given part after a family comma is an " "ordinary word (rules/M2). Before #601: given 'Jane', family " "'Doe', suffix 'PhD', maiden 'V'. As pinned then: the numeral " "stop reads FROM the marker and is not held to the first-word " "floor; here the given slot does not read a lone numeral as a " "suffix with a third comma part behind it (#144's condition, " "asked of the clause since #535), so the clause keeps 'V', as " "2.0.0 and 2.1.0 read it. 2.2.0, 2.3.0 and the #538 commit " "d9d80492 read middle 'nee V', the marker a name word; 'Doe, " "Jane nee V, Jr.' moves the same way"), Case("the_given_title_chain_stops_at_a_member_with_a_title_behind", "Doe, Jane nee Smith Rev. MA Prof.", {"title": "Prof.", "given": "Jane", "middle": "nee Smith Rev.", "family": "Doe", "suffix": "MA"}, classification="fix(#601)", notes="#601: a marker in the given part after a family comma is an " "ordinary word (rules/M2). Before #601: title 'Prof.', given " "'Jane', family 'Doe', suffix 'MA', maiden 'Smith Rev.'. As " "pinned then: after a family comma the given part's title chain " "runs from the end over what its first suffix pass leaves, and " "that pass leaves 'MA' a name word while a title stands behind " "it -- 'Doe, Jane Dr. MA Prof.' reads middle 'Dr.' -- so the " "chain stops at 'MA' and 'Rev.' stays in the clause; only 'MA' " "and 'Prof.' leave. 2.3.0 and the #538 commit d9d80492 read " "maiden 'Smith Rev. MA Prof.'"), Case("a_lenient_numeral_leaves_with_the_title_in_front", "Doe, Jane nee Smith Prof. V", {"title": "Prof.", "given": "Jane", "middle": "nee Smith", "family": "Doe", "suffix": "V"}, classification="fix(#601)", notes="#601: a marker in the given part after a family comma is an " "ordinary word (rules/M2). Before #601: title 'Prof.', given " "'Jane', family 'Doe', suffix 'V', maiden 'Smith'. As pinned " "then: the lenient trailing numeral (#144) is a suffix of the " "given part's first pass once the numeral stop has asked it, so " "the title in front of it leaves the clause too, as 'Doe, Jane " "Prof. V' reads title 'Prof.', suffix 'V'. 2.3.0 read maiden " "'Smith Prof.', suffix 'V'"), Case("a_title_behind_that_numeral_still_stays", "Doe, Jane nee Smith V Prof., PhD", {"title": "Prof.", "given": "Jane", "middle": "nee Smith V", "family": "Doe", "suffix": "PhD"}, classification="fix(#601)", notes="#601: a marker in the given part after a family comma is an " "ordinary word (rules/M2). Before #601: title 'Prof.', given " "'Jane', family 'Doe', suffix 'PhD', maiden 'Smith V'. As " "pinned then: the same third-comma-part decline reaches through " "the title chain: the numeral stays maiden text and the title " "behind it still leaves the clause. 2.3.0 read maiden 'Smith V " "Prof.', suffix 'PhD', with no title at all"), Case("the_bound_given_join_is_the_given_slots_alone", "abdul Doe nee Smith V", {"given": "abdul", "family": "Doe", "suffix": "V", "maiden": "Smith"}, ambiguities=("suffix-or-name",), classification="fix(#274)", notes="P5's bound-given join is LENIENT only after a family " "comma; before one the STRICT reserve declines a join " "that would change a suffix reading, so the release " "here is not withdrawn and the clause gives 'V' up. " "(The contrast this note drew, 'Berg, abdul nee Smith " "V', has made no clause since #601: a marker after a " "family comma is an ordinary word.) Unchanged from " "2.3.0. 'Doe' since #718, which made the 'V' of " "'abdul nee Smith V' a name word (the next row); 1.4.0 " "read first 'abdul Doe', middle 'nee', having no " "maiden markers"), Case("a_numeral_with_no_word_to_spare_stays_in_the_clause", "abdul nee Smith V", {"given": "abdul", "maiden": "Smith V"}, ambiguities=("suffix-or-name",), classification="fix(#718)", notes="the row above without its family word: read without " "the clause the name is 'abdul V', and a one-letter " "numeral needs words to spare (S10, the trailing " "roman numeral), so 'V' is no suffix and the clause " "keeps it as it keeps any name word, by M2 (the " "maiden-marker rule), reporting the numeral it kept"), Case("a_title_released_from_the_bound_given_pair", "abdul nee Smith Dr.", {"title": "Dr.", "family": "abdul", "maiden": "Smith"}, classification="fix(#535)", notes="the same STRICT-reserve reading for a title behind the " "bound word: 'Dr.' leaves the clause and 'abdul' reads " "as the family the title stands in front of, as " "'abdul Dr.' reads it bare. 2.3.0 read given 'abdul', " "maiden 'Smith Dr.'"), Case("a_numeral_straight_after_the_marker_declines_through_a_title", "Jane Doe nee V Prof.", {"title": "Prof.", "given": "Jane", "family": "Doe", "maiden": "V"}, classification="fix(#601)", notes="#601: the first word after the marker is the maiden name (open " "on #601) (rules/M2). Before #601: title 'Prof.', given " "'Jane', middle 'Doe', family 'nee', suffix 'V'. As pinned " "then: the numeral stop reads FROM the marker by design, unlike " "the credential and title stops, so a numeral standing straight " "after it is not held to the first-word floor, and here it " "declines the clause -- as 'Jane Smith née V' does bare " "(rules/M2) -- and the title behind the numeral then reads " "the declined name as 'Jane Doe nee V' plus the title would. " "2.3.0 read maiden 'V Prof.'"), Case("a_title_behind_a_tail_bound_numeral_keeps_both", "Doe, Jane nee Smith Prof. V, PhD", {"given": "Jane", "middle": "nee Smith Prof. V", "family": "Doe", "suffix": "PhD"}, classification="fix(#601)", notes="#601: a marker in the given part after a family comma is an " "ordinary word (rules/M2). Before #601: given 'Jane', family " "'Doe', suffix 'PhD', maiden 'Smith Prof. V'. As pinned then: " "NOT the transparent twin of 'Doe, Jane nee Smith V Prof., PhD' " "(title 'Prof.', maiden 'Smith V'): the third comma part's " "credential tail withdraws the numeral's release, so 'V' stays " "a name word, and the title stop is then asked and declines -- " "the given part's chain runs from the end and stops at 'V', so " "it never reaches 'Prof.', and the title stays maiden text in " "front of the numeral. The same asymmetry is the bare given " "slot's own, with no marker in it -- 'Doe, Jane Prof. V, PhD' " "reads middle 'Prof. V' (no title) where 'Doe, Jane V Prof., " "PhD' reads title 'Prof.', middle 'V'. 2.3.0 read middle 'V', " "maiden 'Smith Prof.'"), Case("a_link_the_walk_stops_at_gives_up_only_what_reads_off", "Jane Doe nee Smith i DO Prof.", {"title": "Prof.", "given": "Jane", "family": "Doe", "suffix": "i DO", "maiden": "Smith"}, classification="fix(#601)", notes="#601: the clause ends at the clause-free name's trailing run, " "which the take consumes (rules/M2). Before #601: title " "'Prof.', given 'Jane', family 'Doe', maiden 'Smith i DO'. As " "pinned then: with the title chained, the DO is the trailing " "peel's, so the link exception refuses 'i' and the walk would " "stop there -- and a stop at a link gives up the words behind " "it. The name left standing ('Jane Doe i DO Prof.') does not " "read 'i DO' as post-nominals, so the clause keeps the link and " "the DO (reporting the kept credential), and only the title " "leaves. 2.3.0 read middle 'Doe i', family 'DO Prof.', maiden " "'Smith' ('i' was a plain suffix word there); the #538 commit " "d9d80492 read maiden 'Smith i DO Prof.'"), Case("a_link_whose_run_would_land_in_a_name_part_stays", "Berg, abdul nee Smith i V Prof., MD", {"title": "Prof.", "given": "abdul", "middle": "nee Smith V", "family": "Berg", "suffix": "i, MD"}, classification="fix(#601)", notes="#601: a marker in the given part after a family comma is an " "ordinary word (rules/M2). Before #601: given 'abdul', " "family 'Berg', suffix 'MD', maiden 'Smith i V Prof.'. As " "pinned then: the given-slot twin of the row above: giving up " "'i V Prof.' would leave the V a middle name behind the bound " "pair, so the clause keeps the whole run. 2.3.0 read middle " "'V', title 'Prof.', suffix 'i, MD', maiden 'Smith'; the #538 " "commit d9d80492 read as this does"), Case("a_released_particle_title_is_kept_after_a_family_comma", "Doe, Jane nee Smith St.", {"given": "Jane", "middle": "nee Smith", "family": "St. Doe"}, classification="fix(#601)", ambiguities=("particle-or-given",), notes="#601: a marker in the given part after a family comma is an " "ordinary word (rules/M2). Before #601: given 'Jane', family " "'Doe', maiden 'Smith St.'. As pinned then: 'St.' is a title " "and a particle. Released after a family comma, P6 would attach " "it to the family ('Doe, Jane St.' reads family 'St. Doe'), a " "word of the birth name carried into the current one, so the " "clause keeps it. Unchanged from 2.3.0"), Case("a_released_particle_title_behind_a_credential_is_kept", "Doe, Jane nee Smith MA St.", {"given": "Jane", "middle": "nee Smith", "family": "St. Doe", "suffix": "MA"}, classification="fix(#601)", ambiguities=("particle-or-given",), notes="#601: a marker in the given part after a family comma is an " "ordinary word (rules/M2). Before #601: given 'Jane', family " "'Doe', maiden 'Smith MA St.'. As pinned then: the same guard " "with a credential in front of the title: releasing 'MA St.' " "would hand 'St.' to the family, so the clause keeps both. " "Unchanged from 2.3.0"), Case("a_split_credential_behind_the_member_counts_as_released", "Jane Doe nee Smith MA Ph. D.", {"given": "Jane", "family": "Doe", "suffix": "MA Ph. D.", "maiden": "Smith"}, classification="fix(#533)", notes="the TRAILING reader's release check counts a piece " "group already flagged a credential ('Ph. D.', merged " "from two tokens) as read off, so 'MA Ph. D.' leaves " "whole. 2.3.0 read maiden 'Smith MA', suffix 'Ph. D.'"), Case("a_split_credential_behind_the_member_after_a_family_comma", "Doe, Jane nee Smith MA Ph. D.", {"given": "Jane", "middle": "nee Smith", "family": "Doe", "suffix": "MA Ph. D."}, classification="fix(#601)", notes="#601: a marker in the given part after a family comma is an " "ordinary word (rules/M2). Before #601: given 'Jane', family " "'Doe', suffix 'MA Ph. D.', maiden 'Smith'. As pinned then: the " "given-slot twin of the row above. 2.3.0 read maiden 'Smith " "MA', suffix 'Ph. D.'"), Case("a_capitals_member_behind_the_maiden_name_takes_the_connective", "Jane Doe nee Smith MA y", {"given": "Jane", "family": "Doe", "maiden": "Smith", "suffix": "MA y"}, shape=1, classification="fix(#785)", ambiguities=("suffix-or-name",), notes="S8: the capitals settle MA (S6) and start the " "credential's run in the clause-free reading, which takes " "the 'y' as 'Jane Doe nee Smith PhD y' does, so the " "clause ends before MA (rules/M2) and the 'y' is " "reported as the run's. 1.4.0 read middle 'Doe nee " "Smith', last 'MA y'; 2.0.0 through 2.6.0 maiden 'Smith " "MA y'"), Case("a_connective_behind_the_member_stops_the_peel", "Jane Doe nee Smith Ma y", {"given": "Jane", "family": "Doe", "maiden": "Smith Ma y"}, classification="fix(#274)", ambiguities=(), notes="the recorded negative control for the PEEL's reach, " "measured 2026-09-19 rather than reasoned: the marker " "pass runs before every join, so 'Ma' and 'y' are two " "pieces here and no joined one -- and 'y' is no " "suffix, so the peel takes nothing, the walk is never " "asked about the member behind it, and the clause " "keeps both words. The connective joins them a stage " "later, into the maiden name. Silent for the reason " "the row below is: nothing was decided. Written in " "Title case since #785: in capitals the member settles " "and starts the credential's run, which takes the 'y' " "(rules/S8), as 'Jane Doe nee Smith PhD y' reads", shape=1), Case("the_numeral_half_of_the_walk_is_unmoved", "Jane Doe nee Smith V", {"given": "Jane", "family": "Doe", "suffix": "V", "maiden": "Smith"}, classification="fix(#424)", ambiguities=("suffix-or-name",), notes="#424's own fork, pinned beside the acronym one " "because the two now share a single peel: the numeral " "half answers off Peel.numerals exactly as it did, and " "this row is what says the shared call did not move " "it. Unchanged since #424", shape=1), Case("the_digit_shaped_trailing_suffix_is_unmoved_and_silent", "Jane Doe nee Smith 2", {"given": "Jane", "family": "Doe", "suffix": "2", "maiden": "Smith"}, classification="fix(#424)", ambiguities=(), notes="the numeral half's silent twin -- a lone digit is " "generational vocabulary and no fork, so the walk " "stops and nothing reports. The pair with the row " "above is what separates 'the walk stopped' from 'the " "walk reported'", shape=1), Case("the_one_case_record_loses_its_second_birth_word", "JANE DOE NEE YO-YO MA", {"given": "JANE", "family": "DOE", "suffix": "MA", "maiden": "YO-YO"}, classification="fix(#533)", ambiguities=("suffix-or-name",), notes="an ACCEPTED COST, and the control beside it is what " "makes it the one-case reading's cost rather than this " "rule's: 'JANE YO-YO MA' reads suffix 'MA' too, so the " "clause form now agrees with the bare form. In mixed " "case the writing saves the name -- see the row below", shape=1), Case("the_one_case_records_control_without_the_clause", "JANE YO-YO MA", {"given": "JANE", "family": "YO-YO", "suffix": "MA"}, classification="fix(#289)", ambiguities=("suffix-or-name",), notes="the recorded control for the accepted cost above. " "Unchanged by this rule and read by #289's count, " "which is the point: a clause must not change how a " "word outside it reads, and here the clause form " "joined the bare form rather than the other way round", shape=1), Case("the_mixed_case_record_keeps_its_second_birth_word", "Jane Doe nee Yo-Yo Ma", {"given": "Jane", "family": "Doe", "maiden": "Yo-Yo Ma"}, classification="fix(#533)", ambiguities=("suffix-or-name",), notes="the same two-word birth surname where the writing " "CAN speak: Title case in a mixed-case name declines " "the peel, so 'Yo-Yo Ma' stays whole and the fork " "reports. 1.4.0 read family 'Yo-Yo', suffix 'Ma'", shape=1), Case("the_view_check_asks_whether_the_reader_takes_that_word", "JOHN NEE JONES SMITH MA PHD", {"family": "JOHN", "suffix": "PHD", "maiden": "JONES SMITH MA"}, classification="fix(#533)", ambiguities=("suffix-or-name",), notes="the row that distinguishes the check this change " "SHIPS from the weaker one. The take would leave " "'JOHN MA PHD', whose peel takes 'PHD' and then " "DECLINES 'MA' for want of words to spare -- so 'does " "the reader take SOMETHING' answers yes while 'MA' " "becomes the FAMILY name, which is #424's own disaster " "one word further on. The check compares the view's " "run start against the member's index in it, answers " "no, and the clause keeps the word -- reporting, " "because the fork was consulted and declined", shape=1), Case("the_view_checks_control_without_the_clause", "JOHN MA PHD", {"given": "JOHN", "family": "MA", "suffix": "PHD"}, classification="fix(#289)", ambiguities=("suffix-or-name",), notes="the recorded control: this IS the view the take would " "leave, and family 'MA' is what the row above must " "not produce. Unchanged", shape=1), Case("a_dangling_connective_can_end_a_maiden_name", "Jane Smith nee Jones and MA", {"given": "Jane", "family": "Smith", "suffix": "MA", "maiden": "Jones and"}, classification="fix(#533)", notes="an ACCEPTED COST. The take runs before the joins " "(#420), so the connective is a piece of its own when " "the walk stops and the maiden name ends on it; M2's " "Accepted row about the join order already owns this " "shape. Untagged: the point is the stage order, not " "the input shape"), Case("a_marker_phrase_ends_at_the_credential_too", "Maria Kowalska z domu Nowak MA", {"given": "Maria", "family": "Kowalska", "suffix": "MA", "maiden": "Nowak"}, classification="fix(#533)", notes="the rule is about the marker's CLAUSE, not about a " "one-word marker: the Polish phrase entry (#434) " "reaches the same walk and the same stop. 1.4.0 read " "middle 'Kowalska z domu', family 'Nowak', suffix 'MA'", shape=1), Case("the_german_marker_ends_at_the_credential_too", "Jane Doe geb. Smith MA", {"given": "Jane", "family": "Doe", "suffix": "MA", "maiden": "Smith"}, classification="fix(#533)", notes="a second vocabulary spelling, for the same reason as " "the row above: nothing here is about the word 'nee'", shape=1), Case("a_kyusei_clause_ends_at_the_credential", "田中 太郎 旧姓 佐藤 MA", {"given": "太郎", "family": "田中", "suffix": "MA", "maiden": "佐藤"}, classification="fix(#533)", ambiguities=("suffix-or-name",), notes="the CJK marker reaches the walk like any other, and " "the Latin credential behind it is read by the Latin " "rule -- is_one_case answers True for a caseless " "script, so the lean is inert and the count decides. " "tolerated rather than shape-tagged: a Latin " "credential wrapped around a CJK name is a composed " "form (the 2026-09-01 demotion), read best-effort", tolerated=True), # ---- #533 after a family comma: #531's slot is the reader ------ Case("the_comma_reader_declines_the_title_cased_member", "Doe, Jane nee Smith Ma", {"given": "Jane", "middle": "nee Smith Ma", "family": "Doe"}, shape=2, classification="fix(#601)", ambiguities=("suffix-or-name",), notes="#601: a marker in the given part after a family comma is an " "ordinary word (rules/M2). Before #601: given 'Jane', family " "'Doe', maiden 'Smith Ma'. As pinned then: after a family comma " "the comma has already settled the count, so the reader is " "#531's slot and the writing decides alone -- Title case in a " "mixed-case name keeps the word. Reported either way. 1.4.0 " "read suffix 'Ma'"), Case("the_comma_reader_takes_the_all_lower_member", "Doe, Jane nee Smith ma", {"given": "Jane", "middle": "nee Smith", "family": "Doe", "suffix": "ma"}, shape=2, classification="fix(#601)", ambiguities=("suffix-or-name",), notes="#601: a marker in the given part after a family comma is an " "ordinary word (rules/M2). Before #601: given 'Jane', family " "'Doe', suffix 'ma', maiden 'Smith'. As pinned then: the row " "that makes the COMMA reader load-bearing rather than " "decorative: the count-based reader declines an all-lower " "member with two pieces to spare, and #531's declines nothing " "but a particle. Measured -- this name moves under the comma " "reader and would not under the count. 'Doe, Jane ma' already " "reads suffix 'ma', which is what the clause form now agrees " "with"), Case("the_do_pair_after_a_comma_keeps_the_particle_spelling", "Doe, Jane nee Smith do", {"given": "Jane", "middle": "nee Smith", "family": "do Doe"}, shape=2, classification="fix(#601)", ambiguities=("particle-or-given",), notes="#601: a marker in the given part after a family comma is an " "ordinary word (rules/M2). Before #601: given 'Jane', family " "'Doe', maiden 'Smith do'. As pinned then: 'do' is the one " "class member that is also particle vocabulary, so after a " "comma the clause's trailing slot, #531's slot and P6's " "attachment all want it -- and the reading is #531's, unchanged " "and shared through one predicate. Lean None plus a particle " "tag means P6's word, so the clause keeps it and reports the " "fork it consulted"), Case("the_do_pair_after_a_comma_reads_the_capitals", "Doe, Jane nee Smith DO", {"given": "Jane", "middle": "nee Smith", "family": "Doe", "suffix": "DO"}, shape=2, classification="fix(#601)", notes="#601: a marker in the given part after a family comma is an " "ordinary word (rules/M2). Before #601: given 'Jane', family " "'Doe', suffix 'DO', maiden 'Smith'. As pinned then: the same " "word where the capitals speak: a positive credential lean is " "the one spelling that outranks P6's attachment " "(decisions/S5, 2026-09-18), so the clause gives the word " "up. 1.4.0 read suffix 'DO'"), Case("the_no_comma_do_reads_by_the_count_instead", "Jane Doe nee Smith do", {"given": "Jane", "family": "Doe", "suffix": "do", "maiden": "Smith"}, classification="fix(#533)", ambiguities=("suffix-or-name",), notes="the row the issue flagged as needing a decision, and " "it goes the other way from its comma twin BECAUSE the " "reader is different: with no comma the reader is the " "S2 peel over the view, which reads the count, and " "P6 does not run at all. The comma-less 'John Doe do' " "reads suffix 'do' today, and a clause must not change " "how a word outside it reads -- so the two now agree", shape=1), Case("the_no_comma_dos_control_without_the_clause", "John Doe do", {"given": "John", "family": "Doe", "suffix": "do"}, classification="fix(#289)", ambiguities=("suffix-or-name",), notes="the recorded control for the row above. Unchanged, " "and it is what the clause form was measured against", shape=1), Case("a_capitals_member_after_a_family_comma_clause_starts_the_run", "Doe, Jane nee Smith MA do", {"given": "Jane", "middle": "nee Smith", "family": "Doe", "suffix": "MA do"}, shape=2, classification="fix(#785)", ambiguities=("suffix-or-name",), notes="S8: the marker is an ordinary word after a family " "comma (rules/M2), and the capitals settle MA (S6), " "which starts the credential's run, taking the lone " "member 'do' as the credential, reported; as 'Doe, Jane " "nee Smith PhD do' reads. 1.4.0 read middle 'nee Smith', " "suffix 'MA, do'; 2.0.0 through 2.3.0 maiden 'Smith MA " "do', and 2.6.0 middle 'nee Smith MA', family 'do Doe'"), Case("the_comma_floor_keeps_a_member_a_particle_follows", "Doe, Jane nee Smith ma do", {"given": "Jane", "middle": "nee Smith ma", "family": "do Doe"}, shape=2, classification="fix(#601)", ambiguities=("particle-or-given",), notes="#601: a marker in the given part after a family comma is an " "ordinary word (rules/M2). Before #601: given 'Jane', family " "'Doe', maiden 'Smith MA do'. As pinned then: where #531's " "FLOOR earns its place, and a check that asked only about the " "member got this wrong: the take would leave 'Jane ma do', " "where 'do' does not read as a suffix (P6 keeps it) so #531's " "slot reads 'ma' as a MIDDLE name -- releasing it from the " "clause would move a word from one person's name into " "another's. With the floor the clause keeps 'ma do' whole, and " "reports the 'do' it kept. Written in lower case since #785: " "in capitals the member settles and starts the credential's " "run (rules/S8), as 'Doe, John MA do' reads"), Case("the_clamp_never_takes_the_first_word_after_the_marker", "Doe, J. nee MA ba", {"given": "J.", "middle": "nee", "family": "Doe", "suffix": "MA ba"}, shape=2, classification="fix(#601)", ambiguities=("suffix-or-name",), notes="#601: a marker in the given part after a family comma is an " "ordinary word (rules/M2). Before #601: given 'J.', family " "'Doe', suffix 'ba', maiden 'MA'. As pinned then: the floor is " "a CLAMP, not a veto, and this row is why. The peel takes 'ba' " "and then 'MA', so the first piece the peel took IS the only " "maiden word; a veto that cancelled the stop whenever nothing " "would be left handed 'ba' back to the clause too, giving " "maiden 'MA ba' where 'Doe, J. ba' reads suffix 'ba'. Clamped " "to the piece after the marker, the clause keeps 'MA' and gives " "'ba' up. TWO reports: the walk's for the word it kept, " "assign's for the word it took"), Case("the_clamps_control_without_the_clause", "Doe, J. ba", {"given": "J.", "family": "Doe", "suffix": "ba"}, classification="fix(#531)", ambiguities=("suffix-or-name",), notes="the recorded control for the clamp: this is what the " "released 'ba' must go on reading, and it is #531's " "slot doing the reading. 'ba' is the fifth listed " "member, the one decisions/suffix-acronym-collisions " "marked ambiguous rather than removing. 1.4.0 parity", shape=2), Case("the_clause_leaves_a_middle_initial_alone", "Doe, Jane Q. nee Smith MA", {"given": "Jane", "middle": "Q. nee Smith", "family": "Doe", "suffix": "MA"}, shape=2, classification="fix(#601)", notes="#601: a marker in the given part after a family comma is an " "ordinary word (rules/M2). Before #601: given 'Jane', middle " "'Q.', family 'Doe', suffix 'MA', maiden 'Smith'. As pinned " "then: the member leaves the clause and 'Q.' stays the middle " "initial it always was -- the stop reaches the clause's " "trailing word, not the name in front of it"), Case("a_no_name_segment_leaves_the_clause_nobody_to_read_it", "Doe, Dr. nee Smith MA", {"title": "Dr.", "given": "nee", "middle": "Smith", "family": "Doe", "suffix": "MA"}, shape=2, classification="fix(#601)", notes="#601: a marker in the given part after a family comma is an " "ordinary word (rules/M2). Before #601: title 'Dr.', family " "'Doe', maiden 'Smith MA'. As pinned then: rules/M2's " "invariant, and the row that used to record the opposite: an " "earlier round of #533 read suffix 'MA' here and said so in " "silence. Once 'Smith' leaves with the marker, segment 1 is " "'Dr. MA' -- a no-name segment, which the credential-run gate " "reads whole without ever reaching #531's emitter, so the " "released word would have landed in `given`, not in `suffix`. " "With no name word ahead of it the member is no trailing word " "of a given part, the walk declines, and the clause keeps it. " "The REPORT survives the decline: the emitter asks whether a " "trailing rule reads these words at all, which after a family " "comma it does. 1.4.0 read given 'nee', middle 'Smith', suffix " "'MA'"), Case("the_bound_given_join_would_take_the_released_member", "Berg, abdul nee Jones MA", {"given": "abdul", "middle": "nee Jones", "family": "Berg", "suffix": "MA"}, shape=2, classification="fix(#601)", notes="#601: a marker in the given part after a family comma is an " "ordinary word (rules/M2). Before #601: given 'abdul', " "family 'Berg', maiden 'Jones MA'. As pinned then: the other " "half of M2's invariant: P5's LENIENT post-comma join runs " "BELOW the marker pass and would swallow the released 'MA' into " "the bound-given pair before assign could read it -- 'abdul MA' " "as the given name, which is where the clause-less control " "below genuinely puts it. A word joined away is a word the " "clause gave up for nothing, so the walk declines and keeps it. " "An earlier round of #533 released it and read given 'abdul MA' " "in silence"), Case("the_bound_given_joins_control_without_the_clause", "Berg, abdul ma", {"given": "abdul ma", "family": "Berg"}, classification="parity", ambiguities=(), notes="the recorded control for the row above -- what the " "released member WOULD have read as, and why releasing " "it buys nothing. Written in lower case since #785: in " "capitals in a name of mixed case the member is settled " "and P5 declines it (the row below). 1.4.0 read it " "identically (first 'abdul ma', last 'Berg')", shape=2), Case("a_capitals_member_opening_the_part_reports_nothing_beside_one_that_does", "Smith, MA PhD Ma", {"family": "Smith", "suffix": "MA PhD Ma"}, classification="fix(#785)", ambiguities=("suffix-or-name",), notes="C11: the opening 'MA' is settled by its own " "capitals (S6) and reports nothing, though the " "Title-case 'Ma' behind PhD is read by S7's company and " "reports; the review of #785 found the first version " "still reporting 'MA' wherever another member in the " "part was not settled. 2.6.0 reported both, and 1.4.0 " "read first 'MA', suffix 'PhD, Ma'"), Case("a_capitals_member_ends_the_chain_after_a_title_particle_head", "Smith, Freiherr von Berg MA Jones", {"title": "Freiherr", "given": "von Berg", "family": "Smith", "suffix": "MA Jones"}, classification="fix(#785)", ambiguities=("suffix-or-name",), notes="rules/P2's chain stops at a member its capitals settle, " "as it stops at a suffix word, so the member starts the " "credential's run (S8) and the given part reads as " "'Smith, Freiherr von Berg M.A. Jones' does; the first " "version of #785 widened the run start but not the " "chain's stop, and the chain took 'MA Jones' into the " "given name in silence. 2.6.0 read given 'von Berg MA " "Jones', and 1.4.0 first 'von Berg', middle 'Jones', " "suffix 'MA'"), Case("a_capitals_member_mid_name_stays_in_the_chain", "Freiherr von Berg MA Ma", {"title": "Freiherr", "family": "von Berg MA Ma"}, classification="fix(#785)", ambiguities=("particle-or-given", "suffix-or-name"), notes="P2: outside the given part after a family comma the " "chain is bounded by the trailing read alone, which " "declines 'Ma' and so takes nothing, and the chain keeps " "'MA Ma' as 2.6.0 did. The review of #785's fix found a " "stop at the settled 'MA' here stranding it as a middle " "name (given 'von Berg', middle 'MA', family 'Ma'); the " "dotted 'Freiherr von Berg M.A. Ma' reads suffix 'M.A. " "Ma', and 1.4.0 read first 'von Berg', last 'MA', suffix " "'Ma'"), Case("a_capitals_member_mid_name_keeps_the_bound_join", "Abdul MA Smith", {"given": "Abdul MA", "family": "Smith"}, classification="fix(#785)", notes="P5: with no comma the join stands, since declined here " "the member would be read as a middle name in silence, " "nothing behind it taking it (the review of #785's fix). " "2.6.0 read the same; 1.4.0 read first 'Abdul', middle " "'MA', last 'Smith'"), Case("a_capitals_member_behind_a_lone_bound_given_name", "Abdul MA", {"given": "Abdul", "suffix": "MA"}, classification="fix(#785)", notes="S6: the capitals settle 'MA' with no words to spare, as " "in 'Jack MA', and nothing reports it; the bound given " "name is claimed by its vocabulary, so O5 has no " "convention to report either, and the name carries no " "report though it has no family. 2.6.0 read the same " "fields and reported suffix-or-name"), Case("the_bound_given_join_declines_a_capitals_member", "Berg, abdul MA", {"given": "abdul", "family": "Berg", "suffix": "MA"}, classification="fix(#785)", notes="rules/P5's join declines a member its capitals settle " "as it declines a suffix word (S6), so 'MA' is the " "credential, as 'Berg, abdul M.A.' reads, and nothing " "reports it. 1.4.0, 2.0.0 and 2.6.0 read given " "'abdul MA'", shape=2), Case("a_particle_chain_would_take_the_released_member", "Berg, Jane van der nee Smith DO", {"given": "Jane", "middle": "van der nee Smith", "family": "Berg", "suffix": "DO"}, shape=2, classification="fix(#601)", notes="#601: a marker in the given part after a family comma is an " "ordinary word (rules/M2). Degenerate input, pinned to " "detect change rather than for a reading anyone wants " "(decisions/M2). Before #601: given 'Jane', family " "'van der Berg', maiden 'Smith DO'. As pinned then: M2's " "invariant against P2 rather than P5. 'DO' is particle " "vocabulary standing behind a particle piece, so the chain " "below this pass would absorb it into the family -- a word " "crossing from the BIRTH name into the current one, which is " "#424's failure from the other side. An earlier round of #533 " "released it and read family 'van der DO Berg' in silence, and " "the clause-less 'Berg, Jane van der DO' reads that way for its " "own reasons and is unchanged"), Case("two_released_particle_members_would_chain_each_other", "Jane Doe nee Smith DO DO", {"given": "Jane", "family": "Doe", "suffix": "DO DO", "maiden": "Smith"}, shape=2, classification="fix(#601)", notes="#601: the clause ends at the clause-free name's trailing run, " "which the take consumes (rules/M2). Before #601: given " "'Jane', family 'Doe', maiden 'Smith DO DO'. As pinned then: " "the no-comma spelling of the same decline, where what would do " "the joining is the OTHER released member: the trailing peel " "reads both 'DO's as credentials, but the moment they are out " "of the clause the first is a non-leading particle and chains " "the second into family 'DO DO'. An earlier round of #533 read " "exactly that, and in silence"), Case("the_default_vocabularys_own_corpus_mover", "John née Jones Smith Ma", {"family": "John", "maiden": "Jones Smith Ma"}, classification="fix(#533)", ambiguities=("suffix-or-name",), notes="the one name in the pre-existing differential corpus " "this rule reaches at the DEFAULT vocabulary, and it " "moves by gaining the REPORT rather than a field: " "'Ma' is Title-case inside a mixed-case name, so the " "lean declines the credential reading and the clause " "keeps it -- which is a fork called, and now said out " "loud. It was only ever pinned under a test lexicon " "before. Already a corpus_rules.jsonl name, so the " "shape tag re-witnesses it rather than growing the " "deduped corpus", shape=2), Case("a_member_alone_after_the_marker_keeps_its_credential_behind", "Jane Doe nee MA PhD", {"given": "Jane", "family": "Doe", "suffix": "PhD", "maiden": "MA"}, classification="fix(#533)", ambiguities=("suffix-or-name",), notes="THE FIRST-WORD FLOOR with an unambiguous credential " "behind it: the walk may not take the first word after " "the marker, so 'MA' stays the maiden name whatever " "the peel read, and the 'PhD' behind it was never this " "rule's to give -- a suffix WORD ends the clause the " "way it always did. The report is the clause's own", shape=2), Case("a_particle_member_declines_on_its_lean_after_a_comma", "Doe, Jane nee Smith Do", {"given": "Jane", "middle": "nee Smith", "family": "Do Doe"}, shape=2, classification="fix(#601)", ambiguities=("particle-or-given",), notes="#601: a marker in the given part after a family comma is an " "ordinary word (rules/M2). Before #601: given 'Jane', family " "'Doe', maiden 'Smith Do'. As pinned then: #531's reading at " "the given slot, reached through a clause: a member that is " "also particle vocabulary is the credential on a POSITIVE lean " "alone, and Title-case inside a mixed-case name is not one. So " "the clause keeps it and says so. The caps spelling 'Doe, Jane " "nee Smith DO' is the other direction"), Case("a_numeral_between_the_clause_and_the_member", "Jane Doe nee Smith V MA", {"given": "Jane", "family": "Doe", "suffix": "MA", "maiden": "Smith V"}, classification="fix(#533)", ambiguities=("suffix-or-name",), notes="both stops read the TRAILING word, so the numeral is " "not the word either fork asks about: the acronym fork " "stops at 'MA' and 'V' stays maiden text behind it. " "Since #721 the kept 'V', a numeral with a post-nominal " "behind it, reports as 'John Smith V MA' reports its " "own (S10, the trailing roman numeral). " "The mirror image, 'Jane Smith née Jones Ma V', keeps " "maiden 'Jones Ma' and reads suffix 'V'", shape=2), Case("delimiters_keep_the_whole_span_whatever_the_last_word_is", "Jane Doe (nee Smith MA)", {"given": "Jane", "family": "Doe", "maiden": "Smith MA"}, classification="parity", ambiguities=(), notes="rules/M2, the delimited clause: the writer drew " "the boundary and it outranks every reading inside it, " "so no fork is called and none is reported. The marker " "inside the pair is what says the span is a maiden " "clause even where the pair is not in " "`maiden_delimiters`. Unchanged by #533 and by every " "release before it -- a settled position, not one of " "the silences about un-asked forks", shape=2), Case("delimiters_keep_the_whole_span_in_title_case_too", "Jane Doe (nee Smith Ma)", {"given": "Jane", "family": "Doe", "maiden": "Smith Ma"}, classification="parity", ambiguities=(), notes="the pair for the row above: the two spellings differ " "in everything the lean reads and the delimiters make " "the difference immaterial. Undelimited, 'Jane Doe nee " "Smith MA' reads suffix 'MA' and 'Jane Doe nee Smith " "Ma' keeps it -- and both report", shape=2), Case("a_word_outside_the_delimiters_is_outside_the_clause", "Jane Doe (nee Smith) MA", {"given": "Jane", "family": "Doe", "suffix": "MA", "maiden": "Smith"}, classification="parity", notes="the boundary cuts both ways: the span is the maiden name " "whole, and a member the writer left OUTSIDE it is an " "ordinary trailing credential that assign peels, settled by " "its capitals and silent since #785 (rules/S6). The control " "for the two rows above", shape=2), Case("two_released_members_each_get_their_own_report", "Jane Doe nee Smith Ma jd", {"given": "Jane", "family": "Doe", "suffix": "jd", "maiden": "Smith Ma"}, classification="fix(#533)", ambiguities=("suffix-or-name", "suffix-or-name"), notes="the shape that puts BOTH suffix-or-name emitters on " "one parse: the clause keeps 'Ma' on its lean and " "reports it, the peel takes 'jd' and reports that, and " "the two name different tokens -- which is what " "test_properties' span test needs to actually " "exercise its own check. Written 'jd' since #785: a " "capitals 'JD' is settled and reports nothing (rules/S6)", shape=2), Case("no_trailing_rule_reads_the_family_segments_clause", "Smith nee Jones, Jane MA", {"given": "Jane", "family": "Smith", "suffix": "MA", "maiden": "Jones"}, classification="fix(#531)", notes="the clause is in segment 0 of a family comma, where " "the comma has ALREADY named the family: those words " "are family text and a stop would hand one to `family` " "rather than to `suffix`, so no trailing rule is " "consulted and the clause keeps what it has. The 'MA' " "that does read as a credential here is in segment 1 " "and is #531's slot's, not this rule's. Unchanged", shape=2), Case("no_trailing_rule_reads_a_third_comma_part", "Smith, John, Jr nee Jones MA", {"given": "John", "family": "Smith", "suffix": "Jr nee Jones MA"}, classification="fix(#601)", ambiguities=("comma-structure",), notes="#601: a marker in a part after a suffix comma is an ordinary " "word (rules/M2). Before #601: given 'John', family 'Smith', " "suffix 'Jr', maiden 'Jones MA'. As pinned then: the other NONE " "reader, and the row that killed the first prototype: a segment " "past the second comma is read as credentials whole, so no " "trailing rule is consulted, the clause keeps 'MA' -- and " "nothing reports, because nothing was decided. Untagged: shape " "2 is a TWO-part listing. Unchanged from 2.0.0"), # ---- #533: the policy sweep, all core-only ----------------------- Case("the_clause_reads_the_same_under_the_strict_comma_knob", "Doe, Jane nee Smith MA", {"given": "Jane", "middle": "nee Smith", "family": "Doe", "suffix": "MA"}, policy=Policy(lenient_comma_suffixes=False), classification="fix(#601)", notes="#601: a marker in the given part after a family comma is an " "ordinary word (rules/M2). Before #601: given 'Jane', family " "'Doe', suffix 'MA', maiden 'Smith'. As pinned then: the knob " "governs the LENIENT trailing predicate, which this slot does " "not inherit, so the reading is the default's"), Case("the_clause_reads_the_same_under_family_first", "Doe, Jane nee Smith MA", {"given": "Jane", "middle": "nee Smith", "family": "Doe", "suffix": "MA"}, policy=Policy(name_order=FAMILY_FIRST), classification="fix(#601)", notes="#601: a marker in the given part after a family comma is an " "ordinary word (rules/M2). Before #601: given 'Jane', family " "'Doe', suffix 'MA', maiden 'Smith'. As pinned then: name_order " "does not enter it: the peel is order-independent and the comma " "has already named the family, so all three orders move the " "same names (measured over the whole corpus under six " "policies). UNTAGGED, and a shape 4 tag would be wrong -- this " "is a comma listing, not the family-first arrangement"), Case("the_clause_reads_the_same_under_ff_given_last", "Doe, Jane nee Smith MA", {"given": "Jane", "middle": "nee Smith", "family": "Doe", "suffix": "MA"}, policy=Policy(name_order=FAMILY_FIRST_GIVEN_LAST), classification="fix(#601)", notes="#601: a marker in the given part after a family comma is an " "ordinary word (rules/M2). Before #601: given 'Jane', family " "'Doe', suffix 'MA', maiden 'Smith'. As pinned then: the third " "order, for the same reason as the row above"), Case("a_leading_title_takes_the_slot_the_member_would_have_had", "Doe, Dr. MA Smith", {"title": "Dr.", "given": "MA", "middle": "Smith", "family": "Doe"}, notes="beside 'Doe, MA Smith' below, and the pair is what " "says how far the FIRST-PIECE emitter reaches: it reads " "the piece standing immediately after the comma, which " "is the title here, so the member behind it is read as " "the given name in silence. Not this slot either -- a " "title took the position the given part would have had. " "Parity at 1.4.0, 2.0.0 and 2.3.0 alike (measured " "2026-09-19)", shape=2), Case("the_post_comma_emitter_reports_the_same_member_untitled", "Doe, MA Smith", {"given": "MA", "middle": "Smith", "family": "Doe"}, ambiguities=("suffix-or-name",), notes="the same member, the same reading, one title fewer -- " "and now the first piece after the comma IS the member, " "so #289's emitter reports it. The report is the whole " "difference from the row above: 1.4.0 read first 'MA', " "middle 'Smith' too, so the fields are parity", shape=2), Case("a_title_led_segment_consumes_a_whole_run_in_silence", "Doe, Mr. MA PhD", {"title": "Mr.", "family": "Doe", "suffix": "MA PhD"}, classification="fix(#289)", notes="'Doe, Dr. MA' with a credential run behind the " "member, which is what the row adds: `_comma.decide` " "(group's head, #613) reads the segment whole, so the " "member joins the run rather than taking the given " "slot, and neither of assign's emitters is in that " "path. 1.4.0, 2.0.0 and 2.3.0 all " "read first 'MA', suffix 'PhD' -- the case lean moved " "it, which is 'Doe, Dr. MA's classification " "(measured 2026-09-19)", shape=2), Case("the_initial_veto_before_a_third_comma_part_is_untouched", "Doe, John V, PhD", {"given": "John", "middle": "V", "family": "Doe", "suffix": "PhD"}, notes="#144's two-segment restriction still governs its own " "predicate: 'V' is a roman numeral reached by the " "LENIENT trailing test, not by the ambiguous class, so " "the new branch never sees it and the middle initial " "survives. The control that pins the restriction's " "scope after #531 narrowed nothing about it"), Case("the_no_name_gate_path_reads_the_capitals_at_the_comma", "Doe, MA", {"family": "Doe", "suffix": "MA"}, classification="fix(#289)", notes="the reading is the_lean_reaches_the_post_comma_slot's " "('Smith, MA') word for word, so it carries that row's " "classification: 1.4.0 AND 2.0.0 both read first 'MA', " "last 'Doe', and #289's lean is what made the " "credential (measured on both wheels 2026-09-18). " "UNCHANGED by #531, and structurally unreachable by the new " "emitter rather than luckily missed: segment 1 holds " "no name word, so `_comma.decide` binds every role at " "group's head (#613), and assign's placement loop is " "handed no piece of it. So the slot the first-piece " "report owns and the slot the new one owns cannot both " "fire. It reported the member there once until #785, " "whose capitals settle it in silence (rules/S6)", shape=2), Case("the_no_name_gate_path_with_a_run_reads_the_capitals", "Doe, MA PhD", {"family": "Doe", "suffix": "MA PhD"}, classification="fix(#289)", notes="the same gate path with a credential run behind the " "member, and the same classification as its bare twin " "above: 1.4.0 and 2.0.0 alike read first 'MA', suffix " "'PhD'. One report, for the one class member, from #531 " "until #785, whose capitals settle it in silence " "(rules/S6)", shape=2), Case("a_title_led_segment_consumes_the_member_in_silence", "Doe, Dr. MA", {"title": "Dr.", "family": "Doe", "suffix": "MA"}, classification="fix(#289)", notes="the THIRD position this kind stays silent at, and a " "boundary rather than an omission: a title took the " "slot the given name would have had, so there is no " "given part for the member to end. The segment holds " "no name word, `_comma.decide` reads it whole at group's " "head, and #531's emitter is in the placement loop " "that path never enters. The first-piece report cannot " "reach it either -- that one reads the FIRST piece " "after the comma, which is 'Dr.'. So the reading moves " "and nothing reports. MEASURED on the wheels: 1.4.0, " "2.0.0 and 2.3.0 all read first 'MA', last 'Doe'; " "#289's case lean is what makes it a credential, which " "is this row's twin 'Doe, MA' above, and 'DOE, DR. MA' " "keeps given 'MA' where one case carries no lean", shape=2), # ---- the `do` pairing: capitals decide, the particle rule keeps # every other spelling (Derek, 2026-09-18) --------------------- Case("capitals_decide_for_the_particle_member", "Doe, John DO", {"given": "John", "family": "Doe", "suffix": "DO"}, classification="fix(#531)", notes="'do' is the one class member that is also particle " "vocabulary, so this slot and P6's attachment want the same " "word. A POSITIVE credential lean -- an all-caps member in " "a name written in more than one case -- is what takes it, " "and P6 stands down. 1.4.0 read suffix 'DO'. Reports " "nothing since #785: the capitals settle the clash with the " "name reading (rules/S6), leaving the particle reading for " "P6, which stands down where this reading wins", shape=2), Case("the_particle_rule_keeps_the_lower_case_member", "Nascimento, Edson Arantes do", {"given": "Edson", "middle": "Arantes", "family": "do Nascimento"}, classification="fix(#380)", ambiguities=("particle-or-given",), notes="UNCHANGED, and the record the pairing is built to " "protect. Mixed case with an all-lower member leans " "nothing, so the reading carve-out hands it to P6 and " "the report carve-out keeps P6's own kind as the only " "one. This is the half of the pair the capitals buy. " "NOT 1.4.0 parity: v1 read suffix 'do', and P6's " "attachment has outranked that since 2.3 -- which is " "'Berg, Jan vd's classification, this word being the " "same collision", shape=2), Case("one_case_cannot_tell_the_osteopath_from_the_record", "SMITH, JOHN DO", {"given": "JOHN", "family": "DO SMITH"}, classification="fix(#380)", ambiguities=("particle-or-given",), notes="ACCEPTED COST, and the pairing IS the argument: in " "one case the rule cannot tell this from " "'NASCIMENTO, EDSON ARANTES DO' and reads both as the " "particle -- right about the Brazilian record, wrong " "about the osteopath, whose 'DO' 1.4.0 read as a " "suffix. Both report `particle-or-given`, which is how " "a caller finds the second. Accepted rather than " "repaired: the one-case reading is the commoner of the " "two collisions and the Nascimento record is the name " "a wrong answer would damage", shape=2), Case("a_member_inside_a_particle_run_never_reaches_the_slot", "Doe, John van DO", {"given": "John", "family": "van DO Doe"}, classification="fix(#380)", ambiguities=("particle-or-given",), notes="the capitals lean cannot reach this 'DO', and the " "reason is EARLIER than #531: grouping merges 'van DO' " "into ONE particle piece before the trailing slot " "exists. What declines it is the TAG test -- the " "piece's first token is 'van', which carries no class " "tag -- and the len() test in front of that decides " "nothing here, since deleting it leaves this row where " "it stands ('Doe, John MA y' is the row that pins it). " "Not a carve-out and not this release's doing -- " "verified byte-identical at cc78c960 and on the 2.3.0 " "wheel (2026-09-19), where 'Doe, John DO' moved and " "this did not. The report is P6's, named for 'van', " "the word of the run that is also a given name. 1.4.0 " "read middle 'van DO'; the attachment is #380's, which " "is the classification 'Nascimento, Edson Arantes do' " "carries above", shape=2), Case("a_member_heading_a_particle_chain_never_reaches_it_either", "Doe, John DO Ed", {"given": "John", "middle": "DO Ed", "family": "Doe"}, classification="fix(comma-family)", notes="the member HEADS the joined piece here where 'van DO' " "has it trailing: 'do' is particle vocabulary, so the " "chain takes the name word behind it and 'DO Ed' is " "one two-token piece. Both words silent -- though " "'Doe, John DO' alone reads the credential and reports " "-- and nothing attaches, the run not being wholly " "particles. The SECOND killer for the len() tests, and " "the one where the piece's first token IS the class " "member: without them the piece reads as a credential, " "suffix 'DO Ed'. 1.4.0 read suffix 'DO, Ed'; 2.0.0 and " "2.3.0 read this (measured 2026-09-19)", shape=2), Case("a_declining_member_inside_a_particle_run_is_silent_too", "Doe, John van Ma", {"given": "John", "middle": "van Ma", "family": "Doe"}, classification="fix(comma-family)", notes="'Doe, John van DO's Title-cased twin, and it differs " "in what P6 does rather than in what the slot does: " "the joined piece is two tokens either way, but 'Ma' " "is not particle vocabulary, so the trailing run is " "not wholly particles and nothing attaches -- middle " "'van Ma', family 'Doe', silent on both counts. 1.4.0 " "read middle 'van', suffix 'Ma'; 2.0.0 and 2.3.0 read " "this", shape=2), Case("the_particle_carve_out_silences_the_member_in_front", "Doe, John ma do", {"given": "John", "middle": "ma", "family": "do Doe"}, classification="fix(#380)", ambiguities=("particle-or-given",), notes="the carve-out cascades: lower-case 'do' leans " "nothing, so this walk does not read it as a suffix -- " "and a piece the walk refuses ENDS the run, so the " "lower-case 'ma' in front of it is never asked and never " "reports. P6 then attaches 'do' a stage later, too late " "to re-open the question. One report, P6's. Until #785 " "the row was written 'Doe, John MA do', and the capitals " "were never asked either; they now start the " "credential's run (the row below). 1.4.0 read suffix " "'ma, do'", shape=2), Case("a_capitals_member_takes_the_particle_behind_it", "Doe, John MA do", {"given": "John", "family": "Doe", "suffix": "MA do"}, classification="fix(#785)", ambiguities=("suffix-or-name",), notes="S8: the capitals settle MA (S6) and start the " "credential's run, which reads the lone member 'do' " "behind it as the credential, as 'Doe, John PhD do' " "does (S8's Except: a lone member of the class is not " "the particle P6 attaches), reported as that member's " "fork. 1.4.0 read suffix 'MA, do', 2.0.0 middle " "'MA do', and 2.3.0 through 2.6.0 middle 'MA', family " "'do Doe'", shape=2), Case("one_case_keeps_the_particle_on_the_real_record", "NASCIMENTO, EDSON ARANTES DO", {"given": "EDSON", "middle": "ARANTES", "family": "DO NASCIMENTO"}, classification="fix(#380)", ambiguities=("particle-or-given",), notes="the fourth corner of the pairing, and the reason the " "one-case cost is accepted: this is a real Portuguese " "record and the particle reading is right about it. " "1.4.0 read middle 'ARANTES', suffix 'DO'", shape=2), Case("the_particle_member_declines_in_title_case_too", "Doe, John Do", {"given": "John", "family": "Do Doe"}, classification="fix(#380)", ambiguities=("particle-or-given",), notes="Title case leans NAME, which is not a positive " "credential lean, so the carve-out hands it to P6 " "like every non-capital spelling. One report, P6's. " "1.4.0 read suffix 'Do'", shape=2), Case("p6_still_claims_the_other_unambiguous_suffix_particle", "Berg, Jan mc", {"given": "Jan", "family": "mc Berg"}, classification="fix(#380)", ambiguities=("suffix-or-name",), notes="'mc' is the second word in both vocabularies whose " "suffix half is unambiguous, and it is the control " "that stops #531's P6 condition being written against " "one example -- 'Berg, Jan vd' above is the first, and " "both are byte-identical before and after. Carries " "that row's classification for the same reason: 1.4.0 " "and 2.0.0 both read suffix 'mc' and P6's attachment " "is what took it (measured on both wheels 2026-09-18)", shape=2), # ---- the particle cascade Derek accepted (Q1) ---------------- Case("the_slot_hands_a_trailing_particle_to_p6", "Doe, John van MA", {"given": "John", "family": "van Doe", "suffix": "MA"}, classification="fix(#531)", ambiguities=("particle-or-given",), notes="A FAMILY MOVE, found by measurement and accepted by Derek " "2026-09-18: today this is middle 'van MA', silent. Once " "'MA' becomes a suffix, P6's own walk looks past that " "post-nominal, finds 'van' as a trailing all-particle run " "and attaches it. That is exactly 'Berg, Jan van Jr.'s " "reading, arriving through a shape it could not reach " "before. ONE report since #785, P6's, the capitals settling " "'MA' (rules/S6)"), Case("the_slot_reaches_past_an_unambiguous_suffix_particle", "Doe, John MA vd", {"given": "John", "family": "vd Doe", "suffix": "MA"}, classification="fix(#531)", ambiguities=("suffix-or-name",), notes="the row above with the particle BEHIND the member instead " "of in front, and the pair says the cascade is not about " "where the particle stands: 'vd' is claimed outright by the " "suffix vocabulary, so this walk reads past it and takes " "'MA', and P6 then attaches 'vd' over that reading -- " "reporting in the kind naming the reading it OVERRODE, " "which is why its report is `suffix-or-name` where 'Doe, " "John van MA' gives `particle-or-given`; since #785 it is " "the only one, the capitals settling 'MA' (rules/S6). Today " "middle 'MA', family 'vd Doe', one report ('Berg, Jan vd' " "is that reading with nothing in front of the particle)"), # ---- policy rows. Every one is _CORE_ONLY (see # tests/v2/test_facade_cases.py) -------------------------------- Case("the_dotted_slot_reports_with_the_switch_off", "Doe, John X.Y.Z.", {"given": "John", "middle": "X.Y.Z.", "family": "Doe"}, policy=Policy(unlisted_dotted_suffixes=False), classification="fix(#531)", ambiguities=("suffix-or-name",), notes="the declined-but-reported shape at this slot: " "classify writes `shape:acronym` whether or not the " "switch admits the token, which `peel_trailing` " "already relies on, so the fork is consulted and " "reported while the reading stays a middle name. Same " "treatment 'Smith, A.B.' gets at the first post-comma " "slot"), Case("the_caps_switch_reaches_the_trailing_slot", "Doe, John XYZ", {"given": "John", "family": "Doe", "suffix": "XYZ"}, policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), classification="fix(#531)", ambiguities=("suffix-or-name",), notes="the by-shape class reaches this slot through the " "same tag the listed class does. `listed_lean` returns " "None wherever `shape:acronym` rides, so a by-shape " "member never has a lean and takes the positional " "reading -- which at this slot is the credential"), Case("the_trailing_slot_ignores_the_strict_comma_knob", "Doe, John MA", {"given": "John", "family": "Doe", "suffix": "MA"}, policy=Policy(lenient_comma_suffixes=False), classification="fix(#531)", notes="`lenient_comma_suffixes` governs #144's initial-veto " "predicate, which this branch does not go through -- " "so the strict knob reads this slot exactly as the " "default does. The control that says which predicate " "owns the word"), Case("the_trailing_slot_reads_the_same_under_family_first", "Doe, John MA", {"given": "John", "family": "Doe", "suffix": "MA"}, policy=Policy(name_order=FAMILY_FIRST), classification="fix(#531)", notes="a family comma names the family, so the declared " "order arbitrates nothing here. Measured identical " "under all three orders"), Case("the_trailing_slot_reads_the_same_under_ff_given_last", "Doe, John MA", {"given": "John", "family": "Doe", "suffix": "MA"}, policy=Policy(name_order=FAMILY_FIRST_GIVEN_LAST), classification="fix(#531)", notes="the third order, for the same reason as the second"), # ---- caseless scripts. tolerated=True, NOT a shape tag: # cases.py's __post_init__ hard-errors on a composed CJK comma # form carrying a shape. Case("a_caseless_script_takes_the_trailing_credential", "田中, 太郎 MA", {"given": "太郎", "family": "田中", "suffix": "MA"}, classification="fix(#531)", ambiguities=("suffix-or-name",), notes="'caseless is inert' holds for the LEAN, not for the " "OUTCOME: `is_one_case` answers True for a caseless " "script, so nothing leans, so the positional reading " "applies -- and at this slot the positional reading IS " "the credential. 1.4.0 parity, measured. A composed " "CJK comma form, so tolerated rather than shape-tagged " "(rules/W3's 2026-09-01 demotion)", tolerated=True), Case("a_hangul_family_takes_the_trailing_credential_too", "김, 민준 MA", {"given": "민준", "family": "김", "suffix": "MA"}, classification="fix(#531)", ambiguities=("suffix-or-name",), notes="the Korean twin of the row above, and the pair is " "what stops the reading being a fact about Han. " "Tolerated for the same reason", tolerated=True), Case("family_comma_lenient_trailing", "Smith, John V", {"given": "John", "family": "Smith", "suffix": "V"}, notes="v1 #144: the trailing piece of a two-part comma name " "takes the lenient suffix test"), Case("family_comma_first_piece_is_given", "Steven Hardman, RN - CRNA", {"given": "Steven", "family": "Hardman", "suffix": "RN, CRNA"}, classification="fix(#603/#889)", notes="a credential opening the part after the comma makes it " "the postnominal part (rules/C13, #603), so the " "pre-comma name keeps its positional read, and since " "#889 the hyphen standing alone parts the run as a " "built-in delimiter (rules/C10). v1 and " "2.0 through 2.3 read v1's walk order here, the first " "post-comma piece the given before any suffix check -- " "given 'RN', middle '-', family 'Steven Hardman', the " "documented limitation of an UNSET delimiter -- which the " "row id still names"), Case("a_credential_opening_the_comma_part_needs_an_unambiguous_one", "John Smith, Ma Jones", {"given": "Ma", "middle": "Jones", "family": "John Smith"}, ambiguities=("suffix-or-name",), notes="the boundary of #603's opened part (rules/C13): only " "a word that starts S8's run opens it, and a member of " "the ambiguous credential class does not, so the part " "keeps the listing form's given name and the first " "post-comma piece reports the fork it read (#289)"), Case("a_dual_opens_no_part_behind_a_two_word_surname", "García Márquez, Ms Gabriela", {"title": "Ms", "given": "Gabriela", "family": "García Márquez"}, notes="#603's boundary for a title/suffix dual (rules/C13): it " "opens nothing, being the listing form's title in front " "of a given name, and no count before the comma tells a " "two-word surname from a given name and a family name. A " "first draft opened the part behind two words and read " "given 'García', suffix 'Ms Gabriela' here; Derek chose " "that duals never open (2026-10-04)"), Case("the_split_credential_starts_the_given_part_run", "Smith, John Ph. D. Jones", {"given": "John", "family": "Smith", "suffix": "Ph. D. Jones"}, ambiguities=("suffix-or-name",), classification="fix(#603)", notes="#602's run in the given part after a family comma, " "started by the split credential group merges: " "starts_a_credential_run refused any piece longer than " "one token, so 'Ph. D.' started no run where 'PhD' did, " "and this read middle 'Jones', suffix 'Ph. D.' (2.3.0 " "too). Found by #603, whose opened part asks the same " "predicate (nameparser/AGENTS.md's spelling sweep)"), Case("a_split_credential_opens_the_part_as_the_joined_one_does", "John Smith, Ph. D. and Mary Jones", {"given": "John", "family": "Smith", "suffix": "Ph. D. and Mary Jones"}, ambiguities=("suffix-or-name", "suffix-or-name", "suffix-or-name"), classification="fix(#603)", notes="the two spellings agree (rules/C13): since #613 the " "part after the comma is read before group's connective " "join, so 'Ph. D.' opens it as 'PhD' does ('John Smith, " "PhD and Mary Jones' reads the same) and the words it takes " "are reported. Before, the join made 'Ph. D. and Mary' one " "piece and both spellings read given 'Ph. D. and Mary', " "family 'John Smith', which #615's row pinned"), Case("a_connective_surname_before_the_comma_counts_its_words", "Vega y Lopez, PhD Jones", {"given": "Vega", "middle": "y", "family": "Lopez", "suffix": "PhD Jones"}, ambiguities=("suffix-or-name",), classification="fix(#603)", notes="C3's one count before the comma, read off classify's " "tags (#613): a connective join is not one word there " "(#575, Derek on #613), so the part before the comma is a " "whole name and reads positionally, as 'Vega y Lopez, PhD' " "already did. #615 pinned family 'Vega y Lopez' here, the " "answer of assign's second count over group's joined " "pieces, which #613 deleted. 2.3.0 read given 'PhD', " "middle 'Jones'"), Case("a_connective_surname_before_a_title_comma_counts_its_words", "Ortega y Gasset, Dr.", {"title": "Dr.", "given": "Ortega", "middle": "y", "family": "Gasset"}, ambiguities=(), classification="fix(#613)", notes="decision (1) of #613, whole: the connective surname is " "three words to C3's count (#575), so a comma followed by " "a title alone is the suffix comma and the part before it " "reads as the comma-less 'Ortega y Gasset' does. 1.4.0 " "read first 'Ortega', middle 'y', last 'Gasset' too; " "b547c247 read family 'Ortega y Gasset'"), Case("a_suffixed_family_before_a_comma_suffix_reads_positionally", "Smith Jr., Esq.", {"family": "Smith", "suffix": "Jr., Esq."}, ambiguities=(), classification="fix(#613)", notes="decision (4) of #613: the part after the comma is a " "suffix behind 'Smith Jr.', whose one name word reads " "positionally as 'Smith Jr.' alone does, with nothing to " "report. b547c247 read given 'Smith' and reported " "given-or-family, assign's second count having made the " "comma's positional read of one word"), Case("a_title_chain_after_the_comma_stays_one_title", "John Smith, Mr. and Mrs.", {"title": "Mr. and Mrs.", "given": "John", "family": "Smith"}, ambiguities=(), classification="fix(#613)", notes="the one join made inside a part read as postnominal " "(rules/C7, P3): a connective between titles binds " "them into one title. #613's first draft bound each word " "on its own and lost it (`_comma._title_chains` then, " "group's `_pieces.join_connectives` since #617)"), Case("a_title_chain_after_the_comma_runs_through_every_link", "John Smith, Mr. and Mrs. and Dr.", {"title": "Mr. and Mrs. and Dr.", "given": "John", "family": "Smith"}, ambiguities=(), classification="fix(#613)", notes="the chain takes its kind from the title before each " "connective, so the second 'and' joins as the first does; " "asking the vocabulary of the connective alone read " "family 'John Smith' (mutation measured in review)"), Case("a_connective_run_after_the_comma_joins_the_title_whole", "John Smith, Minister of the Interior and Mrs.", {"title": "Minister of the Interior and Mrs.", "given": "John", "family": "Smith"}, ambiguities=(), classification="fix(#617)", notes="rules/P3's connective runs ('of the') merge before " "they join, in the part after the comma as in the name: " "the comma decision joins with group's own loop since " "#617. Its copy joined 'Minister of the' alone, left " "'Interior' a name word and kept the listing form, " "family 'John Smith' (83f4e914; 2.3.0 read as here)"), Case("a_connective_run_title_leaves_the_credential_behind_it", "Smith, Chancellor of the Exchequer MP", {"title": "Chancellor of the Exchequer", "family": "Smith", "suffix": "MP"}, ambiguities=(), classification="fix(#617)", notes="the run joins 'Exchequer' into the title, so nothing in " "the part is a name word and 'MP' is the credential; " "83f4e914 read given 'MP', 'Exchequer' standing alone " "having made the part the given part (2.3.0 read as " "here)"), Case("a_doubled_connective_after_the_comma_joins_as_one_run", "Prof. Smith, and and Dr.", {"title": "Prof. and and Dr.", "family": "Smith"}, ambiguities=(), classification="fix(#617)", notes="garbage in, decided explicitly in #617: the doubled " "connective is one run, and the run takes its kind from " "the title after it, as group reads a run. 2.3.0 and " "b547c247 read the same; 83f4e914 read title 'and and Dr.', family " "'Prof. Smith', its copy of the loop merging no run"), Case("a_single_letter_inside_a_connective_run_joins_with_the_run", "John Smith, Mr. y and Mrs.", {"title": "Mr. y and Mrs.", "given": "John", "family": "Smith"}, ambiguities=(), classification="fix(#617)", notes="rules/P3's carve-out keeps a LONE single-letter " "connective a word; one beside another connective is a " "member of their run, which joins whole. 2.3.0 read the " "same; 83f4e914 kept the 'y' out of the copy's chain and " "read family 'John Smith'"), Case("a_lone_single_letter_connective_after_the_comma_joins_no_title", "Juan Garcia Lopez, Sr. y Sra.", {"title": "Sr. Sra.", "given": "y", "family": "Juan Garcia Lopez"}, ambiguities=(), classification="parity", notes="the comma decision joins no title across a lone " "single-letter connective, so the part is no titles-alone " "part and the listing form reads it, P3's count keeping " "the 'y' a word in a part this short ('Smith, Sr. y Sra. " "Juan Maria' joins it); the comma-less 'Sr. y Sra. Juan " "Garcia Lopez' joins it too. Read so by 2.3.0 and " "83f4e914 alike (decisions/P3, #617)"), Case("a_comma_part_of_one_connective_is_the_given_name", "Smith, and", {"given": "and", "family": "Smith"}, ambiguities=(), classification="parity", notes="garbage in: a connective with no title beside it joins " "nothing in the comma part, so it is the listing form's " "given name (2.3.0, 83f4e914 and #617 alike)"), Case("a_title_chain_ending_in_a_connective_keeps_it", "John Smith, Mr. and", {"title": "Mr. and", "given": "John", "family": "Smith"}, ambiguities=(), classification="parity", notes="garbage in: the trailing connective takes its kind from " "the title on its left and joins with nothing on its " "right (2.3.0, 83f4e914 and #617 alike)"), Case("connectives_between_given_names_after_the_comma_join_them", "Smith, Jones and Brown", {"given": "Jones and Brown", "family": "Smith"}, ambiguities=(), classification="parity", notes="no title beside the connective, so the comma decision " "joins nothing and the part is the given part, where " "group's own join makes one given name of it -- the " "same reading if the comma decision had joined it, so " "this guards the listing reading, not the titles-only " "mode (2.3.0, 83f4e914 and #617 alike)"), Case("a_connective_between_particles_chains_on_as_one", "Otto von und zu Habsburg", {"given": "Otto", "family": "von und zu Habsburg"}, ambiguities=(), classification="parity", notes="rules/P3's join takes the particle's prefix kind, so " "'von und zu' chains onto the name as one particle (v1 PR " "#191); the derivation had only v1 tests behind it " "(#617's test review)"), Case("a_connective_opening_the_part_takes_the_title_after_it", "John Smith, and Dr.", {"title": "and Dr.", "given": "John", "family": "Smith"}, ambiguities=(), classification="fix(#613)", notes="garbage in, read as b547c247 read it: a connective with " "nothing before it takes its kind from the word after " "it, so it joins the title rather than standing as a " "name word; reading the left neighbour only made it " "family 'John Smith' (mutation measured in review)"), Case("a_connective_between_a_credential_and_a_title_joins_nothing", "John Smith, PhD and Dr.", {"title": "Dr.", "given": "John", "family": "Smith", "suffix": "PhD and"}, ambiguities=("suffix-or-name",), classification="fix(#613)", notes="garbage in, and accepted as decision (3)'s cost: no join " "reaches into a part read as postnominal except a title " "chain, and 'and' takes its kind from the credential in " "front, so it joins nothing and the run #603 opened takes " "it as a word, reporting it. b547c247 read given 'PhD " "and Dr.', family 'John Smith'"), Case("a_numeral_behind_a_class_member_keeps_the_listing_form", "John Smith, Ma I", {"given": "Ma", "family": "John Smith", "suffix": "I"}, ambiguities=("suffix-or-name",), notes="contrast for the row below: C4's run test admits no " "single-letter numeral, which could be an initial, so " "'Ma' is the given name, as b547c247 read it"), Case("a_generation_behind_a_class_member_makes_the_run", "John Smith, Ma Jr", {"given": "John", "family": "Smith", "suffix": "Ma Jr"}, ambiguities=("suffix-or-name",), notes="contrast for the row above: a suffix word that is no " "initial completes the run, and two name words before " "the comma read it as the credential run"), Case("a_comma_with_nothing_before_it_reads_its_credentials", ", MD PhD", {"suffix": "MD PhD"}, ambiguities=(), classification="fix(#613)", notes="an empty part before the comma names no family, but the " "part after it is read all the same, as 2.3.0 read it. " "The first #613 tree returned early there and left " "'MD PhD' to the listing walk: title 'MD', family 'PhD' " "(PR review; empty surname fields in CSV data write " "this)"), Case("a_comma_with_nothing_before_it_reads_a_lone_generation", ", Jr.", {"suffix": "Jr."}, ambiguities=(), classification="fix(#613)", notes="the same exit read ', Jr.' title 'Jr.' (PR review)"), Case("empty_head_attaches_the_particle_before_the_title_rule", ", Mr. Jones vd", {"title": "Mr.", "given": "Jones", "family": "vd"}, ambiguities=("suffix-or-name",), classification="fix(#892)", notes="a comma with nothing before it is a family comma naming " "no surname (rules/C15), so P6 attaches the particle as " "behind any family comma, ahead of H1, which then finds " "a family: the twin 'Smith, Mr. Jones vd' reads given " "'Jones', family 'vd Smith'. #613 had kept the " "comma-less reading, family 'Jones', suffix 'vd', with " "a second P6 site in post_rules (decisions/P6)"), Case("empty_head_attaches_the_particle_before_a_suffix", ", Dr. Jones vd Jr.", {"title": "Dr.", "given": "Jones", "family": "vd", "suffix": "Jr."}, ambiguities=("suffix-or-name",), classification="fix(#892)", notes="the walk looks past the trailing suffix to the particle " "(rules/P6), behind an empty head as behind its twin " "'Smith, Dr. Jones vd Jr.'; #613's post_rules site read " "family 'Jones', suffix 'vd Jr.'"), Case("empty_head_attaches_the_particle_before_a_suffix_part", ", Dr. Jones vd, PhD", {"title": "Dr.", "given": "Jones", "family": "vd", "suffix": "PhD"}, ambiguities=("suffix-or-name",), classification="fix(#892)", notes="a third comma part, which C15's invariant grid does not " "write; #613's post_rules site read family 'Jones', " "suffix \"vd, PhD\""), Case("empty_head_attaches_the_particle_family_first", ", Jane van Jr.", {"given": "Jane", "family": "van", "suffix": "Jr."}, ambiguities=("particle-or-given",), policy=Policy(name_order=FAMILY_FIRST), classification="fix(#892)", notes="pinned field by field because C15's invariant compares " "the empty head with its twin, and the two now share " "assign's one attachment site, so a defect there would " "move both; master read the same"), Case("empty_head_never_given_particle_keeps_the_suffix_tail", ", de PhD vd", {"given": "de", "suffix": "PhD vd"}, ambiguities=(), classification="fix(#892)", notes="a never-given particle opening the part declines the " "attachment (P1's guard at assign's site), as in the twin " "'Smith, de PhD vd' and in ', de vd'; #613's post_rules " "site, which had no such guard, read family 'vd', suffix " "'PhD', reported (#916 review)"), Case("particles_side_by_side_in_a_credential_part_stay_credentials", "De La Cruz, DO DO DO", {"family": "De La Cruz", "suffix": "DO DO DO"}, classification="fix(#613)", notes="decision (2) of #613: the part is bound before group's chain can join the particles; b547c247 read given 'DO', middle 'DO DO'"), Case("the_chain_cannot_take_a_title_from_a_credential_part", "De La Cruz, MA DO Mr.", {"title": "Mr.", "family": "De La Cruz", "suffix": "MA DO"}, classification="fix(#613)", notes="decision (3) of #613: b547c247's chain joined 'DO Mr.' and read given 'MA', middle 'DO Mr.'"), Case("a_suffixed_family_before_a_comma_takes_a_lone_given_initial", "Smith Jr., V", {"given": "V", "family": "Smith", "suffix": "Jr."}, ambiguities=(), classification="fix(#613)", notes="decision (4) of #613, as 'Smith Jr., John' reads; b547c247 read given 'Smith', suffix 'Jr., V'"), Case("a_split_credential_around_a_word_is_not_merged", "John Smith, Ph. Ma D.", {"title": "Ph.", "given": "Ma", "middle": "D.", "family": "John Smith"}, ambiguities=(), classification="fix(#613)", notes="garbage in, accepted under #613: the merge takes adjacent halves only; b547c247 read suffix 'Ph. Ma D.'"), Case("a_listed_member_with_a_period_reads_as_without_one", "John Smith, Ma.", {"given": "John", "family": "Smith", "suffix": "Ma."}, ambiguities=("suffix-or-name",), classification="fix(#613)", notes="accepted under #613: the count licenses the listed member before the lone-piece route's period inference, as 'John Smith, Ma' reads; b547c247 read title 'Ma.'"), Case("a_period_written_member_behind_a_credential_reports_the_flip", "John Smith, PhD Ma.", {"given": "John", "family": "Smith", "suffix": "PhD Ma."}, ambiguities=("suffix-or-name",), classification="fix(#613)", notes="accepted under #613: one report, the count's flip over the part, as 'John Smith, PhD Ma' reports; b547c247 reported the anchored pick"), Case("a_single_letter_connective_is_no_title_chain", "John Smith, Mr. y", {"title": "Mr.", "given": "y", "family": "John Smith"}, ambiguities=(), classification="fix(#613)", notes="rules/P3's single-letter connective is left as it stands by the comma decision's title join (`_comma._title_chains` in #613, `_pieces.join_connectives` since #617), so it is a name word and the part the listing form's (mutation measured in #613's PR review)"), Case("a_periodless_name_word_carries_the_contrast_a_period_does_not", "Chas. SMITH JONES, XYZ", {"given": "XYZ", "family": "Chas. SMITH JONES"}, ambiguities=(), classification="fix(#613)", notes="#564's contrast is read off name words written as names in mixed case, and a period-written abbreviation is not one, so 'XYZ' is no credential here and the comma keeps the listing form (mutation measured in #613's PR review)"), Case("a_class_member_beside_a_name_word_keeps_a_cjk_name_whole", "김민준 박, MA Jones", {"given": "MA", "middle": "Jones", "family": "김민준 박"}, ambiguities=("suffix-or-name",), classification="fix(#613)", tolerated=True, notes="a name word after the comma keeps the listing form, so the comma's decision leaves the name whole; a mutation of the hand copy script_segment then asked divided it behind the family comma, family '김 민준 박' (#613's PR review; the stage asks decide itself since its /simplify)"), Case("a_cjk_honorific_behind_a_one_word_comma_peels_before_credentials", "김민준씨, MA PhD", {"family": "김민준", "suffix": "씨, MA PhD"}, classification="fix(#613)", tolerated=True, notes="rules/W3 tolerated: peels as '김민준씨, PhD' does; b547c247 kept family '김민준씨'"), Case("an_unlisted_caps_credential_divides_a_cjk_name_as_decide_reads_it", "Smith 김민준씨, XYZ", {"given": "Smith", "middle": "김", "family": "민준", "suffix": "씨, XYZ"}, ambiguities=("suffix-or-name",), classification="fix(#613)", tolerated=True, notes="rules/W3 tolerated: script_segment asks the comma's " "decision itself (#613's /simplify), which reads 'XYZ' " "as a credential behind a whole name, so the name " "divides and the honorific peels, as b547c247 read it; " "the hand copy it replaced had left it whole"), Case("an_unlisted_caps_credential_peels_a_glued_honorific", "John 田中さん, XYZ", {"given": "John", "family": "田中", "suffix": "さん, XYZ"}, ambiguities=("suffix-or-name",), classification="fix(#613)", tolerated=True, notes="rules/W3 tolerated: the split-off follows the comma's " "own decision, a credential behind a whole name, as " "b547c247 read it; the hand copy #613's /simplify " "replaced had kept 'さん' glued"), Case("an_unlisted_dotted_credential_divides_a_cjk_name", "김민준 박, X.Y.Z.", {"given": "민준", "middle": "박", "family": "김", "suffix": "X.Y.Z."}, ambiguities=(), classification="fix(#613)", tolerated=True, notes="rules/W3 tolerated: decide reads the dotted shape as " "a credential behind two name words, and the division " "follows it, as b547c247 read it"), Case("a_title_and_one_cjk_name_word_before_a_credential", "Dr 김민준, MA", {"title": "Dr", "given": "민준", "family": "김", "suffix": "MA"}, classification="fix(#613)", tolerated=True, notes="rules/W3 tolerated, decided with Derek (#613's " "/simplify): script_segment asks the comma's decision, " "which licenses 'MA' by the capitals lean behind a title " "and a name word and reads a suffix comma, so the name " "divides as it would with no comma. b547c247 kept " "'김민준' whole, H1 then making it the family"), Case("an_empty_head_credential_run_reports_once", ", Jane PhD do MA", {"given": "Jane", "suffix": "PhD do MA"}, ambiguities=("suffix-or-name",), classification="fix(#613)", notes="the walk stops short of 'do', read as the credential, so " "P6 finds no particle run to attach (#613's PR " "simplification pass found a bogus third fork on 'MA' from " "post_rules' empty-head site, removed in #892). One " "report since #785, the capitals settling 'MA' (rules/S6); " "b547c247 gave two"), Case("a_glued_honorific_in_a_postnominal_part_stays_glued", "John Smith, Dr. J.씨", {"title": "Dr.", "given": "John", "family": "Smith", "suffix": "J.씨"}, ambiguities=(), classification="fix(#613)", tolerated=True, notes="rules/W3 tolerated: the comma's decision reads 'Dr. " "J.씨' as a title and a credential behind a whole name, " "so the peel stays in the part before the comma and the " "part reads as 'John Smith, J.씨' does; b547c247's hand " "copy peeled the 씨 and read given 'J.', family 'John " "Smith'"), Case("a_suffix_beside_a_connective_surname_is_no_name_word", "Vega y Lopez Jr., PhD Jones", {"given": "Vega", "middle": "y", "family": "Lopez", "suffix": "Jr., PhD Jones"}, ambiguities=("suffix-or-name",), classification="fix(#603)", notes="the count holds units with a word that is not a suffix " "word, so 'Jr.' adds nothing to the three of 'Vega y " "Lopez' (#575's count, #613). 2.3.0 read given 'PhD', " "middle 'Jones', suffix 'Jr.'"), Case("only_the_first_suffix_word_can_open_the_comma_part", "Smith, MA PhD Jones", {"given": "MA", "family": "Smith", "suffix": "PhD Jones"}, ambiguities=("suffix-or-name", "suffix-or-name"), notes="#603's opener is the part's FIRST suffix word with only " "titles in front of it (rules/C13): 'MA', a member of " "the ambiguous class, opens nothing and ends the leading " "run, so the 'PhD' behind it opens nothing either and the " "part is the walk's. Letting a later credential open it " "reads suffix 'MA PhD Jones'"), Case("a_misplaced_generation_opens_the_comma_part", "Smith, Jr. John", {"family": "Smith", "suffix": "Jr. John"}, ambiguities=("suffix-or-name",), classification="fix(#603)", notes="decisions/C13's accepted cost: a generational word " "starts S8's run, so it opens the part and 'John' is " "absorbed, one word before the comma reading the whole " "part as suffixes (Derek). 2.3.0 read title 'Jr.', given " "'John'"), Case("a_connective_led_title_past_the_second_comma_is_a_title", "John Smith, Jr., and Secretary of State", {"title": "and Secretary of State", "given": "John", "family": "Smith", "suffix": "Jr."}, ambiguities=("comma-structure",), classification="fix(#603)", notes="C2's tail title read off the piece group's connective join " "built (rules/C2): the join tags the piece a title " "though its first token, 'and', is no title word, so the " "test is is_title_piece's whole and not a first-token " "check (the /simplify review found a first-token check " "read suffix 'Jr., and Secretary of State' and no test " "saw it). The flag stays: segment asks word by word"), Case("a_part_of_titles_behind_a_credential_was_already_postnominal", "Eric H. Holder, Jr. Attorney General", {"title": "Attorney General", "given": "Eric", "middle": "H.", "family": "Holder", "suffix": "Jr."}, notes="the shape #603 generalizes: a part after the comma " "holding no name word fixed no family boundary, so the " "name before it keeps its positional read and the part " "reads as its titles and suffixes. Unmoved by #603, which " "extends that reading to a part a credential opens " "whatever stands behind it ('Eric H. Holder, Jr. Chief " "Justice', 'justice' being no title word, reads suffix " "'Jr. Justice', title 'Chief')"), Case("family_comma_lone_suffix_piece", "Andrews, M.D.", {"family": "Andrews", "suffix": "M.D."}, classification="fix(comma-family)", notes="v1 made the lone post-comma strict-suffix piece the " "given; 2.0 routes it to suffix (same family as the " "'Smith, Dr.' row)"), Case("family_comma_suffix_run_renders_unjoined", "Smith, MD PhD", {"family": "Smith", "suffix": "MD PhD"}, classification="fix(comma-family)", notes="a space-separated post-nominal run after a family " "comma RENDERED with a comma the input never had " "('MD, PhD'), because the one-entry join was asked by " "segment index and this segment is not a tail one. The " "full-name 'John Smith, MD PhD' has rendered 'MD PhD' " "since 1.4.0, and #429 brought this form into line with " "it. The roles were already right after #428; this was " "its remaining half"), Case("period_joined_ambiguous_chunk", "John Doe, Msc.Ed.", {"given": "John", "family": "Doe", "suffix": "Msc.Ed."}, notes="chunk-level suffix membership is v1's is_suffix: bare " "ambiguous acronyms count within period-joined tokens"), Case("suffix_comma_split_phd", "John Smith, Ph. D.", {"given": "John", "family": "Smith", "suffix": "Ph. D."}, notes="the adjacent Ph./D. pair counts as one suffix unit in " "the suffix-comma detection (v1 fix_phd parity). The " "pair leads the run here; the row below is where it does " "not"), Case("suffix_comma_split_phd_after_another_suffix", "John Smith, Jr. Ph. D.", {"given": "John", "family": "Smith", "suffix": "Jr. Ph. D."}, classification="fix(credential-pair-order)", notes="the pair is merged wherever it sits in the run, not " "only at its head, and the row above cannot say so -- " "there the pair IS the head. Restricted to position 0 " "the merge never fires, 'D.' is suffix vocabulary in no " "lexicon, and the run stops being wholly suffix: this " "reads as a FAMILY comma instead, family 'John Smith' / " "title 'Jr.' / suffix 'Ph. D.'. Pinned at the segment " "stage too (test_segment.test_the_credential_pair_" "merges_anywhere_in_the_run), because the neighbouring " "input 'John Smith, MD, Jr. Ph. D.' keeps every field " "under that break and gains only a comma-structure " "ambiguity, so fields alone do not catch it. Measured: " "1.4.0 gave suffix 'Ph. D., Jr.' -- fix_phd EXTRACTED " "the pair pre-parse and re-appended it, reordering the " "tail, where 2.0 renders it as written"), Case("tail_segment_entry_space_joined", "John Smith, V MD", {"given": "John", "family": "Smith", "suffix": "V MD"}, notes="v1 renders each tail comma segment as ONE suffix " "entry; words within an entry space-join via the " "'joined' tag"), Case("inline_suffix_then_comma_suffix", "John Smith Jr., PhD", {"given": "John", "family": "Smith", "suffix": "Jr., PhD"}, classification="parity", notes="shape 3's optional inline suffix standing WITH a comma " "suffix, which no other row writes: the two compose " "rather than one displacing the other. C1 decides on " "the part after the comma alone -- wholly suffix words, " "more than one word before it -- so the trailing-suffix " "mode fires with 'Jr.' already inside the name part, " "and the written comma survives between the pieces. " "'John Smith, PhD' is the comma half alone and " "'John Smith Jr.' the inline half", shape=3), Case("maiden_delimiters_win_when_shared", 'Baker (Johnson), Jenny', {"given": "Jenny", "family": "Baker", "maiden": "Johnson"}, policy=Policy(maiden_delimiters=frozenset({("(", ")")})), notes="listing a pair in maiden_delimiters drops it from the " "effective nickname set (maiden wins, 2026-07-19) -- the " "one-liner replaces the bucket-move idiom. The facade " "runner reaches this row by making that same move, so " "it agrees. What v1 does NOT agree on is a pair left in " "BOTH buckets, which it gives to nickname; that spelling " "is a different config, not this row, and is pinned in " "test_config_shim"), # #269 follow-up: Arabic-script bound given names, mirroring the # Latin transliterations' behavior (probed live 2026-07-19: bound # join fires only with 3+ tokens, eats the NEXT token into given; # two-token kunya stays split -- 'أبو مازن' pinned in test_locales; # non-leading أبو still prefix-chains onto family). Case("arabic_bound_given_abd", 'عبد الرحمن محمد', {"given": "عبد الرحمن", "family": "محمد"}, classification="feat(#269)", notes="script twin of 'Abdul Rahman Mohammed'; عبد was the " "missing bound entry -- 1.x split it into given عبد + " "middle الرحمن"), Case("arabic_bound_given_kunya", 'أبو بكر أحمد', {"given": "أبو بكر", "family": "أحمد"}, classification="feat(#269)"), Case("arabic_bound_given_kunya_hamzaless", 'ابو بكر احمد', {"given": "ابو بكر", "family": "احمد"}, classification="feat(#269)", notes="both kunya spellings ship, like the أبو/ابو prefix pair"), Case("arabic_bound_given_umm", 'أم كلثوم إبراهيم', {"given": "أم كلثوم", "family": "إبراهيم"}, classification="feat(#269)"), # #273: typographic nickname delimiters ship as defaults -- one row # per new pair; expectations verified live 2026-07-19 before the # pairs were added to DEFAULT_NICKNAME_DELIMITERS. Case("smart_double_quotes_nickname", 'John “Jack” Kennedy', {"given": "John", "family": "Kennedy", "nickname": "Jack"}, classification="feat(#273)"), Case("low_high_quotes_nickname", 'Hans „Hansi“ Müller', {"given": "Hans", "family": "Müller", "nickname": "Hansi"}, classification="feat(#273)", notes="the closing '“' doubles as the English pair's opener; " "no spurious unbalanced-delimiter ambiguity (pinned in " "test_extract)"), Case("guillemets_nickname_inner_spaces", 'Jean « Petit » Dupont', {"given": "Jean", "family": "Dupont", "nickname": "Petit"}, classification="feat(#273)", notes="French spacing: inner padding is trimmed from the " "extracted nickname"), Case("reversed_guillemets_nickname", 'Hans »Hansi« Müller', {"given": "Hans", "family": "Müller", "nickname": "Hansi"}, classification="feat(#273)"), Case("swedish_right_quotes_nickname", 'Anna ”Ann” Larsson', {"given": "Anna", "family": "Larsson", "nickname": "Ann"}, classification="feat(#273)"), Case("cjk_corner_bracket_nickname", '山田「タロ」太郎', {"family": "山田", "given": "太郎", "nickname": "タロ"}, classification="feat(#273) + fix(#271)", notes="extraction also splits the unspaced remainder -- the " "masked region acts as a token boundary. Both name " "pieces are then wholly Han, so script_orders reads " "them family-first; the nickname's kana is outside the " "name pieces and does not enter that test"), Case("cjk_white_corner_bracket_nickname", '田中『ハナ』花子', {"family": "田中", "given": "花子", "nickname": "ハナ"}, classification="feat(#273) + fix(#271)"), Case("cjk_halfwidth_corner_bracket_nickname", '山田 「タロー」 タロウ', {"family": "山田", "given": "タロウ", "nickname": "タロー"}, classification="fix(#597)", notes="the halfwidth corner brackets U+FF62/U+FF63 are the " "same punctuation as 「」 in JIS X 0201's narrower " "encoding, the data halfwidth katakana comes from (#594). " "2.3.0 and every earlier release left the clause in the " "name as a middle 「タロー」 and read given 山田, the " "halfwidth kana being unclassified until #594. Since " "#594 the bracket alone kept the name from being wholly " "East Asian script; extracted, the rest is kanji plus " "katakana and takes the kana license"), Case("cjk_halfwidth_corner_bracket_unspaced_nickname", '山田「タロー」太郎', {"family": "山田", "given": "太郎", "nickname": "タロー"}, classification="fix(#597)", notes="the halfwidth twin of cjk_corner_bracket_nickname: the " "extracted clause is a token boundary, so the unspaced " "remainder divides there and both pieces are Han"), Case("latin_halfwidth_corner_bracket_nickname", 'John 「Jack」 Smith', {"given": "John", "family": "Smith", "nickname": "Jack"}, classification="fix(#597)", notes="a delimiter pair is not script-gated: the halfwidth " "brackets lift a Latin nickname exactly as 「」 does in " "'John 「Jack」 Smith'. 2.3.0 read middle 「Jack」"), Case("fullwidth_paren_nickname", 'John (Jack) Kennedy', {"given": "John", "family": "Kennedy", "nickname": "Jack"}, classification="feat(#273)"), Case("curly_apostrophe_stays_literal", 'Sean O’Connor', {"given": "Sean", "family": "O’Connor"}, notes="U+2019 is the typographic apostrophe; curly single " "quotes are deliberately NOT delimiters (#273 excludes " "them)"), Case("family_segment_trailing_suffix", "Smith Jr., John", {"given": "John", "family": "Smith", "suffix": "Jr."}, notes="v1: the family part may have suffixes in it " "(parser.py:1368); the first piece is always the family " "(pinned live 2026-07-17). Shape 2's pre-comma " "[Suffix] slot", shape=2), Case("family_segment_multiple_suffixes", "Smith Jr. MD, John", {"given": "John", "family": "Smith", "suffix": "Jr. MD"}, classification="fix(#436/#437)", notes="the pre-comma segment's post-nominal run, written " "with spaces and rendered with them since #436. It " "read 'Jr., MD' at 1.4.0 and through 2.2.0, the " "one-entry join being asked by segment index and this " "run standing in segment 0. The corpus twin is " "'Washington Jr. MD, Franklin'"), Case("family_segment_particle_chain_suffix", "de la Vega III, Juan", {"given": "Juan", "family": "de la Vega", "suffix": "III"}), Case("interior_periods_block_vocab", "Smith, J.R.", {"given": "J.R.", "family": "Smith"}, ambiguities=("suffix-or-name",), notes="v1's lc() keeps interior periods: 'J.R.' is not the " "suffix word 'jr' (pinned live 2026-07-17), so this " "FIELD reading is unchanged clear back to 1.4.0. #516: " "two unlisted single-letter chunks join the ambiguous " "class by shape all the same, the same fork " "'Smith, A.B.' reports -- the class is considered and " "declined (one word before the comma is never enough), " "and the consideration is what reports, exactly as the " "trailing peel's own declined picks always have"), Case("dotted_acronym_suffix", "John Smith M.D.", {"given": "John", "family": "Smith", "suffix": "M.D."}, notes="suffix-ACRONYM membership alone strips periods (v1 " "is_suffix parity)"), Case("nickname_rule_counts_whole_segment", "Xyz. (Bud) Smith", {"title": "Xyz.", "family": "Smith", "nickname": "Bud"}, classification="fix(#410)", notes="v1's lone-piece nickname rule counts the segment " "BEFORE title peeling (parser.py:1285, pinned live " "2026-07-17), which is what this row pins and what " "#410 does not change. The FIELD moved: a nickname is " "not a further name word, so the one name word behind " "the title is the family. 1.4.0 read first 'Smith'"), Case("suffix_comma_decided_by_first_segment", "Dr. John P. Doe-Ray, CLU, CFP, LUTC", {"title": "Dr.", "given": "John", "middle": "P.", "family": "Doe-Ray", "suffix": "CLU, CFP, LUTC"}, ambiguities=("comma-structure",), notes="only parts[1] decides the suffix-comma structure " "(v1 parser.py:1318); 'lutc' is not in the vocabulary " "but rides along (v1 parity, pinned live 2026-07-16). " "Deliberately the COMPOUND shape-3 exemplar: it is the " "only row filling Title, Middle and the repeated " "[, Suffix] at once, and it carries the corpus's " "hyphenated family besides, so a diff on it is not " "attributable to any one slot -- read it as the " "notation's fullest form rather than as a witness for " "whichever slot the failure seems to be about", shape=3), Case("suffix_comma_nonsuffix_tail_flagged", "John Smith, MD, Xyzzy", {"given": "John", "family": "Smith", "suffix": "MD, Xyzzy"}, ambiguities=("comma-structure",), notes="the unrecognized tail is consumed best-effort with the " "flag (v1 consumed it silently)"), Case("period_joined_titles", "Lt.Gov. John Doe", {"title": "Lt.Gov.", "given": "John", "family": "Doe"}, notes="v1 derived-title rule: ANY period chunk being a title " "makes the token a title (pinned live 2026-07-16). " "Title wins before #516's shape verdict is even asked, " "so this stays a protected control for the switch, not " "just for the chunk rule", shape=1), Case("period_joined_suffixes", "John Doe JD.CPA", {"given": "John", "family": "Doe", "suffix": "JD.CPA"}, notes="the multi-character chunk match 'jd'/'cpa' still wins " "over #516's shape verdict, which is asked only where " "classify's own fall-through has not already tagged " "'vocab:suffix' -- unchanged either side of the switch", shape=1), Case("period_joined_any_rule", "Mr.Smith", {"title": "Mr.Smith"}, notes="the ANY rule is deliberate v1 parity: one title chunk " "claims the whole token"), Case("doubled_comma_suffix", "Doe,, Jr.", {"family": "Doe", "suffix": "Jr."}, notes="the EMPTY given segment keeps its position: 'Jr.' is a " "tail suffix, not a lone post-comma title (v1 parity, " "pinned live 2026-07-16)"), Case("doubled_comma_given_kept", "Doe, John,, Jr.", {"given": "John", "family": "Doe", "suffix": "Jr."}), Case("single_trailing_comma_cosmetic", "John,", {"given": "John"}, ambiguities=("given-or-family",), notes="v1 collapse_whitespace strips exactly ONE trailing " "comma before parsing -- and a comma stripped before " "parsing decided nothing, so what is left is one name " "word and O5's convention reports it (#449). Roles " "parity; the flag is #449's"), Case("double_trailing_comma_structural", "Doe,,", {"family": "Doe"}, notes="one trailing comma is cosmetic, the second is " "structural: an empty given segment makes this a " "family-comma parse (v1 parity, pinned live 2026-07-16)"), Case("doubled_comma_blocks_suffix_comma", "John Smith,, MD", {"family": "John Smith", "suffix": "MD"}, notes="an empty segment 1 fails the suffix-comma detection " "(v1 parity)"), Case("suffix_delimiter_tail_segment", "Doe, John, RN | CRNA", {"given": "John", "family": "Doe", "suffix": "RN, CRNA"}, policy=_SD, notes="v1 suffix_delimiter parity (#206): the delimiter token " "is dropped from consumed tail segments (pinned live " "2026-07-16)"), Case("suffix_delimiter_detection", "Doe, John RN | CRNA", {"given": "John", "family": "Doe", "suffix": "RN | CRNA"}, policy=_SD, classification="fix(#602)", notes="the delimiter fires only at suffix sites, so in the " "given part the core stays a word -- and since #602 a " "word after the credential is in the run: one entry " "'RN | CRNA', the core unreported, holding no letter or " "digit (rules/A2). Before #602 the stray token kept " "its per-piece walk role, middle '|', suffix 'RN, CRNA' " "(v1 parity, pinned live 2026-07-16 with ' - ')"), Case("suffix_delimiter_suffix_comma", "John Smith, RN | CRNA", {"given": "John", "family": "Smith", "suffix": "RN, CRNA"}, policy=_SD, notes="delimiter transparency in the SUFFIX_COMMA " "determination: the post-comma segment counts as " "all-suffix (v1 parity, pinned live 2026-07-16)"), Case("suffix_delimiter_no_space_core", "John Smith, RN/CRNA", {"given": "John", "family": "Smith", "suffix": "RN/CRNA"}, policy=Policy(extra_suffix_delimiters=frozenset({"/"})), classification="fix(suffix-delimiter-rendering)", notes="v1 split 'RN/CRNA' and rendered 'RN, CRNA'; v2 keeps " "the token whole (anti-#100) with Role.SUFFIX -- role " "assignment matches, rendering differs (migration plan " "deviation 5, release-log classified)"), Case("suffix_delimiter_name_segment_untouched", "Doe, Mary | Kate, RN", {"given": "Mary", "middle": "| Kate", "family": "Doe", "suffix": "RN"}, policy=_SD, notes="a delimiter token in a NAME segment is kept (v1 parity, " "pinned live 2026-07-16)"), Case("suffix_delimiter_core_between_entries_is_a_boundary", "John Doe, MD | PhD | FACS", {"given": "John", "family": "Doe", "suffix": "MD, PhD, FACS"}, policy=_SD, classification="fix(#436/#437)", notes="the DROPPED core half of #436's entry boundary: a " "suffix-comma tail drops the delimiter cores (#206), " "and the entry the writer drew ends at each of them. " "The core is gone before the render sees it, so the " "boundary is read off the dropped indices rather than " "off a surviving token. Parity at every baseline"), Case("suffix_delimiter_core_parts_a_connective_join", "Doe, John, PhD | and MD", {"given": "John", "family": "Doe", "suffix": "PhD, and MD"}, policy=_SD, ambiguities=("comma-structure",), notes="a declared core parts a tail as a comma would, so a " "connective beside it joins nothing across it and the " "core is dropped (rules/C10, #549) -- 1.4.0's " "expand_suffix_delimiter reading, where 2.0.0 through " "2.3.0 merged the core into the joined piece and " "rendered 'PhD - and MD' under a declared ' - '"), Case("suffix_delimiter_lone_core_part_is_kept", "Smith, John, PhD, |", {"given": "John", "family": "Smith", "suffix": "PhD, |"}, policy=_SD, notes="a tail part that is nothing but its one core keeps it " "(v1 expand() split within a part and never erased a " "lone part, #206): the core is cut and dropped only " "where the part holds something else. Pairs with " "suffix_delimiter_core_parts_a_connective_join. " "RECORDED NEGATIVE CONTROL: with the one-token guard " "removed this reads suffix 'PhD' (review of #549, " "which found no test pinned the guard before or after " "the change)"), Case("suffix_delimiter_core_after_a_marker_takes_no_clause", "Smith, John, PhD née | Jones", {"given": "John", "family": "Smith", "suffix": "PhD née, Jones"}, policy=_SD, ambiguities=("comma-structure",), notes="a marker in a part after a suffix comma is an ordinary " "word, core or no core, so it takes no clause (rules/M2, " "#601; rules/C10's maiden-marker Accepted entry). 2.0.0 " "through 2.3.0 read maiden 'Jones', stepping past the core"), Case("suffix_delimiter_core_that_survives_is_a_boundary_too", "Smith, RA | PhD | FACS", {"title": "RA", "given": "|", "family": "Smith", "suffix": "PhD | FACS"}, policy=_SD, classification="fix(#602)", notes="#602 moved this row: the core after 'PhD' is in the " "credential run now, one entry 'PhD | FACS'. The parting " "half it pinned is held by test_post_rules' " "test_a_surviving_name_word_parts_two_entries on a name " "word that survives ('Smith, Jane abd Jones PhD'). As " "pinned under #436/#437: " "the other half, and the one a dropped-core test alone " "would miss. After a FAMILY comma segment 1 is not a " "tail, so nothing drops the cores and they stand " "as ordinary name words between the two post-nominals. " "A run does not span a name word: the separator holds " "whether the core was dropped or kept, which is why " "#436's predicate asks what stands between the two " "rather than only what was dropped. With ' - ' and no " "policy the string reads the same, the built-in hyphen " "being no delimiter after a family comma either (#889)"), Case("semicolon_glued_after_a_whole_name", "John Smith, MD; PhD", {"given": "John", "family": "Smith", "suffix": "MD; PhD"}, classification="fix(semicolon-suffix-delimiter)", notes="the semicolon is a suffix delimiter by default, and one " "glued to a suffix word reads the word as it would read " "without it, so the part is the credential run " "(rules/C10). The word keeps its semicolon, one entry " "'MD; PhD' as written. 1.4.0 through 2.5.0 read given " "'MD;', family 'John Smith', suffix 'PhD'"), Case("semicolon_inside_a_word", "John Smith, MD;PhD", {"given": "John", "family": "Smith", "suffix": "MD;PhD"}, classification="fix(semicolon-suffix-delimiter)", notes="the in-word split a declared delimiter always had " "('RN/CRNA' under '/'), the token kept whole (rules/C10). " "1.4.0 through 2.5.0 read given 'MD;PhD'"), Case("semicolon_standing_alone_is_dropped", "John Smith, MD ; PhD", {"given": "John", "family": "Smith", "suffix": "MD, PhD"}, classification="fix(semicolon-suffix-delimiter)", notes="a delimiter standing alone in a trailing part is " "dropped and its entries render with the comma it " "stands for (rules/C10's Accepted bullet). 1.4.0 and " "2.5.0 read title 'MD', given ';'; the 2.6 cycle " "before this read suffix 'MD ; PhD'"), Case("semicolon_glued_after_one_word", "Smith, MD; PhD", {"family": "Smith", "suffix": "MD; PhD"}, classification="fix(semicolon-suffix-delimiter)", notes="behind one name word the part is the credential run " "as 'Smith, MD PhD' is (rules/C10, behind any head). " "1.4.0 through 2.5.0 read given 'MD;'"), Case("semicolon_glued_after_an_empty_head", ", MD; PhD", {"suffix": "MD; PhD"}, classification="fix(semicolon-suffix-delimiter)", notes="the empty head nameparser/AGENTS.md's comma sweep names, read as " "', MD PhD' is. 1.4.0 through 2.5.0 read given 'MD;'"), Case("semicolon_glued_after_a_nickname_head", "(Bob), MD;", {"nickname": "Bob", "suffix": "MD;"}, classification="fix(semicolon-suffix-delimiter)", notes="a nickname-only head, read as '(Bob), MD' is. 1.4.0 " "through 2.5.0 read given 'MD;'"), Case("semicolon_glued_to_a_name_word_is_the_listing_form", "Smith, John; MD", {"given": "John;", "family": "Smith", "suffix": "MD"}, notes="rules/C10's boundary: the semicolon parts suffix words, " "and a name word glued to one is still a name word, so " "the listing form stands, the semicolon kept in the word"), Case("semicolon_glued_initial_behind_one_word", "Smith, V; MD", {"given": "V;", "family": "Smith", "suffix": "MD"}, notes="rules/C10's Except bullet: behind a head that is not a " "whole name the glued word is read by the strict test, " "as 'Smith, V MD' reads 'V' as the given name"), Case("semicolon_glued_ambiguous_credential", "John Smith, MA; PhD", {"given": "John", "family": "Smith", "suffix": "MA; PhD"}, classification="fix(semicolon-suffix-delimiter)", notes="a word of the ambiguous class with a semicolon glued " "carries the class's tags, so C4's count reads it as 'John " "Smith, MA PhD' does, and reads it the same way " "(rules/C10), settled by its capitals and silent since #785 " "(rules/S6). 1.4.0 through 2.5.0 read given 'MA;'"), Case("semicolon_glued_to_a_split_credential_keeps_the_listing_form", "John Smith, Ph. D.; MD", {"title": "Ph.", "given": "D.;", "family": "John Smith", "suffix": "MD"}, notes="rules/C10's Accepted bullet: 'Ph.' and 'D.' are one " "credential only as a pair written exactly so, and 'D.;' " "is not the second half of one; 'John Smith, Ph. D. MD' " "reads suffix 'Ph. D. MD'"), Case("semicolon_glued_to_a_connective_joins_nothing_across_it", "Smith, Juan y; Maria", {"given": "Juan", "middle": "y; Maria", "family": "Smith"}, notes="a glued word is tagged as its stem, less the tags of a " "word that joins the next: the semicolon stands between " "'y' and 'Maria', so P3 joins nothing across it (rules/" "C10), where 'Smith, Juan y Maria' reads given 'Juan y " "Maria'. RECORDED NEGATIVE CONTROL: with the joining tags " "kept this reads given 'Juan y; Maria'"), Case("semicolon_glued_to_a_trailing_particle", "Berg, Jan van; MD", {"given": "Jan", "middle": "van;", "family": "Berg", "suffix": "MD"}, notes="rules/C10's Accepted bullet: a glued particle joins " "nothing, so P6 does not attach it, where 'Berg, Jan van " "MD' reads family 'van Berg' (decisions/C10). RECORDED " "NEGATIVE CONTROL: with the particle tags kept this " "reads family 'van; Berg'"), Case("semicolon_glued_to_a_particle_joins_nothing_across_it", "Berg, de; Smith", {"given": "de;", "middle": "Smith", "family": "Berg"}, notes="no join reaches across the delimiter (rules/C10's Then), so the " "particle neither chains forward to 'Smith' nor folds it " "into the family. RECORDED NEGATIVE CONTROL: with the " "particle tags kept this reads family 'Berg de; Smith' " "(#871's second review)"), Case("glued_semicolon_beside_a_declared_core_inside_the_word", "Jane Smith, MD/PhD; MBA", {"given": "Jane", "family": "Smith", "suffix": "MD/PhD; MBA"}, policy=Policy(extra_suffix_delimiters=frozenset({"/"})), classification="fix(semicolon-suffix-delimiter)", notes="'MD/PhD;' holds '/' inside and ';' glued at its end: " "the glued one is found whatever order the cores are " "tried in, and the stem 'MD/PhD' splits under '/' " "(rules/C10). Before #871's second review the first core " "found decided, and the reading changed with the hash " "seed (given 'MD/PhD;' on 3 of 8 seeds)"), Case("declared_word_delimiter_is_not_glued", "John Smith, Stand", {"given": "Stand", "family": "John Smith"}, policy=Policy(extra_suffix_delimiters=frozenset({" and "})), notes="a declared core that is a word parts only standing " "alone: 'Stand' holds 'and' and is no 'St' with it glued " "(rules/C10). RECORDED NEGATIVE CONTROL: with every core " "gluing, this reads title 'Stand', given 'John'"), Case("semicolon_glued_to_a_numeral_ending_the_given_part", "Smith, John V;", {"given": "John", "middle": "V;", "family": "Smith"}, notes="rules/C10's Accepted bullet: the trailing numeral is told " "by its letters, which 'V;' does not spell, so it stays a " "name word where 'Smith, John V' reads suffix 'V'"), Case("semicolon_before_the_first_comma_is_a_word", "John Smith MD; PhD", {"given": "John", "middle": "Smith", "family": "MD;", "suffix": "PhD"}, notes="a semicolon where a comma would go BEFORE the first " "comma is rules/T1's separator question, left open by " "the change that made the semicolon a suffix delimiter " "(decisions/C10): pinned so a change to it shows here"), Case("semicolon_beside_a_declared_delimiter", "John Smith, MD ; PhD | FACS", {"given": "John", "family": "Smith", "suffix": "MD, PhD, FACS"}, policy=_SD, classification="fix(semicolon-suffix-delimiter)", notes="a declared delimiter adds to the semicolon and does not " "replace it (rules/C10)"), Case("declared_hyphen_reads_glued", "John Smith, MD- PhD", {"given": "John", "family": "Smith", "suffix": "MD- PhD"}, policy=Policy(extra_suffix_delimiters=frozenset({" - "})), classification="fix(semicolon-suffix-delimiter)", notes="a declared core is read glued, as the semicolon is, where " "the built-in hyphen is the word's own and reads given " "'MD-' (rules/C10, #889); 1.4.0 read given 'MD-' with the " "delimiter declared too"), Case("declared_hyphen_glued_beside_a_semicolon", "Jane Smith, MD-; PhD", {"given": "Jane", "family": "Smith", "suffix": "MD-; PhD"}, policy=Policy(extra_suffix_delimiters=frozenset({" - "})), classification="fix(semicolon-suffix-delimiter)", notes="a declared hyphen and the semicolon glued to one word " "(rules/C10, #889); 1.4.0 read given 'MD-;'"), Case("declared_slash_reads_glued", "John Smith, MD/ PhD", {"given": "John", "family": "Smith", "suffix": "MD/ PhD"}, policy=Policy(extra_suffix_delimiters=frozenset({" / "})), classification="fix(semicolon-suffix-delimiter)", notes="the slash half of declared_hyphen_reads_glued: the " "built-in slash reads given 'MD/' (rules/C10, #889)"), Case("glued_hyphen_after_the_comma_keeps_the_report", "Smith, John, MD -Dr", {"given": "John", "family": "Smith", "suffix": "MD -Dr"}, ambiguities=("comma-structure",), notes="segment reads only the glued cores, so the built-in " "hyphen glued to 'Dr' strips nothing and the part is no " "title; reading the standing-alone set there drops the " "report (#889's review)"), Case("family_comma_transparent_then_core", 'John Doe, MD "Doc" PhD | FACS', {"given": "John", "family": "Doe", "nickname": "Doc", "suffix": "MD PhD, FACS"}, policy=_SD, classification="fix(#436/#437)", notes="both arms of #436's predicate in one run: the nickname " "between MD and PhD renders into another field, so it " "is transparent and the two are one entry, while the " "dropped core between PhD and FACS parts them. A row " "pinning either arm alone passes on a rule that " "collapses the two into one test -- part at every " "dropped index and the suffix reads 'MD, PhD, FACS'; " "part at no dropped index and it reads 'MD PhD FACS'"), Case("comma_extras_become_suffixes", "Smith, John, Extra, Jr.", {"given": "John", "family": "Smith", "suffix": "Extra, Jr."}, ambiguities=("comma-structure",), notes="post-comma segments land in suffix even when not " "suffix-shaped; the ambiguity flags the guess (v1 " "parity, pinned live 2026-07-13). Shape 2's repeated " "[, Suffix] slot, the double trailing suffix", shape=2), Case("delavega", "Dr. Juan de la Vega III", {"title": "Dr.", "given": "Juan", "family": "de la Vega", "suffix": "III"}, notes="shape 1's Title and trailing Suffix slots paired, the " "arrangement written end to end", shape=1), Case("prefix_chain_to_end", "Juan de la Vega Martinez", {"given": "Juan", "family": "de la Vega Martinez"}), Case("van_johnson", "Van Johnson", {"given": "Van", "family": "Johnson"}, ambiguities=("particle-or-given",), notes="v2 surfaces #121's irreducible ambiguity"), Case("family_comma_particles", "de la Vega, Juan", {"given": "Juan", "family": "de la Vega"}, notes="shape 2's particle-bearing family, before the comma", shape=2), Case("paren_suffix_escapes_nickname", "Andrew Perkins (MBA)", {"given": "Andrew", "family": "Perkins", "suffix": "MBA"}, notes="v1 parse_nicknames: suffix-shaped delimited content is " "left in place for normal parsing (pinned live " "2026-07-17)"), Case("paren_period_escapes_nickname", "Andrew Perkins (Ret.)", {"given": "Andrew", "family": "Perkins", "suffix": "Ret."}), # rules/S13 (#810): a member of the ambiguous class alone in the # brackets after the name, settled by its writing. Each row is # paired with a control its writing or its position does not settle. Case("bracket_member_in_capitals_is_the_suffix", "John Smith (MA)", {"given": "John", "family": "Smith", "suffix": "MA"}, notes="capitals in a mixed-case name settle it; 2.0.0 through " "2.6.0 read nickname 'MA', reported"), Case("bracket_member_in_title_case_stays_the_nickname", "John Smith (Ed)", {"given": "John", "family": "Smith", "nickname": "Ed"}, classification="fix(#810)", ambiguities=("suffix-or-nickname",), notes="Title case leans to the name (rules/S6); Ed is " "Edward's nickname far more often than a credential"), Case("bracket_member_with_a_period_inside_is_the_suffix", "John Smith (M.A)", {"given": "John", "family": "Smith", "suffix": "M.A"}, notes="the suffix, not the bare reading: bare, M.A reads as " "paired initials (rules/S3)"), Case("bracket_member_with_a_period_inside_in_one_case", "JOHN SMITH (M.A)", {"given": "JOHN", "family": "SMITH", "suffix": "M.A"}, notes="the period settles it whatever the name's case"), Case("bracket_member_in_a_one_case_name_stays_the_nickname", "JOHN SMITH (MA)", {"given": "JOHN", "family": "SMITH", "nickname": "MA"}, classification="fix(#810)", ambiguities=("suffix-or-nickname",), notes="capitals settle nothing in a name written in one case; " "1.4.0 read suffix 'MA'"), Case("bracket_member_in_a_lower_case_name_stays_the_nickname", "john smith (MA)", {"given": "john", "family": "smith", "nickname": "MA"}, classification="fix(#810)", ambiguities=("suffix-or-nickname",), notes="the case asked is the name's own words', never the " "clause's, so this differs from bare 'john smith MA'; " "1.4.0 read suffix 'MA'"), Case("bracket_member_after_a_one_case_name_with_a_nickname", "JOHN SMITH (Bob) (MA)", {"given": "JOHN", "family": "SMITH", "nickname": "Bob MA"}, classification="fix(#810)", ambiguities=("suffix-or-nickname",), notes="another clause's case is no name's: 'Bob' does not " "make the name mixed; 1.4.0 read suffix 'MA'"), Case("bracket_member_jd_is_the_suffix", "Andrew Perkins (JD)", {"given": "Andrew", "family": "Perkins", "suffix": "JD"}, classification="fix(#810)", notes="1.4.0 through 2.6.0 read nickname 'JD'"), Case("bracket_member_inside_the_name_stays_the_nickname", "Jeffrey (JD) Bricken", {"given": "Jeffrey", "family": "Bricken", "nickname": "JD"}, classification="fix(#810)", ambiguities=("suffix-or-nickname",), notes="a name word follows: let out, JD would be a middle name"), Case("bracket_member_with_a_period_inside_the_name", "Jean (M.A) Smith", {"given": "Jean", "family": "Smith", "nickname": "M.A"}, classification="fix(#810)", ambiguities=("suffix-or-nickname",), notes="inside the name however it is written, and reported as " "a capitals member is; 1.4.0 read middle 'M.A'"), Case("bracket_member_before_a_family_comma_stays_the_nickname", "Smith (MA), John", {"given": "John", "family": "Smith", "nickname": "MA"}, classification="fix(#810)", ambiguities=("suffix-or-nickname",), notes="the given name after the comma is a name word after " "it; 1.4.0 read suffix 'MA'"), Case("bracket_member_after_a_family_comma", "Smith, John (MA)", {"given": "John", "family": "Smith", "suffix": "MA"}), Case("bracket_member_before_a_suffix_comma", "John Smith (MA), PhD", {"given": "John", "family": "Smith", "suffix": "MA, PhD"}, notes="a suffix after the clause is no name word"), Case("bracket_member_before_a_maiden_clause", "John Smith (MA) née Jones", {"given": "John", "family": "Smith", "suffix": "MA", "maiden": "Jones"}, classification="fix(#810)", notes="a maiden clause after it is no name word, and stays the " "maiden name, where its twin's credential run takes it " "(rules/S13's Accepted); 1.4.0 read middle 'Smith MA " "née', last 'Jones'"), Case("bracket_member_settled_by_another_in_a_one_case_name", "JOHN SMITH (m.a) (MA)", {"given": "JOHN", "family": "SMITH", "suffix": "m.a", "nickname": "MA"}, classification="fix(#810)", ambiguities=("suffix-or-nickname",), notes="the case is asked once, of the name's own words without " "either clause, so 'MA' stays the nickname where its twin " "'JOHN SMITH (mba) (MA)' makes the name mixed " "(rules/S13's Accepted)"), Case("bracket_member_settled_by_another_in_a_lower_case_name", "john smith (M.A) (MA)", {"given": "john", "family": "smith", "suffix": "M.A", "nickname": "MA"}, classification="fix(#810)", ambiguities=("suffix-or-nickname",), notes="as the row above; the twin 'john smith (MBA) (MA)' " "reads suffix 'MBA MA'"), Case("bracket_member_past_the_second_comma", "Smith, John, Jr. (M.A)", {"given": "John", "family": "Smith", "suffix": "Jr. M.A"}, classification="fix(#810)", notes="no comma-structure report, as its twin '(MBA)' draws " "none: the part is tested while the clause is still a " "nickname, and 'Jr.' alone is a suffix"), Case("bracket_member_past_the_second_comma_after_a_dotted_shape", "Smith, John, X.Y.Z. (MA)", {"given": "John", "family": "Smith", "suffix": "X.Y.Z. MA"}, classification="fix(#810)", notes="no report, where its twin '(MBA)' draws comma-structure: " "the part is tested while the clause is a nickname, and " "'X.Y.Z.' alone is a run of shape-admitted words " "(rules/S13's Accepted)"), Case("bracket_member_beside_a_dropped_character", "John Smith (MA \N{GRINNING FACE})", {"given": "John", "family": "Smith", "nickname": "MA"}, classification="fix(#810)", ambiguities=("suffix-or-nickname",), notes="not the member alone, as its twin, the same clause " "with PhD, is no suffix-shaped clause"), Case("bracket_member_before_a_title_case_member", "John Smith (MA) Ma", {"given": "John", "middle": "Smith", "family": "Ma", "nickname": "MA"}, classification="fix(#810)", ambiguities=("suffix-or-name", "suffix-or-nickname"), notes="read on the nickname reading, 'Ma' is a name word after " "the clause, where the twin's credential run would take " "it (rules/S13's Except)"), Case("bracket_member_with_a_leading_period", "John Smith (.ma)", {"given": "John", "family": "Smith", "nickname": ".ma"}, classification="fix(#810)", ambiguities=("suffix-or-nickname",), notes="a period opening the word is no period inside it"), Case("bracket_member_in_a_name_of_no_word", "Dr. (MA)", {"title": "Dr.", "nickname": "MA"}, classification="fix(#810)", ambiguities=("suffix-or-nickname",), notes="no name word, so no clause after the name; the twin " "'Dr. (MBA)' reads given 'Dr.'"), Case("bracket_member_in_quotes", 'John Smith "MA"', {"given": "John", "family": "Smith", "suffix": "MA"}, notes="every default nickname pair escapes alike (rules/S1)"), Case("bracket_member_after_a_lone_name_word", "Jack (MA)", {"family": "Jack", "suffix": "MA"}, classification="fix(#810)", notes="the word stays N3's family name, read beside the clause " "as a nickname, where its twin 'Jack (PhD)' reads given " "'Jack' (rules/S13's Accepted); 1.4.0 read last 'MA', " "2.0.0 through 2.6.0 family 'Jack', nickname 'MA'"), Case("bracket_member_with_a_period_after_a_lone_name_word", "Jack (M.A)", {"family": "Jack", "suffix": "M.A"}, classification="fix(#810)", notes="as the row above; bare 'Jack M.A' reads family 'M.A'; " "2.0.0 through 2.6.0 read family 'Jack', nickname 'M.A'"), Case("bracket_member_before_a_shape_read_suffix", "John Smith (MA) Ph. D.", {"given": "John", "family": "Smith", "suffix": "MA Ph. D."}, classification="fix(#810)", notes="a suffix after the clause is no name word whatever " "spelling made it one; the first draft asked the " "suffix tag, which the split Ph. D. does not carry"), Case("bracket_member_before_a_numeral", "John Smith (MA) VI", {"given": "John", "family": "Smith", "suffix": "MA VI"}, classification="fix(#810)", ambiguities=("suffix-or-name",), notes="VI is read by shape, not listed (rules/S10)"), Case("bracket_member_before_a_trailing_title", "John Smith (MA) Dr.", {"title": "Dr.", "given": "John", "family": "Smith", "suffix": "MA"}, classification="fix(#810)", notes="a title after the clause is no name word (rules/H5)"), Case("nickname_quotes", 'John "Jack" Kennedy', {"given": "John", "family": "Kennedy", "nickname": "Jack"}, notes="shape 1's double-quoted Nickname slot, which is the " "spelling its notation writes", shape=1), Case("nickname_parens", "John (Jack) Kennedy", {"given": "John", "family": "Kennedy", "nickname": "Jack"}), Case("sir_bob", "Sir Bob Andrew Dole", {"title": "Sir", "given": "Bob", "middle": "Andrew", "family": "Dole"}, notes="shape 1's first Middle slot; middle_run_at_two_words " "below is the second", shape=1), Case("middle_run_at_two_words", "John Jack Andrew Kennedy", {"given": "John", "middle": "Jack Andrew", "family": "Kennedy"}, classification="parity", notes="shape 1's SECOND Middle slot, which sir_bob above " "leaves unwritten: everything standing between the " "given name and the family is middle, at any arity, and " "the pieces render space-joined. The row that fails if " "the middle run is ever capped at one word", shape=1), Case("family_comma_paren_nickname", "Kennedy, John (Jack)", {"given": "John", "family": "Kennedy", "nickname": "Jack"}, classification="parity", notes="shape 2's (Nickname) slot: the clause is lifted out " "before the comma structure is read, so what reaches " "C1 is the bare 'Kennedy, John' and the listing form " "still wins. nickname_parens above is the same clause " "in the medial position of shape 1", shape=2), Case("long_title", "President of the United States Barack Obama", {"title": "President of the United States", "given": "Barack", "family": "Obama"}), Case("secretary", "The Secretary of State Hillary Clinton", {"title": "The Secretary of State", "given": "Hillary", "family": "Clinton"}), Case("comma_middle_initial", "Doe, John A.", {"given": "John", "middle": "A.", "family": "Doe"}, notes="shape 2's post-comma Middle slot, in the form it is " "usually written after a family comma -- an initial", shape=2), Case("single", "John", {"given": "John"}, ambiguities=("given-or-family",), notes="the plainest O5 shape there is: nothing decides one " "name word, so the convention picks the field and says " "so (#449). Roles parity; the flag is #449's"), Case("title_only", "Dr.", {"title": "Dr."}, notes="rules/H4's boundary as well as a shape row: a LONE " "title word demotes nothing, so no reading was chosen " "and nothing is reported. The title-vs-given-name " "collision on a word like `Baron` is #348's"), Case("double_comma_suffix", "Smith, John, Jr.", {"given": "John", "family": "Smith", "suffix": "Jr."}), Case("bound_given_two", "abdul rahman", {"given": "abdul", "family": "rahman"}), Case("bound_given_three", "abdul rahman al-said", {"given": "abdul rahman", "family": "al-said"}), Case("mr_and_mrs", "Mr. and Mrs. John Smith", {"title": "Mr. and Mrs.", "given": "John", "family": "Smith"}), Case("roman_suffix", "John Smith V", {"given": "John", "family": "Smith", "suffix": "V"}, ambiguities=("suffix-or-name",), notes="a trailing single letter is a name part unless it is a " "roman numeral; V/X/I are also ordinary middle initials, " "so the reading is reported"), Case("a_lone_numeral_with_no_word_to_spare_is_the_family", "John V", {"given": "John", "family": "V"}, ambiguities=("suffix-or-name",), notes="S10 (the trailing roman numeral): a one-letter " "numeral needs words to spare, as a bare ambiguous " "acronym does, and 'John' is the only other word -- the " "contrast with 'John Smith V' above is the count, and " "either reading reports. 2.0.0 to 2.4.2 read suffix " "'V' with no family; 1.4.0 read last 'V'"), Case("a_lone_shape_numeral_with_no_word_to_spare_is_the_family", "Malcolm X", {"given": "Malcolm", "family": "X"}, ambiguities=("suffix-or-name",), notes="the shape-only spelling of the row above: 'x' is in " "no vocabulary and reaches the fork by its shape"), Case("a_lone_numeral_in_a_one_case_name_is_counted", "JOHN V", {"given": "JOHN", "family": "V"}, ambiguities=("suffix-or-name",), notes="a name written in one case says nothing about the " "letter, so the count decides as it does for 'John V'"), Case("a_title_is_no_word_to_spare_for_a_lone_numeral", "Dr Smith V", {"title": "Dr", "given": "Smith", "family": "V"}, ambiguities=("suffix-or-name",), notes="S10's title clause (the trailing roman numeral); " "1.4.0 read first 'Smith', last 'V' too"), Case("a_multi_letter_numeral_needs_no_word_to_spare", "John III", {"given": "John", "suffix": "III"}, ambiguities=("given-or-family",), classification="fix(suffix-routing)", notes="the boundary of the count: a listed numeral of two or " "more letters is not initial-shaped and reads as any suffix " "word, so the lone 'John' is what is reported (O5). " "1.4.0 read last 'III', as it read 'Johnson PhD'"), Case("a_lowercase_numeral_in_a_mixed_case_name_reports_nothing", "John Smith vi", {"given": "John", "family": "Smith", "suffix": "vi"}, notes="S10 (the trailing roman numeral): nobody writes an " "initial in lower case beside capitals, so the writing " "settles the fork. It " "reported until #718, the shape fork reporting all it " "took, while the listed 'John Smith v' never did"), Case("the_given_part_after_a_family_comma_does_not_count_a_numeral", "Smith, Freiherr von Berg V", {"title": "Freiherr", "given": "von Berg", "family": "Smith", "suffix": "V"}, classification="fix(#424)", notes="S4: the given part's trailing slot after a family comma " "is read without the count, so the one-letter numeral " "S10 counts in a comma-less name is the suffix here " "behind a title-particle's chain, as 'Smith, von V' " "reads. #718's first form asked the count through the " "chain's bound and read given 'von Berg V', unreported; " "review found it. 1.4.0 read first 'von Berg V'"), Case("an_empty_head_comma_does_not_count_a_numeral_either", ", Freiherr von V", {"title": "Freiherr", "family": "von", "suffix": "V"}, classification="fix(#424)", notes="the empty-head shape of the row above, which #718's " "first form read family 'von V'; 1.4.0 read last " "'von V'"), Case("a_shape_numeral_with_no_word_to_spare_is_still_counted", "John VI", {"given": "John", "suffix": "VI"}, ambiguities=("suffix-or-name", "given-or-family"), classification="fix(suffix-routing)", notes="the count's other boundary: a numeral of two letters " "read by its shape alone takes no count and still " "reports (S10), so 'John VI' keeps the generation where " "'John V' gives it up. 1.4.0 read last 'VI'"), Case("a_lone_numeral_counts_in_units_under_family_first", "John V", {"given": "V", "family": "John"}, policy=Policy(name_order=FAMILY_FIRST), ambiguities=("suffix-or-name",), classification="fix(#718)", notes="the count is the same under a family-first order: " "'John' is the only other word, so the letter is a name " "word, and the order lays it out as the given name"), Case("a_lone_numeral_the_chain_takes_reports", "Freiherr von Richthofen V", {"title": "Freiherr", "family": "von Richthofen V"}, ambiguities=("particle-or-given", "suffix-or-name"), notes="behind a title the chain is one name word, so the V " "has none to spare and the chain takes it (P2); the " "declined numeral is still the read's pick, reported " "from the role it ends in. 2.2.0 to 2.4.2 read suffix " "'V'; 1.4.0 read last 'von Richthofen V'"), Case("a_lone_one_case_connective_numeral_the_chain_takes_reports", "FREIHERR VON RICHTHOFEN I", {"title": "FREIHERR", "family": "VON RICHTHOFEN I"}, ambiguities=("particle-or-given", "suffix-or-name"), notes="the row above in one case with the connective 'I'. " "Ending the name it connects nothing, so classify does " "not report it (P8, #721), and the declined numeral is " "what reports, as for 'V'"), Case("a_lowercase_shape_numeral_in_a_mixed_case_name_is_settled", "John x", {"given": "John", "suffix": "x"}, ambiguities=("given-or-family",), classification="fix(#718)", notes="lower case beside capitals settles the fork with no " "count (S10): the generation, unreported, though no " "word is spared -- the one shape that path reaches, " "'v' and 'i' being listed suffix words. 1.4.0 read last " "'x'"), Case("a_lowercase_shape_numeral_in_a_one_case_name_is_counted", "john x", {"given": "john", "family": "x"}, ambiguities=("suffix-or-name",), notes="the row above written wholly in lower case: the case " "says nothing, so the count decides and reports"), Case("a_lowercase_numeral_after_a_maiden_clause_reports_nothing", "Jane Doe nee Smith vi", {"given": "Jane", "family": "Doe", "suffix": "vi", "maiden": "Smith"}, classification="fix(#274)", notes="the clause's take reports the numeral it gives up, and " "asks the case as assign does: lower case in a mixed-" "case name reports nothing (S10, #718's review). 1.4.0 " "had no maiden markers"), Case("a_lowercase_shape_numeral_after_a_maiden_clause_reports_nothing", "Jane Doe nee Smith x", {"given": "Jane", "family": "Doe", "suffix": "x", "maiden": "Smith"}, classification="fix(#274)", notes="the unlisted spelling of the row above"), Case("a_capital_numeral_after_a_maiden_clause_still_reports", "Jane Doe nee Smith VI", {"given": "Jane", "family": "Doe", "suffix": "VI", "maiden": "Smith"}, ambiguities=("suffix-or-name",), classification="fix(#274)", notes="the contrast that makes the silence above a decision " "about case"), Case("a_real_generation_with_a_middle_initial", "John W Ingram V", {"given": "John", "middle": "W", "family": "Ingram", "suffix": "V"}, ambiguities=("suffix-or-name",), notes="a sheriff in North Carolina, consistently written this " "way (Derek, #721); #136's reporter wrote the same name " "'John W. Ingram V' and '..., V', expecting the suffix " "both ways -- the evidence that kept #718's two-word " "count against reading a comma-less letter as never " "the generation"), Case("a_real_generation_with_no_middle_name", "John Ingram V", {"given": "John", "family": "Ingram", "suffix": "V"}, ambiguities=("suffix-or-name",), notes="the same sheriff as a local paper wrote him (Derek, " "#721), two name words and the numeral: the shape #36's " "'Hardie Leannon V' was reported as a bug for reading " "otherwise in 2015"), Case("a_lone_numeral_before_a_credential_reports", "John Smith V PhD", {"given": "John", "middle": "Smith", "family": "V", "suffix": "PhD"}, ambiguities=("suffix-or-name",), notes="S10's Accepted clause: a numeral with a credential " "behind it is the family name, and since #721 the " "reading is reported (Derek). 1.4.0 read the same " "fields"), Case("a_one_case_link_before_a_credential_reports_the_numeral", "john i phd", {"given": "john", "family": "i", "suffix": "phd"}, ambiguities=("suffix-or-name",), notes="the letter has only a credential behind it, so it " "connects nothing (P8) and the numeral is what reports, " "as for 'john i, phd' (Derek, #721)"), Case("a_one_case_link_before_a_title_reports_the_numeral", "john i dr.", {"title": "dr.", "given": "john", "family": "i"}, ambiguities=("suffix-or-name",), classification="fix(#316)", notes="the same with a trailing title (H5): one report on " "the letter, the numeral's. 1.4.0 read suffix 'dr.'"), Case("a_one_case_non_numeral_link_before_a_credential_is_silent", "jose e phd", {"given": "jose", "family": "e", "suffix": "phd"}, notes="nothing to join and no numeral fork: no report"), Case("a_one_case_link_before_a_maiden_marker_is_silent", "jose e nee smith", {"given": "jose", "family": "e", "maiden": "smith"}, classification="fix(#274)", notes="the clause is one of the ends of a part, so the letter " "before the marker connects nothing (P8, #721); 1.4.0 " "had no maiden markers"), Case("a_one_case_letter_ending_a_comma_part_is_silent", "jones, john e", {"given": "john", "middle": "e", "family": "jones"}, notes="a corpus name: 'e' ends its part, so it connects " "nothing, and being no numeral nothing else reports"), Case("a_one_case_bare_letter_in_front_keeps_the_numeral_a_name", "john j v", {"given": "john", "middle": "j", "family": "v"}, classification="fix(#721)", notes="read as 'JOHN J V' is: in a one-case name the bare 'j' " "in front is the initial its capital is (S10). 1.4.0 " "read last 'j', suffix 'v'"), Case("a_one_case_lone_letter_is_an_initial_and_unreported", "j", {"given": "j"}, notes="read as 'J' is (T4): an initial claims the word, so " "O5's convention is not reported, where before #723 the " "lowercase letter reported given-or-family"), Case("a_one_case_letter_before_a_maiden_marker_stays_given", "j nee smith", {"given": "j", "maiden": "smith"}, classification="fix(#274)", notes="read as 'J NEE SMITH' is (T4): M4 does not make an " "initial the family name, where before #723 the " "lowercase letter read family 'j'; 1.4.0 had no maiden " "markers"), Case("a_one_case_letter_before_a_link_does_not_join", "jose j y lopez", {"given": "jose", "middle": "j y", "family": "lopez"}, classification="fix(#723)", notes="read as 'JOSE J Y LOPEZ' is, in 1.4.0 too (T4): the " "initial is no name word to P3's count, so the " "single-letter link is in a three-word name and stays a " "name word; 1.4.0 and 2.0-2.4 read the lowercase " "spelling family 'j y lopez'"), Case("a_one_case_letter_before_a_link_after_a_family_comma", "smith, j y lopez", {"given": "j", "middle": "y lopez", "family": "smith"}, classification="fix(#723)", notes="read as 'SMITH, J Y LOPEZ' is, in 1.4.0 too (T4), " "for the row above's reason; 1.4.0 and 2.0-2.4 read the " "lowercase spelling given 'j y lopez'"), Case("a_one_case_letter_past_a_declined_marker_reads_as_its_capital", "dr. née juan y lopez", {"title": "dr.", "given": "née", "middle": "juan y", "family": "lopez"}, classification="fix(#723)", notes="the declined marker leaves 'y' outside the name's own " "words, so P8's one-case fork does not read it and T4 " "does: the initial its capital is, as in 'DR. NÉE JUAN " "Y LOPEZ'. 1.4.0 and 2.0-2.4 read family 'juan y lopez'"), Case("a_lowercase_letter_past_a_declined_marker_among_capitals_joins", "DR. NÉE JUAN y LOPEZ", {"title": "DR.", "given": "NÉE", "family": "JUAN y LOPEZ"}, notes="the row above's mixed-case twin: the own words are " "capitals, but the name is not written in one case, so " "the lower-case 'y' past the marker is no initial (T4) " "and joins (#723's review)"), Case("a_lowercase_numeral_after_a_marker_among_capitals_is_no_initial", "MARY SMITH NEE v JONES", {"given": "MARY", "middle": "SMITH NEE v", "family": "JONES"}, notes="the name is not written in one case, so the lower-case " "'v' is no initial (T4) and the marker reads as " "'Mary Smith nee v Jones' does, as 1.4.0 and 2.4.2 read " "it; its capital twin 'NEE V JONES' keeps maiden 'V " "JONES'"), Case("an_uncased_word_in_front_is_no_initial", "김 v", {"given": "김", "family": "v"}, ambiguities=("suffix-or-name",), notes="hangul has no capital, so the one-case test of the word " "in front does not make it an initial; the count reads " "the numeral as it reads '김 V'"), Case("a_non_ascii_capital_in_front_is_no_initial", "IVAN Ж V", {"given": "IVAN", "family": "Ж", "suffix": "V"}, ambiguities=("suffix-or-name",), notes="the one-case test of the word in front asks whether its " "CAPITAL is initial-shaped, and 'Ж' is not (an initial is " "a Latin capital), so this reads as 'Ivan Ж V' does"), Case("a_non_ascii_lowercase_letter_in_front_is_no_initial", "josé é v", {"given": "josé", "family": "é", "suffix": "v"}, ambiguities=("suffix-or-name",), notes="the lowercase twin of the row above; its decomposed " "spelling reads the same (rules/H2's encoding note)"), Case("a_one_case_link_ending_a_particle_chain_before_a_credential", "john van e phd", {"given": "john", "family": "van e", "suffix": "phd"}, notes="the letter ends the chain the particle made, with only " "a credential behind it: it connects nothing (P8), so " "nothing reports"), Case("a_one_case_link_before_a_credential_after_a_family_comma", "smith, john e phd", {"given": "john", "middle": "e", "family": "smith", "suffix": "phd"}, notes="the given part after a family comma: only a " "credential follows the letter in its part (P8)"), Case("a_chained_one_case_numeral_before_a_title_reports_once", "freiherr von i dr.", {"title": "freiherr dr.", "family": "von i"}, ambiguities=("particle-or-given", "suffix-or-name"), classification="fix(#316)", notes="a letter ending a particle chain with only a title " "behind it: the numeral's report, the connective's " "withdrawn (P8). 1.4.0 read first 'freiherr von', last " "'i', suffix 'dr.'"), Case("a_numeral_kept_by_a_maiden_clause_before_a_credential_reports", "Jane Doe nee Smith V PhD", {"given": "Jane", "family": "Doe", "suffix": "PhD", "maiden": "Smith V"}, ambiguities=("suffix-or-name",), classification="fix(#274)", notes="the clause's twin of 'John Smith V PhD': the clause " "keeps the numeral a post-nominal stands behind, and " "reports it (S10). 1.4.0 had no maiden markers"), Case("a_one_case_link_before_a_marker_read_as_a_name_reports", "jose e nee", {"given": "jose", "middle": "e", "family": "nee"}, ambiguities=("conjunction-or-initial",), notes="no clause forms, so the marker is the family name and a " "name word follows the letter: P8 reports it, as 1.4.0's " "successors did -- the contrast with 'jose e nee smith'"), Case("a_lowercase_shape_numeral_before_a_credential_is_settled", "John x PhD", {"given": "John", "family": "x", "suffix": "PhD"}, notes="lower case beside capitals settles the numeral (S10), " "so the report on a numeral a post-nominal follows does " "not fire"), Case("a_lowercase_numeral_in_a_one_case_name_still_reports", "john smith i", {"given": "john", "family": "smith", "suffix": "i"}, ambiguities=("suffix-or-name",), notes="the contrast that makes the silence above a decision " "about case: written wholly in lower case the letter " "could be an initial, and the fork reports (Derek, " "#718)"), Case("a_shape_numeral_before_a_credential_joins_the_run", "John Smith VI MD", {"given": "John", "family": "Smith", "suffix": "VI MD"}, ambiguities=("suffix-or-name",), classification="fix(#784)", notes="S10: a numeral of two or more letters is read anywhere " "in the trailing run, so 'VI' joins it in front of a " "credential as the listed 'III' does, and reports, being " "read by its shape alone. 1.4.0 through 2.6.0 read family " "'VI', suffix 'MD'"), Case("a_shape_numeral_starts_the_credential_run", "John Smith VI Jones", {"given": "John", "family": "Smith", "suffix": "VI Jones"}, ambiguities=("suffix-or-name", "suffix-or-name"), classification="fix(#784)", notes="S8: a numeral of two or more letters that reads as the " "generation starts the run as 'III' does; both the " "numeral and the absorbed name word report"), Case("a_numeral_behind_an_initial_stays_a_name_before_a_credential", "John J. VI MD", {"given": "John", "middle": "J.", "family": "VI", "suffix": "MD"}, notes="S10's Except: an initial in front makes an unlisted " "numeral a name word, before a credential as at the end; " "no fork is weighed, so nothing reports"), Case("a_shape_numeral_before_dotted_initials_stays_the_suffix", "John Smith V, M.J.", {"given": "M.J.", "family": "John Smith", "suffix": "V"}, ambiguities=("suffix-or-name", "suffix-or-name"), classification="fix(#774)", notes="C1: a numeral ending the family part is read as the " "comma-less name reads it, the two name words in front " "sparing the one-letter one, so 'V' stays the suffix as " "'Jr.' and 'III' do. 1.4.0 through 2.6.0 read family " "'John Smith V'"), Case("a_one_letter_numeral_behind_a_connective_surname_stays_family", "Vega y Lopez V, Mary", {"given": "Mary", "family": "Vega y Lopez V"}, notes="C1's count of the family part goes by pieces, and the " "part joins 'Vega y Lopez' into one, so the 'V' has no " "word to spare and stays the family name, silent. The " "comma-less twin leaves the connective unjoined and reads " "suffix 'V' (decisions/C1's 2026-10-25 #774 entry, " "ACCEPTED); counting the join's words was declined for " "the row below"), Case("a_one_letter_numeral_behind_a_joined_surname_matches_its_twin", "Smith and Jones V, Mary", {"given": "Mary", "family": "Smith and Jones V"}, notes="both spellings join 'Smith and Jones', so the 'V' has " "no word to spare with the comma or without it; counting " "a join's words (the first fix for the row above) read " "suffix 'V' here and broke the match"), Case("a_lone_letter_ahead_of_a_further_comma_is_the_initial", "Smith, John PhD I, Jr.", {"given": "John", "middle": "I", "family": "Smith", "suffix": "PhD, Jr."}, notes="C9 (#819): the further comma has named the suffix, so " "the credential run stops short of the letter, which is " "the middle initial, as 1.4.0 through 2.3.0 read it; " "2.4.0 through 2.6.0 read suffix 'PhD I, Jr.'"), Case("a_title_case_numeral_leans_to_the_name", "Nguyen Van Vi", {"given": "Nguyen", "family": "Van Vi"}, ambiguities=("suffix-or-name",), notes="S10: a Title-case numeral in a name of mixed case leans " "to the name, as a Title-case member of the ambiguous " "class does, and reports. 1.4.0 read the same fields; " "2.2.0 through 2.6.0 read suffix 'Vi'"), Case("a_numeral_written_with_its_period_is_the_generation", "Georg VI.", {"given": "Georg", "suffix": "VI."}, ambiguities=("suffix-or-name", "given-or-family"), classification="fix(#752)", notes="S10: a numeral of two or more letters keeps its reading " "written with a period, the German ordinal, as 'III.' " "does. 1.4.0 through 2.6.0 read family 'VI.'"), Case("a_shape_numeral_ending_the_given_part_is_the_generation", "Smith, John VI", {"given": "John", "family": "Smith", "suffix": "VI"}, ambiguities=("suffix-or-name",), classification="fix(#784)", notes="S10's Precedence: in the given part after a family comma " "a numeral of two or more letters reads as the " "generation, as 'III' does, and reports by its shape. " "1.4.0 through 2.6.0 read middle 'VI'"), Case("an_unlisted_letter_ending_the_given_part_is_the_initial", "SMITH, JOHN X", {"given": "JOHN", "middle": "X", "family": "SMITH"}, notes="S10's Precedence and C9: in the given part after a " "family comma a one-letter numeral is the generation only " "where the vocabulary lists it, so 'X' stays the middle " "initial it nearly always is in donor lists (the " "predicate's FEC constraint), where 'V' reads the suffix"), Case("initial_not_suffix", "John V. Smith", {"given": "John", "middle": "V.", "family": "Smith"}, notes="shape 1's Middle slot filled by an initial-shaped " "word, which is the branch a numeral spelling would " "otherwise take to the suffix", shape=1), Case("lenient_after_comma", "John Ingram, V", {"given": "John", "family": "Ingram", "suffix": "V"}), Case("comma_then_title", "Smith, Dr. John", {"title": "Dr.", "given": "John", "family": "Smith"}, notes="shape 2's post-comma Title slot", shape=2), Case("nickname_single_name", "John (Jack)", {"family": "John", "nickname": "Jack"}), Case("nickname_only", "(Jack)", {"nickname": "Jack"}), Case("suffix_run", "John Jack Kennedy PhD MD", {"given": "John", "middle": "Jack", "family": "Kennedy", "suffix": "PhD MD"}, classification="fix(#436/#437)", notes="the NO-COMMA path, which is the one rules/R1 " "described and the parser violated from 1.4.0 to " "2.2.0: no comma stands between PhD and MD, so none " "is rendered. The corpus twin is 'John Doe MD PhD'"), Case("maiden_marker", "Jane Smith née Jones", {"given": "Jane", "family": "Smith", "maiden": "Jones"}, classification="fix(#274)", notes="v1 mangles to middle='Smith née'"), Case("phrase_marker_takes_the_maiden_name", "Maria Kowalska z domu Nowak", {"given": "Maria", "family": "Kowalska", "maiden": "Nowak"}, classification="fix(#434)", notes="the fork a PHRASE entry opens: a marker matched over " "more than one token. 'z domu' is the first multi-word " "entry any shipped vocabulary set holds, so this is the " "row where the multi-token branch of the lookahead is " "taken at all -- and the row that pins how many tokens " "the take drops. With the marker run forced back to one " "piece this reads maiden 'domu Nowak' (measured " "2026-08-26), which is also what the library's former " "advice produced: the dead-entry warning used to say " "'split it into separate entries', and z plus domu as " "two entries gives exactly that. 1.4.0 read first Maria " "/ middle 'Kowalska z domu' / last Nowak (2026-08-26) " "-- the marker inside the name, its ordinary reading of " "every marker. phrase_marker_partial_is_not_a_marker " "and preposition_alone_is_not_a_marker below are the " "boundaries"), Case("phrase_marker_partial_is_not_a_marker", "Maria Kowalska z Nowak", {"given": "Maria", "middle": "Kowalska z", "family": "Nowak"}, notes="the boundary above it: the phrase's first word with " "the second one missing. A lookahead that settled for a " "PREFIX of an entry would find a marker here and read " "family Kowalska, maiden Nowak (measured 2026-08-26 " "with the match relaxed to entries starting with the " "key). Nothing about z is marker-ish on its own -- it " "is an ordinary Polish preposition -- which is the " "whole reason the entry is a phrase. Parity"), Case("preposition_alone_is_not_a_marker", "Anna z Nowak", {"given": "Anna", "middle": "z", "family": "Nowak"}, notes="the same boundary with only one name word ahead of " "the preposition, which is where the damage of getting " "it wrong is worst: under the split-entry workaround " "the library used to advise, a bare z IS a marker and " "M2 hands it every word after it, so this name reads " "maiden 'Nowak' with NO family name at all (measured " "2026-08-26). This is the row that would have caught " "that advice. It is also the shape " "diminutive_that_was_a_marker_keeps_the_family pins for " "the roz collision -- a marker entry that is also an " "ordinary word eats the family name, and a phrase entry " "is how that is avoided rather than accepted " "(decisions/vocabulary-collisions). Parity"), Case("phrase_marker_split_by_a_clause_is_not_a_marker", "Anna z (domu) Nowak", {"given": "Anna", "middle": "z", "family": "Nowak", "nickname": "domu"}, notes="the fork that is about WHERE a run stands rather than " "how it is spelled: the phrase's two words with a " "bracketed clause between them. A marker run is tagged " "over the whole token stream and consumed over one " "SEGMENT, and a segment holds neither -- extract gives " "a clause's tokens a role and segment keeps only the " "role-less ones -- so a run written across a clause " "edge is one the consuming walk cannot see whole. " "Tagged anyway, the walk read the first word as the " "entire marker and this name came back family 'Anna', " "maiden 'Nowak' (measured 2026-08-26): the bare " "preposition eating the name, which is the exact damage " "the phrase entry exists to prevent. classify refuses " "to tag a run that crosses such a boundary, so the " "clause is an ordinary nickname and nothing else moves. " "Parity: 1.4.0 read first Anna / middle z / last Nowak " "/ nickname domu (2026-08-26)"), Case("phrase_marker_split_by_a_clause_keeps_the_family", "Maria z (domu) Kowalska Nowak", {"given": "Maria", "middle": "z Kowalska", "family": "Nowak", "nickname": "domu"}, notes="the row above with a family name to lose, and it is " "kept as its own row for the reason " "preposition_alone_is_not_a_marker is: the two fail the " "same mutation and record different damage from it. " "Where that one shuffles fields, this one came back " "family 'Maria', maiden 'Kowalska Nowak' -- the real " "family name inside the maiden value and gone from its " "own field (measured 2026-08-26). Losing a family name " "is the consequence worth pinning, not the shuffle. " "Parity: 1.4.0 read first Maria / middle 'z Kowalska' / " "last Nowak / nickname domu (2026-08-26)"), Case("diminutive_that_was_a_marker_keeps_the_family", "Rosalind Roz Smith", {"given": "Rosalind", "middle": "Roz", "family": "Smith"}, notes="'roz', the Czech/Slovak abbreviation, shipped in " "MAIDEN_MARKERS through 2.1 and collided with the " "English diminutive of Rosalind -- matching is " "whole-token, case-folded and period-insensitive, so " "Roz, roz and roz. are one string. This name read " "maiden 'Smith' with NO family name at all (measured on " "the pre-removal tree), because M2 hands the marker " "every word after it. The entry is gone in 2.2, which " "is what this row pins. Nothing to do with the " "delimited path, though M3 would have widened it: " "the defect is M2's and predates #335, and the one-word " "'(Roz)' spelling was never affected since M3 declines " "a lone marker -- but 'Jane Smith (Roz Jones)' reads " "maiden 'Jones' with the entry restored, where 2.1.0 " "read nickname 'Roz Jones' (measured 2026-08-26). " "Parity, and it is " "RESTORED parity rather than untouched -- 1.4.0 has no " "maiden support and read first Rosalind / middle Roz / " "last Smith (2026-08-26), which is where the removal " "puts this name back"), Case("full_participle_marker_still_consumes", "Anna Nováková rozená Svobodová", {"given": "Anna", "family": "Nováková", "maiden": "Svobodová"}, classification="fix(#274)", notes="the other half of the roz removal, and the reason it " "was a removal and not a retreat from Czech: the full " "participle rozená stays, being a word no one is " "called. Pinned because deleting a vocabulary entry " "invites deleting its neighbours, and because nothing " "else in the suite reaches this entry: removing " "rozená from MAIDEN_MARKERS fails exactly this row's " "two tests, one per runner, and nothing else (measured " "2026-08-26) -- the position " "maiden_marker_delimited_unaccented holds for 'nee'. " "The cost the removal accepts is the " "abbreviation: 'Anna Nováková roz. Svobodová' now reads " "middle 'Nováková roz.', family 'Svobodová' (measured), " "which is exactly how 1.4.0 read it. 1.4.0 read this " "row middle 'Nováková rozená' / last Svobodová " "(2026-08-26) -- the marker inside the name, the " "ordinary v1 reading of every marker"), Case("maiden_marker_after_particle_chain", "Ursula von der Leyen geb. Albrecht", {"given": "Ursula", "family": "von der Leyen", "maiden": "Albrecht"}, classification="fix(#399)", notes="#399: the chain that joins 'von der' to Leyen used to " "run on past the marker and take the maiden name with " "it (family 'von der Leyen geb. Albrecht', maiden ''), " "because the marker is consumed AFTER the chain merges " "and by then there is no lone marker piece left to " "find. A suffix already stopped the chain; a marker now " "does too. The same words one chain apart -- " "maiden_marker_no_particle below is the control"), Case("maiden_marker_no_particle", "Ursula Leyen geb. Albrecht", {"given": "Ursula", "family": "Leyen", "maiden": "Albrecht"}, classification="fix(#274)", notes="the control for maiden_marker_after_particle_chain: " "identical but for the particles, and it always worked. " "Pinned so a regression in the plain path cannot hide " "behind the particle rows"), Case("maiden_marker_after_one_particle", "Anna von Müller geb. Schmidt", {"given": "Anna", "family": "von Müller", "maiden": "Schmidt"}, classification="fix(#399)", notes="one particle is enough to break it -- #399 is not " "about the length of the run"), Case("maiden_marker_after_leading_particle", "von Müller geb. Schmidt", {"given": "von", "family": "Müller", "maiden": "Schmidt"}, classification="fix(#274)", ambiguities=("particle-or-given",), notes="the second control for #399, and the one that shows " "where the old boundary fell: a LEADING particle chains " "nothing (P4), so it never reached the marker and this " "shape always worked. given 'von' is P4 plus P1 " "declining to fold ('von' is outside the never-given " "particles), which is also why the row reports the " "fork -- not R2, whose output here is the family " "('Müller', base 'Müller', particles '')"), Case("maiden_marker_after_leading_particle_run", "von der Müller geb. Schmidt", {"given": "von", "family": "der Müller", "maiden": "Schmidt"}, classification="fix(#399)", ambiguities=("particle-or-given",), notes="a leading RUN of two broke where a leading single did " "not: 'der' is not the leading piece, so its own chain " "fired and swallowed the marker. What #399 left alone " "is the given/family SPLIT -- given 'von', family " "starting at 'der' (P4 + R2); the family itself shed " "the marker and the maiden name it had swallowed " "('der Müller geb. Schmidt' -> 'der Müller')"), Case("maiden_marker_after_particle_chain_with_suffix", "Jane van der Berg née Jones PhD", {"given": "Jane", "family": "van der Berg", "maiden": "Jones", "suffix": "PhD"}, classification="fix(#399)", notes="marker and suffix in the same name: M2's walk takes " "the maiden name up to the suffix before the chain " "runs, so the chain has nothing to stop at (under #399 " "it stopped at the marker). Dutch spelling of the same " "defect, and 'née' unaccented-vs-accented is not the " "variable here"), Case("maiden_marker_stops_the_leading_run_family_first", "de la Cruz née Vega", {"family": "de la Cruz", "maiden": "Vega"}, policy=Policy(name_order=FAMILY_FIRST), classification="fix(#399)", notes="#399's open question, answered by the marker being " "consumed before anything can place it rather than by " "a rule of its own: the marker used to " "survive as an ordinary name word and compete for the " "leftover given slot, so this read given 'née' / middle " "'Vega'. Consumed and dropped, it never reaches the " "placement. No given name at all is the right answer " "for family-plus-maiden input", shape=4), Case("maiden_marker_leaves_family_all_particles", "Jane de la née Jones", {"given": "Jane", "family": "de la", "maiden": "Jones"}, classification="fix(#399)", notes="taking the marker before the chain runs can leave a " "family group that is wholly particles, which is exactly " "the shape R2 " "reserves: they are not in particle position, so they " "report as ordinary words -- family_base 'de la', " "family_particles ''. test_cases pins that split only " "up to the partition invariant (non-empty base, words " "conserved), so base 'la' / particles 'de' would also " "satisfy it. Before #399 this read family 'de la née " "Jones' with base 'née Jones'. Nonsense input either " "way; pinned " "because the two rules have to compose without " "either producing an empty base"), Case("maiden_marker_trailing_after_particles", "Jane van der Berg née", {"given": "Jane", "family": "van der Berg née"}, notes="M2's boundary on the particle side. A marker with " "nothing after it is just a word: the consumer declines " "it and the chain takes it like any other word. A chain " "that stopped at it instead left the marker as a piece " "of its own, which then took the whole family field and " "demoted the real surname to the middle (family 'née', " "middle 'van der Berg'). The first cut " "of #399 did exactly that -- the marker-less spelling " "'Jones née' -> family 'née' is M2's own boundary " "example, and the particle spelling has to agree with " "the rest of the family name, not replace it"), Case("maiden_marker_trailing_before_suffix", "Jane van der Berg née PhD", {"given": "Jane", "family": "van der Berg née", "suffix": "PhD"}, notes="the other way the consumer declines: it walks only up " "to a trailing suffix, so a marker with nothing but a " "suffix after it is not consumed either, and the chain " "takes it. Pinned " "separately from the row above because the two reach " "the same decision through different conditions -- last " "piece vs nothing-but-suffix-follows -- and #399's chain " "stop, keyed on only one of them, looked correct on the " "other"), Case("maiden_marker_surname_spelling_keeps_its_particles", "Jane van der Nee", {"given": "Jane", "family": "van der Nee"}, notes="'Nee' is an attested surname as well as a marker " "spelling, which M1 already says ('a one-word clause " "keeps its word, which may itself be a surname'). " "Because nothing follows it the consumer declines, so a " "Dutch bearer of the surname keeps the tussenvoegsel " "in the family name. Ungated, #399 moved 'van der' out " "to the middle and left family 'Nee'"), Case("maiden_marker_trailing_keeps_the_fork_report", "St van née", {"title": "St", "family": "van née"}, ambiguities=("particle-or-given",), notes="'st' is both a title and an ambiguous particle (#367), " "so it leads the chain and the ambiguous 'van' behind it " "reaches group's PARTICLE_OR_GIVEN emitter, which is " "guarded on the chain having merged something. An " "ungated marker stop made the chain merge nothing for a " "DIFFERENT reason than the guard assumes, silencing the " "report while still deciding the fork -- the shape A1 " "forbids and #405 closed at P6. Pinned because removing " "a report a caller already sees is worse than never " "emitting one. Until #624 the row was 'St St née', the " "second 'St' chained; of two title-particles the second " "now leads and is a title (title 'St St', family 'née', " "no report), so the row moved to a chained particle " "that is not a title"), Case("maiden_marker_particles_on_both_sides", "Anna von der Müller geb. von der Berg", {"given": "Anna", "family": "von der Müller", "maiden": "von der Berg"}, classification="fix(#399)", notes="a particle chain on each side of the marker. This is " "the shape decisions/M2 first cited against moving " "the maiden handler ahead of the chain and then " "retracted -- merged and unmerged pieces hand the " "consumer the same words -- and the handler now does " "run ahead (#420); pinned so the claim that the reorder " "cannot disturb it stays checked. Before #399 the marker rode " "into the middle name (middle 'von der Müller geb.')"), Case("maiden_marker_stops_the_leading_run", "de la Cruz née Vega", {"family": "de la Cruz", "maiden": "Vega"}, classification="fix(#399)", notes="the default-order reading of the family-first row " "below, added because that one is core-only and the " "facade runner would otherwise never see this class. " "Before #399: family 'de la Cruz née Vega'"), Case("maiden_marker_stops_the_leading_run_family_first_given_last", "de la Cruz née Vega", {"family": "de la Cruz", "maiden": "Vega"}, policy=Policy(name_order=FAMILY_FIRST_GIVEN_LAST), classification="fix(#399)", notes="the sibling of the family-first row, and the point is " "that the two orders now AGREE: consuming the marker " "leaves no leftover to distribute, so the reading that " "distinguishes them has nothing to work on. Before " "#399 they differed -- given 'Vega' middle 'née' here " "against given 'née' middle 'Vega' under FAMILY_FIRST", shape=5), Case("connective_join_never_reaches_a_taken_marker", "Jane van der Berg née y Jones", {"given": "Jane", "family": "van der Berg", "maiden": "y Jones"}, classification="fix(#412)", notes="the last of the join-swallows. #399's stop tested for " "a LONE marker piece, and P3's connective join ran " "earlier in the same stage and merged the marker into " "a multi-word piece the stop could not see. The marker " "pass now runs before every join, so there is no piece " "list on which a join can reach a taken marker. The " "maiden name is 'y Jones' by M2's own reading: the " "marker takes the words after it, and a connective is " "one of them"), Case("connective_carveout_counts_the_surviving_name", "juan y garcia nee jones", {"given": "juan", "middle": "y", "family": "garcia", "maiden": "jones"}, classification="fix(#418)", notes="P3's three-word carve-out counted the marker and the " "maiden name, so a two-word maiden clause lifted " "'juan y garcia' from three words to five, 'y' joined, " "and when the clause left nothing was behind for the " "family: given 'juan y garcia', family ''. The count " "now sees the name that remains, so the clause changes " "nothing about how the rest of this name reads -- the " "reading is 'juan y garcia' plus a maiden name. " "test_parser.py asserts that over the corpus, and " "since #410 the only names it steps over are those " "that parse to nothing at all"), Case("bound_given_reserve_excludes_a_multi_word_maiden_name", "Abd Berg née Mary Jones", {"given": "Abd", "family": "Berg", "maiden": "Mary Jones"}, classification="fix(#411)", notes="the maiden name is TWO pieces, which is what " "distinguishes excluding the span from excluding the " "marker plus one. Capping the exclusion at two pieces " "reproduces #411 exactly -- given 'Abd Berg', family " "'' -- and every other row here has a one-word maiden " "name, so none of them can tell the two apart. A " "particle-led maiden name does NOT serve: P2's chain " "makes 'van der Jones' a single piece"), Case("bound_given_join_sees_only_the_surviving_name", "abd née Jones Jr Smith Berg", {"given": "abd", "maiden": "Jones Jr Smith Berg"}, classification="fix(#601)", notes="#601: a credential in the clause starts a run the take " "consumes (with #602) (rules/M2). Before #601: given 'abd', " "middle 'Jr Smith', family 'Berg', maiden 'Jones'. As pinned " "then: #411's shape, re-pinned twice. The maiden walk stops at " "the inner suffix and takes only 'Jones'; with the marker pass " "ahead of the joins, P5 then sees 'abd Jr Smith Berg' and reads " "it exactly as it reads that name written alone. Under #411 the " "join declined here because the piece it would absorb was the " "marker; under #420 the marker was gone before P5 looked and " "the join took the suffix instead, given 'abd Jr'; since #421 " "the join declines a suffix piece as it declines a marker, so " "it takes nothing and 'Jr Smith' is the middle name, as for " "'John Jr Smith Berg'"), Case("bound_given_join_takes_a_chain_carrying_a_declined_marker", "Abd van der Berg née Jr Jones", {"given": "Abd", "family": "van der Berg née", "suffix": "Jr Jones"}, classification="fix(#602)", ambiguities=("suffix-or-name",), notes="#602: 'Jr' after the name core starts the run, so P5's " "reserve sees no name word to spare and the bound word " "stands alone. Before #602 this pinned " "the field-level face of #417 (given 'Abd van der Berg " "née', middle 'Jr', family 'Jones'). The consumer declines " "(a suffix follows the marker), the particle chain " "takes the declined marker as the word M2 says it is, " "and P5 then joins the bound word to the chain -- a " "name piece like any other. P5's decline is for a " "marker standing as a word of its own, and this one " "is not. Under the #399 stop this read given 'Abd van " "der Berg', middle 'née Jr'. Pinned here because the " "lone-piece reading is the nicer-looking one: a " "marker test widened to match inside a piece would " "give given 'Abd', middle 'van der Berg née Jr' with " "the whole suite otherwise green"), Case("bound_given_join_declines_a_marker_before_only_a_suffix", "Berg, abdul née PhD", {"given": "abdul", "middle": "née", "family": "Berg", "suffix": "PhD"}, classification="fix(#411)", notes="a marker the consumer declines is still a piece when " "P5 looks, which is why the join asks about the marker " "directly rather than relying on the marker pass having " "removed it. Nothing but a suffix follows the marker, so " "M2 declines -- yet the join would " "still absorb it, reading given 'abdul née'. The " "marker stays an ordinary word here (M2: with nothing " "after it, it is just a word)"), Case("bound_given_join_declines_leaving_the_suffix_reading", "Berg, abd née Jones", {"given": "abd", "middle": "née Jones", "family": "Berg"}, classification="fix(#601)", notes="#601: a marker in the given part after a family comma is an " "ordinary word (rules/M2). Before #601: family 'Berg', " "suffix 'abd', maiden 'Jones'. As pinned then: 'abd' is the one " "word in both the bound-given and the suffix vocabulary, and P5 " "says the suffix reading wins in the given slot after a family " "comma. With the join declining, that reading is what is left " "-- so the name has no given name at all, matching how 'Berg, " "abd' alone has always parsed. Pinned because it is the shape " "where the declining join changes most and it reads alarmingly"), Case("bound_given_marker_immediately_after_the_bound_word", "abd née Jones", {"given": "abd", "maiden": "Jones"}, classification="fix(#411)", notes="the shortest form of the same decision: the very next " "piece is the marker. It is also M4's boundary on the " "vocabulary side, and a corpus name rather than a " "constructed one: 'abd' is a bound given-name word, so " "the vocabulary layer has claimed it as a GIVEN name " "and M4 -- which changes only the positional default " "-- does not reach it (mechanisms.md#TWO-LAYER-ASSIGN). " "Dropping that carve-out reads family 'abd'. The empty " "family is what M4 leaves standing here, not a " "leftover of the join, and not M2's ordinary " "one-name-word behaviour either -- since #445 'Smith " "née Jones' reads family 'Smith', and this row is why " "'abd née Jones' does not"), Case("bound_given_reserve_arabic_script", "عبد Berg née Jones", {"given": "عبد", "family": "Berg", "maiden": "Jones"}, classification="fix(#411)", notes="the Arabic-script bound word, in the vocabulary since " "2.0, pinned so the reserve change reaches it too. " "Script segmentation is NOT the point and does not " "fire: the segments and the effective script are the " "same as for the Latin row, so what this adds is " "vocabulary coverage, not a second code path"), Case("bound_given_join_no_longer_swallows_a_marker", "van der Berg, abdul née Jones", {"given": "abdul", "middle": "née Jones", "family": "van der Berg"}, classification="fix(#601)", notes="#601: a marker in the given part after a family comma is an " "ordinary word (rules/M2). Before #601: given 'abdul', " "family 'van der Berg', maiden 'Jones'. As pinned then: was the " "second of #412's two join-swallows and is now fixed as a side " "effect of #411, which is why the row is renamed rather than " "deleted. P5's join used to merge 'abdul' with the marker " "before M2's bound could see a lone marker piece. #411 made the " "join decline by counting the reserve without the words the " "maiden name takes; since #420 the marker pass takes 'née " "Jones' before P5 looks, leaving 'abdul' alone with nothing to " "join. P3's connective join was the last to go, under #412 -- " "connective_join_never_reaches_a_taken_marker"), Case("maiden_marker_ahead_of_a_conjunction", "Jane née and Jones Smith", {"family": "Jane", "maiden": "and Jones Smith"}, classification="fix(#445)", notes="M2's greedy reading, with a connective among the " "words taken: the same as 'Jane née Jones Smith' with " "'and' inside it -- which is why M4 reads it the same " "way too, the take leaving one name word either way " "(the family was empty here until #445; 1.4.0 read " "first 'Jane' / middle 'née and Jones' / last 'Smith', " "measured 2026-08-27). Until #412 closed, P3's join ran " "first and produced a marker-HEADED piece 'née and " "Jones' that the lone-piece test could not see, so the " "name read middle 'née and Jones', family 'Smith'. " "The lone-piece test stays as M2's own wording " "('standing as a word of its own'), but with the pass " "ahead of the joins no default-vocabulary input reaches " "a marker-headed piece any more, so nothing pins its " "`len(piece) == 1` half: kept as definition, not as a " "guard -- the comment at _is_maiden_marker_piece " "records the measurement"), Case("maiden_marker_after_particles_in_a_comma_segment", "Smith, Jane van der Berg née Jones", {"given": "Jane", "middle": "van der Berg née Jones", "family": "Smith"}, classification="fix(#601)", notes="#601: a marker in the given part after a family comma is an " "ordinary word (rules/M2). Before #601: given 'Jane', middle " "'van der Berg', family 'Smith', maiden 'Jones'. As pinned " "then: the listing form, where the chain and the marker are " "both on the given side of the comma. Before #399 the marker " "and the maiden name stayed in the middle name ('van der Berg " "née Jones'). Distinct from M2's remaining Accepted note, which " "is about a marker standing straight AFTER the comma"), Case("bound_given_reserve_excludes_the_maiden_name", "Abd Berg née Jones", {"given": "Abd", "family": "Berg", "maiden": "Jones"}, classification="fix(#411)", notes="#411: P5 reserves a name word so the join always " "leaves a family name behind, but until #420 the " "reserve was counted while the marker and the maiden " "name were still pieces, the marker pass running after " "the join. Four words counted, the join fired, and when " "the two departed nothing was left for the family: given " "'Abd Berg', family ''. The pass now runs first, so the " "reserve sees the two-word name P5 says must not join"), Case("bound_given_reserve_excludes_the_maiden_name_with_particles", "Abd van der Berg née Jones", {"given": "Abd", "family": "van der Berg", "maiden": "Jones"}, classification="fix(#411)", notes="the particle spelling of the row above, which is how " "#411 was found -- #399's chain stop put this shape in " "front of the reserve for the first time. The chain " "makes 'van der Berg' ONE piece, so the count is the " "same three-versus-two question"), Case("bound_given_reserve_still_joins_with_a_word_to_spare", "Abd Allah Smith née Jones", {"given": "Abd Allah", "family": "Smith", "maiden": "Jones"}, classification="fix(#400)", notes="the control: one more name word, so the join has its " "word to spare even after the maiden name leaves, and " "fires exactly as it does without a maiden clause. " "Unchanged by #411 -- pinned so the fix cannot be " "mistaken for switching the join off near a marker"), Case("bound_given_reserve_maiden_and_suffix", "Abd Berg née Jones PhD", {"given": "Abd", "family": "Berg", "maiden": "Jones", "suffix": "PhD"}, classification="fix(#411)", notes="the marker walk stops at a trailing suffix, so the " "excluded span is the marker plus 'Jones' and not the " "suffix -- which the reserve already discounted. Two " "different reasons for a piece not to count, on one " "name"), Case("maiden_marker_kyusei", "山田花子 旧姓 佐藤", {"family": "山田花子", "maiden": "佐藤"}, classification="fix(#309)", notes="旧姓 is default vocabulary, not pack data: a " "native-script marker cannot collide with a Latin-script " "name and matching is whole-token, the same rule that " "admitted урожд. Reaches a marker that is its own TOKEN. " "Japanese more often brackets the marker and writes a " "fullwidth colon after it, and '山田(旧姓:佐藤)' under " "maiden_delimiters still gives maiden '旧姓:佐藤', marker " "and colon attached -- not because the marker escapes " "tagging (classify tags it fine wherever it is a token; " "what #329 fixed was the CONSUMING, since group's #274 " "rule walks pieces and a role-bearing token is not in " "pieces) but because :glues marker to name into a " "single token, leaving nothing to drop. The spaced " "bracketed form is pinned by " "maiden_marker_kyusei_delimited below; the glued one " "wants the head-peel #317 tracks. 1.4.0 read this " "first 山田花子 / middle 旧姓 / last 佐藤 -- the marker " "sat in the name"), Case("maiden_marker_kyusei_segmented", "山田 花子 旧姓 佐藤", {"given": "花子", "family": "山田", "maiden": "佐藤"}, classification="fix(#309)", notes="the row above with the family name spaced, so the " "marker is consumed from a name that already has a " "given side -- pinned because #274's consuming rule " "takes the marker plus the piece after it, and the " "pieces before it are what could have gone wrong. " "1.4.0 read this first 山田 / middle '花子 旧姓' / " "last 佐藤"), Case("maiden_marker_delimited", "Jane Smith (née Jones)", {"given": "Jane", "family": "Smith", "maiden": "Jones"}, policy=Policy(maiden_delimiters=frozenset({("(", ")")})), classification="fix(#329)", notes="#329: the bracketed form now agrees with the bare " "maiden_marker row above -- both give maiden 'Jones'. " "Before, the marker rode along inside the value: " "extract records the clause as a span (it makes no " "tokens at all), tokenize gives the tokens it cuts " "there Role.MAIDEN, and group's #274 consuming rule " "walks pieces, which hold no role-bearing token; the " "fix drops the marker inside the CLAUSE instead. The " "facade runner reaches this row by performing the " "bucket move itself, so it is exercised twice. 1.4.0 " "expresses the policy the same way: " "measured 2026-08-02 through the bucket-move idiom " "maiden_delimiters['parenthesis'] = " "nickname_delimiters.pop('parenthesis'), it gave first " "Jane / last Smith / maiden 'née Jones' -- same name " "fields, marker still inside the value, which is the " "single field this change moves. Since #335 the same " "input reads identically with NO policy at all " "(maiden_marked_clause_reads_maiden_by_default below), " "which does not make this row redundant: the pair " "sitting in the maiden bucket settles the role before " "M3 is consulted, so this row exercises M1's path and " "that one exercises M3's. Rewriting it to drop the " "policy would delete the configured path's coverage " "rather than move it"), Case("maiden_marker_delimited_unaccented", "Jane Smith (nee Jones)", {"given": "Jane", "family": "Smith", "maiden": "Jones"}, policy=Policy(maiden_delimiters=frozenset({("(", ")")})), classification="fix(#329)", notes="the row above with the marker spelled unaccented, and " "it is the ONLY row in the suite whose value depends " "on 'nee' being in the default MAIDEN_MARKERS: " "everything else reaches the marker branch through " "'née', 'geb' or '旧姓', or through a stage lexicon of " "its own. Removing the entry now fails exactly this " "row's two tests, one per runner (measured 2026-08-03); " "before this row existed it left the whole suite green, " "so the shipped spelling English writes most often was " "one vocabulary edit from silence. 1.4.0 under the " "bucket-move idiom gave first Jane / last Smith / " "maiden 'nee Jones' (2026-08-03) -- the same diff the " "accented row records, which is the point: the two " "spellings behave alike on both sides"), Case("maiden_marked_clause_reads_maiden_by_default", "Jane Smith (née Jones)", {"given": "Jane", "family": "Smith", "maiden": "Jones"}, classification="fix(#335)", notes="rules/M3 -- the clause says 'maiden' out loud, so the " "pair enclosing it does not have to be configured. " "maiden_marker_delimited above is the same input under " "Policy(maiden_delimiters=...) and reads identically -- " "what M3 adds is the DEFAULT reading, where 1.4.0 and " "2.1 alike gave nickname 'née Jones'"), Case("phrase_marker_delimited_clause", "Maria Kowalska (z domu Nowak)", {"given": "Maria", "family": "Kowalska", "maiden": "Nowak"}, classification="fix(#434)", notes="the phrase fork at the OTHER drop site. M3 reads the " "clause and #329's clause pass drops the marker from " "inside it, and that pass counts tokens of its own -- " "phrase_marker_takes_the_maiden_name above exercises " "M2's pieces walk instead, so a count that stayed at " "one token in either place is caught by exactly one of " "the two rows (measured 2026-08-26: forcing the clause " "pass back to a single token moves this row to maiden " "'domu Nowak' and leaves the bare row alone, and " "forcing the pieces walk back does the mirror). 1.4.0 " "read first Maria / last Kowalska / nickname 'z domu " "Nowak' (2026-08-26), the clause a nickname because " "nothing looked inside it"), Case("phrase_marker_delimited_alone_stays_a_nickname", "Maria Kowalska (z domu)", {"given": "Maria", "family": "Kowalska", "nickname": "z domu"}, notes="M3's word-after condition, asked of a PHRASE: the word " "must come after the whole marker run, not after the " "clause's first word. " "maiden_marked_clause_one_word_stays_a_nickname holds " "the same boundary for a one-word marker, and cannot " "reach this one -- a condition written as 'more than " "one word in the clause' satisfies that row and turns " "this clause into a maiden one. What it produces is " "maiden 'z domu' (measured 2026-08-26): the same two " "words, in the other field, which is the whole of what " "M3 decides here. The VALUE does not move, because the " "#329 drop is a separate site and declines for its own " "reason -- there is no word past the run inside the " "clause -- so this row catches the mutation on the " "field alone. Parity: 1.4.0 read nickname 'z domu'"), Case("phrase_marker_delimited_alone_keeps_its_words", "Maria Kowalska (z domu)", {"given": "Maria", "family": "Kowalska", "maiden": "z domu"}, policy=Policy(maiden_delimiters=frozenset({("(", ")")})), notes="the same string as " "phrase_marker_delimited_alone_stays_a_nickname above, " "under the pair M1 governs, and it reaches a branch " "that row cannot: with the pair configured the clause " "IS the maiden name, so the #329 drop runs and has to " "decide whether a word stands past the marker. It does " "not -- both words ARE the marker -- so M1's carve-out " "keeps them, which is what the value shows. That " "containment test spans the whole marker run, and " "reading it one token in instead deletes this clause's " "text: the marker is dropped and nothing is left. " "Measured 2026-08-26, that mutation fails exactly one " "test in the suite, M1's own doc example -- which is a " "doc, edited by the commit that changes behavior, so " "the pin belongs here too (the argument " "build_rules_corpus.py makes for the rules corpus, one " "layer down). Deleting the guard OUTRIGHT is caught by " "the one-word rows instead -- " "maiden_marker_delimited_two_clauses and M1's (Nee) " "examples -- so this row is the phrase half of the " "branch and not the branch. Parity: 1.4.0 under the " "bucket-move idiom read first Maria / last Kowalska / " "maiden 'z domu' (2026-08-26)"), Case("maiden_marked_clause_interior_keeps_the_family", "Jane (née Jones) Smith", {"given": "Jane", "family": "Smith", "maiden": "Jones"}, classification="fix(#335)", notes="the row that decides the MECHANISM. Extracting the " "clause as a Role.MAIDEN region keeps the closing " "delimiter as the maiden name's right boundary; masking " "the delimiters and letting M2's bare-marker rule " "consume the content instead would read maiden 'Jones " "Smith' with an empty family, because M2's take runs to " "the end of the name. The parens say where it stops"), Case("maiden_marked_clause_one_word_stays_a_nickname", "Jane Smith (née)", {"given": "Jane", "family": "Smith", "nickname": "née"}, notes="M3's boundary: a marker with no word after it is not a " "maiden clause. Without this condition the default " "reading of a lone parenthesized marker would flip to " "maiden 'née', and M1's own (Nee) boundary -- a " "one-word clause keeps its word, which may be the " "surname Nee -- would be contradicted on the " "unconfigured path. Parity: 1.4.0 read nickname 'née'"), Case("markerless_parenthesized_clause_stays_a_nickname", "Cherice J. (Mary Johnson) Williams", {"given": "Cherice", "middle": "J.", "family": "Williams", "nickname": "Mary Johnson"}, notes="M3's other boundary, and the reason the maiden " "delimiters remain worth configuring: the parenthesized " "birth surname without a marker is a real US convention " "-- 'Cherice J. (Johnson) Williams' is the corpus name " "(corpus_issues.jsonl) -- but nothing in the clause " "says 'maiden', so it stays a nickname by default. Only " "a caller who knows their data can say otherwise, which " "is what Policy(maiden_delimiters=...) is for. The " "clause is TWO words here, and that is the whole point " "of the row: M3 tests the clause length before it tests " "the vocabulary, so the corpus spelling's one-word " "clause is refused by the length condition and never " "reaches the vocabulary one -- it would duplicate " "maiden_marked_clause_one_word_stays_a_nickname rather " "than fence the other condition. Measured 2026-08-26: " "with the vocabulary test dropped this reads maiden " "'Mary Johnson' -- the WHOLE clause, because #329's " "drop is gated on the first token carrying " "vocab:maiden-marker and 'Mary' does not, so nothing is " "dropped -- and with the vocabulary test in place the " "one-word spelling reads nickname either way. Parity: " "1.4.0 and 2.1.0 both read nickname 'Mary Johnson'"), Case("markerless_one_word_clause_stays_a_nickname", "Cherice J. (Johnson) Williams", {"given": "Cherice", "middle": "J.", "family": "Williams", "nickname": "Johnson"}, notes="the corpus spelling (corpus_issues.jsonl) of the row " "above, kept beside it rather than replaced by it. It " "reaches M3's length condition and stops there, so it " "cannot fence the vocabulary one -- which is why the " "row above widens the clause to two words -- but it is " "the name real US data actually carries, and a row for " "the corpus name is worth its two lines. Parity"), Case("maiden_marker_not_first_stays_a_nickname", "Jane Smith (Jones née)", {"given": "Jane", "family": "Smith", "nickname": "Jones née"}, notes="the OPENS-WITH half of M3, which nothing else reaches: " "a marker inside the clause but not first leaves the " "clause a nickname. Measured 2026-08-26, widening the " "predicate from the first word to any word left the " "whole suite green and all three gates at 0 unexplained " "-- this row is what closes that. The bracketed twin of " "maiden_marker_delimited_trailing_marker, which pins the " "same asymmetry one layer down, inside a clause already " "routed to maiden by policy: no marker the shipped " "vocabulary carries is written after the name it marks. " "Parity: 1.4.0 and 2.1.0 both read nickname 'Jones née'"), Case("marker_led_clause_with_one_name_word", "Smith (née Jones)", {"family": "Smith", "maiden": "Jones"}, classification="fix(#445)", notes="N3's shape meeting M3, and the row exists because the " "two rules disagree about what a clause is. N3 reads a " "name that is only a nickname plus one name word as " "'that word is the family name' -- but a marker-led " "clause is not a nickname clause, so N3 never sees this " "one, and until #445 the word kept the given-name " "reading the bare spelling gives it. M4 now reaches " "both spellings from the other side: the marker " "announces a FORMER surname, so the one name word left " "beside it is the current one. The bracketed spelling " "RESTORES the family EVERY released version read: " "1.4.0, 2.0.0 and 2.1.0 all read this name family " "'Smith', nickname 'née Jones' (measured on the wheels " "2026-08-27), so the clause reading maiden rather than " "nickname is #335's half and the only thing left that " "differs -- at all three baselines alike. Be exact " "about which spelling read `given`, because a ledger " "took the loose wording this note used to carry and " "narrowed one baseline where three needed it: the BARE " "spelling is what read given 'Smith' on 2.0.0, on " "2.1.0 and here until this rule (1.4.0 read it first " "'Smith' / middle 'née' / last 'Jones'). The bracketed " "spelling this row holds never read `given` on any " "released version"), Case("maiden_marker_makes_the_lone_name_word_the_family", "Smith née Jones", {"family": "Smith", "maiden": "Jones"}, classification="fix(#445)", notes="the rule, in its bare spelling: a maiden marker marks " "a surname the bearer no longer uses, so it only means " "anything beside one they do. With exactly one name " "word left after the take, that word is the current " "surname, and the positional convention O5 would " "otherwise apply (a lone name word is read given) is " "the thing M4 overrides. Read given 'Smith', family '' " "on 2.0.0 and 2.1.0; 1.4.0 had no maiden support and " "read first 'Smith' / middle 'née' / last 'Jones' " "(measured 2026-08-27), so this is a new reading and " "not a restoration -- the bracketed sibling " "marker_led_clause_with_one_name_word is the " "restoration"), Case("maiden_marker_interior_makes_the_lone_name_word_the_family", "Jane née Jones Smith", {"family": "Jane", "maiden": "Jones Smith"}, classification="fix(#445)", notes="the marker standing INSIDE the name, where M2's take " "runs to the end and swallows the rest -- so what is " "left is again one name word, and M4 counts what is " "left rather than where the marker stood. The widest " "half of the rule and the row that pins it: a guard " "that asked for the marker to be trailing would leave " "this one given 'Jane'. A new reading, and the one " "furthest from 1.4.0, which read first 'Jane' / middle " "'née Jones' / last 'Smith' (measured 2026-08-27) -- " "the real surname there is 'Smith', which 2.x reads as " "part of the maiden name (M2's greedy take, unchanged " "here)"), Case("maiden_marker_lone_name_word_with_suffix", "Smith née Jones PhD", {"family": "Smith", "suffix": "PhD", "maiden": "Jones"}, classification="fix(#445)", notes="an annotation is not a name word, which is what #410 " "established for H1 and M4 inherits: the credential " "stands beside the name and does not make it any " "longer, so the count that decides this reading is " "one either way. A guard written over roles generally " "rather than the three name roles reads this name as " "two words and declines. 1.4.0 read first 'Smith' / " "middle 'née' / last 'Jones' / suffix 'PhD' (measured " "2026-08-27)"), Case("maiden_marked_lone_initial_stays_given", "J. née Jones Smith V", {"given": "J.", "maiden": "Jones Smith V"}, classification="fix(#274)", notes="M4's boundary on the shape side, and a corpus name " "rather than a constructed one: an initial is not a " "family name, so the word the vocabulary layer has " "already claimed as a shape keeps its reading " "(mechanisms.md#TWO-LAYER-ASSIGN -- M4 changes the " "POSITIONAL default and must not reach a word another " "layer has claimed). Dropping the carve-out reads " "family 'J.'. Unchanged by #445, and the maiden value " "is M2's: the trailing 'V' is S2's suffix reading only " "where a name word precedes it, and an initial does " "not count, so the numeral stays maiden text (M2 " "carries the same input as a boundary example). The " "fix classification is #274's marker consumption, " "which is what makes this differ from 1.4.0 (first " "'J.' / middle 'née Jones' / last 'Smith' / suffix " "'V', measured 2026-08-27)"), Case("lone_name_word_reports_the_convention", "Andrew", {"given": "Andrew"}, ambiguities=("given-or-family",), classification="feat(#449)", notes="rules/O5 -- nothing decided this reading, so the " "convention picked the field and says so"), Case("lone_name_word_reports_the_convention_family_first", "Garcia", {"family": "Garcia"}, policy=Policy(name_order=FAMILY_FIRST), ambiguities=("given-or-family",), classification="feat(#449)", notes="the kind cannot name the field -- the convention " "follows the read order, the PARTICLE_OR_GIVEN precedent"), Case("lone_name_word_joined_by_a_connective_is_one_word", "Juan & Garcia", {"given": "Juan & Garcia"}, ambiguities=("given-or-family",), classification="feat(#449)", notes="rules/P3 -- a joined part is one name word wherever " "another rule counts them, and O5 counts them"), Case("lone_name_word_bound_given_vocabulary_decides", "abdul", {"given": "abdul"}, classification="parity", notes="negative control: the vocabulary claimed the word as a " "given name, so the convention decided nothing"), Case("lone_name_word_particle_reading_is_p4s", "de", {"given": "de"}, classification="parity", notes="negative control: a lone particle's reading is P4's, " "not O5's convention"), Case("lone_name_word_initial_shape_decides_it", "J", {"given": "J"}, classification="parity", notes="negative control: the word's own SHAPE claimed it as " "an initial -- the third member of " "_WORD_ALREADY_CLAIMED beside 'abdul' and 'de', and " "the only one of the three that is not vocabulary"), Case("lone_name_word_beside_a_suffix_still_reports", "Smith Jr.", {"given": "Smith", "suffix": "Jr."}, ambiguities=("given-or-family",), classification="feat(#449)", notes="rules/S2 peels the suffix and leaves ONE name word, " "which O5's convention then places -- the peel decided " "the suffix, not the field"), Case("lone_name_word_beside_a_nickname_and_a_suffix_reports", "'Smitty' Jones Jr.", {"given": "Jones", "suffix": "Jr.", "nickname": "Smitty"}, ambiguities=("given-or-family",), classification="feat(#449)", notes="rules/O5's own example: N3's count does not set " "aside a suffix standing beside the nickname, so N3 " "declines and the convention is what places 'Jones'"), Case("lone_name_word_title_decides_it", "Dr. Smith", {"title": "Dr.", "family": "Smith"}, classification="parity", notes="negative control for both conventions: H1 decided it, " "and `smith` is not title vocabulary, so H4 does not " "claim the input either"), Case("lone_name_word_nickname_decides_it", "'Smitty' Smith", {"family": "Smith", "nickname": "Smitty"}, classification="parity", notes="boundary: N3 returns before the O5 site is reached, so " "the convention never ran -- not a control of any guard " "clause, which is why the emitter carries none"), Case("lone_name_word_comma_decides_it", "Smith, Andrew", {"given": "Andrew", "family": "Smith"}, classification="parity", notes="boundary: the comma named the family before the " "positional read, so one name word never stood alone " "here -- not a control of any guard clause"), Case("all_titles_input_reports_the_demoted_word", "Lord Chancellor", {"title": "Lord", "family": "Chancellor"}, ambiguities=("title-or-name",), classification="feat(#491)", notes="rules/H4 -- nothing but title vocabulary, so the " "last title word is the name by convention and says so"), Case("all_titles_input_the_queens_bench_string", "The Right Hon. the President of the Queen's Bench Division", {"title": "The Right Hon. the President of the Queen's Bench", "family": "Division"}, ambiguities=("title-or-name",), classification="feat(#491)", notes="decisions/v1-xfail-triage's fourth NOT FIXED entry: " "the reading stands, and the guess stops being silent"), Case("all_titles_input_a_title_vocabulary_surname", "Dr. Pope", {"title": "Dr.", "family": "Pope"}, ambiguities=("title-or-name",), classification="feat(#491)", notes="`pope` is title vocabulary for the addressing forms, so " "the peel leaves one title-vocabulary word and the rule " "claims it"), Case("all_titles_input_family_first", "Lord Chancellor", {"title": "Lord", "family": "Chancellor"}, policy=Policy(name_order=FAMILY_FIRST), ambiguities=("title-or-name",), classification="feat(#491)", notes="the report is made where the word is PLACED, so a " "declared family-first order -- which puts 'Chancellor' " "in the family directly and leaves H1's retag no work -- " "gives the same reading and the same kind. The site was " "H1's retag until the family-first orders were measured " "silent there"), Case("title_run_then_a_credential_reports_the_name_word", "Dr. Pope MD", {"title": "Dr.", "family": "Pope", "suffix": "MD"}, ambiguities=("title-or-name",), classification="fix(#489)", notes="rules/H3's floor: everything behind the run is a " "suffix piece and `Pope` is not one, so the run gives " "it back and the credential is a credential. `pope` " "being title vocabulary, the word left standing is " "H4's title half -- the same reading `Dr. Pope, Jr.` " "has always had"), Case("title_run_then_a_bare_generational_reports_the_name_word", "Dr Pope Jr", {"title": "Dr", "family": "Pope", "suffix": "Jr"}, ambiguities=("title-or-name",), classification="fix(#489)", notes="v1 wanted exactly this reading and the 2026-09-01 " "triage pinned the old one as NOT FIXED; the " "descriptive note rules/S2 then carried (decisions/S2, " "2026-10-20) said a change toward S2's prediction " "would be an improvement, and this is it"), Case("title_run_floor_gives_back_a_given_name_title", "Dr Jr", {"given": "Dr", "suffix": "Jr"}, ambiguities=("title-or-name",), classification="fix(#489)", notes="the accepted edge: with `Dr` given back and `Jr` " "peeled as the suffix, no title is left to make the " "reading H1's, so the lone name word is H4's -- " "`dr` is title vocabulary and the word standing is it. " "This shape is what the descriptive note rules/S2 " "then carried (decisions/S2, 2026-10-20) named, in its " "`Sir Jr` spelling, as the reading it " "predicted and did not get; once the floor empties the " "run no branch reads `vocab:given-title` at all, so " "`Sir Jr` is this row in every part -- same roles, same " "kind, the detail naming a different word -- and it is " "not pinned twice"), Case("title_run_floor_gives_back_the_last_of_a_run", "Lord Chancellor Jr", {"title": "Lord", "family": "Chancellor", "suffix": "Jr"}, ambiguities=("title-or-name",), classification="fix(#489)", notes="the floor takes back ONE word, the run's last, and " "`Lord Chancellor` is the input decisions/H4 already " "uses for the all-titles convention"), Case("title_run_floor_keeps_a_joined_title_unit", "Prince of Wales Jr", {"title": "Prince of Wales", "family": "Jr"}, classification="parity", notes="the floor gives back its last piece only when that " "piece is ONE WORD: a joined unit led by a title is a " "title run, and handing it back would turn a title into " "a given name and leave the name with no title at all"), Case("title_run_floor_declines_an_all_suffix_input", "RA DDS", {"title": "RA", "family": "DDS"}, classification="parity", notes="negative control: the word the run would give back is " "`RA`, which IS suffix vocabulary, so the floor " "declines. `ra` is title vocabulary too, so the run is " "`RA` and H1's fold claims `DDS` -- not the bare-suffix " "carve-out, which reads the FIRST word as the name and " "reports `suffix-or-name` (`DDS RA`). Nothing reports " "here. Spelled with `MD` until #853 took md out of the " "titles"), Case("title_run_floor_declines_a_split_credential", "Jr. Ph. D.", {"title": "Jr.", "suffix": "Ph. D."}, classification="parity", notes="negative control: `Jr.` is suffix vocabulary wearing " "H2's opening-abbreviation shape, so the floor declines " "there too"), Case("title_run_floor_declines_an_all_title_input", "Marquess of Bath", {"title": "Marquess of Bath"}, classification="parity", notes="negative control: nothing stands behind the run, so " "there is no all-suffix rest for the floor to see"), Case("title_run_addresses_by_its_last_title", "Her Majesty Queen Elizabeth", {"title": "Her Majesty Queen", "given": "Elizabeth"}, classification="fix(#489)", notes="rules/H1 -- `queen` is a given-name title and it is " "the last word of the run, so the run addresses as it " "does; the empty family is H1's Accepted outcome"), Case("title_run_addresses_by_its_last_title_with_a_suffix", "Her Majesty Queen Elizabeth II", {"title": "Her Majesty Queen", "given": "Elizabeth", "suffix": "II"}, classification="fix(#489)", notes="the suffix peel runs first and does not decide H1"), Case("title_run_addresses_by_its_last_title_clerical", "Reverend Mother Teresa", {"title": "Reverend Mother", "given": "Teresa"}, classification="fix(#489)", notes="`mother` is a given-name title, `reverend` is not"), Case("title_run_addresses_by_its_last_title_dotted", "Dr. Sir John", {"title": "Dr. Sir", "given": "John"}, classification="fix(#489)", notes="the whole run keyed 'dr sir', which the shipped " "vocabulary has no entry for; the last word is `sir`"), Case("title_run_addresses_by_its_last_title_bare", "Mr Sir John", {"title": "Mr Sir", "given": "John"}, classification="fix(#489)", notes="the 2026-08-22 #369 entry's own example -- 'mr sir' is " "not a given-name title to either site, and now the " "LAST word is what both sites read"), Case("title_run_addresses_by_its_last_title_unlisted_first", "Xyz. Sir John", {"title": "Xyz. Sir", "given": "John"}, classification="fix(#489)", notes="H2's unlisted abbreviation joins the run: it sits " "inside the whole-run key, which the shipped " "vocabulary has no entry for, and an unlisted word can " "never match as a last-word key either -- `sir` is " "what this run is read by"), Case("title_run_licences_the_bound_join_by_its_last_title", "Dr. Sir abdul rahman", {"title": "Dr. Sir", "given": "abdul rahman"}, classification="fix(#489)", notes="rules/P5's licence keys the same way H1 does, the " "invariant the #369 entry set: behind a given-name " "title there is no family to spare. 1.4.0 read last " "'rahman'. Written 'Sir Sheikh' until #752 phase 2j " "made `sheikh` a title only inside a title phrase"), Case("title_run_does_not_address_by_a_non_given_name_title", "His Excellency Lord Duncan", {"title": "His Excellency Lord", "family": "Duncan"}, classification="parity", notes="negative control: `lord` is not a given-name title, so " "the run's last word does not address by given name"), # -- #519: prince and princess join the given-name titles. The # criterion is rules/H Background's: a title that precedes and # addresses by the GIVEN name belongs. "Prince Harry", "Princess # Anne" address by given name and no surname reading of the word # behind them exists. lord and lady stay out, decided: both split # by the bearer's rank, given name for children of the senior # ranks (Lord Peter, Lady Diana) and title or surname for every # peer and every wife (Lord Byron, Lady Thatcher), and the set # has no way to say "sometimes" -- the reason `venerable` stayed # out. Since #752 phase 2j prince and princess are titles only # inside a title phrase (rules/H9), so these rows write the style # in front. Case("title_run_princess_addresses_by_given_name", "Her Royal Highness Princess Anne", {"title": "Her Royal Highness Princess", "given": "Anne"}, classification="fix(#519)", notes="was the #489 bundle's negative control for the " "vocabulary question it filed as #519; `princess` now " "IS a given-name title, so the run's last word " "addresses by given name and the family is empty"), Case("prince_and_one_name_word_is_a_given_name", "His Royal Highness Prince Harry", {"title": "His Royal Highness Prince", "given": "Harry"}, classification="fix(#519)", notes="1.4.0 read last 'Harry'. H1's fold is pinned by the Sir " "John rows; this pins the membership. The style keeps " "'prince' a title (rules/H9): the bare 'Prince Harry' " "this row held until #752 phase 2j reads given 'Prince'"), Case("prince_licences_the_bound_given_join", "His Royal Highness Prince abdul Rahman", {"title": "His Royal Highness Prince", "given": "abdul Rahman"}, classification="fix(#519)", notes="the second site that reads the set, rules/P5's " "licence: behind a given-name title there is no family " "to spare, so the bound word joins forward as it does " "behind Sir. 1.4.0 read last 'Rahman'"), Case("lord_stays_a_surname_title", "Lord Byron", {"title": "Lord", "family": "Byron"}, notes="negative control for #519: `lord` addresses a duke's " "or marquess's younger son by given name (Lord Peter) " "and every peer by title (Lord Byron); the set cannot " "say 'sometimes', so it stays out and H1 families the " "one word"), Case("lady_stays_a_surname_title", "Lady Gaga", {"title": "Lady", "family": "Gaga"}, notes="negative control for #519: `lady` addresses by given " "name for a peer's daughter (Lady Diana) and by " "surname for a wife (Lady Thatcher); the set cannot " "say 'sometimes', so it stays out as `lord` does"), Case("title_and_two_name_words_is_not_h1s", "Sir John Smith", {"title": "Sir", "given": "John", "family": "Smith"}, classification="parity", notes="negative control: H1 never fires with two name words, " "whatever the run keys to"), Case("all_suffix_input_reports_suffix_or_name", "Rinpoche", {"given": "Rinpoche"}, ambiguities=("suffix-or-name",), classification="feat(#491)", notes="rules/H4's suffix half: post-nominal vocabulary with " "no name word beside it, read as the name"), Case("all_suffix_input_two_words", "QC MP", {"given": "QC", "suffix": "MP"}, ambiguities=("suffix-or-name",), classification="feat(#491)", notes="the first word is the name and the rest the run; only " "the word made into a name reports"), Case("lone_joined_unit_carrying_title_vocabulary", "John of Bishop", {"given": "John of Bishop"}, ambiguities=("title-or-name",), classification="feat(#491)", notes="rules/O5's exception: the one name unit is a join " "(P3) carrying title vocabulary, so the doubt is " "whether `Bishop` is a title rather than which field " "the unit takes, `bishop` being in TITLES. The row held " "`John of Prince` until #752 phase 2j gave `prince` a " "given reading, which reports given-or-family"), Case("a_joined_unit_h1_moves_is_reported_in_the_field_it_lands_in", "Attorney General of Minnesota", {"title": "Attorney", "family": "General of Minnesota"}, ambiguities=("title-or-name",), classification="parity", notes="#626: assign places the lone unit as the given name and " "H1 moves it to the family behind the title; the report " "was worded at assign and said 'given'. The order sweep " "in test_cases.py checks the field it names"), Case("a_pick_the_attachment_moves_is_reported_in_its_family", "Kim Min Do", {"given": "Kim", "middle": "Min", "family": "Do"}, ambiguities=("suffix-or-name",), classification="fix(#289)", notes="#626: under FAMILY_FIRST the order sweep reads P6 " "attaching the Title-case 'Do' to the family ('Do Kim') " "after assign read it as a middle name; the report was " "worded at assign and said 'middle'. Under " "FAMILY_FIRST_GIVEN_LAST nothing moves it: given 'Do'. " "1.4.0 read suffix 'Do'"), Case("a_pick_the_family_first_fold_moves_is_reported_where_it_lands", "de Kim Ma", {"family": "de Kim", "given": "Ma"}, ambiguities=("suffix-or-name",), classification="fix(#289)", policy=Policy(name_order=FAMILY_FIRST), notes="#626's review: P1's family-first fold takes the particle " "run and one name unit, and lays the rest out by the " "order, so 'Ma', which assign read as a middle name, " "lands in the given name -- a third rule moving a " "reported word after assign. 2.3.0 read suffix 'Ma'"), Case("a_leading_particle_the_east_slavic_rotation_moves_is_reported_in_the_family", "Van Ivan Petrovich", {"given": "Ivan", "middle": "Petrovich", "family": "Van"}, ambiguities=("particle-or-given",), classification="parity", policy=_ES, notes="#626's second review: O1 rotates the leading 'Van', which " "assign reported as a given name, into the family; the " "report names where it lands. Reverting that emitter " "passed every test until this row and its Turkic twin. " "1.4.0 with patronymic_name_order reads the same fields"), Case("a_leading_particle_the_turkic_rotation_moves_is_reported_in_the_family", "Van Ali Veli oglu", {"given": "Ali", "middle": "Veli oglu", "family": "Van"}, ambiguities=("particle-or-given",), classification="parity", policy=_TK, notes="#626's second review: O2's twin of the row above"), Case("a_pick_the_middle_as_family_fold_moves_is_reported_in_the_family", "Do Bishop Do", {"given": "Bishop", "family": "Do Do"}, ambiguities=("particle-or-given", "suffix-or-name"), classification="fix(#289)", policy=Policy(name_order=FAMILY_FIRST, middle_as_family=True), notes="#626's second review: the trailing 'Do' is a middle name " "at assign and O3's fold takes it into the family. 2.3.0 " "read suffix 'Do'"), Case("a_comma_report_h1_moves_is_reported_in_the_family", ", Ma Dr.", {"title": "Dr.", "family": "Ma"}, ambiguities=("suffix-or-name",), classification="fix(#316)", notes="#626's review: with nothing before the comma, the comma " "report on 'Ma' was worded 'the given name' at assign, " "and H1 then moved it to the family behind the trailing " "title. The empty-head comma shape nameparser/AGENTS.md's sweep " "paragraph asks for; 1.4.0 read first 'Ma', suffix 'Dr.'"), Case("lone_joined_unit_carrying_collision_set_title_vocabulary", "Smith and Pope", {"given": "Smith and Pope"}, ambiguities=("title-or-name",), classification="feat(#491)", notes="the clause reaches every join whose non-leading member " "is TITLES vocabulary, not just `bishop`: `John and " "Pope`, `Smith and Bishop` and `John of Judge` report " "it too. That is the pope/judge/bishop collision set " "(decisions/vocabulary-collisions) met inside a " "join, and the report is deliberate -- a second surname " "that is also title vocabulary is the doubt the kind " "names"), Case("lone_joined_unit_led_by_a_title_is_a_title_run", "Prince of Wales", {"title": "Prince of Wales"}, classification="parity", notes="boundary: a join whose FIRST word is title vocabulary " "chains into a title run (H3) and never reaches the " "assignment site at all, so it reports nothing -- the " "same silence as a lone `Dr.`"), Case("lone_joined_unit_behind_a_peeled_title", "Dr. Smith and Bishop", {"title": "Dr.", "family": "Smith and Bishop"}, ambiguities=("title-or-name",), classification="parity", notes="the join clause (rules/H7, split from H4) asks whether " "a WORD is a title, so a title peeled in FRONT of the unit " "does not answer it -- which is why the clause sits beside H4's " "peel half rather than under O5's field-deciding " "leg (#449 review round). 'Lord Chancellor née Jones' " "is the same hoist on the maiden clause. Roles parity; " "the flag is #491's"), Case("lone_title_word_beside_a_maiden_name_still_reports", "Lord Chancellor née Jones", {"title": "Lord", "family": "Chancellor", "maiden": "Jones"}, ambiguities=("title-or-name",), classification="fix(#410)", notes="a maiden name decides the FIELD (M4, and H1's widening " "is what keeps 'Chancellor' in the family), and says " "nothing about whether 'Chancellor' is a title -- so it " "silences O5's report and not H4's. 1.4.0 had no maiden " "SUPPORT and read title 'Lord Chancellor', first 'née', " "last 'Jones'"), Case("comma_path_title_decides_the_lone_word", "John III, Dr.", {"title": "Dr.", "family": "John", "suffix": "III"}, classification="fix(#296)", notes="a comma with no name word after it hands segment 0 " "back to the positional read, and the title deciding " "the field stands in the OTHER segment where the " "leading-title peel cannot count it -- so the read is " "told (`titled`) and O5 stays silent, where it named " "`given` for a word H1 then wrote to `family` (#449 " "review round). 'John III' since #718, which made the " "'V' of 'John V, Dr.' a name word (the next row) and " "left no lone word to decide"), Case("a_lone_numeral_before_a_suffix_comma_is_the_family", "John V, Dr.", {"title": "Dr.", "given": "John", "family": "V"}, ambiguities=("suffix-or-name",), classification="fix(#718)", notes="the part before a suffix comma reads as the comma-less " "name, and 'John V' has no word to spare for a " "one-letter numeral (S10, the trailing roman " "numeral). 1.4.0 read first 'John', last 'V', suffix " "'Dr.', 'dr' having been " "suffix vocabulary before the audit"), Case("comma_path_given_name_title_behind_a_listed_numeral", "John III, Sir", {"title": "Sir", "given": "John", "suffix": "III"}, classification="fix(#720)", notes="the title row above with a GIVEN-NAME title. O5 is " "silenced all the same: what the report is about is a " "field NOTHING decided, and this one a title decided. " "A comma with no given name after it does not decide " "the field (H1), so the title keeps the word given, as " "the shape-only 'John VI, Sir' already read it (2.x; " "1.4.0 read last 'John VI'). Assign " "writes the family on this path and H1 moves it back. " "1.4.0 read last 'John'"), Case("a_lone_numeral_before_a_given_name_title_comma", "John V, Sir", {"title": "Sir", "given": "John", "family": "V"}, ambiguities=("suffix-or-name",), classification="fix(#718)", notes="the row above with a one-letter numeral, which has no " "word to spare (S10, the trailing roman numeral). " "1.4.0 read title 'Sir', last 'John V'"), Case("generation_before_a_credential_comma_behind_a_nickname", "John 'Jack' Smith II, AD", {"given": "John", "family": "Smith", "suffix": "II, AD", "nickname": "Jack"}, ambiguities=("suffix-or-name",), classification="fix(#656)", notes="C12 (a suffix word ending the name before the comma): " "the nickname is no word of the count, so 'John Smith' " "is the whole name in front of the generation, and AD, " "not written as a name, is the credential. 1.4.0 " "through 2.4.2 read first 'AD', last 'John Smith'"), Case("generation_before_a_comma_holding_a_title_and_a_credential", "John Smith Jr., Sir AD", {"title": "Sir", "given": "John", "family": "Smith", "suffix": "Jr., AD"}, ambiguities=("suffix-or-name",), classification="fix(#656)", notes="C12 with a title in the part: the title reads as a " "title and the report names only the credential it " "took, so the field sweep holds it to the suffix. " "2.4.2 read given 'AD', family 'John Smith'"), Case("generation_before_a_credential_comma_behind_a_maiden_clause", "Jane Doe nee Smith Jr., AD", {"given": "Jane", "family": "Doe", "suffix": "Jr., AD", "maiden": "Smith"}, ambiguities=("suffix-or-name",), classification="fix(#656)", notes="C12 behind a maiden clause: the clause keeps 'Smith', " "the generation stays the suffix, and the part after " "the comma joins it. 2.4.2 read given 'AD', family " "'Jane Doe' (1.4.0: last 'Jane Doe nee Smith')"), Case("marker_led_clause_in_a_quote_pair", 'Jane Smith "née Jones"', {"given": "Jane", "family": "Smith", "maiden": "Jones"}, classification="fix(#335)", notes="M3 is keyed on the CONTENT, not on which pair matched, " "and this is the row that says so in the commonest " "spelling: a quote pair is how nicknames are usually " "written, and the same clause inside one reads maiden " "exactly as it does inside parentheses. Three of the " "eleven shipped nickname pairs are exercised by a " "marker-led clause anywhere in the suite -- this one, " "the parenthesis, and the fullwidth pair below -- and " "the other EIGHT have no row. Measured 2026-08-26 by " "disabling the swap one pair at a time: those three " "redden and the eight do not. That is a count over a " "wordlist, so read it the way " "mechanisms.md#VOCABULARY-EXERCISES-FORKS says to: the " "eight are not eight gaps, since the pairs fork on " "whether open and close are the same character and on " "the apostrophe carve-out inside that, not on which " "pair. This row is here because a quote pair is the " "same-character branch, and because the release note " "advertises the spelling. 1.4.0 and 2.1.0 both read " "nickname 'née Jones'"), Case("maiden_marked_clause_takes_the_suffix_reading_from_s1", "Jane Smith (née Jr.)", {"given": "Jane", "middle": "Smith", "family": "née", "suffix": "Jr."}, notes="S1 takes a suffix-shaped clause before M3 is " "consulted, and the whole reading is here because the " "surprising part is not the suffix: it is that the " "MARKER becomes the family name. S1 drops the brackets " "and lets the content read as if written bare, and " "bare 'Jane Smith née Jr.' has no name word after the " "marker for M2 to take, so 'née' stays an ordinary " "word and lands in the family. rules/M3 carries the " "same input as an example line, but the runner checks " "one field per line; this row is the other three. " "Parity, and unchanged by #335 -- 1.4.0 and 2.1.0 read " "it the same way, which is why the corpus row it added " "diffs against no baseline"), Case("maiden_marked_clause_beside_a_nickname", 'Jane "Janey" Smith (née Jones)', {"given": "Jane", "family": "Smith", "nickname": "Janey", "maiden": "Jones"}, classification="fix(#335)", notes="two clauses, two roles. Through 2.1 both were " "nicknames and the facade joined them into one value, " "'Janey née Jones' -- the merged-nickname half of #335"), Case("maiden_marker_delimited_unmarked_content", "Jane Smith (Mary Jones)", {"given": "Jane", "family": "Smith", "maiden": "Mary Jones"}, policy=Policy(maiden_delimiters=frozenset({("(", ")")})), classification="parity", notes="the row the rest of the #329 battery leaves out: a " "multi-token clause whose first token is NOT a marker, " "which keeps every one of its tokens. The clause-size " "test and the marker-tag test are separate conditions, " "and this shape is one of the two that separates them " "-- with the tag test removed the pass eats the opening " "word of every delimited maiden name ('Jones' here), " "and five tests go red across this row and " "maiden_marker_delimited_trailing_marker (measured " "2026-08-03). What is this row's alone is that NO token " "in its clause is a marker; the trailing-marker row has " "one, just not first. Measured against " "1.4.0 2026-08-03 through the bucket-move idiom " "maiden_delimiters['parenthesis'] = " "nickname_delimiters.pop('parenthesis'): first Jane / " "last Smith / maiden 'Mary Jones', so #329 leaves this " "input exactly where v1 had it"), Case("maiden_marker_delimited_three_token_clause", "Jane Smith (née Mary Jones)", {"given": "Jane", "family": "Smith", "maiden": "Mary Jones"}, policy=Policy(maiden_delimiters=frozenset({("(", ")")})), classification="fix(#329)", notes="the only clause in the battery holding THREE tokens, " "which is what bounds the drop in both directions: it " "takes the marker and stops. Every other delimited row " "has a two-token clause, where 'the first token' and " "'all but the last token' agree, so two opposite " "mistakes both survive them -- restricting the drop to " "a clause of exactly two tokens gives maiden 'née Mary " "Jones' here (marker never dropped), and letting it eat " "the token after the marker gives maiden 'Jones' " "(a name eaten). Both measured 2026-08-03. 1.4.0 under " "the bucket-move idiom " "maiden_delimiters['parenthesis'] = " "nickname_delimiters.pop('parenthesis') gave first Jane " "/ last Smith / maiden 'née Mary Jones' (2026-08-03) -- " "marker inside the value, the single field #329 moves"), Case("maiden_marker_delimited_trailing_marker", "Jane Smith (Jones née)", {"given": "Jane", "family": "Smith", "maiden": "Jones née"}, policy=Policy(maiden_delimiters=frozenset({("(", ")")})), classification="parity", notes="the drop takes the clause's FIRST token or nothing: a " "marker anywhere else in the clause is content. Pinned " "because the cheap generalization -- drop every marker " "in the clause -- passes the whole battery above and " "gives maiden 'Jones' here, and because no marker the " "shipped vocabulary carries is written after the name " "it marks. Measured against 1.4.0 2026-08-03 " "through the bucket-move idiom: first Jane / last " "Smith / maiden 'Jones née'"), Case("maiden_marker_delimited_beside_a_nickname_clause", 'Jane "née Janie" Smith {née Jones}', {"given": "Jane", "family": "Smith", "maiden": "Janie Jones"}, policy=Policy(maiden_delimiters=frozenset({("{", "}")})), classification="fix(#335)", notes="#335 took this row's job away, and the row is kept to " "record that. It was the pin for the #329 drop pass " "being scoped to MAIDEN clauses -- two extracted " "clauses, both opening with a marker word, only the " "maiden one losing it, nickname 'née Janie' and maiden " "'Jones'. M3 now reads the QUOTED clause as maiden too, " "since it is marker-led like the braced one and M3 is " "keyed on content rather than on which pair matched, so " "there is no nickname left to contrast: both clauses " "are maiden and M1's independence rule joins them into " "one value. The role filter it used to discriminate " "(the 'role is not Role.MAIDEN' branch of " "_group.group's drop pass) is still reachable and " "still pinned -- " "that job moved to " "marker_glued_to_punctuation_keeps_the_clause_a_nickname " "below and, since #653, on to " "marker_divided_by_a_nakaguro_keeps_the_clause_a_nickname, " "each reaching a marker-led clause M3 declines. " "1.4.0 cannot express a brace delimiter at " "all (its buckets hold the NAMES of compiled regexes " "and there is no brace one; measured 2026-08-03, " "maiden_delimiters['brace'] = ('{', '}') is accepted " "and then raises ValueError('references unknown " "regexes key') at parse time), so the " "classification compares against its single reading, " "first Jane / middle 'Smith {née' / last 'Jones}' / " "nickname 'née Janie' -- braces as name text, the same " "convention maiden_marker_kyusei_delimited uses for a " "knob with no v1 spelling (re-measured 2026-08-26, " "unchanged)"), Case("marker_glued_to_punctuation_keeps_the_clause_a_nickname", 'Jane "née, Janie" Smith (née Jones)', {"given": "Jane", "family": "Smith", "nickname": "née, Janie", "maiden": "Jones"}, classification="fix(#335)", notes="a marker glued to a comma is no marker, inside a " "clause as in the main stream ('Jane Smith née, Jones' " "reads no maiden either): M3 splits the clause on " "WHITESPACE and normalizes the first word, and 'née,' " "stays 'née,' -- _normalize strips a trailing period " "but not a comma -- so the quoted clause stays a " "nickname, its comma kept as written (#653). Until #653 " "tokenize split a region's comma off as a separator, " "so the clause's first TOKEN was 'née' with " "vocab:maiden-marker while M3 declined, and this row " "was the witness for the 'role is not Role.MAIDEN' " "branch of _group.group's drop pass, reading nickname " "'née Janie'. A region's comma is word text now, the " "two questions agree here, and the witness moved to " "marker_divided_by_a_nakaguro_keeps_the_clause_a_nickname " "below. 1.4.0 gave first Jane / last Smith / nickname " "'née, Janie née Jones' (2026-08-26) -- comma kept, " "both clauses merged into the one field, which is the " "merged-nickname half of #335 and the only difference " "left (re-measured 2026-10-12)"), Case("marker_followed_by_a_comma_behind_a_bidi_mark_is_no_marker", "Jane (n\u00e9e \u200f, Jones) Smith", {"given": "Jane", "family": "Smith", "nickname": "n\u00e9e , Jones"}, classification="parity", notes="rules/M3's comma clause where the clause's whitespace " "word is '\\u200f,' and its token is ',' -- tokenize drops " "the right-to-left mark under the default strip_bidi. " "Extract's M3 test and group's M1 drop ask " "_vocab.comma_follows_marker of those two different " "strings, so the predicate reads past what tokenize " "drops; testing only the first character made M3 read " "the clause as maiden and the drop pass keep the marker, " "maiden 'n\u00e9e , Jones' (#653's second review, " "measured 2026-10-12). It looks exactly like " "'Jane (n\u00e9e , Jones) Smith', which is the point. " "1.4.0 read nickname 'n\u00e9e , Jones' (2026-10-12)"), Case("marker_divided_by_a_nakaguro_keeps_the_clause_a_nickname", '佐藤 花子 "旧姓・田中" (旧姓 鈴木)', {"given": "花子", "family": "佐藤", "nickname": "旧姓 田中", "maiden": "鈴木"}, classification="fix(#335) + fix(#271) + fix(#272)", notes="the witness for the 'role is not Role.MAIDEN' branch " "of _group.group's drop pass since #653 took the job " "from the comma row above. M3 and the drop pass ask " "the marker question of different things: M3 splits " "the clause on WHITESPACE, so its first word is " "'旧姓・田中' and it declines, while tokenize divides " "at the nakaguro (rules/T2), so the clause's first " "TOKEN is '旧姓' with vocab:maiden-marker -- exactly " "what the branch refuses to drop from a nickname. " "Measured 2026-10-12: with the branch removed this " "reads nickname '田中'. BOTH clauses are load-bearing " "-- the drop pass is gated on the name holding a " "MAIDEN region, which the marker-led paren clause " "supplies through M3. 1.4.0 gave first 佐藤 / last 花子 " "/ nickname '旧姓・田中 旧姓 鈴木' (2026-10-12): the " "order is #271's, the separate maiden #335's, the " "divided nickname #272's"), Case("maiden_marker_delimited_two_clauses", "Jane Smith (Nee) (Jones)", {"given": "Jane", "family": "Smith", "maiden": "Nee Jones"}, policy=Policy(maiden_delimiters=frozenset({("(", ")")})), classification="parity", notes="the scoping pin for #329, and the row a simplification " "would break: the drop is CLAUSE-scoped, so a one-token " "clause keeps its token even when the next clause could " "read as the name it marks. A neighbour-scoped rule -- " "drop a marker whose successor is also maiden -- gives " "'Jones' here, eating a real surname (Irish Ní/Nee, and " "a Chinese romanization). Unaccented 'nee' is in the " "default MAIDEN_MARKERS, so the 'Nee' token really is " "tagged and the CLAUSE bound is the only thing keeping " "it -- which is what makes this row kill a rule that " "drops the bound. The VALUE does not depend on that " "vocabulary entry, though: the clause test is checked " "before the tag test, so 'nee' leaving MAIDEN_MARKERS " "would leave this expectation green. " "maiden_marker_delimited_unaccented above is the row " "that fails when it goes. " "Parity is measured, not inferred from the row being " "untouched: 1.4.0 under the same bucket move gave first " "Jane / last Smith / maiden 'Nee Jones' (2026-08-02), " "so the two clauses joined with a space on that side " "too -- the classification the facade runner checks " "against, since it expresses this policy through the " "same bucket move"), Case("maiden_marker_delimited_content_free", "(née —)", {}, policy=Policy(maiden_delimiters=frozenset({("(", ")")})), classification="fix(#329)", notes="the drop can empty the WHOLE parse, and that is a " "decision rather than fallout. assemble's content test " "runs over the SURVIVING tokens, so once the marker " "goes structural the em dash is the only one left, no " "alnum character remains and every field clears -- " "bool() False. Reachable only where a maiden clause is " "the entire input and its non-marker tokens are pure " "punctuation -- the same clause inside a name is " "maiden_marker_delimited_content_free_in_a_name below. " "Coherent with the model 2.0 " "already had: a dropped marker is structural like a " "delimiter character, and '(-)' empties on both sides " "of this change. Structurally unreachable on the bare " "#274 path, whose scan starts at piece 1, so a token " "always survives ahead of the marker ('née —' gives " "given 'née', family '—'). Do NOT restore the old value " "with a guard on what else the clause holds: that is a " "different rule, and it would leave maiden holding " "marker-plus-punctuation. fix rather than parity " "because 1.4.0 CAN express this policy and disagrees: " "measured 2026-08-03 through the bucket-move idiom " "maiden_delimiters['parenthesis'] = " "nickname_delimiters.pop('parenthesis'), it gave maiden " "'née —' and a truthy name, as pre-#329 did. The 2.0 " "content rule already deviated from 1.4.0 here ('(-)' " "is maiden '-' in 1.4.0); this change moves one more " "input into its reach"), Case("maiden_marker_delimited_content_free_in_a_name", "Jane Smith (née —)", {"given": "Jane", "family": "Smith", "maiden": "—"}, policy=Policy(maiden_delimiters=frozenset({("(", ")")})), classification="fix(#329)", notes="the row above with a name in front of the clause, " "which is what bounds the emptying: assemble's content " "test is about the WHOLE parse, so a clause of " "marker-plus-punctuation empties only a name that is " "nothing else. Here Jane Smith carries the alnum " "content and maiden keeps the em dash. Pinned because " "the drop could plausibly have been widened to take the " "clause's punctuation with the marker -- that mutation " "gives maiden '' here (measured 2026-08-03) and leaves " "the row above green, since both readings empty a parse " "that is only the clause. 1.4.0 under the bucket-move " "idiom gave first Jane / last Smith / maiden 'née —' " "(2026-08-03)"), Case("maiden_marker_kyusei_delimited", "山田 花子(旧姓 佐藤)", {"given": "花子", "family": "山田", "maiden": "佐藤"}, policy=Policy( maiden_delimiters=frozenset({("(", ")"), ("(", ")")})), classification="fix(#329)", notes="the Japanese bracketed form that #329 reaches: the " "marker is spaced off inside fullwidth brackets, so it " "is a token of its own and the clause-scoped drop " "applies. The form Japanese more often writes puts a " "fullwidth colon after the marker instead, and " "'山田(旧姓:佐藤)' is ONE token -- nothing reaches it, " "and it wants the head-peel #317 tracks (see " "maiden_marker_kyusei above). Unlike its two Latin " "siblings, 1.4.0 cannot express this policy at all: v1's " "delimiter buckets hold the NAMES of compiled regexes, " "and no fullwidth pair is among them (#273 added it), so " "maiden_delimiters['fullwidth_parenthesis'] = ('(', ')') " "raises ValueError('references unknown regexes key') at " "parse time. The classification therefore compares " "against 1.4.0's single reading, first 山田 / middle " "'花子(旧姓' / last '佐藤)' -- the brackets were name " "text -- the same convention " "ko_honorific_period_under_strict_comma_suffixes uses " "for a knob with no v1 spelling. That reading is also " "what the differential harness sees, since it runs the " "corpus under the DEFAULT policy. What the harness does " "with it changed in 2.2: through 2.1 the () pair was a " "#273 NICKNAME delimiter and nothing in #329 was " "reachable, so the diff classified under " "fix(cjk-fullwidth-paren-nickname). Since #335 the marker " "inside the clause is enough on its own, so this name " "reads maiden under the default policy too -- see " "maiden_marked_fullwidth_clause_by_default below -- and " "the diff classifies under fix(#335) at 2.1.0 and 2.0.0 " "while at 1.4.0 it moved to fix(cjk-maiden-marker), " "leaving the fullwidth-paren rule dormant in that ledger. " "This row keeps its policy because M1 still governs a " "configured pair and settles the role before M3 is " "consulted"), Case("maiden_marked_fullwidth_clause_by_default", "山田 花子(旧姓 佐藤)", {"given": "花子", "family": "山田", "maiden": "佐藤"}, classification="feat(#273) + fix(#271) + fix(#335)", notes="the row above without its policy, and the one " "that fences M3 across the delimiter SET rather " "than at the parenthesis. Measured 2026-08-26 by " "gating the swap to '(' and the double quote: this row " "and its facade twin are the only two failures in the " "suite, and of the gates only 1.4.0 and 2.0.0 redden " "-- 2.1.0 stays green with its fix(#335) rule quietly " "falling from six names to five, while the 2.0.0 " "ledger catches it on the four-field rule it gives " "this name. The fullwidth pair is the " "one the maiden_markers docstring and the 2.2 " "release note both advertise as newly working " "without configuration, so it is the one that " "most needs a row. Three changes compound in the " "classification: #273 taught the parser the " "fullwidth pair, #271 gives the wholly-Han " "remainder its family-first reading, and #335 " "makes the marker inside the clause enough on its " "own. 1.4.0 read first 山田 / middle '花子(旧姓' / " "last '佐藤)' with the brackets as name text; " "2.1.0 read given 花子 / family 山田 / nickname " "'旧姓 佐藤' (both measured 2026-08-26)"), Case("east_slavic", "Сидоров Иван Петрович", {"given": "Иван", "middle": "Петрович", "family": "Сидоров"}, policy=_ES), Case("turkic", "Mammadova Aygun Ali kizi", {"given": "Aygun", "middle": "Ali kizi", "family": "Mammadova"}, policy=_TK), Case("ru_pack_formal_rotation", "Сидоров Иван Петрович", {"given": "Иван", "middle": "Петрович", "family": "Сидоров"}, locale="ru", notes="the RU pack end-to-end: same expectation as the " "east_slavic synthetic row, through parser_for"), Case("ru_pack_transliterated", "Petrov Ivan Sergeyevich", {"given": "Ivan", "middle": "Sergeyevich", "family": "Petrov"}, locale="ru"), Case("ru_pack_comma_untouched", "Петров, Иван", {"given": "Иван", "family": "Петров"}, locale="ru", notes="a comma is an explicit signal that suppresses the " "rotation (rule O1)"), Case("tr_az_pack_marker", "Mammadova Aygun Ali kizi", {"given": "Aygun", "middle": "Ali kizi", "family": "Mammadova"}, locale="tr_az", notes="the TR_AZ pack end-to-end: same expectation as the " "turkic synthetic row (pinned live during Plan 3), " "through parser_for"), Case("empty", "", {}), Case("whitespace", " ", {}), Case("bare_ambiguous_acronym", "John Ed", {"given": "John", "family": "Ed"}, ambiguities=("suffix-or-name",), notes="'ed' is an ambiguous acronym; bare form is a name (S6), " "and the parse reports which reading it took"), # MOVED by #289, not deleted (roles unchanged, a report gained): # 'Ed' is Title-case in a mixed-case name, so it leans SURNAME and # the post-comma slot declines it exactly as before -- but the # decision is now reported, both directions of the fork being # worth telling the caller about (decisions/S4). Case("comma_ambiguous_acronym", "Smith, Ed", {"given": "Ed", "family": "Smith"}, ambiguities=("suffix-or-name",)), # Quality-review finding, 2026-09-17 (item 5): name_word_count's # title half used a bare `folded in lexicon.titles` lookup, not # is_leading_title's H2 shape test -- so an UNLISTED period-marked # opener counted as a NAME word where a LISTED one did not, and # this pair split on that alone: 'Dr. Smith, Ed' (LISTED 'Dr.') # read family 'Dr. Smith' unflipped (the count is unchanged; since # #701 the listed 'Dr.' then peels, rules/H6), but 'Xyz. Smith, Ed' # (UNLISTED, H2-shaped) flipped the comma structure and read title # 'Xyz.', family 'Smith', suffix 'Ed' -- a different STRUCTURE for # the same shape, from a title-vocabulary difference the count had # no business seeing. Fixed by sharing H2's shape test # (`_vocab.is_title_shaped`, asked in this count and inlined in # `_pieces.is_leading_title`, two spellings of one predicate -- # sharing the function costs a frame on the hot leading-peel path, # measured, so they stay separate call sites instead). Case("comma_count_reads_h2_s_shape_test_too", "Xyz. Smith, Ed", {"given": "Ed", "family": "Xyz. Smith"}, classification="fix(#289)", ambiguities=("suffix-or-name",), notes="MOVED: title 'Xyz.', family 'Smith', suffix 'Ed' before " "this fix (measured at e545803) -- an unlisted period-" "marked opener now reads the way a listed title does at " "this count, exactly as 'Dr. Smith, Ed' beside it"), Case("ambiguous_acronym_with_suffix", "John Ed III", {"given": "John", "family": "Ed", "suffix": "III"}, ambiguities=("suffix-or-name",)), Case("phd_split", "John Ph. D.", {"given": "John", "suffix": "Ph. D."}, ambiguities=("given-or-family",), notes="v1 fix_phd; healed via the stable 'joined' tag. Roles " "parity; the flag is #449's -- a split credential beside " "the lone word decides the field no more than a whole " "one does"), Case("phd_split_mid_name", "Dr. John Ph. D. Smith", {"title": "Dr.", "given": "John", "family": "Smith", "suffix": "Ph. D."}), Case("phd_split_leading_van_johnson", "Ph. D. Van Johnson", {"title": "Ph.", "given": "D.", "family": "Van Johnson"}, classification="parity", notes="#371's own subject: the leading pair read given " "'Van Johnson' with an EMPTY family until the merge " "learned to decline at the head of the input. A Case " "row rather than a rules line because the empty " "fields are the symptom -- test_case asserts the whole " "dict, so `suffix` and `middle` being empty is pinned " "here and nowhere else"), Case("phd_split_leading_bare", "Ph. D.", {"title": "Ph.", "family": "D."}, classification="parity", notes="a bare credential yields a family name, which is " "surprising and is 1.4.0's reading exactly. Pinned " "because nothing else in the suite says so"), Case("phd_split_leading", "Ph. D. John Smith", {"title": "Ph.", "given": "D.", "middle": "John", "family": "Smith"}, classification="parity", notes="v1's fix_phd regex required a preceding space, so it " "split them (title 'Ph.', given 'D.', real given name " "pushed to middle). Surfaced by the issue-tracker " "corpus, which is where this shape existed at all."), Case("leading_never_given_particle", "de la Vega", {"family": "de la Vega"}, notes="v1 handle_non_first_name_prefix: never-given leading " "particle folds the whole name into family"), Case("unbalanced_quote", 'Jon "Nick Smith', {"given": "Jon", "middle": '"Nick', "family": "Smith"}, ambiguities=("unbalanced-delimiter",), notes="quote char stays literal (rule N2)"), Case("suffix_stays_suffix", "Johnson PhD", {"given": "Johnson", "suffix": "PhD"}, classification="fix(suffix-routing)", ambiguities=("given-or-family",), notes="v1 routes a lone trailing suffix to family " "(first=Johnson last=PhD); v2 keeps recognized " "suffixes in suffix -- which leaves 'Johnson' the one " "name word O5's convention places, reported since " "#449. Roles parity; the flag is #449's"), Case("suffix_stays_suffix_title", "Mr. Johnson PhD", {"title": "Mr.", "family": "Johnson", "suffix": "PhD"}, classification="fix(#410)", notes="two fixes meet here. v1 routed a lone trailing suffix " "to family (title 'Mr.', first 'Johnson', last 'PhD') " "and v2 keeps recognized suffixes in `suffix` " "(fix(suffix-routing)); that left 'Johnson' in `given` " "with an empty family until #410 stopped counting the " "suffix as a further name word"), Case("family_comma_lone_title", "Smith, Dr.", {"title": "Dr.", "family": "Smith"}, classification="fix(comma-family)", notes="pre-comma is family, as Dr. addresses by surname (a " "given-name title reads it as given, #720); v1 put it in first"), Case("family_comma_all_title_segment_keeps_split", "John Smith, Mr.", {"title": "Mr.", "given": "John", "family": "Smith"}, classification="fix(comma-family)", notes="a comma followed only by titles said nothing about " "where the family name ends, so segment 0 keeps its " "positional read instead of merging into one family " "name (v1 read first 'John Smith'; 2.0 read it as the " "family, the comma-precomma-family move)"), Case("family_comma_all_title_segment_needs_two_pieces", "Smith, Dr.", {"title": "Dr.", "family": "Smith"}, classification="fix(comma-family)", notes="the guard on family_comma_all_title_segment_keeps_split: " "one pre-comma piece has no split to keep, and the " "positional read would make it a lone GIVEN"), Case("family_comma_untitled_segment_still_merges", "John Smith, Jones", {"given": "Jones", "family": "John Smith"}, notes="the non-flip: a post-comma NAME means the comma did fix " "the family, so segment 0 stays wholly family (v1 parity)"), # -- #296: the TITLES/suffix overlap audit. A word that is only ever # a postnominal leaves TITLES, so the title peel stops claiming it # first. Leading position is the price and is pinned here. Case("audit_phd_leading_is_a_name", "PhD Smith", {"given": "PhD", "family": "Smith"}, classification="fix(#296)", notes="'phd' left TITLES because M.D./Ph.D. are postnominal " "only; nothing is prenominal-'PhD', so the leading " "position falls through to the positional read"), Case("audit_jr_leading_is_a_name", "Jr Smith", {"given": "Jr", "family": "Smith"}, classification="fix(#296)", notes="same as audit_phd_leading_is_a_name: 'jr' is never " "prenominal in any tradition"), Case("audit_md_leading_is_the_given_name", "Md Abdul Karim", {"given": "Md", "middle": "Abdul", "family": "Karim"}, classification="fix(#853)", notes="bare 'Md' before a name abbreviates Muhammad, a given " "name, and reads as the spelled-out 'Mohammad Abdul " "Karim' does. 1.4.0 through 2.5.0 " "read title 'Md', given 'Abdul': 'md' was a title from " "#32 (the medical degree written before the name) until " "#853, which measured the forename box UK filers type " "it into (decisions/indic-honorifics). 'MD' after the " "name is still the degree", shape=1), Case("md_after_a_title_is_the_given_name", "Mr Md Karim Uddin", {"title": "Mr", "given": "Md", "middle": "Karim", "family": "Uddin"}, classification="fix(#853)", notes="the shape #853 was filed on: 'md' in a title run had " "joined it, title 'Mr Md', given 'Karim'", shape=1), Case("md_before_a_lone_family_name_is_the_given_name", "Md Rahman", {"given": "Md", "family": "Rahman"}, classification="fix(#853)", notes="the title reading left the given name empty here " "(rules/H1's fold of a title and one name word)", shape=1), Case("md_before_a_particle_gives_it_to_the_family", "Md Abu Sayed", {"given": "Md", "family": "Abu Sayed"}, classification="fix(#853)", notes="an accepted cost of #853: 'abu' behind a given name " "is a particle starting the family, as in the spelled-" "out 'Mohammad Abu Sayed', where the title reading had " "left 'abu' the leading name piece and the bound join " "read given 'Abu' (1.4.0 read family 'Abu Sayed' " "too, behind title 'Md'). The filer's surname box holds " "'Sayed' (18 of #853's 1,300 PSC names lose their family " "name this way, " "with 'Abu', 'Al' or 'Bin'); the gap is the spelled-out " "name's, not Md's (decisions/indic-honorifics)", shape=1), Case("md_opening_a_family_comma_given_part_is_a_credential", "Uddin, Md Karim", {"family": "Uddin", "suffix": "Md Karim"}, classification="fix(#853)", ambiguities=("suffix-or-name",), notes="an accepted cost of #853: a credential opening the " "part after a family comma starts a credential run to " "its end (rules/C13), and 'md' is a credential with no " "title reading left to stand it down, so the given name " "is lost. 9 FEC names take this shape, against about " "380 ClinicalTrials.gov officials ('John Smith, MD " "Jones' shapes) the title reading had misread " "(decisions/indic-honorifics)", shape=2), Case("md_behind_a_title_opening_a_family_comma_given_part", "Uddin, Mr Md Karim", {"title": "Mr", "family": "Uddin", "suffix": "Md Karim"}, classification="fix(#853)", ambiguities=("suffix-or-name",), notes="the same accepted cost behind a title: C13's run " "starts behind titles alone, so the title in front " "does not stand 'Md' down. The title reading gave " "title 'Mr Md', given 'Karim' (decisions/indic-" "honorifics)", shape=2), Case("audit_md_after_comma_is_the_degree", "Smith, MD", {"family": "Smith", "suffix": "MD"}, classification="fix(#296)", notes="'md' after a comma is the degree, as it is at the " "end of a name"), # -- #346: the renunciate class. A title of religious renunciation # addresses the GIVEN name AND belongs to a tradition that # abolishes the surname, so rules/H1's given-name-title fold is # the right reading and family="" is the right output. The two # criteria are separate (rules/H Background) and the control # row below is what keeps them separate. Case("renunciate_title_keeps_the_given_name", "Swami Vivekananda", {"title": "Swami", "given": "Vivekananda"}, classification="fix(#346)", notes="pins the #346 membership change: 'swami' moved from the TITLES-only block " "into GIVEN_NAME_TITLES, so H1's fold fires and the " "family is empty where 1.4.0 through 2.2.0 read family " "'Vivekananda'. The fold itself is already pinned by " "the Sir John rows; this is the name the release note " "advertises"), Case("surname_retaining_title_keeps_the_family", "Rabbi Cohen", {"title": "Rabbi", "family": "Cohen"}, notes="makes rules/H Background's 'Rabbi Cohen' sentence " "executable -- no other row parses a Rabbi. 'rabbi' " "addresses by title and the tradition keeps surnames, " "so it stays out of GIVEN_NAME_TITLES and H1 families " "the one name word. The control that keeps 'addresses " "by the given name' and 'has no surname' separate"), Case("renunciate_title_with_a_trailing_honorific", "Lama Zopa Rinpoche", {"title": "Lama", "given": "Zopa", "suffix": "Rinpoche"}, classification="feat(#346)", notes="the renunciate fold and the spaced trailing honorific " "together. 'rinpoche' is postpositional in Tibetan " "usage ('Sogyal Rinpoche'), so it is SUFFIX_WORDS " "vocabulary rather than a title; that takes the third " "word out of the name and leaves H1 exactly one given " "to fold, which is why the family is empty. Without " "the suffix entry this reads family 'Rinpoche'" "; classified feat, not fix -- the fold was already " "pinned by the Swami row, and this row's delta is the " "rinpoche entry"), Case("renunciate_title_with_two_name_words_keeps_the_family", "Swami Vivekananda Saraswati", {"title": "Swami", "given": "Vivekananda", "family": "Saraswati"}, notes="H1's fold requires exactly one name word. The " "release note advertises it"), Case("renunciate_title_swallows_a_real_given_name", "Guru Dutt", {"title": "Guru", "given": "Dutt"}, classification="fix(#346)", notes="the known Latin collision: Guru IS the filmmaker's " "given name. 'guru' was already a title, so the " "leading-position swallow predates #346; the promotion " "into GIVEN_NAME_TITLES is what moved 'Dutt' from " "family to given. Accepted as the pre-existing cost of " "'guru' being a title at all " "(decisions/indic-honorifics); TITLES has no " "ambiguous subset to say otherwise"), Case("audit_do_leading_is_a_name", "Do Nguyen", {"given": "Do", "family": "Nguyen"}, ambiguities=("particle-or-given",), classification="fix(#296)", notes="the disposition's own argument, realized: 'Do' is a " "Vietnamese name, and dropping the title membership is " "what lets it be read as one -- with the fork 'Van " "Johnson' reports, since 'do' is an ambiguous particle " "too and the title membership had been hiding the fork"), Case("audit_se_leading_is_a_name", "SE Smith", {"given": "SE", "family": "Smith"}, classification="fix(#296)", notes="Structural Engineer is a US licensure postnominal (the " "PE/SE pair); no prenominal SE convention exists"), Case("audit_junior_leading_is_a_name", "Junior Smith", {"given": "Junior", "family": "Smith"}, classification="fix(#296)", notes="and 'Junior' is a real given name besides"), # the non-flips: trailing position was already right and must stay Case("audit_phd_trailing_unchanged", "John Smith PhD", {"given": "John", "family": "Smith", "suffix": "PhD"}), Case("audit_jr_trailing_unchanged", "John Smith Jr.", {"given": "John", "family": "Smith", "suffix": "Jr."}, notes="shape 1's trailing Suffix slot, written without a " "comma", shape=1), Case("audit_lt_leading_stays_a_title", "Lt. Smith", {"title": "Lt.", "family": "Smith"}, notes="'lt' KEPT its dual membership -- a prenominal rank with " "real retired-designation postnominal use; position " "decides, so leading is untouched"), Case("audit_sr_leading_stays_a_title", "Sr. Garcia", {"title": "Sr.", "family": "Garcia"}, notes="Señor (leading) vs Senior (trailing): kept dual, and " "the leading read is the peel's normal path"), Case("audit_sra_leading_stays_a_title", "Sra Garcia", {"title": "Sra", "family": "Garcia"}, notes="Señora is title-only; the SUFFIX_ACRONYMS entry was v1 " "residue and is what got dropped, not this"), Case("audit_dr_leading_stays_a_title", "Dr. Smith", {"title": "Dr.", "family": "Smith"}), Case("audit_dr_after_comma_is_a_title", "Smith, Dr.", {"title": "Dr.", "family": "Smith"}, classification="fix(comma-family)", notes="'dr' left SUFFIX_WORDS: 'Dr.' is not a postnominal in " "any tradition and the entry was v1 residue. This is " "what keeps the postnominal reading of a lone post-comma " "suffix piece from taking it -- the vocabulary decides, " "not the position"), Case("audit_dr_after_two_word_comma_keeps_the_split", "John Smith, Dr.", {"title": "Dr.", "given": "John", "family": "Smith"}, classification="fix(comma-family)", notes="with 'dr' gone from the suffix sets the post-comma word " "is a title only, so the no-name-word repair keeps the " "pre-comma split. v1, and 2.0 through master, kept the " "same split by a different route -- 'dr' was " "suffix-tagged, making this a SUFFIX_COMMA -- but put " "'Dr.' in suffix, not title: a change against every " "baseline"), Case("audit_sra_after_comma_is_a_title", "Smith, Sra", {"title": "Sra", "family": "Smith"}, classification="fix(comma-family)", notes="the same removal on the acronym side"), Case("audit_ms_after_comma_is_the_degree", "Smith, MS", {"family": "Smith", "suffix": "MS"}, classification="fix(#296)", notes="'ms' is a genuine dual -- 'Ms.' leading, 'MS' the " "degree trailing -- and position decides. The 2026-07-30 " "table put it in the AMBIGUOUS set so bare 'MS' would " "read as the honorific; measured, that gate is " "position-blind: 'John Smith, MS' lost its suffix-comma " "route and read title 'MS', and 'Smith, Ms.' passed the " "gate on its one period anyway. The second deviation " "from the table, with 'md'"), Case("audit_ms_after_two_word_comma_is_the_degree", "John Smith, MS", {"given": "John", "family": "Smith", "suffix": "MS"}, notes="the common listing, unchanged at every baseline -- " "what the ambiguous gate would have cost"), Case("audit_ms_leading_is_the_honorific", "Ms. Smith", {"title": "Ms.", "family": "Smith"}), Case("audit_perioded_ms_is_the_degree", "Smith, M.S.", {"family": "Smith", "suffix": "M.S."}), Case("audit_ms_honorific_spelling_after_comma_is_the_postnominal", "Smith, Ms.", {"family": "Smith", "suffix": "Ms."}, classification="fix(#296)", notes="the cost of the dual, recorded: after a family comma " "the slot is postnominal and the vocabulary says suffix, " "whatever the period suggests -- as for 'Smith, Sr.'. " "Every baseline read title 'Ms.' (the design-docs " "review found the flip unrecorded). 'Smith, Ms. Jane' " "still reads the title: a name word follows"), Case("audit_ms_before_a_name_after_comma_is_the_title", "Smith, Ms. Jane", {"title": "Ms.", "given": "Jane", "family": "Smith"}), # #857: sa is in NO vocabulary set -- not a credential, not a title # (its military and credential readings are both excluded on the # word). It is the Portuguese surname Sa written without its accent, # so it reads as any unlisted word does, whatever the case. These # rows pin that decision against the listed twin `ma`, which stays a # credential in the same shapes: an unlisted all-caps word is a # credential by SHAPE only behind a comma (S9), and the period # spellings go through the unlisted-dotted-suffix rule (S3). Case("sa_after_a_particle_is_the_surname", "Maria de Sa", {"given": "Maria", "family": "de Sa"}, classification="fix(#857)"), Case("bare_sa_is_a_surname", "Joao Sa", {"given": "Joao", "family": "Sa"}), Case("capitals_sa_after_a_particle_is_the_surname", "MARIA DE SA", {"given": "MARIA", "family": "DE SA"}, classification="fix(#857)", notes="the 255 SA / SA pairs of tse-donors (#843) read alike"), Case("capitals_ma_after_a_particle_is_the_credential", "MARIA DE MA", {"given": "MARIA", "family": "DE", "suffix": "MA"}, ambiguities=("suffix-or-name",), notes="the listed twin of the row above"), Case("lowercase_sa_after_a_particle_is_the_surname", "maria de sa", {"given": "maria", "family": "de sa"}, classification="fix(#857)"), Case("capitals_sa_with_words_to_spare_is_the_surname", "ANA SILVA SA", {"given": "ANA", "middle": "SILVA", "family": "SA"}, classification="fix(#857)"), Case("capitals_sa_with_no_word_to_spare_is_the_surname", "ANA SA", {"given": "ANA", "family": "SA"}), Case("sa_after_a_middle_name_is_the_surname", "Maria Silva Sa", {"given": "Maria", "middle": "Silva", "family": "Sa"}, classification="fix(#857)"), Case("sa_before_a_family_comma_is_the_surname", "Sa, Maria", {"given": "Maria", "family": "Sa"}), Case("sa_in_front_is_a_name_word", "SA Smith", {"given": "SA", "family": "Smith"}, classification="fix(#857)", notes="not a title: Seaman Apprentice has no bearer in any " "corpus (decisions/C8, #857)"), Case("mrs_then_sa_is_one_title", "Mrs Sa Li", {"title": "Mrs", "given": "Sa", "family": "Li"}, classification="fix(#857)", notes="`sa` was a second title word and took the given name"), Case("sa_after_a_family_comma_is_the_given_name", "Smith, SA", {"given": "SA", "family": "Smith"}, classification="fix(#857)", notes="2.5.0 read suffix 'SA' (#296's audit row); no person " "writes it as a credential in any corpus (#857)"), Case("sa_after_a_family_comma_and_a_name_is_a_middle_name", "Smith, SA John", {"given": "SA", "middle": "John", "family": "Smith"}, classification="fix(#857)"), Case("mixed_case_sa_after_a_full_name_is_the_family_name", "John Smith SA", {"given": "John", "middle": "Smith", "family": "SA"}, classification="fix(#857)", notes="accepted cost (#857): no shape reads an unlisted " "all-caps tail as a credential at this slot (S9), so " "with no vocabulary behind it SA is the family name"), Case("sa_after_a_full_name_and_a_comma_merges_the_name", "John Smith, SA", {"given": "SA", "family": "John Smith"}, classification="fix(#857)", notes="accepted cost (#857): the reading an unlisted two-letter " "caps word gets after a full name; 1.4.0 through 2.5.0 " "read suffix 'SA'. The listed twin `John Smith, MA` " "keeps the suffix"), Case("ma_after_a_full_name_and_a_comma_is_the_credential", "John Smith, MA", {"given": "John", "family": "Smith", "suffix": "MA"}), Case("sa_after_a_comma_with_no_head_is_the_given_name", ", SA", {"given": "SA"}, classification="fix(#857)", notes="1.4.0 read title 'SA'; 2.5.0 suffix 'SA'"), Case("sa_ending_the_given_part_is_a_middle_name", "Smith, John SA", {"given": "John", "middle": "SA", "family": "Smith"}, classification="fix(#857)"), Case("sa_after_a_credential_joins_it", "Smith, John PhD Sa", {"given": "John", "family": "Smith", "suffix": "PhD Sa"}, ambiguities=("suffix-or-name",)), Case("perioded_sa_after_a_family_comma_is_unlisted", "Smith, S.A.", {"given": "S.A.", "family": "Smith"}, ambiguities=("suffix-or-name",)), # MOVED by #289, not deleted: 'DO' is written in capitals inside a # mixed-case name, so it now leans CREDENTIAL and the post-comma # slot takes it with one word to spare rather than falling to the # given position (decisions/S6). Case("audit_bare_caps_do_after_comma_is_a_credential", "Smith, DO", {"family": "Smith", "suffix": "DO"}, classification="fix(#289)", notes="'do' left TITLES but was already AMBIGUOUS; the period " "gate handles the real collision, which is that 'Do' is " "a name -- but the bare, all-caps spelling is now read " "the way a credential is written"), Case("audit_perioded_do_after_comma_is_a_suffix", "Smith, D.O.", {"family": "Smith", "suffix": "D.O."}), # -- the TRAILING half of the same two removals. `dr` and `sra` are # the ONLY audit words that lose SUFFIX membership; every other # audit word keeps its suffix membership, so trailing position is # untouched for them. Case("audit_dr_trailing_is_a_title", "John Smith Dr.", {"title": "Dr.", "given": "John", "family": "Smith"}, classification="fix(#316)", notes="'dr' left SUFFIX_WORDS, so a trailing 'Dr.' stopped " "being suffix vocabulary and fell to the positional " "read, taking the family name with it -- the reading " "every other title-only word already had, which this " "row recorded 'dr' JOINING rather than endorsing. #316 " "answers the class, so it leaves that reading with " "them: the trailing walk takes the word to the title " "and 'Smith' is the family again"), Case("audit_sra_trailing_joins_the_title_word_gap", "John Smith Sra", {"given": "John", "middle": "Smith", "family": "Sra"}, classification="fix(#296)", notes="the same move for the other word losing suffix " "membership -- and, since #316, the pair's contrast: " "'Dr.' wears the period the trailing walk reads and has " "gone to the title, 'Sra' bare wears no shape at all " "and a bare trailing title word is a name word"), Case("family_comma_lone_generational_suffix", "Smith, Jr.", {"family": "Smith", "suffix": "Jr."}, classification="fix(#296)", notes="the issue as filed: the peel's whole-segment exception " "claimed 'Jr.' through the period-abbreviation inference " "even after the audit, so the ordering change is what " "actually reaches this input"), Case("family_comma_lone_generational_suffix_bare", "Smith, Jr", {"family": "Smith", "suffix": "Jr"}, classification="fix(#296)"), Case("family_comma_lone_degree", "Smith, PhD", {"family": "Smith", "suffix": "PhD"}, classification="fix(#296)"), Case("family_comma_dual_word_reads_postnominal", "Smith, Sr.", {"family": "Smith", "suffix": "Sr."}, classification="fix(#296)", notes="Señor vs Senior: 'sr' keeps both memberships and this " "is the position that picks Senior"), Case("family_comma_dual_rank_reads_postnominal", "Smith, CPT", {"family": "Smith", "suffix": "CPT"}, classification="fix(#296)", notes="the retired-designation reading of a prenominal rank"), Case("family_comma_lone_esquire_is_the_postnominal", "Smith, Esq.", {"family": "Smith", "suffix": "Esq."}, classification="fix(#296)", notes="H2's period-abbreviation inference reads a LEADING " "'Esq.' as a title ('Esq. Smith'); after a family comma " "the slot is postnominal and the vocabulary says " "suffix, so the inference does not run"), # -- #325: the whole credential run, not the lone piece Case("family_comma_split_credential_run", "Smith, Ph. D. Jr.", {"family": "Smith", "suffix": "Ph. D. Jr."}, classification="fix(#325) + fix(#429)", notes="one word before the comma, the space-split 'Ph. D.' " "and a suffix after it: the lone-piece route did not " "apply and the merged credential fell through to the " "given name (a 1.4.0 regression -- v1 read suffix 'Ph. " "D.', title 'Jr.'). A run that is nothing but suffix " "pieces is the credential run C8 describes, whole -- " "and #429 made it render whole too, where #325 shipped " "it as 'Ph. D., Jr.' with a comma the writer never " "typed. The full-name 'John Smith, Ph. D. Jr.' rendered " "it unjoined at 2.0.0 and after -- 1.4.0 rendered " "'Ph. D., Jr.' there too -- so this row now agrees with " "the form it has agreed with since 2.0"), Case("family_comma_credential_run_then_numeral", "Smith, Ph. D. III", {"family": "Smith", "suffix": "Ph. D. III"}, classification="fix(#325) + fix(#429)", notes="the numeral does not end the run; the render lost its " "inserted comma with the rest (#429)"), Case("family_comma_two_credentials", "Smith, PhD Jr.", {"family": "Smith", "suffix": "PhD Jr."}, classification="fix(#325) + fix(#429)", notes="'PhD' led the run as a title until the audit; the audit " "alone would have made it the given name, which is why " "the ordering shipped in the same commit. The run is " "suffixes, and since #429 renders as one entry rather " "than 'PhD, Jr.'"), Case("family_comma_title_led_credential_run", "Smith, Dr. MD PhD", {"title": "Dr.", "family": "Smith", "suffix": "MD PhD"}, classification="fix(#429)", notes="the title-led run: assign routes Dr. to TITLE and the " "two credentials to SUFFIX, so the ENTRY is the " "credential run, not the whole segment. 384 inputs of " "this shape moved with #429 and none was pinned until " "the review said so"), Case("family_comma_title_between_credentials", "Smith, MD Dr. PhD", {"title": "Dr.", "family": "Smith", "suffix": "MD PhD"}, classification="fix(#429)", notes="the entry is sticky across a piece that is not in it: " "an interleaved title must not split the run it sits " "in, or the render inserts the very comma #429 removes"), Case("family_comma_nickname_between_credentials", 'Smith, MD "Doc" PhD', {"family": "Smith", "nickname": "Doc", "suffix": "MD PhD"}, classification="fix(#436/#437)", notes="the transparency above is not a TITLE rule: a nickname " "renders into `nickname`, so it is no more standing in " "the credential run than a title is, and the writer " "typed no comma between MD and PhD. 'MD, PhD' at every " "baseline, and still 'MD, PhD' on #436's first draft, " "which read only a title as transparent"), Case("family_comma_title_led_run_keeps_the_written_comma", "Smith Jr., Mr. Jr.", {"title": "Mr.", "family": "Smith", "suffix": "Jr., Jr."}, notes="THE #429 REGRESSION GUARD, and unchanged since 1.4.0. " "The pre-comma name leaves a suffix, and the segment " "after the comma is title-led. #429's first draft let " "the title piece OPEN the entry, so the following Jr. " "was tagged as a continuation and the view joined it " "backward across the writer's own comma -- suffix " "'Jr. Jr.', the exact inverse of the bug #429 fixes. " "Only a piece that renders into the same run may " "continue an entry"), Case("family_comma_written_commas_are_kept", "Smith, MD, PhD", {"family": "Smith", "suffix": "MD, PhD"}, notes="the negative control for #429, and the distinction the " "whole change rests on: the parser renders the run as " "the writer spaced it, and never stops emitting a comma " "the writer typed. Parity at every baseline"), Case("family_comma_run_matches_the_full_name_form", "John Smith, MD PhD", {"given": "John", "family": "Smith", "suffix": "MD PhD"}, notes="the full-name twin of family_comma_suffix_run_renders_" "unjoined, and the reference #429 brought the one-word " "form into line with. Unchanged since 1.4.0, so this row " "fails if a future change fixes one form by breaking the " "other"), Case("no_comma_suffix_run_renders_unjoined", "John Smith MD PhD", {"given": "John", "family": "Smith", "suffix": "MD PhD"}, classification="fix(#436/#437)", notes="the NO-COMMA path #429 recorded as left alone, and " "the rules/R1 example line. 'MD, PhD' at 1.4.0, " "2.0.0, 2.1.0 and 2.2.0 alike -- a comma the writer " "never typed -- because the entry boundary was derived " "from segment SHAPE and this string has no comma to " "shape a segment with. It reads the same as the " "comma-written 'John Smith, MD PhD' above, which is " "what makes str() of that parse round-trip"), Case("family_comma_segment_zero_is_not_the_run", "MD PhD Jr., John", {"given": "John", "family": "MD", "suffix": "PhD Jr."}, classification="fix(#436/#437)", notes="segment 0 is the family segment even when it is wholly " "credential-shaped, so 'MD' is the FAMILY and not the " "head of a credential run, and that is what this row " "still guards. From #701 until #853 took 'md' out of " "the titles it guarded rules/H6's stop too, a title " "before the comma peeling only in front of a family " "word. The SEPARATOR is no longer the row's " "subject. Until #436 it was: the join was asked of " "segment 1 alone, and dropping that conjunct left the " "whole suite green while this shape's suffix silently " "became 'PhD Jr.' (the mutation matrix found it). The " "conjunct is gone with the block, and 'PhD Jr.' is now " "the right answer for a different reason -- PhD and " "Jr. share a comma bucket with nothing between them, " "so they render as the writer spaced them"), Case("family_comma_run_ending_in_a_numeral", "Smith, PSM I", {"family": "Smith", "suffix": "PSM I"}, classification="fix(#430)", notes="the credential run does not end because its last word " "is a roman numeral: PSM I is Professional Scrum Master " "level I, and the numeral describes the credential " "rather than the person's generation. The run read as " "given 'PSM' + suffix 'I' because the initial veto in " "is_suffix_piece keeps a numeral out of a credential " "run, so the segment did not look like one"), Case("family_comma_run_numeral_ignores_the_period", "Smith, PSM I.", {"family": "Smith", "suffix": "PSM I."}, classification="fix(#430)", notes="and the period does not end it either. After a suffix " "word the numeral is describing that suffix, and an " "initial in that position is not a name shape anyone " "writes -- so the abbreviation reading that governs " "#432 does not reach here. The full-name 'John Smith, " "PSM I.' has read it this way all along"), Case("family_comma_run_numeral_after_a_dual_word", "Smith, MD I", {"family": "Smith", "suffix": "MD I"}, classification="fix(#430)", notes="the same shape reached through the leading-title peel " "instead: md was TITLES vocabulary too until #853 (the " "#296 deviation), so this read title 'MD' + given 'I' where " "'Smith, PSM I' read given 'PSM' + suffix 'I'. Two " "wrong answers, one cause -- a fix verified on PSM " "alone would leave this one broken and look green. " "'Smith, Jr. I' is the same story by the other route, " "reaching the peel through the period-abbreviation " "inference rather than TITLES membership, and is the " "spelling a writer actually produces; it had a row of " "its own until the mutation matrix showed the two trace " "identically once fixed -- both heads are suffix pieces " "now, so is_leading_title is never consulted for either"), Case("family_comma_numeral_after_a_name_is_an_initial", "Smith, John V.", {"given": "John", "middle": "V.", "family": "Smith"}, classification="fix(#432)", notes="the other half of the boundary: after a NAME word the " "period is decisive, because it marks an abbreviation " "and an abbreviation is name material. 'Smith, John B.' " "has always read middle 'B.'; the only thing that made " "V. different is that V is also suffix vocabulary"), Case("family_comma_bare_numeral_after_a_name_is_the_suffix", "Smith, John V", {"given": "John", "family": "Smith", "suffix": "V"}, notes="THE BOUNDARY, and v1 parity (#144): with no period " "there is no abbreviation, so the numeral is the " "generation it looks like. This row is what makes " "#432's fix a period test rather than a numeral test"), Case("family_comma_title_resets_the_credential_run", "Smith, RA Dr. I", {"title": "RA Dr.", "given": "I", "family": "Smith"}, notes="THE RESET. A title ends the run: what follows a bare " "title is not continuing a credential, so the numeral " "behind it does not join and the segment is no run at " "all, leaving the walk to read the dual and the title as " "the given part's titles and 'I' as its given name. " "Without the reset the numeral continues the 'RA' in " "front of the title and the part reads title 'Dr.', " "suffix 'RA I'. Spelled with a dual since #603, 'MD' " "until #853 took md out of the titles and 'RA' since: " "'Smith, PSM Dr. I', this row until then, is a part the " "credential 'PSM' OPENS, which reads as #602's run reads " "whatever stands behind it (rules/C13) -- title 'Dr.', " "suffix 'PSM I' -- so the reset no longer decides it. A " "dual opens nothing, and the reset is what is left " "deciding this spelling. 1.4.0 read the " "old spelling suffix 'Dr., I'"), Case("family_comma_run_numeral_after_a_split_credential", "Smith, Ph. D. I", {"family": "Smith", "suffix": "Ph. D. I"}, classification="fix(#430)", notes="the numeral continues a run whose head is a MERGED " "piece -- the Ph./D. pair the #325 split-credential " "merge builds, which carries 'suffix' in its piece tags " "rather than on a single token. A structurally different " "pin on the same readers as the PSM rows, so an edit to " "those cannot quietly unpin this. #430 counted three of " "them; since #436 the render join is a rule over the " "written commas in post_rules, and since #613 " "segment_suffix_reading has one reader, `_comma.decide`, " "where assign's gate and router had shared it"), Case("family_comma_numeral_behind_a_suffix_is_not_an_initial", "Smith, John PhD I.", {"given": "John", "family": "Smith", "suffix": "PhD I."}, notes="THE OTHER BOUNDARY. " "The period makes a numeral name material only behind a NAME " "word; behind a suffix the run owns it, and the first " "draft of #432 read the piece alone and made this middle " "'I.'. Rendered without a comma since #436: the " "boundary is the comma the writer typed, and this " "writer typed none between PhD and I. -- the walk " "still decides the ROLES, which is what this row is " "about, and no longer decides the separator"), Case("family_comma_strict_keeps_the_initial_veto", "Smith, PSM I.", {"family": "Smith", "suffix": "PSM I."}, ambiguities=("suffix-or-name",), policy=Policy(lenient_comma_suffixes=False), classification="fix(#603)", notes="C14's strict knob (C13 for the opened run) still vetoes initial-shaped words as " "SUFFIX words, so the numeral does not continue the run " "as a suffix word (#430's first draft read no policy at " "all and silently overrode the knob; " "test_strict_ends_the_run_at_the_initial_shaped_numeral " "pins that reading in test_pieces.py). Since #603 the " "credential 'PSM' opens the part, and the run it opens " "takes 'I.' in as a word, as #602's run takes it in the " "given part under strict ('Smith, John PhD V.'), and " "reports it, a word strict would otherwise read as a " "name, as the no-comma run reports 'John Smith PhD V.'. " "Until then this row read given 'PSM', family 'Smith', " "suffix 'I.'"), Case("family_comma_run_with_a_name_is_not_a_run", "Smith, John Jr.", {"given": "John", "family": "Smith", "suffix": "Jr."}, notes="the non-flip: a name word in the run makes it the " "given-and-suffix walk v1 had. Shape 2's trailing " "suffix with the optional comma OMITTED; " "family_comma_three_part_trailing_strict is the " "spelling that writes it", shape=2), Case("family_comma_title_then_suffix", "Smith, Dr. Jr.", {"title": "Dr.", "family": "Smith", "suffix": "Jr."}, classification="fix(comma-family)", notes="a title and a postnominal, each read where it stands " "-- the 2.0 deviation's other case (v1 read first " "'Jr.'), and what keeps the no-name-word test from " "reading 'Dr. Jr.' as one title run"), Case("family_comma_title_then_suffix_mr", "Smith, Mr. Jr.", {"title": "Mr.", "family": "Smith", "suffix": "Jr."}, classification="fix(comma-family)"), Case("family_comma_title_then_suffix_keeps_the_split", "John Smith, Mr. Jr.", {"title": "Mr.", "given": "John", "family": "Smith", "suffix": "Jr."}, classification="fix(#296)", notes="no name word after the comma, so it fixed no family " "boundary -- the same reasoning as 'John Smith, Mr.'; " "v1 read first 'Jr.', last 'John Smith', and 2.0 through " "master family 'John Smith' (the design-docs review " "found C1 silent on the shape)"), Case("family_comma_title_run_keeps_the_split", "John Smith, Mr. Dr.", {"title": "Mr. Dr.", "given": "John", "family": "Smith"}, classification="fix(#296)"), Case("family_comma_title_run_one_word", "Smith, Mr. Dr.", {"title": "Mr. Dr.", "family": "Smith"}, classification="fix(#296)", notes="one pre-comma piece, no split to keep; the run is " "titles (master read suffix 'Dr.')"), Case("family_comma_three_segments_credential_run", "Smith, Jr., PhD", {"family": "Smith", "suffix": "Jr., PhD"}, classification="fix(#325)", notes="segments 2+ compose with the credential run"), Case("family_comma_suffixed_family_before_a_title", "Smith Jr., Dr.", {"title": "Dr.", "family": "Smith", "suffix": "Jr."}, classification="fix(#296)", notes="two pre-comma pieces but ONE name piece: the positional " "read peels the suffix first and would have left a lone " "given and no family (the code review found 'Smith Jr., " "Mr.' reading so), so the guard counts name pieces and " "the family stays. v1 read first 'Smith', last 'Jr.', " "suffix 'Dr.'; 2.0 through master family 'Smith', " "suffix 'Jr.', title 'Mr.' for the Mr. spelling"), Case("family_comma_suffixed_family_before_a_title_mr", "Smith Jr., Mr.", {"title": "Mr.", "family": "Smith", "suffix": "Jr."}, notes="unchanged at every baseline -- the shape the guard " "protects"), Case("family_comma_suffixed_two_word_family_before_a_title", "John Smith Jr., Mr.", {"title": "Mr.", "given": "John", "family": "Smith", "suffix": "Jr."}, classification="fix(#296)", notes="two name pieces, so the split is kept"), # -- the positional read keeps its ORDER, so the family-first fold # (P1) reaches a particle-led pre-comma name as it does without # the comma (the test review found it reading family 'de') Case("family_comma_no_name_word_family_first", "de Mesnil Jean, Dr.", {"title": "Dr.", "given": "Jean", "family": "de Mesnil"}, policy=Policy(name_order=FAMILY_FIRST), classification="feat(#395)", notes="core-only: name_order has no v1 spelling, so 'parity' " "(this field's default) could never have been true of " "this row. The differential compares it at 2.0.0 and " "2.1.0 under its own order instead, where it moves: " "#395's fold takes 'de Mesnil' where both read family " "'de', and the #296 half turns the post-comma 'Dr.' " "from a suffix into a title. As 'de Mesnil Jean' reads " "under the same order; master read it through the " "suffix-comma route ('dr' was suffix vocabulary) and " "got the fold that way", shape=4), Case("family_comma_no_name_word_family_first_given_last", "de la Cruz Juan Carlos, Dr.", {"title": "Dr.", "given": "Carlos", "middle": "Juan", "family": "de la Cruz"}, policy=Policy(name_order=FAMILY_FIRST_GIVEN_LAST), classification="feat(#395)", notes="core-only: name_order has no v1 spelling. The row " "above with two leftovers instead of one, which is the " "only shape in which the two family-first orders can " "disagree about the distribution -- 'Juan' is the " "middle name here and the given name there", shape=5), Case("family_comma_no_name_word_family_first_plain", "John Smith, Dr.", {"title": "Dr.", "given": "Smith", "family": "John"}, policy=Policy(name_order=FAMILY_FIRST), classification="fix(#296)", notes="core-only: name_order has no v1 spelling, but the " "CHANGE here is the vocabulary half alone -- no " "particle, so nothing folds, and the diff against 2.0.0 " "is the {title, suffix} move 'dr' leaving the suffix " "sets produces under every order. The declared order " "applies to the pre-comma name as it does to 'John " "Smith' alone -- deliberate", shape=4), Case("title_word_trailing_is_a_title", "John Smith Prof.", {"title": "Prof.", "given": "John", "family": "Smith"}, classification="fix(#316)", notes="rules/H5 -- after the trailing suffix run, a " "period-marked title word chains into the title from " "the end. The comma path already read it this way " "('Smith, Prof.'); the two paths agree now"), Case("ja_honorific_glued_family_comma_title_only", "田中さん, Dr.", {"title": "Dr.", "family": "田中さん"}, classification="fix(#296)", notes="Accepted (C1): with 'dr' out of the suffix sets the " "comma is a family comma, and the honorific peel runs " "in script_segment on the other structures only, before " "group or assign can say this comma fixed nothing -- so " "the honorific stays glued, joining master's '田中さん, " "Mr.'. Master peeled it through the suffix-comma route", tolerated=True), Case("title_word_trailing_is_a_title_mr", "John Smith Mr.", {"title": "Mr.", "given": "John", "family": "Smith"}, classification="fix(#316)"), # -- #789: `wm` (William) is no title. Bare, it is the given name in # every position; dotted, H2's shape still takes it at the head of # the given part, and vocabulary no longer does anywhere else. Case("wm_bare_is_the_given_name", "Wm Smith", {"given": "Wm", "family": "Smith"}, classification="fix(#789)", notes="1.4.0 through 2.5.0 read title 'Wm' and no given name"), Case("wm_bare_behind_a_title_is_the_given_name", "Dr. Wm Smith", {"title": "Dr.", "given": "Wm", "family": "Smith"}, classification="fix(#789)"), Case("wm_dotted_opening_the_name_is_the_given_name", "Wm. Smith", {"given": "Wm.", "family": "Smith"}, classification="fix(#789)", notes="a given name written short outranks rules/H2's shape " "(#752, phase 2k): 1.4.0 through 2.6.0 read title 'Wm.'"), # -- #789, #752 phase 2k: a given name written short outranks rules/H2's # shape. The reading moves only where the shape fired; the bare word # and the dotted word anywhere else read as they did. Case("chas_dotted_opening_the_name_is_the_given_name", "Chas. Smith", {"given": "Chas.", "family": "Smith"}, classification="fix(#789)", notes="1.4.0 through 2.6.0 read title 'Chas.'"), Case("chas_dotted_behind_a_title_is_the_given_name", "Dr. Chas. Smith", {"title": "Dr.", "given": "Chas.", "family": "Smith"}, classification="fix(#789)", notes="the shape chained it into the title run: 1.4.0 through " "2.6.0 read title 'Dr. Chas.' with no given name"), Case("ma_dotted_behind_a_salutation_is_the_given_name", "Mrs Ma. Liza Cruz", {"title": "Mrs", "given": "Ma.", "middle": "Liza", "family": "Cruz"}, classification="fix(#789)", notes="#853's PSC shape, Filipino María written short behind a " "salutation: 1.4.0 through 2.6.0 read title 'Mrs Ma.', " "given 'Liza'"), Case("jo_dotted_opening_the_name_is_the_given_name", "Jo. Rizal Cruz", {"given": "Jo.", "middle": "Rizal", "family": "Cruz"}, classification="fix(#789)", notes="Filipino José written short: 1.4.0 through 2.6.0 read " "title 'Jo.', given 'Rizal'"), Case("wm_dotted_after_a_family_comma_in_capitals", "SMITH, WM. DAVID", {"given": "WM.", "middle": "DAVID", "family": "SMITH"}, classification="fix(#789)", notes="the FEC donor list's shape (20 names): 1.4.0 through " "2.6.0 read title 'WM.', given 'DAVID'"), Case("chas_dotted_under_family_first_is_placed_by_the_order", "Chas. Smith", {"given": "Smith", "family": "Chas."}, policy=Policy(name_order=FAMILY_FIRST), classification="fix(#789)", notes="out of the title, the word is a name word the declared " "order places, as 'Jack Smith' reads under it"), Case("ma_bare_opening_the_name_is_unchanged", "Ma Smith", {"given": "Ma", "family": "Smith"}, notes="the shape never read a bare word, so the reading has " "nothing to outrank"), Case("ma_dotted_inside_the_name_is_unchanged", "John Ma. Smith", {"given": "John", "middle": "Ma.", "family": "Smith"}, notes="the shape reads only the head of the given part"), Case("wm_dotted_trailing_is_a_name_word", "John Smith Wm.", {"given": "John", "middle": "Smith", "family": "Wm."}, notes="H5 read a listed `wm` as a title here from 2.3.0 " "through 2.5.0; unlisted, it is a name word again"), Case("wm_dotted_trailing_after_a_family_comma", "Smith, John Wm.", {"given": "John", "middle": "Wm.", "family": "Smith"}), # -- #316(a): the trailing title RUN. The leading slot has a SHAPE # rule that outranks vocabulary (H2); the trailing slot has no # shape rule and reads vocabulary only. #316(b), the bare-safe # subset ('Smith Dr'), is deliberately out. Case("title_word_trailing_run_chains", "John Smith Prof. Dr.", {"title": "Prof. Dr.", "given": "John", "family": "Smith"}, classification="fix(#316)", notes="a RUN, mirroring H3 -- one word only would leave " "'Prof.' a name word"), Case("title_word_trailing_joins_the_leading_run_in_input_order", "Dr. John Smith Prof.", {"title": "Dr. Prof.", "given": "John", "family": "Smith"}, classification="fix(#316)", notes="the title view joins TITLE tokens in token order, so " "leading then trailing needs no rendering change"), Case("title_word_trailing_leaves_one_name_word", "Smith Prof.", {"title": "Prof.", "family": "Smith"}, classification="fix(#316)", notes="the floor: the walk stops with one name piece " "standing, and H1 then names it the family. Nothing is " "reported -- the trailing title is a Role.TITLE by the " "time the lone-name-word emitter looks, so H1 decided " "the field and O5's convention did not, the same answer " "'Dr. Smith' gets"), Case("title_word_trailing_behind_a_peeled_suffix", "John Smith Prof. Jr.", {"title": "Prof.", "given": "John", "family": "Smith", "suffix": "Jr."}, classification="fix(#316)", notes="the suffix peel takes its run first and the title " "walk reads what it left"), Case("title_word_trailing_ahead_of_a_suffix_word", "John Smith Jr. Prof.", {"title": "Prof.", "given": "John", "family": "Smith", "suffix": "Jr."}, classification="fix(#316)", notes="the same reading with the two words swapped, and the " "reason the first peel is PROVISIONAL: 'Prof.' stood " "behind 'Jr.', so that peel halted there, and the walk " "then removed the very word that had stopped it. The " "walk's piece is spliced out and one peel runs over " "what stands, reading 'Jr.' the suffix it is; a single " "pass promoted a generational suffix to the family " "name"), Case("title_word_trailing_is_transparent_to_the_suffix_peel", "John Smith MA Prof.", {"title": "Prof.", "given": "John", "family": "Smith", "suffix": "MA"}, classification="fix(#316)", notes="the principle the two peels serve: 'X Prof. Y' reads " "exactly as 'X Y' reads, plus the title. This is 'John " "Smith MA' -- suffix 'MA', which reported once before #785 " "settled its capitals (rules/S6), because the reports come " "from the single peel over the spliced pieces. Collecting " "both peels' picks instead reported the same coin flip " "twice"), # MOVED by #289, not deleted: spliced, this is 'John MA' plus a # title, and 'MA' is written in capitals inside a mixed-case name # -- the caps lean now takes it with no words to spare where the # old reserve kept it the family (decisions/S6). Case("title_word_trailing_run_is_read_to_a_fixed_point", "John Prof. MA Prof.", {"title": "Prof. Prof.", "family": "John", "suffix": "MA"}, classification="fix(#316)", notes="transparency for a SECOND title, which peel and chain " "reading to a FIXED POINT buys and one re-peel did " "not: the chain takes the last 'Prof.', the peel over " "what is left un-peels 'MA' and re-exposes the first " "'Prof.', and only asking the chain again takes it. " "Iterating once read title 'Prof.', given 'John', " "family 'Prof.', suffix 'MA' -- which is 'John Prof. " "MA' plus a title in no reading at all. Found by the " "/simplify round on the bundle, 2026-09-09; 1.4.0 read " "first 'John' / middle 'Prof. MA' / last 'Prof.' " "(measured 2026-09-09), so the row is #316's parity " "break either way and the round decided only which " "reading it is. #289 moves it again: the caps lean " "takes 'MA' as a credential rather than the family " "(decisions/S6)"), Case("title_word_trailing_reads_as_the_leading_title_does", "John Prof. MA", {"title": "Prof.", "family": "John", "suffix": "MA"}, classification="fix(#316)", notes="the same principle where the peel's answer DEPENDS on " "the spliced list: this is 'John MA' plus a title and " "reads exactly as 'Prof. John MA' does. #289 moved this " "row: 'MA' is written in capitals inside a mixed-case " "name, so the caps lean takes it with no words to " "spare, so this spelling no longer shows the reserve at " "work. The one-case row below does (decisions/S6)"), Case("title_word_trailing_keeps_the_bare_acronym_reserve", "JOHN PROF. MA", {"title": "PROF.", "given": "JOHN", "family": "MA"}, ambiguities=("suffix-or-name",), classification="fix(#316)", notes="the row above in one case, where the capitals say " "nothing and S4's reserve still decides: read as " "'PROF. JOHN MA' is, 'MA' stays the family because " "taking it would leave none. 1.4.0 read last 'PROF.', " "suffix 'MA'"), Case("title_word_trailing_between_two_numerals", "John Smith V Prof. VI", {"title": "Prof.", "given": "John", "middle": "Smith V", "family": "VI"}, classification="fix(#316)", notes="'John Smith V VI' plus a title, reports and all. The " "numeral fork reads the piece BEFORE the numeral, and " "over the spliced list that piece is 'V', an initial " "shape, so the fork declines and nothing is reported. " "Two peels each reporting their own last piece read " "suffix 'V VI' and reported the fork twice"), Case("title_word_trailing_unlisted_abbreviation_is_a_name_word", "John Smith Xyz.", {"given": "John", "middle": "Smith", "family": "Xyz."}, classification="parity", notes="negative control, and the doctrine: the leading slot " "has a SHAPE rule that outranks vocabulary (H2), the " "trailing slot reads vocabulary only", shape=1), Case("title_word_trailing_bare_is_a_name_word", "John Smith Sir", {"given": "John", "middle": "Smith", "family": "Sir"}, classification="parity", notes="negative control: no period, so no claim -- TITLES " "holds ordinary surnames and a bare trailing title word " "is a name word (#316(b), deliberately out)"), Case("title_word_trailing_ordinary_surname_is_a_name_word", "Mary Jane Pope", {"given": "Mary", "middle": "Jane", "family": "Pope"}, classification="parity", notes="negative control: the decisions' trailing-position " "rule that must NOT be adopted, still not adopted"), Case("title_word_trailing_credential_is_a_suffix", "John Smith Esq.", {"given": "John", "family": "Smith", "suffix": "Esq."}, classification="parity", notes="negative control: the suffix peel runs first, so a " "period-marked post-nominal never reaches the walk"), Case("title_word_trailing_leading_slot_is_unchanged", "Esq. Smith", {"title": "Esq.", "family": "Smith"}, classification="parity", notes="negative control: H2 stays unconditional and S2's 'a " "suffix never opens the string' stands (#316 open " "question 2 declined)"), Case("title_word_trailing_in_a_parenthetical", "Andrew Perkins (Mgr.)", {"title": "Mgr.", "given": "Andrew", "family": "Perkins"}, classification="fix(#316)", notes="the fifth corpus name the walk reaches, found by " "measurement: the trailing period keeps the " "parenthetical out of nickname parsing and 'mgr' is " "title vocabulary, so the word arrives in the trailing " "slot as any other piece would"), Case("title_word_trailing_leaves_a_joined_title_unit_standing", "John of Bishop Prof.", {"title": "Prof.", "family": "John of Bishop"}, ambiguities=("title-or-name",), classification="fix(#316)", notes="H7's join clause, reached through the trailing walk: " "the unit left standing is the one H7 already reports " "for on its own ('John of Bishop'), and the walk is " "what makes it the only one. The single-word half of " "H4 cannot be reached this way -- a lone title-" "vocabulary word in front of a trailing title is taken " "by the LEADING run first ('Pope Prof.' reads title " "'Pope')"), Case("title_word_trailing_after_a_family_comma", "Smith, John Prof.", {"title": "Prof.", "given": "John", "family": "Smith"}, classification="fix(#316)", notes="the comma path's walk gets the same rule: a name word " "in segment 1 keeps `_comma.decide` from reading the " "segment as the postnominal part, so the trailing title had no " "route to 'title' there either. Contrast the no-name " "segment ('Smith, Dr.', pinned above as " "family_comma_lone_title): that shape routes through " "`_comma.decide` at group's head, a different " "mechanism, untouched"), Case("title_word_trailing_after_a_family_comma_run", "Smith, John Prof. Dr.", {"title": "Prof. Dr.", "given": "John", "family": "Smith"}, classification="fix(#316)"), Case("title_word_trailing_after_a_family_comma_ahead_of_a_suffix", "Smith, John Prof. Jr.", {"title": "Prof.", "given": "John", "family": "Smith", "suffix": "Jr."}, classification="fix(#316)", notes="'Smith, John Jr.' plus a title. The walk's candidates " "are the pieces this segment does NOT read as a " "suffix, which is this path's answer to the peel the " "no-comma path runs first, so the walk reaches 'Prof.' " "past the postnominal behind it; over every piece it " "found no title at all"), Case("title_word_trailing_after_a_family_comma_ahead_of_a_numeral", "Smith, John Prof. V", {"title": "Prof.", "given": "John", "family": "Smith", "suffix": "V"}, classification="fix(#316)", notes="the same, through the LENIENT tail test (#144) rather " "than the strict one: 'V' is what this segment reads " "as its suffix, so it is not a candidate either and " "'Smith, John Prof. V' is 'Smith, John V' plus a " "title. Filtering the candidates on the strict suffix " "test alone left this a middle 'Prof.'"), Case("title_word_trailing_after_a_family_comma_behind_a_numeral", "Smith, John V Prof.", {"title": "Prof.", "given": "John", "family": "Smith", "suffix": "V"}, classification="fix(#316)", notes="the mirror of the row above, and what makes the " "lenient test read the pieces as if the title were " "absent: the walk took the last piece, so 'V' is where " "this segment's name ends and the test applies to it. " "Against the segment's literal last piece, 'V' was a " "middle initial here and a suffix one word earlier"), Case("title_word_trailing_after_a_family_comma_behind_an_initial", "Smith, John V. Prof.", {"title": "Prof.", "given": "John", "middle": "V.", "family": "Smith"}, classification="fix(#316)", notes="the boundary of the row above, kept: #432 reads the " "period as the abbreviation mark it is, so 'V.' is a " "middle initial -- 'Smith, John V.' plus a title"), Case("contested_title_alone_at_the_head", "Dean Smith", {"given": "Dean", "family": "Smith"}, ambiguities=("title-or-name",), classification="feat(#752)", notes="H9: a title that may be a given name stands in no " "title phrase, so it is the given name (decision 13 of " "decisions/vocabulary-model), and the fork is reported"), Case("contested_title_after_a_salutation", "Mr Dean Smith", {"title": "Mr", "given": "Dean", "family": "Smith"}, ambiguities=("title-or-name",), classification="feat(#853)", notes="H9 and #853's rule: a salutation is never a phrase " "partner, so the word after it is the given name"), Case("contested_title_before_a_trailing_salutation", "SMITH, DEAN MR.", {"title": "MR.", "given": "DEAN", "family": "SMITH"}, classification="feat(#752)", notes="H9: no title AFTER the word is a partner unless " "the word attaches to it -- FEC writes the salutation " "after the given name, and 'DEAN MR.' read as a phrase " "moved 99 FEC names in the design's spike"), Case("contested_title_before_of", "Dean of Students Jane Smith", {"title": "Dean of Students", "given": "Jane", "family": "Smith"}, classification="feat(#752)", notes="H9: 'of' after the word is its partner, the join " "making the phrase one title"), Case("contested_title_before_a_coordinating_connective", "Dean and Mary Smith", {"given": "Dean and Mary", "family": "Smith"}, notes="H9: 'and' is no partner, so the word is a name " "word and the join reads two given names, as before"), Case("contested_title_after_a_modifier", "Associate Dean Smith", {"title": "Associate Dean", "family": "Smith"}, classification="feat(#752)", notes="H9: a modifier before the word is its partner"), Case("contested_title_attached_to_after", "Major General John Smith", {"title": "Major General", "given": "John", "family": "Smith"}, classification="feat(#752)", notes="H9: major attaches to a military title after it"), Case("contested_title_attached_to_before", "Chief Justice John Roberts", {"title": "Chief Justice", "given": "John", "family": "Roberts"}, classification="feat(#752)", notes="H9: chief attaches to an office, justice's kind; " "until 2026-10-25 justice was excluded from the titles " "and this read title 'Chief', given 'Justice'"), Case("contested_title_after_a_style", "The Hon. Justice Mary Smith", {"title": "The Hon. Justice", "given": "Mary", "family": "Smith"}, classification="feat(#752)", notes="H9: a style before the word is its partner"), Case("contested_title_after_a_comma_in_capitals", "John Smith, DEAN", {"given": "John", "family": "Smith", "suffix": "DEAN"}, ambiguities=("suffix-or-name",), notes="H9: outside a phrase the word reads as the " "unlisted word it was, which rules/C6 reads as a " "capitals credential here"), Case("contested_style_after_a_modifier", "His Grace John Smith", {"title": "His Grace", "given": "John", "family": "Smith"}, classification="fix(#752)", notes="H9: grace is a style that may be a given name, and 'his' " "is its partner; master read title 'His', given 'Grace' " "(Derek, 2026-10-26)"), Case("contested_style_alone", "Grace Smith", {"given": "Grace", "family": "Smith"}, classification="feat(#752)", notes="H9: no partner, so the given name; and no listed phrase " "holds 'grace', a title only behind a style or a " "modifier, so nothing is reported (Derek, 2026-10-26)"), Case("contested_style_alone_in_capitals", "GRACE SMITH", {"given": "GRACE", "family": "SMITH"}, classification="feat(#752)", notes="H9: the row above in capitals, as TSE writes it"), Case("contested_rank_after_a_modifier", "Field Marshal Bernard Montgomery", {"title": "Field Marshal", "given": "Bernard", "family": "Montgomery"}, classification="fix(#752)", notes="H9: marshal is a rank that may be a given name, 'field' " "its partner; 1.4.0 through 2.6.0 read title 'Field', " "given 'Marshal' (#606's accepted cost)"), Case("contested_rank_attached_to", "Sergeant Major John Smith", {"title": "Sergeant Major", "given": "John", "family": "Smith"}, classification="fix(#752)", notes="H9: sergeant attaches to a military title after it"), Case("contested_office_alone_held_by_chief", "Justice Smith", {"given": "Justice", "family": "Smith"}, ambiguities=("title-or-name",), classification="feat(#752)", notes="H9: no partner, so the given name, reported: 'chief " "justice' holds the word, through chief's attaching to " "the office kind"), Case("contested_rank_alone_held_by_its_own_phrase", "Major Smith", {"given": "Major", "family": "Smith"}, ambiguities=("title-or-name",), classification="feat(#752)", notes="H9: no partner, so the given name, reported: 'major " "general' and 'sergeant major' hold the word, phrases " "naming it rather than its kind"), Case("contested_rank_after_a_rank_that_attaches_elsewhere", "Sgt Marshal Smith", {"title": "Sgt", "given": "Marshal", "family": "Smith"}, ambiguities=("title-or-name",), classification="feat(#752)", notes="H9: sergeant attaches to the word 'major', not to every " "military title; attaching it to the kind made 'Sgt " "Marshal' a phrase and lost the given name (the review of " "the fix commits)"), Case("contested_rank_alone", "Marshal Yanda", {"given": "Marshal", "family": "Yanda"}, ambiguities=("title-or-name",), classification="feat(#752)", notes="H9: no partner, so the given name, reported: a listed " "phrase holds 'marshal' ('chief marshal'), so it may head " "a title"), Case("contested_rank_after_a_modifier_air", "Air Marshal John Smith", {"title": "Air Marshal", "given": "John", "family": "Smith"}, classification="fix(#752)", notes="H9: 'air' is a modifier and marshal's partner"), Case("contested_style_with_no_partner_before_a_contested_word", "Grace Dean Smith", {"given": "Grace", "middle": "Dean", "family": "Smith"}, classification="feat(#752)", notes="H9: a style is a partner only where it is a title there; " "'Grace' stands in no phrase, so neither does 'Dean', and " "neither reports: 'Grace' is in no listed phrase and " "'Dean' is not the first name word"), Case("contested_title_after_an_empty_head", ", Dean", {"given": "Dean"}, notes="H9: the comma has marked the given name, so no report"), Case("contested_title_after_a_nickname_head", "(Bob), Dean", {"given": "Dean", "nickname": "Bob"}, notes="H9: the comma has marked the given name, so no report"), Case("lone_modifier_at_the_head", "Wing Lau", {"given": "Wing", "family": "Lau"}, classification="fix(#752)", notes="H9, decision 10: a modifier is a title only with a " "head title after it in its run; read title 'Wing', " "family 'Lau' before (CLDR's conventions row reads given)"), Case("modifier_before_its_head", "Wing Commander Lau", {"title": "Wing Commander", "family": "Lau"}, notes="H9's boundary: the head title follows"), Case("modifier_after_a_salutation", "Mr Wing Lau", {"title": "Mr", "given": "Wing", "family": "Lau"}, classification="fix(#752)", notes="H9: a head title before a modifier is not its " "phrase; read title 'Mr Wing', family 'Lau' before"), Case("modifier_in_a_connectives_object", "President of the United States Barack Obama", {"title": "President of the United States", "given": "Barack", "family": "Obama"}, notes="H9: 'united' and 'states' are modifiers with no " "head after them, standing in the object of 'of the'"), Case("modifier_before_of", "Mother Superior of the Convent Mary Smith", {"title": "Mother Superior of the Convent", "given": "Mary", "family": "Smith"}, notes="H9: 'of' after a modifier is its phrase, as master read " "it; dropping that partner in the first review round " "broke this (the second review)"), Case("modifier_in_the_object_of_the", "The Grand Lau", {"title": "The Grand", "family": "Lau"}, notes="H9: 'the' carries the object as 'of' does, as master " "read it (the second review of #752 phase 2i)"), Case("modifier_before_a_family_comma", "Wing Smith, John", {"given": "John", "family": "Wing Smith"}, classification="fix(#752)", notes="H9 at the head of the name before a family comma: " "H6's peel read title 'Wing', family 'Smith'"), Case("modifier_in_a_comma_part", "John Smith, Vice", {"title": "Vice", "given": "John", "family": "Smith"}, notes="H9: after a comma a modifier reads as any title " "does, the part being the person's role (decision 10 " "measured 247 of 367 ctgov comma-part moves reversing the " "name)"), Case("given_name_modifier_in_a_comma_part", "Lau, Wing Kai", {"given": "Wing", "middle": "Kai", "family": "Lau"}, classification="fix(#752)", notes="H9: a modifier that may be a given name is a title after " "a comma only with a head title after it; read title " "'Wing', given 'Kai' before (#752 phase 2j)"), Case("given_name_modifier_alone_after_a_comma", "Lau, Wing", {"given": "Wing", "family": "Lau"}, classification="fix(#752)", notes="H9: read title 'Wing', no given name, before"), Case("given_name_modifier_after_an_empty_head", ", Wing", {"given": "Wing"}, classification="fix(#752)", notes="H9: read title 'Wing' before"), Case("given_name_modifier_before_its_head_in_a_comma_part", "Lau, Wing Commander", {"title": "Wing Commander", "family": "Lau"}, notes="H9's boundary: the head title follows, after a comma too"), Case("given_name_modifier_after_a_full_name_and_comma", "John Smith, Wing", {"given": "Wing", "family": "John Smith"}, classification="fix(#752)", notes="H9: reverses as 'John Smith, Dean' does since phase 2i; " "read title 'Wing', given 'John' before (#752 phase 2j)"), Case("given_name_modifier_past_a_second_comma", "Smith, John, Wing", {"given": "John", "family": "Smith", "suffix": "Wing"}, ambiguities=("comma-structure",), classification="fix(#752)", notes="C2, as 'Smith, John, Dean' reads; title 'Wing' before"), Case("given_name_modifier_ending_the_name", "John Smith Wing.", {"given": "John", "middle": "Smith", "family": "Wing."}, notes="H9 over H5: a modifier that may be a given name needs its " "head at the end too; 1.4.0's reading, which 2.3.0 " "through 2.6.0 read as title 'Wing.'"), Case("given_name_modifier_before_a_dotted_suffix", "John Smith Wing. Jr.", {"title": "Wing.", "given": "John", "family": "Smith", "suffix": "Jr."}, notes="H9's Accepted clause: H2's shape reads the period-marked " "suffix as a head in the run, as master read it; an " "unmarked 'MD' behind gives family 'Wing.' (#752 phase 2j)"), Case("given_name_modifier_after_the_and_a_style", "The Hon. Wing Lau", {"title": "The Hon. Wing", "family": "Lau"}, notes="H9's Accepted clause: the object of 'the' keeps the " "title, as master read it (#752 phase 2j)"), Case("given_name_title_mahdi_at_the_head", "Mahdi Karimi", {"given": "Mahdi", "family": "Karimi"}, classification="fix(#867)", notes="H9: no listed phrase holds 'mahdi', so no report; read " "title 'Mahdi', no given name, 1.4.0 through 2.6.0 " "(#752 phase 2j)"), Case("given_name_title_mahdi_after_a_salutation", "Mr Mahdi Karimi", {"title": "Mr", "given": "Mahdi", "family": "Karimi"}, classification="fix(#867)", notes="H9: a salutation is no partner (#853); read title " "'Mr Mahdi' before"), Case("given_name_title_mahdi_after_a_family_comma", "Karimi, Mahdi", {"given": "Mahdi", "family": "Karimi"}, classification="fix(#867)", notes="H9; read title 'Mahdi' before"), Case("given_name_title_mahdi_after_an_empty_head", ", Mahdi", {"given": "Mahdi"}, classification="fix(#867)", notes="H9; read title 'Mahdi' before"), Case("given_name_title_mahdi_after_a_title", "Imam Mahdi Karimi", {"title": "Imam", "given": "Mahdi", "family": "Karimi"}, classification="fix(#867)", notes="H9: a title that is no style, modifier or attaching " "partner leaves the word a given name; read title " "'Imam Mahdi' before"), Case("given_name_title_mahdi_as_an_epithet", "Muhammad Ahmad al-Mahdi", {"given": "Muhammad", "middle": "Ahmad", "family": "al-Mahdi"}, notes="the epithet the title reading names reads as the family " "in 1.4.0, 2.6.0 and now: the reading never served it"), Case("given_name_title_mahdi_as_an_epithet_after_a_comma", "Muhammad Ahmad, the Mahdi", {"given": "the Mahdi", "family": "Muhammad Ahmad"}, classification="fix(#867)", notes="H9's Accepted clause: the epithet with its article reads " "as the given name; read title 'the Mahdi' in every " "release before (#752 phase 2j)"), Case("given_name_title_mahdi_ending_the_name", "John Karimi Mahdi.", {"given": "John", "middle": "Karimi", "family": "Mahdi."}, notes="H9; 1.4.0's reading, which 2.3.0 through 2.6.0 read as " "title 'Mahdi.' (H5)"), Case("given_name_title_bard_at_the_head", "Bard Larsen", {"given": "Bard", "family": "Larsen"}, classification="fix(#867)", notes="H9: 'Bard' is mostly Norwegian 'Bård' written without " "its accent; read title 'Bard' before (#752 phase 2j)"), Case("given_name_title_bard_in_capitals_after_a_comma", "LARSEN, BARD", {"given": "BARD", "family": "LARSEN"}, classification="fix(#867)", notes="H9, the FEC listing form; read title 'BARD' before"), Case("given_name_title_bard_as_a_surname", "Ola Bard", {"given": "Ola", "family": "Bard"}, notes="a trailing bare title word was already a name word"), Case("given_name_title_buddha_opening_a_compound", "Buddha Bahadur Tamang", {"given": "Buddha", "middle": "Bahadur", "family": "Tamang"}, classification="fix(#869)", notes="H9: the first half of a Nepali compound given name; read " "title 'Buddha', given 'Bahadur' 1.4.0 through 2.6.0 " "(#752 phase 2j)"), Case("given_name_title_gyani_opening_a_compound", "Gyani Maya Gurung", {"given": "Gyani", "middle": "Maya", "family": "Gurung"}, classification="fix(#869)", notes="H9; read title 'Gyani', given 'Maya' before"), Case("given_name_title_siddha_after_a_family_comma", "Joshi, Siddha Raj", {"given": "Siddha", "middle": "Raj", "family": "Joshi"}, classification="fix(#869)", notes="H9; read title 'Siddha', given 'Raj' before"), Case("given_name_title_buddha_after_a_salutation", "Mr Buddha Tamang", {"title": "Mr", "given": "Buddha", "family": "Tamang"}, classification="fix(#869)", notes="H9: a salutation is no partner (#853); read title " "'Mr Buddha' before"), Case("given_name_title_buddha_behind_a_style", "His Holiness Buddha Ram", {"title": "His Holiness Buddha", "family": "Ram"}, notes="H9: a style before it is its partner, as master read it"), Case("given_name_title_gyani_as_the_sikh_title", "Gyani Zail Singh", {"given": "Gyani", "middle": "Zail", "family": "Singh"}, classification="fix(#869)", notes="H9's Accepted clause: the Sikh title spelled 'Gyani' reads " "as the given name; 'Giani' is still the title"), Case("sikh_title_in_its_usual_spelling", "Giani Zail Singh", {"title": "Giani", "given": "Zail", "family": "Singh"}, notes="'giani' has no given reading; the title phrase's boundary"), Case("given_name_title_buddha_before_a_family_comma", "Buddha Tamang, Ram", {"given": "Ram", "family": "Buddha Tamang"}, notes="H6 no longer peels a contested word from the family part; " "1.4.0's reading, which 2.5.0 through 2.6.0 read as title " "'Buddha', family 'Tamang'"), Case("period_marked_word_before_and", "Dean. and Mary Smith", {"given": "Dean. and Mary", "family": "Smith"}, notes="H9's connective precedence: the join takes a period-marked " "word before H2's shape is asked, as 2.6.0 read it"), Case("buddha_alone_after_a_title", "Lord Buddha", {"title": "Lord", "family": "Buddha"}, notes="1.4.0's fields; H4 had reported title-or-name on an input that was all " "vocabulary; 'Buddha' is a name word now, as 'Mahdi' is"), Case("given_name_title_king_at_the_head", "King Curtis", {"given": "King", "family": "Curtis"}, ambiguities=("title-or-name",), classification="fix(#348)", notes="H9: 'king' is a title only inside a title phrase; read " "title 'King', given 'Curtis' 1.4.0 through 2.6.0 " "(#752 phase 2j)"), Case("given_name_title_king_after_a_salutation", "Mr King Smith", {"title": "Mr", "given": "King", "family": "Smith"}, ambiguities=("title-or-name",), classification="fix(#853)", notes="H9: a salutation is no partner; read title 'Mr King' " "before"), Case("given_name_title_king_in_the_listing_form", "SMITH, KING MR", {"title": "MR", "given": "KING", "family": "SMITH"}, classification="fix(#752)", notes="H9, FEC's listing form; read title 'KING MR' before"), Case("king_behind_a_style_keeps_the_title", "His Majesty King Charles", {"title": "His Majesty King", "given": "Charles"}, classification="fix(#489)", notes="H9: a style is a partner, and the title addresses by the " "given name, as master read it; 1.4.0 read last 'Charles'"), Case("monarch_with_a_bare_leading_king", "King Henry", {"given": "King", "family": "Henry"}, ambiguities=("title-or-name",), classification="fix(#752)", notes="H9's Accepted clause: a monarch written with the bare " "title reads it as the given name (Derek, 2026-10-26); " "read title 'King', given 'Henry' 1.4.0 through 2.6.0"), Case("pope_still_addresses_by_the_given_name", "Pope Francis", {"title": "Pope", "given": "Francis"}, notes="'pope' has no given reading: still the title that " "addresses by the given name, H1"), Case("given_name_title_sultana_at_the_head", "Sultana Begum", {"given": "Sultana", "family": "Begum"}, classification="fix(#752)", notes="H9; read title 'Sultana', no given name, 1.4.0 through " "2.6.0 (#752 phase 2j)"), Case("given_name_title_sultana_before_a_credential_comma", "Nasreen Sultana, MBBS", {"given": "Nasreen", "family": "Sultana", "suffix": "MBBS"}, ambiguities=("suffix-or-name",), classification="fix(#752)", notes="two name words now stand before the comma, so the " "credential is the suffix; read given 'MBBS', family " "'Nasreen Sultana' before"), Case("given_name_title_sultan_after_a_salutation", "Mr Sultan Khan", {"title": "Mr", "given": "Sultan", "family": "Khan"}, classification="fix(#853)", notes="#853's own example; read title 'Mr Sultan' before"), Case("given_name_title_sultan_after_a_family_comma", "Khan, Sultan", {"given": "Sultan", "family": "Khan"}, classification="fix(#752)", notes="H9; read title 'Sultan' before"), Case("sultan_behind_a_style_keeps_the_title", "His Majesty Sultan Hassanal Bolkiah", {"title": "His Majesty Sultan", "given": "Hassanal", "family": "Bolkiah"}, notes="H9: a style is a partner, as master and 1.4.0 read it"), Case("ruler_with_a_bare_leading_sultan", "Sultan Hassanal Bolkiah", {"given": "Sultan", "middle": "Hassanal", "family": "Bolkiah"}, classification="fix(#752)", notes="H9's Accepted clause, the class accepted for 'king' " "(Derek, 2026-10-26); read title 'Sultan' before"), Case("sultan_behind_a_bare_leading_sheikh", "Sheikh Sultan Khan", {"given": "Sheikh", "middle": "Sultan", "family": "Khan"}, classification="fix(#752)", notes="H9: a style read as a name word is no title there, so it " "partners nothing, as for 'shaikh'; read title " "'Sheikh Sultan' 1.4.0 through 2.6.0"), Case("sultan_behind_a_styled_sheikh_keeps_the_title", "His Highness Sheikh Sultan Khan", {"title": "His Highness Sheikh Sultan", "family": "Khan"}, notes="H9: behind a style 'sheikh' is a title, and a style " "partners the contested word behind it"), Case("given_name_title_sheikh_at_the_head", "Sheikh Ahmed Khan", {"given": "Sheikh", "middle": "Ahmed", "family": "Khan"}, classification="fix(#752)", notes="H9; read title 'Sheikh', given 'Ahmed' 1.4.0 through " "2.6.0 (#752 phase 2j)"), Case("given_name_title_sheikh_after_a_salutation", "Mr Sheikh Ahmed", {"title": "Mr", "given": "Sheikh", "family": "Ahmed"}, classification="fix(#853)", notes="#853's table: a salutation is no partner; read title " "'Mr Sheikh' before"), Case("given_name_title_sheikh_after_a_family_comma", "Khan, Sheikh", {"given": "Sheikh", "family": "Khan"}, classification="fix(#752)", notes="H9; read title 'Sheikh' before"), Case("given_name_title_sheikh_before_a_family_comma", "Sheikh Smith, Ahmed", {"given": "Ahmed", "family": "Sheikh Smith"}, notes="H6 peels only a title, so the surname keeps the word, as " "1.4.0 read it; master read title 'Sheikh'"), Case("sheikh_behind_a_style_keeps_the_title", "His Highness Sheikh Ahmed Khan", {"title": "His Highness Sheikh", "given": "Ahmed", "family": "Khan"}, notes="H9: a style is a partner, as master and 1.4.0 read it"), Case("ruler_with_a_bare_leading_sheikh", "Sheikh Mohammed bin Rashid Al Maktoum", {"given": "Sheikh", "middle": "Mohammed bin Rashid", "family": "Al Maktoum"}, classification="fix(#752)", notes="H9's Accepted clause, the class 'king', 'sultan', " "'shaikh' and 'prince' took; read title 'Sheikh' before"), Case("given_name_title_cheikh_at_the_head", "Cheikh Anta Diop", {"given": "Cheikh", "middle": "Anta", "family": "Diop"}, classification="fix(#752)", notes="H9: the French spelling, a common Senegalese given " "name; read title 'Cheikh', given 'Anta' before"), Case("given_name_title_shekh_at_the_head", "Shekh Abdul Rahim", {"given": "Shekh", "middle": "Abdul", "family": "Rahim"}, classification="fix(#752)", notes="H9: the spelling Nepal's romanization writes. Read " "title 'Shekh', given 'Abdul Rahim' (P5's licensed join) " "2.2.0 through 2.6.0, and given 'Abdul', last 'Rahim' in " "1.4.0; as a name word it licenses no join, so the bound " "word is a middle name"), Case("shaykh_stays_a_title", "Shaykh Hamza Yusuf", {"title": "Shaykh", "given": "Hamza", "family": "Yusuf"}, notes="'shaykh' has no given reading: no filed data writes it " "as a given name, so it is still a title everywhere"), Case("given_name_title_shaikh_at_the_head", "Shaikh Ahmed Khan", {"given": "Shaikh", "middle": "Ahmed", "family": "Khan"}, classification="fix(#752)", notes="H9; read title 'Shaikh', given 'Ahmed' 1.4.0 through " "2.6.0 (#752 phase 2j)"), Case("given_name_title_shaikh_after_a_salutation", "Mr Shaikh Ahmed", {"title": "Mr", "given": "Shaikh", "family": "Ahmed"}, classification="fix(#853)", notes="H9: a salutation is no partner; read title 'Mr Shaikh' " "before"), Case("given_name_title_shaikh_after_a_family_comma", "Khan, Shaikh", {"given": "Shaikh", "family": "Khan"}, classification="fix(#752)", notes="H9; read title 'Shaikh' before"), Case("given_name_title_shaikh_before_a_family_comma", "Shaikh Smith, Ahmed", {"given": "Ahmed", "family": "Shaikh Smith"}, notes="H6 peels only a title, so the surname keeps the word, as " "1.4.0 read it; master read title 'Shaikh'"), Case("shaikh_behind_a_style_keeps_the_title", "His Highness Shaikh Ahmed Khan", {"title": "His Highness Shaikh", "given": "Ahmed", "family": "Khan"}, notes="H9: a style is a partner, as master and 1.4.0 read it"), Case("ruler_with_a_bare_leading_shaikh", "Shaikh Hamad bin Isa Al Khalifa", {"given": "Shaikh", "middle": "Hamad bin Isa", "family": "Al Khalifa"}, classification="fix(#752)", notes="H9's Accepted clause, the class accepted for 'king' and " "'sultan'; read title 'Shaikh' before"), Case("sultan_behind_a_bare_leading_shaikh", "Shaikh Sultan Khan", {"given": "Shaikh", "middle": "Sultan", "family": "Khan"}, classification="fix(#752)", notes="H9: a style read as a name word is no title there, so it " "partners nothing; read title 'Shaikh Sultan' before"), Case("given_name_title_prince_at_the_head", "Prince Fielder", {"given": "Prince", "family": "Fielder"}, ambiguities=("title-or-name",), classification="fix(#348)", notes="H9; read title 'Prince', given 'Fielder' 2.3.0 through " "2.6.0 and last 'Fielder' in 1.4.0 (#752 phase 2j)"), Case("given_name_title_prince_after_a_salutation", "Mr Prince Smith", {"title": "Mr", "given": "Prince", "family": "Smith"}, ambiguities=("title-or-name",), classification="fix(#853)", notes="H9: a salutation is no partner; read title 'Mr Prince' " "before"), Case("given_name_title_princess_after_a_salutation", "Ms Princess Smith", {"title": "Ms", "given": "Princess", "family": "Smith"}, ambiguities=("title-or-name",), classification="fix(#853)", notes="H9: a salutation is no partner; read title " "'Ms Princess' before"), Case("given_name_title_princess_after_a_family_comma", "Smith, Princess", {"given": "Princess", "family": "Smith"}, classification="fix(#752)", notes="H9; read title 'Princess', given 'Smith' before"), Case("given_name_title_prince_before_a_family_comma", "Prince Smith, John", {"given": "John", "family": "Prince Smith"}, notes="H6 peels only a title, so the surname keeps the word, as " "1.4.0 read it; master read title 'Prince'"), Case("royal_with_a_bare_leading_prince", "Prince Harry", {"given": "Prince", "family": "Harry"}, ambiguities=("title-or-name",), classification="fix(#752)", notes="H9's Accepted clause, the class 'king' and 'sultan' " "took; read title 'Prince', given 'Harry' 2.3.0 through " "2.6.0 and last 'Harry' in 1.4.0"), Case("princess_royal_behind_a_style", "Her Royal Highness Princess Royal Anne", {"title": "Her Royal Highness Princess Royal", "given": "Anne"}, notes="H9: 'princess' attaches to 'royal', so the modifier behind " "its head is in its phrase (#913); read given 'Royal', " "family 'Anne' 2.7.0"), Case("royal_title_phrase_queen_mother", "Queen Mother Elizabeth", {"title": "Queen Mother", "given": "Elizabeth"}, notes="H9: 'queen' attaches to 'mother'; 2.7.0 read it so, #926 " "given 'Queen', middle 'Mother', and 1.4.0 last " "'Elizabeth'"), Case("royal_title_phrase_consort", "Prince Consort Albert", {"title": "Prince Consort", "given": "Albert"}, classification="fix(#913)", notes="H9: 'prince' attaches to 'consort', a modifier read in " "its phrase behind its head; 'consort' addresses by the " "given name. Read given 'Prince', middle 'Consort' 2.7.0"), Case("royal_title_phrase_princess_royal", "Princess Royal Anne", {"title": "Princess Royal", "given": "Anne"}, classification="fix(#913)", notes="H9: 'princess' attaches to 'royal'; read given " "'Princess', middle 'Royal' 2.7.0 and last 'Anne' in 1.4.0"), Case("royal_alone_is_a_given_name", "Royal Smith", {"given": "Royal", "family": "Smith"}, ambiguities=("title-or-name",), classification="fix(#913)", notes="H9: 'royal' may be a given name, so it is a title only " "in its phrase; 'Princess Royal' holds it, so it reports. " "Read title 'Royal' 1.4.0"), Case("royal_after_a_family_comma_is_the_given_name", "BALES, ROYAL", {"given": "ROYAL", "family": "BALES"}, classification="fix(#913)", notes="H9: a modifier that may be a given name is read everywhere " "as in its run; read title 'ROYAL' 1.4.0 through 2.7.0"), Case("given_name_title_marquis_at_the_head", "Marquis Johnson", {"given": "Marquis", "family": "Johnson"}, classification="fix(#913)", notes="H9; read title 'Marquis' 1.4.0 through 2.7.0"), Case("given_name_title_marquise_after_a_salutation", "Ms Marquise Smith", {"title": "Ms", "given": "Marquise", "family": "Smith"}, classification="fix(#913)", notes="H9: a salutation is no partner; read title " "'Ms Marquise' before"), Case("given_name_title_marquise_after_a_family_comma", "Johnson, Marquise", {"given": "Marquise", "family": "Johnson"}, classification="fix(#913)", notes="H9; read title 'Marquise', given 'Johnson' before"), Case("given_name_title_marquis_before_a_family_comma", "Marquis Smith, John", {"given": "John", "family": "Marquis Smith"}, classification="parity", notes="H6 peels only a title, so the surname keeps the word, as " "1.4.0 read it; 2.7.0 read title 'Marquis'"), Case("marquis_keeps_the_title_before_of", "Marquis of Queensberry", {"title": "Marquis of Queensberry"}, classification="parity", notes="H9: 'of' after the word is its phrase"), Case("noble_with_a_bare_leading_marquis", "Marquis de Lafayette", {"given": "Marquis", "family": "de Lafayette"}, classification="fix(#913)", notes="H9's Accepted clause, the class 'king' and 'prince' " "took: 'de' is no partner; read title 'Marquis' 1.4.0 " "through 2.7.0"), Case("given_name_title_queen_at_the_head", "Queen Latifah", {"given": "Queen", "family": "Latifah"}, ambiguities=("title-or-name",), classification="fix(#913)", notes="H9; read title 'Queen', given 'Latifah' 1.4.0 through " "2.7.0, 'queen' addressing by the given name"), Case("given_name_title_queen_after_a_salutation", "Ms Queen Smith", {"title": "Ms", "given": "Queen", "family": "Smith"}, ambiguities=("title-or-name",), classification="fix(#913)", notes="H9: a salutation is no partner; read title 'Ms Queen', " "given 'Smith' 2.3.0 through 2.7.0, last 'Smith' in 1.4.0"), Case("given_name_title_queen_after_a_family_comma", "Smith, Queen", {"given": "Queen", "family": "Smith"}, classification="fix(#913)", notes="H9; read title 'Queen', given 'Smith' before"), Case("given_name_title_queen_before_a_family_comma", "Queen Smith, Mary", {"given": "Mary", "family": "Queen Smith"}, classification="parity", notes="H6 peels only a title, so the surname keeps the word, as " "1.4.0 read it; 2.7.0 read title 'Queen'"), Case("queen_keeps_the_title_before_of", "Queen of England", {"title": "Queen of England"}, classification="parity", notes="H9: 'of' after the word is its phrase"), Case("monarch_with_a_bare_leading_queen", "Queen Elizabeth II", {"given": "Queen", "family": "Elizabeth", "suffix": "II"}, ambiguities=("title-or-name",), classification="fix(#913)", notes="H9's Accepted clause, the class 'king' took; read title " "'Queen', given 'Elizabeth', suffix 'II' through 2.7.0, " "and first 'Elizabeth', last 'II' in 1.4.0"), Case("monarch_with_queen_alone_after_a_comma", "Elizabeth II, Queen", {"given": "Queen", "family": "Elizabeth", "suffix": "II"}, classification="fix(#913)", notes="H9: a lone word that may be a given name after a comma is " "the listing form's given name; read given 'Elizabeth' " "2.5.0 through 2.7.0 (rules/H1's example until #913), " "last 'Elizabeth' in 1.4.0"), Case("given_name_title_deacon_at_the_head", "Deacon Jones", {"given": "Deacon", "family": "Jones"}, classification="fix(#913)", notes="H9; read title 'Deacon' 1.4.0 through 2.7.0"), Case("given_name_title_deacon_after_a_salutation", "Mr Deacon Smith", {"title": "Mr", "given": "Deacon", "family": "Smith"}, classification="fix(#913)", notes="H9: a salutation is no partner; read title 'Mr Deacon' " "before"), Case("given_name_title_deacon_after_a_family_comma", "Smith, Deacon", {"given": "Deacon", "family": "Smith"}, classification="fix(#913)", notes="H9; read title 'Deacon', given 'Smith' before"), Case("deacon_keeps_the_title_before_of", "Deacon of the Church Jane Smith", {"title": "Deacon of the Church", "given": "Jane", "family": "Smith"}, classification="parity", notes="H9: 'of' after the word is its phrase"), Case("cleric_with_deacon_behind_rev", "The Rev. Deacon John Smith", {"title": "The Rev.", "given": "Deacon", "middle": "John", "family": "Smith"}, classification="fix(#913)", notes="H9's Accepted clause: 'rev' is no partner, so the cleric's " "title reads as the given name; 1.4.0 through 2.7.0 read " "title 'The Rev. Deacon'"), Case("given_name_title_burgess_reports_title_or_name", "Burgess Meredith", {"given": "Burgess", "family": "Meredith"}, ambiguities=("title-or-name",), classification="fix(#913)", notes="H9: 'Chief Burgess' is a listed phrase, so the bare word " "reports; read title 'Burgess' 1.4.0 through 2.7.0"), Case("given_name_title_burgess_before_a_family_comma", "Burgess Smith, John", {"given": "John", "family": "Burgess Smith"}, classification="parity", notes="H6 peels only a title, so the surname keeps the word, as " "1.4.0 read it"), Case("given_name_title_ranger_at_the_head", "Ranger Smith", {"given": "Ranger", "family": "Smith"}, classification="fix(#913)", notes="H9; read title 'Ranger' 1.4.0 through 2.7.0"), Case("given_name_title_chancellor_reports_title_or_name", "Chancellor Smith", {"given": "Chancellor", "family": "Smith"}, ambiguities=("title-or-name",), classification="fix(#913)", notes="H9: 'Vice Chancellor' and 'Lord Chancellor' hold it; read " "title 'Chancellor' 1.4.0 through 2.7.0"), Case("chancellor_keeps_the_title_behind_lord", "Lord Chancellor John Smith", {"title": "Lord Chancellor", "given": "John", "family": "Smith"}, classification="parity", notes="H9: 'lord' attaches to 'chancellor' (#913)"), Case("chancellor_keeps_the_title_behind_deputy", "Deputy Chancellor Ann Lee", {"title": "Deputy Chancellor", "given": "Ann", "family": "Lee"}, classification="parity", notes="H9: 'deputy' attaches to the office kind, as 'chief' does " "(#913)"), Case("chancellor_keeps_the_title_behind_pro", "Pro Chancellor Ann Lee", {"title": "Pro Chancellor", "given": "Ann", "family": "Lee"}, classification="parity", notes="H9: 'pro' attaches to 'chancellor' (#913)"), Case("lord_justice_is_one_title", "Lord Justice Leveson", {"title": "Lord Justice", "family": "Leveson"}, classification="fix(#913)", notes="H9: 'lord' attaches to 'justice'; 1.4.0 through 2.7.0 read " "title 'Lord', given 'Justice'"), Case("lady_justice_is_one_title", "Lady Justice Arden", {"title": "Lady Justice", "family": "Arden"}, classification="fix(#913)", notes="H9: 'lady' attaches to 'justice'; 1.4.0 through 2.7.0 read " "title 'Lady', given 'Justice'"), Case("given_name_modifier_after_a_style", "Hon. Wing Lau", {"title": "Hon.", "given": "Wing", "family": "Lau"}, notes="H9: the modifier's test, not a contested word's style " "partner, decides 'wing'; 'Hon. Dean Smith' reads the title"), Case("given_name_modifier_before_a_salutation_in_a_comma_part", "LAU, WING MR", {"title": "WING MR", "family": "LAU"}, notes="H9: the modifier test takes any title after it as its " "head, a salutation too, so 'WING MR' stays a title where " "'DEAN MR' gives given 'DEAN'; no FEC donor writes it " "(#752 phase 2j, 2026-10-26)"), Case("contested_title_after_a_period_joined_title", "Lt.Gov. Dean Smith", {"title": "Lt.Gov.", "given": "Dean", "family": "Smith"}, ambiguities=("title-or-name",), classification="feat(#752)", notes="H9 reads only the contested and modifier words out of the " "title: a period-joined title, which no wordlist holds " "whole, keeps its tag (the code review of #752 phase 2i)"), Case("modifier_after_a_salutation_pair", "Mr. and Mrs. Wing Lau", {"title": "Mr. and Mrs.", "given": "Wing", "family": "Lau"}, classification="fix(#752)", notes="H9: only 'of' opens a phrase's object; a coordinating " "connective between two salutations does not, so the " "modifier after them has no head (the code review)"), Case("role_word_in_a_comma_part", "John Smith, Resident", {"title": "Resident", "given": "John", "family": "Smith"}, notes="resident is an occupation since 2026-10-25, not a " "modifier: a part written 'Resident' is the role"), Case("bare_salutation_ending_a_family_comma_given_part", "Doe, Jane Mrs", {"title": "Mrs", "given": "Jane", "family": "Doe"}, classification="fix(#835)", notes="H8's reading (#835): the family comma has fixed the surname, so " "the one reading a bare salutation there competes with " "is a middle name, and H5's chain takes it as it takes " "'Doe, Jane Mrs.'"), Case("bare_salutation_ending_a_one_case_given_part", "DOE, JOHN Q MR", {"title": "MR", "given": "JOHN", "middle": "Q", "family": "DOE"}, classification="fix(#835)", notes="the FEC's spelling of the shape (#834), the period " "left off"), Case("bare_salutation_ahead_of_a_suffix_after_a_family_comma", "Doe, John Mr Jr", {"title": "Mr", "given": "John", "family": "Doe", "suffix": "Jr"}, classification="fix(#835)", notes="the walk's candidates leave the suffix out, so the " "salutation is the last of them, as 'Smith, John Prof. " "Jr.' reaches 'Prof.'"), Case("bare_salutation_before_a_bound_given_name_join", "Doe, abdul Mrs", {"title": "Mrs", "given": "abdul", "family": "Doe"}, classification="fix(#835)", notes="rules/P5's join declines a word H8 reads as the title, " "as it declines 'Berg, abdul Prof.': the group-side " "predictor admits the same bare salutations the walk " "does, or the join read given 'abdul Mrs'"), Case("bare_salutations_around_a_suffix_before_a_bound_given_join", "Doe, abdul Mr Jr Mrs", {"title": "Mr Mrs", "given": "abdul", "family": "Doe", "suffix": "Jr"}, classification="fix(#835)", notes="title, suffix, title behind a bound given name: the " "join's decline reads the part through tail_reading, " "whose second chain pass takes 'Mr' only if the " "salutation flag reaches it, as 'Doe, abdul Mr. Jr Mrs.' " "reads"), Case("bare_salutation_behind_a_trailing_particle", "Doe, Jane van Mrs", {"title": "Mrs", "given": "Jane", "family": "van Doe"}, ambiguities=("particle-or-given",), classification="fix(#835)", notes="P6 attaches the particle as though the salutation were " "absent ('Doe, Jane van' reads the same), and reports " "the fork it decides, as 'Doe, Jane van Mrs.' does"), Case("bare_salutation_borne_as_a_name_stays_a_middle_name", "Doe, John Goodman", {"given": "John", "middle": "Goodman", "family": "Doe"}, notes="rules/H8's Except: goodman is a salutation the " "vocabulary refuses after_given, a surname (the counts " "are on en.toml's entry), since a middle name may be a " "surname"), Case("bare_office_word_ending_a_family_comma_given_part", "Doe, John Pope", {"given": "John", "middle": "Pope", "family": "Doe"}, notes="rules/H8's Except: only a salutation qualifies, and an " "office or rank word is borne as a middle name"), Case("bare_salutation_without_a_comma_is_a_name_word", "Jane Doe Mrs", {"given": "Jane", "middle": "Doe", "family": "Mrs"}, notes="rules/H5's bare title word: without the comma the " "surname is not fixed, so the word stays a name word"), Case("title_word_trailing_ahead_of_a_reserved_acronym", "John Smith Prof. MA", {"title": "Prof.", "given": "John", "family": "Smith", "suffix": "MA"}, classification="fix(#316)", notes="'John Smith MA' plus a title, from the other " "direction: S2's peel takes the acronym BEFORE the " "walk here, so the title is the trailing piece and the " "spliced peel reads the same suffix it reads without " "it. The comma path parts company here and is left " "alone -- after a family comma a bare ambiguous " "acronym has been a MIDDLE name since 2.0 ('Smith, " "John MA'), so in 'Smith, John Prof. MA' a name word " "stands behind 'Prof.' and no title is in trailing " "position at all"), Case("cjk_trailing_latin_title_keeps_the_script_order", "毛 泽东 Dr.", {"title": "Dr.", "given": "泽东", "family": "毛"}, classification="fix(#316)", notes="why the walk runs BEFORE the positional read: a Latin " "title at the back of a wholly-Han name is the one " "piece that would make the piece set look " "mixed-script, and a mixed set declines the script " "order. Taken first, the pieces the script test sees " "are all Han and the Han order stands -- this is " "'毛 泽东' plus a title. 1.4.0 read first '毛' / last " "'泽东' / suffix 'Dr.' and master family 'Dr.' " "(measured 2026-09-09)", tolerated=True), Case("title_word_trailing_behind_a_bound_given_pair", "Prof. abdul rahman Prof.", {"title": "Prof. Prof.", "given": "abdul", "family": "rahman"}, classification="fix(#316)", notes="P5's reserve counts the name words assign will leave, " "and a trailing title word is not one of them: this is " "'Prof. abdul rahman' plus a title, which reads given " "'abdul' / family 'rahman' because two name words " "alone do not join. Counting the title word as a word " "to spare joined the pair and read family 'abdul " "rahman' (measured on the bundle's third commit). " "1.4.0 read title 'Prof.' / first 'abdul rahman' / " "last 'Prof.'"), Case("title_word_trailing_behind_a_bound_pair_at_the_peel_reserve", "abdul rahman MA Prof.", {"title": "Prof.", "given": "abdul", "family": "rahman", "suffix": "MA"}, classification="fix(#316)", notes="the row above's shape at S4's bare-ambiguous reserve, " "which is the one place the reserve's own model of " "assign's reading and assign's reading disagreed. This " "is 'abdul rahman MA' plus a title, and that reads " "given 'abdul' / family 'rahman' / suffix 'MA': the " "join declines because peeling the acronym unjoined " "and not joined is a suffix reading the join would " "change. Modelling the peel and the chain as a " "subtraction, the reserve counted 'MA' a name word to " "spare here and not without the title, so the join " "fired and read given 'abdul rahman', family 'MA'. One " "shared reading of the tail is what makes the two " "agree -- found by the /simplify round on the bundle, " "2026-09-09 (decisions/H5). 1.4.0 read first 'abdul " "rahman' / middle 'MA' / last 'Prof.' (measured " "2026-09-09)"), Case("title_word_trailing_behind_a_licensed_bound_pair", "Sir abdul rahman Prof.", {"title": "Sir Prof.", "given": "abdul rahman"}, classification="fix(#316)", notes="the licensed half of the row above, and transparent " "at both ends: the join fires ('sir' asserts a given " "name follows) and the joined pair stays the GIVEN " "name, which is what 'Sir abdul rahman' reads without " "the trailing title. Two fixes were needed and this " "row took both. P5's reserve had counted the trailing " "title word as a name word to spare, which made the " "join fire for the wrong reason; with that corrected " "the join is right and the field was still wrong, H1 " "reading the TITLE ROLE -- both ends of the name by " "then -- as one run keyed 'sir prof', which addresses " "by neither word, so the pair became the family. H1 " "now asks the LEADING run, and 'Sir John Prof.' " "against 'Sir John' is the same movement in one word " "(rules/H1, both are examples there). 1.4.0 read " "title 'Sir' / first 'abdul rahman' / last 'Prof.' " "(measured 2026-09-09)"), Case("title_word_trailing_behind_a_bound_pair_after_a_comma", "Berg, abdul Prof.", {"title": "Prof.", "given": "abdul", "family": "Berg"}, classification="fix(#637)", notes="after a family comma the reserve reads no peel " "(rules/P5), but the join declines a trailing title " "word the H5 run takes, so the title is read as the " "segment with an ordinary given name reads it ('Smith, " "John Prof.'), and as the comma-less 'Sir abdul rahman " "Prof.' above does. Until #637 the row pinned the gap: " "given 'abdul Prof.', as 1.4.0 read it (measured " "2026-09-09)"), Case("a_bound_join_declines_a_title_past_the_second_comma", "John Smith, Jr., abdul Dr. Smith", {"title": "Dr.", "given": "John", "family": "Smith", "suffix": "Jr., abdul Smith"}, ambiguities=("comma-structure",), classification="fix(#640)", notes="a part past the second comma holds no name word, so " "every title word in it is a title (rules/C2) and the " "bound join declines one (rules/P5, #640), as a " "family comma's tail reads since #603 ('Smith, John, " "abdul Dr. Smith'); 1.4.0 and 2.3.0 read suffix 'Jr., " "abdul Dr. Smith'"), Case("a_bound_join_declines_a_bare_title_past_the_second_comma", "John Smith, Jr., abdul Sir Smith", {"title": "Sir", "given": "John", "family": "Smith", "suffix": "Jr., abdul Smith"}, ambiguities=("comma-structure",), classification="fix(#640)", notes="the row above with a bare title word: C2 reads any title " "word past the second comma as a title, period-marked or " "not, so the join declines it too (#643's review: a " "period-only test passed every other row); 1.4.0 and " "2.3.0 read suffix 'Jr., abdul Sir Smith'"), Case("a_bound_join_declines_a_run_of_trailing_titles", "Berg, abdul Prof. Dr.", {"title": "Prof. Dr.", "given": "abdul", "family": "Berg"}, classification="fix(#637)", notes="after a family comma the join asks H5's whole trailing " "run, not whether the title is the last word: 'Prof.' " "stands before another title the run takes as well " "(#643's review: a last-word test passed every other " "row); 1.4.0 read first 'abdul Prof.', suffix 'Dr.', and " "2.3.0 title 'Dr.', first 'abdul Prof.'"), Case("title_run_leading_addresses_over_a_trailing_title", "Sir John Prof.", {"title": "Sir Prof.", "given": "John"}, classification="fix(#316)", notes="rules/H1's clause in one word: the run BEFORE the " "one name word addresses, so this is 'Sir John' plus a " "title and the given name survives the title being " "added. Keying every TITLE token as one run gave 'sir " "prof', which addresses by neither, and read family " "'John'. 1.4.0 read title 'Sir' / first 'John' / last " "'Prof.' (measured 2026-09-09)"), Case("title_run_leading_addresses_over_a_trailing_given_name_title", "Dr. Smith Sir.", {"title": "Dr. Sir.", "family": "Smith"}, classification="fix(#316)", notes="the mirror, and the reading the clause's ordering " "decides: a trailing given-name title does NOT lift " "the leading run's reading, so this is 'Dr. Smith' " "plus a title and the family stands. The composite key " "'dr sir' matched on 'sir' and read given 'Smith'. " "Where NO run stands in front the trailing one does " "decide -- 'Smith Sir.' reads given 'Smith', a " "rules/H1 example unchanged by this. 1.4.0 read " "title 'Dr.' / first 'Smith' / last 'Sir.' (measured " "2026-09-09)"), Case("title_word_trailing_after_a_maiden_take", "Mary Smith née Jones Prof.", {"title": "Prof.", "given": "Mary", "family": "Smith", "maiden": "Jones"}, classification="fix(#535)", notes="rules/H5's M2 boundary, removed by #535: the maiden " "walk now reads the trailing title chain, so the title " "ends the clause and reads as 'Mary Smith Prof.' would " "read it. 2.3.0 read maiden 'Jones Prof.'; 1.4.0 had no " "maiden support and read first 'Mary' / middle 'Smith " "née Jones' / last 'Prof.'"), Case("title_word_trailing_ahead_of_a_maiden_marker", "Mary Jones Prof. née Smith", {"title": "Prof.", "given": "Mary", "family": "Jones", "maiden": "Smith"}, classification="fix(#316)", notes="the mirror: with the marker BEHIND it the title is " "the last piece the walk sees, so it is taken and " "'Mary Jones née Smith' is what is left. Master read " "middle 'Jones' / family 'Prof.'; 1.4.0 read first " "'Mary' / middle 'Jones Prof. née' / last 'Smith' " "(measured 2026-09-09)"), Case("title_word_trailing_in_a_conjunction_unit", "John Smith Prof. and Dr.", {"given": "John", "middle": "Smith", "family": "Prof. and Dr."}, classification="parity", notes="negative control for the ONE-WORD-per-piece gate: the " "conjunction merge made 'Prof. and Dr.' one piece, and " "the tokens of a joined unit are not each a title " "word. The row that PINS that gate -- with it deleted " "the walk takes the unit, because the shape test then " "runs on the piece's first token and 'Prof.' wears the " "period ('John de la Prof.' reads 0 either way; both " "measured 2026-09-09 in test_pieces.py)"), Case("a_salutation_alone_keeps_its_connective", "Mr. and Mrs.", {"title": "Mr. and Mrs."}, ambiguities=(), classification="parity", notes="#614: S2's run is read after P3's connective joins, so " "the join makes one title of the salutation before the H5 " "chain reads it. Read before the joins (#614's first " "design), the chain took 'Mrs.' and left 'and' standing " "as the family name (#614's review)"), Case("a_salutation_alone_keeps_its_ampersand", "Mr. & Mrs.", {"title": "Mr. & Mrs."}, ambiguities=(), classification="parity", notes="the ampersand twin of the row above"), Case("a_joined_salutation_is_one_leading_title", "Mr. and Mrs. Do", {"title": "Mr. and Mrs.", "family": "Do"}, ambiguities=(), classification="parity", notes="#614: the joined salutation is one leading title before " "S2 counts, so 'Do' stands alone and reports nothing; " "counted as three words it was weighed as a credential " "and reported (#614's review)"), Case("a_particle_chain_is_one_word_to_s2", "Freiherr von Berg X.Y.Z.", {"title": "Freiherr", "family": "von Berg X.Y.Z."}, ambiguities=("particle-or-given", "suffix-or-name"), classification="parity", notes="rules/S2 counts the particle run and the word the " "chain takes with it as one name word (#614), so the " "dotted acronym has no word to spare, as every release " "read it; counted word by word it was the credential, " "where 'Freiherr Berg X.Y.Z.' kept it the family (#614's " "review)"), Case("a_connective_join_is_one_word_to_s2", "Juan y Garcia X.Y.Z.", {"given": "Juan y Garcia", "family": "X.Y.Z."}, ambiguities=("suffix-or-name",), classification="parity", notes="P3's join is made before S2 counts (#614), so the joined " "pair is one name word and the dotted acronym has none to " "spare, as every release read it (#614's review)"), Case("a_shape_only_numeral_behind_a_particle_chain", "Juan de la Vega VI", {"given": "Juan", "family": "de la Vega", "suffix": "VI"}, ambiguities=("suffix-or-name",), classification="fix(#424)", notes="'VI' is in no wordlist and the numeral fork reads it by " "shape, so S2's count keeps it out of the particle " "chain's unit; folded in with the plain words it was " "family 'de la Vega VI' (#614's review, the #610 class " "nameparser/AGENTS.md's spelling sweep records) (1.4.0 family " "'de la Vega VI', bisected to the #424 chain stop)"), Case("a_particle_takes_the_dual_behind_it_at_the_head", "Freiherr von vd", {"title": "Freiherr", "family": "von vd"}, ambiguities=("particle-or-given",), classification="fix(R2)", notes="the chain's leading position stops at a word that is a " "title and a particle, so 'von' is not the leading piece " "and its run takes the dual behind it; counting from the " "end of the title run instead read suffix 'vd' (#614's " "review) (1.4.0 given 'Freiherr von vd', its all-particles " "guard reading the family as a given name; read so since the " "2.0 pipeline)"), Case("a_second_title_particle_leads_the_chain", "Freiherr St van Berg MA", {"title": "Freiherr St", "family": "van Berg", "suffix": "MA"}, ambiguities=("particle-or-given",), classification="fix(#624)", notes="of two title-particles at the head the second leads the " "chain (rules/P4), so both are titles and 'van Berg' " "is the family, as 'Dr. St van Johnson' reads; the chain " "and the read's count take that one answer (1.4.0 given " "'St van Berg', family 'MA'; 2.0-2.3 family 'St van Berg " "MA'; until #624 title 'Freiherr', family 'St van Berg', " "the chain starting at the first)"), Case("the_chain_opens_no_unit_inside_the_titles", "Freiherr Freiherr Prof do", {"title": "Freiherr Freiherr Prof", "family": "do"}, ambiguities=(), classification="fix(#624)", notes="the second 'Freiherr' leads the chain, so no unit opens " "inside the titles and the name is 'do' alone (1.4.0 " "given 'Freiherr Freiherr Prof', its particle chain " "taking the leading title; until #624 the second " "'Freiherr' opened the unit 'Freiherr Prof' inside the " "titles, given 'Freiherr Prof', family 'do')"), Case("a_title_word_inside_a_credential_run_stays_a_title", "John Smith MD van Secretary Jones", {"given": "John", "family": "Smith", "title": "Secretary", "suffix": "MD van Jones"}, ambiguities=("suffix-or-name", "suffix-or-name"), classification="fix(#602)", notes="rules/S8 (#602): inside the run a title word reads as " "a title. #614's read merged 'van Secretary' into one " "piece and the title went into the suffix (its second " "review); since #620 the read counts the chain's units " "rather than reading a merged copy, and only " "particle runs are merged ahead of it (#625), so the " "run's title test sees 'Secretary' as written (1.4.0 middle 'Smith MD', family 'van " "Secretary Jones'; bisected to #602's run)"), Case("two_particle_runs_merge_each", "John van der Smith de la Berg", {"given": "John", "middle": "van der Smith", "family": "de la Berg"}, classification="parity", notes="each particle run joins the words after it, the final " "group the family (rules/P2); group merges both runs " "ahead of S2's read (#625), whose bookkeeping across the " "first merge no other row reached (#625's review: an " "off-by-one there read family 'van der Smith de la " "Berg', the q + 1 one)"), Case("a_second_run_of_duals_is_the_family", "Anna van der Berg van mc", {"given": "Anna", "middle": "van der Berg", "family": "van mc"}, classification="parity", notes="'van mc' is one particle run, the dual a particle the " "run took and no suffix (rules/P2, #S2); merged " "ahead of the read after a first run (#625's review: an " "off-by-one left 'mc' a suffix)"), Case("a_second_particle_run_is_a_unit_of_its_own", "Jan van Berg de Mc", {"given": "Jan", "middle": "van Berg", "family": "de Mc"}, ambiguities=(), classification="parity", notes="a particle ends the plain words a unit takes, so 'de Mc' " "is a second unit and the dual is its particle, not a " "trailing post-nominal (#614's second review, mutation)"), Case("a_joined_particle_phrase_opens_a_unit", "Jan von und zu Mc", {"given": "Jan", "family": "von und zu Mc"}, ambiguities=(), classification="parity", notes="P3 joins 'von und zu' into one piece tagged prefix; the " "chain's run reads that tag as a particle, so the dual " "behind it is in the run and no trailing post-nominal " "(#614's second review, mutation)"), Case("a_joined_salutation_inside_a_credential_run_is_a_title", "John Smith Jr. van Mr. and Mrs.", {"given": "John", "family": "Smith", "title": "Mr. and Mrs.", "suffix": "Jr. van"}, ambiguities=("suffix-or-name",), classification="fix(#602)", notes="past the run's start no unit takes the joined title " "(#614's second review, mutation) (1.4.0 middle 'Smith Jr.', " "family 'van Mr. and Mrs.'; bisected to #602's run)"), Case("a_joined_particle_phrase_is_a_unit_of_its_own", "Jan van Berg von und zu Mc", {"given": "Jan", "middle": "van Berg", "family": "von und zu Mc"}, ambiguities=(), classification="parity", notes="P3's joined 'von und zu' is a particle, so it ends the " "plain words of the 'van Berg' unit and opens its own " "(#614's second review, mutation)"), Case("a_numeral_behind_a_join_opening_with_an_initial_is_a_name", "John van B and Smith X", {"given": "John", "family": "van B and Smith X"}, ambiguities=(), classification="parity", notes="the numeral fork reads the piece in front of the " "numeral as written, and P3's join 'B and Smith' opens " "with an initial, so X stays a name word and the chain " "takes it, as 0c54662b read it. #614's merged copy read " "the unit's first word 'van' instead and took suffix " "'X'; #620's count reads the pieces (the row kept its id " "from #614's second review)"), Case("a_listed_title_word_is_a_plain_word_in_a_unit", "Freiherr von Bishop X.Y.Z.", {"title": "Freiherr", "family": "von Bishop X.Y.Z."}, ambiguities=("particle-or-given", "suffix-or-name", "title-or-name"), classification="parity", notes="only a PERIOD-marked title word stays out of a unit; " "bare 'Bishop' is a surname the chain joins (#614's " "second review, mutation)"), Case("an_unlisted_abbreviation_is_a_plain_word_in_a_unit", "Freiherr von Berg. X.Y.Z.", {"title": "Freiherr", "family": "von Berg. X.Y.Z."}, ambiguities=("particle-or-given", "suffix-or-name"), classification="parity", notes="the period-marked exclusion is for title VOCABULARY; an " "unlisted abbreviation is a name word (#614's second " "review, mutation)"), Case("a_split_credential_is_part_of_the_trailing_block", "Freiherr von Berg Prof. Ph. D.", {"title": "Freiherr Prof.", "family": "von Berg", "suffix": "Ph. D."}, ambiguities=("particle-or-given",), classification="fix(#614)", notes="the merged 'Ph. D.' is a piece the read's walk never " "holds, so it stands outside the run's token set; the " "contiguity check passes over it, or the run is not one " "block, the read is declined, and the chain takes " "'Prof.' into the family as 0c54662b did (family 'von " "Berg Prof.'; #614's /simplify, mutation)"), Case("a_run_with_a_name_inside_keeps_the_bound_join_two_views", "abdul Dr. X.Y.Z.", {"title": "Dr.", "given": "abdul", "family": "X.Y.Z."}, ambiguities=("suffix-or-name",), classification="fix(#516)", notes="the read is declined (the H5 title stands in front of " "a name word the peel declined), so P5's reserve reads " "both views as before #614: the chain takes 'Dr.', and " "a piece the chain takes is no word to join (#614's " "/simplify: the one main-segment row reaching that " "branch) (1.4.0 middle 'Dr.'; 2.3.0 given 'abdul Dr.', " "on the wheels; bisected to #516). Written 'abdul Dr. " "G.J.' until #864, whose paired initials are no word of " "the class there and read as 'abdul Dr. Smith' does " "(given 'abdul Dr.')"), Case("a_weighed_word_the_chain_may_join_counts_as_a_word", "Freiherr von Berg X.Y.Z. X.Y.Z.", {"title": "Freiherr", "family": "von Berg X.Y.Z.", "suffix": "X.Y.Z."}, ambiguities=("particle-or-given", "suffix-or-name", "suffix-or-name"), classification="fix(#614)", notes="a word the trailing reading weighs counts as a word of " "its own in front of the next member, as a position " "always counted it, though the chain joins it once " "declined: so the last 'G.J.' has words to spare. #620's " "first count folded the first 'G.J.' into the chain's unit " "and kept both in the family, 1.4.0's reading, and the " "review found the same fold losing 'Freiherr von Berg MA " "X.Y.Z.' its suffix (adf6da88 given 'von Berg G.J.', " "family 'G.J.'). Written with 'G.J.' until #864, whose " "paired initials are no word the trailing reading " "weighs (family 'von Berg G.J. G.J.')"), Case("a_particle_run_is_one_name_word_before_a_credential", "John der la Jr. Prof. Smith", {"given": "John", "family": "der la", "title": "Prof.", "suffix": "Jr. Smith"}, ambiguities=("suffix-or-name",), classification="fix(#602)", notes="#602's run needs two name words before the credential, " "and 'der la' is one: a particle opening a chain run " "counts once, its bound particle not at all (#620, " "counted over pieces the chain has not joined yet; 1.4.0 " "middle 'der la Jr. Prof.')"), Case("a_joined_name_word_is_no_word_before_a_credential", "van la Smith Secretary Jr. Dr. Smith", {"given": "van", "middle": "la Smith Secretary Jr. Dr.", "family": "Smith"}, ambiguities=("particle-or-given",), classification="parity", notes="'la Smith Secretary' is one name word, the chain " "joining the two after the particle, and the leading " "'van' is a lone particle: one name word before 'Jr.', " "so #602's run does not start (#620)"), Case("a_lone_particle_before_the_run_is_the_surname", "John von PhD Jones", {"given": "John", "family": "von", "suffix": "PhD Jones"}, ambiguities=("suffix-or-name",), classification="fix(#627)", notes="the credential stands right behind the lone particle, " "leaving it nothing to join, so the particle is the " "surname and counts toward #602's two name words, as " "'van der' and 'von und zu' already did; the row above " "is the contrast, a lone particle with a name word " "behind it (1.4.0 and 2.3.0 middle 'von PhD')"), Case("a_particle_run_before_the_run_stays_one_name_word", "Freiherr von vd PhD Jones", {"title": "Freiherr", "given": "von vd", "middle": "PhD", "family": "Jones"}, ambiguities=("particle-or-given",), classification="parity", notes="the row above with a particle run: 'von vd' is one " "name word, 'vd' a particle the run took, so only one " "name word stands before 'PhD' and #602's run does not " "start -- the lone-particle count is asked only of a " "piece that opens a unit, not of 'vd' standing right " "before the credential (#627's review)"), Case("a_dual_in_the_chain_stays_a_name_word_behind_a_title", "John van mc Prof.", {"title": "Prof.", "given": "John", "family": "van mc"}, ambiguities=(), classification="fix(#614)", notes="the dual behind the particle is a word the chain's run " "took, so the walk stops at it on every pass, the one " "after the H5 chain takes 'Prof.' included (#620's " "review; 1.4.0 family 'van mc Prof.')"), Case("a_dual_in_the_chain_survives_a_resumed_walk", "John der mc Dr. do PhD", {"title": "Dr.", "given": "John", "family": "der mc", "suffix": "do PhD"}, ambiguities=("suffix-or-name",), classification="fix(#614)", notes="the walk resumed past the H5 chain's 'Dr.' still stops " "at the dual the chain's run took (#620's review; 1.4.0 " "family 'der mc', suffix 'Dr., do, PhD')"), Case("a_particle_run_is_one_name_word_behind_a_title_too", "John der la Jr. Smith Dr.", {"title": "Dr.", "given": "John", "family": "der la", "suffix": "Jr. Smith"}, ambiguities=("suffix-or-name",), classification="fix(#602)", notes="#602's run start counted in units after the H5 chain's " "splice as before it (#620's review; 1.4.0 middle 'der la " "Jr.', family 'Smith', suffix 'Dr.')"), Case("a_joined_particle_phrase_is_one_name_word_before_a_credential", "Berg von und zu PhD Jones Smith", {"given": "Berg", "family": "von und zu", "suffix": "PhD Jones Smith"}, ambiguities=("suffix-or-name", "suffix-or-name"), classification="fix(#602)", notes="P3's 'von und zu' is one piece of several words, no lone " "particle, so it counts toward the two name words #602's " "run needs (#620's review; 1.4.0 middle 'von und zu PhD " "Jones', family 'Smith')"), Case("a_numeral_alone_behind_two_title_particles_stays_a_name", "St St VI", {"title": "St St", "family": "VI"}, ambiguities=(), classification="fix(#624)", notes="the second 'St' leads the chain and is a title, so the " "numeral is the one name word and the fork has nothing in " "front of it (1.4.0 title 'St', family 'St VI'; #620's " "first count read title 'St St', family 'VI' while the " "chain took 'St VI', and its review pinned the chain's " "answer; #624 gives both the count's)"), Case("an_acronym_alone_behind_two_title_particles_stays_a_name", "Freiherr St MA", {"title": "Freiherr St", "family": "MA"}, ambiguities=(), classification="fix(#624)", notes="as the row above, for the acronym (1.4.0 given " "'Freiherr St', family 'MA'; until #624 title 'Freiherr', " "family 'St MA')"), Case("a_weighed_word_behind_a_particle_counts_as_a_word", "Freiherr von Berg MA X.Y.Z.", {"title": "Freiherr", "family": "von Berg", "suffix": "MA X.Y.Z."}, ambiguities=("particle-or-given", "suffix-or-name"), classification="fix(#516)", notes="'MA' is a word the reading weighs, so it counts as a word " "in front of 'X.Y.Z.' though the chain would join it (#620's " "review: its first count kept both in the family; 1.4.0 " "given 'von Berg', middle 'MA', family 'X.Y.Z.'; bisected " "to #516)"), Case("an_initial_behind_a_particle_counts_as_a_word", "Freiherr von J. ma", {"title": "Freiherr", "family": "von J.", "suffix": "ma"}, ambiguities=("particle-or-given", "suffix-or-name"), classification="fix(#614)", notes="an initial is a word the reading weighs (the numeral fork " "reads the word in front of a letter), so it counts as a " "word of its own though the chain joins it (#620's review, " "mutation; 1.4.0 given 'Freiherr von J.', family 'ma'; " "adf6da88 family 'von J. ma')"), Case("a_period_title_alone_behind_two_title_particles_stays_a_name", "Freiherr St Prof.", {"title": "Freiherr St", "family": "Prof."}, ambiguities=("title-or-name",), classification="fix(#624)", notes="as the rows above, for a period-marked title word: the " "H5 chain takes no title out of a name of one word, so " "'Prof.' is the name (1.4.0 given 'Freiherr St Prof.'; " "until #624 title 'Freiherr', family 'St Prof.')"), Case("a_title_given_back_still_leads_no_chain", "Dr. Mc Mc", {"given": "Dr.", "suffix": "Mc Mc"}, ambiguities=("title-or-name",), classification="fix(#624)", notes="H3 gives 'Dr.' back to the name, everything behind it " "being suffix vocabulary, and the chain reads the titles " "as written: 'Mc' is the name's leading piece, chains " "nothing (P4), and the two 'Mc' are the post-nominals H3 " "saw (1.4.0 title 'Dr.', family 'Mc Mc'; until #624 given " "'Dr.', middle 'Mc', family 'Mc', the read counting the " "second 'Mc' as bound into a run the chain never built; " "#624's first draft read title 'Dr.', family 'Mc Mc', " "chaining through the give-back)"), Case("a_second_title_particle_is_a_title", "Freiherr St John Smith MA", {"title": "Freiherr St", "given": "John", "family": "Smith", "suffix": "MA"}, classification="fix(#624)", notes="#624's example: as 'Dr. St John Smith' and 'Sir St John " "Smith' read, the second title-particle is a title and " "the name starts behind it (1.4.0 title 'Freiherr', given " "'St John Smith', family 'MA'; until #624 family 'St John " "Smith', with a title-or-name report naming it)"), Case("a_connective_join_behind_a_particle_is_one_word", "Freiherr von B and Smith ma", {"title": "Freiherr", "family": "von B and Smith ma"}, ambiguities=("particle-or-given", "suffix-or-name"), classification="fix(P2)", notes="P3's join is one name word, the chain's own, not a word " "the reading weighs, so one unit stands before 'ma' and it " "stays a name word (#620's review, mutation; 1.4.0 given " "'von B and Smith', family 'ma', read so since the 2.0 " "pipeline)"), Case("a_particle_run_keeps_joining_after_its_dual", "John van Mc Smith", {"given": "John", "family": "van Mc Smith"}, ambiguities=(), classification="parity", notes="the dual is a word of the particle run, and the run " "joins on to the name word after it as it does without " "the dual"), Case("a_run_with_a_name_inside_reads_as_before", "John van V Dr. V", {"given": "John", "family": "van V Dr.", "suffix": "V"}, ambiguities=("suffix-or-name",), classification="fix(#296)", notes="the trailing read takes the title and the numeral with a " "name word between them, so its run is not one block at " "the end and the segment is joined and read as before " "#614; split anyway, the read took 'Dr.' for a title " "and left family 'V' (#614's review) (1.4.0 family 'Dr.', " "middle 'van V'; bisected to #296)"), Case("a_joined_leading_title_keeps_the_trailing_title_a_name", "Prince of Wales Dr.", {"title": "Prince of Wales", "family": "Dr."}, ambiguities=("title-or-name",), classification="parity", notes="a parity guard for the title joins in front of the run: " "the joined 'Prince of Wales' is one leading title, and " "the trailing 'Dr.' stays the name word it was. The row " "that pins assign's reading the name again is the " "Freiherr row below (measured 2026-10-07 by mutation)"), Case("a_title_read_before_the_chain_can_strand_the_name", "Freiherr von Berg Dr. and Ed. Prof.", {"title": "Freiherr von Berg Dr. and Ed.", "family": "Prof."}, ambiguities=("particle-or-given", "title-or-name"), classification="fix(#614)", notes="garbage in: the trailing 'Prof.' is read before the " "chain (rules/H5), the chain then joins a piece " "carrying the joined 'Dr. and Ed.' title, which reads as " "a leading title, and assign reads the name again and " "keeps 'Prof.' as its word; 0c54662b chained the whole " "into family 'von Berg Dr. and Ed. Prof.'"), Case("family_comma_then_a_lone_suffix_word_segment", "Smith, John, Prof.", {"given": "John", "family": "Smith", "title": "Prof."}, classification="fix(#603)", notes="the trailing slot is a segment away: a second comma " "makes the last part its own segment, which the tail " "reads before any trailing walk reads a piece. Since " "#603 a title word there is a title (rules/C2), so " "'Smith, John, Prof.' reads title 'Prof.' as 'Smith, " "John Prof.' does, and the part is recognized rather " "than flagged. Until then, and in 1.4.0, it read suffix " "'Prof.' with a comma-structure flag"), # -- #271: script-scoped order + segmentation (amendment 2026-07-27) Case("ko_unspaced_default", "김민준", {"family": "김", "given": "민준"}, classification="fix(#271)", notes="hangul is unambiguously Korean: census surnames ship " "as default vocabulary and HANGUL segmentation is " "default-on. Shape 6's bare Family Given arrangement, " "written unspaced, the floor the other shape-6 rows " "vary from", shape=6), Case("ko_two_syllable_surname_default", "남궁민수", {"family": "남궁", "given": "민수"}, classification="fix(#271)", ambiguities=("segmentation",), notes="남 is itself a shipped surname; longest-first takes " "남궁 and records the decided fork. Shape 6's Family " "slot at two syllables, where the arrangement's own " "boundary is what has to be found", shape=6), Case("ko_bare_two_syllable_surname", "남궁", {"family": "남궁"}, classification="fix(#271)", notes="a token that IS a surname never splits by its " "shorter prefix (남+궁); whole token takes the script " "order's first role; nothing was split, so no fork is " "recorded"), Case("ko_family_comma_stays_whole", "남궁민수, 지훈", {"family": "남궁민수", "given": "지훈"}, classification="fix(#271)", notes="the comma already decided the family: segmentation " "is inert under FAMILY_COMMA (comma doctrine -- see " "the script_segment stage docstring, which uses this " "exact example)", tolerated=True), Case("ko_family_comma_given_side_stays_whole", "지훈, 남궁민수", {"family": "지훈", "given": "남궁민수"}, notes="the mirror of the row above, and one of " "rules/W3's comma illustrations: with the " "comma naming 지훈 the family, the post-comma side is " "given text with no family to find, so 남궁민수 is " "not segmented THERE either -- the same inertness " "reached from the other side. Added 2026-09-01 with " "the W3 demotion: the rules corpus stopped harvesting " "W3's examples, and this text lived in no other " "corpus, so without a row it would have left the " "corpora entirely. Classification measured for this row " "rather than copied from the row above: 1.4.0 gives " "first 남궁민수 / last 지훈, field for field what 2.3 " "gives, so parity", tolerated=True), Case("ko_suffix_comma_name_part_splits", "Dr 김민준, Jr.", {"title": "Dr", "family": "김", "given": "민준", "suffix": "Jr."}, classification="fix(#271)", notes="the one comma structure where segmentation still " "fires: a second word before the comma makes it " "SUFFIX_COMMA, and the name part is a full positional " "name", tolerated=True), Case("ko_spaced_family_first_default", "김 민준", {"family": "김", "given": "민준"}, classification="fix(#271)", notes="script_orders, no segmentation involved. Shape 6's " "Family Given with the space written, the spelling " "ko_unspaced_default reaches by segmenting instead", shape=6), Case("han_spaced_family_first_default", "毛 泽东", {"family": "毛", "given": "泽东"}, classification="fix(#271)", notes="Han ORDER is default-safe without knowing zh from ja " "(both write family-first natively); only " "SEGMENTATION needs the zh opt-in"), Case("han_unspaced_unsegmented_default", "毛泽东", {"family": "毛泽东"}, classification="fix(#271)", notes="no default Han segmentation: one token, and a lone " "wholly-Han token takes the script order's first " "role = family"), Case("han_unspaced_no_script_orders_reports_the_convention", "毛泽东", {"given": "毛泽东"}, policy=Policy(script_orders=()), ambiguities=("given-or-family",), classification="feat(#449)", notes="core-only: an emptied script table has no v1 spelling, " "so 'parity' could never have been true of this row -- " "though the ROLES are 1.4.0's, v1 having no script " "orders to empty. The positive control for `by_script`: " "with nothing resolving the order the same text takes " "O4/O5's default read and the convention reports it. " "The row below is the same input with the entries in " "place and is SILENT, which is the pair"), Case("kana_honorific_no_script_orders_reports_the_convention", "さん", {"given": "さん"}, policy=Policy(script_orders=()), ambiguities=("suffix-or-name",), classification="feat(#491)", notes="core-only for the reason given on the Han row above. " "The suffix half's positive control, and what makes " "rules/H4's CJK-honorific silence a `by_script` " "scope rather than a claim about the word: empty the " "script table and the same lone honorific reaches the " "bare-suffix carve-out and reports"), Case("han_unspaced_family_first_declared_reports_nothing", "毛泽东", {"family": "毛泽东"}, policy=Policy(name_order=FAMILY_FIRST), notes="W4 AUTHORED this reading, and a declared family-first " "order agreeing with the Han entry is agreement, not " "authorship: the script rule resolved the order, so O5's " "convention decided nothing and reports nothing (#449). " "The one row that would fail if the emitter compared the " "order used against the order declared"), Case("mixed_script_untouched_by_script_orders", "John 王", {"given": "John", "family": "王"}, notes="effective_script is None for a mixed name: script_orders " "declines and the positional default governs. Swept as a " "2026-09-01 tolerated candidate and declined for the " "reason the kana-stem rows were: the Latin is a name " "PART here, not a wrapper around a CJK name"), Case("two_han_scripts_untouched_by_script_orders", "毛 김", {"given": "毛", "family": "김"}, notes="two scripts also decline -- the rule is one script, or " "the Han/kana repertoire the #272 license covers"), Case("zh_unspaced", "毛泽东", {"family": "毛", "given": "泽东"}, locale="zh", classification="fix(#271)"), Case("zh_unspaced_two_char", "张伟", {"family": "张", "given": "伟"}, locale="zh", classification="fix(#271)"), Case("zh_compound_tie_break", "夏侯惇", {"family": "夏侯", "given": "惇"}, locale="zh", classification="fix(#271)", ambiguities=("segmentation",), notes="夏 (rank 65) is itself listed, so longest-first " "decides a real fork here and records it"), Case("zh_compound_two_char_given", "司马相如", {"family": "司马", "given": "相如"}, locale="zh", classification="fix(#271)", ambiguities=("segmentation",), notes="司 is listed since #695's ranked surnames, so the " "compound wins a fork, as 夏侯 does"), Case("zh_traditional_compound", "諸葛亮", {"family": "諸葛", "given": "亮"}, locale="zh", classification="fix(#271)", ambiguities=("segmentation",), notes="諸 is listed since #695's ranked surnames"), Case("zh_no_surname_match", "小明", {"family": "小明"}, locale="zh", classification="fix(#271)", notes="no surname prefix in the vocabulary: the token stays " "whole and takes the script order's first role"), Case("zh_japanese_kanji_tradeoff", "高橋一郎", {"family": "高", "given": "橋一郎"}, locale="zh", classification="fix(#271)", notes="the RECORDED tradeoff, not a bug: applying the zh " "pack declares the data Chinese, so Japanese kanji " "names mis-split (高橋 is the real surname). This is " "why Han segmentation is opt-in and why the gate " "cannot guard it (DEVIATES declares all Han); " "Japanese data belongs under locales.JA and its " "segmenter instead"), # -- #272: the kana license + nakaguro (amendment 2026-07-29). # Segmenter-dependent divisions cannot live here (Case has no # segmenter field); they are pinned in test_locales.py's # integration tests instead. Case("ja_kana_spaced_family_first", "高橋 みなみ", {"family": "高橋", "given": "みなみ"}, classification="fix(#272)", notes="hiragana identifies Japanese as certainly as hangul " "identifies Korean; kana-licensed names read " "family-first by default. Shape 6's Family Given with " "a kanji family and a hiragana given rather than the " "hangul of the Korean rows -- the arrangement is one " "shape across the scripts that carry it, and across a " "mix of them within one name", shape=6), Case("ja_kanji_katakana_pieces", "山田 エミ", {"family": "山田", "given": "エミ"}, classification="fix(#272)", notes="a kanji piece + a katakana piece cannot be a foreign " "transcription (those are katakana-only): native, " "licensed"), Case("ja_unspaced_unsegmented_default", "高橋みなみ", {"family": "高橋みなみ"}, classification="fix(#272)", notes="no segmenter by default: one token, family role via " "the kana license"), Case("ja_pure_katakana_positional", "マイケル ジャクソン", {"given": "マイケル", "family": "ジャクソン"}, notes="parity row guarding the license's boundary: " "pure-katakana is predominantly transcribed foreign " "names in original order -- never licensed"), Case("ja_nakaguro_divides_the_transcription", "マイケル・ジャクソン", {"given": "マイケル", "family": "ジャクソン"}, classification="fix(#272)", notes="the katakana middle dot is the transcription's own " "part divider: it separates like whitespace, the " "license declines each katakana token, and the " "positional default keeps the source-language order. " "Shape 7's katakana-transcription half, the arrangement " "admitted by the script rather than by the 间隔号", shape=7), Case("ja_nakaguro_han_takes_the_han_order", "高橋・一郎", {"family": "高橋", "given": "一郎"}, classification="fix(#272)", notes="the dot splits; both tokens are pure Han, so the HAN " "family-first entry fires -- the katakana row's sibling " "through the other outcome"), Case("ja_lone_hiragana_takes_family", "みなみ", {"family": "みなみ"}, classification="fix(#272)", notes="hiragana earns a script_orders entry in its own right: " "a lone token takes the entry's first role"), Case("ja_lone_katakana_stays_given", "マイケル", {"given": "マイケル"}, ambiguities=("given-or-family",), notes="parity: katakana deliberately has no entry, so the " "positional default holds -- transcribed foreign names " "keep source order. W4 DECLINING is what leaves the " "reading to O5's convention, which reports it (#449); " "a name whose script order decides it stays silent. " "Roles parity; the flag is #449's"), Case("ja_halfwidth_kanji_katakana_pieces", "山田 タロウ", {"family": "山田", "given": "タロウ"}, classification="fix(#594)", notes="halfwidth katakana (U+FF65-U+FF9F) is katakana: the " "kana license reads it exactly as it reads 山田 エミ. " "Legacy JIS X 0201 data -- bank, payroll and CSV " "exports -- writes kana this way. 2.3.0 and every " "earlier release left the block unclassified and read " "given 山田, family タロウ"), Case("ja_halfwidth_voicing_marks_are_katakana", "山田 ダイスケ", {"family": "山田", "given": "ダイスケ"}, classification="fix(#594)", notes="the halfwidth voicing mark ゙ (U+FF9E) is a SPACING " "character after its base, not a combining one, so " "only the block span classifies it; a range ending at " "U+FF9D would leave ダイスケ mixed-script and the name " "positional"), Case("ja_halfwidth_semi_voiced_and_long_vowel_marks_are_katakana", "山田 ペーター", {"family": "山田", "given": "ペーター"}, classification="fix(#594)", notes="the block's last codepoint, the semi-voiced mark ゚ " "(U+FF9F), and the prolonged sound mark ー (U+FF70, which " "a range starting at the ordinary letters, U+FF71, " "would drop). Narrowing the range past either leaves " "ペーター mixed-script and the name positional"), Case("ja_halfwidth_unspaced_unsegmented_default", "山田タロウ", {"family": "山田タロウ"}, classification="fix(#594)", notes="the halfwidth twin of ja_unspaced_unsegmented_default: " "one Han+halfwidth token takes the kana license on its " "own and reads family. 2.3.0 read it given, reporting " "given-or-family; the license decides it, so nothing is " "reported now"), Case("ja_halfwidth_pure_katakana_positional", "ヤマダ タロウ", {"given": "ヤマダ", "family": "タロウ"}, notes="parity row guarding the license's boundary in " "halfwidth: wholly-katakana keeps the declared order " "(rules/W4). Legacy halfwidth data writes Japanese " "names this way too, family-first, but the script " "cannot tell a reading from a transcription, so the " "caller opts in through script_orders (#594)"), Case("ja_interpunct_b7_halfwidth_katakana", "タロウ·ヤマダ", {"given": "タロウ", "family": "ヤマダ"}, classification="fix(#594)", notes="the 间隔号 divides between two classified characters " "(rules/T3), and halfwidth kana is classified now, " "so the dot divides here as it does in タロウ·ヤマダ " "and the parts keep source order. 2.3.0 kept the whole " "text one token"), Case("ja_combining_voicing_mark_keeps_katakana_declared", "ア゙イ タロウ", {"given": "ア゙イ", "family": "タロウ"}, classification="fix(#596)", notes="ア has no precomposed voiced form, so NFC leaves the " "combining dakuten U+3099 standing, and the mark sits in " "the HIRAGANA block. A voicing mark takes the script of " "the kana before it, so the name is wholly katakana and " "keeps the declared order (rules/W4). 2.1.0 through " "2.3.0 read the mark as hiragana and the token as " "kana-licensed Japanese: family ア゙イ"), Case("ja_combining_voicing_mark_beside_plain_katakana", "マイケル ア゙イ", {"given": "マイケル", "family": "ア゙イ"}, classification="fix(#596)", notes="the mark flipped its NEIGHBOUR's role too: the license " "is read over the whole name's pieces, so one licensed " "piece made 2.1.0-2.3.0 read マイケル as the family. " "Positional now, as マイケル ジャクソン is"), Case("ja_spacing_voicing_mark_keeps_katakana_declared", "ア゛イ タロウ", {"given": "ア゛イ", "family": "タロウ"}, classification="fix(#596)", notes="the SPACING dakuten U+309B, which NFC never composes, " "sits in the hiragana block beside the combining one and " "takes its base's script the same way. The row that " "fails if the fix narrows to the two combining marks"), Case("ja_combining_voicing_mark_on_hiragana_stays_hiragana", "あ゙い たろう", {"family": "あ゙い", "given": "たろう"}, notes="parity row: on hiragana the mark's base is hiragana, so " "the name is wholly hiragana and reads family-first as it " "always has. Guards the alternative #596 declined -- " "taking the marks out of the hiragana range would leave " "あ゙い in no script and the name positional"), Case("ja_combining_voicing_mark_kanji_katakana_licensed", "山田 ア゙イ", {"family": "山田", "given": "ア゙イ"}, notes="parity row: kanji plus katakana is the kana license " "whatever the katakana's mark does, so the name stays " "family-first. Under the declined alternative ア゙イ " "would have no script, the license would not fire, and " "the name would read family ア゙イ"), Case("ko_voicing_mark_after_hangul_stays_undivided", "김\u3099민준", {"given": "김\u3099민준"}, ambiguities=("given-or-family",), notes="parity row: a voicing mark after HANGUL voices no kana, " "so #596 leaves it alone and the token stays mixed-" "script, undivided and reported, as before. A first cut " "of #596 dropped every mark with a character before it; " "the token then classified as hangul, the surname site " "divided the RAW text after 김, and the stranded mark " "opened the family: given 김, family \u3099민준. " "Tolerated: rules/W's Background leaves a mark after " "a non-kana unpromised, so this reading is watched by " "the snapshot rather than pinned as contract", tolerated=True), Case("ja_iteration_mark_is_han", "佐々木 太郎", {"family": "佐々木", "given": "太郎"}, classification="fix(#272)", notes="々 (U+3005, the ideographic iteration mark) repeats " "the preceding kanji and is Script=Han under UAX #24, " "but sits outside every CJK ideograph BLOCK -- and the " "classifier is a block table, so it needs its own " "entry to count as Han; without one 佐々木 -- a top-20 " "Japanese surname -- would be a mixed-script token and " "reverse"), Case("ja_shime_mark_is_han", "〆木 太郎", {"family": "〆木", "given": "太郎"}, classification="fix(shime-mark)", notes="〆 (U+3006, the shime mark) opens real Japanese " "surnames -- 〆木 Shimeki, 〆谷 Shimetani, 〆野 -- but " "carries Script=Common under UAX #24; the table counts " "it as Han anyway (a deliberate step PAST the Script " "property, unlike 々's), else 〆木 is a mixed-script " "token and the name reverses"), Case("ja_shime_lone_token_takes_family", "〆木太郎", {"family": "〆木太郎"}, classification="fix(shime-mark)", notes="wholly-Han lone token under the family-first entry: " "the whole token lands in family, unsegmented by " "default"), Case("ja_shime_with_kana_given", "〆木 ひろ", {"family": "〆木", "given": "ひろ"}, classification="fix(shime-mark)", notes="the kana license composes with the widened Han span: " "〆木 reads as kanji beside a kana given name"), Case("ja_nakaguro_inside_a_nickname", "山田 太郎 (マイケル・ジャクソン)", {"family": "山田", "given": "太郎", "nickname": "マイケル ジャクソン"}, classification="fix(#272)", notes="delimited content tokenizes under the same separator " "rules: the dot renders back as a space in the nickname " "join -- a decision, not an accident"), Case("zh_interpunct_transcription_source_order", "威廉·莎士比亚", {"given": "威廉", "family": "莎士比亚"}, classification="fix(#298)", notes="间隔号-divided Han is a transcribed foreign name and " "keeps source order -- the B7 is the transcription " "marker, playing the role pure katakana plays in the " "kana license; it divides only between classified " "characters. Shape 7's Given·Family with the 间隔号 " "itself written, the divider half of the notation", shape=7), Case("zh_interpunct_nakaguro_typed_stays_roster", "威廉・莎士比亚", {"given": "莎士比亚", "family": "威廉"}, classification="fix(#272)", notes="the SAME transcription typed with the Japanese " "nakaguro reads as the dot's own typography says -- a " "姓・名 roster pair, family-first (#272) -- because the " "nakaguro records nothing (per decisions/T3, " "codepoint-scoped; only the Chinese B7 marks a " "transcription). A limitation row: chosen, not " "accidental -- cross-convention input reads by the " "convention of the codepoint it was typed with"), Case("ja_interpunct_b7_katakana", "マイケル·ジャクソン", {"given": "マイケル", "family": "ジャクソン"}, classification="fix(#298)", notes="sloppy-IME B7 between katakana divides like the " "nakaguro; katakana was never licensed, so order was " "already positional -- the split is the fix"), Case("latin_punt_volat_is_name_interior", "Gal·la Marcet", {"given": "Gal·la", "family": "Marcet"}, notes="the Catalan punt volat: U+00B7 with Latin neighbors " "is interior to the name, never a divider -- the flank " "guard's whole reason"), Case("zh_interpunct_suppresses_segmentation", "马丁·路德·金", {"given": "马丁", "middle": "路德", "family": "金"}, locale="zh", classification="fix(#298)", notes="马 is a listed zh surname, but a 间隔号-divided name " "is a transcription: the dot gates segmentation off, " "so 马丁 stays whole and the positional read stands"), Case("ko_interpunct_transcription_source_order", "마이클·잭슨", {"given": "마이클", "family": "잭슨"}, classification="fix(#298)", notes="Korean writes transcribed foreign names with the " "interpunct too: the dot suppresses the hangul " "family-first entry AND the default segmentation -- 마 " "is a listed census surname, and the spaced form " "마이클 잭슨 really does mis-split 마|이클 today, so " "the dot is what rescues the ko transcription"), Case("zh_interpunct_with_suffix_comma", "威廉·莎士比亚, PhD", {"given": "威廉", "family": "莎士比亚", "suffix": "PhD"}, classification="fix(#298)", notes="the transcription reading composes with a suffix " "comma: the marker is structure-independent", tolerated=True), Case("zh_interpunct_half_flanked_stays", "王·Smith", {"given": "王·Smith"}, ambiguities=("given-or-family",), notes="one classified neighbor is not enough: the guard " "requires both, so the undivided dot remains part of " "the word -- declining, not deciding. Swept as a " "2026-09-01 tolerated candidate and declined on the " "same boundary as 'John 王': the Latin is a name part, " "not a wrapper. T3 declining is what leaves the " "reading to O5's convention, which reports it (#449). " "Roles parity; the flag is #449's"), Case("zh_honorific_suffix_spaced", "王小明 先生", {"family": "王小明", "suffix": "先生"}, classification="fix(#307) + fix(#271)", notes="CJK honorifics FOLLOW the name; a spaced 先生 (Mr.) is " "a suffix, and recognizing it must come before the " "family-first order hands it a role -- unrecognized it " "read as the GIVEN name under the 2.1 defaults. NOT " "tagged shape 6, though it looks like the Han spelling " "of one: no DEFAULT segmenter divides 王小明, so the " "fields here are an undivided name plus an honorific " "and the arrangement's Given slot is never filled -- " "the same reason han_unspaced_unsegmented_default " "(毛泽东) carries no tag. The zh pack is what splits " "the token (zh_honorific_glued_given), and a pack row " "exercises a locale fork rather than an input shape"), Case("ko_honorific_ssi", "김민준 씨", {"family": "김", "given": "민준", "suffix": "씨"}, classification="fix(#307) + fix(#271)", notes="Korean orthography standardly SPACES 씨, so the " "whole-token suffix machinery reaches it; the name " "still segments (suffix classification runs after the " "script_segment stage, which only ever saw 김민준)"), Case("ko_degree_baksa", "김민준 박사", {"family": "김", "given": "민준", "suffix": "박사"}, classification="fix(#307) + fix(#271)", notes="박사 (doctorate) is the ko analogue of a trailing " "PhD: fix(suffix-routing)'s two-token shape, one " "script over"), Case("ja_sama_spaced", "田中 太郎 様", {"family": "田中", "given": "太郎", "suffix": "様"}, classification="fix(#307) + fix(#271)", notes="the spaced 様 of forms and databases, which whole-token " "matching reaches on its own; the glued " "mail-addressing form is ja_sama_glued below, reached " "by #308's peel instead"), Case("ja_san_spaced", "田中 さん", {"family": "田中", "suffix": "さん"}, classification="fix(#308) + fix(#271)", notes="the kana honorifics ship as suffix vocabulary so the " "glued peel has somewhere to hand its tail; spaced " "recognition falls out of the same entry -- until this " "change さん read as the given name under the " "family-first default"), Case("ja_san_glued", "田中さん", {"family": "田中", "suffix": "さん"}, classification="fix(#308) + fix(#271)", notes="the everyday glued form, and the one that also " "corrupted classification: 田中さん is Han plus " "hiragana, so the kana license read the whole string " "as a Japanese name. The peel runs before the license " "is consulted, so it now sees 田中 alone"), Case("ja_honorific_glued_before_a_roman_suffix", "田中さん II", {"family": "田中", "suffix": "さん II"}, classification="fix(#308) + fix(#271)", notes="an unrelated trailing suffix does not hide the peel " "site: the scan-back steps over II and peels さん off " "the token behind it. Half of the pair that pins " "_is_post_nominal's use of is_suffix_STRICT -- the " "other half is the row below, and swapping in " "is_suffix_lenient changes that one and not this one. " "The run renders with the space the writer typed " "since #436; the comma form '田中さん, 様.' " "(ja_honorific_period_does_not_stop_the_peel) keeps " "its comma, which is the pair that shows the " "separator is read from the text", tolerated=True), Case("ja_honorific_glued_before_an_initial", "田中さん V.", {"given": "田中さん", "family": "V."}, notes="the strict/lenient discriminator, and the reason " "_is_post_nominal reads is_suffix_strict. Bare 'v' is " "a suffix word, but 'V.' is an initial and the strict " "test vetoes it -- so the scan-back stops HERE rather " "than stepping over it, finds no tail on 'V.', and " "does not peel. Under is_suffix_lenient it would step " "over and give given 田中, middle さん, family 'V.'. " "Parity besides: 1.4.0 read this first 田中さん / last " "'V.', which is these fields under the 2.0 names. " "Classification agrees with what classify does with " "the same token downstream -- 'V.' is a middle " "initial, not a post-nominal", tolerated=True), Case("ja_honorific_glued_before_a_one_case_numeral", "田中さん v", {"given": "田中さん", "family": "v"}, ambiguities=("suffix-or-name",), notes="nothing in this name is written in a case, so the bare " "'v' is the initial its capital is (T4): the scan-back " "stops at it as at 'V', and this reads as '田中さん V' " "does -- 1.4.0's reading too (#727; 2.1 through 2.4.2 " "peeled さん, reading suffix 'さん, v' in 2.1 and 2.2 " "and 'さん v' from 2.3)", tolerated=True), Case("ja_honorific_with_a_period_no_comma", "田中さん 様.", {"family": "田中", "suffix": "さん 様."}, classification="fix(#320)", notes="the SPACED form, and the example _vocab.is_initial's " "own docstring cites as what #320 cost. Same fields as " "the comma row below, reached without a comma: the peel " "scans segment 0 either way, and with '様.' no longer " "vetoed the scan-back steps over it onto 田中さん and " "peels さん. Worth its own row because it is the only " "one of the pair where the SCAN-BACK is what #320 " "fixes, and since #319 the only one where the PEEL " "depends on #320 at all: here '様.' shares the run with " "田中さん and has to be stepped over, while the comma " "form declines its post-comma run and never looks at " "'様.' in the peel. That decline does not carry the " "veto over -- is_wholly_suffix asks is_suffix_lenient " "under the default policy, which takes '様.' whether " "the veto is in place or not (see the row below) -- so " "restoring the veto strands さん HERE and nowhere else " "in the pair, giving given '田中さん', family '様.'. " "(Before #319 the distinction was structural -- " "FAMILY_COMMA flattened TWO runs before scanning where " "this has one -- which is no longer what separates " "them.) 1.4.0 read this first " "田中さん / last '様.', which is what 2.0 produced until " "#320: parity before, a classified change after. " "TOLERATED since 2026-09-05: a trailing ASCII period on " "a CJK honorific is a listing artifact no writing " "system produces -- the same class as a comma listing " "or a Latin credential. The row still pins #320's " "mechanism at HEAD; what moves is which corpus file " "carries the text. The run renders with the space the " "writer typed since #436; the comma form " "'田中さん, 様.' below keeps its comma, which is the " "pair that shows the separator is read from the text", tolerated=True), Case("ja_honorific_period_does_not_stop_the_peel", "田中さん, 様.", {"family": "田中", "suffix": "さん, 様."}, classification="fix(#320)", notes="the comma spelling of the row above, and since #319 a " "DIFFERENT dependence on #320. At #320 this row was " "about the peel: the veto made _is_suffix_strict say " "no of '様.', so the #312 scan-back stopped AT it as " "the site, found no listed tail there and abandoned, " "leaving さん glued to 田中 over one period. #319 then " "declined the post-comma run outright, and what is " "left here for #320 to fix is the ASSIGNMENT -- with " "the veto restored the peel still fires (family 田中, " "suffix さん) and only '様.' moves, to given. The " "fields and the classification are unchanged either " "way. CORRECTION, recorded here because commit " "4aff219's message claims otherwise and cannot be " "amended: the veto does NOT reach is_wholly_suffix " "under this row's DEFAULT policy, so it is not true " "that #319 merely handed the same veto to a different " "predicate. is_wholly_suffix selects is_suffix_lenient " "there, whose contract is suffix_words accepted " "unconditionally, BYPASSING the initial veto, and " "_normalize('様.') is '様', a suffix word -- so " "is_wholly_suffix(['様.']) is True veto or no veto, " "the run is declined either way, and simulating the " "veto PEELS さん here rather than stranding it. The " "is_wholly_suffix route to an abandoned peel exists " "only under Policy(lenient_comma_suffixes=False), " "which drops the call to is_suffix_strict and gives " "family '田中さん', given '様.'; this row does not set " "that knob, and " "ko_honorific_period_under_strict_comma_suffixes is " "the table's row for it. Was the " "shape of " "ja_honorific_glued_before_an_initial above, except " "that here the token stopping the scan is a real " "honorific rather than an initial, which is what makes " "it a bug rather than the intended veto. The structure " "is FAMILY_COMMA throughout -- SUFFIX_COMMA needs more " "than one word ahead of the comma and 田中さん is one -- " "so at #320 both runs were in the peel's reach and " "only the strict test's answer moved. " "1.4.0 read " "this first '様.' / last 田中さん, which is exactly what " "2.0 produced before this change -- the row sat at " "parity until #320 moved it", tolerated=True), Case("ja_sama_glued", "山田太郎様", {"family": "山田太郎", "suffix": "様"}, classification="fix(#308) + fix(#271)", notes="the mail-addressing form. Undivided without a " "segmenter -- no surname list divides a kanji name -- " "so the family name is the whole 山田太郎; " "tests/v2/test_locales.py pins the divided twin under " "locales.JA"), Case("ko_honorific_nim_glued", "김민준님", {"family": "김", "given": "민준", "suffix": "님"}, classification="fix(#308) + fix(#271)", notes="the online/formal glued address form, 씨's twin"), Case("ko_honorific_written_with_a_period", "김민준, 씨.", {"family": "김민준", "suffix": "씨."}, classification="fix(#320)", notes="the period-written form of ko_honorific_after_comma " "('김민준, 씨'), whose field ASSIGNMENT it must match " "and before #320 did not -- same roles, the suffix " "VALUE differing by the period it was written with. " "_normalize strips the trailing period, " "so the vocabulary sees 씨 either way -- the initial " "veto was the only thing rejecting the written form, " "and literally the veto: is_suffix_piece is " "'vocab:suffix' in tags and 'initial' not in tags, and " "'씨.' carried both, so the suffix-shaped piece went to " "the given. 1.4.0 read this first '씨.' / last 김민준 -- " "the same fields 2.0 gave before this change, so the row " "was at parity and #320 is what moves it", tolerated=True), Case("ko_honorific_period_under_strict_comma_suffixes", "김민준, 씨.", {"family": "김민준", "suffix": "씨."}, policy=Policy(lenient_comma_suffixes=False), classification="fix(#320)", notes="the row above under the knob that governs exactly this " "shape, and one of the table's three exercises of it " "(ja_honorific_glued_family_comma_strict_knob is the " "second, on the initial-shaped side of the same gap; " "ja_honorific_glued_family_comma_credential_pair_strict_" "knob is the third, showing where the knob changes " "nothing). " "lenient_comma_suffixes=False drops the post-comma test " "(`_comma.decide`'s since #613) to the strict one, so a 'Family, Suffix' input " "whose suffix is INITIAL-SHAPED reads as a given-name " "initial instead ('John Smith, V' -> given 'V'). '씨.' " "is a single character plus a period, so it was in that " "class by shape, and before #320 the knob decided it: " "given '씨.' / family 김민준. It is out of the class now, " "and the honorific parses identically under both " "settings -- which is the claim this row exists to " "hold, since the knob's own documentation scopes it to " "the initial-shaped suffix words ('John Smith, V') and " "_script_segment names it as the setting that keeps a " "glued honorific inside the name in a neighbouring " "shape ('田中さん, V.' gives family '田中さん' under " "it). No v1 spelling exists for the knob " "(the facade runner skips this row), so the " "classification compares against 1.4.0's single " "reading, first '씨.' / last 김민준 -- the same fields " "2.0 gave under EITHER setting before this change", tolerated=True), Case("ko_honorific_with_a_period_no_comma", "김민준 씨.", {"given": "민준", "family": "김", "suffix": "씨."}, classification="fix(#320)", notes="the period-written ko_honorific_ssi ('김민준 씨'), and " "#320 by a different route than the row above: the veto " "left '씨.' a NAME piece, and effective_script('씨.') is " "None because the trailing period defeats the " "wholly-one-script test, so script_orders declined for " "the whole name and the three pieces fell back to " "name_order -- given 김, middle 민준, family '씨.'. " "Hangul segmentation is NOT what moves: it divides " "김민준 identically either way, and only the order the " "pieces are read in changes. 1.4.0 read this first " "김민준 / last '씨.' -- undivided, no suffix. The only " "row of the three that already differed from 1.4.0 " "before this change, but it differed as given 김 / " "middle 민준 / family '씨.'; the fields above are #320's, " "not the segmenter's, so the row is classified to it -- " "as ko_honorific_ssi is classified to #307 without " "naming the same segmentation it also depends on. " "TOLERATED since 2026-09-05 with its two period twins: " "a trailing ASCII period on a CJK honorific is a " "listing artifact no writing system produces. The row " "still pins #320's mechanism at HEAD", tolerated=True), Case("ko_honorific_with_a_fullwidth_full_stop", "김민준 씨.", {"family": "김", "given": "민준", "suffix": "씨."}, classification="fix(#322)", notes="U+FF0E, the stop a Japanese or Chinese IME produces by " "default. The lookup fold reads it as an edge period " "(_lexicon.FULL_STOPS), so 씨. reaches the suffix entry " "the ASCII spelling already reached; the token text " "keeps its stop. Pins the fold on the one stop NFKC " "would ALSO have folded -- the row beside it pins the " "one it would not -- and earns its place as a member " "of a set the library SHIPS, which " "mechanisms.md#VOCABULARY-EXERCISES-FORKS says is " "caller-visible behavior wanting a row per member, " "unlike caller-configured vocabulary. 2.2.0 read given " "김, middle 민준, family 씨..", tolerated=True), Case("ko_honorific_with_an_ideographic_full_stop", "김민준 씨。", {"family": "김", "given": "민준", "suffix": "씨。"}, classification="fix(#322)", notes="U+3002, which NFKC leaves alone -- the row that shows " "the stop SET is what fixes #322, not a Unicode " "normalization. 2.2.0 read given 김, middle 민준, " "family 씨。.", tolerated=True), Case("ko_honorific_with_a_halfwidth_ideographic_full_stop", "김민준 씨。", {"family": "김", "given": "민준", "suffix": "씨。"}, classification="fix(#322)", notes="U+FF61, which NFKC folds to U+3002 and no further -- " "so even under NFKC this spelling needs the set. 2.2.0 " "read given 김, middle 민준, family 씨。.", tolerated=True), Case("ko_honorific_written_nfd_after_a_family_comma", unicodedata.normalize("NFD", "김민준, 씨."), {"family": unicodedata.normalize("NFD", "김민준"), "suffix": unicodedata.normalize("NFD", "씨.")}, classification="fix(#322)", notes="the lookup fold composes NFC, so decomposed 씨. reaches " "the suffix entry (2.2.0 read title 씨.). The family " "stays WHOLE and stays NFD: segmentation matches raw " "text on purpose (decisions/W1, 2026-07-29 ja " "amendment), so NFD degrades to no-split, never to a " "wrong split, and no " "token text is rewritten. Contrast 'NFD(田中さん, 様.)', " "which matched before: Han does not decompose. NEITHER " "tolerated NOR shape-tagged, deliberately: this table's " "_has_cjk reads raw codepoints (jamo sit outside " "_SCRIPT_RANGES), so a decomposed text is not CJK to the " "purity gate and reaches no corpus; the row is a HEAD " "pin only."), Case("latin_title_written_nfd_is_still_a_title", unicodedata.normalize("NFD", "Señor Juan Garcia"), {"title": unicodedata.normalize("NFD", "Señor"), "given": "Juan", "family": "Garcia"}, classification="fix(#322)", notes="the Latin side of the NFC fork: the lookup fold " "composes every non-ASCII word, not hangul alone, so a " "decomposed spelling of a shipped diacritic entry " "(señor, née, attaché) reaches it. 2.2.0 read given " "Señor, middle Juan, family Garcia. The token keeps its " "NFD text; only the lookup composes."), Case("ko_leading_honorific_surname_with_a_period_keeps_the_given_name", "양. 지훈", {"family": "양.", "given": "지훈"}, classification="fix(#323)", notes="the issue's own input. effective_script('양.') is " "HANGUL once the classification fold drops the edge " "stop, so the surname site lands on 양. -- which is " "post-nominal vocabulary in the LEADING position, " "which _is_post_nominal's docstring says the surname " "site reads as an ANSWER rather than a token to step " "past -- and declines, leaving 지훈 whole. The stop " "stays on the token: nothing rewrites text (rules/T's " "Background). 2.2.0 read given 양., middle 지, family " "훈.", tolerated=True), Case("ko_leading_family_name_with_a_period_keeps_the_given_name", "김. 민준", {"family": "김.", "given": "민준"}, classification="fix(#323)", notes="the non-honorific twin of the row above, and the row " "that decides between #323's two candidate fixes: " "consulting is_suffix_strict at the surname site would " "have rescued 양. and left this one cut (김 is no " "suffix), so the fix is the classification fold. The " "site lands on 김. and matches on its CORE: 김 is itself " "a listed surname, so nothing splits and the stop stays " "on the family name (matched on the raw text, 김 was a " "listed HEAD and the stop was the remainder, cutting " "김 + '.'). 2.2.0 read given 김., middle 민, " "family 준.", tolerated=True), Case("ko_trailing_period_keeps_the_family_first_order", "양 지훈.", {"family": "양", "given": "지훈."}, classification="fix(#323)", notes="the ORDER reader of the same None: assign's script " "walk returns the declared order the moment one piece " "has no script, so a stop on the LAST token flipped " "the name to given-first. rules/W4's family-first " "reading survives the stop now. 2.2.0 read given 양, " "family 지훈..", tolerated=True), Case("ko_glued_honorific_with_a_period_peels", "김민준씨.", {"family": "김", "given": "민준", "suffix": "씨."}, classification="fix(#323)", notes="the W3 reading that was pinned by nothing (measured " "2026-09-05 as title 김민준씨.): the peel now matches " "its tail through the trailing stop and cuts before it, " "so this divides exactly as '김민준 씨.' does and the " "stop stays on the honorific. Peel first, then the " "surname site divides 김민준. 2.2.0 read title 김민준씨.", tolerated=True), Case("ja_glued_honorific_with_a_period_peels", "田中さん.", {"family": "田中", "suffix": "さん."}, classification="fix(#323)", notes="the Japanese twin, and the one where nothing follows " "the peel: 田中 is a lone Han token under the default " "policy, so no division runs and the family stands " "whole, as it does for '田中さん'. 2.2.0 read title " "田中さん.", tolerated=True), Case("ko_lone_name_with_a_period_is_not_a_title", "김민준.", {"family": "김", "given": "민준."}, classification="fix(#323)", notes="reads this way since the surname site learned the " "core match, NOT through H2's veto: HANGUL segmentation " "is on by default and script_segment runs before " "assign, so 김민준. is divided into 김 + 민준. before " "any period-marked opening word exists for H2 to see. " "Kept as the hangul reading of the shape; the veto's " "own witness is the Han row below, where no default " "segmentation stands in front. The stop rides with the " "given name -- nothing rewrites text. 2.2.0 read title " "김민준.", tolerated=True), Case("ko_period_marked_first_word_then_a_name_word", "김민준. 지훈", {"family": "김", "given": "민준.", "middle": "지훈"}, classification="fix(#323)", notes="the two-word hangul shape, divided by the surname " "site before assign as the row above is: family 김, " "given 민준., middle 지훈 -- the reading of '김민준 " "지훈' with the stop kept. H2's veto is not what " "decides it (see the row above); the Han two-word row " "below is where the veto is the whole difference. " "2.2.0 read title 김민준., family 지, given 훈 -- " "hangul segmentation ran on the trailing word.", tolerated=True), Case("ja_lone_name_with_a_period_is_not_a_title", "田中.", {"family": "田中."}, classification="fix(#323)", notes="the H2 veto's witness: Han has no default segmentation, " "so the period-marked word reaches assign whole, and " "rules/H2's shape -- a Latin convention, an " "abbreviation's period -- declines it because Han has " "no period abbreviations (the #320 veto, extended from " "is_initial to H2). A lone name word is the family " "name. Remove the veto and this reads title 田中. " "again, which is what 2.2.0 read.", tolerated=True), Case("ja_period_marked_first_word_then_a_name_word", "田中. 太郎", {"family": "田中.", "given": "太郎"}, classification="fix(#323)", notes="the two-word Han shape, where the veto is the whole " "difference: without it H2 fires on 田中. and 太郎 " "becomes the entire name. With it the pair reads as " "'田中 太郎' does, family-first by script (rules/W4), " "with the stop kept. The stop stays on 田中, the word " "that carried it: family 田中., given 太郎. 2.2.0 read " "title 田中., family 太郎. Stays tolerated like its " "three #323 siblings, H2's Accepted clause being " "illustrated in W3's tolerated example block rather " "than by promoting this row, per the 2026-09-05 " "precedent recorded in tools/differential/compare.py: " "marking the row alone would leave the name enforced " "and documented as demoted.", tolerated=True), Case("ja_katakana_lone_name_with_a_period_is_not_a_title", "マイケル.", {"given": "マイケル."}, ambiguities=("given-or-family",), classification="fix(#323)", notes="the katakana arm of the H2 veto, and the one where it " "cuts across rules/W4 -- a wholly-katakana name keeps " "the declared order, so the lone word is the GIVEN name " "-- the veto decides title-or-name, the order rule " "decides which name. 2.2.0 read title マイケル.", tolerated=True), Case("ja_halfwidth_katakana_lone_name_with_a_period_is_not_a_title", "マイケル.", {"given": "マイケル."}, ambiguities=("given-or-family",), classification="fix(#594)", notes="the halfwidth twin of the row above: katakana has no " "initials (_NO_INITIALS), and halfwidth kana is katakana " "now, so H2's opening-abbreviation shape declines it. " "2.3.0 read title マイケル., the halfwidth limit " "decisions/cjk-full-stops recorded", tolerated=True), Case("ja_halfwidth_katakana_opener_with_a_period_is_a_name", "タナカ. John", {"given": "タナカ.", "family": "John"}, classification="fix(#594)", notes="decisions/cjk-full-stops' recorded misroute: a " "period-marked halfwidth opener read as a TITLE, where " "タナカ. John reads given. Now both read given", tolerated=True), Case("latin_period_marked_opening_word_is_still_a_title", "Smith. John", {"title": "Smith.", "family": "John"}, classification="parity", notes="the Latin side of the #323 veto's fork, pinned so the " "veto can never widen onto Latin: Latin has " "period abbreviations, H2's shape fires, and an " "unlisted period-marked opening word is a title even " "when it is a surname."), Case("cyrillic_period_marked_opening_word_is_still_a_title", "Проф. Иванов", {"title": "Проф.", "family": "Иванов"}, classification="parity", notes="the alphabet _policy._NO_INITIALS's own comment names " "as the one it would be wrong to add: Cyrillic has " "initials and abbreviations, so H2's shape fires and " "the #323 veto stays out of its way. Pins the veto's " "repertoire at the four CJK scripts from the other " "side."), Case("ko_name_with_a_period_in_a_bracketed_credential", "(김민준.) John Smith", {"given": "김", "middle": "민준. John", "family": "Smith"}, classification="fix(#323)", notes="a recorded DEGRADATION, pinned so it cannot move " "silently. rules/S1's bracketed-credential escape in " "_extract calls a clause suffix-shaped when it ends in " "an ASCII period -- an unwidened test, deliberately -- " "so the brackets are dropped and the content reads as " "if written bare. Bare, '김민준. John Smith' used to " "reach H2 and give title 김민준. (2.2.0's reading); " "since #323 the veto declines a period-marked opening " "word written in an initialless script, so 김민준. is " "name text, the surname site divides it, and the pieces " "spread across the Latin name -- given 김, middle " "'민준. John', family Smith. The TRAILING spelling " "degrades the same way ('John Smith (김민준.)' reads " "given John, middle 'Smith 김', family 민준., where " "2.2.0 read given John, middle Smith, family 김민준.); " "one row is enough, the mechanism being the unwrap in " "front rather than the position. Latin wrapped around a " "CJK name is the 2026-09-01 demotion's own ground, so " "the row is tolerated and the degradation is recorded " "in decisions/cjk-full-stops rather than fixed here", tolerated=True), Case("latin_suffix_with_an_ideographic_full_stop", "John Smith, Jr。", {"given": "John", "family": "Smith", "suffix": "Jr。"}, classification="fix(#322)", notes="the stop set is script-agnostic at the lookup fold, so " "the reach it bought is not CJK-only: _normalize strips " "any of the four FULL_STOPS off any word, and a LATIN " "suffix wearing the ideographic stop now folds to its " "entry. 2.2.0 read given 'Jr。' / family 'John Smith' -- " "the stop defeated the lookup and the credential became " "name text. Not tolerated and carrying no shape: the " "text is Latin, which _has_cjk does not see, and the " "row is a HEAD pin on the fold's Latin reach"), Case("latin_roman_numeral_with_an_ideographic_full_stop", "Smith, John V。", {"given": "John", "family": "Smith", "suffix": "V。"}, classification="fix(#322)", notes="the same reach where it costs something, and the " "asymmetry worth pinning: 'Smith, John V.' still reads " "middle 'V.', because _reads_as_a_trailing_suffix's " "carve-out (_assign) tests an ASCII period ending the " "piece's own text -- text.endswith('.') -- and nothing " "else; is_initial is not the decider: is_initial('V') " "is True, yet 'Smith, John V', no period at all, still " "reads suffix 'V'. The WIDE stop does not end the text " "in an ASCII period, so the carve-out never fires, the " "fold takes the stop off, and 'v' is roman five -- " "suffix. 2.2.0 read middle 'V。', the whole word being " "unfoldable then. Neither reading was designed; the " "bundle widened the stop set and this is where the " "widening lands on Latin text, which " "decisions/cjk-full-stops records as an unasked-for " "reach rather than a promise"), Case("ko_honorific_glued_teacher", "김선생님", {"family": "김", "suffix": "선생님"}, classification="fix(#307) + fix(#271)", notes="longest-first, end to end: 선생님 peels whole where " "님 alone would have left 김선생 to segment into a " "family 김 and a given 선생. Classified to #307 " "because the fields do not move in this change -- " "segmentation already delivered this shape (김 is a " "listed surname, so the split reached it); #308 " "changes which mechanism gets there first"), Case("latin_stem_glued_kana_honorific", "Andersonさん", {"given": "Anderson", "suffix": "さん"}, classification="fix(#308)", ambiguities=("given-or-family",), notes="no script precondition on the remainder -- the tail " "is the license. Japanese text about a foreigner, and " "the Latin remainder keeps the positional default. " "Single-issue on purpose where the block around it is " "compound: measured, disabling script_orders and " "segment_scripts leaves this row unchanged, because a " "Latin remainder never reaches either. Swept as a " "2026-09-01 tolerated candidate (CJK text, ASCII " "letters) and DECLINED, this row and its hangul twin " "below: the demoted forms wrap Latin AROUND a CJK " "name, while here the Latin is the name's own core " "with a CJK honorific glued on -- a form the glued " "peel is written for, and one #308 promises"), Case("latin_stem_glued_hangul_honorific", "Anderson선생님", {"given": "Anderson", "suffix": "선생님"}, classification="fix(#308)", ambiguities=("given-or-family",), notes="the hangul twin of latin_stem_glued_kana_honorific, " "and the one that shows why a post-nominal is not a " "surname site: 선 is a listed census surname, so the " "peeled 선생님 would otherwise be split into 선 + 생님 " "-- the stage dissecting the honorific it had just " "manufactured. Single-issue for the same reason as its " "kana twin: the remainder is Latin, so #271 never " "applies -- and a remainder no script order can place " "is a remainder O5's convention places, which is what " "#449 reports here and on the kana twin. Roles " "parity; the flag is #449's"), Case("ko_honorific_glued_doctor", "김민준박사님", {"family": "김", "given": "민준", "suffix": "박사님"}, classification="fix(#308) + fix(#271)", notes="박사님 is one honorific, not 박사 plus 님, and ships " "as one entry: 선생님, 교수님 and 박사님 are the three " "standard -님 professional honorifics and the first two " "shipped without it, which stranded 박사 in the given " "name here and rendered the spaced 김민준 박사님 as two " "suffixes. This row USED to pin the one-peel rule " "(named ko_glued_stack_peels_once) on the reading that " "left 박사 behind; no shipped vocabulary now has that " "shape, so the pin lives at stage level with a " "synthetic lexicon -- test_script_segment.py's " "test_one_peel_never_a_stack"), Case("ko_honorific_glued_doctor_spaced", "김민준 박사님", {"family": "김", "given": "민준", "suffix": "박사님"}, classification="fix(#308) + fix(#271)", notes="the spaced twin, and the second half of the same gap: " "without a 박사님 entry the peel cut this token too -- " "it is not a whole-token suffix word, so 박사 + 님 came " "back as two suffixes for one honorific"), Case("ko_glued_tail_alone_never_peels", "씨", {"family": "씨"}, classification="fix(#271)", notes="a token that IS a tail is not a peel site at all -- " "every tail is a suffix word, so the site scan steps " "past it and finds nothing else. NOT the length cap, " "which is never reached here. Classified to #271 " "because that is what moves the lone token from first " "to family; the row exists for the guard"), Case("ko_honorific_token_alone_stays_whole", "선생님", {"family": "선생님"}, classification="fix(#308) + fix(#271)", notes="a lone honorific is not a name to be taken apart: it " "is not a peel site (every tail is a suffix word) and " "not a surname site, though 선 is listed and the " "default segmentation split it 선 + 생님 before this " "change"), Case("ja_glued_tail_alone_never_peels", "さん", {"family": "さん"}, classification="fix(#272)", notes="the kana twin of ko_glued_tail_alone_never_peels, and " "of that row only: a lone さん carries none of " "ko_honorific_token_alone_stays_whole's risk, since no " "surname vocabulary is written in kana. The field " "placement is the kana-licensed order default, not the " "peel"), Case("ja_glued_degree_stays", "田中博士", {"family": "田中博士"}, classification="fix(#271)", notes="the exclusion pinned: glued 田中博士 IS Tanaka " "Hiroshi, an attested given name, so 博士 never peels " "-- it stays recognized in the SPACED position only, " "where the writer's own token boundary settles it"), Case("zh_spaced_legislator_is_a_suffix", "彭德威 议员", {"family": "彭", "given": "德威", "suffix": "议员"}, locale="zh", classification="fix(#671)", notes="2.4.2 read middle '议员'; the spaced word is a token " "the writer drew, so it needs only to be no name"), Case("ja_spaced_diet_member_is_a_suffix", "佐藤恵子 国会議員", {"family": "佐藤恵子", "suffix": "国会議員"}, classification="fix(#671)", notes="2.4.2 read given '国会議員'; family-first comes from " "the wholly-Han script order. The unspaced twin peels " "too since #707 (ja_glued_diet_member_peels)"), Case("zh_comma_legislator_is_a_suffix", "王雅婷,議員", {"family": "王雅婷", "suffix": "議員"}, classification="fix(#671)", tolerated=True, notes="the fullwidth comma is a listing convention (W3, " "best-effort); the word is vocabulary, so C8 reads the " "part after the comma as a credential run rather than " "reversing the name -- 2.4.2 gave it as the given name"), Case("zh_comma_degree_is_a_suffix", "陳怡君,醫學博士", {"family": "陳怡君", "suffix": "醫學博士"}, classification="fix(#671)", tolerated=True, notes="a four-character degree word that is whole after the " "comma, so the glued-博士 exclusion never applies"), Case("zh_spaced_generational_two_is_a_suffix", "陳怡君 二世", {"family": "陳", "given": "怡君", "suffix": "二世"}, locale="zh", classification="fix(#671)", notes="2.4.2 read middle '二世'"), Case("ja_spaced_job_title_is_a_suffix", "田中 社長", {"family": "田中", "suffix": "社長"}, classification="fix(#671)", notes="a form of address after the name, the slot 先生 " "fills; a job title, not a credential"), Case("ja_glued_job_title_stays", "田中部長", {"family": "田中部長"}, classification="fix(#271)", notes="the glued bar is unmet: 長 alone is a borne given " "name (阿部長 = 阿部 + 長), so 部長 is spaced-only -- " "#707 declined it on that argument while peeling its " "neighbours (ja_glued_job_title_peels). Unchanged from " "2.4.2, pinned so GLUED_HONORIFICS cannot gain it unseen"), Case("zh_listed_surname_then_new_word_divides", "李部長", {"family": "李", "suffix": "部長"}, locale="zh", classification="fix(#671)", notes="the segmenter divides at the listed surname and the " "split-off word is a whole-token suffix word, as 王博士 " "already reads; 2.4.2 read given '部長'. 阿部長 stays " "whole because 阿 is no listed surname"), Case("zh_glued_generational_two_peels", "陳怡君二世", {"family": "陳", "given": "怡君", "suffix": "二世"}, locale="zh", classification="fix(#707)", notes="the glued form #671 left open, vetted in #707: no " "Wikidata name item ends in 二世 bar a mislabel, so it " "joined GLUED_HONORIFICS; 2.4.2 read given '怡君二世'. " "After a listed surname the segmenter already divided " "it (王二世, family 王, suffix 二世) before any peel"), Case("ja_glued_diet_member_peels", "佐藤恵子国会議員", {"family": "佐藤恵子", "suffix": "国会議員"}, classification="fix(#707)", notes="2.4.2 read family '佐藤恵子国会議員'. Longest-first: " "国会議員 peels whole rather than leaving 国会 behind " "for 議員 to peel off"), Case("ja_glued_degree_word_peels", "田中医学博士", {"family": "田中", "suffix": "医学博士"}, classification="fix(#707)", notes="2.4.2 read family '田中医学博士'. The four-character " "word peels where its tail 博士 cannot (ja_glued_degree_" "stays): 医学博士 ends no name, 博士 is ひろし"), Case("ja_glued_job_title_peels", "田中社長", {"family": "田中", "suffix": "社長"}, classification="fix(#707)", notes="2.4.2 read family '田中社長'. Unlike 部長, no given " "name 長 follows a family name ending in 社 in " "Wikidata, and none is plausible"), Case("ja_glued_generational_after_katakana_peels", "エリザベス二世", {"given": "エリザベス", "suffix": "二世"}, classification="fix(#707)", ambiguities=("given-or-family",), notes="Elizabeth II as Japanese writes it. 2.4.2 read family " "'エリザベス二世'; the peeled katakana name keeps the " "positional default, a lone word read as given"), Case("zh_glued_jun_stays", "王君", {"family": "王君"}, classification="fix(#271)", notes="likewise unpeeled: 君 is a common Chinese given-name " "final, so 王君 is a complete name, not Mr. Wang. " "Unlike its neighbours here, 君 is in NEITHER set -- " "there is no spaced entry to fall back on either, and " "only its kana spelling くん peels"), Case("zh_glued_shi_stays", "王氏", {"family": "王氏"}, classification="fix(#271)", notes="likewise: 王氏 is a historical name form ('the Wang " "woman'). The spaced 田中氏 keeps its entry"), Case("ja_glued_dono_stays", "鵜殿", {"family": "鵜殿"}, classification="fix(#271)", notes="the exclusion with the longest argument behind it and, " "until this row, the only one nothing held: adding 殿 " "back to GLUED_HONORIFICS passed the whole suite. 鵜殿 " "(Udono) is a real surname, one of roughly ninety " "Japanese surnames ending in 殿 (真殿, 大殿, ...), and a " "peeled 殿 would give family 鵜 with 殿 in suffix. It " "has to be the BARE surname: in a two-token 真殿 太郎 " "the site scan lands on 太郎 and the exclusion is never " "consulted, so only a lone surname discriminates. " "Classified to #271 like its neighbours, not parity: " "1.4 gave first 鵜殿 and no last, and it is the CJK " "order flip that makes the one token a family name"), Case("ja_dono_spaced", "田中 殿", {"family": "田中", "suffix": "殿"}, classification="fix(#308) + fix(#271)", notes="殿 waited on an argument in #307 and gets one here: " "spaced it is safe for the reason 양/군 are -- a " "殿-surnamed person's name LEADS, and the suffix gate " "is trailing-only -- while glued it would cut 鵜殿 and " "真殿 in two, so it ships spaced only"), Case("ko_honorific_nim_spaced", "김민준 님", {"family": "김", "given": "민준", "suffix": "님"}, classification="fix(#308) + fix(#271)", notes="님 is new in both sets -- #307 shipped only the -님 " "compounds 선생님/교수님. Standardly glued in online " "address, spaced too, and never the end of a Korean " "given name, which is what qualifies it for the " "harsher glued vetting as well"), Case("ko_honorific_glued_via_segmentation", "김씨", {"family": "김", "suffix": "씨"}, classification="fix(#307) + fix(#271)", notes="the one glued shape that was already reachable before " "#308, which is why this row stays fix(#307) where its " "neighbours are fix(#308): stage order alone delivered " "it, since segmentation split 김 off the front and the " "honorific was whatever remained. The peel now takes 씨 " "off the END before segmentation is consulted, so the " "route changed and these fields did not. The glued " "shapes that needed the peel to be reached at all are " "the rows around it (김민준씨 below)"), Case("ko_honorific_after_comma", "김민준, 씨", {"family": "김민준", "suffix": "씨"}, classification="fix(#307)", notes="the post-comma run is normally the given name -- " "'김민준, 태호' gives given 태호 -- and group's " "is_suffix_piece diverts this one because 씨 is a " "single-token piece carrying vocab:suffix. NOT the " "lenient comma gate, which an earlier note named: " "measured, lenient_comma_suffixes=False leaves this " "row unchanged. The comma disables segmentation per " "the comma doctrine, so 김민준 stays whole -- which is " "also why this row stays single-issue while the rest of " "the block is compound with fix(#271): measured, the " "order table and the segmenter both leave it alone, " "because the comma already decided the family", tolerated=True), Case("ko_honorific_glued_given", "김민준씨", {"family": "김", "given": "민준", "suffix": "씨"}, classification="fix(#308) + fix(#271)", notes="the common full-name glued shape, and the row this " "replaces (ko_honorific_glued_given_stays) pinned the " "old boundary: 씨 peels off the last token first, and " "the remainder 김민준 then segments as usual -- peel " "and split compose, in that order. Shape 6's optional " "Honorific slot glued, the everyday spelling next to " "zh_honorific_suffix_spaced's spaced one", shape=6), Case("ko_honorific_glued_given_trailing_suffix", "김민준씨 Jr.", {"family": "김", "given": "민준", "suffix": "씨 Jr."}, classification="fix(#308) + fix(#271)", notes="the peel site is the last token that is not itself a " "post-nominal, so an unrelated trailing suffix cannot " "hide it -- this now agrees with the comma-written " "'Dr 김민준씨, Jr.', where the suffix comma had " "already put 씨 within reach. The run renders with the " "space the writer typed since #436; the comma form " "'Dr 김민준씨, Jr.' below keeps its comma, which is " "the pair that shows the separator is read from the " "text", tolerated=True), Case("ko_honorific_glued_given_suffix_comma", "Dr 김민준씨, Jr.", {"title": "Dr", "family": "김", "given": "민준", "suffix": "씨, Jr."}, classification="fix(#308) + fix(#271)", notes="the peel scans the NAME's runs, not the token stream: " "under a suffix comma that is segments[0] alone, a " "strict subset, and the peel site is found within it " "(a FAMILY comma is the one structure that splits the " "name across two runs, #312). Pairs with " "ko_honorific_glued_given_trailing_suffix, whose " "comma-less spelling of the same name reaches the same " "answer by the scan-back instead", tolerated=True), Case("ko_honorific_glued_given_nickname", "김민준씨 (Jimmy)", {"family": "김", "given": "민준", "suffix": "씨", "nickname": "Jimmy"}, classification="fix(#308) + fix(#271)", notes="the other half of scanning the NAME's runs: extracted " "content is still in the token stream at this stage but " "in NO segment, so the scan-back never reaches " "Jimmy. Scanning the tokens instead would take Jimmy as " "the site -- it is no post-nominal -- and lose the peel " "entirely, with 씨 back in the given name. Nothing else " "pins that choice: under NO_COMMA the two are otherwise " "the same run", tolerated=True), Case("ko_honorific_glued_given_nickname_family_comma", "김, 민준씨 (Jimmy)", {"family": "김", "given": "민준", "suffix": "씨", "nickname": "Jimmy"}, classification="fix(#312)", notes="the family-comma half of the same argument, which the " "row above cannot make: there the two runs are one, so " "'flatten the name's segments' and 'take segments[0]' " "agree. Here the peel has to cross the comma AND still " "not reach Jimmy, so declining to cross whenever " "extract_delimited claimed something passes the row " "above and fails only here", tolerated=True), Case("ja_honorific_glued_family_comma", "田中さん, PhD", {"family": "田中", "suffix": "さん, PhD"}, classification="fix(#312)", notes="the peel reaches 田中さん across the comma, though no " "longer by crossing it: since #319 is_wholly_suffix " "declines the post-comma run outright (PhD is suffix " "vocabulary and it is the whole run), so the scan never " "meets the comma. PhD reads as the postnominal it is " "since #296's audit took 'phd' out of TITLES -- this row " "carried title 'PhD' until then, which was the title " "peel claiming a credential because v1's lists put it " "where v1's parser needed it", tolerated=True), Case("ja_honorific_glued_family_comma_suffixy_second_run", "田中さん, V.", {"given": "V.", "family": "田中", "suffix": "さん"}, classification="fix(#319)", notes="under a family comma the peel scanned both runs on " "the premise that segments[1] is name text, and here it " "is not: the structure never said so (a one-word part " "before the comma read as FAMILY_COMMA even where the " "part after it was entirely suffix-shaped, and since " "#613 segment hands every comma form over as " "FAMILY_COMMA), so the scan reached 'V.' -- which " "is_suffix_strict rejects as an initial though the " "lenient test admits it to the run. 'V.' " "was therefore the site, ended in no tail, and the peel " "was abandoned with さん still in the family name. #319 " "asks C1's run predicate (_vocab.is_wholly_suffix) " "instead of inferring name text from the structure: the " "run is declined, the scan stays inside segments[0], " "and さん peels off 田中さん as it always did without a " "second run. Same credential, three spellings, ONE " "answer FROM THE PEEL now -- this, '田中さん, PhD' " "above and '田中さん, Ph. D.' in " "ja_honorific_glued_family_comma_credential_pair below " "all reach 田中さん. Where the credential itself LANDS " "still differs by spelling (title 'PhD', given 'V.', " "suffix 'さん, Ph. D.'); that is assign's question, not the " "peel's, and this row is not a claim about it. Nor does " "the comma form now agree with its SPACED twin: " "ja_honorific_glued_before_an_initial ('田中さん V.') " "still does not peel, because under NO_COMMA there is " "one run and 'V.' is inside the name's own tokens where " "the scan-back legitimately stops -- declining a " "post-comma run does not reach it. Parity with 1.4.0 " "(first V., last 田中さん) until this change, which is " "what moves it; the 1.4.0 fields are still reachable " "through Policy(lenient_comma_suffixes=False), pinned " "by ja_honorific_glued_family_comma_strict_knob below", tolerated=True), Case("ja_honorific_glued_family_comma_credential_pair", "田中さん, Ph. D.", {"family": "田中", "suffix": "さん, Ph. D."}, classification="fix(#319)", notes="the third of the three spellings #319 named, and the " "only one the Ph./D. merge reaches: is_wholly_suffix " "folds the adjacent pair into the single unit 'phd' " "(v1's fix_phd extracted the credential pre-parse), so " "the run counts as wholly suffix, the peel declines it, " "and さん comes off 田中さん exactly as in " "ja_honorific_glued_family_comma_suffixy_second_run " "above. The merge is also why this spelling is NOT the " "one to reach for when exercising " "Policy(lenient_comma_suffixes=False): 'phd' satisfies " "is_suffix_strict as readily as is_suffix_lenient, so " "the run is declined under EITHER setting and this row " "is identical under the knob, which " "ja_honorific_glued_family_comma_credential_pair_strict_" "knob below holds rather than leaving to the claim -- " "ja_honorific_glued_family_comma_strict_knob below uses " "'V.' because the initial-shaped words are where the " "two predicates actually part. Where the credential " "lands is a separate question and answers differently " "again: suffix here, title for 'PhD', given for 'V.' " "Measured: 1.4.0 gave first 田中さん / suffix 'Ph. D.' " "(fix_phd lifted the credential pre-parse, leaving a " "lone pre-comma word), so like " "ja_honorific_glued_family_comma above the expectation " "carries TWO deviations -- the peel is #319's, first -> " "family is comma-family's, which 2.0 already had before " "this change (family 田中さん / suffix 'Ph. D.')", tolerated=True), Case("ja_honorific_glued_family_comma_strict_knob", "田中さん, V.", {"family": "田中さん", "given": "V."}, policy=Policy(lenient_comma_suffixes=False), classification="parity", notes="the same input as " "ja_honorific_glued_family_comma_suffixy_second_run " "above under the knob that keeps the pre-#319 answer, " "and one of the table's three exercises of it " "(ko_honorific_period_under_strict_comma_suffixes and " "ja_honorific_glued_family_comma_credential_pair_strict_" "knob are the others). The knob drops is_wholly_suffix " "to the " "strict predicate, which rejects 'V.' as an initial, so " "the post-comma run reads as name text after all, the " "scan crosses into it, 'V.' is the site, it ends in no " "listed tail and the peel is abandoned -- さん stays in " "the family name. Not a blanket freeze of pre-#319 " "behavior, and the row must not be read as one: it " "holds for the INITIAL-shaped suffix words ('V.', 'V', " "'I'), which is the whole of where the strict/lenient " "gap lives. The counterexample is the row above, " "ja_honorific_glued_family_comma_credential_pair: its " "Ph./D. pair merges to a form strict accepts too, so " "that run is " "declined and the peel fires under this setting as " "well -- pinned, not merely stated, by " "ja_honorific_glued_family_comma_credential_pair_strict_" "knob above. No v1 spelling exists for the knob, so the " "facade runner skips this row and the classification " "compares against 1.4.0's single reading of the same " "text, as the other exercise of the knob named above " "does: measured, 1.4.0 gave first 'V.' / last " "田中さん, which is field for field what the knob holds " "here -- parity, and the point of the knob", tolerated=True), Case("ja_honorific_glued_family_comma_credential_pair_strict_knob", "田中さん, Ph. D.", {"family": "田中", "suffix": "さん, Ph. D."}, policy=Policy(lenient_comma_suffixes=False), classification="fix(#319)", notes="the counterexample the two rows above assert and " "neither measured: the knob does NOT freeze the " "pre-#319 reading in general, only for the " "initial-shaped suffix words. Here the Ph./D. merge " "folds the run into 'phd', which is_suffix_strict " "accepts as readily as is_suffix_lenient, so the run is " "declined and さん peels under this setting exactly as " "under the default -- field for field the same " "expectation as " "ja_honorific_glued_family_comma_credential_pair, which " "is the whole claim. Cheap to hold and worth holding " "separately, because the two rows differ only in the " "policy and a knob that started gating the decline " "wholesale would move this one alone. Same " "classification and the same two deviations as its " "default-policy twin (1.4.0: first 田中さん / suffix " "'Ph. D.'), which is also why the knob cannot be judged " "against a v1 spelling here -- there is none, so the " "facade runner skips this row as it does the other two " "knob rows", tolerated=True), Case("ko_honorific_glued_family_comma_suffixy_second_run", "김민준씨, V.", {"given": "V.", "family": "김민준", "suffix": "씨"}, classification="fix(#319)", notes="ja_honorific_glued_family_comma_suffixy_second_run in " "hangul, and pinned because the decline reads no script " "at all: it asks is_wholly_suffix about the post-comma " "run and the peel's own scan-back about segments[0], " "both vocabulary questions, so a script-conditional " "regression would be invisible in a table whose every " "other witness to #319 is written in kana. The family " "stays 김민준 undivided -- the FAMILY comma gates the " "surname split off, so hangul segmentation never runs " "here and only the peel acts. 1.4.0 gave first 'V.' / " "last 김민준씨, peeling nothing", tolerated=True), Case("zh_honorific_glued_family_comma_suffixy_second_run", "王先生, V.", {"given": "V.", "family": "王", "suffix": "先生"}, classification="fix(#319)", notes="the Han spelling of the row above, and the third " "script. 先生 is a shipped tail, so the peel fires with " "no locale opted in -- honorific_tails is licensed by " "the entries themselves rather than by " "Policy.segment_scripts, and HAN is not activated here " "(the family is what the peel left behind, not a " "vocabulary split). 1.4.0 gave first 'V.' / last " "王先生", tolerated=True), Case("ko_honorific_glued_family_comma_site_only_beyond_the_comma", "이, J.씨", {"given": "J.", "family": "이", "suffix": "씨"}, classification="fix(#312)", notes="the limit of #319's decline, and the row that says why " "it carries a second condition. is_wholly_suffix reaches " "period_joined_vocab, which calls an interior-period " "token a suffix when ANY chunk is suffix vocabulary -- " "and every honorific tail is a suffix WORD by the " "Lexicon invariant, so 'J.씨' reads as suffix-shaped " "BECAUSE of the 씨 the peel exists to remove. Declining " "on that evidence does not move the peel elsewhere the " "way 田中さん, V. does: segments[0] is the lone 이, which " "ends in no tail, so the scan finds no site at all, " "nothing peels, and the given name goes to suffix " "glued to its honorific ('J.씨'). So the gate declines " "only where segments[0] holds a peel site of its own, " "and this input reaches the pre-#319 fields by having " "none. Not rescued by " "Policy(lenient_comma_suffixes=False) either, unlike the " "strict-knob row above: the knob picks between " "is_suffix_lenient and is_suffix_strict per token and " "period_joined_vocab is downstream of neither, so both " "settings call this run wholly suffix. 1.4.0 gave first " "'J.씨' / last 이 -- it peels nothing, so the deviation " "here is #312's crossing, which is what puts the site on " "'J.씨' in the first place", tolerated=True), Case("ko_honorific_glued_family_comma_site_in_both_runs", "김민준씨, J.씨", {"family": "김민준", "suffix": "씨, J.씨"}, classification="fix(#319)", notes="the two-honorific input, where both runs hold a site " "and the decline therefore stands -- a deliberate choice " "between two readings rather than a fallout. Pre-#319 " "the scan crossed and took the LAST site, peeling the 씨 " "off the junk 'J.씨' and leaving the person's own " "honorific glued in the family name (given 'J.', family " "김민준씨, suffix 씨); now 씨 comes off 김민준씨 and " "'J.씨' is consumed whole as a suffix. It is also the " "row that rules out the narrower gate: declining only " "where the SECOND run has no peel site fixes " "ko_honorific_glued_family_comma_site_only_beyond_the_" "comma above and reverts this input to the pre-#319 " "reading, which is the worse of the two -- the same " "junk-tail reach " "ko_honorific_glued_given_suffix_comma_initial's note " "names under a suffix comma. 1.4.0 gave first 'J.씨' / " "last 김민준씨, peeling neither", tolerated=True), Case("ko_honorific_glued_family_comma_lone_post_nominal_before_it", "선생님, J.씨", {"given": "J.", "family": "선생님", "suffix": "씨"}, classification="fix(#312)", notes="segments[0] is a single token that IS a listed tail " "entire, which the site scan skips as a post-nominal " "(the same guard that keeps 선생님 from being peeled to " "선생 + 님) -- so there is no site before the comma, the " "run beyond it is scanned after all, and the fields are " "the pre-#319 ones as in the 이 row above. Pinned " "because it separates asking for the site with the " "peel's own scan-back from asking the cheaper question, " "whether any token ENDS in a tail: the cheaper one " "counts 선생님, declines, and then finds nothing to cut " "-- 씨 lost into suffix 'J.씨'. Only the lone-token " "shape of that divergence is reachable from the gate, " "which is why this row is one token before the comma; " "_peel_site's docstring derives the bound. " "1.4.0 gave first 'J.씨' / last 선생님", tolerated=True), Case("ko_honorific_glued_family_comma_stop_on_the_first_run", "김민준씨., J.씨", {"family": "김민준", "suffix": "씨., J.씨"}, classification="fix(#323)", notes="the stop-bearing spelling of " "ko_honorific_glued_family_comma_site_in_both_runs, " "here because the FAMILY_COMMA decline is a gate of TWO " "conjuncts and a trailing stop reaches exactly one of " "them. Measured on this tree and on d37b8ec: the first " "conjunct, is_wholly_suffix of the post-comma run, is " "True on both -- the stop is not there. The SECOND, " "segments[0] holding a peel site, is what the stop " "flipped, False before #323 (the tail match ran on the " "raw text and 김민준씨. ends in a stop, not in 씨) and " "True now. So the decline stands, 씨. peels off 김민준씨. " "and 'J.씨' is consumed whole. Agrees with the stop-less " "twin field for field, the stop riding on the honorific " "piece it arrived with (family 김민준, suffix '씨, J.씨' " "there). 2.2.0 read given 'J.', family 김민준씨., suffix " "씨 -- the junk 씨 peeled off 'J.씨' while the person's " "own honorific stayed in the family name", tolerated=True), Case("ja_honorific_glued_family_comma_stop_on_the_first_run", "田中さん., V.", {"given": "V.", "family": "田中", "suffix": "さん."}, classification="fix(#323)", notes="the kana twin of the row above, and the same conjunct: " "is_wholly_suffix(['V.']) is True on both trees under " "the lenient default, and the segments[0] site is False " "before #323 and True now. Before, the gate therefore " "did not decline, the scan crossed to 'V.', which ends " "in no tail, and the peel was abandoned with さん. left " "in the family name -- 2.2.0 read given 'V.', family " "田中さん., no suffix at all. Agrees with " "ja_honorific_glued_family_comma_suffixy_second_run " "field for field (given 'V.', family 田中, suffix さん " "there), the stop riding on the honorific", tolerated=True), Case("ko_honorific_glued_family_comma_stop_beyond_the_comma", "이, J.씨.", {"given": "J.", "family": "이", "suffix": "씨."}, classification="fix(#323)", notes="the stop on the OTHER side of the comma, and the row " "that keeps the two rows above from reading as 'a stop " "moves the gate'. Measured on this tree and on d37b8ec, " "NEITHER conjunct moves: the post-comma run is wholly " "suffix-shaped on both, and segments[0] -- the lone 이 " "-- holds no peel site on either, so the gate declines " "nothing and the scan crosses the comma under #312 both " "times. What the stop moved is the site scan itself. " "'J.씨.' ended in no listed tail before #323, so the " "peel found no site at all and the whole run went to " "suffix (2.2.0 read family 이, suffix 'J.씨.'); now the " "tail matches through the stop and the cut lands before " "it. Agrees with " "ko_honorific_glued_family_comma_site_only_beyond_the_" "comma field for field (given 'J.', family 이, suffix " "씨 there), the stop riding on 씨", tolerated=True), Case("ko_honorific_glued_given_after_family_comma", "김, 민준씨", {"family": "김", "given": "민준", "suffix": "씨"}, classification="fix(#312)", notes="under a family comma the name spans both segments and " "the honorific is on the GIVEN side, where the peel " "never looked before #312. Agrees with the spaced " "김 민준씨", tolerated=True), Case("ja_honorific_glued_given_after_family_comma", "田中, 太郎さん", {"family": "田中", "given": "太郎", "suffix": "さん"}, classification="fix(#312)", notes="the Han twin of the row above", tolerated=True), Case("zh_interpunct_transcription_glued_honorific", "威廉·莎士比亚さん", {"given": "威廉", "family": "莎士比亚", "suffix": "さん"}, classification="fix(#312)", notes="the dot still gates the surname split -- a " "transcription's pieces are syllable groups -- but an " "honorific glued to a transcribed name is still an " "honorific, so the peel crosses it. Agrees with the " "spaced 威廉·莎士比亚 さん"), Case("ja_honorific_glued_family_comma_no_site", "田中さん, 太郎", {"family": "田中さん", "given": "太郎"}, notes="unchanged, and pinned because a naive fix breaks it: " "the site is the last NON-POST-NOMINAL token, which is " "太郎, so nothing peels -- exactly as in the spaced " "田中さん 太郎. #312 was originally filed naming this " "pair as a disagreement; it never was one", tolerated=True), Case("ko_honorific_glued_given_suffix_comma_initial", "Dr 김민준씨, V.", {"title": "Dr", "family": "김", "given": "민준", "suffix": "씨, V."}, classification="fix(#308) + fix(#271)", notes="fix(#308) rather than fix(#312) because the fields do " "not move in this change -- a SUFFIX comma keeps the " "whole name in segments[0], which is what the peel " "already scanned. The row is here as the end-to-end " "guard for the scoping #312 introduced: widen the scan " "to every segment and 'V.' becomes the site, since " "segment admits a post-comma run on is_suffix_lenient " "while the site scan asks is_suffix_strict and an " "initial fails it. 'V.' then ends in no tail, the peel " "is abandoned, and 씨 is back in the given name -- the " "original bug. It was the table's only witness to that " "widening until #319; ja_honorific_glued_family_comma_" "suffixy_second_run and " "ja_honorific_glued_family_comma_credential_pair notice " "it as well now, but from the FAMILY_COMMA side, where " "the guard is is_wholly_suffix rather than this " "structural scoping. Their strict-knob sibling does NOT " "join them: under the knob is_wholly_suffix is False on " "the same run, so the scan was already crossing into " "segments[1] and widening past it changes nothing. This " "row is still the only SUFFIX comma among the three. " "Its comma-less twin ja_honorific_glued_before_an_initial " "shows the same veto from the other side, where 'V.' is " "in the name's own run and so IS the site", tolerated=True), Case("zh_honorific_glued_surname", "王先生", {"family": "王", "suffix": "先生"}, locale="zh", classification="fix(#307) + fix(#271)", notes="the Han twin of 김씨: the zh pack's segmentation " "splits off the surname and the remaining 先生 is the " "honorific token"), Case("zh_honorific_glued_given", "王小明先生", {"family": "王", "given": "小明", "suffix": "先生"}, locale="zh", classification="fix(#308) + fix(#271)", notes="the Han twin, replacing zh_honorific_glued_given_stays: " "先生 peels, and the zh pack's surname vocabulary then " "divides the remainder 王小明"), Case("zh_honorific_glued_given_default", "王小明先生", {"family": "王小明", "suffix": "先生"}, classification="fix(#308) + fix(#271)", notes="the same input WITHOUT the pack: the peel is default-on " "and script-independent, so the honorific still routes " "to suffix -- only the surname split needs the opt-in, " "so the undivided 王小明 stays one family name"), Case("ko_suffix_matching_is_whole_token", "김지양", {"family": "김", "given": "지양"}, classification="fix(#271)", notes="지양 ENDS with the honorific 양 but is a given name: " "suffix matching is whole-token, never endswith -- the " "pin the differential rule's anchor mirrors at the " "name-string level. #308 leaves it alone too: 양 is " "excluded from the glued tail set for exactly this " "name. Classified to #271, not parity: 1.4 gave first " "김지양 and no last, and it is #271's hangul " "segmentation plus the CJK order flip that produces " "these two fields"), Case("ko_surname_yang_leads", "양 미선", {"family": "양", "given": "미선"}, classification="fix(#271)", notes="양 is both a top-tier surname (Yang) and a shipped " "honorific: position decides, and a surname LEADS -- " "the trailing-only suffix gate never sees it here. " "Classified to #271, not parity: 1.4 gave first 양, " "last 미선, and it is the CJK order flip that swaps " "them"), Case("ko_honorific_yang_trails", "김민준 양", {"family": "김", "given": "민준", "suffix": "양"}, classification="fix(#307) + fix(#271)", notes="the other side of ko_surname_yang_leads: the same " "token trailing a name is 'Miss', and that is the whole " "argument shipping it -- the vocabulary names 양 and 군 " "as a risk class (in 님's note), 양 being a top-tier " "surname admitted to the vocabulary on the strength of " "position alone. Nothing pinned the trailing reading " "before this row, so the leading rows carried the pair " "by themselves. Classified to #307 like ko_honorific_ssi " "(1.4 gave first 김민준, last 양; the recognition and " "the order flip both move it) -- the point of the row " "is the twin below"), Case("ko_honorific_yang_written_with_a_period", "김민준 양.", {"family": "김", "given": "민준", "suffix": "양."}, classification="fix(#320)", notes="the period-written twin, whose fields must equal the " "row above and before #320 did not (given 김, middle " "민준, family '양.' -- the veto kept '양.' a name piece, " "exactly ko_honorific_with_a_period_no_comma's route). " "The pair is the point: 양 is the shipped vocabulary's " "acknowledged risk, so if the 양/군 policy is ever " "tightened or withdrawn, both spellings have to move " "together and neither row can be adjusted alone. 군 " "gets no pair of its own -- it parses identically and " "is the SAFER half (no surname reading), so it would " "pin nothing these two do not. Classified to #320 like " "its 씨 counterpart: 1.4.0 read this first 김민준 / last " "'양.', and the fields above are the ones this change " "produced, not the segmenter's. TOLERATED since " "2026-09-05 for the same reason as that counterpart -- " "a trailing ASCII period on a CJK honorific is a " "listing artifact no writing system produces -- and the " "pair moves tiers together the way it moves fields " "together. The row still pins #320's mechanism at HEAD", tolerated=True), Case("ko_surname_yang_leads_a_segmentable_given", "양 지훈", {"family": "양", "given": "지훈"}, classification="fix(#271)", notes="양 is a surname AND a shipped honorific, and 지 is a " "listed surname too -- so this is the name that " "catches a segmentation site scanning PAST the " "honorific reading of 양 into the given name. The " "first script-written token decides, and deciding " "includes deciding there is no surname site. " "Classified to #271, not parity: 1.4 gave first 양, " "last 지훈, and it is the CJK order flip that puts 양 " "in family -- nothing in #308 moves these fields"), Case("ko_honorific_stack", "김민준 박사 씨", {"family": "김", "given": "민준", "suffix": "박사 씨"}, classification="fix(#307) + fix(#271)", notes="a trailing RUN of honorifics peels whole, like " "'Smith PhD MD' -- the multi-suffix loop the peel " "shares with Latin suffixes. The run renders with the " "space the writer typed since #436; the comma form " "'Dr 김민준씨, Jr.' (ko_honorific_glued_given_suffix_" "comma) keeps its comma, which is the pair that shows " "the separator is read from the text"), ) derek73-python-nameparser-456b024/tests/v2/conftest.py000066400000000000000000000047041527303271700226040ustar00rootroot00000000000000"""Shared helpers for the tests/v2 package. The parent suite's autouse `_isolate_constants` fixture (tests/conftest.py) applies here ON PURPOSE: the v2 facade/shim tests mutate the shared CONSTANTS singleton (tests/v2/test_config_shim.py), so the snapshot/restore is wanted protection, not overhead. This conftest once overrode the parent's dual-run fixture by name to skip it -- that override went dead when the parent was renamed for #255, and it was deleted rather than reconnected because its premise ("v2 code never reads shared CONSTANTS") had stopped being true. """ import json from pathlib import Path from ._differential_fixtures import _entry_name def differential_corpus() -> list[str]: """Every name the differential harness compares, from all of its corpus files. Globs `corpus*.jsonl` for the reason compare.py does: a corpus you have to ask for by name is one that stops being run. Two test modules had grown their own hard-coded `corpus.jsonl` loader, so both silently missed `corpus_issues.jsonl` -- 198 reported names, 166 of them found nowhere else. tools/ is still not imported: it is a standalone uv-run script tree rather than a package, and the one-JSON-string-per-line format is cheaper to read than to make importable. corpus_cldr.jsonl joins it since #652: decomposed (NFD) renderings of its Slavic names, `Janez Novak, univ. dipl. inž.`, read differently from their composed twins until the shape regexes learned combining marks, and the decomposed-twin property test (tests/v2/test_properties.py) is the corpus-wide guard. Recorded negative control, measured 2026-10-12 with 04cc0814's nameparser/ and this file's corpus: that test fails with five non-hangul texts parsing differently decomposed -- four from corpus_cldr.jsonl (`g. Janez Peter Novak ml., univ. dipl. inž.` and three more Slavic renderings) and `Dr. inž. Janez Novak` from corpus_rules.jsonl, which #652 added. Joining the file took test_properties.py from 34.5 s to 39.6 s before the fix and it runs in 39.8 s after (py3.11, local). """ corpus_dir = Path(__file__).parents[2] / "tools" / "differential" paths = sorted(corpus_dir.glob("corpus*.jsonl")) assert paths, f"no corpus*.jsonl in {corpus_dir}" names = [_entry_name(json.loads(line)) for path in paths for line in path.read_text().splitlines() if line.strip()] return list(dict.fromkeys(names)) derek73-python-nameparser-456b024/tests/v2/data/000077500000000000000000000000001527303271700213115ustar00rootroot00000000000000derek73-python-nameparser-456b024/tests/v2/data/constants_v14.pickle000066400000000000000000000323721527303271700252170ustar00rootroot00000000000000€•ï4Œnameparser.config”Œ Constants”“”)�”}”(Œprefixes”hŒ SetManager”“”)�”}”(Œelements”�”(Œvel”Œ't”Œd플auf”Œibn”Œdelli”Œheer”Œdegli”Œvd”Œvom”Œdella”Œop”Œabu”Œdel”Œaan”Œbint”Œder”Œdei”Œdelle”Œvon”Œdi”Œal”Œle”Œdello”Œmc”Œden”Œst”Œav”Œaen”Œtho”Œaf”Œdela”Œdal”Œdo”Œde”Œbar”Œfreiherr”Œhet”Œda”Œsanta”Œmac”Œsan”Œbat”Œdu”Œzu”Œdem”Œthoe”Œ freiherrin”Œbon”Œvande”Œvander”Œde'”Œdos”Œte”Œter”Œbin”Œla”Œvan”Œste”�Œ _on_change”Œbuiltins”Œgetattr”“”hŒ_invalidate_pst”†”R”ubŒsuffix_acronyms”h)�”}”(h �”(Œlvo”Œfacep”Œcipm”Œiso”Œcga”Œsgm”Œscmp”Œcbe”Œcgma”Œcwep”Œsi”Œasc”Œcgm”Œemd”Œiaccp”Œcplp”Œbcl”Œfacofp”Œcissp”Œdo”Œmai”Œra”Œasla”Œcpcm”Œcpfo”Œgisp”Œrcp”Œsccp”Œccmt”Œcpo”Œfhames”Œcht”Œcpre”Œcapp”Œrmsks”Œasp”Œqpm”Œcsslp”Œkcmg”Œlitl”Œcscp”Œasid”Œ nbcdch-ps”Œrdms”Œcfeds”Œba”Œmp”Œcd”Œnp”Œrtrp”Œphr”Œpgmp”Œapss”Œnp[18]”Œcbt”Œcpht”Œdcvo”Œemt-b”Œocp”Œfac-c”Œcgc”Œmches”Œdiplac”Œcrtt”Œciro”Œcchp”Œcpg”Œchba”Œaas”Œfws”Œapr”Œsphr”Œcwna”Œgrp”Œmsm”Œcpsc”Œncto”Œccs”Œpla”Œctp”Œcprc”Œed”Œfmva”Œfala”Œrvt”Œcp”Œlmsw”Œam”Œndtr”Œissp-csp”Œipep”Œthm”Œcwap”Œstmieee”Œalc”Œnbc-his”Œcla”Œcds”Œsiie”Œcmc”Œdep”Œcaps”Œpe”Œbn”Œcb”Œchse”Œea”Œom”Œdfc”Œmcsd”Œitil”Œlpc”Œmdiv”Œabpp”Œlg”Œqgm”Œbpi”Œcmfo”Œlcmt”Œbpe”Œcxa”Œdabfm”Œcpfa”Œsasm”Œissp-sa”Œcfcm”Œfec”Œkcie”Œcna”Œdvep”Œrdn”Œcfc”Œedd”Œgaee[14]”Œkbe”Œcism”Œrai”Œrvm”Œclu”Œchrm”Œgri”Œdrph”Œmsc”Œchp”Œnmd”Œchi”Œcgfm”Œemt-i/85”Œcyds”Œoca”Œkg”Œcia”Œpmp”Œidsm”Œcsp”Œcsre”Œbls-i”Œcaro”Œches”Œcdal”Œlsi”Œdmd”Œfacs”Œoscp”Œcae”Œccna”Œcap”Œlitp”Œqfsm”Œafasma”Œccca”Œcgap”Œmscmsm”Œcams”Œcapm”Œgcmg”Œmdhb”Œrba”Œaqp”Œph”Œlmt”Œels”Œcea”Œusar”Œpmc”Œcig”Œfacem”Œlvt”Œabr”Œusa”Œcpwa”Œabc”Œfacph”Œphd”Œpmi-acp”Œcsbe”Œcprp”Œccim”Œpharmd”Œmem”Œmmas”Œmt”Œcrp”Œstb”Œccpr”Œpsm ii”Œmfa”Œcela”Œfsa”Œamd”Œpcc”Œpa-c”Œrd”Œcdfm”Œapn”Œcva[22]”Œgbe”Œmts”Œphc”Œctfa”Œae”Œdfm”Œcfi”Œusaf”Œcnp”Œncso”Œcips”Œaphr”Œprm”Œcgsp”Œcfr”Œcce”Œsecb”Œkcsi”Œcwp”Œcpcu”Œvd”Œalp”Œcfps”Œfnss”Œdc”Œfaafp”Œmdbb”Œchse-a”Œpta”Œrdcs”Œse”Œdd”Œ kchs/dchs”Œleed ap”Œcma”Œ nicet iii”Œcha”Œpsm”Œafc”Œmcpd”Œcciso”Œceas”Œncidq”Œcrme”Œnd”Œrid”Œuxc”Œncps”Œccnp”Œlcpc”Œaem”Œcgr”Œcip”Œmmad”Œdtr”Œcvo”Œcrisc”Œcspo”Œcwb”Œrph”Œrrt-accs”Œfasla”Œcbnt”Œfacog”Œfaicp”Œkp”Œllm”Œarrc”Œacha”Œusnr”Œcsa”Œceds”Œcp-c”Œfieee”Œeit”Œcppm”Œjp”Œod”Œlpn”Œgchs”Œesq”Œbtcs”Œdrb”Œcera”Œame”Œaicp”Œrrt”Œdpt”Œaba”Œmirm”Œmdb”Œfrm”Œcem”Œfasid”Œmaaa”Œchmm”Œamieee”Œcltd”Œcrma”Œhrs”Œdds”Œcrna”Œcpl”Œnicet iv”Œded”Œmcsa”Œcfe”Œsfp”Œcbv”Œocm”Œpps”Œccie”Œrhca”Œmbs”Œfmp”Œbem”Œmlt”Œfca”Œcwsp”Œchpse”Œfpc”Œdvm”Œawb”Œfaspen”Œcpacc”Œcms”Œlitk”Œchdm”Œemt-p”Œrfp”Œmieee”Œcfa”Œdnp”Œsra”Œcpp”Œdbe”Œenp”Œuscg”Œmle”Œlac”Œcsi”Œnbcch-ps”Œqam”Œfasma”Œgc”Œrls”Œcpm”Œmeng”Œchpa”Œlp”Œcvrs”Œcrt”Œchfc”Œfaap”Œqcsw”Œjd”Œres”Œfacha”Œobi”Œmdl”Œcmsp”Œkcvo”Œcm”Œgcsi”Œdma”Œacp”Œbca”Œlpss”Œusn”Œmls”Œcst”Œcto”Œcdmp”Œcbm”Œfacd”Œcpa”Œcro”Œnrp”Œsscp”Œkcb”Œace”Œshrm-scp”Œceh”Œatc”Œfic”Œifgict”Œnremt”Œerd”Œbt”Œsame”Œaca”Œdabvlm”Œiom”Œbpt”Œcysa”Œiccm-d”Œcgb”Œpsp”Œkhs/dhs”Œipm”Œcwne”Œpsyd”Œdmin”Œfp&a”Œcusp”Œphrca”Œacas”Œcep”Œpsm i”Œabd”Œei”Œcmt”Œcvp”Œala”Œgit”Œgmr”Œuxmc”Œcisa”Œkt”Œcca”Œcscu”Œmicp”Œdjur”Œbds”Œdrmp”Œrn”Œcbrte”Œcnm”Œcaia”Œchmc”Œnccp”Œrci”Œdacvim”Œcie”Œcph”Œlvn”Œagsf”Œcas”Œgm”Œch”Œmnaa”Œtd”Œcic”Œsa”Œncarb”Œmnae”Œcebs”Œmba”Œafm”Œclsd”Œqsd”Œpg”Œccm”Œcapa”Œccp”Œnbcfch”Œcsrte”Œnbcdch”Œusmc”Œcrde”Œcbne”Œcccm”Œfaan”Œqc”Œpp”Œmdep”Œcgfo”Œcbs”Œthd”Œqsp”Œmvo”Œiccm-f”Œfashp”Œchsc”Œ8-vsb”Œcacts”Œficf”Œmph”Œdsc”Œpls”Œevp”Œcpsi”Œfacc”Œchpln”Œud”Œccufc”Œvcp”Œcpbe”Œgba”Œrp”Œdcb”Œnraemt”Œbsc”Œcts”Œfsdp”Œdsm”Œmct”Œcmg”Œpt”Œfaia”Œarm”Œdiplom”Œmcdba”Œemt-i/99”Œlsit”Œaia”Œrrc”Œcpe”Œgcb”Œchsos”Œcmas”Œfacp”Œcbp”Œcet”Œmpa”Œdpm”Œshrm-cp”Œcpss”Œgsmieee”Œcaha”Œlm”Œnbct”Œlpa”Œmpse”Œfcas”Œvc”Œcssm”Œmcm”Œmbe”Œnremr”Œcbsp”Œccc”Œgphr”Œmcp”Œcsm”Œobe”Œmlse”Œcmp”Œfp-c”Œmm”Œchpe”Œcva”Œaprn”Œnbcch”Œiaee”Œdso”Œcbte”Œasa”Œrpa”Œpfmp”Œnicet ii”Œctbs”Œbcss”Œchss”Œccp-c”Œ nbcfch-ps”Œcua”Œdcm”Œcste”Œrpl”Œcasp”Œcwdp”Œrdh”Œvrd”Œdss”Œcpim”Œcfp”Œtbr-ct”Œcgp”Œcfcc”Œfcela”Œmsa”Œmcse”Œche”Œsmieee”Œlt”Œcsep”Œmra”Œmda”Œms”Œdcmg”Œcdt”Œcpt”Œmc”Œbts”Œnicet i”Œlcsw”Œma”Œcfce”Œgcvo”Œcfm”Œgcie”Œrrt-nps”Œhccp”Œot”Œcfre”Œcsc”Œmd”Œgmb”Œams”Œpci”Œmcct”Œrrt-sds”Œfd”�hGhJhhK†”R”ubŒsuffix_not_acronyms”h)�”}”(h �”(Œi”Œret”Œesq”Œjunior”Œesquire”Œsnr”Œdr”Œii”Œiii”Œ2”Œsr”Œjr”Œv”Œvet”Œjnr”Œiv”�hGhJhhK†”R”ubŒtitles”h)�”}”(h �”(Œstaff”Œuk”Œmystery”Œ bodhisattva”Œmarcher”Œ marketing”Œpaleontologist”Œchief”Œasst”Œsgm”Œ1lt”Œ evangelist”Œlaw”Œplayer”Œranger”Œdrummer”Œcomedian”Œsinger”Œstates”Œ assistant”Œsrtas”Œcomic”Œdo”Œ1stlt”Œ co-founder”Œ politician”Œra”Œsainte”Œcpo”Œ gentiluomo”Œ biologist”Œmajgen”Œeditor”Œgyani”Œ fräulein”Œ architect”Œ monseigneur”Œpres”Œssg”Œ technical”Œcustoms”Œmrs”Œbishop”Œliterary”Œbailiff”Œexec”Œqueen's”Œmag”Œtimi”Œ abolitionist”Œ printmaker”Œfederal”Œ saoshyant”Œdancer”Œshaykh”Œattache”Œ co-chairs”Œco-chair”Œdpty”Œsultana”Œ swordbearer”Œ psychiatrist”Œcountess”Œflag”Œpro”Œceo”Œrdml”Œteacher”Œopera”Œlcpl”Œ signorina”Œ archbishop”Œmisses”Œsergeant”Œ historicus”Œmlle”Œ playwright”Œfirst”Œ venerable”Œvicar”Œ señorita”Œlady”Œsailor”Œchef”Œ photographer”Œlinguist”Œprof”Œmayor”Œ barrister”Œroyal”Œsgt”Œ ambassador”Œrevenue”Œ industrialist”Œcmsgt”Œ bandleader”Œmadam”Œmsg”Œrep”Œ designated”Œpriest”Œmanager”Œneuroscientist”Œsr”Œ philosopher”Œpir”Œamn”Œprin”Œmusician”Œ broadcaster”Œmost”Œsaint”Œ treasurer”Œsinger-songwriter”Œmcpon”Œadvocate”Œ großfürst”Œfürsterzbischof”Œ knowledge”Œqueen”Œ1stsgt”Œbgen”Œproducer”Œenglish”Œguru”Œ pursuivant”Œforeign”Œking's”Œblessed”Œtenor”Œjunior”Œviscount”Œ subaltern”Œabbot”Œ analytics”Œwo5”Œexpert”Œinventor”Œmarquis”Œcomposer”Œservant”Œmcpoc”Œsn”Œreverend”Œsubedar”Œsres”Œ honourable”Œ associate”Œsrta”Œprovost”Œus”Œ2nd”Œking”Œshaikh”Œmarchess”Œstrategy”Œhigh”Œcdr”Œdivision”Œhajji”Œ professor”Œsheriff”Œpetty”Œsfc”Œsultan”Œpharaoh”Œwp”Œdutchess”Œmobster”Œherzog”Œwoodman”Œgroup”Œimam”Œ councillor”Œ mathematics”Œvrouwe”Œ clergyman”Œpianist”Œ marchioness”Œpvt”Œmajesty”Œ erzbischof”Œ mag-judge”Œcwo-4”Œpoet”Œforester”Œcardinal”Œsheik”Œ counselor”Œcritic”Œ corporate”Œtax”Œdeacon”Œfrau”Œpolice”Œ celebrity”Œadmiral”Œ controller”Œrepresentative”Œ naturalist”Œbaron”Œfriar”Œ ornithologist”Œ instructor”Œbg”Œdistrict”Œanthropologist”Œfru”Œ obstetritian”Œphd”Œpilot”Œbp”Œsenhora”Œsurgeon”Œ senhorita”Œritter”Œbusiness”Œwo-1”Œsheikh”Œ historian”Œsecurity”Œ presbyter”Œ researcher”Œsister”Œtravel”Œ mathematician”Œacademic”Œ burlesque”Œfr”Œjr”Œsgtmajmc”Œdesigner”Œfilm”Œalmoner”Œsoftware”Œminister”Œ economist”Œ mademoiselle”Œmahdi”Œ biochemist”Œprivate”Œpv2”Œ operating”Œwo2”Œvice”Œ appellate”Œ developer”Œ rangatira”Œvardapet”Œdocket”Œ keyboardist”Œ excellent”Œ schoolmaster”Œpremier”Œdame”Œcfo”Œuncle”Œprimate”Œfamily”Œspc”Œbaba”Œ physician”Œmgysgt”Œbriggen”Œ technologist”Œwarden”Œkingdom”Œlegal”Œattach锌rev”Œse”Œcwo5”Œsuperior”Œprior”Œassoc”Œher”Œmilitary”Œakhoond”Œdirector”Œjudge”Œ tirthankar”Œ guitarist”Œmister”Œcwo-3”Œpioneer”Œ ayatollah”Œbaseball”Œnavy”Œblues”Œgysgt”Œcwo-5”Œlord”Œ biographer”Œ historien”Œ excellency”Œfreifrau”Œprefect”Œdra”Œ9th”Œmother”Œcol”Œburgess”Œseñora”Œeffendi”Œholiness”Œdelegate”Œsenior”Œbard”Œdcn”Œemperor”Œ principal”Œarranger”Œ dipl.-ing”Œlama”Œleader”Œmsgt”Œwing”Œ maharajah”Œresident”Œpslc”Œ conductor”Œhost”Œsenator”Œ archdeacon”Œherren”Œshayk”Œacolyte”Œsignor”Œactor ”Œactivist”Œ compositeur”Œarhat”Œrt”Œtsgt”Œfadm”Œrear”Œlecturer”Œme”Œsignore”Œ presiding”Œdocent”Œmaharani”Œchair”Œlawyer”Œ apprentice”Œ political”Œvadm”Œcpl”Œ entertainer”Œrock”Œengineer”Œ theologian”Œ comptroller”Œcriminal”Œgräfin”Œmgr”Œmodel”Œlyricist”Œcheikh”Œchairs”Œltgen”Œrebbe”Œprime”Œ priestess”Œ dramatist”Œ detective”Œprince”Œens”Œsupreme”Œattorney”Œ award-winning”Œ discovery”Œ entrepreneur”Œcmsaf”Œmonsieur”Œspeaker”Œmerchant”Œpo2”Œadjutant”Œsra”Œsoccer”Œnanny”Œbrother”Œmevrouwe”Œ behavioral”Œ chieftain”Œbearer”Œaunt”Œpfc”Œadviser”Œ classical”Œscholar”Œ archdruid”Œcomtesse”Œpastor”Œcwo-2”Œ librarian”Œclerk”Œ matriarch”Œmpco-cg”Œltcol”Œshekh”Œcwo3”Œeminence”Œ cartoonist”Œunited”Œdiplomat”Œ2lt”Œblogger”Œsoldier”Œadept”Œcoach”Œmufti”Œstate”Œcanon”Œhon”Œ missionary”Œdr”Œsmsgt”Œ arbitrator”Œgrand”Œ archeologist”Œ correspondent”Œ monsignor”Œherrn”Œtheatre”Œcredit”Œ ecologist”Œa1c”Œnational”Œmet”Œ intelligence”Œdeputy”Œgovernor”Œedohen”Œ comedienne”Œhis”Œmagnate”Œmevr”Œcolonel”Œarchduke”Œair”Œmurshid”Œ sociologist”Œ1st”Œjudicial”Œ lieutenant”Œ pediatrician”Œsoprano”Œempress”Œgoodwife”Œeducator”Œgeneral”Œchargé d'affaires”Œssgt”Œdhr”Œ businesswoman”Œenvoy”Œbuddha”Œchaplain”Œst”Œgraf”Œ hereditary”Œmullah”Œ10th”Œballet”Œcyclist”Œ journalist”Œ cartographer”Œ magistrate”Œ registrar”Œradm”Œseñor”Œcorporal”Œ information”Œfather”Œflying”Œfh-prof”Œvizier”Œsa”Œ physicist”Œcaptain”Œhighness”Œfrk”Œ3rd”Œprinter”Œrisk”Œ personality”Œshehu”Œabbess”Œ sysselmann”Œsiddha”Œverderer”Œltg”Œ zoologist”Œ television ”Œ reichsgraf”Œ solicitor”Œab”Œbench”Œdoyen”Œvoice”Œgiani”Œedmi”Œcomputer”Œbaroness”Œactress”Œ dissident”Œarmy”Œ elerunwon”Œedler”Œ bookseller”Œ metropolitan”Œseñores”Œwo3”Œcommander-in-chief”Œ commander”Œmaid”Œmx”Œ executive”Œgaf”Œmonk”Œscpo”Œ scientist”Œekegbian”Œprelate”Œheir”Œ bibliographer”Œpo1”Œwo4”Œlcdr”Œinvestor”Œ philantropist”Œfield”Œ municipal”Œsma”Œherr”Œprinz”Œwm”Œspecial”Œpope”Œ mountaineer”Œchemist”Œ anarchist”Œheren”Œ memoirist”Œ2ndlt”Œ7th”Œprincess”Œbwana”Œmadame”Œsatirist”Œswami”Œwriter”Œdir”Œcwo4”Œ patriarch”Œwarrant”Œwo1”Œ generalissimo”Œlamido”Œpd”Œ mag/judge”Œjurist”Œmarquess”Œgen”Œ criminologist”Œ financial”Œtsar”Œ musicologist”Œvc”Œgoodman”Œheiress”Œ6th”Œ businessman”Œpo3”Œccmsgt”Œsgtmaj”Œcsm”Œ psychologist”Œshaik”Œnovelist”Œ publisher”Œ secretary”Œcwo2”Œfrøken”Œ chancellor”Œ consultant”Œtheater”Œsocial”Œbiblical”Œbanker”Œofficer”Œltjg”Œ archduchess”Œ investigator”Œmr”Œpainter”Œvocalist”Œ astronomer”Œ8th”Œhr”Œcount”Œ senior-judge”Œ priv.-doz”Œ advertising”Œmevrouw”Œ brigadier”Œcurator”Œ suffragist”Œ illustrator”Œessayist”Œ choreographer”Œ1sgt”Œ collector”Œ intendant”Œ honorable”Œ president”Œ univ.prof”Œmcpo”Œmg”Œcapt”Œ catholicos”Œ screenwriter”Œmaj”Œheadman”Œlt”Œmagistrate-judge”Œbotanist”Œfounder”Œnarrator”Œmember”Œcourtier”Œduchesse”Œsir”Œsenhor”Œmarquise”Œalderman”Œ commodore”Œ journeyman”Œms”Œmme”Œ señoritas”Œcpt”Œsignora”Œtheorist”Œauthor”Œ5th”Œmaster”Œadm”Œbanner”Œseigneur”Œrabbi”Œfreiherr”Œfürst”Œradio”Œ4th”Œmd”Œanimator”Œtsarina”Œdoctor”Œauntie”Œ translator”Œmiss”Œcivil”Œbritish”Œdruid”Œnurse”Œright”Œltc”�hGhJhhK†”R”ubŒfirst_name_titles”h)�”}”(h �”(Œsheik”Œshayk”Œmaster”Œshaykh”Œbrother”Œdame”Œmother”Œuncle”Œshaik”Œshekh”Œsheikh”Œmaid”Œfather”Œsister”Œaunt”Œqueen”Œpope”Œking”Œshaikh”Œsir”Œauntie”Œcheikh”�hGNubŒ conjunctions”h)�”}”(h �”(Œund”Œy”Œof”Œand”Œ&”Œe”Œthe”Œet”�hGNubŒbound_first_names”h)�”}”(h �”(Œabdul”Œabdal”Œabu”Œumm”Œabdel”Œabou”�hGNubŒnon_first_name_prefixes”h)�”}”(h �”(Œaf”Œ't”Œde”Œauf”Œibn”Œdelli”Œdegli”Œvd”Œvom”Œop”Œhet”Œbint”Œder”Œdei”Œdelle”Œzu”Œdem”Œde'”Œdos”Œdello”Œter”Œav”�hGNubŒsuffix_acronyms_ambiguous”h)�”}”(h �”(Œjd”Œed”�hGNubŒcapitalization_exceptions”hŒ TupleManager”“”)R”}”(Œii”ŒII”Œiii”ŒIII”Œiv”ŒIV”Œmd”ŒM.D.”Œphd”ŒPh.D.”ubŒregexes”hŒRegexTupleManager”“”)R”}”(Œspaces”Œre”Œ_compile”“”Œ\s+”K †”R”Œword”jáŒ(\w|\.)+”K †”R”Œmac”jáŒ^(ma?c)(\w{2,})”K"†”R”Œinitial”jáŒ^(\w\.|[A-Z])?$”K †”R”Œ quoted_word”jáŒ(? — ``. A rule, a section and a rule-keyed decisions entry are filed as ``-.md``; a decisions entry keyed by a slug is filed under the slug alone, which already is a name. A citation names the key only (``rules/S2``, ``decisions/vocabulary-collisions``), so renaming a file never breaks one. """ from __future__ import annotations import re import unicodedata from collections.abc import Set as AbstractSet from dataclasses import dataclass from pathlib import Path DESIGN_DIR = Path(__file__).resolve().parents[2] / "docs" / "design" RULES_DIR = DESIGN_DIR / "rules" DECISIONS_DIR = DESIGN_DIR / "decisions" MECHANISMS_DOC = DESIGN_DIR / "mechanisms.md" #: rules order, the order the decisions index groups its entries in SECTION_ORDER = "HPSNMCOWTARD" # A citation: the folder and the key, or mechanisms.md and its slug. A # key is not followed by a word character, nor by a hyphen and another # word, which is how a file path in the rules folder (a stem like # S6-case-signal-at-the-suffix-slot) is told from a citation, and a `.md` # after it marks a file name (the README); a sentence's full stop is # allowed, and so is a hyphen ending the line, where a comment wraps a # long key. A decisions key starting with a # digit (`3-0-reevaluations`) is not matched, as it was not before the # split. MENTION_RE = re.compile( r"(?:(?Prules|decisions)/|(?Pmechanisms)\.md#)" r"(?P[A-Za-z][A-Za-z0-9_]*(?:-[A-Za-z0-9_]+)*)" r"(?![A-Za-z0-9_]|-[A-Za-z0-9_])(?!\.md\b)") _LINK_RE = re.compile(r"\[(?P[^\]]+)\]\((?P<file>[^)/]+\.md)\)") _KEY_RE = re.compile(r"[^\s—,]+") _SECTION_RE = re.compile(r"\(([A-Z])\)$") _RULE_KEY_RE = re.compile(r"[A-Z]\d*") _ARTICLE_RE = re.compile(r"^(?:a|an|the)\s+", re.IGNORECASE) @dataclass(frozen=True) class IndexEntry: key: str title: str # the link text, "S6 — the case signal at the suffix slot" path: Path def slugify(name: str) -> str: """A name as a file-name slug: `&` read as "and", one leading article dropped, an apostrophe dropped ("run's" is "runs"), folded to lowercase ASCII, every run of other characters one hyphen.""" name = _ARTICLE_RE.sub("", name.replace("&", " and ").strip(' "\'')) name = re.sub(r"(?<=\w)['’](?=\w)", "", name) ascii_name = (unicodedata.normalize("NFKD", name) .encode("ascii", "ignore").decode()) return re.sub(r"[^a-z0-9]+", "-", ascii_name.lower()).strip("-") def stem_for(key: str, name: str) -> str: """The file stem for an entry: a rule or section key with its name's slug, any other key alone.""" if _RULE_KEY_RE.fullmatch(key): return f"{key}-{slugify(name)}" return key def read_index(folder: Path) -> list[IndexEntry]: text = (folder / "README.md").read_text(encoding="utf-8") entries = [] for m in _LINK_RE.finditer(text): # a section is linked by its heading, "Suffixes: ... (S)" km = (_SECTION_RE.search(m.group("title")) or _KEY_RE.match(m.group("title"))) assert km, f"{folder.name}/README.md: link without a key: {m[0]}" entries.append(IndexEntry(km.group(km.lastindex or 0), m.group("title"), folder / m.group("file"))) return entries def read_rules_text(folder: Path = RULES_DIR) -> str: """Every section and rule file, in index order, as one text.""" return "\n".join(e.path.read_text(encoding="utf-8") for e in read_index(folder)) def read_decision_files(folder: Path = DECISIONS_DIR) -> dict[str, str]: return {e.key: e.path.read_text(encoding="utf-8") for e in read_index(folder)} def resolve(doc: str, cid: str, folder: Path | None = None) -> Path | None: """The one file a citation names, or None when none or several do.""" if folder is None: folder = RULES_DIR if doc == "rules" else DECISIONS_DIR hits = [p for p in folder.glob("*.md") if p.name != "README.md" and (p.stem == cid or p.stem.startswith(cid + "-"))] return hits[0] if len(hits) == 1 else None # A rule ID is never reused (rules/README.md). A retired rule's file # leaves rules/, and its decisions file records the retirement on a line # of its own opening `Retired:`; a rule file carrying that ID again is # refused. A line that means `Retired:` and misspells it (bold, a list # marker or number, lowercase) would switch the refusal off for its ID, so it is a # finding too. _RETIRED_RE = re.compile(r"^Retired:", re.M) _NEAR_RETIRED = re.compile( r"^[ \t>*_+-]*(?:\d+[.)][ \t]*)?retired[ \t*_]*:.*$", re.M | re.I) def rule_file_ids(folder: Path = RULES_DIR) -> set[str]: """The ID of every rule and section file in the folder, read from the file names rather than the index, so a file the index misses is still counted.""" return {p.stem.split("-", 1)[0] for p in folder.glob("*.md") if p.name != "README.md" and _RULE_KEY_RE.fullmatch(p.stem.split("-", 1)[0])} def retired_ids(decisions: dict[str, str]) -> set[str]: """The keys of the decisions files that record a retirement.""" return {key for key, text in decisions.items() if _RETIRED_RE.search(text)} def reused_id_findings(rule_ids: set[str], decisions: dict[str, str]) -> list[str]: """What breaks never-reused: a rule file whose ID a decisions file records as retired, a `Retired:` line in a decisions file keyed by no rule ID (it retires nothing), and a line misspelling `Retired:`.""" findings = [] for key, text in sorted(decisions.items()): for m in _NEAR_RETIRED.finditer(text): if not m.group(0).startswith("Retired:"): findings.append(f"decisions/{key}: {m.group(0)!r} is not " "a line opening 'Retired:'") for key in sorted(retired_ids(decisions)): if not _RULE_KEY_RE.fullmatch(key): findings.append(f"decisions/{key} carries a Retired: line but " "is keyed by no rule ID, so it retires nothing") elif key in rule_ids: findings.append(f"rules/{key} reuses an ID decisions/{key} " "records as retired: a new rule takes a new ID") return findings # A decisions file carries `## Standing`, an index of the decisions in # force, ahead of its entries under `## Log` -- every file but the # reference lists (below). Each Standing # bullet ends its claim with the entry that decided it, by date and # issue (`— 2026-10-06 #613`) or by date alone; a `Not standing:` line # names entries a later one answered. An entry is a log bullet opening # with its date, bare or in bold (`- **2026-10-14 #679 — …**`), and its # head is the text before its first ` — `, read without any # parenthesized note saying part of it was superseded (README edit kind # 1). A pointer's candidates are the entries whose head opens with # exactly its date and whose first issue is its issue -- for a date-only # pointer, whose head names no issue. Where several are candidates, the # words after the pointer pick the one whose head continues with them, # the longest such continuation winning (`— 2026-09-14 (Derek)`, # `— 2026-10-18 #718 review`); a date inside those words is part of # them, not a second pointer. A pointer with no words names the # candidate whose head ends there, and one whose words continue no # candidate's head is a finding, since nothing tells a typo from prose # there. Where heads are the same words (twins), the words past the # head pick the entry whose text opens with them, also where another # head of that date and issue continues past those words # (`— 2026-09-13 #383/#479, AMENDS`): there the words are a key, not # prose. A pointer still naming more than one entry is ambiguous. An # entry whose text after the head opens with `SUPERSEDED by` is # superseded, and a Standing pointer may not rest on it; one opening # `SUPERSEDED:` or `SUPERSEDED IN PART:` announces what it replaces and # is live. A file's title is never read as an entry # (`test_a_dated_title_founds_no_entry`); decisions/README.md says how # a bundle of undated bullets gains a dated line to point at. _DATE = r"\d{4}-\d\d(?:-\d\d)?(?:/\d\d)?" # The issue is the first `#N` within 40 characters of the date that no # `—`, `.`, `;` or other date interrupts, so a head copied whole # (`2026-10-18 (Derek), #718`) still reads its issue and `— 2026-10-19, # 2026-10-18 #721` stays two pointers; an issue written in prose # right after a date-only pointer (`— 2026-09-14, see #500`) is read as # the pointer's and fails as no entry, so end the pointer first. _POINTER_RE = re.compile( rf"(?P<date>{_DATE})(?![\d/-])" rf"(?:(?:(?!\d{{4}}-\d\d)[^—#.;]){{0,40}}?#(?P<num>\d+))?") _ENTRY_RE = re.compile(r"- (?:\*\*)?(?=\d{4}-\d\d)") def _entries(log: str) -> list[tuple[str, str]]: """Each log bullet as its head and the text after it. A bold head's markers are not part of it (`- **2026-10-14 #679** (Derek) — x` has the head `2026-10-14 #679 (Derek)`); with no ` — ` in the line, a bold head ends where its bold closes and the text follows it (`- **2026-10-15 #5**: text`); and a bold opening the text is not part of it, so `— **SUPERSEDED by** …` still marks the entry.""" out = [] for line in log.splitlines(): m = _ENTRY_RE.match(line) if m: body = line[m.end():] head, dash, rest = body.partition(" — ") if m.group(0).endswith("**"): if not dash and "**" in body: head, _, rest = body.partition("**") rest = rest.lstrip(": ") head = head.replace("**", "") out.append((head, rest.removeprefix("**"))) return out def _clean(head: str) -> str: """A head without its parenthesized supersession notes, which can hold parentheses of their own (`(item (ii) superseded by …)`); the word is matched in any case, and a parenthesis never closed is text, not a note.""" out: list[str] = [] i = 0 while i < len(head): if head[i] == "(": depth, j = 0, i while j < len(head): depth += {"(": 1, ")": -1}.get(head[j], 0) if depth == 0: break j += 1 if depth == 0 and "superseded" in head[i:j + 1].lower(): while out and out[-1] == " ": out.pop() i = j + 1 continue out.append(head[i]) i += 1 return "".join(out) def _names(head: str, date: str, num: str | None) -> bool: head = _clean(head) m = re.match(_DATE, head) first = re.search(r"#(\d+)", head) return (m is not None and m.group(0) == date and (first.group(1) if first else None) == num) def _tail(head: str, date: str, num: str | None) -> str: """What a head says after the date or issue a pointer names. Asked only of a candidate, whose head holds that issue by `_names`.""" head = _clean(head) if num is None: return head[len(date):] at = re.search(rf"#{num}(?!\d)", head) assert at is not None return head[at.end():] @dataclass(frozen=True) class _Resolved: hits: list[tuple[str, str]] # the entries named, as (head, rest) used: int # characters of the line the pick read unmatched: str = "" # words that continue no candidate def _resolve(line: str, m: re.Match[str], entries: list[tuple[str, str]]) -> _Resolved: """The log entries one pointer names.""" date, num = m["date"], m["num"] found = [e for e in entries if _names(e[0], date, num)] after = line[m.end():] if len(found) < 2: return _Resolved(found, _read(after, found, date, num)) # The words end at `.` or `;`, or where the next pointer's date # starts (`— 2026-10-17 #718, 2026-10-18 #721`). said = re.split(rf"[.;]|{_DATE}", after, maxsplit=1)[0].rstrip(" ,") tails = {e: _tail(e[0], date, num) for e in found} # The whole rest of the line is matched, so a period or a date # inside a head's words is read as part of them. whole = [e for e in found if tails[e] and after.startswith(tails[e])] if whole: longest = max(len(tails[e]) for e in whole) hits = [e for e in whole if len(tails[e]) == longest] used = longest else: cut = [e for e in found if said and tails[e].startswith(said)] if cut: hits, used = cut, len(said) elif said: # Words past a bare head are a twin key where two or more # heads end at the pointer, though another head continues. bare = [e for e in found if not tails[e]] if not bare or (len(bare) < 2 and len({_clean(h) for h, _ in found}) > 1): return _Resolved([], 0, said) hits, used = bare, 0 else: hits = [e for e in found if not tails[e]] or found used = 0 if len(hits) > 1 and len({_clean(head) for head, _ in hits}) == 1: # Twins: entries whose heads are the same words. What the # pointer says past the head names one by how its text opens. words = said[len(tails[hits[0]]):].lstrip(" ,") twin = [e for e in hits if words and e[1].startswith(words)] hits = twin or hits return _Resolved(hits, max(used, _read(after, hits, date, num))) def _read(after: str, hits: list[tuple[str, str]], date: str, num: str | None) -> int: """How much of the line the one entry named was read as its head's words -- however much of them the pointer wrote -- so a date among them is not taken for a second pointer.""" if len(hits) != 1: return 0 tail = _tail(hits[0][0], date, num) n = 0 while n < min(len(after), len(tail)) and after[n] == tail[n]: n += 1 return n def _hits(line: str, m: re.Match[str], entries: list[tuple[str, str]]) -> list[tuple[str, str]]: """The log entries one pointer names, as (head, rest) pairs.""" return _resolve(line, m, entries).hits _STANDING_HEADING = re.compile(r"^## Standing$", re.M) _LOG_HEADING = re.compile(r"^## Log$", re.M) # A line that means the heading and misspells it: the exact match would # miss it and switch every Standing check off for the file. _NEAR_STANDING = re.compile(r"^[ \t]*#{1,6}[ \t]*standing\b.*$", re.M | re.I) def misspelled_standing(text: str) -> list[str]: """The lines that try to be the Standing heading and are not it.""" return [m.group(0) for m in _NEAR_STANDING.finditer(text) if m.group(0) != "## Standing"] def has_standing(text: str) -> bool: """Whether a decisions file carries a Standing block: the heading on a line of its own, not the words quoted in prose.""" return _STANDING_HEADING.search(text) is not None def standing_findings(text: str) -> list[str]: """What is wrong with a decisions file's Standing block: a bullet with no `— <date>` pointer, any pointer from there to the line's end naming no entry (dates match exactly), carrying words that continue no candidate's head where its date and issue are shared, naming more than one (ambiguous), or naming a `SUPERSEDED by` entry; a Not-standing pointer naming no entry, carrying such words or naming more than one (a superseded entry is what it may name); a Standing with no Log after it. Empty for a file with no Standing block, whose absence is standing_roster_findings' question.""" head = _STANDING_HEADING.search(text) if head is None: return [] if _LOG_HEADING.search(text, 0, head.start()): return ["## Log before ## Standing: the block opens the file"] log_head = _LOG_HEADING.search(text, head.end()) if log_head is None: return ["## Standing without a ## Log after it"] standing, log = text[head.end():log_head.start()], text[log_head.end():] entries = _entries(log) findings = [] for line in standing.splitlines(): if line.startswith("- "): dash = re.search(rf"— {_DATE}(?![\d/-])", line) if not dash: findings.append(f"no entry pointer: {line[:60]}") continue read_to = 0 for m in _POINTER_RE.finditer(line, dash.start()): if m.start() < read_to: # a date inside a pick's words continue got = _resolve(line, m, entries) hits, read_to = got.hits, m.end() + got.used if got.unmatched: findings.append( f"pointer {m['date']} #{m['num']}: " f"{got.unmatched!r} continues no entry's head") elif not hits: findings.append(f"no entry {m['date']} #{m['num']}") elif len(hits) > 1: findings.append( f"ambiguous pointer {m['date']} #{m['num']}: add " "the words its entry's head continues with") elif any(r.startswith("SUPERSEDED by") for _, r in hits): findings.append( f"superseded entry {m['date']} #{m['num']}") elif line.startswith("Not standing:"): read_to = 0 for m in _POINTER_RE.finditer(line): if m.start() < read_to: continue got = _resolve(line, m, entries) read_to = m.end() + got.used if got.unmatched: findings.append( f"not-standing pointer {m['date']} #{m['num']}: " f"{got.unmatched!r} continues no entry's head") elif not got.hits: findings.append( f"not-standing names no entry {m['date']} " f"#{m['num']}") elif len(got.hits) > 1: findings.append( f"ambiguous not-standing pointer {m['date']} " f"#{m['num']}: add the words its entry's head " "continues with") return findings # A reference list -- a file of Declined, Excluded, Open, removal or # mapping lines, each already saying what stands -- carries no Standing # block, which would copy it. The decisions index names them under this # heading, and only the five in REFERENCE_LISTS may be listed: a new # decisions file opens with ## Standing whatever it holds (#762). A # file there may not take a log's shapes: a dated entry, a bullet # opening with a tag and a date (`- (A) 2026-08-17 — …`, as # 3-0-reevaluations writes its log), or a dated title (a bundle, as # cjk-full-stops is). A log written in none of those shapes is caught # only in review. _REFERENCE_HEADING = "## Reference lists" #: The reference lists, fixed when #762 finished giving every other #: decisions file a Standing block (2026-10-22). REFERENCE_LISTS = frozenset({ 'api-churn-declines', 'given-name-titles', 'legacy-rule-numbers', 'removed-v1-surface', 'vocabulary-exclusions'}) _LOG_SHAPED = re.compile( r"^- (?:\*\*)?(?:\([^)\n]{1,12}\) )?(?=\d{4}-\d\d)|\A# [^\n]*\d{4}-\d\d", re.M) def reference_lists(readme: str) -> set[str]: """The keys the index lists, one bullet each (`- [key — …](…)`), under its Reference lists heading, a `## ` line of its own; a link in its prose, or one a bullet holds after other words, is no listing.""" at = readme.find(f"\n{_REFERENCE_HEADING}\n") if at < 0: return set() end = readme.find("\n## ", at + 1) section = readme[at:end if end >= 0 else len(readme)] return {km.group(0) for line in section.splitlines() if (m := _LINK_RE.match(line, 2)) and line.startswith("- [") and (km := _KEY_RE.match(m.group("title")))} def read_reference_lists(folder: Path = DECISIONS_DIR) -> set[str]: return reference_lists((folder / "README.md").read_text(encoding="utf-8")) def standing_roster_findings( files: dict[str, str], reference: AbstractSet[str], allowed: AbstractSet[str] = REFERENCE_LISTS) -> list[str]: """What breaks the Standing rule: a file with no Standing block that is not a reference list, and a heading that misspells `## Standing`; and of a reference list, a Standing block, a log's shape (a dated or tagged-date bullet, a dated title), or a key outside the fixed set.""" findings = [] for key in sorted(reference & files.keys()): text = files[key] if has_standing(text): findings.append(f"{key} is a reference list and carries " "## Standing: index a log under its section") if _LOG_SHAPED.search(text): findings.append(f"{key} is a reference list but is shaped " "like a log (a dated entry or title): a log " "opens with ## Standing") for key in sorted(reference - allowed): findings.append(f"{key} is under Reference lists but is not one " "of REFERENCE_LISTS: a decisions file opens with " "## Standing") for key, text in sorted(files.items()): for line in misspelled_standing(text): findings.append(f"{key}: {line!r} is not the heading " "'## Standing'") for key, text in sorted(files.items()): if key not in reference and not has_standing(text): findings.append(f"{key} has no Standing block: a decisions " "file opens with ## Standing and ## Log") return findings ���������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/pipeline/������������������������������������������������0000775�0000000�0000000�00000000000�15273032717�0022205�5����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/pipeline/__init__.py�������������������������������������0000664�0000000�0000000�00000000000�15273032717�0024304�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/pipeline/test_assemble.py��������������������������������0000664�0000000�0000000�00000026277�15273032717�0025427�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������from nameparser._lexicon import Lexicon from nameparser._pipeline import run from nameparser._pipeline._assemble import assemble from nameparser._pipeline._state import ParseState, PendingAmbiguity, WorkToken from nameparser._policy import Policy from nameparser._types import AmbiguityKind, ParsedName, Role, Span _LEX = Lexicon( titles=frozenset({"dr"}), particles=frozenset({"de", "la", "van"}), particles_ambiguous=frozenset({"van"}), suffix_words=frozenset({"iii"}), maiden_markers=frozenset({"née"}), ) def _parse(text: str) -> ParsedName: return assemble(run(ParseState(original=text, lexicon=_LEX, policy=Policy()))) def test_assemble_produces_validated_parsedname() -> None: pn = _parse("Dr. Juan de la Vega III") assert pn.title == "Dr." assert pn.given == "Juan" assert pn.family == "de la Vega" assert pn.family_base == "Vega" # particle tags carried over assert pn.family_particles == "de la" assert pn.suffix == "III" assert pn.original == "Dr. Juan de la Vega III" for t in pn.tokens: assert t.span is not None assert t.text == pn.original[t.span.start:t.span.end] def test_assemble_materializes_ambiguities_on_final_tokens() -> None: pn = _parse("Van Johnson") assert len(pn.ambiguities) == 1 amb = pn.ambiguities[0] assert amb.kind is AmbiguityKind.PARTICLE_OR_GIVEN assert amb.tokens[0] is pn.tokens[0] def test_assemble_drops_structural_marker_tokens() -> None: pn = _parse("Jane Smith née Jones") assert [t.text for t in pn.tokens] == ["Jane", "Smith", "Jones"] assert pn.maiden == "Jones" def test_empty_parse_is_falsy() -> None: assert not _parse("") assert not _parse(" ") def test_content_free_input_parses_to_empty() -> None: # An input with no alphanumeric character is not a name. v1 kept # it (parse('.') -> first='.'); 2.0 empties it so bool() stays an # honest "did I get a name?" check. isalnum is Unicode-aware, so # this only fires on pure punctuation/symbols. The test is over the # SURVIVING tokens, which since #329 is not the same as over the # input -- see cases.py's maiden_marker_delimited_content_free, # where dropping a marker is what leaves nothing behind. for junk in [".", ".,", "- -", ". .", "'", "∫≜⩕", "()", "-"]: pn = _parse(junk) assert not pn, f"{junk!r} should be falsy" assert pn.tokens == (), f"{junk!r} should have no tokens" assert pn.original == junk # the raw input is still remembered def test_a_single_letter_of_content_survives() -> None: # the guard keys on content, not length: one alnum char is a name for real in ["a.", ".a", "a", "李", "О"]: assert _parse(real), f"{real!r} should be truthy" def test_a_content_free_parse_keeps_its_diagnostics() -> None: # Emptying the name must not take the reports with it. "Was this # input malformed?" is the one question still worth answering when # there is no parse left to infer it from, and every junk row in # the test above is balanced or delimiter-free, so none of them # would notice if the ambiguities were dropped again. pn = _parse("(") assert not pn and pn.tokens == () assert [a.kind for a in pn.ambiguities] == [ AmbiguityKind.UNBALANCED_DELIMITER] # points at no token, because none survived -- the load-bearing # assertion: a fix that materialized a phantom token would # otherwise pass, and ParsedName would reject it anyway assert pn.ambiguities[0].tokens == () assert len(_parse("((").ambiguities) == 2 # one report each # a delimiter that IS balanced still reports nothing assert _parse("()").ambiguities == () def test_assemble_falls_back_to_given_for_unassigned_role() -> None: # This should never happen through the real pipeline -- assign/group # always set a role on every main-stream token before assemble runs. # This test constructs the invariant violation directly to pin the # documented last-resort behavior: assemble() must stay total over # its input and never raise just because upstream left a role unset. state = ParseState( original="Jane", lexicon=Lexicon.default(), policy=Policy(), tokens=(WorkToken("Jane", Span(0, 4), role=None),), ) pn = assemble(state) assert pn.tokens[0].role is Role.GIVEN assert pn.given == "Jane" def test_ambiguity_with_all_indices_dropped_is_omitted() -> None: # an ambiguity whose referent tokens were ALL dropped describes # nothing; emitting it hollow would mislead consumers. Born-empty # ambiguities (unbalanced delimiters) are kept -- they are # token-independent by design. from nameparser._pipeline._state import PendingAmbiguity from nameparser._types import AmbiguityKind as AK import dataclasses state = run(ParseState(original="Jane Smith née Jones", lexicon=_LEX, policy=Policy())) née_idx = next(i for i, t in enumerate(state.tokens) if t.text == "née") poisoned = dataclasses.replace( state, ambiguities=state.ambiguities + ( PendingAmbiguity(AK.ORDER, "refers only to the marker", (née_idx,)), PendingAmbiguity(AK.UNBALANCED_DELIMITER, "born empty", ()), )) pn = assemble(poisoned) kinds = [a.kind for a in pn.ambiguities] assert AK.ORDER not in kinds # fully dangled: omitted assert AK.UNBALANCED_DELIMITER in kinds # born empty: kept def test_a_connective_or_initial_report_is_withdrawn_where_the_role_is_a_suffix( ) -> None: """rules/A1, the withdrawal (#397 second review). classify's connective-or-initial fork offers a connective and an initial and its detail says which it took. A generation is neither, so where the parse goes on to role the letter SUFFIX the report describes a branch nobody took -- and 'JOHN QUINCY SMITH I' carried it beside a `suffix-or-name` saying the same token reads as a generational suffix. 'i' is the first word that is both a marked connective and suffix vocabulary, so no parse before this cycle could reach the shape. The shipped vocabulary is what these names need, so `parse` is used rather than the trimmed `_LEX` above. """ from nameparser import parse withdrawn = { "JOHN QUINCY SMITH I": ["suffix-or-name"], "john smith i": ["suffix-or-name"], "SMITH, JOHN I": [], "HENRY SMITH I": ["suffix-or-name"], } for text, kinds in withdrawn.items(): name = parse(text) assert name.suffix.lower() == "i", text assert sorted(a.kind.value for a in name.ambiguities) \ == sorted(kinds), text # the contrast that keeps the withdrawal honest: where the parse # roles the same letter a NAME word the fork DID resolve to one # of its branches, and the report stands # -- each with a word to the letter's right: ending its part, as # in 'HENRY I', the letter connects nothing and classify reports # nothing (rules/P8, #721) for text in ("JOSEP CAROD I ROVIRA", "josep carod i rovira", "JOHN I SMITH", "JOSEP CAROD I ROVIRA III"): name = parse(text) assert "conjunction-or-initial" in [ a.kind.value for a in name.ambiguities], text # ... and an unmarked-position letter of the same class reports # nothing at all, which is the third state assert [a.kind.value for a in parse("Josep Carod i Rovira").ambiguities] \ == [] def test_the_withdrawal_reads_the_role_and_not_the_word() -> None: """The same withdrawal asked of a TITLE, the other role a fork between a connective and an initial cannot have resolved to. Nothing shipped builds this state -- a marked letter roled TITLE -- which is exactly why it is built here: an implementation keyed on the suffix role alone would pass every row above and fail this one (_pipeline/AGENTS.md: "Pin the decision, not the vocabulary"). """ import dataclasses from nameparser._pipeline._state import PendingAmbiguity from nameparser._types import AmbiguityKind as AK state = run(ParseState(original="Dr. Jane", lexicon=_LEX, policy=Policy())) dr = next(i for i, t in enumerate(state.tokens) if t.text == "Dr.") assert state.tokens[dr].role is Role.TITLE poisoned = dataclasses.replace( state, ambiguities=state.ambiguities + ( PendingAmbiguity(AK.CONJUNCTION_OR_INITIAL, "read as an initial", (dr,)),)) assert AK.CONJUNCTION_OR_INITIAL not in [ a.kind for a in assemble(poisoned).ambiguities] def _with_reports(original: str, tokens: tuple[WorkToken, ...], *reports: PendingAmbiguity) -> ParsedName: return assemble(ParseState( original=original, lexicon=Lexicon.default(), policy=Policy(), tokens=tokens, ambiguities=reports)) def test_a_field_tail_report_is_worded_from_the_final_role() -> None: # #626: the emitter writes the sentence up to the field and the # rest as field_tail; assemble names the field of the FIRST # referent's final role, whatever the emitter saw. An empty tail is # a real tail (the field ends the sentence), not an absent one. tokens = (WorkToken("Kim", Span(0, 3), role=Role.FAMILY), WorkToken("Do", Span(4, 6), role=Role.FAMILY)) pn = _with_reports( "Kim Do", tokens, PendingAmbiguity(AmbiguityKind.SUFFIX_OR_NAME, "'Do' read as ", (1,), field_tail=" rather than a post-nominal"), PendingAmbiguity(AmbiguityKind.PARTICLE_OR_GIVEN, "leading 'Kim' read as ", (0,), field_tail=""), PendingAmbiguity(AmbiguityKind.ORDER, "a whole sentence", (0,))) assert [a.detail for a in pn.ambiguities] == [ "'Do' read as a family name rather than a post-nominal", "leading 'Kim' read as a family name", "a whole sentence"] def test_a_field_tail_report_in_an_emptied_name_is_withdrawn() -> None: # A name emptied for want of content keeps its reports, but one # naming a field has no field left to name: it goes rather than # ship the sentence's first half. No emitter reaches this today. pn = _with_reports( "-", (WorkToken("-", Span(0, 1), role=Role.GIVEN),), PendingAmbiguity(AmbiguityKind.GIVEN_OR_FAMILY, "'-' read as ", (0,), field_tail=" by convention"), PendingAmbiguity(AmbiguityKind.UNBALANCED_DELIMITER, "kept", (0,))) assert not pn assert [a.detail for a in pn.ambiguities] == ["kept"] def test_a_field_tail_report_names_its_first_referent_s_field() -> None: # The field comes from the FIRST referent: every emitter's referents # share one role today, so this pins the documented choice rather # than a case the pipeline produces. tokens = (WorkToken("General", Span(0, 7), role=Role.FAMILY), WorkToken("Smith", Span(8, 13), role=Role.GIVEN)) pn = _with_reports( "General Smith", tokens, PendingAmbiguity(AmbiguityKind.TITLE_OR_NAME, "read as ", (0, 1), field_tail="")) assert [a.detail for a in pn.ambiguities] == ["read as a family name"] ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/pipeline/test_assign.py����������������������������������0000664�0000000�0000000�00000125766�15273032717�0025123�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# tests/v2/pipeline/test_assign.py import pytest from nameparser._lexicon import Lexicon from nameparser._pipeline import run from nameparser._pipeline._assemble import assemble from nameparser._pipeline._assign import assign from nameparser._pipeline._classify import classify from nameparser._pipeline._extract import extract_delimited from nameparser._pipeline._group import group from nameparser._pipeline._pieces import credential_at_the_given_slot from nameparser._pipeline._segment import segment from nameparser._pipeline._state import ParseState from nameparser._pipeline._tokenize import tokenize from nameparser._policy import ( FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST, Policy, Script, ) from nameparser._types import Ambiguity, AmbiguityKind, Role #: The three read orders and the role a lone name word takes under #: each, shared by every parametrized case below that asks the same #: question of all three: the default (None, given-first), and the two #: declared family-first orders. _ORDERS = [ (None, "given"), (Policy(name_order=FAMILY_FIRST), "family"), (Policy(name_order=FAMILY_FIRST_GIVEN_LAST), "family"), ] _LEX = Lexicon( titles=frozenset({"dr", "mr", "mrs", "sir", "sr"}), given_name_titles=frozenset({"sir"}), suffix_acronyms=frozenset({"phd", "md"}), suffix_words=frozenset({"jr", "iii", "v", "sr"}), particles=frozenset({"de", "la", "van"}), particles_ambiguous=frozenset({"van"}), conjunctions=frozenset({"and"}), maiden_markers=frozenset({"née"}), ) def _assigned(text: str, policy: Policy | None = None, lexicon: Lexicon | None = None) -> ParseState: state = ParseState(original=text, lexicon=lexicon or _LEX, policy=policy or Policy()) return assign(group(classify(segment(tokenize( extract_delimited(state)))))) def _reported(text: str, policy: Policy | None = None, lexicon: Lexicon | None = None) -> tuple[Ambiguity, ...]: """The reports as the caller reads them. A report naming a field is worded by assemble from the word's FINAL role (#626), so its detail is complete only there, past the rules that still move a word after assign.""" return assemble(run(ParseState( original=text, lexicon=lexicon or _LEX, policy=policy or Policy()))).ambiguities def _by_role(state: ParseState, role: Role) -> str: return " ".join(t.text for t in state.tokens if t.role is role) _ROLE_NAMES = (("title", Role.TITLE), ("given", Role.GIVEN), ("middle", Role.MIDDLE), ("family", Role.FAMILY), ("suffix", Role.SUFFIX), ("nickname", Role.NICKNAME), ("maiden", Role.MAIDEN)) def _fields(text: str, policy: Policy | None = None) -> dict[str, str]: """The non-empty role fields, by name -- #289's real ambiguous vocabulary (`ba do ed jd ma`) is Lexicon.default()'s, not the synthetic `_LEX` most of this module's tests share, so this reads the default lexicon rather than take one as a parameter every caller would otherwise have to pass.""" out = _assigned(text, policy, Lexicon.default()) return {name: v for name, role in _ROLE_NAMES if (v := _by_role(out, role))} def test_the_lean_reaches_the_post_comma_given_slot() -> None: # #289, Derek's own comment on the issue: positive evidence # outranks position here, so the credential lean fires with ONE # word before the comma where the count would not. assert _fields("Smith, MA") == {"family": "Smith", "suffix": "MA"} assert _fields("Smith, BA") == {"family": "Smith", "suffix": "BA"} assert _fields("Smith, MA PhD") == {"family": "Smith", "suffix": "MA PhD"} # the other two spellings keep today's reading: a surname lean, and # no lean at all assert _fields("Smith, Ma") == {"given": "Ma", "family": "Smith"} assert _fields("Smith, ma") == {"given": "ma", "family": "Smith"} # a caseless script wrote no contrast, so nothing leans assert _fields("毛泽东, MA") == {"given": "MA", "family": "毛泽东"} def test_the_comma_path_reports_its_ambiguous_reading_once() -> None: # The first report of the comma's OWN decision in the library # (#289) -- C2's structural flag already reports on the comma # path, but it reports what the parse could not recognize, not a # fork it called. P6's attachment fork has separately reported on # a family-comma path since 2.3 ("Berg, Jan vd"). One per # DECISION, in either direction, and never twice for one name -- # the structure decision reports where it is taken and this one # reports where the family comma stands. # # The report tracks the FORK BEING CONSULTED, not the lean -- # exactly as the trailing slot always has ('Jack MA' reported # before #289 too, even where the peel declined the pick for want # of words to spare). So a caseless script ('毛泽东, MA') and an # all-lower spelling ('Smith, ma') still called this fork and # report it, read positionally; and a two-piece post-comma part # whose FIRST piece is the class member ('Smith, MA PhD') reports # once through that same first-piece read (F4/F5 review finding, # 2026-09-17 -- reverses an earlier round's `ambiguous_lean(...) # is not None` gate, which wrongly excluded both). # 'Smith, A.B.' is in the loop since `Policy.unlisted_dotted_ # suffixes` shipped: its token carries the by-shape tag, so the # fork IS consulted at this comma and reports exactly once -- # which is also true with the switch OFF, the shape tag going on # either way (its own case rows pin both). # Capitals in a name of mixed case settle the word, so those # spellings report nothing (rules/S6, #785); the name written in # one case reaches the same fork with no writing to settle it. for text in ("SMITH, MA", "Smith, Ma", "Smith, ma", "JOHN SMITH, MA", "John Smith, Ed", "毛泽东, MA", "SMITH, MA PHD", "Smith, A.B."): kinds = [a.kind.value for a in _assigned( text, lexicon=Lexicon.default()).ambiguities] assert kinds.count("suffix-or-name") == 1, (text, kinds) for text in ("Smith, MA", "John Smith, MA", "Smith, MA PhD"): kinds = [a.kind.value for a in _assigned( text, lexicon=Lexicon.default()).ambiguities] assert "suffix-or-name" not in kinds, (text, kinds) # a name outside the class reports nothing at its comma, as every # release before this one for text in ("Smith, John", "John Smith, PhD", "Smith, Dr."): kinds = [a.kind.value for a in _assigned( text, lexicon=Lexicon.default()).ambiguities] assert "suffix-or-name" not in kinds, text def test_the_family_comma_report_detail_is_verbatim() -> None: # The credential half of the FIRST comma-path report, which # `_comma.decide` emits where it binds the part at group's head # (#613; assign's family-comma emitter keeps the given-name half, # and the structure flip's report is pinned in test_comma.py). # Pinned verbatim for the same reason. Capitals settle 'Smith, MA' # (rules/S6, #785), so the half is reached by a generation word its # lower case leans to the suffix, opening a part the whole name in # front reads as a credential run of words it settled. out = _assigned("Alexandre Dumas, fils MA", lexicon=Lexicon.default()) (amb,) = [a for a in out.ambiguities if a.kind is AmbiguityKind.SUFFIX_OR_NAME] assert amb.detail == ( "'fils' after the comma is also an ordinary name word; read as " "a credential") def test_a_by_shape_pick_is_not_described_as_a_listed_member() -> None: # #563: the trailing slot's listed-member wording ("written without # periods is both a post-nominal") is false of a word the class # admits by SHAPE -- 'X.Y.Z.' is written WITH its periods and is in # no wordlist. Both readings of the fork, so neither branch of the # template can keep the old wording; 'MA' in the test below is the # listed control that keeps it. for text, taken, declined in ( ("John Smith X.Y.Z.", "a suffix", "a name part"), ("Jack X.Y.Z.", "a family name", "a post-nominal")): (amb,) = [a for a in _reported(text, lexicon=Lexicon.default()) if a.kind is AmbiguityKind.SUFFIX_OR_NAME] assert amb.detail == ( f"'X.Y.Z.' is shaped like a post-nominal but listed in no " f"vocabulary, so it may be an ordinary name; read as " f"{taken} rather than {declined}"), text def test_jack_ma_s_two_detail_strings_are_verbatim() -> None: # The trailing slot's OWN report existed before #289 (a bare # ambiguous acronym was always a coin-flip); what #289 changes is # which way 'Jack MA' flips, not that it reports. Pinning both # strings verbatim: the credential lean also turns 'Jack' into the # only name word left, which is a SECOND fork (GIVEN_OR_FAMILY) # this one row now calls. Since #785 the capitals settle 'MA', so # only that second fork reports (rules/S6); the trailing slot's # string is pinned on the name written in one case, which the # count decides. details = {a.kind.value: a.detail for a in _reported("Jack MA", lexicon=Lexicon.default())} assert details == { "given-or-family": ( "'Jack' is the only name word and nothing else decides " "it; read as a given name by convention, which follows " "the read order"), } details = {a.kind.value: a.detail for a in _reported("JOHN SMITH MA", lexicon=Lexicon.default())} assert details == { "suffix-or-name": ( "'MA' written without periods is both a post-nominal and " "an ordinary name; read as a suffix rather than a name " "part"), } def test_given_first_positional() -> None: out = _assigned("Dr. Juan de la Vega III") assert _by_role(out, Role.TITLE) == "Dr." assert _by_role(out, Role.GIVEN) == "Juan" assert _by_role(out, Role.FAMILY) == "de la Vega" assert _by_role(out, Role.SUFFIX) == "III" def test_middles() -> None: out = _assigned("John Quincy Adams Smith") assert _by_role(out, Role.GIVEN) == "John" assert _by_role(out, Role.MIDDLE) == "Quincy Adams" assert _by_role(out, Role.FAMILY) == "Smith" def test_single_token_takes_name_order_head() -> None: assert _by_role(_assigned("John"), Role.GIVEN) == "John" out = _assigned("John", Policy(name_order=FAMILY_FIRST)) assert _by_role(out, Role.FAMILY) == "John" def test_title_only() -> None: out = _assigned("Dr.") assert _by_role(out, Role.TITLE) == "Dr." assert not _by_role(out, Role.GIVEN) def test_a_title_needs_a_following_piece() -> None: # v1 parity, in the count group shares since #424: the last of two # title words is the name, not a second title out = _assigned("Dr. Mr.") assert _by_role(out, Role.TITLE) == "Dr." assert _by_role(out, Role.GIVEN) == "Mr." def test_leading_ambiguous_particle_reads_as_given_with_ambiguity() -> None: out = _assigned("Van Johnson") assert _by_role(out, Role.GIVEN) == "Van" assert _by_role(out, Role.FAMILY) == "Johnson" assert any(a.kind is AmbiguityKind.PARTICLE_OR_GIVEN for a in out.ambiguities) # unambiguous input: no ambiguity recorded assert not _assigned("John Smith").ambiguities @pytest.mark.parametrize("policy,role", _ORDERS) def test_leading_particle_detail_names_the_field_the_word_lands_in( policy: Policy | None, role: str) -> None: # The fork is the same under every order -- particle or name -- # but which role the head piece actually took is the assignment's # answer, so the user-facing detail names the field the word lands # in rather than hardcoding "given", exactly as SUFFIX_OR_NAME does. # kind is public API and stays PARTICLE_OR_GIVEN throughout: the # fork really is "particle or given" even where the piece landed # in FAMILY. (amb,) = _reported("Van Johnson", policy) assert amb.kind is AmbiguityKind.PARTICLE_OR_GIVEN assert amb.detail == ( f"leading 'Van' may be a family-name particle; " f"read as a {role} name") @pytest.mark.parametrize("policy,role", _ORDERS) @pytest.mark.parametrize("text,kind,detail", [ ("Andrew", AmbiguityKind.GIVEN_OR_FAMILY, "'Andrew' is the only name word and nothing else decides it; " "read as a {role} name by convention, which follows the read order"), ("Rinpoche", AmbiguityKind.SUFFIX_OR_NAME, "'Rinpoche' is post-nominal vocabulary with no name word beside " "it; read as a {role} name rather than a post-nominal, nothing " "else being left to be the name"), ("John of Bishop", AmbiguityKind.TITLE_OR_NAME, "'John of Bishop' is the only name unit and joins title " "vocabulary to a name word; read as a {role} name by convention"), ]) def test_convention_details_name_the_field_the_word_lands_in( text: str, kind: AmbiguityKind, detail: str, policy: Policy | None, role: str) -> None: # The three conventions this site reports all place a lone name # word, and all three details name the field the word lands in # rather than hardcoding "given": the field follows the read # order, which is why none of the three kinds names it. The # PARTICLE_OR_GIVEN test above is the precedent, and these are the # only other details at this site that name a field -- H4's PEEL # shape ("Lord Chancellor") deliberately names none, because H1 # retags that word after assign under the default order. lex = _LEX.add(suffix_words={"rinpoche"}, conjunctions={"of"}, titles={"bishop"}) (amb,) = _reported(text, policy, lexicon=lex) assert amb.kind is kind assert amb.detail == detail.format(role=role) def test_leading_particle_detail_follows_the_effective_order() -> None: # Reading policy.name_order[0] instead of the token's own role # would pass every case above, because there the two agree. They # come apart on the script_orders path (#271): a wholly-Han name # resolves family-first through _effective_order while name_order # is untouched and still reads given-first. The head piece is the # FAMILY name here, and the detail has to say so. han = _LEX.add(particles={"毛"}, particles_ambiguous={"毛"}) out = _assigned("毛 泽东", lexicon=han) assert out.policy.name_order[0] is Role.GIVEN assert out.policy.script_orders[0][0] is Script.HAN assert _by_role(out, Role.FAMILY) == "毛" assert _by_role(out, Role.GIVEN) == "泽东" (amb,) = _reported("毛 泽东", lexicon=han) assert amb.kind is AmbiguityKind.PARTICLE_OR_GIVEN assert amb.detail == ( "leading '毛' may be a family-name particle; " "read as a family name") def test_family_comma() -> None: out = _assigned("de la Vega, Juan") assert _by_role(out, Role.FAMILY) == "de la Vega" assert _by_role(out, Role.GIVEN) == "Juan" def test_family_comma_with_title_and_middle() -> None: out = _assigned("Smith, Dr. John A.") assert _by_role(out, Role.TITLE) == "Dr." assert _by_role(out, Role.GIVEN) == "John" assert _by_role(out, Role.MIDDLE) == "A." assert _by_role(out, Role.FAMILY) == "Smith" def test_family_comma_lone_post_comma_title() -> None: # v1 parity: a lone post-comma title is a TITLE ('Smith, Dr.'), # not a given name or suffix; post_rules later applies the # title+lone-family handling out = _assigned("Smith, Dr.") assert _by_role(out, Role.TITLE) == "Dr." assert _by_role(out, Role.FAMILY) == "Smith" assert not _by_role(out, Role.GIVEN) def test_suffix_comma_and_extra_segments() -> None: out = _assigned("Smith, John, Jr.") assert _by_role(out, Role.GIVEN) == "John" assert _by_role(out, Role.FAMILY) == "Smith" assert _by_role(out, Role.SUFFIX) == "Jr." def test_suffix_comma_structure() -> None: # Structure.SUFFIX_COMMA (`_comma.decide`, at group's head since # #613): a whole name before the first comma AND the part after it # read as the postnominal part. # ('Smith, John, Jr.' above only has ONE word before its first # comma, so it is FAMILY_COMMA with a trailing suffix segment, not # this branch.) Previously this assign() branch (tail=1) was only # reached via the full-corpus test, not its own isolated case. out = _assigned("John Smith, PhD") assert _by_role(out, Role.GIVEN) == "John" assert _by_role(out, Role.FAMILY) == "Smith" assert _by_role(out, Role.SUFFIX) == "PhD" def test_trailing_suffix_run_no_comma() -> None: out = _assigned("John Jack Kennedy PhD MD") assert _by_role(out, Role.MIDDLE) == "Jack" assert _by_role(out, Role.FAMILY) == "Kennedy" assert _by_role(out, Role.SUFFIX) == "PhD MD" def test_trailing_title_run_is_set_before_the_positional_read() -> None: """The walk shortens the name-piece list, and does it early. Two TITLE tokens from opposite ends of the input, and the piece between them read as the family name -- which is only true if the walk ran before _name_positions did. `order` is the default because the positional read still happened: the walk removes pieces from it, it does not replace it. """ out = _assigned("Dr. John Smith Mr.") assert _by_role(out, Role.TITLE) == "Dr. Mr." assert _by_role(out, Role.GIVEN) == "John" assert _by_role(out, Role.FAMILY) == "Smith" assert out.order == Policy().name_order def test_the_suffix_peel_runs_over_the_pieces_the_walk_left() -> None: """The trailing title can stand BEHIND a suffix word. 'Mr.' stopped the first peel, so a single pass would have left 'Jr.' as the last name piece and made a generational suffix the family name. The first peel is provisional: the walk's piece is spliced out and the peel runs over what stands, reading 'Jr.' as the suffix it is. """ out = _assigned("John Smith Jr. Mr.") assert _by_role(out, Role.TITLE) == "Mr." assert _by_role(out, Role.GIVEN) == "John" assert _by_role(out, Role.FAMILY) == "Smith" assert _by_role(out, Role.SUFFIX) == "Jr." def test_the_trailing_title_is_transparent_to_the_suffix_peel() -> None: """The principle the provisional peel serves. 'X Mr. Y' reads exactly as 'X Y' reads, plus the title -- so the peel decides over the pieces with the title spliced OUT, in original order, rather than over the two halves separately. Both readings below are what the same input without 'Mr.' gives: the reserve keeps a bare ambiguous acronym the family of a two-word name, and takes it as a credential when a full name remains. """ lex = _LEX.add(suffix_acronyms={"ma"}, suffix_acronyms_ambiguous={"ma"}) # MOVED by #289, not deleted: spliced, 'John MA' is the bare # 'Jack MA' shape -- an ALL-CAPS ambiguous acronym in a mixed-case # name leans credential and is taken with no words to spare, so # the reserve that used to keep it the family now takes it as the # suffix instead (decisions/S6). out = _assigned("John Mr. MA", lexicon=lex) assert _by_role(out, Role.TITLE) == "Mr." assert _by_role(out, Role.GIVEN) == "John" assert not _by_role(out, Role.FAMILY) assert _by_role(out, Role.SUFFIX) == "MA" out = _assigned("John Smith Mr. MA", lexicon=lex) assert _by_role(out, Role.FAMILY) == "Smith" assert _by_role(out, Role.SUFFIX) == "MA" def test_the_walk_reports_only_the_peel_that_decided() -> None: """One peel decides, so one peel reports. The numeral fork reads the piece before the numeral. Over the input as written that piece is 'Mr.' and 'VI' is taken; over the spliced pieces it is 'V', an initial shape, and the fork declines -- which is the answer 'John Smith V VI' gets, with no report. Reporting from the provisional peel as well said suffix 'V VI' and reported the fork twice. """ out = _assigned("John Smith V Mr. VI") assert _by_role(out, Role.TITLE) == "Mr." assert _by_role(out, Role.MIDDLE) == "Smith V" assert _by_role(out, Role.FAMILY) == "VI" assert not _by_role(out, Role.SUFFIX) assert not out.ambiguities def test_the_family_comma_walk_reads_past_its_own_suffix_tail() -> None: """Segment 1's candidates are what its walk does not read as a suffix -- the strict test AND the lenient one (#144). 'V' after the comma is this segment's suffix, so the title behind it is still the trailing piece; and with the two words swapped 'V' is where the name ends once the walk has taken the title, so the lenient test still reaches it. Both are 'Smith, John V' plus a title. """ for text in ("Smith, John Mr. V", "Smith, John V Mr."): out = _assigned(text) assert _by_role(out, Role.TITLE) == "Mr.", text assert _by_role(out, Role.GIVEN) == "John", text assert _by_role(out, Role.FAMILY) == "Smith", text assert _by_role(out, Role.SUFFIX) == "V", text assert not _by_role(out, Role.MIDDLE), text def test_trailing_title_run_after_a_family_comma() -> None: """The same rule on segment 1's own walk. A name word after the comma keeps `_comma.decide` from binding the segment as the postnominal part, so this shape had no route to TITLE at all and read the word as a middle name. """ out = _assigned("Smith, John Mr.") assert _by_role(out, Role.TITLE) == "Mr." assert _by_role(out, Role.GIVEN) == "John" assert _by_role(out, Role.FAMILY) == "Smith" assert not _by_role(out, Role.MIDDLE) def test_the_trailing_title_is_taken_before_the_script_order_resolves( ) -> None: """The other half of "set BEFORE _name_positions". The sibling above pins that the walk shortens the piece list in time for the POSITIONAL read. This pins it for the SCRIPT read, which is the reason the placement was chosen: a Latin title at the back of a wholly-Han name is the one piece that would make the piece set look mixed-script, and a mixed set declines the script order. Taken first, the pieces the script test sees are all Han and the Han order stands -- family '毛', given '泽东', which is not what the default order would have given. """ out = _assigned("毛 泽东 Dr.") assert _by_role(out, Role.TITLE) == "Dr." assert _by_role(out, Role.FAMILY) == "毛" assert _by_role(out, Role.GIVEN) == "泽东" assert out.order != Policy().name_order def test_initial_veto_keeps_v_in_middle() -> None: out = _assigned("John V. Smith") assert _by_role(out, Role.MIDDLE) == "V." assert _by_role(out, Role.FAMILY) == "Smith" def test_nickname_only_leaves_name_fields_empty() -> None: out = _assigned("(Jack)") assert _by_role(out, Role.NICKNAME) == "Jack" assert not _by_role(out, Role.GIVEN) and not _by_role(out, Role.FAMILY) def test_single_name_with_nickname_goes_to_family() -> None: out = _assigned("John (Jack)") assert _by_role(out, Role.FAMILY) == "John" assert not _by_role(out, Role.GIVEN) def test_family_first_order() -> None: out = _assigned("Yamada Taro", Policy(name_order=FAMILY_FIRST)) assert _by_role(out, Role.FAMILY) == "Yamada" assert _by_role(out, Role.GIVEN) == "Taro" out2 = _assigned("Yamada Hanako Taro", Policy(name_order=FAMILY_FIRST)) assert _by_role(out2, Role.FAMILY) == "Yamada" assert _by_role(out2, Role.GIVEN) == "Hanako" assert _by_role(out2, Role.MIDDLE) == "Taro" def test_family_first_given_last_order() -> None: # Pinned against the actual built pipeline (2026-07-12), not guessed: # 'Van' is particles_ambiguous and non-leading, so group's prefix # chain (which is name_order-agnostic -- it lands upstream of assign # and doesn't consult Policy) absorbs 'Van Anh Thu' into ONE piece # before assign ever sees it. With only two name pieces left, # FAMILY_FIRST_GIVEN_LAST's positional rule (family, then given for # count==2) correctly assigns Nguyen -> FAMILY and the whole merged # piece -> GIVEN; there is no MIDDLE piece to assign. This is a # sensible consequence of the current, order-agnostic group stage -- # not a contract violation of assign -- and is the exact kind of gap # the Vietnamese-aware locale pack (#270/#272 follow-ups) is meant to # close by teaching group to suppress the particle chain under this # name_order. Do not "fix" this by changing assign. out = _assigned("Nguyen Van Anh Thu", Policy(name_order=FAMILY_FIRST_GIVEN_LAST)) assert _by_role(out, Role.FAMILY) == "Nguyen" assert _by_role(out, Role.GIVEN) == "Van Anh Thu" assert _by_role(out, Role.MIDDLE) == "" def test_script_order_applies_when_every_piece_is_one_script() -> None: out = _assigned("毛 泽东") assert _by_role(out, Role.FAMILY) == "毛" assert _by_role(out, Role.GIVEN) == "泽东" def test_script_order_declines_on_a_mixed_piece_set() -> None: # {None, HAN}: one Latin piece is enough to put the name back on # the positional default, even though the other piece is Han. out = _assigned("毛 Smith") assert _by_role(out, Role.GIVEN) == "毛" assert _by_role(out, Role.FAMILY) == "Smith" def test_script_order_declines_when_no_piece_has_a_script() -> None: # all-None: the ordinary Latin path, unreachable by the table. out = _assigned("John Smith") assert _by_role(out, Role.GIVEN) == "John" assert _by_role(out, Role.FAMILY) == "Smith" def test_kana_licensed_piece_resolves_via_hiragana_entry() -> None: # a kanji+hiragana mixed piece set takes the license (#272) and # resolves through the HIRAGANA table entry, family-first out = _assigned("高橋 みなみ") assert _by_role(out, Role.FAMILY) == "高橋" assert _by_role(out, Role.GIVEN) == "みなみ" def test_pure_katakana_piece_falls_back_to_name_order() -> None: # katakana alone is not in the table (transcription ambiguity), so # a pure-katakana piece set falls back to the positional default out = _assigned("マイケル ジャクソン") assert _by_role(out, Role.GIVEN) == "マイケル" assert _by_role(out, Role.FAMILY) == "ジャクソン" def test_interpunct_divided_name_reads_positionally() -> None: # 威廉·莎士比亚 is William Shakespeare: a 间隔号-divided name is a # transcription and keeps the declared order (rule W4's # Accepted), which under the default is its written order -- the # B7 is the marker, playing the role pure katakana plays in the # kana license out = _assigned("威廉·莎士比亚") assert _by_role(out, Role.GIVEN) == "威廉" assert _by_role(out, Role.FAMILY) == "莎士比亚" def test_interpunct_suppression_yields_to_explicit_name_order() -> None: # the suppression falls back to name_order, it does not force # given-first: an explicit FAMILY_FIRST governs the transcription out = _assigned("威廉·莎士比亚", Policy(name_order=FAMILY_FIRST)) assert _by_role(out, Role.FAMILY) == "威廉" assert _by_role(out, Role.GIVEN) == "莎士比亚" def test_script_with_no_table_entry_falls_back() -> None: # A single, well-defined script the table simply does not list: # resolution must fall through to name_order rather than pick an # arbitrary entry. hangul_only = Policy(script_orders={Script.HANGUL: FAMILY_FIRST}) # type: ignore[arg-type] out = _assigned("毛 泽东", hangul_only) assert _by_role(out, Role.GIVEN) == "毛" assert _by_role(out, Role.FAMILY) == "泽东" def test_all_title_post_comma_segment_leaves_segment_zero_positional() -> None: # 'John Smith, Dr.' -- the comma is followed by nothing but a title, # so it never said where the family name ends. Reading segment 0 # wholly as family throws away a split the writer gave us. out = _assigned("John Smith, Dr.") assert _by_role(out, Role.TITLE) == "Dr." assert _by_role(out, Role.GIVEN) == "John" assert _by_role(out, Role.FAMILY) == "Smith" def test_all_title_post_comma_segment_needs_two_pre_comma_pieces() -> None: # 'Smith, Dr.' has nothing to split: one pre-comma piece stays FAMILY # rather than becoming a lone GIVEN under the positional read. out = _assigned("Smith, Dr.") assert _by_role(out, Role.TITLE) == "Dr." assert _by_role(out, Role.FAMILY) == "Smith" assert _by_role(out, Role.GIVEN) == "" def test_post_comma_title_run_is_all_titles() -> None: out = _assigned("John Smith, Mr. Dr.") assert _by_role(out, Role.TITLE) == "Mr. Dr." assert _by_role(out, Role.GIVEN) == "John" assert _by_role(out, Role.FAMILY) == "Smith" def test_a_title_and_a_suffix_after_the_comma_fix_no_family_either() -> None: # the condition is "no name word", not "all titles": a title and a # postnominal with nothing between them said nothing about where # the family ends (the design-docs review found C1 silent on it) out = _assigned("John Smith, Mr. Jr.") assert _by_role(out, Role.TITLE) == "Mr." assert _by_role(out, Role.GIVEN) == "John" assert _by_role(out, Role.FAMILY) == "Smith" assert _by_role(out, Role.SUFFIX) == "Jr." def test_the_positional_segment_zero_records_its_order() -> None: # post_rules' family-first fold and its leading-piece scan key on # "assign records no order after a family comma"; the positional # read -- the suffix comma `_comma.decide` makes behind a whole # name since #613 -- is the path that gives one (the test review found the fold # missing 'de Mesnil Jean, Dr.' under a family-first order) out = _assigned("John Smith, Dr.") assert out.order is not None out = _assigned("Smith, Dr. John") assert out.order is None def test_partly_title_post_comma_segment_keeps_family_comma() -> None: # 'Smith, Dr. John' still has a name after the title, so the comma # DID fix the family: segment 0 stays wholly family. out = _assigned("Smith, Dr. John") assert _by_role(out, Role.TITLE) == "Dr." assert _by_role(out, Role.GIVEN) == "John" assert _by_role(out, Role.FAMILY) == "Smith" def test_non_title_post_comma_segment_is_untouched() -> None: out = _assigned("John Smith, Jones") assert _by_role(out, Role.FAMILY) == "John Smith" assert _by_role(out, Role.GIVEN) == "Jones" def test_positional_segment_zero_reports_the_particle_fork() -> None: # The comma no longer fixed the family, so the leading ambiguous # particle IS a live fork again -- emitted at the site that decides # it, per the ambiguity doctrine. Three words, since the read needs # two name words counted as units (#575): the particle and the word # it attaches to are one. out = _assigned("Van Johnson Smith, Dr.") assert _by_role(out, Role.GIVEN) == "Van" assert _by_role(out, Role.FAMILY) == "Smith" assert [a.kind for a in out.ambiguities] == \ [AmbiguityKind.PARTICLE_OR_GIVEN] def test_a_lone_particle_surname_before_a_title_only_comma_is_the_family() -> None: # #575: 'Van Johnson' is ONE name word before the comma -- a # particle and the word it attaches to -- so there are not two to # read positionally, and the listing form's surname reading # settles P1's fork. This test pinned given 'Van', family # 'Johnson' and a particle-or-given report until then. out = _assigned("Van Johnson, Dr.") assert _by_role(out, Role.FAMILY) == "Van Johnson" assert _by_role(out, Role.GIVEN) == "" assert out.ambiguities == () def test_a_credential_run_after_a_family_comma_reads_as_suffixes() -> None: # 'Smith, Jr.' -- the peel's whole-segment exception claimed this # even with 'jr' out of TITLES, because is_leading_title also # infers a title from the period-abbreviation shape. The slot after # a family comma IS postnominal position, so a run that is nothing # but suffix pieces is read as one before the peel gets a chance # (#296) -- and the whole run, not the lone piece: 'Smith, Ph. D. # Jr.' put the split credential in the given name (#325). out = _assigned("Smith, Jr.") assert _by_role(out, Role.FAMILY) == "Smith" assert _by_role(out, Role.SUFFIX) == "Jr." assert _by_role(out, Role.TITLE) == "" out = _assigned("Smith, Ph. D. Jr.") assert _by_role(out, Role.FAMILY) == "Smith" assert _by_role(out, Role.SUFFIX) == "Ph. D. Jr." assert _by_role(out, Role.GIVEN) == "" out = _assigned("Smith, Jr. PhD") assert _by_role(out, Role.SUFFIX) == "Jr. PhD" assert _by_role(out, Role.TITLE) == "" def test_lone_post_comma_dual_word_reads_as_the_postnominal() -> None: # 'sr' kept BOTH memberships; position is what decides, and this is # the position that decides postnominal. out = _assigned("Smith, Sr.") assert _by_role(out, Role.SUFFIX) == "Sr." assert _by_role(out, Role.TITLE) == "" def test_leading_dual_word_still_reads_as_the_title() -> None: # The other half of the same fork, untouched: the peel's normal path. out = _assigned("Sr. Garcia") assert _by_role(out, Role.TITLE) == "Sr." # assign leaves the one name word as the given; H1 (post_rules) # makes it the family assert _by_role(out, Role.GIVEN) == "Garcia" def test_lone_post_comma_title_is_not_a_suffix() -> None: # 'Smith, Dr.' decides WITHOUT consulting the ordering: after the # audit 'dr' is not suffix-tagged, so the suffix test simply declines # and the title peel takes it as before. out = _assigned("Smith, Dr.") assert _by_role(out, Role.TITLE) == "Dr." assert _by_role(out, Role.SUFFIX) == "" def test_a_mixed_post_comma_run_keeps_the_walk_order() -> None: # a title then a suffix word is read where each stands (v1's walk: # leading titles peel, the rest is given / middle / suffix), and a # name word anywhere in the run makes it a name, not a credential run out = _assigned("Smith, Dr. Jr.") assert _by_role(out, Role.TITLE) == "Dr." assert _by_role(out, Role.SUFFIX) == "Jr." out = _assigned("Smith, John Jr.") assert _by_role(out, Role.GIVEN) == "John" assert _by_role(out, Role.SUFFIX) == "Jr." def test_the_comma_report_says_which_way_it_read_the_word() -> None: """The report's OTHER branch, verbatim (2026-09-18 review round). `test_the_family_comma_report_detail_is_verbatim` above pins the credential wording; only the string distinguishes the two branches, the kind being the same either way and the roles telling a caller which reading won only if it already knows which field to look in. So the NAME branch needs its own row, and the by-shape half with its switch OFF -- which takes that branch for a fork the parser considered and declined -- is the second one. """ detail = { text: [a.detail for a in _reported( text, policy, lexicon=Lexicon.default()) if a.kind.value == "suffix-or-name"] for text, policy in (("Smith, Ma", None), (", Ma Dr.", None), ("Smith, A.B.", Policy(unlisted_dotted_suffixes=False))) } assert detail["Smith, Ma"] == [ "'Ma' after the comma is also an ordinary name word; read as " "a given name"] assert detail["Smith, A.B."] == [ "'A.B.' after the comma is also an ordinary name word; read as " "a given name"] # #626's review: with nothing before the comma, H1 moves the word # to the family behind the title after this report is made, so the # field is worded from where it lands assert detail[", Ma Dr."] == [ "'Ma' after the comma is also an ordinary name word; read as " "a family name"] def test_the_trailing_given_slot_takes_a_bare_class_member() -> None: out = _assigned("Doe, John MA", lexicon=Lexicon.default()) assert _by_role(out, Role.SUFFIX) == "MA" assert _by_role(out, Role.MIDDLE) == "" def test_the_trailing_given_slot_walks_past_a_title() -> None: """previous_kept() is what makes 'past a trailing title' the same walk H5 already uses, so the two spellings agree rather than needing two notions of 'trailing'.""" for text in ("Doe, John MA Prof.", "Doe, John Prof. MA"): out = _assigned(text, lexicon=Lexicon.default()) assert _by_role(out, Role.SUFFIX) == "MA", text assert _by_role(out, Role.TITLE) == "Prof.", text def test_the_trailing_given_slot_steps_over_a_title_inside_the_run() -> None: """A title BETWEEN the member and a suffix: the floor's descent starts on 'Jr', steps over the titled 'Prof.' and lands on the member. Both spellings above put the title at an end of the run, where the descent starts past it and never takes that step.""" out = _assigned("Doe, John MA Prof. Jr", lexicon=Lexicon.default()) assert _by_role(out, Role.SUFFIX) == "MA Jr" assert _by_role(out, Role.TITLE) == "Prof." assert _by_role(out, Role.MIDDLE) == "" # the declined spelling takes the same step and stops on the member out = _assigned("Doe, John Ma Prof. Jr", lexicon=Lexicon.default()) assert _by_role(out, Role.SUFFIX) == "Jr" assert _by_role(out, Role.MIDDLE) == "Ma" def test_the_trailing_given_slot_reads_the_lean_three_ways() -> None: """credential / name / no-lean, the three answers listed_lean gives -- read for this slot through `credential_at_the_given_slot`, which assign calls -- each landing where the rule says.""" assert _by_role(_assigned("Doe, John MA", lexicon=Lexicon.default()), Role.SUFFIX) == "MA" # lean credential assert _by_role(_assigned("Doe, John Ma", lexicon=Lexicon.default()), Role.MIDDLE) == "Ma" # lean name assert _by_role(_assigned("doe, john ma", lexicon=Lexicon.default()), Role.SUFFIX) == "ma" # one case, no lean def test_the_trailing_given_slot_falls_through_for_a_by_shape_member( ) -> None: """listed_lean returns None wherever the shape tag rides, so a by-shape member never leans and takes the positional reading -- which at this slot is the credential. Reached through `credential_at_the_given_slot`, whose no-lean arm this is.""" out = _assigned("Doe, John X.Y.Z.", lexicon=Lexicon.default()) assert _by_role(out, Role.SUFFIX) == "X.Y.Z." def test_the_trailing_given_slot_ignores_the_two_segment_floor() -> None: """#144's len(segments) == 2 restriction belongs to the lenient trailing predicate and is NOT inherited here: 'MA' is not initial-shaped, and a credential list behind it makes the credential reading likelier rather than less.""" out = _assigned("Doe, John MA, PhD", lexicon=Lexicon.default()) # _by_role space-joins by role; the written comma is preserved by # post_rules' entry pass (#436/#437, rules/R1), a later stage # this assign-only helper does not run -- the full-pipeline case # row `the_trailing_slot_survives_a_third_comma_part` pins the # comma-rendered "MA, PhD". What this unit test checks is the role # assignment alone: both members land in SUFFIX regardless of # #144's two-segment floor. assert _by_role(out, Role.SUFFIX) == "MA PhD" # and the restriction still governs its own predicate out = _assigned("Doe, John V, PhD", lexicon=Lexicon.default()) assert _by_role(out, Role.MIDDLE) == "V" def test_the_shared_given_slot_predicate_reads_the_lean_three_ways( ) -> None: """credential_at_the_given_slot is the ONE place #531's reading lives since #533, and its two callers sit in different stages -- assign's walk over the given part, and the maiden walk's second check. A test of its own is what keeps the three answers pinned where a caller's test would only pin the reading it needs.""" lex = Lexicon.default() state = _assigned("Doe, John MA", lexicon=lex) member = next(t for t in state.tokens if t.text == "MA") assert credential_at_the_given_slot(member, False) is True state = _assigned("Doe, John Ma", lexicon=lex) member = next(t for t in state.tokens if t.text == "Ma") assert credential_at_the_given_slot(member, False) is False state = _assigned("doe, john ma", lexicon=lex) member = next(t for t in state.tokens if t.text == "ma") assert credential_at_the_given_slot(member, True) is True # the particle carve-out: no positive lean, so P6 keeps the word state = _assigned("Doe, John do", lexicon=lex) member = next(t for t in state.tokens if t.text == "do") assert credential_at_the_given_slot(member, False) is False def test_a_name_word_behind_the_member_ends_the_run() -> None: # in one case: in a name of mixed case the capitals settle 'MA', # which then starts the credential's run instead (rules/S8, #785) out = _assigned("DOE, JOHN MA SMITH", lexicon=Lexicon.default()) assert _by_role(out, Role.MIDDLE) == "MA SMITH" assert not [a for a in out.ambiguities if a.kind is AmbiguityKind.SUFFIX_OR_NAME] out = _assigned("Doe, John MA Smith", lexicon=Lexicon.default()) assert _by_role(out, Role.SUFFIX) == "MA Smith" def test_two_members_in_the_trailing_run_report_once_each() -> None: out = _assigned("DOE, JOHN MA JD", lexicon=Lexicon.default()) assert len([a for a in out.ambiguities if a.kind is AmbiguityKind.SUFFIX_OR_NAME]) == 2 # and none at all where the capitals settled both (rules/S6) out = _assigned("Doe, John MA JD", lexicon=Lexicon.default()) assert not [a for a in out.ambiguities if a.kind is AmbiguityKind.SUFFIX_OR_NAME] def test_the_no_name_gate_path_still_reports_exactly_once() -> None: """The sibling of the existing count assertion. Where the first post-comma piece IS the trailing run, segment 1 holds no name word, so `_comma.decide` binds it at group's head and reports the member there (#613), and assign's placement loop is handed no piece of it -- so the new emitter is unreachable on exactly the path the first-piece report owns. A member its capitals settle reports nothing on that path (rules/S6, #785), so the path is reached by one a credential in front speaks for (rules/S7).""" for text in ("Doe, PhD Ma", "Doe, PhD Ma JD", "Doe, PhD ma"): out = _assigned(text, lexicon=Lexicon.default()) assert len([a for a in out.ambiguities if a.kind is AmbiguityKind.SUFFIX_OR_NAME]) == 1, text for text in ("Doe, MA", "Doe, MA PhD", "Doe, MA JD"): out = _assigned(text, lexicon=Lexicon.default()) assert not [a for a in out.ambiguities if a.kind is AmbiguityKind.SUFFIX_OR_NAME], text def test_the_trailing_given_slot_detail_is_verbatim() -> None: """Both branches, verbatim -- only the string distinguishes them, the kind being the same either way. The credential branch is read in one case: in a name of mixed case its capitals settle 'MA' and it reports nothing (rules/S6, #785).""" detail = { text: [a.detail for a in _assigned(text, lexicon=Lexicon.default()).ambiguities if a.kind is AmbiguityKind.SUFFIX_OR_NAME] for text in ("DOE, JOHN MA", "Doe, John Ma") } assert detail["DOE, JOHN MA"] == [ "'MA' ending the given part is also an ordinary name word; " "read as a credential"] assert detail["Doe, John Ma"] == [ "'Ma' ending the given part is also an ordinary name word; " "read as a name"] ����������derek73-python-nameparser-456b024/tests/v2/pipeline/test_classify.py��������������������������������0000664�0000000�0000000�00000121122�15273032717�0025432�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������import dataclasses import pytest from nameparser._policy import CapsSuffixes from nameparser import Parser from nameparser._lexicon import Lexicon, _normalize from nameparser._pipeline import STAGES from nameparser._pipeline import _classify as _classify_module from nameparser._pipeline import _vocab from nameparser._pipeline._classify import classify from nameparser._pipeline._extract import extract_delimited from nameparser._pipeline._segment import segment from nameparser._pipeline._state import ( SHAPE_ACRONYM_TAG, ParseState, WorkToken, ) from nameparser._pipeline._tokenize import tokenize from nameparser._pipeline._vocab import ( ambiguous_class_candidate, ambiguous_class_member, caps_shape_candidate, is_cased_paired_initials, surname_unit_facts, surname_unit_tags, ) from nameparser._policy import Policy from nameparser._types import AmbiguityKind, Role _LEX = Lexicon( titles=frozenset({"dr", "sir"}), given_name_titles=frozenset({"sir"}), suffix_acronyms=frozenset({"phd", "ma"}), suffix_words=frozenset({"jr", "v"}), suffix_acronyms_ambiguous=frozenset({"ma"}), particles=frozenset({"de", "la", "van"}), particles_ambiguous=frozenset({"van"}), # 'e' and 'y' are both shipped single-letter conjunctions and the # fork treats them differently, which is the whole point of the # subset; й is COPIED from the shipped conjunctions (Ukrainian, # #267) so the collision # test_cyrillic_initial_outranks_the_conjunction pins is one that # really ships and the reader can check against the defaults. The # copies are local: this file never reads the shipped sets. # 'i' (#397) is the second marked entry and the only one that is # ALSO in suffix_words here, which is what makes the collision cell # below reachable. conjunctions=frozenset({"and", "e", "i", "y", "й"}), conjunctions_ambiguous=frozenset({"e", "i"}), bound_given_names=frozenset({"abdul"}), # "née" and the unaccented "nee" are both shipped (English writes # the French marker either way, AGENTS.md); a clause-truncation # test below needs the unaccented spelling since _normalize does # not fold accents away. maiden_markers=frozenset({"née", "nee"}), ) def _classified_with(text: str, lexicon: Lexicon) -> ParseState: state = ParseState(original=text, lexicon=lexicon, policy=Policy()) return classify(segment(tokenize(extract_delimited(state)))) def _classified(text: str) -> ParseState: return _classified_with(text, _LEX) def _state_through(stage_name: str, text: str) -> ParseState: """Run the pipeline up to and including the named stage (STAGES' own names) using this module's `_LEX`, for a test that needs a state classify has not yet touched (#289/#516).""" state = ParseState(original=text, lexicon=_LEX, policy=Policy()) for stage in STAGES: state = stage(state) if stage.__name__ == stage_name: break return state def _tags(state: ParseState, text: str) -> frozenset[str]: return next(t.tags for t in state.tokens if t.text == text) def _tags_by_text(text: str, policy: Policy = Policy(), lexicon: Lexicon = _LEX) -> dict[str, frozenset[str]]: """Every token's tags after classify, keyed by text -- the shape tests below ask about a token whose text is not in `_LEX` at all, which the by-name `_tags` above still finds fine, but a dict reads more plainly at more than one lookup per name (#516).""" state = ParseState(original=text, lexicon=lexicon, policy=policy) out = classify(segment(tokenize(extract_delimited(state)))) return {t.text: t.tags for t in out.tokens} def test_vocabulary_tags() -> None: out = _classified("Dr. van de la Smith and abdul née PhD Jr") assert "vocab:title" in _tags(out, "Dr.") assert {"particle", "vocab:particle-ambiguous"} <= _tags(out, "van") assert "particle" in _tags(out, "de") assert "vocab:particle-ambiguous" not in _tags(out, "de") assert "conjunction" in _tags(out, "and") assert "vocab:bound-given" in _tags(out, "abdul") assert "vocab:maiden-marker" in _tags(out, "née") assert "vocab:suffix" in _tags(out, "PhD") assert {"vocab:suffix", "vocab:suffix-word"} <= _tags(out, "Jr") assert _tags(out, "Smith") == frozenset() def test_given_title_tag() -> None: out = _classified("Sir John") assert {"vocab:title", "vocab:given-title"} <= _tags(out, "Sir") def test_initial_tag() -> None: out = _classified("John A. B Smith") assert "initial" in _tags(out, "A.") assert "initial" in _tags(out, "B") assert "initial" not in _tags(out, "John") def test_cyrillic_initial_outranks_the_conjunction() -> None: """#320 regression. 'й' is the Ukrainian conjunction (#267); 'Й.' is an initial and must not be read as it. Narrowing is_initial to [A-Za-z] -- the fix #320 originally proposed -- flips this token to 'conjunction' and strips 'initial' off every Cyrillic, Greek, Arabic, Hebrew, Devanagari and Armenian initial (the six test_vocab.test_is_initial_script_repertoire asserts). Neither moves field output on a short name, so this asserts the TAG. _LEX copies й from the SHIPPED conjunctions so the collision is a real one -- but the copy is local, so this test would keep passing if й were dropped from the defaults.""" out = _classified("Й. Сліпий") assert "initial" in _tags(out, "Й.") assert "conjunction" not in _tags(out, "Й.") def test_ambiguous_suffix_acronym_needs_periods() -> None: out = _classified("M.A. Ma") assert "vocab:suffix" in _tags(out, "M.A.") assert "vocab:suffix" not in _tags(out, "Ma") assert "vocab:suffix-ambiguous" in _tags(out, "Ma") def test_v_is_suffix_word_and_initial() -> None: # both tags present; assign applies the veto, not classify out = _classified("John V Smith") assert {"vocab:suffix", "vocab:suffix-word", "initial"} <= _tags(out, "V") def test_bare_ambiguous_acronym_in_acronyms_is_not_suffix() -> None: # the default lexicon has suffix_acronyms_ambiguous SUBSET OF # suffix_acronyms (v1 data shape); the plain membership test must # exclude the ambiguous members or the period gate is dead code # and bare 'Ed'/'Jd' silently become suffixes (PR review C1) out = _classified("Ma M.A.") assert "vocab:suffix" not in _tags(out, "Ma") assert "vocab:suffix" in _tags(out, "M.A.") def test_one_case_subset_member_is_an_initial_and_reports() -> None: # rules/P8 says a one-case name carries no case evidence, so # the vocabulary decides, and 'e' is marked as reading both ways. # (No colon after the rule id on purpose: tests/ is swept by # test_doc_citations.py, and the colon form must quote the rule # verbatim and puts this file on P8's implemented: list.) for text in ("jose e maria santos", "JOSE E MARIA SANTOS"): out = _classified(text) letter = "e" if text.islower() else "E" assert "initial" in _tags(out, letter), text assert "conjunction" not in _tags(out, letter), text kinds = [a.kind for a in out.ambiguities] assert kinds == [AmbiguityKind.CONJUNCTION_OR_INITIAL], text assert out.ambiguities[0].indices == (1,), text assert repr(letter) in out.ambiguities[0].detail, text def test_one_case_non_member_joins_even_as_a_bare_capital() -> None: # the half that changes for 'y': a bare capital used to be vetoed # into an initial by its Latin shape, and now joins, because the # rule is about EVIDENCE and an all-upper name has none. out = _classified("JUAN GARCIA Y LOPEZ") assert "conjunction" in _tags(out, "Y") assert "initial" not in _tags(out, "Y") assert out.ambiguities == () def test_mixed_case_keeps_todays_rule_verbatim() -> None: # both halves, unchanged and unreported: a bare capital is an # initial, a lowercase letter is the connective. upper = _classified("Jose E Maria Santos") assert "initial" in _tags(upper, "E") assert "conjunction" not in _tags(upper, "E") lower = _classified("John e Smith") assert "conjunction" in _tags(lower, "e") assert "initial" not in _tags(lower, "e") assert upper.ambiguities == () and lower.ambiguities == () def test_a_trailing_uppercase_suffix_makes_the_name_mixed_case() -> None: # the gate reads the whole name's text, so 'john e jones, III' is # mixed case and keeps today's reading -- the boundary that keeps # a v1 corpus name still. 'iii' is listed here as it is in the # shipped vocabulary: unlisted, 'III' is an all-caps word after a # comma behind two name words, which the default reads as a # credential run since #564 and reports -- a different fork. lex = dataclasses.replace(_LEX, suffix_words=_LEX.suffix_words | {"iii"}) out = _classified_with("john e jones, III", lex) assert "conjunction" in _tags(out, "e") assert out.ambiguities == () def test_emptying_the_subset_restores_joining_for_e() -> None: # the knob, and the reason there is no switch: remove 'e' and a # one-case name joins it again, silently. lex = dataclasses.replace(_LEX, conjunctions_ambiguous=frozenset()) out = _classified_with("jose e maria santos", lex) assert "conjunction" in _tags(out, "e") assert "initial" not in _tags(out, "e") assert out.ambiguities == () def test_the_catalan_link_reads_as_an_initial_in_a_one_case_name( ) -> None: # #397's letter through #527's fork, and the reason it ships # marked: a bare I initial is as common as a bare E, so a name # with no case evidence reads the letter as an initial and says # so. rules/P8 says the marked set is where that answer lives. for text in ("josep carod i rovira", "JOSEP CAROD I ROVIRA"): out = _classified(text) letter = "i" if text.islower() else "I" assert "initial" in _tags(out, letter), text assert "conjunction" not in _tags(out, letter), text kinds = [a.kind for a in out.ambiguities] assert kinds == [AmbiguityKind.CONJUNCTION_OR_INITIAL], text assert repr(letter) in out.ambiguities[0].detail, text def test_the_catalan_link_joins_where_the_writing_speaks() -> None: # the mixed-case half, unchanged and unreported: a lowercase # letter among capitals is the connective and a bare capital is # an initial, exactly as for 'e'. lower = _classified("Josep Carod i Rovira") assert "conjunction" in _tags(lower, "i") assert "initial" not in _tags(lower, "i") upper = _classified("Josep Carod I Rovira") assert "initial" in _tags(upper, "I") assert "conjunction" not in _tags(upper, "I") assert lower.ambiguities == () and upper.ambiguities == () def test_removing_the_link_from_the_subset_restores_joining() -> None: # the Catalan/Polish removal recipe docs/customize.rst teaches, # exercised: drop 'i' from the marked subset and a one-case name # joins it again, silently, which is what a ca/pl corpus wants. lex = dataclasses.replace( _LEX, conjunctions_ambiguous=_LEX.conjunctions_ambiguous - {"i"}) out = _classified_with("josep carod i rovira", lex) assert "conjunction" in _tags(out, "i") assert "initial" not in _tags(out, "i") assert out.ambiguities == () def test_an_orphan_marker_is_inert_even_in_the_emitter() -> None: # 'e' left in conjunctions_ambiguous but removed from conjunctions # -- legal since the pair is not in _SUBSET_FIELDS (decisions/P8) # -- never takes the fork, so 'E' in an all-upper name falls to the # else branch and is tagged "initial" by is_initial() alone, same as # any bare capital. Without the emitter's base-vocabulary gate this # would still report a connective that the lexicon no longer has. orphan = dataclasses.replace( _LEX, conjunctions=_LEX.conjunctions - {"e"}) out = _classified_with("JOSE E MARIA SANTOS", orphan) assert "initial" in _tags(out, "E") assert "conjunction" not in _tags(out, "E") assert out.ambiguities == () def test_a_caseless_connective_never_enters_the_fork() -> None: # Arabic و has no case, so token.upper() == token.lower() and # today's rule stands whatever the name's case class is. Marked # as well -- the only configuration where the casedness gate # decides anything: without this, و is absent from # conjunctions_ambiguous the same way every other shipped # conjunction but 'e' is, so the test would pass even if the # casedness check were deleted outright. lex = dataclasses.replace( _LEX, conjunctions=_LEX.conjunctions | frozenset({"و"}), conjunctions_ambiguous=_LEX.conjunctions_ambiguous | frozenset({"و"})) out = _classified_with("محمد و علي", lex) assert "conjunction" in _tags(out, "و") assert out.ambiguities == () def test_a_multi_letter_connective_is_untouched() -> None: out = _classified("john and jane smith") assert "conjunction" in _tags(out, "and") assert out.ambiguities == () def test_a_dotted_letter_is_an_initial_by_shape_in_any_case_class() -> None: # 'e.' is initial-SHAPED, so the fork's len(text) == 1 gate declines # and today's rule takes it -- initial, unreported, as it always was. out = _classified("john e. smith") assert "initial" in _tags(out, "e.") assert "conjunction" not in _tags(out, "e.") assert out.ambiguities == () def test_a_maiden_clause_does_not_count_toward_the_case_class() -> None: # rules/P3 says a maiden marker, taken as one, and the words it # takes, are not among the name's own words -- so appending # ' née Jones' must not flip 'Y' back to an initial: the name's own # words ("JUAN GARCIA Y LOPEZ") are still all-upper on their own, # and 'née Jones' being Title-case is not evidence about THEM. out = _classified("JUAN GARCIA Y LOPEZ née Jones") assert "conjunction" in _tags(out, "Y") assert "initial" not in _tags(out, "Y") assert out.ambiguities == () def test_a_maiden_clause_leaves_the_own_words_report_intact() -> None: # the other half: a one-case name's own words still report, clause # appended or not. out = _classified("jose e maria santos née jones") assert "initial" in _tags(out, "e") assert "conjunction" not in _tags(out, "e") kinds = [a.kind for a in out.ambiguities] assert kinds == [AmbiguityKind.CONJUNCTION_OR_INITIAL] def test_a_delimited_clause_does_not_count_toward_the_case_class() -> None: # delimited (nickname) content arrives with its role already set by # extract, before classify ever runs, so it is excluded from the # name's own words the same way a maiden clause is -- confirmed by # inspecting the token here (role is Role.NICKNAME, not None). out = _classified('JOSE E MARIA SANTOS "Pepe"') pepe = next(t for t in out.tokens if t.text == "Pepe") assert pepe.role is Role.NICKNAME assert "initial" in _tags(out, "E") kinds = [a.kind for a in out.ambiguities] assert kinds == [AmbiguityKind.CONJUNCTION_OR_INITIAL] def test_the_fork_itself_never_reads_a_delimited_clauses_tokens() -> None: # the other half of the bug the above test alone did not catch: not # just the CASE CLASS but the fork itself must skip a clause's own # tokens, or a nickname's bare capital gets read as though it were # one of the name's own words. 'Y' is initial-SHAPED, so pre-fork # (2.3.0) it read "initial" and nothing else -- that must hold # whatever the surrounding name's case class is. y = _classified('JOSE MARIA SANTOS "Y"') assert "initial" in _tags(y, "Y") assert "conjunction" not in _tags(y, "Y") assert y.ambiguities == () e = _classified('JOSE MARIA SANTOS "E"') assert "initial" in _tags(e, "E") assert "conjunction" not in _tags(e, "E") assert e.ambiguities == () @pytest.mark.parametrize("letter", ["e", "y"]) def test_a_letter_after_the_maiden_marker_reads_as_its_capital( letter: str) -> None: # symmetric with the delimited-clause case: a word inside the # maiden clause itself is not one of the name's own words either, # so it never enters P8's fork, which would read the marked 'e' as # an initial WITH a report and the unmarked 'y' as a connective. # It is read as its capital in the clause is (rules/T4, #723): an # initial, reported by nobody. lower = _classified(f"juan garcia lopez née {letter}") upper = _classified(f"JUAN GARCIA LOPEZ NÉE {letter.upper()}") assert "initial" in _tags(lower, letter) assert "conjunction" not in _tags(lower, letter) assert _tags(lower, letter) == _tags(upper, letter.upper()) assert lower.ambiguities == upper.ambiguities == () @pytest.mark.parametrize("text", [ "MARIA GARCIA NEE LOPEZ y FERNANDEZ", "MARIA GARCIA (LOPEZ y FERNANDEZ)", "DR. NÉE JUAN y LOPEZ", ]) def test_a_lowercase_clause_letter_among_capitals_is_no_initial( text: str) -> None: # #723's review: the own words are capitals, but the clause beside # them is not, so the name is written in more than one case and a # bare lower-case letter there is no initial (rules/T4) -- the # own-words fact alone would have tagged it one out = _classified(text) assert "initial" not in _tags(out, "y") assert "conjunction" in _tags(out, "y") def test_a_clauses_own_marker_word_does_not_truncate_the_own_words() -> None: # #527 review: _vocab.tag_marker_runs walks every token, so a maiden # marker WORD that arrives already ROLED (parenthesised clause # content, extract's doing -- WorkToken.role's docstring) is the # CLAUSE's own word, not a bare marker opening a new clause, and # must not move clause_at. Before the fix, "NEE"'s match truncated # "own" at its own index, excluding the trailing "GARCIA Y LOPEZ" # from the case class and misreading 'Y' as an initial instead of # joining the family (rules/P4). out = _classified("JUAN (NEE JONES) GARCIA Y LOPEZ") nee = next(t for t in out.tokens if t.text == "NEE") # the parenthesised clause opens as Role.NICKNAME (parens are a # nickname delimiter by default) and extract promotes it to # Role.MAIDEN once the marker word is recognized inside it (M1/M3) assert nee.role is Role.MAIDEN assert "conjunction" in _tags(out, "Y") assert "initial" not in _tags(out, "Y") assert out.ambiguities == () def test_a_clauses_own_marker_word_does_not_skew_the_case_class() -> None: # the mixed-case sibling of the test above: excluding "Santos" (a # trailing capitalized own word) from "own" made the truncated # prefix "jose e maria" look uniformly lowercase, so 'e' wrongly # took the one-case fork and reported. out = _classified('jose e maria "Nee" Santos') nee = next(t for t in out.tokens if t.text == "Nee") # a lone marker word in the clause: no content follows it inside # the quotes, so nothing promotes NICKNAME to MAIDEN (M3) -- the # fix does not care which role a clause carries, only that it # carries one at all (WorkToken.role is not None) assert nee.role is Role.NICKNAME assert "conjunction" in _tags(out, "e") assert "initial" not in _tags(out, "e") assert out.ambiguities == () def test_a_title_counts_toward_the_case_class_unlike_a_clause() -> None: # a title is one of the name's own words, unlike a clause: "Dr." # beside an all-upper name makes the WHOLE name mixed case, so 'Y' # is evidence-backed and stays an initial; "DR." (itself all-caps) # leaves the name one-case and 'Y' joins as the plain connective. mixed = _classified("Dr. JUAN GARCIA Y LOPEZ") assert "initial" in _tags(mixed, "Y") assert "conjunction" not in _tags(mixed, "Y") assert mixed.ambiguities == () one_case = _classified("DR. JUAN GARCIA Y LOPEZ") assert "conjunction" in _tags(one_case, "Y") assert "initial" not in _tags(one_case, "Y") assert one_case.ambiguities == () def test_the_emitter_runs_per_token() -> None: # each cased single-letter connective gets its OWN report, keyed by # its own index -- 'e and e' in the corpus is the other witness. out = _classified("jose e maria e santos") kinds = [a.kind for a in out.ambiguities] assert kinds == [AmbiguityKind.CONJUNCTION_OR_INITIAL, AmbiguityKind.CONJUNCTION_OR_INITIAL] assert out.ambiguities[0].indices == (1,) assert out.ambiguities[1].indices == (3,) def test_classify_is_per_token_independent_of_the_comma() -> None: # classify runs before the comma settles any structural question, # so the case class and the fork read the same whether the comma # is there or not. family_comma = _classified("SANTOS, JOSE E MARIA") assert "initial" in _tags(family_comma, "E") kinds = [a.kind for a in family_comma.ambiguities] assert kinds == [AmbiguityKind.CONJUNCTION_OR_INITIAL] suffix_comma = _classified("GARCIA Y LOPEZ, JUAN") assert "conjunction" in _tags(suffix_comma, "Y") assert "initial" not in _tags(suffix_comma, "Y") assert suffix_comma.ambiguities == () def test_classify_records_the_one_case_fact_on_the_state() -> None: # #289/#516 promotes the fact #527 computed as a local: the suffix # slot, the post-comma slot and the tail-segment reading all # consult it, and two sites deciding it apart is what # ParseState.order's shape exists to prevent. assert _classified("JOHN SMITH MA").one_case is True assert _classified("John Smith Ma").one_case is False assert _classified("john smith ma").one_case is True # a caseless script has only one case, and answers True harmlessly assert _classified("毛泽东").one_case is True # the span is the name's OWN words: a maiden clause beside a # one-case name does not make it mixed (rules/P3) assert _classified("JUAN GARCIA Y LOPEZ née Jones").one_case is True def test_classify_does_not_overwrite_a_fact_already_recorded() -> None: # segment writes it first where a comma form asked; classify reads # what is there rather than deciding it a second time. state = _state_through("segment", "John Smith, MA") forced = dataclasses.replace(state, one_case=True) assert classify(forced).one_case is True def test_classify_s_fork_reads_the_pre_recorded_fact_not_its_own_answer() -> None: # Not just that the field survives (the test above) -- the FORK # that reads one_case must consult the recorded value, not # recompute its own. "Jose e Maria Santos" is mixed case on its # own words, so an unforced parse takes the conjunction branch and # reports nothing; forcing one_case=True ahead of classify must # flip 'e' to an initial and report CONJUNCTION_OR_INITIAL even # though the tokens themselves never changed case (measured # 2026-09-17, #289/#516). unforced = _classified("Jose e Maria Santos") assert "conjunction" in _tags(unforced, "e") assert "initial" not in _tags(unforced, "e") assert unforced.ambiguities == () state = _state_through("segment", "Jose e Maria Santos") forced = dataclasses.replace(state, one_case=True) out = classify(forced) assert "initial" in _tags(out, "e") kinds = [a.kind for a in out.ambiguities] assert kinds == [AmbiguityKind.CONJUNCTION_OR_INITIAL] def test_a_by_shape_acronym_carries_both_tags() -> None: # #516: the class membership is `vocab:suffix-ambiguous`, so the # peel needs no new branch -- and `shape:acronym` rides beside it # because the `vocab:` namespace records MEMBERSHIP and the word # is not in the vocabulary. The two consumers that must tell them # apart read the shape tag. tags = _tags_by_text("John Smith X.Y.Z.")["X.Y.Z."] assert "shape:acronym" in tags assert "vocab:suffix-ambiguous" in tags assert "vocab:suffix" not in tags # a listed member carries membership and no shape claim assert "shape:acronym" not in _tags_by_text("John Smith MA")["MA"] # whole-token vocabulary wins outright assert "shape:acronym" not in _tags_by_text("John Smith M.A.")["M.A."] assert "vocab:suffix" in _tags_by_text("John Smith M.A.")["M.A."] def test_the_switch_leaves_the_shape_tag_and_takes_the_membership() -> None: # OFF reads the token as name material everywhere, as 2.3 did for # a token no chunk claimed (the roman-chunk retirement is not # behind this switch) -- and still reports, the parser having # chosen the name reading over a credential one. off = _tags_by_text("John Smith X.Y.Z.", policy=Policy( unlisted_dotted_suffixes=False))["X.Y.Z."] assert "shape:acronym" in off assert "vocab:suffix-ambiguous" not in off def test_no_listed_member_ever_carries_the_shape_tag() -> None: # The invariant, stated over the TAGS rather than over a branch: # SHAPE_ACRONYM_TAG says "the WRITING made this credential-shaped, # the vocabulary did not", so a token whose normalized text IS a # listed ambiguous acronym must never carry it -- the tag silences # `_pieces.listed_lean`, which is the listing's whole effect. # # The custom lexicon is the case that was broken (review round, # 2026-09-18): a DOTTED entry matches `suffix_acronyms_ambiguous` # whole while `suffix_as_written`'s period-free acronym lookup # ('ab') misses it, so the chunk view reached the shape branch and # 'Jack A.B.' read family where 'Jack MA' reads suffix. dotted = Lexicon.default().add(suffix_acronyms={"a.b"}, suffix_acronyms_ambiguous={"a.b"}) for lex, texts in ((Lexicon.default(), ("John Smith MA", "John Smith Ma", "Smith, MA", "John Smith BA", "John Smith X.Y.Z.")), (dotted, ("Jack A.B.", "John Smith A.B.", "Smith, A.B."))): for policy in (Policy(), Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), Policy(unlisted_dotted_suffixes=False)): for text in texts: for word, tags in _tags_by_text(text, lexicon=lex, policy=policy).items(): if SHAPE_ACRONYM_TAG in tags: assert _normalize(word) not in \ lex.suffix_acronyms_ambiguous, (text, word) def test_delimited_content_never_joins_the_shape_class() -> None: # A bracketed clause is decided by extract's escape, not by the # trailing slot. 'Bridge (1.4)' cannot exercise the `token.role is # None` guard on its own -- a digit chunk never reaches the shape # verdict at all, guard or no guard -- so 'Bridge (A.B)' is the # control that actually load-bears it: without the guard, the # nickname 'A.B' would gain SHAPE_ACRONYM_TAG and (with the switch # on) `vocab:suffix-ambiguous`, and classify's own nickname check # would then misreport SUFFIX_OR_NICKNAME (post_rules' report, since # #810) on a token the escape already decided (measured regression, #516 review round). for text, word in (("Bridge (1.4)", "1.4"), ("Bridge (A.B)", "A.B")): tags = _tags_by_text(text)[word] assert "shape:acronym" not in tags, text assert "vocab:suffix-ambiguous" not in tags, text @pytest.mark.parametrize("word,policy,one_case", [ ("X.Y.Z.", Policy(), None), ("A.B.", Policy(), None), # #864: paired initials of a cased script are held back from the # class outside the comma; a caseless script's pair is not ("B.K", Policy(), None), ("พ.บ.", Policy(), None), ("M.A.", Policy(), None), ("A.B.C.", Policy(), None), ("Msc.Ed.", Policy(), None), ("Lt.Gov.", Policy(), None), ("1.4", Policy(), None), ("Xyz.", Policy(), None), ("MA", Policy(), None), ("田.中.", Policy(), None), # #516's caps half: the default is silent here, outside a suffix # comma's part (#564), and so is OFF (matches the tag either way, # since the token never carries the fact); ON needs the REAL # `one_case` fact, which these rows hand to `caps_shape_candidate` # -- the predicate classify calls, and since the review round the # only route into the caps half at all. ("XYZ", Policy(), None), ("XYZ", Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), False), ("MC", Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), False), ("X", Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), False), ("XY2", Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), False), ("DUPONT", Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), False), # #516 review round, F1/F1b: LISTED members must keep the LEAN # (SHAPE_ACRONYM_TAG must NOT ride beside their membership tag), # and UNLISTED means in no wordlist at all -- a particle, an # ambiguous particle and a conjunction, capitalized, must not # join the class either. ("MA", Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), False), ("BA", Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), False), ("DE", Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), False), ("Y", Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), False), ("VAN", Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), False), # 'Y' declines at the `len(text) >= 2` shape gate and never # actually reaches the conjunction-exclusion check at all; # 'AND' is the row that genuinely exercises it (quality-review # finding). ("AND", Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), False), # #516 review round (second finding): the four wordlists the # first fix round's exclusion left with no row of their own # (titles, given_name_titles, bound_given_names, suffix_words -- # `given_name_titles` is always a subset of `titles` in the # shipped lexicon, so 'AUNT' declines via BOTH the outer # `vocab:title` guard and this elif's own check, and the row # still pins that it declines either way), and the maiden-marker # gap itself. ("SIR", Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), False), ("AUNT", Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), False), ("ABDUL", Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), False), ("JR", Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), False), ("NEE", Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), False), ]) def test_ambiguous_class_candidate_agrees_with_the_tag( word: str, policy: Policy, one_case: bool | None) -> None: # `ambiguous_class_candidate` (`_vocab.py`) and classify's own tag # emission ask the SAME questions twice, of necessity -- `segment` # runs before `classify` and has no tags to read yet. Kept from # drifting by this test rather than by a sentence alone: both # docstrings point here by name. `MA.`/`Ed.` are deliberately # absent: a listed member's DOTTED spelling is carried by the tag # path alone (`ambiguous_class_member`'s docstring), the one place # these two answers are meant to differ. # # Membership alone is not enough to catch #516's review-round # regression: `ambiguous_class_candidate("MA", ...)` was ALWAYS # True (the listed half), on both sides of the bug, so comparing # candidacy to `vocab:suffix-ambiguous` presence alone never # noticed that classify's caps branch was ALSO wrongly adding # `shape:acronym` beside it, silencing `listed_lean`. The second # assertion is what catches that: SHAPE_ACRONYM_TAG must appear # if and only if the class was joined BY SHAPE, never for a # listed member. # # `candidate` is spelled as the DISJUNCTION of the two predicates # that answer the class's membership before tags exist: # `ambiguous_class_candidate` for the listed and dotted halves # (segment's C2 flag asks it), and `caps_shape_candidate` for the # caps half (`_comma.decide` asks it). The caps half used to hang off # `ambiguous_class_candidate` behind an optional `one_case` no # production caller passed, so it answered False for every name # the library parsed and only this test reached it; the parameter # is gone and the rows point at the live predicate instead. lex = Lexicon.default() tags = _tags_by_text(f"John Smith {word}", lexicon=lex, policy=policy)[word] candidate = (ambiguous_class_candidate(word, lex, policy) or (policy.unlisted_caps_suffixes and caps_shape_candidate(word, lex, policy, one_case))) # rules/S3's paired initials, the one shape the two answers part # on by design: the candidate (segment's, before the comma has # been read) admits them, and classify holds the class tag back, # which `_comma.decide` restores at the comma C5 reads (#864) paired = (is_cased_paired_initials(word) and not ambiguous_class_member(word, lex)) assert (candidate and not paired) == ("vocab:suffix-ambiguous" in tags) shape_member = candidate and not ambiguous_class_member(word, lex) assert (SHAPE_ACRONYM_TAG in tags) == shape_member def test_the_caps_branch_reads_the_name_level_case_not_the_own_span( monkeypatch: pytest.MonkeyPatch) -> None: """The mutation control for #516's `one_case` vs `one_case_own` split, run rather than described. A maiden marker OPENING the name leaves `own_words` empty, so the NAME-level fact is one-case and nothing may join the caps class. `one_case_own` is a different question -- "one case AND this token is one of the name's own words" -- and every token past the clause cut answers it False by construction, so reading it here makes a wholly one-case name look mixed for exactly those tokens. The revert is applied at runtime: `_tags_for` is wrapped so the caps branch sees `one_case_own` where it should see `one_case`. Without the wrapper the reading below holds; with it, 'XYZ' joins the class and the family name is lost. """ on = Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE) parser = Parser(policy=on) name = parser.parse("née JONES XYZ") assert (name.given, name.middle, name.family) == ("née", "JONES", "XYZ") assert name.ambiguities == () real = _classify_module._tags_for def reverted(token: WorkToken, n: str, state: ParseState, marker_tag: str | None, one_case_own: bool, one_case: bool, letter_one_case: bool) -> frozenset[str]: return real(token, n, state, marker_tag, one_case_own=one_case_own, one_case=one_case_own, letter_one_case=letter_one_case) monkeypatch.setattr(_classify_module, "_tags_for", reverted) broken = Parser(policy=on).parse("née JONES XYZ") assert broken.family != "XYZ", ( "the revert changed nothing, so this control measures nothing: " "check that classify's caps branch still reads `one_case`") def test_the_caps_shape_is_silent_until_its_switch_is_on() -> None: # The default emits NOTHING at the trailing slot -- not the # membership tag and not the shape tag either, which is where this # half differs from the dotted one: there is no fork to report # while a caller has not asked for the reading there (#516; the # default reads only a suffix comma's part since #564). assert _tags_by_text("John Smith XYZ")["XYZ"] == frozenset() on = Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE) tags = _tags_by_text("John Smith XYZ", policy=on)["XYZ"] assert "shape:acronym" in tags and "vocab:suffix-ambiguous" in tags def test_the_caps_shape_is_a_testable_predicate() -> None: # Spelled for an implementer: isalpha() and isupper() with at # least two characters, in a MIXED-case name. 'MC' is real suffix # vocabulary only in the SHIPPED lexicon (this module's own _LEX # is deliberately minimal and does not carry it), so that one # assertion uses Lexicon.default() rather than the module fixture. on = Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE) # a digit anywhere disqualifies it assert "shape:acronym" not in _tags_by_text("John Smith XY2", policy=on)["XY2"] # a single capital stays what it is today, an initial assert "shape:acronym" not in _tags_by_text("John Smith X", policy=on)["X"] assert "initial" in _tags_by_text("John Smith X", policy=on)["X"] # a one-case name has no contrast to read assert "shape:acronym" not in _tags_by_text("JOHN SMITH XYZ", policy=on)["XYZ"] # #516 review round (second finding): a token PAST the maiden # clause cut must decline on the same one-case ground as every # other token, not on `one_case_own`'s span alone -- 'NEE' sits # outside `one_case_own`'s span by construction (it opens the # clause), so testing that flag alone made a wholly one-case name # look mixed for this one token and only this one. assert "shape:acronym" not in _tags_by_text( "JOHN SMITH NEE", policy=on, lexicon=Lexicon.default())["NEE"] # UNLISTED means no whole-token suffix vocabulary claims it: 'MC' # is suffix vocabulary and never reaches this switch assert "shape:acronym" not in _tags_by_text( "John Smith MC", policy=on, lexicon=Lexicon.default())["MC"] # an interior period is the other switch's shape, not this one: # 'X.Y' already carries `shape:acronym` at the DEFAULT (the # dotted half, `unlisted_dotted_suffixes`, on by default), so the # bare "in tags" assertion this row had before passed regardless # of whether the caps switch touched it at all -- a differential # is the real test (quality-review finding): turning the caps # switch on must not CHANGE anything about a dotted token's tags. assert (_tags_by_text("John Smith X.Y", policy=on)["X.Y"] == _tags_by_text("John Smith X.Y")["X.Y"]) def _surname_unit_sweep() -> list[str]: lex = Lexicon.default() words: set[str] = {"Smith", "Ma", "M.D.", "Ph.D.", "JD.CPA", "Msc.Ed.", "Lt.Gov.", "X.Y.Z.", "V.", "I", "v", "Jr.", "de."} for field in ("particles", "suffix_acronyms", "suffix_words", "conjunctions", "bound_given_names", "titles"): for w in getattr(lex, field): if " " not in w: words |= {w, w.title(), w.upper()} # rules/P7's spelling: a one-letter particle written with its # period is an initial, and both copies must say so (#604) words |= {w + "." for w in set(words) if len(w) == 1 and w.lower() in lex.particles} return sorted(words) def _surname_unit_disagreements() -> list[str]: lex = Lexicon.default() disagree = [] for word in _surname_unit_sweep(): tags = _tags_by_text(f"Smith {word}, John", lexicon=lex).get(word) if tags is None: # tokenize split it; nothing to compare continue # both positions: the leading one is where a title-particle # ('Freiherr', 'St') stops being a particle if any(surname_unit_tags(word, lex, leading) != surname_unit_facts(tags, leading) for leading in (True, False)): disagree.append(word) return disagree #: The agreement test's recorded negative control: what it reports #: with `surname_unit_tags`' period-joined mirror switched off -- the #: two words its first run caught, which classify tags as suffixes #: through that derivation alone. _SURNAME_UNIT_CONTROL = ["JD.CPA", "Msc.Ed."] def test_surname_unit_tags_agree_with_classify() -> None: # #575: rules/C3's count before the comma reads two facts per # token -- particle, suffix -- and `segment` must build them from # the vocabulary (`_vocab.surname_unit_tags`), since it runs before # classify has tagged anything, while assign derives them from # classify's tags (`_vocab.surname_unit_facts`). Swept over every # single-word entry of the vocabularies a token can be tagged # from, in three casings and both positions, plus the period shapes # classify derives a suffix from; the title-particles ('Freiherr', # 'St') are in the sweep through those lists. assert _surname_unit_disagreements() == [] def test_the_surname_unit_agreement_test_can_fail( monkeypatch: pytest.MonkeyPatch) -> None: # The guard above, with the mirror it exists to hold switched off: # `_vocab.surname_unit_tags` reads `period_joined_vocab` from its # own module, so patching it there leaves classify untouched. monkeypatch.setattr(_vocab, "period_joined_vocab", lambda text, lexicon: None) assert _surname_unit_disagreements() == _SURNAME_UNIT_CONTROL def test_the_surname_unit_agreement_test_sees_the_initial_veto( monkeypatch: pytest.MonkeyPatch) -> None: # The same guard over the other half of the mirror: with # `surname_unit_tags`' one-letter-initial veto switched off, the # period-written particle reads as a particle there and not in # classify (#604, rules/P7). monkeypatch.setattr(_vocab, "_spells_an_initial", lambda n, text: False) assert _surname_unit_disagreements() == ["Ó.", "ó."] ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/pipeline/test_comma.py�����������������������������������0000664�0000000�0000000�00000047572�15273032717�0024731�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""rules/C1's decision, made at the head of group once classify has tagged the words (#613): `_comma.decide`. These tests were segment's until the decision moved; they run the stages up to classify and then the decision, as group does.""" from nameparser import Parser, parse from nameparser._lexicon import Lexicon from nameparser._pipeline import _comma from nameparser._pipeline._classify import classify from nameparser._pipeline._extract import extract_delimited from nameparser._pipeline._segment import segment from nameparser._pipeline._state import ParseState, Structure from nameparser._pipeline._tokenize import tokenize import dataclasses from nameparser._policy import Policy from nameparser._types import AmbiguityKind, Role # synthetic vocabulary: behavior given a lexicon, never default() contents _LEX = Lexicon( suffix_acronyms=frozenset({"phd", "md", "do", "dds", "ma", "ed"}), suffix_acronyms_ambiguous=frozenset({"ma", "ed", "do"}), suffix_words=frozenset({"jr", "v"}), ) def _decided(text: str, lexicon: Lexicon = _LEX, policy: Policy = Policy()) -> ParseState: state = ParseState(original=text, lexicon=lexicon, policy=policy) return _comma.decide(classify(segment(tokenize(extract_delimited(state))))) def _texts(state: ParseState, seg: tuple[int, ...]) -> list[str]: return [state.tokens[i].text for i in seg] def _part(state: ParseState) -> list[str]: """The part after the comma: decide empties its segment when it binds the part (#613's /simplify), so a bound part is read off the roles decide wrote, and a declined one off its segment.""" if state.segments[1]: return _texts(state, state.segments[1]) return [t.text for t in state.tokens if t.role in (Role.SUFFIX, Role.TITLE)] def _flip_reports(state: ParseState) -> list[list[str]]: return [_texts(state, a.indices) for a in state.ambiguities if a.kind is AmbiguityKind.SUFFIX_OR_NAME] def test_suffix_comma_when_all_rest_groups_are_suffixes() -> None: out = _decided("John Smith, PhD") assert out.structure is Structure.SUFFIX_COMMA assert (_texts(out, out.segments[0]), _part(out)) == ( ["John", "Smith"], ["PhD"]) def test_suffix_comma_lenient_accepts_initial_shaped_suffix_word() -> None: # "V" is initial-shaped; the strict test vetoes it, the post-comma # lenient test accepts suffix_words unconditionally (v1 parity) out = _decided("John Ingram, V") assert out.structure is Structure.SUFFIX_COMMA def test_the_credential_pair_merges_anywhere_in_the_run() -> None: # v1's fix_phd healed a split 'Ph. D.' wherever it fell, and the # suffix-comma test inherits that: the pair counts as ONE unit at # any position, not just at the head of the run. Behind a class # member, which opens nothing (#603's opener being an unambiguous # credential), the pair is what makes the part a run: without the # merge 'Ph.' and 'D.' are suffix vocabulary in no lexicon and this # reads as the FAMILY comma (measured with `_pieces` patched to # merge nothing, #613's PR review). Behind 'Jr.' the opener alone # would decide it, so that input pinned nothing. out = _decided("John Smith, MA Ph. D.") assert out.structure is Structure.SUFFIX_COMMA assert (_texts(out, out.segments[0]), _part(out)) == ( ["John", "Smith"], ["MA", "Ph.", "D."]) def test_structure_flips_for_the_ambiguous_class_on_a_name_word_count() -> None: # rules/C4 for the ambiguous class: two or more NAME words # before the comma read the part after it as the credential run. # 'John Smith, MA' flips; 'Smith Jr., MA' does not, having two # TOKENS and one name word -- which is what keeps Smith in the # family (#289, decisions/S6). assert _decided("John Smith, MA").structure is Structure.SUFFIX_COMMA assert _decided("Smith Jr., MA").structure is Structure.FAMILY_COMMA assert _decided("Smith, MA").structure is Structure.FAMILY_COMMA # the extension reaches the LISTED set whatever the case says, so # a one-case name flips too -- 1.4.0 read all three as suffixes assert _decided("JOHN SMITH, MA").structure is Structure.SUFFIX_COMMA assert _decided("john smith, ma").structure is Structure.SUFFIX_COMMA assert _decided("John Smith, Ed").structure is Structure.SUFFIX_COMMA assert _decided("Davis Royce, Ed").structure is Structure.SUFFIX_COMMA assert _decided("Royce, Ed").structure is Structure.FAMILY_COMMA def test_structure_flips_for_a_by_shape_member_too() -> None: # #516: an unlisted dotted token joins the class the same way, # through the shape tags classify writes -- 'X.Y.Z.' is three # unclaimed single-letter chunks, not vocabulary at all, so this is # the by-shape twin of the test above. 'Smith Jr., X.Y.Z.' does not # flip for the SAME reason 'Smith Jr., MA' does not (#516 review # round: is_wholly_suffix must never admit the shape class, or # decide's whole-name suffix fallback would read it without the # NAME-word count, as segment's token-count disjunct once did on # 'Jr.'). assert _decided("John Smith, X.Y.Z.").structure \ is Structure.SUFFIX_COMMA assert _decided("Smith, X.Y.Z.").structure is Structure.FAMILY_COMMA assert _decided("Smith Jr., X.Y.Z.").structure \ is Structure.FAMILY_COMMA def test_paired_initials_need_a_word_to_speak_for_them() -> None: # rules/C5 (#563): two dotted single letters are how a person's # own initials are written, and two words before the comma may be # one surname, so alone they keep the family comma. Each contrast # pair differs in the one thing that speaks: a third letter, a # credential IN FRONT (not behind), a second by-shape word. A # generational word in front speaks like any suffix word; the # title-vocabulary exception needs titles this lexicon lacks, so # its contrast is the case-table pair # a_title_in_front_of_paired_initials_does_not_speak. for alone, spoken in (("García Márquez, G.J.", "García Márquez, G.J.R."), ("De La Cruz, M.J. PhD", "De La Cruz, PhD M.J."), ("John Smith, X.Y. MA", "John Smith, X.Y. P.Q."), ("García Márquez, G.J.", "García Márquez, Jr G.J."), # a class member in front speaks for nothing, # as S7's company has it; an unambiguous one does ("García Márquez, Ma G.J.", "García Márquez, PhD G.J.")): assert _decided(alone).structure is Structure.FAMILY_COMMA, alone assert _decided(spoken).structure is Structure.SUFFIX_COMMA, spoken def test_a_flip_no_listed_member_takes_part_in_is_silent() -> None: # rules/C11 (#563): a flip in which no listed member takes part # is silent, while a LISTED member in the same position still # reports the flip -- the control that keeps the silence from # being a lost emitter -- and so do paired initials spoken for # only by each other, each of them a word a reader takes for a # name. A pair beside a three-letter word, or behind PhD, is # spoken for by something else and stays silent. for text in ("John Smith, X.Y.Z.", "John Smith, B.Tech.", "John Smith, X.Y.Z. P.D.Q.", "John Smith, PhD P.D.Q.", "John Smith, X.Y.Z. G.J.", "John Smith, PhD G.J. K.L."): out = _decided(text) assert out.structure is Structure.SUFFIX_COMMA, text assert _flip_reports(out) == [], text for text in ("John Smith, Ma", "John Smith, PhD Ma", "John Smith, X.Y.Z. MA", "De La Cruz, M.J. K.L.", "John Smith, X.Y. P.Q."): out = _decided(text) assert out.structure is Structure.SUFFIX_COMMA, text assert _flip_reports(out) == [_part(out)], text def test_the_structure_flip_reports_a_verbatim_detail() -> None: # The first report of the comma's OWN decision in the library # (#289): C2's structural flag already reports on the comma path, # but it reports what the parse could not recognize, not a fork # it called. P6's attachment fork has separately reported on a # family-comma path since 2.3 ("Berg, Jan vd"). Pinned verbatim so # a wording edit is a deliberate one, # not a silent drift the case table's looser `ambiguities=` tuple # check would never catch. In one case: capitals in a name of mixed # case settle the word and the flip reports nothing (rules/S6, #785). state = _decided("JOHN SMITH, MA") (amb,) = [a for a in state.ambiguities if a.kind is AmbiguityKind.SUFFIX_OR_NAME] assert amb.detail == ( "'MA' after the comma is also an ordinary name word; the part " "before the comma holds 2 name words, so it is read as a " "credential run") def test_the_structure_flip_report_counts_the_real_pre_comma_words() -> None: # The count is INTERPOLATED, not a hardcoded "two": a three-word # pre-comma part reports its own count. state = _decided("JOHN Q. PUBLIC, MA") (amb,) = [a for a in state.ambiguities if a.kind is AmbiguityKind.SUFFIX_OR_NAME] assert "holds 3 name words" in amb.detail def test_the_name_word_count_reads_a_run_as_it_reads_one_word() -> None: # rules/C4 (#544): a part of two or more words, every one of # them suffix vocabulary or a class member and at least one a # member, reads by the same NAME-word count as the single token, # and the flip reports once, over the whole part. for text in ("John Smith, PhD Ma", "John Smith, Ed Ma", "John Smith, Ma PhD", "john smith, phd ma", "JOHN SMITH, PHD MA", "John Smith, MD Ma", "John Smith, X.Y.Z. MA", "John Smith, Ph. D. Ma"): out = _decided(text) assert out.structure is Structure.SUFFIX_COMMA, text assert _flip_reports(out) == [_part(out)], text # one name word before the comma: the count leaves the family comma out = _decided("Smith, PhD Ma") assert out.structure is Structure.FAMILY_COMMA assert _decided("Smith Jr., PhD Ma").structure \ is Structure.FAMILY_COMMA def test_a_run_the_writing_settles_raises_no_flip_report() -> None: # every member written in capitals in a mixed-case name leans # credential (S6), so the part is the credential run on that # evidence rather than the count's: a suffix comma, and no flip # report. (Before #613 segment left this part to the listing form, # whose reading of a no-name part reached the same fields.) out = _decided("John Smith, PhD MA") assert out.structure is Structure.SUFFIX_COMMA assert not _flip_reports(out) assert out.one_case is False # the lean is the LISTED set's alone: a member admitted by SHAPE # is read by the count, capitals or not out = _decided("John Smith, X.Y.Z. MA") assert out.structure is Structure.SUFFIX_COMMA # and one Title-case member is enough to make it the count's call assert _decided("John Smith, MA Ed").structure \ is Structure.SUFFIX_COMMA def test_the_run_test_reads_a_delimiter_core_transparently() -> None: # without a delimiter policy the bare '|' is a word, which ends the # run; with the core configured behind a whole name it is no word # of the reading (#549, #613), so the two members either side of # it complete the run. Not '-': a hyphen standing alone is a # built-in core (#889), as the next line shows. assert _decided("John Smith, Ma - Ed").structure \ is Structure.SUFFIX_COMMA text = "John Smith, Ma | Ed" assert _decided(text).structure is Structure.FAMILY_COMMA state = ParseState( original=text, lexicon=_LEX, policy=dataclasses.replace( Policy(), extra_suffix_delimiters=frozenset({" | "}))) out = _comma.decide(classify(segment(tokenize(extract_delimited(state))))) assert out.structure is Structure.SUFFIX_COMMA def test_the_run_test_reads_lenient_comma_suffixes() -> None: # rules/C4's leftovers ask `is_wholly_suffix`'s own POLICY- # selected predicate (#544): an # initial-shaped suffix WORD ('B.') that is not a single-letter # roman numeral is a member of neither the ambiguous class nor # `is_single_letter_numeral`'s carve-out, so it reaches `others` and # is read by `is_wholly_suffix` alone -- lenient by default (v1's # is_suffix_lenient bypasses the initial veto), strict under # Policy(lenient_comma_suffixes=False) (v1's is_suffix, which the # veto reads as a middle initial instead). lex = _LEX.add(suffix_words={"b"}) text = "John Smith, Ma B." state = ParseState(original=text, lexicon=lex, policy=Policy()) out = _comma.decide(classify(segment(tokenize(extract_delimited(state))))) assert out.structure is Structure.SUFFIX_COMMA state = ParseState( original=text, lexicon=lex, policy=dataclasses.replace(Policy(), lenient_comma_suffixes=False)) out = _comma.decide(classify(segment(tokenize(extract_delimited(state))))) assert out.structure is Structure.FAMILY_COMMA def test_one_word_before_the_comma_never_makes_a_suffix_comma() -> None: # v1: suffix-comma requires >1 word before the comma. The part # after it is still bound as the postnominal part, but the comma # stays the family comma naming the one word; a second word makes # it the suffix comma. out = _decided("Johnson, Jr.") assert out.structure is Structure.FAMILY_COMMA assert out.tokens[1].role is Role.SUFFIX assert _decided("John Johnson, Jr.").structure is Structure.SUFFIX_COMMA def test_strict_comma_suffixes_veto_lenient_only_members() -> None: # lenient_comma_suffixes=False: the post-comma test drops back to # the strict predicate, so an initial-shaped suffix word no longer # qualifies, the part is not bound, and the comma stays the family # comma -- where the default reads 'John Ingram, V' as a suffix # comma (test_suffix_comma_lenient_accepts_initial_shaped_suffix_word) out = _decided("John Ingram, V", policy=dataclasses.replace( Policy(), lenient_comma_suffixes=False)) assert out.structure is Structure.FAMILY_COMMA assert out.tokens[2].role is None def test_the_run_test_declines_a_name_word_and_a_numeral() -> None: # a name word anywhere in the part, or a word that is neither # vocabulary nor a member, keeps the listing form -- and so does a # single-letter roman numeral, in any case, for its one-letter # shape (a multi-letter one runs: 'John Smith, III Ma') for text in ("John Smith, Jones Ma", "John Smith, V Ma", "John Smith, J. Ma"): out = _decided(text) assert out.structure is Structure.FAMILY_COMMA, text assert not _flip_reports(out), text # a credential opening the part makes it the postnominal part # however it goes on (#603), but the run test still declines it: # no report spans the whole part as the count's flip does for text in ("John Smith, PhD Jones Ma", "John Smith, PhD v Ma"): out = _decided(text) assert out.structure is Structure.SUFFIX_COMMA, text assert _part(out) not in _flip_reports(out), text out = _decided("John Smith, III Ma") assert _flip_reports(out) == [_part(out)] def test_a_listed_surname_still_carries_the_name_contrast() -> None: # #564: a word a caller lists as a SURNAME is name text, so it # carries the contrast the comma caps reading needs # (`_vocab.claimed_as_non_name` leaves the surname and bound-given # lists out); `in_any_wordlist`, the caps shape's own "unlisted", # is a different question. A draft shared one predicate for both # and this read given 'XYZ'. The all-caps spelling is the control: # no word before the comma is written as a name, so no contrast. lex = Lexicon.default().add(surnames={"smith", "jones"}) out = _decided("Smith Jones, XYZ", lexicon=lex) assert out.structure is Structure.SUFFIX_COMMA assert out.tokens[2].role is Role.SUFFIX assert _decided("SMITH JONES, XYZ", lexicon=lex).structure \ is Structure.FAMILY_COMMA def test_decide_hands_back_a_state_with_no_family_comma_unchanged() -> None: # group calls decide only on a family comma, but decide's contract # covers any state: a comma-less name, and a comma with nothing # after it, are returned as they came. Nothing BEFORE the comma is # not this exit -- ', Jr.' is read and bound, as the row below pins for text in ("John Smith", "Smith,, Jr."): state = classify(segment(tokenize(extract_delimited(ParseState( original=text, lexicon=_LEX, policy=Policy()))))) assert _comma.decide(state) is state, text def test_a_part_of_delimiter_cores_alone_decides_nothing() -> None: # behind a whole name a declared core is no word of the reading, so # a part holding nothing else has no reading to bind and keeps the # listing form (garbage in; pinned so the empty-part exit is held) policy = Policy(extra_suffix_delimiters=frozenset({" | "})) state = classify(segment(tokenize(extract_delimited(ParseState( original="John Smith, | |", lexicon=_LEX, policy=policy))))) decided = _comma.decide(state) assert decided is state assert decided.structure is Structure.FAMILY_COMMA def test_an_empty_part_before_the_comma_still_reads_the_part_after_it() -> None: # #613 PR review: decide had returned early on an empty part before # the comma, leaving the credentials to the listing walk (', PhD' # read given 'PhD', ', Jr.' title 'Jr.', where 2.3.0 read suffix) for text, bound in ((", PhD", ["PhD"]), (", Jr.", ["Jr."]), (", MD PhD", ["MD", "PhD"])): decided = _decided(text) assert decided.structure is Structure.FAMILY_COMMA, text assert _part(decided) == bound, text def test_a_bound_caps_word_keeps_its_capitals_in_case_repair() -> None: # decide writes the caps shape's marks on a word it binds (#564, # rules/R4), so case repair keeps the capitals -- also where the # run test declined and #603's opener bound the part anyway; the # marks had been cleared there, and 'XYZ' repaired to 'Xyz' # (#613's PR review). A name in one case carries no contrast. def repaired(text: str) -> str: return str(parse(text).capitalized(force=True)) assert repaired("John Smith, XYZ") == "John Smith XYZ" assert repaired("John Smith, PhD Jones XYZ") == \ "John Smith PhD Jones XYZ" assert repaired("JOHN SMITH, PhD JONES XYZ") == \ "John Smith PhD Jones Xyz" def test_a_lone_core_after_the_comma_is_a_word_of_the_part() -> None: # the cores guard needs two tokens: a core alone is no delimiter # between suffixes, so it stays in the part (garbage in, pinned # because the guard reads so) policy = Policy(extra_suffix_delimiters=frozenset({" | "})) name = Parser(policy=policy).parse("John Smith, |") assert (name.given, name.family, name.suffix) == ("John", "Smith", "|") def test_a_generational_connective_joins_no_title_after_the_comma() -> None: # P3's generational clause: a connective that is also # generational vocabulary joins only between two name words, and a # part read for credentials holds none, so the comma decision # freezes it as group freezes one with no name word beside it # (#617). The default class, 'i', is one letter, so the freeze # reaches it only in a run ('Rovira, Dr. i i', pinned by the # property grid's off-switch walk); a word of more letters shows # it in a plain join, which only caller vocabulary can spell. The # control is the same word as a plain connective, which joins the # titles. generational = Lexicon.default().add(conjunctions={"og"}, suffix_words={"og"}) plain = Lexicon.default().add(conjunctions={"og"}) name = Parser(lexicon=generational).parse("Smith, Mr. og Mrs.") assert (name.title, name.suffix) == ("Mr. Mrs.", "og") name = Parser(lexicon=plain).parse("Smith, Mr. og Mrs.") assert (name.title, name.suffix) == ("Mr. og Mrs.", "") ��������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/pipeline/test_extract.py���������������������������������0000664�0000000�0000000�00000025217�15273032717�0025277�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������import dataclasses import pytest from nameparser import parse from nameparser._lexicon import Lexicon from nameparser._pipeline._extract import extract_delimited from nameparser._pipeline._state import ParseState from nameparser._policy import Policy from nameparser._types import AmbiguityKind, Role, Span def _state(text: str, policy: Policy | None = None) -> ParseState: return ParseState(original=text, lexicon=Lexicon.empty(), policy=policy or Policy()) def test_double_quoted_nickname_extracted() -> None: # 0123456789012345678 out = extract_delimited(_state('John "Jack" Kennedy')) assert out.extracted == ((Role.NICKNAME, Span(6, 10)),) assert out.masked == (Span(5, 11),) def test_parenthesized_nickname_extracted() -> None: out = extract_delimited(_state("John (Jack) Kennedy")) assert out.extracted == ((Role.NICKNAME, Span(6, 10)),) def test_same_char_delimiter_needs_boundaries() -> None: # the apostrophe in O'Connor is not an opening quote out = extract_delimited(_state("Sean O'Connor")) assert out.extracted == () and out.masked == () def test_single_quoted_nickname_at_boundaries() -> None: # 01234567890123456789 out = extract_delimited(_state("John 'Jack' Kennedy")) assert out.extracted == ((Role.NICKNAME, Span(6, 10)),) def test_unbalanced_delimiter_left_literal_with_ambiguity() -> None: out = extract_delimited(_state('Jon "Nick Smith')) assert out.extracted == () and out.masked == () assert len(out.ambiguities) == 1 assert out.ambiguities[0].kind is AmbiguityKind.UNBALANCED_DELIMITER def test_no_spurious_unbalanced_from_role_overlapping_pairs() -> None: # #273: '“' closes the German „…“ pair but OPENS the English “…” # pair, and '»' closes «…» but opens the reversed »…« pair. A # delimiter character consumed by another pair's successful # extraction is literal for every other pair -- it must not # surface as an unbalanced-delimiter ambiguity. policy = Policy(nickname_delimiters=frozenset( {("“", "”"), ("„", "“"), ("«", "»"), ("»", "«")})) for text, inner in (("Hans „Hansi“ Müller", "Hansi"), ("Jean «Petit» Dupont", "Petit")): out = extract_delimited(_state(text, policy)) assert [text[s.start:s.end] for _, s in out.extracted] == [inner] assert out.ambiguities == () def test_mixed_conventions_both_extract_leftmost_wins() -> None: # Review finding (2026-07-19, three agents independently): with the # default #273 pairs, a string containing TWO delimited asides in # different conventions mis-extracted -- the earlier-sorted pair # claimed the other's close character ('“' closes „…“ but opens # “…”) as its own open and swallowed a bogus span across the real # boundary, silently dropping the legitimate match. The scan must # be position-driven: the leftmost genuine opener wins. text = "Hans „Erster“ und “Zweiter” Müller" out = extract_delimited(_state(text)) assert [text[s.start:s.end] for _, s in out.extracted] == [ "Erster", "Zweiter"] assert out.ambiguities == () def test_mixed_guillemet_directions_both_extract() -> None: text = "Jean «Petit» Dupont »Rex« Martin" out = extract_delimited(_state(text)) assert [text[s.start:s.end] for _, s in out.extracted] == [ "Petit", "Rex"] assert out.ambiguities == () def test_mixed_conventions_across_maiden_and_nickname_buckets() -> None: # same shape across ROLES: guillemets routed to maiden must not let # the reversed nickname pair steal the maiden close policy = Policy(maiden_delimiters=frozenset({("«", "»")})) text = "Jean «Dupont» Martin »Rex« Smith" out = extract_delimited(_state(text, policy)) got = {(role, text[s.start:s.end]) for role, s in out.extracted} assert got == {(Role.MAIDEN, "Dupont"), (Role.NICKNAME, "Rex")} assert out.ambiguities == () def test_extraction_and_genuine_unbalanced_coexist() -> None: # the kept path: a successful extraction must not suppress a REAL # dangling open elsewhere in the same string text = 'John (Jack) Smith "Extra' out = extract_delimited(_state(text)) assert [text[s.start:s.end] for _, s in out.extracted] == ["Jack"] assert [a.kind for a in out.ambiguities] == [ AmbiguityKind.UNBALANCED_DELIMITER] def test_same_char_bulk_unbalanced_filtered_by_other_pairs_mask() -> None: # the same-char forward-scan records EVERY dangling quote; one that # sits inside a region another pair successfully extracts is # literal content, not a dangling delimiter -- only the truly # dangling ones surface. All three quotes here fail the close-walk # (no quote is followed by a boundary), so the bulk pass records # all three; the middle one lands inside the paren match. # 01234567890123456789 text = 'Jon "A (x "y) Bob "C' out = extract_delimited(_state(text)) assert [text[s.start:s.end] for _, s in out.extracted] == ['x "y'] # quotes at 4 and 18 are genuinely dangling; the one at 10 was # consumed by the parenthesized extraction assert sorted(a.detail for a in out.ambiguities) == [ "unmatched '\"' at offset 18; treated as literal text", "unmatched '\"' at offset 4; treated as literal text", ] assert all(a.kind is AmbiguityKind.UNBALANCED_DELIMITER for a in out.ambiguities) def test_genuine_unbalanced_still_flagged_alongside_overlapping_pairs() -> None: # the suppression must not swallow REAL unbalanced opens: here the # German open has no close anywhere, and no other pair extracts policy = Policy(nickname_delimiters=frozenset( {("“", "”"), ("„", "“")})) out = extract_delimited(_state("Hans „Hansi Müller", policy)) assert out.extracted == () assert [a.kind for a in out.ambiguities] == [ AmbiguityKind.UNBALANCED_DELIMITER] def test_maiden_delimiters_route_to_maiden() -> None: policy = dataclasses.replace( Policy(), nickname_delimiters=frozenset({("'", "'"), ('"', '"')}), maiden_delimiters=frozenset({("(", ")")}), ) out = extract_delimited(_state("Jane Smith (Jones)", policy)) assert out.extracted == ((Role.MAIDEN, Span(12, 17)),) def test_empty_enclosure_is_masked_but_not_extracted() -> None: out = extract_delimited(_state("John () Kennedy")) assert out.extracted == () assert out.masked == (Span(5, 7),) def test_multiple_extracts_and_no_overlap() -> None: # 0123456789012345678901234 out = extract_delimited(_state('"Jack" John (Jonny) Kim')) roles = {span: role for role, span in out.extracted} assert roles == {Span(1, 5): Role.NICKNAME, Span(13, 18): Role.NICKNAME} def test_adjacent_pairs_extract_separately() -> None: out = extract_delimited(_state('John "A" "B" Kim')) assert len(out.extracted) == 2 def test_close_at_end_of_string() -> None: out = extract_delimited(_state('John "Jack"')) assert out.extracted == ((Role.NICKNAME, Span(6, 10)),) def test_unmatched_open_at_last_char() -> None: out = extract_delimited(_state('John "')) assert out.extracted == () assert len(out.ambiguities) == 1 def test_nested_delimiters_inner_scan_order_pins() -> None: # parens win over quotes when the quote chars sit flush against the # parens (their word-boundary test fails); the inner quote chars # flow into the extracted span verbatim -- v1 parity, deliberate out = extract_delimited(_state('John ("Jack") Kim')) assert out.extracted == ((Role.NICKNAME, Span(6, 12)),) @pytest.mark.parametrize(("text", "char"), [ ("John Smith)", ")"), ("John Jack) Smith", ")"), ('John Smith"', '"'), ("John Smith」", "」"), # a #273 typographic pair ]) def test_unmatched_close_is_reported(text: str, char: str) -> None: # the scan is opener-driven, so a close with no open to its left was # never in its search space and went unreported -- even though the # AmbiguityKind docstring promises "or closed without opening" ambiguities = parse(text).ambiguities assert [a.kind for a in ambiguities] == [ AmbiguityKind.UNBALANCED_DELIMITER] assert char in ambiguities[0].detail def test_word_final_apostrophe_is_not_an_unbalanced_delimiter() -> None: # ambiguity means the part could REASONABLY read two ways where it # sits. A "'" after a word character is an apostrophe -- it is the # one delimiter character that occurs inside and at the end of real # name parts, and the least likely to mark a nickname. The same # position with a quote IS ambiguous, because quotes do not appear # inside names. (#273 excluded the curly apostrophe from the # delimiters entirely for this reason; the straight one is a # delimiter, so it needs the carve-out here instead.) assert parse("Mari' Aube'").ambiguities == () assert parse("Ali Baba'").ambiguities == () quoted = parse('Mari" Aube"') assert [a.kind for a in quoted.ambiguities] == \ [AmbiguityKind.UNBALANCED_DELIMITER] * 2 def test_apostrophe_after_a_space_is_still_reported() -> None: # the carve-out is about what PRECEDES the character: an apostrophe # opening a quote is still an unmatched open when nothing closes it assert [a.kind for a in parse("John 'Jack Smith").ambiguities] == \ [AmbiguityKind.UNBALANCED_DELIMITER] @pytest.mark.parametrize("text", [ "Brian O'connor", # apostrophe mid-word, not a delimiter "O'B. John Smith", "The Queen's Bench", "John (Jack) Smith", # matched pair 'John "Jack" Smith', ]) def test_no_spurious_unmatched_close(text: str) -> None: assert [a for a in parse(text).ambiguities if a.kind is AmbiguityKind.UNBALANCED_DELIMITER] == [] def test_unmatched_open_is_not_double_reported_as_a_close() -> None: # a symmetric delimiter can satisfy both boundary tests; it must # still produce exactly one ambiguity assert len(parse('John " Smith').ambiguities) == 1 assert len(parse('Jon "Nick Smith').ambiguities) == 1 @pytest.mark.parametrize(("text", "expected"), [ ('Jon "Nick Smith', '"Nick'), ("John (Jack Smith", "(Jack"), ("John Smith)", "Smith)"), ("John Jack) Smith", "Jack)"), ]) def test_unbalanced_delimiter_points_at_its_token( text: str, expected: str ) -> None: # the kind was documented as possibly carrying no tokens, but the # stray character does survive into one -- without it the ambiguity # is locatable only by parsing an offset back out of the detail # string, which is not an API (amb,) = parse(text).ambiguities assert [t.text for t in amb.tokens] == [expected] ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/pipeline/test_group.py�����������������������������������0000664�0000000�0000000�00000305473�15273032717�0024766�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������import bisect import dataclasses from collections.abc import Sequence from typing import cast import pytest from nameparser import Parser, parse from nameparser._lexicon import Lexicon from nameparser._pipeline import _group as _group_module from nameparser._pipeline._classify import classify from nameparser._pipeline._extract import extract_delimited, _maiden_marked from nameparser._pipeline._group import ( ClauseSite, _group_segment, group, marker_run_length, ) from nameparser._pipeline._script_segment import script_segment from nameparser._pipeline._segment import segment from nameparser._pipeline._state import ( ParseState, Structure, WorkToken, ) from nameparser._pipeline._tokenize import tokenize from nameparser._pipeline._vocab import maiden_marker_run from nameparser._policy import Policy, Script from nameparser._types import AmbiguityKind, Role _LEX = Lexicon( titles=frozenset({"mr", "mrs", "secretary", "the", "state"}), suffix_acronyms=frozenset({"phd"}), suffix_words=frozenset({"jr"}), particles=frozenset({"de", "la", "van", "von", "und", "zu"}), particles_ambiguous=frozenset({"van"}), conjunctions=frozenset({"and", "of", "the", "y"}), bound_given_names=frozenset({"abdul"}), maiden_markers=frozenset({"née", "geb"}), ) # NOTE: "the" sits in both titles and conjunctions here, mirroring v1's # real vocabulary overlap; the subset rule only constrains particles. def _grouped(text: str, policy: Policy | None = None, lexicon: Lexicon | None = None) -> ParseState: state = ParseState(original=text, lexicon=lexicon or _LEX, policy=policy or Policy()) return group(classify(segment(tokenize(extract_delimited(state))))) def _piece_texts(state: ParseState) -> list[list[str]]: return [[" ".join(state.tokens[i].text for i in piece) for piece in seg] for seg in state.pieces] def _piece_has_tag(state: ParseState, piece: tuple[int, ...], tag: str) -> bool: return any(tag in state.tokens[i].tags for i in piece) def test_no_joins_pass_through() -> None: out = _grouped("John Smith") assert _piece_texts(out) == [["John", "Smith"]] def test_conjunction_joins_neighbors() -> None: out = _grouped("Mr. and Mrs. John Smith") assert _piece_texts(out) == [["Mr. and Mrs.", "John", "Smith"]] # joined-to-a-title piece is flagged a derived title assert "title" in out.piece_tags[0][0] def test_contiguous_conjunctions_join_first() -> None: out = _grouped("The Secretary of State Hillary Clinton") assert _piece_texts(out) == [["The Secretary of State", "Hillary", "Clinton"]] assert "title" in out.piece_tags[0][0] def test_single_letter_conjunction_prefers_initial_when_short() -> None: # v1 Google Code issue 11 ("john e smith"): 3 rootname parts, # single-letter conjunction "y" out = _grouped("John y Smith") assert _piece_texts(out) == [["John", "y", "Smith"]] def test_prefix_chain_joins_to_following() -> None: out = _grouped("Juan de la Vega") assert _piece_texts(out) == [["Juan", "de la Vega"]] def test_prefix_chain_absorbs_through_to_next_suffix() -> None: out = _grouped("Juan de la Vega Martinez PhD") assert _piece_texts(out) == [["Juan", "de la Vega Martinez", "PhD"]] def test_leading_prefix_is_never_chained() -> None: # "Van Johnson": the leading piece is a first name, not a particle out = _grouped("Van Johnson") assert _piece_texts(out) == [["Van", "Johnson"]] def test_a_title_does_not_make_the_particle_behind_it_non_leading() -> None: # #367: "leading" is the first piece of the NAME. A title is not # part of the name, so it is stepped over and the grouping is the # untitled one with the title in front of it. assert _piece_texts(_grouped("Mr. Van Johnson")) == \ [["Mr.", "Van", "Johnson"]] assert _piece_texts(_grouped("Mr. Van Johnson Smith")) == \ [["Mr.", "Van", "Johnson", "Smith"]] def test_a_title_that_is_also_a_particle_stops_the_scan() -> None: # the other half of the same rule, and the reason it is not spelled # "the first piece that is not a title": the default vocabulary # puts `freiherr`, `st` and `do` in BOTH sets, so any of them could # be the name's own first piece. Stepping over one would make the # particle behind it chain, and would break a name with no title in # front of it at all ("St John Smith" -> title St, given John, # family Smith). Spelled here as the overlap a CONFIG can create, # which is the same shape: tests/test_constants.py::test_add_title # adds `te` to the titles while `te` ships as a particle. overlap = dataclasses.replace(_LEX, titles=_LEX.titles | {"van"}) assert _piece_texts(_grouped("Van von Richthofen", lexicon=overlap)) == \ [["Van", "von Richthofen"]] assert _piece_texts(_grouped("Van Johnson Smith", lexicon=overlap)) == \ [["Van", "Johnson", "Smith"]] def test_a_suffix_shaped_leading_piece_is_not_stepped_over() -> None: # #367 steps over TITLE pieces only. Adding suffix pieces to that # scan survives the whole suite while changing what these names # parse to, so pin them here: with the skip, `leading` moves to # "Van", the chain loop passes over it, and the two pieces below # become three -- which puts Van in the middle name rather than the # family once roles exist. See _group.py for why that reading is # worse rather than merely different. # # "Ph. D. Van Johnson" left this test with #371: the pair is no # longer merged at the head, so there is no suffix-shaped leading # piece there to step over or not. # # `PhD` is the replacement witness and `II` is NOT one, which is # the trap this file's reduced lexicon sets: `_LEX` ships # suffix_acronyms={"phd"} and no roman numerals, so `II` is not a # suffix piece HERE however the shipped vocabulary reads it, and # the assertion below survives the very mutation this test is # named for. Measured: with suffix pieces added to the scan, # "PhD Van Johnson" moves and "II Van Johnson" does not. assert _piece_texts(_grouped("PhD Van Johnson")) == [["PhD", "Van Johnson"]] assert _piece_texts(_grouped("II Van Johnson")) == [["II", "Van Johnson"]] def test_the_phd_merge_declines_at_the_head_of_a_name() -> None: # A suffix never begins a name (S2, and #371). The merge is what # made a leading "Ph." "D." a credential at all, and it emptied the # family doing it. Segment 0 only: after a family comma the run # legitimately opens its segment, which C8 reads as a listing's credential run. assert _piece_texts(_grouped("Ph. D. Van Johnson")) == \ [["Ph.", "D.", "Van Johnson"]] # the part after the comma is bound by group's head (#613), which # builds the same merge for its reading, so no piece is left there assert _piece_texts(_grouped("Smith, Ph. D. Jr.")) == [["Smith"], []] assert parse("Smith, Ph. D. Jr.").suffix == "Ph. D. Jr." # segment 0 BEFORE a family comma is the family the comma named, # and v1 merges there too ("Ph. D., John" reads last 'Ph. D.'), so # the `not family_comma` half of the flag has its own witness -- # without it this name declines and the family splits. assert _piece_texts(_grouped("Ph. D., John")) == \ [["Ph. D."], ["John"]] # the first PIECE is not the first word: a quoted clause leaves the # token stream before grouping, so the pair reaches k == 0 with a # word ahead of it in the input. v1 merges (its regex needed a # preceding space); declining here broke parity on 112 names. assert _piece_texts(_grouped('"Bob" Ph. D. John Smith')) == \ [["Ph. D.", "John", "Smith"]] def test_von_und_zu_bridges() -> None: # conjunction "und" joins two prefixes; the joined piece is a derived # prefix and still chains onto the following name (v1 PR #191). # `und` is a CONNECTIVE here and no particle: _LEX lists it among # the particles, which walks the particle chain instead and passed # with P3's prefix derivation deleted (#617's test review) lexicon = _LEX.remove(particles={"und"}).add(conjunctions={"und"}) out = _grouped("Otto von und zu Habsburg", lexicon=lexicon) assert _piece_texts(out) == [["Otto", "von und zu Habsburg"]] def test_bound_given_joins_with_three_rootnames() -> None: assert _piece_texts(_grouped("abdul rahman al-said")) == \ [["abdul rahman", "al-said"]] # only two rootname pieces: no join (v1 reserve_last) assert _piece_texts(_grouped("abdul rahman")) == [["abdul", "rahman"]] def test_phd_split_across_tokens_merges_as_suffix() -> None: out = _grouped("John Smith Ph. D.") assert _piece_texts(out) == [["John", "Smith", "Ph. D."]] assert "suffix" in out.piece_tags[0][2] # continuation tokens of the merged piece carry the stable "joined" # tag so the suffix string view can heal the split (", " join would # otherwise render 'Ph., D.') d_tok = next(t for t in out.tokens if t.text == "D.") assert "joined" in d_tok.tags def test_maiden_marker_consumes_tail() -> None: out = _grouped("Jane Smith née Jones") assert _piece_texts(out) == [["Jane", "Smith"]] maiden = [t.text for t in out.tokens if t.role is Role.MAIDEN] assert maiden == ["Jones"] # the marker token itself is structural: dropped from assembly née_idx = next(i for i, t in enumerate(out.tokens) if t.text == "née") assert née_idx in out.dropped def test_maiden_marker_stops_at_suffix() -> None: # the take consumes the trailing run it ends at, roled (#601): the # PhD leaves the pieces with the clause, already a suffix out = _grouped("Jane Smith née Jones PhD") maiden = [t.text for t in out.tokens if t.role is Role.MAIDEN] assert maiden == ["Jones"] assert [t.text for t in out.tokens if t.role is Role.SUFFIX] == ["PhD"] assert _piece_texts(out) == [["Jane", "Smith"]] def test_leading_marker_is_not_consumed() -> None: # "née Jones" alone: marker at piece 0 has no name before it out = _grouped("née Jones") assert _piece_texts(out) == [["née", "Jones"]] _MAIDEN_PARENS = Policy(maiden_delimiters=frozenset({("(", ")")})) #: `nee` a marker AND `Nee` a surname in one vocabulary -- the collision #: the clause-size guard exists for. _LEX alone leaves `Nee` untagged, #: which would let the guard tests pass under every mutant. _NEE_LEX = _LEX.add(maiden_markers=frozenset({"nee"})) def test_delimited_marker_is_dropped() -> None: """#329: the marker IS tagged -- classify reaches it like any other token. What it never enters is `pieces`: extract claims the clause and its tokens carry Role.MAIDEN from tokenize, so segment keeps them out of the main stream. The #274 rule above walks pieces, so the tag alone does it no good.""" out = _grouped("Jane Smith (née Jones)", policy=_MAIDEN_PARENS) maiden = [t.text for i, t in enumerate(out.tokens) if t.role is Role.MAIDEN and i not in out.dropped] assert maiden == ["Jones"] née_idx = next(i for i, t in enumerate(out.tokens) if t.text == "née") assert née_idx in out.dropped def test_lone_delimited_marker_is_kept_when_a_clause_follows() -> None: """The clause-size guard, and it is load-bearing rather than defensive: `Nee` is a real surname (Irish Ni/Nee, and a Chinese romanization), so a one-token clause is a maiden NAME, not a marker. The trailing "(Jones)" is what makes this pin the guard. With "(Nee)" alone the marker is also the last token in the string, so a rule that merely checked for a following token would keep it too and the mutant would live. Here a token does follow -- only the CLAUSE bound distinguishes them.""" out = _grouped("Jane Smith (Nee) (Jones)", policy=_MAIDEN_PARENS, lexicon=_NEE_LEX) kept = [t.text for i, t in enumerate(out.tokens) if t.role is Role.MAIDEN and i not in out.dropped] assert kept == ["Nee", "Jones"] def test_marker_in_the_bare_form_is_left_to_the_piece_rule() -> None: """#274 sets Role.MAIDEN on consumed tokens itself, so a maiden role is NOT proof that extract produced it. Keying this pass on state.extracted spans is what keeps it off the bare path -- a neighbour test would eat the `Nee` here, the very surname the guard above exists to protect. Reads inert and is not: deleting the #329 pass outright leaves this green, because the value comes from #274's piece rule and the pass never touches the bare path. What it kills is a SPELLING of the pass -- drop a maiden marker whose next token is also maiden, the form this fix originally took -- and nothing else here covers the bare path against it. Checked both ways 2026-08-03.""" out = _grouped("Jane Smith nee Nee Jones", lexicon=_NEE_LEX) kept = [t.text for i, t in enumerate(out.tokens) if t.role is Role.MAIDEN and i not in out.dropped] assert kept == ["Nee", "Jones"] def test_every_delimited_marker_is_dropped_not_only_the_first() -> None: """Two maiden clauses land as ONE contiguous run of maiden-role tokens, so a rule keyed on the run would strip the first marker and keep the second. Each clause is scoped separately, so each loses its own leading marker.""" out = _grouped("Jane Smith (née Jones) (geb Braun)", policy=_MAIDEN_PARENS) kept = [t.text for i, t in enumerate(out.tokens) if t.role is Role.MAIDEN and i not in out.dropped] assert kept == ["Jones", "Braun"] def test_clause_containment_survives_script_segmentation() -> None: """The #329 pass finds the clause's first token by SPAN, and the comment on it rests that on script_segment only ever cutting a token into sub-slices. _grouped omits that stage, so this is the one place the two meet: 王小明 becomes 王 + 小明 before group runs, which shifts every token index after it while the spans stay exact, and the marker is still the token the clause drops.""" lex = Lexicon(surnames=frozenset({"王"}), maiden_markers=frozenset({"旧姓"})) policy = Policy(segment_scripts=frozenset({Script.HAN}), maiden_delimiters=frozenset({("(", ")")})) state = ParseState(original="王小明(旧姓 李四)", lexicon=lex, policy=policy) out = group(classify(script_segment(segment( tokenize(extract_delimited(state)))))) assert [t.text for t in out.tokens] == ["王", "小明", "旧姓", "李四"] kept = [t.text for i, t in enumerate(out.tokens) if t.role is Role.MAIDEN and i not in out.dropped] assert kept == ["李四"] def test_initials_do_not_count_as_rootnames_for_conjunction_carveout() -> None: # v1 parity: 'J.' is an initial, so total rootnames stay under 4 and # the single-letter conjunction 'y' is treated as an initial, not joined out = _grouped("J. Ruiz y Gomez") assert _piece_texts(out) == [["J.", "Ruiz", "y", "Gomez"]] def test_extra_suffix_delimiter_splits_tail_entries() -> None: # v1 expand_suffix_delimiter parity (#206): a configured delimiter # is transparent in a FAMILY_COMMA tail segment -- it separates # suffix ENTRIES (each rendered independently) and is itself # dropped, rather than becoming a suffix token or fusing the # entries on either side into one "joined" run. lex = Lexicon(suffix_acronyms=frozenset({"phd"}), suffix_words=frozenset({"jr", "v", "md"})) out = _grouped("Smith, John, V MD / PhD", Policy(extra_suffix_delimiters=frozenset({"/"})), lexicon=lex) # one tail SEGMENT, split into three suffix pieces: "/" is dropped # rather than surviving as its own piece assert _piece_texts(out) == [["Smith"], ["John"], ["V", "MD", "PhD"]] slash_idx = next(i for i, t in enumerate(out.tokens) if t.text == "/") assert slash_idx in out.dropped # The tagging half moved to post_rules with #436: the core is # dropped HERE, and the entry it separates is decided there, over # the spans. test_a_dropped_core_parts_two_entries in # tests/v2/pipeline/test_post_rules.py is the assertion that used # to sit on the two lines below this one. # group writes no between-piece tag at all since #436, and a # re-add here would glue the run across the dropped core. assert not any("joined" in t.tags for t in out.tokens) def test_suffix_comma_name_segment_gets_no_additional_count() -> None: # v1 parity: additional_parts_count applies to FAMILY_COMMA parts only; # ', PhD' must not tip the single-letter-conjunction carve-out out = _grouped("John y Smith, PhD") # the credential part is bound at group's head (#613) assert _piece_texts(out) == [["John", "y", "Smith"], []] _DUAL_LEX = dataclasses.replace( _LEX, bound_given_names=frozenset({"abdul", "dual"}), suffix_acronyms=frozenset({"phd", "dual"})) def test_a_bound_word_in_two_vocabularies_still_joins() -> None: # The reserve counts the pieces left to spare, and the bound word's # own piece is not one of them to spare -- it is the piece the rule # has already claimed. Counting it as a suffix, because the same # word is ALSO suffix vocabulary, left the rule silently unable to # fire: three name words, but only two counted, one short of the # threshold. This is what kept 'abd' out of BOUND_GIVEN_NAMES; the # shipped word is exercised in tests/test_bound_given_names.py. # Synthetic word, so this pins the MECHANISM -- 'abd' is the only # shipped member of the intersection, and removing it from the # vocabulary leaves this test green. out = _grouped("dual Allah Smith", lexicon=_DUAL_LEX) assert _piece_texts(out) == [["dual Allah", "Smith"]] def test_the_reserve_still_declines_when_only_a_suffix_is_left() -> None: # The counted piece is the bound word's own, and NOTHING else that # a suffix check excludes: with 'jr' behind it there is no family # name to spare, so the join must still decline. The tempting # simpler repair -- count every non-title piece -- passes the test # above and fails this one, joining 'dual Allah' and leaving the # name with no family at all. out = _grouped("dual Allah jr", lexicon=_DUAL_LEX) assert _piece_texts(out) == [["dual", "Allah", "jr"]] def test_a_chain_never_leaves_a_marker_standing_alone() -> None: """A marker standing as its own piece never follows a piece carrying a particle -- the invariant the ORDER buys, pinned at the piece level because the fields cannot see it break: a stranded marker is a lone trailing piece, which takes the family field and demotes the real surname to the middle, and every field still holds something plausible. The consumer runs before the chain, so a marker still standing when the chain arrives is one the consumer declined, and the chain takes it as the word M2 says it is. Either the consumer took the marker (gone from pieces altogether) or the chain did. What breaks this is the pass moving back behind the chain, or a stop on the chain reintroduced: #399's stop restated the consumer's condition and disagreed with it one suffix later (#417 -- the 'née Jr Jones' row below, which the old list did not carry). A marker DOES stand alone where no chain reached it, which is M2's own "Jones nee" -> family "nee" boundary, so the assertion is keyed on the preceding piece rather than on markers as such. """ for text in ("Jane van der Berg née Jones", # consumer takes "Jane van der Berg née", # nothing follows "Jane van der Berg née Jr", # only a suffix "Jane van der Berg née Jr Jones", # suffix, then a word (#417) "Jane van der née", # particles only "Jane Smith née Jones", # no particle: takes "Jane Smith née", # no particle: stands "Jane née", # The bound-given join also produces multi-word # pieces, and it may leave a marker standing after # one -- 'abdul Berg' + 'née'. That is M2's allowed # boundary, not a stranding, which is why this # invariant is keyed on the PARTICLE chain rather # than on any joined neighbour. Listed so the # distinction is exercised rather than assumed. "abdul Berg née", "abdul Berg née Jones", "abdul née Jones", "abdul née"): out = _grouped(text) for seg, texts in zip(out.pieces, _piece_texts(out)): for k, (piece, shown) in enumerate(zip(seg, texts)): if shown.lower() not in _LEX.maiden_markers or k == 0: continue assert not _piece_has_tag(out, seg[k - 1], "particle"), ( f"{text!r}: marker {shown!r} left standing after " f"the particle piece {texts[k - 1]!r}") def test_where_the_marker_lands_when_the_consumer_declines() -> None: """The concrete shapes behind the invariant above, so a change that preserves it by restructuring the pieces still has to say so here. """ # consumer takes: marker and maiden name leave `pieces` entirely assert _piece_texts(_grouped("Jane van der Berg née Jones")) == [ ["Jane", "van der Berg"]] assert _piece_texts(_grouped("Jane Smith née Jones")) == [ ["Jane", "Smith"]] # consumer declines, chain present: the marker rides inside it assert _piece_texts(_grouped("Jane van der Berg née")) == [ ["Jane", "van der Berg née"]] assert _piece_texts(_grouped("Jane van der Berg née Jr")) == [ ["Jane", "van der Berg née", "Jr"]] # #417: a name word BEHIND the inner suffix does not change the # consumer's answer (its walk stops at the suffix), so the marker # still rides inside the chain rather than standing between the # chain and the suffix. The parsed fields read the same either # way -- middle 'van der Berg née Jr', family 'Jones' -- which is # why this is pinned at the piece level. assert _piece_texts(_grouped("Jane van der Berg née Jr Jones")) == [ ["Jane", "van der Berg née", "Jr", "Jones"]] # ...and a chain carrying a declined marker is a name piece like # any other to the bound-given join (P5 declines a marker "standing # as a word of its own", and this one is not) -- but since #602 the # 'Jr' behind it starts a credential run to the end of the part # (rules/S8), so the reserve finds no name word to spare and the # join declines for that reason instead assert _piece_texts(_grouped("abdul van der Berg née Jr Jones")) == [ ["abdul", "van der Berg née", "Jr", "Jones"]] # consumer declines, no chain: the marker stands as its own piece assert _piece_texts(_grouped("Jane Smith née")) == [ ["Jane", "Smith", "née"]] def test_the_connective_carveout_counts_the_surviving_name() -> None: # P3's carve-out asks how many name words the name has, # and the marker and the maiden name are not among them: they # leave. Counted, 'juan y garcia née jones' was five words, 'y' # joined, and once the clause left there was no family name (#418). assert _piece_texts(_grouped("juan y garcia née jones")) == [ ["juan", "y", "garcia"]] # the control: the clause changes nothing about the rest assert _piece_texts(_grouped("juan y garcia")) == [ ["juan", "y", "garcia"]] # a marker the consumer DECLINES is a word (M2) and counts: four # words, so the connective joins assert _piece_texts(_grouped("juan y garcia née")) == [ ["juan y garcia", "née"]] # a marker straight after a connective is an ordinary word (#601's # head rule), so 'Jane and née Jones' groups as 'Jane and Zee # Jones' does. Until #601 the marker was taken there and the #418 # exposure was the three-piece gate counting it. assert _piece_texts(_grouped("Jane and née Jones")) == [ ["Jane and née", "Jones"]] assert _piece_texts(_grouped("Jane and Zee Jones")) == [ ["Jane and Zee", "Jones"]] # " | ", not " - ": a hyphen standing alone is built in (#889) _DASH = Policy(extra_suffix_delimiters=frozenset({" | "})) def test_a_delimiter_core_in_a_suffix_tail_is_not_maiden_text() -> None: """A tail segment is cut at its delimiter cores before the marker pass, and the cores are dropped (#206, #549). A core is not a word the marker can take, and the marker cannot take across one: 'PhD née | Jones' reads as 'PhD née, Jones' does, the marker ending its part with nothing behind it, so there is no clause.""" out = _grouped("Smith, John, PhD née | Jones", policy=_DASH) core = next(i for i, t in enumerate(out.tokens) if t.text == "|") assert core in out.dropped assert not any(t.role is Role.MAIDEN for t in out.tokens) twin = _grouped("Smith, John, PhD née, Jones") assert not any(t.role is Role.MAIDEN for t in twin.tokens) def test_a_marker_in_a_suffix_part_is_a_word() -> None: # A marker counts only in the part holding the family name # (rules/M2, #601), so in a tail part it takes nothing, core or # no core. This was the #549 test that a trailing core is cut # before the walk, and the walk no longer runs in a tail part. out = _grouped("Smith, John, PhD née Jones V |", policy=_DASH) assert not any(t.role is Role.MAIDEN for t in out.tokens) def test_a_core_is_screened_before_the_marker_looks_for_a_word_ahead() -> None: """M2 needs a name word BEFORE the marker. A core is not one, so a marker standing behind nothing but a core is a leading marker and is not taken -- the core, which is not the segment's only token, parts the segment there and is dropped (#549).""" out = _grouped("Smith, John, | née Jones", policy=_DASH) assert not any(t.role is Role.MAIDEN for t in out.tokens) core = next(i for i, t in enumerate(out.tokens) if t.text == "|") assert core in out.dropped assert _piece_texts(out)[2] == ["née", "Jones"] def test_no_join_reaches_a_taken_marker() -> None: """A marker the consumer takes is gone before any join looks, so there is no piece list on which a join could absorb it (#412). Pinned per shape the connective join reached the marker through; the particle chain on its own is pinned in test_where_the_marker_lands_when_the_consumer_declines.""" # connective after the marker, behind a chain (#412's headline) assert _piece_texts(_grouped("Jane van der Berg née y Jones")) == [ ["Jane", "van der Berg"]] # connective before the marker: the marker is an ordinary word # there (#601), and groups as any word would -- 'Jane Smith and # Zee Jones' reads the same. Until #601 the marker was taken and # this read 'Jane', 'Smith and'. assert _piece_texts(_grouped("Jane Smith and née Jones")) == [ ["Jane", "Smith and née", "Jones"]] # marker-headed: the connective is the first word the marker takes assert _piece_texts(_grouped("Jane née and Jones Smith")) == [ ["Jane"]] def test_the_bound_given_join_never_absorbs_a_marker() -> None: """P5 joins the bound word to "the word after it", and a maiden marker is not a name word -- it announces that another name follows. Joining one merged the marker into the given name and left M2 nothing to find, which was the P5 half of the join-swallow #412 records. With the marker pass ahead of the joins, a marker the consumer takes is gone before P5 looks, so the taken-marker rows below hold by construction. The guard's live shape is a DECLINED marker standing directly after the bound word with the reserve satisfied -- 'Berg, abdul née Jr', 'Berg, abdul née PhD', 'Berg, abdul née' -- where only the guard keeps the join from reading given 'abdul née'. Asserted at the piece level because the field reading can look perfectly ordinary while this is wrong: the shipped-vocabulary twin of the first case below read given 'abdul nee' with a plausible family name beside it. """ for text in ("abdul née Jones", "abdul née Jones Jr Berg Smith", "abdul née", "abdul née Jr", "Berg, abdul née Jones", "Berg, abdul née Jr"): out = _grouped(text) for seg, texts in zip(out.pieces, _piece_texts(out)): for piece, shown in zip(seg, texts): # Keyed on the piece carrying the BOUND word, not on # width. "no wide piece holds a marker" would be a # different and false claim -- the particle chain # builds exactly that, and the test above pins it # ("Jane van der Berg née" -> ['Jane', # 'van der Berg née']). if not _piece_has_tag(out, piece, "vocab:bound-given"): continue assert not _piece_has_tag( out, piece, "vocab:maiden-marker"), ( f"{text!r}: the join absorbed a marker into " f"the piece {shown!r}") _GIVEN_NAME_TITLE_LEX = dataclasses.replace( _LEX, titles=_LEX.titles | {"sir"}, given_name_titles=frozenset({"sir"})) def test_a_given_name_title_licenses_the_bound_join_with_one_word_to_spare() -> None: # rules/P5 (#369): a given-name title asserts that a given name # follows -- the same assertion H1 reads when it keeps "Sir John" a # given name -- so behind one the reserve is the post-comma one, # and two name words join where STRICT keeps the second back as # the family name. out = _grouped("sir abdul rahman", lexicon=_GIVEN_NAME_TITLE_LEX) assert _piece_texts(out) == [["sir", "abdul rahman"]] def test_a_plain_title_keeps_the_strict_reserve() -> None: # The licence is the given-name title's, not any title's: "mr" # addresses by family, so the second word is still the family # name the reserve exists to keep. out = _grouped("mr abdul rahman", lexicon=_GIVEN_NAME_TITLE_LEX) assert _piece_texts(out) == [["mr", "abdul", "rahman"]] def test_the_licence_still_needs_two_name_words() -> None: # A suffix is still no word to spare: LENIENT needs two name # words and "jr" is not one, exactly as after a family comma. # (The count is what this witnesses; the decline below, which # #421 will make general, fires on a different shape.) out = _grouped("sir abdul jr", lexicon=_GIVEN_NAME_TITLE_LEX) assert _piece_texts(out) == [["sir", "abdul", "jr"]] def test_the_title_run_is_read_as_h1_reads_it() -> None: # P5 and post_rules ask ONE predicate of the run # (_run_addresses_by_given), so they cannot disagree about what the # same run asserts: if P5 joined where H1 then read the joined # piece as the family name, one run would have two readings. The # run "mr sir" is #369's own example and it moved with #489 -- the # whole run is no entry, its LAST word is, so the run addresses by # given name and the licence fires. out = _grouped("mr sir abdul rahman", lexicon=_GIVEN_NAME_TITLE_LEX) assert _piece_texts(out) == [["mr", "sir", "abdul rahman"]] def test_the_licence_joins_a_word_not_a_particle_chain() -> None: # P5 joins "the word after it", and the licence lifts the reserve # for two WORDS. A particle chain is one piece but not one word -- # it is the family name P2 built -- so behind a given-name title # the bound word must not absorb it: untitled, "abdul van der # Berg" keeps the chain as its family, and the title cannot make # the surname vanish. Found in review; the rules example carries # the shape into the rules corpus, so the snapshot watches it too. out = _grouped("sir abdul van der Berg", lexicon=_GIVEN_NAME_TITLE_LEX) assert _piece_texts(out) == [["sir", "abdul", "van der Berg"]] def test_the_licence_takes_a_name_word_not_a_suffix() -> None: # P5 joins "the word after it", and a suffix is not a name word. # The lowered reserve must not open to titled names the # absorb-a-suffix shape #421 records for the post-comma LENIENT # path: "abdul Jr rahman" keeps 'Jr' out of the given name behind # a plain title, and a given-name title cannot change that. (#421 # will make the decline general; until then it is the licence's.) out = _grouped("sir abdul jr rahman", lexicon=_GIVEN_NAME_TITLE_LEX) assert _piece_texts(out) == [["sir", "abdul", "jr", "rahman"]] def test_a_conjunction_joined_title_run_is_keyed_over_every_token_of_the_piece( ) -> None: # A conjunction-merged title is one multi-token PIECE. The key is # built from every token of every title piece, as post_rules # builds it from every title token; keyed on first tokens alone, # "sir and mrs" would read as "sir", the join would fire, and H1 # would then read the joined pair as the family. out = _grouped("sir and mrs abdul rahman", lexicon=_GIVEN_NAME_TITLE_LEX) assert _piece_texts(out) == [["sir and mrs", "abdul", "rahman"]] def test_the_licence_still_declines_a_lone_marker() -> None: # The lowered reserve changes the count, not what the join may # take: a marker the consumer declined (nothing after it) is no # name word behind a given-name title either, as after a comma. out = _grouped("sir abdul née", lexicon=_GIVEN_NAME_TITLE_LEX) assert _piece_texts(out) == [["sir", "abdul", "née"]] # -- #401 / #421: the reserve and the join agree about what a name word is def test_the_reserve_counts_a_trailing_numeral_as_the_suffix_assign_reads() -> None: # rules/P5 says the reserve "needs a name word to spare". A bare # 'V' carries both vocab:suffix and the initial tag, so the # suffix-piece test vetoes it -- right for assign's middle-initial # question, wrong here: assign reads a FINAL roman numeral after a # non-initial piece as the suffix (S2's fork), so the family the # reserve believed it was sparing was never there. 'abdul Smith V' # read given 'abdul Smith', family '' (#401). out = _grouped("abdul Smith V") assert _piece_texts(out) == [["abdul", "Smith", "V"]] def test_a_numeral_that_is_not_last_is_a_name_word() -> None: # The mirror is exact: assign's fork takes only the LAST piece, so # a numeral with a suffix behind it is a middle initial to assign # and a name word to the reserve -- 'abdul Smith V jr' joins, and # the family assign then reads is 'V', not nothing. out = _grouped("abdul Smith V jr") assert _piece_texts(out) == [["abdul Smith", "V", "jr"]] def test_the_numeral_is_read_after_a_suffix_word_that_is_also_a_title() -> None: # assign's fork asks only that the numeral not be the first NAME # piece; 'jr' is title vocabulary as well as a suffix, so a guard # phrased as "the piece before it is not a title" loses the # family again: 'abdul Smith jr V' read given 'abdul Smith', # suffix 'jr, V'. Found by the design-docs review. The shipped # vocabulary has jr in TITLES; the test lexicon gets the same. out = _grouped("abdul Smith jr V", lexicon=_LEX.add(titles={"jr"})) assert _piece_texts(out) == [["abdul", "Smith", "jr", "V"]] def test_the_reserve_counts_the_names_the_read_left_before_a_numeral() -> None: # History: the reserve once re-read the fork over the joined view, # where the initial-shaped 'J.' no longer stood before the # numeral, and declined; 2.2 and 2.3 read middle 'J.'. # # Since #614 the reserve reads no view: S2's run is read once # before the join, and here it takes nothing -- the initial-shaped # 'J.' suppresses the fork -- so three name words stand and the # join leaves two, which the reserve allows (decisions/P5, # 2026-10-07). The join no longer re-reads the fork it would move. out = _grouped("abdul J. V") assert _piece_texts(out) == [["abdul J.", "V"]] def test_the_numeral_is_last_among_the_pieces_assign_keeps() -> None: # assign drops a group-flagged credential piece (the Ph. D. merge) # from its walk at ANY position before the trailing peel, so a # numeral can be last in that walk without being the last piece. # A one-letter numeral with the merged credential behind it is a # name word, as it is behind 'PhD' (rules/S10, #752), so the bound # given-name join has the three name words it needs and joins as it # does for 'abdul Smith V PhD'. (Until #752 the walk read the V as # the generation here, which kept the pair apart: the first fix of # this test's defect, found by the code review, pinned that.) out = _grouped("abdul Smith V Ph. D.") assert _piece_texts(out) == [["abdul Smith", "V", "Ph. D."]] assert (_piece_texts(_grouped("abdul Smith V PhD")) == [["abdul Smith", "V", "PhD"]]) def test_the_numeral_fork_is_not_mirrored_after_a_family_comma() -> None: # The post-comma walk has no roman-numeral fork -- assign reads a # trailing 'V' there by a lenient last-of-two rule, and as a middle # initial with a third part -- so the LENIENT reserve counts suffix # pieces only, as it always did: 'Berg, abdul V' keeps given # 'abdul V' at every baseline, and so does 'Berg, abdul V, jr'. # Found by the code review; a mirror of the main walk's fork here # declined both with nothing classifying the change. out = _grouped("Berg, abdul V") assert _piece_texts(out) == [["Berg"], ["abdul V"]] out = _grouped("Berg, abdul V, jr") assert _piece_texts(out) == [["Berg"], ["abdul V"], ["jr"]] def test_the_join_never_absorbs_a_suffix_piece() -> None: # rules/P5 says the join takes "the word after it"; a suffix is # not a name word -- the same decline the join already makes for a # marker. The reserve counted only non-suffix pieces, so 'abdul jr # Jones' declined, but with a word to spare the join took the # suffix as the word: 'abdul jr Smith Berg' read given 'abdul jr' # (#421; 1.4.0 parity, not a regression). out = _grouped("abdul jr Smith Berg") assert _piece_texts(out) == [["abdul", "jr", "Smith", "Berg"]] def test_the_join_never_absorbs_a_split_credential() -> None: # The worse half of #421, a 2.0 regression: merge_pieces unions piece # tags, so joining onto the 'Ph. D.' piece made the joined piece a # SUFFIX piece and assign routed the bound word to the suffix # field -- 'abdul Ph. D. Smith Berg' read suffix 'abdul Ph. D.'. # The credential piece carries the suffix ptag, which is the first # thing the suffix-piece test asks. out = _grouped("abdul Ph. D. Smith Berg") assert _piece_texts(out) == [["abdul", "Ph. D.", "Smith", "Berg"]] assert "suffix" not in out.piece_tags[0][0] def test_the_join_declines_a_suffix_after_a_family_comma_too() -> None: # The post-comma LENIENT reserve is the path decisions/P5 first # recorded the absorb-a-suffix shape on ('Berg, abdul jr Smith' # read given 'abdul jr'); the decline is the join's, so it holds # under every reserve. out = _grouped("Berg, abdul jr Smith") assert _piece_texts(out) == [["Berg"], ["abdul", "jr", "Smith"]] # -- #425: the reserve runs assign's peel over the post-join view _AMBIGUOUS_LEX = _LEX.add(suffix_acronyms={"ma"}, suffix_acronyms_ambiguous={"ma"}) def test_the_reserve_mirrors_the_bare_acronym_fork() -> None: # S4's other positional fork: a bare ambiguous acronym is peeled # only with a given AND a family left. Read over the joined view # 'abdul Smith Jr Ma' is three pieces, so assign peels the acronym, # then the suffix, and one piece remains -- no family. The reserve # now runs that same peel over the view and declines (#425); it # used to count 'Ma' as a name word and join. # # MOVED by #289, then back by #544: 'Ma' is Title-case in a # mixed-case name, so its writing leans SURNAME, but the # unambiguous 'jr' IN FRONT of it anchors it (the peel's # `credential_anchors`), so the peel takes both unjoined. Joined, # the view would be two name pieces and the family would be gone, # so the reserve declines the join again, as it did before #289. out = _grouped("abdul Smith jr Ma", lexicon=_AMBIGUOUS_LEX) assert _piece_texts(out) == [["abdul", "Smith", "jr", "Ma"]] def test_the_join_never_turns_a_suffix_into_a_name() -> None: # Unjoined, 'abdul Smith Ma' has words to spare and the peel reads # the acronym as a credential; joined, the view is two pieces and # the fork would keep it as the family. The join joins two name # words and changes nothing else, so it declines -- 1.4.0's # reading, and 'John Smith Ma's. With a family behind it the # acronym peels either way, and the join stands. # # MOVED by #289, not deleted: 'Ma' now leans SURNAME (Title case, # mixed-case name) on BOTH sides of the join, so the two views' # suffix readings still agree and the join stands -- 'abdul Smith' # given, 'Ma' family (decisions/S6's corpus row). out = _grouped("abdul Smith Ma", lexicon=_AMBIGUOUS_LEX) assert _piece_texts(out) == [["abdul Smith", "Ma"]] out = _grouped("abdul Smith Berg Ma", lexicon=_AMBIGUOUS_LEX) assert _piece_texts(out) == [["abdul Smith", "Berg", "Ma"]] def test_the_join_never_turns_a_name_into_a_suffix_either() -> None: # The same rule from the other side: 'abdul V' is two pieces # whose V the peel reads as the suffix; joined it would be one # piece the fork cannot fire on, so the V would become a name # word. The peel takes the V unjoined and nothing joined -- the # join declines. (Of the numeral pins above, 'abdul J. V' # declines by this comparison; 'abdul Smith V' by the threshold, # one name word being no family to spare.) out = _grouped("abdul V") assert _piece_texts(out) == [["abdul", "V"]] def test_the_joined_pair_is_a_given_name_whatever_tag_the_word_carried() -> None: # A title word standing in the name is a name word (H3) and the # join takes it as v1 did -- but the conjunction merge derives a # `title` piece tag for "mr and mrs", and merge_pieces' tag union # would hand that tag to the joined pair, which assign then peels # as a leading title: 'abdul Sir and Ahmad Bakar Smith' read # title 'abdul Sir and Ahmad' on 2.0 and 2.1 (written with `Sheikh`, # a title then), and the shorter 'abdul Sir and Ahmad Bakar' would have too once the count's # title exclusion went. The bound join drops the tag: the pair is # a given name. Found by the code review. out = _grouped("abdul mr and mrs Smith Berg") assert _piece_texts(out) == [["abdul mr and mrs", "Smith", "Berg"]] assert "title" not in out.piece_tags[0][0] def test_a_title_word_in_the_name_is_a_name_word_to_the_join() -> None: # The 2.x reserve had excluded title pieces from its count, an # unrecorded deviation from v1, which joined them; assign reads a # mid-name title word as a name word, and the shared peel follows # assign. 1.4.0 parity on every shape here. out = _grouped("abdul mr Smith Berg") assert _piece_texts(out) == [["abdul mr", "Smith", "Berg"]] out = _grouped("abdul Smith mr") assert _piece_texts(out) == [["abdul Smith", "mr"]] out = _grouped("Berg, abdul mr") assert _piece_texts(out) == [["Berg"], ["abdul mr"]] def test_the_licence_joins_where_the_read_took_nothing() -> None: # 'sir abdul J. V' under a given-name title: the licence's # threshold of one. Before #614 the join was declined here, # because over the joined view the numeral fork moved (unjoined # the V was a name word, joined it was the suffix). Since #614 # there is no joined view to compare: the run, read once before # the join, takes nothing, and the join stands, as for 'abdul J. # V' (test_the_reserve_counts_the_names_the_read_left_before_a_ # numeral, decisions/P5 2026-10-07). out = _grouped("sir abdul J. V", lexicon=_GIVEN_NAME_TITLE_LEX) assert _piece_texts(out) == [["sir", "abdul J.", "V"]] # -- #424: the chain and the maiden walk stop where assign's peel begins def test_the_chain_stops_before_the_numeral_assign_reads_as_the_suffix() -> None: # P2's chain ran "until a trailing suffix begins" and asked with # the suffix-piece test, which vetoes a bare 'V' as an initial -- # the #401 question at a third site: 'John van der Berg V' read # family 'van der Berg V'. The chain now stops where the S2 peel, # read over the pieces as they stand, begins the trailing run. out = _grouped("John van der Berg V") assert _piece_texts(out) == [["John", "van der Berg", "V"]] # the stop is kept as a length from the end, so a second chain # ahead of it stops there too (the test review's surviving mutant) out = _grouped("John van der Berg de la Vega V") assert _piece_texts(out) == [ ["John", "van der Berg", "de la Vega", "V"]] out = _grouped("John van der Berg jr") assert _piece_texts(out) == [["John", "van der Berg", "jr"]] def test_the_chain_keeps_an_acronym_assign_will_not_peel() -> None: # Behind a word in both the title and particle vocabularies the # chain's leading position sits (P4, #367, `chain_lead`) inside # assign's title run, so the chain takes the name's first word: read over # the pieces as they stand the acronym has three pieces to spare, # read over the pieces the chain leaves it has two, and assign # would make it the family ('Freiherr von Berg Ma' read given 'von # Berg', family 'Ma' -- 1.4.0's reading, and the reviews' find). # The chain asks the peel again over what it leaves, and takes the # acronym assign will not peel. lex = _AMBIGUOUS_LEX.add(titles={"st"}, particles={"st"}) out = _grouped("St van Berg Ma", lexicon=lex) assert _piece_texts(out) == [["St", "van Berg Ma"]] # MOVED by #289, not deleted: with a given word of its own the # three pieces used to survive the chain (words to spare read 'Ma' # as a credential); now 'Ma' leans SURNAME (Title case, mixed-case # name) and the peel declines it regardless of the count, so the # second re-ask absorbs it into the particle run too # (decisions/S6). out = _grouped("St John van Berg Ma", lexicon=lex) assert _piece_texts(out) == [["St", "John", "van Berg Ma"]] def test_the_chain_keeps_a_numeral_the_peel_does_not_take() -> None: # The fork is assign's: a numeral after an initial-shaped piece is # a name word, so the chain takes it as before -- as 'John J. V' # reads the V as a name. And a numeral with a suffix behind it is # not last in the walk. out = _grouped("John van der J. V") assert _piece_texts(out) == [["John", "van der J. V"]] out = _grouped("John van der Berg V jr") assert _piece_texts(out) == [["John", "van der Berg V", "jr"]] def test_the_chain_stops_before_a_bare_acronym_with_words_to_spare() -> None: # S4's other fork, the same way: 'John Smith Ma' peels the acronym # as a credential, so 'John van der Berg Ma' does too -- 1.4.0 read # suffix 'Ma' there, and 2.0 had let the chain take it. # # MOVED by #289, not deleted: 'Ma' is Title-case in a mixed-case # name, so it now leans SURNAME and the chain's re-ask no longer # stops before it -- 'Ma' joins the particle run instead # (decisions/S6's corpus row). out = _grouped("John van der Berg Ma", lexicon=_AMBIGUOUS_LEX) assert _piece_texts(out) == [["John", "van der Berg Ma"]] def test_the_maiden_walk_stops_before_the_numeral_too() -> None: # M2's walk takes the words after the marker "up to any trailing # suffix", asked with the same test: 'John née Jones Smith V' took # the V into the maiden name. Read by the peel from the marker on, # the V is the suffix, and the walk stops before it. # Since #601 the take consumes the numeral it ends at, already a # suffix -- and finds it by the fork's SHAPE test, which asks no # vocabulary: this module's lexicon lists no 'v' at all. # 'Jane Doe' rather than 'John' since #718: a one-letter numeral # needs words to spare (rules/S10), and 'John V' has none, so # there the V is no suffix and the clause keeps it (rules/M2). out = _grouped("Jane Doe née Jones Smith V") assert [t.text for t in out.tokens if t.role is Role.MAIDEN] == \ ["Jones", "Smith"] assert [t.text for t in out.tokens if t.role is Role.SUFFIX] == ["V"] assert _piece_texts(out) == [["Jane", "Doe"]] out = _grouped("John née Jones Smith V") assert [t.text for t in out.tokens if t.role is Role.MAIDEN] == \ ["Jones", "Smith", "V"] def test_the_maiden_walk_keeps_the_acronym_its_writing_declines( ) -> None: # The walk asks the acronym fork the way it asks the numeral one # (#533): the peel over the pieces as they stand, then again over # the name the take would leave. 'Ma' is Title case in a # mixed-case name, so the peel declines it either way and the # clause keeps it -- the same answer this test pinned when the # fork was left to assign, for a different reason. The WRITING # keeps the word, not the count. out = _grouped("John née Jones Smith Ma", lexicon=_AMBIGUOUS_LEX) assert [t.text for t in out.tokens if t.role is Role.MAIDEN] == \ ["Jones", "Smith", "Ma"] # and the capitals go the other way, which is what makes the row # above a reading rather than a floor out = _grouped("John née Jones Smith MA", lexicon=_AMBIGUOUS_LEX) assert [t.text for t in out.tokens if t.role is Role.MAIDEN] == \ ["Jones", "Smith"] # The numeral half is untouched: the acronym BETWEEN the maiden # name and the numeral still declines, and the walk still stops at # the numeral fork (the test review's surviving mutant). out = _grouped("Jane Smith née Jones Ma V", lexicon=_AMBIGUOUS_LEX) assert [t.text for t in out.tokens if t.role is Role.MAIDEN] == \ ["Jones", "Ma"] # -- #533: the acronym fork, the reader, the clamp and the emitter def _maiden_texts(state: ParseState) -> list[str]: return [t.text for t in state.tokens if t.role is Role.MAIDEN] def _suffix_forks(state: ParseState) -> list[str]: return [a.detail for a in state.ambiguities if a.kind is AmbiguityKind.SUFFIX_OR_NAME] def test_the_clause_stops_before_a_credential_the_reader_takes( ) -> None: out = _grouped("Jane Doe née Smith MA", lexicon=_AMBIGUOUS_LEX) assert _maiden_texts(out) == ["Smith"] def test_the_clause_never_gives_up_the_first_word_after_the_marker( ) -> None: """The first word after the marker is the maiden name whatever it is, the marker having announced one. Since #601 that word is never in the view the trailing run is read over, so it cannot be taken -- the CLAMP this test pinned, for a peel that consumed it and the words behind it, has nothing left to clamp.""" out = _grouped("Jane Doe née MA", lexicon=_AMBIGUOUS_LEX) assert _maiden_texts(out) == ["MA"] def test_the_clamped_stop_may_land_on_no_member_and_declines() -> None: """The clamp can move the stop onto a piece that is no class member at all, and then the test declines and nothing changes -- the walk stopping at that suffix word of its own accord.""" out = _grouped("Jane Doe née MA Jr", lexicon=_AMBIGUOUS_LEX) assert _maiden_texts(out) == ["MA"] def test_the_view_check_asks_about_the_member_and_not_about_the_run( ) -> None: """The take would leave 'JOHN MA PHD', whose peel takes 'PHD' and then declines 'MA' for want of words to spare. A check asking whether the reader takes SOMETHING answers yes there, and the member becomes the FAMILY name.""" # NOTE the accented marker: this module's `_LEX` ships # maiden_markers={"née", "geb"} and NOT the unaccented "nee", so # the plain spelling takes nothing at all here and the test would # pass vacuously. cases.py's row of the same shape uses the # DEFAULT vocabulary, where both spellings are markers. lex = _AMBIGUOUS_LEX.add(suffix_acronyms={"phd"}) out = _grouped("JOHN NÉE JONES SMITH MA PHD", lexicon=lex) assert _maiden_texts(out) == ["JONES", "SMITH", "MA"] assert len(_suffix_forks(out)) == 1 def test_the_reader_is_none_in_the_family_segment() -> None: """Segment 0 of a family comma: the comma has already named the family, so no trailing rule reads those words and the clause keeps them -- and nothing reports, nothing having been decided.""" out = _grouped("Smith née Jones MA, Jane", lexicon=_AMBIGUOUS_LEX) assert _maiden_texts(out) == ["Jones", "MA"] assert _suffix_forks(out) == [] def test_a_marker_in_a_third_comma_part_is_a_word() -> None: """A segment past the second comma is a tail part, read as credentials whole, and a marker there takes nothing (#601). Until #601 the clause was taken whole there, maiden 'Jones MA'.""" out = _grouped("Smith, John, Jr née Jones MA", lexicon=_AMBIGUOUS_LEX.add(suffix_words={"jr"})) assert _maiden_texts(out) == [] assert _suffix_forks(out) == [] def test_the_emitter_fires_on_the_last_maiden_piece_and_only_there( ) -> None: """The word the trailing rule was asked about is the LAST piece of the maiden name: everything behind it read as a suffix, which is what let the peel reach it. A member with a name word behind it was never asked.""" out = _grouped("Jane Doe née Smith Ma", lexicon=_AMBIGUOUS_LEX) assert _suffix_forks(out) == [ "'Ma' ending the maiden name is also a post-nominal; the " "maiden marker's clause keeps it rather than reading it as one"] out = _grouped("Jane Doe née MA Smith", lexicon=_AMBIGUOUS_LEX) assert _suffix_forks(out) == [] def test_the_emitter_reports_a_by_shape_member_too() -> None: """The emitter's gate reads EITHER tag, as the chain emitter's does, so a member admitted by SHAPE reports even where the class does not admit it and the peel declined to consume it -- it records the word in `picks` and breaks, so the walk's own reading gate is never asked about it (measured 2026-09-19).""" out = _grouped("John Smith née Jones R.A.I.", policy=Policy(unlisted_dotted_suffixes=False)) assert _maiden_texts(out) == ["Jones", "R.A.I."] assert len(_suffix_forks(out)) == 1 def test_an_unmapped_site_is_a_loud_failure_rather_than_a_default( ) -> None: """The exhaustive dispatch, exercised. `_maiden_take` ends its site dispatch with `assert_never`, which makes a fifth `ClauseSite` member a mypy error at this site rather than a silent fall-through to one of the four readings. At RUNTIME that line is unreachable by construction, so it is reached here the only way it can be -- with a value outside the enum -- both to pin the loudness and to keep the line from being the one uncovered statement in the module. """ state = classify(segment(tokenize(extract_delimited(ParseState( original="Jane Doe née Smith MA", lexicon=Lexicon.default(), policy=Policy()))))) with pytest.raises(AssertionError): _group_segment(state.segments[0], 0, state.tokens, ambiguities=[], one_case=state.one_case, site=cast(ClauseSite, 99), ) def test_the_site_is_pinned_to_the_structure_it_is_read_from( monkeypatch: pytest.MonkeyPatch, ) -> None: """`ClauseSite` is a closed set, and group() maps (structure, segment index) onto it in one place. Pinned here by WATCHING that mapping rather than restating it -- a restatement passes when the code changes under it, which is the shape of vacuous guard AGENTS.md warns about. `_maiden_take` dispatches on the enum exhaustively (`assert_never`), so a fifth member with no row here is a type error rather than a silent default. """ assert len(ClauseSite) == 4 want = { # no comma: the whole name, read by the S2 peel (Structure.NO_COMMA, 0): ClauseSite.TRAILING, # suffix comma: segment 0 is the name, the rest is the # credential run, where a marker is an ordinary word (Structure.SUFFIX_COMMA, 0): ClauseSite.TRAILING, (Structure.SUFFIX_COMMA, 1): ClauseSite.TAIL, (Structure.SUFFIX_COMMA, 2): ClauseSite.TAIL, # family comma: segment 0 is the family the comma named, where # a marker counts; segment 1 is the given part, where it does # not (#601); and a third part is a credential run again (Structure.FAMILY_COMMA, 0): ClauseSite.FAMILY_PART, (Structure.FAMILY_COMMA, 1): ClauseSite.GIVEN_SLOT, (Structure.FAMILY_COMMA, 2): ClauseSite.TAIL, } texts = ("Jane Doe née Smith MA", "Jane Doe née Smith, MD, PhD", "Doe, Jane née Smith MA, MD") seen: dict[tuple[Structure, int], ClauseSite] = {} real = _group_module._group_segment real_decide = _group_module._comma.decide # the structure group reads its sites off is the one it decided at # its head (#613), not the family comma segment handed over decided: list[Structure] = [] def spy(seg: tuple[int, ...], additional: int, tokens: Sequence[WorkToken], *args: object, **kwargs: object) -> object: seen[(decided[-1], len(seen_order))] = cast( ClauseSite, kwargs["site"]) seen_order.append(seg) return real(seg, additional, tokens, *args, **kwargs) # type: ignore[arg-type] def decide_spy(st: ParseState) -> ParseState: out = real_decide(st) decided[-1] = out.structure return out monkeypatch.setattr(_group_module, "_group_segment", spy) monkeypatch.setattr(_group_module._comma, "decide", decide_spy) for text in texts: seen_order: list[tuple[int, ...]] = [] state = classify(segment(tokenize(extract_delimited(ParseState( original=text, lexicon=Lexicon.default(), policy=Policy()))))) # group asks decide only of a comma form; the structure it # hands over stands where it does not ask decided.append(state.structure) group(state) assert seen == want, ( f"group() maps the structures to {seen}, pinned as {want}") def test_a_marker_followed_only_by_the_numeral_is_just_a_word() -> None: # A word straight after the marker is the maiden name unless it # is an unambiguous suffix piece, and a lone 'V' is not one -- it # is initial-shaped -- so since #601 'Jane Smith née V' reads # maiden 'V', where the old walk read the numeral fork from the # marker and declined (suffix 'V', as 1.4.0 read it). An open # question on #601; 'Jane Smith née PhD' still declines. out = _grouped("Jane Smith née V") assert [t.text for t in out.tokens if t.role is Role.MAIDEN] == ["V"] out = _grouped("Jane Smith née PhD") assert [t.text for t in out.tokens if t.role is Role.MAIDEN] == [] def test_the_walk_stops_only_where_the_numeral_survives_the_take() -> None: # The fork reads the piece before the numeral, and the walk # REMOVES that piece: after the take, what assign sees before the # V is the piece before the marker. Where that is initial-shaped # the fork will not fire, and a walk that stopped anyway handed # the V to the family ('J. née Jones Smith V' read family 'V'). # So the walk stops only where the numeral reads as the suffix # both as written and as the take would leave it. Found by both # reviews. out = _grouped("J. née Jones Smith V") assert [t.text for t in out.tokens if t.role is Role.MAIDEN] == \ ["Jones", "Smith", "V"] # A title straight before the marker leaves it an ordinary word # since #601 ('Dr. Zee Jones Smith V' groups the same); until then # the whole peel was re-asked over 'Dr. V' and the clause kept the V. out = _grouped("Dr. née Jones Smith V") assert not any(t.role is Role.MAIDEN for t in out.tokens) def test_an_unlisted_abbreviation_is_as_transparent_as_a_title() -> None: # #367 keyed the leading-particle exception on the first piece of # the NAME, stepping over titles; assign also peels an unlisted # abbreviation as a title (H2), and the scan here did not, so # 'Xyz. van Johnson' chained where 'Dr. van Johnson' did not -- # and 'Xyz. van Berg Ma' chained the given word into the family, # leaving assign two pieces where the acronym fork had counted # three. The scan asks assign's leading-title test now. out = _grouped("Xyz. van Johnson") assert _piece_texts(out) == [["Xyz.", "van", "Johnson"]] out = _grouped("Xyz. van Berg Ma", lexicon=_AMBIGUOUS_LEX) assert _piece_texts(out) == [["Xyz.", "van", "Berg", "Ma"]] # and the bound join's "first non-title piece" is the same count, # so a bound word behind an unlisted abbreviation joins as it does # behind a listed title (the design-docs review's find) out = _grouped("Xyz. abdul John Smith") assert _piece_texts(out) == [["Xyz.", "abdul John", "Smith"]] out = _grouped("Berg, Xyz. abdul van") assert _piece_texts(out)[1] == ["Xyz.", "abdul van"] # -- the marker sites' cross-site contract -------------------------- # A maiden marker is asked about at FOUR sites, and _title_key's # docstring names what a divergence between two of them costs: "a # divergence between them fails silently: the entry simply stops # matching". This lexicon carries a word entry, a phrase entry, and a # phrase whose FIRST WORD is itself an entry -- the longest-first case, # where a site that stopped at the first word would still find a # marker and simply find a shorter one. _MARKER_LEX = Lexicon( maiden_markers=frozenset({"née", "z domu", "geb", "geb von"})) def _marker_sites(spelling: str) -> dict[str, int]: """Every site's answer to "how many words is the marker here", for a name whose marker is written `spelling`.""" clause = spelling.split() + ["Jones"] answers = {"predicate": maiden_marker_run(clause, _MARKER_LEX.maiden_markers)} def staged(text: str) -> ParseState: return classify(segment(tokenize(extract_delimited(ParseState( original=text, lexicon=_MARKER_LEX, policy=Policy()))))) # classify: the run it tagged, head plus continuations bare = staged(f"Jane Smith {spelling} Jones") head = next(i for i, t in enumerate(bare.tokens) if "vocab:maiden-marker" in t.tags) answers["classify"] = marker_run_length( t.tags for t in bare.tokens[head + 1:]) # group's piece test and the M2 take built on it: the pass drops # the marker and nothing else, so the count of dropped tokens is # where that walk decided the marker ends answers["group piece walk"] = len(group(bare).dropped) # group's OTHER drop, the one inside an extracted clause (#329) -- # a separate site reached only through a delimited name answers["group clause drop"] = len( group(staged(f"Jane Smith ({spelling} Jones)")).dropped) # extract's clause test, which is a boolean: a clause is marker-led # exactly while a word remains past the run, so the LONGEST prefix # it still declines is the run itself. Read this way rather than as # "the first prefix it accepts", which would answer 1 for every # phrase whose first word is also an entry. answers["extract"] = max( k for k in range(len(clause) + 1) if not _maiden_marked(" ".join(clause[:k]), _MARKER_LEX)) return answers @pytest.mark.parametrize("spelling", [ "née", "Née", "née.", # a word entry "z domu", "Z Domu", "z. domu", "z domu.", # a phrase entry "geb", "geb von", # both, longest first ]) def test_every_marker_site_ends_the_run_in_the_same_place( spelling: str) -> None: # The contract is AGREEMENT, not four expected numbers: a site that # drifted would keep passing its own tests while disagreeing with # the others about an input neither covers, which is exactly how # the title-run key's two builders could have gone wrong (#369) and # why P5 and H1 have a contract test of their own. # # This test varies the marker's SPELLING and holds its PLACEMENT # fixed, so every run it builds is structurally contiguous by # construction. That was a hole: the sites can also disagree # because they walk different token populations, which no spelling # reaches. test_a_tagged_marker_run_is_contiguous_and_drops_whole # below is the placement axis, added after a real disagreement got # through here. answers = _marker_sites(spelling) assert len(set(answers.values())) == 1, answers # never vacuous: the parametrization's own precondition is that # each spelling is WHOLLY a marker, so the agreed answer is its # word count -- an all-zero agreement would otherwise pass assert answers["predicate"] == len(spelling.split()) # Placements, not spellings: the axis the test above does not vary. A # marker run is tagged over the whole span-sorted token stream and # consumed over one SEGMENT, and the two populations differ -- extract # gives a delimited clause's tokens a role and segment's role-None filter keeps # only role-None tokens, then buckets those by the commas before them. # So a run written across a clause edge or a structure comma is one the # piece walk cannot see whole. Every row here writes 'z domu' at a # different place relative to those boundaries. _MARKER_PLACEMENTS = [ "Jane Smith z domu Jones", # contiguous, bare "Jane Smith (z domu Jones)", # contiguous, wholly inside a clause 'Jane Smith "z domu Jones"', # the same, in the other default pair "Jane z (domu) Jones", # straddles a clause OPEN "Jane Smith (z) domu Jones", # straddles a clause CLOSE "Jane z, domu Jones", # straddles a structure comma "Jane Smith née Jones", # the one-word control "Jane Smith (née Jones)", # the one-word control, delimited ] def _tagged_runs(state: ParseState) -> list[list[int]]: """Every maiden marker run classify tagged, as token indices.""" runs = [] for i, token in enumerate(state.tokens): if "vocab:maiden-marker" not in token.tags: continue length = marker_run_length(t.tags for t in state.tokens[i + 1:]) runs.append(list(range(i, i + length))) return runs @pytest.mark.parametrize("text", _MARKER_PLACEMENTS) def test_a_tagged_marker_run_is_contiguous_and_drops_whole(text: str) -> None: state = ParseState(original=text, lexicon=_MARKER_LEX, policy=Policy()) classified = classify(segment(tokenize(extract_delimited(state)))) runs = _tagged_runs(classified) commas = classified.comma_offsets def bucket(i: int) -> int: return bisect.bisect_left(commas, classified.tokens[i].span.start) # 1. What classify tags is what a segment can hold: one role and one # comma bucket throughout. _group._marker_run_pieces asserts this in # prose and depends on it in fact -- with a run allowed to straddle, # its walk stops at the boundary and hands M2 a proper PREFIX of the # phrase as if it were the whole marker, which is how # 'Anna z (domu) Nowak' came to read family 'Anna', maiden 'Nowak'. for run in runs: roles = {classified.tokens[i].role for i in run} assert len(roles) == 1, (text, run, roles) assert len({bucket(i) for i in run}) == 1, (text, run) # 2. And the consumer takes the whole run or declines it. A count # that disagrees with the tagged length is the cross-site # disagreement itself, whatever produced it. dropped = set(group(classified).dropped) for run in runs: taken = dropped & set(run) assert taken in (set(), set(run)), (text, run, sorted(taken)) def test_the_marker_placements_reach_both_answers() -> None: # Neither half of the pin above is vacuous: some placements must # tag a run (or the contiguity assertion quantifies over nothing) # and some must tag none (or the straddling rows have stopped # straddling and the regression they pin is unwatched). tagged = {} for text in _MARKER_PLACEMENTS: state = ParseState(original=text, lexicon=_MARKER_LEX, policy=Policy()) runs = _tagged_runs(classify(segment(tokenize( extract_delimited(state))))) tagged[text] = bool(runs) assert sum(tagged.values()) >= 4 assert sum(not v for v in tagged.values()) >= 3 # --- #397: the carve-out's count, and the both-sides condition ------ # _LEX ships no single letter that is BOTH connective and generational # vocabulary, so each test below builds the overlap it is about. That # is the point of the rule -- it is keyed on the CLASS, never on the # letter -- and a test using the shipped sets would walk the right # branch while proving nothing about it (_pipeline/AGENTS.md: "Pin the # decision, not the vocabulary"). _LINK_LEX = _LEX.add(conjunctions={"i"}, suffix_words={"i"}) #: the same letter as a connective that is NOT generational vocabulary _PLAIN_LEX = _LEX.add(conjunctions={"i"}) def test_a_connective_piece_counts_toward_the_carve_outs_total() -> None: # rules/P3's count (#397). Four words, one of them the link, # and the link is suffix vocabulary -- so the total reaches four # only because a connective counts ITSELF. Without the count arm # the total is three, the carve-out declines, and the link stays # a name word in the middle. out = _grouped("Josep Carod i Rovira", lexicon=_LINK_LEX) assert _piece_texts(out) == [["Josep", "Carod i Rovira"]] def test_the_count_arm_is_what_moves_it_not_the_vocabulary() -> None: # the control that separates the two halves of commit 1: with the # letter a connective but NOT generational vocabulary, nothing # refused it before and the join already fired. Same output, and # the pair is what says the count arm is about the overlap. out = _grouped("Josep Carod i Rovira", lexicon=_PLAIN_LEX) assert _piece_texts(out) == [["Josep", "Carod i Rovira"]] def test_a_connective_with_nothing_to_its_right_does_not_join() -> None: # the both-sides condition (#397). Four name words, so the count # no longer declines -- what keeps the generation here is the # condition, and dropping it reads 'Smith i' as one piece. The # simplest shape of it: there is no piece to the right at all. out = _grouped("John Quincy Smith i", lexicon=_LINK_LEX) assert _piece_texts(out) == [["John", "Quincy", "Smith", "i"]] # The condition asks about the NEIGHBOUR's class, and every arm of # that question has a LINK/PLAIN pair below: with the letter outside # the generational vocabulary nothing is asked and the link joins, so # each pair says which arm is doing the work rather than that some # arm is (#397 review -- the first cut asked about the link's # POSITION, which every one of these shapes satisfies). def test_a_suffix_word_on_the_right_is_no_name_word() -> None: out = _grouped("Josep Lluis Carod i Jr.", lexicon=_LINK_LEX) assert _piece_texts(out) == [["Josep", "Lluis", "Carod", "i", "Jr."]] plain = _grouped("Josep Lluis Carod i Jr.", lexicon=_PLAIN_LEX) assert _piece_texts(plain) == [["Josep", "Lluis", "Carod i Jr."]] def test_the_right_hand_test_reads_the_peel_not_the_suffix_piece( ) -> None: # the arm that forces trailing_start rather than is_suffix_piece: # a ONE-CHARACTER suffix word is initial-shaped, so the # suffix-piece test refuses it (the initial veto, # `_vocab.is_initial`) while assign's trailing peel takes it. Spelled with the piece test # alone, the link would swallow the generation here. lex = _LINK_LEX.add(suffix_words={"v"}) out = _grouped("Josep Lluis Carod i V", lexicon=lex) assert _piece_texts(out) == [["Josep", "Lluis", "Carod", "i", "V"]] plain = _grouped("Josep Lluis Carod i V", lexicon=_PLAIN_LEX.add(suffix_words={"v"})) assert _piece_texts(plain) == [["Josep", "Lluis", "Carod i V"]] def test_a_title_on_the_right_is_no_name_word() -> None: out = _grouped("Josep Lluis Carod i Mr.", lexicon=_LINK_LEX) assert _piece_texts(out) == [["Josep", "Lluis", "Carod", "i", "Mr."]] plain = _grouped("Josep Lluis Carod i Mr.", lexicon=_PLAIN_LEX) assert _piece_texts(plain) == [["Josep", "Lluis", "Carod i Mr."]] def test_a_leading_title_on_the_left_is_no_name_word() -> None: # the link opens the NAME even though a piece stands before it: # assign peels the title run off the front. out = _grouped("Mr. i Rovira Puig Vila", lexicon=_LINK_LEX) assert _piece_texts(out) == [["Mr.", "i", "Rovira", "Puig", "Vila"]] plain = _grouped("Mr. i Rovira Puig Vila", lexicon=_PLAIN_LEX) assert _piece_texts(plain) == [["Mr. i Rovira", "Puig", "Vila"]] def test_an_unlisted_leading_abbreviation_is_no_name_word_either( ) -> None: # and THIS is the `lo` bound's own row, the listed spelling above # being caught by the title-piece test as well. rules/H2 reads # an unlisted abbreviation opening a name as a title by SHAPE, so # the vocabulary says nothing about 'Xyz.' and only the bound # assign's own title run draws keeps the link from joining it. # Measured 2026-09-20: replace `lo` with 0 and this is the one # test in the suite that dies. out = _grouped("Xyz. i Rovira Puig Vila", lexicon=_LINK_LEX) assert _piece_texts(out) == [["Xyz.", "i", "Rovira", "Puig", "Vila"]] plain = _grouped("Xyz. i Rovira Puig Vila", lexicon=_PLAIN_LEX) assert _piece_texts(plain) == [["Xyz. i Rovira", "Puig", "Vila"]] def test_a_credential_or_honorific_mid_name_is_no_name_word_either( ) -> None: # what the two bounds do NOT reach, and why the piece tests stay # inside them: neither the title run nor the trailing peel walks # into the middle of a name. suffix = _grouped("Josep Lluis Jr. i Rovira", lexicon=_LINK_LEX) assert _piece_texts(suffix) == [ ["Josep", "Lluis", "Jr.", "i", "Rovira"]] title = _grouped("Josep Lluis Mr. i Rovira", lexicon=_LINK_LEX) assert _piece_texts(title) == [ ["Josep", "Lluis", "Mr.", "i", "Rovira"]] assert _piece_texts(_grouped("Josep Lluis Jr. i Rovira", lexicon=_PLAIN_LEX)) == [ ["Josep", "Lluis", "Jr. i Rovira"]] assert _piece_texts(_grouped("Josep Lluis Mr. i Rovira", lexicon=_PLAIN_LEX)) == [ ["Josep", "Lluis", "Mr. i Rovira"]] def test_a_credential_or_honorific_mid_name_on_the_right_too() -> None: # the MIRROR of the pair above, and the two rows that make the # right-hand piece tests mean something: the credential and the # honorific rows further up stand at the END of the name, where # `hi` refuses them before either piece test is asked, so with # those rows alone the right-hand tests could be deleted and no # test would fail (measured 2026-09-21 by mutation, which is what # asking both sides in ONE call made visible -- a per-side call # mutated both sides at once and the left-hand rows covered it). # Mid-name on the RIGHT is inside both bounds, so only the piece # tests keep the link from joining a credential or a title. suffix = _grouped("Josep Lluis i Jr. Rovira", lexicon=_LINK_LEX) assert _piece_texts(suffix) == [ ["Josep", "Lluis", "i", "Jr.", "Rovira"]] title = _grouped("Josep Lluis i Mr. Rovira", lexicon=_LINK_LEX) assert _piece_texts(title) == [ ["Josep", "Lluis", "i", "Mr.", "Rovira"]] assert _piece_texts(_grouped("Josep Lluis i Jr. Rovira", lexicon=_PLAIN_LEX)) == [ ["Josep", "Lluis i Jr.", "Rovira"]] assert _piece_texts(_grouped("Josep Lluis i Mr. Rovira", lexicon=_PLAIN_LEX)) == [ ["Josep", "Lluis i Mr.", "Rovira"]] def test_the_walk_looks_past_a_run_of_connectives() -> None: # a RUN joins as one, so the word the condition is about is the # first one past the run, not the connective beside the link. out = _grouped("Carod i y Rovira", lexicon=_LINK_LEX) assert _piece_texts(out) == [["Carod i y Rovira"]] def test_where_the_run_runs_out_there_is_no_name_word() -> None: # the same walk reaching the end of the pieces: a link behind # nothing but connectives is joining nothing, and the run may not # absorb it either. out = _grouped("Juan i y", lexicon=_LINK_LEX) assert _piece_texts(out) == [["Juan", "i", "y"]] assert _piece_texts(_grouped("Juan i y", lexicon=_PLAIN_LEX)) == [ ["Juan i y"]] doubled = _grouped("Josep Lluis Carod i i", lexicon=_LINK_LEX) assert _piece_texts(doubled) == [ ["Josep", "Lluis", "Carod", "i", "i"]] assert _piece_texts(_grouped("Josep Lluis Carod i i", lexicon=_PLAIN_LEX)) == [ ["Josep", "Lluis", "Carod i i"]] def test_a_link_that_joins_nothing_does_not_count_for_another_join( ) -> None: # the COUNT half agreeing with the join (#397 review). The # trailing link joins nothing, so it is the generation -- and a # generation is no rootname, so the total stays at three and the # unrelated 'y' two pieces away keeps the carve-out. Counting it # gave the total four and joined the 'y'. out = _grouped("Carod y Rovira i", lexicon=_LINK_LEX) assert _piece_texts(out) == [["Carod", "y", "Rovira", "i"]] plain = _grouped("Carod y Rovira i", lexicon=_PLAIN_LEX) assert _piece_texts(plain) == [["Carod y Rovira i"]] def test_a_connective_with_nothing_to_its_left_does_not_join() -> None: # the other side of the same condition, and it needs four pieces # to get past the count: a link OPENING the name has no name word # behind it either. out = _grouped("i Carod Rovira Puig", lexicon=_LINK_LEX) assert _piece_texts(out)[0][0] == "i" def test_the_both_sides_condition_reads_the_class_not_the_letter( ) -> None: # the recorded negative control for the class test the freeze # walk opens with, and the one the property invariants CANNOT # give: with the same letter outside the generational vocabulary # the condition declines to ask and the trailing connective # joins, exactly as a trailing 'y' does today. Measured -- remove # the "vocab:suffix" arm and this test is the one that dies. out = _grouped("John Quincy Smith i", lexicon=_PLAIN_LEX) assert _piece_texts(out) == [["John", "Quincy", "Smith i"]] def test_a_trailing_shipped_connective_is_untouched_by_the_condition( ) -> None: # the shipped-vocabulary half of the same control: 'y' is a # connective and not a suffix word, so the condition never reaches # it and 'Lopez y' stays one piece. out = _grouped("Juan Garcia Lopez y") assert _piece_texts(out) == [["Juan", "Garcia", "Lopez y"]] def test_the_three_word_carve_out_still_declines_before_both_sides( ) -> None: # the two gates are separate and this is what separates them: # three name words, so the count refuses and the both-sides test # is never reached. Delete the both-sides condition and this test # still passes while its four-word sibling does not. out = _grouped("Josep Carod i", lexicon=_LINK_LEX) assert _piece_texts(out) == [["Josep", "Carod", "i"]] def test_a_one_case_letter_is_an_initial_and_never_counts() -> None: # what keeps the one-case fork's count where it was: classify # writes `initial` or `conjunction` on a single letter and never # both, so a letter the fork read as an initial reaches # _is_rootname with no conjunction tag at all, the count is # unmoved, and the name reads as it always did. The EXCLUSIVITY is # the load-bearing part, not the order of the two tests -- measured # (swapping them moves nothing). lex = _LINK_LEX.add(conjunctions_ambiguous={"i"}) out = _grouped("josep carod i rovira", lexicon=lex) assert _piece_texts(out) == [["josep", "carod", "i", "rovira"]] def test_the_class_reaches_a_callers_own_connective() -> None: # rules/P3 is keyed on the class throughout: a caller who adds # 'v' to their connectives gets the Catalan link's behavior for # it, because 'v' is generational vocabulary the way 'i' is. p = Parser(lexicon=Lexicon.default().add(conjunctions={"v"})) joined = p.parse("Josep Carod v Rovira") assert (joined.given, joined.family) == ("Josep", "Carod v Rovira") trailing = p.parse("John Quincy Smith v") assert (trailing.family, trailing.suffix) == ("Smith", "v") def test_a_callers_non_generational_letter_is_outside_the_condition( ) -> None: # the recorded negative control, at the reading level: 'x' is a # roman numeral letter that is NOT suffix vocabulary, so the # both-sides condition declines to ask and BOTH positions join, # which is what they did before this change too. p = Parser(lexicon=Lexicon.default().add(conjunctions={"x"})) assert p.parse("John Quincy Smith x").family == "Smith x" assert p.parse("Josep Carod x Rovira").family == "Carod x Rovira" def test_the_count_reaches_a_connective_that_is_particle_vocabulary( ) -> None: # the ACCEPTED CONSEQUENCE of counting a connective whatever else # it is: _is_rootname refuses a PARTICLE piece the same way it # refuses a generational one, so a caller who makes 'y' a particle # too used to get a different reading from the default lexicon. # Now it agrees with it -- the direction the rule wants. Measured # before this change: given 'Juan', middle 'Velasquez', family # 'y Garcia', family_base 'Garcia'. p = Parser(lexicon=Lexicon.default().add(particles={"y"})) out = p.parse("Juan Velasquez y Garcia") assert (out.given, out.middle, out.family) == ( "Juan", "", "Velasquez y Garcia") # the cost that comes with it, pinned rather than hidden: # family_base drops a particle wherever it stands and not only # leading, so the joined run loses the letter here. A standing # rules/R2 limit this row surfaces, not one it creates. assert out.family_base == "Velasquez Garcia" # --- #397 review: the link inside a MAIDEN CLAUSE ------------------- # rules/M2's link clause. The walk ends the birth name at the first # suffix WORD after the marker, and the link is one -- so it ended the # clause there, and what #397 added was to JOIN the words it left # standing into the current surname. Each branch below has the same # LINK/PLAIN pair the both-sides tests above use: with the letter # outside the generational vocabulary the walk never stopped at it in # the first place, so the pair says which arm does the work. # # `ma` is added to BOTH acronym sets where the right-hand neighbour # has to be an AMBIGUOUS credential: such a word carries no # `vocab:suffix` tag, so the piece test cannot refuse it and only the # peel bound keeps it out of the clause. _LINK_MA_LEX = _LINK_LEX.add(suffix_acronyms={"ma"}, suffix_acronyms_ambiguous={"ma"}) _PLAIN_MA_LEX = _PLAIN_LEX.add(suffix_acronyms={"ma"}, suffix_acronyms_ambiguous={"ma"}) def test_a_link_inside_a_maiden_clause_does_not_end_it() -> None: # the statement of the rule. A birth-name word on each side of the # link, so the walk steps over it and the clause takes all three # words; without the exception the clause is 'Puig' and 'i Soler' # is left for the joins to build a surname out of. out = _grouped("Jane Doe née Puig i Soler", lexicon=_LINK_LEX) assert _maiden_texts(out) == ["Puig", "i", "Soler"] assert _piece_texts(out) == [["Jane", "Doe"]] def test_the_clause_link_arm_is_what_moves_it_not_the_vocabulary( ) -> None: # the PLAIN twin: with the letter a connective but NOT generational # vocabulary the walk never stopped at it, so the reading is the # one it always had. The pair is what says the exception is about # the overlap. out = _grouped("Jane Doe née Puig i Soler", lexicon=_PLAIN_LEX) assert _maiden_texts(out) == ["Puig", "i", "Soler"] def test_a_clause_link_runs_twice_over() -> None: # every link of the clause is asked, not just the first: the walk # steps over each one it finds between two birth-name words. out = _grouped("Jane Doe née Puig i Soler i Vila", lexicon=_LINK_LEX) assert _maiden_texts(out) == ["Puig", "i", "Soler", "i", "Vila"] def test_a_clause_link_with_nothing_on_its_right_still_ends_it( ) -> None: # the first control. Nothing stands after the link at all, so it # is joining nothing and is the generation it also spells -- the # clause ends at it exactly as it did before, and `_name_word_ # beside` walks off the end. The PLAIN twin keeps the letter, # which is what says this row is the condition's doing. out = _grouped("Jane Doe née Puig i", lexicon=_LINK_LEX) assert _maiden_texts(out) == ["Puig"] plain = _grouped("Jane Doe née Puig i", lexicon=_PLAIN_LEX) assert _maiden_texts(plain) == ["Puig"] def test_a_generation_on_the_links_right_is_no_name_word() -> None: # the second control, refused by CLASS: 'jr' is suffix vocabulary, # so `_between_name_words` declines it wherever it stands. out = _grouped("Jane Doe née Puig i jr", lexicon=_LINK_LEX) assert _maiden_texts(out) == ["Puig"] plain = _grouped("Jane Doe née Puig i jr", lexicon=_PLAIN_LEX) assert _maiden_texts(plain) == ["Puig", "i"] def test_an_ambiguous_credential_on_the_right_is_refused_by_bound( ) -> None: # the third control, and the one that pins WHICH right-hand bound # the exception reads. 'MA' carries no `vocab:suffix` tag -- the # piece test says nothing about it -- so what keeps it out of the # clause is `peel_start`, where assign's trailing run begins over # the pieces as written. Read the walk's own stop instead and this # row takes 'i MA' into the birth name. out = _grouped("Jane Doe née Puig i MA", lexicon=_LINK_MA_LEX) assert _maiden_texts(out) == ["Puig"] plain = _grouped("Jane Doe née Puig i MA", lexicon=_PLAIN_MA_LEX) assert _maiden_texts(plain) == ["Puig", "i"] def test_a_suffix_word_that_is_no_connective_still_ends_the_clause( ) -> None: # 'jr' stands between two birth-name words and is not a # connective, so it ends the clause -- and since #601 and #602 it # starts a credential run in the clause-free name ('Jane Doe jr # Soler'), which the take consumes, 'Soler' included. Until then # the clause ended at 'jr' and handed 'jr Soler' to the name. out = _grouped("Jane Doe née Puig jr Soler", lexicon=_LINK_LEX) assert _maiden_texts(out) == ["Puig"] assert [t.text for t in out.tokens if t.role is Role.SUFFIX] == [ "jr", "Soler"] assert _piece_texts(out) == [["Jane", "Doe"]] def test_the_marker_is_not_the_name_word_on_the_links_left() -> None: # the fourth control, and the one the clause's own `lo` bound # carries: the marker announces the name and is no word of it, so # a link standing first inside the clause joins nothing there. The # walk then stops at its very first piece and the pass declines # altogether, leaving the marker an ordinary word (rules/M2). out = _grouped("Jane Doe née i Soler", lexicon=_LINK_LEX) assert _maiden_texts(out) == [] assert _piece_texts(out) == [["Jane", "Doe", "née i Soler"]] plain = _grouped("Jane Doe née i Soler", lexicon=_PLAIN_LEX) assert _maiden_texts(plain) == ["i", "Soler"] def test_a_core_beside_a_connective_in_a_credential_tail_is_dropped( ) -> None: """No connective joins across a core, generational or ordinary, and whatever stands beyond it: the core parts the tail as a comma would and is dropped, so the entries it stood between separate the way they do in 'PhD - MD' -> 'PhD, MD' (#206, #549). RECORDED NEGATIVE CONTROLS: at e0f1a2fa, before #538, the first two read suffix 'PhD - i Soler' and '- i Puig'; at 061f02da, after #538 and before #549, the last three read 'Puig - i Soler', 'Puig i - Soler' and 'PhD - and MD' -- the core kept inside the piece a connective joined across it. """ # ' | ', not ' - ': a hyphen standing alone is built in (#889) dash = Parser(policy=Policy(extra_suffix_delimiters=frozenset({" | "}))) assert str(dash.parse("Smith, John, PhD | i Soler").suffix) == \ "PhD, i Soler" assert str(dash.parse("Smith, John, | i Puig").suffix) == "i Puig" assert str(dash.parse("Smith, John, Puig | i Soler").suffix) == \ "Puig, i Soler" assert str(dash.parse("Smith, John, Puig i | Soler").suffix) == \ "Puig i, Soler" assert str(dash.parse("Smith, John, PhD | and MD").suffix) == \ "PhD, and MD" def test_a_marker_with_nothing_after_it_declines_before_the_bound( ) -> None: # the clause's `lo` is the first piece after the marker run, and # with nothing behind the marker there is no such piece: the pass # declines here rather than indexing for a bound it would never # read. Unchanged behavior, and the row exists because the early # return is what makes it unchanged. out = _grouped("Jane Doe née", lexicon=_LINK_LEX) assert _maiden_texts(out) == [] assert _piece_texts(out) == [["Jane", "Doe", "née"]] # --- #397 second review: the bound, the class and the frozen piece -- def test_a_trailing_title_does_not_hide_the_suffix_run_from_the_join( ) -> None: # rules/H5 read where the JOIN asks its question (#397 second # review). `trailing_start` reads the peel over the pieces as # WRITTEN, so a title standing behind the suffix run makes the # peel take nothing and the answer is `len(pieces)` -- and a # caller using it as the right bound of the NAME is then told a # credential is a name word. 'MA' carries no `vocab:suffix` tag, # so the piece test cannot refuse it either and the join swallowed # it: family 'Adams i MA', no report, and `initials()` gaining an # 'M.'. # # The pair is the finding, and it is H5's own sentence: the name # one title shorter has always read the other way. # the SHIPPED vocabulary reaches this row -- 'i' is a default # connective and a default suffix word, 'MA' a default ambiguous # acronym and 'Prof.' a default title -- so this is one of the # few #397 rows that needs no built lexicon at all. shipped = Lexicon.default() titled = _grouped("John Quincy Adams i MA Prof.", lexicon=shipped) bare = _grouped("John Quincy Adams i MA", lexicon=shipped) assert _piece_texts(titled) == [ ["John", "Quincy", "Adams", "i", "MA", "Prof."]] assert _piece_texts(bare) == [["John", "Quincy", "Adams", "i", "MA"]] # and end to end, where the fields say what the bound bought. # The SHIPPED vocabulary reaches this: 'i' is a default # connective and a default suffix word, and 'MA' a default # ambiguous acronym, so no built lexicon is needed here and the # row is a real parse rather than a configured one. parser = Parser() name = parser.parse("John Quincy Adams i MA Prof.") assert name.as_dict() == { "title": "Prof.", "given": "John", "middle": "Quincy", "family": "Adams", "suffix": "i MA", "nickname": "", "maiden": ""} assert name.initials() == "J. Q. A." # 'MA' in capitals in a name of mixed case is settled by its # writing, so nothing reports it (rules/S6, #785) assert name.ambiguities == () # a LOWER-CASE title is the same shape and reaches the same # bound: the vocabulary lookup is folded, so 'prof.' peels like # 'Prof.' lower_title = parser.parse("John Quincy Adams i MA prof.") assert lower_title.title == "prof." assert lower_title.suffix == "i MA" # the ONE-CASE spellings never reached the defect and are pinned # as the control: written wholly in one case the letter reads as # an initial (rules/P8's marked subset), no join is attempted # at all, and both spellings already agreed with each other and # with the untitled name at dc3bdf9c -- which is what says the # bound and not the marking is what this row is about. for text, title in (("john quincy adams i ma prof.", "prof."), ("JOHN QUINCY ADAMS I MA PROF.", "PROF.")): one_case = parser.parse(text) assert one_case.title == title assert one_case.suffix == text.split()[-2] def test_a_multi_letter_link_of_the_suffix_vocabulary_joins_by_the_same_rule( ) -> None: # rules/P3's both-sides clause names a CLASS -- "a connective # that is also generational vocabulary" -- and says nothing about # how the word is spelled (#397 second review). A `len(text) != 1` # filter stood in the stage and narrowed the clause to one-letter # connectives, untested and undocumented; this is the row that was # caller-reachable past it. Nothing SHIPPED reaches it -- 'i' is # the only member of the class in the default vocabulary and in # every locale pack -- which is why the lexicon is built here. lex = Lexicon.default().add(conjunctions={"og"}, suffix_words={"og"}) parser = Parser(lexicon=lex) # nothing on its right: it is the generation it also spells lone = parser.parse("John Quincy Smith og") assert lone.family == "Smith" assert lone.suffix == "og" # a name word on each side: it joins, exactly as a one-letter # member does joined = parser.parse("Josep Carod og Rovira") assert joined.family == "Carod og Rovira" # the three-word carve-out stays SINGLE-LETTER, which is what its # own sentence says ("a single-letter connective in a three-word # name"): 'og' is two letters, so it joins in a THREE-word name # where the one-letter 'i' of the shipped vocabulary stays a name # word in the middle. assert parser.parse("Josep og Carod").given == "Josep og Carod" assert Parser().parse("Josep i Carod").middle == "i" # and with nothing on its right the class test decides before the # carve-out is ever asked, for either spelling assert parser.parse("Josep Carod og").suffix == "og" assert Parser().parse("Josep Carod i").suffix == "i" def test_a_frozen_link_is_still_absorbed_by_a_neighbours_join() -> None: # What freezing a piece claims and what it does not (#397 second # review). `frozen` keeps a connective from being the SUBJECT of a # join; it does not keep the word out of the span another # connective's join takes. The trailing 'i' here has nothing on # its right and is frozen, and the 'y' beside it joins across it # all the same. # # Not a defect and not a silence: this is the reading the parent # commit 46651750 gives the same name, the letter being no # connective there at all, so the row is a CONTROL for the # comment beside `frozen` rather than a behavior claim of its own. out = Parser().parse("Josep Carod Rovira Puig y i") assert out.family == "Puig y i" assert out.middle == "Carod Rovira" assert out.suffix == "" def test_the_first_word_floor_holds_a_title_out_of_the_chain() -> None: """A title straight after the marker stays the maiden name: the floor is the title chain's own, so the chain never takes that word and stops at it, taking only the titles behind it.""" assert _maiden_texts(_grouped("Jane Doe nee Pope.", lexicon=Lexicon.default())) == ["Pope."] assert _maiden_texts(_grouped("Jane Doe nee Prof. Dr.", lexicon=Lexicon.default())) == ["Prof."] def test_the_floor_keeps_the_first_word_in_the_chains_count() -> None: """rules/M2 (#535 review): the floor lives in the chain now, not in a clamp read after the fact -- the chain may not take the first word after the marker, so the re-peel's count still holds it and a trailing suffix behind a chained title reads as 'Jane Doe nee Smith ba' reads it.""" out = _grouped("Jane Doe nee Pope. ba", lexicon=Lexicon.default()) assert _maiden_texts(out) == ["Pope."] # the control: an unlisted first word gives the same count ok = _grouped("Jane Doe nee Smith ba", lexicon=Lexicon.default()) assert _maiden_texts(ok) == ["Smith"] def test_group_hands_assign_the_trailing_read() -> None: """`tail_reads` carries S2's run per segment, read once between P3's joins and the particle chain (#614), and None where the segment was not read there: a run holding a name piece, and a segment of fewer than three pieces.""" lex = Lexicon.default() out = _grouped("John van der Berg Prof.", lexicon=lex) (read,) = out.tail_reads assert read is not None assert sorted(out.tokens[i].text for i in read.titles) == ["Prof."] assert _grouped("John Dr. X.Y.Z.", lexicon=lex).tail_reads == (None,) assert _grouped("John Smith", lexicon=lex).tail_reads == (None,) def test_the_chain_reports_a_member_the_read_did_not_weigh() -> None: """The chain reports a member it took short of the run, its scan ending at the next particle, which the read never reached ('Ed' here, 'Do' being the read's own pick, reported by assign). Recorded negative control, #614's review (2026-10-07): with the emitter switched off wherever a read was taken, 'Ed' went unreported.""" out = parse("Freiherr von Berg Ed Do") reported = [[t.text for t in a.tokens] for a in out.ambiguities if a.kind is AmbiguityKind.SUFFIX_OR_NAME] assert reported == [["Ed"], ["Do"]] def test_a_pick_the_chain_took_is_reported_in_the_chains_words() -> None: """A pick the particle chain took into the name is reported by the chain, once, and the read gives the pick up, so assign does not report it again: assign words a pick by the role it holds before post_rules, and under a leading title-particle that is the given role the fold then moves into the family. Recorded negative controls, #614's second review (2026-10-07): with the chain's emitter skipping the read's picks the report said 'read as a given name' of a word in the family; with the read keeping the pick the word was reported twice.""" out = parse("Freiherr von Berg Ma") assert out.family == "von Berg Ma" details = [a.detail for a in out.ambiguities if a.kind is AmbiguityKind.SUFFIX_OR_NAME] assert len(details) == 1 assert "particle chain took it into the name" in details[0] def test_an_absorbed_particle_run_reports_as_the_one_word_the_chain_makes( ) -> None: """A run of particles a credential run absorbs is reported as the one piece P2's chain makes of it, a dual among them included, as it was before #614 split the run off ahead of the chain (#620): group merges the particles straight behind each other before the read (#625), which had gathered them out of `_chain_units`' marks instead. Only particles that stand together: the merged 'Ph. D.' between two parts them. M2's clause-free view reads its words before any join and reports them one by one. Recorded negative controls, #620 (2026-10-08): with the gathering off 'Mc Mc' went unreported; with it on in M2's view the clause reported 'mc van'; without the adjacency test 'vd van'. #625 (2026-10-10): with the merge off this fails, the read taking the pieces one by one.""" def reported(text: str) -> list[list[str]]: return [[t.text for t in a.tokens] for a in parse(text).ambiguities if a.kind is AmbiguityKind.SUFFIX_OR_NAME] assert reported("Dr. John Smith Esq. RN Mc Mc") == [["Mc", "Mc"]] assert reported("Mary Ann Smith Dr. MD vd Ph. D. van") == [["van"]] assert reported("Jane Doe nee van der Berg Jr mc van") == [["van"]] # a P3-joined phrase is a particle too, and the merged run is one # piece on every pass of the read, after the H5 chain's splice as # before it assert reported("John Smith Jr. von und zu Mc") == [ ["von", "und", "zu", "Mc"]] assert reported("John Smith PhD van mc Dr.") == [["van", "mc"]] �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/pipeline/test_pieces.py����������������������������������0000664�0000000�0000000�00000176746�15273032717�0025113�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Unit tests for the shared piece predicates. _pieces has had no unit-test module since #439 moved it out of _group; its predicates were reached only end to end through the case table. These pin the two contracts that shape cannot reach: a defensive branch no parse can produce, and the stability its readers rest on. """ import dataclasses import itertools from collections.abc import Callable, Sequence, Set import pytest from nameparser import Parser, parse from nameparser._lexicon import Lexicon, _normalize from nameparser._pipeline import STAGES from nameparser._pipeline._classify import classify from nameparser._pipeline import _group from nameparser._pipeline import _pieces from nameparser._pipeline._group import group from nameparser._pipeline import _comma from nameparser._pipeline._pieces import ( _anchors, _numeral_behind_the_initial_veto, anchor_in_reach, credential_anchors, JOINED, OPENS, TailRead, _chain_units, credential_at_the_given_slot, is_leading_title, join_connectives, leading_titles, merge_particle_runs, read_trailing_run, own_words, peel_trailing, peel_walk, segment_suffix_reading, tail_reading, trailing_candidates, trailing_titles, ) from nameparser._pipeline._segment import segment from nameparser._pipeline._state import ( AMBIGUOUS_ACRONYM_TAG, ParseState, PendingAmbiguity, WorkToken, ) from nameparser._pipeline._tokenize import tokenize from nameparser._pipeline._vocab import is_one_case, is_title_shaped, tag_marker_runs from nameparser._policy import Policy from ..cases import CASES def _through_group(text: str, policy: Policy = Policy()) -> ParseState: state = ParseState(original=text, lexicon=Lexicon.default(), policy=policy) for stage in (tokenize, segment, classify, group): state = stage(state) return state def _comma_part(text: str) -> tuple[list[list[int]], list[set[str]], list, bool | None]: """The part after the first comma as `_comma.decide` reads it: the tokens classify tagged, one piece each but the Ph./D. pair and a title chain (#613, made by the connective joins group shares since #617) -- the reading's own input, before group binds the part.""" state = ParseState(original=text, lexicon=Lexicon.default(), policy=Policy()) for stage in (tokenize, segment, classify): state = stage(state) tokens = list(state.tokens) pieces, ptags = _comma._reading_pieces(state.segments[1], tokens) return pieces, ptags, tokens, state.one_case def _state_through(stage_name: str, text: str) -> ParseState: """Run the pipeline up to and including the named stage (STAGES' own names), for a test that needs a state a later stage has not yet touched (#289/#516).""" state = ParseState(original=text, lexicon=Lexicon.default(), policy=Policy()) for stage in STAGES: state = stage(state) if stage.__name__ == stage_name: break return state def test_the_numeral_veto_refuses_a_multi_token_piece() -> None: """The len(piece) != 1 guard, which no parse can exercise. A merged multi-token piece carries "suffix" in its PIECE tags, so is_suffix_piece claims it one branch earlier and the reading never asks this helper about one -- the review instrumented it over the whole suite and both differential corpora and found no call with a longer piece. That makes the guard unreachable, not wrong: without it the helper would read piece[0]'s tags and answer for the FIRST token of a piece rather than for the piece, which is a wrong answer where returning False is a safe one. Asserted directly because it cannot be asserted through a name -- and with a piece the TAG test would accept, so that only the guard can produce the False. A first version of this test used the merged Ph./D. piece, whose head carries vocab:suffix but not initial: it COVERED the line and pinned nothing, since the tag test answered first and deleting the guard changed no result. """ state = _through_group("Smith, V PSM") tokens = list(state.tokens) head = next(i for i, tok in enumerate(tokens) if tok.text == "V") assert {"vocab:suffix", "initial"} <= tokens[head].tags, ( "the probe needs a token the TAG test would accept, or the " "assertion below passes without reaching the guard") # one token: the tag test answers, and answers yes assert _numeral_behind_the_initial_veto((head,), tokens) is True # two tokens headed by that same token: only the guard can say no, # so deleting it flips this assert _numeral_behind_the_initial_veto((head, head + 1), tokens) is False def test_the_reading_is_positional_and_total() -> None: """One verdict per piece, in order -- the invariant its readers index by, and the only thing that makes reading[k] mean pieces[k]. Three read it until #436 took the render join out of group, then assign's gate and its router; since #613 `_comma.decide` is the one reader.""" pieces, ptags, tokens, one_case = _comma_part("Smith, MD PSM I") reading = segment_suffix_reading(pieces, ptags, tokens, True, one_case) assert reading is not None assert len(reading) == len(pieces) assert all(isinstance(v, bool) for v in reading) def test_strict_ends_the_run_at_the_initial_shaped_numeral() -> None: """C14's strict knob, at the predicate rather than through a parse. Lenient continues the credential run through a one-character suffix word; strict vetoes initial-shaped words, so the run is no run at all and the segment falls to the walk. Asked behind a title/suffix dual, which opens no part (#603). Behind a credential that does open it ('PSM'), strict refuses the numeral the suffix reading but the opened part takes it in all the same, and hands it to the caller to report. """ pieces, ptags, tokens, one_case = _comma_part("Smith, RA I") args = (pieces, ptags, tokens) assert segment_suffix_reading(*args, True, one_case) == (True, True) assert segment_suffix_reading(*args, False, one_case) is None pieces, ptags, tokens, one_case = _comma_part("Smith, PSM I") args = (pieces, ptags, tokens) for lenient, taken in ((True, []), (False, [1])): absorbed: list[int] = [] assert segment_suffix_reading(*args, lenient, one_case, None, absorbed) == (True, True) assert absorbed == taken def _comma_part_reading(text: str) -> tuple[tuple[bool, ...] | None, list[int]]: pieces, ptags, tokens, one_case = _comma_part(text) picks: list[int] = [] reading = segment_suffix_reading(pieces, ptags, tokens, True, one_case, picks) return reading, picks def test_the_reading_names_the_picks_its_anchor_made() -> None: """#544: the members the anchor read as credentials after their own writing declined -- the picks assign reports. A member whose capitals lean credential decided itself and is not among them, and nothing stands in front of piece 0 to anchor it.""" assert _comma_part_reading("Smith, PhD Ma") == ((True, True), [1]) assert _comma_part_reading("Smith, PhD MA") == ((True, True), []) assert _comma_part_reading("Smith, Dr. PhD Ed Ma") == ( (False, True, True, True), [2, 3]) assert _comma_part_reading("Smith, MA PhD ba") == ( (True, True, True), [2]) def test_a_dual_in_the_leading_title_run_turns_the_anchor_off() -> None: """#544: a title/suffix dual standing in the given part's leading title run is a title there, and this reading then anchors nothing in the part -- neither by that dual, nor by a second one in the same run, nor by a credential behind them -- so the walk reads it as the parent did, its given slot's company starting past the given name. A plain title does not do this, and a dual behind a credential anchors like any suffix word.""" for text in ("Smith, Ms Ma", "Smith, Ms RA Ma", "Smith, RA PhD Ma", "Smith, RA MS Ma", "SMITH, RA MS BA", "Smith, Prof. RA Ma", "smith, prof. ra ma", "Smith, Dr. RA PhD Ma"): assert _comma_part_reading(text)[0] is None, text assert _comma_part_reading("Smith, Dr. PhD LAc") == ( (False, True, True), [2]) assert _comma_part_reading("Smith, PhD Ms Ma") == ( (True, True, True), [2]) # with no member to anchor, the dual reads as the suffix it is assert _comma_part_reading("Smith, RA PhD") == ((True, True), []) # and the walk's given slot, past the given name, reads its own # company as behind any given name assert parse("Smith, RA PhD Jr Ma").suffix == "Jr Ma" assert parse("Smith, RA PhD Jr Ma").given == "PhD" def _leading(text: str) -> int: state = _through_group(text) return leading_titles(state.pieces[0], state.piece_tags[0], list(state.tokens)) def test_the_leading_run_gives_back_the_name_word_a_suffix_cannot_be() -> None: """The peel's second floor, at the predicate. The run is counted before the trailing suffix peel runs, so its only floor was "leave one piece" and a run in front of nothing but suffix pieces took the last name word with it. The floor gives one word back -- and only where the rest really is all suffix pieces, which is what separates the first two readings below. """ assert _leading("Dr Pope Jr") == 1 # the floor fired: 2 -> 1 assert _leading("Dr Pope") == 1 # no all-suffix rest to see assert _leading("Lord Chancellor Jr") == 1 assert _leading("Lord Chancellor") == 1 def test_the_leading_run_declines_to_give_back_a_suffix_word() -> None: """The three shapes the floor must not touch. The word given back has to be a name CANDIDATE, so a run whose last word is itself suffix vocabulary keeps it; and a run with nothing behind it has no all-suffix rest to read at all, which is what leaves the whole-segment carve-out and the all-title inputs exactly as they were. """ assert _leading("RA DDS") == 1 # 'RA' is suffix vocabulary assert _leading("Jr. Ph. D.") == 1 # so is 'Jr.' assert _leading("Marquess of Bath") == 1 # nothing behind the run assert _leading("Dr.") == 1 # the whole-segment carve-out def test_the_leading_run_keeps_a_joined_unit_it_cannot_give_back() -> None: """The piece given back has to be ONE WORD. A joined unit led by a title is a title run in its own right, so handing it back would turn the title into a given name and leave the name with no title at all -- 'Prince of Wales Jr' reads title 'Prince of Wales', family 'Jr'. The run behind it is all suffix pieces and its last piece is no kind of suffix, so every other condition of the floor is met and only the length gate declines. """ assert _leading("Prince of Wales Jr") == 1 assert _leading("Prince of Wales") == 1 def _trailing(text: str) -> int: """trailing_titles over the rest assign hands it: the name pieces the S2 peel left, after the leading run is counted off. The count is what the chain LEAVES STANDING, the way peel_trailing counts -- so a walk that takes nothing returns the length of the rest it was handed, and each title taken is one off that.""" state = _through_group(text) pieces, ptags = state.pieces[0], state.piece_tags[0] tokens = list(state.tokens) rest = peel_walk(leading_titles(pieces, ptags, tokens), ptags) peeled = peel_trailing(rest, pieces, ptags, tokens, state.one_case) return trailing_titles(rest[:peeled.names], pieces, ptags, tokens) def test_the_trailing_run_chains_period_marked_title_words() -> None: """A RUN, mirroring the leading one. One word only would leave 'Prof.' a name word in the first reading below, which is the reason the walk chains rather than taking the last piece and stopping. """ assert _trailing("John Smith Prof. Dr.") == 2 # of four: both taken assert _trailing("John Smith Prof.") == 2 # of three assert _trailing("Dr. John Smith Prof.") == 2 # leading run too def test_the_trailing_run_leaves_one_name_piece_standing() -> None: """The floor, and the empty rest assign's carve-out needs. A name is never all title: the walk stops with one name piece left. An input that IS all title never reaches the walk at all -- the leading peel took the whole segment and the rest is empty, where the same floor returns 0 and leaves assign's bare-suffix carve-out reached exactly as before. """ assert _trailing("Smith Prof.") == 1 # of two: the title taken assert _trailing("Dr. Prof.") == 1 # the floor, on a rest the # walk would otherwise take assert _trailing("Smith") == 1 # one-piece rest assert _trailing("Prof.") == 0 # empty rest def test_the_trailing_run_reads_vocabulary_and_not_shape() -> None: """The whole difference from is_leading_title. That predicate carries H2's unlisted-abbreviation inference, so with it the first reading below would lose its family name to a title. The trailing slot has no shape rule: a period-marked word is claimed there only when the vocabulary claims it, and a bare title word is claimed not at all. """ # nothing claimed: the walk leaves every piece it was handed assert _trailing("John Smith Xyz.") == 3 # unlisted abbreviation assert _trailing("John Smith Sir") == 3 # no period assert _trailing("John Smith Esq.") == 2 # the peel took it first def test_the_trailing_run_refuses_a_joined_piece() -> None: """ONE WORD per piece, the gate the leading give-back shares. 'de la Prof.' is one piece of three tokens, and the tokens of a joined unit are not each a title word -- the particle chain made that unit a name. That first row does not PIN the gate, though: with the one-word test deleted it stands unchanged, because the shape test then runs on the piece's first token and 'de' wears no period (measured 2026-09-09). The conjunction-merged unit is the row that pins it -- 'Prof. and Dr.' is one piece whose first token is a period-marked title word, so without the gate the walk takes it and the name loses its family (measured one piece fewer standing under that mutation). """ assert _trailing("John de la Prof.") == 2 assert _trailing("John Smith Prof. and Dr.") == 3 def _peel_inputs(text: str, policy: Policy = Policy() ) -> tuple[list[int], Sequence[Sequence[int]], Sequence[Set[str]], Sequence[WorkToken]]: """The trailing peel's own inputs for segment 0, run through `group` (#289/#516's `one_case` reaches the peel only after group has assigned tags, so the shorter `_through_group` fixture -- not a bare tokenize+segment -- is what these tests need). The walk starts AFTER the leading title run, exactly as `_assign_main` and `_trailing` above start it: `peel_walk(0, ...)` would leave a leading title piece sitting in `rest` as an ordinary name piece the walk never filters out (peel_walk only drops group-flagged suffix pieces), and 'Mr MA' needs the title excluded to reach the one-piece floor its own test pins. """ state = _through_group(text, policy) pieces, ptags = state.pieces[0], state.piece_tags[0] tokens = state.tokens start = leading_titles(pieces, ptags, tokens) return peel_walk(start, ptags), pieces, ptags, tokens def test_peel_trailing_takes_the_three_lean_outcomes() -> None: # #289 at the trailing slot, the three outcomes against the peel's # three: a CREDENTIAL lean consumes with no words to spare, a NAME # lean declines WITH words to spare, and no lean at all leaves # rules/S4's count deciding exactly as it did. # A credential lean on a listed acronym is the writing settling the # clash (rules/S6, #785): taken as any suffix word is, and no pick, # so nothing reports it. Every other case still REPORTS: the pick # is appended either way, which is what the fork's report is built # from. for text, one_case, names, picked in ( ("Jack MA", False, 1, False), # credential lean, k == 2 ("Jack Ma", False, 2, True), # name lean, k == 2 ("Jack MA", True, 2, True), # one case: today's count ("John Smith Ma", False, 3, True), # name lean, k == 3 ("John Smith MA", False, 2, False), # credential lean ("JOHN SMITH MA", True, 2, True), # one case: the count ): rest, pieces, ptags, tokens = _peel_inputs(text) peeled = peel_trailing(rest, pieces, ptags, tokens, one_case=one_case) assert peeled.names == names, (text, one_case) assert bool(peeled.picks) is picked, (text, one_case) def test_peel_trailing_keeps_its_two_piece_floor_under_a_lean() -> None: # The floor is not what the lean moves. One piece behind a title # never reaches the peel at all -- measured 2026-09-15, 'Mr MA' # reads title 'Mr', family 'MA' and reports nothing -- and a lean # that reached below `k >= 2` would read it as a title with a # credential and no name at all. rest, pieces, ptags, tokens = _peel_inputs("Mr MA") peeled = peel_trailing(rest, pieces, ptags, tokens, one_case=False) assert peeled.names == len(rest) assert peeled.picks == () def test_peel_trailing_takes_a_declined_pick_an_anchor_stands_before( ) -> None: # #544 reverses the cost decisions/S6 had accepted: the surname # lean declines 'Ma', but the unambiguous 'Jr' IN FRONT of it # anchors it, so the walk takes it and reaches 'Jr' too -- and the # member is still a pick, reported as a counted one is. rest, pieces, ptags, tokens = _peel_inputs("abdul Smith Jr Ma") peeled = peel_trailing(rest, pieces, ptags, tokens, one_case=False) assert peeled.names == len(rest) - 2 assert len(peeled.picks) == 1 # a credential BEHIND the member anchors nothing: the walk peels # 'PhD' and stops at the declined 'Ma', a name word rest, pieces, ptags, tokens = _peel_inputs("Wang Ma PhD") peeled = peel_trailing(rest, pieces, ptags, tokens, one_case=False) assert peeled.names == len(rest) - 1 def test_the_walks_own_leading_piece_never_anchors_what_follows_it( ) -> None: # #544: a # bare unambiguous suffix word standing in the walk's OWN leading # position -- 'PhD'/'Om'/'Jr' are each unambiguous suffix # vocabulary and also plausible given names/surnames -- must never # anchor a member behind it, because that leading position is # always the name H4's carve-out keeps. Anchoring it let the run # collapse entirely: 'PhD Ma' read given 'PhD', suffix 'Ma', # losing the family outright, rather than 'PhD Ma' keeping the # reading the count alone gives it (given 'PhD', family 'Ma'). for text, family in (("Om Ma", "Ma"), ("PhD Ma", "Ma"), ("Jr Ma", "Ma")): rest, pieces, ptags, tokens = _peel_inputs(text) peeled = peel_trailing(rest, pieces, ptags, tokens, one_case=None) assert peeled.names == len(rest), text n = parse("Om Ma") assert (n.given, n.family, n.suffix) == ("Om", "Ma", "") n = parse("Om Ma Jr") assert (n.given, n.family, n.suffix) == ("Om", "Ma", "Jr") n = parse("PhD Ma") assert (n.given, n.family, n.suffix) == ("PhD", "Ma", "") # a genuine given name ahead of it frees the SAME word to anchor: # 'Om' is no longer the walk's own leading piece once 'John' is, # so it anchors 'Ma' exactly as any other unambiguous credential # standing in front does # 2026-10-21: 'om' is now an ambiguous credential (decisions/ # suffix-acronym-collisions, the 33-word batch), so the run is no # longer read as a suffix: 'Om' is a middle name and 'Ma' the family, # reported as suffix-or-name n = parse("John Om Ma") assert (n.given, n.middle, n.family, n.suffix) == ("John", "Om", "Ma", "") assert [str(a.kind) for a in n.ambiguities] == ["suffix-or-name"] def test_a_reserve_kept_leading_piece_beside_a_genuine_family_loss( ) -> None: # rules/S2's Accepted clause ("an unambiguous suffix is # consumed even when that leaves no family name at all", 'Smith # Jr.' -> family='') applies just the same when the LEADING piece # is itself listed suffix vocabulary rather than an ordinary name: # 'Om' is the walk's own leading piece and stays given (the # reserve), but 'Jr' -- NOT the leading piece -- is still consumed # unconditionally, leaving no family at all. n = parse("Om Jr") assert (n.given, n.family, n.suffix) == ("Om", "", "Jr") # and 'Jr', standing in front of 'Ma' but not itself the leading # piece, anchors it exactly as any other unambiguous credential # does -- the boundary costs the family, not the anchor n = parse("Om Jr Ma") assert (n.given, n.family, n.suffix) == ("Om", "", "Jr Ma") def _anchored_words(text: str) -> list[str]: rest, pieces, ptags, tokens = _peel_inputs(text) return [" ".join(tokens[t].text for t in pieces[i]) for i, anchored in zip( rest, credential_anchors(rest, pieces, ptags, tokens)) if anchored] def test_credential_anchors_reads_in_front_and_through_members() -> None: # #544: a position is anchored when the run in FRONT of it holds a # suffix piece that may anchor, and a lone member passes the # anchor on to the member behind it assert _anchored_words("John Smith PhD Ma") == ["Ma"] assert _anchored_words("John Smith PhD Ed Ma") == ["Ed", "Ma"] # the credential behind anchors nothing assert _anchored_words("John Smith Ma PhD") == [] assert _anchored_words("Wang Ma PhD") == [] def test_credential_anchors_stops_at_a_numeral_or_a_name_word() -> None: # the piece straight behind the credential is marked whatever it # is; what matters is the member past it, which a single-letter # roman numeral (one letter, in any case, the shape a bare middle # initial is written in) or a name word leaves unanchored. A # multi-letter numeral is not this shape and anchors like any # other suffix piece (pinned below). assert _anchored_words("John Smith PhD v Ma") == ["v"] assert _anchored_words("John Smith PhD Jones Ma") == ["Jones"] assert _anchored_words("John Smith PhD III Ma") == ["III", "Ma"] def test_a_connective_or_a_numeral_suffix_word_anchors_nothing() -> None: # the generational 'i' is also Catalan's conjunction, and between # two name words it is a link (rules/P3); a single-letter roman # numeral, in any case, is ONE LETTER, the shape a middle initial # is written in -- not excluded for being "no credential", since a # multi-letter numeral ('III') anchors like any other suffix word # (pinned in `test_credential_anchors_stops_at_a_numeral...` and # `test_a_multi_letter_numeral_anchors_like_any_suffix_word` # below). Neither a connective nor a single-letter numeral may # anchor. state = _through_group("John Smith Jr Ma") tokens = list(state.tokens) jr = next(i for i, t in enumerate(tokens) if t.text == "Jr") assert _anchors((jr,), tokens) tokens[jr] = dataclasses.replace( tokens[jr], tags=tokens[jr].tags | {"conjunction"}) assert not _anchors((jr,), tokens) state = _through_group("John Smith v Ma") v = next(i for i, t in enumerate(state.tokens) if t.text == "v") assert not _anchors((v,), state.tokens) # a particle that is also suffix vocabulary is the head of the # family name behind it, not a credential: the same 'Jr' tagged a # particle anchors nothing either tokens[jr] = dataclasses.replace( tokens[jr], tags=(tokens[jr].tags - {"conjunction"}) | {"particle"}) assert not _anchors((jr,), tokens) @pytest.mark.parametrize("text, fields", [ ("Jan vd Ma", {"given": "Jan", "family": "vd Ma"}), ("Smith vd Ma, John", {"given": "John", "family": "Smith vd Ma"}), ("Smith Mc Ma, John", {"given": "John", "family": "Smith Mc Ma"}), ("D. Mc Ba Ed, Smith", {"given": "Smith", "family": "D. Mc Ba Ed"}), ]) def test_a_particle_in_suffix_vocabulary_anchors_nothing( text: str, fields: dict[str, str]) -> None: # #544: 'vd' and 'mc' are particle AND unambiguous suffix # vocabulary; standing in front of a member they head the family # name, and anchoring there split it around a suffix ('Smith vd # Ma, John' read family 'Smith Ma', suffix 'vd'). A particle # MEMBER is still anchored by a credential in front of it. n = parse(text) assert {k: v for k, v in n.as_dict().items() if v} == fields assert parse("doe, jane v phd do").suffix == "phd do" def test_anchor_in_reach_is_false_only_where_the_pass_is() -> None: # the reach test is a necessary condition for the pass: False # where the first piece in front past the lone members is no # suffix piece, True (ask the pass) otherwise -- including where # the pass then answers False, a non-anchoring suffix piece # ('v') or the kept leading piece being in reach for text, expect in (("John Smith Ma", False), ("John Smith Ed Ma", False), ("John Smith PhD Ma", True), ("John Smith PhD Ed Ma", True), ("John Smith PhD v Ma", True)): rest, pieces, ptags, tokens = _peel_inputs(text) back = rest[len(rest) - 2::-1] assert anchor_in_reach(back, pieces, ptags, tokens) is expect, text if not expect: assert not credential_anchors(rest, pieces, ptags, tokens)[-1] # `skip` splices pieces out of the walk rest, pieces, ptags, tokens = _peel_inputs("John Smith PhD Ma") assert not anchor_in_reach(rest[2::-1], pieces, ptags, tokens, skip={rest[2]}) # a merged split credential is ONE suffix piece of two tokens, and # it anchors as the unsplit spelling does wherever it is in the # walk -- after a comma ('John Smith Ph. D. MEng' is rules/S7's # Accepted shape "a credential written split across two words with # no comma after the name", the merged piece standing outside its # walk; decisions/S7) assert parse("Doe, Jane Ph. D. MEng").suffix == "Ph. D. MEng" assert parse("Smith, Ph. D. MEng").suffix == "Ph. D. MEng" # and the member it speaks for reports as the pick it is assert [(a.kind.value, [t.text for t in a.tokens]) for a in parse("Smith, Ph. D. MEng").ambiguities] == [ ("suffix-or-name", ["MEng"])] def test_a_multi_letter_numeral_anchors_like_any_suffix_word() -> None: # #544: the exclusion is about SHAPE (a single letter # reads as a middle initial, 'John Smith PhD V Ma' keeping 'V' a # name word), not about numerals being no credential -- 'III' is # unambiguous suffix vocabulary of more than one letter and # anchors exactly as 'Jr' does. n = parse("John Smith PhD III Ma") assert (n.family, n.suffix) == ("Smith", "PhD III Ma") # without the degree in front: the single letter is a middle # initial and anchors nothing. With it, since #602, the degree # starts a run to the end of the part and 'V Ma' are inside it # ('John Smith PhD V Ma' reads suffix 'PhD V Ma'). n = parse("John Smith V Ma") assert (n.middle, n.family, n.suffix) == ("Smith V", "Ma", "") def test_the_anchor_pass_starts_past_the_leading_title_run() -> None: # a title/suffix dual opening the given part is a title there and # anchors nothing, at the given slot and in the part read whole assert parse("Smith, RA MA Ma").middle == "Ma" assert parse("Smith, Ms Ma").given == "Ma" # past that position the same dual anchors assert parse("John Smith RA MEng").suffix == "RA MEng" def test_the_given_slot_asks_the_anchor_only_when_the_writing_declines( ) -> None: # #544: `credential_at_the_given_slot` takes the anchor as a THUNK # and asks it last, so a member the writing already settles never # pays for the anchor pass calls: list[str] = [] def anchored() -> bool: calls.append("asked") return True tokens = _through_group("Doe, John MA Ma do").tokens caps, title, particle = ( next(t for t in tokens if t.text == text) for text in ("MA", "Ma", "do")) # the capitals lean credential, and one case leans nothing (the # count's reading): neither asks assert credential_at_the_given_slot(caps, False, anchored) assert credential_at_the_given_slot(title, True, anchored) assert calls == [] # a Title-case member and a particle member with no lean decline, # so the anchor answers -- once each assert credential_at_the_given_slot(title, False, anchored) assert credential_at_the_given_slot(particle, True, anchored) assert calls == ["asked", "asked"] # with no anchor to ask, the declined reading stands assert not credential_at_the_given_slot(title, False) assert not credential_at_the_given_slot(particle, True) def test_a_shape_only_token_reports_without_being_taken() -> None: # `Policy(unlisted_dotted_suffixes=False)`: name material # everywhere, as 2.3 read it -- and # the fork is still reported, because the parser chose the name # reading over a credential one and that is the call a caller # wants told (#516). rest, pieces, ptags, tokens = _peel_inputs( "John Smith X.Y.Z.", Policy(unlisted_dotted_suffixes=False)) peeled = peel_trailing(rest, pieces, ptags, tokens, one_case=False) assert peeled.names == len(rest) assert peeled.picks != () def test_a_by_shape_token_takes_the_count_and_never_a_lean() -> None: # A token admitted by SHAPE carries no writing convention to read, # so the count decides it in either case spelling (#516). for text, names in (("John Smith X.Y.Z.", 2), ("john smith x.y.z.", 2), ("Jack X.Y.Z.", 2)): rest, pieces, ptags, tokens = _peel_inputs(text) peeled = peel_trailing(rest, pieces, ptags, tokens, one_case=False) assert peeled.names == names, text assert peeled.picks != (), text @pytest.mark.parametrize("text, expected", [ ("Xyz.", True), # rules/H2 -- an unlisted Latin abbreviation ("김민준.", False), # #323: hangul has no period abbreviations ("田中.", False), # nor Han ("たなか.", False), # nor kana ("Kim김.", False), # contains-any, not wholly-of: one CJK char vetoes ("J.", False), # a bare initial never was (H2 boundary) ]) def test_leading_title_shape_refuses_an_initialless_script( text: str, expected: bool) -> None: # Xyz. rather than Rev. for the Latin side: Rev. is LISTED, so # is_title_piece claims it before the shape is asked state = _through_group(text + " Smith") assert is_leading_title(state.pieces[0][0], state.piece_tags[0][0], state.tokens) is expected @pytest.mark.parametrize("text", [ "Xyz.", "Dr.", "Xyz", "X.", "田中.", "김민준.", "たなか.", "Kim김.", "J.", "Chas.", "MA.", ]) def test_is_title_shaped_and_is_leading_title_agree(text: str) -> None: # The two spellings of H2's shape test -- `is_title_shaped`'s own # docstring and `is_leading_title`'s inline copy each point here -- # kept in step by a check over the UNION of both predicates' own # example tables (this one and test_vocab.py's # test_is_title_shaped_is_h2_s_shape_alone), rather than by a # sentence alone (#289/#516 quality-review follow-up). # 'Dr.' answers True through `is_leading_title`'s EARLIER # `is_title_piece` (LISTED vocabulary) branch, not through the # inline shape copy this test means to pin -- it stays in the # union for parity with `is_title_shaped`'s own table (which # answers True the same way, for the same reason: listed or not # is a question neither predicate asks), not because it exercises # the shape branch on either side. # 'Chas.' and 'MA.' are given names written short, which outrank the # shape (#789): the inline copy reads classify's tag, this one the # lexicon, and the two must agree. state = _through_group(text + " Smith") assert is_title_shaped(text, state.lexicon) == is_leading_title( state.pieces[0][0], state.piece_tags[0][0], state.tokens) def test_own_words_is_the_name_s_own_span_and_its_clause_cut() -> None: # rules/P3 says the case question is asked of the name's OWN # words. This helper is that span, taken off the token stream so # both the site that runs before classify and classify itself ask # one function (#289/#516). state = _state_through("segment", "Jane née Jones Smith") texts, cut = own_words(state.tokens, state.comma_offsets, state.lexicon.maiden_markers) assert texts == ["Jane"] assert cut == 1 # a delimited clause's tokens arrive with a role already set, so # they are not the name's own words either state = _state_through("segment", "Andrew (Andy) Perkins") texts, cut = own_words(state.tokens, state.comma_offsets, state.lexicon.maiden_markers) assert texts == ["Andrew", "Perkins"] assert cut == 3 # `comma_offsets` is load-bearing only for a marker PHRASE whose # words would otherwise straddle a comma: 'z domu' is one entry, # and 'z' and 'domu' sit on opposite sides of the comma in 'Anna # z, domu Nowak'. With the real offsets the two words are in # different buckets, the phrase cannot complete, and clause_at # falls through to the whole name; passing () collapses every # token into one bucket, the phrase wrongly completes, and the # cut lands at 'z' instead. Measured 2026-09-17. state = _state_through("segment", "Anna z, domu Nowak") texts, cut = own_words(state.tokens, state.comma_offsets, state.lexicon.maiden_markers) assert texts == ["Anna", "z", "domu", "Nowak"] assert cut == 4 texts, cut = own_words(state.tokens, (), state.lexicon.maiden_markers) assert texts == ["Anna"] assert cut == 1 def test_own_words_takes_a_marker_map_when_the_caller_has_one() -> None: # classify has already decided which tokens are marker RUN heads, # so it hands that map over rather than paying a second walk. state = _state_through("segment", "Jane née Jones Smith") heads = {1: "vocab:maiden-marker", 2: "vocab:maiden-marker-cont"} assert own_words(state.tokens, state.comma_offsets, state.lexicon.maiden_markers, heads) == (["Jane"], 1) # an incomplete phrase entry: no head tag, so the map's answer is # "no clause" -- and the self-built path agrees, since both now # run the SAME completion test (#289/#516) state = _state_through("segment", "Anna z Nowak") assert own_words(state.tokens, state.comma_offsets, state.lexicon.maiden_markers, {}) == (["Anna", "z", "Nowak"], 3) assert own_words(state.tokens, state.comma_offsets, state.lexicon.maiden_markers)[1] == 3 def test_own_words_two_spellings_agree_on_the_one_case_verdict() -> None: # #289/#516's fix: own_words' self-built path (no marker_tags map) # now calls _vocab.tag_marker_runs -- the SAME run-completion test # classify uses -- instead of approximating it with a text-only # head walk (the retired first_marker_head). The two spellings # below -- an explicit map built ahead of time, and none at all -- # are now the SAME computation, so they agree on the CUT, not just # the verdict, by construction rather than by corpus luck. # # 'ANNA z Nowak, MD' is why the old approximation was not merely # imprecise but wrong: 'z' opens the phrase entry 'z domu', which # completes neither way, but the text-only walk took the open # alone as the cut (1: just 'ANNA', one-case True), while the real # run-completion test finds no run and falls through to the full # own-words span (mixed case, False) -- a flipped verdict, not a # different span with the same answer. Measured 2026-09-17. for text in ("Anna z Nowak", "Anna z (domu) Nowak", "ANNA z Nowak, MD"): state = _state_through("segment", text) folded = [_normalize(t.text) for t in state.tokens] built_map = tag_marker_runs(state.tokens, state.comma_offsets, state.lexicon.maiden_markers, folded) tagged = own_words(state.tokens, state.comma_offsets, state.lexicon.maiden_markers, built_map) walked = own_words(state.tokens, state.comma_offsets, state.lexicon.maiden_markers) assert tagged == walked, text assert is_one_case(tagged[0]) == is_one_case(walked[0]), text def test_tag_marker_runs_answers_in_ascending_index_order() -> None: """`own_words` depends on it, and nothing else said so. Its own docstring states the requirement -- "`marker_tags`' keys must arrive in index order for the walk below to find the SMALLEST head in one pass" -- and then satisfies it by BREAKING at the first head. So a map whose keys arrived out of order would cut the span at whichever head happened to come first, silently, and every caller's one-case verdict with it. The guarantee lives in `tag_marker_runs`' left-to-right walk; this is the assertion that holds it there (2026-09-18 review round). """ for text in ("Anna z Nowak nee Jones", "Jane née Jones geb Schmidt", "Anna z domu Nowak nee Jones", "John Smith"): state = _state_through("segment", text) folded = [_normalize(t.text) for t in state.tokens] keys = list(tag_marker_runs(state.tokens, state.comma_offsets, state.lexicon.maiden_markers, folded)) assert keys == sorted(keys), text #: The run words of the reach sweep below: unambiguous credentials, #: members (one a particle too), the two particles of the suffix #: vocabulary, a one-letter numeral, titles and a name word. _REACH_WORDS = ("PhD", "Jr", "MA", "Ma", "Ed", "Do", "vd", "Mc", "V", "Prof.", "Dr.", "Jones") def _reach_failures(texts: Sequence[str]) -> list[str]: """Every (text, segment, position, first_kept, start) at which `anchor_in_reach` answers False while `credential_anchors` anchors the lone member standing there -- the direction the reach test must never get wrong, since a False skips the pass. Asked of every segment of every text, over `order` both from the segment's first piece and from past its leading title run, and with the leading position both kept and read, the reach walking everything in front each time (the superset every caller passes).""" out = [] for text in texts: state = _through_group(text) tokens = state.tokens for seg, (pieces, ptags) in enumerate( zip(state.pieces, state.piece_tags)): for start in {0, leading_titles(pieces, ptags, tokens)}: order = range(start, len(pieces)) for first_kept in (True, False): anchors = credential_anchors(order, pieces, ptags, tokens, first_kept) for pos, m in enumerate(order): piece = pieces[m] if not (len(piece) == 1 and AMBIGUOUS_ACRONYM_TAG in tokens[piece[0]].tags): continue if anchors[pos] and not anchor_in_reach( range(m - 1, -1, -1), pieces, ptags, tokens): out.append(f"{text!r} seg {seg} at {m} " f"first_kept={first_kept} " f"start={start}") return out def test_anchor_in_reach_never_hides_an_anchor() -> None: """`anchor_in_reach` False implies `credential_anchors` False, at every lone member of every segment, over the case table's texts and every run of one to three `_REACH_WORDS` behind 'John Smith ' and 'Doe, Jane ' (4,497 distinct texts, 742 of them the table's; 0.23s on py3.11, measured 2026-09-28). RECORDED NEGATIVE CONTROL: with the reach test reading a "vocab:suffix" token as no suffix piece (returning False there), 878 positions fail (measured 2026-09-28).""" texts = {case.text for case in CASES if case.policy is None and case.locale is None} for n in (1, 2, 3): for run in itertools.product(_REACH_WORDS, repeat=n): for head in ("John Smith ", "Doe, Jane "): texts.add(head + " ".join(run)) failures = _reach_failures(sorted(texts)) assert not failures, (f"{len(failures)} anchored member(s) the " f"reach test hides:\n" + "\n".join(failures[:15])) def test_a_title_the_fixed_point_splices_out_keeps_no_peel_pick() -> None: # tail_reading's resumed pass (#558) carries the picks of the walk # before it, and that walk STOPPED at the title the chain then took: # where the title is also an ambiguous acronym the stop was a pick, # and a fresh walk over the spliced pieces never meets the word. # No default title is an ambiguous acronym, so a caller's lexicon is # the only way here. Recorded negative control (2026-10-01): with # the drop removed, this reads the same fields and reports # 'suffix-or-name' on 'Ma.', a word the parse took as a title. parser = Parser(lexicon=Lexicon.default().add(titles={"ma"})) name = parser.parse("John Smith Ma. PhD Jr.") assert (name.title, name.given, name.family, name.suffix) == ( "Ma.", "John", "Smith", "PhD Jr.") assert name.ambiguities == () # rules/S8 (#602): a credential after the name core starts a run to # the end of its part. The exclusion tests pass before the change too: # they are the negative controls that make the first two meaningful. def test_a_credential_after_two_name_words_starts_the_run() -> None: name = parse("John Smith PhD Jones") assert (name.given, name.family, name.suffix, name.middle) == ( "John", "Smith", "PhD Jones", "") def test_a_title_word_inside_the_run_reads_as_a_title() -> None: name = parse("Eric H. Holder Jr. Attorney General") assert (name.title, name.suffix, name.family) == ( "Attorney General", "Jr.", "Holder") def test_a_title_word_never_starts_the_run() -> None: # 746 title words are in no suffix set, many of them surnames assert parse("Mary Jane Pope Smith").family == "Smith" def test_the_ambiguous_class_and_initials_do_not_start_the_run() -> None: assert parse("John Smith Ma Jones").family == "Jones" assert parse("JOHN SMITH MA JONES").family == "JONES" assert parse("John Smith V Jones").family == "Jones" def test_a_member_its_capitals_settle_starts_the_run() -> None: # rules/S8 (#785): written in capitals in a name of mixed case, a # listed member marks the post-nominals as its dotted spelling does; # its particle reading still refuses 'DO', the clash settled being # the one with the name reading alone for written in ("MA", "M.A."): name = parse(f"Smith, John {written} Jones") assert name.suffix == f"{written} Jones" assert [a.kind.value for a in name.ambiguities] == [ "suffix-or-name"] assert parse("John Smith MA JD Jones").suffix == "MA JD Jones" # a credential split across a space behind it is no name word, # merged into one piece ('Pharm D') or left out of the walk (Ph. D.) assert parse("John Smith MA Pharm D Jones").suffix == "MA Pharm D Jones" assert parse("John Smith MA Ph. D. Jones").suffix == "MA Ph. D. Jones" assert parse("John Smith M.A. Jones").suffix == "M.A. Jones" # with no comma a name word behind the capitals keeps them a name # word, as initials may be (Derek, 2026-10-26) for text, family in (("John Smith MA Jones", "Jones"), ("Maria Ana MA Silva", "Silva"), ("Jean Pierre MA DUPONT", "DUPONT"), ("Mary Jane MA Pope", "Pope")): assert parse(text).family == family, text assert parse("John Smith DO Jones").family == "DO Jones" def test_a_joined_connective_does_not_start_the_run() -> None: assert parse("Josep Carod i Rovira").suffix == "" def test_one_name_word_before_the_credential_is_no_core() -> None: assert parse("John PhD Smith").family == "Smith" def test_the_given_part_after_a_family_comma_reads_the_run_too() -> None: name = parse("Smith, John PhD Jones") assert (name.given, name.family, name.suffix, name.middle) == ( "John", "Smith", "PhD Jones", "") def test_a_title_inside_the_given_parts_run_is_a_title() -> None: name = parse("Holder, Eric Jr. Attorney General") assert (name.title, name.suffix) == ("Attorney General", "Jr.") # #610: `trailing_candidates` is the maiden take's choice of which # clause words to read the clause-free view over, and its contract is # a SUPERSET of what `tail_reading` takes from that view. Checked AT # THE TAKE, on the pieces the take is handed -- before any join, which # is where it runs -- by wrapping `_group._maiden_take` and the # candidates call inside it: the clause-free name is the head plus every # clause word after the first (which the take always keeps), and every # piece the tail reading takes from it must fall at or after the cut. _CANDIDATE_HEADS = ("Jane Doe", "J.", "Jane van der Berg", "Mai Le", "abdul Berg", "John") _CANDIDATE_WORDS = ("Smith", "VI", "V", "III", "MA", "Ma", "PhD", "Jr.", "Prof.", "Dr.", "Pope.", "do", "DO", "de", "X.Y.Z.", "Ph. D.", "i", "Jones") def _candidate_misses(monkeypatch: pytest.MonkeyPatch, candidates: Callable[..., int] = trailing_candidates, ) -> list[str]: misses: list[str] = [] seen: list[tuple[int, int]] = [] real_take = _group._maiden_take def cut(lo: int, pieces: Sequence[Sequence[int]], ptags: Sequence[Set[str]], tokens: Sequence[WorkToken]) -> int: c = candidates(lo, pieces, ptags, tokens) seen.append((lo, c)) return c def take(pieces: Sequence[Sequence[int]], ptags: Sequence[Set[str]], tokens: Sequence[WorkToken], one_case: bool | None, site: _group.ClauseSite, ambiguities: list[PendingAmbiguity], ) -> _group.MaidenIndices | None: seen.clear() answer = real_take(pieces, ptags, tokens, one_case, site, ambiguities) for lo, c in seen: m = next(v for v in range(1, len(pieces)) if _group._is_maiden_marker_piece(pieces[v], tokens)) left = list(range(m)) + list(range(lo + 1, len(pieces))) view = [pieces[q] for q in left] vtags = [ptags[q] for q in left] rest = peel_walk(leading_titles(view, vtags, tokens), vtags) kept, chained, peel = tail_reading(rest, view, vtags, tokens, one_case) taken = {left[q] for q in (*kept[peel.names:], *chained) if q >= m} if any(j < c for j in taken): misses.append(" ".join(tokens[i].text for p in pieces for i in p)) return answer monkeypatch.setattr(_group, "trailing_candidates", cut) monkeypatch.setattr(_group, "_maiden_take", take) for head in _CANDIDATE_HEADS: for n in (1, 2, 3): for tail in itertools.product(_CANDIDATE_WORDS, repeat=n): _through_group(f"{head} nee Smith {' '.join(tail)}") return misses def test_the_trailing_candidates_cover_what_the_tail_reading_takes( monkeypatch: pytest.MonkeyPatch) -> None: """M2's take ends the clause at the trailing run the clause-free name reads, which it can only find if every word that run might hold is in its view. 6 heads x 18 words to a tail of three after 'nee Smith', 37,044 parses through `group`, about 2.6s on py3.11 (measured 2026-10-04). RECORDED NEGATIVE CONTROL, measured 2026-10-04 on this grid: the candidate loop as #601 shipped it (master 8a8459a8), which admitted a roman numeral by its shape only as the clause's LAST word, misses 432 texts -- every one a numeral in no wordlist ('VI') with a period-marked title or the merged 'Ph. D.' behind it. The chain takes the title first, and the walk never holds the flagged 'Ph. D.', so either way the numeral is the walk's last piece and the fork reads it: 'Jane Doe nee Smith VI Prof.' kept maiden 'Smith VI' where 'John Smith VI Prof.' reads suffix 'VI'. A first draft of the fix covered the title half and missed the 'Ph. D.' half. A first version of this test read plain names through all of `group`, so its pieces had the joins applied that the take runs before ('de VI Prof.' arrived as one piece); review moved the oracle to the take itself. """ assert _candidate_misses(monkeypatch) == [] def _joined(text: str, *, letter_stays: bool, titles_only: bool, frozen_words: Set[str] = frozenset(), ) -> tuple[list[str], list[set[str]]]: """`join_connectives` over a whole name's words as classify tagged them, one fresh piece each -- what it takes from either caller -- with the words in `frozen_words` frozen by token. Returns each piece's text and its tags.""" state = _state_through("classify", text) tokens = list(state.tokens) pieces = [[i] for i in state.segments[0]] ptags: list[set[str]] = [set() for _ in pieces] frozen = {i for i, t in enumerate(tokens) if t.text in frozen_words} join_connectives(pieces, ptags, tokens, letter_stays=letter_stays, titles_only=titles_only, frozen=frozen) return [" ".join(tokens[i].text for i in p) for p in pieces], ptags def test_a_connective_run_merges_before_it_joins_in_both_modes() -> None: """rules/P3's "connective runs included", for both callers: the run joins as one link, so a title before it takes the word after it too. The comma decision's copy of the loop merged no run and joined 'Minister of the' alone (#617).""" for titles_only in (True, False): words, ptags = _joined("Minister of the Interior Smith", letter_stays=True, titles_only=titles_only) assert words == ["Minister of the Interior", "Smith"], titles_only assert "title" in ptags[0], titles_only def test_titles_only_leaves_a_join_with_no_title_unmade() -> None: """The comma part's mode: a connective whose neighbour is no title stays a word for the reading to place; group's mode joins it anyway, taking no title kind.""" words, _ = _joined("Jones and Smith", letter_stays=True, titles_only=True) assert words == ["Jones", "and", "Smith"] words, ptags = _joined("Jones and Smith", letter_stays=True, titles_only=False) assert words == ["Jones and Smith"] assert "title" not in ptags[0] def test_the_single_letter_carve_out_is_the_callers_answer() -> None: """`letter_stays` is P3's carve-out as the caller reads it: group from the rootname count, the comma part always.""" assert _joined("Mr. y Mrs.", letter_stays=True, titles_only=True)[0] == ["Mr.", "y", "Mrs."] assert _joined("Mr. y Mrs.", letter_stays=False, titles_only=True)[0] == ["Mr. y Mrs."] def test_a_frozen_connective_neither_joins_nor_merges_into_a_run() -> None: """`frozen` names connectives by TOKEN index; one there is the subject of no join and no run merge (a neighbour's join may still take it in, group's documented reach).""" assert _joined("Mr. and of Mrs.", letter_stays=True, titles_only=True, frozen_words={"of"})[0] == ["Mr. and of", "Mrs."] assert _joined("Mr. and of Mrs.", letter_stays=True, titles_only=True)[0] == ["Mr. and of Mrs."] # the join half: a frozen connective is no join's subject, whatever # its neighbours are (the run above never reaches it as one, the # 'and' join taking it in first -- #617's test review) assert _joined("Mr. and Mrs.", letter_stays=True, titles_only=True, frozen_words={"and"})[0] == ["Mr.", "and", "Mrs."] def test_only_groups_joins_derive_a_prefix() -> None: """Group's mode takes the neighbour's prefix kind, which is what lets a joined 'von und zu' chain on as a particle (v1 PR #191); the comma part's titles-only mode takes none, nothing there being a particle chain. `und` is a connective in the default vocabulary and no particle, so the derivation and not the particle chain is what is read.""" words, ptags = _joined("Otto von und zu Habsburg", letter_stays=False, titles_only=False) assert words == ["Otto", "von und zu", "Habsburg"] assert "prefix" in ptags[1] and "title" not in ptags[1] # 'freiherr' is a title AND a particle: titles-only takes the title words, ptags = _joined("Freiherr and Dr.", letter_stays=True, titles_only=True) assert words == ["Freiherr and Dr."] assert "title" in ptags[0] and "prefix" not in ptags[0] def _read(text: str) -> tuple[ParseState, tuple[TailRead, int] | None]: """S2's trailing read (#614) over a whole name's words as classify tagged them, one piece each, the particle runs merged as group merges them ahead of the read (#625).""" state = _state_through("classify", text) tokens = list(state.tokens) pieces = [[i] for i in state.segments[0]] ptags: list[set[str]] = [set() for _ in pieces] n = leading_titles(pieces, ptags, tokens) _merged, lead = merge_particle_runs(pieces, ptags, tokens, n) return state, read_trailing_run(pieces, ptags, tokens, state.one_case, n, lead) def _merged_pieces(state: ParseState) -> tuple[list[list[int]], list[set[str]]]: pieces = [[i] for i in state.segments[0]] ptags: list[set[str]] = [set() for _ in pieces] tokens = list(state.tokens) merge_particle_runs(pieces, ptags, tokens, leading_titles(pieces, ptags, tokens)) return pieces, ptags def _texts(state: ParseState, indices: Set[int]) -> list[str]: return sorted(state.tokens[i].text for i in indices) def test_the_trailing_read_hands_over_the_run_whole() -> None: """Every answer assign takes off the read: where the run starts, its tokens, the H5 chain's titles, the title words inside #602's run and the name word that run absorbed.""" state, found = _read("John Smith PhD Prof Jones Dr.") assert found is not None read, start = found assert start == 2 assert _texts(state, read.tail) == ["Dr.", "Jones", "PhD", "Prof"] assert _texts(state, read.titles) == ["Dr."] assert _texts(state, read.run_titles) == ["Prof"] assert [[state.tokens[i].text for i in p] for p in read.peel.absorbed] \ == [["Jones"]] def test_the_trailing_read_counts_a_particle_chain_as_one_word() -> None: """The chain's run and the word it takes are one name word, so a dotted acronym behind them has no word to spare and the read takes nothing: a run of no pieces, at the end.""" state, found = _read("Freiherr von Berg X.Y.Z.") assert found is not None read, start = found assert read.tail == frozenset() and start == len(state.segments[0]) def test_the_trailing_read_declines_where_there_is_nothing_to_split() -> None: """None where no name piece stands past the leading titles, and where the run holds a name piece (a title in front of a name word the peel declined), which is read as before #614.""" assert _read("Dr.")[1] is None assert _read("John Dr. X.Y.Z.")[1] is None def _units(text: str) -> list[tuple[str, int]]: """`_chain_units` over a whole name's pieces as group holds them at the read, as (piece text, mark) pairs.""" state = _state_through("classify", text) pieces, ptags = _merged_pieces(state) tokens = list(state.tokens) units = _chain_units(pieces, ptags, tokens, leading_titles(pieces, ptags, tokens)) assert units is not None return [(" ".join(tokens[i].text for i in p), u) for p, u in zip(pieces, units)] def test_the_read_marks_the_units_the_chain_will_make() -> None: """#620: a particle opens a unit, and a name word the chain joins to it is JOINED; a suffix piece ends the run and stays a unit of its own. The particles behind it are in its piece already, a dual among them no word of its own ('van mc', #625).""" marks = _units("John van mc Berg PhD") assert marks == [("John", OPENS), ("van mc", OPENS), ("Berg", JOINED), ("PhD", OPENS)] def test_the_chain_opens_no_unit_inside_the_titles() -> None: """#624: of two titles that are also particles the second leads the chain (`chain_lead`), so no unit opens inside the titles: 'van' opens the first, past them. A word the reading weighs ('MA') is a word of its own though the chain may join it.""" marks = _units("Freiherr St van Berg MA") assert [u for _, u in marks] == [OPENS, OPENS, OPENS, JOINED, OPENS] marks = _units("Freiherr St MA") assert [u for _, u in marks] == [OPENS, OPENS, OPENS] def test_a_merged_particle_run_is_a_particle_and_no_title() -> None: """#625: the particles straight behind each other past the chain's lead merge into one piece that keeps `prefix` and drops `title`. P3's join carries the title tag in from a word in both vocabularies ('St und Berg'), and kept on the merged run it read the run as a leading title: 'Freiherr Do St und Berg MA' read title 'Freiherr Do St und Berg' with the tag kept, family 'Do St und Berg' without it. The particle leading the name stays out of the merge (P4), so 'de la van Vega' merges 'la van' alone; two runs in one name merge each, and a lone particle is no run ('John van Smith de la Berg').""" state = _state_through("classify", "Freiherr Do St und Berg MA") tokens = list(state.tokens) pieces = [[i] for i in state.segments[0]] ptags: list[set[str]] = [set() for _ in pieces] join_connectives(pieces, ptags, tokens, letter_stays=False, titles_only=False) assert "title" in ptags[2] merged, lead = merge_particle_runs(pieces, ptags, tokens, leading_titles(pieces, ptags, tokens)) assert lead == 0 assert [" ".join(tokens[i].text for i in p) for p in pieces] \ == ["Freiherr", "Do St und Berg", "MA"] assert "prefix" in ptags[1] and "title" not in ptags[1] assert merged == {pieces[1][0]} state = _state_through("classify", "de la van Vega") tokens = list(state.tokens) pieces = [[i] for i in state.segments[0]] ptags = [set() for _ in pieces] merged, lead = merge_particle_runs(pieces, ptags, tokens, 0) assert lead == 0 assert [" ".join(tokens[i].text for i in p) for p in pieces] \ == ["de", "la van", "Vega"] for text, want in ( ("John van der Smith de la Berg", ["John", "van der", "Smith", "de la", "Berg"]), ("John van Smith de la Berg", ["John", "van", "Smith", "de la", "Berg"])): state = _state_through("classify", text) tokens = list(state.tokens) pieces = [[i] for i in state.segments[0]] ptags = [set() for _ in pieces] merged, _lead = merge_particle_runs(pieces, ptags, tokens, 0) assert [" ".join(tokens[i].text for i in p) for p in pieces] \ == want assert merged == {p[0] for p in pieces if len(p) > 1} def test_the_read_takes_the_title_run_as_written_before_the_merge( ) -> None: """#625: group hands the read the title run read before the merge. Merging 'mc mc' makes it no suffix piece, which would undo H3's give-back of 'Dr.' and move the name's start; the read keeps the run as written, so the credential run behind the first 'mc' stays the read's. Recorded negative controls (2026-10-10): with the read asking `leading_titles` again after the merge, the read takes nothing here and the parse reads family 'mc Jr mc mc'; with group handing the read the run asked after the merge, the read helper below cannot see it and the parse assertion fails the same way.""" state, found = _read("Dr. mc Jr mc mc") assert found is not None read, start = found assert start == 2 assert _texts(state, read.tail) == ["Jr", "mc", "mc"] name = parse("Dr. mc Jr mc mc") assert (name.title, name.family, name.suffix) == ("Dr.", "mc", "Jr mc mc") _WEIGHED_GRID_HEADS = ("John van", "Freiherr von", "anh van", "Jan de la", "John van Berg", "Freiherr von Berg") _WEIGHED_GRID_WORDS = ("MA", "Ma", "ma", "DO", "do", "vd", "mc", "V", "VI", "III", "X.Y.Z.", "G.J.", "XYZ", "PhD", "Jr", "Jr.", "J.", "Prof.", "Dr.", "Prof", "Jones", "and") def _weighed_grid_violations() -> list[str]: """The names over the grid whose trailing read puts a piece `_chain_units` marks JOINED into the run.""" bad = [] for head in _WEIGHED_GRID_HEADS: for n in (1, 2): for tail in itertools.product(_WEIGHED_GRID_WORDS, repeat=n): text = " ".join((head,) + tail) state, found = _read(text) if found is None: continue pieces, ptags = _merged_pieces(state) tokens = list(state.tokens) units = _chain_units(pieces, ptags, tokens, leading_titles(pieces, ptags, tokens)) if units is None: continue read, _start = found if any(units[k] == JOINED and p[0] in read.tail for k, p in enumerate(pieces)): bad.append(text) return bad def test_a_word_the_read_takes_is_never_one_the_count_joined() -> None: """`_weighed` is a hand statement of what the trailing reading weighs, and this is what pins it to the reading: over the grid, no piece the count folded into a particle run as a plain word ends up in the run the read takes (#620's /simplify, after `trailing_candidates`' pin against `tail_reading`). An `initial` is weighed for the numeral fork's count rather than for being taken, so this cannot see it; its case row holds it. Recorded negative control (2026-10-08): with `_weighed` answering False, 320 of the grid's names fail ('John van Prof.', 'John van MA Prof.').""" assert _weighed_grid_violations() == [] def test_the_second_unit_is_found_once_per_read( monkeypatch: pytest.MonkeyPatch) -> None: """The second name unit is looked up once for the read and handed to every pass, not rescanned from the front by each pass, which cost the square of a credential run behind a long joined surname -- C-level work no frame guard sees (#620's /simplify, measured 5.77x for 4x the input against master's 4.08x). Recorded negative control: one lookup per pass, 32 at the size below.""" calls = [0] real = _pieces.second_unit def counting(rest: Sequence[int], units: Sequence[int]) -> int: calls[0] += 1 return real(rest, units) monkeypatch.setattr(_pieces, "second_unit", counting) parse("Freiherr von " + "Berg " * 32 + "MA Dr. " * 32) assert calls[0] == 1 ��������������������������derek73-python-nameparser-456b024/tests/v2/pipeline/test_post_rules.py������������������������������0000664�0000000�0000000�00000172167�15273032717�0026033�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������import dataclasses import sys import pytest from nameparser import Parser, parse from nameparser._lexicon import Lexicon from nameparser._pipeline import run from nameparser._pipeline import _post_rules from nameparser._pipeline._post_rules import suffix_entries from nameparser._pipeline._state import ParseState from nameparser._policy import (FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST, GIVEN_FIRST, PatronymicRule, Policy, Script) from nameparser._types import (FOLDED_TAG, STABLE_TAGS, UNJOINED_CONJUNCTION_TAG, UNJOINED_TAG, AmbiguityKind, Role) # A reduced lexicon, the convention in every pipeline stage module: a # stage test should not move when shipped vocabulary does. What it must # NOT do is classify a word DIFFERENTLY from the shipped sets, which # makes a test pass by parsing something other than the name it reads # as -- `_assert_fixture_mirrors_shipped` below holds the line, and # found three such words when it was written (#395): `dr` and `md` were # absent while shipping as titles, and `la` sat in the never-given half # while shipping as ambiguous. _LEX = Lexicon( titles=frozenset({"mr", "sir", "dr"}), given_name_titles=frozenset({"sir"}), after_given_titles=frozenset({"mr"}), particles=frozenset({"de", "der", "ibn", "la", "van", "vd"}), particles_ambiguous=frozenset({"la", "van"}), # `씨` is a ONE-LETTER suffix word, which starts no credential run # (rules/S8's two-letter minimum -- any longer suffix word would, # an honorific included since #679), the filler a test needs where # a credential would now take the rest of the part suffix_words=frozenset({"jr", "씨"}), # `vd` mirrors its shipped dual membership -- particle AND # UNAMBIGUOUS suffix acronym -- which is what puts a name on P6's # suffix arm below. `ma` mirrors the ambiguous class, which starts # no run either. suffix_acronyms=frozenset({"md", "vd", "ma"}), suffix_acronyms_ambiguous=frozenset({"ma"}), # and `ma` is a given name written short too (Filipino María, #789), # read so only period-marked at the head of the given part given_name_abbreviations=frozenset({"ma"}), conjunctions=frozenset({"y"}), bound_given_names=frozenset({"abdul"}), ) #: The non-namespaced tags a LEXICON can produce: STABLE_TAGS minus #: the two that come from a token's SHAPE, which no lexicon controls. #: Derived rather than listed so that a new stable tag cannot quietly #: drop out of the comparison below -- STABLE_TAGS is itself pinned, #: at tests/v2/test_types.py. _VOCAB_TAGS = STABLE_TAGS - {"initial", "joined"} def _fixture_mirrors_shipped(text: str, policy: Policy) -> str: """Empty unless `_LEX` classifies a word in `text` differently from the shipped sets. A reduced fixture is fine -- a MISCLASSIFYING one is not, because the test then reads one name and parses another, and passes for a reason its author never sees (#395; the three words it caught are named on `_LEX`).""" def vocab(lexicon: Lexicon) -> list[tuple[str, frozenset[str]]]: state = run(ParseState(original=text, lexicon=lexicon, policy=policy)) return [(t.text, frozenset(g for g in t.tags if g.startswith("vocab:") or g in _VOCAB_TAGS)) for t in state.tokens] mine, shipped = vocab(_LEX), vocab(Lexicon.default()) if len(mine) != len(shipped): return f"{text!r}: tokenizes differently under the shipped lexicon" return "; ".join( f"{word!r} is {sorted(ours) or 'plain'} here but " f"{theirs_word!r} {sorted(theirs) or 'plain'} in the shipped " f"sets" if word != theirs_word else f"{word!r} is {sorted(ours) or 'plain'} here but " f"{sorted(theirs) or 'plain'} in the shipped sets" for (word, ours), (theirs_word, theirs) in zip(mine, shipped) if (word, ours) != (theirs_word, theirs)) def _parsed(text: str, policy: Policy | None = None) -> ParseState: policy = policy or Policy() divergence = _fixture_mirrors_shipped(text, policy) assert not divergence, ( f"{divergence}. Mirror the shipped classification in _LEX. A " f"test that needs the word classified some OTHER way builds " f"its own state with an explicit lexicon instead of calling " f"this helper, the way the vocabulary rows below do.") return run(ParseState(original=text, lexicon=_LEX, policy=policy)) def _by_role(state: ParseState, role: Role) -> str: return " ".join(t.text for t in state.tokens if t.role is role) def test_plain_title_with_single_name_swaps_to_family() -> None: out = _parsed("Mr. Johnson") assert _by_role(out, Role.FAMILY) == "Johnson" assert not _by_role(out, Role.GIVEN) def test_given_name_title_keeps_given() -> None: out = _parsed("Sir Bob") assert _by_role(out, Role.GIVEN) == "Bob" assert not _by_role(out, Role.FAMILY) def test_no_swap_when_more_fields_present() -> None: out = _parsed("Mr. John Johnson") assert _by_role(out, Role.GIVEN) == "John" assert _by_role(out, Role.FAMILY) == "Johnson" _ES = Policy(patronymic_rules=frozenset({PatronymicRule.EAST_SLAVIC})) _TK = Policy(patronymic_rules=frozenset({PatronymicRule.TURKIC})) _FF = Policy(name_order=FAMILY_FIRST) _FFGL = Policy(name_order=FAMILY_FIRST_GIVEN_LAST) #: Both family-first orders, since the rules claimed of a family-first #: order are claimed of every one of them and only one was ever parsed. #: They differ in where the given name lands behind the family, which #: is exactly what a leading shape must not depend on; the #: leading-particle cases below fold identically under both. _FAMILY_FIRST = [pytest.param(_FF, id="FAMILY_FIRST"), pytest.param(_FFGL, id="FAMILY_FIRST_GIVEN_LAST")] #: What the SHIPPED 2.2.0 wheel reads as the family, per (input, #: declared order) -- the rotation firing on the family slot whatever #: order assign read, which under a family-first order is the FIRST #: word. This column is the negative control: it is the reading the #: two stand-down tests below exist to prevent, recorded as data and #: re-measurable (`pip install nameparser==2.2.0`) rather than left in #: a comment. The stand-down tests do not assert on it -- what they #: assert is the expected column -- but a failure quotes it, so a #: rotation that fires again says so in the message. The vacuity probe #: below DOES assert on it, which is what keeps this column honest. #: The key is (input, declared order): each input picks out its own #: pack, `Мицкевич` being East Slavic and `oglu` Turkic, so no pack #: identifier is needed to disambiguate a row. _PRE_GATE_FAMILY = { ("Мицкевич Адам Юзеф", FAMILY_FIRST): "Адам", ("Мицкевич Адам Юзеф", FAMILY_FIRST_GIVEN_LAST): "Юзеф", ("Dr. Мицкевич Адам Юзеф", FAMILY_FIRST): "Адам", ("Dr. Мицкевич Адам Юзеф", FAMILY_FIRST_GIVEN_LAST): "Юзеф", ("oglu Ahmad Vali Ali", FAMILY_FIRST): "Ahmad", ("oglu Ahmad Vali Ali", FAMILY_FIRST_GIVEN_LAST): "Ali", } def _assert_rotation_stood_down(pack: Policy, order: Policy, text: str, family: str, title: str = "") -> None: """The pack opted in under `order` reads `text` exactly as `order` alone reads it, family included.""" out = _parsed(text, dataclasses.replace(pack, name_order=order.name_order)) plain = _parsed(text, order) roles = (Role.TITLE, Role.GIVEN, Role.MIDDLE, Role.FAMILY, Role.SUFFIX) pre_gate = _PRE_GATE_FAMILY[text, order.name_order] assert [_by_role(out, r) for r in roles] == \ [_by_role(plain, r) for r in roles], ( f"the pack changed the reading; before the gate the 2.2.0 " f"wheel read family {pre_gate!r} here") assert _by_role(out, Role.FAMILY) == family assert _by_role(out, Role.TITLE) == title def test_east_slavic_rotation() -> None: out = _parsed("Сидоров Иван Петрович", _ES) assert _by_role(out, Role.GIVEN) == "Иван" assert _by_role(out, Role.MIDDLE) == "Петрович" assert _by_role(out, Role.FAMILY) == "Сидоров" def test_east_slavic_needs_one_one_one() -> None: # four tokens: left unchanged (v1 parity) out = _parsed("Anna Maria Petrova Ivanovna", _ES) assert _by_role(out, Role.GIVEN) == "Anna" def test_east_slavic_skips_comma_forms() -> None: # v1 parity: patronymic reorder never fires on comma input -- # the comma already established the family out = _parsed("Abramovich, Roman Petrovich", _ES) assert _by_role(out, Role.FAMILY) == "Abramovich" assert _by_role(out, Role.GIVEN) == "Roman" def test_east_slavic_skips_when_middle_is_also_patronymic() -> None: # v1 parity: given + patronymic + patronymic-derived surname # (Abramovich) must not rotate out = _parsed("Roman Petrovich Abramovich", _ES) assert _by_role(out, Role.GIVEN) == "Roman" assert _by_role(out, Role.MIDDLE) == "Petrovich" assert _by_role(out, Role.FAMILY) == "Abramovich" @pytest.mark.parametrize("text,title", [ ("Мицкевич Адам Юзеф", ""), # a peeled title must not move the shape the gate is claimed of: # rules/O1 counts name words "titles, suffixes and nicknames # aside", so the rotation still reaches this input pre-gate # (_PRE_GATE_FAMILY records the same family as the bare form) and # the title has to survive the stand-down ("Dr. Мицкевич Адам Юзеф", "Dr."), ]) @pytest.mark.parametrize("order", _FAMILY_FIRST) def test_east_slavic_stands_down_under_a_declared_family_first_order( order: Policy, text: str, title: str) -> None: # rules/O1's scope clause (decisions/O1, 2026-09-07, #384): # the rotation RESTORES the default reading a family-first listing # hides, so a declared family-first order supplies that reading and # the rotation stands down. The gate is on the order assign READ, # so it needs an input the rotation would otherwise fire on: under # a family-first order the family slot holds the FIRST word, and a # patronymic-DERIVED surname written first carries the ending. # # BOTH family-first orders, because the gate is claimed of the # order class and the two place the given name differently: the # rotation rewrote a different word under each (_PRE_GATE_FAMILY), # so one order alone would leave the other's reading unpinned. _assert_rotation_stood_down(_ES, order, text, "Мицкевич", title) def test_east_slavic_off_by_default() -> None: out = _parsed("Сидоров Иван Петрович") assert _by_role(out, Role.GIVEN) == "Сидоров" def test_turkic_rotation() -> None: out = _parsed("Mammadova Aygun Ali kizi", _TK) assert _by_role(out, Role.GIVEN) == "Aygun" assert _by_role(out, Role.MIDDLE) == "Ali kizi" assert _by_role(out, Role.FAMILY) == "Mammadova" @pytest.mark.parametrize("order", _FAMILY_FIRST) def test_turkic_stands_down_under_a_declared_family_first_order( order: Policy) -> None: # The other pack, gated by the same read: rules/O2 is the same # restoration of a hidden given-first reading, and the gate is one # condition covering both rotations -- so pinning only the East # Slavic half would leave half the gate free to come back out. # 'oglu' is the Turkic patronymic marker standing where a # family-first order puts the surname, which is what makes O2's # trailing-marker test see it. _assert_rotation_stood_down(_TK, order, "oglu Ahmad Vali Ali", "oglu") def test_the_order_gate_is_what_stands_the_rotations_down( monkeypatch: pytest.MonkeyPatch) -> None: """Reachability probe: with the gate patched back to the ungated condition, every stand-down input must rotate again, reading the family _PRE_GATE_FAMILY records from the 2.2.0 wheel. If this fails green-side, the stand-down rows above have gone vacuous -- the rotation no longer reaches their inputs.""" from nameparser._pipeline import _post_rules monkeypatch.setattr( _post_rules, "_rotations_apply", lambda state: state.structure is _post_rules.Structure.NO_COMMA) for pack, text in ((_ES, "Мицкевич Адам Юзеф"), (_ES, "Dr. Мицкевич Адам Юзеф"), (_TK, "oglu Ahmad Vali Ali")): for order in (_FF, _FFGL): policy = dataclasses.replace(pack, name_order=order.name_order) out = _parsed(text, policy) assert _by_role(out, Role.FAMILY) == \ _PRE_GATE_FAMILY[text, policy.name_order], ( f"{text!r} no longer reaches the rotation under " f"{policy.name_order}; the stand-down rows are vacuous") def test_leading_never_given_particle_folds_into_family() -> None: # v1 handle_non_first_name_prefix: a leading particle that is never # a given name ('de') means the whole name is a surname out = _parsed("de la Vega") assert _by_role(out, Role.FAMILY) == "de la Vega" assert not _by_role(out, Role.GIVEN) def test_leading_ambiguous_particle_stays_given() -> None: # 'van' is particles_ambiguous: the given reading stands (v1 parity) out = _parsed("van Gogh") assert _by_role(out, Role.GIVEN) == "van" assert _by_role(out, Role.FAMILY) == "Gogh" def test_degenerate_bare_particle_stays_given() -> None: # v1's guard: with no middle or family, a bare 'de' keeps given='de' # rather than inventing a surname out = _parsed("de") assert _by_role(out, Role.GIVEN) == "de" assert not _by_role(out, Role.FAMILY) # --- rule 1b under the family-first orders (#359) --------------------- # The fold keys on POSITION, not on the GIVEN role: a never-given # particle keeps its particle whatever name_order says. @pytest.mark.parametrize("policy", _FAMILY_FIRST) @pytest.mark.parametrize("text,family,given,suffix", [ # the leading particle chains the rest of the name into the family ("de Mesnil", "de Mesnil", "", ""), ("de la Vega", "de la Vega", "", ""), # three pieces: the run stops at 'Mesnil' and 'Garcia' is left to # the order (#395). The only no-comma corpus name that reaches # this rule, and the one #364 was closed on twice ("de Mesnil Garcia", "de Mesnil", "Garcia", ""), # ... and the trailing suffix run is peeled before the rule looks, # comma or no comma (NO_COMMA and SUFFIX_COMMA both fold) ("de Mesnil MD", "de Mesnil", "", "MD"), ("De Mesnil, MD", "De Mesnil", "", "MD"), ]) def test_family_first_folds_leading_never_given_particle( policy: Policy, text: str, family: str, given: str, suffix: str) -> None: out = _parsed(text, policy) assert _by_role(out, Role.FAMILY) == family assert _by_role(out, Role.GIVEN) == given assert _by_role(out, Role.SUFFIX) == suffix @pytest.mark.parametrize("policy", _FAMILY_FIRST) def test_leading_piece_scan_skips_pieces_that_hold_no_name( policy: Policy) -> None: # `_leading_name_piece` walks PAST pieces carrying no name role # rather than reading piece 0. Without the skip the scan finds the # title and the name splits: family='de', given='MD'. # # An older version of this comment claimed the simpler # 'Mr de Mesnil' could not show the skip, its particle being # chained into one piece with 'Mesnil'. That was true before #367, # when a title displaced the particle out of the name's leading # position and the chain fired. It is not true now -- the pieces # are [Mr][de][Mesnil] and the simpler name fails the same way # under the same mutation. What this input adds is the narrowing # with a title present: the run stops after 'MD'. Not pinned here, # despite the scan supporting it: walking past MORE than one # non-name piece, which no fixture in this module produces. # # The title is deliberately UNDOTTED: a period makes any opening # abbreviation a title by shape (rules/H2), so a dotted one # would pass this test with the title vocabulary empty. out = _parsed("Mr de MD Mesnil", policy) assert _by_role(out, Role.TITLE) == "Mr" # 'MD' mid-name is a name word, so it is the ONE word the run # takes (#395); 'Mesnil' is left to the order. What the skip # decides is that the run is found at all -- without it the scan # stops on the title and the family is 'de' alone. assert _by_role(out, Role.FAMILY) == "de MD" assert _by_role(out, Role.GIVEN) == "Mesnil" assert not _by_role(out, Role.MIDDLE) @pytest.mark.parametrize("policy", _FAMILY_FIRST) @pytest.mark.parametrize("text,family,given", [ # a title is transparent to the fold (#367 removed the title-> # particle chain, so the pieces are [Mr.][de][Mesnil]), and with # one name word after the run there is nothing for the stop to # leave behind: wholly family under both family-first orders ("Mr. de Mesnil", "de Mesnil", ""), # a family comma has already fixed the family; the post-comma part # is the given name and must not be folded into it ("de Mesnil, Juan", "de Mesnil", "Juan"), # degenerate: nothing to fold, so no surname is invented ("de", "de", ""), # the leading piece is not a particle ("Juan de Mesnil", "Juan", "de Mesnil"), # 'van' is particles_ambiguous -- out of the rule's scope in EVERY # order, so a family-first order still splits at the particle (#360) ("van Gogh", "van", "Gogh"), ]) def test_family_first_leading_particle_cases_that_do_not_fold( policy: Policy, text: str, family: str, given: str) -> None: out = _parsed(text, policy) assert _by_role(out, Role.FAMILY) == family assert _by_role(out, Role.GIVEN) == given @pytest.mark.parametrize("text,title,given,middle,family,suffix", [ ("de Mesnil", "", "", "", "de Mesnil", ""), ("de la Vega", "", "", "", "de la Vega", ""), ("de Mesnil Garcia", "", "", "", "de Mesnil Garcia", ""), ("Mr de MD Mesnil", "Mr", "", "", "de MD Mesnil", ""), ("de Mesnil MD", "", "", "", "de Mesnil", "MD"), ("De Mesnil, MD", "", "", "", "De Mesnil", "MD"), ("Mr. de Mesnil", "Mr.", "", "", "de Mesnil", ""), ("de Mesnil, Juan", "", "Juan", "", "de Mesnil", ""), ("de", "", "de", "", "", ""), ("Juan de Mesnil", "", "Juan", "", "de Mesnil", ""), ("van Gogh", "", "van", "", "Gogh", ""), # a family comma folds the post-comma part when IT opens with a # never-given particle -- long-standing behaviour, and rule 1b's # own doing: assign hands it the same roles in every order (the # comma already fixed the family), and both of 1b's sites then # agree, the opening piece of segment 1 and the lone given being # the same token. Pinned here so the re-key cannot quietly drop it ("Smith, de Mesnil", "", "", "", "Smith de Mesnil", ""), ("Smith, van Gogh", "", "van", "Gogh", "Smith", ""), ]) def test_default_order_is_unchanged_by_the_family_first_fold( text: str, title: str, given: str, middle: str, family: str, suffix: str) -> None: out = _parsed(text) assert _by_role(out, Role.TITLE) == title assert _by_role(out, Role.GIVEN) == given assert _by_role(out, Role.MIDDLE) == middle assert _by_role(out, Role.FAMILY) == family assert _by_role(out, Role.SUFFIX) == suffix @pytest.mark.parametrize("policy", _FAMILY_FIRST) def test_family_comma_fold_is_order_independent(policy: Policy) -> None: out = _parsed("Smith, de Mesnil", policy) assert _by_role(out, Role.FAMILY) == "Smith de Mesnil" assert not _by_role(out, Role.GIVEN) @pytest.mark.parametrize("policy", _FAMILY_FIRST) def test_lone_never_given_particle_in_given_position_is_the_given_name( policy: Policy) -> None: # The reverse of what this file asserted until #467. A particle # standing where the declared order puts the GIVEN name is the # given name: the never-given vocabulary supplies a reading where # position leaves the question open, and vetoes none that position # has already given. `Mesnil de` has two pieces under either # family-first order, so `de` lands in GIVEN and stays there. # # P6's no-comma site does not reach it either, and the negative # assertion says so: that site takes a trailing particle only out # of a MIDDLE, which two pieces never produce. out = _parsed("Mesnil de", policy) assert _by_role(out, Role.FAMILY) == "Mesnil" assert _by_role(out, Role.GIVEN) == "de" assert not _folded(out) # --- P6's no-comma site (#467) ------------------------------------ def _folded(state: ParseState) -> str: return " ".join(t.text for t in state.tokens if FOLDED_TAG in t.tags) @pytest.mark.parametrize("policy", _FAMILY_FIRST) def test_trailing_particle_out_of_a_middle_joins_the_family( policy: Policy) -> None: # #467: three pieces put the trailing particle in MIDDLE under # FAMILY_FIRST and in GIVEN under FAMILY_FIRST_GIVEN_LAST, and the # slot is the whole criterion -- a middle is a further given name, # which a particle is not, so only that one is reinterpreted. out = _parsed("Mesnil Garcia de", policy) if policy.name_order[1] is Role.GIVEN: # FAMILY_FIRST assert _by_role(out, Role.FAMILY) == "Mesnil de" assert _by_role(out, Role.GIVEN) == "Garcia" assert _folded(out) == "de" else: # FAMILY_FIRST_GIVEN_LAST assert _by_role(out, Role.FAMILY) == "Mesnil" assert _by_role(out, Role.GIVEN) == "de" assert not _folded(out) def test_the_slot_test_reads_both_traditions_without_asking_the_word() -> None: # The same vocabulary spells the Dutch tussenvoegsel and the # Vietnamese given name, and `van` is in it. A never-given test # here would have to lose one of these two; the declared order # loses neither, which is why no vocabulary test appears at that # site. Such a test would also have excluded every other ambiguous # particle -- von, di, da, del, le. dutch = _parsed("Beethoven Ludwig van", Policy(name_order=FAMILY_FIRST)) assert _by_role(dutch, Role.FAMILY) == "Beethoven van" assert _folded(dutch) == "van" viet = _parsed("Nguyen Thi Van", Policy(name_order=FAMILY_FIRST_GIVEN_LAST)) assert _by_role(viet, Role.GIVEN) == "Van" assert not _folded(viet) def test_the_family_the_run_joins_must_have_a_base() -> None: # Attaching to an all-particle family renders one particle in # front of another ('de van'), and R2 reads those words as # ordinary name words, which no rule reorders. # # FAMILY_FIRST only: under FAMILY_FIRST_GIVEN_LAST this name puts # `de` in GIVEN, so the run is empty for an unrelated reason and # the guard is never consulted. `family` is asserted as well as the # tag, because inverting the guard retags the run to FAMILY without # the tag -- which `_folded` alone cannot see. out = _parsed("van Berg Jan de", Policy(name_order=FAMILY_FIRST)) assert _by_role(out, Role.FAMILY) == "van" assert _by_role(out, Role.MIDDLE) == "Jan de" assert not _folded(out) def test_no_leftover_is_re_laid_out() -> None: # Under FAMILY_FIRST the roles run family, given, middle, middle..., # so dropping a trailing middle leaves every other piece where it # was. NOT under FAMILY_FIRST_GIVEN_LAST (family, middle..., given), # which is one of the two reasons the site tests the order. # # An earlier draft (#466) removed the piece and re-laid the leftover # out. The whole role map is asserted because that draft failed in # two directions: it lost a given name outright on "van Berg Jan de" # and promoted a post-nominal into the given slot here. A test on # `GIVEN != "Ma"` alone passes on an EMPTY given, which is the # first of those. # # The post-nominal is the ambiguous `Ma`, which stays a middle # name. It was `Jr.` until #602, which made a credential after the # name core start a run to the end of the part, taking `de` in. out = _parsed("Berg Jan Ma de", Policy(name_order=FAMILY_FIRST)) assert _by_role(out, Role.GIVEN) == "Jan" assert _by_role(out, Role.MIDDLE) == "Ma" assert _by_role(out, Role.FAMILY) == "Berg de" assert _folded(out) == "de" @pytest.mark.parametrize("policy", _FAMILY_FIRST) def test_a_multi_token_run_is_taken_whole(policy: Policy) -> None: # `van de` and `van der` are the commonest real tussenvoegsels and # the shape this rule exists for, and every other fixture here runs # the walk for a single token -- so a walk that took only the last # particle passed the entire suite when review mutated it in. out = _parsed("Jong Anke van de", policy) if policy.name_order[1] is Role.GIVEN: # FAMILY_FIRST assert _by_role(out, Role.FAMILY) == "Jong van de" assert _folded(out) == "van de" assert not _by_role(out, Role.MIDDLE) else: assert not _folded(out) def test_the_run_must_end_the_name() -> None: # The trailing MIDDLE is not the trailing NAME word under # FAMILY_FIRST_GIVEN_LAST, where the given name stands behind the # middles. Without this test the rule hoists a particle in front of # a base it was written after, with a given name still following it # -- 366 such folds over a generated sweep, all of them shapes where # a conjunction stopped the particle's forward chain. out = _parsed("de Anke van y", Policy(name_order=FAMILY_FIRST_GIVEN_LAST)) assert _by_role(out, Role.MIDDLE) == "van" assert _by_role(out, Role.FAMILY) == "de Anke" assert not _folded(out) def test_the_site_reports_the_fork_it_decides() -> None: # decisions/P6 (#405): the attachment reports the fork it # decides. The state that decision fixed is the one this site # would otherwise recreate -- the comma writing reporting while # the comma-less writing decides the identical fork in silence. # ONE arm, since the suffix reading stands without a comma. ff = _parsed("Beethoven Ludwig van", Policy(name_order=FAMILY_FIRST)) kinds = [a.kind for a in ff.ambiguities] assert kinds == [AmbiguityKind.PARTICLE_OR_GIVEN] # a never-given run overrides no live reading, so it stays silent quiet = _parsed("Jong Anke de", Policy(name_order=FAMILY_FIRST)) assert not quiet.ambiguities def test_the_default_order_is_not_reached() -> None: # The site reads `state.order`, which is recorded under EVERY # no-comma order, so the order itself must be tested -- the slot # does not imply it. A conjunction stops a particle's forward chain # and leaves it standing in a middle under the default order too: # measured, 52 names moved before this test was added, and the # differential corpus holds no instance of the shape, so the gate # reported none of them. out = _parsed("Maria Luisa y de la Cruz") assert _by_role(out, Role.FAMILY) == "la Cruz" assert not _folded(out) def test_lone_never_given_particle_needs_no_repair_by_default() -> None: # the default order reaches the same rule from the other side: the # particle is already the family, so there is nothing to repair default = _parsed("Mesnil de") assert _by_role(default, Role.GIVEN) == "Mesnil" assert _by_role(default, Role.FAMILY) == "de" # --- the whole never-given class, not just the fixture's 'de' --------- _ALL_ORDERS = [pytest.param(Policy(), id="GIVEN_FIRST"), *_FAMILY_FIRST] @pytest.mark.parametrize("policy", _ALL_ORDERS) def test_no_never_given_particle_is_left_as_the_given_name( policy: Policy) -> None: """Every case above rides on the fixture lexicon's 'de'. The rule is claimed of the whole never-given class in every name_order, so sweep the live class rather than pinning another word or two of it -- a hardcoded handful would document those entries and catch nothing else in the set (AGENTS.md, "Prefer behavior tests over constant-content tests"). Derived from the lexicon, so an addition to NON_GIVEN_NAME_PARTICLES is swept the day it lands, and asserts nothing about which words are in the set. """ lex = Lexicon.default() never_given = sorted(lex.particles - lex.particles_ambiguous) assert never_given, "no never-given particles to exercise" failures = [] for particle in never_given: text = f"{particle} Mesnil" out = run(ParseState(original=text, lexicon=lex, policy=policy)) given = _by_role(out, Role.GIVEN) family = _by_role(out, Role.FAMILY) if given or family != text: failures.append( f"{text!r}: given={given!r} family={family!r}") assert not failures, ( f"{len(failures)} of {len(never_given)} left as a given name " f"or unfolded:\n" + "\n".join(failures[:15])) def test_middle_as_family_folds_middles() -> None: # v1 handle_middle_name_as_last, opt-in: middles prepend to family out = _parsed("John Quincy Adams Smith", Policy(middle_as_family=True)) assert _by_role(out, Role.GIVEN) == "John" assert not _by_role(out, Role.MIDDLE) assert _by_role(out, Role.FAMILY) == "Quincy Adams Smith" def test_middle_as_family_off_by_default() -> None: out = _parsed("John Quincy Adams Smith") assert _by_role(out, Role.MIDDLE) == "Quincy Adams" @pytest.mark.parametrize("text,given,middle,family", [ # #360's two measured misparses, now folded ("Mc Donald", "", "", "Mc Donald"), ("Ste Marie", "", "", "Ste Marie"), # the Spanish plural articles: 'de' leads, 'los' chains onto the # surname. Correct before #360 too, but by the whole-remainder sweep # rather than by knowing 'los' -- and the #390 fold narrowing # regressed it precisely because the vocabulary did not ("de los Santos", "", "", "de los Santos"), ("de las Casas", "", "", "de las Casas"), # 'das' mid-name chains FORWARD, which is the gain: family was # 'Neves' before #360, losing the particle ("Maria das Neves", "Maria", "", "das Neves"), # NEGATIVE CONTROL, and the reason C-i needs its positional # qualifier: 'Das' is a borne Bengali surname in TRAILING position, # where the leading-particle rule never reaches. Never-given 'das' # must leave these alone -- if this row moves, the qualifier is # wrong and the membership has to come back out. ("Anjali Das", "Anjali", "", "Das"), ("Bimal Das", "Bimal", "", "Das"), ]) def test_article_particles_fold_without_eating_trailing_surnames( text: str, given: str, middle: str, family: str) -> None: # The DEFAULT lexicon deliberately, not this module's reduced _LEX: # these rows are about which words the shipped vocabulary claims, so # a fixture that omits them would pass while proving nothing. out = run(ParseState(original=text, lexicon=Lexicon.default(), policy=Policy())) assert _by_role(out, Role.GIVEN) == given assert _by_role(out, Role.MIDDLE) == middle assert _by_role(out, Role.FAMILY) == family # --- #395: how far the run reaches under a family-first order ------- @pytest.mark.parametrize("policy", _FAMILY_FIRST) @pytest.mark.parametrize("text,family,given,middle,suffix", [ # one leftover -- where the two family-first orders still agree ("de la Cruz Juan", "de la Cruz", "Juan", "", ""), # a trailing suffix is peeled before the rule looks, so it is not # a leftover and cannot change where the run stops ("de la Cruz Juan MD", "de la Cruz", "Juan", "", "MD"), # ... nor can a leading title, which is not a name word either ("Mr de la Cruz Juan", "de la Cruz", "Juan", "", ""), ]) def test_family_first_run_stops_at_the_first_name_word( policy: Policy, text: str, family: str, given: str, middle: str, suffix: str) -> None: out = _parsed(text, policy) assert _by_role(out, Role.FAMILY) == family assert _by_role(out, Role.GIVEN) == given assert _by_role(out, Role.MIDDLE) == middle assert _by_role(out, Role.SUFFIX) == suffix @pytest.mark.parametrize("policy", _FAMILY_FIRST) @pytest.mark.parametrize("text,family,rest", [ # the conjunction join is ONE name word (rules/P3), so the run # takes 'Vega y Santos' whole rather than stopping inside it ("de la Vega y Santos Juan", "de la Vega y Santos", "Juan"), # ... and the same clause applies to what is LEFT: 'Juan y Eva' is # one unit, so it lands in one field rather than being split ("de la Cruz Juan y Eva", "de la Cruz", "Juan y Eva"), # a bound given-name word and the word it completes are one unit # (rules/P5), so the fold cannot leave half of the pair behind ("ibn Awf abdul Rahman", "ibn Awf", "abdul Rahman"), # a particle in what is LEFT chains forward the same way it would # if the fold had never run (rules/P2). Handing its words out # separately would report given='van' -- a bare particle as the # given name, which is the reading P1 exists to forbid -- and # would disagree with the same tail parsed alone: 'Mesnil van # Berg Juan' gives given='van Berg Juan' ("de Mesnil van Berg Juan", "de Mesnil", "van Berg Juan"), # ... including a never-given particle, where the split output # would have been given='de' ("de Mesnil de Berg Juan", "de Mesnil", "de Berg Juan"), # ... and a two-particle chain ("de Mesnil van der Berg", "de Mesnil", "van der Berg"), ]) def test_the_run_counts_units_not_words( policy: Policy, text: str, family: str, rest: str) -> None: # one leftover unit, so both family-first orders put it in `given` out = _parsed(text, policy) assert _by_role(out, Role.FAMILY) == family assert _by_role(out, Role.GIVEN) == rest assert not _by_role(out, Role.MIDDLE) def test_the_two_family_first_orders_differ_at_two_leftovers() -> None: # The only shape that tells them apart, and the reason # tests/v2/cases.py carries it: with one leftover both orders send # it to `given`, so nothing else in the suite distinguishes the # name_order argument to the leftover placement. ff = _parsed("de la Cruz Juan Carlos", Policy(name_order=FAMILY_FIRST)) fgl = _parsed("de la Cruz Juan Carlos", Policy(name_order=FAMILY_FIRST_GIVEN_LAST)) assert _by_role(ff, Role.GIVEN) == "Juan" assert _by_role(ff, Role.MIDDLE) == "Carlos" assert _by_role(fgl, Role.GIVEN) == "Carlos" assert _by_role(fgl, Role.MIDDLE) == "Juan" assert _by_role(ff, Role.FAMILY) == _by_role(fgl, Role.FAMILY) @pytest.mark.parametrize("policy", _FAMILY_FIRST) @pytest.mark.parametrize("text,family", [ # an AMBIGUOUS leading particle is out of the rule's scope in every # order: the fold never fires on it, so there is no run to stop. # Untouched by #395 ("van Gogh Jan", "van"), # a family comma has already fixed the surname, so there is no # positional read for a declared order to narrow (state.order is # None on this path) ("Smith, de Mesnil", "Smith de Mesnil"), ]) def test_shapes_the_stop_does_not_reach( policy: Policy, text: str, family: str) -> None: # the family alone: the rows above place their remaining words by # order, which is not what these rows are about -- what they pin # is that the family is not narrowed out = _parsed(text, policy) assert _by_role(out, Role.FAMILY) == family def test_the_fixture_guard_fires( monkeypatch: pytest.MonkeyPatch) -> None: # The guard is a correctness check on the fixture, so it needs one # on itself: without this, deleting its body leaves the suite green # -- the same inertness it exists to prevent. assert not _fixture_mirrors_shipped("de la Cruz Juan", Policy()) monkeypatch.setattr(sys.modules[__name__], "_LEX", dataclasses.replace( _LEX, particles=frozenset(), particles_ambiguous=frozenset())) assert "'de'" in _fixture_mirrors_shipped("de la Cruz Juan", Policy()) def test_the_stop_reads_the_effective_order_not_the_policy() -> None: # Why ParseState.order exists. A script_orders entry overrides # name_order, so the two disagree -- and this is the direction that # matters: the policy says family-first, the SCRIPT says given- # first, and the read assign actually made is the given-first one. # Keying the stop on policy.name_order would narrow a name that was # never read family-first. Needs a custom lexicon (every shipped # particle is Latin, and Latin has no script_orders entry), so it # builds the state directly rather than going through _parsed. lex = Lexicon(particles=frozenset({"ノ"})) policy = Policy(name_order=FAMILY_FIRST, script_orders=((Script.KATAKANA, GIVEN_FIRST),)) out = run(ParseState(original="ノ クルス フアン カルロス", lexicon=lex, policy=policy)) assert out.order == GIVEN_FIRST assert _by_role(out, Role.FAMILY) == "ノ クルス フアン カルロス" assert not _by_role(out, Role.GIVEN) @pytest.mark.parametrize("policy", _FAMILY_FIRST) def test_a_suffix_comma_still_stops_the_run(policy: Policy) -> None: # SUFFIX_COMMA takes a different assign branch from NO_COMMA # (tail = 1), while the fold still reads segment 0 out = _parsed("de la Cruz Juan Carlos, MD", policy) assert _by_role(out, Role.FAMILY) == "de la Cruz" assert _by_role(out, Role.SUFFIX) == "MD" assert {_by_role(out, Role.GIVEN), _by_role(out, Role.MIDDLE)} == {"Juan", "Carlos"} def test_a_maiden_name_is_not_swept_into_the_run() -> None: # `name_idx` is givens + middles + families, and MAIDEN is # deliberately not among them: a regression there moves the maiden # name silently into the family out = _parsed("de la Cruz Juan Carlos (Vega)", Policy(name_order=FAMILY_FIRST, maiden_delimiters=frozenset({("(", ")")}))) assert _by_role(out, Role.FAMILY) == "de la Cruz" assert _by_role(out, Role.MAIDEN) == "Vega" assert _by_role(out, Role.GIVEN) == "Juan" def test_middle_as_family_folds_a_middle_the_old_reach_never_left() -> None: # O3 now has a middle to fold, which the greedy reach never left # it. In TOKEN order the family is 'de la Cruz Carlos'; the field # renders it 'Carlos de la Cruz', the folded word prepended (R1), # which is why this asserts roles rather than the rendered field out = _parsed("de la Cruz Juan Carlos", Policy(name_order=FAMILY_FIRST, middle_as_family=True)) assert _by_role(out, Role.GIVEN) == "Juan" assert not _by_role(out, Role.MIDDLE) assert _by_role(out, Role.FAMILY) == "de la Cruz Carlos" @pytest.mark.parametrize("policy,given,middle", [ (Policy(name_order=FAMILY_FIRST), "van Berg", "씨 Juan"), (Policy(name_order=FAMILY_FIRST_GIVEN_LAST), "Juan", "van Berg 씨"), ]) def test_a_suffix_word_mid_run_ends_the_chain( policy: Policy, given: str, middle: str) -> None: # `_units` stops a particle chain at a suffix word, which is the # stop _group's own chain uses (`not prefix(j) and not suffix(j)`). # Without it the whole leftover is ONE unit and lands in one field. # Three leftover units here, so this is also the only place the two # orders are pinned apart at a count other than two. The suffix word # is one letter because a longer one -- a credential, or since #679 # an honorific -- would start #602's run and take `Juan` with it # (rules/S8); it was `MD` until #602. out = _parsed("de Mesnil van Berg 씨 Juan", policy) assert _by_role(out, Role.FAMILY) == "de Mesnil" assert _by_role(out, Role.GIVEN) == given assert _by_role(out, Role.MIDDLE) == middle @pytest.mark.parametrize("policy", _FAMILY_FIRST) def test_a_bound_pair_survives_the_leading_particle_stop( policy: Policy) -> None: # The stop leaves the bound pair whole (rules/P5 via _units). # Asserted under a family-first order deliberately: in the DEFAULT # order the fold takes the whole name into the family, so the join # is invisible there and the assertion would pass with the join # broken. out = run(ParseState(original="de Mesnil abd Allah", lexicon=Lexicon.default(), policy=policy)) assert _by_role(out, Role.FAMILY) == "de Mesnil" assert _by_role(out, Role.GIVEN) == "abd Allah" def test_a_dual_membership_word_decides_rather_than_raising() -> None: # P6's report has two arms, and the plan that added them (#405) # recorded them as disjoint by construction -- "a suffix-vocabulary # particle arrives suffix-roled and IS the whole run". That is true # of the SHIPPED vocabulary and not of the rule: `vd` and `mc` are # the only words in both the particle and the unambiguous suffix # sets, and neither is ambiguous particle vocabulary, so no shipped # input reaches both arms. A caller's Lexicon can put one word in # both, and an `assert` written on that premise DID fire here # before this test existed -- rules/A1 says parsing never fails # on any input, and an AssertionError out of a stage is exactly the # failure it forbids. # # So the arms are ORDERED and the suffix one wins: assign had read # the word as a post-nominal, so the name-word reading was never on # the table for the attachment to decline # (mechanisms.md#AMBIGUITY-AT-THE-DECISION-SITE -- report the # branch actually taken). lex = dataclasses.replace( _LEX, particles=frozenset(_LEX.particles | {"zz"}), particles_ambiguous=frozenset(_LEX.particles_ambiguous | {"zz"}), suffix_acronyms=frozenset(_LEX.suffix_acronyms | {"zz"})) out = run(ParseState(original="Berg, Jan zz", lexicon=lex, policy=Policy())) # token order, so the attachment shows as the run joining the # family rather than as the family view's rendered order assert _by_role(out, Role.FAMILY) == "Berg zz" assert _by_role(out, Role.GIVEN) == "Jan" assert [a.kind.value for a in out.ambiguities] == ["suffix-or-name"] def test_the_attachment_report_names_the_whole_run_it_moved() -> None: # Both arms' detail text and the reported token span, asserted # here because neither was: mutation-checked before this test # existed, replacing either f-string with a constant left the # full suite green, and so did reporting `run[:1]` instead of the # whole run. _types.py says PARTICLE_OR_GIVEN now has three # shapes and that `detail` is what tells them apart, and the two # older shapes DO assert their detail verbatim (test_assign.py), # so this one owed the same. # # A TWO-token run, so the span assertion has something to lose: # the report is named for `van`, the word that is ambiguous # vocabulary, and covers `der` with it. cases.py says "one report # covers the whole run" and nothing pinned it. # # The detail names the ATTACHMENT and stops there -- what P6 # decides is that the run joins the family the comma named. It # deliberately does not promise how the joined words then READ, # which is R2's call taken later in the same stage and can go the # other way ("de la, Jan van" leaves an all-particle family whose # words every other view reads as name words). out = _parsed("Berg, Jan van der") assert _by_role(out, Role.FAMILY) == "Berg van der" (amb,) = out.ambiguities assert amb.kind is AmbiguityKind.PARTICLE_OR_GIVEN assert amb.detail == ( "'van' is both a family-name particle and an ordinary given " "name; after a family comma 'van der' joins the family the " "comma named rather than standing as a name word of its own") assert [out.tokens[i].text for i in amb.indices] == ["van", "der"] def test_the_suffix_arm_report_names_the_post_nominal_it_declined() -> None: # The other arm, verbatim for the same reason. `vd` is particle # AND unambiguous suffix vocabulary, so assign read the trailing # one as a post-nominal and the attachment overrode that reading # -- which is the branch the kind names (#405), and the detail # follows _assign.py's "written without periods" template for the # same fork. out = _parsed("Berg, Jan vd") assert _by_role(out, Role.FAMILY) == "Berg vd" (amb,) = out.ambiguities assert amb.kind is AmbiguityKind.SUFFIX_OR_NAME assert amb.detail == ( "'vd' written without periods is both a post-nominal and a " "family-name particle; after a family comma it joins the " "family the comma named rather than standing as a " "post-nominal") assert [out.tokens[i].text for i in amb.indices] == ["vd"] def _entry_tags(text: str, policy: Policy | None = None) -> list[tuple[str, bool]]: """(text, is a continuation) per SUFFIX token, after the stage. Read off the tokens rather than off the rendered string: the tag is what post_rules produces and the ", " join is _types.py's, and a test that read the string would pass on a stage that tagged nothing if the render ever stopped honoring the tag. """ state = run(ParseState(original=text, lexicon=Lexicon.default(), policy=policy or Policy())) return [(tok.text, "joined" in tok.tags) for i, tok in enumerate(state.tokens) if tok.role is Role.SUFFIX and i not in set(state.dropped)] def test_a_space_separated_run_is_one_entry() -> None: """rules/R1's no-comma path, which is the whole of #436.""" assert _entry_tags("John Smith MD PhD") == [("MD", False), ("PhD", True)] def test_the_writers_comma_parts_two_entries() -> None: """The `same_part` conjunct. Drop it and 'Smith Jr., Mr. Jr.' renders suffix 'Jr. Jr.' -- the tag gluing a run across the writer's own comma, which is the inverse of the bug #429 fixed and the reason the bucket comparison is not optional.""" assert _entry_tags("Smith, MD, PhD") == [("MD", False), ("PhD", False)] assert _entry_tags("Smith Jr., Mr. Jr.") == [("Jr.", False), ("Jr.", False)] def test_a_run_of_three_reads_each_gap_on_its_own() -> None: """The pass walks CONSECUTIVE pairs, so a mixed run decides each gap against the post-nominal immediately before it. Measure the bucket against the run's FIRST suffix instead of the predecessor and the first assertion below reads [F, F, F]: 'FACS' loses its continuation because 'MD' sits in an earlier bucket than the 'PhD' it actually follows. The mirror is the same run written the other way round, where that mutant agrees and only the pairwise reading distinguishes the two (measured 2026-09-06).""" assert _entry_tags("John Doe, MD, PhD FACS") == [ ("MD", False), ("PhD", False), ("FACS", True)] assert _entry_tags("John Doe, MD PhD, FACS") == [ ("MD", False), ("PhD", True), ("FACS", False)] def test_an_interleaved_title_does_not_part_the_run() -> None: """A title renders into another field, so it is transparent to the entry: 'Smith, MD Dr. PhD' is one run, as it was when group kept the entry open across a piece that was not in it.""" assert _entry_tags("Smith, MD Dr. PhD") == [("MD", False), ("PhD", True)] def test_an_interleaved_nickname_does_not_part_the_run() -> None: """TITLE is not the only transparent role: a nickname renders into `nickname`, so it is no more in the run than a title is. Narrow _RENDERS_ELSEWHERE back to {TITLE} and this renders 'MD, PhD'.""" assert _entry_tags('Smith, MD "Doc" PhD') == [("MD", False), ("PhD", True)] def test_an_interleaved_nickname_does_not_part_a_no_comma_run() -> None: """The same transparency on the no-comma path #436 is about: with a name before it there is no family comma, and the run still has to survive the nickname. Narrow _RENDERS_ELSEWHERE back to {TITLE} and this renders 'MD, PhD'.""" assert _entry_tags('John Smith MD "Doc" PhD') == [ ("MD", False), ("PhD", True)] def test_a_bracketed_maiden_clause_does_not_part_the_run() -> None: """MAIDEN renders into `maiden`, so the KEPT word of a maiden clause is transparent too. Narrow _RENDERS_ELSEWHERE back to {TITLE} and this renders 'MD, PhD'.""" assert _entry_tags("Smith, MD (nee Jones) PhD") == [ ("MD", False), ("PhD", True)] def test_a_bare_marker_maiden_clause_does_not_part_the_run() -> None: """The dropped arm's other half: written without brackets the marker word itself is DROPPED, with no role for the transparency test to read. It is not a delimiter core, so it does not part the run either -- make the dropped arm part on any dropped index and this renders 'MD, PhD'. The bracketed twin above drops its marker too, so that mutant fails both; what separates the two tests is the role test, which sees 'Jones' in both and 'nee' in neither (measured 2026-09-06). Since #601 a bare marker counts only behind a name word, so the clause stands between two post-nominals only inside #602's credential run: 'Jr.' starts it and takes 'Smith' in, the marker standing after that name word. The fixture was 'Smith, MD nee Jones PhD' until then, a marker in the given part after a family comma, which is an ordinary word now.""" assert _entry_tags("Jane Doe Jr. Smith nee Jones PhD") == [ ("Jr.", False), ("Smith", True), ("PhD", True)] def test_a_marker_the_policy_names_as_a_delimiter_parts_the_run() -> None: """Where the two sites' gates differ, and the one case that reaches it. group drops a core only on a `tail` segment, cutting the segment there (#549); this pass reads `delimiter_cores` whole and asks by TEXT, so a dropped token the policy names as a delimiter parts the run whatever dropped it. A maiden marker the policy ALSO lists is the reachable case, and it parts by the policy's own declaration (measured 2026-09-06).""" nee = Policy(extra_suffix_delimiters=frozenset({" nee "})) # the fixture puts the clause inside #602's credential run, the one # place a bare clause stands between two post-nominals since #601 # (the bare test above says why); it was 'John Doe, MD nee Jones # PhD' until then assert _entry_tags("Jane Doe Jr. Smith nee Jones PhD", nee) == [ ("Jr.", False), ("Smith", True), ("PhD", False)] assert _entry_tags("Jane Doe Jr. Smith nee Jones PhD") == [ ("Jr.", False), ("Smith", True), ("PhD", True)] def test_a_dropped_core_parts_two_entries() -> None: """The `parted` conjunct, dropped-core half (#206). Drop it and 'John Doe, MD | PhD | FACS' renders 'MD PhD FACS' under this policy. Moved here from test_group.py's test_extra_suffix_delimiter_splits_tail_entries with #436: the core is dropped in group and the entry it separates is decided here.""" dash = Policy(extra_suffix_delimiters=frozenset({" | "})) assert _entry_tags("John Doe, MD | PhD | FACS", dash) == [ ("MD", False), ("PhD", False), ("FACS", False)] def test_a_surviving_name_word_parts_two_entries() -> None: """The `parted` conjunct, kept-core half -- the one a dropped-core test alone cannot reach: a name word standing between two post-nominals parts their entries. `abd` is suffix vocabulary that starts no credential run (it is bound given-name vocabulary too, rules/S8), so `Jones` behind it survives as a name word before the credential. Drop the conjunct and this renders 'abd PhD'. The fixture was 'Smith, MD - PhD - FACS' until #602, whose dashes stood as name words between the post-nominals; a credential now starts a run to the end of the part, taking them in.""" for policy in (Policy(), Policy(extra_suffix_delimiters=frozenset({" | "}))): assert _entry_tags("Smith, Jane abd Jones PhD", policy) == [ ("abd", False), ("PhD", False)] def test_the_pass_runs_after_roles_are_settled() -> None: """Why the pass keys on Role.SUFFIX and runs after assign: the same span rule asked role-blind would make 'John A B Smith' one middle_list element. Asserted as the middle staying two tokens, neither of them a continuation.""" state = run(ParseState(original="John A B Smith", lexicon=Lexicon.default(), policy=Policy())) middles = [tok for tok in state.tokens if tok.role is Role.MIDDLE] assert [tok.text for tok in middles] == ["A", "B"] assert not any("joined" in tok.tags for tok in middles) # -- suffix_entries as a function of its own (#511) ------------------- # # Parser.revise forces the named role on a sub-parse's tokens and then # runs THIS pass over the forced state, so the pass is exercised here # on states whose roles were set by hand rather than by assign. def _forced(text: str, role: Role, policy: Policy | None = None) -> ParseState: """A full parse of `text` with every non-dropped token re-roled to `role`, which is what revise() hands the pass. The forcing keeps the sub-parse's tags, as revise() keeps them. Dropped tokens keep their role, as revise() leaves them.""" state = run(ParseState(original=text, lexicon=Lexicon.default(), policy=policy or Policy())) dropped = set(state.dropped) return dataclasses.replace(state, tokens=tuple( tok if i in dropped else dataclasses.replace(tok, role=role) for i, tok in enumerate(state.tokens))) def _forced_entry_tags(text: str, role: Role, policy: Policy | None = None) -> list[tuple[str, bool]]: state = suffix_entries(_forced(text, role, policy)) return [(tok.text, "joined" in tok.tags) for i, tok in enumerate(state.tokens) if tok.role is role and i not in set(state.dropped)] def test_suffix_entries_joins_a_forced_role_run() -> None: """A bare 'MD PhD' parses as a title and a family name, so the pass inside that parse marks nothing; over the FORCED state it reads two suffix tokens in one comma bucket and joins them.""" assert _forced_entry_tags("MD PhD", Role.SUFFIX) == [ ("MD", False), ("PhD", True)] def test_suffix_entries_reads_the_values_own_comma() -> None: """The pass reads the comma offsets of the STATE it is handed -- for revise() that is the value's own string, not the outer name's -- so a comma in the value parts the entries.""" assert _forced_entry_tags("MD, PhD", Role.SUFFIX) == [ ("MD", False), ("PhD", False)] def test_suffix_entries_heals_a_spaced_credential_by_the_comma_rule() -> None: """The Ph. D. MERGE is a head-position rule (#371) and does not fire in a bare value; the ENTRY pass joins the pair anyway, for the reason it joins MD and PhD -- same bucket, nothing between.""" assert _forced_entry_tags("Ph. D.", Role.SUFFIX) == [ ("Ph.", False), ("D.", True)] def test_suffix_entries_keys_on_the_suffix_role() -> None: """A forced MIDDLE value has no suffix token, so the pass writes nothing there: revise(middle='A B') stays two middle tokens, neither a continuation.""" assert _forced_entry_tags("A B", Role.MIDDLE) == [ ("A", False), ("B", False)] def test_p6_declines_a_credential_lean_class_member() -> None: """P6 keys on VOCABULARY, not role, so assign's suffix role would otherwise be silently overridden -- verified by running the change's assign half alone, which left 'Doe, John DO' family 'DO Doe'. Its capitals settle the credential (rules/S6, #785), which silences the report and must not touch the particle reading P6 asks about: a retag did, giving family 'DO Doe' again.""" name = parse("Doe, John DO") assert name.suffix == "DO" assert name.family == "Doe" assert name.ambiguities == () # the Title-case twin, which no writing settles: P6 attaches it assert parse("Doe, John Do").family == "Do Doe" def test_p6_still_claims_an_unambiguous_suffix_particle() -> None: """The condition is narrowed to AMBIGUOUS_ACRONYM_TAG, which is what keeps 'vd' and 'mc' -- unambiguous suffix vocabulary, also particles, also suffix-roled -- inside the run.""" for text, family in (("Berg, Jan vd", "vd Berg"), ("Berg, Jan mc", "mc Berg")): name = parse(text) assert name.family == family, text assert name.suffix == "", text def test_p6_keeps_every_non_capital_spelling_of_the_particle_member( ) -> None: """Capitals decide; the particle rule keeps the rest, and each reports P6's own kind ONCE (decisions/S5, 2026-09-18).""" for text, family in (("Doe, John do", "do Doe"), ("Doe, John Do", "Do Doe"), ("DOE, JOHN DO", "DO DOE"), ("doe, john do", "do doe")): name = parse(text) assert name.family == family, text assert [a.kind.value for a in name.ambiguities] == \ ["particle-or-given"], text def test_a_lone_connective_is_marked_as_joining_nothing() -> None: # rules/R3's mark (#461), decided where the parts are settled. r = parse("Juan de y") y = [t for t in r.tokens if t.text == "y"][0] assert UNJOINED_CONJUNCTION_TAG in y.tags assert UNJOINED_TAG not in y.tags def test_a_joining_connective_carries_no_mark() -> None: # the recorded negative control: name words beside it, so it is # doing a connective's work and the mark is not written. r = parse("John and Jane Smith") conj = [t for t in r.tokens if t.text == "and"][0] assert UNJOINED_CONJUNCTION_TAG not in conj.tags # and the two-word part, where the criterion is the PART and not # a count r2 = parse("Jon Dough and") conj2 = [t for t in r2.tokens if t.text == "and"][0] assert UNJOINED_CONJUNCTION_TAG not in conj2.tags def test_an_all_particle_part_keeps_r2s_mark_and_gains_no_other( ) -> None: # the `elif`'s precedence, and the reason it is an `elif`: where # the part is all particles R2's mark already readmits every word, # a word that is BOTH particle and connective included. Swapping # the branches moves the caller's own rows. p = Parser(lexicon=Lexicon.default().add(particles={"y"})) r = p.parse("Juan de y") y = [t for t in r.tokens if t.text == "y"][0] assert UNJOINED_TAG in y.tags assert UNJOINED_CONJUNCTION_TAG not in y.tags assert r.initials() == "J. d. y." def test_a_word_that_is_both_particle_and_connective_is_marked_too( ) -> None: # The walk sorts the part's words into three buckets and a word of # BOTH vocabularies belongs in the connective one, which is what # the second arm of the sort does. The shape that needs it: a part # that is not all-particle -- so R2's mark does not fire -- holding # a plain connective, a working particle, and a word that is both. # Without the arm the both-vocabulary word takes neither mark, # drops as an ordinary family particle, and the part's own base # disagrees with the view over it. p = Parser(lexicon=Lexicon.default().add(particles={"y"})) r = p.parse("Juan de y and") assert r.family == "de y and" assert r.initials() == "J. y. a." tags = {t.text: t.tags for t in r.tokens if t.role is Role.FAMILY} assert UNJOINED_CONJUNCTION_TAG in tags["y"] assert UNJOINED_CONJUNCTION_TAG in tags["and"] # the working particle beside them takes no mark and does not # initial, which is what makes the criterion "nothing to join" # rather than "nothing else here" assert UNJOINED_CONJUNCTION_TAG not in tags["de"] assert UNJOINED_TAG not in tags["de"] def test_two_family_names_declines_a_family_first_name_whose_family_is_not_first() -> None: # rules/O6's family-first arm takes the unit after a family standing # FIRST; no shipped rule leaves a family-first name otherwise # (searched over a 19-word grid to four words under both # family-first orders, #754), so the guard is pinned on a state built # by hand: the family moved to the end, where taking "the unit # after it" would take the first word of the name. from nameparser._pipeline._post_rules import _take_second_family_name from nameparser._pipeline._state import copy_with policy = Policy(name_order=FAMILY_FIRST, two_family_names=True) state = run(ParseState(original="García Ana María", lexicon=_LEX, policy=Policy(name_order=FAMILY_FIRST))) roles = (Role.GIVEN, Role.MIDDLE, Role.FAMILY) tokens = [copy_with(t, role=r) for t, r in zip(state.tokens, roles)] moved = copy_with(state, policy=policy, tokens=tuple(tokens)) _take_second_family_name(moved, tokens) assert [t.role for t in tokens] == list(roles) #: rules/O6's hyphen class, written out: a member dropped from the #: regex fails its row below, and one added there fails the roster #: test until it is listed here (#878 review). _O6_HYPHENS = ("-", "\u2010", "\u2011", "\u2012", "\u2013", "\u2212", "\ufe63", "\uff0d") def test_two_family_names_hyphen_class_is_the_listed_one() -> None: assert set(_post_rules._HYPHENS.pattern[1:-1]) == set(_O6_HYPHENS) @pytest.mark.parametrize("hyphen", _O6_HYPHENS, ids=lambda h: f"U+{ord(h):04X}") def test_two_family_names_counts_every_listed_hyphen_as_two_words( hyphen: str) -> None: # O6's hyphen clause, for each member of the class out = _parsed(f"Ana María García{hyphen}López", Policy(two_family_names=True)) assert _by_role(out, Role.MIDDLE) == "María" assert _by_role(out, Role.FAMILY) == f"García{hyphen}López" ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/pipeline/test_script_segment.py��������������������������0000664�0000000�0000000�00000145052�15273032717�0026653�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Stage: script_segment (#271, #272) -- unspaced CJK splitting, by vocabulary and by the pluggable segmenter behind it.""" import dataclasses import pytest from nameparser import Parser, parse from nameparser._lexicon import Lexicon from nameparser._pipeline import STAGES from nameparser._pipeline._script_segment import _PEELED_TAG, script_segment from nameparser._pipeline._segment import segment from nameparser._pipeline._state import ( ParseState, PendingAmbiguity, Structure, ) from nameparser._pipeline._tokenize import tokenize from nameparser._policy import Policy, Script from nameparser._types import AmbiguityKind, Segmentation, Segmenter _HAN = Policy(segment_scripts=frozenset({Script.HAN})) _HANGUL = Policy() # HANGUL is the default activation # the JA pack's own activation (amendment 2026-07-29): HIRAGANA is the # kana license's carrier key, so a kanji+kana composite gates in # through it; KATAKANA is deliberately absent from every set. _JA = Policy(segment_scripts=frozenset({Script.HAN, Script.HIRAGANA})) # 欧 AND 欧阳 both present on purpose: compound-before-single must # pick 欧阳 (the issue's own acceptance example); same for 남/남궁. # "jr" so the comma cases can reach SUFFIX_COMMA. _LEX = Lexicon(surnames=frozenset({"毛", "欧", "欧阳", "김", "남", "남궁"}), suffix_words=frozenset({"jr"})) # The peel's own vocabulary (#308). Tails are also suffix words here, # as the shipped config asserts they are: the neighbour rule in the # segmenter tests below reads that membership, so a stage lexicon that # omitted it would pin a shape no real configuration can have. _TAILS = frozenset({"씨", "님", "선생님", "박사", "さん", "先生", "様"}) _LEX_TAILS = _LEX.add(suffix_words=_TAILS, honorific_tails=_TAILS) def _run(text: str, policy: Policy = _HAN, lexicon: Lexicon = _LEX, segmenter: Segmenter | None = None) -> ParseState: state = ParseState(original=text, lexicon=lexicon, policy=policy, segmenter=segmenter) return script_segment(segment(tokenize(state))) def _texts(state: ParseState) -> list[str]: return [t.text for t in state.tokens] def _fake(splits: tuple[int, ...], confidence: float | None = None) -> Segmenter: """A segmenter that gives the same answer whatever it is handed: every case below pins what the STAGE does with an answer, not how a real segmenter arrives at one (no external dependency here).""" def segmenter(text: str) -> Segmentation | None: return Segmentation(splits, confidence=confidence) return segmenter def _capture( splits: tuple[int, ...] = (2,)) -> tuple[list[str], Segmenter]: """_fake, plus the list of texts it was handed: the two cases below that pin WHETHER and WITH WHAT the stage consults a segmenter need the same closure.""" asked: list[str] = [] def segmenter(text: str) -> Segmentation | None: asked.append(text) return Segmentation(splits) return asked, segmenter def _declines(text: str) -> Segmentation | None: return None def test_splits_leading_surname_and_spans_index_the_original() -> None: state = _run("毛泽东") assert _texts(state) == ["毛", "泽东"] assert [(t.span.start, t.span.end) for t in state.tokens] == [ (0, 1), (1, 3)] assert all(state.original[t.span.start:t.span.end] == t.text for t in state.tokens) def test_compound_before_single() -> None: assert _texts(_run("欧阳明")) == ["欧阳", "明"] assert _texts(_run("남궁민수", policy=_HANGUL)) == ["남궁", "민수"] def test_hangul_splits_under_the_default_policy() -> None: assert _texts(_run("김민준", policy=_HANGUL)) == ["김", "민준"] def test_han_needs_the_opt_in() -> None: # default policy activates HANGUL only: Han tokens stay whole assert _texts(_run("毛泽东", policy=_HANGUL)) == ["毛泽东"] def test_inert_without_surname_vocabulary() -> None: assert _texts(_run("毛泽东", lexicon=Lexicon.empty())) == ["毛泽东"] def test_inert_with_segmentation_disabled() -> None: off = Policy(segment_scripts=()) # type: ignore[arg-type] assert _texts(_run("김민준", policy=off)) == ["김민준"] def test_whole_token_surname_does_not_split() -> None: # a bare surname has nothing to split off; a lone token's role is # the order resolution's call, not this stage's. The trap: 欧 and # 남 are ALSO listed, so without the whole-token guard these # would split 欧+阳 / 남+궁 by the shorter prefix assert _texts(_run("欧阳")) == ["欧阳"] assert _texts(_run("남궁", policy=_HANGUL)) == ["남궁"] def test_no_match_leaves_the_token_alone() -> None: assert _texts(_run("阿明")) == ["阿明"] def test_only_the_first_script_token_is_considered() -> None: assert _texts(_run("毛泽东 毛泽东")) == ["毛", "泽东", "毛泽东"] def test_leading_non_script_token_is_skipped_over() -> None: assert _texts(_run("Dr 毛泽东")) == ["Dr", "毛", "泽东"] def test_mixed_script_token_is_not_split() -> None: assert _texts(_run("毛zedong")) == ["毛zedong"] def test_first_script_token_decides_even_on_no_match() -> None: # no fallthrough: the front of the name is the only surname site assert _texts(_run("阿明 毛泽东")) == ["阿明", "毛泽东"] def test_compound_fork_emits_segmentation_ambiguity() -> None: # both 欧阳+明 and 欧+阳明 are vocabulary-supported: the stage # decided a fork, and the pipeline contract is that deciding # stages record it (indices = the two result tokens) out = _run("欧阳明") assert [a.kind for a in out.ambiguities] == [ AmbiguityKind.SEGMENTATION] assert out.ambiguities[0].indices == (0, 1) # the documented promise: detail names BOTH readings detail = out.ambiguities[0].detail assert "欧阳" in detail and "明" in detail assert "阳明" in detail ko = _run("남궁민수", policy=_HANGUL) assert [a.kind for a in ko.ambiguities] == [ AmbiguityKind.SEGMENTATION] def test_single_possible_split_emits_no_ambiguity() -> None: # only 毛 matches: no fork, no noise assert _run("毛泽东").ambiguities == () assert _run("김민준", policy=_HANGUL).ambiguities == () def test_a_family_comma_stands_the_surname_split_down() -> None: # the comma declared the family: splitting it would invent a # boundary the writer did not draw ("남궁 민수" with a space). # The SPLIT only -- the stage as a whole is not inert under a # family comma since #312, and would not be on a lexicon carrying # honorific_tails (_LEX does not; see the peel's own cases below). out = _run("남궁민수, 지훈", policy=_HANGUL) assert out.structure is Structure.FAMILY_COMMA assert _texts(out) == ["남궁민수", "지훈"] assert out.ambiguities == () def test_family_comma_given_side_untouched() -> None: assert _texts(_run("박, 남궁민수", policy=_HANGUL)) == [ "박", "남궁민수"] def test_one_word_before_the_comma_is_never_suffix_comma() -> None: # an unspaced CJK name is ONE word, and C1's suffix-comma rule # needs >1 word before the comma -- so this stage's own reading of # it (`_postnominal_behind_a_whole_name`) keeps the family comma's # opt-out above, and the name is not split out = _run("김민준, Jr.", policy=_HANGUL) assert _texts(out) == ["김민준", "Jr."] def test_a_listed_ambiguous_credential_behind_two_name_words_divides() -> None: # #613 review: `_postnominal_behind_a_whole_name` asks the comma's # decision, which licenses a listed member of the ambiguous class # by C4's name-word count, so the name before the comma divides as # it does before 'PhD'. A name word after the comma and one word # before it are the contrasts: neither divides. divided = {"family": "김", "given": "민준", "middle": "박"} for text in ("김민준 박, MA", "김민준 박, PhD"): fields = {k: v for k, v in parse(text).as_dict().items() if v} assert {k: fields[k] for k in divided} == divided, text assert parse("김민준 박, Jones").family == "김민준 박" assert parse("김민준, MA").family == "김민준" # script_segment runs before group, and divides the name before a # comma exactly where the comma's own decision, taken on the name as # written, reads a suffix comma (`_postnominal_behind_a_whole_name` # asks `_comma.decide` itself since #613's /simplify). The hand copy it # replaced drifted in both directions: dividing a name decide then read # as a FAMILY comma laid the divided pieces out as the family ('김 민준 # 박'), and leaving whole one decide read as a suffix comma lost the # division and the honorific's peel ('Smith 김민준씨, XYZ'). # # Recorded negative controls (#613 reviews, 2026-10-06), each on the # grid as it stood then: at 47fb2b21, where the stand-in asked the # vocabulary alone, all 12 `_DIVIDES` pairs were left whole; at # bac1b510, whose count arm skipped decide's numeral refusal and its # policy-selected suffix test, 24 of the then 168 parses (four heads, # 21 tails, two policies) divided behind a family comma ('MA V', 'MA # I', 'V MA', 'MA V.', and 'V.' under strict). _AGREEMENT_HEADS = ("김민준 박", "마틴 킹", "Smith 김민준씨", "Dr 김민준", "김민준 Jr.", "김민준 III") _AGREEMENT_TAILS = ( "MA", "Ma", "MA PhD", "PhD", "Jr.", "Jr. MA", "MD Ma", "Ed", "Jones", "G.J.", "MA V", "V MA", "MA I", "V.", "MA V.", "X.Y.Z.", "MA X.Y.Z.", "MA Ph. D.", "Dr MA", "MA Dr", "XYZ", "Esq.", "MA Jones", "Ma Jones", "Jones MA") #: a listed member behind two name words, which the count licenses _DIVIDES = {(head, tail) for head in ("김민준 박", "마틴 킹") for tail in ("MA", "Ma", "MA PhD", "Jr. MA", "MD Ma", "Ed")} def _through_group(text: str, policy: Policy) -> ParseState: state = ParseState(original=text, lexicon=Lexicon.default(), policy=policy) for stage in STAGES: state = stage(state) if stage.__name__ == "group": break return state def test_the_division_never_outruns_the_comma_decision() -> None: # decide's answer is taken on the UNDIVIDED name (`segment_scripts` # off), not after the division: a division can make the very whole # name it guessed at, and decide then agrees with it -- the PR # review's '김민준 Jr., PhD', divided by a stand-in that counted the # suffix as a unit, read as a suffix comma only because '김 민준' # had become two name words (control, measured on 9dd271b2: 14 of # the then 264 parses outran, every one a head with a suffix in it, # '김민준 Jr.' or '김민준 III' before a part of suffix words). Both # directions are asserted since the stage asks decide itself; on # 3320d700, the last tree with the hand copy, 0 of these 300 parses # outran and 44 lagged -- left whole where decide read a suffix # comma, 'Dr 김민준, MA' and the caps and dotted shapes among them outran, lagged, divided_where_expected = set(), set(), set() for policy in (Policy(), Policy(lenient_comma_suffixes=False)): undivided_policy = dataclasses.replace( policy, segment_scripts=frozenset()) for head in _AGREEMENT_HEADS: for tail in _AGREEMENT_TAILS: text = f"{head}, {tail}" state = _through_group(text, policy) own = [i for i in state.segments[0] if _PEELED_TAG not in state.tokens[i].tags] divided = len(own) > len(head.split()) undivided = _through_group(text, undivided_policy) suffix_comma = (undivided.structure is Structure.SUFFIX_COMMA) key = (head, tail, policy.lenient_comma_suffixes) if divided and not suffix_comma: outran.add(key) if suffix_comma and not divided: lagged.add(key) if divided and (head, tail) in _DIVIDES: divided_where_expected.add( (head, tail, policy.lenient_comma_suffixes)) assert outran == set() assert lagged == set() # per policy: a division lost under one policy alone must fail assert divided_where_expected == { (head, tail, lenient) for head, tail in _DIVIDES for lenient in (True, False)} def test_suffix_comma_name_part_still_splits() -> None: # the structure stays the family comma here: group decides it once # the words are tagged (#613), and this stage asks the question # for itself (_postnominal_behind_a_whole_name) out = _run("Dr 김민준, Jr.", policy=_HANGUL) assert _texts(out) == ["Dr", "김", "민준", "Jr."] def test_segments_remap_after_the_split() -> None: out = _run("Dr 김민준, Jr.", policy=_HANGUL) # name segment gains the second half in place; the suffix # segment's index follows the token it names assert out.segments == ((0, 1, 2), (3,)) assert [[out.tokens[i].text for i in run] for run in out.segments] \ == [["Dr", "김", "민준"], ["Jr."]] def test_ambiguity_indices_shift_past_the_split() -> None: state = segment(tokenize(ParseState( original="毛泽东 X", lexicon=_LEX, policy=_HAN))) state = dataclasses.replace(state, ambiguities=( PendingAmbiguity(AmbiguityKind.UNBALANCED_DELIMITER, "synthetic", indices=(0,)), PendingAmbiguity(AmbiguityKind.UNBALANCED_DELIMITER, "synthetic", indices=(1,)), )) out = script_segment(state) # index 0 (the split token itself) stays; index 1 moves right assert out.ambiguities[0].indices == (0,) assert out.ambiguities[1].indices == (2,) def test_the_real_fold_splits_and_keeps_the_stage_after_segment() -> None: # the stage-order contract end to end: a set-comparison ownership # test cannot catch a reorder, but this can -- placed before # segment, the split would feed segment two words where the writer # wrote one and turn the family-comma parse below into a # suffix-comma one p = Parser(lexicon=_LEX, policy=_HANGUL) plain = p.parse("김민준") assert (plain.family, plain.given) == ("김", "민준") declared = p.parse("남궁민수, 지훈") assert (declared.family, declared.given) == ("남궁민수", "지훈") def test_delimited_only_input_reaches_the_empty_segments_guard() -> None: # extraction masks the whole string, so segment produces no runs # at all -- the guard the stage-level helper above cannot reach nick = Parser(lexicon=_LEX, policy=_HANGUL).parse("(민준)") assert nick.nickname == "민준" # -- the pluggable segmenter, consulted on decline (#272) --------------- def test_segmenter_splits_on_vocabulary_decline() -> None: # the baseline: with no segmenter configured a declined token is # simply left whole, so every split below is the hook's doing assert _texts(_run("山田太郎", policy=_JA)) == ["山田太郎"] # 山田太郎 has no vocabulary surname prefix at all, so the # vocabulary declines and the token reaches the segmenter state = _run("山田太郎", policy=_JA, segmenter=_fake((2,))) assert _texts(state) == ["山田", "太郎"] assert [(t.span.start, t.span.end) for t in state.tokens] == [ (0, 2), (2, 4)] assert all(state.original[t.span.start:t.span.end] == t.text for t in state.tokens) def test_vocabulary_wins_over_the_segmenter() -> None: # precedence, and the reason parser_for(ZH, JA, segmenter=...) # composes: a matched surname is a dictionary certainty, so the # segmenter is not even asked asked, seg = _capture() state = _run("毛泽东", policy=_JA, segmenter=seg) assert _texts(state) == ["毛", "泽东"] assert asked == [] def test_segmenter_decline_and_confident_whole_are_distinct() -> None: # two different ANSWERS -- None is "I don't know", () is # "confidently one token" -- with the same effect on the tokens: # the difference is the protocol's to carry, not this stage's for seg in (_declines, _fake(())): out = _run("山田太郎", policy=_JA, segmenter=seg) assert _texts(out) == ["山田太郎"] assert out.ambiguities == () # nothing decided, nothing to report def test_kana_licensed_token_gates_in_for_the_segmenter() -> None: # the gate reads effective_script, not single_script: 高橋みなみ is # mixed Han+hiragana, which the kana license resolves to the # HIRAGANA carrier entry -- and HIRAGANA is in the JA activation state = _run("高橋みなみ", policy=_JA, segmenter=_fake((2,))) assert _texts(state) == ["高橋", "みなみ"] def test_pure_katakana_never_gates_in() -> None: # the license's other half: a lone katakana token is predominantly # a transcribed foreign name, so KATAKANA sits in no activation set # and no segmenter is consulted for one out = _run("マイケル", policy=_JA, segmenter=_fake((2,))) assert _texts(out) == ["マイケル"] assert out.ambiguities == () def test_kana_licensed_token_stays_whole_under_default_policy() -> None: # the pack IS the language declaration: without it the default # activation is HANGUL alone, so nothing gates in and the # configured segmenter is inert out = _run("高橋みなみ", segmenter=_fake((2,))) assert _texts(out) == ["高橋みなみ"] assert out.ambiguities == () def test_segmenter_multi_split_produces_n_plus_one_tokens() -> None: # n cuts make n+1 sub-slices, and every index the earlier stages # recorded shifts by n rather than by one state = segment(tokenize(ParseState( original="山田太 X", lexicon=_LEX, policy=_JA, segmenter=_fake((1, 2))))) state = dataclasses.replace(state, ambiguities=( PendingAmbiguity(AmbiguityKind.UNBALANCED_DELIMITER, "synthetic", indices=(1,)),)) out = script_segment(state) assert _texts(out) == ["山", "田", "太", "X"] assert [(t.span.start, t.span.end) for t in out.tokens] == [ (0, 1), (1, 2), (2, 3), (4, 5)] assert all(out.original[t.span.start:t.span.end] == t.text for t in out.tokens) assert out.segments == ((0, 1, 2, 3),) assert out.ambiguities[0].indices == (3,) def test_low_confidence_emits_segmentation_ambiguity() -> None: # a statistical guess is a fork the stage decided, so a score under # _SEGMENTER_CONFIDENCE_FLOOR is reported -- unlike a vocabulary # split, which reports only when a SECOND surname also matched state = _run("山田太郎", policy=_JA, segmenter=_fake((2,), confidence=0.42)) assert [a.kind for a in state.ambiguities] == [ AmbiguityKind.SEGMENTATION] assert "0.42" in state.ambiguities[0].detail assert state.ambiguities[0].indices == (0, 1) def test_high_confidence_and_no_confidence_emit_nothing() -> None: # a confident answer is not a fork, and an opinionless one gives # the stage nothing to judge -- neither is worth a report # 0.9 is the floor itself: the comparison is strict, so an answer # AT the floor is not under it for conf in (0.99, 0.9, None): state = _run("山田太郎", policy=_JA, segmenter=_fake((2,), confidence=conf)) assert _texts(state) == ["山田", "太郎"] assert state.ambiguities == () def test_out_of_bounds_split_raises() -> None: # Segmentation cannot check the upper bound (it never sees the # text), so the stage does -- and it RAISES, the same call the # wrong-answer-type check beside it makes: both are protocol # violations by the segmenter's author, inside the declared # totality exception. Declining silently instead (as this did # before the review) made an off-by-one segmenter undebuggable -- # every answer vanished and the name merely looked undivided. with pytest.raises(ValueError, match=r"segmenter returned splits beyond the " r"token: last offset 5, the segmenter was " r"given 2 characters"): _run("山田", policy=_JA, segmenter=_fake((5,))) # 2 on a two-character token is the boundary itself: the check is # >=, not >, since a cut at len(core) would leave an empty piece with pytest.raises(ValueError, match="last offset 2, the segmenter was given 2"): _run("山田", policy=_JA, segmenter=_fake((2,))) # #323: the bound is the CORE's length, which is what the segmenter # was handed -- 2 is past the end of '山田' whether or not the token # wears a stop, and the message reports the core's length too. # Against len(text) this answer would pass the check and make the # stop a piece of its own ('山田' + '.'). with pytest.raises(ValueError, match="last offset 2, the segmenter was given 2"): _run("山田.", policy=_JA, segmenter=_fake((2,))) def test_family_comma_gates_the_segmenter_too() -> None: # the comma doctrine is about the input's structure, not the # split's source: the structural opt-out runs before any consult out = _run("山田太郎, 花子", policy=_JA, segmenter=_fake((2,))) assert out.structure is Structure.FAMILY_COMMA assert _texts(out) == ["山田太郎", "花子"] assert out.ambiguities == () def test_empty_vocabulary_still_consults_the_segmenter() -> None: # "consult on vocabulary decline" has no unstated exception for a # lexicon that declines EVERYTHING: a surname-less vocabulary is # the JA pack's own shape (the segmenter is its whole mechanism), # so an emptiness bail here would make it silently inert state = _run("山田太郎", policy=_JA, lexicon=Lexicon.empty(), segmenter=_fake((2,))) assert _texts(state) == ["山田", "太郎"] def test_a_wrong_answer_type_raises_a_curated_error() -> None: # a duck-typed lookalike would otherwise wander into the split # path and surface as a ValueError naming Token -- the reader's # bug is in their segmenter, and the message must say so class NotASegmentation: splits = (2,) confidence = None def seg(text: str) -> Segmentation | None: return NotASegmentation() # type: ignore[return-value] with pytest.raises(TypeError, match="segmenter must return Segmentation or None, " "got NotASegmentation"): _run("山田太郎", policy=_JA, segmenter=seg) def test_a_neighbour_in_an_UNACTIVATED_script_still_blocks_the_consult() -> None: # The precondition counts a second script-written token whatever # script it is written in -- effective_script merely non-None, NOT # membership in the activation set. A katakana or hangul neighbour # is a boundary its writer drew just as deliberately as a Han one, # so the name is already divided and the segmenter must not be # asked. Narrowing the check to `in scripts` would make both of # these split 山 + 田太郎 while the writer's own boundary sat one # token to the right. The halfwidth katakana neighbour is the # same boundary in legacy data, and counts since #594 classified it. for name in ("山田太郎 マイケル", "山田太郎 マイケル", "山田太郎 김민준"): out = _run(name, policy=_JA, segmenter=_fake((1,))) assert _texts(out) == name.split(), name assert out.ambiguities == (), name def test_a_period_marked_neighbour_still_blocks_the_consult() -> None: # #323: the neighbour precondition reads effective_script, which # now sees through an edge stop, so '田中.' beside 山田太郎 counts # as the writer's own boundary and the segmenter is not consulted # (before, the stop hid the neighbour and 山田太郎 was divided as # if it stood alone) asked: list[str] = [] def seg(text: str) -> Segmentation | None: asked.append(text) return Segmentation((2,)) out = _run("山田太郎 田中.", policy=_JA, lexicon=Lexicon.empty(), segmenter=seg) assert asked == [] assert _texts(out) == ["山田太郎", "田中."] def test_a_consulted_segmenter_receives_the_core_without_the_stop() -> None: # #323: the segmenter is handed the same CORE the vocabulary match # reads, not the raw token -- so a repertoire test like the ja # pack's _wholly_japanese sees a string the stop cannot spoil, and # no answer of the segmenter's can make the stop a piece of its # own. A head is a prefix, so the offsets come back valid for the # whole token and the stop rides with the last piece. asked: list[str] = [] def seg(text: str) -> Segmentation | None: asked.append(text) return None _run("山田太郎.", policy=_JA, lexicon=Lexicon.empty(), segmenter=seg) assert asked == ["山田太郎"] out = _run("山田太郎.", policy=_JA, lexicon=Lexicon.empty(), segmenter=_fake((2,))) assert _texts(out) == ["山田", "太郎."] assert [(t.span.start, t.span.end) for t in out.tokens] == [ (0, 2), (2, 5)] assert all(out.original[t.span.start:t.span.end] == t.text for t in out.tokens) def test_only_a_manufactured_tail_does_not_block_the_consult() -> None: # The exemption is PROVENANCE, not vocabulary. A tail the peel # manufactured is a boundary nobody drew -- glued 山田太郎様 was # written as one token -- so the segmenter is asked, and asked # about the name alone. The SPACED spelling of the same honorific # is the opposite case: its writer drew that boundary and wrote # 山田太郎 as a unit beside it, so the consult is declined and the # unit stays whole. Asking the suffix VOCABULARY instead cannot # tell these apart -- same word, same place -- and answering both # the first way divided 佐藤 氏 into 佐 + 藤. asked, seg = _capture() out = _run("山田太郎様", policy=_JA, lexicon=_LEX_TAILS, segmenter=seg) assert asked == ["山田太郎"] assert _texts(out) == ["山田", "太郎", "様"] asked, seg = _capture() out = _run("山田太郎 様", policy=_JA, lexicon=_LEX_TAILS, segmenter=seg) assert asked == [] assert _texts(out) == ["山田太郎", "様"] def test_a_manufactured_tail_does_not_excuse_a_real_boundary() -> None: # The peeled tail stops counting, and nothing else does: 太郎 is # still a boundary its writer drew, so the name is already divided # and the segmenter must not be asked. Without the narrowing the # rule reads as "an exempt token anywhere disables the neighbour # test", which passes every other test in this file and divides # 山田 into 山 + 田. asked, seg = _capture() out = _run("山田 太郎様", policy=_JA, lexicon=_LEX_TAILS, segmenter=seg) assert asked == [] assert _texts(out) == ["山田", "太郎", "様"] # -- the glued honorific peel (#308) ----------------------------------- def test_peels_a_listed_tail_without_any_activation() -> None: # The peel is licensed by the TAIL, not by the script, so it runs # under a policy that activates nothing -- 田中さん must peel under # the DEFAULT policy, where HAN is in no activation set. Spans stay # sub-slices of the original (anti-#100), like every split here. off = Policy(segment_scripts=()) # type: ignore[arg-type] out = _run("田中さん", policy=off, lexicon=_LEX_TAILS) assert _texts(out) == ["田中", "さん"] assert [(t.span.start, t.span.end) for t in out.tokens] == [ (0, 2), (2, 4)] assert all(out.original[t.span.start:t.span.end] == t.text for t in out.tokens) assert out.ambiguities == () # decisive vocabulary, no fork def test_peel_then_segmentation_compose() -> None: # the peel runs first, so the segmentation half sees the REMAINDER assert _texts(_run("김민준씨", policy=_HANGUL, lexicon=_LEX_TAILS)) == ["김", "민준", "씨"] @pytest.mark.parametrize("stop", [".", ".", "。", "。", "。。"]) def test_peels_a_listed_tail_through_a_trailing_full_stop(stop: str) -> None: # #323 (the W3 reading that was pinned by nothing): the tail is # matched on the token with its trailing stops removed, and the # cut lands BEFORE the tail, so the stop rides with the honorific. # Spans stay sub-slices of the original (anti-#100). # a run of stops rides whole: the offset is len(core) - tail, and # stops only lengthen the remainder. out = _run("김민준씨" + stop, lexicon=_LEX_TAILS) assert _texts(out) == ["김민준", "씨" + stop] assert [(t.span.start, t.span.end) for t in out.tokens] == [ (0, 3), (3, 4 + len(stop))] assert all(out.original[t.span.start:t.span.end] == t.text for t in out.tokens) def test_the_peel_reads_the_trailing_stop_only() -> None: # the rstrip in _peel_site is not strip, and this is what the # difference costs: a LEADING stop is not between the name and its # honorific, so folding it away would shorten the token the split # arithmetic measures from and cut two characters early # ('.김민' + '준씨'). The peel is licensed by the tail, not by the # script, so it fires here whatever the classification fold said. out = _run(".김민준씨", lexicon=_LEX_TAILS) assert _texts(out) == [".김민준", "씨"] assert [(t.span.start, t.span.end) for t in out.tokens] == [ (0, 4), (4, 5)] assert all(out.original[t.span.start:t.span.end] == t.text for t in out.tokens) def test_a_token_that_is_only_full_stops_offers_no_peel_site() -> None: # pins the len(core) - 1 arithmetic staying inert, not a reading: # the scan-back stops at the first non-post-nominal token, which is # the lone stop; its core is empty, the cap goes negative and the # match loop never runs. Passes on the pre-#323 tree too (cap 0 # there); it is here so a cap that ever went positive on an empty # core would be caught. out = _run("김민준 。", lexicon=_LEX_TAILS) assert _texts(out) == ["김민준", "。"] def test_surname_site_matches_through_a_trailing_full_stop() -> None: # #323: the head is matched on the token's core, so the stop rides # with the remainder rather than BEING the remainder assert _texts(_run("김민준.", policy=_HANGUL, lexicon=_LEX)) == ["김", "민준."] # a token whose core IS a surname never splits, stop or no stop: # 남궁 is a compound surname and 남 a single one, so the head match # would find 남 and leave 궁. -- the whole-token guard has to read # the CORE, not the text, or the stop takes 남궁. out of it assert _texts(_run("남궁.", policy=_HANGUL, lexicon=_LEX)) == ["남궁."] # the one-character cap carries these two: len(core) - 1 is 0, so # the match loop never runs whatever the guard above did assert _texts(_run("김.", policy=_HANGUL, lexicon=_LEX)) == ["김."] assert _texts(_run("김", policy=_HANGUL, lexicon=_LEX)) == ["김"] # a LEADING stop is not a site at all: the classification fold in # front rstrips too (_vocab._normalized_for_script), so '.김민준' # classifies as no script and never reaches this stage. The rstrip # here is belt and braces -- were the token to arrive, the text is # its own core's prefix only when the stop trails. assert _texts(_run(".김민준", policy=_HANGUL, lexicon=_LEX)) == [".김민준"] def test_a_token_that_is_a_tail_never_peels() -> None: # nothing to split off, and the guard is the peel site's scan past # post-nominals, not the length cap: 선생님 ENDS in the shorter # tail 님, so a cap-based guard would peel it to 선생 + 님 assert _texts(_run("씨", policy=_HANGUL, lexicon=_LEX_TAILS)) == ["씨"] assert _texts(_run("さん", policy=_HANGUL, lexicon=_LEX_TAILS)) == ["さん"] assert _texts(_run("선생님", policy=_HANGUL, lexicon=_LEX_TAILS)) == ["선생님"] # #323: since the tail is matched on the CORE, a trailing stop no # longer stands between 님 and the end -- what keeps 선생님. whole # is is_suffix_strict normalizing the edge stop, so the scan-back # steps past it as a post-nominal. Without that the cap reaches # 님 and dissects it (measured by removing the normalization). assert _texts(_run("선생님.", policy=_HANGUL, lexicon=_LEX_TAILS)) == ["선생님."] assert _texts(_run("씨.", policy=_HANGUL, lexicon=_LEX_TAILS)) == ["씨."] def test_longest_tail_wins() -> None: # 님 and 선생님 both match by endswith, and only the longer one # leaves a remainder that is a name (박선생 is not one) assert _texts(_run("박선생님", policy=_HANGUL, lexicon=_LEX_TAILS)) == ["박", "선생님"] def test_one_peel_never_a_stack() -> None: # The remainder here ENDS in another listed tail (박사) and is # still not peeled again: one peel, then the stage moves on to # segmentation, which splits the surname off what is left. # The SOLE home of this pin, and it needs the synthetic lexicon: # _TAILS deliberately omits 박사님, which the shipped vocabulary # carries, so 김민준박사님 gives up the whole honorific there (the # ko_honorific_glued_doctor case row). No shipped entry pair has # the shape this needs -- a tail whose remainder ends in a # DIFFERENT tail, the two not forming a listed entry themselves -- # so no realistic name can carry the pin end to end. assert _texts(_run("김민준박사님", policy=_HANGUL, lexicon=_LEX_TAILS)) == ["김", "민준박사", "님"] def test_peel_needs_no_script_on_the_remainder() -> None: # the tail is the license: Japanese text about a foreigner assert _texts(_run("Andersonさん", policy=_HANGUL, lexicon=_LEX_TAILS)) == ["Anderson", "さん"] def test_an_unlisted_tail_never_peels() -> None: # endswith against the vocabulary, never against a shape: 지양 ends # in a shipped SPACED honorific that is deliberately not a tail assert _texts(_run("김지양", policy=_HANGUL, lexicon=_LEX_TAILS)) == ["김", "지양"] def test_an_interpunct_gates_the_split_but_not_the_peel() -> None: # The dot marks a transcription, which is a claim about where the # name divides into syllable groups -- so it still gates the # surname split. An honorific glued to a transcribed name is # still an honorific, so the peel crosses it (#312). 威 is a # surname in this lexicon precisely so the first clause bites: # without the gate 威廉 would divide 威 + 廉. lex = _LEX_TAILS.add(surnames={"威"}) state = segment(tokenize(ParseState( original="威廉·莎士比亚先生", lexicon=lex, policy=_HAN))) assert state.interpunct_offsets out = script_segment(state) assert _texts(out) == ["威廉", "莎士比亚", "先生"] def test_a_peeled_tail_is_not_a_surname_site() -> None: # The peel manufactures a token, and the site scan below must not # then treat it as a name: 남궁 opens with the listed surname 남, # so without the post-nominal guard the stage would split the # honorific it had just created. lex = Lexicon(surnames=frozenset({"남"}), suffix_words=frozenset({"남궁"}), honorific_tails=frozenset({"남궁"})) assert _texts(_run("Anderson남궁", policy=_HANGUL, lexicon=lex)) == ["Anderson", "남궁"] def test_a_leading_post_nominal_is_not_a_surname_site() -> None: # A surname LEADS, so an honorific in the surname's own position # means there is no surname to find -- decline rather than scan # on. Scanning on would reach the given name, which is what the # first-token rule above exists to prevent: 지 is a listed # surname, so 양 지훈 would split its own given name in half. assert _texts(_run("씨 김민준", policy=_HANGUL, lexicon=_LEX_TAILS)) == ["씨", "김민준"] def test_a_trailing_post_nominal_does_not_hide_the_peel_site() -> None: # the site is the last token that is not ITSELF a post-nominal, so # an unrelated trailing suffix cannot put the glued one out of # reach -- "김민준씨 jr" must answer as "Dr 김민준씨, Jr." does assert _texts(_run("김민준씨 jr", policy=_HANGUL, lexicon=_LEX_TAILS)) == ["김", "민준", "씨", "jr"] def test_the_peel_only_looks_at_the_last_non_post_nominal_token() -> None: # a tail anywhere but the end is somebody's name, not an # honorific: only the trailing position is an honorific site, the # same reasoning the trailing-only suffix gate follows assert _texts(_run("田中さん 太郎", policy=_HANGUL, lexicon=_LEX_TAILS)) == ["田中さん", "太郎"] def test_the_peel_reaches_past_a_family_comma() -> None: # A comma says where the FAMILY name ends. It says nothing about # whether a trailing honorific is part of the name, so the peel # does not inherit the gate that stops the surname split -- and # under FAMILY_COMMA the name spans both segments, so the site # scan has to cross the boundary to find 민준씨 at all. assert _texts(_run("김, 민준씨", policy=_HANGUL, lexicon=_LEX_TAILS)) == ["김", "민준", "씨"] def test_the_peel_scans_the_name_runs_and_no_further() -> None: # Crossing a FAMILY comma is not crossing every comma: past the # name's own runs lie post-nominals, and taking one as the site # abandons the peel. Two shapes, both silently broken by a scan # over ALL segments. The run predicate admits a post-comma run on # is_suffix_lenient while the site scan asks is_suffix_strict, so # an initial-shaped suffix word is a site rather than a token to # step over -- "V." ends in no tail, and 씨 would stay glued. And # a later junk segment would take the peel off the person's own # name onto 박씨, peeling the wrong 씨 of two. lex = _LEX_TAILS.add(suffix_words={"v"}) assert _texts(_run("Dr 김민준씨, V.", policy=_HANGUL, lexicon=lex)) == ["Dr", "김", "민준", "씨", "V."] assert _texts(_run("Dr 김민준씨, Jr., 박씨", policy=_HANGUL, lexicon=lex)) == [ "Dr", "김", "민준", "씨", "Jr.", "박씨"] def test_a_suffix_comma_keeps_the_peel_inside_the_name() -> None: # The test above shows a suffix comma not being crossed while the # name HOLDS a site, which is the easy half: segments[0] answers # the scan either way, so a gate that had stopped keying on # FAMILY_COMMA -- on "there is a second run at all", say -- would # still land the peel on 민준씨 and pass. This is the twin that can # see it: two words before the comma so the structure is # SUFFIX_COMMA, NO site among them, and a token beyond the comma # that ends in a listed tail. The peel must answer None, not reach # past the name for the junk one -- "J.씨" is a stray post-nominal, # and its 씨 is nobody's honorific. assert _texts(_run("John Smith, J.씨", policy=_HANGUL, lexicon=_LEX_TAILS)) == ["John", "Smith", "J.씨"] def test_the_peel_crosses_a_family_comma_and_stops_there() -> None: # The family-comma mirror of the test above, and the only input # that pins the SECOND half of segments[:2] -- that it is two runs # and not three. Every other family-comma case here has exactly # two segments, so the slice is otherwise only ever exercised as # "more than one run": widening it to three passes them all. # Both shapes above, re-spelled with the name split across a family # comma -- but only the SECOND of the two pins the slice, and the # measurement says so: widen this scan to three runs and 박씨 is # the junk tail whose 씨 gets peeled instead of the person's own, # while "Jr." is a post-nominal by the STRICT test as well, so a # wider scan steps over it onto 민준씨 and peels correctly anyway. # It is the initial-shaped words ("V." in the test above) that fall # in the strict/lenient gap, not "Jr."; this input keeps 씨's # placement pinned against a third run of ordinary post-nominals. assert _texts(_run("김, 민준씨, Jr.", policy=_HANGUL, lexicon=_LEX_TAILS)) == ["김", "민준", "씨", "Jr."] assert _texts(_run("김, 민준씨, 박씨", policy=_HANGUL, lexicon=_LEX_TAILS)) == ["김", "민준", "씨", "박씨"] def test_a_wholly_suffix_run_after_a_family_comma_is_declined() -> None: # The two tests above pin HOW FAR the crossing reaches; this pins # WHETHER it happens, which the structure alone does not decide. # The structure here is FAMILY_COMMA whenever a single word precedes # the comma (and, since #613, for every comma form), even where the # part after it is entirely suffix-shaped, so "the second run is # name text" is an inference and a wrong one here. Scanning it lands # the site on "V." -- admitted to the run by is_suffix_lenient, # rejected as an initial by the scan's is_suffix_strict -- which # ends in no listed tail, so the peel is abandoned and さん stays # glued to 田中 (#319). The peel asks is_wholly_suffix, C1's run # predicate, and declines the run. lex = _LEX_TAILS.add(suffix_words={"v"}) assert _texts(_run("田中さん, V.", policy=_HANGUL, lexicon=lex)) == ["田中", "さん", "V."] def test_a_long_wholly_suffix_post_comma_run_is_declined_too() -> None: # The decline is a question about the run's VOCABULARY and not its # LENGTH, and no other input here says so: every case pinning the # decline has a one- or two-token run, so a gate that declined only # short runs passes all of them and this one alone catches it. # Three post-nominals, with the initial-shaped one LEFTMOST on # purpose -- a scan that reached this run would step over PhD and # MD (both post-nominals by the strict test) and stop on "V.", # which ends in no tail, abandoning the peel exactly as above. lex = _LEX_TAILS.add(suffix_words={"v"}, suffix_acronyms={"md", "phd"}) assert _texts(_run("田中さん, V. MD PhD", policy=_HANGUL, lexicon=lex)) == ["田中", "さん", "V.", "MD", "PhD"] def test_a_third_segment_does_not_reopen_the_declined_run() -> None: # WHICH run the decline reads, and how many there may be, are two # more axes no other input here moves: every case pinning the # decline has exactly two segments, so reading segments[-1] instead # of segments[1], or requiring exactly two segments before # declining at all, passes all of them. Both mistakes give the same # visible loss on this input -- the run consulted is 太郎, or none # is, so the scan crosses, "V." is the site, it ends in no tail and # the peel is abandoned. The third segment is junk beyond the name # either way (segment flags it, and the peel already refuses to # scan it -- test_the_peel_crosses_a_family_comma_and_stops_there # above); the question here is only whether its presence changes # the answer about the SECOND. lex = _LEX_TAILS.add(suffix_words={"v"}) assert _texts(_run("田中さん, V., 太郎", policy=_HANGUL, lexicon=lex)) == ["田中", "さん", "V.", "太郎"] def test_a_configured_suffix_delimiter_reaches_the_peel() -> None: # is_wholly_suffix is handed the CALLER's policy, so # extra_suffix_delimiters decides what counts as a suffix run here # exactly as it does inside segment -- a peel that passed a default # policy instead would answer differently from the stage whose # question it is borrowing. Two routes into that answer, neither # reachable from the other's input, so both are pinned: a # delimiter WITHOUT whitespace leaves one token no core equals # ("PhD+MD"), admitted by splits_into_suffixes, while a # whitespace-padded one surfaces as a standalone token ("-"), # admitted by plain core membership. Each peels only with its # delimiter configured; the bare-policy line under each is the # baseline that says so. lex = _LEX_TAILS.add(suffix_words={"v"}, suffix_acronyms={"md", "phd"}) # not '/': a word joined by a slash or a hyphen whose every chunk # is listed reads as a suffix with no delimiter configured (#752) plus = Policy(extra_suffix_delimiters=frozenset({"+"})) assert _texts(_run("田中さん, PhD+MD", policy=plus, lexicon=lex)) == ["田中", "さん", "PhD+MD"] assert _texts(_run("田中さん, PhD+MD", policy=_HANGUL, lexicon=lex)) == ["田中さん", "PhD+MD"] # ' | ', not ' - ': a hyphen standing alone is built in (#889) pipe = Policy(extra_suffix_delimiters=frozenset({" | "})) assert _texts(_run("田中さん, Jr. | V.", policy=pipe, lexicon=lex)) == ["田中", "さん", "Jr.", "|", "V."] assert _texts(_run("田中さん, Jr. | V.", policy=_HANGUL, lexicon=lex)) == ["田中さん", "Jr.", "|", "V."] def test_an_empty_first_run_still_reaches_the_second() -> None: # A leading comma empties segments[0] without emptying the name: # the scan flattens both runs, so an empty first one is nothing to # stop at. Guarding the crossing on a non-empty segments[0] passes # the ", , 씨" test below, where BOTH runs are empty, and fails # only here. assert _texts(_run(", 민준씨", policy=_HANGUL, lexicon=_LEX_TAILS)) == ["민준", "씨"] def test_the_peel_crosses_a_comma_and_a_dot_at_once() -> None: # The two gates #312 made siblings, composed. Both stand over the # surname split here -- 威 is a listed surname in this lexicon, so # bare 威廉 would divide 威 + 廉, and either gate alone stops that # (the family comma reaches its return first) -- while the peel # crosses both and reaches 太郎さん on the far side of the comma. # Neither gate alone pins this: keying the cross-comma reach on # interpunct-free input passes every other case in this file. lex = _LEX_TAILS.add(surnames={"威"}) state = segment(tokenize(ParseState( original="威廉·莎士比亚, 太郎さん", lexicon=lex, policy=_HAN))) assert state.interpunct_offsets assert state.structure is Structure.FAMILY_COMMA assert _texts(script_segment(state)) == [ "威廉", "莎士比亚", "太郎", "さん"] def test_the_peel_survives_a_name_with_no_name_tokens() -> None: # The name's runs are EMPTY -- a leading comma yields an empty one # per comma -- so the scan has nothing to look at and answers None # rather than indexing. Reachable, so taking the last token # outright would raise here. The scan's other way to answer None, # a name that is nothing but post-nominals, is not what this # exercises: next() short-circuits on the empty runs and never # asks. test_a_token_that_is_a_tail_never_peels pins that one. assert _texts(_run(", , 씨", policy=_HANGUL, lexicon=_LEX_TAILS)) == ["씨"] def test_a_comma_does_not_by_itself_change_the_peel() -> None: # The counterpart, and the shape #312 was originally filed about: # the site is the last NON-POST-NOMINAL token, which is 太郎 here # whichever spelling is used, so neither peels. Crossing the comma # must not turn this into a peel. for name in ("田中さん 太郎", "田中さん, 太郎"): expected = name.replace(",", "").split() assert _texts(_run(name, policy=_HANGUL, lexicon=_LEX_TAILS)) == expected, name # -- the 间隔号 gate: a divided name is a transcription (#298) ---------- def test_interpunct_divided_name_never_segments() -> None: # 马丁·路德·金 (Martin Luther King): 马 is a listed surname, but a # 间隔号-divided name is a transcription -- its pieces are syllable # groups, not surname+given, so the dot gates the stage off # entirely, vocabulary AND segmenter: rules/W1's Except bullet "A # name the interpunct divides", the clause this test pins # (decisions/W1, decisions/T3). Built # through the real tokenize so interpunct_offsets is the field's # own producer's, not hand-set. state = segment(tokenize(ParseState( original="马丁·路德·金", lexicon=Lexicon(surnames=frozenset({"马"})), policy=_HAN))) assert state.interpunct_offsets # tokenize recorded the dots out = script_segment(state) assert out.tokens == state.tokens # nothing split: no 马 + 丁 assert out.ambiguities == () def test_interpunct_divided_name_never_consults_the_segmenter() -> None: # the gate's other consumer, pinned separately: today the # second-script-token precondition would decline this consult # anyway, but the transcription doctrine must not depend on it -- # the dot gates BEFORE either mechanism is reached asked, seg = _capture() state = segment(tokenize(ParseState( original="马丁·路德·金", lexicon=Lexicon.empty(), policy=_HAN, segmenter=seg))) out = script_segment(state) assert out.tokens == state.tokens assert asked == [] def test_interpunct_gates_the_whole_name_not_just_dotted_tokens() -> None: # state-global on purpose: the marker reads the WHOLE name as a # transcription listing, so the un-dotted hangul token stays whole # too -- the baseline shows it segments without the B7 elsewhere assert _texts(_run("김민준 马丁 路德", policy=_HANGUL)) == [ "김", "민준", "马丁", "路德"] state = segment(tokenize(ParseState( original="김민준 马丁·路德", lexicon=_LEX, policy=_HANGUL))) assert state.interpunct_offsets out = script_segment(state) assert out.tokens == state.tokens def test_the_segmenter_is_handed_the_gated_token_only() -> None: # It is asked where ONE token divides, so it gets that token's text # and nothing else -- not the original, not the name part joined # back together. A leading Latin title is the case that can tell # the difference: it is skipped by the gate (no script) and is not # a neighbour for the precondition either, so the consult happens # and the argument is observable. asked, seg = _capture() out = _run("Dr 山田太郎", policy=_JA, segmenter=seg) assert asked == ["山田太郎"] assert _texts(out) == ["Dr", "山田", "太郎"] def test_the_stand_in_answers_no_where_there_is_no_part_after_a_comma() -> None: from nameparser._pipeline._script_segment import ( _postnominal_behind_a_whole_name, ) state = segment(tokenize(ParseState( original="김민준 박", lexicon=Lexicon.default(), policy=Policy()))) assert _postnominal_behind_a_whole_name(state) is False ��������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/pipeline/test_segment.py���������������������������������0000664�0000000�0000000�00000011123�15273032717�0025256�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������from nameparser._lexicon import Lexicon from nameparser._pipeline._extract import extract_delimited from nameparser._pipeline._segment import segment from nameparser._pipeline._state import ParseState, Structure from nameparser._pipeline._tokenize import tokenize from nameparser._policy import Policy from nameparser._types import AmbiguityKind # synthetic vocabulary: behavior given a lexicon, never default() contents _LEX = Lexicon( suffix_acronyms=frozenset({"phd", "md", "do", "dds", "ma", "ed"}), suffix_acronyms_ambiguous=frozenset({"ma", "ed", "do"}), suffix_words=frozenset({"jr", "v"}), ) def _segmented(text: str) -> ParseState: state = ParseState(original=text, lexicon=_LEX, policy=Policy()) return segment(tokenize(extract_delimited(state))) def _texts(state: ParseState, seg: tuple[int, ...]) -> list[str]: return [state.tokens[i].text for i in seg] def test_no_comma() -> None: out = _segmented("John Smith") assert out.structure is Structure.NO_COMMA assert [_texts(out, s) for s in out.segments] == [["John", "Smith"]] def test_family_comma() -> None: out = _segmented("Smith, John") assert out.structure is Structure.FAMILY_COMMA assert [_texts(out, s) for s in out.segments] == [["Smith"], ["John"]] def test_family_comma_with_trailing_suffix_segment() -> None: out = _segmented("Smith, John, Jr.") assert out.structure is Structure.FAMILY_COMMA assert [_texts(out, s) for s in out.segments] == [["Smith"], ["John"], ["Jr."]] def test_excess_non_suffix_segment_flags_comma_structure() -> None: out = _segmented("Smith, John, Extra, Jr.") assert out.structure is Structure.FAMILY_COMMA kinds = [a.kind for a in out.ambiguities] assert AmbiguityKind.COMMA_STRUCTURE in kinds def test_the_comma_structure_report_names_no_field() -> None: # #629: a title word past the second comma reads as a title # (rules/C2, #603), so the part can land in two fields; the # report says the part was not recognized and leaves where each # word went to the fields. 2.3.0 said 'consumed as suffix'. out = _segmented("John Smith, Jr., Dr. Bart") assert [a.detail for a in out.ambiguities if a.kind is AmbiguityKind.COMMA_STRUCTURE] == [ "segment 'Dr. Bart' beyond the recognized comma structures; " "read best-effort"] def test_empty_input_yields_no_segments() -> None: assert _segmented("").segments == () def test_comma_only_input_is_no_comma_structure() -> None: out = _segmented(",,,") assert out.structure is Structure.NO_COMMA assert out.segments == () def test_single_trailing_comma_is_cosmetic_and_dropped() -> None: # v1 parity: exactly ONE trailing comma is cosmetic and stripped # (segment.py's "pop the trailing empty bucket" step), so # 'Smith, John,' segments identically to 'Smith, John' -- unlike a # genuinely empty INTERIOR bucket ('Doe,, Jr.'), which stays # structural and keeps its position. out = _segmented("Smith, John,") assert out.structure is Structure.FAMILY_COMMA assert [_texts(out, s) for s in out.segments] == [["Smith"], ["John"]] def test_leading_comma_yields_empty_first_segment() -> None: # A leading comma produces a non-trailing empty bucket, which is # structural (not cosmetic) and keeps its position as segment 0. out = _segmented(",John Smith") assert out.structure is Structure.FAMILY_COMMA assert [_texts(out, s) for s in out.segments] == [[], ["John", "Smith"]] def test_a_tail_segment_of_leaning_credentials_is_not_flagged() -> None: # The third reading site, and the one place this design QUIETS a # report: 'DO' leans credential in a mixed-case name, so the third # segment is a credential run and rules/C2's flag stops firing. state = _segmented("Steven Hardman, MD, DO, DDS") assert not [a for a in state.ambiguities if a.kind is AmbiguityKind.COMMA_STRUCTURE] # the one-case spelling keeps today's reading, flag and all state = _segmented("STEVEN HARDMAN, MD, DO, DDS") assert [a for a in state.ambiguities if a.kind is AmbiguityKind.COMMA_STRUCTURE] def test_segment_records_the_case_fact_only_where_c2_asks() -> None: # (a-lazy): segment asks the fact only for C2's flag on a part past # the second comma that neither suffix reading recognizes case-free; # the comma decision reads classify's record of it (#613) assert _segmented("John Smith MA").one_case is None assert _segmented("John Smith, MA").one_case is None assert _segmented("Smith, John").one_case is None assert _segmented("John Smith, MD, Ma").one_case is False ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/pipeline/test_state.py�����������������������������������0000664�0000000�0000000�00000035530�15273032717�0024744�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������import dataclasses import inspect from collections.abc import Callable import pytest from nameparser._lexicon import Lexicon from nameparser._pipeline._state import ( ParseState, PendingAmbiguity, Structure, WorkToken, _copy_refusals, _copyable_fields, copy_with, ) from nameparser._policy import Policy from nameparser._types import AmbiguityKind, Role, Span def _state(text: str) -> ParseState: return ParseState(original=text, lexicon=Lexicon.empty(), policy=Policy()) def test_state_defaults_are_empty() -> None: s = _state("John Smith") assert s.tokens == () and s.segments == () and s.pieces == () assert s.structure is Structure.NO_COMMA assert s.ambiguities == () and s.extracted == () and s.masked == () assert s.comma_offsets == () and s.interpunct_offsets == () assert s.dropped == () and s.piece_tags == () assert s.segmenter is None assert s.one_case is None def test_state_is_frozen_and_replace_works() -> None: s = _state("x") tok = WorkToken("x", Span(0, 1)) s2 = dataclasses.replace(s, tokens=(tok,)) assert s.tokens == () and s2.tokens == (tok,) assert s2.tokens[0].role is None and s2.tokens[0].tags == frozenset() def test_copy_with_builds_what_dataclasses_replace_builds() -> None: # copy_with stands in for dataclasses.replace on every pipeline # dataclass, so the two have to agree on each of them. tok = WorkToken("x", Span(0, 1)) pending = PendingAmbiguity(AmbiguityKind.COMMA_STRUCTURE, "detail") for obj, changes in ( (_state("x"), {"tokens": (tok,), "one_case": True}), (tok, {"role": Role.GIVEN, "tags": frozenset({"initial"})}), (pending, {"indices": (0,)}), ): assert copy_with(obj, **changes) == dataclasses.replace(obj, **changes) assert copy_with(obj) == obj and copy_with(obj) is not obj def test_copy_with_rejects_a_field_the_class_does_not_have() -> None: with pytest.raises(TypeError, match="no field named rol"): copy_with(WorkToken("x", Span(0, 1)), rol=Role.GIVEN) # type: ignore[call-arg] def test_copy_with_copies_only_the_pipeline_dataclasses() -> None: with pytest.raises(TypeError, match="not _Validated"): copy_with(_Validated(1), value=2) @dataclasses.dataclass(frozen=True) class _Validated: value: int def __post_init__(self) -> None: if self.value < 0: raise ValueError("negative") @dataclasses.dataclass(frozen=True) class _OwnInit: value: int def __init__(self, value: int) -> None: if value < 0: raise ValueError("negative") object.__setattr__(self, "value", value) @dataclasses.dataclass(frozen=True) class _Derived: value: int doubled: int = dataclasses.field(init=False, default=0) @dataclasses.dataclass class _Base: value: int class _Undecorated(_Base): def __init__(self, value: int) -> None: super().__init__(value) self.extra = "set by __init__" @dataclasses.dataclass(frozen=True) class _OwnNew: value: int def __new__(cls, value: int) -> "_OwnNew": if value < 0: raise ValueError("negative") return super().__new__(cls) @dataclasses.dataclass(frozen=True) class _FrozenBase: value: int @dataclasses.dataclass(frozen=True) class _Other: # `extra` is set by the __init__ without being one of its # parameters, so a class borrowing it takes exactly its own fields value: int extra: str = dataclasses.field( default_factory=lambda: "set by _Other.__init__", init=False) class _Borrower(_FrozenBase): __init__ = _Other.__init__ class _Spoof(_FrozenBase): # borrows _Other's __init__ and takes its name, so the name test # passes and only the missing decoration gives it away __init__ = _Other.__init__ __qualname__ = "_Other" @dataclasses.dataclass(frozen=True) class _WithInitVar: value: int token: dataclasses.InitVar[int] @dataclasses.dataclass class _Guarded: value: int def __setattr__(self, name: str, value: object) -> None: if name == "value" and isinstance(value, int) and value < 0: raise ValueError("negative") super().__setattr__(name, value) class _ValidatingMeta(type): def __call__(cls, *args: object, **kwargs: object) -> object: if any(isinstance(v, int) and v < 0 for v in (*args, *kwargs.values())): raise ValueError("negative") return super().__call__(*args, **kwargs) @dataclasses.dataclass(frozen=True) class _MetaBuilt(metaclass=_ValidatingMeta): value: int def _derived_with_doubled_set() -> _Derived: obj = _Derived(1) object.__setattr__(obj, "doubled", 2) return obj def _unguarded_copy(obj: object, **changes: object) -> object: new = object.__new__(type(obj)) for f in dataclasses.fields(obj): # type: ignore[arg-type] object.__setattr__(new, f.name, changes.get(f.name, getattr(obj, f.name))) return new def _outcome(copy: Callable[[], object]) -> object: """What a caller reads off the copy: every field, plus any attribute set outside the fields, or "raises" where the copy refused (a missing InitVar is a ValueError to `replace` through 3.12 and a TypeError from 3.13).""" try: result = copy() except (ValueError, TypeError): return "raises" names = [f.name for f in dataclasses.fields(result)] # type: ignore[arg-type] return {name: getattr(result, name) for name in dict.fromkeys([*names, *vars(result)])} #: What dataclasses.replace builds for each class the guard refuses, and #: what a field copy without the guard would build instead: the negative #: control, recorded so the refusal test cannot pass vacuously. _UNGUARDED_EFFECT = [ ("validating __post_init__", _Validated(1), {"value": -1}, "raises", {"value": -1}, ["defines __post_init__"]), ("validating __init__", _OwnInit(1), {"value": -1}, "raises", {"value": -1}, ["__init__ not generated for it"]), ("validating __new__", _OwnNew(1), {"value": -1}, "raises", {"value": -1}, ["defines __new__"]), ("init=False field", _derived_with_doubled_set(), {"value": 3}, {"value": 3, "doubled": 0}, {"value": 3, "doubled": 2}, ["has an init=False field"]), ("undecorated subclass", _Undecorated(1), {"value": 2}, {"value": 2, "extra": "set by __init__"}, {"value": 2}, ["not decorated as a dataclass itself", "__init__ not generated for it"]), ("borrowed __init__", _Borrower(1), {"value": 2}, {"value": 2, "extra": "set by _Other.__init__"}, {"value": 2}, ["not decorated as a dataclass itself", "__init__ not generated for it"]), ("borrowed __init__ under the lender's name", _Spoof(1), {"value": 2}, {"value": 2, "extra": "set by _Other.__init__"}, {"value": 2}, ["not decorated as a dataclass itself"]), ("InitVar", _WithInitVar(1, 5), {"value": 2}, "raises", {"value": 2}, ["__init__ does not take exactly its init fields"]), ("validating __setattr__", _Guarded(1), {"value": -1}, "raises", {"value": -1}, ["not frozen"]), ("validating metaclass __call__", _MetaBuilt(1), {"value": -1}, "raises", {"value": -1}, ["built by a metaclass"]), ] @pytest.mark.parametrize( "shape,obj,changes,by_replace,by_field_copy,refusals", _UNGUARDED_EFFECT, ids=[row[0] for row in _UNGUARDED_EFFECT]) def test_the_guard_refuses_a_class_a_field_copy_would_get_wrong( shape: str, obj: object, changes: dict[str, object], by_replace: object, by_field_copy: object, refusals: list[str]) -> None: assert _outcome(lambda: dataclasses.replace(obj, **changes)) == by_replace # type: ignore[type-var] assert _outcome(lambda: _unguarded_copy(obj, **changes)) == by_field_copy assert _copy_refusals(type(obj)) == refusals with pytest.raises(TypeError, match="cannot copy"): _copyable_fields(type(obj)) def test_every_guard_condition_alone_refuses_a_recorded_shape() -> None: # The table is the guard's negative control, so each condition must # be the ONLY reason for at least one row: otherwise dropping it # would fail nothing here. The count ties the table to the code. sole = {row[5][0] for row in _UNGUARDED_EFFECT if len(row[5]) == 1} conditions = inspect.getsource(_copy_refusals).count("reasons.append(") assert len(sole) == conditions, sorted(sole) def test_worktoken_carries_optional_role() -> None: t = WorkToken("Jack", Span(6, 10), role=Role.NICKNAME) assert t.role is Role.NICKNAME def test_stage_field_ownership() -> None: # The ParseState docstring's ownership map, pinned mechanically: run # the whole case corpus through the fold stage by stage and assert # each stage only changes the fields it owns. Converts the prose # contract into a test (a future stage clobbering another stage's # field fails here, not in a distant assertion). import dataclasses as _dc from nameparser import Lexicon as _Lexicon from nameparser._pipeline import STAGES from ..cases import CASES ownership = { "extract_delimited": {"extracted", "masked", "ambiguities"}, # tokenize also rewrites ambiguities: extract_delimited runs # before tokens exist, so its UNBALANCED_DELIMITER entries carry # a character offset that tokenize resolves to a token index "tokenize": {"tokens", "comma_offsets", "interpunct_offsets", "ambiguities"}, # segment records `one_case` too, lazily, only where C2's flag # on a part past the second comma asks for it (#289/#516, #613); # a name that never asks pays nothing. "segment": {"segments", "structure", "ambiguities", "one_case"}, # script_segment splits one unspaced CJK token into n+1 pieces # (n = 1 from the vocabulary, any n from a segmenter), so it # rewrites tokens and shifts every later index the earlier # stages recorded -- the segment runs included. It is # deliberately absent from token_ownership below, whose # token-count assert is the one contract this stage is exempt # from. structure and segmenter it only READS (the # FAMILY_COMMA opt-out, asked again for itself since #613, and # the hook it consults on a vocabulary decline). "script_segment": {"tokens", "segments", "ambiguities"}, # classify emits CONJUNCTION_OR_INITIAL; SUFFIX_OR_NICKNAME # moved to post_rules with the decision it reports (rules/S13, # #810) "classify": {"tokens", "ambiguities", "one_case"}, # group also emits PARTICLE_OR_GIVEN: the prefix chain takes # the particle branch of a fork whose given branch _assign # takes, so each stage reports the side it decides # and since #613 group writes `structure`: segment hands every # comma form over as the family comma, and group's head decides # it once classify has tagged the words (_comma.decide), # binding a postnominal part's roles and dropping its cores # and empties the segment of a part it bound, so no join can # reach it (#613's /simplify) # and since #614 the trailing run it reads once at its head, # handed to assign (`tail_reads`) "group": {"tokens", "pieces", "piece_tags", "dropped", "ambiguities", "structure", "segments", "tail_reads"}, # assign also records `order`: the effective order it read the # name under, which post_rules needs and must not re-derive "assign": {"tokens", "ambiguities", "order"}, # post_rules also emits PARTICLE_OR_GIVEN: P6's no-comma # attachment (#467) takes the family branch of a fork whose # other branches assign and group take, so each stage reports # the side it decides; the comma site is assign's since #613; # and SUFFIX_OR_NICKNAME, with S13's decision (#810) "post_rules": {"tokens", "ambiguities"}, } assert {s.__name__ for s in STAGES} == set(ownership) # Within the tokens themselves the contract is finer: texts and # spans are fixed at tokenize (the anti-#100 invariant -- tokens # are never re-created), classify touches only tags, and the # role-assigning stages touch only roles (group also tags, for the # ph-d "joined" marker and the caps shape's acronym marks # `_comma.decide` writes on a bound part (#564, #613), assign for P6's folded-middle mark after a # comma, and post_rules for the entry "joined" marker beside its # own folded-middle marks). token_ownership = { "classify": {"tags"}, "group": {"tags", "role"}, # assign also tags since #613: P6's attachment after a family # comma marks the particles it joins to the family # vocab:folded-middle, for the family view's prepend order "assign": {"role", "tags"}, # post_rules also tags: P6's attachment it still makes (the # no-comma site; behind an empty head it is assign's since #892) and # the middle_as_family fold mark folded tokens # vocab:folded-middle for the family view's prepend order, R2 # and R3 write their unjoined marks, and R1's entry pass marks # a post-nominal "joined" when the writer wrote no comma # before it (#436) "post_rules": {"role", "tags"}, } for case in CASES: if case.locale is not None: # locale rows dissolve into a Policy only through parser_for # (nameparser._parser); this test exercises the raw stage # pipeline directly, and the same inputs already run here # under the equivalent synthetic Policy row (_ES/_TK) -- # covering them again through a resolved locale policy would # be redundant, not additive. continue state = ParseState(original=case.text, lexicon=_Lexicon.default(), policy=case.policy or Policy()) for stage in STAGES: before = {f.name: getattr(state, f.name) for f in _dc.fields(state)} state = stage(state) changed = {name for name, value in before.items() if getattr(state, name) != value} assert changed <= ownership[stage.__name__], ( f"{case.id}: {stage.__name__} changed {changed - ownership[stage.__name__]}") if stage.__name__ not in token_ownership: continue allowed = token_ownership[stage.__name__] assert len(state.tokens) == len(before["tokens"]), ( f"{case.id}: {stage.__name__} changed the token count") for old, new in zip(before["tokens"], state.tokens): token_changed = { f.name for f in _dc.fields(old) if getattr(old, f.name) != getattr(new, f.name)} assert token_changed <= allowed, ( f"{case.id}: {stage.__name__} changed token fields " f"{token_changed - allowed} on {old.text!r}") ������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/pipeline/test_tokenize.py��������������������������������0000664�0000000�0000000�00000026634�15273032717�0025461�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������import dataclasses import pytest from nameparser._lexicon import Lexicon from nameparser._pipeline._extract import extract_delimited from nameparser._pipeline._state import ParseState from nameparser._pipeline._tokenize import tokenize from nameparser._policy import Policy from nameparser._types import Role def _tokenized(text: str, policy: Policy | None = None) -> ParseState: state = ParseState(original=text, lexicon=Lexicon.empty(), policy=policy or Policy()) return tokenize(extract_delimited(state)) def test_whitespace_split_with_spans() -> None: out = _tokenized("John Smith") assert [(t.text, tuple(t.span)) for t in out.tokens] == [ ("John", (0, 4)), ("Smith", (6, 11)), ] assert all(t.role is None for t in out.tokens) def test_provenance_text_equals_original_slice() -> None: out = _tokenized(" Dr. Juan de la Vega ") for t in out.tokens: assert t.text == out.original[t.span.start:t.span.end] def test_commas_are_separators_and_recorded() -> None: out = _tokenized("Smith, John") assert [t.text for t in out.tokens] == ["Smith", "John"] assert out.comma_offsets == (5,) def test_fullwidth_and_arabic_commas_segment() -> None: out = _tokenized("سميث، جون") assert out.comma_offsets == (4,) out2 = _tokenized("山田,太郎") assert out2.comma_offsets == (2,) def test_extracted_regions_are_skipped_and_tokenized_with_role() -> None: out = _tokenized('John "Jack Jr" Kennedy') main = [(t.text, t.role) for t in out.tokens if t.role is None] nick = [(t.text, t.role) for t in out.tokens if t.role is Role.NICKNAME] assert main == [("John", None), ("Kennedy", None)] assert nick == [("Jack", Role.NICKNAME), ("Jr", Role.NICKNAME)] # tokens are span-sorted overall starts = [t.span.start for t in out.tokens] assert starts == sorted(starts) def test_two_simultaneous_extracted_regions_interleave_correctly() -> None: # tokenize's masked/extracted loops each run over ALL their regions # before the final span-sort; every other test here exercises at # most one masked region at a time. With two active extraction # types (nickname parens AND a configured maiden bracket) on the # same string, main-stream and both extracted regions must still # interleave into the correct overall span order. pol = Policy(maiden_delimiters=frozenset({("[", "]")})) out = _tokenized("John (Jack) [Doe] Smith", pol) assert [(t.text, t.role) for t in out.tokens] == [ ("John", None), ("Jack", Role.NICKNAME), ("Doe", Role.MAIDEN), ("Smith", None), ] starts = [t.span.start for t in out.tokens] assert starts == sorted(starts) def test_comma_inside_extracted_region_is_word_text_not_an_offset() -> None: # #653: a region segments nothing, so its comma is punctuation the # clause keeps, like ';' -- the view then renders 'Jack, Jr' out = _tokenized('John "Jack, Jr" Kim') assert out.comma_offsets == () nick = [t.text for t in out.tokens if t.role is Role.NICKNAME] assert nick == ["Jack,", "Jr"] def test_emoji_and_bidi_are_ignorable_by_policy() -> None: out = _tokenized("John‏ \U0001f600Smith") assert [t.text for t in out.tokens] == ["John", "Smith"] keep = dataclasses.replace(Policy(), strip_emoji=False, strip_bidi=False) out2 = _tokenized("John \U0001f600Smith", keep) assert [t.text for t in out2.tokens] == ["John", "\U0001f600Smith"] _KEEP_ALL = Policy(strip_emoji=False, strip_bidi=False) @pytest.mark.parametrize("policy", [Policy(), _KEEP_ALL], ids=["default", "keep-emoji-and-bidi"]) @pytest.mark.parametrize("ch", ["​", ""], ids=["zero-width-space", "byte-order-mark"]) def test_zero_width_space_and_bom_separate_under_every_policy( ch: str, policy: Policy) -> None: # #851: no opt-out reaches them -- neither strip switch covers # them, since neither is an emoji or a direction control for text, words in [(f"John{ch}Smith", ["John", "Smith"]), (f"{ch}Dr. John", ["Dr.", "John"]), (f"John {ch}Smith{ch}", ["John", "Smith"])]: state = _tokenized(text, policy) assert [t.text for t in state.tokens] == words assert all(state.original[t.span.start:t.span.end] == t.text for t in state.tokens) assert state.comma_offsets == () @pytest.mark.parametrize("ch", ["‌", "‍", "⁠", "­"], ids=["zwnj", "zwj", "word-joiner", "soft-hyphen"]) def test_joiners_and_soft_hyphen_stay_inside_the_word(ch: str) -> None: # #851's exclusions: ZWNJ and ZWJ decide how the letters beside # them join, the word joiner means no break, and the soft hyphen # marks a break inside one word, so splitting at any of them would # cut a real word in two assert [t.text for t in _tokenized(f"ab{ch}cd ef").tokens] == [ f"ab{ch}cd", "ef"] def test_zero_width_space_defeats_the_flank_guard_like_a_space() -> None: # #851: a separator, not a stripped mark, so beside the interpunct # it reads as whitespace does, not as U+200F does spaced = _tokenized("威廉· 莎士比亚") zwsp = _tokenized("威廉·​莎士比亚") assert [t.text for t in zwsp.tokens] == [t.text for t in spaced.tokens] assert zwsp.interpunct_offsets == spaced.interpunct_offsets == () def test_empty_and_whitespace_yield_no_tokens() -> None: assert _tokenized("").tokens == () assert _tokenized(" ").tokens == () def test_nakaguro_separates_and_enters_no_token() -> None: # U+30FB divides transcribed foreign names (マイケル・ジャクソン); # native names never contain it -- amendment 2026-07-29 section 1b state = _tokenized("マイケル・ジャクソン") assert [t.text for t in state.tokens] == ["マイケル", "ジャクソン"] assert all(state.original[t.span.start:t.span.end] == t.text for t in state.tokens) # like whitespace, and unlike COMMA_CHARS: it never records an offset assert state.comma_offsets == () # leading/trailing dots are plain separators, not content assert [t.text for t in _tokenized("・ジャクソン").tokens] == ["ジャクソン"] def test_nakaguro_next_to_a_real_comma_still_only_the_comma_offsets() -> None: # the dot and a real comma side by side must not blur together -- # the comma still segments and records; the dot still separates # and stays silent state = _tokenized("マイケル・, X") assert [t.text for t in state.tokens] == ["マイケル", "X"] assert state.comma_offsets == (5,) def test_interpunct_splits_between_classified_characters() -> None: state = _tokenized("威廉·莎士比亚") assert [t.text for t in state.tokens] == ["威廉", "莎士比亚"] assert state.interpunct_offsets == (2,) def test_interpunct_stays_in_latin_names() -> None: # the Catalan punt volat is INTERIOR to names; Latin neighbors # never qualify state = _tokenized("Gal·la Marcet") assert [t.text for t in state.tokens] == ["Gal·la", "Marcet"] assert state.interpunct_offsets == () def test_interpunct_between_kana_splits() -> None: # sloppy-IME katakana transcription state = _tokenized("マイケル·ジャクソン") assert [t.text for t in state.tokens] == ["マイケル", "ジャクソン"] def test_interpunct_at_edges_stays() -> None: # no classified neighbor on one side: not a divider position state = _tokenized("·威廉") assert [t.text for t in state.tokens] == ["·威廉"] def test_stripped_bidi_mark_is_transparent_to_the_flank_guard() -> None: # an RTL document quoting a transcription: U+200F beside the dot, # visually identical to the clean form -- the stripped mark must # not occupy a flank position state = _tokenized("威廉‏·莎士比亚") assert [t.text for t in state.tokens] == ["威廉", "莎士比亚"] assert state.interpunct_offsets != () def test_stripped_emoji_is_transparent_to_the_flank_guard() -> None: state = _tokenized("威廉\U0001f600·莎士比亚") assert [t.text for t in state.tokens] == ["威廉", "莎士比亚"] def test_unstripped_bidi_mark_defeats_the_guard() -> None: # the contrast: with strip_bidi off the mark is real token text, # an unclassified flank -- the dot stays interior state = _tokenized("威廉‏·莎士比亚", policy=Policy(strip_bidi=False)) assert state.interpunct_offsets == () def test_interpunct_trailing_edge_stays() -> None: # the upper bound: a trailing dot has no right flank -- and # dropping the bound is an IndexError, not a wrong answer state = _tokenized("威廉·") assert [t.text for t in state.tokens] == ["威廉·"] def test_interpunct_minimal_flanks_split() -> None: state = _tokenized("威·廉") assert [t.text for t in state.tokens] == ["威", "廉"] assert state.interpunct_offsets == (1,) def test_interpunct_one_sided_flank_stays() -> None: # the AND in the guard: one classified neighbor is not enough, # and no offset records (a spurious record would fake the # transcription marker downstream) state = _tokenized("Anna·威") assert [t.text for t in state.tokens] == ["Anna·威"] assert state.interpunct_offsets == () def test_hangul_flanks_split_and_record() -> None: # Korean writes transcribed foreign names with the interpunct too # (마틴·루터·킹) -- hangul flanks are classified flanks state = _tokenized("김·민준") assert [t.text for t in state.tokens] == ["김", "민준"] assert state.interpunct_offsets == (1,) def test_interpunct_in_delimited_regions_splits_but_does_not_record() -> None: # a B7 inside a nickname must not mark the OUTER name as a # transcription -- same flag that keeps nickname commas out of # comma_offsets state = _tokenized("山田 太郎 (マイケル·ジャクソン)") assert state.interpunct_offsets == () assert "マイケル" in [t.text for t in state.tokens] def test_nakaguro_never_records() -> None: # the Japanese dot is roster/divider typography, not the # transcription marker: it separates (#272) and records nothing -- # 高橋・一郎 keeps its family-first roster reading downstream state = _tokenized("威廉・莎士比亚") assert [t.text for t in state.tokens] == ["威廉", "莎士比亚"] assert state.interpunct_offsets == () def test_halfwidth_nakaguro_separates_too() -> None: # U+FF65 between halfwidth katakana -- build the string from # escapes and VERIFY the codepoints (the U+F900 homoglyph lesson): # マイケル = halfwidth マイケル (MA I KE RU), # ・ = the halfwidth dot, ジャクソン = # halfwidth ジャクソン (SI + voiced-sound-mark, small-YA, KU, SO, N) text = ("\uff8f\uff72\uff79\uff99" # \uff8f\uff72\uff79\uff99 = halfwidth MA I KE RU "\uff65" # \uff65 = halfwidth middle dot "\uff7c\uff9e\uff6c\uff78\uff7f\uff9d") # halfwidth SI+voice, small-YA, KU, SO, N # belt and braces: the escapes above must spell the halfwidth dot, # not the fullwidth U+30FB it could be confused for by eye -- and # this is exactly the check the un-escaped version was missing: # it would still pass with U+30FB substituted for U+FF65 assert "・" in text state = _tokenized(text) assert len(state.tokens) == 2 assert "・" not in "".join(t.text for t in state.tokens) ����������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/pipeline/test_vocab.py�����������������������������������0000664�0000000�0000000�00000167123�15273032717�0024722�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������import dataclasses import unicodedata import pytest from nameparser._policy import CapsSuffixes from nameparser import Parser from nameparser._lexicon import ( Lexicon, _VOCAB_FIELDS, _normalize, _title_key, ) from nameparser._pipeline._vocab import ( ambiguous_class_candidate, ambiguous_class_member, ambiguous_lean, caps_shape_candidate, comma_follows_marker, delimiter_cores, glued_cores, glued_stem, effective_script, is_initial, is_initial_shaped, is_one_case, SPLIT_CHUNK, SPLIT_PAIR_OPENS, is_cased_paired_initials, is_paired_initials, is_single_letter_numeral, split_pair, is_suffix_lenient, is_suffix_strict, is_title_shaped, is_wholly_suffix, lexicon_cue, maiden_marker_run, name_word_count, period_joined_vocab, written_as_a_name, resolve_script_set, single_script, suffix_as_written, ) from nameparser._policy import (Policy, Script, _NO_INITIALS, _SCRIPT_RANGES) _LEX = Lexicon( suffix_acronyms=frozenset({"phd", "ma"}), suffix_words=frozenset({"jr", "v"}), suffix_acronyms_ambiguous=frozenset({"ma"}), ) def test_is_initial() -> None: assert is_initial("A.") assert is_initial("j.") assert is_initial("B") assert not is_initial("Jo") assert not is_initial("b") # bare lowercase letter is not an initial def test_is_initial_script_repertoire() -> None: # An initial is a single LETTER standing in for a name. Alphabets # have letters, so these are real initials ("А. С. Пушкин"). assert is_initial("А.") # Cyrillic assert is_initial("Α.") # Greek assert is_initial("م.") # Arabic assert is_initial("ה.") # Hebrew assert is_initial("र.") # Devanagari assert is_initial("Ա.") # Armenian # Han ideographs, hangul syllables and kana are morphemes or # syllables -- a single one never stands in for a name (#320). assert not is_initial("씨.") assert not is_initial("様.") assert not is_initial("김.") assert not is_initial("さ.") assert not is_initial("ラ.") assert not is_initial("ラ.") # halfwidth katakana is katakana (#594) # unchanged: a digit is ONE edge of \w's reach and '_' is another, # and the shape half still owns both -- only the repertoire narrowed assert is_initial("2.") assert is_initial("_.") # unchanged: the SHAPE half still requires a single character assert not is_initial("राम.") @pytest.mark.parametrize("word", ["씨.", "씨.", "씨。", "씨。", unicodedata.normalize("NFD", "씨.")]) def test_suffix_lookup_reads_every_full_stop(word: str) -> None: # #322: the four stops and NFD all reach the one stored entry. # is_initial stays False on every spelling: the initial veto is a # repertoire test on the raw text (#320) and the stop set does not # touch it. lex = Lexicon(suffix_words=frozenset({"씨"})) assert is_suffix_strict(word, lex) assert not is_initial(word) def test_is_initial_shaped_keeps_the_shape_half_reachable() -> None: """The two halves are separately askable (#320): assign's roman-numeral fork asks the SHAPE question about the piece before a trailing 'V', and answering it with the narrowed predicate dropped the family name out of 'John 씨. V' entirely.""" for text in ("A.", "j.", "B", "2."): assert is_initial_shaped(text) is is_initial(text) is True for text in ("Jo", "b", "raam."): assert is_initial_shaped(text) is is_initial(text) is False # the whole difference between them, in both directions for text in ("씨.", "様.", "김.", "さ.", "ラ."): assert is_initial_shaped(text) and not is_initial(text) def test_strict_suffix_veto_skips_cjk() -> None: """#320: the initial veto is what stopped a period-written CJK honorific being recognized. _normalize strips the trailing period, so '씨.' reaches the vocabulary as '씨' -- the veto was the only thing rejecting it.""" lex = Lexicon(suffix_words=frozenset({"씨", "様"})) assert is_suffix_strict("씨.", lex) assert is_suffix_strict("様.", lex) def _representative(script: Script) -> str: """The first codepoint of `script`'s _SCRIPT_RANGES spans that the SHAPE half admits as an initial. Shape-admitted, not simply the first codepoint: a range's first codepoint is often unassigned or punctuation (KATAKANA's 0x30A0 is a hyphen, HIRAGANA's 0x3040 is unassigned), which \\w does not match -- and testing is_initial on such a character answers False for the SHAPE's reason, making the repertoire assertion below vacuously green. Raising when no span holds one is the point rather than a corner: a script whose declared ranges contain no initial-shaped character at all has ranges that do not describe it.""" for lo, hi in _SCRIPT_RANGES[script]: for cp in range(lo, hi + 1): if is_initial_shaped(chr(cp) + "."): return chr(cp) raise AssertionError( f"no character in _SCRIPT_RANGES[{script}] is initial-SHAPED, " f"so this script's declaration cannot be tested against " f"is_initial -- check that the ranges are that script's") def test_every_script_is_classified_for_initials() -> None: """A member joining Script must be classified here on purpose; _policy._NO_INITIALS carries the reasoning. The classification lives in this table rather than the assertion being `set(Script) == set(_NO_INITIALS)`: that passes trivially today, since all four current members are CJK, and the only way to green it again after adding a script would be to declare that script initial-less. That prejudges the answer. The point is to force a decision, not a particular one. Three bindings, not two: the table covers Script, the table's False rows are _NO_INITIALS, and -- the one that makes this a behavioral test rather than a comparison of two constants -- each row is checked against is_initial on a character DERIVED from that script's own _SCRIPT_RANGES entry. Without the third, a script declared initial-less under ranges that are not its own passes all the way through while is_initial still says yes to its characters. """ has_initials = { Script.HAN: False, # ideographs are morphemes Script.HANGUL: False, # syllable blocks Script.HIRAGANA: False, # syllables Script.KATAKANA: False, # syllables } assert set(has_initials) == set(Script), ( "a Script member is unclassified for initials: decide whether " "a single character of it can stand in for a name, add the row, " "and put it in _policy._NO_INITIALS if it cannot") assert {s for s, yes in has_initials.items() if not yes} \ == set(_NO_INITIALS), ( "this table and _policy._NO_INITIALS disagree about which " "scripts have initials: a row here saying False is what puts a " "script in the constant, so add the missing member to " "_NO_INITIALS -- or, if the constant is the one that's right, " "flip the row") for script, yes in has_initials.items(): char = _representative(script) assert is_initial(char + ".") is yes, ( f"the declaration for {script} does not reach is_initial: " f"the row says has_initials={yes}, but is_initial(" f"{char + '.'!r}) -- on a character taken from " f"_SCRIPT_RANGES[{script}] -- says {not yes}. Either the " f"ranges are not this script's, or _NO_INITIALS and the " f"repertoire predicate have come apart") def test_strict_suffix_initial_veto() -> None: assert is_suffix_strict("PhD", _LEX) assert not is_suffix_strict("V.", _LEX) # initial veto assert not is_suffix_strict("V", _LEX) # initial veto assert is_suffix_strict("Jr", _LEX) def test_a_wide_stop_on_a_latin_word_reaches_the_vocabulary() -> None: # #322's unasked-for Latin reach, pinned rather than argued: the # fold strips all four FULL_STOPS off any word, while the initial # veto reads an ASCII-period pattern alone. So the WIDE spelling of # a Latin word gets past a veto its ASCII twin does not -- 'V。' # folds to 'v', is not initial-shaped, and reads as roman five, # where 'V.' stays a middle initial. 'Jr。' needs no veto argument # and simply reaches its entry. d = Lexicon.default() assert is_suffix_strict("Jr。", d) assert is_suffix_strict("V。", d) assert not is_suffix_strict("V.", d) def test_ambiguous_acronym_needs_periods_and_beats_the_veto() -> None: assert is_suffix_strict("M.A.", _LEX) assert not is_suffix_strict("Ma", _LEX) def test_lenient_accepts_suffix_words_unconditionally() -> None: assert is_suffix_lenient("V", _LEX) assert is_suffix_lenient("V.", _LEX) assert not is_suffix_lenient("Ma", _LEX) def test_strict_excludes_bare_ambiguous_even_when_in_acronyms() -> None: # mirrors the real data shape: ambiguous is a SUBSET of acronyms assert not is_suffix_strict("Ma", _LEX) assert is_suffix_strict("M.A.", _LEX) _SUFFIX_LEX = Lexicon( suffix_acronyms=frozenset({"phd", "md"}), suffix_words=frozenset({"jr", "v"}), ) def test_the_built_in_cores_are_cores_every_policy_has() -> None: # a declared delimiter adds to the semicolon, hyphen and slash, and # none removes them; only the semicolon and a declared core are # read glued to a word (decisions/C10, #889) assert delimiter_cores(frozenset()) == {";", "-", "/"} assert delimiter_cores(frozenset({" | "})) == {";", "-", "/", "|"} assert glued_cores(frozenset()) == {";"} assert glued_cores(frozenset({" - ", "/"})) == {";", "-", "/"} def test_a_core_glued_to_an_end_is_found_beside_one_inside() -> None: # every core is tried, so a word holding one core inside and another # at an end has a stem whatever the set's order; it read two ways by # hash seed when the first core found decided (#871's second review) cores = frozenset({";", "/", "-"}) assert glued_stem("MD/PhD;", cores) == "MD/PhD" assert glued_stem(";MD-PhD", cores) == "MD-PhD" assert glued_stem("MD;PhD", cores) is None assert glued_stem("MD;;", cores) is None assert glued_stem("Stand", frozenset({"and"})) is None def test_a_word_with_a_semicolon_glued_reads_as_the_word() -> None: # a glued word reads by the test it gets alone (decisions/C10) -- at # either end, beside another suffix word, and through the strict # test when the caller asks for it ('V' is an initial there) lex, pol = _SUFFIX_LEX, Policy() assert is_wholly_suffix(["MD;", "PhD"], lex, pol) assert is_wholly_suffix([";PhD"], lex, pol) assert is_wholly_suffix(["MD;PhD"], lex, pol) assert not is_wholly_suffix(["Smith;", "PhD"], lex, pol) assert not is_wholly_suffix(["MD;Smith"], lex, pol) assert is_wholly_suffix(["V;", "MD"], lex, pol) assert not is_wholly_suffix(["V;", "MD"], lex, pol, strict=True) assert is_wholly_suffix(["Jr.;", "MD"], lex, pol, strict=True) def test_is_wholly_suffix() -> None: lex, pol = _SUFFIX_LEX, Policy() assert is_wholly_suffix(["Jr."], lex, pol) assert is_wholly_suffix(["PhD", "Jr."], lex, pol) assert not is_wholly_suffix(["Smith"], lex, pol) assert not is_wholly_suffix(["PhD", "Smith"], lex, pol) # an adjacent Ph./D. pair is ONE unit. 'D.' is what makes the merge # load-bearing: it is not suffix vocabulary in ANY lexicon, so the # pair fails without the merge. ('Ph.' happens to fail alone here # too, but only because _SUFFIX_LEX is synthetic -- under # Lexicon.default(), 'ph' IS in suffix_acronyms.) assert is_wholly_suffix(["Ph.", "D."], lex, pol) # either period may be left off ('Ph D', 'PH D'), v1's fix_phd assert is_wholly_suffix(["Ph", "D"], lex, pol) assert not is_wholly_suffix(["Ph."], lex, pol) assert is_wholly_suffix(["Ph.", "D.", "Jr."], lex, pol) # and the pair is found wherever it sits, not only at the head of # the run: the merge walks the whole list. 'John Smith, Jr. Ph. D.' # is the reachable input -- restricted to position 0 the pair never # merges, 'D.' fails alone, and segment reads a FAMILY comma where # it should read a suffix comma. assert is_wholly_suffix(["Jr.", "Ph.", "D."], lex, pol) # the two routes that are neither the policy-selected predicate nor # the merge, each with the input that reaches ONLY it. Both die in # the v1 banks and the case table today, so a break here is # diagnosed a long way from its cause. assert is_wholly_suffix(["Lt.Jr."], lex, pol) # period-joined assert not is_wholly_suffix(["Lt.Smith"], lex, pol) # no suffix chunk delim = Policy(extra_suffix_delimiters=frozenset({"+"})) assert is_wholly_suffix(["PhD+MD"], lex, delim) # delimiter split assert not is_wholly_suffix(["PhD+MD"], lex, pol) assert is_wholly_suffix(["PhD/MD"], lex, pol) # joined (S3) def test_is_wholly_suffix_empty_run_is_false() -> None: """NOT vacuous truth, unlike Python's all() and unlike v1's are_suffixes. v1's suffix-comma detection fails on an empty parts[1] -- 'John Smith,, MD' is a family-comma parse -- and the 'wholly' idiom agrees (_script_matcher(whole=True) requires non-empty too).""" assert not is_wholly_suffix([], _SUFFIX_LEX, Policy()) def test_is_wholly_suffix_is_not_the_plural_of_is_post_nominal() -> None: """The #319 bug in one assertion. _script_segment._is_post_nominal asks is_suffix_strict per token; this asks the POLICY-selected predicate over a run. 'V.' is the input that tells them apart: a suffix run, but not a post-nominal.""" lex, pol = _SUFFIX_LEX, Policy() assert is_wholly_suffix(["V."], lex, pol) assert not is_suffix_strict("V.", lex) # and the knob moves it: under strict, 'V.' is name text assert not is_wholly_suffix( ["V."], lex, Policy(lenient_comma_suffixes=False)) def test_is_wholly_suffix_never_reads_the_by_shape_class() -> None: # #516 review round: an EARLIER version of this predicate admitted # a by-shape member unconditionally, bypassing both the lean and # the NAME-word count -- combined with C1's own legacy TOKEN-count # disjunct in segment (deleted in #613), that flipped 'Smith Jr., # A.B.' to a one-word given with a self-contradicting report. # Proved by mutation to be otherwise unreached, and dropped: the # by-shape class reaches the comma form only through classify's # shape tags and `_comma.decide`'s caps test, never through this # predicate, on or off. lex, pol = Lexicon.default(), Policy() assert not is_wholly_suffix(["A.B."], lex, pol) assert not is_wholly_suffix( ["A.B."], lex, Policy(unlisted_dotted_suffixes=False)) # whole-token vocabulary is untouched either way -- it never went # through the shape branch this predicate lost assert is_wholly_suffix(["A.B.C."], lex, pol) assert is_wholly_suffix(["A.B.C."], lex, Policy(unlisted_dotted_suffixes=False)) def test_is_wholly_suffix_reads_the_credential_lean() -> None: # The third reading site (#289): segment's C2 flag on tail # segments and script_segment's peel pass the lean here # (`_comma.decide` reads it through `segment_suffix_reading` # instead), and 'Steven Hardman, MD, DO, DDS' # loses its comma-structure flag because 'DO' leans credential # here. A caller with nothing to say passes nothing and gets the # answer every release before this one gave. lex, pol = Lexicon.default(), Policy() assert not is_wholly_suffix(["DO"], lex, pol) assert is_wholly_suffix(["DO"], lex, pol, one_case=False) assert not is_wholly_suffix(["Do"], lex, pol, one_case=False) assert not is_wholly_suffix(["DO"], lex, pol, one_case=True) assert not is_wholly_suffix(["DO", "Smith"], lex, pol, one_case=False) def test_ambiguous_class_member_is_the_comma_form_s_candidate() -> None: # The comma structure asks a different question from the lean: is # this token a member of the ambiguous class AT ALL, in any case? # -- because the count of NAME words before the comma is what # decides there, and it decides for the listed set too # ('JOHN SMITH, MA', 1.4.0 parity restored). Case-free: this # predicate takes no `one_case` at all, unlike the lean. lex = Lexicon.default() assert ambiguous_class_member("MA", lex) assert ambiguous_class_member("Ma", lex) assert ambiguous_class_member("ed", lex) assert not ambiguous_class_member("PhD", lex) assert not ambiguous_class_member("Smith", lex) # whole-token vocabulary wins over any shape reading assert not ambiguous_class_member("M.A.", lex) # a period ANYWHERE excludes membership here, deliberately # stricter than S3's own dotted-form test: the trailing-period # spelling still leans (ambiguous_lean('MA.', ...) reads as 'MA' # does) but reaches the comma slot through the TAG path, not this # predicate, which only the comma-form's own candidate check and # the credential-lean disjunct in is_wholly_suffix consult. assert not ambiguous_class_member("MA.", lex) assert not ambiguous_class_member("Ed.", lex) def test_period_joined_vocab_retires_the_single_character_chunk() -> None: # #516, NARROWLY: the chunk rule survives except where every chunk # the vocabulary matches is a single ASCII character, which is the # roman numeral reaching a word that is not about generations at # all. CHARACTER because '2' is a digit and in the set, ASCII # because '씨' is the one that must KEEP its claim. The roster # itself is asserted below, not just described, so a future # vocabulary change cannot silently drift this test's premise. lex = Lexicon.default() assert {c for c in lex.suffix_acronyms | lex.suffix_words if len(c) == 1 and c.isascii()} == {"2", "i", "v"} assert period_joined_vocab("R.A.I.", lex) == "shape" assert period_joined_vocab("X.Y.I.", lex) == "shape" assert period_joined_vocab("J.u.n.i.o.r.", lex) == "shape" assert period_joined_vocab("Msc.Ed.", lex) == "suffix" # 'ed', two chars assert period_joined_vocab("JD.CPA", lex) == "suffix" assert period_joined_vocab("J.씨", lex) == "suffix" # not ASCII assert period_joined_vocab("Lt.Gov.", lex) == "title" # title wins # the shape itself: two or more chunks nothing claims assert period_joined_vocab("X.Y.Z.", lex) == "shape" assert period_joined_vocab("B.Tech.", lex) == "shape" assert period_joined_vocab("Q.W.E.R.T.", lex) == "shape" assert period_joined_vocab("E.S.Q.", lex) == "shape" # one trailing period is not the shape, and never was assert period_joined_vocab("Xyz.", lex) is None # a bare digit chunk is never an acronym by shape either assert period_joined_vocab("1.4", lex) is None # nor is a CJK word glued into period-separated single characters: # a script with no period abbreviations at all has nothing for # interior periods to abbreviate (#323's reasoning, shared with # is_title_shaped) assert period_joined_vocab("田.中.", lex) is None assert period_joined_vocab("이.박.", lex) is None assert period_joined_vocab("たな.か.", lex) is None @pytest.mark.parametrize("text, expected", [ ("MD/PhD", True), ("MD-PhD", True), ("MD/PhD/MPH", True), ("md-phd", True), ("M.D./Ph.D.", True), # a chunk counts as written, periods and all ("M.A./Ph.D.", True), # an ambiguous acronym written with its periods ("MA/PhD", False), # a bare member of the ambiguous class ("PhD/Ed", False), ("Smith-Jones", False), # a hyphen also joins names ("MD-Jones", False), # every chunk, not any ("MD/", False), # an empty chunk is no chunk ("/MD", False), ("MD//PhD", False), ("MD+PhD", False), # '+' is no joiner ("I-V", False), # every chunk a single ASCII character ("I.-V.", False), # its periods are not the chunk's ("I-V.", False), ("MS-V", True), # one chunk is not ("Ae-Ra", False), # every chunk Title case: a hyphenated name ("Md-Rn", False), ("Ae-ra", False), # the standard romanization's lower-case tail ("ae-Ra", True), # a name opens in Title case ("Md-phd", False), ("PhD-Md", True), # one chunk is not Title case ("Ph.D.-Md", True), ("AE-RA", True), # capitals: the cue cannot read a name in them ("ae-ra", True), ("Ae/Ra", True), # a slash joins no name, so it takes no cue ("Ae-Ra/Md", True), # nor does a word holding a slash anywhere ("Md-v", False), # a one-letter tail in lower case is a name's ("Phd-Md", False), # a credential typed in Title case: S3 Accepted ]) def test_suffix_as_written_reads_a_joined_credential( text: str, expected: bool) -> None: # a word joined by a hyphen or a slash is a suffix only # where EVERY chunk counts as a suffix as written (#752) lex = Lexicon.default() assert suffix_as_written(_normalize(text), text, lex) is expected @pytest.mark.parametrize("text, expected", [ ("PU-PH", True), ("PU/PH", True), ("MS-OMPT", True), ("MD/PU-PH", True), # either joiner, more than two chunks ("A-BC", True), # one chunk of two letters is enough ("A-B", False), # single letters alone are how initials join ("Pu-Ph", False), # capitals, not Title case ("PU-PH2", False), # a digit is no letter chunk ("PU-", False), # an empty chunk is no chunk ("PU--PH", False), ("P.U.-PH", False), # a period is the dotted shape's, not this ("XYZ", True), # the unjoined word, as before ]) def test_caps_shape_candidate_reads_joined_capitals( text: str, expected: bool) -> None: # the caps shape's joined spelling (#752): an unlisted word of # capitals in letter chunks joined by a hyphen or a slash lex = Lexicon.default() pol = Policy() assert caps_shape_candidate(text, lex, pol, one_case=False) is expected # gated as the unjoined word is: a name in one case says nothing assert caps_shape_candidate(text, lex, pol, one_case=True) is False def test_caps_shape_candidate_refuses_a_listed_joined_word() -> None: # unlisted means the whole word in no wordlist, as for the unjoined # word lex = Lexicon.default().add(surnames={"dupont-moretti"}) assert not caps_shape_candidate("DUPONT-MORETTI", lex, Policy(), one_case=False) assert caps_shape_candidate("DUPONT-MORETTI", Lexicon.default(), Policy(), one_case=False) def test_ambiguous_class_candidate_admits_a_by_shape_member() -> None: # #516: the comma form's own candidate test reaches a by-shape # member too, once Policy admits it -- case-free either way, the # periods being the whole signal. lex, pol = Lexicon.default(), Policy() assert ambiguous_class_candidate("A.B.", lex, pol) assert ambiguous_class_candidate("MA", lex, pol) # listed, unaffected assert not ambiguous_class_candidate( "A.B.", lex, Policy(unlisted_dotted_suffixes=False)) # whole-token vocabulary wins over the shape reading here too assert not ambiguous_class_candidate("A.B.C.", lex, pol) assert not ambiguous_class_candidate("M.A.", lex, pol) assert not ambiguous_class_candidate("Smith", lex, pol) def test_a_listed_dotted_entry_is_not_read_by_shape() -> None: # Review round, 2026-09-18. A caller may list a DOTTED entry in # `suffix_acronyms_ambiguous`, and then the whole token matches # that set while `suffix_as_written`'s period-free acronym lookup # ('ab') misses it -- so the chunk view reached the shape verdict # and called a LISTED member by-shape, which silences the case # lean everywhere downstream (`_pieces.listed_lean` declines # wherever SHAPE_ACRONYM_TAG rides). Both halves of the class # test the same membership now. lex = Lexicon.default().add(suffix_acronyms={"a.b"}, suffix_acronyms_ambiguous={"a.b"}) assert not ambiguous_class_candidate("A.B.", lex, Policy()) # the shipped vocabulary carries no dotted ambiguous entry, so # nothing default moves assert ambiguous_class_candidate("A.B.", Lexicon.default(), Policy()) def test_a_callers_own_conjunction_marker_keeps_its_word_a_name() -> None: # The exclusion end to end through a CALLER's vocabulary rather # than the shipped one: `conjunctions_ambiguous` has no subset # check of its own (an orphan decides nothing, _lexicon's own # note), so a caller can list a word there alone -- and the caps # shape test must still decline it. 'John Smith ZZQ' reads suffix # 'ZZQ' with the switch on and the default vocabulary; one # wordlist entry is the whole difference (2026-09-18 review # round). on = Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE) plain = Parser(policy=on).parse("John Smith ZZQ") assert (plain.family, plain.suffix) == ("Smith", "ZZQ") listed = Parser( lexicon=Lexicon.default().add(conjunctions_ambiguous={"zzq"}), policy=on).parse("John Smith ZZQ") assert (listed.middle, listed.family, listed.suffix) == ( "Smith", "ZZQ", "") assert listed.ambiguities == () def test_the_caps_exclusion_covers_every_vocabulary_field() -> None: # The roster is `_lexicon._VOCAB_FIELDS`, not a list written into # the predicate, and this is what says so. The hand-written one it # replaced named ELEVEN of the thirteen, leaving out `surnames` # and `honorific_tails` -- so a caller who listed a word as a # SURNAME still had it read as a credential, which is this # switch's own worst failure arriving through the one wordlist # that says "this is a family name". # # Measured per field rather than asserted about the source: each # loop adds the same unlisted word to ONE field and checks the # predicate declines it. A field whose exclusion is dropped fails # here by name. on = Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE) base = Lexicon.default() assert caps_shape_candidate("ZZQX", base, on, False) # H9: two fields are left out on purpose -- a title that may # be a given name reads, outside a phrase, as the unlisted word it # is there, and a phrase is two words -- and #789's third: a given # name written short is read only by H2's shape for field in ("titles_ambiguous", "title_phrases", "given_name_abbreviations"): assert caps_shape_candidate( "ZZQX", base.add(**{field: {"zzqx"}}), on, False), field for field in _VOCAB_FIELDS: if field in ("titles_ambiguous", "title_phrases", "given_name_abbreviations"): continue extra: dict[str, set[str]] = {field: {"zzqx"}} if field in ("honorific_tails", "suffix_words_ambiguous"): # honorific_tails ⊆ suffix_words is a Lexicon invariant, # so this entry cannot be added alone -- which is also why # the field was already excluded transitively, and why it # joins the roster for completeness rather than for a # behavior change (suffix_words_ambiguous the same) extra["suffix_words"] = {"zzqx"} elif field == "given_name_titles": extra["titles"] = {"zzqx"} # ⊆ titles in practice elif field == "particles_ambiguous": extra["particles"] = {"zzqx"} # enforced subset elif field == "suffix_acronyms_ambiguous": extra["suffix_acronyms"] = {"zzqx"} # enforced subset lex = base.add(**extra) # type: ignore[arg-type] assert not caps_shape_candidate("ZZQX", lex, on, False), field def test_is_title_shaped_is_h2_s_shape_alone() -> None: # Shared with _pieces.is_leading_title's own inline copy # (#289/#516, quality-review finding): both must answer alike for # name_word_count's comma-form count not to disagree with the # leading peel about what a title is. lex = Lexicon.default() assert is_title_shaped("Xyz.", lex) # unlisted, H2-shaped # LISTED or not is a vocabulary question this predicate never # asks -- 'Dr.' wears the same shape 'Xyz.' does, and answers the # same way; the caller's own listed lookup is what tells them # apart (is_title_piece/lexicon.titles, at each call site) assert is_title_shaped("Dr.", lex) assert not is_title_shaped("Xyz", lex) # no trailing period assert not is_title_shaped("X.", lex) # one letter: an initial, # not an abbreviation assert not is_title_shaped("田中.", lex) # initialless script (#323) # rules/H2's precedence: a given name written short outranks the # shape (#789) -- the one vocabulary the predicate reads, and only # that: the same word unlisted wears the shape again assert not is_title_shaped("Chas.", lex) assert not is_title_shaped("MA.", lex) assert is_title_shaped("Chas.", lex.remove( given_name_abbreviations={"chas"})) def test_name_word_count_counts_names_not_tokens() -> None: # rules/C4's count for the ambiguous class: 'Smith Jr.' is two # tokens and ONE name word, which is what keeps its family where a # token count would hand it to `given`. lex, pol = Lexicon.default(), Policy() assert name_word_count(["John", "Smith"], lex, pol) == 2 assert name_word_count(["Smith", "Jr."], lex, pol) == 1 assert name_word_count(["Dr.", "Smith"], lex, pol) == 1 assert name_word_count(["Davis", "Royce"], lex, pol) == 2 assert name_word_count(["Royce"], lex, pol) == 1 # #289/#516, quality-review finding: the title half asks H2's # shape test too, not just the listed lookup -- an UNLISTED # period-marked opener now counts the way a LISTED one does # ('Xyz.' beside 'Dr.', both 1), where before this it counted as # a plain name word and could flip a comma structure a listed # title of the same shape would not. assert name_word_count(["Xyz.", "Smith"], lex, pol) == 1 # 2026-09-18 review round: the two arms behind 'Mr Smith, Ma' and # 'Smith Jr, Ma', neither of which had a unit row. A BARE title # word and a BARE suffix word each count as no name -- the period # is no part of either test -- so both parts hold ONE name word # and neither comma flips. The parses are pinned in cases.py; the # arms are pinned here, because a count of 1 for the wrong reason # reads identically at the parse. assert name_word_count(["Mr", "Smith"], lex, pol) == 1 assert name_word_count(["Smith", "Jr"], lex, pol) == 1 # Stored form: space-joined, per-word normalized -- what _normset # writes for this field. Built as bare frozensets rather than through a # Lexicon so these exercise the predicate and nothing else. _WORD_ONLY = frozenset({"née"}) _PHRASE = frozenset({"z domu"}) _BOTH = frozenset({"geb", "geb von"}) def test_maiden_marker_run_has_nothing_to_claim() -> None: assert maiden_marker_run([], _WORD_ONLY) == 0 assert maiden_marker_run(["née", "Jones"], frozenset()) == 0 assert maiden_marker_run(["Smith", "Jones"], _WORD_ONLY) == 0 def test_maiden_marker_run_claims_one_word() -> None: assert maiden_marker_run(["née", "Jones"], _WORD_ONLY) == 1 # the marker alone is still a run of one; the caller decides # whether a word has to follow it assert maiden_marker_run(["née"], _WORD_ONLY) == 1 def test_maiden_marker_run_claims_a_phrase() -> None: assert maiden_marker_run(["z", "domu", "Nowak"], _PHRASE) == 2 def test_maiden_marker_run_takes_the_longest_match_first() -> None: # A caller configuring both a word and a phrase starting with it # gets the phrase where it matches and the bare word everywhere # else. Shortest-first would claim 'geb' and leave 'von' a name # word. assert maiden_marker_run(["geb", "von", "Braun"], _BOTH) == 2 assert maiden_marker_run(["geb", "Braun"], _BOTH) == 1 def test_maiden_marker_run_declines_a_partial_phrase() -> None: # 'z' alone is not a marker -- the whole point of shipping the # phrase rather than its words. This is the reading the library's # own split-it-into-separate-entries advice produced. assert maiden_marker_run(["z", "Nowak"], _PHRASE) == 0 # and a phrase with nothing after its first word cannot match either assert maiden_marker_run(["z"], _PHRASE) == 0 def test_maiden_marker_run_folds_per_word() -> None: # _title_key's storage rule, rebuilt at match time: case folds, and # each word loses its own edge periods. Normalizing the JOINED # phrase would leave 'z.' with its period and never match. assert maiden_marker_run(["Z", "Domu", "Nowak"], _PHRASE) == 2 assert maiden_marker_run(["z", "domu.", "Nowak"], _PHRASE) == 2 assert maiden_marker_run(["z.", "domu", "Nowak"], _PHRASE) == 2 @pytest.mark.parametrize("words", [ ("z", "domu"), ("Z.", "Domu"), ("née",), ("née", "."), (".", "z"), ("z", "", "domu"), ("GEB", "VON"), (), ]) def test_the_marker_key_is_title_keys_own_fold(words: tuple[str, ...]) -> None: """maiden_marker_run builds its lookup key inline so each word is folded once rather than once per candidate length. That makes it a COPY of a fold whose definition lives in _lexicon, and this is the pin that keeps the copy honest: change _title_key and this fails rather than the entry quietly ceasing to match.""" assert " ".join(filter(None, (_normalize(w) for w in words))) \ == _title_key(words) def test_maiden_marker_run_does_not_claim_a_word_that_folds_away() -> None: # _title_key drops a word that normalizes to nothing, so ['née', # '.'] keys as 'née' -- a one-word entry would match a two-word # run and the drop would take the period with it. assert maiden_marker_run(["née", "."], _WORD_ONLY) == 1 # and a word that folds away cannot OPEN a run either -- the # question the head fast path has to answer the same way the loop # would assert maiden_marker_run([".", "née"], _WORD_ONLY) == 0 assert maiden_marker_run([".", "z", "domu"], _PHRASE) == 0 def test_single_script_requires_every_char_in_one_script() -> None: assert single_script("毛泽东") is Script.HAN assert single_script("諸葛") is Script.HAN # traditional assert single_script("김민준") is Script.HANGUL assert single_script("Smith") is None assert single_script("毛zedong") is None # mixed assert single_script("毛김") is None # mixed CJK # kana classifies as its own script (#272), not HAN -- was None # before kana had a table entry; single_script's job is telling # kana apart from Han, not lumping the two together assert single_script("イチロー") is Script.KATAKANA def test_single_script_range_edges() -> None: # one char at each declared bound, and a neighbour outside assert single_script("㐀") is Script.HAN # Ext A first assert single_script("䶿") is Script.HAN # Ext A last assert single_script("䷀") is None # hexagram, not Han # U+F900 is a COMPATIBILITY ideograph; it renders identically to # the URO 豈 (U+8C48) it decomposes to, so spell it as an escape # or this assertion silently tests the URO range instead assert single_script("\uf900") is Script.HAN assert single_script("\U00020bb7") is Script.HAN # Ext B (𠮷) assert single_script("가") is Script.HANGUL assert single_script("힣") is Script.HANGUL # last ASSIGNED syllable assert single_script("ힰ") is None # jungseong, not a syllable assert single_script("ㄱ") is None # bare jamo def test_single_script_empty_string_is_no_script() -> None: assert single_script("") is None def test_no_script_range_reaches_ascii() -> None: # single_script short-circuits on an ASCII token instead of # sweeping the ranges (the hot path: every Latin name). The # classifications above stay pinned either way; what the shortcut # rests on is this floor, so a future script entry that dips below # it fails here rather than silently going unclassified. assert all(lo >= 0x80 for ranges in _SCRIPT_RANGES.values() for lo, _ in ranges) def test_kana_singles_classify() -> None: assert single_script("みなみ") is Script.HIRAGANA assert single_script("エミ") is Script.KATAKANA assert single_script("ー") is Script.KATAKANA # prolonged mark, in-block def test_effective_script_kana_license() -> None: # pure single-script tokens pass through unchanged assert effective_script("山田") is Script.HAN assert effective_script("みなみ") is Script.HIRAGANA assert effective_script("マイケル") is Script.KATAKANA # the license: a MIXED token wholly in Han∪kana is Japanese and # resolves to the HIRAGANA carrier entry assert effective_script("高橋みなみ") is Script.HIRAGANA # kanji+hira assert effective_script("山田エミ") is Script.HIRAGANA # kanji+kata assert effective_script("さくらエミ") is Script.HIRAGANA # hira+kata # outside the license: anything beyond the JA repertoire assert effective_script("毛김") is None assert effective_script("山田x") is None # NOT None: U+30FB sits INSIDE the katakana block (verified by # codepoint), so this stays a PURE-katakana token, same as # "マイケル" above -- the license only ever fires on a MIXED # token, and this one isn't mixed. It has no order-default entry # (DEFAULT_SCRIPT_ORDERS carries no KATAKANA key), which is where # "declines the license" actually shows up. tokenize (#272 Task 2b) # now turns U+30FB into a token separator, so this exact string # arrives at effective_script as two tokens during a real parse -- # this unit test calls effective_script directly on the whole # string, bypassing tokenize, so it still sees the dot and must # keep passing unchanged. assert effective_script("マイケル・ジャクソン") is Script.KATAKANA assert effective_script("") is None def test_script_classification_ignores_edge_full_stops() -> None: # #323: a period glued to a script-written token is not part of # its script and must not remove the token from classification -- # the surname site, the order rule and the segmenter's neighbour # precondition all read this answer. Each of the four stops # trailing; the remainder still has to be classifiable on its own. assert effective_script("양.") is Script.HANGUL assert effective_script("양.") is Script.HANGUL assert effective_script("양。") is Script.HANGUL assert effective_script("양。") is Script.HANGUL # TRAILING ONLY, and each of the four leading pins it. This fold # feeds the two division sites, which index a word from its start, # so a leading stop is the one edge classification must not hide: # classified, '.김민준' becomes a surname site whose head match # (rstrip, so it never matches through a leading stop) declines, # and a configured segmenter is then handed the raw token and can # answer offset 1 -- '.' as the given name, the name as the family. # No script means no site, which is the pre-#323 reading and the # no-split rules/W1 already accepts. assert effective_script(".양") is None assert effective_script(".양") is None assert effective_script("。양") is None assert effective_script("。양") is None assert single_script("太郎.") is Script.HAN assert effective_script("高橋みなみ。") is Script.HIRAGANA # license survives # nothing left, or ASCII left: no script, as before assert effective_script(".") is None assert effective_script("。") is None assert effective_script("abc。") is None assert single_script("Smith.") is None @pytest.mark.parametrize("mark", ["゙", "゚", "゛", "゜"]) def test_a_voicing_mark_takes_the_script_of_the_kana_before_it( mark: str) -> None: # #596: all four marks sit in the hiragana BLOCK, but none belongs # to either syllabary -- a mark voices whatever kana it follows. # ア and ン have no precomposed voiced form, so NFC leaves the mark # standing; before #596 these tokens read katakana plus hiragana # and took the kana license. assert effective_script("ア" + mark + "イ") is Script.KATAKANA assert effective_script("ン" + mark) is Script.KATAKANA # hiragana stays hiragana, and the license still fires where a # real second script stands -- dropping the marks from the # hiragana range instead would have left all three unclassified assert effective_script("あ" + mark + "い") is Script.HIRAGANA assert effective_script("山田ア" + mark + "イ") is Script.HIRAGANA # a mark after anything but kana is voicing nothing and keeps the # table's answer, hiragana, as before #596: deleting it after hangul # handed 김゙민준 a script the surname site then divided on the raw # text, cutting the mark off its base. So hangul plus the mark is # mixed (None), and Han plus the mark is the kana license's shape assert effective_script("김" + mark + "민준") is None assert effective_script("山" + mark) is Script.HIRAGANA # a mark with nothing before it has no base either. Degenerate # input, recorded rather than promised (rules/W's Background) assert effective_script(mark + "アイ") is Script.HIRAGANA assert effective_script(mark) is Script.HIRAGANA def test_resolve_script_set_generalizes_the_license_across_pieces() -> None: # a single script passes through as-is, including a script with no # order-default entry (KATAKANA): the caller decides what to do # with that, this function only reports what was found assert resolve_script_set([Script.HAN]) is Script.HAN assert resolve_script_set([Script.HAN, Script.HAN]) is Script.HAN assert resolve_script_set([Script.KATAKANA]) is Script.KATAKANA # the license, generalized: Han + Hiragana across two SEPARATE, # individually single-script pieces (never mixed within one # token) is exactly the repertoire effective_script licenses # inside a single token, so it collapses to the carrier key assert resolve_script_set([Script.HAN, Script.HIRAGANA]) \ is Script.HIRAGANA assert resolve_script_set([Script.HAN, Script.KATAKANA]) \ is Script.HIRAGANA assert resolve_script_set([Script.HIRAGANA, Script.KATAKANA]) \ is Script.HIRAGANA # outside the license: Han+Hangul is two real scripts, neither # kana -- no single tradition, so this declines like the caller's # own None (Latin, mixed, empty) assert resolve_script_set([Script.HAN, Script.HANGUL]) is None assert resolve_script_set([]) is None def test_nfd_katakana_still_classifies_and_declines_the_license() -> None: # Built via normalize(), never pasted as decomposed literals -- # NFD "ガガ" is base katakana カ + COMBINING VOICED SOUND MARK # (U+3099) twice; U+3099 sits in the HIRAGANA block, not # katakana's, so classifying raw NFD text would see one char from # each block and either call it mixed (single_script: None) or # wrongly grant the kana license (effective_script: HIRAGANA) for # what is really one pure-katakana token. NFC-normalizing first # (the #272 amendment's NFC decision) recomposes it back to ガガ, # which reads as ordinary katakana either way -- the license # still correctly declines, because this token isn't mixed. nfd = unicodedata.normalize("NFD", "ガガ") assert nfd != "ガガ" # sanity: confirms the decomposition actually ran assert single_script(nfd) is Script.KATAKANA assert effective_script(nfd) is Script.KATAKANA def test_nfd_hangul_still_classifies() -> None: # NFD decomposes each precomposed syllable onto 2-3 jamo # (U+1100-U+11FF), entirely outside the HANGUL range -- raw NFD # input would silently miss the shipped family-first order rule # rather than merely misclassify. Built via normalize(), not # pasted decomposed literals, same reason as above. nfd = unicodedata.normalize("NFD", "김민준") assert nfd != "김민준" # sanity: confirms the decomposition actually ran assert single_script(nfd) is Script.HANGUL def test_script_ranges_are_pairwise_disjoint() -> None: # The table's own comment states this invariant and nothing # checked it: single_script returns the FIRST covering entry (dict # iteration order), so any overlap would make the answer depend on # declaration order instead of being well-defined. Compared over # every entry of every script rather than script by script, # because the adjacent blocks are exactly where a future edit # would go wrong -- HIRAGANA ends at U+309F and KATAKANA opens at # U+30A0 with no gap at all, and HAN's U+3005-U+3006 span sits just # below both. spans = [(lo, hi, script) for script, ranges in _SCRIPT_RANGES.items() for lo, hi in ranges] assert len(spans) > 1 # never passes vacuously for i, (lo, hi, script) in enumerate(spans): assert lo <= hi, f"{script} range ({lo:#x}, {hi:#x}) is inverted" for other_lo, other_hi, other in spans[i + 1:]: assert hi < other_lo or other_hi < lo, ( f"{script} range ({lo:#x}, {hi:#x}) overlaps {other} " f"range ({other_lo:#x}, {other_hi:#x})") def test_is_one_case() -> None: assert is_one_case(["jose", "e", "maria", "santos"]) assert is_one_case(["JOSE", "E", "MARIA", "SANTOS"]) assert not is_one_case(["Jose", "e", "Maria", "Santos"]) assert not is_one_case(["john", "e", "jones", "III"]) # a caseless script is "one case" harmlessly: the fork that reads # this ALSO requires a cased token, so a caseless letter never # enters it (rules/P8, decisions/P8) assert is_one_case(["محمد", "و", "علي"]) assert is_one_case(["山田", "太郎"]) # the empty and single-token edges assert is_one_case([]) assert is_one_case(["e"]) # the comparison is over the SPACE-JOINED text, R5's own gate, so a # token that is caseless does not break a Latin name's verdict assert is_one_case(["john", "e", "山田"]) assert not is_one_case(["John", "e", "山田"]) def test_ambiguous_lean_reads_the_written_case() -> None: # #289: in a name written in more than one case, an all-caps # member of the ambiguous set leans CREDENTIAL and a member in any # other cased form that is not wholly lower leans SURNAME. A # lowercase member carries no lean, and neither does anything at # all in a one-case name -- both fall through to today's count. assert ambiguous_lean("MA", one_case=False) == "credential" assert ambiguous_lean("Ma", one_case=False) == "name" assert ambiguous_lean("ma", one_case=False) is None assert ambiguous_lean("MA", one_case=True) is None assert ambiguous_lean("Ma", one_case=True) is None # a trailing period is not the signal and does not disturb one: # 'MA.' is still written in capitals ('.' has no case) assert ambiguous_lean("MA.", one_case=False) == "credential" assert ambiguous_lean("Ma.", one_case=False) == "name" # a caseless token can be written against nothing, so it leans # neither way even where the name around it is mixed assert ambiguous_lean("씨", one_case=False) is None assert ambiguous_lean("毛", one_case=False) is None def test_a_listed_member_written_in_period_closed_chunks_is_dotted( ) -> None: """#544: S3's period gate counts a listed member written in two or more letter chunks, each closed by a period ('M.Eng.'), as it counts one written with a period after each letter ('M.A.'). One chunk is the single trailing period any word can wear and stays outside the gate, and the chunks must still spell the member: the gate reads the spelling, the membership test the letters.""" lex = Lexicon( suffix_acronyms=frozenset({"meng", "lac", "ma", "phd"}), suffix_acronyms_ambiguous=frozenset({"meng", "lac", "ma"}), ) for text in ("M.Eng.", "m.eng.", "L.Ac.", "M.A."): assert is_suffix_strict(text, lex), text # one chunk, a missing closing period, the bare word, and chunks # that spell nothing listed for text in ("Meng.", "Ma.", "M.Eng", "MEng", "X.Eng."): assert not is_suffix_strict(text, lex), text def test_split_pair_answers_with_the_piece_tag_the_pair_merges_with() -> None: lex = Lexicon.default() # the Ph./D. pair, its periods optional and its case free assert all(split_pair(a, b, lex) == "suffix" for a in ("Ph.", "Ph", "PH", "ph.") for b in ("D.", "D", "d")) # any other pair whose fold is unambiguous suffix vocabulary, its # halves not both listed: a member of the ambiguous class (#752) for a, b in (("Pharm.", "D."), ("Pharm", "D"), ("MB", "ChB"), ("SAM", "E.")): assert split_pair(a, b, lex) == "split", (a, b) # an opening single letter ('A. MS.' folds to 'ams'), both halves # listed, an ambiguous or unlisted fold, a chunk of six letters, and # the Ph./D. halves in the other order for a, b in (("M.", "Sc."), ("A.", "MS."), ("M", "D"), ("MD", "PhD"), ("Smith", "Jones"), ("Pharma", "D"), ("D.", "Ph."), ("Ph.", "D..")): assert split_pair(a, b, lex) is None, (a, b) def test_two_dotted_letters_merge_only_outside_a_cased_script() -> None: # S12: two dotted single letters are paired initials where the # script has capitals (#872's predicate), so they merge only in a # caseless one, and only where the fold is listed, as no default # entry is lex = Lexicon.default().add(suffix_acronyms={"jr", "พบ"}) assert split_pair("J.", "R.", lex) is None assert split_pair("พ.", "บ.", lex) == "split" assert split_pair("พ.", "บ.", Lexicon.default()) is None assert split_pair("พ", "บ", lex) is None def test_the_c_level_pre_tests_are_necessary_conditions_of_split_pair( ) -> None: # classify asks SPLIT_CHUNK of both words, the opening letter's # length and the fold's membership C-level before the call, and is_wholly_suffix SPLIT_PAIR_OPENS # before asking for the Ph./D. answer, so a split_pair widened past # either would never be reached for the new spelling (#752) lex = Lexicon.default() words = ["Ph.", "Ph", "PH.", "ph", "pH", "D.", "D", "d", "Pharm.", unicodedata.normalize("NFD", "Éd"), "É.", unicodedata.normalize( "NFD", "É."), "Ж.", "Ш.", "Pharm", "M.", "MD", "Phd", "Ph..", "", "Smith", "MB", "ChB", "SAM", "E.", "Pharma", "Ed", "J.", "R."] for first in words: for second in words: kind = split_pair(first, second, lex) if kind is None: continue fold = unicodedata.normalize( "NFC", (first + second).replace(".", "").lower()) assert SPLIT_CHUNK.fullmatch(first) and SPLIT_CHUNK.fullmatch( second), (first, second) assert (len(first.rstrip(".")) >= 2 or first.endswith(".") and second.endswith(".") and (len(second) == 2 or not second.isascii())) if kind == "suffix": # merged whatever the vocabulary, as v1's fix_phd did assert SPLIT_PAIR_OPENS.fullmatch(first), (first, second) else: assert (fold in lex.suffix_acronyms or fold in lex.suffix_words), (first, second) def test_cased_paired_initials_is_the_bare_shape_in_a_cased_script() -> None: for text in ("M.J.", "m.j.", "M.J", "Ж.Ш."): assert is_cased_paired_initials(text), text # Thai writes an abbreviation as dotted letters and has no case # (rules/S3); the bare predicate, which C5 asks, still takes it assert is_paired_initials("พ.บ.") assert not is_cased_paired_initials("พ.บ.") assert not is_cased_paired_initials("X.Y.Z.") def test_is_single_letter_numeral() -> None: """#544: the one-letter shape C4's run and the anchor both leave out -- one letter, periods allowed, that is a roman numeral.""" for text in ("V", "v", "I", "I.", "x", "X."): assert is_single_letter_numeral(text), text for text in ("II", "IV", "Jr", "B", "", ".", "Ma"): assert not is_single_letter_numeral(text), text @pytest.mark.parametrize("text, expected", [ ("Smith", True), ("DiCaprio", True), ("IJzerman", True), ("al-Rashid", True), ("d'Estaing", True), ("McDonald", True), ("MacLeod", True), ("Mack", True), ("O'Neil", True), ("Džokić", True), ("E\u0301lodie", True), ("Andre\u0301", True), ("Ha\u0300", True), ("Jones'", True), ("Smith2", True), ("Smith)", True), ("Weiß", True), ("KAĸ", False), ("Jones\u02bc", True), ("Wafāʾ", True), ("Smith李", True), ("SMITHʾ", False), ("ANDRE\u0301", False), ("SMITH", False), ("smith", False), ("C", False), ("ap", False), ("d'ESTAING", False), ("al-ASSAD", False), ("McDONALD", False), ("MacDONALD", False), ("FitzGERALD", False), ("DeVITO", False), ("LaFLEUR", False), ("St-PIERRE", False), ("SMITH-McDONALD", False), ("WEIß", False), ("O'NEIL", False), # no letter carrying case evidence at all ("李", False), ("2", False), ("ß", False), ("", False), ]) def test_written_as_a_name(text: str, expected: bool) -> None: # #564 (Derek): the name's case contrast is a word holding a # capital and ending in a lowercase letter -- a surname written in # capitals ends in one whatever is glued in front of it. The last # LETTER: composed first, so a decomposed accent at the end counts # as its letter; trailing non-letters and a lowercase letter with # no single capital (ß, ĸ) are passed over. assert written_as_a_name(text) is expected @pytest.mark.parametrize(("word", "expected"), [ (",", True), (",Jones", True), ("\uff0c田中", True), ("\u060c", True), # past what the tokenizer drops or divides at (#653's second review) ("\u200f,", True), ("\U0001f600,", True), ("\u30fb\uff0c田中", True), ("Jones", False), ("Jones,", False), ("\u30fb田中", False), ("-", False), ("", False), ]) def test_comma_follows_marker_reads_past_what_tokenize_drops( word: str, expected: bool) -> None: assert comma_follows_marker(word) is expected def test_the_ph_d_pair_merges_when_both_its_words_are_listed() -> None: # #752's second review: classify's two-suffix-words refusal stood in # front of the Ph./D. answer, which merges whatever the vocabulary # lists parser = Parser(lexicon=Lexicon.default().add(suffix_words={"ph", "d"})) assert parser.parse("John Ph. D. Smith").suffix == "Ph. D." assert parser.parse("John Smith Ph. D. Jones").suffix == "Ph. D. Jones" def test_the_lean_reads_the_cue_of_what_the_member_is() -> None: # an acronym's capitals are the credential, a generation # word's lower case is the suffix and its capitals say nothing, and # an agnomen's writing never leans; Title case leans to the name # for both that lean at all lex = Lexicon.default() assert [lexicon_cue(w, lex) for w in ("ma", "fils", "neto")] == [ "acronym", "word", "none"] assert [ambiguous_lean(t, False, "word") for t in ("fils", "FILS", "Fils")] == [ "credential", None, "name"] assert [ambiguous_lean(t, False, "none") for t in ("neto", "NETO", "Neto")] == [ None, None, None] def test_a_comma_part_reads_a_gated_word_by_its_cue() -> None: # is_wholly_suffix has no tags to read, so it asks lexicon_cue: a # lower-case fils in a mixed-case name counts as a suffix, a # Title-case one and an agnomen in any case do not lex, pol = Lexicon.default(), Policy() assert is_wholly_suffix(["fils"], lex, pol, one_case=False) assert not is_wholly_suffix(["Fils"], lex, pol, one_case=False) assert not is_wholly_suffix(["Neto"], lex, pol, one_case=False) # H9: "the word is a title only inside a title phrase" -- each # partner, and each non-partner, alone @pytest.mark.parametrize("words, head, expected", [ (["dean", "smith"], True, [False, False]), (["mr", "dean", "smith"], True, [True, False, False]), (["associate", "dean", "smith"], True, [True, True, False]), (["hon", "justice", "smith"], True, [True, True, False]), (["chief", "justice", "smith"], True, [True, True, False]), (["major", "general", "smith"], True, [True, True, False]), (["dean", "of", "students"], True, [True, False, False]), (["dean", "and", "mary"], True, [False, False, False]), # a title after the word it does not attach to is no partner (["dean", "mr"], False, [False, True]), (["dean", "general", "smith"], True, [False, True, False]), # decision 10: a modifier with a head title after it, or in a # connective's object; at the head only (["wing", "lau"], True, [False, False]), (["vice", "lau"], False, [True, False]), # a modifier that may be a given name needs its head after a comma # too (#752 phase 2j, `wing`), and takes any title as one (["wing", "lau"], False, [False, False]), (["wing", "commander", "lau"], False, [True, True, False]), (["wing", "mr"], False, [True, True]), (["wing", "commander", "lau"], True, [True, True, False]), (["mr", "wing", "lau"], True, [True, False, False]), (["vice", "dean", "smith"], True, [True, True, False]), (["president", "of", "the", "united", "states", "barack"], True, [True, False, False, True, True, False]), (["wing", "insp.", "lau"], True, [True, False, False]), ]) def test_title_readings(words: list[str], head: bool, expected: list[bool]) -> None: from nameparser._pipeline._vocab import title_readings folded = [_normalize(w) for w in words] assert title_readings(words, folded, Lexicon.default(), head=head) == expected def test_a_word_a_caller_lists_as_a_title_is_one_everywhere() -> None: # the 1.4-era recipe for a title the default leaves out: listing it # makes it a title wherever it stands, phrase or none lex = Lexicon.default().add(titles={"dean"}) name = Parser(lexicon=lex).parse("Dean Smith") assert (name.title, name.family, name.ambiguities) == ("Dean", "Smith", ()) def test_a_contested_title_in_a_phrase_addresses_as_its_vocabulary_says() -> None: # a title that may be a given name and addresses by the given name # (no shipped word is both yet) takes the given-name title tag once a # partner makes it a title, so H1 reads the one name word after it as # the given name, as 'Sir John' reads lex = Lexicon.default().add(titles_ambiguous={"zed"}, given_name_titles={"zed"}) phrase = Parser(lexicon=lex).parse("Hon Zed John") assert (phrase.title, phrase.given, phrase.family) == ("Hon Zed", "John", "") # the tag classify documents for a given-name title, which a word # read into the title by its phrase carries as one listed there does assert "vocab:given-title" in phrase.tokens[1].tags alone = Parser(lexicon=lex).parse("Zed John") assert (alone.title, alone.given, alone.family) == ("", "Zed", "John") @pytest.mark.parametrize("phrases, reports", [ (frozenset(), False), # a title before the word: classify reads the pair, so the word may # head a title of itself and opening a name it reports (frozenset({"chief grace"}), True), # 'your' is no title, so classify never reads the pair, and a phrase # it never reads makes no word report (frozenset({"your grace"}), False), # classify reads two-word phrases only (frozenset({"chief of grace"}), False), ]) def test_only_a_phrase_classify_can_read_makes_a_contested_word_report( phrases: frozenset[str], reports: bool) -> None: lex = dataclasses.replace(Lexicon.default(), title_phrases=phrases) kinds = [a.kind.value for a in Parser(lexicon=lex).parse( "Grace Smith").ambiguities] assert kinds == (["title-or-name"] if reports else []) ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/rules_budget.json����������������������������������������0000664�0000000�0000000�00000024066�15273032717�0023767�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������{ "A1": {"words": 195, "then_words": 171, "because_sentences": 1, "except": 0, "precedence": 0, "accepted": 1, "accepted_words": 32}, "A2": {"words": 60, "then_words": 31, "because_sentences": 1, "except": 0, "precedence": 0, "accepted": 0, "accepted_words": 0}, "C1": {"words": 454, "then_words": 202, "because_sentences": 1, "except": 0, "precedence": 5, "accepted": 1, "accepted_words": 116}, "C2": {"words": 525, "then_words": 432, "because_sentences": 1, "except": 2, "precedence": 0, "accepted": 1, "accepted_words": 81}, "C3": {"words": 260, "then_words": 228, "because_sentences": 1, "except": 0, "precedence": 0, "accepted": 1, "accepted_words": 173}, "C4": {"words": 529, "then_words": 393, "because_sentences": 1, "except": 0, "precedence": 2, "accepted": 0, "accepted_words": 0}, "C5": {"words": 319, "then_words": 79, "because_sentences": 1, "except": 2, "precedence": 1, "accepted": 1, "accepted_words": 54}, "C6": {"words": 207, "then_words": 162, "because_sentences": 1, "except": 0, "precedence": 0, "accepted": 0, "accepted_words": 0}, "C7": {"words": 119, "then_words": 68, "because_sentences": 1, "except": 0, "precedence": 0, "accepted": 0, "accepted_words": 0}, "C8": {"words": 176, "then_words": 110, "because_sentences": 1, "except": 0, "precedence": 1, "accepted": 1, "accepted_words": 36}, "C9": {"words": 298, "then_words": 219, "because_sentences": 1, "except": 0, "precedence": 1, "accepted": 1, "accepted_words": 48}, "C10": {"words": 434, "then_words": 272, "because_sentences": 1, "except": 1, "precedence": 1, "accepted": 7, "accepted_words": 480}, "C11": {"words": 391, "then_words": 226, "because_sentences": 1, "except": 2, "precedence": 0, "accepted": 0, "accepted_words": 0}, "C12": {"words": 316, "then_words": 107, "because_sentences": 2, "except": 2, "precedence": 1, "accepted": 2, "accepted_words": 59}, "C13": {"words": 259, "then_words": 74, "because_sentences": 1, "except": 1, "precedence": 1, "accepted": 0, "accepted_words": 0}, "C14": {"words": 231, "then_words": 111, "because_sentences": 3, "except": 1, "precedence": 0, "accepted": 0, "accepted_words": 0}, "C15": {"words": 242, "then_words": 31, "because_sentences": 3, "except": 1, "precedence": 1, "accepted": 2, "accepted_words": 101}, "D1": {"words": 73, "then_words": 26, "because_sentences": 1, "except": 0, "precedence": 0, "accepted": 0, "accepted_words": 0}, "D2": {"words": 65, "then_words": 37, "because_sentences": 2, "except": 0, "precedence": 0, "accepted": 0, "accepted_words": 0}, "H1": {"words": 463, "then_words": 306, "because_sentences": 3, "except": 1, "precedence": 0, "accepted": 4, "accepted_words": 370}, "H2": {"words": 508, "then_words": 172, "because_sentences": 1, "except": 1, "precedence": 3, "accepted": 3, "accepted_words": 203}, "H3": {"words": 142, "then_words": 72, "because_sentences": 2, "except": 0, "precedence": 0, "accepted": 3, "accepted_words": 113}, "H4": {"words": 410, "then_words": 183, "because_sentences": 3, "except": 1, "precedence": 1, "accepted": 1, "accepted_words": 127}, "H5": {"words": 757, "then_words": 289, "because_sentences": 2, "except": 0, "precedence": 5, "accepted": 1, "accepted_words": 117}, "H6": {"words": 316, "then_words": 79, "because_sentences": 3, "except": 4, "precedence": 0, "accepted": 2, "accepted_words": 100}, "H7": {"words": 339, "then_words": 270, "because_sentences": 2, "except": 0, "precedence": 0, "accepted": 1, "accepted_words": 54}, "H8": {"words": 431, "then_words": 68, "because_sentences": 3, "except": 3, "precedence": 3, "accepted": 3, "accepted_words": 131}, "H9": {"words": 713, "then_words": 272, "because_sentences": 3, "except": 0, "precedence": 5, "accepted": 9, "accepted_words": 369}, "M1": {"words": 231, "then_words": 120, "because_sentences": 2, "except": 1, "precedence": 2, "accepted": 0, "accepted_words": 0}, "M2": {"words": 714, "then_words": 435, "because_sentences": 1, "except": 4, "precedence": 1, "accepted": 6, "accepted_words": 209}, "M3": {"words": 234, "then_words": 84, "because_sentences": 2, "except": 2, "precedence": 2, "accepted": 1, "accepted_words": 80}, "M4": {"words": 327, "then_words": 164, "because_sentences": 2, "except": 1, "precedence": 1, "accepted": 0, "accepted_words": 0}, "N1": {"words": 95, "then_words": 62, "because_sentences": 1, "except": 0, "precedence": 0, "accepted": 0, "accepted_words": 0}, "N2": {"words": 131, "then_words": 79, "because_sentences": 1, "except": 0, "precedence": 0, "accepted": 0, "accepted_words": 0}, "N3": {"words": 60, "then_words": 29, "because_sentences": 1, "except": 0, "precedence": 0, "accepted": 3, "accepted_words": 184}, "O1": {"words": 152, "then_words": 92, "because_sentences": 1, "except": 0, "precedence": 0, "accepted": 1, "accepted_words": 122}, "O2": {"words": 115, "then_words": 60, "because_sentences": 1, "except": 0, "precedence": 0, "accepted": 2, "accepted_words": 66}, "O3": {"words": 47, "then_words": 18, "because_sentences": 1, "except": 0, "precedence": 0, "accepted": 0, "accepted_words": 0}, "O4": {"words": 91, "then_words": 57, "because_sentences": 1, "except": 0, "precedence": 0, "accepted": 0, "accepted_words": 0}, "O5": {"words": 478, "then_words": 160, "because_sentences": 2, "except": 6, "precedence": 1, "accepted": 0, "accepted_words": 0}, "O6": {"words": 573, "then_words": 138, "because_sentences": 2, "except": 5, "precedence": 4, "accepted": 7, "accepted_words": 244}, "P1": {"words": 415, "then_words": 287, "because_sentences": 2, "except": 0, "precedence": 1, "accepted": 4, "accepted_words": 319}, "P2": {"words": 315, "then_words": 279, "because_sentences": 1, "except": 0, "precedence": 0, "accepted": 3, "accepted_words": 167}, "P3": {"words": 639, "then_words": 598, "because_sentences": 1, "except": 0, "precedence": 0, "accepted": 0, "accepted_words": 0}, "P4": {"words": 190, "then_words": 82, "because_sentences": 1, "except": 1, "precedence": 0, "accepted": 0, "accepted_words": 0}, "P5": {"words": 717, "then_words": 399, "because_sentences": 1, "except": 2, "precedence": 3, "accepted": 3, "accepted_words": 192}, "P6": {"words": 1086, "then_words": 703, "because_sentences": 2, "except": 2, "precedence": 5, "accepted": 6, "accepted_words": 459}, "P7": {"words": 138, "then_words": 64, "because_sentences": 3, "except": 0, "precedence": 0, "accepted": 1, "accepted_words": 28}, "P8": {"words": 515, "then_words": 450, "because_sentences": 2, "except": 0, "precedence": 0, "accepted": 2, "accepted_words": 309}, "P9": {"words": 405, "then_words": 265, "because_sentences": 1, "except": 2, "precedence": 0, "accepted": 0, "accepted_words": 0}, "R1": {"words": 176, "then_words": 145, "because_sentences": 1, "except": 0, "precedence": 0, "accepted": 1, "accepted_words": 146}, "R2": {"words": 143, "then_words": 19, "because_sentences": 1, "except": 1, "precedence": 0, "accepted": 2, "accepted_words": 85}, "R3": {"words": 483, "then_words": 407, "because_sentences": 2, "except": 1, "precedence": 0, "accepted": 3, "accepted_words": 409}, "R4": {"words": 991, "then_words": 729, "because_sentences": 1, "except": 4, "precedence": 0, "accepted": 4, "accepted_words": 600}, "R5": {"words": 250, "then_words": 154, "because_sentences": 2, "except": 0, "precedence": 0, "accepted": 0, "accepted_words": 0}, "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}, "S4": {"words": 306, "then_words": 164, "because_sentences": 2, "except": 0, "precedence": 3, "accepted": 0, "accepted_words": 0}, "S5": {"words": 731, "then_words": 400, "because_sentences": 1, "except": 2, "precedence": 1, "accepted": 0, "accepted_words": 0}, "S6": {"words": 585, "then_words": 330, "because_sentences": 2, "except": 1, "precedence": 2, "accepted": 0, "accepted_words": 0}, "S7": {"words": 602, "then_words": 388, "because_sentences": 1, "except": 2, "precedence": 2, "accepted": 1, "accepted_words": 105}, "S8": {"words": 571, "then_words": 406, "because_sentences": 1, "except": 1, "precedence": 2, "accepted": 1, "accepted_words": 63}, "S9": {"words": 463, "then_words": 262, "because_sentences": 2, "except": 0, "precedence": 3, "accepted": 0, "accepted_words": 0}, "S10": {"words": 665, "then_words": 246, "because_sentences": 3, "except": 4, "precedence": 3, "accepted": 3, "accepted_words": 163}, "S11": {"words": 511, "then_words": 298, "because_sentences": 3, "except": 1, "precedence": 1, "accepted": 2, "accepted_words": 107}, "S12": {"words": 674, "then_words": 372, "because_sentences": 2, "except": 3, "precedence": 4, "accepted": 2, "accepted_words": 93}, "S13": {"words": 431, "then_words": 180, "because_sentences": 3, "except": 2, "precedence": 1, "accepted": 4, "accepted_words": 138}, "T1": {"words": 93, "then_words": 18, "because_sentences": 1, "except": 1, "precedence": 0, "accepted": 3, "accepted_words": 83}, "T2": {"words": 43, "then_words": 14, "because_sentences": 1, "except": 0, "precedence": 0, "accepted": 0, "accepted_words": 0}, "T3": {"words": 56, "then_words": 23, "because_sentences": 1, "except": 0, "precedence": 0, "accepted": 0, "accepted_words": 0}, "T4": {"words": 334, "then_words": 236, "because_sentences": 2, "except": 0, "precedence": 1, "accepted": 1, "accepted_words": 56}, "T5": {"words": 149, "then_words": 95, "because_sentences": 1, "except": 0, "precedence": 0, "accepted": 2, "accepted_words": 89}, "W1": {"words": 181, "then_words": 105, "because_sentences": 1, "except": 1, "precedence": 0, "accepted": 0, "accepted_words": 0}, "W2": {"words": 99, "then_words": 56, "because_sentences": 1, "except": 0, "precedence": 0, "accepted": 2, "accepted_words": 192}, "W3": {"words": 651, "then_words": 348, "because_sentences": 1, "except": 1, "precedence": 3, "accepted": 0, "accepted_words": 0}, "W4": {"words": 168, "then_words": 49, "because_sentences": 1, "except": 1, "precedence": 0, "accepted": 0, "accepted_words": 0} } ��������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/rules_budget.py������������������������������������������0000664�0000000�0000000�00000014266�15273032717�0023447�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""The recorded size of every rule in docs/design/rules/. A rule grows one sound clause at a time, every review passing, until it has to be split by hand (S2 by #682, C1 by #728). This module measures each rule and tests/v2/test_rules_budget.py holds the tree to the measurement recorded in rules_budget.json beside it, so a rule that grows fails the suite and its growth shows in the record's diff -- the reviewer's view of it (mechanisms.md#RECORDED-ROSTERS). The record is not a cap: an author whose rule has to grow re-records with uv run python -m tests.v2.rules_budget --record and says so in the PR, where axis 12 (docs/design/AGENTS.md) asks what the new words read. A rule that shrinks fails too, so the record follows it down and the words cannot come back unseen. Seven measures, read from the rule's parsed fields (rules_skeleton.py) rather than counted over prose, the word counts off rules_skeleton.skeleton_parts, the list a citation quotes: ``words`` (every quotable field but the Accepted bullets: When, Tolerated, Then and its bullets, Because, Except and Precedence, so a word added anywhere in them moves the record), ``then_words`` (Then and its bullets), ``because_sentences``, ``except``, ``precedence`` and ``accepted`` (bullet counts) and ``accepted_words`` (the Accepted bullets' words). The footer, the fences and the labels are not counted. """ from __future__ import annotations import argparse import json from collections.abc import Callable from pathlib import Path from tests.v2 import rules_skeleton from tests.v2.design_docs import read_rules_text from tests.v2.rules_doc import Rule, parse_rules_doc from tests.v2.rules_skeleton import count_sentences RECORD = Path(__file__).with_name("rules_budget.json") RECORD_COMMAND = "uv run python -m tests.v2.rules_budget --record" MEASURES = ("words", "then_words", "because_sentences", "except", "precedence", "accepted", "accepted_words") def _row(rule: Rule) -> dict[str, int]: sk = rule.skeleton assert sk is not None parts = rules_skeleton.skeleton_parts(sk) def words(keep: Callable[[str], bool]) -> int: return len(" ".join(t for f, t in parts if keep(f)).split()) return { "words": words(lambda f: f != "Accepted"), "then_words": words(lambda f: f in ("Then", "Then bullet")), "because_sentences": count_sentences(sk.because), "except": len(sk.excepts), "precedence": len(sk.precedence), "accepted": len(sk.accepted), "accepted_words": sum(len(b.text.split()) for b in sk.accepted), } def measure(text: str) -> dict[str, dict[str, int]]: """Each rule's measures, keyed by rule ID, in ID order. The text is parsed first, so a rules text the doc tests reject raises here rather than being measured.""" out = {r.rule_id: _row(r) for r in parse_rules_doc(text)} return dict(sorted(out.items(), key=lambda kv: (kv[0][0], int(kv[0][1:])))) def _unique(pairs: list[tuple[str, object]]) -> dict[str, object]: seen: dict[str, object] = {} for key, value in pairs: if key in seen: raise ValueError( f"{RECORD.name} holds {key!r} twice -- a merge kept both " f"lines; json would keep the last and never compare the " f"first. Re-record with `{RECORD_COMMAND}`") seen[key] = value return seen def read_record(path: Path | None = None) -> dict[str, dict[str, int]]: """The record, refusing a duplicated key or a value that is not a measure's count, either of which would make the comparison lie.""" data = json.loads((path or RECORD).read_text(encoding="utf-8"), object_pairs_hook=_unique) for rid, row in data.items(): if not (isinstance(row, dict) and all( isinstance(v, int) and not isinstance(v, bool) for v in row.values())): raise ValueError( f"{RECORD.name}: {rid}'s row is not counts by measure: " f"{row!r}. Re-record with `{RECORD_COMMAND}`") return data # type: ignore[no-any-return] def changes(record: dict[str, dict[str, int]], now: dict[str, dict[str, int]]) -> list[str]: """Every difference between the record and the tree, one line each: a rule grown or shrunk past its record, a rule the record lacks, a record for no rule.""" out = [] for rid in sorted(set(record) | set(now)): if rid not in record: out.append(f"{rid}: no record -- a new rule is recorded too") continue if rid not in now: out.append(f"{rid}: recorded, but no rule has that ID") continue old_row, new_row = record[rid], now[rid] for key in MEASURES: old, new = old_row.get(key), new_row[key] if old != new: way = "grew" if old is None or new > old else "shrank" out.append(f"{rid}: {key} {way}, {old} -> {new}") for key in sorted(set(old_row) - set(MEASURES)): out.append(f"{rid}: recorded {key!r}, which is no longer " f"measured") return out def _dump(record: dict[str, dict[str, int]]) -> str: """One rule per line, so a rule's move is a one-line diff.""" rows = [f" {json.dumps(rid)}: {json.dumps(row)}" for rid, row in record.items()] return "{\n" + ",\n".join(rows) + "\n}\n" def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) ap.add_argument("--record", action="store_true", help=f"rewrite {RECORD.name} from the tree") args = ap.parse_args(argv) now = measure(read_rules_text()) if not now: print("no rules found; refusing to compare or record nothing") return 1 if RECORD.exists(): found = changes(read_record(), now) print("\n".join(found) or "every rule matches its record") else: found = [f"no {RECORD.name} yet"] print(found[0]) if args.record: RECORD.write_text(_dump(now), encoding="utf-8") print(f"recorded {len(now)} rules in {RECORD.name}") return 0 return 1 if found else 0 if __name__ == "__main__": raise SystemExit(main()) ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/rules_doc.py���������������������������������������������0000664�0000000�0000000�00000037354�15273032717�0022745�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Parser and annotation registry for docs/design/rules/ example lines. Every rule is written in the skeleton format (#761): a heading `# ID — name`, bold-labelled fields, fenced examples and a footer, up to the next markdown heading. ``rules_skeleton.py`` defines and parses the format; this module splits the rules text into rule blocks and section Backgrounds, and owns what the skeleton parser builds on: the example-line grammar, the ``Example`` and ``Rule`` records, and the registries an example's annotation resolves through. Outside a rule the text holds only section headings (``## Name (X)``), each letter once and each followed by one ``Background:`` paragraph, and blank lines; any other line there is refused, since read as nothing it would neither run nor be quotable -- a subheading inside a rule file, a misspelled rule heading, a fence in a section file, and those glued to a Background paragraph too. The example-line grammar (described for humans in the rules README; this module is its executable definition): "INPUT" [annotation] → field=VALUE [· boundary] [deviates: #N (today: field=VALUE)] or, for a D-section subject, ``[subject]`` in place of the quoted input. A line that looks like an example but does not parse is a hard error naming the rule: a silent skip would un-execute a claim. The block format the rules were written in before #761 is retired: a line opening ``ID.`` at the start of a line is refused wherever it stands, outside any rule here and inside one by parse_skeleton, rather than skipped or read as prose, since its examples would never run. """ from __future__ import annotations import ast import re from dataclasses import dataclass, field as dc_field from collections.abc import Iterator from typing import TYPE_CHECKING if TYPE_CHECKING: from tests.v2.rules_skeleton import Skeleton #: The opening line of a block-format rule (``X1. Statement.``), the #: format #761 retired. _RETIRED_RULE_RE = re.compile(r"^[A-Z]\d+\.\s") _VALUE = r"\"[^\"]*\"|\([^)]*\)|\[(?:[^\[\]]|\[[^\]]*\])*\]" _EXAMPLE_RE = re.compile( r'^\s*(?:"(?P<text>[^"]*)"|\[(?P<subject>[a-z][a-z0-9_+-]*)\])' r"(?:\s+(?P<annot>[a-z][a-z0-9_+-]*|\[[a-z][a-z0-9_+-]*\]))?" r"\s+→\s+" rf"(?P<field>[a-z_]+)=(?P<value>{_VALUE})" r"(?P<boundary>\s+·\s+boundary)?" r"(?:\s+deviates:\s+#(?P<issue>\d+)\s+\(today:\s+" rf"(?P<tfield>[a-z_]+)=(?P<today>{_VALUE})\))?" r"\s*$") #: Any line meant to be a signal line, in any spelling (``signal:``, #: ``Signal :``, bold or not): above a rule's footer, or outside any #: rule, it would be read as prose or as nothing, leaving the rule with #: no signal. NEAR_SIGNAL_RE = re.compile(r"^\s*[*_]*signals?[*_]*\s*[:\uff1a]", re.IGNORECASE) ASSERTABLE_FIELDS = frozenset({ "title", "given", "middle", "family", "suffix", "nickname", "maiden", "family_base", "family_particles", "surnames", "given_names", "initials", "capitalized", "capitalized_forced", "ambiguities", "pieces", "warns", "raises"}) @dataclass(frozen=True) class Example: text: str annotation: str | None field: str value: object boundary: bool deviates_issue: int | None today_value: object subject: str | None = None @dataclass class Rule: rule_id: str examples: list[Example] = dc_field(default_factory=list) no_boundary: str | None = None implemented: tuple[str, ...] = () #: Issues that would ship a rule nothing implements yet. Mutually #: exclusive with ``implemented:`` -- a rule points at code or at #: the issues that will produce it, never at neither. tracked: tuple[str, ...] = () #: The ``**Signal:**`` line's text: what in the written name the #: rule reads. None only where the line is missing, which the doc #: tests refuse for the real rules. signal: str | None = None #: For a hub (``**Signal:** [<own signals>; ]hub of C3, C4``), the #: rules whose signals it combines; () for a rule that is no hub. hub: tuple[str, ...] = () #: The rule's parsed fields (rules_skeleton.py), None only while #: parse_skeleton is building it. ``examples`` above is the list #: that runs; a skeleton's #: ``Bullet.examples`` is a placement view of the same objects (which #: bullet's fence an example stands in), not a second set, and an #: example sits under one bullet at most. skeleton: Skeleton | None = None def has_boundary_or_waiver(self) -> bool: return self.no_boundary is not None or any( e.boundary for e in self.examples) from nameparser._policy import ( # noqa: E402 FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST, CapsSuffixes, Policy) #: Named policies example annotations may reference. Grown as #: extraction demands; each addition is a diff to this dict only. POLICIES: dict[str, Policy] = { "family-first": Policy(name_order=FAMILY_FIRST), "family-first-given-last": Policy(name_order=FAMILY_FIRST_GIVEN_LAST), "middle_as_family": Policy(middle_as_family=True), "family-first+middle_as_family": Policy( name_order=FAMILY_FIRST, middle_as_family=True), #: rules/O6's switch (#754), named after its Policy FIELD for the #: reason the by-shape switches below give, and with each order and #: with middle_as_family, which the rule states its reading under. "two_family_names": Policy(two_family_names=True), "family-first+two_family_names": Policy( name_order=FAMILY_FIRST, two_family_names=True), "family-first-given-last+two_family_names": Policy( name_order=FAMILY_FIRST_GIVEN_LAST, two_family_names=True), "two_family_names+middle_as_family": Policy( two_family_names=True, middle_as_family=True), "maiden-parens": Policy(maiden_delimiters=frozenset({("(", ")")})), "keep-emoji": Policy(strip_emoji=False), "strict-comma-suffixes": Policy(lenient_comma_suffixes=False), #: The two switches over the by-shape credential class (#516). #: Named after the Policy FIELD rather than after the behavior, #: which is deliberate: the rules' statements stay #: implementation-free, so the annotation slot is the one place #: the doc can put the caller-facing name of the switch its prose #: describes. The suffix says which way the field is set, since #: one is on by default and the caps field (#564) has three values, #: so its suffix names the value set. "unlisted_dotted_suffixes-off": Policy(unlisted_dotted_suffixes=False), "unlisted_caps_suffixes-everywhere": Policy( unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), "unlisted_caps_suffixes-off": Policy( unlisted_caps_suffixes=CapsSuffixes.OFF), #: The delimiter switch (#206), named after its Policy FIELD for #: the reason above and carrying the DELIMITER in the suffix, #: because this field's value is a set rather than a flag: a rule #: reached only under a configured separator has to say which one, #: and " | " is the spelling docs/customize.rst teaches and the #: one the group tests configure (" - " until #889 made a hyphen #: standing alone built in). "extra_suffix_delimiters-pipe": Policy( extra_suffix_delimiters=frozenset({" | "})), } #: D-section subjects: zero-arg constructions whose diagnostics the #: warns=/raises= assertion forms exercise. def _segmenterless_ja() -> object: from nameparser import locales, parser_for return parser_for(locales.get("ja")) def _bad_name_order() -> object: return Policy(name_order=("given",)) # type: ignore[arg-type] def _bad_order_none() -> object: return Policy(name_order=None) # type: ignore[arg-type] SUBJECTS: dict[str, object] = { "segmenterless-ja": _segmenterless_ja, "bad-name-order": _bad_name_order, "bad-order-none": _bad_order_none, } #: Extras gates: locale requiring an optional dependency; the examples #: runner skips these when the import is absent (CI's ja-extra job #: exercises them). GATES: dict[str, tuple[str, str]] = {"[ja+segmenter]": ("ja", "namedivider")} _LOCALE_ANNOT_RE = re.compile(r"\[[a-z_]{2,5}\]") def resolve_annotation(annot: str) -> tuple[str, object]: if annot in POLICIES: return "policy", POLICIES[annot] if annot in GATES: return "gated_locale", GATES[annot][0] if _LOCALE_ANNOT_RE.fullmatch(annot): return "locale", annot[1:-1] raise KeyError(annot) def _literal(tok: str) -> object: return ast.literal_eval(tok) def parse_example_line(line: str, rule_id: str, lineno: int) -> Example | None: """The example a line holds, None for a line that is no example, and a ValueError for one that looks like an example but does not parse (a silent skip would un-execute a claim). Called for each line in a rule's fences (rules_skeleton.py).""" em = _EXAMPLE_RE.match(line) if em: fieldname = em.group("field") if fieldname not in ASSERTABLE_FIELDS: raise ValueError( f"{rule_id}: line {lineno}: field " f"{fieldname!r} not assertable") if em.group("tfield") and em.group("tfield") != fieldname: raise ValueError( f"{rule_id}: line {lineno}: deviates " f"today-field {em.group('tfield')!r} differs from " f"the asserted field {fieldname!r}") return Example( text=em.group("text") or "", annotation=em.group("annot"), field=fieldname, value=_literal(em.group("value")), boundary=bool(em.group("boundary")), deviates_issue=(int(em.group("issue")) if em.group("issue") else None), today_value=(_literal(em.group("today")) if em.group("today") else None), subject=em.group("subject")) if line.lstrip().startswith(('"', "[", "“", "”", "„", "‘", "’", "«")): raise ValueError( f"{rule_id}: line {lineno} looks like an example " f"but does not parse: {line.strip()!r}") return None def _walk(text: str) -> Iterator[ tuple[int, str, tuple[str, list[tuple[int, str]], int] | None]]: """Each line outside a rule, as (line number, line, None), and each rule's heading as (line number, line, its block), the block's lines then skipped (rules_skeleton.skeleton_block_at).""" from tests.v2 import rules_skeleton lines = text.splitlines() i = 0 while i < len(lines): block = rules_skeleton.skeleton_block_at(lines, i) yield i + 1, lines[i], block i = block[2] if block is not None else i + 1 _SECTION_RE = re.compile(r"## .*\(([A-Z])\)\s*$") #: Lines at the start of a line that are structure, never a Background #: paragraph's wrapped prose: a fence, a heading, a field label, a list #: item (only an item numbered 1 can interrupt a paragraph in #: CommonMark, so "2026. Then" stays prose). Column 0 only: an indented #: fence, a setext underline or a blockquote is not read. A wrapped #: line may open with a quote (the H and N Backgrounds do). _STRUCTURE_RE = re.compile( r"^(?:```|~~~|#+ |\*\*[^*]+:\*\*|(?:[-*+]|1[.)])(?:\s|$))") def _retired(rid: str, lineno: int, line: str) -> ValueError: return ValueError( f"{rid}: line {lineno} opens a rule in the block format, which " f"#761 retired: {line.strip()!r}. Write the rule as a skeleton " f"(docs/design/rules/README.md): a `# ID — name` heading, then " f"**When:**, **Then:** and **Because:**, examples in fences and " f"a `---` footer") def parse_rules_doc(text: str) -> list[Rule]: from tests.v2 import rules_skeleton rules: list[Rule] = [] section: str | None = None # a section heading awaits its Background background = False # inside a Background paragraph letters: set[str] = set() def unresolved(lineno: int) -> ValueError: return ValueError( f"line {lineno}: section {section}'s heading has no " f"`Background:` paragraph after it") for lineno, line, block in _walk(text): if block is None: after = rules[-1].rule_id if rules else "the start" sm = _SECTION_RE.match(line) if _RETIRED_RULE_RE.match(line): raise _retired(line.split(".")[0], lineno, line) if NEAR_SIGNAL_RE.match(line): raise ValueError( f"line {lineno}, after {after}: a signal line outside " f"any rule; it belongs in its rule's footer, directly " f"above the pointer line: {line.strip()!r}") if not line.strip(): background = False elif sm: if section is not None: raise unresolved(lineno) if sm.group(1) in letters: raise ValueError( f"line {lineno}: section {sm.group(1)} is headed " f"twice; its first Background would stop being " f"quotable") letters.add(sm.group(1)) section, background = sm.group(1), False elif background and rules_skeleton.holds_assertion(line) or ( section is not None and line.startswith("Background:") and rules_skeleton.holds_assertion(line)): raise ValueError( f"line {lineno}, after {after}: an example in a " f"Background, where it would never run; examples " f"stand in a rule's fences: {line.strip()!r}") elif section is not None and line.startswith("Background:"): section, background = None, True elif not background or _STRUCTURE_RE.match(line): raise ValueError( f"line {lineno}, after {after}: text outside any rule. " f"A rule opens with a `# ID — name` heading, and a " f"section file holds its `## Name (X)` heading and one " f"`Background:` paragraph; read as nothing, this line " f"would neither run nor be quotable: {line.strip()!r}") continue if section is not None: raise unresolved(lineno) background = False rid, numbered, _ = block if any(r.rule_id == rid for r in rules): raise ValueError(f"duplicate rule ID {rid} at line {lineno}") rules.append(rules_skeleton.parse_skeleton(rid, numbered)) if section is not None: raise unresolved(len(text.splitlines())) return rules def prose_blocks(text: str) -> dict[str, list[str]]: """The quotable lines of each rule, keyed by its ID, and of each section's Background paragraph, keyed by the section letter: for a rule, the text of its fields (rules_skeleton.skeleton_prose); for a section, the Background paragraph's lines, unjoined. What a citation may quote (test_doc_citations.py), read by one function.""" from tests.v2 import rules_skeleton out: dict[str, list[str]] = {} section: str | None = None background: list[str] | None = None for _lineno, line, block in _walk(text): if block is not None: section = background = None parsed = rules_skeleton.parse_skeleton(block[0], block[1]) assert parsed.skeleton is not None out[block[0]] = rules_skeleton.skeleton_prose(parsed.skeleton) continue hm = _SECTION_RE.match(line) if background is not None: if line.strip() and not hm: background.append(line) continue background = None if hm: section = hm.group(1) elif section and line.startswith("Background:"): background = out[section] = [line] section = None return out ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/rules_skeleton.py����������������������������������������0000664�0000000�0000000�00000052761�15273032717�0024023�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Parser for the skeleton rule format (#761), the format every rule in docs/design/rules/ is written in. A rule file opens with its heading and carries up to seven bold-labelled fields, When, Then and Because required, in this order, then a footer under a `---` line:: # S10 — the trailing roman numeral **When:** the situation in the written name, one quoted example. **Tolerated:** reason (optional; a tolerated rule, W3) **Then:** what results, one sentence; an optional bullet list of detail. **Because:** why, three sentences at most. ``` (a bare fence holds examples, one per line) "John Smith III" → suffix="III" ``` **Except:** / **Precedence:** / **Accepted:** (each a `- ` bullet list) - **A bold lead**, then prose, continued on lines indented two spaces. ``` (a fence indented under a bullet belongs to it) "John V" → family="V" · boundary ``` --- **Signal:** a tag or `hub of C3, C4` **history:** decisions/S10 · **implemented:** nameparser/x.py Rules the parser holds, all hard errors naming the rule and line: the rule runs from its heading to the next markdown heading (a `#` run and a space); a label is exactly `**Name:**` at the start of a line (a near miss would leave its words as unlabelled prose, so it raises); When, Then and Because are required and the fields stand in the order above, each at most once; a fence is a bare ``` line (a language tag or a ~~~ fence raises) holding at least one example; every non-blank line inside a fence is an example (rules_doc.parse_example_line), and outside one a line, the text after a label or a bullet's text that parses as an example or opens with a quote or a bracket (``_EXAMPLE_LEADS``: straight, curly and guillemet quotes) raises, as does any line holding an arrow and an assertion (`→ family=`) outside a backtick span, since prose there would never run; a bullet is `- ` and text (`* `, `+ `, a numbered item, a glued hyphen or an empty bullet raises, as does a nested list or a heading in its continuation lines) and an Except, Precedence or Accepted label has at least one; every Except bullet carries an example; a Tolerated field is not empty; When, Tolerated and Then are one paragraph each, Then one sentence, and Because three sentences at most; a Signal or `no-boundary:` line above the `---` raises, and below it only Signal, standing directly above the pointer line, once, and spelled as a hub's where it names one, the pointer line (every part `history:`, `implemented:` or `tracked:` with a value, none repeated) and `no-boundary:` may appear, and `interacts:`, the block format's advisory pointer, is refused. Everything outside a fence, the footer and `no-boundary:` is quotable (``skeleton_prose``). """ from __future__ import annotations import re from dataclasses import dataclass from tests.v2.rules_doc import ( _EXAMPLE_RE, _RETIRED_RULE_RE, ASSERTABLE_FIELDS, NEAR_SIGNAL_RE, Example, Rule, _retired, parse_example_line) HEADING_RE = re.compile(r"^# ([A-Z]\d+) — ") #: What ends a rule's block: a markdown heading, a `#` run and a space. #: A prose line opening `#761` is not one. _ANY_HEADING_RE = re.compile(r"^#+ ") _ORDER = ("When", "Tolerated", "Then", "Because", "Except", "Precedence", "Accepted") _BULLET_FIELDS = frozenset({"Then", "Except", "Precedence", "Accepted"}) _BULLET_ONLY = frozenset({"Except", "Precedence", "Accepted"}) #: The prose fields refusing a second paragraph; Because is held to its #: sentence count instead. _ONE_PARAGRAPH = frozenset({"When", "Tolerated", "Then"}) _BOLD_LABEL_RE = re.compile(r"^\*\*(?P<label>[^*:]+):\*\*") _NEAR_LABEL_RE = re.compile( r"^[*_\s]*(?:when|then|because|except|precedence|accepted|tolerated" r"|rationale)[*_\s]*:", re.IGNORECASE) _NO_BOUNDARY_RE = re.compile(r"^\s*no-boundary:\s+(?P<reason>\S.*)$") #: A hub's signal: the rules it maps, after any signal it reads itself. _HUB_RE = re.compile( r"(?:(?P<own>\S.*?); )?hub of (?P<ids>[A-Z]\d+(?:, [A-Z]\d+)*)") #: A signal meant as a hub's: one that does not fullmatch _HUB_RE #: raises rather than reading as a plain signal, which would leave the #: hub's rules unchecked. A plain word holding the letters ("Hubert", #: "a hub-like word") is not one. _NEAR_HUB_RE = re.compile(r"\bhubs?\b(?!-)", re.IGNORECASE) _SIGNAL_LINE_RE = re.compile(r"^\*\*Signal:\*\*\s+(?P<text>\S.*?)\s*$") _POINTER_LINE_RE = re.compile(r"^\s*(history|interacts|implemented|tracked):") _POINTER_PART_RE = re.compile( r"(?P<key>history|interacts|implemented|tracked):\s*(?P<val>\S.*)") #: A footer line written above the footer, in any spelling: read as #: prose it would leave the rule's waiver or signal unset. _NEAR_NO_BOUNDARY_RE = re.compile(r"^\s*[*_]*no-boundary\b", re.IGNORECASE) #: An example's arrow and assertion, wherever it stands: outside a #: fence a line holding one is an example that would never run. _ARROW_FIELD_RE = re.compile(r"\s→\s+(?P<field>[a-z_]+)=") #: A bullet marker other than `- ` then text: `* `, `+ `, a numbered #: item (`1. `, `1) `), or a hyphen glued to its text. _MALFORMED_BULLET_RE = re.compile(r"^(?:[*+](?:\s|$)|-(?! )|\d+[.)](?:\s|$))") #: A list item in any markdown marker, as a bullet's continuation would #: hold a nested list: `- `, `* `, `+ ` or a number and `.` or `)`. _NESTED_ITEM_RE = re.compile(r"^(?:[-*+]|\d+[.)])\s") _QUOTED_RE = re.compile(r'"[^"]*"') _CODE_RE = re.compile(r"`[^`]*`") _SENTENCE_END_RE = re.compile(r"[.?!]\s+[A-Z]") #: A period after one of these ends no sentence: an initial ("V. #: Smith") or a title abbreviation. _NOT_TERMINAL_RE = re.compile( r"(?:^|[^\w])(?:[A-Z]|Dr|Mr|Mrs|Ms|St|Prof|Jr|Sr)$") #: What parse_example_line treats as an attempted example: a line that #: parses as one, or one opening with a quote or a bracket. _EXAMPLE_LEADS = ('"', "[", "“", "”", "„", "‘", "’", "«") def holds_assertion(s: str) -> bool: """Whether prose holds an example's assertion, which would never run there: a line that parses as an example, or an arrow and an assertion anywhere in it. One inside a backtick span is prose writing ABOUT the grammar (`` `→ family=` ``), not an example. Asked of a section's Background too (rules_doc.parse_rules_doc).""" if _EXAMPLE_RE.match(s): return True return any(m.group("field") in ASSERTABLE_FIELDS for m in _ARROW_FIELD_RE.finditer(_CODE_RE.sub("", s))) def _looks_like_example(s: str) -> bool: """holds_assertion, or a line opening with a quote or a bracket, which a rule's prose may not: a Background may, since its wrapped lines do.""" return holds_assertion(s) or s.lstrip().startswith(_EXAMPLE_LEADS) @dataclass(frozen=True) class Bullet: text: str examples: tuple[Example, ...] @dataclass(frozen=True) class Skeleton: when: str tolerated: str | None then: str then_bullets: tuple[Bullet, ...] because: str excepts: tuple[Bullet, ...] precedence: tuple[Bullet, ...] accepted: tuple[Bullet, ...] def strip_bold(s: str) -> str: return s.replace("**", "") def count_sentences(text: str) -> int: """Sentences in prose: quoted spans and backtick spans removed, `e.g.`/`i.e.` not terminal, a split being a `.?!` followed by whitespace and a capital, except a period after an initial (a lone capital, "V. Smith") or after Dr, Mr, Mrs, Ms, St, Prof, Jr or Sr. Not detected, so undercounted: a sentence that opens with anything but a capital (lowercase, a digit, a quote or backtick span, which is removed first), and a sentence that ENDS in an initial or one of those abbreviations, since the next one's capital reads as the name the initial belongs to ("It ends in V. Then more." is one).""" text = _CODE_RE.sub("", _QUOTED_RE.sub("", text)) text = text.replace("e.g.", "eg").replace("i.e.", "ie") return 1 + sum( 1 for m in _SENTENCE_END_RE.finditer(text) if not (text[m.start()] == "." and _NOT_TERMINAL_RE.search(text, 0, m.start()))) def skeleton_block_at( lines: list[str], i: int) -> tuple[str, list[tuple[int, str]], int] | None: """If lines[i] is a rule's heading: (rule ID, the block's (line number, text) pairs after it, the last line number of the block). Else None. Every block under a rule heading goes to parse_skeleton, which refuses one holding no field.""" m = HEADING_RE.match(lines[i]) if m is None: return None j = i + 1 while j < len(lines) and not _ANY_HEADING_RE.match(lines[j]): j += 1 body = lines[i + 1:j] return m.group(1), [(i + 2 + k, ln) for k, ln in enumerate(body)], j _OUTSIDE_FENCE = ( "an example line outside a fence: examples stand inside a bare ``` " "fence, where they run; outside one they would be read as prose: ") class _Bul: def __init__(self, lineno: int, text: str) -> None: self.lineno = lineno self.parts = [text] self.examples: list[Example] = [] def freeze(self) -> Bullet: return Bullet(strip_bold(" ".join(self.parts)), tuple(self.examples)) def parse_skeleton(rule_id: str, lines: list[tuple[int, str]]) -> Rule: rule = Rule(rule_id=rule_id) def err(lineno: int, msg: str) -> ValueError: return ValueError(f"{rule_id}: line {lineno}: {msg}") texts: dict[str, list[str]] = {} bullets: dict[str, list[_Bul]] = {} seen: list[str] = [] # labels in file order cur: str | None = None # the open field mode = "closed" # prose | bullets | closed fence: list[Example] | None = None fence_at = 0 fence_len = 0 # len(fence) when it opened gap = False # a blank line since the last line footer = False signal_at: int | None = None for lineno, line in lines: if footer: body = line.strip() if signal_at is not None: if not _POINTER_LINE_RE.match(strip_bold(line)): raise err(signal_at, "a `**Signal:**` line must stand directly " "above the rule's pointer line, on one line; " f"the next line is {body!r}") signal_at = None if not body: continue sm = _SIGNAL_LINE_RE.match(body) nb = _NO_BOUNDARY_RE.match(body) if sm: if rule.signal is not None: raise err(lineno, "a second `**Signal:**` line; " "a rule states one") rule.signal = sm.group("text") hub = _HUB_RE.fullmatch(rule.signal) if hub is None and _NEAR_HUB_RE.search(rule.signal): raise err( lineno, "a hub's signal is `**Signal:** hub of " "C3, C4` or `**Signal:** <own signals>; hub of " "C3, C4`, rule IDs joined by a comma and a " f"space: {body!r}") rule.hub = tuple(hub.group("ids").split(", ")) if hub else () signal_at = lineno elif nb: rule.no_boundary = nb.group("reason") elif _POINTER_LINE_RE.match(strip_bold(body)): keys: list[str] = [] for part in strip_bold(body).split("·"): pm = _POINTER_PART_RE.fullmatch(part.strip()) if pm is None: raise err( lineno, "every part of the pointer line is " "`history:`, `implemented:` or `tracked:` and " f"a value, joined by ` · `: {part.strip()!r}") key, val = pm.group("key"), pm.group("val") if key in keys: raise err(lineno, f"`{key}:` is repeated in the " "pointer line") keys.append(key) items = tuple( v.strip() for v in val.split(",") if v.strip()) if key == "interacts": raise err( lineno, "interacts: is retired with the block " "format; name the rule in Except or Precedence") if key == "implemented": rule.implemented = items elif key == "tracked": rule.tracked = items else: raise err( lineno, "below the `---` footer only `**Signal:**`, " f"the pointer line and `no-boundary:` may appear: " f"{body!r}") continue if fence is not None: if line.strip() == "```": if len(fence) == fence_len: raise err(fence_at, "an empty fence: a fence holds " "examples, one per line") fence = None continue if not line.strip(): continue ex = parse_example_line(line, rule_id, lineno) if ex is None: raise err(lineno, "every line inside a fence is an " f"example and this is not one: {line.strip()!r}") fence.append(ex) rule.examples.append(ex) continue if not line.strip(): gap = True continue after_gap, gap = gap, False if line.strip() == "---" and line == "---": footer = True cur = None continue if _RETIRED_RULE_RE.match(line): raise _retired(rule_id, lineno, line) if NEAR_SIGNAL_RE.match(line) or _NEAR_NO_BOUNDARY_RE.match(line): raise err(lineno, "a `**Signal:**` or `no-boundary:` line " "belongs below the `---` footer; above it, it " f"would be read as prose: {line.strip()!r}") bm = _BOLD_LABEL_RE.match(line) if bm: label = bm.group("label") rest = line[bm.end():] if label not in _ORDER: raise err(lineno, f"unknown field label {line.strip()!r}; " f"the labels are {', '.join(_ORDER)}") if rest and not rest[0].isspace(): raise err(lineno, f"a label is followed by a space: " f"{line.strip()!r}") if label in seen: raise err(lineno, f"field {label} is repeated") if seen and _ORDER.index(label) < _ORDER.index(seen[-1]): raise err(lineno, f"field {label} is out of order after " f"{seen[-1]}; the order is {', '.join(_ORDER)}") if not seen and label != "When": raise err(lineno, f"field {label} is out of order: " "a rule opens with **When:**") seen.append(label) cur = label text = rest.strip() if _looks_like_example(text): raise err(lineno, _OUTSIDE_FENCE + repr(text)) if label in _BULLET_ONLY: if text: raise err(lineno, f"{label} takes a `- ` bullet list " "under the label, not text on it") mode = "bullets" else: mode = "prose" texts[label] = [text] if text else [] bullets.setdefault(label, []) continue if _NEAR_LABEL_RE.match(line): if re.match(r"^\*\*[^*:]+\*\*\s*:", line): how = "the colon goes inside the bold" elif "*" not in line.split(":", 1)[0]: how = "a label is bold" else: how = "a label is spelled exactly so" raise err(lineno, "looks like a field label but is not " f"spelled `**Label:**` ({how}, capitalized): " f"{line.strip()!r}") inner = line.strip() if _looks_like_example(inner): raise err(lineno, _OUTSIDE_FENCE + repr(inner)) if inner.startswith(("```", "~~~")) and inner != "```": raise err(lineno, "a fence is a bare ``` line, no language " f"tag and no ~~~: {inner!r}") if line.rstrip() == "```": if cur is None: raise err(lineno, "a fence outside any field") fence, fence_at, fence_len = [], lineno, 0 mode = "closed" continue if line.startswith("- ") and line[2:].strip(): if cur not in _BULLET_FIELDS: raise err(lineno, f"a bullet outside a field that takes " f"them (Then, Except, Precedence, Accepted): " f"{line.strip()!r}") text = line[2:].strip() if _looks_like_example(text): raise err(lineno, _OUTSIDE_FENCE + repr(text)) bullets[cur].append(_Bul(lineno, text)) mode = "bullets" continue if line.rstrip() == "-": raise err(lineno, "an empty bullet: a bullet is `- ` and text") if _MALFORMED_BULLET_RE.match(line): raise err(lineno, "a bullet is `- ` then text, a hyphen and " f"one space: {line.strip()!r}") if line.startswith(" ") and mode == "bullets" and bullets.get( cur or ""): last = bullets[cur or ""][-1] inner = line.strip() if inner == "```": fence, fence_at = last.examples, lineno fence_len = len(fence) continue if _NESTED_ITEM_RE.match(inner): raise err(lineno, f"a nested list is not part of the " f"grammar: {inner!r}") if _ANY_HEADING_RE.match(inner): raise err(lineno, f"a heading inside a bullet is not part " f"of the grammar: {inner!r}") last.parts.append(inner) continue if cur is not None and mode == "prose" and not line.startswith(" "): if cur in _ONE_PARAGRAPH and after_gap and texts[cur]: raise err(lineno, f"{cur} is one paragraph" + (" (detail goes in a bullet list under it)" if cur == "Then" else "") + f": {line.strip()!r}") texts[cur].append(line.strip()) continue raise err(lineno, "a line outside a field (prose belongs in a " "labelled field; a bullet continues on lines indented " f"two spaces): {line.strip()!r}") if fence is not None: raise err(fence_at, "unclosed fence") if signal_at is not None: raise err(signal_at, "a `**Signal:**` line must stand directly " "above the rule's pointer line; the text ends after it") for need in ("When", "Then", "Because"): if need not in seen: raise ValueError(f"{rule_id}: missing the **{need}:** field") for label in ("When", "Then", "Because"): if not texts[label]: raise ValueError(f"{rule_id}: the **{label}:** field is empty") def joined(label: str) -> str: return strip_bold(" ".join(texts[label])) then, because = joined("Then"), joined("Because") if count_sentences(then) > 1: raise ValueError( f"{rule_id}: Then is one sentence (detail goes in a bullet " f"list under it): {then!r}") if count_sentences(because) > 3: raise ValueError( f"{rule_id}: Because is three sentences at most: {because!r}") if "Tolerated" in seen and not texts["Tolerated"]: raise ValueError(f"{rule_id}: the **Tolerated:** field is empty; " "a tolerated rule states why") for label in _BULLET_ONLY: if label in seen and not bullets[label]: raise ValueError(f"{rule_id}: the **{label}:** field has no " "bullets; it is a `- ` bullet list, or absent") for b in bullets.get("Except", []): if not b.examples: raise err(b.lineno, "an Except bullet carries an example " "(a fenced block under it): there is no example " f"here: {b.parts[0]!r}") rule.skeleton = Skeleton( when=joined("When"), tolerated=joined("Tolerated") if "Tolerated" in texts else None, then=then, then_bullets=tuple(b.freeze() for b in bullets["Then"]), because=because, excepts=tuple(b.freeze() for b in bullets.get("Except", [])), precedence=tuple(b.freeze() for b in bullets.get("Precedence", [])), accepted=tuple(b.freeze() for b in bullets.get("Accepted", []))) return rule def skeleton_parts(sk: Skeleton) -> list[tuple[str, str]]: """Every quotable field's text, tagged with its field (``Then bullet`` for a bullet under Then), in file order: the one list the quotable prose, the ID check and the size budget all read.""" out = [("When", sk.when)] if sk.tolerated is not None: out.append(("Tolerated", sk.tolerated)) out.append(("Then", sk.then)) out += [("Then bullet", b.text) for b in sk.then_bullets] out.append(("Because", sk.because)) out += [("Except", b.text) for b in sk.excepts] out += [("Precedence", b.text) for b in sk.precedence] out += [("Accepted", b.text) for b in sk.accepted] return out def skeleton_prose(sk: Skeleton) -> list[str]: """The quotable lines, in file order: each field's text, its label left out.""" return [text for _field, text in skeleton_parts(sk)] ���������������derek73-python-nameparser-456b024/tests/v2/test_benchmark.py����������������������������������������0000664�0000000�0000000�00000172523�15273032717�0023755�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Perf smoke: parse cost stays v1-comparable (microseconds per name). Deliberately generous bound -- guards against order-of-magnitude regressions, does not gate normal variance. Two different things are measured here, and the distinction matters. The thousand-names tests below bound ABSOLUTE cost on constant-size input; the scaling test bounds GROWTH in input length. Neither subsumes the other, and the absolute tests are structurally blind to a complexity regression: their workload is a short name with no delimiters, so a stage that goes quadratic in the number of delimiter pairs stays far inside the one-second bound. Three such quadratics (_extract's mask-overlap scan, ParsedName's token-subset check, and _group's #329 clause scan) were caught in review rather than here -- hence the scaling test. The third is why _POLICY_SHAPES exists below: it was quadratic in a shape the scaling test ALREADY had ("(a) "), and still went unseen, because the stage is gated on an opt-in Policy field that bare parse() leaves empty. A shape guards nothing if the default policy cannot reach the code under it. _PREFIXED_SHAPES exists for a shape that needs a prefix before its repeated run, which _SHAPES cannot express (#553). Which guard a new shape needs (the comment beside each table carries the measurements behind its rule): - A unit repeated at n and 4n that a bare parse() reaches: a _SHAPES row, timed on the clock. The rows cover different dimensions, and the comments at the table say which dimension each row alone covers and what its unit must hold to reach the code -- measure before pruning one. Calibrate _MAX_RATIO against the WEAKEST quadratic's signal, and confirm a planted regression fails across REPEATED runs. - A stage gated on an opt-in Policy field: a _POLICY_SHAPES row, with the reachability probe every row carries. - A shape that needs a PREFIX before its run, whose cost is Python-level: a _REREAD_SHAPES row, counted in the frames of the one function the defect re-runs, or a frame test of its own (test_a_clause_link_run_does_not_cost_quadratically). - A prefixed shape whose cost is C-level work (a list scanned by `in`, a tuple re-hashed as a key), which emits no frame: a _PREFIXED_SHAPES row, on the clock at its own base. That table skips under a line tracer, and NAMEPARSER_REQUIRE_CLOCK_GUARDS turns the skip into a failure (the comment at the table says why CI still runs it). - An exponential, whose separating input length never finishes on the clock: a frame-count pair, cheap pair first, then a longer pair that can see a quadratic (test_a_trailing_credential_run_does_not_cost_exponentially). A frame guard either counts the whole parse (the exponential pair, the clause-link test, _REREAD_SHAPES' `lead` row) or isolates ONE function with `only=`. An isolated guard goes blind when the code under it stops calling that function, so when that code moves, move `only=` with it and re-take its negative control (#563, where #613's move left the guard reading 3.6x on a returned quadratic). A whole-parse guard's ratio includes the parse's linear work, which its calibration accounts for. Either kind needs the same reachability assertion a _POLICY_SHAPES row carries. Frame counts do not move under load, so they need no repeated-run calibration. The module is marked `serial` (tests/conftest.py says why), and a new clock-timed test elsewhere takes the mark too. """ import gc import os import sys import time from collections.abc import Callable from types import FrameType import pytest from nameparser import ParsedName, Parser, parse from nameparser._policy import Policy #: The whole module, though only the clock-timed tests need it: a #: parallel run's load fails their ratios (tests/conftest.py), and the #: frame-count tests beside them cost too little to be worth splitting #: the module's one serial pass. pytestmark = pytest.mark.serial #: What one parse of the reference name is allowed to cost, counted in #: Python frame entries rather than seconds (#475). A wall-clock bound #: cannot separate a 1% change from a busy CI runner: the 1.0s version #: of these tests failed four times across #466 and #474 at 1.01-1.08s #: while master re-ran green each time, and three local measurement #: methods disagreed with CI and with each other. Frame counts do not #: move under load, and -- measured -- do not move under `pytest --cov` #: either, though coverage costs 3x on the clock. #: #: PER INTERPRETER, because the count is not machine-independent: PEP #: 709 inlined the comprehension frames 3.11 counts, and 3.13 added #: others back. A version with no row here fails loudly with its own #: number rather than passing unguarded -- recording it is the point. #: #: A BAND, not a ceiling. A ceiling with headroom is another threshold #: that happens to break, which is the failure this replaced: the 2.2 #: cycle's +67 calls arrived across a dozen PRs at roughly five each, #: and no ceiling loose enough to be safe can see five. The band is #: +/-2%, so a single PR's worth of growth lands in a diff with a #: reason beside it. A DROP is a signal too, and trips the same test. #: #: Raising or lowering a row is a decision to record in #: decisions/parse-cost, not a maintenance chore. Recompute with #: `uv run python tools/perf/call_count.py`, which is the harness every #: number in that entry comes from. #: #: Measured 2026-08-31 on this tree with that harness. 3.11 and 3.14 #: are the two interpreters on the author's machine; 3.12, 3.13 and #: 3.15 are CI's, seeded from a review measurement and confirmed by #: the first green run -- a wrong seed fails with the real number. #: #: Lowered 2026-09-26 when the stages stopped copying state through #: dataclasses.replace (decisions/parse-cost). All five rows were #: re-measured with that harness, each on its own interpreter. #: #: Lowered 2026-10-06 (#617) when P3's connective joins left group's #: closures for `_pieces.join_connectives`, which calls the piece #: predicates directly: each interpreter measured 10 below 83f4e914's #: own count (374/411 on 3.11, 353/390 on 3.12-3.15, which had sat #: +4/+5 inside the old 370/407 and 348/385). Then 39 more on every #: row, in #617's /simplify, when group stopped running the rootname #: count and the shared loop over segments with no connective left #: free to join (decisions/parse-cost). #: #: Lowered 2026-10-07 by 8 on every row when `_group_segment`'s last #: three predicate closures (`prefix`, `suffix`, `marker`) were #: inlined: the reference name asked `prefix` 7 times and `suffix` #: once, each a frame of its own (decisions/parse-cost). And 9 on #: 3.11, 6 on 3.12-3.15, in #614, when S2's trailing run came to be #: read once, between P3's joins and the particle chain, rather than #: re-asked by the chain and the reserve and read again by assign #: (decisions/parse-cost). The 3.12-3.15 rows read 289/326 from #: #614's /simplify on and were corrected in #620, which moved no row #: (decisions/parse-cost). Then down by 4 on every row in #624 and up #: by 3 in #625, to the figures below (decisions/parse-cost). _CALL_BASELINE = { (3, 11): {"parse": 307, "facade": 344}, (3, 12): {"parse": 288, "facade": 325}, (3, 13): {"parse": 288, "facade": 325}, (3, 14): {"parse": 288, "facade": 325}, (3, 15): {"parse": 288, "facade": 325}, } _BAND = 0.02 #: The reference name is fixed WIDTH, not merely fixed: an incrementing #: counter grows a digit and costs one more call when it does, which #: made the first draft's "mean over n names" a function of n rather #: than of the parser. _REFERENCE = "Dr. Juan{i:04d} de la Vega III" def _calls_per_parse(fn: Callable[[str], object], n: int = 50) -> float: """Mean Python frame entries for one parse of the reference name. `sys.setprofile` counts Python frame entries -- NOT C calls, and not the interpreter's own work inside one frame -- so this measures the work the parser does rather than how fast the machine did it. Per parse there are roughly 575 `c_call` events this cannot see. Deterministic for a given tree AND interpreter: verified identical across repeated calls, fresh processes, PYTHONHASHSEED values, and with or without coverage installed. """ fn("warm up the caches") calls = 0 def counter(frame: object, event: str, arg: object) -> None: nonlocal calls if event == "call": calls += 1 sys.setprofile(counter) try: for i in range(n): fn(_REFERENCE.format(i=i)) finally: sys.setprofile(None) return calls / n def _check_budget(kind: str, fn: Callable[[str], object]) -> None: """Assert one entry point sits inside its band. Skips rather than clobbers when something else owns the profile slot: `sys.setprofile(None)` in the helper CLEARS the hook, and a maintainer profiling the parser -- the very workflow that produced decisions/parse-cost -- would otherwise get silently truncated data. Restoring is not an option: `sys.getprofile()` hands back a `Profile` object that `sys.setprofile()` then refuses. """ if sys.getprofile() is not None: pytest.skip("a profile hook is already installed; this test owns it") version = sys.version_info[:2] if version not in _CALL_BASELINE: actual = _calls_per_parse(fn) pytest.fail( f"no call baseline for Python {version[0]}.{version[1]}; " f"{kind} measures {actual:.1f} here. Add the row to " f"_CALL_BASELINE and record it in decisions/parse-cost") baseline = _CALL_BASELINE[version][kind] actual = _calls_per_parse(fn) low, high = baseline * (1 - _BAND), baseline * (1 + _BAND) assert low <= actual <= high, ( f"{kind} costs {actual:.1f} calls/name on Python " f"{version[0]}.{version[1]}, band {low:.0f}-{high:.0f} around a " f"baseline of {baseline}. Growth and shrinkage are both signals: " f"recompute with tools/perf/call_count.py, then either make it " f"cheaper or move the baseline deliberately -- see " f"decisions/parse-cost") def test_parse_cost_stays_within_its_band() -> None: _check_budget("parse", parse) def test_facade_cost_stays_within_its_band() -> None: # the legacy-API path (what all existing users call): snapshot # resolution must stay generation-cached, not rebuilt per instance. # Measured: defeating that cache costs 4826 calls against this # band, while taking only 0.93s per 1000 -- which the 1.0s # wall-clock test this replaced would have PASSED. from nameparser import HumanName _check_budget("facade", HumanName) @pytest.mark.parametrize("kind,fn", [ ("parse", parse), ("facade", lambda name: __import__("nameparser").HumanName(name)), ]) def test_a_thousand_names_still_parse_in_reasonable_time( kind: str, fn: Callable[[str], object]) -> None: """The order-of-magnitude backstop the call bands do not give. Frame counts cannot see work that happens without entering a Python frame: a compiled regex that starts backtracking emits no `call` event at all, and neither does a C-level structure turning quadratic. (A quadratic inside a comprehension or generator IS visible -- `call` fires once per generator resume.) Both entry points are covered, because both failures that motivated #475 were on the facade and the first draft of this replacement guarded only `parse`. The bound is loose enough that runner variance cannot reach it: the failures were at 1.01-1.08s against 1.0, and coverage costs about 3x, so 5s leaves roughly 5x of margin on the CI runner that failed. """ fn("warm up the caches") start = time.perf_counter() for i in range(1000): fn(_REFERENCE.format(i=i)) elapsed = time.perf_counter() - start assert elapsed < 5.0, f"1000 {kind} parses took {elapsed:.2f}s" # Pathological shapes: each repeats a unit that drives one stage's inner # loop. Cheap per unit, so a superlinear stage shows up as growth rather # than as a slow parse. # # What each DIMENSION is covered by, measured at _BASE. Several shapes # differ only in a column you cannot see from the string, so measure # before pruning one that looks redundant: # # dimension covered by # token count every row # piece count unmatched_*, plain_tokens, commas, titles # SEGMENT count commas ONLY -- deleting it leaves every # segment-keyed regression unguarded # intra-piece accumulation particles (one 799-token piece), # conjunctions (800) -- the merge_pieces quadratic # masked-span count delimiter_pairs, quote_pairs (0 pieces: # everything is consumed as a delimited run) # NON-ASCII input honorifics ONLY -- every other unit here is # pure ASCII, so script_segment returns at # its isascii() bail and neither the #308 # peel nor the #271 surname site is measured # at all. A repeated 씨 is what walks the # peel's site scan: every token is a # post-nominal, so the scan-back crosses the # whole run before declining # P5 reserve bound_given ONLY -- the one unit whose # first piece is a bound given-name word, # so the reserve's per-piece question is # asked over the whole run (#401) # M2 clause VIEW maiden_clause ONLY -- the one unit # holding a maiden marker, so the take's # clause-free view over a run of class # members (#601). Until #601 it reached # the #533 acronym fork, which only this # order did; since then 'MA nee ' reaches # the take too (measured 2026-10-04) # connective RUN LENGTH link_run ONLY -- P3's both-sides # condition walks the run of connectives # beside a link, and no other unit here # puts two connectives in a row where one # of them is GENERATIONAL vocabulary. # 'and ' is a run too, but no member of it # is in the class, so the frozen loop's # body never runs and the walk is never # entered; the unit needs MIXED CASE as # well, since a one-case name reads the # marked letter as an initial and the # frozen loop declines it on the tag # ('i und ' reaches nothing, 'i Und ' # reaches everything) # S7 ANCHOR pass credential_run ONLY -- a Title-case # member in a mixed-case name declines on # its writing, so the peel asks # `credential_anchors` (#544), and every # 'Ma' stands behind a 'PhD' that anchors # it: 38 of the 40 words of 'PhD Ma ' x20 # read as the suffix, the reserve keeping # the first 'PhD' and 'Ma' as given and # family. 'PhD MA ' reads the # same and never asks the pass at all, the # capitals deciding each member first # gated-word front scan agnomens (#890), carried_agnomens (the # scan through carried members) # S13 clause pass bracket_members (#810) # # The last three rows were added after the census above was taken; on # 2026-10-27 none of them reached S7's anchor pass or the honorific # peel (counted in calls to credential_anchors and # _peel_honorific_tail at 20 units), and none holds a comma, a # non-ASCII character, a bound given name, a maiden marker or a # connective, so the ONLY claims above still hold. _SHAPES = { "delimiter_pairs": "(a) ", # extract: matched pairs -> masked spans "quote_pairs": '"a" ', # extract: the open==close path "unmatched_opens": "( ", # extract: the bulk unmatched-open sweep "unmatched_closes": ") ", # extract: the unmatched-close sweep "plain_tokens": "a ", # tokenize/assign: long token stream "commas": "a, ", # segment: many comma segments "titles": "Dr. ", # group: one long title chain "particles": "van ", # group: the prefix-chain inner loop "conjunctions": "and ", # group: merge_pieces accumulating one piece "honorifics": "씨 ", # script_segment: the peel's site scan "bound_given": "abdul ", # group: the P5 reserve over every piece "maiden_clause": "nee MA ", # group: M2's view over the segment "link_run": "i Und ", # group: P3's both-sides walk (#397) "credential_run": "PhD Ma ", # pieces: S7's anchor pass (#544) "agnomens": "Neto ", # pieces: a gated word scans its front (#890) "carried_agnomens": "MA ma Neto ", # pieces: the scan through carried members "bracket_members": "(M.A) ", # post_rules: S13's clause pass (#810) } _BASE = 800 _FACTOR = 4 # Calibrated, not guessed, and calibrated against the WEAKEST signal. # # A quadratic here is MIXED -- linear work dominates at small sizes and # the quadratic term takes over slowly -- so the textbook 16x never # appears at a testable size, different quadratics surface at different # strengths, and the operating point matters more than the bound. # Measured against the two real bugs this guards, per shape, ratio for # 4x the input: # # base clean _extract mask-overlap _group merge re-flatten # 200 3.9 - 4.3 7.7 5.6 # 400 4.0 - 4.3 9.2 6.3 - 6.6 # 800 4.0 - 4.4 >9.2 7.9 - 8.1 # # The bound must clear the weakest column, not the first one measured. # An earlier version calibrated 6.5 against _extract alone and then # gained the conjunction shape, whose signal is 6.3-6.6 at base 400 -- # the bound landed on the MEDIAN of the broken distribution and caught # the regression it was added for about half the time. Raising the base # separates the populations again rather than squeezing the bound into # a gap that is not there: at 800 the bound has ~1.4x over the worst # clean run (noise headroom on a shared runner) and ~1.3x under the # weakest quadratic. Re-derive BOTH numbers when adding a shape. # The tenth shape (honorifics) was measured into the clean column when # it arrived: 4.0-4.3 at base 800 across repeated runs, inside the range # the ASCII shapes already occupy, so neither number moved. The # eleventh (bound_given, #401) arrived with its own quadratic in hand: # the P5 reserve rebuilt assign's walk per piece and measured 13.2 at # base 800 -- well outside the bound, which is why it was found in # review rather than here, no earlier unit leading with a bound word. # Computed once, the shape reads 4.2, inside the clean column; neither # number moved. # The twelfth (maiden_clause, #533 review) arrived the same way, with # its own quadratic in hand. M2's walk builds ONE view per take over # the segment's own indices, and a rewrite that rebuilt those indices # per piece -- behavior-identical, and it passed the whole suite -- # measures 8.3 at base 200, 10.4 at 400 and 12.2-12.6 at 800 against # a clean 4.0-4.1 at every one of the three, repeated runs. So the # signal is the strongest of the three quadratics on record and does # not decide the bound; the shape reads 4.05-4.12 at base 800 across # repeated runs, inside the clean column, and neither number moved. # The thirteenth (link_run, #397 second review) arrived with its own # quadratic in hand as well, and it is the one this shape was added # FOR rather than one found by adding it: `_between_name_words` walked # the run of connectives beside a link once per MEMBER of that run, # so at commit b9ed1429 the shape measures 8.48 at base 100, 10.29 at # 200 and 12.11 at 400 -- outside the bound at every one of them, and # the second-strongest signal on record. Answered once per run # (`_group._run_neighbours`) it reads 4.04 at base 200, 4.02 at 400 # and 4.02-4.19 at 800 across repeated runs, inside the clean column; # neither number moved. The absolute cost is the shape's own price # and is paid at the top of the clean range: 11.6ms at base 800 # against 48.7ms at 3200. # The fourteenth (credential_run, #544) was measured against the # per-member look-behind the one-pass anchor replaces -- each position # walking back over the whole run in front of it, behavior-identical # -- which reads 14.8 at base 200, 15.1 at 400 and 15.8 at 800, the # strongest signal on record. The shape reads 4.13-4.21 at every one # of the three bases on this tree (py3.11, 2026-09-27), inside the # clean column; neither number moved. _MAX_RATIO = 6.0 def _best(text: str, parse_: Callable[[str], object], repeats: int = 7) -> float: """Minimum of several runs: on a shared runner the mean carries the noise of whatever else is running, while the minimum approaches the true cost. Timed with the cyclic collector OFF, after one collection. A full collection walks every live object, so its cost is the HEAP's, set by whatever ran before this test, not the parse's; landing inside an allocation-heavy parse it read as superlinear growth. Measured 2026-10-02 (#573's CI): after the property grids and the case table, 'PhD Ma ' x800 -> x3200 read 6.33x with the collector on (one 34ms pass inside group's snapshot comprehension) and 4.00-4.06x with it off, on identical frame counts (96,100 -> 384,100). The parse's own work, Python or C level, is still timed whole, so a planted quadratic is as visible as before.""" parse_(text) # warm the parser cache best = float("inf") gc.collect() enabled = gc.isenabled() gc.disable() try: for _ in range(repeats): start = time.perf_counter() parse_(text) best = min(best, time.perf_counter() - start) finally: if enabled: gc.enable() return best def _assert_grows_linearly(unit: str, parse_: Callable[[str], object], prefix: str = "", base: int = _BASE) -> None: small = _best(prefix + unit * base, parse_) large = _best(prefix + unit * (base * _FACTOR), parse_) ratio = large / small assert ratio < _MAX_RATIO, ( f"{prefix!r} + {unit!r} x{base} took {small * 1e3:.2f}ms, " f"x{base * _FACTOR} took {large * 1e3:.2f}ms -- {ratio:.1f}x for " f"{_FACTOR}x the input, which is superlinear (linear is ~{_FACTOR})") @pytest.mark.parametrize("unit", _SHAPES.values(), ids=list(_SHAPES)) def test_parse_cost_grows_no_worse_than_linearly(unit: str) -> None: _assert_grows_linearly(unit, parse) # Shapes that need a PREFIX, which `_SHAPES` cannot express: it repeats # a unit and nothing else, and the walk these guard runs only on the # given part AFTER a family comma. `"a, "` puts the comma in every unit # and measures segment count instead; `"Smith "` alone never takes the # comma path. So each row is (prefix, unit, reachability probe), timed # on the clock like `_SHAPES` -- the defect is C-level work (a list # scanned by `in`, a tuple re-hashed as a dict key), which emits no # frame, so the frame-ratio guards below were blind to it (#553). # # Base 1600, not `_BASE`, and the recorded negative control is why. # Measured 2026-09-28 on py3.11, three runs of each on the tree before # the fix (003b4962), ratio for 4x the run: # # base given_part_run given_part_titles # 800 (_BASE) 6.71 7.60 5.97 7.83 8.31 8.16 # 1600 8.67 8.43 8.51 10.09 10.95 10.29 # # against 3.94-4.32 for both rows at both bases on the fixed tree # (60aa9028). At 800 the run row's quadratic read 5.97 once, under # `_MAX_RATIO` -- a coin-flip guard; at 1600 the bound sits ~1.4x over # the worst clean run and ~1.4x under the weakest broken one, so # `_MAX_RATIO` did not move. Each row also fails, three runs of three, # against a copy reverting only ITS half of the fix, and passes against # a copy reverting only the other half. # # SKIPPED UNDER A LINE TRACER (`sys.settrace`), the core coverage.py # 7.15 uses below py3.14 -- a local `pytest --cov` on 3.11-3.13, or a # debugger; CI collects coverage on 3.14 alone, so no CI job has one # and every CI job measures these rows. A line # tracer slows every Python line and leaves a C-level scan alone, so # the quadratic becomes a smaller share of the parse. Measured the same # day under `coverage run` on py3.11, three runs each: # # base broken: run broken: titles fixed: run / titles # 1600 5.46 - 5.52 6.50 - 6.54 -- # 3200 6.61 - 6.70 8.19 - 8.23 4.16 / 4.17 # 6400 8.19 - 8.29 10.03 - 10.23 4.21-4.28 / 4.22-4.25 # # so at this base the run row PASSES a broken tree under that tracer. # 6400 separates the populations again and costs 15.6s a job under # coverage (4.9s without), against ~1s here; declined for the CI time # AGENTS.md's grid rules guard. The `sys.monitoring` core coverage # uses from 3.14 does NOT dilute it -- 8.87-10.91 broken against # 3.99-4.08 fixed at this base, GIL py3.14, same day -- so the rows # run in CI's coverage job too. `ja-extra` (py3.14, no coverage: # 9.27-10.95 broken against 3.95-4.03 fixed) also sets # NAMEPARSER_REQUIRE_CLOCK_GUARDS, under which a line tracer FAILS # these rows instead of skipping them: whatever the build matrix's # coverage setup becomes, one job cannot retire this guard in silence. _PREFIXED_BASE = 1600 # # #613's PR review added four rows, every one a C-level or growing- # slice cost in `_comma.decide`'s reading of the part after the comma # that no frame guard could see. Measured 2026-10-06 on py3.11 at this # base, three runs of best-of-three each, on 9dd271b2 (broken) against # the fixed tree: title_chain 11.78-11.88 against 4.19-4.23, pair_run # 9.99-10.07 against 4.02-4.16, caps_pairs 9.86-10.03 against # 4.09-4.20, delimiter_cores 10.06-10.22 against 4.16-4.34. _PREFIXED_SHAPES: dict[ str, tuple[str, str, Parser, Callable[[str], bool]]] = { # every word of the run is a middle name, so the walk asks the # untitled membership test once per piece "given_part_run": ( "Doe, Jane ", "Smith ", Parser(), lambda text: (parse(text).middle.split() == text.split()[2:] and parse(text).family == "Doe"), ), # every trailing title joins the H5 chain, so the walk asks the # titled membership test once per piece, and the name word in # front of the chain asks it again at every step of walking down # through it (`previous_kept`) "given_part_titles": ( "Doe, Jane Smith ", "Prof. ", Parser(), lambda text: (parse(text).title.split() == text.split()[3:] and (parse(text).given, parse(text).family) == ("Jane", "Doe")), ), # every 'and' joins the title chain the comma decision builds: # #613's first copy of the loop rebuilt the growing piece from a # slice at each join (the control above, on 9dd271b2); since #617 # the chain is `_pieces.join_connectives`, group's loop, which # extends in place. Re-taken on the moved code 2026-10-06, py3.11, # this base, three runs of best-of-five: 4.47-4.49 against # 9.95-10.00 with the slice rebuild planted in `merge_pieces` "title_chain": ( "John Smith, ", "Mr. and ", Parser(), lambda text: (parse(text).given == "John" and parse(text).title.startswith("Mr. and Mr.")), ), # every pair is paired initials behind a dual, so the speaker test # runs per pair; a copy of the shape set per pair was quadratic "pair_run": ( "John Smith, MD ", "G.J. ", Parser(), lambda text: parse(text).given == "John", ), # every two-capital word is a caps-shape pair, through the same # per-pair test and the members list scanned per piece "caps_pairs": ( "John Smith, ", "XY ", Parser(), lambda text: parse(text).given == "John", ), # every core is dropped behind the whole name, and the part was # filtered against a list of the cores "delimiter_cores": ( "John Smith, ", "MD / ", Parser(policy=Policy(extra_suffix_delimiters=frozenset({"/"}))), lambda text: Parser(policy=Policy( extra_suffix_delimiters=frozenset({"/"}))).parse(text).given == "John", ), } @pytest.mark.parametrize("prefix,unit,parser,reaches", _PREFIXED_SHAPES.values(), ids=list(_PREFIXED_SHAPES)) def test_prefixed_cost_grows_no_worse_than_linearly( prefix: str, unit: str, parser: Parser, reaches: Callable[[str], bool]) -> None: # at the measured size, with each row's own reading asserted: a # shape that stopped reaching the code it guards would go on timing # something else (the titles row reads the same title on the # comma-less path, which is why its probe holds the family comma) text = prefix + unit * _PREFIXED_BASE assert reaches(text), "shape no longer reaches the code it guards" if sys.gettrace() is not None: reason = ("a line tracer dilutes a C-level cost below the bound " "(#553)") if os.environ.get("NAMEPARSER_REQUIRE_CLOCK_GUARDS"): pytest.fail(f"{reason}, and this run is the one that must " f"measure it: drop the tracer from this job") pytest.skip(reason) _assert_grows_linearly(unit, parser.parse, prefix=prefix, base=_PREFIXED_BASE) # Shapes that need a NON-DEFAULT POLICY to reach the code they guard. # Every _SHAPES entry runs bare parse(), and a stage gated on an opt-in # Policy field is dead there -- #329's clause loop exits on its first # line when maiden_delimiters is empty, so the quadratic it shipped # with was invisible to every shape above even though "(a) " was # already one of them. The unit is the SAME string as delimiter_pairs; # only the policy differs, which is the whole point. # # Calibrated like the others, per the note above: the #329 clause scan # measured 14.1x at base 800 (against a 4.1x bare-policy control), and # 4.3x once bisected -- inside the clean column, so _MAX_RATIO did not # move. Every ratio in this file is for _FACTOR x the input, never per # doubling; re-measuring the scan on another runner gave 11.2x against # the same 4.2x control, which is the spread to expect here. # # Third element: a REACHABILITY probe, run before the measurement. # "the shape must reach the code" is the whole premise of this table, # and it is a premise about precedence, which moves -- route ( ) back # to nickname and the parse below stops producing a maiden clause, # leaving this test measuring a no-op at a comfortable 4.2x forever. # That is the module docstring's failure mode one level up: a guard # whose subject has quietly left the building. The probe is per shape # because what "reached" means is per shape. _POLICY_SHAPES: dict[str, tuple[str, Parser, Callable[[Parser], bool]]] = { "maiden_pairs": ( "(a) ", Parser(policy=Policy(maiden_delimiters=frozenset({("(", ")")}))), # a maiden clause whose marker the #329 pass consumes: the # parenthesis pair must route to maiden AND the clause loop # must run for this to read "b" rather than "" or "née b" lambda p: p.parse("a (née b)").maiden == "b", ), } def test_shape_tables_are_not_empty() -> None: # pytest turns an EMPTY parametrize into a SKIP, not a failure, so # deleting the last entry of any of these tables would retire its guard # into the skip count with nothing going red. _POLICY_SHAPES is # the nearer risk, holding only shapes whose stage a default parse # cannot reach at all -- so it gains an entry only when an opt-in # Policy field turns out to have a scaling cliff behind it. assert _SHAPES assert _POLICY_SHAPES assert _PREFIXED_SHAPES assert _REREAD_SHAPES @pytest.mark.parametrize("unit,parser,reaches", _POLICY_SHAPES.values(), ids=list(_POLICY_SHAPES)) def test_policy_gated_cost_grows_no_worse_than_linearly( unit: str, parser: Parser, reaches: Callable[[Parser], bool]) -> None: assert reaches(parser), "shape no longer reaches the gated stage" _assert_grows_linearly(unit, parser.parse) # The shapes above repeat a unit 800 times and measure the CLOCK. That # pairing cannot guard a mutual recursion: #531's trailing-slot walk # re-entered the predicate that owns it, so a run of k ambiguous # credentials cost 2**k, and a run long enough to separate 2**k from # k**2 on the clock does not finish -- 24 members measured 5.8s and # 5.9s on two runs, 32 would be days. So this one counts FRAMES over a # run of 8 against a run of 16, where the exponential is still cheap # enough to profile (112ms at 16 UNDER the sys.setprofile hook this # measures with, which is most of that figure: 23ms unprofiled) and # already 168x. # # Frames are the right instrument here for the #475 reason and one # more: the recursion IS frame entries, one per re-entry, so the count # is the defect itself rather than a proxy for it. Measured on the # unfixed tree before the fix landed: 2,347 frames at 8 and 394,671 at # 16. On this tree (py3.11): 865 at 8, 1,497 at 16, 5,289 at 64. # # TWO ratios, ordered, because one pair cannot see both defects. At 2x # the input a quadratic is NOT the textbook 4x -- the per-member linear # work dominates at these sizes, and the per-member memo #531 first # shipped, a genuine quadratic, measured 2.08x here against this tree's # 1.73x. So the 8-vs-16 pair guards the EXPONENTIAL and nothing else, # and a second pair at 4x the input separates quadratic from linear: # 16-vs-64 measures 3.53x here, 3.33x at cc78c960 (the tree before the # slot existed at all) and 7.42x with that memo. The ORDER is what # keeps the larger pair usable: an exponential does not return from a # run of 64, so the cheap pair is asserted first and the tree that # would hang has already failed. _RUN_SMALL = 8 _RUN_LARGE = 16 _RUN_HUGE = 64 #: 8 -> 16, the exponential's bound: 1.73x measured here against the #: 168x of the recursion this was written for, so 6.0 leaves 3.5x of #: headroom over the measurement and nothing short of a re-entrant walk #: can reach it. It does NOT see a quadratic (2.08x, measured above), #: which is what the second bound is for. _RUN_MAX_RATIO = 6.0 #: 16 -> 64, the quadratic's bound: 3.53x measured here against the #: memo version's 7.42x, so 5.0 sits ~1.4x over the measurement and #: ~1.5x under the regression it is aimed at. Frame counts do not move #: under load, so both margins are for a future shape change rather #: than for runner noise. _RUN_HUGE_MAX_RATIO = 5.0 def _frames_for(text: str, only: str | None = None) -> int: """Python frame entries for ONE parse of `text` -- of every function, or of the one named `only`. One parse, not a mean: this measures growth between two inputs, and the count is deterministic for a given (tree, interpreter) -- see `_calls_per_parse`, which takes a mean only because it reports an absolute figure against a 2% band. """ parse("warm up the caches") calls = 0 def counter(frame: FrameType, event: str, arg: object) -> None: nonlocal calls if event == "call" and (only is None or frame.f_code.co_name == only): calls += 1 sys.setprofile(counter) try: parse(text) finally: sys.setprofile(None) return calls def test_a_trailing_credential_run_does_not_cost_exponentially() -> None: if sys.getprofile() is not None: pytest.skip("a profile hook is already installed; this test owns it") small_text = "Doe, John " + "MA " * _RUN_SMALL large_text = "Doe, John " + "MA " * _RUN_LARGE huge_text = "Doe, John " + "MA " * _RUN_HUGE # REACHABILITY, for the reason _POLICY_SHAPES carries one: the walk # under measurement runs only where every member of the run reads # as a credential. Route these to MIDDLE instead and the guard # measures a walk that no longer happens, at a comfortable ratio, # forever. Asked of the longest run too: the run length is what # this varies, so "still a credential run" is a claim at each size. assert parse(small_text).suffix == " ".join(["MA"] * _RUN_SMALL) assert parse(huge_text).suffix == " ".join(["MA"] * _RUN_HUGE) small = _frames_for(small_text) large = _frames_for(large_text) ratio = large / small assert ratio < _RUN_MAX_RATIO, ( f"a run of {_RUN_SMALL} credentials costs {small} frames and a run " f"of {_RUN_LARGE} costs {large} -- {ratio:.1f}x for 2x the input, " f"where this tree measures 1.7x and the exponential this guards " f"measured 168x. The trailing-slot walk in _assign.py has " f"re-entered the predicate that owns it (#531)") # Only now the long run: it is the pair that can see a QUADRATIC, # and it is also the one an exponential never returns from, which # the assertion above has already caught. huge = _frames_for(huge_text) huge_ratio = huge / large assert huge_ratio < _RUN_HUGE_MAX_RATIO, ( f"a run of {_RUN_LARGE} credentials costs {large} frames and a run " f"of {_RUN_HUGE} costs {huge} -- {huge_ratio:.1f}x for 4x the " f"input, where this tree measures 3.5x and the per-member memo " f"#531 first shipped measured 7.4x. Something in _assign.py's " f"trailing slot is asking a walk per member again (#531)") # The clause caller of the same walk, and a THIRD instrument for the # same reason the one above needed a second: `_SHAPES` repeats a unit # and nothing else, so it cannot express "a name word, a marker, then # a long run" -- and a maiden clause needs exactly that prefix. # Measured: `"nee i Und " * n` never reached the clause link exception # (retired by #601; `_clause_run` says what the guard measures now) # at all (the take declines, and b9ed1429 and this tree measure the # identical 3.92/4.07/4.12 on it), which is the silent-no-op a # reachability probe exists to catch. So this guard builds its own # input and counts FRAMES, which the walk is made of and which do not # move under load. # # One pair, not two: the defect here is a quadratic and there is no # exponential to order it against, so the 16-vs-64 pair is the whole # guard. Re-measured 2026-09-21 on py3.11 through this file's own # `_frames_for`: 859 frames at 16 and 2,587 at 64 on this tree # (3.01x), against 1,125 and 6,741 at b9ed1429 (5.99x), where # `_between_name_words` walked the run once per member -- identical on # three repeated runs at each end, frame counts being deterministic. # One frame per link below the 875/2,651 this same helper read before # `_between_name_words` answered both sides in one call. The pair # first recorded here, 876/2,652, is one frame above that and does NOT # reproduce: re-measured 2026-09-22 on py3.11, 6048eb5d -- the commit # that wrote those two figures into this comment -- reads 875 and # 2,651, which is the same pair 9fd84463 reads. So that recording came # off another interpreter; it stays, with the py3.11 reading of its own # tree now beside it. _CLAUSE_RUN_SMALL = 16 _CLAUSE_RUN_LARGE = 64 #: 3.01x measured here against 5.99x at b9ed1429: 4.5 sits ~1.5x over #: the measurement and ~1.3x under the regression. Frame counts are #: deterministic for a given tree and interpreter, so both margins are #: for a future shape change rather than for runner noise. _CLAUSE_RUN_MAX_RATIO = 4.5 def _clause_run(members: int) -> str: """A maiden clause whose birth name is a RUN of links. Mixed case on purpose: written wholly in one case the letter reads as an initial (rules/P8's marked subset). Since #601 the clause keeps the run because nothing in it ends the clause, so this measures the take's walk over a long clause; until then it measured the clause's link exception, which #601 retired. """ return "Jane Doe nee Puig " + "i " * members + "Soler" def _name_link_run(members: int) -> str: """A NAME whose family is a run of links, the shape P3's frozen loop asks `_group._between_name_words` about once per link. Mixed case on purpose, for the reason `_clause_run` gives.""" return "Jane Puig " + "i " * members + "Soler" def test_a_clause_link_run_does_not_cost_quadratically() -> None: if sys.getprofile() is not None: pytest.skip("a profile hook is already installed; this test owns it") small_text = _clause_run(_CLAUSE_RUN_SMALL) large_text = _clause_run(_CLAUSE_RUN_LARGE) # REACHABILITY, the probe every shape in this file carries: the # take walks the clause only while the clause KEEPS the run. End # the clause at the first link instead and the guard measures a # walk that no longer happens, at a comfortable ratio, forever. # Asked at both sizes, the run length being what this varies. for text, members in ((small_text, _CLAUSE_RUN_SMALL), (large_text, _CLAUSE_RUN_LARGE)): assert parse(text).maiden == " ".join( ["Puig"] + ["i"] * members + ["Soler"]) small = _frames_for(small_text) large = _frames_for(large_text) ratio = large / small assert ratio < _CLAUSE_RUN_MAX_RATIO, ( f"a clause holding {_CLAUSE_RUN_SMALL} links costs {small} frames " f"and one holding {_CLAUSE_RUN_LARGE} costs {large} -- " f"{ratio:.1f}x for 4x the input, where this tree measures 3.0x " f"and the per-member walk at b9ed1429 measured 6.0x. " f"_group.py's `_between_name_words` is walking the run per member " f"again (#397)") # #563 simplify round: the paired-initials speaker test scanned every # word in front of EACH pair for a non-title speaker, so a comma run of # title/suffix duals followed by pairs ('MD MD ... G.J. G.J. ...') cost # duals x pairs. Only the first speaker's position can change the # answer, and the fix finds it once. A `_SHAPES` row cannot express it # -- the run needs the 'John Smith, ' prefix -- and the cost is # Python-level, so it is counted in frames of the function the scan # calls, which isolates it from the rest of the parse. # # The function counted MOVED with the code, and that is this guard's # recorded failure: #563 counted `_normalize`, which segment's scan # reached, and when #613 moved the scan into `_comma.decide` it called # `is_suffix_piece` there instead, so the quadratic came back while # this guard read 3.6x and passed (caught in review, 2026-10-06). # Measured 2026-10-06 on py3.11 through `_frames_for(..., # only="is_suffix_piece")`, k duals and k pairs: 44 at k=8 and 164 at # k=32 on this tree (3.7x), against 120 and 1,620 at 47fb2b21 (13.5x), # where every pair rescanned. The dual was 'MD' until #853 took md out # of the titles; re-taken with 'RA' on 2026-10-23, 35 and 131 on the # tree (3.7x) and 120 and 1,620 at 47fb2b21 again. A change to what the scan calls must # move `only=` with it, and re-take that negative control. _PAIR_SCAN_SMALL = 8 _PAIR_SCAN_LARGE = 32 _PAIR_SCAN_MAX_RATIO = 6.0 def test_the_paired_initials_title_scan_does_not_cost_quadratically() -> None: if sys.getprofile() is not None: pytest.skip("a profile hook is already installed; this test owns it") small_text = "John Smith, " + "RA " * _PAIR_SCAN_SMALL + "G.J. " * _PAIR_SCAN_SMALL large_text = "John Smith, " + "RA " * _PAIR_SCAN_LARGE + "G.J. " * _PAIR_SCAN_LARGE # REACHABILITY: the flip REPORTS only where the scan ran and found # nothing but titles in front of the first pair, every shape word # being a pair (rules/C11's "nothing but other paired initials"). # A change that stopped the scan being asked -- 'RA' leaving the # titles, the pair test moving -- loses the report and fails here # rather than leaving this guard measuring nothing. for text, k in ((small_text, _PAIR_SCAN_SMALL), (large_text, _PAIR_SCAN_LARGE)): name = parse(text) assert len(name.suffix.split()) == 2 * k, text assert [a.kind.value for a in name.ambiguities] == ["suffix-or-name"] small = _frames_for(small_text, only="is_suffix_piece") large = _frames_for(large_text, only="is_suffix_piece") ratio = large / small assert ratio < _PAIR_SCAN_MAX_RATIO, ( f"{_PAIR_SCAN_SMALL} duals and pairs cost {small} is_suffix_piece " f"calls and {_PAIR_SCAN_LARGE} cost {large} -- {ratio:.1f}x for 4x " f"the input, where this tree measures 3.7x and the per-pair rescan " f"at 47fb2b21 measured 13.5x. _comma.py's paired-initials speaker " f"scan is running once per pair again (#563, #613)") def test_a_listing_comma_with_nothing_to_decide_skips_the_counts() -> None: """`_comma.decide` runs on every family-comma parse, so its early return is a cost gate on the commonest comma shape: a part after the comma that holds a name word, no ambiguous member and no core returns before C1's counts are taken. Recorded negative control, a review mutation of #613 (2026-10-06, py3.11): with the early return's whole `if` deleted no output moves, `_whole_name` is entered once on 'Doe Smith, Jane Q.', and that parse costs 22 more frames -- a regression no output test and no ±2% band could see, since the reference parse has no comma. The ambiguous-class count is asked only where a member stands in the part, which is the second gate.""" if sys.getprofile() is not None: pytest.skip("a profile hook is already installed; this test owns it") for text in ("Doe Smith, Jane Q.", "Smith, John"): # REACHABILITY: decide is asked, so a zero below means it # returned early rather than that it never ran assert _frames_for(text, only="decide") == 1, text assert _frames_for(text, only="_whole_name") == 0, text assert _frames_for(text, only="name_word_count") == 0, text # a part of unambiguous credentials takes the whole-name count and # not the class count; a member in it takes both assert _frames_for("John Smith, PhD", only="name_word_count") == 0 assert _frames_for("John Smith, MA", only="name_word_count") == 1 def test_a_comma_part_with_no_free_connective_skips_the_join() -> None: """`_comma._reading_pieces` enters the shared connective loop only where the part holds a connective left free: the loop asks `is_conj_piece` of every piece twice, which the comma decision's own copy had asked inline before #617 folded it, and every family-comma parse paid two frames a word of the part. Recorded negative control, #617's second review (2026-10-07, py3.11): with the gate removed no output moves (66,924 parses compared), `join_connectives` is entered once on both names below, and 'Smith, John' costs 163 frames against 161. Group asks nothing of parts this short, so every call counted is the comma decision's.""" if sys.getprofile() is not None: pytest.skip("a profile hook is already installed; this test owns it") for text in ("Smith, John", "John Smith, PhD MD"): # REACHABILITY: decide reads the part, so a zero below means the # gate declined rather than that nothing ran assert _frames_for(text, only="_reading_pieces") == 1, text assert _frames_for(text, only="join_connectives") == 0, text # a part holding a connective takes the loop assert _frames_for("John Smith, Mr. and Mrs.", only="join_connectives") == 1 def test_a_given_part_with_no_particle_skips_the_chain_bound() -> None: """Group asks where the particle chain stops only where a particle stands past the chain's lead, since the chain merges nothing otherwise: after a family comma the bound is `trailing_run_start`, a whole `tail_reading` (#623), which every family-comma name with a trailing word would pay for nothing. Recorded negative control, #623's review (2026-10-10, py3.11): with the gate removed no output moves (226,776 parses compared), the bound is entered twice on each name below (the bound and the re-ask after the chain), and 'Smith, John Prof.' costs 250 frames against 207.""" if sys.getprofile() is not None: pytest.skip("a profile hook is already installed; this test owns it") for text in ("Smith, John Prof.", "Smith, John PhD"): assert _frames_for(text, only="trailing_run_start") == 0, text # REACHABILITY: a particle in the given part asks it assert _frames_for("Smith, John van der Berg", only="trailing_run_start") == 1 def test_a_segment_with_no_free_connective_skips_the_join() -> None: """Group's side of the same gate: a segment with no connective left free to join runs neither P3's rootname count, whose only reader is the carve-out, nor the shared loop, both of which do nothing there. Recorded negative control, #617's /simplify (2026-10-07, py3.11): with the gate removed no output moves (both #617 grids, the fingerprint and the connective grid), the reference-shaped 'Dr. Juan Q. Xavier de la Vega III' enters `join_connectives` once and `_is_rootname` 8 times and costs 436 frames against 386, and 'John Quincy Smith' 229 against 207 -- 39 frames on `_CALL_BASELINE`'s reference row, outside its band.""" if sys.getprofile() is not None: pytest.skip("a profile hook is already installed; this test owns it") for text in ("Dr. Juan Q. Xavier de la Vega III", "John Quincy Smith"): assert _frames_for(text, only="join_connectives") == 0, text assert _frames_for(text, only="_is_rootname") == 0, text # REACHABILITY: a segment whose connective joins takes both assert _frames_for("Josep Carod i Rovira", only="join_connectives") == 1 assert _frames_for("Josep Carod i Rovira", only="_is_rootname") == 4 # Fixed points and scans that re-read what they had already read, each # found by the `_pipeline/` sweep for #553. A `_SHAPES` row cannot # express any of them: the tail ones need a name in front of the run, # and the chain one grows at BOTH ends. The cost is Python-level, so it # is counted in frames of the one function each re-ran, which isolates # it from the rest of the parse and holds under a line tracer. # # tail #558: `tail_reading` re-peeled the whole walk once per # title the H5 chain took, asking `listed_lean` of every # member it had already peeled. # clause #558 again, through the maiden take, which reads that # fixed point over the clause-free view (until #601 three # times: the take, its release check, and # `trailing_start_past_titles`). # chain #559: the particle chain asked "is every piece ahead of # this one a title?" afresh at every chain site, so leading # titles x particle sites `is_leading_title` calls. # lead #573: the given slot asked each member whether the # never-given particle run in front of it was all # particles, rescanning the run per member. That scan lived # in generator expressions, so this row counts every # `<genexpr>` frame in the parse -- NOT isolated like the # three above: the pre-#573 tree already reads 3.39x on # this input from generators elsewhere, and the signal sits # on top of that background. # # Measured 2026-10-01 on py3.11 through `_frames_for(..., only=...)`, k # units, ratio for 4x the units: # # k=8 -> k=32, fixed k=8 -> k=32, at fc682e36 (broken) # tail 9 -> 33 3.67x 36 -> 528 14.67x # clause 27 -> 99 3.67x 108 -> 1,584 14.67x # chain 52 -> 196 3.77x 108 -> 1,188 11.00x # lead 212 -> 716 3.38x 408 -> 3,072 7.53x (at 30e38ed5, # measured 2026-10-02) # # 6.0 sits between the populations with room on both sides for the # first three; for `lead` the broken side's margin is thin (7.53x), so # a shape change there wants re-measuring. Counts are deterministic, # so the margin is for future shape changes, not noise. # Each probe pins the reading the shape needs, so a change that stops # the shape reaching the walk fails here instead of leaving the row # counting nothing. _REREAD_SMALL = 8 _REREAD_LARGE = 32 _REREAD_MAX_RATIO = 6.0 _REREAD_SHAPES: dict[str, tuple[Callable[[int], str], str, Callable[[ParsedName, int], bool]]] = { "tail": ( lambda k: "John Smith " + "MA Prof. " * k, "listed_lean", lambda n, k: (n.title.split() == ["Prof."] * k and n.suffix.split() == ["MA"] * k and n.family == "Smith"), ), "clause": ( lambda k: "Jane Doe nee Smith " + "MA Prof. " * k, "listed_lean", lambda n, k: (n.title.split() == ["Prof."] * k and n.suffix.split() == ["MA"] * k and n.maiden == "Smith"), ), "chain": ( lambda k: "Dr. " * k + "Jan " + "van Berg " * k, "is_leading_title", lambda n, k: (n.title.split() == ["Dr."] * k and n.given == "Jan" and n.family == "van Berg"), ), # #573: a never-given particle run opening the given part, then # members -- the slot asks each member whether it is the run's # word. A per-member rescan of the run was 7.53x here (counted in # generator frames, which is where the rescan lived; 3.38x with # the run's end kept once). Measured 2026-10-02, py3.11. "lead": ( lambda k: "SMITH, VD " + "DE " * k + "MA " * k, "<genexpr>", lambda n, k: (n.family == "SMITH VD " + "DE " * k + "MA" and n.suffix.split() == ["MA"] * (k - 1)), ), # #890: a gated generation word looks past what the walk takes in # front of it, here a run of members a credential carries; a rescan # per member was 15.6x in `is_suffix_piece` calls for 4x the input # (3e035c5b, measured by the PR review, py3.11) "agnomen_front": ( lambda k: "Pedro PhD " + "Ma " * k + "Filho", "is_suffix_piece", lambda n, k: (n.middle.split() == ["PhD"] + ["Ma"] * k and n.family == "Filho"), ), } @pytest.mark.parametrize("text,only,reaches", _REREAD_SHAPES.values(), ids=list(_REREAD_SHAPES)) def test_a_fixed_point_does_not_reread_what_it_has_read( text: Callable[[int], str], only: str, reaches: Callable[[ParsedName, int], bool]) -> None: if sys.getprofile() is not None: pytest.skip("a profile hook is already installed; this test owns it") for k in (_REREAD_SMALL, _REREAD_LARGE): assert reaches(parse(text(k)), k), ( f"shape no longer reaches the walk at k={k}") small = _frames_for(text(_REREAD_SMALL), only=only) large = _frames_for(text(_REREAD_LARGE), only=only) ratio = large / small assert ratio < _REREAD_MAX_RATIO, ( f"{_REREAD_SMALL} units cost {small} {only} calls and " f"{_REREAD_LARGE} cost {large} -- {ratio:.1f}x for 4x the input, " f"where this tree measured under 3.8x and the re-reading walk " f"7.5-14.7x (#558, #559, #573)") # THE ABSOLUTE COST OF A LINK, which the ratio above cannot see: a # change costing ONE MORE FRAME PER LINK moves both ends of the pair # and leaves the ratio where it was. Re-splitting the #397 follow-up's # fold is exactly that change -- 859/2,587 here against 875/2,651 at # 9fd84463, +1 per link at each size -- and against THIS suite the # pre-fold parser is green everywhere but here. Measured 2026-09-22 on # py3.11: a copy of this tree carrying `git archive 9fd84463 # nameparser` in place of its own runs 9,652 passed, 324 skipped, 4 # xfailed and ONE failure, this test. So a link-bearing name gets a # banded absolute pin, keyed by interpreter and banded like # `_CALL_BASELINE` above. # # THE LONG RUN AND NOT THE SHORT ONE, and the arithmetic is the whole # reason: +1 per link is 16 frames at `_CLAUSE_RUN_SMALL`, and 2% of # 859 is 17.2, so the small end's band swallows the very regression # this pin exists for (875 sits inside 842-876). At # `_CLAUSE_RUN_LARGE` the same change is +64 against a 2% band of # 51.7, and 2,651 sits outside 2,535-2,639. A tighter band on the # short name would do it too and was not taken: the band is the one # `_CALL_BASELINE` uses, and a bespoke one here would need its own # argument every time the shape moved. # # RE-POINTED 2026-10-04 (#601), and why: the pin measured a link run # in a MAIDEN CLAUSE, whose link exception asked `_between_name_words` # once per link. #601 retired the exception -- the clause keeps its # links because nothing in it ends the clause -- and the clause's # links leave with it before P3's frozen loop runs, so that shape # reached the function 0 times (measured). The same run written in # the NAME reaches it 64 times, one per link, and a per-link # regression moves it as it moved the clause: +64 against a 2% band # of 53.6 at the 2,678 frames recorded below. # # An unknown interpreter SKIPS rather than fails -- which is where # this parts company with `_check_budget`, whose table carries every # interpreter CI runs and so can afford to fail on a missing row. A # figure nobody ran is not a pin: until 2026-10-07 only py3.11 was # measurable in this working tree, and the #537 reviewer's 3.12-3.14 # figures were deliberately left unrecorded for want of a run behind # them. The 3.12-3.15 rows were added 2026-10-07, each measured on its # own interpreter (2,459 on all four, one below py3.11's 2,478). A new # interpreter gets a row the same way: run it there, then add it. # # Lowered 2026-09-26 from 2587 with `_CALL_BASELINE` above, for the # same reason (decisions/parse-cost). 2,678 from 2026-10-04, the # re-pointed name-level shape (above), measured on py3.11. 2,479 # from 2026-10-06 (#617), 199 below 83f4e914's 2,678, for two # reasons, counted per function: P3's joins call the piece # predicates directly rather than through `_group_segment`'s # closures (the `conj` closure's 133 frames, two more from `title` # and `prefix`), and the single-letter test no longer joins the # piece's text through a generator (65 frames, one per connective, # more than this pin's whole band); `join_connectives` adds one. # 2,478 from 2026-10-07, one below: the name asks group's chain # `is_prefix_piece` once, which had gone through a closure. _LINK_BASELINE = { (3, 11): 2478, (3, 12): 2459, (3, 13): 2459, (3, 14): 2459, (3, 15): 2459, } #: The same +-2% `_CALL_BASELINE` uses, and for the same reason: frame #: counts are deterministic for a given tree and interpreter, so the #: band is headroom for a deliberate shape change rather than for #: runner noise. _LINK_BAND = 0.02 def test_a_link_costs_what_it_is_pinned_at() -> None: """The absolute frame cost of one link-bearing name. The ratio guard above cannot fail on a per-link constant, because a constant moves its two ends together. This one can, and it is the only thing in the suite that can. """ if sys.getprofile() is not None: pytest.skip("a profile hook is already installed; this test owns it") version = sys.version_info[:2] if version not in _LINK_BASELINE: pytest.skip( f"no link baseline for Python {version[0]}.{version[1]}; " f"measure `_name_link_run({_CLAUSE_RUN_LARGE})` on this " f"interpreter and add the row to _LINK_BASELINE") text = _name_link_run(_CLAUSE_RUN_LARGE) # REACHABILITY, the probe every shape in this file carries: the # frames counted are P3's link test's, and they are only there # while every link joins the family. A link that stopped joining # would leave a name that no longer holds 64 links in one run, # comfortably inside the band forever. assert parse(text).family == " ".join( ["Puig"] + ["i"] * _CLAUSE_RUN_LARGE + ["Soler"]) baseline = _LINK_BASELINE[version] actual = _frames_for(text) low, high = baseline * (1 - _LINK_BAND), baseline * (1 + _LINK_BAND) assert low <= actual <= high, ( f"a name holding {_CLAUSE_RUN_LARGE} links costs " f"{actual} frames on Python {version[0]}.{version[1]}, band " f"{low:.0f}-{high:.0f} around a baseline of {baseline}. Growth " f"and shrinkage are both signals, and one frame per link is " f"enough to reach this band where the ratio guard above cannot " f"see it: check whether `_group._between_name_words` still " f"answers both sides of a link in one call, then move the " f"baseline deliberately (#397)") def test_a_member_opening_a_comma_part_asks_no_anchor_pass() -> None: """rules/S7's company clause speaks only for a member with a credential in FRONT of it, so a member opening the part after a one-word family comma ('Smith, Ed') is never anchored, and `segment_suffix_reading` builds no `credential_anchors` pass for it: the pass is built only where `anchor_in_reach` finds a suffix piece in front. RECORDED NEGATIVE CONTROL: with the pass built for every member whose writing declines, `credential_anchors` is entered once for each of these (measured 2026-09-28).""" if sys.getprofile() is not None: pytest.skip("a profile hook is already installed; this test owns it") for text in ("Smith, Ed", "Smith, Ma", "Smith, Ed John"): assert parse(text).family == "Smith", text assert _frames_for(text, only="credential_anchors") == 0, text �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_cases.py��������������������������������������������0000664�0000000�0000000�00000123757�15273032717�0023126�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Core runner over the shared case table. The facade runner (migration plan) consumes the same CASES.""" import functools import re from typing import Any import pytest from nameparser import (Ambiguity, AmbiguityKind, ParsedName, Parser, Policy, ReadingKind, ReadingSource, Role, Token, locales, parser_for) from nameparser._pipeline._state import FIELD_READING, NAME_ROLES from nameparser._policy import FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST from .cases import CASES, Case _ORDER_NAMES = {FAMILY_FIRST: "family-first", FAMILY_FIRST_GIVEN_LAST: "family-first-given-last"} _FIELDS = tuple(r.value for r in Role) # declaration order is canonical def _parser_for_case(case: Case) -> Parser: if case.locale is not None: return parser_for(locales.get(case.locale)) return Parser(policy=case.policy) if case.policy else Parser() @pytest.mark.parametrize("case", CASES, ids=lambda c: c.id) def test_case(case: Case) -> None: parser = _parser_for_case(case) pn = parser.parse(case.text) actual = {f: getattr(pn, f) for f in _FIELDS if getattr(pn, f)} assert actual == case.expect, f"{case.text!r} ({case.classification})" kinds = sorted(a.kind.value for a in pn.ambiguities) assert kinds == sorted(case.ambiguities), \ f"{case.text!r} ({case.classification})" #: The orders the invariant below is checked under. A case row carries #: its own policy where it needs one; these are applied to every row #: that does NOT, because the shapes this invariant is about are mostly #: reached under a family-first order and the table has almost no rows #: that declare one. _INVARIANT_ORDERS = (None, FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST) _CASES_BY_ID = {c.id: c for c in CASES} #: The (row, order) pairs the order sweep checks: every row under each #: order, a row with its own policy only as declared, and no locale row #: (a pack's parser is not one of the three orders). Both invariants #: below walk this one list. _SWEPT_ROWS = [(case, order) for case in CASES for order in _INVARIANT_ORDERS if case.locale is None and not (order is not None and case.policy)] _SWEPT_IDS = [f"{case.id}-" f"{'as-declared' if order is None else _ORDER_NAMES[order]}" for case, order in _SWEPT_ROWS] @functools.cache def _swept(case_id: str, order: tuple[Role, Role, Role] | None) -> ParsedName: """One parse per row and order, shared by every invariant the sweep checks (AGENTS.md: a new invariant over an existing grid joins that grid's walk).""" case = _CASES_BY_ID[case_id] parser = (_parser_for_case(case) if order is None else Parser(policy=Policy(name_order=order))) return parser.parse(case.text) @pytest.mark.parametrize("case,order", _SWEPT_ROWS, ids=_SWEPT_IDS) def test_the_family_partitions_into_particles_and_base( case: Case, order: tuple[Role, Role, Role] | None) -> None: """rules/R2's invariant: a particle needs a base to attach to, so a family made only of particles is a family whose words are not acting as particles -- and therefore a non-empty family always has a non-empty base. Asserted as a PARTITION, which is the stronger form: the family's words are exactly the particles' words plus the base's words. That catches over-marking as well as under-marking, where "the base is non-empty" catches only the second. Word multisets rather than strings, because the family renders in written order while the two views render particles first ("Vega, de la" is family 'Vega de la', particles 'de la', base 'Vega'). """ pn = _swept(case.id, order) if not pn.family: return assert pn.family_base, ( f"{case.text!r}: family={pn.family!r} but family_base is " f"empty (particles={pn.family_particles!r})") assert sorted(pn.family.split()) == sorted( (pn.family_particles + " " + pn.family_base).split()), ( f"{case.text!r}: family={pn.family!r} is not partitioned by " f"particles={pn.family_particles!r} + base={pn.family_base!r}") _NAME_FIELDS = frozenset(NAME_ROLES) #: Every way a report's detail names the field its word was read into, #: mapped to the fields that make it true: assemble's FIELD_READING, #: which words every report a later rule could move (#626), plus the #: phrasings worded where the field is settled -- a comma's credential #: reading, the suffix run's and the numeral fork's ("reads as part of #: the suffix run", "a generational suffix"), H4's "the name", P6's #: "joins the family". A report that names a READING rather than a #: field ("read as an initial") is out of scope, as is any phrasing #: not listed here: the count below is what notices the list going #: stale. _FIELD_CLAIMS: dict[str, frozenset[Role]] = { **{reading: frozenset({role}) for role, reading in FIELD_READING.items()}, **dict.fromkeys(("a credential", "a generational suffix", "part of the suffix run", "a post-nominal"), frozenset({Role.SUFFIX})), "a name": _NAME_FIELDS, "the name": _NAME_FIELDS, "the family": frozenset({Role.FAMILY}), } _FIELD_CLAIM = re.compile( r"\b(?:reads? as|joins) (" + "|".join(map(re.escape, sorted(_FIELD_CLAIMS, key=len, reverse=True))) + r")\b") @pytest.mark.parametrize("case,order", _SWEPT_ROWS, ids=_SWEPT_IDS) def test_a_report_names_the_field_its_word_lands_in( case: Case, order: tuple[Role, Role, Role] | None) -> None: """#626: a report's detail that names the field its word was read into names the field the word holds in the RESULT. Rules in post_rules move a word after assign reports it -- H1's move behind a title, P1's family-first fold, P6's attachment, and under opt-in policies the patronymic rotations, O6's two family names and `middle_as_family`'s fold, which rows carrying those policies reach as declared -- and six of assign's reports were worded from the role at assign, so 'Kim Min Do' under FAMILY_FIRST was told 'Do' was a middle name when it landed in the family. Assemble words them now. Checked over `_SWEPT_ROWS`, which the count test below walks too. Negative control, measured 2026-10-08 and re-measured 2026-10-10 over the rows after #624 (which rewrote six of them): this test over master's parser (26cdb891) fails 14 parses, every one a report worded at assign -- nine title-or-name join reports saying 'given' of a unit H1 moved to the family behind a title (`Attorney General of Minnesota`, `John of Bishop Prof.`, `Freiherr St MA`, `Freiherr von Bishop X.Y.Z.` and five more rows, as declared; on 2026-10-08 `St St née` stood where `Freiherr St John Smith MA` does), `Kim Min Do` under FAMILY_FIRST ('middle', family, P6), `de Kim Ma` under FAMILY_FIRST ('middle', given, P1), and the opt-in movers' rows as declared: `Van Ivan Petrovich` and `Van Ali Veli oglu` ('given', family, O1 and O2) and `Do Bishop Do` ('middle', family, O3). The sixth emitter, the comma report on a word after an empty head, is NOT in that count: master worded it 'the given name', a phrasing the tree no longer emits and so not one `_FIELD_CLAIMS` lists, and `, Ma Dr.` (H1 moving it to the family) is pinned instead by test_assign.py's verbatim detail. No row reached that shape until #626's review. #793's control, measured 2026-10-21: with the comma stage's listed-member report worded "read as a credential" again, this test fails 6 parses -- `John, Si.` and `John, Ma.` under each order, a word the listing form binds as a title. """ pn = _swept(case.id, order) for a in pn.ambiguities: m = _FIELD_CLAIM.search(a.detail) if m is None: continue landed = {t.role for t in a.tokens} assert landed <= _FIELD_CLAIMS[m[1]], ( f"{case.text!r}: {a.kind.value} says {m[0]!r} but its " f"tokens landed in {sorted(r.value for r in landed)}: " f"{a.detail!r}") def test_the_field_sweep_sees_the_claims_it_checks() -> None: """The sweep above passes vacuously if no detail matches `_FIELD_CLAIM` -- an emitter reworded out of the list, or rows dropped -- so the count of claims it checks is recorded here. Move it deliberately when a row or a phrasing moves, never to 0.""" claims = sum( _FIELD_CLAIM.search(a.detail) is not None for case, order in _SWEPT_ROWS for a in _swept(case.id, order).ambiguities) # 1156 -> 1159 on 2026-10-11: the JOHN PROF. MA row (one # suffix-or-name claim under each of the three orders); 1159 -> # 1162 on 2026-10-14: the Агриппина Младшая row (#661, one # given-or-family claim under each order); 1162 -> 1171 on # 2026-10-17: three #701 rows reporting suffix-or-name, under each # order; 1171 -> 1174 on 2026-10-17: the エリザベス二世 row (#707, # one given-or-family claim under each order); 1174 -> 1172 on # 2026-10-17: #718's re-pinned and added numeral rows (the lone # numeral rows lose their given-or-family claims; the 'John III' # row brings some back); 1172 -> 1191 on 2026-10-18: # #718's declined-numeral report, which names the field the letter # was read into, on those rows under each order; 1191 -> 1210 the # same day: the PR review's rows (the chain-taken, family-first, # lowercase and maiden-clause numerals); 1210 -> 1243 the same day: # #721's rows (#722); 1243 -> 1246 the same day: #727's # '田中さん v' row (one suffix-or-name claim under each order); # 1246 -> 1252 the same day: #656's two C12 rows behind a nickname # and a maiden clause (one suffix-or-name claim under each order); # 1252 -> 1255 the same day: the C12 row with a title in the part; # 1255 -> 1282 on 2026-10-21: #791's nine reporting rows (si and # chi), one suffix-or-name or given-or-family claim under each order; # 1282 -> 1302 the same day: the review's empty-head, caps and # family-first rows (two reports on 'Luo SI', and the family-first # rows, which carry their own policy and so claim under one order); # 1302 -> 1308 on 2026-10-21: #796's two case rows (Robert Capa, # John Smith CAPA), each claiming under the three orders; # 1308 -> 1314 the same day: #797's PA and John Pa rows, each # claiming one suffix-or-name report under the three orders; # 1314 -> 1341 the same day: #793's nine reporting rows ('John, # Si.', 'John, Ma.' and the #794 review's seven), one # suffix-or-name claim each under the three orders; 1341 -> 1353 # on 2026-10-23: #848's four Ing rows, one suffix-or-name claim # each under the three orders; 1353 -> 1356 the same day: the # review's 'Novak, Ing.' row; 1356 -> 1359 the same day: the second # review's 'Novak, ING.' row; 1359 -> 1362 the same day: #853's # 'Uddin, Md Karim' row, one suffix-or-name claim under the three # orders; 1362 -> 1365 the same day: the review's 'Uddin, Mr Md # Karim' row, the same claim behind a title; 1365 -> 1368 on # 2026-10-24: the glued-semicolon row 'John Smith, MA; PhD', one # suffix-or-name claim under the three orders; 1368 -> 1396 the # same day: #864's paired-initials and gc rows, nine claiming one # suffix-or-name report under each order and the switch-off row, # which carries its own policy, under one; 1396 -> 1399 the same # day: the review's 'Jane Doe MA R.T.' row, one claim under each; # 1399 -> 1400 the same day: #754's 'Beethoven Ludwig Maria van' # row, which carries its own policy, one particle-or-given claim; # 1400 -> 1403 the same day: #835's particle row 'Doe, Jane van # Mrs', one particle-or-given claim under each order; 1403 -> 1406 # the same day: #857's sa rows, after the word left every set (the # one reporting row, 'Smith, S.A.', claims under the orders it # reaches); 1406 -> 1415 the same day: the review's comma rows for # sa and the listed `ma` twins, three of them reporting; 1415 -> # 1445 the same day: #752's numeral rows (phase 2f) and the # 'Dr. Smith VI, John' row, which reports since, each claim under # the three orders; 1445 -> 1451 the same day: #752's two # joined-capitals rows that report (phase 2d), one suffix-or-name # claim under each order; 1451 -> 1493 the same day: #752's # generation-word rows (phase 2h, fils and the agnomens), the claims # their reports make under the three orders; 1493 -> 1508 the same # day: #890's five rows with a suffix word in front of an agnomen; # 1508 -> 1514 the same day: its numeral and capitals rows; 1514 # -> 1517 the same day: its carried-member rows; 1517 -> 1520 the # same day: its joined-surname row; 1520 -> 1523 the same day: the # floor's fils rows; 1523 -> 1529 the same day: its comma rows; # 1529 -> 1568 the same day, #891 (phase 2e) merged over it: the # split-credential rows, two run rows and the particle-chain row # (9), the split credential's own report on nine rows (27) and the # opening-credential row (3), each claim under the three orders; # 1568 -> 1583 the same day: #752's title phrase rows (phase 2i), the # five that report a title read as a name, under the three orders; # 1583 -> 1592 on 2026-10-26: Derek's answers on #894, three rows # that report ('Sgt Marshal', 'Marshal Yanda', 'Grace Dean'), the # one that stopped ('SMITH, DEAN MR.') offset by 'Grace Smith'; # 1592 -> 1586 the same day: 'Grace Smith' and 'Grace Dean Smith' # stopped reporting, no listed phrase holding 'grace'; 1586 -> 1592 # the same day: 'Justice Smith' and 'Major Smith', which report. # 1568 -> 1310 the same day, #785 (phase 2g): a listed member its # capitals settle in a name of mixed case reports nothing, which # takes the claims of the rows that reported one (each under # the orders it reaches); its new rows add their run reports, and # the rows rewritten in one case or lower case to keep reaching # their emitter add theirs back; 1310 -> 1316 the same day, the # review's rows for the comma's opening word and the chain's stop; # 1316 -> 1318 on 2026-10-26, the narrowing's caps-switch numeral row; # 1318 -> 1321 the same day, its Ph./D. row. # 1321 + 24 = 1345 on 2026-10-26, #894 (phase 2i) merged over #895 # (phase 2g): 2i's net change from 1568 (1592 - 1568), the two # changes moving disjoint claims. # 1345 -> 1366 on 2026-10-26, #810 (phase 2l): seven rows whose # bracketed member stays the nickname, one suffix-or-nickname or # given-or-family claim each under each order; 1366 -> 1369 the same # day, its review round: 'Jack (MA)' keeps N3's family and loses its # given-or-family claims (-3), 'John Smith (MA) VI' (+3) and the # dotted 'Jean (M.A) Smith', reported now (+3); 1369 -> 1381 the same # day, the twin reading (Derek): 'Jack (MA)' and 'Jack (M.A)' read # as 'Jack (PhD)' does, a given-or-family claim each (+6), and # 'John Smith (MA) née Jones' its two suffix-or-name claims (+6); # 1381 -> 1390 the same day, its review round's rows: a member # beside a dropped character (+3) and before 'Ma' (+6); 1390 -> 1384 # the same day, one pass in place of the twin reading (Derek): # 'Jack (MA)' and 'Jack (M.A)' lose their given-or-family claims and # 'John Smith (MA) née Jones' its two suffix-or-name claims (-12), # and the two clauses a second pass would settle keep the nickname, # reported (+6); 1384 -> 1390 the same day, its review round's rows # for a leading period and a name of no word (+6); 1390 -> 1393 the # same day, #752 phase 2j's 'Nasreen Sultana, MBBS', the credential # behind two name words reported suffix-or-name under three orders; # 1393 -> 1396 on 2026-10-27, #892's ', Mr. Jones vd' reporting the # suffix-or-name P6's attachment decides, under three orders; 1396 # -> 1403 the same day, the #916 review's rows (', Dr. Jones vd # Jr.' and ', Dr. Jones vd, PhD' under three orders, and ', Jane # van Jr.' under FAMILY_FIRST); 1403 -> 1412 the same day, #843's # three abbreviated-agnomen rows, one suffix-or-name claim each under # the three orders; 1412 -> 1415 the same day, #913's 'Burgess # Meredith', reporting title-or-name under three orders; 1415 -> # 1448 the same day, #913's royal title phrases: ten rows of bare # King, Queen, Prince and Princess now reporting title-or-name, and # 'Royal Smith', each under three orders; 1448 -> 1451 on 2026-10-28, # #913's 'Chancellor Smith', reporting title-or-name under three # orders. assert claims == 1451, ( f"the field sweep checks {claims} claims, recorded as 1451 on " f"2026-10-28") #: The kinds that report a fork between two readings of a word, so carry #: the reading not taken: three of the rest report a shape, and `order` #: is reserved (#752 phase 2m). _FORK_KINDS = frozenset(AmbiguityKind) - { AmbiguityKind.COMMA_STRUCTURE, AmbiguityKind.UNBALANCED_DELIMITER, AmbiguityKind.SEGMENTATION, AmbiguityKind.ORDER} #: The field a non-name reading names by what it is. _FIELD_OF_KIND = { **dict.fromkeys((ReadingKind.CREDENTIAL, ReadingKind.GENERATION, ReadingKind.AGNOMEN, ReadingKind.POST_NOMINAL), Role.SUFFIX), ReadingKind.TITLE: Role.TITLE, ReadingKind.PARTICLE: Role.FAMILY, } #: A plain word of each kind the oracle below writes in place of the #: referent, in the referent's case: a name word, an initial and a #: connective no rule reads as anything else. _STAND_INS = {ReadingKind.NAME: "Zork", ReadingKind.INITIAL: "Q", ReadingKind.CONJUNCTION: "y"} #: (row, order) pairs whose stand-in reads differently from the #: alternative because the stand-in changes a reading OTHER than the #: referent's, which the alternative holds fixed: a word behind it #: that the run or the numeral fork had read as a suffix becomes a name #: word ('John Smith VI Jones'), a word in a given part's credential #: run is absorbed as a particle never is ('Smith, John PhD de PhD #: van'), and a #: bracketed shape read as if written bare (rules/S1) has a stand-in #: that stays bracketed ('Andrew Perkins (X.Y.Z.)'). Measured #: 2026-10-26. _STAND_IN_REREADS = { ("John Smith Pharm D Jones", None), ("John Smith Pharm D Jones", 2), ("Smith, John PhD de PhD van", None), ("Smith, John PhD de PhD van", 1), ("Smith, John PhD de PhD van", 2), ("Smith, John PhD Jr. de la", None), ("Smith, John PhD Jr. de la", 1), ("Smith, John PhD Jr. de la", 2), ("Andrew Perkins (X.Y.Z.)", None), ("Andrew Perkins (X.Y.Z.)", 1), ("Andrew Perkins (X.Y.Z.)", 2), ("John Smith VI Ma", None), ("John Smith VI Jones", None), ("John Smith VI Jones", 2), } #: Reports whose deferred field assign could not name: the particle the #: chain took ends in a title a connective's join made ('Freiherr von #: Berg Dr. and Ed. Prof.', where group's joins left no name piece), so #: the alternative is withheld rather than guessed. _NO_ALTERNATIVE = {("Freiherr von Berg Dr. and Ed. Prof.", o) for o in (None, 1, 2)} def _order_key(order: tuple[Role, Role, Role] | None) -> int | None: return None if order is None else _INVARIANT_ORDERS.index(order) def _parser_for(case: Case | None, order: tuple[Role, Role, Role] | None) -> Parser: if case is not None and order is None: return _parser_for_case(case) return Parser(policy=Policy(name_order=order)) if order else Parser() def _stand_in(parser: Parser, pn: ParsedName, a: Ambiguity, kind: ReadingKind) -> tuple[str, Role]: """The name with the report's referents replaced by `kind`'s plain word, written in their case, and the field that word lands in.""" first, last = a.tokens[0].span, a.tokens[-1].span assert first is not None and last is not None written = pn.original[first.start:last.end] letters = [c for c in written if c.isalpha()] word = _STAND_INS[kind] if len(letters) > 1 and all(c.isupper() for c in letters): word = word.upper() elif letters and all(c.islower() for c in letters): word = word.lower() text = pn.original[:first.start] + word + pn.original[last.end:] landed = next(t.role for t in parser.parse(text).tokens if t.span is not None and t.span.start == first.start) return text, landed def _opens_its_part(pn: ParsedName, token: Token) -> bool: """No name word before `token` in its comma part.""" assert token.span is not None for t in pn.tokens: if t.span is None or t.span.start >= token.span.start: break if "," in pn.original[t.span.end:token.span.start]: continue if t.role in _NAME_FIELDS: return False return True def _check_alternatives(key: tuple[str, int | None], pn: ParsedName, parser: Parser) -> None: for a in pn.ambiguities: alt = a.alternative if a.kind not in _FORK_KINDS: assert alt is None, (key, a) continue if key in _NO_ALTERNATIVE and alt is None: continue if (alt is None and a.kind is AmbiguityKind.CONJUNCTION_OR_INITIAL and _opens_its_part(pn, a.tokens[0])): # a connective with no word before it to join: no field continue assert alt is not None, f"{key[0]!r}: {a.kind.value} has none" if alt.kind in _FIELD_OF_KIND: assert alt.role is _FIELD_OF_KIND[alt.kind], (key, a, alt) continue if a.kind is AmbiguityKind.GIVEN_OR_FAMILY: # rules/O5's other convention, which no stand-in can reach assert {a.tokens[0].role, alt.role} == {Role.GIVEN, Role.FAMILY}, ( key, a, alt) continue if key in _STAND_IN_REREADS: continue text, landed = _stand_in(parser, pn, a, alt.kind) if landed is a.tokens[0].role: continue assert landed is alt.role, ( f"{key[0]!r}: {a.kind.value} says its {alt.kind.value} " f"reading lands in {alt.role.value}, but {text!r} puts the " f"stand-in in {landed.value}") @pytest.mark.parametrize("case,order", _SWEPT_ROWS, ids=_SWEPT_IDS) def test_an_alternative_names_the_field_the_other_reading_gives( case: Case, order: tuple[Role, Role, Role] | None) -> None: """#752 phase 2m: every report of a fork carries the reading the parse did not take, and its role is the field the token would have had under it. A non-name reading names its field by what it is (a credential the suffix, a particle the family). A name reading is checked by an ORACLE: the referent is replaced by a plain word of the alternative's kind in its case ('Zork', 'Q', 'y') and the name parsed again under the same parser, where the stand-in must land in the alternative's field. The stand-in removes the word's own reading, not the position: where it still lands in the taken field the decision was the position's, which the oracle cannot undo, and the pair is not checked; the count test below records how many are. `_STAND_IN_REREADS` lists the pairs where the stand-in moves a NEIGHBOUR's reading too. Negative control, measured 2026-10-26: with the trailing peel's name alternative pinned to the family (`as_a_name`'s field in `_assign_main`), this test fails 63 parses, the family-first rows whose taken suffix would be the given or middle name; with `_name_alt` never deferring to the piece in front, 16 (15 before the first review's fixes, re-taken after them).""" _check_alternatives((case.text, _order_key(order)), _swept(case.id, order), _parser_for(case, order)) #: Shapes no case row holds, each found by the first review of #752 #: phase 2m with a field named wrong: a numeral or credential with no #: name word before it (the carve-out's name word uncounted), a numeral #: behind a particle P6 then attached (read as a live chain), a bound #: given name before a numeral with no family word left for its join, #: and a particle P6 attached behind a particle chain (named from its #: own field, not the chain's). The second review's six follow. _REVIEW_SHAPES = ("PhD VI", "Jr VI", "III VI", "Smith, John van VI", "John Smith van VI", "abdul VI", "Abu VI", "Dr. abdul VI", "Smith, Freiherr von Berg van", "Juan de la Vega van", # the second review's: a bound join that is no chain # though `abu` is a particle, a given-name title that # licenses the bound join, and a never-given particle # opening the part, which P1 folds with the word "Smith, Abu Mary van", "Smith, Abu Mary VI", "Sir abdul VI", "Dr. mc VI", "de VI", "Smith, Dr. de VI", # the PR review's: a given-name title licenses the # bound join only from the leading run, as group asks "abdul VI Sir.", "Sir Dr. abdul VI", "Dr. Sir abdul VI") @pytest.mark.parametrize("order", _INVARIANT_ORDERS) @pytest.mark.parametrize("text", _REVIEW_SHAPES) def test_an_alternative_names_the_field_in_the_reviews_shapes( text: str, order: tuple[Role, Role, Role] | None) -> None: """The oracle above over `_REVIEW_SHAPES`. Negative controls, measured 2026-10-26: with the first review's fixes reverted this failed 34 of its then 48 parses, and with the second review's, 14; with `as_a_name`'s given-name-title licence asked of every title token rather than the leading run, as the PR review found it, 3 of its 57.""" parser = _parser_for(None, order) pn = parser.parse(text) _check_alternatives((text, _order_key(order)), pn, parser) _FF = FAMILY_FIRST #: (text, policy fields) -> each report's (kind, alternative), the #: alternative as (reading kind, role, source) or None. One row per #: branch of `_vocab.suffix_reading` and `name_reading`, per emitter the #: oracle above cannot check (a credential run's, whose stand-in is #: read by the same run; a one-case connective's, whose stand-in is an #: initial too), per `_resolve_deferred` branch, and per fold #: `middle_as_family` makes in assemble. _EXPECTED_ALTERNATIVES: dict[tuple[str, tuple[tuple[str, Any], ...]], tuple[tuple[AmbiguityKind, tuple[ReadingKind, Role, ReadingSource] | None], ...]] = { # suffix_reading: agnomen, generation word, listed and shaped # numeral, other post-nominal word, listed and shaped credential ("Joao Neto", ()): ((AmbiguityKind.SUFFIX_OR_NAME, ( ReadingKind.AGNOMEN, Role.SUFFIX, ReadingSource.VOCABULARY)),), ("Jean Fils", ()): ((AmbiguityKind.SUFFIX_OR_NAME, ( ReadingKind.GENERATION, Role.SUFFIX, ReadingSource.VOCABULARY)),), ("John V", ()): ((AmbiguityKind.SUFFIX_OR_NAME, ( ReadingKind.GENERATION, Role.SUFFIX, ReadingSource.VOCABULARY)),), ("John Smith Vi", ()): ((AmbiguityKind.SUFFIX_OR_NAME, ( ReadingKind.GENERATION, Role.SUFFIX, ReadingSource.SHAPE)),), ("Esq Jr", ()): ((AmbiguityKind.SUFFIX_OR_NAME, ( ReadingKind.POST_NOMINAL, Role.SUFFIX, ReadingSource.VOCABULARY)),), ("Jack Ma", ()): ((AmbiguityKind.SUFFIX_OR_NAME, ( ReadingKind.CREDENTIAL, Role.SUFFIX, ReadingSource.VOCABULARY)),), ("Jack X.Y.Z.", ()): ((AmbiguityKind.SUFFIX_OR_NAME, ( ReadingKind.CREDENTIAL, Role.SUFFIX, ReadingSource.SHAPE)),), ("(MA) John Smith", ()): ((AmbiguityKind.SUFFIX_OR_NICKNAME, ( ReadingKind.CREDENTIAL, Role.SUFFIX, ReadingSource.VOCABULARY)),), # name_reading: an initial, a declared name, a word by position ("John Smith V", ()): ((AmbiguityKind.SUFFIX_OR_NAME, ( ReadingKind.INITIAL, Role.FAMILY, ReadingSource.SHAPE)),), ("JOHN SMITH MA", ()): ((AmbiguityKind.SUFFIX_OR_NAME, ( ReadingKind.NAME, Role.FAMILY, ReadingSource.DECLARED)),), ("John Smith PhD Jones", ()): ((AmbiguityKind.SUFFIX_OR_NAME, ( ReadingKind.NAME, Role.FAMILY, ReadingSource.POSITION)),), ("Smith", ()): ((AmbiguityKind.GIVEN_OR_FAMILY, ( ReadingKind.NAME, Role.FAMILY, ReadingSource.POSITION)),), ("Van Buren", ()): ((AmbiguityKind.PARTICLE_OR_GIVEN, ( ReadingKind.PARTICLE, Role.FAMILY, ReadingSource.VOCABULARY)),), ("Freiherr von Berg MA", ()): ((AmbiguityKind.PARTICLE_OR_GIVEN, ( ReadingKind.NAME, Role.GIVEN, ReadingSource.DECLARED)),), ("Dean Koontz", ()): ((AmbiguityKind.TITLE_OR_NAME, ( ReadingKind.TITLE, Role.TITLE, ReadingSource.VOCABULARY)),), # the credential runs' emitters, behind a credential and after a # comma, the maiden clause's, and the split credential's (S12) ("Doe, John MA Smith", ()): ((AmbiguityKind.SUFFIX_OR_NAME, ( ReadingKind.NAME, Role.MIDDLE, ReadingSource.POSITION)),), ("Dr. Smith Jr., AD", ()): ((AmbiguityKind.SUFFIX_OR_NAME, ( ReadingKind.NAME, Role.GIVEN, ReadingSource.POSITION)),), ("JOHN SMITH, VD MA", ()): ((AmbiguityKind.SUFFIX_OR_NAME, ( ReadingKind.NAME, Role.GIVEN, ReadingSource.POSITION)),), ("Smith, PhD Ma", ()): ((AmbiguityKind.SUFFIX_OR_NAME, ( ReadingKind.NAME, Role.GIVEN, ReadingSource.DECLARED)),), ("JANE DOE NEE SMITH MA", ()): ((AmbiguityKind.SUFFIX_OR_NAME, ( ReadingKind.NAME, Role.MAIDEN, ReadingSource.DECLARED)),), ("JOHN SMITH SAM E.", ()): ((AmbiguityKind.SUFFIX_OR_NAME, ( ReadingKind.NAME, Role.FAMILY, ReadingSource.POSITION)),), # a one-case connective, lone and inside a joined piece, with and # without a family comma on either side, and opening the name; in a # three-word name P3 joins nothing, and the letter keeps its field, # where a family comma's surname is the third word that lets it join ("JOSE E MARIA SANTOS", ()): ((AmbiguityKind.CONJUNCTION_OR_INITIAL, ( ReadingKind.CONJUNCTION, Role.GIVEN, ReadingSource.VOCABULARY)),), ("JOHN E SMITH", ()): ((AmbiguityKind.CONJUNCTION_OR_INITIAL, ( ReadingKind.CONJUNCTION, Role.MIDDLE, ReadingSource.VOCABULARY)),), ("JOHN E SMITH", (("name_order", _FF),)): ( (AmbiguityKind.CONJUNCTION_OR_INITIAL, ( ReadingKind.CONJUNCTION, Role.GIVEN, ReadingSource.VOCABULARY)),), ("SMITH, JOHN E MARY", ()): ((AmbiguityKind.CONJUNCTION_OR_INITIAL, ( ReadingKind.CONJUNCTION, Role.GIVEN, ReadingSource.VOCABULARY)),), ("MARIA DA SILVA E SOUZA", ()): ((AmbiguityKind.CONJUNCTION_OR_INITIAL, ( ReadingKind.CONJUNCTION, Role.FAMILY, ReadingSource.VOCABULARY)),), ("SMITH, JOHN E MARY ANN", ()): ((AmbiguityKind.CONJUNCTION_OR_INITIAL, ( ReadingKind.CONJUNCTION, Role.GIVEN, ReadingSource.VOCABULARY)),), ("SMITH E JONES, JOHN", ()): ((AmbiguityKind.CONJUNCTION_OR_INITIAL, ( ReadingKind.CONJUNCTION, Role.FAMILY, ReadingSource.VOCABULARY)),), ("E SMITH JONES", ()): ((AmbiguityKind.CONJUNCTION_OR_INITIAL, None),), # middle_as_family folds a middle alternative as it folds the middle ("Doe, John MA Smith", (("middle_as_family", True),)): ( (AmbiguityKind.SUFFIX_OR_NAME, ( ReadingKind.NAME, Role.FAMILY, ReadingSource.POSITION)),), ("John Smith PhD Jones", (("name_order", _FF),)): ( (AmbiguityKind.SUFFIX_OR_NAME, ( ReadingKind.NAME, Role.MIDDLE, ReadingSource.POSITION)),), ("John Smith PhD Jones", (("name_order", _FF), ("middle_as_family", True))): ( (AmbiguityKind.SUFFIX_OR_NAME, ( ReadingKind.NAME, Role.FAMILY, ReadingSource.POSITION)),), } @pytest.mark.parametrize("key", _EXPECTED_ALTERNATIVES, ids=[f"{t}{dict(p)}" for t, p in _EXPECTED_ALTERNATIVES]) def test_an_alternative_has_its_recorded_kind_role_and_source( key: tuple[str, tuple[tuple[str, Any], ...]]) -> None: """The reading kind and source are pinned only here and by the snapshot, so every branch that chooses them has a row.""" text, fields = key pn = Parser(policy=Policy(**dict(fields))).parse(text) got = tuple( (a.kind, None if a.alternative is None else ( a.alternative.kind, a.alternative.role, a.alternative.source)) for a in pn.ambiguities) assert got == _EXPECTED_ALTERNATIVES[key] def test_the_alternative_sweep_sees_the_fields_it_checks() -> None: """The oracle above skips a pair whose stand-in keeps the taken field, so a change that made every decision positional would pass it vacuously: the counts it checks and skips are recorded here.""" checked = skipped = 0 for case, order in _SWEPT_ROWS: if (case.text, _order_key(order)) in _STAND_IN_REREADS: continue pn = _swept(case.id, order) parser = _parser_for(case, order) for a in pn.ambiguities: alt = a.alternative if (alt is None or alt.kind not in _STAND_INS or a.kind is AmbiguityKind.GIVEN_OR_FAMILY): continue _, landed = _stand_in(parser, pn, a, alt.kind) if landed is a.tokens[0].role: skipped += 1 else: checked += 1 # measured 2026-10-26: 435 stand-ins land in the alternative's # field, 296 keep the taken one; 435 -> 436 the same day, the first # review's fix to P6's report ('Anna van der Berg van mc' under # FAMILY_FIRST, a reread no longer); 296 -> 299 the same day, the # PR review's MARIA DA SILVA E SOUZA row under each order, a # connective whose stand-in is an initial too, so its field is # pinned in _EXPECTED_ALTERNATIVES instead; 299 -> 302 the same day, # master's 'Nasreen Sultana, MBBS' row (#903) under each order, a # comma credential run's report, whose stand-in the run reads too; # 436 -> 437 on 2026-10-27, #892's ', Jane van Jr.' row under # FAMILY_FIRST, a particle-or-given report P6's attachment decides; # 437 -> 443 the same day, #843's two abbreviated-agnomen rows read # as the suffix, each under the three orders assert (checked, skipped) == (443, 302), (checked, skipped) def test_every_case_name_is_unique() -> None: """A repeated Case name silently shadows one row: pytest parametrizes the ids with a numeric suffix and the table reads as if both rows ran under one name. The review of PR #794 found comma_capital_si_is_a_suffix twice (the John Smith, SI row and the Wang, SI row, which carries a different reading), so this test is the check that would have caught it. Recorded negative control (2026-10-21): with the Wang, SI row renamed back to the duplicate, this test fails naming comma_capital_si_is_a_suffix.""" names = [case.id for case in CASES] repeated = sorted({name for name in names if names.count(name) > 1}) assert not repeated, f"Case names repeated in tests/v2/cases.py: {repeated}" #: Case.__post_init__'s shape checks, each probed for the message that #: identifies it. A row here is a Case that must fail to construct, not #: one that ever joins CASES -- unlike test_case above, this exercises #: the dataclass's own validation rather than the parser. One message #: is shared by three rows and deliberately: the residue arm of the #: purity check (2026-09-05) refuses every non-space ASCII character #: the comma and Latin-letter arms do not, so its probes differ in the #: character that trips it rather than in what they are told. @pytest.mark.parametrize("kwargs, match", [ pytest.param( dict(text="Beethoven, Ludwig van", shape=2, locale="nl_NL"), "needs the row's own policy", id="shape-plus-locale-has-no-order-to-check"), pytest.param( dict(text="田中さん, Jr. Ph. D.", shape=1), "cannot tag CJK text", id="cjk-text-is-corpus-cjk-jsonl-ground-not-a-shape"), pytest.param( dict(text="John Smith", shape=4), "declares no policy", id="family-first-shape-needs-a-family-first-policy"), pytest.param( dict(text="John Smith", shape=4, policy=Policy(name_order=FAMILY_FIRST_GIVEN_LAST)), "the row's policy declares FAMILY_FIRST_GIVEN_LAST", id="shape-order-disagrees-with-the-rows-own-policy"), pytest.param( dict(text="John Smith", shape=8), "unknown shape", id="shape-id-outside-the-inventory"), pytest.param( dict(text="田中太郎", shape=4, policy=Policy(name_order=FAMILY_FIRST)), "cannot tag CJK text", id="cjk-refusal-survives-a-matching-order"), pytest.param( dict(text="김민준, 지훈", shape=6), "refuses a comma", id="shape-6-refuses-a-comma"), pytest.param( dict(text="김민준 V", shape=6), "refuses a Latin letter", id="shape-6-refuses-a-latin-letter"), pytest.param( # The 2026-09-05 widening, and the text that motivated it: the # comma and Latin-letter arms above both said no of every # composed form anyone had written down, and this one carries # neither. It is a tolerated row today # (ja_honorific_with_a_period_no_comma); the tag it must not be # able to take back is what this probe holds. dict(text="田中さん 様.", shape=6), "refuses the ASCII", id="shape-6-refuses-a-trailing-period"), pytest.param( dict(text="김민준 2", shape=6), "refuses the ASCII", id="shape-6-refuses-a-digit"), pytest.param( # Refused for its parentheses, BEFORE the transcription test # this text would also fail -- the residue arm runs first, so # the message names the ASCII rather than the divider. The # nickname row this text belongs to (fix(#272)) stays contract # and untagged: the purity gate is a property of a SHAPE tag, # not of the corpus. dict(text="山田 太郎 (マイケル・ジャクソン)", shape=7), "refuses the ASCII", id="shape-7-refuses-ascii-parentheses"), pytest.param( dict(text="김민준·지훈", shape=6), "belongs to shape 7", id="shape-6-refuses-the-interpunct"), pytest.param( dict(text="マイケル・ジャクソン", shape=6), "belongs to shape 7", id="shape-6-refuses-the-nakaguro"), pytest.param( dict(text="マイケルジャクソン", shape=6), "belongs to shape 7", id="shape-6-refuses-wholly-katakana-text"), pytest.param( dict(text="John Smith", shape=6), "requires a classified codepoint", id="shape-6-requires-cjk-text"), pytest.param( dict(text="김민준", shape=7), r"requires U\+00B7 or wholly-katakana", id="shape-7-requires-a-divider-or-katakana"), pytest.param( dict(text="김민준, 지훈", shape=7), "refuses a comma", id="shape-7-refuses-a-comma-too"), pytest.param( dict(text="高橋・一郎", shape=7), r"requires U\+00B7 or wholly-katakana", id="shape-7-refuses-a-nakaguro-on-non-katakana-text"), pytest.param( dict(text="김민준", shape=6, locale="zh"), "carry neither policy nor locale", id="shape-6-refuses-a-locale"), pytest.param( # The other arm of the same `or`. The locale row above passes # with `self.policy is not None` deleted, so without this one # a shape-6 row could carry a policy fork -- the refusal's own # comment says a stray policy on shapes 6/7 would silently do # nothing, which is exactly why it must not be admitted. dict(text="김민준", shape=6, policy=Policy(middle_as_family=True)), "carry neither policy nor locale", id="shape-6-refuses-a-policy"), pytest.param( dict(text="김민준", shape=6, tolerated=True), "mutually exclusive with shape", id="tolerated-and-shape-are-mutually-exclusive"), pytest.param( dict(text="John Smith", tolerated=True), "tolerated requires CJK text", id="tolerated-requires-cjk-text"), ]) def test_case_construction_rejects_a_bad_shape_tag( kwargs: dict[str, Any], match: str) -> None: with pytest.raises(ValueError, match=match): Case(id="probe", expect={}, **kwargs) #: The constructions the battery above proves nothing rejects: a pure #: shape-6 row, a Han shape-6 row divided by a nakaguro that does NOT #: mark it as source order (U+30FB is not a divider outside katakana #: -- decisions/T3; this is family-first per #: ja_nakaguro_han_takes_the_han_order), an interpunct-divided shape-7 #: row, a SPACED wholly-katakana shape-7 row (the subtler admission -- #: a transcription with no U+00B7 at all is still a shape, not a #: demotion, as long as every non-space character is katakana), a #: SPACED HONORIFIC written without the period the residue arm refuses #: (the boundary the 2026-09-05 widening had to leave standing: what #: the demoted text loses is its period, not its arrangement), and a #: tolerated row built from the SAME text a shape probe above refuses #: as a comma -- the boundary reading the pair as intended: what a #: shape tag refuses, tolerated=True admits. Each must construct #: cleanly -- the purity rule is a REFUSAL rule, not a requirement #: that admits nothing. @pytest.mark.parametrize("kwargs", [ pytest.param(dict(text="김민준", shape=6), id="pure-shape-6-constructs"), pytest.param(dict(text="田中さん 様", shape=6), id="spaced-honorific-without-a-period-constructs"), pytest.param(dict(text="高橋・一郎", shape=6), id="han-nakaguro-shape-6-constructs"), pytest.param(dict(text="威廉·莎士比亚", shape=7), id="interpunct-shape-7-constructs"), pytest.param(dict(text="マイケル ジャクソン", shape=7), id="spaced-wholly-katakana-shape-7-constructs"), pytest.param(dict(text="김민준, 지훈", tolerated=True), id="tolerated-accepts-the-comma-text-a-shape-tag-refuses"), ]) def test_case_construction_accepts_a_valid_shape_or_tolerated_tag( kwargs: dict[str, Any]) -> None: case = Case(id="probe", expect={}, **kwargs) if "shape" in kwargs: assert case.shape == kwargs["shape"] else: assert case.tolerated is True �����������������derek73-python-nameparser-456b024/tests/v2/test_cldr_agreement.py�����������������������������������0000664�0000000�0000000�00000066514�15273032717�0025000�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""The agreement ratchets, and the comparison rules they rest on. tools/differential/cldr_agreement.py decides, for each rendering and each role, whether nameparser agrees with the source's stated fields (CLDR's, and the GND's, Wikidata's and the conventions table's written in CLDR's vocabulary). The rules are pinned here first, each with the wrong answer the plausible alternative gives recorded beside it; the ratchet test at the end then holds each source's corpus to its recorded disagreements in tools/differential/, one record per source. tools/ is not a package, so the module is loaded by path, as _differential_fixtures.py loads compare.py. """ import contextlib import functools import importlib.util import io import json import sys from collections import Counter from pathlib import Path from types import ModuleType from typing import Any import pytest from nameparser import locales, parse _TOOLS = Path(__file__).parents[2] / "tools" / "differential" def _load() -> ModuleType: spec = importlib.util.spec_from_file_location( "cldr_agreement", _TOOLS / "cldr_agreement.py") assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module spec.loader.exec_module(module) return module agreement = _load() @pytest.mark.parametrize(("value", "text", "result"), [ ("Falke", "Falke, Iris", True), # commas stripped from the tokens ("Anna Cornelia", "Prof. Anna Cornelia Wolf", True), # Token containment, not substring: the substring rule says True here # and would expect a surname the rendering dropped. ("Wolf", "Wolfgang Smith", False), ("佐藤", "佐藤グレース恵子", True), # unspaced: substring ("中村", "中村 敬子", True), # ideographic space is a space # A no-break space is a space too. Treated as "no space", the text # falls to substring rules and finds `Ann` inside `Anna`. ("Ann", "Anna Smith", False), ("", "Iris Falke", False), # an empty field is never present ]) def test_occurs(value: str, text: str, result: bool) -> None: assert agreement.occurs(value, text) is result def test_expected_roles_drop_fields_the_rendering_omits() -> None: # Spanish given-first omits the second surname and the title stays. fields = {"title": "Sr.", "given": "Miguel Ángel", "given2": "Juan Antonio", "surname-core": "Pablo", "surname2": "Pérez", "generation": "II"} roles = agreement.expected_roles(fields, "Sr. Miguel Ángel Juan Antonio Pablo II") assert roles == {"title": "Sr.", "given_names": "Miguel Ángel Juan Antonio", "family": "Pablo", "suffix": "II", "family_particles": "", "family_base": "Pablo", # stated, so expected empty where the rendering omits it "surname2": ""} def test_a_pending_field_is_scored_inside_its_merged_role_until_parsed_name_has_it( ) -> None: # surname2 inside family, as CLDR merges it; a patronymic inside # given_names, never family -- so an Icelandic name expects no family. text = "Vigdís Finnbogadóttir" roles = agreement.expected_roles({"given": "Vigdís", "patronymic": "Finnbogadóttir"}, text) assert (roles["given_names"], roles["family"]) == (text, "") assert roles["patronymic"] == "Finnbogadóttir" roles = agreement.expected_roles( {"given": "Gabriel", "surname": "García", "surname2": "Márquez"}, "Gabriel García Márquez") assert (roles["family"], roles["surname2"]) == ("García Márquez", "Márquez") # Not scored on its own while ParsedName lacks the attribute ... assert "surname2" not in agreement.got_roles(parse("Gabriel García Márquez")) rows = [{"file": "Q1", "label": "x", "order": "givenFirst", "shape": None, "text": "Gabriel García Márquez", "fields": {"given": "Gabriel", "surname": "García", "surname2": "Márquez"}}] assert "surname2" not in agreement.disagreements(rows).get("Q1/x/givenFirst", {}) @pytest.mark.parametrize("field", sorted(agreement.PENDING_ROLES)) def test_each_pending_field_is_scored_inside_the_role_it_names(field: str) -> None: # PENDING_ROLES names the merged role; expected_roles must agree. roles = agreement.expected_roles({"given": "Ana", "surname": "Ruiz", field: "Vila"}, "Ana Vila Ruiz") merged = agreement.PENDING_ROLES[field] assert "Vila" in roles[merged].split() assert not [r for r in ("given_names", "family") if r != merged and "Vila" in roles[r].split()] @functools.cache def _summary_run(*args: str) -> tuple[int, str]: """One `main(args)` run and what it printed, shared by the tests that read the summary: each run scores every source (about 0.7 s).""" out = io.StringIO() with contextlib.redirect_stdout(out): code = agreement.main(list(args)) return code, out.getvalue() 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 def test_a_role_parsed_name_stops_answering_still_fails_loudly( monkeypatch: pytest.MonkeyPatch) -> None: # Only a PENDING_ROLES field may go unanswered; any other gap is a bug. rows = [{"file": "Q1", "label": "x", "order": "givenFirst", "shape": None, "text": "Ana Ruiz", "fields": {"given": "Ana", "surname": "Ruiz"}}] real = agreement.got_roles monkeypatch.setattr(agreement, "got_roles", lambda name: { k: v for k, v in real(name).items() if k != "family"}) with pytest.raises(KeyError): agreement.disagreements(rows) def test_a_pending_field_is_scored_once_parsed_name_answers_it() -> None: class WithSurname2: title = given_names = suffix = family_particles = family_base = "" family = "García Márquez" surname2 = "García" assert agreement.got_roles(WithSurname2())["surname2"] == "García" # type: ignore[arg-type] def test_an_honorific_is_scored_inside_the_suffix() -> None: """A word of address written after the name (the conventions source's `honorific`, #686) is a suffix, as nameparser reads さん.""" roles = agreement.expected_roles({"given": "Murat", "honorific": "Bey"}, "Murat Bey") assert roles["suffix"] == "Bey" assert roles["title"] == "" assert roles["given_names"] == "Murat" assert roles["family"] == "" def test_a_field_no_rule_reads_raises() -> None: """A mistyped field would be expected in no role, and a row holding it would pass as agreeing with an empty parse.""" with pytest.raises(ValueError, match="honorfic"): agreement.expected_roles({"given": "Murat", "honorfic": "Bey"}, "Murat Bey") def test_expected_roles_compare_particles_only_where_cldr_splits_them() -> None: plain = agreement.expected_roles({"given": "Iris", "surname": "Falke"}, "Iris Falke") assert set(plain) == {"title", "given_names", "family", "suffix"} split = agreement.expected_roles( {"given": "Paul", "surname-prefix": "von", "surname-core": "Fischer"}, "Paul von Fischer") assert (split["family"], split["family_particles"], split["family_base"]) == ( "von Fischer", "von", "Fischer") def test_expected_roles_count_a_word_shared_by_two_fields_in_both() -> None: roles = agreement.expected_roles( {"given": "Syed Sadiq", "surname": "Syed Hassan"}, "Syed Sadiq Syed Hassan") assert (roles["given_names"], roles["family"]) == ("Syed Sadiq", "Syed Hassan") @pytest.mark.parametrize(("expected", "got", "text", "result"), [ ("Pablo Pérez", "Pérez Pablo", "Pablo Pérez Miguel", True), # order-free ("Pablo", "Pablo Pérez", "Pablo Pérez Miguel", False), # a word too many ("", "", "Iris Falke", True), # absent on both sides agrees ("", "Dr.", "Dr. Iris Falke", False), # an invented title disagrees ("jr. MdB", "jr., MdB", "Paul Fischer jr., MdB", True), # commas ignored ("민규 헨리", "민규헨리", "김민규헨리", True), # unspaced text ]) 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: 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 def _row(text: str, shape: int | None, **fields: str) -> dict[str, object]: return {"file": "xx", "label": "nativeG", "order": "givenFirst", "text": text, "shape": shape, "fields": fields} def test_a_shape_4_rendering_is_parsed_family_first() -> None: # The recorder and the checker share parse_rendering, so they cannot # parse a rendering two ways. A lone word under FAMILY_FIRST reads as # the family name, so CLDR's given name disagrees -- by design. found = agreement.disagreements([_row("Lena", 4, given="Lena")]) assert found == {"xx/nativeG/givenFirst": { "text": "Lena", "given_names": {"expected": "Lena", "got": ""}, "family": {"expected": "", "got": "Lena"}}} assert agreement.disagreements([_row("Lena", None, given="Lena")]) == {} _RECORDED = {"a/nativeG/givenFirst": {"text": "A", "family": {"expected": "X", "got": "Y"}}, "b/nativeG/givenFirst": {"text": "B", "title": {"expected": "", "got": "Dr."}}} def test_changes_is_empty_when_nothing_moved() -> None: assert agreement.changes(_RECORDED, _RECORDED, set(_RECORDED)) == [] def test_changes_reports_a_regression() -> None: current = {**_RECORDED, "c/nativeG/givenFirst": { "text": "C", "family": {"expected": "Z", "got": ""}}} [message] = agreement.changes(_RECORDED, current, set(current)) assert message.startswith("regression: c/nativeG/givenFirst family") def test_changes_reports_progress_that_must_be_recorded() -> None: current = {"a/nativeG/givenFirst": _RECORDED["a/nativeG/givenFirst"]} [message] = agreement.changes(_RECORDED, current, set(_RECORDED)) assert message.startswith("progress: b/nativeG/givenFirst title") def test_changes_reports_a_changed_wrong_answer() -> None: current = {**_RECORDED, "a/nativeG/givenFirst": { "text": "A", "family": {"expected": "X", "got": "W"}}} [message] = agreement.changes(_RECORDED, current, set(current)) assert message.startswith("changed: a/nativeG/givenFirst family") assert "'Y'" in message and "'W'" in message def test_changes_reports_a_rendering_that_is_gone() -> None: # A rebuild that drops a rendering is not progress. [message] = agreement.changes( _RECORDED, {"a/nativeG/givenFirst": _RECORDED["a/nativeG/givenFirst"]}, {"a/nativeG/givenFirst"}) assert message.startswith("gone: b/nativeG/givenFirst") #: Each source's corpus file, and a floor its distinct texts must #: exceed: a truncated file would otherwise pass both checks below, and #: an emptied one re-recorded would match its emptied record. _CORPORA = {"CLDR": ("corpus_cldr.jsonl", 1000), "GND": ("corpus_gnd.jsonl", 500), "Wikidata": ("corpus_wikidata.jsonl", 500), "Conventions": ("corpus_conventions.jsonl", 25), "Wikidata native": ("corpus_wikidata_native.jsonl", 700)} #: Sources whose fields file states only some of its corpus's names: the #: conventions table leaves a row without fields where no field can state #: it, and says why in the row's `unstated`. _PARTIAL = frozenset({"Conventions"}) def test_every_source_has_a_corpus_file() -> None: assert set(_CORPORA) == set(agreement.sources()) @pytest.mark.parametrize("source", sorted(_CORPORA)) def test_the_fields_file_holds_the_corpus_renderings(source: str) -> None: fields, _ = agreement.sources()[source] corpus, least = _CORPORA[source] from_fields = {(row["text"], row["shape"]) for row in agreement.load_rows(fields)} from_corpus = set() for line in (_TOOLS / corpus).read_text(encoding="utf-8").splitlines(): if line.strip(): entry = json.loads(line) from_corpus.add((entry, None) if isinstance(entry, str) else (entry["name"], entry["shape"])) if source in _PARTIAL: assert from_fields <= from_corpus else: assert from_corpus == from_fields assert len(from_fields) > least, f"the {source} corpus is missing or truncated" @pytest.mark.parametrize("source", sorted(set(_CORPORA) - {"CLDR"})) def test_every_committed_field_occurs_in_its_rendering(source: str) -> None: # A field that did not occur would be expected EMPTY by the # comparison, a disagreement no parser fix could resolve. CLDR is # exempt by design: its formats drop fields by locale and order, and # it states fields (locale, given-informal) no rendering writes. fields, _ = agreement.sources()[source] missing = [(row["text"], value) for row in agreement.load_rows(fields) for value in row["fields"].values() if not agreement.occurs(value, row["text"])] assert not missing @pytest.mark.parametrize("source", sorted(set(_CORPORA) - {"CLDR"})) def test_the_fields_partition_their_rendering(source: str) -> None: """Every word of a rendering is stated by exactly one field, so a part left out is a failure here rather than an expectation of empty. CLDR is exempt for the reason above; the other sources' builders claim every word, and the conventions table is written to.""" fields, _ = agreement.sources()[source] bad = [] for row in agreement.load_rows(fields): values = list(row["fields"].values()) if agreement._has_space(row["text"]): ok = (Counter(w for v in values for w in agreement._words(v)) == Counter(agreement._words(row["text"]))) else: ok = (sum(len(agreement._squash(v)) for v in values) == len(agreement._squash(row["text"]))) if not ok: bad.append((row["text"], row["fields"])) assert not bad @pytest.mark.parametrize("source", sorted(_CORPORA)) def test_nameparser_still_agrees_with_each_source_where_it_did(source: str) -> None: fields, record = agreement.sources()[source] rows = agreement.load_rows(fields) problems = agreement.changes( agreement.read_record(record), agreement.disagreements(rows), {agreement.rendering_key(row) for row in rows}) assert not problems, "\n".join([ *problems[:40], f"{len(problems)} change(s) against tools/differential/{record.name}.", f"If every one is intended, re-record with `{agreement.RECORD_COMMAND}` " "and say why in the commit."]) def test_the_summary_counts_each_role_and_matches_the_record() -> None: code, out = _summary_run() assert code == 0, out for role in agreement.ROLES: assert any(line.split()[0] == role and "agree" in line for line in out.splitlines() if line.strip()), role for source in agreement.sources(): assert f"{source} view matches the record" in out for source in agreement.locale_sources(): assert f"{source} locale view matches the record" in out # each locale view prints its own default count, not another source's native = out.split("Wikidata native locale view", 1)[1] rows = agreement.load_rows(agreement.NATIVE_FIELDS) wrong = agreement.disagreements(rows) zh = agreement.locale_rows(rows, agreement.locale_sources()["Wikidata native"], "ZH") before = sum(agreement.rendering_key(r) not in wrong for r in zh) assert f"ZH zh-cn, zh-tw: {before}/{len(zh)} default" in native @pytest.mark.parametrize("text", [ "ستوبر، زيزينيا هاميش", # Arabic comma, as nameparser's COMMA_CHARS has "Smith, John", # fullwidth comma glued to the word ]) def test_a_word_beside_a_non_ascii_comma_still_occurs(text: str) -> None: # Only ',' was stripped at first, so the word glued to the comma did # not occur and CLDR's field was expected empty -- a false # disagreement the parser could never resolve. first = text.split()[0].rstrip("،,") assert agreement.occurs(first, text) def test_an_unspaced_role_is_expected_in_text_order() -> None: # CLDR's field order is given, given2; this rendering writes given2 # first. Joined in field order, the parser's correct 'ローズ セシリア' # disagreed with 'セシリア ローズ'. roles = agreement.expected_roles( {"given": "セシリア", "given2": "ローズ", "surname": "ブラウン"}, "ブラウン・ローズ・セシリア") assert agreement.same(roles["given_names"], "ローズ セシリア", "ブラウン・ローズ・セシリア") # -- the locale view (#672) -------------------------------------------------- def test_every_locale_pack_is_measured_or_listed_as_having_no_cldr_file() -> None: # A new pack must add its CLDR files to LOCALE_FILES or say it has # none; otherwise it goes unmeasured by omission. mapped = set(agreement.LOCALE_FILES.values()) assert not mapped & agreement.NO_CLDR assert mapped | agreement.NO_CLDR == {code.upper() for code in locales.available()} def test_every_mapped_cldr_file_and_native_stratum_has_renderings() -> None: # A misspelled file or stratum in the tables would measure nothing and pass. assert set(agreement.LOCALE_FILES) <= {row["file"] for row in agreement.load_rows()} native = agreement.load_rows(agreement.NATIVE_FIELDS) assert set(agreement.NATIVE_LABELS) <= {row["label"] for row in native} _CLDR, _NATIVE = (agreement.locale_sources()[name] for name in ("CLDR", "Wikidata native")) _ZH_ROW = {"file": "Q1", "label": "zh-cn", "order": "surnameFirst", "text": "王小明", "shape": None, "fields": {"surname": "王", "given": "小明"}} def test_the_locale_view_parses_with_the_pack() -> None: # A view that fell back to the default parser would record the # default's disagreements and still pass its own ratchet. 王小明 is # one unspaced Han token: only ZH segments it. assert agreement.disagreements([_ZH_ROW], "ZH") == {} assert agreement.disagreements([_ZH_ROW]) != {} def test_each_source_finds_a_renderings_pack_in_its_own_key() -> None: # CLDR by its file, the native-script source by its stratum: a QID # names no language, and a CLDR label names no stratum. assert _CLDR.pack_of("zh/nativeG/givenFirst") == "ZH" assert _NATIVE.pack_of("Q1/zh-cn/surnameFirst") == "ZH" assert _NATIVE.pack_of("Q1/ja-jp/surnameFirst") == "JA" assert _NATIVE.pack_of("Q1/ko-kr/surnameFirst") is None assert _CLDR.pack_of("Q1/zh-cn/surnameFirst") is None assert agreement.locale_rows([_ZH_ROW], _NATIVE, "ZH") == [_ZH_ROW] def test_a_pack_parser_keeps_the_family_first_order_of_its_shape() -> None: # Latin text: no script decides the order, so only the FAMILY_FIRST # base the pack is applied over can read Ivanov as the family. name = agreement.parse_rendering("Ivanov Ivan", 4, "RU") assert (name.family, name.given) == ("Ivanov", "Ivan") def test_a_recorded_entry_no_pack_reads_is_reported_gone() -> None: # No pack's check reads such an entry, so without this it would sit # in the record unguarded -- and survive every re-record. [message] = agreement.unmapped({"zh/nativeG/givenFirst": {}, "xx/nativeG/sorting": {}}, _CLDR) assert message.startswith("gone: xx/nativeG/sorting") [message] = agreement.unmapped({"Q1/zh-cn/surnameFirst": {}, "Q2/xx-yy/surnameFirst": {}}, _NATIVE) assert message.startswith("gone: Q2/xx-yy/surnameFirst") for name, source in agreement.locale_sources().items(): assert agreement.unmapped(agreement.read_record(source.record), source) == [], name def test_a_recorded_rendering_that_is_gone_is_reported_by_its_packs_check() -> None: recorded: dict[str, dict[str, Any]] = {"Q2/zh-cn/surnameFirst": {"text": "王"}} [message] = agreement.locale_changes([_ZH_ROW], _NATIVE, "ZH", recorded) assert message.startswith("gone: Q2/zh-cn/surnameFirst") def _redirect(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: """Point every record, each source's and each locale one, into tmp_path through sources() and locale_sources() themselves, so a source added later cannot be written into the tree by a test.""" redirected = {name: (fields, tmp_path / f"{name}.json") for name, (fields, _) in agreement.sources().items()} redirected_locale = {name: source._replace(record=tmp_path / f"{name}.locales.json") for name, source in agreement.locale_sources().items()} monkeypatch.setattr(agreement, "sources", lambda: redirected) monkeypatch.setattr(agreement, "locale_sources", lambda: redirected_locale) return redirected_locale def test_a_re_record_drops_entries_no_pack_reads( tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: # The carry-over that keeps an unmeasured pack's entries must not # keep an unmapped file's too. real, real_locale = agreement.sources(), agreement.locale_sources() record = _redirect(tmp_path, monkeypatch)["CLDR"].record agreement.write_record({"xx/nativeG/sorting": {"text": "X"}}, record) assert agreement.main(["--record"]) == 0 assert "xx/nativeG/sorting" not in agreement.read_record(record) # and the re-record wrote every source's record, not CLDR's alone, and # every locale record: each its committed record's measured packs (an # unmeasured pack's entries are carried over only from the old record, # which here holds none of them) for name, (_, written) in agreement.sources().items(): assert agreement.read_record(written) == agreement.read_record(real[name][1]), name for name, source in agreement.locale_sources().items(): committed = real_locale[name] measured = {k: v for k, v in agreement.read_record(committed.record).items() if agreement.pack_available(committed.pack_of(k) or "")} assert agreement.read_record(source.record) == measured, name def test_every_locale_record_is_read() -> None: # Dropping a source from locale_sources() would leave its committed # record read by nothing, and its rows' ratchet would simply vanish # from the parametrization. records = {source.record.name for source in agreement.locale_sources().values()} assert records == {path.name for path in _TOOLS.glob("*_agreement_locales.json")} assert set(agreement.locale_sources()) <= set(agreement.sources()) @pytest.mark.parametrize("source", sorted(agreement.locale_sources())) def test_a_re_record_keeps_an_unmeasured_packs_entries( source: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: # Without the ja extra, --record must carry the JA entries over, or a # run without it would erase them and only CI's ja-extra job would see. committed = agreement.locale_sources()[source] redirected = committed._replace(record=tmp_path / committed.record.name) redirected.record.write_text(committed.record.read_text(encoding="utf-8"), encoding="utf-8") monkeypatch.setattr(agreement, "_EXTRAS", {"JA": "a_module_that_is_not_installed"}) rows = agreement.load_rows(agreement.sources()[source][0]) agreement._record_locale(source, rows, redirected) old, new = agreement.read_record(committed.record), agreement.read_record(redirected.record) kept = {k: v for k, v in old.items() if committed.pack_of(k) == "JA"} assert kept, f"{source} records no JA entry: the carry-over is not exercised" assert {k: v for k, v in new.items() if committed.pack_of(k) == "JA"} == kept assert "JA not measured" in capsys.readouterr().out def test_the_summary_still_checks_a_pack_with_no_renderings_left( capsys: pytest.CaptureFixture[str]) -> None: # Its recorded entries are gone, and must be reported as such. rows = [r for r in agreement.load_rows() if _CLDR.pack_of(agreement.rendering_key(r)) != "ZH"] problems = agreement._locale_summary("CLDR", rows, _CLDR, {}) assert problems and all(p.startswith("gone:") for p in problems) @pytest.mark.parametrize(("source", "pack"), [ pytest.param(source, pack, marks=pytest.mark.skipif( not agreement.pack_available(pack), reason=f"needs {agreement._EXTRAS.get(pack)}; CI's ja-extra job runs it")) for source in sorted(agreement.locale_sources()) for pack in agreement._packs()]) def test_each_pack_still_agrees_with_each_source_where_it_did(source: str, pack: str) -> None: locale = agreement.locale_sources()[source] problems = agreement.locale_changes( agreement.load_rows(agreement.sources()[source][0]), locale, pack, agreement.read_record(locale.record)) assert not problems, "\n".join([ *problems[:40], f"{len(problems)} change(s) against " f"tools/differential/{locale.record.name} for {pack}.", f"If every one is intended, re-record with `{agreement.RECORD_COMMAND}` " "and say why in the commit."]) def test_the_split_view_reports_a_compound_given_name_the_merged_role_passes() -> None: # #702: Kim Min Ho reads given Min, middle Ho; the merged role agrees. row = _row("Kim Min Ho", 4, given="Min Ho", surname="Kim") assert agreement.disagreements([row]) == {} assert agreement.split_disagreements([row]) == {"xx/nativeG/givenFirst": { "text": "Kim Min Ho", "given": {"expected": "Min Ho", "got": "Min"}, "middle": {"expected": "", "got": "Ho"}}} # a split the source states and the parser reads the same way is silent agreed = _row("Ana Maria Ruiz", None, given="Ana", given2="Maria", surname="Ruiz") assert agreement.split_disagreements([agreed]) == {} def test_the_split_view_skips_a_rendering_whose_merged_role_already_disagrees() -> None: # reported once, by the ratcheted role, not again by the split view row = _row("Kim Min Ho", 4, given="Zed Qux", surname="Kim") assert agreement.disagreements([row]) assert agreement.split_disagreements([row]) == {} def test_the_split_view_skips_a_rendering_that_states_a_patronymic() -> None: # the merged role holds the patronymic; the split has nowhere to put it text = "Anna Ivanovna Petrova" row = _row(text, None, given="Anna", patronymic="Ivanovna", surname="Petrova") assert agreement.parse_rendering(text, None).middle == "Ivanovna" assert agreement.split_disagreements([row]) == {} def test_the_split_view_is_reported_for_exactly_the_sources_that_state_it() -> None: # GND and Wikidata record every given name in one field: never scored on it assert agreement.SPLIT_SOURCES == {"CLDR", "Conventions"} assert agreement.SPLIT_SOURCES <= set(agreement.sources()) out = _summary_run()[1] assert {line.split(" given/middle split")[0] for line in out.splitlines() if "given/middle split" in line} == agreement.SPLIT_SOURCES assert "expected '" not in out # no listing without --split def test_split_lists_the_view_s_renderings() -> None: out = _summary_run("--split")[1] assert "conventions/Kim Min Ho/FAMILY_FIRST: 'Kim Min Ho': given expected " \ "'Min Ho', got 'Min'" in out ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_cldr_corpus.py��������������������������������������0000664�0000000�0000000�00000007345�15273032717�0024341�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Offline checks on tools/differential/build_cldr_corpus.py. The build script is the only part of the CLDR corpus that touches the network, so its parsing of CLDR's personNameTest format is pinned here on a hand-written sample in that format. tools/ is not a package, so the module is loaded by path, as _differential_fixtures.py loads compare.py. """ import importlib.util import json import sys from pathlib import Path from types import ModuleType import pytest _PATH = Path(__file__).parents[2] / "tools" / "differential" / "build_cldr_corpus.py" def _load() -> ModuleType: spec = importlib.util.spec_from_file_location("build_cldr_corpus", _PATH) assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module spec.loader.exec_module(module) return module build = _load() _SAMPLE = """# Test data for Person Name Data # SPDX-License-Identifier: Unicode-3.0 enum ; field ; title, given, surname # nativeGS name ; given; Iris name ; surname; Falke name ; locale; de_AQ expectedResult; Iris Falke parameters; givenFirst; long; referring; formal parameters; givenFirst; medium; referring; formal expectedResult; Falke, Iris parameters; surnameFirst; long; referring; formal parameters; sorting; long; referring; formal endName # nativeG name ; given; Lena name ; locale; de_AQ expectedResult; Lena parameters; givenFirst; long; referring; formal parameters; surnameFirst; long; referring; formal expectedResult; Lena Lena parameters; sorting; long; referring; informal endName """ def test_parse_test_file_keeps_only_the_long_referring_formal_renderings() -> None: rows = build.parse_test_file(_SAMPLE, "de") assert [(r["label"], r["order"], r["text"]) for r in rows] == [ ("nativeGS", "givenFirst", "Iris Falke"), ("nativeGS", "surnameFirst", "Falke, Iris"), ("nativeGS", "sorting", "Falke, Iris"), ("nativeG", "givenFirst", "Lena"), ("nativeG", "surnameFirst", "Lena"), ] # the medium and the informal parameters are not selected assert rows[0]["fields"] == {"given": "Iris", "surname": "Falke", "locale": "de_AQ"} assert rows[0]["file"] == "de" @pytest.mark.parametrize(("order", "text", "shape"), [ ("givenFirst", "Iris Falke", None), ("surnameFirst", "Falke, Iris", 4), # declared surname-first, comma or not ("surnameFirst", "Lena", 4), ("sorting", "Falke, Iris", None), # the comma carries the order ("sorting", "Adler Irene", 4), # a comma-less sorting form is surname-first ]) def test_shape_for_tags_surname_first_renderings(order: str, text: str, shape: int | None) -> None: assert build.shape_for(order, text) == shape def test_parse_test_file_refuses_a_name_with_no_label() -> None: with pytest.raises(ValueError, match="no label"): build.parse_test_file("name ; given; X\nexpectedResult; X\n" "parameters; givenFirst; long; referring; formal\nendName\n", "xx") def test_parse_test_file_refuses_an_unknown_line_kind() -> None: with pytest.raises(ValueError, match="unknown line kind"): build.parse_test_file("# nativeG\nnmae ; given; X\n", "xx") def test_corpus_lines_dedupe_on_text_and_shape() -> None: lines = build.corpus_lines(build.parse_test_file(_SAMPLE, "de")) entries = [json.loads(line) for line in lines] assert entries == [ "Falke, Iris", {"name": "Falke, Iris", "shape": 4}, "Iris Falke", "Lena", {"name": "Lena", "shape": 4}, ] def test_fields_lines_refuse_a_duplicate_rendering_key() -> None: rows = build.parse_test_file(_SAMPLE, "de") with pytest.raises(ValueError, match="duplicate rendering"): build.fields_lines(rows + rows[:1]) �������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_cli.py����������������������������������������������0000664�0000000�0000000�00000005364�15273032717�0022570�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������import json import subprocess import sys def _run(*args: str) -> subprocess.CompletedProcess: return subprocess.run([sys.executable, "-m", "nameparser", *args], capture_output=True, text=True) def test_cli_prints_repr_capitalized_and_initials() -> None: out = _run("dr. juan de la vega iii").stdout assert "juan" in out # raw repr first assert "Juan" in out # capitalized repr second assert "Initials:" in out def test_cli_labels_each_section() -> None: # two of the three outputs were unlabeled ParsedName reprs, and for # already-cased input they are byte-identical -- the reader cannot # tell which is the parse and which is the capitalized view out = _run("Dr. Juan de la Vega III").stdout assert out.count("<ParsedName:") == 2 # still both shown assert "Parsed:" in out assert "Capitalized:" in out assert out.index("Parsed:") < out.index("Capitalized:") def test_cli_json() -> None: proc = _run("John Smith", "--json") data = json.loads(proc.stdout) assert data["given"] == "John" and data["family"] == "Smith" def test_cli_no_args_usage() -> None: proc = _run() assert proc.returncode != 0 def test_cli_locale() -> None: proc = _run("Сидоров Иван Петрович", "--locale", "ru") assert proc.returncode == 0 assert "Иван" in proc.stdout proc = _run("Сидоров Иван Петрович", "--locale", "ru", "--json") data = json.loads(proc.stdout) assert "Иван" in data["given"] def test_cli_locale_unknown_code_lists_available() -> None: proc = _run("John Smith", "--locale", "xx") assert proc.returncode != 0 assert "ru" in proc.stderr and "tr_az" in proc.stderr def test_cli_locale_empty_string_errors_not_silent_default() -> None: # --locale "" is a mistake (empty shell variable, typo), not a # request for the default parser; a truthiness check would swallow # it silently -- it must error like any other unknown code proc = _run("John Smith", "--locale", "") assert proc.returncode != 0 assert "ru" in proc.stderr and "tr_az" in proc.stderr def test_cli_locale_ja_activates_nothing_by_itself() -> None: # usage.rst's claim, pinned at the layer it is about: the CLI takes # the pack but has no way to attach a segmenter, and the ja pack # ships no vocabulary -- so --locale ja is byte-identical to the # default parser on the very name the pack exists to divide. A # future --segmenter flag, or a pack that grew a surname list, # would land here first. packed = _run("山田太郎", "--locale", "ja") default = _run("山田太郎") assert packed.returncode == 0 assert packed.stdout == default.stdout ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_config_aliases.py�����������������������������������0000664�0000000�0000000�00000045536�15273032717�0024774�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""The 1.x vocabulary names, served from their 2.2 homes (#293). The alias table here is written out literally rather than imported from the shim modules. Importing their table would make every assertion below a tautology -- it would prove the bridge is self-consistent, not that it points where the migration guide says it does. """ from __future__ import annotations import importlib import inspect import pathlib import sys import warnings from typing import NamedTuple import pytest import nameparser class _Alias(NamedTuple): """One retired 1.x vocabulary name and the 2.2 name serving it. Four same-typed strings whose order carries all the meaning, which is what makes a bare tuple the wrong shape here: ``for m, n, _, _ in ALIASES`` appeared six times below and reads correctly only if you remember which end is which (#356). """ old_module: str old_name: str new_module: str new_name: str def _id(alias: _Alias) -> str: return f"{alias.old_module.rsplit('.', 1)[-1]}.{alias.old_name}" #: One row per alias. ALIASES = [ _Alias("nameparser.config.prefixes", "PREFIXES", "nameparser.config.particles", "PARTICLES"), _Alias("nameparser.config.prefixes", "NON_FIRST_NAME_PREFIXES", "nameparser.config.particles", "NON_GIVEN_NAME_PARTICLES"), _Alias("nameparser.config.bound_first_names", "BOUND_FIRST_NAMES", "nameparser.config.bound_given_names", "BOUND_GIVEN_NAMES"), _Alias("nameparser.config.titles", "FIRST_NAME_TITLES", "nameparser.config.titles", "GIVEN_NAME_TITLES"), _Alias("nameparser.config.suffixes", "SUFFIX_NOT_ACRONYMS", "nameparser.config.suffixes", "SUFFIX_WORDS"), ] #: Old module paths, one per shim, for the per-module parametrizations. OLD_MODULES = sorted({a.old_module for a in ALIASES}) def _served(module: object) -> dict[str, object]: """The live constants 2.8.1 moved out of a module's globals into its ``__getattr__`` (#1007), each now warning on a read; empty for the two data-free shims.""" hook = vars(module).get("__getattr__") return dict(getattr(hook, "removed_values", {})) @pytest.mark.parametrize( ("old_module", "old_name", "new_module", "new_name"), ALIASES, ids=[_id(a) for a in ALIASES], ) def test_old_name_warns_and_resolves_to_the_new_constant( old_module: str, old_name: str, new_module: str, new_name: str, ) -> None: expected = getattr(importlib.import_module(new_module), new_name) with pytest.warns(DeprecationWarning) as record: value = getattr(importlib.import_module(old_module), old_name) assert value is expected message = str(record[0].message) # both paths: naming only the destination would let the message # misidentify which name the caller actually has to edit assert f"{old_module}.{old_name}" in message, message assert f"{new_module}.{new_name}" in message, message assert "3.0" in message, message def test_warning_points_at_the_line_that_read_the_name() -> None: """A message nobody can trace back to their own code is advice that cannot be acted on -- #337's scar is exactly this regressing unnoticed, since a wrong ``stacklevel`` is invisible from inside the warning call. Only the recorded frame shows it.""" module = importlib.import_module("nameparser.config.prefixes") frame = inspect.currentframe() assert frame is not None with pytest.warns(DeprecationWarning) as record: expected_lineno = frame.f_lineno + 1 module.PREFIXES # noqa: B018 assert (record[0].filename, record[0].lineno) == (__file__, expected_lineno) @pytest.mark.parametrize( ("old_module", "old_name"), [(a.old_module, a.old_name) for a in ALIASES], ids=[_id(a) for a in ALIASES], ) def test_from_import_is_attributed_to_the_importing_module( old_module: str, old_name: str, ) -> None: """The form the ``stacklevel`` comment singles out, and the one most callers use. ``from x import Y`` resolves the alias while the importing module's frame is on top, so the report names the file holding the import -- the line that has to be edited. Every alias, not the one ``prefixes`` row this covered until #356. The two that KEPT their module are a structurally different shape: their table sits at the bottom of a module that is mid-execution during its own import, so a regression could reach those two and not the moved ones. """ code = compile(f"from {old_module} import {old_name}\n", "caller_module.py", "exec") with pytest.warns(DeprecationWarning) as record: exec(code, {"__name__": "caller_module"}) assert (record[0].filename, record[0].lineno) == ("caller_module.py", 1) @pytest.mark.parametrize( ("old_module", "old_name"), [(a.old_module, a.old_name) for a in ALIASES], ids=[_id(a) for a in ALIASES], ) def test_old_name_warns_once_per_read_location( old_module: str, old_name: str, ) -> None: """Every line that has to be edited is told, and told once. The bridge deliberately does not cache the resolved value back into the shim module, so the suppression is ``__warningregistry__``'s: keyed on (text, category, lineno) in the READING module's globals, it silences a repeat from the same line and lets a new line through. A write-back would instead hand the single warning to whoever read first, which in a real program is usually a dependency. The filter action is load-bearing, and this test is vacuous under the wrong one. ``pytest.warns`` installs ``always``, and the suite's own ``filterwarnings = ["error"]`` raises before recording -- under either, nothing is ever written to the registry, so all three reads below report and the assertion measures nothing. ``default`` is the action that records what it has already shown. Entering and leaving ``catch_warnings`` bumps the filter version, which invalidates any registry this file left behind, so each parametrization starts cold without anyone clearing it. """ module = importlib.import_module(old_module) frame = inspect.currentframe() assert frame is not None with warnings.catch_warnings(record=True) as record: warnings.simplefilter("default") repeated_lineno = frame.f_lineno + 2 for _ in range(2): first = getattr(module, old_name) fresh_lineno = frame.f_lineno + 1 second = getattr(module, old_name) assert first is second assert [w.lineno for w in record] == [repeated_lineno, fresh_lineno] @pytest.mark.parametrize( "old_module", OLD_MODULES) def test_unknown_attribute_still_raises(old_module: str) -> None: module = importlib.import_module(old_module) with pytest.raises(AttributeError, match="NOT_A_CONSTANT"): module.NOT_A_CONSTANT # noqa: B018 @pytest.mark.parametrize( ("old_module", "old_name"), [(a.old_module, a.old_name) for a in ALIASES], ids=[_id(a) for a in ALIASES], ) def test_dir_advertises_the_old_names(old_module: str, old_name: str) -> None: assert old_name in dir(importlib.import_module(old_module)) @pytest.mark.parametrize("old_module", OLD_MODULES) def test_dir_survives_the_module_being_dropped_from_sys_modules( old_module: str, monkeypatch: pytest.MonkeyPatch, ) -> None: """``dir()`` may raise ``AttributeError``; it may not raise ``KeyError``. The override reads the module back out of ``sys.modules`` to reach the live names, and a subscript there made ``dir(module)`` raise ``KeyError`` for anyone holding a reference to a module that had been dropped -- a package reload, a plugin teardown. The aliases live in the closure and are still nameable, so they are what a degraded listing can honestly offer (#356). """ module = importlib.import_module(old_module) retired = {a.old_name for a in ALIASES if a.old_module == old_module} monkeypatch.delitem(sys.modules, old_module) # 2.8.1's served constants live in the closure too assert set(dir(module)) == retired | set(_served(module)) def test_dir_lists_the_live_names_as_well_as_the_retired_ones() -> None: """The other half of what these four ``__dir__`` overrides owe. A module ``__dir__`` REPLACES the default listing, so an override that returns only the alias table takes every live constant out of REPL completion, out of ``inspect.getmembers``, and out of autodoc's module member scan -- which walks ``dir()`` and would then document nothing from ``suffixes``/``titles``. The retired-name assertion above is satisfied by exactly that override, so it has to be said separately. Stated over the whole of ``vars()`` rather than the vocabulary alone: the union is what the override actually promises, and it cannot go vacuous the way an empty vocabulary filter can on the two data-free shim modules. """ live_seen = [] dropped = {} for old_module in OLD_MODULES: module = importlib.import_module(old_module) missing = set(vars(module)) - set(dir(module)) if missing: dropped[old_module] = sorted(missing) live_seen += [name for name, value in vars(module).items() if name.isupper() and isinstance(value, frozenset)] # 2.8.1 serves them through __getattr__ (#1007), so dir() must # list those as well served = set(_served(module)) live_seen += sorted(served) if served - set(dir(module)): dropped[old_module] = sorted(served - set(dir(module))) # checked first: the sweep below is equally happy with four modules # holding no vocabulary at all, which is the shape that would make # it prove nothing assert live_seen, ( "no live vocabulary constant found in any alias-bearing module " "-- the sweep below would be measuring only dunders") assert not dropped, ( f"__dir__ returned less than the module's own globals: {dropped}" f" -- an override that does not union them hides the live " f"constants from every getattr-free member scan") @pytest.mark.parametrize( "old_module", OLD_MODULES) def test_every_alias_table_row_reaches_star_import(old_module: str) -> None: """The direction the star-import test cannot see. ``__all__`` is hand-written per module and the alias table is a second hand-written list. An ``__all__`` entry with no table row fails loudly (the name resolves to nothing). A table row missing from ``__all__`` is the silent one: ``from x import *`` reads ``__all__`` and never the module ``__getattr__``, so the row is simply dropped -- no warning, no ``AttributeError`` -- which is verbatim the failure fc46a9b added ``__all__`` to eliminate. The star-import test derives its expected set from this file's ``ALIASES``, so it agrees with a truncated ``__all__`` and stays green. ``ALIASES`` itself is checked against the module's table for the same reason: a row added there and not here would leave every other assertion in this file blind to the new alias. """ module = importlib.import_module(old_module) table = module.__getattr__.deprecated_aliases # type: ignore[attr-defined] exported = set(module.__all__) assert set(table) <= exported, ( f"{old_module} serves {sorted(set(table) - exported)} through " f"__getattr__ but omits it from __all__, so `from {old_module} " f"import *` drops the name silently") assert set(table) == {a.old_name for a in ALIASES if a.old_module == old_module}, ( f"{old_module}'s alias table and this file's ALIASES disagree; " f"the literal table here is what proves the bridge points where " f"the migration guide says, so it has to cover every row") @pytest.mark.parametrize( "old_module", OLD_MODULES) def test_star_import_binds_exactly_the_live_and_retired_names( old_module: str, ) -> None: """``from x import *`` consults ``__all__`` and nothing else. A module ``__getattr__`` is invisible to it, so before ``__all__`` landed this was the one 1.x import form the bridge did not cover, and it failed in the mode the bridge exists to prevent: no warning, no ``AttributeError``, just a ``NameError`` further down at a line with nothing to do with the rename -- and ``alias_getattr`` bound into the caller's namespace in place of the vocabulary. The expected set is DERIVED from the module rather than listed, so a constant added to ``suffixes``/``titles`` without a matching ``__all__`` entry fails here. A hand-written list would have to be kept in step by the same person who forgot ``__all__``. """ module = importlib.import_module(old_module) retired = {a.old_name for a in ALIASES if a.old_module == old_module} # A retired name is served by __getattr__ and never written into the # module, so vars() holds the live constants -- plus whatever else # the file imported. The type test is what separates the two: # `TYPE_CHECKING`, imported to hide the __getattr__ assignment from # mypy, has the name shape of a constant and is not vocabulary. # Every live constant in these four modules is a frozenset (the 2.2 # freeze), so a new one still has to reach __all__ or fail here. live = {n for n, v in vars(module).items() if n.isupper() and not n.startswith("_") and isinstance(v, frozenset)} # since 2.8.1 the live constants are served by __getattr__ (#1007) # and warn like the retired ones served = _served(module) namespace: dict[str, object] = {} with pytest.warns(DeprecationWarning) as record: exec(f"from {old_module} import *", namespace) # noqa: S102 bound = {n for n in namespace if not n.startswith("__")} assert bound == live | retired | set(served) # NAMES alone would pass with an __all__ entry routed to the wrong # constant -- the set is the same either way. Each retired name is # checked against its destination and each live one against the # module, so a mis-wired row fails here rather than in whatever # parses differently three releases later (#356). for alias in (a for a in ALIASES if a.old_module == old_module): destination = getattr( importlib.import_module(alias.new_module), alias.new_name) assert namespace[alias.old_name] is destination, ( f"`from {old_module} import *` bound {alias.old_name} to " f"something other than {alias.new_module}.{alias.new_name}") for name in live: assert namespace[name] is vars(module)[name], ( f"`from {old_module} import *` bound {name} to something " f"other than the module's own constant") for name, value in served.items(): assert namespace[name] is value, ( f"`from {old_module} import *` bound {name} to something " f"other than the module's own constant") # the helper the bridge is built from is not vocabulary; before # __all__ it was the only thing a star import bound here assert "alias_getattr" not in bound # one warning per retired or served name, each naming where to go assert len(record) == len(retired) + len(served) for warning in record: message = str(warning.message) assert old_module in message, message assert "3.0" in message, message assert {n for n in retired if any(f"{old_module}.{n} " in str(w.message) for w in record) } == retired #: Serving a 1.x name is an alias table's whole job, so the file #: holding that table may spell it. Nothing else in the package may, #: including the bridge machinery itself. One row per retired name, #: mapped to the package-relative files it is allowed to appear in -- #: relative paths rather than bare filenames, so a future #: ``locales/titles.py`` does not inherit ``config/titles.py``'s #: exemption. #: #: The match below is ``name in source``: raw text, not a token, so a #: mention in a comment or a docstring counts too. That is the intent -- #: prose naming a retired constant goes stale exactly the way code does #: -- but it admits two hits that are not stale references, neither of #: which can hide a real one: #: #: * ``NON_FIRST_NAME_PREFIXES`` contains ``PREFIXES``, so a file #: holding only the longer name trips both rows. It costs a duplicate #: line in the report; every row is still checked against the file. #: * an unrelated identifier may simply contain a retired name -- #: ``TITLE_PREFIXES`` reports as ``PREFIXES``. Renaming is the wrong #: advice there, so the failure message offers this allow-list as the #: other remedy. #: DERIVED from ``ALIASES``, not listed again: the allow-listed file is #: always the module that serves the name -- for the three that moved #: because that is where the alias table sits, and for the two that kept #: their module because the table sits at the bottom of the file whose #: constant it renames. A second hand-written copy of the same five #: names in the same file is drift surface: a sixth alias added to one #: list and not the other would silently exempt that name from the scan #: below rather than failing (#356). _RETIRED_NAMES = { a.old_name: ( a.old_module.removeprefix("nameparser.").replace(".", "/") + ".py",) for a in ALIASES } def test_no_internal_code_reads_a_retired_vocabulary_name() -> None: """The bridge exists for callers, not for us. An internal read of a 1.x name would warn on a path the suite may never take, so ``filterwarnings = ["error"]`` alone does not pin this. It would also aim the bridge at the wrong reader: the warning is attributed to the line that did the read, so a library-internal one reports a file inside nameparser and hands the caller advice about code they cannot edit. """ package = pathlib.Path(nameparser.__file__).parent seen = set() offenders = [] for path in sorted(package.rglob("*.py")): source = path.read_text(encoding="utf-8") relative = path.relative_to(package).as_posix() for name, allowed in _RETIRED_NAMES.items(): if name not in source: continue seen.add(name) if relative not in allowed: offenders.append(f"{relative}: {name}") # every retired name is spelled in its own allow-listed file, so a # name the scan never saw at all means the scan is broken rather # than the tree clean -- the failure mode where this test passes # while measuring nothing assert seen == set(_RETIRED_NAMES), ( f"scanned {package} and never saw " f"{sorted(set(_RETIRED_NAMES) - seen)}; the alias tables spell " f"every retired name, so the scan itself is broken") assert not offenders, ( "retired 1.x vocabulary names used inside the package; move them " "to their 2.2 names (#293) -- or, where a hit is an unrelated " "identifier that merely contains a retired name, add its path to " f"that row's _RETIRED_NAMES allow-list: {offenders}") ������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_config_shim.py��������������������������������������0000664�0000000�0000000�00000172464�15273032717�0024314�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Shim Constants/SetManager/TupleManager (mechanisms.md#CONFIG-SHIM-SNAPSHOT).""" import copy import pickle import warnings from pathlib import Path import pytest from nameparser import _config_shim from nameparser import GIVEN_FIRST, HumanName, Lexicon, PatronymicRule, Policy from nameparser._config_shim import ( CONSTANTS, Constants, SetManager, TupleManager, _DelimiterManager, _RegexesProxy, _cached_parser, ) from nameparser.config.regexes import EMPTY_REGEX, REGEXES from nameparser.config._titles_2_6_0 import TITLES_2_6_0 from nameparser.config.titles import TITLE_MODIFIERS, TITLES, TITLES_AMBIGUOUS from nameparser.util import _lc as lc from tests.removals import ignore_removals _DATA_DIR = Path(__file__).parent / "data" def test_set_manager_normalizes_and_holds_membership() -> None: s = SetManager(["Dr", "MRS."]) assert "dr" in s and "Dr" in s # lc() normalization, both ways assert "mrs" in s assert len(s) == 2 assert sorted(s) == ["dr", "mrs"] def test_set_manager_add_remove_chain_and_keyerror() -> None: s = SetManager() assert s.add("Dame", "Fra") is s # chainable, v1 parity assert "dame" in s assert s.remove("Dame") is s assert "dame" not in s with pytest.raises(KeyError): # 1.3.0 grace period ended (#243) s.remove("never-there") def test_set_manager_call_is_removed() -> None: s = SetManager(["dr"]) with pytest.raises(TypeError): # #243: __call__ removed in 2.0 s() # type: ignore[operator] def test_set_manager_operators_and_equality() -> None: a, b = SetManager(["a", "b"]), SetManager(["b", "c"]) assert a | b == {"a", "b", "c"} assert a & b == {"b"} assert a - b == {"a"} assert a | {"z"} == {"a", "b", "z"} assert {"z"} | a == {"a", "b", "z"} # reflected: set op manager assert {"a", "b", "c"} - a == {"c"} # operand order matters here assert a == {"a", "b"} assert a == SetManager(["a", "b"]) and a != b with pytest.raises(TypeError): # mutable, unhashable (v1 parity) hash(a) def test_set_manager_reports_mutations_to_owner() -> None: bumps = [] s = SetManager(["a"], _on_change=lambda: bumps.append(1)) s.add("b") s.remove("a") assert len(bumps) == 2 s.add("b") # no-op: already present assert len(bumps) == 2 # must not bump (v1 parity) def test_set_manager_partial_remove_still_notifies_owner() -> None: bumps = [] s = SetManager(["a", "b"], _on_change=lambda: bumps.append(1)) with pytest.raises(KeyError): s.remove("a", "missing") # "a" IS removed before the raise assert "a" not in s assert len(bumps) == 1 # the real removal was reported def test_set_manager_accepts_v1_pickle_state() -> None: s = SetManager.__new__(SetManager) s.__setstate__({"elements": {"dr", "mr"}, "_on_change": None}) assert "dr" in s and len(s) == 2 # the shim's own key spelling, with un-normalized elements: loading # must re-normalize rather than trust the blob passed through lc() s2 = SetManager.__new__(SetManager) s2.__setstate__({"_elements": {"Dr", "MRS."}}) assert "dr" in s2 and "mrs" in s2 assert sorted(s2) == ["dr", "mrs"] def test_set_manager_pickle_round_trip() -> None: # in-process round trip of a blob we just built; pickle is not a # security boundary here (same stance as the v2 pickle guards) t = pickle.loads(pickle.dumps(SetManager(["Dr", "Mrs."]))) assert t == SetManager(["dr", "mrs"]) def test_tuple_manager_attribute_access_and_unknown_key() -> None: t = TupleManager({"mcdonald": "McDonald"}) assert t.mcdonald == "McDonald" assert t["mcdonald"] == "McDonald" with pytest.raises(AttributeError, match="mcdonalds"): # #256 t.mcdonalds def test_tuple_manager_mutations_bump_owner() -> None: bumps = [] t = TupleManager({"a": "A"}, _on_change=lambda: bumps.append(1)) t["b"] = "B" del t["a"] t.pop("b") assert len(bumps) == 3 def test_tuple_manager_pop_default_on_missing_key_is_noop() -> None: bumps = [] t = TupleManager({"a": "A"}, _on_change=lambda: bumps.append(1)) assert t.pop("missing", None) is None assert len(bumps) == 0 # no-op: key was never present def test_tuple_manager_bulk_mutations_bump_owner() -> None: # dict's C fast paths (update, setdefault, |=, clear, popitem) must # notify too, or a cached parser built from the owner goes stale bumps = [] t = TupleManager(_on_change=lambda: bumps.append(1)) t.update({"a": "A", "b": "B"}) assert len(bumps) == 2 # per-key, via __setitem__ assert t.setdefault("c", "C") == "C" assert len(bumps) == 3 assert t.setdefault("a", "ignored") == "A" assert len(bumps) == 3 # existing key: read, not write t |= {"d": "D"} assert len(bumps) == 4 assert t.popitem()[0] in "abcd" assert len(bumps) == 5 t.clear() assert len(bumps) == 6 t.clear() assert len(bumps) == 6 # already empty: no-op def test_tuple_manager_pickle_round_trip() -> None: t = pickle.loads(pickle.dumps(TupleManager({"a": "A"}))) assert dict(t) == {"a": "A"} assert t._on_change is None def test_delimiter_manager_sentinels_only() -> None: d = _DelimiterManager({"parenthesis": "parenthesis"}) moved = d.pop("parenthesis") d2 = _DelimiterManager() d2["parenthesis"] = moved # the documented bucket-move idiom assert "parenthesis" in d2 with pytest.raises(TypeError, match="quoted_word"): d2["angle_brackets"] = "custom" # custom keys raise def test_delimiter_manager_no_bypass_via_constructor_or_update() -> None: # dict's C fast paths (dict.__init__, dict.update) skip a subclass's # __setitem__ -- the sentinel rule must hold on every mutation path with pytest.raises(TypeError, match="quoted_word"): _DelimiterManager({"angle_brackets": "x"}) with pytest.raises(TypeError, match="quoted_word"): _DelimiterManager(angle_brackets="x") with pytest.raises(TypeError, match="quoted_word"): _DelimiterManager().update({"angle_brackets": "x"}) with pytest.raises(TypeError, match="quoted_word"): _DelimiterManager().setdefault("angle_brackets", "x") with pytest.raises(TypeError, match="quoted_word"): d0 = _DelimiterManager() d0 |= {"angle_brackets": "x"} # update through the validated path still notifies the owner per key bumps = [] d = _DelimiterManager(_on_change=lambda: bumps.append(1)) d.update({"quoted_word": "quoted_word", "parenthesis": "parenthesis"}) assert dict(d) == {"quoted_word": "quoted_word", "parenthesis": "parenthesis"} assert len(bumps) == 2 def test_regexes_reads_ok_assignment_raises() -> None: r = _RegexesProxy() assert r.word.match("Smith") # type: ignore[attr-defined] # reads keep working with pytest.raises(TypeError, match="strip_bidi"): r.bidi = None # slot-aware message with pytest.raises(TypeError, match="Policy"): r.roman_numeral = None # generic message def test_regexes_membership_iteration_and_deepcopy() -> None: r = _RegexesProxy() assert "word" in r # membership is a read assert "nope" not in r assert "word" in sorted(r) # iteration is a read assert "word" in r.keys() # dunder probes must raise AttributeError, not resolve as regex # names -- the classic copy.deepcopy regression (see AGENTS.md) assert isinstance(copy.deepcopy(r), _RegexesProxy) def test_regexes_supports_the_v1_read_only_mapping_surface() -> None: # v1's regexes was a dict subclass. The proxy is not, and its # catch-all __getattr__ claims every unimplemented mapping method # as a regex name -- the same failure that made .get() raise # "no regex named 'get'". Cover the rest of the read surface. r = _RegexesProxy() assert dict(r.items())["word"] is REGEXES["word"] assert REGEXES["word"] in list(r.values()) assert len(r) == len(REGEXES) assert r.copy() == dict(REGEXES) # v1's dict.copy() def test_regexes_unsupported_method_is_not_reported_as_a_missing_regex() -> None: # "no regex named 'pop'" sends the reader looking for a vocabulary # entry when the truth is the proxy does not implement a mutator r = _RegexesProxy() with pytest.raises(AttributeError, match="not supported"): r.pop with pytest.raises(AttributeError, match="no regex named"): r.definitely_not_a_regex def test_regexes_get_is_the_soft_access_escape_hatch() -> None: # 1.4's #256 deprecation told users ".get() remains available for # intentional soft access"; __getattr__ must not swallow `get` # itself. capitalization_exceptions inherits it from dict -- the # regexes proxy is not a dict, so it has to say so explicitly. r = _RegexesProxy() assert r.get("word") is REGEXES["word"] assert r.get("typo") is None assert r.get("typo", EMPTY_REGEX) is EMPTY_REGEX def test_constants_default_fields_present() -> None: c = Constants() assert "dr" in c.titles assert "phd" in c.suffix_acronyms assert "van" in c.prefixes assert c.patronymic_name_order is False assert c.middle_name_as_last is False assert c.capitalize_name is False assert c.force_mixed_case_capitalization is False assert c.string_format == "{title} {first} {middle} {last} {suffix} ({nickname})" assert c.suffix_delimiter is None # every named sentinel is a default nickname bucket (the v1 trio # plus the #273 typographic pairs); derived from the map itself so # a new sentinel can't leave this pin stale from nameparser._config_shim import _SENTINEL_PAIRS assert set(c.nickname_delimiters) == set(_SENTINEL_PAIRS) assert {"quoted_word", "double_quotes", "parenthesis", "smart_double_quotes", "guillemets"} <= set(_SENTINEL_PAIRS) assert len(c.maiden_delimiters) == 0 def test_constants_mutation_bumps_generation() -> None: c = Constants() g0 = c._generation c.titles.add("zqxtitle") # not a default title (real bump) assert c._generation > g0 g1 = c._generation c.patronymic_name_order = True assert c._generation > g1 def test_private_constants_do_not_warn_shared_singleton_does() -> None: c = Constants() with warnings.catch_warnings(): warnings.simplefilter("error") c.titles.add("zqxtitle") # private: silent with pytest.deprecated_call(match="Lexicon"): CONSTANTS.titles.add("zqxtest") with pytest.deprecated_call(): CONSTANTS.titles.remove("zqxtest") # leave the singleton clean def test_scalar_noop_assignment_neither_bumps_nor_warns() -> None: # managers already suppress no-op mutations (no-op add, pop with # default); re-assigning a scalar's current value must match -- # neither a generation bump nor, on the shared singleton, the # deprecation warning c = Constants() g0 = c._generation c.string_format = c.string_format assert c._generation == g0 g_shared = CONSTANTS._generation with warnings.catch_warnings(): warnings.simplefilter("error") CONSTANTS.string_format = CONSTANTS.string_format assert CONSTANTS._generation == g_shared def test_empty_attribute_default_assignment_raises() -> None: c = Constants() with pytest.raises(AttributeError, match="#255"): c.empty_attribute_default = None def test_regexes_attribute_assignment_raises() -> None: with pytest.raises(TypeError, match="Policy"): Constants().regexes = {} # type: ignore[assignment] def test_constants_copy_is_independent() -> None: # #260 c = Constants() d = c.copy() d.titles.add("zqxtitle") # not a default title (real addition) assert "zqxtitle" in d.titles and "zqxtitle" not in c.titles assert d._generation != -1 # a real instance with its own counter def test_constants_pickle_round_trip() -> None: c = Constants() c.titles.add("zqxtitle") loaded = pickle.loads(pickle.dumps(c)) assert "zqxtitle" in loaded.titles loaded.titles.add("fra") # mutations still tracked assert "fra" in loaded.titles def test_pre_1_3_constants_blob_raises() -> None: # #279 # pre-1.3.0 blobs are recognizable by the computed property their # dir()-sweep __getstate__ captured; the 1.4 warning promised # ValueError in 2.0 with pytest.raises(ValueError, match="#279|1.3"): Constants.__new__(Constants).__setstate__( {"prefixes": set(), "suffixes_prefixes_titles": set()}) def test_v14_constants_state_shape_accepted() -> None: # v1.4 __getstate__ emitted public field names -> manager values; # scalars ride along. empty_attribute_default is accepted and # DROPPED (#255: empty is always '' in 2.0). c = Constants.__new__(Constants) c.__setstate__({"prefixes": {"van", "de"}, "string_format": "{first} {last}", "empty_attribute_default": None}) assert "van" in c.prefixes assert c.string_format == "{first} {last}" assert not hasattr(c, "empty_attribute_default") or True # dropped def test_v14_constants_blob_unpickles_into_shim() -> None: # tests/v2/data/constants_v14.pickle was produced by a real 1.4.0 # install (`uv run --no-project --with "nameparser==1.4.*" ...`), # not synthesized here -- it is the actual bytes a caller upgrading # from 1.4 to 2.0 would hand to pickle.load(). Since the M11 swap # replaced nameparser.config.Constants with this shim, the blob's # pickled class reference now resolves here. Its nested `regexes` # field pickles as nameparser.config.RegexTupleManager, reconstructed # by the unpickler before Constants.__setstate__ runs -- see # RegexTupleManager's docstring in _config_shim.py. with open(_DATA_DIR / "constants_v14.pickle", "rb") as f: loaded = pickle.load(f) assert isinstance(loaded, Constants) assert "van" in loaded.prefixes assert "dr" in loaded.titles assert "jr" in loaded.suffix_not_acronyms assert loaded.string_format == "{title} {first} {middle} {last} {suffix} ({nickname})" def test_v14_pickle_restores_and_parses_warning_free() -> None: # A 1.4 pickle froze the then-shipped vocabulary, including the # eight dead multi-word entries 2.0 removed; restoring it must not # spray warnings about entries the user never added. A multi-word # entry the user DID add still warns (second half) -- at PARSE # time, not add() time, since the shim's Lexicon snapshot is built # lazily on the first parse after a mutation. with open(_DATA_DIR / "constants_v14.pickle", "rb") as f: c = pickle.load(f) with warnings.catch_warnings(): warnings.simplefilter("error") ignore_removals() HumanName("John Smith", constants=c) c.titles.add("grand moff") with pytest.warns(UserWarning, match="matched one word at a time"): HumanName("Jane Roe", constants=c) def test_an_unrelated_capitalization_exceptions_valueerror_has_no_v1_hint( monkeypatch: pytest.MonkeyPatch, ) -> None: """The v1-spelled hint is added ONLY for _MaskValueError -- a value that does not spell its key -- caught by TYPE in _build_snapshot's try/except. Since #582 the shim drops every key Lexicon would fold to empty, so no config reaches a plain ValueError from _normpairs here; the filter is switched off to force one, so the except clause is still pinned to catch by TYPE and to pass an unrelated ValueError through without the v1 hint.""" monkeypatch.setattr(_config_shim, "_v1_matchable", lambda e: True) c = Constants(capitalization_exceptions={'\u3002': 'x'}) with pytest.raises(ValueError, match="normalizes to empty") as caught: HumanName("john smith", constants=c) assert "on a v1 Constants" not in str(caught.value) def test_a_non_str_exception_value_raises_typeerror_at_the_first_parse( ) -> None: """The shim does not translate or otherwise intercept a capitalization_exceptions value -- a caller's non-str entry (v1's TupleManager stores dict[str, object], never statically str-typed) reaches Lexicon's own TypeError unchanged, at the first parse where the snapshot is built.""" c = Constants(capitalization_exceptions={'phd': 42}) with pytest.raises(TypeError, match="str -> str"): HumanName("john smith phd", constants=c) def test_snapshot_keeps_a_multi_word_first_name_title() -> None: # v1 looks first_name_titles up on the joined title string, so a # multi-word entry is reachable with only its WORDS in titles. # Verified on a live 1.4.0: "Grand Duke John" -> first='John'. # Intersecting the entry away made the lookup miss and silently # reclassified the given name as a family name. c = Constants() c.titles.add("grand", "duke") c.first_name_titles.add("grand duke") lexicon, _, _ = c._snapshot() assert "grand duke" in lexicon.given_name_titles name = HumanName("Grand Duke John", constants=c) assert (name.title, name.first, name.last) == ("Grand Duke", "John", "") @pytest.mark.parametrize("entry", [".", "..", "grand duke", "grand\tduke"]) def test_snapshot_drops_first_name_titles_v1_could_not_match(entry: str) -> None: # v1's key is lc(" ".join(title_list)) -- always single-spaced, and # never empty -- so an entry with a whitespace run, or one that is # all periods, could never match there. Translating it anyway would # either widen behavior (the double-space entry starts matching) or # raise (the all-period entry normalizes to empty and _normset # rejects it). 1.4.0 accepts all of these and leaves them inert. c = Constants() c.titles.add("grand", "duke") c.first_name_titles.add(entry) with pytest.warns(UserWarning, match="first_name_titles"): lexicon, _, _ = c._snapshot() # must not raise assert "" not in lexicon.given_name_titles name = HumanName("Grand Duke John", constants=c) assert (name.first, name.last) == ("", "John") @pytest.mark.parametrize(("entry", "text"), [ # v1's lookup key is lc(joined-title-run), and lc strips only the # EDGE periods of the whole string, so an interior word keeps its # period. v2 normalizes per token and then joins, so the entry has # to be TRANSLATED between the two spellings, not filtered out. ("lt. col", "Lt. Col. Smith"), # v1 joins titles across conjunctions, and v2 tags the conjunction # Role.TITLE too, so the joined key contains it in both versions ("sir and dame", "Sir and Dame Alex"), ]) def test_snapshot_translates_a_multi_word_first_name_title( entry: str, text: str ) -> None: c = Constants() c.titles.add("lt", "col") c.first_name_titles.add(entry) name = HumanName(text, constants=c) assert name.first and not name.last, f"{entry!r} was not honored" def test_snapshot_ignores_an_ambiguous_acronym_added_as_a_suffix_word() -> None: # Adding an ambiguous acronym to suffix_not_acronyms is INERT in # v1: is_suffix already accepts it through the acronym branch, and # reserve_last keeps it as the surname anyway. 1.4.0 with this # config parses "Jack Ma" exactly as it does without it. # # Lexicon forbids the overlap, since the word branch would bypass # the period gate. Dropping the word from the AMBIGUOUS set instead # (the reverse subtraction) ungates it and loses the family name # entirely -- worse than the raise it was meant to avoid. Drop it # from suffix_words instead, which reproduces v1's inertness. c = Constants() c.suffix_not_acronyms.add("ma") lexicon, _, _ = c._snapshot() # must not raise assert "ma" in lexicon.suffix_acronyms_ambiguous assert "ma" not in lexicon.suffix_words name = HumanName("Jack Ma", constants=c) assert (name.first, name.last, name.suffix) == ("Jack", "Ma", "") def test_bound_never_given_prefix_deviates_on_two_pieces() -> None: # Pinned DEVIATION, not parity. v1's bound-given join has a # reserve_last guard, so with only two pieces it does not fire and # the word stays never-given: 1.4.0 gives first='', last='dos # Santos'. The shim promotes such a word to may-be-given # unconditionally, because v1's rule is piece-count dependent and a # static vocabulary set cannot express that. Three-piece parity is # the case that matters and is covered below; this records the cost. c = Constants() c.bound_first_names.add("dos") name = HumanName("dos Santos", constants=c) assert (name.first, name.last) == ("dos", "Santos") def test_snapshot_keeps_a_bound_never_given_prefix_parseable() -> None: # the vocabulary build refuses a shipped word in both # NON_GIVEN_NAME_PARTICLES and BOUND_GIVEN_NAMES, so the defaults # behind non_first_name_prefixes and bound_first_names never # collide; nothing stops a v1 caller adding one at runtime, and 1.4 # accepts it -- letting the bound rule win, so "dos Santos Silva" # parses first="dos Santos". # Lexicon rejects that combination, so the shim promotes such a word # to may-be-given rather than raising on config v1 allowed. c = Constants() assert "dos" in c.non_first_name_prefixes # baseline: never-given c.bound_first_names.add("dos") lexicon, _, _ = c._snapshot() # must not raise assert "dos" in lexicon.particles_ambiguous name = HumanName("dos Santos Silva", constants=c) assert (name.first, name.last) == ("dos Santos", "Silva") def test_snapshot_removing_a_honorific_word_turns_the_peel_off() -> None: # The deciding case for honorific_tails, which has no v1 manager of # its own: the snapshot intersects GLUED_HONORIFICS with the WORD # set, so deleting 씨 from suffix_not_acronyms -- the only v1 knob # that reaches it -- must make the tail stop mattering rather than # raise the subset error or leave 씨 peeling into a suffix field # that no longer recognizes it. With the default config the # intersection is a no-op, so the equality test above pins nothing # here. c = Constants() assert HumanName("김민준씨", constants=c).suffix == "씨" # baseline c.suffix_not_acronyms.remove("씨") lexicon, _, _ = c._snapshot() # must not raise assert "씨" not in lexicon.honorific_tails # the peel is off: the glued honorific goes back into the name, # which is 2.0's answer for this input name = HumanName("김민준씨", constants=c) assert (name.first, name.last, name.suffix) == ("민준씨", "김", "") def test_snapshot_removing_a_conjunction_turns_the_marker_off() -> None: # The deciding case for conjunctions_ambiguous, which has no v1 # manager of its own: the snapshot intersects CONJUNCTIONS_AMBIGUOUS # with the v1 conjunction set, so deleting 'e' from the one v1 knob # that reaches it must make the marking stop mattering rather than # raise the subset error or leave 'e' reading as an initial on a # word the parse no longer treats as a connective at all. With the # default config the intersection is a no-op, so the default-equality # test above pins nothing here. c = Constants() assert HumanName("john e smith", constants=c).middle == "e" # baseline c.conjunctions.remove("e") lexicon, _, _ = c._snapshot() # must not raise assert "e" not in lexicon.conjunctions_ambiguous # 'e' is no vocabulary at all now: a bare lowercase letter is not # initial-shaped, so it is an ordinary middle name word name = HumanName("john e smith", constants=c) assert (name.first, name.middle, name.last) == ("john", "e", "smith") assert name.initials() == "j. e. s." def test_snapshot_removing_a_title_turns_its_after_given_reading_off() -> None: # The deciding case for after_given_titles (#835, rules/H8), which has # no v1 manager of its own: the snapshot intersects AFTER_GIVEN_TITLES # with the v1 title set, so the marker drops the deleted title. The # parse check below is held by classify too, which tags a marker # entry only where it is a title, so the intersection is kept for # `_snapshot() == Lexicon.default()` legibility, as # conjunctions_ambiguous' is; the set assertion is what pins it c = Constants() assert HumanName("Doe, Jane Mrs", constants=c).title == "Mrs" # baseline c.titles.remove("mrs") lexicon, _, _ = c._snapshot() assert "mrs" not in lexicon.after_given_titles name = HumanName("Doe, Jane Mrs", constants=c) assert (name.title, name.first, name.middle, name.last) == ( "", "Jane", "Mrs", "Doe") def test_snapshot_removing_a_suffix_word_turns_its_gate_off() -> None: # The deciding case for suffix_words_ambiguous (#752, rules/S11), # which has no v1 manager of its own: the snapshot intersects it with # the v1 word set, and Lexicon holds the subset, so deleting the word # turns the reading off rather than leaving an orphan that gates a # word the caller removed; agnomens rides inside it c = Constants() name = HumanName("Pedro Alves Neto", constants=c) assert (name.last, name.suffix) == ("Alves", "Neto") # baseline c.suffix_not_acronyms.remove("neto") lexicon, _, _ = c._snapshot() assert "neto" not in lexicon.suffix_words_ambiguous assert "neto" not in lexicon.agnomens name = HumanName("Pedro Alves Neto", constants=c) assert (name.middle, name.last, name.suffix) == ("Alves", "Neto", "") def test_snapshot_lets_a_gated_word_v1_reads_as_an_acronym_out_of_the_gate() -> None: # v1 read a word added to suffix_acronyms as a suffix through the # acronym branch; the snapshot takes it out of suffix_words_ambiguous # rather than raising at Lexicon's word-gate check, which a config # v1 accepted must never do c = Constants() c.suffix_acronyms.add("neto") lexicon, _, _ = c._snapshot() assert "neto" not in lexicon.suffix_words_ambiguous assert HumanName("Pedro Alves Neto", constants=c).suffix == "Neto" def test_snapshot_field_translation() -> None: c = Constants() lexicon, policy, defaults = c._snapshot() # drift guard: a default Constants must resolve to EXACTLY the v2 # defaults -- a field populated in _default_lexicon() but forgotten # in _snapshot() (or vice versa) fails loudly here assert lexicon == Lexicon.default() assert policy == Policy() assert isinstance(lexicon, Lexicon) and isinstance(policy, Policy) assert lexicon.suffix_words == frozenset(c.suffix_not_acronyms) # complement translation: v1 marks never-given, v2 marks may-be-given assert lexicon.particles_ambiguous == \ frozenset(c.prefixes) - frozenset(c.non_first_name_prefixes) assert lexicon.suffix_acronyms_ambiguous <= lexicon.suffix_acronyms assert policy.name_order == GIVEN_FIRST assert policy.patronymic_rules == frozenset() assert defaults.string_format == c.string_format # a snapshot is a pure read: no generation bump, no deprecation # warning even on the shared singleton g0 = CONSTANTS._generation with warnings.catch_warnings(): warnings.simplefilter("error") CONSTANTS._snapshot() assert CONSTANTS._generation == g0 def test_snapshot_patronymic_and_middle_flags() -> None: c = Constants() c.patronymic_name_order = True c.middle_name_as_last = True _, policy, _ = c._snapshot() assert policy.patronymic_rules == frozenset( {PatronymicRule.EAST_SLAVIC, PatronymicRule.TURKIC}) assert policy.middle_as_family is True def test_typographic_delimiter_sentinels_present_and_movable() -> None: # #273: the eight typographic pairs surface in the v1 API as new # named sentinels, so the documented keyed idioms (pop/move/del) # work on them exactly like the original trio c = Constants() assert "smart_double_quotes" in c.nickname_delimiters assert "guillemets" in c.nickname_delimiters c.maiden_delimiters["guillemets"] = c.nickname_delimiters.pop("guillemets") _, policy, _ = c._snapshot() assert ("«", "»") in policy.maiden_delimiters assert ("«", "»") not in policy.nickname_delimiters def test_snapshot_delimiter_bucket_move() -> None: c = Constants() c.maiden_delimiters["parenthesis"] = c.nickname_delimiters.pop("parenthesis") _, policy, _ = c._snapshot() assert ("(", ")") in policy.maiden_delimiters assert ("(", ")") not in policy.nickname_delimiters def test_snapshot_overlap_keeps_v1_nickname_precedence() -> None: # v1 semantics: a pair present in BOTH v1 buckets parses as nickname. # 2.0's Policy resolves overlap the other way (maiden wins), so the # snapshot pre-subtracts on the maiden side -- a v1 user who added # parens to maiden_delimiters WITHOUT removing them from # nickname_delimiters keeps their 1.x parse through the facade. c = Constants() c.maiden_delimiters["parenthesis"] = ("(", ")") # nickname still has it _, policy, _ = c._snapshot() assert ("(", ")") in policy.nickname_delimiters assert ("(", ")") not in policy.maiden_delimiters from nameparser import HumanName n = HumanName("Jane (Jones) Smith", constants=c) assert n.nickname == "Jones" and n.maiden == "" def test_snapshot_ambiguous_removed_acronym_intersects() -> None: c = Constants() c.suffix_acronyms.remove("jd") # 'jd' stays in ambiguous lexicon, _, _ = c._snapshot() assert "jd" not in lexicon.suffix_acronyms assert "jd" not in lexicon.suffix_acronyms_ambiguous # subset holds def test_parser_cache_shared_across_equal_snapshots() -> None: a, b = Constants(), Constants() la, pa, _ = a._snapshot() lb, pb, _ = b._snapshot() assert _cached_parser(la, pa) is _cached_parser(lb, pb) # -- Gap 1: Constants() constructor kwargs (v1.4 parity, #238/#242/#244) ---- def test_constants_kwarg_replaces_field_others_keep_defaults() -> None: default = Constants() c = Constants(titles=["Abc"]) assert set(c.titles) == {"abc"} # lc()-normalized assert "abc" not in default.titles # sanity: not a real default # a kwarg REPLACES that field's vocabulary wholesale (v1 parity); the # other eight set fields are untouched for field in ("prefixes", "suffix_acronyms", "suffix_not_acronyms", "suffix_acronyms_ambiguous", "first_name_titles", "conjunctions", "bound_first_names", "non_first_name_prefixes"): assert set(getattr(c, field)) == set(getattr(default, field)) def test_constants_kwarg_bare_string_raises_typeerror() -> None: with pytest.raises(TypeError, match="titles"): Constants(titles="abc") # type: ignore[arg-type] def test_constants_kwarg_non_iterable_raises_typeerror() -> None: with pytest.raises(TypeError): Constants(titles=5) # type: ignore[arg-type] def test_constants_kwarg_capitalization_exceptions_wraps_tuple_manager() -> None: c = Constants(capitalization_exceptions={"mcdonald": "McDonald"}) assert isinstance(c.capitalization_exceptions, TupleManager) assert c.capitalization_exceptions["mcdonald"] == "McDonald" def test_constants_kwarg_regexes_raises_typeerror() -> None: with pytest.raises(TypeError, match="Policy"): Constants(regexes={}) # type: ignore[call-arg] def test_constants_kwarg_unknown_raises_typeerror() -> None: with pytest.raises(TypeError): Constants(bogus=1) # type: ignore[call-arg] def test_constants_kwarg_feeds_facade_parse() -> None: from nameparser._facade import HumanName c = Constants(titles=["zzqtitle"]) n = HumanName("Zzqtitle Judy Dench", constants=c) assert n.title == "Zzqtitle" # -- Restoring v1.3/1.4 manager hardening (#221/#238/#241/#242/#260) ------- def test_set_manager_discard_is_noop_on_missing_and_notifies_on_real_change() -> None: bumps = [] s = SetManager(["a"], _on_change=lambda: bumps.append(1)) assert s.discard("missing") is s # never raises, chainable assert len(bumps) == 0 # no-op: nothing removed assert s.discard("A") is s # normalized match assert "a" not in s assert len(bumps) == 1 def test_set_manager_clear_notifies_only_if_non_empty() -> None: bumps = [] s = SetManager(["a", "b"], _on_change=lambda: bumps.append(1)) assert s.clear() is s assert len(s) == 0 assert len(bumps) == 1 assert s.clear() is s # already empty: no-op assert len(bumps) == 1 def test_set_manager_operators_accept_arbitrary_iterables() -> None: a = SetManager(["a", "b"]) assert a | ["B.", "z"] == {"a", "b", "z"} # list operand, normalized assert a & (x for x in ["A", "q"]) == {"a"} # generator operand assert a - ["a"] == {"b"} assert a ^ ["b", "c"] == {"a", "c"} # symmetric difference assert ["b", "c"] ^ a == {"a", "c"} # reflected def test_set_manager_bare_str_or_bytes_operand_raises_typeerror() -> None: a = SetManager(["a"]) with pytest.raises(TypeError): a | "ab" with pytest.raises(TypeError): a & b"ab" with pytest.raises(TypeError): "ab" | a # reflected form too def test_set_manager_comparison_operators() -> None: a = SetManager(["a", "b"]) assert a <= {"a", "b", "c"} assert a < {"a", "b", "c"} assert not (a < {"a", "b"}) assert {"a", "b", "c"} >= a assert a <= SetManager(["a", "b", "c"]) assert SetManager(["a", "b", "c"]) > a def test_set_manager_constructor_rejects_bare_str() -> None: with pytest.raises(TypeError): # #238/#241: not shredded to chars SetManager("dr") # type: ignore[arg-type] def test_set_manager_constructor_rejects_bare_bytes_with_decode_hint() -> None: with pytest.raises(TypeError, match="decode"): SetManager(b"dr") # type: ignore[arg-type] def test_set_manager_constructor_rejects_non_str_element() -> None: with pytest.raises(TypeError): SetManager([None]) # type: ignore[list-item] def test_set_manager_add_rejects_bytes_with_decode_hint() -> None: s = SetManager() with pytest.raises(TypeError, match="decode"): s.add(b"dr") # type: ignore[arg-type] def test_constants_setattr_rejects_bare_str_like_constructor() -> None: c = Constants() with pytest.raises(TypeError, match="conjunctions"): c.conjunctions = "and" # type: ignore[assignment] def test_tuple_manager_constructor_rejects_bare_str() -> None: with pytest.raises(TypeError): # #242 TupleManager("ab") # type: ignore[arg-type] def test_tuple_manager_constructor_rejects_iterable_of_strings() -> None: with pytest.raises(TypeError): # #242: 2-char strings silently split TupleManager(["ab", "cd"]) # type: ignore[arg-type] def test_tuple_manager_unknown_key_error_names_known_keys() -> None: t = TupleManager({"mcdonald": "McDonald", "obrien": "O'Brien"}) with pytest.raises(AttributeError, match="mcdonald"): t.bogus def test_tuple_manager_attribute_style_set_and_delete() -> None: bumps = [] t = TupleManager({"a": "A"}, _on_change=lambda: bumps.append(1)) t.b = "B" # attribute-style routes to dict assert t["b"] == "B" assert len(bumps) == 1 del t.a assert "a" not in t assert len(bumps) == 2 # the manager's own private attribute is unaffected assert t._on_change is not None def test_constants_copy_preserves_subclass() -> None: # #260 class MyConstants(Constants): pass c = MyConstants() d = c.copy() assert type(d) is MyConstants def test_constants_repr_shows_collection_counts() -> None: # #221 c = Constants() r = repr(c) assert r.startswith("<Constants") assert "titles:" in r assert f"titles: {len(c.titles)}" in r def test_constants_repr_shows_customized_scalars_only() -> None: # #221 c = Constants() assert "capitalize_name" not in repr(c) # default: not shown c.capitalize_name = True assert "capitalize_name: True" in repr(c) def test_constants_bool_kwargs() -> None: # v1.4 constructor kwargs used by the customize.rst doctests c = Constants(middle_name_as_last=True) assert c.middle_name_as_last is True assert c.patronymic_name_order is False d = Constants(patronymic_name_order=True) _, policy, _ = d._snapshot() assert len(policy.patronymic_rules) == 2 def test_set_manager_partial_add_still_notifies_owner() -> None: # a TypeError mid-argument-list leaves earlier additions applied; # the owner must hear about them or its cached parser goes stale bumps: list[int] = [] s = SetManager(["a"], _on_change=lambda: bumps.append(1)) with pytest.raises(TypeError, match="decode"): s.add("b", b"bad") # type: ignore[arg-type] assert "b" in s assert len(bumps) == 1 def test_set_manager_remove_discard_bytes_decode_hint() -> None: s = SetManager(["dr"]) with pytest.raises(TypeError, match="decode"): s.remove(b"dr") # type: ignore[arg-type] with pytest.raises(TypeError, match="decode"): s.discard(b"dr") # type: ignore[arg-type] def test_delimiter_manager_constructor_bare_str_gets_242_error() -> None: # the parent's #242 guard, not dict's cryptic ValueError with pytest.raises(TypeError, match="mapping or iterable"): _DelimiterManager("ab") # type: ignore[arg-type] with pytest.raises(TypeError, match="pairs"): _DelimiterManager(["ab", "cd"]) # type: ignore[list-item] def test_constants_shared_flag_is_read_only() -> None: c = Constants() with pytest.raises(AttributeError, match="read-only"): c._shared = True with pytest.raises(AttributeError, match="read-only"): CONSTANTS._shared = False assert CONSTANTS._shared is True and c._shared is False def test_multiword_warning_through_shim_points_at_caller() -> None: c = Constants() c.titles.add("zqx zqy") with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") HumanName("John Smith", constants=c) # snapshot builds lazily here multi = [x for x in w if "matched one word at a time" in str(x.message)] assert len(multi) == 1 assert multi[0].filename == __file__ def test_a_multiword_capitalization_exceptions_key_warns_exactly_once( ) -> None: """_build_snapshot's Lexicon(...) construction is the ONLY place capitalization_exceptions is validated, so a multi-word key must warn once, not twice.""" c = Constants(capitalization_exceptions={'zqx zqy': 'ZqXZqY'}) with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") HumanName("John Smith", constants=c) # snapshot builds lazily here multi = [x for x in w if "matched one word at a time" in str(x.message)] assert len(multi) == 1 def test_a_multiword_capitalization_exceptions_key_with_a_bad_mask_only_warns( ) -> None: """#459 review, shim half of test_a_multiword_key_skips_the_mask_ check_entirely in tests/v2/test_lexicon.py: a v1 caller's multi-word key is unreachable through capitalized() regardless of its value, so a value that does not spell it must not raise here either.""" c = Constants(capitalization_exceptions={'ph d': 'Doctor'}) with pytest.warns(UserWarning, match="matched one word at a time"): HumanName("John Smith", constants=c) lexicon, _policy, _render = c._snapshot() assert lexicon.capitalization_exceptions_map == {"ph d": "Doctor"} def test_2x_pickle_roundtrip_keeps_a_readded_dead_entry() -> None: # the all-eight gate: a 2.0 user's deliberate re-add of ONE legacy # string survives a round-trip (only a full pre-2.0 blob, which # froze all eight, is subtracted) c = Constants() with pytest.warns(UserWarning): c.suffix_acronyms.add("leed ap") HumanName("John Smith", constants=c) # snapshot builds lazily here c2 = pickle.loads(pickle.dumps(c)) assert "leed ap" in c2.suffix_acronyms # -- entries v1 could never match (#541) --------------------------------- # # A v1 piece comes from a whitespace split, re-joined only with single # spaces, so nameparser 1.x never matched an entry that is empty or # holds edge whitespace, a whitespace run or any whitespace character # other than a single space (a tab, NBSP, newline). Each row names a text # on which the entry WOULD act if the shim translated it, so a row that # reads like the entry-free parse proves the drop rather than an inert # word. An entry holding only full stops is dropped too: Lexicon folds # it to empty (#582), although v1 could match a token made of that full # stop. (field, entry, text, mask value for capitalization_exceptions) _V1_UNMATCHABLE_ROWS = [ ("titles", " dean ", "dean john smith", None), ("titles", "dean\t", "dean john smith", None), ("titles", "...", "dean john smith", None), ("titles", "de an", "de an john smith", None), ("prefixes", " zz ", "john zz smith", None), ("suffix_acronyms", " zzq ", "john smith zzq", None), ("suffix_not_acronyms", " zzw", "john smith zzw", None), ("suffix_not_acronyms", "ma ", "John Smith", None), ("suffix_not_acronyms", "ba ", "John Smith", None), ("suffix_acronyms_ambiguous", "phd ", "John Smith PhD", None), ("conjunctions", " zand ", "john zand jane smith", None), ("bound_first_names", " zzb ", "zzb ali smith", None), ("bound_first_names", "'t ", "Gerard 't Hooft", None), ("non_first_name_prefixes", " van ", "van johnson", None), ("capitalization_exceptions", " zzc ", "john zzc", "ZzC"), ("capitalization_exceptions", "...", "john zzc", "ZzC"), ("first_name_titles", "grand duke", "Grand Duke John", None), ("titles", "。", "。 john smith", None), ("prefixes", ".", "john . smith", None), ("suffix_acronyms", "。", "john smith 。", None), ("conjunctions", "。 。", "john 。 。 jane smith", None), ("capitalization_exceptions", "。", "john zzc", "。"), ] # Recorded negative control: what each row did with the filter off # (measured 2026-10-02 on master 42515ebd, where only first_name_titles # was filtered). "raises" rows broke the shim's never-raise contract, # "activates" rows its never-silently-change one; "inert" rows were # harmless already and are swept so the warning covers the whole family. _UNFILTERED_OUTCOME = { ("titles", " dean "): "activates", ("titles", "dean\t"): "activates", ("titles", "..."): "raises", ("titles", "de an"): "inert", ("prefixes", " zz "): "activates", ("suffix_acronyms", " zzq "): "activates", ("suffix_not_acronyms", " zzw"): "activates", ("suffix_not_acronyms", "ma "): "raises", ("suffix_not_acronyms", "ba "): "raises", ("suffix_acronyms_ambiguous", "phd "): "inert", ("conjunctions", " zand "): "activates", ("bound_first_names", " zzb "): "activates", ("bound_first_names", "'t "): "raises", ("non_first_name_prefixes", " van "): "inert", ("capitalization_exceptions", " zzc "): "activates", ("capitalization_exceptions", "..."): "raises", ("first_name_titles", "grand duke"): "activates", ("titles", "。"): "raises", ("prefixes", "."): "raises", ("suffix_acronyms", "。"): "raises", ("conjunctions", "。 。"): "raises", ("capitalization_exceptions", "。"): "raises", } def _base(field: str) -> Constants: c = Constants() if field == "first_name_titles": # the entry only acts on known titles c.titles.add("grand", "duke") return c def _with_entry(field: str, entry: str, value: str | None) -> Constants: c = _base(field) if value is None: getattr(c, field).add(entry) else: c.capitalization_exceptions[entry] = value return c def _reading(c: Constants, text: str) -> tuple[dict[str, str], str]: name = HumanName(text, constants=c) fields = name.as_dict() name.capitalize(force=True) return fields, str(name) @pytest.mark.parametrize(("field", "entry", "text", "value"), _V1_UNMATCHABLE_ROWS) def test_an_entry_v1_could_not_match_is_dropped_with_one_warning( field: str, entry: str, text: str, value: str | None, ) -> None: baseline = _reading(_base(field), text) c = _with_entry(field, entry, value) with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") ignore_removals() got = _reading(c, text) assert got == baseline messages = [str(w.message) for w in caught] assert len(messages) == 1, messages assert caught[0].category is UserWarning # a set field stores the lc()-folded entry, a mask key as written shown = entry if value is not None else lc(entry) assert f"{field}: {shown!r}" in messages[0] # attributed to the caller's line, not to library code assert caught[0].filename == __file__ @pytest.mark.parametrize(("field", "entry", "text", "value"), _V1_UNMATCHABLE_ROWS) def test_the_unmatchable_entry_filter_has_a_recorded_control( field: str, entry: str, text: str, value: str | None, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(_config_shim, "_v1_matchable", lambda e: True) # the three rows that recorded "raises" through the two cross-set # checks ('ma ', 'ba ', "'t ") raise only while those compare RAW # spellings; the shim now folds them there (#582), which would turn # them into "inert". This control measures the filter alone, so that # fold is switched off beside it. monkeypatch.setattr(_config_shim, "_normalize", lambda e: e) baseline = _reading(_base(field), text) c = _with_entry(field, entry, value) with warnings.catch_warnings(): warnings.simplefilter("ignore") # Lexicon's multi-word warning try: got = _reading(c, text) except ValueError: outcome = "raises" else: outcome = "activates" if got != baseline else "inert" assert outcome == _UNFILTERED_OUTCOME[(field, entry)] # -- an edge full stop and the two cross-set checks (#582) --------------- # # Lexicon strips a CJK full stop at an entry's edge, v1's lc() does not. # Lexicon folds each field on its own, but re-checks two relations # between separately built sets AFTER its fold, and the shim built those # two from raw strings: 'ma。' beside the ambiguous 'ma' (suffix word vs # ambiguous acronym) and a bound 'zed。' beside the particle 'zed' (bound # given name vs particles_ambiguous). 1.4.0 through 2.2.0 accepted both. # Each row: (id, plain entries as (field, word), the field and spelling # carrying the edge full stop, text). _EDGE_COLLISION_ROWS = [ # (case, the config's entries, text): each fold folds BOTH of its # operands, so each computation gets a row with the full stop on # either side of the pair ("ambiguous-vs-word", [("suffix_not_acronyms", "ma\u3002")], "jack ma"), ("word-vs-ambiguous", [("suffix_acronyms", "zq\u3002"), ("suffix_acronyms_ambiguous", "zq\u3002"), ("suffix_not_acronyms", "zq")], "john smith zq"), ("bound-vs-particle", [("prefixes", "zed"), ("non_first_name_prefixes", "zed"), ("bound_first_names", "zed\u3002")], "zed bakr smith"), ("particle-vs-bound", [("prefixes", "zed\u3002"), ("non_first_name_prefixes", "zed\u3002"), ("bound_first_names", "zed")], "zed bakr smith"), ] # Recorded negative control: with the shim's fold switched to identity # all four raise ValueError at the first parse (measured 2026-10-02). # It measures the raise only; the readings are pinned by the tests below. _UNFOLDED_OUTCOME = { "ambiguous-vs-word": "raises", "word-vs-ambiguous": "raises", "bound-vs-particle": "raises", "particle-vs-bound": "raises", } def _edge_config(entries: list[tuple[str, str]]) -> Constants: c = Constants() for field, word in entries: getattr(c, field).add(word) return c @pytest.mark.parametrize(("case", "entries", "text"), _EDGE_COLLISION_ROWS, ids=[r[0] for r in _EDGE_COLLISION_ROWS]) def test_an_edge_full_stop_collision_reads_as_its_folded_spelling( case: str, entries: list[tuple[str, str]], text: str, ) -> None: folded = [(f, w.strip("\u3002\uff0e\uff61")) for f, w in entries] want = _reading(_edge_config(folded), text) got = _reading(_edge_config(entries), text) # error filter: no warning assert got == want @pytest.mark.parametrize(("case", "entries", "text"), _EDGE_COLLISION_ROWS, ids=[r[0] for r in _EDGE_COLLISION_ROWS]) def test_the_edge_full_stop_fold_has_a_recorded_control( case: str, entries: list[tuple[str, str]], text: str, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(_config_shim, "_normalize", lambda e: e) try: _reading(_edge_config(entries), text) except ValueError: outcome = "raises" else: outcome = "reads" assert outcome == _UNFOLDED_OUTCOME[case] def _fields(first: str, last: str, suffix: str, title: str = "") -> dict[str, str]: return {"title": title, "first": first, "middle": "", "last": last, "suffix": suffix, "nickname": "", "maiden": ""} # Readings recorded 2026-10-02 on the tree before any shim fold (afc45f35, # a scratch `git archive` copy), where these configs never raised: the # fold must not move them. A field Lexicon folds on its own is NOT # re-folded by the shim, so 'zz。' in non_first_name_prefixes beside the # particle 'zz' still leaves 'zz' a given name. _UNCHANGED_READINGS = [ ("never-given-edge-stop", [("prefixes", "zz")], "non_first_name_prefixes", "zz\u3002", "zz smith", (_fields("zz", "smith", ""), "Zz Smith")), ("never-given-fullwidth-stop", [("prefixes", "zz")], "non_first_name_prefixes", "zz\uff0e", "zz smith", (_fields("zz", "smith", ""), "Zz Smith")), ("never-given-nfd-nfc", [("prefixes", "z\u00e9")], "non_first_name_prefixes", "ze\u0301", "z\u00e9 smith", (_fields("z\u00e9", "smith", ""), "Z\u00e9 Smith")), ("ambiguous-edge-stop", [("suffix_acronyms", "zq")], "suffix_acronyms_ambiguous", "zq\u3002", "john zq", (_fields("john", "", "zq"), "John ZQ")), ("title-edge-stop", [], "titles", "dean\u3002", "dean john smith", (_fields("john", "smith", "", "dean"), "Dean John Smith")), ] @pytest.mark.parametrize(("case", "plain", "field", "entry", "text", "want"), _UNCHANGED_READINGS, ids=[r[0] for r in _UNCHANGED_READINGS]) def test_an_edge_full_stop_entry_outside_the_two_checks_reads_as_before( case: str, plain: list[tuple[str, str]], field: str, entry: str, text: str, want: tuple[dict[str, str], str], ) -> None: assert _reading(_edge_config([*plain, (field, entry)]), text) == want def test_the_offered_remedy_runs_and_silences_the_warning() -> None: c = Constants() c.titles.add(" dean ") c.bound_first_names.add("'t ") # a quote the remedy must repr c.capitalization_exceptions[" zzc "] = "ZzC" c.capitalization_exceptions["'q "] = "Q" with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") ignore_removals() HumanName("x", constants=c) assert len(caught) == 1 # one warning for them all message = str(caught[0].message) listing, remedy = message.split("remove these: ") for label in ("titles: ' dean '", "bound_first_names: \"'t \"", "capitalization_exceptions: ' zzc '", "capitalization_exceptions: \"'q \""): assert label in listing, label # the offered spelling, pinned, and then run as written # (in the shim's field order, entries sorted within a field) assert remedy == ( "constants.bound_first_names.remove(\"'t \"); " "constants.titles.remove(' dean '); " "del constants.capitalization_exceptions[' zzc ']; " "del constants.capitalization_exceptions[\"'q \"]") exec(remedy, {"constants": c}) # the code the message hands over c.titles.add("dean") # the stripped word, if it was meant c.capitalization_exceptions["zzc"] = "ZzC" name = HumanName("dean john zzc", constants=c) # no warning: error filter assert name.title == "dean" # as 1.4.0 reads "dean" name.capitalize(force=True) assert str(name) == "Dean John ZzC" def test_the_1_4_typo_roster_is_keyed_by_field() -> None: # 'actor ' is silent only where 1.4.0 shipped it, in titles c = Constants() c.prefixes.add("actor ") with pytest.warns(UserWarning, match="prefixes: 'actor '"): HumanName("x", constants=c) def test_the_warning_fires_once_per_config_change() -> None: c = Constants() c.titles.add(" dean ") with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") ignore_removals() HumanName("dean john smith", constants=c) HumanName("dean john smith", constants=c) # cached snapshot assert len(caught) == 1 c.titles.add("x") # a new generation HumanName("dean john smith", constants=c) assert len(caught) == 2 def test_a_clean_config_snapshots_without_a_warning() -> None: c = Constants() c.titles.add("dean") c.capitalization_exceptions["McDonald"] = "McDonald" # case: accepted widening lexicon, _, _ = c._snapshot() # error filter: any warning fails assert "dean" in lexicon.titles def test_the_1_4_shipped_typos_are_dropped_silently() -> None: c = Constants() c.titles.remove("actor") c.titles.add("actor ") name = HumanName("Actor John Smith", constants=c) # error filter assert (name.first, name.title) == ("Actor", "") def test_the_1_4_shipped_unmatchable_roster_is_exactly_the_pickles() -> None: with open(_DATA_DIR / "constants_v14.pickle", "rb") as f: c = pickle.load(f) found = { (field, entry) for field in _config_shim._SET_FIELDS for entry in getattr(c, field) if not _config_shim._v1_matchable(entry) } | { ("capitalization_exceptions", key) for key in c.capitalization_exceptions if not _config_shim._v1_matchable(key) } assert found == _config_shim._V14_SHIPPED_UNMATCHABLE def test_a_first_name_title_lexicon_folds_to_nothing_is_dropped_with_the_warning( ) -> None: # '。' survives v1's lc() but Lexicon's fold empties it (#582): # dropped like any other entry that matches no name word c = Constants() c.first_name_titles.add("。") with pytest.warns(UserWarning, match="first_name_titles: '。'"): lexicon, _, _ = c._snapshot() assert "" not in lexicon.given_name_titles assert HumanName("john smith", constants=c).last == "smith" def test_snapshot_cleared_titles_turn_the_title_phrase_off() -> None: # rules/H9's titles_ambiguous holds words no v1 set holds, so the # one v1 way to turn title detection off -- emptying `titles` -- has # to drop them too, or 'Dean of Students' stays a title where 1.4 # read none (the design-docs review of #752 phase 2i) c = Constants() assert HumanName("Dean of Students Jane Smith", constants=c).title == ( "Dean of Students") # baseline c.titles.clear() lexicon, _, _ = c._snapshot() assert lexicon.titles_ambiguous == frozenset() assert HumanName("Dean of Students Jane Smith", constants=c).title == "" # A title 2.6.0 listed that a later release moved into TITLES_AMBIGUOUS, # a title only inside a title phrase (rules/H9), is still a v1 title: # derived from the compiled sets, so a batch that moves another word is # swept here with no edit (decisions/H9, 2026-10-26). _MOVED_TITLES = sorted(TITLES_2_6_0 & TITLES_AMBIGUOUS) def _phrase_probe(word: str) -> str: # a name reading the word as a title by default only through the # phrase: behind a style, or ahead of a head title for a modifier if word in TITLE_MODIFIERS: return f"{word.title()} Commander Smith" return f"Hon. {word.title()} Smith" def test_the_moved_titles_are_found() -> None: # an empty parametrize is a skip, not a failure assert _MOVED_TITLES def test_a_v1_titles_default_holds_every_2_6_0_title_still_read_as_one() -> None: # membership, iteration and len, against the frozen 2.6.0 list: a # word still read as a title of either kind stays a v1 title, while # one taken out of the vocabulary since may leave kept = TITLES_2_6_0 & (TITLES | TITLES_AMBIGUOUS) titles = Constants().titles assert kept <= set(titles) assert all(w in titles for w in kept) assert len(titles) == len(TITLES | kept) # and a word 2.6.0 never listed stays out, so the 1.4 recipe # `c.titles.add("dean")` still adds it assert set(titles) - TITLES - TITLES_2_6_0 == set() @pytest.mark.parametrize("word", _MOVED_TITLES) def test_a_moved_title_reads_by_its_phrase_while_v1_keeps_it(word: str) -> None: c = Constants() assert word in c.titles assert c.titles & {word} == {word} and {word} - c.titles == set() lexicon, _, _ = c._snapshot() assert word not in lexicon.titles and word in lexicon.titles_ambiguous # reachability: the probe reads the word as a title only by the phrase assert word.title() in HumanName(_phrase_probe(word), constants=c).title @pytest.mark.parametrize("op", ["remove", "discard"]) @pytest.mark.parametrize("word", _MOVED_TITLES) def test_removing_a_moved_title_turns_every_title_reading_off( word: str, op: str) -> None: # 2.6.0 accepted c.titles.remove(word) and read the word as a name # everywhere after; the phrase reading goes with it c = Constants() getattr(c.titles, op)(word) # must not raise assert word not in c.titles lexicon, _, _ = c._snapshot() assert word not in lexicon.titles assert word not in lexicon.titles_ambiguous assert word not in lexicon.title_modifiers | lexicon.title_styles assert not any(word in p.split(" ") for p in lexicon.title_phrases) assert word.title() not in HumanName( _phrase_probe(word), constants=c).title name = HumanName(f"Hon. {word.title()} Smith", constants=c) assert (name.title, name.first) == ("Hon.", word.title()) @pytest.mark.parametrize("word", _MOVED_TITLES) def test_adding_a_moved_title_back_restores_the_default(word: str) -> None: # re-adding gives the default reading, the phrase-only one; 2.6.0's # title-everywhere reading has no v1 spelling (decisions/H9) c = Constants() c.titles.remove(word) c.titles.add(word) assert c._snapshot()[0] == Lexicon.default() @pytest.mark.parametrize("word", _MOVED_TITLES) def test_a_moved_title_survives_a_pickle_and_a_deepcopy(word: str) -> None: c = Constants() for copied in (pickle.loads(pickle.dumps(c)), copy.deepcopy(c), copy.copy(c)): assert word in copied.titles assert copied._snapshot()[0] == Lexicon.default() c.titles.remove(word) for copied in (pickle.loads(pickle.dumps(c)), copy.deepcopy(c)): assert word not in copied.titles assert word not in copied._snapshot()[0].titles_ambiguous def test_a_replaced_titles_list_holding_a_moved_title_reads_it_by_phrase() -> None: # Constants(titles=...) replaces the list; a moved word in it is # read as the default reads it, a title inside a phrase word = _MOVED_TITLES[0] c = Constants(titles=["dr", word]) lexicon, _, _ = c._snapshot() assert lexicon.titles == {"dr"} assert word in lexicon.titles_ambiguous @pytest.mark.parametrize("word", _MOVED_TITLES) def test_a_moved_title_spelled_with_a_full_stop_reads_as_the_default( word: str) -> None: # compared folded (#582): Lexicon folds 'king。' onto 'king', so it # reads as the listed spelling does, by the phrase, a modifier's # reading included (`Wing Commander Smith`) c = Constants() c.titles.remove(word) c.titles.add(f"{word}。") assert c._snapshot()[0] == Lexicon.default() assert HumanName(f"{word.title()} Smith", constants=c).first == ( word.title()) assert word.title() in HumanName( _phrase_probe(word), constants=c).title def test_a_replaced_titles_list_keeps_the_phrase_reading_of_unlisted_words( ) -> None: # decisions/H9's Standing line: a replaced list keeps the phrase # reading of the words 2.6.0 never listed c = Constants() c.titles.clear() c.titles.add("dr") assert HumanName("Dean of Students Jane Smith", constants=c).title == ( "Dean of Students") def test_a_v1_title_never_listed_by_2_6_0_is_a_title_everywhere() -> None: # the 1.4 recipe for the phrase-only words 2.6.0 never listed c = Constants() assert "dean" not in c.titles c.titles.add("dean") assert "dean" in c._snapshot()[0].titles assert HumanName("Dean Smith", constants=c).title == "Dean" def test_snapshot_a_given_name_written_short_listed_as_a_title_is_a_title() -> None: # #789: given_name_abbreviations rides in the snapshot whole, no v1 # manager reaching it; the v1 recipe that lists the word as a title # (`c.titles.add("chas")`) gets 1.4's reading back, the leading peel # asking the titles before the shape the set outranks c = Constants() assert HumanName("Chas. Smith", constants=c).first == "Chas." # baseline c.titles.add("chas") assert "chas" in c._snapshot()[0].given_name_abbreviations assert HumanName("Chas. Smith", constants=c).title == "Chas." ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_contracts.py����������������������������������������0000664�0000000�0000000�00000027722�15273032717�0024023�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Stable-string contract tests: every enum member and stable tag has a canonical triggering input, parametrized by iterating the registries -- a new member without an entry here fails loudly.""" import pytest from nameparser import Parser, Policy, parse from nameparser._policy import PatronymicRule from nameparser._types import STABLE_TAGS, AmbiguityKind _AMBIGUITY_TRIGGERS: dict[AmbiguityKind, str | None] = { AmbiguityKind.PARTICLE_OR_GIVEN: "Van Johnson", # #479 row 1, all-upper: 'E' is a marked single-letter connective # and the name's one case says nothing about which reading is meant AmbiguityKind.CONJUNCTION_OR_INITIAL: "JOSE E MARIA SANTOS", AmbiguityKind.UNBALANCED_DELIMITER: 'Jon "Nick Smith', AmbiguityKind.COMMA_STRUCTURE: "Smith, John, Extra, Jr.", AmbiguityKind.SUFFIX_OR_NICKNAME: "JEFFREY (JD) BRICKEN", # Title case: capitals in a name of mixed case settle the word and # report nothing (rules/S6, #785) AmbiguityKind.SUFFIX_OR_NAME: "John Smith Ma", AmbiguityKind.GIVEN_OR_FAMILY: "Andrew", AmbiguityKind.TITLE_OR_NAME: "Lord Chancellor", # no emitter yet -- arrives with locale-pack order detection (2.x) AmbiguityKind.ORDER: None, # 남 and 남궁 are both shipped surnames, so longest-first picks # between two vocabulary-supported splits AmbiguityKind.SEGMENTATION: "남궁민수", } @pytest.mark.parametrize("kind", [ # the per-entry comment above says WHY each None is None (no # emitter yet, or an emitter no default parse can reach) pytest.param(k, marks=pytest.mark.xfail( strict=True, reason=f"{k.value}: no trigger registered yet")) if k in _AMBIGUITY_TRIGGERS and _AMBIGUITY_TRIGGERS[k] is None else k for k in AmbiguityKind ]) def test_every_ambiguity_kind_has_a_registered_trigger( kind: AmbiguityKind) -> None: assert kind in _AMBIGUITY_TRIGGERS, ( f"new AmbiguityKind {kind.value!r} needs a canonical trigger " f"(or an explicit None with its planned emitter)") trigger = _AMBIGUITY_TRIGGERS[kind] assert trigger is not None # None triggers are strict-xfail marked kinds = {a.kind for a in parse(trigger).ambiguities} assert kind in kinds _PATRONYMIC_TRIGGERS: dict[PatronymicRule, tuple[str, str]] = { # rule -> (input, expected given) PatronymicRule.EAST_SLAVIC: ("Сидоров Иван Петрович", "Иван"), PatronymicRule.TURKIC: ("Mammadova Aygun Ali kizi", "Aygun"), } @pytest.mark.parametrize("rule", list(PatronymicRule)) def test_every_patronymic_rule_has_a_trigger(rule: PatronymicRule) -> None: assert rule in _PATRONYMIC_TRIGGERS text, expected_given = _PATRONYMIC_TRIGGERS[rule] p = Parser(policy=Policy(patronymic_rules=frozenset({rule}))) assert p.parse(text).given == expected_given _TAG_TRIGGERS: dict[str, tuple[str, str]] = { # tag -> (input, token text carrying the tag) "particle": ("Juan de la Vega", "de"), "conjunction": ("Mr. and Mrs. John Smith", "and"), "initial": ("John A. Smith", "A."), "joined": ("John Ph. D.", "D."), } @pytest.mark.parametrize("tag", sorted(STABLE_TAGS)) def test_every_stable_tag_has_a_trigger(tag: str) -> None: assert tag in _TAG_TRIGGERS text, token_text = _TAG_TRIGGERS[tag] pn = parse(text) tagged = next(t for t in pn.tokens if t.text == token_text) assert tag in tagged.tags def test_every_guarded_config_module_is_imported() -> None: """Import-time asserts only run if the module is imported. Since the vocabulary moved to nameparser/vocab/*.toml, whose compiler checks it (tests/v2/test_vocab.py), the asserts left are on data still written in Python -- ``_ko_surnames`` today (the Korean census list, ``surnames.py``'s until 2.8.2), which is lazy (imported inside ``_snapshot``/``_default_lexicon``), so its guard fires only incidentally, whenever some other test happens to build a default Lexicon. Importing them all here makes every guard unconditional. The roster is DERIVED from the source tree, not listed: a hand-written tuple fails open, silently skipping the next guarded module nobody remembers to add. """ import importlib import pathlib import nameparser.config config_dir = pathlib.Path(nameparser.config.__file__).parent # _invariants excluded: it DEFINES the guard (the substring hits its # own def) and holds no vocabulary of its own. Private modules are # read, since the data moved to one in 2.8.2 guarded = sorted( p.stem for p in config_dir.glob("*.py") if p.stem != "_invariants" and "assert_normalized(" in p.read_text(encoding="utf-8")) assert guarded, ( f"no guarded config module found in {config_dir} -- the " f"derivation broke, and an empty roster asserts nothing") for name in guarded: importlib.import_module(f"nameparser.config.{name}") def test_every_vocabulary_constant_is_frozen() -> None: """A module vocabulary constant must not be mutable (#293). ``Lexicon.default()`` is ``functools.cache``d and reads these sets once, at its first call, while the v1 shim's ``Constants`` copy from them at every construction. A runtime ``TITLES.add("dean")`` was therefore always visible to a freshly built ``Constants``, and visible to the default ``Lexicon`` only when it landed before the first parse -- after that the cache was already built and the same edit was invisible there. Two APIs disagreeing about their own defaults, decided by construction order, and no way from the mutating code to tell which branch it was on. Frozen makes that unrepresentable: the mutation raises where it is written. It also carries more than it did. ``_default_lexicon()`` used to wrap every constant in ``frozenset(...)`` on the way into the ``Lexicon``; #293 dropped the wraps because the sources are frozen, which makes this test the only thing anywhere that checks they still are. The vocabulary build's checks (tools/vocab/build.py) cover normalization and subset relations, never mutability. The roster is DERIVED from the source tree for the same reason the guarded-module roster above is: a hand-written list fails open on the next module or the next constant. ``rglob``, not ``glob``, so a future ``config/`` subpackage is in scope from the day it lands rather than from the day someone notices. Scope stops at ``nameparser/config``, which is where the hazard is: three consumers read these constants at three different moments (the cached ``Lexicon.default()``, a per-construction ``Constants``, the import-time ``CONSTANTS``), so a mutable one lets two defaults disagree. A locale pack has one consumer and one moment -- the ``Lexicon(...)`` in its own module body, whose fields are frozen copies -- so ``locales/zh.py``'s ``_SURNAMES`` could not desync anything even as a plain ``set``. It is a ``frozenset`` anyway. The two deprecated alias modules are in the glob too and contribute nothing: the bridge deliberately does not write a resolved value back into their globals (see ``config/_deprecated.py``), so their ``vars()`` never gains a vocabulary name however often it is read, and the roster does not depend on what ran first. """ import importlib import pathlib import nameparser.config # The two mapping constants, EXEMPT and named rather than dropped by # the isinstance filter without comment. REGEXES is a compiled- # pattern table rather than vocabulary and was never in #293's # scope. CAPITALIZATION_EXCEPTIONS is vocabulary-shaped and is a # decided, in-scope exemption, which means the split-default hazard # the freeze closes is STILL LIVE for it -- an edit reaches a # freshly built Constants and neither the cached Lexicon.default() # nor the shared CONSTANTS. Written down here, in docs/migrate.rst # and in AGENTS.md so it does not read as covered. exempt_mappings = { "capitalization.CAPITALIZATION_EXCEPTIONS", "regexes.REGEXES", # the same dict, by the name the facade reads without the # warning 2.8.1 puts on REGEXES (#1008) "regexes._REGEXES", } config_dir = pathlib.Path(nameparser.config.__file__).parent checked = [] offenders = [] exempt_seen = set() for path in sorted(config_dir.rglob("*.py")): relative = path.relative_to(config_dir).with_suffix("") if any(part.startswith("_") for part in relative.parts): continue stem = ".".join(relative.parts) module = importlib.import_module(f"nameparser.config.{stem}") # since 2.8.1 a constant 3.0 removes is held by the module's # __getattr__ rather than its globals (#1007, #1008) served = getattr(vars(module).get("__getattr__"), "removed_values", {}) for name, value in sorted({**vars(module), **served}.items()): if not name.isupper(): continue qualified = f"{stem}.{name}" if isinstance(value, dict): assert qualified in exempt_mappings, ( f"{qualified} is a mutable mapping constant with no " f"recorded exemption; freeze it, or add it to " f"exempt_mappings with the reason it stays mutable") exempt_seen.add(qualified) continue if not isinstance(value, (set, frozenset)): continue checked.append(qualified) if not isinstance(value, frozenset): offenders.append(qualified) assert exempt_seen == exempt_mappings, ( f"exempt_mappings names {sorted(exempt_mappings - exempt_seen)}, " f"which the sweep never found -- a stale exemption hides the " f"next mutable mapping that inherits the name") # A FLOOR, not a presence check: `assert checked` is satisfied by # one surviving constant, so a filter or a path change that quietly # dropped twelve of the thirteen would still read as a pass. Thirteen # distinct constants across seven modules (measured 2026-10-18; the # fourteenth entry before then was particles.BOUND_GIVEN_NAMES, an # import for a disjointness assert that became a vocabulary build # check). The private _compiled.py they are assigned from is skipped # with the other private modules: its objects are the same ones. # Raise this when a constant is added; a drop is the regression. assert len(checked) >= 13, ( f"only {len(checked)} vocabulary set constants found under " f"{config_dir} ({checked}) -- the derivation shrank, and a " f"roster that shrinks silently stops guarding silently") assert not offenders, ( f"vocabulary constants must be frozensets (#293): {offenders}") def test_the_documented_replacements_for_an_in_place_edit_work() -> None: """``docs/migrate.rst``'s two recipes, as behavior (#293). Both live there as ``::`` literal blocks, which ``sphinx -b doctest`` never runs -- so the page that tells a 1.x caller what to do INSTEAD of ``TITLES.add("dean")`` was the one claim about the freeze with nothing checking it. "dean" is the canonical example for this: a common academic title and a common given name, so it is deliberately absent from the shipped ``TITLES`` and a caller who wants it has to add it themselves. """ from nameparser import HumanName, Lexicon, Parser from nameparser.config import Constants from nameparser.config.titles import TITLES # what the freeze retired with pytest.raises(AttributeError): TITLES.add("dean") # type: ignore[attr-defined] assert HumanName("Dean Smith").title == "" # recipe 1: a private Constants for the v1 API constants = Constants() constants.titles.add("dean") assert HumanName("Dean Smith", constants=constants).title == "Dean" # recipe 2: an extended Lexicon for the 2.0 API parser = Parser(lexicon=Lexicon.default().add(titles={"dean"})) assert parser.parse("Dean Smith").title == "Dean" ����������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_corpora.py������������������������������������������0000664�0000000�0000000�00000021734�15273032717�0023465�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""The corpora under tools/differential/ that are GENERATED stay equal to what their generators would write, and every corpus line is one the snapshot can read. A generated corpus that falls behind its source silently narrows the names snapshot.jsonl watches: a case row or a rules example added without regenerating is then read by nothing but its own test, which moves with the change it pins. These pins make a stale corpus fail instead. """ import json from pathlib import Path import pytest from ._differential_fixtures import _TOOLS, load_tool snapshot = load_tool("snapshot") def _checked_in(name: str) -> list[object]: return [json.loads(line) for line in (_TOOLS / name).read_text(encoding="utf-8").splitlines() if line.strip()] def test_cjk_corpus_matches_the_case_table() -> None: """corpus_cjk.jsonl is every distinct case-table text bearing a codepoint the script table classifies (#295; build_cjk_corpus.py says why no other corpus can carry these names).""" module = load_tool("build_cjk_corpus") assert _checked_in("corpus_cjk.jsonl") == module.selected_names(), ( "corpus_cjk.jsonl is stale: regenerate with " "`uv run python tools/differential/build_cjk_corpus.py`") def test_shapes_corpus_matches_the_case_table() -> None: """corpus_shapes.jsonl is generated from the shape-tagged case rows.""" module = load_tool("build_shapes_corpus") assert _checked_in("corpus_shapes.jsonl") == module.selected(), ( "corpus_shapes.jsonl is stale: regenerate with " "`uv run python tools/differential/build_shapes_corpus.py`") def test_conventions_corpus_matches_its_table() -> None: """corpus_conventions.jsonl and conventions_fields.jsonl are generated from conventions.py's hand-written table (#686).""" module = load_tool("build_conventions_corpus") for name, expected in (("corpus_conventions.jsonl", module.selected()), ("conventions_fields.jsonl", module.fields_rows())): assert _checked_in(name) == expected, ( f"{name} is stale: regenerate with " "`uv run python tools/differential/build_conventions_corpus.py`") def test_case_shape_ids_exist_in_the_inventory() -> None: """cases.py cannot import tools/, so Case.__post_init__ validates every shape tag against its own hand copy of the inventory's ids (cases._SHAPE_IDS). The equality holds that copy honest; the subset check catches a row tagged under a stale copy.""" from tests.v2 import cases shapes = load_tool("shapes") tagged = {c.shape for c in cases.CASES if c.shape is not None} assert tagged <= set(shapes.SHAPES), ( f"tests/v2/cases.py tags shape id(s) " f"{sorted(tagged - set(shapes.SHAPES))} that are not in " f"tools/differential/shapes.py's SHAPES; add the shape there " f"or fix the tag in cases.py") assert cases._SHAPE_IDS == set(shapes.SHAPES), ( f"cases._SHAPE_IDS {sorted(cases._SHAPE_IDS)} != shapes.py's " f"{sorted(shapes.SHAPES)}; they are hand copies of one " f"inventory -- update whichever side is stale") def test_rules_corpus_matches_the_rules_doc() -> None: """corpus_rules.jsonl is generated from docs/design/rules/'s own examples (#414), through the doc's parser. test_rules_doc executes every example against an expectation stored beside it, so a deliberate behavior change edits both and stays green; the snapshot is what shows the moved name in the diff.""" module = load_tool("build_rules_corpus") assert _checked_in("corpus_rules.jsonl") == module.selected_names(), ( "corpus_rules.jsonl is stale: regenerate with " "`uv run python tools/differential/build_rules_corpus.py`") # The equality cannot see a generator that silently selects LESS: # regenerating then agrees with the degraded selection. The parser # refuses a malformed rule outright, so a degraded selection now # means rules deleted from the tree; a rule losing every example is # the `empty` check's below. from .design_docs import read_rules_text from .rules_doc import parse_rules_doc rules = parse_rules_doc(read_rules_text()) assert len(rules) >= 70, ( f"docs/design/rules/ parsed to {len(rules)} rules, fewer than the 70 " f"this floor was ratcheted to (2026-10-20). A deleted rule shrinks " f"the corpus silently. Ratchet this floor up when rules are added") empty = [r.rule_id for r in rules if not any(e.text for e in r.examples)] assert empty == ["D1", "D2"], ( f"rules with no example NAME: {empty}. D1 and D2 are the " f"expected two -- their examples name a policy rather than a " f"name string. Any other rule contributing nothing means its " f"examples stopped being recognized as examples") _ORDERS = {k: s.order for k, s in load_tool("shapes").SHAPES.items()} @pytest.mark.parametrize("line,expected", [ ('"John Smith"', ("John Smith", None)), ('{"name": "John Smith", "tests": ["t"]}', ("John Smith", None)), ('{"name": "Ménil Christophe du", "shape": 4}', ("Ménil Christophe du", "FAMILY_FIRST")), ]) def test_a_corpus_line_reads_as_a_name_and_order( line: str, expected: tuple[str, str | None]) -> None: assert snapshot._corpus_line(Path("corpus_x.jsonl"), line, _ORDERS) == expected @pytest.mark.parametrize("line,match", [ # A misspelled key would drop the shape the line declares and read # the name under the default order with nothing saying so. ('{"name": "John Smith", "shpae": 4}', r"corpus_x\.jsonl.*'shpae'"), ('{"name": "John Smith", "order": "FAMILY_FIRST"}', "'order'"), ('{"name": "John Smith", "tests": "t"}', "'tests' must be a list"), # bool is an int subclass and hash(True) == hash(1): unrefused, it # would read the name under shape 1's order. ('{"name": "John Smith", "shape": true}', "'shape' must name"), ('{"name": "John Smith", "shape": 99}', "'shape' must name"), ('{"nmae": "John Smith"}', "neither a JSON string"), ]) def test_a_malformed_corpus_line_is_a_hard_error(line: str, match: str) -> None: with pytest.raises(SystemExit, match=match): snapshot._corpus_line(Path("corpus_x.jsonl"), line, _ORDERS) harvester = load_tool("build_issues_corpus") def test_a_backticked_name_is_harvested() -> None: """This tracker writes names in backticks, because issues are markdown. Matching only quoted names left the corpus whose whole purpose is "what users reported" blind to how this project reports (#413). """ assert harvester._harvest( "the tussenvoegsel in `Beethoven, Ludwig van` is filed behind " "the given name") == {"Beethoven, Ludwig van"} def test_a_call_inside_backticks_yields_the_name_not_the_call() -> None: """The reason backticks are a SECOND PASS rather than a third alternative in one pattern. A reporter writes the failing input as a call inside a code span. Regex scanning takes the leftmost match, so a backtick alternative starts one character before the HumanName branch can and swallows the whole span -- harvesting the call and losing the name inside it. This is the bug the two-pass structure exists to prevent, and it is reachable from a single string. """ got = harvester._harvest('reported as `HumanName("John Q Doe")`') assert got == {"John Q Doe"} # ... and a call anywhere in the span, not only at its start assert not any("HumanName(" in n for n in harvester._harvest('see `Foo HumanName("Jane Roe")`')) def test_a_sentence_is_not_a_name() -> None: """Prose is quoted and backticked as often as names are, and a capitalized phrase is well-formed enough that the character screen cannot see it. """ for text in ("`What this gate does not cover` is the section", "the note said `Takes everything` about it", "`C is CONSTANTS` in that example", "wrote `NO Latin twins on purpose` there", "raised `TypeError: a bytes-like object is required`"): assert harvester._harvest(text) == set(), text def test_the_sentence_screen_keeps_its_hands_off_names() -> None: """The screen matches a function word only where it is written LOWERCASE, which is what keeps it off names: a name capitalizes its words and a sentence's function words do not. Every name here contains a word that is in the stop list, and every one is a real naming convention -- Burmese, Norwegian, Estonian, Cantonese, Luhya, Dutch, Lakota. Without the case test the screen drops all of them, silently, from the one corpus meant to catch what users report. """ for name in ("U Than Shwe", "Sven Are Olsen", "Kertu Must", "Chan On Kei", "Wafula Were", "John Does", "Marie Takes War Bonnet", "Rob And Beth Edmunds", "Duke of Wellington", "Anh Do", "Will De Groot"): assert harvester._harvest(f"reported `{name}` today") == {name}, name ������������������������������������derek73-python-nameparser-456b024/tests/v2/test_corpus_build.py�������������������������������������0000664�0000000�0000000�00000020113�15273032717�0024500�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Offline checks on tools/differential/corpus_build.py, the helpers the labeled-corpus builders share. The builders' own tests reach these helpers through one builder at a time; what is pinned here is each helper's contract, and that every builder can find the module at all. tools/ is not a package, so modules are loaded by path, as _differential_fixtures.py loads compare.py. """ import json import subprocess import sys import time import urllib.request from pathlib import Path import pytest from ._differential_fixtures import _TOOLS, _Response, load_tool corpus_build = load_tool("corpus_build") _BUILDERS = ("build_cldr_corpus", "build_gnd_corpus", "build_wikidata_corpus", "build_wikidata_native_corpus") @pytest.mark.parametrize("stem", _BUILDERS) def test_each_builder_finds_the_shared_module_on_its_own( stem: str, tmp_path: Path) -> None: # In a fresh interpreter, from an unrelated cwd and with safe-path on: # in the test process an earlier builder's sys.path entry, or a cached # import, would hide a builder that forgot its own. loader = ("import importlib.util as u, sys; " f"s = u.spec_from_file_location('b', {str(_TOOLS / (stem + '.py'))!r}); " "m = u.module_from_spec(s); s.loader.exec_module(m); " "print(m.corpus_lines is sys.modules['corpus_build'].corpus_lines)") result = subprocess.run([sys.executable, "-P", "-c", loader], cwd=tmp_path, capture_output=True, text=True, check=False) assert result.returncode == 0, result.stderr assert result.stdout.strip() == "True" def _record_fetches(monkeypatch: pytest.MonkeyPatch) -> list[tuple[urllib.request.Request, int]]: """Answer every urlopen with b"ok"; each entry is the request and its timeout.""" seen: list[tuple[urllib.request.Request, int]] = [] def urlopen(request: urllib.request.Request, timeout: int) -> _Response: seen.append((request, timeout)) return _Response(b"ok") monkeypatch.setattr(urllib.request, "urlopen", urlopen) return seen def test_fetch_bytes_sends_the_user_agent_and_the_extra_headers( monkeypatch: pytest.MonkeyPatch) -> None: seen = _record_fetches(monkeypatch) body = corpus_build.fetch_bytes("https://x.test/a", user_agent="bot/1", timeout=7, headers={"Accept": "application/json"}) [(request, timeout)] = seen assert body == b"ok" and timeout == 7 assert request.get_method() == "GET" and request.data is None assert request.get_header("User-agent") == "bot/1" assert request.get_header("Accept") == "application/json" def test_fetch_bytes_posts_when_given_data(monkeypatch: pytest.MonkeyPatch) -> None: seen = _record_fetches(monkeypatch) corpus_build.fetch_bytes("https://x.test/q", user_agent="bot/1", timeout=1, data=b"a=b") [(request, _)] = seen assert request.get_method() == "POST" and request.data == b"a=b" def test_fetch_bytes_sleeps_only_when_given_a_delay( monkeypatch: pytest.MonkeyPatch) -> None: slept: list[float] = [] _record_fetches(monkeypatch) monkeypatch.setattr(time, "sleep", slept.append) corpus_build.fetch_bytes("https://x.test/a", user_agent="bot/1", timeout=1) assert slept == [] corpus_build.fetch_bytes("https://x.test/a", user_agent="bot/1", timeout=1, delay=1) assert slept == [1] def test_a_failed_fetch_does_not_sleep(monkeypatch: pytest.MonkeyPatch) -> None: slept: list[float] = [] def urlopen(request: urllib.request.Request, timeout: int) -> None: raise OSError("down") monkeypatch.setattr(urllib.request, "urlopen", urlopen) monkeypatch.setattr(time, "sleep", slept.append) with pytest.raises(OSError, match="down"): corpus_build.fetch_bytes("https://x.test/a", user_agent="bot/1", timeout=1, delay=1) assert slept == [] def test_a_shape_must_be_an_int_or_none() -> None: with pytest.raises(TypeError, match="shape must be an int or None, not '4'"): corpus_build.corpus_lines([{"text": "A", "shape": "4"}]) def test_an_untagged_line_sorts_before_the_tagged_ones_of_its_text() -> None: # Shape 0 is the case to watch: a key that ties it with None leaves # the order to set iteration, which a single text can pass by luck. texts = [f"name {i}" for i in range(40)] rows = [{"text": t, "shape": shape} for t in texts for shape in (0, None, 4)] assert corpus_build.corpus_lines(rows) == [ line for t in sorted(texts) for line in (json.dumps(t), json.dumps({"name": t, "shape": 0}), json.dumps({"name": t, "shape": 4}))] def _row(text: str, order: str = "givenFirst") -> dict[str, object]: return {"file": "Q1", "label": "x", "order": order, "text": text, "shape": None, "fields": {"given": text}} def test_write_corpus_writes_the_corpus_and_the_fields_file(tmp_path: Path) -> None: corpus, fields = tmp_path / "corpus.jsonl", tmp_path / "fields.jsonl" corpus_build.write_corpus([_row("B", "sorting"), _row("A")], corpus, fields) assert corpus.read_text(encoding="utf-8") == '"A"\n"B"\n' assert [json.loads(line)["order"] for line in fields.read_text(encoding="utf-8").splitlines()] == ["givenFirst", "sorting"] def test_a_refused_row_leaves_both_files_as_they_were(tmp_path: Path) -> None: corpus, fields = tmp_path / "corpus.jsonl", tmp_path / "fields.jsonl" corpus.write_text("old corpus\n", encoding="utf-8") fields.write_text("old fields\n", encoding="utf-8") with pytest.raises(ValueError, match="duplicate rendering Q1/x/givenFirst"): corpus_build.write_corpus([_row("A"), _row("B")], corpus, fields) assert corpus.read_text(encoding="utf-8") == "old corpus\n" assert fields.read_text(encoding="utf-8") == "old fields\n" def test_a_failed_write_leaves_both_files_as_they_were( tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: corpus, fields = tmp_path / "corpus.jsonl", tmp_path / "fields.jsonl" corpus.write_text("old corpus\n", encoding="utf-8") fields.write_text("old fields\n", encoding="utf-8") real = Path.write_text def full_disk(self: Path, text: str, encoding: str | None = None) -> int: if self.name.startswith("fields"): raise OSError("No space left on device") return real(self, text, encoding=encoding) monkeypatch.setattr(Path, "write_text", full_disk) with pytest.raises(OSError, match="No space"): corpus_build.write_corpus([_row("A")], corpus, fields) monkeypatch.undo() assert corpus.read_text(encoding="utf-8") == "old corpus\n" assert fields.read_text(encoding="utf-8") == "old fields\n" assert sorted(p.name for p in tmp_path.iterdir()) == ["corpus.jsonl", "fields.jsonl"] def _check(body: bytes | str, source: str) -> dict[str, object]: data = corpus_build.parse_json(body, source) assert isinstance(data, dict) return data def test_an_interrupted_cache_write_leaves_no_cache_file( tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: # The body goes to a .part file and is renamed into place; if the # rename never happens, the next run must not find a cached answer. def interrupted(src: object, dst: object) -> None: raise OSError("interrupted") monkeypatch.setattr(corpus_build.os, "replace", interrupted) with pytest.raises(OSError, match="interrupted"): corpus_build.cached_json(tmp_path, "k", "label", lambda: b'{"a": 1}', _check, refresh=False) assert not list(tmp_path.glob("*.json")) @pytest.mark.parametrize("brk", ["\x85", "\u2028", "\u2029"]) def test_a_jsonl_line_holding_a_line_break_reads_back_as_one_line(brk: str) -> None: # json.dumps writes these raw and str.splitlines() cuts at them; the # writer escapes them, and the reader cuts at "\n" only in case a # file was written some other way. row = {"name": f"Ann{brk}Lee, MD"} line = corpus_build.jsonl_line(row) assert brk not in line and json.loads(line) == row raw = json.dumps(row, ensure_ascii=False) assert [json.loads(x) for x in corpus_build.jsonl_lines(f"{raw}\n\n{raw}\n")] == [row, row] �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_deprecations.py�������������������������������������0000664�0000000�0000000�00000043341�15273032717�0024476�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""2.8's warnings for everything 3.0 removes (decisions/3-0-reevaluations, 2026-10-28). One test per warning, each asserting its category, its message, that it is attributed to the CALLER's file (the stacklevel) and that the code it offers runs. The offered code is also written below as real functions, so ``uv run mypy`` type-checks it, and each test asserts the message quotes that function's source (AGENTS.md: code a message hands the reader has to survive a type checker, and then run). """ from __future__ import annotations import ast import copy import inspect import pickle import subprocess import sys import tomllib import warnings from collections.abc import Callable from pathlib import Path import pytest import nameparser from nameparser import HumanName, Lexicon, ParsedName, Parser, parse from nameparser._config_shim import CONSTANTS, Constants from nameparser.util import _lc from tests.removals import REMOVAL_MESSAGES # -- the code the messages offer, type-checked by mypy ------------------------ def _offered_parse(text: str) -> ParsedName: return nameparser.parse(text) def _offered_store(text: str) -> dict[str, str]: return parse(text).as_dict() def _offered_parser() -> Parser: return Parser(lexicon=Lexicon.default().add(titles={'dean'})) def _offered_lexicon() -> Lexicon: return Lexicon.default().add(titles={'dean'}) def _offered_lc(value: str) -> str: return value.lower().strip('.') def _quoted(fn: Callable[..., object]) -> str: """The expression ``fn`` returns, as its source spells it.""" source = inspect.getsource(fn) return source.split("return ", 1)[1].strip() def _one(caught: list[warnings.WarningMessage], start: str ) -> warnings.WarningMessage: hits = [w for w in caught if str(w.message).startswith(start)] assert len(hits) == 1, [str(w.message) for w in caught] return hits[0] def _assert_removal(w: warnings.WarningMessage, offered: Callable[..., object] ) -> None: assert w.category is DeprecationWarning assert "3.0" in str(w.message) assert _quoted(offered) in str(w.message), (_quoted(offered), w.message) assert w.filename == __file__, w.filename # the caller's line def _record() -> warnings.catch_warnings[list[warnings.WarningMessage]]: return warnings.catch_warnings(record=True) def _from_import(name: str) -> None: # spelled out rather than exec()'d, so the warning's filename is # this file's if name == "SetManager": from nameparser.config import SetManager # noqa: F401 elif name == "TupleManager": from nameparser.config import TupleManager # noqa: F401 else: from nameparser.config import RegexTupleManager # noqa: F401 # -- the warnings ------------------------------------------------------------ def test_constructing_a_humanname_warns_at_the_caller() -> None: with _record() as caught: warnings.simplefilter("always") HumanName("John Smith") w = _one(caught, "HumanName is deprecated") _assert_removal(w, _offered_parse) assert "given and family" in str(w.message) assert _offered_parse("John Smith").given == "John" def test_every_construction_warns_not_once_per_process() -> None: with _record() as caught: warnings.simplefilter("always") HumanName("John Smith") HumanName("Jane Doe") assert sum(str(w.message).startswith("HumanName is deprecated") for w in caught) == 2 def test_a_subclass_warns_at_its_own_super_call() -> None: class Sub(HumanName): def __init__(self, text: str) -> None: super().__init__(text) with _record() as caught: warnings.simplefilter("always") Sub("John Smith") assert _one(caught, "HumanName is deprecated").filename == __file__ def test_loading_a_humanname_pickle_warns_at_the_load() -> None: blob = pickle.dumps(HumanName("John Smith")) with _record() as caught: warnings.simplefilter("always") pickle.loads(blob) w = _one(caught, "HumanName pickles stop loading in 3.0") _assert_removal(w, _offered_store) assert _offered_store("John Smith")["family"] == "Smith" def test_constructing_a_constants_warns_at_the_caller() -> None: with _record() as caught: warnings.simplefilter("always") Constants() w = _one(caught, "Constants is deprecated") _assert_removal(w, _offered_parser) assert _offered_parser().parse("Dean John Smith").title == "Dean" def test_copying_a_constants_warns_as_construction_does() -> None: # CONSTANTS.copy() is a private Constants built past __init__ with _record() as caught: warnings.simplefilter("always") CONSTANTS.copy() _assert_removal(_one(caught, "Constants is deprecated"), _offered_parser) def test_loading_a_constants_pickle_warns_at_the_load() -> None: with warnings.catch_warnings(): warnings.simplefilter("ignore") blob = pickle.dumps(Constants()) with _record() as caught: warnings.simplefilter("always") pickle.loads(blob) w = _one(caught, "Constants pickles stop loading in 3.0") _assert_removal(w, _offered_parser) # what it offers pickles, which is the point of offering it assert pickle.loads(pickle.dumps(_offered_parser())).parse( "Dean John Smith").title == "Dean" def _pop_parenthesis() -> None: # the documented recipe for routing a built-in delimiter between # buckets: a pop() and a __setitem__, two mutations on one line CONSTANTS.maiden_delimiters["parenthesis"] = ( CONSTANTS.nickname_delimiters.pop("parenthesis")) # Every way to mutate the shared singleton, each written in this file so # the caller's line is here. They reach the warning at different depths # (a scalar through Constants.__setattr__, a set through add() and # _changed(), a dict's update() and |= through __setitem__ per key), and # a fixed stacklevel served only the scalar one until 2.8.0's release # review: CONSTANTS.titles.add() was attributed inside the shim, so # Python's default filters hid it. With that fixed stacklevel=3 back, # 10 of these 11 shapes fail, every one but the scalar (2026-10-28). _SHARED_MUTATIONS: dict[str, Callable[[], object]] = { "scalar": lambda: setattr(CONSTANTS, "string_format", "{last}, {first}"), "set add": lambda: CONSTANTS.titles.add("xyzzy"), "set remove": lambda: CONSTANTS.titles.remove("dr"), "set discard": lambda: CONSTANTS.titles.discard("dr"), "set clear": lambda: CONSTANTS.conjunctions.clear(), "dict setitem": lambda: CONSTANTS.capitalization_exceptions.__setitem__( "xyzzy", "XyZzy"), "dict update": lambda: CONSTANTS.capitalization_exceptions.update( {"xyzzy": "XyZzy"}), "dict |=": lambda: CONSTANTS.capitalization_exceptions.__ior__( {"xyzzy": "XyZzy"}), "dict setdefault": lambda: CONSTANTS.capitalization_exceptions.setdefault( "xyzzy", "XyZzy"), "dict pop": lambda: CONSTANTS.capitalization_exceptions.pop("phd"), "delimiter recipe": _pop_parenthesis, } @pytest.mark.parametrize("shape", sorted(_SHARED_MUTATIONS)) def test_mutating_the_shared_constants_warns_at_the_caller(shape: str) -> None: with _record() as caught: warnings.simplefilter("always") # tests/conftest.py restores the singleton after every test _SHARED_MUTATIONS[shape]() hits = [w for w in caught if str(w.message).startswith( "mutating the shared CONSTANTS singleton")] assert hits, [str(w.message) for w in caught] for w in hits: _assert_removal(w, _offered_parser) assert "private Constants" not in str(w.message) @pytest.mark.parametrize("name", ["SetManager", "TupleManager", "RegexTupleManager"]) def test_reading_a_manager_from_nameparser_config_warns_once(name: str) -> None: with _record() as caught: warnings.simplefilter("always") _from_import(name) w = _one(caught, f"nameparser.config.{name} is deprecated") assert w.filename == __file__ _assert_removal(w, _offered_lexicon) assert "dean" in _offered_lexicon().titles import nameparser.config with _record() as caught: warnings.simplefilter("always") getattr(nameparser.config, name) assert _one(caught, f"nameparser.config.{name}").filename == __file__ def test_reading_lc_from_nameparser_util_warns() -> None: import nameparser.util with _record() as caught: warnings.simplefilter("always") lc = nameparser.util.lc w = _one(caught, "nameparser.util.lc is deprecated") _assert_removal(w, _offered_lc) for value in ("M.D.", ".Dr", "", "Smith"): assert _offered_lc(value) == lc(value) == _lc(value) def test_the_hook_override_warning_says_humanname_goes_in_3_0() -> None: class Custom(HumanName): def is_title(self, value: object) -> bool: return False with _record() as caught: warnings.simplefilter("always") Custom("John Smith") w = _one(caught, "Custom overrides v1 parsing hooks") assert w.category is DeprecationWarning assert "HumanName is removed in 3.0" in str(w.message) assert w.filename == __file__ # -- what stays quiet -------------------------------------------------------- def test_copying_an_instance_is_not_a_pickle_load() -> None: with warnings.catch_warnings(): warnings.simplefilter("ignore") hn = HumanName("Dr. John Smith", constants=Constants()) with _record() as caught: warnings.simplefilter("always") for fn in (copy.copy, copy.deepcopy): clone = fn(hn) assert clone.as_dict() == hn.as_dict() assert fn(hn.C).titles == hn.C.titles assert caught == [] # deepcopy copies the config, copy shares it, as before 2.8 assert copy.copy(hn).C is hn.C assert copy.deepcopy(hn).C is not hn.C class _TaggedName(HumanName): # a subclass carrying state of its own through the pickle hooks def __getstate__(self) -> dict[str, object]: return {**super().__getstate__(), "tag": getattr(self, "tag", None)} def __setstate__(self, state: dict[str, object]) -> None: super().__setstate__(state) self.tag = state["tag"] class _ExtraConstants(Constants): # a subclass whose own __init__ sets state, which a restore ran # before 2.8 and runs still def __init__(self, extra: str = "default") -> None: super().__init__() self.extra = extra def test_a_subclass_copies_and_loads_through_its_own_hooks() -> None: with warnings.catch_warnings(): warnings.simplefilter("ignore") tagged = _TaggedName("John Smith") tagged.tag = "T" extra = _ExtraConstants("set") with _record() as caught: warnings.simplefilter("always") for fn in (copy.copy, copy.deepcopy): assert fn(tagged).tag == "T" assert fn(extra).extra == "default" # its __init__ ran assert caught == [] with _record() as caught: warnings.simplefilter("always") assert pickle.loads(pickle.dumps(tagged)).tag == "T" assert pickle.loads(pickle.dumps(extra)).extra == "default" # the load warns, a restore is no construction assert sorted(str(w.message).split(" ")[0] for w in caught) == [ "Constants", "HumanName"] def test_dir_lists_the_names_getattr_serves() -> None: # autodoc collects members from dir(), so a name missing there # drops out of the rendered API reference import nameparser.config import nameparser.util assert {"SetManager", "TupleManager", "RegexTupleManager", "Constants", "CONSTANTS", "titles"} <= set(dir(nameparser.config)) assert "lc" in dir(nameparser.util) for module in (nameparser.config, nameparser.util): with pytest.raises(AttributeError, match="no attribute 'nope'"): getattr(module, "nope") @pytest.mark.parametrize("module,names", [ ("nameparser.config", {"SetManager", "TupleManager", "RegexTupleManager"}), ("nameparser.util", {"lc"}), ]) def test_a_star_import_warns_for_each_removed_name_it_binds( module: str, names: set[str]) -> None: namespace: dict[str, object] = {} with _record() as caught: warnings.simplefilter("always") exec(f"from {module} import *", namespace) assert names <= set(namespace) assert len(caught) == len(names) assert all(w.category is DeprecationWarning for w in caught) _REMOVED = {"nameparser.config": {"SetManager", "TupleManager", "RegexTupleManager"}, "nameparser.util": {"lc"}} def _removed_reads(path: Path, package: Path) -> list[str]: """Each read of a removed name in one module: ``from`` imports (relative ones resolved, star imports included), and attribute or ``getattr`` reads through a name bound to either module.""" tree = ast.parse(path.read_text(encoding="utf-8")) parts = ["nameparser", *path.relative_to(package).with_suffix("").parts] here = parts[:-1] # the package a module sits in aliases: dict[str, str] = {} # local name -> module hits: list[str] = [] for node in ast.walk(tree): if isinstance(node, ast.Import): for a in node.names: if a.name in _REMOVED: aliases[a.asname or a.name] = a.name elif isinstance(node, ast.ImportFrom): base = node.module or "" if node.level: anchor = here[:len(here) - node.level + 1] base = ".".join([*anchor, *([base] if base else [])]) for a in node.names: full = f"{base}.{a.name}" if full in _REMOVED: # from nameparser import util aliases[a.asname or a.name] = full elif base in _REMOVED and (a.name == "*" or a.name in _REMOVED[base]): hits.append(f"{node.lineno} from {base} import {a.name}") for node in ast.walk(tree): if isinstance(node, ast.Attribute): owner, name = ast.unparse(node.value), node.attr elif (isinstance(node, ast.Call) and ast.unparse(node.func) == "getattr" and len(node.args) >= 2 and isinstance(node.args[1], ast.Constant)): owner, name = ast.unparse(node.args[0]), str(node.args[1].value) else: continue module = aliases.get(owner, owner) if name in _REMOVED.get(module, ()): hits.append(f"{node.lineno} {module}.{name}") return hits def test_no_internal_code_reads_a_removed_name() -> None: # an internal read would warn on the library's behalf, at a line # inside nameparser the caller cannot edit; the library reads _lc # and _config_shim directly. Read from the AST, so a docstring # naming one is not a read package = Path(nameparser.__file__).parent modules = sorted(package.rglob("*.py")) assert len(modules) > 20 # the scan saw the package offenders = [f"{m.relative_to(package)}:{hit}" for m in modules for hit in _removed_reads(m, package)] assert offenders == [] @pytest.mark.parametrize("source", [ "from nameparser.util import lc", "from .util import lc", "from nameparser.config import *", "import nameparser.util as u\nu.lc", "from nameparser import config as c\nc.SetManager", "import nameparser.config\ngetattr(nameparser.config, 'TupleManager')", ]) def test_the_internal_read_guard_sees_each_shape(source: str, tmp_path: Path) -> None: package = tmp_path / "nameparser" package.mkdir() module = package / "probe.py" module.write_text(source + "\n", encoding="utf-8") assert _removed_reads(module, package) module.write_text("self.config.SetManager\n", encoding="utf-8") assert not _removed_reads(module, package) def test_a_copy_leaves_the_once_per_line_registry_alone() -> None: # catch_warnings clears every module's registry, so a copy that # entered it would re-show the caller's once-per-line warnings with warnings.catch_warnings(): warnings.simplefilter("ignore") hn = HumanName("John Smith", constants=Constants()) with _record() as caught: warnings.simplefilter("default") for _ in range(3): HumanName("Jane Doe"); copy.copy(hn); copy.deepcopy(hn) # noqa: E702 assert len(caught) == 1 @pytest.mark.parametrize("code", [ "import nameparser", "import nameparser.config, nameparser.parser, nameparser.util", "from nameparser import *", "from nameparser import HumanName", "from nameparser.parser import HumanName", "from nameparser.config import CONSTANTS, Constants", # the modules import quietly; their constants warn since 2.8.1 # (#1007), surnames' since 2.8.2, asserted in test_removals_2_8_1.py "import nameparser.config.titles", "import nameparser.config.surnames", "from nameparser import parse; parse('John Smith')", ]) def test_imports_and_the_core_stay_quiet(code: str) -> None: # a fresh interpreter, since this one has imported everything result = subprocess.run( [sys.executable, "-W", "error", "-c", code], capture_output=True, text=True, check=False) assert result.returncode == 0, result.stderr def test_the_suite_ignores_exactly_these_messages() -> None: # pyproject's filterwarnings and tests/removals.py's tuple are one # list in two places; a message changed in one goes unignored (or # unasserted) in the other pyproject = Path(__file__).parents[2] / "pyproject.toml" config = tomllib.loads(pyproject.read_text(encoding="utf-8")) ignored = [f.split(":")[1] for f in config["tool"]["pytest"]["ini_options"]["filterwarnings"] if f.startswith("ignore:") and f.endswith(":DeprecationWarning")] assert tuple(ignored) == REMOVAL_MESSAGES �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_design_docs.py��������������������������������������0000664�0000000�0000000�00000062700�15273032717�0024277�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""The design docs' folder layout: names, the index, citations.""" from __future__ import annotations import re from pathlib import Path import pytest from tests.v2.design_docs import ( DECISIONS_DIR, MENTION_RE, RULES_DIR, read_decision_files, read_index, REFERENCE_LISTS, _POINTER_RE, _clean, _entries, _hits, has_standing, read_reference_lists, read_rules_text, reference_lists, resolve, retired_ids, reused_id_findings, rule_file_ids, slugify, standing_findings, standing_roster_findings, stem_for) from tests.v2.rules_doc import parse_rules_doc _FIXTURE_FILES = ("S-suffixes.md", "S1-x.md", "S6-case-signal-at-the-suffix-slot.md", "vocabulary-collisions.md") @pytest.fixture def folder(tmp_path: Path) -> Path: (tmp_path / "README.md").write_text( "# Index\n\n" "## [Suffixes (S)](S-suffixes.md)\n\n" "- [S1 — x](S1-x.md)\n" "- [S6 — the case signal at the suffix slot]" "(S6-case-signal-at-the-suffix-slot.md)\n\n" "## Named topics\n\n" "- [vocabulary-collisions — when a word earns the ambiguous " "marking](vocabulary-collisions.md)\n", encoding="utf-8") for name in _FIXTURE_FILES: (tmp_path / name).write_text(f"# {name}\n", encoding="utf-8") return tmp_path def test_slugify_follows_the_spec() -> None: assert slugify("the case signal at the suffix slot") == ( "case-signal-at-the-suffix-slot") assert slugify("Titles & honorifics") == "titles-and-honorifics" assert slugify("a title and one name word") == "title-and-one-name-word" assert slugify("Suffixes: generational & credentials") == ( "suffixes-generational-and-credentials") assert slugify('the "Ph. D." split') == "ph-d-split" assert slugify("the title run's floor") == "title-runs-floor" assert slugify("the writer’s divisions") == "writers-divisions" def test_stem_for_names_rules_and_keeps_slugs() -> None: assert stem_for("S6", "the case signal at the suffix slot") == ( "S6-case-signal-at-the-suffix-slot") assert stem_for("S", "Suffixes") == "S-suffixes" assert stem_for("vocabulary-collisions", "when a word …") == ( "vocabulary-collisions") def test_read_index_keeps_order_and_keys(folder: Path) -> None: entries = read_index(folder) assert [e.key for e in entries] == [ "S", "S1", "S6", "vocabulary-collisions"] assert entries[2].title == "S6 — the case signal at the suffix slot" assert entries[2].path == folder / "S6-case-signal-at-the-suffix-slot.md" (folder / "README.md").write_text( "- [differential-ledger — the differential ledger's arcs]" "(differential-ledger.md)\n", encoding="utf-8") assert read_index(folder)[0].key == "differential-ledger" @pytest.mark.parametrize(("text", "doc", "cid"), [ ("see rules/S2.", "rules", "S2"), ("rules/S2's Accepted clause", "rules", "S2"), ("rules/H Background", "rules", "H"), ("decisions/vocabulary-collisions, which", "decisions", "vocabulary-collisions"), ("mechanisms.md#VERSION-TELL", "mechanisms", "VERSION-TELL"), ]) def test_mention_re_reads_each_shape(text: str, doc: str, cid: str) -> None: m = MENTION_RE.search(text) assert m is not None assert (m.group("doc") or m.group("mdoc"), m.group("cid")) == (doc, cid) def test_mention_re_reads_a_key_wrapped_at_its_hyphen() -> None: # a comment wraps a long key at a hyphen; the checker joins the # next line's half on, so the first half must still match for text in ("mechanisms.md#ONE-PREDICATE-PER-", "decisions/two-input-'"): m = MENTION_RE.search(text) assert m is not None, text assert m.group("cid") in ("ONE-PREDICATE-PER", "two-input") @pytest.mark.parametrize("text", [ "docs/design/rules/S6-case-signal-at-the-suffix-slot.md", "decisions/README.md", "rules/README.md", "docs/design/rules/", ]) def test_mention_re_skips_file_paths(text: str) -> None: assert MENTION_RE.search(text) is None def test_resolve_section_letter_is_not_a_rule(folder: Path) -> None: def name(cid: str) -> str | None: p = resolve("rules", cid, folder) return p.name if p else None assert name("S") == "S-suffixes.md" assert name("S6") == "S6-case-signal-at-the-suffix-slot.md" assert name("S99") is None assert name("README") is None assert resolve("decisions", "vocabulary-collisions", folder) == ( folder / "vocabulary-collisions.md") _LOG = ("## Log\n\n" "- 2026-09-14 (Derek) — THE LEAN.\n" "- 2026-09-18 — THE BLAST RADIUS.\n" "- 2026-09-18, REVIEW ROUND — WHAT IT MOVED.\n" "- 2026-09-19 (Derek) (item (ii) superseded by #9, decisions/S7) — Z.\n" "- 2026-10-18 (Derek), #718 — THE REPORT.\n" "- 2026-10-18, #718 review — THE COMMA.\n" "- 2026-10-19 #730 one — X.\n" "- 2026-10-19 #730 two — Y.\n" "- 2026-10-21 #731/#732 — X.\n" "- 2026-10-21 #731/#732 — Y.\n" "- 2026-09-19 — W.\n" "- 2026-10-22 #740 — A.\n" "- 2026-10-22 #740 (part superseded by #9) — B.\n" "- 2026-10-23 #741 — X and Y.\n" "- 2026-10-23 #741 — Y.\n" "- 2026-10-24, #742 review — A.\n" "- 2026-10-24, #742 review round — B.\n" "- 2026-10-25 (Derek), v2.4 review — A.\n" "- 2026-10-25 (Derek), v2.5 review — B.\n" "- 2026-10-26, corrected 2026-10-27 — A.\n" "- 2026-10-26 — B.\n" "- 2026-09-19 (Derek), #533 — Z.\n" "- 2026-10-28 (Derek), scope corrected 2026-11-09 — Q.\n" "- 2026-10-30, corrected 2026-11-10 and 2026-11-11 — S.\n" "- 2026-10-30 — T.\n" "- 2026-09-14, corrected 2026-09-17 — WHERE THE FACT LIVES.\n" "- 2026-07 (v2 core, PR #288) — v1 parity.\n" "- 2026-10-01 (Derek), #562 — SUPERSEDED by #613. The pair.\n" "- 2026-09-24 — SUPERSEDED IN PART: the mark.\n" "- 2026-10-03 #549 — SUPERSEDED: the skip is reversed.\n" "- 2026-10-04 #611 (in part superseded by #613) — FILED.\n" "- 2026-10-06 #613 — DECIDED ONCE.\n" "- 2026-10-31 #750 (PR on x) — A.\n" "- 2026-10-31 #750 — B.\n" "- 2026-10-31 #750 — C.\n") @pytest.mark.parametrize("standing, finding", [ ("- Only the second part decides — 2026-07 PR #288.\n" "- Decided once — 2026-10-06 #613, (1)–(4).\n" "\nNot standing: 2026-10-01 #562, answered by #613.\n", None), ("- Decided once.\n", "no entry pointer"), ("- Decided once — 2026-10-07 #613.\n", "no entry 2026-10-07 #613"), ("- Decided once — 2026-10-06 #61.\n", "no entry 2026-10-06 #61"), ("- The pair — 2026-10-01 #562.\n", "superseded entry"), ("- The skip — 2026-10-03 #549.\n", None), ("- The mark — 2026-09-24, in part.\n", None), ("- The lean — 2026-09-14 (Derek).\n", None), ("- The fact — 2026-09-14, corrected.\n", None), ("- One of two — 2026-09-14.\n", "ambiguous pointer 2026-09-14"), ("- The lean — 2026-09-14 (Derek), decided again.\n", None), ("- The blast — 2026-09-18.\n", None), ("- Old shape — 2026-09-19 (Derek).\n", None), ("- The report — 2026-10-18 #718.\n", None), ("- The comma — 2026-10-18 #718 review.\n", None), ("- One of two — 2026-10-19 #730.\n", "ambiguous pointer 2026-10-19 #730"), ("- Either twin — 2026-10-21 #731/#732.\n", "ambiguous pointer 2026-10-21 #731"), ("- The second twin — 2026-10-21 #731/#732, Y.\n", None), ("- Noted twin — 2026-10-22 #740, B.\n", None), ("- Opens with — 2026-10-23 #741, Y.\n", None), ("- Trailing space — 2026-09-14 \n", "ambiguous pointer 2026-09-14"), ("- Full words — 2026-10-26, corrected 2026-10-27.\n", None), ("- Typo — 2026-10-18 #718 reveiw.\n", "continues no entry's head"), ("- Typo — 2026-09-19 (Dreek).\n", "continues no entry's head"), ("- Copied head — 2026-10-24, #742 review round.\n", None), ("- Two — 2026-10-24 #742 review round, 2026-10-23 #741, Y.\n", None), ("\nNot standing: 2026-10-18 #718 reveiw.\n", "not-standing pointer"), # The inner dates name no entry, so reading one as a second pointer # is a finding rather than a silent extra citation. ("- One candidate — 2026-10-28 (Derek), scope corrected 2026-11-09.\n", None), ("- Cut short — 2026-10-30, corrected 2026-11-10.\n", None), ("- Two kinds — 2026-09-24, 2026-10-23 #741, Y.\n", None), ("- The round — 2026-09-18, REVIEW ROUND.\n", None), ("- Decided once — 2026-10-04 #613.\n", "no entry 2026-10-04 #613"), ("- Decided once — 2026-10 #613.\n", "no entry 2026-10 #613"), ("- Decided once — 2026-10-06 #613 and 2026-11-01 #999.\n", "no entry 2026-11-01 #999"), ("\nNot standing: 2026-10-04 #612.\n", "names no entry"), ("- Beside a labelled head — 2026-10-31 #750, C.\n", None), ("- The labelled head — 2026-10-31 #750 (PR on x).\n", None), ("- Either bare twin — 2026-10-31 #750.\n", "ambiguous pointer 2026-10-31 #750"), ("- Keyed by no text — 2026-10-31 #750, D.\n", "ambiguous pointer 2026-10-31 #750"), ("- One bare head — 2026-10-18 #718, THE REPORT.\n", "continues no entry's head"), ("\nNot standing: 2026-10-31 #750, typo here.\n", "ambiguous not-standing pointer 2026-10-31 #750"), ("\nNot standing: 2026-10-31 #750.\n", "ambiguous not-standing pointer 2026-10-31 #750"), ("\nNot standing: 2026-10-31 #750, C.\n", None), ], ids=["clean", "no-pointer", "wrong-date", "number-prefix", "superseded", "announcing-is-live", "date-only", "date-only-qualified", "date-only-qualified-by-its-words", "date-only-shared-date", "date-only-qualifier-then-prose", "date-only-bare-beside-qualified", "supersession-note-ignored", "issue-bare-picks-unqualified-head", "issue-qualified", "issue-shared-ambiguous", "twins-unqualified-ambiguous", "twins-qualified-by-text", "twins-differing-by-a-note", "twins-text-opens-with-not-contains", "trailing-space-ambiguous", "date-inside-the-words", "issue-words-typo", "date-words-typo", "head-copied-whole-reads-its-issue", "comma-joined-second-pointer", "not-standing-unmatched-words", "one-candidate-date-inside-its-words", "cut-words-date-inside", "date-only-then-comma-and-an-issue-pointer", "date-only-qualified-beside-a-bare-date", "later-issue-in-a-head", "month-only-date", "second-pointer", "not-standing-missing", "twins-beside-a-labelled-head", "labelled-head-beside-twins", "twins-beside-a-label-unqualified", "twins-beside-a-label-keyed-by-no-text", "one-bare-head-words-are-no-twin-key", "not-standing-twin-key-typo-ambiguous", "not-standing-twins-unqualified-ambiguous", "not-standing-twin-key-resolves"]) def test_standing_findings_name_each_broken_pointer( standing: str, finding: str | None) -> None: found = standing_findings(f"# C1 — x\n\n## Standing\n\n{standing}\n" + _LOG) if finding is None: assert found == [] else: assert len(found) == 1 and finding in found[0], found @pytest.mark.parametrize("pointer, head", [ ("2026-10-18 #718.", "2026-10-18 (Derek), #718"), ("2026-10-18 #718 review.", "2026-10-18, #718 review"), ("2026-09-14 (Derek), decided again.", "2026-09-14 (Derek)"), ("2026-09-14, corrected.", "2026-09-14, corrected 2026-09-17"), ("2026-09-18.", "2026-09-18"), ("2026-09-18, REVIEW ROUND.", "2026-09-18, REVIEW ROUND"), ("2026-10-21 #731/#732, X.", "2026-10-21 #731/#732 — X."), ("2026-10-21 #731/#732, Y.", "2026-10-21 #731/#732 — Y."), ("2026-09-19.", "2026-09-19"), ("2026-09-19 (Derek), again.", "2026-09-19 (Derek) (item (ii) superseded by #9, decisions/S7)"), ("2026-10-23 #741, Y.", "2026-10-23 #741 — Y."), ("2026-10-24 #742 review.", "2026-10-24, #742 review"), ("2026-10-24 #742 review round.", "2026-10-24, #742 review round"), ("2026-10-25 (Derek), v2.5 review.", "2026-10-25 (Derek), v2.5 review"), ("2026-10-26, corrected 2026-10-27.", "2026-10-26, corrected 2026-10-27"), ("2026-09-19 (Derek), #533.", "2026-09-19 (Derek), #533"), ]) def test_a_pointer_resolves_to_the_entry_its_words_name( pointer: str, head: str) -> None: # Resolving is not enough: a pointer that lands on a sibling entry # of the same date and issue passes every finding, so pin the target. line = f"- x — {pointer}" m = _POINTER_RE.search(line, line.index("— ")) assert m is not None assert [f"{h} — {r}" if "—" in head else h for h, r in _hits(line, m, _entries(_LOG))] == [head] def test_a_supersession_note_is_dropped_in_any_case_and_only_when_closed( ) -> None: assert _clean("2026-09-19 (SUPERSEDED in part by #9) x") == "2026-09-19 x" assert _clean("2026-09-19 (superseded by #9 #500") == ( "2026-09-19 (superseded by #9 #500") assert _clean("2026-09-19 (Derek)") == "2026-09-19 (Derek)" def test_a_bold_date_opens_an_entry() -> None: assert _entries("- **2026-10-14 #679 — the rest (Derek).** Text.\n" "- **2026-10-15/16** — the model.\n" "- **Declined 2026-09-25 (#540)** — not an entry.\n" "- **NFKC was declined.** Nor this.\n") == [ ("2026-10-14 #679", "the rest (Derek).** Text."), ("2026-10-15/16", "the model.")] assert _entries("- **2026-10-15 #5**: text.\n" "- **2026-10-14 #679** (Derek) — x.\n" "- **2026-10-16** — **SUPERSEDED by** 2026-10-17.\n") == [ ("2026-10-15 #5", "text."), ("2026-10-14 #679 (Derek)", "x."), ("2026-10-16", "SUPERSEDED by** 2026-10-17.")] head = "# C1 — x\n\n## Standing\n\n{}\n\n## Log\n\n" assert standing_findings( head.format("- a — 2026-10-16.") + "- **2026-10-16** — **SUPERSEDED by** 2026-10-17.\n") == [ "superseded entry 2026-10-16 #None"] assert standing_findings( head.format("- a — 2026-10-15 #5.") + "- **2026-10-15 #5**: SUPERSEDED by 2026-10-17 #6.\n") == [ "superseded entry 2026-10-15 #5"] assert standing_findings( head.format("- a — 2026-10-14 #679 (Derek).") + "- **2026-10-14 #679** (Derek) — x.\n" "- **2026-10-14 #679** (Joe) — y.\n") == [] def test_a_dated_title_founds_no_entry() -> None: title = "# cjk-full-stops — which characters (2026-09-10, #322/#323)\n\n" block = "## Standing\n\n- Four stops. — 2026-09-10 #322.\n\n## Log\n\n" assert standing_findings(title + block + "- **A.** B.\n") == [ "no entry 2026-09-10 #322"] assert standing_findings( title + block + "- 2026-09-10 #322/#323 — the bundle below.\n" "- **A.** B.\n") == [] assert standing_findings( title + block.replace("2026-09-10 #322", "2026-10-14 #679") + "- **A.** B.\n- **2026-10-14 #679 — the rest.** C.\n") == [] def test_standing_needs_a_log_after_it() -> None: assert standing_findings("# C1 — x\n\n## Standing\n\n- a\n") == [ "## Standing without a ## Log after it"] assert standing_findings( "# C1 — x\n\n" + _LOG + "\n## Standing\n\n- a — 2026-10-06 #613.\n" ) == ["## Log before ## Standing: the block opens the file"] assert standing_findings( "# C1 — x\n\n" + _LOG + "\n## Standing\n\n- a — 2026-10-06 #613.\n\n" + _LOG) == ["## Log before ## Standing: the block opens the file"] def test_standing_findings_leave_an_absent_block_to_the_roster() -> None: assert standing_findings("# C1 — x\n\n" + _LOG) == [] def test_a_prose_mention_of_the_heading_is_no_block() -> None: assert not has_standing("- 2026-10-20 — follows the `## Standing` rule.") assert not has_standing("### Standing\n") assert has_standing("# C1 — x\n\n## Standing\n") # -- the real tree --------------------------------------------------------- @pytest.mark.parametrize("folder", [RULES_DIR, DECISIONS_DIR], ids=["rules", "decisions"]) def test_every_entry_file_is_indexed_once(folder: Path) -> None: indexed = [e.path for e in read_index(folder)] assert len(indexed) == len(set(indexed)), "an index lists a file twice" on_disk = {p for p in folder.glob("*.md") if p.name != "README.md"} assert set(indexed) == on_disk @pytest.mark.parametrize("folder", [RULES_DIR, DECISIONS_DIR], ids=["rules", "decisions"]) def test_index_title_matches_heading_and_file_name(folder: Path) -> None: for e in read_index(folder): first = e.path.read_text(encoding="utf-8").split("\n", 1)[0] if len(e.key) == 1: # a section keeps its old heading assert first == "## " + e.title, e.path.name name = e.title.rsplit(" (", 1)[0] else: assert first == "# " + e.title, e.path.name name = e.title.split(" — ", 1)[1] if " — " in e.title else "" assert e.path.stem == stem_for(e.key, name), e.path.name def test_each_citation_key_resolves_to_one_file() -> None: for doc, folder in (("rules", RULES_DIR), ("decisions", DECISIONS_DIR)): for e in read_index(folder): assert resolve(doc, e.key) == e.path, (doc, e.key) def test_the_folders_are_not_empty() -> None: rule_files = [p for p in RULES_DIR.glob("*.md") if re.match(r"[A-Z]\d+-", p.name)] rules = parse_rules_doc(read_rules_text()) assert len(rules) == len(rule_files) >= 46 assert len(read_decision_files()) >= 60 def test_every_standing_line_points_at_a_live_entry() -> None: files = read_decision_files() with_standing = [k for k, t in files.items() if has_standing(t)] assert with_standing, "no decisions file has a Standing block" for key in with_standing: assert standing_findings(files[key]) == [], key @pytest.mark.parametrize("files, finding", [ ({"C1": "## Standing", "L": "- Declined: x."}, None), ({"C1": "## Standing", "C14": "- 2026-10-20 — the `## Standing` rule.", "L": "- Declined: x."}, "C14 has no Standing"), ({"C1": "## Standing", "C14": "log", "L": "- Declined: x."}, "C14 has no Standing"), ({"C1": "## Standing", "L": "## Standing \n- a"}, "'## Standing ' is not the heading"), ({"C1": "## Standing", "L": "### Standing\n- a"}, "'### Standing' is not the heading"), ({"C1": "## Standing", "L": " ## Standing\n- a"}, "' ## Standing' is not the heading"), ], ids=["clean", "prose-mention-is-no-block", "file-without", "misspelled-heading-trailing-space", "misspelled-heading-level", "misspelled-heading-indented"]) def test_standing_roster_findings_name_each_break( files: dict[str, str], finding: str | None) -> None: found = standing_roster_findings(files, {"L"}, frozenset({"L"})) if finding is None: assert found == [] else: assert len(found) == 1 and finding in found[0], found def test_reference_lists_are_the_links_under_their_heading() -> None: readme = ("## Named topics\n\n- [a-log — x](a-log.md)\n\n" "## Reference lists\n\nProse with [a link](x.md).\n\n" "- [b-list — y](b-list.md)\n- [c-list](c-list.md)\n\n" "## Later\n\n- [d-log — z](d-log.md)\n") assert reference_lists(readme) == {"b-list", "c-list"} for not_a_listing in ["* [e](e.md)", "> [e](e.md)", "- see [e](e.md)", "- [x] see [e](e.md)"]: assert reference_lists( f"x\n## Reference lists\n\n{not_a_listing}\n" "- [e2 — y](e2.md)\n") == {"e2"}, not_a_listing for heading in ["### Reference lists", "## Reference lists (old)", "## Reference lists "]: assert reference_lists( f"x\n{heading}\n\n- [e — y](e.md)\n") == set(), heading assert reference_lists("## Named topics\n\n- [a — x](a.md)\n") == set() @pytest.mark.parametrize("files, finding", [ ({"C1": "## Standing", "L": "- Declined: x."}, None), ({"C1": "## Standing", "L": "- Declined: x.\n- 2026-10-01 — y."}, "L is a reference list but is shaped like a log"), ({"C1": "## Standing", "L": "- Declined: x.\n- (A) 2026-08-17 — parked."}, "L is a reference list but is shaped like a log"), ({"C1": "## Standing", "L": "- **2026-10-01 #5 — y.**"}, "L is a reference list but is shaped like a log"), ({"C1": "## Standing", "L": "# L — a bundle (2026-09-10, #322)\n\n- **NFKC declined.**"}, "L is a reference list but is shaped like a log"), ({"C1": "## Standing", "L": "# L — x\n\n- abd — ADDED 2026-08-18, see decisions/P5.\n" "- (Derek's call) removed 2026-08-26.\n"}, None), ({"C1": "## Standing", "L": "## Standing\n- x"}, "L is a reference list and carries ## Standing"), ({"C1": "## Standing", "N": "- Declined: x."}, "N is under Reference lists but is not one"), ], ids=["list", "list-with-entry", "list-with-tagged-date", "list-with-bold-entry", "list-with-dated-title", "list-with-dates-inside-lines", "list-with-standing", "list-new-since-the-rule"]) def test_a_reference_list_carries_no_standing_and_no_log( files: dict[str, str], finding: str | None) -> None: listed = {"N"} if "N" in files else {"L"} found = standing_roster_findings(files, listed, frozenset({"L"})) if finding is None: assert found == [] else: assert len(found) == 1 and finding in found[0], found # Off the heading, the same file is a log with no block. if finding is None: assert standing_roster_findings(files, set(), frozenset({"L"})) == [ "L has no Standing block: a decisions file opens with " "## Standing and ## Log"] def test_every_decisions_file_opens_with_standing_or_is_a_reference_list( ) -> None: assert standing_roster_findings(read_decision_files(), read_reference_lists()) == [] def test_the_reference_lists_are_a_fixed_five() -> None: # Fixed when #762 finished (2026-10-22): a key added here would let a # new file skip its block, so the size is asserted rather than # trusted to the comment. assert len(REFERENCE_LISTS) == 5 _RETIRED_ENTRY = ("# X9 — a retired rule\n\n## Standing\n\n" "Retired: 2026-10-20 #0 — folded into X1.\n\n" "## Log\n\n- 2026-10-20 #0 — RETIRED.\n") def _retirement_layout(tmp_path: Path, entry: str) -> tuple[Path, Path]: """A rules folder holding X1 and X9, and a decisions folder whose one indexed file, X9's, holds `entry`.""" rules, decisions = tmp_path / "rules", tmp_path / "decisions" rules.mkdir(parents=True) decisions.mkdir() (rules / "README.md").write_text("# Rules\n", encoding="utf-8") for name in ("X-section.md", "X1-kept.md", "X9-reused.md"): (rules / name).write_text(f"# {name}\n", encoding="utf-8") (decisions / "README.md").write_text( "# Decisions\n\n- [X9 — a retired rule](X9-retired-rule.md)\n", encoding="utf-8") (decisions / "X9-retired-rule.md").write_text(entry, encoding="utf-8") return rules, decisions def test_a_rule_file_reusing_a_retired_id_is_refused(tmp_path: Path) -> None: # The negative control the real tree cannot give (no rule is # retired yet): a decisions file recording X9's retirement and a # rule file carrying X9 again, read through the same readers the # real-tree test uses. rules, decisions = _retirement_layout(tmp_path, _RETIRED_ENTRY) assert rule_file_ids(rules) == {"X", "X1", "X9"} found = reused_id_findings(rule_file_ids(rules), read_decision_files(decisions)) assert found == ["rules/X9 reuses an ID decisions/X9 records as " "retired: a new rule takes a new ID"] # and the Retired: line is what bites: the same files without it # pass rules, decisions = _retirement_layout( tmp_path / "control", _RETIRED_ENTRY.replace("Retired:", "Folded:")) assert reused_id_findings(rule_file_ids(rules), read_decision_files(decisions)) == [] @pytest.mark.parametrize("line", [ "**Retired:** 2026-10-20 #0", "- Retired: 2026-10-20 #0", "retired: 2026-10-20 #0", "Retired : 2026-10-20 #0", " Retired: 2026-10-20 #0", "1. Retired: 2026-10-20 #0", ]) def test_a_misspelled_retired_line_is_a_finding(line: str) -> None: # each spelling would leave X9 out of retired_ids and the refusal # off for it entry = _RETIRED_ENTRY.replace("Retired: 2026-10-20 #0", line) assert retired_ids({"X9": entry}) == set() found = reused_id_findings({"X9"}, {"X9": entry}) assert len(found) == 1 and "is not a line opening 'Retired:'" in found[0] def test_a_retired_line_needs_a_rule_key() -> None: found = reused_id_findings(set(), {"some-slug": _RETIRED_ENTRY}) assert found == ["decisions/some-slug carries a Retired: line but is " "keyed by no rule ID, so it retires nothing"] def test_no_rule_reuses_a_retired_id() -> None: """rules/README.md: a rule ID is never reused. No rule is retired on 2026-10-20, so on the real tree this passes with nothing to refuse; test_a_rule_file_reusing_a_retired_id_is_refused is what shows it bites. The readers must still reach both folders, or the pass would be vacuous for a second reason.""" ids = rule_file_ids() assert {"C1", "S2", "H"} <= ids decisions = read_decision_files() assert "C1" in decisions assert reused_id_findings(ids, decisions) == [] ����������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_differential.py�������������������������������������0000664�0000000�0000000�00000050537�15273032717�0024457�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Unit tests for compare.py, the released-wheel comparison. `tools/` is outside `testpaths`, and adding it would run `--doctest-modules` over the corpus builders, so `compare.py` is imported by path through `_differential_fixtures.load_tool`. Nothing here spawns `uv` or the network. What is tested is what produces FALSE CONFIDENCE when it silently misbehaves: a version tell believed when the wrong library answered, a tree side that is not the checkout, a worker that reads a name differently from the tree, and a comparison that cannot report a difference at all. """ import contextlib import io import json import sys from collections.abc import Callable from pathlib import Path from typing import Any import pytest from ._differential_fixtures import _TOOLS, load_tool compare = load_tool("compare") shapes = load_tool("shapes") snapshot = load_tool("snapshot") def test_parse_version_pads_a_short_release_to_three_parts() -> None: """A requested '2.0' and a wheel reporting '2.0.0' are the same release; comparing the raw strings would call them unequal and abort a correct run as a tell mismatch.""" assert compare._parse_version("2.0") == compare._parse_version("2.0.0") def test_parse_version_orders_numerically_not_lexically() -> None: """The bug string comparison would introduce: '10.0.0' sorts BELOW '2.0.0' as text.""" assert compare._parse_version("10.0.0") > compare._parse_version("2.0.0") def test_parse_version_ignores_a_prerelease_segment() -> None: assert compare._parse_version("2.0.0rc1") == (2, 0, 0) def test_parse_version_rejects_a_string_with_no_release_in_it() -> None: with pytest.raises(SystemExit, match="cannot parse a version"): compare._parse_version("not-a-version") @pytest.mark.parametrize("version,expected", [ ("1.4.0", {"facade"}), ("1.4", {"facade"}), ("1.9.9", {"facade"}), ("2.0.0", {"facade", "v2"}), ("2.0", {"facade", "v2"}), ("2.1.0", {"facade", "v2"}), # the row string comparison gets wrong: '10.0.0' < '2.0.0' as text ("10.0.0", {"facade", "v2"}), ]) def test_surfaces_are_derived_from_the_baseline( version: str, expected: set[str]) -> None: assert compare._surfaces_for(version) == expected def test_worker_source_carries_the_requested_pin() -> None: src = compare._worker_source("2.0.0", want_v2=True) assert 'dependencies = ["nameparser==2.0.0"]' in src def test_worker_source_always_emits_a_version_tell() -> None: """The tell is the whole defence against a worker that silently resolved to the checkout, so it is not conditional on anything.""" for want_v2 in (True, False): src = compare._worker_source("1.4.0", want_v2=want_v2) assert "__version__" in src and "__file__" in src def test_worker_source_gates_the_v2_import_on_the_baseline() -> None: """1.4 has no nameparser.parse to import; asking for it would make the worker die on import rather than report a clean facade diff.""" assert "WANT_V2 = False" in compare._worker_source("1.4.0", want_v2=False) assert "WANT_V2 = True" in compare._worker_source("2.0.0", want_v2=True) @pytest.mark.parametrize("want_v2", [True, False]) def test_worker_source_compiles(want_v2: bool) -> None: """A syntax error in the rendered template surfaces only as the opaque 'worker exited 1', and by then the temp dir holding the rendered source is gone; this catches it at test time, for both renderings (WANT_V2 gates a def-inside-if that is easy to misindent).""" compile(compare._worker_source("2.2.0", want_v2=want_v2), "<worker>", "exec") def test_every_shape_orders_resolve_and_bound_sanely() -> None: """An `order` is a public constant name that Policy accepts as a name_order -- hasattr alone would admit any real attribute, failing only inside the worker -- and a shape with an order cannot claim a pre-2.0 baseline, since Policy shipped in 2.0.0. Every shape's min_baseline must parse, ordered or not.""" import nameparser from nameparser import Policy for sid, shape in shapes.SHAPES.items(): parsed_min = compare._parse_version(shape.min_baseline) if shape.order is not None: assert hasattr(nameparser, shape.order), (sid, shape.order) Policy(name_order=getattr(nameparser, shape.order)) assert parsed_min >= (2, 0, 0), sid def test_v2_fields_matches_the_Role_enum() -> None: """The seven roles are 'defined once and derived everywhere'. The snapshot cannot import Role into the WORKER (that runs under the old wheel), but its copy reads the tree's ParsedName and must track Role: a role missing here is invisible to every comparison.""" from nameparser import Role assert snapshot.V2_FIELDS == tuple(str(r) for r in Role) def _run_template(line: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: """The generated worker, exec'd in-process on one input line. Its `import nameparser` then resolves to this checkout, so what it emits is the tree's own reading, comparable with snapshot.read's.""" import nameparser source = compare._worker_source(nameparser.__version__, want_v2=True) real_stdin, buf = sys.stdin, io.StringIO() try: sys.stdin = io.StringIO(json.dumps(line, ensure_ascii=False) + "\n") with contextlib.redirect_stdout(buf): exec(compile(source, "baseline_worker.py", "exec"), {}) finally: sys.stdin = real_stdin tell, row = (json.loads(x) for x in buf.getvalue().splitlines()) assert tell["__version__"] == nameparser.__version__ return tell, row _BOTH = frozenset({"capitalized", "capitalized_force"}) @pytest.mark.parametrize("name,order,views", [ # The one corpus name whose facade and core initials differ # ('J. P D.' against 'J. P. D.'), so a row that read the core's # value into the facade's slot cannot pass. ("Ph. D., John", None, frozenset()), ("Ménil Christophe du", "FAMILY_FIRST", frozenset()), # The case views (#826), one row per arm of the worker's copy of # snapshot.case_views: both views (the plain repair keeps the # mixed-case 'EdD', the forced one does not); the plain one alone, # order-bearing; the forced one alone. With only the first, a # worker comparing plain against forced, or forced against the # parse, passed every row (measured 2026-10-22, PR #837's review). ("john smith EdD", None, _BOTH), ("ménil christophe du", "FAMILY_FIRST", frozenset({"capitalized"})), ("John Smith EdD", None, frozenset({"capitalized_force"})), # a report's alternative (#752 phase 2m), which the worker records # in its own copy of snapshot.alternatives() ("Jack", None, frozenset()), ]) def test_the_worker_reads_a_line_as_the_tree_does( name: str, order: str | None, views: frozenset[str]) -> None: """The worker's row and snapshot.read's are separate code on two sides of a process boundary; a comparison means a behavior change only while both turn the same parse into the same snapshot row.""" _, raw = _run_template({"name": name, "order": order}) if order is not None: # the facade is never consulted for an order-bearing entry assert "facade" not in raw tree = snapshot.read([(name, order)])[0] # the row still reaches the arm it is here for assert set(snapshot.CASE_VIEWS) & set(tree) == views assert snapshot.row(name, order, raw.get("v2"), raw.get("facade")) == tree if name == "Jack": assert tree["alternatives"] == [ "GIVEN_OR_FAMILY: name as family (position)"] def test_a_baseline_without_alternatives_is_not_compared_on_them() -> None: # A wheel before 2.7 reports no alternative, so every name the tree # reports one for would read as moved: 867 of the 1,088 entries # --baseline 2.6.0 reported on 2026-10-26 (#902's review). tell, _ = _run_template({"name": "Jack", "order": None}) assert tell["alternatives"] is True assert compare.unrecorded(tell) == () assert compare.unrecorded({**tell, "alternatives": False}) == ( "alternatives",) assert compare.unrecorded({"__version__": "2.6.0"}) == ("alternatives",) _WHEEL = "/Users/x/.cache/uv/environments-v2/w/lib/python3.11/" \ "site-packages/nameparser/__init__.py" def test_tell_accepts_a_matching_wheel() -> None: compare._check_tell({"__version__": "2.0.0", "__file__": _WHEEL}, "2.0.0") def test_tell_accepts_an_equivalent_short_release() -> None: compare._check_tell({"__version__": "2.0.0", "__file__": _WHEEL}, "2.0") def test_tell_rejects_a_version_mismatch() -> None: with pytest.raises(SystemExit, match="not the requested"): compare._check_tell( {"__version__": "2.1.0", "__file__": _WHEEL}, "2.0.0") def test_tell_rejects_a_module_loaded_from_the_checkout() -> None: """The failure the whole design exists to make impossible. An editable install reports the TREE's version, so when the tree and the baseline share a version the version half of the tell agrees and only the path gives it away.""" checkout = _TOOLS.parents[1] / "nameparser" / "__init__.py" with pytest.raises(SystemExit, match="CHECKOUT"): compare._check_tell( {"__version__": "2.0.0", "__file__": str(checkout)}, "2.0.0") def test_tell_rejects_an_empty_tell() -> None: with pytest.raises(SystemExit): compare._check_tell({}, "2.0.0") def test_check_tree_accepts_the_checkout_and_rejects_anything_else( tmp_path: Path) -> None: inside = _TOOLS.parents[1] / "nameparser" / "__init__.py" assert compare._check_tree(str(inside)) == inside.resolve() with pytest.raises(SystemExit, match="not from the source package"): compare._check_tree(str(tmp_path / "nameparser" / "__init__.py")) @pytest.mark.parametrize("rel", [ ".venv/lib/python3.11/site-packages/nameparser/__init__.py", "build/lib/nameparser/__init__.py", "dist/unpacked/nameparser/__init__.py", ]) def test_check_tree_rejects_a_wheel_sitting_inside_the_checkout( rel: str) -> None: """The repo contains .venv/, build/ and dist/, any of which can hold a released wheel, so "under the repo" is not "the source package".""" with pytest.raises(SystemExit, match="not from the source package"): compare._check_tree(str(compare.REPO_ROOT / rel)) def test_check_tree_resolves_before_comparing() -> None: """Without .resolve(), a path escaping via .. reads as inside.""" escaped = compare.REPO_ROOT / "nameparser" / ".." / ".." / "x" \ / "nameparser" / "__init__.py" with pytest.raises(SystemExit, match="not from the source package"): compare._check_tree(str(escaped)) def test_worker_env_strips_the_import_path_overrides( monkeypatch: pytest.MonkeyPatch) -> None: """PEP 723 isolation does not survive PYTHONPATH -- it precedes site-packages, so a directory named there shadows the pinned wheel inside uv's own environment.""" monkeypatch.setenv("PYTHONPATH", "/somewhere/else") monkeypatch.setenv("PYTHONHOME", "/elsewhere") monkeypatch.setenv("PATH", "/usr/bin") env = compare._worker_env() assert "PYTHONPATH" not in env and "PYTHONHOME" not in env assert env["PATH"] == "/usr/bin", "the rest of the env must survive" class _FakePopen: """Records how _run_worker spawned the child, and replays a canned stdout. Lets the subprocess-facing guards be tested without uv.""" last: dict = {} out: str = "" rc: int = 0 def __init__(self, argv: list[str], **kw: object) -> None: _FakePopen.last = {"argv": argv, **kw} self.returncode = _FakePopen.rc def communicate(self, payload: str) -> tuple[str, str]: _FakePopen.last["stdin"] = payload return _FakePopen.out, "" def _fake_popen(monkeypatch: pytest.MonkeyPatch, out: str, rc: int = 0) -> None: _FakePopen.out, _FakePopen.rc = out, rc monkeypatch.setattr(compare.subprocess, "Popen", _FakePopen) _TELL = ('{"__version__": "1.4.0", ' '"__file__": "/wheel/nameparser/__init__.py"}') _ROW = '{"facade": {"first": "John"}}' def test_run_worker_strips_the_import_path_overrides_from_the_child( monkeypatch: pytest.MonkeyPatch) -> None: """_worker_env has its own test; this pins that _run_worker USES it.""" monkeypatch.setenv("PYTHONPATH", "/shadow") _fake_popen(monkeypatch, f"{_TELL}\n{_ROW}\n") compare._run_worker("1.4.0", False, [{"name": "John Smith"}]) env = _FakePopen.last["env"] assert "PYTHONPATH" not in env and "PYTHONHOME" not in env def test_run_worker_aborts_on_a_nonzero_exit( monkeypatch: pytest.MonkeyPatch) -> None: _fake_popen(monkeypatch, "", rc=3) with pytest.raises(SystemExit, match="exited 3"): compare._run_worker("1.4.0", False, [{"name": "John Smith"}]) def test_run_worker_aborts_on_empty_output( monkeypatch: pytest.MonkeyPatch) -> None: _fake_popen(monkeypatch, "") with pytest.raises(SystemExit, match="not even a version tell"): compare._run_worker("1.4.0", False, [{"name": "John Smith"}]) def test_run_worker_aborts_when_fewer_results_than_names( monkeypatch: pytest.MonkeyPatch) -> None: """The guard behind compare()'s zip(), which truncates silently.""" _fake_popen(monkeypatch, f"{_TELL}\n{_ROW}\n") with pytest.raises(SystemExit, match="1 results for 2 corpus entries"): compare._run_worker("1.4.0", False, [{"name": "John Smith"}, {"name": "Jane Doe"}]) def test_run_worker_checks_the_tell_before_returning_results( monkeypatch: pytest.MonkeyPatch) -> None: wrong = ('{"__version__": "9.9.9", ' '"__file__": "/wheel/nameparser/__init__.py"}') _fake_popen(monkeypatch, f"{wrong}\n{_ROW}\n") with pytest.raises(SystemExit, match="not the requested"): compare._run_worker("1.4.0", False, [{"name": "John Smith"}]) def test_run_worker_sends_the_name_and_order_on_the_wire( monkeypatch: pytest.MonkeyPatch) -> None: """The wire format is the whole contract between compare.py and the generated worker.""" _fake_popen(monkeypatch, f"{_TELL}\n{_ROW}\n{_ROW}\n") compare._run_worker( "1.4.0", False, [{"name": "John Smith", "order": None}, {"name": "Ménil Christophe du", "order": "FAMILY_FIRST"}]) lines = _FakePopen.last["stdin"].splitlines() assert lines[0] == '{"name": "John Smith", "order": null}' assert lines[1] == \ '{"name": "Ménil Christophe du", "order": "FAMILY_FIRST"}' _ENTRIES = [("John Smith", None), ("Ménil Christophe du", "FAMILY_FIRST")] def _tree_results(entries: list[tuple[str, str | None]], want_v2: bool ) -> list[dict[str, Any]]: """What a worker would emit if the baseline read every entry as the tree does -- built by running the template on each line.""" out = [] for name, order in entries: _, raw = _run_template({"name": name, "order": order}) if not want_v2: raw.pop("v2", None) out.append(raw) return out def _fake_compare(monkeypatch: pytest.MonkeyPatch, baseline: str, alter: "Callable[[list[dict[str, Any]]], None] | None" = None, entries: list[tuple[str, str | None]] = _ENTRIES, ) -> tuple[Any, list[dict[str, Any]]]: """compare() over _ENTRIES with the worker replaced by the tree's own readings, optionally altered by `alter(results)`; also returns the entries the worker was sent.""" monkeypatch.setattr(snapshot, "corpus_entries", lambda paths=None: list(entries)) monkeypatch.setattr(compare, "_load_snapshot", lambda: snapshot) sent: list[list[dict[str, Any]]] = [] def fake_run(version: str, want_v2: bool, entries: list[dict[str, Any]] ) -> tuple[dict[str, str], list[dict[str, Any]]]: sent.append(entries) results = _tree_results([(e["name"], e["order"]) for e in entries], want_v2) if alter: alter(results) return {}, results monkeypatch.setattr(compare, "_run_worker", fake_run) return compare.compare(baseline), sent[0] def test_compare_reports_no_move_when_the_baseline_reads_like_the_tree( monkeypatch: pytest.MonkeyPatch) -> None: result, sent = _fake_compare(monkeypatch, "2.4.0") assert (result.moves, result.moved, result.compared, result.skipped) \ == ([], 0, 2, 0) # The boundary: an order shipped in 2.0.0, so 2.0.0 itself sends it. _, sent = _fake_compare(monkeypatch, "2.0.0") assert [e["order"] for e in sent] == [None, "FAMILY_FIRST"] def test_compare_reports_a_move(monkeypatch: pytest.MonkeyPatch) -> None: """A clean run and a broken comparison print the same thing; this is the control that the comparison can see a difference.""" def alter(results: list[dict[str, Any]]) -> None: results[0]["v2"]["family"] = "Smyth" results[0]["facade"]["last"] = "Smyth" r, _ = _fake_compare(monkeypatch, "2.4.0", alter) moves, moved, latin = r.moves, r.moved, r.latin assert (moved, latin, r.cldr) == (1, 1, 0) assert moves[0] == "family: 1" assert "'Smyth' -> 'Smith'" in moves[1] def test_compare_counts_a_moved_name_only_cldr_holds( monkeypatch: pytest.MonkeyPatch) -> None: """A release figure counts CLDR's fictional renderings out, so the report says how many moved names only corpus_cldr.jsonl holds.""" cldr_name = "Macáková, Alexandra Zuzana (pani, PhD)" def alter(results: list[dict[str, Any]]) -> None: results[0]["v2"]["nickname"] = "pani PhD" results[0]["facade"]["nickname"] = "pani PhD" r, _ = _fake_compare(monkeypatch, "2.4.0", alter, entries=[(cldr_name, None)]) assert (r.moved, r.cldr) == (1, 1) def test_compare_reads_the_facade_alone_below_2_0( monkeypatch: pytest.MonkeyPatch) -> None: """1.4 has no 2.0 surface, so the tree's facade reading is what it is compared with: a facade move shows, and nothing order-bearing is sent, since no order existed before 2.0.""" def alter(results: list[dict[str, Any]]) -> None: results[0]["facade"]["last"] = "Smyth" r, sent = _fake_compare(monkeypatch, "1.4.0", alter) assert r.moved == 1 and r.moves[0] == "family: 1" # The order-bearing entry is skipped, and COUNTED as skipped: a # figure quoted from this run has a stated population. assert [e["order"] for e in sent] == [None] assert (r.compared, r.skipped) == (1, 1) def test_compare_checks_that_the_tree_side_is_the_checkout( tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Pins that compare() CALLS the tree check: run as a script, sys.path[0] is tools/differential/, so a PYTHONPATH wheel outranks the editable install and two wheels would agree on everything. REPO_ROOT is moved, which reproduces the trap's effect.""" monkeypatch.setattr(compare, "REPO_ROOT", tmp_path) with pytest.raises(SystemExit, match="not from the source package"): _fake_compare(monkeypatch, "2.4.0") @pytest.mark.parametrize("name,latin", [ ("John Smith", True), ("Anna Müller", True), ("Jane Smith (née Jones)", True), ("田中さん", False), ("김민준", False), ("Хосе Сантос", False), ("威廉·莎士比亚", False), ]) def test_latin_only_partition(name: str, latin: bool) -> None: assert compare._is_latin_only(name) is latin def test_snapshot_checks_that_it_read_the_checkout( tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """snapshot.py has compare.py's exposure: run as a script, a PYTHONPATH wheel outranks the editable install, and --record would write the wheel's readings while --since reported parity. Moving REPO_ROOT reproduces the trap's effect.""" monkeypatch.setattr(snapshot, "corpus_entries", lambda paths=None: []) monkeypatch.setattr(snapshot, "REPO_ROOT", tmp_path) with pytest.raises(SystemExit, match="not from the source package"): snapshot.main([]) def test_snapshot_tree_must_hold_its_own_source_package( monkeypatch: pytest.MonkeyPatch) -> None: """An ancestor of this checkout holds no nameparser/ of its own, so the import falls through to this checkout; "under the root" would pass it and compare the tree with its own snapshot.""" monkeypatch.setattr(snapshot, "corpus_entries", lambda paths=None: []) monkeypatch.setattr(sys, "path", list(sys.path)) with pytest.raises(SystemExit, match="not from the source package"): snapshot.main(["--tree", str(_TOOLS.parents[2])]) �����������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_doc_citations.py������������������������������������0000664�0000000�0000000�00000070254�15273032717�0024643�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Referential integrity for docs/design/ citations. Checks: every reference to a design doc in code names a rule, section, mechanism or decisions entry that exists; every quote a citation carries -- in any of the shapes _LEAD_RE accepts, chained quotes included -- is a normalized verbatim excerpt of what it cites ("[...]" eliding, in order): a rule's quotable prose, a section's Background, a mechanism's Contract statement, a decisions entry's text. Normalized means whitespace, case, "--" for the em dash and ' for a nested ", so emphasis in capitals is not checked. A quote standing more than four words from its ID and chained to nothing is prose to this module and is NOT checked: the reader cannot tell it from a name written in double quotes, which is the common case; ``implemented:`` lists match the set of modules actually citing the rule; every rule ID a rule's prose names exists. The legacy-pattern check (armed) keeps gitignored-spec citation forms out of the committed tree. """ from __future__ import annotations import re import subprocess from collections.abc import Callable from pathlib import Path from typing import NamedTuple import pytest from tests.v2.design_docs import ( DECISIONS_DIR, MECHANISMS_DOC, MENTION_RE, RULES_DIR, read_decision_files, read_rules_text) from tests.v2.rules_doc import Rule, parse_rules_doc, prose_blocks from tests.v2.rules_skeleton import skeleton_prose REPO = Path(__file__).resolve().parents[2] MECH_DOC = MECHANISMS_DOC SWEEP_DIRS = ("nameparser", "tests", "tools") ENFORCE_NO_LEGACY = True # armed 2026-08-15, the rewrite complete _LEGACY_RES = (re.compile(r"§"), re.compile(r"superpowers"), re.compile(r"plan[\s#]+deviation"), re.compile(r"spec\s+[S§]?\d")) # A reference to a design doc: the doc and the ID or entry key # (design_docs.MENTION_RE). A decisions key may be a lowercase slug # ('suffix-acronym-collisions'). _MENTION_RE = MENTION_RE # The same reference in the docs themselves, where a key starting with a # digit (decisions/3-0-reevaluations) is checked too. _DOC_ANCHOR_RE = re.compile(MENTION_RE.pattern.replace( "[A-Za-z][A-Za-z0-9_]*", "[A-Za-z0-9][A-Za-z0-9_]*", 1)) assert _DOC_ANCHOR_RE.pattern != MENTION_RE.pattern, ( "MENTION_RE was reworded; widen its key's first character here again") # A reference is a CITATION when a quote follows it in one of the # shapes the tree writes: the colon form (`rules/S2: "..."`, where # prose may stand before the quote), or the ID, an optional "'s", up # to four words and optionally a dash, colon, comma or parenthesis, # then the # quote (`rules/P5 -- "..."`, `rules/M2's Accepted clause # ("...")`, `rules/S2 consumes "..."`, `rules/M2 -- the # numeral is taken "..."`). Any other reference is a # pointer, which must still name something that exists. _COLON_RE = re.compile(r"\s*:") _LEAD_RE = re.compile( r"(?:'s)?(?:\s*(?:--|—))?(?:\s+[A-Za-z]+){0,4}?" r"\s*(?:--|—|[:,(])?\s*(?=\")") # A quote, and the quotes chained to it by "and", a comma or a dash: # every one of them is part of the citation and must be verbatim. _QUOTE_RE = re.compile(r'"([^"]*)"') _CHAIN_RE = re.compile(r'\s*,?\s*(?:and\s+|--\s*|—\s*)?(?=")') # An excerpt may elide with "[...]": each fragment must then be # verbatim, non-empty, and in order. _ELISION = "[...]" class Citation(NamedTuple): path: Path line: int doc: str cid: str excerpts: tuple[str, ...] # normalized; () for a pointer colon: bool # written in the colon form lead: str # the text between the ID and its quote def _cite(doc: str, cid: str) -> str: return f"{doc}.md#{cid}" if doc == "mechanisms" else f"{doc}/{cid}" def _norm(s: str) -> str: # Case folds because a quote routinely lowercases the first letter # of the sentence it lifts; "--" is how an ASCII comment spells the # em dash the docs use, and a quote nested in a quoted excerpt can # only be written single s = s.replace(" -- ", " — ").replace('"', "'") return " ".join(s.split()).lower() def _statements() -> dict[str, str]: """What a citation may quote: for a rule, the text of its fields (#761) -- When, Tolerated, Then and its bullets, Because, and the Except, Precedence and Accepted bullets -- with the labels, the fences and the footer below `---` left out (rules_skeleton.skeleton_prose); for a section letter (`rules/H Background`), the section's Background paragraph; for a mechanism, its Contract statement.""" out = prose_blocks(read_rules_text()) stmts = {rid: _norm(" ".join(body)) for rid, body in out.items()} mech = MECH_DOC.read_text(encoding="utf-8") # split into sections first so a missing Contract line cannot # bleed into the next section's statement for sec in re.split(r"^(?=#{2,3} )", mech, flags=re.M): hm = re.match(r"#{2,3} (?P<slug>[A-Z][A-Z0-9_-]+)(?=[\s—-])", sec) if not hm: continue cm = re.search(r"Contract statement[.:*]*\s*(?P<stmt>.+?)(?=\n\n|\Z)", sec, flags=re.S) if cm: stmts[hm.group("slug")] = _norm(cm.group("stmt")) return stmts def _decision_entries() -> dict[str, str]: """decisions entry bodies keyed by their key, one file each. An entry is a dated record rather than a statement, so a citation of one need not quote it; but what it does quote must be verbatim. An arc spread over several headings (the differential-ledger arcs) answers to its key with all of them.""" return {key: _norm(text) for key, text in read_decision_files().items()} def _is_excerpt(excerpt: str, body: str) -> bool: pos = 0 for frag in excerpt.split(_ELISION): frag = frag.strip() if not frag: return False found = body.find(frag, pos) if found < 0: return False pos = found + len(frag) return True def _swept_files() -> list[Path]: # this module's own comments show citation shapes, citing nothing, # and so do test_design_docs.py's fixtures for the citation pattern shapes = {Path(__file__).resolve(), REPO / "tests" / "v2" / "test_design_docs.py"} files = [p for d in SWEEP_DIRS for p in sorted((REPO / d).rglob("*.py")) if p not in shapes] return files def _quotes(text: str, at: int) -> tuple[str, ...]: """The quote opening at `at` and every quote chained to it.""" out = [] while (qm := _QUOTE_RE.match(text, at)): out.append(_norm(qm.group(1))) cm = _CHAIN_RE.match(text, qm.end()) if not cm: break at = cm.end() return tuple(out) def _citations() -> list[Citation]: """Every reference to a design doc, with what it quotes. A reference's text runs to the next reference, over the comment lines that continue it; on a line that is not a comment (a docstring, a string) it ends with the line, since a quote there may be the string's own delimiter.""" found = [] for path in _swept_files(): lines = path.read_text(encoding="utf-8").splitlines() for i, line in enumerate(lines): for m in _MENTION_RE.finditer(line): cid, text = m.group("cid"), line[m.end():] if re.fullmatch(r"-[\"']?", text) and i + 1 < len(lines): # a key wrapped at its hyphen ('ONE-PREDICATE-PER-' # then 'QUESTION' on the next line) wm = re.match(r"[\s#\"']*([A-Za-z0-9_]+" r"(?:-[A-Za-z0-9_]+)*)", lines[i + 1]) if wm: cid += "-" + wm.group(1) if "#" in line[:m.start()]: for cont in lines[i + 1:]: cs = cont.strip() if not cs.startswith("#"): break text += " " + re.sub(r"^#+:?\s*", "", cs) nxt = _MENTION_RE.search(text) if nxt: text = text[:nxt.start()] colon = bool(_COLON_RE.match(text)) doc = m.group("doc") or m.group("mdoc") if colon and doc != "decisions": # the colon form's quote may follow a paraphrase q = text.find('"') else: lm = _LEAD_RE.match(text) q = lm.end() if lm else -1 excerpts = _quotes(text, q) if q >= 0 else () found.append(Citation(path, i + 1, doc, cid, excerpts, colon, text[:q] if excerpts else "")) return found def test_citations_are_verbatim_excerpts() -> None: statements = _statements() entries = _decision_entries() problems = [] for c in _citations(): where = f"{c.path.relative_to(REPO)}:{c.line}" known = entries if c.doc == "decisions" else statements if c.cid not in known: problems.append(f"{where}: {_cite(c.doc, c.cid)} names nothing") continue if c.colon and c.doc != "decisions" and not c.excerpts: problems.append( f"{where}: citation of {c.cid} has no quoted excerpt") for excerpt in c.excerpts: if not _is_excerpt(excerpt, known[c.cid]): problems.append( f"{where}: not a verbatim excerpt of " f"{_cite(c.doc, c.cid)}: {excerpt[:60]!r}") assert not problems, "\n".join(problems) def test_the_sweep_reaches_every_citation_shape() -> None: # Each half of the sweep decides its own scope -- a glob, a lead # grammar, an elision, the decisions routing -- and one matching # nothing would let the excerpt test pass on what is left. cites = _citations() leads = [c.lead for c in cites if c.excerpts and not c.colon] # The possessive lead (`rules/X's Accepted clause ("...")`), the # dash lead (`rules/X -- "..."`) and the "[...]" elision had # their only witnesses in the differential ledgers' comments, # deleted with them (#664). The grammar still reads both leads, and # test_an_elided_excerpt_must_keep_its_order holds the elision. for lead in ("'s Accepted clause (", " -- "): m = _LEAD_RE.match(lead + '"') assert m is not None and m.group() == lead assert any("(" in ld for ld in leads) assert any(re.fullmatch(r"(?:\s+[A-Za-z]+)+\s*", ld) for ld in leads) assert any(c.excerpts and c.doc == "decisions" for c in cites) assert any(len(c.excerpts) > 1 for c in cites) def test_the_sweep_finds_the_tree_s_citations() -> None: # A change to the citation pattern can narrow what it reads without # failing anything: #713's first pattern dropped three keys wrapped # at a hyphen, found only by diffing citation sets (that shape is # pinned by test_mention_re_reads_a_key_wrapped_at_its_hyphen). # 1,010 references, pointers included, on 2026-10-17 (#713): the # floor catches a pattern that stops reading a large shape -- the # mechanisms.md form, slug keys, hyphenated keys -- and not a small # one, nor a lead change that turns citations into pointers. assert len(_citations()) >= 1000 def test_an_elided_excerpt_must_keep_its_order() -> None: assert _is_excerpt("a b [...] d", "a b c d") assert not _is_excerpt("d [...] a b", "a b c d") assert not _is_excerpt("[...]", "a b c d") assert not _is_excerpt("a [...]", "a b c d") def test_the_quotable_text_is_the_prose_and_only_the_prose() -> None: stmts = _statements() # an Accepted bullet, and a Background's wrapped lines, are quotable assert _is_excerpt(_norm("an unambiguous suffix is consumed even " "when that leaves no family name at all"), stmts["S2"]) assert _is_excerpt(_norm("another's opener"), stmts["N"]) # an example line, and a footer no-boundary: waiver, are not assert not _is_excerpt(_norm("Smith Jr."), stmts["S2"]) assert not _is_excerpt(_norm("its boundaries are the other rules"), stmts["O4"]) # an Except bullet is quotable, its fenced example not assert _is_excerpt(_norm("A numeral past X is a name word"), stmts["S10"]) assert not _is_excerpt(_norm("John Smith XI"), stmts["S10"]) # The recorded negative control: excerpts that stood stale in the tree # until #632, each still false against the text the check reads. They # guard the QUOTABLE TEXT -- an Accepted clause or a fold loosening the # match far enough to let one back in. The scanner's reach is the reach # test's to guard. _RETIRED_EXCERPTS = ( ("P5", "a trailing roman numeral that assign reads as the " "suffix (S2) is no word to spare, and is not joined"), ("M2", "a bare acronym the reading declines is maiden text " "all the same"), ("S2", "A suffix never BEGINS a name: position outranks " "the vocabulary match"), ("C7", "The part is read as its words stand, before any " "join"), ("A1", "a kind is worth adding only if a reader would " "hesitate too"), ) _BULLET_LEAD_RE = re.compile(r"^- \*\*(?P<lead>[^*]+)\*\*", re.M) def _bullet_leads(rule_id: str, label: str) -> list[str]: """The bold leads of a rule's bullets under one label, normalized.""" (path,) = RULES_DIR.glob(f"{rule_id}-*.md") text = path.read_text(encoding="utf-8") m = re.search(rf"^\*\*{label}:\*\*\n(?P<body>.*?)(?=^\*\*\w+:\*\*|^---$)", text, re.S | re.M) body = m.group("body") if m else "" return [_norm(lm.group("lead")).rstrip(".:") for lm in _BULLET_LEAD_RE.finditer(body)] def bullet_lead_findings( citations: list[Citation], leads: Callable[[str, str], list[str]] = _bullet_leads, ) -> tuple[int, list[str]]: """How many citations name a rule's Except or Precedence bullet and quote it, and those whose quote is not one of that rule's bold leads under that label.""" found, problems = 0, [] for c in citations: lm = re.search(r"\b(Except|Precedence) bullet", c.lead) if lm is None or c.doc != "rules" or not c.excerpts: continue found += 1 named = leads(c.cid, lm.group(1)) if not any(c.excerpts[0].startswith(lead) for lead in named): problems.append( f"{c.path.name}:{c.line} quotes rules/{c.cid}'s " f"{lm.group(1)} bullet as {c.excerpts[0]!r}; quote the " f"bullet's bold lead, one of {named}") return found, problems def test_a_citation_naming_a_bullet_quotes_its_bold_lead() -> None: """A comment that names a rule's Except or Precedence bullet and quotes it quotes the bullet's bold lead, which is the bullet's name: the excerpt check holds a quote only to the rule's text, so another sentence of the rule would pass it (#768's review). A comment that describes a bullet without quoting it is not read here.""" found, problems = bullet_lead_findings(_citations()) assert found >= 2, "no citation names a bullet; the check reads nothing" assert not problems, "\n".join(problems) @pytest.mark.parametrize("label, quote, finding", [ ("Except", "one letter needs words", False), ("Except", "either reading is reported", True), ("Precedence", "before a comma", False), ("Precedence", "one letter needs words", True), ]) def test_bullet_lead_findings_catch_a_quote_that_is_no_lead( label: str, quote: str, finding: bool) -> None: leads = {"Except": ["one letter needs words"], "Precedence": ["before a comma"]} c = Citation(path=Path("x.py"), line=1, doc="rules", cid="X1", excerpts=(quote,), colon=False, lead=f"'s {label} bullet ") found, problems = bullet_lead_findings( [c], lambda cid, lab: leads[lab]) assert found == 1 assert bool(problems) is finding, problems def test_retired_excerpts_stay_rejected() -> None: statements = _statements() for cid, old in _RETIRED_EXCERPTS: assert not _is_excerpt(_norm(old), statements[cid]), (cid, old) def test_implemented_matches_citing_modules() -> None: citing: dict[str, set[str]] = {} for c in _citations(): if not c.colon or c.doc == "decisions" or c.path.suffix != ".py": continue citing.setdefault(c.cid, set()).add(str(c.path.relative_to(REPO))) problems = [] for rule in parse_rules_doc(read_rules_text()): actual = citing.get(rule.rule_id, set()) if rule.implemented: declared = set(rule.implemented) if actual != declared: problems.append( f"{rule.rule_id}: implemented: says {sorted(declared)} " f"but citations found in {sorted(actual)}") elif rule.tracked and actual: # The other half of test_rules_doc.py's exactly-one-pointer # rule: that test cannot see code, so a rule that SHIPPED # while keeping tracked: would pass it. Without this branch # the stale pointer is invisible -- the loop above skips # any rule with no implemented: at all. problems.append( f"{rule.rule_id}: declares tracked: {sorted(rule.tracked)} " f"but code cites it in {sorted(actual)}; swap tracked: for " f"implemented: now that something implements it") assert not problems, "\n".join(problems) _ID_RE = re.compile(r"\b[A-Z]\d+\b") def _skeleton_texts(r: Rule) -> list[str]: sk = r.skeleton assert sk is not None return skeleton_prose(sk) def named_id_findings(rules: list[Rule]) -> list[str]: """Every rule ID a rule names that no rule has: the IDs written anywhere in its quotable prose, every field's text as rules_skeleton.skeleton_prose lists it. A rule's own ID is not a citation of another.""" ids = {r.rule_id for r in rules} out = [] for r in rules: found: set[str] = set() for text in _skeleton_texts(r): found |= set(_ID_RE.findall(text)) - {r.rule_id} out += [f"{r.rule_id} -> {t}" for t in sorted(found) if t not in ids] return out def test_the_rule_ids_a_rule_names_exist() -> None: missing = named_id_findings(parse_rules_doc(read_rules_text())) assert not missing, f"rules name unknown rule IDs: {missing}" def _skeleton_doc(*, signal: str = "a shape", then: str = "", bullets: tuple[str, ...] = (), excepts: tuple[str, ...] = (), precedence: tuple[str, ...] = (), accepted: tuple[str, ...] = (), when: str = "a name ends in a thing.", tolerated: str = "", because: str = "it is how it is written.", others: tuple[str, ...] = ("X2",)) -> str: """A synthetic rule X1, then a minimal rule for each of ``others`` (each under its own heading, which ends the footer).""" out = f'# X1 — one\n\n**When:** {when}\n\n' if tolerated: out += f"**Tolerated:** {tolerated}\n\n" out += f'**Then:** {then or "the thing is read."}\n\n' out += "".join(f"- {b}\n" for b in bullets) out += f"\n**Because:** {because}\n\n" out += '```\n"John Smith III" → suffix="III"\n```\n\n' if excepts: out += "**Except:**\n\n" + "".join( f'- {b}\n ```\n "John V" → family="V" · boundary\n ```\n' for b in excepts) + "\n" if precedence: out += "**Precedence:**\n\n" + "".join( f"- {b}\n" for b in precedence) + "\n" if accepted: out += "**Accepted:**\n\n" + "".join( f"- {b}\n" for b in accepted) + "\n" out += (f"---\n**Signal:** {signal}\n" "**history:** decisions/P2 · **implemented:** " "nameparser/_parser.py\n\n") return out + "".join(_minimal_rule(o) for o in others) def _minimal_rule(rid: str) -> str: return (f"# {rid} — other\n\n**When:** w.\n\n**Then:** t.\n\n" "**Because:** b.\n\n```\n\"a\" → given=\"a\" · boundary\n" "```\n\n---\n**Signal:** a shape\n" "**history:** decisions/P2 · **implemented:** " "nameparser/_parser.py\n\n") def test_ids_a_skeleton_rule_names_exist() -> None: doc = _skeleton_doc(precedence=("X9 decides first.",)) assert named_id_findings(parse_rules_doc(doc)) == ["X1 -> X9"] # its own ID, and an ID that exists, are fine doc = _skeleton_doc(precedence=("X1 and X2 agree.",)) assert named_id_findings(parse_rules_doc(doc)) == [] @pytest.mark.parametrize("field", [ "when", "tolerated", "then", "bullets", "because", "excepts", "precedence", "accepted"]) def test_an_unknown_id_in_any_skeleton_field_is_a_finding(field: str) -> None: # every quotable field is scanned, read off skeleton_prose (#768) text = "X9 says so." value: str | tuple[str, ...] = ( (text,) if field in ("bullets", "excepts", "precedence", "accepted") else text) doc = _skeleton_doc(**{field: value}) # type: ignore[arg-type] assert named_id_findings(parse_rules_doc(doc)) == ["X1 -> X9"] def _skeleton_hub_findings(r: Rule, ids: set[str]) -> list[str]: """A skeleton hub's map is the bullet list under its Then: the union of the rule IDs the bullets name. Each hub rule must be in it, each ID in it must be a hub rule, and each bullet must name a rule.""" assert r.skeleton is not None problems = [] bullets = r.skeleton.then_bullets mapped = set().union(*(set(_ID_RE.findall(b.text)) for b in bullets)) for rid in r.hub: where = f"{r.rule_id}'s Signal: hub of ... {rid}" if rid not in ids or rid == r.rule_id: problems.append(f"{where}: names no other rule") elif rid not in mapped: problems.append(f"{where}: its statement's map omits it") for rid in sorted(mapped & ids - set(r.hub) - {r.rule_id}): problems.append(f"{r.rule_id}'s map names {rid}, which its " f"Signal: hub of ... leaves off") for b in bullets: # a bullet naming a rule off the hub list is the "leaves off" # finding above; this one is a bullet naming no rule at all if not set(_ID_RE.findall(b.text)) & ((ids | set(r.hub)) - {r.rule_id}): problems.append(f"{r.rule_id}'s map bullet names no rule of " f"its hub: {b.text!r}") return problems def hub_findings(rules: list[Rule]) -> list[str]: """A hub's ``**Signal:** ... hub of ...`` names the rules stating the signals it combines, and its map is the bullet list under its Then; each rule on the list must be another rule and named by a bullet, every bullet must name one of them, and the check runs both ways: a rule a bullet names that the Signal: line leaves off is a finding too, which is the half of S2's lost S9 (named in neither its map nor its pointer line before PR #745) the first direction alone would miss.""" ids = {r.rule_id for r in rules} problems = [] for r in rules: if r.hub: problems += _skeleton_hub_findings(r, ids) return problems def test_a_hub_names_each_rule_its_map_names() -> None: rules = parse_rules_doc(read_rules_text()) assert [r.rule_id for r in rules if r.hub], "no hub to check" problems = hub_findings(rules) assert not problems, "\n".join(problems) @pytest.mark.parametrize("signal, bullets, excepts, finding", [ ("hub of X2, X3", ("**Reach:** X2 first.", "**Then:** X3 next."), (), None), # the map omits X3 ("hub of X2, X3", ("**Reach:** X2 first.",), (), "its statement's map omits it"), # a map bullet names X4, which the hub list leaves off ("hub of X2, X3", ("X2 first.", "X3 next.", "X4 last."), (), "which its Signal: hub of ... leaves off"), # a map bullet naming no rule of the hub ("hub of X2, X3", ("X2 first.", "X3 next.", "Only the last one."), (), "names no rule of its hub"), # X2 named only in an Except bullet is not in the map ("hub of X2, X3", ("X3 next.",), ("X2 is an exception.",), "its statement's map omits it"), ("hub of X9", ("X9 first.",), (), "names no other rule"), # a hub naming itself names no other rule ("hub of X1, X2", ("X1 and X2.",), (), "names no other rule"), # X2 is not named merely because X21 is ("hub of X2, X3", ("X21 and X3.",), (), "its statement's map omits it"), ]) def test_skeleton_hub_findings_catch_each_gap( signal: str, bullets: tuple[str, ...], excepts: tuple[str, ...], finding: str | None) -> None: doc = _skeleton_doc(signal=signal, bullets=bullets, excepts=excepts, others=("X2", "X3", "X4")) got = hub_findings(parse_rules_doc(doc)) if finding is None: assert got == [] else: assert len(got) == 1 and finding in got[0], got def test_a_quote_of_a_bold_lead_matches_and_a_fenced_example_does_not( ) -> None: doc = _skeleton_doc( bullets=("**One letter needs a word to spare** when it is last.",)) text = _norm(" ".join(prose_blocks(doc)["X1"])) assert _is_excerpt(_norm("One letter needs a word to spare"), text) assert _is_excerpt(_norm("spare when it is last"), text) # "Smith III" occurs only in the fence assert not _is_excerpt(_norm("John Smith III"), text) def test_doc_internal_anchors_resolve() -> None: """Every rules/X / decisions/Y / mechanisms.md#Z reference INSIDE the three docs points at a real rule ID, ### key, or entry heading (## or ###). The code-side citations are covered above; this closes the doc-to-doc half.""" paths = [*sorted(RULES_DIR.glob("*.md")), *sorted(DECISIONS_DIR.glob("*.md")), MECH_DOC] docs = {str(p.relative_to(REPO)): p.read_text(encoding="utf-8") for p in paths} # the parser's own reading, so a new rule format needs no copy here rule_ids = {r.rule_id for r in parse_rules_doc(read_rules_text())} dec_keys = set(read_decision_files()) mech_slugs = set(re.findall(r"^#{2,3} ([A-Z][A-Z0-9_-]+)", docs[str(MECH_DOC.relative_to(REPO))], re.M)) problems = [] for name, text in docs.items(): for m in _DOC_ANCHOR_RE.finditer(text): doc, anchor = m.group("doc") or m.group("mdoc"), m.group("cid") ok = (anchor in rule_ids if doc == "rules" else anchor in dec_keys if doc == "decisions" else anchor in mech_slugs) if not ok: line = text[:m.start()].count("\n") + 1 problems.append(f"{name}:{line} -> {_cite(doc, anchor)}") assert not problems, "\n".join(problems) def test_no_legacy_citations() -> None: if not ENFORCE_NO_LEGACY: return # armed by the final rewrite pass self_files = {Path(__file__).name, "test_doc_spellings.py", "rules_doc.py"} problems = [] for d in SWEEP_DIRS: for path in sorted((REPO / d).rglob("*")): if path.suffix != ".py": continue if path.name in self_files: continue text = path.read_text(encoding="utf-8", errors="ignore") for pat in _LEGACY_RES: if pat.search(text): problems.append( f"{path}: matches {pat.pattern!r}") assert not problems, "\n".join(problems) _OLD_ANCHOR_RE = re.compile(r"\b(rules|decisions)\.md#") def test_no_old_design_doc_anchors() -> None: """rules.md and decisions.md are folders since #713, cited as rules/S2 and decisions/key. An old-form citation written from habit would not be read by the citation check at all, so its quote would go unchecked; this keeps the form out of every tracked file.""" tracked = subprocess.run(["git", "ls-files"], cwd=REPO, check=True, capture_output=True, text=True).stdout.split() problems = [] for rel in tracked: if not rel.endswith((".py", ".md", ".rst")): continue path = REPO / rel if not path.exists(): continue for i, line in enumerate( path.read_text(encoding="utf-8").splitlines(), 1): if _OLD_ANCHOR_RE.search(line): problems.append(f"{rel}:{i}: {line.strip()[:80]}") assert not problems, ( f"{len(problems)} old-form citations; write rules/ID or " "decisions/key:\n" + "\n".join(problems[:40])) ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_doc_spellings.py������������������������������������0000664�0000000�0000000�00000002102�15273032717�0024631�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Recorded roster of spellings that have actually shipped wrong in docs, docstrings, or messages. Add an entry when one is caught; never remove one without a decisions note.""" from __future__ import annotations from pathlib import Path REPO = Path(__file__).resolve().parents[2] DENYLIST: dict[str, str] = { "Policy(segment_scripts=())": ( "#334/#337: tuple literal fails mypy on this field; " "the type-clean spelling is frozenset()"), "segment_scripts=()": "#334: same arg-type error, keyword form", } SWEEP = ("nameparser", "docs") def test_no_denylisted_spellings() -> None: hits = [] for d in SWEEP: for path in sorted((REPO / d).rglob("*")): if path.suffix not in (".py", ".rst", ".md"): continue if "superpowers" in path.parts: continue text = path.read_text(encoding="utf-8", errors="ignore") for bad, why in DENYLIST.items(): if bad in text: hits.append(f"{path}: {bad!r} ({why})") assert not hits, "\n".join(hits) ��������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_facade.py�������������������������������������������0000664�0000000�0000000�00000137270�15273032717�0023226�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""The 2.0 HumanName facade (mechanisms.md#FACADE-CONTRACT).""" import copy import pickle import warnings from pathlib import Path import pytest from nameparser._config_shim import CONSTANTS, Constants from nameparser._facade import HumanName from nameparser._types import UNCLASSIFIED_TAG, Role, Token from tests.removals import ignore_removals _DATA_DIR = Path(__file__).parent / "data" def test_basic_parse_and_v1_spellings() -> None: n = HumanName("Dr. Juan de la Vega III") assert n.title == "Dr." assert n.first == "Juan" # v1 spelling of core 'given' assert n.last == "de la Vega" # v1 spelling of core 'family' assert n.suffix == "III" assert n.original == "Dr. Juan de la Vega III" assert n.full_name == "Dr. Juan de la Vega III" def test_bytes_raises_with_decode_hint() -> None: # #245 with pytest.raises(TypeError, match="decode"): HumanName(b"John Smith") # type: ignore[arg-type] def test_constants_none_raises_migration_hint() -> None: # #261 with pytest.raises(TypeError, match="constants"): HumanName("John Smith", constants=None) def test_full_name_assignment_reparses() -> None: n = HumanName("John Smith") n.full_name = "Jane Doe" assert (n.first, n.last) == ("Jane", "Doe") def test_private_constants_mutation_honored_lazily() -> None: # NB: the task spec's example used "dame" as a marker not in the # default titles, but the shipped vocabulary already lists # "dame" -- swapped in a nonsense marker to keep the test's premise # (mutate a private Constants, next parse sees the change) true. c = Constants() n = HumanName("Zzqtitle Judy Dench", constants=c) assert n.title == "" # 'zzqtitle' not a default title c.titles.add("zzqtitle") n.full_name = "Zzqtitle Judy Dench" # next parse sees the change assert n.title == "Zzqtitle" def test_capitalize_name_flag_capitalizes_on_parse() -> None: c = Constants() c.capitalize_name = True n = HumanName("john smith", constants=c) assert (n.first, n.last) == ("John", "Smith") def test_constructor_suffix_delimiter_layers_onto_policy() -> None: # ' | ', not ' - ': a hyphen standing alone is built in (#889) n = HumanName("Doe, John, RN | CRNA", constants=Constants(), suffix_delimiter=" | ") assert n.suffix == "RN, CRNA" def test_subclass_overriding_no_hooks_never_warns() -> None: class Plain(HumanName): pass with warnings.catch_warnings(): warnings.simplefilter("error") ignore_removals() Plain("John Smith") def test_subclass_hook_override_warns_once_per_class() -> None: # #280 class Custom(HumanName): def parse_pieces(self, parts: object, additional_parts_count: int = 0) -> object: return parts with pytest.deprecated_call(match="parse_pieces"): Custom("John Smith") with warnings.catch_warnings(): warnings.simplefilter("error") ignore_removals() Custom("Jane Doe") # second instance: silent def test_c_property_and_has_own_config() -> None: n = HumanName("John Smith") assert n.C is CONSTANTS assert n.has_own_config is False m = HumanName("John Smith", constants=Constants()) assert m.has_own_config is True def test_dirty_tracking_reuses_cached_parser_across_unchanged_generation() -> None: n = HumanName("John Smith") assert n._resolve() is n._resolve() c = Constants() m = HumanName("John Smith", constants=c) parser_before = m._resolve() c.titles.add("zzq") # bumps the generation parser_after = m._resolve() assert parser_before is not parser_after def test_component_kwargs_bypass_parsing() -> None: n = HumanName(first="John", last="de la Vega") assert n.first == "John" and n.last == "de la Vega" assert n.full_name == "" def test_field_assignment_str_list_none() -> None: n = HumanName("John Smith") n.first = "Jane" assert n.first == "Jane" n.middle = ["Q", "Xavier"] # lists join with space assert n.middle == "Q Xavier" n.middle = None # None clears assert n.middle == "" assert n.last == "Smith" # untouched fields survive assert n.full_name == "John Smith" # no re-parse (v1 parity) def test_list_attributes_are_snapshots() -> None: n = HumanName("John Quincy Adams Smith") lst = n.middle_list lst.append("HACKED") assert "HACKED" not in n.middle_list def test_derived_views() -> None: n = HumanName("Juan Q. de la Vega") assert n.surnames == "Q. de la Vega" # middle + last (v1 shape) assert n.given_names == "Juan Q." # first + middle assert n.last_prefixes == "de la" assert n.last_base == "Vega" def test_split_last_all_prefix_guard() -> None: n = HumanName(last="de la") # every word is a particle assert n.last_prefixes == "" # v1 guard: no stripping assert n.last_base == "de la" @pytest.mark.parametrize("member", [ "title", "first", "middle", "last", "suffix", "nickname", "maiden", ]) def test_every_member_set_get_and_list(member: str) -> None: n = HumanName() setattr(n, member, "Alpha Beta") # the suffix string view joins parts with ", " (v1 parity) joined = "Alpha, Beta" if member == "suffix" else "Alpha Beta" assert getattr(n, member) == joined assert getattr(n, f"{member}_list") == ["Alpha", "Beta"] def test_list_assignment_rejects_non_str_elements() -> None: n = HumanName("John Smith") with pytest.raises(TypeError, match="strings"): n.first = [1, 2] # type: ignore[list-item] with pytest.raises(TypeError, match="strings"): n.first = [None] # type: ignore[list-item] def test_list_assignment_multiword_elements_join() -> None: n = HumanName("John Smith") n.middle = ["ab", "cd ef"] assert n.middle == "ab cd ef" def test_suffix_list_heals_joined_continuations() -> None: # v1 fix_phd n = HumanName("John Ph. D.") assert n.suffix == "Ph. D." assert n.suffix_list == ["Ph. D."] # ONE element, v1 parity def test_the_joined_tag_never_reaches_a_title( # #429 regression guard ) -> None: """The "joined" tag is role-BLIND and _list_tokens_for heals it for every role (_list_for is only the string view built from that walk), so a tag placed for the suffix view is read by the title view too. So the pass that writes it (post_rules' R1 entry pass, #436) walks the SUFFIX tokens alone and reads every other role as transparent to the run rather than as part of it. #429's first draft, over the piece-shaped join this replaced, let any piece open an entry: a title piece doing so tagged the title behind it, and title_list collapsed. The shape of that bug survives the rewrite, which is why this guard does. Asserted on the LIST views because the string views cannot see it -- titles render space-joined either way, which is why the case table and the differential (both string-only) stayed green while this was broken. """ n = HumanName("Smith, Rev. Dr.") assert n.title == "Rev. Dr." # unchanged, and why it hid assert n.title_list == ["Rev.", "Dr."] # TWO elements # The other half: with a suffix already standing from the pre-comma # segment, a title opening the entry glued the next suffix backward # across the writer's own comma. m = HumanName("Smith Jr., Mr. Jr.") assert m.suffix == "Jr., Jr." assert m.suffix_list == ["Jr.", "Jr."] # The control: where the pieces really are one entry, they DO heal. k = HumanName("Smith, MD PhD") assert k.suffix_list == ["MD PhD"] # ONE element # And the pin for the _RENDERS_ELSEWHERE transparency itself, which # the three above miss: a role that renders elsewhere is stepped # OVER by the pass -- it pairs each SUFFIX with the NEXT SUFFIX -- # and is never stepped onto. Pair a suffix with the tokens that # follow it instead, and MD reaches both titles here in its own # comma bucket with nothing parting them: both are tagged, and # title_list collapses to ['Rev. Dr.']. It needs a suffix word # FIRST, so a run is legitimately open, and then TWO titles -- # which is why 'Smith, Rev. Dr.' above (no suffix at all, so the # pass never runs) cannot reach this mutant. j = HumanName("Smith, MD Rev. Dr.") assert j.suffix == "MD" assert j.title == "Rev. Dr." # identical either way assert j.title_list == ["Rev.", "Dr."] # the mutant gives ['Rev. Dr.'] def test_str_uses_string_format_with_v1_cleanup() -> None: n = HumanName("Dr. Juan de la Vega III") assert str(n) == "Dr. Juan de la Vega III" n2 = HumanName("John Smith") # empty nickname: no ' ()' assert str(n2) == "John Smith" n2.string_format = "{last}, {first}" assert str(n2) == "Smith, John" def test_str_none_format_falls_back_to_space_join() -> None: # v1-identical: the format wraps the nickname in parens, the plain # member join does not n = HumanName('John "Jack" Kennedy') assert str(n) == "John Kennedy (Jack)" n.string_format = None assert str(n) == "John Kennedy Jack" def test_getitem_unknown_key_raises_attribute_error() -> None: n = HumanName("John Smith") with pytest.raises(AttributeError): # v1 behavior, not KeyError n["nope"] def test_repr_v1_shape() -> None: r = repr(HumanName("John Smith")) assert r.startswith("<HumanName : [") assert "first: 'John'" in r and "last: 'Smith'" in r def test_iter_and_len_count_nonempty() -> None: n = HumanName("John Smith") assert list(n) == ["John", "Smith"] assert len(n) == 2 assert len(HumanName("")) == 0 # documented emptiness check def test_getitem_str_ok_slice_raises() -> None: # #258 n = HumanName("John Smith") assert n["first"] == "John" with pytest.raises(TypeError, match="#258"): n[1:-1] # type: ignore[index] with pytest.raises(TypeError): n["first"] = "Jane" # type: ignore[index] # no __setitem__ in 2.0 def test_getitem_accepts_role_members() -> None: # Role is a StrEnum, so Role members reach __getitem__'s getattr; # the v1 spellings (first/last) must resolve for given/family too. hn = HumanName("Dr. John Quincy Smith Jr.") assert hn[Role.TITLE] == hn.title assert hn[Role.GIVEN] == hn.first assert hn[Role.MIDDLE] == hn.middle assert hn[Role.FAMILY] == hn.last assert hn[Role.SUFFIX] == hn.suffix assert hn[Role.NICKNAME] == hn.nickname assert hn[Role.MAIDEN] == hn.maiden # the plain strings work the same way assert hn["given"] == hn.first assert hn["family"] == hn.last def test_assigning_v2_field_spellings_warns_but_still_sets() -> None: # "given"/"family" are 2.0 spellings the facade has no attribute # for, so plain assignment silently forks the name: a stray # instance attribute appears while the parse (and .first/.last) # keeps the old value. Warn -- but still set, so any v1-legal code # that worked keeps working. hn = HumanName("John Smith") with pytest.warns(UserWarning, match=r"use \.first"): hn.given = "Jane" # type: ignore[attr-defined] assert hn.given == "Jane" # type: ignore[attr-defined] # stray attr still set assert hn.first == "John" # the parse did not change with pytest.warns(UserWarning, match=r"use \.last"): setattr(hn, Role.FAMILY, "Doe") # StrEnum member, same trap def test_other_attribute_assignment_stays_silent() -> None: # The five 2.0 field names that ARE facade properties go through # their setters; the v1 spellings work; ad-hoc attribute stashing # is a legal v1 pattern. None of these warn. hn = HumanName("John Smith") with warnings.catch_warnings(): warnings.simplefilter("error") hn.title = "Dr." hn.first = "Jane" hn.my_cache = 42 # type: ignore[attr-defined] assert hn.title == "Dr." and hn.first == "Jane" def test_eq_and_hash_are_object_identity() -> None: # #223 a, b = HumanName("John Smith"), HumanName("John Smith") assert a != b and a == a assert hash(a) != hash(b) or a is b # default object hash def test_as_dict_v1_keys() -> None: n = HumanName("Dr. John Smith") d = n.as_dict() assert d["title"] == "Dr." and d["first"] == "John" and d["last"] == "Smith" assert set(n.as_dict(include_empty=False)) == {"title", "first", "last"} def test_initials_v1_semantics() -> None: assert HumanName("Sir Bob Andrew Dole").initials() == "B. A. D." assert HumanName("Sir Bob Andrew Dole").initials_list() == ["B", "A", "D"] n = HumanName("Doe, John A.", initials_delimiter="", initials_separator="") assert n.initials() == "J A D" # prefixes/conjunctions are filtered except in first names (v1 rule) assert HumanName("Juan de la Vega").initials() == "J. V." def test_initials_format_kwarg() -> None: n = HumanName("Sir Bob Andrew Dole", initials_format="{first} {middle}") assert n.initials() == "B. A." def test_render_default_setters_validate() -> None: n = HumanName("John Smith") with pytest.raises(TypeError, match="initials_delimiter"): n.initials_delimiter = 5 # type: ignore[assignment] with pytest.raises(TypeError, match="initials_format"): n.initials_format = 5 # type: ignore[assignment] with pytest.raises(TypeError, match="initials_separator"): n.initials_separator = None # type: ignore[assignment] with pytest.raises(TypeError, match="string_format"): n.string_format = 5 # type: ignore[assignment] with pytest.raises(TypeError, match="suffix_delimiter"): n.suffix_delimiter = 5 # type: ignore[assignment] n.string_format = None # None allowed for these two n.suffix_delimiter = None def test_capitalize_gate_and_force() -> None: n = HumanName("bob v. de la macdole-eisenhower phd") n.capitalize() assert str(n) == "Bob V. de la MacDole-Eisenhower PhD" m = HumanName("Shirley Maclaine") # mixed case: untouched m.capitalize() assert str(m) == "Shirley Maclaine" m.capitalize(force=True) assert str(m) == "Shirley MacLaine" def test_force_mixed_case_flag_feeds_default() -> None: c = Constants() c.force_mixed_case_capitalization = True n = HumanName("Shirley Maclaine", constants=c) n.capitalize() # force=None -> flag -> True assert str(n) == "Shirley MacLaine" def test_matches_and_comparison_key() -> None: # NB: the task spec's example compared "Dr. John A. Smith" against # "John Smith" -- but matches()/comparison_key() are component-wise # over all seven fields (v1 parity, verified against live 1.4), so a # title/middle-initial mismatch always fails the match; swapped in # case/order variations that actually exercise "case-insensitive, # component-wise" without dropping fields. n = HumanName("Dr. John A. Smith") assert n.matches("dr. john a. smith") assert n.matches(HumanName("smith, dr. john a.")) assert not n.matches("Jane Smith") assert n.comparison_key() == HumanName("DR. JOHN A. SMITH").comparison_key() def test_pickle_round_trip_preserves_components() -> None: n = HumanName("Dr. Juan de la Vega III") n.first = "Johan" # mutated state must survive loaded = pickle.loads(pickle.dumps(n)) assert loaded.first == "Johan" assert loaded.last == "de la Vega" assert loaded.original == "Dr. Juan de la Vega III" assert loaded.C is CONSTANTS # shared sentinel restored @pytest.mark.parametrize("bad", [ "John", # str: iterating yields characters {"John": 1}, # Mapping: iterating yields keys only b"John", # bytes: iterating yields ints ["John", None], # a non-str entry ]) def test_setstate_rejects_malformed_component_lists(bad: object) -> None: # _normset on the Lexicon side rejects str AND Mapping AND checks # entry types; this guard covered only str, so a dict silently # loaded its keys and bytes/None died later with an opaque # AttributeError instead of naming the field n = HumanName("John Smith") state = n.__getstate__() state["first_list"] = bad loaded = HumanName.__new__(HumanName) with pytest.raises(TypeError, match="first_list"): loaded.__setstate__(state) def test_setstate_restores_a_foreign_multi_word_suffix_entry() -> None: # the parametrized round-trip tests above go through v2's own # __getstate__, which is self-consistent by construction. The case # the fix actually protects is a 1.4-produced blob carrying a # multi-word suffix entry, so build that state directly. n = HumanName("John Smith") state = n.__getstate__() state["suffix_list"] = ["Ph. D."] loaded = HumanName.__new__(HumanName) loaded.__setstate__(state) assert loaded.suffix_list == ["Ph. D."] assert loaded.suffix == "Ph. D." #: Every name in the 486-name differential corpus whose HumanName pickle #: round-trip drifted before the __setstate__ element-boundary fix. Each #: carries a multi-word suffix entry -- a "joined" continuation token -- #: inside one comma segment. Verified against a live 1.4.0: all eight #: round-trip unchanged there, so drift here is a regression, not an #: inherited quirk. _MULTIWORD_SUFFIX_NAMES = [ "Andrew Perkins, Jr., Col. (Ret)", "Clarke, Kenneth, Q.C. M.P.", "Doe, John, MD PhD - FACS Fellow", "Franklin Washington, Jr. MD", "John Smith Ph. D.", "John Smith, Ph. D.", "John Smith, V Jr.", "John Smith, V MD", ] @pytest.mark.parametrize("raw", _MULTIWORD_SUFFIX_NAMES) def test_pickle_round_trip_preserves_multiword_suffix_entries(raw: str) -> None: # __setstate__ restores components from the pickled *_list values. # Joining them into one string and letting replace() re-split on # whitespace destroys the entry boundaries, and the suffix view's # ", " join then renders one credential as two ("Ph." + "D." -> # "Ph., D."). Entries must survive as entries. n = HumanName(raw) loaded = pickle.loads(pickle.dumps(n)) assert loaded.suffix_list == n.suffix_list assert loaded.suffix == n.suffix assert str(loaded) == str(n) assert loaded.as_dict() == n.as_dict() # -- Gap 2: parse_full_name() (v1's documented re-parse idiom) ------------- def test_parse_full_name_reparses_documented_idiom() -> None: c = Constants() n = HumanName("Zzqtitle Judy Dench", constants=c) assert n.title == "" # 'zzqtitle' not a default title c.titles.add("zzqtitle") # mutate C in place, no full_name reassignment n.parse_full_name() # documented v1 idiom assert n.title == "Zzqtitle" def test_subclass_overriding_parse_full_name_warns() -> None: # #280 class CustomReparse(HumanName): def parse_full_name(self) -> None: pass with pytest.deprecated_call(match="parse_full_name"): CustomReparse("John Smith") # -- Gap 3: HumanName.C setter (v1.4 #239) ---------------------------------- def test_c_setter_assigns_and_next_parse_honors_it() -> None: n = HumanName("John Smith") c2 = Constants() c2.titles.add("zzqtitle") n.C = c2 assert n.C is c2 n.full_name = "Zzqtitle Judy Dench" # next parse: v1's setter only stored, no reparse assert n.title == "Zzqtitle" def test_c_setter_none_raises_migration_hint() -> None: n = HumanName("John Smith") with pytest.raises(TypeError, match="constants"): n.C = None # type: ignore[assignment] def test_c_setter_non_constants_raises() -> None: n = HumanName("John Smith") with pytest.raises(TypeError): n.C = 42 # type: ignore[assignment] # -- Gap 4: matches() error message names the facade type ------------------- def test_matches_type_error_names_facade_type() -> None: n = HumanName("John Smith") with pytest.raises(TypeError, match="HumanName"): n.matches(None) # type: ignore[arg-type] def test_v14_humanname_blob_unpickles() -> None: # tests/v2/data/humanname_v14.pickle was produced by a real 1.4.0 # install (`uv run --no-project --with "nameparser==1.4.*" ...`) on # HumanName("Dr. Juan de la Vega III"); components come back exactly # as pickled, NOT re-parsed. Since the M11 swap replaced # nameparser.parser.HumanName with this facade, the blob's pickled # class reference now resolves here (mirrors # test_v14_constants_blob_unpickles_into_shim in test_config_shim.py). # pickle.load is safe here: humanname_v14.pickle is a repo-controlled # fixture generated by this task from a real 1.4.0 install (Step 2 # above), not data from an untrusted source. with open(_DATA_DIR / "humanname_v14.pickle", "rb") as f: loaded = pickle.load(f) assert isinstance(loaded, HumanName) assert loaded.first == "Juan" and loaded.last == "de la Vega" assert loaded.title == "Dr." and loaded.suffix == "III" assert loaded.original == "Dr. Juan de la Vega III" assert loaded.C is CONSTANTS # shared sentinel restored (v1.4's C: None) def test_suffix_delimiter_reassignment_after_construction() -> None: # ' | ', not ' - ': a hyphen standing alone is built in (#889) n = HumanName("Doe, John, RN | CRNA") assert n.suffix == "RN | CRNA" # no delimiter: one entry n.suffix_delimiter = " | " n.parse_full_name() assert n.suffix == "RN, CRNA" # setter invalidated the Policy n.suffix_delimiter = None n.parse_full_name() assert n.suffix == "RN | CRNA" # and back def test_c_swap_keeps_instance_suffix_delimiter() -> None: # both invalidation paths together: swapping C must not lose the # instance-level delimiter layered onto the new snapshot's Policy n = HumanName("Doe, John, RN | CRNA", suffix_delimiter=" | ") assert n.suffix == "RN, CRNA" n.C = Constants() n.parse_full_name() assert n.suffix == "RN, CRNA" def test_failed_reparse_leaves_state_consistent() -> None: # _resolve() raising must not leave full_name pointing at the new # value over the OLD parsed fields n = HumanName("John Smith") class _Boom(Exception): pass def explode() -> None: raise _Boom n._C = Constants() n._C._snapshot = explode # type: ignore[method-assign, assignment] n._snapshot_gen = -1 with pytest.raises(_Boom): n.full_name = "Jane Doe" assert n.full_name == "John Smith" assert n.first == "John" def test_middle_as_family_list_view_matches_string_view() -> None: # v1 PREPENDED middle_list to last_list; the list view must agree # with the string view, not with raw token order c = Constants() c.middle_name_as_last = True n = HumanName("Hassan, Mohamad Ahmad Ali", constants=c) assert n.last == "Ahmad Ali Hassan" assert n.last_list == ["Ahmad", "Ali", "Hassan"] def test_cold_import_order_config_first() -> None: # the _facade cycle-breaker (import nameparser.config first) is # order-dependent; this pins BOTH cold-start directions in fresh # interpreters so an import-sorting pass cannot silently break one import subprocess import sys for first in ("nameparser.config", "nameparser._facade"): proc = subprocess.run( [sys.executable, "-c", f"import {first}; import nameparser; " f"print(nameparser.HumanName('John Smith').last)"], capture_output=True, text=True) assert proc.returncode == 0, (first, proc.stderr) assert proc.stdout.strip() == "Smith" def test_facade_reads_wholly_han_names_family_first() -> None: # classified fix(#271): script_orders reaches the v1 surface. # Constants has no opt-out knob (v1 surface is frozen); the # migration path is v2 Policy(script_orders={}). n = HumanName("毛 泽东") assert (n.last, n.first) == ("毛", "泽东") # unsplit (Han segmentation is locales.ZH, v2-only), but the lone # wholly-Han token now takes the family role, not first assert HumanName("毛泽东").last == "毛泽东" assert HumanName("毛泽东").first == "" def test_facade_reads_kana_licensed_names_family_first() -> None: n = HumanName("高橋 みなみ") assert (n.last, n.first) == ("高橋", "みなみ") def test_facade_parses_unspaced_korean_by_default() -> None: # the default-behavior fix flows through the v1 facade (its # lexicon mirrors Lexicon.default() via the shim snapshot) n = HumanName("김민준") assert (n.last, n.first) == ("김", "민준") def test_list_tokens_for_carries_the_list_view_s_own_elements() -> None: # #528: the initials view needs the TOKEN behind each word, and the # element boundaries are the whole point -- a "joined" continuation # belongs to its predecessor's part and a folded middle sorts first. # One walk builds both views so they cannot drift apart. # # Verified at review (2026-09-13): 0 mismatches between # _list_tokens_for's join and _list_for's own output over the # full corpus, 1173 non-empty names (the glob holds 1174 distinct # names, one of them empty) x 7 members = 8211 pairs. That sweep # can't fail by construction -- _list_for IS DEFINED as that join # -- so it does not stand as a test by itself. What a per-token # walk COULD get wrong is the element BOUNDARIES, so this pins the # three shapes where that could diverge by name: a folded middle # (reordered ahead of the other parts), a "joined" continuation # (two tokens sharing one element), and a spliced field # (UNCLASSIFIED_TAG tokens from replace(), no STABLE tag to walk). folded_c = Constants() folded_c.middle_name_as_last = True spliced = HumanName("john smith") spliced.middle = "e f" cases: list[tuple[HumanName, str, list[list[str]], list[str]]] = [ (HumanName("Hassan, Mohamad Ahmad Ali", constants=folded_c), "last", [["Ahmad"], ["Ali"], ["Hassan"]], # folded middle, first ["Ahmad", "Ali", "Hassan"]), (HumanName("Ph. D., John"), "last", [["Ph.", "D."]], # "joined" continuation ["Ph. D."]), (spliced, "middle", [["e"], ["f"]], # spliced field ["e", "f"]), ] for n, member, expected_shape, expected_str in cases: groups = n._list_tokens_for(member) assert [[t.text for t in g] for g in groups] == expected_shape, \ (n.original, member) # Asserted against its own literal, not against _list_for(member) -- # _list_for IS DEFINED as this same join, so comparing the two # can't fail by construction (measured; see the comment above). assert n._list_for(member) == expected_str, (n.original, member) for name in ("Ph. D., John", "Dr. Juan Q. Xavier de la Vega III", "der, y van", "JUAN GARCIA Y LOPEZ", "Doe, John A."): n = HumanName(name) for member in ("title", "first", "middle", "last", "suffix", "nickname", "maiden"): groups = n._list_tokens_for(member) # `all(g for g in groups)` cannot fail by construction either -- # _list_tokens_for never emits an empty group, vacuously true # (including over the empty list every unused member here # returns). What IS worth pinning: a second call returns an # EQUAL grouping. That is not a given for free -- a one-shot # walk built over an exhausted iterator behind tokens_for() # would return nothing at all the second time, not merely a # fresh-but-equal list, so this assert would catch that shape # of bug too. assert n._list_tokens_for(member) == groups, (name, member) def test_token_is_conjunction_reads_the_tag_then_the_vocabulary() -> None: # #528, the two-way decision. A token the parser classified answers # from its tags -- the source the core's initials() reads # (mechanisms.md#RENDER-HONORS-THE-PARSE) -- so a one-case 'e' that # rules/P8 tagged `initial` is a name word and a bare capital 'Y' # that P8 tagged `conjunction` is not, which is the reverse of what # the vocabulary-and-shape test said about either. parsed = HumanName("JUAN Y GARCIA") tags = {t.text: t for t in parsed._parsed.tokens} # #461: the 'Y' holds the middle part alone, so the parse marked # it as joining nothing and the predicate reads that mark beside # the tag. The CONTROL below is a 'Y' that IS joining, where the # tag still answers on its own. assert parsed._token_is_conjunction(tags["Y"]) is False assert parsed._token_is_conjunction(tags["JUAN"]) is False joining = HumanName("JUAN GARCIA Y LOPEZ") jtags = {t.text: t for t in joining._parsed.tokens} assert joining._token_is_conjunction(jtags["Y"]) is True fork = HumanName("john e smith") e = {t.text: t for t in fork._parsed.tokens}["e"] assert fork._token_is_conjunction(e) is False # A field spliced in as raw text was read by no parse, so there is # no tag to honor and the vocabulary answers -- the same fallback # rules/R4's case repair takes, through the same helper. spliced = HumanName("john smith") spliced.middle = "e" lower = spliced._parsed.tokens_for(Role.MIDDLE)[0] assert UNCLASSIFIED_TAG in lower.tags assert spliced._token_is_conjunction(lower) is True spliced.middle = "E" upper = spliced._parsed.tokens_for(Role.MIDDLE)[0] assert spliced._token_is_conjunction(upper) is False def test_token_is_conjunction_resolves_an_unresolved_instance() -> None: # _token_is_conjunction calls self._resolve() before reading # self._lexicon (see the comment on that call). Pinning that it is # load-bearing, not defensive dead code: a keyword-constructed # HumanName never runs the full-string parse path other callers # rely on to have resolved already, so _lexicon is absent here # until this method's own _resolve() call builds it. hn = HumanName(first="John", middle="y", last="Smith") assert not hasattr(hn, "_lexicon") tok = hn._list_tokens_for("middle")[0][0] assert hn._token_is_conjunction(tok) is True def test_process_initial_direct_call_keeps_the_v1_string_path() -> None: # tests/test_initials.py calls this with a bare string and no # tokens, which is v1's shape and stays supported: with no tokens # there is no parse to honor, so every word takes the spliced-text # fallback -- the reading this method had for every word before # #528. These five are measured, not predicted. _process_initial # joins with initials_separator only (never initials_delimiter -- # that is applied by the caller in initials()), so the bare-string # probes with the default Constants come back undotted: 'j s' and # 'J Y G', not 'j. s.' / 'J. Y. G.'. hn = HumanName("", initials_separator="-", initials_delimiter=".") assert hn._process_initial("Van Berg", firstname=True) == "V-B" hn2 = HumanName("", initials_separator="") assert hn2._process_initial("Van Berg", firstname=True) == "VB" hn3 = HumanName("") assert hn3._process_initial("john e smith") == "j s" assert hn3._process_initial("JUAN Y GARCIA") == "J Y G" assert hn3._process_initial("de la") == "" def test_process_initial_with_tokens_reads_the_parse() -> None: # The same two name parts, this time handed the tokens the parse # built: the answer flips, which is #528 in one assertion. # #461 moved the FIRST half's value and the flip with it: the 'Y' # of 'JUAN Y GARCIA' holds the middle part alone, so the parse # marks it as joining nothing and this view takes its letter -- # which is what the bare-string path above already said about the # same text. The flip the first half pinned is re-pinned below on # a 'Y' that IS joining, where the two paths still disagree; # rules/R3 states the rule and decisions/R3 records it. hn = HumanName("JUAN Y GARCIA") middle = hn._list_tokens_for("middle")[0] assert hn._process_initial("", firstname=False, tokens=middle) == "Y" joining = HumanName("JUAN GARCIA Y LOPEZ") joined_y = [g for g in joining._list_tokens_for("last") if g[0].text == "Y"][0] assert joining._process_initial("", firstname=False, tokens=joined_y) == "" fork = HumanName("john e smith") fork_middle = fork._list_tokens_for("middle")[0] assert fork._process_initial("", firstname=False, tokens=fork_middle) == "e" def test_v1_signature_override_raises_from_initials() -> None: # The stated break (Derek, 2026-09-13): _initials_lists always # calls _process_initial with `tokens=`, so a subclass overriding # it with v1's two-argument signature (name_part, firstname=False) # raises TypeError the moment initials() runs, rather than being # silently skipped. Accepted over the alternative -- a string # wrapper kept over a token core -- which would make such an # override silently ineffective instead: the override would look # like it works and never actually run. Cited by the docs commit # as the accepted cost of #528's move to tokens. class LegacyOverride(HumanName): # Deliberately v1's narrower signature -- the incompatibility # IS the break under test, not a typing slip. def _process_initial(self, name_part: str, # type: ignore[override] firstname: bool = False) -> str: return super()._process_initial(name_part, firstname) hn = LegacyOverride("John Smith") with pytest.raises(TypeError, match="tokens"): hn.initials() def test_an_override_that_forwards_tokens_keeps_working() -> None: # The remedy for the break above, pinned because the OBVIOUS # remedy is wrong in the quiet direction: widening the signature # alone -- `**kwargs`, or a `tokens=None` the super() call drops -- # leaves the override reading `name_part`, which the token path # now passes as the group's own text (Derek, 2026-09-14 -- #528 # passed "" here originally), so a widen-only override takes the # STRING path instead of raising or going silent: it gets the # PRE-#528 answer, computed from the vocabulary fallback rather # than the parse. "john e smith" is where the two views disagree # -- the parse TAGS the middle "e" an initial # (test_process_initial_with_tokens_reads_the_parse); rules/P8's # one-case fork reads it from the vocabulary, not from its shape, # which is exactly why the bare-word vocabulary fallback below # reads it the other way -- "e" is not initial-SHAPED by any # pattern over the bare word, so a fallback with no parse behind it # reads lowercase "e" as the # without #528's fix. Accepting AND FORWARDING `tokens` is still # the only way to receive the fix. decisions/R3's 2026-09-13 # entry (amended 2026-09-14) and the 2.4.0 release note both # state this. class Forwards(HumanName): def _process_initial(self, name_part: str, firstname: bool = False, tokens: tuple[Token, ...] | None = None) -> str: return super()._process_initial(name_part, firstname, tokens=tokens) class WidensOnly(HumanName): # The recorded negative control. `**kwargs` is the spelling a # reader reaches for first, and mypy rejects it here -- worth # noting, since a typed caller is warned and an untyped one is # not, which is who this control is written for. def _process_initial(self, name_part: str, # type: ignore[override] firstname: bool = False, **kwargs: object) -> str: return super()._process_initial(name_part, firstname) assert HumanName("john e smith").initials() == "j. e. s." assert Forwards("john e smith").initials() == "j. e. s." assert WidensOnly("john e smith").initials() == "j. s." # The multi-token-group pin: "Ph." + "D." is a "joined" continuation # (_list_tokens_for), the only producer of a group with more than one # token, so it is the one place the join's SHAPE -- space-separated, # both words -- is observable at all. A join without the separator, # or with only one token, gives "J. P." / "J. D." and passes every # single-token name; this is what actually exercises `zip(words, # conjunctions, strict=True)` over more than one element per group. # Measured 2026-09-14. assert HumanName("Ph. D., John").initials() == "J. P D." assert Forwards("Ph. D., John").initials() == "J. P D." assert WidensOnly("Ph. D., John").initials() == "J. P D." assert (WidensOnly("Ph. D., John", initials_separator="-").initials() == "J. P-D.") def test_initials_honor_an_overridden_list_property() -> None: # Found in review: before #528, _initials_lists read # self.first_list/middle_list/last_list -- public properties a v1 # subclass may override -- and #528 switched it to the private # token walk (_list_tokens_for) directly, which does not consult # such an override. last_base still honors it through _split_last, # which reads self.last_list; surnames is middle_list + last_list # and given_names is first_list + middle_list, so both read the # properties directly. initials() alone went silently stale. The fix detects an # overridden property per member (a cheap class-attribute identity # check) and, for that member only, takes the pre-#528 STRING path # over the override's own strings -- same degradation a # WidensOnly-style _process_initial override gets, and the same # pre-#528 answer. Measured 2026-09-14 against `git show # 338daf7:nameparser/_facade.py` (before #528): both values below # are that package's answer for the same construction. class Sub(HumanName): @property def first_list(self) -> list[str]: return [p.upper() for p in super().first_list] @property def middle_list(self) -> list[str]: return [p for p in super().middle_list if not p.startswith("X")] @property def last_list(self) -> list[str]: return ["Zorro"] sub = Sub("john Xavier smith") assert sub.initials() == "J. Z." # last_base/surnames already honored the override before this fix; # pinned here so a future change can't silently regress it back # into agreement with initials() for the wrong reason. assert sub.last_base == "Zorro" assert sub.surnames == "Zorro" class SubLastOnly(HumanName): @property def last_list(self) -> list[str]: return ["Zorro"] # Only ONE property overridden: first/middle are un-overridden and # must keep the (post-#528) TOKEN path -- the middle "e" is "e." # because the parse tagged it an initial, not the connective -- and # only last takes the string path and the override's "Zorro". assert SubLastOnly("john e smith").initials() == "j. e. Z." def test_a_group_of_particles_and_a_connective_drops_only_unparsed( ) -> None: # `_process_initial`'s closing comment, pinned (#397 review). The # "group yields nothing for a reason other than being wholly # particles" branch is the one #461 emptied on the PARSE path: the # middle here is a working particle plus a connective with nothing # to join, so the connective is readmitted and initials. Measured # 2026-09-20 over every corpus and case text plus the review's # generated grid, 95,119 names: zero parsed groups reach the drop. assert HumanName("Vega, Santa de y").initials() == "S. y. V." # and the paths with no parse to read, where a connective is # answered from the vocabulary and carries no mark, which keep the # pre-#461 answer and so keep the branch alive built = HumanName(first="Santa", middle="de y", last="Vega") assert built.initials() == "S. V." class Sub(HumanName): @property def middle_list(self) -> list[str]: return ["de y"] assert Sub("Vega, Santa de y").initials() == "S. V." def test_initials_of_a_spliced_field_ask_the_vocabulary() -> None: # A field assigned after the parse is raw text: ParsedName.replace() # stamps UNCLASSIFIED_TAG on it, which says the words were read by # nothing rather than read and found plain. There is no tag to # honor, so this view falls back to the vocabulary for the one # question a word can answer alone -- the same fallback and the # same helper as rules/R4's case repair. The lowercase spelling # reads as the connective and drops; the capital is initial-shaped # and stays, keeping the letter's case as every initial does. lower = HumanName("john smith") lower.middle = "e" assert lower.initials() == "j. s." upper = HumanName("john smith") upper.middle = "E" assert upper.initials() == "j. E. s." def test_initials_freeze_the_connective_answer_at_parse_time() -> None: # The accepted cost of #528 (Derek, 2026-09-13): for a word backed # by a token, "is this the connective" is decided when the name is # parsed, exactly as capitalize()'s answer already was. A # vocabulary edit after the parse therefore takes effect on the # next full_name assignment, not on the next initials() call. # A local Constants, never CONSTANTS: the shared singleton would # leak the removal into every later test in the process. # #461 moved the VALUE on 'juan y garcia' and with it this name's # ability to WITNESS the freeze: its 'y' holds the middle part # alone, so it initials as a marked connective and would initial # again as a plain name word, and all three readings below are now # the same string (rules/R3, decisions/R3). The name is kept # for its moved value and for the capitalize() precedent; the # freeze itself is re-pinned under it on a name where the # connective is JOINING, which is where a vocabulary edit still # changes the answer. constants = Constants() name = HumanName("juan y garcia", constants=constants) assert name.initials() == "j. y. g." constants.conjunctions.remove("y") assert name.initials() == "j. y. g." # frozen at parse time # capitalize() has behaved this way all along, which is the # precedent this cost was accepted on name.capitalize() assert str(name) == "Juan y Garcia" name.full_name = "juan y garcia" # re-parse applies it assert name.initials() == "j. y. g." joined = Constants() joining = HumanName("juan garcia y lopez", constants=joined) assert joining.initials() == "j. g. l." joined.conjunctions.remove("y") assert joining.initials() == "j. g. l." # frozen at parse time joining.full_name = "juan garcia y lopez" # re-parse applies it assert joining.initials() == "j. g. y. l." def test_initials_of_an_unpickled_or_copied_name_ask_the_vocabulary_too() -> None: # __setstate__ is the second producer of UNCLASSIFIED_TAG tokens: a # v1 pickle carries the *_list STRINGS and no tags, so a restored # name is spliced text throughout and takes the fallback above. # That makes it disagree with a live parse of the same string on # the two names rules/P8's one-case fork moved -- which # capitalize() has done since the tag was introduced, for the same # reason and through the same helper. Pinned rather than left to # prose; decisions/R3 records it. # # copy.copy and copy.deepcopy go through the same __getstate__/ # __setstate__ hooks as pickle -- HumanName's own pair, defined # right here in nameparser/_facade.py (not nameparser/_types.py's # guarded pair, which belongs to a different set of classes) -- so # a copied name takes the identical vocabulary fallback -- measured, # not assumed. # # The keyword constructor is the third no-parse path: a name built # from its fields never ran the full-string parse, so its tokens # carry the same mark and take the same fallback. Rebuilt here from # the live parse's own fields, so the strings are identical and # only the missing parse explains the difference. # 'JUAN Y GARCIA' no longer contrasts on INITIALS: #461 gave the # live parse the restored answer, both views now saying 'J. Y. G.' # because the 'Y' holds its part alone (rules/R3, # decisions/R3). Kept for its CAPITALIZE half, which still # differs, and replaced on the initials side by # 'JUAN GARCIA Y LOPEZ', where the letter IS joining and the live # parse still drops it. 'john e smith' is untouched throughout. for name, live_initials, restored_initials, live_cap, restored_cap in ( ("JUAN Y GARCIA", "J. Y. G.", "J. Y. G.", "Juan y Garcia", "Juan Y Garcia"), ("JUAN GARCIA Y LOPEZ", "J. G. L.", "J. G. Y. L.", "Juan Garcia y Lopez", "Juan Garcia Y Lopez"), ("john e smith", "j. e. s.", "j. s.", "John E Smith", "John e Smith")): assert HumanName(name).initials() == live_initials deep = copy.deepcopy(HumanName(name)) assert deep.initials() == restored_initials shallow = copy.copy(HumanName(name)) assert shallow.initials() == restored_initials restored = pickle.loads(pickle.dumps(HumanName(name))) assert restored.initials() == restored_initials live = HumanName(name) built = HumanName(first=live.first, middle=live.middle, last=live.last) assert built.initials() == restored_initials live.capitalize() assert str(live) == live_cap restored.capitalize() assert str(restored) == restored_cap built.capitalize() assert str(built) == restored_cap def test_the_v1_off_switch_unjoins_the_link_only() -> None: """decisions/P3's off switch on the V1 SURFACE (#397 second review). The core-side switch is pinned by the off-switch grid in tests/v2/test_properties.py and this is the facade half, which has no `Lexicon` of its own to remove from. The switch moves the Catalan join and leaves the three one-case role movers of decisions/P3's paragraph alone: with `i` out of the conjunctions it is a one-letter roman numeral, the initial its capital is (rules/S10), so each reads as with the switch on. A local `Constants`, never the shared `CONSTANTS`: a removal on the singleton would leak into every later test in the process. """ constants = Constants() constants.conjunctions.remove("i") for text, fields in ( ("rovira, i", {"first": "i", "last": "rovira", "suffix": ""}), ("john smith i jr", {"first": "john", "middle": "smith", "last": "i", "suffix": "jr"}), ("maier, amy i, jr.", {"first": "amy", "middle": "i", "last": "maier", "suffix": "jr."})): for name in (HumanName(text), HumanName(text, constants)): for field, value in fields.items(): assert getattr(name, field) == value, (text, field) # and the join itself, the reading the bullet leads with joined = HumanName("Josep Carod i Rovira") assert joined.last == "Carod i Rovira" unjoined = HumanName("Josep Carod i Rovira", constants) assert unjoined.middle == "Carod i" assert unjoined.last == "Rovira" def test_the_ph_d_pair_merges_whatever_the_vocabulary_says() -> None: # v1's fix_phd merged 'Ph. D.' by its spelling alone, so a config # without 'phd' still read the pair as one suffix; the split # credential's vocabulary pre-test must not stand in front of that # answer (#752's review) c = Constants() c.suffix_acronyms.remove("phd") assert HumanName("John Smith Ph. D.", constants=c).suffix == "Ph. D." h = HumanName("John Smith, Ph. D.", constants=c) assert (h.first, h.last, h.suffix) == ("John", "Smith", "Ph. D.") ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_facade_cases.py�������������������������������������0000664�0000000�0000000�00000042660�15273032717�0024402�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Facade runner (mechanisms.md#FACADE-CONTRACT): the shared case table asserted through HumanName. Deleted wholesale in 3.0 with the facade.""" import dataclasses import pytest from nameparser import Policy from nameparser._config_shim import Constants from nameparser._facade import HumanName from .cases import CASES, Case _V1_KEY = {"given": "first", "family": "last"} # identity for the rest #: The parenthesis maiden shape. A row routing parens to maiden does #: NOT carry the default nickname set: Policy's maiden-wins #: canonicalization subtracts the pair from nickname_delimiters at #: CONSTRUCTION, so the shape to match is the default MINUS the pair. #: Comparing against the unsubtracted default is what kept this gate #: false for every row until #329. #: #: The v1 side has two spellings and only one of them is equivalent. #: ADDING parenthesis to the maiden bucket leaves the nickname bucket #: holding it too, and v1 gives a shared pair to nickname -- measured on #: "Baker (Johnson), Jenny", that spelling yields nickname Johnson and #: an empty maiden, the reverse of what maiden_delimiters_win_when_shared #: asserts. MOVING it (pop from nickname, assign to maiden) yields #: maiden Johnson and an empty nickname, matching. That is the idiom #: tests/test_nicknames.py already uses, and the one below. #: #: Which rows this leaves skipping is not described here but asserted, #: by id, in test_core_only_rows_are_the_declared_ones below. _MAIDEN_PARENS = frozenset({("(", ")")}) #: Policy fields _constants_for does not translate. A row moving one #: off its default SKIPS: the facade would otherwise run it under the #: field's inherited default and pass or fail on a knob the row never #: asked for. Some have no v1 spelling at all -- script_orders and #: segment_scripts, the v1 surface being frozen -- and the rest have #: none this runner has ever needed to write. _UNTRANSLATED = frozenset({ "name_order", "script_orders", "segment_scripts", "lenient_comma_suffixes", "unlisted_dotted_suffixes", "unlisted_caps_suffixes", "strip_emoji", "strip_bidi", "two_family_names", }) #: Expressible in exactly one shape, the parenthesis bucket move. #: BOTH delimiter fields are listed because the canonicalization that #: routes the pair to maiden is the same step that takes it out of #: nickname, so a row expressible this way necessarily differs from #: the default on both. _BUCKET_MOVE_ONLY = frozenset({ "nickname_delimiters", "maiden_delimiters", }) #: Fields _constants_for writes into the Constants it returns. _TRANSLATED = frozenset({ "patronymic_rules", "middle_as_family", "extra_suffix_delimiters", }) #: The non-locale rows the facade never sees. Held here rather than #: described in prose, because pytest reports a skipped row exactly #: like a row nobody wrote: reverting the _MAIDEN_PARENS shape above #: to the unsubtracted default pushes every parenthesis-maiden row #: into this set and turns nothing red. _CORE_ONLY_IDS = frozenset({ # #754: rules/O6's switch, which v1 has no Constants spelling for "two_family_names_counts_a_fullwidth_hyphen_as_a_hyphen", "two_family_names_takes_run_together_initials", "two_family_names_takes_no_number", "two_family_names_leaves_a_hyphenated_given_name_before_the_family", "two_family_names_leaves_an_initial_before_the_family", "two_family_names_leaves_a_bound_given_pair_before_the_family", "two_family_names_counts_an_en_dash_as_a_hyphen", "two_family_names_takes_no_spaced_dash", "two_family_names_leaves_a_name_its_script_ordered", "two_family_names_leaves_an_empty_head_comma_alone", "two_family_names_reads_a_patronymic_family_first", "two_family_names_takes_the_word_before_the_family", "two_family_names_leaves_the_middles_before_it", "two_family_names_counts_a_particle_with_its_word", "two_family_names_keeps_a_family_already_two_words_after_a_particle", "two_family_names_counts_a_connective_join_as_its_words", "two_family_names_counts_a_hyphenated_family_as_its_words", "two_family_names_counts_a_trailing_suffix_as_no_name_word", "two_family_names_counts_a_title_as_no_name_word", "two_family_names_reads_the_name_before_a_suffix_comma", "two_family_names_leaves_a_family_comma_alone", "two_family_names_leaves_a_two_word_name_alone", "two_family_names_takes_the_first_two_words_family_first", "two_family_names_takes_the_first_two_words_family_first_given_last", "two_family_names_runs_before_middle_as_family", "two_family_names_counts_a_nickname_as_no_name_word", "two_family_names_takes_a_word_before_a_particle_family", "two_family_names_takes_the_word_after_a_family_first_fold", "two_family_names_moves_a_particle_chain_whole_or_not_at_all", "two_family_names_keeps_the_particle_p6_attached", "two_family_names_stands_down_where_a_rotation_reordered_the_name", "two_family_names_reads_a_compound_given_name_as_a_family", # #864: the dotted switch turned off, which the v1 shim does not # translate (decisions/S9) "paired_initials_under_the_switch_off_read_the_same", # #789: a given name written short under a declared family-first # order, which v1 cannot express "chas_dotted_under_family_first_is_placed_by_the_order", # #718: S10's count under a declared family-first order, which v1 # cannot express "a_lone_numeral_counts_in_units_under_family_first", # #701: H6's peel under a declared family-first order, which v1 # cannot express "a_title_before_a_family_comma_reads_the_same_family_first", "a_title_before_a_family_comma_reads_the_same_given_last", # #296's bundle: the positional read of the pre-comma name under a # declared family-first order -- v1 has no name order to declare "family_comma_no_name_word_family_first", # #791: the family-first roles of the si rows, which v1 cannot express "family_first_bare_si_is_a_given_name", "family_first_comma_si_is_a_given_name", # #687: a declared Vietnamese order, which v1 cannot express "trailing_given_name_mai_is_not_a_credential", # #700: P6's family-first site for the elided particle, under a # declared order v1 has no spelling for "an_elided_particle_ending_a_family_first_name", # #892: P6's attachment behind an empty head under a declared # family-first order, which v1 cannot express "empty_head_attaches_the_particle_family_first", "family_comma_no_name_word_family_first_given_last", "family_comma_no_name_word_family_first_plain", "leading_never_given_particle_two_leftovers_family_first", "leading_never_given_particle_two_leftovers_family_first_given_last", "maiden_marker_delimited_beside_a_nickname_clause", "maiden_marker_stops_the_leading_run_family_first", "maiden_marker_stops_the_leading_run_family_first_given_last", "maiden_marker_kyusei_delimited", "ko_honorific_period_under_strict_comma_suffixes", "ja_honorific_glued_family_comma_strict_knob", # the strict knob has no v1 spelling, so the row that pins C14's # initial veto surviving #430 is core-only "family_comma_strict_keeps_the_initial_veto", "ja_honorific_glued_family_comma_credential_pair_strict_knob", # #449: O5's convention under a DECLARED family-first order, which # is the half of the rule v1 has no way to say -- the given-first # half ("Andrew") is an ordinary row and runs here. The Han row # beside it declares the same order to pin W4 AUTHORING the # reading, and is core-only for the same reason. "lone_name_word_reports_the_convention_family_first", "han_unspaced_family_first_declared_reports_nothing", # #491: H4's report under a DECLARED family-first order, the half # that proves the report is order-independent -- the order has no # v1 spelling, so the row is core-only. The default-order twin # ("Lord Chancellor") is an ordinary row and runs here. "all_titles_input_family_first", # #606: the excluded salutation under the order Vietnamese names # use, which has no v1 spelling. "salutation_that_leads_a_surname_stays_a_name", # #626's reviews: P1's family-first fold and O3's middle_as_family # fold moving a reported word, under a declared order v1 has no # spelling for "a_pick_the_family_first_fold_moves_is_reported_where_it_lands", "a_pick_the_middle_as_family_fold_moves_is_reported_in_the_family", # #518 review round: the `by_script` scope's positive controls. # An emptied script table has no v1 spelling -- v1 has no script # orders to empty -- so both rows are core-only, though the roles # they assert are 1.4.0's for exactly that reason. "han_unspaced_no_script_orders_reports_the_convention", "kana_honorific_no_script_orders_reports_the_convention", # #516's all-caps half: unlisted_caps_suffixes has no v1 spelling # (v1 has no by-shape credential class at all), so every row that # sets it is core-only. "caps_surname_is_swallowed_with_the_switch_on", "caps_surname_reports_but_does_not_move_at_two_words", "unlisted_caps_reads_by_position_with_the_switch_on", "the_caps_comma_count_needs_two_name_words", "the_caps_comma_count_declines_at_one_word", "one_case_input_never_reaches_the_caps_switch", "suffix_vocabulary_never_reaches_the_caps_switch", "the_caps_comma_count_reaches_a_multi_word_run", "the_caps_comma_multi_word_run_declines_at_one_word", # #564: CapsSuffixes.OFF has no v1 spelling either; the facade # reads the field's default, AFTER_COMMA, like the core. "the_comma_caps_reading_can_be_switched_off", # #516 review round: the F1/F1b/F2/F5 regression-guard rows, all # under the same non-default policy. "caps_switch_does_not_silence_the_listed_lean", "caps_switch_does_not_silence_the_comma_lean", "caps_switch_does_not_claim_a_capitalized_particle", "caps_switch_does_not_claim_a_capitalized_particle_phrase", "caps_switch_does_not_move_a_roman_numeral", "caps_switch_does_not_move_a_roman_numeral_with_words_to_spare", "caps_switch_does_not_move_a_title_floor_control", "caps_switch_does_not_reach_delimited_content", "caps_switch_run_test_declines_a_pure_listed_run", "caps_switch_does_not_claim_a_one_case_maiden_marker", "caps_switch_does_not_claim_a_mixed_case_maiden_marker", # The 2026-09-18 review round's negative control for the # tail-segment class test: `unlisted_dotted_suffixes` has no v1 # spelling either (v1 reads no token by shape), so the row that # turns it OFF is core-only, exactly as the caps rows above are. # Its default-policy twin is an ordinary row and runs here. "the_by_shape_tail_segment_is_flagged_with_the_switch_off", # The same round's unpinned-branch rows, every one under a policy # with no v1 spelling: the caps switch, and -- for the two strict # comma rows -- `lenient_comma_suffixes`, which the block above # already names two rows for. "caps_switch_reads_the_name_level_case_past_a_clause", "caps_one_case_comma_declines_a_single_token", "caps_one_case_comma_declines_a_run", "caps_run_needs_every_token_not_any", "caps_run_declines_a_bound_given_head", "caps_run_declines_a_conjunction", "caps_switch_leaves_a_capitalized_title_a_title", "strict_comma_reads_the_dotted_numeral_as_a_name_word", "strict_comma_reads_the_bare_numeral_into_the_run", "strict_comma_reads_a_one_case_numeral_as_its_capital", "strict_comma_reports_a_one_case_numeral_in_a_third_part", "the_comma_flip_is_read_under_the_declared_order", "two_caps_credentials_peel_as_a_run", "a_shape_admitted_numeral_keeps_its_run_under_the_caps_switch", "the_caps_shape_is_script_agnostic_cyrillic", "the_caps_shape_is_script_agnostic_accented", "the_dotted_switch_off_still_reports_the_post_comma_fork", "the_dotted_switch_off_leaves_a_lone_token_to_the_convention", "the_dotted_switch_off_leaves_a_titled_token_a_name", # The 2026-09-18 verification round's two unpinned-branch rows, # both under a policy with no v1 spelling: the caps switch again, # and `unlisted_dotted_suffixes` turned OFF, which is the only # policy that makes the chain emitter's by-shape tag reachable # (at the default, classify writes both tags and the listed one # answers first). "the_caps_shape_never_reaches_a_tail_segment", "the_chain_reports_the_by_shape_half_too", # #531: the given part's trailing slot. Five rows, and all five are # core-only for a field-by-field reason rather than a blanket one # -- `unlisted_dotted_suffixes`, `unlisted_caps_suffixes`, # `lenient_comma_suffixes` and `name_order` are each in # _UNTRANSLATED above, the first three having no v1 Constants # manager at all and the fourth having no v1 spelling. The # DEFAULT-policy rows of the same change are ordinary rows and run # here, which is what pins the facade parity that matters: # HumanName("Doe, John MA") gives first John, last Doe, suffix MA. "the_dotted_slot_reports_with_the_switch_off", "the_caps_switch_reaches_the_trailing_slot", "the_trailing_slot_ignores_the_strict_comma_knob", "the_trailing_slot_reads_the_same_under_family_first", "the_trailing_slot_reads_the_same_under_ff_given_last", # #533: the maiden clause's trailing credential. Five policy rows, # each core-only for a field-by-field reason rather than a blanket # one -- `unlisted_dotted_suffixes`, `unlisted_caps_suffixes`, # `lenient_comma_suffixes` and `name_order` are each already in # _UNTRANSLATED above, the first three having no v1 Constants # manager at all and the fourth no v1 spelling. The DEFAULT-policy # rows of the same change are ordinary rows and run here, which is # what pins the facade parity that matters: HumanName("Jane Doe # nee Smith MA") gives first Jane, last Doe, suffix MA. "the_dotted_member_is_kept_with_the_switch_off_and_reports", "the_caps_switch_reaches_the_clause", "the_clause_reads_the_same_under_the_strict_comma_knob", "the_clause_reads_the_same_under_family_first", "the_clause_reads_the_same_under_ff_given_last", }) def _constants_for(case: Case) -> Constants | None: """Translate the row's Policy to a Constants, or None if the policy has no v1 spelling (those rows are core-only).""" policy = case.policy or Policy() default = Policy() c = Constants() maiden_via_bucket_move = ( policy.maiden_delimiters == _MAIDEN_PARENS and policy.nickname_delimiters == default.nickname_delimiters - _MAIDEN_PARENS ) # field by field, off the dataclass rather than by hand: a field # named in no table above would be admitted here and then run # under the facade's default for it (see # test_every_policy_field_is_translated_or_skipped). moved = {f.name for f in dataclasses.fields(Policy) if getattr(policy, f.name) != getattr(default, f.name)} if moved & _UNTRANSLATED or (moved & _BUCKET_MOVE_ONLY and not maiden_via_bucket_move): return None if policy.patronymic_rules: c.patronymic_name_order = True if policy.middle_as_family: c.middle_name_as_last = True if policy.extra_suffix_delimiters: c.suffix_delimiter = next(iter(policy.extra_suffix_delimiters)) if maiden_via_bucket_move: # pop, not assign: leaving parenthesis in the nickname bucket # would give v1's shared-pair reading, which goes to nickname. c.maiden_delimiters["parenthesis"] = c.nickname_delimiters.pop( "parenthesis") return c def test_every_policy_field_is_translated_or_skipped() -> None: # a Policy field in none of the three tables is neither rejected # nor written into the Constants, so a row setting it is ADMITTED # and then asserted under the facade's default value for that # field. segment_scripts sat in exactly that state until # 2026-08-03, invisibly, because no row set it. Same job # test_policy_patch_mirrors_policy_field_names does one file over. assert (_UNTRANSLATED | _BUCKET_MOVE_ONLY | _TRANSLATED == {f.name for f in dataclasses.fields(Policy)}) def test_core_only_rows_are_the_declared_ones() -> None: # the gate that decides this is one comparison away from claiming # no policy is expressible (see _MAIDEN_PARENS), and every row it # wrongly rejects leaves the suite green -- a skip reads as a pass. assert {case.id for case in CASES if case.locale is None and _constants_for(case) is None} == _CORE_ONLY_IDS @pytest.mark.parametrize("case", CASES, ids=lambda c: c.id) def test_facade_case(case: Case) -> None: if case.locale is not None: # v1 Constants' patronymic bool enables BOTH the East Slavic and # Turkic rules at once, so it cannot express a single-rule pack # faithfully -- these rows are core-only (proven via parser_for # in test_cases.py instead). pytest.skip("locale rows are core-only") constants = _constants_for(case) if constants is None: pytest.skip("policy not expressible through v1 Constants") name = HumanName(case.text, constants=constants) for field, expected in case.expect.items(): assert getattr(name, _V1_KEY.get(field, field)) == expected, ( f"{case.id}: {field}") for field in {"title", "given", "middle", "family", "suffix", "nickname", "maiden"} - set(case.expect): assert getattr(name, _V1_KEY.get(field, field)) == "", ( f"{case.id}: {field} expected empty") ��������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_gnd_corpus.py���������������������������������������0000664�0000000�0000000�00000033235�15273032717�0024162�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Offline checks on tools/differential/build_gnd_corpus.py. The build script is the only part of the GND corpus that touches the network, so the renderings it writes from a lobid record are pinned here on hand-written records in lobid's shape, its sampling on a stubbed search, and the committed files on the invariants the build promises. tools/ is not a package, so the module is loaded by path, as _differential_fixtures.py loads compare.py. """ import json from collections import Counter from collections.abc import Callable from pathlib import Path from typing import Any import pytest from ._differential_fixtures import load_tool, serve build = load_tool("build_gnd_corpus") agreement = load_tool("cldr_agreement") _PLAIN = build.Stratum("plain", "q", 1) _FAMILY_FIRST = build.Stratum("cjk", "q", 1, family_first=True) def _record(written: str, gnd: str = "118540238", **entity: str) -> dict[str, Any]: return {"gndIdentifier": gnd, "preferredName": written, "preferredNameEntityForThePerson": {k: [v] for k, v in entity.items()}} def _texts(rows: list[dict[str, Any]]) -> list[tuple[str, str, bool]]: return [(row["order"], row["text"], row["composed"]) for row in rows] # -- renderings --------------------------------------------------------------- def test_a_surname_record_gives_its_index_form_and_a_composed_one() -> None: rows = build.renderings(_record("Gogh, Vincent van", forename="Vincent", prefix="van", surname="Gogh"), _PLAIN) assert _texts(rows) == [("sorting", "Gogh, Vincent van", False), ("givenFirst", "Vincent van Gogh", True)] assert rows[0]["fields"] == {"given": "Vincent", "surname-prefix": "van", "surname-core": "Gogh"} @pytest.mark.parametrize(("written", "entity"), [ ("Zuylen van Nijevelt, Julius van", {"forename": "Julius", "prefix": "van", "surname": "Zuylen van Nijevelt"}), ("Croix, Edmond de La", {"forename": "Edmond", "prefix": "de La", "surname": "Croix"}), ]) def test_the_split_is_compared_only_before_a_one_word_core( written: str, entity: dict[str, str]) -> None: # A core of several words may hold an unstated particle (the second # `van`), so its family is stated whole; a one-word core keeps the split. index = build.renderings(_record(written, **entity), _PLAIN)[0] split = " " not in entity["surname"] assert ("surname-core" in index["fields"]) is split assert ("family_particles" in agreement.expected_roles(index["fields"], written)) is split def test_a_surname_with_no_prefix_is_stated_whole() -> None: # GND keeps a particle inside a surname (`Vélez de Guevara`), so a # missing prefix is not a statement that there is none. As # surname-core it would expect family_particles '' and record the # parser's `de` as a disagreement no fix could clear. rows = build.renderings(_record("Vélez de Guevara, Luis", forename="Luis", surname="Vélez de Guevara"), _PLAIN) for row in rows: assert row["fields"] == {"given": "Luis", "surname": "Vélez de Guevara"} assert "family_particles" not in agreement.expected_roles(row["fields"], row["text"]) @pytest.mark.parametrize("mark", build._GLUED) def test_a_glued_particle_is_stated_whole_where_it_is_glued(mark: str) -> None: # `d'` and `Arenberg` are not words of `d'Arenberg`: stated apart, the # comparison would expect an empty family no parser fix could match. index, composed = build.renderings( _record(f"Arenberg, Auguste d{mark}", forename="Auguste", prefix=f"d{mark}", surname="Arenberg"), _PLAIN) assert composed["text"] == f"Auguste d{mark}Arenberg" assert composed["fields"] == {"given": "Auguste", "surname": f"d{mark}Arenberg"} assert index["fields"]["surname-prefix"] == f"d{mark}" def test_a_family_first_area_keeps_the_index_form_only() -> None: rows = build.renderings(_record("Zhang, Tingyu", forename="Tingyu", surname="Zhang"), _FAMILY_FIRST) assert _texts(rows) == [("sorting", "Zhang, Tingyu", False)] def test_a_personal_name_expects_no_family_name() -> None: [row] = build.renderings(_record("Björk", personalName="Björk"), _PLAIN) assert row["fields"] == {"given": "Björk"} assert agreement.expected_roles(row["fields"], row["text"])["family"] == "" def test_a_regnal_name_is_composed_without_its_name_addition() -> None: record = _record("Johann II., Pfalz-Zweibrücken, Pfalzgraf", personalName="Johann", counting="II.", nameAddition="Pfalz-Zweibrücken, Pfalzgraf") [row] = build.renderings(record, _PLAIN) assert (row["text"], row["composed"]) == ("Johann II.", True) assert row["fields"] == {"given": "Johann", "generation": "II."} def test_a_field_value_is_read_without_its_edge_spaces() -> None: [index, _] = build.renderings(_record("Gogh, Vincent", forename=" Vincent ", surname="Gogh"), _PLAIN) assert index["fields"]["given"] == "Vincent" def _multi(written: str, **entity: list[str]) -> dict[str, Any]: return {"gndIdentifier": "1", "preferredName": written, "preferredNameEntityForThePerson": entity} @pytest.mark.parametrize("record", [ # Grouped by the guard each row is for; the comment names it. # a descriptor the fields cannot place _record("Eutychios, von Alexandria", personalName="Eutychios", nameAddition="von Alexandria"), # a nameAddition on a surname record whose index form still matches _record("King, Martin", forename="Martin", surname="King", nameAddition="Jr."), # a preferred name its fields do not spell _record("Gogh, Vincent", forename="Vincent", prefix="van", surname="Gogh"), _record("Amha Selassie", personalName="Amha Selassie I."), _record("Something else", personalName="Ludwig", counting="II."), # a personalName beside surname fields _record("Musabbiḥī al-", personalName="Musabbiḥī", prefix="al-"), _record("Ludwig II.", personalName="Ludwig", counting="II.", prefix="von", surname="Bayern"), # a comma inside a field, where the written name agrees _record("A, B", personalName="A, B"), _record("A, B, C", surname="A, B", forename="C"), # a surname with no forename _record("Gogh", surname="Gogh"), # a field this script does not know _record("Björk", personalName="Björk", epithet="the Great"), # two values for one field: the plain direct name would lose its number _multi("Johann", personalName=["Johann"], counting=["II.", "III."]), _multi("A, B", surname=["A", "C"], forename=["B"]), ]) def test_a_record_its_fields_cannot_account_for_is_left_out( record: dict[str, Any]) -> None: assert build.renderings(record, _PLAIN) == [] def test_the_corpus_holds_each_text_once() -> None: rows = [{"text": "Björk", "shape": None}, {"text": "Björk", "shape": None}, {"text": "Abe, Kōbō", "shape": None}] assert build.corpus_lines(rows) == ['"Abe, Kōbō"', '"Björk"'] def test_a_duplicate_rendering_is_refused() -> None: row = {"file": "1", "label": "x", "order": "sorting", "text": "A, B"} with pytest.raises(ValueError, match="duplicate rendering 1/x/sorting"): build.fields_lines([row, dict(row)]) def test_every_stratum_has_a_distinct_label() -> None: labels = [stratum.label for stratum in build.STRATA] assert len(labels) == len(set(labels)) # -- sampling: pool() and build() on a stubbed search -------------------------- def _stub_search(totals: dict[str, int], members: dict[str, list[dict[str, Any]]], calls: list[tuple[str, int, int]] ) -> Callable[[str, int, int, bool], dict[str, Any]]: """A _search answering by the query's id-slice suffix ('' for none).""" def search(query: str, size: int, start: int, refresh: bool) -> dict[str, Any]: suffix = query.rsplit("gndIdentifier:", 1)[1] if "gndIdentifier:" in query else "" calls.append((suffix, size, start)) return {"totalItems": totals[suffix], "member": members.get(suffix, [])[start:start + size]} return search def _people(n: int) -> list[dict[str, Any]]: return [_record(f"P{i}", gnd=str(i), personalName=f"P{i}") for i in range(n)] def test_pool_takes_the_widest_slice_that_fits_and_pages_it_whole( monkeypatch: pytest.MonkeyPatch) -> None: calls: list[tuple[str, int, int]] = [] totals = {"": build.POOL + 1, "*7": build.POOL, "*07": 3, "*007": 1} monkeypatch.setattr(build, "_search", _stub_search( totals, {"*7": _people(build.POOL)}, calls)) records = build.pool(_PLAIN, refresh=False) assert len(records) == build.POOL # total == POOL fits pages = [start for suffix, size, start in calls if size == build.PAGE] assert pages == list(range(0, build.POOL, build.PAGE)) assert {suffix for suffix, _, _ in calls} == {"", "*7"} def test_pool_applies_the_stratum_predicate(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(build, "_search", _stub_search({"": 4}, {"": _people(4)}, [])) odd = build.Stratum("odd", "q", 1, keep=lambda r: int(r["gndIdentifier"]) % 2 == 1) assert [r["gndIdentifier"] for r in build.pool(odd, False)] == ["1", "3"] @pytest.mark.parametrize(("totals", "members", "message"), [ ({"": 0}, {}, "matches nothing"), ({"": 5}, {"": _people(3)}, "paged for a total of 5"), ({"": 2}, {"": [_people(1)[0], _people(1)[0]]}, r"\(1 distinct\)"), ({s: build.POOL + 1 for s in build.SLICES}, {}, "add a slice"), ]) def test_pool_refuses_a_stratum_it_cannot_trust( monkeypatch: pytest.MonkeyPatch, totals: dict[str, int], members: dict[str, list[dict[str, Any]]], message: str) -> None: monkeypatch.setattr(build, "_search", _stub_search(totals, members, [])) with pytest.raises(ValueError, match=message): build.pool(_PLAIN, refresh=False) def test_build_takes_records_once_in_hash_order_up_to_the_quota( monkeypatch: pytest.MonkeyPatch) -> None: people = _people(6) # A refused record that sorts FIRST, so it is met before the quota fills. gnd = min((str(i) for i in range(100, 200)), key=lambda g: build._order({"gndIdentifier": g})) refused = _record("Wrong", gnd=gnd, personalName="Right") assert build._order(refused) < min(build._order(p) for p in people) first = build.Stratum("first", "a", 2) second = build.Stratum("second", "b", 10, least=1) pools = {"a": [*people, refused], "b": list(people)} monkeypatch.setattr(build, "STRATA", (first, second)) monkeypatch.setattr(build, "pool", lambda stratum, refresh: pools[stratum.query]) rows = build.build(refresh=False) taken = {label: [r["file"] for r in rows if r["label"] == label] for label in ("first", "second")} by_hash = [p["gndIdentifier"] for p in sorted(people, key=build._order)] # taken in hash order, not input order; the refused record costs no quota assert taken["first"] == by_hash[:2] # an id the first stratum took is not taken again assert taken["second"] == by_hash[2:] def test_build_stops_before_writing_when_a_stratum_falls_short( monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(build, "STRATA", (build.Stratum("thin", "a", 5, least=3),)) monkeypatch.setattr(build, "pool", lambda stratum, refresh: _people(2)) with pytest.raises(ValueError, match="thin 2/3"): build.build(refresh=False) # -- the cache ---------------------------------------------------------------- _GOOD = json.dumps({"totalItems": 0, "member": []}).encode() def test_a_cached_response_is_read_without_fetching( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: fetched = serve(monkeypatch, tmp_path, _GOOD) build._get_json({"q": "x"}, refresh=False) build._get_json({"q": "x"}, refresh=False) assert len(fetched) == 1 build._get_json({"q": "x"}, refresh=True) assert len(fetched) == 2 assert not list((tmp_path / "gnd").glob("*.part")) def test_a_response_that_is_not_a_search_result_is_not_cached( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: serve(monkeypatch, tmp_path, b'{"error": "rate limited"}') with pytest.raises(ValueError, match="not a lobid search response"): build._get_json({"q": "x"}, refresh=False) assert not list((tmp_path / "gnd").iterdir()) def test_a_damaged_cache_file_is_named( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: serve(monkeypatch, tmp_path, _GOOD) build._get_json({"q": "x"}, refresh=False) [cached] = (tmp_path / "gnd").iterdir() cached.write_text('{"totalItems": 0, "memb', encoding="utf-8") with pytest.raises(ValueError, match=f"{cached.name}.*--refresh"): build._get_json({"q": "x"}, refresh=False) # -- the committed files ------------------------------------------------------- _ROWS = agreement.load_rows(agreement.GND_FIELDS) _STRATA = {stratum.label: stratum for stratum in build.STRATA} def test_each_committed_stratum_holds_what_the_build_promises() -> None: names = {label: {row["file"] for row in _ROWS if row["label"] == label} for label in {row["label"] for row in _ROWS}} assert set(names) == set(_STRATA) for label, ids in names.items(): assert _STRATA[label].floor <= len(ids) <= _STRATA[label].quota, label # one stratum per person: a person in two would be counted twice owners = Counter(len({r["label"] for r in _ROWS if r["file"] == gnd}) for gnd in {row["file"] for row in _ROWS}) assert set(owners) == {1} assert not [row for row in _ROWS if _STRATA[row["label"]].family_first and row["order"] == "givenFirst"] �������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_layering.py�����������������������������������������0000664�0000000�0000000�00000034452�15273032717�0023633�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Enforce the conventions doc's contracts mechanically: import layering, public exports, and the pickle layout guards.""" import ast import pathlib import nameparser import nameparser._pipeline PKG = pathlib.Path(nameparser.__file__).parent # ALLOWED keys whose module must exist on disk -- the exists() skip in # test_layering_contract is for entries that precede their module within # a plan, and without this list a renamed existing module would silently # drop out of enforcement. Later tasks add each stage file as it lands. _MUST_EXIST = {"_types.py", "_lexicon.py", "_policy.py", "_locale.py", "_render.py", "_pipeline/_state.py", "_pipeline/__init__.py", "_pipeline/_extract.py", "_pipeline/_tokenize.py", "_pipeline/_vocab.py", "_pipeline/_script_segment.py", "_pipeline/_segment.py", "_pipeline/_comma.py", "_pipeline/_classify.py", "_pipeline/_group.py", "_pipeline/_pieces.py", "_pipeline/_assign.py", "_pipeline/_post_rules.py", "_pipeline/_assemble.py", "_parser.py", "_facade.py", "_config_shim.py", "locales/__init__.py", "locales/ja.py", "locales/ru.py", "locales/tr_az.py", "locales/zh.py"} _PIPELINE_STAGE_ALLOWED = ( "nameparser._types", "nameparser._lexicon", "nameparser._policy", # stages share _state plus in-package helpers (_vocab and # _pieces); the prefix still forbids _render/_locale/_parser "nameparser._pipeline.", ) # a locale pack: pure data over the base types, no pipeline or config # access (mechanisms.md#LOCALE-PACKS-PURE-DATA) -- one contract # shared by every pack, like # _PIPELINE_STAGE_ALLOWED above, so tightening it is a one-line edit. # _types joined the three in #272: the ja pack's segmenter factory # constructs a Segmentation, and _types is the bottom of the graph # (Locale itself imports it), so this widens the base types, not the # reach. _LOCALE_PACK_ALLOWED = ( "nameparser._lexicon", "nameparser._locale", "nameparser._policy", "nameparser._types", ) # module -> prefixes it may import from within nameparser ALLOWED = { # call-time imports only (inside the rendering-delegate method # bodies); module level stays import-free. TYPE_CHECKING imports # are skipped by _nameparser_imports and need no entry. "_types.py": ("nameparser._render", "nameparser._parser"), # intent: DATA modules only, during 2.x -- mechanically this admits # any config submodule; "data only" holds by convention/review "_lexicon.py": ("nameparser.config.",), "_policy.py": ("nameparser._types",), # _types is a downward import (bottom of the graph): Locale shares # the _guarded_getstate/_guarded_setstate pickle functions "_locale.py": ("nameparser._types", "nameparser._lexicon", "nameparser._policy"), # _lexicon is needed at runtime: capitalized(lexicon=None) resolves # to Lexicon.default() "_render.py": ("nameparser._types", "nameparser._lexicon"), # every stage module: state + the three config/type modules "_pipeline/_state.py": ("nameparser._types", "nameparser._lexicon", "nameparser._policy"), "_pipeline/__init__.py": ("nameparser._pipeline.",), # Tighter than the stage allowance on purpose: _vocab takes # normalized-or-raw text explicitly, no state, with named # departures (its own docstring) -- tag_marker_runs is one, and it # reads WorkToken.role/span and comma_bucket, never a whole # ParseState. Widening this past _state is the tell that a text- # level predicate has grown a dependency on a stage. "_pipeline/_vocab.py": ("nameparser._lexicon", "nameparser._policy", "nameparser._pipeline._state"), "_pipeline/_extract.py": _PIPELINE_STAGE_ALLOWED, "_pipeline/_tokenize.py": _PIPELINE_STAGE_ALLOWED, "_pipeline/_script_segment.py": _PIPELINE_STAGE_ALLOWED, "_pipeline/_segment.py": _PIPELINE_STAGE_ALLOWED, "_pipeline/_classify.py": _PIPELINE_STAGE_ALLOWED, # Piece-level predicates shared by four stages -- segment, # classify, group and assign # (mechanisms.md#ONE-PREDICATE-PER-QUESTION). Segment joined with # #289/#516's own-words span, and "both stages" below was written # when there were two. Tighter than the # stage allowance on purpose: it is a leaf those stages sit on, so # it may read the token type and the vocabulary layer and NOTHING # else -- not even _lexicon, which is the likeliest next reach (a # title predicate wanting _title_key) and so the one this entry # most needs to refuse. Carrying the stage allowance's other # prefixes here would pre-authorise the very widening the entry # exists to make visible. Widening it is the tell that a piece # predicate has grown a dependency on a stage. "_pipeline/_pieces.py": ("nameparser._pipeline._state", "nameparser._pipeline._vocab"), "_pipeline/_group.py": _PIPELINE_STAGE_ALLOWED, # not a stage: C1's decision, which group calls at its head (#613) "_pipeline/_comma.py": _PIPELINE_STAGE_ALLOWED, "_pipeline/_assign.py": _PIPELINE_STAGE_ALLOWED, "_pipeline/_post_rules.py": _PIPELINE_STAGE_ALLOWED, "_pipeline/_assemble.py": _PIPELINE_STAGE_ALLOWED, # Parser sits on everything except _render and the facade "_parser.py": ("nameparser._types", "nameparser._lexicon", "nameparser._policy", "nameparser._locale", "nameparser._pipeline"), # facade layer (mechanisms.md#FACADE-CONTRACT): may import anything # public # plus _render (unused yet) and each other. _facade delegates # parsing to the core Parser resolved from the bound Constants shim. # The bare "nameparser.config" import (not just its submodules) is # deliberate -- it resolves an import cycle, see _facade.py's comment. "_facade.py": ("nameparser._config_shim", "nameparser._lexicon", "nameparser._parser", "nameparser._types", "nameparser._render", "nameparser.util", "nameparser.config"), # the Constants/SetManager/TupleManager shim: config DATA modules # stay the single vocabulary source through 2.x (nameparser.config.*) "_config_shim.py": ("nameparser._lexicon", "nameparser._parser", "nameparser._policy", "nameparser.util", "nameparser.config"), # PEP 562 lazy loader: the only static import is _locale (for the # Locale return type); pack modules load via importlib.import_module # (a runtime string, invisible to the AST walker) -- the # "nameparser.locales." prefix documents that reach for humans even # though the walker never exercises it. "locales/__init__.py": ("nameparser._locale", "nameparser.locales."), "locales/ja.py": _LOCALE_PACK_ALLOWED, "locales/ru.py": _LOCALE_PACK_ALLOWED, "locales/tr_az.py": _LOCALE_PACK_ALLOWED, "locales/zh.py": _LOCALE_PACK_ALLOWED, # v1 import-path preservation: thin re-exports of the facade/shim "parser.py": ("nameparser._facade",), "config/__init__.py": ("nameparser._config_shim",), # CLI: imports only the public package, same as # any other consumer -- no access to internal modules. "__main__.py": ("nameparser",), } def _is_type_checking(test: ast.expr) -> bool: return (isinstance(test, ast.Name) and test.id == "TYPE_CHECKING") or ( isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING") def _nameparser_imports(path: pathlib.Path) -> list[str]: """All nameparser-internal imports in the module, at any nesting depth, EXCEPT those under `if TYPE_CHECKING:` -- annotation-only imports are not runtime dependencies.""" found: list[str] = [] def _record(node: ast.AST) -> None: # guard checked on EVERY node uniformly, so `elif TYPE_CHECKING:` # (a nested If in orelse) is skipped exactly like the plain form if isinstance(node, ast.If) and _is_type_checking(node.test): for stmt in node.orelse: _record(stmt) return if isinstance(node, ast.Import): found.extend(a.name for a in node.names) elif isinstance(node, ast.ImportFrom) and node.module: found.append(node.module) for child in ast.iter_child_nodes(node): _record(child) _record(ast.parse(path.read_text())) return [m for m in found if m.startswith("nameparser")] def _permitted(imported: str, allowed: tuple[str, ...]) -> bool: # An entry ending in "." is a pure prefix (subpackage contents only); # any other entry means that exact module or its submodules -- a bare # startswith would also admit siblings like nameparser._types_helpers. for entry in allowed: if entry.endswith("."): if imported.startswith(entry): return True elif imported == entry or imported.startswith(entry + "."): return True return False def test_layering_contract() -> None: for mod, allowed in ALLOWED.items(): path = PKG / mod if not path.exists(): assert mod not in _MUST_EXIST, ( f"{mod} keyed in ALLOWED but file is missing") continue # entries may precede their module within a plan for imported in _nameparser_imports(path): assert _permitted(imported, allowed), ( f"{mod} imports {imported}, which the layering contract " f"forbids (allowed prefixes: {allowed or 'none'})" ) def test_every_pipeline_module_is_keyed_in_allowed() -> None: """ALLOWED is a hand-maintained list, and test_layering_contract iterates ITS keys -- so a module absent from it is not checked loosely, it is not checked AT ALL. Nothing else notices: the suite stays green, and the new module may import whatever it likes. Scoped to _pipeline/ because that is what this contract governs. Unkeyed by choice: the config/ DATA modules (config/__init__.py is keyed, being code), util.py (no internal imports, and it dies with the 1.x layer) and _version.py. nameparser/__init__.py is unkeyed too and is none of those -- it is the export surface, held by test_public_exports instead. Measured when this was added: all 13 _pipeline modules were keyed, so the gap was latent rather than active. #439's _pieces.py would have been the first to slip through, which is why closing it then cost nothing to clean up. The directory comes from the imported subpackage rather than a path literal, so a typo is an AttributeError; and the floor below is not ceremony. Path.glob on a missing directory returns EMPTY rather than raising, so a mistyped pattern would leave this asserting `set() - keyed`, which passes for every possible ALLOWED -- a completeness check measuring nothing, which is the shape of the bug it exists to prevent. """ keyed = set(ALLOWED) pipeline_dir = pathlib.Path(nameparser._pipeline.__file__).parent shipped = {f"_pipeline/{p.name}" for p in pipeline_dir.glob("*.py")} assert "_pipeline/_group.py" in shipped, ( f"the glob matched no pipeline modules, so this check can no " f"longer fail: {sorted(shipped)}") missing = sorted(shipped - keyed) assert not missing, ( f"pipeline modules absent from ALLOWED, and therefore exempt " f"from the layering contract entirely: {missing}. Add each with " f"the narrowest prefix tuple that admits what it actually " f"imports -- _PIPELINE_STAGE_ALLOWED for a stage, something " f"tighter for a leaf several stages sit on (see _pipeline/_pieces.py)" ) def test_lexicon_never_imports_config_package_root_or_parser() -> None: for imported in _nameparser_imports(PKG / "_lexicon.py"): assert imported != "nameparser.config" assert not imported.startswith("nameparser.parser") def test_public_exports() -> None: expected = { "Span", "Role", "Token", "Ambiguity", "AmbiguityKind", "ParsedName", "Segmentation", "Segmenter", "Lexicon", "Policy", "PolicyPatch", "PatronymicRule", "Script", "UNSET", "GIVEN_FIRST", "FAMILY_FIRST", "FAMILY_FIRST_GIVEN_LAST", "DEFAULT_NICKNAME_DELIMITERS", "DEFAULT_SCRIPT_ORDERS", "Locale", "Parser", "parse", "parser_for", } assert expected <= set(nameparser.__all__) for name in expected: assert getattr(nameparser, name) is not None def test_type_checking_imports_do_not_count(tmp_path: pathlib.Path) -> None: mod = tmp_path / "snippet.py" mod.write_text( "from typing import TYPE_CHECKING\n" "if TYPE_CHECKING:\n" " from nameparser._lexicon import Lexicon\n" "elif TYPE_CHECKING:\n" " import nameparser.never_runtime\n" "else:\n" " import nameparser.util\n" "def f():\n" " from nameparser import _render\n" ) assert _nameparser_imports(mod) == ["nameparser.util", "nameparser"] def test_every_frozen_dataclass_carries_the_pickle_guards() -> None: # Forgetting the two class-body assignments is SILENT -- # @dataclass(slots=True) installs its own working pickle methods # without skew detection. Lexicon keeps a private copy of the guard # (layering keeps _lexicon import-free of _types). import dataclasses import inspect import nameparser._lexicon import nameparser._locale import nameparser._parser import nameparser._policy import nameparser._types from nameparser._types import _guarded_getstate, _guarded_setstate # _pipeline internals (ParseState, WorkToken, ...) are exempt: they # are never pickled and are not public API. modules = (nameparser._types, nameparser._lexicon, nameparser._policy, nameparser._locale, nameparser._parser) for module in modules: for _, cls in inspect.getmembers(module, inspect.isclass): if cls.__module__ != module.__name__: continue if not dataclasses.is_dataclass(cls): continue if cls.__name__ == "Lexicon": assert "__getstate__" in cls.__dict__ assert "__setstate__" in cls.__dict__ continue assert cls.__dict__.get("__getstate__") is _guarded_getstate, cls assert cls.__dict__.get("__setstate__") is _guarded_setstate, cls ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_lexicon.py������������������������������������������0000664�0000000�0000000�00000144613�15273032717�0023463�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������import copy import dataclasses import pickle import re import unicodedata import warnings from collections.abc import Callable import pytest from nameparser import Parser from nameparser._lexicon import ( Lexicon, _MaskValueError, _PHRASE_FIELDS, _VOCAB_FIELDS, _default_lexicon, _normalize, _title_key, ) from nameparser._policy import Script, _SCRIPT_RANGES from nameparser.config import Constants from nameparser.config.suffixes import SUFFIX_ACRONYMS, SUFFIX_WORDS from tests.removals import ignore_removals def test_entries_are_normalized_at_construction() -> None: lex = Lexicon(titles=frozenset({"Dr.", "MR"})) assert lex.titles == frozenset({"dr", "mr"}) def test_default_sources_v1_vocabulary() -> None: lex = Lexicon.default() assert "dr" in lex.titles assert "van" in lex.particles assert "phd" in lex.suffix_acronyms # flipped model: 'dos' is never-given in v1, so NOT ambiguous here assert "dos" in lex.particles and "dos" not in lex.particles_ambiguous assert "van" in lex.particles_ambiguous # the policy is 'e' and not 'y' (decisions/P8): a bare E initial is # common, where 'y' between two surnames is the commonest Hispanic compound assert "e" in lex.conjunctions_ambiguous and "y" not in lex.conjunctions_ambiguous # CAPITALIZATION_EXCEPTIONS maps 'phd' -> 'PhD', a case mask since # #459 (verbatim, not normalized -- only keys are lowercased and # period-stripped at construction; values pass through unchanged). assert lex.capitalization_exceptions_map["phd"] == "PhD" # maiden markers source from the same data-module pattern (#274); # non-colliding Cyrillic entries live in the default per the locales # design's sorting rule, and both ё/е spellings are listed because # case normalization does not fold them. assert "geborene" in lex.maiden_markers assert "урожденная" in lex.maiden_markers and "урождённая" in lex.maiden_markers # #269 fidelity: entries whose spelling casefold() would mutate must # arrive in the lexicon exactly as authored in the data modules # (final sigma intact, ß intact) -- the review that caught 'κοσ'. assert "κος" in lex.titles and "κοσ" not in lex.titles assert "großfürst" in lex.titles and "grossfürst" not in lex.titles def test_default_is_cached_single_instance() -> None: assert Lexicon.default() is Lexicon.default() def test_particles_ambiguous_must_be_subset_of_particles() -> None: with pytest.raises(ValueError, match="subset"): Lexicon(particles_ambiguous=frozenset({"van"})) def test_capitalization_exceptions_canonical_and_no_aliasing() -> None: exceptions = {"phd": "PhD", "ii": "II"} lex = Lexicon.empty() lex2 = dataclasses.replace(lex, capitalization_exceptions=exceptions) # type: ignore[arg-type] exceptions["iii"] = "III" # mutate caller's dict afterwards assert "iii" not in lex2.capitalization_exceptions_map # canonical: insertion order does not affect equality/hash lex3 = dataclasses.replace(lex, capitalization_exceptions=(("ii", "II"), ("phd", "PhD"))) assert lex2 == lex3 and hash(lex2) == hash(lex3) def test_lexicon_is_hashable() -> None: assert isinstance(hash(Lexicon.default()), int) def test_lexicon_rejects_bare_string_vocab() -> None: with pytest.raises(TypeError, match="bare string"): Lexicon(titles="dr") # type: ignore[arg-type] def test_lexicon_rejects_non_str_vocab_entries() -> None: with pytest.raises(TypeError, match="entries must be strings"): Lexicon(titles={"Dr.", 42}) # type: ignore[arg-type] def test_lexicon_rejects_non_iterable_vocab_field() -> None: # A non-iterable (an int) used to fall through to tuple(entries) # and surface as a bare "'int' object is not iterable" with no # hint which field was wrong. with pytest.raises(TypeError, match="titles.*iterable"): Lexicon(titles=5) # type: ignore[arg-type] with pytest.raises(TypeError, match="titles.*iterable"): Lexicon.empty().add(titles=5) # type: ignore[arg-type] def test_capitalization_exceptions_rejects_non_iterable() -> None: with pytest.raises(TypeError, match="capitalization_exceptions"): Lexicon(capitalization_exceptions=5) # type: ignore[arg-type] def test_entries_normalizing_to_empty_raise() -> None: # "." or "" is a data bug (stray split artifact, empty CSV cell); # dropping it silently would also let a future data-module typo # vanish instead of failing CI. One rule for vocab AND exception keys. with pytest.raises(ValueError, match="normalizes to empty"): Lexicon(titles=frozenset({"Dr.", "."})) with pytest.raises(ValueError, match="normalizes to empty"): Lexicon.empty().add(titles={" "}) with pytest.raises(ValueError, match="normalizes to empty"): Lexicon(capitalization_exceptions=(("...", "X"), ("phd", "PhD"))) def test_colliding_exception_keys_dedupe_last_wins() -> None: # 'Phd.' and 'phd' collide under edge-period normalization lex = Lexicon(capitalization_exceptions=(("Phd.", "PHD"), ("phd", "PhD"))) assert lex.capitalization_exceptions == (("phd", "PhD"),) rebuilt = Lexicon(capitalization_exceptions=lex.capitalization_exceptions_map) # type: ignore[arg-type] assert rebuilt == lex and hash(rebuilt) == hash(lex) def test_lexicon_rejects_non_str_exception_values() -> None: with pytest.raises(TypeError, match="str -> str"): Lexicon(capitalization_exceptions={"phd": 42}) # type: ignore[dict-item, arg-type] def _unpickled_with(value: object) -> Lexicon: state = Lexicon.default().__getstate__() state["capitalization_exceptions"] = value restored = Lexicon.__new__(Lexicon) restored.__setstate__(state) return restored # Every entry point that builds the pair field, because a check on one # member of a family belongs on all of it (AGENTS.md): construction, # replace(), the right-biased union, unpickling, and the v1 shim. _MASK_ENTRY_POINTS: list[Callable[[object], object]] = [ lambda v: Lexicon(capitalization_exceptions=v), # type: ignore[arg-type] lambda v: dataclasses.replace(Lexicon.default(), capitalization_exceptions=v), # type: ignore[arg-type] lambda v: Lexicon.default() | Lexicon(capitalization_exceptions=v), # type: ignore[arg-type] _unpickled_with, lambda v: Constants(capitalization_exceptions=v)._snapshot(), ] @pytest.mark.parametrize("build", _MASK_ENTRY_POINTS) @pytest.mark.parametrize("key, value", [ ("jr", "Junior"), ("phd", "PhDs"), ("phd", ""), ("md", "MB"), ("phd", "D.Ph."), ("2nd", "3ND"), ]) def test_an_exception_value_that_does_not_spell_its_key_raises( build: Callable[[object], object], key: str, value: str) -> None: """#459: a capitalization_exceptions value is a case MASK, the key's own letters recased. Case repair lays it over the word as written, so a value spelling anything else -- a replacement ('Junior'), a letter too many or too few, the right letters in the wrong order, a DIGIT changed ('2nd' -> '3ND') -- has no reading, and raises where it is built.""" with pytest.raises(ValueError, match="does not spell the key's letters") as caught: build(((key, value),)) assert repr(key) in str(caught.value) assert repr(value) in str(caught.value) def test_a_mask_may_recase_a_digit_key_unchanged() -> None: """#459 review: the mask compares ALPHANUMERICS (_lexicon._alnum), not letters alone, so a digit is part of what a value must spell -- '2nd' accepts '2ND' ('2' unchanged, letters recased) and rejects '3ND' above.""" lex = Lexicon(capitalization_exceptions=(("2nd", "2ND"),)) assert lex.capitalization_exceptions_map["2nd"] == "2ND" def test_a_decomposed_value_is_compared_and_stored_nfc_composed() -> None: """A decomposed value is accepted against a composed key, the check folding both sides through _normalize, and is STORED composed, so both spellings of one value construct alike (test_render's decomposed-mask test shows why that matters).""" composed = unicodedata.normalize("NFC", "CAFÉ") decomposed = unicodedata.normalize("NFD", "CAFÉ") assert decomposed != composed # the draw actually decomposed something lex = Lexicon(capitalization_exceptions=(("café", decomposed),)) assert lex.capitalization_exceptions_map["café"] == composed @pytest.mark.parametrize("key, value", [ ("phd", "Ph.D."), ("md", "M.D."), ("hc", "h.c"), ("ph.d", "PhD"), ("phd", "P.H.D."), ("md", "M. D."), ("dphil", "DPhil"), ]) def test_a_value_may_carry_punctuation_that_marks_its_joins( key: str, value: str) -> None: """#459 review: a value's punctuation is not ignored -- it marks which of the key's letters are JOINED (one run) versus split, and _apply_mask's initial rule reads that structure -- but it is never WRITTEN into the word. Every one of these is warning-free at construction, where a construction diagnostic could be raised.""" with warnings.catch_warnings(): warnings.simplefilter("error") lex = Lexicon(capitalization_exceptions=((key, value),)) assert tuple(lex.capitalization_exceptions_map.values()) == (value,) def test_the_mask_error_offers_a_spelling_that_works_and_type_checks( ) -> None: """The message hands the reader code to paste, so the code has to run and to type-check. The field is annotated with what it STORES, a tuple of pairs, so a dict literal there is an arg-type error -- AGENTS.md's rule for actionable messages. The offered spelling is the offending KEY recased upper, not a fixed example, so the two constructions below use the spelling the 'jr' key's own error offers, verbatim; mypy checks this file.""" offered = "capitalization_exceptions=(('jr', 'JR'),)" with pytest.raises(ValueError) as caught: Lexicon(capitalization_exceptions=(("jr", "Junior"),)) assert offered in str(caught.value) assert Lexicon(capitalization_exceptions=(('jr', 'JR'),)) \ .capitalization_exceptions_map == {"jr": "JR"} lexicon, _policy, _render = Constants( capitalization_exceptions=(('jr', 'JR'),))._snapshot() assert lexicon.capitalization_exceptions_map == {"jr": "JR"} def test_the_mask_error_falls_back_when_upper_would_not_itself_validate( ) -> None: """#459 review: .upper() can change a letter's COUNT -- 'straße'.upper() is 'STRASSE', ß growing to two letters -- so offering it verbatim would raise if pasted. The offer falls back to the key unchanged (an identity mask, always legal) whenever upper() would not itself spell the key's own letters.""" with pytest.raises(ValueError) as caught: Lexicon(capitalization_exceptions=(("straße", "STRASSE"),)) offered = "capitalization_exceptions=(('straße', 'straße'),)" assert offered in str(caught.value) # pasted, the offered spelling constructs assert Lexicon(capitalization_exceptions=(('straße', 'straße'),)) \ .capitalization_exceptions_map == {"straße": "straße"} def test_the_mask_error_echoes_the_callers_own_spelling_not_the_composed_one( ) -> None: """#459 review: the value used to be captured AFTER NFC normalization, so the message (and _MaskValueError.value) showed the composed spelling even when the caller wrote a decomposed one. A decomposed 'e' + a combining acute and its NFC-composed 'é' look identical printed, but are different str objects -- repr() shows it (different escaping), and here it also changes len().""" decomposed = "PhéD" # 'PhéD', 'e' + U+0301 COMBINING ACUTE composed = unicodedata.normalize("NFC", decomposed) assert repr(decomposed) != repr(composed) with pytest.raises(_MaskValueError) as caught: Lexicon(capitalization_exceptions=(("phed", decomposed),)) assert caught.value.value == decomposed assert repr(decomposed) in str(caught.value) assert repr(composed) not in str(caught.value) def test_mask_value_error_survives_pickle_and_copy() -> None: """The inherited __reduce__ would re-call __init__ with self.args, the message alone, and __init__ needs `key`, `normalized_key` and `value` too -- so without the override a ProcessPoolExecutor worker raising it would surface as BrokenProcessPool rather than this ValueError.""" try: Lexicon(capitalization_exceptions=(("jr", "Junior"),)) except _MaskValueError as caught: original = caught else: raise AssertionError("expected _MaskValueError") original.add_note("seen in worker 3") for restored in (pickle.loads(pickle.dumps(original)), copy.copy(original)): assert isinstance(restored, ValueError) assert isinstance(restored, _MaskValueError) assert restored.key == original.key == "jr" assert restored.offered == original.offered assert str(restored) == str(original) # __reduce__'s third element (self.__dict__) is what carries a # note added after construction -- reconstructing from the # three __init__ fields alone would drop it. assert restored.__notes__ == ["seen in worker 3"] def test_add_and_remove_return_new_lexicons() -> None: # "zqtitle" is a synthetic word absent from v1's TITLES data (unlike # e.g. "dra", the feminine "dr." abbreviation, which is already there). base = Lexicon.default() lex = base.add(titles={"zqtitle"}).remove(suffix_words={"esquire"}) assert "zqtitle" in lex.titles and "zqtitle" not in base.titles # precondition, or the removal assertion passes vacuously (this is a # guard for the operation under test, not a vocabulary content pin) assert "esquire" in base.suffix_words assert "esquire" not in lex.suffix_words def test_add_unknown_field_raises_with_valid_names() -> None: with pytest.raises(TypeError, match="prefixes"): Lexicon.default().add(prefixes={"van"}) # v1 name: helpful error def test_add_capitalization_exceptions_raises_pointing_at_replace() -> None: # The offered spelling is a tuple, not a dict: the field is # annotated as one, so a pasted dict fails mypy (AGENTS.md). And # it must RUN as pasted: the value is a case mask of its key, so # ('key', 'Value') type-checks and then raises (2.4 release sweep). offered = re.escape( "dataclasses.replace(lexicon, capitalization_exceptions=" "(*lexicon.capitalization_exceptions, ('key', 'KEY')))") with pytest.raises(TypeError, match=offered) as raised: Lexicon.default().add(capitalization_exceptions={"x": "X"}) code = re.search(r"dataclasses\.replace\(.*?\)\)\)", str(raised.value)) assert code is not None pasted = eval(code[0], {"dataclasses": dataclasses, "lexicon": Lexicon.default()}) assert pasted.capitalization_exceptions_map["key"] == "KEY" def test_union_is_fieldwise_and_right_biased_for_exceptions() -> None: a = dataclasses.replace(Lexicon.empty(), capitalization_exceptions=(("phd", "PhD"),)) a = a.add(titles={"dr"}) b = dataclasses.replace(Lexicon.empty(), capitalization_exceptions=(("phd", "Ph.D."),)) b = b.add(titles={"mr"}) u = a | b assert u.titles == frozenset({"dr", "mr"}) assert u.capitalization_exceptions_map["phd"] == "Ph.D." # right wins def test_remove_breaking_subset_invariant_raises() -> None: lex = Lexicon(particles=frozenset({"van"}), particles_ambiguous=frozenset({"van"})) with pytest.raises(ValueError, match="subset"): lex.remove(particles={"van"}) # would orphan particles_ambiguous def test_pickle_round_trip_preserves_equality_and_cap_map() -> None: # _cap_map holds a MappingProxyType, which pickle rejects; Lexicon # must round-trip anyway because Parser is picklable by construction # (spec: 2026-07-11-v2-core-api-design.md) and a Parser holds a Lexicon. import pickle for lex in (Lexicon.default(), Lexicon.empty().add(titles={"Dr."})): loaded = pickle.loads(pickle.dumps(lex)) assert loaded == lex assert hash(loaded) == hash(lex) assert (loaded.capitalization_exceptions_map == lex.capitalization_exceptions_map) def test_lexicon_rejects_mapping_for_plain_vocab_field() -> None: # A dict here almost always means the caller confused the field with # capitalization_exceptions; silently keeping just the keys would be # the lone quiet coercion on an otherwise fail-loud surface. with pytest.raises(TypeError, match="mapping"): Lexicon(titles={"Dr.": "Doctor"}) # type: ignore[arg-type] with pytest.raises(TypeError, match="mapping"): Lexicon.empty().add(titles={"Dr.": "Doctor"}) def test_capitalization_exceptions_rejects_malformed_shapes() -> None: with pytest.raises(TypeError, match="bare string"): Lexicon(capitalization_exceptions="ab") # type: ignore[arg-type] with pytest.raises(TypeError, match="pairs"): Lexicon(capitalization_exceptions=(("a", "b", "c"),)) # type: ignore[arg-type] with pytest.raises(TypeError, match="pairs"): # a 2-char string entry would unpack into two chars and silently # store {"a": "b"} -- reject str entries outright Lexicon(capitalization_exceptions=("ab",)) # type: ignore[arg-type] def test_setstate_rejects_mismatched_field_layout() -> None: # A pickle from a different Lexicon version (field added/renamed) # must fail at load time with a message naming the mismatch, not at # some later attribute read far from the unpickle site. lex = Lexicon.empty() good_state = lex.__getstate__() missing = dict(good_state) del missing["titles"] with pytest.raises(ValueError, match="titles"): Lexicon.__new__(Lexicon).__setstate__(missing) extra = dict(good_state) extra["zq_future_field"] = frozenset() with pytest.raises(ValueError, match="zq_future_field"): Lexicon.__new__(Lexicon).__setstate__(extra) def test_normalization_lowercases_and_keeps_interior_periods() -> None: # v1's lc() semantics: lowercase, EDGE periods trimmed, interior # periods KEPT ('J.R.' must not collapse to 'jr' and hit the # periodless vocabulary -- v1 parity, pinned live 2026-07-17). # Suffix-ACRONYM membership alone strips periods (see # _vocab.suffix_as_written). lex = Lexicon(titles=frozenset({"STRAßE", "Ph.D", "Dr."})) assert lex.titles == frozenset({"straße", "ph.d", "dr"}) def test_normalization_preserves_spellings_casefold_would_mutate() -> None: # lower(), not casefold(): casefold's caseless-matching folds would # rewrite stored vocabulary -- Greek final sigma flattens ('κος' -> # the misspelling 'κοσ') and German ß expands ('großfürst' -> # 'grossfürst'). lower() follows Unicode SpecialCasing contextually # ('ΚΟΣ' -> 'κος') and keeps entries as authored, matching v1's lc(). lex = Lexicon(titles=frozenset({"ΚΟΣ", "Großfürst"})) assert lex.titles == frozenset({"κος", "großfürst"}) def test_suffix_ambiguous_must_be_subset_of_acronyms() -> None: # same invariant as particles_ambiguous <= particles: the ambiguous # set marks a subset of suffix_acronyms, and classify's period gate # depends on the membership tests agreeing about it with pytest.raises(ValueError, match="subset"): Lexicon(suffix_acronyms_ambiguous=frozenset({"ma"})) def test_an_ambiguous_suffix_word_may_not_be_an_unambiguous_acronym() -> None: # the mirror of the gate-bypass raise: the unambiguous acronym # branch claims a word outright, so a gated suffix word listed there # loses its count ('Ana Neto' would read suffix 'Neto', rules/S11) with pytest.raises(ValueError, match="bypasses the count"): Lexicon.default().add(suffix_acronyms={"neto"}) def test_the_shipped_suffix_sets_are_disjoint() -> None: # the vocabulary build refuses the overlap (tools/vocab/build.py, # run over the shipped files by tests/v2/test_vocab.py), but over the # TOML, not over the constants a running process holds, so the # invariant over the shipped constants gets a named test as well. The two # sets normalize differently -- the word test strips only edge # periods, the acronym test strips all of them -- so a word in both # is matched by two rules and which one fired is unreadable from # outside. The last overlap, 'esq', left the acronyms 2026-09-08 # (decisions/suffix-acronym-collisions). # # The SHIPPED sets only: Lexicon has no such invariant, and a # caller who wants the overlap in their own vocabulary keeps it. assert SUFFIX_ACRONYMS & SUFFIX_WORDS == frozenset() default = Lexicon.default() assert default.suffix_acronyms & default.suffix_words == frozenset() def test_given_name_titles_may_hold_a_multi_word_phrase() -> None: # given_name_titles is looked up against the SPACE-JOINED title run # (_pipeline/_post_rules.py), not per token, so "grand duke" is a # legitimate entry. Its words must be titles -- that is what makes # the run a title in the first place -- but the phrase itself is # not, and must not be required to be. lex = Lexicon(titles=frozenset({"grand", "duke"}), given_name_titles=frozenset({"grand duke"})) assert "grand duke" in lex.given_name_titles @pytest.mark.parametrize("spelling", ["lt. col", "lt col", "Lt. Col."]) def test_given_name_titles_folds_per_word_so_abbreviations_match( spelling: str) -> None: # The key is built per word and rejoined, so an abbreviated entry # has to fold the same way the parse folds the title run. Storing # 'lt. col' verbatim kept the interior period and matched nothing -- # a silent no-op on the config surface, the failure this field is # most prone to (it has no validation by design). # # It is the whole-run arm that matches here, and only that arm: # `col` alone is not a given-name title, so #489's last-word arm # must not have replaced the phrase lookup. base = Lexicon.default() lex = dataclasses.replace( base, titles=base.titles | {"lt", "col"}, given_name_titles=base.given_name_titles | {spelling}) assert "lt col" in lex.given_name_titles assert "col" not in lex.given_name_titles assert Parser(lexicon=lex).parse("Lt. Col. Smith").given == "Smith" def test_the_shipped_given_name_titles_are_every_one_a_single_word() -> None: # The invariant that makes the whole-run arm dead for the shipped # vocabulary: it can only ever match a one-word run, which the # last-word arm reads the same way (#489). A phrase entry here # would give that arm reach the docstring says it has not. assert not any(" " in t for t in Lexicon.default().given_name_titles) def test_a_run_matches_by_its_last_word_when_the_whole_run_does_not() -> None: # The other arm's premise, on the shipped vocabulary: 'dr sir' is # no entry and never could be, and `sir` is the run's last word # (#489). What the parse then reads is the `Dr. Sir John` case # row's, and is not asserted twice. lex = Lexicon.default() assert "dr sir" not in lex.given_name_titles assert "sir" in lex.given_name_titles @pytest.mark.parametrize("entry", ["lt .", "lt . col", ". col", ". ."]) def test_given_name_titles_fold_is_a_fixed_point(entry: str) -> None: # Storage re-runs the fold on unpickle and on every # dataclasses.replace, so a value that changes under a second pass # is one Lexicon later rejects as "not written by this version". once = _title_key(entry.split()) assert _title_key(once.split()) == once assert once == once.strip() and " " not in once def test_a_title_word_that_folds_away_leaves_no_gap() -> None: # 'lt .' stored 'lt' before this field folded per word. A per-word # join that keeps the empty slot stores 'lt ', which match time can # never build -- silently inert config, the exact failure the # per-word fold was introduced to remove. base = Lexicon.default() lex = dataclasses.replace( base, titles=base.titles | {"lt", "col"}, given_name_titles=base.given_name_titles | {"lt ."}) assert "lt" in lex.given_name_titles assert Parser(lexicon=lex).parse("Lt. John").given == "John" def test_the_constructor_and_add_store_an_entry_identically() -> None: # add() goes through dataclasses.replace, which re-runs # __post_init__ -- a non-idempotent fold converges there and not in # the constructor, so the two paths silently disagree. base = dataclasses.replace(Lexicon.default(), titles=Lexicon.default().titles | {"lt", "col"}) built = dataclasses.replace( base, given_name_titles=base.given_name_titles | {"lt . col"}) assert built.given_name_titles == base.add( given_name_titles={"lt . col"}).given_name_titles def test_an_all_punctuation_given_name_title_is_rejected() -> None: # every other vocab field raises on this; this one must not be # special just because it folds per word with pytest.raises(ValueError, match="normalizes to empty"): Lexicon(given_name_titles=frozenset({". ."})) def test_given_name_titles_is_not_constrained_against_titles() -> None: # Deliberately unvalidated. The lookup key is the joined run of # Role.TITLE tokens, which the PARSE builds, and a conjunction # inside a run is title-tagged too -- so "sir and dame" is a # matchable key whose middle word is a conjunction, not a title. # A per-word check rejected it; a whole-entry check rejected # multi-word entries. An unreachable entry is inert rather than # harmful, so nothing is rejected here. assert Lexicon(titles=frozenset({"sir", "dame"}), given_name_titles=frozenset({"sir and dame"})) # and an entry nothing will ever consult is accepted, not rejected assert Lexicon(given_name_titles=frozenset({"zzqnotatitle"})) @pytest.mark.parametrize(("make", "marker", "base"), [ (lambda w: Lexicon(particles_ambiguous=w), "particles_ambiguous", "particles"), (lambda w: Lexicon(suffix_acronyms_ambiguous=w), "suffix_acronyms_ambiguous", "suffix_acronyms"), (lambda w: Lexicon(honorific_tails=w), "honorific_tails", "suffix_words"), (lambda w: Lexicon(suffix_words_ambiguous=w), "suffix_words_ambiguous", "suffix_words"), ], ids=["particles_ambiguous", "suffix_acronyms_ambiguous", "honorific_tails", "suffix_words_ambiguous"]) def test_subset_error_names_the_fix( make: Callable[[frozenset[str]], Lexicon], marker: str, base: str ) -> None: # the constraint alone leaves the reader to infer the remedy, and # the remedy is direction-dependent: a caller who was ADDING wants # to add to the base, one who was REMOVING wants to drop from the # marker. Naming both keeps the message right either way. with pytest.raises(ValueError, match=f"Add them to {base}, or drop " f"them from {marker}"): make(frozenset({"zzqnovel"})) def test_bound_given_name_that_is_a_particle_must_be_ambiguous() -> None: # the vocabulary build checks this on the shipped data (check 9): # NON_GIVEN_NAME_PARTICLES stays disjoint from BOUND_GIVEN_NAMES. In # 2.0's complement model that reads as bound_given_names & particles # <= particles_ambiguous. A particle declared never-to-start-a-given- # name cannot simultaneously bind one; without the check, one of the # two contradictory declarations is silently discarded. with pytest.raises(ValueError, match="particles_ambiguous"): Lexicon(particles=frozenset({"dos"}), particles_ambiguous=frozenset(), bound_given_names=frozenset({"dos"})) def test_bound_given_name_may_overlap_an_ambiguous_particle() -> None: # the shipped case: "abu" is both, and legitimately so lex = Lexicon(particles=frozenset({"abu"}), particles_ambiguous=frozenset({"abu"}), bound_given_names=frozenset({"abu"})) assert "abu" in lex.bound_given_names # and a bound name that is not a particle at all is unconstrained assert "abdul" in Lexicon(bound_given_names=frozenset({"abdul"}) ).bound_given_names def test_default_lexicon_satisfies_the_bound_particle_invariant() -> None: d = Lexicon.default() assert not (d.bound_given_names & d.particles) - d.particles_ambiguous def test_removing_a_title_leaves_its_given_name_marker_alone() -> None: # the mirror of the above: removing from titles does not raise, and # the now-inert marker entry simply stops being reachable lex = Lexicon(titles=frozenset({"sheikh"}), given_name_titles=frozenset({"sheikh"})) lean = lex.remove(titles={"sheikh"}) assert lean.titles == frozenset() assert lean.given_name_titles == frozenset({"sheikh"}) def test_removing_a_conjunction_leaves_its_ambiguous_marker_alone() -> None: # conjunctions_ambiguous is the same shape as given_name_titles: not # in _SUBSET_FIELDS, so an orphan is inert rather than rejected -- # the marker is never consulted once its base entry is gone # (decisions/P8). lean = Lexicon.default().remove(conjunctions={"e"}) assert "e" not in lean.conjunctions assert "e" in lean.conjunctions_ambiguous @pytest.mark.parametrize("word", [ "dr.", " Dr. ", ". a .", ". .", "..x..", " .b. ", # the three non-ASCII full stops (#322), alone and mixed with # the ASCII one, and around whitespace like the rows above "씨.", "씨。", "씨。", ".씨。", " 。씨 ", "。 씨 .", # NFD input: the fold composes, and composing twice is a no-op unicodedata.normalize("NFD", "씨."), unicodedata.normalize("NFD", "Müller."), # a case-mapping that produces a combining mark: lower() then NFC # has to land somewhere a second pass leaves alone "İ.", ]) def test_normalize_reaches_a_fixed_point(word: str) -> None: # strip() then strip(".") leaves periods-around-whitespace half # done: '. a .' -> ' a ' -> 'a'. Anything that stores a normalized # value and later re-normalizes it (unpickling, a second add) then # sees the value change under it, so the fold has to converge. once = _normalize(word) assert _normalize(once) == once def test_normalize_folds_every_full_stop_and_composes_nfd() -> None: # #322: the vocabulary is stored NFC with edge stops removed, so a # lookup written with a fullwidth or ideographic stop, or in NFD, # has to fold to the same key. Edge stops only: 'J.R.' keeps its # interior period exactly as before. assert _normalize("씨.") == "씨" assert _normalize("씨。") == "씨" assert _normalize("씨。") == "씨" # the fold is script-agnostic, so the reach is not CJK-only: a # LATIN word wearing an ideographic stop folds to its entry too, # and 'Dr。' consequently reads as a title where before it read as # a name word. Pinned here because it is the shape the tests # elsewhere in this bundle deliberately leave in ASCII. assert _normalize("Dr。") == "dr" assert _normalize(unicodedata.normalize("NFD", "씨.")) == "씨" assert _normalize("J.R.") == "j.r" assert _normalize("김.민준") == "김.민준" # an INTERIOR ideographic stop survives too: the fold composes NFC, # which leaves U+3002 exactly where it is, so this pins the # interior survival the ASCII row above pins for '.' assert _normalize("김。민준") == "김。민준" # NFC, NOT NFKC, and this is the row that separates them. NFKC # folds every fullwidth character to its ASCII compatibility form, # so it would give 'm.b.a' here -- whose period-free form is 'mba', # a shipped suffix acronym (_vocab.suffix_as_written strips # periods for the acronym test alone). Folding widths at the # lookup would therefore make a fullwidth-written credential match # the ASCII vocabulary, which is a decision this bundle did not # take: the stop set was widened, the letters were not. assert _normalize("M.B.A.") == "m.b.a" def test_an_entry_that_is_only_full_stops_is_rejected() -> None: # the "normalizes to empty" guard reads the same stop set as the # fold, so an ideographic stop on its own is the data bug an ASCII # one already is with pytest.raises(ValueError, match="normalizes to empty"): Lexicon(titles=frozenset({"。"})) def test_an_nfd_authored_entry_is_stored_composed() -> None: # the storage fold composes NFC (#322), so a caller who authors a # surname in NFD gets the NFC entry -- and raw NFD input, which # segmentation matches without folding, then finds no entry and # goes unsplit (decisions/W1's 2026-07-29 NFD bullet, ja amendment 1a: NFD # degrades to no-split, never to a wrong split). Pinned # because the surname site's comment relies on it. nfd = unicodedata.normalize("NFD", "김") lex = Lexicon(surnames=frozenset({nfd})) assert lex.surnames == frozenset({"김"}) assert nfd not in lex.surnames def test_every_shipped_entry_is_already_nfc() -> None: # the measurement behind _normalize's docstring, as a standing # assertion: NFC at storage changes no shipped entry, so the # vocabulary a caller reads back is the vocabulary that was # authored. A data module written in NFD would fail here. # # The roster is _VOCAB_FIELDS, which is the roster __post_init__ # and __setstate__ normalize -- so a fourteenth vocabulary field # has to join it for add/remove to work at all, and joining it # gets the field checked here for free (#322/#323 review). lex = Lexicon.default() for field in _VOCAB_FIELDS: for word in getattr(lex, field): assert unicodedata.normalize("NFC", word) == word, (field, word) for pair in lex.capitalization_exceptions: for word in pair: assert unicodedata.normalize("NFC", word) == word, pair def test_unpickling_revalidates_invariants() -> None: # the field-layout guard catches SHAPE skew, but the likeliest skew # is same-name/flipped-meaning: particles_ambiguous inverted sense # between v1's never-given set and v2's may-be-given set. A state # dict that violates an invariant must not load in silence. lex = Lexicon(particles=frozenset({"van"}), particles_ambiguous=frozenset({"van"})) state = lex.__getstate__() state["particles"] = frozenset() # orphans particles_ambiguous forged = Lexicon.__new__(Lexicon) with pytest.raises(ValueError, match="particles_ambiguous"): forged.__setstate__(state) def test_unpickling_rejects_unnormalized_entries() -> None: # a genuine pickle always holds normalized entries, so state that # does not is foreign. Correcting it silently would make the load # path a fourth place where caller data is rewritten without a # word; say so instead, at the site that can still name the entry. lex = Lexicon(titles=frozenset({"dr"})) state = lex.__getstate__() state["titles"] = {"Sir ", "DR."} restored = Lexicon.__new__(Lexicon) with pytest.raises(ValueError, match="not normalized"): restored.__setstate__(state) def test_unpickling_rejects_uncanonical_capitalization_exceptions() -> None: # the pair field gets the same treatment as the vocab sets: it is # re-canonicalized by __post_init__ (normalized keys, sorted, # deduped), so accepting a rewritten value would leave one field # silently corrected while ten raise lex = Lexicon(capitalization_exceptions=(("phd", "PhD"),)) state = lex.__getstate__() state["capitalization_exceptions"] = ((" PhD. ", "Ph.D."), ("aa", "AA")) restored = Lexicon.__new__(Lexicon) with pytest.raises(ValueError, match="not normalized"): restored.__setstate__(state) def test_unpickling_a_real_pickle_is_the_identity() -> None: # the corollary: anything this library produced round-trips # unchanged, including entries whose normalized form needed more # than one strip pass to converge for lex in (Lexicon.default(), Lexicon.empty(), Lexicon(titles=frozenset({". a ."}))): assert pickle.loads(pickle.dumps(lex)) == lex def test_remove_error_offers_the_removal_remedy_too() -> None: # a caller removing from the base does not want to be told to add # it back; the message names both directions lex = Lexicon(particles=frozenset({"van"}), particles_ambiguous=frozenset({"van"})) with pytest.raises(ValueError, match="drop them from particles_ambiguous"): lex.remove(particles={"van"}) @pytest.mark.parametrize("value", [b"dr", bytearray(b"dr"), memoryview(b"dr")]) @pytest.mark.parametrize("build", [ lambda v: Lexicon(titles=v), lambda v: Lexicon.empty().add(titles=v), lambda v: Lexicon(capitalization_exceptions=v), ]) def test_every_lexicon_entry_point_rejects_a_buffer_with_a_decode_hint( build: Callable[[object], object], value: object) -> None: # Binary sequences iterate to INTS, so the generic entry check # reported "entries must be strings, got 100" -- the byte value of # 'd', naming neither the cause nor the fix. v1 shipped a decode # hint for exactly this (#238). # # Every entry point, because the first version of this fix covered # the vocabulary fields and left capitalization_exceptions behind: # guarding one member of a family and not its siblings is the # single most repeated defect on this branch. with pytest.raises(TypeError, match="decode"): build(value) # Derived from the exemption, not written out beside it: an exemption # that widens must take fields OUT of this list, where the parametrized # test below stops covering them and the count assertion notices. _PER_WORD_FIELDS = [f for f in _VOCAB_FIELDS if f not in _PHRASE_FIELDS] def test_only_the_phrase_fields_are_exempt_from_the_warning() -> None: # The exemption is exactly the kind of edit that silently widens: # one more field in _PHRASE_FIELDS costs nothing at construction and # turns a dead entry back into a silent no-op. Three fields, named. assert _PHRASE_FIELDS == ("given_name_titles", "maiden_markers", "title_phrases") assert len(_PER_WORD_FIELDS) == len(_VOCAB_FIELDS) - 3 @pytest.mark.parametrize("field", _PER_WORD_FIELDS) def test_multiword_entry_warns_per_field(field: str) -> None: # Guard the whole family: every per-word field warns, not one. with pytest.warns(UserWarning, match="matched one word at a time"): if field in ("particles_ambiguous", "suffix_acronyms_ambiguous", "honorific_tails", "suffix_words_ambiguous"): # subset fields need their base populated to construct base = {"particles_ambiguous": "particles", "suffix_acronyms_ambiguous": "suffix_acronyms", "honorific_tails": "suffix_words", "suffix_words_ambiguous": "suffix_words"}[field] Lexicon.empty().add(**{base: ["zqx zqy"], field: ["zqx zqy"]}) else: Lexicon.empty().add(**{field: ["zqx zqy"]}) def test_multiword_given_name_title_does_not_warn() -> None: # given_name_titles matches space-joined runs -- multi-word entries # are the point (see the __post_init__ note in _lexicon.py). with warnings.catch_warnings(): warnings.simplefilter("error") ignore_removals() # 2.8.1's set-field warning is not this test's Lexicon.empty().add(given_name_titles=["lt col"]) def test_multiword_maiden_marker_does_not_warn() -> None: # The second exemption, and the one whose warning was wrong rather # than merely unhelpful: markers are matched by lookahead, and # "split it into separate entries" reads 'Maria Kowalska z domu # Nowak' as maiden 'domu Nowak'. with warnings.catch_warnings(): warnings.simplefilter("error") ignore_removals() Lexicon.empty().add(maiden_markers=["z domu"]) def test_phrase_fields_store_the_phrase_folded_per_word() -> None: # The shared storage rule: space-joined, each word normalized on # its own. Whole-phrase _normalize would keep 'z.' interior period # and the entry would never match. lex = Lexicon.empty().add(maiden_markers=["Z. Domu"], given_name_titles=["Lt. Col"]) assert lex.maiden_markers == frozenset({"z domu"}) assert lex.given_name_titles == frozenset({"lt col"}) def test_multiword_capitalization_key_warns() -> None: with pytest.warns(UserWarning, match="matched one word at a time"): dataclasses.replace( Lexicon.empty(), capitalization_exceptions=(("zqx zqy", "ZqXZqY"),)) def test_a_multiword_key_skips_the_mask_check_entirely() -> None: """#459 review: the mask check ran before the multi-word-key warning, so a key that can never match -- 'ph d' has no word in capitalized()'s per-word lookup -- still raised over a value that does not spell it (AGENTS.md: an invariant guards harm, and an unreachable entry does none). Reordered so a multi-word key only warns, storing its value verbatim regardless of the mask question.""" with pytest.warns(UserWarning, match="matched one word at a time"): lex = dataclasses.replace( Lexicon.empty(), capitalization_exceptions=(("ph d", "Doctor"),)) assert lex.capitalization_exceptions_map == {"ph d": "Doctor"} def test_default_lexicon_builds_warning_free() -> None: # Would have caught the eight dead entries the previous commit # repaired. _default_lexicon.cache_clear() try: with warnings.catch_warnings(): warnings.simplefilter("error") Lexicon.default() finally: _default_lexicon.cache_clear() def test_multiword_entry_warns_from_constructor_and_unpickle() -> None: with pytest.warns(UserWarning, match="matched one word at a time"): lex = Lexicon(titles=frozenset({"zqx zqy"})) with pytest.warns(UserWarning, match="matched one word at a time"): pickle.loads(pickle.dumps(lex)) def test_remove_does_not_warn() -> None: # remove() stores nothing; a dead-entry warning there would be a # false positive with wrong advice. with warnings.catch_warnings(): warnings.simplefilter("error") ignore_removals() Lexicon.default().remove(titles=["zqx zqy"]) def test_multiword_warning_points_at_caller_line() -> None: # the frame walk is the feature: both entry depths must attribute # the warning to THIS file, not library internals with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") Lexicon(titles=frozenset({"zqx zqy"})) Lexicon.empty().add(titles=["zqx zqy"]) assert len(w) >= 2 assert all(x.filename == __file__ for x in w) def test_add_warns_exactly_once() -> None: # the warn=False plumbing exists to prevent the double warning # (_edit's pass + the new instance's __post_init__); count it with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") Lexicon.empty().add(titles=["zqx zqy"]) assert len([x for x in w if "matched one word" in str(x.message)]) == 1 def test_remove_of_a_stored_dead_entry_is_silent() -> None: # the scenario the design argues for: user got the warning, now # removes the dead entry -- silently. (An UNRELATED edit on a # lexicon still holding one re-warns; that is per-construction # warning, by design.) with pytest.warns(UserWarning): dirty = Lexicon.empty().add(titles=["zqx zqy"]) with warnings.catch_warnings(): warnings.simplefilter("error") ignore_removals() dirty.remove(titles=["zqx zqy"]) def test_surnames_is_a_vocab_field() -> None: lex = Lexicon.empty().add(surnames={"毛", "欧阳"}) assert lex.surnames == frozenset({"毛", "欧阳"}) assert lex.remove(surnames={"毛"}).surnames == frozenset({"欧阳"}) merged = Lexicon(surnames=frozenset({"김"})) | Lexicon(surnames=frozenset({"박"})) assert merged.surnames == frozenset({"김", "박"}) def test_default_lexicon_ships_korean_surnames_only() -> None: lex = Lexicon.default() assert "김" in lex.surnames and "남궁" in lex.surnames # Han surnames are locales.ZH's cargo (segmentation opt-in), so # the DEFAULT set is wholly hangul -- structural check, not # content-pinning. The spans come from the shared table rather than # a second hand-copy of 0xAC00/0xD7A3 (test_locales.py's precedent). hangul = _SCRIPT_RANGES[Script.HANGUL] assert all(all(any(lo <= ord(c) <= hi for lo, hi in hangul) for c in s) and 1 <= len(s) <= 2 for s in lex.surnames) def test_honorific_tails_is_a_vocab_field() -> None: # every tail is also a suffix word -- the peeled piece is claimed # by suffix classification -- so each construction carries both lex = Lexicon.empty().add(honorific_tails={"씨", "さん"}, suffix_words={"씨", "さん"}) assert lex.honorific_tails == frozenset({"씨", "さん"}) assert lex.remove(honorific_tails={"씨"}).honorific_tails == \ frozenset({"さん"}) merged = (Lexicon(honorific_tails=frozenset({"씨"}), suffix_words=frozenset({"씨"})) | Lexicon(honorific_tails=frozenset({"様"}), suffix_words=frozenset({"様"}))) assert merged.honorific_tails == frozenset({"씨", "様"}) def test_removing_a_honorific_tail_is_the_peels_off_switch() -> None: # The off-switch docs/customize.rst documents, at parse level: # dropping a tail leaves the glued name unsplit while the SPACED # spelling still routes the honorific, since suffix_words is a # separate field and only the peel consults honorific_tails. # honorific_tails is the one marker field a caller can empty # without a matching base edit -- an orphan is what raises, and a # removal cannot make one. lex = Lexicon.default().remove(honorific_tails={"さん"}) p = Parser(lexicon=lex) glued = p.parse("田中さん") assert (glued.family, glued.suffix) == ("田中さん", "") spaced = p.parse("田中 さん") assert (spaced.family, spaced.suffix) == ("田中", "さん") # the baseline the removal is against, so the test cannot pass by # the peel being broken outright on = Parser().parse("田中さん") assert (on.family, on.suffix) == ("田中", "さん") def test_after_given_titles_is_a_vocab_field() -> None: # #835: add, remove and | reach it, and it is normalized like every # other field, so the period-marked entry matches the bare word lex = Lexicon.empty().add(after_given_titles={"Mrs.", "mr"}) assert lex.after_given_titles == frozenset({"mrs", "mr"}) respelled = (Lexicon.default().remove(after_given_titles={"mrs"}) .add(after_given_titles={"Mrs."})) assert Parser(lexicon=respelled).parse("Doe, Jane Mrs").title == "Mrs" assert lex.remove(after_given_titles={"mr"}).after_given_titles == \ frozenset({"mrs"}) merged = (Lexicon(after_given_titles=frozenset({"mrs"})) | Lexicon(after_given_titles=frozenset({"miss"}))) assert merged.after_given_titles == frozenset({"mrs", "miss"}) with pytest.raises(TypeError): Lexicon(after_given_titles="mrs") # type: ignore[arg-type] edited = Lexicon.default().add(after_given_titles={"zzq"}) assert pickle.loads(pickle.dumps(edited)) == edited assert "after_given_titles: +1" in repr(edited) def test_removing_an_after_given_title_is_rule_h8s_off_switch() -> None: # Dropping the marker leaves the bare salutation a middle name after # a family comma, while the period-marked spelling still reads as # the title by H5's shape, which never consults the marker lex = Lexicon.default().remove(after_given_titles={"mrs"}) p = Parser(lexicon=lex) assert (p.parse("Doe, Jane Mrs").title, p.parse("Doe, Jane Mrs").middle) == ("", "Mrs") assert p.parse("Doe, Jane Mrs.").title == "Mrs." # the baseline the removal is against assert Parser().parse("Doe, Jane Mrs").title == "Mrs" def test_every_shipped_after_given_title_is_reachable() -> None: # a property of the set, not its contents: each entry must reach # rules/H8 at the slot it is listed for, so a member that another # reading claims first (a suffix dual, a particle, a bound given # name) shows up here as silently inert p = Parser() unreached = [w for w in sorted(p.lexicon.after_given_titles) if p.parse(f"Doe, Jane {w.title()}").title != w.title()] assert not unreached, unreached @pytest.mark.parametrize(("lexicon", "text", "word"), [ # in the marker, removed from the titles (Lexicon.default().remove(titles={"mrs"}), "Doe, Jane Mrs", "Mrs"), # added to the marker only (Lexicon.default().add(after_given_titles={"zzq"}), "Doe, Jane Zzq", "Zzq"), ], ids=["removed-from-titles", "marker-only"]) def test_an_after_given_title_that_is_no_title_is_never_read( lexicon: Lexicon, text: str, word: str) -> None: # the orphan contract docs/customize.rst states: an entry is read # only for a word that is a title, so an orphan is inert rather than # an error (classify tags the marker only where the word is a title) name = Parser(lexicon=lexicon).parse(text) assert (name.title, name.middle) == ("", word) @pytest.mark.parametrize("field", _PHRASE_FIELDS) def test_a_shipped_phrase_entry_is_stored_as_written(field: str) -> None: """The half of a phrase entry's storage rule config's own assert_normalized cannot see. It checks the single-spaced lowercase form; the per-WORD period strip is _lexicon._title_key's. So the question is asked here, and it is stronger than the fold: does Lexicon store the shipped constant UNCHANGED? An entry written 'z. domu' passes import-time hygiene and is silently rewritten to 'z domu' -- inert as a lookup key nobody wrote. Over _PHRASE_FIELDS rather than over maiden_markers, so a third phrase field arrives already pinned.""" from nameparser.config.maiden_markers import MAIDEN_MARKERS from nameparser.config.titles import GIVEN_NAME_TITLES, TITLE_PHRASES shipped = {"maiden_markers": MAIDEN_MARKERS, "given_name_titles": GIVEN_NAME_TITLES, "title_phrases": TITLE_PHRASES}[field] assert getattr(Lexicon.default(), field) == frozenset(shipped), ( f"{field}: Lexicon rewrote the shipped constant, so the entries " f"it stores are not the ones the data module wrote -- " f"{sorted(frozenset(shipped) - getattr(Lexicon.default(), field))} " f"were folded away or changed") ���������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_local_agreement.py����������������������������������0000664�0000000�0000000�00000063647�15273032717�0025152�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Offline checks on tools/differential/local_agreement.py. The score compares a parse of each filed display name with the boxes the filer typed it into, so what these pin is the comparison's three decisions (the fold, the suffix counted in the surname box, the title box set aside as filer error), each beside the reading with the decision off, and the record's refusals: no record, a record that is not one, a record of another fetch or another scorer, and a corpus damaged under the stream. The names are invented. """ import hashlib import importlib import json import sys import unicodedata from pathlib import Path import pytest from nameparser import Parser from ._differential_fixtures import checkout_copy, load_tool, own_cache, plant_corpus agreement = load_tool("local_agreement") corpora = agreement.local_corpora _PSC = corpora.PSC _PSC_FIELDS = agreement.FIELDED[_PSC] _LISTED = agreement._title_words(Parser().lexicon) @pytest.fixture(autouse=True) def _own_cache(monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory) -> Path: return own_cache(monkeypatch, tmp_path_factory) def _filed(title: str = "", forename: str = "", middle_name: str = "", surname: str = "", nationality: str = "British") -> dict[str, str]: boxes = {"title": title, "forename": forename, "middle_name": middle_name, "surname": surname} return {"name": " ".join(v for v in boxes.values() if v), "nationality": nationality, **{k: v for k, v in boxes.items() if v}} _AGREES = _filed("Mrs", "Jo", surname="Ng") _DOUBLE_SURNAME = _filed("", "Ana", surname="García López", nationality="Spanish") _SUFFIX = _filed("Mr", "Bob", surname="Roe Jr") _JOB_TITLE = _filed("Managing Partner", "Cy", surname="Doe") _SHAPE_TITLE = _filed("Insp.", "Di", surname="Fox") def _read(record: dict[str, str]) -> tuple[dict[str, str], bool, bool]: """The record's reading: the fields that disagree, whether it was set aside, whether the family agreed by the suffix.""" reading = agreement.read(record, _PSC_FIELDS, Parser(), _LISTED) return reading.got, reading.set_aside, reading.by_suffix def test_a_box_agrees_whatever_its_case_periods_and_spacing( monkeypatch: pytest.MonkeyPatch) -> None: assert agreement.fold(" Mr. ANN ") == "mr ann" upper = _filed("MR", "ANN", surname="LEE") upper["forename"] = "Ann." assert _read(upper)[0] == {} # The control: compared as written, the box disagrees. monkeypatch.setattr(agreement, "fold", lambda text: text) assert _read(upper)[0] == {"given": "ANN"} @pytest.mark.parametrize("surname", ["Roe, Jr.", "Roe,Jr.", "Roe, III", "Roe, Jr., PhD"]) def test_a_suffix_after_a_comma_in_the_surname_box_agrees( monkeypatch: pytest.MonkeyPatch, surname: str) -> None: # The parser reads "Bob Roe, Jr." as family Roe and suffix Jr., and # the surname box holds the two with the filer's comma between them. filed = _filed("", "Bob", surname=surname) assert _read(filed)[0] == {} # The control: the fold before commas were read as spaces misses it. monkeypatch.setattr(agreement, "fold", lambda text: " ".join(text.replace(".", "").casefold().split())) assert list(_read(filed)[0]) == ["family"] def test_a_two_word_surname_read_with_a_middle_name_is_two_misses() -> None: got = _read(_DOUBLE_SURNAME)[0] assert got == {"middle": "García", "family": "López"} assert agreement.shape(_DOUBLE_SURNAME, _PSC_FIELDS, got) == ( "middle 0->1, family 2->1") def test_a_peeled_suffix_is_compared_inside_the_surname_box() -> None: assert _read(_SUFFIX) == ({}, False, True) assert _read(_AGREES) == ({}, False, False) # The control: the parser's family alone leaves the box short. parsed = Parser().parse(_SUFFIX["name"]) assert (parsed.family, parsed.suffix) == ("Roe", "Jr") def test_a_title_box_holding_an_unlisted_word_the_parse_reads_otherwise_is_set_aside( monkeypatch: pytest.MonkeyPatch) -> None: got, set_aside, _ = _read(_JOB_TITLE) assert set_aside and "title" in got # An unlisted title the parse reads as the title, by its period, is # scored, and agrees. assert agreement.unlisted("Insp.", _LISTED) assert not agreement.unlisted("Mr. and MRS", _LISTED) # a connective is listed assert _read(_SHAPE_TITLE) == ({}, False, False) # The control: with every word listed, the job is three misses. monkeypatch.setattr(agreement, "unlisted", lambda title, listed: False) assert _read(_JOB_TITLE) == ( {"title": "", "given": "Managing", "middle": "Partner Cy"}, False, False) def _corpus(monkeypatch: pytest.MonkeyPatch, *records: dict[str, str]) -> None: """A PSC corpus of `records` fetched into the cache, no network behind it.""" # The sample pinned as the real source pins it: a --base child # loads the corpus under the real Source and its check. plant_corpus(monkeypatch, corpora, _PSC, list(records), "2026-10-22", "filed names", {"sample": f"1 in {importlib.import_module('local_psc').PSC_SAMPLE}"}) _ALL = (_AGREES, _DOUBLE_SURNAME, _SUFFIX, _JOB_TITLE, _SHAPE_TITLE) def test_the_report_counts_by_group_and_field_and_prints_no_name( monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(agreement, "GROUP_FLOOR", 1) monkeypatch.setattr(agreement, "TITLE_FLOOR", 1) result = agreement.score(list(_ALL), _PSC_FIELDS, Parser()) assert result.scored == {"British": 3, "Spanish": 1} assert result.agreed == {"British": 3} assert result.misses == {"Spanish": {"middle": 1, "family": 1}} assert result.set_aside == {"Managing Partner": 1} assert result.by_suffix == 1 assert set(result.entries) == {agreement.key(_DOUBLE_SURNAME), agreement.key(_JOB_TITLE)} lines = agreement.report(result, _PSC_FIELDS, 5) assert "every field agrees: 3 of 4 (75.0%); 1 of them with the parser's suffix" \ in "\n".join(lines) assert " British 3 100.0% 0 0 0 0" \ in lines names = {w for r in _ALL for k, v in r.items() if k in ("forename", "surname") for w in v.split()} printed = " ".join(lines).replace("Managing Partner", "") assert not {w for w in names if w in printed.split()} def test_a_stream_of_damaged_records_is_refused_once_read( monkeypatch: pytest.MonkeyPatch, _own_cache: Path) -> None: _corpus(monkeypatch, *_ALL) loaded = corpora.load(_PSC) assert len(list(agreement.records(loaded))) == len(_ALL) # the control raw = _own_cache / "local" / _PSC / "raw.jsonl" raw.write_text(raw.read_text().replace("Jo", "Al")) with pytest.raises(corpora.CorpusDamaged, match="does not match the sha256"): list(agreement.records(loaded)) def test_a_rerun_reports_what_moved_since_the_record( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: _corpus(monkeypatch, *_ALL) assert agreement.main([_PSC, "--record"]) == 0 assert agreement.main([_PSC]) == 0 # the control: nothing moved assert "matches the record" in capsys.readouterr().out # A parser that reads every name with its words reversed moves them all. real = Parser.parse monkeypatch.setattr(Parser, "parse", lambda self, text: real(self, " ".join(reversed(text.split())))) assert agreement.main([_PSC]) == 1 out = capsys.readouterr().out assert "regression: 3 records (" in out and "progress" not in out assert "changed: 2 records" in out def test_a_record_of_another_fetch_is_refused( monkeypatch: pytest.MonkeyPatch, _own_cache: Path) -> None: _corpus(monkeypatch, *_ALL) assert agreement.main([_PSC, "--record"]) == 0 _corpus(monkeypatch, *_ALL[:2]) with pytest.raises(SystemExit, match="recorded from another fetch"): agreement.main([_PSC]) path = _own_cache / "local" / _PSC / agreement.RECORD header = json.loads(path.read_text().splitlines()[0]) assert set(header) == {"raw_sha256", "scorer", "data_date", "tree", "entries"} def test_a_run_with_no_record_names_the_command_that_writes_one( monkeypatch: pytest.MonkeyPatch) -> None: _corpus(monkeypatch, *_ALL) with pytest.raises(SystemExit, match="--record --tree"): agreement.main([_PSC]) def test_a_set_aside_title_shared_by_too_few_records_is_not_printed( monkeypatch: pytest.MonkeyPatch) -> None: typed = _filed("Jo Bloggs", "Cy", surname="Doe") # a name in the title box result = agreement.score([typed] * 3 + [_JOB_TITLE], _PSC_FIELDS, Parser()) assert result.set_aside == {"Jo Bloggs": 3, "Managing Partner": 1} assert "Bloggs" not in "\n".join(agreement.report(result, _PSC_FIELDS, 5)) # The control: under a floor of 3 the box shared by 3 records is # printed, and the one typed once is not. monkeypatch.setattr(agreement, "TITLE_FLOOR", 3) printed = "\n".join(agreement.report(result, _PSC_FIELDS, 5)) assert "Jo Bloggs" in printed and "Managing" not in printed def test_a_group_below_the_floor_has_no_row_and_rows_run_by_size( monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(agreement, "GROUP_FLOOR", 2) rows = [_AGREES, _filed("Mr", "Bo", surname="Wu", nationality=" british "), _filed("Ms", "Di", surname="Fox", nationality="Irish"), _filed("Ms", "Al", surname="Ng", nationality="Irish"), _filed("Ms", "Ed", surname="Lin", nationality="Irish"), _DOUBLE_SURNAME, _filed("Mr", "Cy", surname="Roe", nationality="")] result = agreement.score(rows, _PSC_FIELDS, Parser()) assert result.scored == {"Irish": 3, "British": 2, "Spanish": 1, "(none)": 1} groups = [line.split()[0] for line in agreement.report(result, _PSC_FIELDS, 5) if line.startswith(" ") and line.split()[0] in result.scored] assert groups == ["Irish", "British"] def test_a_line_that_is_not_a_record_is_refused_as_damage( monkeypatch: pytest.MonkeyPatch, _own_cache: Path) -> None: _corpus(monkeypatch, *_ALL) loaded = corpora.load(_PSC) raw = _own_cache / "local" / _PSC / "raw.jsonl" for damage in ('{"name": ', '["a list"]', '{"name": "Al Ng", "surname": 1}'): lines = raw.read_text().splitlines(keepends=True) raw.write_text("".join(lines[:1] + [damage + "\n"] + lines[1:])) # Through score(), which reads each record before the stream ends. with pytest.raises(corpora.CorpusDamaged, match="fetch it again"): agreement.score(agreement.records(loaded), _PSC_FIELDS, Parser()) raw.write_text("".join(lines)) assert len(list(agreement.records(loaded))) == len(_ALL) # the control def test_a_record_holding_a_value_that_is_not_text_is_refused_as_damage( monkeypatch: pytest.MonkeyPatch, _own_cache: Path) -> None: # With its sha256 rewritten to match, as a fetch that wrote a number # into a box would leave it, only the stream's own check sees it, and # the scorer would otherwise fold a number as a name's box. _corpus(monkeypatch, *_ALL) folder = _own_cache / "local" / _PSC raw = folder / "raw.jsonl" body = raw.read_bytes() + b'{"name": "Al Ng", "surname": 1}\n' raw.write_bytes(body) meta = json.loads((folder / "meta.json").read_text()) meta["sha256"] = hashlib.sha256(body).hexdigest() (folder / "meta.json").write_text(json.dumps(meta)) loaded = corpora.load(_PSC) assert loaded is not None with pytest.raises(corpora.CorpusDamaged, match="not text.*fetch it again"): list(agreement.records(loaded)) @pytest.mark.parametrize("body", ["", "{not json\n", '["a list"]\n', '{"raw_sha256": "x"}\n{"got": {}}\n'], ids=["empty", "header not JSON", "header not an object", "a row without its key"]) def test_a_record_that_is_not_one_is_refused_naming_the_record_command( monkeypatch: pytest.MonkeyPatch, _own_cache: Path, body: str) -> None: _corpus(monkeypatch, *_ALL) assert agreement.main([_PSC, "--record"]) == 0 (_own_cache / "local" / _PSC / agreement.RECORD).write_text(body) with pytest.raises(SystemExit, match="is not a record this tool wrote.*--record"): agreement.main([_PSC]) def test_a_record_written_by_another_scorer_is_refused( monkeypatch: pytest.MonkeyPatch) -> None: _corpus(monkeypatch, *_ALL) assert agreement.main([_PSC, "--record"]) == 0 before = agreement.scorer_fingerprint(_PSC) # Each part of the code that decides a record's entry moves the # fingerprint: how a field is compared, which records get an entry, # how an entry is keyed, and the source's own boxes; another source's # boxes do not, so adding a source leaves this one's record standing. other = next(s for s in agreement.FIELDED if s != _PSC) with monkeypatch.context() as patch: patch.setitem(agreement.FIELDED, other, agreement.Fielded({"forename": "given", "surname": "family"}, group="x")) assert agreement.scorer_fingerprint(_PSC) == before, other patch.setitem(agreement.FIELDED, _PSC, agreement.Fielded({"forename": "given", "surname": "family"}, group="x")) assert agreement.scorer_fingerprint(_PSC) != before, "its own boxes" for name, changed in (("fold", lambda text: text.casefold()), ("score", lambda rows, fielded, parser: None), ("key", lambda record: str(record))): with monkeypatch.context() as patch: patch.setattr(agreement, name, changed) assert agreement.scorer_fingerprint(_PSC) != before, name with monkeypatch.context() as patch: patch.setattr(agreement.corpus_build, "jsonl_line", lambda obj, sort_keys=False: str(obj)) assert agreement.scorer_fingerprint(_PSC) != before, "jsonl_line" # fold() casefolds, so a Python whose Unicode database moves it reads # names differently; its moves would be the database's, not the parser's. with monkeypatch.context() as patch: patch.setattr(unicodedata, "unidata_version", "0.0.0") assert agreement.scorer_fingerprint(_PSC) != before, "unidata_version" assert agreement.scorer_fingerprint(_PSC) == before monkeypatch.setattr(agreement, "fold", lambda text: text.casefold()) with pytest.raises(SystemExit, match="another version of this scorer"): agreement.main([_PSC]) def test_progress_is_reported_where_a_disagreement_now_agrees( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: _corpus(monkeypatch, *_ALL) real = Parser.parse monkeypatch.setattr(Parser, "parse", lambda self, text: real( self, "Al Ng" if text == _AGREES["name"] else text)) assert agreement.main([_PSC, "--record"]) == 0 monkeypatch.setattr(Parser, "parse", real) assert agreement.main([_PSC, "--list", "1"]) == 1 out = capsys.readouterr().out assert "progress: 1 records (" in out and "regression" not in out assert f" progress: {agreement.key(_AGREES)}" in out def test_a_tree_that_is_not_the_imported_package_is_refused( monkeypatch: pytest.MonkeyPatch, _own_cache: Path, tmp_path: Path) -> None: _corpus(monkeypatch, *_ALL) monkeypatch.setattr(sys, "path", list(sys.path)) # main puts the tree on it with pytest.raises(SystemExit, match="not from the source package"): agreement.main([_PSC, "--record", "--tree", str(tmp_path)]) assert not (_own_cache / "local" / _PSC / agreement.RECORD).exists() # The control: this checkout itself is the tree it imported. root = agreement.HERE.parents[1] assert agreement.main([_PSC, "--record", "--tree", str(root)]) == 0 # -- --base ---------------------------------------------------------------------- def _kept(cache: Path) -> list[Path]: return sorted((cache / "local" / _PSC / agreement.KEPT).glob("*.jsonl")) def test_a_base_check_reports_a_change_planted_in_the_base_and_none_across_a_copy( monkeypatch: pytest.MonkeyPatch, _own_cache: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: # The harness must be able to report a difference: a base scored with # the head's tree by mistake would show no move across a planted one. _corpus(monkeypatch, *_ALL) planted = checkout_copy(tmp_path / "planted", planted=True) assert agreement.main([_PSC, "--base", str(planted)]) == 1 out = capsys.readouterr().out assert f"base: {planted.resolve()}, nameparser/ " in out and "scored it now" in out assert "progress: 3 records (" in out and "regression" not in out # The control: an unplanted copy is the head's package byte for byte. copy = checkout_copy(tmp_path / "copy") assert agreement.main([_PSC, "--base", str(copy)]) == 0 out = capsys.readouterr().out assert "matches the base" in out and "byte-identical" in out assert len(_kept(_own_cache)) == 2 def test_a_base_check_never_touches_the_shared_record( monkeypatch: pytest.MonkeyPatch, _own_cache: Path, tmp_path: Path) -> None: _corpus(monkeypatch, *_ALL) shared = _own_cache / "local" / _PSC / agreement.RECORD base = checkout_copy(tmp_path / "base", planted=True) assert agreement.main([_PSC, "--base", str(base)]) == 1 assert not shared.exists() assert agreement.main([_PSC, "--record"]) == 0 shared.write_text(shared.read_text().splitlines()[0] + "\n") # would read as moves before = (shared.stat().st_mtime_ns, shared.read_bytes()) assert agreement.main([_PSC, "--base", str(checkout_copy(tmp_path / "copy"))]) == 0 assert (shared.stat().st_mtime_ns, shared.read_bytes()) == before def test_a_kept_base_record_is_reused_and_a_damaged_one_scored_again( monkeypatch: pytest.MonkeyPatch, _own_cache: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: _corpus(monkeypatch, *_ALL) base = checkout_copy(tmp_path / "base", planted=True) assert agreement.main([_PSC, "--base", str(base)]) == 1 [kept] = _kept(_own_cache) recorded = kept.read_bytes() capsys.readouterr() real = agreement._score_base def unscored(*args: object) -> None: raise AssertionError("scored the base again") monkeypatch.setattr(agreement, "_score_base", unscored) assert agreement.main([_PSC, "--base", str(base)]) == 1 assert "reused the kept record" in capsys.readouterr().out # A record cut short on a line boundary still parses line by line; # its header's count is what refuses it. header = json.loads(recorded.decode().splitlines()[0]) uncounted = json.dumps({**header, "entries": None}) + "\n" + "".join( recorded.decode().splitlines(keepends=True)[1:]) for damage in ("not a record\n", "".join(recorded.decode().splitlines(keepends=True)[:-1]), uncounted): kept.write_text(damage) with pytest.raises(AssertionError, match="scored the base again"): agreement.main([_PSC, "--base", str(base)]) assert "deleted the kept record" in capsys.readouterr().out monkeypatch.setattr(agreement, "_score_base", real) assert agreement.main([_PSC, "--base", str(base)]) == 1 assert kept.read_bytes() == recorded assert not list(kept.parent.glob(".base-*")) # the child's scratch is gone monkeypatch.setattr(agreement, "_score_base", unscored) def test_a_base_that_changed_while_it_was_scored_keeps_no_record( monkeypatch: pytest.MonkeyPatch, _own_cache: Path, tmp_path: Path) -> None: _corpus(monkeypatch, *_ALL) base = checkout_copy(tmp_path / "base", planted=True) # The module base_entries imports by its bare name (local_agreement # put its folder on sys.path), not a second load by path. snapshot = importlib.import_module("snapshot") answers = ["a" * 64, "b" * 64] published: list[bool] = [] def digest(root: Path) -> str: if not answers: return "c" * 64 if len(answers) == 1: # the check after the score: nothing kept yet published.append(bool(_kept(_own_cache))) return answers.pop(0) monkeypatch.setattr(snapshot, "_source_digest", digest) with pytest.raises(SystemExit, match="changed while it was scored"): agreement.main([_PSC, "--base", str(base)]) # Written to the kept path before the check, a sibling could have # reused it as the base it was keyed for in between. assert published == [False] assert not _kept(_own_cache) assert not list((_own_cache / "local" / _PSC / agreement.KEPT).glob(".base-*")) def test_a_base_that_is_the_tree_read_or_no_checkout_is_refused( monkeypatch: pytest.MonkeyPatch, _own_cache: Path, tmp_path: Path) -> None: _corpus(monkeypatch, *_ALL) with pytest.raises(SystemExit, match="is the tree being read"): agreement.main([_PSC, "--base", str(agreement.HERE.parents[1])]) with pytest.raises(SystemExit, match="no nameparser package"): agreement.main([_PSC, "--base", str(tmp_path)]) assert not _kept(_own_cache) def test_a_base_whose_import_fails_is_refused_with_the_childs_output( monkeypatch: pytest.MonkeyPatch, _own_cache: Path, tmp_path: Path) -> None: _corpus(monkeypatch, *_ALL) base = checkout_copy(tmp_path / "base") with open(base / "nameparser" / "__init__.py", "a", encoding="utf-8") as f: f.write("\nraise ImportError('planted')\n") with pytest.raises(SystemExit, match="(?s)scoring the base .* failed.*planted"): agreement.main([_PSC, "--base", str(base)]) assert not _kept(_own_cache) def test_a_base_check_refuses_to_record( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: _corpus(monkeypatch, *_ALL) for extra in (["--record"], ["--out", "x.jsonl"]): with pytest.raises(SystemExit) as raised: agreement.main([_PSC, "--base", ".", *extra]) assert raised.value.code == 2 and "--base compares" in capsys.readouterr().err def test_record_writes_where_out_says_and_leaves_the_shared_record( monkeypatch: pytest.MonkeyPatch, _own_cache: Path, tmp_path: Path) -> None: _corpus(monkeypatch, *_ALL) out = tmp_path / "elsewhere.jsonl" assert agreement.main([_PSC, "--record", "--out", str(out)]) == 0 assert agreement.read_record(out, corpora.load(_PSC)) assert not (_own_cache / "local" / _PSC / agreement.RECORD).exists() # -- --jobs ---------------------------------------------------------------------- def test_a_score_read_in_workers_is_the_score_read_in_process( monkeypatch: pytest.MonkeyPatch) -> None: # Every decision's record, so a worker dropping one reads differently. serial = agreement.score(list(_ALL), _PSC_FIELDS, Parser()) monkeypatch.setattr(importlib.import_module("snapshot"), "PARALLEL_FLOOR", 1) assert agreement.score(list(_ALL), _PSC_FIELDS, Parser(), jobs=2) == serial assert serial.entries and serial.set_aside and serial.by_suffix def test_a_score_in_workers_refuses_a_parser_it_cannot_send_them() -> None: from nameparser import Lexicon configured = Parser(lexicon=Lexicon.default().add(titles={"partner"})) with pytest.raises(ValueError, match="default Parser"): agreement.score(list(_ALL), _PSC_FIELDS, configured, jobs=2) agreement.score(list(_ALL), _PSC_FIELDS, configured) # the control: in process def test_a_score_whose_worker_read_another_nameparser_aborts( monkeypatch: pytest.MonkeyPatch) -> None: import nameparser monkeypatch.setattr(importlib.import_module("snapshot"), "PARALLEL_FLOOR", 1) monkeypatch.setattr(nameparser, "__file__", "/elsewhere/nameparser/__init__.py") with pytest.raises(SystemExit, match="a worker imported nameparser from"): agreement.score(list(_ALL), _PSC_FIELDS, Parser(), jobs=2) # -- a source whose given box holds the middle names (orcid-researchers) ----- _ORCID_FIELDS = agreement.FIELDED[corpora.ORCID] def _held(given: str, family: str, country: str = "NZ") -> dict[str, str]: return {"name": f"{given} {family}".strip(), "given": given, "family": family, "country": country} def test_a_given_box_holding_the_middle_names_is_compared_with_both() -> None: # The parser reads Marie as a middle name; the box holds both words. agrees = agreement.read(_held("Ann Marie", "Lee"), _ORCID_FIELDS, Parser(), _LISTED) assert (agrees.got, agrees.set_aside) == ({}, False) # Off: the box compared with the given name alone misses. alone = agreement.Fielded({"given": "given", "family": "family"}, group="country") assert agreement.read(_held("Ann Marie", "Lee"), alone, Parser(), _LISTED).got == { "given": "Ann"} def test_a_source_with_no_title_box_scores_a_read_title_as_a_miss_not_filer_error() -> None: reading = agreement.read(_held("Dr Ann", "Lee"), _ORCID_FIELDS, Parser(), _LISTED) assert (reading.got, reading.set_aside) == ({"given+middle": "Ann"}, False) def test_a_peeled_suffix_is_compared_inside_the_family_box_of_a_joined_source() -> None: reading = agreement.read(_held("Bob", "Roe Jr"), _ORCID_FIELDS, Parser(), _LISTED) assert (reading.got, reading.by_suffix) == ({}, True) def test_a_country_code_group_prints_as_written(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(agreement, "GROUP_FLOOR", 1) result = agreement.score([_held("Ann", "Lee"), _held("Bo Yu", "Wu Li", "CN")], _ORCID_FIELDS, Parser()) assert result.scored == {"NZ": 1, "CN": 1} assert result.misses == {"CN": {"given+middle": 1, "family": 1}} assert result.shapes == {"given+middle 2->3, family 2->1": 1} def test_the_roles_a_box_may_name_are_the_parsers() -> None: from nameparser import Role assert agreement.ROLES == {role.value for role in Role} @pytest.mark.parametrize("fields", [ {"forename": "given", "surname": "famly"}, {"forename": "given+midle", "surname": "family"}, {"forename": "given"}]) def test_a_fielded_source_naming_an_unknown_role_or_no_family_is_refused( fields: dict[str, str]) -> None: with pytest.raises(ValueError, match="each box names roles of"): agreement.Fielded(fields, group="nationality") �����������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_local_corpora.py������������������������������������0000664�0000000�0000000�00000655460�15273032717�0024650�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Offline checks on tools/differential/local_corpora.py and its source modules (local_core.py and the local_<source>.py modules). A local corpus is fetched once from a live source and then trusted as the pin, so what these pin is the fetch's refusals (a fetch straddling the source's daily refresh, a fetch cut short or paging a study twice, a fetch with no names), the load's refusal of anything fetch() did not write, the retries a long run needs, and the views' counting. The failure that matters is a SMALLER corpus passing for the whole one: its counts read as real zeros. The network is a stub answering by URL. """ import csv import dataclasses import email.message import gzip import hashlib import http.client import importlib import io import json import os import re import shutil import sys import time import types import unicodedata import urllib.error import urllib.parse import urllib.request import zipfile from collections.abc import Callable from pathlib import Path from typing import Any import pytest from ._differential_fixtures import ( _Response, checkout_copy, load_tool, own_cache, plant_corpus, ) local = load_tool("local_corpora") snapshot = load_tool("snapshot") # The modules local_corpora imports, by name: the objects whose globals # the fetches read, so a patch reaches the code only when it is set on # the module holding the name (local_core.RETRIES, local_fec.FEC_MAX_BAD). # A second load by path (load_tool) would be a copy nothing reads. core, ctgov, fec, tse, tw, ukec, psc, jrc, nepal, orcid, patentsview = ( importlib.import_module(f"local_{key}") for key in ("core", "ctgov", "fec", "tse", "tw", "ukec", "psc", "jrc", "nepal", "orcid", "patentsview")) _SOURCE = "ctgov-officials" _DATE = "2026-10-21T09:00:05" @pytest.fixture(autouse=True) def _own_cache(monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory) -> Path: return own_cache(monkeypatch, tmp_path_factory) def _study(nct: str, *officials: str) -> dict[str, Any]: return {"protocolSection": { "identificationModule": {"nctId": nct}, "contactsLocationsModule": {"overallOfficials": [ {"name": name, "role": "PRINCIPAL_INVESTIGATOR"} for name in officials]}}} #: Two pages holding three studies, one study without officials. _PAGES: list[dict[str, Any]] = [ {"totalCount": 3, "nextPageToken": "t2", "studies": [_study("NCT1", "Ann Lee, MD, PhD", "BOB ROE MD"), {"protocolSection": {"identificationModule": {"nctId": "NCT2"}}}]}, {"studies": [_study("NCT3", "Ann Lee, MD, PhD", "Cy Doe,M.D.")]}, ] Answer = Exception | bytes | None def _serve(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, *, dates: tuple[str, ...] = (_DATE,) * 2, pages: list[dict[str, Any]] = _PAGES, fail: Callable[[str], Answer] = lambda url: None, sleeps: list[float] | None = None) -> list[str]: """Answer the API from `pages` by pageToken and /version from `dates` in turn. `fail` may answer a URL instead: an exception to raise, or a body to return. Returns the URLs fetched; `sleeps` collects every sleep asked for.""" monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) monkeypatch.setattr(time, "sleep", (sleeps if sleeps is not None else []).append) fetched: list[str] = [] versions = iter(dates) def urlopen(request: urllib.request.Request, timeout: int) -> _Response: url = request.full_url fetched.append(url) answer = fail(url) if isinstance(answer, Exception): raise answer if isinstance(answer, bytes): return _Response(answer) if url.endswith("/version"): body: object = {"apiVersion": "2.0.5", "dataTimestamp": next(versions)} else: token = urllib.parse.parse_qs(urllib.parse.urlsplit(url).query).get( "pageToken", [""])[0] body = pages[0] if not token else pages[int(token[1:]) - 1] return _Response(json.dumps(body).encode()) monkeypatch.setattr(urllib.request, "urlopen", urlopen) return fetched def _folder(tmp_path: Path) -> Path: return tmp_path / "local" / _SOURCE def _once(*answers: Answer) -> Callable[[str], Answer]: """Answer the second page's requests with `answers` in turn, then normally: a transient failure on one request of a long run.""" queue = list(answers) return lambda url: queue.pop(0) if "pageToken" in url and queue else None def _http_error(code: int, retry_after: str = "") -> urllib.error.HTTPError: headers = email.message.Message() if retry_after: headers["Retry-After"] = retry_after return urllib.error.HTTPError("u", code, "x", headers, None) # -- fetch --------------------------------------------------------------------- def test_a_fetch_pages_through_and_writes_the_three_files( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: fetched = _serve(monkeypatch, tmp_path) corpus = local.fetch(_SOURCE) assert [u.rsplit("/", 1)[-1].split("?")[0] for u in fetched] == [ "version", "studies", "studies", "version"] assert [r["name"] for r in corpus.records] == [ "Ann Lee, MD, PhD", "BOB ROE MD", "Ann Lee, MD, PhD", "Cy Doe,M.D."] assert corpus.records[0] == {"name": "Ann Lee, MD, PhD", "study": "NCT1", "role": "PRINCIPAL_INVESTIGATOR"} assert [json.loads(line) for line in (_folder(tmp_path) / "corpus.jsonl").read_text().splitlines()] == [ "Ann Lee, MD, PhD", "BOB ROE MD", "Cy Doe,M.D."] meta = json.loads((_folder(tmp_path) / "meta.json").read_text()) assert (meta["data_date"], meta["records"], meta["names"]) == (_DATE, 4, 3) assert meta["detail"] == {"api_version": "2.0.5", "studies": 3, "officials_without_a_name": 0} assert local.load(_SOURCE) == corpus assert corpus.header().startswith( f"ClinicalTrials.gov (NLM) study officials, data as of {_DATE}; 4 officials") def test_a_fetch_straddling_a_refresh_is_refused( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: _serve(monkeypatch, tmp_path, dates=(_DATE, "2026-10-22T09:00:04")) with pytest.raises(ValueError, match="refreshed during the fetch"): local.fetch(_SOURCE) assert local.load(_SOURCE) is None def test_a_fetch_cut_short_is_refused( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: # The second page lost: the API stops handing out tokens early. first = {**_PAGES[0]} del first["nextPageToken"] _serve(monkeypatch, tmp_path, pages=[first]) with pytest.raises(ValueError, match="paged 2 studies of the 3"): local.fetch(_SOURCE) assert local.load(_SOURCE) is None @pytest.mark.parametrize("total", [3, 2], ids=["and one missed", "and none missed"]) def test_a_fetch_paging_a_study_twice_is_refused( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, total: int) -> None: # NCT1 paged twice. With NCT3 missed, a count of the studies paged # equals totalCount; with none missed, a count of the DISTINCT studies # does (the first fix's check, which let the repeat's officials count # twice). Both rows, so neither check can come back alone. first = {**_PAGES[0], "totalCount": total} _serve(monkeypatch, tmp_path, pages=[ first, {"studies": [_study("NCT1", "Ann Lee, MD, PhD")]}]) with pytest.raises(ValueError, match="NCT1 was paged twice"): local.fetch(_SOURCE) @pytest.mark.parametrize("name", ["", " ", None, 3], ids=["empty", "blank", "null", "a number"]) def test_a_fetched_record_whose_name_is_empty_or_not_text_is_refused( monkeypatch: pytest.MonkeyPatch, name: object) -> None: # The corpus skips a second key's empty value (Source.corpus_keys); # an empty "name" is a fetch's own fault, refused rather than skipped. monkeypatch.setitem(local.SOURCES, _SOURCE, local.Source( "credit", "names", lambda: local.Fetched([{"name": "Ann Lee"}, {"name": name}], _DATE, {}))) with pytest.raises(ValueError, match="a fetched record has no name"): local.fetch(_SOURCE) def test_a_fetch_with_no_names_is_refused( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: # Every official nameless, as a renamed field would leave them: the # study count passes, and an empty corpus would read 0 for every word. nameless = {"totalCount": 1, "studies": [{"protocolSection": { "identificationModule": {"nctId": "NCT1"}, "contactsLocationsModule": {"overallOfficials": [{"role": "STUDY_CHAIR"}]}}}]} _serve(monkeypatch, tmp_path, pages=[nameless]) with pytest.raises(ValueError, match="found no names"): local.fetch(_SOURCE) assert local.load(_SOURCE) is None def test_an_official_without_a_name_is_counted_not_dropped_in_silence( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: page = {"totalCount": 1, "studies": [{"protocolSection": { "identificationModule": {"nctId": "NCT1"}, "contactsLocationsModule": {"overallOfficials": [ {"role": "STUDY_CHAIR"}, {"name": "Ann Lee, MD"}]}}}]} _serve(monkeypatch, tmp_path, pages=[page]) corpus = local.fetch(_SOURCE) assert [r["name"] for r in corpus.records] == ["Ann Lee, MD"] assert corpus.meta["detail"]["officials_without_a_name"] == 1 @pytest.mark.parametrize("answer", [ _http_error(503), _http_error(429), http.client.RemoteDisconnected("closed"), ConnectionResetError("reset"), http.client.IncompleteRead(b'{"stud'), b'{"studies": [', # a 200 whose body was cut short ], ids=["503", "429", "disconnected", "reset", "incomplete", "truncated body"]) def test_a_transient_failure_is_retried( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, answer: Answer) -> None: fetched = _serve(monkeypatch, tmp_path, fail=_once(answer)) local.fetch(_SOURCE) assert sum("pageToken" in u for u in fetched) == 2 def test_a_retry_says_so_on_stderr( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: _serve(monkeypatch, tmp_path, fail=_once(_http_error(503))) local.fetch(_SOURCE) assert "retrying in 1s after HTTPError" in capsys.readouterr().err def test_an_error_that_is_not_the_network_is_not_retried( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: # A bug, not a dropped connection: four attempts would hide it for # seconds and then raise it anyway. fetched = _serve(monkeypatch, tmp_path, fail=lambda url: ValueError("a bug") if "studies" in url else None) with pytest.raises(ValueError, match="a bug"): local.fetch(_SOURCE) assert sum("studies" in u for u in fetched) == 1 def test_a_rate_limit_waits_as_long_as_the_server_asks( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: sleeps: list[float] = [] _serve(monkeypatch, tmp_path, fail=_once(_http_error(429, "30")), sleeps=sleeps) local.fetch(_SOURCE) assert 30 in sleeps def test_a_client_error_is_raised_at_once( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: fetched = _serve(monkeypatch, tmp_path, fail=lambda url: _http_error(400) if "studies" in url else None) with pytest.raises(urllib.error.HTTPError) as raised: local.fetch(_SOURCE) raised.value.close() assert sum("studies" in u for u in fetched) == 1 def test_the_last_attempt_raises_its_own_error( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: fetched = _serve(monkeypatch, tmp_path, fail=lambda url: _http_error(503) if "studies" in url else None) with pytest.raises(urllib.error.HTTPError) as raised: local.fetch(_SOURCE) assert raised.value.code == 503 raised.value.close() assert sum("studies" in u for u in fetched) == core.RETRIES def test_a_source_detail_cannot_replace_what_load_checks( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) fetched = local.Fetched([{"name": "Ann Lee, MD"}], _DATE, {"records": 99, "sha256": "x", "version": 0}) monkeypatch.setitem(local.SOURCES, _SOURCE, local.Source( "credit", "officials", lambda: fetched)) local.fetch(_SOURCE) loaded = local.load(_SOURCE) assert loaded is not None and loaded.meta["records"] == 1 def test_looking_for_a_corpus_creates_no_folder( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: # name_usage.py looks on every run; a user who never fetched one # should not find empty folders in the cache. monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) assert local.load(_SOURCE) is None assert not (tmp_path / "local").exists() def test_an_unknown_source_names_the_known_ones() -> None: with pytest.raises(ValueError, match=_SOURCE): local.directory("nppes") with pytest.raises(ValueError, match=_SOURCE): local.fetch("nppes") # -- FEC individual contributions ------------------------------------------------ _FEC = "fec-individuals" _FEC_DATE = (2026, 10, 18, 16, 17, 0) def _fec_row(entity: str, name: str, columns: int = 21) -> str: cols = ["C00000001", "N", "Q3", "P2026", "1", "15", entity, name, "BRAINTREE", "MA", "02184", "SELF", "HOMEMAKER", "10132026", "100", "", "SA1", "1", "", "", "1"] return "|".join(cols[:columns]) #: Two people, one giving twice and once with the name padded, a #: committee, a person with no name, a line cut short and a line in #: Latin-1: 7 lines. _FEC_LINES = [ _fec_row("IND", "LEE, ANN"), _fec_row("IND", "ROE JR, BOB MR"), _fec_row("IND", " LEE, ANN "), _fec_row("PAC", "ACME PAC"), _fec_row("IND", " "), _fec_row("IND", "WU, BO", columns=20), ] _FEC_LATIN1 = _fec_row("IND", "PE\xd1A, JOS\xc9").encode("latin-1") def _fec_body(lines: list[str], *extra: bytes) -> bytes: return b"".join([*(line.encode() + b"\n" for line in lines), *(line + b"\n" for line in extra)]) def _fec_zip(path: Path, body: bytes | None = None, date: tuple[int, int, int, int, int, int] = _FEC_DATE) -> bytes: """A cycle's zip at `path`, holding the whole file and a by_date slice holding only its first line, stamped alike: a fetch reading the slice instead would lose names; its bytes.""" if body is None: body = _fec_body(_FEC_LINES, _FEC_LATIN1) path.parent.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(path, "w") as archive: archive.writestr(zipfile.ZipInfo("itcont.txt", date), body) archive.writestr(zipfile.ZipInfo("by_date/itcont_2026_slice.txt", date), body.split(b"\n", 1)[0] + b"\n") return path.read_bytes() class _Download: """A streamed download: read(n) hands out `body` in pieces, the headers stating `length` (none when it is None), and raises `fail` once the first piece is out.""" def __init__(self, body: bytes, length: int | None, fail: Exception | None = None) -> None: self.body, self.fail = body, fail self.headers = {} if length is None else {"Content-Length": str(length)} self.sent = False def __enter__(self) -> "_Download": return self def __exit__(self, *exc: object) -> None: return None def read(self, n: int) -> bytes: if self.sent and self.fail: raise self.fail chunk, self.body = self.body[:n], self.body[n:] self.sent = True return chunk def _fec_cache(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) monkeypatch.setattr(core, "CHUNK", 64) # several reads, not one return tmp_path / "local" / _FEC / fec.FEC_ZIP def _serve_fec(monkeypatch: pytest.MonkeyPatch, *responses: _Download) -> list[str]: """Answer each download with the next of `responses`; the URLs asked.""" queue, asked = list(responses), [] def urlopen(request: urllib.request.Request, timeout: int) -> _Download: asked.append(request.full_url) if not queue: pytest.fail(f"an unexpected download of {request.full_url}") return queue.pop(0) monkeypatch.setattr(urllib.request, "urlopen", urlopen) return asked def test_a_fec_fetch_keeps_each_person_s_name_once_and_counts_the_rest( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: _fec_zip(_fec_cache(monkeypatch, tmp_path)) _serve_fec(monkeypatch) # a cached zip is not downloaded again monkeypatch.setattr(fec, "FEC_MAX_BAD", 1) # a fixture of 7 lines corpus = local.fetch(_FEC) assert corpus.records == [{"name": "LEE, ANN"}, {"name": "PEÑA, JOSÉ"}, {"name": "ROE JR, BOB MR"}] assert corpus.meta["data_date"] == "2026-10-18T16:17:00" assert corpus.meta["detail"] == { "cycle": fec.FEC_CYCLE, "file": f"{fec.FEC_ZIP}/itcont.txt", "rows": 7, "individual_contributions": 5, "rows_from_other_entities": 1, "individuals_without_a_name": 1, "lines_not_21_columns": 1, "lines_not_utf8": 1} assert corpus.header().endswith("3 names (local corpus fec-individuals)") @pytest.mark.parametrize(("lines", "match"), [ ([_fec_row("IND", "LEE, ANN") + "|X"] * 2 + [_fec_row("IND", "WU, BO")], "2 of 3 lines .* are not 21 columns"), # The 22nd column dropped and one inserted before NAME: 21 columns, # entity in place, and NAME now holding the city. (["|".join(_fec_row("IND", "x").split("|")[:7] + ["BRAINTREE", "LEE, ANN"] + _fec_row("IND", "x").split("|")[8:20])], "0 of 1 names hold a comma"), ], ids=["lines not 21 columns", "a column moved under NAME"]) def test_a_fec_file_whose_layout_has_changed_is_refused( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, lines: list[str], match: str) -> None: _fec_zip(_fec_cache(monkeypatch, tmp_path), _fec_body(lines)) with pytest.raises(ValueError, match=match): local.fetch(_FEC) assert local.load(_FEC) is None def test_a_fec_file_mostly_not_utf8_is_refused( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: _fec_zip(_fec_cache(monkeypatch, tmp_path), _fec_body([_fec_row("IND", "LEE, ANN")], _FEC_LATIN1)) with pytest.raises(ValueError, match="1 of 2 lines .* are not UTF-8"): local.fetch(_FEC) @pytest.mark.parametrize("damage", ["not a zip", "no itcont.txt"]) def test_a_fec_zip_that_cannot_be_read_names_the_way_out( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, damage: str) -> None: path = _fec_cache(monkeypatch, tmp_path) path.parent.mkdir(parents=True) if damage == "not a zip": path.write_bytes(_fec_zip(tmp_path / "whole.zip")[:-40]) match = "fetch fec-individuals --refresh" else: with zipfile.ZipFile(path, "w") as archive: archive.writestr("itcont_renamed.txt", b"") match = "holds no itcont.txt" with pytest.raises(ValueError, match=match): local.fetch(_FEC) def test_a_fec_fetch_downloads_the_zip_when_it_is_missing( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: path = _fec_cache(monkeypatch, tmp_path) monkeypatch.setattr(fec, "FEC_MAX_BAD", 1) body = _fec_zip(tmp_path / "elsewhere.zip") stale = path.with_name("indiv24.zip") stale.parent.mkdir(parents=True) stale.write_bytes(b"the last cycle's zip") assert _serve_fec(monkeypatch, _Download(body, len(body))) == [] assert len(local.fetch(_FEC).records) == 3 assert path.read_bytes() == body assert not stale.exists() @pytest.mark.parametrize("stated", [10**6, -1, None], ids=["cut short", "longer than stated", "no length"]) def test_a_fec_download_that_does_not_match_its_length_is_refused( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, stated: int | None) -> None: path = _fec_cache(monkeypatch, tmp_path) body = _fec_zip(tmp_path / "elsewhere.zip") length = None if stated is None else len(body) - 1 if stated < 0 else stated _serve_fec(monkeypatch, _Download(body, length)) with pytest.raises(ValueError, match="fetch again"): local.fetch(_FEC) assert not path.exists() and not path.with_name(path.name + ".part").exists() assert local.load(_FEC) is None def test_a_fec_download_cut_off_mid_stream_leaves_no_part_file( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: path = _fec_cache(monkeypatch, tmp_path) body = _fec_zip(tmp_path / "elsewhere.zip") _serve_fec(monkeypatch, _Download(body, len(body), http.client.IncompleteRead(b""))) with pytest.raises(http.client.IncompleteRead): local.fetch(_FEC) assert not path.exists() and not path.with_name(path.name + ".part").exists() def test_a_fec_refresh_rebuilds_the_corpus_from_a_new_download( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: # Re-reading the cached zip would reproduce the same file under a # refresh, with the same names and data date. path = _fec_cache(monkeypatch, tmp_path) monkeypatch.setattr(fec, "FEC_MAX_BAD", 1) _fec_zip(path) newer = _fec_zip(tmp_path / "newer.zip", _fec_body([_fec_row("IND", "WU, BO")]), date=(2026, 10, 25, 9, 0, 0)) asked = _serve_fec(monkeypatch, _Download(newer, len(newer))) assert local.main(["fetch", _FEC]) == 0 assert asked == [] assert local.main(["fetch", _FEC, "--refresh"]) == 0 assert asked == [fec.FEC_URL] corpus = local.load(_FEC) assert corpus is not None and corpus.records == [{"name": "WU, BO"}] assert corpus.meta["data_date"] == "2026-10-25T09:00:00" def test_a_failed_fec_refresh_keeps_the_zip_and_the_corpus_it_had( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: path = _fec_cache(monkeypatch, tmp_path) monkeypatch.setattr(fec, "FEC_MAX_BAD", 1) cached = _fec_zip(path) _serve_fec(monkeypatch, _Download(cached[:100], len(cached))) before = local.fetch(_FEC) with pytest.raises(ValueError, match="fetch again"): local.main(["fetch", _FEC, "--refresh"]) assert path.read_bytes() == cached assert not path.with_name(path.name + ".part").exists() assert local.load(_FEC) == before def test_a_fec_corpus_of_another_cycle_is_refused_on_load( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: # Bumping FEC_CYCLE would otherwise label the cached cycle's names # with the new cycle's credit. _fec_zip(_fec_cache(monkeypatch, tmp_path)) monkeypatch.setattr(fec, "FEC_MAX_BAD", 1) local.fetch(_FEC) monkeypatch.setattr(fec, "FEC_CYCLE", "2028") with pytest.raises(ValueError, match=f"holds the 2026 cycle.*reads the 2028 cycle" f".*fetch {_FEC} --refresh"): local.load(_FEC) # -- TSE campaign donations ------------------------------------------------------ _TSE = "tse-donors" #: An invented CPF, which must reach no file the fetch writes, whether #: in its own column or typed into a name. _CPF = "52998224725" _TSE_HEADER = (["DT_GERACAO", "AA_ELEICAO", "DS_ORIGEM_RECEITA", "NR_CPF_CNPJ_DOADOR", "NM_DOADOR", "NM_DOADOR_RFB"] + [f"COL_{i}" for i in range(54)]) _PARTY = "Recursos de partido político" def _tse_row(origin: str, name: str, registry: str, columns: int = 60) -> list[str]: row = ["22/10/2026", "2022", origin, _CPF, name, registry] + ["x"] * 54 return row[:columns] def _tse_csv(rows: list[list[str]], header: list[str] = _TSE_HEADER, encoding: str = "cp1252") -> bytes: out = io.StringIO() writer = csv.writer(out, delimiter=";", quoting=csv.QUOTE_ALL, lineterminator="\r\n") writer.writerows([header, *rows]) return out.getvalue().encode(encoding) _PF = tse.TSE_INDIVIDUALS #: 2022: one donor twice (once padded), a donor whose filed name is #: not the registry's, a person with no name, a party, a row cut short #: and a filed name holding cp1252's right quote. 2024: the first donor #: again, the same filed name beside another registry name, a donor the #: registry has no name for, a blank name, a registry name holding the #: right quote, and three numbers typed into a name: the CPF bare, the #: CPF formatted, and five digits in a registry name. _TSE_ROWS = { "2022": [_tse_row(_PF, "ANA SOUZA", "ANA SOUZA"), _tse_row(_PF, " ANA SOUZA ", "ANA SOUZA"), _tse_row(_PF, "JOSE DA SILVA JR", "JOSÉ DA SILVA JUNIOR"), _tse_row(_PF, "#NULO", "#NULO"), _tse_row(_PARTY, "PARTIDO DO EXEMPLO", "PARTIDO DO EXEMPLO"), _tse_row(_PF, "RUI MOTA", "RUI MOTA", columns=59), _tse_row(_PF, "LIA D’AVILA", "LIA D AVILA")], "2024": [_tse_row(_PF, "ANA SOUZA", "ANA SOUZA"), _tse_row(_PF, "ANA SOUZA", "ANA MARIA SOUZA"), _tse_row(_PF, "BIA COSTA", "#NULO"), _tse_row(_PF, " ", "EVA LOPES"), _tse_row(_PF, "RUI DELIA", "RUI D’ELIA"), _tse_row(_PF, f"DOADOR {_CPF}", "DOADOR"), _tse_row(_PF, "ANA 529.982.247-25", "ANA"), _tse_row(_PF, "CAIO REIS", "CAIO REIS 12345")], } _TSE_RECORDS = [ {"name": "ANA SOUZA", "registry": "ANA MARIA SOUZA"}, {"name": "ANA SOUZA", "registry": "ANA SOUZA"}, {"name": "BIA COSTA", "registry": ""}, {"name": "JOSE DA SILVA JR", "registry": "JOSÉ DA SILVA JUNIOR"}, {"name": "LIA D’AVILA", "registry": "LIA D AVILA"}, {"name": "RUI DELIA", "registry": "RUI D’ELIA"}] _TSE_STAMPS = {"2022": (2026, 10, 18, 18, 10, 28), "2024": (2026, 10, 22, 2, 28, 38)} #: The bytes of a member no fetch should read: a fetch reading the #: whole zip would ask for them. _TSE_FILLER = bytes(range(256)) * 200 def _tse_zip(year: str, member: bytes | None = None, name: str | None = None, stored: bool = False) -> bytes: """A candidates' zip holding the national member, deflated as the TSE's are (stored where a test needs its bytes to stay many), a state member whose one donor the national member lacks (a fetch reading it would gain a name), and a member of _TSE_FILLER.""" member = _tse_csv(_TSE_ROWS[year]) if member is None else member out = io.BytesIO() with zipfile.ZipFile(out, "w") as archive: archive.writestr(zipfile.ZipInfo("leiame.pdf", _TSE_STAMPS[year]), _TSE_FILLER) archive.writestr(zipfile.ZipInfo(f"receitas_candidatos_{year}_BA.csv", _TSE_STAMPS[year]), _tse_csv([_tse_row(_PF, "SO NO ESTADO", "")]), compress_type=zipfile.ZIP_DEFLATED) archive.writestr(zipfile.ZipInfo(name or f"receitas_candidatos_{year}_BRASIL.csv", _TSE_STAMPS[year]), member, compress_type=zipfile.ZIP_STORED if stored else zipfile.ZIP_DEFLATED) return out.getvalue() def _url(year: str) -> str: return str(tse.TSE_URL.format(year=year)) class _Answer: def __init__(self, status: int, body: bytes, headers: dict[str, str]) -> None: self.status, self.body, self.headers = status, body, headers def __enter__(self) -> "_Answer": return self def __exit__(self, *exc: object) -> None: return None def read(self, n: int = -1) -> bytes: return self.body if n < 0 else self.body[:n] class _Cdn: """The CDN: HEAD states each zip's length, ETag (each zip its own) and byte ranges, and a Range request with a matching If-Range is answered in part with its Content-Range, any other whole, as a server answers for a file replaced since the HEAD.""" def __init__(self, zips: dict[str, bytes]) -> None: self.zips = {_url(y): body for y, body in zips.items()} self.etag = '"v1"' self.head: dict[str, str] | None = None # headers replacing HEAD's self.head_etag: str | None = None # the ETag HEAD states, "" for none self.last_modified = "" # the Last-Modified HEAD states, if any self.accept_ranges: str | None = "bytes" # None states none self.head_failures: list[Exception] = [] # raised by the next HEADs self.failures: list[Exception] = [] # raised by the next range requests self.short = 0 # range answers to cut a byte short self.long = False # range answers to send a byte past the range self.content_range: str | None = None # replacing the true one; "" for none self.replace_after: int | None = None # range requests before a new ETag self.replace_year = "" # the one zip replaced, or any self.replaced: set[str] = set() self.ranges = self.served = 0 def tag(self, url: str) -> str: """`url`'s validator: the zip's own ETag, or the shared date.""" if not self.etag.startswith('"'): return self.etag return f'"{url[-8:-4]}-{self.etag[1:]}' + ("-new" if url in self.replaced else "") def urlopen(self, request: urllib.request.Request, timeout: int) -> _Answer: url = request.full_url body = self.zips[url] if request.get_method() == "HEAD": if self.head_failures: raise self.head_failures.pop(0) if self.head is not None: return _Answer(200, b"", self.head) etag = self.tag(url) if self.head_etag is None else self.head_etag return _Answer(200, b"", {"Content-Length": str(len(body)), **({"Accept-Ranges": self.accept_ranges} if self.accept_ranges else {}), **({"ETag": etag} if etag else {}), **({"Last-Modified": self.last_modified} if self.last_modified else {})}) self.ranges += 1 if (self.replace_after is not None and self.ranges > self.replace_after and self.replace_year in url): self.replaced.add(url) if self.failures: raise self.failures.pop(0) if request.get_header("If-range") != self.tag(url): return _Answer(200, body, {}) start, end = map(int, str(request.get_header("Range"))[len("bytes="):].split("-")) part = body[start:end + 1] if self.short: self.short -= 1 part = part[:-1] if self.long: part += b"!" self.served += len(part) stated = (f"bytes {start}-{end}/{len(body)}" if self.content_range is None else self.content_range) return _Answer(206, part, {"Content-Range": stated} if stated else {}) @pytest.fixture def cdn(monkeypatch: pytest.MonkeyPatch) -> _Cdn: served = _Cdn({year: _tse_zip(year) for year in tse.TSE_ELECTIONS}) monkeypatch.setattr(urllib.request, "urlopen", served.urlopen) monkeypatch.setattr(tse, "TSE_BLOCK", 256) # many ranges, not one # A fixture of 15 rows holds more of each kind of damage than the # real file may: the tests of each limit set it back. monkeypatch.setattr(tse, "TSE_MAX_BAD", 1) monkeypatch.setattr(tse, "TSE_MAX_MISSING", 1) monkeypatch.setattr(tse, "TSE_MAX_NUMBERED", 1) monkeypatch.setattr(time, "sleep", lambda s: None) return served def _serve_member(cdn: _Cdn, year: str, member: bytes) -> None: cdn.zips[_url(year)] = _tse_zip(year, member) def test_a_tse_fetch_keeps_each_donor_s_pair_once_and_counts_the_rest(cdn: _Cdn) -> None: corpus = local.fetch(_TSE) assert corpus.records == _TSE_RECORDS assert corpus.meta["names"] == 5 # corpus.jsonl holds each filed name once assert corpus.meta["data_date"] == "2026-10-18T18:10:28 (2022), 2026-10-22T02:28:38 (2024)" assert corpus.meta["detail"] == { "elections": "2022 2024", "2022_rows": 7, "2022_rows_from_individuals": 5, "2022_records": 3, "2022_new_records": 3, "2022_rows_not_60_columns": 1, "2022_lines_folded_into_a_field": 0, "2022_individuals_without_a_name": 1, "2022_individuals_without_a_registry_name": 0, "2022_individuals_whose_name_holds_a_number": 0, "2022_rows_read_as_cp1252": 1, "2024_rows": 8, "2024_rows_from_individuals": 8, "2024_records": 4, "2024_new_records": 3, "2024_rows_not_60_columns": 0, "2024_lines_folded_into_a_field": 0, "2024_individuals_without_a_name": 1, "2024_individuals_without_a_registry_name": 1, "2024_individuals_whose_name_holds_a_number": 3, "2024_rows_read_as_cp1252": 1, "records_whose_name_differs_from_the_registry": 5} assert corpus.header().startswith("TSE (Tribunal Superior Eleitoral)") assert corpus.header().endswith("6 name pairs (local corpus tse-donors)") assert cdn.ranges > 4 # read in ranges, not in one answer assert cdn.served < len(_TSE_FILLER) # and only the ranges the member needs def test_a_tse_fetch_writes_no_column_but_the_two_names(cdn: _Cdn) -> None: local.fetch(_TSE) folder = local.directory(_TSE) for path in folder.iterdir(): text = path.read_text(encoding="utf-8") for leak in (_CPF, "529.982.247-25", "12345", "COL_", "x\""): assert leak not in text, (path.name, leak) corpus = local.load(_TSE) assert corpus is not None assert {key for r in corpus.records for key in r} == {"name", "registry"} def test_a_tse_file_s_columns_are_found_by_name(cdn: _Cdn) -> None: # The registry before the name and the origin last: read by position, # the names would swap and every category would be unknown. order = [0, 1, 5, 4] + list(range(6, 60)) + [3, 2] for year in tse.TSE_ELECTIONS: _serve_member(cdn, year, _tse_csv([[row[i] for i in order] if len(row) == 60 else row for row in _TSE_ROWS[year]], [_TSE_HEADER[i] for i in order])) assert local.fetch(_TSE).records == _TSE_RECORDS @pytest.mark.parametrize(("header", "match"), [ (_TSE_HEADER[:-1], "header is 59 columns, not 60"), ([c if c != "NM_DOADOR_RFB" else "NM_DOADOR_RECEITA" for c in _TSE_HEADER], "lacks NM_DOADOR_RFB"), ], ids=["a column dropped", "a column renamed"]) def test_a_tse_file_whose_header_has_changed_is_refused( cdn: _Cdn, header: list[str], match: str) -> None: _serve_member(cdn, "2024", _tse_csv(_TSE_ROWS["2024"], header)) with pytest.raises(ValueError, match=match): local.fetch(_TSE) assert local.load(_TSE) is None @pytest.mark.parametrize(("member", "match"), [ # The individuals' category renamed: read as one more category, the # election would have no individual donors. (_tse_csv([_tse_row("Recursos de Pessoas Físicas", "ANA SOUZA", "ANA SOUZA")] * 10), "1 DS_ORIGEM_RECEITA value.*'Recursos de Pessoas Físicas'"), # The file written in UTF-8: físicas no longer spells the category. (_tse_csv([_tse_row(_PF, "ANA SOUZA", "ANA SOUZA")] * 10, encoding="utf-8"), "1 DS_ORIGEM_RECEITA value.*'Recursos de pessoas fÃ"), ], ids=["a category renamed", "the file in UTF-8"]) def test_a_tse_file_whose_categories_have_changed_is_refused( cdn: _Cdn, member: bytes, match: str) -> None: _serve_member(cdn, "2022", member) with pytest.raises(ValueError, match=match): local.fetch(_TSE) def test_a_tse_category_held_by_a_few_rows_is_not_quoted(cdn: _Cdn) -> None: # A row misread into 60 columns can put a CPF where the origin was. _serve_member(cdn, "2022", _tse_csv( [_tse_row(_CPF, "ANA SOUZA", "ANA SOUZA")] * 9 + [_tse_row(_PF, "ANA", "ANA")])) with pytest.raises(ValueError, match="none held by enough rows") as raised: local.fetch(_TSE) assert _CPF not in str(raised.value) @pytest.mark.parametrize(("rows", "match"), [ ([], "0 of 0 rows .* are from individuals"), ([_tse_row("#NULO", "ANA SOUZA", "ANA SOUZA")] * 9 + [_tse_row(_PF, "ANA", "ANA")], "1 of 10 rows .* are from individuals"), ], ids=["the header alone", "the individuals moved to another category"]) def test_a_tse_election_with_too_few_individuals_is_refused( cdn: _Cdn, monkeypatch: pytest.MonkeyPatch, rows: list[list[str]], match: str) -> None: monkeypatch.setattr(tse, "TSE_MIN_INDIVIDUALS", 0.11) # 1 of 10 falls short _serve_member(cdn, "2024", _tse_csv(rows)) with pytest.raises(ValueError, match=match): local.fetch(_TSE) assert local.load(_TSE) is None @pytest.mark.parametrize(("name", "registry", "match"), [ ("#NULO", "ANA SOUZA", "carry no name"), ("ANA SOUZA", "", "carry no registry name"), ], ids=["names masked", "registry names masked"]) def test_a_tse_election_whose_names_are_masked_is_refused( cdn: _Cdn, monkeypatch: pytest.MonkeyPatch, name: str, registry: str, match: str) -> None: monkeypatch.setattr(tse, "TSE_MAX_MISSING", 1 / 100) _serve_member(cdn, "2022", _tse_csv([_tse_row(_PF, "ANA SOUZA", "ANA SOUZA")])) _serve_member(cdn, "2024", _tse_csv([_tse_row(_PF, name, registry)])) with pytest.raises(ValueError, match=f"1 of 1 individuals' rows .* {match}"): local.fetch(_TSE) @pytest.mark.parametrize(("member", "match"), [ (_tse_csv(_TSE_ROWS["2022"]), "1 rows not 60 columns in 7 rows"), (_tse_csv([_tse_row(_PF, "ANA", "ANA") + ["x"], _tse_row(_PF, "BO", "BO")]), "1 rows not 60 columns in 2 rows"), # A field holding a line break: two lines, one row. (_tse_csv([_tse_row(_PF, "ANA\r\nSOUZA", "ANA SOUZA"), _tse_row(_PF, "BO", "BO")]), "1 lines folded into another row's field in 2 rows"), ], ids=["a row short", "a row long", "a line folded into a field"]) def test_a_tse_file_with_too_many_malformed_lines_is_refused( cdn: _Cdn, monkeypatch: pytest.MonkeyPatch, member: bytes, match: str) -> None: monkeypatch.setattr(tse, "TSE_MAX_BAD", 1 / 10) _serve_member(cdn, "2022", member) with pytest.raises(ValueError, match=match): local.fetch(_TSE) def test_a_tse_zip_without_the_national_member_is_refused(cdn: _Cdn) -> None: cdn.zips[_url("2022")] = _tse_zip("2022", name="receitas_candidatos_2022_BRASIL_v2.csv") with pytest.raises(ValueError, match="holds no receitas_candidatos_2022_BRASIL.csv"): local.fetch(_TSE) def test_a_tse_answer_that_is_not_a_zip_names_the_way_out(cdn: _Cdn) -> None: cdn.zips[_url("2022")] = b"<html>" + b"x" * 494 # a CDN's error page with pytest.raises(ValueError, match="2022.zip is not a readable zip.*fetch again"): local.fetch(_TSE) @pytest.mark.parametrize(("year", "after"), [("2022", 0), ("2022", 6), ("2024", 0)], ids=["at the start", "inside the member", "the second zip"]) def test_a_tse_zip_replaced_during_the_read_is_refused( cdn: _Cdn, monkeypatch: pytest.MonkeyPatch, year: str, after: int) -> None: # The member stored and long, so that the seventh range falls inside # it, and its 20 repeats not refused for their few distinct names. monkeypatch.setattr(tse, "TSE_MIN_DISTINCT", 0) cdn.zips[_url("2022")] = _tse_zip("2022", _tse_csv(_TSE_ROWS["2022"] * 20), stored=True) cdn.replace_year, cdn.replace_after = year, after with pytest.raises(ValueError, match=f"{year}.zip answered a range request whole " r"\(HTTP 200\)"): local.fetch(_TSE) assert local.load(_TSE) is None @pytest.mark.parametrize(("content_range", "long"), [ ("bytes 0-9/10", False), ("", False), (None, True)], ids=["another range", "no Content-Range", "a byte too many"]) def test_a_tse_part_that_is_not_the_range_asked_for_is_refused( cdn: _Cdn, content_range: str | None, long: bool) -> None: cdn.content_range, cdn.long = content_range, long with pytest.raises(ValueError, match=r"answered bytes \d+-\d+ of .*fetch again"): local.fetch(_TSE) assert cdn.ranges == 1 # refused, not retried @pytest.mark.parametrize("head", [ {"ETag": '"v1"', "Accept-Ranges": "bytes"}, {"Content-Length": "100", "Accept-Ranges": "bytes"}, {"Content-Length": "100", "ETag": 'W/"v1"'}, {"Content-Length": "100", "ETag": '"v1"', "Accept-Ranges": "none"}, ], ids=["no length", "no validator", "a weak ETag alone", "ranges refused"]) def test_a_tse_zip_that_cannot_be_read_in_ranges_safely_is_refused( cdn: _Cdn, head: dict[str, str]) -> None: cdn.head = head with pytest.raises(ValueError, match="states no length, no strong ETag"): local.fetch(_TSE) @pytest.mark.parametrize("etag", ["", 'W/"v1"'], ids=["no ETag", "a weak ETag"]) def test_a_tse_zip_with_a_last_modified_date_is_read_by_it(cdn: _Cdn, etag: str) -> None: cdn.etag = cdn.last_modified = "Tue, 18 Oct 2026 18:25:05 GMT" cdn.head_etag = etag assert local.fetch(_TSE).records == _TSE_RECORDS def test_a_tse_strong_etag_is_preferred_to_the_last_modified_date(cdn: _Cdn) -> None: # The fake honours the ETag alone: If-Range sent with the date would # be answered whole. cdn.last_modified = "Tue, 18 Oct 2026 18:25:05 GMT" assert local.fetch(_TSE).records == _TSE_RECORDS def test_a_tse_request_cut_short_or_dropped_is_retried( cdn: _Cdn, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: monkeypatch.setattr(core, "RETRIES", 5) # four failures on one range cdn.short = 1 cdn.head_failures = [urllib.error.URLError("reset")] cdn.failures = [urllib.error.URLError("reset"), _http_error(503), _http_error(429)] assert local.fetch(_TSE).records == _TSE_RECORDS assert capsys.readouterr().err.count("retrying in") == 5 def test_a_tse_range_refused_by_the_server_is_raised_at_once(cdn: _Cdn) -> None: cdn.failures = [_http_error(403)] with pytest.raises(urllib.error.HTTPError) as raised: local.fetch(_TSE) raised.value.close() assert cdn.ranges == 1 def _letters(i: int) -> str: """An invented name word for row `i`, all letters: AAA, AAB, ...""" return "".join(chr(65 + i // 26 ** k % 26) for k in (2, 1, 0)) def _tse_names(n: int) -> list[list[str]]: """`n` donors, each a distinct invented name filed as registered.""" return [_tse_row(_PF, f"ANA {_letters(i)}", f"ANA {_letters(i)}") for i in range(n)] @pytest.mark.parametrize("column", ["name", "registry"]) @pytest.mark.parametrize("mask", ["#NE", "***", "-4", "#NULO#", _CPF]) def test_a_tse_column_masked_with_no_letter_is_refused( cdn: _Cdn, monkeypatch: pytest.MonkeyPatch, column: str, mask: str) -> None: monkeypatch.setattr(tse, "TSE_MAX_MISSING", 1 / 100) _serve_member(cdn, "2022", _tse_csv(_tse_names(4))) # 2022 within the limits rows = [_tse_row(_PF, mask if column == "name" else f"ANA {_letters(i)}", mask if column == "registry" else f"ANA {_letters(i)}") for i in range(20)] _serve_member(cdn, "2024", _tse_csv(rows)) with pytest.raises(ValueError, match=f"20 of 20 individuals' rows .* carry no " f"{'name' if column == 'name' else 'registry'}"): local.fetch(_TSE) assert local.load(_TSE) is None def test_a_tse_numeric_column_moved_under_a_name_is_refused( cdn: _Cdn, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(tse, "TSE_MAX_NUMBERED", 1 / 1000) _serve_member(cdn, "2022", _tse_csv(_tse_names(4))) _serve_member(cdn, "2024", _tse_csv( [_tse_row(_PF, f"ANA {_letters(i)}", f"CEP 401{i:05d}") for i in range(20)])) with pytest.raises(ValueError, match="20 of 20 individuals' rows .* a number in a name"): local.fetch(_TSE) @pytest.mark.parametrize("column", ["NM_DOADOR", "NM_DOADOR_RFB"]) def test_a_tse_column_masked_with_one_word_is_refused(cdn: _Cdn, column: str) -> None: rows = [_tse_row(_PF, "XXXXX" if column == "NM_DOADOR" else f"ANA {_letters(i)}", "XXXXX" if column == "NM_DOADOR_RFB" else f"ANA {_letters(i)}") for i in range(20)] _serve_member(cdn, "2024", _tse_csv(rows)) with pytest.raises(ValueError, match=f"20 individuals' rows hold 1 distinct {column}$" "|hold 1 distinct " + column): local.fetch(_TSE) def test_a_tse_election_at_each_limit_is_read( cdn: _Cdn, monkeypatch: pytest.MonkeyPatch) -> None: # 100 individuals' rows of 1,000: the floor exactly. One without a name # and one with a number: each ceiling exactly. 25 distinct names of # 100 rows: the distinct floor exactly. A registry name of 4 digits is # kept, a filed name of 5 is not. for name in ("TSE_MAX_MISSING", "TSE_MAX_NUMBERED"): monkeypatch.setattr(tse, name, 1 / 100) _serve_member(cdn, "2022", _tse_csv(_tse_names(4))) rows = ([_tse_row(_PARTY, "PARTIDO", "PARTIDO")] * 900 + [_tse_row(_PF, f"ANA {_letters(i % 25)}", f"ANA {_letters(i % 25)}") for i in range(97)] + [_tse_row(_PF, "#NULO", "ANA"), _tse_row(_PF, "ANA 12345", "ANA"), _tse_row(_PF, "ANA AAA", "ANA AAA 1234")]) _serve_member(cdn, "2024", _tse_csv(rows)) corpus = local.fetch(_TSE) assert {"name": "ANA AAA", "registry": "ANA AAA 1234"} in corpus.records assert not any("12345" in r["name"] for r in corpus.records) assert corpus.meta["detail"]["2024_individuals_whose_name_holds_a_number"] == 1 @pytest.mark.parametrize(("rows", "shown"), [ # A frequent donor's name, held by a tenth of the rows: not a category. ([_tse_row("ANA SOUZA", "ANA", "ANA")] * 10 + _tse_names(90), False), # A value holding digits, however many rows hold it. ([_tse_row(_CPF, "ANA", "ANA")] * 100, False), ([_tse_row("Recursos novos", "ANA", "ANA")] * 60 + _tse_names(40), True), ], ids=["a frequent name", "a number", "a category"]) def test_a_tse_unknown_value_is_quoted_only_when_it_reads_as_a_category( cdn: _Cdn, monkeypatch: pytest.MonkeyPatch, rows: list[list[str]], shown: bool) -> None: monkeypatch.setattr(tse, "TSE_QUOTED_SHARE", 1 / 2) _serve_member(cdn, "2022", _tse_csv(rows)) with pytest.raises(ValueError, match="outside TSE_ORIGINS") as raised: local.fetch(_TSE) message = str(raised.value) assert ("Recursos novos" in message) is shown assert "ANA SOUZA" not in message and _CPF not in message def test_a_tse_member_the_csv_reader_cannot_read_names_the_way_out(cdn: _Cdn) -> None: # A quote left open: the field runs past the csv module's limit. member = _tse_csv([]) + b'"x";"' + b"A" * 200_000 _serve_member(cdn, "2022", member) with pytest.raises(ValueError, match="receitas_candidatos_2022_BRASIL.csv in .* " "cannot be read \\(Error: .*fetch again"): local.fetch(_TSE) def test_a_tse_rate_limit_waits_as_long_as_the_server_asks( cdn: _Cdn, monkeypatch: pytest.MonkeyPatch) -> None: waits: list[float] = [] monkeypatch.setattr(time, "sleep", waits.append) cdn.failures = [_http_error(429, retry_after="30")] assert local.fetch(_TSE).records == _TSE_RECORDS assert waits == [30] def test_a_tse_range_failing_every_attempt_raises_the_last_error(cdn: _Cdn) -> None: cdn.failures = [_http_error(503) for _ in range(core.RETRIES)] with pytest.raises(urllib.error.HTTPError) as raised: local.fetch(_TSE) raised.value.close() assert cdn.ranges == core.RETRIES def test_a_tse_server_that_states_no_accept_ranges_is_read(cdn: _Cdn) -> None: cdn.accept_ranges = None # the header is optional; only "none" refuses assert local.fetch(_TSE).records == _TSE_RECORDS def test_a_tse_range_file_refuses_a_seek_before_its_start(cdn: _Cdn) -> None: reader = tse._RangeFile(_url("2022")) with pytest.raises(OSError, match="cannot seek"): reader.seek(-1) assert reader.tell() == 0 def test_a_tse_corpus_of_other_elections_is_refused_on_load( cdn: _Cdn, monkeypatch: pytest.MonkeyPatch) -> None: local.fetch(_TSE) monkeypatch.setattr(tse, "TSE_ELECTIONS", ("2022", "2024", "2026")) with pytest.raises(ValueError, match=f"holds the elections 2022 2024.*reads " f"2022 2024 2026.*fetch {_TSE} --refresh"): local.load(_TSE) # -- Taiwan political donations ------------------------------------------------ _TW = "tw-donors" _TW_ELECTION, _TW_AREA = "109年立法委員選舉", "臺北市" #: A masked ID, an address and a phone that must never reach the corpus. _TW_PRIVATE = ("A12****789", "臺北市中正區某某路1號", "02-2****678") #: An area's code in its zip's name, as the platform's names carry one. _TW_AREA_CODES = {"臺北市": "63000", "新北市": "65000"} _FIRST, _RESIDUAL = "首次申報", "第1次賸餘" def _tw_row(category: str, donor: str, serial: str = _FIRST, columns: int = 25) -> list[str]: row = ["1", "候選人甲", _TW_ELECTION, serial, "1081201", category, donor, _TW_PRIVATE[0], "1000", "0", "現金", "1081202", "", "", "", _TW_PRIVATE[1], _TW_PRIVATE[2], "", "", "", "", "", "", "", ""] return row[:columns] def _tw_csv(rows: list[list[str]], header: tuple[str, ...] | None = None) -> bytes: out = io.StringIO() writer = csv.writer(out, lineterminator="\r\n") writer.writerow(header or tw.TW_HEADER) writer.writerows(rows) return out.getvalue().encode("utf-8-sig") def _tw_zip(incomes: bytes | None, date: tuple[int, int, int, int, int, int] = (2023, 9, 12, 14, 4, 54)) -> bytes: """A report zip holding `incomes` as incomes.csv (none when it is None) beside an expenditures.csv, deflated as the platform's zips are.""" out = io.BytesIO() with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as archive: if incomes is not None: archive.writestr(zipfile.ZipInfo("incomes.csv", date, ), incomes, zipfile.ZIP_DEFLATED) archive.writestr(zipfile.ZipInfo("expenditures.csv", date), b"") return out.getvalue() def _tw_report(serial: int = 1, election: str = _TW_ELECTION, area: str = _TW_AREA, version: str = "112091204") -> dict[str, Any]: return {"type": "Election", "yearOrSerial": float(serial), "electionName": election, "electionArea": area, "zipFileName": f"election-{version}-{serial}-{_TW_AREA_CODES[area]}.zip"} def _tw_first(*rows: list[str]) -> dict[tuple[str, str, int], bytes]: """The zips of a listing of the first report alone, holding `rows`.""" return {(_TW_ELECTION, _TW_AREA, 1): _tw_zip(_tw_csv(list(rows)))} #: Two reports of one election: a donor in both, one padded with #: spaces, a business, an anonymous gift, and donations naming no one. #: The first report is the newer file, so the data date is not simply #: the last report's. _TW_ZIPS = { (_TW_ELECTION, _TW_AREA, 1): _tw_zip(_tw_csv([ _tw_row("個人捐贈收入", "王小明"), _tw_row("個人捐贈收入", " 陳大文 "), _tw_row("營利事業捐贈收入", "某某股份有限公司"), _tw_row("匿名捐贈", "匿名"), _tw_row("個人捐贈收入", " ")]), date=(2024, 3, 1, 9, 0, 0)), (_TW_ELECTION, _TW_AREA, 2): _tw_zip(_tw_csv([ _tw_row("個人捐贈收入", "王小明", _RESIDUAL), _tw_row("個人捐贈收入", "林一二", _RESIDUAL), _tw_row("個人捐贈收入", "匿名", _RESIDUAL), _tw_row("個人捐贈收入", "-", _RESIDUAL)])), } _TW_LISTING = [_tw_report(1), _tw_report(2)] TwAnswer = Exception | bytes def _serve_tw(monkeypatch: pytest.MonkeyPatch, *, listings: list[list[dict[str, Any]]] | None = None, zips: dict[tuple[str, str, int], bytes] | None = None, counted: int | None = None, fail: Callable[[str], TwAnswer | None] = lambda url: None) -> list[str]: """Answer the listing from `listings` in turn (the last one again once they run out), paged by page and pageSize and counted as `counted` (their length when None), and each download from `zips` by its election, area and serial. `fail` may answer a URL instead. The URLs asked.""" monkeypatch.setattr(time, "sleep", lambda s: None) listings = listings or [_TW_LISTING] zips = _TW_ZIPS if zips is None else zips asked: list[str] = [] turn = iter(listings) current: list[dict[str, Any]] = [] def urlopen(request: urllib.request.Request, timeout: int) -> _Response: url = request.full_url asked.append(url) if (answer := fail(url)) is not None: if isinstance(answer, Exception): raise answer return _Response(answer) query = {k: v[0] for k, v in urllib.parse.parse_qs( urllib.parse.urlsplit(url).query, keep_blank_values=True).items()} if "/search/elections" in url: if query["page"] == "1": current[:] = next(turn, current) size, page = int(query["pageSize"]), int(query["page"]) body: Any = {"paging": {"recordCount": len(current) if counted is None else counted}, "data": current[(page - 1) * size:page * size]} return _Response(json.dumps(body).encode()) return _Response(zips[(query["ElectionName"], query["ElectionArea"], int(query["YearOrSerial"]))]) monkeypatch.setattr(urllib.request, "urlopen", urlopen) return asked def _tw_downloads(monkeypatch: pytest.MonkeyPatch, listings: list[list[dict[str, Any]]] | None = None, zips: dict[tuple[str, str, int], bytes] | None = None) -> list[str]: """Fetch over _serve_tw; the serial of each report downloaded.""" asked = _serve_tw(monkeypatch, listings=listings, zips=zips) local.fetch(_TW) return _tw_serials(asked) def _tw_serials(asked: list[str]) -> list[str]: return [urllib.parse.parse_qs(urllib.parse.urlsplit(u).query)["YearOrSerial"][0] for u in asked if "/Search/download" in u] def _tw_cache(tmp_path: Path) -> Path: return tmp_path / "local" / _TW / "reports" @pytest.fixture def _tw_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) return tmp_path def test_a_tw_fetch_keeps_each_donor_s_name_once_and_counts_the_rest( monkeypatch: pytest.MonkeyPatch, _tw_home: Path) -> None: _serve_tw(monkeypatch) corpus = local.fetch(_TW) assert corpus.records == [{"name": "林一二"}, {"name": "王小明"}, {"name": "陳大文"}] assert corpus.meta["data_date"] == "2024-03-01T09:00:00" # the newer file's assert corpus.meta["detail"] == { "reports": 2, "first_reports": 1, "elections": 1, "rows": 9, "individual_donations": 7, "rows_from_other_categories": 2, "donations_without_a_name": 3, "donations_naming_a_masked_donor": 0, "rows_of_a_report_with_no_income": 0, "records_cut_back_into_their_lines": 0, "rows_not_as_long_as_the_header": 0, "rows_without_a_category": 0, "rows_cut_by_a_stray_quote": 0} assert corpus.header().startswith("Control Yuan political donation reports") assert "Open Government Data License, version 1.0" in corpus.header() assert corpus.header().endswith("3 names (local corpus tw-donors)") def test_a_tw_fetch_keeps_only_the_donor_column( monkeypatch: pytest.MonkeyPatch, _tw_home: Path) -> None: _serve_tw(monkeypatch) local.fetch(_TW) for name in ("raw.jsonl", "corpus.jsonl", "meta.json"): text = (_tw_home / "local" / _TW / name).read_text(encoding="utf-8") assert not [field for field in _TW_PRIVATE if field in text], name def test_a_tw_fetch_asks_for_each_listed_report_s_zip( monkeypatch: pytest.MonkeyPatch, _tw_home: Path) -> None: asked = _serve_tw(monkeypatch) local.fetch(_TW) downloads = [urllib.parse.parse_qs(urllib.parse.urlsplit(u).query, keep_blank_values=True) for u in asked if "/Search/download" in u] assert [(d["ElectionName"], d["ElectionArea"], d["YearOrSerial"], d["DownloadType"]) for d in downloads] == [([_TW_ELECTION], [_TW_AREA], [s], ["3"]) for s in ("1", "2")] def test_a_tw_fetch_pauses_after_each_download( monkeypatch: pytest.MonkeyPatch, _tw_home: Path) -> None: sleeps: list[float] = [] _serve_tw(monkeypatch) monkeypatch.setattr(time, "sleep", sleeps.append) local.fetch(_TW) assert sleeps.count(tw.TW_DELAY) == 2 and tw.TW_DELAY >= 1 def test_a_tw_listing_of_several_pages_is_read_whole( monkeypatch: pytest.MonkeyPatch, _tw_home: Path) -> None: monkeypatch.setattr(tw, "TW_PAGE", 1) _serve_tw(monkeypatch) assert local.fetch(_TW).meta["detail"]["reports"] == 2 @pytest.mark.timeout(30) # a listing loop that never stops hangs, not fails @pytest.mark.parametrize(("listing", "counted", "match"), [ (_TW_LISTING[:1], 2, "listed 1 reports of the 2"), ([_TW_LISTING[0], _TW_LISTING[0]], None, "listed twice"), ([_TW_LISTING[0], {**_TW_LISTING[1], "zipFileName": _TW_LISTING[0]["zipFileName"]}], None, "zip name .* another report has"), ([{**_TW_LISTING[0], "type": "Party"}], None, "type 'Party'"), ([{**_TW_LISTING[0], "yearOrSerial": 7.0}], None, "serial 7.0"), ], ids=["cut short", "a report twice", "two reports one zip", "not an election's", "an unknown serial"]) def test_a_tw_listing_that_cannot_be_trusted_is_refused( monkeypatch: pytest.MonkeyPatch, _tw_home: Path, listing: list[dict[str, Any]], counted: int | None, match: str) -> None: _serve_tw(monkeypatch, listings=[listing], counted=counted) with pytest.raises(ValueError, match=match): local.fetch(_TW) assert local.load(_TW) is None @pytest.mark.parametrize("changed", [ [*_TW_LISTING, _tw_report(1, area="新北市")], [_TW_LISTING[0], _tw_report(2, version="113010100")], _TW_LISTING[:1], ], ids=["a report published", "a report replaced", "a report withdrawn"]) def test_a_tw_fetch_whose_listing_moves_under_it_is_refused( monkeypatch: pytest.MonkeyPatch, _tw_home: Path, changed: list[dict[str, Any]]) -> None: _serve_tw(monkeypatch, listings=[_TW_LISTING, changed]) with pytest.raises(ValueError, match="published, replaced or withdrew a report"): local.fetch(_TW) assert local.load(_TW) is None _TW_RENAMED = tuple("捐贈者" if c == "捐贈者/支出對象" else c for c in tw.TW_HEADER) #: A column inserted before the category's, the last dropped: 25 #: columns, and the donor column now holding the category. _TW_SHIFTED = (*tw.TW_HEADER[:5], "新欄位", *tw.TW_HEADER[5:24]) #: The category column named twice, in place of a column not read. _TW_TWICE = tuple("收支科目" if c == "地址" else c for c in tw.TW_HEADER) @pytest.mark.parametrize(("incomes", "match"), [ (_tw_csv([_tw_row("個人捐贈收入", "王小明")], header=_TW_RENAMED), "has the header"), (_tw_csv([_tw_row("個人捐贈收入", "王小明")], header=_TW_SHIFTED), "has the header"), (_tw_csv([_tw_row("個人捐贈收入", "王小明")], header=_TW_TWICE), "has the header"), (_tw_csv([_tw_row("個人捐贈收入", "王小明")], header=tw.TW_HEADER[:5]), "has the header"), (_tw_csv([_tw_row("政治團體捐贈收入", "王小明")]), "the category '政治團體捐贈收入'"), (_tw_csv([_tw_row("個人捐贈收入", "王小明")]).decode("utf-8-sig").encode("big5"), "is not UTF-8"), ], ids=["a column renamed", "a column inserted", "a column twice", "a needed column missing", "an unknown category", "Big5"]) def test_a_tw_report_whose_layout_has_changed_is_refused( monkeypatch: pytest.MonkeyPatch, _tw_home: Path, incomes: bytes, match: str) -> None: _serve_tw(monkeypatch, listings=[_TW_LISTING[:1]], zips={(_TW_ELECTION, _TW_AREA, 1): _tw_zip(incomes)}) with pytest.raises(ValueError, match=match): local.fetch(_TW) assert local.load(_TW) is None @pytest.mark.parametrize("serial", [_FIRST, "第一次賸餘", _RESIDUAL]) def test_a_tw_zip_of_another_report_is_refused( monkeypatch: pytest.MonkeyPatch, _tw_home: Path, serial: str) -> None: # A server answering a residual report with the first report's zip, # or the reverse, would merge one report's donors into another's. listing = [_tw_report(3)] zips = {(_TW_ELECTION, _TW_AREA, 3): _tw_zip(_tw_csv([ _tw_row("個人捐贈收入", "王小明", serial)]))} _serve_tw(monkeypatch, listings=[listing], zips=dict(zips)) with pytest.raises(ValueError, match="the zip is another report's"): local.fetch(_TW) assert not _tw_cache(_tw_home).exists() # so a later fetch asks again # Its own serial, in either numerals, is read. for version, own in (("113010101", "第2次賸餘"), ("113010102", "第二次賸餘")): zips[(_TW_ELECTION, _TW_AREA, 3)] = _tw_zip(_tw_csv([ _tw_row("個人捐贈收入", "王小明", own)])) listing[0] = _tw_report(3, version=version) assert _tw_downloads(monkeypatch, listings=[listing], zips=zips) == ["3"], own def test_a_tw_report_missing_a_column_it_does_not_read_is_read_by_name( monkeypatch: pytest.MonkeyPatch, _tw_home: Path) -> None: # A 2020 by-election's report had no 更正註記, which a check of the # header against all 25 columns found on 2026-10-22; the column # after it moved up one, so a reader indexing TW_HEADER reads the # wrong one. The test reverses the header to move every column. header = tuple(reversed([c for c in tw.TW_HEADER if c != "更正註記"])) row = dict(zip(tw.TW_HEADER, _tw_row("個人捐贈收入", "王小明"))) zips = {(_TW_ELECTION, _TW_AREA, 1): _tw_zip(_tw_csv([[row[c] for c in header]], header=header))} _serve_tw(monkeypatch, listings=[_TW_LISTING[:1]], zips=zips) assert local.fetch(_TW).records == [{"name": "王小明"}] def test_a_tw_report_with_no_income_is_counted_and_read_past( monkeypatch: pytest.MonkeyPatch, _tw_home: Path) -> None: # Such a report writes one row, every column a dash but the # candidate's, as a by-election's report did on 2026-10-22. empty = ["-"] * 25 empty[1] = "候選人乙" zips = {**_TW_ZIPS, (_TW_ELECTION, _TW_AREA, 2): _tw_zip(_tw_csv([empty]))} _serve_tw(monkeypatch, zips=zips) corpus = local.fetch(_TW) assert corpus.records == [{"name": "王小明"}, {"name": "陳大文"}] assert corpus.meta["detail"]["rows_of_a_report_with_no_income"] == 1 assert corpus.meta["detail"]["rows"] == 6 def _dashed(**filled: str) -> list[str]: """A row of dashes but the candidate's column and `filled`.""" row: dict[str, str] = {**dict.fromkeys(tw.TW_HEADER, "-"), "擬參選人/政黨": "候選人乙", **filled} return [row[c] for c in tw.TW_HEADER] @pytest.mark.parametrize("row", [ _tw_row("-", "王小明"), _dashed(收入金額="1000"), ], ids=["a donor named", "an amount filled"]) def test_a_tw_dash_category_in_a_row_that_is_not_empty_is_refused( monkeypatch: pytest.MonkeyPatch, _tw_home: Path, row: list[str]) -> None: _serve_tw(monkeypatch, listings=[_TW_LISTING[:1]], zips=_tw_first(row)) with pytest.raises(ValueError, match="the category '-'"): local.fetch(_TW) @pytest.mark.parametrize(("bad", "key", "match"), [ (_tw_row("個人捐贈收入", "王小明", columns=24), "rows_not_as_long_as_the_header", "1 of 2 rows are not as long as"), (_tw_row("", "王小明"), "rows_without_a_category", "1 of 2 rows are without a category"), ], ids=["short rows", "rows without a category"]) def test_a_tw_fetch_with_too_many_bad_rows_is_refused( monkeypatch: pytest.MonkeyPatch, _tw_home: Path, bad: list[str], key: str, match: str) -> None: # The share is the whole fetch's: one bad row of two is half, which # the share for one report lets through. _serve_tw(monkeypatch, listings=[_TW_LISTING[:1]], zips=_tw_first(bad, _tw_row("個人捐贈收入", "陳大文"))) with pytest.raises(ValueError, match=match): local.fetch(_TW) monkeypatch.setattr(tw, "TW_MAX_BAD", 1) # under the share: counted corpus = local.fetch(_TW) assert corpus.records == [{"name": "陳大文"}] assert corpus.meta["detail"][key] == 1 def test_a_small_tw_report_whose_rows_are_all_bad_is_refused( monkeypatch: pytest.MonkeyPatch, _tw_home: Path) -> None: # Over the whole fetch its rows are a sliver; alone they are all of # it, and a report whose donors all go must not pass for read. big = [_tw_row("個人捐贈收入", f"王小{n}") for n in range(30)] short = [_tw_row("個人捐贈收入", f"陳大{n}", _RESIDUAL, columns=24) for n in range(3)] zips = {(_TW_ELECTION, _TW_AREA, 1): _tw_zip(_tw_csv(big)), (_TW_ELECTION, _TW_AREA, 2): _tw_zip(_tw_csv(short))} monkeypatch.setattr(tw, "TW_MAX_BAD", 1) _serve_tw(monkeypatch, zips=zips) with pytest.raises(ValueError, match="3 of .* report 2's rows are read past"): local.fetch(_TW) def _tw_incomes_text(lines: list[str]) -> bytes: return ("\r\n".join([",".join(tw.TW_HEADER), *lines]) + "\r\n").encode("utf-8-sig") def _line(donor: str, address: str = "臺北市", phone: str = "02") -> str: row = _tw_row("個人捐贈收入", donor) row[15], row[16] = address, phone return ",".join(row) def test_rows_a_stray_quote_takes_in_are_read_back( monkeypatch: pytest.MonkeyPatch, _tw_home: Path) -> None: # An address opening with a quote nothing closes until a later row: # the rows between, 169 in three reports on 2026-10-22, would go # into one field and their donors with them. lines = [_line("王小明", address='"臺北市'), _line("陳大文"), _line("林一二"), _line("趙六", phone='02"')] _serve_tw(monkeypatch, listings=[_TW_LISTING[:1]], zips={(_TW_ELECTION, _TW_AREA, 1): _tw_zip(_tw_incomes_text(lines))}) corpus = local.fetch(_TW) # every line whole with quotes taken literally assert len(corpus.records) == 4 assert corpus.meta["detail"]["records_cut_back_into_their_lines"] == 1 assert corpus.meta["detail"]["rows_cut_by_a_stray_quote"] == 0 # A line between holding a quoted comma is no whole row read # literally: the lines are read alone as quoted, the opening line cut. lines[2] = _line("林一二", phone='"02,03"') for cached in _tw_cache(_tw_home).iterdir(): cached.unlink() _serve_tw(monkeypatch, listings=[_TW_LISTING[:1]], zips={(_TW_ELECTION, _TW_AREA, 1): _tw_zip(_tw_incomes_text(lines))}) monkeypatch.setattr(tw, "TW_MAX_BAD", 1) corpus = local.fetch(_TW) assert {"name": "陳大文"} in corpus.records and {"name": "林一二"} in corpus.records assert corpus.meta["detail"]["rows_cut_by_a_stray_quote"] >= 1 monkeypatch.setattr(tw, "TW_MAX_BAD", 1 / 10_000) with pytest.raises(ValueError, match="rows are cut by a stray quote"): local.fetch(_TW) def test_two_stray_quotes_one_row_after_another_lose_neither_row( monkeypatch: pytest.MonkeyPatch, _tw_home: Path) -> None: # The second quote closes the first: one record as wide as the # header out of two rows, which no line read alone as quoted shows. lines = [_line("王小明", address='"臺北市'), _line("陳大文", address='"臺南市'), _line("林一二")] _serve_tw(monkeypatch, listings=[_TW_LISTING[:1]], zips={(_TW_ELECTION, _TW_AREA, 1): _tw_zip(_tw_incomes_text(lines))}) corpus = local.fetch(_TW) assert corpus.records == [{"name": "林一二"}, {"name": "王小明"}, {"name": "陳大文"}] assert corpus.meta["detail"]["records_cut_back_into_their_lines"] == 1 @pytest.mark.parametrize("ending", ["\r", "\r\n", "\n"], ids=["CR", "CRLF", "LF"]) def test_a_bare_cr_ends_a_line_as_csv_reads_one( monkeypatch: pytest.MonkeyPatch, _tw_home: Path, ending: str) -> None: # A stray CR in a cell cuts its row in two short rows, counted, as # csv.reader over a file opened with newline="" reads it, and a file # whose lines end in CR alone reads whole. text = ending.join([",".join(tw.TW_HEADER), _line("王小明"), _line("陳大文", address="臺北\r市"), _line("林一二")]) + ending _serve_tw(monkeypatch, listings=[_TW_LISTING[:1]], zips={(_TW_ELECTION, _TW_AREA, 1): _tw_zip(text.encode("utf-8-sig"))}) monkeypatch.setattr(tw, "TW_MAX_BAD", 1) corpus = local.fetch(_TW) assert corpus.records == [{"name": "林一二"}, {"name": "王小明"}] assert corpus.meta["detail"]["rows_not_as_long_as_the_header"] == 2 def test_a_tw_file_csv_cannot_read_is_refused_naming_the_report( monkeypatch: pytest.MonkeyPatch, _tw_home: Path) -> None: huge = _line("王小明", address='"' + "x" * 200_000) # past csv's field limit text = f"{','.join(tw.TW_HEADER)}\r\n{huge}\r\n" _serve_tw(monkeypatch, listings=[_TW_LISTING[:1]], zips={(_TW_ELECTION, _TW_AREA, 1): _tw_zip(text.encode("utf-8-sig"))}) with pytest.raises(ValueError, match="report 1's incomes.csv is not a CSV file"): local.fetch(_TW) def test_a_quoted_field_spanning_two_lines_stays_one_row( monkeypatch: pytest.MonkeyPatch, _tw_home: Path) -> None: # A quoted field that truly spans lines, its second line no row # (none was found on 2026-10-22: all 12 multi-line records were # rows a stray quote joined). lines = [_line("王小明", phone='"02\r\n09"'), _line("陳大文")] _serve_tw(monkeypatch, listings=[_TW_LISTING[:1]], zips={(_TW_ELECTION, _TW_AREA, 1): _tw_zip(_tw_incomes_text(lines))}) corpus = local.fetch(_TW) assert corpus.records == [{"name": "王小明"}, {"name": "陳大文"}] assert corpus.meta["detail"]["records_cut_back_into_their_lines"] == 0 def test_a_masked_donor_name_is_counted_and_refused_past_the_share( monkeypatch: pytest.MonkeyPatch, _tw_home: Path) -> None: # A platform that began masking names as it masks IDs would fill the # corpus with masked ones. _serve_tw(monkeypatch, listings=[_TW_LISTING[:1]], zips=_tw_first( _tw_row("個人捐贈收入", "王*明"), _tw_row("個人捐贈收入", "陳大文"))) with pytest.raises(ValueError, match="1 of 2 individual donations name a " "masked donor"): local.fetch(_TW) monkeypatch.setattr(tw, "TW_MAX_BAD", 1) corpus = local.fetch(_TW) assert corpus.records == [{"name": "陳大文"}] assert corpus.meta["detail"]["donations_naming_a_masked_donor"] == 1 def test_a_tw_zip_with_no_incomes_file_is_refused_and_not_cached( monkeypatch: pytest.MonkeyPatch, _tw_home: Path) -> None: asked = _serve_tw(monkeypatch, listings=[_TW_LISTING[:1]], zips={(_TW_ELECTION, _TW_AREA, 1): _tw_zip(None)}) with pytest.raises(ValueError, match="holds no incomes.csv"): local.fetch(_TW) assert _tw_serials(asked) == ["1"] # not retried assert not list(_tw_cache(_tw_home).glob("*.zip")) # nor kept to refuse again def _corrupt(body: bytes) -> bytes: """`body` with a byte of its deflated member flipped.""" at = body.index(b"incomes.csv") + 40 return body[:at] + bytes([body[at] ^ 0xFF]) + body[at + 1:] @pytest.mark.parametrize("damage", [lambda b: b[:-30], _corrupt], ids=["cut short", "a member that does not inflate"]) def test_a_damaged_tw_zip_is_fetched_again( monkeypatch: pytest.MonkeyPatch, _tw_home: Path, damage: Callable[[bytes], bytes]) -> None: bad = [damage(_TW_ZIPS[(_TW_ELECTION, _TW_AREA, 1)])] asked = _serve_tw(monkeypatch, fail=lambda url: bad.pop() if "/Search/download" in url and bad else None) assert len(local.fetch(_TW).records) == 3 assert _tw_serials(asked) == ["1", "1", "2"] def test_a_listed_tw_report_that_keeps_failing_is_raised( monkeypatch: pytest.MonkeyPatch, _tw_home: Path) -> None: # The platform answers a fault with the 500 it gives a report that # does not exist; the listing says this one does, so it is no absence. asked = _serve_tw(monkeypatch, fail=lambda url: _http_error(500) if "/Search/download" in url else None) with pytest.raises(urllib.error.HTTPError) as raised: local.fetch(_TW) raised.value.close() assert len(_tw_serials(asked)) == core.RETRIES assert local.load(_TW) is None def test_a_tw_refetch_downloads_only_the_reports_rebuilt_since( monkeypatch: pytest.MonkeyPatch, _tw_home: Path) -> None: # A refresh would otherwise download every listed report again. assert _tw_downloads(monkeypatch) == ["1", "2"] assert _tw_downloads(monkeypatch) == [] rebuilt = [_TW_LISTING[0], _tw_report(2, version="113010100")] zips = {**_TW_ZIPS, (_TW_ELECTION, _TW_AREA, 2): _tw_zip(_tw_csv([ _tw_row("個人捐贈收入", "趙六", _RESIDUAL)]))} assert _tw_downloads(monkeypatch, listings=[rebuilt], zips=zips) == ["2"] assert sorted(p.name for p in _tw_cache(_tw_home).iterdir()) == [ "election-112091204-1-63000.zip", "election-113010100-2-63000.zip"] corpus = local.load(_TW) assert corpus is not None and {"name": "趙六"} in corpus.records def test_a_refused_tw_fetch_keeps_its_zips_and_goes_on_where_it_stopped( monkeypatch: pytest.MonkeyPatch, _tw_home: Path) -> None: stale = _tw_cache(_tw_home) / "election-100000000-1-63000.zip" stale.parent.mkdir(parents=True) stale.write_bytes(b"a report the listing no longer names") asked = _serve_tw(monkeypatch, fail=lambda url: _http_error(500) if "YearOrSerial=2" in url else None) with pytest.raises(urllib.error.HTTPError) as raised: local.fetch(_TW) raised.value.close() assert stale.exists() # deleted only by a fetch that passes its checks assert _tw_serials(asked)[0] == "1" assert _tw_downloads(monkeypatch) == ["2"] assert not stale.exists() def test_a_tw_refresh_rebuilds_the_corpus_from_the_cached_zips( monkeypatch: pytest.MonkeyPatch, _tw_home: Path) -> None: asked = _serve_tw(monkeypatch) assert local.main(["fetch", _TW]) == 0 first = (_tw_home / "local" / _TW / "meta.json").read_bytes() assert local.main(["fetch", _TW, "--refresh"]) == 0 assert _tw_serials(asked) == ["1", "2"] # each downloaded once assert sum("/search/elections" in u for u in asked) == 4 # listed again corpus = local.load(_TW) assert corpus is not None and len(corpus.records) == 3 assert json.loads(first)["sha256"] == corpus.meta["sha256"] def test_a_cached_tw_zip_cut_short_is_downloaded_again( monkeypatch: pytest.MonkeyPatch, _tw_home: Path, capsys: pytest.CaptureFixture[str]) -> None: _tw_downloads(monkeypatch) cached = _tw_cache(_tw_home) / "election-112091204-1-63000.zip" cached.write_bytes(cached.read_bytes()[:-30]) assert _tw_downloads(monkeypatch) == ["1"] assert cached.read_bytes() == _TW_ZIPS[(_TW_ELECTION, _TW_AREA, 1)] assert "downloading again a cached zip that is not whole" in capsys.readouterr().err @pytest.mark.parametrize("name", ["../meta.json", "a/b.zip", "", None]) def test_a_tw_zip_name_that_is_no_file_name_is_refused( monkeypatch: pytest.MonkeyPatch, _tw_home: Path, name: str | None) -> None: # The name is the platform's, and the cache writes a file under it. _serve_tw(monkeypatch, listings=[[{**_TW_LISTING[0], "zipFileName": name}]]) with pytest.raises(ValueError, match="no file name this tool caches"): local.fetch(_TW) assert not _tw_cache(_tw_home).exists() # -- UK Electoral Commission donations --------------------------------------------- _UKEC = "ukec-individuals" #: 2026-10-23T03:13:19Z, as the search states it. _UKEC_GENERATED = "/Date(1792725199000)/" def _ukec_row(name: str, status: str = "Individual", donor_id: str = "1", fields: int = 29) -> list[str]: row = [""] * max(fields, 29) row[ukec.UKEC_COLUMNS.index("DonorName")] = name row[ukec.UKEC_COLUMNS.index("DonorStatus")] = status row[ukec.UKEC_COLUMNS.index("DonorId")] = donor_id return row[:fields] #: One person under two IDs and once with the name padded, a name holding #: a comma, a company, an individual with no name, a row of 28 fields and #: a person with no ID: 8 rows. _UKEC_ROWS = [ _ukec_row("Mr Bo Wu", donor_id="1"), _ukec_row("Mr Bo Wu", donor_id="2"), _ukec_row(" Mr Bo Wu ", donor_id="2"), _ukec_row("Lee, Ann", donor_id="3"), _ukec_row("Acme Ltd", status="Company", donor_id="4"), _ukec_row(" ", donor_id="5"), _ukec_row("Cy Roe", fields=28), _ukec_row("Dee Fox", donor_id=""), ] #: 2026-10-23T09:00:00Z: a second search's answer, later than the first. _UKEC_LATER = "/Date(1792746000000)/" def _ukec_csv(rows: list[list[str]] = _UKEC_ROWS, header: tuple[str, ...] | None = None, bom: bytes = b"\xef\xbb\xbf") -> bytes: out = io.StringIO(newline="") writer = csv.writer(out, lineterminator="\r\n") writer.writerow(ukec.UKEC_COLUMNS if header is None else header) writer.writerows(rows) return bom + out.getvalue().encode() def _serve_ukec(monkeypatch: pytest.MonkeyPatch, body: bytes, *totals: int) -> list[str]: """Answer the export with `body` and the search with each of `totals` in turn (the rows of `body` twice, by default), the first answer generated at _UKEC_GENERATED and the second at _UKEC_LATER; the URLs asked.""" counts = iter(totals or (body.count(b"\r\n") - 1,) * 2) stamps = iter([_UKEC_GENERATED, _UKEC_LATER]) asked: list[str] = [] def urlopen(request: urllib.request.Request, timeout: int) -> _Response: asked.append(request.full_url) if request.full_url == ukec.UKEC_CSV_URL: return _Response(body) assert request.full_url == ukec.UKEC_COUNT_URL return _Response(json.dumps({"Total": next(counts), "Generated": next(stamps)}).encode()) monkeypatch.setattr(urllib.request, "urlopen", urlopen) return asked def _tolerate_a_small_fixture(monkeypatch: pytest.MonkeyPatch) -> None: """Let _UKEC_ROWS, whose 8 rows skip one of each kind, pass the shares.""" monkeypatch.setattr(ukec, "UKEC_MAX_BAD", 1) monkeypatch.setattr(ukec, "UKEC_MAX_NAMELESS", 1) def _failing_once(monkeypatch: pytest.MonkeyPatch, url: str) -> list[str]: """Make the stubbed urlopen raise a 503 the first time `url` is asked, with no sleep between attempts; the URLs that failed.""" served: Callable[..., object] = urllib.request.urlopen failed: list[str] = [] def flaky(request: urllib.request.Request, timeout: int) -> object: if request.full_url == url and not failed: failed.append(url) raise _http_error(503) return served(request, timeout) monkeypatch.setattr(urllib.request, "urlopen", flaky) monkeypatch.setattr(time, "sleep", lambda seconds: None) return failed @pytest.mark.parametrize("which", ["UKEC_COUNT_URL", "UKEC_CSV_URL"]) def test_a_ukec_request_failing_once_is_retried( monkeypatch: pytest.MonkeyPatch, which: str) -> None: # The register's search and export once went out bare: one 503 in # either ended the fetch, where every other source retries. _tolerate_a_small_fixture(monkeypatch) _serve_ukec(monkeypatch, _ukec_csv()) failed = _failing_once(monkeypatch, getattr(ukec, which)) assert local.fetch(_UKEC).records assert failed == [getattr(ukec, which)] def test_a_ukec_search_answering_no_json_is_refused_as_a_value_error( monkeypatch: pytest.MonkeyPatch) -> None: # An error page served with HTTP 200 is retried as a body cut short, # and once the retries run out the fetch must still fail as a # ValueError, as the other refusals do, not as the retry's own class. monkeypatch.setattr(time, "sleep", lambda seconds: None) asked: list[str] = [] def urlopen(request: urllib.request.Request, timeout: int) -> _Response: asked.append(request.full_url) return _Response(b"<html>Service Unavailable</html>") monkeypatch.setattr(urllib.request, "urlopen", urlopen) with pytest.raises(ValueError, match="is not JSON"): local.fetch(_UKEC) assert asked == [ukec.UKEC_COUNT_URL] * core.RETRIES def test_the_ukec_count_asks_the_export_s_own_filter() -> None: # A count under another filter would vouch for an export it did not count. def query(url: str) -> list[tuple[str, str]]: return urllib.parse.parse_qsl(urllib.parse.urlsplit(url).query, keep_blank_values=True) csv_query, count_query = query(ukec.UKEC_CSV_URL), query(ukec.UKEC_COUNT_URL) assert ("donorStatus", "individual") in csv_query assert [p for p in csv_query if p[0] != "rows"] == [ p for p in count_query if p[0] != "rows"] def test_a_ukec_fetch_keeps_each_person_s_name_once_and_counts_the_rest( monkeypatch: pytest.MonkeyPatch) -> None: _tolerate_a_small_fixture(monkeypatch) asked = _serve_ukec(monkeypatch, _ukec_csv()) corpus = local.fetch(_UKEC) assert asked == [ukec.UKEC_COUNT_URL, ukec.UKEC_CSV_URL, ukec.UKEC_COUNT_URL] assert corpus.records == [{"name": "Dee Fox"}, {"name": "Lee, Ann"}, {"name": "Mr Bo Wu"}] # The first search's time, not the second's. assert corpus.meta["data_date"] == "2026-10-23T03:13:19Z" assert corpus.meta["detail"] == { "donations": 8, "donor_ids": 4, "individuals_without_a_name": 1, "rows_not_29_fields": 1, "rows_not_individual": 1} assert corpus.header().endswith("3 names (local corpus ukec-individuals)") def test_a_ukec_export_without_a_bom_reads_the_same( monkeypatch: pytest.MonkeyPatch) -> None: _tolerate_a_small_fixture(monkeypatch) assert ukec._ukec_read(_ukec_csv(bom=b""), 8) == ukec._ukec_read(_ukec_csv(), 8) @pytest.mark.parametrize("header", [ tuple("DonorFullName" if c == "DonorName" else c for c in ukec.UKEC_COLUMNS), (*ukec.UKEC_COLUMNS, "DonorTitle"), ukec.UKEC_COLUMNS[:-1], ], ids=["a column renamed", "a column added", "a column dropped"]) def test_a_ukec_export_whose_header_has_changed_is_refused( monkeypatch: pytest.MonkeyPatch, header: tuple[str, ...]) -> None: _serve_ukec(monkeypatch, _ukec_csv([_ukec_row("Mr Bo Wu")], header=header)) with pytest.raises(ValueError, match="header is not the 29 columns"): local.fetch(_UKEC) assert local.load(_UKEC) is None @pytest.mark.parametrize(("totals", "match"), [ ((9, 9), "held 8 donations where the search counts 9"), ((7, 7), "held 8 donations where the search counts 7"), ((8, 9), r"register changed during the fetch \(8 -> 9"), ], ids=["a capped or cut export", "an export holding more", "a register that grew"]) def test_a_ukec_export_that_is_not_the_whole_register_is_refused( monkeypatch: pytest.MonkeyPatch, totals: tuple[int, int], match: str) -> None: _tolerate_a_small_fixture(monkeypatch) _serve_ukec(monkeypatch, _ukec_csv(), *totals) with pytest.raises(ValueError, match=match): local.fetch(_UKEC) assert local.load(_UKEC) is None @pytest.mark.parametrize(("rows", "match"), [ ([_ukec_row("Acme Ltd", status="Company")] * 2 + [_ukec_row("Mr Bo Wu")], "2 of 3 rows .* from a donor that is not 'Individual'"), ([_ukec_row("Mr Bo Wu", fields=30)] * 2 + [_ukec_row("Cy Roe")], "2 of 3 rows .* not 29 fields"), ], ids=["the filter no longer applied", "rows not 29 fields"]) def test_a_ukec_export_holding_rows_it_cannot_read_is_refused( monkeypatch: pytest.MonkeyPatch, rows: list[list[str]], match: str) -> None: _serve_ukec(monkeypatch, _ukec_csv(rows)) with pytest.raises(ValueError, match=match): local.fetch(_UKEC) assert local.load(_UKEC) is None #: Rows enough that UKEC_MAX_BAD's real share (1 in 10,000) is a row. _UKEC_MANY = 10_000 @pytest.mark.parametrize(("bad", "kind", "match"), [ (_ukec_row("Cy Roe", fields=28), "rows_not_29_fields", "not 29 fields"), (_ukec_row("Acme Ltd", status="Company"), "rows_not_individual", "not 'Individual'"), ], ids=["rows not 29 fields", "rows not individual"]) @pytest.mark.parametrize("skipped", [1, 2]) def test_a_ukec_export_is_refused_past_its_real_share_of_skipped_rows( monkeypatch: pytest.MonkeyPatch, bad: list[str], kind: str, match: str, skipped: int) -> None: # One skipped row in 10,000 passes and two are refused, at the # shipped UKEC_MAX_BAD, unpatched; each arm alone. rows = [bad] * skipped + [_ukec_row(f"Bo Wu {i}") for i in range(_UKEC_MANY - skipped)] _serve_ukec(monkeypatch, _ukec_csv(rows)) if skipped == 1: assert local.fetch(_UKEC).meta["detail"][kind] == 1 else: with pytest.raises(ValueError, match=f"2 of 10,000 rows .* {match}"): local.fetch(_UKEC) @pytest.mark.parametrize("nameless", [10, 11]) def test_a_ukec_export_is_refused_past_its_share_of_nameless_individuals( monkeypatch: pytest.MonkeyPatch, nameless: int) -> None: # A DonorName column emptied wholesale would otherwise read as a # register of a few names: 10 in 10,000 pass at UKEC_MAX_NAMELESS. rows = [_ukec_row(" ")] * nameless + [ _ukec_row(f"Bo Wu {i}") for i in range(_UKEC_MANY - nameless)] _serve_ukec(monkeypatch, _ukec_csv(rows)) if nameless == 10: assert local.fetch(_UKEC).meta["detail"]["individuals_without_a_name"] == 10 else: with pytest.raises(ValueError, match="11 of 10,000 rows .* no name"): local.fetch(_UKEC) @pytest.mark.parametrize("cut", [ lambda body: body[:-5], lambda body: body[:body.rindex(b"Wu 9999") + 2], lambda body: body[:-2], lambda body: body[:body.rindex(b"Wu\n") + 3], ], ids=["inside the last row", "inside a quoted field", "before the last line break", "at a line break inside a quoted field"]) def test_a_ukec_export_cut_inside_its_last_row_is_refused( monkeypatch: pytest.MonkeyPatch, cut: Callable[[bytes], bytes]) -> None: # csv returns the cut row as a short last row, so the count matches # the search's and the row would pass as one of the few malformed # ones UKEC_MAX_BAD allows, losing its name: at the real share. rows = [_ukec_row(f"Bo Wu {i}") for i in range(_UKEC_MANY - 1)] # A field holding line breaks, which the export quotes, so that one # cut ends at a line break and still inside the row. rows.append(_ukec_row('Bo "Wu 9999"', donor_id="Bo\nWu\nId")) body = _ukec_csv(rows) _serve_ukec(monkeypatch, cut(body), _UKEC_MANY, _UKEC_MANY) with pytest.raises(ValueError, match="last row is cut short"): local.fetch(_UKEC) assert local.load(_UKEC) is None def test_a_ukec_export_ending_in_a_long_row_is_not_taken_for_a_cut_one( monkeypatch: pytest.MonkeyPatch) -> None: # A cut only shortens a row, so a whole export whose last row is one # of the malformed few UKEC_MAX_BAD tolerates is read, not refused # as cut: a refetch would return the same export. rows = [_ukec_row(f"Bo Wu {i}") for i in range(_UKEC_MANY - 1)] rows.append(_ukec_row("Cy Roe", fields=30)) _serve_ukec(monkeypatch, _ukec_csv(rows)) assert local.fetch(_UKEC).meta["detail"]["rows_not_29_fields"] == 1 def test_a_ukec_export_ending_in_a_blank_line_is_refused( monkeypatch: pytest.MonkeyPatch) -> None: rows = [_ukec_row(f"Bo Wu {i}") for i in range(_UKEC_MANY)] _serve_ukec(monkeypatch, _ukec_csv(rows) + b"\r\n", _UKEC_MANY, _UKEC_MANY) with pytest.raises(ValueError, match="cut short or blank"): local.fetch(_UKEC) def test_a_ukec_export_that_is_not_utf8_is_refused( monkeypatch: pytest.MonkeyPatch) -> None: body = _ukec_csv([_ukec_row("Mr Bo Wu")]).replace(b"Wu", b"W\xfc") _serve_ukec(monkeypatch, body) with pytest.raises(ValueError, match="not UTF-8"): local.fetch(_UKEC) assert local.load(_UKEC) is None def test_a_ukec_export_csv_cannot_read_is_refused_as_a_value_error( monkeypatch: pytest.MonkeyPatch) -> None: # csv.Error is no ValueError, so the CLI would show a traceback. limit = csv.field_size_limit() csv.field_size_limit(20) try: _serve_ukec(monkeypatch, _ukec_csv([_ukec_row("Mr Bo Wu " * 5)])) with pytest.raises(ValueError, match="not CSV this reads"): local.fetch(_UKEC) finally: csv.field_size_limit(limit) @pytest.mark.parametrize("answer", [ {"Generated": _UKEC_GENERATED}, {"Total": "7", "Generated": _UKEC_GENERATED}, {"Total": 7}, [], {"Total": True, "Generated": _UKEC_GENERATED}, ], ids=["no Total", "a Total not a number", "no Generated", "not an object", "a Total that is a boolean"]) def test_a_ukec_search_that_states_no_count_is_refused( monkeypatch: pytest.MonkeyPatch, answer: object) -> None: def urlopen(request: urllib.request.Request, timeout: int) -> _Response: return _Response(json.dumps(answer).encode()) monkeypatch.setattr(urllib.request, "urlopen", urlopen) with pytest.raises(ValueError, match="states no"): local.fetch(_UKEC) # -- Nepal's local election candidates ------------------------------------------- _NEPAL = "nepal-candidates" _NEPAL_TOKEN = "a" * 32 def _nepal_row(romanized: object, devanagari: object = "राम बहादुर", **extra: object ) -> dict[str, object]: """A candidate as a local body's file writes one, every name invented.""" return {"CandidateName": devanagari, "CandidateNameEng": romanized, "Gender": "पुरुष", "Age": 40, "Ward": 1, "PostId": 6, "CandidateID": 1, "PartyID": 2, "SymbolID": 3, "TotalVoteReceived": 99, **extra} #: Three bodies: a pair twice in one body (once padded) and again in #: another, the same Devanagari under a second romanization, a candidate #: with no Devanagari name, and an empty file. _NEPAL_FILES: dict[int, object] = { 101: [_nepal_row("Dil Maya B.K.", "दिल माया वि.क."), _nepal_row("Sita Kumari K.C.", "सीता कुमारी के.सी."), _nepal_row(" Dil Maya B.K. ", "दिल माया वि.क. "), _nepal_row("Hari Thapa", "")], 102: [_nepal_row("Dil Maya B.K.", "दिल माया वि.क."), _nepal_row("Dil Maya BK", "दिल माया वि.क.")], 103: [], } def _nepal_error(url: str, code: int) -> urllib.error.HTTPError: return urllib.error.HTTPError(url, code, "x", email.message.Message(), None) class _Root(_Response): """The site's root page, setting `cookies`.""" def __init__(self, *cookies: str) -> None: super().__init__(b"<html></html>") self.headers = email.message.Message() for cookie in cookies: self.headers["Set-Cookie"] = cookie class _Site: """result.election.gov.np, offline. The root sets a token; the handler answers 403 to a request not echoing the current one, else the lookup from `files`' ids and each file from `files` (a list served as JSON, bytes as they are, an exception raised; a missing id is a 404). `expire` makes a token lapse after that many handler requests, as a session does.""" def __init__(self, monkeypatch: pytest.MonkeyPatch, files: dict[int, object], *, bom: bytes = b"\xef\xbb\xbf", listed: object = None) -> None: self.files, self.bom, self.listed = files, bom, listed self.requests: list[urllib.request.Request] = [] self.tokens = 0 self.expire: int | None = None self.used = 0 monkeypatch.setattr(time, "sleep", lambda seconds: None) monkeypatch.setattr(nepal, "NEPAL_BODIES", len(files)) # Pinned as if an empty file had held one candidate before it # was blanked: every real file did. monkeypatch.setattr(nepal, "NEPAL_CANDIDATES", sum( len(rows) or 1 for rows in files.values() if isinstance(rows, list))) monkeypatch.setattr(urllib.request, "urlopen", self.urlopen) @property def token(self) -> str: return f"{_NEPAL_TOKEN[:-1]}{self.tokens}" def handler(self, path: str) -> str: return nepal.NEPAL_HANDLER + path def urlopen(self, request: urllib.request.Request, timeout: int) -> _Response: url = request.full_url self.requests.append(request) if url == nepal.NEPAL_SITE: self.tokens += 1 self.used = 0 return _Root("ASP.NET_SessionId=s1; path=/; HttpOnly", f"CsrfToken={self.token}; path=/") assert url.startswith(nepal.NEPAL_HANDLER) self.used += 1 if (request.get_header("X-csrf-token") != self.token or f"CsrfToken={self.token}" not in (request.get_header("Cookie") or "") or self.expire is not None and self.used > self.expire): raise _nepal_error(url, 403) path = url[len(nepal.NEPAL_HANDLER):] if path == nepal.NEPAL_LOOKUP: listed = self.listed if self.listed is not None else [ {"id": i, "name": "नगरपालिका", "parentId": 1} for i in self.files] return _Response(self.bom + json.dumps(listed).encode()) body = int(path.rsplit("/", 1)[1].removesuffix(".json")) if body not in self.files: raise _nepal_error(url, 404) answer = self.files[body] if callable(answer): answer = answer() if isinstance(answer, Exception): raise answer if isinstance(answer, bytes): return _Response(answer) return _Response(self.bom + json.dumps(answer, ensure_ascii=False).encode()) def asked(self) -> list[str]: return [r.full_url.removeprefix(nepal.NEPAL_HANDLER) for r in self.requests] @pytest.fixture def site(monkeypatch: pytest.MonkeyPatch) -> _Site: return _Site(monkeypatch, dict(_NEPAL_FILES)) def _tolerate_nepal_s_small_fixture(monkeypatch: pytest.MonkeyPatch) -> None: """Let _NEPAL_FILES, whose 6 candidates include one with no Devanagari name and whose 3 files one that is empty, pass NEPAL_MAX_NAMELESS, NEPAL_MAX_MISSING and NEPAL_MAX_SHORT.""" monkeypatch.setattr(nepal, "NEPAL_MAX_NAMELESS", 1) monkeypatch.setattr(nepal, "NEPAL_MAX_MISSING", 1) monkeypatch.setattr(nepal, "NEPAL_MAX_SHORT", 1) def test_a_nepal_fetch_keeps_each_pair_once_and_counts_the_rest( site: _Site, monkeypatch: pytest.MonkeyPatch) -> None: _tolerate_nepal_s_small_fixture(monkeypatch) corpus = local.fetch(_NEPAL) assert corpus.records == [ {"name": "Dil Maya B.K.", "devanagari": "दिल माया वि.क."}, {"name": "Dil Maya BK", "devanagari": "दिल माया वि.क."}, {"name": "Hari Thapa", "devanagari": ""}, {"name": "Sita Kumari K.C.", "devanagari": "सीता कुमारी के.सी."}] assert re.fullmatch(r"\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ", corpus.meta["data_date"]) assert corpus.meta["detail"] == { "election": "Election2079", "local_bodies": 3, "local_bodies_missing": "", "local_bodies_empty": "103", "files": 3, "candidates": 6, "without_the_name_keys": 0, "without_a_romanized_name": 0, "without_a_devanagari_name": 1, "devanagari_name_without_devanagari": 0, "romanized_name_not_latin": 0, "sessions_renewed": 0, "largest_file": 4} assert corpus.meta["corpus_keys"] == ["name", "devanagari"] assert corpus.header().endswith("4 name pairs (local corpus nepal-candidates)") assert site.asked() == [nepal.NEPAL_SITE, nepal.NEPAL_LOOKUP, *(nepal.NEPAL_BODY.format(id=i) for i in (101, 102, 103))] def test_a_nepal_fetch_writes_no_field_but_the_two_names( site: _Site, monkeypatch: pytest.MonkeyPatch, _own_cache: Path) -> None: # Gender, age, ward, votes and the ids stay in the site's files. _tolerate_nepal_s_small_fixture(monkeypatch) local.fetch(_NEPAL) raw = (_own_cache / "local" / _NEPAL / "raw.jsonl").read_text() assert {key for line in raw.splitlines() for key in json.loads(line)} == { "name", "devanagari"} assert "पुरुष" not in raw and "99" not in raw def test_a_nepal_corpus_holds_both_scripts_names_for_the_snapshot( site: _Site, monkeypatch: pytest.MonkeyPatch, _own_cache: Path) -> None: # The romanized name is the record's "name"; the Devanagari one is # read too, so a snapshot watches both scripts, an empty one skipped. _tolerate_nepal_s_small_fixture(monkeypatch) local.fetch(_NEPAL) corpus = _own_cache / "local" / _NEPAL / "corpus.jsonl" texts = sorted(json.loads(line) for line in corpus.read_text().splitlines()) assert texts == ["Dil Maya B.K.", "Dil Maya BK", "Hari Thapa", "Sita Kumari K.C.", "दिल माया वि.क.", "सीता कुमारी के.सी."] assert local.load(_NEPAL).meta["names"] == 6 assert snapshot.main(["--local", _NEPAL, "--record"]) == 0 recorded = _own_cache / "local" / _NEPAL / "snapshot.jsonl" assert snapshot.row_count(recorded.read_text()) == 6 def test_a_nepal_name_keeps_its_vowel_signs_virama_and_joiners( monkeypatch: pytest.MonkeyPatch, _own_cache: Path) -> None: # What the corpus is for: the combining marks and the ZWJ/ZWNJ a # Devanagari name is written with reach the corpus as written. joined = "क्\u200dष्मा प्\u200cरसाद" _Site(monkeypatch, {7: [_nepal_row("Ksma Prasad", joined)]}) local.fetch(_NEPAL) corpus = (_own_cache / "local" / _NEPAL / "corpus.jsonl").read_text() assert joined in {json.loads(line) for line in corpus.splitlines()} def test_every_nepal_request_carries_the_site_s_own_check( site: _Site, monkeypatch: pytest.MonkeyPatch) -> None: # The site's double-submit check: its CsrfToken cookie echoed in a # header, with the Referer its pages send, and a User-Agent of a # browser's form, the site refusing one holding "python". _tolerate_nepal_s_small_fixture(monkeypatch) local.fetch(_NEPAL) for request in site.requests: agent = request.get_header("User-agent") or "" assert agent.startswith("Mozilla/5.0") and "python" not in agent.lower() for request in site.requests[1:]: assert request.get_header("X-csrf-token") == site.token assert request.get_header("Referer") == nepal.NEPAL_SITE assert request.get_header("Cookie") == f"ASP.NET_SessionId=s1; CsrfToken={site.token}" def test_a_nepal_token_the_site_no_longer_holds_renews_the_session( site: _Site, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: # A fetch can outlast a session: a 403 renews it, counted, and asks again. _tolerate_nepal_s_small_fixture(monkeypatch) site.expire = 2 corpus = local.fetch(_NEPAL) assert len(corpus.records) == 4 assert corpus.meta["detail"]["sessions_renewed"] == 1 assert site.asked().count(nepal.NEPAL_SITE) == 2 assert "renewing the session after a 403" in capsys.readouterr().err def test_a_nepal_403_after_a_renewed_session_is_raised( site: _Site, monkeypatch: pytest.MonkeyPatch) -> None: # A file the site keeps refusing is no missing file: the fetch stops. site.files[102] = lambda: _nepal_error("u", 403) with pytest.raises(urllib.error.HTTPError, match="403") as raised: local.fetch(_NEPAL) raised.value.close() assert site.asked().count(nepal.NEPAL_BODY.format(id=102)) == 2 assert local.load(_NEPAL) is None def test_a_nepal_server_error_is_retried_and_its_last_raised( site: _Site, monkeypatch: pytest.MonkeyPatch) -> None: errors = iter([_nepal_error("u", 503)]) site.files[102] = lambda: next(errors, _NEPAL_FILES[102]) _tolerate_nepal_s_small_fixture(monkeypatch) assert len(local.fetch(_NEPAL).records) == 4 site.files[102] = lambda: _nepal_error("u", 503) with pytest.raises(urllib.error.HTTPError, match="503") as raised: local.fetch(_NEPAL) raised.value.close() assert site.asked().count(nepal.NEPAL_BODY.format(id=102)) == 2 + core.RETRIES _NEPAL_WHOLE = json.dumps(_NEPAL_FILES[101], ensure_ascii=False).encode() @pytest.mark.parametrize("cut", [ lambda body: body[:-1], lambda body: body[:body.rindex(b"}") + 1], lambda body: body[:body.rindex(b"},") + 2], lambda body: body[:body.rindex("माया".encode()) + 2], lambda body: b"", ], ids=["before the closing bracket", "after a whole candidate", "after a candidate's comma", "inside a letter", "empty"]) def test_a_nepal_file_cut_short_fails_to_parse_and_is_refused( site: _Site, cut: Callable[[bytes], bytes]) -> None: # A JSON array cut anywhere short of its end does not parse, so a cut # file is retried as one cut in transit and never read as a body # with fewer candidates. site.files[101] = cut(_NEPAL_WHOLE) with pytest.raises(ValueError, match="is not UTF-8 JSON"): local.fetch(_NEPAL) assert site.asked().count(nepal.NEPAL_BODY.format(id=101)) == core.RETRIES assert local.load(_NEPAL) is None def test_a_nepal_file_cut_once_is_read_whole_on_the_retry( site: _Site, monkeypatch: pytest.MonkeyPatch) -> None: answers = iter([_NEPAL_WHOLE[:-1]]) site.files[101] = lambda: next(answers, _NEPAL_WHOLE) _tolerate_nepal_s_small_fixture(monkeypatch) assert len(local.fetch(_NEPAL).records) == 4 def test_a_nepal_file_with_or_without_a_bom_reads_the_same( monkeypatch: pytest.MonkeyPatch) -> None: _tolerate_nepal_s_small_fixture(monkeypatch) _Site(monkeypatch, dict(_NEPAL_FILES)) with_bom = local.fetch(_NEPAL).records _Site(monkeypatch, dict(_NEPAL_FILES), bom=b"") assert local.fetch(_NEPAL).records == with_bom @pytest.mark.parametrize("answer", [b"{}", b"[1]", b"null", b'{"rows": []}'], ids=["an object", "not candidates", "null", "a wrapped list"]) def test_a_nepal_file_that_is_not_a_list_of_candidates_is_refused( site: _Site, answer: bytes) -> None: site.files[101] = answer with pytest.raises(ValueError, match="is not a list of candidates"): local.fetch(_NEPAL) @pytest.mark.parametrize(("listed", "match"), [ ([{"id": 101}, {"id": 102}], "lists 2 local bodies .* not the 3"), ([{"id": 101}, {"id": 102}, {"id": 103}, {"id": 104}], "lists 4 local bodies"), ([{"id": 101}, {"id": 101}, {"id": 102}], r"\(2 distinct ids\)"), ([{"id": 101}, {"id": "102"}, {"id": 103}], "not a list of local bodies"), ([{"id": 101}, {"id": True}, {"id": 103}], "not a list of local bodies"), ({"bodies": []}, "not a list of local bodies"), ], ids=["one fewer", "one more", "an id twice", "an id as text", "an id a boolean", "not a list"]) def test_a_nepal_lookup_that_has_changed_is_refused( monkeypatch: pytest.MonkeyPatch, listed: object, match: str) -> None: _Site(monkeypatch, dict(_NEPAL_FILES), listed=listed) with pytest.raises(ValueError, match=match): local.fetch(_NEPAL) def _nepal_bodies(n: int, missing: int = 0, empty: int = 0) -> dict[int, object]: """`n` bodies of one candidate each, the first `missing` of them answered as missing and the `empty` after those answered empty.""" return {i: [] if i < missing + empty else [_nepal_row(f"Ram {_letters(i)}", f"राम {i}")] for i in range(missing, n)} @pytest.mark.parametrize(("missing", "empty"), [(7, 0), (8, 0), (0, 7), (0, 8), (4, 3), (4, 4)]) def test_a_nepal_fetch_is_refused_past_its_real_share_of_missing_or_empty_files( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], missing: int, empty: int) -> None: # NEPAL_MAX_MISSING unpatched, and NEPAL_BODIES set back to its # shipped 753 (which the next test holds): 7 of 753 files missing or # empty pass, the ids of both kept, and 8 are refused. An empty file # counts as a missing one: a site that blanked its files would # otherwise read as a smaller election. site = _Site(monkeypatch, _nepal_bodies(753, missing, empty)) monkeypatch.setattr(nepal, "NEPAL_BODIES", 753) monkeypatch.setattr(nepal, "NEPAL_CANDIDATES", 753) site.listed = [{"id": i, "name": "", "parentId": 1} for i in range(753)] if missing + empty == 7: detail = local.fetch(_NEPAL).meta["detail"] assert detail["local_bodies_missing"] == " ".join(map(str, range(missing))) assert detail["local_bodies_empty"] == " ".join(map(str, range(missing, 7))) assert detail["files"] == 753 - missing assert "750 of 753 local bodies" in capsys.readouterr().err else: with pytest.raises(ValueError, match="8 of 753 local bodies' files are missing" r" \(HTTP 404\) or empty"): local.fetch(_NEPAL) def test_the_shipped_nepal_body_count_is_the_lookup_s() -> None: # The count the share above is taken at, as the lookup listed it. assert nepal.NEPAL_BODIES == 753 def test_the_nepal_user_agent_is_one_the_site_answers() -> None: # The site refuses a User-Agent holding "python" or "curl" with a 403 # (2026-10-24), which would read as a lapsed token and then stop the fetch. agent = nepal.NEPAL_USER_AGENT.lower() assert "python" not in agent and "curl" not in agent def _hundred(*bad: dict[str, object]) -> list[dict[str, object]]: """100 candidates: `bad`, then good ones.""" good = [_nepal_row(f"Ram {_letters(i)}", f"राम {i}") for i in range(100 - len(bad))] return [*bad, *good] @pytest.mark.parametrize(("bad", "key", "match"), [ ({"CandidateID": 1}, "without_the_name_keys", "have no CandidateName or"), (_nepal_row(" "), "without_a_romanized_name", "have no romanized name"), (_nepal_row(None), "without_a_romanized_name", "have no romanized name"), (_nepal_row("- - -"), "without_a_romanized_name", "have no romanized name"), (_nepal_row("12 34"), "without_a_romanized_name", "have no romanized name"), (_nepal_row("Ram Zed", "१२३"), "without_a_devanagari_name", "have no Devanagari name"), (_nepal_row("Ram Zed", "| |"), "without_a_devanagari_name", "have no Devanagari name"), (_nepal_row("Ram Zed", ""), "without_a_devanagari_name", "have no Devanagari name"), (_nepal_row("Ram Zed", "राम ज़ेड", CandidateName=None), "without_a_devanagari_name", "have no Devanagari name"), ], ids=["no name keys", "a blank romanized name", "a null romanized name", "a placeholder for the romanized name", "digits for the romanized name", "digits for the Devanagari name", "a placeholder for the Devanagari name", "a blank Devanagari name", "a null Devanagari name"]) @pytest.mark.parametrize("count", [1, 2]) def test_a_nepal_fetch_is_refused_past_its_real_share_of_nameless_candidates( monkeypatch: pytest.MonkeyPatch, bad: dict[str, object], key: str, match: str, count: int) -> None: # At NEPAL_MAX_NAMELESS, unpatched: 1 of 100 passes and 2 are refused. _Site(monkeypatch, {1: _hundred(*[bad] * count)}) if count == 1: assert local.fetch(_NEPAL).meta["detail"][key] == 1 else: with pytest.raises(ValueError, match=f"2 of 100 (candidates |)?{match}"): local.fetch(_NEPAL) @pytest.mark.parametrize(("bad", "key", "match"), [ (_nepal_row("Ram Zed", "Ram Zed"), "devanagari_name_without_devanagari", "CandidateName values hold no Devanagari"), (_nepal_row("राम ज़ेड", "राम ज़ेड"), "romanized_name_not_latin", "CandidateNameEng values are not Latin"), ], ids=["Latin under Devanagari", "Devanagari under the romanized name"]) @pytest.mark.parametrize("count", [1, 2]) def test_a_nepal_fetch_is_refused_past_its_real_share_of_names_in_the_wrong_script( monkeypatch: pytest.MonkeyPatch, bad: dict[str, object], key: str, match: str, count: int) -> None: # Two columns swapped, or a romanization dropped, at # NEPAL_MAX_WRONG_SCRIPT, unpatched: 1 of 100 passes, 2 are refused. _Site(monkeypatch, {1: _hundred(*[bad] * count)}) if count == 1: assert local.fetch(_NEPAL).meta["detail"][key] == 1 else: with pytest.raises(ValueError, match=f"2 of 100 (candidates |)?{match}"): local.fetch(_NEPAL) @pytest.mark.parametrize("word", ["Rām Śarmā", "Kṛṣṇa Ṭhākura", "Saʼadi"], ids=["Latin Extended", "IAST's dotted letters", "a modifier letter"]) def test_a_nepal_romanized_name_in_any_latin_is_latin( monkeypatch: pytest.MonkeyPatch, word: str) -> None: # A romanization may write IAST's dotted letters (Latin Extended # Additional) or an apostrophe as a letter: 5 of 100 such names would # refuse the fetch were they read as another script. _Site(monkeypatch, {1: _hundred(*[_nepal_row(f"{word} {_letters(i)}") for i in range(5)])}) assert local.fetch(_NEPAL).meta["detail"]["romanized_name_not_latin"] == 0 @pytest.mark.parametrize("column", ["CandidateNameEng", "CandidateName"]) def test_a_nepal_column_masked_with_one_word_is_refused( monkeypatch: pytest.MonkeyPatch, column: str) -> None: # Each pair stays distinct by the other column, so the pairs alone # would pass a masked column. masked = "Xxx" if column == "CandidateNameEng" else "राम" _Site(monkeypatch, {1: [{**row, column: masked} for row in _hundred()]}) with pytest.raises(ValueError, match=f"hold 1 distinct {column}: the column may be masked"): local.fetch(_NEPAL) @pytest.mark.parametrize("column", ["CandidateNameEng", "CandidateName"]) @pytest.mark.parametrize("distinct", [25, 24]) def test_a_nepal_column_is_refused_below_its_real_share_of_distinct_names( monkeypatch: pytest.MonkeyPatch, column: str, distinct: int) -> None: # At NEPAL_MIN_DISTINCT, unpatched: 25 distinct names in 100 # candidates pass and 24 are refused, the other column distinct. def word(i: int) -> str: return f"Ram {_letters(i % distinct)}" if column == "CandidateNameEng" \ else f"राम {i % distinct}" _Site(monkeypatch, {1: [{**row, column: word(i)} for i, row in enumerate(_hundred())]}) if distinct == 25: assert local.fetch(_NEPAL).records else: with pytest.raises(ValueError, match=f"hold 24 distinct {column}"): local.fetch(_NEPAL) def test_a_nepal_devanagari_word_is_looked_up_in_the_devanagari_names( site: _Site, monkeypatch: pytest.MonkeyPatch) -> None: # The word index reads every name the corpus holds, so a Devanagari # word is counted in the records whose Devanagari name writes it, not # read as a false zero; the romanized names are read as before. _tolerate_nepal_s_small_fixture(monkeypatch) local.fetch(_NEPAL) loaded = local.load(_NEPAL) assert loaded is not None assert local.lookup(loaded, "माया").anywhere == 2 assert local.lookup(loaded, "Maya").anywhere == 2 assert local.lookup(loaded, "B.K.").anywhere == 2 # BK folds alike # A phrase is read in each name, the Devanagari as the romanized. assert local.lookup(loaded, "दिल माया").anywhere == 2 assert local.lookup(loaded, "सीता कुमारी").anywhere == 1 @pytest.mark.parametrize("word", ["Bishnu", "Bishnu Kumar"], ids=["a word", "a phrase"]) def test_a_word_both_of_a_record_s_names_write_counts_the_record_once( monkeypatch: pytest.MonkeyPatch, word: str) -> None: # A Devanagari name left partly in Latin shares words with the # romanization; the index counts records, not names, and a spelling # once a record. _Site(monkeypatch, {1: _hundred(_nepal_row("Bishnu Kumar Zed", "Bishnu Kumar राम"))}) local.fetch(_NEPAL) loaded = local.load(_NEPAL) assert loaded is not None found = local.lookup(loaded, word) assert found.anywhere == 1 if word == "Bishnu": assert found.spellings == {"Bishnu": 1} def test_a_phrase_is_not_matched_across_a_record_s_two_names( monkeypatch: pytest.MonkeyPatch) -> None: # Each name is read alone: the romanization's last word and the # Devanagari name's first are not a phrase. _Site(monkeypatch, {1: _hundred(_nepal_row("Opal Zed", "Bishnu राम"))}) local.fetch(_NEPAL) loaded = local.load(_NEPAL) assert loaded is not None assert local.lookup(loaded, "Zed").anywhere == 1 assert local.lookup(loaded, "Zed Bishnu").anywhere == 0 def test_the_cached_index_is_keyed_to_the_names_it_reads( site: _Site, monkeypatch: pytest.MonkeyPatch) -> None: # A source that starts or stops reading a record's second name must # not answer from an index built over the other set, nor load a # corpus.jsonl built from it. _tolerate_nepal_s_small_fixture(monkeypatch) local.fetch(_NEPAL) loaded = local.load(_NEPAL) assert loaded is not None assert local.lookup(loaded, "माया").anywhere == 2 monkeypatch.setitem(local.SOURCES, _NEPAL, dataclasses.replace( local.SOURCES[_NEPAL], corpus_keys=("name",))) with pytest.raises(local.CorpusDamaged, match=r"record keys \['name', 'devanagari'\]"): local.load(_NEPAL) # The index, read from the cache beside the same records, is rebuilt. assert local.lookup(local.Local(_NEPAL, loaded.meta), "माया").anywhere == 0 def test_a_corpus_fetched_before_its_keys_were_recorded_reads_as_name_alone( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: # Every cached corpus fetched before corpus_keys were recorded was # built from "name": it loads for a source reading "name" alone. folder = _fetched(monkeypatch, tmp_path) _rewrite_meta(folder, corpus_keys=None) assert local.load(_SOURCE) is not None def test_a_nepal_election_whose_files_hold_no_names_is_refused( monkeypatch: pytest.MonkeyPatch) -> None: # Past the nameless share's own refusal, which a wholly nameless # election would meet first, the empty corpus is still refused. monkeypatch.setattr(nepal, "NEPAL_MAX_NAMELESS", 1) _Site(monkeypatch, {1: [_nepal_row(""), _nepal_row(" ")]}) with pytest.raises(ValueError, match="hold no candidate with a name"): local.fetch(_NEPAL) @pytest.mark.parametrize("cookies", [[], ["CsrfToken=; path=/"]], ids=["no cookie", "an empty token"]) def test_a_nepal_site_that_sets_no_token_is_refused( site: _Site, monkeypatch: pytest.MonkeyPatch, cookies: list[str]) -> None: monkeypatch.setattr(urllib.request, "urlopen", lambda request, timeout: _Root(*cookies)) with pytest.raises(ValueError, match="set no CsrfToken cookie"): local.fetch(_NEPAL) def test_a_nepal_root_failing_once_is_retried( site: _Site, monkeypatch: pytest.MonkeyPatch) -> None: _tolerate_nepal_s_small_fixture(monkeypatch) failed = _failing_once(monkeypatch, nepal.NEPAL_SITE) assert len(local.fetch(_NEPAL).records) == 4 assert failed == [nepal.NEPAL_SITE] def test_a_nepal_corpus_of_another_election_is_refused_on_load( site: _Site, monkeypatch: pytest.MonkeyPatch, _own_cache: Path) -> None: _tolerate_nepal_s_small_fixture(monkeypatch) local.fetch(_NEPAL) monkeypatch.setattr(nepal, "NEPAL_ELECTION", "Election2084") with pytest.raises(local.CorpusDamaged, match="holds the election Election2079"): local.load(_NEPAL) def test_the_pairs_view_does_not_pair_two_scripts( site: _Site, monkeypatch: pytest.MonkeyPatch) -> None: # The pairs view compares the letters of two spellings, which a # Devanagari name and its romanization share none of; the Devanagari # is kept under its own key, not as a reference name. _tolerate_nepal_s_small_fixture(monkeypatch) local.fetch(_NEPAL) with pytest.raises(SystemExit, match="carry no reference name"): local.main(["pairs", _NEPAL]) def test_the_shipped_nepal_candidate_count_is_the_files_whole() -> None: # The count every whole fetch must read, as the 753 files held it. assert nepal.NEPAL_CANDIDATES == 152_960 @pytest.mark.parametrize("change", [-1, 1], ids=["one fewer", "one more"]) def test_a_nepal_election_of_another_size_with_every_file_whole_is_refused( site: _Site, monkeypatch: pytest.MonkeyPatch, change: int) -> None: # A site that trimmed its files (to the winners, or to one post), or # added to them, is refused however well formed what is left: with # every file read the count must be the pinned one, exactly. _tolerate_nepal_s_small_fixture(monkeypatch) del site.files[103] # the empty one: every file whole monkeypatch.setattr(nepal, "NEPAL_BODIES", 2) monkeypatch.setattr(nepal, "NEPAL_CANDIDATES", 6 - change) with pytest.raises(ValueError, match="hold 6 candidates, 0 missing or empty"): local.fetch(_NEPAL) @pytest.mark.parametrize(("pin", "refused"), [(7, False), (6, True), (5, True)], ids=["one a lost file", "the pin itself", "over the pin"]) def test_a_nepal_election_with_a_file_lost_reads_at_most_the_pin_less_a_candidate_a_file( site: _Site, monkeypatch: pytest.MonkeyPatch, pin: int, refused: bool) -> None: # Every real file held a candidate, so with the empty file 103 the 6 # read must be at most the pin less one: the count landing on the pin # with a file lost is the site's files grown by what it lost. _tolerate_nepal_s_small_fixture(monkeypatch) monkeypatch.setattr(nepal, "NEPAL_CANDIDATES", pin) if refused: with pytest.raises(ValueError, match="hold 6 candidates, 1 missing or empty"): local.fetch(_NEPAL) else: assert local.fetch(_NEPAL).meta["detail"]["local_bodies_empty"] == "103" @pytest.mark.parametrize("held", [980, 979]) def test_a_nepal_election_with_a_file_missing_is_refused_past_its_real_shortfall( monkeypatch: pytest.MonkeyPatch, held: int) -> None: # With a file missing the count cannot be exact: at NEPAL_MAX_SHORT, # unpatched, 20 of 1,000 short pass and 21 are refused. _Site(monkeypatch, {2: [_nepal_row(f"Ram {_letters(i)}", f"राम {i}") for i in range(held)]}, listed=[{"id": i, "name": "", "parentId": 1} for i in (1, 2)]) monkeypatch.setattr(nepal, "NEPAL_BODIES", 2) monkeypatch.setattr(nepal, "NEPAL_CANDIDATES", 1000) monkeypatch.setattr(nepal, "NEPAL_MAX_MISSING", 1) if held == 980: assert local.fetch(_NEPAL).meta["detail"]["local_bodies_missing"] == "1" else: with pytest.raises(ValueError, match="21 of 1,000 NEPAL_CANDIDATES are missing"): local.fetch(_NEPAL) def test_a_nepal_site_answering_every_body_with_one_file_is_refused( monkeypatch: pytest.MonkeyPatch) -> None: # A cache serving one file under every id: every file whole and well # formed, the count matching, and the names four, read 40 times over. four = [_nepal_row(f"Ram {_letters(i)}", f"राम {i}") for i in range(4)] _Site(monkeypatch, {i: list(four) for i in range(40)}) with pytest.raises(ValueError, match="distinct CandidateNameEng: the column may be masked"): local.fetch(_NEPAL) @pytest.mark.parametrize("missing", ["CandidateName", "CandidateNameEng"]) @pytest.mark.parametrize("count", [1, 2]) def test_a_nepal_candidate_without_one_name_key_is_counted( monkeypatch: pytest.MonkeyPatch, missing: str, count: int) -> None: bad = {k: v for k, v in _nepal_row("Ram Zed", "राम ज़ेड").items() if k != missing} _Site(monkeypatch, {1: _hundred(*[bad] * count)}) if count == 1: assert local.fetch(_NEPAL).meta["detail"]["without_the_name_keys"] == 1 else: with pytest.raises(ValueError, match="2 of 100 candidates have no CandidateName or"): local.fetch(_NEPAL) def test_a_nepal_lookup_answered_as_missing_is_raised_not_counted( site: _Site, monkeypatch: pytest.MonkeyPatch) -> None: # Only a body's file is counted as missing; without the lookup there # is no election to read. served = site.urlopen def urlopen(request: urllib.request.Request, timeout: int) -> object: if request.full_url == nepal.NEPAL_HANDLER + nepal.NEPAL_LOOKUP: site.requests.append(request) raise _nepal_error(request.full_url, 404) return served(request, timeout) monkeypatch.setattr(urllib.request, "urlopen", urlopen) with pytest.raises(urllib.error.HTTPError, match="404") as raised: local.fetch(_NEPAL) raised.value.close() assert site.asked().count(nepal.NEPAL_LOOKUP) == 1 assert local.load(_NEPAL) is None @pytest.mark.parametrize(("code", "asked"), [(503, None), (403, 1)], ids=["a server error every time", "a refusal"]) def test_a_nepal_root_that_does_not_answer_is_raised( site: _Site, monkeypatch: pytest.MonkeyPatch, code: int, asked: int | None) -> None: # A 5xx at the root is retried and its last raised; a 403, what the # site answers a refused User-Agent, is raised at once, never read as # a site that sets no token. served = site.urlopen def urlopen(request: urllib.request.Request, timeout: int) -> object: if request.full_url == nepal.NEPAL_SITE: site.requests.append(request) raise _nepal_error(request.full_url, code) return served(request, timeout) monkeypatch.setattr(urllib.request, "urlopen", urlopen) with pytest.raises(urllib.error.HTTPError, match=str(code)) as raised: local.fetch(_NEPAL) raised.value.close() assert site.asked() == [nepal.NEPAL_SITE] * (asked or core.RETRIES) @pytest.mark.parametrize(("cookies", "token", "header"), [ (["CsrfToken=abc; path=/; Priority=High", "ASP.NET_SessionId=s1; HttpOnly"], "abc", "ASP.NET_SessionId=s1; CsrfToken=abc"), (['CsrfToken="a=b"; path=/'], '"a=b"', 'CsrfToken="a=b"'), (["CsrfToken=abc", "lang=ne np; path=/"], "abc", "CsrfToken=abc; lang=ne np"), ], ids=["an attribute the stdlib does not know", "a quoted value", "a value with a space"]) def test_a_nepal_cookie_is_sent_as_the_site_set_it( monkeypatch: pytest.MonkeyPatch, cookies: list[str], token: str, header: str) -> None: # An attribute is no cookie, a value is sent as written, and no # cookie the site set is left out. monkeypatch.setattr(urllib.request, "urlopen", lambda request, timeout: _Root(*cookies)) assert nepal._session() == {"User-Agent": nepal.NEPAL_USER_AGENT, "Referer": nepal.NEPAL_SITE, "X-CSRF-Token": token, "Cookie": header} @pytest.mark.parametrize("cookie", ["=abc; path=/", "Secure; path=/"], ids=["no name", "no equals sign"]) def test_a_nepal_cookie_this_cannot_read_is_refused( monkeypatch: pytest.MonkeyPatch, cookie: str) -> None: # Read past, it would leave a cookie out of every request, and the # fetch die on a 403 that hides why. monkeypatch.setattr(urllib.request, "urlopen", lambda request, timeout: _Root("CsrfToken=abc", cookie)) with pytest.raises(ValueError, match="set a cookie with no name"): nepal._session() @pytest.mark.parametrize(("records", "match"), [ ([{"name": "Ann Lee", "devanagari": 3}], "'devanagari' is not text"), ([{"name": "Ann Lee", "devanagari": " "}], "no fetched record holds the corpus key 'devanagari'"), ([{"name": "Ann Lee", "devanagri": "राम"}], "no fetched record holds the corpus key 'devanagari'"), ], ids=["a value not text", "a value blank", "a key misspelled"]) def test_a_second_corpus_key_the_records_do_not_fill_is_refused( monkeypatch: pytest.MonkeyPatch, records: list[dict[str, object]], match: str) -> None: # Skipped in silence, its names would never reach the snapshot. monkeypatch.setitem(local.SOURCES, _NEPAL, dataclasses.replace( local.SOURCES[_NEPAL], fetch=lambda: local.Fetched(records, _DATE, {}), check=None)) with pytest.raises(ValueError, match=match): local.fetch(_NEPAL) assert local.load(_NEPAL) is None @pytest.mark.parametrize("source", sorted( s for s, row in local.SOURCES.items() if row.corpus_keys == ("name",))) def test_every_other_source_s_corpus_and_index_read_the_name_alone( monkeypatch: pytest.MonkeyPatch, source: str) -> None: # A record's other keys (psc's boxes, tse's registry name) are no # names of the corpus or the word index. record = {"name": "Ann Lee", "given": "Bo Wu", "devanagari": "Cy Doe", "registry": "Dee Fox"} monkeypatch.setitem(local.SOURCES, source, dataclasses.replace( local.SOURCES[source], fetch=lambda: local.Fetched([record], _DATE, {}), check=None, renew=None)) local.fetch(source) lines = local.corpus_path(source).read_text().splitlines() assert [json.loads(line) for line in lines] == ["Ann Lee"] loaded = local.load(source) assert loaded is not None assert local.lookup(loaded, "Lee").anywhere == 1 assert [local.lookup(loaded, w).anywhere for w in ("Wu", "Cy", "Fox")] == [0, 0, 0] # -- Companies House people with significant control ------------------------------ _PSC = "psc-individuals" _PSC_DAY = "2026-10-22" _PSC_PERSON = "individual-person-with-significant-control" def _psc_person(name: str, nationality: str | None = "British", kind: str = _PSC_PERSON, **elements: str) -> dict[str, Any]: """A PSC record as the snapshot writes it, its boxes from `elements` (or split from `name`: forename, then surname), with the fields the corpus must not keep.""" if not elements: forename, _, surname = name.partition(" ") elements = {"title": "Mr", "forename": forename, "surname": surname} name = f"Mr {name}" data: dict[str, Any] = { "kind": kind, "name": name, "name_elements": elements, "address": {"premises": "1 Invented Row", "postal_code": "ZZ1 1ZZ"}, "date_of_birth": {"month": 4, "year": 1970}, "country_of_residence": "England", "natures_of_control": ["ownership-of-shares-75-to-100-percent"]} if nationality is not None: data["nationality"] = nationality return {"company_number": "00000001", "data": data} def _psc_lines(*records: dict[str, Any] | str) -> bytes: return b"".join((r if isinstance(r, str) else json.dumps(r)).encode() + b"\n" for r in records) #: Part 1: a person, a person filed twice (two companies), a company, a #: super-secure PSC with no name, a person with no nationality, and a #: person whose name and boxes are all empty. #: Part 2: the first person again, filed by another company, and an #: overseas entity's beneficial owner. _PSC_PARTS = { 1: _psc_lines(_psc_person("Ann Lee"), _psc_person("Bo Wu"), _psc_person("Bo Wu"), {"data": {"kind": "corporate-entity-person-with-significant-control", "name": "Invented Holdings Ltd"}}, {"data": {"kind": "super-secure-person-with-significant-control"}}, _psc_person("Cy Doe", nationality=None), _psc_person("", title="", forename="", surname="")), 2: _psc_lines(_psc_person("Ann Lee"), _psc_person("Dr Di Fox Roe", "Irish", "individual-beneficial-owner", title="Dr", forename="Di", middle_name="Fox", surname="Roe")), } def _psc_part(n: int, body: bytes, of: int = 2, day: str = _PSC_DAY, member: str | None = None) -> bytes: """Part `n` of `of` as the download serves it: a zip of one JSON-lines file.""" out = io.BytesIO() with zipfile.ZipFile(out, "w") as archive: archive.writestr(member or f"psc-snapshot-{day}_{n}of{of}.txt", body) return out.getvalue() def _psc_page(*files: str) -> bytes: return "".join(f'<a href="{f}">{f}</a> (74Mb)\n' for f in files).encode() def _psc_files(of: int = 2, day: str = _PSC_DAY) -> list[str]: return [f"psc-snapshot-{day}_{n}of{of}.zip" for n in range(1, of + 1)] def _serve_psc(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, parts: dict[str, Answer], page: bytes | None = None) -> list[str]: """Answer the download page with `page` (every file in `parts` linked) and each part file with its answer: bytes to serve, or an exception to raise. Returns the file names downloaded.""" monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) monkeypatch.setattr(core, "CHUNK", 64) # several reads, not one monkeypatch.setattr(psc, "PSC_SAMPLE", 1) # every record, unless a test samples page = _psc_page(*parts) if page is None else page asked: list[str] = [] def urlopen(request: urllib.request.Request, timeout: int) -> _Response | _Download: url = request.full_url if url == psc.PSC_PAGE: return _Response(page) file = url.rsplit("/", 1)[1] asked.append(file) answer = parts.get(file) if isinstance(answer, Exception): raise answer if answer is None: pytest.fail(f"an unexpected download of {url}") return _Download(answer, len(answer)) monkeypatch.setattr(urllib.request, "urlopen", urlopen) return asked def _psc_snapshot(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, bodies: dict[int, bytes] = _PSC_PARTS, day: str = _PSC_DAY) -> list[str]: of = len(bodies) return _serve_psc(monkeypatch, tmp_path, { file: _psc_part(n, bodies[n], of, day) for n, file in enumerate(_psc_files(of, day), 1)}) def test_a_psc_listing_failing_once_is_retried( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: _psc_snapshot(monkeypatch, tmp_path) failed = _failing_once(monkeypatch, psc.PSC_PAGE) day, parts = psc._psc_listing() assert (day, len(parts), failed) == (_PSC_DAY, len(_PSC_PARTS), [psc.PSC_PAGE]) def _gone(file: str) -> urllib.error.HTTPError: return urllib.error.HTTPError(f"{psc.PSC_PAGE}/{file}", 404, "Not Found", email.message.Message(), None) def test_a_psc_fetch_keeps_each_filed_name_once_and_counts_the_rest( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setattr(psc, "PSC_MAX_BAD", 1) # a fixture of 9 lines asked = _psc_snapshot(monkeypatch, tmp_path) corpus = local.fetch(_PSC) assert asked == _psc_files() # In the order of their lines, whose keys are sorted: by forename. assert corpus.records == [ {"name": "Mr Ann Lee", "title": "Mr", "forename": "Ann", "surname": "Lee", "nationality": "British"}, {"name": "Mr Bo Wu", "title": "Mr", "forename": "Bo", "surname": "Wu", "nationality": "British"}, {"name": "Mr Cy Doe", "title": "Mr", "forename": "Cy", "surname": "Doe"}, {"name": "Dr Di Fox Roe", "title": "Dr", "forename": "Di", "middle_name": "Fox", "surname": "Roe", "nationality": "Irish"}, ] assert corpus.meta["data_date"] == _PSC_DAY assert corpus.meta["detail"] == { "parts": 2, "sample": "1 in 1", "lines": 9, psc.PSC_KINDS[0]: 6, psc.PSC_KINDS[1]: 1, "records_of_other_kinds": 2, "individuals_without_a_nationality": 1, "sampled_names_that_are_empty": 1} assert corpus.header().endswith("4 filed names (local corpus psc-individuals)") def test_a_psc_fetch_writes_no_field_but_the_name_its_boxes_and_the_nationality( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: _psc_snapshot(monkeypatch, tmp_path) local.fetch(_PSC) folder = tmp_path / "local" / _PSC kept = set(psc.PSC_KEPT) for path in [folder / "raw.jsonl", *(folder / "parts").iterdir()]: text = (gzip.decompress(path.read_bytes()) if path.suffix == ".gz" else path.read_bytes()).decode() for leaked in ("Invented Row", "ZZ1", "1970", "England", "00000001", "ownership", "Invented Holdings"): assert leaked not in text, (path.name, leaked) for line in text.splitlines(): row = json.loads(line) assert "part" in row or set(row) <= kept, (path.name, row) def test_a_psc_sample_keeps_the_same_records_whatever_part_files_them( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: people = [_psc_person(f"Ann Lee{n}") for n in range(40)] _psc_snapshot(monkeypatch, tmp_path, {1: _psc_lines(*people[:25]), 2: _psc_lines(*people[25:])}) monkeypatch.setattr(psc, "PSC_SAMPLE", 4) corpus = local.fetch(_PSC) kept = corpus.records assert 0 < len(kept) < 20 and corpus.meta["detail"]["sample"] == "1 in 4" assert all(psc.psc_sampled(_extract_line(r)) for r in kept) # Filed by other parts, in another order, the same records are kept. _psc_snapshot(monkeypatch, tmp_path, {1: _psc_lines(*people[30:]), 2: _psc_lines(*reversed(people[:30]))}) monkeypatch.setattr(psc, "PSC_SAMPLE", 4) shutil.rmtree(tmp_path / "local" / _PSC / "parts") assert local.fetch(_PSC).records == kept # The control: with every record kept, all 40 are there. monkeypatch.setattr(psc, "PSC_SAMPLE", 1) assert len(local.fetch(_PSC).records) == 40 def _extract_line(record: dict[str, str]) -> str: """A record as its extract line, the text psc_sampled hashes.""" return local.corpus_build.jsonl_line(record, sort_keys=True) @pytest.mark.parametrize(("bad", "match"), [ ({"data": {"kind": _PSC_PERSON, "name": "Mr Ann Lee"}}, "1 of 2 individuals have no name_elements"), (_psc_person("Mr Ann Lee", title="Mr", forename="Ann", surname="Lee"), "1 of 2 individuals' display names are not their four boxes joined"), ("{not json", "1 of 2 lines are not JSON"), ], ids=["no name_elements", "a name not its boxes joined", "a line not JSON"]) def test_a_psc_snapshot_whose_layout_has_changed_is_refused( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, bad: dict[str, Any] | str, match: str) -> None: good = _psc_person("Bo Wu") _psc_snapshot(monkeypatch, tmp_path, {1: _psc_lines(bad, good)}) with pytest.raises(ValueError, match=match): local.fetch(_PSC) assert local.load(_PSC) is None # The control: past the threshold the same snapshot is read, the bad # record counted and dropped, so the threshold is what refused it. monkeypatch.setattr(psc, "PSC_MAX_BAD", 1) assert [r["name"] for r in local.fetch(_PSC).records] == ["Mr Bo Wu"] @pytest.mark.parametrize(("page", "match"), [ (b"<p>The snapshot has moved.</p>", "links no snapshot parts"), (_psc_page(*_psc_files(3)[:2]), r"parts \[1, 2\] of \[3\]"), (_psc_page(_psc_files()[0], _psc_files(day="2026-10-23")[1]), r"dated \['2026-10-22', '2026-10-23'\]"), ], ids=["no parts", "a part missing", "parts of two days"]) def test_a_psc_page_that_lists_no_whole_snapshot_is_refused( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, page: bytes, match: str) -> None: asked = _serve_psc(monkeypatch, tmp_path, {}, page) with pytest.raises(ValueError, match=match): local.fetch(_PSC) assert asked == [] def test_a_psc_part_gone_mid_fetch_is_refused_and_the_next_run_resumes( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: files = _psc_files() parts: dict[str, Answer] = {files[0]: _psc_part(1, _PSC_PARTS[1]), files[1]: _gone(files[1])} asked = _serve_psc(monkeypatch, tmp_path, parts) with pytest.raises(ValueError, match="replaced during the fetch; fetch again"): local.fetch(_PSC) folder = tmp_path / "local" / _PSC assert not (folder / "parts").exists() assert sorted(p.name for p in (folder / "parts.new").iterdir()) == [ psc._extract_name(files[0])] parts[files[1]] = _psc_part(2, _PSC_PARTS[2]) assert len(local.fetch(_PSC).records) == 4 assert asked == [*files, files[1]] # part 1 was not downloaded again assert not (folder / "parts.new").exists() def test_a_resumed_psc_fetch_drops_what_another_day_left( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: asked = _psc_snapshot(monkeypatch, tmp_path) staging = tmp_path / "local" / _PSC / "parts.new" staging.mkdir(parents=True) stale = staging / "psc-snapshot-2026-10-21_1of2.jsonl.gz" stale.write_bytes(gzip.compress(b'{"part": "yesterday", "counts": {}}\n')) (staging / "psc-snapshot-2026-10-22_2of2.zip.part").write_bytes(b"cut") assert len(local.fetch(_PSC).records) == 4 assert asked == _psc_files() assert sorted(p.name for p in (tmp_path / "local" / _PSC / "parts").iterdir()) == [ psc._extract_name(f) for f in _psc_files()] def test_a_failed_psc_refresh_keeps_the_parts_and_the_corpus_it_had( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: _psc_snapshot(monkeypatch, tmp_path) before = local.fetch(_PSC) newer = _psc_files(day="2026-10-23") cut = _psc_part(2, _PSC_PARTS[2], day="2026-10-23") _serve_psc(monkeypatch, tmp_path, {newer[0]: _psc_part(1, _PSC_PARTS[1], day="2026-10-23"), newer[1]: cut[:-10]}) with pytest.raises(ValueError, match="fetch again"): local.main(["fetch", _PSC, "--refresh"]) assert local.load(_PSC) == before assert sorted(p.name for p in (tmp_path / "local" / _PSC / "parts").iterdir()) == [ psc._extract_name(f) for f in _psc_files()] # The control: the same refresh, whole, replaces both. _serve_psc(monkeypatch, tmp_path, {newer[0]: b"", newer[1]: cut}) assert local.main(["fetch", _PSC, "--refresh"]) == 0 after = local.load(_PSC) assert after is not None and after.meta["data_date"] == "2026-10-23" def test_a_psc_cache_missing_a_part_is_refused_with_the_way_out( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: _psc_snapshot(monkeypatch, tmp_path) local.fetch(_PSC) (tmp_path / "local" / _PSC / "parts" / psc._extract_name(_psc_files()[1]) ).unlink() with pytest.raises(ValueError, match=r"holds parts \[1\] of \[2\].*--refresh"): local.fetch(_PSC) @pytest.mark.parametrize(("body", "match"), [ (b"not a zip at all", "is not a readable zip"), (_psc_part(1, _PSC_PARTS[1], of=1, member="renamed.txt"), "holds no psc-snapshot-2026-10-22_1of1.txt"), ], ids=["not a zip", "no member of its own name"]) def test_a_psc_part_that_cannot_be_read_is_refused( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, body: bytes, match: str) -> None: _serve_psc(monkeypatch, tmp_path, {_psc_files(1)[0]: body}) with pytest.raises(ValueError, match=match): local.fetch(_PSC) assert not list((tmp_path / "local" / _PSC / "parts.new").glob("*.zip*")) def _plant(tmp_path: Path, day: str, bodies: dict[int, bytes] = _PSC_PARTS, folder: str = "parts") -> Path: """The extracts of a snapshot of `bodies` dated `day`, written into the cache's `folder` as renew_psc would write them; the folder.""" where = tmp_path / "local" / _PSC / folder where.mkdir(parents=True, exist_ok=True) of = len(bodies) for n, file in enumerate(_psc_files(of, day), 1): zipped = tmp_path / file zipped.write_bytes(_psc_part(n, bodies[n], of, day)) (where / psc._extract_name(file)).write_bytes( psc._psc_extract(zipped, file.replace(".zip", ".txt"))) return where def test_a_psc_cache_holding_parts_of_two_snapshots_is_refused_before_any_is_read( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: _serve_psc(monkeypatch, tmp_path, {}) # no download is expected monkeypatch.setattr(psc, "PSC_MAX_BAD", 1) parts = _plant(tmp_path, _PSC_DAY) assert len(local.fetch(_PSC).records) == 4 # the control: one snapshot _plant(tmp_path, "2026-10-21") with pytest.raises(ValueError, match=r"holds part 1 twice.*--refresh"): local.fetch(_PSC) for path in parts.glob("*2026-10-21*"): path.unlink() (parts / psc._extract_name(_psc_files(day="2026-10-21")[1])).write_bytes( (parts / psc._extract_name(_psc_files()[1])).read_bytes()) (parts / psc._extract_name(_psc_files()[1])).unlink() with pytest.raises(ValueError, match=r"dated \['2026-10-21', '2026-10-22'\]"): local.fetch(_PSC) def test_a_psc_snapshot_whose_person_kind_was_renamed_is_refused( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: people = [_psc_person(f"Ann Lee{n}", kind="individual-person-renamed") for n in range(9)] owner = _psc_person("Bo Wu", kind=psc.PSC_KINDS[1]) _psc_snapshot(monkeypatch, tmp_path, {1: _psc_lines(*people, owner)}) with pytest.raises(ValueError, match="1 of 10 lines are .* records: a kind"): local.fetch(_PSC) # The control: under the kind's own name, the same snapshot is read. monkeypatch.setattr(psc, "PSC_KINDS", ("individual-person-renamed", psc.PSC_KINDS[1])) shutil.rmtree(tmp_path / "local" / _PSC / "parts") assert len(local.fetch(_PSC).records) == 10 @pytest.mark.parametrize(("damage", "match"), [ (lambda body: body[:-10], "is not an extract this tool wrote"), (lambda body: b"not gzip", "is not an extract this tool wrote"), (lambda body: gzip.compress(b"{not json\n"), "is not an extract this tool wrote"), (lambda body: gzip.compress( gzip.decompress(body).replace(b"_1of2.txt", b"_2of2.txt", 1)), "its header does not describe it"), (lambda body: gzip.compress( gzip.decompress(body).replace(b'"lines": 7', b'"lines": "7"', 1)), "its header does not describe it"), ], ids=["cut short", "not gzip", "header not JSON", "another part's header", "a count not a number"]) def test_a_damaged_psc_extract_is_refused_naming_the_refresh( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, damage: Callable[[bytes], bytes], match: str) -> None: _serve_psc(monkeypatch, tmp_path, {}) monkeypatch.setattr(psc, "PSC_MAX_BAD", 1) parts = _plant(tmp_path, _PSC_DAY) first = parts / psc._extract_name(_psc_files()[0]) assert b'"lines": 7' in gzip.decompress(first.read_bytes()) first.write_bytes(damage(first.read_bytes())) with pytest.raises(ValueError, match=f"{match}.*fetch {_PSC} --refresh"): local.fetch(_PSC) def test_a_psc_extract_of_another_version_is_neither_read_nor_resumed( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: asked = _psc_snapshot(monkeypatch, tmp_path) monkeypatch.setattr(psc, "PSC_EXTRACT_VERSION", 0) parts = _plant(tmp_path, _PSC_DAY) monkeypatch.setattr(psc, "PSC_EXTRACT_VERSION", 1) with pytest.raises(ValueError, match="is not an extract of version 1"): local.fetch(_PSC) # Staged for a resume, the old version's extracts are downloaded anew. os.replace(parts, parts.with_name("parts.new")) assert len(local.fetch(_PSC).records) == 4 assert asked == _psc_files() def test_a_psc_corpus_of_another_sample_is_refused_on_load( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: _psc_snapshot(monkeypatch, tmp_path) local.fetch(_PSC) assert local.load(_PSC) is not None # the control monkeypatch.setattr(psc, "PSC_SAMPLE", 8) with pytest.raises(ValueError, match=r"keeps 1 in 1 filed names.*keeps 1 in 8" f".*fetch {_PSC} --refresh"): local.load(_PSC) def test_a_psc_page_linking_each_part_twice_is_one_snapshot( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: files = _psc_files() _serve_psc(monkeypatch, tmp_path, { f: _psc_part(n, _PSC_PARTS[n]) for n, f in enumerate(files, 1)}, page=_psc_page(*files, *files)) assert len(local.fetch(_PSC).records) == 4 def test_a_psc_fetch_cut_off_between_its_two_renames_resumes_without_downloading( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: asked = _serve_psc(monkeypatch, tmp_path, {}, _psc_page(*_psc_files())) # any download fails _plant(tmp_path, "2026-10-21", folder="parts.old") _plant(tmp_path, _PSC_DAY, folder="parts.new") assert len(local.fetch(_PSC).records) == 4 assert asked == [] folder = tmp_path / "local" / _PSC assert sorted(p.name for p in folder.iterdir() if p.is_dir()) == ["parts"] # -- load ---------------------------------------------------------------------- def _fetched(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, *names: str, date: str = _DATE) -> Path: """A corpus of `names` fetched into tmp_path's cache, with no network behind it; its folder.""" monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) records = [{"name": n} for n in names or ("Ann Lee, MD", "Bo Wu, PhD")] plant_corpus(monkeypatch, local, _SOURCE, records, date, "officials") return _folder(tmp_path) def _rewrite_meta(folder: Path, **changes: object) -> None: meta = json.loads((folder / "meta.json").read_text()) for key, value in changes.items(): if value is None: del meta[key] else: meta[key] = value (folder / "meta.json").write_text(json.dumps(meta)) def test_a_raw_file_that_no_longer_matches_its_sha256_is_refused( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: raw = _fetched(monkeypatch, tmp_path) / "raw.jsonl" raw.write_text("".join(raw.read_text().splitlines(keepends=True)[:-1])) with pytest.raises(ValueError, match="raw.jsonl does not match the sha256"): local.load(_SOURCE) def test_a_corpus_file_that_no_longer_matches_its_sha256_is_refused( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: # snapshot.py --local reads corpus.jsonl, not raw.jsonl: a truncated # one would record a smaller baseline that reads as a clean run. corpus = _fetched(monkeypatch, tmp_path) / "corpus.jsonl" corpus.write_text(corpus.read_text().splitlines(keepends=True)[0]) with pytest.raises(ValueError, match="corpus.jsonl does not match the sha256"): local.load(_SOURCE) @pytest.mark.parametrize(("changes", "match"), [ ({"version": 1}, "not written by this version"), ({"sha256": None}, "no str 'sha256'"), ({"data_date": 3}, "no str 'data_date'"), ({"records": True}, "no int 'records'"), ], ids=["old version", "missing key", "wrong type", "bool for int"]) def test_a_meta_file_load_cannot_trust_is_refused_with_the_way_out( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, changes: dict[str, object], match: str) -> None: _rewrite_meta(_fetched(monkeypatch, tmp_path), **changes) with pytest.raises(ValueError, match=match) as raised: local.load(_SOURCE) assert f"local_corpora.py fetch {_SOURCE} --refresh" in str(raised.value) def test_a_meta_file_that_is_not_json_is_refused( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: (_fetched(monkeypatch, tmp_path) / "meta.json").write_text("{") with pytest.raises(ValueError, match="is not JSON"): local.load(_SOURCE) def test_a_name_holding_a_line_break_survives_the_round_trip( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: # Pasted from a document: str.splitlines cuts at these, and written # raw they would make the corpus unloadable for good. names = ("Ann
Lee, MD", "Bo
Wu", "Cy\x85Doe, PhD", "Di Fox") folder = _fetched(monkeypatch, tmp_path, *names) loaded = local.load(_SOURCE) assert loaded is not None and [r["name"] for r in loaded.records] == list(names) lines = (folder / "corpus.jsonl").read_text(encoding="utf-8").splitlines() assert sorted(json.loads(line) for line in lines) == sorted(names) # -- the command line ------------------------------------------------------------ def test_fetch_over_a_damaged_copy_fetches_again( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: # The way out every refusal names has to work: without this, a plain # `fetch` loaded the damaged copy first and raised the same refusal. _serve(monkeypatch, tmp_path, dates=(_DATE,) * 4) local.fetch(_SOURCE) (_folder(tmp_path) / "raw.jsonl").write_text("") assert local.main(["fetch", _SOURCE]) == 0 captured = capsys.readouterr() assert "unusable, fetching again" in captured.err assert captured.out.startswith("ClinicalTrials.gov (NLM) study officials") assert local.load(_SOURCE) is not None def test_fetch_uses_the_cached_copy_unless_asked_to_refresh( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: fetched = _serve(monkeypatch, tmp_path, dates=(_DATE,) * 4) assert local.main(["fetch", _SOURCE]) == 0 assert local.main(["fetch", _SOURCE]) == 0 assert "cached in" in capsys.readouterr().out assert len(fetched) == 4 assert local.main(["fetch", _SOURCE, "--refresh"]) == 0 assert len(fetched) == 8 def test_tally_reports_the_parts_after_commas_under_the_attribution( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) with pytest.raises(SystemExit, match="is not fetched"): local.main(["tally", _SOURCE]) _fetched(monkeypatch, tmp_path, "Ann Lee, MD", "Bo Wu, MD, PhD", "Cy Doe") assert local.main(["tally", _SOURCE, "--top", "1"]) == 0 out = capsys.readouterr().out.splitlines() assert out[0].startswith(f"ClinicalTrials.gov (NLM) study officials, data as of {_DATE}") assert out[1].startswith("2 hold a comma") assert out[2:] == [" 2 MD"] # -- the cached word index ------------------------------------------------------- def _looked_up(word: str = "MD") -> tuple[Any, Any]: """A fresh load of the corpus, and `word` looked up in it.""" loaded = local.load(_SOURCE) assert loaded is not None return loaded, local.lookup(loaded, word) def test_a_lookup_answered_from_the_cached_index_reads_no_records( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: folder = _fetched(monkeypatch, tmp_path, "Ann Lee, MD", "Bo Wu, MD, PhD", "Cy Doe") first, built = _looked_up() assert "records" in vars(first) and (folder / local.INDEX).exists() again, cached = _looked_up() assert cached == built assert "records" not in vars(again) assert again.header() == first.header() def test_the_cached_index_is_the_index_it_was_built_as( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: _fetched(monkeypatch, tmp_path, "Ann Lee, M.D.", "MD Bo Wu", "Cy Doe, MD, PhD", "Pe\u00f1a, Jos\u00e9", "Ed Ng, md; MD") first, _ = _looked_up() built = local.word_index(first) again, _ = _looked_up() assert again.index == local.WordIndex( dict(built.anywhere), dict(built.after_comma), {k: dict(v) for k, v in built.spellings.items()}) def test_a_built_index_reads_a_missing_word_as_a_cached_one_does( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: # A Counter answers 0 for a missing key where the cached dict raises: # a reader written against a built index would fail on a cached one. _fetched(monkeypatch, tmp_path, "Ann Lee, MD") built, _ = _looked_up() cached, _ = _looked_up() for index in (built.index, cached.index): with pytest.raises(KeyError): index.anywhere["absent"] # noqa: B018 with pytest.raises(KeyError): index.spellings["md"]["absent"] # noqa: B018 def test_a_refetch_rebuilds_the_cached_index( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: _fetched(monkeypatch, tmp_path, "Ann Lee, MD") _looked_up() _fetched(monkeypatch, tmp_path, "Ann Lee, MD", "Bo Wu, MD") _, found = _looked_up() assert found is not None and found.anywhere == 2 def test_the_cached_index_is_read_only_under_its_own_key( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: # Doctored counts under the right key are served, which shows the # file is what answers; under another fold's key they are not. folder = _fetched(monkeypatch, tmp_path, "Ann Lee, MD") _looked_up() data = json.loads((folder / local.INDEX).read_text()) data["anywhere"]["md"] = 99 (folder / local.INDEX).write_text(json.dumps(data)) _, found = _looked_up() assert found is not None and found.anywhere == 99 monkeypatch.setattr(local, "fold_fingerprint", lambda: "another fold") _, found = _looked_up() assert found is not None and found.anywhere == 1 def _another(name: str) -> object: """A stand-in for one of the fold's functions or constants that differs from it.""" value = getattr(local, name) if isinstance(value, re.Pattern): return re.compile(r"\s+") if isinstance(value, frozenset): return frozenset("'") return lambda *args: None @pytest.mark.parametrize("change", [*local._FOLD_CODE, *local._FOLD_CONSTANTS]) def test_a_change_to_how_a_word_is_cut_or_folded_changes_the_key( monkeypatch: pytest.MonkeyPatch, change: str) -> None: before = local.fold_fingerprint() monkeypatch.setattr(local, change, _another(change)) assert local.fold_fingerprint() != before @pytest.mark.parametrize("reader", ["read_records", "corpus_build.jsonl_lines"]) def test_a_change_to_how_raw_jsonl_is_cut_into_records_changes_the_key( monkeypatch: pytest.MonkeyPatch, reader: str) -> None: # The index is built from Local.records, an attribute the fold's own # functions read without calling what fills it, so the walk in # test_the_fold_lists_everything_its_functions_call_and_read cannot # see these two; they are hashed by name. before = local.fold_fingerprint() owner = local.corpus_build if reader.startswith("corpus_build.") else local monkeypatch.setattr(owner, reader.rsplit(".", 1)[-1], lambda *args, **kwargs: []) assert local.fold_fingerprint() != before def test_the_key_is_the_same_in_every_process() -> None: # A frozenset constant's iteration order moves with string hashing, # which differs per process: hashed unsorted, the key would too, and # no cache would ever be read twice. import subprocess import sys run = [sys.executable, "-c", "import sys; sys.path.insert(0, 'tools/differential'); " "import local_corpora; print(local_corpora.fold_fingerprint())"] keys = {subprocess.run(run, capture_output=True, text=True, check=True, env={**__import__("os").environ, "PYTHONHASHSEED": seed}, cwd=Path(__file__).resolve().parents[2]).stdout for seed in ("1", "2", "3")} assert len(keys) == 1 and keys != {""} def _globals_read(code: types.CodeType) -> set[str]: names = set(code.co_names) for const in code.co_consts: if isinstance(const, types.CodeType): names |= _globals_read(const) return names def test_no_module_imports_a_sibling_constant() -> None: # A test patches a constant on the module that holds it (core.RETRIES, # tse.TSE_MAX_BAD), and every other reader reads it through that # module. A `from local_x import CONSTANT` would bind a second name a # patch on local_x cannot reach, and a test could pass with its guard # off. The modules are SOURCES' own, so a new source is covered. PSC, # JRC, ORCID and PV are sources' names, keys of SOURCES, and no fetch's setting. import ast sources = {source.fetch.__module__ for source in local.SOURCES.values()} assert sources == {m.__name__ for m in (ctgov, fec, tse, tw, ukec, psc, jrc, nepal, orcid, patentsview)} siblings = {"local_corpora", "local_core", *sources} imported = {(path.stem, alias.name) for path in (Path(str(local.__file__)).parent / f"{s}.py" for s in sorted(siblings)) for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))) if isinstance(node, ast.ImportFrom) and node.module in siblings for alias in node.names if alias.name.lstrip("_").isupper()} assert imported == {("local_corpora", "PSC"), ("local_corpora", "JRC"), ("local_corpora", "ORCID"), ("local_corpora", "PV")} def test_the_fold_lists_everything_its_functions_call_and_read() -> None: # _FOLD_CODE is kept by hand: a helper the fold starts calling, or a # constant it starts reading, left off it, leaves the key unmoved by # a change to that helper. import inspect module = vars(local) read = set().union(*(_globals_read(inspect.unwrap(module[name]).__code__) for name in local._FOLD_CODE)) unlisted = set() for name in read & module.keys(): value = module[name] if isinstance(value, types.ModuleType): continue if getattr(value, "__module__", None) != local.__name__ and not name.isupper() \ and not name.startswith("_"): continue # imported: Counter if name in (*local._FOLD_CODE, *local._FOLD_CONSTANTS, "WordIndex"): continue unlisted.add(name) assert unlisted == set() @pytest.mark.parametrize("damage", ["{", "[]", '{"key": 1}'], ids=["not JSON", "not an object", "no counts"]) def test_a_cached_index_that_cannot_be_read_is_built_over( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, damage: str) -> None: folder = _fetched(monkeypatch, tmp_path, "Ann Lee, MD") (folder / local.INDEX).write_text(damage) _, found = _looked_up() assert found is not None and found.anywhere == 1 assert json.loads((folder / local.INDEX).read_text())["anywhere"] == { "ann": 1, "lee": 1, "md": 1} def test_a_corpus_not_read_from_the_cache_is_never_cached(_own_cache: Path) -> None: # A test's corpus has no sha256 to key an index on; cached under the # key "None", it would answer for the real corpus of that source. The # folder exists, as on a machine that fetched it, so a write would land. (_own_cache / "local" / _SOURCE).mkdir(parents=True) corpus: Any = _corpus("Ann Lee, MD") assert corpus.index.anywhere == {"ann": 1, "lee": 1, "md": 1} assert not list(_own_cache.rglob(local.INDEX)) def test_an_index_that_cannot_be_written_still_answers( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: _fetched(monkeypatch, tmp_path, "Ann Lee, MD") def refuse(path: Path, body: bytes) -> None: raise PermissionError(f"read-only: {path}") monkeypatch.setattr(local.corpus_build, "write_atomic", refuse) _, found = _looked_up() assert found is not None and found.anywhere == 1 assert "could not cache the word index" in capsys.readouterr().err def test_a_spaced_word_absent_from_the_cached_index_reads_no_records( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: _fetched(monkeypatch, tmp_path, "Ann Lee, Ph. D.", "Bo Wu, PhD") _looked_up() loaded, found = _looked_up("Ph. Q.") assert found is not None and found.anywhere == 0 assert "records" not in vars(loaded) loaded, found = _looked_up("Ph. D.") assert found is not None and (found.anywhere, found.joined) == (1, 1) @pytest.mark.parametrize("damage", [ {"anywhere": {"md": "1"}}, {"anywhere": {"md": True}}, {"anywhere": {"md": None}}, {"after_comma": []}, {"spellings": {"md": 5}}, {"spellings": {"md": {"MD": "1"}}}, ], ids=["a count as text", "a count as a bool", "no count", "counts as a list", "spellings as a number", "a spelling count as text"]) def test_an_index_under_its_own_key_with_counts_that_are_not_counts_is_built_over( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, damage: dict[str, Any], capsys: pytest.CaptureFixture[str]) -> None: folder = _fetched(monkeypatch, tmp_path, "Ann Lee, MD") _looked_up() data = json.loads((folder / local.INDEX).read_text()) (folder / local.INDEX).write_text(json.dumps({**data, **damage})) _, found = _looked_up() assert found is not None and (found.anywhere, found.spellings) == (1, {"MD": 1}) assert "rebuilding the word index" in capsys.readouterr().err def test_tally_over_damaged_records_exits_naming_the_fetch( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: # load() no longer parses the records, so their damage first shows # in tally, which must exit as require() does, not with a traceback. folder = _fetched(monkeypatch, tmp_path) raw = b'{"nom": "Ann Lee, MD"}\n' (folder / "raw.jsonl").write_bytes(raw) _rewrite_meta(folder, sha256=hashlib.sha256(raw).hexdigest()) with pytest.raises(SystemExit, match=f"record without a name.*fetch {_SOURCE} --refresh"): local.main(["tally", _SOURCE]) @pytest.mark.parametrize(("raw", "match"), [ (b'{"nom": "Ann Lee, MD"}\n', "record without a name"), (b'{"name": "Ann Lee, MD"\n', "a line that is not JSON"), ], ids=["a record without a name", "a line that is not JSON"]) def test_a_damaged_record_fails_when_the_records_are_read( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, raw: bytes, match: str) -> None: # With the sha256 rewritten to match, only the record checks see it, # and each must raise CorpusDamaged, which name_usage's per-row guard # catches, not the bare ValueError a JSON error is. folder = _fetched(monkeypatch, tmp_path) (folder / "raw.jsonl").write_bytes(raw) _rewrite_meta(folder, sha256=hashlib.sha256(raw).hexdigest()) loaded = local.load(_SOURCE) assert loaded is not None with pytest.raises(local.CorpusDamaged, match=match): loaded.records # noqa: B018 # -- views ------------------------------------------------------------------------- def _corpus(*names: str) -> object: return local.Local(_SOURCE, {"data_date": _DATE}, [{"name": n} for n in names]) def test_the_tally_counts_each_part_after_a_comma_once_per_record() -> None: parts, with_comma = local.tally(_corpus( "Ann Lee, MD, PhD", "Bo Wu,MD", "Cy Doe MD", "Di Fox, MD, MD")) assert with_comma == 3 assert parts == {"MD": 3, "PhD": 1} def test_a_comma_inside_parentheses_cuts_a_part_unlike_the_parser() -> None: # The docstring's stated limit: the parser sets the bracketed nickname # aside first, this view does not. assert local.after_commas("Ann (Lee, Jr) Roe, MD") == ["Jr) Roe", "MD"] def test_a_word_is_counted_anywhere_and_after_a_comma_ignoring_case_and_periods() -> None: index = local.word_index(_corpus( "Ann Lee, M.D.", "MD Bo Wu", "Cy Doe, md, MD", "Dr. Ma Li, (Psychiatry)")) assert (index.anywhere.get("md", 0), index.after_comma.get("md", 0)) == (3, 2) assert (index.anywhere.get("psychiatry", 0), index.after_comma.get("psychiatry", 0)) == (1, 1) assert (index.anywhere.get("ma", 0), index.after_comma.get("ma", 0)) == (1, 0) @pytest.mark.parametrize("name", [ "Bob Ray, MD/PhD", "Bob Ray, MD;", "Bob Ray, MD; PhD", "Bob Ray, (MD)", "Bob Ray, “MD”", "Bob Ray, [MD]:"]) def test_a_word_is_found_beside_any_punctuation(name: str) -> None: # Cut only at spaces and commas, 'MD/PhD' and 'MD;' were words of # their own, and a lookup of MD read zero: 3,698 officials' names # held a slash and 548 a semicolon on 2026-10-21. index = local.word_index(_corpus(name)) assert (index.anywhere.get("md", 0), index.after_comma.get("md", 0)) == (1, 1) @pytest.mark.parametrize(("word", "other"), [ ("d'", "D"), ("d’", "D"), ("al-", "Al"), ("शर्मा", "शर्म"), ("محمدٌ", "محمد")]) def test_a_word_keeps_what_tells_it_from_another(word: str, other: str) -> None: # The first edge fold stripped apostrophes, hyphens and combining # marks: d' answered as the initial D (5,954 officials, 2026-10-21), # al- as Al, and a final vowel sign or haraka was lost. assert local.word_key(word) != local.word_key(other) index = local.word_index(_corpus(f"Ann {other} Lee")) assert index.anywhere.get(local.word_key(word), 0) == 0 def test_a_decomposed_letter_finds_its_composed_twin() -> None: index = local.word_index(_corpus(unicodedata.normalize("NFD", "Ana Žic, MD"))) assert index.anywhere.get(local.word_key("Žic"), 0) == 1 def test_spellings_are_counted_once_per_record() -> None: # Two records, one writing MD twice: a spelling count above the # record count read as more officials than wrote the word. index = local.word_index(_corpus("Di Fox, MD, MD", "Ed Ng, MD")) assert index.anywhere.get("md", 0) == 2 assert index.spellings["md"] == {"MD": 2} def test_a_spaced_word_is_matched_as_its_words_in_a_row() -> None: # 'Ed Ph, D' runs across the comma, starting before it: written, not # after a comma. 'Di Fox, PhD' is the joined spelling, not the phrase. # 'Fy Ph D, Ph D' writes it on both sides: after a comma too. corpus = _corpus("Ann Lee, Ph. D.", "Bo Wu, Ph D, MD", "Ph D Cy", "Di Fox, PhD", "Ed Ph, D", "Fy Ph D, Ph D") assert local.words("Ph. D.") == ["ph", "d"] assert local.phrase_counts(corpus, ["ph", "d"]) == (5, 3) def test_a_phrase_with_a_word_written_nowhere_scans_nothing( monkeypatch: pytest.MonkeyPatch) -> None: corpus: Any = _corpus("Ann Lee, MD") corpus.index # noqa: B018 -- built before the scan is forbidden def scanned(local_: object) -> object: raise AssertionError("scanned every name for a word no name holds") monkeypatch.setattr(local, "_names", scanned) assert local.phrase_counts(corpus, ["md", "zz"]) == (0, 0) # -- pairs ------------------------------------------------------------------------- #: One invented pair of each shape, in PAIR_SHAPES's order. _PAIRS = [ ("ANA SOUZA", "ANA SOUZA", "the same"), ("ANA SOUZA", "", "no reference name"), ("--", "ANA SOUZA", "no filed name"), ("Ana Souza", "ANA SOUZA", "case only"), ("JOSE LIMA", "JOSÉ LIMA", "accents only"), ("Jose Lima", "JOSÉ LIMA", "case and accents"), ("ANA SOUZA-LIMA", "ANA SOUZA LIMA", "punctuation or spacing only"), ("SOUZA ANA", "ANA SOUZA", "the same words reordered"), ("ANA SILVA", "ANA DA SILVA", "words left out"), ("ANA DA SILVA", "ANA SILVA", "words added"), ("ANA M SILVA", "ANA MARIA SILVA", "words shortened"), ("ANA SOUSA", "ANA SOUZA", "words misspelled"), ("ANA M SOUZA", "ANA MARIA DA SOUZA", "words left out and shortened"), ("ANA SOUZA RIBEIRO", "ANA SOUZA CAMPOS", "mostly the same words"), ("BIA COSTA", "ANA SOUZA", "few words in common"), ] def test_each_pair_shape_has_a_pair() -> None: assert [shape for _, _, shape in _PAIRS] == list(local.PAIR_SHAPES) @pytest.mark.parametrize(("filed", "reference", "shape"), _PAIRS, ids=[shape for _, _, shape in _PAIRS]) def test_a_pair_takes_the_first_shape_it_fits(filed: str, reference: str, shape: str) -> None: assert local.pair_shape(filed, reference) == shape @pytest.mark.parametrize(("filed", "reference", "shape"), [ # An accent and a word left out: not accents only. ("JOSE SILVA", "JOSÉ DA SILVA", "words left out"), # Two words misspelled is the most a misspelling takes. ("ANA SOUSA RIBERO", "ANA SOUZA RIBEIRO", "words misspelled"), # Three, and no word left in common. ("ANNA SOUSA RIBERO", "ANA SOUZA RIBEIRO", "few words in common"), # A word too unlike its counterpart is not a misspelling: 0.75 is, # 0.727 is not. ("ANA SOUZA", "ANA SANTOS", "mostly the same words"), ("ANA LIMA", "ANA LIMO", "words misspelled"), ("ANA ROCHA", "ANA ROSHAN", "mostly the same words"), # A word both shortened and alike enough to be misspelled: shortened. ("ANA SOUZ", "ANA SOUZA", "words shortened"), # Two words run together are spacing, not a word changed. ("ANA SOUZALIMA", "ANA SOUZA LIMA", "punctuation or spacing only"), # Mostly the same words: all but one of the shorter name's words, # and at least one word. ("ANA SOUZA", "ANA LIMA COSTA RIBEIRO", "mostly the same words"), ("ANA", "BIA", "few words in common"), # Left out and shortened keeps the order: out of order, mostly. ("SOUZA A", "ANA SOUZA", "mostly the same words"), ], ids=["accent and a word", "two misspelled", "three misspelled", "unlike", "alike at the limit", "alike under the limit", "shortened before misspelled", "run together", "mostly, the shorter name's words", "one word each", "shortened out of order"]) def test_a_pair_s_shape_at_its_edges(filed: str, reference: str, shape: str) -> None: assert local.pair_shape(filed, reference) == shape def _paired(*pairs: tuple[str, str]) -> object: return local.Local("tse-donors", {"data_date": _DATE}, [{"name": a, "registry": b} for a, b in pairs]) def test_the_pairs_view_counts_shapes_and_the_words_left_out_and_added() -> None: # A pair leaving DE out twice counts DE once, and a pair leaving out # one of its two DEs counts it too. found = local.pair_counts(_paired(*[(a, b) for a, b, _ in _PAIRS], ("BO WU", "BO DE WU"), ("CY LIMA", "CY DA LIMA"), ("DI LUZ", "DI DE LUZ DE MELO"), ("EVA DE LUZ", "EVA DE LUZ DE MELO"))) assert found.total == len(_PAIRS) + 4 assert found.shapes["words left out"] == 5 assert found.left_out == {"DA": 2, "DE": 3, "MELO": 2} assert found.added == {"DA": 1} assert found.agreement is None def test_the_pairs_view_lists_only_words_many_pairs_share() -> None: # A word left out of nine pairs could be one family's: not listed. found = local.pair_counts(_paired(*[("ANA SILVA", "ANA DA SILVA")] * 10, *[("BO WU", "BO NOVAIS WU")] * 9, *[("CY DO LIMA", "CY LIMA")] * 10, ("EVA LIMA", "EVA LIMA"))) report = local.format_pairs(found, top=15) assert "words most often left out: DA 10\n" in report + "\n" assert "NOVAIS" not in report assert "words most often added: DO 10" in report assert "30 pairs; 1 the same, 29 differing:" in report assert " 19 65.5% words left out" in report # a share of those differing def test_the_pairs_view_lists_the_top_words_asked_for() -> None: found = local.pair_counts(_paired(*[("ANA SILVA", "ANA DA SILVA")] * 11, *[("BO WU", "BO DE WU")] * 10)) assert "left out: DA 11\n" in local.format_pairs(found, top=1) + "\n" assert "left out: DA 11, DE 10" in local.format_pairs(found, top=2) def test_the_pairs_view_counts_the_pairs_whose_spellings_share_a_surname() -> None: # Two readings with no surname (one word each) share none; a pair # with no reference name is not parsed. found = local.pair_counts(_paired( ("ANA SILVA", "ANA DA SILVA"), ("ANA M SILVA", "ANA MARIA SILVA"), ("PEDRO ALVES FH", "PEDRO ALVES FILHO"), ("ANA SOUZA", "ANA SOUZA"), ("ANA", "ANNA"), ("BO WU", "")), agreement=True) # FH is too unlike FILHO to be its misspelling, and both read as the # agnomen since #843 listed the abbreviation, so the pair shares ALVES. assert found.agreement == {"words left out": (1, 1), "words shortened": (1, 1), "mostly the same words": (1, 1), "words misspelled": (0, 1)} report = local.format_pairs(found, top=15) assert "(one surname in 0%)" in report assert " no reference name\n" in report # with no agreement beside it def test_the_pairs_view_lists_the_accents_only_pairs_read_with_two_surnames() -> None: # RA is a credential and RÁ is not, so the readings part; JÚNIOR and # JUNIOR are both suffixes (#843), and the accent on JOSÉ moves # nothing. Fewer than ten: not listed. found = local.pair_counts(_paired(*[("ANA SILVA RÁ", "ANA SILVA RA")] * 10, *[("JOSE SILVA JÚNIOR", "JOSÉ SILVA JUNIOR")] * 10, *[("JOSE SILVA", "JOSÉ SILVA")] * 10, *[("JOSE SILVA MÁ", "JOSÉ SILVA MA")] * 9), agreement=True) assert found.agreement == {"accents only": (20, 39)} assert found.accent_splits == {"RÁ / RA": 10, "MÁ / MA": 9} report = local.format_pairs(found, top=15) assert ("accents-only pairs read with two surnames, by their last words: " "RÁ / RA 10") in report assert "MÁ / MA" not in report def test_the_pairs_view_refuses_a_source_with_no_reference_name( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: _fetched(monkeypatch, tmp_path) with pytest.raises(SystemExit, match="carry no reference name"): local.main(["pairs", _SOURCE]) @pytest.mark.parametrize(("records", "match"), [ ([], "holds no records"), ([{"name": "ANA", "registry": "ANA"}, {"name": "BO"}], "carry no reference name"), ], ids=["no records", "one record without"]) def test_the_pairs_view_refuses_records_it_cannot_pair( records: list[dict[str, str]], match: str) -> None: with pytest.raises(ValueError, match=match): local.pair_counts(local.Local("tse-donors", {"data_date": _DATE}, records)) def test_the_pairs_command_reports_a_fetched_corpus( cdn: _Cdn, capsys: pytest.CaptureFixture[str]) -> None: local.fetch(_TSE) capsys.readouterr() assert local.main(["pairs", _TSE]) == 0 out = capsys.readouterr().out assert out.startswith("TSE (Tribunal Superior Eleitoral)") assert "6 pairs; 1 the same, 5 differing:" in out assert "one surname" not in out assert local.main(["pairs", _TSE, "--agreement"]) == 0 assert "(one surname in" in capsys.readouterr().out # -- snapshot.py --local ----------------------------------------------------------- def test_snapshot_reads_a_local_corpus_against_its_own_snapshot( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: _fetched(monkeypatch, tmp_path) with pytest.raises(SystemExit, match="no snapshot"): snapshot.main(["--local", _SOURCE]) assert snapshot.main(["--local", _SOURCE, "--record"]) == 0 recorded = _folder(tmp_path) / "snapshot.jsonl" assert snapshot.row_count(recorded.read_text()) == 2 assert snapshot.main(["--local", _SOURCE]) == 0 assert "matches the snapshot" in capsys.readouterr().out # A reading that moved, as a change to the parser would move it. header, *rows = recorded.read_text().splitlines() row = json.loads(rows[0]) row["family"] = "Moved" recorded.write_text("\n".join([header, json.dumps(row)] + rows[1:]) + "\n") assert snapshot.main(["--local", _SOURCE]) == 1 out = capsys.readouterr().out assert out.startswith("ClinicalTrials.gov (NLM) study officials, data as of") assert "Moved" in out and "1 entries differ" in out # The committed corpora's tail would tell the reader to re-record the # COMMITTED snapshot, from the committed corpora. assert f"--local {_SOURCE} --record --tree" in out assert snapshot.RECORD_COMMAND not in out and "corpus_cldr" not in out def test_snapshot_refuses_a_corpus_fetched_after_its_snapshot( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: # Fetched again, the registry has gained and lost names; compared # with the old snapshot they would read as the parser's moves. _fetched(monkeypatch, tmp_path) assert snapshot.main(["--local", _SOURCE, "--record"]) == 0 _fetched(monkeypatch, tmp_path, "Eve Moe, DO", date="2026-10-22T09:00:04") with pytest.raises(SystemExit, match="recorded from another fetch") as raised: snapshot.main(["--local", _SOURCE]) assert _DATE in str(raised.value) and "2026-10-22T09:00:04" in str(raised.value) assert snapshot.main(["--local", _SOURCE, "--record"]) == 0 assert snapshot.main(["--local", _SOURCE]) == 0 def test_snapshot_records_a_local_snapshot_where_out_says( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: _fetched(monkeypatch, tmp_path) out = tmp_path / "elsewhere.jsonl" assert snapshot.main(["--local", _SOURCE, "--record", "--out", str(out)]) == 0 assert snapshot.row_count(out.read_text()) == 2 assert json.loads((tmp_path / "elsewhere.jsonl.source.json").read_text())[ "data_date"] == _DATE assert not (_folder(tmp_path) / "snapshot.jsonl").exists() def test_snapshot_refuses_a_damaged_local_corpus_with_the_way_out( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: (_fetched(monkeypatch, tmp_path) / "corpus.jsonl").write_text("") with pytest.raises(SystemExit, match="--refresh"): snapshot.main(["--local", _SOURCE, "--record"]) def test_snapshot_refuses_a_local_corpus_not_fetched( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) with pytest.raises(SystemExit, match="not fetched"): snapshot.main(["--local", _SOURCE]) def test_snapshot_refuses_since_with_local( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: # Fetched first: on an empty cache the not-fetched refusal answered # for this row, and the --since guard could go unnoticed. _fetched(monkeypatch, tmp_path) with pytest.raises(SystemExit) as raised: snapshot.main(["--local", _SOURCE, "--since", "v2.5.0"]) assert raised.value.code == 2 assert "--since reads a committed snapshot" in capsys.readouterr().err def test_snapshot_reads_a_local_name_holding_a_line_break( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: # The first fix escaped the cache's files; snapshot.py's own dump and # load still wrote the parsed name raw and cut it at the break. _fetched(monkeypatch, tmp_path, "Cy\x85Doe, PhD", "Ann\u2028Lee, MD", "Bo Wu") assert snapshot.main(["--local", _SOURCE, "--record"]) == 0 assert snapshot.main(["--local", _SOURCE]) == 0 @pytest.mark.parametrize("sidecar", ["{", "[]", None], ids=["not json", "not a dict", "missing"]) def test_snapshot_refuses_a_snapshot_with_no_readable_fetch_before_reading_names( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, sidecar: str | None) -> None: folder = _fetched(monkeypatch, tmp_path) assert snapshot.main(["--local", _SOURCE, "--record"]) == 0 record = folder / "snapshot.jsonl.source.json" if sidecar is None: record.unlink() else: record.write_text(sidecar) def unread(entries: object) -> list[dict[str, Any]]: raise AssertionError("parsed every name before refusing") monkeypatch.setattr(snapshot, "read", unread) with pytest.raises(SystemExit, match="recorded from another fetch"): snapshot.main(["--local", _SOURCE]) def _unread(*args: object, **kwargs: object) -> list[dict[str, Any]]: raise AssertionError("parsed the names") def test_snapshot_refuses_a_check_with_no_snapshot_before_reading_names( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: _fetched(monkeypatch, tmp_path) monkeypatch.setattr(snapshot, "read", _unread) with pytest.raises(SystemExit, match="no snapshot"): snapshot.main(["--local", _SOURCE]) def test_a_local_snapshot_does_not_read_the_facade( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: # A name the facade reads otherwise: the committed snapshot records # its facade, a local one does not. folder = _fetched(monkeypatch, tmp_path, "Ph. D., John") assert "facade" in snapshot.read([("Ph. D., John", None)])[0] assert snapshot.main(["--local", _SOURCE, "--record"]) == 0 assert not any("facade" in r for r in snapshot.load( (folder / "snapshot.jsonl").read_text()).values()) def test_a_local_snapshot_recorded_with_the_facade_compares_without_it( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: folder = _fetched(monkeypatch, tmp_path) assert snapshot.main(["--local", _SOURCE, "--record"]) == 0 recorded = folder / "snapshot.jsonl" rows = [json.loads(r) for r in recorded.read_text().splitlines()] rows[1]["facade"] = {"last": "Elsewhere"} # rows[0] is the header recorded.write_text("".join(json.dumps(r) + "\n" for r in rows)) capsys.readouterr() assert snapshot.main(["--local", _SOURCE]) == 0 assert "not compared: facade" in capsys.readouterr().out _MANY = tuple(f"Ann Lee{i}, MD" for i in range(40)) def test_a_sample_reads_against_a_snapshot_of_its_own( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: folder = _fetched(monkeypatch, tmp_path, *_MANY) assert snapshot.main(["--local", _SOURCE, "--record", "--sample", "3"]) == 0 kept = snapshot.load((folder / "snapshot.sample-3.jsonl").read_text()) assert 0 < len(kept) < len(_MANY) assert not (folder / "snapshot.jsonl").exists() capsys.readouterr() assert snapshot.main(["--local", _SOURCE, "--sample", "3"]) == 0 assert f"a 1-in-3 sample: {len(kept)} of {len(_MANY)} entries" in capsys.readouterr().out with pytest.raises(SystemExit, match="no snapshot"): snapshot.main(["--local", _SOURCE]) def test_a_sample_is_never_compared_with_a_snapshot_of_another( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: folder = _fetched(monkeypatch, tmp_path, *_MANY) whole = str(folder / "snapshot.jsonl") assert snapshot.main(["--local", _SOURCE, "--record", "--sample", "3", "--out", whole]) == 0 with pytest.raises(SystemExit, match="recorded from a 1-in-3 sample"): snapshot.main(["--local", _SOURCE]) @pytest.mark.parametrize("argv", [["--sample", "3"], ["--local", _SOURCE, "--sample", "1"]], ids=["without --local", "N below 2"]) def test_snapshot_refuses_a_sample_it_cannot_take( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, argv: list[str]) -> None: _fetched(monkeypatch, tmp_path) with pytest.raises(SystemExit) as raised: snapshot.main(argv) assert raised.value.code == 2 def test_a_local_recording_and_check_run_in_workers( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: # The wiring only main assembles: spawned workers importing this # file by name, reading the names main gave them. folder = _fetched(monkeypatch, tmp_path, *_MANY) monkeypatch.setattr(snapshot, "PARALLEL_FLOOR", 1) assert snapshot.main(["--local", _SOURCE, "--record", "--jobs", "2"]) == 0 serial = list(snapshot.load((folder / "snapshot.jsonl").read_text()).values()) assert serial == snapshot.read([(n, None) for n in sorted(_MANY)], facade=False) capsys.readouterr() assert snapshot.main(["--local", _SOURCE, "--jobs", "2"]) == 0 assert "matches the snapshot" in capsys.readouterr().out def _digest(monkeypatch: pytest.MonkeyPatch, *values: str) -> None: """snapshot._source_digest answering `values` in turn, then the last.""" answers = list(values) monkeypatch.setattr(snapshot, "_source_digest", lambda root: answers.pop(0) if len(answers) > 1 else answers[0]) def _baselines(folder: Path) -> list[Path]: return sorted((folder / "baselines").glob("*.jsonl.gz")) def test_a_recording_is_reused_for_the_same_package_and_fetch( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: folder = _fetched(monkeypatch, tmp_path) _digest(monkeypatch, "a" * 64) assert snapshot.main(["--local", _SOURCE, "--record"]) == 0 recorded = (folder / "snapshot.jsonl").read_text() assert len(_baselines(folder)) == 1 (folder / "snapshot.jsonl").unlink() (folder / "snapshot.jsonl.source.json").unlink() real_read = snapshot.read monkeypatch.setattr(snapshot, "read", _unread) capsys.readouterr() assert snapshot.main(["--local", _SOURCE, "--record"]) == 0 assert "copied from" in capsys.readouterr().out assert (folder / "snapshot.jsonl").read_text() == recorded # The copy wrote its fetch record too, so a check can read against it. monkeypatch.setattr(snapshot, "read", real_read) assert snapshot.main(["--local", _SOURCE]) == 0 def test_a_recording_is_not_reused_for_another_package_sample_or_fetch( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: _fetched(monkeypatch, tmp_path) _digest(monkeypatch, "a" * 64) assert snapshot.main(["--local", _SOURCE, "--record"]) == 0 monkeypatch.setattr(snapshot, "read", _unread) _digest(monkeypatch, "b" * 64) with pytest.raises(AssertionError, match="parsed the names"): snapshot.main(["--local", _SOURCE, "--record"]) _digest(monkeypatch, "a" * 64) with pytest.raises(AssertionError, match="parsed the names"): snapshot.main(["--local", _SOURCE, "--record", "--sample", "2"]) _fetched(monkeypatch, tmp_path, "Eve Moe, DO") with pytest.raises(AssertionError, match="parsed the names"): snapshot.main(["--local", _SOURCE, "--record"]) def test_the_baseline_key_is_taken_from_the_tree_that_is_read( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: # Keyed by the checkout running the tool instead of --tree's, a base # recorded after the branch would copy the branch's readings, and # the check would report no moves. _fetched(monkeypatch, tmp_path) base = (tmp_path / "base").resolve() asked: list[Path] = [] def digest(root: Path) -> str: asked.append(root) return ("b" if root == base else "a") * 64 monkeypatch.setattr(snapshot, "_source_digest", digest) assert snapshot.main(["--local", _SOURCE, "--record"]) == 0 monkeypatch.setattr(snapshot, "read", _unread) with pytest.raises(AssertionError, match="parsed the names"): snapshot.main(["--local", _SOURCE, "--record", "--tree", str(base)]) assert asked[-1] == base def test_a_package_that_changes_during_the_read_keeps_no_baseline( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: folder = _fetched(monkeypatch, tmp_path) _digest(monkeypatch, "a" * 64, "b" * 64) assert snapshot.main(["--local", _SOURCE, "--record"]) == 0 assert "changed while it was read" in capsys.readouterr().out assert (folder / "snapshot.jsonl").exists() and not _baselines(folder) @pytest.mark.parametrize("damage", ["truncated", "corrupt", "short", "empty"]) def test_a_damaged_baseline_is_deleted_and_the_names_read( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str], damage: str) -> None: folder = _fetched(monkeypatch, tmp_path) _digest(monkeypatch, "a" * 64) assert snapshot.main(["--local", _SOURCE, "--record"]) == 0 recorded = (folder / "snapshot.jsonl").read_text() [kept] = _baselines(folder) if damage == "truncated": kept.write_bytes(kept.read_bytes()[:-12]) elif damage == "corrupt": # The first deflate byte, past the 10-byte header the keep # writes: a bad block header raises zlib.error, not an OSError. data = bytearray(kept.read_bytes()) data[10] ^= 0xFF kept.write_bytes(bytes(data)) else: with gzip.open(kept, "wt", encoding="utf-8") as f: f.write(recorded.splitlines(keepends=True)[0] if damage == "short" else "") capsys.readouterr() assert snapshot.main(["--local", _SOURCE, "--record"]) == 0 out = capsys.readouterr().out assert "deleted the kept baseline" in out and "copied from" not in out assert (folder / "snapshot.jsonl").read_text() == recorded [rewritten] = _baselines(folder) with gzip.open(rewritten, "rt", encoding="utf-8") as f: assert f.read() == recorded def test_a_baseline_that_cannot_be_kept_leaves_the_recording( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: folder = _fetched(monkeypatch, tmp_path) _digest(monkeypatch, "a" * 64) def full(fd: int) -> None: raise OSError(28, "No space left on device") monkeypatch.setattr(snapshot.os, "fsync", full) assert snapshot.main(["--local", _SOURCE, "--record"]) == 0 out = capsys.readouterr().out assert "baseline was not kept" in out and "recorded 2 readings" in out assert (folder / "snapshot.jsonl").exists() and not _baselines(folder) def test_the_least_recently_used_baselines_and_stale_partials_are_dropped( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: folder = _fetched(monkeypatch, tmp_path) monkeypatch.setattr(snapshot, "BASELINES_KEPT", 2) (folder / "baselines").mkdir() stale = folder / "baselines" / "x.jsonl.gz.1.partial" fresh = folder / "baselines" / "y.jsonl.gz.2.partial" stale.write_text("") fresh.write_text("") os.utime(stale, (1_000_000, 1_000_000)) for i, tree in enumerate("abc"): _digest(monkeypatch, tree * 64) assert snapshot.main(["--local", _SOURCE, "--record"]) == 0 [new] = [p for p in _baselines(folder) if f"-{tree * 16}-" in p.name] os.utime(new, (2_000_000 + i, 2_000_000 + i)) kept = [p.name for p in _baselines(folder)] assert len(kept) == 2 and not any(f"-{'a' * 16}-" in k for k in kept) assert not stale.exists() and fresh.exists() def test_the_source_digest_is_the_package_as_it_is_on_disk(tmp_path: Path) -> None: import shutil as sh one, two = tmp_path / "one", tmp_path / "two" for root in (one, two): (root / "nameparser").mkdir(parents=True) (root / "nameparser" / "a.py").write_text("x = 1\n") assert snapshot._source_digest(one) == snapshot._source_digest(two) first = snapshot._source_digest(one) (one / "nameparser" / "__pycache__").mkdir() (one / "nameparser" / "__pycache__" / "a.cpython-311.pyc").write_bytes(b"x") assert snapshot._source_digest(one) == first # bytecode caches aside for change in ("edited", "added", "renamed"): sh.rmtree(two) (two / "nameparser").mkdir(parents=True) files = {"edited": {"a.py": "x = 2\n"}, "added": {"a.py": "x = 1\n", "b.pyc": "sourceless"}, "renamed": {"b.py": "x = 1\n"}}[change] for name, body in files.items(): (two / "nameparser" / name).write_text(body) assert snapshot._source_digest(two) != first, change def test_the_source_digest_follows_a_symlinked_subpackage(tmp_path: Path) -> None: # The import follows the link, so the digest must read through it. digests: list[str] = [] for target in ("x = 1\n", "x = 2\n"): root = tmp_path / f"tree{len(digests)}" (root / "real" / "sub").mkdir(parents=True) (root / "real" / "sub" / "m.py").write_text(target) (root / "nameparser").mkdir() (root / "nameparser" / "sub").symlink_to(root / "real" / "sub") digests.append(snapshot._source_digest(root)) assert digests[0] != digests[1] # -- snapshot.py --local --base ------------------------------------------------- def _shared(folder: Path) -> dict[str, tuple[int, bytes]]: """The shared snapshot's files beside a local corpus, as mtime and bytes, for a check that --base left them alone.""" return {p.name: (p.stat().st_mtime_ns, p.read_bytes()) for p in sorted(folder.glob("snapshot*"))} def test_a_base_check_reports_a_change_planted_in_the_base_and_none_across_a_copy( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: # The harness must be able to report a difference: a base read from # the head's tree by mistake would show no move across a planted one. folder = _fetched(monkeypatch, tmp_path) planted = checkout_copy(tmp_path / "planted", planted=True) assert snapshot.main(["--local", _SOURCE, "--base", str(planted)]) == 1 out = capsys.readouterr().out assert f"base: {planted.resolve()}, nameparser/ " in out and "recorded it now" in out assert "2 entries differ from the base" in out # The control: an unplanted copy is the head's package byte for byte. copy = checkout_copy(tmp_path / "copy") assert snapshot.main(["--local", _SOURCE, "--base", str(copy)]) == 0 out = capsys.readouterr().out assert "matches the base" in out and "byte-identical" in out assert len(_baselines(folder)) == 2 def test_a_base_check_never_touches_the_shared_snapshot( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: folder = _fetched(monkeypatch, tmp_path) base = checkout_copy(tmp_path / "base", planted=True) assert snapshot.main(["--local", _SOURCE, "--base", str(base)]) == 1 assert not _shared(folder) # none was there, and none was written assert snapshot.main(["--local", _SOURCE, "--record"]) == 0 before = _shared(folder) assert set(before) == {"snapshot.jsonl", "snapshot.jsonl.source.json"} # A shared snapshot that would read as a move, ignored by --base. (folder / "snapshot.jsonl").write_text("") before = _shared(folder) copy = checkout_copy(tmp_path / "copy") assert snapshot.main(["--local", _SOURCE, "--base", str(copy)]) == 0 assert snapshot.main(["--local", _SOURCE, "--base", str(copy), "--sample", "2"]) == 0 assert _shared(folder) == before assert not list((folder / "baselines").glob(".base-*")) # the child's scratch is gone def test_a_kept_base_is_reused_without_reading_it_again( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: _fetched(monkeypatch, tmp_path) base = checkout_copy(tmp_path / "base", planted=True) assert snapshot.main(["--local", _SOURCE, "--base", str(base)]) == 1 capsys.readouterr() def unrecorded(*args: object) -> None: raise AssertionError("recorded the base again") monkeypatch.setattr(snapshot, "_record_base", unrecorded) assert snapshot.main(["--local", _SOURCE, "--base", str(base)]) == 1 out = capsys.readouterr().out assert "reused the kept baseline" in out and "2 entries differ" in out # An edit to the base keys it as another package, read again. (base / "nameparser" / "_edited.py").write_text("") with pytest.raises(AssertionError, match="recorded the base again"): snapshot.main(["--local", _SOURCE, "--base", str(base)]) def test_a_base_check_composes_with_a_sample( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: folder = _fetched(monkeypatch, tmp_path, *(f"Ann Lee{n}, MD" for n in range(12))) base = checkout_copy(tmp_path / "base", planted=True) assert snapshot.main(["--local", _SOURCE, "--base", str(base), "--sample", "2"]) == 1 out = capsys.readouterr().out held = int(re.search(r"a 1-in-2 sample: (\d+) of 12 entries", out)[1]) # type: ignore[index] assert 0 < held < 12 and f"{held} entries differ from the base" in out [kept] = _baselines(folder) assert kept.name.endswith("-sample-2.jsonl.gz") def test_a_base_that_is_the_tree_read_or_no_checkout_is_refused( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: _fetched(monkeypatch, tmp_path) with pytest.raises(SystemExit, match="is the tree being read"): snapshot.main(["--local", _SOURCE, "--base", str(snapshot.REPO_ROOT)]) other = checkout_copy(tmp_path / "other") with pytest.raises(SystemExit, match="is the tree being read"): snapshot.main(["--local", _SOURCE, "--base", str(other), "--tree", str(other)]) with pytest.raises(SystemExit, match="no nameparser package"): snapshot.main(["--local", _SOURCE, "--base", str(tmp_path / "empty")]) assert not _baselines(_folder(tmp_path)) def test_a_base_whose_import_fails_is_refused_with_the_childs_output( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: _fetched(monkeypatch, tmp_path) base = checkout_copy(tmp_path / "base") with open(base / "nameparser" / "__init__.py", "a", encoding="utf-8") as f: f.write("\nraise ImportError('planted')\n") with pytest.raises(SystemExit, match="(?s)recording the base .* failed.*planted"): snapshot.main(["--local", _SOURCE, "--base", str(base)]) assert not _baselines(_folder(tmp_path)) def test_a_base_child_is_started_without_the_import_path_overrides( monkeypatch: pytest.MonkeyPatch) -> None: # A unit check, not an end-to-end one: --tree's sys.path entry already # outranks a PYTHONPATH entry in the child, so no decoy can show the # strip; it is kept as compare.py's worker keeps it, against an # import made before the insert. monkeypatch.setenv("PYTHONPATH", "/decoy") monkeypatch.setenv("PYTHONHOME", "/decoy") env = snapshot.child_env() assert "PYTHONPATH" not in env and "PYTHONHOME" not in env and env def test_a_kept_baseline_another_run_pruned_is_read_as_none_kept( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: # Pruned between a lookup and the open, it is not damage to delete: # read as damage, its unlink raised on the file already gone. kept = tmp_path / "gone.jsonl.gz" with gzip.open(kept, "wt", encoding="utf-8") as f: f.write("{}\n{}\n") real_open = gzip.open def pruned_first(path: Path, mode: str, encoding: str) -> object: Path(path).unlink() return real_open(path, mode, encoding=encoding) monkeypatch.setattr(snapshot.gzip, "open", pruned_first) assert snapshot._reused(kept, 2) is None assert "deleted the kept baseline" not in capsys.readouterr().out def test_a_scratch_folder_a_killed_run_left_is_swept_once_stale(tmp_path: Path) -> None: stale, fresh = snapshot.scratch(tmp_path), snapshot.scratch(tmp_path) (stale / "base.jsonl").write_text("x\n") os.utime(stale, (1_000_000, 1_000_000)) snapshot._prune(tmp_path) assert not stale.exists() and fresh.exists() @pytest.mark.parametrize("extra", [["--record"], ["--out", "x.jsonl"]]) def test_a_base_check_refuses_to_record( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str], extra: list[str]) -> None: _fetched(monkeypatch, tmp_path) with pytest.raises(SystemExit) as raised: snapshot.main(["--local", _SOURCE, "--base", str(tmp_path), *extra]) assert raised.value.code == 2 and "--base compares" in capsys.readouterr().err def test_a_base_check_needs_a_local_corpus(capsys: pytest.CaptureFixture[str]) -> None: with pytest.raises(SystemExit) as raised: snapshot.main(["--base", "."]) assert raised.value.code == 2 and "--base reads a local corpus" in capsys.readouterr().err def test_a_baseline_another_run_pruned_is_not_reported_as_unkept( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: _fetched(monkeypatch, tmp_path) _digest(monkeypatch, "a" * 64) real_mtime = snapshot._mtime def vanished(path: Path) -> float | None: path.unlink() return real_mtime(path) monkeypatch.setattr(snapshot, "_mtime", vanished) assert snapshot.main(["--local", _SOURCE, "--record"]) == 0 assert "not kept" not in capsys.readouterr().out # -- the views in workers, and alike against a base ---------------------------------- #: Two pairs that read alike and one that does not (a title on one side). _NEPAL_RECORDS = [{"name": "Ram Thapa", "devanagari": "राम थापा"}, {"name": "Sita Kumari Rai", "devanagari": "सीता कुमारी राई"}, {"name": "Dr Hari Shrestha", "devanagari": "हरि श्रेष्ठ"}] def _spelled(*spellings: tuple[str, str]) -> object: """A jrc-names corpus holding each (entity, name) of `spellings`.""" return local.Local(local.JRC, {}, [{"entity": e, "name": n, "script": "Latin"} for e, n in spellings]) def _by_name_snapshot() -> types.ModuleType: """The snapshot module local_corpora imports by its bare name, whose PARALLEL_FLOOR decides whether a view reads in workers.""" return importlib.import_module("snapshot") def _nepal(monkeypatch: pytest.MonkeyPatch, records: list[dict[str, str]] = _NEPAL_RECORDS ) -> None: # The election pinned as the real source pins it: a --base child # loads the corpus under the real Source and its check. plant_corpus(monkeypatch, local, _NEPAL, records, "2026-10-24", "name pairs", {"election": importlib.import_module("local_nepal").NEPAL_ELECTION}) def test_each_source_that_pairs_spellings_has_an_alike_kind() -> None: entity = _spelled(("1", "Ann Lee"), ("1", "Анна Ли")) assert local.alike_kind(entity) == "entity" assert local.alike_kind(local.Local(_NEPAL, {}, _NEPAL_RECORDS)) == "devanagari" assert local.alike_kind(_paired(("ANA SILVA", "ANA DA SILVA"))) == "registry" with pytest.raises(local.Ungroupable, match="pair no two spellings"): local.alike_kind(local.Local(_SOURCE, {}, [{"name": "Ann Lee"}])) def test_alike_reads_each_kind_of_pair() -> None: # A person read alike and one read apart, of each kind; a person # spelled once and a pair spelled the same are no one's to count. entity = local.alike_flags(_spelled(("1", "Ann Lee"), ("1", "Анна Ли"), ("2", "Dr. Bo Wu"), ("2", "Bo Wu"), ("3", "Cy Fox"))) assert entity == {"1": True, "2": False} assert local.alike_flags(local.Local(_NEPAL, {}, _NEPAL_RECORDS)) == { "0": True, "1": True, "2": False} registry = local.alike_flags(_paired(("ANA SILVA", "ANA DA SILVA"), ("ANA SILVA", "ANA SOUZA"), ("BO WU", "BO WU"), ("CY FOX", ""))) assert registry == {"0": True, "1": False} # A pair with no second spelling is no one's to count, either way. blank = local.alike_flags(local.Local(_NEPAL, {}, [*_NEPAL_RECORDS, {"name": "Ram Rai", "devanagari": " "}])) assert set(blank) == {"0", "1", "2"} def test_the_views_read_in_workers_as_they_read_in_process( monkeypatch: pytest.MonkeyPatch) -> None: grouped = _spelled(("1", "Ann Lee"), ("1", "Анна Ли"), ("2", "Dr. Bo Wu"), ("2", "Bo Wu")) paired = _paired(("ANA SILVA", "ANA DA SILVA"), ("ANA SILVA", "ANA SOUZA"), ("JOSÉ SILVA RÁ", "JOSE SILVA RA")) nepal = local.Local(_NEPAL, {}, _NEPAL_RECORDS) serial = (local.group_counts(grouped), local.pair_counts(paired, agreement=True), local.alike_flags(grouped), local.alike_flags(paired), local.alike_flags(nepal)) monkeypatch.setattr(_by_name_snapshot(), "PARALLEL_FLOOR", 1) assert (local.group_counts(grouped, jobs=2), local.pair_counts(paired, agreement=True, jobs=2), local.alike_flags(grouped, jobs=2), local.alike_flags(paired, jobs=2), local.alike_flags(nepal, jobs=2)) == serial def test_a_view_whose_worker_read_another_nameparser_aborts( monkeypatch: pytest.MonkeyPatch) -> None: # The negative control for snapshot.parallel's check, through a view: # the workers import the real package, so a parent that believes it # imported another sees every chunk come back from elsewhere. import nameparser monkeypatch.setattr(_by_name_snapshot(), "PARALLEL_FLOOR", 1) monkeypatch.setattr(nameparser, "__file__", "/elsewhere/nameparser/__init__.py") with pytest.raises(SystemExit, match="a worker imported nameparser from"): local.alike_flags(local.Local(_NEPAL, {}, _NEPAL_RECORDS), jobs=2) def _alike_kept(cache: Path) -> list[Path]: return sorted((cache / "local" / _NEPAL / local.ALIKE_KEPT).glob("*.json")) def test_alike_against_a_planted_base_reports_moves_and_none_across_a_copy( monkeypatch: pytest.MonkeyPatch, _own_cache: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: # The harness must be able to report a difference: a base read with # the head's tree by mistake would show no move across a planted one. _nepal(monkeypatch) planted = checkout_copy(tmp_path / "planted", planted=True) assert local.main(["alike", _NEPAL, "--base", str(planted)]) == 1 out = capsys.readouterr().out assert f"base: {planted.resolve()}, nameparser/ " in out and "read it now" in out assert "2 of 3 pairs read alike" in out assert "0 pairs now alike, 0 now apart" not in out for record in _NEPAL_RECORDS: # counts only assert record["name"] not in out and record["devanagari"] not in out copy = checkout_copy(tmp_path / "copy") assert local.main(["alike", _NEPAL, "--base", str(copy)]) == 0 out = capsys.readouterr().out assert "0 pairs now alike, 0 now apart" in out and "byte-identical" in out assert len(_alike_kept(_own_cache)) == 2 def test_a_kept_base_is_reused_and_a_damaged_one_read_again( monkeypatch: pytest.MonkeyPatch, _own_cache: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: _nepal(monkeypatch) base = checkout_copy(tmp_path / "base", planted=True) assert local.main(["alike", _NEPAL, "--base", str(base)]) == 1 [kept] = _alike_kept(_own_cache) recorded = kept.read_bytes() capsys.readouterr() real = local._alike_base def unread(*args: object) -> None: raise AssertionError("read the base again") monkeypatch.setattr(local, "_alike_base", unread) assert local.main(["alike", _NEPAL, "--base", str(base)]) == 1 assert "reused the kept flags" in capsys.readouterr().out body = json.loads(recorded) for damage in ("not json", json.dumps({**body, "count": body["count"] + 1}), json.dumps({**body, "flags": {k: 1 for k in body["flags"]}}), json.dumps({**body, "flags": list(body["flags"])}), json.dumps({**body, "raw_sha256": "0" * 64}), json.dumps({**body, "code": "another"})): kept.write_text(damage) with pytest.raises(AssertionError, match="read the base again"): local.main(["alike", _NEPAL, "--base", str(base)]) assert "deleted the kept flags" in capsys.readouterr().out monkeypatch.setattr(local, "_alike_base", real) assert local.main(["alike", _NEPAL, "--base", str(base)]) == 1 assert kept.read_bytes() == recorded assert not list(kept.parent.glob(".base-*")) # the child's scratch is gone monkeypatch.setattr(local, "_alike_base", unread) def test_a_base_that_changed_while_it_was_read_keeps_no_flags( monkeypatch: pytest.MonkeyPatch, _own_cache: Path, tmp_path: Path) -> None: _nepal(monkeypatch) base = checkout_copy(tmp_path / "base", planted=True) answers = ["a" * 64, "b" * 64] published: list[bool] = [] def digest(root: Path) -> str: if not answers: return "c" * 64 if len(answers) == 1: # the check after the read: nothing kept yet published.append(bool(_alike_kept(_own_cache))) return answers.pop(0) monkeypatch.setattr(_by_name_snapshot(), "_source_digest", digest) with pytest.raises(SystemExit, match="changed while it was read"): local.main(["alike", _NEPAL, "--base", str(base)]) assert published == [False] assert not _alike_kept(_own_cache) assert not list((_own_cache / "local" / _NEPAL / local.ALIKE_KEPT).glob(".base-*")) def test_alike_refuses_a_tree_it_did_not_import_and_a_base_with_no_package( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: _nepal(monkeypatch) monkeypatch.setattr(sys, "path", list(sys.path)) # --tree puts the tree on it with pytest.raises(SystemExit, match="not from the source package"): local.main(["alike", _NEPAL, "--tree", str(tmp_path)]) with pytest.raises(SystemExit, match="no nameparser package"): local.main(["alike", _NEPAL, "--base", str(tmp_path)]) def test_an_alike_base_whose_import_fails_is_refused_with_the_childs_output( monkeypatch: pytest.MonkeyPatch, _own_cache: Path, tmp_path: Path) -> None: _nepal(monkeypatch) base = checkout_copy(tmp_path / "base") with open(base / "nameparser" / "__init__.py", "a", encoding="utf-8") as f: f.write("\nraise ImportError('planted')\n") with pytest.raises(SystemExit, match="(?s)reading the base .* failed.*planted"): local.main(["alike", _NEPAL, "--base", str(base)]) assert not _alike_kept(_own_cache) def test_the_alike_key_lists_everything_its_functions_call_and_read() -> None: # _ALIKE_CODE is kept by hand, as _FOLD_CODE is: a helper the view # starts calling left off it leaves a kept base's key unmoved by a # change to that helper, and the base reads as the old code read it. import inspect module = vars(local) read = set().union(*(_globals_read(inspect.unwrap(module[name]).__code__) for name in local._ALIKE_CODE)) unlisted = set() for name in read & module.keys(): value = module[name] if isinstance(value, types.ModuleType) or name.startswith("__"): continue if getattr(value, "__module__", None) != local.__name__ and not name.isupper() \ and not name.startswith("_"): continue # imported: Counter if name in (*local._ALIKE_CODE, *local._ALIKE_CONSTANTS, "Reading", "_Read", "Ungroupable"): continue unlisted.add(name) assert unlisted == set() @pytest.mark.parametrize("change", [*local._ALIKE_CODE, *local._ALIKE_CONSTANTS]) def test_each_thing_the_alike_key_lists_moves_it(monkeypatch: pytest.MonkeyPatch, change: str) -> None: before = local.alike_fingerprint() monkeypatch.setattr(local, change, _another(change)) assert local.alike_fingerprint() != before def test_alike_against_a_base_counts_each_direction_apart() -> None: nepal = local.Local(_NEPAL, {}, _NEPAL_RECORDS) assert local.format_alike(nepal, {"0": True, "1": True}, {"0": False, "1": True}) == [ "2 of 2 pairs read alike (1 in the base)", "1 pairs now alike, 0 now apart"] assert local.format_alike(nepal, {"0": False, "1": True}, {"0": True, "1": True}) == [ "1 of 2 pairs read alike (2 in the base)", "0 pairs now alike, 1 now apart"] assert local.format_alike(nepal, {"0": True}) == ["1 of 1 pairs read alike"] with pytest.raises(SystemExit, match="other persons"): local.format_alike(nepal, {"0": True, "1": True}, {"0": True}) # -- the box index ------------------------------------------------------------------ def _extract(path: Path, header: dict[str, object], lines: list[dict[str, object]]) -> Path: """An extract written as the fetch writes one: a header line, then a record a line, gzipped.""" path.parent.mkdir(parents=True, exist_ok=True) text = "".join(json.dumps(x) + "\n" for x in (header, *lines)) path.write_bytes(gzip.compress(text.encode(), mtime=0)) return path def _pv_extract(*lines: dict[str, object]) -> Path: return _extract(patentsview.extract_path(), {"release": patentsview.PV_RELEASE}, list(lines)) _PV_LINES = ( {"given": "Ana María", "family": "del Río", "country": "ES", "people": 3}, {"given": "Juan del", "family": "del Castillo del Río", "country": "MX", "people": 2}, {"given": "Ann", "family": "Lee", "country": "US", "people": 10}, {"given": "Bo", "family": "DEL.", "people": 1}) def test_the_box_index_counts_people_by_box_and_country_once_a_record() -> None: index = local.box_index(patentsview.pv_boxes(_pv_extract(*_PV_LINES))) assert index.totals == {"ES": 3, "MX": 2, "US": 10, "": 1} # 'del Castillo del Río' counts del once; 'DEL.' folds to it as the word # index folds it; the country it is a share of is every record's. assert index.family["del"] == {"ES": 3, "MX": 2, "": 1} assert index.given["del"] == {"MX": 2} assert index.given["maría"] == {"ES": 3} and "lee" not in index.given def test_an_orcid_holder_weighs_one(_own_cache: Path) -> None: path = _extract(orcid.extract_path(), {"file": orcid.ORCID_FILE}, [ {"given": "Ann", "family": "Lee", "country": "NZ"}, {"given": "Ann"}]) index = local.box_index(orcid.orcid_boxes(path)) assert index.totals == {"NZ": 1, "": 1} and index.given["ann"] == {"NZ": 1, "": 1} @pytest.mark.parametrize(("reader", "path", "header"), [ ("orcid", "orcid", {"file": "ORCID_2024_10_summaries.tar.gz"}), ("patentsview", "patentsview", {"release": "2024-12-31"})]) def test_an_extract_of_another_release_is_refused_by_its_reader( reader: str, path: str, header: dict[str, object]) -> None: module = {"orcid": orcid, "patentsview": patentsview}[reader] boxes = module.orcid_boxes if reader == "orcid" else module.pv_boxes written = _extract(module.extract_path(), header, []) with pytest.raises(ValueError, match="is not an extract of"): list(boxes(written)) def test_the_cached_box_index_is_read_back_under_its_key_and_rebuilt_off_it( capsys: pytest.CaptureFixture[str]) -> None: _pv_extract(*_PV_LINES) built = local.cached_box_index(local.PV) cache = local.directory(local.PV) / local.BOX_INDEX assert "building the box index" in capsys.readouterr().err assert local.cached_box_index(local.PV) == built assert capsys.readouterr().err == "" # read back, not built again # A refetched extract is other bytes: the index is built from them. _pv_extract(*_PV_LINES[:2]) assert local.cached_box_index(local.PV).totals == {"ES": 3, "MX": 2} assert "rebuilding the box index" in capsys.readouterr().err assert json.loads(gzip.decompress(cache.read_bytes()))["totals"] == {"ES": 3, "MX": 2} @pytest.mark.parametrize("damage", [b"not gzip", gzip.compress(b"{", mtime=0), gzip.compress(b'{"key": 1}', mtime=0)], ids=["not gzip", "not JSON", "no counts"]) def test_a_cached_box_index_that_cannot_be_read_is_built_over( capsys: pytest.CaptureFixture[str], damage: bytes) -> None: _pv_extract(*_PV_LINES) cache = local.directory(local.PV) / local.BOX_INDEX cache.write_bytes(damage) assert local.cached_box_index(local.PV).totals["US"] == 10 assert "rebuilding the box index" in capsys.readouterr().err def test_a_box_index_without_its_extract_names_the_refresh() -> None: local.directory(local.PV) with pytest.raises(ValueError, match="is missing.*fetch patentsview-inventors --refresh"): local.cached_box_index(local.PV) def test_a_change_to_the_box_reader_moves_the_key(monkeypatch: pytest.MonkeyPatch) -> None: before = local.box_fingerprint(local.PV) monkeypatch.setitem(local.SOURCES, local.PV, dataclasses.replace( local.SOURCES[local.PV], boxes=lambda path: iter(()))) assert local.box_fingerprint(local.PV) != before def test_the_box_fingerprint_lists_everything_box_index_calls() -> None: # box_fingerprint's list is kept by hand, as _FOLD_CODE is: a helper # box_index starts calling, left off it, leaves the key unmoved. import inspect module = vars(local) listed = {"box_index", "words", "word_key", "_strippable", "_keyed", *local._FOLD_CONSTANTS, "BoxIndex", "Counter"} read: set[str] = set() for name in ("box_index", "words", "word_key", "_keyed"): read |= _globals_read(inspect.unwrap(module[name]).__code__) called = {n for n in read & module.keys() if not isinstance(module[n], types.ModuleType)} assert called - listed == set() @pytest.mark.parametrize("damage", ["truncated", "corrupt", "empty", "no people"]) def test_an_extract_that_cannot_be_read_fails_as_a_value_error(damage: str) -> None: # _attempt in name_usage catches ValueError and OSError: anything else # gzip, json or the reader raised took every row of the run down. path = _pv_extract(*_PV_LINES) body = path.read_bytes() if damage == "truncated": path.write_bytes(body[: len(body) // 2]) elif damage == "corrupt": path.write_bytes(body[:12] + bytes(b ^ 0xFF for b in body[12:40]) + body[40:]) elif damage == "empty": path.write_bytes(gzip.compress(b"", mtime=0)) else: _pv_extract({"given": "Ann", "family": "Lee", "country": "US"}) with pytest.raises(ValueError, match="could not be read.*--refresh"): local.cached_box_index(local.PV) def test_a_cached_box_index_whose_deflate_is_damaged_is_built_over( capsys: pytest.CaptureFixture[str]) -> None: _pv_extract(*_PV_LINES) local.cached_box_index(local.PV) cache = local.directory(local.PV) / local.BOX_INDEX body = cache.read_bytes() cache.write_bytes(body[:12] + bytes(b ^ 0xFF for b in body[12:40]) + body[40:]) assert local.cached_box_index(local.PV).totals["US"] == 10 assert "rebuilding the box index" in capsys.readouterr().err ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_local_jrc.py����������������������������������������0000664�0000000�0000000�00000061572�15273032717�0023754�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Offline checks on tools/differential/local_jrc.py, the jrc-names local corpus, and on local_corpora.py's groups view, which reads it. Every name here is invented: the JRC-Names licence bars redistributing the resource or anything derived from it, so not one of its names, nor a reading of one, may reach the repository. What these pin is the fetch's refusals (a file other than the pinned one, a gzip cut short, a file ending inside a line, a layout or a marking that has moved), what it keeps and counts, and the view's counting, every count a person's. """ import gzip import hashlib import importlib import unicodedata import urllib.request from pathlib import Path import pytest from ._differential_fixtures import load_tool, own_cache, plant_corpus local = load_tool("local_corpora") # The module the fetch reads its constants from, by name: a patch set on # a second load by path would reach nothing (test_local_corpora.py). jrc = importlib.import_module("local_jrc") _JRC = "jrc-names" @pytest.fixture(autouse=True) def _own_cache(monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory) -> Path: return own_cache(monkeypatch, tmp_path_factory) #: The header line, one person spelled three ways (one of them marked #: Russian, one repeated), one person whose words run "++", an #: organisation, and one person spelled twice, once marked Arabic with a #: glued "and": 9 lines. _LINES = ["#", "1\tP\tu\tAnn+Lee", "1\tP\tu\tАнна+Ли", "1\tP\tru\tАнны+Ли", "1\tP\tu\tAnn+Lee", "2\tP\tu\tBo++Wu", "3\tO\tu\tAcme+Corp", "4\tP\tar\tوأحمد+علي", "4\tP\tu\tأحمد+علي"] def _body(lines: list[str], end: bytes = b"\r\n") -> bytes: return b"".join(line.encode() + end for line in lines) def _cache(monkeypatch: pytest.MonkeyPatch, raw: bytes, *, pin: bool = True) -> Path: """`raw` as the cached download, pinned unless `pin` is False; its path.""" path = local.directory(_JRC) / jrc.JRC_FILE path.write_bytes(raw) if pin: monkeypatch.setattr(jrc, "JRC_SHA256", hashlib.sha256(raw).hexdigest()) return path def _cached(monkeypatch: pytest.MonkeyPatch, lines: list[str], **kw: object) -> Path: return _cache(monkeypatch, gzip.compress(_body(lines, **kw))) # type: ignore[arg-type] def _lenient(monkeypatch: pytest.MonkeyPatch, *, but: str = "") -> None: """Shares a fixture of 9 lines can pass: its header is 1 in 9, its marked rows 2 in 7 and its repeat 1 in 7. Every share but `but`.""" for share in ("JRC_MAX_BAD", "JRC_MAX_MARKED", "JRC_MAX_REPEATED", "JRC_MAX_LOST"): if share != but: monkeypatch.setattr(jrc, share, 1) def test_a_jrc_fetch_keeps_each_plain_spelling_once_and_counts_the_rest( monkeypatch: pytest.MonkeyPatch) -> None: _cached(monkeypatch, _LINES) _lenient(monkeypatch) corpus = local.fetch(_JRC) assert corpus.records == [ {"entity": "1", "name": "Ann Lee", "script": "Latin"}, {"entity": "1", "name": "Анна Ли", "script": "Cyrillic"}, {"entity": "2", "name": "Bo Wu", "script": "Latin"}, {"entity": "4", "name": "أحمد علي", "script": "Arabic"}] assert corpus.meta["data_date"] == jrc.JRC_DATE assert corpus.meta["detail"] == { "sha256": jrc.JRC_SHA256, "read_version": jrc.JRC_READ_VERSION, "unicode": unicodedata.unidata_version, "lines": 9, "lines_read_past": 1, "organisation_rows": 1, "person_rows": 7, "marked_variants_dropped": 2, "marked_variants_by_language": "ar:1 ru:1", "repeated_spellings": 1, "entities": 3, "entities_with_marked_spellings_only": 0, "entities_with_one_spelling": 2, "spellings_holding_a_replacement_character": 0} assert corpus.header().endswith("4 spellings (local corpus jrc-names)") def test_a_jrc_file_that_is_not_the_pinned_one_is_refused( monkeypatch: pytest.MonkeyPatch) -> None: _cache(monkeypatch, gzip.compress(_body(_LINES)), pin=False) with pytest.raises(ValueError, match="not the pinned JRC_SHA256.*fetch jrc-names --refresh"): local.fetch(_JRC) assert local.load(_JRC) is None @pytest.mark.parametrize("damage", ["cut short", "not a gzip", "corrupt"]) def test_a_jrc_gzip_that_is_not_whole_names_the_way_out( monkeypatch: pytest.MonkeyPatch, damage: str) -> None: # Pinned to the damaged bytes: the pin is what stops this file, and # a pin moved to a cut download must still not read as a corpus. whole = gzip.compress(_body(_LINES)) raw = {"cut short": whole[:-20], "not a gzip": _body(_LINES), "corrupt": whole[:20] + bytes(b ^ 0xFF for b in whole[20:40]) + whole[40:]}[damage] _cache(monkeypatch, raw) _lenient(monkeypatch) with pytest.raises(ValueError, match="not a whole gzip.*fetch jrc-names --refresh"): local.fetch(_JRC) @pytest.mark.parametrize(("lines", "but", "match"), [ ([*_LINES, "5\tP\tru\tАнны+Ли"], "JRC_MAX_LOST", "1 of 4 persons .* marked spellings only"), (_LINES, "JRC_MAX_REPEATED", "1 of 7 person rows .* repeat a spelling"), ], ids=["a person spelled only in marked rows", "a repeated spelling"]) def test_a_jrc_file_that_would_shrink_in_silence_is_refused( monkeypatch: pytest.MonkeyPatch, lines: list[str], but: str, match: str) -> None: _cached(monkeypatch, lines) _lenient(monkeypatch, but=but) monkeypatch.setattr(jrc, but, 0) with pytest.raises(ValueError, match=match): local.fetch(_JRC) monkeypatch.setattr(jrc, but, 1 / 4) detail = local.fetch(_JRC).meta["detail"] assert (detail["entities_with_marked_spellings_only"], detail["repeated_spellings"]) == ( (1, 1) if but == "JRC_MAX_LOST" else (0, 1)) def test_a_jrc_file_naming_no_person_is_refused(monkeypatch: pytest.MonkeyPatch) -> None: _cached(monkeypatch, ["#"]) _lenient(monkeypatch) with pytest.raises(ValueError, match="0 of 0 lines .* name a person"): local.fetch(_JRC) def test_a_jrc_spelling_is_read_as_written_but_for_its_spaces( monkeypatch: pytest.MonkeyPatch) -> None: # Ids are kept as written ("01" and "1" two entities), runs of "+" # and spaces are one space, and a replacement character is kept and # counted. _cached(monkeypatch, ["1\tP\tu\tAnn+ +Lee", "01\tP\tu\tAnn+Lee", "2\tP\tu\tBo\ufffd+Wu"]) _lenient(monkeypatch) corpus = local.fetch(_JRC) assert [(r["entity"], r["name"]) for r in corpus.records] == [ ("01", "Ann Lee"), ("1", "Ann Lee"), ("2", "Bo\ufffd Wu")] assert corpus.meta["detail"]["spellings_holding_a_replacement_character"] == 1 assert corpus.meta["detail"]["entities"] == 3 def test_a_jrc_file_ending_inside_a_line_is_refused(monkeypatch: pytest.MonkeyPatch) -> None: _cache(monkeypatch, gzip.compress(_body(_LINES)[:-6])) _lenient(monkeypatch) with pytest.raises(ValueError, match="ends inside a line"): local.fetch(_JRC) @pytest.mark.parametrize("bad", [ "5\tP\tu", "5\tP\tu\tAnn+Lee\tX", "x5\tP\tu\tAnn+Lee", "5\tQ\tu\tAnn+Lee", "5\tP\tRU\tAnn+Lee", "5\tP\tu\t+", b"5\tP\tu\tPe\xf1a".decode("latin-1"), "\u0665\tP\tu\tAnn+Lee", "\uff15\tP\tu\tAnn+Lee"], ids=["three columns", "five columns", "an id not digits", "another type", "a language not a code", "an empty name", "not UTF-8", "an Arabic-Indic digit id", "a fullwidth digit id"]) def test_a_jrc_line_read_past_is_counted_up_to_its_share( monkeypatch: pytest.MonkeyPatch, bad: str) -> None: # 2 lines of 10 read past: refused at a share under 2/10, read at it. # The two digit ids are digits to int() and to \\d, and would merge a # spelling into entity 5's group. lines = [*_LINES, bad] raw = (_body(lines[:-1]) + bad.encode("latin-1" if "\xf1" in bad else "utf-8") + b"\r\n") _cache(monkeypatch, gzip.compress(raw)) _lenient(monkeypatch, but="JRC_MAX_BAD") monkeypatch.setattr(jrc, "JRC_MAX_BAD", 2 / 10 - 1e-9) with pytest.raises(ValueError, match="2 of 10 lines .* are not 4 well-formed columns"): local.fetch(_JRC) monkeypatch.setattr(jrc, "JRC_MAX_BAD", 2 / 10) assert local.fetch(_JRC).meta["detail"]["lines_read_past"] == 2 def test_a_jrc_file_with_too_few_persons_is_refused(monkeypatch: pytest.MonkeyPatch) -> None: # A person type renamed reads every person row as read past, or, as # here, a file of organisations: 1 person row of 3. _cached(monkeypatch, ["1\tP\tu\tAnn+Lee", "2\tO\tu\tAcme", "3\tO\tu\tAcme+Two"]) _lenient(monkeypatch) with pytest.raises(ValueError, match="1 of 3 lines .* name a person"): local.fetch(_JRC) monkeypatch.setattr(jrc, "JRC_MIN_PERSONS", 1 / 3) assert len(local.fetch(_JRC).records) == 1 def test_a_jrc_file_marking_too_many_spellings_is_refused( monkeypatch: pytest.MonkeyPatch) -> None: # 2 of the fixture's 7 person rows carry a language. _cached(monkeypatch, _LINES) _lenient(monkeypatch, but="JRC_MAX_MARKED") monkeypatch.setattr(jrc, "JRC_MAX_MARKED", 2 / 7 - 1e-9) with pytest.raises(ValueError, match="2 of 7 person rows .* carry a language"): local.fetch(_JRC) monkeypatch.setattr(jrc, "JRC_MAX_MARKED", 2 / 7) assert len(local.fetch(_JRC).records) == 4 class _Download: """A streamed download stating its whole length.""" def __init__(self, body: bytes) -> None: self.body = body self.headers = {"Content-Length": str(len(body))} def __enter__(self) -> "_Download": return self def __exit__(self, *exc: object) -> None: return None def read(self, n: int) -> bytes: chunk, self.body = self.body[:n], self.body[n:] return chunk def _serve(monkeypatch: pytest.MonkeyPatch, *bodies: bytes) -> list[str]: queue, asked = list(bodies), [] def urlopen(request: urllib.request.Request, timeout: int) -> _Download: asked.append(request.full_url) if not queue: pytest.fail(f"an unexpected download of {request.full_url}") return _Download(queue.pop(0)) monkeypatch.setattr(urllib.request, "urlopen", urlopen) return asked def test_a_jrc_fetch_downloads_the_file_when_it_is_missing_and_once( monkeypatch: pytest.MonkeyPatch) -> None: raw = gzip.compress(_body(_LINES)) monkeypatch.setattr(jrc, "JRC_SHA256", hashlib.sha256(raw).hexdigest()) _lenient(monkeypatch) asked = _serve(monkeypatch, raw) assert local.main(["fetch", _JRC]) == 0 assert local.main(["fetch", _JRC]) == 0 assert asked == [jrc.JRC_URL] assert (local.directory(_JRC) / jrc.JRC_FILE).read_bytes() == raw def test_a_jrc_refresh_downloads_the_file_again(monkeypatch: pytest.MonkeyPatch) -> None: raw = gzip.compress(_body(_LINES)) _cache(monkeypatch, b"an older download") monkeypatch.setattr(jrc, "JRC_SHA256", hashlib.sha256(raw).hexdigest()) _lenient(monkeypatch) asked = _serve(monkeypatch, raw) assert local.main(["fetch", _JRC, "--refresh"]) == 0 assert asked == [jrc.JRC_URL] assert len(local.require(_JRC).records) == 4 @pytest.mark.parametrize("cached", [True, False], ids=["a refresh", "a first fetch"]) def test_a_jrc_download_that_is_not_the_pinned_file_replaces_nothing( monkeypatch: pytest.MonkeyPatch, cached: bool) -> None: # The JRC serves only its current file: a download writing a newer # one over the cached one would leave nothing to rebuild from. (A # refresh of the pinned file downloads nothing at all; this cached # file is a damaged one, which the refresh is for.) pinned = gzip.compress(_body(_LINES)) damaged = pinned[:-20] path = local.directory(_JRC) / jrc.JRC_FILE if cached: path.write_bytes(damaged) monkeypatch.setattr(jrc, "JRC_SHA256", hashlib.sha256(pinned).hexdigest()) _lenient(monkeypatch) newer = gzip.compress(_body(_LINES[:3])) _serve(monkeypatch, newer) with pytest.raises(ValueError, match="not the pinned JRC_SHA256: it is kept at"): local.main(["fetch", _JRC, "--refresh"] if cached else ["fetch", _JRC]) assert path.with_name(jrc.JRC_FILE + ".new").read_bytes() == newer assert path.read_bytes() == damaged if cached else not path.exists() def test_a_jrc_download_kept_by_a_mismatch_is_adopted_once_pinned( monkeypatch: pytest.MonkeyPatch) -> None: raw = gzip.compress(_body(_LINES)) local.directory(_JRC).joinpath(jrc.JRC_FILE + ".new").write_bytes(raw) monkeypatch.setattr(jrc, "JRC_SHA256", hashlib.sha256(raw).hexdigest()) _lenient(monkeypatch) assert _serve(monkeypatch) == [] and len(local.fetch(_JRC).records) == 4 assert (local.directory(_JRC) / jrc.JRC_FILE).read_bytes() == raw def test_a_jrc_fetch_adopts_a_kept_download_over_the_old_pinned_file( monkeypatch: pytest.MonkeyPatch) -> None: # The pin moved to the download a refresh kept: a plain fetch reads # it, without calling the old file damaged or downloading again. _cache(monkeypatch, gzip.compress(_body(_LINES[:3])), pin=False) raw = gzip.compress(_body(_LINES)) local.directory(_JRC).joinpath(jrc.JRC_FILE + ".new").write_bytes(raw) monkeypatch.setattr(jrc, "JRC_SHA256", hashlib.sha256(raw).hexdigest()) _lenient(monkeypatch) assert _serve(monkeypatch) == [] and len(local.fetch(_JRC).records) == 4 assert (local.directory(_JRC) / jrc.JRC_FILE).read_bytes() == raw def test_a_jrc_refresh_of_the_pinned_file_downloads_nothing( monkeypatch: pytest.MonkeyPatch) -> None: # The pinned file cannot change, and once the JRC serves another a # download would fail: a corpus read another way is read again from # the cached file. _cached(monkeypatch, _LINES) _lenient(monkeypatch) local.fetch(_JRC) monkeypatch.setattr(jrc, "JRC_READ_VERSION", jrc.JRC_READ_VERSION + 1) assert _serve(monkeypatch) == [] assert local.main(["fetch", _JRC, "--refresh"]) == 0 assert local.require(_JRC).meta["detail"]["read_version"] == jrc.JRC_READ_VERSION def test_a_jrc_corpus_labelled_under_another_unicode_is_refused_on_load( monkeypatch: pytest.MonkeyPatch) -> None: # script() reads the Unicode table this Python carries. _cached(monkeypatch, _LINES) _lenient(monkeypatch) local.fetch(_JRC) monkeypatch.setattr(jrc.unicodedata, "unidata_version", "0.0.0") with pytest.raises(ValueError, match="labelled under Unicode .* reads Unicode 0.0.0"): local.load(_JRC) def test_a_jrc_corpus_read_another_way_is_refused_on_load( monkeypatch: pytest.MonkeyPatch) -> None: # A change to script() would otherwise leave the cached records' # labels standing under code that no longer gives them. _cached(monkeypatch, _LINES) _lenient(monkeypatch) local.fetch(_JRC) held = jrc.JRC_READ_VERSION monkeypatch.setattr(jrc, "JRC_READ_VERSION", held + 1) with pytest.raises(ValueError, match=f"read by read version {held}, and this tool " f"reads version {held + 1}.*fetch {_JRC} --refresh"): local.load(_JRC) def test_a_jrc_corpus_read_from_another_file_is_refused_on_load( monkeypatch: pytest.MonkeyPatch) -> None: # Moving the pin would otherwise leave the old file's corpus standing # under the new pin's data date. _cached(monkeypatch, _LINES) _lenient(monkeypatch) local.fetch(_JRC) monkeypatch.setattr(jrc, "JRC_SHA256", "0" * 64) with pytest.raises(ValueError, match=f"read from the file with sha256 .*reads 0{{64}}" f".*fetch {_JRC} --refresh"): local.load(_JRC) @pytest.mark.parametrize(("name", "script"), [ ("Ann Lee", "Latin"), ("José Peña", "Latin"), ("Анна Ли", "Cyrillic"), ("أحمد علي", "Arabic"), ("王小明", "Han"), ("アン・リー", "Katakana"), ("ルーシー", "Katakana"), ("アン", "Katakana"), ("John", "Latin"), ("ʿAli", "Latin"), ("Maªa", "Latin"), ("佐々木", "Han"), ("のゞ々", "Hiragana"), ("Мар\u02bcяна", "Cyrillic"), ("ʿ", "none"), ("Ann Ли", "mixed"), ("- ? -", "none")]) def test_a_spelling_s_script_is_the_script_of_its_letters(name: str, script: str) -> None: # ー, the prolonged sound mark, is a letter Unicode files under # KATAKANA-HIRAGANA; a halfwidth or fullwidth letter names its form # before its script; ʿ, ʼ, ª and 々 are written in several scripts # and name none: none is a second script. assert jrc.script(name) == script # -- the groups view ------------------------------------------------------------- def _spelled(*spellings: tuple[str, str]) -> object: """A jrc-names corpus holding each (entity, name) of `spellings`.""" return local.Local(_JRC, {"data_date": jrc.JRC_DATE}, [{"entity": entity, "name": name, "script": jrc.script(name)} for entity, name in spellings]) def test_a_reading_names_its_fields_as_the_report_does() -> None: assert local.reading("Ann Lee").family_at == "last" assert local.GROUP_FIELDS == ("title", "suffix", "given", "family", "family words", "family at") def test_a_reading_is_the_structure_of_the_parse_never_its_words() -> None: assert local.reading("Ann Lee") == (False, False, True, True, 1, "last") assert local.reading("Dr. Ann de la Lee Jr.") == (True, True, True, True, 3, "last") assert local.reading("Lee, Ann") == (False, False, True, True, 1, "first") assert local.reading("Ann") == (False, False, True, False, 0, "none") assert local.reading("de Mesnil") == (False, False, False, True, 2, "whole") assert local.reading("Анна Ли") == local.reading("Ann Lee") def test_the_groups_view_counts_persons_by_the_scripts_of_their_spellings() -> None: found = local.group_counts(_spelled( ("1", "Ann Lee"), ("1", "Анна Ли"), ("2", "Bo Wu"), ("2", "Dr. Bo Wu"), ("2", "Бо Ву"), ("3", "Cy Ng"))) # one spelling: no group assert (found.persons, found.spellings) == (2, 5) assert found.groups == {"any": 2, "Cyrillic / Latin": 2, "Latin / Latin": 1} # Person 2's Latin spellings differ between themselves, and its # Cyrillic one reads as one of them does: the scripts agree. assert found.agree == {"any": 1, "Cyrillic / Latin": 2} assert found.differ == {("any", "title"): 1, ("Latin / Latin", "title"): 1} assert found.titles == {"dr": 1} and found.suffixes == {} def test_the_groups_view_finds_two_scripts_that_read_a_field_apart() -> None: # Every Latin spelling reads a title and no Cyrillic one does; the two # titled spellings count the person once. found = local.group_counts(_spelled(("1", "Dr. Ann Lee"), ("1", "Dr Ann Lee"), ("1", "Анна Ли"))) assert found.differ == {("any", "title"): 1, ("Cyrillic / Latin", "title"): 1} assert found.agree == {"Latin / Latin": 1} assert found.titles == {"dr": 1} def test_the_groups_view_lists_no_title_every_spelling_reads() -> None: # Two titles, two suffixes, and no spelling without: no lead, since # the vocabulary already reads each spelling's word. found = local.group_counts(_spelled(("1", "Dr. Ann Lee"), ("1", "Prof. Ann Lee"), ("2", "Bo Wu Jr."), ("2", "Bo Wu Sr."))) assert found.titles == {} and found.suffixes == {} and found.differ == {} def test_the_groups_view_lists_a_title_run_word_by_word() -> None: found = local.group_counts(_spelled(("1", "Dr. Prof. Ann Lee"), ("1", "Ann Lee"))) assert found.titles == {"dr": 1, "prof": 1} def test_the_groups_view_pairs_every_two_of_a_person_s_scripts() -> None: # The Arabic spelling reads a title the others do not: only its two # pairs differ. A spelling of two scripts keys a pair as "mixed". found = local.group_counts(_spelled( ("1", "Ann Lee"), ("1", "Анна Ли"), ("1", "الدكتور أحمد علي"), ("1", "Ann Ли"))) assert local.reading("الدكتور أحمد علي").title assert found.groups == {"any": 1, "Arabic / Cyrillic": 1, "Arabic / Latin": 1, "Arabic / mixed": 1, "Cyrillic / Latin": 1, "Cyrillic / mixed": 1, "Latin / mixed": 1} assert found.agree == {"Cyrillic / Latin": 1, "Cyrillic / mixed": 1, "Latin / mixed": 1} def test_two_scripts_read_alike_only_where_one_spelling_matches_one_whole() -> None: # The Cyrillic spelling reads a title and a suffix, and each Latin one # reads one of the two: no field is read apart, yet no spelling in one # script reads as any in the other does. found = local.group_counts(_spelled(("1", "Dr. Ann Lee"), ("1", "Ann Lee Jr."), ("1", "Др. Анна Ли мл."))) assert local.reading("Др. Анна Ли мл.")[:2] == (True, True) assert ("Cyrillic / Latin", "title") not in found.differ assert ("Cyrillic / Latin", "suffix") not in found.differ assert "Cyrillic / Latin" not in found.agree # Counted apart, so that the pair's persons are each alike, apart in # a field, or here. assert found.differ[("Cyrillic / Latin", local.NO_ONE_FIELD)] == 1 def test_the_groups_view_counts_a_person_once_however_many_spellings_differ() -> None: found = local.group_counts(_spelled(*[ ("1", name) for name in ("Ann Lee", "Dr. Ann Lee", "Ann Lee Jr.", "Ann de Lee", "Lee")])) assert found.groups == {"any": 1, "Latin / Latin": 1} # Every spelling reads a given name; "Lee" alone reads no family. assert found.differ == {(pair, field): 1 for pair in ("any", "Latin / Latin") for field in local.GROUP_FIELDS if field != "given"} assert found.titles == {"dr": 1} and found.suffixes == {"jr": 1} def test_the_groups_report_gives_each_share_of_a_pair_s_persons( capsys: pytest.CaptureFixture[str]) -> None: rows = [("1", "Ann Lee"), ("1", "Dr. Ann Lee"), ("2", "Bo Wu"), ("2", "Bo Wu"), ("2", "Bo Wu"), ("3", "Cy Ng"), ("3", "Сай Нг")] print(local.format_groups(local.group_counts(_spelled(*rows)), 1)) out = capsys.readouterr().out assert " 2 50.0% alike Latin / Latin; differ in title 50.0%" in out # top=1: "any" and the largest pair only. assert "Cyrillic / Latin" not in out def test_the_groups_view_lists_only_titles_many_persons_share( capsys: pytest.CaptureFixture[str]) -> None: rows = [(str(i), name) for i in range(10) for name in ("Ann Lee", "Dr. Ann Lee")] rows += [("10", "Bo Wu"), ("10", "Prof. Bo Wu")] print(local.format_groups(local.group_counts(_spelled(*rows)), 5)) out = capsys.readouterr().out assert out.startswith("11 persons with two or more spellings (22 spellings)") assert "title words read in some spellings only (10 persons or more): dr 10" in out assert "prof" not in out @pytest.mark.parametrize(("records", "match"), [ ([], "holds no records"), ([{"name": "ANA", "registry": "ANA"}], "carry no entity and script"), ], ids=["no records", "records with no entity"]) def test_the_groups_view_refuses_records_it_cannot_group( records: list[dict[str, str]], match: str) -> None: with pytest.raises(ValueError, match=match): local.group_counts(local.Local("tse-donors", {"data_date": jrc.JRC_DATE}, records)) def test_the_groups_command_reports_a_fetched_corpus( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: _cached(monkeypatch, _LINES) _lenient(monkeypatch) local.fetch(_JRC) capsys.readouterr() assert local.main(["groups", _JRC]) == 0 out = capsys.readouterr().out assert out.startswith("JRC-Names (European Commission Joint Research Centre)") assert "1 persons with two or more spellings (2 spellings)" in out assert "100.0% alike Cyrillic / Latin" in out def test_the_groups_command_does_not_hide_a_parser_error_as_damage( monkeypatch: pytest.MonkeyPatch) -> None: # Only the corpus's own refusals become a one-line exit: a ValueError # from parsing a name is a bug, and keeps its traceback. _cached(monkeypatch, _LINES) _lenient(monkeypatch) local.fetch(_JRC) def broken(name: str) -> None: raise ValueError("a parser bug") monkeypatch.setattr(local, "_read", broken) with pytest.raises(ValueError, match="a parser bug"): local.main(["groups", _JRC]) @pytest.mark.parametrize("top", ["0", "-1"]) def test_the_groups_command_refuses_a_top_under_one( monkeypatch: pytest.MonkeyPatch, top: str) -> None: # A fetched corpus, so that the refusal is the argument's (argparse # exits 2), not the missing corpus's. _cached(monkeypatch, _LINES) _lenient(monkeypatch) local.fetch(_JRC) with pytest.raises(SystemExit) as refused: local.main(["groups", _JRC, "--top", top]) assert refused.value.code == 2 def test_the_groups_command_refuses_a_source_with_no_entities( monkeypatch: pytest.MonkeyPatch) -> None: plant_corpus(monkeypatch, local, "ukec-individuals", [{"name": "Ann Lee"}], jrc.JRC_DATE, "names") with pytest.raises(SystemExit, match="carry no entity and script"): local.main(["groups", "ukec-individuals"]) ��������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_local_orcid.py��������������������������������������0000664�0000000�0000000�00000061777�15273032717�0024305�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Offline checks on tools/differential/local_orcid.py, the orcid-researchers local corpus. Every name here is invented, and no reading of a real ORCID record may reach the repository: the file is CC0, but its names are living people's, which is why the corpus is local. What these pin is what a summary gives (the boxes, the country's order of preference, the refusals), the stream's resuming and its refusal of a file that is not the pinned one, what the fetch keeps and counts, and the per-country cap. """ import email.message import gzip import hashlib import importlib import io import json import tarfile import urllib.request from pathlib import Path import pytest from ._differential_fixtures import load_tool, own_cache local = load_tool("local_corpora") # The module the fetch reads its constants from, by name: a patch set on # a second load by path would reach nothing (test_local_corpora.py). orcid = importlib.import_module("local_orcid") _ORCID = "orcid-researchers" _MODIFIED = "Fri, 17 Oct 2025 13:21:49 GMT" _NS = " ".join(f'xmlns:{p}="http://www.orcid.org/ns/{p}"' for p in ( "record", "person", "personal-details", "other-name", "address", "common", "activities", "employment", "education", "preferences")) @pytest.fixture(autouse=True) def _own_cache(monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory) -> Path: return own_cache(monkeypatch, tmp_path_factory) def _org(kind: str, country: str) -> str: return (f"<activities:{kind}s><activities:affiliation-group>" f"<{kind}:{kind}-summary><common:organization><common:name>Uni</common:name>" f"<common:address><common:city>X</common:city><common:country>{country}" f"</common:country></common:address></common:organization>" f"</{kind}:{kind}-summary></activities:affiliation-group></activities:{kind}s>") def _xml(given: str | None = "Ann", family: str = "", credit: str = "", other: tuple[str, ...] = (), address: str = "", employment: str = "", education: str = "", locale: str = "en") -> bytes: """A summary as the file writes one; `given` None leaves out the whole name, as a record whose name is private does.""" name = "" if given is None else ( "<person:name>" + (f"<personal-details:given-names>{given}</personal-details:given-names>" if given else "") + (f"<personal-details:family-name>{family}</personal-details:family-name>" if family else "") + (f"<personal-details:credit-name>{credit}</personal-details:credit-name>" if credit else "") + "</person:name>") others = "".join(f"<other-name:other-name><other-name:content>{o}</other-name:content>" f"</other-name:other-name>" for o in other) home = (f"<address:addresses><address:address><address:country>{address}" f"</address:country></address:address></address:addresses>" if address else "") return (f'<?xml version="1.0" encoding="UTF-8"?><record:record {_NS}>' f"<preferences:preferences><preferences:locale>{locale}</preferences:locale>" f"</preferences:preferences><person:person>{name}" f"<other-name:other-names>{others}</other-name:other-names>{home}</person:person>" f"<activities:activities-summary>" + (_org("education", education) if education else "") + (_org("employment", employment) if employment else "") + "</activities:activities-summary></record:record>").encode() def test_a_summary_gives_its_boxes_other_names_and_the_holders_own_country() -> None: body = _xml("Ann Marie", "Lee", "A. M. Lee", ("Анна Ли",), address="nz", employment="FR", education="DE", locale="ru") assert orcid._summary(body) == ("record", { "given": "Ann Marie", "family": "Lee", "credit": "A. M. Lee", "other": ["Анна Ли"], "country": "NZ", "country_from": "address", "locale": "ru"}) @pytest.mark.parametrize(("where", "country_from"), [ ({"employment": "FR", "education": "DE"}, "employment"), ({"education": "DE"}, "education")]) def test_without_an_address_the_country_is_the_first_organizations( where: dict[str, str], country_from: str) -> None: # An organization's own address is {.../common}address: it never # reads as the holder's. _, record = orcid._summary(_xml(employment=where.get("employment", ""), education=where.get("education", ""))) assert record is not None assert (record["country"], record["country_from"]) == ( where[country_from], country_from) def test_a_summary_with_no_country_keeps_its_name_without_one() -> None: assert orcid._summary(_xml("Bo", "Wu", locale="")) == ("record", {"given": "Bo", "family": "Wu"}) @pytest.mark.parametrize(("body", "outcome"), [ (_xml(None), "name_private"), (_xml(""), "name_empty"), (b"<record", "not_xml"), (b'<?xml version="1.0"?><!DOCTYPE r [<!ENTITY a "aaaa">]><r>&a;</r>', "dtd_refused")]) def test_a_summary_without_a_public_name_is_counted_and_gives_no_record( body: bytes, outcome: str) -> None: assert orcid._summary(body) == (outcome, None) # -- the stream -------------------------------------------------------------- class _Response: """A ranged response serving `body` from `start`, cut after `cut` bytes with a reset connection where `cut` is set.""" def __init__(self, body: bytes, start: int, *, cut: int | None = None, size: int | None = None, modified: str = _MODIFIED) -> None: self.status = 206 self.headers = email.message.Message() total = len(body) if size is None else size self.headers["Content-Range"] = f"bytes {start}-{total - 1}/{total}" self.headers["Last-Modified"] = modified self._body = io.BytesIO(body[start:]) self._cut = cut def end_at(self, n: int) -> None: """End the body cleanly after `n` bytes, as a server closing early does.""" self._body = io.BytesIO(self._body.read(n)) def readinto(self, buffer: memoryview) -> int: if self._cut is not None and self._body.tell() >= self._cut: raise ConnectionResetError("reset by peer") limit = len(buffer) if self._cut is None else min(len(buffer), self._cut - self._body.tell()) return self._body.readinto(memoryview(buffer)[:limit]) def close(self) -> None: pass def _summaries_in(body: bytes) -> int: """The summaries a served tar.gz holds, or 0 for a body that is not one.""" try: with tarfile.open(fileobj=io.BytesIO(body), mode="r:gz") as tar: return sum(m.isfile() and bool(orcid._SUMMARY.fullmatch(m.name)) for m in tar) except (tarfile.TarError, OSError, EOFError): return 0 def _serve(monkeypatch: pytest.MonkeyPatch, body: bytes, *, cuts: tuple[int, ...] = (), size: int | None = None, modified: str = _MODIFIED, status: int = 206, eofs: tuple[int, ...] = ()) -> list[str]: """`body` as the pinned file, its size, MD5 and count of summaries pinned, each connection cut after the next of `cuts` bytes with a reset, or ending cleanly after the next of `eofs`; the Ranges asked.""" monkeypatch.setattr(orcid, "ORCID_SIZE", len(body)) monkeypatch.setattr(orcid, "ORCID_MD5", hashlib.md5(body).hexdigest()) monkeypatch.setattr(orcid, "ORCID_SUMMARIES", _summaries_in(body)) monkeypatch.setattr(importlib.import_module("local_core").time, "sleep", lambda _s: None) asked: list[str] = [] pending, ends = list(cuts), list(eofs) def urlopen(request: urllib.request.Request, timeout: int) -> _Response: assert request.full_url == orcid.ORCID_URL ranged = request.get_header("Range") assert ranged is not None asked.append(ranged) start = int(ranged.removeprefix("bytes=").removesuffix("-")) response = _Response(body, start, cut=pending.pop(0) if pending else None, size=size, modified=modified) response.status = status if ends: response.end_at(ends.pop(0)) return response monkeypatch.setattr(orcid.urllib.request, "urlopen", urlopen) return asked def test_a_stream_cut_mid_body_resumes_from_the_byte_it_reached( monkeypatch: pytest.MonkeyPatch) -> None: body = bytes(range(256)) * 40 asked = _serve(monkeypatch, body, cuts=(1000, 2500)) stream = orcid._RangeStream(orcid.ORCID_URL, orcid.ORCID_SIZE, orcid.ORCID_MD5) assert io.BufferedReader(stream, 700).read() == body stream.finish() assert asked == ["bytes=0-", "bytes=1000-", "bytes=3500-"] def test_a_stream_refuses_a_file_replaced_mid_read(monkeypatch: pytest.MonkeyPatch) -> None: body = bytes(range(256)) * 8 _serve(monkeypatch, body, cuts=(100,)) stream = orcid._RangeStream(orcid.ORCID_URL, orcid.ORCID_SIZE, orcid.ORCID_MD5) assert stream.read(100) monkeypatch.setattr(orcid.urllib.request, "urlopen", lambda request, timeout: _Response( body, 100, modified="Sat, 01 Nov 2025 00:00:00 GMT")) with pytest.raises(ValueError, match="not the pinned one"): stream.read(100) def test_a_stream_refuses_a_file_of_another_size(monkeypatch: pytest.MonkeyPatch) -> None: body = b"x" * 100 _serve(monkeypatch, body, size=101) with pytest.raises(ValueError, match="not the pinned one"): orcid._RangeStream(orcid.ORCID_URL, orcid.ORCID_SIZE, orcid.ORCID_MD5).read(10) def test_a_stream_whose_bytes_are_not_the_pinned_md5_is_refused_at_the_end( monkeypatch: pytest.MonkeyPatch) -> None: body = b"x" * 100 _serve(monkeypatch, body) stream = orcid._RangeStream(orcid.ORCID_URL, orcid.ORCID_SIZE, "0" * 32) assert stream.read() == body with pytest.raises(ValueError, match="is pinned; fetch it again"): stream.finish() # -- the fetch --------------------------------------------------------------- def _tar(*members: tuple[str, bytes]) -> bytes: raw = io.BytesIO() with tarfile.open(fileobj=raw, mode="w") as tar: for name, body in members: info = tarfile.TarInfo(name) info.size = len(body) tar.addfile(info, io.BytesIO(body)) return gzip.compress(raw.getvalue()) def _member(digits: str, body: bytes) -> tuple[str, bytes]: return f"ORCID_2025_10_summaries/{digits[-3:]}/0000-0002-1825-{digits}.xml", body _FILE = [_member("0097", _xml("Ann", "Lee", "Lee, Ann", address="NZ")), _member("1097", _xml("Ann", "Lee", "Lee, Ann", address="NZ")), _member("2097", _xml("Bo", "Wu", employment="CN")), _member("3097", _xml("Cy")), _member("409X", _xml(None))] def test_an_orcid_fetch_keeps_each_record_once_and_counts_the_rest( monkeypatch: pytest.MonkeyPatch) -> None: _serve(monkeypatch, _tar(*_FILE)) corpus = local.fetch(_ORCID) assert corpus.records == [ {"credit": "Lee, Ann", "country": "NZ", "country_from": "address", "family": "Lee", "given": "Ann", "name": "Ann Lee"}, {"country": "CN", "country_from": "employment", "family": "Wu", "given": "Bo", "name": "Bo Wu"}, {"given": "Cy", "name": "Cy"}] assert corpus.meta["data_date"] == "2025-10-17T13:21:49" assert corpus.meta["detail"] == { "kept": orcid._kept(), "summaries": 5, "record": 4, "name_private": 1, "countries": 2, "countries_at_the_cap": 0} # The corpus reads the credit name beside the joined boxes. corpus_lines = (local.directory(_ORCID) / local.CORPUS).read_text(encoding="utf-8") assert '"Lee, Ann"' in corpus_lines and '"Ann Lee"' in corpus_lines def test_the_extract_keeps_every_record_with_its_other_names_and_locale( monkeypatch: pytest.MonkeyPatch) -> None: _serve(monkeypatch, _tar(_member("0097", _xml("Ann", "Lee", "A. Lee", other=("Анна",), address="NZ")))) local.fetch(_ORCID) with gzip.open(orcid.extract_path(), "rt", encoding="utf-8") as lines: header, *records = lines.read().splitlines() assert '"file": "ORCID_2025_10_summaries.tar.gz"' in header assert records == [ '{"country": "NZ", "country_from": "address", "credit": "A. Lee", "family": "Lee",' ' "given": "Ann", "locale": "en", "other": ["Анна"]}'] def test_workers_extract_what_one_process_does(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(orcid, "_BATCH", 2) body = _tar(*_FILE) extracts = [] for jobs in (1, 2): _serve(monkeypatch, body) orcid.renew_orcid(jobs=jobs) extracts.append(gzip.decompress(orcid.extract_path().read_bytes())) assert extracts[0] == extracts[1] def test_the_extract_holds_no_member_it_has_read(monkeypatch: pytest.MonkeyPatch) -> None: held: list[int] = [] next_member = tarfile.TarFile.next def counting(self: tarfile.TarFile) -> tarfile.TarInfo | None: held.append(len(self.members)) # type: ignore[attr-defined] # undeclared in typeshed return next_member(self) _serve(monkeypatch, _tar(*_FILE)) # counts the tar's summaries, before the patch monkeypatch.setattr(tarfile.TarFile, "next", counting) orcid.renew_orcid(jobs=1) # Without clearing, the list grows by a member a read: [0, 1, 2, ...]. assert len(held) > 3 and max(held) <= 1 def test_a_fetch_that_fails_its_md5_keeps_the_extract_it_had( monkeypatch: pytest.MonkeyPatch) -> None: _serve(monkeypatch, _tar(*_FILE)) orcid.renew_orcid(jobs=1) before = orcid.extract_path().read_bytes() _serve(monkeypatch, _tar(*_FILE[:2])) monkeypatch.setattr(orcid, "ORCID_MD5", "0" * 32) with pytest.raises(ValueError, match="is pinned"): orcid.renew_orcid(jobs=1) assert orcid.extract_path().read_bytes() == before assert sorted(p.name for p in orcid.extract_path().parent.iterdir()) == [ orcid.extract_path().name] def test_a_file_past_the_share_of_unreadable_summaries_is_refused( monkeypatch: pytest.MonkeyPatch) -> None: _serve(monkeypatch, _tar(*_FILE, _member("5097", b"<record"))) with pytest.raises(ValueError, match="1 of 6 summaries are not XML"): local.fetch(_ORCID) monkeypatch.setattr(orcid, "ORCID_MAX_BAD", 1 / 5) assert local.fetch(_ORCID).meta["detail"]["not_xml"] == 1 def test_a_tar_past_the_share_of_files_that_are_not_summaries_is_refused( monkeypatch: pytest.MonkeyPatch) -> None: stray = ("ORCID_2025_10_summaries/README.txt", b"not a summary") _serve(monkeypatch, _tar(*_FILE, stray)) with pytest.raises(ValueError, match="1 of 6 files in the tar are not summaries"): local.fetch(_ORCID) monkeypatch.setattr(orcid, "ORCID_MAX_BAD", 1 / 5) assert local.fetch(_ORCID).meta["detail"]["members_not_summaries"] == 1 def test_a_corpus_kept_under_another_cap_is_refused_on_load( monkeypatch: pytest.MonkeyPatch) -> None: _serve(monkeypatch, _tar(*_FILE)) local.fetch(_ORCID) assert local.load(_ORCID) is not None monkeypatch.setattr(orcid, "ORCID_CAP", 7) with pytest.raises(local.CorpusDamaged, match="at most 7 records a country"): local.load(_ORCID) # -- the cap ----------------------------------------------------------------- def _people(country: str, n: int) -> list[dict[str, str]]: return [{"name": f"Ann Lee{i}", "country": country} for i in range(n)] def test_the_cap_samples_a_large_country_and_keeps_a_small_one_whole() -> None: records = _people("NZ", 30) + _people("FR", 3) + [{"name": "Cy"}] * 2 kept = orcid.orcid_kept(iter(records), 10) by_country = [r.get("country", "") for r in kept] assert (by_country.count("NZ"), by_country.count("FR"), by_country.count("")) == (10, 3, 1) def test_the_cap_keeps_the_same_records_whatever_the_order_or_the_rest() -> None: nz = _people("NZ", 40) kept = orcid.orcid_kept(iter(nz), 10) shuffled = orcid.orcid_kept(iter(nz[::-1] + _people("FR", 50) + nz[:5]), 10) assert sorted(map(str, kept)) == sorted(map(str, (r for r in shuffled if r["country"] == "NZ"))) # -- what a summary gives, further ------------------------------------------- def test_the_first_organization_naming_a_country_gives_it() -> None: body = _xml(employment="FR").replace( b"</activities:affiliation-group></activities:employments>", b"</activities:affiliation-group><activities:affiliation-group>" b"<employment:employment-summary><common:organization><common:address>" b"<common:country>DE</common:country></common:address></common:organization>" b"</employment:employment-summary></activities:affiliation-group>" b"</activities:employments>") assert b"DE" in body _, record = orcid._summary(body) assert record is not None and record["country"] == "FR" @pytest.mark.parametrize(("body", "outcome", "record"), [ (_xml("", credit="A. Lee"), "name_empty", None), (_xml("", "Lee"), "record", {"family": "Lee", "locale": "en"})]) def test_a_name_is_its_two_boxes_and_a_credit_name_alone_is_none( body: bytes, outcome: str, record: dict[str, str] | None) -> None: assert orcid._summary(body) == (outcome, record) # -- the stream, further ----------------------------------------------------- def test_a_stream_refuses_an_answer_that_is_not_a_range(monkeypatch: pytest.MonkeyPatch) -> None: # A server ignoring Range answers 200 with the body from byte 0, which # on a resume would splice the file's start into the stream. _serve(monkeypatch, b"x" * 100, status=200) with pytest.raises(ValueError, match="answered 200"): orcid._RangeStream(orcid.ORCID_URL, orcid.ORCID_SIZE, orcid.ORCID_MD5).read(10) def test_a_stream_refuses_a_file_that_states_no_last_modified( monkeypatch: pytest.MonkeyPatch) -> None: # Without it the replaced-file check would have nothing to compare. _serve(monkeypatch, b"x" * 100, modified="") with pytest.raises(ValueError, match="last modified not stated"): orcid._RangeStream(orcid.ORCID_URL, orcid.ORCID_SIZE, orcid.ORCID_MD5).read(10) def test_a_body_that_ends_early_without_an_error_resumes(monkeypatch: pytest.MonkeyPatch) -> None: body = bytes(range(256)) * 8 asked = _serve(monkeypatch, body, eofs=(1000,)) stream = orcid._RangeStream(orcid.ORCID_URL, orcid.ORCID_SIZE, orcid.ORCID_MD5) assert io.BufferedReader(stream, 700).read() == body stream.finish() assert asked == ["bytes=0-", "bytes=1000-"] def test_a_stream_rides_out_more_failures_than_a_small_request_would( monkeypatch: pytest.MonkeyPatch) -> None: body = bytes(range(256)) * 4 failures = orcid.ORCID_RETRIES - 1 assert failures > importlib.import_module("local_core").RETRIES asked = _serve(monkeypatch, body, cuts=(0,) * failures) sleeps: list[float] = [] monkeypatch.setattr(importlib.import_module("local_core").time, "sleep", sleeps.append) stream = orcid._RangeStream(orcid.ORCID_URL, orcid.ORCID_SIZE, orcid.ORCID_MD5) assert stream.read() == body assert len(asked) == failures + 1 assert sleeps == [min(2 ** n, 60) for n in range(failures)] def test_a_gzip_whose_crc_fails_after_the_tar_ends_is_refused( monkeypatch: pytest.MonkeyPatch) -> None: body = bytearray(_tar(*_FILE)) body[-8] ^= 0xFF # the trailer's CRC32, read only past the tar's end _serve(monkeypatch, bytes(body)) with pytest.raises(gzip.BadGzipFile, match="CRC check failed"): orcid.renew_orcid(jobs=1) assert not orcid.extract_path().parent.exists() or not any( orcid.extract_path().parent.iterdir()) # -- the reader's checks ----------------------------------------------------- def _write_extract(header: dict[str, object] | bytes, lines: tuple[str, ...] = ()) -> Path: """An extract written by hand: `header` and `lines`, or bytes as they stand.""" path = orcid.extract_path() path.parent.mkdir(parents=True, exist_ok=True) if isinstance(header, bytes): path.write_bytes(header) else: text = json.dumps(header) + "\n" + "".join(f"{line}\n" for line in lines) path.write_bytes(gzip.compress(text.encode())) return path _HEAD = {"file": orcid.ORCID_FILE, "data_date": "2025-10-17T13:21:49", "counts": {"record": 0}} @pytest.mark.parametrize("header", [ {**_HEAD, "file": "ORCID_2024_10_summaries.tar.gz"}, {**_HEAD, "counts": [0]}, {**_HEAD, "counts": {"record": "0"}}, {k: v for k, v in _HEAD.items() if k != "data_date"}, {**_HEAD, "data_date": 2025}, gzip.compress(b"", mtime=0), # a gzip header holds its time: one id for every worker b"not gzip"], ids=["another file", "counts not a dict", "a count not an int", "no data date", "a data date not text", "empty", "not gzip"]) def test_a_file_that_is_not_an_extract_is_refused_naming_the_refresh( header: dict[str, object] | bytes) -> None: with pytest.raises(ValueError, match="not an extract this tool wrote.*--refresh"): orcid._orcid_read(_write_extract(header)) def test_a_tar_read_only_in_part_is_refused_and_the_extract_kept( monkeypatch: pytest.MonkeyPatch) -> None: # tarfile ends a stream at a damaged header without raising, and the # bytes still pass their MD5: only the pinned count shows it. _serve(monkeypatch, _tar(*_FILE)) orcid.renew_orcid(jobs=1) before = orcid.extract_path().read_bytes() monkeypatch.setattr(orcid, "ORCID_SUMMARIES", len(_FILE) + 1) with pytest.raises(ValueError, match="the tar was read in part"): orcid.renew_orcid(jobs=1) assert orcid.extract_path().read_bytes() == before assert sorted(p.name for p in orcid.extract_path().parent.iterdir()) == [ orcid.extract_path().name] def test_an_extract_missing_records_its_header_counts_is_refused( monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(orcid, "ORCID_SUMMARIES", 2) path = _write_extract({**_HEAD, "counts": {"record": 2}}, ('{"country": "NZ", "family": "Lee", "given": "Ann"}',)) with pytest.raises(ValueError, match="holds 1 records .* counts 2 records"): orcid._orcid_read(path) @pytest.mark.parametrize(("extra", "says"), [ (_member("5097", b"<record"), "1 of 6 summaries are not XML"), (_member("5097", b'<?xml version="1.0"?><!DOCTYPE r><r/>'), "1 of 6 summaries declare a DTD or an entity"), (("ORCID_2025_10_summaries/README.txt", b"x"), "1 of 6 files in the tar are not summaries"), (_member("5097", _xml("Di", "Ng", address="nzl")), "1 of 4 countries are not two capital letters")]) def test_a_file_past_a_share_of_what_it_reads_past_is_refused_before_it_is_kept( monkeypatch: pytest.MonkeyPatch, extra: tuple[str, bytes], says: str) -> None: _serve(monkeypatch, _tar(*_FILE)) orcid.renew_orcid(jobs=1) before = orcid.extract_path().read_bytes() _serve(monkeypatch, _tar(*_FILE, extra)) with pytest.raises(ValueError, match=says): orcid.renew_orcid(jobs=1) assert orcid.extract_path().read_bytes() == before @pytest.mark.parametrize("bad", [1, 2]) def test_the_share_of_unreadable_summaries_is_held_at_its_shipped_value( monkeypatch: pytest.MonkeyPatch, bad: int) -> None: assert orcid.ORCID_MAX_BAD == 1 / 10_000 monkeypatch.setattr(orcid, "ORCID_SUMMARIES", 10_000) good = 10_000 - bad lines = tuple(f'{{"country": "NZ", "family": "Lee{i}", "given": "Ann"}}' for i in range(good)) path = _write_extract({**_HEAD, "counts": {"record": good, "not_xml": bad}}, lines) if bad == 1: assert orcid._orcid_read(path).detail["not_xml"] == 1 else: with pytest.raises(ValueError, match="2 of 10,000 summaries are not XML"): orcid._orcid_read(path) @pytest.mark.parametrize(("members", "says"), [ # The name moved: every summary reads as private. ([_member(f"{n}097", _xml(None)) for n in range(4)] + _FILE[:1], "only 1 of 5 summaries give a record"), # The family box was renamed: no record holds one. ([_member(f"{n}097", _xml(f"Ann{n}", address="NZ")) for n in range(4)], "only 0 of 4 records hold a family name"), # The address and organization namespaces moved: no record has a country. ([_member(f"{n}097", _xml(f"Ann{n}", "Lee")) for n in range(4)], "only 0 of 4 records hold a country")]) def test_a_file_whose_layout_moved_under_the_reader_is_refused( monkeypatch: pytest.MonkeyPatch, members: list[tuple[str, bytes]], says: str) -> None: _serve(monkeypatch, _tar(*members)) with pytest.raises(ValueError, match=says): local.fetch(_ORCID) def test_the_countries_at_the_cap_leave_out_the_records_with_none( monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(orcid, "ORCID_CAP", 1) _serve(monkeypatch, _tar(*_FILE)) detail = local.fetch(_ORCID).meta["detail"] assert (detail["countries"], detail["countries_at_the_cap"]) == (2, 2) def test_an_extract_of_another_version_is_streamed_again(monkeypatch: pytest.MonkeyPatch) -> None: _serve(monkeypatch, _tar(*_FILE)) orcid.fetch_orcid() asked = _serve(monkeypatch, _tar(*_FILE)) orcid.fetch_orcid() assert asked == [] # the extract cached for this version is read monkeypatch.setattr(orcid, "ORCID_EXTRACT_VERSION", orcid.ORCID_EXTRACT_VERSION + 1) orcid.fetch_orcid() assert asked == ["bytes=0-"] �derek73-python-nameparser-456b024/tests/v2/test_local_patentsview.py��������������������������������0000664�0000000�0000000�00000037520�15273032717�0025543�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Offline checks on tools/differential/local_patentsview.py, the patentsview-inventors local corpus. Every name here is invented, and no reading of a real inventor may reach the repository: the inventors are living people, which is why the corpus is local. No test reaches the portal or needs its key: the server is a stub, and the key is a file in the test's own home. What these pin is where the key goes, the refusal of another release or of bytes that are not the pinned ones, the join of the two inventor tables and its people count, the checks a read makes, and the cap. """ import csv import email.message import gzip import hashlib import importlib import io import json import urllib.error import urllib.request import zipfile from collections.abc import Callable, Sequence from pathlib import Path import pytest from ._differential_fixtures import load_tool, own_cache local = load_tool("local_corpora") # The module the fetch reads its constants from, by name (test_local_corpora.py). pv = importlib.import_module("local_patentsview") _PV = "patentsview-inventors" @pytest.fixture(autouse=True) def _own_cache(monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory) -> Path: return own_cache(monkeypatch, tmp_path_factory) @pytest.fixture def key(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: path = tmp_path / "uspto-odp-key" path.write_text("test-key-not-real\n") monkeypatch.setattr(pv, "PV_KEY", path) return path def _table(header: list[str], rows: list[list[str]]) -> bytes: """A table as PatentsView ships one: a zip of one TSV, every field quoted.""" text = io.StringIO() writer = csv.writer(text, delimiter="\t", quoting=csv.QUOTE_ALL, lineterminator="\n") writer.writerow(header) writer.writerows(rows) raw = io.BytesIO() with zipfile.ZipFile(raw, "w") as archive: archive.writestr("table.tsv", text.getvalue()) return raw.getvalue() _RAW_HEAD = ["patent_id", "inventor_sequence", "inventor_id", "raw_inventor_name_first", "raw_inventor_name_last", "deceased_flag", "rawlocation_id"] _DIS_HEAD = ["patent_id", "inventor_sequence", "inventor_id", "disambig_inventor_name_first", "disambig_inventor_name_last", "gender_code", "location_id"] _LOC_HEAD = ["location_id", "disambig_city", "disambig_state", "disambig_country", "latitude", "longitude", "county", "state_fips", "county_fips"] #: (patent, sequence, inventor id, first, last, location). Ann Lee holds #: two patents (one person), a second Ann Lee is another person, and Bo #: Wu files in Japan, Cy Doe's location names no country, and Eve Ng #: holds four patents in Japan: 9 of the 10 rows name a country, which #: is PV_MIN_COUNTRY's shipped floor exactly. _INVENTORS = [("1", "0", "i1", "Ann", "Lee", "l1"), ("2", "0", "i1", "Ann", "Lee", "l1"), ("2", "1", "i2", "Ann", "Lee", "l1"), ("3", "0", "i3", "Bo", "Wu", "l2"), ("4", "0", "i4", "Cy Q.", "Doe", "l3"), ("5", "0", "i5", "Di", "Fox", "l2"), *((str(n), "0", "i6", "Eve", "Ng", "l2") for n in range(6, 10))] _LOCATIONS = [["l1", "X", "", "US", "0", "0", "", "", ""], ["l2", "Y", "", "JP", "0", "0", "", "", ""], ["l3", "Z", "", "", "0", "0", "", "", ""]] def _files(inventors: Sequence[tuple[str, ...]] = _INVENTORS, *, dis: Sequence[tuple[str, ...]] | None = None, raw_extra: Sequence[list[str]] = ()) -> dict[str, bytes]: raw_rows = [[p, s, i, f, last, "False", "r"] for p, s, i, f, last, _ in inventors] dis_rows = [[p, s, i, f, last, "U", loc] for p, s, i, f, last, loc in (dis or inventors)] return {pv.PV_RAW: _table(_RAW_HEAD, raw_rows + list(raw_extra)), pv.PV_INVENTORS: _table(_DIS_HEAD, dis_rows), pv.PV_LOCATIONS: _table(_LOC_HEAD, _LOCATIONS)} class _Body(io.BytesIO): def __enter__(self) -> "_Body": return self def _serve(monkeypatch: pytest.MonkeyPatch, files: dict[str, bytes], *, listed: dict[str, tuple[int, str]] | None = None, rows: int | None = None) -> list[str]: """`files` as the pinned release, their sizes and sha256 pinned and the rows the raw table holds; the URLs asked.""" monkeypatch.setattr(pv, "PV_FILES", { name: (len(body), "2026-03-12 22:26:59", hashlib.sha256(body).hexdigest()) for name, body in files.items()}) monkeypatch.setattr(pv, "PV_ROWS", rows if rows is not None else len(_INVENTORS)) asked: list[str] = [] stated = listed or {name: (len(body), "2026-03-12 22:26:59") for name, body in files.items()} def opened(url: str) -> _Body: asked.append(url) if "includeFiles" in url: return _Body(json.dumps({"bulkDataProductBag": [{"productFileBag": {"fileDataBag": [ {"fileName": n, "fileSize": size, "fileLastModifiedDateTime": when, "fileDownloadURI": f"{pv.PV_API}/files/{pv.PV_PRODUCT}/{n}"} for n, (size, when) in stated.items()]}}]}).encode()) return _Body(files[url.rsplit("/", 1)[-1]]) monkeypatch.setattr(pv, "_open", opened) return asked # -- the key ------------------------------------------------------------------ @pytest.mark.parametrize("held", [None, "", "two words\n"]) def test_a_missing_or_malformed_key_is_refused_saying_how_to_get_one( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, held: str | None) -> None: path = tmp_path / "uspto-odp-key" if held is not None: path.write_text(held) monkeypatch.setattr(pv, "PV_KEY", path) with pytest.raises(ValueError, match="ID.me.*My ODP.*chmod 600"): pv._key() def test_the_key_goes_to_an_https_uspto_host_only( monkeypatch: pytest.MonkeyPatch, key: Path) -> None: sent: list[urllib.request.Request] = [] class Opener: def open(self, request: urllib.request.Request, timeout: int) -> None: sent.append(request) monkeypatch.setattr(pv.urllib.request, "build_opener", lambda *handlers: Opener()) pv._open(f"{pv.PV_API}/{pv.PV_PRODUCT}") assert sent[0].get_header("X-api-key") == "test-key-not-real" for elsewhere in ("http://api.uspto.gov/x", "https://uspto.gov.example.com/x", "https://example.com/x"): with pytest.raises(ValueError, match="the key goes nowhere else"): pv._open(elsewhere) assert len(sent) == 1 @pytest.mark.parametrize(("to", "keeps"), [ ("https://data.uspto.gov/file.zip", True), ("https://bucket.s3.amazonaws.com/file.zip", False)]) def test_a_redirect_off_uspto_drops_the_key(to: str, keeps: bool) -> None: request = urllib.request.Request("https://api.uspto.gov/f", headers={"X-API-KEY": "k"}) new = pv._KeyStaysHome().redirect_request(request, None, 302, "Found", {}, to) assert new is not None and (new.get_header("X-api-key") == "k") is keeps # -- the release -------------------------------------------------------------- @pytest.mark.parametrize("listed", [ {pv.PV_RAW: (1, "2026-03-12 22:26:59")}, "size", "date"]) def test_a_listing_of_another_release_is_refused( monkeypatch: pytest.MonkeyPatch, key: Path, listed: dict[str, tuple[int, str]] | str) -> None: files = _files() if listed == "size": listed = {n: (len(b) + 1, "2026-03-12 22:26:59") for n, b in files.items()} elif listed == "date": listed = {n: (len(b), "2026-06-11 00:00:00") for n, b in files.items()} assert isinstance(listed, dict) _serve(monkeypatch, files, listed=listed) with pytest.raises(ValueError, match="another release, measured and pinned by hand"): pv.renew_pv() def test_bytes_that_are_not_the_pinned_sha256_are_refused_and_not_kept( monkeypatch: pytest.MonkeyPatch, key: Path) -> None: files = _files() _serve(monkeypatch, files) name = pv.PV_LOCATIONS size, when, _ = pv.PV_FILES[name] monkeypatch.setitem(pv.PV_FILES, name, (size, when, "0" * 64)) with pytest.raises(ValueError, match="sha256"): pv.renew_pv() assert not pv.extract_path().exists() # The files that arrived whole stay for the next run; nothing partial does. assert sorted(p.name for p in pv.extract_path().parent.rglob("*") if p.is_file()) == [ pv.PV_INVENTORS, pv.PV_RAW] def test_a_run_after_a_failed_one_downloads_only_what_was_not_whole( monkeypatch: pytest.MonkeyPatch, key: Path) -> None: files = _files() asked = _serve(monkeypatch, files) size, when, sha = pv.PV_FILES[pv.PV_LOCATIONS] monkeypatch.setitem(pv.PV_FILES, pv.PV_LOCATIONS, (size, when, "0" * 64)) with pytest.raises(ValueError, match="sha256"): pv.renew_pv() monkeypatch.setitem(pv.PV_FILES, pv.PV_LOCATIONS, (size, when, sha)) asked.clear() pv.renew_pv() assert [url.rsplit("/", 1)[-1] for url in asked if "includeFiles" not in url] == [ pv.PV_LOCATIONS] assert not (pv.extract_path().parent / "downloads").exists() def test_a_download_dropped_mid_file_is_tried_again( monkeypatch: pytest.MonkeyPatch, key: Path) -> None: files = _files() _serve(monkeypatch, files) served: Callable[[str], _Body] = pv._open drops = [pv.PV_RAW] monkeypatch.setattr(importlib.import_module("local_core").time, "sleep", lambda _s: None) def dropping(url: str) -> _Body: if drops and url.endswith(drops[0]): drops.pop() raise ConnectionResetError("reset by peer") return served(url) monkeypatch.setattr(pv, "_open", dropping) pv.renew_pv() assert pv.extract_path().exists() and not drops @pytest.mark.parametrize("code", [401, 403]) def test_a_key_the_portal_refuses_is_reported_as_the_keys( monkeypatch: pytest.MonkeyPatch, key: Path, code: int) -> None: class Opener: def open(self, request: urllib.request.Request, timeout: int) -> None: raise urllib.error.HTTPError(request.full_url, code, "Unauthorized", email.message.Message(), io.BytesIO()) monkeypatch.setattr(pv.urllib.request, "build_opener", lambda *handlers: Opener()) with pytest.raises(ValueError, match=f"refused the key in .*uspto-odp-key \\({code}\\).*ID.me"): pv._listed() # -- the fetch ------------------------------------------------------------------ def test_a_fetch_counts_people_not_patents_and_keeps_the_country( monkeypatch: pytest.MonkeyPatch, key: Path) -> None: _serve(monkeypatch, _files()) corpus = local.fetch(_PV) assert sorted(corpus.records, key=lambda r: r["name"]) == [ {"country": "US", "family": "Lee", "given": "Ann", "name": "Ann Lee", "people": "2"}, {"country": "JP", "family": "Wu", "given": "Bo", "name": "Bo Wu", "people": "1"}, {"family": "Doe", "given": "Cy Q.", "name": "Cy Q. Doe", "people": "1"}, {"country": "JP", "family": "Fox", "given": "Di", "name": "Di Fox", "people": "1"}, {"country": "JP", "family": "Ng", "given": "Eve", "name": "Eve Ng", "people": "1"}] detail = corpus.meta["detail"] assert (detail["rows"], detail["names"], detail["people_by_name"], detail["rows_with_a_country"], detail["countries"]) == (10, 5, 6, 9, 2) # The zips are gone; only the extract and the corpus files stay. assert not (pv.extract_path().parent / "downloads").exists() def test_the_extract_keeps_every_name_with_its_people_as_a_number( monkeypatch: pytest.MonkeyPatch, key: Path) -> None: _serve(monkeypatch, _files()) local.fetch(_PV) with gzip.open(pv.extract_path(), "rt", encoding="utf-8") as lines: header, *names = lines.read().splitlines() assert json.loads(header)["release"] == pv.PV_RELEASE assert json.loads(names[0]) == {"country": "US", "family": "Lee", "given": "Ann", "people": 2} def test_the_two_inventor_tables_out_of_step_are_refused( monkeypatch: pytest.MonkeyPatch, key: Path) -> None: shuffled = _INVENTORS[2:] + _INVENTORS[:2] _serve(monkeypatch, _files(dis=shuffled)) with pytest.raises(ValueError, match="not row for row the same inventors"): pv.renew_pv() _serve(monkeypatch, _files(dis=_INVENTORS + [("9", "0", "i9", "Ed", "Ng", "l1")])) with pytest.raises(ValueError, match="holds more rows than"): pv.renew_pv() def test_a_table_read_in_part_is_refused_and_the_extract_kept( monkeypatch: pytest.MonkeyPatch, key: Path) -> None: _serve(monkeypatch, _files()) pv.renew_pv() before = pv.extract_path().read_bytes() _serve(monkeypatch, _files(), rows=len(_INVENTORS) + 1) with pytest.raises(ValueError, match="a table was read in part"): pv.renew_pv() assert pv.extract_path().read_bytes() == before # Nothing staged or partial stays beside it (the whole zips may, for the next run). assert sorted(p.name for p in pv.extract_path().parent.iterdir() if p.is_file()) == [ pv.extract_path().name] def test_a_row_not_the_headers_width_is_counted_and_past_the_share_refused( monkeypatch: pytest.MonkeyPatch, key: Path) -> None: files = _files(raw_extra=[["6", "0", "i6", "Ed"]]) _serve(monkeypatch, files) with pytest.raises(ValueError, match="1 of 10 g_inventor_not_disambiguated.tsv.zip: " "rows not 7 fields"): pv.renew_pv() monkeypatch.setattr(pv, "PV_MAX_BAD", 1 / 5) assert local.fetch(_PV).meta["detail"][f"{pv.PV_RAW}: rows not 7 fields"] == 1 @pytest.mark.parametrize(("inventors", "says"), [ ([(p, s, i, f, last, "l3") for p, s, i, f, last, _ in _INVENTORS], "only 0 of 10 rows name a country"), ([(p, s, i, f, "", loc) for p, s, i, f, _, loc in _INVENTORS], "only 0 of 10 rows fill both boxes")]) def test_tables_whose_columns_moved_under_the_reader_are_refused( monkeypatch: pytest.MonkeyPatch, key: Path, inventors: Sequence[tuple[str, ...]], says: str) -> None: _serve(monkeypatch, _files(inventors)) with pytest.raises(ValueError, match=says): pv.renew_pv() def test_a_header_without_a_column_read_is_refused( monkeypatch: pytest.MonkeyPatch, key: Path) -> None: files = _files() files[pv.PV_LOCATIONS] = _table(["location_id", "country_name"], [["l1", "US"]]) _serve(monkeypatch, files) with pytest.raises(ValueError, match="header holds no disambig_country"): pv.renew_pv() def test_a_file_that_is_not_an_extract_is_refused_naming_the_refresh() -> None: path = pv.extract_path() path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(gzip.compress(b'{"release": "2024-12-31", "files": {}, "counts": {}}\n', mtime=0)) with pytest.raises(ValueError, match="not an extract this tool wrote.*--refresh"): pv._pv_read(path) def test_a_corpus_kept_under_another_cap_is_refused_on_load( monkeypatch: pytest.MonkeyPatch, key: Path) -> None: _serve(monkeypatch, _files()) local.fetch(_PV) assert local.load(_PV) is not None monkeypatch.setattr(pv, "PV_CAP", 7) with pytest.raises(local.CorpusDamaged, match="at most 7 names a country"): local.load(_PV) def test_the_cap_samples_a_large_country_and_keeps_the_same_names_whatever_the_order() -> None: people = [{"name": f"Ann Lee{i}", "country": "US", "people": "1"} for i in range(30)] kept = pv.pv_kept(iter(people + [{"name": "Bo Wu", "country": "JP", "people": "1"}]), 10) assert sum(r["country"] == "US" for r in kept) == 10 again = pv.pv_kept(iter(people[::-1]), 10) assert sorted(map(str, again)) == sorted(map(str, (r for r in kept if r["country"] == "US"))) def test_the_corpus_scores_with_its_boxes(monkeypatch: pytest.MonkeyPatch, key: Path) -> None: agreement = load_tool("local_agreement") _serve(monkeypatch, _files()) corpus = local.fetch(_PV) from nameparser import Parser parser = Parser() listed = agreement._title_words(parser.lexicon) fielded = agreement.FIELDED[_PV] assert all(not agreement.read(r, fielded, parser, listed).got for r in corpus.records) ��������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_locale.py�������������������������������������������0000664�0000000�0000000�00000005546�15273032717�0023262�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������import pytest from nameparser._lexicon import Lexicon from nameparser._locale import Locale from nameparser._policy import PatronymicRule, PolicyPatch def test_locale_holds_code_lexicon_fragment_and_patch() -> None: ru = Locale( code="ru", lexicon=Lexicon.empty(), policy=PolicyPatch( patronymic_rules=frozenset({PatronymicRule.EAST_SLAVIC})), ) assert ru.code == "ru" assert ru.policy.patronymic_rules == frozenset( {PatronymicRule.EAST_SLAVIC}) def test_locale_defaults_to_empty_patch() -> None: assert Locale(code="xx", lexicon=Lexicon.empty()).policy == PolicyPatch() def test_locale_code_must_be_nonempty_lowercase() -> None: with pytest.raises(ValueError, match="lowercase"): Locale(code="RU", lexicon=Lexicon.empty()) with pytest.raises(ValueError, match="non-empty"): Locale(code="", lexicon=Lexicon.empty()) def test_locale_code_rejects_whitespace() -> None: # caught by the [a-z0-9_]+ charset pin (a dedicated whitespace check # would be pure redundancy; the charset message is accurate) for bad in ("ru ", " ru", "ru\n", "r u"): with pytest.raises(ValueError, match="a-z0-9_"): Locale(code=bad, lexicon=Lexicon.empty()) def test_locale_is_hashable() -> None: loc = Locale(code="ru", lexicon=Lexicon.empty()) assert isinstance(hash(loc), int) def test_locale_validates_component_types() -> None: with pytest.raises(TypeError, match="Lexicon"): Locale(code="ru", lexicon={"titles": set()}) # type: ignore[arg-type] with pytest.raises(TypeError, match="PolicyPatch"): Locale(code="ru", lexicon=Lexicon.empty(), policy={"name_order": None}) # type: ignore[arg-type] with pytest.raises(TypeError, match="must be a str"): Locale(code=5, lexicon=Lexicon.empty()) # type: ignore[arg-type] def test_locale_with_lexicon_pickles_round_trip() -> None: import pickle loc = Locale(code="ru", lexicon=Lexicon.empty().add(titles={"Dr."})) assert pickle.loads(pickle.dumps(loc)) == loc def test_locale_code_is_pinned_to_registry_charset() -> None: # Codes become registry keys the moment parser_for and third-party # packs exist: every accepted character is supported forever, so pin # [a-z0-9_]+ now (matches ru/tr_az). One separator only -- allowing # both '-' and '_' would make tr-az and tr_az distinct keys. for bad in ("ru!", "tr-az", "тр", "zh/tw"): with pytest.raises(ValueError, match="a-z0-9_"): Locale(code=bad, lexicon=Lexicon.empty()) assert Locale(code="tr_az", lexicon=Lexicon.empty()).code == "tr_az" def test_setstate_rejects_layout_skew() -> None: loc = Locale(code="ru", lexicon=Lexicon.empty()) state = dict(loc.__getstate__()) del state["code"] with pytest.raises(ValueError, match="code"): Locale.__new__(Locale).__setstate__(state) ����������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_locales.py������������������������������������������0000664�0000000�0000000�00000212345�15273032717�0023442�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""The locale pack layer (mechanisms.md#LOCALE-PACKS-PURE-DATA): lazy access, the shipped packs, composition, and the non-interference gate.""" import functools import importlib.util import re import sys import types from collections.abc import Callable, Iterable from types import ModuleType import pytest from nameparser import Locale, Parser, locales, parse, parser_for from nameparser._lexicon import _VOCAB_FIELDS, Lexicon from nameparser._policy import ( FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST, UNSET, PatronymicRule, Policy, Script, _SCRIPT_RANGES, ) from nameparser._types import AmbiguityKind from nameparser.locales import ja as _ja from nameparser.locales import ru as _ru from nameparser.locales import tr_az as _tr_az from nameparser.locales import zh as _zh from .conftest import differential_corpus _CORPUS = differential_corpus() # The registry is the pack contract (design note 2026-07-18, option C): # the DEVIATES requirement, the rotator lists, and the non-interference # gate below all iterate the registered packs, so adding a pack is one # registry entry plus one rotator list -- the contract meta-test fails # until both exist, instead of a human remembering to extend N tests. def _pack_modules() -> dict[str, ModuleType]: out: dict[str, ModuleType] = {} for modname, attr in locales._REGISTRY.values(): module = importlib.import_module(modname) out[getattr(module, attr).code] = module return out _PACKS = _pack_modules() # ja is the one pack whose behavior is not pack data: it activates # segmentation and supplies nothing to segment WITH, so every parse # through it needs the optional nameparser[ja] segmenter. Probed once # here; without the extra the ja code has no _PACKED entry, so every # test that PARSES through the pack skips -- its rotator rows and its # non-interference run included. What still runs either way is what # needs no parse: the registry contract (DEVIATES and a rotator list # exist), the range pin, the pack's contents, and the inertness test # below, which is the unsegmented pack's whole observable behavior. _JA_AVAILABLE = importlib.util.find_spec("namedivider") is not None _needs_ja = pytest.mark.skipif(not _JA_AVAILABLE, reason="needs nameparser[ja]") # shared across the gate and rotator tests: the default-parser baseline # is identical everywhere (only the packed side varies per test), so # parse each name once for the whole module instead of once per test _DEFAULT_PARSER = Parser() def _packed_parsers() -> dict[str, Parser]: out: dict[str, Parser] = {} for code in _PACKS: if code != "ja": out[code] = parser_for(locales.get(code)) elif _JA_AVAILABLE: out[code] = parser_for( locales.JA, segmenter=locales.ja_segmenter()) return out _PACKED = _packed_parsers() def _require_packed(code: str) -> Parser: """The pack's parser, skipping the test when it needs an optional extra that is not installed (ja only, today).""" parser = _PACKED.get(code) if parser is None: pytest.skip(f"the {code!r} pack needs nameparser[{code}], " f"which is not installed") return parser @functools.cache def _default_parse(name: str) -> dict: """Default-parser components for `name` (treat as read-only -- the dict is cached and shared across callers).""" return _DEFAULT_PARSER.parse(name).as_dict() def test_locales_module_attribute_access() -> None: assert isinstance(locales.RU, Locale) assert locales.RU.code == "ru" assert locales.RU is locales.RU # cached, not rebuilt def test_locales_get_and_available() -> None: assert locales.get("ru") is locales.RU assert set(locales.available()) == {"ja", "ru", "tr_az", "zh"} with pytest.raises(KeyError, match="ja, ru, tr_az, zh"): locales.get("xx") # the segmenter FACTORY is not a locale: it is an ordinary function # on the package, so it must not appear among the codes assert "ja_segmenter" not in locales.available() assert callable(locales.ja_segmenter) def test_tr_az_pack_contents() -> None: assert locales.TR_AZ.code == "tr_az" assert locales.TR_AZ.policy.patronymic_rules == frozenset( {PatronymicRule.TURKIC}) assert locales.TR_AZ.lexicon == Lexicon.empty() def test_zh_pack_contents() -> None: assert locales.ZH.code == "zh" # the pack activates Han segmentation and ships vocabulary -- # and does NOT touch order: script_orders already reads Han # family-first by default (amendment 2026-07-27) assert locales.ZH.policy.segment_scripts == frozenset({Script.HAN}) assert locales.ZH.policy.name_order is UNSET assert locales.ZH.policy.script_orders is UNSET assert "王" in locales.ZH.lexicon.surnames assert "欧阳" in locales.ZH.lexicon.surnames # compound assert "歐陽" in locales.ZH.lexicon.surnames # traditional assert _zh.DEVIATES("毛泽东") assert _zh.DEVIATES("𠮷田") # astral Han declared assert not _zh.DEVIATES("John Smith") assert not _zh.DEVIATES("김민준") # HAN alone, not a wider union def test_zh_surname_entries_are_wellformed() -> None: # structural integrity, not content-pinning: 1-2 chars each, # every char in the stage's own Han ranges (catches a stray # Latin char, a pasted full name, or a hangul entry) ranges = _SCRIPT_RANGES[Script.HAN] for entry in locales.ZH.lexicon.surnames: assert 1 <= len(entry) <= 2, entry assert all(any(lo <= ord(c) <= hi for lo, hi in ranges) for c in entry), entry def test_zh_parses_unspaced_names() -> None: p = _PACKED["zh"] n = p.parse("毛泽东") assert (n.family, n.given) == ("毛", "泽东") n = p.parse("欧阳修") # compound + 1-char given assert (n.family, n.given) == ("欧阳", "修") n = p.parse("司马相如") # compound + 2-char given assert (n.family, n.given) == ("司马", "相如") # the 肖/萧 census merger ships both simplified spellings n = p.parse("萧红") assert (n.family, n.given) == ("萧", "红") def test_zh_longest_match_prefers_compound_over_single_surname() -> None: # The longest-first tie-break, exercised by the PACK's own data # rather than by the synthetic lexicon the stage tests use. Most # compounds here begin with a character that is not itself a listed # surname (欧, 司, 诸 are all outside the top-100), so they would # split the same way under shortest-first and prove nothing. 夏侯 is # one of only three compounds that would not -- 夏 is rank 65 -- so # this is the pair that shows the shipped data actually reaches the # tie-break. p = _PACKED["zh"] n = p.parse("夏侯惇") assert (n.family, n.given) == ("夏侯", "惇") n = p.parse("夏雨") # the single-surname foil assert (n.family, n.given) == ("夏", "雨") # ...and the ambiguity contract on that same data: a decided fork # is REPORTED (first Han coverage of the SEGMENTATION emitter -- # the stage tests reach it through a synthetic lexicon, and the # default lexicon can only reach it through hangul), while the # foil, where only one split was ever possible, stays silent. assert [a.kind for a in p.parse("夏侯惇").ambiguities] == [ AmbiguityKind.SEGMENTATION] assert p.parse("夏雨").ambiguities == () # 毛泽东/欧阳修/夏侯惇/萧红 appear in _ROTATORS["zh"] below, and 毛泽东/ # 夏侯惇 in cases.py's zh rows, as well. Not deduplicated in either # direction, on purpose: the rotator tests assert only THAT the packed # parse differs from the default and that DEVIATES declares it -- they # never look at a field value -- and the case rows reach the same # packed parser only through the shared-table runners # (test_cases.py/test_facade_cases.py). These tests are where the zh # readings are pinned at unit level, against the stage directly. def test_zh_composes_with_korean_defaults() -> None: # the pack only ADDS (vocabulary + one union field): Korean # segmentation keeps working through a zh parser n = _PACKED["zh"].parse("김민준") assert (n.family, n.given) == ("김", "민준") def test_honorific_spellings_agree_where_the_vocabulary_divides() -> None: # usage.rst says a SPACED honorific is how you keep a family name # whole. That holds only where a SEGMENTER divides it -- the ja # tests below pin that half -- because the segmenter's precondition # counts any neighbour and a spaced honorific is one. The # VOCABULARY has no such precondition: the peel (#308) hands it the # same remainder whichever way the honorific was written, so the # two spellings agree exactly. Pinned in both scripts because the # reader's escape hatch differs -- the zh pack can be declined, # hangul segmentation is a DEFAULT and cannot, so a Korean-data # reader has no lever at all here. ko = _DEFAULT_PARSER.parse("김민준 씨") assert (ko.family, ko.given, ko.suffix) == ("김", "민준", "씨") assert _DEFAULT_PARSER.parse("김민준씨").as_dict() == ko.as_dict() zh = _PACKED["zh"].parse("王小明 先生") assert (zh.family, zh.given, zh.suffix) == ("王", "小明", "先生") assert _PACKED["zh"].parse("王小明先生").as_dict() == zh.as_dict() def test_ja_pack_contents() -> None: assert locales.JA.code == "ja" # segmentation activation ONLY: no vocabulary (no list settles a # kanji name) and no order (the kana license already reads # Japanese family-first by default, decisions/W4) assert locales.JA.policy.segment_scripts == frozenset( {Script.HAN, Script.HIRAGANA}) assert locales.JA.policy.name_order is UNSET assert locales.JA.policy.script_orders is UNSET assert locales.JA.lexicon == Lexicon.empty() assert _ja.DEVIATES("山田太郎") assert _ja.DEVIATES("𠮷田") # astral Han declared # katakana declared (see the pack's repertoire note) assert _ja.DEVIATES("マイケル") assert _ja.DEVIATES("みなみ") # hiragana declared assert not _ja.DEVIATES("John Smith") assert not _ja.DEVIATES("김민준") # the three JA scripts, not a wider union def test_ja_segmenter_without_extra_raises_helpfully() -> None: if _JA_AVAILABLE: pytest.skip("namedivider installed; the error path is untestable") with pytest.raises(ImportError, match=r"nameparser\[ja\]"): locales.ja_segmenter() class _FakeDivided: """The shape of namedivider's DividedName, minus namedivider — lets the adapter's guard stack run in the plain (no-extra) suite, including the two defensive branches the real 0.4.x library can never reach (reconstruction failure, an empty side).""" def __init__(self, family: str, given: str, score: float) -> None: self.family, self.given, self.score = family, given, score def _fake_namedivider(monkeypatch: pytest.MonkeyPatch, divide: "Callable[[str], _FakeDivided]") -> list[str]: """Install a stub namedivider module and return the gbdt-call log (empty unless GBDTNameDivider was constructed).""" constructed: list[str] = [] class _Basic: def __init__(self) -> None: constructed.append("basic") def divide_name(self, text: str) -> _FakeDivided: return divide(text) class _GBDT(_Basic): def __init__(self) -> None: constructed.append("gbdt") stub = types.ModuleType("namedivider") stub.BasicNameDivider = _Basic # type: ignore[attr-defined] stub.GBDTNameDivider = _GBDT # type: ignore[attr-defined] monkeypatch.setitem(sys.modules, "namedivider", stub) return constructed def test_ja_adapter_guard_stack_against_a_stub( monkeypatch: pytest.MonkeyPatch) -> None: # The adapter's whole contract without the extra: this is what the # coverage-bearing CI job sees, so the guards are pinned here and # not only behind the nameparser[ja] integration tests. divide = _fake_namedivider( monkeypatch, lambda text: _FakeDivided(text[:2], text[2:], 0.5)) seg = locales.ja_segmenter() assert divide == ["basic"] answer = seg("山田太郎") assert answer is not None assert answer.splits == (2,) and answer.confidence == 0.5 assert seg("김민준") is None # outside the repertoire # contains JA chars but is not WHOLLY JA assert seg("Yamada太郎") is None assert seg("林") is None # namedivider raises below 2 # halfwidth kana is katakana, so a Han+halfwidth token is wholly # Japanese and reaches the divider (#594) assert seg("山田タロウ") is not None def test_ja_adapter_declines_shime_tokens( monkeypatch: pytest.MonkeyPatch) -> None: # Since U+3006 classifies as Han, 〆木太郎 is wholly Japanese and # reaches the adapter, but the divider is never consulted for 〆 # tokens -- see the decline comment in ja.py for the measured # mis-cut it prevents. calls: list[str] = [] def divide(text: str) -> _FakeDivided: calls.append(text) return _FakeDivided(text[:1], text[1:], 1.0) _fake_namedivider(monkeypatch, divide) assert locales.ja_segmenter()("〆木太郎") is None assert calls == [] # the decline sits before divide_name def test_ja_adapter_defensive_branches_against_a_stub( monkeypatch: pytest.MonkeyPatch) -> None: # reconstruction failure -> decline; an empty side -> the stated # "confidently undivided" opinion; a score a hair over 1 -> clamped, # so Segmentation's [0, 1] validation cannot raise mid-parse _fake_namedivider( monkeypatch, lambda text: _FakeDivided("外", "れ", 0.5)) assert locales.ja_segmenter()("山田太郎") is None _fake_namedivider( monkeypatch, lambda text: _FakeDivided(text, "", 1.0000001)) whole = locales.ja_segmenter()("山田太郎") assert whole is not None assert whole.splits == () and whole.confidence == 1.0 def test_ja_adapter_declines_a_garbage_score( monkeypatch: pytest.MonkeyPatch) -> None: # The other half of the score handling, and the half the old # unconditional clamp got wrong: float noise at the edge of a # softmax is arithmetic around a real answer and clamps (above), # but 87.0 is not noise -- a divider scoring that is broken, and # its DIVISION is worth no more than its score. Clamping mapped it # to 1.0, i.e. "certain", which silenced the SEGMENTATION report at # exactly the point of maximum brokenness. Declining leaves the # token whole: safe, and observable by the report's absence. _fake_namedivider( monkeypatch, lambda text: _FakeDivided(text[:2], text[2:], 87.0)) assert locales.ja_segmenter()("山田太郎") is None _fake_namedivider( monkeypatch, lambda text: _FakeDivided(text[:2], text[2:], -0.5)) assert locales.ja_segmenter()("山田太郎") is None def test_ja_adapter_declines_a_nan_score( monkeypatch: pytest.MonkeyPatch) -> None: # NaN takes the garbage-score exit for free rather than needing one # of its own: every comparison against NaN is False, so it fails # BOTH bounds of the epsilon band. Worth a test precisely because # it is a property of the comparison rather than a written branch # -- under the old clamp, min/max propagated it straight into # Segmentation, whose own range check would then have raised # mid-parse. _fake_namedivider( monkeypatch, lambda text: _FakeDivided(text[:2], text[2:], float("nan"))) assert locales.ja_segmenter()("山田太郎") is None def test_ja_adapter_gbdt_flag_selects_the_gbdt_divider( monkeypatch: pytest.MonkeyPatch) -> None: divide = _fake_namedivider( monkeypatch, lambda text: _FakeDivided(text[:1], text[1:], 1.0)) locales.ja_segmenter(gbdt=True) assert divide == ["gbdt"] def test_ja_pack_alone_is_inert() -> None: # The pack's own claim, and the reason it is safe to register # without the extra: activating segmentation while shipping nothing # to segment WITH changes no parse at all. Pure pack data, so this # runs whether or not nameparser[ja] is installed -- and it is the # unsegmented pack's entire observable behavior. # construction now SAYS the pack alone cannot divide anything -- # the segmenterless-activation warning is the loud half of this # test's claim, and the inert parses below are the quiet half with pytest.warns(UserWarning, match="ja_segmenter"): p = parser_for(locales.JA) for name in _ROTATORS["ja"]: assert p.parse(name).as_dict() == _default_parse(name), name @_needs_ja def test_ja_end_to_end() -> None: p = _PACKED["ja"] n = p.parse("山田太郎") assert (n.family, n.given) == ("山田", "太郎") # the case a zh surname list corrupts (高 + 橋一郎), done right n = p.parse("高橋一郎") assert (n.family, n.given) == ("高橋", "一郎") # kana-licensed composite: gated in through the HIRAGANA entry n = p.parse("高橋みなみ") assert (n.family, n.given) == ("高橋", "みなみ") # the iteration mark 々 gates in as Han (Script=Han under UAX #24, # but outside the ideograph blocks the classifier spans, so it # rides in on a singleton range entry) and namedivider reads the # repeated character correctly -- verified against 0.4.1 rather # than assumed, since a divider could plausibly have choked on it # or cut beside it n = p.parse("佐々木健") assert (n.family, n.given) == ("佐々木", "健") @_needs_ja def test_ja_keeps_a_transcribed_name_in_its_source_order() -> None: # pure katakana is excluded from the pack's activation by design # (rule W4's boundary: katakana is how Japanese writes FOREIGN # names), so # the nakaguro form divides on the dot and keeps its given-first # source order instead of being read family-first n = _PACKED["ja"].parse("マイケル・ジャクソン") assert (n.given, n.family) == ("マイケル", "ジャクソン") @_needs_ja def test_ja_composes_with_zh() -> None: # vocabulary first, segmenter on decline (rule W1) p = parser_for(locales.ZH, locales.JA, segmenter=locales.ja_segmenter()) assert p.parse("毛泽东").family == "毛" # zh surname wins assert p.parse("山田太郎").family == "山田" # zh declines, segmenter # ...and a Korean name still splits on the default vocabulary, # never reaching the segmenter (which would decline it anyway) assert p.parse("김민준").family == "김" @_needs_ja def test_ja_segmenter_declines_what_it_cannot_read() -> None: # The adapter's repertoire guard, which only a token that actually # REACHES the adapter can pin: a listed Korean surname is split by # the vocabulary first (the second case below), so this hangul # deliberately opens with a syllable no surname list carries. It # gates in on script, declines vocabulary, and reaches the adapter, # which must decline it rather than let namedivider divide Korean # -- without the guard it comes back as family 쿄 carrying a bogus # SEGMENTATION report. p = _PACKED["ja"] n = p.parse("쿄쿄쿄") assert (n.family, n.given) == ("쿄쿄쿄", "") assert n.ambiguities == () # ...and the vocabulary-wins case, which never reaches the adapter assert p.parse("김민준").family == "김" @_needs_ja def test_ja_segmenter_declines_a_lone_character() -> None: # namedivider RAISES below two characters, and a segmenter's # exceptions propagate by contract, so the adapter's length guard # is what keeps this parse total. It takes a LONE token to reach: # in "林 太郎" the stage's precondition stops the segmenter first. p = _PACKED["ja"] n = p.parse("林") assert (n.family, n.given) == ("林", "") assert n.ambiguities == () @_needs_ja def test_ja_leaves_spaced_names_to_the_default_order() -> None: # the segmenter's precondition (see test_parser.py): a spaced name # is already divided, and the kana/Han family-first defaults read # it correctly with no segmenter involved. Without this the pack # would wreck the commonest written form -- 山田 + 太郎 divided # again into 山 + 田 + 太郎. p = _PACKED["ja"] for name in ("山田 太郎", "高橋 みなみ", "高橋 エミ", "山田 太郎 Jr.", "山田 太郎 様"): assert p.parse(name).as_dict() == _default_parse(name), name assert p.parse("山田 太郎").family == "山田" @_needs_ja def test_ja_divides_a_glued_name_carrying_an_honorific() -> None: # #308 end to end, with the real divider: the honorific peels off # first, so namedivider is handed 山田太郎 rather than 山田太郎様 # -- which it would have divided somewhere wrong, answering for # any string it is given. GLUED only: the writer of 山田太郎様 drew # no boundary anywhere, so the tail the peel manufactured is none # either and the consult goes ahead. p = _PACKED["ja"] n = p.parse("山田太郎様") assert (n.family, n.given, n.suffix) == ("山田", "太郎", "様") # the kana-licensed composite too, whose division is rule-based n = p.parse("高橋みなみ様") assert (n.family, n.given, n.suffix) == ("高橋", "みなみ", "様") # and the spaced spelling is left alone, honorific and all: its # writer wrote 山田太郎 as a unit, which the pack respects the way # it respects "山田 太郎" n = p.parse("山田太郎 様") assert (n.family, n.given, n.suffix) == ("山田太郎", "", "様") @_needs_ja def test_ja_does_not_divide_a_spaced_surname_under_an_honorific() -> None: # A spaced honorific is a neighbour, and the segmenter is asked # only where an UNDIVIDED name divides, so it is never consulted # here. Only a tail this stage MANUFACTURED is exempt -- a glued # honorific means no boundary was typed anywhere, a spaced one # means one was, and by position a spaced honorific cannot be told # apart from a spaced name element besides. # The bare control is what makes any of that load-bearing: without # it the assertions pass vacuously, for any reason at all that the # segmenter went unconsulted. Each family name here DOES divide # standing alone, so the honorific is what stopped it -- and these # four are the measured price of counting spaced honorifics, paid # to decline the one division 山田太郎 様 (the test above). p = _PACKED["ja"] for name, family, divided in (("佐藤 氏", "佐藤", ("佐", "藤")), ("田中 様", "田中", ("田", "中")), ("鈴木 先生", "鈴木", ("鈴", "木")), ("中村 教授", "中村", ("中", "村"))): n = p.parse(name) assert (n.family, n.given) == (family, ""), name bare = p.parse(family) assert (bare.family, bare.given) == divided, family @_needs_ja def test_ja_divides_a_two_character_name_under_a_glued_honorific() -> None: # The consequence of the rule above on the commonest addressed # form, pinned so it is a recorded decision rather than a side # effect: with the peeled tail not blocking the consult, a # two-character remainder reaches namedivider, which divides it # one character each way BY RULE (score 1.0, so no SEGMENTATION # report). That is the same presumption locales/ja.py already # documents and test_ja_reports_statistical_divisions... pins for # 原恵 -- 田中さん is simply the shortest route to it. A caller # who wants 田中 kept whole is asking not to have unspaced names # divided, which is what declining the pack means -- or can write # the honorific spaced, which the test above pins. n = _PACKED["ja"].parse("田中さん") assert (n.family, n.given, n.suffix) == ("田", "中", "さん") assert n.ambiguities == () # rule-based, so nothing to report # the spaced twin, for the contrast in one place n = _PACKED["ja"].parse("田中 さん") assert (n.family, n.given, n.suffix) == ("田中", "", "さん") @_needs_ja def test_ja_reports_statistical_divisions_and_not_rule_based_ones() -> None: # the confidence floor's measured consequence (_script_segment.py's # constant): namedivider scores a rule-based division 1.0 and a # kanji-statistics division far below the floor, so the ambiguity # report separates exactly those two epistemic states p = _PACKED["ja"] assert [a.kind for a in p.parse("山田太郎").ambiguities] == [ AmbiguityKind.SEGMENTATION] assert p.parse("高橋みなみ").ambiguities == () # kana boundary rule assert p.parse("原恵").ambiguities == () # two-character rule @_needs_ja def test_ja_divides_a_lone_hiragana_token() -> None: # The whole-name presumption, applied to hiragana: at token level a # bare given name is indistinguishable from a full name, so the # stage gates みなみ in and namedivider divides it -- み + なみ, # which is wrong if the writer meant the given name alone and right # if they meant a (rare) all-kana full name. Exactly the acceptance # 田中 -> 田 + 中 already carries for kanji, pinned here so the kana # path's version is a recorded consequence rather than a surprise. # The statistical score puts a SEGMENTATION report on it, which is # the caller's handle for routing it to review. n = _PACKED["ja"].parse("みなみ") assert (n.family, n.given) == ("み", "なみ") assert [a.kind for a in n.ambiguities] == [AmbiguityKind.SEGMENTATION] @_needs_ja def test_ja_divides_an_astral_kanji_name() -> None: # 𠮷 (U+20BB7, the "tsuchiyoshi" 吉) is a real surname character in # the supplementary plane -- the reason _SCRIPT_RANGES carries the # astral Han span at all. It has to survive the whole chain as one # character and not two: the script gate, the adapter's repertoire # guard, namedivider itself, and the offset arithmetic that turns # the answer into spans (Python indexes by codepoint, so an offset # of 2 here spans four UTF-16 units -- a surrogate-pair bug would # land the split in the middle of the character). n = _PACKED["ja"].parse("𠮷田太郎") assert (n.family, n.given) == ("𠮷田", "太郎") assert [a.kind for a in n.ambiguities] == [AmbiguityKind.SEGMENTATION] @_needs_ja def test_namedivider_still_miscuts_shime_names() -> None: # The canary behind the adapter's 〆 decline: when this fails, # namedivider learned the mark -- revisit the decline in ja.py # rather than deleting this test. import namedivider result = namedivider.BasicNameDivider().divide_name("〆木太郎") assert (result.family, result.given) == ("〆", "木太郎") assert float(result.score) == 1.0 @_needs_ja def test_ja_stacked_on_zh_pays_the_zh_packs_documented_cost() -> None: # Stacking composes MECHANICALLY (the test above) but not for free, # and this pins the cost so it stays a documented trade rather than # a bug report. Vocabulary runs first, so a Japanese kanji name # opening on a listed CHINESE surname never reaches the segmenter: # 高 is a common Chinese surname, so 高橋一郎 splits 高 + 橋一郎 # under ZH+JA exactly as it does under ZH alone -- the same reading # cases.py's zh_japanese_kanji_tradeoff row records, and the reason # the docs call the two packs corpus alternatives and qualify the # stack (see _script_segment.py's module docstring, usage.rst and # the release log). JA alone reads it correctly (test_ja_end_to_end). p = parser_for(locales.ZH, locales.JA, segmenter=locales.ja_segmenter()) n = p.parse("高橋一郎") assert (n.family, n.given) == ("高", "橋一郎") # and silently: one vocabulary match is not a fork, so nothing here # flags the mis-split -- the pack choice is what has to be right assert n.ambiguities == () def test_pack_vocabulary_entries_are_single_words() -> None: # The shipped-vocabulary guard (test_lexicon.py's # test_default_lexicon_builds_warning_free), extended to the OTHER # vocabulary source: a locale pack's lexicon fields are matched one # word at a time same as Lexicon.default()'s, so a multi-word entry # in any field but given_name_titles would be dead. Checked # directly against field contents (not by capturing the # construction-time warning) because a pack module is imported -- # and its Locale built -- at most once per process; a later test in # this file may already have forced the import, which would make a # warning-capture version of this test pass even on a regression. # Iterates every registered pack, not just ru/tr_az by name, so a # future pack is covered automatically. zh is the pack that gives # the field loop something to chew on (ru/tr_az build on # Lexicon.empty()); the registry-non-empty assert below keeps the # test from passing vacuously if that ever stops being true. assert locales.available() for code in locales.available(): lexicon = locales.get(code).lexicon for field in _VOCAB_FIELDS: if field == "given_name_titles": continue for entry in getattr(lexicon, field): assert entry == "".join(entry.split()), ( f"{code}: {field} entry {entry!r} contains " f"whitespace and can never match") def test_pack_marker_regexes_stay_in_sync_with_post_rules() -> None: # ru.py/tr_az.py hand-copy their DEVIATES marker regexes from # _pipeline._post_rules (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 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), ): assert pack_regex.pattern == rule_regex.pattern assert pack_regex.flags == rule_regex.flags def test_ru_deviates_scans_per_token() -> None: # Regression: the rule rotates on the family-POSITION token, so a # whole-string search missed a patronymic ending followed by a # suffix -- under-declaration, the unsafe direction for the # non-interference gate. The pack rotates this name; DEVIATES must # say so. assert _ru.DEVIATES("Ivan Petr Sidorovich Jr.") # Over-declaration is the accepted safe direction: the ending # matches a token although the rule's 1 given + 1 middle + # 1 family shape never fires on a two-token name. assert _ru.DEVIATES("Sidorovich Anna") assert not _ru.DEVIATES("John Smith") def test_tr_az_deviates_scans_per_token() -> None: # Mirror of the RU regression above: the Turkic marker regexes are # fullmatch-shaped (^...$), so anything but a per-token scan would # miss a marker followed by a suffix (under-declaration) -- and a # whole-string match would never fire at all on multi-token names. assert _tr_az.DEVIATES("Mammadova Aygun Ali kizi Jr.") # over-declaration stays the safe direction: a bare marker-shaped # token declares even where the rule won't rewrite anything assert _tr_az.DEVIATES("Kizi Anna") assert not _tr_az.DEVIATES("John Smith") # markers are whole-token: a name merely CONTAINING one must not # declare ("Ogluev" is not a marker) assert not _tr_az.DEVIATES("Ogluev Ivan") def test_locales_unknown_attribute() -> None: with pytest.raises(AttributeError, match="XX"): locales.XX def test_ru_plus_tr_az_unions_patronymic_rules() -> None: p = parser_for(locales.RU, locales.TR_AZ) assert p.policy.patronymic_rules == frozenset( {PatronymicRule.EAST_SLAVIC, PatronymicRule.TURKIC}) # both rules live: one name from each pack's case segment ru = p.parse("Сидоров Иван Петрович") assert ru.given == "Иван" tr = p.parse("Mammadova Aygun Ali kizi") assert (tr.given, tr.middle, tr.family) == ( "Aygun", "Ali kizi", "Mammadova") def test_pack_over_custom_base() -> None: base = Parser(lexicon=Lexicon.default().add(titles={"zqxcustom"})) p = parser_for(locales.RU, base=base) n = p.parse("Zqxcustom Иван Петрович") assert n.title == "Zqxcustom" # base lexicon survives the fold def test_pack_preserves_custom_base_policy() -> None: # the lexicon-survival twin above has a policy counterpart: a # policy-only pack must ADD its patronymic rule without resetting # unrelated policy fields the base customized base = Parser(policy=Policy(strip_emoji=False)) p = parser_for(locales.RU, base=base) assert p.policy.strip_emoji is False assert p.policy.patronymic_rules == frozenset( {PatronymicRule.EAST_SLAVIC}) def test_parser_for_results_chain_as_bases() -> None: # parser_for's result is itself a valid base= -- packs applied in # two steps accumulate exactly like one call with both packs chained = parser_for(locales.TR_AZ, base=_PACKED["ru"]) assert chained.policy.patronymic_rules == frozenset( {PatronymicRule.EAST_SLAVIC, PatronymicRule.TURKIC}) assert chained.parse("Сидоров Иван Петрович").given == "Иван" assert chained.parse("Mammadova Aygun Ali kizi").family == "Mammadova" def test_the_rotation_stands_down_under_a_declared_family_first_order( ) -> None: """#384 option 1: O1's rotation is the DEFAULT order's work. It exists to RESTORE the given-first reading from a family-first listing, so a caller who has DECLARED FAMILY_FIRST has already supplied what it would have inferred and position decides instead. That scoping is a GATE, not a property of the shape: the rotation reads the word in the FAMILY position for a patronymic ending, and under FAMILY_FIRST that slot holds the name's FIRST word. 'Мицкевич Адам Юзеф' is the input that makes the difference visible -- a patronymic-DERIVED surname written first. Without the gate the rotation fires on it and reports family 'Адам'; with the gate it reads exactly as plain FAMILY_FIRST does. The other two are one measurement of the accepted divergence. The family-first listing reads the same either way, and the natural-order name reads as the caller declared it -- family 'Иван' -- which is what #384 measured, Accepted in docs/design/decisions/O1 rather than treated as a defect. rules/O1 states it in prose because an example line there takes ONE annotation, a pack or an order, never both. """ ff = parser_for(locales.RU, base=Parser(policy=Policy(name_order=FAMILY_FIRST))) # the firing control: without the gate this reads family 'Адам' derived = ff.parse("Мицкевич Адам Юзеф") assert (derived.family, derived.given, derived.middle) == ( "Мицкевич", "Адам", "Юзеф") plain = Parser(policy=Policy(name_order=FAMILY_FIRST)).parse( "Мицкевич Адам Юзеф") assert (derived.family, derived.given, derived.middle) == ( plain.family, plain.given, plain.middle) listing = ff.parse("Сидоров Иван Петрович") assert (listing.family, listing.given, listing.middle) == ( "Сидоров", "Иван", "Петрович") natural = ff.parse("Иван Петрович Сидоров") assert (natural.family, natural.given, natural.middle) == ( "Иван", "Петрович", "Сидоров") def test_the_turkic_marker_lands_in_a_name_field_under_both_orders( ) -> None: """Pins the prose decisions/O1's 2026-09-07 entry states for O2. Gate-independent: `Ali Ahmad Vali oglu` is natural-order input, so the rotation would not fire on it under the default order either. What the two readings show is O2's own 2026-07-02 Accepted consequence reached by a second route -- a declared family-first order leaves the marker standing in a name field. Under FAMILY_FIRST that costs nothing visible, the family being the word the rotation would have chosen anyway; under FAMILY_FIRST_GIVEN_LAST the marker becomes the GIVEN name, which is the reading rules/O2 carries as Accepted. """ ff = parser_for(locales.TR_AZ, base=Parser( policy=Policy(name_order=FAMILY_FIRST))) n = ff.parse("Ali Ahmad Vali oglu") assert (n.family, n.given, n.middle) == ("Ali", "Ahmad", "Vali oglu") ffgl = parser_for(locales.TR_AZ, base=Parser( policy=Policy(name_order=FAMILY_FIRST_GIVEN_LAST))) n = ffgl.parse("Ali Ahmad Vali oglu") assert (n.family, n.given, n.middle) == ("Ali", "oglu", "Ahmad Vali") def test_locales_import_is_lazy(monkeypatch: pytest.MonkeyPatch) -> None: # importing the package must not import any pack module; PEP 562 # loads them on first attribute access (the lazy-access contract: # "importing # nameparser never pays for pack data"). monkeypatch snapshots # sys.modules AND the parent package attribute (the fresh import # below rebinds nameparser.locales), so everything rolls back even # if an assert fails mid-test (manual del + reload left other tests # running against a half-restored module cache on failure). import sys import nameparser as _np monkeypatch.setattr(_np, "locales", _np.locales) for mod in list(sys.modules): if mod.startswith("nameparser.locales"): monkeypatch.delitem(sys.modules, mod) import nameparser.locales assert "nameparser.locales.ru" not in sys.modules nameparser.locales.RU assert "nameparser.locales.ru" in sys.modules def _assert_non_interference( packed: Parser, deviates: Callable[[str], bool], corpus: Iterable[str], ) -> int: """Return the number of DECLARED deviations seen; fail on any undeclared one (the pack-acceptance rejection rule).""" declared = 0 for name in corpus: base = _default_parse(name) got = packed.parse(name).as_dict() if got != base: assert deviates(name), ( f"UNDECLARED deviation: {name!r}\n" f" default: {base}\n packed: {got}") declared += 1 return declared def _default_corpus() -> list[str]: from .cases import CASES return list(_CORPUS) + [c.text for c in CASES if c.locale is None and c.policy is None] # Synthetic rotator names, keyed by pack code: one per alternation # branch of each pack's marker regexes (both scripts), in the shape that # makes the rule fire (RU: family-first, patronymic last, plain middle; # TR_AZ: a standalone marker token). They serve the gate's POSITIVE side # -- without them the shared corpus exercises TR_AZ with a single name, # leaving the gate nearly vacuous (review finding, 2026-07-17). Branch # named per row, enforced by the branch-coverage test below. _ROTATORS: dict[str, list[str]] = {} _ROTATORS["ru"] = [ "Sidorov Ivan Petrovich", # ovich "Sidorova Anna Petrovna", # ovna "Karpov Oleg Sergeevich", # evich "Karpova Olga Sergeevna", # evna "Petrova Maria Nikitichna", # ichna "Ulyanov Vladimir Ilyich", # ilyich "Bulgakov Ivan Kuzmich", # kuzmich "Titov Pyotr Lukich", # lukich "Orlov Semyon Fomich", # fomich "Zaitsev Andrei Fokich", # fokich "Сидоров Иван Петрович", # ович "Сидорова Анна Петровна", # овна "Карпов Олег Сергеевич", # евич "Карпова Ольга Сергеевна", # евна "Петрова Мария Никитична", # ична "Ульянов Владимир Ильич", # ильич "Булгаков Иван Кузьмич", # кузьмич "Титов Пётр Лукич", # лукич "Орлов Семён Фомич", # фомич "Зайцев Андрей Фокич", # фокич ] _ROTATORS["tr_az"] = [ "Aliyev Ali Vali oglu", # oglu "Aliyev Rashad Vali oğlu", # oğlu "Mammadov Elchin Hasan ogly", # ogly "Karimov Aziz Vali ogli", # ogli "Karimov Alisher Vali o'g'li", # o['’ʻ]g['’ʻ]li, ASCII ' "Yusupov Botir Vali o’g’li", # o['’ʻ]g['’ʻ]li, typographic ’ "Mammadova Aygun Hasan qizi", # qizi "Aliyeva Leyla Vali qızı", # qızı "Mammadova Sevinj Ali kizi", # kizi "Asanova Aigul Bolot kyzy", # kyzy "Guliyeva Nigar Vali gyzy", # gyzy "Nazarbayev Nursultan Abish uly", # uly "Zheenbekov Sooronbay Sharip uulu", # uulu "Алиев Али Вали оглу", # оглу "Мамедов Эльчин Гасан оглы", # оглы "Алиев Рашад Вали оғлу", # оғлу "Каримов Алишер Вали ўғли", # ўғли "Юсупов Ботир Вали угли", # угли "Мамедова Айгюн Гасан кызы", # кызы "Алиева Лейла Вали гызы", # гызы "Назарбаева Дарига Нурсултан қызы", # қызы "Каримова Лола Вали қизи", # қизи "Назарбаев Нурсултан Абиш улы", # улы "Токаев Касым Кемел ұлы", # ұлы "Жээнбеков Сооронбай Шарип уулу", # уулу ] # zh has no marker regexes to rotate through: its rotators cover the # SEGMENTATION shapes instead (single/compound surname, simplified/ # traditional, 1- and 2-character given names). No spaced entry -- # spaced Han already parses family-first by default, so a spaced name # would not deviate from the default parser at all. _ROTATORS["zh"] = [ "毛泽东", # 1-char surname, unspaced "张伟", # 2-char name: 1-char surname + 1-char given "欧阳修", # compound surname (欧 is NOT itself listed) "夏侯惇", # compound beats single: 夏 IS listed too "司马相如", # compound + 2-char given "王力宏", # 1-char surname + 2-char given "諸葛亮", # traditional-script compound "萧红", # the 肖/萧 merger's second simplified spelling ] # ja has no marker regexes and no vocabulary either: its rotators cover # the shapes only the pack PLUS its segmenter changes. Every row is # UNSPACED on purpose -- a spaced kana-licensed name (高橋 みなみ) now # reads family-first by DEFAULT (decisions/W4), so it would # not deviate and would fail the must-deviate assertion below. _ROTATORS["ja"] = [ "山田太郎", # 2-kanji family + 2-kanji given, the common shape "高橋一郎", # the name a zh surname list corrupts (高 + 橋一郎) "田中花子", # ditto (田 is a listed zh surname) "原辰徳", # 1-kanji family + 2-kanji given: the other fork "高橋みなみ", # kanji + hiragana: the kana license's carrier case "山田エミ", # kanji + katakana: licensed as a MIXED token, where # pure katakana (マイケル) is deliberately not "佐々木健", # the iteration mark 々 inside a top-20 surname ] def _marker_regexes(module: ModuleType) -> list[re.Pattern]: return [v for v in vars(module).values() if isinstance(v, re.Pattern)] # Packs whose DEVIATES surface is defined by marker REGEXES -- derived, # never listed, same rule as the registry itself: a pack qualifies by # having any module-level re.Pattern, so zh and ja (which declare by # codepoint range, derived from the shared table via _script_matcher, # so they hold no pattern of their own) drop out on their own and a # future marker-regex pack cannot silently miss branch coverage. _MARKER_REGEX_PACKS = tuple(code for code in sorted(_PACKS) if _marker_regexes(_PACKS[code])) def test_range_declaring_packs_stay_out_of_marker_classification() -> None: # Load-bearing since the packs derive from the shared table: zh/ja # hold no module-level re.Pattern because _script_matcher's closure # hides the compiled class. A pack author who "simplifies" one back # to a module-level pattern flips its classification into the # rotator branch-coverage sweep, which a codepoint-range predicate # cannot satisfy. assert "zh" not in _MARKER_REGEX_PACKS assert "ja" not in _MARKER_REGEX_PACKS def test_registry_is_the_pack_contract() -> None: # the declared-deviations authoring requirement # (mechanisms.md#LOCALE-PACKS-PURE-DATA), enforced structurally # (design # note 2026-07-18, option C): every registered pack module must # declare its deviation surface, and every pack must feed the gate's # positive side -- a new pack fails HERE until it ships both, rather # than a reviewer remembering to extend the gate by hand. assert set(_PACKS) == set(locales.available()) for code, module in _PACKS.items(): assert callable(getattr(module, "DEVIATES", None)), ( f"pack {code!r} does not declare DEVIATES") # _MARKER_REGEX_PACKS selects on module-level patterns, so a # pack that declares by regex but keeps its patterns inside a # function would drop out of the branch-coverage sweep in # silence. Importing `re` is the tell. The tradeoff is # deliberate: a range-declaring pack that imports re for some # unrelated reason trips this loudly, which beats a silent # coverage gap -- and the fix is to expose the pattern. if "re" in vars(module): assert _marker_regexes(module), ( f"pack {code!r} imports re but exposes no module-level " f"pattern") assert set(_ROTATORS) == set(_PACKS), ( "every pack needs a rotator list (and only registered packs)") # the derivation is code, not a list, so an empty result would make # the branch-coverage test vanish into zero parametrizations rather # than fail. Assert it found something. assert _MARKER_REGEX_PACKS, "no pack declares marker regexes" def _alternation_branches(pattern: str) -> list[str]: """Split a marker regex of the shape '(a|b|...)$' / '^(a|b|...)$' into its alternatives. The packs' character classes contain no '|', so a flat split is safe (and the shape assert guards the day one stops being true).""" inner = pattern.removeprefix("^").removesuffix("$") assert inner.startswith("(") and inner.endswith(")") and "|" not in ( pattern.replace(inner, "")) return inner[1:-1].split("|") @pytest.mark.parametrize("code", _MARKER_REGEX_PACKS) def test_rotators_cover_every_marker_branch(code: str) -> None: # The rotator lists' per-row branch comments are a human convention; # 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) # fails HERE until a rotator name covers it. Anchoring follows the # pattern's own shape: '^(...)$' = whole-token marker, '(...)$' = # token ending. tokens = [tok for name in _ROTATORS[code] for tok in name.split()] # non-empty by construction: having marker regexes is what put this # pack in _MARKER_REGEX_PACKS in the first place for regex in _marker_regexes(_PACKS[code]): for branch in _alternation_branches(regex.pattern): anchored = regex.pattern.startswith("^") branch_re = re.compile( f"^({branch})$" if anchored else f"({branch})$", re.I) assert any(branch_re.search(tok) for tok in tokens), ( f"no {code!r} rotator exercises marker branch {branch!r}") @pytest.mark.parametrize("code, name", [ (code, name) for code in sorted(_ROTATORS) for name in _ROTATORS[code]]) def test_rotator_deviates_and_declares(code: str, name: str) -> None: # every branch of the pack's marker regexes both FIRES (packed parse # differs from default) and is DECLARED (DEVIATES says so) -- the # per-name proof behind the gate's declared-count floor below packed = _require_packed(code) assert packed.parse(name).as_dict() != _default_parse(name) assert _PACKS[code].DEVIATES(name) @pytest.mark.parametrize("code", sorted(_PACKS)) def test_non_interference_each_pack(code: str) -> None: packed = _require_packed(code) corpus = _default_corpus() + _ROTATORS[code] declared = _assert_non_interference( packed, _PACKS[code].DEVIATES, corpus) # the positive side: every synthetic rotator (plus the corpus's own # in-scope names) must actually flow through the declared branch assert declared >= len(_ROTATORS[code]) def test_non_interference_all_packs_combined() -> None: # Every pack is applied whether or not its extra is installed (the # ja pack's policy patch is pure data); only the ROTATORS of a pack # that cannot act are dropped, since the count assertion below # requires each listed name to actually deviate. all_rotators = [n for code in sorted(_ROTATORS) if code in _PACKED for n in _ROTATORS[code]] corpus = _default_corpus() + all_rotators if _JA_AVAILABLE: packed = parser_for( *(locales.get(code) for code in sorted(_PACKS)), segmenter=locales.ja_segmenter()) else: # without the extra, the stack's hiragana activation is # unservable -- which construction now SAYS (the segmenterless # warning, pinned in test_parser.py); expected noise here, the # gate below is what this test is about with pytest.warns(UserWarning, match="ja_segmenter"): packed = parser_for( *(locales.get(code) for code in sorted(_PACKS))) declared = _assert_non_interference( packed, lambda n: any(m.DEVIATES(n) for m in _PACKS.values()), corpus) assert declared >= len(all_rotators) # -- #269: non-Latin default vocabulary (Cyrillic, Greek, Arabic, # Hebrew, Devanagari, Bengali) -- # # This is DEFAULT vocabulary (nameparser/vocab/*.toml, which file a # word is in by its language), not a locale pack -- it lives here # because it's the non-Latin counterpart to the pack tests above. Every # row was verified live against a runtime-augmented Lexicon.default() # before the data landed (2026-07-17), so these pin actual observed # behavior, not guesses -- see the per-script comments below for the # rows that came out differently than a first guess would suggest. # The Devanagari block grew and a Bengali one opened on 2026-09-06 # with the Indic honorific bundle (#346/#344/#343); those rows were confirmed the # same way, live against a runtime-augmented Lexicon.default() before # the data landed. See decisions/indic-honorifics. @pytest.mark.parametrize("name, field, expected", [ # Cyrillic (ru/uk) titles. ("г-н Иван Петров", "title", "г-н"), ("г-жа Мария Иванова", "title", "г-жа"), ("д-р Мария Иванова", "title", "д-р"), ("проф Петро Шевченко", "title", "проф"), ("акад Іван Франко", "title", "акад"), ("пан Тарас Шевченко", "title", "пан"), ("пані Марія Іванова", "title", "пані"), # Cyrillic conjunction "и": v1's single-letter carve-out (Google # Code issue 11, the "john e smith" bug) treats a bare # single-alphabetic-character conjunction in a short name as more # likely an initial (group._group_segment), so a 3-piece chain # ("проф и акад Іван Франко") does NOT join -- "и" reads as a given # name instead. The chain only joins once the segment has enough # rootname pieces (total >= 4); pinned against that actual behavior # with a 5-piece name instead of the shorter guess. ("проф и акад Тарас Григорович Шевченко", "title", "проф и акад"), ("проф та акад Іван Франко", "title", "проф та акад"), # Ukrainian "і" is single-character like "и", so the same # single-letter carve-out applies: pinned with a 5-piece name. ("проф і акад Тарас Григорович Шевченко", "title", "проф і акад"), # Ukrainian "й" is the euphonic alternate of "і" (the two swap by # surrounding vowel/consonant, so real data carries both) -- same # single-letter carve-out, pinned with the same 5-piece shape. ("проф й акад Тарас Григорович Шевченко", "title", "проф й акад"), # ...and joining two given names, the shape #267's reporter raised: # a bare Cyrillic letter between given names reads as the # conjunction, not an initial (Cyrillic typography writes initials # with periods -- see # test_267_dotted_cyrillic_initial_wins_over_conjunction). ("Олесь й Олена Коваленки", "given", "Олесь й Олена"), # Greek titles + conjunction (και has 3 letters, so the single-char # initial carve-out above never applies to it). Bare κ is NOT # shipped -- it collides with the initial+surname shape (see the # regression test below). # Match-time contract of the lower()-not-casefold() normalization # (review 2026-07-19): case variants that lower() folds still # match; the ASCII-SS spelling of ß does NOT (v1-parity -- casefold # would have matched it, at the cost of mutating stored spellings). ("ΚΟΣ Γιώργος Παπαδόπουλος", "title", "ΚΟΣ"), ("GROẞFÜRST Otto Schmidt", "title", "GROẞFÜRST"), ("GROSSFÜRST Otto Schmidt", "title", ""), ("δρ Νίκος Παπαδόπουλος", "title", "δρ"), ("κος Γιώργος Παπαδόπουλος", "title", "κος"), ("κα Μαρία Παπαδοπούλου", "title", "κα"), ("καθ και δρ Νίκος Παπαδόπουλος", "title", "καθ και δρ"), # Arabic patronymic/clan prefixes: non-leading "بن"/"بنت" chain onto # the family, mirroring the Latin "von"/"bin" prefix-chain rule. ("محمد بن سلمان", "family", "بن سلمان"), ("فاطمة بنت محمد", "family", "بنت محمد"), # "ابن" (alternate spelling) and the clan prefix "آل" are # never-given: non-leading they chain onto the family, and a # LEADING "آل" consumes the whole name as family (no given), like # "de Mesnil". ("محمد ابن رشد", "family", "ابن رشد"), ("محمد آل سعود", "family", "آل سعود"), ("آل سعود", "family", "آل سعود"), ("آل سعود", "given", ""), # The kunya "أبو"/"ابو" is AMBIGUOUS (it can begin a standalone # byname): leading it reads as the given name -- the "Van Johnson" # rule -- while non-leading it chains onto the family like any # particle. ("أبو مازن", "given", "أبو"), ("أحمد أبو خليل", "family", "أبو خليل"), ("علي ابو خالد", "family", "ابو خالد"), # "الشيخ" is a given-name title: a single following name reads as # given, not family. Its transliteration 'sheikh' is a title only # inside a title phrase since #752 phase 2j, so 'Sheikh Mohammed' # reads given 'Sheikh'; the article form is no given name. ("الشيخ محمد", "given", "محمد"), # Arabic honorifics precede the GIVEN name, so all ship in # given_name_titles like الشيخ: article forms (never given names) # and the bare doctor/professor/engineer forms (not used as given # names). Deferred with the same collision rule as мл/ст: bare # سيد/شيخ/أمير/سلطان (common given names) and the 'د.' # abbreviation (bare 'د' would swallow initials, the bare-κ trap). ("الدكتور أحمد", "given", "أحمد"), ("الدكتور أحمد", "title", "الدكتور"), ("الدكتورة منى السيد", "title", "الدكتورة"), ("الدكتورة منى السيد", "family", "السيد"), ("دكتور أحمد", "given", "أحمد"), ("دكتورة منى", "given", "منى"), ("الأستاذ محمود", "given", "محمود"), ("أستاذ محمود", "given", "محمود"), ("أستاذة سلمى", "given", "سلمى"), ("الأستاذة سلمى", "given", "سلمى"), ("مهندس حسن", "given", "حسن"), ("الشيخة موزة", "given", "موزة"), ("الحاجة فاطمة", "given", "فاطمة"), # compound with the bound given name: title + bound join + family ("الحاج عبد الرحمن السيد", "title", "الحاج"), ("الحاج عبد الرحمن السيد", "given", "عبد الرحمن"), ("الحاج عبد الرحمن السيد", "family", "السيد"), # a given-name title plus the bound pair ALONE: the licence # (rules/P5, #369) lifts the family reserve, so the pair is the # given name and, as for "Sir John", there is no family. Thirteen # of the given-name titles are this script's, so this is where # the licence mostly fires. ("الشيخ عبد الله", "title", "الشيخ"), ("الشيخ عبد الله", "given", "عبد الله"), ("الشيخ عبد الله", "family", ""), # "و" ("and") joins title chains like Cyrillic "и"; single-char # conjunctions need the same single-letter carve-out headroom (enough # rootname pieces), so pinned with the 6-piece shape the и row uses ("الأستاذ و الدكتور طارق حسن السيد", "title", "الأستاذ و الدكتور"), # Hebrew patronymic prefixes: same non-leading chain-onto-family # behavior. ("דוד בן גוריון", "family", "בן גוריון"), ("שרה בת אברהם", "family", "בת אברהם"), # Hebrew "מר" title (plain title, not GIVEN_NAME_TITLES -- like # 'mr', the following name reads as family). ("מר דוד לוי", "title", "מר"), # Hebrew title/suffix sweep (#269 follow-up): plain titles (Israeli # convention: family follows, the מר precedent); both geresh/ # gershayim spellings ship where an abbreviation carries one. ("גברת רות כהן", "title", "גברת"), ("פרופ' דוד לוי", "title", "פרופ'"), ("פרופ׳ דוד לוי", "title", "פרופ׳"), ("פרופסור רות כהן", "title", "פרופסור"), ('עו"ד דוד לוי', "title", 'עו"ד'), ("עו״ד דוד לוי", "title", "עו״ד"), ("הרב עובדיה יוסף", "title", "הרב"), # post-nominal Hebrew suffixes: ז"ל ("of blessed memory") and # שליט"א (honorific for living rabbis) -- suffix words, exact # token incl. the mid-word gershayim (inert in extraction, like # the ד"ר title) ('משה כהן ז"ל', "suffix", 'ז"ל'), ("משה כהן ז״ל", "suffix", "ז״ל"), ('הרב משה פיינשטיין שליט"א', "suffix", 'שליט"א'), ("הרב משה פיינשטיין שליט״א", "title", "הרב"), # Devanagari (hi/mr) titles -- a new #269 script: श्री/श्रीमती are # the Mr./Mrs. analogs, डॉ the Dr. abbreviation (edge-period # normalization makes "डॉ." match too). NO Latin twins on purpose: # transliterated sri/shri collide with real given names (Sri # Mulyani); the native script cannot. ("श्री राम शर्मा", "title", "श्री"), ("श्रीमती सीता शर्मा", "title", "श्रीमती"), ("डॉ राम शर्मा", "title", "डॉ"), ("डॉ. राम शर्मा", "title", "डॉ."), # Geresh/gershayim gate (#269 step 2): probed live against # extract_delimited's _open_ok/_close_ok boundary rules. Both the # ASCII-quote spelling and the typographic Unicode spelling of # "doctor"/"Mrs." survive extraction untouched -- the quote sits # mid-word (no preceding whitespace before '"', and for the # trailing "'" no following word boundary), so it never satisfies # the boundary-valid open/close test and is left as literal text. # Both spellings are shipped; see their notes in nameparser/vocab/he.toml. ('ד"ר דוד לוי', "title", 'ד"ר'), ("גב' דוד לוי", "title", "גב'"), ("ד״ר דוד לוי", "title", "ד״ר"), ("גב׳ דוד לוי", "title", "גב׳"), # Indic trailing honorifics (#344/#343) -- SUFFIX_WORDS, spaced # only. These rows need none of the Devanagari/Bengali title # vocabulary; the trailing path reads the suffix set alone. ("नरेन्द्र मोदी जी", "suffix", "जी"), ("नरेन्द्र मोदी जी", "family", "मोदी"), # The glued prohibition, both shapes. जी is deliberately NOT a # honorific_tails entry: Banerjee/Mukherjee/Chatterjee end in the # substring and a glued peel strands a fragment on a bare virama # (बनर् + जी). गांधीजी staying unpeeled is the accepted cost. ("अमित बनर्जी", "family", "बनर्जी"), ("अमित बनर्जी", "suffix", ""), ("बनर्जी", "given", "बनर्जी"), ("बनर्जी", "suffix", ""), ("गांधीजी", "given", "गांधीजी"), ("गांधीजी", "suffix", ""), # Bengali trailing honorific. One name word plus a suffix reads # GIVEN, not family -- H1 never fires, there being no title. ("রহমান সাহেব", "suffix", "সাহেব"), ("রহমান সাহেব", "given", "রহমান"), ("রহমান সাহেব", "family", ""), # বাবু is TRAILING in Bengali where Devanagari बाबू is LEADING. # Different codepoints, so the two cannot interact -- and a sweep # "harmonizing" them changes four parses and, without these rows, # reddens nothing (decisions/indic-honorifics). ("অমল বাবু", "suffix", "বাবু"), ("অমল বাবু", "given", "অমল"), ("बाबू अमल", "title", "बाबू"), ("बाबू अमल", "family", "अमल"), # Devanagari honorifics (#344). The renunciate set folds like the # Latin one: स्वामी/गुरु/बाबा/संत are given-name titles, so one # following name is a GIVEN name and the family is empty. ("स्वामी विवेकानंद", "title", "स्वामी"), ("स्वामी विवेकानंद", "given", "विवेकानंद"), ("स्वामी विवेकानंद", "family", ""), # Two name words: H1's fold needs exactly one, so the family # survives. This is the boundary the ledger's probes also pin. ("स्वामी शिवानंद सरस्वती", "given", "शिवानंद"), ("स्वामी शिवानंद सरस्वती", "family", "सरस्वती"), # संत ships and Latin Sant deliberately does not (Sant Singh is a # Punjabi given name) -- the script asymmetry, executable. ("संत कबीर", "title", "संत"), ("संत कबीर", "given", "कबीर"), ("Sant Kabir", "given", "Sant"), ("Sant Kabir", "family", "Kabir"), # The civil control: a TITLES-only Devanagari honorific families # the one name word, exactly as श्री/डॉ have since 2.0.0. ("डॉक्टर शर्मा", "title", "डॉक्टर"), ("डॉक्टर शर्मा", "family", "शर्मा"), # Bengali honorifics (#343) -- a new #269 script. The renunciate # set folds as it does in Devanagari and Latin. ("স্বামী বিবেকানন্দ", "title", "স্বামী"), ("স্বামী বিবেকানন্দ", "given", "বিবেকানন্দ"), ("স্বামী বিবেকানন্দ", "family", ""), # শ্রীল is renunciate and folds the same way; unprefixed শ্রী is # civil, so it families the one name word instead. ("শ্রীল প্রভুপাদ", "given", "প্রভুপাদ"), ("শ্রীল প্রভুপাদ", "family", ""), ("শ্রী সেন", "title", "শ্রী"), ("শ্রী সেন", "family", "সেন"), # Vocabulary beats the initial reading: 'ড.' is is_initial-shaped # and the title entry wins, which is the fork #343 asked about. ("ড. মুহাম্মদ ইউনূস", "title", "ড."), ("ড. মুহাম্মদ ইউনূস", "given", "মুহাম্মদ"), ("ড. মুহাম্মদ ইউনূস", "family", "ইউনূস"), # ... and real Bengali initials are untouched, because no entry # matches them. The negative control for the row above. ("র. কে. নারায়ণ", "given", "র."), ("র. কে. নারায়ণ", "middle", "কে."), ("র. কে. নারায়ণ", "family", "নারায়ণ"), # These mirrored Latin md until #853 made a leading Md the given # name (cases.py's audit_md_leading_is_the_given_name); the Bengali # spellings stay titles, no filed data reaching them. Both # spellings of the abbreviation ship: the visarga form মোঃ (the visarga ঃ (U+0983) is a spacing # combining mark, and the lookup fold strips only edge periods and # whitespace, so it reaches the lexicon intact) and the period # form মো., which matches the bare stem মো through the edge-period # normalization. ("মোঃ আবদুল করিম", "title", "মোঃ"), ("মোঃ আবদুল করিম", "given", "আবদুল"), ("মোঃ আবদুল করিম", "family", "করিম"), ("মো. আবদুল করিম", "title", "মো."), ("মো. আবদুল করিম", "given", "আবদুল"), ("মো. আবদুল করিম", "family", "করিম"), # the women's form, period spelling; the visarga form rides the same entry pattern ("মোসা. ফাতেমা বেগম", "title", "মোসা."), ("মোসা. ফাতেমা বেগম", "given", "ফাতেমা"), ("মি. রহমান", "title", "মি."), ("মি. রহমান", "family", "রহমান"), # The exclusion holds: ঠাকুর is Tagore, a surname, and is # deliberately not a title (its `excluded` entry in # nameparser/vocab/bn.toml). # LEADING position is where the exclusion is load-bearing -- # TITLES is leading-only, so the trailing spelling reads the same # whether or not the word ships and cannot guard anything. ("রবীন্দ্রনাথ ঠাকুর", "family", "ঠাকুর"), ("ঠাকুর রবীন্দ্রনাথ", "given", "ঠাকুর"), ("ঠাকুর রবীন্দ্রনাথ", "title", ""), ]) def test_269_nonlatin_vocabulary_parses( name: str, field: str, expected: str) -> None: assert getattr(parse(name), field) == expected def test_269_vocabulary_reaches_the_v1_facade() -> None: # one row per script through HumanName: the facade resolves its # lexicon from the CONSTANTS shim snapshot, a different path from # parse()'s Lexicon.default() -- proves the #269 data modules feed # both surfaces from nameparser import HumanName assert HumanName("г-н Иван Петров").title == "г-н" assert HumanName("κος Γιώργος Παπαδόπουλος").title == "κος" assert HumanName("محمد بن سلمان").last == "بن سلمان" assert HumanName('ד"ר דוד לוי').title == 'ד"ר' # one row per NEW vocabulary category (2026-07-19 batches): bound # given name, Hebrew post-nominal suffix, Devanagari title assert HumanName("عبد الرحمن محمد").first == "عبد الرحمن" assert HumanName('משה כהן ז"ל').suffix == 'ז"ל' assert HumanName("डॉ. राम शर्मा").title == "डॉ." def test_269_bare_greek_kappa_not_a_title() -> None: # Regression for the deferred bare 'κ' entry: were it in TITLES, # _normalize's edge-period strip would make the abbreviated-initial # 'Κ.' match it, degrading the very common initial+surname shape to # title='Κ.' with an EMPTY given. Pin the correct reading. n = parse("Κ. Παπαδόπουλος") assert n.title == "" assert n.given == "Κ." assert n.family == "Παπαδόπουλος" def test_267_dotted_cyrillic_initial_wins_over_conjunction() -> None: # Blast-radius guard for the single-letter Cyrillic conjunctions # (и/і/й): each is also the first letter of real given names # (Игорь, Ірина, Йосип), so the shipped entries would be a trap if # membership could outrank a punctuated initial. It cannot -- the # initial regex's dotted branch is Unicode-aware -- and #267 settled # the BARE letter in the conjunction's favor precisely because # Cyrillic typography writes initials with the period. for dotted, family in (("И.", "Иванов"), ("І.", "Франко"), ("Й.", "Сліпий")): n = parse(f"{dotted} {family}") assert n.given == dotted assert n.family == family �������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_measure.py������������������������������������������0000664�0000000�0000000�00000021062�15273032717�0023453�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Offline checks on tools/differential/measure.py. What these pin is the plan (which steps run for which fetched corpora, in order, each handed --base and --jobs), the summary's refusal to carry an indented line (where the tools list names), and that a failed step fails the run while a step reporting moves does not. The steps themselves are each tool's own tests'. """ import json import sys from pathlib import Path import pytest from ._differential_fixtures import load_tool, own_cache measure = load_tool("measure") corpora = measure.local_corpora @pytest.fixture(autouse=True) def _own_cache(monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory) -> Path: return own_cache(monkeypatch, tmp_path_factory) def _fetch(cache: Path, *sources: str) -> None: """`sources` marked fetched: the plan reads meta.json's presence.""" for source in sources: folder = cache / "local" / source folder.mkdir(parents=True) (folder / corpora.META).write_text(json.dumps({})) def test_the_plan_runs_each_check_for_the_corpora_fetched(_own_cache: Path, tmp_path: Path) -> None: _fetch(_own_cache, "ctgov-officials", corpora.PSC, corpora.JRC, corpora.ORCID, corpora.PV, "tse-donors") steps = measure.plan(tmp_path, 3) assert [s.label for s in steps] == [ "committed snapshot", "CLDR and GND agreement", "local snapshot ctgov-officials", f"local snapshot {corpora.JRC}", f"local snapshot {corpora.ORCID}", f"local snapshot {corpora.PV}", f"local snapshot {corpora.PSC}", "local snapshot tse-donors", f"agreement {corpora.ORCID}", f"agreement {corpora.PV}", f"agreement {corpora.PSC}", f"alike {corpora.JRC}", "alike tse-donors"] for step in steps: # Every step through the wrapper: unwrapped, a refusal reads as moves. assert step.command[:3] == [sys.executable, str(measure.HERE / "measure.py"), "--step"] if step.label.startswith(("local", "agreement", "alike")): assert step.command[-4:] == ["--base", str(tmp_path), "--jobs", "3"] assert step.against == measure.AGAINST_BASE else: assert step.against == measure.AGAINST_RECORD assert steps[0].command[-2:] == ["--jobs", "3"] assert measure.unmeasured() == sorted(set(corpora.SOURCES) - { "ctgov-officials", corpora.PSC, corpora.JRC, corpora.ORCID, corpora.PV, "tse-donors"}) def test_the_plan_with_nothing_fetched_runs_the_committed_checks(tmp_path: Path) -> None: assert [s.label for s in measure.plan(tmp_path, 1)] == [ "committed snapshot", "CLDR and GND agreement"] def test_the_summary_keeps_the_counts_and_drops_the_listed_names() -> None: # snapshot.py's grouped moves: a count flush left, each name indented. output = ("base: /b, nameparser/ abc; reused\n\nfamily, given: 2\n Ann Zyx\n" " given: 'Ann' -> ''\n Bo Qwv\n\n2 entries differ from the base /b\n") assert measure.digest(output) == ["base: /b, nameparser/ abc; reused", "family, given: 2", "2 entries differ from the base /b"] # Past the cap, the head and the tail, where a tool prints its # verdict, and a not-measured line from between. many = [f"line {i}" for i in range(measure.SUMMARY_LINES + 5)] many[6] = "JA ja-jp: not measured (needs namedivider)" kept = measure.digest("\n".join(many)) head = measure.HEAD_LINES assert kept[:head] == many[:head] assert kept[-(measure.SUMMARY_LINES - head):] == many[head - measure.SUMMARY_LINES:] assert kept[head:head + 2] == [many[6], "... 4 more lines"] def test_a_failed_step_fails_the_run_and_one_with_moves_does_not( monkeypatch: pytest.MonkeyPatch, _own_cache: Path, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: base = tmp_path / "base" (base / "nameparser").mkdir(parents=True) (base / "nameparser" / "__init__.py").write_text("") _fetch(_own_cache, "ukec-individuals") codes = {"committed snapshot": 1, "CLDR and GND agreement": 0} def run(step: object) -> object: label = step.label # type: ignore[attr-defined] return measure.Done(step, codes.get(label, 0), 1.0, f"{label} out\n Ann Zyx\n") monkeypatch.setattr(measure, "run", run) out = tmp_path / "out" assert measure.main(["--base", str(base), "--out", str(out)]) == 0 summary = (out / "summary.txt").read_text() assert "Zyx" not in summary assert "none failed" in summary assert "== committed snapshot (against this branch's committed record): moves" in summary assert "not measured, not fetched: ctgov-officials," in summary assert (out / "01-committed-snapshot.txt").read_text().endswith("Ann Zyx\n") codes["CLDR and GND agreement"] = 2 assert measure.main(["--base", str(base), "--out", str(out)]) == 1 assert "failed: CLDR and GND agreement" in capsys.readouterr().out def test_a_step_is_run_from_the_checkout_and_its_output_kept(tmp_path: Path) -> None: step = measure.Step("echo", [sys.executable, "-c", "import os, sys; print(os.getcwd()); sys.exit(1)"]) done = measure.run(step) assert (done.code, done.status) == (1, "moves") assert done.output.strip() == str(measure.HERE.parents[1]) assert measure.Done(step, 3, 0.0, "").status == "FAILED (exit 3)" def test_a_base_that_is_this_checkout_is_refused() -> None: with pytest.raises(SystemExit, match="is the tree being read"): measure.main(["--base", str(measure.HERE.parents[1])]) def _wrapped(tmp_path: Path, body: str) -> object: """A step running a script of `body` through measure.py's --step wrapper, as every planned step runs.""" script = tmp_path / "tool.py" script.write_text(body, encoding="utf-8") return measure.run(measure.Step("tool", [sys.executable, str(measure.HERE / "measure.py"), "--step", str(script), "--flag"])) @pytest.mark.parametrize(("body", "status"), [ ("import sys; sys.exit(0)", "no moves"), ("import sys; sys.exit(1)", "moves"), ("print('no exit')", "no moves"), # A tool's refusal and a crash both exit 1 unwrapped, which reads as # moves; the wrapper is what tells them from a result. ("raise SystemExit('refused: a damaged corpus')", "FAILED (exit 2)"), ("raise ValueError('a bug')", "FAILED (exit 2)"), ("import sys; sys.exit(3)", "FAILED (exit 3)"), ]) def test_a_refusal_or_a_crash_fails_a_step_and_moves_do_not( tmp_path: Path, body: str, status: str) -> None: done = _wrapped(tmp_path, body) assert done.status == status # type: ignore[attr-defined] def test_the_wrapper_hands_the_tool_its_arguments(tmp_path: Path) -> None: done = _wrapped(tmp_path, "import sys; print(sys.argv[1:])") assert done.output.strip() == "['--flag']" # type: ignore[attr-defined] def test_a_name_holding_a_line_break_stays_in_its_indented_line(tmp_path: Path) -> None: # Cut by str.splitlines(), or read in text mode (which turns \r into # \n), the rest of each name would stand flush left in the summary. done = _wrapped(tmp_path, "import sys; sys.stdout.buffer.write(" "'fields: 3\\n Ann\\rZyx\\n Bo\\u2028Qwv\\n Cy\\x85Vex\\n'" ".encode())") kept = measure.digest(done.output) # type: ignore[attr-defined] assert kept == ["fields: 3"] def test_a_run_with_no_local_corpus_fetched_fails_and_says_why( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: # The committed steps compare with the branch's own records, so with # nothing fetched no step read --base: a clean run's look-alike. base = tmp_path / "base" (base / "nameparser").mkdir(parents=True) (base / "nameparser" / "__init__.py").write_text("") monkeypatch.setattr(measure, "run", lambda step: measure.Done(step, 0, 0.0, "")) out = tmp_path / "out" assert measure.main(["--base", str(base), "--out", str(out)]) == 1 summary = (out / "summary.txt").read_text() assert "none failed" in summary and "no step read --base" in summary def test_a_crash_s_message_stays_out_of_the_summary(tmp_path: Path) -> None: # An exception's message may hold a name; only its type is flush left. done = _wrapped(tmp_path, "raise KeyError('Ann Zyx')") assert done.status == "FAILED (exit 2)" # type: ignore[attr-defined] kept = measure.digest(done.output) # type: ignore[attr-defined] assert kept == ["uncaught KeyError; its traceback, indented:"] assert "Ann Zyx" in done.output # type: ignore[attr-defined] ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_name_usage_tool.py����������������������������������0000664�0000000�0000000�00000321205�15273032717�0025155�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Offline checks on tools/name_usage.py, the name-collision lookup. The tool's sources are a downloaded file, a hand-downloaded file and a live SPARQL endpoint, none of which a test may touch -- the autouse guard below turns any network call into a failure. What CAN go wrong without them is the folding and the bookkeeping, and the failure that matters is a FALSE ZERO: a word that misses because it was folded into a different name, or a source that read nothing, reports "not listed", which is the all-clear the tool exists to withhold. These pin those pieces, and each spelling row records what the first version got wrong. tools/ is not a package, so the module is loaded by path, as _differential_fixtures.py loads compare.py. """ import importlib.util import hashlib import http.client import io import json import unicodedata import sys import urllib.error import urllib.parse import urllib.request import zipfile from collections import Counter from collections.abc import Callable from pathlib import Path from types import ModuleType from typing import Any, Protocol import pytest from ._differential_fixtures import own_cache, plant_corpus _PATH = Path(__file__).parents[2] / "tools" / "name_usage.py" def _load() -> ModuleType: spec = importlib.util.spec_from_file_location("name_usage", _PATH) assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) # Registered before it runs: @dataclass looks the module up in # sys.modules to check string annotations for ClassVar/InitVar, and # fails on a module loaded by path that is not in it. sys.modules[spec.name] = module spec.loader.exec_module(module) return module tool = _load() class _NetworkUsed(AssertionError): pass @pytest.fixture(autouse=True) def _no_network(monkeypatch: pytest.MonkeyPatch) -> None: def refuse(*args: object, **kwargs: object) -> None: raise _NetworkUsed("a test reached the network") monkeypatch.setattr(urllib.request, "urlopen", refuse) #: The real loader, for the tests that read a cached dictionary through #: main; every other test sees no dictionary, as an empty cache under #: --no-wikidata would, since an online main would otherwise download it. _LOAD_NAMDICT = tool.load_namdict @pytest.fixture(autouse=True) def _no_namdict(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(tool, "load_namdict", lambda download: None) #: The same for the NPPES tally, which a developer's real cache may hold: #: a test reading it would pass or fail by what is on that machine. _LOAD_NPPES = tool.load_nppes @pytest.fixture(autouse=True) def _no_nppes(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(tool, "load_nppes", lambda download: None) #: And for the local corpora, which a developer's cache may hold too. _LOAD_LOCALS = tool.load_locals @pytest.fixture(autouse=True) def _no_locals(monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory) -> None: # The cache too: a local corpus's word index is written there, and a # test's must never land in the developer's. own_cache(monkeypatch, tmp_path_factory) monkeypatch.setattr(tool, "load_locals", lambda: []) def test_the_network_guard_bites() -> None: # Negative control for the guard: without it, this call goes to # query.wikidata.org. with pytest.raises(_NetworkUsed): tool._sparql("SELECT * WHERE {}") def test_sparql_sends_the_query_in_the_body_not_the_url( monkeypatch: pytest.MonkeyPatch) -> None: # #680: a GET carried the query in the URL, and the endpoint answered # HTTP 431 once it passed about 7 KB -- reached by a seven-letter # Cyrillic word (Младший), each letter six bytes percent-encoded and # repeated once per label language and spelling. sent: list[urllib.request.Request] = [] def capture(request: urllib.request.Request, **kwargs: object) -> io.BytesIO: sent.append(request) return io.BytesIO(b'{"results": {"bindings": []}}') monkeypatch.setattr(urllib.request, "urlopen", capture) query = tool.wikidata_countries_query(tool.spellings("Младший")) assert tool._sparql(query) == {"results": {"bindings": []}} (request,) = sent assert request.get_method() == "POST" assert request.full_url == tool.WIKIDATA_ENDPOINT assert isinstance(request.data, bytes) assert urllib.parse.parse_qs(request.data.decode()) == {"query": [query]} _CENSUS = """name,rank,count,prop100k,cum_prop100k,pctwhite,pctblack,pctapi,pctaian,pct2prace,pcthispanic OBRIEN,258,118557,40.19,0,93.85,1.29,0.76,0.27,1.51,2.32 SHEHU,39212,561,0.19,0,94.47,3.92,(S),0,0.89,(S) SORENSEN,1101,28069,9.5,0,96.6,0.5,0.6,0.5,1.1,0.7 """ @pytest.mark.parametrize(("word", "key"), [ ("O'Brien", "OBRIEN"), # apostrophe dropped, as the Census spells it ("de la Cruz", "DELACRUZ"), # spaces dropped ("Ó Murchú", "OMURCHU"), # diacritics folded away # Letters NFKD leaves whole. The first version deleted them, looking # up a DIFFERENT name: SRENSEN, STRAU, UKASZ, and SIR for Æsir. ("Sørensen", "SORENSEN"), ("Strauß", "STRAUSS"), ("Łukasz", "LUKASZ"), ("Æsir", "AESIR"), ("Đorđević", "DORDEVIC"), # A modifier letter (the ʻokina) is an apostrophe, dropped like # O'Brien's. The second version refused it as a letter with no ASCII # spelling, leaving the name with no answer. ("Kaʻahumanu", "KAAHUMANU"), # No ASCII spelling, wholly or in part: no key at all, never a partial # one (the first version looked up AHMED for the second). ("关", ""), ("Ahmed关", ""), ]) def test_us_key_folds_a_word_the_way_the_us_lists_spell_it(word: str, key: str) -> None: assert tool.us_key(word) == key def test_census_usage_finds_a_folded_surname_and_says_how() -> None: usage = tool.census_usage("Sørensen", tool.read_census(_CENSUS)) assert usage.family == 28069 assert "looked up as SORENSEN" in usage.note def test_census_usage_omits_suppressed_shares() -> None: # (S) marks a share the Census suppressed for privacy; printing it as a # percentage would invent a number. note = tool.census_usage("shehu", tool.read_census(_CENSUS)).note assert "asian/PI" not in note and "hispanic" not in note assert "white 94.47%" in note def test_census_usage_distinguishes_unlisted_from_unrepresentable() -> None: table = tool.read_census(_CENSUS) unlisted = tool.census_usage("Marshal", table) assert (unlisted.status, unlisted.family) == ("ok", 0) unrepresentable = tool.census_usage("关", table) assert unrepresentable.status == "unrepresentable" assert unrepresentable.family is None # no answer, not a zero def test_read_census_names_missing_columns() -> None: with pytest.raises(ValueError, match="pctwhite"): tool.read_census("name,rank,count\nSMITH,1,2442977\n") def _zip(files: dict[str, str]) -> zipfile.ZipFile: buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w") as zf: for name, text in files.items(): zf.writestr(name, text) buffer.seek(0) return zipfile.ZipFile(buffer) def _ssa_files(**extra: str) -> dict[str, str]: files = {f"yob{year}.txt": "Filler,F,5\n" for year in range(1880, 1990)} files.update(extra) return files def test_read_ssa_totals_each_name_by_sex_across_years() -> None: totals = tool.read_ssa(_zip(_ssa_files(**{ "yob1990.txt": "Justice,F,40\nJustice,M,60\n", "yob2000.txt": "Justice,M,100\nMarshal,M,7\n", "NationalReadMe.pdf": "not data", }))) assert totals["justice"] == {"F": 40, "M": 160} assert tool.ssa_usage("Justice", totals).given == 200 def test_read_ssa_reads_a_re_zipped_folder_in_any_case() -> None: files = {f"names/YOB{year}.TXT": "Justice,M,5\n" for year in range(1880, 1990)} assert tool.read_ssa(_zip(files))["justice"] == {"M": 5 * 110} @pytest.mark.parametrize("files", [ {"AK.TXT": "AK,F,1910,Mary,14\n"}, # the state-level zip {f"yob{y}.txt": "A,F,5\n" for y in range(1880, 1900)}, # a partial set ]) def test_read_ssa_refuses_a_zip_that_is_not_the_national_file(files: dict[str, str]) -> None: # The first version read such a zip as empty, so every word reported # "not listed" -- a false zero for every name. with pytest.raises(ValueError, match="yobYYYY.txt"): tool.read_ssa(_zip(files)) def test_ssa_usage_distinguishes_unlisted_from_unrepresentable() -> None: table = {"justice": Counter(M=5)} assert tool.ssa_usage("Zzyzx", table).given == 0 unrepresentable = tool.ssa_usage("关", table) assert unrepresentable.status == "unrepresentable" assert unrepresentable.given is None def test_ssa_usage_reports_a_missing_download_without_a_count() -> None: usage = tool.ssa_usage("justice", None) assert (usage.status, usage.given) == ("not downloaded", None) def test_render_shows_no_answer_and_unlisted_differently_from_a_count() -> None: text = tool.render("x", [ tool.Usage("census", family=0, floor="<100"), tool.Usage("ssa", status="not downloaded"), tool.Usage("wikidata", given=0, family=12, lower_bound=True), ]) rows = {cells[0]: cells[1:] for cells in map(str.split, text.splitlines()[2:]) if cells} assert rows["census"] == ["-", "<100", "-"] assert rows["ssa"] == ["-", "-", "-"] assert rows["wikidata"] == ["0+", "12+", "-"] def test_spellings_cover_lower_and_capitalized_forms_once() -> None: assert tool.spellings("de la cruz") == ["de la cruz", "De La Cruz"] assert tool.spellings("McDonald") == ["McDonald", "mcdonald", "Mcdonald"] def test_wikidata_queries_escape_the_word_in_every_language() -> None: query = tool.wikidata_totals_query(['Say "hi"'], languages=("mul", "zh-hans")) assert r'"Say \"hi\""@mul' in query assert r'"Say \"hi\""@zh-hans' in query def _stub_sparql(monkeypatch: pytest.MonkeyPatch, responses: list[Any]) -> list[str]: queries: list[str] = [] def fake(query: str) -> dict[str, Any]: queries.append(query) response = responses[len(queries) - 1] if isinstance(response, Exception): raise response return {"results": {"bindings": [ {k: {"value": v} for k, v in row.items()} for row in response]}} monkeypatch.setattr(tool, "_sparql", fake) return queries def test_wikidata_usage_sends_every_spelling_in_one_query(monkeypatch: pytest.MonkeyPatch) -> None: # The first version queried each spelling separately and summed the # counts, counting twice a person reached through two spellings. queries = _stub_sparql(monkeypatch, [ [{"role": "family", "n": "180"}, {"role": "given", "n": "1"}], [{"role": "family", "country": "Albania", "n": "11"}, {"role": "family", "n": "148"}], ]) usage = tool.wikidata_usage("shehu", top=5) assert len(queries) == 2 assert '"shehu"@mul' in queries[0] and '"Shehu"@mul' in queries[0] assert (usage.given, usage.family) == (1, 180) assert usage.family_countries == [("Albania", 11)] assert usage.family_stateless == 148 assert "matched as shehu, Shehu" in usage.note @pytest.mark.parametrize("failure", [ OSError("timed out"), json.JSONDecodeError("Expecting value", "<html>", 0), # not an OSError ]) def test_wikidata_usage_reports_a_failed_query_as_no_answer( monkeypatch: pytest.MonkeyPatch, failure: Exception) -> None: _stub_sparql(monkeypatch, [[{"role": "given", "n": "3"}], failure]) usage = tool.wikidata_usage("shehu", top=5) assert usage.status == "failed" assert (usage.given, usage.family) == (None, None) def test_read_wikidata_refuses_a_role_it_cannot_have_produced() -> None: # A misspelled role must not fall through to a count of 0. with pytest.raises(ValueError, match="givn"): tool.read_wikidata([{"role": "givn", "n": "5"}], [], top=5) def test_main_reports_json_offline_without_downloading( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) with zipfile.ZipFile(tmp_path / tool.CENSUS_FILE, "w") as zf: zf.writestr(tool.CENSUS_MEMBER, _CENSUS) status = tool.main(["--no-wikidata", "--json", "O'Brien", "关"]) out, err = capsys.readouterr() report = json.loads(out) assert status == 0 assert list(report) == ["O'Brien", "关"] assert [u["source"] for u in report["关"]] == [ "US Census 2010 surnames", "US SSA given names 1880-", tool.NAMDICT_SOURCE, tool.NPPES_SOURCE] assert report["O'Brien"][0]["family"] == 118557 assert report["关"][0]["family"] is None # null, not 0 assert "SSA given names not downloaded" in err def test_main_does_not_download_under_no_wikidata( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) assert tool.main(["--no-wikidata", "--json", "knight"]) == 0 census = json.loads(capsys.readouterr().out)["knight"][0] assert (census["status"], census["family"]) == ("not downloaded", None) def test_main_exits_2_when_a_source_failed( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) (tmp_path / tool.CENSUS_FILE).write_text("<html>error page</html>") assert tool.main(["--no-wikidata", "knight"]) == 2 assert "delete it and run again" in capsys.readouterr().out def test_main_reports_a_bad_ssa_zip_without_losing_the_other_sources( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: # The second version validated the SSA zip outside any try, so a # state-level zip saved in its place crashed the run and lost the # Census answer too. monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) with zipfile.ZipFile(tmp_path / tool.CENSUS_FILE, "w") as zf: zf.writestr(tool.CENSUS_MEMBER, _CENSUS) with zipfile.ZipFile(tmp_path / tool.SSA_FILE, "w") as zf: zf.writestr("AK.TXT", "AK,F,1910,Mary,14\n") assert tool.main(["--no-wikidata", "--json", "shehu"]) == 2 out, err = capsys.readouterr() census, ssa, *_ = json.loads(out)["shehu"] assert census["family"] == 561 assert (ssa["status"], ssa["given"]) == ("failed", None) assert "not downloaded" not in err # the file exists; it is just wrong # -- the CJK name index (#707) -------------------------------------------- @pytest.mark.parametrize(("word", "cjk"), [ ("关", True), ("씨", True), ("さん", True), ("サン", True), ("佐々木", True), ("Smith", False), ("Sørensen", False), ]) def test_is_cjk_finds_han_kana_and_hangul(word: str, cjk: bool) -> None: assert tool.is_cjk(word) is cjk def test_us_sources_say_they_do_not_apply_to_a_cjk_word() -> None: # A CJK word is not an unlisted name there: the lists hold no # non-Latin spelling at all, and the report says so in those words. census = tool.census_usage("議員", tool.read_census(_CENSUS)) ssa = tool.ssa_usage("議員", {"justice": Counter(M=5)}) for usage in (census, ssa): assert usage.status == "unrepresentable" assert "does not apply to a CJK word" in usage.note def test_labels_query_asks_for_the_batch_by_item_and_filters_to_cjk() -> None: query = tool.labels_query(["Q1", "Q2"]) assert "VALUES ?item { wd:Q1 wd:Q2 }" in query assert "REGEX(STR(?label)" in query assert "wd:Q101352" in tool.ids_query("Q101352") def _listing(*qids: str) -> list[list[dict[str, str]]]: """The count query's answer, then the listing's.""" return [[{"n": str(len(qids))}], [{"item": f"http://www.wikidata.org/entity/{q}"} for q in qids]] def _labels(*pairs: tuple[str, str]) -> list[dict[str, str]]: return [{"item": f"http://www.wikidata.org/entity/{q}", "label": label} for q, label in pairs] def _overrun() -> Exception: # What WDQS sends at its limit: a 200 whose JSON stops mid-stream. return json.JSONDecodeError("Expecting ',' delimiter", "{...", 3) @pytest.fixture def one_class(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: # The listing is fetched as CSV; route it through the _sparql stub so # one response queue serves every query. monkeypatch.setattr(tool, "_sparql_csv", lambda query: tool._bindings(tool._sparql(query))) monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) monkeypatch.setattr(tool, "NAME_CLASSES", {"Q101352": "family"}) monkeypatch.setattr(tool, "INDEX_BATCH", 2) monkeypatch.setattr(tool, "INDEX_MIN_BATCH", 1) def _values(query: str) -> list[str]: return query.split("VALUES ?item { ")[1].split(" }")[0].split() @pytest.mark.usefixtures("one_class") def test_a_class_is_listed_once_and_labelled_by_batch( monkeypatch: pytest.MonkeyPatch) -> None: queries = _stub_sparql(monkeypatch, [ *_listing("Q10", "Q9", "Q300", "Q2", "Q9"), # unsorted, one repeat _labels(("Q2", "向殿")), _labels(("Q10", "井殿")), ]) index = tool.load_index(download=True) assert index is not None # Sorted by number, not as strings, with the repeat dropped. assert [_values(q) for q in queries[2:]] == [ ["wd:Q2", "wd:Q9"], ["wd:Q10", "wd:Q300"]] assert {(e.qid, e.role, e.label) for e in index.entries} == { ("Q2", "family", "向殿"), ("Q10", "family", "井殿")} @pytest.mark.usefixtures("one_class") def test_the_listing_skips_lexemes_and_refuses_anything_else( monkeypatch: pytest.MonkeyPatch) -> None: # Lexemes and their senses carry P31 family name too (L1000723 and # L2873-S1 did, 2026-10-17); no person bears one, and the first two # builds failed on them. queries = _stub_sparql(monkeypatch, [ *_listing("Q1", "L1000723", "L2873-S1", "L5-F2"), _labels()]) assert tool.load_index(download=True) is not None assert _values(queries[2]) == ["wd:Q1"] _stub_sparql(monkeypatch, [[{"n": "1"}], [{"item": "_:b0"}]]) with pytest.raises(ValueError, match="not an item"): tool.load_index(download=True, refresh=True) @pytest.mark.usefixtures("one_class") def test_a_batch_that_overruns_is_halved( monkeypatch: pytest.MonkeyPatch) -> None: queries = _stub_sparql(monkeypatch, [ *_listing("Q1", "Q2"), _overrun(), _labels(("Q1", "向殿")), _labels(), ]) index = tool.load_index(download=True) assert index is not None assert [_values(q) for q in queries[2:]] == [ ["wd:Q1", "wd:Q2"], ["wd:Q1"], ["wd:Q2"]] assert [e.label for e in index.entries] == ["向殿"] @pytest.mark.usefixtures("one_class") def test_a_resumed_build_queries_only_what_is_missing( monkeypatch: pytest.MonkeyPatch) -> None: # An interrupted build keeps its finished batches and its item list, # and a halved batch is not asked whole again -- it would only # overrun a second time. _stub_sparql(monkeypatch, [ *_listing("Q1", "Q2", "Q3", "Q4"), _overrun(), _labels(("Q1", "向殿")), OSError("down")]) with pytest.raises(OSError): tool.load_index(download=True) queries = _stub_sparql(monkeypatch, [ _labels(("Q2", "井殿")), _labels(("Q3", "殿"))]) index = tool.load_index(download=True) assert index is not None assert [_values(q) for q in queries] == [["wd:Q2"], ["wd:Q3", "wd:Q4"]] assert sorted(e.label for e in index.entries) == ["井殿", "向殿", "殿"] @pytest.mark.usefixtures("one_class") def test_a_complete_index_loads_offline_and_an_incomplete_one_does_not( monkeypatch: pytest.MonkeyPatch) -> None: _stub_sparql(monkeypatch, []) # any query fails the test assert tool.load_index(download=False) is None _stub_sparql(monkeypatch, [*_listing("Q1"), _labels(("Q1", "向殿"))]) fetched = tool.load_index(download=True) assert fetched is not None and fetched.fetched _stub_sparql(monkeypatch, []) index = tool.load_index(download=False) assert index is not None and index.entries[0].label == "向殿" @pytest.mark.usefixtures("one_class") def test_refresh_fetches_the_index_again( monkeypatch: pytest.MonkeyPatch) -> None: _stub_sparql(monkeypatch, [*_listing("Q1"), _labels(("Q1", "向殿"))]) tool.load_index(download=True) _stub_sparql(monkeypatch, [*_listing("Q1"), _labels(("Q1", "井殿"))]) index = tool.load_index(download=True, refresh=True) assert index is not None and index.entries[0].label == "井殿" @pytest.mark.usefixtures("one_class") @pytest.mark.parametrize("failure", [ urllib.error.HTTPError("u", 400, "Bad Request", {}, io.BytesIO(b"syntax")), # type: ignore[arg-type] urllib.error.HTTPError("u", 500, "Error", {}, io.BytesIO(b"MalformedQuery")), # type: ignore[arg-type] ]) def test_an_error_that_is_not_an_overrun_is_not_retried_smaller( monkeypatch: pytest.MonkeyPatch, failure: Exception) -> None: queries = _stub_sparql(monkeypatch, [*_listing("Q1", "Q2"), failure]) with pytest.raises(urllib.error.HTTPError): tool.load_index(download=True) assert len(queries) == 3 # count, listing, and the one failure @pytest.mark.usefixtures("one_class") @pytest.mark.parametrize("failure", [ urllib.error.HTTPError("u", 504, "Gateway Timeout", {}, io.BytesIO(b"")), # type: ignore[arg-type] urllib.error.HTTPError("u", 500, "Error", {}, io.BytesIO( # type: ignore[arg-type] b"java.util.concurrent.TimeoutException")), urllib.error.HTTPError("u", 502, "Bad Gateway", {}, io.BytesIO(b"")), # type: ignore[arg-type] http.client.IncompleteRead(b""), TimeoutError("read timed out"), ]) def test_a_timeout_is_an_overrun( monkeypatch: pytest.MonkeyPatch, failure: Exception) -> None: _stub_sparql(monkeypatch, [*_listing("Q1", "Q2"), failure, _labels(), _labels()]) assert tool.load_index(download=True) is not None @pytest.mark.usefixtures("one_class") def test_halving_stops_at_the_smallest_batch( monkeypatch: pytest.MonkeyPatch) -> None: _stub_sparql(monkeypatch, [*_listing("Q1", "Q2"), _overrun(), _overrun()]) with pytest.raises(ValueError, match="items 0-1 still overran"): tool.load_index(download=True) @pytest.mark.usefixtures("one_class") def test_a_listing_cut_short_is_an_error( monkeypatch: pytest.MonkeyPatch) -> None: _stub_sparql(monkeypatch, [[{"n": "5000"}], _listing("Q1", "Q2")[1]]) with pytest.raises(ValueError, match="returned 2 of 5,000 items"): tool.load_index(download=True) def test_sparql_csv_reads_the_rows_by_header( monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(urllib.request, "urlopen", lambda request, timeout: ( io.BytesIO(b"item\r\nhttp://www.wikidata.org/entity/Q1\r\n"))) assert tool._sparql_csv("SELECT") == [ {"item": "http://www.wikidata.org/entity/Q1"}] @pytest.mark.usefixtures("one_class") def test_an_overrun_listing_is_an_error_not_an_empty_class( monkeypatch: pytest.MonkeyPatch) -> None: _stub_sparql(monkeypatch, [_overrun()]) with pytest.raises(ValueError, match="listing wd:Q101352 items overran"): tool.load_index(download=True) class _Index(Protocol): # tools/ is loaded by path, so mypy cannot see NameIndex itself. def ending_in(self, word: str) -> list[Any]: ... def boundaries(self, word: str) -> list[Any]: ... def _index(*entries: tuple[str, str, str]) -> _Index: return tool.NameIndex([tool.IndexEntry(*e) for e in entries], fetched="2026-10-17") def test_ending_in_matches_labels_that_end_in_the_word_or_equal_it() -> None: index = _index(("Q1", "family", "向殿"), ("Q2", "family", "殿"), ("Q3", "family", "殿村"), ("Q4", "given", "二世")) assert {e.qid for e in index.ending_in("殿")} == {"Q1", "Q2"} def test_boundaries_find_a_family_tail_followed_by_a_whole_given_name() -> None: # 阿部 + 長 is how 部長 ends a real name without either part ending # in 部長: the argument #707 makes against peeling it. index = _index(("Q1", "family", "阿部"), ("Q2", "family", "岡部"), ("Q3", "given", "長"), ("Q4", "given", "部長代"), ("Q5", "family", "長")) splits = index.boundaries("部長") assert [(s.family_tail, s.given) for s in splits] == [("部", "長")] assert splits[0].family_items == ["Q1", "Q2"] assert splits[0].given_items == ["Q3"] def test_ends_with_usage_counts_bearers_of_the_matched_items( monkeypatch: pytest.MonkeyPatch) -> None: index = _index(("Q1", "family", "向殿"), ("Q2", "family", "井殿"), ("Q3", "given", "太郎")) queries = _stub_sparql(monkeypatch, [ [{"role": "family", "n": "7"}], [{"role": "family", "country": "Japan", "n": "6"}, {"role": "family", "n": "1"}], ]) usage = tool.ends_with_usage("殿", index, top=5) assert "VALUES ?item { wd:Q1 wd:Q2 }" in queries[0] assert "rdfs:label" not in queries[0].split("UNION")[0] assert (usage.given, usage.family) == (0, 7) assert usage.family_countries == [("Japan", 6)] assert "2 name items end in it: 井殿 Q2, 向殿 Q1" in usage.note def test_ends_with_usage_names_a_single_item_in_the_singular( monkeypatch: pytest.MonkeyPatch) -> None: _stub_sparql(monkeypatch, [[{"role": "given", "n": "109"}], []]) usage = tool.ends_with_usage("二世", _index(("Q18339792", "given", "厄德二世")), top=5) assert "1 name item ends in it: 厄德二世 Q18339792" in usage.note @pytest.mark.usefixtures("one_class") def test_loading_a_cached_index_prints_no_progress( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: monkeypatch.setattr(tool, "INDEX_BATCH", 1) _stub_sparql(monkeypatch, [*_listing(*(f"Q{n}" for n in range(1, 51))), *([_labels()] * 50)]) tool.load_index(download=True) assert "50 of 50 items" in capsys.readouterr().err _stub_sparql(monkeypatch, []) tool.load_index(download=True) assert capsys.readouterr().err == "" def test_ends_with_usage_with_no_matching_item_sends_no_query( monkeypatch: pytest.MonkeyPatch) -> None: queries = _stub_sparql(monkeypatch, []) usage = tool.ends_with_usage("議員", _index(("Q1", "family", "向殿")), top=5) assert queries == [] assert (usage.status, usage.given, usage.family) == ("ok", 0, 0) assert "no indexed name item ends in 議員" in usage.note def test_ends_with_usage_sums_chunks_and_says_it_may_double_count( monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(tool, "ITEM_CHUNK", 1) index = _index(("Q1", "family", "向殿"), ("Q2", "family", "井殿")) _stub_sparql(monkeypatch, [ [{"role": "family", "n": "3"}], [{"role": "family", "n": "3"}], [{"role": "family", "n": "4"}], [{"role": "family", "n": "4"}], ]) usage = tool.ends_with_usage("殿", index, top=5) assert usage.family == 7 assert "may count twice" in usage.note def test_ends_with_usage_counts_people_across_a_boundary( monkeypatch: pytest.MonkeyPatch) -> None: index = _index(("Q1", "family", "阿部"), ("Q3", "given", "長")) queries = _stub_sparql(monkeypatch, [_people("Q99", "Q7")]) usage = tool.ends_with_usage("部長", index, top=5) assert (usage.given, usage.family) == (0, 0) # nothing ENDS in 部長 assert "VALUES ?f { wd:Q1 }" in queries[0] assert "VALUES ?g { wd:Q3 }" in queries[0] [split] = usage.splits assert (split.people, split.example) == (2, "Q7") text = tool.render("部長", [usage]) assert "family ending 部 + given 長: 2+ people" in text assert "e.g. 阿部; bearer Q7" in text def test_sparql_waits_out_a_rate_limit_and_retries( monkeypatch: pytest.MonkeyPatch) -> None: waits: list[float] = [] calls: list[int] = [] def answer(request: urllib.request.Request, timeout: float) -> io.BytesIO: calls.append(1) if len(calls) == 1: raise urllib.error.HTTPError( "u", 429, "Too Many Requests", {"Retry-After": "7"}, io.BytesIO(b"")) # type: ignore[arg-type] return io.BytesIO(b'{"results": {"bindings": []}}') monkeypatch.setattr(urllib.request, "urlopen", answer) monkeypatch.setattr(tool, "_sleep", waits.append) assert tool._sparql("SELECT") == {"results": {"bindings": []}} assert waits == [7] def test_main_skips_the_index_for_a_latin_word( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) monkeypatch.setattr(tool, "load_census", lambda download: None) monkeypatch.setattr(tool, "load_index", lambda download, refresh=False: ( pytest.fail("the index was loaded for a Latin word"))) _stub_sparql(monkeypatch, [[], []]) assert tool.main(["--json", "knight"]) == 0 sources = [u["source"] for u in json.loads(capsys.readouterr().out)["knight"]] assert sources[-1] == "Wikidata people" @pytest.mark.parametrize("local", ["none", "not fetched", "fetched"]) def test_main_adds_the_ends_with_row_for_a_cjk_word( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str], local: str) -> None: # With a local corpus listed, fetched or not: the local rows' loop # once shadowed main's CJK index, so the row read None or a WordIndex. monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) monkeypatch.setattr(tool, "load_census", lambda download: None) corpus = _local("Ann Lee, MD") locals_ = {"none": [], "not fetched": [("ctgov-officials", None, "")], "fetched": [("ctgov-officials", corpus, "")]} monkeypatch.setattr(tool, "load_locals", lambda: locals_[local]) monkeypatch.setattr(tool, "load_index", lambda download, refresh=False: _index(("Q1", "family", "向殿"))) _stub_sparql(monkeypatch, [[], [], [{"role": "family", "n": "1"}], []]) assert tool.main(["--json", "殿"]) == 0 rows = json.loads(capsys.readouterr().out)["殿"] assert rows[-1]["source"] == tool.ENDS_WITH_SOURCE assert rows[-1]["family"] == 1 def test_main_reports_an_index_that_failed_to_load( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) monkeypatch.setattr(tool, "load_census", lambda download: None) def broken(download: bool, refresh: bool = False) -> None: raise OSError("down") monkeypatch.setattr(tool, "load_index", broken) _stub_sparql(monkeypatch, [[], []]) assert tool.main(["--json", "殿"]) == 2 row = json.loads(capsys.readouterr().out)["殿"][-1] assert (row["status"], row["family"]) == ("failed", None) # -- review of #712: the index's failure paths ------------------------------ def _people(*qids: str) -> list[dict[str, str]]: return [{"p": f"http://www.wikidata.org/entity/{q}"} for q in qids] @pytest.mark.usefixtures("one_class") def test_each_class_keeps_its_own_role_and_batches( monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(tool, "NAME_CLASSES", {"Q101352": "family", "Q202444": "given"}) queries = _stub_sparql(monkeypatch, [ *_listing("Q1"), _labels(("Q1", "向殿")), *_listing("Q2"), _labels(("Q2", "太郎")), ]) index = tool.load_index(download=True) assert index is not None assert {(e.qid, e.role) for e in index.entries} == { ("Q1", "family"), ("Q2", "given")} assert [_values(q) for q in queries if "rdfs:label" in q] == [ ["wd:Q1"], ["wd:Q2"]] @pytest.mark.usefixtures("one_class") def test_a_class_that_lists_no_items_is_an_error_not_an_empty_index( monkeypatch: pytest.MonkeyPatch) -> None: # A mistyped or merged class answers 0 to both queries; caching that # would make every later ends-with row a silent zero. _stub_sparql(monkeypatch, [*_listing()]) with pytest.raises(ValueError, match="wd:Q101352 lists no items"): tool.load_index(download=True) assert not (tool.cache_dir() / tool.INDEX_DIR / "Q101352-ids.json").exists() @pytest.mark.usefixtures("one_class") @pytest.mark.parametrize(("rows", "loads"), [(999, True), (998, False)]) def test_the_listing_count_allows_a_thousandth_of_slack( monkeypatch: pytest.MonkeyPatch, rows: int, loads: bool) -> None: monkeypatch.setattr(tool, "INDEX_BATCH", 5000) qids = [f"Q{n}" for n in range(1, rows + 1)] _stub_sparql(monkeypatch, [[{"n": "1000"}], _listing(*qids)[1], _labels()]) if loads: assert tool.load_index(download=True) is not None else: with pytest.raises(ValueError, match="returned 998 of 1,000"): tool.load_index(download=True) @pytest.mark.usefixtures("one_class") @pytest.mark.parametrize(("name", "content"), [ ("Q101352-ids.json", "["), ("Q101352-ids.json", "{}"), ("Q101352-ids.json", '["Q1", "L5"]'), ("Q101352-ids.json", "[]"), ("Q101352-0-1.json", '{"rows": []}'), ("Q101352-0-1.json", '[]'), ("Q101352-0-1.json", '{"fetched": "x", "digest": "DIGEST", "rows": null}'), ("Q101352-0-1.json", '{"fetched": "x", "digest": "DIGEST", "rows": [["Q9", "殿"]]}'), ("Q101352-0-1.json", '{"fetched": "x", "digest": "DIGEST", "rows": [["Q1"]]}'), ("Q101352-0-1.json", '{"fetched": "x", "digest": "DIGEST", "rows": [["Q1", 5]]}'), ("version.json", "{"), ]) def test_an_unusable_cache_file_is_named_with_its_remedy( monkeypatch: pytest.MonkeyPatch, name: str, content: str) -> None: _stub_sparql(monkeypatch, [*_listing("Q1"), _labels(("Q1", "向殿"))]) tool.load_index(download=True) # DIGEST stands for the batch's own, so the row checks are reached. (tool.cache_dir() / tool.INDEX_DIR / name).write_text( content.replace("DIGEST", tool._batch_digest(["Q1"]))) _stub_sparql(monkeypatch, []) with pytest.raises(ValueError, match=f"{name} is unusable.*--refresh-index"): tool.load_index(download=True) @pytest.mark.usefixtures("one_class") def test_a_batch_cached_against_another_listing_is_fetched_again( monkeypatch: pytest.MonkeyPatch) -> None: # Deleting one class's listing must not leave its batches answering # for whatever items now sit at the same positions. _stub_sparql(monkeypatch, [*_listing("Q1", "Q2"), _labels(("Q1", "向殿"))]) tool.load_index(download=True) (tool.cache_dir() / tool.INDEX_DIR / "Q101352-ids.json").unlink() queries = _stub_sparql(monkeypatch, [ *_listing("Q10", "Q11"), _labels(("Q11", "井殿"))]) index = tool.load_index(download=True) assert index is not None assert [(e.qid, e.label) for e in index.entries] == [("Q11", "井殿")] assert _values(queries[2]) == ["wd:Q10", "wd:Q11"] @pytest.mark.usefixtures("one_class") def test_an_index_from_an_older_version_of_the_tool_is_rebuilt( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: _stub_sparql(monkeypatch, [*_listing("Q1"), _labels(("Q1", "向殿"))]) tool.load_index(download=True) (tool.cache_dir() / tool.INDEX_DIR / "version.json").write_text("1") _stub_sparql(monkeypatch, []) assert tool.load_index(download=False) is None capsys.readouterr() _stub_sparql(monkeypatch, [*_listing("Q1"), _labels(("Q1", "井殿"))]) index = tool.load_index(download=True) assert index is not None and index.entries[0].label == "井殿" assert "older version" in capsys.readouterr().err def test_sparql_csv_refuses_a_body_cut_mid_line( monkeypatch: pytest.MonkeyPatch) -> None: # A last line cut inside its IRI (Q1234567 arriving as Q12) is still # a valid Q-id; the missing newline is the only sign. monkeypatch.setattr(urllib.request, "urlopen", lambda request, timeout: ( io.BytesIO(b"item\r\nhttp://www.wikidata.org/entity/Q12"))) with pytest.raises(ValueError, match="cut short"): tool._sparql_csv("SELECT") def test_sparql_csv_asks_for_csv(monkeypatch: pytest.MonkeyPatch) -> None: seen: list[str | None] = [] def answer(request: urllib.request.Request, timeout: float) -> io.BytesIO: seen.append(request.get_header("Accept")) return io.BytesIO(b"item\r\n") monkeypatch.setattr(urllib.request, "urlopen", answer) tool._sparql_csv("SELECT") assert seen == ["text/csv"] @pytest.mark.parametrize(("word", "cjk"), [ ("𠮷田", True), # supplementary-plane Han (Extension B) ("〇", True), ("䷀", False), # a Yijing hexagram, inside the old range ]) def test_is_cjk_covers_the_supplementary_han_planes(word: str, cjk: bool) -> None: assert tool.is_cjk(word) is cjk def test_the_index_query_and_is_cjk_share_one_character_class() -> None: assert f"[{tool._CJK_CHARS}]" in tool.labels_query(["Q1"]) def test_boundaries_try_every_cut_and_match_the_given_name_whole() -> None: index = _index(("Q1", "family", "某国会"), ("Q2", "given", "議員"), ("Q3", "given", "信議員"), ("Q4", "given", "某国"), ("Q5", "given", "国会議員")) splits = index.boundaries("国会議員") # The only split is at the second cut, and 信議員 ends in the tail # without being it; 某国 is a GIVEN name ending in the head, which is # no family name; and a word equal to a whole given name is no split. assert [(s.family_tail, s.given, s.family_items, s.given_items) for s in splits] == [("国会", "議員", ["Q1"], ["Q2"])] def test_a_failed_bearer_count_fails_the_row( monkeypatch: pytest.MonkeyPatch) -> None: _stub_sparql(monkeypatch, [OSError("down")]) usage = tool.ends_with_usage("殿", _index(("Q1", "family", "向殿")), top=5) assert (usage.source, usage.status) == (tool.ENDS_WITH_SOURCE, "failed") assert (usage.given, usage.family) == (None, None) def test_a_failed_boundary_count_keeps_the_rows_other_counts( monkeypatch: pytest.MonkeyPatch) -> None: index = _index(("Q1", "family", "阿部"), ("Q2", "given", "長"), ("Q3", "family", "山部長")) _stub_sparql(monkeypatch, [[{"role": "family", "n": "7"}], [], OSError("timed out")]) usage = tool.ends_with_usage("部長", index, top=5) assert (usage.status, usage.family) == ("ok", 7) [split] = usage.splits assert split.people is None and "timed out" in split.error assert "family ending 部 + given 長: - people (query failed" in ( tool.render("部長", [usage])) def test_a_boundary_count_is_chunked_and_counts_a_person_once( monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(tool, "ITEM_CHUNK", 1) index = _index(("Q1", "family", "阿部"), ("Q2", "family", "岡部"), ("Q3", "given", "長")) # The larger answer first: a count that kept only the last chunk's # people would read 1. queries = _stub_sparql(monkeypatch, [_people("Q9", "Q8"), _people("Q9")]) usage = tool.ends_with_usage("部長", index, top=5) assert len(queries) == 2 assert usage.splits[0].people == 2 def test_an_empty_boundary_answer_is_zero_people( monkeypatch: pytest.MonkeyPatch) -> None: _stub_sparql(monkeypatch, [[]]) usage = tool.ends_with_usage( "部長", _index(("Q1", "family", "阿部"), ("Q3", "given", "長")), top=5) assert (usage.splits[0].people, usage.splits[0].example) == (0, "") def test_the_ends_with_note_gives_the_index_size( monkeypatch: pytest.MonkeyPatch) -> None: _stub_sparql(monkeypatch, []) usage = tool.ends_with_usage("議員", _index(("Q1", "family", "向殿")), top=5) assert "index of 1 entries" in usage.note def test_chunked_country_counts_are_summed( monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(tool, "ITEM_CHUNK", 1) index = _index(("Q1", "family", "向殿"), ("Q2", "family", "井殿")) _stub_sparql(monkeypatch, [ [{"role": "family", "n": "3"}], [{"role": "family", "country": "Japan", "n": "3"}], [{"role": "family", "n": "4"}], [{"role": "family", "country": "Japan", "n": "4"}], ]) usage = tool.ends_with_usage("殿", index, top=5) assert usage.family_countries == [("Japan", 7)] def _http_error(code: int, retry_after: str = "") -> urllib.error.HTTPError: headers = {"Retry-After": retry_after} if retry_after else {} return urllib.error.HTTPError("u", code, "x", headers, io.BytesIO(b"")) # type: ignore[arg-type] def test_sparql_raises_a_non_rate_limit_error_without_waiting( monkeypatch: pytest.MonkeyPatch) -> None: calls: list[int] = [] waits: list[float] = [] def answer(request: urllib.request.Request, timeout: float) -> io.BytesIO: calls.append(1) raise _http_error(500) monkeypatch.setattr(urllib.request, "urlopen", answer) monkeypatch.setattr(tool, "_sleep", waits.append) with pytest.raises(urllib.error.HTTPError) as caught: tool._sparql("SELECT") caught.value.close() # 3.14 warns on an HTTPError left open assert (len(calls), waits) == (1, []) def test_sparql_gives_up_after_its_retries_with_the_http_error( monkeypatch: pytest.MonkeyPatch) -> None: waits: list[float] = [] def answer(request: urllib.request.Request, timeout: float) -> io.BytesIO: raise _http_error(429, "Fri, 17 Oct 2026 18:00:00 GMT") monkeypatch.setattr(urllib.request, "urlopen", answer) monkeypatch.setattr(tool, "_sleep", waits.append) with pytest.raises(urllib.error.HTTPError) as caught: tool._sparql("SELECT") caught.value.close() # A date-form Retry-After waits the default minute. assert waits == [60] * tool.SPARQL_RETRIES @pytest.mark.parametrize(("argv", "refresh"), [ (["--json", "殿"], False), (["--json", "--refresh-index", "殿"], True), (["--json", "--refresh-index", "knight"], True), ]) def test_main_passes_refresh_to_the_index( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, argv: list[str], refresh: bool) -> None: monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) monkeypatch.setattr(tool, "load_census", lambda download: None) seen: list[bool] = [] def load(download: bool, refresh: bool = False) -> _Index: seen.append(refresh) return _index(("Q1", "family", "x")) monkeypatch.setattr(tool, "load_index", load) monkeypatch.setattr(tool, "_sparql", lambda query: {"results": {"bindings": []}}) tool.main(argv) assert seen == [refresh] def test_main_reads_a_decomposed_word_as_its_composed_twin( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: # Wikidata labels are NFC; a decomposed hangul word is conjoining # jamo, which neither is_cjk nor any label would match. monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) monkeypatch.setattr(tool, "load_census", lambda download: None) monkeypatch.setattr(tool, "load_index", lambda download, refresh=False: _index(("Q1", "given", "민준씨"))) monkeypatch.setattr(tool, "_sparql", lambda query: {"results": {"bindings": []}}) tool.main(["--json", unicodedata.normalize("NFD", "씨")]) [rows] = json.loads(capsys.readouterr().out).values() assert rows[-1]["source"] == tool.ENDS_WITH_SOURCE def test_main_exits_2_when_a_boundary_count_failed( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) monkeypatch.setattr(tool, "load_census", lambda download: None) monkeypatch.setattr(tool, "load_index", lambda download, refresh=False: _index(("Q1", "family", "阿部"), ("Q2", "given", "長"))) _stub_sparql(monkeypatch, [[], [], OSError("timed out")]) assert tool.main(["--json", "部長"]) == 2 def test_main_reports_an_unusable_cache_without_losing_the_other_sources( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) monkeypatch.setattr(tool, "NAME_CLASSES", {"Q101352": "family"}) with zipfile.ZipFile(tmp_path / tool.CENSUS_FILE, "w") as zf: zf.writestr(tool.CENSUS_MEMBER, _CENSUS) index_dir = tmp_path / tool.INDEX_DIR index_dir.mkdir() (index_dir / "version.json").write_text(str(tool.INDEX_VERSION)) (index_dir / "Q101352-ids.json").write_text("{") _stub_sparql(monkeypatch, [[], []]) assert tool.main(["--json", "殿"]) == 2 rows = json.loads(capsys.readouterr().out)["殿"] assert rows[-1]["status"] == "failed" and "unusable" in rows[-1]["note"] # -- second review of #712 --------------------------------------------------- @pytest.mark.usefixtures("one_class") @pytest.mark.parametrize("relisted", [ ("Q1", "Q3"), # the first item kept ("Q0", "Q2"), # the last item kept ]) def test_a_batch_whose_items_changed_at_either_end_is_fetched_again( monkeypatch: pytest.MonkeyPatch, relisted: tuple[str, str]) -> None: _stub_sparql(monkeypatch, [*_listing("Q1", "Q2"), _labels(("Q1", "向殿"))]) tool.load_index(download=True) (tool.cache_dir() / tool.INDEX_DIR / "Q101352-ids.json").unlink() queries = _stub_sparql(monkeypatch, [*_listing(*relisted), _labels()]) tool.load_index(download=True) assert _values(queries[2]) == [f"wd:{q}" for q in relisted] @pytest.mark.usefixtures("one_class") def test_a_batch_whose_middle_item_changed_is_fetched_again( monkeypatch: pytest.MonkeyPatch) -> None: # Same first and last items, another in between: the batch's whole # membership is what its file is checked against. monkeypatch.setattr(tool, "INDEX_BATCH", 3) _stub_sparql(monkeypatch, [*_listing("Q1", "Q2", "Q4"), _labels(("Q2", "向殿"))]) tool.load_index(download=True) (tool.cache_dir() / tool.INDEX_DIR / "Q101352-ids.json").unlink() queries = _stub_sparql(monkeypatch, [*_listing("Q1", "Q3", "Q4"), _labels(("Q3", "井殿"))]) index = tool.load_index(download=True) assert index is not None assert [e.label for e in index.entries] == ["井殿"] assert _values(queries[2]) == ["wd:Q1", "wd:Q3", "wd:Q4"] @pytest.mark.usefixtures("one_class") def test_a_batch_halved_under_a_new_listing_leaves_no_stale_whole( monkeypatch: pytest.MonkeyPatch) -> None: # Listing A fetched the batch whole; listing B overran it and cached # its halves. The whole batch's old file must not shadow the halves, # or the complete index reads as missing offline. _stub_sparql(monkeypatch, [*_listing("Q1", "Q2"), _labels(("Q1", "向殿"))]) tool.load_index(download=True) (tool.cache_dir() / tool.INDEX_DIR / "Q101352-ids.json").unlink() _stub_sparql(monkeypatch, [*_listing("Q1", "Q3"), _overrun(), _labels(("Q1", "向殿")), _labels(("Q3", "井殿"))]) tool.load_index(download=True) _stub_sparql(monkeypatch, []) # any query fails the test offline = tool.load_index(download=False) assert offline is not None assert sorted(e.label for e in offline.entries) == ["井殿", "向殿"] online = tool.load_index(download=True) assert online is not None and len(online.entries) == 2 @pytest.mark.usefixtures("one_class") def test_an_index_built_before_versioning_is_rebuilt( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: # The first index (2026-10-17) had no version file at all, and its # character class missed supplementary-plane Han. _stub_sparql(monkeypatch, [*_listing("Q1"), _labels(("Q1", "向殿"))]) tool.load_index(download=True) (tool.cache_dir() / tool.INDEX_DIR / "version.json").unlink() _stub_sparql(monkeypatch, []) assert tool.load_index(download=False) is None capsys.readouterr() _stub_sparql(monkeypatch, [*_listing("Q1"), _labels(("Q1", "𠮷田"))]) index = tool.load_index(download=True) assert index is not None and index.entries[0].label == "𠮷田" assert "older version" in capsys.readouterr().err @pytest.mark.usefixtures("one_class") def test_a_listing_without_an_item_column_is_an_error( monkeypatch: pytest.MonkeyPatch) -> None: _stub_sparql(monkeypatch, [[{"n": "1"}], [{"x": "y"}]]) with pytest.raises(ValueError, match="not item"): tool.load_index(download=True) @pytest.mark.parametrize(("word", "cjk"), [ ("\U00031350", True), # Extension H ("\U0002f800", True), # the supplementary compatibility block ]) def test_is_cjk_covers_extension_h(word: str, cjk: bool) -> None: assert tool.is_cjk(word) is cjk def _closable(code: int, body: bytes = b"") -> tuple[urllib.error.HTTPError, io.BytesIO]: fp = io.BytesIO(body) return urllib.error.HTTPError("u", code, "x", {}, fp), fp # type: ignore[arg-type] @pytest.mark.usefixtures("one_class") @pytest.mark.parametrize(("code", "body"), [ (504, b""), (500, b"java.util.concurrent.TimeoutException"), (400, b"syntax")]) def test_the_index_closes_each_http_error_it_reads( monkeypatch: pytest.MonkeyPatch, code: int, body: bytes) -> None: # Since 3.14 an HTTPError left open warns when collected. error, fp = _closable(code, body) _stub_sparql(monkeypatch, [*_listing("Q1", "Q2"), error, _labels(), _labels()]) try: tool.load_index(download=True) except urllib.error.HTTPError: pass assert fp.closed def test_a_failed_wikidata_row_closes_its_http_error( monkeypatch: pytest.MonkeyPatch) -> None: error, fp = _closable(400) _stub_sparql(monkeypatch, [error]) assert tool.wikidata_usage("shehu", top=5).status == "failed" assert fp.closed def test_a_failed_ends_with_row_and_pairing_close_their_http_errors( monkeypatch: pytest.MonkeyPatch) -> None: first, first_fp = _closable(400) _stub_sparql(monkeypatch, [first]) tool.ends_with_usage("殿", _index(("Q1", "family", "向殿")), top=5) second, second_fp = _closable(400) _stub_sparql(monkeypatch, [second]) tool.ends_with_usage( "部長", _index(("Q1", "family", "阿部"), ("Q3", "given", "長")), top=5) assert first_fp.closed and second_fp.closed def test_main_reports_a_refresh_that_failed_for_a_latin_word( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: # A Latin word has no ends-with row to carry the failure, and the # refresh has already deleted the old index. monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) monkeypatch.setattr(tool, "load_census", lambda download: None) def broken(download: bool, refresh: bool = False) -> None: raise OSError("down") monkeypatch.setattr(tool, "load_index", broken) _stub_sparql(monkeypatch, [[], []]) assert tool.main(["--refresh-index", "knight"]) == 2 assert "CJK name index: OSError: down" in capsys.readouterr().err # -- nam_dict given names ---------------------------------------------------- # # A dictionary built here in the file's own layout: the 55-country legend, # each label above a line holding only a bar in its column, then # fixed-width lines. The real file is GFDL and is never in the tree. _COUNTRIES = ([f"Country {i}" for i in range(52)] + ["Korea", "Vietnam", "other countries"]) def _legend_lines(columns: list[int]) -> list[str]: lines = [] for country, column in zip(_COUNTRIES, columns): lines.append(f"#{country:>{column}} $") lines.append("#" + " " * (column - 1) + "|" + " " * (86 - column - 1) + "$") return lines def _line(code: str, name: str, bands: dict[str, str], flag: str = " ") -> str: digits = "".join(bands.get(c, " ") for c in _COUNTRIES) return f"{code:<2} {name:<26}{flag}{digits} $" def _namdict_text(*lines: str, columns: list[int] | None = None) -> str: header = ["# nam_dict.txt", "#"] + _legend_lines( columns or list(range(30, 85))) + ["#"] return "\r\n".join(header + list(lines)) + "\r\n" _DICT = _namdict_text( _line("?", "In", {"Korea": "A"}), _line("M", "Dean", {"Country 0": "5", "Country 2": "5"}), _line("?", "Dean", {"Country 2": "6", "Country 30": "1"}), # Joined, De+An spells Dean too; its lower Country 2 band comes after # Dean's 6, so a last-wins merge would show. _line("?", "De+An", {"Country 2": "2", "Country 50": "2"}), _line("?", "Jun+Wei", {"Country 50": "3"}), _line("M", "İlker", {"Country 47": "5"}), _line("F", "Işıl", {"Country 47": "6"}), _line("M", "Isa", {"Country 46": "4"}), _line("M", "İsa", {"Country 47": "7"}), _line("M", "Kasim", {"Country 46": "3"}), _line("M", "Kasım", {"Country 47": "5"}), # Tied bands, the later line naming the earlier legend country. _line("M", "Ali", {"Country 5": "3"}), _line("?", "Ali", {"Country 1": "3"}), # An expanded-umlaut copy: skipped, so its band never shows. _line("F", "Käte", {"Country 11": "4"}, flag="-"), _line("F", "Käte", {"Country 11": "D"}, flag="+"), _line("=", "Al Albert", {"Country 0": "1"}), ) _DICT_SHA256 = hashlib.sha256(_DICT.encode()).hexdigest() def test_read_namdict_reads_the_countries_from_the_legend() -> None: table = tool.read_namdict(_DICT) assert table.countries == _COUNTRIES assert table.names["in"] == [("In", {"Korea": 10})] def test_read_namdict_keeps_every_listing_line_of_a_name() -> None: table = tool.read_namdict(_DICT) assert table.names["dean"] == [ ("Dean", {"Country 0": 5, "Country 2": 5}), ("Dean", {"Country 2": 6, "Country 30": 1}), ("De+An", {"Country 2": 2, "Country 50": 2}), ] def test_read_namdict_skips_the_expanded_umlaut_copies() -> None: assert tool.read_namdict(_DICT).names["käte"] == [("Käte", {"Country 11": 4})] def test_read_namdict_reads_equivalences_as_short_forms_not_names() -> None: table = tool.read_namdict(_DICT) assert "al" not in table.names and "al albert" not in table.names assert table.short_for["al"] == ["Albert"] @pytest.mark.parametrize(("name", "forms"), [ ("Dean", ["Dean"]), ("Jun+Wei", ["Jun-Wei", "Jun Wei", "Junwei"]), ("Maria+da+Glória", [ "Maria-da-Glória", "Maria-da Glória", "Maria-daglória", "Maria da-Glória", "Maria da Glória", "Maria daglória", "Mariada-Glória", "Mariada Glória", "Mariadaglória"]), ]) def test_expand_plus_reads_a_plus_as_hyphen_space_or_nothing( name: str, forms: list[str]) -> None: assert tool.expand_plus(name) == forms def test_read_namdict_files_every_expansion_of_a_plus_name() -> None: table = tool.read_namdict(_DICT) # The listed spelling too, so a name copied from the file finds itself. for form in ("jun+wei", "jun-wei", "jun wei", "junwei"): assert table.names[form] == [("Jun+Wei", {"Country 50": 3})] @pytest.mark.parametrize("columns", [ list(range(31, 86)), # shifted a column list(range(30, 84)) + [86], # a gap before the last country ]) def test_read_namdict_refuses_a_legend_it_was_not_written_for( columns: list[int]) -> None: with pytest.raises(ValueError, match="legend marks columns"): tool.read_namdict(_namdict_text(_line("?", "In", {}), columns=columns)) @pytest.mark.parametrize("line", [ "? In A", # short " " + _line("?", "In", {"Korea": "A"})[:-3] + " $", # shifted, same length _line("?", "In", {"Korea": "A"})[:28] + "x" + _line("?", "In", {})[29:], _line("?", "In", {"Korea": "A"})[:-2] + " ", # no " $" end ]) def test_read_namdict_refuses_a_line_that_is_not_fixed_width(line: str) -> None: # A shifted line keeps its length: read as written, In's Korea band # would land under Vietnam, filed under " in". with pytest.raises(ValueError, match="line .* is not fixed-width"): tool.read_namdict(_namdict_text(line)) @pytest.mark.parametrize("pair", ["Al", "Al Albert Bert"]) def test_read_namdict_names_the_line_of_a_malformed_equivalence(pair: str) -> None: with pytest.raises(ValueError, match=r"line \d+ is not '= short long'"): tool.read_namdict(_namdict_text(_line("=", pair, {}))) def test_namdict_usage_reports_bands_highest_first_never_as_a_count() -> None: usage = tool.namdict_usage("Dean", tool.read_namdict(_DICT), top=5) assert usage.given is None and usage.lower_bound # Peak per country across the M and ? lines, then legend order. assert usage.given_bands == [("Country 2", 6), ("Country 0", 5), ("Country 50", 2), ("Country 30", 1)] assert "banded in 4 countries" in usage.note assert "listed as Dean, De+An" in usage.note def test_namdict_usage_breaks_a_tie_in_legend_order() -> None: usage = tool.namdict_usage("Ali", tool.read_namdict(_DICT), top=5) assert usage.given_bands == [("Country 1", 3), ("Country 5", 3)] @pytest.mark.parametrize(("word", "bands"), [ # casefold() turns İ into i + U+0307, so a plain fold missed every # name listed only under İ (24 in the real file, measured 2026-10-19). ("Ilker", [("Country 47", 5)]), ("ilker", [("Country 47", 5)]), ("ILKER", [("Country 47", 5)]), ("İlker", [("Country 47", 5)]), # as listed: the plain fold had it # Turkish lowers I to dotless ı, so a Turkish speaker's lowercase and # capitals of Işıl miss under the default fold, which lowers I to i. ("ışıl", [("Country 47", 6)]), ("IŞIL", [("Country 47", 6)]), ("Işıl", [("Country 47", 6)]), # as listed: the plain fold had it ("işıl", [("Country 47", 6)]), # the default fold of Işıl: had it too # Isa and İsa are one name with two capitals, so both answer both. ("Isa", [("Country 47", 7), ("Country 46", 4)]), ("İsa", [("Country 47", 7), ("Country 46", 4)]), # ...but dotless ı is a letter, not a case: Kasim is not Kasım, # though KASIM, written in capitals, may be either. ("Kasim", [("Country 46", 3)]), ("Kasım", [("Country 47", 5)]), ("KASIM", [("Country 47", 5), ("Country 46", 3)]), ]) def test_namdict_usage_matches_turkish_capitals_in_both_folds( word: str, bands: list[tuple[str, int]]) -> None: usage = tool.namdict_usage(word, tool.read_namdict(_DICT), top=5) assert usage.given_bands == bands @pytest.mark.parametrize("word", ["Isil", "isil", "ISIL", "Işil"]) def test_namdict_usage_does_not_fold_a_dotless_i_into_a_dotted_one( word: str) -> None: # The Turkish fold must not turn ı into i: Işıl is not Isil, as Käte # is not Kaete. usage = tool.namdict_usage(word, tool.read_namdict(_DICT), top=5) assert usage.given_bands == [] def test_namdict_usage_matches_ignoring_case_and_reads_a_plus_name() -> None: table = tool.read_namdict(_DICT) assert tool.namdict_usage("IN", table, top=5).given_bands == [("Korea", 10)] for word in ("Jun Wei", "Jun+Wei"): usage = tool.namdict_usage(word, table, top=5) assert usage.given_bands == [("Country 50", 3)] assert "listed as Jun+Wei" in usage.note def test_namdict_usage_shows_the_top_countries_and_says_so() -> None: usage = tool.namdict_usage("Dean", tool.read_namdict(_DICT), top=1) assert usage.given_bands == [("Country 2", 6)] assert "banded in 4 countries, the top 1 shown" in usage.note def test_namdict_usage_reports_a_missing_name_as_no_answer() -> None: # The dictionary is a selection (Shehu has no entry): a miss must not # read as a zero anywhere, in the row or in the JSON. usage = tool.namdict_usage("Shehu", tool.read_namdict(_DICT), top=5) assert (usage.status, usage.given, usage.given_bands) == ("ok", None, []) assert "proves nothing" in usage.note assert tool.render("Shehu", [usage]).splitlines()[2].split()[-3:] == ["-", "-", "-"] def test_namdict_usage_names_a_short_form() -> None: usage = tool.namdict_usage("Al", tool.read_namdict(_DICT), top=5) assert usage.given is None assert "short form of Albert" in usage.note def test_namdict_usage_calls_a_non_latin_word_unrepresentable() -> None: table = tool.read_namdict(_DICT) for word in ("关", "Иван"): usage = tool.namdict_usage(word, table, top=5) assert (usage.status, usage.given) == ("unrepresentable", None) def test_namdict_usage_reports_a_missing_download_without_an_answer() -> None: usage = tool.namdict_usage("Dean", None, top=5) assert (usage.status, usage.given) == ("not downloaded", None) def test_render_shows_the_peak_band_and_the_bands_by_country() -> None: usage = tool.namdict_usage("Dean", tool.read_namdict(_DICT), top=5) text = tool.render("Dean", [usage]) assert text.splitlines()[2].split()[-4:] == ["band", "6", "-", "-"] assert ("given band by country: Country 2 6, Country 0 5, Country 50 2, " "Country 30 1") in text def test_render_writes_a_band_in_hex_as_the_note_does() -> None: usage = tool.namdict_usage("In", tool.read_namdict(_DICT), top=5) text = tool.render("In", [usage]) assert text.splitlines()[2].split()[-4:] == ["band", "A", "-", "-"] assert "given band by country: Korea A" in text def _sdist(member_text: bytes, member: str = tool.NAMDICT_MEMBER) -> bytes: import tarfile buffer = io.BytesIO() with tarfile.open(fileobj=buffer, mode="w:gz") as tf: for name, data in ((member, member_text), ("gender-guesser-0.4.0/setup.py", b"print('no')")): info = tarfile.TarInfo(name) info.size = len(data) tf.addfile(info, io.BytesIO(data)) return buffer.getvalue() def _serve(monkeypatch: pytest.MonkeyPatch, data: bytes, url: str | None = None) -> None: def answer(request: urllib.request.Request, timeout: float) -> io.BytesIO: assert request.full_url == (url or tool.NAMDICT_URL) return io.BytesIO(data) monkeypatch.setattr(urllib.request, "urlopen", answer) def test_load_namdict_caches_only_the_dictionary_from_a_pinned_sdist( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) sdist = _sdist(_DICT.encode()) monkeypatch.setattr(tool, "NAMDICT_SHA256", hashlib.sha256(sdist).hexdigest()) monkeypatch.setattr(tool, "NAMDICT_FILE_SHA256", _DICT_SHA256) _serve(monkeypatch, sdist) table = _LOAD_NAMDICT(download=True) assert table is not None and table.names["in"] == [("In", {"Korea": 10})] assert sorted(p.name for p in tmp_path.iterdir()) == [tool.NAMDICT_FILE] def test_load_namdict_refuses_an_sdist_that_is_not_the_pinned_one( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) _serve(monkeypatch, _sdist(_DICT.encode())) with pytest.raises(ValueError, match="not the pinned"): _LOAD_NAMDICT(download=True) assert list(tmp_path.iterdir()) == [] # nothing cached def test_load_namdict_does_not_download_when_told_not_to( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) assert _LOAD_NAMDICT(download=False) is None def test_main_reads_a_cached_dictionary_offline( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) monkeypatch.setattr(tool, "load_namdict", _LOAD_NAMDICT) monkeypatch.setattr(tool, "NAMDICT_FILE_SHA256", _DICT_SHA256) (tmp_path / tool.NAMDICT_FILE).write_text(_DICT, encoding="utf-8") assert tool.main(["--no-wikidata", "--json", "In", "Shehu"]) == 0 report = json.loads(capsys.readouterr().out) assert report["In"][2]["given_bands"] == [["Korea", 10]] assert report["Shehu"][2]["given"] is None # null, not 0 def test_main_exits_2_on_an_unusable_cached_dictionary( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) monkeypatch.setattr(tool, "load_namdict", _LOAD_NAMDICT) (tmp_path / tool.NAMDICT_FILE).write_text("<html>error</html>") assert tool.main(["--no-wikidata", "--json", "In"]) == 2 row = json.loads(capsys.readouterr().out)["In"][2] assert (row["status"], row["given"]) == ("failed", None) assert "delete it and run again" in row["note"] @pytest.mark.parametrize(("sdist", "why"), [ (lambda: _sdist(_DICT.encode(), member="elsewhere/nam_dict.txt"), "not found"), (lambda: b"not a gzip at all", ""), (lambda: _sdist(_DICT.encode())[:200], ""), # truncated ]) def test_load_namdict_reports_an_archive_the_pin_does_not_describe( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, sdist: Callable[[], bytes], why: str) -> None: # A hash bumped without the member path, or an archive of another # layout: tarfile raises KeyError, ReadError or EOFError, none of them # an OSError, which escaped _attempt and took every source down. monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) data = sdist() monkeypatch.setattr(tool, "NAMDICT_SHA256", hashlib.sha256(data).hexdigest()) _serve(monkeypatch, data) with pytest.raises(ValueError, match=f"cannot read .*{why}"): _LOAD_NAMDICT(download=True) assert list(tmp_path.iterdir()) == [] def _legend_only() -> str: return _DICT.split("\r\n? In")[0] + "\r\n" @pytest.mark.parametrize("cut", [ _legend_only, # every name gone lambda: _DICT[:_DICT.rindex("\r\n", 0, -2) + 2], # the last line gone lambda: _DICT[:-20], # cut inside a line ]) def test_load_namdict_refuses_a_cache_file_cut_short( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, cut: Callable[[], str]) -> None: # Cut at a line boundary, the file still parses, and every name it # lost would read "not listed" -- the SSA zip's false zero again. A # floor on the names read refused the first and not the second. monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) monkeypatch.setattr(tool, "NAMDICT_FILE_SHA256", _DICT_SHA256) (tmp_path / tool.NAMDICT_FILE).write_bytes(cut().encode()) with pytest.raises(ValueError, match="unusable"): _LOAD_NAMDICT(download=False) def test_load_namdict_accepts_the_pinned_file( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) monkeypatch.setattr(tool, "NAMDICT_FILE_SHA256", _DICT_SHA256) (tmp_path / tool.NAMDICT_FILE).write_bytes(_DICT.encode()) table = _LOAD_NAMDICT(download=False) assert table is not None and table.lookup("In") == [("In", {"Korea": 10})] def test_load_namdict_does_not_cache_a_member_the_pin_does_not_describe( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: # The sdist matches its pin but the member does not match its own: # caching it would leave a file every later load refuses. monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) sdist = _sdist(_DICT.encode()) monkeypatch.setattr(tool, "NAMDICT_SHA256", hashlib.sha256(sdist).hexdigest()) _serve(monkeypatch, sdist) with pytest.raises(ValueError, match="not the pinned"): _LOAD_NAMDICT(download=True) assert list(tmp_path.iterdir()) == [] def test_main_does_not_download_the_dictionary_under_no_wikidata( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: # With the flag ignored, the download would reach the network guard, # whose AssertionError _attempt does not catch. monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) monkeypatch.setattr(tool, "load_namdict", _LOAD_NAMDICT) assert tool.main(["--no-wikidata", "--json", "In"]) == 0 row = json.loads(capsys.readouterr().out)["In"][2] assert (row["status"], row["given"]) == ("not downloaded", None) def test_main_downloads_the_dictionary_when_online( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) monkeypatch.setattr(tool, "load_namdict", _LOAD_NAMDICT) monkeypatch.setattr(tool, "load_census", lambda download: None) monkeypatch.setattr(tool, "NAMDICT_FILE_SHA256", _DICT_SHA256) sdist = _sdist(_DICT.encode()) monkeypatch.setattr(tool, "NAMDICT_SHA256", hashlib.sha256(sdist).hexdigest()) _serve(monkeypatch, sdist) _stub_sparql(monkeypatch, [[], []]) assert tool.main(["--json", "In"]) == 0 row = json.loads(capsys.readouterr().out)["In"][2] assert row["given_bands"] == [["Korea", 10]] def test_main_refuses_fewer_than_one_country( capsys: pytest.CaptureFixture[str]) -> None: # --countries 0 emptied the band list, and a listed name's given cell # then showed the "-" of a name that is not listed. with pytest.raises(SystemExit): tool.main(["--countries", "0", "In"]) assert "must be 1 or more" in capsys.readouterr().err # -- NPPES credentials ------------------------------------------------------ def _providers(*fields: str, organizations: int = 0) -> list[dict[str, str]]: rows = [{"entity": "Individual", "pcredential": f} for f in fields] return rows + [{"entity": "Organization", "pcredential": "MD"}] * organizations def test_tally_counts_a_provider_once_per_credential_and_keeps_spellings() -> None: tally = tool.tally_credentials(iter(_providers("MD, M.D.", "M.D", ""))) assert (tally.people, tally.credentialed) == (3, 2) providers, spellings = tally.tokens["MD"] assert providers == 2 assert dict(spellings) == {"MD": 1, "M.D.": 1, "M.D": 1} def test_tally_reads_only_individual_providers() -> None: tally = tool.tally_credentials(iter(_providers("RN", organizations=3))) assert tally.people == 1 and tally.tokens["RN"][0] == 1 assert "MD" not in tally.tokens def test_tally_joins_one_credential_written_in_pieces() -> None: # PHARMD alone far outnumbers its rarest piece, so the pieces are # one credential; split, 'PHARM D' scored a D the field never meant. fields = ["PHARMD"] * 100 + ["PHARM"] * 3 + ["PHARM D"] tally = tool.tally_credentials(iter(_providers(*fields))) assert tally.tokens["PHARMD"][0] == 101 assert ("PHARM D", 1) in tally.tokens["PHARMD"][1] assert "D" not in tally.tokens and tally.tokens["PHARM"][0] == 3 def test_tally_joins_pieces_onto_a_hyphenated_or_slashed_spelling() -> None: fields = ["PA-C"] * 100 + ["OTR/L"] * 100 + ["PA"] * 60 + [ "PA C", "OTR L"] tally = tool.tally_credentials(iter(_providers(*fields))) assert tally.tokens["PA-C"][0] == 101 and tally.tokens["OTR/L"][0] == 101 assert tally.tokens["PA"][0] == 60 def test_tally_splits_credentials_written_side_by_side() -> None: # Each piece is written alone more often than the two joined. fields = ["MD"] * 100 + ["PHD"] * 80 + ["MD/PHD"] * 5 + [ "MD PHD"] tally = tool.tally_credentials(iter(_providers(*fields))) assert (tally.tokens["MD"][0], tally.tokens["PHD"][0]) == (101, 81) assert tally.tokens["MD/PHD"][0] == 5 def test_tally_keeps_a_job_title_whole() -> None: # PRACTITIONER is rarely written alone (19 times on the real file); # splitting the title would have counted NURSE for each of the 5,803 # providers who write NURSE PRACTITIONER. fields = ["NURSE"] * 60 + ["NURSE PRACTITIONER"] * 2 tally = tool.tally_credentials(iter(_providers(*fields))) assert tally.tokens["NURSE PRACTITIONER"][0] == 2 assert tally.tokens["NURSE"][0] == 60 assert "PRACTITIONER" not in tally.tokens def test_tally_splits_after_a_join_inside_one_segment() -> None: fields = (["MS"] * 100 + ["CCC-SLP"] * 100 + ["CCC"] * 2 + ["SLP"] * 60 + ["MS CCC SLP"]) tally = tool.tally_credentials(iter(_providers(*fields))) assert tally.tokens["MS"][0] == 101 and tally.tokens["CCC-SLP"][0] == 101 def test_tally_drops_a_piece_that_is_only_periods() -> None: fields = ["MD"] * 100 + ["M"] * 2 + ["D"] * 2 + ["M . D."] tally = tool.tally_credentials(iter(_providers(*fields))) assert tally.tokens["MD"][0] == 101 class _Tally(Protocol): # tools/ is loaded by path, so mypy cannot see CredentialTally itself. people: int credentialed: int tokens: dict[str, tuple[int, list[tuple[str, int]]]] joined: dict[str, str] def _tally(joined: dict[str, str] | None = None, /, **tokens: int) -> _Tally: """A tally of these provider counts. Unless `joined` is given, each unspaced key's letters are filed under the commonest key holding them, as tally_credentials files them when providers and whole segments agree.""" credentialed = sum(tokens.values()) or 1 if joined is None: joined = {} for k, _ in sorted(tokens.items(), key=lambda kn: -kn[1]): if " " not in k: joined.setdefault(tool._squash(k), k) return tool.CredentialTally( people=max(1000, credentialed), credentialed=credentialed, tokens={k: (n, [(k, n)]) for k, n in tokens.items()}, joined=joined) @pytest.mark.parametrize("word", ["m.d.", "MD", "Md", "M. D."]) def test_credential_usage_matches_as_the_parser_matches_an_acronym(word: str) -> None: usage = tool.credential_usage(word, _tally(MD=40, RN=60)) assert (usage.status, usage.credential) == ("ok", 40) def test_credential_usage_finds_a_credential_the_tally_joined() -> None: usage = tool.credential_usage("Ph. D.", _tally(PHD=7)) assert usage.credential == 7 and "looked up as PHD" in usage.note def test_credential_usage_files_a_spaced_word_as_the_tally_filed_it() -> None: # The tally files 'PA C' under the commonest key sharing its letters; # the lookup first squashed it to PAC and read 5,910 where the # credential's providers number 122,676 (review of the first version). tally = _tally(**{"PA-C": 120, "PAC": 6}) usage = tool.credential_usage("PA C", tally) assert usage.credential == 120 and "looked up as PA-C" in usage.note def test_credential_usage_reads_the_tallys_choice_not_the_provider_order() -> None: # The lookup used to re-derive the choice by provider count, and # _resolve made it by whole segments: CPRI's one provider writes it # twice, so _resolve filed 'CPR I' under CPRI while the lookup, by # providers then by key, found CPR-I (measured 2026-10-21). tally = _tally({"CPRI": "CPRI"}, **{"CPR-I": 1, "CPRI": 1}) usage = tool.credential_usage("CPR I", tally) assert "looked up as CPRI" in usage.note assert "also written CPR-I 1" in usage.note def test_credential_usage_names_the_other_spellings_of_its_letters() -> None: tally = _tally(**{"PA-C": 120, "PAC": 6}) assert "also written PA-C 120" in tool.credential_usage("PAC", tally).note missing = tool.credential_usage("OTR-L", _tally(**{"OTR/L": 60})) assert missing.credential == 0 and "also written OTR/L 60" in missing.note def test_credential_usage_takes_a_spaced_key_the_tally_holds_whole() -> None: tally = _tally(**{"NURSE PRACTITIONER": 9, "NURSEPRACTITIONER": 40}) usage = tool.credential_usage("Nurse Practitioner", tally) assert usage.credential == 9 assert "also written NURSEPRACTITIONER 40" in usage.note def test_credential_usage_gives_a_word_without_letters_no_siblings() -> None: usage = tool.credential_usage("- -", _tally(**{"-": 5, "MD": 1})) assert usage.credential == 0 and "also written" not in usage.note def test_credential_usage_reports_a_missing_word_as_zero_with_the_scope() -> None: # The field is a census, so 0 is an answer -- but for US healthcare # alone, which the note must carry wherever the zero goes. usage = tool.credential_usage("CPA", _tally(MD=1)) assert (usage.status, usage.credential) == ("ok", 0) assert "US healthcare only" in usage.note def test_credential_usage_reports_a_missing_download_without_a_count() -> None: usage = tool.credential_usage("MD", None) assert (usage.status, usage.credential) == ("not downloaded", None) assert "--fetch-nppes" in usage.note def test_render_shows_the_credential_column() -> None: text = tool.render("MA", [tool.credential_usage("MA", _tally(MA=1234))]) assert text.splitlines()[1].split()[-1] == "credential" assert text.splitlines()[2].split()[-3:] == ["-", "-", "1,234"] def test_share_never_prints_a_zero_for_a_credential_someone_writes() -> None: assert tool._share(5 / 4_824_544) == "<0.01%" assert tool._share(0.0234) == "2.34%" def _nppes_zip(*fields: str, header: str = "npi,entity,pcredential", text: str | None = None, member: str | None = None) -> bytes: """A zip of individual providers with these credential fields, or of `text` as written, under `member` (the pinned member by default).""" if text is None: lines = [header] + [f'{i},Individual,"{f}"' for i, f in enumerate(fields)] text = "\n".join(lines) + "\n" buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w") as zf: zf.writestr(member or tool.NPPES_MEMBER, text) return buffer.getvalue() def _pin_nppes(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, data: bytes, cached: bool = False) -> None: """Point the cache at tmp_path and the pin at `data`, and with `cached` put `data` there as the downloaded zip.""" monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) monkeypatch.setattr(tool, "NPPES_SHA256", hashlib.sha256(data).hexdigest()) if cached: (tmp_path / tool.NPPES_FILE).write_bytes(data) def test_load_nppes_downloads_the_pinned_zip_and_caches_its_tally( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: data = _nppes_zip("MD", "RN, MD") _pin_nppes(monkeypatch, tmp_path, data) _serve(monkeypatch, data, tool.NPPES_URL) tally = _LOAD_NPPES(download=True) assert tally is not None and tally.tokens["MD"][0] == 2 assert sorted(p.name for p in tmp_path.iterdir()) == [ tool.NPPES_TALLY, tool.NPPES_FILE] # The second load reads the tally and never opens the zip. (tmp_path / tool.NPPES_FILE).unlink() again = _LOAD_NPPES(download=False) assert again is not None and again.tokens == tally.tokens assert again.joined == tally.joined and again.joined def test_load_nppes_refuses_a_zip_that_is_not_the_pinned_one( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) _serve(monkeypatch, _nppes_zip("MD"), tool.NPPES_URL) with pytest.raises(ValueError, match="not the pinned"): _LOAD_NPPES(download=True) assert list(tmp_path.iterdir()) == [] # nothing cached, no .part left def test_load_nppes_refuses_a_pinned_zip_without_the_member( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: data = _nppes_zip(text="entity,pcredential\n", member="elsewhere.csv") _pin_nppes(monkeypatch, tmp_path, data) _serve(monkeypatch, data, tool.NPPES_URL) with pytest.raises(ValueError, match="cannot read"): _LOAD_NPPES(download=True) assert list(tmp_path.iterdir()) == [] def test_load_nppes_does_not_download_when_told_not_to( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) assert _LOAD_NPPES(download=False) is None def test_load_nppes_refuses_a_cached_zip_that_does_not_match_its_pin( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: # A whole, readable zip of another month (or one missing rows) opens # and tallies without complaint; only the hash says it is not the # file the counts claim to be. A truncated zip did not test this -- # it fails to open whether the pin is checked or not. _pin_nppes(monkeypatch, tmp_path, _nppes_zip("MD", "RN")) (tmp_path / tool.NPPES_FILE).write_bytes(_nppes_zip("MD")) with pytest.raises(ValueError, match="not the pinned.*--fetch-nppes"): _LOAD_NPPES(download=False) assert not (tmp_path / tool.NPPES_TALLY).exists() def test_load_nppes_names_a_member_without_the_credential_column( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: data = _nppes_zip("MD", header="npi,entity,credential") _pin_nppes(monkeypatch, tmp_path, data, cached=True) with pytest.raises(ValueError, match="pcredential"): _LOAD_NPPES(download=False) @pytest.mark.parametrize("stale", [ {"version": 0}, {"source_sha256": "0" * 64}, {"rules": {"min_alone": 1, "spellings": tool.NPPES_SPELLINGS}}]) def test_load_nppes_rebuilds_a_tally_from_another_version_or_zip( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, stale: dict[str, object]) -> None: data = _nppes_zip("MD", "MD") _pin_nppes(monkeypatch, tmp_path, data, cached=True) old = tool._tally_json(_tally(RN=9)) | stale (tmp_path / tool.NPPES_TALLY).write_text(json.dumps(old)) tally = _LOAD_NPPES(download=False) assert tally is not None and dict( (k, n) for k, (n, _) in tally.tokens.items()) == {"MD": 2} def test_load_nppes_rebuilds_a_tally_written_before_the_rules_were_recorded( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: data = _nppes_zip("MD") _pin_nppes(monkeypatch, tmp_path, data, cached=True) old = tool._tally_json(_tally(RN=9)) del old["rules"] (tmp_path / tool.NPPES_TALLY).write_text(json.dumps(old)) tally = _LOAD_NPPES(download=False) assert tally is not None and "RN" not in tally.tokens def test_load_nppes_leaves_a_stale_tally_unread_when_nothing_can_rebuild_it( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) old = tool._tally_json(_tally(RN=9)) | {"version": 0} (tmp_path / tool.NPPES_TALLY).write_text(json.dumps(old)) assert _LOAD_NPPES(download=False) is None @pytest.mark.parametrize("cut", [ lambda text: text[:len(text) // 2], # cut mid-file lambda text: text.replace('"tokens": {', '"tokens": {}, "x": {', 1), lambda text: "[]", ]) def test_load_nppes_refuses_a_tally_that_is_not_whole( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, cut: Callable[[str], str]) -> None: # A tally missing tokens would read every credential it lost as 0, # which this source reports as a real zero. monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) text = json.dumps(tool._tally_json(_tally(MD=3, RN=4))) (tmp_path / tool.NPPES_TALLY).write_text(cut(text)) with pytest.raises(ValueError, match="unusable.*re-tally"): _LOAD_NPPES(download=False) def _cache_tally(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, tally: _Tally) -> None: monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) monkeypatch.setattr(tool, "load_nppes", _LOAD_NPPES) (tmp_path / tool.NPPES_TALLY).write_text(json.dumps(tool._tally_json(tally))) def test_main_reports_a_cached_tally_offline( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: _cache_tally(monkeypatch, tmp_path, _tally(MA=12, DO=30)) assert tool.main(["--no-wikidata", "--json", "ma", "CPA"]) == 0 report = json.loads(capsys.readouterr().out) assert [r["credential"] for r in report["ma"]] == [None, None, None, 12] assert report["CPA"][3]["credential"] == 0 def test_main_does_not_download_nppes_unless_asked( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: # Online and uncached: without --fetch-nppes the 673 MB download # must not start (the network guard would raise if it did). monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) monkeypatch.setattr(tool, "load_nppes", _LOAD_NPPES) monkeypatch.setattr(tool, "load_census", lambda download: None) _stub_sparql(monkeypatch, [[], []]) assert tool.main(["--json", "MD"]) == 0 row = json.loads(capsys.readouterr().out)["MD"][3] assert (row["status"], row["credential"]) == ("not downloaded", None) def test_main_fetches_nppes_when_asked( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: data = _nppes_zip("MD", "MD, RN") _pin_nppes(monkeypatch, tmp_path, data) monkeypatch.setattr(tool, "load_nppes", _LOAD_NPPES) monkeypatch.setattr(tool, "load_census", lambda download: None) _serve(monkeypatch, data, tool.NPPES_URL) monkeypatch.setattr(tool, "wikidata_usage", lambda word, top: tool.Usage("Wikidata people")) assert tool.main(["--fetch-nppes", "--json", "MD"]) == 0 assert json.loads(capsys.readouterr().out)["MD"][3]["credential"] == 2 def test_main_exits_2_on_an_unusable_tally_without_losing_the_other_sources( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) monkeypatch.setattr(tool, "load_nppes", _LOAD_NPPES) with zipfile.ZipFile(tmp_path / tool.CENSUS_FILE, "w") as zf: zf.writestr(tool.CENSUS_MEMBER, _CENSUS) (tmp_path / tool.NPPES_TALLY).write_text("{") assert tool.main(["--no-wikidata", "--json", "shehu"]) == 2 census, _, _, nppes = json.loads(capsys.readouterr().out)["shehu"] assert census["family"] == 561 assert (nppes["status"], nppes["credential"]) == ("failed", None) def test_main_rank_lists_credentials_with_vocabulary_and_readings( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: # Q-R-S still reverses after a comma: joined single letters are no # caps shape (rules/S9), so the given-name readout stays exercised _cache_tally(monkeypatch, tmp_path, _tally(RA=50, **{"Q-R-S": 40}, ESQ=3)) assert tool.main(["--no-wikidata", "--json", "--rank", "5"]) == 0 rows = {r["credential"]: r for r in json.loads(capsys.readouterr().out)["rows"]} assert [r["rank"] for r in rows.values()] == [1, 2, 3] assert rows["RA"]["vocabulary"] == "acronym, title" assert (rows["RA"]["after_comma"], rows["RA"]["bare"]) == ("suffix", "suffix") assert rows["Q-R-S"]["vocabulary"] == "-" assert (rows["Q-R-S"]["after_comma"], rows["Q-R-S"]["bare"]) == ("given", "family") assert rows["ESQ"]["vocabulary"] == "suffix word" assert rows["RA"]["census"] == "not downloaded" def test_main_rank_unlisted_keeps_each_credentials_overall_rank( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: _cache_tally(monkeypatch, tmp_path, _tally(MD=50, **{"QRS-C": 40}, MA=30)) assert tool.main(["--no-wikidata", "--json", "--rank", "5", "--unlisted"]) == 0 rows = json.loads(capsys.readouterr().out)["rows"] assert [(r["credential"], r["rank"]) for r in rows] == [("QRS-C", 2)] def test_main_rank_exits_2_without_a_tally( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) monkeypatch.setattr(tool, "load_nppes", _LOAD_NPPES) assert tool.main(["--no-wikidata", "--rank", "5"]) == 2 assert "not cached" in capsys.readouterr().err @pytest.mark.parametrize(("argv", "message"), [ ([], "give words"), (["--rank", "5", "MD"], "takes no words"), (["--unlisted", "MD"], "goes with --rank"), (["--rank", "0"], "must be 1 or more"), ]) def test_main_refuses_a_rank_request_it_cannot_answer( argv: list[str], message: str, capsys: pytest.CaptureFixture[str]) -> None: with pytest.raises(SystemExit): tool.main(argv) assert message in capsys.readouterr().err @pytest.mark.parametrize(("key", "expected"), [ ("MA", "ambiguous acronym"), ("DO", "ambiguous acronym, particle"), ("ESQ", "suffix word"), ("JR", "suffix word"), ("PHD", "acronym"), ("QRS-C", "-"), ]) def test_vocabulary_reads_the_suffix_kind_as_the_parser_does( key: str, expected: str) -> None: assert tool._vocabulary(key) == expected @pytest.mark.parametrize(("word", "found"), [ ("Psicóloga", "PSICOLOGA"), # the file has no accents at all ("Médico", "MEDICO"), ("Ørn", "ORN"), # a letter NFKD leaves whole, transliterated ]) def test_credential_usage_folds_an_accented_word_as_the_file_spells_it( word: str, found: str) -> None: # Unfolded, the accented key could never match and read a false 0. usage = tool.credential_usage(word, _tally(**{found: 16})) assert (usage.status, usage.credential) == ("ok", 16) assert f"looked up as {found}" in usage.note def test_credential_usage_reads_any_dash_as_a_hyphen() -> None: # The file writes only the ASCII hyphen; PA–C (an en dash) read 0 # beside PA-C's 122,676 after the first accent fold. for dash in ("\u2013", "\u2010", "\u2014"): usage = tool.credential_usage(f"PA{dash}C", _tally(**{"PA-C": 120})) assert (usage.status, usage.credential) == ("ok", 120), dash @pytest.mark.parametrize("word", ["博士", "врач", "M.D.\u2019", "", " ", "\u0301", ".", ". ."]) def test_credential_usage_calls_a_non_latin_word_unrepresentable(word: str) -> None: # The file spells every credential in ASCII: no answer, never a zero. usage = tool.credential_usage(word, _tally(MD=1)) assert (usage.status, usage.credential) == ("unrepresentable", None) assert tool.render(word, [usage]).splitlines()[2].split()[-1] == "-" def test_load_nppes_refuses_a_source_with_no_individual_provider( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: # An empty tally answers 0 for every word. Cached, it read "no # provider writes MD" with exit 0 for one run, and was refused only by # the next one's load. data = _nppes_zip(text="npi,entity,pcredential\n1,1,MD\n" "2,Organization,MD\n") _pin_nppes(monkeypatch, tmp_path, data, cached=True) with pytest.raises(ValueError, match="no individual provider"): _LOAD_NPPES(download=False) assert not (tmp_path / tool.NPPES_TALLY).exists() # A row replacing the tokens replaces `joined` with them: left naming a # dropped token, it is refused by the joined check too, so with its own # value check deleted it still passes (mechanisms.md#SHADOWED-NEGATIVE-ROWS). @pytest.mark.parametrize("change", [ {"credentialed": 0}, # a share divided by zero {"credentialed": 2000}, # more than the people read {"tokens": {"MD": [0, [["MD", 1]]]}, "joined": {"MD": "MD"}}, {"tokens": {"MD": [5000, [["MD", 1]]]}, "joined": {"MD": "MD"}}, {"tokens": {"MD": [5, []]}, "joined": {"MD": "MD"}}, # --rank printed spellings[0] {"tokens": {"MD": [5, [["MD", 0]]]}, "joined": {"MD": "MD"}}, {"joined": {"PAC": "PA-C"}}, # names no token {"joined": {"RN": "MD"}}, # names a token of other letters {"joined": None}, # a version-2 tally without the map ]) def test_load_nppes_refuses_a_tally_whose_values_cannot_be( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, change: dict[str, object]) -> None: # Each parsed as a tally and crashed the run later, outside _attempt, # taking every other source's answer down with it. monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) data = tool._tally_json(_tally(MD=3, RN=4)) | change (tmp_path / tool.NPPES_TALLY).write_text(json.dumps(data)) with pytest.raises(ValueError, match="unusable.*re-tally"): _LOAD_NPPES(download=False) def test_main_rank_downloads_nppes_when_online( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: data = _nppes_zip("MD", "MD, RN") _pin_nppes(monkeypatch, tmp_path, data) monkeypatch.setattr(tool, "load_nppes", _LOAD_NPPES) monkeypatch.setattr(tool, "load_census", lambda download: None) _serve(monkeypatch, data, tool.NPPES_URL) assert tool.main(["--json", "--rank", "5"]) == 0 rows = json.loads(capsys.readouterr().out)["rows"] assert [(r["credential"], r["providers"]) for r in rows] == [ ("MD", 2), ("RN", 1)] def test_main_rank_cuts_the_list_at_n( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: _cache_tally(monkeypatch, tmp_path, _tally(MD=50, RN=40, DO=30)) assert tool.main(["--no-wikidata", "--json", "--rank", "1"]) == 0 rows = json.loads(capsys.readouterr().out)["rows"] assert [r["credential"] for r in rows] == ["MD"] def test_main_rank_reports_a_failed_name_source_and_exits_2( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: # The column must say failed, not the "not downloaded" a missing file # gets, and the run must not exit 0. _cache_tally(monkeypatch, tmp_path, _tally(MD=50)) (tmp_path / tool.CENSUS_FILE).write_text("<html>error page</html>") assert tool.main(["--no-wikidata", "--json", "--rank", "3"]) == 2 out, err = capsys.readouterr() assert json.loads(out)["rows"][0]["census"] == "failed" assert "US Census 2010 surnames:" in err and "delete it" in err def test_main_rank_prints_a_table( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: # Every other rank test reads --json, so a bad format spec in the # table would have crashed the default output unseen. _cache_tally(monkeypatch, tmp_path, _tally(MD=5000, **{"Q-R-S": 40})) assert tool.main(["--no-wikidata", "--rank", "2"]) == 0 lines = capsys.readouterr().out.splitlines() assert lines[0].startswith("5,040 of 5,040 individual providers") assert lines[1].split()[:3] == ["rank", "credential", "providers"] assert lines[3].split()[:4] == ["2", "Q-R-S", "40", "0.79%"] assert lines[3].split()[4:7] == ["-", "given", "family"] def test_lands_says_split_when_no_field_holds_the_whole_credential() -> None: assert tool._lands("John Smith, PA C", "PA C") == "split" assert tool._lands("John Smith, MD", "MD") == "suffix" def test_credential_usage_falls_back_to_a_sibling_for_letters_only_phrases_hold() -> None: # The tally stores a choice only for letters some UNSPACED key holds. # A job title spaced unlike every recorded spelling has none, and the # first version of the stored choice read 0 for it, beside the # sibling's 5,054 -- where the lookup before it had found the sibling. usage = tool.credential_usage("Registered / Nurse", _tally(**{"REGISTERED NURSE": 50, "MD": 5})) assert usage.credential == 50 assert "looked up as REGISTERED NURSE" in usage.note assert "also written" not in usage.note # found, not also another # -- local corpora --------------------------------------------------------------- _CREDIT = "ClinicalTrials.gov (NLM) study officials, data as of 2026-10-21" def _local(*names: str) -> object: return tool._local_corpora().Local( "ctgov-officials", {"data_date": "2026-10-21"}, [{"name": n} for n in names]) def test_a_local_corpus_not_fetched_names_the_fetch( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) # One row per source, every one not fetched naming its own fetch. loaded = _LOAD_LOCALS() assert loaded == [(source, None, "") for source in sorted(tool._local_corpora().SOURCES)] assert {"ctgov-officials", "fec-individuals"} <= {s for s, _, _ in loaded} for source, corpus, _ in loaded: row = tool.local_usage("MD", source, corpus) assert row.status == "not downloaded" assert f"local_corpora.py fetch {source}" in row.note def test_a_local_corpus_row_counts_officials_and_the_comma_side() -> None: corpus = _local("Ann Lee, M.D.", "MD Bo Wu", "Cy Doe, MD, PhD", "Di Fox, MD/PhD", "Ed Ng, md; MD") row = tool.local_usage("m.d", "ctgov-officials", corpus) assert row.status == "ok" # The credit and date lead, as ClinicalTrials.gov's terms ask; the # spellings count records, so none exceeds the officials writing it. assert row.note == (f"{_CREDIT}; 5 of 5 officials write it, 4 after a comma; " "written MD 4, M.D. 1, md 1") assert tool.local_usage("PhD", "ctgov-officials", corpus).note.startswith( f"{_CREDIT}; 2 of 5 officials write it, 2 after a comma") missing = tool.local_usage("FNP-C", "ctgov-officials", corpus) assert missing.note == f"{_CREDIT}; 0 of 5 officials write it, 0 after a comma" assert tool.local_usage("..", "ctgov-officials", corpus).status == "unrepresentable" @pytest.mark.parametrize("word", ["Ph. D.", "Ph D", "ph d,"]) def test_a_spaced_word_is_looked_up_as_its_words_in_a_row(word: str) -> None: # As one key it could match no word of the index, so it read zero. corpus = _local("Ann Lee, Ph. D.", "Bo Wu, Ph D", "Cy Doe, PhD") row = tool.local_usage(word, "ctgov-officials", corpus) assert row.note == (f"{_CREDIT}; 2 of 3 officials write it, 2 after a comma; " "as one word 'phd' 1") def test_a_local_corpus_row_appears_in_the_report( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: monkeypatch.setattr(tool, "load_locals", lambda: [ ("ctgov-officials", _local("Ann Lee, MD"), "")]) assert tool.main(["--no-wikidata", "MD"]) == 0 out = capsys.readouterr().out assert "ctgov-officials (local corpus)" in out assert "1 of 1 officials write it, 1 after a comma" in out def test_a_local_corpus_that_fails_to_load_fails_the_run( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: monkeypatch.setattr(tool, "load_locals", lambda: [ ("ctgov-officials", None, "ValueError: does not match the sha256")]) assert tool.main(["--no-wikidata", "MD"]) == 2 assert "failed; ValueError: does not match the sha256" in capsys.readouterr().out def test_a_bug_in_a_lookup_is_raised_not_reported_as_a_failed_row( monkeypatch: pytest.MonkeyPatch) -> None: def broken(*args: object) -> None: raise ValueError("a bug, not a damaged corpus") monkeypatch.setattr(tool._local_corpora(), "lookup", broken) with pytest.raises(ValueError, match="a bug"): tool.local_rows("MD", [("ctgov-officials", _local("Ann Lee, MD"), "")]) def test_records_damaged_after_the_index_was_cached_fail_their_own_row( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: # A cached index answers load_locals without the records; a spaced # word then reads them first in the lookup, outside its guard. monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) module = tool._local_corpora() plant_corpus(monkeypatch, module, "ctgov-officials", [{"name": "Ann Lee, Ph. D."}], "2026-10-21", "officials") _LOAD_LOCALS() # builds and caches the index locals_ = [row for row in _LOAD_LOCALS() if row[0] == "ctgov-officials"] (tmp_path / "local" / "ctgov-officials" / "raw.jsonl").write_text("") [row] = tool.local_rows("Ph. D.", locals_) assert row.status == "failed" and "does not match the sha256" in row.note @pytest.mark.parametrize("damage", ["meta key", "record shape"]) def test_a_damaged_local_corpus_fails_its_row_not_the_run( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str], damage: str) -> None: # The real loader over a real cache: a meta.json missing a key raised # KeyError, which _attempt does not catch, and took every row down. monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) module = tool._local_corpora() plant_corpus(monkeypatch, module, "ctgov-officials", [{"name": "Ann Lee, MD"}], "2026-10-21", "officials") folder = tmp_path / "local" / "ctgov-officials" meta = json.loads((folder / "meta.json").read_text()) if damage == "meta key": del meta["sha256"] else: raw = b'{"nom": "Ann Lee, MD"}\n' (folder / "raw.jsonl").write_bytes(raw) meta["sha256"] = hashlib.sha256(raw).hexdigest() (folder / "meta.json").write_text(json.dumps(meta)) monkeypatch.setattr(tool, "load_locals", _LOAD_LOCALS) assert tool.main(["--no-wikidata", "MD"]) == 2 out = capsys.readouterr().out assert "ctgov-officials (local corpus)" in out and "failed; CorpusDamaged" in out assert "US Census 2010 surnames" in out # -- box indexes ----------------------------------------------------------------- def _boxes() -> object: corpora = tool._local_corpora() return corpora.BoxIndex( totals={"ES": 400, "MX": 100, "US": 5000, "": 9000}, given={"del": {"MX": 30, "ES": 20, "": 50}}, family={"del": {"ES": 60, "MX": 10, "US": 5, "": 80}}) def test_a_box_row_counts_each_box_and_gives_each_country_its_share() -> None: row = tool.box_usage("del", "patentsview-inventors", _boxes(), "", 2, []) assert (row.given, row.family) == (100, 155) # Ranked by people writing it, the records stating no country last: # they would lead every list and say nothing of where a name is borne. assert row.given_shares == [("MX", 30, 100), ("ES", 20, 400), ("--", 50, 9000)] assert row.family_shares == [("ES", 60, 400), ("MX", 10, 100), ("--", 80, 9000)] assert "14,500 people in 3 countries" in row.note def test_a_country_asked_for_is_listed_outside_the_top() -> None: row = tool.box_usage("del", "patentsview-inventors", _boxes(), "", 1, ["US", "PH"]) assert row.family_shares == [("ES", 60, 400), ("US", 5, 5000), ("PH", 0, 0), ("--", 80, 9000)] rendered = tool.render("del", [row]) assert "family box by country: ES 60 (15.00% of 400), US 5 (0.10% of 5,000), " \ "PH 0 (- of 0), -- 80 (0.89% of 9,000)" in rendered @pytest.mark.parametrize(("word", "says"), [ ("de la", "a spaced word is not looked up"), ("..", "nothing in it is left")]) def test_a_box_row_answers_single_words_only(word: str, says: str) -> None: row = tool.box_usage(word, "patentsview-inventors", _boxes(), "", 5, []) assert row.status == "unrepresentable" and says in row.note def test_a_box_row_not_fetched_or_failed_says_so() -> None: missing = tool.box_usage("del", "patentsview-inventors", None, "", 5, []) assert missing.status == "not downloaded" assert "local_corpora.py fetch patentsview-inventors" in missing.note failed = tool.box_usage("del", "patentsview-inventors", None, "ValueError: gone", 5, []) assert (failed.status, failed.note) == ("failed", "ValueError: gone") def test_only_the_sources_with_boxes_get_a_box_row( monkeypatch: pytest.MonkeyPatch) -> None: built: list[str] = [] module = tool._local_corpora() def built_for(source: str) -> object: built.append(source) return _boxes() monkeypatch.setattr(module, "cached_box_index", built_for) locals_ = [(source, object(), "") for source in sorted(module.SOURCES)] found = tool.load_box_indexes(locals_) # type: ignore[arg-type] assert [s for s, _, _ in found] == built == sorted( s for s, src in module.SOURCES.items() if src.boxes is not None) assert {"orcid-researchers", "patentsview-inventors"} <= set(built) def test_the_country_option_reaches_the_box_rows( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: monkeypatch.setattr(tool, "load_locals", lambda: []) monkeypatch.setattr(tool, "load_box_indexes", lambda locals_: [ ("patentsview-inventors", _boxes(), "")]) assert tool.main(["--no-wikidata", "--countries", "1", "--country", "us", "del"]) == 0 out = capsys.readouterr().out assert "patentsview-inventors (boxes by country)" in out assert "family box by country: ES 60 (15.00% of 400), US 5 (0.10% of 5,000)" in out def test_the_box_rows_are_built_only_when_asked_for( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: # Building one takes minutes and reading one seconds: a plain lookup # must not pay for rows nobody asked for. asked: list[object] = [] monkeypatch.setattr(tool, "load_locals", lambda: []) def loaded(locals_: object) -> list[tuple[str, object, str]]: asked.append(1) return [("patentsview-inventors", _boxes(), "")] monkeypatch.setattr(tool, "load_box_indexes", loaded) assert tool.main(["--no-wikidata", "del"]) == 0 assert asked == [] and "boxes by country" not in capsys.readouterr().out assert tool.main(["--no-wikidata", "--boxes", "del"]) == 0 assert asked == [1] and "boxes by country" in capsys.readouterr().out def test_a_country_asked_for_twice_or_blank_is_listed_once( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: monkeypatch.setattr(tool, "load_locals", lambda: []) monkeypatch.setattr(tool, "load_box_indexes", lambda locals_: [ ("patentsview-inventors", _boxes(), "")]) assert tool.main(["--no-wikidata", "--countries", "1", "--country", "us", "--country", "US", "--country", " ", "del"]) == 0 line = next(l for l in capsys.readouterr().out.splitlines() if "family box" in l) assert line.count("US 5") == 1 and line.count("--") == 1 �������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_order_correspondence.py�����������������������������0000664�0000000�0000000�00000024227�15273032717�0026224�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""The family-first/comma correspondence, as an executable invariant (#469). Form 4 (Title Family Given Middle Middle [Particle] [, Suffix] under FAMILY_FIRST) is form 2 (Family [Suffix], Title Given (Nickname) Middle Middle[,] Suffix [, Suffix or Title]) with the comma removed and the family inline -- the two notations quoted verbatim from tools/differential/shapes.py. The pairs are GENERATED and asserted equal, which consults no vocabulary and no rule -- so it catches a rule that is WRONG, which review cannot. Provenance (decisions/P6, the 2026-08-30 #467 entry): over ALL 70 particles x 3 families x 3 givens = 630 generated names, 0 of 630 agreed before the #467 change and 603 of 630 after. The 27 failures are the particle-and-suffix trio -- vd, mc, do -- a real limit and not noise (rules/P6, restated as a third bullet below); this module's own parametrized sweep below excludes that trio on purpose and covers 2 x 1 x 3 x 4 x 2 x 2 pairs of its own -- families, givens, middles, particles, titles, suffixes -- a SEPARATE, smaller count that must never be quoted as the 630 measurement or vice versa. Written as the product rather than as a figure because the figure was the thing that went stale: the tuples below are what decides it. decisions/two-input-invariants recomputes the same correspondence a second way -- a 6x6 spread of families and givens per particle rather than 3x3 -- and gets 67 of 70 particles agreeing on all 36 of their pairs, the same trio failing all 36; its Recompute recipe is what this file's negative-control test below runs. #466, the rejected predecessor, has no comparable number on record; its defects are qualitative -- it lost a given name outright on "van Berg Jan de" and promoted a post-nominal into the given slot on "Berg Jan Jr. de" (decisions/P6). An earlier draft of this module cited "0/216 -> 216/216", which decisions/P6 explicitly retracts: that number came from a script slicing the vocabulary (`sorted(particles)[:14]`), which happened to exclude exactly vd, mc and do -- the project's own example of a detector inheriting the design's blind spot (decisions/P6 says: "A FIRST DRAFT OF THIS ENTRY CLAIMED 216 of 216 ... Recompute over the WHOLE vocabulary, never a sample"). Three limits, load-bearing (from #469 and #467). All three are now restated in customize.rst's family-first section, alongside the corrected 603/630 measurement: - One shape written two ways, NOT comma-deletion in general. A name whose SHAPE changes when the comma goes (a title moving mid-name) is a different shape, not a counterexample, so no such pair is generated here. - Form 5 has no comma twin: no comma format puts the given name last, which is why FAMILY_FIRST_GIVEN_LAST appears nowhere in this file. - Where a word is both particle and suffix vocabulary, the two writings read it differently and the correspondence genuinely fails. It is the ASYMMETRY that breaks it, not a precedence applying to both sides: the attachment outranks the suffix reading on the COMMA side, which is the scope rules/P6 states it in, so `Ménil, Christophe vd` reads family `vd Ménil` while the family-first `Ménil Christophe vd` reads family `Ménil` and suffix `vd`. Not a bug, but rules/P6 stating it plainly: "the word is BOTH a particle and suffix vocabulary, this attachment outranks the suffix reading (S2): a trailing abbreviation after a family comma is the tussenvoegsel far more often than the decoration it collides with" -- and decisions/P6 calling it "a real limit, not noise". The parametrized vocabulary below deliberately excludes vd/mc/do; the negative control test owns them instead. A mass failure here -- most or all pairs turning red, rather than a handful -- means the correspondence itself has narrowed and rules/P6 needs amending alongside this file, not that the vocabulary needs shrinking to get back to green. """ import itertools import unicodedata from collections.abc import Iterator import pytest from nameparser import FAMILY_FIRST, ParsedName, Parser, Policy, Role, parse from nameparser.config.particles import PARTICLES as _SHIPPED_PARTICLES _FF = Parser(policy=Policy(name_order=FAMILY_FIRST)) _ROLES = tuple(r.value for r in Role) # test_cases.py's idiom; declaration # order is canonical def _reading(p: ParsedName) -> dict[str, object]: """The comparator shape tools/differential/compare.py uses: the seven role fields (never None -- ParsedName's role properties are typed `-> str`) plus the ambiguity-kind SET. decisions/P6 (#405) records that this design's own repaired failure was exactly an ambiguity ASYMMETRY between two writings of one name, so a role-only comparison would miss the most fragile part of the correspondence. A set and not a sorted multiset, mirroring compare.py's own `sorted({a.kind.name for a in ...})` exactly -- two occurrences of one kind are not a second thing to agree on.""" out: dict[str, object] = {r: getattr(p, r) for r in _ROLES} out["_ambiguities"] = sorted({a.kind.name for a in p.ambiguities}) return out FAMILIES = ("Ménil", "Jong") # Ménil pins the non-ASCII case GIVENS = ("Christophe",) MIDDLES = (None, "Marie", "Marie Louise") # shape 4's notation names # Middle Middle; "Marie Louise" # is the one pair generating two # never-given (de), ambiguous (van), and a multi-token run (van der). # The multi-token member is not decoration: a last-particle-only walk # once survived the whole suite on single-token fixtures alone, and # tests/v2/pipeline/test_post_rules.py::test_a_multi_token_run_is_taken_whole # is the pin that now owns that shape at the rule layer. PARTICLES = (None, "de", "van", "van der") TITLES = (None, "Dr.") SUFFIXES = (None, "Jr.") def _slug(s: str) -> str: """Turn an inline spelling into a bare pytest id: no quoting needed to select one case. Folded to ASCII -- pytest itself backslash- escapes a non-ASCII character in an id, which would make that claim false for Menil's accent.""" ascii_s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore") return ascii_s.decode("ascii").replace(" ", "_").translate( str.maketrans("", "", ".,")) def _pairs() -> Iterator[tuple[str, str]]: """(comma spelling, family-first spelling) for every combination. The title moves position between the writings -- post-comma in form 2, leading in form 4 -- which is the part of the correspondence review keeps getting wrong, so it is generated rather than sampled.""" for fam, giv, mid, part, title, suf in itertools.product( FAMILIES, GIVENS, MIDDLES, PARTICLES, TITLES, SUFFIXES): tail = " ".join(p for p in (giv, mid, part) if p) t = f"{title} " if title else "" comma = f"{fam}, {t}{tail}" + (f", {suf}" if suf else "") inline = f"{t}{fam} {tail}" + (f", {suf}" if suf else "") yield comma, inline _PAIRS = list(_pairs()) _IDS = [_slug(inline) for _, inline in _PAIRS] @pytest.mark.parametrize("comma, inline", _PAIRS, ids=_IDS) def test_form_4_parses_as_its_comma_twin(comma: str, inline: str) -> None: a, b = parse(comma), _FF.parse(inline) got_a, got_b = _reading(a), _reading(b) assert got_a == got_b, ( f"{comma!r} (default) and {inline!r} (FAMILY_FIRST) are one " f"shape written two ways and must agree") # The recorded negative control (decisions/P6, decisions/two- # input-invariants, rules/P6): the ONLY members of the shipped # particle vocabulary where the correspondence fails are the words # that are both particle and suffix vocabulary. Swept over the entire # shipped vocabulary -- nameparser.config.particles.PARTICLES, the # same source the invariant's own measurements drew from, never a # hand copy -- rather than the small parametrized set above, so this # is the test that would notice if a fourth word joined the trio. # # mechanisms.md#RECORDED-ROSTERS: "never re-derive the expectation # from the same inputs the check reads, because a derivation from the # same data always agrees with itself" -- a recorded literal, kept by # hand and never computed from the vocabulary intersection below. _EXPECTED_DISAGREEING_PARTICLES = frozenset({"do", "mc", "vd"}) def test_only_the_particle_suffix_trio_breaks_the_correspondence() -> None: # rules/P6 states the exception this sweep measures: "the word # is BOTH a particle and suffix vocabulary, this attachment # outranks the suffix reading (S2)" # # The shape is the minimal "Family Given Particle" -- one family, # one given -- rather than decisions/two-input-invariants' 6x6 # spread, because that entry's own Recompute bullet says a spread # only matters to rule out families/givens that are THEMSELVES # particle vocabulary (the precondition asserted below); once # that is excluded, the failures partition on the particle word # alone; one point suffices. fam, giv = "Ménil", "Christophe" # mechanisms.md#VOCABULARY-OVERLAP-AS-PRECONDITION: "assert the # intersection as a precondition" -- inverted from the mechanism's # usual shape: what has to hold here is the intersection being # EMPTY, not populated. decisions/two-input-invariants records # that a particle-vocabulary family or given measures a different # shape and reports 0 of 70 rather than 67 of 70. assert not ({fam.lower(), giv.lower()} & _SHIPPED_PARTICLES), ( f"{fam!r} or {giv!r} joined the shipped particle vocabulary; " f"this sweep measures the Family Given Particle shape and " f"needs a family/given that is not itself particle vocabulary " f"-- pick another pair") disagreeing = set() for p in _SHIPPED_PARTICLES: comma = f"{fam}, {giv} {p}" inline = f"{fam} {giv} {p}" got_a = _reading(parse(comma)) got_b = _reading(_FF.parse(inline)) if got_a != got_b: disagreeing.add(p) assert disagreeing == _EXPECTED_DISAGREEING_PARTICLES, ( f"the particle vocabulary's disagreeing set changed: " f"got {sorted(disagreeing)}, expected " f"{sorted(_EXPECTED_DISAGREEING_PARTICLES)} -- this is a finding, " f"not a fixture to update blindly") �������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_parser.py�������������������������������������������0000664�0000000�0000000�00000327175�15273032717�0023324�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������import dataclasses import itertools import pickle import re import unicodedata import pytest from nameparser import ( Lexicon, Locale, Parser, Policy, PolicyPatch, locales, parse, parser_for, ) from nameparser._policy import ( CapsSuffixes, FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST, GIVEN_FIRST, PatronymicRule, ) from nameparser._types import (UNJOINED_CONJUNCTION_TAG, UNJOINED_TAG, AmbiguityKind, ParsedName, Role, Segmentation) def test_parser_defaults_and_properties() -> None: p = Parser() assert p.lexicon == Lexicon.default() assert p.policy == Policy() def test_parser_rejects_wrong_types_eagerly() -> None: with pytest.raises(TypeError, match="lexicon"): Parser(lexicon={"titles": set()}) # type: ignore[arg-type] with pytest.raises(TypeError, match="policy"): Parser(policy="strict") # type: ignore[arg-type] def test_parse_end_to_end_with_default_vocabulary() -> None: pn = parse("Dr. Juan de la Vega III") assert pn.title == "Dr." assert pn.given == "Juan" assert pn.family == "de la Vega" assert pn.suffix == "III" assert str(pn) == "Dr. Juan de la Vega III" def test_parse_rejects_non_str_with_decode_hint() -> None: with pytest.raises(TypeError, match="decode"): parse(b"John Smith") # type: ignore[arg-type] with pytest.raises(TypeError, match="str"): parse(None) # type: ignore[arg-type] def test_degenerate_inputs_are_total() -> None: # the quote-pair defaults table (rule N2's conventions) assert not parse("") assert not parse(" ") assert parse("").original == "" # an input with no alphanumeric character is not a name (v1 kept it) assert not parse(".,") assert not parse("- -") assert parse(".,").original == ".," # but the raw input is kept single = parse("John") assert single.given == "John" family_first = Parser(policy=Policy(name_order=FAMILY_FIRST)) assert family_first.parse("Yamada").family == "Yamada" title_only = parse("Dr.") assert title_only.title == "Dr." and not title_only.given unbalanced = parse('Jon "Nick Smith') kinds = {a.kind for a in unbalanced.ambiguities} assert AmbiguityKind.UNBALANCED_DELIMITER in kinds assert '"Nick' in [t.text for t in unbalanced.tokens] # literal def test_parser_is_picklable_and_frozen() -> None: p = Parser(policy=Policy(name_order=FAMILY_FIRST)) loaded = pickle.loads(pickle.dumps(p)) assert loaded == p assert loaded.parse("Yamada Taro").family == "Yamada" with pytest.raises(AttributeError): p.policy = Policy() # type: ignore[misc] def test_parser_repr_composes_component_reprs() -> None: assert repr(Parser()) == "Parser(Lexicon(default), Policy())" p = Parser(policy=Policy(name_order=FAMILY_FIRST)) assert repr(p) == "Parser(Lexicon(default), Policy(name_order=FAMILY_FIRST))" def test_parsedname_repr_includes_ambiguities_line() -> None: pn = parse("Van Johnson") r = repr(pn) assert "given: 'Van'" in r assert "ambiguities:" in r and "particle-or-given" in r def test_module_parse_reuses_the_default_parser() -> None: import nameparser._parser as parser_mod assert parser_mod._default_parser() is parser_mod._default_parser() def test_parser_for_stacks_locales() -> None: ru = Locale(code="ru", lexicon=Lexicon.empty().add(titles={"г-н"}), policy=PolicyPatch(patronymic_rules=frozenset( {PatronymicRule.EAST_SLAVIC}))) p = parser_for(ru) assert PatronymicRule.EAST_SLAVIC in p.policy.patronymic_rules pn = p.parse("г-н Сидоров Иван Петрович") assert pn.title == "г-н" assert pn.given == "Иван" assert pn.family == "Сидоров" def test_parser_for_rejects_non_locales() -> None: with pytest.raises(TypeError, match="Locale"): parser_for("ru") # type: ignore[arg-type] def test_parser_for_wraps_pack_errors_with_identity() -> None: # PolicyPatch validates lazily (by design), so an invalid value sits # latent in a perfectly constructible Locale until apply time bad = Locale(code="xx", lexicon=Lexicon.empty(), policy=PolicyPatch(name_order=(1, 2, 3))) # type: ignore[arg-type] # the rewrap preserves the taxonomy's exception type (here the # non-Role element TypeError) while adding the pack identity with pytest.raises(TypeError, match="while applying locale 'xx'"): parser_for(bad) def test_parser_for_warns_on_scalar_conflict() -> None: a = Locale(code="aa", lexicon=Lexicon.empty(), policy=PolicyPatch(strip_emoji=False)) b = Locale(code="bb", lexicon=Lexicon.empty(), policy=PolicyPatch(strip_emoji=True)) with pytest.warns(UserWarning, match="strip_emoji"): p = parser_for(a, b) assert p.policy.strip_emoji is True # later wins def test_matches_component_wise_case_insensitive() -> None: pn = parse("John Smith") assert pn.matches("JOHN SMITH") assert pn.matches(parse("john smith")) assert not pn.matches("John Smythe") with pytest.raises(TypeError, match="str or ParsedName"): pn.matches(42) # type: ignore[arg-type] def test_family_first_given_last_places_middle_between() -> None: # T1: the three-piece FAMILY_FIRST_GIVEN_LAST assignment -- family # from the front, given from the END, middle between (not a rotation # of FAMILY_FIRST) p = Parser(policy=Policy(name_order=FAMILY_FIRST_GIVEN_LAST)) pn = p.parse("Zeng Xiao Long") assert (pn.family, pn.middle, pn.given) == ("Zeng", "Xiao", "Long") def test_ambiguous_particle_middle_defeats_both_family_first_orders() -> None: # the vocabulary layer joins the ambiguous particle "van" forward # before the positional layer runs, so a name whose middle word # collides with it parses identically under either family-first # order -- the caution in customize.rst rests on this for order in (FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST): pn = Parser(policy=Policy(name_order=order)).parse("Nguyen Van Minh") assert (pn.family, pn.middle, pn.given) == ("Nguyen", "", "Van Minh") def test_multiple_unbalanced_delimiters_each_reported() -> None: # T4: the extract scan continues past the first unmatched opener; # each one is reported and treated as literal text pn = parse('John "Jack (Smith') unbalanced = [a for a in pn.ambiguities if a.kind is AmbiguityKind.UNBALANCED_DELIMITER] assert len(unbalanced) == 2 assert pn.given == "John" and pn.family == "(Smith" assert not pn.nickname def test_matches_accepts_explicit_parser() -> None: family_first = Parser(policy=Policy(name_order=FAMILY_FIRST)) pn = family_first.parse("Yamada Taro") assert pn.matches("Yamada Taro", parser=family_first) assert not pn.matches("Yamada Taro") # default parser reads given-first def test_phd_split_heals_in_the_suffix_view() -> None: # v1 parity via fix_phd: the split credential renders as one suffix assert parse("John Ph. D.").suffix == "Ph. D." # #436: the writer typed no comma, so none is rendered assert parse("John Smith PhD MD").suffix == "PhD MD" def test_phd_split_mid_name_is_a_suffix() -> None: # v1 parity: fix_phd extracted the credential BEFORE parsing, so # position never mattered; the merged piece is a suffix anywhere pn = parse("Dr. John Ph. D. Smith") assert pn.suffix == "Ph. D." assert pn.family == "Smith" assert pn.middle == "" def test_ambiguous_acronym_reports_the_reading_it_took() -> None: # 'ma' is both a post-nominal and a surname, so whichever way the # peel resolves it is a guess -- the same shape as the leading # ambiguous particle that already reports PARTICLE_OR_GIVEN. In a # name written in one case: in a name of mixed case the capitals # settle the word and nothing reports it (rules/S6, #785) took_suffix = parse("JOHN SMITH MA") assert took_suffix.suffix == "MA" assert [a.kind for a in took_suffix.ambiguities] == \ [AmbiguityKind.SUFFIX_OR_NAME] assert [t.text for t in took_suffix.ambiguities[0].tokens] == ["MA"] # MOVED by #289, not deleted: 'MA' is written in capitals inside a # mixed-case name, so it now leans CREDENTIAL and is taken with no # words to spare -- 'Jack' is the only name word left, which is # what also turns on the GIVEN_OR_FAMILY fork (decisions/S6). The # capitals settled 'MA', so that fork is the only one (#785). took_family = parse("Jack MA") assert took_family.suffix == "MA" assert not took_family.family assert set(a.kind for a in took_family.ambiguities) == \ {AmbiguityKind.GIVEN_OR_FAMILY} # and in one case the count decides, and reports the family it kept took_family = parse("JACK MA") assert took_family.family == "MA" assert [a.kind for a in took_family.ambiguities] == \ [AmbiguityKind.SUFFIX_OR_NAME] @pytest.mark.parametrize("text", [ "John Smith M.A.", # periods decide it; no guess "Ma, Jack", # a comma fixes the family "Joao da Silva do Amaral de Souza", # 'do' mid-name, never at the peel "John Smith PhD", # unambiguous vocabulary "John Smith MA", # capitals settle it (#785) ]) def test_no_suffix_ambiguity_when_nothing_was_guessed(text: str) -> None: assert [a for a in parse(text).ambiguities if a.kind is AmbiguityKind.SUFFIX_OR_NAME] == [] def test_delimited_ambiguous_acronym_reports_suffix_or_nickname() -> None: # inside delimiters the competing readings are suffix and nickname: # "(MBA)" is unambiguously a credential and escapes to suffix, while # "(JD)" could be either, so it keeps the nickname reading -- a # guess, and until now a silent one n = parse("JEFFREY (JD) BRICKEN") assert n.nickname == "JD" assert [a.kind for a in n.ambiguities] == \ [AmbiguityKind.SUFFIX_OR_NICKNAME] assert [t.text for t in n.ambiguities[0].tokens] == ["JD"] # the unambiguous one decided on vocabulary, so it is not a guess assert parse("Andrew Perkins (MBA)").ambiguities == () def test_every_ambiguous_acronym_in_a_name_is_reported() -> None: # one coin-flip per acronym: a single-slot record dropped all but # the last, which defeats the point of reporting at all # (in one case, where no writing settles either word, rules/S6) n = parse("JOHN SMITH MA JD") # the scenery the record is read off; only its separator moved (#436) assert n.suffix == "MA JD" assert [a.kind for a in n.ambiguities] == \ [AmbiguityKind.SUFFIX_OR_NAME] * 2 assert sorted(t.text for a in n.ambiguities for t in a.tokens) == \ ["JD", "MA"] def _reports(text: str) -> list[tuple[AmbiguityKind, list[str]]]: return [(a.kind, [t.text for t in a.tokens]) for a in parse(text).ambiguities] def test_a_member_the_company_decides_after_a_family_comma_reports() -> None: # #544: a part after a one-word family comma that a credential in # front reads wholly as credentials reports each member it decided # -- and only those: a member whose capitals already lean # credential decided itself, so the same fields are silent n = parse("Smith, PhD Ma") assert (n.family, n.given, n.suffix) == ("Smith", "", "PhD Ma") assert _reports("Smith, PhD Ma") == [ (AmbiguityKind.SUFFIX_OR_NAME, ["Ma"])] n = parse("Smith, PhD MA") assert (n.family, n.given, n.suffix) == ("Smith", "", "PhD MA") assert n.ambiguities == () def test_a_dotted_credential_is_no_title_run_dual() -> None: # 'M.D.' carries the suffix reading and not the title one, so it # does not stand in the given part's leading title run: 'PhD' # behind it still speaks for 'Ma', and the pick reports (the parent # read given 'M.D.', middle 'Ma', suffix 'PhD') n = parse("Smith, M.D. PhD Ma") assert (n.family, n.given, n.suffix) == ("Smith", "", "M.D. PhD Ma") assert _reports("Smith, M.D. PhD Ma") == [ (AmbiguityKind.SUFFIX_OR_NAME, ["Ma"])] def test_ambiguous_acronym_detail_names_the_role_it_got() -> None: # the unpeeled piece is the last NAME piece, which is the family # name only under GIVEN_FIRST -- FAMILY_FIRST puts it in given, so # the detail has to follow the role actually assigned # # MOVED by #289, not deleted: 'Jack MA' is mixed case now, so 'MA' # leans CREDENTIAL and is peeled as a suffix under every order -- # there is no unpeeled acronym left for the detail to name a role # for. 'JACK MA' (one case, no lean) is the input that still # exercises the mechanism this test pins: the count alone decides, # and the detail names whichever role the order actually gave it. fam_first = Parser(policy=Policy(name_order=FAMILY_FIRST)) n = fam_first.parse("JACK MA") assert (n.family, n.given) == ("JACK", "MA") assert "given name" in n.ambiguities[0].detail assert "family name" not in n.ambiguities[0].detail def test_leading_particle_detail_names_the_role_it_got() -> None: # the same requirement as the acronym above, for the other kind: # `detail` is public output, so the role it names has to survive # assembly into ParsedName under a non-default order, not just be # right where _assign builds it fam_first = Parser(policy=Policy(name_order=FAMILY_FIRST)) n = fam_first.parse("Van Johnson") assert (n.family, n.given) == ("Van", "Johnson") (amb,) = n.ambiguities assert amb.kind is AmbiguityKind.PARTICLE_OR_GIVEN assert amb.detail == ( "leading 'Van' may be a family-name particle; " "read as a family name") # the default order is untouched by that change (default,) = parse("Van Johnson").ambiguities assert default.detail == ( "leading 'Van' may be a family-name particle; " "read as a given name") def test_trailing_roman_numeral_reports_the_fork() -> None: # a trailing single letter is a name part unless it happens to be a # roman numeral, in which case it is silently reclassified -- and # V/X/I are common middle initials, so this is a real coin-flip numeral = parse("John Smith V") assert numeral.suffix == "V" assert [a.kind for a in numeral.ambiguities] == \ [AmbiguityKind.SUFFIX_OR_NAME] assert [t.text for t in numeral.ambiguities[0].tokens] == ["V"] # the same shape with a non-numeral letter faces no fork assert parse("John Smith B").ambiguities == () # and a numeral after an initial is not treated as a suffix at all assert parse("John Q. V").ambiguities == () #: The words the _group-emitter tests below spell, apart from the #: by-construction test, which invents its own. The emitter asks two #: DIFFERENT things of two different words. Measured against #: "Freiherr von Richthofen", one membership dropped at a time: #: #: leading word ('Freiherr') titles & PARTICLES, and the two halves #: buy different things. Without `particles` there is no CHAIN -- #: 'von' is the leading name piece again (given='von', #: family='Richthofen') and _assign reports the fork. Without #: `titles` the chain still happens (family='von Richthofen') but #: this emitter's `all(title(x) for x in range(k))` guard fails, #: so the REPORT is lost and _assign reports a fork about #: 'Freiherr' instead. The merge sits outside that guard. #: Its `particles_ambiguous` membership is irrelevant either way, #: pinned by control 3 in the test below. #: chained word ('von') PARTICLES_AMBIGUOUS. Drop it and the #: chain still happens (family='von Richthofen') but no fork is #: reported -- an unambiguous particle is not a decision. #: #: The distinction matters because this file used to assert #: `titles & particles_ambiguous` for the LEADING word, which is the #: wrong set. It reads as correct only because the two intersections are #: the same three words today. #360 moves words between the may-be-given #: and never-given halves of the particle vocabulary; both halves are #: subsets of `particles`, so it cannot empty `titles & particles` and #: cannot orphan this emitter. #: #: Both roles are supplied rather than borrowed, because reachability is #: a property of the EMITTER, not of the shipped word lists: a caller may #: configure the overlap themselves, and no `Lexicon` invariant forbids #: it -- constructing one emits no warning. Supplying only the leading #: half would leave the tests coupled to #360 through the chained word, #: which is the bug the first cut of this commit shipped. #: #: What the SHIPPED vocabulary reaches is a separate claim, pinned in #: tests/v2/cases.py's "Freiherr von Richthofen" row. Note that row #: tracks the PARSE, not the memberships: moving `freiherr` between the #: particle halves leaves it green, and only a change to the leading #: word's `titles`/`particles` membership, or to `von`'s ambiguous one, #: moves it. _TITLE_PARTICLES = frozenset({"freiherr", "do", "st"}) #: The words those tests CHAIN. Disjoint from the leading set on purpose #: -- the two roles need different memberships, and holding them apart is #: what keeps that legible. _CHAINED_PARTICLES = frozenset({"von", "van"}) def _overlap_parser(policy: Policy | None = None) -> Parser: """A Parser whose lexicon gives each word the memberships its ROLE needs, whatever the shipped data says today: the leading words become titles and particles, the chained words ambiguous particles. `particles` covers both because `_SUBSET_FIELDS` requires `particles_ambiguous <= particles`; the leading words are deliberately NOT made ambiguous, since that membership does nothing for them and asserting it is how the wrong set got written down in the first place. """ lex = Lexicon.default().add( titles=_TITLE_PARTICLES, particles=_TITLE_PARTICLES | _CHAINED_PARTICLES, particles_ambiguous=_CHAINED_PARTICLES, ) return Parser(lexicon=lex, policy=policy or Policy()) def test_the_chained_emitter_is_reachable_by_construction() -> None: """_group's PARTICLE_OR_GIVEN emitter fires when a piece that is both a title and a PARTICLE sits ahead of the chained particle. Asserted against a lexicon built here, so what it pins is the emitter rather than today's word lists. Three controls carry the weight, one per membership the claim rests on: drop the leading word's `particles` and the chain is gone; drop the chained word's `particles_ambiguous` and the report is gone; drop the leading word's `particles_ambiguous` and nothing moves at all -- which is the whole correction, since asserting THAT membership is what this file used to do. If this test fails, the emitter is gone or broken, or one of the two words this test builds has lost a membership it supplies itself. An empty `titles & particles_ambiguous` in the SHIPPED vocabulary does not fail it and does not mean the emitter is unreachable. """ word = "zzoverlap" base = Lexicon.default().add( particles=_CHAINED_PARTICLES, particles_ambiguous=_CHAINED_PARTICLES) overlap = base.add(titles={word}, particles={word}) text = f"{word} van Johnson" # the emitter, reached by construction chained = Parser(lexicon=overlap).parse(text) assert (chained.given, chained.family) == ("", "van Johnson") (amb,) = chained.ambiguities assert amb.kind is AmbiguityKind.PARTICLE_OR_GIVEN assert amb.detail == ( "'van' was chained onto the following name piece; " "it is also a given name in other names") # control 1 -- leading word a plain title, NOT a particle: no chain # at all, and _assign reports the other side of the same fork. title_only = Parser(lexicon=base.add(titles={word})).parse(text) assert (title_only.given, title_only.family) == ("van", "Johnson") assert [a.detail for a in title_only.ambiguities] == [ "leading 'van' may be a family-name particle; read as a given name"] # control 2 -- overlap intact but the CHAINED word unambiguous: the # chain still fires, so the parse matches, and the only difference is # that there is no decision left to report. Without this control the # assertion above could not tell "the emitter ran" from "the chain # happened to produce this grouping". unambiguous = dataclasses.replace( overlap, particles_ambiguous=overlap.particles_ambiguous - {"van"}) quiet = Parser(lexicon=unambiguous).parse(text) assert (quiet.given, quiet.family) == (chained.given, chained.family) assert quiet.ambiguities == () # control 3 -- the leading word made ambiguous as well, which is the # membership the deleted guard test asserted. Byte-identical to the # treatment, fork included: it buys the emitter nothing. This is the # executable half of the correction; without it the claim that # `titles & particles` is the right set lives only in a comment, and # a fixture supplying all three sets could never contradict it. also_ambiguous = overlap.add(particles_ambiguous={word}) same = Parser(lexicon=also_ambiguous).parse(text) assert (same.given, same.family) == (chained.given, chained.family) assert [a.detail for a in same.ambiguities] == [amb.detail] def test_ambiguous_particle_reports_both_branches_of_its_fork() -> None: # "von Richthofen" reads von as a given name and says so. Put a # piece in front of it that is BOTH a title and a particle and von # is no longer the name's leading piece, the prefix-chain merge # fires, and von becomes a particle instead -- the SAME fork, # called the other way. The two branches are taken in different # stages (_assign vs _group), so only the one with an emitter used # to report. # # Spelled with 'Freiherr' rather than the 'Dr.' this test used # until 2.2: an ordinary title is now transparent to the # leading-particle exception (#367), so "Dr. Van Johnson" takes the # _assign branch like everything else. `freiherr`/`st`/`do` -- a # title that could also be the name's own first piece -- is what # still reaches _group's emitter. This is the canonical spelling of # that, not the only one: "St Van Johnson", "Do St Johnson" and # "Dr. Do van Johnson" reach it as well. # # Parsed through _overlap_parser so the memberships are supplied # rather than borrowed -- see _TITLE_PARTICLES. p = _overlap_parser() given_reading = p.parse("von Richthofen") assert given_reading.given == "von" assert [a.kind for a in given_reading.ambiguities] == \ [AmbiguityKind.PARTICLE_OR_GIVEN] particle_reading = p.parse("Freiherr von Richthofen") assert particle_reading.family == "von Richthofen" assert [a.kind for a in particle_reading.ambiguities] == \ [AmbiguityKind.PARTICLE_OR_GIVEN] assert [t.text for t in particle_reading.ambiguities[0].tokens] == ["von"] # and the branch the title no longer takes: "Dr. Van Johnson" is # now byte-identical to the bare "Van Johnson", fork included titled, bare = p.parse("Dr. Van Johnson"), p.parse("Van Johnson") assert (titled.given, titled.family) == (bare.given, bare.family) \ == ("Van", "Johnson") assert [a.detail for a in titled.ambiguities] == \ [a.detail for a in bare.ambiguities] def test_unambiguous_particle_chain_reports_nothing() -> None: # 'de' is never a given name, so merging it is not a fork assert parse("Dr. de la Vega").ambiguities == () def test_bound_given_name_that_is_also_a_particle() -> None: # The one case #367 regressed, restored by #369 for the right # reason. Through 2.1 it read correctly only because the title # displaced 'abu' out of the leading position and the prefix chain # claimed 'Bakar' -- a side effect of the #367 bug, not a rule. Now # the bound given-name join reads it: "Sir" is a given-name # title, which licenses the join with one word to spare # (rules/P5), so the particle never enters into it -- and a # bound word read as the bound word is not a fork, so the # PARTICLE_OR_GIVEN report the chain used to emit is gone. The # same precedence, untitled, is what "Abu Bakar Salim" has always # had; where the reserve blocks the join, P4 and its report stand. titled = parse("Sir Abu Bakar") assert (titled.given, titled.ambiguities) == ("Abu Bakar", ()) assert parse("Abu Bakar Salim").ambiguities == () assert [a.kind for a in parse("Abu Bakar").ambiguities] == \ [AmbiguityKind.PARTICLE_OR_GIVEN] def test_a_given_name_title_licenses_the_bound_given_join() -> None: # rules/P5 (#369): the contrast #367's release note draws -- # "Sheik abdul salam", whose lead is a bound given name and NOT a # particle -- now joins the same way, and for the same reason. A # plain title does not license it: "Dr." addresses by family, so # the second word stays the family name. # `sheik` has been a title only inside a title phrase since #752 # phase 2j, so `Sir` stands in for it here, reading as it did. licensed = parse("Sir abdul salam") assert (licensed.title, licensed.given, licensed.family) == \ ("Sir", "abdul salam", "") plain = parse("Dr. abdul salam") assert (plain.title, plain.given, plain.family) == \ ("Dr.", "abdul", "salam") # and the pair's report. Until #614 a bound word read as the bound # word was no fork, so 'Sir abdul Ma' reported nothing where # 'Sir John Ma' reports its pick (the #369 precedent). S2's run is # read once now, before the join, and the read weighs 'Ma' as a # credential and declines it before the join makes it part of the # given pair -- so the pick reports in both (decisions/P5, # 2026-10-07) for text in ("Sir abdul Ma", "Sir John Ma"): assert [a.kind for a in parse(text).ambiguities] == \ [AmbiguityKind.SUFFIX_OR_NAME], text assert parse("Sir abdul Ma").given == "abdul Ma" def test_the_bound_given_reserve_spares_the_family_assign_will_keep() -> None: # #401: the reserve asks whether a family name survives the join, # and the only right answer is the one assign gives. 'V' is the # suffix there, so there is no word to spare -- and behind a # given-name title (#369's licence) the same count declines. n = parse("abdul Smith V") assert (n.given, n.family, n.suffix) == ("abdul", "Smith", "V") assert [a.kind for a in n.ambiguities] == [AmbiguityKind.SUFFIX_OR_NAME] # Since #718 a one-letter numeral needs words to spare (rules/S10) # and a title is none, so 'V' is a name word there, and the # licence joins it into the given pair as it would any word licensed = parse("Sir abdul V") assert (licensed.given, licensed.family, licensed.suffix) == \ ("abdul V", "", "") def test_the_bound_given_join_leaves_a_suffix_where_it_stands() -> None: # #421: the join declines a suffix piece, so an inner suffix goes # where it goes for any given name -- 'John Jr Smith Berg' reads # middle 'Jr Smith' -- and the split credential is a suffix again, # which is 1.4.0's reading restored. n = parse("abdul Jr Smith Berg") assert (n.given, n.middle, n.family, n.suffix) == \ ("abdul", "Jr Smith", "Berg", "") n = parse("abdul Ph. D. Smith Berg") assert (n.given, n.middle, n.family, n.suffix) == \ ("abdul", "Smith", "Berg", "Ph. D.") # after a family comma the decline holds under the LENIENT reserve, # and since #602 the 'Jr' starts the given part's credential run, # taking 'Smith' with it (rules/S8) n = parse("Berg, abdul Jr Smith") assert (n.given, n.middle, n.family, n.suffix) == \ ("abdul", "", "Berg", "Jr Smith") def test_the_reserve_counts_what_the_trailing_read_left() -> None: # 'abdul J. V': until #614 the reserve read the V as the suffix it # would be behind the joined pair and declined, so the name read as # 'John J. V' does. S2's run is read once now, before the join: it # takes nothing (the initial-shaped 'J.' suppresses the fork), three # name words stand, and the join leaves two, so it stands -- given # 'abdul J.', family 'V'. The join no longer re-reads the fork it # would move (decisions/P5, 2026-10-07). n = parse("abdul J. V") assert (n.given, n.middle, n.family, n.suffix) == ("abdul J.", "", "V", "") n = parse("John J. V") assert (n.middle, n.family, n.suffix) == ("J.", "V", "") # and behind a merged credential, which assign drops from its walk # so the V is last in it, the numeral is the name word it is behind # 'PhD' (rules/S10, #752), so the name reads as its one-word twin n = parse("abdul Smith V Ph. D.") assert (n.given, n.family, n.suffix) == ("abdul Smith", "V", "Ph. D.") twin = parse("abdul Smith V PhD") assert (twin.given, twin.family, twin.suffix) == ("abdul Smith", "V", "PhD") def test_the_reserve_spares_the_family_the_acronym_fork_would_take() -> None: # #425: with a suffix word between the pair and a bare ambiguous # acronym, assign peels the acronym (three pieces, words to spare) # and then the suffix, and the family the join left was never # there. The reserve declines, so these read as their # ordinary-given twins -- since #614 by counting the names S2's # run left before the join, read once, rather than re-reading the # peel over the joined view. for bound, plain in (("abdul Smith Jr Ma", "John Smith Jr Ma"), ("abdul Rahman PhD MA", "John Rahman PhD MA")): n, m = parse(bound), parse(plain) assert (n.family, n.suffix) == (m.family, m.suffix) assert n.family != "" # MOVED by #289, then back by #544: 'Ed' is Title-case in a # mixed-case name, so its writing leans SURNAME, but the # unambiguous 'Jr' in front of it anchors it, so the peel takes # both and the reserve spares the family as for 'abdul Smith Jr # Ma' above -- the ordinary-given twin reads the same n, m = parse("abu Bakar Jr Ed"), parse("John Bakar Jr Ed") assert (n.family, n.suffix) == (m.family, m.suffix) == ("Bakar", "Jr Ed") # and the join never turns a suffix into a name: unjoined, the # acronym is a credential with words to spare, so 'abdul Smith # Ma' reads as 'John Smith Ma' does (1.4.0 parity restored) # # MOVED by #289, not deleted: 'Ma' is Title-case in a mixed-case # name, so it leans SURNAME on both sides of the join and stays a # name word instead of a credential (decisions/S6). n, m = parse("abdul Smith Ma"), parse("John Smith Ma") assert (n.family, n.suffix) == (m.family, m.suffix) == ("Ma", "") def test_the_leading_piece_never_anchors_under_any_name_order() -> None: # #544: the walk's leading position is the piece the # H4 carve-out keeps regardless of which ROLE it ends up in, so # the fix must hold under every name_order -- FAMILY_FIRST puts # the same 'PhD'/'Om' word in the FAMILY slot instead, and it # still must not anchor 'Ma' behind it. for order in (FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST): p = Parser(policy=Policy(name_order=order)) n = p.parse("Om Ma") assert (n.given, n.family, n.suffix) == ("Ma", "Om", "") n = p.parse("PhD Ma") assert (n.given, n.family, n.suffix) == ("Ma", "PhD", "") def test_a_joined_pair_is_never_peeled_as_a_title() -> None: # The conjunction merge derives a title tag for 'Sir and Ahmad'; # the bound join takes the piece (a mid-name title word is a name # word, as 1.4.0 read it) and the pair must stay the given name, # not inherit the tag and be peeled as a leading title. n = parse("abdul Sir and Ahmad Bakar") assert (n.title, n.given, n.family) == \ ("", "abdul Sir and Ahmad", "Bakar") # the title-word class: name words to P5, as v1 read them -- # parity restored for the post-comma shape, never lost for the # main-walk one n = parse("abdul Sir Smith Berg") assert (n.given, n.middle, n.family) == ("abdul Sir", "Smith", "Berg") n = parse("Berg, abdul Sir") assert (n.given, n.family) == ("abdul Sir", "Berg") def test_the_licence_joins_where_the_read_took_nothing() -> None: # Behind a given-name title the reserve needs one name piece, so # "changes no suffix reading" was the only thing between 'Sir abdul # J. V' and a join that turned the V from a name word into the # suffix (#369 had joined it). Since #614 nothing re-reads the # suffix after the join: the run, read once before it, takes # nothing, and the pair joins with the V still the family -- the # suffix reading the equality guarded against no longer arises # (decisions/P5, 2026-10-07). n = parse("Sir abdul J. V") assert (n.given, n.family, n.suffix) == ("abdul J.", "V", "") n = parse("Sir John J. V") assert (n.middle, n.family, n.suffix) == ("J.", "V", "") def test_the_chain_and_the_walk_stop_where_the_peel_begins() -> None: # #424: the third and fourth sites that asked "is this a suffix?" # with the initial-vetoed test. Each reads as its ordinary twin # ('John Smith V', 'John Smith Ma') reads. for text, family, suffix in ( ("John van der Berg V", "van der Berg", "V"), ("John van der Berg X", "van der Berg", "X"), ("abdul van der Berg V", "van der Berg", "V"), # MOVED by #289, not deleted: 'Ma' is Title-case in a # mixed-case name, so it now leans SURNAME, the # run's read leaves it a name word (S2's read ahead of the # chain since #614, its re-ask before that), so the chain # takes it into the particle run (decisions/S6). ("John van der Berg Ma", "van der Berg Ma", "")): n = parse(text) assert (n.family, n.suffix) == (family, suffix), text n = parse("Jane Doe née Jones Smith V") assert (n.maiden, n.suffix) == ("Jones Smith", "V") n = parse("Jane Smith née Jones V") assert (n.family, n.maiden, n.suffix) == ("Smith", "Jones", "V") # the numeral must read as the suffix as the take leaves the name # too: an initial before the marker vetoes the fork, so the walk # keeps the V as maiden text rather than hand it to the family n = parse("J. née Jones Smith V") assert (n.family, n.maiden, n.suffix) == ("", "Jones Smith V", "") # an unlisted abbreviation is as transparent to the leading # particle as a listed title (H2 meets #367), so the acronym fork # counts the same pieces in group and in assign for abbrev in ("Xyz.", "Dr."): n = parse(f"{abbrev} van Berg MA") assert (n.given, n.family, n.suffix) == ("van", "Berg", "MA"), abbrev n = parse(f"{abbrev} van Johnson") assert (n.given, n.family) == ("van", "Johnson"), abbrev n = parse(f"{abbrev} abdul John Smith") assert (n.given, n.middle) == ("abdul John", ""), abbrev # behind a title-and-particle word the chain takes the name's first # word (#367), and the acronym it leaves has no words to spare for # assign: the chain keeps it rather than leave it as the family # # MOVED by #289, ACCEPTED COST (decisions/S6): 'MA' is written # in capitals inside a mixed-case name, so it now leans CREDENTIAL # and the caps lean makes it reachable where the count alone was # not -- the lean ends the chain's re-ask with no words to spare, # where the count would not have. This was listed as staying # fix(#424)'s and measured not to: 1.4.0 read "von Berg", "MA". n = parse("Freiherr von Berg MA") assert (n.title, n.family, n.suffix) == ("Freiherr", "von Berg", "MA") # the numeral keeps its three pieces behind the same word, and the # chain is the one name piece. Since #718 a one-letter numeral # needs words to spare (rules/S10) and a title is none, so behind # one name word the V is a name word: the chain takes it as P2 # takes any, where 'Dr. Smith V' gives it the family n = parse("Freiherr von Richthofen V") assert (n.given, n.family, n.suffix) == ("", "von Richthofen V", "") n = parse("Dr. Smith V") assert (n.given, n.family, n.suffix) == ("Smith", "V", "") # both stops read the TRAILING word, so an acronym with the # numeral behind it is not the word either fork asks about and # stays maiden text -- and so does a numeral with an acronym # behind it ('Jane Doe nee Smith V MA' keeps maiden 'Smith V') n = parse("Jane Smith née Jones Ma V") assert (n.maiden, n.suffix) == ("Jones Ma", "V") n = parse("Jane Doe nee Smith V MA") assert (n.maiden, n.suffix) == ("Smith V", "MA") def test_the_numeral_fork_fires_on_the_last_piece_only() -> None: # The shared peel's own contract, pinned at the stage that owns # it: a numeral with a suffix behind it is a name word, for an # ordinary given name and the bound pair alike. for text, family in (("John Smith V Jr", "V"), ("abdul Smith V Jr", "V")): n = parse(text) assert (n.family, n.suffix) == (family, "Jr") @pytest.mark.parametrize("text, fragment", [ # rules/S10's Title-case lean, worded for what declined the numeral ("John Smith Vi", "written in Title case, which leans to the name"), ("Jane Doe nee Smith Vi", "written in Title case it leans to the name"), # one-letter, declined for want of words to spare, as #718 words it ("John V", "the name has no other word to spare"), # a declined numeral a credential run in front then takes is the # run's absorbed name word (rules/S8), not a numeral read as a name ("John Smith III Vi", "follows a credential"), ("John Smith PhD Vi", "follows a credential"), ]) def test_a_declined_numeral_is_reported_for_what_declined_it( text: str, fragment: str) -> None: # #752's review: the Title-case pick first took #718's "no other # word to spare" wording, and one a run took read "as a suffix # rather than a generational suffix" details = [a.detail for a in parse(text).ambiguities] assert any(fragment in d for d in details), (text, details) assert not any("rather than a generational suffix" in d and "suffix rather" in d for d in details), details @pytest.mark.parametrize("bound", ["abd", "abu"]) @pytest.mark.parametrize("numeral", ["I", "X"]) def test_every_bound_word_spares_the_family_before_every_numeral( bound: str, numeral: str) -> None: # The release log's claim: I and X as well as V, and the bound # words that are ALSO suffix vocabulary ('abd') or an ambiguous # particle ('abu') -- the dual-vocabulary paths where a veto could # hide. Two name words behind the bound word, so the join fires. n = parse(f"{bound} Allah Smith {numeral}") assert (n.given, n.family, n.suffix) == (f"{bound} Allah", "Smith", numeral) @pytest.mark.parametrize("title", [ "Sir", "Swami", "Pope", "الشيخ", "Dr.", "Mr.", "Mr. Sir", "Sir Dr.", "Sir and Dame", "Mr. and Mrs.", "Swami and Mrs"]) def test_the_p5_licence_and_h1_read_a_title_run_the_same_way( title: str) -> None: # The licence's one invariant, as a contract: P5 lifts the reserve # behind a title run exactly when H1 keeps the one word after that # run a given name. Both ask _run_addresses_by_given, of the # LEADING run, for the whole run's key or the run's last word's # (#489); if either side's read drifted, a run P5 licensed that H1 # then read as title-plus-family would hand the joined pair to the # family. So "no family" must agree, run by run. assert (parse(f"{title} John").family == "") == \ (parse(f"{title} abdul rahman").family == "") # The same invariant with a trailing title behind the pair, and # stated the way rules/H5's transparency clause states it # rather than as agreement between the two halves: `X Prof.` is # `X` plus a title, so adding the title moves NEITHER field. That # is what discriminates. Comparing the two spellings' "no family" # to each other passes under the composite keying this replaced, # where H1 read the TITLE role -- both ends of the name by then -- # as one run: `sir prof` addresses by neither word, so both # spellings handed their name words to the family together and # agreed while both were wrong. Pinned by mutation: restore the # whole-`titles` key and every given-name-title row below fails # here (2026-09-09). # # The NICKNAME spellings are here because the first fix for this # split the run at the first token of another role, which a # nickname written in front of the titles satisfies -- so # "'Smitty' Sir Jones Prof." found no leading run and read family # 'Jones' where "'Smitty' Sir Jones" reads given. What decides is # the name WORD, H1's rationale saying in as many words that a # nickname beside it does not decide the reading, and only these # rows can tell the two splits apart. for suffixless in (f"{title} John", f"{title} abdul rahman", f"'Smitty' {title} John", f"'Smitty' {title} abdul rahman"): plain, titled = parse(suffixless), parse(f"{suffixless} Prof.") assert (titled.given, titled.family) == (plain.given, plain.family) # The first three reach the chain loop and decline inside it: the piece # after the particle is a suffix, so the scan never advances and merge_pieces # is a no-op -- nothing was chained, no fork taken. They are spelled with # a title that is ALSO a particle ('Do', 'St'), because that is what # still puts an ambiguous particle off the name's leading position since # #367. The last three are the same strings with a plain title, which # now decline one step earlier -- the particle IS the leading name piece # and the loop skips it -- and are kept so the pair stays visible: two # different reasons, one output, and neither may start reporting a fork. @pytest.mark.parametrize("text", [ "Do Van Jr.", "Do Van MD", "St Van Jr.", "Dr. Van Jr.", "Dr. Van MD", ]) def test_no_op_prefix_chain_is_not_a_fork(text: str) -> None: assert not _overlap_parser().parse(text).ambiguities def test_dr_do_jr_reports_the_word_left_standing() -> None: """Since #489 this one reports from somewhere else entirely. It was the sixth row of the parametrization above until the 2026-09-09 review, where carrying it made that test assert "no fork EXCEPT this one" and so stopped saying the thing it exists to say. The leading peel's floor gives 'Do' back as the name word (the run stood in front of nothing but 'Jr.'), so 'Dr. Do Jr.' reads family 'Do', suffix 'Jr.' and H4's title half claims the lone name word, which happens to be title vocabulary here. That report is about the word left standing, not about a chain that never chained -- PARTICLE_OR_GIVEN is still absent, which is what the kind tuple below pins. """ n = _overlap_parser().parse("Dr. Do Jr.") assert (n.family, n.suffix) == ("Do", "Jr.") assert tuple(a.kind for a in n.ambiguities) == ( AmbiguityKind.TITLE_OR_NAME,) def test_a_fork_is_reported_by_exactly_one_stage() -> None: # the no-op merge left the particle a lone leading piece, which is # _assign's trigger, so both stages reported the same token. # 'Do' rather than the 'Dr.' this used until 2.2, for the reason # above: with a plain title the chain loop never fires at all now, # so the double-report it guards against is out of reach there. n = _overlap_parser().parse("Do Van Jr Smith") assert n.given == "Van" assert len(n.ambiguities) == 1 def test_chained_particle_detail_does_not_claim_a_role() -> None: # _group runs before assignment, so it cannot know which field the # chained piece lands in -- "Freiherr von Richthofen de la Cruz" # puts it in GIVEN, while the bare "Freiherr von Richthofen" above # puts it in FAMILY. The detail must describe the decision, not # guess a role. n = _overlap_parser().parse("Freiherr von Richthofen de la Cruz") assert n.given == "von Richthofen" (amb,) = n.ambiguities assert "family name" not in amb.detail @pytest.mark.parametrize("policy", [ Policy(), Policy(name_order=FAMILY_FIRST), Policy(name_order=FAMILY_FIRST_GIVEN_LAST), ]) def test_chained_particle_detail_is_order_invariant(policy: Policy) -> None: # _group's emitter is the reason the docs can scope leading-particle # DESTINATIONS to the default order without qualifying this text: # it names no field, and the chain it reports is a grouping-stage # decision taken before any role exists. "Freiherr von Richthofen" # takes the chained branch under every order, so the string is the # same one three times -- pin it, or the invariant is only an # intention. ("Dr. Van Johnson" carried this until 2.2; #367 made a # plain title transparent, so it no longer chains at all.) n = _overlap_parser(policy).parse("Freiherr von Richthofen") assert (n.given, n.family) == ("", "von Richthofen") (amb,) = n.ambiguities assert amb.kind is AmbiguityKind.PARTICLE_OR_GIVEN assert amb.detail == ( "'von' was chained onto the following name piece; " "it is also a given name in other names") # The shape above is the one #367 did NOT move, so pin the one it # did in the same three orders: a plain title is transparent, so # "Dr. Van Johnson" is byte-identical to the bare "Van Johnson" # under every name_order, and the fork comes from _assign -- whose # detail DOES name the role, unlike the grouping-stage text above. # through _overlap_parser as well: 'Van' has to be an ambiguous # particle for _assign to report anything here, and that membership # is exactly what #360 may move p = _overlap_parser(policy) titled = p.parse("Dr. Van Johnson") bare = p.parse("Van Johnson") assert titled.title == "Dr." assert (titled.given, titled.middle, titled.family) == \ (bare.given, bare.middle, bare.family) (titled_amb,) = titled.ambiguities assert titled_amb.detail == bare.ambiguities[0].detail role = "family" if policy.name_order[0] is Role.FAMILY else "given" assert titled_amb.detail == ( f"leading 'Van' may be a family-name particle; " f"read as a {role} name") def test_each_suffix_or_name_branch_describes_itself() -> None: # one kind, two causes: the acronym branch turns on periods, the # roman-numeral branch turns on the letter being a numeral. Sharing # one template made "V written without periods" -- a distinction # that does not exist for V -- and hid which branch fired. acronym = parse("JOHN SMITH MA").ambiguities[0].detail assert "periods" in acronym and "post-nominal" in acronym numeral = parse("John Smith V").ambiguities[0].detail assert "periods" not in numeral assert "numeral" in numeral and "initial" in numeral def test_parser_matches_uses_its_own_config() -> None: p = Parser(lexicon=Lexicon.default().add(titles=["moff"])) a = p.parse("Moff Tarkin") # ParsedName.matches falls back to the DEFAULT parser for the str # argument, which reads "Moff" as a given name -- mismatch. assert not a.matches("Moff Tarkin") # Parser.matches parses the str with the same config -- match. assert p.matches(a, "Moff Tarkin") assert p.matches("Moff Tarkin", "MOFF TARKIN") def test_parser_matches_rejects_wrong_types() -> None: p = Parser() with pytest.raises(TypeError, match="takes str or ParsedName"): p.matches(42, "John Smith") # type: ignore[arg-type] with pytest.raises(TypeError, match="takes str or ParsedName"): p.matches("John Smith", 42) # type: ignore[arg-type] def test_parser_capitalized_uses_its_own_lexicon() -> None: # pair-tuples, not a dict: the field is tuple-annotated and typed # call sites pass canonical pairs (see _default_lexicon's note) exceptions = dict( Lexicon.default().capitalization_exceptions_map) | {"zqx": "ZqX"} lex = dataclasses.replace( Lexicon.default(), capitalization_exceptions=tuple(sorted(exceptions.items()))) p = Parser(lexicon=lex) n = p.parse("john zqx") assert p.capitalized(n).family == "ZqX" # ParsedName.capitalized() with no argument uses the DEFAULT # lexicon, which has no such exception. assert n.capitalized().family == "Zqx" def test_parser_capitalized_rejects_non_parsed_name() -> None: with pytest.raises(TypeError, match="takes a ParsedName"): Parser().capitalized("john smith") # type: ignore[arg-type] def test_revise_preserves_particle_tags() -> None: p = Parser() n = p.parse("Juan de la Vega") r = p.revise(n, family="de la Vega Smith") assert r.family == "de la Vega Smith" assert r.family_particles == "de la" assert r.initials() == "J. V. S." # particles contribute no initial def test_revise_derives_suffix_entries_from_the_values_commas() -> None: # A suffix value's entry structure is the value's own commas, read # by rules/R1's pass after the named role is forced (#511). The # sub-parse of a bare "MD PhD" reads a title and a family name and # so joins nothing on its own; revise() runs the pass again over # the forced roles, where the two share a comma bucket. p = Parser() n = p.parse("John Smith") assert p.revise(n, suffix="MD PhD").suffix == "MD PhD" assert p.revise(n, suffix="MD, PhD").suffix == "MD, PhD" # The Ph. D. MERGE is a head-position rule (#371) with no head to # consult in a field value, and it still does not fire here; the # ENTRY pass joins the pair for the reason it joins MD and PhD. # Supersedes the 2026-08-31 acceptance of 'Ph., D.' on this path # (decisions/phd-merge). assert p.revise(n, suffix="Ph. D.").suffix == "Ph. D." # a dash is a token under the default policy, and a suffix word # once the role is forced assert (p.revise(n, suffix="MD PhD - FACS Fellow").suffix == "MD PhD - FACS Fellow") def test_revise_views_match_a_fresh_parse() -> None: p = Parser() r = p.revise(p.parse("John Smith"), family="de la Vega") f = p.parse("John de la Vega") for view in ("given", "family", "family_particles", "family_base"): assert getattr(r, view) == getattr(f, view) assert r.initials() == f.initials() def test_revise_replace_shared_semantics() -> None: p = Parser() n = p.parse("Dr. Juan de la Vega Jr.") r = p.revise(n, given="José", suffix="") assert r.given == "José" assert r.suffix == "" # empty value clears the field assert p.revise(n, suffix="()").suffix == "" # punctuation-only too assert r.original == n.original # provenance unchanged assert all(t.span is None for t in r.tokens_for("given")) assert r.title == "Dr." # untouched fields keep spans def test_revise_validation_matches_replace() -> None: p = Parser() n = p.parse("John Smith") with pytest.raises(TypeError, match="takes a ParsedName"): p.revise("John Smith", family="Doe") # type: ignore[arg-type] with pytest.raises(TypeError, match="unknown field 'last'"): p.revise(n, last="Doe") with pytest.raises(TypeError, match="must be a str"): p.revise(n, family=None) # type: ignore[arg-type] def test_revise_strips_the_fold_marker() -> None: # middle_as_family's fold tag must not survive the harvest: a # carried tag would make the family view reorder the value. p = Parser(policy=Policy(middle_as_family=True)) r = p.revise(p.parse("Juan Perez"), family="Gabriel García Márquez") assert r.family == "Gabriel García Márquez" def test_revise_clears_a_stale_unjoined_mark() -> None: # UNJOINED_TAG says a particle stands alone in its PART, so an edit # that re-roles tokens invalidates it -- the harvest splices a # sub-parse's tokens into one field, and a particle marked alone # there can land beside a name word. Recomputed rather than # stripped (the fold marker above is stripped, which is only right # for one direction). Without this, an identity revise drifted: # base 'Toro' became 'del Toro' and initials 'T.' became 'd. T.' p = Parser() r = p.parse("Mr. do Jr. del Toro") assert (r.family, r.family_base, r.family_particles) == ( "del Toro", "Toro", "del") again = p.revise(r, family=r.family) assert (again.family, again.family_base, again.family_particles) == ( "del Toro", "Toro", "del") assert again.initials() == r.initials() def test_revise_sets_a_missing_unjoined_mark() -> None: # The other direction, and the one that made rules/R2's # invariant false through this path: "St" alone parses as a TITLE # (a word in both the title and particle vocabularies), so the # sub-parse marks nothing, and the harvest then re-roles a bare # particle into FAMILY. The recompute marks it there, so a # non-empty family still has a non-empty base. Spelled with "Do" # until #296's audit took 'do' out of TITLES; "Do" alone is a # marked given name now, which is the other path (kept below). p = Parser() revised = p.revise(p.parse("Juan de la Vega"), family="St") assert revised.family == "St" assert revised.family_base == "St" assert revised.family_particles == "" revised = p.revise(p.parse("Juan de la Vega"), family="Do") assert (revised.family, revised.family_base) == ("Do", "Do") def test_revise_recomputes_the_connective_mark_in_both_directions( ) -> None: # The mark says a connective stands in a part with nothing to # join, which is a fact about the PART -- so the harvest that # splices a sub-parse's tokens into one field invalidates it both # ways, exactly as it does the particle mark above (rules/R3, # #461). p = Parser() lone = p.parse("Juan de y") assert lone.initials() == "J. y." # STALE: the marked 'y' lands beside a name word widened = p.revise(lone, family="y Garcia") assert widened.initials() == "J. G." # MISSING: a connective revised into a part of its own narrowed = p.revise(p.parse("Juan Velasquez y Garcia"), family="y") assert narrowed.initials() == "J. y." # and the identity revise round-trips, which is the property the # particle-mark tests above pin for R2 again = p.revise(lone, family=lone.family) assert (again.family, again.initials()) == (lone.family, lone.initials()) def test_revise_writes_a_connective_mark_the_sub_parse_did_not( ) -> None: # The MISSING direction that role-forcing alone cannot produce, # and the one input shape that does: the sub-parse of 'and y' # reads 'and' as the given name and 'y' -- a particle under this # caller's vocabulary -- as an all-particle family, so R2's mark # is what the sub-parse writes on it. Forcing both into one field # makes that part no longer all-particle, so R2's mark is cleared # and #461's has to be written in its place, by the recompute # rather than by any stage. Without it the 'y' carries no mark at # all, drops as an ordinary family particle, and the field and # the view disagree again. p = Parser(lexicon=Lexicon.default().add(particles={"y"})) revised = p.revise(p.parse("John Smith"), family="and y") assert revised.family == "and y" assert revised.initials() == "J. a. y." marks = {t.text: t.tags for t in revised.tokens if t.role is Role.FAMILY} assert UNJOINED_CONJUNCTION_TAG in marks["y"] assert UNJOINED_TAG not in marks["y"] def test_revise_keeps_r2s_precedence_over_the_connective_mark( ) -> None: # The recompute mirrors the pipeline walk's `elif`, and this is # the row where the two marks would otherwise both be written: # 'de y' under a vocabulary that makes 'y' a particle is an # all-particle part, so R2's mark readmits every word of it -- # the connective included -- and #461's is not written there. The # rendered answer is the same either way, which is why this # asserts the TAGS: the facade's own connective predicate reads # the second mark, so writing it here would move that view alone. p = Parser(lexicon=Lexicon.default().add(particles={"y"})) revised = p.revise(p.parse("John Smith"), family="de y") assert revised.initials() == "J. d. y." y = [t for t in revised.tokens if t.text == "y"][0] assert UNJOINED_TAG in y.tags assert UNJOINED_CONJUNCTION_TAG not in y.tags def test_revise_sub_parse_structural_behavior() -> None: # the docstring's three structural promises, pinned: delimiters # never become tokens, marker words are consumed as in parsing, # and the sub-parse's ambiguities are discarded. p = Parser() n = p.parse("John Smith") assert p.revise(n, family="Smith (Jones)").family == "Smith Jones" revised = p.revise(n, family="Mary née Smith") assert revised.family == "Mary Smith" assert revised.maiden == "" assert p.revise(n, given="J.R. 'Bob'").given == "J.R. Bob" assert p.revise(n, family="Smith (Jones").ambiguities == () def test_revise_sub_parses_under_this_parsers_policy() -> None: # the sub-parse runs on SELF, not on a default Parser: revise's # docstring promises a marker LEADING a delimited value is # consumed, and only a policy routing that pair to maiden makes # the value delimited at all. Sub-parsing with Parser() instead # leaves the rest of the suite green -- every other revise # assertion uses a default parser, so nothing else can tell the # two apart. p = Parser(policy=Policy(maiden_delimiters=frozenset({("(", ")")}))) n = p.parse("John Smith") assert p.revise(n, family="(née Jones)").family == "Jones" # and the third leg: a leading marker in an UNDELIMITED value is # no marker at all, so the same words keep it (#329) assert p.revise(n, family="née Jones").family == "née Jones" def test_revise_forces_the_named_role_on_every_harvested_token() -> None: # the sub-parse reads "Dr." as a title and "Jr." as a suffix; the # named field's role must win for every token or the family view # silently drops them p = Parser() r = p.revise(p.parse("John Smith"), family="Dr. Vega Jr.") assert r.family == "Dr. Vega Jr." assert all(t.role is Role.FAMILY for t in r.tokens_for(Role.FAMILY)) def test_revise_round_trips_a_rendered_suffix() -> None: # the view renders entries comma-joined and a comma is what the # pass reads, so a name's own suffix revises back to itself -- # the three shapes #511 reported, none of which round-tripped, # and a fourth from the corpus -- the only value in the set whose # own suffix CONTAINS A COMMA ('Jr., Col. Ret'), so it is the one # pinning that the pass does not over-join two entries into one p = Parser() for text in ("John Smith MD PhD", "John Smith, Ph. D.", "Doe, John, MD PhD - FACS Fellow", "Andrew Perkins, Jr., Col. (Ret)"): n = p.parse(text) assert p.revise(n, suffix=n.suffix).suffix == n.suffix, text def test_revise_leaves_a_policy_delimiter_unparted_without_a_tail_segment() -> None: # The pinned limit, and the rule behind it: a configured delimiter # core is dropped -- and so parts entries -- only on a TAIL # segment, which a value gets only where its own words read as a # name. A run of post-nominals has none, with or without a comma # of its own, so the core stays a word; a value that reads as a # name does part. The round-trip is unaffected because the # whole-name view already rendered that boundary as a comma. p = Parser(policy=Policy(extra_suffix_delimiters=frozenset({" | "}))) n = p.parse("Doe, John, MD PhD | FACS") assert n.suffix == "MD PhD, FACS" assert p.revise(n, suffix="MD PhD | FACS").suffix == "MD PhD | FACS" assert p.revise(n, suffix="MD, PhD | FACS").suffix == "MD, PhD | FACS" assert p.revise(n, suffix=n.suffix).suffix == "MD PhD, FACS" assert (p.revise(n, suffix="John Doe, MD | FACS").suffix == "John Doe, MD, FACS") def test_revise_reads_a_glued_honorific_on_its_own() -> None: # The docstring's "classified ON ITS OWN" limit, on the one corpus # name of 1117 whose suffix does not revise back to itself after # #511 (measured 2026-09-06). The whole-name parse keeps 'J.씨' one # glued suffix token; the sub-parse of the bare value peels the # honorific off the initial, and the entry pass then joins the # two it made. Right entries, the spurious comma of before gone # ('씨, J., 씨'), one word read differently in the value than in # the name. Honorific peeling is a W-rule question, not R1's. p = Parser() n = p.parse("김민준씨, J.씨") assert n.suffix == "씨, J.씨" assert p.revise(n, suffix=n.suffix).suffix == "씨, J. 씨" #: The suffix-bearing corpus names whose suffix does not revise back #: to itself: the honorific peel pinned just above, and since #: 2026-09-10 its stop-bearing spelling, which parses to the same #: suffix with a stop on the first word ('씨., J.씨' revising to #: '씨., J. 씨'). One limit, two writings of one name -- the stop #: rides on 씨 and reaches neither the sub-parse's peel nor the entry #: join. Named here so the guard below fails on a NEW exception and #: not on the known one. #: 'John Smith, Dr. J.씨' joined 2026-10-06 (#613's /simplify): the #: comma's own decision now reads its part as postnominal, so the whole #: name leaves 'J.씨' glued where the hand copy had peeled it -- the #: same limit, characterized by the test below. _HONORIFIC_PEEL = frozenset({"김민준씨, J.씨", "김민준씨., J.씨", "John Smith, Dr. J.씨"}) #: The second known limit (2026-10-04, #601/#602): a suffix that holds #: a maiden marker the whole name left a word -- in a tail part, or #: taken into #602's credential run behind a credential -- and that #: the value's own sub-parse consumes mid-value, as revise() documents. #: 'Smith, John, MD - née Jones Smith' left it in #889: the built-in #: hyphen parts its suffix ('MD, née Jones Smith'), whose sub-parse #: reads the marker as a word too. _MARKER_IN_SUFFIX = frozenset({"Jane Doe PhD nee Smith", "Smith, John, PhD née Jones"}) def _suffix_bearing_corpus_names() -> list[str]: from ._differential_fixtures import _CORPUS_NAMES p = Parser() return [n for n in _CORPUS_NAMES if p.parse(n).suffix and n not in _HONORIFIC_PEEL and n not in _MARKER_IN_SUFFIX] def test_the_known_round_trip_exceptions_are_one_limit() -> None: # What makes the two spellings ONE limit rather than two names # somebody enrolled: each one's parsed suffix carries a word that # ENDS in a listed honorific_tails entry without BEING one ('J.씨' # ends in 씨), which is exactly the shape the sub-parse peels and # the whole-name parse leaves glued. A genuinely different # round-trip failure added to the frozenset by the same gesture # fails here rather than riding in on the exemption. The # characterization selects the members and nothing else in the # suffix-bearing corpus (two, measured 2026-09-10; three since # 2026-10-06). p = Parser() tails = Lexicon.default().honorific_tails def ends_in_a_tail_without_being_one(name: str) -> bool: return any(word not in tails and any(word.endswith(tail) for tail in tails) for word in p.parse(name).suffix.split()) assert all(ends_in_a_tail_without_being_one(n) for n in _HONORIFIC_PEEL) assert not [n for n in _suffix_bearing_corpus_names() if ends_in_a_tail_without_being_one(n)] # the marker limit, characterized the same way: the parsed suffix, # read ON ITS OWN, takes a maiden clause -- its marker the whole # name left a word, and the value's sub-parse consumes. Not every # suffix holding a marker word: 'Jane Doe Jr. nee Smith' renders # 'Jr. nee Smith', whose own parse reads 'Jr.' as a leading title # (H2), so the marker stays a word there too and the value # round-trips (measured 2026-10-04). def takes_a_clause_on_its_own(name: str) -> bool: return bool(p.parse(p.parse(name).suffix).maiden) assert all(takes_a_clause_on_its_own(n) for n in _MARKER_IN_SUFFIX) assert not [n for n in _suffix_bearing_corpus_names() if takes_a_clause_on_its_own(n)] def test_the_suffix_bearing_corpus_is_not_empty() -> None: # 367 on 2026-09-06; a floor, so a filter that empties the # parametrization below fails here instead of skipping there assert len(_suffix_bearing_corpus_names()) > 300 @pytest.mark.parametrize("name", _suffix_bearing_corpus_names()) def test_a_rendered_suffix_revises_back_to_itself(name: str) -> None: # The #511 property over the corpus rather than by example, because # revise() is off the differential gate's compare path and a change # to group's marks, delimiter_cores or the entry predicate could # reopen it with the gate green and the four pins above passing. p = Parser() n = p.parse(name) assert p.revise(n, suffix=n.suffix).suffix == n.suffix def test_revise_keeps_the_sub_parses_within_piece_mark() -> None: # The forcing keeps the sub-parse's tags: a "joined" made WITHIN a # piece (the Ph. D. merge, mid-value where the head rule lets it # fire) is role-blind and right for every role (decisions/R1's # #436 DECLINED list), and a draft that cleared it with the role # lost it on every non-suffix value. For a suffix value the pass # writes the marks itself ('Ph. D. MD' sub-parses with none), so # nothing there depends on the sub-parse's own. The between-piece # mark a sub-parse leaves on a token forced to another role # predates #511; on a ParsedName only the suffix string view reads # the tag, and no path puts a revised name into a HumanName, whose # list views would heal it. p = Parser() r = p.revise(p.parse("John Smith"), family="John Ph. D. Smith") family = r.tokens_for(Role.FAMILY) assert [t.text for t in family] == ["John", "Ph.", "D.", "Smith"] assert "joined" in family[2].tags assert r.family == "John Ph. D. Smith" s = p.revise(p.parse("John Smith"), suffix="Ph. D. MD") assert s.suffix == "Ph. D. MD" assert [("joined" in t.tags) for t in s.tokens_for(Role.SUFFIX)] == [ False, True, True] # the stale between-piece mark, and that no ParsedName view reads it stale = p.revise(p.parse("John Smith"), family="Jones MD PhD") fam = stale.tokens_for(Role.FAMILY) assert "joined" in fam[2].tags assert stale.family == "Jones MD PhD" assert stale.family_base == stale.family def test_wholly_cjk_names_read_family_first_by_default() -> None: # the 2026-07-27 amendment: script determines the convention, so # no pack is needed -- release-log-classified fix (#271) n = parse("毛 泽东") assert (n.family, n.given) == ("毛", "泽东") n = parse("김 민준") assert (n.family, n.given) == ("김", "민준") # a lone wholly-CJK token takes the script order's first role: Han # segmentation is opt-in (locales.ZH), so the default parser leaves # this one token whole and reads it as the family name assert parse("毛泽东").family == "毛泽东" def test_nfd_korean_input_still_reads_family_first() -> None: # fix(#271) classification, landed via the #272 NFC-classification # amendment: NFD decomposes each Hangul syllable onto bare jamo, # entirely outside the HANGUL range, so raw NFD input used to miss # script_orders' family-first rule and fall back to the positional # default -- a live gap in #294's shipped behavior until # single_script started classifying an NFC-normalized copy. n = parse(unicodedata.normalize("NFD", "김 민준")) # classification-only: the rendered text is exactly what was # typed (still NFD, spans untouched), so compare NFC-normalized -- # the point under test is the ORDER (family first), not encoding assert (unicodedata.normalize("NFC", n.family), unicodedata.normalize("NFC", n.given)) == ("김", "민준") # pinned, not just stated: a future tokenize-level normalize (the # tempting over-fix, and an anti-#100 violation) must fail here assert n.family == unicodedata.normalize("NFD", "김") def test_kana_licensed_names_read_family_first_by_default() -> None: # the #272 amendment: hiragana identifies Japanese as certainly # as hangul identifies Korean -- release-log-classified fix n = parse("高橋 みなみ") assert (n.family, n.given) == ("高橋", "みなみ") n = parse("山田 エミ") # kanji piece + katakana piece: native assert (n.family, n.given) == ("山田", "エミ") # lone kana-licensed token takes the family role, unsplit assert parse("高橋みなみ").family == "高橋みなみ" def test_iteration_mark_counts_as_han() -> None: # 々 (U+3005) repeats the preceding kanji. It is Script=Han under # UAX #24 already, but it lives outside every CJK ideograph BLOCK, # and the classifier is a block table -- so without its singleton # entry a 佐々木 token was in no script at all: the name reversed, # and the token never reached the segmentation gate. The Script # property would have got this one right unaided; the block table # is what needed the special case. n = parse("佐々木 太郎") assert (n.family, n.given) == ("佐々木", "太郎") n = parse("野々村 真") assert (n.family, n.given) == ("野々村", "真") # and a lone one takes the family role like any other Han token assert parse("奈々").family == "奈々" def test_pure_katakana_stays_positional() -> None: # transcribed foreign names keep their original (usually Western) # order; katakana alone licenses nothing n = parse("マイケル ジャクソン") assert (n.given, n.family) == ("マイケル", "ジャクソン") def test_katakana_transcription_parses_by_its_divider() -> None: # the dot tells us where the parts meet; transcriptions keep the # source language's order, which the positional default provides n = parse("マイケル・ジャクソン") assert (n.given, n.family) == ("マイケル", "ジャクソン") def test_nakaguro_split_han_tokens_take_the_han_order() -> None: # different code path from the katakana case above: splitting on # the dot here produces two PURE-HAN tokens (no kana involved), so # script_orders' family-first HAN entry fires, same as any other # two-token Han name -- the dot only decides where the split falls n = parse("高橋・一郎") assert (n.family, n.given) == ("高橋", "一郎") def test_halfwidth_nakaguro_splits_at_parse_level_too() -> None: # decision, not accident: halfwidth kana is katakana (#594), and a # name written wholly in katakana keeps the declared order # (rules/W4) as マイケル・ジャクソン does -- KATAKANA has no # script_orders entry -- while the dot divides the tokens regardless text = "マイケル・ジャクソン" n = parse(text) assert (n.given, n.family) == ( "マイケル", "ジャクソン") def test_nakaguro_inside_a_nickname_still_splits() -> None: # decision, not accident: delimited content tokenizes under the # same separator rules as the main stream, so a dot inside a # nickname still divides it -- and re-rendering a token stream # necessarily uses the render join, so the dot comes back as a # space, same as any other separator n = parse("山田 太郎 (マイケル・ジャクソン)") assert n.nickname == "マイケル ジャクソン" assert (n.family, n.given) == ("山田", "太郎") def test_latin_names_are_untouched_by_script_orders() -> None: n = parse("John Smith") assert (n.given, n.family) == ("John", "Smith") # mixed-script names fall back to name_order too n = parse("John 王") assert (n.given, n.family) == ("John", "王") def test_script_order_survives_latin_titles_and_suffixes() -> None: # The script test runs on the NAME pieces, after both peels, so a # Latin title or post-nominal cannot make the name look mixed. n = parse("Dr. 毛 泽东") assert (n.title, n.family, n.given) == ("Dr.", "毛", "泽东") n = parse("毛 泽东, PhD") assert (n.family, n.given, n.suffix) == ("毛", "泽东", "PhD") def test_a_comma_still_decides_the_family_name_for_cjk() -> None: # The family-comma structure fixes the family before any positional # read, so the table is never consulted -- same rule name_order has. n = parse("泽东, 毛") assert (n.family, n.given) == ("泽东", "毛") def test_three_cjk_pieces_take_the_script_order_middles() -> None: n = parse("毛 泽东 泽民") assert (n.family, n.given, n.middle) == ("毛", "泽东", "泽民") def test_two_cjk_scripts_fall_back_even_though_both_read_family_first() -> None: # The rule is one script, or the Han/kana repertoire the #272 # license covers -- not "the entries agree": Han+Hangul is written # in neither tradition, so it takes the positional default. n = parse("毛 김") assert (n.given, n.family) == ("毛", "김") def test_a_hyphen_in_a_name_piece_declines_the_script_order() -> None: # Documenting the conservative direction, not proposing it: ANY # non-CJK character in a name piece (here the hyphen) puts that # piece in no script, so the piece set has two members and # script_orders declines in favour of the positional default. n = parse("毛 泽东-泽民") assert (n.given, n.family) == ("毛", "泽东-泽民") def test_script_orders_opt_out_restores_positional_reading() -> None: p = Parser(policy=Policy(script_orders={})) # type: ignore[arg-type] n = p.parse("毛 泽东") assert (n.given, n.family) == ("毛", "泽东") def test_script_order_beats_explicit_global_name_order() -> None: # the script entry is the more specific rule; opting out means # script_orders={}, not a different name_order p = Parser(policy=Policy(name_order=FAMILY_FIRST_GIVEN_LAST)) n = p.parse("김 민준") assert (n.family, n.given) == ("김", "민준") def test_unspaced_korean_names_parse_by_default() -> None: # the whole point of shipping the census list as default # vocabulary (#271): no pack, no config n = parse("김민준") assert (n.family, n.given) == ("김", "민준") n = parse("남궁민수") # two-syllable surname beats single 남 assert (n.family, n.given) == ("남궁", "민수") # -- the segmenter hook (#272) ------------------------------------------ def _module_level_decline(text: str) -> Segmentation | None: return None # module-level so pickle can find it # Inert sentinels for the plumbing tests below, which only ever compare # identity. Two of them, because the override test needs the loser and # the winner to be DISTINCT objects or its assertion is vacuous. def _decline_a(text: str) -> Segmentation | None: return None def _decline_b(text: str) -> Segmentation | None: return None def test_parser_segmenter_is_keyword_only_and_validated() -> None: assert Parser().segmenter is None with pytest.raises(TypeError, match="callable"): Parser(segmenter=5) # type: ignore[arg-type] with pytest.raises(TypeError, match="positional"): Parser(Lexicon.default(), Policy(), None) # type: ignore[misc] # positional: rejected def test_parser_for_carries_the_base_segmenter() -> None: # not given: the base's carries through unchanged p = parser_for(locales.get("zh"), base=Parser(segmenter=_decline_a)) assert p.segmenter is _decline_a def test_parser_for_rejects_a_non_parser_base() -> None: with pytest.raises(TypeError, match="base must be a Parser"): parser_for(locales.get("zh"), base=5) # type: ignore[arg-type] def test_parser_for_takes_a_segmenter_keyword() -> None: p = parser_for(locales.get("zh"), segmenter=_decline_a) assert p.segmenter is _decline_a assert parser_for(locales.get("zh")).segmenter is None def test_parser_for_segmenter_keyword_overrides_the_base() -> None: # later wins, the same rule scalar policy fields follow p = parser_for(locales.get("zh"), base=Parser(segmenter=_decline_a), segmenter=_decline_b) assert p.segmenter is _decline_b def test_parser_for_segmenter_none_clears_the_base() -> None: # the third state UNSET buys (#272 review): None is a VALUE here, # not an absence, so passing it explicitly drops the base's # segmenter instead of inheriting the very thing it was asked to # remove. Omitting the keyword is what carries the base's through # (the test above), and this is how you derive an unsegmented # parser from a segmented one without rebuilding its lexicon and # policy by hand. base = Parser(segmenter=_decline_a) pack = locales.get("zh") assert parser_for(pack, base=base, segmenter=None).segmenter is None # ...and the base is untouched: parser_for builds a fresh Parser assert base.segmenter is _decline_a def test_parser_picklability_is_conditional_on_the_segmenter() -> None: # declared contract (locales spec section 4): Parser pickles iff # its segmenter does -- like any callable-holding object p = pickle.loads(pickle.dumps(Parser(segmenter=_module_level_decline))) assert p.segmenter is _module_level_decline unpicklable = Parser(segmenter=lambda t: None) # constructs fine with pytest.raises(Exception): # pickle's exception type varies pickle.dumps(unpicklable) # only pickling fails def test_segmenter_exceptions_propagate() -> None: # the ONE exception to parse-totality (locales spec section 4, # declared 2026-07-11): a user-supplied callable's own error is a # user-code error, not a content error, and must not be swallowed def boom(text: str) -> Segmentation | None: raise RuntimeError("segmenter bug") p = parser_for(locales.get("zh"), base=Parser(segmenter=boom)) with pytest.raises(RuntimeError, match="segmenter bug"): p.parse("小明") # zh pack active, 小 unmatched by vocabulary -> # the stage consults the segmenter def test_the_segmenter_sees_only_an_undivided_name() -> None: # its precondition (#272 Task 5): a segmenter answers where an # UNDIVIDED name divides, so a name part carrying a second # script-written token is already divided and the segmenter is not # consulted -- otherwise "山田 太郎" would have its family divided # again. A Latin title/suffix is not such a boundary. def always(text: str) -> Segmentation | None: return Segmentation((1,), confidence=1.0) p = parser_for(locales.get("zh"), base=Parser(segmenter=always)) assert p.parse("小明").family == "小" # one token: consulted n = p.parse("小明 日月") # already divided assert (n.family, n.given) == ("小明", "日月") assert p.parse("Dr 小明").family == "小" # a title is not a split def test_a_segmenter_split_reaches_the_fields() -> None: # end to end: the sub-slices the stage makes are ordinary tokens # from there on, so the pack's family-first order reads them like # any vocabulary split, and a low-confidence answer surfaces def two(text: str) -> Segmentation | None: return Segmentation((1,), confidence=0.5) p = parser_for(locales.get("zh"), base=Parser(segmenter=two)) n = p.parse("小明") assert (n.family, n.given) == ("小", "明") assert [a.kind for a in n.ambiguities] == [AmbiguityKind.SEGMENTATION] # two cuts, three pieces -- and the token indices the comma # structure recorded still name the right words afterwards def three(text: str) -> Segmentation | None: return Segmentation((1, 2)) q = parser_for(locales.get("zh"), base=Parser(segmenter=three)) n3 = q.parse("Dr 小明日, Jr.") assert (n3.title, n3.family, n3.given, n3.middle, n3.suffix) == ( "Dr", "小", "明", "日", "Jr.") def _noop_segmenter(text: str) -> None: return None def test_segmenterless_activation_without_vocabulary_warns() -> None: # The JA pack activates HAN and HIRAGANA segmentation but ships no # vocabulary, and no bundled list could serve those scripts -- so # without a segmenter, Japanese names can never divide. That is a # CONFIGURATION gap, not a fact about any name, and it was silent: # the misconfigured parser behaved identically to a working one # minus the feature. Statically detectable at construction, so # warn there. with pytest.warns(UserWarning, match=r"ja_segmenter"): parser_for(locales.JA) def test_the_activation_warning_offers_a_spelling_that_type_checks() -> None: # The message's actionable half is the deactivation it offers, and # a user pastes it verbatim. nameparser ships py.typed, so that # paste has to survive a type checker: Policy(segment_scripts=()) # is an arg-type error, since the field is annotated with what it # STORES (a frozenset) rather than everything the constructor # accepts. Nothing pinned this half of the message before. with pytest.warns(UserWarning) as caught: parser_for(locales.JA) message = str(caught[0].message) assert "Policy(segment_scripts=frozenset())" in message assert "Policy(segment_scripts=())" not in message def test_a_segmenter_silences_the_activation_warning() -> None: # any segmenter counts: the gap is "nothing can divide these", # not "you did not use namedivider" parser_for(locales.JA, segmenter=_noop_segmenter) def test_covered_activation_does_not_warn() -> None: # HANGUL is served by the census surnames; the zh pack ships the # vocabulary its own activation needs Parser() parser_for(locales.ZH) def test_stacked_activation_warns_only_for_uncovered_scripts() -> None: # zh covers HAN and the default vocabulary covers HANGUL; only # HIRAGANA is left unservable, and the message must say WHICH # scripts are dead rather than naming the whole activation set with pytest.warns(UserWarning) as caught: parser_for(locales.ZH, locales.JA) # select by content: pack application can emit its own warnings # ahead of construction, so positional indexing is order-fragile message = next(str(w.message) for w in caught if "segment_scripts activates" in str(w.message)) assert "hiragana" in message assert "hangul" not in message #: The corpus names a maiden clause can be appended to without the #: clause itself being the variable: Latin script, no comma (a clause #: behind a comma is post-comma text: rules/M2's Except bullet "In the #: given part after a family comma, in a part after a suffix comma, and #: behind a title, a suffix word or a connective, the marker is an #: ordinary word", decisions/M2), and no marker already present. _LATIN = re.compile(r"^[\x00-\u024f]*$") def _clause_free_latin_corpus_names() -> list[str]: from nameparser import DEFAULT_NICKNAME_DELIMITERS from nameparser._pipeline._vocab import maiden_marker_run from nameparser.config.maiden_markers import MAIDEN_MARKERS from ._differential_fixtures import _CORPUS_NAMES # "Does this name already carry a marker" is the parser's own # question, so it is asked with the parser's own predicate rather # than by word membership. A word test cannot see a PHRASE entry: # no word of 'z domu' is a marker, so 'Maria Kowalska z domu Nowak' # sat in this corpus with a maiden clause of its own and the # appended one was not the only variable # (mechanisms.md#ONE-PREDICATE-PER-QUESTION -- a test guard is as # able to write the mirroring condition as a stage is). # # The strip stays, and runs BEFORE the predicate: a marker glued to # a delimiter character is still a marker, and neither the # predicate nor the vocabulary strips a bracket, so '(geb.' must # lose it before being asked. Stripping only the period admitted # every corpus name that brackets its marker, and once rules/M3 # read such a clause as the maiden name, six of them had a maiden # clause of their own. The strip turns away TEN names in all: those # six, plus four that M3 declines and that would have been safe to # keep -- the one-word '(Nee)', '(Nee) (Jones)' and '(née)', and # '(née Jr.)', which S1 takes before M3 sees it. Textual, and so # deliberately conservative in exactly that direction. # # Every count in this comment quantifies over the corpus, so one # added corpus row falsifies it silently. Recount rather than # adjust: # The body must sit flush left: `python -c` compiles it as a module, # so an indented first line raises IndentationError on paste. # uv run python -c " # import re, sys; sys.path.insert(0, 'tests') # from nameparser import DEFAULT_NICKNAME_DELIMITERS as D # from nameparser._pipeline._vocab import maiden_marker_run as run # from nameparser.config.maiden_markers import MAIDEN_MARKERS as M # from v2._differential_fixtures import _CORPUS_NAMES # base = [n for n in _CORPUS_NAMES if re.match(r'^[\x00-\u024f]*$', n) and ',' not in n] # strip = ''.join({c for p in D for c in p}) + '.' # words = lambda n, f: [f(w) for w in n.split()] # keep = lambda f: [n for n in base if not any(w in M for w in words(n, f))] # old = keep(lambda w: w.lower().rstrip('.')) # new = keep(lambda w: w.lower().strip(strip)) # ws = lambda n: words(n, lambda w: w.lower().strip(strip)) # pred = [n for n in base if not any(run(ws(n)[i:], M) for i in range(len(ws(n))))] # print(len(old), len(new), len(pred), sorted(set(old) - set(new)), sorted(set(new) - set(pred)))" # # Delimiter characters come from the shipped set rather than a # literal, so a pair added there cannot quietly reopen this. strip = "".join({ch for pair in DEFAULT_NICKNAME_DELIMITERS for ch in pair}) + "." def marked(name: str) -> bool: words = [word.lower().strip(strip) for word in name.split()] return any(maiden_marker_run(words[i:], MAIDEN_MARKERS) for i in range(len(words))) return [name for name in _CORPUS_NAMES if _LATIN.match(name) and "," not in name and not marked(name)] def test_the_clause_free_corpus_is_not_empty() -> None: """The invariant below is parametrized over a FILTERED corpus, and an empty parametrization passes as a skip rather than failing -- the shape #329 left behind. The filter has been widened twice already -- the delimiter strip, then the move from word membership to the marker predicate, which a phrase entry made necessary -- and the one-liner in that filter's comment recounts every stage: 646 names before either, 636 after the strip, 632 once the predicate decides. Recounted 2026-08-27 by pasting it, and recounted AGAIN in the review round, which is the point of shipping the one-liner: the first recount was taken before the last of #445's rules-corpus rows existed and came out one low at every stage. #445 adds five rows in all, of which three clear the marker predicate ('Garcia', 'Smith', 'Smith (Jones)'), so every stage moves by three -- and the first two figures were already one low before any of it, a corpus row having landed without the recount this comment asks for. So the floor is what says a future widening emptied it. Deliberately far below today's count: this asks whether the filter still selects a corpus, not what the corpus holds.""" assert len(_clause_free_latin_corpus_names()) > 100 @pytest.mark.parametrize("name", _clause_free_latin_corpus_names()) def test_a_maiden_clause_changes_nothing_else(name: str) -> None: """The grouping rules count and join only the words that remain once the marker and the maiden name leave (rules/M2, #418), so appending a clause adds a maiden name and moves no other field -- wherever the appended marker counts at all. Over the corpus rather than by example, because the defect was an appended-clause shape on names that are otherwise ordinary ('John e Smith', 'Lt.Gov. juan e garcia'), which no corpus carries in that form and which the differential gate therefore cannot see. Before the marker pass moved ahead of the joins, seven of these names failed this. Whether the marker counts is M2's head rule (#601): the word straight before it -- the base name's last word, a nickname aside -- must be a name word, which a word of the leading title run, a word of the unambiguous suffix vocabulary and a connective are not. Where the clause is taken the assertion is the one above; where it is not ('Smith Jr.', 'Coach', 'John Smith PhD') the word before the marker must be one of those refusals, so neither half of the split can pass vacuously. A corpus name that parses to no name word ('', '(', '()') has nothing before the marker and is skipped. One class of name moves ON PURPOSE, and is asserted MOVING rather than skipped: rules/M4 makes the clause decide a name that holds exactly one name word, because a marker announces a former surname and only means anything beside a current one. The flip is exactly given -> family with every other field standing still -- which is the whole of what #445 changed, checked over the corpus rather than at the rows cases.py carries. The `flips` predicate below is a second reading of M4's guard, and that is a maintenance cost taken deliberately rather than the silent-drift hazard it resembles: it fails LOUDLY in both directions -- narrow M4 and the flip assertion fails, widen it and the stands-still assertion does. What it buys is the carve-out witness: dropping the `vocab:bound-given` carve-out would stop failing here on 'abdul', and the corpus is the only place that name is asked. """ base = parse(name) if not (base.given or base.middle or base.family or base.title or base.suffix): pytest.skip("nothing before the marker at all: M2 leaves it a word") with_clause = parse(name + " née Jones") # The head rule, read both ways. Where the appended marker takes # nothing, the word straight before it -- the base name's last # word, a nickname aside -- must be one of the rule's refusals: a # title, a word of the UNAMBIGUOUS suffix vocabulary (an ambiguous # member or a numeral the peel happens to take is none) or the # merged credential's continuation, or a connective. A decline for # any other reason fails, and so does a taken clause that moves a # field, below. words = [t for t in base.tokens if t.role is not Role.NICKNAME] last = words[-1] if words else None if with_clause.maiden == "": assert last is not None and ( last.role is Role.TITLE or ("vocab:suffix" in last.tags and "initial" not in last.tags) or (last.role is Role.SUFFIX and "joined" in last.tags) or "conjunction" in last.tags), ( f"{name!r}: the appended marker took nothing, but the word " f"before it, {last.text if last else None!r}, is none of " f"rules/M2's refusals") return # M4's guard, read off the base parse: one GIVEN token, no other # name word, no title (a titled name is H1's), and neither # carve-out tag. Tokens rather than fields because the rule counts # tokens -- a joined 'de la Vega' in `given` is one field and # three of them. givens = [t for t in base.tokens if t.role is Role.GIVEN] flips = (len(givens) == 1 and not base.middle and not base.family and not base.title and not ({"initial", "vocab:bound-given"} & givens[0].tags)) assert with_clause.maiden == "Jones" moved = ("title", "middle", "suffix", "nickname") if flips else ( "title", "given", "middle", "family", "suffix", "nickname") if flips: assert (with_clause.family, with_clause.given) == (base.given, ""), ( f"{name!r}: one name word beside a maiden clause is the " f"family name (rules/M4), but it reads family " f"{with_clause.family!r} / given {with_clause.given!r}") for field in moved: assert getattr(with_clause, field) == getattr(base, field), ( f"{name!r}: {field} reads {getattr(base, field)!r} alone and " f"{getattr(with_clause, field)!r} with a maiden clause") def test_a_phrase_marker_outranks_the_word_it_starts_with() -> None: # Longest first, and the row cases.py cannot hold: a Case carries a # Policy or a Locale, never a Lexicon, so the one marker fork that # needs two entries at once lives here. # # 'geb' ships and 'geb von' is the caller's addition. The phrase # must win where it matches -- maiden 'Braun', the particle being # part of the marker -- and the bare word must still match # everywhere else. Shortest-first would take 'geb' in both and read # the first as maiden 'von Braun', which is also what the default # vocabulary reads, so the control below is what makes the first # assertion mean anything. configured = Parser(lexicon=Lexicon.default().add( maiden_markers=["geb von"])) assert configured.parse("Jane Smith geb von Braun").maiden == "Braun" assert configured.parse("Jane Smith geb Braun").maiden == "Braun" assert parse("Jane Smith geb von Braun").maiden == "von Braun" def test_the_catalan_recipe_unmarks_the_link_for_one_case_names() -> None: """rules/P8's per-caller answer for Catalan and Polish data (#397 second review), pinned rather than described. `i` ships in the MARKED subset, so a name written wholly in one case reads it as an initial and reports the fork. A caller whose data is Catalan knows better, and the knob the rule names is `remove(conjunctions_ambiguous={"i"})` -- which leaves the letter a connective and takes the one-case fork out of its way. The prose said so; nothing ran it. """ catalan = Parser( lexicon=Lexicon.default().remove(conjunctions_ambiguous={"i"})) for text in ("JOSEP CAROD I ROVIRA", "josep carod i rovira"): name = catalan.parse(text) assert name.family == text.split(" ", 1)[1] assert name.ambiguities == () # the three things the recipe does NOT change, and they are what # make it safe to hand a Catalan user. The link with nothing on # its right is still the generation it also spells ... kept = catalan.parse("JOHN QUINCY SMITH I") assert kept.suffix == "I" assert kept.family == "SMITH" # ... P3's three-word carve-out still leaves a lone letter a name # word ... assert catalan.parse("JOHN I SMITH").middle == "I" # ... and no report is emitted anywhere, the fork the recipe # removes being the only one these names raised assert catalan.parse("JOHN I SMITH").ambiguities == () assert catalan.parse("JOHN QUINCY SMITH I").ambiguities == () _NFD_ROWS = [ # the period-abbreviation shape (rules/H2, #H5), in each slot "Dr. inž. Janez Novak", "inž. Janez Novak", "prof. dr. inž. Janez Novak", "Janez Novak, univ. dipl. inž.", "Novak, inž. Janez", "Smith, inž. Ž.Š.", # the comma shapes nameparser/AGENTS.md asks of every comma grid ", inž.", "(Bob), inž.", # paired initials (rules/C5) "Janez Novak, Ž.Š.", "Janez Novak, Ž.Š., Jr.", # the all-caps credential shape: default policy reads it in the # part after a comma "Janez Novak, ŽŠČ", "Janez NOVAK, ŽŠČ", # ... and in every slot under CapsSuffixes.EVERYWHERE "Janez Novak ŽŠČ", "Smith, John ÉS", # the two-letter caps word is a PAIR of initials, and counts # letters, not code points (review of the first fix) "John Smith, MÁ", "John Smith, ŽŠ", # the rules name Greek and Cyrillic as well as Latin "Επίκ. Νίκος Παπαδόπουλος", "Иван Петров, ЙЁЖ", # these four read the same even with every shape regex reverted # (measured), so they pin nothing but parity "Janez Novak inž.", "Janez Novak, Ž.Š.Č.", "Janez Novak, Žž.Šš.", "Smith, Ž.Š. Jones", # #665: a period-marked initial (`\w\.`) beside a connective, in # each slot and comma shape -- a lone one read alike before the fix "É. y É. Smith", "Й. John Smith", "Ana É. y Luis", "Smith, É. y É.", ", É. y É.", "(Bob), É. y É.", # ... and the one-letter connective written in two code points: # three words (P3's carve-out keeps it a name word), four words # (it joins), one case, and after a comma "John й Smith", "JOHN Й SMITH", "john й smith", "Ivan й Petrov Sidorov", "Smith, John й Jones", # ... and the veto on scripts that write no initials (#320), asked # of the composed spelling too: decomposed hangul is conjoining # jamo, outside every _NO_INITIALS range, so composing the initial # shape alone made a decomposed '씨.' an initial (a title or a # given name). Spaced or after a comma, since an UNSPACED # decomposed hangul name is not split (docs/usage.rst) and so has # no composed twin to match "김민준, 씨.", "김 민준 씨.", "김 민준, 씨.", ", 씨.", "김. 민준", ] _NFD_POLICIES = [ Policy(), Policy(name_order=FAMILY_FIRST), Policy(name_order=FAMILY_FIRST_GIVEN_LAST), Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE), ] def _composed_roles(parser: Parser, text: str) -> dict[str, str]: return {k: unicodedata.normalize("NFC", v) for k, v in parser.parse(text).as_dict().items()} def _composed_reading(parser: Parser, text: str) -> tuple[object, ...]: """The roles, the reports' kinds and initials(), composed: #665's `John й Smith` differed in its report as well as its fields.""" name = parser.parse(text) return (_composed_roles(parser, text), [a.kind for a in name.ambiguities], unicodedata.normalize("NFC", name.initials())) @pytest.mark.parametrize("policy", _NFD_POLICIES, ids=["default", "family-first", "family-first-given-last", "caps-everywhere"]) @pytest.mark.parametrize("text", _NFD_ROWS) def test_a_decomposed_abbreviation_reads_as_its_composed_twin( text: str, policy: Policy) -> None: """#652: the abbreviation shape rules saw a combining mark as the end of the word (not \\w), so `inž.` written i, n, z, U+030C, '.' was not an abbreviation and `Dr. inž. Janez Novak` made it the given name, and the caps shape's isalpha() refused `ŽŠČ` the same way. Output keeps the encoding written, so both are compared composed. Recorded negative control, measured 2026-10-12 against 04cc0814's nameparser/ (the package checked out from that commit, these tests on top): 22 rows x 4 policies = 88 twin cases, of which 58 fail (14 rows under all four policies, `Janez Novak ŽŠČ` and `Smith, John ÉS` under caps-everywhere only), and the 3 direct tests below fail too: 61 of 91. The four parity rows and the two two-letter rows pass there; the two-letter rows exist for the caps fix's own length count, which counted code points the first time (`John Smith, MÁ` read as a pair composed and a credential decomposed, found in the review of 08c0dd60). #665 added sixteen rows and compares kinds and initials() too. Recorded control, measured 2026-10-13 against f9b2b81c's nameparser/: 38 rows x 4 policies = 152 twin cases plus the four direct tests, of which 41 fail -- the six initial rows under all four policies (24; the same 24 with only `is_initial_shaped`'s compose reverted), the three-word `й` rows (12; `_pieces`' carve-out alone), `김. 민준` (4), and the one-case report test below (1; either `_classify` width site alone). The other four hangul rows pass there: they guard the fix itself, failing (with `김. 민준`, 20 in all) when `in_initialless_script`'s compose alone is reverted, since the composed initial shape without the composed veto made a decomposed '씨.' an initial. `Ivan й Petrov Sidorov` and `Smith, John й Jones` pass everywhere and pin parity only.""" composed = unicodedata.normalize("NFC", text) decomposed = unicodedata.normalize("NFD", text) assert decomposed != composed parser = Parser(policy=policy) assert (_composed_reading(parser, decomposed) == _composed_reading(parser, composed)) def test_a_decomposed_conjunction_reports_as_its_composed_twin() -> None: """#665: the one-case report on a letter that is both a connective and an initial (rules/P8) counted code points, so it is reached by `й` only once a caller marks it ambiguous -- no default entry is decomposable.""" parser = Parser(lexicon=Lexicon.default().add( conjunctions_ambiguous={"й"})) text = "ivan й petrov" composed = parser.parse(text) assert [a.kind for a in composed.ambiguities] == [ AmbiguityKind.CONJUNCTION_OR_INITIAL] assert (_composed_reading(parser, unicodedata.normalize("NFD", text)) == _composed_reading(parser, text)) def test_a_decomposed_caps_credential_is_still_a_suffix() -> None: """The twin comparison above would pass if both encodings broke alike; this pins the reading itself (`Janez Novak, ŽŠČ`).""" name = parse(unicodedata.normalize("NFD", "Janez Novak, ŽŠČ")) assert unicodedata.normalize("NFC", name.suffix) == "ŽŠČ" assert name.given == "Janez" def test_a_decomposed_chunked_credential_reads_as_its_composed_twin() -> None: """`_CHUNKED` is masked by the dotted-shape rule (S3) under the default policy; with that off and the acronym listed, the chunked gate is the only reader, and it read NFD `Ž.Šč.` as no credential.""" parser = Parser( lexicon=Lexicon.default().add(suffix_acronyms={"žšč"}, suffix_acronyms_ambiguous={"žšč"}), policy=Policy(unlisted_dotted_suffixes=False)) text = "John Smith Ž.Šč." decomposed = unicodedata.normalize("NFD", text) assert decomposed != text assert (_composed_roles(parser, decomposed) == _composed_roles(parser, text)) assert unicodedata.normalize( "NFC", parser.parse(decomposed).suffix) == "Ž.Šč." def test_a_decomposed_dotted_letter_is_dotted() -> None: """`_DOTTED` is unobservable through a parse (`_CHUNKED` subsumes it for two or more letters and no input made the single-letter case differ), so it is pinned directly.""" from nameparser._pipeline._vocab import _dotted assert _dotted(unicodedata.normalize("NFD", "Ž.")) is True # #671: the credential, office and generational words, one spelling each # per distinct entry, all spaced / after-comma vocabulary. #707 vetted # the glued bar ("can it END a name?") and every word passed but # 部長/部长, so the two lists below partition this one: a word moves # between them only on purpose. _CJK_GLUED_WORDS = ( "議員", "议员", "国会議員", "國會議員", "太平紳士", "太平绅士", "医学博士", "醫學博士", "二世", "社長", "課長", "社长", "课长", ) _CJK_SPACED_ONLY_WORDS = ("部長", "部长") _CJK_SPACED_WORDS = _CJK_GLUED_WORDS + _CJK_SPACED_ONLY_WORDS @pytest.mark.parametrize("word", _CJK_SPACED_WORDS) def test_a_cjk_credential_word_is_a_suffix_spaced_and_after_a_comma( word: str) -> None: parser = Parser() for text in (f"田中 {word}", f"田中, {word}", f"Tanaka, {word}"): parsed = parser.parse(text) assert parsed.suffix == word, text assert word not in parsed.family + parsed.given, text # a one-word head and an empty head (CSV data with the surname # missing) are the comma paths that have disagreed before assert parser.parse(f", {word}").suffix == word @pytest.mark.parametrize("word", _CJK_GLUED_WORDS) def test_a_vetted_cjk_credential_word_peels_off_a_glued_name( word: str) -> None: parsed = Parser().parse(f"田中{word}") assert (parsed.family, parsed.suffix) == ("田中", word) @pytest.mark.parametrize("word", _CJK_SPACED_ONLY_WORDS) def test_a_declined_cjk_credential_word_does_not_peel_off_a_glued_name( word: str) -> None: # 長 alone is a borne given name, so 阿部長 can be 阿部 + 長 (#707). assert word not in Parser().lexicon.honorific_tails assert Parser().parse(f"田中{word}").suffix == "" # rules/S13 (#810): where a bracketed member reads differently from its # twin, a bracketed unambiguous credential in the same place. The twin # is MBA, a word no head holds, so the twin's reading maps back by # replacing its text. 1,080 texts, about 2s. _TWIN_HEADS = ( "John Smith {c}", "Jack {c}", "{c}", "John {c}", "JOHN SMITH {c}", "Smith, John {c}", "Smith, {c}", ", {c}", "(Bob), {c}", "John Smith {c}, PhD", "John Smith, PhD {c}", "John Smith PhD {c}", "Dr. Jack {c}", "Dr. {c}", "Freiherr von Berg {c}", "Juan de la Vega {c}", "Jane Doe nee Smith {c}", "John Smith {c} née Jones", "John Smith {c} Dr.", "John Smith {c} VI", "John Smith {c} Ph. D.", "Smith, Jane {c} Mrs", "Ortega y Gasset, José {c}", "Smith, Freiherr von Berg {c}", "Doe, John van {c}", # a third comma part, and a clause whose settling another makes # possible, the first settled word making the name mixed case "Smith, John, Jr. {c}", "JOHN SMITH (m.a) {c}", "john smith (M.A) {c}", "Smith, John, X.Y.Z. {c}", "Smith, John, Bob {c}", ) _TWIN_MEMBERS = ("MA", "M.A", "JD", "DO", "D.O", "MENG") _TWIN_PAIRS = (("(", ")"), ('"', '"')) _TWIN_ORDERS = (GIVEN_FIRST, FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST) def _twin_reading(name: ParsedName, twin: str | None, word: str) -> tuple[ dict[str, str], list[tuple[str, str]]]: def back(text: str) -> str: return text.replace(twin, word) if twin else text return ({k: back(v) for k, v in name.as_dict().items() if v}, [(a.kind.value, back(a.detail)) for a in name.ambiguities]) #: The census, by head and by whether the member was settled or left #: the nickname, recorded 2026-10-26 (#810). rules/S13's Accepted bullets #: and its Except bullets name each shape: a lone name word stays N3's #: family name beside a settled member where its twin's is given; the #: maiden clause stays the maiden name where the twin's credential run #: takes it; a part past a second comma reports what is left without #: the member; a name written in one case settles no capitals; a name #: with no word of its own (a title, a nickname, nothing) settles no #: clause, where the twin escapes, read as the name ('(MBA)' given #: 'MBA', 'Dr. (MBA)' given 'Dr.') or as the suffix behind a comma #: (', (MBA)'); and a clause another #: settled one would make settle, on a second reading, is left the #: nickname. _TWIN_CENSUS = { ("(Bob), {c}", "nickname"): 36, (", {c}", "nickname"): 36, ("Dr. {c}", "nickname"): 36, ("JOHN SMITH (m.a) {c}", "nickname"): 24, ("JOHN SMITH {c}", "nickname"): 24, ("Jack {c}", "settled"): 36, ("John Smith {c} née Jones", "settled"): 36, ("John {c}", "settled"): 36, ("Smith, John, Bob {c}", "settled"): 36, ("Smith, John, X.Y.Z. {c}", "settled"): 36, ("john smith (M.A) {c}", "nickname"): 24, ("{c}", "nickname"): 36, } def test_where_a_bracketed_member_reads_otherwise_than_its_twin() -> None: """A census, not a guarantee: one pass decides S13 once the roles are read, so the rest of the name is read around the clause as a nickname, and that differs from the twin's reading in the shapes _TWIN_CENSUS names. A change that moves a count, or adds a head, is a change to what rules/S13 accepts: re-record it there and here. The twin reading folded to a fixed point (the PR's fourth draft, declined by Derek for one pass) differed in none of the settled rows.""" census: dict[tuple[str, str], int] = {} for (open_, close), word, head, order in itertools.product( _TWIN_PAIRS, _TWIN_MEMBERS, _TWIN_HEADS, _TWIN_ORDERS): parser = Parser(policy=Policy(name_order=order)) name = parser.parse(head.format(c=f"{open_}{word}{close}")) twin = parser.parse(head.format(c=f"{open_}MBA{close}")) if _twin_reading(name, None, word) != _twin_reading( twin, "MBA", word): key = (head, "nickname" if word in name.nickname.split() else "settled") census[key] = census.get(key, 0) + 1 assert census == _TWIN_CENSUS ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_policy.py�������������������������������������������0000664�0000000�0000000�00000123500�15273032717�0023311�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������import dataclasses from collections.abc import Iterator import pytest from nameparser._policy import CapsSuffixes from nameparser._policy import ( DEFAULT_SCRIPT_ORDERS, FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST, GIVEN_FIRST, PatronymicRule, Policy, PolicyPatch, Script, UNSET, _BOOL_FIELDS, _SCRIPT_RANGES, _script_matcher, apply_patch, ) from nameparser._types import Role def test_order_constants_read_as_their_contents() -> None: assert GIVEN_FIRST == (Role.GIVEN, Role.MIDDLE, Role.FAMILY) assert FAMILY_FIRST == (Role.FAMILY, Role.GIVEN, Role.MIDDLE) assert FAMILY_FIRST_GIVEN_LAST == (Role.FAMILY, Role.MIDDLE, Role.GIVEN) def test_policy_defaults() -> None: p = Policy() assert p.name_order == GIVEN_FIRST assert p.patronymic_rules == frozenset() assert ("(", ")") in p.nickname_delimiters assert p.maiden_delimiters == frozenset() assert p.strip_emoji and p.strip_bidi and p.lenient_comma_suffixes def test_policy_is_hashable_and_replaceable() -> None: p = dataclasses.replace(Policy(), name_order=FAMILY_FIRST) assert p.name_order == FAMILY_FIRST assert isinstance(hash(p), int) def test_name_order_must_be_permutation_and_error_names_constants() -> None: with pytest.raises(ValueError, match="FAMILY_FIRST_GIVEN_LAST"): Policy(name_order=(Role.TITLE, Role.GIVEN, Role.FAMILY)) with pytest.raises(ValueError, match="GIVEN_FIRST"): Policy(name_order=(Role.GIVEN, Role.GIVEN, Role.FAMILY)) def test_name_order_restricted_to_the_three_exported_orders() -> None: # _name_positions only implements the three exported semantics; the # unnamed permutations would silently misassign (PR review I7). # Pre-2.0 strictness is free: relaxing later is compatible. with pytest.raises(ValueError, match="GIVEN_FIRST"): Policy(name_order=(Role.MIDDLE, Role.GIVEN, Role.FAMILY)) def test_name_order_rejects_non_role_elements_with_type_error() -> None: # taxonomy: wrong element type -> TypeError, not the permutation # ValueError (PR review polish) with pytest.raises(TypeError, match="Role"): Policy(name_order=(1, 2, 3)) # type: ignore[arg-type] def test_name_order_rejects_plain_string_tuples() -> None: # Role is a StrEnum, so ("given", ...) == GIVEN_FIRST -- the # isinstance loop is the only guard rejecting string elements. with pytest.raises(TypeError, match="must be Role members"): Policy(name_order=("given", "middle", "family")) # type: ignore[arg-type] def test_script_values_are_the_public_names() -> None: assert Script.HAN == "han" and Script.HANGUL == "hangul" assert Script.HIRAGANA == "hiragana" and Script.KATAKANA == "katakana" def test_script_matcher_contains_any() -> None: has_han = _script_matcher(Script.HAN) assert has_han("毛泽东") assert has_han("Bruce 李") # mixed input: CONTAINS is enough assert not has_han("Bruce Lee") assert not has_han("") def test_script_matcher_whole() -> None: wholly_ja = _script_matcher( Script.HAN, Script.HIRAGANA, Script.KATAKANA, whole=True) assert wholly_ja("高橋みなみ") assert wholly_ja("マイケル") # katakana is in this union assert not wholly_ja("高橋 みなみ") # the space is outside it assert not wholly_ja("") # empty is not WHOLLY anything # the same union in contains mode accepts what wholly rejects assert _script_matcher( Script.HAN, Script.HIRAGANA, Script.KATAKANA)("高橋 みなみ") def test_script_matcher_rejects_zero_scripts() -> None: # an empty union would compile "[]" and raise a cryptic pattern # error far from the mistake with pytest.raises(ValueError, match="at least one Script"): _script_matcher() def test_script_matcher_reaches_astral_han() -> None: # the supplementary-plane span must survive the class compilation # (\U escapes, not \u): 𠮷 is U+20BB7 assert _script_matcher(Script.HAN)("𠮷田") assert _script_matcher(Script.HAN, whole=True)("𠮷田") def test_every_script_member_has_a_range_entry() -> None: # a member added to the enum alone would be accepted by # Policy(segment_scripts=...) yet never classified by # single_script -- a silent no-op until this fails assert set(_SCRIPT_RANGES) == set(Script) def test_patronymic_rules_coerce_and_reject() -> None: p = Policy(patronymic_rules=frozenset({"east-slavic"})) # type: ignore[arg-type] assert p.patronymic_rules == frozenset({PatronymicRule.EAST_SLAVIC}) with pytest.raises(ValueError, match="east-slavic, turkic"): Policy(patronymic_rules=frozenset({"klingon"})) # type: ignore[arg-type] def test_delimiter_pairs_must_be_nonempty_string_pairs() -> None: with pytest.raises(ValueError, match="non-empty"): Policy(nickname_delimiters=frozenset({("", ")")})) def test_delimiter_pair_rejects_two_char_string() -> None: with pytest.raises(TypeError, match="tuples"): Policy(nickname_delimiters=frozenset({"()"})) # type: ignore[arg-type] def test_patronymic_rules_rejects_bare_string_and_non_iterable() -> None: with pytest.raises(TypeError, match="bare string"): Policy(patronymic_rules="east-slavic") # type: ignore[arg-type] with pytest.raises(TypeError, match="iterable"): Policy(patronymic_rules=5) # type: ignore[arg-type] def test_patronymic_rules_true_points_at_the_v1_migration() -> None: # v1's patronymic_name_order was a BOOL flag, so True is the single # likeliest wrong value a migrator passes here -- the message must # name the v1 flag and the working replacements, not just say # "not iterable". Same guard on the PolicyPatch path (packs). with pytest.raises(TypeError, match="patronymic_name_order"): Policy(patronymic_rules=True) # type: ignore[arg-type] with pytest.raises(TypeError, match="patronymic_name_order"): PolicyPatch(patronymic_rules=True) # type: ignore[arg-type] # non-patronymic union fields get the generic curated message with pytest.raises(TypeError, match="extra_suffix_delimiters"): PolicyPatch(extra_suffix_delimiters=True) # type: ignore[arg-type] def test_maiden_delimiters_win_over_nickname_defaults() -> None: # A pair routes to exactly one field, and listing it in # maiden_delimiters is the specific intent -- so the one-liner works # without knowing (or rebuilding) the nickname default set. The # review that prompted this: routing parens to maiden used to # require both buckets edited in tandem. p = Policy(maiden_delimiters=frozenset({("(", ")")})) assert ("(", ")") in p.maiden_delimiters assert ("(", ")") not in p.nickname_delimiters # canonicalization: the explicit-removal spelling (the documented # set-math idiom) converges to the same value (equal AND same hash # -- cache keys agree) from nameparser import DEFAULT_NICKNAME_DELIMITERS explicit = Policy( nickname_delimiters=DEFAULT_NICKNAME_DELIMITERS - {("(", ")")}, maiden_delimiters=frozenset({("(", ")")}), ) assert p == explicit and hash(p) == hash(explicit) def test_maiden_wins_applies_to_typographic_pairs_too() -> None: # the subtraction is pair-agnostic; pin one #273 pair end-to-end from nameparser import Parser p = Policy(maiden_delimiters=frozenset({("«", "»")})) assert ("«", "»") not in p.nickname_delimiters n = Parser(policy=p).parse("Jean «Dupont» Martin") assert n.maiden == "Dupont" and n.nickname == "" def test_maiden_precedence_applies_through_policy_patch() -> None: # apply_patch re-runs Policy's constructor, so a patch (e.g. from a # locale pack) adding a maiden pair gets the same subtraction patched = apply_patch( Policy(), PolicyPatch(maiden_delimiters=frozenset({("(", ")")}))) assert ("(", ")") in patched.maiden_delimiters assert ("(", ")") not in patched.nickname_delimiters def test_default_nickname_delimiters_constant_is_the_default() -> None: from nameparser import DEFAULT_NICKNAME_DELIMITERS assert Policy().nickname_delimiters == DEFAULT_NICKNAME_DELIMITERS # the documented set-math idiom stays valid input p = Policy(nickname_delimiters=DEFAULT_NICKNAME_DELIMITERS | {("«", "»")}) assert ("«", "»") in p.nickname_delimiters def test_policy_delimiters_coerce_to_frozensets() -> None: p = Policy(nickname_delimiters=[("(", ")")]) # type: ignore[arg-type] assert isinstance(p.nickname_delimiters, frozenset) assert isinstance(hash(p), int) assert p == Policy(nickname_delimiters=frozenset({("(", ")")})) def test_policy_delimiters_do_not_alias_caller_containers() -> None: source = {("(", ")")} p = Policy(nickname_delimiters=source) # type: ignore[arg-type] source.add(("'", "'")) assert ("'", "'") not in p.nickname_delimiters def test_extra_suffix_delimiters_validated_and_coerced() -> None: with pytest.raises(TypeError, match="bare string"): Policy(extra_suffix_delimiters="ab") # type: ignore[arg-type] with pytest.raises(TypeError, match="must be strings"): Policy(extra_suffix_delimiters=frozenset({5})) # type: ignore[arg-type] with pytest.raises(ValueError, match="non-empty strings"): Policy(extra_suffix_delimiters=frozenset({""})) p = Policy(extra_suffix_delimiters=["-"]) # type: ignore[arg-type] assert p.extra_suffix_delimiters == frozenset({"-"}) assert isinstance(hash(p), int) def test_non_iterable_fields_name_the_offending_field() -> None: # Non-iterables (an int, a bool) used to fall through to tuple()/ # frozenset() and surface as a bare "'int' object is not iterable", # with no hint which field was wrong -- unlike patronymic_rules, # which has always named itself. Every iterable-typed Policy field # gets the same treatment. with pytest.raises(TypeError, match="name_order"): Policy(name_order=5) # type: ignore[arg-type] with pytest.raises(TypeError, match="nickname_delimiters"): Policy(nickname_delimiters=5) # type: ignore[arg-type] with pytest.raises(TypeError, match="maiden_delimiters"): Policy(maiden_delimiters=5) # type: ignore[arg-type] with pytest.raises(TypeError, match="extra_suffix_delimiters"): Policy(extra_suffix_delimiters=5) # type: ignore[arg-type] def test_policy_patch_mirrors_policy_field_names() -> None: policy_fields = {f.name for f in dataclasses.fields(Policy)} patch_fields = {f.name for f in dataclasses.fields(PolicyPatch)} assert policy_fields == patch_fields def test_policy_patch_mirrors_policy_field_types() -> None: for f in dataclasses.fields(Policy): patch_annotation = PolicyPatch.__dataclass_fields__[f.name].type assert patch_annotation == f"{f.type} | _Unset" def test_policy_patch_canonicalizes_union_fields() -> None: p = PolicyPatch(extra_suffix_delimiters=frozenset({"-"})) assert isinstance(p.extra_suffix_delimiters, frozenset) assert isinstance(hash(p), int) out = apply_patch(Policy(), PolicyPatch(extra_suffix_delimiters=["-"])) # type: ignore[arg-type] assert out.extra_suffix_delimiters == frozenset({"-"}) def test_policy_patch_rejects_bare_string_union_fields() -> None: with pytest.raises(TypeError, match="bare string"): PolicyPatch(extra_suffix_delimiters="ab") # type: ignore[arg-type] def test_apply_patch_overrides_scalars_and_unions_sets() -> None: base = Policy(patronymic_rules=frozenset({PatronymicRule.EAST_SLAVIC})) patch = PolicyPatch( name_order=FAMILY_FIRST, patronymic_rules=frozenset({PatronymicRule.TURKIC}), ) out = apply_patch(base, patch) assert out.name_order == FAMILY_FIRST # override assert out.patronymic_rules == frozenset( # union {PatronymicRule.EAST_SLAVIC, PatronymicRule.TURKIC}) assert out.strip_emoji is True # untouched def test_apply_patch_with_empty_patch_returns_same_policy() -> None: base = Policy() assert apply_patch(base, PolicyPatch()) is base def test_unset_fields_are_distinguishable_from_defaults() -> None: patch = PolicyPatch(strip_emoji=True) # explicitly set to the default value assert patch.strip_emoji is True assert PolicyPatch().strip_emoji is UNSET def test_policy_rejects_non_bool_flags() -> None: # "no" and "false" are truthy: storing them would silently invert # the caller's intent downstream. # # Swept over the DATACLASS, not over a list written here. The list # this replaced named 2.3's four flags and was never extended when # 2.4 added `unlisted_dotted_suffixes` and `unlisted_caps_suffixes`, # so both shipped with no coverage for the check the library was # already making -- the drift AGENTS.md's guard-the-whole-family # rule is about, in the test rather than in the guard. assert set(_BOOL_FIELDS) == { f.name for f in dataclasses.fields(Policy) if isinstance(getattr(Policy(), f.name), bool)} for flag in _BOOL_FIELDS: with pytest.raises(TypeError, match="must be a bool"): Policy(**{flag: "no"}) # type: ignore[arg-type] #: 2.3's Policy fields, in 2.3's order, read off the released tree with #: `git show 1f78bef:nameparser/_policy.py`. A dated snapshot of an #: immutable commit, so it cannot go stale (AGENTS.md's counting #: claims). _FIELDS_AT_2_3 = ( "name_order", "script_orders", "segment_scripts", "patronymic_rules", "middle_as_family", "nickname_delimiters", "maiden_delimiters", "extra_suffix_delimiters", "lenient_comma_suffixes", "strip_emoji", "strip_bidi", ) def test_the_2_3_positional_fields_did_not_move() -> None: # Policy and PolicyPatch are not `kw_only`, so a field's POSITION # is API: `Policy(GIVEN_FIRST, ..., True, False)` binds by # position, and inserting a field mid-class silently re-binds every # argument after it. 2.4's two switches were first written beside # `lenient_comma_suffixes`, which moved `strip_emoji` and # `strip_bidi` two places to the right; they are appended now, and # this is what holds them there. for cls in (Policy, PolicyPatch): names = tuple(f.name for f in dataclasses.fields(cls)) assert names[:len(_FIELDS_AT_2_3)] == _FIELDS_AT_2_3, cls.__name__ # each later cycle's fields appended behind the last: 2.4's two, # then 2.6's two_family_names assert names[len(_FIELDS_AT_2_3):] == ( "unlisted_dotted_suffixes", "unlisted_caps_suffixes", "two_family_names"), cls.__name__ # the positional binding itself, not just the names assert Policy(GIVEN_FIRST, (), frozenset(), frozenset(), False, frozenset(), frozenset(), frozenset(), True, False).strip_emoji is False def test_patronymic_rules_generator_errors_propagate_untouched() -> None: # A ValueError raised inside the caller's own generator must not be # rewritten as "unknown patronymic rule" with the traceback erased. def bad_loader(): # noqa: ANN202 yield "east-slavic" raise ValueError("config line 7: bad int") with pytest.raises(ValueError, match="config line 7"): Policy(patronymic_rules=bad_loader()) # type: ignore[arg-type] def test_unknown_patronymic_rule_error_names_the_offender() -> None: with pytest.raises(ValueError, match="klingon"): Policy(patronymic_rules=iter(["east-slavic", "klingon"])) # type: ignore[arg-type] def test_policy_patch_canonicalizes_scalar_name_order() -> None: # A list name_order stored as-is made the patch -- and any Locale # holding it -- unhashable, failing far from the construction site. p = PolicyPatch(name_order=[Role.FAMILY, Role.GIVEN, Role.MIDDLE]) # type: ignore[arg-type] assert p.name_order == (Role.FAMILY, Role.GIVEN, Role.MIDDLE) assert isinstance(hash(p), int) def test_policy_patch_canonicalizes_script_orders_all_the_way_down() -> None: # Same failure as name_order above, one level deeper: tuple-izing # only the (Script, order) pair left the ORDER a list, so the patch # -- and any Locale holding it -- was still unhashable. p = PolicyPatch(script_orders={Script.HAN: [Role.FAMILY, Role.GIVEN, # type: ignore[arg-type] Role.MIDDLE]}) assert p.script_orders == ((Script.HAN, FAMILY_FIRST),) assert isinstance(hash(p), int) def test_apply_patch_overrides_script_orders_as_a_scalar() -> None: # script_orders composes as a SCALAR: a patch REPLACES the table # rather than merging into it, so an empty one is how a pack turns # the default off. An UNSET patch must leave the default alone -- # the distinction a union field would blur. cleared = apply_patch(Policy(), PolicyPatch(script_orders={})) # type: ignore[arg-type] assert cleared.script_orders == () untouched = apply_patch(Policy(), PolicyPatch()) assert untouched.script_orders == DEFAULT_SCRIPT_ORDERS replaced = apply_patch( Policy(), PolicyPatch(script_orders={Script.HANGUL: GIVEN_FIRST})) # type: ignore[arg-type] assert replaced.script_orders == ((Script.HANGUL, GIVEN_FIRST),) def test_apply_patch_revalidates_deferred_script_orders() -> None: # The value half defers exactly like name_order's, and the error # must name script_orders -- not name_order, whose message the # check is shared with. bad = PolicyPatch(script_orders={Script.HAN: (Role.MIDDLE, Role.GIVEN, # type: ignore[arg-type] Role.FAMILY)}) with pytest.raises(ValueError, match="script_orders must be one of"): apply_patch(Policy(), bad) def test_policy_patch_defers_malformed_script_orders_verbatim() -> None: # The canonicalization must not eat the evidence: a bare string # would shred to character tuples (its elements are themselves # tuple-izable, so nothing raises to stop it) and bytes to ints, # leaving Policy's curated messages nothing of the caller's to # quote. Both shapes must survive the patch and raise at APPLY. with pytest.raises(TypeError, match="bare string"): apply_patch(Policy(), PolicyPatch(script_orders="han")) # type: ignore[arg-type] with pytest.raises(TypeError, match="decode first"): apply_patch(Policy(), PolicyPatch(script_orders=b"han")) # type: ignore[arg-type] def test_apply_patch_revalidates_deferred_values() -> None: # PolicyPatch documents lazy validation: invalid values sit latent in # the patch and must fail when applied, not silently flow into Policy. bad_order = PolicyPatch(name_order=(Role.TITLE, Role.GIVEN, Role.FAMILY)) with pytest.raises(ValueError, match="exported orders"): apply_patch(Policy(), bad_order) bad_rules = PolicyPatch(patronymic_rules=frozenset({"klingon"})) # type: ignore[arg-type] with pytest.raises(ValueError, match="valid rules"): apply_patch(Policy(), bad_rules) def test_all_set_valued_patch_fields_declare_union_composition() -> None: # apply_patch is driven by this metadata; dropping it from one field # would silently flip locale layering from union to override. union_fields = { f.name for f in dataclasses.fields(PolicyPatch) if f.metadata.get("compose") == "union" } assert union_fields == { "patronymic_rules", "nickname_delimiters", "maiden_delimiters", "extra_suffix_delimiters", "segment_scripts", } def test_policy_and_patch_pickle_round_trip_preserves_unset_identity() -> None: import pickle p = Policy(patronymic_rules=frozenset({PatronymicRule.TURKIC})) assert pickle.loads(pickle.dumps(p)) == p patch = PolicyPatch(strip_emoji=False) loaded = pickle.loads(pickle.dumps(patch)) assert loaded == patch # apply_patch gates on 'value is UNSET'; an unpickled patch is only # correct because Enum members round-trip BY IDENTITY. A plain # object() sentinel would break this silently. assert loaded.name_order is UNSET assert loaded.strip_emoji is False def test_setstate_rejects_layout_skew() -> None: state = dict(Policy().__getstate__()) del state["name_order"] with pytest.raises(ValueError, match="name_order"): Policy.__new__(Policy).__setstate__(state) def test_name_order_rejects_bare_string() -> None: # tuple("gmf") is ("g","m","f"): without the guard a bare string # fell through to the permutation ValueError instead of the # taxonomy's bare-string TypeError every other iterable field raises with pytest.raises(TypeError, match="bare string"): Policy(name_order="gmf") # type: ignore[arg-type] with pytest.raises(TypeError, match="bare string"): PolicyPatch(name_order="gmf") # type: ignore[arg-type] # Policy's collection fields had no guard against the two shapes # Lexicon._normset rejects by name: a bare string (iterates into # characters) and a Mapping (contributes only its keys). Both were # accepted silently, storing a value the caller never wrote -- the # expensive failure this library guards against elsewhere. # # Parametrized over BOTH classes on purpose. PolicyPatch had its own # inline copy of the bare-string half and never grew the mapping half, # so the two silently diverged; a patch is also what a Locale pack # ships, and its frozenset() coercion destroys the evidence before # Policy could catch it at apply time. _GUARDED = ["nickname_delimiters", "maiden_delimiters", "extra_suffix_delimiters", "patronymic_rules", "segment_scripts"] @pytest.mark.parametrize("cls", [Policy, PolicyPatch]) @pytest.mark.parametrize("field", _GUARDED) @pytest.mark.parametrize("value", [{"dr": "Dr"}, {}]) def test_collection_fields_reject_a_mapping( cls: type, field: str, value: dict) -> None: # {} is the sharp one: it is Python's empty-set trap, so a caller # disabling delimiters writes it and silently gets what they meant # -- until the day they write a non-empty one. with pytest.raises(TypeError, match="mapping"): cls(**{field: value}) @pytest.mark.parametrize("cls", [Policy, PolicyPatch]) @pytest.mark.parametrize("field", _GUARDED) @pytest.mark.parametrize("value", ["", "()", "dr"]) def test_collection_fields_reject_a_bare_string( cls: type, field: str, value: str) -> None: # '' iterates to nothing, so it silently stored an empty frozenset # rather than failing. with pytest.raises(TypeError, match="bare string"): cls(**{field: value}) @pytest.mark.parametrize("cls", [Policy, PolicyPatch]) @pytest.mark.parametrize("field,items,expected", [ ("nickname_delimiters", [("<", ">")], frozenset({("<", ">")})), ("maiden_delimiters", [("[", "]")], frozenset({("[", "]")})), ("extra_suffix_delimiters", ["/"], frozenset({"/"})), ("patronymic_rules", ["turkic"], frozenset({PatronymicRule.TURKIC})), ("segment_scripts", [Script.HAN], frozenset({Script.HAN})), ]) def test_legitimate_iterables_are_accepted_and_stored_intact( cls: type, field: str, items: list, expected: frozenset) -> None: """The guards must not over-reject -- and must not consume. Asserts the STORED value, not merely that construction returned. `assert cls(...) is not None` is a tautology for a dataclass: it passes for a guard that accepts a one-shot iterable, exhausts it, and stores frozenset(). That is the same "stored a value the caller never wrote" failure the guards exist to prevent, so the generator and iter() entries below are the ones that matter. All four fields, both classes: an over-strict guard on maiden_delimiters was invisible to the entire suite, because every other call site happens to pass it a frozenset. """ for value in (frozenset(items), set(items), list(items), tuple(items), (x for x in items), iter(items)): stored = getattr(cls(**{field: value}), field) assert stored == expected, ( f"{cls.__name__}.{field} stored {stored!r} for " f"{type(value).__name__}") @pytest.mark.parametrize("cls", [Policy, PolicyPatch]) def test_a_pair_mapping_is_accepted_through_items(cls: type) -> None: # The escape the mapping guard's message recommends. It works only # because ItemsView is a Set rather than a Mapping; if that ever # stops being true, the advice becomes wrong and this fails. stored = cls(nickname_delimiters={"<": ">"}.items()).nickname_delimiters assert stored == frozenset({("<", ">")}) @pytest.mark.parametrize("cls", [Policy, PolicyPatch]) @pytest.mark.parametrize("value", [b" - ", bytearray(b" - "), memoryview(b" - ")]) @pytest.mark.parametrize( "field", ["extra_suffix_delimiters", "nickname_delimiters", "maiden_delimiters", "patronymic_rules", "name_order", "segment_scripts"]) def test_every_field_rejects_a_buffer_with_a_decode_hint( cls: type, field: str, value: object) -> None: # Same cryptic int message Lexicon had, and name_order was the one # field the first pass missed. with pytest.raises(TypeError, match="decode"): cls(**{field: value}) def test_patched_unions_set_valued_fields() -> None: base = Policy(extra_suffix_delimiters={"|"}) # type: ignore[arg-type] out = base.patched( PolicyPatch(extra_suffix_delimiters={"/"})) # type: ignore[arg-type] assert out.extra_suffix_delimiters == frozenset({"|", "/"}) def test_patched_overrides_scalars_and_leaves_unset_alone() -> None: base = Policy(middle_as_family=True, lenient_comma_suffixes=False) out = base.patched(PolicyPatch(strip_emoji=False)) assert out.strip_emoji is False assert out.middle_as_family is True assert out.lenient_comma_suffixes is False def test_patched_with_empty_patch_returns_same_policy() -> None: base = Policy(middle_as_family=True) assert base.patched(PolicyPatch()) is base def test_patched_validates_patch_values_at_apply_time() -> None: # PolicyPatch scalars validate lazily by design; the error surfaces # when the patch is applied. patch = PolicyPatch(strip_emoji="off") # type: ignore[arg-type] with pytest.raises(TypeError, match="strip_emoji must be a bool"): Policy().patched(patch) def test_patched_rejects_non_patch() -> None: with pytest.raises(TypeError, match="takes a PolicyPatch"): Policy().patched({"strip_emoji": False}) # type: ignore[arg-type] def test_script_orders_default_and_canonical_storage() -> None: p = Policy() assert p.script_orders == DEFAULT_SCRIPT_ORDERS # three entries now (#272 adds HIRAGANA), sorted by Script value: # "han" < "hangul" < "hiragana" assert p.script_orders == ( (Script.HAN, FAMILY_FIRST), (Script.HANGUL, FAMILY_FIRST), (Script.HIRAGANA, FAMILY_FIRST), ) # constructor tolerates a Mapping; storage is the sorted pair # tuple (hashability, the capitalization_exceptions precedent -- # which is also why the mapping spelling needs the ignore: the # field is annotated with what it STORES) q = Policy(script_orders={Script.HANGUL: FAMILY_FIRST, # type: ignore[arg-type] Script.HAN: FAMILY_FIRST, Script.HIRAGANA: FAMILY_FIRST}) assert q == p and hash(q) == hash(p) assert Policy(script_orders={}).script_orders == () # type: ignore[arg-type] def test_a_later_script_orders_entry_replaces_an_earlier_one() -> None: """Appending is how you override a single script, and the docs now say so (modules.rst, the script_orders entry). The behavior falls out of _validated_script_orders building a dict before sorting, so it was emergent rather than contracted -- and prose that tells readers to append is prose that depends on it. The type-clean spelling for "everything as shipped, but Han reads given-first" is exactly this, which is why it is worth pinning. """ overridden = Policy( script_orders=(*DEFAULT_SCRIPT_ORDERS, (Script.HAN, GIVEN_FIRST))) assert dict(overridden.script_orders)[Script.HAN] == GIVEN_FIRST # the other shipped entries survive untouched assert dict(overridden.script_orders)[Script.HANGUL] == FAMILY_FIRST assert dict(overridden.script_orders)[Script.HIRAGANA] == FAMILY_FIRST # one entry per script, whatever the input carried keys = [s for s, _ in overridden.script_orders] assert len(keys) == len(set(keys)) == len(DEFAULT_SCRIPT_ORDERS) # and the mapping spelling it replaces still means the same thing assert overridden == Policy( script_orders=dict(DEFAULT_SCRIPT_ORDERS) # type: ignore[arg-type] | {Script.HAN: GIVEN_FIRST}) def test_script_orders_validates_keys_and_values() -> None: with pytest.raises(ValueError, match="han, hangul"): Policy(script_orders={"klingon": FAMILY_FIRST}) # type: ignore[arg-type] with pytest.raises(ValueError, match="exported orders"): Policy(script_orders={Script.HAN: (Role.MIDDLE, Role.GIVEN, # type: ignore[arg-type] Role.FAMILY)}) with pytest.raises(TypeError, match="bare string"): Policy(script_orders="han") # type: ignore[arg-type] def test_segment_scripts_defaults_to_hangul_and_coerces() -> None: # hangul segmentation is default-on (amendment 2026-07-27): # hangul is unambiguously Korean and the surname set is closed assert Policy().segment_scripts == frozenset({Script.HANGUL}) p = Policy(segment_scripts=["han", Script.HANGUL]) # type: ignore[arg-type] assert p.segment_scripts == frozenset({Script.HAN, Script.HANGUL}) assert Policy(segment_scripts=()).segment_scripts == frozenset() # type: ignore[arg-type] def test_segment_scripts_rejects_unknown_script() -> None: # bare-string/mapping rejection is covered by the _GUARDED family # sweep (test_collection_fields_reject_a_bare_string / # test_collection_fields_reject_a_mapping); this pins the one # check unique to segment_scripts -- the unknown-script message. with pytest.raises(ValueError, match="han, hangul"): Policy(segment_scripts={"klingon"}) # type: ignore[arg-type] def test_segment_scripts_rejects_non_iterable_with_curated_message() -> None: # parallel to patronymic_rules: name the expected shape rather than # letting _require_iterable's default "an iterable" phrasing stand with pytest.raises(TypeError, match="segment_scripts must be an iterable of " "Script members, got True"): Policy(segment_scripts=True) # type: ignore[arg-type] def test_segment_scripts_patch_unions() -> None: patched = apply_patch( Policy(), PolicyPatch(segment_scripts={Script.HAN})) # type: ignore[arg-type] assert patched.segment_scripts == frozenset( {Script.HAN, Script.HANGUL}) stringly = apply_patch( Policy(), PolicyPatch(segment_scripts={"han"})) # type: ignore[arg-type] assert all(isinstance(s, Script) for s in stringly.segment_scripts) # One item per field, used to build a bad_iter() below that yields a # legitimate first entry, then raises -- forcing consumption past the # iter()-probe stage so a rewrite-vs-propagate bug can only be caught # by actually running the generator. _PROPAGATION_FIELD_VALUES = { "segment_scripts": Script.HAN, "patronymic_rules": PatronymicRule.TURKIC, "script_orders": (Script.HAN, FAMILY_FIRST), "nickname_delimiters": ("<", ">"), "maiden_delimiters": ("[", "]"), "extra_suffix_delimiters": "/", } @pytest.mark.parametrize("cls,field", [ (Policy, "segment_scripts"), (Policy, "patronymic_rules"), (Policy, "script_orders"), (Policy, "nickname_delimiters"), (Policy, "maiden_delimiters"), (Policy, "extra_suffix_delimiters"), (PolicyPatch, "segment_scripts"), (PolicyPatch, "patronymic_rules"), (PolicyPatch, "script_orders"), (PolicyPatch, "nickname_delimiters"), (PolicyPatch, "maiden_delimiters"), (PolicyPatch, "extra_suffix_delimiters"), ]) def test_caller_generator_errors_propagate_untouched( cls: type, field: str) -> None: """A TypeError raised INSIDE a caller's generator, after it has already yielded a legitimate value, must reach the caller with its own message -- not be rewritten as "not an iterable" by whichever guard happens to consume the generator to check its shape. Probe-only-in-try is what makes this true: iter() succeeds (so the guard doesn't fire), and only the SEPARATE, unguarded consumption step sees the generator's own exception. PolicyPatch.script_orders is the scalar (non-union) field with its own canonicalization block, distinct from the shared union loop the other fields go through below -- it needs the identical probe/consume split, since deferring a caller-generator error to apply time is impossible once the generator is already exhausted (verified: the error would otherwise be lost for good, not merely delayed).""" def bad_iter() -> Iterator[object]: yield _PROPAGATION_FIELD_VALUES[field] raise TypeError("boom") with pytest.raises(TypeError, match="boom"): cls(**{field: bad_iter()}) def test_policy_patch_script_orders_non_iterable_still_defers_to_apply() -> None: # The deferral contract this fix must NOT break: a genuinely # non-iterable script_orders value is not a caller-generator error # -- it fails the iter() probe itself, so PolicyPatch construction # stays lazy (and hashable) and Policy's own guard raises the # curated message once the patch is actually applied. patch = PolicyPatch(script_orders=5) # type: ignore[arg-type] assert isinstance(hash(patch), int) with pytest.raises(TypeError, match="script_orders must be a mapping"): apply_patch(Policy(), patch) def test_canonical_script_pair_defers_each_malformed_shape() -> None: # _canonical_script_pair's three per-pair deferral branches, each # constructible (hashable) and each quoted by Policy's own guard at # apply time: a pair of the wrong arity; a str order value (which # tuple() would shred to characters); a non-iterable order value. arity = PolicyPatch( script_orders=[(Script.HAN, FAMILY_FIRST, "extra")]) # type: ignore[arg-type] assert isinstance(hash(arity), int) with pytest.raises(TypeError, match=r"\(Script, order\) pairs"): apply_patch(Policy(), arity) stringly = PolicyPatch(script_orders={Script.HAN: "gmf"}) # type: ignore[arg-type] assert isinstance(hash(stringly), int) with pytest.raises(TypeError, match="bare string"): apply_patch(Policy(), stringly) lone = PolicyPatch(script_orders={Script.HAN: 5}) # type: ignore[arg-type] assert isinstance(hash(lone), int) with pytest.raises(TypeError, match="must be an iterable, got 5"): apply_patch(Policy(), lone) def test_policy_patch_one_shot_bad_tail_defers_without_silent_drop() -> None: # The silent-drop repro: a ONE-SHOT generator whose first item is a # good pair and whose second is a bad shape (not caller-generator # code raising -- just a malformed entry). PolicyPatch construction # must not raise (this is a shape problem, deferred to apply time, # like the bytes case), but the exhausted-generator hole meant # apply_patch used to silently store () -- "opt out of per-script # ordering" -- with NO error anywhere, dropping the Han/Hangul # family-first defaults. The list form of the identical input # already raised correctly; only the one-shot form leaked. gen = (x for x in [(Script.HAN, FAMILY_FIRST), 5]) patch = PolicyPatch(script_orders=gen) # type: ignore[arg-type] # Materialized into a re-iterable tuple, not left as the exhausted # generator -- Policy can still quote the caller's bad entry (5). assert patch.script_orders == ((Script.HAN, FAMILY_FIRST), 5) with pytest.raises(TypeError, match=r"script_orders entries must be .* got 5"): apply_patch(Policy(), patch) def test_unlisted_dotted_suffixes_is_a_validated_bool_defaulting_on() -> None: # #516's dotted half is a switch, and it is ON: a token of two or # more period-separated chunks that no vocabulary claims reads by # position, which is what the periods are for. assert Policy().unlisted_dotted_suffixes is True assert Policy(unlisted_dotted_suffixes=False).unlisted_dotted_suffixes \ is False with pytest.raises(TypeError, match="unlisted_dotted_suffixes"): Policy(unlisted_dotted_suffixes="no") # type: ignore[arg-type] # the patch mirrors it as a SCALAR, override not merge assert Policy().patched( PolicyPatch(unlisted_dotted_suffixes=False) ).unlisted_dotted_suffixes is False def test_unlisted_dotted_suffixes_off_reads_name_material() -> None: # The switch's whole behavior, both directions, on one name -- # "name material" for a token no chunk claims; the roman-chunk # retirement (rules/S3) and real chunk-level vocabulary # ('Msc.Ed.', 'JD.CPA') are not behind this switch either way. from nameparser import Parser on = Parser().parse("John Smith X.Y.Z.") off = Parser(policy=Policy(unlisted_dotted_suffixes=False)).parse( "John Smith X.Y.Z.") assert (on.family, on.suffix) == ("Smith", "X.Y.Z.") assert (off.middle, off.family, off.suffix) == ("Smith", "X.Y.Z.", "") # OFF still reports: the parser chose the name reading over a # credential one, and that is the fork (#516) assert [a.kind.value for a in off.ambiguities] == ["suffix-or-name"] # and the vocabulary is untouched either way for p in (Parser(), Parser(policy=Policy(unlisted_dotted_suffixes=False))): assert p.parse("John Smith M.A.").suffix == "M.A." assert p.parse("Doe, John Msc.Ed.").suffix == "Msc.Ed." def test_unlisted_caps_suffixes_is_a_validated_enum_defaulting_after_comma( ) -> None: # #564: the all-caps half reads the comma position by default and # the trailing one only on request -- an all-caps SURNAME is a real # convention there, and never after a comma behind a full name. assert Policy().unlisted_caps_suffixes is CapsSuffixes.AFTER_COMMA # a plain string is coerced at runtime; the annotation names what # the field stores, so the type checker wants the member assert (Policy(unlisted_caps_suffixes="everywhere") # type: ignore[arg-type] .unlisted_caps_suffixes is CapsSuffixes.EVERYWHERE) assert Policy().patched( PolicyPatch(unlisted_caps_suffixes=CapsSuffixes.OFF) ).unlisted_caps_suffixes is CapsSuffixes.OFF with pytest.raises(ValueError, match="after-comma, everywhere"): Policy(unlisted_caps_suffixes="yes") # type: ignore[arg-type] # a bool: a TypeError naming the members, in a form that # type-checks when pasted (AGENTS.md) for old, new in ((True, "CapsSuffixes.EVERYWHERE"), (False, "CapsSuffixes.OFF")): with pytest.raises(TypeError, match=new.replace(".", r"\.")): Policy(unlisted_caps_suffixes=old) # type: ignore[arg-type] def test_unlisted_caps_suffixes_on_reads_an_all_caps_word() -> None: # Frames, recorded honestly (#516 review round, F4, corrected # 2026-09-18): the switch is OPT-IN and OFF-BAND -- # `tools/perf/call_count.py`'s reference name and the # DEFAULT-policy comma harness both measure +0, the only figures # test_benchmark.py's band gates. With the switch ON, a genuine # comma candidate pays for the fact it forces: re-measured # 2026-09-18 on this tree, same-interpreter harness (the resolved # `sys.executable` used on both sides, `Parser().parse` and # `Parser(policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE)).parse`, mean # of 50 after one warm-up parse) -- `"Smith, John"` is +7 # (206 -> 213), `"Smith, XYZ"` (a real candidate) is +40 # (205 -> 245). An earlier round's comment here read +6/+39 # against baselines 207/206, which do not reproduce (this tree's # own default reading of the two names is 206/205, matching # f7089763 exactly); the earlier round's post-consolidation # measurement itself was one frame off on each name, not the # consolidation's own effect, which still stands (the prior, # three-copies-of-the-predicate tree, cbd87a7d, reads 215 and 256 # with the switch on -- +9/+51 against the same 206/205 baseline, # so the consolidation saved 2 and 11). Nothing gates either number; # they are reported here, dated, so a reader who turns the switch # on knows what it costs and a later re-measurement does not read # as a silent drift. # # 2026-10-01, #564: the default is now AFTER_COMMA, which reads the # comma position, so `default` below is no longer "off" and these # assertions are about the TRAILING slot EVERYWHERE adds. Default # costs, same harness against master: 'Smith, John' 183 -> 183, # 'Smith, XYZ' 182 -> 182 (the comma test needs two words before # the comma), 'John Smith, XYZ' 251 -> 266 (decisions/S9). from nameparser import Parser on = Parser(policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE)) default = Parser() # what it buys assert on.parse("John Smith XYZ").suffix == "XYZ" assert on.parse("John Smith, XYZ").suffix == "XYZ" # what it costs, and why the trailing slot is not the default assert on.parse("Jean Pierre DUPONT").suffix == "DUPONT" assert default.parse("Jean Pierre DUPONT").family == "DUPONT" # the default emits nothing at the trailing slot assert default.parse("John Smith XYZ").ambiguities == () # the boundaries: one case, one letter, a digit, and vocabulary. # 'John Smith X' is NOT the single-letter control -- 'X' is a bare # roman numeral (the trailing numeral fork, # `_vocab.reads_as_generation`) and reads as suffix # 'X' with the switch either way, unrelated to this one; 'Z' is # not vocabulary at all and stays unaffected by the switch, which # is the actual boundary (a single capital never satisfies the # `len(text) >= 2` half of the shape test, on or off). assert on.parse("JOHN SMITH XYZ").family == "XYZ" assert on.parse("John Smith Z").family == default.parse("John Smith Z").family assert on.parse("John Smith XY2").family == "XY2" assert on.parse("John Smith MC").suffix == "MC" ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_properties.py���������������������������������������0000664�0000000�0000000�00000561044�15273032717�0024217�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Property layer. Hypothesis is a dev dependency only. The alphabet is punctuation-heavy on purpose: plain st.text() spreads over all of Unicode, so commas, quotes, and delimiters almost never appear and the interesting planes go unexercised. derandomize=True keeps runs reproducible on shared CI runners -- this layer guards against regressions; exploratory fuzzing happened during review. """ import dataclasses import functools import hashlib import itertools import re import unicodedata import warnings import pytest from hypothesis import given, settings from hypothesis import strategies as st from nameparser._policy import CapsSuffixes from nameparser import ( DEFAULT_SCRIPT_ORDERS, FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST, GIVEN_FIRST, HumanName, Lexicon, Parser, PatronymicRule, Policy, Script, parse, ) from nameparser.config import Constants from nameparser._lexicon import _VOCAB_FIELDS from nameparser._pipeline import run from nameparser._pipeline._state import (AMBIGUOUS_ACRONYM_TAG, ParseState) from nameparser._pipeline._vocab import ambiguous_lean, effective_script from nameparser._types import (SHAPE_ACRONYM_TAG, UNJOINED_CONJUNCTION_TAG, UNJOINED_TAG, AmbiguityKind, ParsedName, Role, Token) from .cases import CASES from .conftest import differential_corpus _ALPHABET = st.sampled_from( 'abcdefgh ABC 12 .,،,\'"()«»‏‏\U0001f600éñßЖ-') _FORK_CORPUS = differential_corpus() @given(st.text(alphabet=_ALPHABET, max_size=200)) @settings(max_examples=300, deadline=None, derandomize=True) def test_parse_never_raises_on_str(text: str) -> None: parse(text) @given(st.text(alphabet=_ALPHABET, max_size=200)) @settings(max_examples=300, deadline=None, derandomize=True) def test_provenance_for_parser_produced_names(text: str) -> None: pn = parse(text) for t in pn.tokens: assert t.span is not None assert t.text == pn.original[t.span.start:t.span.end] @given(st.text(alphabet=_ALPHABET, max_size=100)) @settings(max_examples=200, deadline=None, derandomize=True) def test_capitalized_idempotent(text: str) -> None: once = parse(text).capitalized() assert once.capitalized() == once @given(st.text(alphabet=st.sampled_from("aAbBmcC.ʿʻʼn3 -'"), max_size=40)) @settings(max_examples=200, deadline=None, derandomize=True) def test_capitalized_is_a_fixpoint_past_full_stops_and_modifiers( text: str) -> None: # _ALPHABET holds no modifier letter (Lm) and is shared, so this # alphabet is its own: full stops, Lm leads, 'ʼn' and a Mac prefix, # under both views (#838) for force in (False, True): once = parse(text).capitalized(force=force) assert once.capitalized(force=force) == once @given(st.text(alphabet=_ALPHABET, max_size=100)) @settings(max_examples=200, deadline=None, derandomize=True) def test_render_reparse_reaches_fixpoint(text: str) -> None: # render/reparse legitimately takes several rounds to stabilize on # comma-heavy input (each round can re-segment); the invariant is # BOUNDED CONVERGENCE, not one-step idempotence s = str(parse(text)) for _ in range(10): nxt = str(parse(s)) if nxt == s: break s = nxt assert str(parse(s)) == s, f"no fixpoint within 10 rounds: {s!r}" @given(st.text(alphabet=_ALPHABET, max_size=100)) @settings(max_examples=300, deadline=None, derandomize=True) def test_every_original_char_is_accounted_for(text: str) -> None: # Reverse coverage (the dual of provenance): no character of the # input silently vanishes. Every char lies in a token span, a # masked delimited span, or is individually ignorable -- whitespace, # a structural comma, or a char the strip options remove. Checked on # the pre-assembly state because dropped/extracted tokens keep their # spans there. state = run(ParseState(original=text, lexicon=Lexicon.default(), policy=Policy())) covered: set[int] = set() for tok in state.tokens: covered.update(range(tok.span.start, tok.span.end)) for span in state.masked: covered.update(range(span.start, span.end)) ignorable = {",", "،", ",", "\U0001f600", "‏"} for i, ch in enumerate(text): if i in covered or ch.isspace() or ch in ignorable: continue raise AssertionError( f"char {ch!r} at {i} in {text!r} is unaccounted for") _NAME_ROLES = (Role.GIVEN, Role.MIDDLE, Role.FAMILY) def _fork_count(state: ParseState) -> int: return sum(a.kind is AmbiguityKind.PARTICLE_OR_GIVEN for a in state.ambiguities) def test_a_leading_ambiguous_particle_is_reported_once_and_only_once( ) -> None: """PARTICLE_OR_GIVEN is the one kind two stages emit: _group takes the particle branch when something shifts the particle off the name's leading piece and the chain claims something ("Freiherr von Richthofen"), _assign takes the given branch when it stays a lone leading piece ("Van Johnson", and since #367 "Dr. Van Johnson" as well -- a plain title is transparent, so only a leading word that is BOTH a title and a particle still reaches _group's emitter, which is what the 'Freiherr ' lead below exercises). Each reports the side it decides -- see the ParseState docstring -- but they coordinate only through _group's `j > k + 1` guard, which mirrors _assign's reachability by hand. Nothing checked the mirror. The shape that separates them is the one real-name corpora have least reason to contain: a suffix straight after the particle, where the chain is a no-op and only _assign should speak. Generate it, over every ambiguous particle, so a vocabulary addition is covered too. Deliberately asserts the COUNT and not which stage spoke. Whether a given fork was decided by a vocabulary merge or by position is not recoverable from the finished parse -- 'Dr. aan Johnson Jr.' and 'أبو بكر أحمد' end with the same roles and the same tags, and only one is a fork -- so any reconstruction here would have to re-implement _group rather than check it. """ # The 'Freiherr ' lead below has to be a title AND a particle, or it # is transparent to the leading-particle exception (#367), every # shape using it leaves _group's emitter for _assign's, and this # sweep goes on passing with the fork count unchanged -- coverage # lost silently, which is the failure this sweep is least able to # notice about itself. Supplied rather than borrowed from the shipped # vocabulary, for the reason test_parser.py's _TITLE_PARTICLES block # gives; a no-op against today's data, and #360-proof against # tomorrow's. lex = Lexicon.default().add(titles={"freiherr"}, particles={"freiherr"}) # bound-given prefixes are excluded, not overlooked: 'abu' is both # an ambiguous particle and a bound given prefix, so whether it # forks depends on whether the bound join fired -- a second rule, # covered by the case corpus rather than by this sweep particles = sorted(lex.particles_ambiguous - lex.bound_given_names) assert particles, "no ambiguous particles to exercise" failures = [] for particle in particles: for lead in ("", "Dr. ", "Dr. Ann ", "Freiherr "): for body in ("", "Johnson ", "Johnson Smith "): for tail in ("", "Jr.", "MD", "III"): text = f"{lead}{particle} {body}{tail}".strip() state = run(ParseState(original=text, lexicon=lex, policy=Policy())) names = [t for t in state.tokens if t.role in _NAME_ROLES] # Leading = no name part precedes it. With a given # name in front ("Dr. Ann van Johnson") the particle # sits mid-name, where nothing has to choose -- the # decision-not-a-word rule, so no report. And a lone # name part is the whole name, not a coin flip. leads = bool(names) and "vocab:particle-ambiguous" \ in names[0].tags want = 1 if leads and len(names) >= 2 else 0 got = _fork_count(state) if got != want: failures.append( f"{text!r}: {got} report(s), expected {want} " f"({len(names)} name tokens, " f"leading={leads})") assert not failures, ( f"{len(failures)} shape(s) disagree:\n" + "\n".join(failures[:15])) #: The three `do` rows P6's attachment owns by design. A CLOSED list #: with the rule that owns each one: a fourth exception fails, which is #: the whole point of carrying them by name rather than by count. _COMMA_AGREEMENT_EXCEPTIONS = { "John Doe do": "rules/P6 attaches the lower-case particle member", "John Q. Doe do": "rules/P6 attaches the lower-case particle member", "Mary Jo Doe do": "rules/P6 attaches the lower-case particle member", } def test_a_comma_form_and_its_comma_less_twin_agree_on_the_class( ) -> None: """#531's invariant, and the shape of the defect it closed. A word ending the given part of a family-comma listing and the same word ending the comma-less spelling of the same name must be put in the SAME CLASS -- credential (role SUFFIX) or name (any of the other six roles). CLASS, not FIELD, and the difference matters: 'John Doe Ma' reads family 'Ma' with middle 'Doe' while 'Doe, John Ma' reads middle 'Ma', different fields and both 'name', and this test must pass on that pair. Measured on this tree before #531: 48 of the 78 pairs disagreed, every one of them in the same direction -- the comma form declining a credential the comma-less form took. After: 3, and they are the allowlist above. """ words = [] for base in ("ba", "do", "ed", "jd", "ma", "x.y.z.", "q.w.e.r.t."): words += [base, base.upper(), base.capitalize()] words += ["phd", "PhD", "Jr", "xyz", "XYZ"] shapes = (("John Doe {w}", "Doe, John {w}"), ("John Q. Doe {w}", "Doe, John Q. {w}"), ("Mary Jo Doe {w}", "Doe, Mary Jo {w}")) parser = Parser() def side(name: object, word: str) -> str: for role in Role: value = getattr(name, role.value) if value == word or word in value.split(): return "credential" if role is Role.SUFFIX else "name" return "absent" failures = [] for word in words: for plain, comma in shapes: a = side(parser.parse(plain.format(w=word)), word) b = side(parser.parse(comma.format(w=word)), word) if a == b: continue key = plain.format(w=word) if key in _COMMA_AGREEMENT_EXCEPTIONS: continue failures.append( f"{key!r} reads {a} but {comma.format(w=word)!r} " f"reads {b}") assert not failures, ( f"{len(failures)} pair(s) disagree:\n" + "\n".join(failures[:15])) def test_the_comma_agreement_exceptions_are_all_still_exceptions( ) -> None: """The allowlist's own negative control: a row that stopped being an exception is an allowlist entry silently covering nothing, and the sweep above cannot notice that about itself.""" parser = Parser() for plain in _COMMA_AGREEMENT_EXCEPTIONS: comma_form = "Doe, " + plain.replace("John Doe ", "John ").replace( "John Q. Doe ", "John Q. ").replace("Mary Jo Doe ", "Mary Jo ") assert parser.parse(plain).suffix, plain assert not parser.parse(comma_form).suffix, comma_form # rules/C15's grid: the given parts behind the empty head, as title # runs before, bodies, and trailing words, crossed. Each list holds a # shape a comma rule has met: a title-particle and a dual second in a # run (nameparser/AGENTS.md's head lists), a given-name title, one # only inside a phrase, a particle run and a particle tail, a bound # given name, a nickname alone and inside, a maiden clause, a word of # the ambiguous credential class in each case, a generation word and # an agnomen that are names too, a given name written short, and the # trailing title, salutation and numerals the trailing peel reads. _EMPTY_HEAD_TITLES = ("", "Dr.", "Mr", "Sir", "Freiherr", "St.", "Rev. Sr.", "Dean", "Dame", "dr.") _EMPTY_HEAD_BODIES = ( "", "John", "John Smith", "Ma", "MA", "abdul Rahman", "de Mesnil", "Jane van", "Jones vd", "Jones mc", "John (Bob) Smith", "(Bob)", "Jane née Jones", "John Q.", "J.", "JOHN SMITH", "john smith", "JOHN", "Jane Mrs", "John Smith Jr.", "John V", "Jane fils", "fils", "Neto", "PhD", "Jr.", "Jane PhD", "Ana García López", "Ed", "von Berg", "Chas.", "Van") _EMPTY_HEAD_TAILS = ("", "Jr.", "PhD", "Dr.", "Mrs", "III", "V", "MA", "Ma", "fils", "X.Y.Z.") #: Rows rules/C15's one exception takes, H1 reading the title's lone #: name word as the family (', Dr. John'), under each of the three #: orders. A count per order, so the exception cannot grow unseen in #: one of them (measured 2026-10-27; 1,075 each before #892's #: empty-head P6 site went). _EMPTY_HEAD_H1_ROWS = 963 def _empty_head_view(name: ParsedName, drop_first: bool = False ) -> tuple[dict[str, str], list[str]]: words: dict[str, list[str]] = {role.value: [] for role in Role} for tok in name.tokens: words[tok.role.value].append(tok.text) if drop_first: # the twin's surname, from whichever field it reached: a # given-name title addresses it as the given name ('Smith, Sir # PhD', rules/H1) first = name.tokens[0] words[first.role.value].remove(first.text) return ({role: " ".join(w) for role, w in words.items()}, sorted(a.kind.name for a in name.ambiguities)) def _surname_in_the_case_of(given_part: str) -> str: """The twin's surname, written in the case the given part's own words are written in: a surname in another case would bring the case contrast the empty head lacks ('Smith, MA' reads suffix 'MA' by its capitals, rules/S6, where ', MA' and 'SMITH, MA' read given 'MA'). Delimited words are not the name's own (rules/S6).""" letters = [c for c in re.sub(r"\(.*?\)", "", given_part) if c.isalpha()] if letters and all(c.isupper() for c in letters): return "SMITH" if letters and all(c.islower() for c in letters): return "smith" return "Smith" def test_an_empty_head_reads_as_its_family_comma_twin() -> None: """rules/C15's invariant: ', X' reads as 'Smith, X' reads, less the surname, its fields and ambiguity kinds alike, under every order. Less the surname only: a particle the twin attaches to it (rules/P6) is the family alone here (', Jane van' -> family 'van'). Kinds, not details: a report's wording is not compared. The one exception is H1's, asserted rather than skipped: where the twin reads a title and name words in the given field alone, the empty head reads them as the family (', Dr. John' -> family 'John'), the comma naming no surname to decide by. Only where the twin, less its surname, reads no family: a particle P6 attaches gives H1 a family, so it stands down (', Dr. Jones vd' -> given 'Jones', family 'vd'). This replaces the empty head as a shape every comma sweep must hold (nameparser/AGENTS.md): a change that breaks it gets a generic fix or an entry in decisions/C15, never code of its own. 10,557 rows (3,519 per order), about 2 s serially (measured 2026-10-27). RECORDED NEGATIVE CONTROL, measured 2026-10-27: restoring the defect #613's review found, the comma decision returning early on an empty head (`if not seg1 or not seg0` in `_comma.decide`), fails 1,098 rows, ', PhD' reading given 'PhD' among them. """ failures: list[str] = [] h1_rows: dict[str, int] = {} rows = 0 for label, order in (("given-first", GIVEN_FIRST), ("family-first", FAMILY_FIRST), ("family-first-given-last", FAMILY_FIRST_GIVEN_LAST)): parser = Parser(policy=Policy(name_order=order)) h1_rows[label] = 0 for parts in itertools.product(_EMPTY_HEAD_TITLES, _EMPTY_HEAD_BODIES, _EMPTY_HEAD_TAILS): given_part = " ".join(p for p in parts if p) if not given_part: continue rows += 1 empty = _empty_head_view(parser.parse(", " + given_part)) twin_text = _surname_in_the_case_of(given_part) + ", " \ + given_part twin = _empty_head_view(parser.parse(twin_text), drop_first=True) if empty == twin: continue fields, kinds = twin if (fields["title"] and fields["given"] and not fields["middle"] and not fields["family"]): moved = dict(fields, given="", family=fields["given"]) if empty == (moved, kinds): h1_rows[label] += 1 continue failures.append( f"{label}: {', ' + given_part!r} reads {empty} but " f"{twin_text!r} less its surname reads {twin}") assert rows == 3 * 3519, rows assert not failures, ( f"{len(failures)} given part(s) disagree:\n" + "\n".join(failures[:15])) assert set(h1_rows.values()) == {_EMPTY_HEAD_H1_ROWS}, ( f"H1's exception took {h1_rows} rows, recorded " f"{_EMPTY_HEAD_H1_ROWS}: read the rows it gained or lost " f"before re-recording") #: The one-case-head exception class, and the recorded size of it. #: Structural rather than a name list: a name whose OWN words are #: written in one case, where the member's own writing is the only #: contrast in the string, so the clause hides it. `own_words` stops #: at the maiden marker (rules/P3), so a member inside the clause #: cannot contribute the case contrast `one_case` is computed from -- #: which is decisions/S6's own "THE PREDICATE KEEPS THE JUDGED #: TOKEN IN THE SPAN" (the 2026-09-14 #289/#516 entry) failing #: structurally, the judged token never being in the span at this #: slot. Accepted by Derek 2026-09-19 and recorded in #: decisions/S4 as the M2 instance of #492's deferred question. #: The class is asserted beside the count because a structural #: allowlist cannot notice a new member of it, and a COUNT cannot #: notice a swap -- one pair leaving and another arriving keeps the #: number. So the SET is pinned, by digest: sha256 over the sorted #: "<policy>|<marker>|<clause name>" lines, printed by the assertion #: when it fails, which is how a deliberate move is re-recorded. #: Re-measured 2026-09-19 on the widened grid below: 1,026 of 18,144 #: pairs, which is exactly 9x the 114 of 2,016 the single-marker, #: single-policy grid held -- three markers x three policies, and the #: class is indifferent to both, which is the finding. (Before #533 #: the same grid had 186 allowlisted and 984 failing.) #: #: The ANCHORED-HEAD class beside it (#544), pinned the same way: a #: head ending in an unambiguous credential anchors a member written #: straight after it and not one written after a clause (rules/M2's #: boundary). Recorded 2026-09-27 on the #544 tree: 810 of the 20,412 #: pairs, 270 for each head ending in a credential ('Jane Doe Jr.', #: 'Jane Doe PhD', 'Doe, Jane PhD') and every one a Title-case member #: (a caps member leans credential on both sides and a lower one is #: counted on both, so neither disagrees); 0 with the anchor off, which #: is the recorded negative control. The one-case class's 1,026 is #: unmoved by the three heads #544 added ('Jane Doe PhD', 'Doe, Jane #: PhD', 'PhD'), all three being mixed case. #: Re-recorded 2026-10-04 for #602: 1,134, every one of the 324 new #: members under the `nodot` policy -- a by-shape dotted member #: ('X.Y.Z.', 'R.A.I.' in each case) behind one of the three #: credential heads, 108 each, which the head's credential run (#602) #: took in. Then EMPTIED the same day by #601, and kept at 0 rather #: than deleted, so a member arriving fails here: a marker behind a #: credential is an ordinary word now (rules/M2), the credential's #: run takes the whole clause in, and the clause form reads the member #: as a credential exactly as the plain form does. _MAIDEN_ANCHORED_HEAD_EXCEPTIONS = 0 _MAIDEN_ANCHORED_HEAD_DIGEST = ( "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") _MAIDEN_AGREEMENT_EXCEPTIONS = 1026 #: #785: pairs whose 'MA' body is no name word, its capitals settling #: it as the credential in a name of mixed case (rules/S6). Where the #: marker is an ordinary word -- after a family comma (rules/M2) -- #: such a body is a credential written before the member, and starts #: the credential's run (rules/S8), so removing the "clause" removes a #: credential and changes the question, which the docstring's #: name-words-only premise rules out. Recorded 2026-10-25: 288 pairs, #: every one on a family-comma head ('Doe, Jane', 'Smith, Jane', #: 'Doe, J.', 'Doe, Dr. Jane'). _MAIDEN_SETTLED_BODY_PAIRS = 288 _MAIDEN_AGREEMENT_DIGEST = ( "4b70727a2633fea1a9c219173d48b223cc0866a5d340b69a9eb4f14fc386ae6a") def _one_case(text: str) -> bool | None: """ParseState.one_case for a whole parse -- `is_one_case` over the name's own words, which is exactly the span the allowlist asks about.""" return run(ParseState(original=text, lexicon=Lexicon.default(), policy=Policy())).one_case def _qualifying_front(toks: list[tuple[Token, tuple[int, int]]], i: int, original: str) -> tuple[Token, int] | None: """rules/S7's company clause: walk back from `toks[i]` (a member of the ambiguous class) through lone members to the piece that would speak for their company, and return it -- with its OWN index, which a caller's role question needs -- if it structurally qualifies: unambiguous suffix vocabulary, not tagged an initial, not a connective, not a particle, not a single-letter roman numeral, and neither a comma nor a maiden marker stands between it and `toks[i]` (the company is read within one comma part, and does not reach across a clause, rules/M2). None if no such piece exists or it fails one of those tests. Says NOTHING about either token's ROLE -- not `toks[i]`'s, and not the front's. That is deliberate: a caller checking whether the front SPEAKS asks its own role question afterward (a title heading the part speaks for nothing, 'Smith, Ms Ma'; the piece a name reserve keeps speaks for nothing either, `_reserve_kept` below), and the two properties this feeds ask DIFFERENT things of the SAME front's role -- shared here only where they agree.""" tok, span = toks[i] j = i - 1 while (j >= 0 and AMBIGUOUS_ACRONYM_TAG in toks[j][0].tags and SHAPE_ACRONYM_TAG not in toks[j][0].tags): j -= 1 if j < 0: return None front, front_span = toks[j] between = original[front_span[1]:span[0]] letters = front.text.rstrip(".") if ("," in between or _MARKER_RE.search(between) or "vocab:suffix" not in front.tags or "initial" in front.tags or "conjunction" in front.tags or "particle" in front.tags or (len(letters) == 1 and letters.lower() in "ivx")): return None return front, j def _reserve_kept(toks: list[tuple[Token, tuple[int, int]]], j: int, original: str) -> bool: """Whether `toks[j]` is the reserve rules/H4's no-comma carve-out keeps regardless of its own vocabulary: the name's first non-TITLE piece, in a name with NO COMMA anywhere before it, and no GIVEN/MIDDLE/FAMILY token before it either. 'PhD Ma' is this shape (the reserve keeps 'PhD'); 'Smith, PhD Ma' is NOT -- a family already exists from before the comma, so nothing here is H4's carve-out, whatever `segment_suffix_reading` does with the given part on its own account (not modelled by this walk; `_outside_its_company`'s docstring names where that shape is pinned instead). A comma anywhere before `toks[j]` therefore answers False outright. A maiden clause crossed on the way answers nothing on its own: a MAIDEN-role token is simply not a name role, so the walk continues through it exactly as it does through a TITLE, to whatever real name may stand on the far side of the marker -- 'Jane nee Doe PhD Ma' finds 'Jane' (GIVEN) past the clause and answers False, PhD not being the reserve there either.""" for k in range(j - 1, -1, -1): tok, span = toks[k] _, next_span = toks[k + 1] between = original[span[1]:next_span[0]] if "," in between: return False if tok.role in (Role.GIVEN, Role.MIDDLE, Role.FAMILY): return False return True def _outside_its_company(name: ParsedName) -> list[str]: """rules/S7's company clause, read off a finished parse (#544): a LISTED member of the ambiguous class written behind an unambiguous credential -- with nothing but listed members between them, in the same comma part and on the same side of a maiden marker -- is read as a credential. The word in front must structurally qualify (`_qualifying_front`) and speak for itself: not read as a TITLE, which is how the parse reads a dual opening the given part ('Smith, Ms Ma'), and not the piece a name reserve keeps regardless of its own vocabulary (`_reserve_kept`: 'PhD Ma' reads family 'Ma', because 'PhD' is the name the reserve kept, not a credential run's own member). A dual in the given part's leading title run also keeps a credential BEHIND it from speaking for a member in that part ('Smith, MD PhD Ma' keeps given 'PhD', middle 'Ma'), and that exemption is NOT modelled here: no head of the walk opens a comma part with a title/suffix dual, so it would be code no parse reaches. The case rows `a_dual_opening_the_given_part_turns_the_anchor_off` and `a_dual_opening_the_given_part_silences_a_later_degree`, and test_pieces' `test_a_dual_in_the_leading_title_run_turns_the_anchor_off`, pin it instead. Deliberately does NOT require the front to already be in the SUFFIX role: that was tried and it blinded the check to exactly the no-comma half of the defect this exists for -- with the anchor off, a lost anchor costs the credential IN FRONT its SUFFIX role too ('Jane Doe Jr. Ma' read middle 'Doe Jr.' before #544), and a check requiring `front.role is Role.SUFFIX` would have passed on that very defect by exempting every front it broke. The walk this test runs never puts a bare, real family name before a comma with nothing but a credential run after it ('Smith, PhD Ma') -- every head either has no comma at all or already carries a given name across it. That comma-side shape is pinned separately: `tests/v2/cases.py`'s `family_comma_lone_degree` and `family_comma_two_credentials` rows, and rules/S7's 'Smith, PhD Ma' example. DELIBERATELY A SECOND IMPLEMENTATION of `_pieces.credential_anchors`, over the finished parse's tokens, tags and roles rather than over pieces.""" toks = _placed(name) out = [] for i, (tok, span) in enumerate(toks): if (tok.role is Role.SUFFIX or AMBIGUOUS_ACRONYM_TAG not in tok.tags or SHAPE_ACRONYM_TAG in tok.tags): continue qualifies = _qualifying_front(toks, i, name.original) if qualifies is None: continue front, j = qualifies if front.role is Role.TITLE or _reserve_kept(toks, j, name.original): continue out.append(f"{tok.text!r} -> {tok.role.value} behind " f"{front.text!r}") return out def _credential_without_suffix_role(name: ParsedName) -> list[str]: """The converse of `_outside_its_company` (#544): a member read as a credential (role SUFFIX) because a qualifying word stands in front of it must have that FRONT word in the SUFFIX role too -- the front cannot itself be reserved as the given or family name while lending its credential-ness to the member behind it. 'PhD Ma' reading given 'PhD', suffix 'Ma', family '' is the shape this catches: 'Ma' inheriting PhD's company while PhD itself is handed back to the given slot the walk has to reserve, losing the family entirely (the recorded control below). Same `_qualifying_front` walk-back as `_outside_its_company`, checked the other way; the two properties then ask DIFFERENT role questions of the front on purpose (see `_qualifying_front`'s docstring) -- this one does NOT exempt a front the reserve kept, because that is exactly the shape the defect took: the reserve-kept front is the one whose SUFFIX role went missing. `_one_case(name.original)` is a full extra pipeline run, asked only for a SURVIVING candidate: a member whose qualifying front is outside both TITLE and SUFFIX. Every other member is decided by role tests alone. Skips a member whose OWN case-based lean (rules/S6, #289 -- an ALL-CAPS member of a mixed-case name) already reads it as a credential with no company at all: that class predates #544 and reaches the very same H4 carve-out on its own ('PhD MA' -> given 'PhD', suffix 'MA' on 1.4.0), so it is not this property's question.""" toks = _placed(name) out = [] for i, (tok, span) in enumerate(toks): if tok.role is not Role.SUFFIX or AMBIGUOUS_ACRONYM_TAG not in tok.tags: continue qualifies = _qualifying_front(toks, i, name.original) if qualifies is None: continue front, _ = qualifies if front.role is Role.TITLE or front.role is Role.SUFFIX: continue # a surviving candidate: only now is the extra run worth its cost one_case = _one_case(name.original) # inlined `_pieces.listed_lean`'s own gate, over the public # `Token` rather than the pipeline's `WorkToken` -- the two # types share `.text`/`.tags`, and this is a second # implementation on purpose, like the rest of this function own_lean = (None if (one_case is None or SHAPE_ACRONYM_TAG in tok.tags) else ambiguous_lean(tok.text, one_case)) if own_lean == "credential": continue out.append(f"{front.text!r} -> {front.role.value} in " f"front of {tok.text!r}, which reads suffix") return out def test_a_maiden_clause_does_not_change_how_a_trailing_word_reads( ) -> None: """#533's invariant: appending a maiden clause to a name must not change the CLASS a trailing word is read in. A member of the ambiguous credential class ending a name that carries a maiden clause, and the same member ending the same name with the clause removed, must land in the same class -- credential (role SUFFIX) or name (any other role). CLASS, not FIELD. The clause bodies are NAME WORDS ONLY on purpose: a suffix word or a title inside the clause is a word the count reads, so removing the clause changes the question rather than answering it ('J. nee Smith Jr MA' against 'J. MA' is not a pair). Measured on this tree before #533: 984 of 2016 pairs disagreed outside the allowlist. After: 0, and the allowlist holds exactly its recorded size. #544 joins this walk rather than opening a grid: every parse it takes is also asked `_outside_its_company` and `_credential_without_suffix_role` (rules/S7's company clause, both directions), and the two heads ending in 'PhD' were added so that the given slot holds members behind a credential beside the comma-less trailing slot 'Jane Doe Jr.' already held. A third head, the bare 'PhD', was added so `_credential_without_suffix_role` has a shape to catch at all -- none of the other heads is itself a bare listed-suffix word, so none reaches the walk's own leading position, which is exactly the shape the converse question is about. Each text is parsed ONCE -- the plain form once per head and member, not once per body and marker -- which took the walk from 3.25s to 2.53s on 3.11 while the grid grew from 18,144 pairs to 20,412 (measured 2026-09-27, `--durations`; `_credential_without_suffix_role` forces its own extra parse, `_one_case`, only for a surviving candidate a qualifying front's role has not already decided, rather than unconditionally for every parse). The `company` and `orphaned` asserts below run BEFORE `failures`, so each control here trips its OWN assert rather than one masking another: `assert not company` is the company check's recorded negative control, `assert not orphaned` the converse's. RECORDED NEGATIVE CONTROL for `assert not company`: with `credential_anchors` answering False, it fails on 48 of the walk's parses ('Jane Doe Jr. Ma' reading family 'Ma'); 0 here. RECORDED NEGATIVE CONTROL for `assert not orphaned`: the walk's own leading piece never anchors, and the no-comma peel holds that twice -- `credential_anchors` skips the position, and the peel's reach test (`anchor_in_reach`) never looks at it. With both removed it fails on 42 of the walk's plain-form parses ('PhD Ma' reading given 'PhD', suffix 'Ma', and the by-shape 'PhD X.Y.Z.' alike); with either one alone removed, 0; 0 here (measured 2026-09-28). """ members = ("ba", "do", "ed", "jd", "ma", "x.y.z.", "r.a.i.") # 'Dr.' left the heads with #601: a marker behind a lone title is # an ordinary word, so its pairs compared a name holding one more # word to spare against one without it (648 pairs disagreed, every # one on that head, measured 2026-10-04). Every other head agrees # -- the comma heads, whose marker is a word too, and the heads # ending in a credential, whose run (#602) takes the clause in. heads = ("Jane Doe", "Doe, Jane", "John", "J.", "Jane", "Jane van der Berg", "JANE DOE", "jane doe", "DOE, JANE", "doe, jane", "Jane Q. Doe", "Doe, Dr. Jane", "Doe, J.", "Smith, Jane", "Jane Doe Jr.", "Jane Doe PhD", "Doe, Jane PhD", "PhD") bodies = ("Smith", "Yo-Yo", "van der Berg", "Jones Smith", "MA", "Ma") # three markers and the two 2.4 switches beside the default: the # switches change WHICH tokens are in the class, and the marker # spellings are what `own_words` stops at, so both are dimensions # the allowlist's structural argument rests on. markers = ("nee", "née", "geb.") policies = (("default", Policy()), ("caps", Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE)), ("nodot", Policy(unlisted_dotted_suffixes=False))) def side(name: ParsedName, word: str) -> str: hits = [t for t in name.tokens if t.text == word] if not hits: return "gone" return ("credential" if hits[-1].role is Role.SUFFIX else "name") pairs = 0 allowed: list[str] = [] # #544: the ANCHORED-HEAD class. A head that ENDS in an unambiguous # credential ('Jane Doe Jr.') anchors a member written straight # after it ('Jane Doe Jr. Ma' reads suffix 'Jr. Ma'), while the # clause puts its name words between the two ('Jane Doe Jr. nee # Smith Ma'), and the anchor never reaches across a clause # (rules/M2, decided, a boundary). So the pair differs by # construction, and in ONE direction only: the clause reads a name, # the plain form a credential. Admitted only where the parser # itself gives the head's last token the suffix role and the pair # disagrees in that direction, and pinned by count and digest like # the class above. anchored_head: list[str] = [] company: list[str] = [] # #544: the converse of `company` -- a member reading # SUFFIX because of its company must have that company ALSO in the # SUFFIX role, never handed back to a given/family reserve while # still lending its credential-ness behind it orphaned: list[str] = [] def audit(label: str, text: str, name: ParsedName) -> None: company.extend(f"[{label}] {text!r}: {bad}" for bad in _outside_its_company(name)) orphaned.extend(f"[{label}] {text!r}: {bad}" for bad in _credential_without_suffix_role(name)) failures = [] settled_body = 0 for label, policy in policies: parser = Parser(policy=policy) for head in heads: head_tokens = parser.parse(head).tokens head_ends_in_credential = bool(head_tokens) and ( head_tokens[-1].role is Role.SUFFIX) for base in members: for word in (base.lower(), base.title(), base.upper()): plain = f"{head} {word}" plain_name = parser.parse(plain) audit(label, plain, plain_name) plain_side = side(plain_name, word) for body in bodies: for marker in markers: clause = f"{head} {marker} {body} {word}" clause_name = parser.parse(clause) audit(label, clause, clause_name) clause_side = side(clause_name, word) pairs += 1 if clause_side == plain_side: continue if _one_case(clause) and not _one_case(plain): allowed.append( f"{label}|{marker}|{clause}") continue if (head_ends_in_credential and clause_side == "name" and plain_side == "credential"): anchored_head.append( f"{label}|{marker}|{clause}") continue if body == "MA" and next( t for t in clause_name.tokens if t.text == body).role is Role.SUFFIX: settled_body += 1 continue failures.append( f"[{label}] {clause!r} reads " f"{clause_side} but {plain!r} reads " f"{plain_side}") # company and orphaned assert BEFORE failures: each is its own # control (the anchor-off/reserve-regression negative controls # this test's docstring records), and a pair-agreement failure # elsewhere in the grid must never mask either one assert not company, ( f"{len(company)} member(s) read as a name behind an " f"unambiguous credential:\n" + "\n".join(company[:15])) assert not orphaned, ( f"{len(orphaned)} member(s) read as a credential behind a " f"word not itself in the suffix role:\n" + "\n".join(orphaned[:15])) assert not failures, ( f"{len(failures)} of {pairs} pair(s) disagree outside the " f"one-case-head class:\n" + "\n".join(failures[:15])) assert settled_body == _MAIDEN_SETTLED_BODY_PAIRS, ( f"{settled_body} pairs disagree on a body its capitals settled, " f"recorded as {_MAIDEN_SETTLED_BODY_PAIRS} on 2026-10-25") anchored_digest = hashlib.sha256( "\n".join(sorted(anchored_head)).encode()).hexdigest() assert (len(anchored_head), anchored_digest) == ( _MAIDEN_ANCHORED_HEAD_EXCEPTIONS, _MAIDEN_ANCHORED_HEAD_DIGEST), ( f"the anchored-head class holds {len(anchored_head)} of {pairs} " f"pairs with digest {anchored_digest}. Re-record both " f"deliberately, saying why. Members:\n" + "\n".join(sorted(anchored_head)[:10])) digest = hashlib.sha256( "\n".join(sorted(allowed)).encode()).hexdigest() assert (len(allowed), digest) == ( _MAIDEN_AGREEMENT_EXCEPTIONS, _MAIDEN_AGREEMENT_DIGEST), ( f"the one-case-head class holds {len(allowed)} of {pairs} " f"pairs with digest {digest}, recorded as " f"{_MAIDEN_AGREEMENT_EXCEPTIONS} / " f"{_MAIDEN_AGREEMENT_DIGEST} on 2026-09-19. The SET is " f"pinned, not only the size: a swap keeps the count. " f"Re-record both deliberately, saying why. Members:\n" + "\n".join(sorted(allowed)[:10])) #: The tags that make a token a member of the ambiguous credential #: class as the reader sees it -- the listed one and the by-shape one #: a 2.4 switch writes. Spelled here rather than imported so the #: property is stated in the terms rules/M2 states it in, and so a #: rename in the pipeline cannot quietly narrow what this checks. _CLASS_TAGS = frozenset({"vocab:suffix-ambiguous", "shape:acronym"}) #: Two-member trailing runs: the one shape that puts a maiden-clause #: report and an assign-peel report on the same parse, each naming a #: different token. Written out rather than generated, because what #: makes them work is the CONTRAST between the two members' writing. #: The lower-case pair joined them in #785: capitals in a name of mixed #: case settle a member and report nothing (rules/S6), and 'Ma jd' #: keeps the shape with two members no writing settles. _TAILS = ("Ma JD", "MA JD", "MA Ma", "Ma jd") def _maiden_clause_grid() -> list[tuple[str, Parser, str]]: """(name, parser, policy label) for the M2 release grid. Heads where a marker counts (rules/M2, #601): particle heads, a bound-given head and pair, a title-led head, a lone name word and a Vietnamese head whose surname is particle vocabulary, with by-shape and caps-on members, two adjacent particle members, mixed/ALL-CAPS/ lower writing, a suffix-comma tail beside none, four markers, and the default policy beside each 2.4 switch. Until #601 it also held the #533 review's comma heads ('Doe, Prof.', 'Berg, abdul', ...), where a marker is an ordinary word now; a grid of them could no longer reach the take at all. """ heads = ("Jane Doe", "Jane van der Berg", "abdul Berg", "J. Doe", "Doe", "Prof. Jane Doe", "Jane Q. Doe", "abdul rahman", "Mai Le") bodies = ("Smith", "Smith MA", "Smith Ma", "Smith ma", "Smith A.B.", "Smith X.Y.Z.", "Smith XYZ", "Smith ba", "Smith DO", "Smith Do", "Smith do", "Smith MA XYZ", "Smith DO DO", "Smith Ma JD", "Smith MA JD", "Smith MA Ma", "Smith V MA", "MA", "MA PhD", "MA ba", "Smith Jones MA", "Smith PhD", "Smith Jr", "Jones Smith Ma", "Smith MA PhD") policies = (("default", Policy()), ("caps", Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE)), ("nodot", Policy(unlisted_dotted_suffixes=False))) parsers = [(label, Parser(policy=p)) for label, p in policies] texts: list[str] = [] seen: set[str] = set() for head in heads: for body in bodies: markers = ("nee", "née", "geb.", "z domu") if " " not in body \ else ("nee",) for marker in markers: base = f"{head} {marker} {body}" for text in (base, f"{base}, MD"): for written in (text, text.upper(), text.lower()): if written not in seen: seen.add(written) texts.append(written) return [(t, parser, label) for t in texts for label, parser in parsers] def _released_but_not_suffix(text: str, parser: Parser) -> list[str]: """Words rules/M2 says must be SUFFIX-roled and are not. The clause's parent-style reach is everything from the marker to the first CERTAIN suffix word -- suffix vocabulary that is not initial-shaped, which is where the walk stopped before #533 and still stops. Inside that reach a class member is either still in the maiden name or was given up as a credential; any third answer is a word the clause released into the current name, which is the failure this property exists for. """ marker = _MARKER_RE.search(text) if marker is None: return [] name = parser.parse(text) if not name.maiden: return [] out = [] for tok in name.tokens: if tok.span is None or tok.span.start < marker.end(): continue if "vocab:suffix" in tok.tags and "initial" not in tok.tags: break if (not _CLASS_TAGS.isdisjoint(tok.tags) and tok.role not in (Role.MAIDEN, Role.SUFFIX)): out.append(f"{tok.text!r} -> {tok.role.value}") return out _MARKER_RE = re.compile( r"(?<![\w.])(nee|née|geb\.|z domu)(?![\w])", re.IGNORECASE) def test_a_word_the_clause_gives_up_lands_in_suffix() -> None: """rules/M2's invariant, over the whole release grid. The clause may hand a word to the trailing rule that reads it as a credential, and it may keep the word. There is no third answer: a released word that ends the parse in `given`, `middle` or `family` has crossed from the BIRTH name into the current one, silently, and that is the class of failure #424 named from the other direction. The grid is the pin, and it is a grid rather than a name list so that it could fail. Since #601 the invariant holds by construction -- the take hands the run to the suffix and title roles itself -- so the RECORDED NEGATIVE CONTROL is the design that construction replaced: prototype variant 4 of #601 (the head rule, the run read once over the name as written, and NOT bound) fails 350 of this grid's 4,482 parses, 512 tokens across 122 distinct texts; this tree fails none (measured 2026-10-04). The history, over the pre-#601 grid of 17 heads and 8,466 parses: at d97d3eb7 -- the commit the #533 review round started from -- it failed on 790 of them, 290 distinct names, covering every shape the review reported: 'Doe, Prof. nee Smith A.B.' reading given 'A.B.' (32 parses, and 'X.Y.Z.' another 32), 'DOE, PROF. NEE SMITH MA' reading given 'MA' (206, the largest class, with 'ba' at 96), 'Berg, Jane van der nee Smith DO' reading family 'van der DO Berg' (12) and 'Jane Doe nee Smith DO DO' reading family 'DO DO' (108, two words apiece). At 2f57ff21, the parent, it passes on all 8,466 -- the invariant is what the conservative direction always held. """ failures = [] grid = _maiden_clause_grid() for text, parser, label in grid: for bad in _released_but_not_suffix(text, parser): failures.append(f"[{label}] {text!r}: {bad}") assert not failures, ( f"{len(failures)} of {len(grid)} parse(s) released a word the " f"clause should have kept:\n" + "\n".join(failures[:20])) # the grid has to be able to fail: every shape above must actually # reach the walk, which it does only where a clause is taken reached = sum(bool(p.parse(t).maiden) for t, p, _ in grid) assert reached > len(grid) // 2, ( f"only {reached} of {len(grid)} grid parses carry a maiden " f"name; the rest cannot exercise M2 at all") def test_a_trailing_title_is_transparent_to_the_maiden_clause() -> None: """rules/H5's transparency, asked where #535 found it broken: a maiden clause ending '<run> Prof.' and one ending 'Prof. <run>' must read alike in every field but the order of the title words, wherever a trailing rule reads the clause -- no comma, the part before a suffix comma, the given part after a family comma -- and the run behind the title reads as post-nominals, i.e. the clause gives the credential up, and nothing ahead stands to take the title (no particle chain, no bound-given join, no name left empty). The invariant is claimed over the forms below and no wider. Where the clause KEEPS the credential the spellings differ, a clause being one contiguous run: 'Doe nee Smith ba Prof.' gives the title up, 'Doe nee Smith Prof. ba' cannot -- an example rather than a complete list. No head or run below has a particle, a bound given name or an empty name in or ahead of the clause. An invariant over two INPUTS (docs/design/AGENTS.md axis 11), so it consults no rule statement. RECORDED NEGATIVE CONTROL: at e0f1a2fa, before the walk read the chain, these 36 pairs disagreed on 72 fields ('Jane Doe nee Smith MA Prof.' read maiden 'Smith MA Prof.' against 'Smith Prof.' and suffix 'MA'). """ forms = ("Jane Doe nee Smith {}", "Doe, Jane nee Smith {}", "JANE DOE NEE SMITH {}", "jane doe nee smith {}", "Prof. Jane Doe nee Smith {}", "Jane Doe nee Smith {}, PhD") runs = ("MA", "V", "PhD", "Jr.", "MA JD", "M.A.") failures = [] for form in forms: for tail in runs: title = "Prof." if form == form.upper(): tail, title = tail.upper(), title.upper() elif form == form.lower(): tail, title = tail.lower(), title.lower() a = parse(form.format(f"{tail} {title}")) b = parse(form.format(f"{title} {tail}")) for field in ("given", "middle", "family", "suffix", "maiden"): if str(getattr(a, field)) != str(getattr(b, field)): failures.append( f"{form!r} {tail!r} {field}: " f"{str(getattr(a, field))!r} vs " f"{str(getattr(b, field))!r}") if sorted(str(a.title).split()) != sorted(str(b.title).split()): failures.append(f"{form!r} {tail!r} title") assert not failures, "\n".join(failures) def test_a_title_the_clause_gives_up_lands_in_title() -> None: """rules/M2's invariant for a title in the run (#535, #601): a period-marked title word written after the marker ends the parse in the maiden name or in the title field, never in a name part. Over heads where a marker counts (#601): a plain head, an initial, a particle head (the chain a released title would join) and a bound-given head (P5). The comma heads and the bare title head the #535 version held put the marker where it is an ordinary word now, so they asked nothing of a clause. RECORDED NEGATIVE CONTROL, measured 2026-10-04: prototype variant 4 of #601 (the run read once over the name as written, and not bound) fails 35 tokens across 32 of these 120 texts; this tree none. The #535 figure, over that version's 240 texts: the title stop's release check replaced by `if True:` failed 83 tokens across 78 of them. """ heads = ("Jane Doe", "J.", "Jane van der Berg", "abdul Berg") bodies = ("Smith Prof.", "Smith MA Prof.", "Smith Prof. MA", "Smith V Prof.", "Smith Ma Prof.", "Smith Prof. Dr.", "Prof.", "Prof. Dr.", "Smith Pope.", "Smith MA do Prof.", "Smith i DO Prof.", "Smith i MA Prof.", "Smith St.", "Smith MA St.", "Smith V St.") failures = [] for head in heads: for body in bodies: for text in (f"{head} nee {body}", f"{head} nee {body}".upper()): name = parse(text) marker = text.lower().index(" nee ") + 5 for tok in name.tokens: if (tok.span is not None and tok.span.start >= marker and "vocab:title" in tok.tags and tok.text.endswith(".") and tok.role not in (Role.MAIDEN, Role.TITLE)): failures.append( f"{text!r}: {tok.text!r} -> {tok.role.value}") assert not failures, "\n".join(failures) def test_a_title_first_word_counts_as_a_word() -> None: """rules/M2's first-word floor, over two INPUTS: the floor is the title chain's own (`trailing_titles` takes it), so whether the first word after the marker IS title vocabulary ('Pope.') or not ('Smith') must not change what a trailing credential behind it reads as -- the floor holds either word out of the chain's count, so the credential is read the same way in both. Both sides must also take a clause whose first word is that head, so two declined clauses cannot pass by agreeing about nothing. An invariant over two INPUTS (docs/design/AGENTS.md axis 11), so it consults no rule statement. The second form puts a suffix comma after the clause, where a trailing rule still reads it; until #601 it was a family-comma head, where the marker is an ordinary word now. RECORDED NEGATIVE CONTROL, measured 2026-10-04: with the first word after the marker let into the view `_maiden_take` reads the run over (its two `lo + 1` bounds made `lo`), every one of the 30 pairs fails, on the head check alone -- the suffixes still agree, which is why the head check is here. The same profile was recorded on 2026-09-26 for the floor `tail_reading` then carried. """ forms = ("Jane Doe nee {} {}", "Jane Doe nee {} {}, MD") runs = ("ba", "MA", "V", "PhD", "MA JD") heads = ("Pope.", "Smith") failures = [] for form in forms: for tail in runs: texts = [form.format(h, tail) for h in heads] for variant in (texts, [t.upper() for t in texts], [t.lower() for t in texts]): a, b = (parse(t) for t in variant) if str(a.suffix) != str(b.suffix): failures.append( f"{variant[0]!r} vs {variant[1]!r}: " f"{str(a.suffix)!r} vs {str(b.suffix)!r}") # both sides take a clause, and its first word is the # head -- otherwise agreeing suffixes could be two # declined clauses agreeing about nothing for name, text in ((a, variant[0]), (b, variant[1])): head = text.split(" nee ", 1)[-1].split(" NEE ", 1)[-1] if str(name.maiden).split()[:1] != head.split()[:1]: failures.append( f"{text!r}: maiden {str(name.maiden)!r}") assert not failures, "\n".join(failures) def test_no_two_ambiguities_name_the_same_token_span() -> None: """The maiden walk and assign's trailing peel both report at this class, and group's particle-chain emitter stands beside them. None of the three may report a word another already did -- the maiden pieces are removed before the chain runs, and a member the reading TOOK is out of the clause by the time roles are assigned. Per TOKEN, not per whole span: a first draft compared the ambiguities' token tuples for equality, which sees two reports of the same span and misses the likelier defect -- one report naming `Smith MA` while another names `MA`. The check below is over the spans the reports claim, so an OVERLAP fails it however the two spans differ in length. Measured 2026-09-19: 0 over 27,216 parses, every particle shape among them ('nee van der Berg Ma', 'nee de Ma', 'nee van Ma'). A COUNT of the parses that carry two reports rides along, because a comparison over one report is vacuous and nothing else would say so. The count is also what caught this test measuring the wrong thing. Every one of the 2,160 multi-report parses the grid held at the review paired the MAIDEN emitter with the particle-chain emitter -- the pair this test is named for, maiden against assign's peel, never occurred, because one trailing member is either kept by the clause or peeled by assign and no grid row had TWO. The `_TAILS` below are that shape: 'Ma JD' keeps 'Ma' and peels 'JD', 'MA JD' releases 'MA' and peels 'JD' behind it, and 'MA Ma' keeps the Title-case one with the caps one in front. Since #785 a capitals member in a name of mixed case is settled and reports nothing (rules/S6), so 'Ma jd' carries the two-report shape where the capitals tails no longer do. The count is re-pinned on the widened grid, and the pairing is asserted directly beside it so a later edit that drops the tails fails here rather than silently turning this back into a test of the particle emitter. """ members = ("ba", "do", "ed", "jd", "ma", "x.y.z.", "r.a.i.") heads = ("Jane Doe", "Doe, Jane", "John", "J.", "Dr.", "Jane", "Jane van der Berg", "JANE DOE", "jane doe", "DOE, JANE", "doe, jane", "Jane Q. Doe", "Doe, Dr. Jane", "Doe, J.", "Smith, Jane", "Jane Doe Jr.", "Doe, Jane van", "Doe, Jane van der") bodies = ("Smith", "Yo-Yo", "van der Berg", "de", "van", "Jones Smith") parsers = [Parser(), Parser(policy=Policy(unlisted_dotted_suffixes=False)), Parser(policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE))] failures = [] multi = 0 for head in heads: for body in bodies: for base in members: for word in (base.lower(), base.title(), base.upper()): for marker in ("nee", "née", "geb."): text = f"{head} {marker} {body} {word}" for parser in parsers: claimed: set[object] = set() overlap = False spans = [] reports = parser.parse(text).ambiguities multi += len(reports) > 1 for a in reports: # a synthetic token has no span; fall # back on its identity so it cannot # collide with another report's span = {t.span if t.span is not None else id(t) for t in a.tokens} spans.append(sorted(map(str, span))) overlap = overlap or bool(claimed & span) claimed |= span if overlap: failures.append(f"{text!r}: {spans}") # the tails that put the maiden emitter and assign's peel on one # parse -- the pair this test is named for, which the grid above # cannot produce (see the docstring) both = 0 for head in heads: for body in bodies: for tail in _TAILS: for marker in ("nee", "née", "geb."): text = f"{head} {marker} {body} {tail}" for parser in parsers: claimed = set() overlap = False spans = [] reports = parser.parse(text).ambiguities multi += len(reports) > 1 kinds = [a.kind for a in reports] both += (kinds.count( AmbiguityKind.SUFFIX_OR_NAME) > 1) for a in reports: span = {t.span if t.span is not None else id(t) for t in a.tokens} spans.append(sorted(map(str, span))) overlap = overlap or bool(claimed & span) claimed |= span if overlap: failures.append(f"{text!r}: {spans}") assert not failures, ( f"{len(failures)} parse(s) report one token twice:\n" + "\n".join(failures[:15])) # 4,239 since 2026-10-04 (#601/#602), from 4,320: over the member # grid 2,052 multi-report parses left -- every one on the heads # 'Doe, Jane van' and 'Doe, Jane van der', whose clause in the # given part no longer takes, and with it the maiden report beside # P6's -- and 1,965 arrived, 1,134 on 'Jane Doe Jr.', whose # credential now starts a run that takes the clause in and reports # what it absorbs, the rest on heads whose marker is an ordinary # word now; the tails loop below adds 6. 4,239 -> 3,744 on # 2026-10-25 (#785): a listed member its capitals settle in a name # of mixed case reports nothing (rules/S6), so the grid's capitals # spellings carry one report fewer (2,988 on the old grid), and the # 'Ma jd' tail added beside them restores the two-report shape. assert multi == 3744, ( f"{multi} of these parses carry more than one report, recorded " f"as 3744 on 2026-10-25. A parse with one report cannot fail " f"the check above, so this is what keeps the grid honest: move " f"the number deliberately, and never to 0") # 2,052 since 2026-10-04 (#601/#602), from 1,944: 216 parses left, # every one on a comma head whose clause in the given part no # longer takes ('Doe, Jane', 'Doe, Jane van', ...), and 324 # arrived, on 'Dr.' and 'J.' -- where the marker is an ordinary word # behind the title, or the clause's own run is read over the # clause-free view -- and on 'Jane Doe Jr.', whose credential run # reports what it absorbs. The run's reports moved from assign's # peel to the take that consumes the run (#601), so the pair is # the maiden take's member report against the run's. 2,052 -> # 1,728 on 2026-10-25 (#785): the capitals 'JD' and 'MA' of the # first three tails are settled and report nothing (rules/S6), and # the 'Ma jd' tail, whose members no writing settles, brings the # pair back. assert both == 1728, ( f"{both} parse(s) carry TWO suffix-or-name reports, recorded " f"as 1728 on 2026-10-25. This is the pair the test is named " f"for -- the clause's kept member against the trailing run's " f"reading -- and it was 0 for the whole grid until the " f"two-member tails were added. Never re-record it as 0") @pytest.mark.parametrize("text", _FORK_CORPUS) def test_a_fork_is_never_reported_twice_on_a_real_name(text: str) -> None: state = run(ParseState(original=text, lexicon=Lexicon.default(), policy=Policy())) assert _fork_count(state) <= 1 @given(st.text(alphabet=_ALPHABET, max_size=120)) @settings(max_examples=400, deadline=None, derandomize=True) def test_particle_fork_is_never_double_reported(text: str) -> None: # The half of the invariant that needs no reconstruction: whatever # the two emitters decide, they must not both fire on one parse. state = run(ParseState(original=text, lexicon=Lexicon.default(), policy=Policy())) assert _fork_count(state) <= 1, ( f"{text!r} reported the same fork more than once") # ---------------------------------------------------------------- config # Everything above fuzzes the INPUT STRING against the default # configuration. That leaves 2.0's largest new surface -- Lexicon and # Policy -- covered only by hand-written cases, which is backwards: the # vocabulary and the switches are the parts a user is invited to # change, so they are the parts most likely to be given a combination # nobody tried. # Deliberately mixed: real vocabulary, one-letter words that collide # with initials, an interior period, and non-Latin entries. Nothing # here normalizes to empty, which Lexicon rejects outright. No # multi-word phrase: every field below except given_name_titles is # matched one word at a time, so a multi-word draw in a per-word field # would be a dead entry that trips Lexicon's multi-word warning. # The CJK tail (#271) is what lets a drawn `surnames` set activate # script_segment at all: hangul is the script segmented by default, so # "김"/"남궁"/"남" are what make the stage fire (see _names_using, which # supplies the unspaced token to fire it ON -- and, since the drawn # policy picks its own segment_scripts, only when that policy # activates hangul too). "남" is there for the stage's multi-match # FORK, which nothing else in this pool can reach: it is a proper # PREFIX of "남궁", so a lexicon drawn with both makes "남궁민준" match # twice, and longest-first then has to choose and report the reading # it passed over. Reachable is all it is -- both entries have to land # in the same drawn `surnames`, 0.8% of draws, so the fork fires # roughly once in 900 examples (42 over 36000 measured) and the # committed 250-example seed does not reach it at all; a randomized # run is what sees it. The Han rows ride along for script_orders, and # are not inert here either -- `_policies` draws segment_scripts # freely, so HAN is activated in 37.7% of drawn policies (measured # over 20000 draws), and an activated Han token STANDING EARLIER takes # the surname site: "欧阳 김민준" does not split, where "김민준 欧阳" # does. What these policies never draw is a locale PACK, which is the # only way shipped configuration turns Han segmentation on. _VOCAB = st.sampled_from([ "van", "de", "la", "bin", "abdul", "abu", "dr", "sir", "prof", "md", "jr", "iii", "esq", "ma", "do", "and", "y", "née", "geb", "a", "b", "ph.d", "عبد", "фон", "μεγα", "김", "남궁", "남", "毛", "欧阳", ]) # given_name_titles is the one field matched as a space-joined run, so # a multi-word phrase there is meaningful (not dead) and must not warn. # Derived from _VOCAB rather than duplicated, so the two pools cannot # drift apart. _TITLE_VOCAB = st.one_of(_VOCAB, st.just("grand duke")) def _fix_invariants(**fields: frozenset[str]) -> dict[str, frozenset[str]]: """Repair a random draw into a legal Lexicon instead of generating one legally. Drawing dependent subsets directly (particles_ambiguous from whatever particles happened to be drawn) makes the strategy tree deep and mostly rejects; intersecting after the fact keeps every draw usable and still reaches every shape. The seven rules are Lexicon's own, restated here on purpose -- if one changes, this fails loudly rather than silently fuzzing a narrower space. """ fields["particles_ambiguous"] &= fields["particles"] fields["suffix_acronyms_ambiguous"] &= fields["suffix_acronyms"] fields["suffix_words"] -= fields["suffix_acronyms_ambiguous"] # order matters: must run AFTER the suffix_words subtraction above, # or that later subtraction could re-orphan a tail this repair just # fixed fields["honorific_tails"] &= fields["suffix_words"] fields["suffix_words_ambiguous"] &= fields["suffix_words"] fields["suffix_words_ambiguous"] -= ( fields["suffix_acronyms"] - fields["suffix_acronyms_ambiguous"]) fields["bound_given_names"] -= ( fields["particles"] - fields["particles_ambiguous"]) return fields # Derived, never listed: a new vocabulary field must be fuzzed the day # it is added, and a hand-copied list would leave it silently unfuzzed # -- the invisible gap this whole layer exists to prevent. _SET_FIELDS = _VOCAB_FIELDS @st.composite def _recased(draw: st.DrawFn, word: str) -> str: """A capitalization_exceptions value for `word`: its own letters, each drawn upper or lower. Any other value is a ValueError at construction (#459's case mask), so an independent draw would turn every fuzzed Lexicon holding a pair into a raise.""" flips = draw(st.lists(st.booleans(), min_size=len(word), max_size=len(word))) return "".join(c.upper() if flip else c.lower() for c, flip in zip(word, flips)) @st.composite def _lexicons(draw: st.DrawFn) -> Lexicon: fields = {name: draw(st.frozensets( _TITLE_VOCAB if name == "given_name_titles" else _VOCAB, max_size=5)) for name in _SET_FIELDS} keys = draw(st.lists(_VOCAB, max_size=3)) caps = [(key, draw(_recased(key))) for key in keys] return Lexicon(capitalization_exceptions=tuple(caps), **_fix_invariants(**fields)) # script_orders' legal values are as restricted as name_order's (only # the three exported orders, keyed by Script), so they are sampled # rather than generated. The five cover the axes that matter: the # shipped default, the full opt-out, one script alone, an order that # disagrees with the default -- FAMILY_FIRST_GIVEN_LAST on hangul # reads "김민준 수" differently from every other entry here, which is # what makes a script table that is merely PRESENT distinguishable # from one that is actually consulted -- and a table keyed on the kana # license's carrier (#272). That last row is the only one whose key is # reached INDIRECTLY: a mixed kanji+kana name resolves to HIRAGANA # rather than to any script it is literally written in, and pointing # the carrier back at GIVEN_FIRST makes a consulted table visibly # different from the default it would otherwise agree with. _SCRIPT_ORDER_TABLES = [ DEFAULT_SCRIPT_ORDERS, (), ((Script.HAN, FAMILY_FIRST),), ((Script.HANGUL, FAMILY_FIRST_GIVEN_LAST),), ((Script.HIRAGANA, GIVEN_FIRST),), ] @st.composite def _policies(draw: st.DrawFn) -> Policy: pairs = st.sampled_from([("(", ")"), ('"', '"'), ("'", "'"), ("[", "]"), ("«", "»")]) nickname = draw(st.frozensets(pairs, max_size=2)) # a pair may not sit in both buckets; Policy canonicalizes overlap # away, but constructing the contradiction is not what this fuzzes maiden = draw(st.frozensets(pairs, max_size=2)) - nickname return Policy( name_order=draw(st.sampled_from( [GIVEN_FIRST, FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST])), script_orders=draw(st.sampled_from(_SCRIPT_ORDER_TABLES)), # no max_size: Script has four members, so the unbounded draw # still reaches every subset -- including the empty one, which # is the documented segmentation opt-out, and the full one, # which turns on the Han and kana activation that only # locales.ZH and locales.JA turn on in shipped configuration segment_scripts=draw(st.frozensets(st.sampled_from(list(Script)))), patronymic_rules=draw(st.frozensets( st.sampled_from(list(PatronymicRule)), max_size=2)), middle_as_family=draw(st.booleans()), nickname_delimiters=nickname, maiden_delimiters=maiden, extra_suffix_delimiters=draw( st.frozensets(st.sampled_from(["/", ";", "|"]), max_size=2)), lenient_comma_suffixes=draw(st.booleans()), strip_emoji=draw(st.booleans()), strip_bidi=draw(st.booleans()), ) @st.composite def _names_using(draw: st.DrawFn, lexicon: Lexicon, policy: Policy) -> str: """Build the input out of the lexicon's OWN words. Fuzzing configuration while feeding unrelated text tests almost nothing: a randomly generated string essentially never contains a randomly generated vocabulary entry, so every configured set would sit unused and the parse would take the same path every time. Takes the POLICY as well as the lexicon because one of the shapes below is only reachable when the two agree -- see the segmentation note. """ vocab = sorted({w for name in _SET_FIELDS for w in getattr(lexicon, name)}) # script_segment (#271, #308) holds the two halves a space-joined # name can never reach, and BOTH need their token derived from the # draw: waiting for a drawn entry and a matching literal to # coincide leaves the stage unexercised. The surname half splits an # unspaced token whose PREFIX is a drawn surname; the peel splits a # listed tail off the END of one. The peel's line was added after a # mutation pass asked the same question of it and instrumentation # answered: 14 fires across this file, every one under the DEFAULT # lexicon and none under a drawn one, because no generated token # ended in a drawn tail. Deriving it moves that off zero. # A Latin entry is useful input in its own right: it makes a # mixed-script token the surname half correctly declines, and for # the peel it is the one shape that reaches an ASCII tail at all -- # the stage bails on a wholly-ASCII original, so the non-Latin stem # is what admits '민준jr'. Every peel fire observed under a drawn # lexicon is of exactly that shape -- the committed run's is # '민준de'. # What the forced insertion below is worth, in the one number that # cannot rot: instrument _split under this test's committed # derandomize=True seed -- peel and surname split are told apart by # its tail_tag argument -- and origin/master's version of this # strategy counts ZERO surname splits against two peels, while this # one counts 23 against one. That figure is reproducible by anyone # in one command and moves only when these strategies do. # Being in the POOL is not the same as being in the NAME, and for # the surname half that gap was the whole story. Two things must # coincide before the shape is legal at all -- a HANGUL surname # drawn AND a policy that activates hangul, together 5.5% of draws # over 20000 -- and the token then has to win a place in a 1-8 # piece name against a pool of median size 23. Merely offered, it # reached the name a couple of times per 250. The shortfall was # sampling, never the stage. # What that buys is COVERAGE, not detection, and the two are worth # separating because the first is the easier to oversell. This # layer asserts totality and span-exactness only, so every # BEHAVIORAL mutant in the stage -- the site's last-token scan, # shortest-first, the whole-token guard, the activation gate, the # post-nominal decline, the prefix cap, the segment remap -- passes # here with the insertion and without it, and is killed by # tests/v2/pipeline/test_script_segment.py instead. The one defect # class this layer CAN catch is span arithmetic inside _split, and # the peel already reached that path. Writing `base + end` as # `base + end + 1` and giving each tree ten randomized runs of 250: # origin/master finds it in 8 runs of 10, at a median 1561 shrink # calls and 14.6 seconds; this tree finds it in 10 of 10, at 346 # calls and 3.6 seconds, and shrinks to '김민준 김' where master # shrinks to the peel's '민준van'. st.integers shrinks the # insertion index toward 0, which walks the token into the position # likeliest to fire instead of away from it. # Alignment is still not a guarantee: with the token forced in the # split fires in about four aligned examples in five (350 of 450, # over 24 randomized runs of 250). Every decline is the stage # deciding, not waste. A FAMILY_COMMA opts the stage out whole -- a # ',' piece is in the pool. Otherwise an EARLIER script-written # token takes the surname site: a bare drawn surname ('김 김민준' # leaves 김민준 unsplit -- the whole-token guard), a drawn hangul # post-nominal (the leading post-nominal decline), or a Han token # under a policy that activated HAN. A LATIN word never takes it -- # 'John 김민준' still splits, zero occurrences in 1082 aligned # examples -- which is why the insertion index is drawn rather than # pinned to 0. # Two earlier versions of this comment overstated this half from # small samples -- one calling it "structurally inert, not luck" on # a single derandomize=True run reporting zero, one putting the # conversion above at 100%. So: a derandomize=True run is one # sample and cannot disagree with itself, and every randomized # figure quoted here was taken under derandomize=False instead, # which is how to re-measure them. The structural claim is real but # narrower than it was made: `w + "민준"` on a NON-hangul surname is # a mixed-script token whose effective_script is None, so those # candidates can never be a site whatever the policy says. Only a # drawn hangul surname makes a usable one, which is what # `activatable` selects. # Two shapes the insertion costs, both small, neither zero. It is # unconditional once lexicon and policy align, so the stage's # i-is-None early return under a LIVE configuration fell from 1.3% # of examples to 0.1%: it survives only where a drawn quote pair # carries the token off into a nickname, leaving segments[0] with # nothing in an activated script ("prof ' Smith 남민준 '"). And the # inserted token can SUPPRESS a peel, the peel site being the last # non-post-nominal token and this token being no post-nominal: # '김민준씨 김민준' leaves 씨 glued where '김민준 김민준씨' peels # it. # The peel gets no forced piece of its own -- it is saturated by # case rows and stage tests, so a second one was not worth the # distribution shift -- and its count here is a lottery either way # (0-7 per randomized run of 250). On net the insertion nudges it # UP, by a route worth knowing: a hangul token makes the original # non-ASCII, which lifts the stage's ASCII bail off Latin tails # that are otherwise unreachable -- under honorific_tails={'a'}, # 'John la' does not peel and '김민준 la' does. # sorted for the same reason `vocab` above is: frozenset iteration # order is not stable across runs, and an unsorted pool shifts # every index sampled_from draws -- which would defeat # derandomize=True on the whole strategy, not just this slice. unspaced = sorted(w + "민준" for w in lexicon.surnames) glued = sorted("민준" + w for w in lexicon.honorific_tails) # plain names and structure characters are always available, so the # pool is never empty even for an empty lexicon pieces = st.sampled_from( vocab + unspaced + glued + ["John", "Smith", "Q.", ",", "(", "'"]) drawn = draw(st.lists(pieces, min_size=1, max_size=8)) # The one shape the pool does not deliver RELIABLY -- it is in # there, it just loses the draw. Inserted at a drawn position # rather than the front: the stage takes the first ACTIVATED-script # token, not the first token, so a leading Latin word cannot hide # it -- and both placements are worth covering. activatable = sorted( w + "민준" for w in lexicon.surnames if effective_script(w + "민준") in policy.segment_scripts) if activatable: # rebuilt rather than list.insert()d: st.lists hands back a # fresh list today, so mutating it is safe today, and a # strategy that ever memoized one would make this a bug in the # fuzzer rather than in the code under test at = draw(st.integers(0, len(drawn))) token = draw(st.sampled_from(activatable)) drawn = [*drawn[:at], token, *drawn[at:]] return " ".join(drawn) def _quiet_parser(**kwargs: object) -> Parser: """Parser construction with the segmenterless-activation warning ignored: a drawn segment_scripts with a drawn vocabulary that cannot serve it is exactly the misconfiguration the warning names, so drawn configs hit it legitimately and constantly. The fuzz here targets parse behavior, not construction diagnostics -- tests/v2/test_parser.py pins the warning itself.""" with warnings.catch_warnings(): # message-scoped, not a blanket UserWarning ignore: a future # unrelated construction diagnostic should still fail the fuzz warnings.filterwarnings( "ignore", message=r"Policy\.segment_scripts activates") return Parser(**kwargs) # type: ignore[arg-type] @given(_lexicons(), _policies(), st.data()) @settings(max_examples=250, deadline=None, derandomize=True) def test_any_valid_config_still_parses_totally( lexicon: Lexicon, policy: Policy, data: st.DataObject) -> None: # Building the parser is part of the contract: a Lexicon and Policy # that each constructed must also combine. parser = _quiet_parser(lexicon=lexicon, policy=policy) text = data.draw(_names_using(lexicon, policy)) parsed = parser.parse(text) # must not raise, ever # the anti-#100 invariant, under configuration rather than under # the default vocabulary: spans index the original exactly for token in parsed.tokens: assert token.span is not None assert token.text == parsed.original[ token.span.start:token.span.end] # rendering is downstream of every config choice above assert isinstance(str(parsed), str) assert isinstance(parsed.capitalized().given, str) assert isinstance(parsed.initials(), str) # rules/R4 under configuration: what this buys is COVERAGE of # the non-mask clauses (shape-acronym, numeral, plain word) across # drawn vocabularies and policies -- it falsifies on e.g. a clause # that strips a period -- not DETECTION of a mask substitution. # _recased draws each mask as the key's own letters recased, so # under this fuzzer every masked word already differs from its # mask only in case (`_apply_mask` returning `mask` verbatim still # passes here); the shipped-map walk at the end of this module is # what covers substitution. repaired = parser.capitalized(parsed, force=True) assert ([t.text.casefold() for t in repaired.tokens] == [t.text.casefold() for t in parsed.tokens]) @given(_lexicons(), _policies()) @settings(max_examples=100, deadline=None, derandomize=True) def test_config_values_are_hashable_and_reusable( lexicon: Lexicon, policy: Policy) -> None: # The docs promise these are frozen values: safe as dict keys, and # safe to build a parser from more than once. assert hash(lexicon) == hash(lexicon) assert {lexicon: 1, policy: 2} assert _quiet_parser(lexicon=lexicon, policy=policy) == \ _quiet_parser(lexicon=lexicon, policy=policy) # Values a real caller plausibly passes by mistake: the bare string that # iterates into characters, a mapping confused for a set, bytes, None, # and entries that normalize to nothing. _HOSTILE = st.sampled_from([ None, 0, 1, True, "", "dr", " ", ".", b"dr", ["dr", None], {"dr": "Dr"}, {1, 2}, [("a", "b")], [[]], object(), ]) # Same rule as _SET_FIELDS; test_policy.py already reflects this way. _POLICY_FIELDS = tuple(f.name for f in dataclasses.fields(Policy)) @given(st.sampled_from(_SET_FIELDS + ("capitalization_exceptions",)), _HOSTILE) @settings(max_examples=200, deadline=None, derandomize=True) def test_bad_lexicon_field_fails_cleanly(field: str, value: object) -> None: """A rejected configuration must be a DOCUMENTED rejection. ValueError and TypeError are the contract; anything else means the bad value got past validation and blew up somewhere downstream, where the message no longer names the field the caller typed. """ try: lexicon = Lexicon(**{field: value}) # type: ignore[arg-type] except (ValueError, TypeError): return # Accepted, so it has to survive an actual parse -- construction # succeeding while parsing dies is the same bug one stage later. _quiet_parser(lexicon=lexicon).parse("Dr. John de la Vega III") @given(st.sampled_from(_POLICY_FIELDS), _HOSTILE) @settings(max_examples=200, deadline=None, derandomize=True) def test_bad_policy_field_fails_cleanly(field: str, value: object) -> None: try: policy = Policy(**{field: value}) # type: ignore[arg-type] except (ValueError, TypeError): return _quiet_parser(policy=policy).parse("Dr. John de la Vega III") # --- #397/#461: the connective grid, and its five invariants -------- # A GRID and not a name list, for the reason #531's agreement sweep # was one: every invariant below is stated about a shape the parser # can reach in many ways, and a list pins the ways somebody thought # of. Generators include heads with NO name word, parts of one word, # a part that is nothing but the connective, comma and no-comma, # mixed/ALL-CAPS/lower, class members supplied by a CUSTOM lexicon # (a second single letter that is also generational vocabulary, and a # connective that is also particle vocabulary), and four policies. # # ONE PARSE, MANY CHECKS (2026-09-20). The four invariants below ask # four questions of the SAME parse, so the grid is walked ONCE, in # `_connective_findings`, and each test reads its own answers out of # that walk. Four tests each walking the grid for themselves is what # took this module from 11s to 115s and CI's build jobs from about 5 # minutes to 17-25 (every parse costs several times more under # coverage tracing, which is what CI runs). A new invariant over this # grid joins the walk rather than opening a fifth. #: A grid's configuration variants: (label, value, the features of a #: text that reach it). See `_rows`. _Variants = tuple[tuple[str, Lexicon, frozenset[str]], ...] #: The policies both grids below run, each with the feature a text #: must hold for the policy to be distinguishable at all -- see #: `_rows`. _GRID_POLICIES: tuple[tuple[str, Policy, frozenset[str]], ...] = ( ("default", Policy(), frozenset()), ("family-first", Policy(name_order=FAMILY_FIRST), frozenset()), ("given-last", Policy(name_order=FAMILY_FIRST_GIVEN_LAST), frozenset()), # lenient_comma_suffixes decides what a COMMA hands to the suffix # run, and a name without one has no such hand-off to decide ("strict-comma", Policy(lenient_comma_suffixes=False), frozenset({","})), ) def _reaching(text: str) -> frozenset[str]: """What of a text a configuration variant could read at all: its words, folded the way the vocabulary matches them, plus ',' when it carries one.""" out = {w.strip(".,").lower() for w in text.split()} if "," in text: out.add(",") return frozenset(out) def _rows(texts: list[str], lexicons: _Variants) -> list[tuple[str, Parser, str]]: """Every text against the parsers that can read it DIFFERENTLY. A variant that ADDS vocabulary changes no parse of a name not containing the added word, and `lenient_comma_suffixes` decides nothing in a name with no comma, so most of a full cross product is one configuration re-deriving another's answer. Each variant declares the words that reach it and is paired with the texts holding them. A SHAPE trim and not a sample -- the TEXTS are untouched, and so is every shape class among them; what goes is duplicate pairings. Measured 2026-09-20 over the full cross product of both grids: 177,980 collapsed rows, 0 of them differing from the row they collapse onto in fields, token tags and spans, `initials()` on every group, the four derived views, `capitalized()` plain and forced, or the reports. Reproduce with the signature comparison in the commit that introduced this (`test(#397/#461): the invariant tests share one parsed grid`). Self-maintaining, which a hand-picked subset would not be: give a generator below a 'v' word and the conj+v rows come back on their own. That is the one to know about -- no text of the connective grid holds a standalone 'v', so `conj+v` earns no row there and earns them in the off-switch grid, whose suffixes include 'V'. """ parsers = [(f"{ln}/{pn}", Parser(lexicon=lex, policy=pol), lex_need | pol_need) for ln, lex, lex_need in lexicons for pn, pol, pol_need in _GRID_POLICIES] out: list[tuple[str, Parser, str]] = [] for text in texts: reach = _reaching(text) out += [(text, parser, label) for label, parser, need in parsers if need <= reach] return out def _signature(parser: Parser, text: str) -> tuple[object, ...]: """Everything the four grids' invariants can read of one parse. Used only by the trim's own guard below, which is why it is exhaustive rather than cheap: it has to be able to see a difference no invariant in this file happens to ask about. """ name = parser.parse(text) return ( tuple(sorted(name.as_dict().items())), tuple((t.text, t.role.value, tuple(sorted(t.tags)), t.span) for t in name.tokens), name.initials(), tuple(name.initials(f"{{{r.value}}}") for r in _NAME_ROLES), name.family_base, name.family_particles, str(parser.capitalized(name)), str(parser.capitalized(name, force=True)), tuple(sorted((a.kind.value, a.detail, tuple(t.text for t in a.tokens)) for a in name.ambiguities)), ) #: Every (text, lexicon, policy) the trim drops is checked against #: the row it collapses onto, one in `_TRIM_STRIDE` of them. _TRIM_STRIDE = 61 def _dropped_rows(texts: list[str], lexicons: _Variants) -> list[ tuple[str, tuple[str, Lexicon], tuple[str, Policy], tuple[str, Lexicon], tuple[str, Policy]]]: """The pairings `_rows` declines, each with the pairing it collapses onto: the same text under the variant it could not read differently, replaced by the baseline of whichever dimension declined. Deterministically sampled by stride rather than at random -- a random slice makes a failure unreproducible, and the shapes here are generated in a fixed order. """ base_lex = lexicons[0] base_pol = _GRID_POLICIES[0] out = [] for i, text in enumerate(texts): if i % _TRIM_STRIDE: continue reach = _reaching(text) for ln, lex, lex_need in lexicons: for pn, pol, pol_need in _GRID_POLICIES: if (lex_need | pol_need) <= reach: continue twin_lex = (ln, lex) if lex_need <= reach else base_lex[:2] twin_pol = (pn, pol) if pol_need <= reach else base_pol[:2] out.append((text, (ln, lex), (pn, pol), twin_lex, twin_pol)) return out @functools.cache def _class_letters(lexicon: Lexicon) -> frozenset[str]: """The class rules/P3's both-sides condition is about: a connective that is ALSO generational vocabulary. NO LENGTH TEST, matching the rule's own scope and the stage's (#397 second review, where a `len(w) == 1` came out of both). Measured no-op over every lexicon these grids build: `i` is the only member in the default vocabulary and in every locale pack, and the two variant lexicons add a one-letter connective and a particle. Cached on the lexicon -- a frozen, hashable value -- because both grids ask this of every row and there are five lexicons between them. """ return frozenset(w for w in lexicon.conjunctions if w in lexicon.suffix_words) #: The two grids' lexicon variants, module-level so #: `test_every_declared_variant_earns_a_row` can name them. See #: `_rows` for what the third element of each declares. _CONNECTIVE_LEXICONS: _Variants = ( ("default", Lexicon.default(), frozenset()), ("conj+v", Lexicon.default().add(conjunctions={"v"}), frozenset({"v"})), ("part+y", Lexicon.default().add(particles={"y"}), frozenset({"y"}))) _OFF_SWITCH_LEXICONS: _Variants = ( ("default", Lexicon.default(), frozenset()), ("conj+v", Lexicon.default().add(conjunctions={"v"}), frozenset({"v"}))) def _connective_grid() -> list[tuple[str, Parser, str]]: heads: tuple[list[str], ...] = ( [], ["Josep"], ["Josep", "Lluis"], ["Dr."], ["Dr.", "Josep"]) mids: tuple[list[str], ...] = ( [], ["Carod"], ["de", "Carod"], ["Carod", "Rovira"]) conns = ("i", "y", "e", "and", "&", "of", "и") tails: tuple[list[str], ...] = ( [], ["Rovira"], ["de", "Rovira"], ["Rovira", "Puig"]) suffixes: tuple[list[str], ...] = ( [], ["III"], ["Jr."], ["I"], ["MA"]) texts: list[str] = [] seen: set[str] = set() for head, mid, conn, tail, suffix, comma in itertools.product( heads, mids, conns, tails, suffixes, (False, True)): if not (mid or tail): continue if comma and tail: base = " ".join(tail) + ", " + " ".join(head + mid + [conn]) else: base = " ".join(head + mid + [conn] + tail) base = (base + " " + " ".join(suffix)).strip() for written in (base, base.upper(), base.lower()): if written not in seen: seen.add(written) texts.append(written) return _rows(texts, _CONNECTIVE_LEXICONS) _CONNECTIVE_GRID = _connective_grid() def _readmitted(tok: Token) -> bool: return not {UNJOINED_TAG, UNJOINED_CONJUNCTION_TAG}.isdisjoint(tok.tags) def _has_something_to_join(tok: Token, part: tuple[Token, ...]) -> bool: """rules/R3's question, asked of the WHOLE PART. Working particles are set aside -- a particle the unjoined mark has NOT readmitted is doing a particle's work and is no name word for a connective to join. """ return any(other is not tok and "conjunction" not in other.tags and not ("particle" in other.tags and UNJOINED_TAG not in other.tags) for other in part) def _predicted_initials(part: tuple[Token, ...], role: Role) -> list[str]: out = [] for tok in part: skip = ("conjunction" in tok.tags or ("particle" in tok.tags and role is not Role.GIVEN)) if not skip or _readmitted(tok): out.append(tok.text[0]) return out #: The two kinds whose details assert DIFFERENT readings of the same #: word: one says the letter is read as an initial, the other that it #: is read as a generational suffix. A token cannot be both, so a #: token named by both carries a report that contradicts the reading #: beside it (#397 second review). _CONTRADICTORY_KINDS = (AmbiguityKind.CONJUNCTION_OR_INITIAL, AmbiguityKind.SUFFIX_OR_NAME) def _record_contradicting_reports(name: ParsedName, label: str, text: str, out: list[str]) -> None: """INV10 (#397 second review). Two statements of one rule, the second wider than the first and both about the same defect. rules/A1's own sentence, that a report names the reading the parse took. The connective-or-initial fork offers a connective and an initial and says which it took, so a token the parse roles SUFFIX or TITLE resolved that fork to NEITHER branch and the report is false on its face. And no token may be named by two reports whose details assert different readings of it, which is the same defect stated without naming a role: 'JOHN QUINCY SMITH I' carried connective-or-initial beside suffix-or-name, one saying the letter reads as an initial and the other that it reads as the generation. 'i' is the first word that is both a marked connective and suffix vocabulary, so this could not arise before this cycle -- which is why the invariant is worth writing down now rather than having been written down before. """ by_span: dict[tuple[int, int] | None, set[AmbiguityKind]] = {} for amb in name.ambiguities: for tok in amb.tokens: span = None if tok.span is None else (tok.span.start, tok.span.end) by_span.setdefault(span, set()).add(amb.kind) if (amb.kind is AmbiguityKind.CONJUNCTION_OR_INITIAL and tok.role in (Role.SUFFIX, Role.TITLE)): out.append( f"[{label}] {text!r}: {tok.text!r} is roled " f"{tok.role.value} and reports " f"{amb.kind.value}") for span, kinds in by_span.items(): if set(_CONTRADICTORY_KINDS) <= kinds: out.append( f"[{label}] {text!r}: the token at {span} carries both " f"{_CONTRADICTORY_KINDS[0].value} and " f"{_CONTRADICTORY_KINDS[1].value}") @functools.cache def _connective_findings() -> dict[str, list[str]]: """One walk of the connective grid; FIVE invariants' answers. Each key below is one test's failure list, built with the parse in hand and in grid order, so a test reads exactly what it would have found walking the grid itself -- same rows, same order, same message. What the walk does NOT do is decide anything: every predicate stays where it was, spelled in the terms its own rule is stated in, and this function only asks them all at once. """ out: dict[str, list[str]] = {k: [] for k in ("INV1", "INV2", "INV3/4", "INV5", "INV10")} for text, parser, label in _CONNECTIVE_GRID: letters = _class_letters(parser.lexicon) name = parser.parse(text) _record_contradicting_reports(name, label, text, out["INV10"]) for role in _NAME_ROLES: part = name.tokens_for(role) # hoisted out of the INV3/INV4 comprehensions below, where # it used to be recomputed once per token of the part predicted = _predicted_initials(part, role) if len(part) >= 2: for i, tok in enumerate(part): if ("conjunction" not in tok.tags or len(tok.text) != 1 or tok.text.lower() not in letters): continue left = any("conjunction" not in t.tags for t in part[:i]) right = any("conjunction" not in t.tags for t in part[i + 1:]) if not (left and right): out["INV1"].append( f"[{label}] {text!r}: {role.value} " f"{tok.text!r}") for tok in part: if "conjunction" not in tok.tags: continue joinable = _has_something_to_join(tok, part) if joinable == _readmitted(tok): out["INV2"].append( f"[{label}] {text!r}: {role.value} {tok.text!r} " f"joinable={joinable} " f"marked={_readmitted(tok)}") got = name.initials( f"{{{role.value}}}").replace(".", "").split() if got != predicted: out["INV5"].append( f"[{label}] {text!r}: {role.value} {got!r} != " f"predicted {predicted!r}") if role is not Role.FAMILY: continue base = name.family_base.split() contributing = [t for t in part if t.text[0] in predicted and (not ("conjunction" in t.tags or "particle" in t.tags) or _readmitted(t))] for tok in contributing: if tok.text not in base: out["INV3/4"].append( f"[{label}] {text!r}: INV3 {tok.text!r} initials " f"but is not in base {base!r}") for tok in part: if (tok.text in base and tok not in contributing and "conjunction" not in tok.tags): out["INV3/4"].append( f"[{label}] {text!r}: INV4 {tok.text!r} is a base " f"word, contributes no initial, and is no " f"connective") return out @pytest.mark.parametrize("grid_name", ("connective", "off-switch")) def test_the_reach_trim_drops_only_rows_a_kept_row_repeats( grid_name: str) -> None: """The TRIM ITSELF, as a mechanism rather than as a measurement. `_rows` pairs a text only with the configurations that can read it differently, and each variant declares its reach as a set of WORDS that `_reaching` folds a text into. Sound for the variants that stand here today -- verified row by row when the trim landed -- and silently wrong for a variant nobody has written yet: `_reaching` splits on whitespace and strips edge periods, so a variant declaring a MULTI-WORD entry ("van der") or a hyphenated one would be matched by no text at all and would contribute ZERO rows, leaving a green suite testing one configuration fewer than it names. Two checks, and the second is the one that catches that: every dropped pairing parses identically to the pairing it collapses onto, and every declared variant earns at least one row. A variant that reaches nothing passes the first vacuously. Sampled by stride (`_TRIM_STRIDE`), which keeps this at about a second while covering every shape class: the generators emit shapes in a fixed order, so a stride walks all of them. """ grid, lexicons = ((_CONNECTIVE_GRID, _CONNECTIVE_LEXICONS) if grid_name == "connective" else (_OFF_SWITCH_GRID, _OFF_SWITCH_LEXICONS)) texts: list[str] = [] seen: set[str] = set() for text, _parser, _label in grid: if text not in seen: seen.add(text) texts.append(text) cache: dict[tuple[str, str, str], tuple[object, ...]] = {} def sig(text: str, lex: tuple[str, Lexicon], pol: tuple[str, Policy]) -> tuple[object, ...]: key = (text, lex[0], pol[0]) if key not in cache: cache[key] = _signature( Parser(lexicon=lex[1], policy=pol[1]), text) return cache[key] dropped = _dropped_rows(texts, lexicons) assert dropped, "the trim dropped nothing; it has stopped trimming" problems = [ f"{text!r} [{lex[0]}/{pol[0]}] differs from its kept twin " f"[{twin_lex[0]}/{twin_pol[0]}]" for text, lex, pol, twin_lex, twin_pol in dropped if sig(text, lex, pol) != sig(text, twin_lex, twin_pol)] assert not problems, ( f"{len(problems)} of {len(dropped)} dropped row(s) are not " f"repeats:\n" + "\n".join(problems[:10])) def test_every_declared_variant_earns_a_row_somewhere() -> None: """The other half of the trim's guard: a variant whose declared reach no text can satisfy contributes ZERO rows and the suite goes on testing one configuration fewer than it names, in silence. `_reaching` splits a text on whitespace and strips edge periods, so a multi-word or hyphenated entry is the shape that would do it. Asked of the two grids TOGETHER, because a variant earning rows in one of them alone is the state today and is recorded rather than a defect: no text of the connective grid holds a standalone 'v', so `conj+v` earns its rows in the off-switch grid, whose suffixes include 'V'. `_rows` says so and calls it self-maintaining -- give a connective generator a 'v' word and the rows come back. What no generator can repair is a reach nothing folds to, and that is what this refuses. """ labels = ({label for _t, _p, label in _CONNECTIVE_GRID} | {label for _t, _p, label in _OFF_SWITCH_GRID} | {label for _t, _p, label in _MAIDEN_LINK_GRID}) declared = {f"{ln}/{pn}" for lexicons in (_CONNECTIVE_LEXICONS, _OFF_SWITCH_LEXICONS) for ln, _lex, _ln_need in lexicons for pn, _pol, _pn_need in _GRID_POLICIES} missing = sorted(declared - labels) assert not missing, ( f"declared variant(s) earning no row in any grid: {missing}") def test_the_connective_grid_can_fail() -> None: """The reachability probe every grid in this file carries. A grid that reaches none of the shapes it is about passes vacuously, and a comparison over a population of zero is the same silence as a clean run. These three counts are a dated recorded control, measured 2026-09-20 on the shipped tree. The row count fell from 170,100 to 55,800 when `_rows` stopped pairing a text with configurations that cannot read it; the TEXT count did not move, and that is the number to watch here, because it is the one that says a shape was dropped. 1,580 of the first 2,000 rows join, against 1,280 of the old grid's first 2,000 -- the same texts, reached sooner, the old slice having held twelve rows per text where this one holds three or four. """ assert len(_CONNECTIVE_GRID) == 55800, len(_CONNECTIVE_GRID) assert len({t for t, _, _ in _CONNECTIVE_GRID}) == 14175 joined = sum(1 for text, parser, _ in _CONNECTIVE_GRID[:2000] if any("conjunction" in tok.tags for tok in parser.parse(text).tokens)) assert joined > 1000, joined def test_a_generational_connective_joins_only_with_both_sides() -> None: """INV1 (#397). For a single-letter connective that is ALSO generational vocabulary: if it shares its role part with any other word, a non-connective word stands before it AND after it. Mutation-checked, 2026-09-20, re-measured on the trimmed grid: dropping the whole both-sides condition fails this on 162 parses, where the full cross product gave 504. The gate's other two failure modes stay invisible here and are INV6's and INV1- strengthened's to catch -- testing POSITION instead of class, and dropping the count's frozen exclusion, each leave this invariant green on every row, which is why those two exist. The CLASS test is NOT covered here and has its own rows -- see tests/v2/pipeline/test_group.py. """ failures = _connective_findings()["INV1"] assert not failures, ( f"{len(failures)} parse(s) joined a generational connective " f"without a name word on each side:\n" + "\n".join(failures[:10])) def test_the_mark_is_exactly_the_parts_with_nothing_to_join() -> None: """INV2 (#461). A connective carries a readmitting mark IFF its part holds no other word that is neither a connective nor a working particle. Stated over the PAIR of marks deliberately: keyed on the new mark alone it fails 272 times, every one of them under `add(particles={"y"})`, where the part is all-particle and R2's OLD mark does the readmitting. Mutation-checked, re-measured 2026-09-20 on the trimmed grid: neutering the walk's lone-connective arm, so the new mark is never written, fails this on 4,639 rows -- 14,078 of the full cross product, which is what the trim costs a count and not what it costs detection. """ failures = _connective_findings()["INV2"] assert not failures, ( f"{len(failures)} connective token(s) disagree with the " f"criterion:\n" + "\n".join(failures[:10])) def test_initials_take_only_base_words_and_drop_only_joiners() -> None: """INV3 and INV4, beside R2's existing invariant. INV3: every family word `initials()` contributes is a word of `family_base`. INV4: every `family_base` word that contributes no initial is tagged conjunction. Together they say the two views of one parse cannot come apart again, which is the defect #461 was filed about. Mutation-checked, re-measured 2026-09-20 on the trimmed grid: swapping the two branches of the mark walk fails this on 94 parses (188 over the full cross product) -- `Carod y` under `add(particles={"y"})`, whose base is empty while its `y` initials. """ failures = _connective_findings()["INV3/4"] assert not failures, ( f"{len(failures)} disagreement(s) between initials() and " f"family_base:\n" + "\n".join(failures[:10])) def test_initials_emit_exactly_the_predicted_contributors() -> None: """INV5. Per group, `initials("{role}")` emits exactly the words the criterion predicts, in field order. The output-level statement of the same rule, and the one that catches a view reading the marks correctly and then rendering something else. Mutation-checked, re-measured 2026-09-20 on the trimmed grid: restoring the given group's old blanket exemption (`_SKIP_TAGS_GIVEN` back to the empty set) fails this on 9,308 parses, and dropping the new mark from the readmitting set on 4,639 -- 27,103 and 14,078 over the full cross product. """ failures = _connective_findings()["INV5"] assert not failures, ( f"{len(failures)} group(s) emitted something other than the " f"criterion's contributors:\n" + "\n".join(failures[:10])) def test_no_report_contradicts_the_reading_beside_it() -> None: """INV10 (#397 second review). Two forms of one rule, both over the connective grid's 55,800 rows: no token carries connective-or-initial while the parse roles it SUFFIX or TITLE, and no token is named by two reports whose details assert different readings of it. The fork classify takes offers a connective and an initial. A generation is neither, so where the parse reads one the report describes a branch nobody took -- which is the narrow half. The wide half needs no role at all: two reports on one token, one saying it reads as an initial and the other that it reads as a generational suffix, cannot both be true whatever the roles say. Mutation-checked, 2026-09-20: it fails on 8,204 rows at dc3bdf9c -- 5,078 the narrow half and 3,126 the wide one -- and on the same 8,204 at e540d4c5 and c8550b64, the report having been wrong since the letter joined the marked subset. 0 here, and deleting the withdrawal in `_pipeline/_assemble.py` restores all 8,204. """ failures = _connective_findings()["INV10"] assert not failures, ( f"{len(failures)} report(s) contradict the reading beside " f"them:\n" + "\n".join(failures[:10])) # --- #397 review: the OFF-SWITCH grid, and its two invariants ------- # A second grid, kept apart from the one above rather than folded # into it, and the reason is the cost: both invariants below parse # every entry TWICE -- once as configured and once with the class # letter out of the connective vocabulary -- so the shapes that # earned a place here are the ones the first cut of #397 got wrong, # not every shape the file already covers. What this one adds over # `_connective_grid`: the trailing numeral and bare acronym assign's # peel takes ('V', 'i', beside the 'III'/'Jr.'/'I'/'MA' both carry), # a maiden clause, a head that is nothing but an initial or a # particle, and the SUFFIX comma as a third comma shape. # # The same ONE PARSE, MANY CHECKS rule as the grid above, and here it # buys twice as much: the three invariants share BOTH parses of every # row -- six parses became two -- in `_off_switch_findings`. def _off_switch_grid() -> list[tuple[str, Parser, str]]: heads: tuple[list[str], ...] = ( [], ["Josep"], ["Josep", "Lluis"], ["Dr."], ["J."], ["van"], ["Josep", "Lluis", "Marti"]) mids: tuple[list[str], ...] = ([], ["Carod"], ["de", "Carod"]) conns = ("i", "y", "e", "and") tails: tuple[list[str], ...] = ([], ["Rovira"], ["de", "Rovira"]) # The last two are the #397 SECOND review's addition: a trailing # TITLE, alone and standing behind a credential. Where the peel # is read over the pieces as WRITTEN a following title hides the # suffix run from it, so the bound the join checks its right # neighbour against said "name word" of a credential and the join # swallowed it -- 'John Quincy Adams i MA Prof.' read family # 'Adams i MA' where 'John Quincy Adams i MA' reads family # 'Adams'. The lower-case spelling of the title comes free with # the casing loop below, and the leading-title-only head is # `["Dr."]` above. suffixes: tuple[list[str], ...] = ( [], ["III"], ["Jr."], ["I"], ["V"], ["MA"], ["i"], ["nee", "Puig"], ["MA", "Prof."], ["Prof."]) texts: list[str] = [] seen: set[str] = set() for head, mid, conn, tail, suffix, comma in itertools.product( heads, mids, conns, tails, suffixes, (0, 1, 2)): if not (mid or tail): continue if comma == 1 and not tail: continue if comma == 2 and not suffix: continue front = head + mid + [conn] if comma == 1: base = " ".join(tail) + ", " + " ".join(front + suffix) elif comma == 2: base = " ".join(front + tail) + ", " + " ".join(suffix) else: base = " ".join(front + tail + suffix) for written in (base, base.upper(), base.lower()): if written not in seen: seen.add(written) texts.append(written) return _rows(texts, _OFF_SWITCH_LEXICONS) _OFF_SWITCH_GRID = _off_switch_grid() _OFF_PARSERS: dict[tuple[int, frozenset[str]], Parser] = {} def _off_switch(parser: Parser, letters: frozenset[str]) -> Parser: """The same parser with those letters out of the connectives -- the parent-equivalent reading, where nothing can have joined. Keyed by `id`, which is safe here and nowhere else: the grid above holds every parser for the module's lifetime, so no id is reused. A `Parser` is not hashable (its `Lexicon` carries a mappingproxy). """ key = (id(parser), letters) if key not in _OFF_PARSERS: _OFF_PARSERS[key] = Parser( lexicon=parser.lexicon.remove(conjunctions=set(letters)), policy=parser.policy) return _OFF_PARSERS[key] def _present(text: str, letters: frozenset[str]) -> frozenset[str]: words = {w.strip(".,").lower() for w in text.split()} return frozenset(letters & words) _Placed = tuple[Token, tuple[int, int]] def _placed(name: ParsedName) -> list[_Placed]: """The parse's tokens with their spans, spliced ones dropped.""" return [(tok, tok.span) for tok in name.tokens if tok.span is not None] def _off_roles(off: ParsedName) -> dict[tuple[int, int], Role]: return {span: tok.role for tok, span in _placed(off)} def _name_word_on_the_side(toks: list[_Placed], i: int, step: int, off_role: dict[tuple[int, int], Role], original: str) -> bool: """Whether a name word stands on the `step` side of toks[i], judged by the OFF-SWITCH parse's roles -- which is what makes this a PRE-JOIN reading: in that parse the class letter is no connective, so no join has moved anything. A comma between ends the walk (it is another segment), and connectives are stepped over because a run of them joins as one. DELIBERATELY A SECOND IMPLEMENTATION, and named so that the mirror cannot be mistaken for the glass: it asks ONE side per call over spans and off-switch roles, where the parser's `_group._between_name_words` asks both at once over pieces and tags. The name it used to carry was the implementation's own, which read as a call into the thing under test rather than as a model of it.""" j = i while True: k = j + step if not 0 <= k < len(toks): return False left, right = (j, k) if step > 0 else (k, j) if "," in original[toks[left][1][1]:toks[right][1][0]]: return False j = k if "conjunction" in toks[j][0].tags: continue return off_role.get(toks[j][1]) in _NAME_ROLES def _without_the_link(text: str, letters: frozenset[str]) -> str: """The same name with every class letter deleted -- the LINK-FREE CONTROL. What it is for: a credential can land inside a name part for a reason that has nothing to do with the link, and one such reason is older than #397 and untouched by it. The particle chain reads where the trailing suffix run begins over the pieces as WRITTEN, so a trailing title hides that run from it and the chain runs to the end of the segment -- 'Josep de Carod y Rovira MA Prof.' reads family 'de Carod y Rovira MA Prof.' at the parent 46651750 and at 1.4.0, with no link of the generational class in it at all. Deleting the link answers whether the link is what put the credential there: where the control puts it in a name part too, it did not. A trailing comma rides back onto the word before, so the comma SHAPE survives the deletion ('Josep Carod i, MA' -> 'Josep Carod, MA') -- the one structure whose loss would make the control a different name rather than the same one shorter. """ out: list[str] = [] for word in text.split(): core = word.rstrip(",") if core.lower() in letters: if word.endswith(",") and out: out[-1] += "," continue out.append(word) return " ".join(out) def _initial_spans(name: ParsedName, letters: frozenset[str]) -> set[tuple[int, int]]: return {span for tok, span in _placed(name) if "initial" in tok.tags and tok.text.lower() in letters} def _initial_reading_moved(on: ParsedName, off: ParsedName, letters: frozenset[str]) -> bool: """Whether the two parses disagree about a class letter being an INITIAL -- rules/P8's marked-subset clause rather than P3's both-sides one, and the one thing an off-switch comparison cannot hold fixed: taking the word out of the connectives decides that question too, in either direction. A marked letter in a one-case name reads as an initial only while it IS connective vocabulary; a bare capital the caller's own connectives claim stops reading as one.""" return _initial_spans(on, letters) != _initial_spans(off, letters) def _link_joins_between_name_words(on: ParsedName, off: ParsedName, letters: frozenset[str]) -> bool: off_role = _off_roles(off) toks = _placed(on) for i, (tok, _span) in enumerate(toks): if ("conjunction" not in tok.tags or tok.text.lower() not in letters): continue if (_name_word_on_the_side(toks, i, -1, off_role, on.original) and _name_word_on_the_side(toks, i, 1, off_role, on.original)): return True return False @functools.cache def _off_switch_findings() -> dict[str, list[str]]: """One walk of the off-switch grid; FOUR invariants' answers. Both parses of a row -- as configured, and with the class letters out of the connectives -- are taken once here and handed to all four predicates, which is the whole of what this walk does. The exemptions stay each test's own: INV6 takes the join and the initial reading, INV1-strengthened the initial reading and the link-free control, INV7 the join and the initial reading plus R4's placed-connective sentence, exactly as their docstrings say. INV6b costs NO parse at all, which is why it joins this walk rather than opening a grid of its own: the grid already writes every text in three casings, so the ALL-CAPS twin of a lower-case row is another row of this same walk and the comparison is a lookup over what the walk already read. The link-free controls INV1-strengthened wants are the only parses added here, and only on a row that would otherwise be recorded as a failure. """ out: dict[str, list[str]] = {k: [] for k in ("INV6", "INV1-strengthened", "INV7", "INV6b")} v1_off: dict[frozenset[str], Constants] = {} #: (label, text) -> the roles the configured parse gave, in token #: order, for INV6b's casing comparison below. roles_by: dict[tuple[str, str], tuple[tuple[str, str, bool], ...]] = {} for text, parser, label in _OFF_SWITCH_GRID: letters = _present(text, _class_letters(parser.lexicon)) if not letters: continue on = parser.parse(text) # INV6b's rows, and its scope: the MARKED subset. Its claim # is decisions/P3's own -- a one-case name reads the # marked letter as an initial and so reads as its ALL-CAPS # twin already did -- and a class letter the caller's lexicon # leaves UNMARKED is the shape rules/P8's Accepted bullet # "Two 1.4.0 parity breaks, one in each direction" records # (decisions/P8) instead, where a bare Latin capital and its lowercase # spelling genuinely part. The `conj+v` variant is exactly # that caller: it adds 'v' to the connectives and not to the # marked subset, so 'carod y rovira, v' and its ALL-CAPS twin # disagree, identically at the parent 46651750 (20 rows, # measured 2026-09-20, every one of them strict-comma). if letters <= parser.lexicon.conjunctions_ambiguous: roles_by[(label, text)] = tuple( (tok.text.lower(), tok.role.value, AMBIGUOUS_ACRONYM_TAG in tok.tags) for tok in on.tokens) off_parser = _off_switch(parser, letters) off = off_parser.parse(text) # the SEVEN FIELDS, and not comparison_key: a parse carries # more than its fields, and what the off-switch legitimately # moves besides them is the conjunction-or-initial report same_fields = on.as_dict() == off.as_dict() moved = _initial_reading_moved(on, off, letters) if not (same_fields or _link_joins_between_name_words(on, off, letters) or moved): out["INV6"].append(f"[{label}] {text!r}: {on.as_dict()} != " f"off-switch {off.as_dict()}") if not moved: off_role = _off_roles(off) for role in _NAME_ROLES: part = on.tokens_for(role) if len(part) < 2: continue for tok in part: # not the LINK itself: it is generational # vocabulary by definition of the class, so the # off-switch parse reads it as the suffix in every # name it ends. This rule is about the OTHER word # -- the credential a link must not take with it. # `span is None` is a typing guard and nothing # else: every token of a PARSER-produced name # carries one, and this grid holds no spliced # parse. if "conjunction" in tok.tags or tok.span is None: continue if off_role.get(tok.span) is not Role.SUFFIX: continue # THE LINK-FREE CONTROL, and the reason it is # asked here rather than folded into the oracle: # the particle chain puts a credential into the # family on its own where a trailing title hides # the suffix run from it, link or no link, at # this commit and at the parent and at 1.4.0 # alike ('Josep de Carod y Rovira MA Prof.'). # Turning the class letter off ALSO stops that # chain -- the letter becomes suffix vocabulary # again and the chain halts at it -- so the # off-switch parse reads the credential as a # suffix for a reason this rule is not about. # Deleting the link asks the question directly. # Recorded boundary, not a silence: the shape is # pinned by test_a_trailing_title_still_hides_the # _suffix_run_from_the_particle_chain below and # is the #535 family. # # Taken only on a row already read as a failure, # so the green state pays 15 parses over the # whole grid (measured 2026-09-20). control = parser.parse( _without_the_link(text, letters)) if any(c.text == tok.text and c.role is role for c in control.tokens_for(role)): continue out["INV1-strengthened"].append( f"[{label}] {text!r}: {tok.text!r} reads as " f"the suffix and joined into {role.value}") if not (same_fields and not moved and not _placed_as_a_connective(on, letters)): continue for force in (False, True): here = str(parser.capitalized(on, force=force)) there = str(off_parser.capitalized(off, force=force)) if here != there: out["INV7"].append( f"[{label}] {text!r} force={force}: {here!r} != " f"off-switch {there!r}") if label != "default/default": continue if letters not in v1_off: v1_off[letters] = _v1_off_switch(letters) v1_here = _v1_capitalized(text, None, force) v1_there = _v1_capitalized(text, v1_off[letters], force) if v1_here != v1_there: out["INV7"].append( f"[v1] {text!r} force={force}: {v1_here!r} != " f"off-switch {v1_there!r}") # INV6b, over what the walk above already read. A text that is # its own lower-casing and is not its own upper-casing is the # all-lower spelling of a one-case name, and the grid wrote its # ALL-CAPS twin too, under the same labels. for (label, text), roles in roles_by.items(): if text != text.lower() or text == text.upper(): continue twin = roles_by.get((label, text.upper())) if twin is None or twin == roles: continue if _a_case_reading_the_docs_already_own(roles, twin): continue out["INV6b"].append( f"[{label}] {text!r}: {list(roles)} != ALL-CAPS twin " f"{list(twin)}") return out def _a_case_reading_the_docs_already_own( lower: tuple[tuple[str, str, bool], ...], upper: tuple[tuple[str, str, bool], ...]) -> bool: """INV6b's TWO exemptions, both of them case readings this library decided long before #397 and neither of them the marked subset's. ONE: a single-letter suffix word the lower spelling reads as the suffix and the ALL-CAPS spelling does not. That is the `suffix_not_acronyms` vs `is_an_initial` tension, Latin-only: `_vocab.is_initial` matches an ASCII CAPITAL, so the peel's veto fires on 'I' and 'V' and never on 'i' and 'v'. Measured 2026-09-20 at the parent 46651750 on letters this branch does not touch at all -- 'carod y i' reads suffix 'i' where 'CAROD Y I' reads family 'I', and 'carod y v' against 'CAROD Y V' and 'carod e i' against 'CAROD E I' are the same pair. TWO: a member of the AMBIGUOUS credential class reading differently in the two spellings. That is decisions/S6's own lean -- an ALL-CAPS bare member of the class reads as the credential and a lower-case one as a name word -- and it decides the COMMA STRUCTURE, so its effect reaches every token of the name: 'i rovira, ma' reads given 'ma' and 'I ROVIRA, MA' suffix 'MA', and the rest of the row moves with it. Identical at the parent on all 24 rows it exempts, measured 2026-09-20, and 21 of those carry no letter of #397's class at all. Neither exemption reaches what the branch DID close, which is what makes INV6b worth running: 'rovira, i', 'john smith i jr' and 'josep de carod i rovira' each disagreed with its ALL-CAPS twin at the parent and agrees with it here. Compared by token INDEX, the two spellings having the same tokens in the same order by construction. """ return any( (a[1] == Role.SUFFIX.value and b[1] != Role.SUFFIX.value and len(a[0]) == 1) or ((a[2] or b[2]) and a[1] != b[1]) for a, b in zip(lower, upper)) def test_the_off_switch_grid_can_fail() -> None: """The reachability probe, the shape every grid in this file carries. Dated recorded control, measured 2026-09-20. The row count fell from 103,040 to 53,312 with `_rows` (see the connective probe above); the TEXT count is unmoved by the trim, and 2,011 of the first 4,000 rows carried a class letter against 1,800 of the pre-trim grid's first 4,000. RE-MEASURED 2026-09-20 for the #397 second review, which added the two trailing-title suffix runs: 66,752 rows over 16,576 texts, 2,109 of the first 4,000 rows carrying a class letter and 28,448 over the whole grid -- those being the rows parsed twice. The module's own runtime is the budget these numbers spend, and it is stated where the walk is built.""" assert len(_OFF_SWITCH_GRID) == 66752, len(_OFF_SWITCH_GRID) assert len({t for t, _, _ in _OFF_SWITCH_GRID}) == 16576 reached = sum(1 for text, parser, _ in _OFF_SWITCH_GRID[:4000] if _present(text, _class_letters(parser.lexicon))) assert reached > 1000, reached def test_a_link_that_joins_nothing_changes_no_field() -> None: """INV6 (#397 review), and the strongest thing this rule can be asked: turning the class letter OFF is the parent's reading, so a letter that joins nothing must leave every field where the parent left it. Two exemptions, both narrow and both P3's own OTHER clauses. A letter that JOINED between name words is the rule working, judged on the off-switch parse's classes so an absorbed suffix cannot pass itself off as the name word on the right. And a letter the two parses disagree about being an INITIAL is the marked-subset clause, which the switch decides along with the join and so cannot hold fixed. Mutation-checked, re-measured 2026-09-20 over the grid as it now stands (the second review added the two trailing-title suffix runs, so every count here moved with it): this fails on 1,480 parses at c8550b64, the commit the first review was written against, on 764 where the both-sides gate tests POSITION rather than class -- the c8550b64 defect isolated, which is the one INV1 above cannot see -- and on 154 at dc3bdf9c, where the join's right-hand bound was read over the pieces as WRITTEN and a trailing title hid the suffix run from it. 0 here. """ failures = _off_switch_findings()["INV6"] assert not failures, ( f"{len(failures)} parse(s) moved a field with no link joining " f"anything:\n" + "\n".join(failures[:10])) def test_a_trailing_credential_never_joins_into_a_name_part() -> None: """INV1 strengthened (#397 review). INV1 above inspects the part a join PRODUCED, where an absorbed credential is itself the name word standing on the right, so it is satisfied by the very defect it is about -- `Josep Lluis Carod i III` passes it. This asks the off-switch parse instead: a word THAT reading puts in the suffix never lands inside a joined name part. It carries INV6's second exemption and not its first: a letter the two parses disagree about being an INITIAL moved for the marked-subset clause's reasons, not this one. The JOIN exemption is deliberately absent -- a link joining elsewhere in the name never licenses a credential joining here. THE LINK-FREE CONTROL is its third exemption, added by the second review and documented at the walk: a credential can land in a name part for a reason older than #397 and untouched by it, and deleting the link is how this asks whether the link is what put it there. Mutation-checked, re-measured 2026-09-20 over the grid as it now stands: this fails on 639 parses at c8550b64, where INV1 fails on none of them, on the same 639 where the both-sides gate tests POSITION rather than class, and on 105 at dc3bdf9c -- the trailing-title bound, the defect the two new suffix runs were added to see. 0 here. """ failures = _off_switch_findings()["INV1-strengthened"] assert not failures, ( f"{len(failures)} credential(s) joined into a name part:\n" + "\n".join(failures[:10])) def _placed_as_a_connective(on: ParsedName, letters: frozenset[str]) -> bool: """Whether the parse put a class letter among the NAME words as a connective -- rules/R4's own reading, and the one thing an off-switch comparison of case repair cannot hold fixed: a connective keeps its lowercase there and the off-switch parse, where the letter is no connective at all, capitalizes it. Asked of the token's WORDS rather than of its whole text, because a merged piece renders word by word and repair asks per word.""" for tok in on.tokens: if tok.role is Role.SUFFIX or "conjunction" not in tok.tags: continue if letters & {w.strip(".,").lower() for w in tok.text.split()}: return True return False def _v1_off_switch(letters: frozenset[str]) -> Constants: constants = Constants() for letter in letters: constants.conjunctions.remove(letter) return constants def _v1_capitalized(text: str, constants: Constants | None, force: bool) -> str: name = HumanName(text) if constants is None else HumanName(text, constants) name.capitalize(force=force) return str(name) def test_a_letter_that_did_not_join_repairs_as_the_off_switch_does() -> None: """INV7 (#397 review). Case repair is the third view, and the one the differential cannot see at all, so it gets the same treatment INV6 gives the fields: turning the class letter OFF is the parent's reading, so a letter that became no connective of this name must leave every repair where the parent left it, plain and forced, on both surfaces. Three exemptions. The first two are INV6's -- a name whose FIELDS moved is a name the join changed, and a letter the two parses disagree about being an INITIAL is the marked-subset clause. The third is rules/R4's own sentence: a connective the parse placed among the name words keeps its lowercase there, which the off-switch parse cannot agree with, since for it the letter is not a connective at all. What is left is the generation, and the rule for it is that it repairs as the generation it was read as. Mutation-checked, re-measured 2026-09-20 over the grid as it now stands: this fails on 7,860 repairs at e540d4c5, where the suffix-roled letter still took the connective conjunct, on 8,054 with the generation conjunct removed outright, and on 0 here and at dc3bdf9c -- dc3bdf9c PASSES it, which is the point of INV7b below: the guard there was the suffix ROLE alone, which is too wide, and too wide in a direction this invariant's subject cannot reach. The v1 arm runs on the default-lexicon, default-policy rows, the only ones a `Constants` can express, and 1,017 of the e540d4c5 failures are its. """ failures = _off_switch_findings()["INV7"] assert not failures, ( f"{len(failures)} repair(s) moved for a letter that joined " f"nothing:\n" + "\n".join(failures[:10])) def test_a_one_case_name_reads_the_same_in_either_case() -> None: """INV6b (#397 second review). decisions/P3 states in prose that a name written wholly in lower case now reads as its ALL-CAPS twin already did, and INV6 cannot hold it: the `_initial_reading_moved` exemption there swallows the one-case population, 9,296 of the 28,448 rows that carry a class letter, which is exactly where the marked subset's riskiest movement lives. Every all-lower row's roles, token for token, against the roles of the same text ALL-CAPS under the same parser. Costs no parse: the grid writes both spellings, so the twin is another row of the walk that just ran. Scope and exemptions in `_a_case_reading_the_docs_already_own` and at the `roles_by` write -- the marked subset, less two case readings the library decided long before this rule. Mutation-checked, 2026-09-20: marking the letter for ALL-CAPS names only -- `and token.text.isupper()` on classify's fork -- fails this on 944 rows. Five unit tests in tests/v2/pipeline/test_classify.py fall with it, so this is not the only guard on that line and is not claimed to be; what it adds is the SHAPE of the claim. Those five each assert one spelling's reading against a stored expectation; this asserts that the two spellings AGREE, over the 6,496 all-lower rows this invariant's scope keeps -- where INV6 is exempt and where, until the four rows this round added, no row of tests/v2/cases.py stood. What it holds is real: 'rovira, i', 'john smith i jr' and 'josep de carod i rovira' each disagreed with its ALL-CAPS twin at the parent 46651750 and agrees with it here. """ failures = _off_switch_findings()["INV6b"] assert not failures, ( f"{len(failures)} one-case name(s) read differently from " f"their ALL-CAPS twin:\n" + "\n".join(failures[:10])) # --- #397 second review: the SUFFIX-FIELD CONNECTIVE grid ----------- # A fourth grid, and a tiny one -- 60 rows against the three above in # the tens of thousands -- because the shape it is about is one none # of them can generate. Every grid above puts its connective among # the name's own words or inside a clause; this one puts a connective # in the SUFFIX FIELD without its being generational vocabulary, and # there are exactly two ways to do that: a third comma part, whose # words assign reads as the suffix run whatever they are, and a field # spliced in after the parse. # # The oracle cannot be the off switch, and that is the reason for the # separate grid rather than a second reason for it: no `i` stands in # any of these names, so removing `i` from the connectives changes # nothing and the comparison would be vacuous -- the silence a # reachability probe exists to catch. The oracle is the RULE instead, # the RULE instead, rules/R4 read in its own words: a suffix-roled # connective that the suffix vocabulary does NOT hold was read as no # generation and keeps its lowercase. # The literal strings the released wheels give are pinned separately, # as unit tests in tests/v2/test_render.py. _SUFFIX_FIELD_CONNECTIVES = ("and", "y", "e", "und", "of", "&", "и") def _suffix_field_rows() -> list[tuple[str, str]]: """(description, the connective word) for every way this grid puts a connective into the suffix field.""" rows: list[tuple[str, str]] = [] for word in _SUFFIX_FIELD_CONNECTIVES: rows += [(f"Smith, John, {word}", word), (f"Doe, Jane, {word} Jr.", word), (f"Smith, John, {word} III", word)] return rows def test_a_connective_the_suffix_field_holds_keeps_its_lowercase() -> None: """INV7b (#397 second review). R4's clause turns on the GENERATION and not on the field: a connective the suffix field merely holds, which the suffix vocabulary does not know, was read as no generation, so case repair leaves it lowercase -- plain and forced, on both surfaces. INV7 cannot see this. Its oracle is the off switch and its subject is the class letter, and no name here carries one: `and`, `y`, `e`, `und`, `of`, `&` and `и` are connectives and nothing else, which is the whole point -- they are what a role test alone could not tell apart from the generation. Mutation-checked, 2026-09-20: this fails on 36 of its 42 rows at dc3bdf9c, where `role is not Role.SUFFIX` gated both conjunction arms, and on 0 here; the six that pass there are the Cyrillic 'и', which repairs to itself either way. 'Smith, John, and' repaired forced to 'John Smith And' there, against 'John Smith and' at 1.4.0, at 2.0.0 through 2.3.0 and at the parent 46651750. INV7 above cannot see any of this -- it passes at dc3bdf9c. """ parser = Parser() failures = [] for text, word in _suffix_field_rows(): name = parser.parse(text) if word not in {t.text for t in name.tokens_for(Role.SUFFIX)}: failures.append(f"{text!r}: {word!r} is not in the suffix") continue for force in (False, True): repaired = str(parser.capitalized(name, force=force)) v1 = _v1_capitalized(text, None, force) for label, got in (("core", repaired), ("v1", v1)): if word not in got.split(): failures.append( f"[{label}] {text!r} force={force}: {got!r} does " f"not keep {word!r} lowercase") assert not failures, ( f"{len(failures)} repair(s) capitalized a connective the " f"suffix field merely holds:\n" + "\n".join(failures[:10])) def test_the_suffix_field_connective_grid_can_fail() -> None: """The reachability probe, the shape every grid in this file carries. Dated recorded control, measured 2026-09-20. The one that matters here is the SECOND: a row whose connective the parse did not actually put in the suffix field would pass INV7b for the wrong reason, and the invariant records that as a failure rather than skipping it, so this counts the rows that reach the repair at all. """ rows = _suffix_field_rows() assert len(rows) == 21, len(rows) parser = Parser() reached = sum( 1 for text, word in rows if word in {t.text for t in parser.parse(text).tokens_for( Role.SUFFIX)}) assert reached == 21, reached assert not _class_letters(Lexicon.default()) & { w for _t, w in rows}, "a class letter would make INV7 the oracle" # --- #397 review: the MAIDEN-CLAUSE LINK grid, and its two ---------- # invariants # A third grid, kept apart from the two above for the reason the # off-switch one is: every row here is parsed TWICE, once as written # and once with the link spelled `y`, and the texts that earn the # second parse are the clause shapes -- which neither grid above # generates, both putting their connective among the name's own # words. What this one adds: a link INSIDE a maiden clause, in every # position a clause can hold one, under every marker the library # ships and behind every trailing run the walk stops at. # # The `y` spelling is the ORACLE and not a second subject. It is the # same sentence with the ambiguity removed: `y` is connective # vocabulary and no generation, so nothing in the walk can read it as # the end of the clause, and whatever it does is what the `i` # spelling has to do wherever P3 says the letter is joining. #: The clause bodies, `@` standing where the link goes: a link #: between two words, with a word run on either side of it, doubled, #: and the two shapes where it joins NOTHING -- nothing on its right, #: nothing on its left but the marker. The last two are the #: controls the guard below must refuse, and #: `test_the_maiden_link_grid_can_fail` counts them out. _LINK_BODIES: tuple[tuple[str, ...], ...] = ( ("Puig", "@", "Soler"), ("Puig", "@", "Soler", "Roig"), ("Puig", "Soler", "@", "Roig"), ("Puig", "@", "Soler", "@", "Roig"), ("Puig", "@"), ("@", "Soler"), ) #: Nothing, the two generations and the two credential classes -- the #: four trailing runs M2's walk stops at, plus the empty one. _LINK_TAILS: tuple[tuple[str, ...], ...] = ( (), ("III",), ("Jr.",), ("MA",), ("PhD",)) #: One to three words, and the last carries a link of its OWN, which #: stays spelled `i` in both parses: the head's reading is not what #: this grid is about, and holding it fixed is what makes a moved #: field the clause's doing. _LINK_HEADS: tuple[tuple[str, ...], ...] = ( ("Jane",), ("Doe", "Jane"), ("Jane", "Doe"), ("Jane", "M.", "Doe"), ("Carod", "i", "Rovira")) _LINK_RE = re.compile(r"(?<!\S)y(?!\S)") def _link_texts() -> dict[str, str]: """Every clause shape, mapped to its `y`-spelled twin. The two spellings differ in one CHARACTER per link, so their token spans line up exactly -- which is what lets the second invariant below delimit the clause with the twin's own maiden tokens and then read the first parse at those offsets. """ out: dict[str, str] = {} markers = sorted(Lexicon.default().maiden_markers) for head, marker, body, tail, comma in itertools.product( _LINK_HEADS, markers, _LINK_BODIES, _LINK_TAILS, (False, True)): if comma and len(head) < 2: continue pair = [] for link in ("i", "y"): words = [link if w == "@" else w for w in body] rest = list(head[1:] if comma else head) + [marker] \ + words + list(tail) pair.append(f"{head[0]}, " + " ".join(rest) if comma else " ".join(rest)) out.setdefault(pair[0], pair[1]) return out _LINK_TWIN = _link_texts() _MAIDEN_LINK_GRID = _rows( list(_LINK_TWIN), (("default", Lexicon.default(), frozenset()),)) def _link_joins_inside_the_clause(maiden: str) -> bool: """Whether the ORACLE parse put a link inside the birth name with a birth-name word on each side of it -- rules/P3's both-sides condition, read off the `y` spelling's own maiden field. The guard, and it is narrower than "the twin keeps the link" for a measured reason: 'Jane Doe nee Puig y' keeps its `y` in maiden 'Puig y', and 'Jane Doe nee Puig i' reads maiden 'Puig' with suffix 'i' -- deliberately, the link there joining nothing and being the generation it also spells (rules/M2). So a guard asking only whether the twin kept the letter would demand the two agree where the rules say they must not. `Puig y III` and `y Soler` are the same shape from the other two sides. """ words = maiden.split() at = [k for k, w in enumerate(words) if w == "y"] return bool(at) and all( any(w != "y" for w in words[:k]) and any(w != "y" for w in words[k + 1:]) for k in at) @functools.cache def _maiden_link_findings() -> dict[str, list[str]]: """One walk of the maiden-link grid; two invariants' answers. Both parses of a row are taken once here and handed to both predicates, which is all this walk does -- the ONE PARSE, MANY CHECKS rule the two grids above follow. """ out: dict[str, list[str]] = {k: [] for k in ("INV8", "INV9")} for text, parser, label in _MAIDEN_LINK_GRID: twin = parser.parse(_LINK_TWIN[text]) if not _link_joins_inside_the_clause(twin.maiden): continue on = parser.parse(text) want = {k: _LINK_RE.sub("i", v) for k, v in twin.as_dict().items()} if on.as_dict() != want: out["INV8"].append(f"[{label}] {text!r}: {on.as_dict()} != " f"y-twin {want}") spans = [tok.span for tok in twin.tokens if tok.role is Role.MAIDEN and tok.span is not None] lo = min(s.start for s in spans) hi = max(s.end for s in spans) for tok in on.tokens: if (tok.span is not None and lo <= tok.span.start and tok.span.end <= hi and tok.role in _NAME_ROLES): out["INV9"].append( f"[{label}] {text!r}: {tok.text!r} of the birth " f"name reads as {tok.role.value}") return out def test_the_maiden_link_grid_can_fail() -> None: """The reachability probe, the shape every grid in this file carries. Dated recorded control, measured 2026-09-20. The third count is the one to watch: the guard above refuses the two joining-nothing bodies outright, so a grid whose every row were one of those would pass both invariants in silence. 1,350 of the first 2,000 rows are guarded in, and 10,540 of all 15,810 -- two thirds, which is the four admitted bodies of six. """ assert len(_MAIDEN_LINK_GRID) == 15810, len(_MAIDEN_LINK_GRID) assert len(_LINK_TWIN) == 4590, len(_LINK_TWIN) guarded = sum( 1 for text, parser, _ in _MAIDEN_LINK_GRID[:2000] if _link_joins_inside_the_clause(parser.parse(_LINK_TWIN[text]).maiden)) assert guarded > 800, guarded def test_a_clause_link_reads_as_its_y_twin_does() -> None: """INV8 (#397 review). THE y TWIN. Where the `y` spelling puts a link inside the birth name with a birth-name word on each side, the `i` spelling gives the same seven fields, letter for letter apart from the link itself. `y` is connective vocabulary and nothing else, so its reading is the one the maiden walk was never able to get wrong; `i` is that same connective AND the roman numeral, and the walk used to end the birth name at it. The pair is the whole statement of rules/M2's link clause, and it needs no expected values of its own. Mutation-checked, 2026-09-20: it fails on all 10,540 guarded rows at 0fbcaa0b -- this branch's tip before the fix -- and on the same 10,540 at the parent 46651750, the walk having truncated the birth name at the link since long before #397 reached it. That it is EVERY guarded row and not a subset is the finding: no clause shape holding a joining link read as its twin did. """ failures = _maiden_link_findings()["INV8"] assert not failures, ( f"{len(failures)} parse(s) read a clause link differently " f"from its y twin:\n" + "\n".join(failures[:10])) def test_no_birth_name_word_reads_as_a_word_of_the_current_name() -> None: """INV9 (#397 review). The failure class #424 and #533 exist to prevent, stated for the link: no token standing inside the birth name -- as the `y` twin's own maiden tokens delimit it -- is roled GIVEN, MIDDLE or FAMILY. Weaker than INV8 and kept beside it because it is the one that names the HARM. A truncation moves fields too, and this stays green for it; what it refuses is a word of one person's birth name being handed to the surname they carry now. Mutation-checked, 2026-09-20: it fails on 28,985 tokens at 0fbcaa0b and on 22,185 at the parent 46651750, over the same 10,540 guarded rows. The GAP between those two is what this branch added: at the parent the released words are a truncation that left them in `middle` and `family`, and with the link joining they became words of the current surname itself. """ failures = _maiden_link_findings()["INV9"] assert not failures, ( f"{len(failures)} birth-name word(s) read as a word of the " f"current name:\n" + "\n".join(failures[:10])) # --- #459/#492/#478: case repair changes case and nothing else ------ # Not a grid, and deliberately not joined to one: the three grids # above generate connective shapes under variant lexicons, and this # invariant is about every name a caller has actually written -- the # deduped corpus glob plus the case table's texts -- under the two # lexicons that decide what the exceptions map can do, the shipped one # and one with the map emptied. The fuzzed half rides the existing # hypothesis walk instead (test_any_valid_config_still_parses_totally), # which already draws masks. Measured 2026-09-23 on py3.11: 1606 texts, # and the whole walk -- two lexicons, both surfaces, plain and forced # -- under a second (`--durations`). _CASE_ONLY_TEXTS = list(dict.fromkeys( [*_FORK_CORPUS, *(case.text for case in CASES)])) _FACADE_LISTS = ("title_list", "first_list", "middle_list", "last_list", "suffix_list", "nickname_list", "maiden_list") def _case_only_violations() -> list[str]: """Every repaired token, and every repaired facade list element, that differs from what it repaired by more than case. Compared token by token on the core and list by list on the facade, never through a rendered string, so render spacing cannot hide or fake a difference.""" emptied = dataclasses.replace(Lexicon.default(), capitalization_exceptions=()) configs = (("default", Parser(), Constants()), ("map emptied", Parser(lexicon=emptied), Constants(capitalization_exceptions={}))) out: list[str] = [] for label, parser, constants in configs: for text in _CASE_ONLY_TEXTS: name = parser.parse(text) for force in (False, True): repaired = parser.capitalized(name, force=force) for was, now in zip(name.tokens, repaired.tokens, strict=True): if was.text.casefold() != now.text.casefold(): out.append( f"[core, {label}, force={force}] {text!r}: " f"{was.text!r} -> {now.text!r}") human = HumanName(text, constants=constants) before = {attr: getattr(human, attr) for attr in _FACADE_LISTS} # capitalize() mutates in place, so the forced call repairs # the plain call's output -- still compared against the # lists as parsed, which casefold equality makes transitive for force in (False, True): human.capitalize(force=force) for attr in _FACADE_LISTS: after = getattr(human, attr) if ([s.casefold() for s in after] != [s.casefold() for s in before[attr]]): out.append( f"[facade, {label}, force={force}] {text!r} " f"{attr}: {before[attr]!r} -> {after!r}") return out def test_case_repair_changes_case_and_nothing_else() -> None: """rules/R4 read as an invariant: a repaired token equals its input under str.casefold(), on both surfaces, plain and forced, under the shipped lexicon and under one whose exceptions map is empty. casefold(), not a character-for-character check, is the comparator because the rule was never stated against Python's own casing tables: 'ß'.upper() is 'SS', two characters for one, and 'ß' and 'ss' fold alike, so a lengthened word still passes -- and so would a repair trading 'ss' for 'ß', the comparator's recorded blind spot. It is not blind the other way: the roman-numeral shape under re.I admits the dotless 'ı', which upper()s to 'I', and 'I' does NOT fold back to 'ı', so a name spelling a numeral with it would fail this walk; none in the walk does. Both cases are decisions/R4's Unicode boundary. Recorded negative control, measured 2026-09-23 over this walk: at the parent 4d0680e6, where the exceptions map SUBSTITUTED its value ('md' -> 'M.D.', 'iii.' -> 'III'), this fails on 257 repairs, every one under the shipped lexicon and none with the map emptied. The live control is the test below. """ failures = _case_only_violations() assert not failures, ( f"{len(failures)} repair(s) changed more than case:\n" + "\n".join(failures[:10])) def test_the_case_only_walk_can_fail( monkeypatch: pytest.MonkeyPatch) -> None: """The walk above must be able to report a violation, or its silence proves nothing. Putting the pre-#459 substitution back -- the mask applier returning the map's value verbatim -- has to be seen. Measured 2026-09-23, it fails on 16 repairs: 8 token repairs counted on both surfaces (core and facade), the same way the parent's 257 is counted -- the five texts that write Ph.D. with its periods, in either case, including the all-initials spelling p.h.d., which the mask 'PhD' would strip of them. Far fewer than the parent's 257 because the shipped map no longer holds a value spelled unlike its key's common spellings; the count is a floor on the walk's reach, not a target. """ import nameparser._render as render_module monkeypatch.setattr(render_module, "_apply_mask", lambda word, mask: mask) failures = _case_only_violations() assert failures, "the case-only walk cannot see a substitution" assert any("'Ph.D.' -> 'PhD'" in line for line in failures), failures # --- #542/#585: a decomposed name renders as its composed twin does - # The same texts as the walk above, those that decompose at all, each # parsed in both forms, repaired and initialled, and compared once the # decomposed output is composed again. A text whose two forms PARSE # differently is set aside rather than compared: both views follow the # parse, so the comparison would report the parse, and the one class that does so is # unspaced hangul, which segmentation matches as written by decision # (docs/usage.rst, "Decomposed text"). The test below pins that filter. def _hangul(text: str) -> bool: return any("가" <= c <= "힣" for c in unicodedata.normalize("NFC", text)) def _nfd_twin_findings() -> tuple[list[str], list[str], int]: """(disagreements, texts set aside, texts compared).""" def nfc(text: str) -> str: return unicodedata.normalize("NFC", text) parser = Parser() out: list[str] = [] set_aside: list[str] = [] compared = 0 for text in _CASE_ONLY_TEXTS: decomposed = unicodedata.normalize("NFD", text) if decomposed == nfc(text): continue name, composed = parser.parse(decomposed), parser.parse(nfc(text)) if ([(t.role, nfc(t.text)) for t in name.tokens] != [(t.role, t.text) for t in composed.tokens]): set_aside.append(text) continue compared += 1 human, twin = HumanName(decomposed), HumanName(nfc(text)) for surface, decomposed_initials, composed_initials in ( ("core", name.initials(), composed.initials()), ("facade", human.initials(), twin.initials())): if nfc(decomposed_initials) != composed_initials: out.append(f"[initials, {surface}] {text!r}: " f"{nfc(decomposed_initials)!r} != " f"{composed_initials!r}") for force in (False, True): got = [nfc(t.text) for t in parser.capitalized(name, force=force).tokens] want = [t.text for t in parser.capitalized(composed, force=force).tokens] if got != want: out.append(f"[core, force={force}] {text!r}: " f"{got!r} != {want!r}") for force in (False, True): human.capitalize(force=force) twin.capitalize(force=force) for attr in _FACADE_LISTS: got = [nfc(s) for s in getattr(human, attr)] if got != getattr(twin, attr): out.append(f"[facade, force={force}] {text!r} {attr}: " f"{got!r} != {getattr(twin, attr)!r}") return out, set_aside, compared def test_a_decomposed_name_renders_as_its_composed_twin() -> None: """rules/R3 and #R4 read as an invariant over encodings: a letter written as a base letter and its combining accent initials (#585) and repairs (#542) as the letter written whole does. Core and facade; repair plain and forced. The output is compared composed because both views keep the form they were given. Recorded negative controls, measured 2026-10-02. Repair, with 97af1f02's _render.py, before a word ran on through its marks, over #542's corpus (its own two R4 rows included): 144 disagreements over the 137 texts compared, 47 more set aside, every one hangul ('JOSÉ GARCÍA' repairing to 'José GarcíA'). Initials, with _first_letter taking text[0] again, over #585's corpus (its own two R3 rows included, so 139 texts compared where #542's had 137): 44 disagreements, 22 texts each failing on both surfaces, katakana as well as Latin ('マイケル ジャクソン' initialling 'マ. シ.', the dakuten of 'ジ' decomposing to a combining mark). The live controls are the two tests below.""" failures, set_aside, compared = _nfd_twin_findings() assert compared, "no decomposable text was compared" assert all(_hangul(text) for text in set_aside), ( "a non-hangul text parses differently decomposed: " f"{[t for t in set_aside if not _hangul(t)]}") assert not failures, ( f"{len(failures)} decomposed rendering(s) disagree:\n" + "\n".join(failures[:10])) def test_the_decomposed_walk_can_fail( monkeypatch: pytest.MonkeyPatch) -> None: """Stopping a word at its first combining mark again -- #542's defect -- has to be seen by the walk above.""" import nameparser._render as render_module monkeypatch.setattr(render_module, "_past_marks", lambda text, end: end) failures, _, _ = _nfd_twin_findings() assert any("'José', 'GarcíA'" in line for line in failures), failures def test_the_decomposed_initials_walk_can_fail( monkeypatch: pytest.MonkeyPatch) -> None: """Taking a token's first code point again -- #585's defect -- has to be seen by the walk above, on both surfaces.""" import nameparser._render as render_module monkeypatch.setattr(render_module, "_first_letter", lambda text: text[0]) failures, _, _ = _nfd_twin_findings() for surface in ("core", "facade"): assert any(line.startswith(f"[initials, {surface}]") for line in failures), failures _TWIN_HEADS = ("Smith, John,", "Smith, John, Jr.,", "Doe, Jane, PhD") _TWIN_BODIES = ( "PhD née Puig Mr. i Soler", "PhD née Puig i Soler", "PhD née Puig Dr. i y Soler", "MD née Carod i Rovira Mr.", "PhD née Jones Smith i Soler", "PhD née Puig y Soler", "Puig i Soler", "Puig Dr. i y Soler", "PhD and MD", "PhD i MD", "PhD MD FACS", "Puig y Soler") def _twin_record(name: ParsedName) -> tuple[object, ...]: """The role fields and the reported kinds, less `comma-structure`: C2 reports it once per extra comma part, so the typed comma adds one the core does not, by construction rather than by reading.""" kinds = sorted(a.kind for a in name.ambiguities if a.kind is not AmbiguityKind.COMMA_STRUCTURE) return (name.as_dict(), kinds) def _comma_twin_findings(parser: Parser, core: str = " - ") -> tuple[list[str], int]: """Each body with `core` in one interior gap, against the same text with a typed comma there, all seven fields.""" failures = [] total = 0 for head in _TWIN_HEADS: for body in _TWIN_BODIES: parts = body.split() for gap in range(1, len(parts)): left, right = " ".join(parts[:gap]), " ".join(parts[gap:]) total += 1 got = _twin_record( parser.parse(f"{head} {left}{core}{right}")) want = _twin_record(parser.parse(f"{head} {left}, {right}")) if got != want: failures.append(f"{head} {left}{core}{right}: " f"{got} != {want}") return failures, total def test_a_delimiter_core_in_a_tail_reads_as_its_comma_twin() -> None: """rules/C10's statement: "a suffix delimiter parts the suffix words on each side of it as a comma would" (#549). Every role field of a name with a declared core standing in a part after the suffix comma, and every kind it reports but `comma-structure` (`_twin_record`), equals that of the same text written with a comma in the core's place -- the maiden clause, the connective join and the entry boundary alike. The heads put the core in a TRAILING part only: before the first comma, or in the given part of the listing form, the core is a word (C10's core-is-a-word Accepted entry), and its comma twin a different structure. RECORDED NEGATIVE CONTROL: at 061f02da, where the join and the maiden walk stepped over a core instead (#538), 102 of these 129 texts disagreed -- 'Puig - i Soler' read suffix 'Puig - i Soler' against 'Puig, i Soler', and 'PhD née Puig - i Soler' maiden 'Puig i Soler' against 'Puig'. The core is ' | ' since #889 made a hyphen standing alone built in, so that ' - ' no longer shows the declared path; re-taken with ' | ' at 061f02da on 2026-10-27, 102 of 129 again. """ pipe = Parser(policy=Policy(extra_suffix_delimiters=frozenset({" | "}))) failures, total = _comma_twin_findings(pipe, " | ") assert total == 129 assert not failures, "\n".join(failures) def test_a_semicolon_standing_alone_in_a_tail_reads_as_its_comma_twin() -> None: """rules/C10's "The semicolon, the hyphen and the slash are delimiters by default" -- the same walk with no policy at all, the semicolon standing alone. RECORDED NEGATIVE CONTROL: at 638685b4, before the semicolon was a delimiter, all 129 texts disagreed, the semicolon a word of the suffix run where the comma parts it.""" failures, total = _comma_twin_findings(Parser(), " ; ") assert total == 129 assert not failures, "\n".join(failures) @pytest.mark.parametrize("core", [" - ", " / "]) def test_a_hyphen_or_slash_standing_alone_in_a_tail_reads_as_its_comma_twin( core: str) -> None: """rules/C10's "The semicolon, the hyphen and the slash are delimiters by default" -- #549's walk again with no policy, the hyphen or the slash standing alone (#889). RECORDED NEGATIVE CONTROL: at 8a65ee18, before either was a delimiter, all 129 texts disagreed for each core, the core a word of the suffix run where the comma parts it.""" failures, total = _comma_twin_findings(Parser(), core) assert total == 129 assert not failures, "\n".join(failures) # rules/C10's "A delimiter glued to a suffix word parts it from the next # as a comma glued there would" -- so a semicolon glued to a word after # the first comma reads as the same text with a space in its place, the # glued word kept in its field with its semicolon. Two inputs (docs/ # design/AGENTS.md axis 11), over every comma shape nameparser/AGENTS.md's sweep # names (a whole name, one word, an empty head, a nickname head, a # suffix before the comma, a particle, a connective and a title-particle # head), a second comma part or none, and all three orders, with a word # of each kind a glued word can be: a listed credential, a generation, a # word of the ambiguous class, an unlisted all-caps credential, a title, # a numeral written like an initial, a semicolon glued to the front. Left # out by C10's Accepted bullets: a split 'Ph. D.', a credential only as a # pair the glued half cannot join, and a glued particle, which joins # nothing where its space twin joins a neighbour. _GLUED_HEADS = ("John Smith", "Smith", "", "(Bob)", "John Smith Jr.", "Juan de la Vega", "Ortega y Gasset", "Freiherr von Berg", "St. John", "Dr. John Smith", "Rev. Sr. Smith") _GLUED_TAILS = ("MD; PhD", "MD;PhD", "MD;", "PhD; MD; MPH", "Jr.; PhD", "III; MD", "M.D.; Ph.D.", "md; phd", "V; MD", "Esq.;", "MA; PhD", "MBBS; FCPS", "Dr; MD", "MD ;PhD") def _glued_twin_findings() -> tuple[list[str], int]: failures = [] total = 0 for order in (GIVEN_FIRST, FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST): parser = Parser(policy=Policy(name_order=order)) for head in _GLUED_HEADS: for second in ("", " John,"): for tail in _GLUED_TAILS: text = f"{head},{second} {tail}" twin = f"{head},{second} {tail.replace(';', ' ')}" got, want = parser.parse(text), parser.parse(twin) total += 1 records = [ ({k: " ".join(v.replace(";", " ").split()) for k, v in name.as_dict().items()}, [a.kind for a in name.ambiguities]) for name in (got, want)] if records[0] != records[1]: failures.append(f"{order} {text!r}: {records[0]} " f"!= {records[1]}") return failures, total def test_a_glued_semicolon_reads_as_its_space_twin() -> None: """Every field of a name with a semicolon glued to a word after the first comma, read with its semicolons as spaces, and every kind it reports, equals that of the twin with a space in the semicolon's place. RECORDED NEGATIVE CONTROL: at 638685b4, before a glued semicolon parted anything, 726 of these 924 texts disagreed.""" failures, total = _glued_twin_findings() assert total == 924 assert not failures, "\n".join(failures) # A one-letter word written with its period is an initial and not a # particle (rules/P7), so a name holding 'Ó.' must read exactly as # the same name holding any other initial does. Two INPUTS, # not two views of one parse (docs/design/AGENTS.md axis 11): the rule # is not consulted, so the test can find the rule wrong as well as the # code. 'Q.' is the comparison initial because no word of the grid # holds a q, so mapping it back to Ó cannot touch another word. # # Every particle reader in the package is a site, and each is checked # by every view the comparison takes: the parse's own fields, reports, # initials and case repair, and the v1 facade's initials and its # last-name split. The grid is the eight words below, two and three at # a time, every comma position, each name holding 'Ó.' and run as # written, upper-cased and lower-cased: 537 texts. One walk of both # sides is about 0.75s; the 'Q.' side is walked once per lexicon and # shared, so the invariant and its four-site control together cost # about 3s (measured 2026-10-04, py3.11). _P7_WORDS = ('Ó.', 'de', 'Pérez', 'Juan', 'MA', 'Ed', 'DO', 'Jr.') def _p7_texts() -> list[str]: texts = set() for k in (2, 3): for words in itertools.product(_P7_WORDS, repeat=k): if 'Ó.' not in words: continue texts.add(' '.join(words)) for c in range(1, k): texts.add(' '.join(words[:c]) + ', ' + ' '.join(words[c:])) return sorted(texts) def _p7_views(text: str, lexicon: Lexicon) -> str: parser = Parser(lexicon=lexicon) out = [] for written in (text, text.upper(), text.lower()): name = parser.parse(written) legacy = HumanName(written) out.append((name.as_dict(), [a.kind for a in name.ambiguities], name.initials(), str(name.capitalized(lexicon)), legacy.initials(), legacy.last_prefixes, legacy.last_base)) return str(out) @functools.cache def _p7_reference(lexicon: Lexicon) -> dict[str, str]: """The 'Q.' side of the comparison, mapped back to Ó: walked once per lexicon and shared. No veto site can move it -- each asks the helper only after a particle-membership hit, and no particle of the grid is a one-letter word but 'ó' -- so the site control's patch leaves it as it is.""" return {text: _p7_views(text.replace('Ó.', 'Q.'), lexicon) .replace('Q', 'Ó').replace('q', 'ó') for text in _p7_texts()} def _p7_disagreements(lexicon: Lexicon | None = None) -> list[str]: lexicon = lexicon or Lexicon.default() reference = _p7_reference(lexicon) return [text for text in _p7_texts() if _p7_views(text, lexicon) != reference[text]] def test_a_one_letter_particle_with_its_period_reads_as_any_initial() -> None: assert _p7_disagreements() == [] #: The recorded negative control: how many grid texts disagree with the #: veto removed from ONE site, measured 2026-10-04 (classify 412 and #: the facade 121 until #603 the same day, whose opened comma part #: reads some texts the same with the veto off or on, the credential #: opening the part either way: nine of classify's, 'DO Ó., ...' #: shapes, and one of the facade's), and re-measured 2026-10-06 after #: #613 decided the comma once at group's head, which moved classify, #: the facade and _render one text each. Every site moves #: some, so none is decoration. _render's is measured without the #: particle case masks, as for a caller's one-letter particle that has #: none: with the shipped 'ó' mask the repair takes the mask before #: the particle arm is asked (rules/R4), so the default vocabulary #: alone cannot see that veto. _P7_SITE_EFFECT = { "nameparser._pipeline._classify": 404, "nameparser._pipeline._vocab": 21, "nameparser._render": 339, "nameparser._facade": 121, } def _without_particle_masks() -> Lexicon: default = Lexicon.default() return dataclasses.replace(default, capitalization_exceptions=tuple( (key, mask) for key, mask in default.capitalization_exceptions if key not in default.particles)) @pytest.mark.parametrize("module", sorted(_P7_SITE_EFFECT)) def test_every_one_letter_particle_veto_is_load_bearing( module: str, monkeypatch: pytest.MonkeyPatch) -> None: import importlib lexicon = None if module == "nameparser._render": # the invariant itself, on the lexicon only this case walks lexicon = _without_particle_masks() assert _p7_disagreements(lexicon) == [] monkeypatch.setattr(importlib.import_module(module), "_spells_an_initial", lambda n, text: False) assert len(_p7_disagreements(lexicon)) == _P7_SITE_EFFECT[module] # --- #721, #723: a name written in one case reads as its capitals --- # T4 (the bare letter as an initial) says a name written wholly in one # case says nothing about any letter in it -- so a wholly lower-case # name parses exactly as the same name in capitals, fields (compared # case-folded) and report kinds alike. The grid crosses the shapes a one-letter numeral meets # -- every comma shape, a maiden clause, a title, a particle chain, a # bound given name, an initial, a nickname and a credential before or # after it -- and the shapes any bare letter meets (#723), with the # letters below, three heads (one Catalan, one holding an ambiguous # acronym) and all three orders -- and every shape holding a comma # again under Policy(lenient_comma_suffixes=False), the one switch # whose suffix test vetoes an initial by the word's own shape (#727); # trimmed by shape, since the switch reaches only a comma's parts: # 4,554 pairs, about 9,100 parses, 0.73s on py3.11 (2026-10-18). _ONE_CASE_SHAPES = ( "{a} {n}", "{a} {b} {n}", "{a} {n}, phd", "{a} {b} {n}, phd", "{a} {n}, jr.", "{b}, {a} {n}", ", {a} {n}", "{a} nee {b} {n}", "{a} {b} nee {c} {n}", "dr {a} {n}", "dr {a} {b} {n}", "sir {a} {n}", "{a} van {b} {n}", "freiherr von {b} {n}", "abdul {a} {n}", "{a} j. {n}", "({c}) {a} {n}", "{a} {b} jr {n}", "{a} {n} phd", # a bare letter in front, a trailing title, a doubled numeral and # a trailing period "{a} b {n}", "{a} e {n}", "{a} {n} prof.", "{a} {b} {n} prof.", "{a} {b} {n} {n}", "{a} {b} {n}.", # a non-ASCII letter in front, whose capital is no initial "{a} é {n}", # #723: the letter alone, before a maiden marker, before or after a # connective, opening the name and after an empty head "{n}", "{n} nee {b}", "{a} {n} y {b}", "{b}, {n} y {c}", "{n} {a}", ", {n}", # #727: the letter alone after a comma, behind a whole name or in a # third part, and opening a credential run; and after a glued CJK # honorific, whose peel asks the suffix test by text "{a} {b}, {n}", "{b}, {a}, {n}", "{a} {b}, {n} phd", "田中さん {n}") #: the one-letter numerals and a listed and a shape-only spelling of #: each longer length, then one letter from each other fork a bare #: letter meets (#723): no vocabulary, a connective, a marked one _ONE_CASE_LETTERS = ("v", "i", "x", "vi", "iii", "ii", "iv", "vii", "j", "y", "e") _ONE_CASE_HEADS = (("john", "smith", "jones"), ("carod", "puig", "rovira"), ("mary", "ma", "lee")) #: the negative controls, measured 2026-10-18 on this grid, which #: 2444f847 (before #727) disagreed on 72 of: with classify's one-case #: letter initial taken out, with the numeral fork's one-case letter #: in front taken out, and with the strict suffix test's one-case veto #: taken out (#727: the 54 strict comma pairs and the 18 honorific #: ones, which are 2444f847's 72) -- each guard seen on its own _ONE_CASE_UNGUARDED = 387 _ONE_CASE_LETTER_IN_FRONT_UNGUARDED = 117 _ONE_CASE_STRICT_VETO_UNGUARDED = 72 def _one_case_disagreements() -> list[str]: def sig(name: ParsedName) -> tuple[object, ...]: return ({k: v.lower() for k, v in name.as_dict().items() if v}, tuple(sorted(a.kind.value for a in name.ambiguities))) out = [] commas = tuple(s for s in _ONE_CASE_SHAPES if "," in s) for order in (GIVEN_FIRST, FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST): for strict, shapes in ((False, _ONE_CASE_SHAPES), (True, commas)): parser = Parser(policy=Policy( name_order=order, lenient_comma_suffixes=not strict)) for shape, letter, (a, b, c) in itertools.product( shapes, _ONE_CASE_LETTERS, _ONE_CASE_HEADS): lower = shape.format(a=a, b=b, c=c, n=letter) if (sig(parser.parse(lower)) != sig(parser.parse(lower.upper()))): out.append(f"[{order}{' strict' * strict}] {lower!r}") return out def test_a_one_case_name_reads_as_its_capitals() -> None: failures = _one_case_disagreements() assert not failures, "\n".join(failures[:10]) def test_the_one_case_grid_sees_the_letter_initial( monkeypatch: pytest.MonkeyPatch) -> None: # classify's one use of `_INITIAL` is the one-case letter initial monkeypatch.setattr("nameparser._pipeline._classify._INITIAL", re.compile(r"(?!)")) assert len(_one_case_disagreements()) == _ONE_CASE_UNGUARDED def test_the_one_case_grid_sees_the_strict_veto( monkeypatch: pytest.MonkeyPatch) -> None: # the strict suffix test without its one-case input (#727): the # veto by the word's own writing alone, as before from nameparser._pipeline import _vocab real = _vocab._is_suffix_strict_n monkeypatch.setattr( _vocab, "_is_suffix_strict_n", lambda n, text, lexicon, one_case=None: real(n, text, lexicon)) assert (len(_one_case_disagreements()) == _ONE_CASE_STRICT_VETO_UNGUARDED) def test_the_one_case_grid_sees_the_letter_in_front( monkeypatch: pytest.MonkeyPatch) -> None: from nameparser._pipeline import _vocab monkeypatch.setattr( "nameparser._pipeline._pieces.reads_as_generation", lambda text, tags, preceding, one_case=None: _vocab.reads_as_generation(text, tags, preceding, None)) assert (len(_one_case_disagreements()) == _ONE_CASE_LETTER_IN_FRONT_UNGUARDED) #: H8's grid (#835): family-comma heads, given parts and tails #: holding a bare salutation, each paired with its period-marked twin. #: 704 texts, each parsed with its twin under each of the three #: orders: 2,112 pairs, about 0.5s on py3.11 (measured 2026-10-24). _SALUTATION_HEADS = ("Doe, ", "van Doe, ", ", ", "Doe Jr., ") _SALUTATION_GIVENS = ("Jane", "abdul", "Jane van", "Jane van der", "abdul van", "J.", "Jane Q", "St", "Freiherr", "jane", "JANE") _SALUTATION_TAILS = ("Mrs", "Mr Mrs", "Mrs Jr", "Mr Jr Mrs", "Mrs Jr Mr", "Mrs PhD Mr", "Mrs, PhD", "Mrs V", "Mr VI", "Mrs MA", "Mrs MA Mr", "Mrs Jr MA Mr", "Mrs Sr.", "Mrs X.Y.Z.", "Mrs III", "Mrs, Jr., PhD") def test_a_bare_salutation_reads_as_its_period_marked_twin() -> None: """H8's first Then bullet (#835), the bare spelling reading as the period-marked one reads. After a family comma a listed salutation written bare chains into H5's trailing title run as the period-marked spelling does, so the two spellings read alike, fields (stops aside) and ambiguity kinds, in every comma shape here and under every order. The grid crosses a bound given name and a trailing particle, whose group-side predictors read the part through `tail_reading`, with title, suffix, title tails, which reach that function's second chain pass. Mutation-checked 2026-10-24: dropping the `salutations` hand-off on `tail_reading`'s second chain call fails this on 192 of its 2,112 pairs, and the suite had no other test of that call.""" def dotted(text: str) -> str: return re.sub(r"\b(Mrs|Mr)\b", r"\1.", text) def fields(name: ParsedName) -> tuple[object, ...]: stops_aside = sorted((k, v.replace(".", "")) for k, v in name.as_dict().items()) return (tuple(stops_aside), tuple(sorted(a.kind for a in name.ambiguities))) failures = [] for order in (GIVEN_FIRST, FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST): parser = Parser(policy=Policy(name_order=order)) for head, given_part, tail in itertools.product( _SALUTATION_HEADS, _SALUTATION_GIVENS, _SALUTATION_TAILS): text = f"{head}{given_part} {tail}" if fields(parser.parse(text)) != fields(parser.parse(dotted(text))): failures.append(f"{order!r}: {text!r}") assert not failures, (f"{len(failures)} bare salutation(s) read unlike " f"the period-marked twin:\n" + "\n".join(failures[:10])) #: rules/H2's precedence grid (#789): each slot an abbreviation's shape #: reads (the head of the given part, behind a title, after a family #: comma with and without a word before it, before a nickname, a #: particle, a title-particle, a connective, a maiden marker or a #: trailing credential), and before a comma ahead of a word of the #: ambiguous credential class, where the comma's own count asks the #: shape, a modifier's title phrase among it (`Smith, Ed`), each paired #: with the same text holding a plain given name in the abbreviation's #: place; and the bare spellings, which must read as with the set #: emptied, in two slots of their own (`{} SMITH, XYZ`, `John Smith, {}`, #: where a wordlist roster once made a bare one a name, #789's review). #: The 13 shipped abbreviations in six spellings, 21 slots, three #: orders: 4,914 parses with their control, about 1.5s on py3.11 #: (measured 2026-10-26). Mutation-checked the same day, on the 19 #: slots before the review added two: with the peel's half of the #: precedence off (`_pieces.is_leading_title`) 2,103 of the dotted pairs #: fail, with the comma count's (`_vocab.is_title_shaped`) 234, and with #: `_vocab.title_readings`' two calls alone 117; with the field back in #: `_vocab._LISTED_FIELDS`, 66 of the 21-slot grid. _ABBREVIATION_SLOTS = ( "{} Smith", "{} H. Smith", "Smith, {}", "Smith, {} John", ", {} Smith", "Mr. {} Smith", "Mrs {} Liza Cruz", "Dr. {} Smith, PhD", "(Bob) {} Smith", "{} Smith Jr.", "{} de la Vega", "{} Freiherr von Berg", "Rev. Sr. {} Smith", "{} y Gasset", "{} nee Smith", "Smith Jr., {} John", "{} Smith, Ed", "{} Vice Smith, Ed", "Vice {} Smith, Ed", "{} SMITH, XYZ", "John Smith, {}") def test_a_given_name_written_short_reads_as_a_given_name() -> None: """rules/H2's precedence bullet (#789): "an abbreviation the vocabulary reads as a given name written short is that given name". Two-sided, against the same lexicon with the set emptied: where the shape reads the abbreviation as a title there, or the comma's count reads it as one, it reads, field for field, as the plain given name `Jack` reads in its place; and where the shape reads no title (`Smith, MA.`, the credential its capitals settle), the reading is the one the emptied set gives. Ambiguity kinds are not compared: `ma` keeps its credential reading, and the report that reading draws after a comma. The bare spellings, which the shape never reads, must read exactly as with the set emptied in every slot, the capitals behind a comma included ('John Smith, JAS' keeps suffix 'JAS', #789's review). Every slot must see the shape fire somewhere, so a pass is not a grid the shape never reaches.""" lexicon = Lexicon.default() off = lexicon.remove( given_name_abbreviations=set(lexicon.given_name_abbreviations)) def fields(name: ParsedName, plain: str = "", head: str = "") -> dict[str, str]: return {k: v.replace(plain, head) if plain else v for k, v in name.as_dict().items() if v} failures, fired = [], set() for order in (GIVEN_FIRST, FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST): policy = Policy(name_order=order) parser, control = Parser(policy=policy), Parser(lexicon=off, policy=policy) # the stand-in in capitals behind a mixed-case name would be an # unlisted capitals credential itself ('John Smith, JACK'), which # no given name written short is: read it with that shape off. # OFF reaches every word of the text, so a slot compared through # the stand-in must hold no other capitals word ('{} SMITH, XYZ' # is compared only with the set emptied) stand_in = Parser(policy=dataclasses.replace( policy, unlisted_caps_suffixes=CapsSuffixes.OFF)) for word in sorted(lexicon.given_name_abbreviations): # the bare spellings too, which the set must never move for head, plain in ((word.title() + ".", "Jack"), (word.upper() + ".", "JACK"), (word + ".", "jack"), (word.title(), ""), (word.upper(), ""), (word, "")): for slot in _ABBREVIATION_SLOTS: text = slot.format(head) got = fields(parser.parse(text)) unlisted = control.parse(text) if plain and (head in unlisted.title.split() or slot.endswith(", Ed")): fired.add(slot) want = fields(stand_in.parse(slot.format(plain)), plain, head) else: want = fields(unlisted) if got != want: failures.append(f"{order!r}: {text!r} {got} != {want}") assert not failures, (f"{len(failures)} abbreviation(s) misread:\n" + "\n".join(failures[:10])) # '{} SMITH, XYZ' is there for the bare spellings: before a family # comma the shape never fires (rules/H6) assert fired == set(_ABBREVIATION_SLOTS) - {"{} SMITH, XYZ"}, ( set(_ABBREVIATION_SLOTS) - fired) ��������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_regex_sync.py���������������������������������������0000664�0000000�0000000�00000031262�15273032717�0024163�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Pins the pipeline's hand-duplicated regex/table copies to their nameparser.config.regexes source of truth. The 2.0 layering rule forbids nameparser._pipeline/_render importing nameparser.config directly, so several patterns and tables are copied by hand into the modules that need them, each with a "keep in sync by hand" comment. Nothing previously enforced that promise: if config/regexes.py changed, the copies would silently diverge with no CI signal. Tests may legally import both sides (test_layering.py's own convention), so this module is where the promise gets checked. Layering is the usual reason for a copy but not the only one, so the scope here is the PROMISE rather than that one pair of packages: the comma-set pin below reads _pipeline._state instead of config. """ import pathlib import re import pytest from nameparser.config import regexes as _config from nameparser._pipeline import ( _assemble, _assign, _classify, _comma, _extract, _group, _pieces, _post_rules, _script_segment, _segment, _state, _tokenize, _vocab, ) import nameparser._pipeline from nameparser import _render def test_emoji_ranges_match_config() -> None: assert _tokenize._EMOJI_RANGES == _config._EMOJI_RANGES def test_bidi_pattern_matches_config() -> None: assert _tokenize._BIDI.pattern == _config.re_bidi.pattern assert _tokenize._BIDI.flags == _config.re_bidi.flags def test_period_not_at_end_matches_config() -> None: source = _config.REGEXES["period_not_at_end"] assert _vocab._PERIOD_NOT_AT_END.pattern == source.pattern assert _vocab._PERIOD_NOT_AT_END.flags == source.flags def test_combining_mark_class_matches_config() -> None: from nameparser.config import regexes as _regexes assert _vocab._MARKS == _regexes._MARKS def test_period_abbreviation_matches_config() -> None: source = _config.REGEXES["period_abbreviation"] assert _pieces._PERIOD_ABBREV.pattern == source.pattern assert _pieces._PERIOD_ABBREV.flags == source.flags def test_roman_numeral_matches_config() -> None: source = _config.REGEXES["roman_numeral"] assert _vocab._ROMAN.pattern == source.pattern assert _vocab._ROMAN.flags == source.flags 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"), ) for copy, key in pairs: source = _config.REGEXES[key] assert copy.pattern == source.pattern, key assert copy.flags == source.flags, key def test_initial_copies_agree_with_each_other_and_config() -> None: # _vocab._INITIAL and _render._INITIAL are both v1's "initial" # pattern minus its trailing "?" (documented at _render.py's # _INITIAL definition: the two call sites always fullmatch a # non-empty token, so the empty-string alternative is dropped on # purpose). Assert both the internal-copy agreement and the exact, # documented relationship to the config source, so a future edit to # either side that breaks the relationship fails loudly here # instead of silently drifting. # The two copies stopped answering the same question in #458 and # the agreement matters MORE for it, not less: _vocab's asks what # the parse decided about a token, _render's what it would have # decided about text spliced into a field afterwards, and the # second is only right while it answers as the first would. This # test is what says so. (#458 briefly deleted _render's copy, when # nothing read it; the unparsed-text fallback gave it a reader # again -- see _render._reads_as_conjunction.) assert _vocab._INITIAL.pattern == _render._INITIAL.pattern source = _config.REGEXES["initial"] # config's pattern is the pipeline copy with an extra "?" spliced in # just before the trailing "$", making the whole group optional. trimmed = _vocab._INITIAL.pattern reconstructed = trimmed[:-1] + "?" + trimmed[-1:] assert source.pattern == reconstructed assert source.flags == _vocab._INITIAL.flags def test_dotted_initial_is_the_period_alternative_of_initial() -> None: # _render._DOTTED_INITIAL (#478's hyphen clause) is _INITIAL's # period alternative alone -- the bare-capital half is deliberately # not used there (#458). _INITIAL is "^(\\w\\.|[A-Z])$"; splitting # its alternation and re-wrapping the first half must reproduce # _DOTTED_INITIAL exactly, so the two cannot drift apart unnoticed. inner = _render._INITIAL.pattern[2:-2] # strip "^(" and ")$" period_alt = inner.split("|")[0] assert _render._DOTTED_INITIAL.pattern == f"^{period_alt}$" assert _render._DOTTED_INITIAL.flags == _render._INITIAL.flags # The roster above grew one test at a time, and four hand-copies were # never added to it -- _render's _SPACES, _SPACE_BEFORE_COMMA, _MAC and # _WORD. They had not diverged, but nothing would have said so, which # is the exact promise this module exists to keep. All four mirror a # config key whose only other reader was v1's parser.py, deleted at the # M11 swap, so nothing else touches them either. # # Declared as a roster rather than one test per copy, with a # completeness check below: adding a pattern without declaring its # source now fails here instead of being silently unpinned. _SOURCES: dict[tuple[str, str], str | None] = { ("_pieces", "_PERIOD_ABBREV"): "period_abbreviation", # SAME object as the entry above, not a second copy: _pieces # imports it from _vocab (#289/#516, quality-review finding -- # is_title_shaped moved there so name_word_count could share # is_leading_title's H2 shape test), so it is visible under both # module names and the completeness scan below sees it twice. ("_vocab", "_PERIOD_ABBREV"): "period_abbreviation", # `split_pair`'s two halves, the Ph./D. split credential (#752; # declared under _group until then, which had stopped holding them); # no config key to mirror ("_vocab", "_PH"): None, ("_vocab", "_D"): None, ("_vocab", "_DOTTED"): None, # #544: the chunked sibling of _DOTTED ('M.Eng.'), S3's period gate # for a member with a lower-case tail; no config key to mirror ("_vocab", "_CHUNKED"): None, # the joiners of a joined credential ('MD/PhD', rules/S3, #752); no # config key to mirror ("_vocab", "_JOINERS"): None, # the caps shape's joined spelling ('PU-PH', rules/S9, #752), built # from _LETTER, whose _MARKS the test above holds to config's; no # config key to mirror ("_vocab", "_JOINED_LETTERS"): None, # #563: paired initials ('M.J.'), C5's exception to C4's name-word # count after a comma; no config key to mirror ("_vocab", "_PAIRED_INITIALS"): None, ("_vocab", "_ROMAN"): "roman_numeral", # SAME object, imported for the trailing read's weighed-word test # (#620), as _PERIOD_ABBREV is above ("_pieces", "_ROMAN"): "roman_numeral", # 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", # rules/O6's hyphens (#754): no config counterpart ("_post_rules", "_HYPHENS"): None, ("_render", "_SPACES"): "spaces", ("_render", "_SPACE_BEFORE_COMMA"): "space_before_comma", ("_render", "_MAC"): "mac", ("_render", "_WORD"): "word", # the numeral clause's copy of _vocab._ROMAN (#459); layering # forbids _render the import, so it is pinned here instead ("_render", "_ROMAN"): "roman_numeral", ("_vocab", "_PERIOD_NOT_AT_END"): "period_not_at_end", # Deliberately NOT a straight copy -- pinned by the dedicated tests # above, which assert the documented RELATIONSHIP instead: ("_render", "_INITIAL"): None, # config's pattern minus one "?" ("_vocab", "_INITIAL"): None, # same # SAME object, imported for the one-case letter initial (#723) ("_classify", "_INITIAL"): None, ("_render", "_DOTTED_INITIAL"): None, # _INITIAL's period alternative # #542: the name run after a Mac/Mc prefix, read on the word as # written once _MAC has matched its composed spelling; no config # key to mirror ("_render", "_NAME_RUN"): None, ("_tokenize", "_BIDI"): None, # re_bidi, not a REGEXES key # Mirrors _pipeline._state.COMMA_CHARS, not nameparser.config ("_render", "_COMMA_CHAR"): None, } def test_every_pipeline_module_is_scanned_for_hand_copies() -> None: """The twin of test_layering's completeness check, in the file with the same shape of registry. test_every_hand_copied_pattern_is_declared iterates _MODULES, so a module absent from that dict is not scanned loosely -- it is not scanned at all, and an undeclared hand copy of a config regex in it goes unpinned. Measured on a scratch copy: a new pipeline module carrying a wrong copy of the mac pattern passed all 20 tests here. Scoped to _pipeline/ for the same reason the layering twin is: it is where a new module is a design decision. The directory is derived from the imported subpackage and the floor is asserted -- Path.glob on a missing directory returns empty rather than raising, which would make this pass for every possible _MODULES. """ pipeline_dir = pathlib.Path(nameparser._pipeline.__file__).parent shipped = {p.stem for p in pipeline_dir.glob("*.py") if p.stem != "__init__"} assert "_group" in shipped, ( f"the glob matched no pipeline modules, so this check can no " f"longer fail: {sorted(shipped)}") missing = sorted(shipped - set(_MODULES)) assert not missing, ( f"pipeline modules absent from _MODULES, and therefore never " f"scanned for undeclared hand copies of a config regex: " f"{missing}. Add each one; a module with no hand copies costs " f"nothing to scan" ) _MODULES = {"_assemble": _assemble, "_assign": _assign, "_classify": _classify, "_comma": _comma, "_extract": _extract, "_group": _group, "_pieces": _pieces, "_post_rules": _post_rules, "_render": _render, "_script_segment": _script_segment, "_segment": _segment, "_state": _state, "_tokenize": _tokenize, "_vocab": _vocab} @pytest.mark.parametrize( "where,key", [(w, k) for w, k in _SOURCES.items() if k is not None], ids=lambda v: v if isinstance(v, str) else f"{v[0]}.{v[1]}") def test_declared_copy_matches_its_config_source( where: tuple[str, str], key: str) -> None: copy = getattr(_MODULES[where[0]], where[1]) source = _config.REGEXES[key] assert copy.pattern == source.pattern, f"{where[1]} vs REGEXES[{key!r}]" assert copy.flags == source.flags, f"{where[1]} vs REGEXES[{key!r}]" def test_every_hand_copied_pattern_is_declared() -> None: """The roster must cover every compiled pattern in these modules. Without this, the roster is just another list that a new constant can be left out of -- which is how the four above went unpinned. """ undeclared = [ (name, attr) for name, mod in _MODULES.items() for attr, value in vars(mod).items() if attr.startswith("_") and not attr.startswith("__") and isinstance(value, re.Pattern) and (name, attr) not in _SOURCES ] assert not undeclared, ( f"compiled patterns missing from _SOURCES: {undeclared}. Add each " f"with its nameparser.config.regexes key, or None if it has no " f"config counterpart.") def test_comma_char_matches_the_pipeline_comma_set() -> None: # _render splits on the same comma characters segment does; the set # lives in _state, so this one is pinned against that, not config. from nameparser._pipeline._state import COMMA_CHARS assert set(_render._COMMA_CHAR.pattern.strip("[]")) == set(COMMA_CHARS) def test_every_declared_pattern_still_exists() -> None: """The converse of the completeness check: an entry naming a pattern its module no longer holds pins nothing. `_group._PH` and `_group._D` stood here after both moved to `_vocab`, found when #752 renamed them there; an entry with no config key was never read by the parametrized test above, so nothing failed.""" stale = sorted(where for where in _SOURCES if not isinstance(getattr(_MODULES[where[0]], where[1], None), re.Pattern)) assert not stale, f"_SOURCES entries naming no compiled pattern: {stale}" ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_removals_2_8_1.py�����������������������������������0000664�0000000�0000000�00000044740�15273032717�0024542�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""2.8.1's warnings for the 3.0 breaks 2.8.0 did not announce. Three families, each a ``DeprecationWarning`` attributed to the caller's line and naming the 3.0 replacement and the migration guide: * the ``Lexicon`` set fields 3.0 makes internal (#754), written through ``Lexicon(...)``, ``add()``, ``remove()`` or ``dataclasses.replace``; * the constants of the eight ``nameparser.config`` vocabulary modules 3.0 deletes (#1007); * ``nameparser.config.regexes`` (#1008); * and, since 2.8.2, ``nameparser.config.surnames``, which 3.0 makes private (its words are ``Lexicon.default().surnames``). The tables are written out literally rather than read from the modules, for the reason tests/v2/test_config_aliases.py gives: imported, every assertion would prove the bridge self-consistent rather than complete. Reading an attribute (``lexicon.titles``) is deliberately not warned: the pipeline reads those fields per token, so a property in their place would cost a frame per read on the hot path (see the release log). """ from __future__ import annotations import copy import dataclasses import importlib import inspect import pickle import sys import warnings from collections.abc import Callable, Iterator from contextlib import contextmanager import pytest import nameparser from nameparser import Lexicon, Parser, locales, parse, parser_for GUIDE = "https://nameparser.readthedocs.io/en/latest/migrate.html" @contextmanager def _caught() -> Iterator[list[warnings.WarningMessage]]: with warnings.catch_warnings(record=True) as record: warnings.simplefilter("always") yield record def _deprecations(record: list[warnings.WarningMessage] ) -> list[warnings.WarningMessage]: return [w for w in record if issubclass(w.category, DeprecationWarning)] def _line_after() -> int: """The line after the caller's: where the call under test stands.""" frame = inspect.currentframe() assert frame is not None and frame.f_back is not None return frame.f_back.f_lineno + 1 # -- the vocabulary and regex modules (#1007, #1008) -------------------------- #: (module, constant, a phrase its replacement clause must hold). Every #: public name of the ten modules, the 1.x aliases (which #: test_config_aliases.py asserts) aside; surnames since 2.8.2. REMOVED_CONSTANTS = [ ("titles", "GIVEN_NAME_TITLES", 'words(kind="title", addresses="given")'), ("titles", "TITLES", 'words(kind="title")'), ("titles", "AFTER_GIVEN_TITLES", 'words(kind="salutation", after_given=True)'), ("titles", "TITLES_AMBIGUOUS", 'words(kind="title")'), ("titles", "TITLE_MODIFIERS", 'words(kind="modifier")'), ("titles", "TITLE_STYLES", 'words(kind="style")'), ("titles", "TITLE_PHRASES", "attaches=[...]"), ("suffixes", "SUFFIX_WORDS", 'words(kind="suffix")'), ("suffixes", "GLUED_HONORIFICS", 'words(kind="suffix", attached="after")'), ("suffixes", "SUFFIX_WORDS_AMBIGUOUS", 'words(kind="suffix")'), ("suffixes", "AGNOMENS", 'words(kind="agnomen")'), ("suffixes", "SUFFIX_ACRONYMS_AMBIGUOUS", 'words(kind="credential")'), ("suffixes", "SUFFIX_ACRONYMS", 'words(kind="credential")'), ("particles", "NON_GIVEN_NAME_PARTICLES", 'words(kind="particle")'), ("particles", "PARTICLES", 'words(kind="particle")'), ("conjunctions", "CONJUNCTIONS", 'words(kind="conjunction")'), ("conjunctions", "CONJUNCTIONS_AMBIGUOUS", 'words(kind="conjunction", may_be_initial=True)'), ("bound_given_names", "BOUND_GIVEN_NAMES", 'words(kind="bound_given")'), ("given_names", "GIVEN_NAME_ABBREVIATIONS", 'words(kind="given")'), ("maiden_markers", "MAIDEN_MARKERS", 'words(kind="birth_marker")'), ("capitalization", "CAPITALIZATION_EXCEPTIONS", "Lexicon.default().capitalization_exceptions"), ("regexes", "re_emoji", "copy a pattern you reuse"), ("regexes", "re_bidi", "copy a pattern you reuse"), ("regexes", "EMPTY_REGEX", "copy a pattern you reuse"), ("regexes", "REGEXES", "copy a pattern you reuse"), ("surnames", "KOREAN_SURNAMES", "Lexicon.default().surnames"), ] REMOVED_MODULES = sorted({m for m, _, _ in REMOVED_CONSTANTS}) def _id(row: tuple[str, str, str]) -> str: return f"{row[0]}.{row[1]}" def _issue(module: str) -> str: return "#1008" if module == "regexes" else "#1007" def _assert_removal(w: warnings.WarningMessage, module: str, name: str, phrase: str, line: int) -> None: message = str(w.message) assert w.category is DeprecationWarning assert message.startswith( f"nameparser.config.{module}.{name} is deprecated and will be " f"removed in 3.0 ({_issue(module)})"), message assert phrase in message, message assert message.endswith(f"See {GUIDE}"), message assert (w.filename, w.lineno) == (__file__, line), (w.filename, w.lineno) @pytest.mark.parametrize("row", REMOVED_CONSTANTS, ids=_id) def test_reading_a_removed_constant_warns_at_the_reading_line( row: tuple[str, str, str]) -> None: module_name, name, phrase = row module = importlib.import_module(f"nameparser.config.{module_name}") with _caught() as record: line = _line_after() value = getattr(module, name) [w] = _deprecations(record) _assert_removal(w, module_name, name, phrase, line) # served unchanged: the library's own copy is the same object if module_name == "surnames": from nameparser.config import _ko_surnames assert value is _ko_surnames.KOREAN_SURNAMES elif module_name != "regexes": from nameparser.config import _compiled assert value is getattr(_compiled, name) @pytest.mark.parametrize("row", REMOVED_CONSTANTS, ids=_id) def test_importing_a_removed_constant_warns_at_the_import_line( row: tuple[str, str, str]) -> None: module_name, name, phrase = row namespace: dict[str, object] = {"__name__": __name__} source = f"from nameparser.config.{module_name} import {name}" with _caught() as record: exec(compile(source, __file__, "exec"), namespace) # noqa: S102 [w] = _deprecations(record) _assert_removal(w, module_name, name, phrase, 1) @pytest.mark.parametrize("module_name", REMOVED_MODULES) def test_the_table_covers_every_public_name(module_name: str) -> None: # __all__ lists every public name (test_config_aliases holds it to # star imports); less the 1.x aliases, each one must warn here module = importlib.import_module(f"nameparser.config.{module_name}") aliases = set(getattr(module.__getattr__, "deprecated_aliases", {})) listed = {n for m, n, _ in REMOVED_CONSTANTS if m == module_name} assert set(module.__all__) - aliases == listed def test_the_modules_import_quietly() -> None: with warnings.catch_warnings(): warnings.simplefilter("error") for module_name in REMOVED_MODULES: importlib.import_module(f"nameparser.config.{module_name}") def test_the_surnames_replacement_runs_quietly_and_holds_the_same_words( ) -> None: # 2.8.2: the message offers Lexicon.default().surnames, the field # 3.0 keeps public, as the code that replaces the read; run the # code it names and hold it to the constant it replaces from nameparser.config import surnames with _caught() as record: held = surnames.KOREAN_SURNAMES [w] = _deprecations(record) code = str(w.message).split("; ", 1)[1].split(" holds", 1)[0] assert code == "Lexicon.default().surnames" with warnings.catch_warnings(): warnings.simplefilter("error") offered = eval(code, {"Lexicon": Lexicon}) # noqa: S307 assert offered == held def test_an_alias_to_a_removed_constant_warns_once_naming_both() -> None: from nameparser.config import prefixes with _caught() as record: prefixes.PREFIXES # noqa: B018 [w] = _deprecations(record) message = str(w.message) assert "nameparser.config.prefixes.PREFIXES is deprecated since 2.2" in message assert "nameparser.config.particles.PARTICLES is deprecated" in message assert w.filename == __file__ # -- Lexicon's set fields (#754) ---------------------------------------------- #: Every compiled-set field 3.0 makes internal; surnames, which 3.0 keeps #: as a field, is asserted on its own below. The message names the field #: and the guide's row for it, never a computed 3.0 call: the right #: reading for a word depends on its other readings (decided 2026-11-02). SET_FIELDS = [ "titles", "given_name_titles", "suffix_acronyms", "suffix_words", "suffix_acronyms_ambiguous", "particles", "particles_ambiguous", "conjunctions", "conjunctions_ambiguous", "bound_given_names", "given_name_abbreviations", "maiden_markers", "honorific_tails", "after_given_titles", "suffix_words_ambiguous", "agnomens", "titles_ambiguous", "title_modifiers", "title_styles", "title_phrases", ] FIELDS_GUIDE = GUIDE + "#lexicon-fields-readings" #: the base a marker field needs beside it, or construction refuses it #: as an orphan (_lexicon._SUBSET_FIELDS) _BASES = { "particles_ambiguous": "particles", "suffix_acronyms_ambiguous": "suffix_acronyms", "honorific_tails": "suffix_words", "suffix_words_ambiguous": "suffix_words", } def _entries(field: str) -> dict[str, frozenset[str]]: word = frozenset({"zork zap" if field == "title_phrases" else "zork"}) entries = {field: word} if field in _BASES: entries[_BASES[field]] = word return entries def _assert_set_field(w: warnings.WarningMessage, spelling: str, fields: list[str], line: int) -> None: message = str(w.message) assert w.category is DeprecationWarning assert message.startswith("Lexicon's set fields are deprecated (#754): "), ( message) assert f"{spelling} writes {', '.join(fields)}, which 3.0 makes internal" \ in message, message assert "TypeError" in message and '"Lexicon fields → readings"' in message # no computed 3.0 call: those were wrong for many words assert "kind=" not in message, message assert message.endswith(f"See {FIELDS_GUIDE}"), message assert (w.filename, w.lineno) == (__file__, line), (w.filename, w.lineno) @pytest.mark.parametrize("field", SET_FIELDS) def test_constructing_with_a_set_field_warns(field: str) -> None: entries = _entries(field) with _caught() as record: line = _line_after() Lexicon(**entries) # type: ignore[arg-type] [w] = _deprecations(record) # named in declaration order, which _VOCAB_FIELDS keeps _assert_set_field(w, "Lexicon()", [f for f in SET_FIELDS if f in entries], line) @pytest.mark.parametrize("field", SET_FIELDS) def test_add_with_a_set_field_warns(field: str) -> None: lexicon = Lexicon.empty() entries = _entries(field) with _caught() as record: line = _line_after() lexicon.add(**entries) [w] = _deprecations(record) _assert_set_field(w, "add()", list(entries), line) @pytest.mark.parametrize("field", SET_FIELDS) def test_remove_with_a_set_field_warns(field: str) -> None: with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) lexicon = Lexicon(**_entries(field)) # type: ignore[arg-type] entries = _entries(field) with _caught() as record: line = _line_after() lexicon.remove(**entries) [w] = _deprecations(record) _assert_set_field(w, "remove()", list(entries), line) @pytest.mark.parametrize("field", SET_FIELDS) def test_replace_with_a_set_field_warns(field: str) -> None: lexicon = Lexicon.empty() entries = _entries(field) with _caught() as record: line = _line_after() dataclasses.replace(lexicon, **entries) # type: ignore[arg-type] [w] = _deprecations(record) _assert_set_field(w, "dataclasses.replace()", [f for f in SET_FIELDS if f in entries], line) @pytest.mark.skipif(sys.version_info < (3, 13), reason="copy.replace is 3.13+") def test_copy_replace_with_a_set_field_warns() -> None: # 3.13's dataclasses._replace names the instance `self`, where # dataclasses.replace names it `obj` lexicon = Lexicon.empty() with _caught() as record: line = _line_after() copy.replace(lexicon, titles=frozenset({"zork"})) # type: ignore[attr-defined] [w] = _deprecations(record) _assert_set_field(w, "copy.replace()", ["titles"], line) def test_the_table_covers_every_set_field() -> None: from nameparser._lexicon import _VOCAB_FIELDS assert SET_FIELDS == [f for f in _VOCAB_FIELDS if f != "surnames"] def test_an_empty_set_field_still_names_the_field() -> None: # 3.0 refuses the keyword whatever it holds with _caught() as record: line = _line_after() Lexicon.empty().add(titles=frozenset()) [w] = _deprecations(record) _assert_set_field(w, "add()", ["titles"], line) def test_a_mixed_constructor_names_only_the_fields_3_0_refuses() -> None: with _caught() as record: line = _line_after() Lexicon(titles=frozenset({"zork"}), surnames=frozenset({"kim"}), capitalization_exceptions=(("phd", "PhD"),)) [w] = _deprecations(record) _assert_set_field(w, "Lexicon()", ["titles"], line) assert "surnames" not in str(w.message) @pytest.mark.parametrize("op", ["add", "remove"]) def test_surnames_through_add_or_remove_offers_code_that_runs_quietly( op: str) -> None: # the one 3.0 spelling that runs on 2.8.1 too, so the message hands # over code: run it, quietly, and get what the 2.x call made lexicon = Lexicon.empty().add(surnames=frozenset({"lee"})) if op == "remove": lexicon = lexicon | Lexicon(surnames=frozenset({"kim"})) with _caught() as record: line = _line_after() made = getattr(lexicon, op)(surnames=iter(["Kim"])) [w] = _deprecations(record) message = str(w.message) assert message.startswith("Lexicon's set fields are deprecated (#754): ") assert f"3.0 keeps surnames a field, but {op}() no longer takes it: " \ "write " in message, message assert "which 3.0 makes internal" not in message assert message.endswith(f"See {FIELDS_GUIDE}") assert (w.filename, w.lineno) == (__file__, line) code = message.split(": write ", 1)[1].split(", which runs on", 1)[0] assert code == { "add": "lexicon | Lexicon(surnames=frozenset({'kim'}))", "remove": "dataclasses.replace(lexicon, " "surnames=lexicon.surnames - {'kim'})", }[op] with warnings.catch_warnings(): warnings.simplefilter("error") offered = eval(code, {"lexicon": lexicon, "Lexicon": Lexicon, # noqa: S307 "dataclasses": dataclasses}) assert offered == made def test_a_long_surname_list_is_described_not_spelled() -> None: with _caught() as record: Lexicon.empty().add(surnames=frozenset("abcdefg")) [w] = _deprecations(record) assert "join a Lexicon holding those 7 surnames to it with |" in str( w.message) def test_a_bad_value_raises_its_own_error_before_any_warning() -> None: with warnings.catch_warnings(): warnings.simplefilter("error") with pytest.raises(TypeError, match="decode first"): Lexicon.empty().add(titles=b"dr") # type: ignore[arg-type] _QUIET = [ pytest.param(lambda: Lexicon.empty(), id="empty"), pytest.param(lambda: Lexicon(surnames=frozenset({"kim"})), id="surnames"), pytest.param(lambda: Lexicon(capitalization_exceptions=(("phd", "PhD"),)), id="capitalization_exceptions"), pytest.param(lambda: dataclasses.replace( Lexicon.default(), capitalization_exceptions=()), id="replace-cap"), pytest.param(lambda: dataclasses.replace( Lexicon.default(), surnames=frozenset({"kim"})), id="replace-surnames"), pytest.param(lambda: Lexicon.default() | Lexicon(surnames=frozenset({"kim"})), id="union"), pytest.param(lambda: pickle.loads(pickle.dumps(Lexicon.default())), id="pickle"), pytest.param(lambda: copy.deepcopy(Lexicon.default()), id="deepcopy"), ] if sys.version_info >= (3, 13): _QUIET.append(pytest.param( lambda: copy.replace( # type: ignore[attr-defined] Lexicon.default(), surnames=frozenset({"kim"})), id="copy.replace-surnames")) @pytest.mark.parametrize("make", _QUIET) def test_the_fields_3_0_keeps_stay_quiet(make: Callable[[], object]) -> None: with warnings.catch_warnings(): warnings.simplefilter("error") make() # -- the library itself ------------------------------------------------------- def _packs() -> list[str]: # available() names the packs as their modules are named, lowercase return [name.upper() for name in locales.available()] @pytest.mark.parametrize("call", [ pytest.param(lambda: parse("Dr. Juan Q. de la Vega III, PhD"), id="parse"), pytest.param(Lexicon.default, id="Lexicon.default"), pytest.param(Parser, id="Parser"), pytest.param(lambda: Parser().revise(parse("John Smith"), given="Jane"), id="revise"), *(pytest.param((lambda p: lambda: parser_for(getattr(locales, p)))(p), id=f"parser_for-{p}") for p in _packs()), ]) def test_the_library_emits_none_of_these(call: Callable[[], object]) -> None: # every DeprecationWarning an error, the 2.8.0 facade messages aside; # parser_for(JA) warns a UserWarning without a segmenter, which is # not this test's with warnings.catch_warnings(): warnings.simplefilter("error", DeprecationWarning) warnings.simplefilter("ignore", UserWarning) call() def test_every_pack_module_imports_quietly() -> None: with warnings.catch_warnings(): warnings.simplefilter("error") for pack in _packs(): getattr(locales, pack) importlib.import_module( f"nameparser.locales.{pack.lower()}") def test_the_facade_reads_none_of_these() -> None: # HumanName and Constants read the vocabulary and REGEXES; they warn # their own 2.8.0 removal, and nothing of 2.8.1's from nameparser import HumanName from nameparser.config import CONSTANTS, Constants with _caught() as record: HumanName("Dr. Juan de la Vega III").last # noqa: B018 Constants().titles # noqa: B018 CONSTANTS.regexes.word # noqa: B018 messages = [str(w.message) for w in _deprecations(record)] assert not [m for m in messages if "2.8.1" in m or "(#754)" in m or "(#1007)" in m or "(#1008)" in m], messages def test_the_package_still_exports_the_lexicon_quietly() -> None: with warnings.catch_warnings(): warnings.simplefilter("error") assert nameparser.Lexicon is Lexicon ��������������������������������derek73-python-nameparser-456b024/tests/v2/test_render.py�������������������������������������������0000664�0000000�0000000�00000274177�15273032717�0023312�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������import dataclasses import unicodedata import warnings import pytest from nameparser._policy import CapsSuffixes from nameparser import (FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST, HumanName, Parser, Policy, parse) from nameparser._lexicon import Lexicon from nameparser.config import Constants from nameparser._render import _apply_mask, _collapse, render from nameparser._types import (FOLDED_TAG, UNCLASSIFIED_TAG, UNJOINED_TAG, Ambiguity, AmbiguityKind, ParsedName, Role, Span, Token) from tests.removals import ignore_removals def test_collapse_is_the_254_algorithm() -> None: # normative: leading/trailing whitespace, doubled spaces, # space-before-comma, one trailing comma char (incl. Arabic/CJK), # leading/trailing ', ' debris, and empty-wrapper artifacts from # empty fields are removed assert _collapse(" John Smith ") == "John Smith" assert _collapse("Smith , John") == "Smith, John" assert _collapse("John Smith ,") == "John Smith" assert _collapse("John Smith،") == "John Smith" # Arabic comma assert _collapse("John Smith,") == "John Smith" # fullwidth comma assert _collapse(", John Smith, ") == "John Smith" assert _collapse("John Smith ()") == "John Smith" assert _collapse("John Smith ''") == "John Smith" assert _collapse('John Smith ""') == "John Smith" assert _collapse("") == "" def _pn(original: str, tokens: list[Token]) -> ParsedName: return ParsedName(original=original, tokens=tuple(tokens)) def _delavega() -> ParsedName: # "Dr. Juan de la Vega III" -- spans verified by hand # 0123456789012345678901234 return _pn("Dr. Juan de la Vega III", [ Token("Dr.", Span(0, 3), Role.TITLE), Token("Juan", Span(4, 8), Role.GIVEN), Token("de", Span(9, 11), Role.FAMILY, frozenset({"particle"})), Token("la", Span(12, 14), Role.FAMILY, frozenset({"particle"})), Token("Vega", Span(15, 19), Role.FAMILY), Token("III", Span(20, 23), Role.SUFFIX), ]) def test_render_fills_fields_and_collapses() -> None: pn = _delavega() assert render(pn, "{title} {given} {middle} {family} {suffix}") \ == "Dr. Juan de la Vega III" # empty middle collapses; comma survives correctly assert render(pn, "{family}, {given} {middle}") == "de la Vega, Juan" def test_render_accepts_derived_view_keys() -> None: assert render(_delavega(), "{family_base}, {given} {family_particles}") \ == "Vega, Juan de la" assert render(_delavega(), "{surnames}") == "de la Vega" assert render(_delavega(), "{given_names}") == "Juan" def test_render_every_role_key_is_valid() -> None: pn = _delavega() for role in Role: render(pn, f"{{{role.value}}}") # must not raise def test_render_unknown_key_raises_enriched_keyerror() -> None: with pytest.raises(KeyError, match="valid fields"): render(_delavega(), "{first}") # v1 spelling: redirected loudly def test_render_empty_parse_is_empty_string() -> None: assert render(_pn("", []), "{title} {given} {middle} {family} {suffix}") == "" def test_default_str_includes_nickname_and_maiden() -> None: # Derek's terminal find (2026-07-19): str(parse('Von Johnson # (smith)')) dropped the nickname; the default spec now shows all # seven fields -- nickname quoted after the given name, maiden # parenthesized after the family name (Derek's chosen format). # The nickname decoration round-trips exactly; the maiden parens # re-extract as a NICKNAME on reparse (documented trade-off: # presentation over lossless round-trip). from nameparser import parse assert str(parse("Von Johnson (smith)")) == 'Von "smith" Johnson' assert str(parse("Jane Smith née Jones")) == "Jane Smith (Jones)" both = parse("Jane (Janie) Smith née Jones") assert str(both) == 'Jane "Janie" Smith (Jones)' # no orphaned decoration when the fields are empty assert str(parse("Dr. Juan Q. Xavier de la Vega III")) == ( "Dr. Juan Q. Xavier de la Vega III") def test_parsedname_render_and_str_delegate() -> None: pn = _delavega() assert pn.render() == "Dr. Juan de la Vega III" assert pn.render("{family}, {given}") == "de la Vega, Juan" assert str(pn) == pn.render() assert str(_pn("", [])) == "" def test_a_suffix_run_survives_a_render_and_a_reparse() -> None: """str() of a fixed parse re-parses to the same suffix (#436). The instability #429 recorded and left open: its fix made 'Smith, MD PhD' render suffix 'MD PhD', and str() of that parse is 'Smith MD PhD' -- a NO-COMMA string, which the no-comma path then read back as 'MD, PhD'. One writing round-tripped, the other did not, and which was which depended on a comma the render had already dropped. Three spellings of one run, each its own path into the entry rule: a lone family plus the run, the full name plus a comma, and the full name with no comma at all. """ for text in ("Smith, MD PhD", "John Smith, MD PhD", "John Smith MD PhD"): once = parse(text) assert once.suffix == "MD PhD", text assert parse(str(once)).suffix == once.suffix, text def _bobdole() -> ParsedName: # "Sir Bob Andrew Dole" # 01234567890123456789 return _pn("Sir Bob Andrew Dole", [ Token("Sir", Span(0, 3), Role.TITLE), Token("Bob", Span(4, 7), Role.GIVEN), Token("Andrew", Span(8, 14), Role.MIDDLE), Token("Dole", Span(15, 19), Role.FAMILY), ]) def test_initials_default_spec() -> None: assert _bobdole().initials() == "B. A. D." def test_initials_skips_tagged_particles_outside_given() -> None: # family "de la Vega" with particle tags -> only V contributes; # a given-name token always contributes even if tagged assert _delavega().initials() == "J. V." # conjunction tag skips too pn = _pn("Mr. and Mrs. Smith", [ Token("Mr.", Span(0, 3), Role.TITLE), Token("and", Span(4, 7), Role.FAMILY, frozenset({"conjunction"})), Token("Smith", Span(13, 18), Role.FAMILY), ]) assert pn.initials("{family}") == "S." def test_repair_keeps_a_conjunction_lowercase_in_a_particle_part() -> None: """rules/R4's conjunction carve-out, which no shipped name reaches. The unjoined mark makes the words of an all-particle part ordinary name words, since none of them is acting as a particle there -- but repair's conjunction conjunct is deliberately UNGATED on that mark, R4 keeping a conjunction lowercase "even inside such a part". Witnessing it needs a word in `particles` AND `conjunctions`; the shipped sets are disjoint, in the default vocabulary and in every locale pack, so no input string can witness it and the rules can carry no example line for it. Gating the conjunct on the mark passed the entire suite until this test existed. Only the FORCED call gets here: R5's gate returns a mixed-case name before any of this is consulted. """ # mechanisms.md#VOCABULARY-OVERLAP-AS-PRECONDITION: "assert the # intersection as a precondition" -- built here rather than found, # so what has to hold is the half not being added. assert "y" in Lexicon.default().conjunctions, ( "'y' left the default conjunctions; this test builds the " "particle/conjunction overlap it needs from the other side and " "no longer has one. Pick another shipped conjunction.") lex = Lexicon.default().add(particles={"y"}) p = Parser(lexicon=lex) van = p.parse("Anh y Van") tags = [t.tags for t in van.tokens if t.text == "y"][0] assert {"conjunction", "particle", UNJOINED_TAG} <= tags, sorted(tags) assert van.capitalized(lex, force=True).family == "y Van" def test_initials_readmits_a_conjunction_in_a_particle_part() -> None: """rules/R3, settled (#461). This pinned TODAY's output on an open question so that settling it would fail the suite until the pin moved with it. It fired, and this is the pin moved: a connective contributes no initial where it is JOINING, and a part holding nothing else for it to join is a part where it initials like any other name word, agreeing with the base. The all-particle rows below are unchanged, because R2's mark already readmitted the word there; what moved is everything under the default vocabulary, where a lone connective now initials too. decisions/R3 records the rule and what it costs. Still not a `deviates:` marker and still not a rules example line: markers hang on example lines and every line there names an input parsed with the DEFAULT vocabulary, over which `particles` and `conjunctions` are disjoint, so no string reaches the all-particle-with-a-connective shape. It is also what keeps the values quoted in prose by decisions/, mechanisms.md and `_render.py` from going stale unnoticed. """ assert "y" in Lexicon.default().conjunctions, ( "'y' left the default conjunctions; this test builds the " "particle/conjunction overlap it needs from the other side and " "no longer has one. Pick another shipped conjunction.") p = Parser(lexicon=Lexicon.default().add(particles={"y"})) # the part the mark has turned into name words: every word of it # initials, the conjunction included, and the base agrees de_y = p.parse("Juan de y") assert de_y.family_base == "de y" assert de_y.initials() == "J. d. y." assert p.parse("Anh y Van").initials() == "A. y. V." assert p.parse("johnny y").initials() == "j. y." # OUTSIDE such a part the skip stands where the connective is # JOINING, and these are what the readmission must not reach -- # the question is asked of the whole PART and never of a word # count, which is what keeps a part of two words with a name word # in it on this side of the line assert p.parse("Juan Velasquez y Garcia").initials() == "J. V. G." assert parse("Jon Dough and").initials() == "J. D." # Under THIS lexicon 'Juan y Garcia' is one of them, and for a # reason worth spelling out: making 'y' a particle folds it into # the family, so the part is 'y Garcia' and holds a name word for # it to join. Unchanged, where the same string under the default # vocabulary moves -- which is the row below. assert p.parse("Juan y Garcia").initials() == "J. G." # and it DOES reach a part holding nothing else, under the default # vocabulary too, where 'y' is no particle and only the new mark # readmits it assert parse("Juan y Garcia").initials() == "J. y. G." assert parse("Juan de y").initials() == "J. y." assert parse("Juan y").initials() == "J. y." def test_repair_keeps_a_lone_connective_lowercase_where_it_initials( ) -> None: """rules/R4's own reading, and the view split it accepts. A connective that initials BECAUSE it joins nothing is still not written the way a name is written, so case repair leaves it lowercase while initials() takes its letter. That is R4's reason rather than a borrowing from R3 -- the two rules answer different questions about the same token and this is the row where their answers part. mechanisms.md#RENDER-HONORS-THE-PARSE records the shape; decisions/R4 records the split. """ assert parse("Juan de y").initials() == "J. y." assert parse("Juan de y").capitalized().family == "de y" assert parse("juan y").capitalized(force=True).given == "Juan" assert parse("juan y").capitalized(force=True).family == "y" def test_repair_capitalizes_a_generation_the_connective_also_spells( ) -> None: """rules/R4's other half (#397 review), and the row that made the sentence need two clauses. A word can be the connective and the generation at once -- 'i' is the Catalan link and the roman numeral -- and where the parse read the GENERATION the token still carries the `conjunction` tag classify gave it. Repair reads the ROLE the parse decided, not the tag alone, so a suffix-roled letter is repaired as the suffix it was read as. Without that test these gave 'John Quincy Smith i' and 'Carod i', where every release through 2.3 gave the capital. Both surfaces, because the v1 facade repairs through this same helper and a fix on one of them would be a split. """ assert parse("John Quincy Smith i").suffix == "i" assert str(parse("John Quincy Smith i").capitalized( force=True)) == "John Quincy Smith I" assert str(parse("Carod i").capitalized(force=True)) == "Carod I" assert str(parse("Josep Lluis Carod i III").capitalized( force=True)) == "Josep Lluis Carod I III" v1 = HumanName("John Quincy Smith i") v1.capitalize(force=True) assert str(v1) == "John Quincy Smith I" v1_two = HumanName("Carod i") v1_two.capitalize(force=True) assert str(v1_two) == "Carod I" # the contrast that keeps the role test honest: the SAME letter # in a NAME part is a connective and keeps its lowercase, which # is the answer 'y' has always had there assert parse("Josep i Rovira").middle == "i" assert str(parse("Josep i Rovira").capitalized( force=True)) == "Josep i Rovira" assert str(parse("Josep y Rovira").capitalized( force=True)) == "Josep y Rovira" def test_repair_keeps_a_plain_connective_the_suffix_field_holds_lower( ) -> None: """rules/R4, the GENERATION half read narrowly (#397 second review). The clause turns on the generation and not on the field: the test is the suffix ROLE **and** the suffix VOCABULARY, and a role test alone capitalized every plain connective that merely LANDS in the suffix field. The third part of a comma form is the shape that puts one there -- assign reads those words as the suffix run whatever they are -- and none of them is generational vocabulary, so none of them was read as a generation and all of them keep the lowercase they have always had. Expected strings measured 2026-09-20 on the released 1.4.0 and 2.3.0 wheels from a throwaway environment, and they agree with each other and with the parent commit 46651750. 'Smith, John, и' is the one row where the two wheels part, and for a reason older than this rule: Cyrillic 'и' is a 2.x conjunction and not a 1.4.0 one, so 1.4.0 gives 'John Smith И' and 2.3.0 gives 'John Smith и'. The tree follows 2.3.0, as _render.py's own note on that word says it must. """ for text, plain, forced in ( ("Smith, John, and", "John Smith and", "John Smith and"), ("Smith, John, y", "John Smith y", "John Smith y"), ("Smith, John, e", "John Smith e", "John Smith e"), ("Smith, John, und", "John Smith und", "John Smith und"), ("Smith, John, of", "John Smith of", "John Smith of"), ("Smith, John, и", "John Smith и", "John Smith и"), ("Doe, Jane, and Jr.", "Jane Doe and Jr.", "Jane Doe and Jr."), ("Smith, John, and III", "John Smith and III", "John Smith and III")): name = parse(text) assert name.suffix.split()[0] == text.split(", ")[-1].split()[0] assert str(name.capitalized()) == plain, text assert str(name.capitalized(force=True)) == forced, text v1 = HumanName(text) v1.capitalize(force=True) assert str(v1) == forced, text # A SPLICED field is the other way in, and rules/R4's Accepted # bullet "The boundary is drawn per question rather than per field" # is explicit about it (decisions/R4): text nobody read gets the # vocabulary's answer, so a suffix set to 'de y' keeps its 'y' # exactly as a family set to 'de y' does. 1.4.0 and 2.3.0 both # give 'John Smith De, y'. spliced = HumanName(first="John", last="Smith", suffix="de y") spliced.capitalize(force=True) assert str(spliced) == "John Smith De, y" # ... and the vocabulary's answer is what a spliced 'i' gets too, # which is a CHANGE from the parent and the shape to know about: # 'i' is connective vocabulary here and was not there, so the # spliced field follows 'y' now where it used to follow the # numeral. The PARSED name is unaffected -- it has a reading, and # the reading is the generation (above). spliced_i = HumanName(first="John", last="Smith", suffix="i") spliced_i.capitalize(force=True) assert str(spliced_i) == "John Smith i" spliced_y = HumanName(first="John", last="Smith", suffix="y") spliced_y.capitalize(force=True) assert str(spliced_y) == "John Smith y" def test_initials_order_folded_words_first_like_the_family_field() -> None: """#408: the view and the field must read one parse the same way. O3's fold and P6's attachment both tag rather than move (spans cannot reorder), and the family field reads that tag. `initials()` walked tokens in written order and did not, so the two views disagreed about the same parse -- 'der, y van' gave family 'van der' and initials 'y. d. v.' (mechanisms.md#RENDER-HONORS-THE-PARSE names #408 as that shape). Each assertion pairs the field with the view deliberately: a regression that reordered BOTH would still be caught by the literal, and one that reordered neither by the pairing. """ # P6's attachment, the one CORPUS name the default policy moves # -- constructed ones move too (`de la, y van`, `der, e van`), # so the count is the corpus's reach and not the rule's van_der = parse("der, y van") assert van_der.family == "van der" assert van_der.initials() == "y. v. d." # and the facade, which reached this answer by its own route # (its *_list views prepend the carriers) -- so the core view was # the one out of step assert HumanName("der, y van").initials() == "y. v. d." # O3's fold, which is where the reach is (71 of the corpus's 1094 # names move under this policy at the default order, measured # 2026-08-30) maf = Parser(policy=Policy(middle_as_family=True)) hassan = maf.parse("Hassan, Mohamad Ahmad Ali") assert hassan.family == "Ahmad Ali Hassan" assert hassan.initials() == "M. A. A. H." # The folded RUN needs two elements too, and distinguishable # ones: hassan's run is `Ahmad Ali`, which both initial to # `A.`, so reversing the folded half alone passes every test # above. Same arity trap as the fixture drafts, one level # down -- the partition BUILDS a group, and that group needs # the treatment too (mechanisms.md#TWO-ELEMENT-GROUPS). doe2 = maf.parse("Doe, John A. Kenneth") assert doe2.family == "A. Kenneth Doe" assert doe2.initials() == "J. A. K. D." doe = maf.parse("Doe, Dr. John A.") assert doe.family == "A. Doe" assert doe.initials() == "J. A. D." # under a non-default order too: the fold is not order-specific ff = Parser(policy=Policy(name_order=FAMILY_FIRST, middle_as_family=True)) vega = ff.parse("Smith Juan Vega") assert vega.family == "Vega Smith" assert vega.initials() == "J. V. S." def test_folded_tag_lands_only_on_family_today() -> None: """The guard under the per-role claim three prose sites make. `_render.initials`, its sibling test and mechanics.md#FOLDED_TAG all say the pipeline puts the tag on FAMILY tokens alone, which is why the GIVEN and MIDDLE arms of the partition are uniformity rather than reachable behavior. That claim was true and tested nowhere: a rule that ever folded into another part would falsify all three silently, and turn `initials()`'s "would otherwise reopen #408 there" from a hypothetical into a live gap with nothing to fail. """ from .cases import CASES seen = 0 for policy in (Policy(), Policy(middle_as_family=True)): p = Parser(policy=policy) for case in CASES: if case.locale is not None: continue for tok in p.parse(case.text).tokens: if FOLDED_TAG in tok.tags: seen += 1 assert tok.role is Role.FAMILY, ( f"{case.text!r}: {tok.text!r} carries FOLDED_TAG in " f"{tok.role}. Both producers re-role to FAMILY today; " f"if that changed on purpose, the per-role claims in " f"_render.initials, its sibling test and " f"mechanisms.md#FOLDED_TAG all move with it") assert seen, "no case row exercises the fold; this guard is inert" def test_initials_folds_in_every_role_it_renders() -> None: """The partition is applied per role, exactly as _text_for applies it -- not scoped to FAMILY, where the pipeline's two producers put the tag today. Both producers (O3's fold, P6's attachment) re-role to FAMILY, so no input string reaches this and it is pinned from a hand-built name instead. Uniformity with the mechanism is the point: a producer that ever folded into another part would otherwise reopen #408 there, silently, with nothing to fail. """ pn = _pn("Ann Bea Cyd Dee Eve Fay", [ Token("Ann", Span(0, 3), Role.GIVEN), Token("Bea", Span(4, 7), Role.MIDDLE), Token("Cyd", Span(8, 11), Role.GIVEN, frozenset({FOLDED_TAG})), Token("Dee", Span(12, 15), Role.MIDDLE, frozenset({FOLDED_TAG})), Token("Eve", Span(16, 19), Role.FAMILY), Token("Fay", Span(20, 23), Role.FAMILY, frozenset({FOLDED_TAG})), ]) # the fields already order this way; the view now agrees. All # THREE roles carry two tokens, per # mechanisms.md#TWO-ELEMENT-GROUPS: "on a one-element group a # partition is the identity" and the mutation that should expose # it passes. Two drafts got this wrong in the same way one role # apart, the first carrying both misses: it gave MIDDLE one token, # and skipping the # partition for MIDDLE alone passed the whole suite; the second # gave FAMILY none, so skipping it for FAMILY passed THIS test # and was caught only by its siblings and by R3's example line. A group with zero elements # is the identity too, and reads even less like a gap. assert pn.given == "Cyd Ann" assert pn.initials("{given}") == "C. A." assert pn.middle == "Dee Bea" assert pn.initials("{middle}") == "D. B." assert pn.family == "Fay Eve" assert pn.initials("{family}") == "F. E." def test_initials_custom_delimiter_and_separator() -> None: assert _bobdole().initials(delimiter="", separator="") == "B A D" def test_initials_multiword_group_joins_within_group() -> None: pn = _pn("Mary Jane Watson", [ Token("Mary", Span(0, 4), Role.GIVEN), Token("Jane", Span(5, 9), Role.GIVEN), Token("Watson", Span(10, 16), Role.FAMILY), ]) assert pn.initials() == "M. J. W." def test_initials_custom_spec_and_unknown_key() -> None: assert _bobdole().initials("{given} {middle}") == "B. A." with pytest.raises(KeyError, match="valid fields"): _bobdole().initials("{title}") def test_initials_already_initial_words() -> None: pn = _pn("J. Doe", [ Token("J.", Span(0, 2), Role.GIVEN), Token("Doe", Span(3, 6), Role.FAMILY), ]) assert pn.initials() == "J. D." def test_initials_empty_group_renders_empty() -> None: # v2 returns "" for an empty result -- no v1-style # empty_attribute_default fallback assert _bobdole().initials("{middle}") == "A." assert _pn("Cher", [Token("Cher", Span(0, 4), Role.GIVEN)]).initials("{middle}") == "" def _lowercase_mac() -> ParsedName: # v1 capitalize() doctest input: 'bob v. de la macdole-eisenhower phd' # 0123456789012345678901234567890123456 return _pn("bob v. de la macdole-eisenhower phd", [ Token("bob", Span(0, 3), Role.GIVEN), Token("v.", Span(4, 6), Role.MIDDLE), Token("de", Span(7, 9), Role.FAMILY), Token("la", Span(10, 12), Role.FAMILY), Token("macdole-eisenhower", Span(13, 31), Role.FAMILY), Token("phd", Span(32, 35), Role.SUFFIX), ]) def test_capitalized_all_lower_input_v1_parity() -> None: out = _lowercase_mac().capitalized() assert out.given == "Bob" assert out.middle == "V." assert out.family == "de la MacDole-Eisenhower" # particles stay lower assert out.suffix == "PhD" # exceptions map, a mask # same spans, new texts (provenance is a documented non-invariant) assert [t.span for t in out.tokens] == [t.span for t in _lowercase_mac().tokens] def test_capitalized_all_upper_input() -> None: pn = _pn("JOHN SMITH", [ Token("JOHN", Span(0, 4), Role.GIVEN), Token("SMITH", Span(5, 10), Role.FAMILY), ]) assert str(pn.capitalized()) == "John Smith" def test_capitalized_preserves_mixed_case_unless_forced() -> None: pn = _pn("Shirley Maclaine", [ Token("Shirley", Span(0, 7), Role.GIVEN), Token("Maclaine", Span(8, 16), Role.FAMILY), ]) assert pn.capitalized() == pn # untouched assert pn.capitalized(force=True).family == "MacLaine" @pytest.mark.parametrize("text", ["JOHN SMITH MA", "john smith ma", "Van Buren", "MARIA DA SILVA E SOUZA"]) def test_capitalized_keeps_each_reports_alternative(text: str) -> None: """A rebuilt report keeps the reading not taken (#752 phase 2m). `Ambiguity.alternative` is compare=False, so equal names say nothing about it and the alternatives are compared themselves.""" pn = parse(text) want = [a.alternative for a in pn.ambiguities] assert want and None not in want for out in (pn.capitalized(), pn.capitalized(force=True), Parser().capitalized(pn)): assert [a.alternative for a in out.ambiguities] == want def test_the_gate_leaves_the_suffixes_out() -> None: """rules/R5 (#492): a credential or a generation written the way one is written ('III', 'PhD', 'Jr.') is no evidence about how the NAME was cased, so the one-case test reads every token but the SUFFIX-roled ones. Titles stay in -- a cased title still holds repair back -- and so does every name word.""" for text, repaired in (("juan garcia III", "Juan Garcia III"), ("juan garcia PhD", "Juan Garcia PhD"), ("JUAN GARCIA Jr.", "Juan Garcia Jr."), ("dr. juan garcia III", "Dr. Juan Garcia III"), ("JUAN GARCIA iii", "Juan Garcia III"), # a cased suffix that is NOT the trailing # token: the gate excludes every SUFFIX-roled # token, not only the last one ("juan garcia PhD MD", "Juan Garcia PhD MD"), ("juan garcia, PhD, MD", "Juan Garcia PhD, MD"), # an untagged suffix: 'VI' reads suffix by # shape (roman numeral) and carries no # vocab:* tag, so a tag-driven gate would # wrongly keep it in the one-case test ("juan garcia VI", "Juan Garcia VI"), # the gate over each comma shape: SUFFIX_COMMA, # FAMILY_COMMA, and a FAMILY_COMMA plus a # trailing suffix segment ("juan garcia, III", "Juan Garcia III"), ("garcia, juan III", "Juan Garcia III"), ("GARCIA, JUAN, Jr.", "Juan Garcia Jr.")): assert str(parse(text).capitalized()) == repaired, text assert parse("juan garcia VI").tokens[-1].tags == frozenset() for untouched in ("Dr. juan garcia", "DR. juan garcia III", "Juan garcia III", "Juan Garcia iii", # NICKNAME and MAIDEN tokens stay IN the gate # text -- only SUFFIX is excluded, so a cased # nickname or maiden word still holds repair # back (a mutant that also filtered those # roles survived the whole suite otherwise) "jane doe nee SMITH III", "juan garcia (Bob)"): name = parse(untouched) assert name.capitalized() == name, untouched # a caseless name with a MIXED-CASE suffix: the old gate counted # the suffix, read the whole joined text as mixed case and # refused; the new gate excludes it, and the non-suffix text has # no case at all (trivially one-case), so the name is admitted -- # and then the suffix, written in more than one case, is kept as # written (2026-09-24), while one written in one case repairs assert str(parse("김민준 Phd").capitalized()) == "민준 김 Phd" assert str(parse("김민준 PHD").capitalized()) == "민준 김 PhD" # no non-suffix token at all -- a synthetic name only, since every # parse names somebody (rules/H4): the empty text is one-case only_suffixes = _pn("phd md", [ Token("phd", Span(0, 3), Role.SUFFIX), Token("md", Span(4, 6), Role.SUFFIX), ]) assert [t.text for t in only_suffixes.capitalized().tokens] \ == ["PhD", "MD"] def test_a_suffix_written_in_more_than_one_case_is_kept_as_written( ) -> None: """rules/R5 (decided 2026-09-24): the gate leaves the suffixes out because a cased suffix says nothing about the NAME, and read to its end that also means repair has no business re-spelling one. So where repair was not forced, a SUFFIX token written in more than one case is kept as written -- 'EdD' stays 'EdD' where the acronym clause would write 'EDD' -- while one written in a single case is repaired as before. Force repairs both. The cost is the garbled spelling kept with the deliberate one ('Iii'), pinned here as a boundary. Both surfaces.""" for text, plain, forced in ( ("john smith EdD", "John Smith EdD", "John Smith EDD"), ("JANE DOE, DSc", "Jane Doe DSc", "Jane Doe DSc"), ("juan garcia PsyD", "Juan Garcia PsyD", "Juan Garcia PsyD"), ("john smith B.Tech.", "John Smith B.Tech.", "John Smith B.TECH."), ("john smith, EdD, PhD", "John Smith EdD, PhD", "John Smith EDD, PhD"), # boundary: the garbled spelling is kept too ("juan garcia Iii", "Juan Garcia Iii", "Juan Garcia III"), ("john smith Mba", "John Smith Mba", "John Smith MBA"), # a suffix written in ONE case is repaired as any token is ("juan garcia III", "Juan Garcia III", "Juan Garcia III"), ("JUAN GARCIA iii", "Juan Garcia III", "Juan Garcia III"), ("john smith edd", "John Smith EDD", "John Smith EDD"), # unchanged by the rule: already written as repair writes ("juan garcia PhD", "Juan Garcia PhD", "Juan Garcia PhD"), ("juan garcia Jr.", "Juan Garcia Jr.", "Juan Garcia Jr.")): name = parse(text) assert str(name.capitalized()) == plain, text assert str(name.capitalized(force=True)) == forced, text hn = HumanName(text) hn.capitalize() assert str(hn) == plain, text hn = HumanName(text) hn.capitalize(force=True) assert str(hn) == forced, text def test_the_gate_and_the_parser_read_a_cased_suffix_differently() -> None: """decisions/R5's split: the parser's own one-case readings (rules/P8, decisions/S6) still count a cased suffix, while R5's gate leaves it out. So 'john e jones III' is MIXED to the parser, which reads its 'e' as a connective, and one-case to the gate, which repairs it -- keeping the connective lowercase -- where the one-case 'john e jones iii' reads 'e' as an initial. The gate follows the parse's suffix call, the ambiguous credential class included, so 'jack MA' (S6's lean read MA as the credential) and 'MD, PhD' (family MD, suffix PhD) repair on the default path too.""" mixed = parse("john e jones III") one_case = parse("john e jones iii") assert "conjunction" in mixed.tokens[1].tags assert "initial" in one_case.tokens[1].tags for name, repaired in ((mixed, "John e Jones III"), (one_case, "John E Jones III"), (parse("jack MA"), "Jack MA"), (parse("MD, PhD"), "Md PhD")): assert str(name.capitalized()) == repaired, name.original def test_capitalized_is_idempotent() -> None: once = _lowercase_mac().capitalized() assert once.capitalized() == once assert once.capitalized(force=True) == once def test_a_word_takes_its_capital_past_a_caseless_lead() -> None: """rules/R4 (#838): a modifier letter opening a word is passed over to the letter after it, so 'ʼNa', the repair of 'ʼna' ('ʼn' upper-cases to the modifier apostrophe plus 'N'), is a fixpoint. An apostrophe written as ', ‘ or ’ is no word character at all, so a word after it was already capitalized and still is: those rows are parity pins, not controls.""" once = parse("ʼna smith").capitalized() assert once.given == "ʼNa" assert once.capitalized(force=True) == once # and inside the Mac/Mc prefix's name run, which the chunk step # repairs too mac = parse("mcʼna smith").capitalized() assert mac.given == "McʼNa" assert mac.capitalized(force=True) == mac for lead in ("'", "‘", "’"): assert parse(f"{lead}abd smith").capitalized().given \ == f"{lead}Abd" def test_a_recorded_casing_keeps_an_abbreviations_ending() -> None: """rules/R4's Accepted cost (#838): an unlisted abbreviation's ending after its full stop is written as a word ('Dut.Ra'), and a listed casing is the remedy, asked before the chunk split -- the one the shipped Italian salutations carry (#840).""" lex = dataclasses.replace( Lexicon.default(), capitalization_exceptions=( *Lexicon.default().capitalization_exceptions, ("dut.ra", "Dut.ra"))) masked = Parser(lexicon=lex) name = "DUT.RA MARIA SILVA" assert str(parse(name).capitalized()) == "Dut.Ra Maria Silva" assert str(masked.capitalized(masked.parse(name))) \ == "Dut.ra Maria Silva" def test_capitalized_with_explicit_lexicon() -> None: # empty lexicon: no particle rule, no exceptions -> plain capitalize out = _lowercase_mac().capitalized(Lexicon.empty()) assert out.family == "De La MacDole-Eisenhower" assert out.suffix == "Phd" def _repaired_under(pairs: tuple[tuple[str, str], ...], text: str, *, force: bool = True) -> ParsedName: """`text` parsed and repaired under the default lexicon with its exceptions map replaced by `pairs`.""" p = Parser(lexicon=dataclasses.replace( Lexicon.default(), capitalization_exceptions=pairs)) return p.capitalized(p.parse(text), force=force) def test_a_mask_recases_the_word_as_the_writer_punctuated_it() -> None: """rules/R4's mask (#459): an exceptions-map value is its key's letters in the case each takes, laid over the word as written -- one entry covers every punctuation of the word, and repair adds and removes nothing.""" for text, suffix in (("john smith phd", "PhD"), ("john smith ph.d.", "Ph.D."), ("JOHN SMITH PH.D.", "Ph.D."), ("john smith bsc", "BSc"), ("JOHN SMITH MSC", "MSc")): assert parse(text).capitalized().suffix == suffix, text # two tokens, and 'ph.' is no key: no mask applies, and each word # title-cases on its own assert str(parse("john smith ph. d.").capitalized()) \ == "John Smith Ph. D." # the mask is asked BEFORE the acronym clause, which would give # 'BSC' -- bsc is a listed acronym too assert "bsc" in Lexicon.default().suffix_acronyms # forced: a mixed-case corpus name R5 would otherwise hold back assert str(parse("Dr. med. univ. Margit Popp, MSc").capitalized( force=True)) == "Dr. Med. Univ. Margit Popp MSc" # the mask is also asked BEFORE the numeral clause, which would # give 'III' -- an identity mask ('iii' stays lowercase) proves # the mask decided rather than merely agreeing with it assert _repaired_under((("iii", "iii"),), "john smith iii", force=False).suffix == "iii" def test_a_mixed_case_credential_listed_for_797_repairs_to_its_mask() -> None: """#797 listed credentials whose proper spelling is mixed case (MBChB, DPhil, MDPhD); a listed acronym with no mask repairs in capitals, so each carries one. Lowercase and capitals read alike, a dotted spelling keeps its dots, and a comma credential repairs the same as a trailing one. A joined spelling (fnp-c) has no mask here: it repairs whole in capitals under #808 (test_a_joined_acronym_ repairs_whole_and_a_recorded_casing_wins).""" for text, suffix in (("jane smith mbchb", "MBChB"), ("JANE SMITH MBCHB", "MBChB"), ("jane smith, dphil", "DPhil"), ("jane smith m.b.ch.b.", "M.B.Ch.B."), ("jane smith mdphd", "MDPhD")): assert parse(text).capitalized().suffix == suffix, text def test_a_joined_acronym_repairs_whole_and_a_recorded_casing_wins() -> None: """#808: a listed acronym credential joined by a hyphen or slash is one word, written in capitals across the join, where the per-word walk capitalized each side as a name part ('Otr/L'). No shipped joined entry records a casing, so the contrast is a caller's mask: laid over the whole token, it wins in any role. Hyphenated name parts and titles are not acronym credentials and keep their per-part repair.""" for text, suffix in (("john smith, otr/l", "OTR/L"), ("JOHN SMITH, FNP-BC", "FNP-BC"), ("john smith pa-c", "PA-C"), ("john smith ccc/slp", "CCC/SLP")): assert parse(text).capitalized().suffix == suffix, text assert parse("John Smith, otr/l").capitalized().suffix == "otr/l" assert parse("John Smith, otr/l").capitalized(force=True).suffix \ == "OTR/L" for text, whole in (("john smith-jones", "John Smith-Jones"), ("jean-luc picard", "Jean-Luc Picard"), ("maria da-silva", "Maria da-Silva"), ("dipl.-ing john smith", "Dipl.-Ing John Smith")): assert str(parse(text).capitalized()) == whole, text default = Lexicon.default() masked = dataclasses.replace(default, capitalization_exceptions=( *default.capitalization_exceptions, ("otr/l", "OTr/L"), ("pa-c", "Pa-C"))) parser = Parser(lexicon=masked) for text, suffix in (("john smith, otr/l", "OTr/L"), ("JOHN SMITH, PA-C", "Pa-C"), ("john smith fnp-c", "FNP-C")): assert parser.capitalized(parser.parse(text)).suffix == suffix, text def test_a_joined_acronym_is_capitals_only_as_a_listed_suffix() -> None: """#808's two gates, each with a row the whole-token arm must not take: a listed joined acronym in a NAME role (the vocabulary lists the word, the parse read a name) and an unlisted joined word in the suffix role both repair part by part as before. The comma shapes and the family-first order reach the arm through the role the parse chose, so the empty head and the one-word head are rows too, and a mixed-case spelling is left alone unless forced.""" for text, whole in (("otr/l smith", "Otr/L Smith"), ("john otr/l smith", "John Otr/L Smith"), ("john smith, md, xyz-q", "John Smith MD, Xyz-Q"), ("john smith, md, xyz/q", "John Smith MD, Xyz/Q")): assert str(parse(text).capitalized()) == whole, text for text, suffix in ((", otr/l", "OTR/L"), ("smith, otr/l", "OTR/L"), ("smith, john otr/l", "OTR/L"), ("john smith otr/L", "otr/L")): assert parse(text).capitalized().suffix == suffix, text ff = Parser(policy=Policy(name_order=FAMILY_FIRST)) assert ff.parse("smith otr/l").capitalized().suffix == "OTR/L" assert parse("john smith OTR/l").capitalized(force=True).suffix \ == "OTR/L" def test_a_recorded_casing_reaches_a_joined_token_in_any_role() -> None: """#808: the whole-token mask is role-free as _cap_word's is, and reads the dotted key through the undotted fallback. The shipped Dipl.-Ing title repairs the same either way, so a mask that differs from the walk is what tells the arms apart.""" default = Lexicon.default() masked = dataclasses.replace(default, capitalization_exceptions=( *default.capitalization_exceptions, ("otr/l", "OTr/L"), ("smith-jones", "SMiTH-JoNES"), ("dipl.-ing", "dIpl.-Ing"))) parser = Parser(lexicon=masked) for text, whole in (("otr/l smith", "OTr/L Smith"), ("john otr/l smith", "John OTr/L Smith"), ("john smith-jones", "John SMiTH-JoNES"), ("smith-jones, john", "John SMiTH-JoNES"), ("dipl.-ing john smith", "dIpl.-Ing John Smith"), ("john smith dipl.-ing", "John Smith dIpl.-Ing")): assert str(parser.capitalized(parser.parse(text))) == whole, text def test_a_mask_applies_whatever_role_the_word_took() -> None: """The map stays role-free (#459): an entry is the caller saying how a word is written, wherever it stands. The acronym and numeral clauses read the role; the mask does not.""" assert str(parse("phd smith").capitalized()) == "PhD Smith" assert str(parse("john phd smith").capitalized()) == "John PhD Smith" assert str(parse("MSc Dr. med. univ.").capitalized(force=True)) \ == "MSc Dr. Med. Univ." def test_a_word_that_left_the_map_repairs_by_the_role_it_took() -> None: """md, ii, iii and iv left the exceptions map (#459). A suffix md is a listed acronym and a suffix numeral a numeral, so the acronym and numeral clauses write them in capitals -- keeping whatever punctuation the writer used, which the map's substitution did not. A word the parse put in a NAME role is repaired as a name word whatever vocabulary holds it (decisions/R4's given-role half), which is also how the abbreviated Mohammed is written.""" assert str(parse("john smith md").capitalized()) == "John Smith MD" assert str(parse("john smith m.d.").capitalized()) == "John Smith M.D." assert str(parse("john smith iii").capitalized()) == "John Smith III" assert str(parse("john smith iii.").capitalized()) == "John Smith III." assert str(parse("Andrew Perkins (M.D)").capitalized(force=True)) \ == "Andrew Perkins M.D" assert str(parse("Md Abdul Karim").capitalized(force=True)) \ == "Md Abdul Karim" assert parse("iv smith").given == "iv" assert str(parse("iv smith").capitalized()) == "Iv Smith" # the other name-role numeral shape: a numeral in a name role # repairs as the name word the parse read it as, decided assert str(parse("john iii smith").capitalized()) == "John Iii Smith" def test_a_credential_read_by_its_shape_repairs_to_capitals() -> None: """rules/R4, #516's by-shape half (#459): a suffix classify admitted to the credential class by its dotted shape alone carries SHAPE_ACRONYM_TAG and no vocabulary entry, and repairs as a listed acronym does.""" assert str(parse("john smith x.y.z.").capitalized()) \ == "John Smith X.Y.Z." assert str(parse("John Smith R.A.I.").capitalized(force=True)) \ == "John Smith R.A.I." # the same shape read as the FAMILY -- no words to spare -- is a # name word assert parse("Jack X.Y.Z.").family == "X.Y.Z." # repaired as a name word, whose full stops divide it into words # of their own (#838) assert str(parse("Jack X.Y.Z.").capitalized(force=True)) \ == "Jack X.Y.Z." # and with the dotted-shape switch off it is family by position off = Parser(policy=Policy(unlisted_dotted_suffixes=False)) assert str(off.capitalized(off.parse("john smith x.y.z."))) \ == "John Smith X.Y.Z." # the opt-in all-caps half writes the same tag caps = Parser(policy=Policy(unlisted_caps_suffixes=CapsSuffixes.EVERYWHERE)) assert str(caps.capitalized(caps.parse("John Smith XYZ"), force=True)) == "John Smith XYZ" assert str(parse("John Smith XYZ").capitalized(force=True)) \ == "John Smith Xyz" def test_a_suffix_numeral_repairs_to_capitals_by_its_shape() -> None: """rules/R4 (#459): vi through x carry no vocabulary tag and title-cased to 'Vi'/'Ix'; the numeral clause reads the roman shape of a SUFFIX-roled word. xi and up are no suffix to the parse (its roman shape stops at x), and a numeral after a family comma is the given name -- parse limits both, so both repair as the name words they were read as.""" for text, suffix in (("john smith vi", "VI"), ("john smith vii", "VII"), ("john smith viii", "VIII"), ("john smith ix", "IX"), ("john smith x", "X"), ("john smith, v", "V")): name = parse(text) assert name.suffix == text.split()[-1], text assert name.capitalized().suffix == suffix, text assert parse("john smith xi").family == "xi" assert str(parse("john smith xi").capitalized()) == "John Smith Xi" assert parse("john smith, vi").given == "vi" assert str(parse("john smith, vi").capitalized()) == "Vi John Smith" # the generation guard's other half: a generational 'i' skips the # connective arm, and the numeral clause is what writes it assert str(parse("Carod i").capitalized(force=True)) == "Carod I" # the clause reads no vocabulary, so an empty lexicon repairs too assert _pn("john smith vi", [ Token("john", Span(0, 4), Role.GIVEN), Token("smith", Span(5, 10), Role.FAMILY), Token("vi", Span(11, 13), Role.SUFFIX), ]).capitalized(Lexicon.empty()).suffix == "VI" # the documented Unicode boundary of the case-only invariant: _ROMAN # matches under re.I, which admits the dotless Turkish 'ı' (casefold- # unequal to 'i') as a roman-numeral suffix, and the numeral clause # writes it in capitals same as any other assert "ı".casefold() != "i".casefold() assert parse("john smith ıv").capitalized().suffix == "IV" def test_the_mask_keeps_every_non_letter_and_declines_a_miscount() -> None: assert _apply_mask("ph.d.", "PhD") == "Ph.D." assert _apply_mask("PHD", "PhD") == "PhD" assert _apply_mask("bsc", "BSc") == "BSc" # The lookup key is NFC-composed and the word is not: decomposed # hangul spells one syllable in two letters, so the counts differ # and the applier declines rather than guess -- and the repair # falls through to the next clause instead of raising. decomposed = unicodedata.normalize("NFD", "씨") assert len(decomposed) == 2 assert _apply_mask(decomposed, "씨") is None assert _repaired_under((("씨", "씨"),), unicodedata.normalize( "NFD", "John Smith 씨")).suffix == decomposed def test_a_split_initial_is_capitalized_only_where_the_mask_keeps_it_joined( ) -> None: """rules/R4 (#459 review, narrowed): a letter written alone beside a full stop is an initial ONLY where the MASK writes that same letter inside a run of two or more letters. Where the mask spells it alone too, the mask's own case stands ('h.c' on 'h.c.' stays 'h.c.'), and a run of two or more letters beside a full stop ('sc' in 'b.sc.') is never an initial.""" for word, mask, expected in ( ("p.h.d.", "PhD", "P.H.D."), ("b.sc.", "BSc", "B.Sc."), ("h.c.", "h.c", "h.c."), # mask ALSO spells each letter alone ("y", "y", "y"), # no full stop at all (".a", "a", ".a"), # single-letter mask never overrides ("a.", "a", "a."), ("2b.", "2b", "2b."), # 'b' has no LETTER neighbor in '2b' # a digit ends a run on the NEXT side too, not only the # previous one the row above covers ("b.2", "b2", "b.2"), ("2.b.", "2b", "2.b."), # the mask's own 'A' already agrees, so this row alone # cannot tell the override from plain masking; the two # all-lowercase masks after it can ("a.bc", "Abc", "A.bc"), ("a.bc", "abc", "A.bc"), # stop after the run's first letter ("ab.c", "abc", "ab.C"), # stop before the run's last letter # a digit ends a run: the mask's runs are {x} and {bc}, so # 'x' keeps its lowercase (and has no stop beside it) while # the split 'b' and 'c' are forced upper ("x2b.c", "x2bc", "x2B.C"), # a fullwidth stop is a full stop too -- reachable by a # direct call only, since _WORD splits a token at it ("a.bc", "abc", "A.bc"), ("ab.c", "abc", "ab.C"), # fullwidth stop on the previous side # a TITLECASE mask letter (DŽ, the digraph DŽ's title form) # reads as upper -- `not mask_chars[at].islower()` is true # for it same as for a plain uppercase letter -- a # documented limit (_apply_mask's own docstring) ("dž", "Dž", "DŽ"), ): assert _apply_mask(word, mask) == expected, (word, mask) # The override's two index guards ('i > 0' before reading # word[i - 1], 'i < last' before reading word[i + 1]) matter only # for a split letter at an actual EDGE of the word -- reachable # here only by calling _apply_mask directly, since through # _cap_text a hyphen is never handed to it: _WORD splits a token # at a hyphen first (and the hyphen clause in _cap_text handles # that text separately), so only a full stop reaches this far. assert _apply_mask("ab-c", "abc") == "ab-c" assert _apply_mask("a-bc.", "abc") == "a-bc." for text, suffix in (("john smith b.s.c.", "B.S.C."), ("JOHN SMITH B.S.C.", "B.S.C."), ("john smith m.s.c.", "M.S.C."), ("john smith p.h.d.", "P.H.D.")): assert parse(text).capitalized().suffix == suffix, text hn = HumanName(text) hn.capitalize() assert hn.suffix == suffix, text # the boundary the rule draws: a two-letter run beside a period # is not lone, and still takes the mask's case assert parse("john smith b.sc.").capitalized().suffix == "B.Sc." # end to end: a caller's own mask that ALSO spells each letter # alone (honoris causa -- "Dr. h.c.") leaves the split-looking # word unchanged rather than forcing capitals nobody asked for assert str(_repaired_under((("h.c", "h.c"),), "dr. h.c. hans meier")) \ == "Dr. h.c. Hans Meier" def test_a_decomposed_mask_value_reads_the_same_split_as_composed() -> None: """A mask value is stored NFC-composed (#459 review): decomposed, 'é' would be a base letter plus a non-alpha combining mark, and the split-off-initial rule would read the base as split off. Both spellings of one value must repair the split word identically.""" composed = unicodedata.normalize("NFC", "Péx") decomposed = unicodedata.normalize("NFD", "Péx") assert decomposed != composed for value in (composed, decomposed): assert _repaired_under((("pé.x.", value),), "john smith pé.x.").suffix == "Pé.X." def _nfc_texts(name: ParsedName) -> list[str]: return [unicodedata.normalize("NFC", t.text) for t in name.tokens] @pytest.mark.parametrize(("pairs", "text"), [ # the plain word clause: 'a' after the mark is not a word of its own ((), "josé garcía"), # Mac/Mc, decided on the composed spelling: 'mac' + 'ée' passes # _MAC's \w{2,} composed and fails it at the mark decomposed ((), "macée smith"), ((), "MACÉINRÍ SMITH"), # the split-off-initial override reads the full stop past the mark ((("éx", "éx"),), "john smith é.x."), # a letter after a mark still has the letter before it as neighbour ((("éex", "éeX"),), "john smith ée.x"), # the hyphen clause's initial test, on the composed spelling: 'й' # is a default conjunction, and 'й.' between hyphens an initial ((), "ivan petrov-й.-sidorov"), ]) def test_a_decomposed_word_repairs_as_its_composed_twin( pairs: tuple[tuple[str, str], ...], text: str) -> None: """#542: a combining mark belongs to the letter before it, so every clause of case repair reads a decomposed (NFD) word as it reads the composed one -- the word splitter, the Mac/Mc clause, and the mask's neighbour tests -- and the output keeps the decomposed form. These are the shapes the corpus walk in test_properties.py does not reach. The two mask rows guard _beside: 'é.x.' agreed before the fix only because the old splitter cut the word at its mark before the mask was consulted, and both rows disagree with _beside reading raw neighbours.""" composed = unicodedata.normalize("NFC", text) decomposed = unicodedata.normalize("NFD", text) assert decomposed != composed want = _repaired_under(pairs, composed) got = _repaired_under(pairs, decomposed) assert _nfc_texts(got) == [t.text for t in want.tokens] assert all(unicodedata.is_normalized("NFD", t.text) for t in got.tokens) def test_a_spliced_decomposed_initial_reads_as_its_composed_twin() -> None: """#542, the unclassified-text fallback (_reads_as_conjunction): a middle spliced in as decomposed 'й.' is the initial its composed spelling is, not the conjunction 'й'.""" for form in ("NFC", "NFD"): name = parse("ivan petrov").replace( middle=unicodedata.normalize(form, "й.")) assert unicodedata.normalize( "NFC", name.capitalized(force=True).middle) == "Й." @pytest.mark.parametrize(("text", "want"), [ # decomposed hangul: jamo are letters, not marks, and the initial # is the whole syllable they spell, as composed ("김 민준", "민. 김."), # a composed Bengali vowel sign is a mark that composes with # nothing: it stays out of the initial in both forms (#585 does not # decide the Indic syllable question) ("কিরণ রায়", "ক. র."), # two marks composing into one letter ("ệ smith", "ệ. s."), # a mark composing with nothing (U+0331) sits, in canonical order, # between the letter and the acute that composes: the initial is # 'ó' decomposed, as the composed spelling's initial is 'ó' ("ó̱la smith", "ó. s."), ]) def test_a_decomposed_initial_is_its_composed_twins(text: str, want: str) -> None: """#585: an initial is the shortest start of its word that composes to the composed word's first character, kept in the form written -- on both surfaces.""" decomposed = unicodedata.normalize("NFD", text) for got in (parse(decomposed).initials(), HumanName(decomposed).initials()): assert unicodedata.normalize("NFC", got) == want assert unicodedata.is_normalized("NFD", got) def test_a_composed_initial_is_the_first_character_as_before() -> None: """A word that is not decomposed initials text[0], unchanged by #585 -- including a CJK compatibility ideograph, which NFC would rewrite to its unified twin and which is kept as written.""" from nameparser._render import _first_letter assert _first_letter("\uf900x") == "\uf900" # excluded from composition: NFC splits it into 'क' + nukta, and # the code point is kept whole, nukta and all assert _first_letter("\u0958x") == "\u0958" assert _first_letter("Émile") == "É" with pytest.raises(IndexError): _first_letter("") def test_a_letter_that_uppercases_to_a_combining_mark_is_a_fixpoint( ) -> None: """'ǰ' upper-cases to 'J' + a combining caron. Before #542 the second forced pass read 'J̌o' as two words and gave 'J̌O'; the mark now stays with its letter, so repair is idempotent here.""" p = Parser() once = p.capitalized(p.parse("ǰo smith"), force=True) assert once.given == "J̌o" assert p.capitalized(once, force=True).given == "J̌o" def test_a_mark_with_no_letter_before_it_heads_no_word() -> None: """A leading combining mark stays outside the word after it: str.capitalize() on a word starting with the mark would upper-case the mark and leave the letter lowercase.""" assert parse("́abc smith").capitalized(force=True).given == ( "́Abc") def test_a_masks_upper_fallback_can_lengthen_a_word_through_ss() -> None: """decisions/R4 (2026-09-24 review): where the whole-word upper-casing does not keep the word's length, _apply_mask falls back to a per-character `c.upper()`, which lengthens 'ß' to 'SS' as the acronym/numeral clauses' `word.upper()` does -- whether the letter is upper by the split-off-initial override ('a.ß') or by the mask's own case ('STRAẞE', with the capital ẞ). Both masks validate, spelling their keys' own letters; the validator's refusal of ('straße', 'STRASSE') is a different question.""" assert _repaired_under((("a.ß", "aß"),), "john a.ß smith").middle == "A.SS" assert _repaired_under((("straße", "STRAẞE"),), "john straße").family == "STRASSE" def test_a_lengthening_mask_is_not_a_fixpoint_under_a_second_forced_pass( ) -> None: """decisions/R4 (2026-09-24 review, sub-clause (a)): capitalized()'s own docstring claims every clause is a fixpoint, so a repaired name comes back unchanged if repaired again -- true everywhere except this one boundary. The FIRST forced pass over 'a.ß' under the ('a.ß', 'aß') mask gives 'A.SS' (the previous test). Forcing that OUTPUT through the same lexicon a second time folds it to 'a.ss', which is a different letter sequence from the stored key 'a.ß' -- not merely a different case of the same one -- so the exceptions map lookup that found the entry on the first pass misses on the second, and the word falls through to plain title-casing, which writes each full-stop chunk as a word ('A.Ss', #838).""" lex = dataclasses.replace(Lexicon.default(), capitalization_exceptions=(("a.ß", "aß"),)) first = parse("john a.ß smith").capitalized(lex, force=True) assert first.middle == "A.SS" second = first.capitalized(lex, force=True) assert second.middle == "A.Ss" assert second.middle != first.middle def test_a_masks_punctuation_marks_its_joins_and_is_never_written() -> None: """#459 review: a value's punctuation is never written into the word -- under ('md', 'M.D.') 'md' repairs to 'MD' and 'm.d.' to 'M.D.', as under a plain 'MD' -- but it is not IGNORED: it marks which of the mask's letters are one run, and the split-off-initial rule reads that. So 'h.c' and 'hc', differing ONLY in punctuation, repair the writer's 'h.c.' differently.""" with warnings.catch_warnings(): warnings.simplefilter("error") ignore_removals() lex = dataclasses.replace( Lexicon.default(), capitalization_exceptions=(("md", "M.D."),)) p = Parser(lexicon=lex) assert str(p.capitalized(p.parse("john smith md"))) == "John Smith MD" assert str(p.capitalized(p.parse("john smith m.d."))) \ == "John Smith M.D." # the facade twin: warning-free at the first parse, where the # shim's lazily built Lexicon snapshot is the one place a # construction diagnostic could be raised c = Constants(capitalization_exceptions={'md': 'M.D.'}) with warnings.catch_warnings(): warnings.simplefilter("error") ignore_removals() hn = HumanName("john smith md", constants=c) hn.capitalize() assert str(hn) == "John Smith MD" with warnings.catch_warnings(): warnings.simplefilter("error") ignore_removals() joined = dataclasses.replace( Lexicon.default(), capitalization_exceptions=(("hc", "h.c"),)) split = dataclasses.replace( Lexicon.default(), capitalization_exceptions=(("hc", "hc"),)) pj, ps = Parser(lexicon=joined), Parser(lexicon=split) assert str(pj.capitalized(pj.parse("dr. h.c. hans meier"), force=True)) == "Dr. h.c. Hans Meier" assert str(ps.capitalized(ps.parse("dr. h.c. hans meier"), force=True)) == "Dr. H.C. Hans Meier" def test_a_mask_recases_a_digit_key_unchanged() -> None: """#459 review: the mask walks ALPHANUMERICS, so a digit is carried through (it has no case) while the letters take the mask's case. A synthetic GIVEN-roled token keeps the row off the role-gated acronym/numeral clauses and off how '2nd' happens to parse.""" lex = dataclasses.replace(Lexicon.default(), capitalization_exceptions=(("2nd", "2ND"),)) assert _pn("2nd", [ Token("2nd", Span(0, 3), Role.GIVEN), ]).capitalized(lex, force=True).given == "2ND" def test_a_mask_cases_through_the_whole_word_for_context_sensitive_letters( ) -> None: """#459 review: casing goes through word.lower()/word.upper() when both keep the word's length, since a per-character call is context-free and writes a Greek medial sigma where a FINAL one belongs.""" assert _apply_mask("ΚΟΣ", "Κος") == "Κος" # final sigma # the per-character fallback: 'straße'.upper() is 'STRASSE', # longer than the word, and there ß keeps its own lowercase under # the mask's lowercase letter assert _apply_mask("straße", "STRAßE") == "STRAßE" # same fallback (the word's own upper/lower length still # disagrees regardless of the mask's case), with a LOWERCASE mask # this time: the per-character path lowers every letter, # including ß's own, to 'straße' assert _apply_mask("STRAßE", "straße") == "straße" # `same_length` chains three lengths (lowered, uppered, word); the # ß rows above both fail it through the UPPERED side ('ß' grows # under .upper()). This row fails it through the LOWERED side # instead -- 'İ' (capital dotted I) grows under .lower() to 'i̇' # (dotless i + combining dot above) -- and the last row fails it # through BOTH at once, mixing ß and İ in one word. assert _apply_mask("İx", "ix") == "İx".lower() assert _apply_mask("ßİ", "ßi") == "".join(c.lower() for c in "ßİ") # end to end, through a custom Lexicon: a per-character # c.lower()/c.upper() walk gave 'Κοσ' here (medial sigma), wrong lex = dataclasses.replace(Lexicon.default(), capitalization_exceptions=(("κος", "Κος"),)) assert _pn("ΚΟΣ", [ Token("ΚΟΣ", Span(0, 3), Role.GIVEN), ]).capitalized(lex, force=True).given == "Κος" def test_capitalized_lowers_the_words_the_parse_tagged_conjunction() -> None: # #458: whether a word is the conjunction or an initial is # classify's decision, recorded as the tag; repair honors the tag # and never asks the word. Both halves of that are asserted here, # since a repair that lowered every conjunction-vocabulary word # would pass the first alone. # 01234567890123456789 pn = _pn("juan ortega y gasset", [ Token("juan", Span(0, 4), Role.GIVEN), Token("ortega", Span(5, 11), Role.FAMILY), Token("y", Span(12, 13), Role.FAMILY, frozenset({"conjunction"})), Token("gasset", Span(14, 20), Role.FAMILY), ]) out = pn.capitalized(force=True) assert out.family == "Ortega y Gasset" # v1's is_conjunction excludes initial-shaped words, so classify # withholds the tag from an uppercase 'Y' and repair capitalizes it # ('JOSE ORTEGA Y GASSET' -> 'Jose Ortega Y Gasset', pinned live # against v1.4 2026-07-17 and pinned end to end in # tests/test_capitalization.py). These tokens are what a parse of # the uppercase name builds. upper = _pn("JUAN ORTEGA Y GASSET", [ Token("JUAN", Span(0, 4), Role.GIVEN), Token("ORTEGA", Span(5, 11), Role.FAMILY), Token("Y", Span(12, 13), Role.FAMILY), Token("GASSET", Span(14, 20), Role.FAMILY), ]) assert upper.capitalized(force=True).family == "Ortega Y Gasset" # The tag decides even against the shape: a token tagged # conjunction lowers however it is written. Nothing shipped builds # this token -- that is the point, since the old predicate could # not have honored it. tagged = _pn("JUAN ORTEGA Y GASSET", [ Token("JUAN", Span(0, 4), Role.GIVEN), Token("ORTEGA", Span(5, 11), Role.FAMILY), Token("Y", Span(12, 13), Role.FAMILY, frozenset({"conjunction"})), Token("GASSET", Span(14, 20), Role.FAMILY), ]) assert tagged.capitalized(force=True).family == "Ortega y Gasset" # ... and an untagged word of the conjunction vocabulary is an # ordinary name word. The reachable shape is a token whose text is # more than one word, since the repair walks a token's words while # the tag is the whole token's: 'juan e-f smith' capitalized to # 'Juan e-F Smith' while the old predicate re-decided per word. hyphenated = _pn("juan e-f smith", [ Token("juan", Span(0, 4), Role.GIVEN), Token("e-f", Span(5, 8), Role.MIDDLE), Token("smith", Span(9, 14), Role.FAMILY), ]) assert hyphenated.capitalized(force=True).middle == "E-F" def test_a_link_inside_a_hyphenated_word_keeps_its_lowercase() -> None: """rules/R4 (#478): inside one hyphenated word, a part that is connective vocabulary with a worded part on each side of it keeps its lowercase, in every role. At either END it is ordinary name text -- #458's answer, kept -- and a word outside the connective vocabulary is never reached, the Maori 'a' among them. A single letter marked with a period is an initial there too, never the connective -- a multi-letter word marked with a period is not.""" for text, repaired in ( ("jose ortega-y-gasset", "Jose Ortega-y-Gasset"), ("JOSE ORTEGA-Y-GASSET", "Jose Ortega-y-Gasset"), ("maria silva-e-sousa", "Maria Silva-e-Sousa"), ("mary-e-smith", "Mary-e-Smith"), ("john smith-and-jones", "John Smith-and-Jones"), # edges ("juan e-f smith", "Juan E-F Smith"), ("juan y-garcia", "Juan Y-Garcia"), ("jose ortega-y-", "Jose Ortega-Y-"), ("jose -y-gasset", "Jose -Y-Gasset"), # no connective, and the particle arm's own per-word answer ("donovan mcnabb-smith", "Donovan McNabb-Smith"), ("maria da-silva", "Maria da-Silva"), # three worded parts on the ambiguous side of `first < at # < last`, so a mutant dropping that bound cannot pass ("juan y-garcia-lopez", "Juan Y-Garcia-Lopez"), ("juan garcia-lopez-y", "Juan Garcia-Lopez-Y"), # non-link parts inside a THREE-part hyphenated token still # get the full per-word repair -- the particle arm # ("de"/"la") and the Mac rule ("mcnabb") each apply per # part, not only to a two-part compound ("juan garcia-de-la-vega", "Juan Garcia-de-la-Vega"), ("donovan mcnabb-y-smith", "Donovan McNabb-y-Smith"), # a leading EMPTY part shifts which named index is "first": # 'y' sits at the edge of the NAMED parts (index 0 of # ['y', 'garcia', 'lopez']) though it is not part 0 of the # split, so it stays ordinary name text -- a mutant using # the raw part index (0 < at < len(parts)-1) instead of the # named-relative bound would wrongly read it as interior # and lower it ("jose -y-garcia-lopez", "Jose -Y-Garcia-Lopez"), ("jose garcia-lopez-y-", "Jose Garcia-Lopez-Y-"), # a part holding only punctuation ('.') is not a worded # neighbour -- the split is ['.', 'y', 'garcia'], so 'y' # has only ONE worded neighbour and stays ordinary name # text rather than reading as the connective ("jose .-y-garcia", "Jose .-Y-Garcia")): assert str(parse(text).capitalized()) == repaired, text # mixed case is R5's: untouched unless forced mixed = parse("Jose Ortega-Y-Gasset") assert mixed.capitalized() == mixed assert str(mixed.capitalized(force=True)) == "Jose Ortega-y-Gasset" # not connective vocabulary, so not reached (decisions/R4) assert "a" not in Lexicon.default().conjunctions assert str(parse("Te Awanui-a-Rangi Black").capitalized( force=True)) == "Te Awanui-A-Rangi Black" # a single letter marked with a period is an initial there, # exactly as in spaced text, never the connective -- `_normalize` # strips the period, so a naive vocabulary check alone would read # 'e.'/'y.' as the connective and lower it assert str(parse("j.-e.-p. dupont").capitalized( force=True)) == "J.-E.-P. Dupont" assert str(parse("J.-Y.-M. COUSTEAU").capitalized()) == \ "J.-Y.-M. Cousteau" # a MULTI-letter word marked with a period is not an initial, and # stays reachable as the connective, the same as its spaced # reading ('hans smith und. jones' tags 'und.' a conjunction) assert str(parse("hans smith-und.-jones").capitalized( force=True)) == "Hans Smith-und.-Jones" # every role, not only FAMILY above assert str(parse("smith, jose ortega-y-gasset").capitalized( force=True)) == "Jose Ortega-y-Gasset Smith" def test_the_hyphen_is_the_writers_join_even_in_a_one_case_name() -> None: """rules/R4's hyphen clause against rules/P8's one-case fork, a split DECIDED 2026-09-24 (decisions/R4): spaced, a marked letter in a name written in one case reads as an initial and repairs to a capital; hyphenated, the writer joined the surname on purpose, so the interior word is the connective whatever case the name is in. The cost is a one-case name whose hyphenated bare initials spell a connective -- 'J-E-P DUPONT' repairs to 'J-e-P Dupont', where 1.4.0 and the parent gave 'J-E-P'. And conjunctions_ambiguous, P8's knob, does not reach a hyphenated word: marking 'y' moves the spaced spelling only.""" for text, repaired in (("J-E-P DUPONT", "J-e-P Dupont"), ("JOHN A-Y-B SMITH", "John A-y-B Smith"), ("maria silva-e-sousa", "Maria Silva-e-Sousa"), ("maria silva e sousa", "Maria Silva E Sousa")): assert str(parse(text).capitalized()) == repaired, text hn = HumanName(text) hn.capitalize() assert str(hn) == repaired, text marked = Parser(lexicon=Lexicon.default().add( conjunctions_ambiguous={"y"})) for text, default, under_mark in ( ("JOSE ORTEGA Y GASSET", "Jose Ortega y Gasset", "Jose Ortega Y Gasset"), ("JOSE ORTEGA-Y-GASSET", "Jose Ortega-y-Gasset", "Jose Ortega-y-Gasset")): assert str(parse(text).capitalized()) == default, text assert str(marked.capitalized(marked.parse(text))) == under_mark, \ text def test_a_shipped_mask_spells_its_suffix_in_either_single_case() -> None: """#459 (decisions/R4, 2026-09-24): the PROPERTY every shipped mask pair serves, derived from the pairs rather than restating them -- `john smith <key>` puts the key in the suffix role, and repair spells it as the mask whether it was written all lower or all upper, where the acronym clause alone would write it in capitals. Both surfaces. The suffix keys only: since #604 the map also carries particle masks, whose fork is the next test's.""" lexicon = Lexicon.default() pairs = tuple((key, mask) for key, mask in lexicon.capitalization_exceptions if key in lexicon.suffix_acronyms or key in lexicon.suffix_words) assert pairs # an empty map would make the loop vacuous for key, mask in pairs: for text in (f"john smith {key}", f"JOHN SMITH {key.upper()}"): name = parse(text) assert name.suffix.lower() == key, text assert name.capitalized().suffix == mask, text hn = HumanName(text) hn.capitalize() assert hn.suffix == mask, text def test_a_shipped_particle_mask_outranks_the_particle_lowercase() -> None: """rules/R4, #604: case repair writes a particle in lowercase ('de la Vega') unless the vocabulary records a casing for it, as it does for the Irish particles. The property every shipped particle mask serves, derived from the pairs: `john <key> smith` puts the key in particle position, and repair spells it as the mask from either single case. The control is a particle with no mask, which keeps its lowercase. Both surfaces.""" lexicon = Lexicon.default() pairs = tuple((key, mask) for key, mask in lexicon.capitalization_exceptions if key in lexicon.particles) assert pairs # an empty selection would make the loop vacuous for key, mask in pairs + (("de", "de"),): for text in (f"john {key} smith", f"JOHN {key.upper()} SMITH"): assert parse(text).family.lower() == f"{key} smith", text assert parse(text).capitalized().family == f"{mask} Smith", text hn = HumanName(text) hn.capitalize() assert hn.last == f"{mask} Smith", text def test_a_caller_particle_mask_outranks_the_particle_lowercase() -> None: """rules/R4, #604, decided for CALLER maps too (decisions/R4, 2026-10-04): 1.4.0 through 2.3.0 kept a particle lowercase whatever the map held. The control is the same name with no entry. Both surfaces.""" lex = Lexicon.default() | Lexicon( capitalization_exceptions=(("van", "Van"),)) assert str(parse("ludwig van beethoven").capitalized()) == \ "Ludwig van Beethoven" assert str(parse("ludwig van beethoven").capitalized(lex)) == \ "Ludwig Van Beethoven" constants = Constants() constants.capitalization_exceptions["van"] = "Van" hn = HumanName("ludwig van beethoven", constants=constants) hn.capitalize(force=True) assert str(hn) == "Ludwig Van Beethoven" def test_a_listed_acronym_that_is_a_name_word_gets_no_mask() -> None: """decisions/vocabulary-exclusions, the CAPITALIZATION_EXCEPTIONS block (meng, edd, lac, ded): a mask applies in every role, so an acronym that is also a name word must not carry one. The fork it protects: in a NAME role the word repairs as a title-cased name word, and in the suffix role, with no mask, the acronym clause writes it in capitals. The recorded negative control is the mask added back, which re-spells the person. Since #540 `meng` and `lac` are ambiguous acronyms, so a bare trailing one after a lone given name is the FAMILY name (decisions/suffix-acronym-collisions): `wang meng` repairs to `Wang Meng`, where the credential reading gave `Wang MENG`. With words to spare the credential reading, and its capitals, stay. That puts the trailing slot on both sides of the fork, and the control shows the mask re-spelling the surname there too: `Wang MEng`, `Tran LAc`.""" for text, repaired in (("MENG LI", "Meng Li"), ("edd smith", "Edd Smith"), ("wang meng", "Wang Meng"), ("tran lac", "Tran Lac"), ("john smith meng", "John Smith MENG"), ("nguyen van lac", "Nguyen Van LAC"), ("john smith edd", "John Smith EDD")): assert str(parse(text).capitalized()) == repaired, text hn = HumanName(text) hn.capitalize() assert str(hn) == repaired, text default = Lexicon.default() masked = Parser(lexicon=dataclasses.replace( default, capitalization_exceptions=tuple( default.capitalization_exceptions) + (("meng", "MEng"), ("edd", "EdD"), ("lac", "LAc")))) for text, respelled in (("MENG LI", "MEng Li"), ("edd smith", "EdD Smith"), ("wang meng", "Wang MEng"), ("tran lac", "Tran LAc")): assert str(masked.capitalized(masked.parse(text))) == respelled def test_case_repair_falls_back_for_text_the_parse_never_read() -> None: """A token carrying UNCLASSIFIED_TAG holds raw text no parse read, so there is no decision to honor and case repair -- which is handed a lexicon -- asks the vocabulary instead, getting the answer the parser would have given, v1's initial carve-out included. ONE view falls back. `initials()` takes no lexicon, so it has none to ask; the sibling test below pins what that costs. """ p = Parser() base = p.parse("john smith") spliced = base.replace(family="velasquez y garcia") assert all(UNCLASSIFIED_TAG in t.tags for t in spliced.tokens[1:]) assert spliced.capitalized(force=True).family == "Velasquez y Garcia" # the carve-out rides along: an assigned middle initial is an # initial, not the Italian conjunction assert base.replace(middle="e.").capitalized(force=True).middle == "E." # the per-part particle question is NOT asked: re-deriving it needs # a reading on every word of the part and these words have none, so # it falls through to plain particle treatment assert base.replace(family="de la").capitalized(force=True).family == "de la" assert (base.replace(family="de la vega") .capitalized(force=True).family == "de la Vega") # ... and revise(), which classifies, crosses it assert p.revise(base, family="de la").capitalized(force=True).family == "De La" assert (p.revise(base, family="velasquez y garcia") .capitalized(force=True).family == "Velasquez y Garcia") # a token the parse DID see is decided by its tags, so a hyphenated # word it read as one ordinary name word stays one assert p.parse("juan e-f smith").capitalized(force=True).middle == "E-F" def test_the_mark_and_not_the_span_says_a_token_was_never_read() -> None: """`span is None` means SYNTHETIC, which is a wider set than unclassified, and keying the fallback on it was a measured regression (#463 review). `Parser.revise()` builds span-less tokens too, from a full sub-parse whose tags it keeps on purpose, and its docstring promises the tag-driven views "behave as if the text had been parsed". Under the span discriminator the fallback overrode exactly those tags: `revise(middle='e-f')` repaired to 'e-F' where the same words parsed gave 'E-F'. A HAND-BUILT span-less token is not marked either, so it takes the tag path like every other token in the library -- tag-driven semantics, not span-driven. """ p = Parser() base = p.parse("john smith") revised = p.revise(base, middle="e-f") assert [t.span for t in revised.tokens if t.role is Role.MIDDLE] == [None] assert UNCLASSIFIED_TAG not in revised.tokens_for(Role.MIDDLE)[0].tags # both sides of the pair the span discriminator split assert p.parse("john e-f smith").capitalized(force=True).middle == "E-F" assert revised.capitalized(force=True).middle == "E-F" # the initials half of the same regression, which needs a lexicon # holding no particles at all to witness -- under the default one # 'de la vega' is particle vocabulary and the parse skips it too. # segment_scripts off because a from-scratch Lexicon covers no # script the default policy activates, and the warning is an error # under this suite's filters. empty = Parser(lexicon=Lexicon(), policy=Policy(segment_scripts=frozenset())) assert (empty.parse("john de la vega").initials() == empty.revise(empty.parse("john smith"), family="de la vega").initials() == "j. d. l. v.") # hand-built, span-less, untagged: classified by default handbuilt = _pn("john de la vega", [ Token("john", None, Role.GIVEN), Token("de", None, Role.FAMILY), Token("la", None, Role.FAMILY), Token("vega", None, Role.FAMILY), ]) assert handbuilt.initials() == "j. d. l. v." assert handbuilt.capitalized(force=True).family == "de la Vega" # ... and the same tokens MARKED take the fallback, which is the # only thing that moves them marked = _pn("john de la vega", [ Token("john", None, Role.GIVEN), Token("de", None, Role.FAMILY), Token("la", None, Role.FAMILY), Token("y", None, Role.FAMILY, frozenset({UNCLASSIFIED_TAG})), Token("vega", None, Role.FAMILY), ]) assert marked.capitalized(force=True).family == "de la y Vega" def test_initials_has_no_lexicon_so_a_spliced_field_is_all_name_words()\ -> None: """The accepted cost of `initials()` taking no lexicon: a field spliced in as raw text has no reading, and this view has nothing to read one from, so every word of it initials. That disagrees with the facade and with the same name parsed, and it is a 2.0-core defect rather than a decision -- see #464, filed for giving `Parser` an `initials` crossing. A fallback to `Lexicon.default()` was written and dropped: it guesses a vocabulary, and under a caller's own the guess erases a whole field (`Lexicon.default().add(particles={'y'})`, family 'de y'). """ p = Parser() base = p.parse("john smith") assert base.replace(family="de la vega").initials() == "j. d. l. v." assert p.parse("john de la vega").initials() == "j. v." assert HumanName("john de la vega").initials() == "j. v." # the vocabulary a fallback would have had to guess, and the field # it erased when it guessed wrong y_lex = Lexicon.default().add(particles={"y"}) py = Parser(lexicon=y_lex) assert py.parse("Juan de y").initials() == "J. d. y." assert py.parse("Juan Perez").replace(family="de y").initials() == "J. d. y." def test_capitalized_rebuilds_ambiguity_tokens() -> None: tok = Token("van", Span(0, 3), Role.GIVEN, frozenset({"particle"})) pn = ParsedName( original="van johnson", tokens=(tok, Token("johnson", Span(4, 11), Role.FAMILY)), ambiguities=(Ambiguity(AmbiguityKind.PARTICLE_OR_GIVEN, "leading 'van' may be a particle", (tok,)),), ) out = pn.capitalized() # the ambiguity references the NEW capitalized token (subset invariant) assert out.ambiguities[0].tokens[0] is out.tokens[0] assert out.ambiguities[0].tokens[0].text == "Van" def test_render_and_initials_reject_non_str_arguments() -> None: # eager, like every constructor: not an AttributeError frames deep pn = _delavega() with pytest.raises(TypeError, match="spec must be a str"): pn.render(7) # type: ignore[arg-type] with pytest.raises(TypeError, match="spec must be a str"): pn.initials(7) # type: ignore[arg-type] with pytest.raises(TypeError, match="delimiter must be a str"): pn.initials(delimiter=None) # type: ignore[arg-type] with pytest.raises(TypeError, match="separator must be a str"): pn.initials(separator=0) # type: ignore[arg-type] def test_capitalized_rejects_non_lexicon_argument() -> None: # previously a silent no-op on mixed-case input and a deep # AttributeError on single-case input with pytest.raises(TypeError, match="must be a Lexicon"): _delavega().capitalized("garbage") # type: ignore[arg-type] with pytest.raises(TypeError, match="must be a Lexicon"): _lowercase_mac().capitalized({"titles": set()}) # type: ignore[arg-type] def test_initials_given_tokens_ignore_skip_tags() -> None: # documented: a given-name token contributes even when tagged (the # PARTICLE_OR_GIVEN case -- 'van' read as a given name) pn = _pn("van Johnson", [ Token("van", Span(0, 3), Role.GIVEN, frozenset({"particle"})), Token("Johnson", Span(4, 11), Role.FAMILY), ]) assert pn.initials() == "v. J." def test_render_malformed_specs_surface_raw_format_errors() -> None: # documented contract: only unknown KEYS get the enriched KeyError; # positional fields and bad conversions raise str.format's own error pn = _delavega() with pytest.raises(IndexError): pn.render("{}") with pytest.raises(ValueError): pn.render("{given!q}") def test_capitalized_one_case_connective_that_reads_as_an_initial() -> None: """#383/#479, the half no differential gate can see (decisions/R4). Repair lowercases a CONJUNCTION even inside a part it otherwise capitalizes (rules/R4's carve-out). Once a marked single letter in a one-case name is read as an INITIAL instead, that carve-out no longer reaches it and the letter capitalizes like any name word. The 'y' line is the control: it stays the connective, so it stays lowercase, and the two together show the carve-out itself is untouched. """ assert str(parse("john e jones").capitalized()) == "John E Jones" assert str(parse("john e smith").capitalized()) == "John E Smith" assert str(parse("juan garcia y lopez").capitalized()) \ == "Juan Garcia y Lopez" # R5's gate: mixed-case input is the writer's choice and repair # defers to it, so this one is not repaired at all assert str(parse("John e Smith").capitalized()) == "John e Smith" def test_facade_initials_follow_the_one_case_fork() -> None: """Both surfaces read the same letter the same way (#528). The core's initials() follows the parse's tags: in a name written wholly in one case an 'e' is an INITIAL and a 'y' is the connective (rules/P8), so R3's "each given, middle, and base family word" reaches the first and not the second. Until #528 HumanName.initials() re-derived that from the lexicon and the part's raw shape instead, and kept 1.4.0's answer on both letters; it now reads the same tags, so the two views of one parse agree. decisions/R3 records it. """ assert parse("john e smith").initials() == "j. e. s." assert HumanName("john e smith").initials() == "j. e. s." # #461 moved the VALUE and not the agreement: 'Y' holds the middle # part alone, so it initials on both surfaces -- which is also # 1.4.0's answer on this name, restored. The joined control below # is where the letter still drops, on both surfaces. assert parse("JUAN Y GARCIA").initials() == "J. Y. G." assert HumanName("JUAN Y GARCIA").initials() == "J. Y. G." assert parse("JUAN GARCIA Y LOPEZ").initials() == "J. G. L." assert HumanName("JUAN GARCIA Y LOPEZ").initials() == "J. G. L." # The mixed-case controls, where the writing decides the letter and # nothing moved on either surface assert HumanName("John E Smith").initials() == "J. E. S." assert HumanName("Juan Y. Garcia").initials() == "J. Y. G." # 'maria y lopez' is a ONE-CASE control, not a mixed-case one: written # wholly in lowercase, its 'y' is outside the marked set (rules/P8), # so it stays the CONNECTIVE on both surfaces rather than reading as # an initial the way 'e' does. What it no longer # witnesses is the DROP: #461 gave the letter its initial back here, # because it holds the middle part alone and so joins nothing # (decisions/R3). The joining one-case control below is where # a lowercase 'y' still drops, which is what keeps this row's point # -- the tag, not the value -- observable. assert HumanName("maria y lopez").initials() == "m. y. l." assert parse("maria y lopez").initials() == "m. y. l." assert HumanName("juan garcia y lopez").initials() == "j. g. l." assert parse("juan garcia y lopez").initials() == "j. g. l." # The GIVEN group, where #461 dropped the facade's own blanket # exemption as well as the core's: a connective among given names # is joining there like anywhere else, so it contributes nothing # on BOTH surfaces. Without the facade half these read 'J. a. J. # S.' and 'D. o. E.' while the core reads them as below, which is # the disagreement the one rule exists to prevent. assert parse("John and Jane Smith").initials() == "J. J. S." assert HumanName("John and Jane Smith").initials() == "J. J. S." assert parse("Duke of Edinburgh").initials() == "D. E." assert HumanName("Duke of Edinburgh").initials() == "D. E." assert parse("John & Jane").initials() == "J. J." assert HumanName("John & Jane").initials() == "J. J." # The one corpus name where the two views still differ, and it is # not this rule's: the facade merges 'Ph.' + 'D.' into one list # element and renders it with no inner delimiter # (fix(initials-per-word) the Ph. D. merge, decisions/phd-merge) assert HumanName("Ph. D., John").initials() == "J. P D." assert parse("Ph. D., John").initials() == "J. P. D." def test_initials_separator_is_honored_on_the_live_token_path() -> None: # tests/test_initials.py's two "Van Berg" separator tests call # _process_initial("Van Berg", firstname=True) directly -- v1's # string path, with tokens=None -- which #528 kept working but no # longer the path HumanName.initials() itself takes. This pins # initials_separator on the live TOKEN path (tokens= passed by # _initials_lists), so a regression that reads initials_separator # only on the string branch would pass those two tests and fail # here. Measured. assert HumanName("Ph. D., John", initials_separator="-").initials() \ == "J. P-D." assert HumanName("Ph. D., John", initials_separator="").initials() \ == "J. PD." assert HumanName("Ph. D., John", initials_separator="") \ .initials_list() == ["J", "PD"] def test_a_caps_credential_after_a_comma_keeps_its_capitals_in_every_setting( ) -> None: # A credential read by its capitals (S6) repairs to its all-caps # spelling (rules/R4). #564 made the comma reading a default # decided in `segment` from the text, and classify's shape tag -- # what repair reads -- has to follow it there too, or the default # title-cases a credential EVERYWHERE keeps: the first draft gave # 'John Smith Xyz' at the default and 'John Smith XYZ' under # EVERYWHERE. Forced, since R5 leaves a mixed-case name alone. for setting in (CapsSuffixes.AFTER_COMMA, CapsSuffixes.EVERYWHERE): parser = Parser(policy=Policy(unlisted_caps_suffixes=setting)) for text, rendered in (("John Smith, XYZ", "John Smith XYZ"), ("John Smith, LEED AP", "John Smith LEED AP"), ("John Smith, PhD XYZ", "John Smith PhD XYZ")): name = parser.parse(text) assert str(name.capitalized(force=True)) == rendered, (setting, text) def test_a_marked_particle_lowercases_only_where_the_particle_arm_would( ) -> None: """rules/R4, #700: a particle written with its mark ("d'", "al-", "'t") is asked the particle arm's question as a whole token, since the per-word walk splits it at the mark and asks the stem. Only the arm's lowercase is taken whole; wherever the arm declines, the token repairs by the walk exactly as before. The first version sent every whole-token particle through the rest of _cap_word, and the full suite passed it: 'ED-' became 'Ed-' and a configured 'van-der' became 'Van-der' (the #700 fix-commit review). Both surfaces for the lowercase; the declines on the core.""" lowered = { "IVOI, PAUL D'": "Paul d' Ivoi", "rashid, harun al-": "Harun al- Rashid", "jacobus henricus van 't hoff": "Jacobus Henricus van 't Hoff", } for text, expected in lowered.items(): assert str(parse(text).capitalized()) == expected, text hn = HumanName(text) hn.capitalize(force=True) assert str(hn) == expected, text declined = { # an all-particle part: repaired as ordinary name words ", PAUL D'": "Paul D'", "HARUN AL-": "Harun Al-", # the suffix role: the walk's acronym clause reads the stem "smith, john, ed-": "John Smith ED-", } for text, expected in declined.items(): assert str(parse(text).capitalized()) == expected, text given_last = Parser(policy=Policy(name_order=FAMILY_FIRST_GIVEN_LAST)) name = given_last.parse("IVOI PAUL D'") assert name.given == "D'" # the declared given slot, not P6's assert name.capitalized().given == "D'" hyphened = Parser(lexicon=Lexicon.default().add(particles={"van-der"})) assert str(hyphened.capitalized(hyphened.parse("jan van-der"))) == \ "Jan Van-Der" @pytest.mark.parametrize("text", [ "john smith, PHARM D", "john smith PHARM D", "smith, john PHARM D", "JOHN SMITH, PHARM D", "JOHN SMITH PHARM D", "john smith pharm d"]) def test_a_split_credential_repairs_as_its_one_word_twin(text: str) -> None: # rules/S12's pair is repaired by its fold's mask ('pharmd' -> # 'PharmD') whatever the comma shape, as 'PHARMD' is; the first # version marked its capitals only where the comma's decision bound # it, so the same words repaired two ways (#752's review) assert parse(text).capitalized().suffix == "Pharm D" twin = text.replace("PHARM D", "PHARMD").replace("pharm d", "pharmd") assert parse(twin).capitalized().suffix == "PharmD" def test_a_split_credential_repair_reads_the_pair_group_merged() -> None: # #752's second review: overlapping pairs ('MB CHB' and 'CHB A', # 'chba' being listed) were both repaired, the second writing over # the first; and a pair in two cases is kept as written unforced, # as its one-word spelling is assert parse("JOHN SMITH MB CHB A").capitalized().suffix == "MB ChB A" assert parse("john smith pharm D").capitalized().suffix == "pharm D" assert parse("john smith pharmD").capitalized().suffix == "pharmD" assert (parse("john smith pharm D").capitalized(force=True).suffix == "Pharm D") @pytest.mark.parametrize("text", [ "JEAN DUPONT FILS", "jean dupont fils", "DUMAS, ALEXANDRE FILS", "MARIE FILS", "pedro alves filho", "PEDRO ALVES NETO"]) def test_repairing_a_generation_word_that_is_also_a_name_keeps_its_reading( text: str) -> None: # rules/S6 reads a generation word's case: Title case leans `fils` # to the name, so a suffix `fils` repairs to lower case, the one # writing that keeps the reading, and a family `Fils` to Title case; # an agnomen's case says nothing and it title-cases as any word does name = parse(text) repaired = name.capitalized() assert parse(str(repaired)).as_dict() == repaired.as_dict() �������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_reprs.py��������������������������������������������0000664�0000000�0000000�00000016646�15273032717�0023161�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������import dataclasses import functools from typing import cast import pytest from nameparser._lexicon import Lexicon from nameparser._locale import Locale from nameparser._parser import Parser, parse from nameparser._policy import FAMILY_FIRST, PatronymicRule, Policy, PolicyPatch from nameparser._types import ( Ambiguity, AmbiguityKind, ParsedName, Role, Segmentation, Span, Token, ) def _decline(text: str, blob: str = "") -> None: return None # a stand-in segmenter for the Parser repr cases def test_token_repr_is_compact() -> None: t = Token("de", Span(9, 11), Role.FAMILY, frozenset({"particle"})) assert repr(t) == "Token('de' @9:11 FAMILY {particle})" assert repr(Token("Jane", None, Role.GIVEN)) == "Token('Jane' @synthetic GIVEN)" def test_ambiguity_repr_shows_kind_and_token_texts() -> None: van = Token("Van", Span(0, 3), Role.GIVEN) a = Ambiguity(AmbiguityKind.PARTICLE_OR_GIVEN, "detail", (van,)) assert repr(a) == "Ambiguity('particle-or-given': 'Van')" def test_reading_repr_shows_kind_field_and_source() -> None: alt = parse("JOHN SMITH MA").ambiguities[0].alternative assert repr(alt) == "Reading('name' as 'family', 'declared')" def test_parsedname_repr_lists_nonempty_fields_in_canonical_order() -> None: pn = ParsedName("John Smith", ( Token("John", Span(0, 4), Role.GIVEN), Token("Smith", Span(5, 10), Role.FAMILY), )) assert repr(pn) == ( "<ParsedName: [\n given: 'John'\n family: 'Smith'\n]>" ) def test_parsedname_repr_includes_ambiguities_line_when_present() -> None: van = Token("Van", Span(0, 3), Role.GIVEN) pn = ParsedName("Van Johnson", (van, Token("Johnson", Span(4, 11), Role.FAMILY)), (Ambiguity(AmbiguityKind.PARTICLE_OR_GIVEN, "d", (van,)),)) assert "ambiguities: ['particle-or-given: Van']" in repr(pn) def test_parsedname_repr_distinguishes_same_kind_ambiguities_by_token() -> None: # #431: two distinct suffix-or-name ambiguities about different # words must not render as identical list entries. ma = Token("MA", Span(11, 13), Role.SUFFIX) v = Token("V", Span(14, 15), Role.SUFFIX) pn = ParsedName( "John Smith MA V", (Token("John", Span(0, 4), Role.GIVEN), Token("Smith", Span(5, 10), Role.FAMILY), ma, v), (Ambiguity(AmbiguityKind.SUFFIX_OR_NAME, "d1", (ma,)), Ambiguity(AmbiguityKind.SUFFIX_OR_NAME, "d2", (v,)))) assert "ambiguities: ['suffix-or-name: MA', 'suffix-or-name: V']" in repr(pn) def test_empty_parsedname_repr() -> None: assert repr(ParsedName("", ())) == "<ParsedName: []>" def test_policy_repr_shows_only_nondefault_fields() -> None: assert repr(Policy()) == "Policy()" p = Policy(name_order=FAMILY_FIRST, strip_bidi=False) assert repr(p) == "Policy(name_order=FAMILY_FIRST, strip_bidi=False)" def test_lexicon_repr_is_bounded() -> None: assert repr(Lexicon.default()) == "Lexicon(default)" lex = Lexicon.default().add(titles={"zqx", "zqy"}) assert repr(lex) == "Lexicon(default + titles: +2)" assert "zqx" not in repr(lex) # never dump contents def test_locale_repr_shows_code_and_patched_fields() -> None: ru = Locale("ru", Lexicon.empty(), PolicyPatch(patronymic_rules=frozenset( {PatronymicRule.EAST_SLAVIC}))) assert repr(ru) == "Locale('ru': patronymic_rules)" assert repr(Locale("xx", Lexicon.empty())) == "Locale('xx')" def test_lexicon_repr_diffs_against_nearer_baseline() -> None: # An empty()-built Lexicon must not render as default() minus ~700 # entries -- diff against whichever named constructor is nearer. assert repr(Lexicon.empty()) == "Lexicon(empty)" lex = Lexicon.empty().add(titles={"zqx"}) assert repr(lex) == "Lexicon(empty + titles: +1)" def test_policy_patch_repr_shows_only_set_fields() -> None: assert repr(PolicyPatch()) == "PolicyPatch()" assert repr(PolicyPatch(middle_as_family=True)) == \ "PolicyPatch(middle_as_family=True)" assert repr(PolicyPatch(name_order=FAMILY_FIRST)) == \ "PolicyPatch(name_order=FAMILY_FIRST)" @pytest.mark.parametrize("obj", [ Policy(), Policy(middle_as_family=True), PolicyPatch(), PolicyPatch(strip_emoji=False), Locale("xx", Lexicon.empty()), Locale("xx", Lexicon.empty(), PolicyPatch(middle_as_family=True)), Lexicon.default(), Lexicon.empty(), Parser(), Parser(segmenter=functools.partial(_decline, blob="x" * 500)), ]) def test_config_reprs_never_leak_the_unset_sentinel(obj: object) -> None: # Guard the whole family: every config type's repr is bounded and # deviation-only; the UNSET sentinel must never appear. assert "UNSET" not in repr(obj) assert "_Unset" not in repr(obj) def test_parser_repr_names_the_segmenter_without_its_contents() -> None: # A callable is the one component whose own repr is unbounded: a # partial spells out its bound arguments, a callable instance its # memory address. Neither may reach Parser's repr, so the name comes # from __qualname__ when there is one and the TYPE otherwise. plain = repr(Parser(segmenter=_decline)) assert "segmenter=_decline" in plain partial = repr(Parser(segmenter=functools.partial(_decline, blob="x" * 500))) assert "segmenter=partial" in partial assert "xxx" not in partial and len(partial) < len(plain) + 20 class Div: # a callable INSTANCE: no __qualname__, so the type def __call__(self, text: str) -> Segmentation | None: return None instance = repr(Parser(segmenter=Div())) assert "segmenter=Div" in instance and "0x" not in instance def test_policy_patch_repr_survives_unvalidated_name_order() -> None: # PolicyPatch defers name_order validation to apply time, so a # patch can hold a non-canonical or even garbage tuple -- its repr # must describe it, never raise. patch = PolicyPatch(name_order=(Role.GIVEN, Role.FAMILY, Role.MIDDLE)) assert repr(patch) == "PolicyPatch(name_order=(GIVEN, FAMILY, MIDDLE))" garbage = PolicyPatch(name_order=("given", "family", "middle")) # type: ignore[arg-type] assert "given" in repr(garbage) # described, not crashed unhashable = PolicyPatch(name_order=([1], "y", "z")) # type: ignore[arg-type] repr(unhashable) # must not raise def test_order_repr_survives_smuggled_setstate_values() -> None: # __setstate__ validates layout, not values (the pickling rule), so # the reprs must survive shapes no constructor can produce: an # all-Role list (passes an element check, unhashable), a # non-iterable, and a tuple of Role-EQUAL plain strings (StrEnum # equality would hit the named lookup and lie). for garbage in ([Role.GIVEN, Role.MIDDLE, Role.FAMILY], 5, ("given", "middle", "family")): for cls in (Policy, PolicyPatch): obj = cls() state = {f.name: getattr(obj, f.name) for f in dataclasses.fields(obj)} state["name_order"] = garbage smuggled = cast("Policy | PolicyPatch", cls.__new__(cls)) smuggled.__setstate__(state) rendered = repr(smuggled) # must not raise if isinstance(garbage, tuple): # a smuggled string tuple must not render as the # named constant it merely compares equal to assert "GIVEN_FIRST" not in rendered ������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_rules_budget.py�������������������������������������0000664�0000000�0000000�00000014442�15273032717�0024502�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Holds every rule to its recorded size (tests/v2/rules_budget.py).""" from __future__ import annotations from pathlib import Path import pytest from tests.v2 import rules_budget from tests.v2.design_docs import read_rules_text from tests.v2.rules_doc import parse_rules_doc def test_every_rule_matches_its_recorded_size() -> None: record = rules_budget.read_record() # Not vacuous: an empty record compared with an empty measure would # pass, so the record must hold every rule the doc parses. assert set(record) == { r.rule_id for r in parse_rules_doc(read_rules_text())} found = rules_budget.changes(record, rules_budget.measure(read_rules_text())) assert not found, "\n".join([ *found, "A rule's size moved against tests/v2/rules_budget.json. The " "record is not a cap: if the change is intended, re-record with " f"`{rules_budget.RECORD_COMMAND}` and say so in the PR -- for " "growth, name what each new clause reads (docs/design/AGENTS.md, " "axis 12); a clause reading a signal the rule did not already " "read is a new rule."]) def _row(**moved: int) -> dict[str, int]: row = dict.fromkeys(rules_budget.MEASURES, 0) return {**row, **moved} def test_changes_name_each_move_and_each_unmatched_id() -> None: record = {"X1": _row(words=8, accepted=1), "X3": _row(words=1)} now = {"X1": _row(words=9), "X2": _row(words=2)} assert rules_budget.changes(record, now) == [ "X1: words grew, 8 -> 9", "X1: accepted shrank, 1 -> 0", "X2: no record -- a new rule is recorded too", "X3: recorded, but no rule has that ID", ] assert rules_budget.changes(now, now) == [] def test_measure_orders_rules_by_number() -> None: doc = (_SKELETON_DOC.replace("# X1 —", "# X10 —") + "\n" + _SKELETON_DOC.replace("# X1 —", "# X2 —")) assert list(rules_budget.measure(doc)) == ["X2", "X10"] def test_measure_refuses_text_the_doc_tests_reject() -> None: with pytest.raises(ValueError, match="X1"): rules_budget.measure(_SKELETON_DOC.replace("**Because:**", "Because:")) def test_changes_report_a_measure_the_record_lacks_or_no_longer_has() -> None: now = {"X1": _row(words=1)} assert rules_budget.changes({"X1": {"words": 1}}, now) == [ f"X1: {key} grew, None -> 0" for key in rules_budget.MEASURES if key != "words"] # a measure the record holds and the tree no longer takes (a # block-format row's `exceptions`, #761) is named, never ignored stale = {"X1": {**now["X1"], "exceptions": 3}} assert rules_budget.changes(stale, now) == [ "X1: recorded 'exceptions', which is no longer measured"] @pytest.mark.parametrize("text, match", [ ('{"X1": {"words": 1}, "X1": {"words": 2}}', "twice"), ('{"X1": {"words": "2"}}', "not counts"), ('{"X1": {"words": true}}', "not counts"), ('{"X1": 3}', "not counts"), ]) def test_a_record_that_would_compare_wrongly_is_refused( tmp_path: Path, text: str, match: str) -> None: path = tmp_path / "record.json" path.write_text(text, encoding="utf-8") with pytest.raises(ValueError, match=match): rules_budget.read_record(path) def test_the_command_the_failure_message_offers_runs( tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: # The failure message hands the reader RECORD_COMMAND, so the code # behind it is exercised end to end against a scratch record. record = tmp_path / "rules_budget.json" monkeypatch.setattr(rules_budget, "RECORD", record) assert rules_budget.main([]) == 1 # no record yet assert rules_budget.main(["--record"]) == 0 assert set(rules_budget.read_record(record)) == { r.rule_id for r in parse_rules_doc(read_rules_text())} assert rules_budget.main([]) == 0 record.write_text(record.read_text().replace( '"words": ', '"words": 1', 1)) assert rules_budget.main([]) == 1 _SKELETON_DOC = """\ # X1 — one **When:** a name ends in a thing. **Then:** one two three four five six. - seven eight nine ten. **Because:** Because one. Because two. ``` "John Smith III" → suffix="III" ``` **Except:** - **First.** ``` "John V" → family="V" · boundary ``` - **Second.** ``` "John W" → family="W" · boundary ``` **Precedence:** - **Before a comma**: C1 decides. **Accepted:** - one two three four five. --- **Signal:** a shape **history:** decisions/P2 · **implemented:** nameparser/_parser.py """ def test_the_measure_reads_a_skeleton_rule() -> None: assert rules_budget.measure(_SKELETON_DOC) == { "X1": {"words": 27, "then_words": 10, "because_sentences": 2, "except": 2, "precedence": 1, "accepted": 1, "accepted_words": 5}} @pytest.mark.parametrize("old, new", [ ("- **First.**", "- **First.** And three more words."), ("**When:** a name ends in a thing.", "**When:** a name ends in a thing, written so."), ("**Because:** Because one.", "**Because:** Because one more."), ("- **Before a comma**: C1 decides.", "- **Before a comma**: C1 decides it."), ("**Then:**", "**Tolerated:** no convention writes it.\n\n**Then:**"), ]) def test_words_added_to_any_quotable_field_move_the_row( old: str, new: str) -> None: assert old in _SKELETON_DOC before = rules_budget.measure(_SKELETON_DOC) after = rules_budget.measure(_SKELETON_DOC.replace(old, new, 1)) assert rules_budget.changes(before, after) assert after["X1"]["words"] > before["X1"]["words"] def test_a_field_added_to_the_quotable_parts_moves_words( monkeypatch: pytest.MonkeyPatch) -> None: # words is read off rules_skeleton.skeleton_parts, the one list # skeleton_prose quotes from, so a field added there is counted # without a second edit here (#768) from tests.v2 import rules_skeleton before = rules_budget.measure(_SKELETON_DOC)["X1"] parts = rules_skeleton.skeleton_parts monkeypatch.setattr(rules_skeleton, "skeleton_parts", lambda sk: [ *parts(sk), ("Extra", "one two three")]) after = rules_budget.measure(_SKELETON_DOC)["X1"] assert after["words"] == before["words"] + 3 assert after["accepted_words"] == before["accepted_words"] ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_rules_doc.py����������������������������������������0000664�0000000�0000000�00000027764�15273032717�0024010�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Executes every example line in docs/design/rules/. Wrong-on-arrival defense: each example runs against the live parser. ``deviates:`` examples assert TODAY's output (strict, like an xfail) and additionally that intended != today, so a fixed bug forces the marker out in the same PR. """ from __future__ import annotations import importlib.util import re import pytest from nameparser import Parser, parse, parser_for from nameparser import locales from nameparser._policy import Policy from tests.v2.design_docs import RULES_DIR, read_rules_text from tests.v2.rules_doc import ( Example, Rule, parse_rules_doc, resolve_annotation) RULES = parse_rules_doc(read_rules_text()) def test_doc_has_rules() -> None: assert RULES, "docs/design/rules/ holds no rules" @pytest.mark.parametrize("rule", RULES, ids=lambda r: r.rule_id) def test_every_rule_has_examples_and_boundary(rule: Rule) -> None: assert rule.examples, f"{rule.rule_id} has no examples" assert rule.has_boundary_or_waiver(), ( f"{rule.rule_id}: add a '· boundary' example or an explicit " f"'no-boundary: <reason>'") @pytest.mark.parametrize("rule", RULES, ids=lambda r: r.rule_id) def test_every_rule_points_at_code_or_at_the_work(rule: Rule) -> None: """A normative rule names the code honoring it, or the issues that would ship it -- never neither, so an unimplemented rule cannot sit in the doc untracked, and never both. Scope, precisely: this test reads the DOC only, so it cannot tell that a tracked: rule has since been implemented -- adding the code and its citation while leaving tracked: in place passes here. That half is test_doc_citations.py::test_implemented_matches_citing_ modules, which sees the citing modules. Neither test alone makes a stale pointer unrepresentable; the pair does. """ assert rule.implemented or rule.tracked, ( f"{rule.rule_id}: add 'implemented: <path>' or, if nothing " f"implements it yet, 'tracked: #N' naming the issues that would") assert not (rule.implemented and rule.tracked), ( f"{rule.rule_id}: has both implemented: and tracked:; drop " f"tracked: once the rule ships") bad = [t for t in rule.tracked if not re.fullmatch(r"#\d+", t)] assert not bad, f"{rule.rule_id}: tracked: wants #N issue refs, got {bad}" @pytest.mark.parametrize("rule", RULES, ids=lambda r: r.rule_id) def test_every_rule_states_its_signal(rule: Rule) -> None: """What in the written name the rule reads, on its ``**Signal:**`` line (docs/design/AGENTS.md, "One rule per piece of evidence"). A hub also names the rules stating the signals it combines, after any signal it reads itself, which test_doc_citations.py::test_a_hub_names_each_rule_its_map_names holds to its map.""" assert rule.signal is not None, ( f"{rule.rule_id}: add a `**Signal:** <what it reads>` line in " f"the footer below `---`, directly above the pointer line -- for " f"example a count, a case contrast, a shape, a neighbor, a " f"position -- or `**Signal:** hub of X1, X2` (after any signal of " f"its own: `**Signal:** <its own>; hub of X1, X2`) for a hub") #: The README's recipe for the split candidates, verbatim; in grep's #: basic syntax "+" is a literal plus. _SPLIT_GREP = r"grep -lE '^\*\*Signal:\*\*.* \+ ' docs/design/rules/*.md" #: _SPLIT_GREP's pattern in Python's syntax. _SPLIT_PATTERN = r"^\*\*Signal:\*\*.* \+ " def test_the_readmes_worked_example_is_the_rule_it_copies() -> None: """The README shows rules/D2 whole as the skeleton format's worked example (its first ````markdown block); a copy nothing holds to the file drifts the first time D2 is edited.""" readme = (RULES_DIR / "README.md").read_text(encoding="utf-8") m = re.search(r"^````markdown\n(.*?)^````$", readme, re.S | re.M) assert m is not None, "rules/README.md lost its ````markdown example" (d2,) = RULES_DIR.glob("D2-*.md") assert m.group(1) == d2.read_text(encoding="utf-8"), ( "rules/README.md's worked example differs from rules/D2; " "copy the file into the README again") def test_the_split_candidate_recipe_lists_the_two_signal_rules() -> None: readme = (RULES_DIR / "README.md").read_text(encoding="utf-8") assert f"`{_SPLIT_GREP}`" in readme, ( "rules/README.md's split-candidate grep changed; update " "_SPLIT_GREP and the translation below together") listed = {f.name.split("-")[0] for f in RULES_DIR.glob("*.md") if re.search(_SPLIT_PATTERN, f.read_text(encoding="utf-8"), re.M)} assert listed == {r.rule_id for r in RULES if r.signal and " + " in r.signal} # The tree may hold a single candidate, and the comparison above # would then pass for a recipe that only ever lists that one rule, # so the two halves are also held to a synthetic rule of each kind. for signal, candidate in (("a shape: x + a count: y", True), ("a shape: x", False)): skeleton = ("# X1 — one\n\n**When:** w.\n\n**Then:** t.\n\n" "**Because:** b.\n\n---\n" f"**Signal:** {signal}\n" "**history:** decisions/P2 · **implemented:** " "nameparser/_parser.py\n") (rule,) = parse_rules_doc(skeleton) assert rule.signal == signal assert (" + " in rule.signal) is candidate assert bool(re.search(_SPLIT_PATTERN, skeleton, re.M)) is candidate def signal_tag_findings(readme: str, signals: list[str]) -> tuple[set[str], set[str]]: """The tags the signals use that the README's list leaves out, and the listed tags no signal uses. A tag is a signal's text before its colon, each side of a ` + `, a hub's list left off.""" m = re.search(r"The tags the rules use are (?P<list>[^.]+)\. Two kinds " r"of rule carry none of them: (?P<rest>.+?)\.(?= )", readme) assert m is not None, "rules/README.md lost its signal tag list" listed = set(re.split(r", | and ", m.group("list"))) listed |= set(re.findall(r"`([^`]+)`", m.group("rest"))) used = set() for signal in signals: own = re.sub(r"(?:^|; )hub of .*$", "", signal) used |= {part.split(":")[0] for part in own.split(" + ") if part} return used - listed, listed - used def test_the_readme_lists_every_signal_tag_the_rules_use() -> None: """The README's tag list against the footers, both ways (#761 found `character`, `separator` and `edge full stop` missing).""" readme = (RULES_DIR / "README.md").read_text(encoding="utf-8") signals = [r.signal for r in RULES if r.signal is not None] missing, unused = signal_tag_findings(readme, signals) assert missing == set(), "add these tags to rules/README.md" assert unused == set(), "no rule writes these listed tags" _TAGS = ("The tags the rules use are shape, count and none in the name. " "Two kinds of rule carry none of them: A1 writes `none of its " "own`. More.") @pytest.mark.parametrize("signals, missing, unused", [ (["shape: x", "count + shape", "none in the name: y", "none of its own: z", "hub of C3"], set(), set()), (["shape", "count", "none in the name", "none of its own", "script: x"], {"script"}, set()), (["shape", "none in the name", "none of its own"], set(), {"count"}), (["shape", "count", "none in the name", "position; hub of C3"], {"position"}, {"none of its own"}), ]) def test_signal_tag_findings_run_both_ways( signals: list[str], missing: set[str], unused: set[str]) -> None: assert signal_tag_findings(_TAGS, signals) == (missing, unused) def _check_diagnostic(example: Example) -> None: import re as _re from collections.abc import Callable from typing import cast from tests.v2.rules_doc import SUBJECTS fn = cast(Callable[[], object], SUBJECTS[example.subject or ""]) pattern = _re.escape(str(example.value)) if example.field == "warns": with pytest.warns(UserWarning, match=pattern): fn() else: with pytest.raises((TypeError, ValueError), match=pattern): fn() def _pieces(text: str, policy: Policy | None) -> list[list[str]]: from nameparser._pipeline import run from nameparser._pipeline._state import ParseState from nameparser._parser import Parser as _P p = _P(policy=policy) if policy is not None else _P() state = run(ParseState(original=text, lexicon=p.lexicon, policy=p.policy, segmenter=None)) return [[state.tokens[i].text for i in piece] for seg in state.pieces for piece in seg] def _run(example: Example) -> object: policy: Policy | None = None locale: str | None = None if example.annotation is not None: kind, obj = resolve_annotation(example.annotation) if kind == "policy": assert isinstance(obj, Policy) policy = obj elif kind == "locale": assert isinstance(obj, str) assert obj != "ja", ( "ja needs the optional segmenter: use the " "[ja+segmenter] gate so CI without the extra skips it") locale = obj elif kind == "gated_locale": if importlib.util.find_spec("namedivider") is None: pytest.skip("optional extra absent; the ja-extra CI job " "runs this") assert isinstance(obj, str) locale = obj if example.field == "pieces": return _pieces(example.text, policy) if locale is not None: parsed = parser_for(locales.get(locale)).parse(example.text) elif policy is not None: parsed = Parser(policy=policy).parse(example.text) else: parsed = parse(example.text) if example.field == "ambiguities": return tuple(a.kind.value for a in parsed.ambiguities) if example.field == "initials": return parsed.initials() if example.field == "capitalized": return str(parsed.capitalized()) # R5: the gate returns mixed-case input untouched, so an example # asserting what the REPAIR does must ask for it regardless if example.field == "capitalized_forced": return str(parsed.capitalized(force=True)) return getattr(parsed, example.field) _EXAMPLES = [(r, e) for r in RULES for e in r.examples] _EXAMPLE_IDS = [f"{r.rule_id}-{i}" for r in RULES for i, _ in enumerate(r.examples)] @pytest.mark.parametrize("rule, example", _EXAMPLES, ids=_EXAMPLE_IDS) def test_example(rule: Rule, example: Example) -> None: if example.field in ("warns", "raises"): _check_diagnostic(example) return actual = _run(example) if example.deviates_issue is not None: assert actual == example.today_value, ( f"{rule.rule_id}: deviation #{example.deviates_issue} moved -- " f"update or remove the marker in the same PR") assert example.value != example.today_value, ( f"{rule.rule_id}: intended equals today -- remove the marker") else: assert actual == example.value @pytest.mark.parametrize("decomposed", [ "inž.", # H2's abbreviation shape (#652): z + U+030C "John й Smith", # P3's one-letter connective (#665) "É. y É. Smith", # P3's initial beside a connective (#665) "김. 민준", # H2's script veto on hangul typed as jamo (#665) ]) def test_the_nfd_examples_are_still_decomposed(decomposed: str) -> None: """The decomposed example lines in the rules are the only executable witnesses of its encoding clauses (H2, P3), and an editor or formatter that NFC-normalizes the file turns each into the composed case, which always worked and still passes. Each entry here is typed decomposed, which the first assert checks so that normalizing this file cannot hollow the test out either.""" import unicodedata assert not unicodedata.is_normalized("NFC", decomposed) text = read_rules_text() assert decomposed in text, f"the NFD example {decomposed!r} was recomposed" ������������derek73-python-nameparser-456b024/tests/v2/test_rules_doc_grammar.py��������������������������������0000664�0000000�0000000�00000023314�15273032717�0025501�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Unit tests for rules_doc.py: the example-line grammar, the annotation registry, and the split of the rules text into rules and Backgrounds. The fixtures are SYNTHETIC: they exercise the grammar and track no real rule, so do not "fix" them when docs/design/rules/ changes. The rule format itself is test_rules_skeleton.py's. """ from __future__ import annotations import pytest from tests.v2.rules_doc import ( Example, parse_example_line, parse_rules_doc, prose_blocks, resolve_annotation) def _rule(rid: str, fence: str = '"a b" → family="b" · boundary') -> str: return (f"# {rid} — one\n\n**When:** w.\n\n**Then:** t.\n\n" f"**Because:** b.\n\n```\n{fence}\n```\n\n---\n" "**Signal:** a shape\n" "**history:** decisions/P2 · **implemented:** " "nameparser/_parser.py\n\n") def test_an_example_line_parses_to_its_fields() -> None: assert parse_example_line( '"de la Vega" → family="de la Vega"', "X1", 1) == Example( text="de la Vega", annotation=None, field="family", value="de la Vega", boundary=False, deviates_issue=None, today_value=None) def test_annotation_and_boundary_flags() -> None: ex = parse_example_line( '"Mesnil de" family-first → family="Mesnil" · boundary', "X1", 1) assert ex is not None assert ex.annotation == "family-first" assert ex.boundary is True def test_deviates_marker() -> None: ex = parse_example_line( '"Swami Vivekananda" → family="" deviates: #346 ' '(today: family="Vivekananda")', "X1", 1) assert ex is not None assert ex.deviates_issue == 346 assert ex.value == "" assert ex.today_value == "Vivekananda" def test_nested_pieces_value_parses() -> None: ex = parse_example_line( '"de Mesnil" family-first → pieces=[["de"], ["Mesnil"]]', "X1", 1) assert ex is not None assert ex.field == "pieces" assert ex.value == [["de"], ["Mesnil"]] def test_a_subject_example_parses() -> None: ex = parse_example_line('[bad-name-order] → raises="name_order"', "D2", 1) assert ex is not None assert ex.subject == "bad-name-order" and ex.text == "" def test_prose_is_no_example() -> None: assert parse_example_line("the thing is read.", "X1", 1) is None @pytest.mark.parametrize("line, match", [ pytest.param('"input" -> family="x"', "does not parse", id="ascii-arrow"), pytest.param("“input” → family=\"x\"", "does not parse", id="curly-quotes"), pytest.param('"a" → colour="x"', "not assertable", id="unknown-field"), pytest.param('"a" → family="x" deviates: #1 (today: given="y")', "differs from the asserted field", id="deviates-field"), ]) def test_a_malformed_example_line_is_an_error(line: str, match: str) -> None: with pytest.raises(ValueError, match=match) as ei: parse_example_line(line, "X1", 7) assert str(ei.value).startswith("X1: line 7") def test_registry_resolves_policies_and_gates() -> None: kind, obj = resolve_annotation("family-first") assert kind == "policy" kind, obj = resolve_annotation("[ru]") assert kind == "locale" and obj == "ru" kind, obj = resolve_annotation("[ja+segmenter]") assert kind == "gated_locale" and obj == "ja" with pytest.raises(KeyError): resolve_annotation("no-such-annotation") _SECTION = "## A section (X)\n\nBackground: a section's words.\nWrapped.\n\n" def test_the_text_splits_into_rules_and_backgrounds() -> None: doc = _SECTION + _rule("X1") + _rule("X2") assert [r.rule_id for r in parse_rules_doc(doc)] == ["X1", "X2"] prose = prose_blocks(doc) assert prose["X"] == ["Background: a section's words.", "Wrapped."] assert prose["X1"] == ["w.", "t.", "b."] def test_a_duplicate_rule_id_is_an_error() -> None: with pytest.raises(ValueError, match="duplicate rule ID X1"): parse_rules_doc(_rule("X1") + _rule("X1")) #: A rule in the retired block format, read as nothing, would never run #: its examples, so each placement raises naming the format (#761). @pytest.mark.parametrize("doc", [ pytest.param('X1. Statement.\n "a b" → family="b"\n', id="outside-any-heading"), pytest.param(_SECTION + 'X1. Rationale: r.\n Statement.\n', id="after-a-background"), pytest.param('# X1 — one\n\nX1. Statement.\n "a b" → family="b"\n', id="under-a-rule-heading"), # inside a field, where it would have joined the prose (review) pytest.param(_rule("X1").replace("**Because:** b.\n", "**Because:** b.\n\nX2. Rationale: r.\n"), id="inside-a-field"), pytest.param(_rule("X1").replace("**When:** w.\n", "**When:** w.\nX2. Rationale: r.\n"), id="glued-to-a-field"), ]) def test_a_block_format_rule_is_refused(doc: str) -> None: with pytest.raises(ValueError, match="block format, which #761 retired"): parse_rules_doc(doc) def test_a_background_may_wrap_onto_a_line_opening_with_a_quote() -> None: # the H and N Backgrounds do: a quote is no structure doc = "## A section (X)\n\nBackground: as in\n\"Rabbi Cohen\".\n\n" assert parse_rules_doc(doc) == [] assert prose_blocks(doc)["X"] == ["Background: as in", '"Rabbi Cohen".'] @pytest.mark.parametrize("doc, match", [ pytest.param("## A section (X)\n", "has no `Background:`", id="at-the-end"), pytest.param("## A section (X)\n\n" + _SECTION, "has no `Background:`", id="before-another-section"), pytest.param("## A section (X)\n\n" + _rule("X1"), "has no `Background:`", id="before-a-rule"), pytest.param(_SECTION + _SECTION, "headed twice", id="a-letter-twice"), ]) def test_a_section_needs_one_background(doc: str, match: str) -> None: with pytest.raises(ValueError, match=match): parse_rules_doc(doc) def test_the_walkers_agree_on_a_section_glued_to_a_background() -> None: doc = (_SECTION.rstrip("\n") + "\n## B section (Y)\n\n" "Background: y.\n") assert parse_rules_doc(doc) == [] assert prose_blocks(doc) == { "X": ["Background: a section's words.", "Wrapped."], "Y": ["Background: y."]} def test_a_signal_line_outside_any_rule_is_an_error() -> None: doc = _SECTION + "**Signal:** stray\n" with pytest.raises(ValueError, match="outside any rule"): parse_rules_doc(doc) #: Text outside any rule that is neither a section heading nor its one #: Background paragraph. Read as nothing, each would neither run nor be #: quotable, with the rule count still matching the file count (review). @pytest.mark.parametrize("doc", [ pytest.param(_rule("X1") + '### Notes\n\n```\n"A" → family="Q"\n```\n', id="a-subheading-after-the-footer"), pytest.param(_rule("X1") + _rule("X2").replace("# X2 —", "# x2 —"), id="a-misspelled-rule-heading"), pytest.param(_SECTION + '```\n"A B" → family="B"\n```\n', id="a-fence-after-a-background"), pytest.param(_SECTION + "**When:** stray.\n", id="a-field-in-a-section-file"), pytest.param(_SECTION + "A second paragraph.\n", id="a-second-background-paragraph"), pytest.param("Background: with no section heading.\n", id="a-background-with-no-heading"), # glued to the Background paragraph, with no blank line (review) pytest.param(_SECTION.rstrip("\n") + '\n```\n"A B" → family="B"\n```\n', id="a-fence-glued-to-a-background"), pytest.param(_SECTION.rstrip("\n") + "\n### Notes\n", id="a-heading-glued-to-a-background"), pytest.param(_SECTION.rstrip("\n") + "\n**When:** w.\n", id="a-field-glued-to-a-background"), pytest.param(_SECTION.rstrip("\n") + "\n- an item\n", id="a-list-glued-to-a-background"), ]) def test_text_outside_any_rule_is_an_error(doc: str) -> None: with pytest.raises(ValueError, match="text outside any rule"): parse_rules_doc(doc) @pytest.mark.parametrize("doc", [ pytest.param(_SECTION.rstrip("\n") + '\nit reads "A B" → family="B"\n', id="on-a-wrapped-line"), # every real Background is one line: the example stands on it (review) pytest.param('## A section (X)\n\nBackground: as in "A B" → ' 'family="B" here.\n', id="on-the-background-line"), ]) def test_an_example_in_a_background_is_an_error(doc: str) -> None: with pytest.raises(ValueError, match="an example in a Background"): parse_rules_doc(doc) @pytest.mark.parametrize("line", [ pytest.param('as in `"A B" → family="B"` here.', id="in-backticks"), pytest.param("2026. Then it changed.", id="a-year-opening-a-line"), pytest.param("12) items were seen.", id="a-number-past-1"), ]) def test_a_background_wraps_onto_prose(line: str) -> None: doc = _SECTION.rstrip("\n") + f"\n{line}\n" assert parse_rules_doc(doc) == [] assert prose_blocks(doc)["X"][-1] == line #: A rule heading with no field under it: before #761's retirement it #: produced no rule and dropped its examples silently (review). @pytest.mark.parametrize("body, match", [ pytest.param("", "missing the \\*\\*When:\\*\\* field", id="empty"), pytest.param('```\n"a b" → family="b"\n```\n', "fence outside any field", id="only-a-fence"), pytest.param("prose.\n", "a line outside a field", id="only-prose"), ]) def test_a_rule_heading_with_no_field_is_an_error(body: str, match: str) -> None: with pytest.raises(ValueError, match=match): parse_rules_doc(f"# X1 — one\n\n{body}") ��������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_rules_skeleton.py�����������������������������������0000664�0000000�0000000�00000047262�15273032717�0025062�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Grammar tests for the skeleton rule format (rules_skeleton.py). HEAD is a SYNTHETIC fixture, like test_rules_doc_grammar.py's: it exercises the grammar and tracks no real rule. """ from __future__ import annotations import pytest from tests.v2.rules_doc import parse_rules_doc, prose_blocks from tests.v2.rules_skeleton import count_sentences HEAD = '''\ # X1 — one **When:** a name ends in a thing — "John Smith III". **Then:** the thing is the suffix. - **Reach:** only the last one. **Because:** a thing at the end is how it is written. It is rare otherwise. ``` "John Smith III" → suffix="III" ``` **Except:** - **One letter needs a word to spare.** ``` "John V" → family="V" · boundary ``` **Precedence:** - **Before a comma**: C1 decides. **Accepted:** - A thing with a credential behind it is the family. ``` "John Smith V PhD" → family="V" ``` --- **Signal:** a shape **history:** decisions/P2 · **implemented:** nameparser/_parser.py ''' def test_a_skeleton_rule_parses_its_fields() -> None: rules = parse_rules_doc(HEAD) r = rules[0] sk = r.skeleton assert sk is not None assert sk.when == 'a name ends in a thing — "John Smith III".' assert sk.then == "the thing is the suffix." assert sk.because.startswith("a thing at the end") assert len(sk.then_bullets) == 1 assert len(sk.excepts) == 1 assert len(sk.precedence) == 1 assert len(sk.accepted) == 1 assert r.signal == "a shape" assert r.implemented == ("nameparser/_parser.py",) def test_every_example_reaches_rule_examples() -> None: r = parse_rules_doc(HEAD)[0] assert [(e.text, e.boundary) for e in r.examples] == [ ("John Smith III", False), ("John V", True), ("John Smith V PhD", False)] assert r.skeleton is not None assert len(r.skeleton.excepts[0].examples) == 1 assert r.has_boundary_or_waiver() def test_a_fence_under_a_bullet_belongs_to_it_and_a_column_0_fence_does_not( ) -> None: sk = parse_rules_doc(HEAD)[0].skeleton assert sk is not None assert sk.then_bullets[0].examples == () assert [e.text for e in sk.excepts[0].examples] == ["John V"] assert [e.text for e in sk.accepted[0].examples] == ["John Smith V PhD"] def test_skeleton_prose_is_the_fields_without_examples_or_labels() -> None: prose = prose_blocks(HEAD)["X1"] assert any(ln.startswith("One letter needs a word to spare") for ln in prose) assert not any("→" in ln or "**" in ln for ln in prose) assert prose[0].startswith("a name ends in a thing") assert prose[-1].startswith("A thing with a credential behind it") def test_tolerated_field_sets_rule_tolerated() -> None: doc = HEAD.replace( "**Then:**", "**Tolerated:** no convention writes it.\n\n**Then:**") r = parse_rules_doc(doc)[0] assert r.skeleton is not None assert r.skeleton.tolerated == "no convention writes it." head = parse_rules_doc(HEAD)[0].skeleton assert head is not None and head.tolerated is None def test_a_hub_signal_and_no_boundary_in_the_footer() -> None: doc = HEAD.replace("**Signal:** a shape", "no-boundary: total.\n**Signal:** hub of C3, C4") r = parse_rules_doc(doc)[0] assert r.no_boundary == "total." assert r.hub == ("C3", "C4") @pytest.mark.parametrize("text, n", [ ("One. Two.", 2), ('Reads "John Smith V." here.', 1), ("Like e.g. C4 does.", 1), ("A `x. Y` b.", 1), # an initial, and the title abbreviations, end no sentence (#768) ("V. Smith reads it.", 1), ("Read by J. Smith here.", 1), ("Dr. Smith reads it.", 1), ("Mr. Smith reads it.", 1), ("Mrs. Smith reads it.", 1), ("Ms. Smith reads it.", 1), ("St. Clair reads it.", 1), ("Prof. Smith reads it.", 1), ("John Smith Jr. Reads it.", 1), ("John Smith Sr. Reads it.", 1), # the boundary is still a terminator then a capital, after a word ("A word. B is next.", 2), ("It reads Dr. Then It. Stops.", 2), # not detected, as the docstring says: a sentence ending in an # initial, and a sentence opening in lowercase ("It ends in V. Then more.", 1), ("One. two.", 1), ]) def test_count_sentences(text: str, n: int) -> None: assert count_sentences(text) == n def test_a_column_0_fence_after_a_bullet_list_belongs_to_the_field( ) -> None: doc = _sub("- **Reach:** only the last one.\n", "- **Reach:** only the last one.\n\n```\n" '"John Smith IV" → suffix="IV"\n```\n') r = parse_rules_doc(doc)[0] assert r.skeleton is not None assert r.skeleton.then_bullets[0].examples == () assert "John Smith IV" in [e.text for e in r.examples] def _sub(old: str, new: str) -> str: assert old in HEAD return HEAD.replace(old, new, 1) def test_a_column_0_line_opening_with_a_hash_is_prose_not_a_heading( ) -> None: # only a markdown heading (a `#` run and a space) ends a rule (#768) doc = _sub("It is rare otherwise.\n", "It is rare otherwise.\n#761 settled it.\n") r = parse_rules_doc(doc)[0] assert r.skeleton is not None assert r.skeleton.because.endswith("#761 settled it.") assert r.signal == "a shape" assert len(r.examples) == 3 def test_a_bullet_continuation_opening_with_a_hash_is_prose() -> None: # #768's review: it raised "a nested list" (#761) doc = _sub("- **Before a comma**: C1 decides.\n", "- **Before a comma**: C1 decides.\n #761 settled it.\n") r = parse_rules_doc(doc)[0] assert r.skeleton is not None assert r.skeleton.precedence[0].text.endswith("#761 settled it.") @pytest.mark.parametrize("old, new", [ ("It is rare otherwise.", "It is written `\"a\" → family=\"b\"`."), ("- **Before a comma**: C1 decides.", "- **Before a comma**: C1 decides `\"John V\" → family=\"V\"`."), ]) def test_an_assertion_inside_a_backtick_span_is_prose(old: str, new: str) -> None: # writing ABOUT the grammar is no example (#768's review, #761) r = parse_rules_doc(_sub(old, new))[0] assert r.skeleton is not None assert len(r.examples) == 3 def test_tracked_in_the_footer_sets_rule_tracked() -> None: doc = _sub("**implemented:** nameparser/_parser.py", "**tracked:** #761, #768") r = parse_rules_doc(doc)[0] assert r.tracked == ("#761", "#768") assert r.implemented == () def test_bullet_examples_are_the_rule_examples_placed() -> None: # Bullet.examples is a placement view of rule.examples: the same # objects, each under at most one bullet r = parse_rules_doc(HEAD)[0] sk = r.skeleton assert sk is not None placed = [e for b in (*sk.then_bullets, *sk.excepts, *sk.precedence, *sk.accepted) for e in b.examples] assert placed assert all(any(e is x for x in r.examples) for e in placed) assert len({id(e) for e in placed}) == len(placed) BAD = { "unclosed fence": ( HEAD[:HEAD.index("**Except:**")].rstrip("\n").rsplit("\n", 1)[0] + "\n", "unclosed"), "a non-example in a fence": ( _sub('"John V" → family="V" · boundary', 'John V is an example'), "every line inside a fence"), "unbolded When": (_sub("**When:**", "When:"), "a label is bold"), "lowercase bold": (_sub("**Then:**", "**then:**"), "unknown field label"), "Rationale": (_sub("**Because:**", "**Rationale:**"), "unknown field label"), "unknown label": (_sub("**Precedence:**", "**Foo:**"), "unknown"), "Then before When": ( _sub('**When:** a name ends in a thing — "John Smith III".\n\n' '**Then:** the thing is the suffix.', '**Then:** the thing is the suffix.\n\n' '**When:** a name ends in a thing — "John Smith III".'), "out of order"), "two Because": (_sub("**Except:**", "**Because:** again.\n\n**Except:**"), "repeated"), "missing Because": ( _sub("**Because:** a thing at the end is how it is written. " "It is rare otherwise.\n\n", ""), "missing"), "example-less Except": ( _sub(' ```\n "John V" → family="V" · boundary\n ```\n', ""), "no example"), "four-sentence Because": ( _sub("It is rare otherwise.", "It is rare. Very rare. Truly rare."), "sentences"), "two-sentence Then": ( _sub("the thing is the suffix.", "the thing is. It is the suffix."), "one sentence"), "stray footer line": (_sub("**Signal:** a shape", "stray words\n**Signal:** a shape"), "footer"), "Signal not above pointer": ( _sub("**Signal:** a shape\n", "**Signal:** a shape\n\n"), "directly above"), "interacts in footer": ( _sub("**history:** decisions/P2", "**history:** decisions/P2 · " "**interacts:** H1"), "interacts: is retired"), # the Signal line in the footer "a second Signal line": ( _sub("**Signal:** a shape\n**history:**", "**Signal:** a shape\n**history:** decisions/P3\n" "**Signal:** a count\n**history:**"), "second"), "a Signal line ending the text": ( HEAD.replace("**history:** decisions/P2 · **implemented:** " "nameparser/_parser.py\n", ""), "the text ends"), "a wrapped Signal line": ( _sub("**Signal:** a shape\n", "**Signal:** a shape:\nwrapped\n"), "directly above"), "stray prose outside a field": ( _sub("**Precedence:**\n\n- **Before a comma**: C1 decides.\n", "**Precedence:**\n\n- **Before a comma**: C1 decides.\n\n" "loose words\n"), "outside a field"), "an example line after Because, outside a fence": ( _sub("It is rare otherwise.\n", 'It is rare otherwise.\n"John Smith IV" → suffix="IV"\n'), "outside a fence"), "an example line under a bullet, outside a fence": ( _sub("- **Before a comma**: C1 decides.\n", "- **Before a comma**: C1 decides.\n" ' "John Smith IV" → suffix="IV"\n'), "outside a fence"), "a malformed example line outside a fence": ( _sub("It is rare otherwise.\n", 'It is rare otherwise.\n"John Smith IV" → suffix=IV\n'), "outside a fence"), "a tilde fence": ( _sub('```\n"John Smith III" → suffix="III"\n```\n', '~~~\n"John Smith III" → suffix="III"\n~~~\n'), "bare"), "a fence with a language tag": ( _sub('```\n"John Smith III" → suffix="III"\n```\n', '```text\n"John Smith III" → suffix="III"\n```\n'), "bare"), "a tagged fence under a bullet": ( _sub(' ```\n "John V"', ' ```text\n "John V"'), "bare"), "an empty Tolerated": ( _sub("**Then:**", "**Tolerated:**\n\n**Then:**"), "Tolerated"), "colon outside the bold": (_sub("**When:**", "**When**:"), "the colon goes inside the bold"), # an example where prose stands is never run (G1, #768) "an example on a label's line": ( _sub('**When:** a name ends in a thing — "John Smith III".', '**When:** "John V" → family="V"'), "outside a fence"), "an example as a bullet's first line": ( _sub("- **Before a comma**: C1 decides.\n", '- "John V" → family="V"\n'), "outside a fence"), "an unquoted example outside a fence": ( _sub("It is rare otherwise.\n", 'It is rare otherwise.\nJohn V → family="V"\n'), "outside a fence"), "an unquoted example on a label's line": ( _sub("**Then:** the thing is the suffix.", '**Then:** John V → family="V"'), "outside a fence"), # the pointer line (G3) "a misspelled pointer key": ( _sub("**implemented:**", "**implmented:**"), "pointer line"), "a pointer part with no colon": ( _sub("**implemented:** nameparser", "implemented nameparser"), "pointer line"), "a repeated pointer key": ( _sub("**history:** decisions/P2 ·", "**history:** decisions/P2 · **history:** decisions/P3 ·"), "repeated"), # empty structures (G4) "a bullet field with no bullets": ( _sub("**Precedence:**\n\n- **Before a comma**: C1 decides.\n", "**Precedence:**\n"), "no bullets"), "an empty bullet": ( _sub("- **Before a comma**: C1 decides.\n", "- **Before a comma**: C1 decides.\n- \n"), "empty bullet"), "an empty fence under a bullet": ( _sub(' ```\n "John Smith V PhD" → family="V"\n ```\n', " ```\n ```\n"), "empty fence"), "an empty fence in a field": ( _sub('```\n"John Smith III" → suffix="III"\n```\n', '```\n"John Smith III" → suffix="III"\n```\n\n```\n```\n'), "empty fence"), # Then (G5) "a star bullet under Then": ( _sub("- **Reach:** only", "* **Reach:** only"), "a bullet is"), "a hyphen glued to the bold under Then": ( _sub("- **Reach:** only", "-**Reach:** only"), "a bullet is"), # numbered, which markdown renders as a list (review) "a numbered list in a prose field": ( _sub('**When:** a name ends in a thing — "John Smith III".\n', "**When:** a name holds:\n1. A comma.\n2. A title.\n"), "a bullet is"), "a bare numbered item ending a line": ( _sub("It is rare otherwise.\n", "It is rare otherwise.\n\n1.\n"), "a bullet is"), "a numbered list with a parenthesis": ( _sub("- **Reach:** only", "1) **Reach:** only"), "a bullet is"), "a star bullet under Except": ( _sub("- **One letter", "* **One letter"), "a bullet is"), "a second Then paragraph": ( _sub("**Then:** the thing is the suffix.\n", "**Then:** the thing is the suffix.\n\nand more of it.\n"), "Then is one paragraph"), # When and Tolerated as Then, which they accepted silently (#768) "a second When paragraph": ( _sub('**When:** a name ends in a thing — "John Smith III".\n', '**When:** a name ends in a thing — "John Smith III".\n\n' "and more of it.\n"), "When is one paragraph"), "a second Tolerated paragraph": ( _sub("**Then:**", "**Tolerated:** no convention writes it.\n\n" "and more of it.\n\n**Then:**"), "Tolerated is one paragraph"), # a bullet's continuation lines (#768) "a nested list under a bullet": ( _sub("- **Before a comma**: C1 decides.\n", "- **Before a comma**: C1 decides.\n - nested.\n"), "nested list"), # the other markers a nested list may wear (review) "a nested star list under a bullet": ( _sub("- **Before a comma**: C1 decides.\n", "- **Before a comma**: C1 decides.\n * nested.\n"), "nested list"), "a nested plus list under a bullet": ( _sub("- **Before a comma**: C1 decides.\n", "- **Before a comma**: C1 decides.\n + nested.\n"), "nested list"), "a nested numbered list under a bullet": ( _sub("- **Before a comma**: C1 decides.\n", "- **Before a comma**: C1 decides.\n 1. nested.\n"), "nested list"), "a heading under a bullet": ( _sub("- **Before a comma**: C1 decides.\n", "- **Before a comma**: C1 decides.\n ## Heading\n"), "heading inside a bullet"), "an assertion outside a backtick span": ( _sub("It is rare otherwise.", "It is `written` so → family=\"V\" here."), "outside a fence"), # Tolerated, in the spellings an author reaching for it may write "an unbolded Tolerated": ( _sub("**Then:**", "Tolerated: why.\n\n**Then:**"), "a label is bold"), "a lowercase Tolerated": ( _sub("**Then:**", "**tolerated:** why.\n\n**Then:**"), "unknown field label"), "a Tolerated with its colon outside the bold": ( _sub("**Then:**", "**Tolerated**: why.\n\n**Then:**"), "the colon goes inside the bold"), "a block-format tolerated line": ( _sub("**Then:**", "tolerated: why.\n\n**Then:**"), "a label is bold"), # footer lines above the footer (G6) "a Signal line above the footer": ( _sub("It is rare otherwise.\n", "It is rare otherwise.\nSignal: a shape\n"), "below the `---`"), "a bold Signal line above the footer": ( _sub("It is rare otherwise.\n", "It is rare otherwise.\n**Signal:** a shape\n"), "below the `---`"), "a lowercase signal line in a bullet": ( _sub("- **Before a comma**: C1 decides.\n", "- **Before a comma**: C1 decides.\n signal: a shape\n"), "below the `---`"), "a no-boundary line above the footer": ( _sub("It is rare otherwise.\n", "It is rare otherwise.\nno-boundary: total.\n"), "below the `---`"), } @pytest.mark.parametrize("name", sorted(BAD)) def test_malformed_skeleton_raises(name: str) -> None: doc, fragment = BAD[name] with pytest.raises(ValueError, match="X1") as ei: parse_rules_doc(doc) assert fragment in str(ei.value) def _signal(text: str) -> str: return _sub("**Signal:** a shape\n", f"{text}\n") #: Footer lines meant as the signal line but spelled otherwise: read as #: anything else they would leave the rule with no signal. @pytest.mark.parametrize("line", [ pytest.param("signal: lowercase", id="lowercase-unbolded"), pytest.param("Signal: a count", id="unbolded"), pytest.param("**Signal :** a count", id="space-before-the-colon"), pytest.param("**Signal:**", id="empty"), pytest.param("**Signals:** a count", id="plural"), pytest.param("**Signal:** a count", id="fullwidth-colon"), ]) def test_a_misspelled_signal_line_is_an_error(line: str) -> None: with pytest.raises(ValueError, match="below the `---` footer only"): parse_rules_doc(_signal(line)) #: Each would read as a plain signal with its rules unchecked. @pytest.mark.parametrize("signal", [ pytest.param("hub of C3 and C4", id="malformed-hub"), pytest.param("a shape; hub of C3 C4", id="malformed-hub-after-a-signal"), pytest.param("a shape, hub of C3, C4", id="hub-after-a-comma"), pytest.param("a shape;hub of C3, C4", id="hub-after-an-unspaced-semicolon"), pytest.param("a shape — hub of C3, C4", id="hub-after-a-dash"), pytest.param("hubs of C3, C4", id="plural-hub"), pytest.param("Hub of C3, C4", id="capitalized-hub"), pytest.param("hub of C3, C4.", id="hub-with-a-full-stop"), pytest.param("hub: C3, C4", id="hub-colon"), # refused by the first form of the check, which raised on the word # anywhere; kept so a narrowing cannot drop it (#737's lesson) pytest.param("hub C3, C4", id="hub-without-of"), pytest.param("a shape; hub C3", id="hub-without-of-after-a-signal"), ]) def test_a_malformed_hub_signal_is_an_error(signal: str) -> None: with pytest.raises(ValueError, match="a hub's signal"): parse_rules_doc(_signal(f"**Signal:** {signal}")) def test_a_hub_may_state_its_own_signals_first() -> None: r = parse_rules_doc(_signal( "**Signal:** a position: x + a shape: y; hub of C3, C4"))[0] assert r.hub == ("C3", "C4") assert r.signal == "a position: x + a shape: y; hub of C3, C4" def test_a_word_holding_hub_is_a_plain_signal() -> None: # the near-miss test is not a substring test r = parse_rules_doc(_signal("**Signal:** a shape: Hubert, hub-like"))[0] assert r.hub == () assert r.signal == "a shape: Hubert, hub-like" def test_a_rule_without_a_signal_line_has_none() -> None: # the negative control: test_rules_doc.py's `signal is not None` is # a question the parser can answer no to assert parse_rules_doc(_sub("**Signal:** a shape\n", ""))[0].signal is None ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_snapshot.py�����������������������������������������0000664�0000000�0000000�00000025600�15273032717�0023653�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""The tree's readings of every corpus name match the committed snapshot (tools/differential/snapshot.py, #664). A failure lists the moved names grouped by which fields changed; re-record if the moves are intended, and the diff of snapshot.jsonl is then the review's list of moves.""" import pytest import nameparser from ._differential_fixtures import load_tool snapshot = load_tool("snapshot") def test_readings_match_the_snapshot() -> None: recorded = snapshot.load(snapshot.SNAPSHOT.read_text(encoding="utf-8")) current = {snapshot.key(r): r for r in snapshot.read(snapshot.corpus_entries())} moves = snapshot.grouped(recorded, current) assert not moves, ("\n".join(moves) + f"\n\nif these moves are intended: {snapshot.RECORD_COMMAND}") def test_the_snapshot_can_report_a_move() -> None: # A clean run and a broken comparison print the same thing; prove # this one can see a difference. rows = snapshot.read([("John Smith", None)]) moved = [{**rows[0], "family": "Smyth"}] assert snapshot.grouped({snapshot.key(r): r for r in rows}, {snapshot.key(r): r for r in moved}) def test_the_snapshot_can_report_a_case_only_move() -> None: # Case views reach grouped() only because _flat passes every key # through; a view changing value and a view appearing are both moves. rows = snapshot.read([("john smith EdD", None)]) old = {snapshot.key(r): r for r in rows} recased = {**rows[0], "capitalized_force": "John Smith EdD"} assert snapshot.grouped(old, {snapshot.key(recased): recased})[0] \ == "capitalized_force: 1" gone = {k: v for k, v in rows[0].items() if k != "capitalized"} assert snapshot.grouped(old, {snapshot.key(gone): gone})[0] \ == "capitalized: 1" def test_a_case_view_is_recorded_only_where_it_moves_something() -> None: plain_only, untouched, forced_only = snapshot.read( [("john smith", None), ("John Smith", None), ("John Smith EdD", None)]) assert plain_only["capitalized"] == "John Smith" assert "capitalized_force" not in plain_only assert not set(snapshot.CASE_VIEWS) & set(untouched) assert "capitalized" not in forced_only # A role move in a name repair leaves alone moves no case view... reordered = snapshot.read([("John Smith", "FAMILY_FIRST")])[0] assert reordered["family"] != untouched["family"] assert not set(snapshot.CASE_VIEWS) & set(reordered) # ...but the view is the whole repaired name, so in a name it does # repair, a role move that changes the rendering moves the view # with no word changing case: the echo the initials column has. caps, caps_reordered = snapshot.read( [("JOHN SMITH", None), ("JOHN SMITH", "FAMILY_FIRST")]) assert (caps["capitalized"], caps_reordered["capitalized"]) \ == ("John Smith", "Smith John") def test_a_snapshot_predating_the_case_views_is_not_compared_on_them() -> None: # --since v2.5.0 reported 961 entries on 2026-10-22, 910 of them the # keys appearing; against a headerless snapshot that records a view # anywhere, the views stay compared. keys = frozenset(snapshot.recorded_keys()) rows = snapshot.read([("john smith", None), ("John Smith EdD", None)]) new = {snapshot.key(r): dict(r) for r in rows} old = {k: {f: v for f, v in r.items() if f not in snapshot.CASE_VIEWS} 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") 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. rows = snapshot.read([("john smith", None), ("John Smith", 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",) assert new == current assert not any("capitalized_force" in r for r in current.values()) def test_a_snapshot_predating_the_alternatives_is_not_compared_on_them( ) -> None: # every name with a fork report gained the key on 2026-10-26 (#752 # phase 2m), 969 of the committed snapshot's rows that day; against # a snapshot recorded before the header, whose rows cannot hold # one, they are left out, and the case views are a group of their # own keys = frozenset(snapshot.recorded_keys()) rows = snapshot.read([("JOHN SMITH MA", None), ("john smith", None)]) new = {snapshot.key(r): dict(r) for r in rows} assert any("alternatives" in r for r in new.values()) 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",) 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} moved = {k: dict(r) for k, r in new.items()} first = next(iter(moved)) moved[first]["alternatives"] = ["SUFFIX_OR_NAME: name as given (declared)"] assert snapshot.unrecorded_views(keys, keys, moved, new) == () assert snapshot.grouped(moved, new)[0] == "alternatives: 1" def test_a_snapshot_whose_names_report_nothing_still_records_alternatives( ) -> None: # The header, not the rows, says what a snapshot records: one taken # by a 2.7 tree over names with no fork holds no alternative, and a # report gaining one against it is a move (#902's review). keys = snapshot.recorded_keys() assert "alternatives" in keys text = snapshot.dump(snapshot.read([("john smith", None)]), keys) assert snapshot.recorded(text) == frozenset(keys) assert snapshot.row_count(text) == 1 old = snapshot.load(text) assert not any("alternatives" in r for r in old.values()) new = {k: {**r, "alternatives": ["GIVEN_OR_FAMILY: name as family " "(position)"]} for k, r in old.items()} assert snapshot.unrecorded_views(snapshot.recorded(text), frozenset(keys), old, new) == () assert snapshot.grouped(old, new)[0] == "alternatives: 1" # and a tree that cannot record them, read against it, drops them # from both sides rather than reporting each as lost case_only = frozenset(snapshot.CASE_VIEWS) assert snapshot.unrecorded_views(snapshot.recorded(text), case_only, old, new) == ("alternatives",) assert not snapshot.grouped(old, new) 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 # strings recorded the two as one. row = snapshot.read([("DOE, JOHN MA JD", None)])[0] assert row["alternatives"] == [ "SUFFIX_OR_NAME: name as middle (declared)"] * 2 lost = {**row, "alternatives": row["alternatives"][:1]} key = snapshot.key(row) assert snapshot.grouped({key: row}, {key: lost}) def test_a_name_leaving_the_corpus_is_reported() -> None: # A truncated corpus must not pass for a clean run. rows = snapshot.read([("John Smith", None), ("Jane Doe", None)]) assert snapshot.grouped({snapshot.key(r): r for r in rows}, {snapshot.key(rows[0]): rows[0]}) \ == ["(removed from corpus): 1", " Jane Doe"] def test_a_name_only_cldr_holds_is_counted_out() -> None: # A release figure counts CLDR's fictional renderings out (AGENTS.md, # Release-log claims); a name another corpus also holds stays in. # One of #653's movers, a CLDR rendering; 'Jane (Janey, Jo) Smith' # moved with it and is a rules example only. 'マイケル' is in CLDR # AND the CJK corpus, which is the half the subtraction decides. cldr_name = "Macáková, Alexandra Zuzana (pani, PhD)" shared = "マイケル" assert snapshot.only_in(snapshot.CLDR_CORPUS, {shared}) == set() assert shared in {n for n, _ in snapshot.corpus_entries( [snapshot.CLDR_CORPUS])}, "pick another name CLDR shares" cldr = snapshot.only_in(snapshot.CLDR_CORPUS, {cldr_name, "Jane (Janey, Jo) Smith", shared}) assert cldr == {cldr_name} @pytest.mark.parametrize("facade", [True, False]) def test_a_parallel_read_matches_the_serial_one( monkeypatch: pytest.MonkeyPatch, facade: bool) -> None: # A row the facade reads otherwise and one under another order, so # a worker dropping either half reads differently. entries = [*snapshot.corpus_entries()[:60], ("Ph. D., John", None)] serial = snapshot.read(entries, facade=facade) assert any("order" in r for r in serial) assert any("facade" in r for r in serial) is facade monkeypatch.setattr(snapshot, "PARALLEL_FLOOR", 1) assert snapshot.read(entries, facade=facade, jobs=2) == serial def test_a_worker_that_read_another_nameparser_aborts_the_read( monkeypatch: pytest.MonkeyPatch) -> None: # The negative control for the check: the workers import the real # package, so a parent that believes it imported another one sees # every chunk come back from elsewhere. monkeypatch.setattr(snapshot, "PARALLEL_FLOOR", 1) monkeypatch.setattr(nameparser, "__file__", "/elsewhere/nameparser/__init__.py") with pytest.raises(SystemExit, match="a worker imported nameparser from"): snapshot.read([("John Smith", None), ("Jane Doe", None)], jobs=2) def test_a_read_without_the_facade_drops_only_the_facade() -> None: # A name the facade reads otherwise, so the row has a facade to drop. with_facade, without = (snapshot.read([("Ph. D., John", None)], facade=f)[0] for f in (True, False)) assert "facade" in with_facade assert without == {k: v for k, v in with_facade.items() if k != "facade"} def test_a_sample_is_chosen_by_name_and_keeps_every_order_of_one() -> None: entries = snapshot.corpus_entries() kept = snapshot.sampled(entries, 7) assert 0 < len(kept) < len(entries) # Pinned, so a per-process hash (hash() under PYTHONHASHSEED) would # fail here rather than pick other names in the base's process. assert snapshot.sampled([("John Smith", None), ("Mary Major", None)], 3) \ == [("John Smith", None)] names = {n for n, _ in kept} assert {e for e in entries if e[0] in names} == set(kept) assert any(o for _, o in kept), "pick another N: no order-bearing entry kept" def test_a_moved_name_holding_a_line_break_stays_on_its_indented_line() -> None: old = {("Ann\nZyx", ""): {"name": "Ann\nZyx", "family": "Zyx"}} new = {("Ann\nZyx", ""): {"name": "Ann\nZyx", "family": "Ann"}} lines = "\n".join(snapshot.grouped(old, new)).split("\n") assert lines[0] == "family: 1" assert all(line.startswith(" ") for line in lines[1:]) ��������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_types.py��������������������������������������������0000664�0000000�0000000�00000066362�15273032717�0023172�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������import dataclasses import itertools from collections.abc import Callable, Iterable, Iterator from pathlib import Path import pytest from nameparser import parse from nameparser._types import ( STABLE_TAGS, Ambiguity, AmbiguityKind, ParsedName, Role, Segmentation, Span, Token, ) def test_role_declaration_order_is_canonical_field_order() -> None: assert [r.value for r in Role] == [ "title", "given", "middle", "family", "suffix", "nickname", "maiden", ] def test_role_members_are_their_string_values() -> None: # Role is a StrEnum, like AmbiguityKind: members compare, hash, # and stringify as their field names. assert Role.GIVEN == "given" assert str(Role.FAMILY) == "family" d: dict[str, int] = {Role.GIVEN: 1} assert d["given"] == 1 assert isinstance(Role.GIVEN, str) assert repr(Role.GIVEN) == "<Role.GIVEN: 'given'>" # repr keeps .name form assert Role("maiden") is Role.MAIDEN def test_token_construction_and_span_coercion() -> None: t = Token("Juan", (0, 4), Role.GIVEN) # type: ignore[arg-type] assert t.span == Span(0, 4) assert isinstance(t.span, Span) assert t.span.start == 0 and t.span.end == 4 assert t.tags == frozenset() def test_synthetic_token_has_no_span() -> None: t = Token("Jane", None, Role.GIVEN) assert t.span is None def test_token_rejects_empty_text() -> None: with pytest.raises(ValueError, match="non-empty"): Token("", Span(0, 0), Role.GIVEN) def test_token_rejects_inverted_span() -> None: with pytest.raises(ValueError, match="start <= end"): Token("x", Span(5, 2), Role.GIVEN) def test_token_rejects_negative_span() -> None: with pytest.raises(ValueError, match="start <= end"): Token("x", Span(-1, 1), Role.GIVEN) def test_token_rejects_malformed_span_shapes() -> None: with pytest.raises(TypeError, match="expected a \\(start, end\\) pair"): Token("x", (0, 4, 9), Role.GIVEN) # type: ignore[arg-type] with pytest.raises(TypeError, match="expected a \\(start, end\\) pair"): Token("x", 5, Role.GIVEN) # type: ignore[arg-type] def test_token_rejects_non_string_text() -> None: with pytest.raises(TypeError, match="got None"): Token(None, None, Role.GIVEN) # type: ignore[arg-type] def test_token_is_frozen_and_hashable() -> None: t = Token("Juan", Span(0, 4), Role.GIVEN) with pytest.raises(AttributeError): t.text = "X" # type: ignore[misc] assert hash(t) == hash(Token("Juan", Span(0, 4), Role.GIVEN)) def test_segmentation_validates_splits() -> None: s = Segmentation((2,), confidence=0.97) assert s.splits == (2,) and s.confidence == 0.97 assert Segmentation(()).confidence is None with pytest.raises(ValueError, match="ascending"): Segmentation((3, 2)) with pytest.raises(ValueError, match="interior"): Segmentation((0,)) with pytest.raises(TypeError, match="integers"): Segmentation(("2",)) # type: ignore[arg-type] with pytest.raises(ValueError, match="confidence"): Segmentation((2,), confidence=1.5) # wrong TYPE is the TypeError branch, distinct from the range check with pytest.raises(TypeError, match="confidence"): Segmentation((2,), confidence="high") # type: ignore[arg-type] def test_segmentation_rejects_mappings_and_non_iterables() -> None: # Token.tags' guards, on the other collection field: a mapping # would silently contribute only its keys, and a bare int would # surface as an uncurated "not iterable" naming nothing with pytest.raises(TypeError, match="Segmentation.splits"): Segmentation({2: "a", 3: "b"}) # type: ignore[arg-type] with pytest.raises(TypeError, match="Segmentation.splits"): Segmentation(5) # type: ignore[arg-type] # ...and the bare string, the third member of that family and the # one with teeth: "" is iterable and empty, so without the guard # Segmentation("") became "confidently one token" -- an opinion # nobody stated -- while "23" failed one character deep naming '2' # instead of the argument. Both must name the field. for bad in ("", "23"): with pytest.raises(TypeError, match="Segmentation.splits"): Segmentation(bad) # type: ignore[arg-type] def test_segmentation_rejects_bools_in_both_fields() -> None: # bool is an int subclass in both slots: True as an offset is a # comparison result leaking into an index, True as a confidence one # leaking into a score. Behavioral twin of Token's span guard. with pytest.raises(TypeError, match="Segmentation.splits"): Segmentation((True,)) # type: ignore[list-item] with pytest.raises(TypeError, match="Segmentation.confidence"): Segmentation((2,), confidence=True) # type: ignore[arg-type] def test_segmentation_lets_a_generator_raise_its_own_error() -> None: # the non-iterable guard probes iter(), which cannot run a # generator's body; wrapping the tuple() call instead would catch a # TypeError raised INSIDE the user's generator and relabel it as # "splits must be an iterable", pointing the reader at the wrong bug def boom() -> Iterator[int]: yield 1 raise TypeError("boom") with pytest.raises(TypeError) as caught: Segmentation(boom()) # type: ignore[arg-type] # the message is the generator's own, whole -- a `match=` would # pass on the relabeled text too, since the generator's repr names # the function assert str(caught.value) == "boom" def test_segmentation_is_frozen_and_hashable() -> None: s = Segmentation((2,)) assert isinstance(hash(s), int) with pytest.raises(dataclasses.FrozenInstanceError): s.splits = (3,) # type: ignore[misc] def test_ambiguity_kind_members_are_their_string_values() -> None: assert AmbiguityKind.PARTICLE_OR_GIVEN == "particle-or-given" assert AmbiguityKind("order") is AmbiguityKind.ORDER def test_ambiguity_construction_coerces_kind_string() -> None: t = Token("Van", Span(0, 3), Role.GIVEN, frozenset({"particle"})) a = Ambiguity("particle-or-given", "leading 'van' may be a particle", (t,)) # type: ignore[arg-type] assert a.kind is AmbiguityKind.PARTICLE_OR_GIVEN assert a.tokens == (t,) def test_ambiguity_rejects_unknown_kind() -> None: with pytest.raises(ValueError, match="particle-or-given"): Ambiguity("no-such-kind", "detail", ()) # type: ignore[arg-type] def test_ambiguity_rejects_non_token_elements() -> None: with pytest.raises(TypeError, match="only Token instances"): Ambiguity("order", "detail", ("not-a-token",)) # type: ignore[arg-type] def test_ambiguity_rejects_empty_detail() -> None: with pytest.raises(ValueError, match="non-empty string"): Ambiguity(AmbiguityKind.ORDER, "", ()) def test_ambiguity_rejects_non_str_detail() -> None: with pytest.raises(TypeError, match="must be a str"): Ambiguity(AmbiguityKind.ORDER, None, ()) # type: ignore[arg-type] def _pn(original: str, tokens: Iterable[Token], ambiguities: Iterable[Ambiguity] = ()) -> ParsedName: return ParsedName(original=original, tokens=tuple(tokens), ambiguities=tuple(ambiguities)) def test_parsedname_accepts_valid_spans_and_is_truthy() -> None: pn = _pn("John Smith", [ Token("John", Span(0, 4), Role.GIVEN), Token("Smith", Span(5, 10), Role.FAMILY), ]) assert bool(pn) is True def test_empty_parse_is_falsy() -> None: assert bool(_pn("", [])) is False assert bool(_pn(" ", [])) is False def test_parsedname_rejects_out_of_bounds_span() -> None: with pytest.raises(ValueError, match="out of bounds"): _pn("John", [Token("Johnny", Span(0, 6), Role.GIVEN)]) def test_parsedname_rejects_overlapping_spans() -> None: with pytest.raises(ValueError, match="ascending"): _pn("John Smith", [ Token("John", Span(0, 4), Role.GIVEN), Token("ohn S", Span(1, 6), Role.FAMILY), ]) def test_parsedname_rejects_descending_spans() -> None: with pytest.raises(ValueError, match="ascending"): _pn("John Smith", [ Token("Smith", Span(5, 10), Role.FAMILY), Token("John", Span(0, 4), Role.GIVEN), ]) def test_synthetic_tokens_skip_span_checks() -> None: pn = _pn("John Smith", [ Token("John", Span(0, 4), Role.GIVEN), Token("Qux", None, Role.MIDDLE), Token("Smith", Span(5, 10), Role.FAMILY), ]) assert len(pn.tokens) == 3 def test_ambiguity_tokens_must_be_subset_of_parse_tokens() -> None: inside = Token("Van", Span(0, 3), Role.GIVEN) outside = Token("Zzz", None, Role.GIVEN) with pytest.raises(ValueError, match="subset"): _pn("Van Johnson", [inside, Token("Johnson", Span(4, 11), Role.FAMILY)], [Ambiguity(AmbiguityKind.PARTICLE_OR_GIVEN, "d", (outside,))]) def test_parsedname_equality_is_strict_structural() -> None: a = _pn("John", [Token("John", Span(0, 4), Role.GIVEN)]) b = _pn("John", [Token("John", Span(0, 4), Role.GIVEN)]) c = _pn("John ", [Token("John", Span(0, 4), Role.GIVEN)]) assert a == b and hash(a) == hash(b) assert a != c # different original: not interchangeable def test_parsedname_rejects_non_str_original() -> None: with pytest.raises(TypeError, match="must be a str"): _pn(None, []) # type: ignore[arg-type] def test_parsedname_rejects_non_token_and_non_ambiguity_elements() -> None: with pytest.raises(TypeError, match="only Token instances"): _pn("x", ["not-a-token"]) # type: ignore[list-item] with pytest.raises(TypeError, match="only Ambiguity instances"): _pn("John", [Token("John", Span(0, 4), Role.GIVEN)], ["nope"]) # type: ignore[list-item] def _delavega() -> ParsedName: # "Dr. Juan de la Vega III" -- hand-built, spans verified by hand # 0123456789012345678901234 return _pn("Dr. Juan de la Vega III", [ Token("Dr.", Span(0, 3), Role.TITLE), Token("Juan", Span(4, 8), Role.GIVEN), Token("de", Span(9, 11), Role.FAMILY, frozenset({"particle"})), Token("la", Span(12, 14), Role.FAMILY, frozenset({"particle"})), Token("Vega", Span(15, 19), Role.FAMILY), Token("III", Span(20, 23), Role.SUFFIX), ]) def test_string_properties_join_by_role() -> None: pn = _delavega() assert pn.title == "Dr." assert pn.given == "Juan" assert pn.middle == "" assert pn.family == "de la Vega" assert pn.suffix == "III" assert pn.nickname == "" assert pn.maiden == "" def test_suffix_joins_with_comma_space() -> None: pn = _pn("John Smith PhD MD", [ Token("John", Span(0, 4), Role.GIVEN), Token("Smith", Span(5, 10), Role.FAMILY), Token("PhD", Span(11, 14), Role.SUFFIX), Token("MD", Span(15, 17), Role.SUFFIX), ]) assert pn.suffix == "PhD, MD" def test_derived_views_filter_on_stable_particle_tag() -> None: # Pin the hard-coded "particle" string in _text_for to the published # contract until parser tag-emission contract tests land. assert "particle" in STABLE_TAGS pn = _delavega() assert pn.family_particles == "de la" assert pn.family_base == "Vega" assert pn.surnames == "de la Vega" # middle + family assert pn.given_names == "Juan" # given + middle def test_tokens_for_preserves_order() -> None: pn = _delavega() assert [t.text for t in pn.tokens_for(Role.FAMILY)] == ["de", "la", "Vega"] assert pn.tokens_for(Role.NICKNAME) == () def test_as_dict_canonical_order_and_empty_filtering() -> None: pn = _delavega() d = pn.as_dict() assert list(d) == ["title", "given", "middle", "family", "suffix", "nickname", "maiden"] assert d["family"] == "de la Vega" and d["middle"] == "" d2 = pn.as_dict(include_empty=False) assert list(d2) == ["title", "given", "family", "suffix"] def test_replace_swaps_field_with_synthetic_tokens_in_place() -> None: pn = _delavega() pn2 = pn.replace(given="Jean Paul") assert pn2.given == "Jean Paul" assert pn2.family == "de la Vega" # untouched assert pn2.original == pn.original # provenance unchanged assert all(t.span is None for t in pn2.tokens_for(Role.GIVEN)) assert pn.given == "Juan" # source object unchanged # positional: synthetic given tokens sit where the old ones were assert [t.role for t in pn2.tokens][:3] == [Role.TITLE, Role.GIVEN, Role.GIVEN] def test_replace_adds_missing_field_at_end() -> None: pn = _pn("John Smith", [ Token("John", Span(0, 4), Role.GIVEN), Token("Smith", Span(5, 10), Role.FAMILY), ]) pn2 = pn.replace(suffix="Jr") assert pn2.suffix == "Jr" assert pn2.tokens[-1].role is Role.SUFFIX def test_replace_with_empty_string_clears_field() -> None: pn = _delavega() assert pn.replace(title="").title == "" def test_replace_rejects_unknown_field() -> None: with pytest.raises(TypeError, match="given"): _delavega().replace(firstname="X") def test_replace_drops_ambiguities_referencing_removed_tokens() -> None: van = Token("Van", Span(0, 3), Role.GIVEN) pn = _pn("Van Johnson", [van, Token("Johnson", Span(4, 11), Role.FAMILY)], [Ambiguity(AmbiguityKind.PARTICLE_OR_GIVEN, "d", (van,))]) assert pn.replace(given="Bob").ambiguities == () assert pn.replace(family="Smith").ambiguities != () def test_replace_rejects_non_str_value() -> None: with pytest.raises(TypeError, match="must be a str"): _delavega().replace(given=None) # type: ignore[arg-type] def test_replace_appends_missing_roles_in_canonical_order() -> None: pn = _pn("John", [Token("John", Span(0, 4), Role.GIVEN)]) pn2 = pn.replace(maiden="X", suffix="Y") assert [t.role for t in pn2.tokens] == [Role.GIVEN, Role.SUFFIX, Role.MAIDEN] def test_comparison_key_is_casefolded_canonical_seven_tuple() -> None: pn = _delavega() assert pn.comparison_key() == ( "dr.", "juan", "", "de la vega", "iii", "", "", ) upper = _pn("JUAN DE LA VEGA", [ Token("JUAN", Span(0, 4), Role.GIVEN), Token("DE", Span(5, 7), Role.FAMILY, frozenset({"particle"})), Token("LA", Span(8, 10), Role.FAMILY, frozenset({"particle"})), Token("VEGA", Span(11, 15), Role.FAMILY), ]) lower = _pn("juan de la vega", [ Token("juan", Span(0, 4), Role.GIVEN), Token("de", Span(5, 7), Role.FAMILY, frozenset({"particle"})), Token("la", Span(8, 10), Role.FAMILY, frozenset({"particle"})), Token("vega", Span(11, 15), Role.FAMILY), ]) assert upper.comparison_key() == lower.comparison_key() def test_matches_casefolds_unicode_case_pairs() -> None: # Deliberate 1.4 deviation (release log): comparison folds with # casefold() where 1.4 used lower(). Comparison is the one surface # where casefold's aggressive folds are WANTED -- ß/SS and final- # sigma forms are the same name under different case conventions, # and the key is opaque, so the spelling-mutation bug casefold # caused in vocabulary storage (which stays lower(), v1 lc parity # -- see _lexicon._normalize) cannot happen here. from nameparser import HumanName, parse assert parse("Hans Straße").matches("HANS STRASSE") # ß <-> SS assert parse("Νίκος Παπαδόπουλος").matches("Νίκοσ Παπαδόπουλοσ") # ς <-> σ # same fold on the facade path -- both APIs share the deviation assert HumanName("Hans Straße").matches("HANS STRASSE") key = parse("Hans Straße").comparison_key() assert key == parse("HANS STRASSE").comparison_key() def test_token_rejects_bare_string_and_mapping_tags() -> None: # frozenset("particle") is the set(str) footgun: eight single chars. with pytest.raises(TypeError, match="bare string"): Token("Van", None, Role.GIVEN, tags="particle") # type: ignore[arg-type] with pytest.raises(TypeError, match="mapping"): Token("Van", None, Role.GIVEN, tags={"particle": 1}) # type: ignore[arg-type] def test_token_rejects_non_str_tags() -> None: with pytest.raises(TypeError, match="tags must contain only strings"): Token("Van", None, Role.GIVEN, tags=frozenset({1})) # type: ignore[arg-type] def test_token_coerces_role_string_and_rejects_unknown() -> None: # mirror Ambiguity.kind: coerce the string form, ValueError for any # failed enum lookup (stdlib EnumType precedent). assert Token("Juan", None, "given").role is Role.GIVEN # type: ignore[arg-type] with pytest.raises(ValueError, match="title, given"): Token("Juan", None, "chief") # type: ignore[arg-type] with pytest.raises(ValueError, match="title, given"): Token("Juan", None, 5) # type: ignore[arg-type] def test_types_pickle_round_trip() -> None: import pickle pn = _delavega() assert pickle.loads(pickle.dumps(pn)) == pn amb = Ambiguity(AmbiguityKind.ORDER, "two-comma structure", ()) assert pickle.loads(pickle.dumps(amb)) == amb tok = Token("de", Span(9, 11), Role.FAMILY, frozenset({"particle"})) assert pickle.loads(pickle.dumps(tok)) == tok # the family sweep is the point of this test, so a frozen type # added later belongs in it -- Segmentation (#272) carries the same # guarded __getstate__/__setstate__ pair as its three siblings seg = Segmentation((2,), confidence=0.42) assert pickle.loads(pickle.dumps(seg)) == seg # Reading (#752 phase 2m) carries the same guarded pair from nameparser import Reading reading = Reading("name", "family", "declared") # type: ignore[arg-type] assert pickle.loads(pickle.dumps(reading)) == reading def test_span_add_is_blocked() -> None: # NamedTuple + would concatenate into a 4-tuple, the natural but # wrong spelling of "covering span" (a real cover() arrives with the # pipeline's join stage, its consumer). with pytest.raises(TypeError, match="covering span"): Span(0, 2) + Span(3, 4) # type: ignore[operator] def test_token_rejects_bool_span_coordinates() -> None: # bool is an int subclass; (False, True) is a comparison result # leaking into a coordinate slot, not a span. with pytest.raises(TypeError, match="pair of ints"): Token("x", (False, True), Role.GIVEN) # type: ignore[arg-type] def test_setstate_rejects_layout_skew_on_frozen_types() -> None: # version-skewed pickles must fail at the LOAD site, naming the # mismatch -- not at a distant attribute read (same policy as # Lexicon; values are deliberately NOT re-validated: pickle is not # a security boundary) tok = Token("Juan", Span(0, 4), Role.GIVEN) state = tok.__getstate__() bad = dict(state) del bad["tags"] with pytest.raises(ValueError, match="tags"): Token.__new__(Token).__setstate__(bad) pn = _pn("Juan", [tok]) bad_pn = dict(pn.__getstate__()) bad_pn["zq_future"] = () with pytest.raises(ValueError, match="zq_future"): ParsedName.__new__(ParsedName).__setstate__(bad_pn) def test_reading_coerces_its_strings_and_refuses_unknown_values() -> None: from nameparser import Reading, ReadingKind, ReadingSource r = Reading("name", "family", "declared") # type: ignore[arg-type] assert (r.kind, r.role, r.source) == ( ReadingKind.NAME, Role.FAMILY, ReadingSource.DECLARED) assert r == Reading(ReadingKind.NAME, Role.FAMILY, ReadingSource.DECLARED) with pytest.raises(ValueError, match="unknown ReadingKind 'surname'.*" "valid kinds: credential"): Reading("surname", "family", "declared") # type: ignore[arg-type] with pytest.raises(ValueError, match="unknown Role 'last'"): Reading("name", "last", "declared") # type: ignore[arg-type] with pytest.raises(ValueError, match="unknown ReadingSource 'guess'.*" "valid sources: vocabulary"): Reading("name", "family", "guess") # type: ignore[arg-type] def test_ambiguity_alternative_is_a_reading_and_is_not_compared() -> None: from nameparser import Reading tok = Token("MA", Span(11, 13), Role.SUFFIX) alt = Reading("name", "family", "declared") # type: ignore[arg-type] with_alt = Ambiguity(AmbiguityKind.SUFFIX_OR_NAME, "d", (tok,), alt) assert with_alt == Ambiguity(AmbiguityKind.SUFFIX_OR_NAME, "d", (tok,)) with pytest.raises(TypeError, match="must be a Reading or None"): Ambiguity(AmbiguityKind.SUFFIX_OR_NAME, "d", (tok,), ("name", "family")) # type: ignore[arg-type] def _ambiguity_state() -> dict[str, object]: tok = Token("MA", Span(11, 13), Role.SUFFIX) return dict(Ambiguity(AmbiguityKind.SUFFIX_OR_NAME, "d", (tok,)).__getstate__()) def _without(state: dict[str, object], *names: str) -> dict[str, object]: return {k: v for k, v in state.items() if k not in names} def _renamed(state: dict[str, object], old: str, new: str) -> dict[str, object]: out = _without(state, old) out[new] = state[old] return out #: Every layout skew the guard refused before 2.7, which it still #: refuses: the widening (#752 phase 2m) admits only a field marked #: `ADDED_IN` and missing from an older pickle. Measured 2026-10-26: #: each row raises ValueError under the pre-2.7 guard as under this one #: (the row with two fields missing added after, by the first review), #: though where `alternative` is missing the old guard names it among #: the missing fields too, so its message is not the one matched here; #: and the one admitted row below raises under the pre-2.7 guard, which #: is the widening. _REFUSED_SKEWS = [ pytest.param(Ambiguity, lambda: _without(_ambiguity_state(), "detail"), "missing fields: detail", id="missing-field-without-default"), pytest.param(Token, lambda: _without(dict(Token( "Juan", Span(0, 4), Role.GIVEN).__getstate__()), "tags"), "missing fields: tags", id="missing-shipped-field-with-default"), pytest.param(ParsedName, lambda: _without(dict( _pn("Juan", [Token("Juan", Span(0, 4), Role.GIVEN)]).__getstate__()), "ambiguities"), "missing fields: ambiguities", id="missing-shipped-field-with-default-on-parsedname"), pytest.param(Ambiguity, lambda: {**_ambiguity_state(), "zq_future": 1}, "unexpected fields: zq_future", id="unexpected-field"), pytest.param(Ambiguity, lambda: {**_without(_ambiguity_state(), "alternative"), "zq_future": 1}, "unexpected fields: zq_future", id="unexpected-field-beside-a-missing-added-one"), pytest.param(Ambiguity, lambda: _without(_ambiguity_state(), "alternative", "detail"), "missing fields: detail;", id="missing-field-without-default-beside-a-missing-added-one"), pytest.param(Ambiguity, lambda: _renamed(_ambiguity_state(), "detail", "details"), "missing fields: detail; unexpected fields: details", id="renamed-field"), pytest.param(Ambiguity, lambda: _renamed(_ambiguity_state(), "alternative", "alt"), "unexpected fields: alt", id="renamed-added-field"), ] @pytest.mark.parametrize("cls,state,match", _REFUSED_SKEWS) def test_setstate_still_refuses_every_skew_it_refused( cls: type, state: Callable[[], dict[str, object]], match: str) -> None: with pytest.raises(ValueError, match=match): object.__new__(cls).__setstate__(state()) def test_setstate_admits_a_field_added_later_missing_from_an_older_pickle( ) -> None: state = _without(_ambiguity_state(), "alternative") loaded = Ambiguity.__new__(Ambiguity) loaded.__setstate__(state) assert loaded.alternative is None assert loaded == Ambiguity(**state) # type: ignore[arg-type] def test_a_parsedname_pickled_by_2_6_0_loads() -> None: """tests/v2/data/parsedname_v26.pickle is `parse("JOHN SMITH MA")` pickled (protocol 4) by the released 2.6.0 wheel, its Ambiguity written before `alternative` existed. Made 2026-10-26 with `PYTHONSAFEPATH=1 uv run --isolated --no-project --with 'nameparser==2.6.0'` from a directory outside the checkout, the version and the module path asserted (AGENTS.md's v1 comparison trap). pickle.load is safe here: a repo-controlled fixture.""" import pickle with open(Path(__file__).parent / "data" / "parsedname_v26.pickle", "rb") as f: loaded = pickle.load(f) now = parse("JOHN SMITH MA") # the fields and the report as 2.6.0 read them (the tags are # unstable provenance, and `ma` has gained one since) assert loaded.as_dict() == now.as_dict() assert [(a.kind, a.detail) for a in loaded.ambiguities] == [ (a.kind, a.detail) for a in now.ambiguities] assert loaded.ambiguities[0].alternative is None assert now.ambiguities[0].alternative is not None assert pickle.loads(pickle.dumps(now)).ambiguities[0].alternative == ( now.ambiguities[0].alternative) def test_tokens_for_accepts_role_string() -> None: name = parse("Juan de la Vega") assert name.tokens_for("family") == name.tokens_for(Role.FAMILY) assert name.tokens_for("given") == name.tokens_for(Role.GIVEN) def test_tokens_for_unknown_role_raises_listing_roles() -> None: with pytest.raises(ValueError, match=r"unknown Role 'last'.*valid roles: title, given, middle"): parse("John Smith").tokens_for("last") def test_tokens_for_non_coercible_raises() -> None: with pytest.raises(ValueError, match="unknown Role 3"): parse("John Smith").tokens_for(3) # type: ignore[arg-type] def test_stable_tags_is_public_api() -> None: import nameparser assert "STABLE_TAGS" in nameparser.__all__ # the documented stable tag vocabulary -- these four values are API assert nameparser.STABLE_TAGS == frozenset( {"particle", "conjunction", "initial", "joined"}) def test_as_dict_include_empty_is_keyword_only() -> None: with pytest.raises(TypeError): parse("John Smith").as_dict(False) # type: ignore[misc] def test_particle_tag_marks_vocabulary_membership_not_role() -> None: # the tag follows the WORD, not the field: a leading ambiguous # particle read as a given name still carries it (STABLE_TAGS docs) tok = parse("Van Johnson").tokens_for(Role.GIVEN)[0] assert "particle" in tok.tags def test_str_enum_value_sets_are_pairwise_disjoint() -> None: # three StrEnums cross-compare by value; a future collision would # make unrelated members equal from nameparser import AmbiguityKind, PatronymicRule from nameparser import ReadingKind, ReadingSource values = {enum: {m.value for m in enum} for enum in (Role, AmbiguityKind, PatronymicRule, ReadingKind, ReadingSource)} # the one deliberate overlap: a title reading names the title field allowed = {frozenset({Role, ReadingKind}): {"title"}} for a, b in itertools.combinations(values, 2): assert values[a] & values[b] == allowed.get(frozenset({a, b}), set()) def test_with_field_tokens_rejects_mismatched_roles() -> None: name = parse("John Smith") with pytest.raises(ValueError, match="has role family, not given"): name._with_field_tokens( {Role.GIVEN: (Token("x", None, Role.FAMILY),)}) ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_vocab.py��������������������������������������������0000664�0000000�0000000�00000230253�15273032717�0023110�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""The per-word vocabulary (nameparser/vocab/*.toml) and its compiler. The parser reads nameparser/config/_compiled.py, never the TOML, so the two halves of the guard are: the committed module is exactly what the TOML compiles to (the snapshot.jsonl pattern -- a stale module fails with the command that refreshes it), and the compiler's build checks pass on the shipped files with warnings as errors. Each numbered build check then has a mutation below that it must refuse, because a check nothing exercises passes the same way a missing one does. A check WIDENED to refuse more keeps a row for every case its narrower form refused: #737's review widened check 6 from "the same kind" to "the same set" and the new check quietly stopped refusing the excluded kind itself with `acronym` flipped, a case nothing listed because the old check had made it impossible. compile_pool's final acronym-and-word check is a backstop check 5 always reaches first, so its mutation is refused by check 5. """ from __future__ import annotations import importlib.util import re import sys import warnings from pathlib import Path from types import ModuleType from typing import Any import pytest from nameparser._lexicon import Lexicon _BUILD = Path(__file__).resolve().parents[2] / "tools" / "vocab" / "build.py" def _load_build() -> ModuleType: # tools/ is outside testpaths (see _differential_fixtures.load_tool) spec = importlib.util.spec_from_file_location("vocab_build", _BUILD) assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) # registered before it runs: its dataclasses look their module up sys.modules[spec.name] = module spec.loader.exec_module(module) return module build = _load_build() @pytest.fixture(scope="module") def shipped() -> dict[str, str]: return build.read_dir() def _compile(files: dict[str, str]) -> dict[str, Any]: with warnings.catch_warnings(): warnings.simplefilter("error") result: dict[str, Any] = build.compile_files(files) return result # ------------------------------------------------------------ shipped data def test_the_committed_module_is_what_the_toml_compiles_to(shipped: dict[str, str]) -> None: fresh = build.render(_compile(shipped)) committed = build.OUTPUT.read_text(encoding="utf-8") assert committed == fresh, ( f"nameparser/config/_compiled.py is stale against nameparser/vocab/; " f"run: {build.REFRESH}") def test_the_shipped_files_pass_every_check_with_warnings_as_errors( shipped: dict[str, str]) -> None: assert shipped, "no vocabulary files found; the roster is empty" _compile(shipped) def test_the_compiled_sets_are_the_default_lexicon(shipped: dict[str, str]) -> None: c = _compile(shipped) lex = Lexicon.default() rebuilt = Lexicon( titles=c["TITLES"], given_name_titles=c["GIVEN_NAME_TITLES"], suffix_acronyms=c["SUFFIX_ACRONYMS"], suffix_words=c["SUFFIX_WORDS"], suffix_acronyms_ambiguous=c["SUFFIX_ACRONYMS_AMBIGUOUS"], particles=c["PARTICLES"], particles_ambiguous=c["PARTICLES"] - c["NON_GIVEN_NAME_PARTICLES"], conjunctions=c["CONJUNCTIONS"], conjunctions_ambiguous=c["CONJUNCTIONS_AMBIGUOUS"], bound_given_names=c["BOUND_GIVEN_NAMES"], given_name_abbreviations=c["GIVEN_NAME_ABBREVIATIONS"], maiden_markers=c["MAIDEN_MARKERS"], surnames=lex.surnames, honorific_tails=c["GLUED_HONORIFICS"], after_given_titles=c["AFTER_GIVEN_TITLES"], suffix_words_ambiguous=c["SUFFIX_WORDS_AMBIGUOUS"], agnomens=c["AGNOMENS"], titles_ambiguous=c["TITLES_AMBIGUOUS"], title_modifiers=c["TITLE_MODIFIERS"], title_styles=c["TITLE_STYLES"], title_phrases=c["TITLE_PHRASES"], capitalization_exceptions=tuple(c["CAPITALIZATION_EXCEPTIONS"].items()), ) assert rebuilt == lex def test_the_toml_ships_as_package_data() -> None: # pyproject's package-data glob is what puts the files in a wheel; # a file outside it would be in the checkout and nowhere else import tomllib pyproject = tomllib.loads( (_BUILD.parents[2] / "pyproject.toml").read_text(encoding="utf-8")) globs = pyproject["tool"]["setuptools"]["package-data"]["nameparser"] assert "vocab/*.toml" in globs assert not [p for p in build.VOCAB.rglob("*") if p.is_file() and p.suffix != ".toml" and p.name not in build.SKIPPED] # --------------------------------------------- controls on the shipped data def _edit(shipped: dict[str, str], file: str, old: str, new: str) -> dict[str, str]: files = dict(shipped) assert files[file].count(old) == 1, (file, old) files[file] = files[file].replace(old, new) return files def test_dropping_a_name_reading_changes_the_compiled_module(shipped: dict[str, str]) -> None: # the markers are derived from the name readings (check 11), so the # reading is what the compiled module answers to jd = re.search(r'text = "jd"\n(given = \{[^\n]*\}\n)', shipped["en.toml"]) assert jd c = _compile(_edit(shipped, "en.toml", jd.group(1), "")) assert "jd" not in c["SUFFIX_ACRONYMS_AMBIGUOUS"] assert build.render(c) != build.OUTPUT.read_text(encoding="utf-8") van = re.search(r'text = "van"\n(given = \{[^\n]*\}\n)\[word.preposition\]\n', shipped["nl.toml"]) assert van files = _edit(shipped, "nl.toml", van.group(0), 'text = "van"\n[word.preposition]\nmay_be_given = false\n') assert "van" in _compile(files)["NON_GIVEN_NAME_PARTICLES"] def test_moving_a_word_between_files_changes_nothing(shipped: dict[str, str]) -> None: entry = '[[word]]\ntext = "jr"\ngeneration = {}\n' files = _edit(shipped, "en.toml", entry, "") files["und.toml"] += "\n" + entry assert build.render(_compile(files)) == build.render(_compile(shipped)) def test_an_exclusion_in_one_file_refuses_the_reading_in_another( shipped: dict[str, str]) -> None: files = dict(shipped) # appended to an ordered file, out of place: the reason still comes # first, check 10 running last files["en.toml"] += '\n[[word]]\ntext = "père"\ngeneration = {}\n' with pytest.raises(build.VocabError, match="collides outside French"): _compile(files) def test_a_reading_off_by_default_compiles_to_nothing() -> None: # recorded and checked, for a pack to turn on (phase 3): it feeds no # set, marks nothing beside a name reading, and so cannot clash with # the word's live readings in another file (checks 4 and 5) or be # refused by an exclusion, which keeps a reading out of the default # where this one already is (check 6) off = build.compile_files({"es.toml": _w('[[word]]\ntext = "zzq"\ngeneration = { default = false }\nfamily = { note = "x" }')}) assert not any("zzq" in v for v in off.values()) both = build.compile_files({ "mul.toml": _w('[[word]]\ntext = "zzq"\ngeneration = {}'), "de.toml": _w('[[word]]\ntext = "zzq"\ncredential = { default = false }'), "hr.toml": _w('[[word]]\ntext = "zzr"\nexcluded = { generation = "x" }'), "cs.toml": _w('[[word]]\ntext = "zzr"\ngeneration = { default = false }'), }) assert "zzq" in both["SUFFIX_WORDS"] and "zzq" not in both["SUFFIX_ACRONYMS"] assert not any("zzr" in v for v in both.values()) def test_a_suffix_word_beside_a_name_reading_is_gated_and_an_agnomen_by_the_count() -> None: # the derived marker for a suffix word (rules/S11): a generation # beside a name reading is gated with the case cue, an agnomen with # none; a word with no name reading marks nothing c = build.compile_files({"fr.toml": _w( '[[word]]\ntext = "zzq"\ngeneration = {}\nfamily = { note = "x" }\n\n' '[[word]]\ntext = "zzr"\ngeneration = {}'), "pt.toml": _w('[[word]]\ntext = "zzs"\nagnomen = {}\ngiven = { note = "x" }')}) assert {"zzq", "zzr", "zzs"} <= c["SUFFIX_WORDS"] assert c["SUFFIX_WORDS_AMBIGUOUS"] == {"zzq", "zzs"} assert c["AGNOMENS"] == {"zzs"} # only the kinds a gate reads; another word kind beside a name # reading is refused (check 11), never left ungated # ------------------------------------------------- one mutation per check OK = 'schema = 1\n\n[[word]]\ntext = "jr"\ngeneration = {}\n' def _w(body: str) -> str: return f"schema = 1\n\n{body}\n" MUTATIONS = [ # 1: schema, unknown keys, file names ("no-schema", {"und.toml": '[[word]]\ntext = "jr"\ngeneration = {}\n'}, "needs `schema = 1`"), ("newer-schema", {"und.toml": OK.replace("schema = 1", "schema = 2")}, "newer than this compiler"), ("unknown-top-level-key", {"und.toml": "language = 'en'\n" + OK}, "unknown key 'language'"), ("reserved-top-level-key", {"und.toml": "prefer = ['pt']\n" + OK}, "reserved for phase 3"), ("file-name-not-a-tag", {"english.toml": OK}, "BCP 47 tag"), ("file-name-not-canonical-case", {"pt-br.toml": OK}, "BCP 47 tag"), ("file-with-no-entries", {"en.toml": OK, "und.toml": "schema = 1\n"}, "no \\[\\[word\\]\\] entries"), ("reserved-reading-property", {"und.toml": _w('[[word]]\ntext = "mba"\ncredential = { bare_only = true }')}, "reserved for phase 4"), ("unknown-reading-property", {"und.toml": _w('[[word]]\ntext = "mba"\ncredential = { acronymm = true }')}, "unknown key 'acronymm'"), ("unknown-word-key", {"und.toml": _w('[[word]]\ntext = "jr"\ngeneration = {}\nlang = "en"')}, "unknown key 'lang'"), ("descriptive-key-on-the-word", {"und.toml": _w('[[word]]\ntext = "jr"\ngeneration = {}\nnote = "x"')}, "belongs on a reading"), # 2: text ("text-not-normalized", {"en.toml": _w('[[word]]\ntext = "Jnr."\ngeneration = {}')}, "normalized form"), ("text-double-spaced", {"und.toml": _w('[[word]]\ntext = "z domu"\nbirth_marker = {}')}, "normalized form"), ("text-missing", {"und.toml": _w("[[word]]\ngeneration = {}")}, "non-empty `text`"), # a text no token can equal: the tokenizer divides at a comma and a # nakaguro, strips a bidi mark, and keeps an invisible character the # file does not show ("text-with-a-comma", {"en.toml": _w('[[word]]\ntext = "jr,"\ngeneration = {}')}, "divides a name at"), ("text-with-a-nakaguro", {"ja.toml": _w('[[word]]\ntext = "さん・"\nhonorific = {}')}, "divides a name at"), ("text-with-a-bidi-mark", {"he.toml": _w('[[word]]\ntext = "dr\\u200f"\ntitle = {}')}, "invisible character U\\+200F"), ("text-with-a-zero-width-space", {"en.toml": _w('[[word]]\ntext = "dr\\u200b"\ntitle = {}')}, "invisible character U\\+200B"), ("text-with-a-control-character", {"en.toml": _w('[[word]]\ntext = "dr\\u0007"\ntitle = {}')}, "invisible character U\\+0007"), ("text-with-an-emoji", {"en.toml": _w('[[word]]\ntext = "dr★"\ntitle = {}')}, "strips by default"), ("text-with-an-east-asian-interpunct", {"zh.toml": _w('[[word]]\ntext = "先·生"\nhonorific = {}')}, "rules/T3"), ("phrase-word-with-an-edge-period", {"de.toml": _w('[[word]]\ntext = "dr. med"\ntitle = { addresses = "given" }')}, "write 'dr med'"), ("text-twice-in-a-file", {"en.toml": _w('[[word]]\ntext = "jr"\ngeneration = {}\n\n[[word]]\ntext = "jr"\nstatus = {}')}, "listed twice"), # 3: kinds, properties and values ("unregistered-kind", {"hr.toml": _w('[[word]]\ntext = "mlađi"\ngeneraton = {}')}, "unknown key 'generaton'"), ("reading-not-a-table", {"en.toml": _w('[[word]]\ntext = "jr"\ngeneration = true')}, "must be a table"), ("inert-entry", {"en.toml": _w('[[word]]\ntext = "jr"')}, "inert"), ("addresses-typo", {"und.toml": _w('[[word]]\ntext = "sheikh"\ntitle = { addresses = "givn" }')}, "must be 'family' or 'given'"), # every title kind, not only the generic one (#750): keyed on the # kind, a typo on a classified title would compile as the family ("addresses-typo-on-a-title-kind", {"ar.toml": _w('[[word]]\ntext = "sheikh"\nstyle = { addresses = "givn" }')}, "must be 'family' or 'given'"), ("acronym-with-a-period", {"und.toml": _w('[[word]]\ntext = "x.q"\ncredential = {}')}, "matched with every period removed"), ("excluded-property-unknown", {"ja.toml": _w('[[word]]\ntext = "殿"\nhonorific = { excluded = { atached = "x" } }')}, "not a behavioral property"), ("excluded-defaulted-property-overridden", {"und.toml": _w('[[word]]\ntext = "mba"\ncredential = { acronym = false, excluded = { acronym = "x" } }')}, "acronym = false is excluded"), ("excluded-property-set-in-another-file", {"ja.toml": _w('[[word]]\ntext = "名"\nhonorific = { excluded = { attached = "x" } }'), "zh.toml": _w('[[word]]\ntext = "名"\nhonorific = { attached = { side = "after" } }')}, "attached = .* is excluded"), # `cased` compiles per word, so its exclusion holds over every kind ("excluded-cased-set-by-another-kind", {"und.toml": _w('[[word]]\ntext = "mc"\ntitle = { excluded = { cased = "keep as written" } }'), "en.toml": _w('[[word]]\ntext = "mc"\nparticle = { may_be_given = false, cased = "MC" }')}, "keep as written"), # a property exclusion's baseline is the EXCLUDING kind's unwritten # value: a credential keeps its acronym unwritten, a generic suffix # does not, so the word reading is the refused one, and says so ("excluded-acronym-sidestepped-by-a-word-kind", {"en.toml": _w('[[word]]\ntext = "xyz"\ncredential = { excluded = { acronym = "keep acronym" } }'), "fr.toml": _w('[[word]]\ntext = "xyz"\nsuffix = {}')}, "acronym = false is excluded.*keep acronym"), # a refused value is shown as TOML writes it, dates included ("refused-value-with-a-date", {"ja.toml": _w('[[word]]\ntext = "名"\nhonorific = { excluded = { attached = "x" } }'), "zh.toml": _w('[[word]]\ntext = "名"\nhonorific = { attached = { side = "after", decided = 2026-10-18 } }')}, "decided = 2026-10-18 }"), # the exclusion's reason is shown before check 4's complaint ("excluded-reason-shown-before-one-kind-per-word", {"fr.toml": _w('[[word]]\ntext = "fils"\ncredential = {}\nexcluded = { generation = "RECORDED REASON" }'), "en.toml": _w('[[word]]\ntext = "fils"\ngeneration = {}')}, "RECORDED REASON"), ("excluded-property-reason-shown-before-one-kind-per-word", {"ja.toml": _w('[[word]]\ntext = "名"\nhonorific = { excluded = { attached = "RECORDED REASON" } }'), "zh.toml": _w('[[word]]\ntext = "名"\ncourtesy = { attached = { side = "after" } }')}, "RECORDED REASON"), ("addresses-reserved-value", {"und.toml": _w('[[word]]\ntext = "sheikh"\ntitle = { addresses = "any" }')}, "reserved for a later phase"), ("addresses-reserved-value-on-a-title-kind", {"en.toml": _w('[[word]]\ntext = "sir"\nnobility = { addresses = "full" }')}, "reserved for a later phase"), ("attached-on-an-acronym", {"und.toml": _w('[[word]]\ntext = "mba"\ncredential = { attached = { side = "after" } }')}, "needs a word reading"), ("attached-before", {"ja.toml": _w('[[word]]\ntext = "さん"\nhonorific = { attached = { side = "before" } }')}, "side = 'after'"), ("may-be-initial-on-a-word", {"und.toml": _w('[[word]]\ntext = "and"\nconjunction = { may_be_initial = true }')}, "CASED single letter"), ("may-be-initial-caseless", {"ar.toml": _w('[[word]]\ntext = "و"\nconjunction = { may_be_initial = true }')}, "CASED single letter"), ("after-given-on-an-office", {"en.toml": _w('[[word]]\ntext = "king"\nnobility = { after_given = true }')}, "after_given is allowed on a salutation reading only"), ("after-given-on-a-generic-title", {"und.toml": _w('[[word]]\ntext = "zzq"\ntitle = { after_given = true }')}, "after_given is allowed on a salutation reading only"), # herr's shape: refused in de.toml, flagged in da.toml ("excluded-after-given-in-another-file", {"de.toml": _w('[[word]]\ntext = "herr"\nsalutation = { excluded = { after_given = "a surname" } }'), "da.toml": _w('[[word]]\ntext = "herr"\nsalutation = { after_given = true }')}, "after_given = true is excluded.*a surname"), ("after-given-not-a-flag", {"en.toml": _w('[[word]]\ntext = "mr"\nsalutation = { after_given = "yes" }')}, "after_given must be true or false"), # 11: a clash with a name is a name reading; the two markers that # were flags are refused, naming the reading to write ("may-be-name-refused", {"en.toml": _w('[[word]]\ntext = "ma"\ncredential = { may_be_name = true }')}, "may_be_name = true is derived.*`given` or `family` reading"), ("may-be-name-false-refused", {"en.toml": _w('[[word]]\ntext = "ma"\ncredential = { may_be_name = false }')}, "may_be_name = false is derived"), ("may-be-name-on-a-word", {"en.toml": _w('[[word]]\ntext = "jr"\ngeneration = { may_be_name = true }')}, "may_be_name = true is derived"), ("may-be-given-true-refused", {"und.toml": _w('[[word]]\ntext = "van"\nparticle = { may_be_given = true }')}, "may_be_given = true is derived.*a `given` reading"), ("may-be-given-true-refused-on-a-particle-kind", {"nl.toml": _w('[[word]]\ntext = "van"\npreposition = { may_be_given = true }')}, "may_be_given = true is derived"), ("may-be-given-on-a-title", {"en.toml": _w('[[word]]\ntext = "dr"\ntitle = { may_be_given = false }')}, "may_be_given = false is derived"), ("may-be-given-false-beside-a-given-reading", {"nl.toml": _w('[[word]]\ntext = "van"\ngiven = { note = "x" }\npreposition = { may_be_given = false }')}, "contradicts the word's given reading"), ("may-be-given-false-beside-a-given-reading-in-another-file", {"nl.toml": _w('[[word]]\ntext = "van"\npreposition = { may_be_given = false }'), "en.toml": _w('[[word]]\ntext = "van"\ngiven = { note = "x" }\nexcluded = { title = "x" }')}, "contradicts the word's given reading"), ("particle-without-may-be-given", {"und.toml": _w('[[word]]\ntext = "van"\nparticle = {}')}, "states may_be_given"), # a family reading is not a given reading: the particle still states ("particle-beside-a-family-reading-without-may-be-given", {"nl.toml": _w('[[word]]\ntext = "van"\nfamily = { note = "x" }\npreposition = {}')}, "states may_be_given"), # a name reading may stand without a count, never without a reason ("name-reading-without-a-count-or-a-note", {"nl.toml": _w('[[word]]\ntext = "van"\ngiven = {}\npreposition = {}')}, "with no count needs a `note`"), # every particle kind, not only the generic one (#749): unwritten, # the flag would read false and put the word in the never-given half ("particle-kind-without-may-be-given", {"nl.toml": _w('[[word]]\ntext = "van"\npreposition = {}')}, "states may_be_given"), ("flag-not-a-bool", {"und.toml": _w('[[word]]\ntext = "van"\nparticle = { may_be_given = "yes" }')}, "true or false"), ("issue-not-a-number", {"en.toml": _w('[[word]]\ntext = "jr"\ngeneration = { issue = "661" }')}, "issue number"), # `default`: a reading is on unwritten, so only false is worth # writing; and the four names phase 2 left reserved say which phase ("default-true", {"en.toml": _w('[[word]]\ntext = "jr"\ngeneration = { default = true }')}, "write default = false"), ("default-not-a-bool", {"en.toml": _w('[[word]]\ntext = "jr"\ngeneration = { default = "no" }')}, "write default = false"), ("attaches-not-a-list", {"en.toml": _w('[[word]]\ntext = "chief"\noffice = { attaches = "military" }')}, "attaches must be a non-empty list of title kinds"), ("attaches-empty", {"en.toml": _w('[[word]]\ntext = "chief"\noffice = { attaches = [] }')}, "attaches must be a non-empty list of title kinds"), ("attaches-twice", {"en.toml": _w('[[word]]\ntext = "chief"\noffice = { attaches = ["military", "military"] }')}, "attaches must be a non-empty list of title kinds"), ("attaches-a-suffix-kind", {"en.toml": _w('[[word]]\ntext = "chief"\noffice = { attaches = ["credential"] }')}, "attaches names 'credential', which is not a title kind"), ("attaches-the-generic-kind", {"en.toml": _w('[[word]]\ntext = "chief"\noffice = { attaches = ["title"] }')}, "attaches names 'title', which is not a title kind"), ("attaches-a-word-with-no-title-reading", {"en.toml": _w('[[word]]\ntext = "chief"\noffice = { attaches = ["zzq"] }')}, "attaches names 'zzq', which is no title kind and no title word"), ("attaches-on-a-suffix", {"en.toml": _w('[[word]]\ntext = "phd"\ncredential = { attaches = ["office"] }')}, "unknown key 'attaches'"), ("attaches-differs-between-files", {"en.toml": _w('[[word]]\ntext = "chief"\noffice = { attaches = ["military"] }'), "fr.toml": _w('[[word]]\ntext = "chief"\noffice = {}')}, "attaches"), ("reserved-separators", {"th.toml": _w('[[word]]\ntext = "นาย"\nsalutation = { separators = [] }')}, "reserved for phase 4"), # 11, the agnomen's half: an agnomen is gated by its name reading, # so one without would take the family name from two-word names ("name-reading-beside-an-honorific", {"ja.toml": _w('[[word]]\ntext = "zzt"\nhonorific = {}\nfamily = { note = "x" }')}, "gates nothing"), ("name-reading-beside-a-generic-suffix-word", {"en.toml": _w('[[word]]\ntext = "zzt"\nsuffix = {}\ngiven = { note = "x" }')}, "gates nothing"), ("agnomen-as-an-acronym", {"pt.toml": _w('[[word]]\ntext = "filho"\nagnomen = { acronym = true }\nfamily = { note = "x" }')}, "an agnomen is a word"), ("agnomen-without-a-name-reading", {"pt.toml": _w('[[word]]\ntext = "filho"\nagnomen = {}')}, "an agnomen needs a `given` or `family` reading"), ("agnomen-whose-name-reading-is-elsewhere-only-as-an-exclusion", {"pt.toml": _w('[[word]]\ntext = "filho"\nagnomen = {}\nexcluded = { family = "x" }')}, "an agnomen needs a `given` or `family` reading"), ("decided-not-a-date", {"en.toml": _w('[[word]]\ntext = "jr"\ngeneration = { decided = "2026-10-18" }')}, "TOML date"), # tomllib reads a date-time as a datetime, which isinstance calls a date ("decided-a-date-time", {"en.toml": _w('[[word]]\ntext = "jr"\ngeneration = { decided = 2026-10-18T09:00:00 }')}, "not a string or a date-time"), ("as-of-a-date-time", {"fr.toml": _w('[[word]]\ntext = "fils"\nfamily = { us_census_2010 = 767, as_of = 2026-10-16T00:00:00Z }\nexcluded = { generation = "x" }')}, "as_of must be a TOML date"), ("as-of-without-a-count", {"fr.toml": _w('[[word]]\ntext = "fils"\nfamily = { as_of = 2026-10-16 }\nexcluded = { generation = "x" }')}, "as_of dates a count"), ("gender-out-of-range", {"en.toml": _w('[[word]]\ntext = "jr"\ngeneration = { gender = "male" }')}, "gender must be"), ("count-without-as-of", {"fr.toml": _w('[[word]]\ntext = "fils"\nfamily = { us_census_2010 = 767 }\nexcluded = { generation = "x" }')}, "needs `as_of`"), ("count-source-typo", {"fr.toml": _w('[[word]]\ntext = "fils"\nfamily = { us_census = 767, as_of = 2026-10-16 }\nexcluded = { generation = "x" }')}, "unknown key 'us_census'"), ("exclusion-without-a-reason", {"fr.toml": _w('[[word]]\ntext = "fils"\nexcluded = { generation = { issue = 684 } }')}, "needs a reason"), ("exclusion-of-an-unknown-kind", {"fr.toml": _w('[[word]]\ntext = "fils"\nexcluded = { generaton = "x" }')}, "unknown kind"), ("refused-property-on-its-own-reading", {"ja.toml": _w('[[word]]\ntext = "殿"\nhonorific = { attached = { side = "after" }, excluded = { attached = "surnames end in it" } }')}, "surnames end in it"), # 4: one suffix kind per word, across files ("two-suffix-kinds", {"en.toml": OK, "und.toml": _w('[[word]]\ntext = "jr"\nstatus = {}')}, "one suffix kind per word"), # 5: one word reads one way in every file ("acronym-disagrees-across-files", {"ja.toml": _w('[[word]]\ntext = "博士"\ncredential = {}'), "zh.toml": _w('[[word]]\ntext = "博士"\ncredential = { acronym = false }')}, "acronym is"), # may_be_given is stated per reading, so each file's reading states # it (check 11): one file stating false does not cover another's, # across files and across particle kinds (#749's rows, rewritten when # the marker was derived) ("may-be-given-disagrees-across-files", {"und.toml": _w('[[word]]\ntext = "van"\nparticle = {}'), "nl.toml": _w('[[word]]\ntext = "van"\nparticle = { may_be_given = false }')}, "und.toml: 'van' particle: a particle states may_be_given"), ("may-be-given-disagrees-across-particle-kinds", {"fr.toml": _w('[[word]]\ntext = "de"\npreposition = { may_be_given = false }'), "nl.toml": _w('[[word]]\ntext = "de"\narticle = {}')}, "nl.toml: 'de' article: a particle states may_be_given"), # `cased` too: the narrower check let two kinds through to the # per-word mask check, which refused them with its own message ("cased-disagrees-across-particle-kinds", {"und.toml": _w('[[word]]\ntext = "ó"\nparticle = { may_be_given = false, cased = "Ó" }'), "ga.toml": _w('[[word]]\ntext = "ó"\nclan = { may_be_given = false, cased = "ó" }')}, "cased is"), ("may-be-given-disagrees-with-the-generic-kind", {"und.toml": _w('[[word]]\ntext = "van"\nparticle = {}'), "nl.toml": _w('[[word]]\ntext = "van"\npreposition = { may_be_given = false }')}, "und.toml: 'van' particle: a particle states may_be_given"), ("addresses-disagrees-across-files", {"und.toml": _w('[[word]]\ntext = "sir"\ntitle = { addresses = "given" }'), "en.toml": _w('[[word]]\ntext = "sir"\ntitle = {}')}, "addresses is"), # keyed on the category: a title may take another kind in each file # (#750), and the kinds must still agree on which name they address ("addresses-disagrees-across-title-kinds", {"en.toml": _w('[[word]]\ntext = "sir"\nnobility = { addresses = "given" }'), "fr.toml": _w('[[word]]\ntext = "sir"\nsalutation = {}')}, "addresses is"), ("addresses-disagrees-with-the-generic-title", {"und.toml": _w('[[word]]\ntext = "sir"\ntitle = {}'), "en.toml": _w('[[word]]\ntext = "sir"\nnobility = { addresses = "given" }')}, "addresses is"), # 6: an exclusion holds across files ("excluded-kind-in-another-file", {"fr.toml": _w('[[word]]\ntext = "fils"\nexcluded = { generation = "Marie Fils" }'), "en.toml": _w('[[word]]\ntext = "fils"\ngeneration = {}')}, "Marie Fils"), # the excluded kind ITSELF, with a property moving it to the other # set: the narrower test refused this, so the widened one must too ("excluded-kind-with-acronym-flipped", {"fr.toml": _w('[[word]]\ntext = "fils"\nexcluded = { generation = "Marie Fils" }'), "en.toml": _w('[[word]]\ntext = "fils"\ngeneration = { acronym = true }')}, "Marie Fils"), ("excluded-credential-as-a-word", {"und.toml": _w('[[word]]\ntext = "rai"\nexcluded = { credential = "a surname" }'), "en.toml": _w('[[word]]\ntext = "rai"\ncredential = { acronym = false }')}, "a surname"), ("excluded-kind-in-the-same-entry", {"en.toml": _w('[[word]]\ntext = "fils"\ngeneration = { acronym = true }\nexcluded = { generation = "Marie Fils" }')}, "Marie Fils"), ("excluded-kind-sidestepped-by-the-generic-kind", {"fr.toml": _w('[[word]]\ntext = "fils"\nexcluded = { generation = "Marie Fils" }'), "und.toml": _w('[[word]]\ntext = "fils"\nsuffix = {}')}, "Marie Fils"), ("excluded-kind-sidestepped-by-a-sibling-kind", {"fr.toml": _w('[[word]]\ntext = "fils"\nexcluded = { generation = "Marie Fils" }'), "en.toml": _w('[[word]]\ntext = "fils"\nstatus = {}')}, "Marie Fils"), ("excluded-credential-sidestepped-by-an-acronym", {"und.toml": _w('[[word]]\ntext = "rai"\nexcluded = { credential = "a surname" }'), "en.toml": _w('[[word]]\ntext = "rai"\ncourtesy = { acronym = true }')}, "a surname"), # a particle kind's exclusion refuses every other particle kind: # one particle kind per file made this impossible before #749 ("excluded-particle-kind-sidestepped-by-a-sibling-kind", {"ar.toml": _w('[[word]]\ntext = "ben"\nexcluded = { patronymic = "Ben is a given name" }'), "pl.toml": _w('[[word]]\ntext = "ben"\npreposition = { may_be_given = false }')}, "Ben is a given name"), ("excluded-particle-kind-sidestepped-by-the-generic-kind", {"ar.toml": _w('[[word]]\ntext = "ben"\nexcluded = { patronymic = "Ben is a given name" }'), "und.toml": _w('[[word]]\ntext = "ben"\nparticle = { may_be_given = false }')}, "Ben is a given name"), ("excluded-particle-category-refuses-a-kind", {"ga.toml": _w('[[word]]\ntext = "o"\nexcluded = { particle = "a periodless initial" }\nclan = { may_be_given = false }')}, "a periodless initial"), # a title kind's exclusion refuses every other title kind, and the # title category every kind: one title kind made both impossible # before #750 ("excluded-title-kind-sidestepped-by-a-sibling-kind", {"cs.toml": _w('[[word]]\ntext = "pan"\nexcluded = { salutation = "the Chinese surname Pan" }'), "en.toml": _w('[[word]]\ntext = "pan"\noffice = {}')}, "the Chinese surname Pan"), ("excluded-title-kind-sidestepped-by-the-generic-kind", {"cs.toml": _w('[[word]]\ntext = "pan"\nexcluded = { salutation = "the Chinese surname Pan" }'), "und.toml": _w('[[word]]\ntext = "pan"\ntitle = {}')}, "the Chinese surname Pan"), # a refused property is refused on every kind of its category, with # the recorded reason: keyed on the kind, check 5 would still refuse # this, but with its own message instead of the reason ("excluded-addresses-reason-shown-across-title-kinds", {"ar.toml": _w('[[word]]\ntext = "sheikh"\nsalutation = { excluded = { addresses = "RECORDED REASON" } }'), "en.toml": _w('[[word]]\ntext = "sheikh"\nnobility = { addresses = "given" }')}, "RECORDED REASON"), ("excluded-title-category-refuses-a-kind", {"vi.toml": _w('[[word]]\ntext = "ông"\nexcluded = { title = "a surname that leads" }\nsalutation = {}')}, "a surname that leads"), ("excluded-category-in-another-file", {"und.toml": _w('[[word]]\ntext = "rai"\nexcluded = { suffix = "a surname" }'), "en.toml": _w('[[word]]\ntext = "rai"\ncredential = {}')}, "a surname"), # a reason written as a table reaches the refusal too ("excluded-kind-with-a-table-reason", {"fr.toml": _w('[[word]]\ntext = "fils"\nexcluded = { generation = { reason = "TABLE REASON", issue = 684 } }'), "en.toml": _w('[[word]]\ntext = "fils"\ngeneration = {}')}, "TABLE REASON"), ("excluded-property-in-another-file", {"ja.toml": _w('[[word]]\ntext = "博士"\ncredential = { acronym = false, excluded = { attached = "Hiroshi" } }'), "zh.toml": _w('[[word]]\ntext = "博士"\ncredential = { acronym = false, attached = { side = "after" } }')}, "Hiroshi"), # 7: same_as and cased ("same-as-missing-target", {"en.toml": _w('[[word]]\ntext = "jnr"\ngeneration = { same_as = "jr" }')}, "same_as 'jr'"), ("same-as-its-own-word", {"en.toml": _w('[[word]]\ntext = "jr"\ngeneration = { same_as = "jr" }')}, "names the word itself"), ("same-as-other-category", {"en.toml": _w('[[word]]\ntext = "jr"\ntitle = {}\n\n[[word]]\ntext = "jnr"\ngeneration = { same_as = "jr" }')}, "no suffix reading"), ("cased-spells-other-letters", {"und.toml": _w('[[word]]\ntext = "phd"\ncredential = { cased = "PhDr" }')}, "case mask"), ("two-cased-values", {"und.toml": _w('[[word]]\ntext = "ó"\nparticle = { may_be_given = false, cased = "Ó" }'), "ga.toml": _w('[[word]]\ntext = "ó"\ntitle = { cased = "ó" }')}, "one cased mask per word"), ("cased-disagrees-across-files", {"und.toml": _w('[[word]]\ntext = "ó"\nparticle = { may_be_given = false, cased = "Ó" }'), "ga.toml": _w('[[word]]\ntext = "ó"\nparticle = { may_be_given = false, cased = "ó" }')}, "cased is"), # 8: phrases ("phrase-title-addressing-the-family", {"und.toml": _w('[[word]]\ntext = "deputy secretary"\ntitle = {}')}, "phrase can never match"), ("phrase-title-kind-addressing-the-family", {"en.toml": _w('[[word]]\ntext = "deputy secretary"\noffice = {}')}, "phrase can never match"), ("phrase-with-cased", {"und.toml": _w('[[word]]\ntext = "sir and dame"\ntitle = { addresses = "given", cased = "Sir and Dame" }')}, "looked up per word"), ("phrase-particle", {"und.toml": _w('[[word]]\ntext = "de la"\nparticle = { may_be_given = false }')}, "phrase can never match"), # 9: a bound given name that is a particle has a given reading ("bound-given-particle-never-given", {"und.toml": _w('[[word]]\ntext = "abu"\nparticle = { may_be_given = false }\nbound_given = {}')}, "needs a given reading"), ("bound-given-particle-kind-never-given", {"und.toml": _w('[[word]]\ntext = "abu"\nbound_given = {}'), "ar.toml": _w('[[word]]\ntext = "abu"\nteknonymic = { may_be_given = false }')}, "needs a given reading"), # a family reading does not answer it ("bound-given-particle-with-a-family-reading", {"und.toml": _w('[[word]]\ntext = "abu"\nfamily = { note = "x" }\nparticle = { may_be_given = false }\nbound_given = {}')}, "needs a given reading"), # the data invariants that were import-time asserts ("elided-particle-without-its-twin", {"und.toml": _w('[[word]]\ntext = "d\'"\nparticle = { may_be_given = false }')}, "U\\+2019 spelling"), ("elided-particle-kind-without-its-twin", {"fr.toml": _w('[[word]]\ntext = "l\'"\narticle = { may_be_given = false }')}, "U\\+2019 spelling"), ("one-kind-acronym-and-word", {"en.toml": _w('[[word]]\ntext = "abc"\ncourtesy = {}'), "und.toml": _w('[[word]]\ntext = "abc"\ncourtesy = { acronym = true }')}, "acronym is"), # 10: canonical word order. A swapped pair, within a kind and across # kinds and categories ("words-out-of-order", {"en.toml": _w('[[word]]\ntext = "sr"\ngeneration = {}\n\n[[word]]\ntext = "jr"\ngeneration = {}')}, "'sr' \\(generation\\) belongs after 'jr'"), ("kinds-out-of-order", {"en.toml": _w('[[word]]\ntext = "esq"\ncourtesy = {}\n\n[[word]]\ntext = "jr"\ngeneration = {}')}, "'esq' \\(courtesy\\) belongs after 'jr'"), ("categories-out-of-order", {"de.toml": _w('[[word]]\ntext = "von"\npreposition = { may_be_given = false }\n\n[[word]]\ntext = "dr"\ntitle = {}')}, "'von' \\(preposition\\) belongs after 'dr'"), # the title kinds sort in CATEGORY's order, inside the category ("title-kinds-out-of-order", {"en.toml": _w('[[word]]\ntext = "mayor"\noffice = {}\n\n[[word]]\ntext = "mr"\nsalutation = {}')}, "'mayor' \\(office\\) belongs after 'mr'"), # a same_as variant sorts after its head, not by its own text: in # plain alphabetical order jnr would come first ("same-as-variant-before-its-head", {"en.toml": _w('[[word]]\ntext = "jnr"\ngeneration = { same_as = "jr" }\n\n[[word]]\ntext = "jr"\ngeneration = {}')}, "'jnr' \\(generation\\) belongs after 'jr'"), # and beside it: alphabetically 'younger' would follow 'sr' ("same-as-variant-away-from-its-head", {"en.toml": _w('[[word]]\ntext = "jr"\ngeneration = {}\n\n[[word]]\ntext = "sr"\ngeneration = {}\n\n[[word]]\ntext = "younger"\ngeneration = { same_as = "jr" }')}, "'sr' \\(generation\\) belongs after 'younger'"), # an entry recording only a refusal sorts under the kind it refuses ("refusal-out-of-its-kind", {"en.toml": _w('[[word]]\ntext = "jr"\ngeneration = {}\n\n[[word]]\ntext = "esq"\ncourtesy = {}\n\n[[word]]\ntext = "senior"\nexcluded = { generation = "x" }')}, "'esq' \\(courtesy\\) belongs after 'senior'"), ("refusal-first-in-the-file", {"en.toml": _w('[[word]]\ntext = "senior"\nexcluded = { generation = "x" }\n\n[[word]]\ntext = "jr"\ngeneration = {}')}, "'senior' \\(sorted as generation, the kind it refuses\\) belongs after 'jr'"), # the entry named is the one to move: a word appended at the end, # not the neighbours it displaced ("appended-word-out-of-place", {"en.toml": _w('[[word]]\ntext = "jr"\ngeneration = {}\n\n[[word]]\ntext = "sr"\ngeneration = {}\n\n[[word]]\ntext = "aa"\ngeneration = {}')}, "'aa' \\(generation\\) belongs before 'jr'"), # placed against the run kept in order, not beside a neighbour that # is out of place itself: of c d b a, b goes before c, not after a ("place-named-against-the-kept-run", {"en.toml": _w('[[word]]\ntext = "c"\ngeneration = {}\n\n[[word]]\ntext = "d"\ngeneration = {}\n\n[[word]]\ntext = "b"\ngeneration = {}\n\n[[word]]\ntext = "a"\ngeneration = {}')}, "'b' \\(generation\\) belongs before 'c'"), ("appended-word-mid-file", {"en.toml": _w('[[word]]\ntext = "jr"\ngeneration = {}\n\n[[word]]\ntext = "sr"\ngeneration = {}\n\n[[word]]\ntext = "ta"\ngeneration = {}\n\n[[word]]\ntext = "tb"\ngeneration = {}\n\n[[word]]\ntext = "ka"\ngeneration = {}')}, "'ka' \\(generation\\) belongs after 'jr'"), # a refused category sorts as its first kind ("refused-category-out-of-place", {"en.toml": _w('[[word]]\ntext = "zzz"\nexcluded = { relation = "x" }\n\n[[word]]\ntext = "jr"\ngeneration = {}')}, "'zzz' \\(sorted as birth_marker, the kind it refuses\\) belongs after 'jr'"), # the file's own chains decide its order, not another file's: de # makes jr a spelling of aa, and en must still put its jr first ("other-files-chain-does-not-place-a-head", {"de.toml": _w('[[word]]\ntext = "aa"\ngeneration = {}\n\n[[word]]\ntext = "jr"\ngeneration = { same_as = "aa" }'), "en.toml": _w('[[word]]\ntext = "jnr"\ngeneration = { same_as = "jr" }\n\n[[word]]\ntext = "jr"\ngeneration = {}')}, "en.toml: 'jnr' \\(generation\\) belongs after 'jr'"), # every other refusal is seen before the order's: a same_as typo ("same-as-typo-before-the-order", {"en.toml": _w('[[word]]\ntext = "jr"\ngeneration = {}\n\n[[word]]\ntext = "sr"\ngeneration = {}\n\n[[word]]\ntext = "younger"\ngeneration = { same_as = "jrx" }')}, "same_as 'jrx' has no suffix reading"), # a chain must end for the key to exist; und.toml's too, though # its order is not checked ("same-as-cycle", {"en.toml": _w('[[word]]\ntext = "jr"\ngeneration = { same_as = "jnr" }\n\n[[word]]\ntext = "jnr"\ngeneration = { same_as = "jr" }')}, "same_as loops, jnr -> jr -> jnr;"), ("same-as-cycle-in-und", {"und.toml": _w('[[word]]\ntext = "jr"\ngeneration = { same_as = "jnr" }\n\n[[word]]\ntext = "jnr"\ngeneration = { same_as = "jr" }')}, "same_as loops, jnr -> jr -> jnr;"), # over every same_as written: a loop across files, one another file's # head for the same word would hide, and one through an entry's # second kind of a category ("same-as-cycle-across-files", {"en.toml": _w('[[word]]\ntext = "jr"\ngeneration = {}\n\n[[word]]\ntext = "jnr"\ngeneration = { same_as = "jr" }'), "fr.toml": _w('[[word]]\ntext = "jr"\ngeneration = { same_as = "jnr" }')}, "same_as loops"), ("same-as-cycle-behind-another-files-head", {"de.toml": _w('[[word]]\ntext = "x"\ngeneration = {}\n\n[[word]]\ntext = "jr"\ngeneration = { same_as = "x" }'), "en.toml": _w('[[word]]\ntext = "jnr"\ngeneration = { same_as = "jr" }\n\n[[word]]\ntext = "jr"\ngeneration = { same_as = "jnr" }')}, "same_as loops, jnr -> jr -> jnr;"), ("same-as-cycle-through-a-second-kind", {"nl.toml": _w('[[word]]\ntext = "c"\npreposition = { may_be_given = false }\n\n[[word]]\ntext = "a"\npreposition = { may_be_given = false, same_as = "c" }\narticle = { may_be_given = false, same_as = "b" }\n\n[[word]]\ntext = "b"\narticle = { may_be_given = false, same_as = "a" }')}, "same_as loops, a -> b -> a;"), # a chain running INTO a loop: the loop is named, not the chain ("same-as-chain-into-a-loop", {"en.toml": _w('[[word]]\ntext = "xa"\ngeneration = { same_as = "xb" }\n\n[[word]]\ntext = "xb"\ngeneration = { same_as = "xc" }\n\n[[word]]\ntext = "xc"\ngeneration = { same_as = "xb" }')}, "same_as loops, xb -> xc -> xb;"), # 7, a name reading's half (#789): a given reading's same_as is the # full name it abbreviates, normalized and never the word itself, and # a family reading takes none ("same-as-on-a-family-reading", {"en.toml": _w('[[word]]\ntext = "smyth"\nfamily = { note = "x", same_as = "smith" }')}, "only a `given` reading takes one"), ("same-as-on-a-given-reading-unnormalized", {"en.toml": _w('[[word]]\ntext = "chas"\ngiven = { note = "x", same_as = "Charles" }')}, "not in normalized form; write 'charles'"), ("same-as-on-a-given-reading-names-itself", {"en.toml": _w('[[word]]\ntext = "chas"\ngiven = { note = "x", same_as = "chas" }')}, "names the word itself"), ] @pytest.mark.parametrize(("files", "message"), [m[1:] for m in MUTATIONS], ids=[m[0] for m in MUTATIONS]) def test_a_build_check_refuses_its_mutation(files: dict[str, str], message: str) -> None: with pytest.raises(build.VocabError, match=message): _compile(files) def test_every_mutation_starts_from_a_pool_the_checks_accept() -> None: # the negative control for the table: the shapes it mutates compile # when written correctly, so each refusal is the mutation's for files in ( {"en.toml": OK}, {"ja.toml": _w('[[word]]\ntext = "博士"\ncredential = { acronym = false }'), "zh.toml": _w('[[word]]\ntext = "博士"\ncredential = { acronym = false }')}, {"en.toml": _w('[[word]]\ntext = "jr"\ngeneration = {}\n\n' '[[word]]\ntext = "jnr"\ngeneration = { same_as = "jr" }')}, {"und.toml": _w('[[word]]\ntext = "abu"\ngiven = { note = "x" }\nparticle = {}\nbound_given = {}')}, # check 11 met: a particle beside a given reading states nothing, # in its own file or another, and a count-less one carries a note {"nl.toml": _w('[[word]]\ntext = "van"\ngiven = { note = "x" }\npreposition = {}'), "de.toml": _w('[[word]]\ntext = "van"\npreposition = {}')}, {"und.toml": _w('[[word]]\ntext = "d\'"\nparticle = { may_be_given = false }\n\n' '[[word]]\ntext = "d’"\nparticle = { may_be_given = false }')}, {"und.toml": _w('[[word]]\ntext = "sir and dame"\ntitle = { addresses = "given" }')}, {"en.toml": _w('[[word]]\ntext = "sir and dame"\nnobility = { addresses = "given" }')}, # a same_as may name a word of another title kind: check 7 asks # for the category {"en.toml": _w('[[word]]\ntext = "mr"\nsalutation = {}'), "de.toml": _w('[[word]]\ntext = "mister"\noffice = { same_as = "mr" }')}, # a title kind's exclusion refuses the title set, not the word: # its particle reading stays free {"cs.toml": _w('[[word]]\ntext = "pan"\nexcluded = { salutation = "the Chinese surname Pan" }'), "und.toml": _w('[[word]]\ntext = "pan"\nparticle = { may_be_given = false }')}, # one title in a different kind per file, agreeing on addresses {"en.toml": _w('[[word]]\ntext = "sir"\nnobility = { addresses = "given" }'), "fr.toml": _w('[[word]]\ntext = "sir"\nsalutation = { addresses = "given" }')}, # a zero-width joiner or non-joiner is spelling, and a token keeps it {"fa.toml": _w('[[word]]\ntext = "ab\\u200ccd"\ntitle = {}\n\n' '[[word]]\ntext = "ef\\u200dgh"\ntitle = {}')}, # an interpunct is part of the word except between two East Asian # characters, and an East Asian text may end in one {"ca.toml": _w('[[word]]\ntext = "l·l"\ntitle = {}'), "zh.toml": _w('[[word]]\ntext = "先·"\nhonorific = {}')}, # a phrase word may hold an interior period; a date is a date {"und.toml": _w('[[word]]\ntext = "lt.col and dame"\ntitle = { addresses = "given", decided = 2026-10-18 }')}, {"fr.toml": _w('[[word]]\ntext = "fils"\nfamily = { us_census_2010 = 767, as_of = 2026-10-16 }\n' 'excluded = { generation = { reason = "Marie Fils", issue = 684 } }')}, # the name kinds refuse only themselves: refused as a given name, a # word may still record its surname evidence {"fr.toml": _w('[[word]]\ntext = "fils"\nfamily = { us_census_2010 = 767, as_of = 2026-10-16 }\n' 'excluded = { given = "x", generation = "Marie Fils" }')}, # a property exclusion refuses a value other than the default: # writing the default passes {"und.toml": _w('[[word]]\ntext = "zz"\nsalutation = { after_given = false, excluded = { after_given = "x" } }\n\n' '[[word]]\ntext = "xx"\ncredential = { excluded = { acronym = "x" } }')}, # the defaults agree with the written value across files {"und.toml": _w('[[word]]\ntext = "dr"\ntitle = {}'), "en.toml": _w('[[word]]\ntext = "dr"\ntitle = { addresses = "family" }')}, # an exclusion refuses the set its kind feeds, not every suffix: # refusing the generation word leaves an acronym reading free {"fr.toml": _w('[[word]]\ntext = "fils"\nexcluded = { generation = "Marie Fils" }'), "und.toml": _w('[[word]]\ntext = "fils"\ncredential = {}')}, {"pt-BR.toml": OK, "zh-Hant.toml": _w('[[word]]\ntext = "社長"\nprofessional_honorific = {}'), "es-419.toml": _w('[[word]]\ntext = "sr"\ngeneration = {}')}, # check 10's order, met: kinds in CATEGORY order, a head before # its spellings, a refusal under the kind it refuses, a name # reading placing nothing beside a refusal, a comment moving with # its entry; und.toml is exempt, so its reverse order passes {"en.toml": _w('[[word]]\ntext = "dr"\ntitle = {}\n\n' '[[word]]\ntext = "jr"\ngeneration = {}\n\n' '[[word]]\ntext = "jnr"\ngeneration = { same_as = "jr" }\n\n' '[[word]]\ntext = "junior"\ngeneration = { same_as = "jr" }\n\n' '[[word]]\ntext = "senior"\nfamily = { us_census_2010 = 3864, as_of = 2026-10-16 }\n' 'excluded = { generation = "x" }\n\n' '# a comment above an entry belongs to it\n' '[[word]]\ntext = "sr"\ngeneration = {}\n\n' '[[word]]\ntext = "esq"\ncourtesy = {}'), "und.toml": _w('[[word]]\ntext = "von"\nparticle = { may_be_given = false }\n\n' '[[word]]\ntext = "sir"\ntitle = {}')}, # a spelling's own spellings follow it, under the root head: by # the direct same_as alone, ed- would sort after ul-, under el- {"ar.toml": _w('[[word]]\ntext = "al-"\narticle = { may_be_given = false }\n\n' '[[word]]\ntext = "ad-"\narticle = { may_be_given = false, same_as = "al-" }\n\n' '[[word]]\ntext = "el-"\narticle = { may_be_given = false, same_as = "al-" }\n\n' '[[word]]\ntext = "ed-"\narticle = { may_be_given = false, same_as = "el-" }\n\n' '[[word]]\ntext = "ul-"\narticle = { may_be_given = false, same_as = "al-" }')}, # a word sorts under its EARLIEST kind, not its first written; # a vocabulary reading outranks a refusal beside it; a refused # category sorts as its first kind {"en.toml": _w('[[word]]\ntext = "dr"\ntitle = {}\n\n' '[[word]]\ntext = "md"\ncredential = {}\ntitle = {}\n\n' '[[word]]\ntext = "jr"\ngeneration = {}\n\n' '[[word]]\ntext = "aaa"\ncredential = {}\nexcluded = { generation = "x" }\n\n' '[[word]]\ntext = "zzz"\nexcluded = { relation = "x" }')}, # another file's chain does not place this file's head {"de.toml": _w('[[word]]\ntext = "aa"\ngeneration = {}\n\n' '[[word]]\ntext = "jr"\ngeneration = { same_as = "aa" }'), "en.toml": _w('[[word]]\ntext = "jr"\ngeneration = {}\n\n' '[[word]]\ntext = "jnr"\ngeneration = { same_as = "jr" }')}, # texts compare by codepoint, script by script {"sr.toml": _w('[[word]]\ntext = "mlađi"\ngeneration = {}\n\n' '[[word]]\ntext = "млађи"\ngeneration = {}')}, ): _compile(files) def _derived(files: dict[str, str]) -> tuple[bool, bool]: """Whether zzq compiles as an ambiguous acronym and as a particle that may be given.""" c = _compile(files) return ("zzq" in c["SUFFIX_ACRONYMS_AMBIGUOUS"], "zzq" in c["PARTICLES"] and "zzq" not in c["NON_GIVEN_NAME_PARTICLES"]) @pytest.mark.parametrize(("files", "expected"), [ # an acronym and a name reading of either kind, in one file or two ({"en.toml": _w('[[word]]\ntext = "zzq"\ngiven = { note = "x" }\ncredential = {}')}, (True, False)), ({"en.toml": _w('[[word]]\ntext = "zzq"\nfamily = { note = "x" }\ncredential = {}')}, (True, False)), ({"en.toml": _w('[[word]]\ntext = "zzq"\ncredential = {}'), "fr.toml": _w('[[word]]\ntext = "zzq"\nfamily = { note = "x" }\nexcluded = { title = "x" }')}, (True, False)), # an acronym reading of another suffix kind is marked too: the set is # the acronyms', not the credential kind's ({"en.toml": _w('[[word]]\ntext = "zzq"\ngiven = { note = "x" }\ncourtesy = { acronym = true }')}, (True, False)), # a word reading beside a name is no acronym, and is not marked ({"en.toml": _w('[[word]]\ntext = "zzq"\ngiven = { note = "x" }\ngeneration = {}')}, (False, False)), ({"en.toml": _w('[[word]]\ntext = "zzq"\ncredential = {}')}, (False, False)), # a particle and a given reading, in one file or two, any kind ({"nl.toml": _w('[[word]]\ntext = "zzq"\ngiven = { note = "x" }\npreposition = {}')}, (False, True)), ({"nl.toml": _w('[[word]]\ntext = "zzq"\narticle = {}'), "en.toml": _w('[[word]]\ntext = "zzq"\ngiven = { note = "x" }\nexcluded = { title = "x" }')}, (False, True)), # a family reading does not make a particle given: it states false ({"nl.toml": _w('[[word]]\ntext = "zzq"\nfamily = { note = "x" }\npreposition = { may_be_given = false }')}, (False, False)), # one word holding both clashes derives both markers, as do does ({"en.toml": _w('[[word]]\ntext = "zzq"\ngiven = { note = "x" }\ncredential = {}'), "pt.toml": _w('[[word]]\ntext = "zzq"\npreposition = {}')}, (True, True)), ], ids=["acronym-given", "acronym-family", "acronym-name-in-another-file", "acronym-of-another-kind", "word-reading-given", "acronym-alone", "particle-given", "particle-given-in-another-file", "particle-family-only", "both-clashes"]) def test_the_markers_derive_from_a_name_reading(files: dict[str, str], expected: tuple[bool, bool]) -> None: assert _derived(files) == expected def _apply_order_messages(texts: list[str]) -> int: """Move entries as check 10's messages say until it passes; the moves.""" moves = 0 while True: words: list[dict[str, Any]] = [{"text": t, "generation": {}} for t in texts] try: build.check_order({"en.toml": words}) return moves except build.VocabError as e: m = re.search(r"'(\w+)' \(generation\) belongs (after|before) '(\w+)'", str(e)) assert m, str(e) word, side, other = m.groups() texts.remove(word) texts.insert(texts.index(other) + (side == "after"), word) moves += 1 def test_following_the_order_messages_takes_the_fewest_moves() -> None: # every order of six words: the fewest moves that sort a list is its # length less its longest run already in order, and following the # messages takes exactly that many (#767's second review found the # first version taking up to 22 moves where 10 would do) from itertools import permutations for order in permutations("abcdef"): ranks = [ord(c) - ord("a") for c in order] longest = [1] * 6 for i in range(6): for j in range(i): if ranks[j] < ranks[i]: longest[i] = max(longest[i], longest[j] + 1) assert _apply_order_messages(list(order)) == 6 - max(longest), order def test_the_order_check_ends_on_its_own() -> None: # check_order called alone, without check 7 having refused a # self-reference first, and on a chain longer than Python's # recursion limit: it returns, it does not hang or recurse out build.check_order({"en.toml": [{"text": "a", "preposition": {"same_as": "a"}}, {"text": "b", "preposition": {}}]}) chain = [{"text": f"w{i:04d}", "generation": {"same_as": f"w{i + 1:04d}"}} for i in range(sys.getrecursionlimit() + 100)] build.check_order({"und.toml": chain}) def test_every_character_the_tokenizer_ignores_is_refused() -> None: # check 2's hand copies are held to the pipeline, and the sweep is # driven by the tokenizer's own predicate, so a separator or strip # class added there fails here until the build refuses it too. The # interpunct between East Asian characters is divided by context in # _tokenize_region, not by _ignorable, so the sweep cannot see it: # the MUTATIONS row text-with-an-east-asian-interpunct holds that check, # and the pins below hold only its constants from nameparser._pipeline import _tokenize from nameparser._pipeline._state import COMMA_CHARS, ParseState from nameparser._policy import Policy, _SCRIPT_RANGES assert build.DIVIDERS == COMMA_CHARS | _tokenize._NAME_DOT_SEPARATORS assert build.EMOJI_RANGES == _tokenize._EMOJI_RANGES assert build.EAST_ASIAN_RANGES == tuple( span for spans in _SCRIPT_RANGES.values() for span in spans) assert build.INTERPUNCT == _tokenize._INTERPUNCT state = ParseState(original="", lexicon=Lexicon.default(), policy=Policy(), segmenter=None) ignored = [chr(c) for c in range(0x110000) # a space is the one ignored character a text holds: the # separator between a phrase's words if not 0xD800 <= c <= 0xDFFF and c != 0x20 and (_tokenize._ignorable(chr(c), state) or chr(c) in COMMA_CHARS)] assert len(ignored) > 100, len(ignored) # whitespace, bidi, emoji, dots, commas for ch in ignored: escaped = f"\\U{ord(ch):08X}" # a TOML escape: no raw control in the file # refused by check 2 itself, not by whichever later check happens # to trip, which would leave a new class guarded by accident with pytest.raises(build.VocabError, match="divides a name|strips by default|rules/T3|" "invisible character|not in normalized form"): build.load({"und.toml": _w(f'[[word]]\ntext = "dr{escaped}x"\ntitle = {{}}')}) def test_a_name_reading_on_nothing_warns() -> None: files = {"en.toml": _w('[[word]]\ntext = "smith"\nfamily = { us_census_2010 = 2442977, as_of = 2026-10-16 }')} with pytest.warns(build.VocabWarning, match="inert"): build.compile_files(files) def test_a_given_name_written_short_compiles_and_is_not_inert() -> None: # #789: the same_as of a given reading names a name, which no entry # holds, and puts the word in GIVEN_NAME_ABBREVIATIONS -- so the entry, # holding only that reading, is no inert name reading (`_compile` # turns the warning into an error), and a plain given reading beside # it on another word still marks nothing c = _compile({"en.toml": _w('[[word]]\ntext = "chas"\ngiven = { note = "x", same_as = "charles" }')}) assert c["GIVEN_NAME_ABBREVIATIONS"] == frozenset({"chas"}) assert _sets_fed("given") == set() def test_a_check_failure_names_the_refresh_command_in_main( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: out = tmp_path / "_compiled.py" out.write_text("stale\n", encoding="utf-8") monkeypatch.setattr(build, "OUTPUT", out) monkeypatch.setattr(build, "ROOT", tmp_path) assert build.main(["--check"]) == 1 assert build.REFRESH in capsys.readouterr().out assert out.read_text(encoding="utf-8") == "stale\n" def test_query_tells_a_refused_reading_from_a_refused_property() -> None: # --excluded <kind> is the words refused that reading; a property # refused on a reading the word keeps answers only to its own name spec = importlib.util.spec_from_file_location("vocab_query", _BUILD.with_name("query.py")) assert spec is not None and spec.loader is not None query = importlib.util.module_from_spec(spec) sys.modules[spec.name] = query spec.loader.exec_module(query) pool = query.build.load({ "ja.toml": _w('[[word]]\ntext = "殿"\nhonorific = { excluded = { attached = "surnames end in it" } }'), "zh.toml": _w('[[word]]\ntext = "君"\nexcluded = { honorific = "王君 is a name" }'), }) assert [r.text for r in query.select(pool, excluded="honorific")] == ["君"] assert [r.text for r in query.select(pool, excluded="attached")] == ["殿"] assert [r.text for r in query.select(pool, excluded="suffix")] == ["君"] # the label is for display only, built from the same fields assert sorted(r.label for r in query.rows(pool)) == [ "excluded:honorific", "honorific", "honorific excluded:attached"] def _load_query() -> ModuleType: spec = importlib.util.spec_from_file_location("vocab_query", _BUILD.with_name("query.py")) assert spec is not None and spec.loader is not None query = importlib.util.module_from_spec(spec) sys.modules[spec.name] = query spec.loader.exec_module(query) return query QUERY_POOL = { "ja.toml": _w('[[word]]\ntext = "さん"\nhonorific = { issue = 308, attached = { side = "after", issue = 707 } }\n\n' '[[word]]\ntext = "殿"\nhonorific = { excluded = { attached = { reason = "x", issue = 707 } } }'), "en.toml": _w('[[word]]\ntext = "jr"\ngeneration = { issue = 661 }\n\n' '[[word]]\ntext = "senior"\nexcluded = { generation = { reason = "a surname", issue = 661 } }'), "und.toml": _w('[[word]]\ntext = "dr"\ntitle = {}'), } @pytest.mark.parametrize(("filters", "texts"), [ ({"issue": 707}, ["殿", "さん"]), # in `attached`, and in an exclusion's table ({"issue": 308}, ["さん"]), ({"issue": 661}, ["jr", "senior"]), # a reading and an exclusion ({"issue": 661, "kind": "generation"}, ["jr"]), # --kind alone lists readings ({"kind": "suffix"}, ["さん", "殿", "jr"]), # a category matches its kinds ({"kind": "honorific", "excluded": "attached"}, ["殿"]), ({"excluded": "suffix"}, ["senior"]), ({"excluded": "attach"}, []), # a property by its whole name only ({"file": "und.toml"}, ["dr"]), ({"word": "jr", "kind": "title"}, []), ]) def test_query_filters_combine(filters: dict[str, Any], texts: list[str]) -> None: query = _load_query() pool = query.build.load(QUERY_POOL) assert sorted(r.text for r in query.select(pool, **filters)) == sorted(texts) def test_query_validates_its_arguments_and_reports_no_match( capsys: pytest.CaptureFixture[str]) -> None: query = _load_query() for argv in (["--kind", "honorifc"], ["--excluded", "attachd"], ["--file", "xx"]): with pytest.raises(SystemExit) as e: query.main(argv) assert e.value.code == 2, argv capsys.readouterr() assert query.main(["--word", "Dr.", "--count"]) == 0 # normalized to 'dr' assert capsys.readouterr().out.strip() not in ("", "0") assert query.main(["--word", "zzqnoword"]) == 1 def test_the_build_does_not_import_the_package_it_writes() -> None: # a _compiled.py left broken must not stop the tool that rewrites it import subprocess code = (f"import importlib.util, sys; " f"spec = importlib.util.spec_from_file_location('b', {str(_BUILD)!r}); " f"m = importlib.util.module_from_spec(spec); sys.modules['b'] = m; " f"spec.loader.exec_module(m); " f"print(sorted(n for n in sys.modules if n.split('.')[0] == 'nameparser'))") out = subprocess.run([sys.executable, "-I", "-c", code], capture_output=True, text=True, check=True).stdout assert out.strip() == "[]" def test_broken_data_exits_2_not_1(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: monkeypatch.setattr(build, "read_dir", lambda: {"en.toml": _w('[[word]]\ntext = "jr"')}) assert build.main(["--check"]) == 2 assert "inert" in capsys.readouterr().err def test_an_unreadable_file_exits_2(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: # exit 1 means "stale", so a file that is not UTF-8 must not exit 1 (tmp_path / "en.toml").write_bytes(b"schema = 1\n\xff\n") with pytest.raises(build.VocabError, match="UTF-8"): build.read_dir(tmp_path) read_dir = build.read_dir monkeypatch.setattr(build, "read_dir", lambda: read_dir(tmp_path)) assert build.main(["--check"]) == 2 assert "UTF-8" in capsys.readouterr().err def test_an_empty_word_is_a_usage_error() -> None: query = _load_query() with pytest.raises(SystemExit) as e: query.main(["--word", "."]) assert e.value.code == 2 @pytest.mark.parametrize(("layout", "message"), [ ({}, "no vocabulary files"), ({"EN.TOML": OK}, "BCP 47 tag"), # misnamed files are read, then refused ({"en.toml.orig": OK}, "BCP 47 tag"), ({"readme.md": OK}, "BCP 47 tag"), # only README.md and AGENTS.md, by exact name, are skipped ({"README.md": OK}, "no vocabulary files"), ({"agents.md": OK}, "BCP 47 tag"), ({"AGENTS.md": OK}, "no vocabulary files"), ({"sub/en.toml": OK}, "no subfolders"), ({"en.toml": "->nowhere.toml"}, "a link to nothing"), ]) def test_the_folder_is_read_whole(tmp_path: Path, layout: dict[str, str], message: str) -> None: for name, text in layout.items(): (tmp_path / name).parent.mkdir(parents=True, exist_ok=True) if text.startswith("->"): (tmp_path / name).symlink_to(tmp_path / text[2:]) else: (tmp_path / name).write_text(text, encoding="utf-8") (tmp_path / ".DS_Store").write_bytes(b"\x00") # a dotfile is ignored, never read with pytest.raises(build.VocabError, match=message): build.compile_files(build.read_dir(tmp_path)) with pytest.raises(build.VocabError, match="no vocabulary folder"): build.read_dir(tmp_path / "missing") def _point_at(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, files: dict[str, str]) -> None: folder = tmp_path / "vocab" folder.mkdir() for name, text in files.items(): (folder / name).write_text(text, encoding="utf-8") read_dir = build.read_dir monkeypatch.setattr(build, "read_dir", lambda: read_dir(folder)) monkeypatch.setattr(build, "ROOT", tmp_path) monkeypatch.setattr(build, "OUTPUT", tmp_path / "_compiled.py") def test_no_error_exits_1_the_code_for_stale(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: _point_at(monkeypatch, tmp_path, {"en.toml": OK}) # an unreadable module is stale: rebuilding replaces it build.OUTPUT.write_bytes(b"\xff") assert build.main(["--check"]) == 1 assert "unreadable" in capsys.readouterr().out assert build.main([]) == 0 assert build.main(["--check"]) == 0 # a module that cannot be written is an internal error, exit 3 build.OUTPUT.unlink() build.OUTPUT.mkdir() assert build.main([]) == 3 def test_a_warning_raised_as_an_error_exits_2(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: # a soft finding raised under -W error is a data error, not a bug _point_at(monkeypatch, tmp_path, {"en.toml": _w('[[word]]\ntext = "smith"\nfamily = {}')}) with warnings.catch_warnings(): warnings.simplefilter("error") assert build.main(["--check"]) == 2 def test_a_repointed_root_does_not_move_the_lexicon_module(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: # the tests above repoint ROOT at a fixture tree holding no # _lexicon.py; a cold cache must still load the parser's own, or # each of them passes only after another test has warmed it monkeypatch.setattr(build, "_LEXICON", []) monkeypatch.setattr(build, "ROOT", tmp_path) assert build._normalize(" Dr. ") == "dr" def test_a_broken_lexicon_module_exits_3(monkeypatch: pytest.MonkeyPatch) -> None: def broken() -> ModuleType: raise SyntaxError("conflict markers") monkeypatch.setattr(build, "_LEXICON", []) monkeypatch.setattr(build, "_load_lexicon_module", broken) assert build.main(["--check"]) == 3 def test_a_query_error_exits_3_not_1(monkeypatch: pytest.MonkeyPatch) -> None: query = _load_query() def broken(*args: object, **kwargs: object) -> list[object]: raise KeyError("a bug in select") monkeypatch.setattr(query, "select", broken) assert query.main(["--word", "dr"]) == 3 def test_a_satisfied_property_exclusion_does_not_claim_the_refusal() -> None: # suffix = { acronym = true } is what a credential's acronym exclusion # keeps, so the exclusion has nothing to say; the word is still # refused, by check 4, for having two suffix kinds -- and the message # must not read backwards, calling acronym = true the refused value files = {"en.toml": _w('[[word]]\ntext = "xyz"\ncredential = { excluded = { acronym = "keep acronym" } }'), "fr.toml": _w('[[word]]\ntext = "xyz"\nsuffix = { acronym = true }')} with pytest.raises(build.VocabError, match="one suffix kind per word") as e: _compile(files) assert "is excluded" not in str(e.value) # ------------------------------------------------- the README's reference # nameparser/vocab/README.md documents the kinds and properties for # whoever edits the TOML. Its tables are held to the names build.py # accepts, each kind's compiled sets and each behavioral property's # effect to what the compiler does with a probe reading, and each # property's unwritten value to the default the compiler gives it. def _readme_table(heading: str) -> list[list[str]]: """The rows of the first table under `heading`, header and divider dropped, each a list of its cells. A cell may hold an escaped pipe (GFM's `\\|`), and every row has as many cells as the header.""" lines = (build.VOCAB / build.README).read_text(encoding="utf-8").splitlines() assert heading in lines, f"README.md has no {heading!r} heading" rows: list[list[str]] = [] for line in lines[lines.index(heading) + 1:]: if line.startswith("#"): break if line.startswith("|"): line = line.strip() assert line.endswith("|"), f"{heading}: unterminated row {line!r}" rows.append([c.strip() for c in re.split(r"(?<!\\)\|", line[1:-1])]) elif rows: break assert len(rows) > 2, f"no table under {heading!r}" for row in rows: assert len(row) == len(rows[0]), f"{heading}: {row} has {len(row)} cells" return rows[2:] def _code(cell: str) -> list[str]: return re.findall(r"`([^`]+)`", cell) def _sets_fed(kind: str, props: str = "{}", text: str = "zzq", extra: str = "") -> set[str]: """The compiled constants a one-word file holding a `kind` reading with `props` puts the word into. A particle that does not state may_be_given gets a given reading, as check 11 requires, so the bare kind feeds only the sets its category does; an agnomen gets the family reading check 11 requires of it, which gates it; and a bare name reading gets the note a count-less one carries.""" if build.CATEGORY.get(kind) == "name" and props == "{}": props = '{ note = "probe" }' entry = f'{kind} = {props}' if build.CATEGORY.get(kind) == "particle" and "may_be_given" not in props: entry = 'given = { note = "probe" }\n' + entry if kind == "agnomen": entry = 'family = { note = "probe" }\n' + entry files = {"en.toml": _w(f'[[word]]\ntext = "{text}"\n{entry}{extra}')} with warnings.catch_warnings(): warnings.simplefilter("ignore", build.VocabWarning) # a name reading is inert compiled = build.compile_files(files) # a word is in a set of phrases where it opens one (TITLE_PHRASES) return {name for name, value in compiled.items() if text in value or any(isinstance(v, str) and v.startswith(text + " ") for v in value)} #: For each behavioral property, a reading that sets it away from its #: unwritten value (kind, props, text): what that changes in the compiled #: sets, against the same kind written bare, is the property's effect. _PROPERTY_PROBES = { "addresses": ("title", '{ addresses = "given" }', "zzq"), "acronym": ("generation", "{ acronym = true }", "zzq"), "attached": ("honorific", '{ attached = { side = "after" } }', "zzq"), "may_be_initial": ("conjunction", "{ may_be_initial = true }", "q"), "cased": ("title", '{ cased = "ZzQ" }', "zzq"), "after_given": ("salutation", "{ after_given = true }", "zzq"), "default": ("generation", "{ default = false }", "zzq"), # a phrase needs the head it attaches to: a second title word "attaches": ("office", '{ attaches = ["occupation"] }', "zzq", '\n\n[[word]]\ntext = "zzr"\noccupation = {}'), } #: The title kinds that feed a set of their own besides TITLES (H9). _TITLE_KIND_SETS = {"modifier": ("TITLE_MODIFIERS",), "style": ("TITLE_STYLES",)} @pytest.mark.parametrize("kind", [k for k, c in build.CATEGORY.items() if c == "title"]) def test_every_title_kind_compiles_its_behavioral_properties(kind: str) -> None: # GIVEN_NAME_TITLES is compiled by category, so a classified title # keeps addresses (#750): keyed on the generic kind, a nobility sir # would lose it; and every title kind is allowed `cased`, which the # compiler reads from any reading assert _sets_fed(kind, '{ addresses = "given" }') == { "TITLES", "GIVEN_NAME_TITLES", *_TITLE_KIND_SETS.get(kind, ())} files = {"en.toml": _w(f'[[word]]\ntext = "zzq"\n{kind} = {{ cased = "ZzQ" }}')} assert build.compile_files(files)["CAPITALIZATION_EXCEPTIONS"] == {"zzq": "ZzQ"} def test_the_readme_lists_every_kind_with_its_category_and_set() -> None: rows = _readme_table("## Kinds") # in CATEGORY's order, which is check 10's canonical order of the kinds assert [(_code(r[0])[0], _code(r[1])[0]) for r in rows] == list(build.CATEGORY.items()) for kind, _, fed in rows: assert set(_code(fed)) == _sets_fed(_code(kind)[0]), kind def test_the_readme_lists_every_property_with_its_type_and_categories() -> None: rows = _readme_table("## Properties") behavioral = {p for ps in build.BEHAVIORAL.values() for p in ps} stated = {p for ps in build.STATED.values() for p in ps} assert {_code(r[0])[0]: r[1] for r in rows} == ( {p: "behavioral" for p in behavioral} | {p: "stated" for p in stated} | {p: "descriptive" for p in build.DESCRIPTIVE}) assert len(rows) == len(behavioral | stated | build.DESCRIPTIVE) for prop, kind_of, applies, *_ in rows: name = _code(prop)[0] if kind_of in ("behavioral", "stated"): table = build.BEHAVIORAL if kind_of == "behavioral" else build.STATED assert set(_code(applies)) == { c for c, ps in table.items() if name in ps}, name else: # a name reading takes only the descriptive keys in NAME_KEYS assert ("but `name`" in applies) == (name not in build.NAME_KEYS), name def test_the_readme_states_each_behavioral_propertys_effect() -> None: rows = {_code(r[0])[0]: r[4] for r in _readme_table("## Properties") if r[1] == "behavioral"} assert set(_PROPERTY_PROBES) == set(rows) for prop, (kind, props, text, *extra) in _PROPERTY_PROBES.items(): moved = (_sets_fed(kind, props, text, *extra) ^ _sets_fed(kind, "{}", text, *extra)) assert moved, prop # a probe that moves nothing tests nothing assert set(_code(rows[prop])) & {n for n, _ in build.CONSTANTS} == moved, prop def test_the_readme_states_each_behavioral_propertys_unwritten_value() -> None: for row in _readme_table("## Properties"): prop, kind_of, _, unwritten = _code(row[0])[0], row[1], row[2], row[3] if kind_of == "stated": # check 11 requires each stated property somewhere assert "required" in unwritten, prop if kind_of != "behavioral": continue defaults = {build._behavior(build.Reading("en.toml", "zzq", kind, {}), prop) for kind, cat in build.CATEGORY.items() if prop in build.BEHAVIORAL.get(cat, ())} for value in defaults: if isinstance(value, bool): assert ("true" if value else "false") in unwritten, prop elif isinstance(value, str): assert f'"{value}"' in unwritten, prop else: assert value is None and not {"true", "false"} & set(unwritten.split()), prop #: Each derived marker's probe: a reading of the clashing category #: (kind, props without the name reading, props with it), compiled with #: and without a given reading -- what moves is the marker's effect. _MARKER_PROBES = { "possibly a name": ("credential", "{}", "{}"), "possibly a name, a word": ("generation", "{}", "{}"), "possibly given": ("preposition", "{ may_be_given = false }", "{}"), "possibly a name, a title": ("office", "{}", "{}"), } def test_the_readme_states_each_derived_markers_effect() -> None: rows = {r[0]: r[2] for r in _readme_table("### Derived markers")} assert set(rows) == set(_MARKER_PROBES) for marker, (kind, without, with_) in _MARKER_PROBES.items(): files = {"en.toml": _w(f'[[word]]\ntext = "zzq"\n{kind} = {without}')} named = {"en.toml": _w(f'[[word]]\ntext = "zzq"\ngiven = {{ note = "x" }}\n{kind} = {with_}')} a, b = build.compile_files(files), build.compile_files(named) moved = {n for n, _ in build.CONSTANTS if ("zzq" in a[n]) != ("zzq" in b[n])} assert moved, marker assert set(_code(rows[marker])) & {n for n, _ in build.CONSTANTS} == moved, marker def test_the_readme_names_every_compiled_constant() -> None: # each constant is reached by a kind, a property or a derived marker named = {name for heading in ("## Kinds", "## Properties", "### Derived markers") for row in _readme_table(heading) for cell in row[1:] for name in _code(cell)} assert {name for name, _ in build.CONSTANTS} <= named def test_the_readme_lists_the_name_reading_keys() -> None: rows = _readme_table("### Name readings") assert {_code(r[0])[0] for r in rows} == build.NAME_KEYS assert len(rows) == len(build.NAME_KEYS) def test_the_readme_lists_the_reserved_names_with_their_phases() -> None: rows = _readme_table("## Names reserved for later phases") assert {_code(r[0])[0]: int(r[1]) for r in rows} == build.RESERVED assert len(rows) == len(build.RESERVED) # H9: "the word is a title only inside a title phrase" -- the # fourth derived marker and the three sets # the phrase test reads, compiled from the readings def test_a_title_with_a_given_reading_compiles_out_of_titles() -> None: c = _compile({"und.toml": _w( '[[word]]\ntext = "zzq"\noffice = {}\ngiven = { note = "probe" }')}) assert "zzq" in c["TITLES_AMBIGUOUS"] # a word in TITLES is a title wherever it stands, so the marked word # is not there: a caller listing it there asks for that assert "zzq" not in c["TITLES"] def test_a_title_particle_with_a_given_reading_stays_a_title() -> None: # 'st' and 'freiherr': the given reading derives the particle's # marker, and the particle rules read the word at the front (P4) c = _compile({"und.toml": _w( '[[word]]\ntext = "zzq"\ngiven = { note = "probe" }\nreligious = {}\n' 'saint = {}')}) assert "zzq" in c["TITLES"] and "zzq" not in c["TITLES_AMBIGUOUS"] def test_a_family_reading_beside_a_title_marks_nothing() -> None: c = _compile({"und.toml": _w( '[[word]]\ntext = "zzq"\nsalutation = {}\nfamily = { note = "probe" }')}) assert "zzq" in c["TITLES"] and "zzq" not in c["TITLES_AMBIGUOUS"] def test_a_modifier_is_one_only_where_every_title_reading_is() -> None: one = _compile({"en.toml": _w('[[word]]\ntext = "zzq"\nmodifier = {}')}) both = _compile({ "en.toml": _w('[[word]]\ntext = "zzq"\nmodifier = {}'), "fr.toml": _w('[[word]]\ntext = "zzq"\noffice = {}')}) assert "zzq" in one["TITLE_MODIFIERS"] # an office in another language heads a phrase of its own assert "zzq" not in both["TITLE_MODIFIERS"] and "zzq" in both["TITLES"] def test_attaches_compiles_to_a_phrase_with_each_title_of_its_kinds() -> None: c = _compile({"en.toml": _w( '[[word]]\ntext = "zzq"\nnobility = { attaches = ["military"] }\n\n' '[[word]]\ntext = "zzr"\nmilitary = {}\n\n' '[[word]]\ntext = "zzs"\noffice = {}')}) assert c["TITLE_PHRASES"] == frozenset({"zzq zzr"}) def test_a_shipped_phrase_names_two_titles() -> None: # every phrase the build writes is two words, each a title or a # title that may be a given name, so the phrase test can reach it lex = Lexicon.default() for phrase in lex.title_phrases: words = phrase.split(" ") assert len(words) == 2, phrase assert all(w in lex.titles or w in lex.titles_ambiguous for w in words), phrase def test_attaches_may_name_one_title_word() -> None: # a word where a kind would reach too far: 'sergeant' before 'major' # only, not before every military title (rules/H9) c = _compile({"en.toml": _w( '[[word]]\ntext = "zzq"\nmilitary = { attaches = ["zzr"] }\n\n' '[[word]]\ntext = "zzr"\nmilitary = {}\n\n' '[[word]]\ntext = "zzs"\nmilitary = {}')}) assert c["TITLE_PHRASES"] == frozenset({"zzq zzr"}) �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_wikidata_corpus.py����������������������������������0000664�0000000�0000000�00000047011�15273032717�0025204�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Offline checks on tools/differential/build_wikidata_corpus.py. The build script is the only part of the Wikidata corpus that touches the network, so the token alignment it applies to a person's written names is pinned here on hand-written items in wbgetentities' shape. tools/ is not a package, so the module is loaded by path, as _differential_fixtures.py loads compare.py. """ import json from collections import Counter from pathlib import Path from typing import Any import pytest from ._differential_fixtures import load_tool, serve build = load_tool("build_wikidata_corpus") agreement = load_tool("cldr_agreement") _ES = build.Stratum("surname2-es", "Q29", "P1950", "es", 1) _RU = build.Stratum("patronymic-ru", "Q159", "P5056", "ru", 1) _IS = build.Stratum("patronymic-is", "Q189", "P5056", "is", 1) _MY = build.Stratum("patronymic-my", "Q833", "P5056", "ms", 1) def _claim(value: dict[str, str]) -> dict[str, Any]: return {"rank": "normal", "mainsnak": {"snaktype": "value", "datavalue": {"value": value}}} def _person(label: str, language: str, native: str = "", female: bool = False, **parts: str | list[str]) -> dict[str, Any]: claims: dict[str, list[dict[str, Any]]] = {} for prop, ids in parts.items(): claims[prop] = [_claim({"id": i}) for i in ([ids] if isinstance(ids, str) else ids)] if native: claims["P1559"] = [_claim({"text": native, "language": language})] if female: claims["P21"] = [_claim({"id": build.FEMALE})] return {"labels": {language: {"value": label}}, "claims": claims} def _names(**labels: str) -> dict[str, Any]: """Part items, each labelled in `mul` only.""" return {qid: {"labels": {"mul": {"value": text}}} for qid, text in labels.items()} def _texts(rows: list[dict[str, Any]]) -> list[tuple[str, str]]: return [(row["order"], row["text"]) for row in rows] _GGM = _names(Q1="Gabriel", Q2="García", Q3="Márquez") def test_a_written_name_every_part_covers_gives_one_rendering() -> None: person = _person("Gabriel García Márquez", "es", P735="Q1", P734="Q2", P1950="Q3") [row] = build.renderings("Q5878", person, _GGM, _ES) assert (row["order"], row["shape"], row["composed"]) == ("givenFirst", None, False) assert row["fields"] == {"given": "Gabriel", "surname": "García", "surname2": "Márquez"} def test_a_short_display_name_without_the_second_surname_is_left_out() -> None: # Every word is claimed, but the field the stratum exists for is not # written, so the name tests nothing about it. person = _person("Gabriel García", "es", P735="Q1", P734="Q2", P1950="Q3") assert build.renderings("Q5878", person, _GGM, _ES) == [] @pytest.mark.parametrize("label", [ "Gabriel José García Márquez", # a word no part claims "Gabriel García Márquez (escritor)", # a disambiguator: no part claims it "Gabriel García Márquez García", # a part found in two places ]) def test_a_name_not_covered_once_per_word_is_left_out(label: str) -> None: person = _person(label, "es", P735="Q1", P734="Q2", P1950="Q3") assert build.renderings("Q5878", person, _GGM, _ES) == [] def test_a_connective_joins_the_part_after_it() -> None: names = _names(Q1="Josep", Q2="Carreras", Q3="Coll") person = _person("Josep Carreras i Coll", "es", P735="Q1", P734="Q2", P1950="Q3") [row] = build.renderings("Q1", person, names, _ES) assert row["fields"]["surname2"] == "i Coll" names = _names(Q1="Mohamed", Q4="Hamzah") person = _person("Mohamed bin Hamzah", "ms", P735="Q1", P5056="Q4") [row] = build.renderings("Q2", person, names, _MY) assert row["fields"] == {"given": "Mohamed", "patronymic": "bin Hamzah"} def test_a_given_name_and_for_a_second_surname_the_first_are_required() -> None: # Each is stated, but not written: the rendering would test a split # the source did not show. for label in ("Gabriel Márquez", "García Márquez"): person = _person(label, "es", P735="Q1", P734="Q2", P1950="Q3") assert build.renderings("Q5878", person, _GGM, _ES) == [], label def test_one_rendering_per_order_and_the_native_name_wins() -> None: names = {**_GGM, **_names(Q4="José")} person = _person("Gabriel José García Márquez", "es", native="Gabriel García Márquez", P735=["Q1", "Q4"], P734="Q2", P1950="Q3") assert _texts(build.renderings("Q5878", person, names, _ES)) == [ ("givenFirst", "Gabriel García Márquez")] def test_the_widest_spelling_of_a_part_claims_its_words() -> None: # `María` and `María José` are both spellings of one given-name item; # the narrower match inside the wider one is not a second place. assert build.align("María José Pérez", [ ("given", {"María", "María José"}), ("surname", {"Pérez"})]) == { "given": [0, 1], "surname": [2]} def test_a_comma_may_only_end_a_part() -> None: assert build.align("Мария, Анна Иванова", [ ("given", {"Мария Анна"}), ("surname", {"Иванова"})]) is None assert build.align("Иванова, Мария Анна", [ ("given", {"Мария Анна"}), ("surname", {"Иванова"})]) == { "given": [1, 2], "surname": [0]} def test_a_connective_carrying_a_comma_joins_nothing() -> None: assert build.align("Josep Carreras i, Coll", [ ("given", {"Josep"}), ("surname", {"Carreras"}), ("surname2", {"Coll"})]) is None def test_malay_connectives_may_be_capitalized() -> None: names = _names(Q1="Mohamed", Q4="Hamzah") person = _person("Mohamed Bin Hamzah", "ms", P735="Q1", P5056="Q4") [row] = build.renderings("Q2", person, names, _MY) assert row["fields"]["patronymic"] == "Bin Hamzah" def test_a_capital_letter_is_not_a_connective() -> None: names = _names(Q1="Josep", Q2="Carreras", Q3="Coll") person = _person("Josep Carreras I Coll", "es", P735="Q1", P734="Q2", P1950="Q3") assert build.renderings("Q1", person, names, _ES) == [] def test_one_item_stated_as_two_parts_is_left_out() -> None: # A person whose P734 and P5056 are the same item, seen in Malaysian data. names = _names(Q1="Salma", Q2="Ismail") person = _person("Salma Ismail", "ms", P735="Q1", P734="Q2", P5056="Q2") assert build.renderings("Q1", person, names, _MY) == [] def test_a_womans_russian_surname_matches_the_feminine_form() -> None: names = _names(Q1="Елена", Q2="Лядов", Q3="Игоревна") person = _person("Елена Игоревна Лядова", "ru", female=True, P735="Q1", P734="Q2", P5056="Q3") [row] = build.renderings("Q1", person, names, _RU) assert row["fields"]["surname"] == "Лядова" # ... for a woman only. man = _person("Елена Игоревна Лядова", "ru", P735="Q1", P734="Q2", P5056="Q3") assert build.renderings("Q1", man, names, _RU) == [] def test_a_womans_surname_with_no_masculine_ending_still_matches() -> None: # feminine() has nothing to offer here; an empty spelling must not be # added, or it would match everywhere and drop the name. names = _names(Q1="Анна", Q2="Пастернак", Q3="Ивановна") person = _person("Анна Ивановна Пастернак", "ru", female=True, P735="Q1", P734="Q2", P5056="Q3") assert len(build.renderings("Q1", person, names, _RU)) == 1 @pytest.mark.parametrize(("male", "female"), [ ("Достоевский", "Достоевская"), ("Трубецкой", "Трубецкая"), ("Белый", "Белая"), ("Иванов", "Иванова"), ("Лебедев", "Лебедева"), ("Горбачёв", "Горбачёва"), ("Пушкин", "Пушкина"), ("Лисицын", "Лисицына"), ("Высоцкий", "Высоцкая"), ("Пастернак", "")]) def test_feminine_forms(male: str, female: str) -> None: assert build.feminine(male) == female def test_a_part_found_only_through_its_native_label() -> None: names = {"Q1": {"labels": {}, "claims": {"P1705": [_claim( {"text": "Юрий", "language": "ru"})]}}, **_names(Q2="Кушнарёв", Q3="Викторович")} person = _person("Юрий Викторович Кушнарёв", "ru", P735="Q1", P734="Q2", P5056="Q3") [row] = build.renderings("Q1", person, names, _RU) assert row["fields"]["given"] == "Юрий" def test_a_sorting_label_and_a_native_name_are_two_renderings() -> None: names = _names(Q1="Юрий", Q2="Кушнарёв", Q3="Викторович") person = _person("Кушнарёв, Юрий Викторович", "ru", native="Юрий Викторович Кушнарёв", P735="Q1", P734="Q2", P5056="Q3") rows = build.renderings("Q1", person, names, _RU) assert _texts(rows) == [("givenFirst", "Юрий Викторович Кушнарёв"), ("sorting", "Кушнарёв, Юрий Викторович")] assert rows[1]["fields"]["surname"] == "Кушнарёв" def test_a_second_surname_written_first_is_family_first_too() -> None: assert build._order("Márquez García Gabriel", {"surname2": [0], "surname": [1], "given": [2]}) == ( "surnameFirst", build.FAMILY_FIRST_SHAPE) def test_family_first_without_a_comma_carries_the_family_first_shape() -> None: names = _names(Q1="Юрий", Q2="Кушнарёв", Q3="Викторович") person = _person("Кушнарёв Юрий Викторович", "ru", P735="Q1", P734="Q2", P5056="Q3") [row] = build.renderings("Q1", person, names, _RU) assert (row["order"], row["shape"]) == ("surnameFirst", build.FAMILY_FIRST_SHAPE) assert build.corpus_lines([row]) == [ '{"name": "Кушнарёв Юрий Викторович", "shape": 4}'] def test_a_patronymic_expects_no_family_name() -> None: names = _names(Q1="Vigdís", Q2="Finnbogadóttir") person = _person("Vigdís Finnbogadóttir", "is", P735="Q1", P5056="Q2") [row] = build.renderings("Q1", person, names, _IS) assert agreement.expected_roles(row["fields"], row["text"])["family"] == "" def test_every_field_occurs_in_its_rendering() -> None: # A field that did not occur would be expected EMPTY by the comparison, # a disagreement no parser fix could resolve; the glued comma of a # sorting form is the case to watch. names = _names(Q1="Юрий", Q2="Кушнарёв", Q3="Викторович") person = _person("Кушнарёв, Юрий Викторович", "ru", P735="Q1", P734="Q2", P5056="Q3") for row in build.renderings("Q1", person, names, _RU): for value in row["fields"].values(): assert agreement.occurs(value, row["text"]), (row["text"], value) _ROWS = agreement.load_rows(agreement.WIKIDATA_FIELDS) def test_every_committed_rendering_is_covered_by_its_fields() -> None: # The comparison reads a missing field as an EMPTY role. That is # sound here only because the fields claim every word of the text: # an absent P734 or P5056 means "not entered", and would otherwise # leave a word whose role the comparison wrongly expects empty. uncovered = [row["text"] for row in _ROWS if not agreement.same(" ".join(row["fields"].values()), row["text"], row["text"])] assert not uncovered assert len(_ROWS) > 500, "the Wikidata fields file is missing or truncated" def test_a_deprecated_or_valueless_statement_is_ignored() -> None: item = {"claims": {"P734": [ {**_claim({"id": "Q1"}), "rank": "deprecated"}, {"rank": "normal", "mainsnak": {"snaktype": "novalue"}}, _claim({"id": "Q2"})]}} assert build.statements(item, "P734") == ["Q2"] def test_a_duplicate_rendering_is_refused() -> None: row = {"file": "Q1", "label": "x", "order": "sorting", "text": "A, B"} with pytest.raises(ValueError, match="duplicate rendering Q1/x/sorting"): build.fields_lines([row, dict(row)]) def test_every_stratum_has_a_distinct_label() -> None: labels = [stratum.label for stratum in build.STRATA] assert len(labels) == len(set(labels)) # -- the build ---------------------------------------------------------------- def _stub_build(monkeypatch: pytest.MonkeyPatch, strata: tuple[Any, ...], pooled: dict[str, list[str]]) -> None: people = {q: _person("Gabriel García Márquez", "es", P735="Q1", P734="Q2", P1950="Q3") for qs in pooled.values() for q in qs} monkeypatch.setattr(build, "STRATA", strata) monkeypatch.setattr(build, "pool", lambda stratum, refresh: pooled[stratum.label]) monkeypatch.setattr(build, "entities", lambda ids, props, refresh: {**{q: people[q] for q in ids if q in people}, **{q: _GGM[q] for q in ids if q in _GGM}}) def test_build_takes_each_person_once_up_to_the_quota( monkeypatch: pytest.MonkeyPatch) -> None: first = build.Stratum("first", "Q29", "P1950", "es", 2) second = build.Stratum("second", "Q96", "P1950", "es", 2) _stub_build(monkeypatch, (first, second), {"first": ["Q10", "Q11", "Q12"], "second": ["Q11", "Q13", "Q14"]}) taken = {lab: [r["file"] for r in build.build(refresh=False) if r["label"] == lab] for lab in ("first", "second")} assert taken == {"first": ["Q10", "Q11"], "second": ["Q13", "Q14"]} def test_build_stops_before_writing_when_a_stratum_falls_short( monkeypatch: pytest.MonkeyPatch) -> None: thin = build.Stratum("thin", "Q833", "P1950", "es", 5, least=3) _stub_build(monkeypatch, (thin,), {"thin": ["Q10", "Q11"]}) with pytest.raises(ValueError, match="thin 2/3"): build.build(refresh=False) def test_a_query_takes_as_many_people_as_the_pool_check_expects() -> None: # endswith: "LIMIT 6" is also a prefix of "LIMIT 60" assert build.query(_RU).endswith(f"LIMIT {_RU.size}") # -- the network and the cache ------------------------------------------------ def _results(n: int) -> bytes: return json.dumps({"results": {"bindings": [ {"item": {"value": f"http://www.wikidata.org/entity/Q{7747 + i}"}} for i in range(n)]}}).encode() # a pool the size of _RU's (quota 1, six times that) _RESULTS = _results(build.POOL_FACTOR) def test_a_query_is_posted_as_json_and_the_fetch_waits_after_it( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: requests: list[Any] = [] sleeps: list[float] = [] serve(monkeypatch, tmp_path, _RESULTS, requests=requests, sleeps=sleeps) build.pool(_RU, refresh=False) [request] = requests assert request.get_method() == "POST" and b"query=" in request.data assert request.get_header("Accept") == "application/json" assert request.get_header("User-agent") == build.USER_AGENT assert sleeps == [1] def test_a_cached_response_is_read_without_fetching( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: fetched = serve(monkeypatch, tmp_path, _RESULTS) assert build.pool(_RU, refresh=False)[0] == "Q7747" assert len(build.pool(_RU, refresh=False)) == build.POOL_FACTOR assert len(fetched) == 1 build.pool(_RU, refresh=True) assert len(fetched) == 2 assert not list((tmp_path / "wikidata").glob("*.part")) @pytest.mark.parametrize("body", [ b'{"error": {"code": "maxlag", "info": "Waiting for a database server"}}', b'<html>Query timeout</html>']) def test_a_response_that_is_not_what_was_asked_for_is_not_cached( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, body: bytes) -> None: serve(monkeypatch, tmp_path, body) with pytest.raises(ValueError, match="not JSON|has no 'entities'.*maxlag"): build.entities(["Q1"], "labels", refresh=False) assert not list((tmp_path / "wikidata").iterdir()) def test_a_damaged_cache_file_is_named( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: serve(monkeypatch, tmp_path, _RESULTS) build.pool(_RU, refresh=False) [cached] = (tmp_path / "wikidata").iterdir() cached.write_text('{"results": {"bind', encoding="utf-8") with pytest.raises(ValueError, match=f"{cached.name}.*--refresh"): build.pool(_RU, refresh=False) def test_a_query_matching_nothing_stops_the_build( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: serve(monkeypatch, tmp_path, b'{"results": {"bindings": []}}') with pytest.raises(ValueError, match="patronymic-ru: the query matches nothing"): build.pool(_RU, refresh=False) def test_a_pool_short_of_its_size_stops_the_build_unless_it_is_the_whole( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: serve(monkeypatch, tmp_path, _results(3)) with pytest.raises(ValueError, match="patronymic-ru: the query matched 3 people, " "fewer than its pool of 6"): build.pool(_RU, refresh=False) assert len(build.pool(_RU._replace(whole=True), refresh=False)) == 3 def test_a_stratum_marked_whole_whose_pool_fills_stops_the_build( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: serve(monkeypatch, tmp_path, _results(build.POOL_FACTOR)) with pytest.raises(ValueError, match="patronymic-ru: marked whole=True, but the " "query filled"): build.pool(_RU._replace(whole=True), refresh=False) def test_an_item_the_response_leaves_out_stops_the_build( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: serve(monkeypatch, tmp_path, b'{"entities": {"Q1": {"id": "Q1"}}}') with pytest.raises(ValueError, match="left out Q2"): build.entities(["Q1", "Q2"], "labels", refresh=False) def test_a_missing_person_stops_the_build_and_a_missing_part_does_not( monkeypatch: pytest.MonkeyPatch) -> None: # A pooled person must exist; a deleted name-part item only refuses # the person stating it (it has no spellings), which --refresh could # not fix anyway. stratum = build.Stratum("s", "Q29", "P1950", "es", 1, least=0) _stub_build(monkeypatch, (stratum,), {"s": ["Q10"]}) deleted = {"id": "Q1", "missing": ""} real = build.entities monkeypatch.setattr(build, "entities", lambda ids, props, refresh: { **real(ids, props, refresh), **({"Q1": deleted} if "Q1" in ids else {})}) assert build.build(refresh=False) == [] # the part: the person is refused monkeypatch.setattr(build, "entities", lambda ids, props, refresh: { **real(ids, props, refresh), **({"Q10": deleted} if "Q10" in ids else {})}) with pytest.raises(ValueError, match="s: WDQS pooled items .*missing: Q10"): build.build(refresh=False) # -- the committed files, by stratum --------------------------------------------- _STRATA = {stratum.label: stratum for stratum in build.STRATA} def test_each_committed_stratum_holds_what_the_build_promises() -> None: names = {label: {row["file"] for row in _ROWS if row["label"] == label} for label in {row["label"] for row in _ROWS}} assert set(names) == set(_STRATA) for label, ids in names.items(): assert _STRATA[label].floor <= len(ids) <= _STRATA[label].quota, label # one stratum per person: a person in two would be counted twice owners = Counter(len({r["label"] for r in _ROWS if r["file"] == qid}) for qid in {row["file"] for row in _ROWS}) assert set(owners) == {1} # and every rendering writes its stratum's own field assert not [row["text"] for row in _ROWS if build.PARTS[_STRATA[row["label"]].prop] not in row["fields"]] �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_wikidata_native_corpus.py���������������������������0000664�0000000�0000000�00000056275�15273032717�0026566�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Offline checks on tools/differential/build_wikidata_native_corpus.py. The builder is the only part of the native-script corpus that touches the network, so what it adds to build_wikidata_corpus.py's alignment -- the script filter, character alignment for unspaced text, per-language feminine surnames, the order a script decides -- is pinned here on hand-written items in wbgetentities' shape. """ import json import urllib.error import urllib.request from collections import Counter from pathlib import Path from typing import Any import pytest from nameparser import Policy from ._differential_fixtures import load_tool, serve native = load_tool("build_wikidata_native_corpus") wikidata = load_tool("build_wikidata_corpus") agreement = load_tool("cldr_agreement") _STRATA = {stratum.label: stratum for stratum in native.STRATA} def _claim(value: dict[str, str] | str) -> dict[str, Any]: return {"rank": "normal", "mainsnak": {"snaktype": "value", "datavalue": {"value": value}}} def _person(labels: dict[str, str], native_name: tuple[str, str] | None = None, female: bool = False, **parts: str | list[str]) -> dict[str, Any]: claims: dict[str, list[dict[str, Any]]] = {} for prop, ids in parts.items(): claims[prop] = [_claim({"id": i}) for i in ([ids] if isinstance(ids, str) else ids)] if native_name: claims["P1559"] = [_claim({"text": native_name[0], "language": native_name[1]})] if female: claims["P21"] = [_claim({"id": wikidata.FEMALE})] return {"labels": {lang: {"value": text} for lang, text in labels.items()}, "claims": claims} def _names(language: str, **labels: str) -> dict[str, Any]: return {qid: {"labels": {language: {"value": text}}} for qid, text in labels.items()} def _one(person: dict[str, Any], names: dict[str, Any], label: str) -> dict[str, Any]: [row] = native.renderings("Q1", person, names, _STRATA[label]) return row # -- the script filter ------------------------------------------------------------ @pytest.mark.parametrize(("text", "language", "result"), [ ("Anushka Sharma", "hi", False), # a `mul` label: Latin, not Devanagari ("अनुष्का शर्मा", "hi", True), # vowel signs are marks of the script ("عزت‌الله انتظامی", "fa", True), # the ZWNJ is not a letter, so not asked ("佐々木 勇人", "ja", True), # 々 is IDEOGRAPHIC, not CJK UNIFIED ("長谷川テル", "ja", True), ("長谷川テル", "zh", False), # kana is not Han ("Ратко Janković", "sr", False), # a mixed text is in no one script ("·", "zh", False), # no letter: in no script ("مِحمد", "ar", True), # an Arabic point is a mark of the script ("محمد\u05b4", "ar", False), # a Hebrew point is not ]) def test_a_text_is_used_only_in_its_strata_script(text: str, language: str, result: bool) -> None: assert native.in_script(text, native.LANGUAGES[language].script) is result def test_a_latin_label_does_not_pass_as_a_native_name() -> None: names = {**_names("mul", Q2="Anushka", Q3="Sharma"), **_names("hi", Q3="शर्मा")} person = _person({"hi": "Anushka Sharma", "mul": "Anushka Sharma"}, P735="Q2", P734="Q3") assert native.renderings("Q1", person, names, _STRATA["hi-in"]) == [] # -- character alignment ------------------------------------------------------------ def test_an_unspaced_name_is_aligned_by_character() -> None: assert native.align_chars("김기영", [("surname", {"김"}), ("given", {"기영"})]) == { "surname": [0], "given": [1, 2]} @pytest.mark.parametrize(("text", "parts"), [ ("김기영", [("surname", {"김"}), ("given", {"기"})]), # 영 unclaimed ("이이영", [("surname", {"이"}), ("given", {"영"})]), # 이 in two places ("김기영", [("surname", {"김기"}), ("given", {"기영"})]), # 기 claimed twice ("刘放", [("surname", {"刘"})]), # the given is unstated ]) def test_an_unspaced_name_not_covered_once_per_character_is_left_out( text: str, parts: list[tuple[str, set[str]]]) -> None: assert native.align_chars(text, parts) is None def test_the_widest_spelling_of_a_part_claims_its_characters() -> None: # 佐 and 佐々木 both spell the surname item: the narrower match inside # the wider one is not a second place. assert native.align_chars("佐々木勇人", [("surname", {"佐", "佐々木"}), ("given", {"勇人"})]) == { "surname": [0, 1, 2], "given": [3, 4]} def test_a_chinese_name_stating_only_its_surname_is_not_used() -> None: # Most Chinese items state P734 and no P735: the rest of the text is # NOT read as the given name, which would be an inference. person = _person({"zh": "王之玺"}, P734="Q2") assert native.renderings("Q1", person, _names("zh", Q2="王"), _STRATA["zh-cn"]) == [] # -- the rows --------------------------------------------------------------------- def test_a_hangul_name_is_ordered_by_its_script() -> None: row = _one(_person({"ko": "김기영"}, P734="Q2", P735="Q3"), _names("ko", Q2="김", Q3="기영"), "ko-kr") assert (row["order"], row["shape"]) == ("surnameFirst", None) assert row["fields"] == {"surname": "김", "given": "기영"} @pytest.mark.parametrize(("label", "surname", "given"), [ ("zh-cn", "刘", "放"), ("ja-jp", "平賀", "淳")]) def test_a_han_name_is_ordered_by_its_script(label: str, surname: str, given: str) -> None: language = native.LANGUAGES[_STRATA[label].language].labels[0] row = _one(_person({language: surname + given}, P734="Q2", P735="Q3"), _names(language, Q2=surname, Q3=given), label) assert (row["order"], row["shape"]) == ("surnameFirst", None) def test_the_scripts_said_to_decide_the_order_are_the_parsers() -> None: # A drift alarm, not a derivation: the builder states which scripts # order a name by themselves, so that a parser change shows in the # record; this fails when the two stop naming the same scripts. assert {script.name for script in dict(Policy().script_orders)} == { "HAN", "HANGUL", "HIRAGANA"} assert native.script_orders("平賀淳") and native.script_orders("김기영") assert native.script_orders("高橋みなみ") # kana beside Han: licensed assert not native.script_orders("サトウハチロー") # pure katakana: not def test_a_spaced_han_name_is_ordered_by_its_script_not_by_a_shape() -> None: # Family first and spaced: build_wikidata_corpus would tag it # FAMILY_FIRST; the script reads it family-first by itself. row = _one(_person({"ja": "佐々木 勇人"}, P734="Q2", P735="Q3"), _names("ja", Q2="佐々木", Q3="勇人"), "ja-jp") assert (row["order"], row["shape"]) == ("surnameFirst", None) def test_a_pure_katakana_name_keeps_the_family_first_shape() -> None: # The parser leaves pure katakana to the declared order (a transcribed # foreign name), so the script decides nothing here. row = _one(_person({"ja": "サトウ ハチロー"}, P734="Q2", P735="Q3"), _names("ja", Q2="サトウ", Q3="ハチロー"), "ja-jp") assert (row["order"], row["shape"]) == ("surnameFirst", wikidata.FAMILY_FIRST_SHAPE) def test_an_unspaced_pure_katakana_name_keeps_the_family_first_shape() -> None: row = _one(_person({"ja": "サトウハチロー"}, P734="Q2", P735="Q3"), _names("ja", Q2="サトウ", Q3="ハチロー"), "ja-jp") assert (row["order"], row["shape"]) == ("surnameFirst", wikidata.FAMILY_FIRST_SHAPE) row = _one(_person({"ja": "ハチローサトウ"}, P734="Q2", P735="Q3"), _names("ja", Q2="サトウ", Q3="ハチロー"), "ja-jp") assert (row["order"], row["shape"]) == ("givenFirst", None) def test_a_one_word_name_outside_the_unspaced_scripts_is_never_split() -> None: # Only Han, kana and Hangul are aligned by character: an Arabic # compound written as one word is one word, never a given name and a # surname cut from it. person = _person({"ar": "عبدالله"}, P735="Q2", P734="Q3") assert native.renderings("Q1", person, _names("ar", Q2="عبد", Q3="الله"), _STRATA["ar-eg"]) == [] def test_a_spaced_name_in_another_script_is_ordered_as_written() -> None: row = _one(_person({"ka": "ნოდარ ცინცაძე"}, P734="Q2", P735="Q3"), _names("ka", Q2="ცინცაძე", Q3="ნოდარ"), "ka-ge") assert (row["order"], row["shape"]) == ("givenFirst", None) row = _one(_person({"uk": "Полунін Михайло Степанович"}, P734="Q2", P735="Q3", P5056="Q4"), _names("uk", Q2="Полунін", Q3="Михайло", Q4="Степанович"), "uk-ua") assert (row["order"], row["shape"]) == ("surnameFirst", wikidata.FAMILY_FIRST_SHAPE) assert row["fields"]["patronymic"] == "Степанович" def test_the_native_name_wins_and_one_rendering_is_kept() -> None: person = _person({"ja": "鍵山秀三郎"}, native_name=("鍵山 秀三郎", "ja"), P734="Q2", P735="Q3") rows = native.renderings("Q1", person, _names("ja", Q2="鍵山", Q3="秀三郎"), _STRATA["ja-jp"]) assert [row["text"] for row in rows] == ["鍵山 秀三郎"] def test_a_given_name_and_a_surname_are_both_required() -> None: person = _person({"he": "אהרן"}, P735="Q3", P734="Q2") # no surname written assert native.renderings("Q1", person, _names("he", Q2="הלוי", Q3="אהרן"), _STRATA["he-il"]) == [] person = _person({"ko": "김"}, P735="Q3", P734="Q2") # no given name written assert native.renderings("Q1", person, _names("ko", Q2="김", Q3="기영"), _STRATA["ko-kr"]) == [] def test_a_name_in_a_variant_language_is_used() -> None: # A zh-tw person may be labelled only in zh-hant. row = _one(_person({"zh-hant": "李正中"}, P734="Q2", P735="Q3"), _names("zh-hant", Q2="李", Q3="正中"), "zh-tw") assert row["text"] == "李正中" def test_a_native_name_in_another_language_is_not_used() -> None: # A ja P1559 on a Chinese stratum is not a Chinese name, even in Han. person = _person({}, native_name=("李正中", "ja"), P734="Q2", P735="Q3") assert native.renderings("Q1", person, _names("zh", Q2="李", Q3="正中"), _STRATA["zh-tw"]) == [] # -- feminine surnames --------------------------------------------------------------- @pytest.mark.parametrize(("language", "male", "female"), [ ("el", "Λελούδας", "Λελούδα"), ("el", "Γεωργιάδης", "Γεωργιάδου"), ("el", "Παπαδόπουλος", "Παπαδοπούλου"), ("el", "Παπαδάκης", "Παπαδάκη"), ("el", "Ευαγγελάτος", "Ευαγγελάτου"), ("uk", "Шевченко", ""), ("uk", "Заньківський", "Заньківська"), ("uk", "Полунін", "Полуніна"), ("bg", "Марков", "Маркова"), ("bg", "Стоянски", "Стоянска"), ("kk", "Тұрлыханов", "Тұрлыханова"), ]) def test_feminine_forms_by_language(language: str, male: str, female: str) -> None: assert wikidata.feminine(male, native.LANGUAGES[language].feminine) == female @pytest.mark.parametrize("text", ["Тарас Шевченко", "Оксана Полунін"]) def test_a_womans_surname_still_matches_as_its_item_spells_it(text: str) -> None: # The feminine forms are added to the item's spellings, not put in # their place: a surname with no feminine form (Шевченко), or one a # woman writes as the item does, still matches. given, surname = text.split() row = _one(_person({"uk": text}, female=True, P734="Q2", P735="Q3"), _names("uk", Q2=surname, Q3=given), "uk-ua") assert row["fields"]["surname"] == surname def test_a_womans_greek_surname_matches_its_feminine_form() -> None: names = _names("el", Q2="Λελούδας", Q3="Ντόρα") woman = _person({"el": "Ντόρα Λελούδα"}, female=True, P734="Q2", P735="Q3") assert _one(woman, names, "el-gr")["fields"]["surname"] == "Λελούδα" man = _person({"el": "Ντόρα Λελούδα"}, P734="Q2", P735="Q3") assert native.renderings("Q1", man, names, _STRATA["el-gr"]) == [] def test_the_russian_default_is_unchanged() -> None: assert wikidata.feminine("Достоевский") == "Достоевская" # -- the build -------------------------------------------------------------------- def _stub_build(monkeypatch: pytest.MonkeyPatch, strata: tuple[Any, ...], pooled: dict[str, list[str]]) -> None: people = {q: _person({"ko": "김기영"}, P734="Q2", P735="Q3") for qs in pooled.values() for q in qs} if "Q11" in people: people["Q11"]["claims"]["P244"] = [_claim("n82128473")] if "Q12" in people: # past the first stratum's quota: never taken, so never kept people["Q12"]["claims"]["P244"] = [_claim("n99999999")] names = _names("ko", Q2="김", Q3="기영") monkeypatch.setattr(native, "STRATA", strata) monkeypatch.setattr(native, "pool", lambda stratum, refresh: pooled[stratum.label]) monkeypatch.setattr(native, "entities", lambda ids, language, refresh: {**{q: people[q] for q in ids if q in people}, **{q: names[q] for q in ids if q in names}}) def test_build_takes_each_person_once_up_to_the_quota( monkeypatch: pytest.MonkeyPatch) -> None: first = native.Stratum("first", "Q884", "ko", 2, 3) second = native.Stratum("second", "Q884", "ko", 2, 3) _stub_build(monkeypatch, (first, second), {"first": ["Q10", "Q11", "Q12"], "second": ["Q11", "Q13", "Q14"]}) rows, lccns = native.build(refresh=False) taken = {lab: [r["file"] for r in rows if r["label"] == lab] for lab in ("first", "second")} assert taken == {"first": ["Q10", "Q11"], "second": ["Q13", "Q14"]} assert lccns == {"Q11": "n82128473"} def test_build_stops_before_writing_when_a_stratum_falls_short( monkeypatch: pytest.MonkeyPatch) -> None: thin = native.Stratum("thin", "Q884", "ko", 5, 5, least=3) _stub_build(monkeypatch, (thin,), {"thin": ["Q10", "Q11"]}) with pytest.raises(ValueError, match="thin 2/3"): native.build(refresh=False) def test_a_missing_pooled_person_stops_the_build(monkeypatch: pytest.MonkeyPatch) -> None: stratum = native.Stratum("s", "Q884", "ko", 1, 1) _stub_build(monkeypatch, (stratum,), {"s": ["Q10"]}) monkeypatch.setattr(native, "entities", lambda ids, language, refresh: {q: {"id": q, "missing": ""} for q in ids}) with pytest.raises(ValueError, match="s: WDQS pooled items .*missing: Q10"): native.build(refresh=False) def test_a_deprecated_lccn_is_not_read() -> None: person = {"claims": {"P244": [{**_claim("n1"), "rank": "deprecated"}, _claim("n2")]}} assert native.lccn(person) == "n2" assert native.lccn({"claims": {}}) is None def test_a_query_names_the_strata_label_language() -> None: stratum = _STRATA["zh-cn"] assert 'FILTER(LANG(?own) = "zh")' in native.query(stratum) assert f"LIMIT {stratum.pool}" in native.query(stratum) # -- the network and the cache ------------------------------------------------ def _results(n: int) -> bytes: return json.dumps({"results": {"bindings": [ {"item": {"value": f"http://www.wikidata.org/entity/Q{i}"}} for i in range(n)]}}).encode() def test_a_query_matching_nothing_stops_the_build( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: serve(monkeypatch, tmp_path, _results(0)) with pytest.raises(ValueError, match="ko-kr: the query matches nothing"): native.pool(_STRATA["ko-kr"], refresh=False) def test_a_pool_short_of_its_size_stops_the_build_unless_it_is_the_whole( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: serve(monkeypatch, tmp_path, _results(5)) with pytest.raises(ValueError, match="ko-kr: the query matched 5 people, fewer " "than its pool of 300"): native.pool(_STRATA["ko-kr"], refresh=False) assert len(native.pool(_STRATA["th-th"], refresh=False)) == 5 def test_a_stratum_marked_whole_whose_pool_fills_stops_the_build( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: # The population has outgrown the mark, which would otherwise go on # excusing a short pool. whole = native.Stratum("w", "Q869", "th", 1, 3, whole=True) serve(monkeypatch, tmp_path, _results(3)) with pytest.raises(ValueError, match="w: marked whole=True, but the query filled"): native.pool(whole, refresh=False) def test_entities_asks_only_for_the_languages_labels( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: fetched = serve(monkeypatch, tmp_path, b'{"entities": {"Q1": {"id": "Q1"}}}') native.entities(["Q1"], "zh", refresh=False) [url] = fetched assert "languages=zh%7Czh-hans%7Czh-hant%7Czh-cn%7Czh-tw%7Czh-hk%7Cmul" in url def test_an_lc_record_is_cached_and_read_back( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: sleeps: list[float] = [] fetched = serve(monkeypatch, tmp_path, _MARC.encode(), sleeps=sleeps) assert native.lc_variants("nr93008484", refresh=False) == ["הלוי, אהרן", "이 병기"] assert native.lc_variants("nr93008484", refresh=False) == ["הלוי, אהרן", "이 병기"] assert len(fetched) == 1 assert sleeps == [1] # one fetch, one polite wait; the cached read waits for nothing def _fail(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, code: int) -> None: monkeypatch.setenv("NAMEPARSER_TOOLS_CACHE", str(tmp_path)) def urlopen(request: urllib.request.Request, timeout: int) -> None: raise urllib.error.HTTPError(request.full_url, code, "x", {}, None) # type: ignore[arg-type] monkeypatch.setattr(urllib.request, "urlopen", urlopen) def test_an_lccn_lc_no_longer_has_is_none_and_not_cached( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: _fail(monkeypatch, tmp_path, 404) assert native.lc_variants("n0", refresh=False) is None assert not list((tmp_path / "lc").iterdir()) def test_any_other_lc_error_stops_the_check( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: _fail(monkeypatch, tmp_path, 503) with pytest.raises(urllib.error.HTTPError) as caught: native.lc_variants("n0", refresh=False) caught.value.close() # unclosed, it warns on 3.14 and fails the run def test_the_lc_check_tallies_each_name_once(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: rows = [{"file": q, "label": "he-il", "text": "אהרן הלוי", "fields": {"given": "אהרן", "surname": "הלוי"}} for q in ("Q1", "Q2", "Q3")] variants = {"n2": ["הלוי, אהרן"], "n3": ["אהרן, הלוי"]} monkeypatch.setattr(native, "lc_variants", lambda lccn, refresh: variants[lccn]) native.lc_check(rows, {"Q2": "n2", "Q3": "n3"}, refresh=False) out = capsys.readouterr().out assert ("names 3, with an LCCN 2, no LC record 0, LC splits it 2, agree 1" in out) assert "1 split differently" in out and "Q3" in out def test_the_lc_check_counts_a_stale_lccn(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: row = {"file": "Q1", "label": "he-il", "text": "אהרן הלוי", "fields": {"given": "אהרן", "surname": "הלוי"}} monkeypatch.setattr(native, "lc_variants", lambda lccn, refresh: None) native.lc_check([row], {"Q1": "n0"}, refresh=False) assert "no LC record 1, LC splits it 0" in capsys.readouterr().out # -- the LC cross-check ------------------------------------------------------------ _MARC = """<record xmlns="http://www.loc.gov/MARC21/slim"> <datafield tag="100" ind1="1"><subfield code="a">Haleṿi, Aharon</subfield></datafield> <datafield tag="400" ind1="1"><subfield code="a">הלוי, אהרן</subfield></datafield> <datafield tag="400" ind1="1"><subfield code="a">이 병기,</subfield> <subfield code="d">1891-1968</subfield></datafield> </record>""" def test_an_lc_record_yields_its_variants() -> None: assert native._lc_checked(_MARC, "x") == {"variants": ["הלוי, אהרן", "이 병기"]} with pytest.raises(ValueError, match="not MARC XML"): native._lc_checked("<html>", "x") with pytest.raises(ValueError, match="not a MARC record"): native._lc_checked("<feed/>", "x") @pytest.mark.parametrize(("text", "variants", "split"), [ ("אהרן הלוי", ["הלוי, אהרן"], ("הלוי", "אהרן")), # the comma ("이병기", ["이병기", "이 병기"], ("이", "병기")), # a CJK space ("李秉岐", ["李秉岐"], None), # unspaced: no split ("אהרן הלוי", ["Haleṿi, Aharon"], None), # other letters ("إبراهيم صالح", ["صالح، إبراهيم"], ("صالح", "إبراهيم")), # the Arabic comma # the comma's words in the text's own order: LC's family still leads ("إبراهيم صالح", ["إبراهيم، صالح"], ("إبراهيم", "صالح")), # no comma, and the text is spaced: a space splits only unspaced CJK ("إبراهيم صالح", ["إبراهيم صالح"], None), ]) def test_lc_splits_a_name_by_its_comma_or_a_cjk_space( text: str, variants: list[str], split: tuple[str, str] | None) -> None: assert native.lc_split({"text": text}, variants) == split # -- the committed files --------------------------------------------------------------- _ROWS = agreement.load_rows(agreement.NATIVE_FIELDS) def test_each_committed_stratum_holds_what_the_build_promises() -> None: names = {label: {row["file"] for row in _ROWS if row["label"] == label} for label in {row["label"] for row in _ROWS}} assert set(names) == set(_STRATA) for label, ids in names.items(): assert _STRATA[label].floor <= len(ids) <= _STRATA[label].quota, label # one rendering per person, and one stratum assert set(Counter(row["file"] for row in _ROWS).values()) == {1} def test_every_committed_text_is_in_its_strata_script() -> None: assert not [row["text"] for row in _ROWS if not native.in_script( row["text"], native.LANGUAGES[_STRATA[row["label"]].language].script)] def test_every_han_and_kana_stratum_is_scored_under_its_pack() -> None: # Both ways: a zh or ja stratum missing from the scorer's NATIVE_LABELS # would never be parsed with its pack, and nothing else would notice. packs = {"zh": "ZH", "ja": "JA"} assert agreement.NATIVE_LABELS == {s.label: packs[s.language] for s in native.STRATA if s.language in packs} def test_a_committed_rendering_its_script_orders_has_no_shape() -> None: assert not [row["text"] for row in _ROWS if native.script_orders(row["text"]) and row["shape"] is not None] �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_word_report.py��������������������������������������0000664�0000000�0000000�00000035476�15273032717�0024376�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Offline checks on tools/vocab/word_report.py. The report reads each tree in a child process and counts the local corpora without quoting them, so what these pin is the grid's coverage (every shape, case and order), the child's refusal of a package from outside its tree, a base comparison that can report a difference, the corpus counts' positions and readings, and that no corpus name reaches the output. The corpus names are invented, and each carries a word no grid shape holds, so that one in the output is a quoted name. """ import importlib.util import sys from collections import Counter from pathlib import Path from types import ModuleType from typing import Any import pytest from ._differential_fixtures import checkout_copy, own_cache, plant_corpus _PATH = Path(__file__).parents[2] / "tools" / "vocab" / "word_report.py" def _load() -> ModuleType: spec = importlib.util.spec_from_file_location("vocab_word_report", _PATH) assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module spec.loader.exec_module(module) return module report = _load() corpora = report.local_corpora _ROOT = report.ROOT @pytest.fixture(autouse=True) def _own_cache(monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory) -> Path: return own_cache(monkeypatch, tmp_path_factory) @pytest.fixture(autouse=True) def _no_usage(monkeypatch: pytest.MonkeyPatch) -> None: """name_usage.py reads caches of its own; its rows are stubbed.""" monkeypatch.setattr(report, "usage", lambda words: { w: [{"source": "stub", "status": "ok", "given": 7, "note": ""}] for w in words}) #: Invented names: Zyx and Qwv are in no grid shape. _NAMES = ["Sir Zyx", "Sir Zyx", "Mr Sir Qwv", "Zyx, Sir", "SIR, ANN", "Ann Sir", "Sirius Zyx", "Bishop Zyx"] def _plant(monkeypatch: pytest.MonkeyPatch, names: list[str] = _NAMES) -> None: plant_corpus(monkeypatch, corpora, "ctgov-officials", [{"name": n, "nct": str(i)} for i, n in enumerate(names)], "2026-10-21T09:00:05", "officials") def test_the_grid_spells_every_shape_three_ways_under_every_order() -> None: items = report.grid_items("sir") texts = list(dict.fromkeys(t for t, _ in items)) assert len(items) == 3 * len(texts) == 3 * 3 * len(report.SHAPES) assert {o for _, o in items} == set(report.ORDERS) assert {"Sir Lau", "SIR LAU", "sir lau", "Lau, Sir", "LAU, SIR", ", Sir", "John Smith, Jr., Sir", "Mr. and Mrs. Sir Lau"} <= set(texts) def test_a_grid_folds_the_orders_that_agree_and_with_a_base_prints_only_moves() -> None: items = [("Lau, Sir", o) for o in report.ORDERS] + [("Ana Sir", o) for o in report.ORDERS] same = {"fields": {"title": "Sir"}, "ambiguities": []} rows = [same] * 3 + [{"fields": {"family": "Sir"}, "ambiguities": []}, *[{"fields": {"given": "Sir"}, "ambiguities": ["X"]}] * 2] lines = report.format_grid(items, rows) assert lines[0].split() == ["'Lau,", "Sir'", "all", "title='Sir'"] assert len(lines) == 4 and "GF" in lines[1] and "[X]" in lines[2] base = list(rows) base[4] = same moved = report.format_grid(items, rows, base) assert len(moved) == 2 and "base title='Sir'" in moved[0] and "head given" in moved[1] @pytest.mark.parametrize(("name", "found"), [ ("Sir Zyx", [("sir", "opens")]), ("SIR. ZYX", [("sir", "opens")]), ("Zyx, Sir Ann", [("sir", "after a comma")]), ("Sir, Sir", [("sir", "opens"), ("sir", "after a comma")]), ("Sirius Zyx", []), ("Ann Sir", []), ("Zyx, Ann Sir", []), ]) def test_a_corpus_name_holds_the_word_where_it_opens_the_name_or_the_comma_part( name: str, found: list[tuple[str, str]]) -> None: assert report.positions(name, frozenset({"sir"})) == found @pytest.mark.parametrize(("fields", "reading"), [ ({"title": "Sir", "given": "Zyx"}, "title"), ({"title": "Mr Sir", "given": "Zyx"}, "title"), ({"title": "Sir"}, "title"), ({"given": "Sir", "family": "Zyx"}, "given"), ({"given": "Ann", "family": "Sir"}, "other"), # The registers the ranking is for write in capitals. ({"title": "SIR", "given": "ZYX"}, "title"), ({"given": "SIR", "family": "ZYX"}, "given"), ]) def test_a_reading_is_classed_by_where_the_word_went(fields: dict[str, str], reading: str) -> None: assert report.classify("sir", "opens", {"fields": fields, "ambiguities": []}) == reading def test_the_ranking_puts_the_words_filed_most_as_forenames_first() -> None: # The forename box's count orders: a word three filers typed as a # forename (every one of its filings) ranks below one 164 did among # 165, since a count cannot be rebuilt from a share. A word filed # only as a title is the cost of a move; unfiled words come last, by # titles: a register's LAU, BARON loses its given name as Baron Lau. head: Any = {"a": {"x": {"opens": Counter(title=1, given=9)}, "y": {"opens": Counter(title=50)}, "w": {"after a comma": Counter(title=6)}, "v": {"opens": Counter(title=2)}, "u": {"opens": Counter(title=1)}}, "b": {"x": {"opens": Counter(title=1), "after a comma": Counter(title=2)}}} scanned: Any = {"a": {"found": {}, "boxes": {"y": Counter(title=40), "v": Counter(forename=3), "u": Counter(title=1, forename=164)}}, "b": {"found": {}, "boxes": {"x": Counter(title=1, forename=3)}}} ranked = report.ranking(["x", "y", "w", "v", "u", "z"], head, scanned) # v and x tie on 3 forenames: the higher share first. assert [k for k, _ in ranked] == ["u", "v", "x", "y", "w", "z"] lines = report.format_ranking(ranked) assert lines[1].split() == ["u", "1", "1", "0", "0", "1", "164", "1", "164/165"] assert lines[2].split()[-3:] == ["3", "0", "3/3"] assert lines[3].split()[-3:] == ["3", "1", "3/4"] assert lines[4].split()[-3:] == ["0", "40", "0/40"] assert lines[5].split() == ["w", "0", "0", "6", "6", "6", "0", "0", "-"] def test_the_report_counts_the_corpora_and_quotes_no_name( monkeypatch: pytest.MonkeyPatch) -> None: _plant(monkeypatch) out = "\n".join(report.report(["sir", "bishop"], _ROOT, None, rank_only=False, jobs=1)) assert "Zyx" not in out and "Qwv" not in out # Sir Zyx, twice, reads the title; SIR, ANN opens with the word # as a surname; Zyx, Sir holds it after the comma; Mr Sir Qwv # opens with another word, and Sirius is another word. assert "ctgov-officials, opens: 3 (title 2, other 1)" in out assert "ctgov-officials, after a comma: 1 (title 1)" in out assert "fec-individuals: not fetched" in out assert "stub: given 7" in out ranked = out.split("ranking:")[1].splitlines() assert ranked[1].strip() == f"(read 1 of {len(corpora.SOURCES)} local corpora; not fetched: " \ + ", ".join(s for s in sorted(corpora.SOURCES) if s != "ctgov-officials") + ")" assert ranked[3].split() == ["sir", "3", "2", "1", "1", "3", "0", "0", "-"] assert ranked[4].split() == ["bishop", "1", "1", "0", "0", "1", "0", "0", "-"] def test_the_ranking_alone_reads_no_grid(monkeypatch: pytest.MonkeyPatch) -> None: _plant(monkeypatch) out = report.report(["sir"], _ROOT, None, rank_only=True, jobs=1) assert not any(line.startswith(("grid", "== ")) for line in out) assert out[-1].split() == ["sir", "3", "2", "1", "1", "3", "0", "0", "-"] def test_a_base_check_reports_a_planted_change_and_none_across_a_copy( monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: # The harness must be able to report a difference: a base read with # the head's tree by mistake would show no move across a planted one. _plant(monkeypatch) planted = checkout_copy(tmp_path / "planted", planted=True) out = report.report(["sir"], _ROOT, planted, rank_only=False, jobs=2) text = "\n".join(out) assert "Zyx" not in text and "Qwv" not in text assert sum(" base " in line for line in out) > len(report.SHAPES) assert "->" in text.split("\ncorpora (")[1] copy = checkout_copy(tmp_path / "copy") out = report.report(["sir"], _ROOT, copy, rank_only=False, jobs=2) assert "byte-identical" in out[2] assert out[out.index(next(line for line in out if line.startswith("grid"))) + 1] == " (none)" assert "->" not in "\n".join(out).split("\ncorpora (")[1].split("usage")[0] def test_a_child_refuses_a_package_from_outside_its_tree(tmp_path: Path) -> None: # tmp_path holds no nameparser, so the child's import falls through # to the installed package, which check_source refuses. with pytest.raises(SystemExit, match="(?s)reading with .* failed.*not from the source package"): report.read_trees([tmp_path], [("Sir Lau", None)], 1) # The control: this checkout is the package its child imports. [rows] = report.read_trees([_ROOT], [("Sir Lau", None)], 1) assert rows[0]["fields"]["title"] == "Sir" def test_a_local_corpus_s_credit_is_trimmed_from_its_usage_note() -> None: rows = [{"source": "ctgov-officials (local corpus)", "status": "ok", "given": None, "note": "Some registry; terms, data as of 2026-10-21; 10 of 5 officials write it"}, {"source": "US SSA", "status": "missing", "note": "not cached"}] assert report.format_usage(rows) == [ " ctgov-officials (local corpus): -; 10 of 5 officials write it", " US SSA: -; missing: not cached"] def test_the_command_refuses_a_rank_against_a_base_and_no_words( capsys: pytest.CaptureFixture[str]) -> None: for argv in (["--rank", "sir", "--base", "."], []): with pytest.raises(SystemExit) as raised: report.main(argv) assert raised.value.code == 2 capsys.readouterr() def test_a_ranking_over_no_fetched_corpus_says_its_counts_are_empty() -> None: # Unread corpora counted as zeros are a clean run's look-alike. out = report.report(["sir"], _ROOT, None, rank_only=True, jobs=1) assert any("read no local corpus" in line for line in out) assert out[-1].split() == ["sir", "0", "0", "0", "0", "0", "0", "0", "-"] def test_a_damaged_corpus_ends_the_report(monkeypatch: pytest.MonkeyPatch) -> None: _plant(monkeypatch) raw = Path(str(corpora.directory("ctgov-officials", create=False))) / corpora.RAW raw.write_text(raw.read_text() + "\n") with pytest.raises(SystemExit, match="ctgov-officials: damaged"): report.report(["sir"], _ROOT, None, rank_only=True, jobs=1) def test_a_failed_usage_lookup_is_reported_not_raised(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.undo() # the real usage(), with its subprocess faked import subprocess answers = [subprocess.CompletedProcess([], 1, "", "Traceback\nOSError: boom\n"), subprocess.CompletedProcess([], 1, "", ""), subprocess.CompletedProcess([], 0, "not json", "")] monkeypatch.setattr(report.subprocess, "run", lambda *a, **k: answers.pop(0)) notes = [report.format_usage(report.usage(["sir"])["sir"]) for _ in range(3)] assert notes[0] == [" name_usage.py: -; failed: OSError: boom"] assert notes[1] == [" name_usage.py: -; failed: exit 1"] assert notes[2][0].startswith(" name_usage.py: -; failed: its output is not JSON") def test_the_child_s_worker_path_reads_as_it_reads_in_process( monkeypatch: pytest.MonkeyPatch) -> None: # A child takes the worker path only past PARALLEL_FLOOR texts, which # no test reaches in a child; the same call, run in this process. import importlib items = report.grid_items("sir") serial = report._parse_chunk(items)[1] monkeypatch.setattr(importlib.import_module("snapshot"), "PARALLEL_FLOOR", 1) assert report.snapshot.parallel(report._parse_chunk, items, 2, module="word_report") == serial #: Invented PSC filings: the boxes as Companies House keeps them, a box #: only where the filer filled it. _FILED = [{"title": "Sir", "forename": "Zyx", "surname": "Qwv"}, {"title": "Sir", "forename": "Ann", "surname": "Zyx"}, {"forename": "Sir", "surname": "Zyx"}, {"title": "Dr", "forename": "Sir", "surname": "Qwv"}, {"forename": "Sirius", "surname": "Zyx"}] def test_a_corpus_of_filed_boxes_splits_the_names_by_the_box_the_word_was_typed_in( monkeypatch: pytest.MonkeyPatch) -> None: import importlib psc = corpora.PSC records = [{"name": " ".join(r.get(b, "") for b in ("title", "forename", "surname") ).strip(), "nationality": "British", **r} for r in _FILED] plant_corpus(monkeypatch, corpora, psc, records, "2026-10-22", "filed names", {"sample": f"1 in {importlib.import_module('local_psc').PSC_SAMPLE}"}) out = report.report(["sir"], _ROOT, None, rank_only=False, jobs=1) text = "\n".join(out) assert "Zyx" not in text and "Qwv" not in text # Dr Sir Qwv opens with another word, so only three are boxed. assert f"{psc}, opens: 3 (" in text and "filed in the title box 2, forename box 1" in text assert out[-1].split() == ["sir", "3", "3", "0", "0", "3", "1", "2", "1/3"] def _cases(shape: str) -> list[str]: return [shape, shape.upper(), shape.lower()] def _row(**fields: str) -> dict[str, Any]: return {"fields": fields, "ambiguities": []} def test_a_base_grid_folds_cases_and_orders_and_keeps_an_order_specific_move() -> None: items = [(t, o) for t in _cases("Lau, Sir") + _cases("Ana Sir") for o in report.ORDERS] titled = [_row(title=t.split(", ")[1]) for t in _cases("Lau, Sir") for _ in report.ORDERS] given = [_row(given=t.split()[1], family=t.split()[0]) for t in _cases("Ana Sir") for _ in report.ORDERS] base = titled + given # Every case and order of the first shape moves alike: one block. head = [_row(given=r["fields"]["title"]) for r in titled] + list(given) # The second moves under FAMILY_FIRST alone: a block for that order. head[9 + 1] = head[12 + 1] = head[15 + 1] = _row(title="Sir") moved = report.format_grid(items, head, base) assert len(moved) == 4, moved assert moved[0].split()[:5] == ["'Lau,", "Sir'", "(3", "cases)", "all"] assert moved[2].split()[:8] == ["'Ana", "Sir'", "(3", "cases)", "FF", "base", "given='Sir'", "family='Ana'"] # Spelled differently once lowercased, cases stay apart. head[12 + 1] = _row(title="SIR ANA") assert len(report.format_grid(items, head, base)) == 2 + 3 * 2 def test_a_grid_without_a_base_folds_the_cases_too() -> None: items = [(t, o) for t in _cases("Lau, Sir") for o in report.ORDERS] rows = [_row(title=t.split(", ")[1]) for t in _cases("Lau, Sir") for _ in report.ORDERS] [line] = report.format_grid(items, rows) assert line.split() == ["'Lau,", "Sir'", "(3", "cases)", "all", "title='Sir'"] ��������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tests/v2/test_zh_surnames_tool.py���������������������������������0000664�0000000�0000000�00000013136�15273032717�0025410�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Offline checks on tools/zh_surnames.py, which writes the ZH pack's ranked surname block (#695). The tool's sources are two downloads and a live SPARQL query, none of which a test may touch, so these pin the reading and the selection on hand-built inputs: the failure that matters is a wrong surname shipped silently -- a placeholder row, a variant no one bears as a surname -- since nothing downstream can tell a shipped entry from a meant one. """ from __future__ import annotations import importlib.util import sys import urllib.request from pathlib import Path from types import ModuleType import pytest from nameparser.locales import zh _PATH = Path(__file__).parents[2] / "tools" / "zh_surnames.py" def _load() -> ModuleType: spec = importlib.util.spec_from_file_location("zh_surnames", _PATH) assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module # @dataclass looks itself up here spec.loader.exec_module(module) return module tool = _load() @pytest.fixture(autouse=True) def _no_network(monkeypatch: pytest.MonkeyPatch) -> None: def refuse(*args: object, **kwargs: object) -> None: raise AssertionError("a test reached the network") monkeypatch.setattr(urllib.request, "urlopen", refuse) # One row per surname per age band, as the Taiwan file has it, with its # placeholder row and a compatibility ideograph (U+F9A8, which NFC-folds # to 令) among them. _MOI = """年度,ranking,lastname,age,人口數 112,1,陳,0-14,1 112,1,陳,15-64,1 112,1,陳,65+,1 112,2,其他,0-14,1 112,3,令,0-14,1 112,4,衛,0-14,1 112,5,張簡,0-14,1 """ def test_the_taiwan_file_reads_as_single_unified_surnames_in_rank_order() -> None: # 其他 ("other") is a placeholder, 張簡 a compound (compounds are # curated by hand), and the compatibility ideograph no keyboard # produces for a name. assert tool.read_moi(_MOI) == ["陳", "衛"] def test_an_error_page_is_refused_rather_than_read_as_empty() -> None: with pytest.raises(ValueError, match="columns"): tool.read_moi("<html>Service unavailable</html>") def _item(qid: str, bearers: int, *labels: str) -> list[dict[str, str]]: return [{"item": f"http://www.wikidata.org/entity/{qid}", "n": str(bearers), "label": label} for label in labels] def test_a_label_written_as_surname_x_names_the_character() -> None: # 易, 鄺 and 閆 are labelled this way on Wikidata; read literally, # they had dropped out of the ranking. (item,) = tool.read_wikidata(_item("Q1", 9, "鄺姓", "邝氏")) assert item.singles() == {"鄺", "邝"} def test_items_rank_by_bearers_and_compounds_are_not_singles() -> None: rows = (_item("Q1", 5, "卫", "衛") + _item("Q2", 9, "欧阳") + _item("Q3", 7, "柯")) assert [i.qid for i in tool.read_wikidata(rows)] == ["Q3", "Q1"] def _forms(surnames: list[object]) -> list[set[str]]: return [s.forms for s in surnames] # type: ignore[attr-defined] def test_one_surname_in_two_sources_is_one_entry_with_both_ranks() -> None: items = tool.read_wikidata(_item("Q1", 5, "卫", "衛")) (surname,) = tool.select(["衛"], items, {}, top=10) assert surname.forms == {"卫", "衛"} assert (surname.moi_rank, surname.wikidata_rank) == (1, 1) def test_an_item_bridging_two_entries_merges_them_keeping_the_best_ranks() -> None: # Two ranked rows that are one surname in two scripts become one # entry once an item labelled with both joins them, keeping the # better rank of each source. items = tool.read_wikidata(_item("Q1", 5, "卫", "衛")) surnames = tool.select(["陳", "衛", "卫"], items, {}, top=10) (merged,) = [s for s in surnames if "衛" in s.forms] assert len(surnames) == 2 and merged.forms == {"卫", "衛"} assert (merged.moi_rank, merged.wikidata_rank) == (2, 1) def test_a_traditional_form_gains_its_one_simplified_variant() -> None: assert _forms(tool.select(["關"], [], {"關": {"关"}})) == [{"關", "关"}] def test_a_supplementary_plane_variant_does_not_count_against_the_bmp_one() -> None: # Unihan gives 蘭 both 兰 and a GB 18030 extension form; a name is # typed with 兰, so 兰 is the one variant. variants = tool.read_unihan("U+862D\tkSimplifiedVariant\tU+5170 U+2C795\n") assert _forms(tool.select(["蘭"], [], variants)) == [{"蘭", "兰"}] def test_a_simplified_form_never_gains_an_unattested_traditional_one() -> None: # 余's traditional variants include 餘, which is no one's surname; # only the simplified direction is read from Unihan. variants = tool.read_unihan("U+4F59\tkTraditionalVariant\tU+4F59 U+9918\n" "U+9918\tkSimplifiedVariant\tU+4F59\n") assert _forms(tool.select(["余"], [], variants)) == [{"余"}] def test_each_ranking_contributes_only_its_top() -> None: surnames = tool.select(["陳", "林", "詹"], [], {}, top=2) assert _forms(surnames) == [{"陳"}, {"林"}] def test_the_shipped_block_is_what_the_tool_writes_for_its_own_contents() -> None: # A hand edit inside the generated block, or a block the tool could # not have written, is caught here without the sources. text = tool.ZH_MODULE.read_text(encoding="utf-8") assert tool.rewrite(text, tool.render(list(zh._SINGLE_RANKED))) == text def test_the_block_repeats_no_census_row_and_holds_only_single_ideographs() -> None: by_hand = set(zh._SINGLE) | set(zh._SINGLE_TRADITIONAL) assert tool.listed_by_hand(tool.ZH_MODULE.read_text(encoding="utf-8")) == by_hand assert not by_hand & set(zh._SINGLE_RANKED) assert all(tool.is_han(c) for c in zh._SINGLE_RANKED) ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/������������������������������������������������������������0000775�0000000�0000000�00000000000�15273032717�0020047�5����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/�����������������������������������������������0000775�0000000�0000000�00000000000�15273032717�0022503�5����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/README.md��������������������������������������0000664�0000000�0000000�00000266351�15273032717�0023777�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������# Corpus snapshot and released-wheel comparison Dev-only tooling (not shipped: only `nameparser/` is packaged). Two tools read the same corpora, `corpus*.jsonl` beside this file: - **`snapshot.py`** records how the working tree reads every corpus name, one line per name in `snapshot.jsonl`, and `tests/v2/test_snapshot.py` fails when the tree stops matching it. This is the check that runs on every change (#664). - **`compare.py`** reads the same names with a released nameparser installed fresh from PyPI and prints how the tree differs. It is for questions about a release older than the snapshot, and it fails on nothing. Both print moves the same way: grouped by which fields changed, with each name's old and new values, so a large change reads as a few kinds of move rather than a wall of names. ## The snapshot ``` uv run python tools/differential/snapshot.py # check, grouped uv run python tools/differential/snapshot.py --record # re-record uv run python tools/differential/snapshot.py --since v2.5.0 ``` Each line holds one (name, order): the seven role fields, the reported ambiguity kinds, `initials()` and the two case-repair views on the 2.0 surface (#826) -- `capitalized` where `capitalized()` renders other than the name as parsed, `capitalized_force` where `capitalized(force=True)` renders other than `capitalized()` does. A name repair leaves alone records neither, but a view is the whole repaired name, so in a name repair does change, a role move that changes the rendering moves the view too, whether or not a word's case changed -- and, under `facade`, whatever `HumanName` reads differently from the 2.0 surface (3 names on 2026-10-13). Since 2.7 a line also holds `alternatives`, the reading each ambiguity report did not take, one per report in report order, so a report losing its alternative beside an identical one still moves the line (#752 phase 2m). 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 after the snapshot existed that the recording tree could record: a comparison leaves out a key either side did not record, decided by that header and not by whether a row holds one, since a tree with the field can read a set of names no report reaches. **When the test fails.** Read the grouped moves it prints. If every move is one the change intends, re-record and commit `snapshot.jsonl` with the change: the diff of that file is the review's list of the names the change moved, one line per name. A move nobody predicted is a finding, the same as any failing test. A corpus that loses names shows them as `(removed from corpus)`, so a truncated file cannot pass for a clean run. **At release time.** `--since <last release tag>` compares the tree against the snapshot as committed at that tag: the names this release moved. That is the measurement a release-note claim about moved names is drawn from. Count CLDR's fictional renderings out of such a figure, or say it includes them: the closing line says how many moved names only `corpus_cldr.jsonl` holds. A snapshot committed before #826 records no case view, so against one (`--since v2.5.0`) the views are not compared and the report says so; a case claim for that release is read from `compare.py --baseline 2.5.0`, which computes the views on both sides. A snapshot without the header records no alternatives, so `--since v2.6.0` leaves them out the same way, and so does `compare.py --baseline` against a release before 2.7, whose worker says it has no `Ambiguity.alternative`: a name the tree reports one for is not a name that moved (867 of the 1,088 entries `--baseline 2.6.0` reported on 2026-10-26 were only that). `--tree PATH` reads with another checkout's `nameparser` (the corpus is always this checkout's), and checks that the import really came from there. It is how two commits are compared without a release between them. ## Comparing against a released version ``` uv run python tools/differential/compare.py --baseline 1.4.0 uv run python tools/differential/compare.py # bare = DEFAULT_BASELINE, the last release uv run python tools/differential/compare.py --baseline 1.4.0 --corpus my_names.jsonl ``` `--corpus PATH` (repeatable) compares a file of your own instead of the corpora here, one JSON string per line. `compare.py` renders a small worker script with the release pinned in PEP 723 inline metadata, writes it to a temp directory outside the worktree, and runs it with `uv run --no-project`. The worker parses every corpus name with the pinned release; `compare.py` parses the same names with the tree and prints the moves. From 2.0.0 on both surfaces are compared; at 1.4.0 the facade alone, since 1.4 has no 2.0 API. A name tagged with a family-first shape is skipped below 2.0.0, which introduced `name_order`. The closing lines count the moved entries against the entries compared, how many of the moved are Latin-only, and how many order-bearing entries were skipped: quote a figure with its population. Most of what is left in `compare.py` exists for one failure: a comparison that silently reads the tree on both sides and reports no differences. The worker prints its own `__version__` and `__file__` first, and the run aborts unless the version matches the requested release *and* the module loaded from outside the repository. `compare.py` also checks that its own import is this checkout's source package, and strips `PYTHONPATH` from the worker's environment. The traps below are why. ## The three invocation traps None of the three below is hypothetical -- the second was reproduced twice while working on #320, and the third was demonstrated during review of this harness's own generalization. The first two are recorded here because the generated worker's cwd and script path are what disarm them: a later change that runs the worker from a cwd inside the project reopens both. The analysis is the reason for the design, so it outlives the bug. **Without `--no-project`,** `uv` installs the working tree as an editable dependency and the version pin never takes effect, silently comparing the tree against itself. `compare.py` passes `--no-project`, and runs the worker from a temp cwd where there is no project to discover in the first place. **With `python` in front of the script path** -- `uv run --no-project python <script>` instead of `uv run --no-project <script>` -- `python` becomes the command and the script a mere argument, so `uv` never reads the script's PEP 723 inline metadata and the `nameparser==<baseline>` pin is never installed. With nothing to satisfy, `uv` runs the script in the project's own `.venv`, where the working tree is installed editable (`__editable__.nameparser-2.0.0.pth`) -- so the import resolves to the checkout and **the tree answers every query while the output is labelled with the baseline version**. It is the same editable working tree that the missing-`--no-project` case lands on, by a different road. **`PYTHONSAFEPATH=1` does not rescue it** -- that is the fix for the sibling CWD trap (`AGENTS.md`, "Comparing against v1"), and reaching for it here is the natural wrong turn, since a `.pth`-installed package is on `sys.path` proper and safe-path never touches it. Measured: safe path on, still 2.0.0. `sys.path[0]` is the SCRIPT's directory, which contains no `nameparser` at all, so the CWD is not the route either. Running the same command with an absolute script path from a directory outside the project is the one variant that does not lie: it raises `ModuleNotFoundError` instead. Generating the worker into a temp dir makes that honest variant the only reachable one. That corruption is worse than an ordinary mistake, because of what the bad output looks like. It is not garbage and it does not crash: it is exactly the tree's own expected values, which is exactly what someone asking "did the baseline agree?" is hoping to see. Every diff vanishes, the run comes out as parity, and the conclusion drawn is the precise opposite of the truth. So do not trust a version number you did not make the worker report. Establishing which library actually answered is cheap, and the harness now does it for you: the worker prints `nameparser.__version__` and `nameparser.__file__` from **inside** itself as its first output line, and `compare.py` aborts unless the release tuple matches the requested baseline *and* the module resolved outside the repo root. Both halves matter and neither implies the other -- an editable install reports the TREE's version, so version agreement proves nothing when tree and baseline share a number, while a genuine wheel at the wrong version passes any path check. ### Trap 3: `PYTHONPATH` shadows BOTH sides, and the tell passes The first two traps are about which library answers as the *baseline*. This one is about the *tree*, and it defeats every guard aimed at the other side. Run as a script, `sys.path[0]` is `tools/differential/` -- which holds no `nameparser` -- so a `PYTHONPATH` entry outranks the editable install and `compare.py` imports a released wheel while believing it read the checkout. PEP 723 does not save the worker either: `PYTHONPATH` precedes site-packages *inside* uv's own environment. Measured 2026-08-05, with a released 2.0.0 on `PYTHONPATH`: ``` baseline: nameparser 2.0.0 (.../shadow/nameparser/__init__.py) corpus: 751 names; intentional diffs: 0; unexplained: 0 ``` exit 0, with **both halves of the baseline tell passing** -- the version matched, and the path was outside the repo. 89 diffs became 0. (That output is the ledger-era summary line; the trap is the same for today's report, which would print no differences.) Two things close it. `_worker_env()` strips `PYTHONPATH`/`PYTHONHOME` from the child, and `_check_tree()` requires `compare.py`'s own `nameparser` to be this checkout's **source package**. That second predicate was first written as "somewhere under the repo", which was wrong in a way worth recording: the checkout also contains `.venv/`, `build/lib/` and `dist/`, any of which can hold a released wheel, so `PYTHONPATH=<repo>/build/lib` was the same trap with the shadowing directory moved one level to the left -- and `uv` never touches `build/`, so nothing self-heals it. Why this is easy to miss when probing by hand: from the repo root, `python -c "import nameparser"` puts the CWD on `sys.path` first, so the checkout wins and the trap does not reproduce. Only the script invocation shows it. ## The corpora | File | Job | Source | Blind to | |---|---|---|---| | `corpus.jsonl` | Watch | v1's own test suite at a pinned ref | anything 2.0 added: v1's authors had no reason to test a typographic nickname delimiter or a Cyrillic title | | `corpus_issues.jsonl` | Watch | name-like strings harvested from the GitHub issue tracker | anything nobody ever reported | | `corpus_cjk.jsonl` | Watch | the CJK-bearing rows of `tests/v2/cases.py`, via `build_cjk_corpus.py` (#295) | anything the case table itself missed | | `corpus_cldr.jsonl` | Watch, Score | Unicode CLDR's person-name formatting test data at `release-48`: each fictional name's long, formal rendering in givenFirst, surnameFirst and sorting order, about 110 locales in native scripts, via `build_cldr_corpus.py` (2026-10-12); Unicode-3.0, notice in `cldr_LICENSE` | real-world name distributions: the names are written to fill every field, so full-house names are over-represented and plain two-word names under-represented | | `corpus_gnd.jsonl` | Watch, Score | the German National Library's authority file (GND, via lobid), real people who are deceased and have a Wikipedia article: each preferred name as cataloged (`Gogh, Vincent van`) and, outside family-first areas, a given-first rendering composed from its fields (a regnal name is composed only, `Johann II.`, without its German descriptor), sampled by shape and area via `build_gnd_corpus.py` (2026-10-16); CC0, credit in `gnd_LICENSE` | names as their bearers write them: German library romanization and German forms for historic figures, Latin script only, titles, credentials and nicknames kept out of the preferred name (a few slip into a `personalName`: `Imam Bonjol`), and the catalog's entry rules (not the bearer's culture) decide what counts as a surname | | `corpus_wikidata.jsonl` | Watch, Score | Wikidata, real public figures (deceased, or with at least 10 sitelinks) whose item states a second surname (P1950: Spain, Mexico, Colombia) or a patronymic (P5056: Russia, the Soviet Union, the Russian Empire, Iceland, Malaysia): each P1559 native-language name and own-language label whose every word aligns with exactly one stated part, via `build_wikidata_corpus.py` (2026-10-16); CC0, credit in `wikidata_LICENSE` | everything the strict alignment drops: short display forms without the second surname, nicknames, and names with an unrecorded part, so tidy names are over-represented; only those eight citizenships; no titles, credentials or suffixes | | `corpus_wikidata_native.jsonl` | Watch, Score | Wikidata, real public figures (deceased, or with at least 10 sitelinks) with a name written in their own script: Hangul, Han and kana, Arabic (Syria, Egypt, Saudi Arabia, Iran, and Urdu in Pakistan and India), Hebrew, Georgian, Armenian, Greek, Cyrillic beyond Russian (Serbia, Bulgaria, Ukraine, Kazakhstan), Bengali, Devanagari (Hindi, Marathi, Nepali) and Thai, 24 strata sampled by country and language. Each person's first P1559 or own-language label whose every word, or every character in unspaced text, aligns with exactly one stated part, via `build_wikidata_native_corpus.py` (2026-10-16; the Hindi stratum grown and the Marathi, Urdu and Nepali strata added 2026-10-23). CC0, credit in `wikidata_native_LICENSE` | everything the strict alignment drops, which in these scripts is most names: Arabic nasab chains (`بن` is no connective in Arabic script), `آل` family names and kunyas (most Arabic items state no family name), Chinese names whose given name has no item (most), Japanese names whose given-name item holds only a kana reading, and names whose parts are labelled only in Latin script (most Devanagari, Bengali, Urdu and Thai items, so those strata are 10–60 names: Hindi's whole population of 4,806 yields 147 in build order, Indian Urdu 12 of 3,000, on 2026-10-23); transcriptions with the 间隔号; Dari entirely; no titles, credentials or suffixes | | `corpus_conventions.jsonl` | Watch, Score | invented names patterned on naming conventions the other corpora barely cover -- no family name, religious names, positional patronymics, honorifics after the name -- from the hand-written table in `conventions.py`, via `build_conventions_corpus.py` (#686) | real names, and every convention nobody has written a row for: about one row per convention, so it shows that a convention moved, not how often | | `corpus_rules.jsonl` | Watch | every example in `docs/design/rules/`, via `build_rules_corpus.py` (#414) | anything the rules doc has no example for | | `corpus_shapes.jsonl` | Watch | shape-tagged rows of `tests/v2/cases.py`, via `build_shapes_corpus.py` (#468) | anything no one has tagged a row for | The jobs are mechanisms.md#NAME-DATA-JOBS: Watch is the snapshot above, Score the agreement records below (`cldr_agreement.py`). Each covers a blind spot of the others. `corpus.jsonl` is reproducible forever from an immutable git ref, while the issue tracker is mutable, so regenerating `corpus_issues.jsonl` is an explicit, reviewable act. The CJK corpus exists because both of those are structurally blind to unspaced CJK (v1's banks never tested it; `build_issues_corpus.py` requires an internal space, which unspaced names never have). The rules corpus watches the names the project wrote down as defining its behavior: the doc tests pin each example against an expectation stored beside it, so a deliberate change edits both and stays green, while the snapshot shows the moved name in the diff. A corpus line is a bare JSON string or an object carrying `name` and, optionally, `tests` (labels, below) or `shape`, an input-shape id from `shapes.py`, which records each shape's notation, the `name_order` it is an input shape FOR, and the oldest release that can honor that order. An entry whose shape declares an order is read under that order on the 2.0 surface alone. Any other key is refused, since a misspelled key would drop the shape and read the name under the default order with nothing saying so. `corpus_shapes.jsonl` holds every distinct (shape, name) pair from the tagged case rows, so a name tagged under two orders is two entries. `--coverage` prints names per shape: ``` uv run python tools/differential/build_shapes_corpus.py uv run python tools/differential/build_shapes_corpus.py --coverage ``` ## Local corpora A local corpus holds real names that are not committed: fetched into the cache by `local_corpora.py`, read by `snapshot.py --local` and by a row of `tools/name_usage.py`, scored by `local_agreement.py` where its records carry their fields, and seen by no reviewer and no CI run (the SURVEY job in mechanisms.md#NAME-DATA-JOBS, and WATCH and SCORE outside CI). What reaches the repository is what a contributor cites from it, counts and shapes written with made-up names. `local_corpora.py` holds the `SOURCES` registry, the cache's files, the views and the command line; each source's fetch is a module of its own (`local_ctgov.py`, `local_fec.py`, `local_tse.py`, `local_tw.py`, `local_ukec.py`, `local_psc.py`, `local_jrc.py`, `local_nepal.py`, `local_orcid.py`, `local_patentsview.py`) whose docstring says what the source is and what its terms allow, built on the retry policy, share gate and downloads in `local_core.py`. A new source is a module and a `SOURCES` row. A source whose records hold a value its own code derives, rather than one the source gave (`jrc-names`' script label), stores a read version in `meta.json`'s detail and refuses on load a corpus read by another (`JRC_READ_VERSION`, bumped by hand with any change to how a record is built; `psc-individuals` does the same for its cached extracts, with `PSC_EXTRACT_VERSION` in each extract's file name): the sha256 checks prove the files are the ones fetched, not that today's code would write them. A constant such as `TSE_MAX_BAD` or `RETRIES` lives in one module, which every reader reads it through, so a test patches it there; no other module `from`-imports it, so a patch set elsewhere raises rather than missing the code (`test_no_module_imports_a_sibling_constant` holds this). ``` uv run python tools/differential/local_corpora.py fetch ctgov-officials uv run python tools/differential/local_corpora.py tally ctgov-officials --top 50 uv run python tools/name_usage.py --no-wikidata MA FNP-C uv run python tools/differential/local_agreement.py psc-individuals ``` To read what a branch moves, check it from the branch against the base checkout you name: ``` uv run python tools/differential/snapshot.py --local ctgov-officials --base ../master-checkout uv run python tools/differential/local_agreement.py psc-individuals --base ../master-checkout ``` `--base` reads the base's readings from the baseline kept for its `nameparser/` (below), recording one in a child process where none is kept, and never reads or writes the shared `snapshot.jsonl` or `agreement.jsonl` beside the corpus. It prints which base it read, by path and digest prefix, and whether the reading was reused or recorded now, and then the head's digest, saying outright when the two packages are byte-identical and so no reading can move. It refuses a base that is the tree being read, or that holds no `nameparser` package, and the child refuses a package imported from anywhere but the base, as `--record --tree` does; a reused baseline was keyed by the base's files on disk, so it needs no import to vouch for it. `--sample N` composes with it. Use it whenever another session may be measuring: the shared snapshot is whoever recorded last, and on 2026-10-25 parallel sessions of #752's phase 2 read each other's re-records as their own moves, the worse case being silent, a sibling's record from a branch holding the same change reading as "no moves". The shared file still works for a session alone, recorded with the base and checked with the branch: ``` uv run python tools/differential/snapshot.py --local ctgov-officials --record --tree ../master-checkout uv run python tools/differential/snapshot.py --local ctgov-officials ``` Any snapshot reading of 5,000 entries or more, committed or local, parses in a worker process per CPU, as do, past the same floor, `local_agreement.py`'s score and the `pairs --agreement`, `groups` and `alike` views (`--jobs N` to change that; all of them share `snapshot.parallel`, whose workers abort the run where one imported another `nameparser`), and a local reading does not read the facade, whose v1 compatibility is the committed corpora's question. Measured 2026-10-23 on a 12-CPU machine: a 20,000-name sample cost about 120 µs a name for the 2.0 parse and its renders, and the facade about as much again; a check of `fec-individuals` (1,893,964 names) took 49s, and of `ctgov-officials` (352,104) 9.6s, where one process reading the facade too would take about 7.5 minutes and 85s, estimated from that sample's per-name cost. A local recording is kept gzipped in `baselines/` beside the snapshot (41 MB for `fec-individuals` against 249 MB plain, 2026-10-23), under a key of the fetch, a hash of every file in the read tree's `nameparser/` as it is on disk (`__pycache__` aside), the interpreter's minor version and the code that writes a row (`snapshot.py`, `corpus_build.py`, `shapes.py`); recording that base again copies it rather than parsing. The hash is taken again after the read, and a package that changed meanwhile keeps no baseline; a kept baseline that cannot be read, or holds another number of rows than the corpus has entries, is deleted and the names are read. The ten most recently recorded or reused baselines are kept per source, whole and sampled alike, so a few `--sample` recordings can push out a whole one: room for four sessions measuring at once with a base and a head each (four were kept until 2026-10-25, and parallel sessions evicted each other's bases), about 420 MB for `fec-individuals`, whose baseline is the largest (41.7 MB). A snapshot recorded before 2026-10-23 may carry facade rows; a check against one sets them aside and says so. `--sample N` reads a fixed 1-in-N sample, chosen by a hash of the name, into `snapshot.sample-N.jsonl`, for iterating on a change; a sample is never compared with a snapshot of the whole corpus or of another sample, and a count quoted from one says it is a sample. `compare.py --corpus` takes the cached `corpus.jsonl` too, against a release. | Source | What it is | Blind to | |---|---|---| | `ctgov-officials` | every overall official of every study on ClinicalTrials.gov, whatever the role (principal investigators, study directors and chairs, and a few sub-investigators and officials with no role), the name as the sponsor's staff typed it into a free-text field: credentials run together (`MBBS,FCPS`), job titles in the name, all-caps and all-lowercase names, typos. Fetched from API v2 (NLM) | names outside clinical research, which is mostly medicine and heavily credentialed; names nobody typed by hand | | `fec-individuals` | the distinct names of every individual contributor in the FEC's bulk file for the 2026 cycle (`indiv26.zip`, downloaded once into the cache and read without unpacking; `fetch --refresh` downloads it again). Mostly a filer's software writing a form's fields in a fixed order: of its 1,893,964 names on 2026-10-18 every one was in capitals and 1,893,961 held a comma, with hand-typed text inside the fields -- 3,306 held a second comma, for a credential run or a suffix in a part of its own (`DOE, JR., JOHN`). The population for the one-case family-comma shape. The FEC's terms (52 U.S.C. 30111(a)(4)) bar using the names to solicit contributions or for any commercial purpose | case, since every name is in capitals; names written in their spoken order, or without a comma; small donors, since a committee need itemize only a donor whose gifts to it pass $200; names outside the US; names in any script but Latin | | `tse-donors` | every individual donor to a candidate in Brazil's 2022 general and 2024 municipal elections (the TSE's candidates' accounts, rows from `Recursos de pessoas físicas`), a record per distinct pair of the name as the campaign filed it (`name`) and the federal tax registry's name for the same donor (`registry`): a typed form with its reference form beside it. Of 854,531 records fetched 2026-10-22 (848,351 distinct filed names), 205,925 differ from their registry name -- an agnomen dropped or abbreviated (`JR` filed for the registry's `JUNIOR` in 1,615, `JÚNIOR` for `JUNIOR` in 975), a word left out. Nearly all in capitals (845,498 filed names), in spoken order with no comma (184 records hold one), and full of particles and agnomens (`DE`, `DA`, `DOS`; `FILHO` ends 10,955 distinct filed names, `JUNIOR` 13,784, `NETO` 8,410). Published by the TSE under CC BY | case, mostly; the family-comma shape; credentials and titles, which a donation form has no place for; donors to parties rather than candidates; names outside Brazil; names in any script but Latin | | `tw-donors` | the distinct names of every individual donor in every campaign finance report Taiwan's Control Yuan publishes on its political donation platform (ardata.cy.gov.tw, whose zips data.gov.tw also lists): 454 reports of 64 elections on 2026-10-22, presidential, legislative and local: every report from 2018's elections on, and the residual reports of 2014's and 2016's. Each report's zip is cached under the name the platform lists it by, which carries the date it was built, so a refresh downloads only the reports published or rebuilt since. Names as a candidate's staff typed them into a filing, published in full where the ID, address and phone beside them are masked. Of the 308,667 names dated 2026-10-05, 296,820 were three Han characters, 5,449 four and 5,260 two; the population for unspaced Han names written family first, and for the married double surname (冠夫姓) a four-character name often is. Under the Open Government Data License, version 1.0, with the platform's own bar on using the data for profit, credit investigation or another improper purpose | names in spoken order; Latin script (80 of the names dated 2026-10-05 held a Latin letter); the comma (3 of them held a fullwidth one, none an ASCII one); case; names outside Taiwan; donors whose names the report does not publish, such as anonymous gifts; Japanese kanji names, which the Han script cannot tell from Chinese | | `ukec-individuals` | the distinct names of the individual donors in the UK Electoral Commission's register of donations (to parties, their members, third parties, permitted participants and regulated donees), from the search's CSV export under the site's own default filter, fetched whole on every fetch (9.5 MB on 2026-10-22, accepted dates from 2001 to 2026). The default leaves out donations reported only before a poll (`prePoll=false`: 374 rows and 21 names on 2026-10-22). A name as the donee's return gave it, mostly in its spoken order and mixed case, with the donor's title: of its 15,214 names on 2026-10-22, `parse()` read a title in 8,219 (its `title` field non-empty) (`Mr`, `Ms`, `Mrs`, `Lord`, `Dr`, `Sir`; 32 a run of two or more, `The Rt Hon`) and a suffix in 320 (`MP`, `OBE`, `CBE`, `QC`). The population for titled British names. The Commission publishes its documents under the Open Government Licence v3.0, but the register states no reuse terms of its own, which the credit line says | the family-comma shape (2 names hold a comma) and case (9 are in capitals); small donors, since a donation is reported only above a threshold that depends on the donee; names outside the UK; names in any script but Latin | | `psc-individuals` | the people with significant control of UK companies, and the beneficial owners of overseas entities, from Companies House's daily snapshot (33 zipped parts of JSON lines, 2.44 GB on 2026-10-22): each person's display name, the four boxes the filer typed it into (title, forename, middle name, surname) and the stated nationality, nothing else. A record is a distinct filed name, kept one in eight by a hash of the record (1,040,708 of 8,322,939 on 2026-10-22), a size the machinery can hold in memory. A local corpus whose names carry their fields, scored by `local_agreement.py`, as `orcid-researchers` is | names nobody filed into boxes, so almost no comma (838 of 1,040,708 on 2026-10-22), no credential run and almost no suffix; a display name that is not its boxes joined, which the fetch drops (2 of 14,025,738 individuals on 2026-10-22) | | `orcid-researchers` | the public names of ORCID iD holders, researchers worldwide, from ORCID's Public Data File 2025 (`ORCID_2025_10_summaries.tar.gz`, 46.3 GB, data as of 2025-10-17), read as a stream and never stored: each line of its extract is the two boxes the holder typed their own name into (given names, family name), the credit name they publish under where public, other names, and a country, taken from their public address, else the first employment that names one, else the first education that does; a corpus record keeps the boxes, the credit name and the country. The fetch caches an extract of every summary with a public name (24,990,909 of 26,078,951 on 2026-10-27, 1,086,356 private and 1,686 empty), 369 MB, and the corpus keeps at most `ORCID_CAP` (20,000) records a country, the lowest by a hash of the record, so a change to the cap re-reads the extract rather than the 46 GB file: 1,831,533 records: 250 countries, 65 of them at the cap, and the records with no country (14,293,215 of the extract, 57%) as one more group of 20,000, counted in that total. A record's name is its boxes joined, given first; the credit name is a second corpus key (125,960 kept records hold one). Scored by `local_agreement.py` by country, the boxes as the holder split them. CC0, with ORCID asking for credit and respect for the privacy of the people in it | frequency, since the cap flattens every large country to 20,000; people outside research; name order, which ORCID does not record, so a holder who typed a family-first name into the given box reads as a disagreement; names with no family name and names whose family name is private, which look alike (1,308,200 of the extract hold no family name); titles, credentials and suffixes, which the form has no box for | | `patentsview-inventors` | the inventor of every patent the USPTO granted from 1976 to 2025, from PatentsView's granted-patent tables on the USPTO Open Data Portal (release to 2025-12-31): each inventor's name in the filing's two boxes (first name, last name) and the country of the inventor's address. A record is a distinct name in a country, with the number of people who filed under it (`people`, counted by PatentsView's disambiguated inventor id, so a prolific inventor weighs once): 24,037,380 inventor rows, one per inventor per patent, held 5,319,206 such names on 2026-10-28, 99.3% of rows with a country. The extract keeps every name with its count, the corpus at most 20,000 names a country (657,991 records: 202 countries, 23 of them at the cap, and the records with no country as one more group of 20,000, counted in that total). Split by a professional filer, the applicant's attorney, rather than the bearer: `parse()` agreed with 94.7% of the records, 99.8% in Japan, 99.5% in China and 99.0% in the US, so it reads beside `orcid-researchers` as how a name is written against how its bearer types it. A comma stands in the last box of 92,503 of the extract's names, against 129 in the first, before suffixes and legal roles typed after a deceased inventor's name: the text after it is exactly `Jr.` in 55,459 names, `III` in 14,227, `deceased` in 4,635, `heir` in 514, `legal representative` in 393, `executrix` in 328 and `administrator` in 288 (2026-10-28). CC BY 4.0, credit PatentsView; the portal needs an API key | names in any script but Latin, since a filing is romanized; titles and credentials, which a filing has no place for; names outside patenting, which is mostly engineering and science, and its countries' shares are of patents filed in the US, not of their populations | | `jrc-names` | the person entities of the EU Joint Research Centre's JRC-Names, each with the spellings of its name harvested from news text in many languages and scripts: a record is one distinct spelling, with its entity and its script. The file (`entities.gzip`, 14.8 MB) is pinned by sha256 and dated by its Last-Modified, 2024-03-15. Of its 1,317,586 spellings kept, 1,203,634 are Latin, 66,673 Cyrillic, 17,074 Arabic (7,833 persons, 5,347 of them spelled in Latin too), 13,039 Katakana and 8,016 Han, and 218,822 persons have two or more spellings, 25,415 of them in two scripts or more (a spelling whose letters are in two scripts, or none, not counted as one). The population for spelling and transliteration variants of one name, read by the `groups` view, and for Arabic-script names. Licensed under the JRC-Names EULA, personal and non-transferable, with no redistribution or derivative works, so its views' output and its snapshot stay in the cache like every local corpus's, and here the licence, not only the names being real people's, is why | titles and credentials, since the names are harvested from running text (`parse()` read a title in 3,011 of its 1,315,051 names and a suffix in 1,940, 2026-10-24); the ASCII comma, which none holds (1,510 Arabic spellings hold an Arabic comma, U+060C, which the parser reads as one); the Indic scripts (Devanagari 827 spellings, Bengali 419, the others fewer); people the news did not name | | `nepal-candidates` | every candidate in Nepal's local elections of 2079 BS (2022), from the Election Commission's results site (result.election.gov.np), one file per local body: a record per distinct pair of the Commission's romanization (`name`, the one `parse()` reads) and the name in Devanagari as filed (`devanagari`). The corpus and the word index hold both, so a snapshot and a `name_usage.py` lookup read both scripts; `tally`, which counts what follows a comma, reads the romanized name. Of 126,981 pairs fetched 2026-10-24 (152,960 candidates in 753 files, 26 of whose romanizations were a placeholder such as `- - -` and are dropped), the romanized names carry surnames written as initials (`B.K.`, `K.C.`, `G.C.`), honorific words that are titles in front (`Guru`; `Shekh` until #752's phase 2j made it a title only in a title phrase) and `Md.`, Muhammad's abbreviation, which its period makes a title by shape; the Devanagari ones carry vowel signs and the virama, combining marks the letter-shape tests do not count as letters (decisions/nfd-shapes), and the joiners ZWJ and ZWNJ. The population for romanized South Asian names and for Devanagari. The site states no licence, which the credit line says | case and the comma, since the Commission's romanization is one consistent hand; names a person typed themselves; credentials and generational words; the parliament and provincial candidates, whose files carry Devanagari only; names outside Nepal | A live source has no fixed snapshot to pin, so the cached copy is the pin: `meta.json` holds the source's data date and the sha256 of `raw.jsonl` and `corpus.jsonl`, checked on every load, and every report, the `name_usage.py` row included, carries the source and that date. Quote a count with the date, and expect a later fetch to differ. A local snapshot records which fetch it was read from, and `snapshot.py --local` refuses to compare it with a corpus fetched since, which would report the names the source gained and lost as moves: after a `fetch --refresh`, record again from the base checkout. ClinicalTrials.gov's terms ask anyone redistributing its data to credit it, show NLM's processing date and say what was changed; keeping the names in the cache and citing counts with that header meets them. The FEC's bar on soliciting and commercial use is in the credit line of every `fec-individuals` report, so a count quoted from it carries the bar with it. A record there is a distinct name, not a contribution or a contributor: one donor gives many times (34,264,742 rows held 1,893,964 names in the file dated 2026-10-18), and counting contributions would weight a shape by how often its bearers give. `tools/name_usage.py` looks a word up in each corpus's word index, cached as `wordindex.json` beside it on first use and keyed to the records' sha256, to the code that cuts and folds a word and to the record keys it reads (`Source.corpus_keys`; a record whose two names write one word counts once), so a refetch, a change to the fold or a change to which of a record's names are read rebuilds it; a single-word lookup it answers parses neither the records nor the names. Measured 2026-10-22 with both corpora fetched, a single-word `name_usage.py --no-wikidata` run took 2.5s with their indexes cached (12.5s before the cache, 14.8s on the run that builds it). A spaced word (`Ph. D.`) still scans every name, which took 8.6s, nearly all of it the FEC corpus's. For `orcid-researchers` and `patentsview-inventors`, whose extracts keep every record's boxes and country uncapped, `name_usage.py --boxes` adds a row by box and country, read from a second index, `boxindex.json.gz` beside the corpus (`cached_box_index`): per word and country the people whose given box or family box writes it, each country shown as a share of its people, so a word can be weighed in the country a language pack is for (`--country CC`). It is keyed to the extract's sha256, taken from the bytes as they are read, and to a fingerprint of the reader and the fold, so a refetch rebuilds it. Building it took 2 min 19 s for ORCID's 24,990,909 records (3,780,309 words in 6,660,756 word-and-country cells, 41 MB cached) on 2026-10-28. With both indexes cached a lookup took 10.4 s with `--boxes` against 5.3 s without, the difference nearly all reading the two indexes (the 369 MB extract hashes in a fraction of a second once the system has it cached), which is why the rows are asked for rather than always built; an extract that cannot be read fails its own row, naming the refresh. `fetch --refresh` re-downloads the FEC's zip rather than re-reading the cached one, replacing it only once the new one is whole, and a corpus fetched for another cycle than `FEC_CYCLE` is refused on load. The `fec-individuals` fetch refuses a file whose lines stop matching the 21 columns it reads, or whose names stop holding a comma, rather than read it as a smaller or a different corpus. A `ukec-individuals` record is a distinct name too, not a donation or a `DonorId`: one name often holds several IDs, which seem to come with reports rather than with people (15,214 names held 24,473 on 2026-10-22, the IDs counted in `meta.json`'s detail; 2,663 names held several at one `RegulatedEntityId` alone, counted from the export and not kept, though some of those may be two people of one name), so a count of IDs would weight a name by how often it was reported. Its fetch reads the register's total from the JSON search under the same filter before and after the export, and refuses an export holding another number of rows, or a register that changed in between: the export ignores `rows=`, so the total is what tells a cut or capped export from the whole register. It refuses an export whose last row is cut short (Python's `csv` returns a body cut inside a row as a short last row, which the row count would otherwise take for a whole one), and a header other than the 29 columns `UKEC_COLUMNS` names, and counts into `meta.json`'s detail the rows it skips: rows not 29 fields, rows from a donor that is not an individual (the filter asks the server to leave those out), and individuals with no name. Past `UKEC_MAX_BAD` of either of the first two, or `UKEC_MAX_NAMELESS` of the third (4 of 42,355 rows were nameless on 2026-10-22), it refuses the export. The data date is when the first search answered, in UTC: the register states no date of its own, so the counts above, dated by the day they were fetched (2026-10-22), carry the data date 2026-10-23T03:13:19Z in their header. `tw-donors` keeps one zip per report under `reports/`, whole, its masked ID, address and phone columns included, as the FEC's zip is kept: the platform's download answers a report that does not exist and a fault alike, with a 500 and an incident number, so the fetch reads which reports exist from the platform's listing and refuses a listed report it cannot download. It reads each file's columns by name, since a 2020 report lacks a column the newest ones write, and refuses a column it does not know, a category it does not know, a row whose serial column names another report (the zip is not the one asked for), a listing that changes during the fetch, and a report past `TW_MAX_REPORT_BAD` of whose rows it reads past. It reads back the rows a stray quote took into one field (169 rows in 12 records of three reports on 2026-10-22, 8 of them two rows joined by two stray quotes), counts donor cells that hold no name (匿名, a dash) or a masked one, and caches a downloaded zip only once its rows are read, so a refused zip is asked for again. A zip the platform rebuilt under the name it had is not seen: delete `reports/` to download every report again. A layout check written from one surveyed file meets every other file only at fetch time: `tw-donors`' first three fetches were each refused partway, by a report with no income writing one row of dashes, by a 2020 report with 24 columns where the surveyed one had 25, and by a row with no category. Each refusal cost every download again until the source cached its zips, so a new source that downloads many files caches each one before its checks run. `tse-donors` caches nothing but the corpus: each election's national member (`receitas_candidatos_<year>_BRASIL.csv`) is read from the TSE's CDN in HTTP Range requests, 209 MB for the two elections where their zips are 1.82 GB (2026-10-22), because the TSE regenerates the zips every few days and a cached one would be stale within the week. Every range request carries `If-Range` with the ETag the HEAD gave if it is strong, else its `Last-Modified`, and every part must state the `Content-Range` asked for, so a zip replaced during the read is refused rather than read across two versions; a HEAD that states no length, neither a strong ETag nor a `Last-Modified`, or `Accept-Ranges` other than `bytes` is refused before the read. A fetch took 69s on 2026-10-22. It refuses a zip without the national member, a header that is not 60 columns or lacks its three columns, a `DS_ORIGEM_RECEITA` value it does not know (a renamed category, or the file no longer in Latin-1), malformed rows or lines folded into a field past `TSE_MAX_BAD`, an election with too few rows from individuals (`TSE_MIN_INDIVIDUALS`: a member cut to its header, or the donations moved under another category) too many without a name or registry name (`TSE_MAX_MISSING`, counting a placeholder such as `#NULO` or `***` as none: a masked column) or with a number in one (`TSE_MAX_NUMBERED`: a numeric column moved under a name), too few distinct filed or registry names (`TSE_MIN_DISTINCT`: a column masked with one word), and a corpus fetched for other elections than `TSE_ELECTIONS`. The CPF in each row is unmasked in the file, and like every other column it is read past, never stored or printed; a pair either of whose names holds more than `TSE_MAX_DIGITS` digits is dropped and counted, since campaigns sometimes typed a number, a CPF among them, into the name field. A record is a pair, so the counts in its reports (`tally`, the `name_usage.py` row) are of pairs, and a filed name standing beside two registry names counts twice. `psc-individuals` caches an extract of each of the snapshot's 33 parts rather than the zips: 168 MB against 2.44 GB on 2026-10-22, each extract holding only the records and fields the corpus keeps. A download that fails costs one part, and running the fetch again resumes, keeping the parts already read for the same day; the set replaces the cached one only once every part is read, and a part that disappears mid-fetch (the files are overwritten each morning) is refused naming the refresh. The fetch refuses a snapshot where more than one individual in 10,000 has no `name_elements`, or a display name that is not its four boxes joined by spaces, or where fewer than half of the lines are individuals (87% were on 2026-10-22; a kind Companies House renamed would read as another kind), and counts what it skips into `meta.json`'s detail. It reads the cached extracts only as one whole snapshot of one date, and an extract written by another version of the extraction (its file name carries the version) is neither resumed from nor read. Companies House's data-products guidance says it imposes no rules on how the register is used and leaves data protection to the user; the dataset's data.gov.uk entry gives its licence as "TBD", and gov.uk's footer licenses its own page text under the Open Government Licence. The credit line says the names are personal data. `orcid-researchers` streams the summaries file in HTTP Range requests through `gzip` and `tarfile`'s stream mode, and keeps nothing of it but the extract: a dropped connection reopens at the byte it reached, with `ORCID_RETRIES` attempts at each read (243 s of waits, about four minutes of outage before each attempt's own 60 s timeout, where the other sources' few attempts suit a small request), every part must be a 206 with the `Content-Range` asked for and the `Last-Modified` the first answer gave (a first answer stating none is refused), and once the tar ends the rest of the gzip is read so its CRC is checked, then the whole body's length and MD5 against the figshare listing's (`ORCID_SIZE`, `ORCID_MD5`). The extract replaces the cached one only after that check and after the fetch has read it back through every check a load makes, so a fetch refused at the end keeps the last good one; reading it back took 2 min 23 s on 2026-10-27. The first draft checked its shares only on load, after the rename, where a file past them would have replaced a good extract with one every load refused (found in review, 2026-10-27): a check a fetch's output must pass belongs before the rename, not only on the read. A fetch took 78 minutes on 2026-10-27, about 10 MB/s, with the XML parsed by a worker per CPU. Its summaries are parsed with `xml.etree` and a summary declaring a DTD or an entity is refused and counted, rather than adding a dependency for a safer parser; the fetch refuses past `ORCID_MAX_BAD` of summaries that do not parse or are refused that way, of tar members that are not summaries, or of countries that are not two capital letters (none of the four on 2026-10-27), and below the `ORCID_MIN_` shares of summaries giving a record, of records holding a family name and of records holding a country (95.8%, 94.8% and 42.8% on 2026-10-27), which catch a release whose layout moved under the reader once the pin moves. The count of summaries is pinned too (`ORCID_SUMMARIES`): `tarfile` ends a stream at a damaged header without raising, so a tar read only in part would pass its MD5. The extract is cut at `"\n"` only (`newline="\n"`), as `corpus_build.jsonl_lines` cuts, and streamed rather than read whole. `tarfile` keeps every member it has read in `TarFile.members`, even reading a stream, so the reader clears it after each one: before it did, the fetch held 2.3 GB a GiB into the file. An extract written by another version of the extraction is not read (`ORCID_EXTRACT_VERSION`, in its file name). Of the extract's records, the country came from the address in 2,842,439, an employment in 7,025,517 and an education in 829,738 (2026-10-27): a country is where a holder works more often than where they come from, so read a country's agreement as its institutions' population, not its nationals'. `patentsview-inventors` is the one source whose fetch needs a credential: the USPTO Open Data Portal has needed a sign-in for every download since 2026-06-18, and its API answers a request without an API key with a 401 (2026-10-28); a key needs a USPTO.gov account verified through ID.me (data.uspto.gov, My ODP). The fetch reads it from `~/.config/nameparser-tools/uspto-odp-key` (`PV_KEY`), sends it as `X-API-KEY` to https uspto.gov hosts only, and drops it on a redirect anywhere else; a fetch without it is refused naming how to get one, and no test needs it. The portal lists each file's size and date but no checksum, so the release is pinned by those and by the sha256 of the bytes downloaded on 2026-10-28 (`PV_FILES`), and by the rows its inventor tables hold (`PV_ROWS`): every field of the TSVs is quoted, so a stray quote cannot fold rows together, and a count short of the pin is a table read in part. The raw inventor table gives the names as filed and the disambiguated one, row for row the same inventors, the inventor id and location; the reader takes them in step and refuses a pair of rows naming different inventor slots rather than join them. The fetch refuses past `PV_MAX_BAD` of rows not their header's width and below the `PV_MIN_` shares of rows naming a country and filling both boxes, and reads the extract back through every check a load makes before it replaces the cached one; the zips are deleted once it is built, and a run that fails keeps those already whole, so the next downloads only the rest. A download dropped mid-file is tried again from the start under the shared retry policy, and a key the portal refuses (401 or 403) is reported as the key's, naming how to replace it. A fetch took about five minutes on 2026-10-28, the three zips (1.75 GB) downloading in under one; the extract is 60 MB. The disambiguated table's attributed gender is read past. `nepal-candidates` reads each local body's file through the site's own JSON handler, which asks for the site's double-submit check: the `CsrfToken` cookie a GET of the site sets, echoed in an `X-CSRF-Token` header, with the `Referer` its pages send. The site refuses a User-Agent holding `python` or `curl` with a 403 (2026-10-24), so this source sends a browser's form naming the tool (`NEPAL_USER_AGENT`) where the others send the repository's URL. A 403 mid-fetch is what a lapsed token gets, and renews the session once before it is raised; a 404 counts the file as missing, its id kept in `meta.json`'s detail, and with the empty files (a list of no candidates, as a site that blanked its files would answer) past `NEPAL_MAX_MISSING` of the `NEPAL_BODIES` bodies refuses the fetch, the empty ones' ids kept too. The election is over and its files final, so the candidates are pinned as the bodies are: a fetch reading every file must read exactly `NEPAL_CANDIDATES`, so a site that trimmed its files (to the winners, or to one post) is refused however well formed what is left; with files missing or empty the pin cannot be exact, and a shortfall of up to `NEPAL_MAX_SHORT` of it is accepted, so a few candidates trimmed elsewhere are not seen then. Every listed file answered on 2026-10-24: the 18 a survey on 2026-10-23 saw refused, among them ids 5551 to 5554, answered when asked again. A file is JSON, so a body cut short does not parse, and is retried as one cut in transit rather than read as a body with fewer candidates. The fetch refuses a lookup listing other than `NEPAL_BODIES` bodies (the election is over, so its files are final), a file that is not a list of candidates, too many candidates with no romanized or no Devanagari name (`NEPAL_MAX_NAMELESS`) or with a name in the wrong script (`NEPAL_MAX_WRONG_SCRIPT`: two columns swapped, or a romanization dropped), and too few distinct names in either column (`NEPAL_MIN_DISTINCT`). A record is a distinct pair, as `tse-donors`' are, not a candidate row: a body lists a candidate once per post, and a common name recurs across bodies. The `pairs` view refuses it, its records carrying no reference name: that view compares the letters of two spellings, and a romanization shares none with its Devanagari, so the Devanagari is kept under a key of its own. `meta.json` records the keys the corpus was built from (`corpus_keys`), and a corpus written under other keys is refused on load. A fetch took 28 minutes on 2026-10-24 at `NEPAL_DELAY` (0.3 s) a request. `local_agreement.py` does the SCORE job for a local corpus. It parses each filed display name and compares the parser's roles with the boxes the source states (title, given, middle and family for `psc-individuals`; given and middle joined against the given-names box, then family, for `orcid-researchers`), folding case, periods, commas and spacing. A suffix the parser peels is compared inside the surname box: the display name joins the surname last, so a trailing suffix was typed into it whenever it is filled, with or without a comma (`Smith, Jr.`); the fold read the comma as a miss until 2026-10-28, which moved 83 `psc-individuals` records and 561 `orcid-researchers` records to agreeing and none away. Where a source has a title box, a title the parser reads otherwise, holding a word the vocabulary lists as neither a title nor a connective (a job typed as a title), is set aside as filer error rather than scored, and the report lists the commonest such titles as leads for the vocabulary. The report gives agreement overall and by the group the source states (nationality, country), misses per field, and the commonest disagreement shapes, as counts, word counts and the commonest set-aside titles; `--list N` prints moved records, names and all, for reading locally. Its record, `agreement.jsonl` beside the corpus, holds every record that did not wholly agree, so a re-run reports what moved. As with the local snapshot, check it from the branch against a base you name (`--base <checkout>`, whose record is kept under `agreements/` beside the corpus, keyed by the fetch, the base's `nameparser/` files, the interpreter's minor version and the scorer, the ten most recent kept; the scorer's fingerprint reads only its own source's `FIELDED` entry, so adding a source leaves the others' records standing, which the whole table, hashed until `patentsview-inventors` was added on 2026-10-28, did not), or, alone, record the shared one from the base checkout (`--record --tree <checkout>`) and run it from the branch; it refuses a record from another fetch, or one written by another version of the scorer, whose own change would otherwise read as the parser's moves. No CI run sees it. `pairs` reads a paired source (`tse-donors`, whose records carry the registry's name beside the filed one) as two spellings of one person, and sorts each pair by how the filed name departs from the reference: the first of its shapes that fits, from case, accents (case unchanged) and case and accents together, through words left out, shortened or misspelled, to few words in common. A word left out or added counts once per pair, and is listed only when ten pairs share it. `--agreement` also parses both spellings of every pair with a reference name that differs, and counts the pairs whose two readings end the family name in the same word (two readings with no family name share none). It lists the accents-only pairs read with two surnames by the two spellings' last words, the same ten-pair floor applied. ``` uv run python tools/differential/local_corpora.py pairs tse-donors --agreement ``` On the fetch of 2026-10-22, 205,925 of `tse-donors`' 854,531 pairs differ. The commonest shapes: words left out (22.0%: `DE` left out of 13,412 pairs, `DA` 5,906, `DOS` 3,033, then second surnames, `SILVA` 2,869), accents only (21.1%; 255 of them SÁ / SA pairs read with two surnames until #857), words shortened (15.8%), words misspelled (14.9%) and words left out and shortened (11.8%). Read the agreement per shape, since a shape can change the surname legitimately: a pair that leaves out the last surname ends the family in another word, which is likely most of why only 44% of words left out agree (not measured apart from the other causes). Accents only cannot change the surname, so its 1,304 of 43,552 pairs that disagree are the parser reading one name two ways: by their last words, `JÚNIOR` against `JUNIOR` in 866 (#843) and `SÁ` against `SA` in 255, where `MARIA DE SA` reads `SA` as a credential and `MARIA DE SÁ` as the surname. The run took 39s in one process (15.8s in workers, 2026-10-27). `jrc-names` reads a gzip downloaded once into the cache, and refuses one whose sha256 is not `JRC_SHA256`: the JRC says the resource is updated daily, but the file served on 2026-10-24 was last modified on 2024-03-15, so a new file is measured and the pin moved by hand rather than read under constants measured on another. A download that is not the pinned file replaces nothing: it is kept beside the cached one as `entities.gzip.new`, to be measured, and adopted without another download once the pin moves to it, since the JRC serves only its current file and the pinned one could not be fetched again. For the same reason `fetch --refresh` downloads only where the cached file is not the pinned one; otherwise it reads the cached file again. `meta.json` records the pin, `JRC_READ_VERSION` and the Unicode version `script()` read, and a corpus fetched under another of any is refused on load, so a change to how a spelling is read or its script labelled is not counted under the old records. It keeps the person rows (`P`, 1,338,463 of 1,445,013 lines) and reads past the organisations (`O`). It drops every row the JRC marks with a language (`ru`, `ar`, `sl`, `et`, `pl` and others, 20,819 of the person rows), counted by language in `meta.json`'s detail, on the JRC's marking alone. Where measured, a marked row is a morphological variant of a spelling the JRC leaves unmarked (`u`): an inflected form, or an Arabic spelling with و, ل or ال glued on. Every marked row's entity held an unmarked spelling, and 18,594 of the 20,819 had as many words as one and differed from it only in endings or in glued fronts of three letters or fewer (the rule is stated exactly at `JRC_UNMARKED` in `local_jrc.py`); the other 2,225, 1,680 of them Arabic, fit neither as stated and were not measured further. The unmarked spellings still hold inflected forms the JRC did not mark (by the same rule, 22,732 of the 66,673 distinct unmarked Cyrillic spellings are another Cyrillic spelling of the same person), which are kept. A name's `+` and whitespace are read as single spaces, and an id is kept as written. A line that is not four well-formed columns (the file's first line, `#`, is one) is counted, and past `JRC_MAX_BAD` refuses the file, as do a gzip cut short, a file ending inside a line, person rows under `JRC_MIN_PERSONS` of the lines (a type renamed), more marked rows than `JRC_MAX_MARKED` (a file that marks every spelling), more persons left with only marked spellings than `JRC_MAX_LOST` (none were), and more repeated spellings than `JRC_MAX_REPEATED` (58 were). 99 spellings hold a replacement character (U+FFFD), damage in the source; they are kept and counted. The licence bars redistributing the resource or anything derived from it, so its snapshot, its views' output and every file derived from it stay in the cache, and what is cited from it is counts and shapes written with made-up names. A snapshot of it (`snapshot.py --local jrc-names`) recorded 1,315,051 names in 30s on 12 CPUs (2026-10-24, 128 MB). `groups` reads a source whose records carry an entity and a script (`jrc-names`) as several spellings of one person. It parses every spelling of each person with two or more and compares the readings' structure: whether a title, a suffix, a given name and a family name are read, how many words the family is, and where it stands among the given, middle and family words (one run at the front or the end, all of them, none, or otherwise, which 1 of the 1,315,051 distinct spellings is). Words are not compared, so spellings in two scripts can be; but where the family stands moves with a script's order, so a script read family first (Hangul, spaced Han) differs from one read given first in that field by convention, not by error. It counts PERSONS, not pairs, so a person spelled 600 ways weighs what one spelled twice does. A pair of scripts is one row, and a spelling whose letters are in two scripts keys one as `mixed`: within one script (`Latin / Latin`) a field differs where the person's spellings in it read it more than one way, and they read alike where all read one way; across two (`Cyrillic / Latin`) a field differs where no spelling in one reads it as any in the other does, so two Latin spellings that differ between themselves are not counted against the Cyrillic one, and they read alike where some spelling in one reads, in every field, as some spelling in the other. A person across two scripts can be neither: every field has a value in common, yet no one spelling matches; the report counts those as `no one field` (0.4% of Han / Latin, less elsewhere), so each pair's persons are alike, apart in a field, or there. It then lists the title and suffix words of the persons some of whose spellings read one and others none (two spellings with two different titles and no untitled one add nothing), by the persons sharing a word, ten at least: the lead is a word the vocabulary reads as a title or suffix that is a name in some spelling, or a title spelled in a way it does not know. ``` uv run python tools/differential/local_corpora.py groups jrc-names ``` On the fetch of 2026-10-24 (data dated 2024-03-15), 93.7% of the 218,822 persons with two or more spellings read alike in every spelling, and the run took 50s (11.8s in workers, 2026-10-27). Within a script the disagreement is mostly the writing, not the parse. Katakana / Katakana reads alike for 0.2% of its 4,289 persons because the JRC writes a transliterated name three ways: unspaced and undivided, which the parser cannot divide and reads as a given name alone (4,347 of the 13,039 Katakana spellings), divided by `・` with no space (4,337), and spaced (4,355), the last two read given then family. Han / Han, 2.2% of 2,420, holds unspaced Han, read as one family word, beside spaced Han, read family first, and Han divided by `·`, read in its written order. Arabic / Arabic reads alike for 67.8% of 3,329 persons, and the Arabic comma is most of why: each of the 736 persons with a spelling holding one differs, of the 1,072 that differ, since the comma moves the family to the front. Across scripts read in the same order most persons read alike (Cyrillic / Latin 99.3% of 20,837, Arabic / Latin 98.2% of 5,347, Greek / Latin 98.6% of 915); Hangul against a script read given first reads alike for 3% or fewer in every such row `--top 40` lists (Hangul / Latin 2.0% of 1,247), its family standing first, while Han / Hangul, both read family first, is 87.1% of 1,002. The title words read in some spellings only start `sheikh` 50, `bard` 33, `shaikh` 27, `sir` 23, `executive` 22 and include `mahdi` 17, a common given name; the suffix words `jr` 62, `sa` 38, `iii` 27, `ii` 27. `alike` reads the spelling views as one flag per person, for a source whose records pair spellings (`ALIKE_SOURCES`: `jrc-names`' persons, `nepal-candidates`' romanized and Devanagari pairs, `tse-donors`' filed and registry pairs): whether the person's spellings read alike, as `groups` compares them for a person, the two readings' structure for a Nepali pair, and one shared family last word, as `pairs --agreement` counts it, for a filed pair that differs. With `--base` it prints how many persons (`jrc-names`) or pairs now read alike and how many now apart, the counts phase 2j quoted for each batch. It counts flips only: a person whose spellings all read differently and still alike is no move here, though the local snapshot shows it. The base's flags are kept under `alike/` beside the corpus, keyed like the baselines (the fetch, the base's `nameparser/` files, the interpreter's minor version, and the view's code, `alike_fingerprint`), read in a child process where none is kept, and published under their key only once whole and once the base's digest, taken again, is unchanged. A key holds no name: a person is its entity or its record's place in `raw.jsonl`. ``` uv run python tools/differential/local_corpora.py alike jrc-names --base ../master-checkout ``` Measured 2026-10-27 on a 12-CPU machine (load average 3 to 15 from other sessions), each the same output before and after: `local_agreement.py psc-individuals --record` took 88.9s in one process and 15.4s with a worker per CPU; `groups jrc-names` 55.6s and 11.8s; `pairs tse-donors --agreement` 52.1s with `--jobs 1` and 15.8s. A kept `alike` base for `nepal-candidates` was read in 6.8s, and reused in 3.4s. ## Measuring a change `measure.py --base <checkout>` runs every corpus check of a change, one after another, each but `cldr_agreement.py` parallel inside itself, and writes one summary: ``` uv run python tools/differential/measure.py --base ../master-checkout ``` The steps: the committed snapshot (`snapshot.py`, the check `tests/v2/test_snapshot.py` makes), `cldr_agreement.py`, `snapshot.py --local --base` for every fetched local corpus, `local_agreement.py SOURCE --base` for every fetched source whose records carry their fields (`psc-individuals`, `orcid-researchers`, `patentsview-inventors`), and `local_corpora.py alike --base` for every fetched source that pairs spellings. Only the local steps read `--base`: the first two compare the tree with the records committed on its branch, so on a branch that has re-recorded `snapshot.jsonl` the committed snapshot says no moves and the moves are in the PR's diff of that file, and the summary labels each step with what it compared against. A local corpus that is not fetched gets no step, the summary names it, and a run in which no step read `--base` exits 1. Each step's whole output goes to a file of its own under `--out`, by default a folder named for the time under the cache's `measure/`, outside the repository, since a local snapshot lists its moves by name. The summary, `summary.txt` beside them and printed, gives each step's status (no moves, moves, or failed with its exit code), its time, and its unindented lines (the first few and the last, where the verdicts are, up to `SUMMARY_LINES`, and any line saying part of a check was not measured), never the indented ones where the tools list names; a crash's traceback is indented below a line naming only its type, since its message may hold a name. A step that fails makes the run exit 1; one that finds moves does not. Each tool refuses with `SystemExit("message")`, which exits 1 as a move does, so measure runs every step through its own `--step` wrapper, which turns a message exit or an uncaught exception into exit 2: a refused base or a damaged corpus is reported as a failure, never as moves. For a vocabulary word whose reading acts on a name position, `tools/vocab/word_report.py` gives the per-word view before and alongside this: the word in a grid of shapes, its incidence in every fetched local corpus, and its `name_usage.py` rows (`nameparser/vocab/README.md`, "Adding a word"). ## Corpus provenance `corpus.jsonl` is checked in as a test fixture. It was built by `build_corpus.py`'s AST walk over every top-level `tests/test_*.py` file (the v1-style test banks; `tests/v2/` is a separate 2.0-only harness and is deliberately not scanned), reading each file via `git show <ref>:<path>` rather than the working tree: ``` uv run python tools/differential/build_corpus.py --ref 2d5d8c2 > tools/differential/corpus.jsonl ``` `2d5d8c2` ("Trim constant-factor waste on the tokenize hot path") is the last commit before M12 reconciled/edited the v1 test banks against the 2.0 facade -- M12 changed some expectations in place and deleted bucket-A tests outright, which would have shrunk and skewed the corpus. Reading history at an old ref via `git show` is a read-only operation on this worktree's own log; it does not check out, stash, or otherwise mutate anything. The AST extraction over-collects on purpose (string literals passed as `HumanName(...)`'s first argument, plus string members of list/dict/ tuple banks that contain a space) -- more candidate strings is more coverage, and the corpus is deduplicated. Obvious non-names (strings containing `{`, `@`, or a backslash -- format placeholders, decorator/ email-shaped fixtures, escape sequences) are dropped. Each line is `{"name": ..., "tests": [...]}`: `tests` is the sorted labels the name appeared under at the pinned ref -- the shape context (`test_title_with_conjunction` and kin) the original bare-string scrape kept the string but threw away. A `HumanName(...)` call is labelled by its nearest enclosing test method, falling back to the source filename (e.g. `test_titles.py`) for a call at module scope; a list/dict/tuple bank is labelled by its variable name, prefixed `bank:` so it reads apart from a test method at a glance (`bank:<unnamed>` when the assignment target isn't a plain name). Labels merge across files: two files sharing a method name (e.g. two `test_basic_parsing`s) produce one merged label set on any name both contribute, which is accepted -- the merged labels still describe the same string. Regenerate the corpus only (a) at the same ref, as a format-only enrichment, with the name set proven identical to the previous file by set comparison, or (b) if the v1 test banks are revisited again at a still-earlier point in history; otherwise leave the checked-in file alone so the harness stays comparable run to run. For (a): ``` uv run python tools/differential/build_corpus.py --ref 2d5d8c2 > /tmp/corpus-new.jsonl uv run python -c " import json, subprocess def _n(x): return x if isinstance(x, str) else x['name'] old = {_n(json.loads(l)) for l in subprocess.run( ['git', 'show', 'HEAD:tools/differential/corpus.jsonl'], capture_output=True, text=True, check=True).stdout.splitlines() if l.strip()} new = {_n(json.loads(l)) for l in open('/tmp/corpus-new.jsonl', encoding='utf-8') if l.strip()} if old != new: raise SystemExit(sorted(old ^ new)) print(f'identical: {len(old)} names')" cp /tmp/corpus-new.jsonl tools/differential/corpus.jsonl ``` `corpus_issues.jsonl` is built by `build_issues_corpus.py` from the issue tracker (`gh issue list --state all`), taking `HumanName("...")` calls and quoted capitalized phrases -- the two ways a reporter writes the input that broke on them. Regenerate with: ``` uv run python tools/differential/build_issues_corpus.py > tools/differential/corpus_issues.jsonl ``` Unlike the ref-pinned corpus this is a mutable source, so the checked-in file is the snapshot under test; re-running only adds names as new issues arrive. Over-collection is fine in both builders: the comparator just parses more names, and junk like `Bridge (1.4)` costs one parse and produces no diff. `corpus_cjk.jsonl` is a projection of the case table: ``` uv run python tools/differential/build_cjk_corpus.py ``` Every distinct CJK-bearing `text` in `tests/v2/cases.py`, `tolerated` rows included. The generated corpora (`corpus_cjk`, `corpus_conventions`, `corpus_rules`, `corpus_shapes`) are each pinned by `tests/v2/test_corpora.py` against their generator's selection, so a case row, a rules example or a `conventions.py` row added without regenerating fails the suite instead of silently narrowing what the snapshot watches. `corpus_conventions.jsonl` is generated from `conventions.py`, a table whose rows record the tradition, the convention a name exercises, and what a reader who knows the convention would take each part to be: ``` uv run python tools/differential/build_conventions_corpus.py ``` The note is prose. Where a field can state the parts, the row states them too, and the builder writes those rows to `conventions_fields.jsonl` for the agreement ratchet (below). A row whose reading is settled can graduate to `tests/v2/cases.py`. The names are invented, never copied from the references the conventions come from. ## What the corpora do not cover The corpora run under the **default policy**, so any behavior gated behind a non-default `Policy` field is invisible here. Default *vocabulary* is a different matter: it is fully in EFFECT, never gated off the way a `Policy` field is, so a change to it can show up here. That is not the same as coverage -- only 5 of the 17 shipped `maiden_markers` and 8 of the 15 `honorific_tails` appear anywhere in the corpora (re-measured 2026-08-26; the marker count was 3 until #414's rules corpus brought in a parenthesized `Nee`, the denominator was 17 until `roz` left the vocabulary in 2.2 -- it appeared in no corpus name, so only the denominator moved -- and #434's `z domu` then put both back up by one), so an entry no corpus name exercises is as invisible as an opt-in policy. Those two numbers count WHOLE TOKENS, delimiters stripped, and consecutive RUNS of them: an entry may be a PHRASE (`z domu`), which no single token can equal. The run half earns exactly one of the five, and it is the reason the count is not simply per word -- `z` and `domu` each appear in corpus names that carry no marker at all. The strip earns another: `nee`, which appears in the corpora only inside brackets -- `Jane Smith (Nee)` and `Jane Smith (Nee) (Jones)` -- where the token carries them until they come off. `née` needs no strip, appearing bare in many names, and `né` is not counted at all: it occurs only as a substring of `née`, never as a token. Matching a non-ASCII entry anywhere inside a name instead (旧姓 is written flush against the name it marks) makes the marker count 6, adding `né`. Recompute: A heredoc, not `python -c "..."`: the strip set contains a double quote, which closes the `-c` string and leaves the rest to the shell. Paste this as it stands. ``` uv run python - <<'PY' import glob, json from nameparser import Parser from nameparser._lexicon import _normalize L = Parser().lexicon def _n(x): return x if isinstance(x, str) else x['name'] names = [_n(json.loads(l)) for f in glob.glob('tools/differential/corpus*.jsonl') for l in open(f, encoding='utf-8') if l.strip()] for s in ('maiden_markers', 'honorific_tails'): v = getattr(L, s) longest = max(e.count(' ') + 1 for e in v) runs = set() for n in names: t = [_normalize(w.strip('()\'"«»“”„「」『』()')) for w in n.split()] runs |= {' '.join(t[i:i+k]) for k in range(1, longest+1) for i in range(len(t)-k+1)} carries = set(runs & v) | {e for e in v if not e.isascii() and any(e in n for n in names)} print(s, len(runs & v), 'of', len(v), sorted(runs & v), '| substring reading:', len(carries), sorted(carries)) PY ``` Two independent mechanisms put a birth surname in `maiden`, and what is opt-in about them is narrower than it looks (rows measured 2026-08-05, re-measured 2026-08-26): | input | default policy | `maiden_delimiters={("(", ")")}` | |---|---|---| | `Jane Smith (Jones)` | nickname `Jones` | maiden `Jones` | | `Jane Smith née Jones` | maiden `Jones` | maiden `Jones` | Row 1 carries no marker word, so it isolates the delimiter: the brackets alone route their content to `maiden`, and only once the policy says they do. Row 2 carries no brackets, so it isolates the marker: `Lexicon.maiden_markers` ships 17 entries by default, `nee` among them, and the bare form needs no configuration at all. So what is opt-in is neither the marker words nor the delimited path as a whole: it is the delimited path for content that does not announce itself. Since #335 a clause holding a word past its marker reads as the maiden name whichever bucket its pair sits in (`rules/M3`), so `Jane Smith (née Jones)` needs no configuration either. A word past the MARKER, not a second word in the clause: since #434 a marker may be a phrase, and `Maria Kowalska (z domu)` is a two-word clause that stays a nickname because both its words are the marker. Row 1 is exactly the shape that still does — a markerless clause — along with a one-word clause like `Jane Smith (Nee)`, where nothing in the content says maiden and only a caller who knows the data can. ## The CLDR agreement ratchet `corpus_cldr.jsonl` was the first corpus whose names arrive with ground truth: CLDR states every name's fields separately, and `build_cldr_corpus.py` keeps them in `cldr_fields.jsonl`. The snapshot watches those names like any corpus's. Separately, `cldr_agreement.py` compares them against CLDR, role by role — title, given names, family, suffix, and the particle/base split where CLDR states it — and records every disagreement in `cldr_agreement.json`. `tests/v2/test_cldr_agreement.py` holds the tree to each source's record (and to the locale record below). It fails when a role that agreed stops agreeing, when a recorded disagreement starts agreeing (so it can be recorded and guarded), when a recorded disagreement's answer changes, and when a recorded rendering no longer exists. When every reported change is intended: uv run python tools/differential/cldr_agreement.py --record and say why in the commit. Run it without `--record` for per-role agreement counts. Nothing is special-cased: an unnatural rendering or a modelling difference (CLDR's `さん` title is a suffix here) simply stays a recorded disagreement, which makes the record a measured worklist as well as a guard. A second view measures the locale packs (#672). `LOCALE_FILES` in `cldr_agreement.py` maps each CLDR file to the pack for its language (`zh`, `zh_Hant`, `yue`, `yue_Hans` → `ZH`; `ja` → `JA`; `ru` → `RU`; `tr`, `az` → `TR_AZ`); those renderings are parsed with `parser_for(pack)`, over the same `FAMILY_FIRST` base where CLDR's order is surnameFirst, and their disagreements go to `cldr_agreement_locales.json`, ratcheted per pack by the same test. The native-script Wikidata source's Han and kana strata are mapped by `NATIVE_LABELS` (`zh-cn`, `zh-tw` → `ZH`; `ja-jp` → `JA`), since their file is a QID that names no language, and the view parses them the same way into `wikidata_native_agreement_locales.json`. Each source's pack is read from its own rendering key (`locale_sources()`: CLDR's file, the native source's stratum), so a recorded rendering that is gone is still checked by its pack. The summary prints, per pack, how many of its renderings agree in every role under the default parser and under the pack, so a pack PR shows what it moved on its own language. A new pack adds its files to `LOCALE_FILES`, or its name to `NO_CLDR`; the test fails on a pack in neither. The modelling differences stay recorded here too: a pack is not meant to agree with CLDR where the models differ by design. **The GND source.** `build_gnd_corpus.py` writes the GND's name parts in CLDR's field vocabulary (`forename` → `given`, `prefix` → `surname-prefix` with a one-word `surname` as `surname-core`, any other family → `surname` whole, `counting` → `generation`, and a `personalName` -- a person entered under the whole name, with no surname -- as `given` alone, so the family is expected empty), so `cldr_agreement.py` compares `gnd_fields.jsonl` by the same rules and records its disagreements in `gnd_agreement.json`, ratcheted by the same test. The locale view does not read it: no pack covers its names. A composed rendering says so (`composed: true`); where a particle is glued to the surname (`d'Arenberg`, `al-Ǧaʿfarī`) the composed line states the glued word as a whole `surname`, since the comparison matches fields word by word, and the index form, where the particle is a word of its own, keeps the split. A family with no `prefix`, or with a surname of several words, is stated whole for the same kind of reason: GND does not split a particle inside a surname (`Vélez de Guevara`, `Zuylen van Nijevelt`), so what it leaves unsplit means "not stated", not "no particle", and only `family` is compared there. Raw lobid responses are cached outside the tree, in a `gnd/` directory under `$NAMEPARSER_TOOLS_CACHE` (default `~/.cache/nameparser-tools`); `--refresh` fetches them again. The build stops before writing anything when a stratum falls below its floor, a query matches nothing, or a pool pages short, so a lobid change fails it rather than shrinking the corpus; `--record` prints, per source, how many changes of each kind it accepts. **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 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/`. **The conventions source.** `conventions_fields.jsonl` states the parts of the hand-written conventions corpus, from the `fields` of `conventions.py`'s rows, and its disagreements are in `conventions_agreement.json`. The names are invented, so the record is a worklist of conventions rather than a measure of real data: a patronymic read as a family name (`Hassan bin Yusof`), a title read as a given name (`U Kyaw Win`). It uses the Wikidata source's `patronymic`, written with its connective (`bin Yusof`), and adds `honorific`, a word of address written after the name (`Bey`, `ssi`, `san`), scored inside `suffix` as nameparser reads `さん` (Derek, 2026-10-16). A row no field can state honestly is left out of the fields file and stays in the corpus, which the snapshot still watches: the kunya `Abu Karim`, the `-né` married form `Szabóné Kiss Éva`, and `arap`/`ole`/`wa`, whose last word is a parent's name or a family name depending on the family. Such a row says why in its `unstated`, and the builder refuses a row with neither `fields` nor `unstated`, so this fields file states a subset of its corpus without a row's fields being lost unseen. Being hand-written, it is also held to two checks the generated sources pass anyway: its fields partition each text word for word (`test_the_fields_partition_their_rendering`), and the scorer raises on a field name no rule reads, for every source. `--record` rewrites every record. `JA` needs the `ja` extra (`namedivider-python`), which only CI's `ja-extra` job installs: without it the `JA` ratchet skips, the summary says `not measured`, and `--record` keeps the recorded `JA` entries of both locale records unchanged. Re-record with the extra installed (`uv sync --group dev --extra ja`) when a change moves Japanese. `_CORPUS_NAMES` in `tests/v2/_differential_fixtures.py` leaves the file out (2026-10-12): the tests reading it carry counts measured over the names the other corpora held. `corpus_gnd.jsonl`, `corpus_wikidata.jsonl` and `corpus_wikidata_native.jsonl` stay in it (2026-10-16): the counts those tests carry are floors and dated snapshots, which a larger corpus cannot falsify, and their invariants now walk all three files' names too. A release figure counts GND's moved names in, its composed renderings included, since the names are real; AGENTS.md's rule of counting out names only `corpus_cldr.jsonl` holds stays CLDR's alone (Derek, 2026-10-16). Wikidata's moved names, native-script ones included, count in under the same rule, which singles out CLDR alone; they are real and written, not composed. `differential_corpus()` in `tests/v2/conftest.py`, which feeds the property tests, globs `corpus*.jsonl`, so it reads CLDR (since #652), GND and both Wikidata files, and the decomposed-twin test walks all their names. ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/build_cjk_corpus.py����������������������������0000664�0000000�0000000�00000004655�15273032717�0026410�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Regenerate the CJK corpus from the CJK rows of the case table. The third corpus, with the third provenance (#295): corpus.jsonl regenerates from v1's test banks at an immutable ref and corpus_issues.jsonl harvests the issue tracker, but neither can carry an unspaced CJK name -- v1's authors had no reason to test one, and build_issues_corpus.py requires an internal space, which is exactly the shape #271 classifies. This file derives from the reviewed case table instead: every distinct `text` in tests/v2/cases.py containing a character the script table classifies, regardless of the row's policy/locale context -- the corpus carries name STRINGS and the snapshot reads them under the default policy, so a name from a zh-scoped row is simply one more CJK name to watch. Selection is by the shipped table (nameparser._policy._SCRIPT_RANGES, through the same _script_matcher the parser uses), so a script added to the table widens the harvest on the next regeneration, and a case row added for new CJK work enters the corpus by being written down in the table everyone already reviews (#298's 间隔号 forms arrived exactly this way). Rows marked `tolerated` (the 2026-09-01 CJK demotion, rules/W3) are harvested like any other: the flag says the reading is best-effort, not that nobody should see it move. Regenerate after editing CJK case rows: uv run python tools/differential/build_cjk_corpus.py tests/v2/test_corpora.py pins the checked-in file against this module's selection, so a stale corpus fails the suite rather than silently narrowing what the snapshot watches. """ from __future__ import annotations import json import sys from pathlib import Path HERE = Path(__file__).resolve().parent ROOT = HERE.parents[1] sys.path.insert(0, str(ROOT)) from nameparser._policy import _SCRIPT_RANGES, _script_matcher # noqa: E402 from tests.v2.cases import CASES # noqa: E402 OUT = HERE / "corpus_cjk.jsonl" _has_cjk = _script_matcher(*_SCRIPT_RANGES) def selected_names() -> list[str]: """Every distinct case-table text bearing a classified codepoint, sorted for a deterministic file.""" return sorted({case.text for case in CASES if _has_cjk(case.text)}) def main() -> None: names = selected_names() with OUT.open("w", encoding="utf-8") as fh: for name in names: fh.write(json.dumps(name, ensure_ascii=False) + "\n") print(f"wrote {len(names)} names to {OUT.name}") if __name__ == "__main__": main() �����������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/build_cldr_corpus.py���������������������������0000664�0000000�0000000�00000015451�15273032717�0026561�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Regenerate the CLDR corpus from Unicode CLDR's person-name test data. A corpus with its own provenance. Each corpus covers a blind spot the others have -- the roster, with each file's source and the blind spot it leaves, is the table in tools/differential/README.md. This one is the first whose names come with GROUND TRUTH from outside the project: CLDR's person-name formatting tests state every name's fields separately (title, given, given2, surname or surname-prefix + surname-core, surname2, generation, credentials) and render it in about 110 locales, in their native scripts. Every other corpus holds strings someone here chose or reported; nothing in them says what the right answer is. uv run python tools/differential/build_cldr_corpus.py Writes three files beside this script, always together: - corpus_cldr.jsonl -- one line per distinct (rendering, shape), in the existing corpus line format, read by snapshot.py like every other corpus. - cldr_fields.jsonl -- one line per rendering, with CLDR's fields, read by cldr_agreement.py and its ratchet test. - cldr_LICENSE -- the Unicode License v3 notice, which must travel with the data. It is a separate file because the corpus loader (compare._load_entries) refuses any line that is not JSON. Selection: each test name's long, referring, formal rendering in each of the three orders CLDR formats -- givenFirst, surnameFirst and sorting. Those are the full-name strings; the medium, short and monogram forms drop or abbreviate parts. CLDR deliberately renders some names in an order their culture does not use (a Japanese name given-first) to test formatting; those stay in, and the ratchet simply does not guard what does not agree. Shape: a surnameFirst rendering, and a sorting rendering without a comma, is tagged shape 4 (FAMILY_FIRST in shapes.py), so the harness and the ratchet both parse it under the order it declares. A comma carries its own order, and givenFirst is the default. Blind to: real-world name distributions. These are fictional names written by a formatting committee to exercise every field, so they over-represent full-house names (title, two given names, prefixed and double surnames, generation and credentials) and under-represent the plain two-word names most data holds. Pinned to CLDR_TAG; moving it is a deliberate edit and a rebuild. """ from __future__ import annotations import argparse import json import sys from pathlib import Path # The shared fetch, cache and file helpers (corpus_build.py), found by putting # this directory on sys.path: see corpus_build's docstring for why. _HERE = str(Path(__file__).resolve().parent) if _HERE not in sys.path: sys.path.append(_HERE) import corpus_build # noqa: E402 from corpus_build import corpus_lines, fields_lines, write_corpus # noqa: E402,F401 HERE = Path(__file__).resolve().parent CLDR_TAG = "release-48" _REPO = "unicode-org/cldr" _DIR = "common/testData/personNameTest" LIST_URL = f"https://api.github.com/repos/{_REPO}/contents/{_DIR}?ref={CLDR_TAG}" RAW = f"https://raw.githubusercontent.com/{_REPO}/{CLDR_TAG}" CORPUS = HERE / "corpus_cldr.jsonl" FIELDS = HERE / "cldr_fields.jsonl" LICENSE = HERE / "cldr_LICENSE" ORDERS = ("givenFirst", "surnameFirst", "sorting") LABELS = frozenset({"nativeG", "nativeGS", "nativeGGS", "nativeFull", "foreignG", "foreignGS", "foreignGGS", "foreignFull"}) FAMILY_FIRST_SHAPE = 4 USER_AGENT = ("python-nameparser build_cldr_corpus " "(https://github.com/derek73/python-nameparser)") def shape_for(order: str, text: str) -> int | None: """Shape 4 for a rendering written surname-first without a comma to say so; None where the default order or the comma carries it.""" if order == "surnameFirst" or (order == "sorting" and "," not in text): return FAMILY_FIRST_SHAPE return None def parse_test_file(text: str, stem: str) -> list[dict[str, object]]: """The selected renderings of one personNameTest file. The format: a `# <label>` comment, then `name ; <field> ; <value>` lines, then pairs of `expectedResult; <string>` followed by the `parameters; <order>; <length>; <usage>; <formality>` lines that string answers, then `endName`. Anything else is refused, so a format change in a later CLDR release fails the build instead of quietly yielding fewer names. """ rows: list[dict[str, object]] = [] label: str | None = None fields: dict[str, str] = {} results: dict[tuple[str, ...], str] = {} current: str | None = None for raw in text.splitlines(): line = raw.strip() if not line: continue if line.startswith("#"): candidate = line.lstrip("#").strip() if not fields and candidate in LABELS: label = candidate continue kind = line.split(";", 1)[0].strip() if kind == "enum": continue if kind == "name": _, field, value = (p.strip() for p in line.split(";", 2)) fields[field] = value elif kind == "expectedResult": current = line.split(";", 1)[1].strip() elif kind == "parameters": if current is None: raise ValueError(f"{stem}: parameters before any expectedResult") results[tuple(p.strip() for p in line.split(";")[1:])] = current elif kind == "endName": if label is None: raise ValueError(f"{stem}: a name ends with no label comment before it") for order in ORDERS: rendered = results.get((order, "long", "referring", "formal")) if rendered: rows.append({"file": stem, "label": label, "order": order, "text": rendered, "shape": shape_for(order, rendered), "fields": dict(fields)}) label, fields, results, current = None, {}, {}, None else: raise ValueError(f"{stem}: unknown line kind {kind!r}: {line!r}") return rows def _get(url: str) -> bytes: return corpus_build.fetch_bytes(url, user_agent=USER_AGENT, timeout=60) def main() -> None: argparse.ArgumentParser(description=__doc__.splitlines()[0]).parse_args() listing = json.loads(_get(LIST_URL)) names = sorted(item["name"] for item in listing if item["name"].endswith(".txt") and not item["name"].startswith("_")) rows: list[dict[str, object]] = [] for name in names: text = _get(f"{RAW}/{_DIR}/{name}").decode("utf-8") rows.extend(parse_test_file(text, name[:-len(".txt")])) write_corpus(rows, CORPUS, FIELDS) LICENSE.write_bytes(_get(f"{RAW}/LICENSE")) print(f"CLDR {CLDR_TAG}: {len(names)} files, {len(rows)} renderings; " f"wrote {CORPUS.name}, {FIELDS.name}, {LICENSE.name}") if __name__ == "__main__": main() �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/build_conventions_corpus.py��������������������0000664�0000000�0000000�00000007357�15273032717�0030210�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Regenerate corpus_conventions.jsonl and conventions_fields.jsonl from conventions.py's table (#686). The table holds invented names patterned on naming conventions the other corpora barely cover; conventions.py says where the conventions come from and why each row's expected reading is a note rather than a pin. A row read under a non-default order is written with the shapes.py id for that order, so the snapshot reads it under that order. A row with `fields` is also written to conventions_fields.jsonl, in the row format cldr_agreement.py compares, keyed by the name and its order. Regenerate after editing the table: uv run python tools/differential/build_conventions_corpus.py tests/v2/test_corpora.py pins both checked-in files against this module's selection, so a row added without regenerating fails the suite. """ from __future__ import annotations import importlib.util import json from pathlib import Path from types import ModuleType HERE = Path(__file__).resolve().parent OUT = HERE / "corpus_conventions.jsonl" FIELDS_OUT = HERE / "conventions_fields.jsonl" def _load(stem: str) -> ModuleType: """A sibling module, loaded by path as snapshot.load_shapes does: tools/ is not a package, and putting tools/differential/ on sys.path would let its modules shadow bare imports for the rest of a test run.""" spec = importlib.util.spec_from_file_location(f"_{stem}", HERE / f"{stem}.py") assert spec and spec.loader module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module CONVENTIONS = _load("conventions").CONVENTIONS SHAPES = _load("shapes").SHAPES def _shape_for(order: str) -> int: """The one shapes.py id that is an input shape for `order`.""" ids = [k for k, shape in SHAPES.items() if shape.order == order] if len(ids) != 1: raise ValueError(f"conventions.py names order {order!r}, which " f"{len(ids)} shapes.py shapes declare; use one of " f"{sorted({s.order for s in SHAPES.values() if s.order})}") return ids[0] def selected() -> list[object]: """The corpus lines, in table order: a bare name under the default order, else the name with its order's shape.""" seen: set[tuple[str, str | None]] = set() lines: list[object] = [] for row in CONVENTIONS: if (row.fields is None) == (row.unstated is None): raise ValueError(f"conventions.py row {row.name!r} needs exactly one " f"of `fields` and `unstated` (why no field states it)") if (row.name, row.order) in seen: raise ValueError(f"conventions.py lists {row.name!r} under " f"{row.order or 'the default order'} twice") seen.add((row.name, row.order)) lines.append(row.name if row.order is None else {"name": row.name, "shape": _shape_for(row.order)}) return lines def fields_rows() -> list[dict[str, object]]: """The fields file's rows, for the table's rows that state fields.""" selected() # the same row checks as the corpus return [{"fields": row.fields, "file": "conventions", "label": row.name, "order": row.order or "default", "shape": None if row.order is None else _shape_for(row.order), "text": row.name} for row in CONVENTIONS if row.fields is not None] def _write(path: Path, lines: list[object]) -> None: with path.open("w", encoding="utf-8") as fh: for line in lines: fh.write(json.dumps(line, ensure_ascii=False, sort_keys=True) + "\n") print(f"wrote {len(lines)} lines to {path.name}") def main() -> None: _write(OUT, selected()) _write(FIELDS_OUT, list(fields_rows())) if __name__ == "__main__": main() ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/build_corpus.py��������������������������������0000664�0000000�0000000�00000014066�15273032717�0025556�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Extract every plausible test-name string from the v1 test suite: string literals passed as the first argument to HumanName(...) calls, plus string elements/keys of list/dict banks. Dev tooling -- over- collection is fine, the comparator just parses more names. Output is one JSON object per line, `{"name": ..., "tests": [...]}`, where `tests` is the sorted v1 test method names (bare) or bank variable names (prefixed `bank:`) the string appeared in at the pinned ref -- the shape context the original scrape kept the string but threw away. A `HumanName(...)` call is labelled by its NEAREST enclosing function, falling back to the source filename for one at module scope. PROVENANCE: the checked-in tools/differential/corpus.jsonl was built against commit 2d5d8c2 ("Trim constant-factor waste on the tokenize hot path"), the last commit before M12 reconciled/edited the v1 test banks (M12 rewrote expectations and deleted the bucket-A tests, which would have shrunk the corpus). Reading historical test content is fine; per the migration rules, no git *operations* are ever run against the original (non-worktree) checkout -- this script only shells out to `git show <ref>:<path>` inside the current worktree's own history, which contains that commit as an ancestor. Regenerate with: uv run python tools/differential/build_corpus.py --ref 2d5d8c2 \\ > tools/differential/corpus.jsonl """ import argparse import ast import json import subprocess import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[2] TESTS = ROOT / "tests" # Obvious non-names the AST heuristic sweeps up along with real names: # format-string placeholders, decorators/emails, escape sequences. _NOISE_CHARS = ("{", "@", "\\") def _is_name_like(value: str) -> bool: return not any(ch in value for ch in _NOISE_CHARS) def _labelled_strings_from( tree: ast.Module, module_label: str) -> dict[str, set[str]]: """name -> the v1 test methods (or bank variables) it appeared in. This is the metadata the original scrape discarded: v1's test names say what shape each name exercised (test_title_with_conjunction), and #468's whole complaint is that nothing recorded it. Single recursive walk, tracking the NEAREST enclosing FunctionDef/AsyncFunctionDef as the current label (falling back to `module_label`, the source filename, for a HumanName(...) call at module scope) -- ast.walk's flat iteration cannot express nearest- enclosing, and doing two separate module-wide sweeps double-labels a call inside a nested function with both the inner and outer names. The bank branch is untouched by this label: a list/dict assignment is labelled by its own target name (prefixed `bank:` to read apart from a test method at a glance) regardless of what function, if any, encloses it. """ found: dict[str, set[str]] = {} def _add(value: str, label: str) -> None: found.setdefault(value, set()).add(label) def _walk(node: ast.AST, label: str) -> None: if (isinstance(node, ast.Call) and getattr(node.func, "id", getattr( node.func, "attr", "")) == "HumanName" and node.args and isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str)): _add(node.args[0].value, label) if isinstance(node, ast.Assign) and isinstance( node.value, (ast.List, ast.Tuple, ast.Dict)): target_name = getattr(node.targets[0], "id", None) bank_label = f"bank:{target_name}" if target_name \ else "bank:<unnamed>" for c in ast.walk(node.value): if isinstance(c, ast.Constant) and isinstance(c.value, str) \ and " " in c.value and "\n" not in c.value: _add(c.value, bank_label) for child in ast.iter_child_nodes(node): if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)): _walk(child, child.name) else: _walk(child, label) _walk(tree, module_label) return {n: labels for n, labels in found.items() if _is_name_like(n)} def _working_tree_sources() -> dict[str, str]: return {path.name: path.read_text() for path in sorted(TESTS.glob("test_*.py"))} def _ref_sources(ref: str) -> dict[str, str]: """Read every top-level tests/test_*.py file as it existed at `ref`, via `git show`, without touching the working tree or index.""" listing = subprocess.run( ["git", "-C", str(ROOT), "ls-tree", "-r", "--name-only", ref, "--", "tests"], capture_output=True, text=True, check=True).stdout paths = sorted( p for p in listing.splitlines() if p.startswith("tests/") and Path(p).parent == Path("tests") and Path(p).name.startswith("test_") and p.endswith(".py")) sources = {} for path in paths: show = subprocess.run( ["git", "-C", str(ROOT), "show", f"{ref}:{path}"], capture_output=True, text=True, check=True) sources[Path(path).name] = show.stdout return sources def main() -> None: ap = argparse.ArgumentParser() ap.add_argument( "--ref", default=None, help="git ref to read tests/test_*.py from (via `git show`) " "instead of the working tree, e.g. 2d5d8c2 (pre-M12).") args = ap.parse_args() sources = _ref_sources(args.ref) if args.ref else _working_tree_sources() names: dict[str, set[str]] = {} for filename, text in sources.items(): try: tree = ast.parse(text, filename=filename) except SyntaxError as e: print(f"skipping {filename}: {e}", file=sys.stderr) continue for value, labels in _labelled_strings_from(tree, filename).items(): names.setdefault(value, set()).update(labels) for name in sorted(names): print(json.dumps({"name": name, "tests": sorted(names[name])}, ensure_ascii=False)) print(f"{len(names)} names", file=sys.stderr) if __name__ == "__main__": main() ��������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/build_gnd_corpus.py����������������������������0000664�0000000�0000000�00000044274�15273032717�0026412�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Regenerate the GND corpus from the German National Library's authority file. A corpus with its own provenance. Each corpus covers a blind spot the others have -- the roster, with each file's source and the blind spot it leaves, is the table in tools/differential/README.md. This one is the first with ground truth for REAL people. The GND (Gemeinsame Normdatei, CC0) catalogs every person under a preferred name whose parts are stored separately: `surname`, `forename`, `prefix` (the particle, `van`, `d'`, `al-`), `counting` (a regnal number), or a single `personalName` where the person is entered under the name as a whole, with no surname to invert -- Icelanders, Malays, rulers, saints. CLDR's names are fictional; these are deceased people notable enough for a Wikipedia article, which keeps the corpus to public figures. uv run python tools/differential/build_gnd_corpus.py uv run python tools/differential/build_gnd_corpus.py --refresh Writes three files beside this script, always together: - corpus_gnd.jsonl -- one line per distinct text (two records can render alike), in the existing corpus line format, read by snapshot.py like every other corpus. - gnd_fields.jsonl -- one line per rendering, with the GND fields written in CLDR's field vocabulary (`given`, `surname`, `surname-prefix` and `surname-core`, `generation`), so cldr_agreement.py compares both sources by the same rules. - gnd_LICENSE -- the CC0 statement and the source credit. Raw lobid responses are cached outside the repository, in a gnd/ directory under $NAMEPARSER_TOOLS_CACHE (default ~/.cache/nameparser-tools, as tools/name_usage.py uses), so a rebuild reads the same records until --refresh fetches them again. The GND changes daily; the GND id on each fields line is the provenance. Selection is by SHAPE and AREA, never at random: each stratum below is a lobid query plus a predicate, narrowed to the widest slice of GND ids (`gndIdentifier:*7`, `*07`, ...) that holds at most POOL records, fetched whole, and taken in sha256 order of the id -- skipping a record whose fields cannot account for its name or that an earlier stratum took -- up to its quota. A stratum that falls below its floor stops the build before anything is written, as does a query that matches nothing or a pool that pages short: a lobid change must fail the build, not shrink the corpus. A stratum's count says what was sampled, not how common the shape is. Renderings, per record: - a surname record gives GND's preferred name as written -- the index form, `Gogh, Vincent van` -- and, outside the family-first areas, a given-first rendering COMPOSED from the fields (`Vincent van Gogh`, `Ibrāhīm al-Ǧaʿfarī`, a particle ending in an apostrophe or a hyphen joined without a space). The composed line says so (`composed: true`): it tests the parser on a real name in a form this script wrote, as CLDR's renderings are formatter output. A glued particle's composed line states the glued word as a whole `surname`, since the comparison matches fields word by word; the index form, where the particle is a word of its own, keeps the split. The split is stated only for a prefix before a one-word surname; otherwise the family is stated whole (`surname`, not `surname-core`): GND does not split a particle inside a surname (`Vélez de Guevara`, `Zuylen van Nijevelt`), so what it leaves unsplit means "not stated", not "no particle", and only the family is compared. - a personalName record gives the name as written; it is in direct order and has no inverted form. CLDR's `given` holds it, so the family name is expected empty. - a counting record gives `personalName counting` (`Johann II.`), the preferred name without its `nameAddition` (`Pfalz-Zweibrücken, Pfalzgraf`, a German descriptor rather than a part of the name), so it is composed too. A record whose fields do not spell its preferred name, or that carries a field this script does not know, is left out. Records with a nameAddition are otherwise left out: it mixes bynames, offices and occupations in German, and no field says which. Blind to: names as their bearers wrote them. GND writes in German library romanization (`Dostoevskij, Fëdor Michajlovič`) and German forms for historic figures (`Elisabeth`, `Thomas von Aquin`), Latin script only. Its entry rules, not the bearer's culture, decide what counts as a surname: Vietnamese and Thai names are entered as a personalName (direct order), which would read their family names as given names, so no stratum samples those areas; and an Icelandic family name (`Jón Thoroddsen`) is a personalName too. GND's entry rules keep titles, credentials and nicknames out of the preferred name, though a few slip in as part of a personalName (`Imam Bonjol`, `Paku Alam VIII`). """ from __future__ import annotations import argparse import hashlib import sys import urllib.parse from collections.abc import Callable from pathlib import Path from typing import Any, NamedTuple # The shared fetch, cache and file helpers (corpus_build.py), found by putting # this directory on sys.path: see corpus_build's docstring for why. _HERE = str(Path(__file__).resolve().parent) if _HERE not in sys.path: sys.path.append(_HERE) import corpus_build # noqa: E402 from corpus_build import corpus_lines, fields_lines # noqa: E402,F401 HERE = Path(__file__).resolve().parent CORPUS = HERE / "corpus_gnd.jsonl" FIELDS = HERE / "gnd_fields.jsonl" LICENSE = HERE / "gnd_LICENSE" SEARCH = "https://lobid.org/gnd/search" USER_AGENT = ("python-nameparser build_gnd_corpus " "(https://github.com/derek73/python-nameparser)") #: Deceased and notable: public figures only. BASE = "_exists_:dateOfDeath AND _exists_:wikipedia" #: The most records a stratum fetches before it is cut to its quota. POOL = 300 PAGE = 100 #: Id-suffix slices, tried widest first until a stratum's pool fits #: POOL. A sliced stratum never holds an id whose check digit is X. SLICES = ("", "*7", "*07", "*007") _ENTITY = "preferredNameEntityForThePerson" _NO_ADDITION = f"NOT _exists_:{_ENTITY}.nameAddition" #: Particles that join the next word without a space. _GLUED = ("'", "’", "ʼ", "-") #: The preferred-name fields this script knows; a record holding any #: other is refused, so a field lobid adds is noticed, not ignored. KNOWN = frozenset({"personalName", "counting", "nameAddition", "surname", "forename", "prefix"}) LICENSE_TEXT = """\ corpus_gnd.jsonl and gnd_fields.jsonl are derived from the Gemeinsame Normdatei (GND) of the Deutsche Nationalbibliothek, read through lobid-gnd (https://lobid.org/gnd), and carry the GND's license. The Deutsche Nationalbibliothek states that the GND authority data "is available free of charge for general use under Creative Commons Zero (CC0 1.0) terms." https://www.dnb.de/EN/Professionell/Metadatendienste/Datenbezug/geschaeftsmodell.html CC0 1.0 Universal: https://creativecommons.org/publicdomain/zero/1.0/ No attribution is required; this file credits the source anyway. Source: Gemeinsame Normdatei (GND), Deutsche Nationalbibliothek, via lobid-gnd (hbz). Each line of gnd_fields.jsonl names its record's GND id, resolvable at https://d-nb.info/gnd/<id>. """ def area(code: str) -> str: return ('geographicAreaCode.id:"https://d-nb.info/standards/vocab/gnd/' f'geographic-area-code#{code}"') def areas(*codes: str) -> str: return "(" + " OR ".join(area(c) for c in codes) + ")" def _has(field: str) -> str: return f"_exists_:{_ENTITY}.{field}" def _lacks(field: str) -> str: return f"NOT _exists_:{_ENTITY}.{field}" def _entity(record: dict[str, Any]) -> dict[str, list[str]]: entity: dict[str, list[str]] = record.get(_ENTITY) or {} return entity def _one(entity: dict[str, list[str]], field: str) -> str: values = entity.get(field) or [] return values[0].strip() if len(values) == 1 else "" def _compound(record: dict[str, Any]) -> bool: """A surname of two or more words, or hyphenated. The strata using it exclude a prefix in their query.""" surname = _one(_entity(record), "surname") return " " in surname or "-" in surname.strip("-") def _any(record: dict[str, Any]) -> bool: return True class Stratum(NamedTuple): label: str query: str quota: int keep: Callable[[dict[str, Any]], bool] = _any #: An area where a name is written family-first: only GND's own #: index form is kept, since a composed given-first text would be #: unnatural and the area is where the person was active, not their #: culture, so a family-first composition could not be trusted. family_first: bool = False #: The fewest names the build accepts; below it, it stops. Set below #: the quota only where the pool is known to run short. least: int | None = None @property def floor(self) -> int: return self.quota if self.least is None else self.least _SURNAME = f"{_has('surname')} AND {_NO_ADDITION}" _DIRECT = f"{_has('personalName')} AND {_lacks('counting')} AND {_NO_ADDITION}" _PARTICLE = f"{_has('prefix')} AND {_NO_ADDITION}" STRATA = ( # No surname: entered under the name as a whole. Stratum("direct-is", f"{_DIRECT} AND {area('XA-IS')}", 30), Stratum("direct-my-id", f"{_DIRECT} AND {areas('XB-MY', 'XB-ID')}", 30), Stratum("direct-arab", f"{_DIRECT} AND {areas('XC-EG', 'XB-IQ', 'XB-SY', 'XB-SA')}", 30), Stratum("direct-ir-af", f"{_DIRECT} AND {areas('XB-IR', 'XB-AF')}", 20), Stratum("direct-et-mm", f"{_DIRECT} AND {areas('XC-ET', 'XB-MM')}", 20, least=12), Stratum("direct-eu", f"{_DIRECT} AND {areas('XA-DE', 'XA-IT', 'XA-FR')}", 30), # Regnal numbers. Stratum("regnal", f"{_has('personalName')} AND {_has('counting')}", 40), # Particles, by area, and the elided d' and l' wherever they occur. Stratum("particle-de-at", f"{_PARTICLE} AND {areas('XA-DE', 'XA-AT', 'XA-CH')}", 30), Stratum("particle-nl-be", f"{_PARTICLE} AND {areas('XA-NL', 'XA-BE')}", 30), Stratum("particle-fr", f"{_PARTICLE} AND {area('XA-FR')}", 25), Stratum("particle-it", f"{_PARTICLE} AND {area('XA-IT')}", 20), Stratum("particle-iberia", f"{_PARTICLE} AND {areas('XA-ES', 'XA-PT', 'XD-BR', 'XD-MX', 'XD-AR')}", 25), Stratum("particle-elided", f"{_NO_ADDITION} AND ({_ENTITY}.prefix:d\\' OR {_ENTITY}.prefix:l\\')", 15, least=10), Stratum("particle-arab", f"{_PARTICLE} AND {areas('XC-EG', 'XB-IQ', 'XB-SY', 'XB-SA', 'XB-LB', 'XC-MA')}", 20), # Compound surnames (two words or hyphenated), no particle. Stratum("compound-iberia", f"{_SURNAME} AND {_lacks('prefix')} AND {areas('XA-ES', 'XA-PT')}", 30, _compound), Stratum("compound-latam", f"{_SURNAME} AND {_lacks('prefix')} AND {areas('XD-BR', 'XD-MX', 'XD-AR', 'XD-CO', 'XD-CL', 'XD-PE')}", 30, _compound), # Family-first areas: the index form only. Stratum("index-cjk", f"{_SURNAME} AND {areas('XB-CN', 'XB-TW', 'XB-JP', 'XB-KR')}", 40, family_first=True), Stratum("index-hu", f"{_SURNAME} AND {area('XA-HU')}", 20, family_first=True), # A control slice of plain surname, forename names. Stratum("plain-anglo", f"{_SURNAME} AND {_lacks('prefix')} AND {areas('XD-US', 'XA-GB', 'XA-IE')}", 25), Stratum("plain-europe", f"{_SURNAME} AND {_lacks('prefix')} AND {areas('XA-DE', 'XA-RU', 'XA-PL', 'XA-CZ', 'XA-GR')}", 25), ) def _checked(body: bytes | str, source: str) -> dict[str, Any]: """A lobid search response, or ValueError naming where it came from.""" data = corpus_build.parse_json(body, source) if not isinstance(data, dict) or "totalItems" not in data or "member" not in data: raise ValueError(f"{source} is not a lobid search response") return data def _get_json(params: dict[str, str | int], refresh: bool) -> dict[str, Any]: url = f"{SEARCH}?{urllib.parse.urlencode(params)}" def fetch() -> bytes: return corpus_build.fetch_bytes(url, user_agent=USER_AGENT, timeout=120) return corpus_build.cached_json(corpus_build.cache_dir("gnd"), url, url, fetch, _checked, refresh) def _search(query: str, size: int, start: int, refresh: bool) -> dict[str, Any]: return _get_json({"q": f"{BASE} AND {query}", "filter": "type:Person", "format": "json", "size": size, "from": start, "sort": "gndIdentifier:asc"}, refresh) def pool(stratum: Stratum, refresh: bool) -> list[dict[str, Any]]: """Every record of the stratum's widest id slice that fits POOL, after the stratum's own predicate. Raises on a query that matches nothing and on pages that do not add up to the total, since either means the query or the API changed (or the cache mixes two days' pages: --refresh).""" for suffix in SLICES: query = stratum.query + (f" AND gndIdentifier:{suffix}" if suffix else "") total = int(_search(query, 0, 0, refresh)["totalItems"]) if total <= POOL: break else: raise ValueError(f"{stratum.label}: {total} records even in the " f"narrowest slice; add a slice to SLICES") if total == 0: raise ValueError(f"{stratum.label}: the query matches nothing; " f"has lobid's schema changed? {stratum.query}") records: list[dict[str, Any]] = [] for start in range(0, total, PAGE): records.extend(_search(query, PAGE, start, refresh)["member"]) ids = {str(r["gndIdentifier"]) for r in records} if len(records) != total or len(ids) != total: raise ValueError(f"{stratum.label}: {len(records)} records ({len(ids)} " f"distinct) paged for a total of {total}; rerun with --refresh") return [r for r in records if stratum.keep(r)] def _order(record: dict[str, Any]) -> str: return hashlib.sha256(str(record["gndIdentifier"]).encode()).hexdigest() def renderings(record: dict[str, Any], stratum: Stratum) -> list[dict[str, Any]]: """The fields lines one record contributes; [] when its preferred name holds anything the fields cannot account for.""" entity = _entity(record) if set(entity) - KNOWN or any(len(values) != 1 for values in entity.values()): return [] gnd = str(record["gndIdentifier"]) written = str(record["preferredName"]).strip() def row(order: str, text: str, fields: dict[str, str], composed: bool) -> dict[str, Any]: return {"file": gnd, "label": stratum.label, "order": order, "text": text, "shape": None, "composed": composed, "fields": fields} personal, counting = _one(entity, "personalName"), _one(entity, "counting") if personal: if {"surname", "forename", "prefix"} & set(entity) or "," in personal: return [] if counting: regnal = f"{personal} {counting}" addition = _one(entity, "nameAddition") if written != (f"{regnal}, {addition}" if addition else regnal): return [] return [row("givenFirst", regnal, {"given": personal, "generation": counting}, True)] if written != personal: return [] return [row("givenFirst", written, {"given": personal}, False)] surname, forename = _one(entity, "surname"), _one(entity, "forename") prefix = _one(entity, "prefix") if not surname or not forename or "nameAddition" in entity or "," in surname + forename: return [] index = f"{surname}, {forename}" + (f" {prefix}" if prefix else "") if written != index: return [] # the preferred name is not what its fields say # The particle split is stated only where GND states all of it: a # prefix before a one-word core. With no prefix, or a core of several # words (`Zuylen van Nijevelt`), a particle may sit unstated inside, # so the family is stated whole and the split is not compared. if prefix and " " not in surname: fields = {"given": forename, "surname-prefix": prefix, "surname-core": surname} else: fields = {"given": forename, "surname": f"{prefix} {surname}" if prefix else surname} rows = [row("sorting", written, fields, False)] if not stratum.family_first: if prefix.endswith(_GLUED): # One word: the comparison matches fields word by word, so # the glued word is stated whole; the index form, where the # particle is a word of its own, keeps the split. glued = prefix + surname rows.append(row("givenFirst", f"{forename} {glued}", {"given": forename, "surname": glued}, True)) else: family = f"{prefix} {surname}" if prefix else surname rows.append(row("givenFirst", f"{forename} {family}", fields, True)) return rows def build(refresh: bool) -> list[dict[str, Any]]: """Every stratum's rows, or ValueError naming each stratum that fell below its floor -- raised before main() writes anything.""" rows: list[dict[str, Any]] = [] seen: set[str] = set() short: list[str] = [] for stratum in STRATA: taken = earlier = refused = 0 for record in sorted(pool(stratum, refresh), key=_order): if taken == stratum.quota: break gnd = str(record["gndIdentifier"]) if gnd in seen: earlier += 1 continue mine = renderings(record, stratum) if not mine: refused += 1 continue seen.add(gnd) rows.extend(mine) taken += 1 print(f"{stratum.label:16} {taken:3}/{stratum.quota} names " f"({refused} refused, {earlier} taken by an earlier stratum)") if taken < stratum.floor: short.append(f"{stratum.label} {taken}/{stratum.floor}") if short: raise ValueError(f"strata below their floor, nothing written: {', '.join(short)}") return rows def main() -> None: ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("--refresh", action="store_true", help="fetch from lobid again instead of reading the cache") rows = build(ap.parse_args().refresh) corpus_build.write_corpus(rows, CORPUS, FIELDS) LICENSE.write_text(LICENSE_TEXT, encoding="utf-8") print(f"GND: {len(rows)} renderings of {len({r['file'] for r in rows})} " f"names; wrote {CORPUS.name}, {FIELDS.name}, {LICENSE.name}") if __name__ == "__main__": main() ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/build_issues_corpus.py�������������������������0000664�0000000�0000000�00000017035�15273032717�0027150�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Harvest name-like strings from this project's GitHub issue tracker. Companion to build_corpus.py, and deliberately a SEPARATE corpus with a separate builder. The two have different provenance and different blind spots: - corpus.jsonl comes from v1's own test suite at a pinned git ref. It is reproducible forever, and it is structurally blind to anything 2.0 added -- v1's authors had no reason to write a test for a typographic nickname delimiter or a Cyrillic title. - corpus_issues.jsonl comes from what USERS REPORTED, which is the adversarial half: names that broke the parser in the field, in the reporter's own words. On its first run it surfaced five intended- but-unclassified 2.0 behaviors and one shape (a LEADING "Ph. D.") that no test had considered. Reproducibility differs, and that is why these are not merged. A git ref is immutable; an issue tracker is not. Re-running this later can only ADD names as new issues arrive, so the checked-in file is the snapshot under test and regeneration is an explicit, reviewable act. Over-collection is fine, same as build_corpus.py: the comparator just parses more names. Junk like 'Bridge (1.4)' costs one parse and produces no diff. Regenerate with (requires `gh` authenticated to the repo): uv run python tools/differential/build_issues_corpus.py \\ > tools/differential/corpus_issues.jsonl """ import json import re import subprocess import sys # Either an explicit HumanName("...") call -- the reporter showing the # failing input -- or a capitalized phrase in quotes or BACKTICKS, # which is how names appear in prose ("parsing 'Hans "Hansi" Müller' # gives ...", or `Beethoven, Ludwig van` in a markdown issue). # # The backtick branch was missing until #413, and the cost was not # marginal: this tracker writes names in backticks because issues are # markdown, so the corpus whose whole purpose is "what users reported" # was blind to the way this project reports. Under the screens this # file ships the backtick pass adds 94 names that the quoted branch # never saw, none of them in the previous corpus, and they include the # headline example of the issue that fixed them -- `Beethoven, Ludwig # van` (#379), `Berg, Jan vd` (#380), `Ursula von der Leyen geb. # Albrecht` (#399). That last one sat in its issue's TITLE while #399 # shipped noting the class was invisible to this harness. _CANDIDATE = re.compile( r"""HumanName\(\s*["']([^"']{3,60})["']""" r"""|["']([A-Z][^"'\n]{4,60})["']""" ) # Backticks are scanned SEPARATELY, not as a third alternative, and the # reason is a name this cost before it was split out. A reporter writes # the failing input as `HumanName("John Q Doe")` -- a call inside a # code span. Regex scanning takes the leftmost match, so a backtick # alternative starts one character before the HumanName branch can and # swallows the whole span, harvesting the CALL and losing the name # inside it. Two passes over the same text cannot shadow each other. _BACKTICKED = re.compile(r"""`([A-Z][^`\n]{4,60})`""") # ... and the same code span is dropped from the backtick pass, or the # call itself is harvested as a name. The HumanName branch above has # already taken the argument out of it, which is the part anyone meant. # # Searched, not matched: a call is code wherever it sits in the span, # and anchoring at offset 0 let `Foo HumanName("x y")` through with the # call still attached. _A_CALL = re.compile(r"""[A-Za-z_]\w*\(""") # Structural characters that mean we caught code or markup, not a name. # # Extended when the backtick branch landed (#413): backticks wrap code # far more often than quotes do, so the branch admits code-shaped # strings a quoted phrase rarely produces -- 'Constants.__init__(self, # **state)', 'DEVIATION #364', 'SUFFIX_ACRONYMS ∩ SUFFIX_NOT_ACRONYMS', # 'TypeError: a bytes-like object is required'. None of '*#~;_:' # appears in a name; the colon alone accounted for three error # messages and a PyPI trove classifier, and none of them occurs in any # name in any corpus. The one existing entry this drops, # 'St. ___', is a placeholder, not a name. _NOT_A_NAME = set('{}<>=/\\|*#~;_:') #: Words that make a string a SENTENCE rather than a name. The #: character screen above cannot reach prose that is merely prose -- #: 'What this gate does not cover', 'Takes everything', 'C is #: CONSTANTS' are all well-formed capitalized phrases. #: #: NOT a backtick problem, though the backtick branch is what prompted #: looking: five of the six sentences this drops come from the QUOTED #: branch, which has been running since the harvester was written. #: They are new to the corpus because the tracker grew, not because of #: the new pass -- re-harvesting with backticks disabled still adds 88 #: names over the previous snapshot. Scoping this screen to the #: backtick pass would look like a tightening and would silently #: readmit five of them. #: #: Matched only against a word written in LOWERCASE, which is what #: keeps the screen off names. A name capitalizes its words and a #: sentence's function words do not, so 'Chan On Kei', 'U Than Shwe', #: 'Sven Are Olsen', 'Kertu Must', 'Wafula Were' and 'John Does' all #: survive while 'NO Latin twins on purpose' does not. Without the #: case test this list silently drops real names, and the corpus it #: drops them from is the one whose whole purpose is catching what #: users report. #: #: The exclusions are the load-bearing part, and they divide into two #: kinds. 'and', 'the', 'of', 'do' and 'will' are excluded on corpus #: evidence -- 'Rob And Beth Edmunds', 'The Hon', 'Duke of Wellington', #: 'Anh Do' and 'Will De Groot' are all in the corpora. 'can' and 'no' #: are excluded on no corpus evidence at all: they are Turkish Can and #: Korean No, names this harvest has not reached yet. The distinction #: matters because the corpus cannot supply evidence for a word any #: screen already rejects, so absence from it is not a licence. A name is allowed to #: be odd -- 'der, y van' is real -- it is not allowed to be a #: sentence. _PROSE = frozenset({ "is", "are", "was", "were", "be", "been", "this", "that", "these", "those", "what", "which", "how", "why", "when", "not", "does", "did", "without", "everything", "takes", "reads", "sure", "about", "because", "than", "then", "into", "over", "under", "should", "would", "could", "must", "on", }) def _harvest(text: str) -> set[str]: found = set() values = [(m.group(1) or m.group(2) or "").strip() for m in _CANDIDATE.finditer(text)] values += [m.group(1).strip() for m in _BACKTICKED.finditer(text) if not _A_CALL.search(m.group(1))] for value in values: # Require an internal space: a single word is a surname at best # and carries no structure to disagree about. if not value or " " not in value or (_NOT_A_NAME & set(value)): continue if any(s and s == s.lower() and s in _PROSE for s in (w.strip(".,'\"") for w in value.split())): continue found.add(value) return found def main() -> None: raw = subprocess.run( ["gh", "issue", "list", "--state", "all", "--limit", "1000", "--json", "number,title,body"], capture_output=True, text=True, check=True).stdout issues = json.loads(raw) names: set[str] = set() for issue in issues: names |= _harvest( f"{issue.get('title') or ''}\n{issue.get('body') or ''}") for name in sorted(names): print(json.dumps(name, ensure_ascii=False)) print(f"{len(names)} names from {len(issues)} issues", file=sys.stderr) if __name__ == "__main__": main() ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/build_rules_corpus.py��������������������������0000664�0000000�0000000�00000010214�15273032717�0026757�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Regenerate corpus_rules.jsonl from the examples in docs/design/rules/. A corpus with its own provenance (#414). Each corpus covers a blind spot the others have -- the roster, with each file's source and the blind spot it leaves, is the table in tools/differential/README.md. This one comes from the NORMATIVE RULES: the names the project has written down as defining its behavior, which is a blind spot no corpus built from test suites, issue reports or case rows was made to cover. Why the rules doc needs a corpus when its examples are already tests. `test_rules_doc.py` executes every example, which pins them harder than corpus membership pins anything -- but it pins them against an expectation stored beside them. Change behavior deliberately and the expected value is edited in the same commit; the test goes green and says nothing. A doc example cannot warn about the change that edited it. The snapshot answers the other question: a moved name shows as a line in the diff of tools/differential/snapshot.jsonl, which a reviewer reads beside the doc edit (#664). #409 is the worked example: it changed M2's "Jane de la née Jones" → family="de la née Jones" to family="de la". The doc test followed the edit, the name was in no corpus, and nothing independent observed the movement. Twelve names this corpus CONTRIBUTED moved during the 2.2 cycle the same way -- thirteen of its names move in total, the thirteenth having already been in corpus.jsonl. Selection is every distinct example `text`, through the doc's own parser rather than a second regex -- the same argument that has build_cjk_corpus.py select through the shipped script table. A rule gaining an example enters the corpus by being written down where it already gets reviewed. Policy annotations are deliberately ignored. The corpus carries name STRINGS and the snapshot reads them under the default policy, so a family-first-scoped example is simply one more name to watch -- build_cjk_corpus.py makes the same call for its zh-scoped rows. A rule marked `tolerated:` contributes its examples like any other: the marker says the reading is best-effort, not that nobody should see it move. The one example form that carries no name is skipped. A `[subject]` example names a policy or locale rather than a name string, so its `text` is empty -- the rules have three, and without the filter all three collapse into a single "" that the doc never wrote. Skipping them is also why the promise above is honest: an example enters the corpus by being written down, and a form with nothing to enter is better excluded than folded into a shared empty string. Boundary strings ("(", ".,", "Anna () Smith") are kept, on the over-collection principle build_corpus.py and build_issues_corpus.py state: a name that parses to nothing costs one parse and produces no diff. They are more concentrated here than elsewhere because the doc argues its edges explicitly, which is a reason to keep them rather than to drop them. Regenerate after editing rules examples: uv run python tools/differential/build_rules_corpus.py tests/v2/test_corpora.py pins the checked-in file against this module's selection, so a stale corpus fails the suite rather than silently narrowing what the snapshot watches. """ from __future__ import annotations import json import sys from pathlib import Path HERE = Path(__file__).resolve().parent ROOT = HERE.parents[1] sys.path.insert(0, str(ROOT)) from tests.v2.design_docs import read_rules_text # noqa: E402 from tests.v2.rules_doc import parse_rules_doc # noqa: E402 OUT = HERE / "corpus_rules.jsonl" def selected_names() -> list[str]: """Every distinct example text in the rules doc, sorted for a deterministic file.""" rules = parse_rules_doc(read_rules_text()) return sorted({example.text for rule in rules for example in rule.examples if example.text}) def main() -> None: names = selected_names() with OUT.open("w", encoding="utf-8") as fh: for name in names: fh.write(json.dumps(name, ensure_ascii=False) + "\n") print(f"wrote {len(names)} names to {OUT.name}") if __name__ == "__main__": main() ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/build_shapes_corpus.py�������������������������0000664�0000000�0000000�00000004617�15273032717�0027122�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Regenerate corpus_shapes.jsonl from the shape-tagged rows of the case table. Every entry here was CHOSEN (#468) -- a row someone wrote with reviewed expected values -- and the shape id records, once, at admission, what the name is for. The corpus_cjk pattern generalized: same source of truth (cases.py), same pinned projection (tests/v2/test_corpora.py holds the checked-in file equal to this module's selection), different predicate -- explicit tags instead of codepoint content. snapshot.py resolves each entry's shape to its name_order through tools/differential/shapes.py and reads family-first entries under that order, on the v2 surface; compare.py skips them below the shape's minimum baseline. Regenerate after tagging or editing tagged case rows: uv run python tools/differential/build_shapes_corpus.py Pass --coverage to print names-per-shape instead of writing, which is the "which shapes, how many names deep" answer #468 asked for. """ from __future__ import annotations import argparse import json import sys from collections import Counter from pathlib import Path HERE = Path(__file__).resolve().parent ROOT = HERE.parents[1] sys.path.insert(0, str(ROOT)) from tests.v2.cases import CASES # noqa: E402 OUT = HERE / "corpus_shapes.jsonl" def _pairs() -> list[tuple[int, str]]: """Every distinct (shape, text) pair from tagged rows, sorted for a deterministic file. A text tagged with TWO shapes (the family-first divergence pairs) is two pairs: two orders are two comparisons.""" return sorted({(case.shape, case.text) for case in CASES if case.shape is not None}) def selected() -> list[dict[str, object]]: """The corpus lines, in file order.""" return [{"name": text, "shape": shape} for shape, text in _pairs()] def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--coverage", action="store_true", help="print names-per-shape and exit") args = ap.parse_args() if args.coverage: by_shape = Counter(shape for shape, _ in _pairs()) for shape in sorted(by_shape): print(f"shape {shape}: {by_shape[shape]} names") return rows = selected() with OUT.open("w", encoding="utf-8") as fh: for row in rows: fh.write(json.dumps(row, ensure_ascii=False) + "\n") print(f"wrote {len(rows)} entries to {OUT.name}") if __name__ == "__main__": main() �����������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/build_wikidata_corpus.py�����������������������0000664�0000000�0000000�00000060756�15273032717�0027442�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Regenerate the Wikidata corpus: real names with a second surname or a patronymic stated. A corpus with its own provenance. Each corpus covers a blind spot the others have -- the roster, with each file's source and the blind spot it leaves, is the table in tools/differential/README.md. This one exists for the two fields no other open source labels: Wikidata's P1950 (second family name in a Spanish name) and P5056 (patronym or matronym). CLDR's names are fictional, and the GND holds `García Márquez` as one surname and a patronymic inside the forename. Wikidata is CC0. uv run python tools/differential/build_wikidata_corpus.py uv run python tools/differential/build_wikidata_corpus.py --refresh Writes three files beside this script, always together: - corpus_wikidata.jsonl -- one line per distinct (rendering, shape), in the existing corpus line format, read by snapshot.py like every other corpus. - wikidata_fields.jsonl -- one line per rendering, with the parts written in CLDR's field vocabulary (`given`, `surname`, `surname2`) plus `patronymic`, which CLDR has no field for, so cldr_agreement.py compares it by the same rules. - wikidata_LICENSE -- the CC0 statement and the source credit. Raw responses are cached outside the repository, in a wikidata/ directory under $NAMEPARSER_TOOLS_CACHE (default ~/.cache/nameparser-tools, as tools/name_usage.py uses), so a rebuild reads the same items until --refresh fetches them again. Wikidata changes constantly; the QID on each fields line is the provenance. Selection is one Wikidata Query Service query per stratum (one country of citizenship and one property), restricted to public figures -- deceased, or with at least MIN_SITELINKS Wikipedia and sister-project articles -- and ordered by an MD5 of the item URI, so the sample is deterministic. The queries run one at a time. The items are then fetched with wbgetentities in batches of 50, and a stratum takes the first items in that order that survive the alignment below, up to its quota. A stratum that falls below its floor stops the build before anything is written, as does a query that matches nothing, a pool short of its size (unless the stratum is marked `whole`, its population smaller than the pool, which a full pool then contradicts) or a response that is not what was asked for: a Wikidata change must fail the build, not shrink the corpus. A stratum's count says what was sampled, not how common the shape is. Item labels are NOT concatenated into a name. Joining the parts reproduced the person's own written name for 12-64% of them, by country, in the 2026-10-16 corpus survey: parts are missing, an extra baptismal name is stated, a Russian surname item holds the masculine form of a woman's name. Instead each written name -- the person's P1559 (name in native language) and their label in the stratum's language -- is TOKEN-ALIGNED against the parts: each part's spellings (its labels in the text's language, the stratum's language and `mul`, and its P1705 native labels) are found as whole runs of the text's words, and the text is kept only if - every word is claimed by exactly one part, except a connective (`bin`, `binti`, `i`, `y`, ...) standing directly before a claimed part, which joins that part as written (`bin Hamzah`, `i Coll`); - no part matches in two places, and no two parts claim the same word (a person whose P734 and P5056 are the same item, seen in Malaysian data, falls out here); - the stratum's own property is among the parts found, with a given name and, for a second surname, the first one. A woman's Russian surname may be written in the feminine form of its item's label (`Лядова` for `Лядов`); those forms are added as spellings for a female person only. A P734 "no value" statement is never needed: a text with no family part simply states none. A text that writes the family name first without a comma (the Russian official order) is tagged FAMILY_FIRST (shape 4), as build_cldr_corpus.py tags CLDR's surnameFirst renderings; one with a comma is a sorting form, read by the comma. The field mapping. P735 → `given`, P734 → `surname`, P1950 → `surname2`, P5056 → `patronymic`. A part's words are stated as they stand in the text, so a particle inside a P734 label (`van Gogh`, `del Rey`) stays in the surname: Wikidata does not split it, so the particle and base views are not compared. cldr_agreement.py scores `surname2` inside `family` and `patronymic` inside `given_names` -- a patronymic is not a family name, the GND and LC convention, so `Vigdís Finnbogadóttir` expects the family empty -- and also scores each field on its own once ParsedName has one. Blind to: everything the strict filter drops. The survivors are tidy names whose every word has a Wikidata item, so short display forms (`Pedro Martínez`, with no second surname written), nicknames, and names with an unrecorded middle part are missing; and only the eight citizenships below are sampled, Malaysia thinly (its floor says why). A family-first text with no family part (an Icelandic or Malay name in a sorted list without a comma) cannot be told from a given-first one, so it would be read given-first; none is in the committed data. No title, credential or suffix is in any text. """ from __future__ import annotations import argparse import sys import urllib.parse from collections.abc import Callable, Iterable from pathlib import Path from typing import Any, NamedTuple, Protocol, TypeVar # The shared fetch, cache and file helpers (corpus_build.py), found by putting # this directory on sys.path: see corpus_build's docstring for why. _HERE = str(Path(__file__).resolve().parent) if _HERE not in sys.path: sys.path.append(_HERE) import corpus_build # noqa: E402 from corpus_build import corpus_lines, fields_lines # noqa: E402,F401 HERE = Path(__file__).resolve().parent CORPUS = HERE / "corpus_wikidata.jsonl" FIELDS = HERE / "wikidata_fields.jsonl" LICENSE = HERE / "wikidata_LICENSE" SPARQL = "https://query.wikidata.org/sparql" API = "https://www.wikidata.org/w/api.php" #: WDQS asks for a descriptive agent; "bot" marks automated use. USER_AGENT = ("python-nameparser-corpus-bot/1.0 build_wikidata_corpus " "(https://github.com/derek73/python-nameparser)") #: Living people count as public figures only with this many sitelinks. MIN_SITELINKS = 10 #: Items fetched per stratum before the alignment filter cuts it to its #: quota: the survivors were a quarter to a half in the 2026-10-16 probe. POOL_FACTOR = 6 BATCH = 50 #: shapes.py's FAMILY_FIRST shape, as build_cldr_corpus.py uses it. FAMILY_FIRST_SHAPE = 4 FEMALE = "Q6581072" #: Wikidata property -> the field it is stated as. PARTS = {"P735": "given", "P734": "surname", "P1950": "surname2", "P5056": "patronymic"} #: Words that introduce the part after them and have no item of their #: own: Malay bin/binti (and abbreviations), Catalan i, Spanish y. Matched #: as written: a capital `I` or `Y` is an initial, not a connective. CONNECTIVES = frozenset({"bin", "binti", "bt", "bt.", "b.", "i", "y", # Malay writes these capitalized too "Bin", "Binti"}) LICENSE_TEXT = """\ corpus_wikidata.jsonl and wikidata_fields.jsonl are derived from Wikidata (https://www.wikidata.org), read through the Wikidata Query Service and the wbgetentities API, and carry Wikidata's license. Wikidata states that "All structured data (i.e. the main, Property, Lexeme, and EntitySchema namespaces) is released into the public domain". https://www.wikidata.org/wiki/Wikidata:Licensing CC0 1.0 Universal: https://creativecommons.org/publicdomain/zero/1.0/ No attribution is required; this file credits the source anyway. Source: Wikidata, Wikimedia Foundation. Each line of wikidata_fields.jsonl names its item's QID, resolvable at https://www.wikidata.org/wiki/<QID>. Only public figures are sampled (deceased, or with at least 10 sitelinks), and only name strings and QIDs are kept. """ class Stratum(NamedTuple): label: str country: str # Wikidata item for the country of citizenship (P27) prop: str # the property every sampled item states language: str # the language its people's names are written in quota: int #: The fewest names the build accepts; below it, it stops. Set below #: the quota only where the names that align are known to run short of it. least: int | None = None #: Whether the query's whole population is smaller than `size`; a #: pool short of `size` stops the build unless this is set, and a full #: pool stops it when it is. whole: bool = False @property def size(self) -> int: """How many people the query takes.""" return self.quota * POOL_FACTOR @property def floor(self) -> int: return self.quota if self.least is None else self.least STRATA = ( Stratum("surname2-es", "Q29", "P1950", "es", 150), Stratum("surname2-mx", "Q96", "P1950", "es", 100), Stratum("surname2-co", "Q739", "P1950", "es", 80), Stratum("patronymic-ru", "Q159", "P5056", "ru", 110), Stratum("patronymic-su", "Q15180", "P5056", "ru", 60), Stratum("patronymic-ri", "Q34266", "P5056", "ru", 40), # Icelandic public figures with a P5056 statement: 270 of a pool of # 600 on 2026-10-16, so the population is smaller than the pool. Stratum("patronymic-is", "Q189", "P5056", "is", 100, whole=True), # 17 Malaysian public figures with a P5056 statement on 2026-10-16, # 9 aligning: the population is the limit, and the floor leaves 3 to lose. Stratum("patronymic-my", "Q833", "P5056", "ms", 40, least=6, whole=True), ) def _checked(body: bytes | str, source: str, kind: str) -> dict[str, Any]: """A WDQS result (`kind` "results") or a wbgetentities response ("entities"), or ValueError naming where it came from. An API error arrives with status 200, so the shape is what tells.""" data = corpus_build.parse_json(body, source) if not isinstance(data, dict) or kind not in data or "error" in data: detail = data.get("error") if isinstance(data, dict) else None raise ValueError(f"{source} has no {kind!r}" + (f": {detail}" if detail else "")) return data def _fetch(url: str, data: bytes | None, refresh: bool, kind: str) -> dict[str, Any]: def fetch() -> bytes: return corpus_build.fetch_bytes( url, user_agent=USER_AGENT, timeout=90, data=data, headers={"Accept": "application/json"}, delay=1) # sequential, and gentle return corpus_build.cached_json( corpus_build.cache_dir("wikidata"), url + "\n" + (data or b"").decode(), url, fetch, lambda body, source: _checked(body, source, kind), refresh) def query(stratum: Stratum) -> str: """The stratum's WDQS query: its public figures, in MD5 order.""" return f"""SELECT ?item WHERE {{ ?item wdt:{stratum.prop} ?part; wdt:P31 wd:Q5; wdt:P27 wd:{stratum.country}; wikibase:sitelinks ?links. FILTER(?links >= {MIN_SITELINKS} || EXISTS {{ ?item wdt:P570 ?died }}) }} GROUP BY ?item ORDER BY MD5(STR(?item)) LIMIT {stratum.size}""" def pooled(label: str, text: str, size: int, whole: bool, refresh: bool) -> list[str]: """The QIDs a WDQS query returns, in its order, and ValueError unless their number is the one the stratum expects: some, and `size` of them -- the query's LIMIT -- unless `whole` says the population is smaller than that (then fewer, and a full pool means the mark is stale). A query that starts matching fewer people (a property deprecated, a country item merged) would otherwise rebuild the corpus from a smaller, different sample; both Wikidata builders pool through here.""" data = urllib.parse.urlencode({"query": text, "format": "json"}).encode() result = _fetch(SPARQL, data, refresh, "results") qids = [b["item"]["value"].rsplit("/", 1)[1] for b in result["results"]["bindings"]] if not qids: raise ValueError(f"{label}: the query matches nothing; has a " f"property or item changed?\n{text}") if len(qids) < size and not whole: raise ValueError(f"{label}: the query matched {len(qids)} people, " f"fewer than its pool of {size}; if that is now " "the whole population, mark the stratum whole=True") if len(qids) >= size and whole: raise ValueError(f"{label}: marked whole=True, but the query filled " f"its pool of {size}; drop whole=True") return qids def pool(stratum: Stratum, refresh: bool) -> list[str]: """The stratum's QIDs in MD5 order.""" return pooled(stratum.label, query(stratum), stratum.size, stratum.whole, refresh) def entities(ids: Iterable[str], props: str, refresh: bool, languages: Iterable[str] = ()) -> dict[str, Any]: """Each requested item, keyed by the id asked for (a redirected item comes back under that id, holding its target; a deleted one, marked `missing`). Raises on an id the response leaves out. `languages`, when given, limits the labels fetched to those languages: a famous person's item carries hundreds, and the cache holds every one.""" ids = sorted(set(ids)) languages = tuple(languages) # read once per batch: an iterator would run dry out: dict[str, Any] = {} for start in range(0, len(ids), BATCH): query = {"action": "wbgetentities", "ids": "|".join(ids[start:start + BATCH]), "props": props, "format": "json"} if languages: query["languages"] = "|".join(languages) url = API + "?" + urllib.parse.urlencode(query) out.update(_fetch(url, None, refresh, "entities")["entities"]) absent = sorted(i for i in ids if i not in out) if absent: raise ValueError(f"wbgetentities left out {', '.join(absent[:10])}" f"{' ...' if len(absent) > 10 else ''}; rerun with --refresh") return out def statements(item: dict[str, Any], prop: str) -> list[Any]: """The values of an item's non-deprecated statements for `prop`; an item id for an item value, the text for a monolingual one (with its language as a tuple).""" out: list[Any] = [] for claim in item.get("claims", {}).get(prop, []): snak = claim["mainsnak"] if claim.get("rank") == "deprecated" or snak["snaktype"] != "value": continue value = snak["datavalue"]["value"] if isinstance(value, dict) and "id" in value: out.append(value["id"]) elif isinstance(value, dict) and "text" in value: out.append((value["text"], value["language"])) return out def _label(item: dict[str, Any], language: str) -> str: return str(item.get("labels", {}).get(language, {}).get("value", "")) def spellings(part: dict[str, Any], languages: Iterable[str]) -> set[str]: """Every way the part item's name may be written in a text.""" found = {_label(part, lang) for lang in languages} found |= {text for text, _ in statements(part, "P1705")} return {s.strip() for s in found if s.strip()} #: Masculine Russian surname endings and their feminine forms. _FEMININE = (("ский", "ская"), ("цкий", "цкая"), ("ой", "ая"), ("ый", "ая"), ("ов", "ова"), ("ев", "ева"), ("ёв", "ёва"), ("ин", "ина"), ("ын", "ына")) def feminine(surname: str, endings: tuple[tuple[str, str], ...] = _FEMININE) -> str: """The feminine form of a masculine surname, or '': Russian by default, or by another language's table of (masculine, feminine) endings, tried in order, so a longer ending goes first.""" for male, female in endings: if surname.endswith(male): return surname[: -len(male)] + female return "" def _word(token: str) -> str: """A text word as compared: a comma glued to it is not part of it.""" return token.rstrip(",") def _spans(tokens: list[str], words: list[str], names: set[str]) -> set[tuple[int, int]]: """Where any spelling occurs as whole words, as (start, end) word positions. A comma may only end a span: `Кушнарёв, Юрий` is two parts.""" found: set[tuple[int, int]] = set() for name in names: want = name.split() for i in range(len(words) - len(want) + 1): j = i + len(want) if words[i:j] == want and not any(t.endswith(",") for t in tokens[i:j - 1]): found.add((i, j)) return found def _widest(spans: set[tuple[int, int]]) -> set[tuple[int, int]]: """The spans no other span contains: `María José` over its `María`.""" return {(i, j) for i, j in spans if not any(a <= i and j <= b and (a, b) != (i, j) for a, b in spans)} def claim(found: list[tuple[str, set[tuple[int, int]]]]) -> dict[int, str] | None: """The field each position is claimed by, from each part's (field, spans): a part claims its widest span, or None when it matches in two places or two parts claim one position. Positions are words or, for an unspaced text, characters; coverage is the caller's to check.""" claimed: dict[int, str] = {} for field, spans in found: widest = _widest(spans) if len(widest) > 1: return None # the part matches in two places for i, j in widest: for k in range(i, j): if k in claimed: return None # two parts claim one position claimed[k] = field return claimed def by_field(claimed: dict[int, str]) -> dict[str, list[int]]: """claim()'s positions, grouped by field in text order.""" out: dict[str, list[int]] = {} for k in sorted(claimed): out.setdefault(claimed[k], []).append(k) return out def align(text: str, parts: list[tuple[str, set[str]]]) -> dict[str, list[int]] | None: """Each field's word positions in `text`, or None when the text is not covered by exactly one part per word. `parts` holds one (field, spellings) entry per statement.""" tokens = text.split() words = [_word(t) for t in tokens] claimed = claim([(field, _spans(tokens, words, names)) for field, names in parts]) if claimed is None: return None # Right to left, so a run of connectives chains to the part after it; # tokens[k], not words[k], since a connective carrying a comma joins nothing. for k in range(len(words) - 1, -1, -1): if k not in claimed and words[k] in CONNECTIVES \ and k + 1 in claimed and not tokens[k].endswith(","): claimed[k] = claimed[k + 1] if len(claimed) != len(words): return None return by_field(claimed) def _order(text: str, fields: dict[str, list[int]]) -> tuple[str, int | None]: if "," in text: return "sorting", None family = fields.get("surname", []) + fields.get("surname2", []) if family and min(family) == 0: return "surnameFirst", FAMILY_FIRST_SHAPE return "givenFirst", None def renderings(qid: str, person: dict[str, Any], names: dict[str, Any], stratum: Stratum) -> list[dict[str, Any]]: """The fields lines one person contributes; [] when no written name of theirs aligns.""" female = FEMALE in statements(person, "P21") texts = list(statements(person, "P1559")) if _label(person, stratum.language): texts.append((_label(person, stratum.language), stratum.language)) rows: list[dict[str, Any]] = [] seen: set[str] = set() for text, lang in texts: text = " ".join(text.split()) if text in seen: continue seen.add(text) languages = (lang, stratum.language, "mul") parts: list[tuple[str, set[str]]] = [] for prop, field in PARTS.items(): for value in statements(person, prop): written = spellings(names.get(value, {}), languages) if field == "surname" and female: written |= {f for f in map(feminine, written) if f} parts.append((field, written)) aligned = align(text, parts) if aligned is None or PARTS[stratum.prop] not in aligned \ or "given" not in aligned \ or (stratum.prop == "P1950" and "surname" not in aligned): continue order, shape = _order(text, aligned) if any(row["order"] == order for row in rows): continue # P1559 first: one rendering per order words = [_word(t) for t in text.split()] rows.append({"file": qid, "label": stratum.label, "order": order, "text": text, "shape": shape, "composed": False, "fields": {f: " ".join(words[k] for k in ks) for f, ks in aligned.items()}}) return rows class _Counted(Protocol): """What the driver reads of a stratum; each builder's own NamedTuple. Read-only properties, since a NamedTuple's fields cannot be set.""" @property def label(self) -> str: ... @property def quota(self) -> int: ... @property def floor(self) -> int: ... S = TypeVar("S", bound=_Counted) def build_strata(strata: Iterable[S], *, props: Iterable[str], label_width: int, refresh: bool, pool: Callable[[S, bool], list[str]], entities: Callable[[list[str], S, bool], dict[str, Any]], renderings: Callable[[str, dict[str, Any], dict[str, Any], S], list[dict[str, Any]]], keep: Callable[[str, dict[str, Any]], None] | None = None, ) -> list[dict[str, Any]]: """Every stratum's rows, or ValueError naming each stratum that fell below its floor -- raised before main() writes anything -- or on a pooled person Wikidata reports missing. Both Wikidata builders run their strata through here, so a guard on the loop (a person taken once across strata, the missing-person stop, the floor) is written once; what differs is passed in: how a stratum is pooled, how entities are fetched for it (`entities(ids, stratum, refresh)`), how a person is rendered, and `keep(qid, person)`, called for each person a stratum takes. `props` are the properties whose values are the name-part items to fetch.""" rows: list[dict[str, Any]] = [] seen: set[str] = set() short: list[str] = [] for stratum in strata: pooled = pool(stratum, refresh) qids = [q for q in pooled if q not in seen] people = entities(qids, stratum, refresh) # A pooled person must exist; a deleted name-part item is a fact # about the data, and only refuses the person stating it. stale = sorted(q for q in qids if "missing" in people[q]) if stale: raise ValueError(f"{stratum.label}: WDQS pooled items Wikidata reports " f"missing: {', '.join(stale[:10])}; rerun with --refresh") parts = {v for p in people.values() for prop in props for v in statements(p, prop)} names = entities(sorted(parts), stratum, refresh) taken = refused = 0 for qid in qids: if taken == stratum.quota: break mine = renderings(qid, people[qid], names, stratum) if not mine: refused += 1 continue seen.add(qid) rows.extend(mine) if keep is not None: keep(qid, people[qid]) taken += 1 print(f"{stratum.label:{label_width}} {taken:3}/{stratum.quota} names " f"({refused} refused, {len(pooled) - len(qids)} taken by an " f"earlier stratum, {len(pooled)} pooled)") if taken < stratum.floor: short.append(f"{stratum.label} {taken}/{stratum.floor}") if short: raise ValueError(f"strata below their floor, nothing written: {', '.join(short)}") return rows def build(refresh: bool) -> list[dict[str, Any]]: """Every stratum's rows; see build_strata.""" return build_strata( STRATA, props=PARTS, label_width=15, refresh=refresh, pool=pool, entities=lambda ids, stratum, refresh: entities(ids, "labels|claims", refresh), renderings=renderings) def main() -> None: ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("--refresh", action="store_true", help="fetch from Wikidata again instead of reading the cache") rows = build(ap.parse_args().refresh) corpus_build.write_corpus(rows, CORPUS, FIELDS) LICENSE.write_text(LICENSE_TEXT, encoding="utf-8") print(f"Wikidata: {len(rows)} renderings of {len({r['file'] for r in rows})} " f"names; wrote {CORPUS.name}, {FIELDS.name}, {LICENSE.name}") if __name__ == "__main__": main() ������������������derek73-python-nameparser-456b024/tools/differential/build_wikidata_native_corpus.py����������������0000664�0000000�0000000�00000060131�15273032717�0030773�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Regenerate the native-script Wikidata corpus: real names in their own scripts. A corpus with its own provenance (the roster, with each file's source and blind spot, is the table in tools/differential/README.md). Every other real-name corpus is Latin script but for Wikidata's Russian patronymics, and CLDR's native-script names are fictional, so the parser's script-conditional readings -- family-first order for Han, Hangul and kana, Korean surname segmentation -- had been tested against no real name. It holds no honorific, so it does not exercise the CJK honorific peel; it shows only that the peel leaves plain names alone. This corpus holds real names as Wikidata writes them in Arabic (with Persian and Urdu), Hebrew, Devanagari (Hindi, Marathi and Nepali), Bengali, Thai, Greek, Georgian, Armenian, Hangul, Han and kana, and Cyrillic beyond Russian. Wikidata is CC0. uv run python tools/differential/build_wikidata_native_corpus.py uv run python tools/differential/build_wikidata_native_corpus.py --refresh uv run python tools/differential/build_wikidata_native_corpus.py --lc-check Writes three files beside this script, always together: - corpus_wikidata_native.jsonl -- one line per distinct (rendering, shape), read by snapshot.py like every other corpus. - wikidata_native_fields.jsonl -- one line per rendering, in build_wikidata_corpus.py's field vocabulary (`given`, `surname`, `patronymic`), scored by cldr_agreement.py, which also scores the Han and kana strata under their locale packs (its NATIVE_LABELS). - wikidata_native_LICENSE -- the CC0 statement and the source credit. The machinery is build_wikidata_corpus.py's, imported: the cache under $NAMEPARSER_TOOLS_CACHE/wikidata, the fetches, statements() and spellings(), and the token alignment, which keeps a written name only where every word is claimed by exactly one stated part (P735 given, P734 surname, P5056 patronymic). What this builder adds: - Selection by script, not by property. A stratum is one country of citizenship and one language; its query takes public figures -- deceased, or with at least MIN_SITELINKS sitelinks -- who state a given or family name and carry a label in that language, in MD5 order. A text (the person's P1559 in that language, or their label in it) is used only if every letter and combining mark in it is in the stratum's script (digits and punctuation are not asked): `mul` labels are Latin, and a Latin text would pass as a Hindi name. - Unspaced text is aligned by character. A Han, kana or Hangul text with no space (most of them) is aligned with the same rule over its characters: each part's widest occurrence must be its only one, no two parts may claim a character, and every character must be claimed. No part is inferred: a Chinese item stating only P734 (most do) leaves its given name unstated, and the person is not used, rather than reading the rest of the text as the given name. - Feminine surnames, per language. A woman's surname may be written in a feminine form of its item's label (Greek `Λελούδα` for `Λελούδας`, Bulgarian `-ова`, Ukrainian `-ська`); without these the filter would drop women and the sample would skew male. A wrong form costs nothing: it matches no word, and the name falls out as before. - The script decides the order. A Han, kana or Hangul text carries no shape: the parser reads those scripts family-first by itself, which is what the corpus is for. Pure katakana, which the parser leaves to the declared order, and any other text are ordered by where the surname stands, as build_wikidata_corpus.py orders them. A stratum that falls below its floor stops the build before anything is written, as does a query matching nothing or a response that is not what was asked for. A stratum's count says what was sampled, not how common the shape is. Its pool is sized from the yield measured on 2026-10-16 (2026-10-23 for the Hindi, Marathi, Urdu and Nepali strata). Where the yield was thin (under about two and a half times the quota), or the pool is the whole population (Thailand, Kazakhstan, Bangladesh, Hindi in India, Urdu in Pakistan, Nepal), the floor is set below the quota, leaving a margin a re-pool may lose. A pool short of its size stops the build unless the stratum is marked as its whole population, and so does a stratum so marked whose pool comes back full, since its population has then outgrown the mark. --lc-check compares each name, where its item states a Library of Congress authority id (P244), with the native-script variants (MARC 400 $a) of that LC record, and prints per stratum how many LC confirms and which it splits differently. LC is a witness, never a source: the check runs after the build has written its files, and adds nothing to them and filters nothing from them. LC's split is its comma (`הלוי, אהרן`) or, in CJK, a space (`이 병기`); most Han variants are written unspaced and give no split. Records are cached under $NAMEPARSER_TOOLS_CACHE/lc; an LCCN LC no longer has (HTTP 404) is counted as such and not cached. Blind to: everything the strict filter drops. Survivors are tidy names whose every word has a Wikidata item labelled in the script. Arabic nasab chains never align, since `بن` is not a connective in Arabic script, and `آل` family names and kunyas are missing because most Arabic items state no family name, so the Arabic rows are mostly two-word modern names; Chinese rows are names whose given name has an item, a minority; most Japanese items are refused because the given-name item holds only a kana reading; Devanagari, Bengali, Urdu and Thai items are mostly labelled in Latin script only, so those strata are small: Hindi's whole population of 4,806 yields 147 in build order, and Dari in Afghanistan, left out, 4 of 291 (2026-10-23). A transcribed foreign name written with the 间隔号 (·) never aligns. No title, credential or suffix is in any text, and Wikidata's P734 is a convention, not a fact, where a culture has no family name (an Arabic nisba such as `البغدادي` is entered as one). """ from __future__ import annotations import argparse import sys import unicodedata import urllib.error import urllib.parse import xml.etree.ElementTree as ET from collections import Counter from pathlib import Path from typing import Any, NamedTuple # The shared modules, found by putting this directory on sys.path: see # corpus_build's docstring for why. _HERE = str(Path(__file__).resolve().parent) if _HERE not in sys.path: sys.path.append(_HERE) import build_wikidata_corpus as wikidata # noqa: E402 import corpus_build # noqa: E402 from corpus_build import corpus_lines, fields_lines # noqa: E402,F401 from build_wikidata_corpus import align, statements # noqa: E402 HERE = Path(__file__).resolve().parent CORPUS = HERE / "corpus_wikidata_native.jsonl" FIELDS = HERE / "wikidata_native_fields.jsonl" LICENSE = HERE / "wikidata_native_LICENSE" LC_RECORD = "https://id.loc.gov/authorities/names/{}.marcxml.xml" MARC = "{http://www.loc.gov/MARC21/slim}" #: The parts a native-script name is aligned to. P1950 is Spanish only. PARTS = {"P735": "given", "P734": "surname", "P5056": "patronymic"} #: Feminine surname endings, longest first, by language. _GREEK = (("όπουλος", "οπούλου"), ("ίδης", "ίδου"), ("άδης", "άδου"), ("άκης", "άκη"), ("ής", "ή"), ("ης", "η"), ("άς", "ά"), ("ας", "α"), ("ός", "ού"), ("ος", "ου")) _SLAVIC = (("ов", "ова"), ("ев", "ева"), ("ёв", "ёва"), ("ин", "ина")) _BULGARIAN = (("ски", "ска"), ("цки", "цка"), *_SLAVIC) _UKRAINIAN = (("ський", "ська"), ("цький", "цька"), ("ін", "іна"), *_SLAVIC) class Language(NamedTuple): #: Label languages a text may come from (P1559 is matched by prefix). labels: tuple[str, ...] #: Unicode character-name prefixes every letter and combining mark of #: a text must have. script: tuple[str, ...] feminine: tuple[tuple[str, str], ...] = () _HAN = ("CJK UNIFIED", "CJK COMPATIBILITY", "IDEOGRAPHIC") #: Scripts the parser reads family-first by itself (Policy.script_orders): #: a text in them carries no shape -- unless it is pure katakana, which #: the parser leaves to the declared order (a transcribed foreign name). _SELF_ORDERED = (*_HAN, "HANGUL", "HIRAGANA", "KATAKANA") LANGUAGES = { "ar": Language(("ar",), ("ARABIC",)), "fa": Language(("fa",), ("ARABIC",)), "he": Language(("he",), ("HEBREW",)), "hi": Language(("hi",), ("DEVANAGARI",)), "mr": Language(("mr",), ("DEVANAGARI",)), "ne": Language(("ne",), ("DEVANAGARI",)), "ur": Language(("ur",), ("ARABIC",)), "bn": Language(("bn",), ("BENGALI",)), "th": Language(("th",), ("THAI",)), "el": Language(("el",), ("GREEK",), _GREEK), "ka": Language(("ka",), ("GEORGIAN",)), "hy": Language(("hy",), ("ARMENIAN",)), "ko": Language(("ko",), ("HANGUL",)), "ja": Language(("ja",), (*_HAN, "HIRAGANA", "KATAKANA")), "zh": Language(("zh", "zh-hans", "zh-hant", "zh-cn", "zh-tw", "zh-hk"), _HAN), "uk": Language(("uk",), ("CYRILLIC",), _UKRAINIAN), "bg": Language(("bg",), ("CYRILLIC",), _BULGARIAN), "sr": Language(("sr", "sr-ec"), ("CYRILLIC",)), "kk": Language(("kk", "kk-cyrl"), ("CYRILLIC",), _SLAVIC), } class Stratum(NamedTuple): label: str country: str # Wikidata item for the country of citizenship (P27) language: str # a LANGUAGES key quota: int #: How many people the query takes, sized from the yield measured on #: 2026-10-16 (2026-10-23 for the strata a STRATA comment so dates). pool: int #: The fewest names the build accepts; below it, it stops. Set below #: the quota where the yield is thin enough that a re-pool could #: fall short, or the pool is the whole population. least: int | None = None #: Whether the query's whole population is smaller than `pool`. A #: pool short of `pool` stops the build unless this is set: a query #: that suddenly matches less would otherwise drift the sample unseen. whole: bool = False @property def floor(self) -> int: return self.quota if self.least is None else self.least # The yields in the comments are names kept of 600 pooled, 2026-10-16, # unless the comment gives another denominator. STRATA = ( Stratum("ko-kr", "Q884", "ko", 120, 300), # 405 Stratum("ka-ge", "Q230", "ka", 60, 150), # 402 Stratum("sr-rs", "Q403", "sr", 60, 300), # 192 Stratum("bg-bg", "Q219", "bg", 60, 300), # 165 Stratum("he-il", "Q801", "he", 60, 400), # 144 Stratum("ar-sy", "Q858", "ar", 60, 400), # 145 Stratum("ar-eg", "Q79", "ar", 60, 450), # 131 Stratum("ar-sa", "Q851", "ar", 30, 600, least=25), # 61 Stratum("hy-am", "Q399", "hy", 60, 500), # 106 Stratum("el-gr", "Q41", "el", 60, 600), # 84, before feminine forms Stratum("ja-jp", "Q17", "ja", 60, 700), # 85 Stratum("zh-cn", "Q148", "zh", 50, 800), # 61 Stratum("zh-tw", "Q865", "zh", 30, 700, least=25), # 41 Stratum("fa-ir", "Q794", "fa", 30, 600, least=25), # 49 Stratum("uk-ua", "Q212", "uk", 30, 900, least=25), # 38, before feminine forms Stratum("bn-in", "Q668", "bn", 25, 900, least=20), # 29 # The four India strata share their people: each takes no one an # earlier one took. Yields measured 2026-10-23 in build order with no # quota cap; hi-in's pool is the whole population, and ur-in's yield # is its quota, the floor its only margin. Stratum("hi-in", "Q668", "hi", 60, 5000, least=50, whole=True), # 147 of 4806 Stratum("mr-in", "Q668", "mr", 30, 3000, least=25), # 40 of 3000 Stratum("ur-in", "Q668", "ur", 12, 3000, least=10), # 12 of 3000 # The pools below are the whole population on 2026-10-16. Stratum("bn-bd", "Q902", "bn", 15, 600, least=12, whole=True), # 26 of 563 Stratum("kk-kz", "Q232", "kk", 15, 600, least=12, whole=True), # 22 of 491 Stratum("th-th", "Q869", "th", 10, 600, least=8, whole=True), # 13 of 224 # The whole population on 2026-10-23, yields measured with no quota cap. Stratum("ur-pk", "Q843", "ur", 20, 1600, least=15, whole=True), # 24 of 1456 Stratum("ne-np", "Q837", "ne", 10, 200, least=8, whole=True), # 12 of 170 ) LICENSE_TEXT = """\ corpus_wikidata_native.jsonl and wikidata_native_fields.jsonl are derived from Wikidata (https://www.wikidata.org), read through the Wikidata Query Service and the wbgetentities API, and carry Wikidata's license. Wikidata states that "All structured data (i.e. the main, Property, Lexeme, and EntitySchema namespaces) is released into the public domain". https://www.wikidata.org/wiki/Wikidata:Licensing CC0 1.0 Universal: https://creativecommons.org/publicdomain/zero/1.0/ No attribution is required; this file credits the source anyway. Source: Wikidata, Wikimedia Foundation. Each line of wikidata_native_fields.jsonl names its item's QID, resolvable at https://www.wikidata.org/wiki/<QID>. Only public figures are sampled (deceased, or with at least 10 sitelinks), and only name strings and QIDs are kept. """ def query(stratum: Stratum) -> str: """The stratum's WDQS query: its public figures with a stated given or family name and a label in its language, in MD5 order.""" return f"""SELECT ?item WHERE {{ ?item wdt:P31 wd:Q5; wdt:P27 wd:{stratum.country}; wdt:P735|wdt:P734 ?part; rdfs:label ?own; wikibase:sitelinks ?links. FILTER(LANG(?own) = "{LANGUAGES[stratum.language].labels[0]}") FILTER(?links >= {wikidata.MIN_SITELINKS} || EXISTS {{ ?item wdt:P570 ?died }}) }} GROUP BY ?item ORDER BY MD5(STR(?item)) LIMIT {stratum.pool}""" def pool(stratum: Stratum, refresh: bool) -> list[str]: """The stratum's QIDs in MD5 order.""" return wikidata.pooled(stratum.label, query(stratum), stratum.pool, stratum.whole, refresh) def entities(ids: list[str], language: str, refresh: bool) -> dict[str, Any]: """wbgetentities for `ids`, with labels in the language's label languages and `mul` only.""" return wikidata.entities(ids, "labels|claims", refresh, (*LANGUAGES[language].labels, "mul")) def in_script(text: str, script: tuple[str, ...]) -> bool: """Whether every letter and mark of `text` is in the script (named by Unicode character-name prefixes); spaces, punctuation and joiners are not asked. A text with no letter is in no script.""" letters = [ch for ch in text if ch.isalpha() or unicodedata.category(ch).startswith("M")] return bool(letters) and all(unicodedata.name(ch, "").startswith(script) for ch in letters) def align_chars(text: str, parts: list[tuple[str, set[str]]]) -> dict[str, list[int]] | None: """align() for a text with no space: each field's character positions, or None unless every character is claimed by exactly one part, each part matching (at its widest spelling) in one place only.""" def spans(names: set[str]) -> set[tuple[int, int]]: found: set[tuple[int, int]] = set() for name in names: name = "".join(name.split()) start = text.find(name) if name else -1 while start != -1: found.add((start, start + len(name))) start = text.find(name, start + 1) return found claimed = wikidata.claim([(field, spans(names)) for field, names in parts]) if claimed is None or len(claimed) != len(text): return None return wikidata.by_field(claimed) def script_orders(text: str) -> bool: """Whether the parser reads `text` family-first by its script alone: Han, kana or Hangul, but not pure katakana.""" return in_script(text, _SELF_ORDERED) and not in_script(text, ("KATAKANA",)) def _texts(person: dict[str, Any], language: Language) -> list[tuple[str, str]]: """The person's written names in the language that are in its script, P1559 first, each as (text, its language), whitespace-normalized and deduplicated.""" found = [(text, lang) for text, lang in statements(person, "P1559") if lang.split("-")[0] == language.labels[0].split("-")[0]] found += [(wikidata._label(person, lang), lang) for lang in language.labels] out: dict[str, str] = {} for text, lang in found: text = " ".join(text.split()) if text and text not in out and in_script(text, language.script): out[text] = lang return list(out.items()) def renderings(qid: str, person: dict[str, Any], names: dict[str, Any], stratum: Stratum) -> list[dict[str, Any]]: """The fields lines one person contributes: at most one, their first written name that aligns with a given name and a surname.""" language = LANGUAGES[stratum.language] female = wikidata.FEMALE in statements(person, "P21") for text, lang in _texts(person, language): parts: list[tuple[str, set[str]]] = [] for prop, field in PARTS.items(): for value in statements(person, prop): written = wikidata.spellings(names.get(value, {}), (lang, *language.labels, "mul")) if field == "surname" and female and language.feminine: written |= {f for f in (wikidata.feminine(w, language.feminine) for w in written) if f} parts.append((field, written)) # Only Han, kana and Hangul are written unspaced; a one-word text # in another script is word-aligned, and so never splits a word. by_char = len(text.split()) == 1 and in_script(text, _SELF_ORDERED) aligned = align_chars(text, parts) if by_char else align(text, parts) if aligned is None or "given" not in aligned or "surname" not in aligned: continue units, joint = (text, "") if by_char else ( [wikidata._word(t) for t in text.split()], " ") fields = {f: joint.join(units[k] for k in ks) for f, ks in aligned.items()} order, shape = wikidata._order(text, aligned) if script_orders(text): shape = None # the script decides the order: what is under test return [{"file": qid, "label": stratum.label, "order": order, "text": text, "shape": shape, "composed": False, "fields": fields}] return [] def lccn(person: dict[str, Any]) -> str | None: """The person's Library of Congress authority id (P244), if stated.""" ids = [v for v in (claim["mainsnak"].get("datavalue", {}).get("value") for claim in person.get("claims", {}).get("P244", []) if claim.get("rank") != "deprecated") if isinstance(v, str)] return ids[0] if ids else None def build(refresh: bool) -> tuple[list[dict[str, Any]], dict[str, str]]: """Every stratum's rows, and the LCCN of each person used who states one. The loop is `wikidata.build_strata`, shared with the parent builder. Raises ValueError, before main() writes anything, naming each stratum below its floor, or on a pooled person Wikidata reports missing, or (from pool()) on a pool that is empty or the wrong size.""" lccns: dict[str, str] = {} def keep(qid: str, person: dict[str, Any]) -> None: if (found := lccn(person)) is not None: lccns[qid] = found rows = wikidata.build_strata( STRATA, props=PARTS, label_width=6, refresh=refresh, pool=pool, entities=lambda ids, stratum, refresh: entities(ids, stratum.language, refresh), renderings=renderings, keep=keep) return rows, lccns # -- the LC cross-check ------------------------------------------------------- def _lc_checked(body: bytes | str, source: str) -> dict[str, Any]: """An LC MARC record's 400 $a variants, or ValueError naming the source when the body is not a MARC record.""" try: root = ET.fromstring(body) except ET.ParseError as error: raise ValueError(f"{source} is not MARC XML ({error})") from None if not root.tag.endswith("record"): raise ValueError(f"{source} is not a MARC record: <{root.tag}>") variants = ["".join(sf.text or "" for sf in field.iter(MARC + "subfield") if sf.get("code") == "a").strip(" ,.") for field in root.iter(MARC + "datafield") if field.get("tag") == "400"] return {"variants": [v for v in variants if v]} def lc_variants(lccn: str, refresh: bool) -> list[str] | None: """The record's 400 $a variants, or None when LC has no such record.""" url = LC_RECORD.format(urllib.parse.quote(lccn)) def fetch() -> bytes: return corpus_build.fetch_bytes(url, user_agent=wikidata.USER_AGENT, timeout=60, delay=1) try: record = corpus_build.cached_json(corpus_build.cache_dir("lc"), url, url, fetch, _lc_checked, refresh) except urllib.error.HTTPError as error: if error.code == 404: error.close() # it holds the response; unclosed, 3.14 warns return None raise return list(record["variants"]) def _squash(text: str) -> str: return "".join(text.replace(",", " ").replace("،", " ").split()) def lc_split(row: dict[str, Any], variants: list[str]) -> tuple[str, str] | None: """LC's (family, rest) for the rendering: from a variant that writes the same letters with a comma (`הלוי, אהרן` for `אהרן הלוי`), or, for a text with no space, with one space (`이 병기`); None when no variant gives one.""" whole = _squash(row["text"]) for variant in variants: family, comma, rest = variant.replace("،", ",").partition(",") # inverted, so the text may write the parts either way round if comma and whole in (_squash(family + rest), _squash(rest + family)): return _squash(family), _squash(rest) words = variant.split() if " " not in row["text"] and len(words) == 2 and _squash(variant) == whole: return words[0], words[1] return None def lc_check(rows: list[dict[str, Any]], lccns: dict[str, str], refresh: bool) -> None: """Print, per stratum, how often LC's split agrees with Wikidata's.""" tally: dict[str, Counter[str]] = {} differ: list[str] = [] for row in rows: counts = tally.setdefault(row["label"], Counter()) counts["names"] += 1 if row["file"] not in lccns: continue counts["with an LCCN"] += 1 variants = lc_variants(lccns[row["file"]], refresh) if variants is None: counts["no LC record"] += 1 # a stale P244 continue split = lc_split(row, variants) if split is None: continue counts["LC splits it"] += 1 fields = row["fields"] ours = (_squash(fields["surname"]), _squash(" ".join(v for f, v in fields.items() if f != "surname"))) if split == ours: counts["agree"] += 1 else: differ.append(f" {row['file']} {row['text']!r}: Wikidata {ours}, " f"LC {split} (lccn {lccns[row['file']]})") for label, counts in tally.items(): print(f"{label:6} " + ", ".join(f"{k} {counts[k]}" for k in ("names", "with an LCCN", "no LC record", "LC splits it", "agree"))) print(f"{len(differ)} split differently:", *differ, sep="\n") def main() -> None: ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("--refresh", action="store_true", help="fetch from Wikidata (and LC) again instead of reading the cache") ap.add_argument("--lc-check", action="store_true", help="also compare each name with its Library of Congress record") args = ap.parse_args() rows, lccns = build(args.refresh) corpus_build.write_corpus(rows, CORPUS, FIELDS) LICENSE.write_text(LICENSE_TEXT, encoding="utf-8") print(f"Wikidata native: {len(rows)} renderings; wrote {CORPUS.name}, " f"{FIELDS.name}, {LICENSE.name}") if args.lc_check: lc_check(rows, lccns, args.refresh) if __name__ == "__main__": main() ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/cldr_LICENSE�����������������������������������0000664�0000000�0000000�00000003761�15273032717�0024523�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������UNICODE LICENSE V3 COPYRIGHT AND PERMISSION NOTICE Copyright © 2004-2025 Unicode, Inc. NOTICE TO USER: Carefully read the following legal agreement. BY DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. Permission is hereby granted, free of charge, to any person obtaining a copy of data files and any associated documentation (the "Data Files") or software and any associated documentation (the "Software") to deal in the Data Files or Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, and/or sell copies of the Data Files or Software, and to permit persons to whom the Data Files or Software are furnished to do so, provided that either (a) this copyright and permission notice appear with all copies of the Data Files or Software, or (b) this copyright and permission notice appear in associated Documentation. THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF THIRD PARTY RIGHTS. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA FILES OR SOFTWARE. Except as contained in this notice, the name of a copyright holder shall not be used in advertising or otherwise to promote the sale, use or other dealings in these Data Files or Software without prior written authorization of the copyright holder. SPDX-License-Identifier: Unicode-3.0 ���������������derek73-python-nameparser-456b024/tools/differential/cldr_agreement.json����������������������������0000664�0000000�0000000�00001160722�15273032717�0026362�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������{ "af/foreignFull/surnameFirst": { "family": { "expected": "van den Berg", "got": "van" }, "family_base": { "expected": "Berg", "got": "van" }, "family_particles": { "expected": "van den", "got": "" }, "given_names": { "expected": "Cornelia Margarita Sofia Aletta", "got": "den Berg Cornelia Margarita Sofia Aletta" }, "text": "van den Berg Cornelia Margarita Sofia Aletta MBChB PhD" }, "af/foreignG/sorting": { "family": { "expected": "", "got": "Sarel" }, "given_names": { "expected": "Sarel", "got": "" }, "text": "Sarel" }, "af/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sarel" }, "given_names": { "expected": "Sarel", "got": "" }, "text": "Sarel" }, "af/nativeG/sorting": { "family": { "expected": "", "got": "Piet" }, "given_names": { "expected": "Piet", "got": "" }, "text": "Piet" }, "af/nativeG/surnameFirst": { "family": { "expected": "", "got": "Piet" }, "given_names": { "expected": "Piet", "got": "" }, "text": "Piet" }, "af/nativeGGS/surnameFirst": { "family": { "expected": "Van der Merwe", "got": "Van" }, "given_names": { "expected": "Jan Koos", "got": "der Merwe Jan Koos" }, "text": "Van der Merwe Jan Koos" }, "ak/foreignFull/surnameFirst": { "family": { "expected": "von Brühl González Domingo", "got": "von" }, "family_base": { "expected": "Brühl González Domingo", "got": "von" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "Ada Cornelia César Martín", "got": "Brühl González Domingo Prof. Dr. Ada Cornelia César Martín" }, "text": "von Brühl González Domingo Prof. Dr. Ada Cornelia César Martín MD DDS", "title": { "expected": "Prof. Dr.", "got": "" } }, "ak/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "ak/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "ak/nativeFull/sorting": { "given_names": { "expected": "Bertram Wilberforce Henry Robert", "got": "Owura Bertram Wilberforce Henry Robert" }, "text": "Wooster Owura Bertram Wilberforce Henry Robert MP", "title": { "expected": "Owura", "got": "" } }, "ak/nativeFull/surnameFirst": { "given_names": { "expected": "Bertram Wilberforce Henry Robert", "got": "Owura Bertram Wilberforce Henry Robert" }, "text": "Wooster Owura Bertram Wilberforce Henry Robert MP", "title": { "expected": "Owura", "got": "" } }, "ak/nativeG/sorting": { "family": { "expected": "", "got": "Zendaya" }, "given_names": { "expected": "Zendaya", "got": "" }, "text": "Zendaya" }, "ak/nativeG/surnameFirst": { "family": { "expected": "", "got": "Zendaya" }, "given_names": { "expected": "Zendaya", "got": "" }, "text": "Zendaya" }, "am/foreignFull/givenFirst": { "family": { "expected": "ቮን ብሩህል ጎንዛሌዝ ዶሚንጎ", "got": "ዶ/ር" }, "family_base": { "expected": "ብሩህል ጎንዛሌዝ ዶሚንጎ", "got": "ዶ/ር" }, "family_particles": { "expected": "ቮን", "got": "" }, "given_names": { "expected": "አዳ ኮርኔሊያ ሴዛር ማርቲን", "got": "ፕ/ር ዶ/ር አዳ ኮርኔሊያ ሴዛር ማርቲን ቮን ብሩህል ጎንዛሌዝ ዶሚንጎ ዶ/ር የጥርስ" }, "suffix": { "expected": "ዶ/ር የጥርስ ዶ/ር", "got": "" }, "text": "ፕ/ር ዶ/ር አዳ ኮርኔሊያ ሴዛር ማርቲን ቮን ብሩህል ጎንዛሌዝ ዶሚንጎ ዶ/ር የጥርስ ዶ/ር", "title": { "expected": "ፕ/ር ዶ/ር", "got": "" } }, "am/foreignFull/sorting": { "family_base": { "expected": "ብሩህል ጎንዛሌዝ ዶሚንጎ", "got": "ቮን ብሩህል ጎንዛሌዝ ዶሚንጎ" }, "family_particles": { "expected": "ቮን", "got": "" }, "given_names": { "expected": "አዳ ኮርኔሊያ ሴዛር ማርቲን", "got": "ፕ/ር ዶ/ር አዳ ኮርኔሊያ ሴዛር ማርቲን ዶ/ር የጥርስ ዶ/ር" }, "suffix": { "expected": "ዶ/ር የጥርስ ዶ/ር", "got": "" }, "text": "ቮን ብሩህል ጎንዛሌዝ ዶሚንጎ, ፕ/ር ዶ/ር አዳ ኮርኔሊያ ሴዛር ማርቲን ዶ/ር የጥርስ ዶ/ር", "title": { "expected": "ፕ/ር ዶ/ር", "got": "" } }, "am/foreignFull/surnameFirst": { "family": { "expected": "ቮን ብሩህል ጎንዛሌዝ ዶሚንጎ", "got": "ቮን" }, "family_base": { "expected": "ብሩህል ጎንዛሌዝ ዶሚንጎ", "got": "ቮን" }, "family_particles": { "expected": "ቮን", "got": "" }, "given_names": { "expected": "አዳ ኮርኔሊያ ሴዛር ማርቲን", "got": "ብሩህል ጎንዛሌዝ ዶሚንጎ ፕ/ር ዶ/ር አዳ ኮርኔሊያ ሴዛር ማርቲን ዶ/ር የጥርስ ዶ/ር" }, "suffix": { "expected": "ዶ/ር የጥርስ ዶ/ር", "got": "" }, "text": "ቮን ብሩህል ጎንዛሌዝ ዶሚንጎ ፕ/ር ዶ/ር አዳ ኮርኔሊያ ሴዛር ማርቲን ዶ/ር የጥርስ ዶ/ር", "title": { "expected": "ፕ/ር ዶ/ር", "got": "" } }, "am/foreignG/sorting": { "family": { "expected": "", "got": "ሲንባድ" }, "given_names": { "expected": "ሲንባድ", "got": "" }, "text": "ሲንባድ" }, "am/foreignG/surnameFirst": { "family": { "expected": "", "got": "ሲንባድ" }, "given_names": { "expected": "ሲንባድ", "got": "" }, "text": "ሲንባድ" }, "am/nativeFull/givenFirst": { "family": { "expected": "ዉስተር", "got": "አባል" }, "family_base": { "expected": "ዉስተር", "got": "አባል" }, "given_names": { "expected": "በርትራም ዊልበርፎርስ ሄነሪ ሮበርት", "got": "አቶ በርትራም ዊልበርፎርስ ሄነሪ ሮበርት ዉስተር የፓርላማ" }, "suffix": { "expected": "የፓርላማ አባል", "got": "" }, "text": "አቶ በርትራም ዊልበርፎርስ ሄነሪ ሮበርት ዉስተር የፓርላማ አባል", "title": { "expected": "አቶ", "got": "" } }, "am/nativeFull/sorting": { "given_names": { "expected": "በርትራም ዊልበርፎርስ ሄነሪ ሮበርት", "got": "አቶ በርትራም ዊልበርፎርስ ሄነሪ ሮበርት የፓርላማ አባል" }, "suffix": { "expected": "የፓርላማ አባል", "got": "" }, "text": "ዉስተር አቶ በርትራም ዊልበርፎርስ ሄነሪ ሮበርት የፓርላማ አባል", "title": { "expected": "አቶ", "got": "" } }, "am/nativeFull/surnameFirst": { "given_names": { "expected": "በርትራም ዊልበርፎርስ ሄነሪ ሮበርት", "got": "አቶ በርትራም ዊልበርፎርስ ሄነሪ ሮበርት የፓርላማ አባል" }, "suffix": { "expected": "የፓርላማ አባል", "got": "" }, "text": "ዉስተር አቶ በርትራም ዊልበርፎርስ ሄነሪ ሮበርት የፓርላማ አባል", "title": { "expected": "አቶ", "got": "" } }, "am/nativeG/sorting": { "family": { "expected": "", "got": "ዜንዳያ" }, "given_names": { "expected": "ዜንዳያ", "got": "" }, "text": "ዜንዳያ" }, "am/nativeG/surnameFirst": { "family": { "expected": "", "got": "ዜንዳያ" }, "given_names": { "expected": "ዜንዳያ", "got": "" }, "text": "ዜንዳያ" }, "ar/foreignFull/givenFirst": { "family": { "expected": "فون برول", "got": "عايدة كورنيليا سيزار مارتن فون برول" }, "family_base": { "expected": "برول", "got": "عايدة كورنيليا سيزار مارتن فون برول" }, "family_particles": { "expected": "فون", "got": "" }, "given_names": { "expected": "عايدة كورنيليا سيزار مارتن", "got": "طبيب وجراح" }, "suffix": { "expected": "الإبن طبيب وجراح", "got": "الإبن" }, "text": "الدكتور عايدة كورنيليا سيزار مارتن فون برول الإبن، طبيب وجراح" }, "ar/foreignFull/sorting": { "family_base": { "expected": "برول", "got": "فون برول" }, "family_particles": { "expected": "فون", "got": "" }, "text": "فون برول، عايدة كورنيليا سيزار مارتن" }, "ar/foreignFull/surnameFirst": { "family_base": { "expected": "برول", "got": "فون برول" }, "family_particles": { "expected": "فون", "got": "" }, "text": "فون برول، عايدة كورنيليا سيزار مارتن" }, "ar/foreignG/sorting": { "family": { "expected": "", "got": "سندباد" }, "given_names": { "expected": "سندباد", "got": "" }, "text": "سندباد" }, "ar/foreignG/surnameFirst": { "family": { "expected": "", "got": "سندباد" }, "given_names": { "expected": "سندباد", "got": "" }, "text": "سندباد" }, "ar/nativeFull/givenFirst": { "family": { "expected": "أبو الأغا", "got": "السيد أحمد رامي نجيب محفوظ أبو الأغا" }, "family_base": { "expected": "الأغا", "got": "السيد أحمد رامي نجيب محفوظ الأغا" }, "given_names": { "expected": "أحمد رامي نجيب محفوظ", "got": "النائب" }, "suffix": { "expected": "الابن النائب", "got": "الابن" }, "text": "السيد أحمد رامي نجيب محفوظ أبو الأغا الابن، النائب", "title": { "expected": "السيد", "got": "" } }, "ar/nativeG/sorting": { "family": { "expected": "", "got": "منير" }, "given_names": { "expected": "منير", "got": "" }, "text": "منير" }, "ar/nativeG/surnameFirst": { "family": { "expected": "", "got": "منير" }, "given_names": { "expected": "منير", "got": "" }, "text": "منير" }, "as/foreignFull/givenFirst": { "family": { "expected": "ভেন ডেন কুকুৰনেচীয়া", "got": "এছ" }, "family_base": { "expected": "কুকুৰনেচীয়া", "got": "এছ" }, "family_particles": { "expected": "ভেন ডেন", "got": "" }, "given_names": { "expected": "আডা কৰ্ণেলিয়া ইভা চ’ফিয়া", "got": "আডা কৰ্ণেলিয়া ইভা চ’ফিয়া ভেন ডেন কুকুৰনেচীয়া এম. ডি. ডি. ডি." }, "suffix": { "expected": "এম. ডি. ডি. ডি. এছ", "got": "" }, "text": "আডা কৰ্ণেলিয়া ইভা চ’ফিয়া ভেন ডেন কুকুৰনেচীয়া এম. ডি. ডি. ডি. এছ" }, "as/foreignFull/sorting": { "family": { "expected": "কুকুৰনেচীয়া ভেন ডেন", "got": "কুকুৰনেচীয়া" }, "family_particles": { "expected": "ভেন ডেন", "got": "" }, "given_names": { "expected": "আডা কৰ্ণেলিয়া ইভা চ’ফিয়া", "got": "আডা কৰ্ণেলিয়া ইভা চ’ফিয়া ভেন ডেন" }, "text": "কুকুৰনেচীয়া, আডা কৰ্ণেলিয়া ইভা চ’ফিয়া ভেন ডেন" }, "as/foreignFull/surnameFirst": { "family": { "expected": "ভেন ডেন কুকুৰনেচীয়া", "got": "ভেন" }, "family_base": { "expected": "কুকুৰনেচীয়া", "got": "ভেন" }, "family_particles": { "expected": "ভেন ডেন", "got": "" }, "given_names": { "expected": "আডা কৰ্ণেলিয়া ইভা চ’ফিয়া", "got": "ডেন কুকুৰনেচীয়া আডা কৰ্ণেলিয়া ইভা চ’ফিয়া এম. ডি. ডি. ডি. এছ" }, "suffix": { "expected": "এম. ডি. ডি. ডি. এছ", "got": "" }, "text": "ভেন ডেন কুকুৰনেচীয়া আডা কৰ্ণেলিয়া ইভা চ’ফিয়া এম. ডি. ডি. ডি. এছ" }, "as/foreignG/sorting": { "family": { "expected": "", "got": "সত্যৰাম" }, "given_names": { "expected": "সত্যৰাম", "got": "" }, "text": "সত্যৰাম" }, "as/foreignG/surnameFirst": { "family": { "expected": "", "got": "সত্যৰাম" }, "given_names": { "expected": "সত্যৰাম", "got": "" }, "text": "সত্যৰাম" }, "as/nativeFull/givenFirst": { "family": { "expected": "কাকতি", "got": "পি." }, "family_base": { "expected": "কাকতি", "got": "পি." }, "given_names": { "expected": "নৰেন কলিতা ৰুমী বৰুৱা", "got": "নৰেন কলিতা ৰুমী বৰুৱা কাকতি এম." }, "suffix": { "expected": "এম. পি.", "got": "" }, "text": "নৰেন কলিতা ৰুমী বৰুৱা কাকতি এম. পি." }, "as/nativeFull/surnameFirst": { "given_names": { "expected": "নৰেন কলিতা ৰুমী বৰুৱা", "got": "নৰেন কলিতা ৰুমী বৰুৱা এম. পি." }, "suffix": { "expected": "এম. পি.", "got": "" }, "text": "কাকতি নৰেন কলিতা ৰুমী বৰুৱা এম. পি." }, "as/nativeG/sorting": { "family": { "expected": "", "got": "কিশোৰ" }, "given_names": { "expected": "কিশোৰ", "got": "" }, "text": "কিশোৰ" }, "as/nativeG/surnameFirst": { "family": { "expected": "", "got": "কিশোৰ" }, "given_names": { "expected": "কিশোৰ", "got": "" }, "text": "কিশোৰ" }, "az/foreignFull/surnameFirst": { "family": { "expected": "van den Volf", "got": "van" }, "family_base": { "expected": "Volf", "got": "van" }, "family_particles": { "expected": "van den", "got": "" }, "given_names": { "expected": "Ada Kornelia Sezar Martin", "got": "den Volf Ada Kornelia Sezar Martin" }, "text": "van den Volf Ada Kornelia Sezar Martin M.D. Ph.D." }, "az/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "az/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "az/nativeFull/givenFirst": { "family": { "expected": "Aydan", "got": "Deputat" }, "family_base": { "expected": "Aydan", "got": "Deputat" }, "given_names": { "expected": "Arif Aliyev İlvus Santak", "got": "Cənab Arif Aliyev İlvus Santak Aydan" }, "suffix": { "expected": "Deputat", "got": "" }, "text": "Cənab Arif Aliyev İlvus Santak Aydan Deputat", "title": { "expected": "Cənab", "got": "" } }, "az/nativeFull/sorting": { "given_names": { "expected": "Arif Aliyev İlvus Santak", "got": "Cənab Arif Aliyev İlvus Santak Deputat" }, "suffix": { "expected": "Deputat", "got": "" }, "text": "Aydan Cənab Arif Aliyev İlvus Santak Deputat", "title": { "expected": "Cənab", "got": "" } }, "az/nativeFull/surnameFirst": { "given_names": { "expected": "Arif Aliyev İlvus Santak", "got": "Arif Aliyev İlvus Santak Deputat" }, "suffix": { "expected": "Deputat", "got": "" }, "text": "Aydan Arif Aliyev İlvus Santak Deputat" }, "az/nativeG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "az/nativeG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "ba/foreignFull/givenFirst": { "family": { "expected": "фон Брюль", "got": "проф." }, "family_base": { "expected": "Брюль", "got": "проф." }, "family_particles": { "expected": "фон", "got": "" }, "given_names": { "expected": "Ада Корнелия Сезар Мартин", "got": "Ада Корнелия Сезар Мартин фон Брюль кесе" }, "suffix": { "expected": "кесе м. ф. д.", "got": "м. ф. д." }, "text": "проф., д-р Ада Корнелия Сезар Мартин фон Брюль кесе, м. ф. д.", "title": { "expected": "проф., д-р", "got": "д-р" } }, "ba/foreignFull/sorting": { "family": { "expected": "Брюль фон", "got": "Брюль" }, "family_particles": { "expected": "фон", "got": "" }, "given_names": { "expected": "Ада Корнелия Сезар Мартин", "got": "Ада Корнелия Сезар Мартин фон" }, "text": "Брюль, Ада Корнелия Сезар Мартин фон" }, "ba/foreignFull/surnameFirst": { "family": { "expected": "фон Брюль", "got": "фон Брюль проф." }, "family_base": { "expected": "Брюль", "got": "фон Брюль проф." }, "family_particles": { "expected": "фон", "got": "" }, "given_names": { "expected": "Ада Корнелия Сезар Мартин", "got": "Ада Корнелия Сезар Мартин кесе" }, "suffix": { "expected": "кесе м. ф. д.", "got": "м. ф. д." }, "text": "фон Брюль проф., д-р Ада Корнелия Сезар Мартин кесе, м. ф. д.", "title": { "expected": "проф., д-р", "got": "д-р" } }, "ba/foreignG/sorting": { "family": { "expected": "", "got": "Синдбад" }, "given_names": { "expected": "Синдбад", "got": "" }, "text": "Синдбад" }, "ba/foreignG/surnameFirst": { "family": { "expected": "", "got": "Синдбад" }, "given_names": { "expected": "Синдбад", "got": "" }, "text": "Синдбад" }, "ba/nativeG/sorting": { "family": { "expected": "", "got": "Урал" }, "given_names": { "expected": "Урал", "got": "" }, "text": "Урал" }, "ba/nativeG/surnameFirst": { "family": { "expected": "", "got": "Урал" }, "given_names": { "expected": "Урал", "got": "" }, "text": "Урал" }, "bal_Latn/foreignFull/surnameFirst": { "family": { "expected": "von Brühl González Domingo", "got": "von" }, "family_base": { "expected": "Brühl González Domingo", "got": "von" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "Ada Cornelia César Martín", "got": "Brühl González Domingo Prop. Dr. Ada Cornelia César Martín" }, "text": "von Brühl González Domingo Prop. Dr. Ada Cornelia César Martín MD DDS", "title": { "expected": "Prop. Dr.", "got": "" } }, "bal_Latn/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "bal_Latn/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "bal_Latn/nativeFull/sorting": { "given_names": { "expected": "Monir Mómen Monir Mómen", "got": "Wj. Monir Mómen Monir Mómen" }, "text": "Balóch Wj. Monir Mómen Monir Mómen", "title": { "expected": "Wj.", "got": "" } }, "bal_Latn/nativeFull/surnameFirst": { "given_names": { "expected": "Monir Mómen Monir Mómen", "got": "Wj. Monir Mómen Monir Mómen" }, "text": "Balóch Wj. Monir Mómen Monir Mómen", "title": { "expected": "Wj.", "got": "" } }, "bal_Latn/nativeG/sorting": { "family": { "expected": "", "got": "Karima" }, "given_names": { "expected": "Karima", "got": "" }, "text": "Karima" }, "bal_Latn/nativeG/surnameFirst": { "family": { "expected": "", "got": "Karima" }, "given_names": { "expected": "Karima", "got": "" }, "text": "Karima" }, "be/foreignFull/givenFirst": { "family": { "expected": "фон Бруль", "got": "д-р" }, "family_base": { "expected": "Бруль", "got": "д-р" }, "family_particles": { "expected": "фон", "got": "" }, "given_names": { "expected": "Ада Карнэлія Сесар Марцін", "got": "Ада Карнэлія Сесар Марцін фон Бруль" }, "suffix": { "expected": "д-р", "got": "" }, "text": "Ада Карнэлія Сесар Марцін фон Бруль д-р" }, "be/foreignFull/sorting": { "family": { "expected": "Бруль фон", "got": "Бруль" }, "family_particles": { "expected": "фон", "got": "" }, "given_names": { "expected": "Ада Карнэлія Сесар Марцін", "got": "Ада Карнэлія Сесар Марцін фон" }, "text": "Бруль, Ада Карнэлія Сесар Марцін фон" }, "be/foreignFull/surnameFirst": { "family": { "expected": "фон Бруль", "got": "фон" }, "family_base": { "expected": "Бруль", "got": "фон" }, "family_particles": { "expected": "фон", "got": "" }, "given_names": { "expected": "Ада Карнэлія Сесар Марцін", "got": "Бруль Ада Карнэлія Сесар Марцін д-р" }, "suffix": { "expected": "д-р", "got": "" }, "text": "фон Бруль Ада Карнэлія Сесар Марцін д-р" }, "be/foreignG/sorting": { "family": { "expected": "", "got": "Сіндбад" }, "given_names": { "expected": "Сіндбад", "got": "" }, "text": "Сіндбад" }, "be/foreignG/surnameFirst": { "family": { "expected": "", "got": "Сіндбад" }, "given_names": { "expected": "Сіндбад", "got": "" }, "text": "Сіндбад" }, "be/nativeG/sorting": { "family": { "expected": "", "got": "Васіль" }, "given_names": { "expected": "Васіль", "got": "" }, "text": "Васіль" }, "be/nativeG/surnameFirst": { "family": { "expected": "", "got": "Васіль" }, "given_names": { "expected": "Васіль", "got": "" }, "text": "Васіль" }, "bg/foreignFull/givenFirst": { "family": { "expected": "Брюл", "got": "Ада Корнелия Цезар Мартин Брюл" }, "family_base": { "expected": "Брюл", "got": "Ада Корнелия Цезар Мартин Брюл" }, "given_names": { "expected": "Ада Корнелия Цезар Мартин", "got": "доктор" }, "suffix": { "expected": "доктор", "got": "" }, "text": "проф. д-р Ада Корнелия Цезар Мартин Брюл, доктор" }, "bg/foreignFull/sorting": { "family": { "expected": "Брюл фон", "got": "Брюл" }, "family_particles": { "expected": "фон", "got": "" }, "given_names": { "expected": "Ада Корнелия Цезар Мартин", "got": "Ада Корнелия Цезар Мартин фон" }, "text": "Брюл, Ада Корнелия Цезар Мартин фон" }, "bg/foreignG/sorting": { "family": { "expected": "", "got": "Джон" }, "given_names": { "expected": "Джон", "got": "" }, "text": "Джон" }, "bg/foreignG/surnameFirst": { "family": { "expected": "", "got": "Джон" }, "given_names": { "expected": "Джон", "got": "" }, "text": "Джон" }, "bg/nativeFull/givenFirst": { "suffix": { "expected": "проф. д-р", "got": "" }, "text": "г-н Иван Асен Вернер, проф. д-р", "title": { "expected": "г-н", "got": "г-н проф. д-р" } }, "bg/nativeFull/sorting": { "family": { "expected": "Вернер фон", "got": "Вернер" }, "family_particles": { "expected": "фон", "got": "" }, "given_names": { "expected": "Иван Асен", "got": "Иван Асен фон" }, "text": "Вернер, Иван Асен фон" }, "bg/nativeFull/surnameFirst": { "suffix": { "expected": "проф. д-р", "got": "" }, "text": "г-н Вернер, Иван Асен, проф. д-р", "title": { "expected": "г-н", "got": "г-н проф. д-р" } }, "bg/nativeG/sorting": { "family": { "expected": "", "got": "Иван" }, "given_names": { "expected": "Иван", "got": "" }, "text": "Иван" }, "bg/nativeG/surnameFirst": { "family": { "expected": "", "got": "Иван" }, "given_names": { "expected": "Иван", "got": "" }, "text": "Иван" }, "blo/foreignFull/givenFirst": { "family": { "expected": "fan ƉAM", "got": "Fostɛɛŋ" }, "family_base": { "expected": "ƉAM", "got": "Fostɛɛŋ" }, "family_particles": { "expected": "fan", "got": "" }, "given_names": { "expected": "Samʊwɛl Fostɛɛŋ", "got": "MƉ Samʊwɛl fan ƉAM" }, "suffix": { "expected": "MƉ", "got": "" }, "text": "Prof. Ɖr. MƉ Samʊwɛl fan ƉAM Fostɛɛŋ" }, "blo/foreignFull/sorting": { "family": { "expected": "fan ƉAM", "got": "fan" }, "family_base": { "expected": "ƉAM", "got": "fan" }, "family_particles": { "expected": "fan", "got": "" }, "given_names": { "expected": "Fostɛɛŋ Samʊwɛl", "got": "ƉAM Fostɛɛŋ Samʊwɛl" }, "text": "fan ƉAM Fostɛɛŋ Samʊwɛl" }, "blo/foreignFull/surnameFirst": { "family": { "expected": "fan ƉAM ƉOMIŊGO", "got": "MƉ" }, "family_base": { "expected": "ƉAM ƉOMIŊGO", "got": "MƉ" }, "family_particles": { "expected": "fan", "got": "" }, "given_names": { "expected": "Fostɛɛŋ Samʊwɛl", "got": "fan ƉAM ƉOMIŊGO Fostɛɛŋ Samʊwɛl" }, "suffix": { "expected": "MƉ", "got": "" }, "text": "Prof. Ɖr. MƉ fan ƉAM ƉOMIŊGO Fostɛɛŋ Samʊwɛl" }, "blo/foreignG/sorting": { "family": { "expected": "", "got": "Eemil" }, "given_names": { "expected": "Eemil", "got": "" }, "text": "Eemil" }, "blo/foreignG/surnameFirst": { "family": { "expected": "", "got": "Eemil" }, "given_names": { "expected": "Eemil", "got": "" }, "text": "Eemil" }, "blo/foreignGGS/givenFirst": { "family": { "expected": "MANƉELA", "got": "Rolanɖ" }, "given_names": { "expected": "Alfɔns Rolanɖ", "got": "Alfɔns MANƉELA" }, "text": "Alfɔns MANƉELA Rolanɖ" }, "blo/nativeFull/givenFirst": { "family": { "expected": "KALAM", "got": "Alɛ" }, "family_base": { "expected": "KALAM", "got": "Alɛ" }, "given_names": { "expected": "Mustafa Alɛ", "got": "ʊŋono Alaaji Mustafa KALAM" }, "suffix": { "expected": "Alaaji", "got": "" }, "text": "ʊŋono Alaaji Mustafa KALAM Alɛ", "title": { "expected": "ʊŋono", "got": "" } }, "blo/nativeFull/surnameFirst": { "family": { "expected": "KALAM", "got": "ʊŋono" }, "family_base": { "expected": "KALAM", "got": "ʊŋono" }, "given_names": { "expected": "Alɛ Mustafa", "got": "Alaaji KALAM Alɛ Mustafa" }, "suffix": { "expected": "Alaaji", "got": "" }, "text": "ʊŋono Alaaji KALAM Alɛ Mustafa", "title": { "expected": "ʊŋono", "got": "" } }, "blo/nativeG/sorting": { "family": { "expected": "", "got": "Muniiru" }, "given_names": { "expected": "Muniiru", "got": "" }, "text": "Muniiru" }, "blo/nativeG/surnameFirst": { "family": { "expected": "", "got": "Muniiru" }, "given_names": { "expected": "Muniiru", "got": "" }, "text": "Muniiru" }, "blo/nativeGGS/givenFirst": { "family": { "expected": "GOMINA", "got": "MUSA" }, "given_names": { "expected": "Abɖu-Tɔyib MUSA", "got": "Abɖu-Tɔyib GOMINA" }, "text": "Abɖu-Tɔyib GOMINA MUSA" }, "bn/foreignFull/givenFirst": { "family": { "expected": "রহমান আলি তালুকদার", "got": "এম.ডি.পিএইচ.ডি." }, "family_base": { "expected": "আলি তালুকদার", "got": "এম.ডি.পিএইচ.ডি." }, "family_particles": { "expected": "রহমান", "got": "" }, "given_names": { "expected": "আদিরা ইভা সোফিয়া", "got": "প্রঃ ডঃ আদিরা ইভা সোফিয়া রহমান আলি তালুকদার" }, "suffix": { "expected": "এম.ডি.পিএইচ.ডি.", "got": "" }, "text": "প্রঃ ডঃ আদিরা ইভা সোফিয়া রহমান আলি তালুকদার এম.ডি.পিএইচ.ডি.", "title": { "expected": "প্রঃ ডঃ", "got": "" } }, "bn/foreignFull/sorting": { "family_base": { "expected": "আলি তালুকদার", "got": "রহমান আলি তালুকদার" }, "family_particles": { "expected": "রহমান", "got": "" }, "given_names": { "expected": "আদিরা ইভা সোফিয়া", "got": "প্রঃ ডঃ আদিরা ইভা সোফিয়া এম.ডি.পিএইচ.ডি." }, "suffix": { "expected": "এম.ডি.পিএইচ.ডি.", "got": "" }, "text": "রহমান আলি তালুকদার, প্রঃ ডঃ আদিরা ইভা সোফিয়া এম.ডি.পিএইচ.ডি.", "title": { "expected": "প্রঃ ডঃ", "got": "" } }, "bn/foreignFull/surnameFirst": { "family": { "expected": "রহমান আলি তালুকদার", "got": "রহমান" }, "family_base": { "expected": "আলি তালুকদার", "got": "রহমান" }, "family_particles": { "expected": "রহমান", "got": "" }, "given_names": { "expected": "আদিরা ইভা সোফিয়া", "got": "আলি তালুকদার প্রঃ ডঃ আদিরা ইভা সোফিয়া এম.ডি.পিএইচ.ডি." }, "suffix": { "expected": "এম.ডি.পিএইচ.ডি.", "got": "" }, "text": "রহমান আলি তালুকদার প্রঃ ডঃ আদিরা ইভা সোফিয়া এম.ডি.পিএইচ.ডি.", "title": { "expected": "প্রঃ ডঃ", "got": "" } }, "bn/foreignG/sorting": { "family": { "expected": "", "got": "সিনবাদ" }, "given_names": { "expected": "সিনবাদ", "got": "" }, "text": "সিনবাদ" }, "bn/foreignG/surnameFirst": { "family": { "expected": "", "got": "সিনবাদ" }, "given_names": { "expected": "সিনবাদ", "got": "" }, "text": "সিনবাদ" }, "bn/nativeFull/givenFirst": { "family": { "expected": "গাঙ্গুলী", "got": "এমপি" }, "family_base": { "expected": "গাঙ্গুলী", "got": "এমপি" }, "given_names": { "expected": "এমদাদ আলি অনিল কুমার", "got": "এমদাদ আলি অনিল কুমার গাঙ্গুলী" }, "suffix": { "expected": "এমপি", "got": "" }, "text": "জনাব এমদাদ আলি অনিল কুমার গাঙ্গুলী এমপি" }, "bn/nativeFull/sorting": { "given_names": { "expected": "এমদাদ আলি অনিল কুমার", "got": "জনাব এমদাদ আলি অনিল কুমার এমপি" }, "suffix": { "expected": "এমপি", "got": "" }, "text": "গাঙ্গুলী জনাব এমদাদ আলি অনিল কুমার এমপি", "title": { "expected": "জনাব", "got": "" } }, "bn/nativeFull/surnameFirst": { "given_names": { "expected": "এমদাদ আলি অনিল কুমার", "got": "জনাব এমদাদ আলি অনিল কুমার এমপি" }, "suffix": { "expected": "এমপি", "got": "" }, "text": "গাঙ্গুলী জনাব এমদাদ আলি অনিল কুমার এমপি", "title": { "expected": "জনাব", "got": "" } }, "bn/nativeG/sorting": { "family": { "expected": "", "got": "জেনদায়া" }, "given_names": { "expected": "জেনদায়া", "got": "" }, "text": "জেনদায়া" }, "bn/nativeG/surnameFirst": { "family": { "expected": "", "got": "জেনদায়া" }, "given_names": { "expected": "জেনদায়া", "got": "" }, "text": "জেনদায়া" }, "bs/foreignFull/givenFirst": { "suffix": { "expected": "dipl. stom.", "got": "" }, "text": "prof. dr. Izeta Amra von Selimović Hodžić, dipl. stom.", "title": { "expected": "prof. dr.", "got": "prof. dr. dipl. stom." } }, "bs/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "bs/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "bs/nativeFull/givenFirst": { "family": { "expected": "Hodžić", "got": "parlamentu" }, "family_base": { "expected": "Hodžić", "got": "parlamentu" }, "given_names": { "expected": "Adnan Mirza", "got": "g. Adnan Mirza Hodžić zastupnik u" }, "suffix": { "expected": "zastupnik u parlamentu", "got": "" }, "text": "g. Adnan Mirza Hodžić zastupnik u parlamentu", "title": { "expected": "g.", "got": "" } }, "bs/nativeFull/sorting": { "family": { "expected": "Hodžić", "got": "g. Hodžić Adnan Mirza" }, "family_base": { "expected": "Hodžić", "got": "g. Hodžić Adnan Mirza" }, "given_names": { "expected": "Adnan Mirza", "got": "zastupnik u parlamentu" }, "suffix": { "expected": "zastupnik u parlamentu", "got": "" }, "text": "g. Hodžić Adnan Mirza, zastupnik u parlamentu", "title": { "expected": "g.", "got": "" } }, "bs/nativeFull/surnameFirst": { "family": { "expected": "Hodžić", "got": "g. Hodžić Adnan Mirza" }, "family_base": { "expected": "Hodžić", "got": "g. Hodžić Adnan Mirza" }, "given_names": { "expected": "Adnan Mirza", "got": "zastupnik u parlamentu" }, "suffix": { "expected": "zastupnik u parlamentu", "got": "" }, "text": "g. Hodžić Adnan Mirza, zastupnik u parlamentu", "title": { "expected": "g.", "got": "" } }, "bs/nativeG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "bs/nativeG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "ca/foreignFull/givenFirst": { "suffix": { "expected": "Excma.", "got": "" }, "text": "Excma. Dra. Maria da Graça César Martín van Dalen Hoogeveen", "title": { "expected": "Dra.", "got": "Excma. Dra." } }, "ca/foreignFull/sorting": { "family": { "expected": "Dalen Hoogeveen", "got": "Van Dalen Hoogeveen" }, "family_particles": { "expected": "", "got": "Van" }, "text": "Van Dalen Hoogeveen, Maria da Graça César Martín" }, "ca/foreignFull/surnameFirst": { "family": { "expected": "Dalen Hoogeveen", "got": "Excma. Dra. Van Dalen Hoogeveen" }, "family_base": { "expected": "Dalen Hoogeveen", "got": "Excma. Dra. Dalen Hoogeveen" }, "family_particles": { "expected": "", "got": "Van" }, "suffix": { "expected": "Excma. II", "got": "II" }, "text": "Excma. Dra. Van Dalen Hoogeveen II, Maria da Graça César Martín", "title": { "expected": "Dra.", "got": "" } }, "ca/foreignG/sorting": { "family": { "expected": "", "got": "Iñaki" }, "given_names": { "expected": "Iñaki", "got": "" }, "text": "Iñaki" }, "ca/foreignG/surnameFirst": { "family": { "expected": "", "got": "Iñaki" }, "given_names": { "expected": "Iñaki", "got": "" }, "text": "Iñaki" }, "ca/nativeFull/givenFirst": { "family": { "expected": "Lloret Palol", "got": "Palol" }, "family_base": { "expected": "Lloret Palol", "got": "Palol" }, "given_names": { "expected": "Josep Antoni Carles Joan", "got": "Josep Antoni Carles Joan Lloret" }, "suffix": { "expected": "Excm.", "got": "" }, "text": "Excm. Sr. Josep Antoni Carles Joan Lloret Palol", "title": { "expected": "Sr.", "got": "Excm. Sr." } }, "ca/nativeFull/surnameFirst": { "family": { "expected": "Lloret Palol", "got": "Excm. Lloret Palol" }, "family_base": { "expected": "Lloret Palol", "got": "Excm. Lloret Palol" }, "suffix": { "expected": "Excm. II", "got": "Sr., II" }, "text": "Excm. Sr. Lloret Palol II, Josep Antoni Carles Joan", "title": { "expected": "Sr.", "got": "" } }, "ca/nativeG/sorting": { "family": { "expected": "", "got": "Carolina" }, "given_names": { "expected": "Carolina", "got": "" }, "text": "Carolina" }, "ca/nativeG/surnameFirst": { "family": { "expected": "", "got": "Carolina" }, "given_names": { "expected": "Carolina", "got": "" }, "text": "Carolina" }, "chr/foreignFull/givenFirst": { "family": { "expected": "ᏉᏂ ᏇᎯ", "got": "Ꮙ. ᏅᏬᏘ ᎠᏓ ᎪᏁᎵᎠ ᏏᏌ ᎹᏘᏂ ᏉᏂ ᏇᎯ ᏔᎵᏁ" }, "family_base": { "expected": "ᏇᎯ", "got": "Ꮙ. ᏅᏬᏘ ᎠᏓ ᎪᏁᎵᎠ ᏏᏌ ᎹᏘᏂ ᏉᏂ ᏇᎯ ᏔᎵᏁ" }, "family_particles": { "expected": "ᏉᏂ", "got": "" }, "given_names": { "expected": "ᎠᏓ ᎪᏁᎵᎠ ᏏᏌ ᎹᏘᏂ", "got": "ᎷᎠ ᎠᎠᏚ" }, "suffix": { "expected": "ᏔᎵᏁ ᎷᎠ ᎠᎠᏚ", "got": "" }, "text": "Ꮙ. ᏅᏬᏘ ᎠᏓ ᎪᏁᎵᎠ ᏏᏌ ᎹᏘᏂ ᏉᏂ ᏇᎯ ᏔᎵᏁ, ᎷᎠ ᎠᎠᏚ", "title": { "expected": "Ꮙ. ᏅᏬᏘ", "got": "" } }, "chr/foreignFull/sorting": { "family": { "expected": "ᏇᎯ ᏉᏂ", "got": "ᏇᎯ" }, "family_particles": { "expected": "ᏉᏂ", "got": "" }, "given_names": { "expected": "ᎠᏓ ᎪᏁᎵᎠ ᏏᏌ ᎹᏘᏂ", "got": "ᎠᏓ ᎪᏁᎵᎠ ᏏᏌ ᎹᏘᏂ ᏉᏂ" }, "text": "ᏇᎯ, ᎠᏓ ᎪᏁᎵᎠ ᏏᏌ ᎹᏘᏂ ᏉᏂ" }, "chr/foreignFull/surnameFirst": { "family": { "expected": "ᏉᏂ ᏇᎯ", "got": "ᏉᏂ ᏇᎯ Ꮙ. ᏅᏬᏘ ᎠᏓ ᎪᏁᎵᎠ ᏏᏌ ᎹᏘᏂ ᏔᎵᏁ" }, "family_base": { "expected": "ᏇᎯ", "got": "ᏉᏂ ᏇᎯ Ꮙ. ᏅᏬᏘ ᎠᏓ ᎪᏁᎵᎠ ᏏᏌ ᎹᏘᏂ ᏔᎵᏁ" }, "family_particles": { "expected": "ᏉᏂ", "got": "" }, "given_names": { "expected": "ᎠᏓ ᎪᏁᎵᎠ ᏏᏌ ᎹᏘᏂ", "got": "ᎷᎠ ᎠᎠᏚ" }, "suffix": { "expected": "ᏔᎵᏁ ᎷᎠ ᎠᎠᏚ", "got": "" }, "text": "ᏉᏂ ᏇᎯ Ꮙ. ᏅᏬᏘ ᎠᏓ ᎪᏁᎵᎠ ᏏᏌ ᎹᏘᏂ ᏔᎵᏁ, ᎷᎠ ᎠᎠᏚ", "title": { "expected": "Ꮙ. ᏅᏬᏘ", "got": "" } }, "chr/foreignG/sorting": { "family": { "expected": "", "got": "ᏏᎾᏆᏗ" }, "given_names": { "expected": "ᏏᎾᏆᏗ", "got": "" }, "text": "ᏏᎾᏆᏗ" }, "chr/foreignG/surnameFirst": { "family": { "expected": "", "got": "ᏏᎾᏆᏗ" }, "given_names": { "expected": "ᏏᎾᏆᏗ", "got": "" }, "text": "ᏏᎾᏆᏗ" }, "chr/nativeFull/givenFirst": { "family": { "expected": "ᏬᏍᏖᎢ", "got": "ᎻᏍᏖᎢ ᏇᏔᎻ ᏫᏇᏉᏏ ᎮᏂᏇ ᏆᏇᏘ ᏬᏍᏖᎢ ᏔᎵᏁ" }, "family_base": { "expected": "ᏬᏍᏖᎢ", "got": "ᎻᏍᏖᎢ ᏇᏔᎻ ᏫᏇᏉᏏ ᎮᏂᏇ ᏆᏇᏘ ᏬᏍᏖᎢ ᏔᎵᏁ" }, "given_names": { "expected": "ᏇᏔᎻ ᏫᏇᏉᏏ ᎮᏂᏇ ᏆᏇᏘ", "got": "ᎷᏢ" }, "suffix": { "expected": "ᏔᎵᏁ ᎷᏢ", "got": "" }, "text": "ᎻᏍᏖᎢ ᏇᏔᎻ ᏫᏇᏉᏏ ᎮᏂᏇ ᏆᏇᏘ ᏬᏍᏖᎢ ᏔᎵᏁ, ᎷᏢ", "title": { "expected": "ᎻᏍᏖᎢ", "got": "" } }, "chr/nativeFull/surnameFirst": { "family": { "expected": "ᏬᏍᏖᎢ", "got": "ᏬᏍᏖᎢ ᎻᏍᏖᎢ ᏇᏔᎻ ᏫᏇᏉᏏ ᎮᏂᏇ ᏆᏇᏘ ᏔᎵᏁ" }, "family_base": { "expected": "ᏬᏍᏖᎢ", "got": "ᏬᏍᏖᎢ ᎻᏍᏖᎢ ᏇᏔᎻ ᏫᏇᏉᏏ ᎮᏂᏇ ᏆᏇᏘ ᏔᎵᏁ" }, "given_names": { "expected": "ᏇᏔᎻ ᏫᏇᏉᏏ ᎮᏂᏇ ᏆᏇᏘ", "got": "ᎷᏢ" }, "suffix": { "expected": "ᏔᎵᏁ ᎷᏢ", "got": "" }, "text": "ᏬᏍᏖᎢ ᎻᏍᏖᎢ ᏇᏔᎻ ᏫᏇᏉᏏ ᎮᏂᏇ ᏆᏇᏘ ᏔᎵᏁ, ᎷᏢ", "title": { "expected": "ᎻᏍᏖᎢ", "got": "" } }, "chr/nativeG/sorting": { "family": { "expected": "", "got": "ᎨᏓᏯ" }, "given_names": { "expected": "ᎨᏓᏯ", "got": "" }, "text": "ᎨᏓᏯ" }, "chr/nativeG/surnameFirst": { "family": { "expected": "", "got": "ᎨᏓᏯ" }, "given_names": { "expected": "ᎨᏓᏯ", "got": "" }, "text": "ᎨᏓᏯ" }, "cs/foreignFull/sorting": { "suffix": { "expected": "", "got": "M.D. DDS." }, "text": "Brühl, Ada Cornelia César Martín von (Prof. Dr., M.D. DDS.)", "title": { "expected": "", "got": "Prof. Dr." } }, "cs/foreignFull/surnameFirst": { "family": { "expected": "von Brühl González Domingo", "got": "von" }, "family_base": { "expected": "Brühl González Domingo", "got": "von" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "Ada Cornelia César Martín", "got": "Brühl González Domingo Ada Cornelia César Martín" }, "text": "Prof. Dr. von Brühl González Domingo Ada Cornelia César Martín, M.D. DDS." }, "cs/foreignG/sorting": { "family": { "expected": "", "got": "Jack" }, "given_names": { "expected": "Jack", "got": "" }, "text": "Jack" }, "cs/foreignG/surnameFirst": { "family": { "expected": "", "got": "Jack" }, "given_names": { "expected": "Jack", "got": "" }, "text": "Jack" }, "cs/nativeFull/givenFirst": { "family": { "expected": "Machová Ondřejová", "got": "Ondřejová" }, "family_base": { "expected": "Machová Ondřejová", "got": "Ondřejová" }, "given_names": { "expected": "Alexandra Zuzana", "got": "Alexandra Zuzana Machová" }, "text": "paní Alexandra Zuzana Machová Ondřejová, Ph.D." }, "cs/nativeFull/sorting": { "given_names": { "expected": "Alexandra Zuzana", "got": "Alexandra Zuzana paní" }, "suffix": { "expected": "", "got": "Ph.D." }, "text": "Machová, Alexandra Zuzana (paní, Ph.D.)" }, "cs/nativeFull/surnameFirst": { "family": { "expected": "Machová Ondřejová", "got": "Machová" }, "family_base": { "expected": "Machová Ondřejová", "got": "Machová" }, "given_names": { "expected": "Alexandra Zuzana", "got": "Ondřejová Alexandra Zuzana" }, "text": "paní Machová Ondřejová Alexandra Zuzana, Ph.D." }, "cs/nativeG/sorting": { "family": { "expected": "", "got": "Jiří" }, "given_names": { "expected": "Jiří", "got": "" }, "text": "Jiří" }, "cs/nativeG/surnameFirst": { "family": { "expected": "", "got": "Jiří" }, "given_names": { "expected": "Jiří", "got": "" }, "text": "Jiří" }, "cv/foreignFull/givenFirst": { "family": { "expected": "фон Брюль", "got": "проф." }, "family_base": { "expected": "Брюль", "got": "проф." }, "family_particles": { "expected": "фон", "got": "" }, "given_names": { "expected": "Ада Корнелия Сезар Мартин", "got": "Ада Корнелия Сезар Мартин фон Брюль —" }, "text": "проф., д-р Ада Корнелия Сезар Мартин фон Брюль — мл., к.м.н.", "title": { "expected": "проф., д-р", "got": "д-р" } }, "cv/foreignFull/sorting": { "family": { "expected": "Брюль фон", "got": "Брюль" }, "family_particles": { "expected": "фон", "got": "" }, "given_names": { "expected": "Ада Корнелия Сезар Мартин", "got": "Ада Корнелия Сезар Мартин фон" }, "text": "Брюль, Ада Корнелия Сезар Мартин фон" }, "cv/foreignFull/surnameFirst": { "family": { "expected": "фон Брюль", "got": "фон Брюль проф." }, "family_base": { "expected": "Брюль", "got": "фон Брюль проф." }, "family_particles": { "expected": "фон", "got": "" }, "text": "фон Брюль проф., д-р Ада Корнелия Сезар Мартин к.м.н.", "title": { "expected": "проф., д-р", "got": "д-р" } }, "cv/foreignG/sorting": { "family": { "expected": "", "got": "Синдбад" }, "given_names": { "expected": "Синдбад", "got": "" }, "text": "Синдбад" }, "cv/foreignG/surnameFirst": { "family": { "expected": "", "got": "Синдбад" }, "given_names": { "expected": "Синдбад", "got": "" }, "text": "Синдбад" }, "cv/nativeG/sorting": { "family": { "expected": "", "got": "Аталан" }, "given_names": { "expected": "Аталан", "got": "" }, "text": "Аталан" }, "cv/nativeG/surnameFirst": { "family": { "expected": "", "got": "Аталан" }, "given_names": { "expected": "Аталан", "got": "" }, "text": "Аталан" }, "cy/foreignFull/surnameFirst": { "family": { "expected": "von Wolf", "got": "von" }, "family_base": { "expected": "Wolf", "got": "von" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "Ada Cornelia Eva Sophia", "got": "Wolf Ada Cornelia Eva Sophia" }, "text": "von Wolf Ada Cornelia Eva Sophia MD DDS" }, "cy/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "cy/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "cy/nativeFull/givenFirst": { "family": { "expected": "Wooster", "got": "AS" }, "family_base": { "expected": "Wooster", "got": "AS" }, "given_names": { "expected": "Bertram Wilberforce Henry Robert", "got": "Bertram Wilberforce Henry Robert Wooster" }, "suffix": { "expected": "AS", "got": "" }, "text": "Bertram Wilberforce Henry Robert Wooster AS" }, "cy/nativeFull/surnameFirst": { "given_names": { "expected": "Bertram Wilberforce Henry Robert", "got": "Bertram Wilberforce Henry Robert AS" }, "suffix": { "expected": "AS", "got": "" }, "text": "Wooster Bertram Wilberforce Henry Robert AS" }, "cy/nativeG/sorting": { "family": { "expected": "", "got": "Zendaya" }, "given_names": { "expected": "Zendaya", "got": "" }, "text": "Zendaya" }, "cy/nativeG/surnameFirst": { "family": { "expected": "", "got": "Zendaya" }, "given_names": { "expected": "Zendaya", "got": "" }, "text": "Zendaya" }, "da/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "da/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "da/nativeFull/givenFirst": { "family": { "expected": "Møller", "got": "Hans Henrik Sven Åge Møller" }, "family_base": { "expected": "Møller", "got": "Hans Henrik Sven Åge Møller" }, "given_names": { "expected": "Hans Henrik Sven Åge", "got": "MF" }, "suffix": { "expected": "MF", "got": "" }, "text": "hr. Hans Henrik Sven Åge Møller, MF" }, "da/nativeG/sorting": { "family": { "expected": "", "got": "Katrine" }, "given_names": { "expected": "Katrine", "got": "" }, "text": "Katrine" }, "da/nativeG/surnameFirst": { "family": { "expected": "", "got": "Katrine" }, "given_names": { "expected": "Katrine", "got": "" }, "text": "Katrine" }, "de/foreignG/sorting": { "family": { "expected": "", "got": "Tomás" }, "given_names": { "expected": "Tomás", "got": "" }, "text": "Tomás" }, "de/foreignG/surnameFirst": { "family": { "expected": "", "got": "Tomás" }, "given_names": { "expected": "Tomás", "got": "" }, "text": "Tomás" }, "de/nativeG/sorting": { "family": { "expected": "", "got": "Lena" }, "given_names": { "expected": "Lena", "got": "" }, "text": "Lena" }, "de/nativeG/surnameFirst": { "family": { "expected": "", "got": "Lena" }, "given_names": { "expected": "Lena", "got": "" }, "text": "Lena" }, "dsb/foreignG/sorting": { "family": { "expected": "", "got": "Tomás" }, "given_names": { "expected": "Tomás", "got": "" }, "text": "Tomás" }, "dsb/foreignG/surnameFirst": { "family": { "expected": "", "got": "Tomás" }, "given_names": { "expected": "Tomás", "got": "" }, "text": "Tomás" }, "dsb/nativeG/sorting": { "family": { "expected": "", "got": "Juro" }, "given_names": { "expected": "Juro", "got": "" }, "text": "Juro" }, "dsb/nativeG/surnameFirst": { "family": { "expected": "", "got": "Juro" }, "given_names": { "expected": "Juro", "got": "" }, "text": "Juro" }, "el/foreignFull/givenFirst": { "family": { "expected": "φον Μπριλ Γκονθάλεθ Δομίνγκο", "got": "Δρ.Χ.Ο" }, "family_base": { "expected": "Μπριλ Γκονθάλεθ Δομίνγκο", "got": "Δρ.Χ.Ο" }, "family_particles": { "expected": "φον", "got": "" }, "given_names": { "expected": "Άντα Κορνέλια Σέσαρ Μαρτίν", "got": "Άντα Κορνέλια Σέσαρ Μαρτίν φον Μπριλ Γκονθάλεθ Δομίνγκο Δρ.Ι." }, "suffix": { "expected": "Δρ.Ι. Δρ.Χ.Ο", "got": "" }, "text": "Καθ. δρ. Άντα Κορνέλια Σέσαρ Μαρτίν φον Μπριλ Γκονθάλεθ Δομίνγκο Δρ.Ι. Δρ.Χ.Ο" }, "el/foreignFull/sorting": { "family": { "expected": "Μπριλ φον", "got": "Μπριλ" }, "family_particles": { "expected": "φον", "got": "" }, "given_names": { "expected": "Άντα Κορνέλια Σέσαρ Μαρτίν", "got": "φον Άντα Κορνέλια Σέσαρ Μαρτίν" }, "text": "Μπριλ, φον Άντα Κορνέλια Σέσαρ Μαρτίν" }, "el/foreignFull/surnameFirst": { "family": { "expected": "φον Μπριλ", "got": "φον" }, "family_base": { "expected": "Μπριλ", "got": "φον" }, "family_particles": { "expected": "φον", "got": "" }, "given_names": { "expected": "Άντα Κορνέλια Σέσαρ Μαρτίν", "got": "Μπριλ Άντα Κορνέλια Σέσαρ Μαρτίν Δρ.Ι. Δρ.Χ.Ο" }, "suffix": { "expected": "Δρ.Ι. Δρ.Χ.Ο", "got": "" }, "text": "φον Μπριλ Άντα Κορνέλια Σέσαρ Μαρτίν Δρ.Ι. Δρ.Χ.Ο" }, "el/foreignG/sorting": { "family": { "expected": "", "got": "Σινμπάντ" }, "given_names": { "expected": "Σινμπάντ", "got": "" }, "text": "Σινμπάντ" }, "el/foreignG/surnameFirst": { "family": { "expected": "", "got": "Σινμπάντ" }, "given_names": { "expected": "Σινμπάντ", "got": "" }, "text": "Σινμπάντ" }, "el/nativeG/sorting": { "family": { "expected": "", "got": "Δημήτρης" }, "given_names": { "expected": "Δημήτρης", "got": "" }, "text": "Δημήτρης" }, "el/nativeG/surnameFirst": { "family": { "expected": "", "got": "Δημήτρης" }, "given_names": { "expected": "Δημήτρης", "got": "" }, "text": "Δημήτρης" }, "en/foreignFull/surnameFirst": { "family": { "expected": "von Brühl", "got": "von" }, "family_base": { "expected": "Brühl", "got": "von" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "Ada Cornelia César Martín", "got": "Brühl Prof. Dr. Ada Cornelia César Martín" }, "text": "von Brühl Prof. Dr. Ada Cornelia César Martín Jr, MD DDS", "title": { "expected": "Prof. Dr.", "got": "" } }, "en/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "en/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "en/nativeFull/surnameFirst": { "given_names": { "expected": "Bertram Wilberforce Henry Robert", "got": "Mr. Bertram Wilberforce Henry Robert" }, "text": "Wooster Mr. Bertram Wilberforce Henry Robert Jr, MP", "title": { "expected": "Mr.", "got": "" } }, "en/nativeG/sorting": { "family": { "expected": "", "got": "Zendaya" }, "given_names": { "expected": "Zendaya", "got": "" }, "text": "Zendaya" }, "en/nativeG/surnameFirst": { "family": { "expected": "", "got": "Zendaya" }, "given_names": { "expected": "Zendaya", "got": "" }, "text": "Zendaya" }, "es/foreignFull/surnameFirst": { "family": { "expected": "von Miller Jones", "got": "von" }, "family_base": { "expected": "Miller Jones", "got": "von" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "Mary Sue Marie", "got": "Miller Jones Mary Sue Marie" }, "text": "von Miller Jones Mary Sue Marie II, MD DDS" }, "es/foreignG/sorting": { "family": { "expected": "", "got": "Peter" }, "given_names": { "expected": "Peter", "got": "" }, "text": "Peter" }, "es/foreignG/surnameFirst": { "family": { "expected": "", "got": "Peter" }, "given_names": { "expected": "Peter", "got": "" }, "text": "Peter" }, "es/nativeFull/surnameFirst": { "family": { "expected": "Pablo Pérez", "got": "Pablo" }, "family_base": { "expected": "Pablo Pérez", "got": "Pablo" }, "given_names": { "expected": "Miguel Ángel Juan Antonio", "got": "Pérez Miguel Ángel Juan Antonio" }, "text": "Pablo Pérez Miguel Ángel Juan Antonio II" }, "es/nativeG/sorting": { "family": { "expected": "", "got": "Lola" }, "given_names": { "expected": "Lola", "got": "" }, "text": "Lola" }, "es/nativeG/surnameFirst": { "family": { "expected": "", "got": "Lola" }, "given_names": { "expected": "Lola", "got": "" }, "text": "Lola" }, "et/foreignFull/surnameFirst": { "family": { "expected": "von Vall Heinmann Laas", "got": "von" }, "family_base": { "expected": "Vall Heinmann Laas", "got": "von" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "Anna Liina Eva Sofia", "got": "Vall Heinmann Laas Prof Anna Liina Eva Sofia" }, "text": "von Vall Heinmann Laas Prof Anna Liina Eva Sofia MD, PhD", "title": { "expected": "Prof", "got": "" } }, "et/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "et/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "et/nativeFull/givenFirst": { "family": { "expected": "Tammik", "got": "Mari-Ann Henri Roobert Tammik" }, "family_base": { "expected": "Tammik", "got": "Mari-Ann Henri Roobert Tammik" }, "given_names": { "expected": "Mari-Ann Henri Roobert", "got": "parlamendiliige" }, "suffix": { "expected": "parlamendiliige", "got": "" }, "text": "Mari-Ann Henri Roobert Tammik, parlamendiliige" }, "et/nativeFull/surnameFirst": { "given_names": { "expected": "Mari-Ann Henri Roobert", "got": "hr Mari-Ann Henri Roobert parlamendiliige" }, "suffix": { "expected": "parlamendiliige", "got": "" }, "text": "Tammik hr Mari-Ann Henri Roobert parlamendiliige", "title": { "expected": "hr", "got": "" } }, "et/nativeG/sorting": { "family": { "expected": "", "got": "Mari" }, "given_names": { "expected": "Mari", "got": "" }, "text": "Mari" }, "et/nativeG/surnameFirst": { "family": { "expected": "", "got": "Mari" }, "given_names": { "expected": "Mari", "got": "" }, "text": "Mari" }, "eu/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "eu/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "eu/nativeG/sorting": { "family": { "expected": "", "got": "Maider" }, "given_names": { "expected": "Maider", "got": "" }, "text": "Maider" }, "eu/nativeG/surnameFirst": { "family": { "expected": "", "got": "Maider" }, "given_names": { "expected": "Maider", "got": "" }, "text": "Maider" }, "fa/foreignFull/givenFirst": { "family": { "expected": "وان بروهل", "got": "پروفسور آدا کورنلیا سزار مارتین وان بروهل" }, "family_base": { "expected": "بروهل", "got": "پروفسور آدا کورنلیا سزار مارتین وان بروهل" }, "family_particles": { "expected": "وان", "got": "" }, "given_names": { "expected": "آدا کورنلیا سزار مارتین", "got": "دکترای دندانپزشکی" }, "suffix": { "expected": "جونیور دکترای دندانپزشکی", "got": "جونیور" }, "text": "پروفسور آدا کورنلیا سزار مارتین وان بروهل جونیور، دکترای دندانپزشکی", "title": { "expected": "پروفسور", "got": "" } }, "fa/foreignFull/sorting": { "family": { "expected": "بروهل وان", "got": "بروهل" }, "family_particles": { "expected": "وان", "got": "" }, "given_names": { "expected": "آدا کورنلیا سزار مارتین", "got": "آدا کورنلیا سزار مارتین وان" }, "text": "بروهل، آدا کورنلیا سزار مارتین وان" }, "fa/foreignFull/surnameFirst": { "family_base": { "expected": "بروهل", "got": "وان بروهل" }, "family_particles": { "expected": "وان", "got": "" }, "given_names": { "expected": "آدا کورنلیا سزار مارتین", "got": "پروفسور آدا کورنلیا سزار مارتین" }, "text": "وان بروهل، پروفسور آدا کورنلیا سزار مارتین جونیور، دکترای دندانپزشکی", "title": { "expected": "پروفسور", "got": "" } }, "fa/foreignG/sorting": { "family": { "expected": "", "got": "سامانتا" }, "given_names": { "expected": "سامانتا", "got": "" }, "text": "سامانتا" }, "fa/foreignG/surnameFirst": { "family": { "expected": "", "got": "سامانتا" }, "given_names": { "expected": "سامانتا", "got": "" }, "text": "سامانتا" }, "fa/nativeFull/givenFirst": { "family": { "expected": "رستگار", "got": "دکتر" }, "family_base": { "expected": "رستگار", "got": "دکتر" }, "given_names": { "expected": "امیر هوشنگ", "got": "آقای امیر هوشنگ رستگار" }, "suffix": { "expected": "دکتر", "got": "" }, "text": "آقای امیر هوشنگ رستگار دکتر", "title": { "expected": "آقای", "got": "" } }, "fa/nativeFull/surnameFirst": { "given_names": { "expected": "امیر هوشنگ", "got": "آقای امیر هوشنگ دکتر" }, "suffix": { "expected": "دکتر", "got": "" }, "text": "رستگار، آقای امیر هوشنگ دکتر", "title": { "expected": "آقای", "got": "" } }, "fa/nativeG/sorting": { "family": { "expected": "", "got": "ژیلا" }, "given_names": { "expected": "ژیلا", "got": "" }, "text": "ژیلا" }, "fa/nativeG/surnameFirst": { "family": { "expected": "", "got": "ژیلا" }, "given_names": { "expected": "ژیلا", "got": "" }, "text": "ژیلا" }, "fi/foreignFull/givenFirst": { "given_names": { "expected": "Ana Christina Lëja", "got": "professori Ana Christina Lëja" }, "text": "professori Ana Christina Lëja von Wright González Domingo, MD DDS", "title": { "expected": "professori", "got": "" } }, "fi/foreignFull/surnameFirst": { "family": { "expected": "von Wright González Domingo", "got": "von Wright González Domingo Ana Christina Lëja" }, "family_base": { "expected": "Wright González Domingo", "got": "Wright González Domingo Ana Christina Lëja" }, "given_names": { "expected": "Ana Christina Lëja", "got": "professori" }, "text": "von Wright González Domingo Ana Christina Lëja, professori MD DDS", "title": { "expected": "professori", "got": "" } }, "fi/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "fi/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "fi/nativeFull/givenFirst": { "family": { "expected": "Lehtola-Aalto", "got": "kansanedustaja" }, "family_base": { "expected": "Lehtola-Aalto", "got": "kansanedustaja" }, "given_names": { "expected": "Marja-Liisa Katariina", "got": "Marja-Liisa Katariina Lehtola-Aalto" }, "suffix": { "expected": "kansanedustaja", "got": "" }, "text": "Marja-Liisa Katariina Lehtola-Aalto kansanedustaja" }, "fi/nativeFull/surnameFirst": { "family": { "expected": "Lehtola-Aalto", "got": "Lehtola-Aalto Marja-Liisa Katariina" }, "family_base": { "expected": "Lehtola-Aalto", "got": "Lehtola-Aalto Marja-Liisa Katariina" }, "given_names": { "expected": "Marja-Liisa Katariina", "got": "kansanedustaja" }, "suffix": { "expected": "kansanedustaja", "got": "" }, "text": "Lehtola-Aalto Marja-Liisa Katariina, kansanedustaja" }, "fi/nativeG/sorting": { "family": { "expected": "", "got": "Louhi" }, "given_names": { "expected": "Louhi", "got": "" }, "text": "Louhi" }, "fi/nativeG/surnameFirst": { "family": { "expected": "", "got": "Louhi" }, "given_names": { "expected": "Louhi", "got": "" }, "text": "Louhi" }, "fil/foreignFull/surnameFirst": { "family": { "expected": "von Brühl", "got": "von" }, "family_base": { "expected": "Brühl", "got": "von" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "Ada Cornelia César Martín", "got": "Brühl Prof. Dr. Ada Cornelia César Martín" }, "text": "von Brühl Prof. Dr. Ada Cornelia César Martín Jr, MD DDS", "title": { "expected": "Prof. Dr.", "got": "" } }, "fil/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "fil/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "fil/nativeFull/surnameFirst": { "given_names": { "expected": "Bertram Wilberforce Henry Robert", "got": "Mr. Bertram Wilberforce Henry Robert" }, "text": "Wooster Mr. Bertram Wilberforce Henry Robert Jr, MP", "title": { "expected": "Mr.", "got": "" } }, "fil/nativeG/sorting": { "family": { "expected": "", "got": "Zendaya" }, "given_names": { "expected": "Zendaya", "got": "" }, "text": "Zendaya" }, "fil/nativeG/surnameFirst": { "family": { "expected": "", "got": "Zendaya" }, "given_names": { "expected": "Zendaya", "got": "" }, "text": "Zendaya" }, "fr/foreignFull/surnameFirst": { "family": { "expected": "von Brühl", "got": "von" }, "family_base": { "expected": "Brühl", "got": "von" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "María Florencia Martina Cristina", "got": "Brühl María Florencia Martina Cristina" }, "text": "Prof. Dr. von Brühl María Florencia Martina Cristina Jr., MD DDS" }, "fr/foreignG/sorting": { "family": { "expected": "", "got": "Asmar" }, "given_names": { "expected": "Asmar", "got": "" }, "text": "Asmar" }, "fr/foreignG/surnameFirst": { "family": { "expected": "", "got": "Asmar" }, "given_names": { "expected": "Asmar", "got": "" }, "text": "Asmar" }, "fr/nativeFull/givenFirst": { "given_names": { "expected": "Jean-Nicolas Louis Marcel", "got": "M. Jean-Nicolas Louis Marcel" }, "text": "M. Jean-Nicolas Louis Marcel de Bouchart fils", "title": { "expected": "M.", "got": "" } }, "fr/nativeFull/surnameFirst": { "family": { "expected": "de Bouchart", "got": "M." }, "family_base": { "expected": "Bouchart", "got": "M." }, "family_particles": { "expected": "de", "got": "" }, "given_names": { "expected": "Jean-Nicolas Louis Marcel", "got": "de Bouchart Jean-Nicolas Louis Marcel" }, "text": "M. de Bouchart Jean-Nicolas Louis Marcel fils", "title": { "expected": "M.", "got": "" } }, "fr/nativeG/sorting": { "family": { "expected": "", "got": "Adèle" }, "given_names": { "expected": "Adèle", "got": "" }, "text": "Adèle" }, "fr/nativeG/surnameFirst": { "family": { "expected": "", "got": "Adèle" }, "given_names": { "expected": "Adèle", "got": "" }, "text": "Adèle" }, "ga/foreignFull/surnameFirst": { "family": { "expected": "von Brühl", "got": "von" }, "family_base": { "expected": "Brühl", "got": "von" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "Ada Cornelia César Martín", "got": "Brühl Ada Cornelia César Martín" }, "text": "von Brühl Ada Cornelia César Martín M.D. Ph.D." }, "ga/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "ga/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "ga/nativeFull/givenFirst": { "family_base": { "expected": "de Bhulbh", "got": "Bhulbh" }, "family_particles": { "expected": "", "got": "de" }, "given_names": { "expected": "Brigid Aoibhe Sophia", "got": "An tOll. An Dr. Brigid Aoibhe Sophia" }, "text": "An tOll. An Dr. Brigid Aoibhe Sophia de Bhulbh", "title": { "expected": "An tOll. An Dr.", "got": "" } }, "ga/nativeFull/sorting": { "family_base": { "expected": "de Bhulbh", "got": "Bhulbh" }, "family_particles": { "expected": "", "got": "de" }, "given_names": { "expected": "Brigid Aoibhe Sophia", "got": "An tOll. An Dr. Brigid Aoibhe Sophia" }, "text": "de Bhulbh An tOll. An Dr. Brigid Aoibhe Sophia", "title": { "expected": "An tOll. An Dr.", "got": "" } }, "ga/nativeFull/surnameFirst": { "family_base": { "expected": "de Bhulbh", "got": "Bhulbh" }, "family_particles": { "expected": "", "got": "de" }, "text": "de Bhulbh Brigid Aoibhe Sophia" }, "ga/nativeG/sorting": { "family": { "expected": "", "got": "Hozier" }, "given_names": { "expected": "Hozier", "got": "" }, "text": "Hozier" }, "ga/nativeG/surnameFirst": { "family": { "expected": "", "got": "Hozier" }, "given_names": { "expected": "Hozier", "got": "" }, "text": "Hozier" }, "gd/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "gd/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "gd/nativeFull/givenFirst": { "family_base": { "expected": "Mac a’ Ghobhainn Chorùna", "got": "a’ Ghobhainn Chorùna" }, "family_particles": { "expected": "", "got": "Mac" }, "text": "Mgr. Dòmhnall Iain Bàn Mac a’ Ghobhainn Chorùna am post" }, "gd/nativeFull/sorting": { "family_base": { "expected": "Mac a’ Ghobhainn", "got": "a’ Ghobhainn" }, "family_particles": { "expected": "", "got": "Mac" }, "text": "Mac a’ Ghobhainn, Dòmhnall Iain Bàn" }, "gd/nativeFull/surnameFirst": { "family_base": { "expected": "Mac a’ Ghobhainn Chorùna", "got": "a’ Ghobhainn Chorùna" }, "family_particles": { "expected": "", "got": "Mac" }, "text": "Mac a’ Ghobhainn Chorùna, Mgr. Dòmhnall Iain Bàn am post" }, "gd/nativeG/sorting": { "family": { "expected": "", "got": "Calum" }, "given_names": { "expected": "Calum", "got": "" }, "text": "Calum" }, "gd/nativeG/surnameFirst": { "family": { "expected": "", "got": "Calum" }, "given_names": { "expected": "Calum", "got": "" }, "text": "Calum" }, "gl/foreignFull/givenFirst": { "family": { "expected": "von Brül Cabral de Melo", "got": "de Melo" }, "family_base": { "expected": "Brül Cabral de Melo", "got": "Melo" }, "family_particles": { "expected": "von", "got": "de" }, "given_names": { "expected": "Erika Cornelia Anna Isabelle", "got": "Erika Cornelia Anna Isabelle von Brül Cabral" }, "text": "Dra. Erika Cornelia Anna Isabelle von Brül Cabral de Melo II" }, "gl/foreignFull/sorting": { "family_base": { "expected": "Brül Cabral de Melo", "got": "Brül Cabral Melo" }, "family_particles": { "expected": "von", "got": "von de" }, "text": "Brül Cabral de Melo, Erika Cornelia Anna Isabelle von" }, "gl/foreignFull/surnameFirst": { "family": { "expected": "von Brül", "got": "von" }, "family_base": { "expected": "Brül", "got": "von" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "Erika Cornelia Anna Isabelle", "got": "Brül Erika Cornelia Anna Isabelle" }, "text": "von Brül Erika Cornelia Anna Isabelle II" }, "gl/foreignG/sorting": { "family": { "expected": "", "got": "John" }, "given_names": { "expected": "John", "got": "" }, "text": "John" }, "gl/foreignG/surnameFirst": { "family": { "expected": "", "got": "John" }, "given_names": { "expected": "John", "got": "" }, "text": "John" }, "gl/nativeG/sorting": { "family": { "expected": "", "got": "Uxía" }, "given_names": { "expected": "Uxía", "got": "" }, "text": "Uxía" }, "gl/nativeG/surnameFirst": { "family": { "expected": "", "got": "Uxía" }, "given_names": { "expected": "Uxía", "got": "" }, "text": "Uxía" }, "gu/foreignFull/givenFirst": { "family": { "expected": "ફૉન્ બ્રાઑલ્", "got": "પ્રો. ડૉ. એડા કૉર્નેલિયા સિઝર માર્ટિન ફૉન્ બ્રાઑલ્" }, "family_base": { "expected": "બ્રાઑલ્", "got": "પ્રો. ડૉ. એડા કૉર્નેલિયા સિઝર માર્ટિન ફૉન્ બ્રાઑલ્" }, "family_particles": { "expected": "ફૉન્", "got": "" }, "given_names": { "expected": "એડા કૉર્નેલિયા સિઝર માર્ટિન", "got": "એમ.ડી. ડી.ડી.એસ." }, "suffix": { "expected": "જૂનિયર એમ.ડી. ડી.ડી.એસ.", "got": "જૂનિયર" }, "text": "પ્રો. ડૉ. એડા કૉર્નેલિયા સિઝર માર્ટિન ફૉન્ બ્રાઑલ્ જૂનિયર, એમ.ડી. ડી.ડી.એસ.", "title": { "expected": "પ્રો. ડૉ.", "got": "" } }, "gu/foreignFull/sorting": { "family": { "expected": "બ્રાઑલ્ ફૉન્", "got": "બ્રાઑલ્" }, "family_particles": { "expected": "ફૉન્", "got": "" }, "given_names": { "expected": "એડા કૉર્નેલિયા સિઝર માર્ટિન", "got": "એડા કૉર્નેલિયા સિઝર માર્ટિન ફૉન્" }, "text": "બ્રાઑલ્, એડા કૉર્નેલિયા સિઝર માર્ટિન ફૉન્" }, "gu/foreignFull/surnameFirst": { "family": { "expected": "ફૉન્ બ્રાઑલ્", "got": "ફૉન્ બ્રાઑલ્ પ્રો. ડૉ. એડા કૉર્નેલિયા સિઝર માર્ટિન" }, "family_base": { "expected": "બ્રાઑલ્", "got": "ફૉન્ બ્રાઑલ્ પ્રો. ડૉ. એડા કૉર્નેલિયા સિઝર માર્ટિન" }, "family_particles": { "expected": "ફૉન્", "got": "" }, "given_names": { "expected": "એડા કૉર્નેલિયા સિઝર માર્ટિન", "got": "એમ.ડી. ડી.ડી.એસ." }, "suffix": { "expected": "જૂનિયર એમ.ડી. ડી.ડી.એસ.", "got": "જૂનિયર" }, "text": "ફૉન્ બ્રાઑલ્ પ્રો. ડૉ. એડા કૉર્નેલિયા સિઝર માર્ટિન જૂનિયર, એમ.ડી. ડી.ડી.એસ.", "title": { "expected": "પ્રો. ડૉ.", "got": "" } }, "gu/foreignG/sorting": { "family": { "expected": "", "got": "સિનબાદ" }, "given_names": { "expected": "સિનબાદ", "got": "" }, "text": "સિનબાદ" }, "gu/foreignG/surnameFirst": { "family": { "expected": "", "got": "સિનબાદ" }, "given_names": { "expected": "સિનબાદ", "got": "" }, "text": "સિનબાદ" }, "gu/nativeFull/givenFirst": { "family": { "expected": "પટેલ કુમાર શાહ", "got": "શ્રી. પટેલ ખુશી કિંજલ કુમાર શાહ" }, "family_base": { "expected": "પટેલ શાહ", "got": "શ્રી. પટેલ ખુશી કિંજલ કુમાર શાહ" }, "family_particles": { "expected": "કુમાર", "got": "" }, "given_names": { "expected": "પટેલ ખુશી કિંજલ", "got": "એમપી" }, "suffix": { "expected": "જૂનિયર એમપી", "got": "જૂનિયર" }, "text": "શ્રી. પટેલ ખુશી કિંજલ કુમાર શાહ જૂનિયર, એમપી", "title": { "expected": "શ્રી.", "got": "" } }, "gu/nativeFull/sorting": { "family": { "expected": "શાહ પટેલ કુમાર", "got": "શાહ" }, "family_base": { "expected": "શાહ પટેલ", "got": "શાહ" }, "family_particles": { "expected": "કુમાર", "got": "" }, "given_names": { "expected": "પટેલ ખુશી કિંજલ", "got": "પટેલ ખુશી કિંજલ કુમાર" }, "text": "શાહ, પટેલ ખુશી કિંજલ કુમાર" }, "gu/nativeFull/surnameFirst": { "family": { "expected": "કુમાર શાહ પટેલ", "got": "કુમાર શાહ શ્રી. પટેલ ખુશી કિંજલ" }, "family_base": { "expected": "શાહ પટેલ", "got": "કુમાર શાહ શ્રી. પટેલ ખુશી કિંજલ" }, "family_particles": { "expected": "કુમાર", "got": "" }, "given_names": { "expected": "પટેલ ખુશી કિંજલ", "got": "એમપી" }, "suffix": { "expected": "જૂનિયર એમપી", "got": "જૂનિયર" }, "text": "કુમાર શાહ શ્રી. પટેલ ખુશી કિંજલ જૂનિયર, એમપી", "title": { "expected": "શ્રી.", "got": "" } }, "gu/nativeG/sorting": { "family": { "expected": "", "got": "કિરણ" }, "given_names": { "expected": "કિરણ", "got": "" }, "text": "કિરણ" }, "gu/nativeG/surnameFirst": { "family": { "expected": "", "got": "કિરણ" }, "given_names": { "expected": "કિરણ", "got": "" }, "text": "કિરણ" }, "ha/foreignFull/surnameFirst": { "family": { "expected": "van den Wolf Becker Schmidt", "got": "van" }, "family_base": { "expected": "Wolf Becker Schmidt", "got": "van" }, "family_particles": { "expected": "van den", "got": "" }, "given_names": { "expected": "Ada Cornelia Eva Sophia", "got": "den Wolf Becker Schmidt Farf. Dr. Ada Cornelia Eva Sophia" }, "text": "van den Wolf Becker Schmidt Farf. Dr. Ada Cornelia Eva Sophia M.D. Ph.D.", "title": { "expected": "Farf. Dr.", "got": "" } }, "ha/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "ha/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "ha/nativeFull/givenFirst": { "given_names": { "expected": "Shehu Bello", "got": "Malam Shehu Bello" }, "text": "Malam Shehu Bello Modibbo MP", "title": { "expected": "Malam", "got": "" } }, "ha/nativeFull/surnameFirst": { "given_names": { "expected": "Shehu Bello", "got": "Malam Shehu Bello" }, "text": "Modibbo Malam Shehu Bello MP", "title": { "expected": "Malam", "got": "" } }, "ha/nativeG/sorting": { "family": { "expected": "", "got": "Sule" }, "given_names": { "expected": "Sule", "got": "" }, "text": "Sule" }, "ha/nativeG/surnameFirst": { "family": { "expected": "", "got": "Sule" }, "given_names": { "expected": "Sule", "got": "" }, "text": "Sule" }, "he/foreignFull/givenFirst": { "family": { "expected": "פון ברוהל גונזלס דומינגו", "got": "ד״ר" }, "family_base": { "expected": "ברוהל גונזלס דומינגו", "got": "ד״ר" }, "family_particles": { "expected": "פון", "got": "" }, "given_names": { "expected": "עדה קורנליה סזאר מרטין", "got": "עדה קורנליה סזאר מרטין פון ברוהל גונזלס דומינגו" }, "suffix": { "expected": "ד״ר", "got": "" }, "text": "פרופ׳ עדה קורנליה סזאר מרטין פון ברוהל גונזלס דומינגו ד״ר" }, "he/foreignFull/sorting": { "family_base": { "expected": "ברוהל גונזלס דומינגו", "got": "פון ברוהל גונזלס דומינגו" }, "family_particles": { "expected": "פון", "got": "" }, "given_names": { "expected": "עדה קורנליה סזאר מרטין", "got": "עדה קורנליה סזאר מרטין ד״ר" }, "suffix": { "expected": "ד״ר", "got": "" }, "text": "פון ברוהל גונזלס דומינגו, פרופ׳ עדה קורנליה סזאר מרטין ד״ר" }, "he/foreignFull/surnameFirst": { "family": { "expected": "פון ברוהל גונזלס דומינגו", "got": "פון" }, "family_base": { "expected": "ברוהל גונזלס דומינגו", "got": "פון" }, "family_particles": { "expected": "פון", "got": "" }, "given_names": { "expected": "עדה קורנליה סזאר מרטין", "got": "ברוהל גונזלס דומינגו פרופ׳ עדה קורנליה סזאר מרטין ד״ר" }, "suffix": { "expected": "ד״ר", "got": "" }, "text": "פון ברוהל גונזלס דומינגו פרופ׳ עדה קורנליה סזאר מרטין ד״ר", "title": { "expected": "פרופ׳", "got": "" } }, "he/foreignG/sorting": { "family": { "expected": "", "got": "סינבד" }, "given_names": { "expected": "סינבד", "got": "" }, "text": "סינבד" }, "he/foreignG/surnameFirst": { "family": { "expected": "", "got": "סינבד" }, "given_names": { "expected": "סינבד", "got": "" }, "text": "סינבד" }, "he/nativeFull/givenFirst": { "family": { "expected": "אבן אבן-גבירול", "got": "ח״כ" }, "family_base": { "expected": "אבן-גבירול", "got": "ח״כ" }, "family_particles": { "expected": "אבן", "got": "" }, "given_names": { "expected": "בנימין משה", "got": "בנימין משה אבן אבן-גבירול" }, "suffix": { "expected": "ח״כ", "got": "" }, "text": "מר בנימין משה אבן אבן-גבירול ח״כ" }, "he/nativeFull/sorting": { "family": { "expected": "אבן אבן-גבירול", "got": "אבן" }, "family_base": { "expected": "אבן-גבירול", "got": "אבן" }, "family_particles": { "expected": "אבן", "got": "" }, "given_names": { "expected": "בנימין משה", "got": "אבן-גבירול מר בנימין משה ח״כ" }, "suffix": { "expected": "ח״כ", "got": "" }, "text": "אבן אבן-גבירול מר בנימין משה ח״כ", "title": { "expected": "מר", "got": "" } }, "he/nativeFull/surnameFirst": { "family": { "expected": "אבן אבן-גבירול", "got": "אבן" }, "family_base": { "expected": "אבן-גבירול", "got": "אבן" }, "family_particles": { "expected": "אבן", "got": "" }, "given_names": { "expected": "בנימין משה", "got": "אבן-גבירול מר בנימין משה ח״כ" }, "suffix": { "expected": "ח״כ", "got": "" }, "text": "אבן אבן-גבירול מר בנימין משה ח״כ", "title": { "expected": "מר", "got": "" } }, "he/nativeG/sorting": { "family": { "expected": "", "got": "יונתן" }, "given_names": { "expected": "יונתן", "got": "" }, "text": "יונתן" }, "he/nativeG/surnameFirst": { "family": { "expected": "", "got": "יונתן" }, "given_names": { "expected": "יונתן", "got": "" }, "text": "יונתן" }, "hi/foreignFull/givenFirst": { "family": { "expected": "वॉन वॉटसन चेस्टर", "got": "प्रो॰ डॉ॰ माइकल जॉन वॉन वॉटसन चेस्टर" }, "family_base": { "expected": "वॉटसन चेस्टर", "got": "प्रो॰ डॉ॰ माइकल जॉन वॉन वॉटसन चेस्टर" }, "family_particles": { "expected": "वॉन", "got": "" }, "given_names": { "expected": "माइकल जॉन", "got": "एम॰डी॰" }, "suffix": { "expected": "एम॰डी॰, पी॰एच॰डी॰", "got": "पी॰एच॰डी॰" }, "text": "प्रो॰ डॉ॰ माइकल जॉन वॉन वॉटसन चेस्टर, एम॰डी॰, पी॰एच॰डी॰", "title": { "expected": "प्रो॰ डॉ॰", "got": "" } }, "hi/foreignFull/sorting": { "family_base": { "expected": "वॉटसन", "got": "वॉन वॉटसन" }, "family_particles": { "expected": "वॉन", "got": "" }, "given_names": { "expected": "माइकल जॉन", "got": "माइकल जॉन एम॰डी॰" }, "suffix": { "expected": "एम॰डी॰, पी॰एच॰डी॰", "got": "पी॰एच॰डी॰" }, "text": "वॉन वॉटसन, माइकल जॉन एम॰डी॰, पी॰एच॰डी॰" }, "hi/foreignFull/surnameFirst": { "family": { "expected": "वॉन वॉटसन", "got": "प्रो॰ डॉ॰ वॉन वॉटसन माइकल जॉन" }, "family_base": { "expected": "वॉटसन", "got": "प्रो॰ डॉ॰ वॉन वॉटसन माइकल जॉन" }, "family_particles": { "expected": "वॉन", "got": "" }, "given_names": { "expected": "माइकल जॉन", "got": "एम॰डी॰" }, "suffix": { "expected": "एम॰डी॰, पी॰एच॰डी॰", "got": "पी॰एच॰डी॰" }, "text": "प्रो॰ डॉ॰ वॉन वॉटसन माइकल जॉन, एम॰डी॰, पी॰एच॰डी॰", "title": { "expected": "प्रो॰ डॉ॰", "got": "" } }, "hi/foreignG/sorting": { "family": { "expected": "", "got": "रॉबर्ट" }, "given_names": { "expected": "रॉबर्ट", "got": "" }, "text": "रॉबर्ट" }, "hi/foreignG/surnameFirst": { "family": { "expected": "", "got": "रॉबर्ट" }, "given_names": { "expected": "रॉबर्ट", "got": "" }, "text": "रॉबर्ट" }, "hi/nativeFull/givenFirst": { "family": { "expected": "शर्मा", "got": "एम॰डी॰" }, "family_base": { "expected": "शर्मा", "got": "एम॰डी॰" }, "given_names": { "expected": "ललित कुमार", "got": "ललित कुमार शर्मा" }, "suffix": { "expected": "एम॰डी॰", "got": "" }, "text": "श्री ललित कुमार शर्मा एम॰डी॰" }, "hi/nativeFull/sorting": { "given_names": { "expected": "ललित कुमार", "got": "ललित कुमार एम॰डी॰" }, "suffix": { "expected": "एम॰डी॰", "got": "" }, "text": "शर्मा, ललित कुमार एम॰डी॰" }, "hi/nativeFull/surnameFirst": { "family": { "expected": "शर्मा", "got": "शर्मा ललित कुमार" }, "family_base": { "expected": "शर्मा", "got": "शर्मा ललित कुमार" }, "given_names": { "expected": "ललित कुमार", "got": "एम॰डी॰" }, "suffix": { "expected": "एम॰डी॰", "got": "" }, "text": "श्री शर्मा ललित कुमार, एम॰डी॰" }, "hi/nativeG/sorting": { "family": { "expected": "", "got": "ललित" }, "given_names": { "expected": "ललित", "got": "" }, "text": "ललित" }, "hi/nativeG/surnameFirst": { "family": { "expected": "", "got": "ललित" }, "given_names": { "expected": "ललित", "got": "" }, "text": "ललित" }, "hi_Latn/foreignFull/surnameFirst": { "family": { "expected": "von Bruhl", "got": "von" }, "family_base": { "expected": "Bruhl", "got": "von" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "Ada Cornelia Cesar Martin", "got": "Bruhl Prof. Dr. Ada Cornelia Cesar Martin" }, "text": "von Bruhl Prof. Dr. Ada Cornelia Cesar Martin Jr, MD DDS", "title": { "expected": "Prof. Dr.", "got": "" } }, "hi_Latn/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "hi_Latn/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "hi_Latn/nativeFull/givenFirst": { "given_names": { "expected": "Lalit Kumaar", "got": "Shree Lalit Kumaar" }, "text": "Shree Lalit Kumaar Bharati Jr, MP", "title": { "expected": "Shree", "got": "" } }, "hi_Latn/nativeFull/surnameFirst": { "given_names": { "expected": "Lalit Kumaar", "got": "Shree Lalit Kumaar" }, "text": "Bharati Shree Lalit Kumaar Jr, MP", "title": { "expected": "Shree", "got": "" } }, "hi_Latn/nativeG/sorting": { "family": { "expected": "", "got": "Lalit" }, "given_names": { "expected": "Lalit", "got": "" }, "text": "Lalit" }, "hi_Latn/nativeG/surnameFirst": { "family": { "expected": "", "got": "Lalit" }, "given_names": { "expected": "Lalit", "got": "" }, "text": "Lalit" }, "hr/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "hr/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "hr/nativeFull/givenFirst": { "suffix": { "expected": "mlađi prof.", "got": "mlađi" }, "text": "dr. sc. Branko Hrvoje Marić mlađi, prof.", "title": { "expected": "dr. sc.", "got": "dr. sc. prof." } }, "hr/nativeFull/surnameFirst": { "suffix": { "expected": "mlađi prof.", "got": "mlađi" }, "text": "Marić, dr. sc. Branko Hrvoje mlađi, prof.", "title": { "expected": "dr. sc.", "got": "dr. sc. prof." } }, "hr/nativeG/sorting": { "family": { "expected": "", "got": "Zrinka" }, "given_names": { "expected": "Zrinka", "got": "" }, "text": "Zrinka" }, "hr/nativeG/surnameFirst": { "family": { "expected": "", "got": "Zrinka" }, "given_names": { "expected": "Zrinka", "got": "" }, "text": "Zrinka" }, "hsb/foreignG/sorting": { "family": { "expected": "", "got": "Tomás" }, "given_names": { "expected": "Tomás", "got": "" }, "text": "Tomás" }, "hsb/foreignG/surnameFirst": { "family": { "expected": "", "got": "Tomás" }, "given_names": { "expected": "Tomás", "got": "" }, "text": "Tomás" }, "hsb/nativeG/sorting": { "family": { "expected": "", "got": "Jurij" }, "given_names": { "expected": "Jurij", "got": "" }, "text": "Jurij" }, "hsb/nativeG/surnameFirst": { "family": { "expected": "", "got": "Jurij" }, "given_names": { "expected": "Jurij", "got": "" }, "text": "Jurij" }, "hu/foreignFull/surnameFirst": { "family": { "expected": "von Kovács", "got": "von" }, "family_base": { "expected": "Kovács", "got": "von" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "Luca Sarolta Éva Erzsébet", "got": "Kovács Luca Sarolta Éva Erzsébet" }, "text": "von Kovács Luca Sarolta Éva Erzsébet M.D. Ph.D." }, "hu/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "hu/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "hu/nativeG/sorting": { "family": { "expected": "", "got": "Vuk" }, "given_names": { "expected": "Vuk", "got": "" }, "text": "Vuk" }, "hu/nativeG/surnameFirst": { "family": { "expected": "", "got": "Vuk" }, "given_names": { "expected": "Vuk", "got": "" }, "text": "Vuk" }, "hy/foreignFull/givenFirst": { "family": { "expected": "վան դեն Վոլֆ", "got": "Վոլֆ" }, "family_particles": { "expected": "վան դեն", "got": "" }, "given_names": { "expected": "Ադա Կոռնելիա Սեզար Մարտին", "got": "բ․գ․դ․ դ-ր Ադա Կոռնելիա Սեզար Մարտին վան դեն" }, "suffix": { "expected": "բ․գ․դ․", "got": "" }, "text": "բ․գ․դ․ դ-ր Ադա Կոռնելիա Սեզար Մարտին վան դեն Վոլֆ", "title": { "expected": "դ-ր", "got": "" } }, "hy/foreignFull/surnameFirst": { "family": { "expected": "վան դեն Վոլֆ", "got": "բ․գ․դ․" }, "family_base": { "expected": "Վոլֆ", "got": "բ․գ․դ․" }, "family_particles": { "expected": "վան դեն", "got": "" }, "given_names": { "expected": "Ադա Կոռնելիա Սեզար Մարտին", "got": "վան դեն Վոլֆ Ադա Կոռնելիա Սեզար Մարտին" }, "suffix": { "expected": "բ․գ․դ․", "got": "" }, "text": "բ․գ․դ․ վան դեն Վոլֆ Ադա Կոռնելիա Սեզար Մարտին" }, "hy/foreignG/sorting": { "family": { "expected": "", "got": "Սինբադ" }, "given_names": { "expected": "Սինբադ", "got": "" }, "text": "Սինբադ" }, "hy/foreignG/surnameFirst": { "family": { "expected": "", "got": "Սինբադ" }, "given_names": { "expected": "Սինբադ", "got": "" }, "text": "Սինբադ" }, "hy/nativeFull/givenFirst": { "given_names": { "expected": "Հայկ Պարգևի", "got": "բ․գ․դ․ պրն Հայկ Պարգևի" }, "suffix": { "expected": "բ․գ․դ․", "got": "" }, "text": "բ․գ․դ․ պրն Հայկ Պարգևի Տեր-Պողոսյան", "title": { "expected": "պրն", "got": "" } }, "hy/nativeFull/surnameFirst": { "family": { "expected": "Տեր-Պողոսյան", "got": "բ․գ․դ․" }, "family_base": { "expected": "Տեր-Պողոսյան", "got": "բ․գ․դ․" }, "given_names": { "expected": "Հայկ Պարգևի", "got": "Տեր-Պողոսյան Հայկ Պարգևի" }, "suffix": { "expected": "բ․գ․դ․", "got": "" }, "text": "բ․գ․դ․ Տեր-Պողոսյան Հայկ Պարգևի" }, "hy/nativeG/sorting": { "family": { "expected": "", "got": "Արթուր" }, "given_names": { "expected": "Արթուր", "got": "" }, "text": "Արթուր" }, "hy/nativeG/surnameFirst": { "family": { "expected": "", "got": "Արթուր" }, "given_names": { "expected": "Արթուր", "got": "" }, "text": "Արթուր" }, "id/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "id/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "id/nativeFull/givenFirst": { "family": { "expected": "Adinata", "got": "bin Adinata" }, "family_particles": { "expected": "", "got": "bin" }, "given_names": { "expected": "Dwi Putro bin", "got": "Dwi Putro" }, "text": "Bapak Dwi Putro bin Adinata MP" }, "id/nativeFull/sorting": { "family": { "expected": "Adinata", "got": "bin Adinata" }, "family_particles": { "expected": "", "got": "bin" }, "given_names": { "expected": "Dwi Putro bin", "got": "Dwi Putro" }, "text": "Adinata, Dwi Putro bin" }, "id/nativeFull/surnameFirst": { "family": { "expected": "Adinata", "got": "bin Adinata" }, "family_particles": { "expected": "", "got": "bin" }, "given_names": { "expected": "Dwi Putro bin", "got": "Dwi Putro" }, "text": "Bapak Adinata, Dwi Putro bin MP" }, "id/nativeG/sorting": { "family": { "expected": "", "got": "Budi" }, "given_names": { "expected": "Budi", "got": "" }, "text": "Budi" }, "id/nativeG/surnameFirst": { "family": { "expected": "", "got": "Budi" }, "given_names": { "expected": "Budi", "got": "" }, "text": "Budi" }, "id/nativeGGS/givenFirst": { "family": { "expected": "Akbar", "got": "bin Akbar" }, "given_names": { "expected": "Ahmad Albar bin", "got": "Ahmad Albar" }, "text": "Ahmad Albar bin Akbar" }, "id/nativeGGS/sorting": { "family": { "expected": "Akbar", "got": "bin Akbar" }, "given_names": { "expected": "Ahmad Albar bin", "got": "Ahmad Albar" }, "text": "Akbar, Ahmad Albar bin" }, "id/nativeGGS/surnameFirst": { "family": { "expected": "Akbar", "got": "bin Akbar" }, "given_names": { "expected": "Ahmad Albar bin", "got": "Ahmad Albar" }, "text": "Akbar, Ahmad Albar bin" }, "ig/foreignFull/surnameFirst": { "family": { "expected": "van den Wolf", "got": "van" }, "family_base": { "expected": "Wolf", "got": "van" }, "family_particles": { "expected": "van den", "got": "" }, "given_names": { "expected": "Ada Cornelia Eva Sophia", "got": "den Wolf Prof. Dr. Ada Cornelia Eva Sophia" }, "text": "van den Wolf Prof. Dr. Ada Cornelia Eva Sophia Jr, M.D. Ph.D.", "title": { "expected": "Prof. Dr.", "got": "" } }, "ig/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "ig/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "ig/nativeFull/givenFirst": { "given_names": { "expected": "Christopher Chukwudike", "got": "Maazị Christopher Chukwudike" }, "text": "Maazị Christopher Chukwudike Amadi MP", "title": { "expected": "Maazị", "got": "" } }, "ig/nativeFull/surnameFirst": { "given_names": { "expected": "Christopher Chukwudike", "got": "Maazị Christopher Chukwudike" }, "text": "Amadi Maazị Christopher Chukwudike Jr, MP", "title": { "expected": "Maazị", "got": "" } }, "ig/nativeG/sorting": { "family": { "expected": "", "got": "Ada" }, "given_names": { "expected": "Ada", "got": "" }, "text": "Ada" }, "ig/nativeG/surnameFirst": { "family": { "expected": "", "got": "Ada" }, "given_names": { "expected": "Ada", "got": "" }, "text": "Ada" }, "is/foreignFull/sorting": { "family": { "expected": "von Bjarnadóttir", "got": "Sigríður" }, "family_base": { "expected": "Bjarnadóttir", "got": "Sigríður" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "Sigríður Jóna Valgerður Fanndís", "got": "Jóna Valgerður Fanndís von Bjarnadóttir" }, "text": "Sigríður Jóna Valgerður Fanndís von Bjarnadóttir" }, "is/foreignFull/surnameFirst": { "family": { "expected": "von Bjarnadóttir", "got": "von" }, "family_base": { "expected": "Bjarnadóttir", "got": "von" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "Sigríður Jóna Valgerður Fanndís", "got": "Bjarnadóttir dr. Sigríður Jóna Valgerður Fanndís" }, "text": "von Bjarnadóttir dr. Sigríður Jóna Valgerður Fanndís yngri, M.A. í bókmenntafræði", "title": { "expected": "dr.", "got": "" } }, "is/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "is/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "is/foreignGGS/sorting": { "family": { "expected": "Stöber", "got": "Zäzilia" }, "given_names": { "expected": "Zäzilia Hamish", "got": "Hamish Stöber" }, "text": "Zäzilia Hamish Stöber" }, "is/foreignGS/sorting": { "family": { "expected": "Müller", "got": "Käthe" }, "given_names": { "expected": "Käthe", "got": "Müller" }, "text": "Käthe Müller" }, "is/nativeFull/givenFirst": { "family": { "expected": "Skeggjason", "got": "Geir Jón Magnús Brynjar Skeggjason" }, "family_base": { "expected": "Skeggjason", "got": "Geir Jón Magnús Brynjar Skeggjason" }, "given_names": { "expected": "Geir Jón Magnús Brynjar", "got": "þingmaður" }, "suffix": { "expected": "þingmaður", "got": "" }, "text": "Geir Jón Magnús Brynjar Skeggjason, þingmaður" }, "is/nativeFull/sorting": { "family": { "expected": "Skeggjason", "got": "Geir" }, "family_base": { "expected": "Skeggjason", "got": "Geir" }, "given_names": { "expected": "Geir Jón Magnús Brynjar", "got": "Jón Magnús Brynjar Skeggjason" }, "text": "Geir Jón Magnús Brynjar Skeggjason" }, "is/nativeFull/surnameFirst": { "given_names": { "expected": "Geir Jón Magnús Brynjar", "got": "Hr. Geir Jón Magnús Brynjar" }, "text": "Skeggjason Hr. Geir Jón Magnús Brynjar yngri, þingmaður", "title": { "expected": "Hr.", "got": "" } }, "is/nativeG/sorting": { "family": { "expected": "", "got": "Sigurður" }, "given_names": { "expected": "Sigurður", "got": "" }, "text": "Sigurður" }, "is/nativeG/surnameFirst": { "family": { "expected": "", "got": "Sigurður" }, "given_names": { "expected": "Sigurður", "got": "" }, "text": "Sigurður" }, "is/nativeGGS/sorting": { "family": { "expected": "Valdimarsson", "got": "Jón" }, "given_names": { "expected": "Jón Hannes", "got": "Hannes Valdimarsson" }, "text": "Jón Hannes Valdimarsson" }, "is/nativeGS/sorting": { "family": { "expected": "Adólfsdóttir", "got": "Íris" }, "given_names": { "expected": "Íris", "got": "Adólfsdóttir" }, "text": "Íris Adólfsdóttir" }, "it/foreignFull/surnameFirst": { "family": { "expected": "von Brühl", "got": "von" }, "family_base": { "expected": "Brühl", "got": "von" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "Ada Cornelia César Martín", "got": "Brühl Ada Cornelia César Martín" }, "text": "Prof. Dr. von Brühl Ada Cornelia César Martín Jr, MD DDS" }, "it/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "it/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "it/nativeG/sorting": { "family": { "expected": "", "got": "Mina" }, "given_names": { "expected": "Mina", "got": "" }, "text": "Mina" }, "it/nativeG/surnameFirst": { "family": { "expected": "", "got": "Mina" }, "given_names": { "expected": "Mina", "got": "" }, "text": "Mina" }, "ja/foreignFull/givenFirst": { "family": { "expected": "フォン スミス", "got": "ジェニファー" }, "family_base": { "expected": "スミス", "got": "ジェニファー" }, "family_particles": { "expected": "フォン", "got": "" }, "given_names": { "expected": "ジェニファー ソフィア", "got": "ソフィア フォン スミス ジュニア博士" }, "suffix": { "expected": "ジュニア", "got": "" }, "text": "ジェニファー・ソフィア・フォン・スミス・ジュニア博士", "title": { "expected": "博士", "got": "" } }, "ja/foreignFull/sorting": { "family": { "expected": "フォン スミス", "got": "フォン" }, "family_base": { "expected": "スミス", "got": "フォン" }, "family_particles": { "expected": "フォン", "got": "" }, "given_names": { "expected": "ソフィア ジェニファー", "got": "スミス ソフィア ジェニファー" }, "text": "フォン・スミス・ソフィア・ジェニファー" }, "ja/foreignFull/surnameFirst": { "family": { "expected": "フォン スミス", "got": "フォン" }, "family_base": { "expected": "スミス", "got": "フォン" }, "family_particles": { "expected": "フォン", "got": "" }, "given_names": { "expected": "ソフィア ジェニファー", "got": "スミス ソフィア ジェニファー博士" }, "text": "フォン・スミス・ソフィア・ジェニファー博士", "title": { "expected": "博士", "got": "" } }, "ja/foreignG/sorting": { "family": { "expected": "", "got": "マイケル" }, "given_names": { "expected": "マイケル", "got": "" }, "text": "マイケル" }, "ja/foreignG/surnameFirst": { "family": { "expected": "", "got": "マイケル" }, "given_names": { "expected": "マイケル", "got": "" }, "text": "マイケル" }, "ja/nativeFull/givenFirst": { "family": { "expected": "佐藤", "got": "恵子グレース佐藤ジュニア" }, "family_base": { "expected": "佐藤", "got": "恵子グレース佐藤ジュニア" }, "given_names": { "expected": "恵子 グレース", "got": "" }, "suffix": { "expected": "ジュニア", "got": "さん" }, "text": "恵子グレース佐藤ジュニアさん", "title": { "expected": "さん", "got": "" } }, "ja/nativeFull/sorting": { "family": { "expected": "佐藤", "got": "佐藤グレース恵子" }, "family_base": { "expected": "佐藤", "got": "佐藤グレース恵子" }, "given_names": { "expected": "グレース 恵子", "got": "" }, "text": "佐藤グレース恵子" }, "ja/nativeFull/surnameFirst": { "family": { "expected": "佐藤", "got": "佐藤グレース恵子" }, "family_base": { "expected": "佐藤", "got": "佐藤グレース恵子" }, "given_names": { "expected": "グレース 恵子", "got": "" }, "suffix": { "expected": "", "got": "さん" }, "text": "佐藤グレース恵子さん", "title": { "expected": "さん", "got": "" } }, "ja/nativeG/givenFirst": { "family": { "expected": "", "got": "慎太郎" }, "given_names": { "expected": "慎太郎", "got": "" }, "text": "慎太郎" }, "ja/nativeG/sorting": { "family": { "expected": "", "got": "慎太郎" }, "given_names": { "expected": "慎太郎", "got": "" }, "text": "慎太郎" }, "ja/nativeG/surnameFirst": { "family": { "expected": "", "got": "慎太郎" }, "given_names": { "expected": "慎太郎", "got": "" }, "text": "慎太郎" }, "ja/nativeGGS/givenFirst": { "family": { "expected": "山田", "got": "太郎トーマス山田" }, "given_names": { "expected": "太郎 トーマス", "got": "" }, "text": "太郎トーマス山田" }, "ja/nativeGGS/sorting": { "family": { "expected": "山田", "got": "山田トーマス太郎" }, "given_names": { "expected": "トーマス 太郎", "got": "" }, "text": "山田トーマス太郎" }, "ja/nativeGGS/surnameFirst": { "family": { "expected": "山田", "got": "山田トーマス太郎" }, "given_names": { "expected": "トーマス 太郎", "got": "" }, "text": "山田トーマス太郎" }, "ja/nativeGS/givenFirst": { "family": { "expected": "安藤", "got": "一郎安藤" }, "given_names": { "expected": "一郎", "got": "" }, "text": "一郎安藤" }, "ja/nativeGS/sorting": { "family": { "expected": "安藤", "got": "安藤一郎" }, "given_names": { "expected": "一郎", "got": "" }, "text": "安藤一郎" }, "ja/nativeGS/surnameFirst": { "family": { "expected": "安藤", "got": "安藤一郎" }, "given_names": { "expected": "一郎", "got": "" }, "text": "安藤一郎" }, "jv/foreignFull/surnameFirst": { "family": { "expected": "van. den. Wolf", "got": "Wolf" }, "family_particles": { "expected": "van. den.", "got": "" }, "text": "van. den. Wolf Ada Cornelia Eva Sophia M.D. Ph.D.", "title": { "expected": "", "got": "van. den." } }, "jv/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "jv/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "jv/nativeG/sorting": { "family": { "expected": "", "got": "Zendaya" }, "given_names": { "expected": "Zendaya", "got": "" }, "text": "Zendaya" }, "jv/nativeG/surnameFirst": { "family": { "expected": "", "got": "Zendaya" }, "given_names": { "expected": "Zendaya", "got": "" }, "text": "Zendaya" }, "ka/foreignFull/givenFirst": { "family": { "expected": "ფონ ბრიული გონსალეს დომინგო", "got": "დოქტ." }, "family_base": { "expected": "ბრიული გონსალეს დომინგო", "got": "დოქტ." }, "family_particles": { "expected": "ფონ", "got": "" }, "given_names": { "expected": "ადა კორნელია სეზარ მარტინ", "got": "ადა კორნელია სეზარ მარტინ ფონ ბრიული გონსალეს დომინგო მეცნ." }, "suffix": { "expected": "მეცნ. დოქტ.", "got": "" }, "text": "პროფ. დოქტ. ადა კორნელია სეზარ მარტინ ფონ ბრიული გონსალეს დომინგო მეცნ. დოქტ." }, "ka/foreignFull/sorting": { "family_base": { "expected": "ბრიული გონსალეს დომინგო", "got": "ფონ ბრიული გონსალეს დომინგო" }, "family_particles": { "expected": "ფონ", "got": "" }, "given_names": { "expected": "ადა კორნელია სეზარ მარტინ", "got": "ადა კორნელია სეზარ მარტინ მეცნ. დოქტ." }, "suffix": { "expected": "მეცნ. დოქტ.", "got": "" }, "text": "ფონ ბრიული გონსალეს დომინგო, პროფ. დოქტ. ადა კორნელია სეზარ მარტინ მეცნ. დოქტ." }, "ka/foreignFull/surnameFirst": { "family": { "expected": "ფონ ბრიული გონსალეს დომინგო", "got": "ფონ" }, "family_base": { "expected": "ბრიული გონსალეს დომინგო", "got": "ფონ" }, "family_particles": { "expected": "ფონ", "got": "" }, "given_names": { "expected": "ადა კორნელია სეზარ მარტინ", "got": "ბრიული გონსალეს დომინგო პროფ. დოქტ. ადა კორნელია სეზარ მარტინ მეცნ. დოქტ." }, "suffix": { "expected": "მეცნ. დოქტ.", "got": "" }, "text": "ფონ ბრიული გონსალეს დომინგო პროფ. დოქტ. ადა კორნელია სეზარ მარტინ მეცნ. დოქტ.", "title": { "expected": "პროფ. დოქტ.", "got": "" } }, "ka/foreignG/sorting": { "family": { "expected": "", "got": "სინდბად" }, "given_names": { "expected": "სინდბად", "got": "" }, "text": "სინდბად" }, "ka/foreignG/surnameFirst": { "family": { "expected": "", "got": "სინდბად" }, "given_names": { "expected": "სინდბად", "got": "" }, "text": "სინდბად" }, "ka/nativeG/sorting": { "family": { "expected": "", "got": "ნინო" }, "given_names": { "expected": "ნინო", "got": "" }, "text": "ნინო" }, "ka/nativeG/surnameFirst": { "family": { "expected": "", "got": "ნინო" }, "given_names": { "expected": "ნინო", "got": "" }, "text": "ნინო" }, "kk/foreignFull/givenFirst": { "family": { "expected": "фон Брюль", "got": "Ада Корнелия фон Брюль Джуниор" }, "family_base": { "expected": "Брюль", "got": "Ада Корнелия фон Брюль Джуниор" }, "family_particles": { "expected": "фон", "got": "" }, "given_names": { "expected": "Ада Корнелия", "got": "тіс дәрігері" }, "suffix": { "expected": "Джуниор тіс дәрігері", "got": "" }, "text": "Ада Корнелия фон Брюль Джуниор, тіс дәрігері" }, "kk/foreignFull/sorting": { "family": { "expected": "Брюль фон", "got": "Брюль" }, "family_particles": { "expected": "фон", "got": "" }, "given_names": { "expected": "Ада Корнелия Сезар Мартин", "got": "Ада Корнелия Сезар Мартин фон" }, "text": "Брюль, Ада Корнелия Сезар Мартин фон" }, "kk/foreignFull/surnameFirst": { "family": { "expected": "фон Брюль", "got": "фон" }, "family_base": { "expected": "Брюль", "got": "фон" }, "family_particles": { "expected": "фон", "got": "" }, "given_names": { "expected": "Ада Корнелия", "got": "Брюль Ада Корнелия" }, "text": "фон Брюль Ада Корнелия" }, "kk/foreignG/sorting": { "family": { "expected": "", "got": "Синдбад" }, "given_names": { "expected": "Синдбад", "got": "" }, "text": "Синдбад" }, "kk/foreignG/surnameFirst": { "family": { "expected": "", "got": "Синдбад" }, "given_names": { "expected": "Синдбад", "got": "" }, "text": "Синдбад" }, "kk/nativeG/sorting": { "family": { "expected": "", "got": "Нұрлан" }, "given_names": { "expected": "Нұрлан", "got": "" }, "text": "Нұрлан" }, "kk/nativeG/surnameFirst": { "family": { "expected": "", "got": "Нұрлан" }, "given_names": { "expected": "Нұрлан", "got": "" }, "text": "Нұрлан" }, "kk_Arab/foreignFull/givenFirst": { "family": { "expected": "فون بريۋل", "got": "ادا كورنەليا فون بريۋل جۋنيور" }, "family_base": { "expected": "بريۋل", "got": "ادا كورنەليا فون بريۋل جۋنيور" }, "family_particles": { "expected": "فون", "got": "" }, "given_names": { "expected": "ادا كورنەليا", "got": "ءتىس دارىگەرى" }, "suffix": { "expected": "جۋنيور ءتىس دارىگەرى", "got": "" }, "text": "ادا كورنەليا فون بريۋل جۋنيور، ءتىس دارىگەرى" }, "kk_Arab/foreignFull/sorting": { "family": { "expected": "بريۋل فون", "got": "بريۋل" }, "family_particles": { "expected": "فون", "got": "" }, "given_names": { "expected": "ادا كورنەليا سەزار مارتين", "got": "ادا كورنەليا سەزار مارتين فون" }, "text": "بريۋل، ادا كورنەليا سەزار مارتين فون" }, "kk_Arab/foreignFull/surnameFirst": { "family": { "expected": "فون بريۋل", "got": "فون" }, "family_base": { "expected": "بريۋل", "got": "فون" }, "family_particles": { "expected": "فون", "got": "" }, "given_names": { "expected": "ادا كورنەليا", "got": "بريۋل ادا كورنەليا" }, "text": "فون بريۋل ادا كورنەليا" }, "kk_Arab/foreignG/sorting": { "family": { "expected": "", "got": "سينباد" }, "given_names": { "expected": "سينباد", "got": "" }, "text": "سينباد" }, "kk_Arab/foreignG/surnameFirst": { "family": { "expected": "", "got": "سينباد" }, "given_names": { "expected": "سينباد", "got": "" }, "text": "سينباد" }, "kk_Arab/nativeG/sorting": { "family": { "expected": "", "got": "اسەم" }, "given_names": { "expected": "اسەم", "got": "" }, "text": "اسەم" }, "kk_Arab/nativeG/surnameFirst": { "family": { "expected": "", "got": "اسەم" }, "given_names": { "expected": "اسەم", "got": "" }, "text": "اسەم" }, "km/foreignFull/givenFirst": { "family": { "expected": "វន់ ប្រ៊ូល", "got": "សាស្ត្រាចារ្យបណ្ឌិត អាដា ខរនែលៀ ឆេសារ ម៉ាធីន វន់ ប្រ៊ូល ជូនៀ" }, "family_base": { "expected": "ប្រ៊ូល", "got": "សាស្ត្រាចារ្យបណ្ឌិត អាដា ខរនែលៀ ឆេសារ ម៉ាធីន វន់ ប្រ៊ូល ជូនៀ" }, "family_particles": { "expected": "វន់", "got": "" }, "given_names": { "expected": "អាដា ខរនែលៀ ឆេសារ ម៉ាធីន", "got": "ទន្ដបណ្ឌិត" }, "suffix": { "expected": "ជូនៀ ទន្ដបណ្ឌិត", "got": "" }, "text": "សាស្ត្រាចារ្យបណ្ឌិត អាដា ខរនែលៀ ឆេសារ ម៉ាធីន វន់ ប្រ៊ូល ជូនៀ, ទន្ដបណ្ឌិត", "title": { "expected": "សាស្ត្រាចារ្យបណ្ឌិត", "got": "" } }, "km/foreignFull/sorting": { "family": { "expected": "ប្រ៊ូល វន់", "got": "ប្រ៊ូល" }, "family_particles": { "expected": "វន់", "got": "" }, "given_names": { "expected": "អាដា ខរនែលៀ ឆេសារ ម៉ាធីន", "got": "អាដា ខរនែលៀ ឆេសារ ម៉ាធីន វន់" }, "text": "ប្រ៊ូល, អាដា ខរនែលៀ ឆេសារ ម៉ាធីន វន់" }, "km/foreignFull/surnameFirst": { "family": { "expected": "វន់ ប្រ៊ូល", "got": "វន់ ប្រ៊ូល សាស្ត្រាចារ្យបណ្ឌិត អាដា ខរនែលៀ ឆេសារ ម៉ាធីន ជូនៀ" }, "family_base": { "expected": "ប្រ៊ូល", "got": "វន់ ប្រ៊ូល សាស្ត្រាចារ្យបណ្ឌិត អាដា ខរនែលៀ ឆេសារ ម៉ាធីន ជូនៀ" }, "family_particles": { "expected": "វន់", "got": "" }, "given_names": { "expected": "អាដា ខរនែលៀ ឆេសារ ម៉ាធីន", "got": "ទន្ដបណ្ឌិត" }, "suffix": { "expected": "ជូនៀ ទន្ដបណ្ឌិត", "got": "" }, "text": "វន់ ប្រ៊ូល សាស្ត្រាចារ្យបណ្ឌិត អាដា ខរនែលៀ ឆេសារ ម៉ាធីន ជូនៀ, ទន្ដបណ្ឌិត", "title": { "expected": "សាស្ត្រាចារ្យបណ្ឌិត", "got": "" } }, "km/foreignG/sorting": { "family": { "expected": "", "got": "ស៊ីនប៊ែត" }, "given_names": { "expected": "ស៊ីនប៊ែត", "got": "" }, "text": "ស៊ីនប៊ែត" }, "km/foreignG/surnameFirst": { "family": { "expected": "", "got": "ស៊ីនប៊ែត" }, "given_names": { "expected": "ស៊ីនប៊ែត", "got": "" }, "text": "ស៊ីនប៊ែត" }, "km/nativeFull/givenFirst": { "family": { "expected": "វេហា", "got": "សមាជិកសភា" }, "family_base": { "expected": "វេហា", "got": "សមាជិកសភា" }, "given_names": { "expected": "សុវណ្ណថេត សុធារ៉ា", "got": "លោក សុវណ្ណថេត សុធារ៉ា វេហា" }, "suffix": { "expected": "សមាជិកសភា", "got": "" }, "text": "លោក សុវណ្ណថេត សុធារ៉ា វេហា សមាជិកសភា", "title": { "expected": "លោក", "got": "" } }, "km/nativeFull/surnameFirst": { "given_names": { "expected": "សុវណ្ណថេត សុធារ៉ា", "got": "លោក សុវណ្ណថេត សុធារ៉ា សមាជិកសភា" }, "suffix": { "expected": "សមាជិកសភា", "got": "" }, "text": "វេហា លោក សុវណ្ណថេត សុធារ៉ា សមាជិកសភា", "title": { "expected": "លោក", "got": "" } }, "km/nativeG/sorting": { "family": { "expected": "", "got": "សុខា" }, "given_names": { "expected": "សុខា", "got": "" }, "text": "សុខា" }, "km/nativeG/surnameFirst": { "family": { "expected": "", "got": "សុខា" }, "given_names": { "expected": "សុខា", "got": "" }, "text": "សុខា" }, "kn/foreignFull/givenFirst": { "family": { "expected": "ವಾನ್ ಬ್ರಾಲ್", "got": "ಪ್ರೊ. ಡಾ. ಅಡಾ ಕಾರ್ನೆಲಿಯಾ ಸಿಸಾರ್ ಮಾರ್ಟಿನ್ ವಾನ್ ಬ್ರಾಲ್" }, "family_base": { "expected": "ಬ್ರಾಲ್", "got": "ಪ್ರೊ. ಡಾ. ಅಡಾ ಕಾರ್ನೆಲಿಯಾ ಸಿಸಾರ್ ಮಾರ್ಟಿನ್ ವಾನ್ ಬ್ರಾಲ್" }, "family_particles": { "expected": "ವಾನ್", "got": "" }, "given_names": { "expected": "ಅಡಾ ಕಾರ್ನೆಲಿಯಾ ಸಿಸಾರ್ ಮಾರ್ಟಿನ್", "got": "ಎಂ.ಡಿ ಡಿ.ಡಿ.ಎಸ್" }, "suffix": { "expected": "ಜೂನಿಯರ್ ಎಂ.ಡಿ ಡಿ.ಡಿ.ಎಸ್", "got": "ಜೂನಿಯರ್" }, "text": "ಪ್ರೊ. ಡಾ. ಅಡಾ ಕಾರ್ನೆಲಿಯಾ ಸಿಸಾರ್ ಮಾರ್ಟಿನ್ ವಾನ್ ಬ್ರಾಲ್ ಜೂನಿಯರ್, ಎಂ.ಡಿ ಡಿ.ಡಿ.ಎಸ್", "title": { "expected": "ಪ್ರೊ. ಡಾ.", "got": "" } }, "kn/foreignFull/sorting": { "family": { "expected": "ಬ್ರಾಲ್ ವಾನ್", "got": "ಬ್ರಾಲ್" }, "family_particles": { "expected": "ವಾನ್", "got": "" }, "given_names": { "expected": "ಅಡಾ ಕಾರ್ನೆಲಿಯಾ ಸಿಸಾರ್ ಮಾರ್ಟಿನ್", "got": "ಅಡಾ ಕಾರ್ನೆಲಿಯಾ ಸಿಸಾರ್ ಮಾರ್ಟಿನ್ ವಾನ್" }, "text": "ಬ್ರಾಲ್, ಅಡಾ ಕಾರ್ನೆಲಿಯಾ ಸಿಸಾರ್ ಮಾರ್ಟಿನ್ ವಾನ್" }, "kn/foreignFull/surnameFirst": { "family": { "expected": "ವಾನ್ ಬ್ರಾಲ್", "got": "ವಾನ್ ಬ್ರಾಲ್ ಪ್ರೊ. ಡಾ. ಅಡಾ ಕಾರ್ನೆಲಿಯಾ ಸಿಸಾರ್ ಮಾರ್ಟಿನ್" }, "family_base": { "expected": "ಬ್ರಾಲ್", "got": "ವಾನ್ ಬ್ರಾಲ್ ಪ್ರೊ. ಡಾ. ಅಡಾ ಕಾರ್ನೆಲಿಯಾ ಸಿಸಾರ್ ಮಾರ್ಟಿನ್" }, "family_particles": { "expected": "ವಾನ್", "got": "" }, "given_names": { "expected": "ಅಡಾ ಕಾರ್ನೆಲಿಯಾ ಸಿಸಾರ್ ಮಾರ್ಟಿನ್", "got": "ಎಂ.ಡಿ ಡಿ.ಡಿ.ಎಸ್" }, "suffix": { "expected": "ಜೂನಿಯರ್ ಎಂ.ಡಿ ಡಿ.ಡಿ.ಎಸ್", "got": "ಜೂನಿಯರ್" }, "text": "ವಾನ್ ಬ್ರಾಲ್ ಪ್ರೊ. ಡಾ. ಅಡಾ ಕಾರ್ನೆಲಿಯಾ ಸಿಸಾರ್ ಮಾರ್ಟಿನ್ ಜೂನಿಯರ್, ಎಂ.ಡಿ ಡಿ.ಡಿ.ಎಸ್", "title": { "expected": "ಪ್ರೊ. ಡಾ.", "got": "" } }, "kn/foreignG/sorting": { "family": { "expected": "", "got": "ಸಿನ್‍ಬಾದ್" }, "given_names": { "expected": "ಸಿನ್‍ಬಾದ್", "got": "" }, "text": "ಸಿನ್‍ಬಾದ್" }, "kn/foreignG/surnameFirst": { "family": { "expected": "", "got": "ಸಿನ್‍ಬಾದ್" }, "given_names": { "expected": "ಸಿನ್‍ಬಾದ್", "got": "" }, "text": "ಸಿನ್‍ಬಾದ್" }, "kn/nativeFull/givenFirst": { "family": { "expected": "ಪಾಟೀಲ್", "got": "ಶ್ರೀ. ನಾರಯಣ ಕುಮಾರ್ ಶಿವ ರಾಮ ಪಾಟೀಲ್ ಕಿರಿ" }, "family_base": { "expected": "ಪಾಟೀಲ್", "got": "ಶ್ರೀ. ನಾರಯಣ ಕುಮಾರ್ ಶಿವ ರಾಮ ಪಾಟೀಲ್ ಕಿರಿ" }, "given_names": { "expected": "ನಾರಯಣ ಕುಮಾರ್ ಶಿವ ರಾಮ", "got": "ಎಮ್‌ಪಿ" }, "suffix": { "expected": "ಕಿರಿ ಎಮ್‌ಪಿ", "got": "" }, "text": "ಶ್ರೀ. ನಾರಯಣ ಕುಮಾರ್ ಶಿವ ರಾಮ ಪಾಟೀಲ್ ಕಿರಿ, ಎಮ್‌ಪಿ", "title": { "expected": "ಶ್ರೀ.", "got": "" } }, "kn/nativeFull/surnameFirst": { "family": { "expected": "ಪಾಟೀಲ್", "got": "ಪಾಟೀಲ್ ಶ್ರೀ. ನಾರಯಣ ಕುಮಾರ್ ಶಿವ ರಾಮ ಕಿರಿ" }, "family_base": { "expected": "ಪಾಟೀಲ್", "got": "ಪಾಟೀಲ್ ಶ್ರೀ. ನಾರಯಣ ಕುಮಾರ್ ಶಿವ ರಾಮ ಕಿರಿ" }, "given_names": { "expected": "ನಾರಯಣ ಕುಮಾರ್ ಶಿವ ರಾಮ", "got": "ಎಮ್‌ಪಿ" }, "suffix": { "expected": "ಕಿರಿ ಎಮ್‌ಪಿ", "got": "" }, "text": "ಪಾಟೀಲ್ ಶ್ರೀ. ನಾರಯಣ ಕುಮಾರ್ ಶಿವ ರಾಮ ಕಿರಿ, ಎಮ್‌ಪಿ", "title": { "expected": "ಶ್ರೀ.", "got": "" } }, "kn/nativeG/sorting": { "family": { "expected": "", "got": "ಝೆಂಡಾಯಾ" }, "given_names": { "expected": "ಝೆಂಡಾಯಾ", "got": "" }, "text": "ಝೆಂಡಾಯಾ" }, "kn/nativeG/surnameFirst": { "family": { "expected": "", "got": "ಝೆಂಡಾಯಾ" }, "given_names": { "expected": "ಝೆಂಡಾಯಾ", "got": "" }, "text": "ಝೆಂಡಾಯಾ" }, "ko/foreignFull/givenFirst": { "family": { "expected": "폰 볼프", "got": "에이다" }, "family_base": { "expected": "볼프", "got": "에이다" }, "family_particles": { "expected": "폰", "got": "" }, "given_names": { "expected": "에이다 코넬리아 세사르 마르틴", "got": "코넬리아 세사르 마르틴 폰 볼프" }, "text": "에이다 코넬리아 세사르 마르틴 폰 볼프 박사" }, "ko/foreignFull/sorting": { "family": { "expected": "폰 볼프", "got": "폰" }, "family_base": { "expected": "볼프", "got": "폰" }, "family_particles": { "expected": "폰", "got": "" }, "given_names": { "expected": "에이다 코넬리아 세사르 마르틴", "got": "볼프 에이다 코넬리아 세사르 마르틴" }, "text": "폰 볼프 에이다 코넬리아 세사르 마르틴" }, "ko/foreignFull/surnameFirst": { "family": { "expected": "폰 볼프", "got": "폰" }, "family_base": { "expected": "볼프", "got": "폰" }, "family_particles": { "expected": "폰", "got": "" }, "given_names": { "expected": "에이다 코넬리아 세사르 마르틴", "got": "볼프 에이다 코넬리아 세사르 마르틴" }, "text": "폰 볼프 에이다 코넬리아 세사르 마르틴 박사" }, "ko/foreignG/givenFirst": { "family": { "expected": "", "got": "신" }, "given_names": { "expected": "신밧드", "got": "밧드" }, "text": "신밧드" }, "ko/foreignG/sorting": { "family": { "expected": "", "got": "신" }, "given_names": { "expected": "신밧드", "got": "밧드" }, "text": "신밧드" }, "ko/foreignG/surnameFirst": { "family": { "expected": "", "got": "신" }, "given_names": { "expected": "신밧드", "got": "밧드" }, "text": "신밧드" }, "ko/foreignGGS/givenFirst": { "family": { "expected": "슈퇴버", "got": "제" }, "given_names": { "expected": "제질리아 해미쉬", "got": "질리아 해미쉬 슈퇴버" }, "text": "제질리아 해미쉬 슈퇴버" }, "ko/foreignGS/givenFirst": { "family": { "expected": "뮐러", "got": "케테" }, "given_names": { "expected": "케테", "got": "뮐러" }, "text": "케테 뮐러" }, "ko/nativeFull/givenFirst": { "family": { "expected": "김", "got": "민" }, "family_base": { "expected": "김", "got": "민" }, "given_names": { "expected": "민규 헨리", "got": "규헨리김" }, "text": "민규헨리김" }, "ko/nativeG/givenFirst": { "family": { "expected": "", "got": "유" }, "given_names": { "expected": "유미", "got": "미" }, "text": "유미" }, "ko/nativeG/sorting": { "family": { "expected": "", "got": "유" }, "given_names": { "expected": "유미", "got": "미" }, "text": "유미" }, "ko/nativeG/surnameFirst": { "family": { "expected": "", "got": "유" }, "given_names": { "expected": "유미", "got": "미" }, "text": "유미" }, "ko/nativeGGS/givenFirst": { "family": { "expected": "박", "got": "소" }, "given_names": { "expected": "소현", "got": "현박" }, "text": "소현박" }, "ko/nativeGS/givenFirst": { "family": { "expected": "강", "got": "유" }, "given_names": { "expected": "유미", "got": "미강" }, "text": "유미강" }, "kok/foreignFull/givenFirst": { "family": { "expected": "वॉन ब्रूह्ल गॉन्जालेज डोमिंगो", "got": "आडा कोर्नेलिया सेजार मार्टिन वॉन ब्रूह्ल गॉन्जालेज डोमिंगो जेआर" }, "family_base": { "expected": "ब्रूह्ल गॉन्जालेज डोमिंगो", "got": "आडा कोर्नेलिया सेजार मार्टिन वॉन ब्रूह्ल गॉन्जालेज डोमिंगो जेआर" }, "family_particles": { "expected": "वॉन", "got": "" }, "given_names": { "expected": "आडा कोर्नेलिया सेजार मार्टिन", "got": "बी.ए. एल.एल.बी" }, "suffix": { "expected": "जेआर बी.ए. एल.एल.बी", "got": "" }, "text": "प्रो. डॉ. आडा कोर्नेलिया सेजार मार्टिन वॉन ब्रूह्ल गॉन्जालेज डोमिंगो जेआर, बी.ए. एल.एल.बी" }, "kok/foreignFull/sorting": { "family": { "expected": "ब्रूह्ल वॉन", "got": "ब्रूह्ल" }, "family_particles": { "expected": "वॉन", "got": "" }, "given_names": { "expected": "आडा कोर्नेलिया सेजार मार्टिन", "got": "आडा कोर्नेलिया सेजार मार्टिन वॉन" }, "text": "ब्रूह्ल, आडा कोर्नेलिया सेजार मार्टिन वॉन" }, "kok/foreignFull/surnameFirst": { "family": { "expected": "वॉन ब्रूह्ल गॉन्जालेज डोमिंगो", "got": "वॉन ब्रूह्ल गॉन्जालेज डोमिंगो प्रो. डॉ. आडा कोर्नेलिया सेजार मार्टिन जेआर" }, "family_base": { "expected": "ब्रूह्ल गॉन्जालेज डोमिंगो", "got": "वॉन ब्रूह्ल गॉन्जालेज डोमिंगो प्रो. डॉ. आडा कोर्नेलिया सेजार मार्टिन जेआर" }, "family_particles": { "expected": "वॉन", "got": "" }, "given_names": { "expected": "आडा कोर्नेलिया सेजार मार्टिन", "got": "बी.ए. एल.एल.बी" }, "suffix": { "expected": "जेआर बी.ए. एल.एल.बी", "got": "" }, "text": "वॉन ब्रूह्ल गॉन्जालेज डोमिंगो प्रो. डॉ. आडा कोर्नेलिया सेजार मार्टिन जेआर, बी.ए. एल.एल.बी", "title": { "expected": "प्रो. डॉ.", "got": "" } }, "kok/foreignG/sorting": { "family": { "expected": "", "got": "सिंदबाद" }, "given_names": { "expected": "सिंदबाद", "got": "" }, "text": "सिंदबाद" }, "kok/foreignG/surnameFirst": { "family": { "expected": "", "got": "सिंदबाद" }, "given_names": { "expected": "सिंदबाद", "got": "" }, "text": "सिंदबाद" }, "kok/nativeFull/givenFirst": { "family": { "expected": "दे सोजा ए मेनेजेस", "got": "फ्रांसिस शावियेर जुआंव आंतोन दे सोजा ए मेनेजेस धा." }, "family_base": { "expected": "सोजा ए मेनेजेस", "got": "फ्रांसिस शावियेर जुआंव आंतोन दे सोजा ए मेनेजेस धा." }, "family_particles": { "expected": "दे", "got": "" }, "given_names": { "expected": "फ्रांसिस शावियेर जुआंव आंतोन", "got": "एमपी" }, "suffix": { "expected": "धा. एमपी", "got": "" }, "text": "श्री फ्रांसिस शावियेर जुआंव आंतोन दे सोजा ए मेनेजेस धा., एमपी" }, "kok/nativeFull/sorting": { "family": { "expected": "सोजा दे", "got": "सोजा" }, "family_particles": { "expected": "दे", "got": "" }, "given_names": { "expected": "फ्रांसिस शावियेर जुआंव आंतोन", "got": "फ्रांसिस शावियेर जुआंव आंतोन दे" }, "text": "सोजा, फ्रांसिस शावियेर जुआंव आंतोन दे" }, "kok/nativeFull/surnameFirst": { "family": { "expected": "दे सोजा ए मेनेजेस", "got": "दे सोजा ए मेनेजेस श्री फ्रांसिस शावियेर जुआंव आंतोन धा." }, "family_base": { "expected": "सोजा ए मेनेजेस", "got": "दे सोजा ए मेनेजेस श्री फ्रांसिस शावियेर जुआंव आंतोन धा." }, "family_particles": { "expected": "दे", "got": "" }, "given_names": { "expected": "फ्रांसिस शावियेर जुआंव आंतोन", "got": "एमपी" }, "suffix": { "expected": "धा. एमपी", "got": "" }, "text": "दे सोजा ए मेनेजेस श्री फ्रांसिस शावियेर जुआंव आंतोन धा., एमपी", "title": { "expected": "श्री", "got": "" } }, "kok/nativeG/sorting": { "family": { "expected": "", "got": "दिशा" }, "given_names": { "expected": "दिशा", "got": "" }, "text": "दिशा" }, "kok/nativeG/surnameFirst": { "family": { "expected": "", "got": "दिशा" }, "given_names": { "expected": "दिशा", "got": "" }, "text": "दिशा" }, "kok_Latn/foreignFull/surnameFirst": { "family": { "expected": "von Brühl González Domingo", "got": "von" }, "family_base": { "expected": "Brühl González Domingo", "got": "von" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "Ada Cornelia César Martín", "got": "Brühl González Domingo Prof. Dr. Ada Cornelia César Martín" }, "text": "von Brühl González Domingo Prof. Dr. Ada Cornelia César Martín Jr, MD DDS", "title": { "expected": "Prof. Dr.", "got": "" } }, "kok_Latn/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "kok_Latn/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "kok_Latn/nativeFull/givenFirst": { "family": { "expected": "de Souza e Menezes", "got": "de Souza e Menezes Dk" }, "family_base": { "expected": "Souza e Menezes", "got": "Souza e Menezes Dk" }, "suffix": { "expected": "Dk MP", "got": "MP" }, "text": "Mr. Francisco Xavier Joao Anton de Souza e Menezes Dk, MP" }, "kok_Latn/nativeFull/surnameFirst": { "given_names": { "expected": "Francisco Xavier Joao Anton", "got": "Mr. Francisco Xavier Joao Anton Dk" }, "suffix": { "expected": "Dk MP", "got": "MP" }, "text": "de Souza e Menezes Mr. Francisco Xavier Joao Anton Dk, MP", "title": { "expected": "Mr.", "got": "" } }, "kok_Latn/nativeG/sorting": { "family": { "expected": "", "got": "Prema" }, "given_names": { "expected": "Prema", "got": "" }, "text": "Prema" }, "kok_Latn/nativeG/surnameFirst": { "family": { "expected": "", "got": "Prema" }, "given_names": { "expected": "Prema", "got": "" }, "text": "Prema" }, "ky/foreignFull/givenFirst": { "family": { "expected": "ван ден Вольф Бекер Шмидт", "got": "кандидаты" }, "family_base": { "expected": "Вольф Бекер Шмидт", "got": "кандидаты" }, "family_particles": { "expected": "ван ден", "got": "" }, "given_names": { "expected": "Алтынай Сулайманова Асель Сартбаева", "got": "Алтынай Сулайманова Асель Сартбаева ван ден Вольф Бекер Шмидт Илимдин" }, "suffix": { "expected": "Илимдин кандидаты", "got": "" }, "text": "Проф. Доктор. Алтынай Сулайманова Асель Сартбаева ван ден Вольф Бекер Шмидт Илимдин кандидаты" }, "ky/foreignFull/sorting": { "family_base": { "expected": "Вольф Бекер Шмидт", "got": "ван ден Вольф Бекер Шмидт" }, "family_particles": { "expected": "ван ден", "got": "" }, "given_names": { "expected": "Алтынай Сулайманова Асель Сартбаева", "got": "Алтынай Сулайманова Асель Сартбаева Илимдин кандидаты" }, "suffix": { "expected": "Илимдин кандидаты", "got": "" }, "text": "ван ден Вольф Бекер Шмидт, Проф. Доктор. Алтынай Сулайманова Асель Сартбаева Илимдин кандидаты" }, "ky/foreignFull/surnameFirst": { "family": { "expected": "ван ден Вольф", "got": "ван" }, "family_base": { "expected": "Вольф", "got": "ван" }, "family_particles": { "expected": "ван ден", "got": "" }, "given_names": { "expected": "Алтынай Сулайманова Асель Сартбаева", "got": "ден Вольф Алтынай Сулайманова Асель Сартбаева Илимдин кандидаты" }, "suffix": { "expected": "Илимдин кандидаты", "got": "" }, "text": "ван ден Вольф Алтынай Сулайманова Асель Сартбаева Илимдин кандидаты" }, "ky/foreignG/sorting": { "family": { "expected": "", "got": "Синбад" }, "given_names": { "expected": "Синбад", "got": "" }, "text": "Синбад" }, "ky/foreignG/surnameFirst": { "family": { "expected": "", "got": "Синбад" }, "given_names": { "expected": "Синбад", "got": "" }, "text": "Синбад" }, "ky/nativeFull/givenFirst": { "family": { "expected": "уулу Төрөкул Махмудов", "got": "мүчөсү" }, "family_base": { "expected": "Төрөкул Махмудов", "got": "мүчөсү" }, "family_particles": { "expected": "уулу", "got": "" }, "given_names": { "expected": "Чыңгыз Айтматов Исхак Раззаков", "got": "Мырза Чыңгыз Айтматов Исхак Раззаков уулу Төрөкул Махмудов Парламент" }, "suffix": { "expected": "Парламент мүчөсү", "got": "" }, "text": "Мырза Чыңгыз Айтматов Исхак Раззаков уулу Төрөкул Махмудов Парламент мүчөсү", "title": { "expected": "Мырза", "got": "" } }, "ky/nativeFull/sorting": { "family_base": { "expected": "Төрөкул Махмудов", "got": "уулу Төрөкул Махмудов" }, "family_particles": { "expected": "уулу", "got": "" }, "given_names": { "expected": "Чыңгыз Айтматов Исхак Раззаков", "got": "Мырза Чыңгыз Айтматов Исхак Раззаков Парламент мүчөсү" }, "suffix": { "expected": "Парламент мүчөсү", "got": "" }, "text": "уулу Төрөкул Махмудов, Мырза Чыңгыз Айтматов Исхак Раззаков Парламент мүчөсү", "title": { "expected": "Мырза", "got": "" } }, "ky/nativeFull/surnameFirst": { "family": { "expected": "уулу Төрөкул", "got": "уулу" }, "family_base": { "expected": "Төрөкул", "got": "уулу" }, "family_particles": { "expected": "уулу", "got": "" }, "given_names": { "expected": "Чыңгыз Айтматов Исхак Раззаков", "got": "Төрөкул Чыңгыз Айтматов Исхак Раззаков Парламент мүчөсү" }, "suffix": { "expected": "Парламент мүчөсү", "got": "" }, "text": "уулу Төрөкул Чыңгыз Айтматов Исхак Раззаков Парламент мүчөсү" }, "ky/nativeG/sorting": { "family": { "expected": "", "got": "Сыймык" }, "given_names": { "expected": "Сыймык", "got": "" }, "text": "Сыймык" }, "ky/nativeG/surnameFirst": { "family": { "expected": "", "got": "Сыймык" }, "given_names": { "expected": "Сыймык", "got": "" }, "text": "Сыймык" }, "lo/foreignFull/givenFirst": { "family": { "expected": "ວອນ ບຣູລ", "got": "ຮຸ່ນລູກ" }, "family_base": { "expected": "ບຣູລ", "got": "ຮຸ່ນລູກ" }, "family_particles": { "expected": "ວອນ", "got": "" }, "given_names": { "expected": "ເອດາ ຄໍນີເລຍ ເຊຊາ ມາຕິນ", "got": "ເອດາ ຄໍນີເລຍ ເຊຊາ ມາຕິນ ວອນ ບຣູລ" }, "suffix": { "expected": "ດຣ. ປອ. ຮຸ່ນລູກ", "got": "" }, "text": "ດຣ. ປອ. ສຈ. ປອ. ເອດາ ຄໍນີເລຍ ເຊຊາ ມາຕິນ ວອນ ບຣູລ ຮຸ່ນລູກ", "title": { "expected": "ສຈ. ປອ.", "got": "ດຣ. ປອ. ສຈ. ປອ." } }, "lo/foreignFull/sorting": { "family": { "expected": "ບຣູລ ວອນ", "got": "ບຣູລ" }, "family_particles": { "expected": "ວອນ", "got": "" }, "given_names": { "expected": "ເອດາ ຄໍນີເລຍ ເຊຊາ ມາຕິນ", "got": "ເອດາ ຄໍນີເລຍ ເຊຊາ ມາຕິນ ວອນ" }, "text": "ບຣູລ, ເອດາ ຄໍນີເລຍ ເຊຊາ ມາຕິນ ວອນ" }, "lo/foreignFull/surnameFirst": { "family": { "expected": "ວອນ ບຣູລ", "got": "ວອນ" }, "family_base": { "expected": "ບຣູລ", "got": "ວອນ" }, "family_particles": { "expected": "ວອນ", "got": "" }, "given_names": { "expected": "ເອດາ ຄໍນີເລຍ ເຊຊາ ມາຕິນ", "got": "ບຣູລ ສຈ. ປອ. ເອດາ ຄໍນີເລຍ ເຊຊາ ມາຕິນ ຮຸ່ນລູກ" }, "suffix": { "expected": "ດຣ. ປອ. ຮຸ່ນລູກ", "got": "" }, "text": "ດຣ. ປອ. ວອນ ບຣູລ ສຈ. ປອ. ເອດາ ຄໍນີເລຍ ເຊຊາ ມາຕິນ ຮຸ່ນລູກ", "title": { "expected": "ສຈ. ປອ.", "got": "ດຣ. ປອ." } }, "lo/foreignG/sorting": { "family": { "expected": "", "got": "ຊິນແບດ" }, "given_names": { "expected": "ຊິນແບດ", "got": "" }, "text": "ຊິນແບດ" }, "lo/foreignG/surnameFirst": { "family": { "expected": "", "got": "ຊິນແບດ" }, "given_names": { "expected": "ຊິນແບດ", "got": "" }, "text": "ຊິນແບດ" }, "lo/nativeFull/givenFirst": { "family": { "expected": "ວູສເຕີ", "got": "ຮຸ່ນລູກ" }, "family_base": { "expected": "ວູສເຕີ", "got": "ຮຸ່ນລູກ" }, "given_names": { "expected": "ເບີແທຣັມ ວິວເບີຟອດຊ ເຮັນຣີ ໂຣເບີດ", "got": "ສ.ສ. ທ. ເບີແທຣັມ ວິວເບີຟອດຊ ເຮັນຣີ ໂຣເບີດ ວູສເຕີ" }, "suffix": { "expected": "ສ.ສ. ຮຸ່ນລູກ", "got": "" }, "text": "ສ.ສ. ທ. ເບີແທຣັມ ວິວເບີຟອດຊ ເຮັນຣີ ໂຣເບີດ ວູສເຕີ ຮຸ່ນລູກ", "title": { "expected": "ທ.", "got": "" } }, "lo/nativeFull/surnameFirst": { "family": { "expected": "ວູສເຕີ", "got": "ສ.ສ." }, "family_base": { "expected": "ວູສເຕີ", "got": "ສ.ສ." }, "given_names": { "expected": "ເບີແທຣັມ ວິວເບີຟອດຊ ເຮັນຣີ ໂຣເບີດ", "got": "ວູສເຕີ ທ. ເບີແທຣັມ ວິວເບີຟອດຊ ເຮັນຣີ ໂຣເບີດ ຮຸ່ນລູກ" }, "suffix": { "expected": "ສ.ສ. ຮຸ່ນລູກ", "got": "" }, "text": "ສ.ສ. ວູສເຕີ ທ. ເບີແທຣັມ ວິວເບີຟອດຊ ເຮັນຣີ ໂຣເບີດ ຮຸ່ນລູກ", "title": { "expected": "ທ.", "got": "" } }, "lo/nativeG/sorting": { "family": { "expected": "", "got": "ຄຳຫຼ້າ" }, "given_names": { "expected": "ຄຳຫຼ້າ", "got": "" }, "text": "ຄຳຫຼ້າ" }, "lo/nativeG/surnameFirst": { "family": { "expected": "", "got": "ຄຳຫຼ້າ" }, "given_names": { "expected": "ຄຳຫຼ້າ", "got": "" }, "text": "ຄຳຫຼ້າ" }, "lt/foreignFull/givenFirst": { "given_names": { "expected": "Ada Kornelija Eva Sofija", "got": "m. dr. prof. dr. Ada Kornelija Eva Sofija" }, "suffix": { "expected": "med. m. dr.", "got": "" }, "text": "med. m. dr. prof. dr. Ada Kornelija Eva Sofija von Vilkas Jankauskas-Baranauskas", "title": { "expected": "prof. dr.", "got": "med." } }, "lt/foreignG/sorting": { "family": { "expected": "", "got": "Jurgis" }, "given_names": { "expected": "Jurgis", "got": "" }, "text": "Jurgis" }, "lt/foreignG/surnameFirst": { "family": { "expected": "", "got": "Jurgis" }, "given_names": { "expected": "Jurgis", "got": "" }, "text": "Jurgis" }, "lt/nativeFull/givenFirst": { "given_names": { "expected": "Gabrielė Aldona", "got": "p. Gabrielė Aldona" }, "suffix": { "expected": "dr.", "got": "" }, "text": "dr. p. Gabrielė Aldona Petraitytė", "title": { "expected": "p.", "got": "dr." } }, "lt/nativeFull/surnameFirst": { "family": { "expected": "Petraitytė", "got": "p." }, "family_base": { "expected": "Petraitytė", "got": "p." }, "given_names": { "expected": "Gabrielė Aldona", "got": "Petraitytė Gabrielė Aldona" }, "text": "p. Petraitytė Gabrielė Aldona", "title": { "expected": "p.", "got": "" } }, "lt/nativeG/sorting": { "family": { "expected": "", "got": "Rokas" }, "given_names": { "expected": "Rokas", "got": "" }, "text": "Rokas" }, "lt/nativeG/surnameFirst": { "family": { "expected": "", "got": "Rokas" }, "given_names": { "expected": "Rokas", "got": "" }, "text": "Rokas" }, "lv/foreignFull/givenFirst": { "suffix": { "expected": "Dr.philol.", "got": "" }, "text": "Dr.philol. Laima Kristīne Eva Sofija van den Volfa Bērziņa-Kalniņa", "title": { "expected": "", "got": "Dr.philol." } }, "lv/foreignFull/sorting": { "family": { "expected": "van den Volfa", "got": "van" }, "family_base": { "expected": "Volfa", "got": "van" }, "family_particles": { "expected": "van den", "got": "" }, "given_names": { "expected": "Laima Kristīne Eva Sofija", "got": "den Volfa Laima Kristīne Eva Sofija" }, "text": "van den Volfa Laima Kristīne Eva Sofija" }, "lv/foreignFull/surnameFirst": { "family": { "expected": "van den Volfa", "got": "van" }, "family_base": { "expected": "Volfa", "got": "van" }, "family_particles": { "expected": "van den", "got": "" }, "given_names": { "expected": "Laima Kristīne", "got": "den Volfa Laima Kristīne" }, "text": "van den Volfa Laima Kristīne" }, "lv/foreignG/sorting": { "family": { "expected": "", "got": "Sinbads" }, "given_names": { "expected": "Sinbads", "got": "" }, "text": "Sinbads" }, "lv/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbads" }, "given_names": { "expected": "Sinbads", "got": "" }, "text": "Sinbads" }, "lv/nativeFull/givenFirst": { "given_names": { "expected": "Pēteris Kārlis", "got": "MP Pēteris Kārlis" }, "suffix": { "expected": "MP", "got": "" }, "text": "MP Pēteris Kārlis Ozoliņš" }, "lv/nativeG/sorting": { "family": { "expected": "", "got": "Jānis" }, "given_names": { "expected": "Jānis", "got": "" }, "text": "Jānis" }, "lv/nativeG/surnameFirst": { "family": { "expected": "", "got": "Jānis" }, "given_names": { "expected": "Jānis", "got": "" }, "text": "Jānis" }, "mk/foreignFull/givenFirst": { "family": { "expected": "ван ден Волф", "got": "Волф" }, "family_particles": { "expected": "ван ден", "got": "" }, "given_names": { "expected": "Ана Марија Цезар Мартин", "got": "Ана Марија Цезар Мартин ван ден" }, "text": "проф. д-р Ана Марија Цезар Мартин ван ден Волф" }, "mk/foreignFull/sorting": { "family": { "expected": "Волф ван ден", "got": "Волф" }, "family_particles": { "expected": "ван ден", "got": "" }, "given_names": { "expected": "Ана Марија Цезар Мартин", "got": "Ана Марија Цезар Мартин ван ден" }, "text": "Волф, Ана Марија Цезар Мартин ван ден" }, "mk/foreignFull/surnameFirst": { "family": { "expected": "ван ден Волф", "got": "ван" }, "family_base": { "expected": "Волф", "got": "ван" }, "family_particles": { "expected": "ван ден", "got": "" }, "given_names": { "expected": "Ана Марија Цезар Мартин", "got": "ден Волф Ана Марија Цезар Мартин" }, "text": "проф. д-р ван ден Волф Ана Марија Цезар Мартин" }, "mk/foreignG/sorting": { "family": { "expected": "", "got": "Синбад" }, "given_names": { "expected": "Синбад", "got": "" }, "text": "Синбад" }, "mk/foreignG/surnameFirst": { "family": { "expected": "", "got": "Синбад" }, "given_names": { "expected": "Синбад", "got": "" }, "text": "Синбад" }, "mk/nativeG/sorting": { "family": { "expected": "", "got": "Крсте" }, "given_names": { "expected": "Крсте", "got": "" }, "text": "Крсте" }, "mk/nativeG/surnameFirst": { "family": { "expected": "", "got": "Крсте" }, "given_names": { "expected": "Крсте", "got": "" }, "text": "Крсте" }, "ml/foreignFull/givenFirst": { "family": { "expected": "വാൻ ബ്ര്യൂൾ", "got": "ഡി.ഡി.എസ്" }, "family_base": { "expected": "ബ്ര്യൂൾ", "got": "ഡി.ഡി.എസ്" }, "family_particles": { "expected": "വാൻ", "got": "" }, "given_names": { "expected": "എയ്‌ഡ കോർണീലിയ സീസ൪ മാ൪ട്ടിൻ", "got": "എയ്‌ഡ കോർണീലിയ സീസ൪ മാ൪ട്ടിൻ വാൻ ബ്ര്യൂൾ എം.ഡി." }, "suffix": { "expected": "എം.ഡി. ഡി.ഡി.എസ്", "got": "" }, "text": "എയ്‌ഡ കോർണീലിയ സീസ൪ മാ൪ട്ടിൻ വാൻ ബ്ര്യൂൾ എം.ഡി. ഡി.ഡി.എസ്" }, "ml/foreignFull/sorting": { "family": { "expected": "ബ്ര്യൂൾ വാൻ", "got": "ബ്ര്യൂൾ" }, "family_particles": { "expected": "വാൻ", "got": "" }, "given_names": { "expected": "എയ്‌ഡ കോർണീലിയ സീസ൪ മാ൪ട്ടിൻ", "got": "എയ്‌ഡ കോർണീലിയ സീസ൪ മാ൪ട്ടിൻ വാൻ" }, "text": "ബ്ര്യൂൾ, എയ്‌ഡ കോർണീലിയ സീസ൪ മാ൪ട്ടിൻ വാൻ" }, "ml/foreignFull/surnameFirst": { "family": { "expected": "വാൻ ബ്ര്യൂൾ", "got": "വാൻ ബ്ര്യൂൾ പ്രൊഫ. ഡോ. എയ്‌ഡ കോർണീലിയ സീസ൪ മാ൪ട്ടിൻ" }, "family_base": { "expected": "ബ്ര്യൂൾ", "got": "വാൻ ബ്ര്യൂൾ പ്രൊഫ. ഡോ. എയ്‌ഡ കോർണീലിയ സീസ൪ മാ൪ട്ടിൻ" }, "family_particles": { "expected": "വാൻ", "got": "" }, "given_names": { "expected": "എയ്‌ഡ കോർണീലിയ സീസ൪ മാ൪ട്ടിൻ", "got": "എം.ഡി. ഡി.ഡി.എസ്" }, "suffix": { "expected": "ജൂനിയർ എം.ഡി. ഡി.ഡി.എസ്", "got": "ജൂനിയർ" }, "text": "വാൻ ബ്ര്യൂൾ പ്രൊഫ. ഡോ. എയ്‌ഡ കോർണീലിയ സീസ൪ മാ൪ട്ടിൻ ജൂനിയർ, എം.ഡി. ഡി.ഡി.എസ്", "title": { "expected": "പ്രൊഫ. ഡോ.", "got": "" } }, "ml/foreignG/sorting": { "family": { "expected": "", "got": "സിൻബാദ്" }, "given_names": { "expected": "സിൻബാദ്", "got": "" }, "text": "സിൻബാദ്" }, "ml/foreignG/surnameFirst": { "family": { "expected": "", "got": "സിൻബാദ്" }, "given_names": { "expected": "സിൻബാദ്", "got": "" }, "text": "സിൻബാദ്" }, "ml/nativeFull/givenFirst": { "family": { "expected": "കറുവാപ്പറമ്പിൽ", "got": "എംപി" }, "family_base": { "expected": "കറുവാപ്പറമ്പിൽ", "got": "എംപി" }, "given_names": { "expected": "തോമസ് കുരുവിള അന്തോണി", "got": "തോമസ് കുരുവിള അന്തോണി കറുവാപ്പറമ്പിൽ" }, "suffix": { "expected": "എംപി", "got": "" }, "text": "തോമസ് കുരുവിള അന്തോണി കറുവാപ്പറമ്പിൽ എംപി" }, "ml/nativeFull/surnameFirst": { "family": { "expected": "കറുവാപ്പറമ്പിൽ", "got": "കറുവാപ്പറമ്പിൽ ശ്രീ. തോമസ് കുരുവിള അന്തോണി" }, "family_base": { "expected": "കറുവാപ്പറമ്പിൽ", "got": "കറുവാപ്പറമ്പിൽ ശ്രീ. തോമസ് കുരുവിള അന്തോണി" }, "given_names": { "expected": "തോമസ് കുരുവിള അന്തോണി", "got": "എംപി" }, "suffix": { "expected": "ജൂനിയർ എംപി", "got": "ജൂനിയർ" }, "text": "കറുവാപ്പറമ്പിൽ ശ്രീ. തോമസ് കുരുവിള അന്തോണി ജൂനിയർ, എംപി", "title": { "expected": "ശ്രീ.", "got": "" } }, "ml/nativeG/sorting": { "family": { "expected": "", "got": "ദീപ" }, "given_names": { "expected": "ദീപ", "got": "" }, "text": "ദീപ" }, "ml/nativeG/surnameFirst": { "family": { "expected": "", "got": "ദീപ" }, "given_names": { "expected": "ദീപ", "got": "" }, "text": "ദീപ" }, "mn/foreignFull/givenFirst": { "family": { "expected": "вон Брюхл", "got": "Др. Ада Корнелиа Эва Софиа вон Брюхл Жр" }, "family_base": { "expected": "Брюхл", "got": "Др. Ада Корнелиа Эва Софиа вон Брюхл Жр" }, "family_particles": { "expected": "вон", "got": "" }, "given_names": { "expected": "Ада Корнелиа Эва Софиа", "got": "Анагаах ухааны доктор" }, "suffix": { "expected": "Жр Анагаах ухааны доктор", "got": "" }, "text": "Проф. Др. Ада Корнелиа Эва Софиа вон Брюхл Жр, Анагаах ухааны доктор", "title": { "expected": "Проф. Др.", "got": "Проф." } }, "mn/foreignFull/sorting": { "family": { "expected": "Брюхл вон", "got": "Брюхл" }, "family_particles": { "expected": "вон", "got": "" }, "given_names": { "expected": "Ада Корнелиа Эва Софиа", "got": "Ада Корнелиа Эва Софиа вон" }, "text": "Брюхл, Ада Корнелиа Эва Софиа вон" }, "mn/foreignFull/surnameFirst": { "family": { "expected": "вон Брюхл Гонзалес Доминго", "got": "вон" }, "family_base": { "expected": "Брюхл Гонзалес Доминго", "got": "вон" }, "family_particles": { "expected": "вон", "got": "" }, "given_names": { "expected": "Ада Корнелиа Эва Софиа", "got": "Брюхл Гонзалес Доминго Проф. Др. Ада Корнелиа Эва Софиа Анагаах ухааны доктор" }, "suffix": { "expected": "Анагаах ухааны доктор", "got": "" }, "text": "вон Брюхл Гонзалес Доминго Проф. Др. Ада Корнелиа Эва Софиа Анагаах ухааны доктор", "title": { "expected": "Проф. Др.", "got": "" } }, "mn/foreignG/sorting": { "family": { "expected": "", "got": "Синбад" }, "given_names": { "expected": "Синбад", "got": "" }, "text": "Синбад" }, "mn/foreignG/surnameFirst": { "family": { "expected": "", "got": "Синбад" }, "given_names": { "expected": "Синбад", "got": "" }, "text": "Синбад" }, "mn/nativeFull/givenFirst": { "given_names": { "expected": "Азжаргал", "got": "Ноён Азжаргал" }, "text": "Ноён Азжаргал Төмөр", "title": { "expected": "Ноён", "got": "" } }, "mn/nativeFull/surnameFirst": { "given_names": { "expected": "Азжаргал", "got": "Ноён Азжаргал" }, "text": "Төмөр Ноён Азжаргал", "title": { "expected": "Ноён", "got": "" } }, "mn/nativeG/sorting": { "family": { "expected": "", "got": "Бат" }, "given_names": { "expected": "Бат", "got": "" }, "text": "Бат" }, "mn/nativeG/surnameFirst": { "family": { "expected": "", "got": "Бат" }, "given_names": { "expected": "Бат", "got": "" }, "text": "Бат" }, "mr/foreignFull/givenFirst": { "family": { "expected": "व्हॉन ब्र्यूल", "got": "अदा शर्मा सीझर मार्टिन व्हॉन ब्र्यूल ज्यु." }, "family_base": { "expected": "ब्र्यूल", "got": "अदा शर्मा सीझर मार्टिन व्हॉन ब्र्यूल ज्यु." }, "family_particles": { "expected": "व्हॉन", "got": "" }, "given_names": { "expected": "अदा शर्मा सीझर मार्टिन", "got": "डी. डी. डी. एस." }, "suffix": { "expected": "ज्यु. एम. डी. डी. डी. एस.", "got": "" }, "text": "प्रोफे.डॉ. अदा शर्मा सीझर मार्टिन व्हॉन ब्र्यूल ज्यु., एम. डी. डी. डी. एस.", "title": { "expected": "प्रोफे.डॉ.", "got": "प्रोफे.डॉ. एम." } }, "mr/foreignFull/sorting": { "family": { "expected": "ब्र्यूल व्हॉन", "got": "ब्र्यूल" }, "family_particles": { "expected": "व्हॉन", "got": "" }, "given_names": { "expected": "अदा शर्मा सीझर मार्टिन", "got": "अदा शर्मा सीझर मार्टिन व्हॉन" }, "text": "ब्र्यूल, अदा शर्मा सीझर मार्टिन व्हॉन" }, "mr/foreignFull/surnameFirst": { "family": { "expected": "व्हॉन ब्र्यूल", "got": "व्हॉन ब्र्यूल प्रोफे.डॉ. अदा शर्मा सीझर मार्टिन ज्यु." }, "family_base": { "expected": "ब्र्यूल", "got": "व्हॉन ब्र्यूल प्रोफे.डॉ. अदा शर्मा सीझर मार्टिन ज्यु." }, "family_particles": { "expected": "व्हॉन", "got": "" }, "given_names": { "expected": "अदा शर्मा सीझर मार्टिन", "got": "डी. डी. डी. एस." }, "suffix": { "expected": "ज्यु. एम. डी. डी. डी. एस.", "got": "" }, "text": "व्हॉन ब्र्यूल प्रोफे.डॉ. अदा शर्मा सीझर मार्टिन ज्यु., एम. डी. डी. डी. एस.", "title": { "expected": "प्रोफे.डॉ.", "got": "एम." } }, "mr/foreignG/sorting": { "family": { "expected": "", "got": "सिंदबाद" }, "given_names": { "expected": "सिंदबाद", "got": "" }, "text": "सिंदबाद" }, "mr/foreignG/surnameFirst": { "family": { "expected": "", "got": "सिंदबाद" }, "given_names": { "expected": "सिंदबाद", "got": "" }, "text": "सिंदबाद" }, "mr/nativeFull/givenFirst": { "family": { "expected": "देशपांडे", "got": "धिरज देशपांडे विजय देशपांडे देशपांडे क." }, "family_base": { "expected": "देशपांडे", "got": "धिरज देशपांडे विजय देशपांडे देशपांडे क." }, "given_names": { "expected": "धिरज देशपांडे विजय देशपांडे", "got": "पी." }, "suffix": { "expected": "क. एम. पी.", "got": "" }, "text": "श्री. धिरज देशपांडे विजय देशपांडे देशपांडे क., एम. पी.", "title": { "expected": "श्री.", "got": "श्री. एम." } }, "mr/nativeFull/surnameFirst": { "family": { "expected": "देशपांडे", "got": "देशपांडे श्री. धिरज देशपांडे विजय देशपांडे क." }, "family_base": { "expected": "देशपांडे", "got": "देशपांडे श्री. धिरज देशपांडे विजय देशपांडे क." }, "given_names": { "expected": "धिरज देशपांडे विजय देशपांडे", "got": "पी." }, "suffix": { "expected": "क. एम. पी.", "got": "" }, "text": "देशपांडे श्री. धिरज देशपांडे विजय देशपांडे क., एम. पी.", "title": { "expected": "श्री.", "got": "एम." } }, "mr/nativeG/sorting": { "family": { "expected": "", "got": "माधुरी" }, "given_names": { "expected": "माधुरी", "got": "" }, "text": "माधुरी" }, "mr/nativeG/surnameFirst": { "family": { "expected": "", "got": "माधुरी" }, "given_names": { "expected": "माधुरी", "got": "" }, "text": "माधुरी" }, "ms/foreignFull/surnameFirst": { "family": { "expected": "von Bruhel", "got": "von" }, "family_base": { "expected": "Bruhel", "got": "von" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "Ada Cornelia Martina", "got": "Bruhel Ada Cornelia Martina" }, "text": "von Bruhel Ada Cornelia Martina MD DDS" }, "ms/foreignG/sorting": { "family": { "expected": "", "got": "John" }, "given_names": { "expected": "John", "got": "" }, "text": "John" }, "ms/foreignG/surnameFirst": { "family": { "expected": "", "got": "John" }, "given_names": { "expected": "John", "got": "" }, "text": "John" }, "ms/nativeFull/givenFirst": { "family": { "expected": "Syed A Ismail Petra", "got": "Petra" }, "family_base": { "expected": "Ismail Petra", "got": "Petra" }, "family_particles": { "expected": "Syed A", "got": "" }, "given_names": { "expected": "Syed Abd Aziz", "got": "Syed Abd Aziz Syed A Ismail" }, "text": "Tn. Syed Abd Aziz Syed A Ismail Petra MP" }, "ms/nativeFull/sorting": { "family": { "expected": "Ismail Syed A", "got": "Ismail" }, "family_particles": { "expected": "Syed A", "got": "" }, "given_names": { "expected": "Syed Abd Aziz", "got": "Syed Aziz Syed A" }, "suffix": { "expected": "", "got": "Abd" }, "text": "Ismail, Syed Abd Aziz Syed A" }, "ms/nativeFull/surnameFirst": { "family": { "expected": "Syed A Ismail", "got": "Syed" }, "family_base": { "expected": "Ismail", "got": "Syed" }, "family_particles": { "expected": "Syed A", "got": "" }, "given_names": { "expected": "Syed Abd Aziz", "got": "A Ismail Syed Abd Aziz" }, "text": "Syed A Ismail Syed Abd Aziz MP" }, "ms/nativeG/sorting": { "family": { "expected": "", "got": "Sulaiman" }, "given_names": { "expected": "Sulaiman", "got": "" }, "text": "Sulaiman" }, "ms/nativeG/surnameFirst": { "family": { "expected": "", "got": "Sulaiman" }, "given_names": { "expected": "Sulaiman", "got": "" }, "text": "Sulaiman" }, "ms/nativeGGS/givenFirst": { "family": { "expected": "Syed Hassan", "got": "Hassan" }, "given_names": { "expected": "Syed Sadiq", "got": "Syed Sadiq Syed" }, "text": "Syed Sadiq Syed Hassan" }, "ms/nativeGGS/surnameFirst": { "family": { "expected": "Syed Hassan", "got": "Syed" }, "given_names": { "expected": "Syed Sadiq", "got": "Hassan Syed Sadiq" }, "text": "Syed Hassan Syed Sadiq" }, "my/foreignFull/givenFirst": { "family": { "expected": "ဝူစ်တာ ဗွန် ဝူစ်တာ ဘရူ", "got": "ဒေါက်တာ" }, "family_base": { "expected": "ဝူစ်တာ ဘရူ", "got": "ဒေါက်တာ" }, "family_particles": { "expected": "ဝူစ်တာ ဗွန်", "got": "" }, "given_names": { "expected": "အာဒါ ကွန်နီလီယာ ဆီဇာ မာတင်", "got": "ပရော်ဖက်ဆာ ဒေါက်တာ အာဒါ ကွန်နီလီယာ ဆီဇာ မာတင် ဝူစ်တာ ဗွန် ဝူစ်တာ ဘရူ ဂျူနီယာ၊" }, "suffix": { "expected": "ဒေါက်တာ ဂျူနီယာ", "got": "" }, "text": "ပရော်ဖက်ဆာ ဒေါက်တာ အာဒါ ကွန်နီလီယာ ဆီဇာ မာတင် ဝူစ်တာ ဗွန် ဝူစ်တာ ဘရူ ဂျူနီယာ၊ ဒေါက်တာ", "title": { "expected": "ပရော်ဖက်ဆာ ဒေါက်တာ", "got": "" } }, "my/foreignFull/sorting": { "family": { "expected": "ဝူစ်တာ ဘရူ ဝူစ်တာ ဗွန်", "got": "ဝူစ်တာ" }, "family_base": { "expected": "ဝူစ်တာ ဘရူ", "got": "ဝူစ်တာ" }, "family_particles": { "expected": "ဝူစ်တာ ဗွန်", "got": "" }, "given_names": { "expected": "အာဒါ ကွန်နီလီယာ ဆီဇာ မာတင်", "got": "ဘရူ၊ အာဒါ ကွန်နီလီယာ ဆီဇာ မာတင် ဝူစ်တာ ဗွန်" }, "text": "ဝူစ်တာ ဘရူ၊ အာဒါ ကွန်နီလီယာ ဆီဇာ မာတင် ဝူစ်တာ ဗွန်" }, "my/foreignFull/surnameFirst": { "family": { "expected": "ဝူစ်တာ ဗွန် ဝူစ်တာ ဘရူ", "got": "ဝူစ်တာ" }, "family_base": { "expected": "ဝူစ်တာ ဘရူ", "got": "ဝူစ်တာ" }, "family_particles": { "expected": "ဝူစ်တာ ဗွန်", "got": "" }, "given_names": { "expected": "အာဒါ ကွန်နီလီယာ ဆီဇာ မာတင်", "got": "ဗွန် ဝူစ်တာ ဘရူ ပရော်ဖက်ဆာ ဒေါက်တာ အာဒါ ကွန်နီလီယာ ဆီဇာ မာတင် ဂျူနီယာ၊ ဒေါက်တာ" }, "suffix": { "expected": "ဒေါက်တာ ဂျူနီယာ", "got": "" }, "text": "ဝူစ်တာ ဗွန် ဝူစ်တာ ဘရူ ပရော်ဖက်ဆာ ဒေါက်တာ အာဒါ ကွန်နီလီယာ ဆီဇာ မာတင် ဂျူနီယာ၊ ဒေါက်တာ", "title": { "expected": "ပရော်ဖက်ဆာ ဒေါက်တာ", "got": "" } }, "my/foreignG/sorting": { "family": { "expected": "", "got": "ဆင်းဘတ်" }, "given_names": { "expected": "ဆင်းဘတ်", "got": "" }, "text": "ဆင်းဘတ်" }, "my/foreignG/surnameFirst": { "family": { "expected": "", "got": "ဆင်းဘတ်" }, "given_names": { "expected": "ဆင်းဘတ်", "got": "" }, "text": "ဆင်းဘတ်" }, "my/nativeFull/givenFirst": { "family": { "expected": "ဝူစ်တာ", "got": "ဒေါက်တာ" }, "family_base": { "expected": "ဝူစ်တာ", "got": "ဒေါက်တာ" }, "given_names": { "expected": "ဘာထရမ် ဝဲလ်ဘာဖို့စ် ဟင်နရီ ရောဘတ်", "got": "မစ္စတာ ဘာထရမ် ဝဲလ်ဘာဖို့စ် ဟင်နရီ ရောဘတ် ဝူစ်တာ ဂျူနီယာ၊" }, "suffix": { "expected": "ဂျူနီယာ ဒေါက်တာ", "got": "" }, "text": "မစ္စတာ ဘာထရမ် ဝဲလ်ဘာဖို့စ် ဟင်နရီ ရောဘတ် ဝူစ်တာ ဂျူနီယာ၊ ဒေါက်တာ", "title": { "expected": "မစ္စတာ", "got": "" } }, "my/nativeFull/surnameFirst": { "given_names": { "expected": "ဘာထရမ် ဝဲလ်ဘာဖို့စ် ဟင်နရီ ရောဘတ်", "got": "မစ္စတာ ဘာထရမ် ဝဲလ်ဘာဖို့စ် ဟင်နရီ ရောဘတ် ဂျူနီယာ၊ ဒေါက်တာ" }, "suffix": { "expected": "ဂျူနီယာ ဒေါက်တာ", "got": "" }, "text": "ဝူစ်တာ မစ္စတာ ဘာထရမ် ဝဲလ်ဘာဖို့စ် ဟင်နရီ ရောဘတ် ဂျူနီယာ၊ ဒေါက်တာ", "title": { "expected": "မစ္စတာ", "got": "" } }, "my/nativeG/sorting": { "family": { "expected": "", "got": "ဇင်ဒါယာ" }, "given_names": { "expected": "ဇင်ဒါယာ", "got": "" }, "text": "ဇင်ဒါယာ" }, "my/nativeG/surnameFirst": { "family": { "expected": "", "got": "ဇင်ဒါယာ" }, "given_names": { "expected": "ဇင်ဒါယာ", "got": "" }, "text": "ဇင်ဒါယာ" }, "ne/foreignFull/givenFirst": { "family": { "expected": "भोन ब्रुहल", "got": "डिडिएस" }, "family_base": { "expected": "ब्रुहल", "got": "डिडिएस" }, "family_particles": { "expected": "भोन", "got": "" }, "given_names": { "expected": "एडा कर्नेलिया सिजर मार्टिन", "got": "एडा कर्नेलिया सिजर मार्टिन भोन ब्रुहल एमडी" }, "suffix": { "expected": "एमडी डिडिएस", "got": "" }, "text": "एडा कर्नेलिया सिजर मार्टिन भोन ब्रुहल एमडी डिडिएस" }, "ne/foreignFull/sorting": { "family": { "expected": "ब्रुहल भोन", "got": "ब्रुहल" }, "family_particles": { "expected": "भोन", "got": "" }, "given_names": { "expected": "एडा कर्नेलिया सिजर मार्टिन", "got": "एडा कर्नेलिया सिजर मार्टिन भोन" }, "text": "ब्रुहल, एडा कर्नेलिया सिजर मार्टिन भोन" }, "ne/foreignFull/surnameFirst": { "family": { "expected": "भोन ब्रुहल", "got": "भोन" }, "family_base": { "expected": "ब्रुहल", "got": "भोन" }, "family_particles": { "expected": "भोन", "got": "" }, "given_names": { "expected": "एडा कर्नेलिया सिजर मार्टिन", "got": "ब्रुहल एडा कर्नेलिया सिजर मार्टिन एमडी डिडिएस" }, "suffix": { "expected": "एमडी डिडिएस", "got": "" }, "text": "भोन ब्रुहल एडा कर्नेलिया सिजर मार्टिन एमडी डिडिएस" }, "ne/foreignG/sorting": { "family": { "expected": "", "got": "सिनबाद" }, "given_names": { "expected": "सिनबाद", "got": "" }, "text": "सिनबाद" }, "ne/foreignG/surnameFirst": { "family": { "expected": "", "got": "सिनबाद" }, "given_names": { "expected": "सिनबाद", "got": "" }, "text": "सिनबाद" }, "ne/nativeFull/givenFirst": { "family": { "expected": "वुस्टर", "got": "एमपी" }, "family_base": { "expected": "वुस्टर", "got": "एमपी" }, "given_names": { "expected": "बर्ट्राम विलबरफोर्स हेनरी रोबर्ट", "got": "बर्ट्राम विलबरफोर्स हेनरी रोबर्ट वुस्टर" }, "suffix": { "expected": "एमपी", "got": "" }, "text": "बर्ट्राम विलबरफोर्स हेनरी रोबर्ट वुस्टर एमपी" }, "ne/nativeFull/surnameFirst": { "given_names": { "expected": "बर्ट्राम विलबरफोर्स हेनरी रोबर्ट", "got": "बर्ट्राम विलबरफोर्स हेनरी रोबर्ट एमपी" }, "suffix": { "expected": "एमपी", "got": "" }, "text": "वुस्टर बर्ट्राम विलबरफोर्स हेनरी रोबर्ट एमपी" }, "ne/nativeG/sorting": { "family": { "expected": "", "got": "सुन्दर" }, "given_names": { "expected": "सुन्दर", "got": "" }, "text": "सुन्दर" }, "ne/nativeG/surnameFirst": { "family": { "expected": "", "got": "सुन्दर" }, "given_names": { "expected": "सुन्दर", "got": "" }, "text": "सुन्दर" }, "nl/foreignFull/surnameFirst": { "family": { "expected": "von Brühl", "got": "von" }, "family_base": { "expected": "Brühl", "got": "von" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "Ada Cornelia César Martín", "got": "Brühl mevrouw Ada Cornelia César Martín" }, "text": "von Brühl mevrouw Ada Cornelia César Martín jr., PhD", "title": { "expected": "mevrouw", "got": "" } }, "nl/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "nl/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "nl/nativeFull/surnameFirst": { "given_names": { "expected": "Bertus Wevers Harry Robert", "got": "dhr. Bertus Wevers Harry Robert" }, "text": "de Jong dhr. Bertus Wevers Harry Robert jr., mp", "title": { "expected": "dhr.", "got": "" } }, "nl/nativeG/sorting": { "family": { "expected": "", "got": "Fatima" }, "given_names": { "expected": "Fatima", "got": "" }, "text": "Fatima" }, "nl/nativeG/surnameFirst": { "family": { "expected": "", "got": "Fatima" }, "given_names": { "expected": "Fatima", "got": "" }, "text": "Fatima" }, "nn/foreignFull/givenFirst": { "given_names": { "expected": "Inger Marie Adele Synnøve", "got": "m.d ph.d. Inger Marie Adele Synnøve" }, "suffix": { "expected": "m.d ph.d. jr.", "got": "jr." }, "text": "m.d ph.d. Inger Marie Adele Synnøve von Wolf jr." }, "nn/foreignFull/surnameFirst": { "family": { "expected": "von Wolf", "got": "m.d" }, "family_base": { "expected": "Wolf", "got": "m.d" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "Inger Marie Adele Synnøve", "got": "ph.d. von Wolf Inger Marie Adele Synnøve" }, "suffix": { "expected": "m.d ph.d.", "got": "" }, "text": "m.d ph.d. von Wolf Inger Marie Adele Synnøve" }, "nn/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "nn/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "nn/nativeFull/givenFirst": { "suffix": { "expected": "st.repr. jr.", "got": "jr." }, "text": "st.repr. Gregers Werle Marve Almar von Fleksnes jr.", "title": { "expected": "", "got": "st.repr." } }, "nn/nativeFull/surnameFirst": { "family": { "expected": "von Fleksnes", "got": "von" }, "family_base": { "expected": "Fleksnes", "got": "von" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "Gregers Werle Marve Almar", "got": "Fleksnes Gregers Werle Marve Almar" }, "suffix": { "expected": "st.repr.", "got": "" }, "text": "st.repr. von Fleksnes Gregers Werle Marve Almar", "title": { "expected": "", "got": "st.repr." } }, "nn/nativeG/sorting": { "family": { "expected": "", "got": "Harald" }, "given_names": { "expected": "Harald", "got": "" }, "text": "Harald" }, "nn/nativeG/surnameFirst": { "family": { "expected": "", "got": "Harald" }, "given_names": { "expected": "Harald", "got": "" }, "text": "Harald" }, "no/foreignFull/givenFirst": { "given_names": { "expected": "Inger Marie Adele Synnøve", "got": "m.d ph.d. Inger Marie Adele Synnøve" }, "suffix": { "expected": "m.d ph.d. jr.", "got": "jr." }, "text": "m.d ph.d. Inger Marie Adele Synnøve von Wolf jr." }, "no/foreignFull/surnameFirst": { "family": { "expected": "von Wolf", "got": "m.d" }, "family_base": { "expected": "Wolf", "got": "m.d" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "Inger Marie Adele Synnøve", "got": "ph.d. von Wolf Inger Marie Adele Synnøve" }, "suffix": { "expected": "m.d ph.d.", "got": "" }, "text": "m.d ph.d. von Wolf Inger Marie Adele Synnøve" }, "no/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "no/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "no/nativeFull/givenFirst": { "suffix": { "expected": "st.repr. jr.", "got": "jr." }, "text": "st.repr. Gregers Werle Marve Almar von Fleksnes jr.", "title": { "expected": "", "got": "st.repr." } }, "no/nativeFull/surnameFirst": { "family": { "expected": "von Fleksnes", "got": "von" }, "family_base": { "expected": "Fleksnes", "got": "von" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "Gregers Werle Marve Almar", "got": "Fleksnes Gregers Werle Marve Almar" }, "suffix": { "expected": "st.repr.", "got": "" }, "text": "st.repr. von Fleksnes Gregers Werle Marve Almar", "title": { "expected": "", "got": "st.repr." } }, "no/nativeG/sorting": { "family": { "expected": "", "got": "Harald" }, "given_names": { "expected": "Harald", "got": "" }, "text": "Harald" }, "no/nativeG/surnameFirst": { "family": { "expected": "", "got": "Harald" }, "given_names": { "expected": "Harald", "got": "" }, "text": "Harald" }, "or/foreignFull/givenFirst": { "family": { "expected": "ୱାନ୍ ଡେନ୍ ୱୋଲ୍ଫ", "got": "ଏମ.ଡି.ପିଏଚ.ଡି" }, "family_base": { "expected": "ୱୋଲ୍ଫ", "got": "ଏମ.ଡି.ପିଏଚ.ଡି" }, "family_particles": { "expected": "ୱାନ୍ ଡେନ୍", "got": "" }, "given_names": { "expected": "ଆଡା କର୍ଣ୍ଣେଲିଆ ଇଭା ସୋଫିଆ", "got": "ଆଡା କର୍ଣ୍ଣେଲିଆ ଇଭା ସୋଫିଆ ୱାନ୍ ଡେନ୍ ୱୋଲ୍ଫ" }, "suffix": { "expected": "ଏମ.ଡି.ପିଏଚ.ଡି", "got": "" }, "text": "ଆଡା କର୍ଣ୍ଣେଲିଆ ଇଭା ସୋଫିଆ ୱାନ୍ ଡେନ୍ ୱୋଲ୍ଫ ଏମ.ଡି.ପିଏଚ.ଡି" }, "or/foreignFull/sorting": { "family": { "expected": "ୱୋଲ୍ଫ ୱାନ୍ ଡେନ୍", "got": "ୱୋଲ୍ଫ" }, "family_particles": { "expected": "ୱାନ୍ ଡେନ୍", "got": "" }, "given_names": { "expected": "ଆଡା କର୍ଣ୍ଣେଲିଆ ଇଭା ସୋଫିଆ", "got": "ଆଡା କର୍ଣ୍ଣେଲିଆ ଇଭା ସୋଫିଆ ୱାନ୍ ଡେନ୍" }, "text": "ୱୋଲ୍ଫ, ଆଡା କର୍ଣ୍ଣେଲିଆ ଇଭା ସୋଫିଆ ୱାନ୍ ଡେନ୍" }, "or/foreignFull/surnameFirst": { "family": { "expected": "ୱାନ୍ ଡେନ୍ ୱୋଲ୍ଫ", "got": "ୱାନ୍" }, "family_base": { "expected": "ୱୋଲ୍ଫ", "got": "ୱାନ୍" }, "family_particles": { "expected": "ୱାନ୍ ଡେନ୍", "got": "" }, "given_names": { "expected": "ଆଡା କର୍ଣ୍ଣେଲିଆ ଇଭା ସୋଫିଆ", "got": "ଡେନ୍ ୱୋଲ୍ଫ ଆଡା କର୍ଣ୍ଣେଲିଆ ଇଭା ସୋଫିଆ ଏମ.ଡି.ପିଏଚ.ଡି" }, "suffix": { "expected": "ଏମ.ଡି.ପିଏଚ.ଡି", "got": "" }, "text": "ୱାନ୍ ଡେନ୍ ୱୋଲ୍ଫ ଆଡା କର୍ଣ୍ଣେଲିଆ ଇଭା ସୋଫିଆ ଏମ.ଡି.ପିଏଚ.ଡି" }, "or/foreignG/sorting": { "family": { "expected": "", "got": "ସିନବାଦ" }, "given_names": { "expected": "ସିନବାଦ", "got": "" }, "text": "ସିନବାଦ" }, "or/foreignG/surnameFirst": { "family": { "expected": "", "got": "ସିନବାଦ" }, "given_names": { "expected": "ସିନବାଦ", "got": "" }, "text": "ସିନବାଦ" }, "or/nativeFull/givenFirst": { "family": { "expected": "ୱୋଷ୍ଟର", "got": "ଏମପି" }, "family_base": { "expected": "ୱୋଷ୍ଟର", "got": "ଏମପି" }, "given_names": { "expected": "ବର୍ଟ୍ରାମ ୱିଲବର୍ଫୋର୍ସ ହେନେରୀ ରୋବର୍ଟ", "got": "ବର୍ଟ୍ରାମ ୱିଲବର୍ଫୋର୍ସ ହେନେରୀ ରୋବର୍ଟ ୱୋଷ୍ଟର" }, "suffix": { "expected": "ଏମପି", "got": "" }, "text": "ବର୍ଟ୍ରାମ ୱିଲବର୍ଫୋର୍ସ ହେନେରୀ ରୋବର୍ଟ ୱୋଷ୍ଟର ଏମପି" }, "or/nativeFull/surnameFirst": { "given_names": { "expected": "ବର୍ଟ୍ରାମ ୱିଲବର୍ଫୋର୍ସ ହେନେରୀ ରୋବର୍ଟ", "got": "ବର୍ଟ୍ରାମ ୱିଲବର୍ଫୋର୍ସ ହେନେରୀ ରୋବର୍ଟ ଏମପି" }, "suffix": { "expected": "ଏମପି", "got": "" }, "text": "ୱୋଷ୍ଟର ବର୍ଟ୍ରାମ ୱିଲବର୍ଫୋର୍ସ ହେନେରୀ ରୋବର୍ଟ ଏମପି" }, "or/nativeG/sorting": { "family": { "expected": "", "got": "ସିନବାଦ୍" }, "given_names": { "expected": "ସିନବାଦ୍", "got": "" }, "text": "ସିନବାଦ୍" }, "or/nativeG/surnameFirst": { "family": { "expected": "", "got": "ସିନବାଦ୍" }, "given_names": { "expected": "ସିନବାଦ୍", "got": "" }, "text": "ସିନବାଦ୍" }, "pa/foreignFull/givenFirst": { "family": { "expected": "ਵੈਨ ਡੇਨ ਵੌਲਫ਼ ਬੇਕਰ ਸਮਿੱਥ", "got": "ਡੀ.ਡੀ.ਐੱਸ." }, "family_base": { "expected": "ਵੌਲਫ਼ ਬੇਕਰ ਸਮਿੱਥ", "got": "ਡੀ.ਡੀ.ਐੱਸ." }, "family_particles": { "expected": "ਵੈਨ ਡੇਨ", "got": "" }, "given_names": { "expected": "ਐਡਾ ਕੋਰਨੇਲੀਆ ਈਵਾ ਸੋਫ਼ੀਆ", "got": "ਪ੍ਰੋ. ਡਾ. ਐਡਾ ਕੋਰਨੇਲੀਆ ਈਵਾ ਸੋਫ਼ੀਆ ਵੈਨ ਡੇਨ ਵੌਲਫ਼ ਬੇਕਰ ਸਮਿੱਥ ਐੱਮ.ਡੀ." }, "suffix": { "expected": "ਐੱਮ.ਡੀ. ਡੀ.ਡੀ.ਐੱਸ.", "got": "" }, "text": "ਪ੍ਰੋ. ਡਾ. ਐਡਾ ਕੋਰਨੇਲੀਆ ਈਵਾ ਸੋਫ਼ੀਆ ਵੈਨ ਡੇਨ ਵੌਲਫ਼ ਬੇਕਰ ਸਮਿੱਥ ਐੱਮ.ਡੀ. ਡੀ.ਡੀ.ਐੱਸ.", "title": { "expected": "ਪ੍ਰੋ. ਡਾ.", "got": "" } }, "pa/foreignFull/sorting": { "family": { "expected": "ਵੌਲਫ਼ ਵੈਨ ਡੇਨ", "got": "ਵੌਲਫ਼" }, "family_particles": { "expected": "ਵੈਨ ਡੇਨ", "got": "" }, "given_names": { "expected": "ਐਡਾ ਕੋਰਨੇਲੀਆ ਈਵਾ ਸੋਫ਼ੀਆ", "got": "ਐਡਾ ਕੋਰਨੇਲੀਆ ਈਵਾ ਸੋਫ਼ੀਆ ਵੈਨ ਡੇਨ" }, "text": "ਵੌਲਫ਼, ਐਡਾ ਕੋਰਨੇਲੀਆ ਈਵਾ ਸੋਫ਼ੀਆ ਵੈਨ ਡੇਨ" }, "pa/foreignFull/surnameFirst": { "family": { "expected": "ਵੈਨ ਡੇਨ ਵੌਲਫ਼", "got": "ਵੈਨ" }, "family_base": { "expected": "ਵੌਲਫ਼", "got": "ਵੈਨ" }, "family_particles": { "expected": "ਵੈਨ ਡੇਨ", "got": "" }, "given_names": { "expected": "ਐਡਾ ਕੋਰਨੇਲੀਆ ਈਵਾ ਸੋਫ਼ੀਆ", "got": "ਡੇਨ ਵੌਲਫ਼ ਐਡਾ ਕੋਰਨੇਲੀਆ ਈਵਾ ਸੋਫ਼ੀਆ ਐੱਮ.ਡੀ. ਡੀ.ਡੀ.ਐੱਸ." }, "suffix": { "expected": "ਐੱਮ.ਡੀ. ਡੀ.ਡੀ.ਐੱਸ.", "got": "" }, "text": "ਵੈਨ ਡੇਨ ਵੌਲਫ਼ ਐਡਾ ਕੋਰਨੇਲੀਆ ਈਵਾ ਸੋਫ਼ੀਆ ਐੱਮ.ਡੀ. ਡੀ.ਡੀ.ਐੱਸ." }, "pa/foreignG/sorting": { "family": { "expected": "", "got": "ਸਿੰਨਬੈਡ" }, "given_names": { "expected": "ਸਿੰਨਬੈਡ", "got": "" }, "text": "ਸਿੰਨਬੈਡ" }, "pa/foreignG/surnameFirst": { "family": { "expected": "", "got": "ਸਿੰਨਬੈਡ" }, "given_names": { "expected": "ਸਿੰਨਬੈਡ", "got": "" }, "text": "ਸਿੰਨਬੈਡ" }, "pa/nativeFull/givenFirst": { "family": { "expected": "ਵੂਸਟਰ", "got": "ਐਮ.ਪੀ." }, "family_base": { "expected": "ਵੂਸਟਰ", "got": "ਐਮ.ਪੀ." }, "given_names": { "expected": "ਬਰਟਰਮ ਵਿਲਬਰਫ਼ੋਰਸ ਹੈਨਰੀ ਰਾਬਰਟ", "got": "ਸ਼੍ਰੀ ਬਰਟਰਮ ਵਿਲਬਰਫ਼ੋਰਸ ਹੈਨਰੀ ਰਾਬਰਟ ਵੂਸਟਰ" }, "suffix": { "expected": "ਐਮ.ਪੀ.", "got": "" }, "text": "ਸ਼੍ਰੀ ਬਰਟਰਮ ਵਿਲਬਰਫ਼ੋਰਸ ਹੈਨਰੀ ਰਾਬਰਟ ਵੂਸਟਰ ਐਮ.ਪੀ.", "title": { "expected": "ਸ਼੍ਰੀ", "got": "" } }, "pa/nativeFull/surnameFirst": { "given_names": { "expected": "ਬਰਟਰਮ ਵਿਲਬਰਫ਼ੋਰਸ ਹੈਨਰੀ ਰਾਬਰਟ", "got": "ਬਰਟਰਮ ਵਿਲਬਰਫ਼ੋਰਸ ਹੈਨਰੀ ਰਾਬਰਟ ਐਮ.ਪੀ." }, "suffix": { "expected": "ਐਮ.ਪੀ.", "got": "" }, "text": "ਵੂਸਟਰ ਬਰਟਰਮ ਵਿਲਬਰਫ਼ੋਰਸ ਹੈਨਰੀ ਰਾਬਰਟ ਐਮ.ਪੀ." }, "pa/nativeG/sorting": { "family": { "expected": "", "got": "ਜ਼ੈਨਡਿਆ" }, "given_names": { "expected": "ਜ਼ੈਨਡਿਆ", "got": "" }, "text": "ਜ਼ੈਨਡਿਆ" }, "pa/nativeG/surnameFirst": { "family": { "expected": "", "got": "ਜ਼ੈਨਡਿਆ" }, "given_names": { "expected": "ਜ਼ੈਨਡਿਆ", "got": "" }, "text": "ਜ਼ੈਨਡਿਆ" }, "pcm/foreignFull/givenFirst": { "family": { "expected": "ván den Wólf", "got": "den Wólf" }, "family_particles": { "expected": "ván den", "got": "den" }, "given_names": { "expected": "Adá Cornelia Ẹ́va Sophia", "got": "Adá Cornelia Ẹ́va Sophia ván" }, "text": "Adá Cornelia Ẹ́va Sophia ván den Wólf M.D. PhD." }, "pcm/foreignFull/sorting": { "family_base": { "expected": "Wólf", "got": "ván Wólf" }, "family_particles": { "expected": "ván den", "got": "den" }, "text": "ván den Wólf, Adá Cornelia Ẹ́va Sophia M.D. PhD." }, "pcm/foreignFull/surnameFirst": { "family": { "expected": "ván den Wólf", "got": "ván" }, "family_base": { "expected": "Wólf", "got": "ván" }, "family_particles": { "expected": "ván den", "got": "" }, "given_names": { "expected": "Adá Cornelia Ẹ́va Sophia", "got": "den Wólf Adá Cornelia Ẹ́va Sophia" }, "text": "ván den Wólf Adá Cornelia Ẹ́va Sophia M.D. PhD." }, "pcm/foreignG/sorting": { "family": { "expected": "", "got": "Miret" }, "given_names": { "expected": "Miret", "got": "" }, "text": "Miret" }, "pcm/foreignG/surnameFirst": { "family": { "expected": "", "got": "Miret" }, "given_names": { "expected": "Miret", "got": "" }, "text": "Miret" }, "pcm/nativeFull/surnameFirst": { "family": { "expected": "bin Stromae", "got": "bin" }, "family_base": { "expected": "Stromae", "got": "bin" }, "family_particles": { "expected": "bin", "got": "" }, "given_names": { "expected": "Elizabeth Windsor Maria Teresa", "got": "Stromae Elizabeth Windsor Maria Teresa" }, "text": "bin Stromae Elizabeth Windsor Maria Teresa MP" }, "pcm/nativeG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "pcm/nativeG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "pl/foreignFull/sorting": { "family": { "expected": "von Brühl", "got": "von" }, "family_base": { "expected": "Brühl", "got": "von" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "Ada Cornelia César Martín", "got": "Brühl Ada Cornelia César Martín" }, "text": "von Brühl Ada Cornelia César Martín" }, "pl/foreignFull/surnameFirst": { "family": { "expected": "von Brühl", "got": "von" }, "family_base": { "expected": "Brühl", "got": "von" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "Ada Cornelia César Martín", "got": "Brühl Ada Cornelia César Martín" }, "text": "prof. dr von Brühl Ada Cornelia César Martín" }, "pl/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "pl/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "pl/nativeFull/givenFirst": { "given_names": { "expected": "Tomasz", "got": "Pan Tomasz" }, "text": "Pan Tomasz Nowak", "title": { "expected": "Pan", "got": "" } }, "pl/nativeFull/surnameFirst": { "family": { "expected": "Nowak", "got": "Pan" }, "family_base": { "expected": "Nowak", "got": "Pan" }, "given_names": { "expected": "Tomasz Jan", "got": "Nowak Tomasz Jan" }, "text": "Pan Nowak Tomasz Jan", "title": { "expected": "Pan", "got": "" } }, "pl/nativeG/sorting": { "family": { "expected": "", "got": "Sindbad" }, "given_names": { "expected": "Sindbad", "got": "" }, "text": "Sindbad" }, "pl/nativeG/surnameFirst": { "family": { "expected": "", "got": "Sindbad" }, "given_names": { "expected": "Sindbad", "got": "" }, "text": "Sindbad" }, "ps/foreignFull/givenFirst": { "family": { "expected": "وون لیوه بیکر شمیت", "got": "ایس" }, "family_base": { "expected": "لیوه بیکر شمیت", "got": "ایس" }, "family_particles": { "expected": "وون", "got": "" }, "given_names": { "expected": "اډا کورنیلیا ایوا سوفیا", "got": "پروفیسور ډاکټر اډا کورنیلیا ایوا سوفیا وون لیوه بیکر شمیت ایم ‌ډی ډیی ډیی" }, "suffix": { "expected": "ایم ‌ډی ډیی ډیی ایس", "got": "" }, "text": "پروفیسور ډاکټر اډا کورنیلیا ایوا سوفیا وون لیوه بیکر شمیت ایم ‌ډی ډیی ډیی ایس", "title": { "expected": "پروفیسور ډاکټر", "got": "" } }, "ps/foreignFull/sorting": { "family_base": { "expected": "لیوه بیکر شمیت", "got": "وون لیوه بیکر شمیت" }, "family_particles": { "expected": "وون", "got": "" }, "given_names": { "expected": "اډا کورنیلیا ایوا سوفیا", "got": "پروفیسور ډاکټر اډا کورنیلیا ایوا سوفیا ایم ‌ډی ډیی ډیی ایس" }, "suffix": { "expected": "ایم ‌ډی ډیی ډیی ایس", "got": "" }, "text": "وون لیوه بیکر شمیت, پروفیسور ډاکټر اډا کورنیلیا ایوا سوفیا ایم ‌ډی ډیی ډیی ایس", "title": { "expected": "پروفیسور ډاکټر", "got": "" } }, "ps/foreignFull/surnameFirst": { "family": { "expected": "وون لیوه بیکر شمیت", "got": "وون" }, "family_base": { "expected": "لیوه بیکر شمیت", "got": "وون" }, "family_particles": { "expected": "وون", "got": "" }, "given_names": { "expected": "اډا کورنیلیا ایوا سوفیا", "got": "لیوه بیکر شمیت پروفیسور ډاکټر اډا کورنیلیا ایوا سوفیا ایم ‌ډی ډیی ډیی ایس" }, "suffix": { "expected": "ایم ‌ډی ډیی ډیی ایس", "got": "" }, "text": "وون لیوه بیکر شمیت پروفیسور ډاکټر اډا کورنیلیا ایوا سوفیا ایم ‌ډی ډیی ډیی ایس", "title": { "expected": "پروفیسور ډاکټر", "got": "" } }, "ps/foreignG/sorting": { "family": { "expected": "", "got": "سنباد" }, "given_names": { "expected": "سنباد", "got": "" }, "text": "سنباد" }, "ps/foreignG/surnameFirst": { "family": { "expected": "", "got": "سنباد" }, "given_names": { "expected": "سنباد", "got": "" }, "text": "سنباد" }, "ps/nativeFull/givenFirst": { "family": { "expected": "ووسټر", "got": "پی" }, "family_base": { "expected": "ووسټر", "got": "پی" }, "given_names": { "expected": "برټرام ولبرفورس هنري رابرټ", "got": "برټرام ولبرفورس هنري رابرټ ووسټر ایم" }, "suffix": { "expected": "ایم پی", "got": "" }, "text": "ښاغلی. برټرام ولبرفورس هنري رابرټ ووسټر ایم پی" }, "ps/nativeFull/sorting": { "given_names": { "expected": "برټرام ولبرفورس هنري رابرټ", "got": "ښاغلی. برټرام ولبرفورس هنري رابرټ ایم پی" }, "suffix": { "expected": "ایم پی", "got": "" }, "text": "ووسټر ښاغلی. برټرام ولبرفورس هنري رابرټ ایم پی", "title": { "expected": "ښاغلی.", "got": "" } }, "ps/nativeFull/surnameFirst": { "given_names": { "expected": "برټرام ولبرفورس هنري رابرټ", "got": "ښاغلی. برټرام ولبرفورس هنري رابرټ ایم پی" }, "suffix": { "expected": "ایم پی", "got": "" }, "text": "ووسټر ښاغلی. برټرام ولبرفورس هنري رابرټ ایم پی", "title": { "expected": "ښاغلی.", "got": "" } }, "ps/nativeG/sorting": { "family": { "expected": "", "got": "سنباد" }, "given_names": { "expected": "سنباد", "got": "" }, "text": "سنباد" }, "ps/nativeG/surnameFirst": { "family": { "expected": "", "got": "سنباد" }, "given_names": { "expected": "سنباد", "got": "" }, "text": "سنباد" }, "pt/foreignFull/givenFirst": { "suffix": { "expected": "Jr. Dr. Ph.D", "got": "Jr., Ph.D" }, "text": "Prof. Dr. Mary Lou Cesare Martín von Brühl Jr., Dr. Ph.D", "title": { "expected": "Prof. Dr.", "got": "Prof. Dr. Dr." } }, "pt/foreignFull/surnameFirst": { "family": { "expected": "von Brühl", "got": "von" }, "family_base": { "expected": "Brühl", "got": "von" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "Mary Lou Cesare Martín", "got": "Brühl Prof. Dr. Mary Lou Cesare Martín" }, "suffix": { "expected": "Jr. Dr. Ph.D", "got": "Jr., Ph.D" }, "text": "von Brühl Prof. Dr. Mary Lou Cesare Martín Jr., Dr. Ph.D", "title": { "expected": "Prof. Dr.", "got": "Dr." } }, "pt/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "pt/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "pt/nativeFull/surnameFirst": { "family": { "expected": "da Silva", "got": "da" }, "family_base": { "expected": "Silva", "got": "da" }, "family_particles": { "expected": "da", "got": "" }, "given_names": { "expected": "José Pedro Eduardo", "got": "Silva Sr. José Pedro Eduardo" }, "text": "da Silva Sr. José Pedro Eduardo Jr., MP", "title": { "expected": "Sr.", "got": "" } }, "pt/nativeG/sorting": { "family": { "expected": "", "got": "Maria" }, "given_names": { "expected": "Maria", "got": "" }, "text": "Maria" }, "pt/nativeG/surnameFirst": { "family": { "expected": "", "got": "Maria" }, "given_names": { "expected": "Maria", "got": "" }, "text": "Maria" }, "qu/foreignFull/givenFirst": { "family": { "expected": "de la Cruz", "got": "Ph. D." }, "family_base": { "expected": "Cruz", "got": "Ph. D." }, "family_particles": { "expected": "de la", "got": "" }, "given_names": { "expected": "Alejandro Manuel César Mamani", "got": "Alejandro Manuel César Mamani de la Cruz" }, "suffix": { "expected": "Ph. D. Jr.", "got": "Jr." }, "text": "Ph. D., Prof. Dr. Alejandro Manuel César Mamani de la Cruz Jr." }, "qu/foreignFull/sorting": { "family": { "expected": "Cruz de la", "got": "Cruz de la Alejandro Manuel César Mamani" }, "family_base": { "expected": "Cruz", "got": "Cruz Alejandro Manuel César Mamani" }, "given_names": { "expected": "Alejandro Manuel César Mamani", "got": "" }, "text": "Cruz, de la Alejandro Manuel César Mamani" }, "qu/foreignFull/surnameFirst": { "family": { "expected": "de la Cruz", "got": "Ph. D. de la Cruz Alejandro Manuel César Mamani" }, "family_base": { "expected": "Cruz", "got": "Ph. D. Cruz Alejandro Manuel César Mamani" }, "given_names": { "expected": "Alejandro Manuel César Mamani", "got": "" }, "suffix": { "expected": "Ph. D. Jr.", "got": "Jr." }, "text": "Ph. D., Prof. Dr. de la Cruz Alejandro Manuel César Mamani Jr." }, "qu/foreignG/sorting": { "family": { "expected": "", "got": "Pedro" }, "given_names": { "expected": "Pedro", "got": "" }, "text": "Pedro" }, "qu/foreignG/surnameFirst": { "family": { "expected": "", "got": "Pedro" }, "given_names": { "expected": "Pedro", "got": "" }, "text": "Pedro" }, "qu/nativeFull/givenFirst": { "family": { "expected": "de la Cruz", "got": "A. R." }, "family_base": { "expected": "Cruz", "got": "A. R." }, "family_particles": { "expected": "de la", "got": "" }, "given_names": { "expected": "Maria García Rosa", "got": "Maria García Rosa de la Cruz" }, "suffix": { "expected": "A. R. Jr.", "got": "Jr." }, "text": "A. R., Sr. Maria García Rosa de la Cruz Jr." }, "qu/nativeFull/sorting": { "family": { "expected": "Cruz de la", "got": "Cruz de la Maria García Rosa" }, "family_base": { "expected": "Cruz", "got": "Cruz Maria García Rosa" }, "given_names": { "expected": "Maria García Rosa", "got": "" }, "text": "Cruz, de la Maria García Rosa" }, "qu/nativeFull/surnameFirst": { "family": { "expected": "de la Cruz", "got": "A. R. de la Cruz Maria García Rosa" }, "family_base": { "expected": "Cruz", "got": "A. R. Cruz Maria García Rosa" }, "given_names": { "expected": "Maria García Rosa", "got": "" }, "suffix": { "expected": "A. R. Jr.", "got": "Jr." }, "text": "A. R., Sr. de la Cruz Maria García Rosa Jr." }, "qu/nativeG/sorting": { "family": { "expected": "", "got": "Mario" }, "given_names": { "expected": "Mario", "got": "" }, "text": "Mario" }, "qu/nativeG/surnameFirst": { "family": { "expected": "", "got": "Mario" }, "given_names": { "expected": "Mario", "got": "" }, "text": "Mario" }, "rm/foreignG/sorting": { "family": { "expected": "", "got": "Rüdiger" }, "given_names": { "expected": "Rüdiger", "got": "" }, "text": "Rüdiger" }, "rm/foreignG/surnameFirst": { "family": { "expected": "", "got": "Rüdiger" }, "given_names": { "expected": "Rüdiger", "got": "" }, "text": "Rüdiger" }, "rm/nativeG/sorting": { "family": { "expected": "", "got": "Ursina" }, "given_names": { "expected": "Ursina", "got": "" }, "text": "Ursina" }, "rm/nativeG/surnameFirst": { "family": { "expected": "", "got": "Ursina" }, "given_names": { "expected": "Ursina", "got": "" }, "text": "Ursina" }, "ro/foreignFull/givenFirst": { "given_names": { "expected": "Eva-Eleonora Karl Hans", "got": "MD Chirurgie dentară Eva-Eleonora Karl Hans" }, "suffix": { "expected": "MD Chirurgie dentară Jr.", "got": "Jr." }, "text": "Prof. dr. MD Chirurgie dentară Eva-Eleonora Karl Hans von der Ruhr Jr." }, "ro/foreignFull/surnameFirst": { "family": { "expected": "von der Ruhr", "got": "MD" }, "family_base": { "expected": "Ruhr", "got": "MD" }, "family_particles": { "expected": "von der", "got": "" }, "given_names": { "expected": "Eva-Eleonora Karl Hans", "got": "Chirurgie dentară von der Ruhr Prof. dr. Eva-Eleonora Karl Hans" }, "suffix": { "expected": "MD Chirurgie dentară Jr.", "got": "Jr." }, "text": "MD Chirurgie dentară von der Ruhr Prof. dr. Eva-Eleonora Karl Hans Jr.", "title": { "expected": "Prof. dr.", "got": "" } }, "ro/foreignG/sorting": { "family": { "expected": "", "got": "Mark" }, "given_names": { "expected": "Mark", "got": "" }, "text": "Mark" }, "ro/foreignG/surnameFirst": { "family": { "expected": "", "got": "Mark" }, "given_names": { "expected": "Mark", "got": "" }, "text": "Mark" }, "ro/nativeFull/givenFirst": { "given_names": { "expected": "Daniel Ionescu Dragoș Ioan", "got": "Dl Dr. Daniel Ionescu Dragoș Ioan" }, "suffix": { "expected": "Dr. Jr.", "got": "Jr." }, "text": "Dl Dr. Daniel Ionescu Dragoș Ioan Ardelean Jr.", "title": { "expected": "Dl", "got": "" } }, "ro/nativeFull/surnameFirst": { "given_names": { "expected": "Daniel Ionescu Dragoș Ioan", "got": "Dl Daniel Ionescu Dragoș Ioan" }, "suffix": { "expected": "Dr. Jr.", "got": "Jr." }, "text": "Dr. Ardelean Dl Daniel Ionescu Dragoș Ioan Jr.", "title": { "expected": "Dl", "got": "Dr." } }, "ro/nativeG/sorting": { "family": { "expected": "", "got": "Alina" }, "given_names": { "expected": "Alina", "got": "" }, "text": "Alina" }, "ro/nativeG/surnameFirst": { "family": { "expected": "", "got": "Alina" }, "given_names": { "expected": "Alina", "got": "" }, "text": "Alina" }, "ru/foreignFull/givenFirst": { "family": { "expected": "фон Брюль", "got": "проф." }, "family_base": { "expected": "Брюль", "got": "проф." }, "family_particles": { "expected": "фон", "got": "" }, "given_names": { "expected": "Ада Корнелия Сезар Мартин", "got": "Ада Корнелия Сезар Мартин фон Брюль —" }, "text": "проф., д-р Ада Корнелия Сезар Мартин фон Брюль — мл., к. м. н.", "title": { "expected": "проф., д-р", "got": "д-р" } }, "ru/foreignFull/sorting": { "family": { "expected": "Брюль фон", "got": "Брюль" }, "family_particles": { "expected": "фон", "got": "" }, "given_names": { "expected": "Ада Корнелия Сезар Мартин", "got": "Ада Корнелия Сезар Мартин фон" }, "text": "Брюль, Ада Корнелия Сезар Мартин фон" }, "ru/foreignFull/surnameFirst": { "family": { "expected": "фон Брюль", "got": "фон Брюль Ада Корнелия Сезар Мартин" }, "family_base": { "expected": "Брюль", "got": "фон Брюль Ада Корнелия Сезар Мартин" }, "family_particles": { "expected": "фон", "got": "" }, "given_names": { "expected": "Ада Корнелия Сезар Мартин", "got": "к. м. н." }, "suffix": { "expected": "к. м. н.", "got": "" }, "text": "фон Брюль Ада Корнелия Сезар Мартин, к. м. н." }, "ru/foreignG/sorting": { "family": { "expected": "", "got": "Синдбад" }, "given_names": { "expected": "Синдбад", "got": "" }, "text": "Синдбад" }, "ru/foreignG/surnameFirst": { "family": { "expected": "", "got": "Синдбад" }, "given_names": { "expected": "Синдбад", "got": "" }, "text": "Синдбад" }, "ru/nativeG/sorting": { "family": { "expected": "", "got": "Сергей" }, "given_names": { "expected": "Сергей", "got": "" }, "text": "Сергей" }, "ru/nativeG/surnameFirst": { "family": { "expected": "", "got": "Сергей" }, "given_names": { "expected": "Сергей", "got": "" }, "text": "Сергей" }, "sc/foreignFull/surnameFirst": { "family": { "expected": "von Brühl González Domingo", "got": "MD" }, "family_base": { "expected": "Brühl González Domingo", "got": "MD" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "Maria Giusepa Juana Teresa", "got": "DDS Dut.ra Prof.ra von Brühl González Domingo Maria Giusepa Juana Teresa" }, "suffix": { "expected": "MD DDS", "got": "" }, "text": "MD DDS Dut.ra Prof.ra von Brühl González Domingo Maria Giusepa Juana Teresa", "title": { "expected": "Dut.ra Prof.ra", "got": "" } }, "sc/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "sc/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "sc/nativeFull/surnameFirst": { "family": { "expected": "de Lacon Gunale", "got": "onorèvole" }, "family_base": { "expected": "Lacon Gunale", "got": "onorèvole" }, "family_particles": { "expected": "de", "got": "" }, "given_names": { "expected": "Antoni Baìngiu Torchitòriu Comita", "got": "Sr. de Lacon Gunale Antoni Baìngiu Torchitòriu Comita" }, "suffix": { "expected": "onorèvole", "got": "" }, "text": "onorèvole Sr. de Lacon Gunale Antoni Baìngiu Torchitòriu Comita", "title": { "expected": "Sr.", "got": "" } }, "sc/nativeG/sorting": { "family": { "expected": "", "got": "Frantziscu" }, "given_names": { "expected": "Frantziscu", "got": "" }, "text": "Frantziscu" }, "sc/nativeG/surnameFirst": { "family": { "expected": "", "got": "Frantziscu" }, "given_names": { "expected": "Frantziscu", "got": "" }, "text": "Frantziscu" }, "sd/foreignFull/givenFirst": { "family": { "expected": "وان برل گونزالس ڊومنگو", "got": "ايس" }, "family_base": { "expected": "برل گونزالس ڊومنگو", "got": "ايس" }, "family_particles": { "expected": "وان", "got": "" }, "given_names": { "expected": "ايڊا ڪورنيليا سيزر مارٽن", "got": "پروفيسر ڊاڪٽر ايڊا ڪورنيليا سيزر مارٽن وان برل گونزالس ڊومنگو ايم ڊي ڊي ڊي" }, "suffix": { "expected": "ايم ڊي ڊي ڊي ايس", "got": "" }, "text": "پروفيسر ڊاڪٽر ايڊا ڪورنيليا سيزر مارٽن وان برل گونزالس ڊومنگو ايم ڊي ڊي ڊي ايس", "title": { "expected": "پروفيسر ڊاڪٽر", "got": "" } }, "sd/foreignFull/sorting": { "family_base": { "expected": "برل گونزالس ڊومنگو", "got": "وان برل گونزالس ڊومنگو" }, "family_particles": { "expected": "وان", "got": "" }, "given_names": { "expected": "ايڊا ڪورنيليا سيزر مارٽن", "got": "پروفيسر ڊاڪٽر ايڊا ڪورنيليا سيزر مارٽن ايم ڊي ڊي ڊي ايس" }, "suffix": { "expected": "ايم ڊي ڊي ڊي ايس", "got": "" }, "text": "وان برل گونزالس ڊومنگو, پروفيسر ڊاڪٽر ايڊا ڪورنيليا سيزر مارٽن ايم ڊي ڊي ڊي ايس", "title": { "expected": "پروفيسر ڊاڪٽر", "got": "" } }, "sd/foreignFull/surnameFirst": { "family": { "expected": "وان برل گونزالس ڊومنگو", "got": "وان" }, "family_base": { "expected": "برل گونزالس ڊومنگو", "got": "وان" }, "family_particles": { "expected": "وان", "got": "" }, "given_names": { "expected": "ايڊا ڪورنيليا سيزر مارٽن", "got": "برل گونزالس ڊومنگو پروفيسر ڊاڪٽر ايڊا ڪورنيليا سيزر مارٽن ايم ڊي ڊي ڊي ايس" }, "suffix": { "expected": "ايم ڊي ڊي ڊي ايس", "got": "" }, "text": "وان برل گونزالس ڊومنگو پروفيسر ڊاڪٽر ايڊا ڪورنيليا سيزر مارٽن ايم ڊي ڊي ڊي ايس", "title": { "expected": "پروفيسر ڊاڪٽر", "got": "" } }, "sd/foreignG/sorting": { "family": { "expected": "", "got": "سندباد" }, "given_names": { "expected": "سندباد", "got": "" }, "text": "سندباد" }, "sd/foreignG/surnameFirst": { "family": { "expected": "", "got": "سندباد" }, "given_names": { "expected": "سندباد", "got": "" }, "text": "سندباد" }, "sd/nativeFull/givenFirst": { "family": { "expected": "ووسٽر", "got": "پي" }, "family_base": { "expected": "ووسٽر", "got": "پي" }, "given_names": { "expected": "برٽرم ولبرفورس ھينري رابرٽ", "got": "محترم برٽرم ولبرفورس ھينري رابرٽ ووسٽر ايم" }, "suffix": { "expected": "ايم پي", "got": "" }, "text": "محترم برٽرم ولبرفورس ھينري رابرٽ ووسٽر ايم پي", "title": { "expected": "محترم", "got": "" } }, "sd/nativeFull/sorting": { "given_names": { "expected": "برٽرم ولبرفورس ھينري رابرٽ", "got": "محترم برٽرم ولبرفورس ھينري رابرٽ ايم پي" }, "suffix": { "expected": "ايم پي", "got": "" }, "text": "ووسٽر محترم برٽرم ولبرفورس ھينري رابرٽ ايم پي", "title": { "expected": "محترم", "got": "" } }, "sd/nativeFull/surnameFirst": { "given_names": { "expected": "برٽرم ولبرفورس ھينري رابرٽ", "got": "محترم برٽرم ولبرفورس ھينري رابرٽ ايم پي" }, "suffix": { "expected": "ايم پي", "got": "" }, "text": "ووسٽر محترم برٽرم ولبرفورس ھينري رابرٽ ايم پي", "title": { "expected": "محترم", "got": "" } }, "sd/nativeG/sorting": { "family": { "expected": "", "got": "سنباد" }, "given_names": { "expected": "سنباد", "got": "" }, "text": "سنباد" }, "sd/nativeG/surnameFirst": { "family": { "expected": "", "got": "سنباد" }, "given_names": { "expected": "سنباد", "got": "" }, "text": "سنباد" }, "shn/foreignFull/givenFirst": { "family": { "expected": "ဝွၼ်း ပရူဝ်ႇလ် ၵွၼ်ႇၸႃႇလႅတ်ႉ တူဝ်ႇမိၼ်ႇၵူဝ်", "got": "မေႃယႃ" }, "family_base": { "expected": "ပရူဝ်ႇလ် ၵွၼ်ႇၸႃႇလႅတ်ႉ တူဝ်ႇမိၼ်ႇၵူဝ်", "got": "မေႃယႃ" }, "family_particles": { "expected": "ဝွၼ်း", "got": "" }, "given_names": { "expected": "ဢေႇတႃႇ ၶူဝ်ႇၼီႇလီႇယႃႇ သီႇသႃႇ မႃႇတိၼ်ႇ", "got": "ၶူးမေႃယႃ ဢေႇတႃႇ ၶူဝ်ႇၼီႇလီႇယႃႇ သီႇသႃႇ မႃႇတိၼ်ႇ ဝွၼ်း ပရူဝ်ႇလ် ၵွၼ်ႇၸႃႇလႅတ်ႉ တူဝ်ႇမိၼ်ႇၵူဝ်" }, "suffix": { "expected": "မေႃယႃ", "got": "" }, "text": "ၶူးမေႃယႃ ဢေႇတႃႇ ၶူဝ်ႇၼီႇလီႇယႃႇ သီႇသႃႇ မႃႇတိၼ်ႇ ဝွၼ်း ပရူဝ်ႇလ် ၵွၼ်ႇၸႃႇလႅတ်ႉ တူဝ်ႇမိၼ်ႇၵူဝ် မေႃယႃ", "title": { "expected": "ၶူးမေႃယႃ", "got": "" } }, "shn/foreignFull/sorting": { "family_base": { "expected": "ပရူဝ်ႇလ် ၵွၼ်ႇၸႃႇလႅတ်ႉ တူဝ်ႇမိၼ်ႇၵူဝ်", "got": "ဝွၼ်း ပရူဝ်ႇလ် ၵွၼ်ႇၸႃႇလႅတ်ႉ တူဝ်ႇမိၼ်ႇၵူဝ်" }, "family_particles": { "expected": "ဝွၼ်း", "got": "" }, "given_names": { "expected": "ဢေႇတႃႇ ၶူဝ်ႇၼီႇလီႇယႃႇ သီႇသႃႇ မႃႇတိၼ်ႇ", "got": "ၶူးမေႃယႃ ဢေႇတႃႇ ၶူဝ်ႇၼီႇလီႇယႃႇ သီႇသႃႇ မႃႇတိၼ်ႇ မေႃယႃ" }, "suffix": { "expected": "မေႃယႃ", "got": "" }, "text": "ဝွၼ်း ပရူဝ်ႇလ် ၵွၼ်ႇၸႃႇလႅတ်ႉ တူဝ်ႇမိၼ်ႇၵူဝ်, ၶူးမေႃယႃ ဢေႇတႃႇ ၶူဝ်ႇၼီႇလီႇယႃႇ သီႇသႃႇ မႃႇတိၼ်ႇ မေႃယႃ", "title": { "expected": "ၶူးမေႃယႃ", "got": "" } }, "shn/foreignFull/surnameFirst": { "family": { "expected": "ဝွၼ်း ပရူဝ်ႇလ် ၵွၼ်ႇၸႃႇလႅတ်ႉ တူဝ်ႇမိၼ်ႇၵူဝ်", "got": "ဝွၼ်း" }, "family_base": { "expected": "ပရူဝ်ႇလ် ၵွၼ်ႇၸႃႇလႅတ်ႉ တူဝ်ႇမိၼ်ႇၵူဝ်", "got": "ဝွၼ်း" }, "family_particles": { "expected": "ဝွၼ်း", "got": "" }, "given_names": { "expected": "ဢေႇတႃႇ ၶူဝ်ႇၼီႇလီႇယႃႇ သီႇသႃႇ မႃႇတိၼ်ႇ", "got": "ပရူဝ်ႇလ် ၵွၼ်ႇၸႃႇလႅတ်ႉ တူဝ်ႇမိၼ်ႇၵူဝ် ၶူးမေႃယႃ ဢေႇတႃႇ ၶူဝ်ႇၼီႇလီႇယႃႇ သီႇသႃႇ မႃႇတိၼ်ႇ မေႃယႃ" }, "suffix": { "expected": "မေႃယႃ", "got": "" }, "text": "ဝွၼ်း ပရူဝ်ႇလ် ၵွၼ်ႇၸႃႇလႅတ်ႉ တူဝ်ႇမိၼ်ႇၵူဝ် ၶူးမေႃယႃ ဢေႇတႃႇ ၶူဝ်ႇၼီႇလီႇယႃႇ သီႇသႃႇ မႃႇတိၼ်ႇ မေႃယႃ", "title": { "expected": "ၶူးမေႃယႃ", "got": "" } }, "shn/foreignG/sorting": { "family": { "expected": "", "got": "သိၼ်းပၢတ်ႉ" }, "given_names": { "expected": "သိၼ်းပၢတ်ႉ", "got": "" }, "text": "သိၼ်းပၢတ်ႉ" }, "shn/foreignG/surnameFirst": { "family": { "expected": "", "got": "သိၼ်းပၢတ်ႉ" }, "given_names": { "expected": "သိၼ်းပၢတ်ႉ", "got": "" }, "text": "သိၼ်းပၢတ်ႉ" }, "shn/nativeFull/givenFirst": { "family": { "expected": "ဝုတ်ႉသ်တႃႇ", "got": "မေႃယႃ" }, "family_base": { "expected": "ဝုတ်ႉသ်တႃႇ", "got": "မေႃယႃ" }, "given_names": { "expected": "ပႃႇထရမ်ႇ ဝီလ်ပႃႇၾူတ်ႉ ႁႅၼ်ႇၼရီႇ ရေႃးပၢတ်ႉ", "got": "မိတ်ႇသတႃႇ ပႃႇထရမ်ႇ ဝီလ်ပႃႇၾူတ်ႉ ႁႅၼ်ႇၼရီႇ ရေႃးပၢတ်ႉ ဝုတ်ႉသ်တႃႇ" }, "suffix": { "expected": "မေႃယႃ", "got": "" }, "text": "မိတ်ႇသတႃႇ ပႃႇထရမ်ႇ ဝီလ်ပႃႇၾူတ်ႉ ႁႅၼ်ႇၼရီႇ ရေႃးပၢတ်ႉ ဝုတ်ႉသ်တႃႇ မေႃယႃ", "title": { "expected": "မိတ်ႇသတႃႇ", "got": "" } }, "shn/nativeFull/sorting": { "given_names": { "expected": "ပႃႇထရမ်ႇ ဝီလ်ပႃႇၾူတ်ႉ ႁႅၼ်ႇၼရီႇ ရေႃးပၢတ်ႉ", "got": "မိတ်ႇသတႃႇ ပႃႇထရမ်ႇ ဝီလ်ပႃႇၾူတ်ႉ ႁႅၼ်ႇၼရီႇ ရေႃးပၢတ်ႉ မေႃယႃ" }, "suffix": { "expected": "မေႃယႃ", "got": "" }, "text": "ဝုတ်ႉသ်တႃႇ မိတ်ႇသတႃႇ ပႃႇထရမ်ႇ ဝီလ်ပႃႇၾူတ်ႉ ႁႅၼ်ႇၼရီႇ ရေႃးပၢတ်ႉ မေႃယႃ", "title": { "expected": "မိတ်ႇသတႃႇ", "got": "" } }, "shn/nativeFull/surnameFirst": { "given_names": { "expected": "ပႃႇထရမ်ႇ ဝီလ်ပႃႇၾူတ်ႉ ႁႅၼ်ႇၼရီႇ ရေႃးပၢတ်ႉ", "got": "မိတ်ႇသတႃႇ ပႃႇထရမ်ႇ ဝီလ်ပႃႇၾူတ်ႉ ႁႅၼ်ႇၼရီႇ ရေႃးပၢတ်ႉ မေႃယႃ" }, "suffix": { "expected": "မေႃယႃ", "got": "" }, "text": "ဝုတ်ႉသ်တႃႇ မိတ်ႇသတႃႇ ပႃႇထရမ်ႇ ဝီလ်ပႃႇၾူတ်ႉ ႁႅၼ်ႇၼရီႇ ရေႃးပၢတ်ႉ မေႃယႃ", "title": { "expected": "မိတ်ႇသတႃႇ", "got": "" } }, "shn/nativeG/sorting": { "family": { "expected": "", "got": "ၸႅၼ်ႇတေႇယႃႇ" }, "given_names": { "expected": "ၸႅၼ်ႇတေႇယႃႇ", "got": "" }, "text": "ၸႅၼ်ႇတေႇယႃႇ" }, "shn/nativeG/surnameFirst": { "family": { "expected": "", "got": "ၸႅၼ်ႇတေႇယႃႇ" }, "given_names": { "expected": "ၸႅၼ်ႇတေႇယႃႇ", "got": "" }, "text": "ၸႅၼ်ႇတေႇယႃႇ" }, "si/foreignFull/givenFirst": { "family": { "expected": "වොන් බෲල් ගොන්සාලෙස් ඩොමින්ගෝ", "got": "පී.එච්.ඩී." }, "family_base": { "expected": "බෲල් ගොන්සාලෙස් ඩොමින්ගෝ", "got": "පී.එච්.ඩී." }, "family_particles": { "expected": "වොන්", "got": "" }, "given_names": { "expected": "ඇඩා කොර්නේලියා සීසර් මාටින්", "got": "මහාචාර්ය. ආචාර්ය. ඇඩා කොර්නේලියා සීසර් මාටින් වොන් බෲල් ගොන්සාලෙස් ඩොමින්ගෝ එම්.ඩී." }, "suffix": { "expected": "එම්.ඩී. පී.එච්.ඩී.", "got": "" }, "text": "මහාචාර්ය. ආචාර්ය. ඇඩා කොර්නේලියා සීසර් මාටින් වොන් බෲල් ගොන්සාලෙස් ඩොමින්ගෝ එම්.ඩී. පී.එච්.ඩී.", "title": { "expected": "මහාචාර්ය. ආචාර්ය.", "got": "" } }, "si/foreignFull/sorting": { "family": { "expected": "වොන් බෲල්", "got": "ඇඩා කොර්නේලියා සීසර් මාටින්" }, "family_base": { "expected": "බෲල්", "got": "ඇඩා කොර්නේලියා සීසර් මාටින්" }, "family_particles": { "expected": "වොන්", "got": "" }, "given_names": { "expected": "ඇඩා කොර්නේලියා සීසර් මාටින්", "got": "වොන් බෲල්" }, "text": "ඇඩා කොර්නේලියා සීසර් මාටින්, වොන් බෲල්" }, "si/foreignFull/surnameFirst": { "family": { "expected": "වොන් බෲල්", "got": "වොන් බෲල් මහාචාර්ය. ආචාර්ය. ඇඩා කොර්නේලියා සීසර් මාටින් බාල" }, "family_base": { "expected": "බෲල්", "got": "වොන් බෲල් මහාචාර්ය. ආචාර්ය. ඇඩා කොර්නේලියා සීසර් මාටින් බාල" }, "family_particles": { "expected": "වොන්", "got": "" }, "given_names": { "expected": "ඇඩා කොර්නේලියා සීසර් මාටින්", "got": "එම්.ඩී. පී.එච්.ඩී." }, "suffix": { "expected": "බාල එම්.ඩී. පී.එච්.ඩී.", "got": "" }, "text": "වොන් බෲල් මහාචාර්ය. ආචාර්ය. ඇඩා කොර්නේලියා සීසර් මාටින් බාල, එම්.ඩී. පී.එච්.ඩී.", "title": { "expected": "මහාචාර්ය. ආචාර්ය.", "got": "" } }, "si/foreignG/sorting": { "family": { "expected": "", "got": "සින්බෑඩ්" }, "given_names": { "expected": "සින්බෑඩ්", "got": "" }, "text": "සින්බෑඩ්" }, "si/foreignG/surnameFirst": { "family": { "expected": "", "got": "සින්බෑඩ්" }, "given_names": { "expected": "සින්බෑඩ්", "got": "" }, "text": "සින්බෑඩ්" }, "si/foreignGGS/sorting": { "family": { "expected": "ස්ටෝබර්", "got": "සාසිලියා හමිෂ්" }, "given_names": { "expected": "සාසිලියා හමිෂ්", "got": "ස්ටෝබර්" }, "text": "සාසිලියා හමිෂ්, ස්ටෝබර්" }, "si/foreignGS/sorting": { "family": { "expected": "මුලර්", "got": "කේතේ" }, "given_names": { "expected": "කේතේ", "got": "මුලර්" }, "text": "කේතේ මුලර්" }, "si/nativeFull/givenFirst": { "family": { "expected": "වූස්ටර්", "got": "පා.ම." }, "family_base": { "expected": "වූස්ටර්", "got": "පා.ම." }, "given_names": { "expected": "බර්ට්‍රම් විල්බර්ෆෝස් හෙන්රි රොබට්", "got": "මහතා බර්ට්‍රම් විල්බර්ෆෝස් හෙන්රි රොබට් වූස්ටර්" }, "suffix": { "expected": "පා.ම.", "got": "" }, "text": "මහතා බර්ට්‍රම් විල්බර්ෆෝස් හෙන්රි රොබට් වූස්ටර් පා.ම.", "title": { "expected": "මහතා", "got": "" } }, "si/nativeFull/sorting": { "family": { "expected": "වූස්ටර්", "got": "බර්ට්‍රම් විල්බර්ෆෝස් හෙන්රි රොබට්" }, "family_base": { "expected": "වූස්ටර්", "got": "බර්ට්‍රම් විල්බර්ෆෝස් හෙන්රි රොබට්" }, "given_names": { "expected": "බර්ට්‍රම් විල්බර්ෆෝස් හෙන්රි රොබට්", "got": "වූස්ටර්" }, "text": "බර්ට්‍රම් විල්බර්ෆෝස් හෙන්රි රොබට්, වූස්ටර්" }, "si/nativeFull/surnameFirst": { "family": { "expected": "වූස්ටර්", "got": "වූස්ටර් මහතා බර්ට්‍රම් විල්බර්ෆෝස් හෙන්රි රොබට් බාල" }, "family_base": { "expected": "වූස්ටර්", "got": "වූස්ටර් මහතා බර්ට්‍රම් විල්බර්ෆෝස් හෙන්රි රොබට් බාල" }, "given_names": { "expected": "බර්ට්‍රම් විල්බර්ෆෝස් හෙන්රි රොබට්", "got": "පා.ම." }, "suffix": { "expected": "බාල පා.ම.", "got": "" }, "text": "වූස්ටර් මහතා බර්ට්‍රම් විල්බර්ෆෝස් හෙන්රි රොබට් බාල, පා.ම.", "title": { "expected": "මහතා", "got": "" } }, "si/nativeG/sorting": { "family": { "expected": "", "got": "සෙන්ඩයා" }, "given_names": { "expected": "සෙන්ඩයා", "got": "" }, "text": "සෙන්ඩයා" }, "si/nativeG/surnameFirst": { "family": { "expected": "", "got": "සෙන්ඩයා" }, "given_names": { "expected": "සෙන්ඩයා", "got": "" }, "text": "සෙන්ඩයා" }, "si/nativeGGS/sorting": { "family": { "expected": "වොට්සන්", "got": "මේරි සූ හමිෂ්" }, "given_names": { "expected": "මේරි සූ හමිෂ්", "got": "වොට්සන්" }, "text": "මේරි සූ හමිෂ්, වොට්සන්" }, "si/nativeGS/sorting": { "family": { "expected": "ඇඩ්ලර්", "got": "අයිරින්" }, "given_names": { "expected": "අයිරින්", "got": "ඇඩ්ලර්" }, "text": "අයිරින් ඇඩ්ලර්" }, "sk/foreignFull/sorting": { "family": { "expected": "Kant von", "got": "Kant" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "Anna Karolína Eva Kristína", "got": "Anna Karolína Eva Kristína von Mgr. Ing." }, "suffix": { "expected": "", "got": "CSc." }, "text": "Kant, Anna Karolína Eva Kristína von (Mgr. Ing., CSc.)" }, "sk/foreignFull/surnameFirst": { "family": { "expected": "von Kant Nováková Predná", "got": "von" }, "family_base": { "expected": "Kant Nováková Predná", "got": "von" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "Anna Karolína Eva Kristína", "got": "Kant Nováková Predná Anna Karolína Eva Kristína" }, "text": "Mgr. Ing. von Kant Nováková Predná Anna Karolína Eva Kristína, CSc." }, "sk/foreignG/sorting": { "family": { "expected": "", "got": "Jack" }, "given_names": { "expected": "Jack", "got": "" }, "text": "Jack" }, "sk/foreignG/surnameFirst": { "family": { "expected": "", "got": "Jack" }, "given_names": { "expected": "Jack", "got": "" }, "text": "Jack" }, "sk/nativeFull/givenFirst": { "given_names": { "expected": "Alexandra Zuzana", "got": "pani Alexandra Zuzana" }, "text": "pani Alexandra Zuzana Macáková PhD", "title": { "expected": "pani", "got": "" } }, "sk/nativeFull/surnameFirst": { "family": { "expected": "Macáková", "got": "pani" }, "family_base": { "expected": "Macáková", "got": "pani" }, "given_names": { "expected": "Alexandra Zuzana", "got": "Macáková Alexandra Zuzana" }, "text": "pani Macáková Alexandra Zuzana, PhD", "title": { "expected": "pani", "got": "" } }, "sk/nativeG/sorting": { "family": { "expected": "", "got": "Jozef" }, "given_names": { "expected": "Jozef", "got": "" }, "text": "Jozef" }, "sk/nativeG/surnameFirst": { "family": { "expected": "", "got": "Jozef" }, "given_names": { "expected": "Jozef", "got": "" }, "text": "Jozef" }, "sl/foreignFull/givenFirst": { "family": { "expected": "von Brühl", "got": "von Brühl ml." }, "family_base": { "expected": "Brühl", "got": "Brühl ml." }, "suffix": { "expected": "ml. dr. med., dr. dent. med.", "got": "dent. med." }, "text": "prof. dr. Ada Cornelia César Martín von Brühl ml., dr. med., dr. dent. med.", "title": { "expected": "prof. dr.", "got": "prof. dr. dr. med. dr." } }, "sl/foreignFull/surnameFirst": { "family": { "expected": "von Brühl", "got": "von" }, "family_base": { "expected": "Brühl", "got": "von" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "Ada Cornelia César Martín", "got": "Brühl Ada Cornelia César Martín" }, "suffix": { "expected": "dr. med., dr. dent. med.", "got": "dent. med." }, "text": "prof. dr. von Brühl Ada Cornelia César Martín, dr. med., dr. dent. med.", "title": { "expected": "prof. dr.", "got": "prof. dr. dr. med. dr." } }, "sl/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "sl/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "sl/nativeFull/givenFirst": { "family": { "expected": "Novak", "got": "ml." }, "family_base": { "expected": "Novak", "got": "ml." }, "given_names": { "expected": "Janez Peter", "got": "g. Janez Peter Novak" }, "suffix": { "expected": "ml. univ. dipl. inž.", "got": "" }, "text": "g. Janez Peter Novak ml., univ. dipl. inž.", "title": { "expected": "g.", "got": "univ. dipl. inž." } }, "sl/nativeFull/surnameFirst": { "family": { "expected": "Novak", "got": "g." }, "family_base": { "expected": "Novak", "got": "g." }, "given_names": { "expected": "Janez Peter", "got": "Novak Janez Peter" }, "suffix": { "expected": "univ. dipl. inž.", "got": "" }, "text": "g. Novak Janez Peter, univ. dipl. inž.", "title": { "expected": "g.", "got": "univ. dipl. inž." } }, "sl/nativeG/sorting": { "family": { "expected": "", "got": "Svarun" }, "given_names": { "expected": "Svarun", "got": "" }, "text": "Svarun" }, "sl/nativeG/surnameFirst": { "family": { "expected": "", "got": "Svarun" }, "given_names": { "expected": "Svarun", "got": "" }, "text": "Svarun" }, "so/foreignFull/surnameFirst": { "family": { "expected": "von Brühl", "got": "von" }, "family_base": { "expected": "Brühl", "got": "von" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "Ada Cornelia César Martín", "got": "Brühl Prof. Dr. Ada Cornelia César Martín" }, "text": "von Brühl Prof. Dr. Ada Cornelia César Martín Jr, MD DDS", "title": { "expected": "Prof. Dr.", "got": "" } }, "so/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "so/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "so/nativeFull/givenFirst": { "given_names": { "expected": "Bertram Wilberforce Henry Robert", "got": "Mudane Bertram Wilberforce Henry Robert" }, "text": "Mudane Bertram Wilberforce Henry Robert Wooster MP", "title": { "expected": "Mudane", "got": "" } }, "so/nativeFull/surnameFirst": { "given_names": { "expected": "Bertram Wilberforce Henry Robert", "got": "Mudane Bertram Wilberforce Henry Robert" }, "text": "Wooster Mudane Bertram Wilberforce Henry Robert Jr, MP", "title": { "expected": "Mudane", "got": "" } }, "so/nativeG/sorting": { "family": { "expected": "", "got": "Zendaya" }, "given_names": { "expected": "Zendaya", "got": "" }, "text": "Zendaya" }, "so/nativeG/surnameFirst": { "family": { "expected": "", "got": "Zendaya" }, "given_names": { "expected": "Zendaya", "got": "" }, "text": "Zendaya" }, "sq/foreignFull/givenFirst": { "suffix": { "expected": "dr. shk. mjek.", "got": "" }, "text": "prof. dr. Gentiana van den Popa, dr. shk. mjek.", "title": { "expected": "prof. dr.", "got": "prof. dr. dr. shk. mjek." } }, "sq/foreignFull/surnameFirst": { "given_names": { "expected": "Gentiana", "got": "Gentiana M." }, "suffix": { "expected": "dr. shk. mjek.", "got": "shk. mjek." }, "text": "prof. dr. van den Popa, Gentiana M., dr. shk. mjek.", "title": { "expected": "prof. dr.", "got": "prof. dr. dr." } }, "sq/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "sq/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "sq/foreignGGS/surnameFirst": { "given_names": { "expected": "Amarildo", "got": "Amarildo A." }, "text": "Lombardi, Amarildo A." }, "sq/nativeFull/givenFirst": { "given_names": { "expected": "Gavril", "got": "Z. Gavril" }, "text": "Z. Gavril Dara", "title": { "expected": "Z.", "got": "" } }, "sq/nativeFull/sorting": { "family": { "expected": "Dara", "got": "Z. Dara" }, "family_base": { "expected": "Dara", "got": "Z. Dara" }, "text": "Z. Dara, Gavril", "title": { "expected": "Z.", "got": "" } }, "sq/nativeFull/surnameFirst": { "family": { "expected": "Dara", "got": "Z. Dara" }, "family_base": { "expected": "Dara", "got": "Z. Dara" }, "given_names": { "expected": "Gavril", "got": "Gavril A." }, "text": "Z. Dara, Gavril A.", "title": { "expected": "Z.", "got": "" } }, "sq/nativeG/sorting": { "family": { "expected": "", "got": "Blerim" }, "given_names": { "expected": "Blerim", "got": "" }, "text": "Blerim" }, "sq/nativeG/surnameFirst": { "family": { "expected": "", "got": "Blerim" }, "given_names": { "expected": "Blerim", "got": "" }, "text": "Blerim" }, "sq/nativeGGS/surnameFirst": { "given_names": { "expected": "Ilir", "got": "Ilir I." }, "text": "Rustemi, Ilir I." }, "sr/foreignFull/givenFirst": { "family": { "expected": "ван ден Волф Петровић Југовић", "got": "инж." }, "family_base": { "expected": "Волф Петровић Југовић", "got": "инж." }, "family_particles": { "expected": "ван ден", "got": "" }, "given_names": { "expected": "Александра Ева Сара", "got": "др Александра Ева Сара ван ден Волф Петровић Југовић дипл." }, "suffix": { "expected": "дипл. инж.", "got": "" }, "text": "проф. др Александра Ева Сара ван ден Волф Петровић Југовић дипл. инж.", "title": { "expected": "проф. др", "got": "проф." } }, "sr/foreignFull/sorting": { "family": { "expected": "Волф ван ден", "got": "Волф" }, "family_particles": { "expected": "ван ден", "got": "" }, "given_names": { "expected": "Александра Ева Сара", "got": "Александра Ева Сара ван ден" }, "text": "Волф, Александра Ева Сара ван ден" }, "sr/foreignFull/surnameFirst": { "family": { "expected": "ван ден Волф", "got": "ван" }, "family_base": { "expected": "Волф", "got": "ван" }, "family_particles": { "expected": "ван ден", "got": "" }, "given_names": { "expected": "Александра Ева Сара", "got": "ден Волф проф. др Александра Ева Сара" }, "suffix": { "expected": "млађи дипл. инж.", "got": "млађи" }, "text": "ван ден Волф проф. др Александра Ева Сара млађи, дипл. инж.", "title": { "expected": "проф. др", "got": "дипл. инж." } }, "sr/foreignG/sorting": { "family": { "expected": "", "got": "Синбад" }, "given_names": { "expected": "Синбад", "got": "" }, "text": "Синбад" }, "sr/foreignG/surnameFirst": { "family": { "expected": "", "got": "Синбад" }, "given_names": { "expected": "Синбад", "got": "" }, "text": "Синбад" }, "sr/nativeFull/givenFirst": { "family": { "expected": "Миленковић", "got": "инж." }, "family_base": { "expected": "Миленковић", "got": "инж." }, "given_names": { "expected": "Слађана Вера", "got": "др Слађана Вера Миленковић дипл." }, "suffix": { "expected": "дипл. инж.", "got": "" }, "text": "др Слађана Вера Миленковић дипл. инж.", "title": { "expected": "др", "got": "" } }, "sr/nativeFull/surnameFirst": { "given_names": { "expected": "Слађана Вера", "got": "др Слађана Вера" }, "suffix": { "expected": "млађи дипл. инж.", "got": "млађи" }, "text": "Миленковић др Слађана Вера млађи, дипл. инж.", "title": { "expected": "др", "got": "дипл. инж." } }, "sr/nativeG/sorting": { "family": { "expected": "", "got": "Иван" }, "given_names": { "expected": "Иван", "got": "" }, "text": "Иван" }, "sr/nativeG/surnameFirst": { "family": { "expected": "", "got": "Иван" }, "given_names": { "expected": "Иван", "got": "" }, "text": "Иван" }, "sr_Latn/foreignFull/givenFirst": { "family": { "expected": "van den Volf Petrović Jugović", "got": "van den Volf Petrović Jugović dipl. inž." }, "family_base": { "expected": "Volf Petrović Jugović", "got": "Volf Petrović Jugović dipl. inž." }, "suffix": { "expected": "dipl. inž.", "got": "" }, "text": "prof. dr Aleksandra Eva Sara van den Volf Petrović Jugović dipl. inž." }, "sr_Latn/foreignFull/surnameFirst": { "family": { "expected": "van den Volf", "got": "van" }, "family_base": { "expected": "Volf", "got": "van" }, "family_particles": { "expected": "van den", "got": "" }, "given_names": { "expected": "Aleksandra Eva Sara", "got": "den Volf prof. dr Aleksandra Eva Sara" }, "suffix": { "expected": "mlađi dipl. inž.", "got": "mlađi" }, "text": "van den Volf prof. dr Aleksandra Eva Sara mlađi, dipl. inž.", "title": { "expected": "prof. dr", "got": "dipl. inž." } }, "sr_Latn/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "sr_Latn/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "sr_Latn/nativeFull/givenFirst": { "family": { "expected": "Milenković", "got": "inž." }, "family_base": { "expected": "Milenković", "got": "inž." }, "given_names": { "expected": "Slađana Vera", "got": "Slađana Vera Milenković dipl." }, "suffix": { "expected": "dipl. inž.", "got": "" }, "text": "dr Slađana Vera Milenković dipl. inž." }, "sr_Latn/nativeFull/surnameFirst": { "given_names": { "expected": "Slađana Vera", "got": "dr Slađana Vera" }, "suffix": { "expected": "mlađi dipl. inž.", "got": "mlađi" }, "text": "Milenković dr Slađana Vera mlađi, dipl. inž.", "title": { "expected": "dr", "got": "dipl. inž." } }, "sr_Latn/nativeG/sorting": { "family": { "expected": "", "got": "Ivan" }, "given_names": { "expected": "Ivan", "got": "" }, "text": "Ivan" }, "sr_Latn/nativeG/surnameFirst": { "family": { "expected": "", "got": "Ivan" }, "given_names": { "expected": "Ivan", "got": "" }, "text": "Ivan" }, "sv/foreignFull/givenFirst": { "suffix": { "expected": "med. dr. fil. dr. jur. dr.", "got": "" }, "text": "med. dr. fil. dr. jur. dr. Ada Cornelia César Martín von Brühl González Domingo", "title": { "expected": "", "got": "med. dr. fil. dr. jur. dr." } }, "sv/foreignFull/surnameFirst": { "suffix": { "expected": "med. dr. fil. dr. jur. dr.", "got": "med. fil. jur." }, "text": "von Brühl, Prof. dr. Ada Cornelia César Martín, med. dr. fil. dr. jur. dr.", "title": { "expected": "Prof. dr.", "got": "Prof. dr. dr. dr. dr." } }, "sv/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "sv/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "sv/nativeFull/givenFirst": { "given_names": { "expected": "Lars-Åke Karl", "got": "Riksdagsledamot Lars-Åke Karl" }, "suffix": { "expected": "Riksdagsledamot", "got": "" }, "text": "Riksdagsledamot Lars-Åke Karl Hermansson" }, "sv/nativeG/sorting": { "family": { "expected": "", "got": "Stefan" }, "given_names": { "expected": "Stefan", "got": "" }, "text": "Stefan" }, "sv/nativeG/surnameFirst": { "family": { "expected": "", "got": "Stefan" }, "given_names": { "expected": "Stefan", "got": "" }, "text": "Stefan" }, "sw/foreignFull/givenFirst": { "family": { "expected": "mwana Hamisi Musa", "got": "Musa" }, "family_base": { "expected": "Hamisi Musa", "got": "Musa" }, "family_particles": { "expected": "mwana", "got": "" }, "given_names": { "expected": "Salima Farida", "got": "Salima Farida mwana Hamisi" }, "text": "Prof. Dk. Salima Farida mwana Hamisi Musa M.D. Ph.D." }, "sw/foreignFull/sorting": { "family": { "expected": "Hamisi mwana", "got": "Hamisi" }, "family_particles": { "expected": "mwana", "got": "" }, "given_names": { "expected": "Salima Farida", "got": "Salima Farida mwana" }, "text": "Hamisi, Salima Farida mwana" }, "sw/foreignFull/surnameFirst": { "family": { "expected": "mwana Hamisi", "got": "mwana" }, "family_base": { "expected": "Hamisi", "got": "mwana" }, "family_particles": { "expected": "mwana", "got": "" }, "given_names": { "expected": "Salima Farida", "got": "Hamisi Salima Farida" }, "text": "mwana Hamisi Salima Farida M.D. Ph.D." }, "sw/foreignG/sorting": { "family": { "expected": "", "got": "Kevin" }, "given_names": { "expected": "Kevin", "got": "" }, "text": "Kevin" }, "sw/foreignG/surnameFirst": { "family": { "expected": "", "got": "Kevin" }, "given_names": { "expected": "Kevin", "got": "" }, "text": "Kevin" }, "sw/nativeG/sorting": { "family": { "expected": "", "got": "Hassan" }, "given_names": { "expected": "Hassan", "got": "" }, "text": "Hassan" }, "sw/nativeG/surnameFirst": { "family": { "expected": "", "got": "Hassan" }, "given_names": { "expected": "Hassan", "got": "" }, "text": "Hassan" }, "ta/foreignFull/givenFirst": { "family": { "expected": "வான் ஹெர்னாண்டஸ்", "got": "பிஎச்.டி" }, "family_base": { "expected": "ஹெர்னாண்டஸ்", "got": "பிஎச்.டி" }, "family_particles": { "expected": "வான்", "got": "" }, "given_names": { "expected": "அல்போன்சோ எர்னஸ்டோ", "got": "அல்போன்சோ எர்னஸ்டோ வான் ஹெர்னாண்டஸ்" }, "suffix": { "expected": "பிஎச்.டி", "got": "" }, "text": "அல்போன்சோ எர்னஸ்டோ வான் ஹெர்னாண்டஸ் பிஎச்.டி" }, "ta/foreignFull/sorting": { "family": { "expected": "வான் ஹெர்னாண்டஸ்", "got": "அல்போன்சோ எர்னஸ்டோ" }, "family_base": { "expected": "ஹெர்னாண்டஸ்", "got": "அல்போன்சோ எர்னஸ்டோ" }, "family_particles": { "expected": "வான்", "got": "" }, "given_names": { "expected": "அல்போன்சோ எர்னஸ்டோ", "got": "வான் ஹெர்னாண்டஸ்" }, "text": "அல்போன்சோ எர்னஸ்டோ, வான் ஹெர்னாண்டஸ்" }, "ta/foreignFull/surnameFirst": { "family": { "expected": "வான் ஹெர்னாண்டஸ்", "got": "பேராசிரியர்" }, "family_base": { "expected": "ஹெர்னாண்டஸ்", "got": "பேராசிரியர்" }, "family_particles": { "expected": "வான்", "got": "" }, "given_names": { "expected": "அல்போன்சோ எர்னஸ்டோ", "got": "வான் ஹெர்னாண்டஸ் அல்போன்சோ எர்னஸ்டோ பிஎச்.டி" }, "suffix": { "expected": "பிஎச்.டி", "got": "" }, "text": "பேராசிரியர் வான் ஹெர்னாண்டஸ் அல்போன்சோ எர்னஸ்டோ பிஎச்.டி", "title": { "expected": "பேராசிரியர்", "got": "" } }, "ta/foreignG/sorting": { "family": { "expected": "", "got": "அல்போன்சோ" }, "given_names": { "expected": "அல்போன்சோ", "got": "" }, "text": "அல்போன்சோ" }, "ta/foreignG/surnameFirst": { "family": { "expected": "", "got": "அல்போன்சோ" }, "given_names": { "expected": "அல்போன்சோ", "got": "" }, "text": "அல்போன்சோ" }, "ta/foreignGGS/sorting": { "family": { "expected": "ஹெர்னாண்டஸ்", "got": "அல்போன்சோ எர்னஸ்டோ" }, "given_names": { "expected": "அல்போன்சோ எர்னஸ்டோ", "got": "ஹெர்னாண்டஸ்" }, "text": "அல்போன்சோ எர்னஸ்டோ, ஹெர்னாண்டஸ்" }, "ta/foreignGS/sorting": { "family": { "expected": "ஹெர்னாண்டஸ்", "got": "அல்போன்சோ" }, "given_names": { "expected": "அல்போன்சோ", "got": "ஹெர்னாண்டஸ்" }, "text": "அல்போன்சோ ஹெர்னாண்டஸ்" }, "ta/nativeFull/givenFirst": { "family": { "expected": "சோழன்", "got": "எம்பி" }, "family_base": { "expected": "சோழன்", "got": "எம்பி" }, "given_names": { "expected": "ராஜ ராஜன் ராஜன்", "got": "ராஜ ராஜன் ராஜன் சோழன்" }, "suffix": { "expected": "எம்பி", "got": "" }, "text": "ராஜ ராஜன் ராஜன் சோழன் எம்பி" }, "ta/nativeFull/sorting": { "family": { "expected": "சோழன்", "got": "ராஜ ராஜன் ராஜன்" }, "family_base": { "expected": "சோழன்", "got": "ராஜ ராஜன் ராஜன்" }, "given_names": { "expected": "ராஜ ராஜன் ராஜன்", "got": "சோழன்" }, "text": "ராஜ ராஜன் ராஜன், சோழன்" }, "ta/nativeFull/surnameFirst": { "family": { "expected": "சோழன்", "got": "திரு." }, "family_base": { "expected": "சோழன்", "got": "திரு." }, "given_names": { "expected": "ராஜ ராஜன் ராஜன்", "got": "சோழன் ராஜ ராஜன் ராஜன் எம்பி" }, "suffix": { "expected": "எம்பி", "got": "" }, "text": "திரு. சோழன் ராஜ ராஜன் ராஜன் எம்பி", "title": { "expected": "திரு.", "got": "" } }, "ta/nativeG/sorting": { "family": { "expected": "", "got": "ராஜேந்திரன்" }, "given_names": { "expected": "ராஜேந்திரன்", "got": "" }, "text": "ராஜேந்திரன்" }, "ta/nativeG/surnameFirst": { "family": { "expected": "", "got": "ராஜேந்திரன்" }, "given_names": { "expected": "ராஜேந்திரன்", "got": "" }, "text": "ராஜேந்திரன்" }, "ta/nativeGGS/sorting": { "family": { "expected": "சோழன்", "got": "ராஜ ராஜ" }, "given_names": { "expected": "ராஜ ராஜ", "got": "சோழன்" }, "text": "ராஜ ராஜ, சோழன்" }, "ta/nativeGS/sorting": { "family": { "expected": "சோழன்", "got": "ராஜேந்திர" }, "given_names": { "expected": "ராஜேந்திர", "got": "சோழன்" }, "text": "ராஜேந்திர சோழன்" }, "te/foreignFull/givenFirst": { "family": { "expected": "శర్మ శర్మ చౌదరి చౌదరి", "got": "డి." }, "family_base": { "expected": "శర్మ చౌదరి చౌదరి", "got": "డి." }, "family_particles": { "expected": "శర్మ", "got": "" }, "given_names": { "expected": "సూర్య ప్రతాప్ లాస్య ప్రియ", "got": "సూర్య ప్రతాప్ లాస్య ప్రియ శర్మ చౌదరి ఎం. డి. పిహెచ్." }, "suffix": { "expected": "ఎం. డి. పిహెచ్. డి.", "got": "" }, "text": "సూర్య ప్రతాప్ లాస్య ప్రియ శర్మ చౌదరి ఎం. డి. పిహెచ్. డి." }, "te/foreignFull/sorting": { "family": { "expected": "శర్మ చౌదరి చౌదరి శర్మ", "got": "చౌదరి" }, "family_base": { "expected": "శర్మ చౌదరి చౌదరి", "got": "చౌదరి" }, "family_particles": { "expected": "శర్మ", "got": "" }, "given_names": { "expected": "సూర్య ప్రతాప్ లాస్య ప్రియ", "got": "సూర్య ప్రతాప్ లాస్య ప్రియ శర్మ" }, "text": "చౌదరి, సూర్య ప్రతాప్ లాస్య ప్రియ శర్మ" }, "te/foreignFull/surnameFirst": { "family": { "expected": "శర్మ శర్మ చౌదరి చౌదరి", "got": "శర్మ" }, "family_base": { "expected": "శర్మ చౌదరి చౌదరి", "got": "శర్మ" }, "family_particles": { "expected": "శర్మ", "got": "" }, "given_names": { "expected": "సూర్య ప్రతాప్ లాస్య ప్రియ", "got": "చౌదరి సూర్య ప్రతాప్ లాస్య ప్రియ ఎం. డి. పిహెచ్. డి." }, "suffix": { "expected": "ఎం. డి. పిహెచ్. డి.", "got": "" }, "text": "శర్మ చౌదరి సూర్య ప్రతాప్ లాస్య ప్రియ ఎం. డి. పిహెచ్. డి." }, "te/foreignG/sorting": { "family": { "expected": "", "got": "రవి" }, "given_names": { "expected": "రవి", "got": "" }, "text": "రవి" }, "te/foreignG/surnameFirst": { "family": { "expected": "", "got": "రవి" }, "given_names": { "expected": "రవి", "got": "" }, "text": "రవి" }, "te/nativeFull/givenFirst": { "family": { "expected": "ఏలేటి కుమార్", "got": "పి." }, "family_base": { "expected": "ఏలేటి కుమార్", "got": "పి." }, "given_names": { "expected": "చంద్ర శేఖర్ అబ్దుల్ ఆజాద్", "got": "చంద్ర శేఖర్ అబ్దుల్ ఆజాద్ ఏలేటి కుమార్ ఎం." }, "suffix": { "expected": "ఎం. పి.", "got": "" }, "text": "చంద్ర శేఖర్ అబ్దుల్ ఆజాద్ ఏలేటి కుమార్ ఎం. పి." }, "te/nativeFull/surnameFirst": { "family": { "expected": "ఏలేటి కుమార్", "got": "ఏలేటి" }, "family_base": { "expected": "ఏలేటి కుమార్", "got": "ఏలేటి" }, "given_names": { "expected": "చంద్ర శేఖర్ అబ్దుల్ ఆజాద్", "got": "కుమార్ చంద్ర శేఖర్ అబ్దుల్ ఆజాద్ ఎం. పి." }, "suffix": { "expected": "ఎం. పి.", "got": "" }, "text": "ఏలేటి కుమార్ చంద్ర శేఖర్ అబ్దుల్ ఆజాద్ ఎం. పి." }, "te/nativeG/sorting": { "family": { "expected": "", "got": "రాజశేఖర్" }, "given_names": { "expected": "రాజశేఖర్", "got": "" }, "text": "రాజశేఖర్" }, "te/nativeG/surnameFirst": { "family": { "expected": "", "got": "రాజశేఖర్" }, "given_names": { "expected": "రాజశేఖర్", "got": "" }, "text": "రాజశేఖర్" }, "tg/foreignFull/givenFirst": { "family": { "expected": "вон Брухи Гонзалес Доминго", "got": "Доминго" }, "family_base": { "expected": "Брухи Гонзалес Доминго", "got": "Доминго" }, "family_particles": { "expected": "вон", "got": "" }, "given_names": { "expected": "Ада Корнелиа Сезар Мартин", "got": "Ада Корнелиа Сезар Мартин вон Брухи Гонзалес" }, "text": "Проф. Док. Ада Корнелиа Сезар Мартин вон Брухи Гонзалес Доминго" }, "tg/foreignFull/sorting": { "family_base": { "expected": "Брухи Гонзалес Доминго", "got": "вон Брухи Гонзалес Доминго" }, "family_particles": { "expected": "вон", "got": "" }, "text": "вон Брухи Гонзалес Доминго, Проф. Док. Ада Корнелиа Сезар Мартин" }, "tg/foreignFull/surnameFirst": { "family": { "expected": "вон Брухи Гонзалес Доминго", "got": "вон" }, "family_base": { "expected": "Брухи Гонзалес Доминго", "got": "вон" }, "family_particles": { "expected": "вон", "got": "" }, "given_names": { "expected": "Ада Корнелиа Сезар Мартин", "got": "Брухи Гонзалес Доминго Проф. Док. Ада Корнелиа Сезар Мартин" }, "text": "вон Брухи Гонзалес Доминго Проф. Док. Ада Корнелиа Сезар Мартин", "title": { "expected": "Проф. Док.", "got": "" } }, "tg/foreignG/sorting": { "family": { "expected": "", "got": "Синбад" }, "given_names": { "expected": "Синбад", "got": "" }, "text": "Синбад" }, "tg/foreignG/surnameFirst": { "family": { "expected": "", "got": "Синбад" }, "given_names": { "expected": "Синбад", "got": "" }, "text": "Синбад" }, "tg/nativeG/sorting": { "family": { "expected": "", "got": "Зендая" }, "given_names": { "expected": "Зендая", "got": "" }, "text": "Зендая" }, "tg/nativeG/surnameFirst": { "family": { "expected": "", "got": "Зендая" }, "given_names": { "expected": "Зендая", "got": "" }, "text": "Зендая" }, "th/foreignFull/givenFirst": { "family": { "expected": "วอน บรืล", "got": "บรืล" }, "family_particles": { "expected": "วอน", "got": "" }, "given_names": { "expected": "เอดา คอร์เนเลีย เซซาร์ มาร์ติน", "got": "ศ.ดร. เอดา คอร์เนเลีย เซซาร์ มาร์ติน วอน" }, "text": "ศ.ดร. เอดา คอร์เนเลีย เซซาร์ มาร์ติน วอน บรืล พ.บ. ท.บ.", "title": { "expected": "ศ.ดร.", "got": "" } }, "th/foreignFull/sorting": { "family": { "expected": "บรืล วอน", "got": "บรืล" }, "family_particles": { "expected": "วอน", "got": "" }, "given_names": { "expected": "เอดา คอร์เนเลีย เซซาร์ มาร์ติน", "got": "เอดา คอร์เนเลีย เซซาร์ มาร์ติน วอน" }, "text": "บรืล, เอดา คอร์เนเลีย เซซาร์ มาร์ติน วอน" }, "th/foreignFull/surnameFirst": { "family": { "expected": "วอน บรืล กอนซาเลซ โดมิงโก", "got": "วอน" }, "family_base": { "expected": "บรืล กอนซาเลซ โดมิงโก", "got": "วอน" }, "family_particles": { "expected": "วอน", "got": "" }, "given_names": { "expected": "เอดา คอร์เนเลีย เซซาร์ มาร์ติน", "got": "บรืล กอนซาเลซ โดมิงโก ศ.ดร. เอดา คอร์เนเลีย เซซาร์ มาร์ติน" }, "text": "วอน บรืล กอนซาเลซ โดมิงโก ศ.ดร. เอดา คอร์เนเลีย เซซาร์ มาร์ติน พ.บ. ท.บ.", "title": { "expected": "ศ.ดร.", "got": "" } }, "th/foreignG/sorting": { "family": { "expected": "", "got": "ซินแบด" }, "given_names": { "expected": "ซินแบด", "got": "" }, "text": "ซินแบด" }, "th/foreignG/surnameFirst": { "family": { "expected": "", "got": "ซินแบด" }, "given_names": { "expected": "ซินแบด", "got": "" }, "text": "ซินแบด" }, "th/nativeFull/givenFirst": { "given_names": { "expected": "ปรีชา กล้าหาญ", "got": "คุณ ปรีชา กล้าหาญ" }, "text": "คุณ ปรีชา กล้าหาญ แสงระวี", "title": { "expected": "คุณ", "got": "" } }, "th/nativeFull/surnameFirst": { "given_names": { "expected": "ปรีชา กล้าหาญ", "got": "คุณ ปรีชา กล้าหาญ" }, "text": "แสงระวี คุณ ปรีชา กล้าหาญ", "title": { "expected": "คุณ", "got": "" } }, "th/nativeG/sorting": { "family": { "expected": "", "got": "ธนา" }, "given_names": { "expected": "ธนา", "got": "" }, "text": "ธนา" }, "th/nativeG/surnameFirst": { "family": { "expected": "", "got": "ธนา" }, "given_names": { "expected": "ธนา", "got": "" }, "text": "ธนา" }, "ti/foreignFull/givenFirst": { "family": { "expected": "ቮን ብሩሂ", "got": "ስኒ" }, "family_base": { "expected": "ብሩሂ", "got": "ስኒ" }, "family_particles": { "expected": "ቮን", "got": "" }, "given_names": { "expected": "ኣዳ ኮርኒሊያ ሲዘር ማርቲን", "got": "ኣዳ ኮርኒሊያ ሲዘር ማርቲን ቮን ብሩሂ ማንጁስ፣ ሓኪም መጥባሕቲ" }, "suffix": { "expected": "ማንጁስ ሓኪም መጥባሕቲ ስኒ", "got": "" }, "text": "ፕሮ. ዶር. ኣዳ ኮርኒሊያ ሲዘር ማርቲን ቮን ብሩሂ ማንጁስ፣ ሓኪም መጥባሕቲ ስኒ" }, "ti/foreignFull/sorting": { "family": { "expected": "ብሩሂ ቮን", "got": "ብሩሂ፣" }, "family_particles": { "expected": "ቮን", "got": "" }, "given_names": { "expected": "ኣዳ ኮርኒሊያ ሲዘር ማርቲን", "got": "ኣዳ ኮርኒሊያ ሲዘር ማርቲን ቮን" }, "text": "ብሩሂ፣ ኣዳ ኮርኒሊያ ሲዘር ማርቲን ቮን" }, "ti/foreignFull/surnameFirst": { "family": { "expected": "ቮን ብሩሂ", "got": "ቮን" }, "family_base": { "expected": "ብሩሂ", "got": "ቮን" }, "family_particles": { "expected": "ቮን", "got": "" }, "given_names": { "expected": "ኣዳ ኮርኒሊያ ሲዘር ማርቲን", "got": "ብሩሂ ፕሮ. ዶር. ኣዳ ኮርኒሊያ ሲዘር ማርቲን ማንጁስ፣ ሓኪም መጥባሕቲ ስኒ" }, "suffix": { "expected": "ማንጁስ ሓኪም መጥባሕቲ ስኒ", "got": "" }, "text": "ቮን ብሩሂ ፕሮ. ዶር. ኣዳ ኮርኒሊያ ሲዘር ማርቲን ማንጁስ፣ ሓኪም መጥባሕቲ ስኒ", "title": { "expected": "ፕሮ. ዶር.", "got": "" } }, "ti/foreignG/sorting": { "family": { "expected": "", "got": "ሲንባድ" }, "given_names": { "expected": "ሲንባድ", "got": "" }, "text": "ሲንባድ" }, "ti/foreignG/surnameFirst": { "family": { "expected": "", "got": "ሲንባድ" }, "given_names": { "expected": "ሲንባድ", "got": "" }, "text": "ሲንባድ" }, "ti/nativeFull/givenFirst": { "family": { "expected": "ዉስተር", "got": "ባይቶ" }, "family_base": { "expected": "ዉስተር", "got": "ባይቶ" }, "given_names": { "expected": "በርትረም ዊልበርፎርስ ሄንሪ ሮበርት", "got": "ኣይተ በርትረም ዊልበርፎርስ ሄንሪ ሮበርት ዉስተር ማንጁስ፣ ኣባል" }, "suffix": { "expected": "ማንጁስ ኣባል ባይቶ", "got": "" }, "text": "ኣይተ በርትረም ዊልበርፎርስ ሄንሪ ሮበርት ዉስተር ማንጁስ፣ ኣባል ባይቶ", "title": { "expected": "ኣይተ", "got": "" } }, "ti/nativeFull/surnameFirst": { "given_names": { "expected": "በርትረም ዊልበርፎርስ ሄንሪ ሮበርት", "got": "ኣይተ በርትረም ዊልበርፎርስ ሄንሪ ሮበርት ማንጁስ፣ ኣባል ባይቶ" }, "suffix": { "expected": "ማንጁስ ኣባል ባይቶ", "got": "" }, "text": "ዉስተር ኣይተ በርትረም ዊልበርፎርስ ሄንሪ ሮበርት ማንጁስ፣ ኣባል ባይቶ", "title": { "expected": "ኣይተ", "got": "" } }, "ti/nativeG/sorting": { "family": { "expected": "", "got": "ዘንዳያ" }, "given_names": { "expected": "ዘንዳያ", "got": "" }, "text": "ዘንዳያ" }, "ti/nativeG/surnameFirst": { "family": { "expected": "", "got": "ዘንዳያ" }, "given_names": { "expected": "ዘንዳያ", "got": "" }, "text": "ዘንዳያ" }, "tk/foreignFull/givenFirst": { "family": { "expected": "fon Brül Gonzales Domingo", "got": "ylymlaryň doktory" }, "family_base": { "expected": "Brül Gonzales Domingo", "got": "ylymlaryň doktory" }, "family_particles": { "expected": "fon", "got": "" }, "given_names": { "expected": "Ada Kornelia Sezar Martin", "got": "Ada Kornelia Sezar Martin fon Brül Gonzales Domingo" }, "text": "ylymlaryň doktory, professor Ada Kornelia Sezar Martin fon Brül Gonzales Domingo, hirurg-stomatolog lukman", "title": { "expected": "ylymlaryň doktory, professor", "got": "professor" } }, "tk/foreignFull/sorting": { "family": { "expected": "Brül fon", "got": "Brül" }, "family_particles": { "expected": "fon", "got": "" }, "given_names": { "expected": "Ada Kornelia Sezar Martin", "got": "Ada Kornelia Sezar Martin fon" }, "text": "Brül, Ada Kornelia Sezar Martin fon" }, "tk/foreignFull/surnameFirst": { "family": { "expected": "fon Brül", "got": "fon Brül Ada Kornelia Sezar Martin" }, "family_base": { "expected": "Brül", "got": "fon Brül Ada Kornelia Sezar Martin" }, "family_particles": { "expected": "fon", "got": "" }, "given_names": { "expected": "Ada Kornelia Sezar Martin", "got": "hirurg-stomatolog lukman" }, "suffix": { "expected": "hirurg-stomatolog lukman", "got": "" }, "text": "fon Brül Ada Kornelia Sezar Martin, hirurg-stomatolog lukman" }, "tk/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "tk/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "tk/nativeFull/givenFirst": { "family": { "expected": "Gurdow", "got": "jenap Atajan Saparowiç Gurdow magistr" }, "family_base": { "expected": "Gurdow", "got": "jenap Atajan Saparowiç Gurdow magistr" }, "given_names": { "expected": "Atajan Saparowiç", "got": "ylymlaryň kandidaty" }, "suffix": { "expected": "magistr, ylymlaryň kandidaty", "got": "" }, "text": "jenap Atajan Saparowiç Gurdow magistr, ylymlaryň kandidaty", "title": { "expected": "jenap", "got": "" } }, "tk/nativeFull/surnameFirst": { "family": { "expected": "Gurdow", "got": "Gurdow Atajan Saparowiç" }, "family_base": { "expected": "Gurdow", "got": "Gurdow Atajan Saparowiç" }, "given_names": { "expected": "Atajan Saparowiç", "got": "magistr" }, "suffix": { "expected": "magistr, ylymlaryň kandidaty", "got": "ylymlaryň kandidaty" }, "text": "Gurdow Atajan Saparowiç, magistr, ylymlaryň kandidaty" }, "tk/nativeG/sorting": { "family": { "expected": "", "got": "Gurban" }, "given_names": { "expected": "Gurban", "got": "" }, "text": "Gurban" }, "tk/nativeG/surnameFirst": { "family": { "expected": "", "got": "Gurban" }, "given_names": { "expected": "Gurban", "got": "" }, "text": "Gurban" }, "tr/foreignFull/givenFirst": { "given_names": { "expected": "Ada Cornelia César Martín", "got": "MD DDS Ada Cornelia César Martín" }, "suffix": { "expected": "MD DDS Jr", "got": "Jr" }, "text": "Prof. Dr. MD DDS Ada Cornelia César Martín von Brühl Jr" }, "tr/foreignFull/surnameFirst": { "family": { "expected": "von Brühl Plácido Domingo", "got": "von" }, "family_base": { "expected": "Brühl Plácido Domingo", "got": "von" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "Ada Cornelia César Martín", "got": "Brühl Plácido Domingo Ada Cornelia César Martín" }, "text": "Prof. Dr. von Brühl Plácido Domingo Ada Cornelia César Martín, MD DDS" }, "tr/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "tr/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "tr/nativeFull/givenFirst": { "suffix": { "expected": "Dr.", "got": "" }, "text": "Sn. Dr. Fatma Su Yıldırım", "title": { "expected": "Sn.", "got": "Sn. Dr." } }, "tr/nativeFull/surnameFirst": { "family": { "expected": "Yıldırım Kaya", "got": "Yıldırım" }, "family_base": { "expected": "Yıldırım Kaya", "got": "Yıldırım" }, "given_names": { "expected": "Fatma Su", "got": "Kaya Fatma Su" }, "suffix": { "expected": "Dr.", "got": "" }, "text": "Sn. Yıldırım Kaya Fatma Su, Dr.", "title": { "expected": "Sn.", "got": "Sn. Dr." } }, "tr/nativeG/sorting": { "family": { "expected": "", "got": "Zeynep" }, "given_names": { "expected": "Zeynep", "got": "" }, "text": "Zeynep" }, "tr/nativeG/surnameFirst": { "family": { "expected": "", "got": "Zeynep" }, "given_names": { "expected": "Zeynep", "got": "" }, "text": "Zeynep" }, "uk/foreignFull/givenFirst": { "family": { "expected": "фон Вольф Гонзалес Домінго", "got": "Домінго" }, "family_base": { "expected": "Вольф Гонзалес Домінго", "got": "Домінго" }, "family_particles": { "expected": "фон", "got": "" }, "given_names": { "expected": "Анастасія Єва Софія", "got": "Анастасія Єва Софія фон Вольф Гонзалес" }, "suffix": { "expected": "д-р", "got": "" }, "text": "проф. Анастасія Єва Софія фон Вольф Гонзалес Домінго, д-р", "title": { "expected": "проф.", "got": "проф. д-р" } }, "uk/foreignFull/sorting": { "family": { "expected": "фон Вольф Гонзалес Домінго", "got": "Софія" }, "family_base": { "expected": "Вольф Гонзалес Домінго", "got": "Софія" }, "family_particles": { "expected": "фон", "got": "" }, "given_names": { "expected": "Анастасія Єва Софія", "got": "фон Вольф Гонзалес Домінго Анастасія Єва" }, "suffix": { "expected": "д-р", "got": "" }, "text": "фон Вольф Гонзалес Домінго Анастасія Єва Софія, д-р, проф.", "title": { "expected": "проф.", "got": "д-р проф." } }, "uk/foreignFull/surnameFirst": { "family": { "expected": "фон Вольф Гонзалес Домінго", "got": "фон" }, "family_base": { "expected": "Вольф Гонзалес Домінго", "got": "фон" }, "family_particles": { "expected": "фон", "got": "" }, "given_names": { "expected": "Анастасія Єва Софія", "got": "Вольф Гонзалес Домінго Анастасія Єва Софія д-р" }, "suffix": { "expected": "д-р", "got": "" }, "text": "проф. фон Вольф Гонзалес Домінго Анастасія Єва Софія д-р" }, "uk/foreignG/sorting": { "family": { "expected": "", "got": "Синдбад" }, "given_names": { "expected": "Синдбад", "got": "" }, "text": "Синдбад" }, "uk/foreignG/surnameFirst": { "family": { "expected": "", "got": "Синдбад" }, "given_names": { "expected": "Синдбад", "got": "" }, "text": "Синдбад" }, "uk/nativeFull/givenFirst": { "family": { "expected": "Петренко", "got": "доц." }, "family_base": { "expected": "Петренко", "got": "доц." }, "given_names": { "expected": "Анна Марія Богдан-Зиновій", "got": "Анна Марія Богдан-Зиновій Петренко" }, "suffix": { "expected": "доц.", "got": "" }, "text": "проф. Анна Марія Богдан-Зиновій Петренко доц." }, "uk/nativeFull/sorting": { "family": { "expected": "Петренко", "got": "Богдан-Зиновій" }, "family_base": { "expected": "Петренко", "got": "Богдан-Зиновій" }, "given_names": { "expected": "Анна Марія Богдан-Зиновій", "got": "Петренко Анна Марія" }, "suffix": { "expected": "доц.", "got": "" }, "text": "Петренко Анна Марія Богдан-Зиновій, доц., проф.", "title": { "expected": "проф.", "got": "доц. проф." } }, "uk/nativeFull/surnameFirst": { "given_names": { "expected": "Анна Марія Богдан-Зиновій", "got": "Анна Марія Богдан-Зиновій доц." }, "suffix": { "expected": "доц.", "got": "" }, "text": "проф. Петренко Анна Марія Богдан-Зиновій доц." }, "uk/nativeG/sorting": { "family": { "expected": "", "got": "Юрій" }, "given_names": { "expected": "Юрій", "got": "" }, "text": "Юрій" }, "uk/nativeG/surnameFirst": { "family": { "expected": "", "got": "Юрій" }, "given_names": { "expected": "Юрій", "got": "" }, "text": "Юрій" }, "ur/foreignFull/givenFirst": { "family": { "expected": "ندیم رضا", "got": "ایس" }, "family_base": { "expected": "رضا", "got": "ایس" }, "family_particles": { "expected": "ندیم", "got": "" }, "given_names": { "expected": "یاسمین راشد سدرہ منیر", "got": "یاسمین راشد سدرہ منیر ندیم رضا ایم ڈی ڈی ڈی" }, "suffix": { "expected": "ایم ڈی ڈی ڈی ایس", "got": "" }, "text": "یاسمین راشد سدرہ منیر ندیم رضا ایم ڈی ڈی ڈی ایس" }, "ur/foreignFull/sorting": { "family_base": { "expected": "رضا", "got": "ندیم رضا" }, "family_particles": { "expected": "ندیم", "got": "" }, "given_names": { "expected": "یاسمین راشد سدرہ منیر", "got": "یاسمین راشد سدرہ منیر ایم ڈی ڈی ڈی ایس" }, "suffix": { "expected": "ایم ڈی ڈی ڈی ایس", "got": "" }, "text": "ندیم رضا، یاسمین راشد سدرہ منیر ایم ڈی ڈی ڈی ایس" }, "ur/foreignFull/surnameFirst": { "family": { "expected": "ندیم رضا", "got": "ندیم" }, "family_base": { "expected": "رضا", "got": "ندیم" }, "family_particles": { "expected": "ندیم", "got": "" }, "given_names": { "expected": "یاسمین راشد سدرہ منیر", "got": "رضا یاسمین راشد سدرہ منیر ایم ڈی ڈی ڈی ایس" }, "suffix": { "expected": "ایم ڈی ڈی ڈی ایس", "got": "" }, "text": "ندیم رضا یاسمین راشد سدرہ منیر ایم ڈی ڈی ڈی ایس" }, "ur/foreignG/sorting": { "family": { "expected": "", "got": "سندباد" }, "given_names": { "expected": "سندباد", "got": "" }, "text": "سندباد" }, "ur/foreignG/surnameFirst": { "family": { "expected": "", "got": "سندباد" }, "given_names": { "expected": "سندباد", "got": "" }, "text": "سندباد" }, "ur/nativeFull/givenFirst": { "family": { "expected": "سید", "got": "پی" }, "family_base": { "expected": "سید", "got": "پی" }, "given_names": { "expected": "عبد الکریم عبد الحمید", "got": "عبد الکریم عبد الحمید سید ایم" }, "suffix": { "expected": "ایم پی", "got": "" }, "text": "عبد الکریم عبد الحمید سید ایم پی" }, "ur/nativeFull/sorting": { "given_names": { "expected": "عبد الکریم عبد الحمید", "got": "عبد الکریم عبد الحمید ایم پی" }, "suffix": { "expected": "ایم پی", "got": "" }, "text": "سید، عبد الکریم عبد الحمید ایم پی" }, "ur/nativeFull/surnameFirst": { "given_names": { "expected": "عبد الکریم عبد الحمید", "got": "عبد الکریم عبد الحمید ایم پی" }, "suffix": { "expected": "ایم پی", "got": "" }, "text": "سید عبد الکریم عبد الحمید ایم پی" }, "ur/nativeG/sorting": { "family": { "expected": "", "got": "احمد" }, "given_names": { "expected": "احمد", "got": "" }, "text": "احمد" }, "ur/nativeG/surnameFirst": { "family": { "expected": "", "got": "احمد" }, "given_names": { "expected": "احمد", "got": "" }, "text": "احمد" }, "uz/foreignFull/surnameFirst": { "family": { "expected": "van den Volf", "got": "van" }, "family_base": { "expected": "Volf", "got": "van" }, "family_particles": { "expected": "van den", "got": "" }, "given_names": { "expected": "Ada Kornelia Yeva Sofiya", "got": "den Volf Ada Kornelia Yeva Sofiya" }, "text": "van den Volf Ada Kornelia Yeva Sofiya M.D. Ph.D." }, "uz/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "uz/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "uz/nativeFull/givenFirst": { "family": { "expected": "Qodirov", "got": "Deputat" }, "family_base": { "expected": "Qodirov", "got": "Deputat" }, "given_names": { "expected": "Alisher Salimovich", "got": "Alisher Salimovich Qodirov" }, "suffix": { "expected": "Deputat", "got": "" }, "text": "Alisher Salimovich Qodirov Deputat" }, "uz/nativeFull/surnameFirst": { "given_names": { "expected": "Alisher Salimovich", "got": "Alisher Salimovich Deputat" }, "suffix": { "expected": "Deputat", "got": "" }, "text": "Qodirov Alisher Salimovich Deputat" }, "uz/nativeG/sorting": { "family": { "expected": "", "got": "Bobur" }, "given_names": { "expected": "Bobur", "got": "" }, "text": "Bobur" }, "uz/nativeG/surnameFirst": { "family": { "expected": "", "got": "Bobur" }, "given_names": { "expected": "Bobur", "got": "" }, "text": "Bobur" }, "vi/foreignFull/givenFirst": { "family": { "expected": "von Brühl", "got": "Giáo sư" }, "family_base": { "expected": "Brühl", "got": "Giáo sư" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "Ada Cornelia César Martín", "got": "Tiến sĩ Ada Cornelia César Martín von Brühl" }, "text": "Giáo sư, Tiến sĩ Ada Cornelia César Martín von Brühl Jr, MD DDS", "title": { "expected": "Giáo sư, Tiến sĩ", "got": "" } }, "vi/foreignFull/sorting": { "family": { "expected": "von Brühl", "got": "von" }, "family_base": { "expected": "Brühl", "got": "von" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "César Martín Ada Cornelia", "got": "Brühl César Martín Ada Cornelia" }, "text": "von Brühl César Martín Ada Cornelia" }, "vi/foreignFull/surnameFirst": { "family": { "expected": "von Brühl", "got": "MD" }, "family_base": { "expected": "Brühl", "got": "MD" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "César Martín Ada Cornelia", "got": "DDS von Brühl César Martín Ada Cornelia" }, "suffix": { "expected": "MD DDS", "got": "" }, "text": "MD DDS von Brühl César Martín Ada Cornelia" }, "vi/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "vi/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "vi/nativeFull/givenFirst": { "family": { "expected": "Nguyễn", "got": "ĐBQH" }, "family_base": { "expected": "Nguyễn", "got": "ĐBQH" }, "given_names": { "expected": "Anh Văn", "got": "Ông Anh Văn Nguyễn" }, "suffix": { "expected": "ĐBQH", "got": "" }, "text": "Ông Anh Văn Nguyễn ĐBQH", "title": { "expected": "Ông", "got": "" } }, "vi/nativeFull/surnameFirst": { "family": { "expected": "Nguyễn", "got": "ĐBQH" }, "family_base": { "expected": "Nguyễn", "got": "ĐBQH" }, "given_names": { "expected": "Văn Anh", "got": "Nguyễn Văn Anh" }, "suffix": { "expected": "ĐBQH", "got": "" }, "text": "ĐBQH Nguyễn Văn Anh" }, "vi/nativeG/sorting": { "family": { "expected": "", "got": "Anh" }, "given_names": { "expected": "Anh", "got": "" }, "text": "Anh" }, "vi/nativeG/surnameFirst": { "family": { "expected": "", "got": "Anh" }, "given_names": { "expected": "Anh", "got": "" }, "text": "Anh" }, "wo/foreignFull/givenFirst": { "family": { "expected": "won Baar Dominique Goudiaby", "got": "Goudiaby" }, "family_base": { "expected": "Baar Dominique Goudiaby", "got": "Goudiaby" }, "family_particles": { "expected": "won", "got": "" }, "given_names": { "expected": "Adama Coulibaly Camara Massamba", "got": "Adama Coulibaly Camara Massamba won Baar Dominique" }, "text": "Prof. Dr. Adama Coulibaly Camara Massamba won Baar Dominique Goudiaby MD DDS" }, "wo/foreignFull/sorting": { "family_base": { "expected": "Baar Dominique Goudiaby", "got": "won Baar Dominique Goudiaby" }, "family_particles": { "expected": "won", "got": "" }, "text": "won Baar Dominique Goudiaby, Prof. Dr. Adama Coulibaly Camara Massamba MD DDS" }, "wo/foreignFull/surnameFirst": { "family": { "expected": "won Baar Dominique Goudiaby", "got": "won" }, "family_base": { "expected": "Baar Dominique Goudiaby", "got": "won" }, "family_particles": { "expected": "won", "got": "" }, "given_names": { "expected": "Adama Coulibaly Camara Massamba", "got": "Baar Dominique Goudiaby Prof. Dr. Adama Coulibaly Camara Massamba" }, "text": "won Baar Dominique Goudiaby Prof. Dr. Adama Coulibaly Camara Massamba MD DDS", "title": { "expected": "Prof. Dr.", "got": "" } }, "wo/foreignG/sorting": { "family": { "expected": "", "got": "Samba" }, "given_names": { "expected": "Samba", "got": "" }, "text": "Samba" }, "wo/foreignG/surnameFirst": { "family": { "expected": "", "got": "Samba" }, "given_names": { "expected": "Samba", "got": "" }, "text": "Samba" }, "wo/nativeFull/givenFirst": { "given_names": { "expected": "Baba Wagué Amdy Raas", "got": "Señ Baba Wagué Amdy Raas" }, "text": "Señ Baba Wagué Amdy Raas Sañsé MP", "title": { "expected": "Señ", "got": "" } }, "wo/nativeFull/sorting": { "given_names": { "expected": "Baba Wagué Amdy Raas", "got": "Señ Baba Wagué Amdy Raas" }, "text": "Sañsé Señ Baba Wagué Amdy Raas MP", "title": { "expected": "Señ", "got": "" } }, "wo/nativeFull/surnameFirst": { "given_names": { "expected": "Baba Wagué Amdy Raas", "got": "Señ Baba Wagué Amdy Raas" }, "text": "Sañsé Señ Baba Wagué Amdy Raas MP", "title": { "expected": "Señ", "got": "" } }, "wo/nativeG/sorting": { "family": { "expected": "", "got": "Seynabu" }, "given_names": { "expected": "Seynabu", "got": "" }, "text": "Seynabu" }, "wo/nativeG/surnameFirst": { "family": { "expected": "", "got": "Seynabu" }, "given_names": { "expected": "Seynabu", "got": "" }, "text": "Seynabu" }, "yo/foreignFull/surnameFirst": { "family": { "expected": "von Brühl", "got": "von" }, "family_base": { "expected": "Brühl", "got": "von" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "Ada Cornelia César Martín", "got": "Brühl Ada Cornelia César Martín" }, "text": "von Brühl Ada Cornelia César Martín M.D. Ph.D." }, "yo/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "yo/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "yo/nativeFull/givenFirst": { "family": { "expected": "Adekunle", "got": "Asofin" }, "family_base": { "expected": "Adekunle", "got": "Asofin" }, "given_names": { "expected": "Adebola Opeyemi Olumide Ajao", "got": "Adebola Opeyemi Olumide Ajao Adekunle" }, "suffix": { "expected": "Asofin", "got": "" }, "text": "Adebola Opeyemi Olumide Ajao Adekunle Asofin" }, "yo/nativeFull/surnameFirst": { "given_names": { "expected": "Adebola Opeyemi Olumide Ajao", "got": "Adebola Opeyemi Olumide Ajao Asofin" }, "suffix": { "expected": "Asofin", "got": "" }, "text": "Adekunle Adebola Opeyemi Olumide Ajao Asofin" }, "yo/nativeG/sorting": { "family": { "expected": "", "got": "Boluwatife" }, "given_names": { "expected": "Boluwatife", "got": "" }, "text": "Boluwatife" }, "yo/nativeG/surnameFirst": { "family": { "expected": "", "got": "Boluwatife" }, "given_names": { "expected": "Boluwatife", "got": "" }, "text": "Boluwatife" }, "yue/foreignFull/givenFirst": { "family": { "expected": "陳 陳", "got": "小" }, "family_base": { "expected": "陳", "got": "小" }, "family_particles": { "expected": "陳", "got": "" }, "given_names": { "expected": "怡君 達印", "got": "教授 怡君 達印 陳 陳" }, "suffix": { "expected": "小 博士", "got": "博士" }, "text": "教授·怡君·達印·陳·陳·小,博士", "title": { "expected": "教授", "got": "" } }, "yue/foreignFull/sorting": { "family": { "expected": "陳 陳", "got": "陳" }, "family_particles": { "expected": "陳", "got": "" }, "text": "陳·怡君·達印" }, "yue/foreignFull/surnameFirst": { "family": { "expected": "陳 陳", "got": "陳 陳 怡君" }, "family_base": { "expected": "陳", "got": "陳 陳 怡君" }, "family_particles": { "expected": "陳", "got": "" }, "given_names": { "expected": "怡君 達印", "got": "小 達印" }, "suffix": { "expected": "小 博士", "got": "教授, 博士" }, "text": "陳·陳·怡君·教授,小·達印·博士", "title": { "expected": "教授", "got": "" } }, "yue/foreignG/givenFirst": { "family": { "expected": "", "got": "凱晴" }, "given_names": { "expected": "凱晴", "got": "" }, "text": "凱晴" }, "yue/foreignG/sorting": { "family": { "expected": "", "got": "凱晴" }, "given_names": { "expected": "凱晴", "got": "" }, "text": "凱晴" }, "yue/foreignG/surnameFirst": { "family": { "expected": "", "got": "凱晴" }, "given_names": { "expected": "凱晴", "got": "" }, "text": "凱晴" }, "yue/nativeFull/givenFirst": { "family": { "expected": "陳", "got": "先生子媚惠儀陳小" }, "family_base": { "expected": "陳", "got": "先生子媚惠儀陳小" }, "given_names": { "expected": "子媚 惠儀", "got": "" }, "suffix": { "expected": "小 太平紳士", "got": "太平紳士" }, "text": "先生子媚惠儀陳小,太平紳士", "title": { "expected": "先生", "got": "" } }, "yue/nativeFull/sorting": { "family": { "expected": "陳", "got": "陳子媚惠儀" }, "family_base": { "expected": "陳", "got": "陳子媚惠儀" }, "given_names": { "expected": "子媚 惠儀", "got": "" }, "text": "陳子媚惠儀" }, "yue/nativeFull/surnameFirst": { "family": { "expected": "陳", "got": "陳子媚先生" }, "family_base": { "expected": "陳", "got": "陳子媚先生" }, "given_names": { "expected": "子媚 惠儀", "got": "小惠儀" }, "suffix": { "expected": "小 太平紳士", "got": "太平紳士" }, "text": "陳子媚先生,小惠儀太平紳士", "title": { "expected": "先生", "got": "" } }, "yue/nativeG/givenFirst": { "family": { "expected": "", "got": "文傑" }, "given_names": { "expected": "文傑", "got": "" }, "text": "文傑" }, "yue/nativeG/sorting": { "family": { "expected": "", "got": "文傑" }, "given_names": { "expected": "文傑", "got": "" }, "text": "文傑" }, "yue/nativeG/surnameFirst": { "family": { "expected": "", "got": "文傑" }, "given_names": { "expected": "文傑", "got": "" }, "text": "文傑" }, "yue/nativeGGS/givenFirst": { "family": { "expected": "林", "got": "家豪明德林" }, "given_names": { "expected": "家豪 明德", "got": "" }, "text": "家豪明德林" }, "yue/nativeGGS/sorting": { "family": { "expected": "林", "got": "林家豪明德" }, "given_names": { "expected": "家豪 明德", "got": "" }, "text": "林家豪明德" }, "yue/nativeGGS/surnameFirst": { "family": { "expected": "林", "got": "林家豪明德" }, "given_names": { "expected": "家豪 明德", "got": "" }, "text": "林家豪明德" }, "yue/nativeGS/givenFirst": { "family": { "expected": "張", "got": "雅婷張" }, "given_names": { "expected": "雅婷", "got": "" }, "text": "雅婷張" }, "yue/nativeGS/sorting": { "family": { "expected": "張", "got": "張雅婷" }, "given_names": { "expected": "雅婷", "got": "" }, "text": "張雅婷" }, "yue/nativeGS/surnameFirst": { "family": { "expected": "張", "got": "張雅婷" }, "given_names": { "expected": "雅婷", "got": "" }, "text": "張雅婷" }, "yue_Hans/foreignFull/givenFirst": { "family": { "expected": "陈 陈", "got": "小" }, "family_base": { "expected": "陈", "got": "小" }, "family_particles": { "expected": "陈", "got": "" }, "given_names": { "expected": "怡君 达印", "got": "教授 怡君 达印 陈 陈" }, "suffix": { "expected": "小 博士", "got": "博士" }, "text": "教授·怡君·达印·陈·陈·小,博士", "title": { "expected": "教授", "got": "" } }, "yue_Hans/foreignFull/sorting": { "family": { "expected": "陈 陈", "got": "陈" }, "family_particles": { "expected": "陈", "got": "" }, "text": "陈·怡君·达印" }, "yue_Hans/foreignFull/surnameFirst": { "family": { "expected": "陈 陈", "got": "陈 陈 怡君" }, "family_base": { "expected": "陈", "got": "陈 陈 怡君" }, "family_particles": { "expected": "陈", "got": "" }, "given_names": { "expected": "怡君 达印", "got": "小 达印" }, "suffix": { "expected": "小 博士", "got": "教授, 博士" }, "text": "陈·陈·怡君·教授,小·达印·博士", "title": { "expected": "教授", "got": "" } }, "yue_Hans/foreignG/givenFirst": { "family": { "expected": "", "got": "凯晴" }, "given_names": { "expected": "凯晴", "got": "" }, "text": "凯晴" }, "yue_Hans/foreignG/sorting": { "family": { "expected": "", "got": "凯晴" }, "given_names": { "expected": "凯晴", "got": "" }, "text": "凯晴" }, "yue_Hans/foreignG/surnameFirst": { "family": { "expected": "", "got": "凯晴" }, "given_names": { "expected": "凯晴", "got": "" }, "text": "凯晴" }, "yue_Hans/nativeFull/givenFirst": { "family": { "expected": "陈", "got": "先生子媚惠仪陈小" }, "family_base": { "expected": "陈", "got": "先生子媚惠仪陈小" }, "given_names": { "expected": "子媚 惠仪", "got": "" }, "suffix": { "expected": "小 太平绅士", "got": "太平绅士" }, "text": "先生子媚惠仪陈小,太平绅士", "title": { "expected": "先生", "got": "" } }, "yue_Hans/nativeFull/sorting": { "family": { "expected": "陈", "got": "陈子媚惠仪" }, "family_base": { "expected": "陈", "got": "陈子媚惠仪" }, "given_names": { "expected": "子媚 惠仪", "got": "" }, "text": "陈子媚惠仪" }, "yue_Hans/nativeFull/surnameFirst": { "family": { "expected": "陈", "got": "陈子媚先生" }, "family_base": { "expected": "陈", "got": "陈子媚先生" }, "given_names": { "expected": "子媚 惠仪", "got": "小惠仪" }, "suffix": { "expected": "小 太平绅士", "got": "太平绅士" }, "text": "陈子媚先生,小惠仪太平绅士", "title": { "expected": "先生", "got": "" } }, "yue_Hans/nativeG/givenFirst": { "family": { "expected": "", "got": "文杰" }, "given_names": { "expected": "文杰", "got": "" }, "text": "文杰" }, "yue_Hans/nativeG/sorting": { "family": { "expected": "", "got": "文杰" }, "given_names": { "expected": "文杰", "got": "" }, "text": "文杰" }, "yue_Hans/nativeG/surnameFirst": { "family": { "expected": "", "got": "文杰" }, "given_names": { "expected": "文杰", "got": "" }, "text": "文杰" }, "yue_Hans/nativeGGS/givenFirst": { "family": { "expected": "林", "got": "家豪明德林" }, "given_names": { "expected": "家豪 明德", "got": "" }, "text": "家豪明德林" }, "yue_Hans/nativeGGS/sorting": { "family": { "expected": "林", "got": "林家豪明德" }, "given_names": { "expected": "家豪 明德", "got": "" }, "text": "林家豪明德" }, "yue_Hans/nativeGGS/surnameFirst": { "family": { "expected": "林", "got": "林家豪明德" }, "given_names": { "expected": "家豪 明德", "got": "" }, "text": "林家豪明德" }, "yue_Hans/nativeGS/givenFirst": { "family": { "expected": "张", "got": "雅婷张" }, "given_names": { "expected": "雅婷", "got": "" }, "text": "雅婷张" }, "yue_Hans/nativeGS/sorting": { "family": { "expected": "张", "got": "张雅婷" }, "given_names": { "expected": "雅婷", "got": "" }, "text": "张雅婷" }, "yue_Hans/nativeGS/surnameFirst": { "family": { "expected": "张", "got": "张雅婷" }, "given_names": { "expected": "雅婷", "got": "" }, "text": "张雅婷" }, "zh/foreignFull/givenFirst": { "family": { "expected": "冯 布鲁赫", "got": "布鲁赫博士" }, "family_base": { "expected": "布鲁赫", "got": "布鲁赫博士" }, "family_particles": { "expected": "冯", "got": "" }, "given_names": { "expected": "艾达·科妮莉亚 塞萨尔·马丁", "got": "小艾达 科妮莉亚 塞萨尔 马丁 冯" }, "suffix": { "expected": "小 博士", "got": "教授" }, "text": "小艾达·科妮莉亚·塞萨尔·马丁·冯·布鲁赫博士教授", "title": { "expected": "教授", "got": "" } }, "zh/foreignFull/sorting": { "family": { "expected": "布鲁赫 冯", "got": "布鲁赫" }, "family_particles": { "expected": "冯", "got": "" }, "given_names": { "expected": "艾达·科妮莉亚 塞萨尔·马丁", "got": "艾达 科妮莉亚 塞萨尔 马丁 冯" }, "text": "布鲁赫·艾达·科妮莉亚·塞萨尔·马丁·冯" }, "zh/foreignFull/surnameFirst": { "family": { "expected": "冯 布鲁赫", "got": "小冯" }, "family_base": { "expected": "布鲁赫", "got": "小冯" }, "family_particles": { "expected": "冯", "got": "" }, "given_names": { "expected": "艾达·科妮莉亚 塞萨尔·马丁", "got": "布鲁赫 艾达 科妮莉亚 塞萨尔 马丁" }, "suffix": { "expected": "小 博士", "got": "博士 教授" }, "text": "小冯·布鲁赫·艾达·科妮莉亚·塞萨尔·马丁·博士教授", "title": { "expected": "教授", "got": "" } }, "zh/foreignG/givenFirst": { "family": { "expected": "", "got": "辛巴达" }, "given_names": { "expected": "辛巴达", "got": "" }, "text": "辛巴达" }, "zh/foreignG/sorting": { "family": { "expected": "", "got": "辛巴达" }, "given_names": { "expected": "辛巴达", "got": "" }, "text": "辛巴达" }, "zh/foreignG/surnameFirst": { "family": { "expected": "", "got": "辛巴达" }, "given_names": { "expected": "辛巴达", "got": "" }, "text": "辛巴达" }, "zh/nativeFull/givenFirst": { "family": { "expected": "彭", "got": "小德威东升彭议员" }, "family_base": { "expected": "彭", "got": "小德威东升彭议员" }, "given_names": { "expected": "德威 东升", "got": "" }, "suffix": { "expected": "小 议员", "got": "先生" }, "text": "小德威东升彭议员先生", "title": { "expected": "先生", "got": "" } }, "zh/nativeFull/sorting": { "family": { "expected": "彭", "got": "彭德威东升" }, "family_base": { "expected": "彭", "got": "彭德威东升" }, "given_names": { "expected": "德威 东升", "got": "" }, "text": "彭德威东升" }, "zh/nativeFull/surnameFirst": { "family": { "expected": "彭", "got": "小彭德威东升议员" }, "family_base": { "expected": "彭", "got": "小彭德威东升议员" }, "given_names": { "expected": "德威 东升", "got": "" }, "suffix": { "expected": "小 议员", "got": "先生" }, "text": "小彭德威东升议员先生", "title": { "expected": "先生", "got": "" } }, "zh/nativeG/givenFirst": { "family": { "expected": "", "got": "宇瀚" }, "given_names": { "expected": "宇瀚", "got": "" }, "text": "宇瀚" }, "zh/nativeG/sorting": { "family": { "expected": "", "got": "宇瀚" }, "given_names": { "expected": "宇瀚", "got": "" }, "text": "宇瀚" }, "zh/nativeG/surnameFirst": { "family": { "expected": "", "got": "宇瀚" }, "given_names": { "expected": "宇瀚", "got": "" }, "text": "宇瀚" }, "zh/nativeGGS/givenFirst": { "family": { "expected": "陈", "got": "俊年杰思陈" }, "given_names": { "expected": "俊年 杰思", "got": "" }, "text": "俊年杰思陈" }, "zh/nativeGGS/sorting": { "family": { "expected": "陈", "got": "陈俊年杰思" }, "given_names": { "expected": "俊年 杰思", "got": "" }, "text": "陈俊年杰思" }, "zh/nativeGGS/surnameFirst": { "family": { "expected": "陈", "got": "陈俊年杰思" }, "given_names": { "expected": "俊年 杰思", "got": "" }, "text": "陈俊年杰思" }, "zh/nativeGS/givenFirst": { "family": { "expected": "张", "got": "安怡张" }, "given_names": { "expected": "安怡", "got": "" }, "text": "安怡张" }, "zh/nativeGS/sorting": { "family": { "expected": "张", "got": "张安怡" }, "given_names": { "expected": "安怡", "got": "" }, "text": "张安怡" }, "zh/nativeGS/surnameFirst": { "family": { "expected": "张", "got": "张安怡" }, "given_names": { "expected": "安怡", "got": "" }, "text": "张安怡" }, "zh_Hant/foreignFull/givenFirst": { "family": { "expected": "馮 陳", "got": "陳二世博士" }, "family_base": { "expected": "陳", "got": "陳二世博士" }, "family_particles": { "expected": "馮", "got": "" }, "given_names": { "expected": "怡君 達印", "got": "怡君達印馮" }, "suffix": { "expected": "二世 醫學博士", "got": "醫學博士" }, "text": "怡君達印馮·陳二世博士,醫學博士", "title": { "expected": "博士", "got": "" } }, "zh_Hant/foreignFull/sorting": { "family": { "expected": "馮 陳", "got": "馮" }, "family_base": { "expected": "陳", "got": "馮" }, "family_particles": { "expected": "馮", "got": "" }, "given_names": { "expected": "怡君 達印", "got": "陳 怡君 達印" }, "text": "馮·陳·怡君·達印" }, "zh_Hant/foreignFull/surnameFirst": { "family": { "expected": "馮 陳", "got": "馮" }, "family_base": { "expected": "陳", "got": "馮" }, "family_particles": { "expected": "馮", "got": "" }, "given_names": { "expected": "怡君 達印", "got": "陳 怡君 達印" }, "text": "馮·陳·怡君·達印·二世醫學博士", "title": { "expected": "博士", "got": "" } }, "zh_Hant/foreignG/givenFirst": { "family": { "expected": "", "got": "雅婷" }, "given_names": { "expected": "雅婷", "got": "" }, "text": "雅婷" }, "zh_Hant/foreignG/sorting": { "family": { "expected": "", "got": "雅婷" }, "given_names": { "expected": "雅婷", "got": "" }, "text": "雅婷" }, "zh_Hant/foreignG/surnameFirst": { "family": { "expected": "", "got": "雅婷" }, "given_names": { "expected": "雅婷", "got": "" }, "text": "雅婷" }, "zh_Hant/foreignGGS/givenFirst": { "family": { "expected": "王", "got": "雅婷婷婷王" }, "given_names": { "expected": "雅婷 婷婷", "got": "" }, "text": "雅婷婷婷王" }, "zh_Hant/foreignGS/givenFirst": { "family": { "expected": "王", "got": "雅婷王" }, "given_names": { "expected": "雅婷", "got": "" }, "text": "雅婷王" }, "zh_Hant/nativeFull/givenFirst": { "family": { "expected": "王", "got": "雅婷婷婷王二世" }, "family_base": { "expected": "王", "got": "雅婷婷婷王二世" }, "given_names": { "expected": "雅婷 婷婷", "got": "" }, "suffix": { "expected": "二世 議員", "got": "先生, 議員" }, "text": "雅婷婷婷王二世先生,議員", "title": { "expected": "先生", "got": "" } }, "zh_Hant/nativeFull/sorting": { "family": { "expected": "王", "got": "王雅婷婷婷" }, "family_base": { "expected": "王", "got": "王雅婷婷婷" }, "given_names": { "expected": "雅婷 婷婷", "got": "" }, "text": "王雅婷婷婷" }, "zh_Hant/nativeFull/surnameFirst": { "family": { "expected": "王", "got": "王雅婷婷婷二世" }, "family_base": { "expected": "王", "got": "王雅婷婷婷二世" }, "given_names": { "expected": "雅婷 婷婷", "got": "" }, "suffix": { "expected": "二世 議員", "got": "議員" }, "text": "王雅婷婷婷二世議員" }, "zh_Hant/nativeG/givenFirst": { "family": { "expected": "", "got": "文傑" }, "given_names": { "expected": "文傑", "got": "" }, "text": "文傑" }, "zh_Hant/nativeG/sorting": { "family": { "expected": "", "got": "文傑" }, "given_names": { "expected": "文傑", "got": "" }, "text": "文傑" }, "zh_Hant/nativeG/surnameFirst": { "family": { "expected": "", "got": "文傑" }, "given_names": { "expected": "文傑", "got": "" }, "text": "文傑" }, "zh_Hant/nativeGGS/givenFirst": { "family": { "expected": "林", "got": "家豪明德林" }, "given_names": { "expected": "家豪 明德", "got": "" }, "text": "家豪明德林" }, "zh_Hant/nativeGGS/sorting": { "family": { "expected": "林", "got": "林家豪明德" }, "given_names": { "expected": "家豪 明德", "got": "" }, "text": "林家豪明德" }, "zh_Hant/nativeGGS/surnameFirst": { "family": { "expected": "林", "got": "林家豪明德" }, "given_names": { "expected": "家豪 明德", "got": "" }, "text": "林家豪明德" }, "zh_Hant/nativeGS/givenFirst": { "family": { "expected": "張", "got": "雅婷張" }, "given_names": { "expected": "雅婷", "got": "" }, "text": "雅婷張" }, "zh_Hant/nativeGS/sorting": { "family": { "expected": "張", "got": "張雅婷" }, "given_names": { "expected": "雅婷", "got": "" }, "text": "張雅婷" }, "zh_Hant/nativeGS/surnameFirst": { "family": { "expected": "張", "got": "張雅婷" }, "given_names": { "expected": "雅婷", "got": "" }, "text": "張雅婷" }, "zu/foreignFull/givenFirst": { "family": { "expected": "u-van den u-Wold u-Becker Schmidt", "got": "den u-Wold u-Becker Schmidt" }, "family_particles": { "expected": "u-van den", "got": "den" }, "given_names": { "expected": "u-Ada Cornelia u-Eva Sophia", "got": "u-Prof. Dr. u-Ada Cornelia u-Eva Sophia u-van" }, "text": "u-Prof. Dr. u-Ada Cornelia u-Eva Sophia u-van den u-Wold u-Becker Schmidt M.D. Ph.D.", "title": { "expected": "u-Prof. Dr.", "got": "" } }, "zu/foreignFull/sorting": { "family": { "expected": "u-Wold u-van den", "got": "den u-Wold" }, "family_particles": { "expected": "u-van den", "got": "den" }, "given_names": { "expected": "u-Ada Cornelia u-Eva Sophia", "got": "u-Ada Cornelia u-Eva Sophia u-van" }, "text": "u-Wold, u-Ada Cornelia u-Eva Sophia u-van den" }, "zu/foreignFull/surnameFirst": { "family": { "expected": "u-van den u-Wold", "got": "u-van" }, "family_base": { "expected": "u-Wold", "got": "u-van" }, "family_particles": { "expected": "u-van den", "got": "" }, "given_names": { "expected": "u-Ada Cornelia u-Eva Sophia", "got": "den u-Wold u-Prof. Dr. u-Ada Cornelia u-Eva Sophia isizukulwane" }, "suffix": { "expected": "isizukulwane M.D. Ph.D.", "got": "M.D. Ph.D." }, "text": "u-van den u-Wold u-Prof. Dr. u-Ada Cornelia u-Eva Sophia isizukulwane, M.D. Ph.D.", "title": { "expected": "u-Prof. Dr.", "got": "" } }, "zu/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "zu/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "zu/nativeFull/surnameFirst": { "given_names": { "expected": "Bertram Wilberforce Henry Robert", "got": "Mr. Bertram Wilberforce Henry Robert" }, "text": "Wooster Mr. Bertram Wilberforce Henry Robert Jr, MP", "title": { "expected": "Mr.", "got": "" } }, "zu/nativeG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "zu/nativeG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" } } ����������������������������������������������derek73-python-nameparser-456b024/tools/differential/cldr_agreement.py������������������������������0000664�0000000�0000000�00000064310�15273032717�0026034�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Where nameparser disagrees with each source's stated fields -- a ratchet. CLDR's person-name test data (see build_cldr_corpus.py) states every name's fields separately. This module parses each CLDR rendering with the working tree and compares, role by role, with what CLDR says; the DISAGREEMENTS are recorded in cldr_agreement.json, and tests/v2/test_cldr_agreement.py fails when the tree's disagreements stop matching the record. The GND corpus (build_gnd_corpus.py) writes its fields in CLDR's vocabulary, so it is a second SOURCE compared by the same rules, with its record in gnd_agreement.json, and so is the Wikidata corpus (build_wikidata_corpus.py, wikidata_agreement.json), which states the two fields CLDR's vocabulary merges or lacks, and the hand-written conventions table (build_conventions_corpus.py, conventions_agreement.json), which adds `honorific`, a word of address written after the name, scored inside `suffix`. The native-script Wikidata corpus (build_wikidata_native_corpus.py, wikidata_native_agreement.json), real names in their own scripts, is a fifth source. "CLDR" below means any source wherever the rule is about fields. For each source the test fails on: - a role that agreed now disagrees (a regression); - a recorded disagreement now agrees (progress, which must be recorded so that it is guarded from then on); - a recorded disagreement's answer changed (a different wrong answer, or progress in disguise -- which keeps the recorded answers current; the recorded `text` and `expected` are refreshed only by a re-record); - a recorded rendering that no longer exists. uv run python tools/differential/cldr_agreement.py # summary uv run python tools/differential/cldr_agreement.py --record # re-record uv run python tools/differential/cldr_agreement.py --split # list the given/middle view --record rewrites every record, each source's and the locale ones below, and first prints, per source, how many of each kind of change it is accepting, so a re-record made for one source cannot slip another source's regressions in unseen. A second view does the same with each locale pack (#672): the renderings of every CLDR file in LOCALE_FILES, and of every native-script Wikidata stratum in NATIVE_LABELS (its Han and kana names), are parsed with parser_for(pack), and their disagreements are recorded in cldr_agreement_locales.json and wikidata_native_agreement_locales.json and ratcheted the same way, so a pack change shows what it moved on its own language. A pack needing an optional extra that is not installed (JA's namedivider) is not measured, and its recorded entries are carried over unchanged. Re-record deliberately, and say why in the commit, as with a watched diff. 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 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 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 nameparser reads `さん`, and ParsedName is not meant to gain it. One view is report-only (#702): CLDR and the conventions table state the split of the given names (`given` and `given2`), and the merged `given_names` role cannot see a parse that splits them wrongly (`Kim Min Ho` reads given `Min`, middle `Ho`). The summary counts the renderings that agree merged but split differently, and `--split` lists them (measured 2026-10-17: 251 of CLDR's 265 hold a multi-word `given`; the rest write `given2` before `given`, an order swap that `FAMILY_FIRST_GIVEN_LAST` reads, so the list mixes two populations). It is not a ratcheted role: GND and Wikidata record every given name in one field and are never scored on it, and what `middle` means for a compound given name is a design question. See split_disagreements. A field name no rule reads raises: a mistyped `honorfic` would otherwise be expected in no role and pass as agreement. KNOWN_FIELDS is every name expected_roles reads, IGNORED_FIELDS what a source states that no rendering writes (CLDR's `locale` and `given-informal`). Nothing is special-cased. A deliberately unnatural rendering, `さん` read as a suffix where CLDR calls it a title, a lone word under a family-first order: each simply disagrees, and the ratchet guards what agrees. """ from __future__ import annotations import argparse import functools import json import sys from collections import Counter from importlib.util import find_spec from pathlib import Path from typing import Any, NamedTuple from nameparser import (FAMILY_FIRST, FAMILY_FIRST_GIVEN_LAST, ParsedName, Parser, Policy, locales, parser_for) ROLES = ("title", "given_names", "family", "suffix", "family_particles", "family_base") #: Fields a source states that ParsedName has no attribute for yet, each #: with the role it is scored inside: always there, and ALSO on its own #: once ParsedName has it (see got_roles). Whether the merged role should #: then drop it is the 3.0 design's question, not this one. PENDING_ROLES = {"surname2": "family", "patronymic": "given_names"} HERE = Path(__file__).resolve().parent # The shared JSONL reader, found as the corpus builders find corpus_build # (see its docstring for why the directory is appended). if str(HERE) not in sys.path: sys.path.append(str(HERE)) from corpus_build import jsonl_lines # noqa: E402 FIELDS = HERE / "cldr_fields.jsonl" RECORD = HERE / "cldr_agreement.json" LOCALE_RECORD = HERE / "cldr_agreement_locales.json" GND_FIELDS = HERE / "gnd_fields.jsonl" GND_RECORD = HERE / "gnd_agreement.json" WIKIDATA_FIELDS = HERE / "wikidata_fields.jsonl" WIKIDATA_RECORD = HERE / "wikidata_agreement.json" CONVENTIONS_FIELDS = HERE / "conventions_fields.jsonl" CONVENTIONS_RECORD = HERE / "conventions_agreement.json" NATIVE_FIELDS = HERE / "wikidata_native_fields.jsonl" NATIVE_RECORD = HERE / "wikidata_native_agreement.json" NATIVE_LOCALE_RECORD = HERE / "wikidata_native_agreement_locales.json" RECORD_COMMAND = "uv run python tools/differential/cldr_agreement.py --record" #: shapes.py's FAMILY_FIRST shape, the only tag build_cldr_corpus.py writes. FAMILY_FIRST_SHAPE = 4 #: shapes.py's FAMILY_FIRST_GIVEN_LAST shape, which a conventions row may name. FAMILY_FIRST_GIVEN_LAST_SHAPE = 5 _PARSERS = {None: Parser(), FAMILY_FIRST_SHAPE: Parser(policy=Policy(name_order=FAMILY_FIRST)), FAMILY_FIRST_GIVEN_LAST_SHAPE: Parser(policy=Policy(name_order=FAMILY_FIRST_GIVEN_LAST))} #: The locale view: CLDR file -> the nameparser.locales pack its #: renderings are parsed with. One pack per file, so the locale record is #: keyed like the default one. A new pack adds its files here, or its #: name to NO_CLDR; test_cldr_agreement.py fails on a pack in neither. LOCALE_FILES = {"zh": "ZH", "zh_Hant": "ZH", "yue": "ZH", "yue_Hans": "ZH", "ja": "JA", "ru": "RU", "tr": "TR_AZ", "az": "TR_AZ"} #: Packs with no CLDR file to measure them on. NO_CLDR: frozenset[str] = frozenset() #: The locale view of the native-script Wikidata source: stratum label -> #: the pack its renderings are parsed with (its QID file names no language). NATIVE_LABELS = {"zh-cn": "ZH", "zh-tw": "ZH", "ja-jp": "JA"} #: Packs measured only where an optional extra is installed: pack -> the #: module the extra provides. _EXTRAS = {"JA": "namedivider"} def pack_available(pack: str) -> bool: """Whether the locale view can measure this pack here.""" module = _EXTRAS.get(pack) return module is None or find_spec(module) is not None @functools.cache def _parser(pack: str | None, shape: int | None) -> Parser: if pack is None: return _PARSERS[shape] if pack == "JA": return parser_for(locales.JA, base=_PARSERS[shape], segmenter=locales.ja_segmenter()) return parser_for(getattr(locales, pack), base=_PARSERS[shape]) def _has_space(text: str) -> bool: return any(ch.isspace() for ch in text) #: Commas a rendering may carry: nameparser's own COMMA_CHARS (ASCII, #: Arabic U+060C, fullwidth U+FF0C) plus the Myanmar U+104A and #: Ethiopic U+1363 marks CLDR's sorting forms use, and the ideographic #: U+3001, which no CLDR 48 rendering holds -- defensive. A comma #: left glued to a word hides that word from occurs(), and CLDR's field #: is then expected empty -- a disagreement no parser fix could resolve. _COMMAS = str.maketrans({c: " " for c in ",\u060c\uff0c\u104a\u1363\u3001"}) def _words(text: str) -> list[str]: return text.translate(_COMMAS).split() def _squash(text: str) -> str: return "".join(text.translate(_COMMAS).split()) def occurs(value: str, text: str) -> bool: """Whether a field's value appears in a rendering: word by word where the rendering has spaces (so `Wolf` is not found in `Wolfgang`), by substring where it has none (unspaced scripts).""" if _has_space(text): need = Counter(_words(value)) return bool(need) and not need - Counter(_words(text)) return bool(_squash(value)) and _squash(value) in _squash(text) #: Every field name expected_roles reads, and the ones it skips on purpose. KNOWN_FIELDS = frozenset({"title", "given", "given2", "surname", "surname-prefix", "surname-core", "generation", "credentials", "honorific", *PENDING_ROLES}) IGNORED_FIELDS = frozenset({"locale", "given-informal"}) def expected_roles(fields: dict[str, str], text: str) -> dict[str, str]: """What the source says each role holds IN THIS RENDERING. CLDR's formats drop fields by locale and order, so a role is built only from the fields whose value occurs in the text; a role with none expects ''. The particle and base views are compared only where the source states the split, i.e. the name has a surname-core.""" unknown = set(fields) - KNOWN_FIELDS - IGNORED_FIELDS if unknown: raise ValueError(f"fields {sorted(unknown)} of {text!r} are read by no " f"rule; use one of {sorted(KNOWN_FIELDS)}") def present(*names: str) -> str: # In the order the values occur in the text, not CLDR's field # order: an unspaced rendering compares in order, and one that # writes given2 before given would otherwise never agree. found = [fields[n] for n in names if n in fields and occurs(fields[n], text)] return " ".join(sorted(found, key=lambda v: _squash(text).find(_squash(v)))) def pending(role: str) -> tuple[str, ...]: """The PENDING_ROLES fields scored inside `role`.""" return tuple(f for f, merged in PENDING_ROLES.items() if merged == role) family = ("surname",) if "surname" in fields else ("surname-prefix", "surname-core") roles = {"title": present("title"), "given_names": present("given", "given2", *pending("given_names")), "family": present(*family, *pending("family")), "suffix": present("generation", "credentials", "honorific")} if "surname-core" in fields: roles["family_particles"] = present("surname-prefix") roles["family_base"] = present("surname-core", *pending("family")) for role in PENDING_ROLES: if role in fields: roles[role] = present(role) return roles #: Sources that state the given/middle split: CLDR (`given2`) and the #: hand-written conventions table, where `given` holds a compound given #: name whole (`Min Ho`). GND and Wikidata record every given name in one #: field, so a split scored against them would call their convention a #: disagreement (#702). SPLIT_SOURCES = frozenset({"CLDR", "Conventions"}) def split_disagreements(rows: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: """The report-only given/middle view (#702): renderings whose merged `given_names` agrees but whose `given` and `middle` are split differently from what the source states -- `given` expected from `given`, `middle` from `given2`. Not ratcheted: most entries are the parser's convention (first word given, the rest middle) against a multi-word `given`, and what `middle` means for a compound given name is a design question. A rendering stating a patronymic is skipped, since the merged role holds it and the split has nowhere to put it.""" found: dict[str, dict[str, Any]] = {} for row in rows: fields, text = row["fields"], row["text"] if "patronymic" in fields: continue name = parse_rendering(text, row["shape"]) if not same(expected_roles(fields, text)["given_names"], name.given_names, text): continue expected = {role: " ".join(v for n in names if n in fields and occurs(v := fields[n], text)) for role, names in (("given", ("given",)), ("middle", ("given2",)))} got = {"given": name.given, "middle": name.middle} bad = {role: {"expected": expected[role], "got": got[role]} for role in expected if not same(expected[role], got[role], text)} if bad: found[rendering_key(row)] = {"text": text, **bad} return dict(sorted(found.items())) def same(expected: str, got: str, text: str) -> bool: """Agreement, decided by the rendering as occurs() is: the same multiset of words where it has spaces (order within a role is free), the same text with spaces and commas removed where it has none.""" if _has_space(text): return Counter(_words(expected)) == Counter(_words(got)) return _squash(expected) == _squash(got) def got_roles(name: ParsedName) -> dict[str, str]: """The parser's answer for every role it can answer: ROLES always, a PENDING_ROLES field only once ParsedName has it.""" got = {"title": name.title, "given_names": name.given_names, "family": name.family, "suffix": name.suffix, "family_particles": name.family_particles, "family_base": name.family_base} for role in PENDING_ROLES: value = getattr(name, role, None) if isinstance(value, str): got[role] = value return got def rendering_key(row: dict[str, Any]) -> str: return f"{row['file']}/{row['label']}/{row['order']}" def parse_rendering(text: str, shape: int | None, pack: str | None = None) -> ParsedName: """The one place a rendering is parsed, so the recorder and the checker cannot read it two ways. `pack` names a nameparser.locales pack for the locale view; its parser keeps the shape's name order.""" return _parser(pack, shape).parse(text) def load_rows(path: Path = FIELDS) -> list[dict[str, Any]]: """A fields file's rows. An empty file is an error: compared against an empty record it would agree with everything.""" rows = [json.loads(line) for line in jsonl_lines(path.read_text(encoding="utf-8"))] if not rows: raise ValueError(f"{path} holds no renderings; rebuild it with its " f"build_*_corpus.py") return rows class LocaleSource(NamedTuple): """How the locale view reads one source: its record, and which part of a rendering key (`file/label/order`) names the pack, through what map. The key alone decides, so a recorded rendering that is gone still belongs to its pack's check.""" record: Path segment: int packs: dict[str, str] def pack_of(self, key: str) -> str | None: return self.packs.get(key.split("/")[self.segment]) def locale_rows(rows: list[dict[str, Any]], source: LocaleSource, pack: str) -> list[dict[str, Any]]: """The renderings the locale view parses with `pack`.""" return [row for row in rows if source.pack_of(rendering_key(row)) == pack] def disagreements(rows: list[dict[str, Any]], pack: str | None = None) -> dict[str, dict[str, Any]]: """Every rendering where some role disagrees, with each disagreeing role's expected and parsed value, sorted by key; parsed with `pack` (the locale view) where one is named.""" found: dict[str, dict[str, Any]] = {} for row in rows: got = got_roles(parse_rendering(row["text"], row["shape"], pack)) bad = {role: {"expected": expected, "got": got[role]} for role, expected in expected_roles(row["fields"], row["text"]).items() if (role in got or role not in PENDING_ROLES) and not same(expected, got[role], row["text"])} if bad: found[rendering_key(row)] = {"text": row["text"], **bad} return dict(sorted(found.items())) def read_record(path: Path = RECORD) -> dict[str, dict[str, Any]]: record: dict[str, dict[str, Any]] = json.loads(path.read_text(encoding="utf-8")) return record def write_record(record: dict[str, dict[str, Any]], path: Path = RECORD) -> None: path.write_text(json.dumps(record, ensure_ascii=False, indent=1, sort_keys=True) + "\n", encoding="utf-8") def changes(recorded: dict[str, dict[str, Any]], current: dict[str, dict[str, Any]], keys: set[str]) -> list[str]: """How the current disagreements differ from the record: a regression, progress, a changed wrong answer, or a recorded rendering that no longer exists. `keys` is every current rendering.""" out: list[str] = [] for key in sorted(set(recorded) | set(current)): if key not in keys: out.append(f"gone: {key} is recorded but no longer a rendering") continue old, new = recorded.get(key, {}), current.get(key, {}) for role in sorted((set(old) | set(new)) - {"text"}): if role not in old: out.append(f"regression: {key} {role}: expected " f"{new[role]['expected']!r}, got {new[role]['got']!r} " f"(agreed before) -- {new['text']!r}") elif role not in new: out.append(f"progress: {key} {role} now agrees (recorded got " f"{old[role]['got']!r}); re-record so it is guarded") elif old[role]["got"] != new[role]["got"]: out.append(f"changed: {key} {role}: expected " f"{new[role]['expected']!r}, recorded got " f"{old[role]['got']!r}, now {new[role]['got']!r}") return out def locale_changes(rows: list[dict[str, Any]], source: LocaleSource, pack: str, recorded: dict[str, dict[str, Any]], found: dict[str, dict[str, Any]] | None = None) -> list[str]: """changes() for one pack in the locale view: its renderings against its share of the locale record. `found` is the pack's disagreements, where the caller has them already.""" mine = locale_rows(rows, source, pack) if found is None: found = disagreements(mine, pack) return changes({k: v for k, v in recorded.items() if source.pack_of(k) == pack}, found, {rendering_key(row) for row in mine}) def unmapped(recorded: dict[str, dict[str, Any]], source: LocaleSource) -> list[str]: """Locale-record entries no pack's check reads (a CLDR file no longer in LOCALE_FILES, a stratum no longer in NATIVE_LABELS), reported here as gone.""" return [f"gone: {key} is recorded but no locale pack reads it" for key in sorted(recorded) if source.pack_of(key) is None] def _packs() -> list[str]: return sorted(set(LOCALE_FILES.values()) | set(NATIVE_LABELS.values())) def sources() -> dict[str, tuple[Path, Path]]: """Each source of stated fields: name -> (fields file, record). Read at call time, so a test that points a record elsewhere is obeyed.""" return {"CLDR": (FIELDS, RECORD), "GND": (GND_FIELDS, GND_RECORD), "Wikidata": (WIKIDATA_FIELDS, WIKIDATA_RECORD), "Conventions": (CONVENTIONS_FIELDS, CONVENTIONS_RECORD), "Wikidata native": (NATIVE_FIELDS, NATIVE_RECORD)} def locale_sources() -> dict[str, LocaleSource]: """The sources the locale view reads, each named as in sources(): CLDR by its file (LOCALE_FILES), the native-script Wikidata source by its stratum (NATIVE_LABELS).""" return {"CLDR": LocaleSource(LOCALE_RECORD, 0, LOCALE_FILES), "Wikidata native": LocaleSource(NATIVE_LOCALE_RECORD, 1, NATIVE_LABELS)} def _summary(name: str, rows: list[dict[str, Any]], current: dict[str, dict[str, Any]], record: Path, show_split: bool = False) -> list[str]: """Print one source's per-role agreement; return its changes.""" totals: Counter[str] = Counter() for row in rows: totals.update(expected_roles(row["fields"], row["text"]).keys()) wrong = Counter(role for entry in current.values() for role in entry if role != "text") print(f"{name}, {len(rows)} renderings:") for role in ROLES: agree = totals[role] - wrong[role] 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: 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}) print("\n".join(problems) or f"{name} view matches the record") if name in SPLIT_SOURCES: split = split_disagreements(rows) print(f"{name} given/middle split (report only, #702): {len(split)} of " f"{len(rows)} renderings agree on given_names but split it " f"differently; --split lists them") if show_split: for key, entry in split.items(): detail = "; ".join(f"{r} expected {entry[r]['expected']!r}, got " f"{entry[r]['got']!r}" for r in entry if r != "text") print(f" {key}: {entry['text']!r}: {detail}") return problems def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("--record", action="store_true", help="rewrite every source's record and the locale record " "from the working tree") ap.add_argument("--split", action="store_true", help="list the renderings of the report-only given/middle view") args = ap.parse_args(argv) loaded = {name: (load_rows(fields), record) for name, (fields, record) in sources().items()} current = {name: disagreements(rows) for name, (rows, _) in loaded.items()} if args.record: for name, (source_rows, record) in loaded.items(): old_record = read_record(record) if record.exists() else {} kinds = Counter(line.split(":", 1)[0] for line in changes( old_record, current[name], {rendering_key(r) for r in source_rows})) accepted = ", ".join(f"{n} {kind}" for kind, n in sorted(kinds.items())) write_record(current[name], record) print(f"recorded {len(current[name])} disagreeing renderings to " f"{record.name} (accepting: {accepted or 'no change'})") for name, source in locale_sources().items(): _record_locale(name, loaded[name][0], source) return 0 problems: list[str] = [] for name, (source_rows, record) in loaded.items(): problems += _summary(name, source_rows, current[name], record, args.split) print() for name, source in locale_sources().items(): problems += _locale_summary(name, loaded[name][0], source, current[name]) return 1 if problems else 0 def _record_locale(name: str, rows: list[dict[str, Any]], source: LocaleSource) -> None: """Rewrite one source's locale record, keeping the entries of a pack this run cannot measure.""" old = read_record(source.record) if source.record.exists() else {} locale: dict[str, dict[str, Any]] = {} for pack in _packs(): if pack_available(pack): locale.update(disagreements(locale_rows(rows, source, pack), pack)) else: print(f"{name}: {pack} not measured (needs {_EXTRAS[pack]}); " "its recorded entries are kept unchanged") locale.update({k: v for k, v in old.items() if source.pack_of(k) == pack}) write_record(dict(sorted(locale.items())), source.record) print(f"recorded {len(locale)} disagreeing renderings to {source.record.name}") def _locale_summary(name: str, rows: list[dict[str, Any]], source: LocaleSource, default_wrong: dict[str, dict[str, Any]]) -> list[str]: """Print one source's locale view, pack by pack; return its changes. `default_wrong` is the default parser's disagreements.""" print(f"{name} locale view, renderings with every role agreeing:") recorded = read_record(source.record) problems = unmapped(recorded, source) for pack in _packs(): mine = locale_rows(rows, source, pack) found = None if mine: where = ", ".join(sorted({rendering_key(r).split("/")[source.segment] for r in mine})) if not pack_available(pack): print(f"{pack:6} {where}: not measured (needs {_EXTRAS[pack]})") continue found = disagreements(mine, pack) before = sum(rendering_key(row) not in default_wrong for row in mine) print(f"{pack:6} {where}: {before}/{len(mine)} default -> " f"{len(mine) - len(found)}/{len(mine)} with the pack") # with no renderings left, its recorded entries are still checked problems += locale_changes(rows, source, pack, recorded, found) print("\n".join(problems) or f"{name} locale view matches the record") print() return problems if __name__ == "__main__": sys.exit(main()) ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/cldr_agreement_locales.json��������������������0000664�0000000�0000000�00000060677�15273032717�0030073�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������{ "az/foreignFull/surnameFirst": { "family": { "expected": "van den Volf", "got": "van" }, "family_base": { "expected": "Volf", "got": "van" }, "family_particles": { "expected": "van den", "got": "" }, "given_names": { "expected": "Ada Kornelia Sezar Martin", "got": "den Volf Ada Kornelia Sezar Martin" }, "text": "van den Volf Ada Kornelia Sezar Martin M.D. Ph.D." }, "az/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "az/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "az/nativeFull/givenFirst": { "family": { "expected": "Aydan", "got": "Deputat" }, "family_base": { "expected": "Aydan", "got": "Deputat" }, "given_names": { "expected": "Arif Aliyev İlvus Santak", "got": "Cənab Arif Aliyev İlvus Santak Aydan" }, "suffix": { "expected": "Deputat", "got": "" }, "text": "Cənab Arif Aliyev İlvus Santak Aydan Deputat", "title": { "expected": "Cənab", "got": "" } }, "az/nativeFull/sorting": { "given_names": { "expected": "Arif Aliyev İlvus Santak", "got": "Cənab Arif Aliyev İlvus Santak Deputat" }, "suffix": { "expected": "Deputat", "got": "" }, "text": "Aydan Cənab Arif Aliyev İlvus Santak Deputat", "title": { "expected": "Cənab", "got": "" } }, "az/nativeFull/surnameFirst": { "given_names": { "expected": "Arif Aliyev İlvus Santak", "got": "Arif Aliyev İlvus Santak Deputat" }, "suffix": { "expected": "Deputat", "got": "" }, "text": "Aydan Arif Aliyev İlvus Santak Deputat" }, "az/nativeG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "az/nativeG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "ja/foreignFull/givenFirst": { "family": { "expected": "フォン スミス", "got": "ジェニファー" }, "family_base": { "expected": "スミス", "got": "ジェニファー" }, "family_particles": { "expected": "フォン", "got": "" }, "given_names": { "expected": "ジェニファー ソフィア", "got": "ソフィア フォン スミス ジュニア博士" }, "suffix": { "expected": "ジュニア", "got": "" }, "text": "ジェニファー・ソフィア・フォン・スミス・ジュニア博士", "title": { "expected": "博士", "got": "" } }, "ja/foreignFull/sorting": { "family": { "expected": "フォン スミス", "got": "フォン" }, "family_base": { "expected": "スミス", "got": "フォン" }, "family_particles": { "expected": "フォン", "got": "" }, "given_names": { "expected": "ソフィア ジェニファー", "got": "スミス ソフィア ジェニファー" }, "text": "フォン・スミス・ソフィア・ジェニファー" }, "ja/foreignFull/surnameFirst": { "family": { "expected": "フォン スミス", "got": "フォン" }, "family_base": { "expected": "スミス", "got": "フォン" }, "family_particles": { "expected": "フォン", "got": "" }, "given_names": { "expected": "ソフィア ジェニファー", "got": "スミス ソフィア ジェニファー博士" }, "text": "フォン・スミス・ソフィア・ジェニファー博士", "title": { "expected": "博士", "got": "" } }, "ja/foreignG/sorting": { "family": { "expected": "", "got": "マイケル" }, "given_names": { "expected": "マイケル", "got": "" }, "text": "マイケル" }, "ja/foreignG/surnameFirst": { "family": { "expected": "", "got": "マイケル" }, "given_names": { "expected": "マイケル", "got": "" }, "text": "マイケル" }, "ja/nativeFull/givenFirst": { "family": { "expected": "佐藤", "got": "恵子" }, "family_base": { "expected": "佐藤", "got": "恵子" }, "given_names": { "expected": "恵子 グレース", "got": "グレース佐藤ジュニア" }, "suffix": { "expected": "ジュニア", "got": "さん" }, "text": "恵子グレース佐藤ジュニアさん", "title": { "expected": "さん", "got": "" } }, "ja/nativeFull/surnameFirst": { "suffix": { "expected": "", "got": "さん" }, "text": "佐藤グレース恵子さん", "title": { "expected": "さん", "got": "" } }, "ja/nativeG/givenFirst": { "family": { "expected": "", "got": "慎" }, "given_names": { "expected": "慎太郎", "got": "太郎" }, "text": "慎太郎" }, "ja/nativeG/sorting": { "family": { "expected": "", "got": "慎" }, "given_names": { "expected": "慎太郎", "got": "太郎" }, "text": "慎太郎" }, "ja/nativeG/surnameFirst": { "family": { "expected": "", "got": "慎" }, "given_names": { "expected": "慎太郎", "got": "太郎" }, "text": "慎太郎" }, "ja/nativeGGS/givenFirst": { "family": { "expected": "山田", "got": "太郎" }, "given_names": { "expected": "太郎 トーマス", "got": "トーマス山田" }, "text": "太郎トーマス山田" }, "ja/nativeGS/givenFirst": { "family": { "expected": "安藤", "got": "一郎" }, "given_names": { "expected": "一郎", "got": "安藤" }, "text": "一郎安藤" }, "ru/foreignFull/givenFirst": { "family": { "expected": "фон Брюль", "got": "проф." }, "family_base": { "expected": "Брюль", "got": "проф." }, "family_particles": { "expected": "фон", "got": "" }, "given_names": { "expected": "Ада Корнелия Сезар Мартин", "got": "Ада Корнелия Сезар Мартин фон Брюль —" }, "text": "проф., д-р Ада Корнелия Сезар Мартин фон Брюль — мл., к. м. н.", "title": { "expected": "проф., д-р", "got": "д-р" } }, "ru/foreignFull/sorting": { "family": { "expected": "Брюль фон", "got": "Брюль" }, "family_particles": { "expected": "фон", "got": "" }, "given_names": { "expected": "Ада Корнелия Сезар Мартин", "got": "Ада Корнелия Сезар Мартин фон" }, "text": "Брюль, Ада Корнелия Сезар Мартин фон" }, "ru/foreignFull/surnameFirst": { "family": { "expected": "фон Брюль", "got": "фон Брюль Ада Корнелия Сезар Мартин" }, "family_base": { "expected": "Брюль", "got": "фон Брюль Ада Корнелия Сезар Мартин" }, "family_particles": { "expected": "фон", "got": "" }, "given_names": { "expected": "Ада Корнелия Сезар Мартин", "got": "к. м. н." }, "suffix": { "expected": "к. м. н.", "got": "" }, "text": "фон Брюль Ада Корнелия Сезар Мартин, к. м. н." }, "ru/foreignG/sorting": { "family": { "expected": "", "got": "Синдбад" }, "given_names": { "expected": "Синдбад", "got": "" }, "text": "Синдбад" }, "ru/foreignG/surnameFirst": { "family": { "expected": "", "got": "Синдбад" }, "given_names": { "expected": "Синдбад", "got": "" }, "text": "Синдбад" }, "ru/nativeG/sorting": { "family": { "expected": "", "got": "Сергей" }, "given_names": { "expected": "Сергей", "got": "" }, "text": "Сергей" }, "ru/nativeG/surnameFirst": { "family": { "expected": "", "got": "Сергей" }, "given_names": { "expected": "Сергей", "got": "" }, "text": "Сергей" }, "tr/foreignFull/givenFirst": { "given_names": { "expected": "Ada Cornelia César Martín", "got": "MD DDS Ada Cornelia César Martín" }, "suffix": { "expected": "MD DDS Jr", "got": "Jr" }, "text": "Prof. Dr. MD DDS Ada Cornelia César Martín von Brühl Jr" }, "tr/foreignFull/surnameFirst": { "family": { "expected": "von Brühl Plácido Domingo", "got": "von" }, "family_base": { "expected": "Brühl Plácido Domingo", "got": "von" }, "family_particles": { "expected": "von", "got": "" }, "given_names": { "expected": "Ada Cornelia César Martín", "got": "Brühl Plácido Domingo Ada Cornelia César Martín" }, "text": "Prof. Dr. von Brühl Plácido Domingo Ada Cornelia César Martín, MD DDS" }, "tr/foreignG/sorting": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "tr/foreignG/surnameFirst": { "family": { "expected": "", "got": "Sinbad" }, "given_names": { "expected": "Sinbad", "got": "" }, "text": "Sinbad" }, "tr/nativeFull/givenFirst": { "suffix": { "expected": "Dr.", "got": "" }, "text": "Sn. Dr. Fatma Su Yıldırım", "title": { "expected": "Sn.", "got": "Sn. Dr." } }, "tr/nativeFull/surnameFirst": { "family": { "expected": "Yıldırım Kaya", "got": "Yıldırım" }, "family_base": { "expected": "Yıldırım Kaya", "got": "Yıldırım" }, "given_names": { "expected": "Fatma Su", "got": "Kaya Fatma Su" }, "suffix": { "expected": "Dr.", "got": "" }, "text": "Sn. Yıldırım Kaya Fatma Su, Dr.", "title": { "expected": "Sn.", "got": "Sn. Dr." } }, "tr/nativeG/sorting": { "family": { "expected": "", "got": "Zeynep" }, "given_names": { "expected": "Zeynep", "got": "" }, "text": "Zeynep" }, "tr/nativeG/surnameFirst": { "family": { "expected": "", "got": "Zeynep" }, "given_names": { "expected": "Zeynep", "got": "" }, "text": "Zeynep" }, "yue/foreignFull/givenFirst": { "family": { "expected": "陳 陳", "got": "小" }, "family_base": { "expected": "陳", "got": "小" }, "family_particles": { "expected": "陳", "got": "" }, "given_names": { "expected": "怡君 達印", "got": "教授 怡君 達印 陳 陳" }, "suffix": { "expected": "小 博士", "got": "博士" }, "text": "教授·怡君·達印·陳·陳·小,博士", "title": { "expected": "教授", "got": "" } }, "yue/foreignFull/sorting": { "family": { "expected": "陳 陳", "got": "陳" }, "family_particles": { "expected": "陳", "got": "" }, "text": "陳·怡君·達印" }, "yue/foreignFull/surnameFirst": { "family": { "expected": "陳 陳", "got": "陳 陳 怡君" }, "family_base": { "expected": "陳", "got": "陳 陳 怡君" }, "family_particles": { "expected": "陳", "got": "" }, "given_names": { "expected": "怡君 達印", "got": "小 達印" }, "suffix": { "expected": "小 博士", "got": "教授, 博士" }, "text": "陳·陳·怡君·教授,小·達印·博士", "title": { "expected": "教授", "got": "" } }, "yue/foreignG/givenFirst": { "family": { "expected": "", "got": "凱晴" }, "given_names": { "expected": "凱晴", "got": "" }, "text": "凱晴" }, "yue/foreignG/sorting": { "family": { "expected": "", "got": "凱晴" }, "given_names": { "expected": "凱晴", "got": "" }, "text": "凱晴" }, "yue/foreignG/surnameFirst": { "family": { "expected": "", "got": "凱晴" }, "given_names": { "expected": "凱晴", "got": "" }, "text": "凱晴" }, "yue/nativeFull/givenFirst": { "family": { "expected": "陳", "got": "先生子媚惠儀陳小" }, "family_base": { "expected": "陳", "got": "先生子媚惠儀陳小" }, "given_names": { "expected": "子媚 惠儀", "got": "" }, "suffix": { "expected": "小 太平紳士", "got": "太平紳士" }, "text": "先生子媚惠儀陳小,太平紳士", "title": { "expected": "先生", "got": "" } }, "yue/nativeFull/surnameFirst": { "family": { "expected": "陳", "got": "陳子媚先生" }, "family_base": { "expected": "陳", "got": "陳子媚先生" }, "given_names": { "expected": "子媚 惠儀", "got": "小惠儀" }, "suffix": { "expected": "小 太平紳士", "got": "太平紳士" }, "text": "陳子媚先生,小惠儀太平紳士", "title": { "expected": "先生", "got": "" } }, "yue/nativeG/givenFirst": { "family": { "expected": "", "got": "文" }, "given_names": { "expected": "文傑", "got": "傑" }, "text": "文傑" }, "yue/nativeG/sorting": { "family": { "expected": "", "got": "文" }, "given_names": { "expected": "文傑", "got": "傑" }, "text": "文傑" }, "yue/nativeG/surnameFirst": { "family": { "expected": "", "got": "文" }, "given_names": { "expected": "文傑", "got": "傑" }, "text": "文傑" }, "yue/nativeGGS/givenFirst": { "family": { "expected": "林", "got": "家豪明德林" }, "given_names": { "expected": "家豪 明德", "got": "" }, "text": "家豪明德林" }, "yue/nativeGS/givenFirst": { "family": { "expected": "張", "got": "雅婷張" }, "given_names": { "expected": "雅婷", "got": "" }, "text": "雅婷張" }, "yue_Hans/foreignFull/givenFirst": { "family": { "expected": "陈 陈", "got": "小" }, "family_base": { "expected": "陈", "got": "小" }, "family_particles": { "expected": "陈", "got": "" }, "given_names": { "expected": "怡君 达印", "got": "教授 怡君 达印 陈 陈" }, "suffix": { "expected": "小 博士", "got": "博士" }, "text": "教授·怡君·达印·陈·陈·小,博士", "title": { "expected": "教授", "got": "" } }, "yue_Hans/foreignFull/sorting": { "family": { "expected": "陈 陈", "got": "陈" }, "family_particles": { "expected": "陈", "got": "" }, "text": "陈·怡君·达印" }, "yue_Hans/foreignFull/surnameFirst": { "family": { "expected": "陈 陈", "got": "陈 陈 怡君" }, "family_base": { "expected": "陈", "got": "陈 陈 怡君" }, "family_particles": { "expected": "陈", "got": "" }, "given_names": { "expected": "怡君 达印", "got": "小 达印" }, "suffix": { "expected": "小 博士", "got": "教授, 博士" }, "text": "陈·陈·怡君·教授,小·达印·博士", "title": { "expected": "教授", "got": "" } }, "yue_Hans/foreignG/givenFirst": { "family": { "expected": "", "got": "凯晴" }, "given_names": { "expected": "凯晴", "got": "" }, "text": "凯晴" }, "yue_Hans/foreignG/sorting": { "family": { "expected": "", "got": "凯晴" }, "given_names": { "expected": "凯晴", "got": "" }, "text": "凯晴" }, "yue_Hans/foreignG/surnameFirst": { "family": { "expected": "", "got": "凯晴" }, "given_names": { "expected": "凯晴", "got": "" }, "text": "凯晴" }, "yue_Hans/nativeFull/givenFirst": { "family": { "expected": "陈", "got": "先生子媚惠仪陈小" }, "family_base": { "expected": "陈", "got": "先生子媚惠仪陈小" }, "given_names": { "expected": "子媚 惠仪", "got": "" }, "suffix": { "expected": "小 太平绅士", "got": "太平绅士" }, "text": "先生子媚惠仪陈小,太平绅士", "title": { "expected": "先生", "got": "" } }, "yue_Hans/nativeFull/surnameFirst": { "family": { "expected": "陈", "got": "陈子媚先生" }, "family_base": { "expected": "陈", "got": "陈子媚先生" }, "given_names": { "expected": "子媚 惠仪", "got": "小惠仪" }, "suffix": { "expected": "小 太平绅士", "got": "太平绅士" }, "text": "陈子媚先生,小惠仪太平绅士", "title": { "expected": "先生", "got": "" } }, "yue_Hans/nativeG/givenFirst": { "family": { "expected": "", "got": "文" }, "given_names": { "expected": "文杰", "got": "杰" }, "text": "文杰" }, "yue_Hans/nativeG/sorting": { "family": { "expected": "", "got": "文" }, "given_names": { "expected": "文杰", "got": "杰" }, "text": "文杰" }, "yue_Hans/nativeG/surnameFirst": { "family": { "expected": "", "got": "文" }, "given_names": { "expected": "文杰", "got": "杰" }, "text": "文杰" }, "yue_Hans/nativeGGS/givenFirst": { "family": { "expected": "林", "got": "家豪明德林" }, "given_names": { "expected": "家豪 明德", "got": "" }, "text": "家豪明德林" }, "yue_Hans/nativeGS/givenFirst": { "family": { "expected": "张", "got": "雅婷张" }, "given_names": { "expected": "雅婷", "got": "" }, "text": "雅婷张" }, "zh/foreignFull/givenFirst": { "family": { "expected": "冯 布鲁赫", "got": "布鲁赫博士" }, "family_base": { "expected": "布鲁赫", "got": "布鲁赫博士" }, "family_particles": { "expected": "冯", "got": "" }, "given_names": { "expected": "艾达·科妮莉亚 塞萨尔·马丁", "got": "小艾达 科妮莉亚 塞萨尔 马丁 冯" }, "suffix": { "expected": "小 博士", "got": "教授" }, "text": "小艾达·科妮莉亚·塞萨尔·马丁·冯·布鲁赫博士教授", "title": { "expected": "教授", "got": "" } }, "zh/foreignFull/sorting": { "family": { "expected": "布鲁赫 冯", "got": "布鲁赫" }, "family_particles": { "expected": "冯", "got": "" }, "given_names": { "expected": "艾达·科妮莉亚 塞萨尔·马丁", "got": "艾达 科妮莉亚 塞萨尔 马丁 冯" }, "text": "布鲁赫·艾达·科妮莉亚·塞萨尔·马丁·冯" }, "zh/foreignFull/surnameFirst": { "family": { "expected": "冯 布鲁赫", "got": "小冯" }, "family_base": { "expected": "布鲁赫", "got": "小冯" }, "family_particles": { "expected": "冯", "got": "" }, "given_names": { "expected": "艾达·科妮莉亚 塞萨尔·马丁", "got": "布鲁赫 艾达 科妮莉亚 塞萨尔 马丁" }, "suffix": { "expected": "小 博士", "got": "博士 教授" }, "text": "小冯·布鲁赫·艾达·科妮莉亚·塞萨尔·马丁·博士教授", "title": { "expected": "教授", "got": "" } }, "zh/foreignG/givenFirst": { "family": { "expected": "", "got": "辛" }, "given_names": { "expected": "辛巴达", "got": "巴达" }, "text": "辛巴达" }, "zh/foreignG/sorting": { "family": { "expected": "", "got": "辛" }, "given_names": { "expected": "辛巴达", "got": "巴达" }, "text": "辛巴达" }, "zh/foreignG/surnameFirst": { "family": { "expected": "", "got": "辛" }, "given_names": { "expected": "辛巴达", "got": "巴达" }, "text": "辛巴达" }, "zh/nativeFull/givenFirst": { "family": { "expected": "彭", "got": "小德威东升彭议员" }, "family_base": { "expected": "彭", "got": "小德威东升彭议员" }, "given_names": { "expected": "德威 东升", "got": "" }, "suffix": { "expected": "小 议员", "got": "先生" }, "text": "小德威东升彭议员先生", "title": { "expected": "先生", "got": "" } }, "zh/nativeFull/surnameFirst": { "family": { "expected": "彭", "got": "小彭德威东升议员" }, "family_base": { "expected": "彭", "got": "小彭德威东升议员" }, "given_names": { "expected": "德威 东升", "got": "" }, "suffix": { "expected": "小 议员", "got": "先生" }, "text": "小彭德威东升议员先生", "title": { "expected": "先生", "got": "" } }, "zh/nativeG/givenFirst": { "family": { "expected": "", "got": "宇瀚" }, "given_names": { "expected": "宇瀚", "got": "" }, "text": "宇瀚" }, "zh/nativeG/sorting": { "family": { "expected": "", "got": "宇瀚" }, "given_names": { "expected": "宇瀚", "got": "" }, "text": "宇瀚" }, "zh/nativeG/surnameFirst": { "family": { "expected": "", "got": "宇瀚" }, "given_names": { "expected": "宇瀚", "got": "" }, "text": "宇瀚" }, "zh/nativeGGS/givenFirst": { "family": { "expected": "陈", "got": "俊年杰思陈" }, "given_names": { "expected": "俊年 杰思", "got": "" }, "text": "俊年杰思陈" }, "zh/nativeGS/givenFirst": { "family": { "expected": "张", "got": "安" }, "given_names": { "expected": "安怡", "got": "怡张" }, "text": "安怡张" }, "zh_Hant/foreignFull/givenFirst": { "family": { "expected": "馮 陳", "got": "陳二世博士" }, "family_base": { "expected": "陳", "got": "陳二世博士" }, "family_particles": { "expected": "馮", "got": "" }, "given_names": { "expected": "怡君 達印", "got": "怡君達印馮" }, "suffix": { "expected": "二世 醫學博士", "got": "醫學博士" }, "text": "怡君達印馮·陳二世博士,醫學博士", "title": { "expected": "博士", "got": "" } }, "zh_Hant/foreignFull/sorting": { "family": { "expected": "馮 陳", "got": "馮" }, "family_base": { "expected": "陳", "got": "馮" }, "family_particles": { "expected": "馮", "got": "" }, "given_names": { "expected": "怡君 達印", "got": "陳 怡君 達印" }, "text": "馮·陳·怡君·達印" }, "zh_Hant/foreignFull/surnameFirst": { "family": { "expected": "馮 陳", "got": "馮" }, "family_base": { "expected": "陳", "got": "馮" }, "family_particles": { "expected": "馮", "got": "" }, "given_names": { "expected": "怡君 達印", "got": "陳 怡君 達印" }, "text": "馮·陳·怡君·達印·二世醫學博士", "title": { "expected": "博士", "got": "" } }, "zh_Hant/foreignG/givenFirst": { "family": { "expected": "", "got": "雅婷" }, "given_names": { "expected": "雅婷", "got": "" }, "text": "雅婷" }, "zh_Hant/foreignG/sorting": { "family": { "expected": "", "got": "雅婷" }, "given_names": { "expected": "雅婷", "got": "" }, "text": "雅婷" }, "zh_Hant/foreignG/surnameFirst": { "family": { "expected": "", "got": "雅婷" }, "given_names": { "expected": "雅婷", "got": "" }, "text": "雅婷" }, "zh_Hant/foreignGGS/givenFirst": { "family": { "expected": "王", "got": "雅婷婷婷王" }, "given_names": { "expected": "雅婷 婷婷", "got": "" }, "text": "雅婷婷婷王" }, "zh_Hant/foreignGS/givenFirst": { "family": { "expected": "王", "got": "雅婷王" }, "given_names": { "expected": "雅婷", "got": "" }, "text": "雅婷王" }, "zh_Hant/nativeFull/givenFirst": { "family": { "expected": "王", "got": "雅婷婷婷王二世" }, "family_base": { "expected": "王", "got": "雅婷婷婷王二世" }, "given_names": { "expected": "雅婷 婷婷", "got": "" }, "suffix": { "expected": "二世 議員", "got": "先生, 議員" }, "text": "雅婷婷婷王二世先生,議員", "title": { "expected": "先生", "got": "" } }, "zh_Hant/nativeFull/surnameFirst": { "given_names": { "expected": "雅婷 婷婷", "got": "雅婷婷婷二世" }, "suffix": { "expected": "二世 議員", "got": "議員" }, "text": "王雅婷婷婷二世議員" }, "zh_Hant/nativeG/givenFirst": { "family": { "expected": "", "got": "文" }, "given_names": { "expected": "文傑", "got": "傑" }, "text": "文傑" }, "zh_Hant/nativeG/sorting": { "family": { "expected": "", "got": "文" }, "given_names": { "expected": "文傑", "got": "傑" }, "text": "文傑" }, "zh_Hant/nativeG/surnameFirst": { "family": { "expected": "", "got": "文" }, "given_names": { "expected": "文傑", "got": "傑" }, "text": "文傑" }, "zh_Hant/nativeGGS/givenFirst": { "family": { "expected": "林", "got": "家豪明德林" }, "given_names": { "expected": "家豪 明德", "got": "" }, "text": "家豪明德林" }, "zh_Hant/nativeGS/givenFirst": { "family": { "expected": "張", "got": "雅婷張" }, "given_names": { "expected": "雅婷", "got": "" }, "text": "雅婷張" } } �����������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/cldr_fields.jsonl������������������������������0000664�0000000�0000000�00002336604�15273032717�0026042�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������{"fields": {"credentials": "MBChB PhD", "generation": "jnr.", "given": "Cornelia Margarita", "given-informal": "Nellie", "given2": "Sofia Aletta", "locale": "ja_AQ", "surname-core": "Berg", "surname-prefix": "van den", "surname2": "Wessels Swart", "title": "Prof. dr."}, "file": "af", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Prof. dr. Cornelia Margarita Sofia Aletta van den Berg jnr., MBChB PhD"} {"fields": {"credentials": "MBChB PhD", "generation": "jnr.", "given": "Cornelia Margarita", "given-informal": "Nellie", "given2": "Sofia Aletta", "locale": "ja_AQ", "surname-core": "Berg", "surname-prefix": "van den", "surname2": "Wessels Swart", "title": "Prof. dr."}, "file": "af", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Berg, Cornelia Margarita Sofia Aletta van den"} {"fields": {"credentials": "MBChB PhD", "generation": "jnr.", "given": "Cornelia Margarita", "given-informal": "Nellie", "given2": "Sofia Aletta", "locale": "ja_AQ", "surname-core": "Berg", "surname-prefix": "van den", "surname2": "Wessels Swart", "title": "Prof. dr."}, "file": "af", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "van den Berg Cornelia Margarita Sofia Aletta MBChB PhD"} {"fields": {"given": "Sarel", "locale": "ja_AQ"}, "file": "af", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sarel"} {"fields": {"given": "Sarel", "locale": "ja_AQ"}, "file": "af", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sarel"} {"fields": {"given": "Sarel", "locale": "ja_AQ"}, "file": "af", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sarel"} {"fields": {"given": "Zelda", "given2": "Herman", "locale": "ja_AQ", "surname": "Stander"}, "file": "af", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zelda Herman Stander"} {"fields": {"given": "Zelda", "given2": "Herman", "locale": "ja_AQ", "surname": "Stander"}, "file": "af", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Stander, Zelda Herman"} {"fields": {"given": "Zelda", "given2": "Herman", "locale": "ja_AQ", "surname": "Stander"}, "file": "af", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Stander Zelda Herman"} {"fields": {"given": "Katryn", "locale": "ja_AQ", "surname": "Müller"}, "file": "af", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Katryn Müller"} {"fields": {"given": "Katryn", "locale": "ja_AQ", "surname": "Müller"}, "file": "af", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Müller, Katryn"} {"fields": {"given": "Katryn", "locale": "ja_AQ", "surname": "Müller"}, "file": "af", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Müller Katryn"} {"fields": {"credentials": "LP", "generation": "jnr.", "given": "Bertus Wilbers", "given-informal": "Bertie", "given2": "Henri Retief", "locale": "af_AQ", "surname-core": "Willemse", "title": "mnr."}, "file": "af", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "mnr. Bertus Wilbers Henri Retief Willemse jnr., LP"} {"fields": {"credentials": "LP", "generation": "jnr.", "given": "Bertus Wilbers", "given-informal": "Bertie", "given2": "Henri Retief", "locale": "af_AQ", "surname-core": "Willemse", "title": "mnr."}, "file": "af", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Willemse, Bertus Wilbers Henri Retief"} {"fields": {"credentials": "LP", "generation": "jnr.", "given": "Bertus Wilbers", "given-informal": "Bertie", "given2": "Henri Retief", "locale": "af_AQ", "surname-core": "Willemse", "title": "mnr."}, "file": "af", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Willemse Bertus Wilbers Henri Retief LP"} {"fields": {"given": "Piet", "locale": "af_AQ"}, "file": "af", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Piet"} {"fields": {"given": "Piet", "locale": "af_AQ"}, "file": "af", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Piet"} {"fields": {"given": "Piet", "locale": "af_AQ"}, "file": "af", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Piet"} {"fields": {"given": "Jan", "given2": "Koos", "locale": "af_AQ", "surname": "Van der Merwe"}, "file": "af", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Jan Koos Van der Merwe"} {"fields": {"given": "Jan", "given2": "Koos", "locale": "af_AQ", "surname": "Van der Merwe"}, "file": "af", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Van der Merwe, Jan Koos"} {"fields": {"given": "Jan", "given2": "Koos", "locale": "af_AQ", "surname": "Van der Merwe"}, "file": "af", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Van der Merwe Jan Koos"} {"fields": {"given": "Irene", "locale": "af_AQ", "surname": "Rossouw"}, "file": "af", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Irene Rossouw"} {"fields": {"given": "Irene", "locale": "af_AQ", "surname": "Rossouw"}, "file": "af", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Rossouw, Irene"} {"fields": {"given": "Irene", "locale": "af_AQ", "surname": "Rossouw"}, "file": "af", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Rossouw Irene"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ja_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "ak", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Prof. Dr. Ada Cornelia César Martín von Brühl González Domingo MD DDS"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ja_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "ak", "label": "foreignFull", "order": "sorting", "shape": null, "text": "von Brühl González Domingo, Prof. Dr. Ada Cornelia César Martín MD DDS"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ja_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "ak", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "von Brühl González Domingo Prof. Dr. Ada Cornelia César Martín MD DDS"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "ak", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "ak", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "ak", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stöber"}, "file": "ak", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zäzilia Hamish Stöber"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stöber"}, "file": "ak", "label": "foreignGGS", "order": "sorting", "shape": 4, "text": "Stöber Zäzilia Hamish"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stöber"}, "file": "ak", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Stöber Zäzilia Hamish"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "ak", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Käthe Müller"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "ak", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "Müller Käthe"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "ak", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Müller Käthe"} {"fields": {"credentials": "MP", "generation": "Ketewa", "given": "Bertram Wilberforce", "given-informal": "Bertie", "given2": "Henry Robert", "locale": "ak_AQ", "surname-core": "Wooster", "title": "Owura"}, "file": "ak", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Owura Bertram Wilberforce Henry Robert Wooster MP"} {"fields": {"credentials": "MP", "generation": "Ketewa", "given": "Bertram Wilberforce", "given-informal": "Bertie", "given2": "Henry Robert", "locale": "ak_AQ", "surname-core": "Wooster", "title": "Owura"}, "file": "ak", "label": "nativeFull", "order": "sorting", "shape": 4, "text": "Wooster Owura Bertram Wilberforce Henry Robert MP"} {"fields": {"credentials": "MP", "generation": "Ketewa", "given": "Bertram Wilberforce", "given-informal": "Bertie", "given2": "Henry Robert", "locale": "ak_AQ", "surname-core": "Wooster", "title": "Owura"}, "file": "ak", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Wooster Owura Bertram Wilberforce Henry Robert MP"} {"fields": {"given": "Zendaya", "locale": "ak_AQ"}, "file": "ak", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Zendaya"} {"fields": {"given": "Zendaya", "locale": "ak_AQ"}, "file": "ak", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Zendaya"} {"fields": {"given": "Zendaya", "locale": "ak_AQ"}, "file": "ak", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Zendaya"} {"fields": {"given": "Mary Sue", "given2": "Hamish", "locale": "ak_AQ", "surname": "Watson"}, "file": "ak", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Mary Sue Hamish Watson"} {"fields": {"given": "Mary Sue", "given2": "Hamish", "locale": "ak_AQ", "surname": "Watson"}, "file": "ak", "label": "nativeGGS", "order": "sorting", "shape": 4, "text": "Watson Mary Sue Hamish"} {"fields": {"given": "Mary Sue", "given2": "Hamish", "locale": "ak_AQ", "surname": "Watson"}, "file": "ak", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Watson Mary Sue Hamish"} {"fields": {"given": "Irene", "locale": "ak_AQ", "surname": "Adler"}, "file": "ak", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Irene Adler"} {"fields": {"given": "Irene", "locale": "ak_AQ", "surname": "Adler"}, "file": "ak", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "Adler Irene"} {"fields": {"given": "Irene", "locale": "ak_AQ", "surname": "Adler"}, "file": "ak", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Adler Irene"} {"fields": {"credentials": "ዶ/ር የጥርስ ዶ/ር", "generation": "ልጅ", "given": "አዳ ኮርኔሊያ", "given-informal": "ኒል", "given2": "ሴዛር ማርቲን", "locale": "ja_AQ", "surname-core": "ብሩህል", "surname-prefix": "ቮን", "surname2": "ጎንዛሌዝ ዶሚንጎ", "title": "ፕ/ር ዶ/ር"}, "file": "am", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "ፕ/ር ዶ/ር አዳ ኮርኔሊያ ሴዛር ማርቲን ቮን ብሩህል ጎንዛሌዝ ዶሚንጎ ዶ/ር የጥርስ ዶ/ር"} {"fields": {"credentials": "ዶ/ር የጥርስ ዶ/ር", "generation": "ልጅ", "given": "አዳ ኮርኔሊያ", "given-informal": "ኒል", "given2": "ሴዛር ማርቲን", "locale": "ja_AQ", "surname-core": "ብሩህል", "surname-prefix": "ቮን", "surname2": "ጎንዛሌዝ ዶሚንጎ", "title": "ፕ/ር ዶ/ር"}, "file": "am", "label": "foreignFull", "order": "sorting", "shape": null, "text": "ቮን ብሩህል ጎንዛሌዝ ዶሚንጎ, ፕ/ር ዶ/ር አዳ ኮርኔሊያ ሴዛር ማርቲን ዶ/ር የጥርስ ዶ/ር"} {"fields": {"credentials": "ዶ/ር የጥርስ ዶ/ር", "generation": "ልጅ", "given": "አዳ ኮርኔሊያ", "given-informal": "ኒል", "given2": "ሴዛር ማርቲን", "locale": "ja_AQ", "surname-core": "ብሩህል", "surname-prefix": "ቮን", "surname2": "ጎንዛሌዝ ዶሚንጎ", "title": "ፕ/ር ዶ/ር"}, "file": "am", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "ቮን ብሩህል ጎንዛሌዝ ዶሚንጎ ፕ/ር ዶ/ር አዳ ኮርኔሊያ ሴዛር ማርቲን ዶ/ር የጥርስ ዶ/ር"} {"fields": {"given": "ሲንባድ", "locale": "ja_AQ"}, "file": "am", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "ሲንባድ"} {"fields": {"given": "ሲንባድ", "locale": "ja_AQ"}, "file": "am", "label": "foreignG", "order": "sorting", "shape": 4, "text": "ሲንባድ"} {"fields": {"given": "ሲንባድ", "locale": "ja_AQ"}, "file": "am", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "ሲንባድ"} {"fields": {"given": "ዛዚሊያ", "given2": "ሃሚሽ", "locale": "ja_AQ", "surname": "ስቶበር"}, "file": "am", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "ዛዚሊያ ሃሚሽ ስቶበር"} {"fields": {"given": "ዛዚሊያ", "given2": "ሃሚሽ", "locale": "ja_AQ", "surname": "ስቶበር"}, "file": "am", "label": "foreignGGS", "order": "sorting", "shape": 4, "text": "ስቶበር ዛዚሊያ ሃሚሽ"} {"fields": {"given": "ዛዚሊያ", "given2": "ሃሚሽ", "locale": "ja_AQ", "surname": "ስቶበር"}, "file": "am", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "ስቶበር ዛዚሊያ ሃሚሽ"} {"fields": {"given": "ካቲ", "locale": "ja_AQ", "surname": "ሙለር"}, "file": "am", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "ካቲ ሙለር"} {"fields": {"given": "ካቲ", "locale": "ja_AQ", "surname": "ሙለር"}, "file": "am", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "ሙለር ካቲ"} {"fields": {"given": "ካቲ", "locale": "ja_AQ", "surname": "ሙለር"}, "file": "am", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "ሙለር ካቲ"} {"fields": {"credentials": "የፓርላማ አባል", "generation": "ልጅ", "given": "በርትራም ዊልበርፎርስ", "given-informal": "በርቲ", "given2": "ሄነሪ ሮበርት", "locale": "am_AQ", "surname-core": "ዉስተር", "title": "አቶ"}, "file": "am", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "አቶ በርትራም ዊልበርፎርስ ሄነሪ ሮበርት ዉስተር የፓርላማ አባል"} {"fields": {"credentials": "የፓርላማ አባል", "generation": "ልጅ", "given": "በርትራም ዊልበርፎርስ", "given-informal": "በርቲ", "given2": "ሄነሪ ሮበርት", "locale": "am_AQ", "surname-core": "ዉስተር", "title": "አቶ"}, "file": "am", "label": "nativeFull", "order": "sorting", "shape": 4, "text": "ዉስተር አቶ በርትራም ዊልበርፎርስ ሄነሪ ሮበርት የፓርላማ አባል"} {"fields": {"credentials": "የፓርላማ አባል", "generation": "ልጅ", "given": "በርትራም ዊልበርፎርስ", "given-informal": "በርቲ", "given2": "ሄነሪ ሮበርት", "locale": "am_AQ", "surname-core": "ዉስተር", "title": "አቶ"}, "file": "am", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "ዉስተር አቶ በርትራም ዊልበርፎርስ ሄነሪ ሮበርት የፓርላማ አባል"} {"fields": {"given": "ዜንዳያ", "locale": "am_AQ"}, "file": "am", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "ዜንዳያ"} {"fields": {"given": "ዜንዳያ", "locale": "am_AQ"}, "file": "am", "label": "nativeG", "order": "sorting", "shape": 4, "text": "ዜንዳያ"} {"fields": {"given": "ዜንዳያ", "locale": "am_AQ"}, "file": "am", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "ዜንዳያ"} {"fields": {"given": "ሜሪ ሱ", "given2": "ሃሚሽ", "locale": "am_AQ", "surname": "ዋትሰን"}, "file": "am", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "ሜሪ ሱ ሃሚሽ ዋትሰን"} {"fields": {"given": "ሜሪ ሱ", "given2": "ሃሚሽ", "locale": "am_AQ", "surname": "ዋትሰን"}, "file": "am", "label": "nativeGGS", "order": "sorting", "shape": 4, "text": "ዋትሰን ሜሪ ሱ ሃሚሽ"} {"fields": {"given": "ሜሪ ሱ", "given2": "ሃሚሽ", "locale": "am_AQ", "surname": "ዋትሰን"}, "file": "am", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "ዋትሰን ሜሪ ሱ ሃሚሽ"} {"fields": {"given": "አይሪን", "locale": "am_AQ", "surname": "አድለር"}, "file": "am", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "አይሪን አድለር"} {"fields": {"given": "አይሪን", "locale": "am_AQ", "surname": "አድለር"}, "file": "am", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "አድለር አይሪን"} {"fields": {"given": "አይሪን", "locale": "am_AQ", "surname": "አድለር"}, "file": "am", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "አድለር አይሪን"} {"fields": {"credentials": "طبيب وجراح", "generation": "الإبن", "given": "عايدة كورنيليا", "given-informal": "نيللي", "given2": "سيزار مارتن", "locale": "ja_AQ", "surname-core": "برول", "surname-prefix": "فون", "surname2": "غونزاليس دومينغو", "title": "الدكتور"}, "file": "ar", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "الدكتور عايدة كورنيليا سيزار مارتن فون برول الإبن، طبيب وجراح"} {"fields": {"credentials": "طبيب وجراح", "generation": "الإبن", "given": "عايدة كورنيليا", "given-informal": "نيللي", "given2": "سيزار مارتن", "locale": "ja_AQ", "surname-core": "برول", "surname-prefix": "فون", "surname2": "غونزاليس دومينغو", "title": "الدكتور"}, "file": "ar", "label": "foreignFull", "order": "sorting", "shape": 4, "text": "فون برول، عايدة كورنيليا سيزار مارتن"} {"fields": {"credentials": "طبيب وجراح", "generation": "الإبن", "given": "عايدة كورنيليا", "given-informal": "نيللي", "given2": "سيزار مارتن", "locale": "ja_AQ", "surname-core": "برول", "surname-prefix": "فون", "surname2": "غونزاليس دومينغو", "title": "الدكتور"}, "file": "ar", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "فون برول، عايدة كورنيليا سيزار مارتن"} {"fields": {"given": "سندباد", "locale": "ja_AQ"}, "file": "ar", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "سندباد"} {"fields": {"given": "سندباد", "locale": "ja_AQ"}, "file": "ar", "label": "foreignG", "order": "sorting", "shape": 4, "text": "سندباد"} {"fields": {"given": "سندباد", "locale": "ja_AQ"}, "file": "ar", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "سندباد"} {"fields": {"given": "زيزينيا", "given2": "هاميش", "locale": "ja_AQ", "surname": "ستوبر"}, "file": "ar", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "زيزينيا هاميش ستوبر"} {"fields": {"given": "زيزينيا", "given2": "هاميش", "locale": "ja_AQ", "surname": "ستوبر"}, "file": "ar", "label": "foreignGGS", "order": "sorting", "shape": 4, "text": "ستوبر، زيزينيا هاميش"} {"fields": {"given": "زيزينيا", "given2": "هاميش", "locale": "ja_AQ", "surname": "ستوبر"}, "file": "ar", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "ستوبر، زيزينيا هاميش"} {"fields": {"given": "كاثرين", "locale": "ja_AQ", "surname": "مولر"}, "file": "ar", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "كاثرين مولر"} {"fields": {"given": "كاثرين", "locale": "ja_AQ", "surname": "مولر"}, "file": "ar", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "مولر، كاثرين"} {"fields": {"given": "كاثرين", "locale": "ja_AQ", "surname": "مولر"}, "file": "ar", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "مولر، كاثرين"} {"fields": {"credentials": "النائب", "generation": "الابن", "given": "أحمد رامي", "given-informal": "وسام", "given2": "نجيب محفوظ", "locale": "ar_AQ", "surname-core": "الأغا", "surname-prefix": "أبو", "surname2": "علم الدين", "title": "السيد"}, "file": "ar", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "السيد أحمد رامي نجيب محفوظ أبو الأغا الابن، النائب"} {"fields": {"credentials": "النائب", "generation": "الابن", "given": "أحمد رامي", "given-informal": "وسام", "given2": "نجيب محفوظ", "locale": "ar_AQ", "surname-core": "الأغا", "surname-prefix": "أبو", "surname2": "علم الدين", "title": "السيد"}, "file": "ar", "label": "nativeFull", "order": "sorting", "shape": 4, "text": "أبو الأغا، أحمد رامي نجيب محفوظ"} {"fields": {"credentials": "النائب", "generation": "الابن", "given": "أحمد رامي", "given-informal": "وسام", "given2": "نجيب محفوظ", "locale": "ar_AQ", "surname-core": "الأغا", "surname-prefix": "أبو", "surname2": "علم الدين", "title": "السيد"}, "file": "ar", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "أبو الأغا، أحمد رامي نجيب محفوظ"} {"fields": {"given": "منير", "locale": "ar_AQ"}, "file": "ar", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "منير"} {"fields": {"given": "منير", "locale": "ar_AQ"}, "file": "ar", "label": "nativeG", "order": "sorting", "shape": 4, "text": "منير"} {"fields": {"given": "منير", "locale": "ar_AQ"}, "file": "ar", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "منير"} {"fields": {"given": "كمال", "given2": "مجدي", "locale": "ar_AQ", "surname": "العامر"}, "file": "ar", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "كمال مجدي العامر"} {"fields": {"given": "كمال", "given2": "مجدي", "locale": "ar_AQ", "surname": "العامر"}, "file": "ar", "label": "nativeGGS", "order": "sorting", "shape": 4, "text": "العامر، كمال مجدي"} {"fields": {"given": "كمال", "given2": "مجدي", "locale": "ar_AQ", "surname": "العامر"}, "file": "ar", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "العامر، كمال مجدي"} {"fields": {"given": "سميرة", "locale": "ar_AQ", "surname": "النجار"}, "file": "ar", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "سميرة النجار"} {"fields": {"given": "سميرة", "locale": "ar_AQ", "surname": "النجار"}, "file": "ar", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "النجار، سميرة"} {"fields": {"given": "سميرة", "locale": "ar_AQ", "surname": "النجار"}, "file": "ar", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "النجار، سميرة"} {"fields": {"credentials": "এম. ডি. ডি. ডি. এছ", "generation": "কনিষ্ঠ", "given": "আডা কৰ্ণেলিয়া", "given-informal": "নীলে", "given2": "ইভা চ’ফিয়া", "locale": "ko_AQ", "surname-core": "কুকুৰনেচীয়া", "surname-prefix": "ভেন ডেন", "surname2": "বেকাৰ স্মিট", "title": "প্ৰফ. ডা০"}, "file": "as", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "আডা কৰ্ণেলিয়া ইভা চ’ফিয়া ভেন ডেন কুকুৰনেচীয়া এম. ডি. ডি. ডি. এছ"} {"fields": {"credentials": "এম. ডি. ডি. ডি. এছ", "generation": "কনিষ্ঠ", "given": "আডা কৰ্ণেলিয়া", "given-informal": "নীলে", "given2": "ইভা চ’ফিয়া", "locale": "ko_AQ", "surname-core": "কুকুৰনেচীয়া", "surname-prefix": "ভেন ডেন", "surname2": "বেকাৰ স্মিট", "title": "প্ৰফ. ডা০"}, "file": "as", "label": "foreignFull", "order": "sorting", "shape": null, "text": "কুকুৰনেচীয়া, আডা কৰ্ণেলিয়া ইভা চ’ফিয়া ভেন ডেন"} {"fields": {"credentials": "এম. ডি. ডি. ডি. এছ", "generation": "কনিষ্ঠ", "given": "আডা কৰ্ণেলিয়া", "given-informal": "নীলে", "given2": "ইভা চ’ফিয়া", "locale": "ko_AQ", "surname-core": "কুকুৰনেচীয়া", "surname-prefix": "ভেন ডেন", "surname2": "বেকাৰ স্মিট", "title": "প্ৰফ. ডা০"}, "file": "as", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "ভেন ডেন কুকুৰনেচীয়া আডা কৰ্ণেলিয়া ইভা চ’ফিয়া এম. ডি. ডি. ডি. এছ"} {"fields": {"given": "সত্যৰাম", "locale": "ko_AQ"}, "file": "as", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "সত্যৰাম"} {"fields": {"given": "সত্যৰাম", "locale": "ko_AQ"}, "file": "as", "label": "foreignG", "order": "sorting", "shape": 4, "text": "সত্যৰাম"} {"fields": {"given": "সত্যৰাম", "locale": "ko_AQ"}, "file": "as", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "সত্যৰাম"} {"fields": {"given": "মণিমালা", "given2": "অজিত", "locale": "ko_AQ", "surname": "বৰুৱা"}, "file": "as", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "মণিমালা অজিত বৰুৱা"} {"fields": {"given": "মণিমালা", "given2": "অজিত", "locale": "ko_AQ", "surname": "বৰুৱা"}, "file": "as", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "বৰুৱা, মণিমালা অজিত"} {"fields": {"given": "মণিমালা", "given2": "অজিত", "locale": "ko_AQ", "surname": "বৰুৱা"}, "file": "as", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "বৰুৱা মণিমালা অজিত"} {"fields": {"given": "নগেন", "locale": "ko_AQ", "surname": "বৰা"}, "file": "as", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "নগেন বৰা"} {"fields": {"given": "নগেন", "locale": "ko_AQ", "surname": "বৰা"}, "file": "as", "label": "foreignGS", "order": "sorting", "shape": null, "text": "বৰা, নগেন"} {"fields": {"given": "নগেন", "locale": "ko_AQ", "surname": "বৰা"}, "file": "as", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "বৰা নগেন"} {"fields": {"credentials": "এম. পি.", "generation": "ক.", "given": "নৰেন কলিতা", "given-informal": "নমী", "given2": "ৰুমী বৰুৱা", "locale": "as_AQ", "surname-core": "কাকতি", "title": "শ্ৰী"}, "file": "as", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "নৰেন কলিতা ৰুমী বৰুৱা কাকতি এম. পি."} {"fields": {"credentials": "এম. পি.", "generation": "ক.", "given": "নৰেন কলিতা", "given-informal": "নমী", "given2": "ৰুমী বৰুৱা", "locale": "as_AQ", "surname-core": "কাকতি", "title": "শ্ৰী"}, "file": "as", "label": "nativeFull", "order": "sorting", "shape": null, "text": "কাকতি, নৰেন কলিতা ৰুমী বৰুৱা"} {"fields": {"credentials": "এম. পি.", "generation": "ক.", "given": "নৰেন কলিতা", "given-informal": "নমী", "given2": "ৰুমী বৰুৱা", "locale": "as_AQ", "surname-core": "কাকতি", "title": "শ্ৰী"}, "file": "as", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "কাকতি নৰেন কলিতা ৰুমী বৰুৱা এম. পি."} {"fields": {"given": "কিশোৰ", "locale": "as_AQ"}, "file": "as", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "কিশোৰ"} {"fields": {"given": "কিশোৰ", "locale": "as_AQ"}, "file": "as", "label": "nativeG", "order": "sorting", "shape": 4, "text": "কিশোৰ"} {"fields": {"given": "কিশোৰ", "locale": "as_AQ"}, "file": "as", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "কিশোৰ"} {"fields": {"given": "ৰুলা কলিতা", "given2": "যতীন", "locale": "as_AQ", "surname": "শৰ্মা"}, "file": "as", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "ৰুলা কলিতা যতীন শৰ্মা"} {"fields": {"given": "ৰুলা কলিতা", "given2": "যতীন", "locale": "as_AQ", "surname": "শৰ্মা"}, "file": "as", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "শৰ্মা, ৰুলা কলিতা যতীন"} {"fields": {"given": "ৰুলা কলিতা", "given2": "যতীন", "locale": "as_AQ", "surname": "শৰ্মা"}, "file": "as", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "শৰ্মা ৰুলা কলিতা যতীন"} {"fields": {"given": "নমিতা", "locale": "as_AQ", "surname": "বৰুৱা"}, "file": "as", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "নমিতা বৰুৱা"} {"fields": {"given": "নমিতা", "locale": "as_AQ", "surname": "বৰুৱা"}, "file": "as", "label": "nativeGS", "order": "sorting", "shape": null, "text": "বৰুৱা, নমিতা"} {"fields": {"given": "নমিতা", "locale": "as_AQ", "surname": "বৰুৱা"}, "file": "as", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "বৰুৱা নমিতা"} {"fields": {"credentials": "M.D. Ph.D.", "generation": "Kiçik", "given": "Ada Kornelia", "given-informal": "Neele", "given2": "Sezar Martin", "locale": "ko_AQ", "surname-core": "Volf", "surname-prefix": "van den", "surname2": "Becker Schmidt", "title": "Prof. Dr."}, "file": "az", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Prof. Dr. Ada Kornelia Sezar Martin van den Volf Becker Schmidt M.D. Ph.D."} {"fields": {"credentials": "M.D. Ph.D.", "generation": "Kiçik", "given": "Ada Kornelia", "given-informal": "Neele", "given2": "Sezar Martin", "locale": "ko_AQ", "surname-core": "Volf", "surname-prefix": "van den", "surname2": "Becker Schmidt", "title": "Prof. Dr."}, "file": "az", "label": "foreignFull", "order": "sorting", "shape": null, "text": "van den Volf Becker Schmidt, Prof. Dr. Ada Kornelia Sezar Martin M.D. Ph.D."} {"fields": {"credentials": "M.D. Ph.D.", "generation": "Kiçik", "given": "Ada Kornelia", "given-informal": "Neele", "given2": "Sezar Martin", "locale": "ko_AQ", "surname-core": "Volf", "surname-prefix": "van den", "surname2": "Becker Schmidt", "title": "Prof. Dr."}, "file": "az", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "van den Volf Ada Kornelia Sezar Martin M.D. Ph.D."} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "az", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "az", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "az", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Zazilia", "given2": "Hamiş", "locale": "ko_AQ", "surname": "Ştöber"}, "file": "az", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zazilia Hamiş Ştöber"} {"fields": {"given": "Zazilia", "given2": "Hamiş", "locale": "ko_AQ", "surname": "Ştöber"}, "file": "az", "label": "foreignGGS", "order": "sorting", "shape": 4, "text": "Ştöber Zazilia Hamiş"} {"fields": {"given": "Zazilia", "given2": "Hamiş", "locale": "ko_AQ", "surname": "Ştöber"}, "file": "az", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Ştöber Zazilia Hamiş"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "az", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Käthe Müller"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "az", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "Müller Käthe"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "az", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Müller Käthe"} {"fields": {"credentials": "Deputat", "generation": "Kiçik", "given": "Arif Aliyev", "given-informal": "Arif", "given2": "İlvus Santak", "locale": "az_AQ", "surname-core": "Aydan", "title": "Cənab"}, "file": "az", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Cənab Arif Aliyev İlvus Santak Aydan Deputat"} {"fields": {"credentials": "Deputat", "generation": "Kiçik", "given": "Arif Aliyev", "given-informal": "Arif", "given2": "İlvus Santak", "locale": "az_AQ", "surname-core": "Aydan", "title": "Cənab"}, "file": "az", "label": "nativeFull", "order": "sorting", "shape": 4, "text": "Aydan Cənab Arif Aliyev İlvus Santak Deputat"} {"fields": {"credentials": "Deputat", "generation": "Kiçik", "given": "Arif Aliyev", "given-informal": "Arif", "given2": "İlvus Santak", "locale": "az_AQ", "surname-core": "Aydan", "title": "Cənab"}, "file": "az", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Aydan Arif Aliyev İlvus Santak Deputat"} {"fields": {"given": "Sinbad", "locale": "az_AQ"}, "file": "az", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "az_AQ"}, "file": "az", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "az_AQ"}, "file": "az", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Con", "given2": "Hamiş", "locale": "az_AQ", "surname": "Vatson"}, "file": "az", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Con Hamiş Vatson"} {"fields": {"given": "Con", "given2": "Hamiş", "locale": "az_AQ", "surname": "Vatson"}, "file": "az", "label": "nativeGGS", "order": "sorting", "shape": 4, "text": "Vatson Con Hamiş"} {"fields": {"given": "Con", "given2": "Hamiş", "locale": "az_AQ", "surname": "Vatson"}, "file": "az", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Vatson Con Hamiş"} {"fields": {"given": "İren", "locale": "az_AQ", "surname": "Adler"}, "file": "az", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "İren Adler"} {"fields": {"given": "İren", "locale": "az_AQ", "surname": "Adler"}, "file": "az", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "Adler İren"} {"fields": {"given": "İren", "locale": "az_AQ", "surname": "Adler"}, "file": "az", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Adler İren"} {"fields": {"credentials": "м. ф. д.", "generation": "кесе", "given": "Ада Корнелия", "given-informal": "Неле", "given2": "Сезар Мартин", "locale": "ja_AQ", "surname-core": "Брюль", "surname-prefix": "фон", "surname2": "Гонсалес Доминго", "title": "проф., д-р"}, "file": "ba", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "проф., д-р Ада Корнелия Сезар Мартин фон Брюль кесе, м. ф. д."} {"fields": {"credentials": "м. ф. д.", "generation": "кесе", "given": "Ада Корнелия", "given-informal": "Неле", "given2": "Сезар Мартин", "locale": "ja_AQ", "surname-core": "Брюль", "surname-prefix": "фон", "surname2": "Гонсалес Доминго", "title": "проф., д-р"}, "file": "ba", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Брюль, Ада Корнелия Сезар Мартин фон"} {"fields": {"credentials": "м. ф. д.", "generation": "кесе", "given": "Ада Корнелия", "given-informal": "Неле", "given2": "Сезар Мартин", "locale": "ja_AQ", "surname-core": "Брюль", "surname-prefix": "фон", "surname2": "Гонсалес Доминго", "title": "проф., д-р"}, "file": "ba", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "фон Брюль проф., д-р Ада Корнелия Сезар Мартин кесе, м. ф. д."} {"fields": {"given": "Синдбад", "locale": "ja_AQ"}, "file": "ba", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Синдбад"} {"fields": {"given": "Синдбад", "locale": "ja_AQ"}, "file": "ba", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Синдбад"} {"fields": {"given": "Синдбад", "locale": "ja_AQ"}, "file": "ba", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Синдбад"} {"fields": {"given": "Цецилия", "given2": "Хэмиш", "locale": "ja_AQ", "surname": "Штёбер"}, "file": "ba", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Цецилия Хэмиш Штёбер"} {"fields": {"given": "Цецилия", "given2": "Хэмиш", "locale": "ja_AQ", "surname": "Штёбер"}, "file": "ba", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Штёбер, Цецилия Хэмиш"} {"fields": {"given": "Цецилия", "given2": "Хэмиш", "locale": "ja_AQ", "surname": "Штёбер"}, "file": "ba", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Штёбер Цецилия Хэмиш"} {"fields": {"given": "Кете", "locale": "ja_AQ", "surname": "Мюллер"}, "file": "ba", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Кете Мюллер"} {"fields": {"given": "Кете", "locale": "ja_AQ", "surname": "Мюллер"}, "file": "ba", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Мюллер, Кете"} {"fields": {"given": "Кете", "locale": "ja_AQ", "surname": "Мюллер"}, "file": "ba", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Мюллер Кете"} {"fields": {"given": "Азамат", "given2": "Салауат улы", "locale": "ba_AQ", "surname-core": "Фаттахов"}, "file": "ba", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Азамат Салауат улы Фаттахов"} {"fields": {"given": "Азамат", "given2": "Салауат улы", "locale": "ba_AQ", "surname-core": "Фаттахов"}, "file": "ba", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Фаттахов, Азамат Салауат улы"} {"fields": {"given": "Азамат", "given2": "Салауат улы", "locale": "ba_AQ", "surname-core": "Фаттахов"}, "file": "ba", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Фаттахов Азамат Салауат улы"} {"fields": {"given": "Урал", "locale": "ba_AQ"}, "file": "ba", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Урал"} {"fields": {"given": "Урал", "locale": "ba_AQ"}, "file": "ba", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Урал"} {"fields": {"given": "Урал", "locale": "ba_AQ"}, "file": "ba", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Урал"} {"fields": {"given": "Азамат", "given2": "Салауат улы", "locale": "ba_AQ", "surname": "Фаттахов"}, "file": "ba", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Азамат Салауат улы Фаттахов"} {"fields": {"given": "Азамат", "given2": "Салауат улы", "locale": "ba_AQ", "surname": "Фаттахов"}, "file": "ba", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Фаттахов, Азамат Салауат улы"} {"fields": {"given": "Азамат", "given2": "Салауат улы", "locale": "ba_AQ", "surname": "Фаттахов"}, "file": "ba", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Фаттахов Азамат Салауат улы"} {"fields": {"given": "Ирина", "locale": "ba_AQ", "surname": "Яҡупова"}, "file": "ba", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Ирина Яҡупова"} {"fields": {"given": "Ирина", "locale": "ba_AQ", "surname": "Яҡупова"}, "file": "ba", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Яҡупова, Ирина"} {"fields": {"given": "Ирина", "locale": "ba_AQ", "surname": "Яҡупова"}, "file": "ba", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Яҡупова Ирина"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ja_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prop. Dr."}, "file": "bal_Latn", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Prop. Dr. Ada Cornelia César Martín von Brühl González Domingo MD DDS"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ja_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prop. Dr."}, "file": "bal_Latn", "label": "foreignFull", "order": "sorting", "shape": null, "text": "von Brühl González Domingo, Prop. Dr. Ada Cornelia César Martín MD DDS"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ja_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prop. Dr."}, "file": "bal_Latn", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "von Brühl González Domingo Prop. Dr. Ada Cornelia César Martín MD DDS"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "bal_Latn", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "bal_Latn", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "bal_Latn", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stöber"}, "file": "bal_Latn", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zäzilia Hamish Stöber"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stöber"}, "file": "bal_Latn", "label": "foreignGGS", "order": "sorting", "shape": 4, "text": "Stöber Zäzilia Hamish"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stöber"}, "file": "bal_Latn", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Stöber Zäzilia Hamish"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "bal_Latn", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Käthe Müller"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "bal_Latn", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "Müller Käthe"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "bal_Latn", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Müller Käthe"} {"fields": {"generation": "Jr", "given": "Monir Mómen", "given-informal": "Ostád", "given2": "Monir Mómen", "locale": "bal_Latn_AQ", "surname-core": "Balóch", "title": "Wj."}, "file": "bal_Latn", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Wj. Monir Mómen Monir Mómen Balóch"} {"fields": {"generation": "Jr", "given": "Monir Mómen", "given-informal": "Ostád", "given2": "Monir Mómen", "locale": "bal_Latn_AQ", "surname-core": "Balóch", "title": "Wj."}, "file": "bal_Latn", "label": "nativeFull", "order": "sorting", "shape": 4, "text": "Balóch Wj. Monir Mómen Monir Mómen"} {"fields": {"generation": "Jr", "given": "Monir Mómen", "given-informal": "Ostád", "given2": "Monir Mómen", "locale": "bal_Latn_AQ", "surname-core": "Balóch", "title": "Wj."}, "file": "bal_Latn", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Balóch Wj. Monir Mómen Monir Mómen"} {"fields": {"given": "Karima", "locale": "bal_Latn_AQ"}, "file": "bal_Latn", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Karima"} {"fields": {"given": "Karima", "locale": "bal_Latn_AQ"}, "file": "bal_Latn", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Karima"} {"fields": {"given": "Karima", "locale": "bal_Latn_AQ"}, "file": "bal_Latn", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Karima"} {"fields": {"given": "Karima", "given2": "Mehráb", "locale": "bal_Latn_AQ", "surname": "Balóch"}, "file": "bal_Latn", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Karima Mehráb Balóch"} {"fields": {"given": "Karima", "given2": "Mehráb", "locale": "bal_Latn_AQ", "surname": "Balóch"}, "file": "bal_Latn", "label": "nativeGGS", "order": "sorting", "shape": 4, "text": "Balóch Karima Mehráb"} {"fields": {"given": "Karima", "given2": "Mehráb", "locale": "bal_Latn_AQ", "surname": "Balóch"}, "file": "bal_Latn", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Balóch Karima Mehráb"} {"fields": {"given": "Karima", "locale": "bal_Latn_AQ", "surname": "Balóch"}, "file": "bal_Latn", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Karima Balóch"} {"fields": {"given": "Karima", "locale": "bal_Latn_AQ", "surname": "Balóch"}, "file": "bal_Latn", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "Balóch Karima"} {"fields": {"given": "Karima", "locale": "bal_Latn_AQ", "surname": "Balóch"}, "file": "bal_Latn", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Balóch Karima"} {"fields": {"credentials": "д-р", "generation": "мал.", "given": "Ада Карнэлія", "given-informal": "Ніл", "given2": "Сесар Марцін", "locale": "ja_AQ", "surname-core": "Бруль", "surname-prefix": "фон", "surname2": "Гансалес Дамінга", "title": "праф., д-р"}, "file": "be", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Ада Карнэлія Сесар Марцін фон Бруль д-р"} {"fields": {"credentials": "д-р", "generation": "мал.", "given": "Ада Карнэлія", "given-informal": "Ніл", "given2": "Сесар Марцін", "locale": "ja_AQ", "surname-core": "Бруль", "surname-prefix": "фон", "surname2": "Гансалес Дамінга", "title": "праф., д-р"}, "file": "be", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Бруль, Ада Карнэлія Сесар Марцін фон"} {"fields": {"credentials": "д-р", "generation": "мал.", "given": "Ада Карнэлія", "given-informal": "Ніл", "given2": "Сесар Марцін", "locale": "ja_AQ", "surname-core": "Бруль", "surname-prefix": "фон", "surname2": "Гансалес Дамінга", "title": "праф., д-р"}, "file": "be", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "фон Бруль Ада Карнэлія Сесар Марцін д-р"} {"fields": {"given": "Сіндбад", "locale": "ja_AQ"}, "file": "be", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Сіндбад"} {"fields": {"given": "Сіндбад", "locale": "ja_AQ"}, "file": "be", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Сіндбад"} {"fields": {"given": "Сіндбад", "locale": "ja_AQ"}, "file": "be", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Сіндбад"} {"fields": {"given": "Зазілія", "given2": "Хэміш", "locale": "ja_AQ", "surname": "Шцёбер"}, "file": "be", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Зазілія Хэміш Шцёбер"} {"fields": {"given": "Зазілія", "given2": "Хэміш", "locale": "ja_AQ", "surname": "Шцёбер"}, "file": "be", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Шцёбер, Зазілія Хэміш"} {"fields": {"given": "Зазілія", "given2": "Хэміш", "locale": "ja_AQ", "surname": "Шцёбер"}, "file": "be", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Шцёбер Зазілія Хэміш"} {"fields": {"given": "Кетэ", "locale": "ja_AQ", "surname": "Мюлер"}, "file": "be", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Кетэ Мюлер"} {"fields": {"given": "Кетэ", "locale": "ja_AQ", "surname": "Мюлер"}, "file": "be", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Мюлер, Кетэ"} {"fields": {"given": "Кетэ", "locale": "ja_AQ", "surname": "Мюлер"}, "file": "be", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Мюлер Кетэ"} {"fields": {"given": "Мікалай", "given-informal": "Міхась", "given2": "Аляксандр", "locale": "be_AQ", "surname-core": "Кавалёў", "title": "Сп."}, "file": "be", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Мікалай Аляксандр Кавалёў"} {"fields": {"given": "Мікалай", "given-informal": "Міхась", "given2": "Аляксандр", "locale": "be_AQ", "surname-core": "Кавалёў", "title": "Сп."}, "file": "be", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Кавалёў, Мікалай Аляксандр"} {"fields": {"given": "Мікалай", "given-informal": "Міхась", "given2": "Аляксандр", "locale": "be_AQ", "surname-core": "Кавалёў", "title": "Сп."}, "file": "be", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Кавалёў Мікалай Аляксандр"} {"fields": {"given": "Васіль", "locale": "be_AQ"}, "file": "be", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Васіль"} {"fields": {"given": "Васіль", "locale": "be_AQ"}, "file": "be", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Васіль"} {"fields": {"given": "Васіль", "locale": "be_AQ"}, "file": "be", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Васіль"} {"fields": {"given": "Вольга", "given2": "Караліна", "locale": "be_AQ", "surname": "Равенская"}, "file": "be", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Вольга Караліна Равенская"} {"fields": {"given": "Вольга", "given2": "Караліна", "locale": "be_AQ", "surname": "Равенская"}, "file": "be", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Равенская, Вольга Караліна"} {"fields": {"given": "Вольга", "given2": "Караліна", "locale": "be_AQ", "surname": "Равенская"}, "file": "be", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Равенская Вольга Караліна"} {"fields": {"given": "Ірына", "locale": "be_AQ", "surname": "Алейнік"}, "file": "be", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Ірына Алейнік"} {"fields": {"given": "Ірына", "locale": "be_AQ", "surname": "Алейнік"}, "file": "be", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Алейнік, Ірына"} {"fields": {"given": "Ірына", "locale": "be_AQ", "surname": "Алейнік"}, "file": "be", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Алейнік Ірына"} {"fields": {"credentials": "доктор", "generation": "младша", "given": "Ада Корнелия", "given-informal": "Ади", "given2": "Цезар Мартин", "locale": "ja_AQ", "surname-core": "Брюл", "surname-prefix": "фон", "surname2": "Гонзалес Доминго", "title": "проф. д-р"}, "file": "bg", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "проф. д-р Ада Корнелия Цезар Мартин Брюл, доктор"} {"fields": {"credentials": "доктор", "generation": "младша", "given": "Ада Корнелия", "given-informal": "Ади", "given2": "Цезар Мартин", "locale": "ja_AQ", "surname-core": "Брюл", "surname-prefix": "фон", "surname2": "Гонзалес Доминго", "title": "проф. д-р"}, "file": "bg", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Брюл, Ада Корнелия Цезар Мартин фон"} {"fields": {"credentials": "доктор", "generation": "младша", "given": "Ада Корнелия", "given-informal": "Ади", "given2": "Цезар Мартин", "locale": "ja_AQ", "surname-core": "Брюл", "surname-prefix": "фон", "surname2": "Гонзалес Доминго", "title": "проф. д-р"}, "file": "bg", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "проф. д-р Брюл, Ада Корнелия Цезар Мартин, доктор"} {"fields": {"given": "Джон", "locale": "ja_AQ"}, "file": "bg", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Джон"} {"fields": {"given": "Джон", "locale": "ja_AQ"}, "file": "bg", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Джон"} {"fields": {"given": "Джон", "locale": "ja_AQ"}, "file": "bg", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Джон"} {"fields": {"given": "Зара", "given2": "Мери", "locale": "ja_AQ", "surname": "Джоунс"}, "file": "bg", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Зара Мери Джоунс"} {"fields": {"given": "Зара", "given2": "Мери", "locale": "ja_AQ", "surname": "Джоунс"}, "file": "bg", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Джоунс, Зара Мери"} {"fields": {"given": "Зара", "given2": "Мери", "locale": "ja_AQ", "surname": "Джоунс"}, "file": "bg", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Джоунс, Зара Мери"} {"fields": {"given": "Кати", "locale": "ja_AQ", "surname": "Мюлер"}, "file": "bg", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Кати Мюлер"} {"fields": {"given": "Кати", "locale": "ja_AQ", "surname": "Мюлер"}, "file": "bg", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Мюлер, Кати"} {"fields": {"given": "Кати", "locale": "ja_AQ", "surname": "Мюлер"}, "file": "bg", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Мюлер, Кати"} {"fields": {"credentials": "проф. д-р", "generation": "младши", "given": "Иван", "given-informal": "Ванко", "given2": "Асен", "locale": "bg_AQ", "surname-core": "Вернер", "surname-prefix": "фон", "surname2": "Иванов", "title": "г-н"}, "file": "bg", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "г-н Иван Асен Вернер, проф. д-р"} {"fields": {"credentials": "проф. д-р", "generation": "младши", "given": "Иван", "given-informal": "Ванко", "given2": "Асен", "locale": "bg_AQ", "surname-core": "Вернер", "surname-prefix": "фон", "surname2": "Иванов", "title": "г-н"}, "file": "bg", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Вернер, Иван Асен фон"} {"fields": {"credentials": "проф. д-р", "generation": "младши", "given": "Иван", "given-informal": "Ванко", "given2": "Асен", "locale": "bg_AQ", "surname-core": "Вернер", "surname-prefix": "фон", "surname2": "Иванов", "title": "г-н"}, "file": "bg", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "г-н Вернер, Иван Асен, проф. д-р"} {"fields": {"given": "Иван", "locale": "bg_AQ"}, "file": "bg", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Иван"} {"fields": {"given": "Иван", "locale": "bg_AQ"}, "file": "bg", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Иван"} {"fields": {"given": "Иван", "locale": "bg_AQ"}, "file": "bg", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Иван"} {"fields": {"given": "Ана", "given2": "Иванова", "locale": "bg_AQ", "surname": "Димитрова"}, "file": "bg", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Ана Иванова Димитрова"} {"fields": {"given": "Ана", "given2": "Иванова", "locale": "bg_AQ", "surname": "Димитрова"}, "file": "bg", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Димитрова, Ана Иванова"} {"fields": {"given": "Ана", "given2": "Иванова", "locale": "bg_AQ", "surname": "Димитрова"}, "file": "bg", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Димитрова, Ана Иванова"} {"fields": {"given": "Мария", "locale": "bg_AQ", "surname": "Петрова"}, "file": "bg", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Мария Петрова"} {"fields": {"given": "Мария", "locale": "bg_AQ", "surname": "Петрова"}, "file": "bg", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Петрова, Мария"} {"fields": {"given": "Мария", "locale": "bg_AQ", "surname": "Петрова"}, "file": "bg", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Петрова, Мария"} {"fields": {"credentials": "MƉ", "generation": "Jr", "given": "Samʊwɛl", "given-informal": "Sam", "given2": "Fostɛɛŋ", "locale": "ja_AQ", "surname-core": "ƉAM", "surname-prefix": "fan", "surname2": "ƉOMIŊGO", "title": "Prof. Ɖr."}, "file": "blo", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Prof. Ɖr. MƉ Samʊwɛl fan ƉAM Fostɛɛŋ"} {"fields": {"credentials": "MƉ", "generation": "Jr", "given": "Samʊwɛl", "given-informal": "Sam", "given2": "Fostɛɛŋ", "locale": "ja_AQ", "surname-core": "ƉAM", "surname-prefix": "fan", "surname2": "ƉOMIŊGO", "title": "Prof. Ɖr."}, "file": "blo", "label": "foreignFull", "order": "sorting", "shape": 4, "text": "fan ƉAM Fostɛɛŋ Samʊwɛl"} {"fields": {"credentials": "MƉ", "generation": "Jr", "given": "Samʊwɛl", "given-informal": "Sam", "given2": "Fostɛɛŋ", "locale": "ja_AQ", "surname-core": "ƉAM", "surname-prefix": "fan", "surname2": "ƉOMIŊGO", "title": "Prof. Ɖr."}, "file": "blo", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "Prof. Ɖr. MƉ fan ƉAM ƉOMIŊGO Fostɛɛŋ Samʊwɛl"} {"fields": {"given": "Eemil", "locale": "ja_AQ"}, "file": "blo", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Eemil"} {"fields": {"given": "Eemil", "locale": "ja_AQ"}, "file": "blo", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Eemil"} {"fields": {"given": "Eemil", "locale": "ja_AQ"}, "file": "blo", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Eemil"} {"fields": {"given": "Alfɔns", "given2": "Rolanɖ", "locale": "ja_AQ", "surname": "MANƉELA"}, "file": "blo", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Alfɔns MANƉELA Rolanɖ"} {"fields": {"given": "Alfɔns", "given2": "Rolanɖ", "locale": "ja_AQ", "surname": "MANƉELA"}, "file": "blo", "label": "foreignGGS", "order": "sorting", "shape": 4, "text": "MANƉELA Rolanɖ Alfɔns"} {"fields": {"given": "Alfɔns", "given2": "Rolanɖ", "locale": "ja_AQ", "surname": "MANƉELA"}, "file": "blo", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "MANƉELA Rolanɖ Alfɔns"} {"fields": {"given": "Iiɖa", "locale": "ja_AQ", "surname": "KANSULO"}, "file": "blo", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Iiɖa KANSULO"} {"fields": {"given": "Iiɖa", "locale": "ja_AQ", "surname": "KANSULO"}, "file": "blo", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "KANSULO Iiɖa"} {"fields": {"given": "Iiɖa", "locale": "ja_AQ", "surname": "KANSULO"}, "file": "blo", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "KANSULO Iiɖa"} {"fields": {"credentials": "Alaaji", "generation": "ajala", "given": "Mustafa", "given-informal": "Tafa", "given2": "Alɛ", "locale": "blo_AQ", "surname-core": "KALAM", "title": "ʊŋono"}, "file": "blo", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "ʊŋono Alaaji Mustafa KALAM Alɛ"} {"fields": {"credentials": "Alaaji", "generation": "ajala", "given": "Mustafa", "given-informal": "Tafa", "given2": "Alɛ", "locale": "blo_AQ", "surname-core": "KALAM", "title": "ʊŋono"}, "file": "blo", "label": "nativeFull", "order": "sorting", "shape": 4, "text": "KALAM Alɛ Mustafa"} {"fields": {"credentials": "Alaaji", "generation": "ajala", "given": "Mustafa", "given-informal": "Tafa", "given2": "Alɛ", "locale": "blo_AQ", "surname-core": "KALAM", "title": "ʊŋono"}, "file": "blo", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "ʊŋono Alaaji KALAM Alɛ Mustafa"} {"fields": {"given": "Muniiru", "locale": "blo_AQ"}, "file": "blo", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Muniiru"} {"fields": {"given": "Muniiru", "locale": "blo_AQ"}, "file": "blo", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Muniiru"} {"fields": {"given": "Muniiru", "locale": "blo_AQ"}, "file": "blo", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Muniiru"} {"fields": {"given": "Abɖu-Tɔyib", "given2": "MUSA", "locale": "blo_AQ", "surname": "GOMINA"}, "file": "blo", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Abɖu-Tɔyib GOMINA MUSA"} {"fields": {"given": "Abɖu-Tɔyib", "given2": "MUSA", "locale": "blo_AQ", "surname": "GOMINA"}, "file": "blo", "label": "nativeGGS", "order": "sorting", "shape": 4, "text": "GOMINA MUSA Abɖu-Tɔyib"} {"fields": {"given": "Abɖu-Tɔyib", "given2": "MUSA", "locale": "blo_AQ", "surname": "GOMINA"}, "file": "blo", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "GOMINA MUSA Abɖu-Tɔyib"} {"fields": {"given": "Fuseena", "locale": "blo_AQ", "surname": "AKIME"}, "file": "blo", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Fuseena AKIME"} {"fields": {"given": "Fuseena", "locale": "blo_AQ", "surname": "AKIME"}, "file": "blo", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "AKIME Fuseena"} {"fields": {"given": "Fuseena", "locale": "blo_AQ", "surname": "AKIME"}, "file": "blo", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "AKIME Fuseena"} {"fields": {"credentials": "এম.ডি.পিএইচ.ডি.", "generation": "জুনিয়র", "given": "আদিরা", "given-informal": "নীলা", "given2": "ইভা সোফিয়া", "locale": "ko_AQ", "surname-core": "আলি", "surname-prefix": "রহমান", "surname2": "তালুকদার", "title": "প্রঃ ডঃ"}, "file": "bn", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "প্রঃ ডঃ আদিরা ইভা সোফিয়া রহমান আলি তালুকদার এম.ডি.পিএইচ.ডি."} {"fields": {"credentials": "এম.ডি.পিএইচ.ডি.", "generation": "জুনিয়র", "given": "আদিরা", "given-informal": "নীলা", "given2": "ইভা সোফিয়া", "locale": "ko_AQ", "surname-core": "আলি", "surname-prefix": "রহমান", "surname2": "তালুকদার", "title": "প্রঃ ডঃ"}, "file": "bn", "label": "foreignFull", "order": "sorting", "shape": null, "text": "রহমান আলি তালুকদার, প্রঃ ডঃ আদিরা ইভা সোফিয়া এম.ডি.পিএইচ.ডি."} {"fields": {"credentials": "এম.ডি.পিএইচ.ডি.", "generation": "জুনিয়র", "given": "আদিরা", "given-informal": "নীলা", "given2": "ইভা সোফিয়া", "locale": "ko_AQ", "surname-core": "আলি", "surname-prefix": "রহমান", "surname2": "তালুকদার", "title": "প্রঃ ডঃ"}, "file": "bn", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "রহমান আলি তালুকদার প্রঃ ডঃ আদিরা ইভা সোফিয়া এম.ডি.পিএইচ.ডি."} {"fields": {"given": "সিনবাদ", "locale": "ko_AQ"}, "file": "bn", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "সিনবাদ"} {"fields": {"given": "সিনবাদ", "locale": "ko_AQ"}, "file": "bn", "label": "foreignG", "order": "sorting", "shape": 4, "text": "সিনবাদ"} {"fields": {"given": "সিনবাদ", "locale": "ko_AQ"}, "file": "bn", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "সিনবাদ"} {"fields": {"given": "জাজিলিয়া", "given2": "হামিশ", "locale": "ko_AQ", "surname": "মুন্সি"}, "file": "bn", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "জাজিলিয়া হামিশ মুন্সি"} {"fields": {"given": "জাজিলিয়া", "given2": "হামিশ", "locale": "ko_AQ", "surname": "মুন্সি"}, "file": "bn", "label": "foreignGGS", "order": "sorting", "shape": 4, "text": "মুন্সি জাজিলিয়া হামিশ"} {"fields": {"given": "জাজিলিয়া", "given2": "হামিশ", "locale": "ko_AQ", "surname": "মুন্সি"}, "file": "bn", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "মুন্সি জাজিলিয়া হামিশ"} {"fields": {"given": "কিরণ", "locale": "ko_AQ", "surname": "আহমেদ"}, "file": "bn", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "কিরণ আহমেদ"} {"fields": {"given": "কিরণ", "locale": "ko_AQ", "surname": "আহমেদ"}, "file": "bn", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "আহমেদ কিরণ"} {"fields": {"given": "কিরণ", "locale": "ko_AQ", "surname": "আহমেদ"}, "file": "bn", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "আহমেদ কিরণ"} {"fields": {"credentials": "এমপি", "generation": "জুনিয়র", "given": "এমদাদ আলি", "given-informal": "বরুন", "given2": "অনিল কুমার", "locale": "bn_AQ", "surname-core": "গাঙ্গুলী", "title": "জনাব"}, "file": "bn", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "জনাব এমদাদ আলি অনিল কুমার গাঙ্গুলী এমপি"} {"fields": {"credentials": "এমপি", "generation": "জুনিয়র", "given": "এমদাদ আলি", "given-informal": "বরুন", "given2": "অনিল কুমার", "locale": "bn_AQ", "surname-core": "গাঙ্গুলী", "title": "জনাব"}, "file": "bn", "label": "nativeFull", "order": "sorting", "shape": 4, "text": "গাঙ্গুলী জনাব এমদাদ আলি অনিল কুমার এমপি"} {"fields": {"credentials": "এমপি", "generation": "জুনিয়র", "given": "এমদাদ আলি", "given-informal": "বরুন", "given2": "অনিল কুমার", "locale": "bn_AQ", "surname-core": "গাঙ্গুলী", "title": "জনাব"}, "file": "bn", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "গাঙ্গুলী জনাব এমদাদ আলি অনিল কুমার এমপি"} {"fields": {"given": "জেনদায়া", "locale": "bn_AQ"}, "file": "bn", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "জেনদায়া"} {"fields": {"given": "জেনদায়া", "locale": "bn_AQ"}, "file": "bn", "label": "nativeG", "order": "sorting", "shape": 4, "text": "জেনদায়া"} {"fields": {"given": "জেনদায়া", "locale": "bn_AQ"}, "file": "bn", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "জেনদায়া"} {"fields": {"given": "মেরি সু", "given2": "হামিশ", "locale": "bn_AQ", "surname": "রহমান"}, "file": "bn", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "মেরি সু হামিশ রহমান"} {"fields": {"given": "মেরি সু", "given2": "হামিশ", "locale": "bn_AQ", "surname": "রহমান"}, "file": "bn", "label": "nativeGGS", "order": "sorting", "shape": 4, "text": "রহমান মেরি সু হামিশ"} {"fields": {"given": "মেরি সু", "given2": "হামিশ", "locale": "bn_AQ", "surname": "রহমান"}, "file": "bn", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "রহমান মেরি সু হামিশ"} {"fields": {"given": "শ্রেয়া", "locale": "bn_AQ", "surname": "চৌধুরী"}, "file": "bn", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "শ্রেয়া চৌধুরী"} {"fields": {"given": "শ্রেয়া", "locale": "bn_AQ", "surname": "চৌধুরী"}, "file": "bn", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "চৌধুরী শ্রেয়া"} {"fields": {"given": "শ্রেয়া", "locale": "bn_AQ", "surname": "চৌধুরী"}, "file": "bn", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "চৌধুরী শ্রেয়া"} {"fields": {"credentials": "dipl. stom.", "generation": "ml.", "given": "Izeta", "given-informal": "Beba", "given2": "Amra", "locale": "ko_AQ", "surname-core": "Selimović", "surname-prefix": "von", "surname2": "Hodžić", "title": "prof. dr."}, "file": "bs", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "prof. dr. Izeta Amra von Selimović Hodžić, dipl. stom."} {"fields": {"credentials": "dipl. stom.", "generation": "ml.", "given": "Izeta", "given-informal": "Beba", "given2": "Amra", "locale": "ko_AQ", "surname-core": "Selimović", "surname-prefix": "von", "surname2": "Hodžić", "title": "prof. dr."}, "file": "bs", "label": "foreignFull", "order": "sorting", "shape": null, "text": "prof. dr. von Selimović Hodžić, Izeta Amra, dipl. stom."} {"fields": {"credentials": "dipl. stom.", "generation": "ml.", "given": "Izeta", "given-informal": "Beba", "given2": "Amra", "locale": "ko_AQ", "surname-core": "Selimović", "surname-prefix": "von", "surname2": "Hodžić", "title": "prof. dr."}, "file": "bs", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "prof. dr. von Selimović Hodžić, Izeta Amra, dipl. stom."} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "bs", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "bs", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "bs", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "bs", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zäzilia Hamish Stöber"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "bs", "label": "foreignGGS", "order": "sorting", "shape": 4, "text": "Stöber Zäzilia Hamish"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "bs", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Stöber Zäzilia Hamish"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "bs", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Käthe Müller"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "bs", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "Müller Käthe"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "bs", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Müller Käthe"} {"fields": {"credentials": "zastupnik u parlamentu", "generation": "ml.", "given": "Adnan", "given-informal": "Ado", "given2": "Mirza", "locale": "bs_AQ", "surname-core": "Hodžić", "title": "g."}, "file": "bs", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "g. Adnan Mirza Hodžić zastupnik u parlamentu"} {"fields": {"credentials": "zastupnik u parlamentu", "generation": "ml.", "given": "Adnan", "given-informal": "Ado", "given2": "Mirza", "locale": "bs_AQ", "surname-core": "Hodžić", "title": "g."}, "file": "bs", "label": "nativeFull", "order": "sorting", "shape": null, "text": "g. Hodžić Adnan Mirza, zastupnik u parlamentu"} {"fields": {"credentials": "zastupnik u parlamentu", "generation": "ml.", "given": "Adnan", "given-informal": "Ado", "given2": "Mirza", "locale": "bs_AQ", "surname-core": "Hodžić", "title": "g."}, "file": "bs", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "g. Hodžić Adnan Mirza, zastupnik u parlamentu"} {"fields": {"given": "Sinbad", "locale": "bs_AQ"}, "file": "bs", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "bs_AQ"}, "file": "bs", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "bs_AQ"}, "file": "bs", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Silvije", "given2": "Strahimir", "locale": "bs_AQ", "surname": "Kranjčević"}, "file": "bs", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Silvije Strahimir Kranjčević"} {"fields": {"given": "Silvije", "given2": "Strahimir", "locale": "bs_AQ", "surname": "Kranjčević"}, "file": "bs", "label": "nativeGGS", "order": "sorting", "shape": 4, "text": "Kranjčević Silvije Strahimir"} {"fields": {"given": "Silvije", "given2": "Strahimir", "locale": "bs_AQ", "surname": "Kranjčević"}, "file": "bs", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Kranjčević Silvije Strahimir"} {"fields": {"given": "Razija", "locale": "bs_AQ", "surname": "Mujanović"}, "file": "bs", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Razija Mujanović"} {"fields": {"given": "Razija", "locale": "bs_AQ", "surname": "Mujanović"}, "file": "bs", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "Mujanović Razija"} {"fields": {"given": "Razija", "locale": "bs_AQ", "surname": "Mujanović"}, "file": "bs", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Mujanović Razija"} {"fields": {"credentials": "Excma.", "generation": "II", "given": "Maria da Graça", "given-informal": "Lise", "given2": "César Martín", "locale": "ko_AQ", "surname-core": "Dalen", "surname-prefix": "van", "surname2": "Hoogeveen", "title": "Dra."}, "file": "ca", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Excma. Dra. Maria da Graça César Martín van Dalen Hoogeveen"} {"fields": {"credentials": "Excma.", "generation": "II", "given": "Maria da Graça", "given-informal": "Lise", "given2": "César Martín", "locale": "ko_AQ", "surname-core": "Dalen", "surname-prefix": "van", "surname2": "Hoogeveen", "title": "Dra."}, "file": "ca", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Van Dalen Hoogeveen, Maria da Graça César Martín"} {"fields": {"credentials": "Excma.", "generation": "II", "given": "Maria da Graça", "given-informal": "Lise", "given2": "César Martín", "locale": "ko_AQ", "surname-core": "Dalen", "surname-prefix": "van", "surname2": "Hoogeveen", "title": "Dra."}, "file": "ca", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "Excma. Dra. Van Dalen Hoogeveen II, Maria da Graça César Martín"} {"fields": {"given": "Iñaki", "locale": "ko_AQ"}, "file": "ca", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Iñaki"} {"fields": {"given": "Iñaki", "locale": "ko_AQ"}, "file": "ca", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Iñaki"} {"fields": {"given": "Iñaki", "locale": "ko_AQ"}, "file": "ca", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Iñaki"} {"fields": {"given": "Adrian", "given2": "Dragos", "locale": "ko_AQ", "surname": "Ionescu"}, "file": "ca", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Adrian Dragos Ionescu"} {"fields": {"given": "Adrian", "given2": "Dragos", "locale": "ko_AQ", "surname": "Ionescu"}, "file": "ca", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Ionescu, Adrian Dragos"} {"fields": {"given": "Adrian", "given2": "Dragos", "locale": "ko_AQ", "surname": "Ionescu"}, "file": "ca", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Ionescu Adrian Dragos"} {"fields": {"given": "Jacqueline", "locale": "ko_AQ", "surname": "Beauchêne"}, "file": "ca", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Jacqueline Beauchêne"} {"fields": {"given": "Jacqueline", "locale": "ko_AQ", "surname": "Beauchêne"}, "file": "ca", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Beauchêne, Jacqueline"} {"fields": {"given": "Jacqueline", "locale": "ko_AQ", "surname": "Beauchêne"}, "file": "ca", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Beauchêne Jacqueline"} {"fields": {"credentials": "Excm.", "generation": "II", "given": "Josep Antoni", "given-informal": "Pep", "given2": "Carles Joan", "locale": "ca_AQ", "surname-core": "Lloret", "surname2": "Palol", "title": "Sr."}, "file": "ca", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Excm. Sr. Josep Antoni Carles Joan Lloret Palol"} {"fields": {"credentials": "Excm.", "generation": "II", "given": "Josep Antoni", "given-informal": "Pep", "given2": "Carles Joan", "locale": "ca_AQ", "surname-core": "Lloret", "surname2": "Palol", "title": "Sr."}, "file": "ca", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Lloret Palol, Josep Antoni Carles Joan"} {"fields": {"credentials": "Excm.", "generation": "II", "given": "Josep Antoni", "given-informal": "Pep", "given2": "Carles Joan", "locale": "ca_AQ", "surname-core": "Lloret", "surname2": "Palol", "title": "Sr."}, "file": "ca", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Excm. Sr. Lloret Palol II, Josep Antoni Carles Joan"} {"fields": {"given": "Carolina", "locale": "ca_AQ"}, "file": "ca", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Carolina"} {"fields": {"given": "Carolina", "locale": "ca_AQ"}, "file": "ca", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Carolina"} {"fields": {"given": "Carolina", "locale": "ca_AQ"}, "file": "ca", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Carolina"} {"fields": {"given": "Rosa Maria", "given2": "Ferran", "locale": "ca_AQ", "surname": "Ferrer"}, "file": "ca", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Rosa Maria Ferran Ferrer"} {"fields": {"given": "Rosa Maria", "given2": "Ferran", "locale": "ca_AQ", "surname": "Ferrer"}, "file": "ca", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Ferrer, Rosa Maria Ferran"} {"fields": {"given": "Rosa Maria", "given2": "Ferran", "locale": "ca_AQ", "surname": "Ferrer"}, "file": "ca", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Ferrer Rosa Maria Ferran"} {"fields": {"given": "Gal·la", "locale": "ca_AQ", "surname": "Roig"}, "file": "ca", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Gal·la Roig"} {"fields": {"given": "Gal·la", "locale": "ca_AQ", "surname": "Roig"}, "file": "ca", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Roig, Gal·la"} {"fields": {"given": "Gal·la", "locale": "ca_AQ", "surname": "Roig"}, "file": "ca", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Roig Gal·la"} {"fields": {"credentials": "ᎷᎠ ᎠᎠᏚ", "generation": "ᏔᎵᏁ", "given": "ᎠᏓ ᎪᏁᎵᎠ", "given-informal": "ᏁᎴ", "given2": "ᏏᏌ ᎹᏘᏂ", "locale": "ja_AQ", "surname-core": "ᏇᎯ", "surname-prefix": "ᏉᏂ", "title": "Ꮙ. ᏅᏬᏘ"}, "file": "chr", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Ꮙ. ᏅᏬᏘ ᎠᏓ ᎪᏁᎵᎠ ᏏᏌ ᎹᏘᏂ ᏉᏂ ᏇᎯ ᏔᎵᏁ, ᎷᎠ ᎠᎠᏚ"} {"fields": {"credentials": "ᎷᎠ ᎠᎠᏚ", "generation": "ᏔᎵᏁ", "given": "ᎠᏓ ᎪᏁᎵᎠ", "given-informal": "ᏁᎴ", "given2": "ᏏᏌ ᎹᏘᏂ", "locale": "ja_AQ", "surname-core": "ᏇᎯ", "surname-prefix": "ᏉᏂ", "title": "Ꮙ. ᏅᏬᏘ"}, "file": "chr", "label": "foreignFull", "order": "sorting", "shape": null, "text": "ᏇᎯ, ᎠᏓ ᎪᏁᎵᎠ ᏏᏌ ᎹᏘᏂ ᏉᏂ"} {"fields": {"credentials": "ᎷᎠ ᎠᎠᏚ", "generation": "ᏔᎵᏁ", "given": "ᎠᏓ ᎪᏁᎵᎠ", "given-informal": "ᏁᎴ", "given2": "ᏏᏌ ᎹᏘᏂ", "locale": "ja_AQ", "surname-core": "ᏇᎯ", "surname-prefix": "ᏉᏂ", "title": "Ꮙ. ᏅᏬᏘ"}, "file": "chr", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "ᏉᏂ ᏇᎯ Ꮙ. ᏅᏬᏘ ᎠᏓ ᎪᏁᎵᎠ ᏏᏌ ᎹᏘᏂ ᏔᎵᏁ, ᎷᎠ ᎠᎠᏚ"} {"fields": {"given": "ᏏᎾᏆᏗ", "locale": "ja_AQ"}, "file": "chr", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "ᏏᎾᏆᏗ"} {"fields": {"given": "ᏏᎾᏆᏗ", "locale": "ja_AQ"}, "file": "chr", "label": "foreignG", "order": "sorting", "shape": 4, "text": "ᏏᎾᏆᏗ"} {"fields": {"given": "ᏏᎾᏆᏗ", "locale": "ja_AQ"}, "file": "chr", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "ᏏᎾᏆᏗ"} {"fields": {"given": "ᏆᏏᎵ", "given2": "ᎭᎻᏍᎯ", "locale": "ja_AQ", "surname": "ᏍᏙᏇᎢ"}, "file": "chr", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "ᏆᏏᎵ ᎭᎻᏍᎯ ᏍᏙᏇᎢ"} {"fields": {"given": "ᏆᏏᎵ", "given2": "ᎭᎻᏍᎯ", "locale": "ja_AQ", "surname": "ᏍᏙᏇᎢ"}, "file": "chr", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "ᏍᏙᏇᎢ, ᏆᏏᎵ ᎭᎻᏍᎯ"} {"fields": {"given": "ᏆᏏᎵ", "given2": "ᎭᎻᏍᎯ", "locale": "ja_AQ", "surname": "ᏍᏙᏇᎢ"}, "file": "chr", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "ᏍᏙᏇᎢ ᏆᏏᎵ ᎭᎻᏍᎯ"} {"fields": {"given": "ᎧᏖ", "locale": "ja_AQ", "surname": "ᎽᎴ"}, "file": "chr", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "ᎧᏖ ᎽᎴ"} {"fields": {"given": "ᎧᏖ", "locale": "ja_AQ", "surname": "ᎽᎴ"}, "file": "chr", "label": "foreignGS", "order": "sorting", "shape": null, "text": "ᎽᎴ, ᎧᏖ"} {"fields": {"given": "ᎧᏖ", "locale": "ja_AQ", "surname": "ᎽᎴ"}, "file": "chr", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "ᎽᎴ ᎧᏖ"} {"fields": {"credentials": "ᎷᏢ", "generation": "ᏔᎵᏁ", "given": "ᏇᏔᎻ ᏫᏇᏉᏏ", "given-informal": "ᏇᏘ", "given2": "ᎮᏂᏇ ᏆᏇᏘ", "locale": "chr_AQ", "surname-core": "ᏬᏍᏖᎢ", "title": "ᎻᏍᏖᎢ"}, "file": "chr", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "ᎻᏍᏖᎢ ᏇᏔᎻ ᏫᏇᏉᏏ ᎮᏂᏇ ᏆᏇᏘ ᏬᏍᏖᎢ ᏔᎵᏁ, ᎷᏢ"} {"fields": {"credentials": "ᎷᏢ", "generation": "ᏔᎵᏁ", "given": "ᏇᏔᎻ ᏫᏇᏉᏏ", "given-informal": "ᏇᏘ", "given2": "ᎮᏂᏇ ᏆᏇᏘ", "locale": "chr_AQ", "surname-core": "ᏬᏍᏖᎢ", "title": "ᎻᏍᏖᎢ"}, "file": "chr", "label": "nativeFull", "order": "sorting", "shape": null, "text": "ᏬᏍᏖᎢ, ᏇᏔᎻ ᏫᏇᏉᏏ ᎮᏂᏇ ᏆᏇᏘ"} {"fields": {"credentials": "ᎷᏢ", "generation": "ᏔᎵᏁ", "given": "ᏇᏔᎻ ᏫᏇᏉᏏ", "given-informal": "ᏇᏘ", "given2": "ᎮᏂᏇ ᏆᏇᏘ", "locale": "chr_AQ", "surname-core": "ᏬᏍᏖᎢ", "title": "ᎻᏍᏖᎢ"}, "file": "chr", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "ᏬᏍᏖᎢ ᎻᏍᏖᎢ ᏇᏔᎻ ᏫᏇᏉᏏ ᎮᏂᏇ ᏆᏇᏘ ᏔᎵᏁ, ᎷᏢ"} {"fields": {"given": "ᎨᏓᏯ", "locale": "chr_AQ"}, "file": "chr", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "ᎨᏓᏯ"} {"fields": {"given": "ᎨᏓᏯ", "locale": "chr_AQ"}, "file": "chr", "label": "nativeG", "order": "sorting", "shape": 4, "text": "ᎨᏓᏯ"} {"fields": {"given": "ᎨᏓᏯ", "locale": "chr_AQ"}, "file": "chr", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "ᎨᏓᏯ"} {"fields": {"given": "ᎺᎵ ᏑᎢ", "given2": "ᎭᎻᏏ", "locale": "chr_AQ", "surname": "ᏩᏔᏒᎢ"}, "file": "chr", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "ᎺᎵ ᏑᎢ ᎭᎻᏏ ᏩᏔᏒᎢ"} {"fields": {"given": "ᎺᎵ ᏑᎢ", "given2": "ᎭᎻᏏ", "locale": "chr_AQ", "surname": "ᏩᏔᏒᎢ"}, "file": "chr", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "ᏩᏔᏒᎢ, ᎺᎵ ᏑᎢ ᎭᎻᏏ"} {"fields": {"given": "ᎺᎵ ᏑᎢ", "given2": "ᎭᎻᏏ", "locale": "chr_AQ", "surname": "ᏩᏔᏒᎢ"}, "file": "chr", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "ᏩᏔᏒᎢ ᎺᎵ ᏑᎢ ᎭᎻᏏ"} {"fields": {"given": "ᎠᏪᏁ", "locale": "chr_AQ", "surname": "ᎠᏕᎸ"}, "file": "chr", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "ᎠᏪᏁ ᎠᏕᎸ"} {"fields": {"given": "ᎠᏪᏁ", "locale": "chr_AQ", "surname": "ᎠᏕᎸ"}, "file": "chr", "label": "nativeGS", "order": "sorting", "shape": null, "text": "ᎠᏕᎸ, ᎠᏪᏁ"} {"fields": {"given": "ᎠᏪᏁ", "locale": "chr_AQ", "surname": "ᎠᏕᎸ"}, "file": "chr", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "ᎠᏕᎸ ᎠᏪᏁ"} {"fields": {"credentials": "M.D. DDS.", "generation": "Jr.", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "cs", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Prof. Dr. Ada Cornelia César Martín von Brühl González Domingo, M.D. DDS."} {"fields": {"credentials": "M.D. DDS.", "generation": "Jr.", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "cs", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Brühl, Ada Cornelia César Martín von (Prof. Dr., M.D. DDS.)"} {"fields": {"credentials": "M.D. DDS.", "generation": "Jr.", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "cs", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "Prof. Dr. von Brühl González Domingo Ada Cornelia César Martín, M.D. DDS."} {"fields": {"given": "Jack", "locale": "ko_AQ"}, "file": "cs", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Jack"} {"fields": {"given": "Jack", "locale": "ko_AQ"}, "file": "cs", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Jack"} {"fields": {"given": "Jack", "locale": "ko_AQ"}, "file": "cs", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Jack"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "cs", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zäzilia Hamish Stöber"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "cs", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Stöber, Zäzilia Hamish"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "cs", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Stöber Zäzilia Hamish"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "cs", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Käthe Müller"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "cs", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Müller, Käthe"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "cs", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Müller Käthe"} {"fields": {"credentials": "Ph.D.", "generation": "st.", "given": "Alexandra", "given-informal": "Saša", "given2": "Zuzana", "locale": "cs_AQ", "surname-core": "Machová", "surname2": "Ondřejová", "title": "paní"}, "file": "cs", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "paní Alexandra Zuzana Machová Ondřejová, Ph.D."} {"fields": {"credentials": "Ph.D.", "generation": "st.", "given": "Alexandra", "given-informal": "Saša", "given2": "Zuzana", "locale": "cs_AQ", "surname-core": "Machová", "surname2": "Ondřejová", "title": "paní"}, "file": "cs", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Machová, Alexandra Zuzana (paní, Ph.D.)"} {"fields": {"credentials": "Ph.D.", "generation": "st.", "given": "Alexandra", "given-informal": "Saša", "given2": "Zuzana", "locale": "cs_AQ", "surname-core": "Machová", "surname2": "Ondřejová", "title": "paní"}, "file": "cs", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "paní Machová Ondřejová Alexandra Zuzana, Ph.D."} {"fields": {"given": "Jiří", "locale": "cs_AQ"}, "file": "cs", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Jiří"} {"fields": {"given": "Jiří", "locale": "cs_AQ"}, "file": "cs", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Jiří"} {"fields": {"given": "Jiří", "locale": "cs_AQ"}, "file": "cs", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Jiří"} {"fields": {"given": "Josef", "given2": "Václav", "locale": "cs_AQ", "surname": "Svoboda"}, "file": "cs", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Josef Václav Svoboda"} {"fields": {"given": "Josef", "given2": "Václav", "locale": "cs_AQ", "surname": "Svoboda"}, "file": "cs", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Svoboda, Josef Václav"} {"fields": {"given": "Josef", "given2": "Václav", "locale": "cs_AQ", "surname": "Svoboda"}, "file": "cs", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Svoboda Josef Václav"} {"fields": {"given": "Jana", "locale": "cs_AQ", "surname": "Nováková"}, "file": "cs", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Jana Nováková"} {"fields": {"given": "Jana", "locale": "cs_AQ", "surname": "Nováková"}, "file": "cs", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Nováková, Jana"} {"fields": {"given": "Jana", "locale": "cs_AQ", "surname": "Nováková"}, "file": "cs", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Nováková Jana"} {"fields": {"credentials": "к.м.н.", "generation": "мл.", "given": "Ада Корнелия", "given-informal": "Неле", "given2": "Сезар Мартин", "locale": "ja_AQ", "surname-core": "Брюль", "surname-prefix": "фон", "surname2": "Гонсалес Доминго", "title": "проф., д-р"}, "file": "cv", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "проф., д-р Ада Корнелия Сезар Мартин фон Брюль — мл., к.м.н."} {"fields": {"credentials": "к.м.н.", "generation": "мл.", "given": "Ада Корнелия", "given-informal": "Неле", "given2": "Сезар Мартин", "locale": "ja_AQ", "surname-core": "Брюль", "surname-prefix": "фон", "surname2": "Гонсалес Доминго", "title": "проф., д-р"}, "file": "cv", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Брюль, Ада Корнелия Сезар Мартин фон"} {"fields": {"credentials": "к.м.н.", "generation": "мл.", "given": "Ада Корнелия", "given-informal": "Неле", "given2": "Сезар Мартин", "locale": "ja_AQ", "surname-core": "Брюль", "surname-prefix": "фон", "surname2": "Гонсалес Доминго", "title": "проф., д-р"}, "file": "cv", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "фон Брюль проф., д-р Ада Корнелия Сезар Мартин к.м.н."} {"fields": {"given": "Синдбад", "locale": "ja_AQ"}, "file": "cv", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Синдбад"} {"fields": {"given": "Синдбад", "locale": "ja_AQ"}, "file": "cv", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Синдбад"} {"fields": {"given": "Синдбад", "locale": "ja_AQ"}, "file": "cv", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Синдбад"} {"fields": {"given": "Цецилия", "given2": "Хэмиш", "locale": "ja_AQ", "surname": "Штёбер"}, "file": "cv", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Цецилия Хэмиш Штёбер"} {"fields": {"given": "Цецилия", "given2": "Хэмиш", "locale": "ja_AQ", "surname": "Штёбер"}, "file": "cv", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Штёбер, Цецилия Хэмиш"} {"fields": {"given": "Цецилия", "given2": "Хэмиш", "locale": "ja_AQ", "surname": "Штёбер"}, "file": "cv", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Штёбер Цецилия Хэмиш"} {"fields": {"given": "Кете", "locale": "ja_AQ", "surname": "Мюллер"}, "file": "cv", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Кете Мюллер"} {"fields": {"given": "Кете", "locale": "ja_AQ", "surname": "Мюллер"}, "file": "cv", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Мюллер, Кете"} {"fields": {"given": "Кете", "locale": "ja_AQ", "surname": "Мюллер"}, "file": "cv", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Мюллер Кете"} {"fields": {"given": "Николай", "given-informal": "Николай", "given2": "Иванович", "locale": "cv_AQ", "surname-core": "Ашмарин"}, "file": "cv", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Николай Иванович Ашмарин"} {"fields": {"given": "Николай", "given-informal": "Николай", "given2": "Иванович", "locale": "cv_AQ", "surname-core": "Ашмарин"}, "file": "cv", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Ашмарин, Николай Иванович"} {"fields": {"given": "Николай", "given-informal": "Николай", "given2": "Иванович", "locale": "cv_AQ", "surname-core": "Ашмарин"}, "file": "cv", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Ашмарин Николай Иванович"} {"fields": {"given": "Аталан", "locale": "cv_AQ"}, "file": "cv", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Аталан"} {"fields": {"given": "Аталан", "locale": "cv_AQ"}, "file": "cv", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Аталан"} {"fields": {"given": "Аталан", "locale": "cv_AQ"}, "file": "cv", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Аталан"} {"fields": {"given": "Константин", "given2": "Васильевич", "locale": "cv_AQ", "surname": "Иванов"}, "file": "cv", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Константин Васильевич Иванов"} {"fields": {"given": "Константин", "given2": "Васильевич", "locale": "cv_AQ", "surname": "Иванов"}, "file": "cv", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Иванов, Константин Васильевич"} {"fields": {"given": "Константин", "given2": "Васильевич", "locale": "cv_AQ", "surname": "Иванов"}, "file": "cv", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Иванов Константин Васильевич"} {"fields": {"given": "Илемпи", "locale": "cv_AQ", "surname": "Корнилова"}, "file": "cv", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Илемпи Корнилова"} {"fields": {"given": "Илемпи", "locale": "cv_AQ", "surname": "Корнилова"}, "file": "cv", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Корнилова, Илемпи"} {"fields": {"given": "Илемпи", "locale": "cv_AQ", "surname": "Корнилова"}, "file": "cv", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Корнилова Илемпи"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "Eva Sophia", "locale": "ja_AQ", "surname-core": "Wolf", "surname-prefix": "von", "surname2": "González Domingo", "title": "Athro Dr"}, "file": "cy", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Ada Cornelia Eva Sophia von Wolf MD DDS"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "Eva Sophia", "locale": "ja_AQ", "surname-core": "Wolf", "surname-prefix": "von", "surname2": "González Domingo", "title": "Athro Dr"}, "file": "cy", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Wolf, Ada Cornelia Eva Sophia von"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "Eva Sophia", "locale": "ja_AQ", "surname-core": "Wolf", "surname-prefix": "von", "surname2": "González Domingo", "title": "Athro Dr"}, "file": "cy", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "von Wolf Ada Cornelia Eva Sophia MD DDS"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "cy", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "cy", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "cy", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stöber"}, "file": "cy", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zäzilia Hamish Stöber"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stöber"}, "file": "cy", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Stöber, Zäzilia Hamish"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stöber"}, "file": "cy", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Stöber Zäzilia Hamish"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "cy", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Käthe Müller"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "cy", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Müller, Käthe"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "cy", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Müller Käthe"} {"fields": {"credentials": "AS", "generation": "Jr", "given": "Bertram Wilberforce", "given-informal": "Bertie", "given2": "Henry Robert", "locale": "cy_AQ", "surname-core": "Wooster", "title": "Mr."}, "file": "cy", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Bertram Wilberforce Henry Robert Wooster AS"} {"fields": {"credentials": "AS", "generation": "Jr", "given": "Bertram Wilberforce", "given-informal": "Bertie", "given2": "Henry Robert", "locale": "cy_AQ", "surname-core": "Wooster", "title": "Mr."}, "file": "cy", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Wooster, Bertram Wilberforce Henry Robert"} {"fields": {"credentials": "AS", "generation": "Jr", "given": "Bertram Wilberforce", "given-informal": "Bertie", "given2": "Henry Robert", "locale": "cy_AQ", "surname-core": "Wooster", "title": "Mr."}, "file": "cy", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Wooster Bertram Wilberforce Henry Robert AS"} {"fields": {"given": "Zendaya", "locale": "cy_AQ"}, "file": "cy", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Zendaya"} {"fields": {"given": "Zendaya", "locale": "cy_AQ"}, "file": "cy", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Zendaya"} {"fields": {"given": "Zendaya", "locale": "cy_AQ"}, "file": "cy", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Zendaya"} {"fields": {"given": "Mary Sue", "given2": "Hamish", "locale": "cy_AQ", "surname": "Watson"}, "file": "cy", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Mary Sue Hamish Watson"} {"fields": {"given": "Mary Sue", "given2": "Hamish", "locale": "cy_AQ", "surname": "Watson"}, "file": "cy", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Watson, Mary Sue Hamish"} {"fields": {"given": "Mary Sue", "given2": "Hamish", "locale": "cy_AQ", "surname": "Watson"}, "file": "cy", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Watson Mary Sue Hamish"} {"fields": {"given": "Irene", "locale": "cy_AQ", "surname": "Adler"}, "file": "cy", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Irene Adler"} {"fields": {"given": "Irene", "locale": "cy_AQ", "surname": "Adler"}, "file": "cy", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Adler, Irene"} {"fields": {"given": "Irene", "locale": "cy_AQ", "surname": "Adler"}, "file": "cy", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Adler Irene"} {"fields": {"credentials": "cand.odont.", "generation": "jr.", "given": "Anastasia Eleni", "given-informal": "Anastasia", "given2": "César Martín", "locale": "ja_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "dr."}, "file": "da", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "dr. Anastasia Eleni César Martín von Brühl, cand.odont."} {"fields": {"credentials": "cand.odont.", "generation": "jr.", "given": "Anastasia Eleni", "given-informal": "Anastasia", "given2": "César Martín", "locale": "ja_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "dr."}, "file": "da", "label": "foreignFull", "order": "sorting", "shape": null, "text": "von Brühl, Anastasia Eleni César Martín"} {"fields": {"credentials": "cand.odont.", "generation": "jr.", "given": "Anastasia Eleni", "given-informal": "Anastasia", "given2": "César Martín", "locale": "ja_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "dr."}, "file": "da", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "von Brühl, dr. Anastasia Eleni César Martín, cand.odont."} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "da", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "da", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "da", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Zäzilia", "given2": "Gültan", "locale": "ja_AQ", "surname": "Stöber"}, "file": "da", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zäzilia Gültan Stöber"} {"fields": {"given": "Zäzilia", "given2": "Gültan", "locale": "ja_AQ", "surname": "Stöber"}, "file": "da", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Stöber, Zäzilia Gültan"} {"fields": {"given": "Zäzilia", "given2": "Gültan", "locale": "ja_AQ", "surname": "Stöber"}, "file": "da", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Stöber, Zäzilia Gültan"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "da", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Käthe Müller"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "da", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Müller, Käthe"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "da", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Müller, Käthe"} {"fields": {"credentials": "MF", "given": "Hans Henrik", "given-informal": "Hans", "given2": "Sven Åge", "locale": "da_AQ", "surname-core": "Møller", "title": "hr."}, "file": "da", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "hr. Hans Henrik Sven Åge Møller, MF"} {"fields": {"credentials": "MF", "given": "Hans Henrik", "given-informal": "Hans", "given2": "Sven Åge", "locale": "da_AQ", "surname-core": "Møller", "title": "hr."}, "file": "da", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Møller, Hans Henrik Sven Åge"} {"fields": {"credentials": "MF", "given": "Hans Henrik", "given-informal": "Hans", "given2": "Sven Åge", "locale": "da_AQ", "surname-core": "Møller", "title": "hr."}, "file": "da", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Møller, hr. Hans Henrik Sven Åge, MF"} {"fields": {"given": "Katrine", "locale": "da_AQ"}, "file": "da", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Katrine"} {"fields": {"given": "Katrine", "locale": "da_AQ"}, "file": "da", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Katrine"} {"fields": {"given": "Katrine", "locale": "da_AQ"}, "file": "da", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Katrine"} {"fields": {"given": "Anne Sofie", "given2": "Holm", "locale": "da_AQ", "surname": "Hansen"}, "file": "da", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Anne Sofie Holm Hansen"} {"fields": {"given": "Anne Sofie", "given2": "Holm", "locale": "da_AQ", "surname": "Hansen"}, "file": "da", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Hansen, Anne Sofie Holm"} {"fields": {"given": "Anne Sofie", "given2": "Holm", "locale": "da_AQ", "surname": "Hansen"}, "file": "da", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Hansen, Anne Sofie Holm"} {"fields": {"given": "Irene", "locale": "da_AQ", "surname": "Møller"}, "file": "da", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Irene Møller"} {"fields": {"given": "Irene", "locale": "da_AQ", "surname": "Møller"}, "file": "da", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Møller, Irene"} {"fields": {"given": "Irene", "locale": "da_AQ", "surname": "Møller"}, "file": "da", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Møller, Irene"} {"fields": {"credentials": "M.D. Ph.D.", "generation": "jr.", "given": "Anna Cornelia", "given-informal": "Nele", "given2": "Eva Sophia", "locale": "ko_AQ", "surname-core": "Wolf", "surname-prefix": "van den", "surname2": "Becker Schmidt", "title": "Prof. Dr."}, "file": "de", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Prof. Dr. Anna Cornelia Eva Sophia van den Wolf jr. M.D. Ph.D."} {"fields": {"credentials": "M.D. Ph.D.", "generation": "jr.", "given": "Anna Cornelia", "given-informal": "Nele", "given2": "Eva Sophia", "locale": "ko_AQ", "surname-core": "Wolf", "surname-prefix": "van den", "surname2": "Becker Schmidt", "title": "Prof. Dr."}, "file": "de", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Wolf, Anna Cornelia Eva Sophia van den"} {"fields": {"credentials": "M.D. Ph.D.", "generation": "jr.", "given": "Anna Cornelia", "given-informal": "Nele", "given2": "Eva Sophia", "locale": "ko_AQ", "surname-core": "Wolf", "surname-prefix": "van den", "surname2": "Becker Schmidt", "title": "Prof. Dr."}, "file": "de", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "van den Wolf, Prof. Dr. Anna Cornelia Eva Sophia jr. M.D. Ph.D."} {"fields": {"given": "Tomás", "locale": "ko_AQ"}, "file": "de", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Tomás"} {"fields": {"given": "Tomás", "locale": "ko_AQ"}, "file": "de", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Tomás"} {"fields": {"given": "Tomás", "locale": "ko_AQ"}, "file": "de", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Tomás"} {"fields": {"given": "Kjetil", "given2": "Bjørn", "locale": "ko_AQ", "surname": "Løseth"}, "file": "de", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Kjetil Bjørn Løseth"} {"fields": {"given": "Kjetil", "given2": "Bjørn", "locale": "ko_AQ", "surname": "Løseth"}, "file": "de", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Løseth, Kjetil Bjørn"} {"fields": {"given": "Kjetil", "given2": "Bjørn", "locale": "ko_AQ", "surname": "Løseth"}, "file": "de", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Løseth, Kjetil Bjørn"} {"fields": {"given": "Adélaïde", "locale": "ko_AQ", "surname": "Lemaître"}, "file": "de", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Adélaïde Lemaître"} {"fields": {"given": "Adélaïde", "locale": "ko_AQ", "surname": "Lemaître"}, "file": "de", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Lemaître, Adélaïde"} {"fields": {"given": "Adélaïde", "locale": "ko_AQ", "surname": "Lemaître"}, "file": "de", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Lemaître, Adélaïde"} {"fields": {"credentials": "MdB", "generation": "jr.", "given": "Paul", "given-informal": "Pauli", "given2": "Vinzent", "locale": "de_AQ", "surname-core": "Fischer", "surname-prefix": "von", "title": "Dr."}, "file": "de", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Dr. Paul Vinzent von Fischer jr. MdB"} {"fields": {"credentials": "MdB", "generation": "jr.", "given": "Paul", "given-informal": "Pauli", "given2": "Vinzent", "locale": "de_AQ", "surname-core": "Fischer", "surname-prefix": "von", "title": "Dr."}, "file": "de", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Fischer, Paul Vinzent von"} {"fields": {"credentials": "MdB", "generation": "jr.", "given": "Paul", "given-informal": "Pauli", "given2": "Vinzent", "locale": "de_AQ", "surname-core": "Fischer", "surname-prefix": "von", "title": "Dr."}, "file": "de", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "von Fischer, Dr. Paul Vinzent jr. MdB"} {"fields": {"given": "Lena", "locale": "de_AQ"}, "file": "de", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Lena"} {"fields": {"given": "Lena", "locale": "de_AQ"}, "file": "de", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Lena"} {"fields": {"given": "Lena", "locale": "de_AQ"}, "file": "de", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Lena"} {"fields": {"given": "Max", "given2": "Ben", "locale": "de_AQ", "surname": "Mustermann"}, "file": "de", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Max Ben Mustermann"} {"fields": {"given": "Max", "given2": "Ben", "locale": "de_AQ", "surname": "Mustermann"}, "file": "de", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Mustermann, Max Ben"} {"fields": {"given": "Max", "given2": "Ben", "locale": "de_AQ", "surname": "Mustermann"}, "file": "de", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Mustermann, Max Ben"} {"fields": {"given": "Iris", "locale": "de_AQ", "surname": "Falke"}, "file": "de", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Iris Falke"} {"fields": {"given": "Iris", "locale": "de_AQ", "surname": "Falke"}, "file": "de", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Falke, Iris"} {"fields": {"given": "Iris", "locale": "de_AQ", "surname": "Falke"}, "file": "de", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Falke, Iris"} {"fields": {"credentials": "m.d. ph.d.", "generation": "jr.", "given": "Anna Cornelia", "given-informal": "Nele", "given2": "Eva Sophia", "locale": "ko_AQ", "surname-core": "Wolf", "surname-prefix": "van den", "surname2": "Becker Schmidt", "title": "prof. dr."}, "file": "dsb", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "prof. dr. Anna Cornelia Eva Sophia van den Wolf jr. m.d. ph.d."} {"fields": {"credentials": "m.d. ph.d.", "generation": "jr.", "given": "Anna Cornelia", "given-informal": "Nele", "given2": "Eva Sophia", "locale": "ko_AQ", "surname-core": "Wolf", "surname-prefix": "van den", "surname2": "Becker Schmidt", "title": "prof. dr."}, "file": "dsb", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Wolf, Anna Cornelia Eva Sophia van den"} {"fields": {"credentials": "m.d. ph.d.", "generation": "jr.", "given": "Anna Cornelia", "given-informal": "Nele", "given2": "Eva Sophia", "locale": "ko_AQ", "surname-core": "Wolf", "surname-prefix": "van den", "surname2": "Becker Schmidt", "title": "prof. dr."}, "file": "dsb", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "van den Wolf, prof. dr. Anna Cornelia Eva Sophia jr. m.d. ph.d."} {"fields": {"given": "Tomás", "locale": "ko_AQ"}, "file": "dsb", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Tomás"} {"fields": {"given": "Tomás", "locale": "ko_AQ"}, "file": "dsb", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Tomás"} {"fields": {"given": "Tomás", "locale": "ko_AQ"}, "file": "dsb", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Tomás"} {"fields": {"given": "Kjetil", "given2": "Bjørn", "locale": "ko_AQ", "surname": "Løseth"}, "file": "dsb", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Kjetil Bjørn Løseth"} {"fields": {"given": "Kjetil", "given2": "Bjørn", "locale": "ko_AQ", "surname": "Løseth"}, "file": "dsb", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Løseth, Kjetil Bjørn"} {"fields": {"given": "Kjetil", "given2": "Bjørn", "locale": "ko_AQ", "surname": "Løseth"}, "file": "dsb", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Løseth, Kjetil Bjørn"} {"fields": {"given": "Adélaïde", "locale": "ko_AQ", "surname": "Lemaître"}, "file": "dsb", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Adélaïde Lemaître"} {"fields": {"given": "Adélaïde", "locale": "ko_AQ", "surname": "Lemaître"}, "file": "dsb", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Lemaître, Adélaïde"} {"fields": {"given": "Adélaïde", "locale": "ko_AQ", "surname": "Lemaître"}, "file": "dsb", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Lemaître, Adélaïde"} {"fields": {"credentials": "phd", "generation": "jr.", "given": "Hejba-Marja", "given-informal": "Hejba", "given2": "Hana", "locale": "dsb_AQ", "surname-core": "Lejnikowa", "title": "dr."}, "file": "dsb", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "dr. Hejba-Marja Hana Lejnikowa jr. phd"} {"fields": {"credentials": "phd", "generation": "jr.", "given": "Hejba-Marja", "given-informal": "Hejba", "given2": "Hana", "locale": "dsb_AQ", "surname-core": "Lejnikowa", "title": "dr."}, "file": "dsb", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Lejnikowa, Hejba-Marja Hana"} {"fields": {"credentials": "phd", "generation": "jr.", "given": "Hejba-Marja", "given-informal": "Hejba", "given2": "Hana", "locale": "dsb_AQ", "surname-core": "Lejnikowa", "title": "dr."}, "file": "dsb", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Lejnikowa, dr. Hejba-Marja Hana jr. phd"} {"fields": {"given": "Juro", "locale": "dsb_AQ"}, "file": "dsb", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Juro"} {"fields": {"given": "Juro", "locale": "dsb_AQ"}, "file": "dsb", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Juro"} {"fields": {"given": "Juro", "locale": "dsb_AQ"}, "file": "dsb", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Juro"} {"fields": {"given": "Jan", "given2": "Měto", "locale": "dsb_AQ", "surname": "Šejc"}, "file": "dsb", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Jan Měto Šejc"} {"fields": {"given": "Jan", "given2": "Měto", "locale": "dsb_AQ", "surname": "Šejc"}, "file": "dsb", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Šejc, Jan Měto"} {"fields": {"given": "Jan", "given2": "Měto", "locale": "dsb_AQ", "surname": "Šejc"}, "file": "dsb", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Šejc, Jan Měto"} {"fields": {"given": "Hana", "locale": "dsb_AQ", "surname": "Kulina"}, "file": "dsb", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Hana Kulina"} {"fields": {"given": "Hana", "locale": "dsb_AQ", "surname": "Kulina"}, "file": "dsb", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Kulina, Hana"} {"fields": {"given": "Hana", "locale": "dsb_AQ", "surname": "Kulina"}, "file": "dsb", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Kulina, Hana"} {"fields": {"credentials": "Δρ.Ι. Δρ.Χ.Ο", "generation": "Τζούνιορ", "given": "Άντα Κορνέλια", "given-informal": "Νιλ", "given2": "Σέσαρ Μαρτίν", "locale": "ja_AQ", "surname-core": "Μπριλ", "surname-prefix": "φον", "surname2": "Γκονθάλεθ Δομίνγκο", "title": "Καθ. δρ."}, "file": "el", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Καθ. δρ. Άντα Κορνέλια Σέσαρ Μαρτίν φον Μπριλ Γκονθάλεθ Δομίνγκο Δρ.Ι. Δρ.Χ.Ο"} {"fields": {"credentials": "Δρ.Ι. Δρ.Χ.Ο", "generation": "Τζούνιορ", "given": "Άντα Κορνέλια", "given-informal": "Νιλ", "given2": "Σέσαρ Μαρτίν", "locale": "ja_AQ", "surname-core": "Μπριλ", "surname-prefix": "φον", "surname2": "Γκονθάλεθ Δομίνγκο", "title": "Καθ. δρ."}, "file": "el", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Μπριλ, φον Άντα Κορνέλια Σέσαρ Μαρτίν"} {"fields": {"credentials": "Δρ.Ι. Δρ.Χ.Ο", "generation": "Τζούνιορ", "given": "Άντα Κορνέλια", "given-informal": "Νιλ", "given2": "Σέσαρ Μαρτίν", "locale": "ja_AQ", "surname-core": "Μπριλ", "surname-prefix": "φον", "surname2": "Γκονθάλεθ Δομίνγκο", "title": "Καθ. δρ."}, "file": "el", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "φον Μπριλ Άντα Κορνέλια Σέσαρ Μαρτίν Δρ.Ι. Δρ.Χ.Ο"} {"fields": {"given": "Σινμπάντ", "locale": "ja_AQ"}, "file": "el", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Σινμπάντ"} {"fields": {"given": "Σινμπάντ", "locale": "ja_AQ"}, "file": "el", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Σινμπάντ"} {"fields": {"given": "Σινμπάντ", "locale": "ja_AQ"}, "file": "el", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Σινμπάντ"} {"fields": {"given": "Ζεζίλια", "given2": "Χάμις", "locale": "ja_AQ", "surname": "Στέμπερ"}, "file": "el", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Ζεζίλια Χάμις Στέμπερ"} {"fields": {"given": "Ζεζίλια", "given2": "Χάμις", "locale": "ja_AQ", "surname": "Στέμπερ"}, "file": "el", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Στέμπερ, Ζεζίλια Χάμις"} {"fields": {"given": "Ζεζίλια", "given2": "Χάμις", "locale": "ja_AQ", "surname": "Στέμπερ"}, "file": "el", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Στέμπερ Ζεζίλια Χάμις"} {"fields": {"given": "Κάτε", "locale": "ja_AQ", "surname": "Μίλερ"}, "file": "el", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Κάτε Μίλερ"} {"fields": {"given": "Κάτε", "locale": "ja_AQ", "surname": "Μίλερ"}, "file": "el", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Μίλερ, Κάτε"} {"fields": {"given": "Κάτε", "locale": "ja_AQ", "surname": "Μίλερ"}, "file": "el", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Μίλερ Κάτε"} {"fields": {"given": "Νικόλαος Καρατζής", "given-informal": "Νίκος", "given2": "Κωνσταντίνος Μάριος", "locale": "el_AQ", "surname-core": "Χατζής", "title": "Κος"}, "file": "el", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Κος Νικόλαος Καρατζής Κωνσταντίνος Μάριος Χατζής"} {"fields": {"given": "Νικόλαος Καρατζής", "given-informal": "Νίκος", "given2": "Κωνσταντίνος Μάριος", "locale": "el_AQ", "surname-core": "Χατζής", "title": "Κος"}, "file": "el", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Χατζής, Νικόλαος Καρατζής Κωνσταντίνος Μάριος"} {"fields": {"given": "Νικόλαος Καρατζής", "given-informal": "Νίκος", "given2": "Κωνσταντίνος Μάριος", "locale": "el_AQ", "surname-core": "Χατζής", "title": "Κος"}, "file": "el", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Χατζής Νικόλαος Καρατζής Κωνσταντίνος Μάριος"} {"fields": {"given": "Δημήτρης", "locale": "el_AQ"}, "file": "el", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Δημήτρης"} {"fields": {"given": "Δημήτρης", "locale": "el_AQ"}, "file": "el", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Δημήτρης"} {"fields": {"given": "Δημήτρης", "locale": "el_AQ"}, "file": "el", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Δημήτρης"} {"fields": {"given": "Πέτρος", "given2": "Νικόλαος", "locale": "el_AQ", "surname": "Καρατζής"}, "file": "el", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Πέτρος Νικόλαος Καρατζής"} {"fields": {"given": "Πέτρος", "given2": "Νικόλαος", "locale": "el_AQ", "surname": "Καρατζής"}, "file": "el", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Καρατζής, Πέτρος Νικόλαος"} {"fields": {"given": "Πέτρος", "given2": "Νικόλαος", "locale": "el_AQ", "surname": "Καρατζής"}, "file": "el", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Καρατζής Πέτρος Νικόλαος"} {"fields": {"given": "Ειρήνη", "locale": "el_AQ", "surname": "Πολίτη"}, "file": "el", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Ειρήνη Πολίτη"} {"fields": {"given": "Ειρήνη", "locale": "el_AQ", "surname": "Πολίτη"}, "file": "el", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Πολίτη, Ειρήνη"} {"fields": {"given": "Ειρήνη", "locale": "el_AQ", "surname": "Πολίτη"}, "file": "el", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Πολίτη Ειρήνη"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ja_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "en", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Prof. Dr. Ada Cornelia César Martín von Brühl Jr, MD DDS"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ja_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "en", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Brühl, Ada Cornelia César Martín von"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ja_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "en", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "von Brühl Prof. Dr. Ada Cornelia César Martín Jr, MD DDS"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "en", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "en", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "en", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stöber"}, "file": "en", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zäzilia Hamish Stöber"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stöber"}, "file": "en", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Stöber, Zäzilia Hamish"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stöber"}, "file": "en", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Stöber Zäzilia Hamish"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "en", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Käthe Müller"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "en", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Müller, Käthe"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "en", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Müller Käthe"} {"fields": {"credentials": "MP", "generation": "Jr", "given": "Bertram Wilberforce", "given-informal": "Bertie", "given2": "Henry Robert", "locale": "en_AQ", "surname-core": "Wooster", "title": "Mr."}, "file": "en", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Mr. Bertram Wilberforce Henry Robert Wooster Jr, MP"} {"fields": {"credentials": "MP", "generation": "Jr", "given": "Bertram Wilberforce", "given-informal": "Bertie", "given2": "Henry Robert", "locale": "en_AQ", "surname-core": "Wooster", "title": "Mr."}, "file": "en", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Wooster, Bertram Wilberforce Henry Robert"} {"fields": {"credentials": "MP", "generation": "Jr", "given": "Bertram Wilberforce", "given-informal": "Bertie", "given2": "Henry Robert", "locale": "en_AQ", "surname-core": "Wooster", "title": "Mr."}, "file": "en", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Wooster Mr. Bertram Wilberforce Henry Robert Jr, MP"} {"fields": {"given": "Zendaya", "locale": "en_AQ"}, "file": "en", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Zendaya"} {"fields": {"given": "Zendaya", "locale": "en_AQ"}, "file": "en", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Zendaya"} {"fields": {"given": "Zendaya", "locale": "en_AQ"}, "file": "en", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Zendaya"} {"fields": {"given": "Mary Sue", "given2": "Hamish", "locale": "en_AQ", "surname": "Watson"}, "file": "en", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Mary Sue Hamish Watson"} {"fields": {"given": "Mary Sue", "given2": "Hamish", "locale": "en_AQ", "surname": "Watson"}, "file": "en", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Watson, Mary Sue Hamish"} {"fields": {"given": "Mary Sue", "given2": "Hamish", "locale": "en_AQ", "surname": "Watson"}, "file": "en", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Watson Mary Sue Hamish"} {"fields": {"given": "Irene", "locale": "en_AQ", "surname": "Adler"}, "file": "en", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Irene Adler"} {"fields": {"given": "Irene", "locale": "en_AQ", "surname": "Adler"}, "file": "en", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Adler, Irene"} {"fields": {"given": "Irene", "locale": "en_AQ", "surname": "Adler"}, "file": "en", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Adler Irene"} {"fields": {"credentials": "MD DDS", "generation": "II", "given": "Mary Sue", "given-informal": "Marge", "given2": "Marie", "locale": "ko_AQ", "surname-core": "Miller", "surname-prefix": "von", "surname2": "Jones", "title": "Prof. Dr."}, "file": "es", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Prof. Dr. Mary Sue Marie von Miller II, MD DDS"} {"fields": {"credentials": "MD DDS", "generation": "II", "given": "Mary Sue", "given-informal": "Marge", "given2": "Marie", "locale": "ko_AQ", "surname-core": "Miller", "surname-prefix": "von", "surname2": "Jones", "title": "Prof. Dr."}, "file": "es", "label": "foreignFull", "order": "sorting", "shape": null, "text": "von Miller Jones, Prof. Dr. Mary Sue Marie"} {"fields": {"credentials": "MD DDS", "generation": "II", "given": "Mary Sue", "given-informal": "Marge", "given2": "Marie", "locale": "ko_AQ", "surname-core": "Miller", "surname-prefix": "von", "surname2": "Jones", "title": "Prof. Dr."}, "file": "es", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "von Miller Jones Mary Sue Marie II, MD DDS"} {"fields": {"given": "Peter", "locale": "ko_AQ"}, "file": "es", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Peter"} {"fields": {"given": "Peter", "locale": "ko_AQ"}, "file": "es", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Peter"} {"fields": {"given": "Peter", "locale": "ko_AQ"}, "file": "es", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Peter"} {"fields": {"given": "Christopher", "given2": "Hans", "locale": "ko_AQ", "surname": "Jacobsen"}, "file": "es", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Christopher Hans Jacobsen"} {"fields": {"given": "Christopher", "given2": "Hans", "locale": "ko_AQ", "surname": "Jacobsen"}, "file": "es", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Jacobsen, Christopher Hans"} {"fields": {"given": "Christopher", "given2": "Hans", "locale": "ko_AQ", "surname": "Jacobsen"}, "file": "es", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Jacobsen Christopher Hans"} {"fields": {"given": "Martine", "locale": "ko_AQ", "surname": "Sodersen"}, "file": "es", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Martine Sodersen"} {"fields": {"given": "Martine", "locale": "ko_AQ", "surname": "Sodersen"}, "file": "es", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Sodersen, Martine"} {"fields": {"given": "Martine", "locale": "ko_AQ", "surname": "Sodersen"}, "file": "es", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Sodersen Martine"} {"fields": {"generation": "II", "given": "Miguel Ángel", "given-informal": "Migue", "given2": "Juan Antonio", "locale": "es_AQ", "surname-core": "Pablo", "surname2": "Pérez", "title": "Sr."}, "file": "es", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Sr. Miguel Ángel Juan Antonio Pablo II"} {"fields": {"generation": "II", "given": "Miguel Ángel", "given-informal": "Migue", "given2": "Juan Antonio", "locale": "es_AQ", "surname-core": "Pablo", "surname2": "Pérez", "title": "Sr."}, "file": "es", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Pablo Pérez, Sr. Miguel Ángel Juan Antonio"} {"fields": {"generation": "II", "given": "Miguel Ángel", "given-informal": "Migue", "given2": "Juan Antonio", "locale": "es_AQ", "surname-core": "Pablo", "surname2": "Pérez", "title": "Sr."}, "file": "es", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Pablo Pérez Miguel Ángel Juan Antonio II"} {"fields": {"given": "Lola", "locale": "es_AQ"}, "file": "es", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Lola"} {"fields": {"given": "Lola", "locale": "es_AQ"}, "file": "es", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Lola"} {"fields": {"given": "Lola", "locale": "es_AQ"}, "file": "es", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Lola"} {"fields": {"given": "Rosa", "given2": "María", "locale": "es_AQ", "surname": "Ruiz"}, "file": "es", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Rosa María Ruiz"} {"fields": {"given": "Rosa", "given2": "María", "locale": "es_AQ", "surname": "Ruiz"}, "file": "es", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Ruiz, Rosa María"} {"fields": {"given": "Rosa", "given2": "María", "locale": "es_AQ", "surname": "Ruiz"}, "file": "es", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Ruiz Rosa María"} {"fields": {"given": "Lucía", "locale": "es_AQ", "surname": "Pérez"}, "file": "es", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Lucía Pérez"} {"fields": {"given": "Lucía", "locale": "es_AQ", "surname": "Pérez"}, "file": "es", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Pérez, Lucía"} {"fields": {"given": "Lucía", "locale": "es_AQ", "surname": "Pérez"}, "file": "es", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Pérez Lucía"} {"fields": {"credentials": "MD, PhD", "generation": "jr", "given": "Anna Liina", "given-informal": "Anni", "given2": "Eva Sofia", "locale": "ja_AQ", "surname-core": "Vall", "surname-prefix": "von", "surname2": "Heinmann Laas", "title": "Prof"}, "file": "et", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Anna Liina Eva Sofia von Vall, MD, PhD"} {"fields": {"credentials": "MD, PhD", "generation": "jr", "given": "Anna Liina", "given-informal": "Anni", "given2": "Eva Sofia", "locale": "ja_AQ", "surname-core": "Vall", "surname-prefix": "von", "surname2": "Heinmann Laas", "title": "Prof"}, "file": "et", "label": "foreignFull", "order": "sorting", "shape": null, "text": "von Vall, Anna Liina Eva Sofia, MD, PhD"} {"fields": {"credentials": "MD, PhD", "generation": "jr", "given": "Anna Liina", "given-informal": "Anni", "given2": "Eva Sofia", "locale": "ja_AQ", "surname-core": "Vall", "surname-prefix": "von", "surname2": "Heinmann Laas", "title": "Prof"}, "file": "et", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "von Vall Heinmann Laas Prof Anna Liina Eva Sofia MD, PhD"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "et", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "et", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "et", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stöber"}, "file": "et", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zäzilia Hamish Stöber"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stöber"}, "file": "et", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Stöber, Zäzilia Hamish"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stöber"}, "file": "et", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Stöber Zäzilia Hamish"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "et", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Käthe Müller"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "et", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Müller, Käthe"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "et", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Müller Käthe"} {"fields": {"credentials": "parlamendiliige", "generation": "jr", "given": "Mari-Ann", "given-informal": "Mari", "given2": "Henri Roobert", "locale": "et_AQ", "surname-core": "Tammik", "title": "hr"}, "file": "et", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Mari-Ann Henri Roobert Tammik, parlamendiliige"} {"fields": {"credentials": "parlamendiliige", "generation": "jr", "given": "Mari-Ann", "given-informal": "Mari", "given2": "Henri Roobert", "locale": "et_AQ", "surname-core": "Tammik", "title": "hr"}, "file": "et", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Tammik, Mari-Ann Henri Roobert, parlamendiliige"} {"fields": {"credentials": "parlamendiliige", "generation": "jr", "given": "Mari-Ann", "given-informal": "Mari", "given2": "Henri Roobert", "locale": "et_AQ", "surname-core": "Tammik", "title": "hr"}, "file": "et", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Tammik hr Mari-Ann Henri Roobert parlamendiliige"} {"fields": {"given": "Mari", "locale": "et_AQ"}, "file": "et", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Mari"} {"fields": {"given": "Mari", "locale": "et_AQ"}, "file": "et", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Mari"} {"fields": {"given": "Mari", "locale": "et_AQ"}, "file": "et", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Mari"} {"fields": {"given": "Jaak", "given2": "Eerik", "locale": "et_AQ", "surname": "Kask"}, "file": "et", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Jaak Eerik Kask"} {"fields": {"given": "Jaak", "given2": "Eerik", "locale": "et_AQ", "surname": "Kask"}, "file": "et", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Kask, Jaak Eerik"} {"fields": {"given": "Jaak", "given2": "Eerik", "locale": "et_AQ", "surname": "Kask"}, "file": "et", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Kask Jaak Eerik"} {"fields": {"given": "Anne", "locale": "et_AQ", "surname": "Sepp"}, "file": "et", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Anne Sepp"} {"fields": {"given": "Anne", "locale": "et_AQ", "surname": "Sepp"}, "file": "et", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Sepp, Anne"} {"fields": {"given": "Anne", "locale": "et_AQ", "surname": "Sepp"}, "file": "et", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Sepp Anne"} {"fields": {"credentials": "MD DDS", "generation": "jr.", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "irak. dk."}, "file": "eu", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Ada Cornelia César Martín von Brühl MD DDS"} {"fields": {"credentials": "MD DDS", "generation": "jr.", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "irak. dk."}, "file": "eu", "label": "foreignFull", "order": "sorting", "shape": null, "text": "von Brühl, Ada Cornelia César Martín MD DDS"} {"fields": {"credentials": "MD DDS", "generation": "jr.", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "irak. dk."}, "file": "eu", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "von Brühl, Ada Cornelia César Martín MD DDS"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "eu", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "eu", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "eu", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "eu", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zäzilia Hamish Stöber"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "eu", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Stöber, Zäzilia Hamish"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "eu", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Stöber, Zäzilia Hamish"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "eu", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Käthe Müller"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "eu", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Müller, Käthe"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "eu", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Müller, Käthe"} {"fields": {"generation": "jr.", "given": "Jon", "given-informal": "Jontxu", "given2": "Andoni", "locale": "eu_AQ", "surname-core": "Urrestaratzu", "surname2": "Etxeberria", "title": "jn."}, "file": "eu", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Jon Andoni Urrestaratzu"} {"fields": {"generation": "jr.", "given": "Jon", "given-informal": "Jontxu", "given2": "Andoni", "locale": "eu_AQ", "surname-core": "Urrestaratzu", "surname2": "Etxeberria", "title": "jn."}, "file": "eu", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Urrestaratzu, Jon Andoni"} {"fields": {"generation": "jr.", "given": "Jon", "given-informal": "Jontxu", "given2": "Andoni", "locale": "eu_AQ", "surname-core": "Urrestaratzu", "surname2": "Etxeberria", "title": "jn."}, "file": "eu", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Urrestaratzu, Jon Andoni"} {"fields": {"given": "Maider", "locale": "eu_AQ"}, "file": "eu", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Maider"} {"fields": {"given": "Maider", "locale": "eu_AQ"}, "file": "eu", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Maider"} {"fields": {"given": "Maider", "locale": "eu_AQ"}, "file": "eu", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Maider"} {"fields": {"given": "Ane", "given2": "Miren", "locale": "eu_AQ", "surname": "Urrestaratzu"}, "file": "eu", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Ane Miren Urrestaratzu"} {"fields": {"given": "Ane", "given2": "Miren", "locale": "eu_AQ", "surname": "Urrestaratzu"}, "file": "eu", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Urrestaratzu, Ane Miren"} {"fields": {"given": "Ane", "given2": "Miren", "locale": "eu_AQ", "surname": "Urrestaratzu"}, "file": "eu", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Urrestaratzu, Ane Miren"} {"fields": {"given": "Irene", "locale": "eu_AQ", "surname": "Aduritz"}, "file": "eu", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Irene Aduritz"} {"fields": {"given": "Irene", "locale": "eu_AQ", "surname": "Aduritz"}, "file": "eu", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Aduritz, Irene"} {"fields": {"given": "Irene", "locale": "eu_AQ", "surname": "Aduritz"}, "file": "eu", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Aduritz, Irene"} {"fields": {"credentials": "دکترای دندانپزشکی", "generation": "جونیور", "given": "آدا کورنلیا", "given-informal": "نیل", "given2": "سزار مارتین", "locale": "ko_AQ", "surname-core": "بروهل", "surname-prefix": "وان", "surname2": "گونزالس دومینگو", "title": "پروفسور"}, "file": "fa", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "پروفسور آدا کورنلیا سزار مارتین وان بروهل جونیور، دکترای دندانپزشکی"} {"fields": {"credentials": "دکترای دندانپزشکی", "generation": "جونیور", "given": "آدا کورنلیا", "given-informal": "نیل", "given2": "سزار مارتین", "locale": "ko_AQ", "surname-core": "بروهل", "surname-prefix": "وان", "surname2": "گونزالس دومینگو", "title": "پروفسور"}, "file": "fa", "label": "foreignFull", "order": "sorting", "shape": 4, "text": "بروهل، آدا کورنلیا سزار مارتین وان"} {"fields": {"credentials": "دکترای دندانپزشکی", "generation": "جونیور", "given": "آدا کورنلیا", "given-informal": "نیل", "given2": "سزار مارتین", "locale": "ko_AQ", "surname-core": "بروهل", "surname-prefix": "وان", "surname2": "گونزالس دومینگو", "title": "پروفسور"}, "file": "fa", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "وان بروهل، پروفسور آدا کورنلیا سزار مارتین جونیور، دکترای دندانپزشکی"} {"fields": {"given": "سامانتا", "locale": "ko_AQ"}, "file": "fa", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "سامانتا"} {"fields": {"given": "سامانتا", "locale": "ko_AQ"}, "file": "fa", "label": "foreignG", "order": "sorting", "shape": 4, "text": "سامانتا"} {"fields": {"given": "سامانتا", "locale": "ko_AQ"}, "file": "fa", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "سامانتا"} {"fields": {"given": "زازیلیا", "given2": "هایمیش", "locale": "ko_AQ", "surname": "استوبر"}, "file": "fa", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "زازیلیا هایمیش استوبر"} {"fields": {"given": "زازیلیا", "given2": "هایمیش", "locale": "ko_AQ", "surname": "استوبر"}, "file": "fa", "label": "foreignGGS", "order": "sorting", "shape": 4, "text": "استوبر، زازیلیا هایمیش"} {"fields": {"given": "زازیلیا", "given2": "هایمیش", "locale": "ko_AQ", "surname": "استوبر"}, "file": "fa", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "استوبر، زازیلیا هایمیش"} {"fields": {"given": "کاتیا", "locale": "ko_AQ", "surname": "مولر"}, "file": "fa", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "کاتیا مولر"} {"fields": {"given": "کاتیا", "locale": "ko_AQ", "surname": "مولر"}, "file": "fa", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "مولر، کاتیا"} {"fields": {"given": "کاتیا", "locale": "ko_AQ", "surname": "مولر"}, "file": "fa", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "مولر، کاتیا"} {"fields": {"credentials": "دکتر", "given": "امیر", "given-informal": "هومن", "given2": "هوشنگ", "locale": "fa_AQ", "surname-core": "رستگار", "surname2": "اسدآبادی", "title": "آقای"}, "file": "fa", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "آقای امیر هوشنگ رستگار دکتر"} {"fields": {"credentials": "دکتر", "given": "امیر", "given-informal": "هومن", "given2": "هوشنگ", "locale": "fa_AQ", "surname-core": "رستگار", "surname2": "اسدآبادی", "title": "آقای"}, "file": "fa", "label": "nativeFull", "order": "sorting", "shape": 4, "text": "رستگار، امیر هوشنگ"} {"fields": {"credentials": "دکتر", "given": "امیر", "given-informal": "هومن", "given2": "هوشنگ", "locale": "fa_AQ", "surname-core": "رستگار", "surname2": "اسدآبادی", "title": "آقای"}, "file": "fa", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "رستگار، آقای امیر هوشنگ دکتر"} {"fields": {"given": "ژیلا", "locale": "fa_AQ"}, "file": "fa", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "ژیلا"} {"fields": {"given": "ژیلا", "locale": "fa_AQ"}, "file": "fa", "label": "nativeG", "order": "sorting", "shape": 4, "text": "ژیلا"} {"fields": {"given": "ژیلا", "locale": "fa_AQ"}, "file": "fa", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "ژیلا"} {"fields": {"given": "امید", "given2": "رضا", "locale": "fa_AQ", "surname": "ملایری"}, "file": "fa", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "امید رضا ملایری"} {"fields": {"given": "امید", "given2": "رضا", "locale": "fa_AQ", "surname": "ملایری"}, "file": "fa", "label": "nativeGGS", "order": "sorting", "shape": 4, "text": "ملایری، امید رضا"} {"fields": {"given": "امید", "given2": "رضا", "locale": "fa_AQ", "surname": "ملایری"}, "file": "fa", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "ملایری، امید رضا"} {"fields": {"given": "آیتین", "locale": "fa_AQ", "surname": "عدلو"}, "file": "fa", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "آیتین عدلو"} {"fields": {"given": "آیتین", "locale": "fa_AQ", "surname": "عدلو"}, "file": "fa", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "عدلو، آیتین"} {"fields": {"given": "آیتین", "locale": "fa_AQ", "surname": "عدلو"}, "file": "fa", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "عدلو، آیتین"} {"fields": {"credentials": "MD DDS", "generation": "jr.", "given": "Ana Christina", "given-informal": "Chrissy", "given2": "Lëja", "locale": "ko_AQ", "surname-core": "Wright", "surname-prefix": "von", "surname2": "González Domingo", "title": "professori"}, "file": "fi", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "professori Ana Christina Lëja von Wright González Domingo, MD DDS"} {"fields": {"credentials": "MD DDS", "generation": "jr.", "given": "Ana Christina", "given-informal": "Chrissy", "given2": "Lëja", "locale": "ko_AQ", "surname-core": "Wright", "surname-prefix": "von", "surname2": "González Domingo", "title": "professori"}, "file": "fi", "label": "foreignFull", "order": "sorting", "shape": null, "text": "von Wright, Ana Christina Lëja"} {"fields": {"credentials": "MD DDS", "generation": "jr.", "given": "Ana Christina", "given-informal": "Chrissy", "given2": "Lëja", "locale": "ko_AQ", "surname-core": "Wright", "surname-prefix": "von", "surname2": "González Domingo", "title": "professori"}, "file": "fi", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "von Wright González Domingo Ana Christina Lëja, professori MD DDS"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "fi", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "fi", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "fi", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "John", "given2": "Hamish", "locale": "ko_AQ", "surname": "Watson"}, "file": "fi", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "John Hamish Watson"} {"fields": {"given": "John", "given2": "Hamish", "locale": "ko_AQ", "surname": "Watson"}, "file": "fi", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Watson, John Hamish"} {"fields": {"given": "John", "given2": "Hamish", "locale": "ko_AQ", "surname": "Watson"}, "file": "fi", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Watson John Hamish"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "fi", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Käthe Müller"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "fi", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Müller, Käthe"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "fi", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Müller Käthe"} {"fields": {"credentials": "kansanedustaja", "given": "Marja-Liisa", "given-informal": "Maikki", "given2": "Katariina", "locale": "fi_AQ", "surname-core": "Lehtola-Aalto"}, "file": "fi", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Marja-Liisa Katariina Lehtola-Aalto kansanedustaja"} {"fields": {"credentials": "kansanedustaja", "given": "Marja-Liisa", "given-informal": "Maikki", "given2": "Katariina", "locale": "fi_AQ", "surname-core": "Lehtola-Aalto"}, "file": "fi", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Lehtola-Aalto, Marja-Liisa Katariina"} {"fields": {"credentials": "kansanedustaja", "given": "Marja-Liisa", "given-informal": "Maikki", "given2": "Katariina", "locale": "fi_AQ", "surname-core": "Lehtola-Aalto"}, "file": "fi", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Lehtola-Aalto Marja-Liisa Katariina, kansanedustaja"} {"fields": {"given": "Louhi", "locale": "fi_AQ"}, "file": "fi", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Louhi"} {"fields": {"given": "Louhi", "locale": "fi_AQ"}, "file": "fi", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Louhi"} {"fields": {"given": "Louhi", "locale": "fi_AQ"}, "file": "fi", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Louhi"} {"fields": {"given": "Ulrika", "given2": "Wilhelmina", "locale": "fi_AQ", "surname": "Canth"}, "file": "fi", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Ulrika Wilhelmina Canth"} {"fields": {"given": "Ulrika", "given2": "Wilhelmina", "locale": "fi_AQ", "surname": "Canth"}, "file": "fi", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Canth, Ulrika Wilhelmina"} {"fields": {"given": "Ulrika", "given2": "Wilhelmina", "locale": "fi_AQ", "surname": "Canth"}, "file": "fi", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Canth Ulrika Wilhelmina"} {"fields": {"given": "Seppo", "locale": "fi_AQ", "surname": "Ilmarinen"}, "file": "fi", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Seppo Ilmarinen"} {"fields": {"given": "Seppo", "locale": "fi_AQ", "surname": "Ilmarinen"}, "file": "fi", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Ilmarinen, Seppo"} {"fields": {"given": "Seppo", "locale": "fi_AQ", "surname": "Ilmarinen"}, "file": "fi", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Ilmarinen Seppo"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ja_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "fil", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Prof. Dr. Ada Cornelia César Martín von Brühl Jr, MD DDS"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ja_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "fil", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Brühl, Ada Cornelia César Martín von"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ja_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "fil", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "von Brühl Prof. Dr. Ada Cornelia César Martín Jr, MD DDS"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "fil", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "fil", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "fil", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stöber"}, "file": "fil", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zäzilia Hamish Stöber"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stöber"}, "file": "fil", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Stöber, Zäzilia Hamish"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stöber"}, "file": "fil", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Stöber Zäzilia Hamish"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "fil", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Käthe Müller"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "fil", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Müller, Käthe"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "fil", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Müller Käthe"} {"fields": {"credentials": "MP", "generation": "Jr", "given": "Bertram Wilberforce", "given-informal": "Bertie", "given2": "Henry Robert", "locale": "fil_AQ", "surname-core": "Wooster", "title": "Mr."}, "file": "fil", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Mr. Bertram Wilberforce Henry Robert Wooster Jr, MP"} {"fields": {"credentials": "MP", "generation": "Jr", "given": "Bertram Wilberforce", "given-informal": "Bertie", "given2": "Henry Robert", "locale": "fil_AQ", "surname-core": "Wooster", "title": "Mr."}, "file": "fil", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Wooster, Bertram Wilberforce Henry Robert"} {"fields": {"credentials": "MP", "generation": "Jr", "given": "Bertram Wilberforce", "given-informal": "Bertie", "given2": "Henry Robert", "locale": "fil_AQ", "surname-core": "Wooster", "title": "Mr."}, "file": "fil", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Wooster Mr. Bertram Wilberforce Henry Robert Jr, MP"} {"fields": {"given": "Zendaya", "locale": "fil_AQ"}, "file": "fil", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Zendaya"} {"fields": {"given": "Zendaya", "locale": "fil_AQ"}, "file": "fil", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Zendaya"} {"fields": {"given": "Zendaya", "locale": "fil_AQ"}, "file": "fil", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Zendaya"} {"fields": {"given": "Mary Sue", "given2": "Hamish", "locale": "fil_AQ", "surname": "Watson"}, "file": "fil", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Mary Sue Hamish Watson"} {"fields": {"given": "Mary Sue", "given2": "Hamish", "locale": "fil_AQ", "surname": "Watson"}, "file": "fil", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Watson, Mary Sue Hamish"} {"fields": {"given": "Mary Sue", "given2": "Hamish", "locale": "fil_AQ", "surname": "Watson"}, "file": "fil", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Watson Mary Sue Hamish"} {"fields": {"given": "Irene", "locale": "fil_AQ", "surname": "Adler"}, "file": "fil", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Irene Adler"} {"fields": {"given": "Irene", "locale": "fil_AQ", "surname": "Adler"}, "file": "fil", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Adler, Irene"} {"fields": {"given": "Irene", "locale": "fil_AQ", "surname": "Adler"}, "file": "fil", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Adler Irene"} {"fields": {"credentials": "MD DDS", "generation": "Jr.", "given": "María Florencia", "given-informal": "Flor", "given2": "Martina Cristina", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "fr", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Prof. Dr. María Florencia Martina Cristina von Brühl Jr., MD DDS"} {"fields": {"credentials": "MD DDS", "generation": "Jr.", "given": "María Florencia", "given-informal": "Flor", "given2": "Martina Cristina", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "fr", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Brühl, María Florencia Martina Cristina von"} {"fields": {"credentials": "MD DDS", "generation": "Jr.", "given": "María Florencia", "given-informal": "Flor", "given2": "Martina Cristina", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "fr", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "Prof. Dr. von Brühl María Florencia Martina Cristina Jr., MD DDS"} {"fields": {"given": "Asmar", "locale": "ko_AQ"}, "file": "fr", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Asmar"} {"fields": {"given": "Asmar", "locale": "ko_AQ"}, "file": "fr", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Asmar"} {"fields": {"given": "Asmar", "locale": "ko_AQ"}, "file": "fr", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Asmar"} {"fields": {"given": "Ginevra", "given2": "Molly", "locale": "ko_AQ", "surname": "Weasley"}, "file": "fr", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Ginevra Molly Weasley"} {"fields": {"given": "Ginevra", "given2": "Molly", "locale": "ko_AQ", "surname": "Weasley"}, "file": "fr", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Weasley, Ginevra Molly"} {"fields": {"given": "Ginevra", "given2": "Molly", "locale": "ko_AQ", "surname": "Weasley"}, "file": "fr", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Weasley Ginevra Molly"} {"fields": {"given": "Hermione", "locale": "ko_AQ", "surname": "Granger"}, "file": "fr", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Hermione Granger"} {"fields": {"given": "Hermione", "locale": "ko_AQ", "surname": "Granger"}, "file": "fr", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Granger, Hermione"} {"fields": {"given": "Hermione", "locale": "ko_AQ", "surname": "Granger"}, "file": "fr", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Granger Hermione"} {"fields": {"generation": "fils", "given": "Jean-Nicolas", "given-informal": "Nico", "given2": "Louis Marcel", "locale": "fr_AQ", "surname-core": "Bouchart", "surname-prefix": "de", "title": "M."}, "file": "fr", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "M. Jean-Nicolas Louis Marcel de Bouchart fils"} {"fields": {"generation": "fils", "given": "Jean-Nicolas", "given-informal": "Nico", "given2": "Louis Marcel", "locale": "fr_AQ", "surname-core": "Bouchart", "surname-prefix": "de", "title": "M."}, "file": "fr", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Bouchart, Jean-Nicolas Louis Marcel de"} {"fields": {"generation": "fils", "given": "Jean-Nicolas", "given-informal": "Nico", "given2": "Louis Marcel", "locale": "fr_AQ", "surname-core": "Bouchart", "surname-prefix": "de", "title": "M."}, "file": "fr", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "M. de Bouchart Jean-Nicolas Louis Marcel fils"} {"fields": {"given": "Adèle", "locale": "fr_AQ"}, "file": "fr", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Adèle"} {"fields": {"given": "Adèle", "locale": "fr_AQ"}, "file": "fr", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Adèle"} {"fields": {"given": "Adèle", "locale": "fr_AQ"}, "file": "fr", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Adèle"} {"fields": {"given": "Marie-Agnès", "given2": "Suzanne", "locale": "fr_AQ", "surname": "Gilot"}, "file": "fr", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Marie-Agnès Suzanne Gilot"} {"fields": {"given": "Marie-Agnès", "given2": "Suzanne", "locale": "fr_AQ", "surname": "Gilot"}, "file": "fr", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Gilot, Marie-Agnès Suzanne"} {"fields": {"given": "Marie-Agnès", "given2": "Suzanne", "locale": "fr_AQ", "surname": "Gilot"}, "file": "fr", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Gilot Marie-Agnès Suzanne"} {"fields": {"given": "Louise", "locale": "fr_AQ", "surname": "Péricourt"}, "file": "fr", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Louise Péricourt"} {"fields": {"given": "Louise", "locale": "fr_AQ", "surname": "Péricourt"}, "file": "fr", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Péricourt, Louise"} {"fields": {"given": "Louise", "locale": "fr_AQ", "surname": "Péricourt"}, "file": "fr", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Péricourt Louise"} {"fields": {"credentials": "M.D. Ph.D.", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "ga", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Prof. Dr. Ada Cornelia César Martín von Brühl González Domingo M.D. Ph.D."} {"fields": {"credentials": "M.D. Ph.D.", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "ga", "label": "foreignFull", "order": "sorting", "shape": null, "text": "von Brühl González Domingo, Prof. Dr. Ada Cornelia César Martín M.D. Ph.D."} {"fields": {"credentials": "M.D. Ph.D.", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "ga", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "von Brühl Ada Cornelia César Martín M.D. Ph.D."} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "ga", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "ga", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "ga", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "ga", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zäzilia Hamish Stöber"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "ga", "label": "foreignGGS", "order": "sorting", "shape": 4, "text": "Stöber Zäzilia Hamish"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "ga", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Stöber Zäzilia Hamish"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "ga", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Käthe Müller"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "ga", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "Müller Käthe"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "ga", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Müller Käthe"} {"fields": {"given": "Brigid", "given2": "Aoibhe Sophia", "locale": "ga_AQ", "surname-core": "de Bhulbh", "title": "An tOll. An Dr."}, "file": "ga", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "An tOll. An Dr. Brigid Aoibhe Sophia de Bhulbh"} {"fields": {"given": "Brigid", "given2": "Aoibhe Sophia", "locale": "ga_AQ", "surname-core": "de Bhulbh", "title": "An tOll. An Dr."}, "file": "ga", "label": "nativeFull", "order": "sorting", "shape": 4, "text": "de Bhulbh An tOll. An Dr. Brigid Aoibhe Sophia"} {"fields": {"given": "Brigid", "given2": "Aoibhe Sophia", "locale": "ga_AQ", "surname-core": "de Bhulbh", "title": "An tOll. An Dr."}, "file": "ga", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "de Bhulbh Brigid Aoibhe Sophia"} {"fields": {"given": "Hozier", "locale": "ga_AQ"}, "file": "ga", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Hozier"} {"fields": {"given": "Hozier", "locale": "ga_AQ"}, "file": "ga", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Hozier"} {"fields": {"given": "Hozier", "locale": "ga_AQ"}, "file": "ga", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Hozier"} {"fields": {"given": "Seán", "given2": "Seamas", "locale": "ga_AQ", "surname": "Watson"}, "file": "ga", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Seán Seamas Watson"} {"fields": {"given": "Seán", "given2": "Seamas", "locale": "ga_AQ", "surname": "Watson"}, "file": "ga", "label": "nativeGGS", "order": "sorting", "shape": 4, "text": "Watson Seán Seamas"} {"fields": {"given": "Seán", "given2": "Seamas", "locale": "ga_AQ", "surname": "Watson"}, "file": "ga", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Watson Seán Seamas"} {"fields": {"given": "Irene", "locale": "ga_AQ", "surname": "Adler"}, "file": "ga", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Irene Adler"} {"fields": {"given": "Irene", "locale": "ga_AQ", "surname": "Adler"}, "file": "ga", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "Adler Irene"} {"fields": {"given": "Irene", "locale": "ga_AQ", "surname": "Adler"}, "file": "ga", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Adler Irene"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ja_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "gd", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Prof. Dr. Ada Cornelia César Martín von Brühl González Domingo Jr, MD DDS"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ja_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "gd", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Brühl, Ada Cornelia César Martín von"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ja_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "gd", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "von Brühl González Domingo, Prof. Dr. Ada Cornelia César Martín Jr, MD DDS"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "gd", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "gd", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "gd", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Cecilia", "given2": "Angela", "locale": "ja_AQ", "surname": "Smith"}, "file": "gd", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Cecilia Angela Smith"} {"fields": {"given": "Cecilia", "given2": "Angela", "locale": "ja_AQ", "surname": "Smith"}, "file": "gd", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Smith, Cecilia Angela"} {"fields": {"given": "Cecilia", "given2": "Angela", "locale": "ja_AQ", "surname": "Smith"}, "file": "gd", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Smith Cecilia Angela"} {"fields": {"given": "Kate", "locale": "ja_AQ", "surname": "Miller"}, "file": "gd", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Kate Miller"} {"fields": {"given": "Kate", "locale": "ja_AQ", "surname": "Miller"}, "file": "gd", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Miller, Kate"} {"fields": {"given": "Kate", "locale": "ja_AQ", "surname": "Miller"}, "file": "gd", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Miller Kate"} {"fields": {"credentials": "am post", "given": "Dòmhnall Iain", "given-informal": "Donaidh", "given2": "Bàn", "locale": "gd_AQ", "surname-core": "Mac a’ Ghobhainn", "surname2": "Chorùna", "title": "Mgr."}, "file": "gd", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Mgr. Dòmhnall Iain Bàn Mac a’ Ghobhainn Chorùna am post"} {"fields": {"credentials": "am post", "given": "Dòmhnall Iain", "given-informal": "Donaidh", "given2": "Bàn", "locale": "gd_AQ", "surname-core": "Mac a’ Ghobhainn", "surname2": "Chorùna", "title": "Mgr."}, "file": "gd", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Mac a’ Ghobhainn, Dòmhnall Iain Bàn"} {"fields": {"credentials": "am post", "given": "Dòmhnall Iain", "given-informal": "Donaidh", "given2": "Bàn", "locale": "gd_AQ", "surname-core": "Mac a’ Ghobhainn", "surname2": "Chorùna", "title": "Mgr."}, "file": "gd", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Mac a’ Ghobhainn Chorùna, Mgr. Dòmhnall Iain Bàn am post"} {"fields": {"given": "Calum", "locale": "gd_AQ"}, "file": "gd", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Calum"} {"fields": {"given": "Calum", "locale": "gd_AQ"}, "file": "gd", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Calum"} {"fields": {"given": "Calum", "locale": "gd_AQ"}, "file": "gd", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Calum"} {"fields": {"given": "Dòmhnall Iain", "given2": "Tormod", "locale": "gd_AQ", "surname": "Caimbeul"}, "file": "gd", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Dòmhnall Iain Tormod Caimbeul"} {"fields": {"given": "Dòmhnall Iain", "given2": "Tormod", "locale": "gd_AQ", "surname": "Caimbeul"}, "file": "gd", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Caimbeul, Dòmhnall Iain Tormod"} {"fields": {"given": "Dòmhnall Iain", "given2": "Tormod", "locale": "gd_AQ", "surname": "Caimbeul"}, "file": "gd", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Caimbeul Dòmhnall Iain Tormod"} {"fields": {"given": "Oighrig", "locale": "gd_AQ", "surname": "Dhòmhnallach"}, "file": "gd", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Oighrig Dhòmhnallach"} {"fields": {"given": "Oighrig", "locale": "gd_AQ", "surname": "Dhòmhnallach"}, "file": "gd", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Dhòmhnallach, Oighrig"} {"fields": {"given": "Oighrig", "locale": "gd_AQ", "surname": "Dhòmhnallach"}, "file": "gd", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Dhòmhnallach Oighrig"} {"fields": {"credentials": "MD DDS", "generation": "II", "given": "Erika Cornelia", "given-informal": "Neele", "given2": "Anna Isabelle", "locale": "ko_AQ", "surname-core": "Brül", "surname-prefix": "von", "surname2": "Cabral de Melo", "title": "Dra."}, "file": "gl", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Dra. Erika Cornelia Anna Isabelle von Brül Cabral de Melo II"} {"fields": {"credentials": "MD DDS", "generation": "II", "given": "Erika Cornelia", "given-informal": "Neele", "given2": "Anna Isabelle", "locale": "ko_AQ", "surname-core": "Brül", "surname-prefix": "von", "surname2": "Cabral de Melo", "title": "Dra."}, "file": "gl", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Brül Cabral de Melo, Erika Cornelia Anna Isabelle von"} {"fields": {"credentials": "MD DDS", "generation": "II", "given": "Erika Cornelia", "given-informal": "Neele", "given2": "Anna Isabelle", "locale": "ko_AQ", "surname-core": "Brül", "surname-prefix": "von", "surname2": "Cabral de Melo", "title": "Dra."}, "file": "gl", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "von Brül Erika Cornelia Anna Isabelle II"} {"fields": {"given": "John", "locale": "ko_AQ"}, "file": "gl", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "John"} {"fields": {"given": "John", "locale": "ko_AQ"}, "file": "gl", "label": "foreignG", "order": "sorting", "shape": 4, "text": "John"} {"fields": {"given": "John", "locale": "ko_AQ"}, "file": "gl", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "John"} {"fields": {"given": "Benno", "given2": "Hans", "locale": "ko_AQ", "surname": "Meyer"}, "file": "gl", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Benno Hans Meyer"} {"fields": {"given": "Benno", "given2": "Hans", "locale": "ko_AQ", "surname": "Meyer"}, "file": "gl", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Meyer, Benno Hans"} {"fields": {"given": "Benno", "given2": "Hans", "locale": "ko_AQ", "surname": "Meyer"}, "file": "gl", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Meyer Benno Hans"} {"fields": {"given": "Annelise", "locale": "ko_AQ", "surname": "Weber"}, "file": "gl", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Annelise Weber"} {"fields": {"given": "Annelise", "locale": "ko_AQ", "surname": "Weber"}, "file": "gl", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Weber, Annelise"} {"fields": {"given": "Annelise", "locale": "ko_AQ", "surname": "Weber"}, "file": "gl", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Weber Annelise"} {"fields": {"given": "Xosé Carlos", "given-informal": "Pepe", "given2": "Francisco Xavier", "locale": "gl_AQ", "surname-core": "Castro", "surname-prefix": "de", "surname2": "Sáez", "title": "Sr."}, "file": "gl", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Sr. Xosé Carlos Francisco Xavier de Castro Sáez"} {"fields": {"given": "Xosé Carlos", "given-informal": "Pepe", "given2": "Francisco Xavier", "locale": "gl_AQ", "surname-core": "Castro", "surname-prefix": "de", "surname2": "Sáez", "title": "Sr."}, "file": "gl", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Castro Sáez, Xosé Carlos Francisco Xavier de"} {"fields": {"given": "Xosé Carlos", "given-informal": "Pepe", "given2": "Francisco Xavier", "locale": "gl_AQ", "surname-core": "Castro", "surname-prefix": "de", "surname2": "Sáez", "title": "Sr."}, "file": "gl", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "de Castro Xosé Carlos Francisco Xavier"} {"fields": {"given": "Uxía", "locale": "gl_AQ"}, "file": "gl", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Uxía"} {"fields": {"given": "Uxía", "locale": "gl_AQ"}, "file": "gl", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Uxía"} {"fields": {"given": "Uxía", "locale": "gl_AQ"}, "file": "gl", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Uxía"} {"fields": {"given": "Brais", "given2": "Manoel", "locale": "gl_AQ", "surname": "Souto"}, "file": "gl", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Brais Manoel Souto"} {"fields": {"given": "Brais", "given2": "Manoel", "locale": "gl_AQ", "surname": "Souto"}, "file": "gl", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Souto, Brais Manoel"} {"fields": {"given": "Brais", "given2": "Manoel", "locale": "gl_AQ", "surname": "Souto"}, "file": "gl", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Souto Brais Manoel"} {"fields": {"given": "Xiana", "locale": "gl_AQ", "surname": "Freire"}, "file": "gl", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Xiana Freire"} {"fields": {"given": "Xiana", "locale": "gl_AQ", "surname": "Freire"}, "file": "gl", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Freire, Xiana"} {"fields": {"given": "Xiana", "locale": "gl_AQ", "surname": "Freire"}, "file": "gl", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Freire Xiana"} {"fields": {"credentials": "એમ.ડી. ડી.ડી.એસ.", "generation": "જૂનિયર", "given": "એડા કૉર્નેલિયા", "given-informal": "નીલ", "given2": "સિઝર માર્ટિન", "locale": "ko_AQ", "surname-core": "બ્રાઑલ્", "surname-prefix": "ફૉન્", "surname2": "ગૉન્ઝાલિસ ડમિંગો", "title": "પ્રો. ડૉ."}, "file": "gu", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "પ્રો. ડૉ. એડા કૉર્નેલિયા સિઝર માર્ટિન ફૉન્ બ્રાઑલ્ જૂનિયર, એમ.ડી. ડી.ડી.એસ."} {"fields": {"credentials": "એમ.ડી. ડી.ડી.એસ.", "generation": "જૂનિયર", "given": "એડા કૉર્નેલિયા", "given-informal": "નીલ", "given2": "સિઝર માર્ટિન", "locale": "ko_AQ", "surname-core": "બ્રાઑલ્", "surname-prefix": "ફૉન્", "surname2": "ગૉન્ઝાલિસ ડમિંગો", "title": "પ્રો. ડૉ."}, "file": "gu", "label": "foreignFull", "order": "sorting", "shape": null, "text": "બ્રાઑલ્, એડા કૉર્નેલિયા સિઝર માર્ટિન ફૉન્"} {"fields": {"credentials": "એમ.ડી. ડી.ડી.એસ.", "generation": "જૂનિયર", "given": "એડા કૉર્નેલિયા", "given-informal": "નીલ", "given2": "સિઝર માર્ટિન", "locale": "ko_AQ", "surname-core": "બ્રાઑલ્", "surname-prefix": "ફૉન્", "surname2": "ગૉન્ઝાલિસ ડમિંગો", "title": "પ્રો. ડૉ."}, "file": "gu", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "ફૉન્ બ્રાઑલ્ પ્રો. ડૉ. એડા કૉર્નેલિયા સિઝર માર્ટિન જૂનિયર, એમ.ડી. ડી.ડી.એસ."} {"fields": {"given": "સિનબાદ", "locale": "ko_AQ"}, "file": "gu", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "સિનબાદ"} {"fields": {"given": "સિનબાદ", "locale": "ko_AQ"}, "file": "gu", "label": "foreignG", "order": "sorting", "shape": 4, "text": "સિનબાદ"} {"fields": {"given": "સિનબાદ", "locale": "ko_AQ"}, "file": "gu", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "સિનબાદ"} {"fields": {"given": "જઝિલીયા", "given2": "હૅમીશ", "locale": "ko_AQ", "surname": "સ્ટૂબર"}, "file": "gu", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "જઝિલીયા હૅમીશ સ્ટૂબર"} {"fields": {"given": "જઝિલીયા", "given2": "હૅમીશ", "locale": "ko_AQ", "surname": "સ્ટૂબર"}, "file": "gu", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "સ્ટૂબર, જઝિલીયા હૅમીશ"} {"fields": {"given": "જઝિલીયા", "given2": "હૅમીશ", "locale": "ko_AQ", "surname": "સ્ટૂબર"}, "file": "gu", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "સ્ટૂબર જઝિલીયા હૅમીશ"} {"fields": {"given": "કૅથિ", "locale": "ko_AQ", "surname": "મુલર"}, "file": "gu", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "કૅથિ મુલર"} {"fields": {"given": "કૅથિ", "locale": "ko_AQ", "surname": "મુલર"}, "file": "gu", "label": "foreignGS", "order": "sorting", "shape": null, "text": "મુલર, કૅથિ"} {"fields": {"given": "કૅથિ", "locale": "ko_AQ", "surname": "મુલર"}, "file": "gu", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "મુલર કૅથિ"} {"fields": {"credentials": "એમપી", "generation": "જૂનિયર", "given": "પટેલ ખુશી", "given-informal": "કિંજલ", "given2": "કિંજલ", "locale": "gu_AQ", "surname-core": "શાહ", "surname-prefix": "કુમાર", "surname2": "પટેલ", "title": "શ્રી."}, "file": "gu", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "શ્રી. પટેલ ખુશી કિંજલ કુમાર શાહ જૂનિયર, એમપી"} {"fields": {"credentials": "એમપી", "generation": "જૂનિયર", "given": "પટેલ ખુશી", "given-informal": "કિંજલ", "given2": "કિંજલ", "locale": "gu_AQ", "surname-core": "શાહ", "surname-prefix": "કુમાર", "surname2": "પટેલ", "title": "શ્રી."}, "file": "gu", "label": "nativeFull", "order": "sorting", "shape": null, "text": "શાહ, પટેલ ખુશી કિંજલ કુમાર"} {"fields": {"credentials": "એમપી", "generation": "જૂનિયર", "given": "પટેલ ખુશી", "given-informal": "કિંજલ", "given2": "કિંજલ", "locale": "gu_AQ", "surname-core": "શાહ", "surname-prefix": "કુમાર", "surname2": "પટેલ", "title": "શ્રી."}, "file": "gu", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "કુમાર શાહ શ્રી. પટેલ ખુશી કિંજલ જૂનિયર, એમપી"} {"fields": {"given": "કિરણ", "locale": "gu_AQ"}, "file": "gu", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "કિરણ"} {"fields": {"given": "કિરણ", "locale": "gu_AQ"}, "file": "gu", "label": "nativeG", "order": "sorting", "shape": 4, "text": "કિરણ"} {"fields": {"given": "કિરણ", "locale": "gu_AQ"}, "file": "gu", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "કિરણ"} {"fields": {"given": "ખુશી પટેલ", "given2": "હેતલ", "locale": "gu_AQ", "surname": "દવે"}, "file": "gu", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "ખુશી પટેલ હેતલ દવે"} {"fields": {"given": "ખુશી પટેલ", "given2": "હેતલ", "locale": "gu_AQ", "surname": "દવે"}, "file": "gu", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "દવે, ખુશી પટેલ હેતલ"} {"fields": {"given": "ખુશી પટેલ", "given2": "હેતલ", "locale": "gu_AQ", "surname": "દવે"}, "file": "gu", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "દવે ખુશી પટેલ હેતલ"} {"fields": {"given": "આઇરીન", "locale": "gu_AQ", "surname": "અદાણી"}, "file": "gu", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "આઇરીન અદાણી"} {"fields": {"given": "આઇરીન", "locale": "gu_AQ", "surname": "અદાણી"}, "file": "gu", "label": "nativeGS", "order": "sorting", "shape": null, "text": "અદાણી, આઇરીન"} {"fields": {"given": "આઇરીન", "locale": "gu_AQ", "surname": "અદાણી"}, "file": "gu", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "અદાણી આઇરીન"} {"fields": {"credentials": "M.D. Ph.D.", "generation": "Ƙarami", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "Eva Sophia", "locale": "ko_AQ", "surname-core": "Wolf", "surname-prefix": "van den", "surname2": "Becker Schmidt", "title": "Farf. Dr."}, "file": "ha", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Farf. Dr. Ada Cornelia Eva Sophia van den Wolf Becker Schmidt M.D. Ph.D."} {"fields": {"credentials": "M.D. Ph.D.", "generation": "Ƙarami", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "Eva Sophia", "locale": "ko_AQ", "surname-core": "Wolf", "surname-prefix": "van den", "surname2": "Becker Schmidt", "title": "Farf. Dr."}, "file": "ha", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Wolf, Ada Cornelia Eva Sophia van den"} {"fields": {"credentials": "M.D. Ph.D.", "generation": "Ƙarami", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "Eva Sophia", "locale": "ko_AQ", "surname-core": "Wolf", "surname-prefix": "van den", "surname2": "Becker Schmidt", "title": "Farf. Dr."}, "file": "ha", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "van den Wolf Becker Schmidt Farf. Dr. Ada Cornelia Eva Sophia M.D. Ph.D."} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "ha", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "ha", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "ha", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "ha", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zäzilia Hamish Stöber"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "ha", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Stöber, Zäzilia Hamish"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "ha", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Stöber Zäzilia Hamish"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "ha", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Käthe Müller"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "ha", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Müller, Käthe"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "ha", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Müller Käthe"} {"fields": {"credentials": "MP", "given": "Shehu", "given2": "Bello", "locale": "ha_AQ", "surname-core": "Modibbo", "title": "Malam"}, "file": "ha", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Malam Shehu Bello Modibbo MP"} {"fields": {"credentials": "MP", "given": "Shehu", "given2": "Bello", "locale": "ha_AQ", "surname-core": "Modibbo", "title": "Malam"}, "file": "ha", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Modibbo, Shehu Bello"} {"fields": {"credentials": "MP", "given": "Shehu", "given2": "Bello", "locale": "ha_AQ", "surname-core": "Modibbo", "title": "Malam"}, "file": "ha", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Modibbo Malam Shehu Bello MP"} {"fields": {"given": "Sule", "locale": "ha_AQ"}, "file": "ha", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Sule"} {"fields": {"given": "Sule", "locale": "ha_AQ"}, "file": "ha", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Sule"} {"fields": {"given": "Sule", "locale": "ha_AQ"}, "file": "ha", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Sule"} {"fields": {"given": "Fatima", "given2": "Amina", "locale": "ha_AQ", "surname": "Umar"}, "file": "ha", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Fatima Amina Umar"} {"fields": {"given": "Fatima", "given2": "Amina", "locale": "ha_AQ", "surname": "Umar"}, "file": "ha", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Umar, Fatima Amina"} {"fields": {"given": "Fatima", "given2": "Amina", "locale": "ha_AQ", "surname": "Umar"}, "file": "ha", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Umar Fatima Amina"} {"fields": {"given": "Shehu", "locale": "ha_AQ", "surname": "Muhammed"}, "file": "ha", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Shehu Muhammed"} {"fields": {"given": "Shehu", "locale": "ha_AQ", "surname": "Muhammed"}, "file": "ha", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Muhammed, Shehu"} {"fields": {"given": "Shehu", "locale": "ha_AQ", "surname": "Muhammed"}, "file": "ha", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Muhammed Shehu"} {"fields": {"credentials": "ד״ר", "generation": "ג׳וניור", "given": "עדה קורנליה", "given-informal": "ניל", "given2": "סזאר מרטין", "locale": "ko_AQ", "surname-core": "ברוהל", "surname-prefix": "פון", "surname2": "גונזלס דומינגו", "title": "פרופ׳"}, "file": "he", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "פרופ׳ עדה קורנליה סזאר מרטין פון ברוהל גונזלס דומינגו ד״ר"} {"fields": {"credentials": "ד״ר", "generation": "ג׳וניור", "given": "עדה קורנליה", "given-informal": "ניל", "given2": "סזאר מרטין", "locale": "ko_AQ", "surname-core": "ברוהל", "surname-prefix": "פון", "surname2": "גונזלס דומינגו", "title": "פרופ׳"}, "file": "he", "label": "foreignFull", "order": "sorting", "shape": null, "text": "פון ברוהל גונזלס דומינגו, פרופ׳ עדה קורנליה סזאר מרטין ד״ר"} {"fields": {"credentials": "ד״ר", "generation": "ג׳וניור", "given": "עדה קורנליה", "given-informal": "ניל", "given2": "סזאר מרטין", "locale": "ko_AQ", "surname-core": "ברוהל", "surname-prefix": "פון", "surname2": "גונזלס דומינגו", "title": "פרופ׳"}, "file": "he", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "פון ברוהל גונזלס דומינגו פרופ׳ עדה קורנליה סזאר מרטין ד״ר"} {"fields": {"given": "סינבד", "locale": "ko_AQ"}, "file": "he", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "סינבד"} {"fields": {"given": "סינבד", "locale": "ko_AQ"}, "file": "he", "label": "foreignG", "order": "sorting", "shape": 4, "text": "סינבד"} {"fields": {"given": "סינבד", "locale": "ko_AQ"}, "file": "he", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "סינבד"} {"fields": {"given": "זזיליה", "given2": "חמיש", "locale": "ko_AQ", "surname": "סטובר"}, "file": "he", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "זזיליה חמיש סטובר"} {"fields": {"given": "זזיליה", "given2": "חמיש", "locale": "ko_AQ", "surname": "סטובר"}, "file": "he", "label": "foreignGGS", "order": "sorting", "shape": 4, "text": "סטובר זזיליה חמיש"} {"fields": {"given": "זזיליה", "given2": "חמיש", "locale": "ko_AQ", "surname": "סטובר"}, "file": "he", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "סטובר זזיליה חמיש"} {"fields": {"given": "קתה", "locale": "ko_AQ", "surname": "מולר"}, "file": "he", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "קתה מולר"} {"fields": {"given": "קתה", "locale": "ko_AQ", "surname": "מולר"}, "file": "he", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "מולר קתה"} {"fields": {"given": "קתה", "locale": "ko_AQ", "surname": "מולר"}, "file": "he", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "מולר קתה"} {"fields": {"credentials": "ח״כ", "generation": "ג׳וניור", "given": "בנימין", "given-informal": "בני", "given2": "משה", "locale": "he_AQ", "surname-core": "אבן-גבירול", "surname-prefix": "אבן", "title": "מר"}, "file": "he", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "מר בנימין משה אבן אבן-גבירול ח״כ"} {"fields": {"credentials": "ח״כ", "generation": "ג׳וניור", "given": "בנימין", "given-informal": "בני", "given2": "משה", "locale": "he_AQ", "surname-core": "אבן-גבירול", "surname-prefix": "אבן", "title": "מר"}, "file": "he", "label": "nativeFull", "order": "sorting", "shape": 4, "text": "אבן אבן-גבירול מר בנימין משה ח״כ"} {"fields": {"credentials": "ח״כ", "generation": "ג׳וניור", "given": "בנימין", "given-informal": "בני", "given2": "משה", "locale": "he_AQ", "surname-core": "אבן-גבירול", "surname-prefix": "אבן", "title": "מר"}, "file": "he", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "אבן אבן-גבירול מר בנימין משה ח״כ"} {"fields": {"given": "יונתן", "locale": "he_AQ"}, "file": "he", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "יונתן"} {"fields": {"given": "יונתן", "locale": "he_AQ"}, "file": "he", "label": "nativeG", "order": "sorting", "shape": 4, "text": "יונתן"} {"fields": {"given": "יונתן", "locale": "he_AQ"}, "file": "he", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "יונתן"} {"fields": {"given": "יונתן", "given2": "חיים", "locale": "he_AQ", "surname": "כהן"}, "file": "he", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "יונתן חיים כהן"} {"fields": {"given": "יונתן", "given2": "חיים", "locale": "he_AQ", "surname": "כהן"}, "file": "he", "label": "nativeGGS", "order": "sorting", "shape": 4, "text": "כהן יונתן חיים"} {"fields": {"given": "יונתן", "given2": "חיים", "locale": "he_AQ", "surname": "כהן"}, "file": "he", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "כהן יונתן חיים"} {"fields": {"given": "דנית", "locale": "he_AQ", "surname": "לוי"}, "file": "he", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "דנית לוי"} {"fields": {"given": "דנית", "locale": "he_AQ", "surname": "לוי"}, "file": "he", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "לוי דנית"} {"fields": {"given": "דנית", "locale": "he_AQ", "surname": "לוי"}, "file": "he", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "לוי דנית"} {"fields": {"credentials": "एम॰डी॰, पी॰एच॰डी॰", "generation": "जूनियर", "given": "माइकल", "given-informal": "माइक", "given2": "जॉन", "locale": "ko_AQ", "surname-core": "वॉटसन", "surname-prefix": "वॉन", "surname2": "चेस्टर", "title": "प्रो॰ डॉ॰"}, "file": "hi", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "प्रो॰ डॉ॰ माइकल जॉन वॉन वॉटसन चेस्टर, एम॰डी॰, पी॰एच॰डी॰"} {"fields": {"credentials": "एम॰डी॰, पी॰एच॰डी॰", "generation": "जूनियर", "given": "माइकल", "given-informal": "माइक", "given2": "जॉन", "locale": "ko_AQ", "surname-core": "वॉटसन", "surname-prefix": "वॉन", "surname2": "चेस्टर", "title": "प्रो॰ डॉ॰"}, "file": "hi", "label": "foreignFull", "order": "sorting", "shape": null, "text": "वॉन वॉटसन, माइकल जॉन एम॰डी॰, पी॰एच॰डी॰"} {"fields": {"credentials": "एम॰डी॰, पी॰एच॰डी॰", "generation": "जूनियर", "given": "माइकल", "given-informal": "माइक", "given2": "जॉन", "locale": "ko_AQ", "surname-core": "वॉटसन", "surname-prefix": "वॉन", "surname2": "चेस्टर", "title": "प्रो॰ डॉ॰"}, "file": "hi", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "प्रो॰ डॉ॰ वॉन वॉटसन माइकल जॉन, एम॰डी॰, पी॰एच॰डी॰"} {"fields": {"given": "रॉबर्ट", "locale": "ko_AQ"}, "file": "hi", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "रॉबर्ट"} {"fields": {"given": "रॉबर्ट", "locale": "ko_AQ"}, "file": "hi", "label": "foreignG", "order": "sorting", "shape": 4, "text": "रॉबर्ट"} {"fields": {"given": "रॉबर्ट", "locale": "ko_AQ"}, "file": "hi", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "रॉबर्ट"} {"fields": {"given": "मैरी", "given2": "सिलविया", "locale": "ko_AQ", "surname": "जोनस"}, "file": "hi", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "मैरी सिलविया जोनस"} {"fields": {"given": "मैरी", "given2": "सिलविया", "locale": "ko_AQ", "surname": "जोनस"}, "file": "hi", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "जोनस, मैरी सिलविया"} {"fields": {"given": "मैरी", "given2": "सिलविया", "locale": "ko_AQ", "surname": "जोनस"}, "file": "hi", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "जोनस मैरी सिलविया"} {"fields": {"given": "कीथ", "locale": "ko_AQ", "surname": "ब्राउन"}, "file": "hi", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "कीथ ब्राउन"} {"fields": {"given": "कीथ", "locale": "ko_AQ", "surname": "ब्राउन"}, "file": "hi", "label": "foreignGS", "order": "sorting", "shape": null, "text": "ब्राउन, कीथ"} {"fields": {"given": "कीथ", "locale": "ko_AQ", "surname": "ब्राउन"}, "file": "hi", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "ब्राउन कीथ"} {"fields": {"credentials": "एम॰डी॰", "generation": "द्वितीय", "given": "ललित", "given-informal": "लखन", "given2": "कुमार", "locale": "hi_AQ", "surname-core": "शर्मा", "title": "श्री"}, "file": "hi", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "श्री ललित कुमार शर्मा एम॰डी॰"} {"fields": {"credentials": "एम॰डी॰", "generation": "द्वितीय", "given": "ललित", "given-informal": "लखन", "given2": "कुमार", "locale": "hi_AQ", "surname-core": "शर्मा", "title": "श्री"}, "file": "hi", "label": "nativeFull", "order": "sorting", "shape": null, "text": "शर्मा, ललित कुमार एम॰डी॰"} {"fields": {"credentials": "एम॰डी॰", "generation": "द्वितीय", "given": "ललित", "given-informal": "लखन", "given2": "कुमार", "locale": "hi_AQ", "surname-core": "शर्मा", "title": "श्री"}, "file": "hi", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "श्री शर्मा ललित कुमार, एम॰डी॰"} {"fields": {"given": "ललित", "locale": "hi_AQ"}, "file": "hi", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "ललित"} {"fields": {"given": "ललित", "locale": "hi_AQ"}, "file": "hi", "label": "nativeG", "order": "sorting", "shape": 4, "text": "ललित"} {"fields": {"given": "ललित", "locale": "hi_AQ"}, "file": "hi", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "ललित"} {"fields": {"given": "ललित", "given2": "मोहन", "locale": "hi_AQ", "surname": "शर्मा"}, "file": "hi", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "ललित मोहन शर्मा"} {"fields": {"given": "ललित", "given2": "मोहन", "locale": "hi_AQ", "surname": "शर्मा"}, "file": "hi", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "शर्मा, ललित मोहन"} {"fields": {"given": "ललित", "given2": "मोहन", "locale": "hi_AQ", "surname": "शर्मा"}, "file": "hi", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "शर्मा ललित मोहन"} {"fields": {"given": "ललित", "locale": "hi_AQ", "surname": "शर्मा"}, "file": "hi", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "ललित शर्मा"} {"fields": {"given": "ललित", "locale": "hi_AQ", "surname": "शर्मा"}, "file": "hi", "label": "nativeGS", "order": "sorting", "shape": null, "text": "शर्मा, ललित"} {"fields": {"given": "ललित", "locale": "hi_AQ", "surname": "शर्मा"}, "file": "hi", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "शर्मा ललित"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "Cesar Martin", "locale": "ko_AQ", "surname-core": "Bruhl", "surname-prefix": "von", "surname2": "Gonzalez Domingo", "title": "Prof. Dr."}, "file": "hi_Latn", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Prof. Dr. Ada Cornelia Cesar Martin von Bruhl Jr, MD DDS"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "Cesar Martin", "locale": "ko_AQ", "surname-core": "Bruhl", "surname-prefix": "von", "surname2": "Gonzalez Domingo", "title": "Prof. Dr."}, "file": "hi_Latn", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Bruhl, Ada Cornelia Cesar Martin von"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "Cesar Martin", "locale": "ko_AQ", "surname-core": "Bruhl", "surname-prefix": "von", "surname2": "Gonzalez Domingo", "title": "Prof. Dr."}, "file": "hi_Latn", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "von Bruhl Prof. Dr. Ada Cornelia Cesar Martin Jr, MD DDS"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "hi_Latn", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "hi_Latn", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "hi_Latn", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Zazilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stober"}, "file": "hi_Latn", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zazilia Hamish Stober"} {"fields": {"given": "Zazilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stober"}, "file": "hi_Latn", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Stober, Zazilia Hamish"} {"fields": {"given": "Zazilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stober"}, "file": "hi_Latn", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Stober Zazilia Hamish"} {"fields": {"given": "Kathe", "locale": "ko_AQ", "surname": "Muller"}, "file": "hi_Latn", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Kathe Muller"} {"fields": {"given": "Kathe", "locale": "ko_AQ", "surname": "Muller"}, "file": "hi_Latn", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Muller, Kathe"} {"fields": {"given": "Kathe", "locale": "ko_AQ", "surname": "Muller"}, "file": "hi_Latn", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Muller Kathe"} {"fields": {"credentials": "MP", "generation": "Jr", "given": "Lalit", "given-informal": "Lakhan", "given2": "Kumaar", "locale": "hi_Latn_AQ", "surname-core": "Bharati", "title": "Shree"}, "file": "hi_Latn", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Shree Lalit Kumaar Bharati Jr, MP"} {"fields": {"credentials": "MP", "generation": "Jr", "given": "Lalit", "given-informal": "Lakhan", "given2": "Kumaar", "locale": "hi_Latn_AQ", "surname-core": "Bharati", "title": "Shree"}, "file": "hi_Latn", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Bharati, Lalit Kumaar"} {"fields": {"credentials": "MP", "generation": "Jr", "given": "Lalit", "given-informal": "Lakhan", "given2": "Kumaar", "locale": "hi_Latn_AQ", "surname-core": "Bharati", "title": "Shree"}, "file": "hi_Latn", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Bharati Shree Lalit Kumaar Jr, MP"} {"fields": {"given": "Lalit", "locale": "hi_Latn_AQ"}, "file": "hi_Latn", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Lalit"} {"fields": {"given": "Lalit", "locale": "hi_Latn_AQ"}, "file": "hi_Latn", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Lalit"} {"fields": {"given": "Lalit", "locale": "hi_Latn_AQ"}, "file": "hi_Latn", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Lalit"} {"fields": {"given": "Lalit", "given2": "Mohan", "locale": "hi_Latn_AQ", "surname": "Bharati"}, "file": "hi_Latn", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Lalit Mohan Bharati"} {"fields": {"given": "Lalit", "given2": "Mohan", "locale": "hi_Latn_AQ", "surname": "Bharati"}, "file": "hi_Latn", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Bharati, Lalit Mohan"} {"fields": {"given": "Lalit", "given2": "Mohan", "locale": "hi_Latn_AQ", "surname": "Bharati"}, "file": "hi_Latn", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Bharati Lalit Mohan"} {"fields": {"given": "Lalit", "locale": "hi_Latn_AQ", "surname": "Bharati"}, "file": "hi_Latn", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Lalit Bharati"} {"fields": {"given": "Lalit", "locale": "hi_Latn_AQ", "surname": "Bharati"}, "file": "hi_Latn", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Bharati, Lalit"} {"fields": {"given": "Lalit", "locale": "hi_Latn_AQ", "surname": "Bharati"}, "file": "hi_Latn", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Bharati Lalit"} {"fields": {"credentials": "univ. bacc", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ja_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "prof. dr."}, "file": "hr", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "prof. dr. Ada Cornelia César Martín von Brühl Jr, univ. bacc"} {"fields": {"credentials": "univ. bacc", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ja_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "prof. dr."}, "file": "hr", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Brühl, Ada Cornelia César Martín von"} {"fields": {"credentials": "univ. bacc", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ja_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "prof. dr."}, "file": "hr", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "von Brühl, prof. dr. Ada Cornelia César Martín Jr, univ. bacc"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "hr", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "hr", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "hr", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stöber"}, "file": "hr", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zäzilia Hamish Stöber"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stöber"}, "file": "hr", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Stöber, Zäzilia Hamish"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stöber"}, "file": "hr", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Stöber, Zäzilia Hamish"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "hr", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Käthe Müller"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "hr", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Müller, Käthe"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "hr", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Müller, Käthe"} {"fields": {"credentials": "prof.", "generation": "mlađi", "given": "Branko", "given-informal": "Brane", "given2": "Hrvoje", "locale": "hr_AQ", "surname-core": "Marić", "title": "dr. sc."}, "file": "hr", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "dr. sc. Branko Hrvoje Marić mlađi, prof."} {"fields": {"credentials": "prof.", "generation": "mlađi", "given": "Branko", "given-informal": "Brane", "given2": "Hrvoje", "locale": "hr_AQ", "surname-core": "Marić", "title": "dr. sc."}, "file": "hr", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Marić, Branko Hrvoje"} {"fields": {"credentials": "prof.", "generation": "mlađi", "given": "Branko", "given-informal": "Brane", "given2": "Hrvoje", "locale": "hr_AQ", "surname-core": "Marić", "title": "dr. sc."}, "file": "hr", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Marić, dr. sc. Branko Hrvoje mlađi, prof."} {"fields": {"given": "Zrinka", "locale": "hr_AQ"}, "file": "hr", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Zrinka"} {"fields": {"given": "Zrinka", "locale": "hr_AQ"}, "file": "hr", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Zrinka"} {"fields": {"given": "Zrinka", "locale": "hr_AQ"}, "file": "hr", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Zrinka"} {"fields": {"given": "Marijana", "given2": "Tomislav", "locale": "hr_AQ", "surname": "Babić"}, "file": "hr", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Marijana Tomislav Babić"} {"fields": {"given": "Marijana", "given2": "Tomislav", "locale": "hr_AQ", "surname": "Babić"}, "file": "hr", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Babić, Marijana Tomislav"} {"fields": {"given": "Marijana", "given2": "Tomislav", "locale": "hr_AQ", "surname": "Babić"}, "file": "hr", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Babić, Marijana Tomislav"} {"fields": {"given": "Irena", "locale": "hr_AQ", "surname": "Horvat"}, "file": "hr", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Irena Horvat"} {"fields": {"given": "Irena", "locale": "hr_AQ", "surname": "Horvat"}, "file": "hr", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Horvat, Irena"} {"fields": {"given": "Irena", "locale": "hr_AQ", "surname": "Horvat"}, "file": "hr", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Horvat, Irena"} {"fields": {"credentials": "m.d. ph.d.", "generation": "jr.", "given": "Anna Cornelia", "given-informal": "Nele", "given2": "Eva Sophia", "locale": "ko_AQ", "surname-core": "Wolf", "surname-prefix": "van den", "surname2": "Becker Schmidt", "title": "prof. dr."}, "file": "hsb", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "prof. dr. Anna Cornelia Eva Sophia van den Wolf jr. m.d. ph.d."} {"fields": {"credentials": "m.d. ph.d.", "generation": "jr.", "given": "Anna Cornelia", "given-informal": "Nele", "given2": "Eva Sophia", "locale": "ko_AQ", "surname-core": "Wolf", "surname-prefix": "van den", "surname2": "Becker Schmidt", "title": "prof. dr."}, "file": "hsb", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Wolf, Anna Cornelia Eva Sophia van den"} {"fields": {"credentials": "m.d. ph.d.", "generation": "jr.", "given": "Anna Cornelia", "given-informal": "Nele", "given2": "Eva Sophia", "locale": "ko_AQ", "surname-core": "Wolf", "surname-prefix": "van den", "surname2": "Becker Schmidt", "title": "prof. dr."}, "file": "hsb", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "van den Wolf, prof. dr. Anna Cornelia Eva Sophia jr. m.d. ph.d."} {"fields": {"given": "Tomás", "locale": "ko_AQ"}, "file": "hsb", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Tomás"} {"fields": {"given": "Tomás", "locale": "ko_AQ"}, "file": "hsb", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Tomás"} {"fields": {"given": "Tomás", "locale": "ko_AQ"}, "file": "hsb", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Tomás"} {"fields": {"given": "Kjetil", "given2": "Bjørn", "locale": "ko_AQ", "surname": "Løseth"}, "file": "hsb", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Kjetil Bjørn Løseth"} {"fields": {"given": "Kjetil", "given2": "Bjørn", "locale": "ko_AQ", "surname": "Løseth"}, "file": "hsb", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Løseth, Kjetil Bjørn"} {"fields": {"given": "Kjetil", "given2": "Bjørn", "locale": "ko_AQ", "surname": "Løseth"}, "file": "hsb", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Løseth, Kjetil Bjørn"} {"fields": {"given": "Adélaïde", "locale": "ko_AQ", "surname": "Lemaître"}, "file": "hsb", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Adélaïde Lemaître"} {"fields": {"given": "Adélaïde", "locale": "ko_AQ", "surname": "Lemaître"}, "file": "hsb", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Lemaître, Adélaïde"} {"fields": {"given": "Adélaïde", "locale": "ko_AQ", "surname": "Lemaître"}, "file": "hsb", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Lemaître, Adélaïde"} {"fields": {"credentials": "phd", "generation": "jr.", "given": "Jěwa-Marja", "given-informal": "Jěwa", "given2": "Hańža", "locale": "hsb_AQ", "surname-core": "Wićazowa", "title": "dr."}, "file": "hsb", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "dr. Jěwa-Marja Hańža Wićazowa jr. phd"} {"fields": {"credentials": "phd", "generation": "jr.", "given": "Jěwa-Marja", "given-informal": "Jěwa", "given2": "Hańža", "locale": "hsb_AQ", "surname-core": "Wićazowa", "title": "dr."}, "file": "hsb", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Wićazowa, Jěwa-Marja Hańža"} {"fields": {"credentials": "phd", "generation": "jr.", "given": "Jěwa-Marja", "given-informal": "Jěwa", "given2": "Hańža", "locale": "hsb_AQ", "surname-core": "Wićazowa", "title": "dr."}, "file": "hsb", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Wićazowa, dr. Jěwa-Marja Hańža jr. phd"} {"fields": {"given": "Jurij", "locale": "hsb_AQ"}, "file": "hsb", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Jurij"} {"fields": {"given": "Jurij", "locale": "hsb_AQ"}, "file": "hsb", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Jurij"} {"fields": {"given": "Jurij", "locale": "hsb_AQ"}, "file": "hsb", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Jurij"} {"fields": {"given": "Jan", "given2": "Beno", "locale": "hsb_AQ", "surname": "Krawc"}, "file": "hsb", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Jan Beno Krawc"} {"fields": {"given": "Jan", "given2": "Beno", "locale": "hsb_AQ", "surname": "Krawc"}, "file": "hsb", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Krawc, Jan Beno"} {"fields": {"given": "Jan", "given2": "Beno", "locale": "hsb_AQ", "surname": "Krawc"}, "file": "hsb", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Krawc, Jan Beno"} {"fields": {"given": "Hanka", "locale": "hsb_AQ", "surname": "Šołćina"}, "file": "hsb", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Hanka Šołćina"} {"fields": {"given": "Hanka", "locale": "hsb_AQ", "surname": "Šołćina"}, "file": "hsb", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Šołćina, Hanka"} {"fields": {"given": "Hanka", "locale": "hsb_AQ", "surname": "Šołćina"}, "file": "hsb", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Šołćina, Hanka"} {"fields": {"credentials": "M.D. Ph.D.", "generation": "ifj.", "given": "Luca Sarolta", "given-informal": "Katus", "given2": "Éva Erzsébet", "locale": "fr_AQ", "surname-core": "Kovács", "surname-prefix": "von", "surname2": "Sallói-Horváth", "title": "Prof. Dr."}, "file": "hu", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Luca Sarolta Éva Erzsébet von Kovács M.D. Ph.D."} {"fields": {"credentials": "M.D. Ph.D.", "generation": "ifj.", "given": "Luca Sarolta", "given-informal": "Katus", "given2": "Éva Erzsébet", "locale": "fr_AQ", "surname-core": "Kovács", "surname-prefix": "von", "surname2": "Sallói-Horváth", "title": "Prof. Dr."}, "file": "hu", "label": "foreignFull", "order": "sorting", "shape": null, "text": "von Kovács, Luca Sarolta Éva Erzsébet M.D. Ph.D."} {"fields": {"credentials": "M.D. Ph.D.", "generation": "ifj.", "given": "Luca Sarolta", "given-informal": "Katus", "given2": "Éva Erzsébet", "locale": "fr_AQ", "surname-core": "Kovács", "surname-prefix": "von", "surname2": "Sallói-Horváth", "title": "Prof. Dr."}, "file": "hu", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "von Kovács Luca Sarolta Éva Erzsébet M.D. Ph.D."} {"fields": {"given": "Sinbad", "locale": "fr_AQ"}, "file": "hu", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "fr_AQ"}, "file": "hu", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "fr_AQ"}, "file": "hu", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "fr_AQ", "surname": "Stöber"}, "file": "hu", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zäzilia Hamish Stöber"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "fr_AQ", "surname": "Stöber"}, "file": "hu", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Stöber, Zäzilia Hamish"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "fr_AQ", "surname": "Stöber"}, "file": "hu", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Stöber Zäzilia Hamish"} {"fields": {"given": "Käthe", "locale": "fr_AQ", "surname": "Müller"}, "file": "hu", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Käthe Müller"} {"fields": {"given": "Käthe", "locale": "fr_AQ", "surname": "Müller"}, "file": "hu", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Müller, Käthe"} {"fields": {"given": "Käthe", "locale": "fr_AQ", "surname": "Müller"}, "file": "hu", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Müller Käthe"} {"fields": {"generation": "ifj.", "given": "Bertram Wilberforce", "given-informal": "Bertie", "given2": "Henry Robert", "locale": "hu_AQ", "surname-core": "Wooster"}, "file": "hu", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Bertram Wilberforce Henry Robert Wooster"} {"fields": {"generation": "ifj.", "given": "Bertram Wilberforce", "given-informal": "Bertie", "given2": "Henry Robert", "locale": "hu_AQ", "surname-core": "Wooster"}, "file": "hu", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Wooster, Bertram Wilberforce Henry Robert"} {"fields": {"generation": "ifj.", "given": "Bertram Wilberforce", "given-informal": "Bertie", "given2": "Henry Robert", "locale": "hu_AQ", "surname-core": "Wooster"}, "file": "hu", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Wooster Bertram Wilberforce Henry Robert"} {"fields": {"given": "Vuk", "locale": "hu_AQ"}, "file": "hu", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Vuk"} {"fields": {"given": "Vuk", "locale": "hu_AQ"}, "file": "hu", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Vuk"} {"fields": {"given": "Vuk", "locale": "hu_AQ"}, "file": "hu", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Vuk"} {"fields": {"given": "Máté", "given2": "Bendegúz", "locale": "hu_AQ", "surname": "Szabados"}, "file": "hu", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Máté Bendegúz Szabados"} {"fields": {"given": "Máté", "given2": "Bendegúz", "locale": "hu_AQ", "surname": "Szabados"}, "file": "hu", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Szabados, Máté Bendegúz"} {"fields": {"given": "Máté", "given2": "Bendegúz", "locale": "hu_AQ", "surname": "Szabados"}, "file": "hu", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Szabados Máté Bendegúz"} {"fields": {"given": "Dóra", "locale": "hu_AQ", "surname": "Mátrai"}, "file": "hu", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Dóra Mátrai"} {"fields": {"given": "Dóra", "locale": "hu_AQ", "surname": "Mátrai"}, "file": "hu", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Mátrai, Dóra"} {"fields": {"given": "Dóra", "locale": "hu_AQ", "surname": "Mátrai"}, "file": "hu", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Mátrai Dóra"} {"fields": {"credentials": "բ․գ․դ․", "generation": "կրտսեր", "given": "Ադա Կոռնելիա", "given-informal": "Նիլ", "given2": "Սեզար Մարտին", "locale": "ko_AQ", "surname-core": "Վոլֆ", "surname-prefix": "վան դեն", "surname2": "Գոնսալես Դոմինգո", "title": "դ-ր"}, "file": "hy", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "բ․գ․դ․ դ-ր Ադա Կոռնելիա Սեզար Մարտին վան դեն Վոլֆ"} {"fields": {"credentials": "բ․գ․դ․", "generation": "կրտսեր", "given": "Ադա Կոռնելիա", "given-informal": "Նիլ", "given2": "Սեզար Մարտին", "locale": "ko_AQ", "surname-core": "Վոլֆ", "surname-prefix": "վան դեն", "surname2": "Գոնսալես Դոմինգո", "title": "դ-ր"}, "file": "hy", "label": "foreignFull", "order": "sorting", "shape": 4, "text": "Վոլֆ Ադա Կոռնելիա"} {"fields": {"credentials": "բ․գ․դ․", "generation": "կրտսեր", "given": "Ադա Կոռնելիա", "given-informal": "Նիլ", "given2": "Սեզար Մարտին", "locale": "ko_AQ", "surname-core": "Վոլֆ", "surname-prefix": "վան դեն", "surname2": "Գոնսալես Դոմինգո", "title": "դ-ր"}, "file": "hy", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "բ․գ․դ․ վան դեն Վոլֆ Ադա Կոռնելիա Սեզար Մարտին"} {"fields": {"given": "Սինբադ", "locale": "ko_AQ"}, "file": "hy", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Սինբադ"} {"fields": {"given": "Սինբադ", "locale": "ko_AQ"}, "file": "hy", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Սինբադ"} {"fields": {"given": "Սինբադ", "locale": "ko_AQ"}, "file": "hy", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Սինբադ"} {"fields": {"given": "Ցեցիլիա", "given2": "Համիշ", "locale": "ko_AQ", "surname": "Շտյոբեր"}, "file": "hy", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Ցեցիլիա Համիշ Շտյոբեր"} {"fields": {"given": "Ցեցիլիա", "given2": "Համիշ", "locale": "ko_AQ", "surname": "Շտյոբեր"}, "file": "hy", "label": "foreignGGS", "order": "sorting", "shape": 4, "text": "Շտյոբեր Ցեցիլիա"} {"fields": {"given": "Ցեցիլիա", "given2": "Համիշ", "locale": "ko_AQ", "surname": "Շտյոբեր"}, "file": "hy", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Շտյոբեր Ցեցիլիա Համիշ"} {"fields": {"given": "Քեյթ", "locale": "ko_AQ", "surname": "Մյուլեր"}, "file": "hy", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Քեյթ Մյուլեր"} {"fields": {"given": "Քեյթ", "locale": "ko_AQ", "surname": "Մյուլեր"}, "file": "hy", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "Մյուլեր Քեյթ"} {"fields": {"given": "Քեյթ", "locale": "ko_AQ", "surname": "Մյուլեր"}, "file": "hy", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Մյուլեր Քեյթ"} {"fields": {"credentials": "բ․գ․դ․", "given": "Հայկ", "given-informal": "Հայկո", "given2": "Պարգևի", "locale": "hy_AQ", "surname-core": "Տեր-Պողոսյան", "title": "պրն"}, "file": "hy", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "բ․գ․դ․ պրն Հայկ Պարգևի Տեր-Պողոսյան"} {"fields": {"credentials": "բ․գ․դ․", "given": "Հայկ", "given-informal": "Հայկո", "given2": "Պարգևի", "locale": "hy_AQ", "surname-core": "Տեր-Պողոսյան", "title": "պրն"}, "file": "hy", "label": "nativeFull", "order": "sorting", "shape": 4, "text": "Տեր-Պողոսյան Հայկ"} {"fields": {"credentials": "բ․գ․դ․", "given": "Հայկ", "given-informal": "Հայկո", "given2": "Պարգևի", "locale": "hy_AQ", "surname-core": "Տեր-Պողոսյան", "title": "պրն"}, "file": "hy", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "բ․գ․դ․ Տեր-Պողոսյան Հայկ Պարգևի"} {"fields": {"given": "Արթուր", "locale": "hy_AQ"}, "file": "hy", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Արթուր"} {"fields": {"given": "Արթուր", "locale": "hy_AQ"}, "file": "hy", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Արթուր"} {"fields": {"given": "Արթուր", "locale": "hy_AQ"}, "file": "hy", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Արթուր"} {"fields": {"given": "Հովհաննես", "given2": "Արմենի", "locale": "hy_AQ", "surname": "Դարբինյան"}, "file": "hy", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Հովհաննես Արմենի Դարբինյան"} {"fields": {"given": "Հովհաննես", "given2": "Արմենի", "locale": "hy_AQ", "surname": "Դարբինյան"}, "file": "hy", "label": "nativeGGS", "order": "sorting", "shape": 4, "text": "Դարբինյան Հովհաննես"} {"fields": {"given": "Հովհաննես", "given2": "Արմենի", "locale": "hy_AQ", "surname": "Դարբինյան"}, "file": "hy", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Դարբինյան Հովհաննես Արմենի"} {"fields": {"given": "Հեղինե", "locale": "hy_AQ", "surname": "Ադամյան"}, "file": "hy", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Հեղինե Ադամյան"} {"fields": {"given": "Հեղինե", "locale": "hy_AQ", "surname": "Ադամյան"}, "file": "hy", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "Ադամյան Հեղինե"} {"fields": {"given": "Հեղինե", "locale": "hy_AQ", "surname": "Ադամյան"}, "file": "hy", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Ադամյան Հեղինե"} {"fields": {"credentials": "M.D., Ph.D.", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "Cesar Martín", "locale": "ja_AQ", "surname-core": "Bruhl", "surname-prefix": "von", "surname2": "Gonzalez Domingo", "title": "Prof. Dr."}, "file": "id", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Prof. Dr. Ada Cornelia Cesar Martín von Bruhl Jr, M.D., Ph.D."} {"fields": {"credentials": "M.D., Ph.D.", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "Cesar Martín", "locale": "ja_AQ", "surname-core": "Bruhl", "surname-prefix": "von", "surname2": "Gonzalez Domingo", "title": "Prof. Dr."}, "file": "id", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Bruhl, Ada Cornelia Cesar Martín von"} {"fields": {"credentials": "M.D., Ph.D.", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "Cesar Martín", "locale": "ja_AQ", "surname-core": "Bruhl", "surname-prefix": "von", "surname2": "Gonzalez Domingo", "title": "Prof. Dr."}, "file": "id", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "Prof. Dr. von Bruhl, Ada Cornelia Cesar Martín Jr, M.D., Ph.D."} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "id", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "id", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "id", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Zazilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stober"}, "file": "id", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zazilia Hamish Stober"} {"fields": {"given": "Zazilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stober"}, "file": "id", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Stober, Zazilia Hamish"} {"fields": {"given": "Zazilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stober"}, "file": "id", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Stober, Zazilia Hamish"} {"fields": {"given": "Kathe", "locale": "ja_AQ", "surname": "Muller"}, "file": "id", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Kathe Muller"} {"fields": {"given": "Kathe", "locale": "ja_AQ", "surname": "Muller"}, "file": "id", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Muller, Kathe"} {"fields": {"given": "Kathe", "locale": "ja_AQ", "surname": "Muller"}, "file": "id", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Muller, Kathe"} {"fields": {"credentials": "MP", "given": "Dwi Putro", "given-informal": "Dwi", "given2": "bin", "locale": "id_AQ", "surname-core": "Adinata", "title": "Bapak"}, "file": "id", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Bapak Dwi Putro bin Adinata MP"} {"fields": {"credentials": "MP", "given": "Dwi Putro", "given-informal": "Dwi", "given2": "bin", "locale": "id_AQ", "surname-core": "Adinata", "title": "Bapak"}, "file": "id", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Adinata, Dwi Putro bin"} {"fields": {"credentials": "MP", "given": "Dwi Putro", "given-informal": "Dwi", "given2": "bin", "locale": "id_AQ", "surname-core": "Adinata", "title": "Bapak"}, "file": "id", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Bapak Adinata, Dwi Putro bin MP"} {"fields": {"given": "Budi", "locale": "id_AQ"}, "file": "id", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Budi"} {"fields": {"given": "Budi", "locale": "id_AQ"}, "file": "id", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Budi"} {"fields": {"given": "Budi", "locale": "id_AQ"}, "file": "id", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Budi"} {"fields": {"given": "Ahmad Albar", "given2": "bin", "locale": "id_AQ", "surname": "Akbar"}, "file": "id", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Ahmad Albar bin Akbar"} {"fields": {"given": "Ahmad Albar", "given2": "bin", "locale": "id_AQ", "surname": "Akbar"}, "file": "id", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Akbar, Ahmad Albar bin"} {"fields": {"given": "Ahmad Albar", "given2": "bin", "locale": "id_AQ", "surname": "Akbar"}, "file": "id", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Akbar, Ahmad Albar bin"} {"fields": {"given": "Maria", "locale": "id_AQ", "surname": "Wijaya"}, "file": "id", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Maria Wijaya"} {"fields": {"given": "Maria", "locale": "id_AQ", "surname": "Wijaya"}, "file": "id", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Wijaya, Maria"} {"fields": {"given": "Maria", "locale": "id_AQ", "surname": "Wijaya"}, "file": "id", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Wijaya, Maria"} {"fields": {"credentials": "M.D. Ph.D.", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "Eva Sophia", "locale": "ko_AQ", "surname-core": "Wolf", "surname-prefix": "van den", "surname2": "Becker Schmidt", "title": "Prof. Dr."}, "file": "ig", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Prof. Dr. Ada Cornelia Eva Sophia van den Wolf Becker Schmidt M.D. Ph.D."} {"fields": {"credentials": "M.D. Ph.D.", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "Eva Sophia", "locale": "ko_AQ", "surname-core": "Wolf", "surname-prefix": "van den", "surname2": "Becker Schmidt", "title": "Prof. Dr."}, "file": "ig", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Wolf, Ada Cornelia Eva Sophia van den"} {"fields": {"credentials": "M.D. Ph.D.", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "Eva Sophia", "locale": "ko_AQ", "surname-core": "Wolf", "surname-prefix": "van den", "surname2": "Becker Schmidt", "title": "Prof. Dr."}, "file": "ig", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "van den Wolf Prof. Dr. Ada Cornelia Eva Sophia Jr, M.D. Ph.D."} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "ig", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "ig", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "ig", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "ig", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zäzilia Hamish Stöber"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "ig", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Stöber, Zäzilia Hamish"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "ig", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Stöber Zäzilia Hamish"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "ig", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Käthe Müller"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "ig", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Müller, Käthe"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "ig", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Müller Käthe"} {"fields": {"credentials": "MP", "generation": "Jr", "given": "Christopher", "given-informal": "Chris", "given2": "Chukwudike", "locale": "ig_AQ", "surname-core": "Amadi", "title": "Maazị"}, "file": "ig", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Maazị Christopher Chukwudike Amadi MP"} {"fields": {"credentials": "MP", "generation": "Jr", "given": "Christopher", "given-informal": "Chris", "given2": "Chukwudike", "locale": "ig_AQ", "surname-core": "Amadi", "title": "Maazị"}, "file": "ig", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Amadi, Christopher Chukwudike"} {"fields": {"credentials": "MP", "generation": "Jr", "given": "Christopher", "given-informal": "Chris", "given2": "Chukwudike", "locale": "ig_AQ", "surname-core": "Amadi", "title": "Maazị"}, "file": "ig", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Amadi Maazị Christopher Chukwudike Jr, MP"} {"fields": {"given": "Ada", "locale": "ig_AQ"}, "file": "ig", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Ada"} {"fields": {"given": "Ada", "locale": "ig_AQ"}, "file": "ig", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Ada"} {"fields": {"given": "Ada", "locale": "ig_AQ"}, "file": "ig", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Ada"} {"fields": {"given": "John", "given2": "Chinedu", "locale": "ig_AQ", "surname": "Amadi"}, "file": "ig", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "John Chinedu Amadi"} {"fields": {"given": "John", "given2": "Chinedu", "locale": "ig_AQ", "surname": "Amadi"}, "file": "ig", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Amadi, John Chinedu"} {"fields": {"given": "John", "given2": "Chinedu", "locale": "ig_AQ", "surname": "Amadi"}, "file": "ig", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Amadi John Chinedu"} {"fields": {"given": "Irene", "locale": "ig_AQ", "surname": "Obidike"}, "file": "ig", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Irene Obidike"} {"fields": {"given": "Irene", "locale": "ig_AQ", "surname": "Obidike"}, "file": "ig", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Obidike, Irene"} {"fields": {"given": "Irene", "locale": "ig_AQ", "surname": "Obidike"}, "file": "ig", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Obidike Irene"} {"fields": {"credentials": "M.A. í bókmenntafræði", "generation": "yngri", "given": "Sigríður Jóna", "given-informal": "Sigga", "given2": "Valgerður Fanndís", "locale": "ko_AQ", "surname-core": "Bjarnadóttir", "surname-prefix": "von", "surname2": "Jónsdóttir Möller", "title": "dr."}, "file": "is", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Sigríður Jóna Valgerður Fanndís von Bjarnadóttir, M.A. í bókmenntafræði"} {"fields": {"credentials": "M.A. í bókmenntafræði", "generation": "yngri", "given": "Sigríður Jóna", "given-informal": "Sigga", "given2": "Valgerður Fanndís", "locale": "ko_AQ", "surname-core": "Bjarnadóttir", "surname-prefix": "von", "surname2": "Jónsdóttir Möller", "title": "dr."}, "file": "is", "label": "foreignFull", "order": "sorting", "shape": 4, "text": "Sigríður Jóna Valgerður Fanndís von Bjarnadóttir"} {"fields": {"credentials": "M.A. í bókmenntafræði", "generation": "yngri", "given": "Sigríður Jóna", "given-informal": "Sigga", "given2": "Valgerður Fanndís", "locale": "ko_AQ", "surname-core": "Bjarnadóttir", "surname-prefix": "von", "surname2": "Jónsdóttir Möller", "title": "dr."}, "file": "is", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "von Bjarnadóttir dr. Sigríður Jóna Valgerður Fanndís yngri, M.A. í bókmenntafræði"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "is", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "is", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "is", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "is", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zäzilia Hamish Stöber"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "is", "label": "foreignGGS", "order": "sorting", "shape": 4, "text": "Zäzilia Hamish Stöber"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "is", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Stöber Zäzilia Hamish"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "is", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Käthe Müller"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "is", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "Käthe Müller"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "is", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Müller Käthe"} {"fields": {"credentials": "þingmaður", "generation": "yngri", "given": "Geir Jón", "given-informal": "Geiri", "given2": "Magnús Brynjar", "locale": "is_AQ", "surname-core": "Skeggjason", "title": "Hr."}, "file": "is", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Geir Jón Magnús Brynjar Skeggjason, þingmaður"} {"fields": {"credentials": "þingmaður", "generation": "yngri", "given": "Geir Jón", "given-informal": "Geiri", "given2": "Magnús Brynjar", "locale": "is_AQ", "surname-core": "Skeggjason", "title": "Hr."}, "file": "is", "label": "nativeFull", "order": "sorting", "shape": 4, "text": "Geir Jón Magnús Brynjar Skeggjason"} {"fields": {"credentials": "þingmaður", "generation": "yngri", "given": "Geir Jón", "given-informal": "Geiri", "given2": "Magnús Brynjar", "locale": "is_AQ", "surname-core": "Skeggjason", "title": "Hr."}, "file": "is", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Skeggjason Hr. Geir Jón Magnús Brynjar yngri, þingmaður"} {"fields": {"given": "Sigurður", "locale": "is_AQ"}, "file": "is", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Sigurður"} {"fields": {"given": "Sigurður", "locale": "is_AQ"}, "file": "is", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Sigurður"} {"fields": {"given": "Sigurður", "locale": "is_AQ"}, "file": "is", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Sigurður"} {"fields": {"given": "Jón", "given2": "Hannes", "locale": "is_AQ", "surname": "Valdimarsson"}, "file": "is", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Jón Hannes Valdimarsson"} {"fields": {"given": "Jón", "given2": "Hannes", "locale": "is_AQ", "surname": "Valdimarsson"}, "file": "is", "label": "nativeGGS", "order": "sorting", "shape": 4, "text": "Jón Hannes Valdimarsson"} {"fields": {"given": "Jón", "given2": "Hannes", "locale": "is_AQ", "surname": "Valdimarsson"}, "file": "is", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Valdimarsson Jón Hannes"} {"fields": {"given": "Íris", "locale": "is_AQ", "surname": "Adólfsdóttir"}, "file": "is", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Íris Adólfsdóttir"} {"fields": {"given": "Íris", "locale": "is_AQ", "surname": "Adólfsdóttir"}, "file": "is", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "Íris Adólfsdóttir"} {"fields": {"given": "Íris", "locale": "is_AQ", "surname": "Adólfsdóttir"}, "file": "is", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Adólfsdóttir Íris"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "it", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Prof. Dr. Ada Cornelia César Martín von Brühl Jr, MD DDS"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "it", "label": "foreignFull", "order": "sorting", "shape": null, "text": "von Brühl, Ada Cornelia César Martín"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "it", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "Prof. Dr. von Brühl Ada Cornelia César Martín Jr, MD DDS"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "it", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "it", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "it", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "it", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zäzilia Hamish Stöber"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "it", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Stöber, Zäzilia Hamish"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "it", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Stöber Zäzilia Hamish"} {"fields": {"given": "Julia", "locale": "ko_AQ", "surname": "Roberts"}, "file": "it", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Julia Roberts"} {"fields": {"given": "Julia", "locale": "ko_AQ", "surname": "Roberts"}, "file": "it", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Roberts, Julia"} {"fields": {"given": "Julia", "locale": "ko_AQ", "surname": "Roberts"}, "file": "it", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Roberts Julia"} {"fields": {"credentials": "AD", "generation": "II", "given": "Fabio Massimo", "given-informal": "Max", "given2": "Carlo Alberto", "locale": "it_AQ", "surname-core": "Martini", "title": "Sig."}, "file": "it", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Sig. Fabio Massimo Carlo Alberto Martini II, AD"} {"fields": {"credentials": "AD", "generation": "II", "given": "Fabio Massimo", "given-informal": "Max", "given2": "Carlo Alberto", "locale": "it_AQ", "surname-core": "Martini", "title": "Sig."}, "file": "it", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Martini, Fabio Massimo Carlo Alberto"} {"fields": {"credentials": "AD", "generation": "II", "given": "Fabio Massimo", "given-informal": "Max", "given2": "Carlo Alberto", "locale": "it_AQ", "surname-core": "Martini", "title": "Sig."}, "file": "it", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Sig. Martini Fabio Massimo Carlo Alberto II, AD"} {"fields": {"given": "Mina", "locale": "it_AQ"}, "file": "it", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Mina"} {"fields": {"given": "Mina", "locale": "it_AQ"}, "file": "it", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Mina"} {"fields": {"given": "Mina", "locale": "it_AQ"}, "file": "it", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Mina"} {"fields": {"given": "Gian Carlo", "given2": "Maria", "locale": "it_AQ", "surname": "Conti"}, "file": "it", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Gian Carlo Maria Conti"} {"fields": {"given": "Gian Carlo", "given2": "Maria", "locale": "it_AQ", "surname": "Conti"}, "file": "it", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Conti, Gian Carlo Maria"} {"fields": {"given": "Gian Carlo", "given2": "Maria", "locale": "it_AQ", "surname": "Conti"}, "file": "it", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Conti Gian Carlo Maria"} {"fields": {"given": "Irene", "locale": "it_AQ", "surname": "Fabbri"}, "file": "it", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Irene Fabbri"} {"fields": {"given": "Irene", "locale": "it_AQ", "surname": "Fabbri"}, "file": "it", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Fabbri, Irene"} {"fields": {"given": "Irene", "locale": "it_AQ", "surname": "Fabbri"}, "file": "it", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Fabbri Irene"} {"fields": {"credentials": "医学博士", "generation": "ジュニア", "given": "ジェニファー", "given-informal": "ジェニー", "given2": "ソフィア", "locale": "fr_AQ", "surname-core": "スミス", "surname-prefix": "フォン", "title": "博士"}, "file": "ja", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "ジェニファー・ソフィア・フォン・スミス・ジュニア博士"} {"fields": {"credentials": "医学博士", "generation": "ジュニア", "given": "ジェニファー", "given-informal": "ジェニー", "given2": "ソフィア", "locale": "fr_AQ", "surname-core": "スミス", "surname-prefix": "フォン", "title": "博士"}, "file": "ja", "label": "foreignFull", "order": "sorting", "shape": 4, "text": "フォン・スミス・ソフィア・ジェニファー"} {"fields": {"credentials": "医学博士", "generation": "ジュニア", "given": "ジェニファー", "given-informal": "ジェニー", "given2": "ソフィア", "locale": "fr_AQ", "surname-core": "スミス", "surname-prefix": "フォン", "title": "博士"}, "file": "ja", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "フォン・スミス・ソフィア・ジェニファー博士"} {"fields": {"given": "マイケル", "locale": "fr_AQ"}, "file": "ja", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "マイケル"} {"fields": {"given": "マイケル", "locale": "fr_AQ"}, "file": "ja", "label": "foreignG", "order": "sorting", "shape": 4, "text": "マイケル"} {"fields": {"given": "マイケル", "locale": "fr_AQ"}, "file": "ja", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "マイケル"} {"fields": {"given": "セシリア", "given2": "ローズ", "locale": "fr_AQ", "surname": "ブラウン"}, "file": "ja", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "セシリア・ローズ・ブラウン"} {"fields": {"given": "セシリア", "given2": "ローズ", "locale": "fr_AQ", "surname": "ブラウン"}, "file": "ja", "label": "foreignGGS", "order": "sorting", "shape": 4, "text": "ブラウン・ローズ・セシリア"} {"fields": {"given": "セシリア", "given2": "ローズ", "locale": "fr_AQ", "surname": "ブラウン"}, "file": "ja", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "ブラウン・ローズ・セシリア"} {"fields": {"given": "アルベルト", "locale": "fr_AQ", "surname": "アインシュタイン"}, "file": "ja", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "アルベルト・アインシュタイン"} {"fields": {"given": "アルベルト", "locale": "fr_AQ", "surname": "アインシュタイン"}, "file": "ja", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "アインシュタイン・アルベルト"} {"fields": {"given": "アルベルト", "locale": "fr_AQ", "surname": "アインシュタイン"}, "file": "ja", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "アインシュタイン・アルベルト"} {"fields": {"credentials": "国会議員", "generation": "ジュニア", "given": "恵子", "given-informal": "けいこ", "given2": "グレース", "locale": "ja_AQ", "surname-core": "佐藤", "title": "さん"}, "file": "ja", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "恵子グレース佐藤ジュニアさん"} {"fields": {"credentials": "国会議員", "generation": "ジュニア", "given": "恵子", "given-informal": "けいこ", "given2": "グレース", "locale": "ja_AQ", "surname-core": "佐藤", "title": "さん"}, "file": "ja", "label": "nativeFull", "order": "sorting", "shape": 4, "text": "佐藤グレース恵子"} {"fields": {"credentials": "国会議員", "generation": "ジュニア", "given": "恵子", "given-informal": "けいこ", "given2": "グレース", "locale": "ja_AQ", "surname-core": "佐藤", "title": "さん"}, "file": "ja", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "佐藤グレース恵子さん"} {"fields": {"given": "慎太郎", "locale": "ja_AQ"}, "file": "ja", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "慎太郎"} {"fields": {"given": "慎太郎", "locale": "ja_AQ"}, "file": "ja", "label": "nativeG", "order": "sorting", "shape": 4, "text": "慎太郎"} {"fields": {"given": "慎太郎", "locale": "ja_AQ"}, "file": "ja", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "慎太郎"} {"fields": {"given": "太郎", "given2": "トーマス", "locale": "ja_AQ", "surname": "山田"}, "file": "ja", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "太郎トーマス山田"} {"fields": {"given": "太郎", "given2": "トーマス", "locale": "ja_AQ", "surname": "山田"}, "file": "ja", "label": "nativeGGS", "order": "sorting", "shape": 4, "text": "山田トーマス太郎"} {"fields": {"given": "太郎", "given2": "トーマス", "locale": "ja_AQ", "surname": "山田"}, "file": "ja", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "山田トーマス太郎"} {"fields": {"given": "一郎", "locale": "ja_AQ", "surname": "安藤"}, "file": "ja", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "一郎安藤"} {"fields": {"given": "一郎", "locale": "ja_AQ", "surname": "安藤"}, "file": "ja", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "安藤一郎"} {"fields": {"given": "一郎", "locale": "ja_AQ", "surname": "安藤"}, "file": "ja", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "安藤一郎"} {"fields": {"credentials": "M.D. Ph.D.", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "Eva Sophia", "locale": "ko_AQ", "surname-core": "Wolf", "surname-prefix": "van. den.", "surname2": "Becker Schmidt", "title": "Prof. Dr."}, "file": "jv", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Ada Cornelia Eva Sophia van. den. Wolf M.D. Ph.D."} {"fields": {"credentials": "M.D. Ph.D.", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "Eva Sophia", "locale": "ko_AQ", "surname-core": "Wolf", "surname-prefix": "van. den.", "surname2": "Becker Schmidt", "title": "Prof. Dr."}, "file": "jv", "label": "foreignFull", "order": "sorting", "shape": null, "text": "van. den. Wolf, Ada Cornelia Eva Sophia M.D. Ph.D."} {"fields": {"credentials": "M.D. Ph.D.", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "Eva Sophia", "locale": "ko_AQ", "surname-core": "Wolf", "surname-prefix": "van. den.", "surname2": "Becker Schmidt", "title": "Prof. Dr."}, "file": "jv", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "van. den. Wolf Ada Cornelia Eva Sophia M.D. Ph.D."} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "jv", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "jv", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "jv", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sasilia", "given2": "Hamis", "locale": "ko_AQ", "surname": "Stober"}, "file": "jv", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Sasilia Hamis Stober"} {"fields": {"given": "Sasilia", "given2": "Hamis", "locale": "ko_AQ", "surname": "Stober"}, "file": "jv", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Stober, Sasilia Hamis"} {"fields": {"given": "Sasilia", "given2": "Hamis", "locale": "ko_AQ", "surname": "Stober"}, "file": "jv", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Stober Sasilia Hamis"} {"fields": {"given": "Kathi", "locale": "ko_AQ", "surname": "Muller"}, "file": "jv", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Kathi Muller"} {"fields": {"given": "Kathi", "locale": "ko_AQ", "surname": "Muller"}, "file": "jv", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Muller, Kathi"} {"fields": {"given": "Kathi", "locale": "ko_AQ", "surname": "Muller"}, "file": "jv", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Muller Kathi"} {"fields": {"credentials": "MP", "generation": "Jr", "given": "Bertram Wilberforce", "given-informal": "Bertie", "given2": "Henry Robert", "locale": "jv_AQ", "surname-core": "Wooster", "title": "Bp."}, "file": "jv", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Bertram Wilberforce Henry Robert Wooster MP"} {"fields": {"credentials": "MP", "generation": "Jr", "given": "Bertram Wilberforce", "given-informal": "Bertie", "given2": "Henry Robert", "locale": "jv_AQ", "surname-core": "Wooster", "title": "Bp."}, "file": "jv", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Wooster, Bertram Wilberforce Henry Robert MP"} {"fields": {"credentials": "MP", "generation": "Jr", "given": "Bertram Wilberforce", "given-informal": "Bertie", "given2": "Henry Robert", "locale": "jv_AQ", "surname-core": "Wooster", "title": "Bp."}, "file": "jv", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Wooster Bertram Wilberforce Henry Robert MP"} {"fields": {"given": "Zendaya", "locale": "jv_AQ"}, "file": "jv", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Zendaya"} {"fields": {"given": "Zendaya", "locale": "jv_AQ"}, "file": "jv", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Zendaya"} {"fields": {"given": "Zendaya", "locale": "jv_AQ"}, "file": "jv", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Zendaya"} {"fields": {"given": "Mary Sue", "given2": "Hamish", "locale": "jv_AQ", "surname": "Watson"}, "file": "jv", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Mary Sue Hamish Watson"} {"fields": {"given": "Mary Sue", "given2": "Hamish", "locale": "jv_AQ", "surname": "Watson"}, "file": "jv", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Watson, Mary Sue Hamish"} {"fields": {"given": "Mary Sue", "given2": "Hamish", "locale": "jv_AQ", "surname": "Watson"}, "file": "jv", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Watson Mary Sue Hamish"} {"fields": {"given": "Irene", "locale": "jv_AQ", "surname": "Adler"}, "file": "jv", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Irene Adler"} {"fields": {"given": "Irene", "locale": "jv_AQ", "surname": "Adler"}, "file": "jv", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Adler, Irene"} {"fields": {"given": "Irene", "locale": "jv_AQ", "surname": "Adler"}, "file": "jv", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Adler Irene"} {"fields": {"credentials": "მეცნ. დოქტ.", "generation": "უმც.", "given": "ადა კორნელია", "given-informal": "ნილე", "given2": "სეზარ მარტინ", "locale": "ko_AQ", "surname-core": "ბრიული", "surname-prefix": "ფონ", "surname2": "გონსალეს დომინგო", "title": "პროფ. დოქტ."}, "file": "ka", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "პროფ. დოქტ. ადა კორნელია სეზარ მარტინ ფონ ბრიული გონსალეს დომინგო მეცნ. დოქტ."} {"fields": {"credentials": "მეცნ. დოქტ.", "generation": "უმც.", "given": "ადა კორნელია", "given-informal": "ნილე", "given2": "სეზარ მარტინ", "locale": "ko_AQ", "surname-core": "ბრიული", "surname-prefix": "ფონ", "surname2": "გონსალეს დომინგო", "title": "პროფ. დოქტ."}, "file": "ka", "label": "foreignFull", "order": "sorting", "shape": null, "text": "ფონ ბრიული გონსალეს დომინგო, პროფ. დოქტ. ადა კორნელია სეზარ მარტინ მეცნ. დოქტ."} {"fields": {"credentials": "მეცნ. დოქტ.", "generation": "უმც.", "given": "ადა კორნელია", "given-informal": "ნილე", "given2": "სეზარ მარტინ", "locale": "ko_AQ", "surname-core": "ბრიული", "surname-prefix": "ფონ", "surname2": "გონსალეს დომინგო", "title": "პროფ. დოქტ."}, "file": "ka", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "ფონ ბრიული გონსალეს დომინგო პროფ. დოქტ. ადა კორნელია სეზარ მარტინ მეცნ. დოქტ."} {"fields": {"given": "სინდბად", "locale": "ko_AQ"}, "file": "ka", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "სინდბად"} {"fields": {"given": "სინდბად", "locale": "ko_AQ"}, "file": "ka", "label": "foreignG", "order": "sorting", "shape": 4, "text": "სინდბად"} {"fields": {"given": "სინდბად", "locale": "ko_AQ"}, "file": "ka", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "სინდბად"} {"fields": {"given": "ცეცილია", "given2": "ჰეიმიშ", "locale": "ko_AQ", "surname": "შტიობერი"}, "file": "ka", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "ცეცილია ჰეიმიშ შტიობერი"} {"fields": {"given": "ცეცილია", "given2": "ჰეიმიშ", "locale": "ko_AQ", "surname": "შტიობერი"}, "file": "ka", "label": "foreignGGS", "order": "sorting", "shape": 4, "text": "შტიობერი ცეცილია ჰეიმიშ"} {"fields": {"given": "ცეცილია", "given2": "ჰეიმიშ", "locale": "ko_AQ", "surname": "შტიობერი"}, "file": "ka", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "შტიობერი ცეცილია ჰეიმიშ"} {"fields": {"given": "მონიკა", "locale": "ko_AQ", "surname": "ბელუჩი"}, "file": "ka", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "მონიკა ბელუჩი"} {"fields": {"given": "მონიკა", "locale": "ko_AQ", "surname": "ბელუჩი"}, "file": "ka", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "ბელუჩი მონიკა"} {"fields": {"given": "მონიკა", "locale": "ko_AQ", "surname": "ბელუჩი"}, "file": "ka", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "ბელუჩი მონიკა"} {"fields": {"generation": "უმც.", "given": "მარიამ", "given-informal": "მარი", "given2": "მართა", "locale": "ka_AQ", "surname-core": "აბაშიძე"}, "file": "ka", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "მარიამ მართა აბაშიძე"} {"fields": {"generation": "უმც.", "given": "მარიამ", "given-informal": "მარი", "given2": "მართა", "locale": "ka_AQ", "surname-core": "აბაშიძე"}, "file": "ka", "label": "nativeFull", "order": "sorting", "shape": 4, "text": "აბაშიძე მარიამ მართა"} {"fields": {"generation": "უმც.", "given": "მარიამ", "given-informal": "მარი", "given2": "მართა", "locale": "ka_AQ", "surname-core": "აბაშიძე"}, "file": "ka", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "აბაშიძე მარიამ მართა"} {"fields": {"given": "ნინო", "locale": "ka_AQ"}, "file": "ka", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "ნინო"} {"fields": {"given": "ნინო", "locale": "ka_AQ"}, "file": "ka", "label": "nativeG", "order": "sorting", "shape": 4, "text": "ნინო"} {"fields": {"given": "ნინო", "locale": "ka_AQ"}, "file": "ka", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "ნინო"} {"fields": {"given": "თეკლა", "given2": "მარიამ", "locale": "ka_AQ", "surname": "ნაკაშიძე"}, "file": "ka", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "თეკლა მარიამ ნაკაშიძე"} {"fields": {"given": "თეკლა", "given2": "მარიამ", "locale": "ka_AQ", "surname": "ნაკაშიძე"}, "file": "ka", "label": "nativeGGS", "order": "sorting", "shape": 4, "text": "ნაკაშიძე თეკლა მარიამ"} {"fields": {"given": "თეკლა", "given2": "მარიამ", "locale": "ka_AQ", "surname": "ნაკაშიძე"}, "file": "ka", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "ნაკაშიძე თეკლა მარიამ"} {"fields": {"given": "გიორგი", "locale": "ka_AQ", "surname": "თავაძე"}, "file": "ka", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "გიორგი თავაძე"} {"fields": {"given": "გიორგი", "locale": "ka_AQ", "surname": "თავაძე"}, "file": "ka", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "თავაძე გიორგი"} {"fields": {"given": "გიორგი", "locale": "ka_AQ", "surname": "თავაძე"}, "file": "ka", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "თავაძე გიორგი"} {"fields": {"credentials": "тіс дәрігері", "generation": "Джуниор", "given": "Ада Корнелия", "given-informal": "Нелли", "given2": "Сезар Мартин", "locale": "ko_AQ", "surname-core": "Брюль", "surname-prefix": "фон", "surname2": "Гонсалес", "title": "проф."}, "file": "kk", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Ада Корнелия фон Брюль Джуниор, тіс дәрігері"} {"fields": {"credentials": "тіс дәрігері", "generation": "Джуниор", "given": "Ада Корнелия", "given-informal": "Нелли", "given2": "Сезар Мартин", "locale": "ko_AQ", "surname-core": "Брюль", "surname-prefix": "фон", "surname2": "Гонсалес", "title": "проф."}, "file": "kk", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Брюль, Ада Корнелия Сезар Мартин фон"} {"fields": {"credentials": "тіс дәрігері", "generation": "Джуниор", "given": "Ада Корнелия", "given-informal": "Нелли", "given2": "Сезар Мартин", "locale": "ko_AQ", "surname-core": "Брюль", "surname-prefix": "фон", "surname2": "Гонсалес", "title": "проф."}, "file": "kk", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "фон Брюль Ада Корнелия"} {"fields": {"given": "Синдбад", "locale": "ko_AQ"}, "file": "kk", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Синдбад"} {"fields": {"given": "Синдбад", "locale": "ko_AQ"}, "file": "kk", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Синдбад"} {"fields": {"given": "Синдбад", "locale": "ko_AQ"}, "file": "kk", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Синдбад"} {"fields": {"given": "Сесилия", "given2": "Хэмиш", "locale": "ko_AQ", "surname": "Штобер"}, "file": "kk", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Сесилия Штобер"} {"fields": {"given": "Сесилия", "given2": "Хэмиш", "locale": "ko_AQ", "surname": "Штобер"}, "file": "kk", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Штобер, Сесилия Хэмиш"} {"fields": {"given": "Сесилия", "given2": "Хэмиш", "locale": "ko_AQ", "surname": "Штобер"}, "file": "kk", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Штобер Сесилия"} {"fields": {"given": "Кейт", "locale": "ko_AQ", "surname": "Мюллер"}, "file": "kk", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Кейт Мюллер"} {"fields": {"given": "Кейт", "locale": "ko_AQ", "surname": "Мюллер"}, "file": "kk", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Мюллер, Кейт"} {"fields": {"given": "Кейт", "locale": "ko_AQ", "surname": "Мюллер"}, "file": "kk", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Мюллер Кейт"} {"fields": {"given": "Заңғар", "given-informal": "Зәке", "given2": "Ермекұлы", "locale": "kk_AQ", "surname-core": "Тәуекел", "title": "мырза"}, "file": "kk", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Заңғар Тәуекел"} {"fields": {"given": "Заңғар", "given-informal": "Зәке", "given2": "Ермекұлы", "locale": "kk_AQ", "surname-core": "Тәуекел", "title": "мырза"}, "file": "kk", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Тәуекел, Заңғар Ермекұлы"} {"fields": {"given": "Заңғар", "given-informal": "Зәке", "given2": "Ермекұлы", "locale": "kk_AQ", "surname-core": "Тәуекел", "title": "мырза"}, "file": "kk", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Тәуекел Заңғар"} {"fields": {"given": "Нұрлан", "locale": "kk_AQ"}, "file": "kk", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Нұрлан"} {"fields": {"given": "Нұрлан", "locale": "kk_AQ"}, "file": "kk", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Нұрлан"} {"fields": {"given": "Нұрлан", "locale": "kk_AQ"}, "file": "kk", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Нұрлан"} {"fields": {"given": "Айдар", "locale": "kk_AQ", "surname": "Қасым"}, "file": "kk", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Айдар Қасым"} {"fields": {"given": "Айдар", "locale": "kk_AQ", "surname": "Қасым"}, "file": "kk", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Қасым, Айдар"} {"fields": {"given": "Айдар", "locale": "kk_AQ", "surname": "Қасым"}, "file": "kk", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Қасым Айдар"} {"fields": {"given": "Әсем", "locale": "kk_AQ", "surname": "Мұхамеджан"}, "file": "kk", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Әсем Мұхамеджан"} {"fields": {"given": "Әсем", "locale": "kk_AQ", "surname": "Мұхамеджан"}, "file": "kk", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Мұхамеджан, Әсем"} {"fields": {"given": "Әсем", "locale": "kk_AQ", "surname": "Мұхамеджан"}, "file": "kk", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Мұхамеджан Әсем"} {"fields": {"credentials": "ءتىس دارىگەرى", "generation": "جۋنيور", "given": "ادا كورنەليا", "given-informal": "نەلي", "given2": "سەزار مارتين", "locale": "ja_AQ", "surname-core": "بريۋل", "surname-prefix": "فون", "surname2": "گونسالەس", "title": "پروءەسسوت"}, "file": "kk_Arab", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "ادا كورنەليا فون بريۋل جۋنيور، ءتىس دارىگەرى"} {"fields": {"credentials": "ءتىس دارىگەرى", "generation": "جۋنيور", "given": "ادا كورنەليا", "given-informal": "نەلي", "given2": "سەزار مارتين", "locale": "ja_AQ", "surname-core": "بريۋل", "surname-prefix": "فون", "surname2": "گونسالەس", "title": "پروءەسسوت"}, "file": "kk_Arab", "label": "foreignFull", "order": "sorting", "shape": 4, "text": "بريۋل، ادا كورنەليا سەزار مارتين فون"} {"fields": {"credentials": "ءتىس دارىگەرى", "generation": "جۋنيور", "given": "ادا كورنەليا", "given-informal": "نەلي", "given2": "سەزار مارتين", "locale": "ja_AQ", "surname-core": "بريۋل", "surname-prefix": "فون", "surname2": "گونسالەس", "title": "پروءەسسوت"}, "file": "kk_Arab", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "فون بريۋل ادا كورنەليا"} {"fields": {"given": "سينباد", "locale": "ja_AQ"}, "file": "kk_Arab", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "سينباد"} {"fields": {"given": "سينباد", "locale": "ja_AQ"}, "file": "kk_Arab", "label": "foreignG", "order": "sorting", "shape": 4, "text": "سينباد"} {"fields": {"given": "سينباد", "locale": "ja_AQ"}, "file": "kk_Arab", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "سينباد"} {"fields": {"given": "سەسيليا", "given2": "حەمىش", "locale": "ja_AQ", "surname": "شتوبەر"}, "file": "kk_Arab", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "سەسيليا شتوبەر"} {"fields": {"given": "سەسيليا", "given2": "حەمىش", "locale": "ja_AQ", "surname": "شتوبەر"}, "file": "kk_Arab", "label": "foreignGGS", "order": "sorting", "shape": 4, "text": "شتوبەر، سەسيليا حەمىش"} {"fields": {"given": "سەسيليا", "given2": "حەمىش", "locale": "ja_AQ", "surname": "شتوبەر"}, "file": "kk_Arab", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "شتوبەر سەسيليا"} {"fields": {"given": "كەيت", "locale": "ja_AQ", "surname": "مۇللەر"}, "file": "kk_Arab", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "كەيت مۇللەر"} {"fields": {"given": "كەيت", "locale": "ja_AQ", "surname": "مۇللەر"}, "file": "kk_Arab", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "مۇللەر، كەيت"} {"fields": {"given": "كەيت", "locale": "ja_AQ", "surname": "مۇللەر"}, "file": "kk_Arab", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "مۇللەر كەيت"} {"fields": {"given": "ماقسات", "given-informal": "ماكە", "given2": "توقماحامبەتۇلى", "locale": "kk_Arab_AQ", "surname-core": "بالقىباي", "title": "مىرزا"}, "file": "kk_Arab", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "ماقسات بالقىباي"} {"fields": {"given": "ماقسات", "given-informal": "ماكە", "given2": "توقماحامبەتۇلى", "locale": "kk_Arab_AQ", "surname-core": "بالقىباي", "title": "مىرزا"}, "file": "kk_Arab", "label": "nativeFull", "order": "sorting", "shape": 4, "text": "بالقىباي، ماقسات توقماحامبەتۇلى"} {"fields": {"given": "ماقسات", "given-informal": "ماكە", "given2": "توقماحامبەتۇلى", "locale": "kk_Arab_AQ", "surname-core": "بالقىباي", "title": "مىرزا"}, "file": "kk_Arab", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "بالقىباي ماقسات"} {"fields": {"given": "اسەم", "locale": "kk_Arab_AQ"}, "file": "kk_Arab", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "اسەم"} {"fields": {"given": "اسەم", "locale": "kk_Arab_AQ"}, "file": "kk_Arab", "label": "nativeG", "order": "sorting", "shape": 4, "text": "اسەم"} {"fields": {"given": "اسەم", "locale": "kk_Arab_AQ"}, "file": "kk_Arab", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "اسەم"} {"fields": {"given": "قالاجان", "locale": "kk_Arab_AQ", "surname": "قايرات"}, "file": "kk_Arab", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "قالاجان قايرات"} {"fields": {"given": "قالاجان", "locale": "kk_Arab_AQ", "surname": "قايرات"}, "file": "kk_Arab", "label": "nativeGGS", "order": "sorting", "shape": 4, "text": "قايرات، قالاجان"} {"fields": {"given": "قالاجان", "locale": "kk_Arab_AQ", "surname": "قايرات"}, "file": "kk_Arab", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "قايرات قالاجان"} {"fields": {"given": "مۇرات", "locale": "kk_Arab_AQ", "surname": "كارىباي"}, "file": "kk_Arab", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "مۇرات كارىباي"} {"fields": {"given": "مۇرات", "locale": "kk_Arab_AQ", "surname": "كارىباي"}, "file": "kk_Arab", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "كارىباي، مۇرات"} {"fields": {"given": "مۇرات", "locale": "kk_Arab_AQ", "surname": "كارىباي"}, "file": "kk_Arab", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "كارىباي مۇرات"} {"fields": {"credentials": "ទន្ដបណ្ឌិត", "generation": "ជូនៀ", "given": "អាដា ខរនែលៀ", "given-informal": "នីឡេ", "given2": "ឆេសារ ម៉ាធីន", "locale": "fr_AQ", "surname-core": "ប្រ៊ូល", "surname-prefix": "វន់", "surname2": "ហ្កនហ្សាឡេស ដូមីងហ្គោ", "title": "សាស្ត្រាចារ្យបណ្ឌិត"}, "file": "km", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "សាស្ត្រាចារ្យបណ្ឌិត អាដា ខរនែលៀ ឆេសារ ម៉ាធីន វន់ ប្រ៊ូល ជូនៀ, ទន្ដបណ្ឌិត"} {"fields": {"credentials": "ទន្ដបណ្ឌិត", "generation": "ជូនៀ", "given": "អាដា ខរនែលៀ", "given-informal": "នីឡេ", "given2": "ឆេសារ ម៉ាធីន", "locale": "fr_AQ", "surname-core": "ប្រ៊ូល", "surname-prefix": "វន់", "surname2": "ហ្កនហ្សាឡេស ដូមីងហ្គោ", "title": "សាស្ត្រាចារ្យបណ្ឌិត"}, "file": "km", "label": "foreignFull", "order": "sorting", "shape": null, "text": "ប្រ៊ូល, អាដា ខរនែលៀ ឆេសារ ម៉ាធីន វន់"} {"fields": {"credentials": "ទន្ដបណ្ឌិត", "generation": "ជូនៀ", "given": "អាដា ខរនែលៀ", "given-informal": "នីឡេ", "given2": "ឆេសារ ម៉ាធីន", "locale": "fr_AQ", "surname-core": "ប្រ៊ូល", "surname-prefix": "វន់", "surname2": "ហ្កនហ្សាឡេស ដូមីងហ្គោ", "title": "សាស្ត្រាចារ្យបណ្ឌិត"}, "file": "km", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "វន់ ប្រ៊ូល សាស្ត្រាចារ្យបណ្ឌិត អាដា ខរនែលៀ ឆេសារ ម៉ាធីន ជូនៀ, ទន្ដបណ្ឌិត"} {"fields": {"given": "ស៊ីនប៊ែត", "locale": "fr_AQ"}, "file": "km", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "ស៊ីនប៊ែត"} {"fields": {"given": "ស៊ីនប៊ែត", "locale": "fr_AQ"}, "file": "km", "label": "foreignG", "order": "sorting", "shape": 4, "text": "ស៊ីនប៊ែត"} {"fields": {"given": "ស៊ីនប៊ែត", "locale": "fr_AQ"}, "file": "km", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "ស៊ីនប៊ែត"} {"fields": {"given": "ហ្សាហ្សីលៀ", "given2": "ហេមីស", "locale": "fr_AQ", "surname": "ស្តូបើ"}, "file": "km", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "ហ្សាហ្សីលៀ ហេមីស ស្តូបើ"} {"fields": {"given": "ហ្សាហ្សីលៀ", "given2": "ហេមីស", "locale": "fr_AQ", "surname": "ស្តូបើ"}, "file": "km", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "ស្តូបើ, ហ្សាហ្សីលៀ ហេមីស"} {"fields": {"given": "ហ្សាហ្សីលៀ", "given2": "ហេមីស", "locale": "fr_AQ", "surname": "ស្តូបើ"}, "file": "km", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "ស្តូបើ ហ្សាហ្សីលៀ ហេមីស"} {"fields": {"given": "កាតេ", "locale": "fr_AQ", "surname": "មូលឡើ"}, "file": "km", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "កាតេ មូលឡើ"} {"fields": {"given": "កាតេ", "locale": "fr_AQ", "surname": "មូលឡើ"}, "file": "km", "label": "foreignGS", "order": "sorting", "shape": null, "text": "មូលឡើ, កាតេ"} {"fields": {"given": "កាតេ", "locale": "fr_AQ", "surname": "មូលឡើ"}, "file": "km", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "មូលឡើ កាតេ"} {"fields": {"credentials": "សមាជិកសភា", "given": "សុវណ្ណថេត", "given-informal": "ថេត", "given2": "សុធារ៉ា", "locale": "km_AQ", "surname-core": "វេហា", "title": "លោក"}, "file": "km", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "លោក សុវណ្ណថេត សុធារ៉ា វេហា សមាជិកសភា"} {"fields": {"credentials": "សមាជិកសភា", "given": "សុវណ្ណថេត", "given-informal": "ថេត", "given2": "សុធារ៉ា", "locale": "km_AQ", "surname-core": "វេហា", "title": "លោក"}, "file": "km", "label": "nativeFull", "order": "sorting", "shape": null, "text": "វេហា, សុវណ្ណថេត សុធារ៉ា"} {"fields": {"credentials": "សមាជិកសភា", "given": "សុវណ្ណថេត", "given-informal": "ថេត", "given2": "សុធារ៉ា", "locale": "km_AQ", "surname-core": "វេហា", "title": "លោក"}, "file": "km", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "វេហា លោក សុវណ្ណថេត សុធារ៉ា សមាជិកសភា"} {"fields": {"given": "សុខា", "locale": "km_AQ"}, "file": "km", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "សុខា"} {"fields": {"given": "សុខា", "locale": "km_AQ"}, "file": "km", "label": "nativeG", "order": "sorting", "shape": 4, "text": "សុខា"} {"fields": {"given": "សុខា", "locale": "km_AQ"}, "file": "km", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "សុខា"} {"fields": {"given": "រិទ្ធី", "given2": "មករា", "locale": "km_AQ", "surname": "វ៉េង"}, "file": "km", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "រិទ្ធី មករា វ៉េង"} {"fields": {"given": "រិទ្ធី", "given2": "មករា", "locale": "km_AQ", "surname": "វ៉េង"}, "file": "km", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "វ៉េង, រិទ្ធី មករា"} {"fields": {"given": "រិទ្ធី", "given2": "មករា", "locale": "km_AQ", "surname": "វ៉េង"}, "file": "km", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "វ៉េង រិទ្ធី មករា"} {"fields": {"given": "សុជាតិ", "locale": "km_AQ", "surname": "សុង"}, "file": "km", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "សុជាតិ សុង"} {"fields": {"given": "សុជាតិ", "locale": "km_AQ", "surname": "សុង"}, "file": "km", "label": "nativeGS", "order": "sorting", "shape": null, "text": "សុង, សុជាតិ"} {"fields": {"given": "សុជាតិ", "locale": "km_AQ", "surname": "សុង"}, "file": "km", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "សុង សុជាតិ"} {"fields": {"credentials": "ಎಂ.ಡಿ ಡಿ.ಡಿ.ಎಸ್", "generation": "ಜೂನಿಯರ್", "given": "ಅಡಾ ಕಾರ್ನೆಲಿಯಾ", "given-informal": "ನೀಲ್", "given2": "ಸಿಸಾರ್ ಮಾರ್ಟಿನ್", "locale": "ja_AQ", "surname-core": "ಬ್ರಾಲ್", "surname-prefix": "ವಾನ್", "surname2": "ಗೊನ್ಸಾಲೀಸ್ ಡೊಮಿಂಗೊ", "title": "ಪ್ರೊ. ಡಾ."}, "file": "kn", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "ಪ್ರೊ. ಡಾ. ಅಡಾ ಕಾರ್ನೆಲಿಯಾ ಸಿಸಾರ್ ಮಾರ್ಟಿನ್ ವಾನ್ ಬ್ರಾಲ್ ಜೂನಿಯರ್, ಎಂ.ಡಿ ಡಿ.ಡಿ.ಎಸ್"} {"fields": {"credentials": "ಎಂ.ಡಿ ಡಿ.ಡಿ.ಎಸ್", "generation": "ಜೂನಿಯರ್", "given": "ಅಡಾ ಕಾರ್ನೆಲಿಯಾ", "given-informal": "ನೀಲ್", "given2": "ಸಿಸಾರ್ ಮಾರ್ಟಿನ್", "locale": "ja_AQ", "surname-core": "ಬ್ರಾಲ್", "surname-prefix": "ವಾನ್", "surname2": "ಗೊನ್ಸಾಲೀಸ್ ಡೊಮಿಂಗೊ", "title": "ಪ್ರೊ. ಡಾ."}, "file": "kn", "label": "foreignFull", "order": "sorting", "shape": null, "text": "ಬ್ರಾಲ್, ಅಡಾ ಕಾರ್ನೆಲಿಯಾ ಸಿಸಾರ್ ಮಾರ್ಟಿನ್ ವಾನ್"} {"fields": {"credentials": "ಎಂ.ಡಿ ಡಿ.ಡಿ.ಎಸ್", "generation": "ಜೂನಿಯರ್", "given": "ಅಡಾ ಕಾರ್ನೆಲಿಯಾ", "given-informal": "ನೀಲ್", "given2": "ಸಿಸಾರ್ ಮಾರ್ಟಿನ್", "locale": "ja_AQ", "surname-core": "ಬ್ರಾಲ್", "surname-prefix": "ವಾನ್", "surname2": "ಗೊನ್ಸಾಲೀಸ್ ಡೊಮಿಂಗೊ", "title": "ಪ್ರೊ. ಡಾ."}, "file": "kn", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "ವಾನ್ ಬ್ರಾಲ್ ಪ್ರೊ. ಡಾ. ಅಡಾ ಕಾರ್ನೆಲಿಯಾ ಸಿಸಾರ್ ಮಾರ್ಟಿನ್ ಜೂನಿಯರ್, ಎಂ.ಡಿ ಡಿ.ಡಿ.ಎಸ್"} {"fields": {"given": "ಸಿನ್‍ಬಾದ್", "locale": "ja_AQ"}, "file": "kn", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "ಸಿನ್‍ಬಾದ್"} {"fields": {"given": "ಸಿನ್‍ಬಾದ್", "locale": "ja_AQ"}, "file": "kn", "label": "foreignG", "order": "sorting", "shape": 4, "text": "ಸಿನ್‍ಬಾದ್"} {"fields": {"given": "ಸಿನ್‍ಬಾದ್", "locale": "ja_AQ"}, "file": "kn", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "ಸಿನ್‍ಬಾದ್"} {"fields": {"given": "ಝಝಿಲಿಯಾ", "given2": "ಹಮಿಶ್", "locale": "ja_AQ", "surname": "ಸ್ಟೋಬರ್"}, "file": "kn", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "ಝಝಿಲಿಯಾ ಹಮಿಶ್ ಸ್ಟೋಬರ್"} {"fields": {"given": "ಝಝಿಲಿಯಾ", "given2": "ಹಮಿಶ್", "locale": "ja_AQ", "surname": "ಸ್ಟೋಬರ್"}, "file": "kn", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "ಸ್ಟೋಬರ್, ಝಝಿಲಿಯಾ ಹಮಿಶ್"} {"fields": {"given": "ಝಝಿಲಿಯಾ", "given2": "ಹಮಿಶ್", "locale": "ja_AQ", "surname": "ಸ್ಟೋಬರ್"}, "file": "kn", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "ಸ್ಟೋಬರ್ ಝಝಿಲಿಯಾ ಹಮಿಶ್"} {"fields": {"given": "ಕ್ಯಾಥಿ", "locale": "ja_AQ", "surname": "ಮುಲ್ಲರ್"}, "file": "kn", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "ಕ್ಯಾಥಿ ಮುಲ್ಲರ್"} {"fields": {"given": "ಕ್ಯಾಥಿ", "locale": "ja_AQ", "surname": "ಮುಲ್ಲರ್"}, "file": "kn", "label": "foreignGS", "order": "sorting", "shape": null, "text": "ಮುಲ್ಲರ್, ಕ್ಯಾಥಿ"} {"fields": {"given": "ಕ್ಯಾಥಿ", "locale": "ja_AQ", "surname": "ಮುಲ್ಲರ್"}, "file": "kn", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "ಮುಲ್ಲರ್ ಕ್ಯಾಥಿ"} {"fields": {"credentials": "ಎಮ್‌ಪಿ", "generation": "ಕಿರಿ", "given": "ನಾರಯಣ ಕುಮಾರ್", "given-informal": "ನಾಣಿ", "given2": "ಶಿವ ರಾಮ", "locale": "kn_AQ", "surname-core": "ಪಾಟೀಲ್", "title": "ಶ್ರೀ."}, "file": "kn", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "ಶ್ರೀ. ನಾರಯಣ ಕುಮಾರ್ ಶಿವ ರಾಮ ಪಾಟೀಲ್ ಕಿರಿ, ಎಮ್‌ಪಿ"} {"fields": {"credentials": "ಎಮ್‌ಪಿ", "generation": "ಕಿರಿ", "given": "ನಾರಯಣ ಕುಮಾರ್", "given-informal": "ನಾಣಿ", "given2": "ಶಿವ ರಾಮ", "locale": "kn_AQ", "surname-core": "ಪಾಟೀಲ್", "title": "ಶ್ರೀ."}, "file": "kn", "label": "nativeFull", "order": "sorting", "shape": null, "text": "ಪಾಟೀಲ್, ನಾರಯಣ ಕುಮಾರ್ ಶಿವ ರಾಮ"} {"fields": {"credentials": "ಎಮ್‌ಪಿ", "generation": "ಕಿರಿ", "given": "ನಾರಯಣ ಕುಮಾರ್", "given-informal": "ನಾಣಿ", "given2": "ಶಿವ ರಾಮ", "locale": "kn_AQ", "surname-core": "ಪಾಟೀಲ್", "title": "ಶ್ರೀ."}, "file": "kn", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "ಪಾಟೀಲ್ ಶ್ರೀ. ನಾರಯಣ ಕುಮಾರ್ ಶಿವ ರಾಮ ಕಿರಿ, ಎಮ್‌ಪಿ"} {"fields": {"given": "ಝೆಂಡಾಯಾ", "locale": "kn_AQ"}, "file": "kn", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "ಝೆಂಡಾಯಾ"} {"fields": {"given": "ಝೆಂಡಾಯಾ", "locale": "kn_AQ"}, "file": "kn", "label": "nativeG", "order": "sorting", "shape": 4, "text": "ಝೆಂಡಾಯಾ"} {"fields": {"given": "ಝೆಂಡಾಯಾ", "locale": "kn_AQ"}, "file": "kn", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "ಝೆಂಡಾಯಾ"} {"fields": {"given": "ವಿಜಯ ಲಕ್ಷ್ಮಿ", "given2": "ಹರೀಶ್", "locale": "kn_AQ", "surname": "ಯಶ್"}, "file": "kn", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "ವಿಜಯ ಲಕ್ಷ್ಮಿ ಹರೀಶ್ ಯಶ್"} {"fields": {"given": "ವಿಜಯ ಲಕ್ಷ್ಮಿ", "given2": "ಹರೀಶ್", "locale": "kn_AQ", "surname": "ಯಶ್"}, "file": "kn", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "ಯಶ್, ವಿಜಯ ಲಕ್ಷ್ಮಿ ಹರೀಶ್"} {"fields": {"given": "ವಿಜಯ ಲಕ್ಷ್ಮಿ", "given2": "ಹರೀಶ್", "locale": "kn_AQ", "surname": "ಯಶ್"}, "file": "kn", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "ಯಶ್ ವಿಜಯ ಲಕ್ಷ್ಮಿ ಹರೀಶ್"} {"fields": {"given": "ರಶ್ಮಿಕಾ", "locale": "kn_AQ", "surname": "ಶೆಟ್ಟಿ"}, "file": "kn", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "ರಶ್ಮಿಕಾ ಶೆಟ್ಟಿ"} {"fields": {"given": "ರಶ್ಮಿಕಾ", "locale": "kn_AQ", "surname": "ಶೆಟ್ಟಿ"}, "file": "kn", "label": "nativeGS", "order": "sorting", "shape": null, "text": "ಶೆಟ್ಟಿ, ರಶ್ಮಿಕಾ"} {"fields": {"given": "ರಶ್ಮಿಕಾ", "locale": "kn_AQ", "surname": "ಶೆಟ್ಟಿ"}, "file": "kn", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "ಶೆಟ್ಟಿ ರಶ್ಮಿಕಾ"} {"fields": {"credentials": "박사", "generation": "주니어", "given": "에이다 코넬리아", "given-informal": "닐", "given2": "세사르 마르틴", "locale": "fr_AQ", "surname-core": "볼프", "surname-prefix": "폰", "surname2": "베커 슈미트", "title": "교수"}, "file": "ko", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "에이다 코넬리아 세사르 마르틴 폰 볼프 박사"} {"fields": {"credentials": "박사", "generation": "주니어", "given": "에이다 코넬리아", "given-informal": "닐", "given2": "세사르 마르틴", "locale": "fr_AQ", "surname-core": "볼프", "surname-prefix": "폰", "surname2": "베커 슈미트", "title": "교수"}, "file": "ko", "label": "foreignFull", "order": "sorting", "shape": 4, "text": "폰 볼프 에이다 코넬리아 세사르 마르틴"} {"fields": {"credentials": "박사", "generation": "주니어", "given": "에이다 코넬리아", "given-informal": "닐", "given2": "세사르 마르틴", "locale": "fr_AQ", "surname-core": "볼프", "surname-prefix": "폰", "surname2": "베커 슈미트", "title": "교수"}, "file": "ko", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "폰 볼프 에이다 코넬리아 세사르 마르틴 박사"} {"fields": {"given": "신밧드", "locale": "fr_AQ"}, "file": "ko", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "신밧드"} {"fields": {"given": "신밧드", "locale": "fr_AQ"}, "file": "ko", "label": "foreignG", "order": "sorting", "shape": 4, "text": "신밧드"} {"fields": {"given": "신밧드", "locale": "fr_AQ"}, "file": "ko", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "신밧드"} {"fields": {"given": "제질리아", "given2": "해미쉬", "locale": "fr_AQ", "surname": "슈퇴버"}, "file": "ko", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "제질리아 해미쉬 슈퇴버"} {"fields": {"given": "제질리아", "given2": "해미쉬", "locale": "fr_AQ", "surname": "슈퇴버"}, "file": "ko", "label": "foreignGGS", "order": "sorting", "shape": 4, "text": "슈퇴버 제질리아 해미쉬"} {"fields": {"given": "제질리아", "given2": "해미쉬", "locale": "fr_AQ", "surname": "슈퇴버"}, "file": "ko", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "슈퇴버 제질리아 해미쉬"} {"fields": {"given": "케테", "locale": "fr_AQ", "surname": "뮐러"}, "file": "ko", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "케테 뮐러"} {"fields": {"given": "케테", "locale": "fr_AQ", "surname": "뮐러"}, "file": "ko", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "뮐러 케테"} {"fields": {"given": "케테", "locale": "fr_AQ", "surname": "뮐러"}, "file": "ko", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "뮐러 케테"} {"fields": {"given": "민규", "given-informal": "민규", "given2": "헨리", "locale": "ko_AQ", "surname-core": "김", "title": "님"}, "file": "ko", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "민규헨리김"} {"fields": {"given": "민규", "given-informal": "민규", "given2": "헨리", "locale": "ko_AQ", "surname-core": "김", "title": "님"}, "file": "ko", "label": "nativeFull", "order": "sorting", "shape": 4, "text": "김민규헨리"} {"fields": {"given": "민규", "given-informal": "민규", "given2": "헨리", "locale": "ko_AQ", "surname-core": "김", "title": "님"}, "file": "ko", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "김민규헨리"} {"fields": {"given": "유미", "locale": "ko_AQ"}, "file": "ko", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "유미"} {"fields": {"given": "유미", "locale": "ko_AQ"}, "file": "ko", "label": "nativeG", "order": "sorting", "shape": 4, "text": "유미"} {"fields": {"given": "유미", "locale": "ko_AQ"}, "file": "ko", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "유미"} {"fields": {"given": "소현", "locale": "ko_AQ", "surname": "박"}, "file": "ko", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "소현박"} {"fields": {"given": "소현", "locale": "ko_AQ", "surname": "박"}, "file": "ko", "label": "nativeGGS", "order": "sorting", "shape": 4, "text": "박소현"} {"fields": {"given": "소현", "locale": "ko_AQ", "surname": "박"}, "file": "ko", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "박소현"} {"fields": {"given": "유미", "locale": "ko_AQ", "surname": "강"}, "file": "ko", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "유미강"} {"fields": {"given": "유미", "locale": "ko_AQ", "surname": "강"}, "file": "ko", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "강유미"} {"fields": {"given": "유미", "locale": "ko_AQ", "surname": "강"}, "file": "ko", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "강유미"} {"fields": {"credentials": "बी.ए. एल.एल.बी", "generation": "जेआर", "given": "आडा कोर्नेलिया", "given-informal": "नील", "given2": "सेजार मार्टिन", "locale": "ja_AQ", "surname-core": "ब्रूह्ल", "surname-prefix": "वॉन", "surname2": "गॉन्जालेज डोमिंगो", "title": "प्रो. डॉ."}, "file": "kok", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "प्रो. डॉ. आडा कोर्नेलिया सेजार मार्टिन वॉन ब्रूह्ल गॉन्जालेज डोमिंगो जेआर, बी.ए. एल.एल.बी"} {"fields": {"credentials": "बी.ए. एल.एल.बी", "generation": "जेआर", "given": "आडा कोर्नेलिया", "given-informal": "नील", "given2": "सेजार मार्टिन", "locale": "ja_AQ", "surname-core": "ब्रूह्ल", "surname-prefix": "वॉन", "surname2": "गॉन्जालेज डोमिंगो", "title": "प्रो. डॉ."}, "file": "kok", "label": "foreignFull", "order": "sorting", "shape": null, "text": "ब्रूह्ल, आडा कोर्नेलिया सेजार मार्टिन वॉन"} {"fields": {"credentials": "बी.ए. एल.एल.बी", "generation": "जेआर", "given": "आडा कोर्नेलिया", "given-informal": "नील", "given2": "सेजार मार्टिन", "locale": "ja_AQ", "surname-core": "ब्रूह्ल", "surname-prefix": "वॉन", "surname2": "गॉन्जालेज डोमिंगो", "title": "प्रो. डॉ."}, "file": "kok", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "वॉन ब्रूह्ल गॉन्जालेज डोमिंगो प्रो. डॉ. आडा कोर्नेलिया सेजार मार्टिन जेआर, बी.ए. एल.एल.बी"} {"fields": {"given": "सिंदबाद", "locale": "ja_AQ"}, "file": "kok", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "सिंदबाद"} {"fields": {"given": "सिंदबाद", "locale": "ja_AQ"}, "file": "kok", "label": "foreignG", "order": "sorting", "shape": 4, "text": "सिंदबाद"} {"fields": {"given": "सिंदबाद", "locale": "ja_AQ"}, "file": "kok", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "सिंदबाद"} {"fields": {"given": "जाजिला", "given2": "हामिश", "locale": "ja_AQ", "surname": "स्टोबा"}, "file": "kok", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "जाजिला हामिश स्टोबा"} {"fields": {"given": "जाजिला", "given2": "हामिश", "locale": "ja_AQ", "surname": "स्टोबा"}, "file": "kok", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "स्टोबा, जाजिला हामिश"} {"fields": {"given": "जाजिला", "given2": "हामिश", "locale": "ja_AQ", "surname": "स्टोबा"}, "file": "kok", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "स्टोबा जाजिला हामिश"} {"fields": {"given": "काथा", "locale": "ja_AQ", "surname": "बाउमान"}, "file": "kok", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "काथा बाउमान"} {"fields": {"given": "काथा", "locale": "ja_AQ", "surname": "बाउमान"}, "file": "kok", "label": "foreignGS", "order": "sorting", "shape": null, "text": "बाउमान, काथा"} {"fields": {"given": "काथा", "locale": "ja_AQ", "surname": "बाउमान"}, "file": "kok", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "बाउमान काथा"} {"fields": {"credentials": "एमपी", "generation": "धा.", "given": "फ्रांसिस शावियेर", "given-informal": "फोर्सु", "given2": "जुआंव आंतोन", "locale": "kok_AQ", "surname-core": "सोजा", "surname-prefix": "दे", "surname2": "ए मेनेजेस", "title": "श्री"}, "file": "kok", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "श्री फ्रांसिस शावियेर जुआंव आंतोन दे सोजा ए मेनेजेस धा., एमपी"} {"fields": {"credentials": "एमपी", "generation": "धा.", "given": "फ्रांसिस शावियेर", "given-informal": "फोर्सु", "given2": "जुआंव आंतोन", "locale": "kok_AQ", "surname-core": "सोजा", "surname-prefix": "दे", "surname2": "ए मेनेजेस", "title": "श्री"}, "file": "kok", "label": "nativeFull", "order": "sorting", "shape": null, "text": "सोजा, फ्रांसिस शावियेर जुआंव आंतोन दे"} {"fields": {"credentials": "एमपी", "generation": "धा.", "given": "फ्रांसिस शावियेर", "given-informal": "फोर्सु", "given2": "जुआंव आंतोन", "locale": "kok_AQ", "surname-core": "सोजा", "surname-prefix": "दे", "surname2": "ए मेनेजेस", "title": "श्री"}, "file": "kok", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "दे सोजा ए मेनेजेस श्री फ्रांसिस शावियेर जुआंव आंतोन धा., एमपी"} {"fields": {"given": "दिशा", "locale": "kok_AQ"}, "file": "kok", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "दिशा"} {"fields": {"given": "दिशा", "locale": "kok_AQ"}, "file": "kok", "label": "nativeG", "order": "sorting", "shape": 4, "text": "दिशा"} {"fields": {"given": "दिशा", "locale": "kok_AQ"}, "file": "kok", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "दिशा"} {"fields": {"given": "मारिया", "given2": "हेलेना", "locale": "kok_AQ", "surname": "फर्नॅन्डेज"}, "file": "kok", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "मारिया हेलेना फर्नॅन्डेज"} {"fields": {"given": "मारिया", "given2": "हेलेना", "locale": "kok_AQ", "surname": "फर्नॅन्डेज"}, "file": "kok", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "फर्नॅन्डेज, मारिया हेलेना"} {"fields": {"given": "मारिया", "given2": "हेलेना", "locale": "kok_AQ", "surname": "फर्नॅन्डेज"}, "file": "kok", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "फर्नॅन्डेज मारिया हेलेना"} {"fields": {"given": "शांति", "locale": "kok_AQ", "surname": "गांवकर"}, "file": "kok", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "शांति गांवकर"} {"fields": {"given": "शांति", "locale": "kok_AQ", "surname": "गांवकर"}, "file": "kok", "label": "nativeGS", "order": "sorting", "shape": null, "text": "गांवकर, शांति"} {"fields": {"given": "शांति", "locale": "kok_AQ", "surname": "गांवकर"}, "file": "kok", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "गांवकर शांति"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ja_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "kok_Latn", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Prof. Dr. Ada Cornelia César Martín von Brühl González Domingo Jr, MD DDS"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ja_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "kok_Latn", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Brühl, Ada Cornelia César Martín von"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ja_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "kok_Latn", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "von Brühl González Domingo Prof. Dr. Ada Cornelia César Martín Jr, MD DDS"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "kok_Latn", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "kok_Latn", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "kok_Latn", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stöber"}, "file": "kok_Latn", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zäzilia Hamish Stöber"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stöber"}, "file": "kok_Latn", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Stöber, Zäzilia Hamish"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stöber"}, "file": "kok_Latn", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Stöber Zäzilia Hamish"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "kok_Latn", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Käthe Müller"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "kok_Latn", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Müller, Käthe"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "kok_Latn", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Müller Käthe"} {"fields": {"credentials": "MP", "generation": "Dk", "given": "Francisco Xavier", "given-informal": "Forsu", "given2": "Joao Anton", "locale": "kok_Latn_AQ", "surname-core": "Souza", "surname-prefix": "de", "surname2": "e Menezes", "title": "Mr."}, "file": "kok_Latn", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Mr. Francisco Xavier Joao Anton de Souza e Menezes Dk, MP"} {"fields": {"credentials": "MP", "generation": "Dk", "given": "Francisco Xavier", "given-informal": "Forsu", "given2": "Joao Anton", "locale": "kok_Latn_AQ", "surname-core": "Souza", "surname-prefix": "de", "surname2": "e Menezes", "title": "Mr."}, "file": "kok_Latn", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Souza, Francisco Xavier Joao Anton de"} {"fields": {"credentials": "MP", "generation": "Dk", "given": "Francisco Xavier", "given-informal": "Forsu", "given2": "Joao Anton", "locale": "kok_Latn_AQ", "surname-core": "Souza", "surname-prefix": "de", "surname2": "e Menezes", "title": "Mr."}, "file": "kok_Latn", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "de Souza e Menezes Mr. Francisco Xavier Joao Anton Dk, MP"} {"fields": {"given": "Prema", "locale": "kok_Latn_AQ"}, "file": "kok_Latn", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Prema"} {"fields": {"given": "Prema", "locale": "kok_Latn_AQ"}, "file": "kok_Latn", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Prema"} {"fields": {"given": "Prema", "locale": "kok_Latn_AQ"}, "file": "kok_Latn", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Prema"} {"fields": {"given": "Maria", "given2": "Helena", "locale": "kok_Latn_AQ", "surname": "Fernandes"}, "file": "kok_Latn", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Maria Helena Fernandes"} {"fields": {"given": "Maria", "given2": "Helena", "locale": "kok_Latn_AQ", "surname": "Fernandes"}, "file": "kok_Latn", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Fernandes, Maria Helena"} {"fields": {"given": "Maria", "given2": "Helena", "locale": "kok_Latn_AQ", "surname": "Fernandes"}, "file": "kok_Latn", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Fernandes Maria Helena"} {"fields": {"given": "Shanti", "locale": "kok_Latn_AQ", "surname": "Gaonkar"}, "file": "kok_Latn", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Shanti Gaonkar"} {"fields": {"given": "Shanti", "locale": "kok_Latn_AQ", "surname": "Gaonkar"}, "file": "kok_Latn", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Gaonkar, Shanti"} {"fields": {"given": "Shanti", "locale": "kok_Latn_AQ", "surname": "Gaonkar"}, "file": "kok_Latn", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Gaonkar Shanti"} {"fields": {"credentials": "Илимдин кандидаты", "generation": "Кенже", "given": "Алтынай Сулайманова", "given-informal": "Чыке", "given2": "Асель Сартбаева", "locale": "ja_AQ", "surname-core": "Вольф", "surname-prefix": "ван ден", "surname2": "Бекер Шмидт", "title": "Проф. Доктор."}, "file": "ky", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Проф. Доктор. Алтынай Сулайманова Асель Сартбаева ван ден Вольф Бекер Шмидт Илимдин кандидаты"} {"fields": {"credentials": "Илимдин кандидаты", "generation": "Кенже", "given": "Алтынай Сулайманова", "given-informal": "Чыке", "given2": "Асель Сартбаева", "locale": "ja_AQ", "surname-core": "Вольф", "surname-prefix": "ван ден", "surname2": "Бекер Шмидт", "title": "Проф. Доктор."}, "file": "ky", "label": "foreignFull", "order": "sorting", "shape": null, "text": "ван ден Вольф Бекер Шмидт, Проф. Доктор. Алтынай Сулайманова Асель Сартбаева Илимдин кандидаты"} {"fields": {"credentials": "Илимдин кандидаты", "generation": "Кенже", "given": "Алтынай Сулайманова", "given-informal": "Чыке", "given2": "Асель Сартбаева", "locale": "ja_AQ", "surname-core": "Вольф", "surname-prefix": "ван ден", "surname2": "Бекер Шмидт", "title": "Проф. Доктор."}, "file": "ky", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "ван ден Вольф Алтынай Сулайманова Асель Сартбаева Илимдин кандидаты"} {"fields": {"given": "Синбад", "locale": "ja_AQ"}, "file": "ky", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Синбад"} {"fields": {"given": "Синбад", "locale": "ja_AQ"}, "file": "ky", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Синбад"} {"fields": {"given": "Синбад", "locale": "ja_AQ"}, "file": "ky", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Синбад"} {"fields": {"given": "Зазилия", "given2": "Хамиш", "locale": "ja_AQ", "surname": "Штобер"}, "file": "ky", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Зазилия Хамиш Штобер"} {"fields": {"given": "Зазилия", "given2": "Хамиш", "locale": "ja_AQ", "surname": "Штобер"}, "file": "ky", "label": "foreignGGS", "order": "sorting", "shape": 4, "text": "Штобер Зазилия Хамиш"} {"fields": {"given": "Зазилия", "given2": "Хамиш", "locale": "ja_AQ", "surname": "Штобер"}, "file": "ky", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Штобер Зазилия Хамиш"} {"fields": {"given": "Кэйт", "locale": "ja_AQ", "surname": "Мюллер"}, "file": "ky", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Кэйт Мюллер"} {"fields": {"given": "Кэйт", "locale": "ja_AQ", "surname": "Мюллер"}, "file": "ky", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "Мюллер Кэйт"} {"fields": {"given": "Кэйт", "locale": "ja_AQ", "surname": "Мюллер"}, "file": "ky", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Мюллер Кэйт"} {"fields": {"credentials": "Парламент мүчөсү", "generation": "Кенже", "given": "Чыңгыз Айтматов", "given-informal": "Чыке", "given2": "Исхак Раззаков", "locale": "ky_AQ", "surname-core": "Төрөкул", "surname-prefix": "уулу", "surname2": "Махмудов", "title": "Мырза"}, "file": "ky", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Мырза Чыңгыз Айтматов Исхак Раззаков уулу Төрөкул Махмудов Парламент мүчөсү"} {"fields": {"credentials": "Парламент мүчөсү", "generation": "Кенже", "given": "Чыңгыз Айтматов", "given-informal": "Чыке", "given2": "Исхак Раззаков", "locale": "ky_AQ", "surname-core": "Төрөкул", "surname-prefix": "уулу", "surname2": "Махмудов", "title": "Мырза"}, "file": "ky", "label": "nativeFull", "order": "sorting", "shape": null, "text": "уулу Төрөкул Махмудов, Мырза Чыңгыз Айтматов Исхак Раззаков Парламент мүчөсү"} {"fields": {"credentials": "Парламент мүчөсү", "generation": "Кенже", "given": "Чыңгыз Айтматов", "given-informal": "Чыке", "given2": "Исхак Раззаков", "locale": "ky_AQ", "surname-core": "Төрөкул", "surname-prefix": "уулу", "surname2": "Махмудов", "title": "Мырза"}, "file": "ky", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "уулу Төрөкул Чыңгыз Айтматов Исхак Раззаков Парламент мүчөсү"} {"fields": {"given": "Сыймык", "locale": "ky_AQ"}, "file": "ky", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Сыймык"} {"fields": {"given": "Сыймык", "locale": "ky_AQ"}, "file": "ky", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Сыймык"} {"fields": {"given": "Сыймык", "locale": "ky_AQ"}, "file": "ky", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Сыймык"} {"fields": {"given": "Касым", "given2": "Жоомарт", "locale": "ky_AQ", "surname": "Токаев"}, "file": "ky", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Касым Жоомарт Токаев"} {"fields": {"given": "Касым", "given2": "Жоомарт", "locale": "ky_AQ", "surname": "Токаев"}, "file": "ky", "label": "nativeGGS", "order": "sorting", "shape": 4, "text": "Токаев Касым Жоомарт"} {"fields": {"given": "Касым", "given2": "Жоомарт", "locale": "ky_AQ", "surname": "Токаев"}, "file": "ky", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Токаев Касым Жоомарт"} {"fields": {"given": "Каныкей", "locale": "ky_AQ", "surname": "Алымбекова"}, "file": "ky", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Каныкей Алымбекова"} {"fields": {"given": "Каныкей", "locale": "ky_AQ", "surname": "Алымбекова"}, "file": "ky", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "Алымбекова Каныкей"} {"fields": {"given": "Каныкей", "locale": "ky_AQ", "surname": "Алымбекова"}, "file": "ky", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Алымбекова Каныкей"} {"fields": {"credentials": "ດຣ. ປອ.", "generation": "ຮຸ່ນລູກ", "given": "ເອດາ ຄໍນີເລຍ", "given-informal": "ນີເລ", "given2": "ເຊຊາ ມາຕິນ", "locale": "ko_AQ", "surname-core": "ບຣູລ", "surname-prefix": "ວອນ", "surname2": "ກົງຊາເລ ໂດມິງໂກ", "title": "ສຈ. ປອ."}, "file": "lo", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "ດຣ. ປອ. ສຈ. ປອ. ເອດາ ຄໍນີເລຍ ເຊຊາ ມາຕິນ ວອນ ບຣູລ ຮຸ່ນລູກ"} {"fields": {"credentials": "ດຣ. ປອ.", "generation": "ຮຸ່ນລູກ", "given": "ເອດາ ຄໍນີເລຍ", "given-informal": "ນີເລ", "given2": "ເຊຊາ ມາຕິນ", "locale": "ko_AQ", "surname-core": "ບຣູລ", "surname-prefix": "ວອນ", "surname2": "ກົງຊາເລ ໂດມິງໂກ", "title": "ສຈ. ປອ."}, "file": "lo", "label": "foreignFull", "order": "sorting", "shape": null, "text": "ບຣູລ, ເອດາ ຄໍນີເລຍ ເຊຊາ ມາຕິນ ວອນ"} {"fields": {"credentials": "ດຣ. ປອ.", "generation": "ຮຸ່ນລູກ", "given": "ເອດາ ຄໍນີເລຍ", "given-informal": "ນີເລ", "given2": "ເຊຊາ ມາຕິນ", "locale": "ko_AQ", "surname-core": "ບຣູລ", "surname-prefix": "ວອນ", "surname2": "ກົງຊາເລ ໂດມິງໂກ", "title": "ສຈ. ປອ."}, "file": "lo", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "ດຣ. ປອ. ວອນ ບຣູລ ສຈ. ປອ. ເອດາ ຄໍນີເລຍ ເຊຊາ ມາຕິນ ຮຸ່ນລູກ"} {"fields": {"given": "ຊິນແບດ", "locale": "ko_AQ"}, "file": "lo", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "ຊິນແບດ"} {"fields": {"given": "ຊິນແບດ", "locale": "ko_AQ"}, "file": "lo", "label": "foreignG", "order": "sorting", "shape": 4, "text": "ຊິນແບດ"} {"fields": {"given": "ຊິນແບດ", "locale": "ko_AQ"}, "file": "lo", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "ຊິນແບດ"} {"fields": {"given": "ຊາຊິເລຍ", "given2": "ຮາມີສ", "locale": "ko_AQ", "surname": "ສໂຕເບີ"}, "file": "lo", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "ຊາຊິເລຍ ຮາມີສ ສໂຕເບີ"} {"fields": {"given": "ຊາຊິເລຍ", "given2": "ຮາມີສ", "locale": "ko_AQ", "surname": "ສໂຕເບີ"}, "file": "lo", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "ສໂຕເບີ, ຊາຊິເລຍ ຮາມີສ"} {"fields": {"given": "ຊາຊິເລຍ", "given2": "ຮາມີສ", "locale": "ko_AQ", "surname": "ສໂຕເບີ"}, "file": "lo", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "ສໂຕເບີ ຊາຊິເລຍ ຮາມີສ"} {"fields": {"given": "ເຄທີ", "locale": "ko_AQ", "surname": "ມູລເລີ"}, "file": "lo", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "ເຄທີ ມູລເລີ"} {"fields": {"given": "ເຄທີ", "locale": "ko_AQ", "surname": "ມູລເລີ"}, "file": "lo", "label": "foreignGS", "order": "sorting", "shape": null, "text": "ມູລເລີ, ເຄທີ"} {"fields": {"given": "ເຄທີ", "locale": "ko_AQ", "surname": "ມູລເລີ"}, "file": "lo", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "ມູລເລີ ເຄທີ"} {"fields": {"credentials": "ສ.ສ.", "generation": "ຮຸ່ນລູກ", "given": "ເບີແທຣັມ ວິວເບີຟອດຊ", "given-informal": "ເບີຕີ", "given2": "ເຮັນຣີ ໂຣເບີດ", "locale": "lo_AQ", "surname-core": "ວູສເຕີ", "title": "ທ."}, "file": "lo", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "ສ.ສ. ທ. ເບີແທຣັມ ວິວເບີຟອດຊ ເຮັນຣີ ໂຣເບີດ ວູສເຕີ ຮຸ່ນລູກ"} {"fields": {"credentials": "ສ.ສ.", "generation": "ຮຸ່ນລູກ", "given": "ເບີແທຣັມ ວິວເບີຟອດຊ", "given-informal": "ເບີຕີ", "given2": "ເຮັນຣີ ໂຣເບີດ", "locale": "lo_AQ", "surname-core": "ວູສເຕີ", "title": "ທ."}, "file": "lo", "label": "nativeFull", "order": "sorting", "shape": null, "text": "ວູສເຕີ, ເບີແທຣັມ ວິວເບີຟອດຊ ເຮັນຣີ ໂຣເບີດ"} {"fields": {"credentials": "ສ.ສ.", "generation": "ຮຸ່ນລູກ", "given": "ເບີແທຣັມ ວິວເບີຟອດຊ", "given-informal": "ເບີຕີ", "given2": "ເຮັນຣີ ໂຣເບີດ", "locale": "lo_AQ", "surname-core": "ວູສເຕີ", "title": "ທ."}, "file": "lo", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "ສ.ສ. ວູສເຕີ ທ. ເບີແທຣັມ ວິວເບີຟອດຊ ເຮັນຣີ ໂຣເບີດ ຮຸ່ນລູກ"} {"fields": {"given": "ຄຳຫຼ້າ", "locale": "lo_AQ"}, "file": "lo", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "ຄຳຫຼ້າ"} {"fields": {"given": "ຄຳຫຼ້າ", "locale": "lo_AQ"}, "file": "lo", "label": "nativeG", "order": "sorting", "shape": 4, "text": "ຄຳຫຼ້າ"} {"fields": {"given": "ຄຳຫຼ້າ", "locale": "lo_AQ"}, "file": "lo", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "ຄຳຫຼ້າ"} {"fields": {"given": "ມະນີ", "given2": "ມະນີຈັນ", "locale": "lo_AQ", "surname": "ວັດທະນາ"}, "file": "lo", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "ມະນີ ມະນີຈັນ ວັດທະນາ"} {"fields": {"given": "ມະນີ", "given2": "ມະນີຈັນ", "locale": "lo_AQ", "surname": "ວັດທະນາ"}, "file": "lo", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "ວັດທະນາ, ມະນີ ມະນີຈັນ"} {"fields": {"given": "ມະນີ", "given2": "ມະນີຈັນ", "locale": "lo_AQ", "surname": "ວັດທະນາ"}, "file": "lo", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "ວັດທະນາ ມະນີ ມະນີຈັນ"} {"fields": {"given": "ນາລິນ", "locale": "lo_AQ", "surname": "ເອກະພົນ"}, "file": "lo", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "ນາລິນ ເອກະພົນ"} {"fields": {"given": "ນາລິນ", "locale": "lo_AQ", "surname": "ເອກະພົນ"}, "file": "lo", "label": "nativeGS", "order": "sorting", "shape": null, "text": "ເອກະພົນ, ນາລິນ"} {"fields": {"given": "ນາລິນ", "locale": "lo_AQ", "surname": "ເອກະພົນ"}, "file": "lo", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "ເອກະພົນ ນາລິນ"} {"fields": {"credentials": "med. m. dr.", "generation": "jaun.", "given": "Ada Kornelija", "given-informal": "Nilka", "given2": "Eva Sofija", "locale": "ja_AQ", "surname-core": "Vilkas", "surname-prefix": "von", "surname2": "Jankauskas-Baranauskas", "title": "prof. dr."}, "file": "lt", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "med. m. dr. prof. dr. Ada Kornelija Eva Sofija von Vilkas Jankauskas-Baranauskas"} {"fields": {"credentials": "med. m. dr.", "generation": "jaun.", "given": "Ada Kornelija", "given-informal": "Nilka", "given2": "Eva Sofija", "locale": "ja_AQ", "surname-core": "Vilkas", "surname-prefix": "von", "surname2": "Jankauskas-Baranauskas", "title": "prof. dr."}, "file": "lt", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Vilkas, Ada Kornelija Eva Sofija von"} {"fields": {"credentials": "med. m. dr.", "generation": "jaun.", "given": "Ada Kornelija", "given-informal": "Nilka", "given2": "Eva Sofija", "locale": "ja_AQ", "surname-core": "Vilkas", "surname-prefix": "von", "surname2": "Jankauskas-Baranauskas", "title": "prof. dr."}, "file": "lt", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "prof. dr. von Vilkas Jankauskas-Baranauskas, Ada Kornelija Eva Sofija"} {"fields": {"given": "Jurgis", "locale": "ja_AQ"}, "file": "lt", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Jurgis"} {"fields": {"given": "Jurgis", "locale": "ja_AQ"}, "file": "lt", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Jurgis"} {"fields": {"given": "Jurgis", "locale": "ja_AQ"}, "file": "lt", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Jurgis"} {"fields": {"given": "Sofija", "given2": "Rita", "locale": "ja_AQ", "surname": "Penkauskienė"}, "file": "lt", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Sofija Rita Penkauskienė"} {"fields": {"given": "Sofija", "given2": "Rita", "locale": "ja_AQ", "surname": "Penkauskienė"}, "file": "lt", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Penkauskienė, Sofija Rita"} {"fields": {"given": "Sofija", "given2": "Rita", "locale": "ja_AQ", "surname": "Penkauskienė"}, "file": "lt", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Penkauskienė Sofija Rita"} {"fields": {"given": "Kotryna", "locale": "ja_AQ", "surname": "Miliūtė"}, "file": "lt", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Kotryna Miliūtė"} {"fields": {"given": "Kotryna", "locale": "ja_AQ", "surname": "Miliūtė"}, "file": "lt", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Miliūtė, Kotryna"} {"fields": {"given": "Kotryna", "locale": "ja_AQ", "surname": "Miliūtė"}, "file": "lt", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Miliūtė Kotryna"} {"fields": {"credentials": "dr.", "generation": "jaun.", "given": "Gabrielė", "given-informal": "Gabrė", "given2": "Aldona", "locale": "lt_AQ", "surname-core": "Petraitytė", "title": "p."}, "file": "lt", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "dr. p. Gabrielė Aldona Petraitytė"} {"fields": {"credentials": "dr.", "generation": "jaun.", "given": "Gabrielė", "given-informal": "Gabrė", "given2": "Aldona", "locale": "lt_AQ", "surname-core": "Petraitytė", "title": "p."}, "file": "lt", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Petraitytė, Gabrielė Aldona"} {"fields": {"credentials": "dr.", "generation": "jaun.", "given": "Gabrielė", "given-informal": "Gabrė", "given2": "Aldona", "locale": "lt_AQ", "surname-core": "Petraitytė", "title": "p."}, "file": "lt", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "p. Petraitytė Gabrielė Aldona"} {"fields": {"given": "Rokas", "locale": "lt_AQ"}, "file": "lt", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Rokas"} {"fields": {"given": "Rokas", "locale": "lt_AQ"}, "file": "lt", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Rokas"} {"fields": {"given": "Rokas", "locale": "lt_AQ"}, "file": "lt", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Rokas"} {"fields": {"given": "Jonas", "given2": "Lukas", "locale": "lt_AQ", "surname": "Petrauskas"}, "file": "lt", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Jonas Lukas Petrauskas"} {"fields": {"given": "Jonas", "given2": "Lukas", "locale": "lt_AQ", "surname": "Petrauskas"}, "file": "lt", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Petrauskas, Jonas Lukas"} {"fields": {"given": "Jonas", "given2": "Lukas", "locale": "lt_AQ", "surname": "Petrauskas"}, "file": "lt", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Petrauskas Jonas Lukas"} {"fields": {"given": "Irena", "locale": "lt_AQ", "surname": "Kazlauskienė"}, "file": "lt", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Irena Kazlauskienė"} {"fields": {"given": "Irena", "locale": "lt_AQ", "surname": "Kazlauskienė"}, "file": "lt", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Kazlauskienė, Irena"} {"fields": {"given": "Irena", "locale": "lt_AQ", "surname": "Kazlauskienė"}, "file": "lt", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Kazlauskienė Irena"} {"fields": {"credentials": "Dr.philol.", "generation": "jaunākā", "given": "Laima Kristīne", "given-informal": "Ella", "given2": "Eva Sofija", "locale": "ja_AQ", "surname-core": "Volfa", "surname-prefix": "van den", "surname2": "Bērziņa-Kalniņa", "title": "Prof."}, "file": "lv", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Dr.philol. Laima Kristīne Eva Sofija van den Volfa Bērziņa-Kalniņa"} {"fields": {"credentials": "Dr.philol.", "generation": "jaunākā", "given": "Laima Kristīne", "given-informal": "Ella", "given2": "Eva Sofija", "locale": "ja_AQ", "surname-core": "Volfa", "surname-prefix": "van den", "surname2": "Bērziņa-Kalniņa", "title": "Prof."}, "file": "lv", "label": "foreignFull", "order": "sorting", "shape": 4, "text": "van den Volfa Laima Kristīne Eva Sofija"} {"fields": {"credentials": "Dr.philol.", "generation": "jaunākā", "given": "Laima Kristīne", "given-informal": "Ella", "given2": "Eva Sofija", "locale": "ja_AQ", "surname-core": "Volfa", "surname-prefix": "van den", "surname2": "Bērziņa-Kalniņa", "title": "Prof."}, "file": "lv", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "van den Volfa Laima Kristīne"} {"fields": {"given": "Sinbads", "locale": "ja_AQ"}, "file": "lv", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbads"} {"fields": {"given": "Sinbads", "locale": "ja_AQ"}, "file": "lv", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbads"} {"fields": {"given": "Sinbads", "locale": "ja_AQ"}, "file": "lv", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbads"} {"fields": {"given": "Pīters", "given2": "Džims", "locale": "ja_AQ", "surname": "Millers"}, "file": "lv", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Pīters Džims Millers"} {"fields": {"given": "Pīters", "given2": "Džims", "locale": "ja_AQ", "surname": "Millers"}, "file": "lv", "label": "foreignGGS", "order": "sorting", "shape": 4, "text": "Millers Pīters Džims"} {"fields": {"given": "Pīters", "given2": "Džims", "locale": "ja_AQ", "surname": "Millers"}, "file": "lv", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Millers Pīters"} {"fields": {"given": "Keita", "locale": "ja_AQ", "surname": "Millere"}, "file": "lv", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Keita Millere"} {"fields": {"given": "Keita", "locale": "ja_AQ", "surname": "Millere"}, "file": "lv", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "Millere Keita"} {"fields": {"given": "Keita", "locale": "ja_AQ", "surname": "Millere"}, "file": "lv", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Millere Keita"} {"fields": {"credentials": "MP", "generation": "jaunākais", "given": "Pēteris", "given-informal": "Pēcis", "given2": "Kārlis", "locale": "lv_AQ", "surname-core": "Ozoliņš", "title": "kungs"}, "file": "lv", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "MP Pēteris Kārlis Ozoliņš"} {"fields": {"credentials": "MP", "generation": "jaunākais", "given": "Pēteris", "given-informal": "Pēcis", "given2": "Kārlis", "locale": "lv_AQ", "surname-core": "Ozoliņš", "title": "kungs"}, "file": "lv", "label": "nativeFull", "order": "sorting", "shape": 4, "text": "Ozoliņš Pēteris Kārlis"} {"fields": {"credentials": "MP", "generation": "jaunākais", "given": "Pēteris", "given-informal": "Pēcis", "given2": "Kārlis", "locale": "lv_AQ", "surname-core": "Ozoliņš", "title": "kungs"}, "file": "lv", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Ozoliņš Pēteris"} {"fields": {"given": "Jānis", "locale": "lv_AQ"}, "file": "lv", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Jānis"} {"fields": {"given": "Jānis", "locale": "lv_AQ"}, "file": "lv", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Jānis"} {"fields": {"given": "Jānis", "locale": "lv_AQ"}, "file": "lv", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Jānis"} {"fields": {"given": "Anna", "given2": "Paula", "locale": "lv_AQ", "surname": "Bērziņa"}, "file": "lv", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Anna Paula Bērziņa"} {"fields": {"given": "Anna", "given2": "Paula", "locale": "lv_AQ", "surname": "Bērziņa"}, "file": "lv", "label": "nativeGGS", "order": "sorting", "shape": 4, "text": "Bērziņa Anna Paula"} {"fields": {"given": "Anna", "given2": "Paula", "locale": "lv_AQ", "surname": "Bērziņa"}, "file": "lv", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Bērziņa Anna"} {"fields": {"given": "Aina", "locale": "lv_AQ", "surname": "Kalniņa"}, "file": "lv", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Aina Kalniņa"} {"fields": {"given": "Aina", "locale": "lv_AQ", "surname": "Kalniņa"}, "file": "lv", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "Kalniņa Aina"} {"fields": {"given": "Aina", "locale": "lv_AQ", "surname": "Kalniņa"}, "file": "lv", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Kalniņa Aina"} {"fields": {"credentials": "дипл. инг.", "generation": "јуниор", "given": "Ана Марија", "given-informal": "Ен", "given2": "Цезар Мартин", "locale": "ja_AQ", "surname-core": "Волф", "surname-prefix": "ван ден", "surname2": "Бекер Шмит", "title": "проф. д-р"}, "file": "mk", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "проф. д-р Ана Марија Цезар Мартин ван ден Волф"} {"fields": {"credentials": "дипл. инг.", "generation": "јуниор", "given": "Ана Марија", "given-informal": "Ен", "given2": "Цезар Мартин", "locale": "ja_AQ", "surname-core": "Волф", "surname-prefix": "ван ден", "surname2": "Бекер Шмит", "title": "проф. д-р"}, "file": "mk", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Волф, Ана Марија Цезар Мартин ван ден"} {"fields": {"credentials": "дипл. инг.", "generation": "јуниор", "given": "Ана Марија", "given-informal": "Ен", "given2": "Цезар Мартин", "locale": "ja_AQ", "surname-core": "Волф", "surname-prefix": "ван ден", "surname2": "Бекер Шмит", "title": "проф. д-р"}, "file": "mk", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "проф. д-р ван ден Волф Ана Марија Цезар Мартин"} {"fields": {"given": "Синбад", "locale": "ja_AQ"}, "file": "mk", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Синбад"} {"fields": {"given": "Синбад", "locale": "ja_AQ"}, "file": "mk", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Синбад"} {"fields": {"given": "Синбад", "locale": "ja_AQ"}, "file": "mk", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Синбад"} {"fields": {"given": "Цецилија", "given2": "Хамиш", "locale": "ja_AQ", "surname": "Штебер"}, "file": "mk", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Цецилија Хамиш Штебер"} {"fields": {"given": "Цецилија", "given2": "Хамиш", "locale": "ja_AQ", "surname": "Штебер"}, "file": "mk", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Штебер, Цецилија Хамиш"} {"fields": {"given": "Цецилија", "given2": "Хамиш", "locale": "ja_AQ", "surname": "Штебер"}, "file": "mk", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Штебер Цецилија Хамиш"} {"fields": {"given": "Кете", "locale": "ja_AQ", "surname": "Милер"}, "file": "mk", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Кете Милер"} {"fields": {"given": "Кете", "locale": "ja_AQ", "surname": "Милер"}, "file": "mk", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Милер, Кете"} {"fields": {"given": "Кете", "locale": "ja_AQ", "surname": "Милер"}, "file": "mk", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Милер Кете"} {"fields": {"credentials": "дипл. инг.", "given": "Крсте", "given-informal": "Крки", "given2": "Петков", "locale": "mk_AQ", "surname-core": "Мисирков", "title": "проф. д-р"}, "file": "mk", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "проф. д-р Крсте Петков Мисирков"} {"fields": {"credentials": "дипл. инг.", "given": "Крсте", "given-informal": "Крки", "given2": "Петков", "locale": "mk_AQ", "surname-core": "Мисирков", "title": "проф. д-р"}, "file": "mk", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Мисирков, Крсте Петков"} {"fields": {"credentials": "дипл. инг.", "given": "Крсте", "given-informal": "Крки", "given2": "Петков", "locale": "mk_AQ", "surname-core": "Мисирков", "title": "проф. д-р"}, "file": "mk", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "проф. д-р Мисирков Крсте Петков"} {"fields": {"given": "Крсте", "locale": "mk_AQ"}, "file": "mk", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Крсте"} {"fields": {"given": "Крсте", "locale": "mk_AQ"}, "file": "mk", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Крсте"} {"fields": {"given": "Крсте", "locale": "mk_AQ"}, "file": "mk", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Крсте"} {"fields": {"given": "Крсте", "given2": "Петков", "locale": "mk_AQ", "surname": "Мисирков"}, "file": "mk", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Крсте Петков Мисирков"} {"fields": {"given": "Крсте", "given2": "Петков", "locale": "mk_AQ", "surname": "Мисирков"}, "file": "mk", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Мисирков, Крсте Петков"} {"fields": {"given": "Крсте", "given2": "Петков", "locale": "mk_AQ", "surname": "Мисирков"}, "file": "mk", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Мисирков Крсте Петков"} {"fields": {"given": "Крсте", "locale": "mk_AQ", "surname": "Мисирков"}, "file": "mk", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Крсте Мисирков"} {"fields": {"given": "Крсте", "locale": "mk_AQ", "surname": "Мисирков"}, "file": "mk", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Мисирков, Крсте"} {"fields": {"given": "Крсте", "locale": "mk_AQ", "surname": "Мисирков"}, "file": "mk", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Мисирков Крсте"} {"fields": {"credentials": "എം.ഡി. ഡി.ഡി.എസ്", "generation": "ജൂനിയർ", "given": "എയ്‌ഡ കോർണീലിയ", "given-informal": "നീൽ", "given2": "സീസ൪ മാ൪ട്ടിൻ", "locale": "ja_AQ", "surname-core": "ബ്ര്യൂൾ", "surname-prefix": "വാൻ", "surname2": "ഗോൺസാലെസ് ഡൊമിംഗോ", "title": "പ്രൊഫ. ഡോ."}, "file": "ml", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "എയ്‌ഡ കോർണീലിയ സീസ൪ മാ൪ട്ടിൻ വാൻ ബ്ര്യൂൾ എം.ഡി. ഡി.ഡി.എസ്"} {"fields": {"credentials": "എം.ഡി. ഡി.ഡി.എസ്", "generation": "ജൂനിയർ", "given": "എയ്‌ഡ കോർണീലിയ", "given-informal": "നീൽ", "given2": "സീസ൪ മാ൪ട്ടിൻ", "locale": "ja_AQ", "surname-core": "ബ്ര്യൂൾ", "surname-prefix": "വാൻ", "surname2": "ഗോൺസാലെസ് ഡൊമിംഗോ", "title": "പ്രൊഫ. ഡോ."}, "file": "ml", "label": "foreignFull", "order": "sorting", "shape": null, "text": "ബ്ര്യൂൾ, എയ്‌ഡ കോർണീലിയ സീസ൪ മാ൪ട്ടിൻ വാൻ"} {"fields": {"credentials": "എം.ഡി. ഡി.ഡി.എസ്", "generation": "ജൂനിയർ", "given": "എയ്‌ഡ കോർണീലിയ", "given-informal": "നീൽ", "given2": "സീസ൪ മാ൪ട്ടിൻ", "locale": "ja_AQ", "surname-core": "ബ്ര്യൂൾ", "surname-prefix": "വാൻ", "surname2": "ഗോൺസാലെസ് ഡൊമിംഗോ", "title": "പ്രൊഫ. ഡോ."}, "file": "ml", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "വാൻ ബ്ര്യൂൾ പ്രൊഫ. ഡോ. എയ്‌ഡ കോർണീലിയ സീസ൪ മാ൪ട്ടിൻ ജൂനിയർ, എം.ഡി. ഡി.ഡി.എസ്"} {"fields": {"given": "സിൻബാദ്", "locale": "ja_AQ"}, "file": "ml", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "സിൻബാദ്"} {"fields": {"given": "സിൻബാദ്", "locale": "ja_AQ"}, "file": "ml", "label": "foreignG", "order": "sorting", "shape": 4, "text": "സിൻബാദ്"} {"fields": {"given": "സിൻബാദ്", "locale": "ja_AQ"}, "file": "ml", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "സിൻബാദ്"} {"fields": {"given": "സസിലിയ", "given2": "ഹാമിഷ്", "locale": "ja_AQ", "surname": "സ്‌റ്റോബർ"}, "file": "ml", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "സസിലിയ ഹാമിഷ് സ്‌റ്റോബർ"} {"fields": {"given": "സസിലിയ", "given2": "ഹാമിഷ്", "locale": "ja_AQ", "surname": "സ്‌റ്റോബർ"}, "file": "ml", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "സ്‌റ്റോബർ, സസിലിയ ഹാമിഷ്"} {"fields": {"given": "സസിലിയ", "given2": "ഹാമിഷ്", "locale": "ja_AQ", "surname": "സ്‌റ്റോബർ"}, "file": "ml", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "സ്‌റ്റോബർ സസിലിയ ഹാമിഷ്"} {"fields": {"given": "കാത്തി", "locale": "ja_AQ", "surname": "മുള്ളർ"}, "file": "ml", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "കാത്തി മുള്ളർ"} {"fields": {"given": "കാത്തി", "locale": "ja_AQ", "surname": "മുള്ളർ"}, "file": "ml", "label": "foreignGS", "order": "sorting", "shape": null, "text": "മുള്ളർ, കാത്തി"} {"fields": {"given": "കാത്തി", "locale": "ja_AQ", "surname": "മുള്ളർ"}, "file": "ml", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "മുള്ളർ കാത്തി"} {"fields": {"credentials": "എംപി", "generation": "ജൂനിയർ", "given": "തോമസ് കുരുവിള", "given-informal": "ടോമിച്ചൻ", "given2": "അന്തോണി", "locale": "ml_AQ", "surname-core": "കറുവാപ്പറമ്പിൽ", "title": "ശ്രീ."}, "file": "ml", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "തോമസ് കുരുവിള അന്തോണി കറുവാപ്പറമ്പിൽ എംപി"} {"fields": {"credentials": "എംപി", "generation": "ജൂനിയർ", "given": "തോമസ് കുരുവിള", "given-informal": "ടോമിച്ചൻ", "given2": "അന്തോണി", "locale": "ml_AQ", "surname-core": "കറുവാപ്പറമ്പിൽ", "title": "ശ്രീ."}, "file": "ml", "label": "nativeFull", "order": "sorting", "shape": null, "text": "കറുവാപ്പറമ്പിൽ, തോമസ് കുരുവിള അന്തോണി"} {"fields": {"credentials": "എംപി", "generation": "ജൂനിയർ", "given": "തോമസ് കുരുവിള", "given-informal": "ടോമിച്ചൻ", "given2": "അന്തോണി", "locale": "ml_AQ", "surname-core": "കറുവാപ്പറമ്പിൽ", "title": "ശ്രീ."}, "file": "ml", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "കറുവാപ്പറമ്പിൽ ശ്രീ. തോമസ് കുരുവിള അന്തോണി ജൂനിയർ, എംപി"} {"fields": {"given": "ദീപ", "locale": "ml_AQ"}, "file": "ml", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "ദീപ"} {"fields": {"given": "ദീപ", "locale": "ml_AQ"}, "file": "ml", "label": "nativeG", "order": "sorting", "shape": 4, "text": "ദീപ"} {"fields": {"given": "ദീപ", "locale": "ml_AQ"}, "file": "ml", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "ദീപ"} {"fields": {"given": "ജോൺ", "given2": "ചാണ്ടി", "locale": "ml_AQ", "surname": "പടവീട്ടിൽ"}, "file": "ml", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "ജോൺ ചാണ്ടി പടവീട്ടിൽ"} {"fields": {"given": "ജോൺ", "given2": "ചാണ്ടി", "locale": "ml_AQ", "surname": "പടവീട്ടിൽ"}, "file": "ml", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "പടവീട്ടിൽ, ജോൺ ചാണ്ടി"} {"fields": {"given": "ജോൺ", "given2": "ചാണ്ടി", "locale": "ml_AQ", "surname": "പടവീട്ടിൽ"}, "file": "ml", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "പടവീട്ടിൽ ജോൺ ചാണ്ടി"} {"fields": {"given": "അശ്വതി", "locale": "ml_AQ", "surname": "നായർ"}, "file": "ml", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "അശ്വതി നായർ"} {"fields": {"given": "അശ്വതി", "locale": "ml_AQ", "surname": "നായർ"}, "file": "ml", "label": "nativeGS", "order": "sorting", "shape": null, "text": "നായർ, അശ്വതി"} {"fields": {"given": "അശ്വതി", "locale": "ml_AQ", "surname": "നായർ"}, "file": "ml", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "നായർ അശ്വതി"} {"fields": {"credentials": "Анагаах ухааны доктор", "generation": "Жр", "given": "Ада Корнелиа", "given-informal": "Ниль", "given2": "Эва Софиа", "locale": "en_AQ", "surname-core": "Брюхл", "surname-prefix": "вон", "surname2": "Гонзалес Доминго", "title": "Проф. Др."}, "file": "mn", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Проф. Др. Ада Корнелиа Эва Софиа вон Брюхл Жр, Анагаах ухааны доктор"} {"fields": {"credentials": "Анагаах ухааны доктор", "generation": "Жр", "given": "Ада Корнелиа", "given-informal": "Ниль", "given2": "Эва Софиа", "locale": "en_AQ", "surname-core": "Брюхл", "surname-prefix": "вон", "surname2": "Гонзалес Доминго", "title": "Проф. Др."}, "file": "mn", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Брюхл, Ада Корнелиа Эва Софиа вон"} {"fields": {"credentials": "Анагаах ухааны доктор", "generation": "Жр", "given": "Ада Корнелиа", "given-informal": "Ниль", "given2": "Эва Софиа", "locale": "en_AQ", "surname-core": "Брюхл", "surname-prefix": "вон", "surname2": "Гонзалес Доминго", "title": "Проф. Др."}, "file": "mn", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "вон Брюхл Гонзалес Доминго Проф. Др. Ада Корнелиа Эва Софиа Анагаах ухааны доктор"} {"fields": {"given": "Синбад", "locale": "en_AQ"}, "file": "mn", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Синбад"} {"fields": {"given": "Синбад", "locale": "en_AQ"}, "file": "mn", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Синбад"} {"fields": {"given": "Синбад", "locale": "en_AQ"}, "file": "mn", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Синбад"} {"fields": {"given": "Зазилиа", "given2": "Хамиш", "locale": "en_AQ", "surname": "Стобер"}, "file": "mn", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Зазилиа Хамиш Стобер"} {"fields": {"given": "Зазилиа", "given2": "Хамиш", "locale": "en_AQ", "surname": "Стобер"}, "file": "mn", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Стобер, Зазилиа Хамиш"} {"fields": {"given": "Зазилиа", "given2": "Хамиш", "locale": "en_AQ", "surname": "Стобер"}, "file": "mn", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Стобер Зазилиа Хамиш"} {"fields": {"given": "Кате", "locale": "en_AQ", "surname": "Мюллер"}, "file": "mn", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Кате Мюллер"} {"fields": {"given": "Кате", "locale": "en_AQ", "surname": "Мюллер"}, "file": "mn", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Мюллер, Кате"} {"fields": {"given": "Кате", "locale": "en_AQ", "surname": "Мюллер"}, "file": "mn", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Мюллер Кате"} {"fields": {"given": "Азжаргал", "locale": "mn_AQ", "surname-core": "Төмөр", "title": "Ноён"}, "file": "mn", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Ноён Азжаргал Төмөр"} {"fields": {"given": "Азжаргал", "locale": "mn_AQ", "surname-core": "Төмөр", "title": "Ноён"}, "file": "mn", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Төмөр, Азжаргал"} {"fields": {"given": "Азжаргал", "locale": "mn_AQ", "surname-core": "Төмөр", "title": "Ноён"}, "file": "mn", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Төмөр Ноён Азжаргал"} {"fields": {"given": "Бат", "locale": "mn_AQ"}, "file": "mn", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Бат"} {"fields": {"given": "Бат", "locale": "mn_AQ"}, "file": "mn", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Бат"} {"fields": {"given": "Бат", "locale": "mn_AQ"}, "file": "mn", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Бат"} {"fields": {"given": "Дулмаа", "locale": "mn_AQ", "surname": "Болд"}, "file": "mn", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Дулмаа Болд"} {"fields": {"given": "Дулмаа", "locale": "mn_AQ", "surname": "Болд"}, "file": "mn", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Болд, Дулмаа"} {"fields": {"given": "Дулмаа", "locale": "mn_AQ", "surname": "Болд"}, "file": "mn", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Болд Дулмаа"} {"fields": {"given": "Цэцэг", "locale": "mn_AQ", "surname": "Дорж"}, "file": "mn", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Цэцэг Дорж"} {"fields": {"given": "Цэцэг", "locale": "mn_AQ", "surname": "Дорж"}, "file": "mn", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Дорж, Цэцэг"} {"fields": {"given": "Цэцэг", "locale": "mn_AQ", "surname": "Дорж"}, "file": "mn", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Дорж Цэцэг"} {"fields": {"credentials": "एम. डी. डी. डी. एस.", "generation": "ज्यु.", "given": "अदा शर्मा", "given-informal": "नील", "given2": "सीझर मार्टिन", "locale": "ja_AQ", "surname-core": "ब्र्यूल", "surname-prefix": "व्हॉन", "surname2": "गोन्झालेझ डोमिंगो", "title": "प्रोफे.डॉ."}, "file": "mr", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "प्रोफे.डॉ. अदा शर्मा सीझर मार्टिन व्हॉन ब्र्यूल ज्यु., एम. डी. डी. डी. एस."} {"fields": {"credentials": "एम. डी. डी. डी. एस.", "generation": "ज्यु.", "given": "अदा शर्मा", "given-informal": "नील", "given2": "सीझर मार्टिन", "locale": "ja_AQ", "surname-core": "ब्र्यूल", "surname-prefix": "व्हॉन", "surname2": "गोन्झालेझ डोमिंगो", "title": "प्रोफे.डॉ."}, "file": "mr", "label": "foreignFull", "order": "sorting", "shape": null, "text": "ब्र्यूल, अदा शर्मा सीझर मार्टिन व्हॉन"} {"fields": {"credentials": "एम. डी. डी. डी. एस.", "generation": "ज्यु.", "given": "अदा शर्मा", "given-informal": "नील", "given2": "सीझर मार्टिन", "locale": "ja_AQ", "surname-core": "ब्र्यूल", "surname-prefix": "व्हॉन", "surname2": "गोन्झालेझ डोमिंगो", "title": "प्रोफे.डॉ."}, "file": "mr", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "व्हॉन ब्र्यूल प्रोफे.डॉ. अदा शर्मा सीझर मार्टिन ज्यु., एम. डी. डी. डी. एस."} {"fields": {"given": "सिंदबाद", "locale": "ja_AQ"}, "file": "mr", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "सिंदबाद"} {"fields": {"given": "सिंदबाद", "locale": "ja_AQ"}, "file": "mr", "label": "foreignG", "order": "sorting", "shape": 4, "text": "सिंदबाद"} {"fields": {"given": "सिंदबाद", "locale": "ja_AQ"}, "file": "mr", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "सिंदबाद"} {"fields": {"given": "आलिया", "given2": "हमीश", "locale": "ja_AQ", "surname": "हेमंत"}, "file": "mr", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "आलिया हमीश हेमंत"} {"fields": {"given": "आलिया", "given2": "हमीश", "locale": "ja_AQ", "surname": "हेमंत"}, "file": "mr", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "हेमंत, आलिया हमीश"} {"fields": {"given": "आलिया", "given2": "हमीश", "locale": "ja_AQ", "surname": "हेमंत"}, "file": "mr", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "हेमंत आलिया हमीश"} {"fields": {"given": "काठे", "locale": "ja_AQ", "surname": "मुलर"}, "file": "mr", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "काठे मुलर"} {"fields": {"given": "काठे", "locale": "ja_AQ", "surname": "मुलर"}, "file": "mr", "label": "foreignGS", "order": "sorting", "shape": null, "text": "मुलर, काठे"} {"fields": {"given": "काठे", "locale": "ja_AQ", "surname": "मुलर"}, "file": "mr", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "मुलर काठे"} {"fields": {"credentials": "एम. पी.", "generation": "क.", "given": "धिरज देशपांडे", "given-informal": "धीरू", "given2": "विजय देशपांडे", "locale": "mr_AQ", "surname-core": "देशपांडे", "title": "श्री."}, "file": "mr", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "श्री. धिरज देशपांडे विजय देशपांडे देशपांडे क., एम. पी."} {"fields": {"credentials": "एम. पी.", "generation": "क.", "given": "धिरज देशपांडे", "given-informal": "धीरू", "given2": "विजय देशपांडे", "locale": "mr_AQ", "surname-core": "देशपांडे", "title": "श्री."}, "file": "mr", "label": "nativeFull", "order": "sorting", "shape": null, "text": "देशपांडे, धिरज देशपांडे विजय देशपांडे"} {"fields": {"credentials": "एम. पी.", "generation": "क.", "given": "धिरज देशपांडे", "given-informal": "धीरू", "given2": "विजय देशपांडे", "locale": "mr_AQ", "surname-core": "देशपांडे", "title": "श्री."}, "file": "mr", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "देशपांडे श्री. धिरज देशपांडे विजय देशपांडे क., एम. पी."} {"fields": {"given": "माधुरी", "locale": "mr_AQ"}, "file": "mr", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "माधुरी"} {"fields": {"given": "माधुरी", "locale": "mr_AQ"}, "file": "mr", "label": "nativeG", "order": "sorting", "shape": 4, "text": "माधुरी"} {"fields": {"given": "माधुरी", "locale": "mr_AQ"}, "file": "mr", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "माधुरी"} {"fields": {"given": "रिधान", "given2": "समीर", "locale": "mr_AQ", "surname": "फडके"}, "file": "mr", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "रिधान समीर फडके"} {"fields": {"given": "रिधान", "given2": "समीर", "locale": "mr_AQ", "surname": "फडके"}, "file": "mr", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "फडके, रिधान समीर"} {"fields": {"given": "रिधान", "given2": "समीर", "locale": "mr_AQ", "surname": "फडके"}, "file": "mr", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "फडके रिधान समीर"} {"fields": {"given": "धिरज", "locale": "mr_AQ", "surname": "देशपांडे"}, "file": "mr", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "धिरज देशपांडे"} {"fields": {"given": "धिरज", "locale": "mr_AQ", "surname": "देशपांडे"}, "file": "mr", "label": "nativeGS", "order": "sorting", "shape": null, "text": "देशपांडे, धिरज"} {"fields": {"given": "धिरज", "locale": "mr_AQ", "surname": "देशपांडे"}, "file": "mr", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "देशपांडे धिरज"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Nelly", "given2": "Martina", "locale": "ja_AQ", "surname-core": "Bruhel", "surname-prefix": "von", "surname2": "Domingo", "title": "Prof. Dr."}, "file": "ms", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Prof. Dr. Ada Cornelia Martina von Bruhel Domingo MD DDS"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Nelly", "given2": "Martina", "locale": "ja_AQ", "surname-core": "Bruhel", "surname-prefix": "von", "surname2": "Domingo", "title": "Prof. Dr."}, "file": "ms", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Bruhel, Ada Cornelia Martina von"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Nelly", "given2": "Martina", "locale": "ja_AQ", "surname-core": "Bruhel", "surname-prefix": "von", "surname2": "Domingo", "title": "Prof. Dr."}, "file": "ms", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "von Bruhel Ada Cornelia Martina MD DDS"} {"fields": {"given": "John", "locale": "ja_AQ"}, "file": "ms", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "John"} {"fields": {"given": "John", "locale": "ja_AQ"}, "file": "ms", "label": "foreignG", "order": "sorting", "shape": 4, "text": "John"} {"fields": {"given": "John", "locale": "ja_AQ"}, "file": "ms", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "John"} {"fields": {"given": "Zendaya", "given2": "Marie", "locale": "ja_AQ", "surname": "Coleman"}, "file": "ms", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zendaya Marie Coleman"} {"fields": {"given": "Zendaya", "given2": "Marie", "locale": "ja_AQ", "surname": "Coleman"}, "file": "ms", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Coleman, Zendaya Marie"} {"fields": {"given": "Zendaya", "given2": "Marie", "locale": "ja_AQ", "surname": "Coleman"}, "file": "ms", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Coleman Zendaya Marie"} {"fields": {"given": "Kathy", "locale": "ja_AQ", "surname": "Mueller"}, "file": "ms", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Kathy Mueller"} {"fields": {"given": "Kathy", "locale": "ja_AQ", "surname": "Mueller"}, "file": "ms", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Mueller, Kathy"} {"fields": {"given": "Kathy", "locale": "ja_AQ", "surname": "Mueller"}, "file": "ms", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Mueller Kathy"} {"fields": {"credentials": "MP", "generation": "Jr", "given": "Syed", "given-informal": "Razak", "given2": "Abd Aziz", "locale": "ms_AQ", "surname-core": "Ismail", "surname-prefix": "Syed A", "surname2": "Petra", "title": "Tn."}, "file": "ms", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Tn. Syed Abd Aziz Syed A Ismail Petra MP"} {"fields": {"credentials": "MP", "generation": "Jr", "given": "Syed", "given-informal": "Razak", "given2": "Abd Aziz", "locale": "ms_AQ", "surname-core": "Ismail", "surname-prefix": "Syed A", "surname2": "Petra", "title": "Tn."}, "file": "ms", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Ismail, Syed Abd Aziz Syed A"} {"fields": {"credentials": "MP", "generation": "Jr", "given": "Syed", "given-informal": "Razak", "given2": "Abd Aziz", "locale": "ms_AQ", "surname-core": "Ismail", "surname-prefix": "Syed A", "surname2": "Petra", "title": "Tn."}, "file": "ms", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Syed A Ismail Syed Abd Aziz MP"} {"fields": {"given": "Sulaiman", "locale": "ms_AQ"}, "file": "ms", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Sulaiman"} {"fields": {"given": "Sulaiman", "locale": "ms_AQ"}, "file": "ms", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Sulaiman"} {"fields": {"given": "Sulaiman", "locale": "ms_AQ"}, "file": "ms", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Sulaiman"} {"fields": {"given": "Syed", "given2": "Sadiq", "locale": "ms_AQ", "surname": "Syed Hassan"}, "file": "ms", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Syed Sadiq Syed Hassan"} {"fields": {"given": "Syed", "given2": "Sadiq", "locale": "ms_AQ", "surname": "Syed Hassan"}, "file": "ms", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Syed Hassan, Syed Sadiq"} {"fields": {"given": "Syed", "given2": "Sadiq", "locale": "ms_AQ", "surname": "Syed Hassan"}, "file": "ms", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Syed Hassan Syed Sadiq"} {"fields": {"given": "Salimah", "locale": "ms_AQ", "surname": "Sulaiman"}, "file": "ms", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Salimah Sulaiman"} {"fields": {"given": "Salimah", "locale": "ms_AQ", "surname": "Sulaiman"}, "file": "ms", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Sulaiman, Salimah"} {"fields": {"given": "Salimah", "locale": "ms_AQ", "surname": "Sulaiman"}, "file": "ms", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Sulaiman Salimah"} {"fields": {"credentials": "ဒေါက်တာ", "generation": "ဂျူနီယာ", "given": "အာဒါ ကွန်နီလီယာ", "given-informal": "နီလီ", "given2": "ဆီဇာ မာတင်", "locale": "ko_AQ", "surname-core": "ဝူစ်တာ ဘရူ", "surname-prefix": "ဝူစ်တာ ဗွန်", "title": "ပရော်ဖက်ဆာ ဒေါက်တာ"}, "file": "my", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "ပရော်ဖက်ဆာ ဒေါက်တာ အာဒါ ကွန်နီလီယာ ဆီဇာ မာတင် ဝူစ်တာ ဗွန် ဝူစ်တာ ဘရူ ဂျူနီယာ၊ ဒေါက်တာ"} {"fields": {"credentials": "ဒေါက်တာ", "generation": "ဂျူနီယာ", "given": "အာဒါ ကွန်နီလီယာ", "given-informal": "နီလီ", "given2": "ဆီဇာ မာတင်", "locale": "ko_AQ", "surname-core": "ဝူစ်တာ ဘရူ", "surname-prefix": "ဝူစ်တာ ဗွန်", "title": "ပရော်ဖက်ဆာ ဒေါက်တာ"}, "file": "my", "label": "foreignFull", "order": "sorting", "shape": 4, "text": "ဝူစ်တာ ဘရူ၊ အာဒါ ကွန်နီလီယာ ဆီဇာ မာတင် ဝူစ်တာ ဗွန်"} {"fields": {"credentials": "ဒေါက်တာ", "generation": "ဂျူနီယာ", "given": "အာဒါ ကွန်နီလီယာ", "given-informal": "နီလီ", "given2": "ဆီဇာ မာတင်", "locale": "ko_AQ", "surname-core": "ဝူစ်တာ ဘရူ", "surname-prefix": "ဝူစ်တာ ဗွန်", "title": "ပရော်ဖက်ဆာ ဒေါက်တာ"}, "file": "my", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "ဝူစ်တာ ဗွန် ဝူစ်တာ ဘရူ ပရော်ဖက်ဆာ ဒေါက်တာ အာဒါ ကွန်နီလီယာ ဆီဇာ မာတင် ဂျူနီယာ၊ ဒေါက်တာ"} {"fields": {"given": "ဆင်းဘတ်", "locale": "ko_AQ"}, "file": "my", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "ဆင်းဘတ်"} {"fields": {"given": "ဆင်းဘတ်", "locale": "ko_AQ"}, "file": "my", "label": "foreignG", "order": "sorting", "shape": 4, "text": "ဆင်းဘတ်"} {"fields": {"given": "ဆင်းဘတ်", "locale": "ko_AQ"}, "file": "my", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "ဆင်းဘတ်"} {"fields": {"given": "ဇာဇီလာ", "given2": "ဟာမစ်ရှ်", "locale": "ko_AQ", "surname": "စတိုဘာ"}, "file": "my", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "ဇာဇီလာ ဟာမစ်ရှ် စတိုဘာ"} {"fields": {"given": "ဇာဇီလာ", "given2": "ဟာမစ်ရှ်", "locale": "ko_AQ", "surname": "စတိုဘာ"}, "file": "my", "label": "foreignGGS", "order": "sorting", "shape": 4, "text": "စတိုဘာ၊ ဇာဇီလာ ဟာမစ်ရှ်"} {"fields": {"given": "ဇာဇီလာ", "given2": "ဟာမစ်ရှ်", "locale": "ko_AQ", "surname": "စတိုဘာ"}, "file": "my", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "စတိုဘာ ဇာဇီလာ ဟာမစ်ရှ်"} {"fields": {"given": "ကက်သီ", "locale": "ko_AQ", "surname": "မူလာ"}, "file": "my", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "ကက်သီ မူလာ"} {"fields": {"given": "ကက်သီ", "locale": "ko_AQ", "surname": "မူလာ"}, "file": "my", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "မူလာ၊ ကက်သီ"} {"fields": {"given": "ကက်သီ", "locale": "ko_AQ", "surname": "မူလာ"}, "file": "my", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "မူလာ ကက်သီ"} {"fields": {"credentials": "ဒေါက်တာ", "generation": "ဂျူနီယာ", "given": "ဘာထရမ် ဝဲလ်ဘာဖို့စ်", "given-informal": "ဘာတီ", "given2": "ဟင်နရီ ရောဘတ်", "locale": "my_AQ", "surname-core": "ဝူစ်တာ", "title": "မစ္စတာ"}, "file": "my", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "မစ္စတာ ဘာထရမ် ဝဲလ်ဘာဖို့စ် ဟင်နရီ ရောဘတ် ဝူစ်တာ ဂျူနီယာ၊ ဒေါက်တာ"} {"fields": {"credentials": "ဒေါက်တာ", "generation": "ဂျူနီယာ", "given": "ဘာထရမ် ဝဲလ်ဘာဖို့စ်", "given-informal": "ဘာတီ", "given2": "ဟင်နရီ ရောဘတ်", "locale": "my_AQ", "surname-core": "ဝူစ်တာ", "title": "မစ္စတာ"}, "file": "my", "label": "nativeFull", "order": "sorting", "shape": 4, "text": "ဝူစ်တာ၊ ဘာထရမ် ဝဲလ်ဘာဖို့စ် ဟင်နရီ ရောဘတ်"} {"fields": {"credentials": "ဒေါက်တာ", "generation": "ဂျူနီယာ", "given": "ဘာထရမ် ဝဲလ်ဘာဖို့စ်", "given-informal": "ဘာတီ", "given2": "ဟင်နရီ ရောဘတ်", "locale": "my_AQ", "surname-core": "ဝူစ်တာ", "title": "မစ္စတာ"}, "file": "my", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "ဝူစ်တာ မစ္စတာ ဘာထရမ် ဝဲလ်ဘာဖို့စ် ဟင်နရီ ရောဘတ် ဂျူနီယာ၊ ဒေါက်တာ"} {"fields": {"given": "ဇင်ဒါယာ", "locale": "my_AQ"}, "file": "my", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "ဇင်ဒါယာ"} {"fields": {"given": "ဇင်ဒါယာ", "locale": "my_AQ"}, "file": "my", "label": "nativeG", "order": "sorting", "shape": 4, "text": "ဇင်ဒါယာ"} {"fields": {"given": "ဇင်ဒါယာ", "locale": "my_AQ"}, "file": "my", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "ဇင်ဒါယာ"} {"fields": {"given": "မေရီ စူး", "given2": "ဟာမစ်ရှ်", "locale": "my_AQ", "surname": "ဝပ်ဆင်"}, "file": "my", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "မေရီ စူး ဟာမစ်ရှ် ဝပ်ဆင်"} {"fields": {"given": "မေရီ စူး", "given2": "ဟာမစ်ရှ်", "locale": "my_AQ", "surname": "ဝပ်ဆင်"}, "file": "my", "label": "nativeGGS", "order": "sorting", "shape": 4, "text": "ဝပ်ဆင်၊ မေရီ စူး ဟာမစ်ရှ်"} {"fields": {"given": "မေရီ စူး", "given2": "ဟာမစ်ရှ်", "locale": "my_AQ", "surname": "ဝပ်ဆင်"}, "file": "my", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "ဝပ်ဆင် မေရီ စူး ဟာမစ်ရှ်"} {"fields": {"given": "အိုင်ရင်း", "locale": "my_AQ", "surname": "အက်ဒ်လာ"}, "file": "my", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "အိုင်ရင်း အက်ဒ်လာ"} {"fields": {"given": "အိုင်ရင်း", "locale": "my_AQ", "surname": "အက်ဒ်လာ"}, "file": "my", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "အက်ဒ်လာ၊ အိုင်ရင်း"} {"fields": {"given": "အိုင်ရင်း", "locale": "my_AQ", "surname": "အက်ဒ်လာ"}, "file": "my", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "အက်ဒ်လာ အိုင်ရင်း"} {"fields": {"credentials": "एमडी डिडिएस", "generation": "जेनेरेसन", "given": "एडा कर्नेलिया", "given-informal": "नीले", "given2": "सिजर मार्टिन", "locale": "ko_AQ", "surname-core": "ब्रुहल", "surname-prefix": "भोन", "surname2": "गोन्जालेज डोमिङ्गोज", "title": "प्रोफेसर डाक्टर"}, "file": "ne", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "एडा कर्नेलिया सिजर मार्टिन भोन ब्रुहल एमडी डिडिएस"} {"fields": {"credentials": "एमडी डिडिएस", "generation": "जेनेरेसन", "given": "एडा कर्नेलिया", "given-informal": "नीले", "given2": "सिजर मार्टिन", "locale": "ko_AQ", "surname-core": "ब्रुहल", "surname-prefix": "भोन", "surname2": "गोन्जालेज डोमिङ्गोज", "title": "प्रोफेसर डाक्टर"}, "file": "ne", "label": "foreignFull", "order": "sorting", "shape": null, "text": "ब्रुहल, एडा कर्नेलिया सिजर मार्टिन भोन"} {"fields": {"credentials": "एमडी डिडिएस", "generation": "जेनेरेसन", "given": "एडा कर्नेलिया", "given-informal": "नीले", "given2": "सिजर मार्टिन", "locale": "ko_AQ", "surname-core": "ब्रुहल", "surname-prefix": "भोन", "surname2": "गोन्जालेज डोमिङ्गोज", "title": "प्रोफेसर डाक्टर"}, "file": "ne", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "भोन ब्रुहल एडा कर्नेलिया सिजर मार्टिन एमडी डिडिएस"} {"fields": {"given": "सिनबाद", "locale": "ko_AQ"}, "file": "ne", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "सिनबाद"} {"fields": {"given": "सिनबाद", "locale": "ko_AQ"}, "file": "ne", "label": "foreignG", "order": "sorting", "shape": 4, "text": "सिनबाद"} {"fields": {"given": "सिनबाद", "locale": "ko_AQ"}, "file": "ne", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "सिनबाद"} {"fields": {"given": "जाजिला", "given2": "हार्निस", "locale": "ko_AQ", "surname": "स्टोबर"}, "file": "ne", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "जाजिला हार्निस स्टोबर"} {"fields": {"given": "जाजिला", "given2": "हार्निस", "locale": "ko_AQ", "surname": "स्टोबर"}, "file": "ne", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "स्टोबर, जाजिला हार्निस"} {"fields": {"given": "जाजिला", "given2": "हार्निस", "locale": "ko_AQ", "surname": "स्टोबर"}, "file": "ne", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "स्टोबर जाजिला हार्निस"} {"fields": {"given": "क्याथी", "locale": "ko_AQ", "surname": "मुलर"}, "file": "ne", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "क्याथी मुलर"} {"fields": {"given": "क्याथी", "locale": "ko_AQ", "surname": "मुलर"}, "file": "ne", "label": "foreignGS", "order": "sorting", "shape": null, "text": "मुलर, क्याथी"} {"fields": {"given": "क्याथी", "locale": "ko_AQ", "surname": "मुलर"}, "file": "ne", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "मुलर क्याथी"} {"fields": {"credentials": "एमपी", "generation": "जेआर", "given": "बर्ट्राम विलबरफोर्स", "given-informal": "बर्टी", "given2": "हेनरी रोबर्ट", "locale": "ne_AQ", "surname-core": "वुस्टर", "title": "श्री"}, "file": "ne", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "बर्ट्राम विलबरफोर्स हेनरी रोबर्ट वुस्टर एमपी"} {"fields": {"credentials": "एमपी", "generation": "जेआर", "given": "बर्ट्राम विलबरफोर्स", "given-informal": "बर्टी", "given2": "हेनरी रोबर्ट", "locale": "ne_AQ", "surname-core": "वुस्टर", "title": "श्री"}, "file": "ne", "label": "nativeFull", "order": "sorting", "shape": null, "text": "वुस्टर, बर्ट्राम विलबरफोर्स हेनरी रोबर्ट"} {"fields": {"credentials": "एमपी", "generation": "जेआर", "given": "बर्ट्राम विलबरफोर्स", "given-informal": "बर्टी", "given2": "हेनरी रोबर्ट", "locale": "ne_AQ", "surname-core": "वुस्टर", "title": "श्री"}, "file": "ne", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "वुस्टर बर्ट्राम विलबरफोर्स हेनरी रोबर्ट एमपी"} {"fields": {"given": "सुन्दर", "locale": "ne_AQ"}, "file": "ne", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "सुन्दर"} {"fields": {"given": "सुन्दर", "locale": "ne_AQ"}, "file": "ne", "label": "nativeG", "order": "sorting", "shape": 4, "text": "सुन्दर"} {"fields": {"given": "सुन्दर", "locale": "ne_AQ"}, "file": "ne", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "सुन्दर"} {"fields": {"given": "प्रेमप्रसाद", "given2": "हिमलाल", "locale": "ne_AQ", "surname": "वास्तोला"}, "file": "ne", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "प्रेमप्रसाद हिमलाल वास्तोला"} {"fields": {"given": "प्रेमप्रसाद", "given2": "हिमलाल", "locale": "ne_AQ", "surname": "वास्तोला"}, "file": "ne", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "वास्तोला, प्रेमप्रसाद हिमलाल"} {"fields": {"given": "प्रेमप्रसाद", "given2": "हिमलाल", "locale": "ne_AQ", "surname": "वास्तोला"}, "file": "ne", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "वास्तोला प्रेमप्रसाद हिमलाल"} {"fields": {"given": "रमिता", "locale": "ne_AQ", "surname": "पोख्रेल"}, "file": "ne", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "रमिता पोख्रेल"} {"fields": {"given": "रमिता", "locale": "ne_AQ", "surname": "पोख्रेल"}, "file": "ne", "label": "nativeGS", "order": "sorting", "shape": null, "text": "पोख्रेल, रमिता"} {"fields": {"given": "रमिता", "locale": "ne_AQ", "surname": "पोख्रेल"}, "file": "ne", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "पोख्रेल रमिता"} {"fields": {"credentials": "PhD", "generation": "jr.", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ja_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "mevrouw"}, "file": "nl", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "mevrouw Ada Cornelia César Martín von Brühl jr. PhD"} {"fields": {"credentials": "PhD", "generation": "jr.", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ja_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "mevrouw"}, "file": "nl", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Brühl, Ada Cornelia César Martín von"} {"fields": {"credentials": "PhD", "generation": "jr.", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ja_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "mevrouw"}, "file": "nl", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "von Brühl mevrouw Ada Cornelia César Martín jr., PhD"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "nl", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "nl", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "nl", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stöber"}, "file": "nl", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zäzilia Hamish Stöber"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stöber"}, "file": "nl", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Stöber, Zäzilia Hamish"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stöber"}, "file": "nl", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Stöber Zäzilia Hamish"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "nl", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Käthe Müller"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "nl", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Müller, Käthe"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "nl", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Müller Käthe"} {"fields": {"credentials": "mp", "generation": "jr.", "given": "Bertus Wevers", "given-informal": "Bert", "given2": "Harry Robert", "locale": "nl_AQ", "surname-core": "Jong", "surname-prefix": "de", "title": "dhr."}, "file": "nl", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "dhr. Bertus Wevers Harry Robert de Jong jr. mp"} {"fields": {"credentials": "mp", "generation": "jr.", "given": "Bertus Wevers", "given-informal": "Bert", "given2": "Harry Robert", "locale": "nl_AQ", "surname-core": "Jong", "surname-prefix": "de", "title": "dhr."}, "file": "nl", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Jong, Bertus Wevers Harry Robert de"} {"fields": {"credentials": "mp", "generation": "jr.", "given": "Bertus Wevers", "given-informal": "Bert", "given2": "Harry Robert", "locale": "nl_AQ", "surname-core": "Jong", "surname-prefix": "de", "title": "dhr."}, "file": "nl", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "de Jong dhr. Bertus Wevers Harry Robert jr., mp"} {"fields": {"given": "Fatima", "locale": "nl_AQ"}, "file": "nl", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Fatima"} {"fields": {"given": "Fatima", "locale": "nl_AQ"}, "file": "nl", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Fatima"} {"fields": {"given": "Fatima", "locale": "nl_AQ"}, "file": "nl", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Fatima"} {"fields": {"given": "Heidi", "given2": "Johannes", "locale": "nl_AQ", "surname": "Willemse"}, "file": "nl", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Heidi Johannes Willemse"} {"fields": {"given": "Heidi", "given2": "Johannes", "locale": "nl_AQ", "surname": "Willemse"}, "file": "nl", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Willemse, Heidi Johannes"} {"fields": {"given": "Heidi", "given2": "Johannes", "locale": "nl_AQ", "surname": "Willemse"}, "file": "nl", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Willemse Heidi Johannes"} {"fields": {"given": "Irene", "locale": "nl_AQ", "surname": "Bakker"}, "file": "nl", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Irene Bakker"} {"fields": {"given": "Irene", "locale": "nl_AQ", "surname": "Bakker"}, "file": "nl", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Bakker, Irene"} {"fields": {"given": "Irene", "locale": "nl_AQ", "surname": "Bakker"}, "file": "nl", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Bakker Irene"} {"fields": {"credentials": "m.d ph.d.", "generation": "jr.", "given": "Inger Marie", "given-informal": "Inger", "given2": "Adele Synnøve", "locale": "ko_AQ", "surname-core": "Wolf", "surname-prefix": "von", "surname2": "Meyer Hansen", "title": "prof. dr."}, "file": "nn", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "m.d ph.d. Inger Marie Adele Synnøve von Wolf jr."} {"fields": {"credentials": "m.d ph.d.", "generation": "jr.", "given": "Inger Marie", "given-informal": "Inger", "given2": "Adele Synnøve", "locale": "ko_AQ", "surname-core": "Wolf", "surname-prefix": "von", "surname2": "Meyer Hansen", "title": "prof. dr."}, "file": "nn", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Wolf, Inger Marie Adele Synnøve von"} {"fields": {"credentials": "m.d ph.d.", "generation": "jr.", "given": "Inger Marie", "given-informal": "Inger", "given2": "Adele Synnøve", "locale": "ko_AQ", "surname-core": "Wolf", "surname-prefix": "von", "surname2": "Meyer Hansen", "title": "prof. dr."}, "file": "nn", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "m.d ph.d. von Wolf Inger Marie Adele Synnøve"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "nn", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "nn", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "nn", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Zelda", "given2": "Hamish", "locale": "ko_AQ", "surname": "Støver"}, "file": "nn", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zelda Hamish Støver"} {"fields": {"given": "Zelda", "given2": "Hamish", "locale": "ko_AQ", "surname": "Støver"}, "file": "nn", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Støver, Zelda Hamish"} {"fields": {"given": "Zelda", "given2": "Hamish", "locale": "ko_AQ", "surname": "Støver"}, "file": "nn", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Støver Zelda Hamish"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "nn", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Käthe Müller"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "nn", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Müller, Käthe"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "nn", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Müller Käthe"} {"fields": {"credentials": "st.repr.", "generation": "jr.", "given": "Gregers Werle", "given-informal": "Bertram", "given2": "Marve Almar", "locale": "nn_AQ", "surname-core": "Fleksnes", "surname-prefix": "von", "surname2": "Hansen", "title": "hr."}, "file": "nn", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "st.repr. Gregers Werle Marve Almar von Fleksnes jr."} {"fields": {"credentials": "st.repr.", "generation": "jr.", "given": "Gregers Werle", "given-informal": "Bertram", "given2": "Marve Almar", "locale": "nn_AQ", "surname-core": "Fleksnes", "surname-prefix": "von", "surname2": "Hansen", "title": "hr."}, "file": "nn", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Fleksnes, Gregers Werle Marve Almar von"} {"fields": {"credentials": "st.repr.", "generation": "jr.", "given": "Gregers Werle", "given-informal": "Bertram", "given2": "Marve Almar", "locale": "nn_AQ", "surname-core": "Fleksnes", "surname-prefix": "von", "surname2": "Hansen", "title": "hr."}, "file": "nn", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "st.repr. von Fleksnes Gregers Werle Marve Almar"} {"fields": {"given": "Harald", "locale": "nn_AQ"}, "file": "nn", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Harald"} {"fields": {"given": "Harald", "locale": "nn_AQ"}, "file": "nn", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Harald"} {"fields": {"given": "Harald", "locale": "nn_AQ"}, "file": "nn", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Harald"} {"fields": {"given": "Anne Berit", "given2": "Bernhard", "locale": "nn_AQ", "surname": "Nilsen"}, "file": "nn", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Anne Berit Bernhard Nilsen"} {"fields": {"given": "Anne Berit", "given2": "Bernhard", "locale": "nn_AQ", "surname": "Nilsen"}, "file": "nn", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Nilsen, Anne Berit Bernhard"} {"fields": {"given": "Anne Berit", "given2": "Bernhard", "locale": "nn_AQ", "surname": "Nilsen"}, "file": "nn", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Nilsen Anne Berit Bernhard"} {"fields": {"given": "Irene", "locale": "nn_AQ", "surname": "Andersen"}, "file": "nn", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Irene Andersen"} {"fields": {"given": "Irene", "locale": "nn_AQ", "surname": "Andersen"}, "file": "nn", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Andersen, Irene"} {"fields": {"given": "Irene", "locale": "nn_AQ", "surname": "Andersen"}, "file": "nn", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Andersen Irene"} {"fields": {"credentials": "m.d ph.d.", "generation": "jr.", "given": "Inger Marie", "given-informal": "Inger", "given2": "Adele Synnøve", "locale": "ko_AQ", "surname-core": "Wolf", "surname-prefix": "von", "surname2": "Meyer Hansen", "title": "prof. dr."}, "file": "no", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "m.d ph.d. Inger Marie Adele Synnøve von Wolf jr."} {"fields": {"credentials": "m.d ph.d.", "generation": "jr.", "given": "Inger Marie", "given-informal": "Inger", "given2": "Adele Synnøve", "locale": "ko_AQ", "surname-core": "Wolf", "surname-prefix": "von", "surname2": "Meyer Hansen", "title": "prof. dr."}, "file": "no", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Wolf, Inger Marie Adele Synnøve von"} {"fields": {"credentials": "m.d ph.d.", "generation": "jr.", "given": "Inger Marie", "given-informal": "Inger", "given2": "Adele Synnøve", "locale": "ko_AQ", "surname-core": "Wolf", "surname-prefix": "von", "surname2": "Meyer Hansen", "title": "prof. dr."}, "file": "no", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "m.d ph.d. von Wolf Inger Marie Adele Synnøve"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "no", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "no", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "no", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Zelda", "given2": "Hamish", "locale": "ko_AQ", "surname": "Støver"}, "file": "no", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zelda Hamish Støver"} {"fields": {"given": "Zelda", "given2": "Hamish", "locale": "ko_AQ", "surname": "Støver"}, "file": "no", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Støver, Zelda Hamish"} {"fields": {"given": "Zelda", "given2": "Hamish", "locale": "ko_AQ", "surname": "Støver"}, "file": "no", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Støver Zelda Hamish"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "no", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Käthe Müller"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "no", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Müller, Käthe"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "no", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Müller Käthe"} {"fields": {"credentials": "st.repr.", "generation": "jr.", "given": "Gregers Werle", "given-informal": "Bertram", "given2": "Marve Almar", "locale": "no_AQ", "surname-core": "Fleksnes", "surname-prefix": "von", "surname2": "Hansen", "title": "hr."}, "file": "no", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "st.repr. Gregers Werle Marve Almar von Fleksnes jr."} {"fields": {"credentials": "st.repr.", "generation": "jr.", "given": "Gregers Werle", "given-informal": "Bertram", "given2": "Marve Almar", "locale": "no_AQ", "surname-core": "Fleksnes", "surname-prefix": "von", "surname2": "Hansen", "title": "hr."}, "file": "no", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Fleksnes, Gregers Werle Marve Almar von"} {"fields": {"credentials": "st.repr.", "generation": "jr.", "given": "Gregers Werle", "given-informal": "Bertram", "given2": "Marve Almar", "locale": "no_AQ", "surname-core": "Fleksnes", "surname-prefix": "von", "surname2": "Hansen", "title": "hr."}, "file": "no", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "st.repr. von Fleksnes Gregers Werle Marve Almar"} {"fields": {"given": "Harald", "locale": "no_AQ"}, "file": "no", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Harald"} {"fields": {"given": "Harald", "locale": "no_AQ"}, "file": "no", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Harald"} {"fields": {"given": "Harald", "locale": "no_AQ"}, "file": "no", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Harald"} {"fields": {"given": "Anne Berit", "given2": "Bernhard", "locale": "no_AQ", "surname": "Nilsen"}, "file": "no", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Anne Berit Bernhard Nilsen"} {"fields": {"given": "Anne Berit", "given2": "Bernhard", "locale": "no_AQ", "surname": "Nilsen"}, "file": "no", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Nilsen, Anne Berit Bernhard"} {"fields": {"given": "Anne Berit", "given2": "Bernhard", "locale": "no_AQ", "surname": "Nilsen"}, "file": "no", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Nilsen Anne Berit Bernhard"} {"fields": {"given": "Irene", "locale": "no_AQ", "surname": "Andersen"}, "file": "no", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Irene Andersen"} {"fields": {"given": "Irene", "locale": "no_AQ", "surname": "Andersen"}, "file": "no", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Andersen, Irene"} {"fields": {"given": "Irene", "locale": "no_AQ", "surname": "Andersen"}, "file": "no", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Andersen Irene"} {"fields": {"credentials": "ଏମ.ଡି.ପିଏଚ.ଡି", "generation": "ଜେଆର୍", "given": "ଆଡା କର୍ଣ୍ଣେଲିଆ", "given-informal": "ନୋଏଲ୍", "given2": "ଇଭା ସୋଫିଆ", "locale": "ko_AQ", "surname-core": "ୱୋଲ୍ଫ", "surname-prefix": "ୱାନ୍ ଡେନ୍", "surname2": "ବେକର୍ ସ୍ମିଥ୍", "title": "ପ୍ରଫେସର ଡ."}, "file": "or", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "ଆଡା କର୍ଣ୍ଣେଲିଆ ଇଭା ସୋଫିଆ ୱାନ୍ ଡେନ୍ ୱୋଲ୍ଫ ଏମ.ଡି.ପିଏଚ.ଡି"} {"fields": {"credentials": "ଏମ.ଡି.ପିଏଚ.ଡି", "generation": "ଜେଆର୍", "given": "ଆଡା କର୍ଣ୍ଣେଲିଆ", "given-informal": "ନୋଏଲ୍", "given2": "ଇଭା ସୋଫିଆ", "locale": "ko_AQ", "surname-core": "ୱୋଲ୍ଫ", "surname-prefix": "ୱାନ୍ ଡେନ୍", "surname2": "ବେକର୍ ସ୍ମିଥ୍", "title": "ପ୍ରଫେସର ଡ."}, "file": "or", "label": "foreignFull", "order": "sorting", "shape": null, "text": "ୱୋଲ୍ଫ, ଆଡା କର୍ଣ୍ଣେଲିଆ ଇଭା ସୋଫିଆ ୱାନ୍ ଡେନ୍"} {"fields": {"credentials": "ଏମ.ଡି.ପିଏଚ.ଡି", "generation": "ଜେଆର୍", "given": "ଆଡା କର୍ଣ୍ଣେଲିଆ", "given-informal": "ନୋଏଲ୍", "given2": "ଇଭା ସୋଫିଆ", "locale": "ko_AQ", "surname-core": "ୱୋଲ୍ଫ", "surname-prefix": "ୱାନ୍ ଡେନ୍", "surname2": "ବେକର୍ ସ୍ମିଥ୍", "title": "ପ୍ରଫେସର ଡ."}, "file": "or", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "ୱାନ୍ ଡେନ୍ ୱୋଲ୍ଫ ଆଡା କର୍ଣ୍ଣେଲିଆ ଇଭା ସୋଫିଆ ଏମ.ଡି.ପିଏଚ.ଡି"} {"fields": {"given": "ସିନବାଦ", "locale": "ko_AQ"}, "file": "or", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "ସିନବାଦ"} {"fields": {"given": "ସିନବାଦ", "locale": "ko_AQ"}, "file": "or", "label": "foreignG", "order": "sorting", "shape": 4, "text": "ସିନବାଦ"} {"fields": {"given": "ସିନବାଦ", "locale": "ko_AQ"}, "file": "or", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "ସିନବାଦ"} {"fields": {"given": "ଜେଆର୍", "given2": "ହାମିଶ", "locale": "ko_AQ", "surname": "ଷ୍ଟୋବର"}, "file": "or", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "ଜେଆର୍ ହାମିଶ ଷ୍ଟୋବର"} {"fields": {"given": "ଜେଆର୍", "given2": "ହାମିଶ", "locale": "ko_AQ", "surname": "ଷ୍ଟୋବର"}, "file": "or", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "ଷ୍ଟୋବର, ଜେଆର୍ ହାମିଶ"} {"fields": {"given": "ଜେଆର୍", "given2": "ହାମିଶ", "locale": "ko_AQ", "surname": "ଷ୍ଟୋବର"}, "file": "or", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "ଷ୍ଟୋବର ଜେଆର୍ ହାମିଶ"} {"fields": {"given": "କାଥେ", "locale": "ko_AQ", "surname": "ମୁଲେର୍"}, "file": "or", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "କାଥେ ମୁଲେର୍"} {"fields": {"given": "କାଥେ", "locale": "ko_AQ", "surname": "ମୁଲେର୍"}, "file": "or", "label": "foreignGS", "order": "sorting", "shape": null, "text": "ମୁଲେର୍, କାଥେ"} {"fields": {"given": "କାଥେ", "locale": "ko_AQ", "surname": "ମୁଲେର୍"}, "file": "or", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "ମୁଲେର୍ କାଥେ"} {"fields": {"credentials": "ଏମପି", "generation": "ଜେଆର୍", "given": "ବର୍ଟ୍ରାମ ୱିଲବର୍ଫୋର୍ସ", "given-informal": "ବର୍ଟି", "given2": "ହେନେରୀ ରୋବର୍ଟ", "locale": "or_AQ", "surname-core": "ୱୋଷ୍ଟର", "title": "ଶ୍ରୀଯୁକ୍ତ"}, "file": "or", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "ବର୍ଟ୍ରାମ ୱିଲବର୍ଫୋର୍ସ ହେନେରୀ ରୋବର୍ଟ ୱୋଷ୍ଟର ଏମପି"} {"fields": {"credentials": "ଏମପି", "generation": "ଜେଆର୍", "given": "ବର୍ଟ୍ରାମ ୱିଲବର୍ଫୋର୍ସ", "given-informal": "ବର୍ଟି", "given2": "ହେନେରୀ ରୋବର୍ଟ", "locale": "or_AQ", "surname-core": "ୱୋଷ୍ଟର", "title": "ଶ୍ରୀଯୁକ୍ତ"}, "file": "or", "label": "nativeFull", "order": "sorting", "shape": null, "text": "ୱୋଷ୍ଟର, ବର୍ଟ୍ରାମ ୱିଲବର୍ଫୋର୍ସ ହେନେରୀ ରୋବର୍ଟ"} {"fields": {"credentials": "ଏମପି", "generation": "ଜେଆର୍", "given": "ବର୍ଟ୍ରାମ ୱିଲବର୍ଫୋର୍ସ", "given-informal": "ବର୍ଟି", "given2": "ହେନେରୀ ରୋବର୍ଟ", "locale": "or_AQ", "surname-core": "ୱୋଷ୍ଟର", "title": "ଶ୍ରୀଯୁକ୍ତ"}, "file": "or", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "ୱୋଷ୍ଟର ବର୍ଟ୍ରାମ ୱିଲବର୍ଫୋର୍ସ ହେନେରୀ ରୋବର୍ଟ ଏମପି"} {"fields": {"given": "ସିନବାଦ୍", "locale": "or_AQ"}, "file": "or", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "ସିନବାଦ୍"} {"fields": {"given": "ସିନବାଦ୍", "locale": "or_AQ"}, "file": "or", "label": "nativeG", "order": "sorting", "shape": 4, "text": "ସିନବାଦ୍"} {"fields": {"given": "ସିନବାଦ୍", "locale": "or_AQ"}, "file": "or", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "ସିନବାଦ୍"} {"fields": {"given": "ଜନ୍", "given2": "ହାମିଶ", "locale": "or_AQ", "surname": "ୱାଟସନ୍"}, "file": "or", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "ଜନ୍ ହାମିଶ ୱାଟସନ୍"} {"fields": {"given": "ଜନ୍", "given2": "ହାମିଶ", "locale": "or_AQ", "surname": "ୱାଟସନ୍"}, "file": "or", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "ୱାଟସନ୍, ଜନ୍ ହାମିଶ"} {"fields": {"given": "ଜନ୍", "given2": "ହାମିଶ", "locale": "or_AQ", "surname": "ୱାଟସନ୍"}, "file": "or", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "ୱାଟସନ୍ ଜନ୍ ହାମିଶ"} {"fields": {"given": "ଆଇରେନ୍", "locale": "or_AQ", "surname": "ଆଡଲର୍"}, "file": "or", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "ଆଇରେନ୍ ଆଡଲର୍"} {"fields": {"given": "ଆଇରେନ୍", "locale": "or_AQ", "surname": "ଆଡଲର୍"}, "file": "or", "label": "nativeGS", "order": "sorting", "shape": null, "text": "ଆଡଲର୍, ଆଇରେନ୍"} {"fields": {"given": "ଆଇରେନ୍", "locale": "or_AQ", "surname": "ଆଡଲର୍"}, "file": "or", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "ଆଡଲର୍ ଆଇରେନ୍"} {"fields": {"credentials": "ਐੱਮ.ਡੀ. ਡੀ.ਡੀ.ਐੱਸ.", "generation": "ਜੂ.", "given": "ਐਡਾ ਕੋਰਨੇਲੀਆ", "given-informal": "ਨੀਲ", "given2": "ਈਵਾ ਸੋਫ਼ੀਆ", "locale": "ko_AQ", "surname-core": "ਵੌਲਫ਼", "surname-prefix": "ਵੈਨ ਡੇਨ", "surname2": "ਬੇਕਰ ਸਮਿੱਥ", "title": "ਪ੍ਰੋ. ਡਾ."}, "file": "pa", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "ਪ੍ਰੋ. ਡਾ. ਐਡਾ ਕੋਰਨੇਲੀਆ ਈਵਾ ਸੋਫ਼ੀਆ ਵੈਨ ਡੇਨ ਵੌਲਫ਼ ਬੇਕਰ ਸਮਿੱਥ ਐੱਮ.ਡੀ. ਡੀ.ਡੀ.ਐੱਸ."} {"fields": {"credentials": "ਐੱਮ.ਡੀ. ਡੀ.ਡੀ.ਐੱਸ.", "generation": "ਜੂ.", "given": "ਐਡਾ ਕੋਰਨੇਲੀਆ", "given-informal": "ਨੀਲ", "given2": "ਈਵਾ ਸੋਫ਼ੀਆ", "locale": "ko_AQ", "surname-core": "ਵੌਲਫ਼", "surname-prefix": "ਵੈਨ ਡੇਨ", "surname2": "ਬੇਕਰ ਸਮਿੱਥ", "title": "ਪ੍ਰੋ. ਡਾ."}, "file": "pa", "label": "foreignFull", "order": "sorting", "shape": null, "text": "ਵੌਲਫ਼, ਐਡਾ ਕੋਰਨੇਲੀਆ ਈਵਾ ਸੋਫ਼ੀਆ ਵੈਨ ਡੇਨ"} {"fields": {"credentials": "ਐੱਮ.ਡੀ. ਡੀ.ਡੀ.ਐੱਸ.", "generation": "ਜੂ.", "given": "ਐਡਾ ਕੋਰਨੇਲੀਆ", "given-informal": "ਨੀਲ", "given2": "ਈਵਾ ਸੋਫ਼ੀਆ", "locale": "ko_AQ", "surname-core": "ਵੌਲਫ਼", "surname-prefix": "ਵੈਨ ਡੇਨ", "surname2": "ਬੇਕਰ ਸਮਿੱਥ", "title": "ਪ੍ਰੋ. ਡਾ."}, "file": "pa", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "ਵੈਨ ਡੇਨ ਵੌਲਫ਼ ਐਡਾ ਕੋਰਨੇਲੀਆ ਈਵਾ ਸੋਫ਼ੀਆ ਐੱਮ.ਡੀ. ਡੀ.ਡੀ.ਐੱਸ."} {"fields": {"given": "ਸਿੰਨਬੈਡ", "locale": "ko_AQ"}, "file": "pa", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "ਸਿੰਨਬੈਡ"} {"fields": {"given": "ਸਿੰਨਬੈਡ", "locale": "ko_AQ"}, "file": "pa", "label": "foreignG", "order": "sorting", "shape": 4, "text": "ਸਿੰਨਬੈਡ"} {"fields": {"given": "ਸਿੰਨਬੈਡ", "locale": "ko_AQ"}, "file": "pa", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "ਸਿੰਨਬੈਡ"} {"fields": {"given": "ਜ਼ਜ਼ਿਲੀਆ", "given2": "ਹੇਮਿਸ਼", "locale": "ko_AQ", "surname": "ਸਟੋਬਰ"}, "file": "pa", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "ਜ਼ਜ਼ਿਲੀਆ ਹੇਮਿਸ਼ ਸਟੋਬਰ"} {"fields": {"given": "ਜ਼ਜ਼ਿਲੀਆ", "given2": "ਹੇਮਿਸ਼", "locale": "ko_AQ", "surname": "ਸਟੋਬਰ"}, "file": "pa", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "ਸਟੋਬਰ, ਜ਼ਜ਼ਿਲੀਆ ਹੇਮਿਸ਼"} {"fields": {"given": "ਜ਼ਜ਼ਿਲੀਆ", "given2": "ਹੇਮਿਸ਼", "locale": "ko_AQ", "surname": "ਸਟੋਬਰ"}, "file": "pa", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "ਸਟੋਬਰ ਜ਼ਜ਼ਿਲੀਆ ਹੇਮਿਸ਼"} {"fields": {"given": "ਕੈਥੀ", "locale": "ko_AQ", "surname": "ਮੂਲਰ"}, "file": "pa", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "ਕੈਥੀ ਮੂਲਰ"} {"fields": {"given": "ਕੈਥੀ", "locale": "ko_AQ", "surname": "ਮੂਲਰ"}, "file": "pa", "label": "foreignGS", "order": "sorting", "shape": null, "text": "ਮੂਲਰ, ਕੈਥੀ"} {"fields": {"given": "ਕੈਥੀ", "locale": "ko_AQ", "surname": "ਮੂਲਰ"}, "file": "pa", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "ਮੂਲਰ ਕੈਥੀ"} {"fields": {"credentials": "ਐਮ.ਪੀ.", "generation": "ਜੂ.", "given": "ਬਰਟਰਮ ਵਿਲਬਰਫ਼ੋਰਸ", "given-informal": "ਬਰਟੀ", "given2": "ਹੈਨਰੀ ਰਾਬਰਟ", "locale": "pa_AQ", "surname-core": "ਵੂਸਟਰ", "title": "ਸ਼੍ਰੀ"}, "file": "pa", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "ਸ਼੍ਰੀ ਬਰਟਰਮ ਵਿਲਬਰਫ਼ੋਰਸ ਹੈਨਰੀ ਰਾਬਰਟ ਵੂਸਟਰ ਐਮ.ਪੀ."} {"fields": {"credentials": "ਐਮ.ਪੀ.", "generation": "ਜੂ.", "given": "ਬਰਟਰਮ ਵਿਲਬਰਫ਼ੋਰਸ", "given-informal": "ਬਰਟੀ", "given2": "ਹੈਨਰੀ ਰਾਬਰਟ", "locale": "pa_AQ", "surname-core": "ਵੂਸਟਰ", "title": "ਸ਼੍ਰੀ"}, "file": "pa", "label": "nativeFull", "order": "sorting", "shape": null, "text": "ਵੂਸਟਰ, ਬਰਟਰਮ ਵਿਲਬਰਫ਼ੋਰਸ ਹੈਨਰੀ ਰਾਬਰਟ"} {"fields": {"credentials": "ਐਮ.ਪੀ.", "generation": "ਜੂ.", "given": "ਬਰਟਰਮ ਵਿਲਬਰਫ਼ੋਰਸ", "given-informal": "ਬਰਟੀ", "given2": "ਹੈਨਰੀ ਰਾਬਰਟ", "locale": "pa_AQ", "surname-core": "ਵੂਸਟਰ", "title": "ਸ਼੍ਰੀ"}, "file": "pa", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "ਵੂਸਟਰ ਬਰਟਰਮ ਵਿਲਬਰਫ਼ੋਰਸ ਹੈਨਰੀ ਰਾਬਰਟ ਐਮ.ਪੀ."} {"fields": {"given": "ਜ਼ੈਨਡਿਆ", "locale": "pa_AQ"}, "file": "pa", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "ਜ਼ੈਨਡਿਆ"} {"fields": {"given": "ਜ਼ੈਨਡਿਆ", "locale": "pa_AQ"}, "file": "pa", "label": "nativeG", "order": "sorting", "shape": 4, "text": "ਜ਼ੈਨਡਿਆ"} {"fields": {"given": "ਜ਼ੈਨਡਿਆ", "locale": "pa_AQ"}, "file": "pa", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "ਜ਼ੈਨਡਿਆ"} {"fields": {"given": "ਜੌਨ", "given2": "ਹਾਮਿਸ਼", "locale": "pa_AQ", "surname": "ਵਾਟਸਨ"}, "file": "pa", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "ਜੌਨ ਹਾਮਿਸ਼ ਵਾਟਸਨ"} {"fields": {"given": "ਜੌਨ", "given2": "ਹਾਮਿਸ਼", "locale": "pa_AQ", "surname": "ਵਾਟਸਨ"}, "file": "pa", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "ਵਾਟਸਨ, ਜੌਨ ਹਾਮਿਸ਼"} {"fields": {"given": "ਜੌਨ", "given2": "ਹਾਮਿਸ਼", "locale": "pa_AQ", "surname": "ਵਾਟਸਨ"}, "file": "pa", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "ਵਾਟਸਨ ਜੌਨ ਹਾਮਿਸ਼"} {"fields": {"given": "ਆਰੀਨ", "locale": "pa_AQ", "surname": "ਐਡਲਰ"}, "file": "pa", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "ਆਰੀਨ ਐਡਲਰ"} {"fields": {"given": "ਆਰੀਨ", "locale": "pa_AQ", "surname": "ਐਡਲਰ"}, "file": "pa", "label": "nativeGS", "order": "sorting", "shape": null, "text": "ਐਡਲਰ, ਆਰੀਨ"} {"fields": {"given": "ਆਰੀਨ", "locale": "pa_AQ", "surname": "ਐਡਲਰ"}, "file": "pa", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "ਐਡਲਰ ਆਰੀਨ"} {"fields": {"credentials": "M.D. PhD.", "generation": "jẹnereṣọn", "given": "Adá Cornelia", "given-informal": "Néele", "given2": "Ẹ́va Sophia", "locale": "ko_AQ", "surname-core": "Wólf", "surname-prefix": "ván den", "surname2": "Bécker Schmidt", "title": "Prọf. Dọk."}, "file": "pcm", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Adá Cornelia Ẹ́va Sophia ván den Wólf M.D. PhD."} {"fields": {"credentials": "M.D. PhD.", "generation": "jẹnereṣọn", "given": "Adá Cornelia", "given-informal": "Néele", "given2": "Ẹ́va Sophia", "locale": "ko_AQ", "surname-core": "Wólf", "surname-prefix": "ván den", "surname2": "Bécker Schmidt", "title": "Prọf. Dọk."}, "file": "pcm", "label": "foreignFull", "order": "sorting", "shape": null, "text": "ván den Wólf, Adá Cornelia Ẹ́va Sophia M.D. PhD."} {"fields": {"credentials": "M.D. PhD.", "generation": "jẹnereṣọn", "given": "Adá Cornelia", "given-informal": "Néele", "given2": "Ẹ́va Sophia", "locale": "ko_AQ", "surname-core": "Wólf", "surname-prefix": "ván den", "surname2": "Bécker Schmidt", "title": "Prọf. Dọk."}, "file": "pcm", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "ván den Wólf Adá Cornelia Ẹ́va Sophia M.D. PhD."} {"fields": {"given": "Miret", "locale": "ko_AQ"}, "file": "pcm", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Miret"} {"fields": {"given": "Miret", "locale": "ko_AQ"}, "file": "pcm", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Miret"} {"fields": {"given": "Miret", "locale": "ko_AQ"}, "file": "pcm", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Miret"} {"fields": {"given": "Tunde", "given2": "Seun", "locale": "ko_AQ", "surname": "Okeke"}, "file": "pcm", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Tunde Seun Okeke"} {"fields": {"given": "Tunde", "given2": "Seun", "locale": "ko_AQ", "surname": "Okeke"}, "file": "pcm", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Okeke, Tunde Seun"} {"fields": {"given": "Tunde", "given2": "Seun", "locale": "ko_AQ", "surname": "Okeke"}, "file": "pcm", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Okeke Tunde Seun"} {"fields": {"given": "Katie", "locale": "ko_AQ", "surname": "Stefan"}, "file": "pcm", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Katie Stefan"} {"fields": {"given": "Katie", "locale": "ko_AQ", "surname": "Stefan"}, "file": "pcm", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Stefan, Katie"} {"fields": {"given": "Katie", "locale": "ko_AQ", "surname": "Stefan"}, "file": "pcm", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Stefan Katie"} {"fields": {"credentials": "MP", "generation": "Jr.", "given": "Elizabeth Windsor", "given-informal": "Lilibet", "given2": "Maria Teresa", "locale": "pcm_AQ", "surname-core": "Stromae", "surname-prefix": "bin", "surname2": "Robert", "title": "Mr."}, "file": "pcm", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Elizabeth Windsor Maria Teresa bin Stromae MP"} {"fields": {"credentials": "MP", "generation": "Jr.", "given": "Elizabeth Windsor", "given-informal": "Lilibet", "given2": "Maria Teresa", "locale": "pcm_AQ", "surname-core": "Stromae", "surname-prefix": "bin", "surname2": "Robert", "title": "Mr."}, "file": "pcm", "label": "nativeFull", "order": "sorting", "shape": null, "text": "bin Stromae, Elizabeth Windsor Maria Teresa MP"} {"fields": {"credentials": "MP", "generation": "Jr.", "given": "Elizabeth Windsor", "given-informal": "Lilibet", "given2": "Maria Teresa", "locale": "pcm_AQ", "surname-core": "Stromae", "surname-prefix": "bin", "surname2": "Robert", "title": "Mr."}, "file": "pcm", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "bin Stromae Elizabeth Windsor Maria Teresa MP"} {"fields": {"given": "Sinbad", "locale": "pcm_AQ"}, "file": "pcm", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "pcm_AQ"}, "file": "pcm", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "pcm_AQ"}, "file": "pcm", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "John", "given2": "Hámish", "locale": "pcm_AQ", "surname": "Watson"}, "file": "pcm", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "John Hámish Watson"} {"fields": {"given": "John", "given2": "Hámish", "locale": "pcm_AQ", "surname": "Watson"}, "file": "pcm", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Watson, John Hámish"} {"fields": {"given": "John", "given2": "Hámish", "locale": "pcm_AQ", "surname": "Watson"}, "file": "pcm", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Watson John Hámish"} {"fields": {"given": "Irene", "locale": "pcm_AQ", "surname": "Adler"}, "file": "pcm", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Irene Adler"} {"fields": {"given": "Irene", "locale": "pcm_AQ", "surname": "Adler"}, "file": "pcm", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Adler, Irene"} {"fields": {"given": "Irene", "locale": "pcm_AQ", "surname": "Adler"}, "file": "pcm", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Adler Irene"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "prof. dr"}, "file": "pl", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "prof. dr Ada Cornelia von Brühl"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "prof. dr"}, "file": "pl", "label": "foreignFull", "order": "sorting", "shape": 4, "text": "von Brühl Ada Cornelia César Martín"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "prof. dr"}, "file": "pl", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "prof. dr von Brühl Ada Cornelia César Martín"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "pl", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "pl", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "pl", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "pl", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zäzilia Stöber"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "pl", "label": "foreignGGS", "order": "sorting", "shape": 4, "text": "Stöber Zäzilia Hamish"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "pl", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Stöber Zäzilia Hamish"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "pl", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Käthe Müller"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "pl", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "Müller Käthe"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "pl", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Müller Käthe"} {"fields": {"given": "Tomasz", "given-informal": "Tomek", "given2": "Jan", "locale": "pl_AQ", "surname-core": "Nowak", "title": "Pan"}, "file": "pl", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Pan Tomasz Nowak"} {"fields": {"given": "Tomasz", "given-informal": "Tomek", "given2": "Jan", "locale": "pl_AQ", "surname-core": "Nowak", "title": "Pan"}, "file": "pl", "label": "nativeFull", "order": "sorting", "shape": 4, "text": "Nowak Tomasz Jan"} {"fields": {"given": "Tomasz", "given-informal": "Tomek", "given2": "Jan", "locale": "pl_AQ", "surname-core": "Nowak", "title": "Pan"}, "file": "pl", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Pan Nowak Tomasz Jan"} {"fields": {"given": "Sindbad", "locale": "pl_AQ"}, "file": "pl", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Sindbad"} {"fields": {"given": "Sindbad", "locale": "pl_AQ"}, "file": "pl", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Sindbad"} {"fields": {"given": "Sindbad", "locale": "pl_AQ"}, "file": "pl", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Sindbad"} {"fields": {"given": "Maria", "given2": "Kamila", "locale": "pl_AQ", "surname": "Kowalska"}, "file": "pl", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Maria Kowalska"} {"fields": {"given": "Maria", "given2": "Kamila", "locale": "pl_AQ", "surname": "Kowalska"}, "file": "pl", "label": "nativeGGS", "order": "sorting", "shape": 4, "text": "Kowalska Maria Kamila"} {"fields": {"given": "Maria", "given2": "Kamila", "locale": "pl_AQ", "surname": "Kowalska"}, "file": "pl", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Kowalska Maria Kamila"} {"fields": {"given": "Ewa", "locale": "pl_AQ", "surname": "Nowak"}, "file": "pl", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Ewa Nowak"} {"fields": {"given": "Ewa", "locale": "pl_AQ", "surname": "Nowak"}, "file": "pl", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "Nowak Ewa"} {"fields": {"given": "Ewa", "locale": "pl_AQ", "surname": "Nowak"}, "file": "pl", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Nowak Ewa"} {"fields": {"credentials": "ایم ‌ډی ډیی ډیی ایس", "generation": "نسل", "given": "اډا کورنیلیا", "given-informal": "نیلي", "given2": "ایوا سوفیا", "locale": "ko_AQ", "surname-core": "لیوه", "surname-prefix": "وون", "surname2": "بیکر شمیت", "title": "پروفیسور ډاکټر"}, "file": "ps", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "پروفیسور ډاکټر اډا کورنیلیا ایوا سوفیا وون لیوه بیکر شمیت ایم ‌ډی ډیی ډیی ایس"} {"fields": {"credentials": "ایم ‌ډی ډیی ډیی ایس", "generation": "نسل", "given": "اډا کورنیلیا", "given-informal": "نیلي", "given2": "ایوا سوفیا", "locale": "ko_AQ", "surname-core": "لیوه", "surname-prefix": "وون", "surname2": "بیکر شمیت", "title": "پروفیسور ډاکټر"}, "file": "ps", "label": "foreignFull", "order": "sorting", "shape": null, "text": "وون لیوه بیکر شمیت, پروفیسور ډاکټر اډا کورنیلیا ایوا سوفیا ایم ‌ډی ډیی ډیی ایس"} {"fields": {"credentials": "ایم ‌ډی ډیی ډیی ایس", "generation": "نسل", "given": "اډا کورنیلیا", "given-informal": "نیلي", "given2": "ایوا سوفیا", "locale": "ko_AQ", "surname-core": "لیوه", "surname-prefix": "وون", "surname2": "بیکر شمیت", "title": "پروفیسور ډاکټر"}, "file": "ps", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "وون لیوه بیکر شمیت پروفیسور ډاکټر اډا کورنیلیا ایوا سوفیا ایم ‌ډی ډیی ډیی ایس"} {"fields": {"given": "سنباد", "locale": "ko_AQ"}, "file": "ps", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "سنباد"} {"fields": {"given": "سنباد", "locale": "ko_AQ"}, "file": "ps", "label": "foreignG", "order": "sorting", "shape": 4, "text": "سنباد"} {"fields": {"given": "سنباد", "locale": "ko_AQ"}, "file": "ps", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "سنباد"} {"fields": {"given": "زیزیلیا", "given2": "همیش", "locale": "ko_AQ", "surname": "سټوبر"}, "file": "ps", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "زیزیلیا همیش سټوبر"} {"fields": {"given": "زیزیلیا", "given2": "همیش", "locale": "ko_AQ", "surname": "سټوبر"}, "file": "ps", "label": "foreignGGS", "order": "sorting", "shape": 4, "text": "سټوبر زیزیلیا همیش"} {"fields": {"given": "زیزیلیا", "given2": "همیش", "locale": "ko_AQ", "surname": "سټوبر"}, "file": "ps", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "سټوبر زیزیلیا همیش"} {"fields": {"given": "کیتی", "locale": "ko_AQ", "surname": "مولر"}, "file": "ps", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "کیتی مولر"} {"fields": {"given": "کیتی", "locale": "ko_AQ", "surname": "مولر"}, "file": "ps", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "مولر کیتی"} {"fields": {"given": "کیتی", "locale": "ko_AQ", "surname": "مولر"}, "file": "ps", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "مولر کیتی"} {"fields": {"credentials": "ایم پی", "generation": "نسل", "given": "برټرام ولبرفورس", "given-informal": "برټي", "given2": "هنري رابرټ", "locale": "ps_AQ", "surname-core": "ووسټر", "title": "ښاغلی."}, "file": "ps", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "ښاغلی. برټرام ولبرفورس هنري رابرټ ووسټر ایم پی"} {"fields": {"credentials": "ایم پی", "generation": "نسل", "given": "برټرام ولبرفورس", "given-informal": "برټي", "given2": "هنري رابرټ", "locale": "ps_AQ", "surname-core": "ووسټر", "title": "ښاغلی."}, "file": "ps", "label": "nativeFull", "order": "sorting", "shape": 4, "text": "ووسټر ښاغلی. برټرام ولبرفورس هنري رابرټ ایم پی"} {"fields": {"credentials": "ایم پی", "generation": "نسل", "given": "برټرام ولبرفورس", "given-informal": "برټي", "given2": "هنري رابرټ", "locale": "ps_AQ", "surname-core": "ووسټر", "title": "ښاغلی."}, "file": "ps", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "ووسټر ښاغلی. برټرام ولبرفورس هنري رابرټ ایم پی"} {"fields": {"given": "سنباد", "locale": "ps_AQ"}, "file": "ps", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "سنباد"} {"fields": {"given": "سنباد", "locale": "ps_AQ"}, "file": "ps", "label": "nativeG", "order": "sorting", "shape": 4, "text": "سنباد"} {"fields": {"given": "سنباد", "locale": "ps_AQ"}, "file": "ps", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "سنباد"} {"fields": {"given": "جان", "given2": "همیش", "locale": "ps_AQ", "surname": "واټسن"}, "file": "ps", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "جان همیش واټسن"} {"fields": {"given": "جان", "given2": "همیش", "locale": "ps_AQ", "surname": "واټسن"}, "file": "ps", "label": "nativeGGS", "order": "sorting", "shape": 4, "text": "واټسن جان همیش"} {"fields": {"given": "جان", "given2": "همیش", "locale": "ps_AQ", "surname": "واټسن"}, "file": "ps", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "واټسن جان همیش"} {"fields": {"given": "ارین", "locale": "ps_AQ", "surname": "اډلر"}, "file": "ps", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "ارین اډلر"} {"fields": {"given": "ارین", "locale": "ps_AQ", "surname": "اډلر"}, "file": "ps", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "اډلر ارین"} {"fields": {"given": "ارین", "locale": "ps_AQ", "surname": "اډلر"}, "file": "ps", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "اډلر ارین"} {"fields": {"credentials": "Dr. Ph.D", "generation": "Jr.", "given": "Mary Lou", "given-informal": "Neele", "given2": "Cesare Martín", "locale": "ja_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "pt", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Prof. Dr. Mary Lou Cesare Martín von Brühl Jr., Dr. Ph.D"} {"fields": {"credentials": "Dr. Ph.D", "generation": "Jr.", "given": "Mary Lou", "given-informal": "Neele", "given2": "Cesare Martín", "locale": "ja_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "pt", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Brühl, Mary Lou Cesare Martín von"} {"fields": {"credentials": "Dr. Ph.D", "generation": "Jr.", "given": "Mary Lou", "given-informal": "Neele", "given2": "Cesare Martín", "locale": "ja_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "pt", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "von Brühl Prof. Dr. Mary Lou Cesare Martín Jr., Dr. Ph.D"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "pt", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "pt", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ja_AQ"}, "file": "pt", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stöber"}, "file": "pt", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zäzilia Hamish Stöber"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stöber"}, "file": "pt", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Stöber, Zäzilia Hamish"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stöber"}, "file": "pt", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Stöber Zäzilia Hamish"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "pt", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Käthe Müller"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "pt", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Müller, Käthe"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "pt", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Müller Käthe"} {"fields": {"credentials": "MP", "generation": "Jr.", "given": "José", "given-informal": "Zé", "given2": "Pedro Eduardo", "locale": "pt_AQ", "surname-core": "Silva", "surname-prefix": "da", "surname2": "Silveira", "title": "Sr."}, "file": "pt", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Sr. José Pedro Eduardo da Silva Jr., MP"} {"fields": {"credentials": "MP", "generation": "Jr.", "given": "José", "given-informal": "Zé", "given2": "Pedro Eduardo", "locale": "pt_AQ", "surname-core": "Silva", "surname-prefix": "da", "surname2": "Silveira", "title": "Sr."}, "file": "pt", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Silva, José Pedro Eduardo da"} {"fields": {"credentials": "MP", "generation": "Jr.", "given": "José", "given-informal": "Zé", "given2": "Pedro Eduardo", "locale": "pt_AQ", "surname-core": "Silva", "surname-prefix": "da", "surname2": "Silveira", "title": "Sr."}, "file": "pt", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "da Silva Sr. José Pedro Eduardo Jr., MP"} {"fields": {"given": "Maria", "locale": "pt_AQ"}, "file": "pt", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Maria"} {"fields": {"given": "Maria", "locale": "pt_AQ"}, "file": "pt", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Maria"} {"fields": {"given": "Maria", "locale": "pt_AQ"}, "file": "pt", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Maria"} {"fields": {"given": "João", "given2": "Pedro", "locale": "pt_AQ", "surname": "Silva"}, "file": "pt", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "João Pedro Silva"} {"fields": {"given": "João", "given2": "Pedro", "locale": "pt_AQ", "surname": "Silva"}, "file": "pt", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Silva, João Pedro"} {"fields": {"given": "João", "given2": "Pedro", "locale": "pt_AQ", "surname": "Silva"}, "file": "pt", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Silva João Pedro"} {"fields": {"given": "Maria", "locale": "pt_AQ", "surname": "Silva"}, "file": "pt", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Maria Silva"} {"fields": {"given": "Maria", "locale": "pt_AQ", "surname": "Silva"}, "file": "pt", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Silva, Maria"} {"fields": {"given": "Maria", "locale": "pt_AQ", "surname": "Silva"}, "file": "pt", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Silva Maria"} {"fields": {"credentials": "Ph. D.", "generation": "Jr.", "given": "Alejandro Manuel", "given-informal": "Ale", "given2": "César Mamani", "locale": "ko_AQ", "surname-core": "Cruz", "surname-prefix": "de la", "surname2": "Guzmán Huamán", "title": "Prof. Dr."}, "file": "qu", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Ph. D., Prof. Dr. Alejandro Manuel César Mamani de la Cruz Jr."} {"fields": {"credentials": "Ph. D.", "generation": "Jr.", "given": "Alejandro Manuel", "given-informal": "Ale", "given2": "César Mamani", "locale": "ko_AQ", "surname-core": "Cruz", "surname-prefix": "de la", "surname2": "Guzmán Huamán", "title": "Prof. Dr."}, "file": "qu", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Cruz, de la Alejandro Manuel César Mamani"} {"fields": {"credentials": "Ph. D.", "generation": "Jr.", "given": "Alejandro Manuel", "given-informal": "Ale", "given2": "César Mamani", "locale": "ko_AQ", "surname-core": "Cruz", "surname-prefix": "de la", "surname2": "Guzmán Huamán", "title": "Prof. Dr."}, "file": "qu", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "Ph. D., Prof. Dr. de la Cruz Alejandro Manuel César Mamani Jr."} {"fields": {"given": "Pedro", "locale": "ko_AQ"}, "file": "qu", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Pedro"} {"fields": {"given": "Pedro", "locale": "ko_AQ"}, "file": "qu", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Pedro"} {"fields": {"given": "Pedro", "locale": "ko_AQ"}, "file": "qu", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Pedro"} {"fields": {"given": "Edwin", "given2": "Segundo", "locale": "ko_AQ", "surname": "Condori"}, "file": "qu", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Edwin Segundo Condori"} {"fields": {"given": "Edwin", "given2": "Segundo", "locale": "ko_AQ", "surname": "Condori"}, "file": "qu", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Condori, Edwin Segundo"} {"fields": {"given": "Edwin", "given2": "Segundo", "locale": "ko_AQ", "surname": "Condori"}, "file": "qu", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Condori Edwin Segundo"} {"fields": {"given": "Ana", "locale": "ko_AQ", "surname": "Torres"}, "file": "qu", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Ana Torres"} {"fields": {"given": "Ana", "locale": "ko_AQ", "surname": "Torres"}, "file": "qu", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Torres, Ana"} {"fields": {"given": "Ana", "locale": "ko_AQ", "surname": "Torres"}, "file": "qu", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Torres Ana"} {"fields": {"credentials": "A. R.", "generation": "Jr.", "given": "Maria García", "given-informal": "Mari", "given2": "Rosa", "locale": "qu_AQ", "surname-core": "Cruz", "surname-prefix": "de la", "surname2": "Quispe", "title": "Sr."}, "file": "qu", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "A. R., Sr. Maria García Rosa de la Cruz Jr."} {"fields": {"credentials": "A. R.", "generation": "Jr.", "given": "Maria García", "given-informal": "Mari", "given2": "Rosa", "locale": "qu_AQ", "surname-core": "Cruz", "surname-prefix": "de la", "surname2": "Quispe", "title": "Sr."}, "file": "qu", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Cruz, de la Maria García Rosa"} {"fields": {"credentials": "A. R.", "generation": "Jr.", "given": "Maria García", "given-informal": "Mari", "given2": "Rosa", "locale": "qu_AQ", "surname-core": "Cruz", "surname-prefix": "de la", "surname2": "Quispe", "title": "Sr."}, "file": "qu", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "A. R., Sr. de la Cruz Maria García Rosa Jr."} {"fields": {"given": "Mario", "locale": "qu_AQ"}, "file": "qu", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Mario"} {"fields": {"given": "Mario", "locale": "qu_AQ"}, "file": "qu", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Mario"} {"fields": {"given": "Mario", "locale": "qu_AQ"}, "file": "qu", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Mario"} {"fields": {"given": "Marco", "given2": "Antonio", "locale": "qu_AQ", "surname": "Flores"}, "file": "qu", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Marco Antonio Flores"} {"fields": {"given": "Marco", "given2": "Antonio", "locale": "qu_AQ", "surname": "Flores"}, "file": "qu", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Flores, Marco Antonio"} {"fields": {"given": "Marco", "given2": "Antonio", "locale": "qu_AQ", "surname": "Flores"}, "file": "qu", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Flores Marco Antonio"} {"fields": {"given": "Carlos", "locale": "qu_AQ", "surname": "Zabala"}, "file": "qu", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Carlos Zabala"} {"fields": {"given": "Carlos", "locale": "qu_AQ", "surname": "Zabala"}, "file": "qu", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Zabala, Carlos"} {"fields": {"given": "Carlos", "locale": "qu_AQ", "surname": "Zabala"}, "file": "qu", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Zabala Carlos"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ja_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "rm", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Prof. Dr. Ada Cornelia César Martín von Brühl Jr MD DDS"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ja_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "rm", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Brühl, Ada Cornelia César Martín von"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ja_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "rm", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "von Brühl, Prof. Dr. Ada Cornelia César Martín Jr MD DDS"} {"fields": {"given": "Rüdiger", "locale": "ja_AQ"}, "file": "rm", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Rüdiger"} {"fields": {"given": "Rüdiger", "locale": "ja_AQ"}, "file": "rm", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Rüdiger"} {"fields": {"given": "Rüdiger", "locale": "ja_AQ"}, "file": "rm", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Rüdiger"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stöber"}, "file": "rm", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zäzilia Hamish Stöber"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stöber"}, "file": "rm", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Stöber, Zäzilia Hamish"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ja_AQ", "surname": "Stöber"}, "file": "rm", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Stöber, Zäzilia Hamish"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "rm", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Käthe Müller"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "rm", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Müller, Käthe"} {"fields": {"given": "Käthe", "locale": "ja_AQ", "surname": "Müller"}, "file": "rm", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Müller, Käthe"} {"fields": {"credentials": "CF", "generation": "jr.", "given": "Margaritta Marionna", "given-informal": "Rita", "given2": "Maria", "locale": "rm_AQ", "surname-core": "Planta", "surname-prefix": "de", "title": "dr."}, "file": "rm", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "dr. Margaritta Marionna Maria de Planta jr. CF"} {"fields": {"credentials": "CF", "generation": "jr.", "given": "Margaritta Marionna", "given-informal": "Rita", "given2": "Maria", "locale": "rm_AQ", "surname-core": "Planta", "surname-prefix": "de", "title": "dr."}, "file": "rm", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Planta, Margaritta Marionna Maria de"} {"fields": {"credentials": "CF", "generation": "jr.", "given": "Margaritta Marionna", "given-informal": "Rita", "given2": "Maria", "locale": "rm_AQ", "surname-core": "Planta", "surname-prefix": "de", "title": "dr."}, "file": "rm", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "de Planta, dr. Margaritta Marionna Maria jr. CF"} {"fields": {"given": "Ursina", "locale": "rm_AQ"}, "file": "rm", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Ursina"} {"fields": {"given": "Ursina", "locale": "rm_AQ"}, "file": "rm", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Ursina"} {"fields": {"given": "Ursina", "locale": "rm_AQ"}, "file": "rm", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Ursina"} {"fields": {"given": "Gian Carlo", "given2": "Ramun", "locale": "rm_AQ", "surname": "Foffa"}, "file": "rm", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Gian Carlo Ramun Foffa"} {"fields": {"given": "Gian Carlo", "given2": "Ramun", "locale": "rm_AQ", "surname": "Foffa"}, "file": "rm", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Foffa, Gian Carlo Ramun"} {"fields": {"given": "Gian Carlo", "given2": "Ramun", "locale": "rm_AQ", "surname": "Foffa"}, "file": "rm", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Foffa, Gian Carlo Ramun"} {"fields": {"given": "Aita", "locale": "rm_AQ", "surname": "Caduff"}, "file": "rm", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Aita Caduff"} {"fields": {"given": "Aita", "locale": "rm_AQ", "surname": "Caduff"}, "file": "rm", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Caduff, Aita"} {"fields": {"given": "Aita", "locale": "rm_AQ", "surname": "Caduff"}, "file": "rm", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Caduff, Aita"} {"fields": {"credentials": "MD Chirurgie dentară", "generation": "Jr.", "given": "Eva-Eleonora", "given-informal": "Teo", "given2": "Karl Hans", "locale": "ko_AQ", "surname-core": "Ruhr", "surname-prefix": "von der", "surname2": "Friedrich", "title": "Prof. dr."}, "file": "ro", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Prof. dr. MD Chirurgie dentară Eva-Eleonora Karl Hans von der Ruhr Jr."} {"fields": {"credentials": "MD Chirurgie dentară", "generation": "Jr.", "given": "Eva-Eleonora", "given-informal": "Teo", "given2": "Karl Hans", "locale": "ko_AQ", "surname-core": "Ruhr", "surname-prefix": "von der", "surname2": "Friedrich", "title": "Prof. dr."}, "file": "ro", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Ruhr, Eva-Eleonora Karl Hans von der"} {"fields": {"credentials": "MD Chirurgie dentară", "generation": "Jr.", "given": "Eva-Eleonora", "given-informal": "Teo", "given2": "Karl Hans", "locale": "ko_AQ", "surname-core": "Ruhr", "surname-prefix": "von der", "surname2": "Friedrich", "title": "Prof. dr."}, "file": "ro", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "MD Chirurgie dentară von der Ruhr Prof. dr. Eva-Eleonora Karl Hans Jr."} {"fields": {"given": "Mark", "locale": "ko_AQ"}, "file": "ro", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Mark"} {"fields": {"given": "Mark", "locale": "ko_AQ"}, "file": "ro", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Mark"} {"fields": {"given": "Mark", "locale": "ko_AQ"}, "file": "ro", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Mark"} {"fields": {"given": "Noémie", "given2": "Katerina", "locale": "ko_AQ", "surname": "Yannaki"}, "file": "ro", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Noémie Katerina Yannaki"} {"fields": {"given": "Noémie", "given2": "Katerina", "locale": "ko_AQ", "surname": "Yannaki"}, "file": "ro", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Yannaki, Noémie Katerina"} {"fields": {"given": "Noémie", "given2": "Katerina", "locale": "ko_AQ", "surname": "Yannaki"}, "file": "ro", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Yannaki Noémie Katerina"} {"fields": {"given": "Mary", "locale": "ko_AQ", "surname": "Johnson"}, "file": "ro", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Mary Johnson"} {"fields": {"given": "Mary", "locale": "ko_AQ", "surname": "Johnson"}, "file": "ro", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Johnson, Mary"} {"fields": {"given": "Mary", "locale": "ko_AQ", "surname": "Johnson"}, "file": "ro", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Johnson Mary"} {"fields": {"credentials": "Dr.", "generation": "Jr.", "given": "Daniel Ionescu", "given-informal": "Dani", "given2": "Dragoș Ioan", "locale": "ro_AQ", "surname-core": "Ardelean", "title": "Dl"}, "file": "ro", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Dl Dr. Daniel Ionescu Dragoș Ioan Ardelean Jr."} {"fields": {"credentials": "Dr.", "generation": "Jr.", "given": "Daniel Ionescu", "given-informal": "Dani", "given2": "Dragoș Ioan", "locale": "ro_AQ", "surname-core": "Ardelean", "title": "Dl"}, "file": "ro", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Ardelean, Daniel Ionescu Dragoș Ioan"} {"fields": {"credentials": "Dr.", "generation": "Jr.", "given": "Daniel Ionescu", "given-informal": "Dani", "given2": "Dragoș Ioan", "locale": "ro_AQ", "surname-core": "Ardelean", "title": "Dl"}, "file": "ro", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Dr. Ardelean Dl Daniel Ionescu Dragoș Ioan Jr."} {"fields": {"given": "Alina", "locale": "ro_AQ"}, "file": "ro", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Alina"} {"fields": {"given": "Alina", "locale": "ro_AQ"}, "file": "ro", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Alina"} {"fields": {"given": "Alina", "locale": "ro_AQ"}, "file": "ro", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Alina"} {"fields": {"given": "Alexandru", "given2": "Ioan", "locale": "ro_AQ", "surname": "Popa"}, "file": "ro", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Alexandru Ioan Popa"} {"fields": {"given": "Alexandru", "given2": "Ioan", "locale": "ro_AQ", "surname": "Popa"}, "file": "ro", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Popa, Alexandru Ioan"} {"fields": {"given": "Alexandru", "given2": "Ioan", "locale": "ro_AQ", "surname": "Popa"}, "file": "ro", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Popa Alexandru Ioan"} {"fields": {"given": "Maria", "locale": "ro_AQ", "surname": "Popescu"}, "file": "ro", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Maria Popescu"} {"fields": {"given": "Maria", "locale": "ro_AQ", "surname": "Popescu"}, "file": "ro", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Popescu, Maria"} {"fields": {"given": "Maria", "locale": "ro_AQ", "surname": "Popescu"}, "file": "ro", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Popescu Maria"} {"fields": {"credentials": "к. м. н.", "generation": "мл.", "given": "Ада Корнелия", "given-informal": "Неле", "given2": "Сезар Мартин", "locale": "ko_AQ", "surname-core": "Брюль", "surname-prefix": "фон", "surname2": "Гонсалес Доминго", "title": "проф., д-р"}, "file": "ru", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "проф., д-р Ада Корнелия Сезар Мартин фон Брюль — мл., к. м. н."} {"fields": {"credentials": "к. м. н.", "generation": "мл.", "given": "Ада Корнелия", "given-informal": "Неле", "given2": "Сезар Мартин", "locale": "ko_AQ", "surname-core": "Брюль", "surname-prefix": "фон", "surname2": "Гонсалес Доминго", "title": "проф., д-р"}, "file": "ru", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Брюль, Ада Корнелия Сезар Мартин фон"} {"fields": {"credentials": "к. м. н.", "generation": "мл.", "given": "Ада Корнелия", "given-informal": "Неле", "given2": "Сезар Мартин", "locale": "ko_AQ", "surname-core": "Брюль", "surname-prefix": "фон", "surname2": "Гонсалес Доминго", "title": "проф., д-р"}, "file": "ru", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "фон Брюль Ада Корнелия Сезар Мартин, к. м. н."} {"fields": {"given": "Синдбад", "locale": "ko_AQ"}, "file": "ru", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Синдбад"} {"fields": {"given": "Синдбад", "locale": "ko_AQ"}, "file": "ru", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Синдбад"} {"fields": {"given": "Синдбад", "locale": "ko_AQ"}, "file": "ru", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Синдбад"} {"fields": {"given": "Цецилия", "given2": "Хэмиш", "locale": "ko_AQ", "surname": "Штёбер"}, "file": "ru", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Цецилия Хэмиш Штёбер"} {"fields": {"given": "Цецилия", "given2": "Хэмиш", "locale": "ko_AQ", "surname": "Штёбер"}, "file": "ru", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Штёбер, Цецилия Хэмиш"} {"fields": {"given": "Цецилия", "given2": "Хэмиш", "locale": "ko_AQ", "surname": "Штёбер"}, "file": "ru", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Штёбер Цецилия Хэмиш"} {"fields": {"given": "Кете", "locale": "ko_AQ", "surname": "Мюллер"}, "file": "ru", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Кете Мюллер"} {"fields": {"given": "Кете", "locale": "ko_AQ", "surname": "Мюллер"}, "file": "ru", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Мюллер, Кете"} {"fields": {"given": "Кете", "locale": "ko_AQ", "surname": "Мюллер"}, "file": "ru", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Мюллер Кете"} {"fields": {"given": "Иван", "given-informal": "Ваня", "given2": "Васильевич", "locale": "ru_AQ", "surname-core": "Купцов"}, "file": "ru", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Иван Васильевич Купцов"} {"fields": {"given": "Иван", "given-informal": "Ваня", "given2": "Васильевич", "locale": "ru_AQ", "surname-core": "Купцов"}, "file": "ru", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Купцов, Иван Васильевич"} {"fields": {"given": "Иван", "given-informal": "Ваня", "given2": "Васильевич", "locale": "ru_AQ", "surname-core": "Купцов"}, "file": "ru", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Купцов Иван Васильевич"} {"fields": {"given": "Сергей", "locale": "ru_AQ"}, "file": "ru", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Сергей"} {"fields": {"given": "Сергей", "locale": "ru_AQ"}, "file": "ru", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Сергей"} {"fields": {"given": "Сергей", "locale": "ru_AQ"}, "file": "ru", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Сергей"} {"fields": {"given": "Дмитрий", "given2": "Петрович", "locale": "ru_AQ", "surname": "Васильев"}, "file": "ru", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Дмитрий Петрович Васильев"} {"fields": {"given": "Дмитрий", "given2": "Петрович", "locale": "ru_AQ", "surname": "Васильев"}, "file": "ru", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Васильев, Дмитрий Петрович"} {"fields": {"given": "Дмитрий", "given2": "Петрович", "locale": "ru_AQ", "surname": "Васильев"}, "file": "ru", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Васильев Дмитрий Петрович"} {"fields": {"given": "Ирина", "locale": "ru_AQ", "surname": "Амосова"}, "file": "ru", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Ирина Амосова"} {"fields": {"given": "Ирина", "locale": "ru_AQ", "surname": "Амосова"}, "file": "ru", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Амосова, Ирина"} {"fields": {"given": "Ирина", "locale": "ru_AQ", "surname": "Амосова"}, "file": "ru", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Амосова Ирина"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Maria Giusepa", "given-informal": "Pipina", "given2": "Juana Teresa", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Dut.ra Prof.ra"}, "file": "sc", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Dut.ra Prof.ra Maria Giusepa Juana Teresa von Brühl González Domingo"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Maria Giusepa", "given-informal": "Pipina", "given2": "Juana Teresa", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Dut.ra Prof.ra"}, "file": "sc", "label": "foreignFull", "order": "sorting", "shape": null, "text": "von Brühl, Maria Giusepa Juana Teresa"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Maria Giusepa", "given-informal": "Pipina", "given2": "Juana Teresa", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Dut.ra Prof.ra"}, "file": "sc", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "MD DDS Dut.ra Prof.ra von Brühl González Domingo Maria Giusepa Juana Teresa"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "sc", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "sc", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "sc", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "sc", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zäzilia Hamish Stöber"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "sc", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Stöber, Zäzilia Hamish"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "sc", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Stöber Zäzilia Hamish"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "sc", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Käthe Müller"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "sc", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Müller, Käthe"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "sc", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Müller Käthe"} {"fields": {"credentials": "onorèvole", "generation": "II", "given": "Antoni Baìngiu", "given-informal": "Toninu", "given2": "Torchitòriu Comita", "locale": "sc_AQ", "surname-core": "Lacon", "surname-prefix": "de", "surname2": "Gunale", "title": "Sr."}, "file": "sc", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Sr. Antoni Baìngiu Torchitòriu Comita de Lacon Gunale"} {"fields": {"credentials": "onorèvole", "generation": "II", "given": "Antoni Baìngiu", "given-informal": "Toninu", "given2": "Torchitòriu Comita", "locale": "sc_AQ", "surname-core": "Lacon", "surname-prefix": "de", "surname2": "Gunale", "title": "Sr."}, "file": "sc", "label": "nativeFull", "order": "sorting", "shape": null, "text": "de Lacon, Antoni Baìngiu Torchitòriu Comita"} {"fields": {"credentials": "onorèvole", "generation": "II", "given": "Antoni Baìngiu", "given-informal": "Toninu", "given2": "Torchitòriu Comita", "locale": "sc_AQ", "surname-core": "Lacon", "surname-prefix": "de", "surname2": "Gunale", "title": "Sr."}, "file": "sc", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "onorèvole Sr. de Lacon Gunale Antoni Baìngiu Torchitòriu Comita"} {"fields": {"given": "Frantziscu", "locale": "sc_AQ"}, "file": "sc", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Frantziscu"} {"fields": {"given": "Frantziscu", "locale": "sc_AQ"}, "file": "sc", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Frantziscu"} {"fields": {"given": "Frantziscu", "locale": "sc_AQ"}, "file": "sc", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Frantziscu"} {"fields": {"given": "Giuanne", "given2": "Marcu", "locale": "sc_AQ", "surname": "Piras"}, "file": "sc", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Giuanne Marcu Piras"} {"fields": {"given": "Giuanne", "given2": "Marcu", "locale": "sc_AQ", "surname": "Piras"}, "file": "sc", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Piras, Giuanne Marcu"} {"fields": {"given": "Giuanne", "given2": "Marcu", "locale": "sc_AQ", "surname": "Piras"}, "file": "sc", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Piras Giuanne Marcu"} {"fields": {"given": "Lianora", "locale": "sc_AQ", "surname": "Sanna"}, "file": "sc", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Lianora Sanna"} {"fields": {"given": "Lianora", "locale": "sc_AQ", "surname": "Sanna"}, "file": "sc", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Sanna, Lianora"} {"fields": {"given": "Lianora", "locale": "sc_AQ", "surname": "Sanna"}, "file": "sc", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Sanna Lianora"} {"fields": {"credentials": "ايم ڊي ڊي ڊي ايس", "generation": "جونيئر", "given": "ايڊا ڪورنيليا", "given-informal": "نيل", "given2": "سيزر مارٽن", "locale": "ko_AQ", "surname-core": "برل", "surname-prefix": "وان", "surname2": "گونزالس ڊومنگو", "title": "پروفيسر ڊاڪٽر"}, "file": "sd", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "پروفيسر ڊاڪٽر ايڊا ڪورنيليا سيزر مارٽن وان برل گونزالس ڊومنگو ايم ڊي ڊي ڊي ايس"} {"fields": {"credentials": "ايم ڊي ڊي ڊي ايس", "generation": "جونيئر", "given": "ايڊا ڪورنيليا", "given-informal": "نيل", "given2": "سيزر مارٽن", "locale": "ko_AQ", "surname-core": "برل", "surname-prefix": "وان", "surname2": "گونزالس ڊومنگو", "title": "پروفيسر ڊاڪٽر"}, "file": "sd", "label": "foreignFull", "order": "sorting", "shape": null, "text": "وان برل گونزالس ڊومنگو, پروفيسر ڊاڪٽر ايڊا ڪورنيليا سيزر مارٽن ايم ڊي ڊي ڊي ايس"} {"fields": {"credentials": "ايم ڊي ڊي ڊي ايس", "generation": "جونيئر", "given": "ايڊا ڪورنيليا", "given-informal": "نيل", "given2": "سيزر مارٽن", "locale": "ko_AQ", "surname-core": "برل", "surname-prefix": "وان", "surname2": "گونزالس ڊومنگو", "title": "پروفيسر ڊاڪٽر"}, "file": "sd", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "وان برل گونزالس ڊومنگو پروفيسر ڊاڪٽر ايڊا ڪورنيليا سيزر مارٽن ايم ڊي ڊي ڊي ايس"} {"fields": {"given": "سندباد", "locale": "ko_AQ"}, "file": "sd", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "سندباد"} {"fields": {"given": "سندباد", "locale": "ko_AQ"}, "file": "sd", "label": "foreignG", "order": "sorting", "shape": 4, "text": "سندباد"} {"fields": {"given": "سندباد", "locale": "ko_AQ"}, "file": "sd", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "سندباد"} {"fields": {"given": "زيزيليا", "given2": "ھئمش", "locale": "ko_AQ", "surname": "اسٽوبر"}, "file": "sd", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "زيزيليا ھئمش اسٽوبر"} {"fields": {"given": "زيزيليا", "given2": "ھئمش", "locale": "ko_AQ", "surname": "اسٽوبر"}, "file": "sd", "label": "foreignGGS", "order": "sorting", "shape": 4, "text": "اسٽوبر زيزيليا ھئمش"} {"fields": {"given": "زيزيليا", "given2": "ھئمش", "locale": "ko_AQ", "surname": "اسٽوبر"}, "file": "sd", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "اسٽوبر زيزيليا ھئمش"} {"fields": {"given": "ڪئٿي", "locale": "ko_AQ", "surname": "ملر"}, "file": "sd", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "ڪئٿي ملر"} {"fields": {"given": "ڪئٿي", "locale": "ko_AQ", "surname": "ملر"}, "file": "sd", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "ملر ڪئٿي"} {"fields": {"given": "ڪئٿي", "locale": "ko_AQ", "surname": "ملر"}, "file": "sd", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "ملر ڪئٿي"} {"fields": {"credentials": "ايم پي", "generation": "جونيئر", "given": "برٽرم ولبرفورس", "given-informal": "برٽي", "given2": "ھينري رابرٽ", "locale": "sd_AQ", "surname-core": "ووسٽر", "title": "محترم"}, "file": "sd", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "محترم برٽرم ولبرفورس ھينري رابرٽ ووسٽر ايم پي"} {"fields": {"credentials": "ايم پي", "generation": "جونيئر", "given": "برٽرم ولبرفورس", "given-informal": "برٽي", "given2": "ھينري رابرٽ", "locale": "sd_AQ", "surname-core": "ووسٽر", "title": "محترم"}, "file": "sd", "label": "nativeFull", "order": "sorting", "shape": 4, "text": "ووسٽر محترم برٽرم ولبرفورس ھينري رابرٽ ايم پي"} {"fields": {"credentials": "ايم پي", "generation": "جونيئر", "given": "برٽرم ولبرفورس", "given-informal": "برٽي", "given2": "ھينري رابرٽ", "locale": "sd_AQ", "surname-core": "ووسٽر", "title": "محترم"}, "file": "sd", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "ووسٽر محترم برٽرم ولبرفورس ھينري رابرٽ ايم پي"} {"fields": {"given": "سنباد", "locale": "sd_AQ"}, "file": "sd", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "سنباد"} {"fields": {"given": "سنباد", "locale": "sd_AQ"}, "file": "sd", "label": "nativeG", "order": "sorting", "shape": 4, "text": "سنباد"} {"fields": {"given": "سنباد", "locale": "sd_AQ"}, "file": "sd", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "سنباد"} {"fields": {"given": "ميري سيو", "given2": "ھامش", "locale": "sd_AQ", "surname": "واٽسن"}, "file": "sd", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "ميري سيو ھامش واٽسن"} {"fields": {"given": "ميري سيو", "given2": "ھامش", "locale": "sd_AQ", "surname": "واٽسن"}, "file": "sd", "label": "nativeGGS", "order": "sorting", "shape": 4, "text": "واٽسن ميري سيو ھامش"} {"fields": {"given": "ميري سيو", "given2": "ھامش", "locale": "sd_AQ", "surname": "واٽسن"}, "file": "sd", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "واٽسن ميري سيو ھامش"} {"fields": {"given": "آئرين", "locale": "sd_AQ", "surname": "ائڊلر"}, "file": "sd", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "آئرين ائڊلر"} {"fields": {"given": "آئرين", "locale": "sd_AQ", "surname": "ائڊلر"}, "file": "sd", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "ائڊلر آئرين"} {"fields": {"given": "آئرين", "locale": "sd_AQ", "surname": "ائڊلر"}, "file": "sd", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "ائڊلر آئرين"} {"fields": {"credentials": "မေႃယႃ", "generation": "ၵျူႇၼီႇယႃႇ", "given": "ဢေႇတႃႇ ၶူဝ်ႇၼီႇလီႇယႃႇ", "given-informal": "ၼီးဢႄႇလ်", "given2": "သီႇသႃႇ မႃႇတိၼ်ႇ", "locale": "ja_AQ", "surname-core": "ပရူဝ်ႇလ်", "surname-prefix": "ဝွၼ်း", "surname2": "ၵွၼ်ႇၸႃႇလႅတ်ႉ တူဝ်ႇမိၼ်ႇၵူဝ်", "title": "ၶူးမေႃယႃ"}, "file": "shn", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "ၶူးမေႃယႃ ဢေႇတႃႇ ၶူဝ်ႇၼီႇလီႇယႃႇ သီႇသႃႇ မႃႇတိၼ်ႇ ဝွၼ်း ပရူဝ်ႇလ် ၵွၼ်ႇၸႃႇလႅတ်ႉ တူဝ်ႇမိၼ်ႇၵူဝ် မေႃယႃ"} {"fields": {"credentials": "မေႃယႃ", "generation": "ၵျူႇၼီႇယႃႇ", "given": "ဢေႇတႃႇ ၶူဝ်ႇၼီႇလီႇယႃႇ", "given-informal": "ၼီးဢႄႇလ်", "given2": "သီႇသႃႇ မႃႇတိၼ်ႇ", "locale": "ja_AQ", "surname-core": "ပရူဝ်ႇလ်", "surname-prefix": "ဝွၼ်း", "surname2": "ၵွၼ်ႇၸႃႇလႅတ်ႉ တူဝ်ႇမိၼ်ႇၵူဝ်", "title": "ၶူးမေႃယႃ"}, "file": "shn", "label": "foreignFull", "order": "sorting", "shape": null, "text": "ဝွၼ်း ပရူဝ်ႇလ် ၵွၼ်ႇၸႃႇလႅတ်ႉ တူဝ်ႇမိၼ်ႇၵူဝ်, ၶူးမေႃယႃ ဢေႇတႃႇ ၶူဝ်ႇၼီႇလီႇယႃႇ သီႇသႃႇ မႃႇတိၼ်ႇ မေႃယႃ"} {"fields": {"credentials": "မေႃယႃ", "generation": "ၵျူႇၼီႇယႃႇ", "given": "ဢေႇတႃႇ ၶူဝ်ႇၼီႇလီႇယႃႇ", "given-informal": "ၼီးဢႄႇလ်", "given2": "သီႇသႃႇ မႃႇတိၼ်ႇ", "locale": "ja_AQ", "surname-core": "ပရူဝ်ႇလ်", "surname-prefix": "ဝွၼ်း", "surname2": "ၵွၼ်ႇၸႃႇလႅတ်ႉ တူဝ်ႇမိၼ်ႇၵူဝ်", "title": "ၶူးမေႃယႃ"}, "file": "shn", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "ဝွၼ်း ပရူဝ်ႇလ် ၵွၼ်ႇၸႃႇလႅတ်ႉ တူဝ်ႇမိၼ်ႇၵူဝ် ၶူးမေႃယႃ ဢေႇတႃႇ ၶူဝ်ႇၼီႇလီႇယႃႇ သီႇသႃႇ မႃႇတိၼ်ႇ မေႃယႃ"} {"fields": {"given": "သိၼ်းပၢတ်ႉ", "locale": "ja_AQ"}, "file": "shn", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "သိၼ်းပၢတ်ႉ"} {"fields": {"given": "သိၼ်းပၢတ်ႉ", "locale": "ja_AQ"}, "file": "shn", "label": "foreignG", "order": "sorting", "shape": 4, "text": "သိၼ်းပၢတ်ႉ"} {"fields": {"given": "သိၼ်းပၢတ်ႉ", "locale": "ja_AQ"}, "file": "shn", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "သိၼ်းပၢတ်ႉ"} {"fields": {"given": "ၸၸီးလီႇယႃႇ", "given2": "ႁေးမိသျ်", "locale": "ja_AQ", "surname": "သတူႇပႃႇ"}, "file": "shn", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "ၸၸီးလီႇယႃႇ ႁေးမိသျ် သတူႇပႃႇ"} {"fields": {"given": "ၸၸီးလီႇယႃႇ", "given2": "ႁေးမိသျ်", "locale": "ja_AQ", "surname": "သတူႇပႃႇ"}, "file": "shn", "label": "foreignGGS", "order": "sorting", "shape": 4, "text": "သတူႇပႃႇ ၸၸီးလီႇယႃႇ ႁေးမိသျ်"} {"fields": {"given": "ၸၸီးလီႇယႃႇ", "given2": "ႁေးမိသျ်", "locale": "ja_AQ", "surname": "သတူႇပႃႇ"}, "file": "shn", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "သတူႇပႃႇ ၸၸီးလီႇယႃႇ ႁေးမိသျ်"} {"fields": {"given": "ၶေးတႃႇ", "locale": "ja_AQ", "surname": "မူးလႃႇ"}, "file": "shn", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "ၶေးတႃႇ မူးလႃႇ"} {"fields": {"given": "ၶေးတႃႇ", "locale": "ja_AQ", "surname": "မူးလႃႇ"}, "file": "shn", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "မူးလႃႇ ၶေးတႃႇ"} {"fields": {"given": "ၶေးတႃႇ", "locale": "ja_AQ", "surname": "မူးလႃႇ"}, "file": "shn", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "မူးလႃႇ ၶေးတႃႇ"} {"fields": {"credentials": "မေႃယႃ", "generation": "ၵျူႇၼီႇယႃႇ", "given": "ပႃႇထရမ်ႇ ဝီလ်ပႃႇၾူတ်ႉ", "given-informal": "ပႃတီႇ", "given2": "ႁႅၼ်ႇၼရီႇ ရေႃးပၢတ်ႉ", "locale": "shn_AQ", "surname-core": "ဝုတ်ႉသ်တႃႇ", "title": "မိတ်ႇသတႃႇ"}, "file": "shn", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "မိတ်ႇသတႃႇ ပႃႇထရမ်ႇ ဝီလ်ပႃႇၾူတ်ႉ ႁႅၼ်ႇၼရီႇ ရေႃးပၢတ်ႉ ဝုတ်ႉသ်တႃႇ မေႃယႃ"} {"fields": {"credentials": "မေႃယႃ", "generation": "ၵျူႇၼီႇယႃႇ", "given": "ပႃႇထရမ်ႇ ဝီလ်ပႃႇၾူတ်ႉ", "given-informal": "ပႃတီႇ", "given2": "ႁႅၼ်ႇၼရီႇ ရေႃးပၢတ်ႉ", "locale": "shn_AQ", "surname-core": "ဝုတ်ႉသ်တႃႇ", "title": "မိတ်ႇသတႃႇ"}, "file": "shn", "label": "nativeFull", "order": "sorting", "shape": 4, "text": "ဝုတ်ႉသ်တႃႇ မိတ်ႇသတႃႇ ပႃႇထရမ်ႇ ဝီလ်ပႃႇၾူတ်ႉ ႁႅၼ်ႇၼရီႇ ရေႃးပၢတ်ႉ မေႃယႃ"} {"fields": {"credentials": "မေႃယႃ", "generation": "ၵျူႇၼီႇယႃႇ", "given": "ပႃႇထရမ်ႇ ဝီလ်ပႃႇၾူတ်ႉ", "given-informal": "ပႃတီႇ", "given2": "ႁႅၼ်ႇၼရီႇ ရေႃးပၢတ်ႉ", "locale": "shn_AQ", "surname-core": "ဝုတ်ႉသ်တႃႇ", "title": "မိတ်ႇသတႃႇ"}, "file": "shn", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "ဝုတ်ႉသ်တႃႇ မိတ်ႇသတႃႇ ပႃႇထရမ်ႇ ဝီလ်ပႃႇၾူတ်ႉ ႁႅၼ်ႇၼရီႇ ရေႃးပၢတ်ႉ မေႃယႃ"} {"fields": {"given": "ၸႅၼ်ႇတေႇယႃႇ", "locale": "shn_AQ"}, "file": "shn", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "ၸႅၼ်ႇတေႇယႃႇ"} {"fields": {"given": "ၸႅၼ်ႇတေႇယႃႇ", "locale": "shn_AQ"}, "file": "shn", "label": "nativeG", "order": "sorting", "shape": 4, "text": "ၸႅၼ်ႇတေႇယႃႇ"} {"fields": {"given": "ၸႅၼ်ႇတေႇယႃႇ", "locale": "shn_AQ"}, "file": "shn", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "ၸႅၼ်ႇတေႇယႃႇ"} {"fields": {"given": "မေႇရီႇသူး", "given2": "ႁေးမိသျ်", "locale": "shn_AQ", "surname": "ဝတ်ႉသၼ်ႇ"}, "file": "shn", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "မေႇရီႇသူး ႁေးမိသျ် ဝတ်ႉသၼ်ႇ"} {"fields": {"given": "မေႇရီႇသူး", "given2": "ႁေးမိသျ်", "locale": "shn_AQ", "surname": "ဝတ်ႉသၼ်ႇ"}, "file": "shn", "label": "nativeGGS", "order": "sorting", "shape": 4, "text": "ဝတ်ႉသၼ်ႇ မေႇရီႇသူး ႁေးမိသျ်"} {"fields": {"given": "မေႇရီႇသူး", "given2": "ႁေးမိသျ်", "locale": "shn_AQ", "surname": "ဝတ်ႉသၼ်ႇ"}, "file": "shn", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "ဝတ်ႉသၼ်ႇ မေႇရီႇသူး ႁေးမိသျ်"} {"fields": {"given": "ဢၢႆႇရိၼ်း", "locale": "shn_AQ", "surname": "ဢႅတ်ႉလႃႇ"}, "file": "shn", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "ဢၢႆႇရိၼ်း ဢႅတ်ႉလႃႇ"} {"fields": {"given": "ဢၢႆႇရိၼ်း", "locale": "shn_AQ", "surname": "ဢႅတ်ႉလႃႇ"}, "file": "shn", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "ဢႅတ်ႉလႃႇ ဢၢႆႇရိၼ်း"} {"fields": {"given": "ဢၢႆႇရိၼ်း", "locale": "shn_AQ", "surname": "ဢႅတ်ႉလႃႇ"}, "file": "shn", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "ဢႅတ်ႉလႃႇ ဢၢႆႇရိၼ်း"} {"fields": {"credentials": "එම්.ඩී. පී.එච්.ඩී.", "generation": "බාල", "given": "ඇඩා කොර්නේලියා", "given-informal": "නීලේ", "given2": "සීසර් මාටින්", "locale": "fr_AQ", "surname-core": "බෲල්", "surname-prefix": "වොන්", "surname2": "ගොන්සාලෙස් ඩොමින්ගෝ", "title": "මහාචාර්ය. ආචාර්ය."}, "file": "si", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "මහාචාර්ය. ආචාර්ය. ඇඩා කොර්නේලියා සීසර් මාටින් වොන් බෲල් ගොන්සාලෙස් ඩොමින්ගෝ එම්.ඩී. පී.එච්.ඩී."} {"fields": {"credentials": "එම්.ඩී. පී.එච්.ඩී.", "generation": "බාල", "given": "ඇඩා කොර්නේලියා", "given-informal": "නීලේ", "given2": "සීසර් මාටින්", "locale": "fr_AQ", "surname-core": "බෲල්", "surname-prefix": "වොන්", "surname2": "ගොන්සාලෙස් ඩොමින්ගෝ", "title": "මහාචාර්ය. ආචාර්ය."}, "file": "si", "label": "foreignFull", "order": "sorting", "shape": null, "text": "ඇඩා කොර්නේලියා සීසර් මාටින්, වොන් බෲල්"} {"fields": {"credentials": "එම්.ඩී. පී.එච්.ඩී.", "generation": "බාල", "given": "ඇඩා කොර්නේලියා", "given-informal": "නීලේ", "given2": "සීසර් මාටින්", "locale": "fr_AQ", "surname-core": "බෲල්", "surname-prefix": "වොන්", "surname2": "ගොන්සාලෙස් ඩොමින්ගෝ", "title": "මහාචාර්ය. ආචාර්ය."}, "file": "si", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "වොන් බෲල් මහාචාර්ය. ආචාර්ය. ඇඩා කොර්නේලියා සීසර් මාටින් බාල, එම්.ඩී. පී.එච්.ඩී."} {"fields": {"given": "සින්බෑඩ්", "locale": "fr_AQ"}, "file": "si", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "සින්බෑඩ්"} {"fields": {"given": "සින්බෑඩ්", "locale": "fr_AQ"}, "file": "si", "label": "foreignG", "order": "sorting", "shape": 4, "text": "සින්බෑඩ්"} {"fields": {"given": "සින්බෑඩ්", "locale": "fr_AQ"}, "file": "si", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "සින්බෑඩ්"} {"fields": {"given": "සාසිලියා", "given2": "හමිෂ්", "locale": "fr_AQ", "surname": "ස්ටෝබර්"}, "file": "si", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "සාසිලියා හමිෂ් ස්ටෝබර්"} {"fields": {"given": "සාසිලියා", "given2": "හමිෂ්", "locale": "fr_AQ", "surname": "ස්ටෝබර්"}, "file": "si", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "සාසිලියා හමිෂ්, ස්ටෝබර්"} {"fields": {"given": "සාසිලියා", "given2": "හමිෂ්", "locale": "fr_AQ", "surname": "ස්ටෝබර්"}, "file": "si", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "ස්ටෝබර් සාසිලියා හමිෂ්"} {"fields": {"given": "කේතේ", "locale": "fr_AQ", "surname": "මුලර්"}, "file": "si", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "කේතේ මුලර්"} {"fields": {"given": "කේතේ", "locale": "fr_AQ", "surname": "මුලර්"}, "file": "si", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "කේතේ මුලර්"} {"fields": {"given": "කේතේ", "locale": "fr_AQ", "surname": "මුලර්"}, "file": "si", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "මුලර් කේතේ"} {"fields": {"credentials": "පා.ම.", "generation": "බාල", "given": "බර්ට්‍රම් විල්බර්ෆෝස්", "given-informal": "බර්ටි", "given2": "හෙන්රි රොබට්", "locale": "si_AQ", "surname-core": "වූස්ටර්", "title": "මහතා"}, "file": "si", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "මහතා බර්ට්‍රම් විල්බර්ෆෝස් හෙන්රි රොබට් වූස්ටර් පා.ම."} {"fields": {"credentials": "පා.ම.", "generation": "බාල", "given": "බර්ට්‍රම් විල්බර්ෆෝස්", "given-informal": "බර්ටි", "given2": "හෙන්රි රොබට්", "locale": "si_AQ", "surname-core": "වූස්ටර්", "title": "මහතා"}, "file": "si", "label": "nativeFull", "order": "sorting", "shape": null, "text": "බර්ට්‍රම් විල්බර්ෆෝස් හෙන්රි රොබට්, වූස්ටර්"} {"fields": {"credentials": "පා.ම.", "generation": "බාල", "given": "බර්ට්‍රම් විල්බර්ෆෝස්", "given-informal": "බර්ටි", "given2": "හෙන්රි රොබට්", "locale": "si_AQ", "surname-core": "වූස්ටර්", "title": "මහතා"}, "file": "si", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "වූස්ටර් මහතා බර්ට්‍රම් විල්බර්ෆෝස් හෙන්රි රොබට් බාල, පා.ම."} {"fields": {"given": "සෙන්ඩයා", "locale": "si_AQ"}, "file": "si", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "සෙන්ඩයා"} {"fields": {"given": "සෙන්ඩයා", "locale": "si_AQ"}, "file": "si", "label": "nativeG", "order": "sorting", "shape": 4, "text": "සෙන්ඩයා"} {"fields": {"given": "සෙන්ඩයා", "locale": "si_AQ"}, "file": "si", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "සෙන්ඩයා"} {"fields": {"given": "මේරි සූ", "given2": "හමිෂ්", "locale": "si_AQ", "surname": "වොට්සන්"}, "file": "si", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "මේරි සූ හමිෂ් වොට්සන්"} {"fields": {"given": "මේරි සූ", "given2": "හමිෂ්", "locale": "si_AQ", "surname": "වොට්සන්"}, "file": "si", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "මේරි සූ හමිෂ්, වොට්සන්"} {"fields": {"given": "මේරි සූ", "given2": "හමිෂ්", "locale": "si_AQ", "surname": "වොට්සන්"}, "file": "si", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "වොට්සන් මේරි සූ හමිෂ්"} {"fields": {"given": "අයිරින්", "locale": "si_AQ", "surname": "ඇඩ්ලර්"}, "file": "si", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "අයිරින් ඇඩ්ලර්"} {"fields": {"given": "අයිරින්", "locale": "si_AQ", "surname": "ඇඩ්ලර්"}, "file": "si", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "අයිරින් ඇඩ්ලර්"} {"fields": {"given": "අයිරින්", "locale": "si_AQ", "surname": "ඇඩ්ලර්"}, "file": "si", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "ඇඩ්ලර් අයිරින්"} {"fields": {"credentials": "CSc.", "generation": "ml.", "given": "Anna Karolína", "given-informal": "Sofia", "given2": "Eva Kristína", "locale": "ko_AQ", "surname-core": "Kant", "surname-prefix": "von", "surname2": "Nováková Predná", "title": "Mgr. Ing."}, "file": "sk", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Mgr. Ing. Anna Karolína Eva Kristína von Kant Nováková Predná, CSc."} {"fields": {"credentials": "CSc.", "generation": "ml.", "given": "Anna Karolína", "given-informal": "Sofia", "given2": "Eva Kristína", "locale": "ko_AQ", "surname-core": "Kant", "surname-prefix": "von", "surname2": "Nováková Predná", "title": "Mgr. Ing."}, "file": "sk", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Kant, Anna Karolína Eva Kristína von (Mgr. Ing., CSc.)"} {"fields": {"credentials": "CSc.", "generation": "ml.", "given": "Anna Karolína", "given-informal": "Sofia", "given2": "Eva Kristína", "locale": "ko_AQ", "surname-core": "Kant", "surname-prefix": "von", "surname2": "Nováková Predná", "title": "Mgr. Ing."}, "file": "sk", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "Mgr. Ing. von Kant Nováková Predná Anna Karolína Eva Kristína, CSc."} {"fields": {"given": "Jack", "locale": "ko_AQ"}, "file": "sk", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Jack"} {"fields": {"given": "Jack", "locale": "ko_AQ"}, "file": "sk", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Jack"} {"fields": {"given": "Jack", "locale": "ko_AQ"}, "file": "sk", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Jack"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "sk", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zäzilia Hamish Stöber"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "sk", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Stöber, Zäzilia Hamish"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "sk", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Stöber Zäzilia Hamish"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "sk", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Käthe Müller"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "sk", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Müller, Käthe"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "sk", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Müller Käthe"} {"fields": {"credentials": "PhD", "generation": "ml.", "given": "Alexandra", "given-informal": "Saša", "given2": "Zuzana", "locale": "sk_AQ", "surname-core": "Macáková", "title": "pani"}, "file": "sk", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "pani Alexandra Zuzana Macáková PhD"} {"fields": {"credentials": "PhD", "generation": "ml.", "given": "Alexandra", "given-informal": "Saša", "given2": "Zuzana", "locale": "sk_AQ", "surname-core": "Macáková", "title": "pani"}, "file": "sk", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Macáková, Alexandra Zuzana (pani, PhD)"} {"fields": {"credentials": "PhD", "generation": "ml.", "given": "Alexandra", "given-informal": "Saša", "given2": "Zuzana", "locale": "sk_AQ", "surname-core": "Macáková", "title": "pani"}, "file": "sk", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "pani Macáková Alexandra Zuzana, PhD"} {"fields": {"given": "Jozef", "locale": "sk_AQ"}, "file": "sk", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Jozef"} {"fields": {"given": "Jozef", "locale": "sk_AQ"}, "file": "sk", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Jozef"} {"fields": {"given": "Jozef", "locale": "sk_AQ"}, "file": "sk", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Jozef"} {"fields": {"given": "Ján", "given2": "Matej", "locale": "sk_AQ", "surname": "Slovák"}, "file": "sk", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Ján Matej Slovák"} {"fields": {"given": "Ján", "given2": "Matej", "locale": "sk_AQ", "surname": "Slovák"}, "file": "sk", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Slovák, Ján Matej"} {"fields": {"given": "Ján", "given2": "Matej", "locale": "sk_AQ", "surname": "Slovák"}, "file": "sk", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Slovák Ján Matej"} {"fields": {"given": "Jana", "locale": "sk_AQ", "surname": "Kováčová"}, "file": "sk", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Jana Kováčová"} {"fields": {"given": "Jana", "locale": "sk_AQ", "surname": "Kováčová"}, "file": "sk", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Kováčová, Jana"} {"fields": {"given": "Jana", "locale": "sk_AQ", "surname": "Kováčová"}, "file": "sk", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Kováčová Jana"} {"fields": {"credentials": "dr. med., dr. dent. med.", "generation": "ml.", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "prof. dr."}, "file": "sl", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "prof. dr. Ada Cornelia César Martín von Brühl ml., dr. med., dr. dent. med."} {"fields": {"credentials": "dr. med., dr. dent. med.", "generation": "ml.", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "prof. dr."}, "file": "sl", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Brühl, Ada Cornelia César Martín von"} {"fields": {"credentials": "dr. med., dr. dent. med.", "generation": "ml.", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "prof. dr."}, "file": "sl", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "prof. dr. von Brühl Ada Cornelia César Martín, dr. med., dr. dent. med."} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "sl", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "sl", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "sl", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "sl", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zäzilia Hamish Stöber"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "sl", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Stöber, Zäzilia Hamish"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "sl", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Stöber Zäzilia Hamish"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "sl", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Käthe Müller"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "sl", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Müller, Käthe"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "sl", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Müller Käthe"} {"fields": {"credentials": "univ. dipl. inž.", "generation": "ml.", "given": "Janez", "given-informal": "Jani", "given2": "Peter", "locale": "sl_AQ", "surname-core": "Novak", "title": "g."}, "file": "sl", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "g. Janez Peter Novak ml., univ. dipl. inž."} {"fields": {"credentials": "univ. dipl. inž.", "generation": "ml.", "given": "Janez", "given-informal": "Jani", "given2": "Peter", "locale": "sl_AQ", "surname-core": "Novak", "title": "g."}, "file": "sl", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Novak, Janez Peter"} {"fields": {"credentials": "univ. dipl. inž.", "generation": "ml.", "given": "Janez", "given-informal": "Jani", "given2": "Peter", "locale": "sl_AQ", "surname-core": "Novak", "title": "g."}, "file": "sl", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "g. Novak Janez Peter, univ. dipl. inž."} {"fields": {"given": "Svarun", "locale": "sl_AQ"}, "file": "sl", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Svarun"} {"fields": {"given": "Svarun", "locale": "sl_AQ"}, "file": "sl", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Svarun"} {"fields": {"given": "Svarun", "locale": "sl_AQ"}, "file": "sl", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Svarun"} {"fields": {"given": "Ana Marija", "given2": "Vera", "locale": "sl_AQ", "surname": "Kovač"}, "file": "sl", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Ana Marija Vera Kovač"} {"fields": {"given": "Ana Marija", "given2": "Vera", "locale": "sl_AQ", "surname": "Kovač"}, "file": "sl", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Kovač, Ana Marija Vera"} {"fields": {"given": "Ana Marija", "given2": "Vera", "locale": "sl_AQ", "surname": "Kovač"}, "file": "sl", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Kovač Ana Marija Vera"} {"fields": {"given": "Irena", "locale": "sl_AQ", "surname": "Orel"}, "file": "sl", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Irena Orel"} {"fields": {"given": "Irena", "locale": "sl_AQ", "surname": "Orel"}, "file": "sl", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Orel, Irena"} {"fields": {"given": "Irena", "locale": "sl_AQ", "surname": "Orel"}, "file": "sl", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Orel Irena"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "so", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Prof. Dr. Ada Cornelia César Martín von Brühl González Domingo MD DDS"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "so", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Brühl, Ada Cornelia César Martín von"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. Dr."}, "file": "so", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "von Brühl Prof. Dr. Ada Cornelia César Martín Jr, MD DDS"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "so", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "so", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "so", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "so", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zäzilia Hamish Stöber"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "so", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Stöber, Zäzilia Hamish"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "so", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Stöber Zäzilia Hamish"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "so", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Käthe Müller"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "so", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Müller, Käthe"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "so", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Müller Käthe"} {"fields": {"credentials": "MP", "generation": "Jr", "given": "Bertram Wilberforce", "given-informal": "Bertie", "given2": "Henry Robert", "locale": "so_AQ", "surname-core": "Wooster", "title": "Mudane"}, "file": "so", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Mudane Bertram Wilberforce Henry Robert Wooster MP"} {"fields": {"credentials": "MP", "generation": "Jr", "given": "Bertram Wilberforce", "given-informal": "Bertie", "given2": "Henry Robert", "locale": "so_AQ", "surname-core": "Wooster", "title": "Mudane"}, "file": "so", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Wooster, Bertram Wilberforce Henry Robert"} {"fields": {"credentials": "MP", "generation": "Jr", "given": "Bertram Wilberforce", "given-informal": "Bertie", "given2": "Henry Robert", "locale": "so_AQ", "surname-core": "Wooster", "title": "Mudane"}, "file": "so", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Wooster Mudane Bertram Wilberforce Henry Robert Jr, MP"} {"fields": {"given": "Zendaya", "locale": "so_AQ"}, "file": "so", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Zendaya"} {"fields": {"given": "Zendaya", "locale": "so_AQ"}, "file": "so", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Zendaya"} {"fields": {"given": "Zendaya", "locale": "so_AQ"}, "file": "so", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Zendaya"} {"fields": {"given": "Mariam Geedi", "given2": "Hamish", "locale": "so_AQ", "surname": "Hussein"}, "file": "so", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Mariam Geedi Hamish Hussein"} {"fields": {"given": "Mariam Geedi", "given2": "Hamish", "locale": "so_AQ", "surname": "Hussein"}, "file": "so", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Hussein, Mariam Geedi Hamish"} {"fields": {"given": "Mariam Geedi", "given2": "Hamish", "locale": "so_AQ", "surname": "Hussein"}, "file": "so", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Hussein Mariam Geedi Hamish"} {"fields": {"given": "Amina", "locale": "so_AQ", "surname": "Ali"}, "file": "so", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Amina Ali"} {"fields": {"given": "Amina", "locale": "so_AQ", "surname": "Ali"}, "file": "so", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Ali, Amina"} {"fields": {"given": "Amina", "locale": "so_AQ", "surname": "Ali"}, "file": "so", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Ali Amina"} {"fields": {"credentials": "dr. shk. mjek.", "generation": "E reja", "given": "Gentiana", "given-informal": "Genta", "given2": "Marie", "locale": "ko_AQ", "surname-core": "Popa", "surname-prefix": "van den", "surname2": "Prodani", "title": "prof. dr."}, "file": "sq", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "prof. dr. Gentiana van den Popa, dr. shk. mjek."} {"fields": {"credentials": "dr. shk. mjek.", "generation": "E reja", "given": "Gentiana", "given-informal": "Genta", "given2": "Marie", "locale": "ko_AQ", "surname-core": "Popa", "surname-prefix": "van den", "surname2": "Prodani", "title": "prof. dr."}, "file": "sq", "label": "foreignFull", "order": "sorting", "shape": null, "text": "prof. dr. Popa, Gentiana van den"} {"fields": {"credentials": "dr. shk. mjek.", "generation": "E reja", "given": "Gentiana", "given-informal": "Genta", "given2": "Marie", "locale": "ko_AQ", "surname-core": "Popa", "surname-prefix": "van den", "surname2": "Prodani", "title": "prof. dr."}, "file": "sq", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "prof. dr. van den Popa, Gentiana M., dr. shk. mjek."} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "sq", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "sq", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "sq", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Amarildo", "given2": "Andrea", "locale": "ko_AQ", "surname": "Lombardi"}, "file": "sq", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Amarildo Lombardi"} {"fields": {"given": "Amarildo", "given2": "Andrea", "locale": "ko_AQ", "surname": "Lombardi"}, "file": "sq", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Lombardi, Amarildo"} {"fields": {"given": "Amarildo", "given2": "Andrea", "locale": "ko_AQ", "surname": "Lombardi"}, "file": "sq", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Lombardi, Amarildo A."} {"fields": {"given": "Menada", "locale": "ko_AQ", "surname": "Fonda"}, "file": "sq", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Menada Fonda"} {"fields": {"given": "Menada", "locale": "ko_AQ", "surname": "Fonda"}, "file": "sq", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Fonda, Menada"} {"fields": {"given": "Menada", "locale": "ko_AQ", "surname": "Fonda"}, "file": "sq", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Fonda, Menada"} {"fields": {"generation": "I riu", "given": "Gavril", "given-informal": "Gav", "given2": "Andrea", "locale": "sq_AQ", "surname-core": "Dara", "title": "Z."}, "file": "sq", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Z. Gavril Dara"} {"fields": {"generation": "I riu", "given": "Gavril", "given-informal": "Gav", "given2": "Andrea", "locale": "sq_AQ", "surname-core": "Dara", "title": "Z."}, "file": "sq", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Z. Dara, Gavril"} {"fields": {"generation": "I riu", "given": "Gavril", "given-informal": "Gav", "given2": "Andrea", "locale": "sq_AQ", "surname-core": "Dara", "title": "Z."}, "file": "sq", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Z. Dara, Gavril A."} {"fields": {"given": "Blerim", "locale": "sq_AQ"}, "file": "sq", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Blerim"} {"fields": {"given": "Blerim", "locale": "sq_AQ"}, "file": "sq", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Blerim"} {"fields": {"given": "Blerim", "locale": "sq_AQ"}, "file": "sq", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Blerim"} {"fields": {"given": "Ilir", "given2": "Ilia", "locale": "sq_AQ", "surname": "Rustemi"}, "file": "sq", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Ilir Rustemi"} {"fields": {"given": "Ilir", "given2": "Ilia", "locale": "sq_AQ", "surname": "Rustemi"}, "file": "sq", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Rustemi, Ilir"} {"fields": {"given": "Ilir", "given2": "Ilia", "locale": "sq_AQ", "surname": "Rustemi"}, "file": "sq", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Rustemi, Ilir I."} {"fields": {"given": "Majlinda", "locale": "sq_AQ", "surname": "Kraja"}, "file": "sq", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Majlinda Kraja"} {"fields": {"given": "Majlinda", "locale": "sq_AQ", "surname": "Kraja"}, "file": "sq", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Kraja, Majlinda"} {"fields": {"given": "Majlinda", "locale": "sq_AQ", "surname": "Kraja"}, "file": "sq", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Kraja, Majlinda"} {"fields": {"credentials": "дипл. инж.", "generation": "млађи", "given": "Александра", "given-informal": "Сања", "given2": "Ева Сара", "locale": "ko_AQ", "surname-core": "Волф", "surname-prefix": "ван ден", "surname2": "Петровић Југовић", "title": "проф. др"}, "file": "sr", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "проф. др Александра Ева Сара ван ден Волф Петровић Југовић дипл. инж."} {"fields": {"credentials": "дипл. инж.", "generation": "млађи", "given": "Александра", "given-informal": "Сања", "given2": "Ева Сара", "locale": "ko_AQ", "surname-core": "Волф", "surname-prefix": "ван ден", "surname2": "Петровић Југовић", "title": "проф. др"}, "file": "sr", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Волф, Александра Ева Сара ван ден"} {"fields": {"credentials": "дипл. инж.", "generation": "млађи", "given": "Александра", "given-informal": "Сања", "given2": "Ева Сара", "locale": "ko_AQ", "surname-core": "Волф", "surname-prefix": "ван ден", "surname2": "Петровић Југовић", "title": "проф. др"}, "file": "sr", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "ван ден Волф проф. др Александра Ева Сара млађи, дипл. инж."} {"fields": {"given": "Синбад", "locale": "ko_AQ"}, "file": "sr", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Синбад"} {"fields": {"given": "Синбад", "locale": "ko_AQ"}, "file": "sr", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Синбад"} {"fields": {"given": "Синбад", "locale": "ko_AQ"}, "file": "sr", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Синбад"} {"fields": {"given": "Цецилија", "given2": "Хемиш", "locale": "ko_AQ", "surname": "Штебер"}, "file": "sr", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Цецилија Хемиш Штебер"} {"fields": {"given": "Цецилија", "given2": "Хемиш", "locale": "ko_AQ", "surname": "Штебер"}, "file": "sr", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Штебер, Цецилија Хемиш"} {"fields": {"given": "Цецилија", "given2": "Хемиш", "locale": "ko_AQ", "surname": "Штебер"}, "file": "sr", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Штебер Цецилија Хемиш"} {"fields": {"given": "Кете", "locale": "ko_AQ", "surname": "Милер"}, "file": "sr", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Кете Милер"} {"fields": {"given": "Кете", "locale": "ko_AQ", "surname": "Милер"}, "file": "sr", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Милер, Кете"} {"fields": {"given": "Кете", "locale": "ko_AQ", "surname": "Милер"}, "file": "sr", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Милер Кете"} {"fields": {"credentials": "дипл. инж.", "generation": "млађи", "given": "Слађана", "given-informal": "Слађа", "given2": "Вера", "locale": "sr_AQ", "surname-core": "Миленковић", "title": "др"}, "file": "sr", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "др Слађана Вера Миленковић дипл. инж."} {"fields": {"credentials": "дипл. инж.", "generation": "млађи", "given": "Слађана", "given-informal": "Слађа", "given2": "Вера", "locale": "sr_AQ", "surname-core": "Миленковић", "title": "др"}, "file": "sr", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Миленковић, Слађана Вера"} {"fields": {"credentials": "дипл. инж.", "generation": "млађи", "given": "Слађана", "given-informal": "Слађа", "given2": "Вера", "locale": "sr_AQ", "surname-core": "Миленковић", "title": "др"}, "file": "sr", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Миленковић др Слађана Вера млађи, дипл. инж."} {"fields": {"given": "Иван", "locale": "sr_AQ"}, "file": "sr", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Иван"} {"fields": {"given": "Иван", "locale": "sr_AQ"}, "file": "sr", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Иван"} {"fields": {"given": "Иван", "locale": "sr_AQ"}, "file": "sr", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Иван"} {"fields": {"given": "Јован", "given2": "Драган", "locale": "sr_AQ", "surname": "Поповић"}, "file": "sr", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Јован Драган Поповић"} {"fields": {"given": "Јован", "given2": "Драган", "locale": "sr_AQ", "surname": "Поповић"}, "file": "sr", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Поповић, Јован Драган"} {"fields": {"given": "Јован", "given2": "Драган", "locale": "sr_AQ", "surname": "Поповић"}, "file": "sr", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Поповић Јован Драган"} {"fields": {"given": "Ирена", "locale": "sr_AQ", "surname": "Марковић"}, "file": "sr", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Ирена Марковић"} {"fields": {"given": "Ирена", "locale": "sr_AQ", "surname": "Марковић"}, "file": "sr", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Марковић, Ирена"} {"fields": {"given": "Ирена", "locale": "sr_AQ", "surname": "Марковић"}, "file": "sr", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Марковић Ирена"} {"fields": {"credentials": "dipl. inž.", "generation": "mlađi", "given": "Aleksandra", "given-informal": "Sanja", "given2": "Eva Sara", "locale": "ko_AQ", "surname-core": "Volf", "surname-prefix": "van den", "surname2": "Petrović Jugović", "title": "prof. dr"}, "file": "sr_Latn", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "prof. dr Aleksandra Eva Sara van den Volf Petrović Jugović dipl. inž."} {"fields": {"credentials": "dipl. inž.", "generation": "mlađi", "given": "Aleksandra", "given-informal": "Sanja", "given2": "Eva Sara", "locale": "ko_AQ", "surname-core": "Volf", "surname-prefix": "van den", "surname2": "Petrović Jugović", "title": "prof. dr"}, "file": "sr_Latn", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Volf, Aleksandra Eva Sara van den"} {"fields": {"credentials": "dipl. inž.", "generation": "mlađi", "given": "Aleksandra", "given-informal": "Sanja", "given2": "Eva Sara", "locale": "ko_AQ", "surname-core": "Volf", "surname-prefix": "van den", "surname2": "Petrović Jugović", "title": "prof. dr"}, "file": "sr_Latn", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "van den Volf prof. dr Aleksandra Eva Sara mlađi, dipl. inž."} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "sr_Latn", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "sr_Latn", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "sr_Latn", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Cecilija", "given2": "Hemiš", "locale": "ko_AQ", "surname": "Šteber"}, "file": "sr_Latn", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Cecilija Hemiš Šteber"} {"fields": {"given": "Cecilija", "given2": "Hemiš", "locale": "ko_AQ", "surname": "Šteber"}, "file": "sr_Latn", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Šteber, Cecilija Hemiš"} {"fields": {"given": "Cecilija", "given2": "Hemiš", "locale": "ko_AQ", "surname": "Šteber"}, "file": "sr_Latn", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Šteber Cecilija Hemiš"} {"fields": {"given": "Kete", "locale": "ko_AQ", "surname": "Miler"}, "file": "sr_Latn", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Kete Miler"} {"fields": {"given": "Kete", "locale": "ko_AQ", "surname": "Miler"}, "file": "sr_Latn", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Miler, Kete"} {"fields": {"given": "Kete", "locale": "ko_AQ", "surname": "Miler"}, "file": "sr_Latn", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Miler Kete"} {"fields": {"credentials": "dipl. inž.", "generation": "mlađi", "given": "Slađana", "given-informal": "Slađa", "given2": "Vera", "locale": "sr_Latn_AQ", "surname-core": "Milenković", "title": "dr"}, "file": "sr_Latn", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "dr Slađana Vera Milenković dipl. inž."} {"fields": {"credentials": "dipl. inž.", "generation": "mlađi", "given": "Slađana", "given-informal": "Slađa", "given2": "Vera", "locale": "sr_Latn_AQ", "surname-core": "Milenković", "title": "dr"}, "file": "sr_Latn", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Milenković, Slađana Vera"} {"fields": {"credentials": "dipl. inž.", "generation": "mlađi", "given": "Slađana", "given-informal": "Slađa", "given2": "Vera", "locale": "sr_Latn_AQ", "surname-core": "Milenković", "title": "dr"}, "file": "sr_Latn", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Milenković dr Slađana Vera mlađi, dipl. inž."} {"fields": {"given": "Ivan", "locale": "sr_Latn_AQ"}, "file": "sr_Latn", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Ivan"} {"fields": {"given": "Ivan", "locale": "sr_Latn_AQ"}, "file": "sr_Latn", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Ivan"} {"fields": {"given": "Ivan", "locale": "sr_Latn_AQ"}, "file": "sr_Latn", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Ivan"} {"fields": {"given": "Jovan", "given2": "Dragan", "locale": "sr_Latn_AQ", "surname": "Popović"}, "file": "sr_Latn", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Jovan Dragan Popović"} {"fields": {"given": "Jovan", "given2": "Dragan", "locale": "sr_Latn_AQ", "surname": "Popović"}, "file": "sr_Latn", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Popović, Jovan Dragan"} {"fields": {"given": "Jovan", "given2": "Dragan", "locale": "sr_Latn_AQ", "surname": "Popović"}, "file": "sr_Latn", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Popović Jovan Dragan"} {"fields": {"given": "Irena", "locale": "sr_Latn_AQ", "surname": "Marković"}, "file": "sr_Latn", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Irena Marković"} {"fields": {"given": "Irena", "locale": "sr_Latn_AQ", "surname": "Marković"}, "file": "sr_Latn", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Marković, Irena"} {"fields": {"given": "Irena", "locale": "sr_Latn_AQ", "surname": "Marković"}, "file": "sr_Latn", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Marković Irena"} {"fields": {"credentials": "med. dr. fil. dr. jur. dr.", "generation": "jr.", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. dr."}, "file": "sv", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "med. dr. fil. dr. jur. dr. Ada Cornelia César Martín von Brühl González Domingo"} {"fields": {"credentials": "med. dr. fil. dr. jur. dr.", "generation": "jr.", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. dr."}, "file": "sv", "label": "foreignFull", "order": "sorting", "shape": null, "text": "von Brühl González Domingo, Ada Cornelia César Martín"} {"fields": {"credentials": "med. dr. fil. dr. jur. dr.", "generation": "jr.", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Prof. dr."}, "file": "sv", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "von Brühl, Prof. dr. Ada Cornelia César Martín, med. dr. fil. dr. jur. dr."} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "sv", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "sv", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "sv", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "sv", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zäzilia Hamish Stöber"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "sv", "label": "foreignGGS", "order": "sorting", "shape": 4, "text": "Stöber Zäzilia Hamish"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "sv", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Stöber, Zäzilia Hamish"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "sv", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Käthe Müller"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "sv", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "Müller Käthe"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "sv", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Müller, Käthe"} {"fields": {"credentials": "Riksdagsledamot", "generation": "Jr.", "given": "Lars-Åke", "given-informal": "Lasse", "given2": "Karl", "locale": "sv_AQ", "surname-core": "Hermansson", "title": "Herr"}, "file": "sv", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Riksdagsledamot Lars-Åke Karl Hermansson"} {"fields": {"credentials": "Riksdagsledamot", "generation": "Jr.", "given": "Lars-Åke", "given-informal": "Lasse", "given2": "Karl", "locale": "sv_AQ", "surname-core": "Hermansson", "title": "Herr"}, "file": "sv", "label": "nativeFull", "order": "sorting", "shape": 4, "text": "Hermansson Lars-Åke Karl"} {"fields": {"credentials": "Riksdagsledamot", "generation": "Jr.", "given": "Lars-Åke", "given-informal": "Lasse", "given2": "Karl", "locale": "sv_AQ", "surname-core": "Hermansson", "title": "Herr"}, "file": "sv", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Hermansson, Herr Lars-Åke Karl, Riksdagsledamot"} {"fields": {"given": "Stefan", "locale": "sv_AQ"}, "file": "sv", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Stefan"} {"fields": {"given": "Stefan", "locale": "sv_AQ"}, "file": "sv", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Stefan"} {"fields": {"given": "Stefan", "locale": "sv_AQ"}, "file": "sv", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Stefan"} {"fields": {"given": "Johan", "given2": "Harald", "locale": "sv_AQ", "surname": "Wallin"}, "file": "sv", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Johan Harald Wallin"} {"fields": {"given": "Johan", "given2": "Harald", "locale": "sv_AQ", "surname": "Wallin"}, "file": "sv", "label": "nativeGGS", "order": "sorting", "shape": 4, "text": "Wallin Johan Harald"} {"fields": {"given": "Johan", "given2": "Harald", "locale": "sv_AQ", "surname": "Wallin"}, "file": "sv", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Wallin, Johan Harald"} {"fields": {"given": "Ingrid", "locale": "sv_AQ", "surname": "Andersson"}, "file": "sv", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Ingrid Andersson"} {"fields": {"given": "Ingrid", "locale": "sv_AQ", "surname": "Andersson"}, "file": "sv", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "Andersson Ingrid"} {"fields": {"given": "Ingrid", "locale": "sv_AQ", "surname": "Andersson"}, "file": "sv", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Andersson, Ingrid"} {"fields": {"credentials": "M.D. Ph.D.", "generation": "Jr", "given": "Salima", "given-informal": "Sarah", "given2": "Farida", "locale": "ko_AQ", "surname-core": "Hamisi", "surname-prefix": "mwana", "surname2": "Musa", "title": "Prof. Dk."}, "file": "sw", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Prof. Dk. Salima Farida mwana Hamisi Musa M.D. Ph.D."} {"fields": {"credentials": "M.D. Ph.D.", "generation": "Jr", "given": "Salima", "given-informal": "Sarah", "given2": "Farida", "locale": "ko_AQ", "surname-core": "Hamisi", "surname-prefix": "mwana", "surname2": "Musa", "title": "Prof. Dk."}, "file": "sw", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Hamisi, Salima Farida mwana"} {"fields": {"credentials": "M.D. Ph.D.", "generation": "Jr", "given": "Salima", "given-informal": "Sarah", "given2": "Farida", "locale": "ko_AQ", "surname-core": "Hamisi", "surname-prefix": "mwana", "surname2": "Musa", "title": "Prof. Dk."}, "file": "sw", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "mwana Hamisi Salima Farida M.D. Ph.D."} {"fields": {"given": "Kevin", "locale": "ko_AQ"}, "file": "sw", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Kevin"} {"fields": {"given": "Kevin", "locale": "ko_AQ"}, "file": "sw", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Kevin"} {"fields": {"given": "Kevin", "locale": "ko_AQ"}, "file": "sw", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Kevin"} {"fields": {"given": "Zamara", "given2": "Imani", "locale": "ko_AQ", "surname": "Bukenya"}, "file": "sw", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zamara Imani Bukenya"} {"fields": {"given": "Zamara", "given2": "Imani", "locale": "ko_AQ", "surname": "Bukenya"}, "file": "sw", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Bukenya, Zamara Imani"} {"fields": {"given": "Zamara", "given2": "Imani", "locale": "ko_AQ", "surname": "Bukenya"}, "file": "sw", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Bukenya Zamara Imani"} {"fields": {"given": "John", "locale": "ko_AQ", "surname": "Mkwawa"}, "file": "sw", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "John Mkwawa"} {"fields": {"given": "John", "locale": "ko_AQ", "surname": "Mkwawa"}, "file": "sw", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Mkwawa, John"} {"fields": {"given": "John", "locale": "ko_AQ", "surname": "Mkwawa"}, "file": "sw", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Mkwawa John"} {"fields": {"credentials": "MB", "generation": "Jr", "given": "Adam Juma", "given-informal": "Bita", "given2": "Agape Shukurani", "locale": "sw_AQ", "surname-core": "Masalu", "title": "Bw."}, "file": "sw", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Bw. Adam Juma Agape Shukurani Masalu MB"} {"fields": {"credentials": "MB", "generation": "Jr", "given": "Adam Juma", "given-informal": "Bita", "given2": "Agape Shukurani", "locale": "sw_AQ", "surname-core": "Masalu", "title": "Bw."}, "file": "sw", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Masalu, Adam Juma Agape Shukurani"} {"fields": {"credentials": "MB", "generation": "Jr", "given": "Adam Juma", "given-informal": "Bita", "given2": "Agape Shukurani", "locale": "sw_AQ", "surname-core": "Masalu", "title": "Bw."}, "file": "sw", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Masalu Adam Juma Agape Shukurani MB"} {"fields": {"given": "Hassan", "locale": "sw_AQ"}, "file": "sw", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Hassan"} {"fields": {"given": "Hassan", "locale": "sw_AQ"}, "file": "sw", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Hassan"} {"fields": {"given": "Hassan", "locale": "sw_AQ"}, "file": "sw", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Hassan"} {"fields": {"given": "Ali", "given2": "Juma", "locale": "sw_AQ", "surname": "Hamisi"}, "file": "sw", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Ali Juma Hamisi"} {"fields": {"given": "Ali", "given2": "Juma", "locale": "sw_AQ", "surname": "Hamisi"}, "file": "sw", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Hamisi, Ali Juma"} {"fields": {"given": "Ali", "given2": "Juma", "locale": "sw_AQ", "surname": "Hamisi"}, "file": "sw", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Hamisi Ali Juma"} {"fields": {"given": "Maria", "locale": "sw_AQ", "surname": "Hasani"}, "file": "sw", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Maria Hasani"} {"fields": {"given": "Maria", "locale": "sw_AQ", "surname": "Hasani"}, "file": "sw", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Hasani, Maria"} {"fields": {"given": "Maria", "locale": "sw_AQ", "surname": "Hasani"}, "file": "sw", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Hasani Maria"} {"fields": {"credentials": "பிஎச்.டி", "generation": "இளைய", "given": "அல்போன்சோ", "given-informal": "அல்போன்ஸ்", "given2": "எர்னஸ்டோ", "locale": "fr_AQ", "surname-core": "ஹெர்னாண்டஸ்", "surname-prefix": "வான்", "title": "பேராசிரியர்"}, "file": "ta", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "அல்போன்சோ எர்னஸ்டோ வான் ஹெர்னாண்டஸ் பிஎச்.டி"} {"fields": {"credentials": "பிஎச்.டி", "generation": "இளைய", "given": "அல்போன்சோ", "given-informal": "அல்போன்ஸ்", "given2": "எர்னஸ்டோ", "locale": "fr_AQ", "surname-core": "ஹெர்னாண்டஸ்", "surname-prefix": "வான்", "title": "பேராசிரியர்"}, "file": "ta", "label": "foreignFull", "order": "sorting", "shape": null, "text": "அல்போன்சோ எர்னஸ்டோ, வான் ஹெர்னாண்டஸ்"} {"fields": {"credentials": "பிஎச்.டி", "generation": "இளைய", "given": "அல்போன்சோ", "given-informal": "அல்போன்ஸ்", "given2": "எர்னஸ்டோ", "locale": "fr_AQ", "surname-core": "ஹெர்னாண்டஸ்", "surname-prefix": "வான்", "title": "பேராசிரியர்"}, "file": "ta", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "பேராசிரியர் வான் ஹெர்னாண்டஸ் அல்போன்சோ எர்னஸ்டோ பிஎச்.டி"} {"fields": {"given": "அல்போன்சோ", "locale": "fr_AQ"}, "file": "ta", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "அல்போன்சோ"} {"fields": {"given": "அல்போன்சோ", "locale": "fr_AQ"}, "file": "ta", "label": "foreignG", "order": "sorting", "shape": 4, "text": "அல்போன்சோ"} {"fields": {"given": "அல்போன்சோ", "locale": "fr_AQ"}, "file": "ta", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "அல்போன்சோ"} {"fields": {"given": "அல்போன்சோ", "given2": "எர்னஸ்டோ", "locale": "fr_AQ", "surname": "ஹெர்னாண்டஸ்"}, "file": "ta", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "அல்போன்சோ எர்னஸ்டோ ஹெர்னாண்டஸ்"} {"fields": {"given": "அல்போன்சோ", "given2": "எர்னஸ்டோ", "locale": "fr_AQ", "surname": "ஹெர்னாண்டஸ்"}, "file": "ta", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "அல்போன்சோ எர்னஸ்டோ, ஹெர்னாண்டஸ்"} {"fields": {"given": "அல்போன்சோ", "given2": "எர்னஸ்டோ", "locale": "fr_AQ", "surname": "ஹெர்னாண்டஸ்"}, "file": "ta", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "ஹெர்னாண்டஸ் அல்போன்சோ எர்னஸ்டோ"} {"fields": {"given": "அல்போன்சோ", "locale": "fr_AQ", "surname": "ஹெர்னாண்டஸ்"}, "file": "ta", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "அல்போன்சோ ஹெர்னாண்டஸ்"} {"fields": {"given": "அல்போன்சோ", "locale": "fr_AQ", "surname": "ஹெர்னாண்டஸ்"}, "file": "ta", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "அல்போன்சோ ஹெர்னாண்டஸ்"} {"fields": {"given": "அல்போன்சோ", "locale": "fr_AQ", "surname": "ஹெர்னாண்டஸ்"}, "file": "ta", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "ஹெர்னாண்டஸ் அல்போன்சோ"} {"fields": {"credentials": "எம்பி", "generation": "இளையவர்", "given": "ராஜ ராஜன்", "given-informal": "ராஜன்", "given2": "ராஜன்", "locale": "ta_AQ", "surname-core": "சோழன்", "title": "திரு."}, "file": "ta", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "ராஜ ராஜன் ராஜன் சோழன் எம்பி"} {"fields": {"credentials": "எம்பி", "generation": "இளையவர்", "given": "ராஜ ராஜன்", "given-informal": "ராஜன்", "given2": "ராஜன்", "locale": "ta_AQ", "surname-core": "சோழன்", "title": "திரு."}, "file": "ta", "label": "nativeFull", "order": "sorting", "shape": null, "text": "ராஜ ராஜன் ராஜன், சோழன்"} {"fields": {"credentials": "எம்பி", "generation": "இளையவர்", "given": "ராஜ ராஜன்", "given-informal": "ராஜன்", "given2": "ராஜன்", "locale": "ta_AQ", "surname-core": "சோழன்", "title": "திரு."}, "file": "ta", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "திரு. சோழன் ராஜ ராஜன் ராஜன் எம்பி"} {"fields": {"given": "ராஜேந்திரன்", "locale": "ta_AQ"}, "file": "ta", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "ராஜேந்திரன்"} {"fields": {"given": "ராஜேந்திரன்", "locale": "ta_AQ"}, "file": "ta", "label": "nativeG", "order": "sorting", "shape": 4, "text": "ராஜேந்திரன்"} {"fields": {"given": "ராஜேந்திரன்", "locale": "ta_AQ"}, "file": "ta", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "ராஜேந்திரன்"} {"fields": {"given": "ராஜ", "given2": "ராஜ", "locale": "ta_AQ", "surname": "சோழன்"}, "file": "ta", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "ராஜ ராஜ சோழன்"} {"fields": {"given": "ராஜ", "given2": "ராஜ", "locale": "ta_AQ", "surname": "சோழன்"}, "file": "ta", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "ராஜ ராஜ, சோழன்"} {"fields": {"given": "ராஜ", "given2": "ராஜ", "locale": "ta_AQ", "surname": "சோழன்"}, "file": "ta", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "சோழன் ராஜ ராஜ"} {"fields": {"given": "ராஜேந்திர", "locale": "ta_AQ", "surname": "சோழன்"}, "file": "ta", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "ராஜேந்திர சோழன்"} {"fields": {"given": "ராஜேந்திர", "locale": "ta_AQ", "surname": "சோழன்"}, "file": "ta", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "ராஜேந்திர சோழன்"} {"fields": {"given": "ராஜேந்திர", "locale": "ta_AQ", "surname": "சோழன்"}, "file": "ta", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "சோழன் ராஜேந்திர"} {"fields": {"credentials": "ఎం. డి. పిహెచ్. డి.", "generation": "జూ", "given": "సూర్య ప్రతాప్", "given-informal": "సంధ్య", "given2": "లాస్య ప్రియ", "locale": "fr_AQ", "surname-core": "చౌదరి", "surname-prefix": "శర్మ", "surname2": "శర్మ చౌదరి", "title": "ప్రొ. డా."}, "file": "te", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "సూర్య ప్రతాప్ లాస్య ప్రియ శర్మ చౌదరి ఎం. డి. పిహెచ్. డి."} {"fields": {"credentials": "ఎం. డి. పిహెచ్. డి.", "generation": "జూ", "given": "సూర్య ప్రతాప్", "given-informal": "సంధ్య", "given2": "లాస్య ప్రియ", "locale": "fr_AQ", "surname-core": "చౌదరి", "surname-prefix": "శర్మ", "surname2": "శర్మ చౌదరి", "title": "ప్రొ. డా."}, "file": "te", "label": "foreignFull", "order": "sorting", "shape": null, "text": "చౌదరి, సూర్య ప్రతాప్ లాస్య ప్రియ శర్మ"} {"fields": {"credentials": "ఎం. డి. పిహెచ్. డి.", "generation": "జూ", "given": "సూర్య ప్రతాప్", "given-informal": "సంధ్య", "given2": "లాస్య ప్రియ", "locale": "fr_AQ", "surname-core": "చౌదరి", "surname-prefix": "శర్మ", "surname2": "శర్మ చౌదరి", "title": "ప్రొ. డా."}, "file": "te", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "శర్మ చౌదరి సూర్య ప్రతాప్ లాస్య ప్రియ ఎం. డి. పిహెచ్. డి."} {"fields": {"given": "రవి", "locale": "fr_AQ"}, "file": "te", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "రవి"} {"fields": {"given": "రవి", "locale": "fr_AQ"}, "file": "te", "label": "foreignG", "order": "sorting", "shape": 4, "text": "రవి"} {"fields": {"given": "రవి", "locale": "fr_AQ"}, "file": "te", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "రవి"} {"fields": {"given": "శంకర్", "given2": "నరేష్", "locale": "fr_AQ", "surname": "ప్రసాద్"}, "file": "te", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "శంకర్ నరేష్ ప్రసాద్"} {"fields": {"given": "శంకర్", "given2": "నరేష్", "locale": "fr_AQ", "surname": "ప్రసాద్"}, "file": "te", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "ప్రసాద్, శంకర్ నరేష్"} {"fields": {"given": "శంకర్", "given2": "నరేష్", "locale": "fr_AQ", "surname": "ప్రసాద్"}, "file": "te", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "ప్రసాద్ శంకర్ నరేష్"} {"fields": {"given": "రఘు", "locale": "fr_AQ", "surname": "భరత్"}, "file": "te", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "రఘు భరత్"} {"fields": {"given": "రఘు", "locale": "fr_AQ", "surname": "భరత్"}, "file": "te", "label": "foreignGS", "order": "sorting", "shape": null, "text": "భరత్, రఘు"} {"fields": {"given": "రఘు", "locale": "fr_AQ", "surname": "భరత్"}, "file": "te", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "భరత్ రఘు"} {"fields": {"credentials": "ఎం. పి.", "generation": "జూనియర్", "given": "చంద్ర శేఖర్", "given-informal": "చందు", "given2": "అబ్దుల్ ఆజాద్", "locale": "te_AQ", "surname-core": "ఏలేటి కుమార్", "title": "మిస్టర్"}, "file": "te", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "చంద్ర శేఖర్ అబ్దుల్ ఆజాద్ ఏలేటి కుమార్ ఎం. పి."} {"fields": {"credentials": "ఎం. పి.", "generation": "జూనియర్", "given": "చంద్ర శేఖర్", "given-informal": "చందు", "given2": "అబ్దుల్ ఆజాద్", "locale": "te_AQ", "surname-core": "ఏలేటి కుమార్", "title": "మిస్టర్"}, "file": "te", "label": "nativeFull", "order": "sorting", "shape": null, "text": "ఏలేటి కుమార్, చంద్ర శేఖర్ అబ్దుల్ ఆజాద్"} {"fields": {"credentials": "ఎం. పి.", "generation": "జూనియర్", "given": "చంద్ర శేఖర్", "given-informal": "చందు", "given2": "అబ్దుల్ ఆజాద్", "locale": "te_AQ", "surname-core": "ఏలేటి కుమార్", "title": "మిస్టర్"}, "file": "te", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "ఏలేటి కుమార్ చంద్ర శేఖర్ అబ్దుల్ ఆజాద్ ఎం. పి."} {"fields": {"given": "రాజశేఖర్", "locale": "te_AQ"}, "file": "te", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "రాజశేఖర్"} {"fields": {"given": "రాజశేఖర్", "locale": "te_AQ"}, "file": "te", "label": "nativeG", "order": "sorting", "shape": 4, "text": "రాజశేఖర్"} {"fields": {"given": "రాజశేఖర్", "locale": "te_AQ"}, "file": "te", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "రాజశేఖర్"} {"fields": {"given": "సూర్య", "given2": "ప్రతాప్", "locale": "te_AQ", "surname": "వర్మ"}, "file": "te", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "సూర్య ప్రతాప్ వర్మ"} {"fields": {"given": "సూర్య", "given2": "ప్రతాప్", "locale": "te_AQ", "surname": "వర్మ"}, "file": "te", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "వర్మ, సూర్య ప్రతాప్"} {"fields": {"given": "సూర్య", "given2": "ప్రతాప్", "locale": "te_AQ", "surname": "వర్మ"}, "file": "te", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "వర్మ సూర్య ప్రతాప్"} {"fields": {"given": "రాజశేఖర్", "locale": "te_AQ", "surname": "రెడ్డి"}, "file": "te", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "రాజశేఖర్ రెడ్డి"} {"fields": {"given": "రాజశేఖర్", "locale": "te_AQ", "surname": "రెడ్డి"}, "file": "te", "label": "nativeGS", "order": "sorting", "shape": null, "text": "రెడ్డి, రాజశేఖర్"} {"fields": {"given": "రాజశేఖర్", "locale": "te_AQ", "surname": "రెడ్డి"}, "file": "te", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "రెడ్డి రాజశేఖర్"} {"fields": {"given": "Ада Корнелиа", "given-informal": "Нили", "given2": "Сезар Мартин", "locale": "ko_AQ", "surname-core": "Брухи", "surname-prefix": "вон", "surname2": "Гонзалес Доминго", "title": "Проф. Док."}, "file": "tg", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Проф. Док. Ада Корнелиа Сезар Мартин вон Брухи Гонзалес Доминго"} {"fields": {"given": "Ада Корнелиа", "given-informal": "Нили", "given2": "Сезар Мартин", "locale": "ko_AQ", "surname-core": "Брухи", "surname-prefix": "вон", "surname2": "Гонзалес Доминго", "title": "Проф. Док."}, "file": "tg", "label": "foreignFull", "order": "sorting", "shape": null, "text": "вон Брухи Гонзалес Доминго, Проф. Док. Ада Корнелиа Сезар Мартин"} {"fields": {"given": "Ада Корнелиа", "given-informal": "Нили", "given2": "Сезар Мартин", "locale": "ko_AQ", "surname-core": "Брухи", "surname-prefix": "вон", "surname2": "Гонзалес Доминго", "title": "Проф. Док."}, "file": "tg", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "вон Брухи Гонзалес Доминго Проф. Док. Ада Корнелиа Сезар Мартин"} {"fields": {"given": "Синбад", "locale": "ko_AQ"}, "file": "tg", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Синбад"} {"fields": {"given": "Синбад", "locale": "ko_AQ"}, "file": "tg", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Синбад"} {"fields": {"given": "Синбад", "locale": "ko_AQ"}, "file": "tg", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Синбад"} {"fields": {"given": "Зазилия", "given2": "Ҳамиш", "locale": "ko_AQ", "surname": "Стобер"}, "file": "tg", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Зазилия Ҳамиш Стобер"} {"fields": {"given": "Зазилия", "given2": "Ҳамиш", "locale": "ko_AQ", "surname": "Стобер"}, "file": "tg", "label": "foreignGGS", "order": "sorting", "shape": 4, "text": "Стобер Зазилия Ҳамиш"} {"fields": {"given": "Зазилия", "given2": "Ҳамиш", "locale": "ko_AQ", "surname": "Стобер"}, "file": "tg", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Стобер Зазилия Ҳамиш"} {"fields": {"given": "Кэти", "locale": "ko_AQ", "surname": "Мюлер"}, "file": "tg", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Кэти Мюлер"} {"fields": {"given": "Кэти", "locale": "ko_AQ", "surname": "Мюлер"}, "file": "tg", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "Мюлер Кэти"} {"fields": {"given": "Кэти", "locale": "ko_AQ", "surname": "Мюлер"}, "file": "tg", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Мюлер Кэти"} {"fields": {"given": "Бертрам Вилберфорс", "given-informal": "Берти", "given2": "Ҳенру Роберт", "locale": "tg_AQ", "surname-core": "Вустер"}, "file": "tg", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Бертрам Вилберфорс Ҳенру Роберт Вустер"} {"fields": {"given": "Бертрам Вилберфорс", "given-informal": "Берти", "given2": "Ҳенру Роберт", "locale": "tg_AQ", "surname-core": "Вустер"}, "file": "tg", "label": "nativeFull", "order": "sorting", "shape": 4, "text": "Вустер Бертрам Вилберфорс Ҳенру Роберт"} {"fields": {"given": "Бертрам Вилберфорс", "given-informal": "Берти", "given2": "Ҳенру Роберт", "locale": "tg_AQ", "surname-core": "Вустер"}, "file": "tg", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Вустер Бертрам Вилберфорс Ҳенру Роберт"} {"fields": {"given": "Зендая", "locale": "tg_AQ"}, "file": "tg", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Зендая"} {"fields": {"given": "Зендая", "locale": "tg_AQ"}, "file": "tg", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Зендая"} {"fields": {"given": "Зендая", "locale": "tg_AQ"}, "file": "tg", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Зендая"} {"fields": {"given": "Мэри Сю", "given2": "Хамиш", "locale": "tg_AQ", "surname": "Ватсон"}, "file": "tg", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Мэри Сю Хамиш Ватсон"} {"fields": {"given": "Мэри Сю", "given2": "Хамиш", "locale": "tg_AQ", "surname": "Ватсон"}, "file": "tg", "label": "nativeGGS", "order": "sorting", "shape": 4, "text": "Ватсон Мэри Сю Хамиш"} {"fields": {"given": "Мэри Сю", "given2": "Хамиш", "locale": "tg_AQ", "surname": "Ватсон"}, "file": "tg", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Ватсон Мэри Сю Хамиш"} {"fields": {"given": "Ирене", "locale": "tg_AQ", "surname": "Адлер"}, "file": "tg", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Ирене Адлер"} {"fields": {"given": "Ирене", "locale": "tg_AQ", "surname": "Адлер"}, "file": "tg", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "Адлер Ирене"} {"fields": {"given": "Ирене", "locale": "tg_AQ", "surname": "Адлер"}, "file": "tg", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Адлер Ирене"} {"fields": {"credentials": "พ.บ. ท.บ.", "generation": "จูเนียร์", "given": "เอดา คอร์เนเลีย", "given-informal": "นีเลอะ", "given2": "เซซาร์ มาร์ติน", "locale": "ja_AQ", "surname-core": "บรืล", "surname-prefix": "วอน", "surname2": "กอนซาเลซ โดมิงโก", "title": "ศ.ดร."}, "file": "th", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "ศ.ดร. เอดา คอร์เนเลีย เซซาร์ มาร์ติน วอน บรืล พ.บ. ท.บ."} {"fields": {"credentials": "พ.บ. ท.บ.", "generation": "จูเนียร์", "given": "เอดา คอร์เนเลีย", "given-informal": "นีเลอะ", "given2": "เซซาร์ มาร์ติน", "locale": "ja_AQ", "surname-core": "บรืล", "surname-prefix": "วอน", "surname2": "กอนซาเลซ โดมิงโก", "title": "ศ.ดร."}, "file": "th", "label": "foreignFull", "order": "sorting", "shape": null, "text": "บรืล, เอดา คอร์เนเลีย เซซาร์ มาร์ติน วอน"} {"fields": {"credentials": "พ.บ. ท.บ.", "generation": "จูเนียร์", "given": "เอดา คอร์เนเลีย", "given-informal": "นีเลอะ", "given2": "เซซาร์ มาร์ติน", "locale": "ja_AQ", "surname-core": "บรืล", "surname-prefix": "วอน", "surname2": "กอนซาเลซ โดมิงโก", "title": "ศ.ดร."}, "file": "th", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "วอน บรืล กอนซาเลซ โดมิงโก ศ.ดร. เอดา คอร์เนเลีย เซซาร์ มาร์ติน พ.บ. ท.บ."} {"fields": {"given": "ซินแบด", "locale": "ja_AQ"}, "file": "th", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "ซินแบด"} {"fields": {"given": "ซินแบด", "locale": "ja_AQ"}, "file": "th", "label": "foreignG", "order": "sorting", "shape": 4, "text": "ซินแบด"} {"fields": {"given": "ซินแบด", "locale": "ja_AQ"}, "file": "th", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "ซินแบด"} {"fields": {"given": "ซาซิเลีย", "given2": "ฮามิช", "locale": "ja_AQ", "surname": "สโตเบอร์"}, "file": "th", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "ซาซิเลีย ฮามิช สโตเบอร์"} {"fields": {"given": "ซาซิเลีย", "given2": "ฮามิช", "locale": "ja_AQ", "surname": "สโตเบอร์"}, "file": "th", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "สโตเบอร์, ซาซิเลีย ฮามิช"} {"fields": {"given": "ซาซิเลีย", "given2": "ฮามิช", "locale": "ja_AQ", "surname": "สโตเบอร์"}, "file": "th", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "สโตเบอร์ ซาซิเลีย ฮามิช"} {"fields": {"given": "เคเทอ", "locale": "ja_AQ", "surname": "มึลเลอร์"}, "file": "th", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "เคเทอ มึลเลอร์"} {"fields": {"given": "เคเทอ", "locale": "ja_AQ", "surname": "มึลเลอร์"}, "file": "th", "label": "foreignGS", "order": "sorting", "shape": null, "text": "มึลเลอร์, เคเทอ"} {"fields": {"given": "เคเทอ", "locale": "ja_AQ", "surname": "มึลเลอร์"}, "file": "th", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "มึลเลอร์ เคเทอ"} {"fields": {"given": "ปรีชา", "given-informal": "ชาติ", "given2": "กล้าหาญ", "locale": "th_AQ", "surname-core": "แสงระวี", "title": "คุณ"}, "file": "th", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "คุณ ปรีชา กล้าหาญ แสงระวี"} {"fields": {"given": "ปรีชา", "given-informal": "ชาติ", "given2": "กล้าหาญ", "locale": "th_AQ", "surname-core": "แสงระวี", "title": "คุณ"}, "file": "th", "label": "nativeFull", "order": "sorting", "shape": null, "text": "แสงระวี, ปรีชา กล้าหาญ"} {"fields": {"given": "ปรีชา", "given-informal": "ชาติ", "given2": "กล้าหาญ", "locale": "th_AQ", "surname-core": "แสงระวี", "title": "คุณ"}, "file": "th", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "แสงระวี คุณ ปรีชา กล้าหาญ"} {"fields": {"given": "ธนา", "locale": "th_AQ"}, "file": "th", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "ธนา"} {"fields": {"given": "ธนา", "locale": "th_AQ"}, "file": "th", "label": "nativeG", "order": "sorting", "shape": 4, "text": "ธนา"} {"fields": {"given": "ธนา", "locale": "th_AQ"}, "file": "th", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "ธนา"} {"fields": {"given": "มานี", "given2": "ชัยยศ", "locale": "th_AQ", "surname": "พิชิตชัย"}, "file": "th", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "มานี ชัยยศ พิชิตชัย"} {"fields": {"given": "มานี", "given2": "ชัยยศ", "locale": "th_AQ", "surname": "พิชิตชัย"}, "file": "th", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "พิชิตชัย, มานี ชัยยศ"} {"fields": {"given": "มานี", "given2": "ชัยยศ", "locale": "th_AQ", "surname": "พิชิตชัย"}, "file": "th", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "พิชิตชัย มานี ชัยยศ"} {"fields": {"given": "ไอริณ", "locale": "th_AQ", "surname": "กล้าหาญ"}, "file": "th", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "ไอริณ กล้าหาญ"} {"fields": {"given": "ไอริณ", "locale": "th_AQ", "surname": "กล้าหาญ"}, "file": "th", "label": "nativeGS", "order": "sorting", "shape": null, "text": "กล้าหาญ, ไอริณ"} {"fields": {"given": "ไอริณ", "locale": "th_AQ", "surname": "กล้าหาญ"}, "file": "th", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "กล้าหาญ ไอริณ"} {"fields": {"credentials": "ሓኪም መጥባሕቲ ስኒ", "generation": "ማንጁስ", "given": "ኣዳ ኮርኒሊያ", "given-informal": "ኒሊ", "given2": "ሲዘር ማርቲን", "locale": "ja_AQ", "surname-core": "ብሩሂ", "surname-prefix": "ቮን", "surname2": "ጎንዛሌዝ ዶሚንጐ", "title": "ፕሮ. ዶር."}, "file": "ti", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "ፕሮ. ዶር. ኣዳ ኮርኒሊያ ሲዘር ማርቲን ቮን ብሩሂ ማንጁስ፣ ሓኪም መጥባሕቲ ስኒ"} {"fields": {"credentials": "ሓኪም መጥባሕቲ ስኒ", "generation": "ማንጁስ", "given": "ኣዳ ኮርኒሊያ", "given-informal": "ኒሊ", "given2": "ሲዘር ማርቲን", "locale": "ja_AQ", "surname-core": "ብሩሂ", "surname-prefix": "ቮን", "surname2": "ጎንዛሌዝ ዶሚንጐ", "title": "ፕሮ. ዶር."}, "file": "ti", "label": "foreignFull", "order": "sorting", "shape": 4, "text": "ብሩሂ፣ ኣዳ ኮርኒሊያ ሲዘር ማርቲን ቮን"} {"fields": {"credentials": "ሓኪም መጥባሕቲ ስኒ", "generation": "ማንጁስ", "given": "ኣዳ ኮርኒሊያ", "given-informal": "ኒሊ", "given2": "ሲዘር ማርቲን", "locale": "ja_AQ", "surname-core": "ብሩሂ", "surname-prefix": "ቮን", "surname2": "ጎንዛሌዝ ዶሚንጐ", "title": "ፕሮ. ዶር."}, "file": "ti", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "ቮን ብሩሂ ፕሮ. ዶር. ኣዳ ኮርኒሊያ ሲዘር ማርቲን ማንጁስ፣ ሓኪም መጥባሕቲ ስኒ"} {"fields": {"given": "ሲንባድ", "locale": "ja_AQ"}, "file": "ti", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "ሲንባድ"} {"fields": {"given": "ሲንባድ", "locale": "ja_AQ"}, "file": "ti", "label": "foreignG", "order": "sorting", "shape": 4, "text": "ሲንባድ"} {"fields": {"given": "ሲንባድ", "locale": "ja_AQ"}, "file": "ti", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "ሲንባድ"} {"fields": {"given": "ዛዚሊያ", "given2": "ሃሚሽ", "locale": "ja_AQ", "surname": "ስቶበር"}, "file": "ti", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "ዛዚሊያ ሃሚሽ ስቶበር"} {"fields": {"given": "ዛዚሊያ", "given2": "ሃሚሽ", "locale": "ja_AQ", "surname": "ስቶበር"}, "file": "ti", "label": "foreignGGS", "order": "sorting", "shape": 4, "text": "ስቶበር፣ ዛዚሊያ ሃሚሽ"} {"fields": {"given": "ዛዚሊያ", "given2": "ሃሚሽ", "locale": "ja_AQ", "surname": "ስቶበር"}, "file": "ti", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "ስቶበር ዛዚሊያ ሃሚሽ"} {"fields": {"given": "ካቲ", "locale": "ja_AQ", "surname": "ሙለር"}, "file": "ti", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "ካቲ ሙለር"} {"fields": {"given": "ካቲ", "locale": "ja_AQ", "surname": "ሙለር"}, "file": "ti", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "ሙለር፣ ካቲ"} {"fields": {"given": "ካቲ", "locale": "ja_AQ", "surname": "ሙለር"}, "file": "ti", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "ሙለር ካቲ"} {"fields": {"credentials": "ኣባል ባይቶ", "generation": "ማንጁስ", "given": "በርትረም ዊልበርፎርስ", "given-informal": "በርቲ", "given2": "ሄንሪ ሮበርት", "locale": "ti_AQ", "surname-core": "ዉስተር", "title": "ኣይተ"}, "file": "ti", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "ኣይተ በርትረም ዊልበርፎርስ ሄንሪ ሮበርት ዉስተር ማንጁስ፣ ኣባል ባይቶ"} {"fields": {"credentials": "ኣባል ባይቶ", "generation": "ማንጁስ", "given": "በርትረም ዊልበርፎርስ", "given-informal": "በርቲ", "given2": "ሄንሪ ሮበርት", "locale": "ti_AQ", "surname-core": "ዉስተር", "title": "ኣይተ"}, "file": "ti", "label": "nativeFull", "order": "sorting", "shape": 4, "text": "ዉስተር፣ በርትረም ዊልበርፎርስ ሄንሪ ሮበርት"} {"fields": {"credentials": "ኣባል ባይቶ", "generation": "ማንጁስ", "given": "በርትረም ዊልበርፎርስ", "given-informal": "በርቲ", "given2": "ሄንሪ ሮበርት", "locale": "ti_AQ", "surname-core": "ዉስተር", "title": "ኣይተ"}, "file": "ti", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "ዉስተር ኣይተ በርትረም ዊልበርፎርስ ሄንሪ ሮበርት ማንጁስ፣ ኣባል ባይቶ"} {"fields": {"given": "ዘንዳያ", "locale": "ti_AQ"}, "file": "ti", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "ዘንዳያ"} {"fields": {"given": "ዘንዳያ", "locale": "ti_AQ"}, "file": "ti", "label": "nativeG", "order": "sorting", "shape": 4, "text": "ዘንዳያ"} {"fields": {"given": "ዘንዳያ", "locale": "ti_AQ"}, "file": "ti", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "ዘንዳያ"} {"fields": {"given": "ሜሪ ሱ", "given2": "ሃሚሽ", "locale": "ti_AQ", "surname": "ዋትሰን"}, "file": "ti", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "ሜሪ ሱ ሃሚሽ ዋትሰን"} {"fields": {"given": "ሜሪ ሱ", "given2": "ሃሚሽ", "locale": "ti_AQ", "surname": "ዋትሰን"}, "file": "ti", "label": "nativeGGS", "order": "sorting", "shape": 4, "text": "ዋትሰን፣ ሜሪ ሱ ሃሚሽ"} {"fields": {"given": "ሜሪ ሱ", "given2": "ሃሚሽ", "locale": "ti_AQ", "surname": "ዋትሰን"}, "file": "ti", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "ዋትሰን ሜሪ ሱ ሃሚሽ"} {"fields": {"given": "ኣይሪን", "locale": "ti_AQ", "surname": "ኣድለር"}, "file": "ti", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "ኣይሪን ኣድለር"} {"fields": {"given": "ኣይሪን", "locale": "ti_AQ", "surname": "ኣድለር"}, "file": "ti", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "ኣድለር፣ ኣይሪን"} {"fields": {"given": "ኣይሪን", "locale": "ti_AQ", "surname": "ኣድለር"}, "file": "ti", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "ኣድለር ኣይሪን"} {"fields": {"credentials": "hirurg-stomatolog lukman", "generation": "kiçi", "given": "Ada Kornelia", "given-informal": "Nil", "given2": "Sezar Martin", "locale": "fr_AQ", "surname-core": "Brül", "surname-prefix": "fon", "surname2": "Gonzales Domingo", "title": "ylymlaryň doktory, professor"}, "file": "tk", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "ylymlaryň doktory, professor Ada Kornelia Sezar Martin fon Brül Gonzales Domingo, hirurg-stomatolog lukman"} {"fields": {"credentials": "hirurg-stomatolog lukman", "generation": "kiçi", "given": "Ada Kornelia", "given-informal": "Nil", "given2": "Sezar Martin", "locale": "fr_AQ", "surname-core": "Brül", "surname-prefix": "fon", "surname2": "Gonzales Domingo", "title": "ylymlaryň doktory, professor"}, "file": "tk", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Brül, Ada Kornelia Sezar Martin fon"} {"fields": {"credentials": "hirurg-stomatolog lukman", "generation": "kiçi", "given": "Ada Kornelia", "given-informal": "Nil", "given2": "Sezar Martin", "locale": "fr_AQ", "surname-core": "Brül", "surname-prefix": "fon", "surname2": "Gonzales Domingo", "title": "ylymlaryň doktory, professor"}, "file": "tk", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "fon Brül Ada Kornelia Sezar Martin, hirurg-stomatolog lukman"} {"fields": {"given": "Sinbad", "locale": "fr_AQ"}, "file": "tk", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "fr_AQ"}, "file": "tk", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "fr_AQ"}, "file": "tk", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Zezilia", "given2": "Hemiş", "locale": "fr_AQ", "surname": "Stöber"}, "file": "tk", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zezilia Hemiş Stöber"} {"fields": {"given": "Zezilia", "given2": "Hemiş", "locale": "fr_AQ", "surname": "Stöber"}, "file": "tk", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Stöber, Zezilia Hemiş"} {"fields": {"given": "Zezilia", "given2": "Hemiş", "locale": "fr_AQ", "surname": "Stöber"}, "file": "tk", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Stöber Zezilia Hemiş"} {"fields": {"given": "Kete", "locale": "fr_AQ", "surname": "Müller"}, "file": "tk", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Kete Müller"} {"fields": {"given": "Kete", "locale": "fr_AQ", "surname": "Müller"}, "file": "tk", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Müller, Kete"} {"fields": {"given": "Kete", "locale": "fr_AQ", "surname": "Müller"}, "file": "tk", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Müller Kete"} {"fields": {"credentials": "magistr, ylymlaryň kandidaty", "given": "Atajan", "given-informal": "Ataş", "given2": "Saparowiç", "locale": "tk_AQ", "surname-core": "Gurdow", "title": "jenap"}, "file": "tk", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "jenap Atajan Saparowiç Gurdow magistr, ylymlaryň kandidaty"} {"fields": {"credentials": "magistr, ylymlaryň kandidaty", "given": "Atajan", "given-informal": "Ataş", "given2": "Saparowiç", "locale": "tk_AQ", "surname-core": "Gurdow", "title": "jenap"}, "file": "tk", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Gurdow, Atajan Saparowiç"} {"fields": {"credentials": "magistr, ylymlaryň kandidaty", "given": "Atajan", "given-informal": "Ataş", "given2": "Saparowiç", "locale": "tk_AQ", "surname-core": "Gurdow", "title": "jenap"}, "file": "tk", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Gurdow Atajan Saparowiç, magistr, ylymlaryň kandidaty"} {"fields": {"given": "Gurban", "locale": "tk_AQ"}, "file": "tk", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Gurban"} {"fields": {"given": "Gurban", "locale": "tk_AQ"}, "file": "tk", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Gurban"} {"fields": {"given": "Gurban", "locale": "tk_AQ"}, "file": "tk", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Gurban"} {"fields": {"given": "Meretgeldi", "given2": "Durdyýewiç", "locale": "tk_AQ", "surname": "Gurbanow"}, "file": "tk", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Meretgeldi Durdyýewiç Gurbanow"} {"fields": {"given": "Meretgeldi", "given2": "Durdyýewiç", "locale": "tk_AQ", "surname": "Gurbanow"}, "file": "tk", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Gurbanow, Meretgeldi Durdyýewiç"} {"fields": {"given": "Meretgeldi", "given2": "Durdyýewiç", "locale": "tk_AQ", "surname": "Gurbanow"}, "file": "tk", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Gurbanow Meretgeldi Durdyýewiç"} {"fields": {"given": "Amangeldi", "locale": "tk_AQ", "surname": "Saparow"}, "file": "tk", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Amangeldi Saparow"} {"fields": {"given": "Amangeldi", "locale": "tk_AQ", "surname": "Saparow"}, "file": "tk", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Saparow, Amangeldi"} {"fields": {"given": "Amangeldi", "locale": "tk_AQ", "surname": "Saparow"}, "file": "tk", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Saparow Amangeldi"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "Plácido Domingo", "title": "Prof. Dr."}, "file": "tr", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Prof. Dr. MD DDS Ada Cornelia César Martín von Brühl Jr"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "Plácido Domingo", "title": "Prof. Dr."}, "file": "tr", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Brühl, Ada Cornelia César Martín von"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "Plácido Domingo", "title": "Prof. Dr."}, "file": "tr", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "Prof. Dr. von Brühl Plácido Domingo Ada Cornelia César Martín, MD DDS"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "tr", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "tr", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "tr", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Wolfgang", "given2": "Amadeus", "locale": "ko_AQ", "surname": "Mozart"}, "file": "tr", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Wolfgang Amadeus Mozart"} {"fields": {"given": "Wolfgang", "given2": "Amadeus", "locale": "ko_AQ", "surname": "Mozart"}, "file": "tr", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Mozart, Wolfgang Amadeus"} {"fields": {"given": "Wolfgang", "given2": "Amadeus", "locale": "ko_AQ", "surname": "Mozart"}, "file": "tr", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Mozart Wolfgang Amadeus"} {"fields": {"given": "Yoshi", "locale": "ko_AQ", "surname": "Emomoto"}, "file": "tr", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Yoshi Emomoto"} {"fields": {"given": "Yoshi", "locale": "ko_AQ", "surname": "Emomoto"}, "file": "tr", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Emomoto, Yoshi"} {"fields": {"given": "Yoshi", "locale": "ko_AQ", "surname": "Emomoto"}, "file": "tr", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Emomoto Yoshi"} {"fields": {"credentials": "Dr.", "given": "Fatma", "given-informal": "Fatoş", "given2": "Su", "locale": "tr_AQ", "surname-core": "Yıldırım", "surname2": "Kaya", "title": "Sn."}, "file": "tr", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Sn. Dr. Fatma Su Yıldırım"} {"fields": {"credentials": "Dr.", "given": "Fatma", "given-informal": "Fatoş", "given2": "Su", "locale": "tr_AQ", "surname-core": "Yıldırım", "surname2": "Kaya", "title": "Sn."}, "file": "tr", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Yıldırım, Fatma Su"} {"fields": {"credentials": "Dr.", "given": "Fatma", "given-informal": "Fatoş", "given2": "Su", "locale": "tr_AQ", "surname-core": "Yıldırım", "surname2": "Kaya", "title": "Sn."}, "file": "tr", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Sn. Yıldırım Kaya Fatma Su, Dr."} {"fields": {"given": "Zeynep", "locale": "tr_AQ"}, "file": "tr", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Zeynep"} {"fields": {"given": "Zeynep", "locale": "tr_AQ"}, "file": "tr", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Zeynep"} {"fields": {"given": "Zeynep", "locale": "tr_AQ"}, "file": "tr", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Zeynep"} {"fields": {"given": "Yağız", "given2": "Can", "locale": "tr_AQ", "surname": "Kaya"}, "file": "tr", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Yağız Can Kaya"} {"fields": {"given": "Yağız", "given2": "Can", "locale": "tr_AQ", "surname": "Kaya"}, "file": "tr", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Kaya, Yağız Can"} {"fields": {"given": "Yağız", "given2": "Can", "locale": "tr_AQ", "surname": "Kaya"}, "file": "tr", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Kaya Yağız Can"} {"fields": {"given": "Irmak", "locale": "tr_AQ", "surname": "Yılmaz"}, "file": "tr", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Irmak Yılmaz"} {"fields": {"given": "Irmak", "locale": "tr_AQ", "surname": "Yılmaz"}, "file": "tr", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Yılmaz, Irmak"} {"fields": {"given": "Irmak", "locale": "tr_AQ", "surname": "Yılmaz"}, "file": "tr", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Yılmaz Irmak"} {"fields": {"credentials": "д-р", "generation": "мол.", "given": "Анастасія", "given-informal": "Настя", "given2": "Єва Софія", "locale": "ja_AQ", "surname-core": "Вольф", "surname-prefix": "фон", "surname2": "Гонзалес Домінго", "title": "проф."}, "file": "uk", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "проф. Анастасія Єва Софія фон Вольф Гонзалес Домінго, д-р"} {"fields": {"credentials": "д-р", "generation": "мол.", "given": "Анастасія", "given-informal": "Настя", "given2": "Єва Софія", "locale": "ja_AQ", "surname-core": "Вольф", "surname-prefix": "фон", "surname2": "Гонзалес Домінго", "title": "проф."}, "file": "uk", "label": "foreignFull", "order": "sorting", "shape": null, "text": "фон Вольф Гонзалес Домінго Анастасія Єва Софія, д-р, проф."} {"fields": {"credentials": "д-р", "generation": "мол.", "given": "Анастасія", "given-informal": "Настя", "given2": "Єва Софія", "locale": "ja_AQ", "surname-core": "Вольф", "surname-prefix": "фон", "surname2": "Гонзалес Домінго", "title": "проф."}, "file": "uk", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "проф. фон Вольф Гонзалес Домінго Анастасія Єва Софія д-р"} {"fields": {"given": "Синдбад", "locale": "ja_AQ"}, "file": "uk", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Синдбад"} {"fields": {"given": "Синдбад", "locale": "ja_AQ"}, "file": "uk", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Синдбад"} {"fields": {"given": "Синдбад", "locale": "ja_AQ"}, "file": "uk", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Синдбад"} {"fields": {"given": "Зазилія", "given2": "Хеміш", "locale": "ja_AQ", "surname": "Стобер"}, "file": "uk", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Зазилія Хеміш Стобер"} {"fields": {"given": "Зазилія", "given2": "Хеміш", "locale": "ja_AQ", "surname": "Стобер"}, "file": "uk", "label": "foreignGGS", "order": "sorting", "shape": 4, "text": "Стобер Зазилія Хеміш"} {"fields": {"given": "Зазилія", "given2": "Хеміш", "locale": "ja_AQ", "surname": "Стобер"}, "file": "uk", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Стобер Зазилія Хеміш"} {"fields": {"given": "Кейт", "locale": "ja_AQ", "surname": "Мюллер"}, "file": "uk", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Кейт Мюллер"} {"fields": {"given": "Кейт", "locale": "ja_AQ", "surname": "Мюллер"}, "file": "uk", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "Мюллер Кейт"} {"fields": {"given": "Кейт", "locale": "ja_AQ", "surname": "Мюллер"}, "file": "uk", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Мюллер Кейт"} {"fields": {"credentials": "доц.", "generation": "мол.", "given": "Анна Марія", "given-informal": "Маруся", "given2": "Богдан-Зиновій", "locale": "uk_AQ", "surname-core": "Петренко", "title": "проф."}, "file": "uk", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "проф. Анна Марія Богдан-Зиновій Петренко доц."} {"fields": {"credentials": "доц.", "generation": "мол.", "given": "Анна Марія", "given-informal": "Маруся", "given2": "Богдан-Зиновій", "locale": "uk_AQ", "surname-core": "Петренко", "title": "проф."}, "file": "uk", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Петренко Анна Марія Богдан-Зиновій, доц., проф."} {"fields": {"credentials": "доц.", "generation": "мол.", "given": "Анна Марія", "given-informal": "Маруся", "given2": "Богдан-Зиновій", "locale": "uk_AQ", "surname-core": "Петренко", "title": "проф."}, "file": "uk", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "проф. Петренко Анна Марія Богдан-Зиновій доц."} {"fields": {"given": "Юрій", "locale": "uk_AQ"}, "file": "uk", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Юрій"} {"fields": {"given": "Юрій", "locale": "uk_AQ"}, "file": "uk", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Юрій"} {"fields": {"given": "Юрій", "locale": "uk_AQ"}, "file": "uk", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Юрій"} {"fields": {"given": "Єва", "given2": "Марія", "locale": "uk_AQ", "surname": "Шевченко"}, "file": "uk", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Єва Марія Шевченко"} {"fields": {"given": "Єва", "given2": "Марія", "locale": "uk_AQ", "surname": "Шевченко"}, "file": "uk", "label": "nativeGGS", "order": "sorting", "shape": 4, "text": "Шевченко Єва Марія"} {"fields": {"given": "Єва", "given2": "Марія", "locale": "uk_AQ", "surname": "Шевченко"}, "file": "uk", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Шевченко Єва Марія"} {"fields": {"given": "Анна", "locale": "uk_AQ", "surname": "Мельник"}, "file": "uk", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Анна Мельник"} {"fields": {"given": "Анна", "locale": "uk_AQ", "surname": "Мельник"}, "file": "uk", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "Мельник Анна"} {"fields": {"given": "Анна", "locale": "uk_AQ", "surname": "Мельник"}, "file": "uk", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Мельник Анна"} {"fields": {"credentials": "ایم ڈی ڈی ڈی ایس", "generation": "جونيئر", "given": "یاسمین راشد", "given-informal": "ملک", "given2": "سدرہ منیر", "locale": "ja_AQ", "surname-core": "رضا", "surname-prefix": "ندیم", "surname2": "یوسف زئی", "title": "پروفیسر ڈاکٹر"}, "file": "ur", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "یاسمین راشد سدرہ منیر ندیم رضا ایم ڈی ڈی ڈی ایس"} {"fields": {"credentials": "ایم ڈی ڈی ڈی ایس", "generation": "جونيئر", "given": "یاسمین راشد", "given-informal": "ملک", "given2": "سدرہ منیر", "locale": "ja_AQ", "surname-core": "رضا", "surname-prefix": "ندیم", "surname2": "یوسف زئی", "title": "پروفیسر ڈاکٹر"}, "file": "ur", "label": "foreignFull", "order": "sorting", "shape": 4, "text": "ندیم رضا، یاسمین راشد سدرہ منیر ایم ڈی ڈی ڈی ایس"} {"fields": {"credentials": "ایم ڈی ڈی ڈی ایس", "generation": "جونيئر", "given": "یاسمین راشد", "given-informal": "ملک", "given2": "سدرہ منیر", "locale": "ja_AQ", "surname-core": "رضا", "surname-prefix": "ندیم", "surname2": "یوسف زئی", "title": "پروفیسر ڈاکٹر"}, "file": "ur", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "ندیم رضا یاسمین راشد سدرہ منیر ایم ڈی ڈی ڈی ایس"} {"fields": {"given": "سندباد", "locale": "ja_AQ"}, "file": "ur", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "سندباد"} {"fields": {"given": "سندباد", "locale": "ja_AQ"}, "file": "ur", "label": "foreignG", "order": "sorting", "shape": 4, "text": "سندباد"} {"fields": {"given": "سندباد", "locale": "ja_AQ"}, "file": "ur", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "سندباد"} {"fields": {"given": "محمد", "given2": "ہاشم", "locale": "ja_AQ", "surname": "صابری"}, "file": "ur", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "محمد ہاشم صابری"} {"fields": {"given": "محمد", "given2": "ہاشم", "locale": "ja_AQ", "surname": "صابری"}, "file": "ur", "label": "foreignGGS", "order": "sorting", "shape": 4, "text": "صابری، محمد ہاشم"} {"fields": {"given": "محمد", "given2": "ہاشم", "locale": "ja_AQ", "surname": "صابری"}, "file": "ur", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "صابری محمد ہاشم"} {"fields": {"given": "حاتم", "locale": "ja_AQ", "surname": "منصور"}, "file": "ur", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "حاتم منصور"} {"fields": {"given": "حاتم", "locale": "ja_AQ", "surname": "منصور"}, "file": "ur", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "منصور، حاتم"} {"fields": {"given": "حاتم", "locale": "ja_AQ", "surname": "منصور"}, "file": "ur", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "منصور حاتم"} {"fields": {"credentials": "ایم پی", "generation": "جونیئر", "given": "عبد الکریم", "given-informal": "پٹھان", "given2": "عبد الحمید", "locale": "ur_AQ", "surname-core": "سید", "title": "محترم"}, "file": "ur", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "عبد الکریم عبد الحمید سید ایم پی"} {"fields": {"credentials": "ایم پی", "generation": "جونیئر", "given": "عبد الکریم", "given-informal": "پٹھان", "given2": "عبد الحمید", "locale": "ur_AQ", "surname-core": "سید", "title": "محترم"}, "file": "ur", "label": "nativeFull", "order": "sorting", "shape": 4, "text": "سید، عبد الکریم عبد الحمید ایم پی"} {"fields": {"credentials": "ایم پی", "generation": "جونیئر", "given": "عبد الکریم", "given-informal": "پٹھان", "given2": "عبد الحمید", "locale": "ur_AQ", "surname-core": "سید", "title": "محترم"}, "file": "ur", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "سید عبد الکریم عبد الحمید ایم پی"} {"fields": {"given": "احمد", "locale": "ur_AQ"}, "file": "ur", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "احمد"} {"fields": {"given": "احمد", "locale": "ur_AQ"}, "file": "ur", "label": "nativeG", "order": "sorting", "shape": 4, "text": "احمد"} {"fields": {"given": "احمد", "locale": "ur_AQ"}, "file": "ur", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "احمد"} {"fields": {"given": "مریم", "given2": "نواز", "locale": "ur_AQ", "surname": "خان"}, "file": "ur", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "مریم نواز خان"} {"fields": {"given": "مریم", "given2": "نواز", "locale": "ur_AQ", "surname": "خان"}, "file": "ur", "label": "nativeGGS", "order": "sorting", "shape": 4, "text": "خان، مریم نواز"} {"fields": {"given": "مریم", "given2": "نواز", "locale": "ur_AQ", "surname": "خان"}, "file": "ur", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "خان مریم نواز"} {"fields": {"given": "شازیہ", "locale": "ur_AQ", "surname": "اصغر"}, "file": "ur", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "شازیہ اصغر"} {"fields": {"given": "شازیہ", "locale": "ur_AQ", "surname": "اصغر"}, "file": "ur", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "اصغر، شازیہ"} {"fields": {"given": "شازیہ", "locale": "ur_AQ", "surname": "اصغر"}, "file": "ur", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "اصغر شازیہ"} {"fields": {"credentials": "M.D. Ph.D.", "generation": "Kichik", "given": "Ada Kornelia", "given-informal": "Nil", "given2": "Yeva Sofiya", "locale": "ko_AQ", "surname-core": "Volf", "surname-prefix": "van den", "surname2": "Bekker Shmidt", "title": "Prof. Dr."}, "file": "uz", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Ada Kornelia Yeva Sofiya van den Volf M.D. Ph.D."} {"fields": {"credentials": "M.D. Ph.D.", "generation": "Kichik", "given": "Ada Kornelia", "given-informal": "Nil", "given2": "Yeva Sofiya", "locale": "ko_AQ", "surname-core": "Volf", "surname-prefix": "van den", "surname2": "Bekker Shmidt", "title": "Prof. Dr."}, "file": "uz", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Volf, Ada Kornelia Yeva Sofiya van den"} {"fields": {"credentials": "M.D. Ph.D.", "generation": "Kichik", "given": "Ada Kornelia", "given-informal": "Nil", "given2": "Yeva Sofiya", "locale": "ko_AQ", "surname-core": "Volf", "surname-prefix": "van den", "surname2": "Bekker Shmidt", "title": "Prof. Dr."}, "file": "uz", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "van den Volf Ada Kornelia Yeva Sofiya M.D. Ph.D."} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "uz", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "uz", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "uz", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Zaziliya", "given2": "Hemish", "locale": "ko_AQ", "surname": "Shtober"}, "file": "uz", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zaziliya Hemish Shtober"} {"fields": {"given": "Zaziliya", "given2": "Hemish", "locale": "ko_AQ", "surname": "Shtober"}, "file": "uz", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Shtober, Zaziliya Hemish"} {"fields": {"given": "Zaziliya", "given2": "Hemish", "locale": "ko_AQ", "surname": "Shtober"}, "file": "uz", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Shtober Zaziliya Hemish"} {"fields": {"given": "Kete", "locale": "ko_AQ", "surname": "Myuller"}, "file": "uz", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Kete Myuller"} {"fields": {"given": "Kete", "locale": "ko_AQ", "surname": "Myuller"}, "file": "uz", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Myuller, Kete"} {"fields": {"given": "Kete", "locale": "ko_AQ", "surname": "Myuller"}, "file": "uz", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Myuller Kete"} {"fields": {"credentials": "Deputat", "generation": "Kichik", "given": "Alisher", "given-informal": "Alisher", "given2": "Salimovich", "locale": "uz_AQ", "surname-core": "Qodirov", "title": "Janob"}, "file": "uz", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Alisher Salimovich Qodirov Deputat"} {"fields": {"credentials": "Deputat", "generation": "Kichik", "given": "Alisher", "given-informal": "Alisher", "given2": "Salimovich", "locale": "uz_AQ", "surname-core": "Qodirov", "title": "Janob"}, "file": "uz", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Qodirov, Alisher Salimovich"} {"fields": {"credentials": "Deputat", "generation": "Kichik", "given": "Alisher", "given-informal": "Alisher", "given2": "Salimovich", "locale": "uz_AQ", "surname-core": "Qodirov", "title": "Janob"}, "file": "uz", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Qodirov Alisher Salimovich Deputat"} {"fields": {"given": "Bobur", "locale": "uz_AQ"}, "file": "uz", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Bobur"} {"fields": {"given": "Bobur", "locale": "uz_AQ"}, "file": "uz", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Bobur"} {"fields": {"given": "Bobur", "locale": "uz_AQ"}, "file": "uz", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Bobur"} {"fields": {"given": "Muhammad", "given2": "Yusuf", "locale": "uz_AQ", "surname": "Qodirov"}, "file": "uz", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Muhammad Yusuf Qodirov"} {"fields": {"given": "Muhammad", "given2": "Yusuf", "locale": "uz_AQ", "surname": "Qodirov"}, "file": "uz", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Qodirov, Muhammad Yusuf"} {"fields": {"given": "Muhammad", "given2": "Yusuf", "locale": "uz_AQ", "surname": "Qodirov"}, "file": "uz", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Qodirov Muhammad Yusuf"} {"fields": {"given": "Alisher", "locale": "uz_AQ", "surname": "Sattorov"}, "file": "uz", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Alisher Sattorov"} {"fields": {"given": "Alisher", "locale": "uz_AQ", "surname": "Sattorov"}, "file": "uz", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Sattorov, Alisher"} {"fields": {"given": "Alisher", "locale": "uz_AQ", "surname": "Sattorov"}, "file": "uz", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Sattorov Alisher"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "fr_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Giáo sư, Tiến sĩ"}, "file": "vi", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Giáo sư, Tiến sĩ Ada Cornelia César Martín von Brühl Jr, MD DDS"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "fr_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Giáo sư, Tiến sĩ"}, "file": "vi", "label": "foreignFull", "order": "sorting", "shape": 4, "text": "von Brühl César Martín Ada Cornelia"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "fr_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Giáo sư, Tiến sĩ"}, "file": "vi", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "MD DDS von Brühl César Martín Ada Cornelia"} {"fields": {"given": "Sinbad", "locale": "fr_AQ"}, "file": "vi", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "fr_AQ"}, "file": "vi", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "fr_AQ"}, "file": "vi", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "fr_AQ", "surname": "Stöber"}, "file": "vi", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zäzilia Hamish Stöber"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "fr_AQ", "surname": "Stöber"}, "file": "vi", "label": "foreignGGS", "order": "sorting", "shape": 4, "text": "Stöber Hamish Zäzilia"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "fr_AQ", "surname": "Stöber"}, "file": "vi", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Stöber Hamish Zäzilia"} {"fields": {"given": "Käthe", "locale": "fr_AQ", "surname": "Müller"}, "file": "vi", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Käthe Müller"} {"fields": {"given": "Käthe", "locale": "fr_AQ", "surname": "Müller"}, "file": "vi", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "Müller Käthe"} {"fields": {"given": "Käthe", "locale": "fr_AQ", "surname": "Müller"}, "file": "vi", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Müller Käthe"} {"fields": {"credentials": "ĐBQH", "given": "Anh", "given2": "Văn", "locale": "vi_AQ", "surname-core": "Nguyễn", "title": "Ông"}, "file": "vi", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Ông Anh Văn Nguyễn ĐBQH"} {"fields": {"credentials": "ĐBQH", "given": "Anh", "given2": "Văn", "locale": "vi_AQ", "surname-core": "Nguyễn", "title": "Ông"}, "file": "vi", "label": "nativeFull", "order": "sorting", "shape": 4, "text": "Nguyễn Văn Anh"} {"fields": {"credentials": "ĐBQH", "given": "Anh", "given2": "Văn", "locale": "vi_AQ", "surname-core": "Nguyễn", "title": "Ông"}, "file": "vi", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "ĐBQH Nguyễn Văn Anh"} {"fields": {"given": "Anh", "locale": "vi_AQ"}, "file": "vi", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Anh"} {"fields": {"given": "Anh", "locale": "vi_AQ"}, "file": "vi", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Anh"} {"fields": {"given": "Anh", "locale": "vi_AQ"}, "file": "vi", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Anh"} {"fields": {"given": "Anh", "given2": "Văn", "locale": "vi_AQ", "surname": "Nguyễn"}, "file": "vi", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Anh Văn Nguyễn"} {"fields": {"given": "Anh", "given2": "Văn", "locale": "vi_AQ", "surname": "Nguyễn"}, "file": "vi", "label": "nativeGGS", "order": "sorting", "shape": 4, "text": "Nguyễn Văn Anh"} {"fields": {"given": "Anh", "given2": "Văn", "locale": "vi_AQ", "surname": "Nguyễn"}, "file": "vi", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Nguyễn Văn Anh"} {"fields": {"given": "Anh", "locale": "vi_AQ", "surname": "Nguyễn"}, "file": "vi", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Anh Nguyễn"} {"fields": {"given": "Anh", "locale": "vi_AQ", "surname": "Nguyễn"}, "file": "vi", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "Nguyễn Anh"} {"fields": {"given": "Anh", "locale": "vi_AQ", "surname": "Nguyễn"}, "file": "vi", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Nguyễn Anh"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Adama Coulibaly", "given-informal": "Néné", "given2": "Camara Massamba", "locale": "ja_AQ", "surname-core": "Baar", "surname-prefix": "won", "surname2": "Dominique Goudiaby", "title": "Prof. Dr."}, "file": "wo", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Prof. Dr. Adama Coulibaly Camara Massamba won Baar Dominique Goudiaby MD DDS"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Adama Coulibaly", "given-informal": "Néné", "given2": "Camara Massamba", "locale": "ja_AQ", "surname-core": "Baar", "surname-prefix": "won", "surname2": "Dominique Goudiaby", "title": "Prof. Dr."}, "file": "wo", "label": "foreignFull", "order": "sorting", "shape": null, "text": "won Baar Dominique Goudiaby, Prof. Dr. Adama Coulibaly Camara Massamba MD DDS"} {"fields": {"credentials": "MD DDS", "generation": "Jr", "given": "Adama Coulibaly", "given-informal": "Néné", "given2": "Camara Massamba", "locale": "ja_AQ", "surname-core": "Baar", "surname-prefix": "won", "surname2": "Dominique Goudiaby", "title": "Prof. Dr."}, "file": "wo", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "won Baar Dominique Goudiaby Prof. Dr. Adama Coulibaly Camara Massamba MD DDS"} {"fields": {"given": "Samba", "locale": "ja_AQ"}, "file": "wo", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Samba"} {"fields": {"given": "Samba", "locale": "ja_AQ"}, "file": "wo", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Samba"} {"fields": {"given": "Samba", "locale": "ja_AQ"}, "file": "wo", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Samba"} {"fields": {"given": "Samira", "given2": "Ahmet", "locale": "ja_AQ", "surname": "Saar"}, "file": "wo", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Samira Ahmet Saar"} {"fields": {"given": "Samira", "given2": "Ahmet", "locale": "ja_AQ", "surname": "Saar"}, "file": "wo", "label": "foreignGGS", "order": "sorting", "shape": 4, "text": "Saar Samira Ahmet"} {"fields": {"given": "Samira", "given2": "Ahmet", "locale": "ja_AQ", "surname": "Saar"}, "file": "wo", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Saar Samira Ahmet"} {"fields": {"given": "Katy", "locale": "ja_AQ", "surname": "Mané"}, "file": "wo", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Katy Mané"} {"fields": {"given": "Katy", "locale": "ja_AQ", "surname": "Mané"}, "file": "wo", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "Mané Katy"} {"fields": {"given": "Katy", "locale": "ja_AQ", "surname": "Mané"}, "file": "wo", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Mané Katy"} {"fields": {"credentials": "MP", "generation": "Jr", "given": "Baba Wagué", "given-informal": "Binta", "given2": "Amdy Raas", "locale": "wo_AQ", "surname-core": "Sañsé", "title": "Señ"}, "file": "wo", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Señ Baba Wagué Amdy Raas Sañsé MP"} {"fields": {"credentials": "MP", "generation": "Jr", "given": "Baba Wagué", "given-informal": "Binta", "given2": "Amdy Raas", "locale": "wo_AQ", "surname-core": "Sañsé", "title": "Señ"}, "file": "wo", "label": "nativeFull", "order": "sorting", "shape": 4, "text": "Sañsé Señ Baba Wagué Amdy Raas MP"} {"fields": {"credentials": "MP", "generation": "Jr", "given": "Baba Wagué", "given-informal": "Binta", "given2": "Amdy Raas", "locale": "wo_AQ", "surname-core": "Sañsé", "title": "Señ"}, "file": "wo", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Sañsé Señ Baba Wagué Amdy Raas MP"} {"fields": {"given": "Seynabu", "locale": "wo_AQ"}, "file": "wo", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Seynabu"} {"fields": {"given": "Seynabu", "locale": "wo_AQ"}, "file": "wo", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Seynabu"} {"fields": {"given": "Seynabu", "locale": "wo_AQ"}, "file": "wo", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Seynabu"} {"fields": {"given": "Mari shu", "given2": "Halimatu", "locale": "wo_AQ", "surname": "Waly"}, "file": "wo", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Mari shu Halimatu Waly"} {"fields": {"given": "Mari shu", "given2": "Halimatu", "locale": "wo_AQ", "surname": "Waly"}, "file": "wo", "label": "nativeGGS", "order": "sorting", "shape": 4, "text": "Waly Mari shu Halimatu"} {"fields": {"given": "Mari shu", "given2": "Halimatu", "locale": "wo_AQ", "surname": "Waly"}, "file": "wo", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Waly Mari shu Halimatu"} {"fields": {"given": "Isseu", "locale": "wo_AQ", "surname": "Awa"}, "file": "wo", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Isseu Awa"} {"fields": {"given": "Isseu", "locale": "wo_AQ", "surname": "Awa"}, "file": "wo", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "Awa Isseu"} {"fields": {"given": "Isseu", "locale": "wo_AQ", "surname": "Awa"}, "file": "wo", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Awa Isseu"} {"fields": {"credentials": "M.D. Ph.D.", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Ọ̀jọ̀gbọ́n Ọ̀mọ̀wé"}, "file": "yo", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "Ada Cornelia César Martín von Brühl M.D. Ph.D."} {"fields": {"credentials": "M.D. Ph.D.", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Ọ̀jọ̀gbọ́n Ọ̀mọ̀wé"}, "file": "yo", "label": "foreignFull", "order": "sorting", "shape": null, "text": "Brühl, Ada Cornelia César Martín von"} {"fields": {"credentials": "M.D. Ph.D.", "generation": "Jr", "given": "Ada Cornelia", "given-informal": "Neele", "given2": "César Martín", "locale": "ko_AQ", "surname-core": "Brühl", "surname-prefix": "von", "surname2": "González Domingo", "title": "Ọ̀jọ̀gbọ́n Ọ̀mọ̀wé"}, "file": "yo", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "von Brühl Ada Cornelia César Martín M.D. Ph.D."} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "yo", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "yo", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "yo", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "yo", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zäzilia Hamish Stöber"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "yo", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Stöber, Zäzilia Hamish"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "yo", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Stöber Zäzilia Hamish"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "yo", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Käthe Müller"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "yo", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Müller, Käthe"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "yo", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Müller Käthe"} {"fields": {"credentials": "Asofin", "generation": "Jr", "given": "Adebola Opeyemi", "given-informal": "Bolanle", "given2": "Olumide Ajao", "locale": "yo_AQ", "surname-core": "Adekunle", "title": "Ọ̀gbẹ́ni"}, "file": "yo", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Adebola Opeyemi Olumide Ajao Adekunle Asofin"} {"fields": {"credentials": "Asofin", "generation": "Jr", "given": "Adebola Opeyemi", "given-informal": "Bolanle", "given2": "Olumide Ajao", "locale": "yo_AQ", "surname-core": "Adekunle", "title": "Ọ̀gbẹ́ni"}, "file": "yo", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Adekunle, Adebola Opeyemi Olumide Ajao"} {"fields": {"credentials": "Asofin", "generation": "Jr", "given": "Adebola Opeyemi", "given-informal": "Bolanle", "given2": "Olumide Ajao", "locale": "yo_AQ", "surname-core": "Adekunle", "title": "Ọ̀gbẹ́ni"}, "file": "yo", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Adekunle Adebola Opeyemi Olumide Ajao Asofin"} {"fields": {"given": "Boluwatife", "locale": "yo_AQ"}, "file": "yo", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Boluwatife"} {"fields": {"given": "Boluwatife", "locale": "yo_AQ"}, "file": "yo", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Boluwatife"} {"fields": {"given": "Boluwatife", "locale": "yo_AQ"}, "file": "yo", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Boluwatife"} {"fields": {"given": "Adeolu", "given2": "Adegboyega", "locale": "yo_AQ", "surname": "Akintola"}, "file": "yo", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "Adeolu Adegboyega Akintola"} {"fields": {"given": "Adeolu", "given2": "Adegboyega", "locale": "yo_AQ", "surname": "Akintola"}, "file": "yo", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "Akintola, Adeolu Adegboyega"} {"fields": {"given": "Adeolu", "given2": "Adegboyega", "locale": "yo_AQ", "surname": "Akintola"}, "file": "yo", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "Akintola Adeolu Adegboyega"} {"fields": {"given": "Olabisi", "locale": "yo_AQ", "surname": "Adeboye"}, "file": "yo", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "Olabisi Adeboye"} {"fields": {"given": "Olabisi", "locale": "yo_AQ", "surname": "Adeboye"}, "file": "yo", "label": "nativeGS", "order": "sorting", "shape": null, "text": "Adeboye, Olabisi"} {"fields": {"given": "Olabisi", "locale": "yo_AQ", "surname": "Adeboye"}, "file": "yo", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "Adeboye Olabisi"} {"fields": {"credentials": "博士", "generation": "小", "given": "怡君", "given-informal": "小君", "given2": "達印", "locale": "fr_AQ", "surname-core": "陳", "surname-prefix": "陳", "title": "教授"}, "file": "yue", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "教授·怡君·達印·陳·陳·小,博士"} {"fields": {"credentials": "博士", "generation": "小", "given": "怡君", "given-informal": "小君", "given2": "達印", "locale": "fr_AQ", "surname-core": "陳", "surname-prefix": "陳", "title": "教授"}, "file": "yue", "label": "foreignFull", "order": "sorting", "shape": 4, "text": "陳·怡君·達印"} {"fields": {"credentials": "博士", "generation": "小", "given": "怡君", "given-informal": "小君", "given2": "達印", "locale": "fr_AQ", "surname-core": "陳", "surname-prefix": "陳", "title": "教授"}, "file": "yue", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "陳·陳·怡君·教授,小·達印·博士"} {"fields": {"given": "凱晴", "locale": "fr_AQ"}, "file": "yue", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "凱晴"} {"fields": {"given": "凱晴", "locale": "fr_AQ"}, "file": "yue", "label": "foreignG", "order": "sorting", "shape": 4, "text": "凱晴"} {"fields": {"given": "凱晴", "locale": "fr_AQ"}, "file": "yue", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "凱晴"} {"fields": {"given": "永強", "given2": "德安", "locale": "fr_AQ", "surname": "吳"}, "file": "yue", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "永強·德安·吳"} {"fields": {"given": "永強", "given2": "德安", "locale": "fr_AQ", "surname": "吳"}, "file": "yue", "label": "foreignGGS", "order": "sorting", "shape": 4, "text": "吳·永強·德安"} {"fields": {"given": "永強", "given2": "德安", "locale": "fr_AQ", "surname": "吳"}, "file": "yue", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "吳·永強·德安"} {"fields": {"given": "永翹", "locale": "fr_AQ", "surname": "黃"}, "file": "yue", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "永翹·黃"} {"fields": {"given": "永翹", "locale": "fr_AQ", "surname": "黃"}, "file": "yue", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "黃·永翹"} {"fields": {"given": "永翹", "locale": "fr_AQ", "surname": "黃"}, "file": "yue", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "黃·永翹"} {"fields": {"credentials": "太平紳士", "generation": "小", "given": "子媚", "given-informal": "阿媚", "given2": "惠儀", "locale": "yue_AQ", "surname-core": "陳", "title": "先生"}, "file": "yue", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "先生子媚惠儀陳小,太平紳士"} {"fields": {"credentials": "太平紳士", "generation": "小", "given": "子媚", "given-informal": "阿媚", "given2": "惠儀", "locale": "yue_AQ", "surname-core": "陳", "title": "先生"}, "file": "yue", "label": "nativeFull", "order": "sorting", "shape": 4, "text": "陳子媚惠儀"} {"fields": {"credentials": "太平紳士", "generation": "小", "given": "子媚", "given-informal": "阿媚", "given2": "惠儀", "locale": "yue_AQ", "surname-core": "陳", "title": "先生"}, "file": "yue", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "陳子媚先生,小惠儀太平紳士"} {"fields": {"given": "文傑", "locale": "yue_AQ"}, "file": "yue", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "文傑"} {"fields": {"given": "文傑", "locale": "yue_AQ"}, "file": "yue", "label": "nativeG", "order": "sorting", "shape": 4, "text": "文傑"} {"fields": {"given": "文傑", "locale": "yue_AQ"}, "file": "yue", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "文傑"} {"fields": {"given": "家豪", "given2": "明德", "locale": "yue_AQ", "surname": "林"}, "file": "yue", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "家豪明德林"} {"fields": {"given": "家豪", "given2": "明德", "locale": "yue_AQ", "surname": "林"}, "file": "yue", "label": "nativeGGS", "order": "sorting", "shape": 4, "text": "林家豪明德"} {"fields": {"given": "家豪", "given2": "明德", "locale": "yue_AQ", "surname": "林"}, "file": "yue", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "林家豪明德"} {"fields": {"given": "雅婷", "locale": "yue_AQ", "surname": "張"}, "file": "yue", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "雅婷張"} {"fields": {"given": "雅婷", "locale": "yue_AQ", "surname": "張"}, "file": "yue", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "張雅婷"} {"fields": {"given": "雅婷", "locale": "yue_AQ", "surname": "張"}, "file": "yue", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "張雅婷"} {"fields": {"credentials": "博士", "generation": "小", "given": "怡君", "given-informal": "小君", "given2": "达印", "locale": "fr_AQ", "surname-core": "陈", "surname-prefix": "陈", "title": "教授"}, "file": "yue_Hans", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "教授·怡君·达印·陈·陈·小,博士"} {"fields": {"credentials": "博士", "generation": "小", "given": "怡君", "given-informal": "小君", "given2": "达印", "locale": "fr_AQ", "surname-core": "陈", "surname-prefix": "陈", "title": "教授"}, "file": "yue_Hans", "label": "foreignFull", "order": "sorting", "shape": 4, "text": "陈·怡君·达印"} {"fields": {"credentials": "博士", "generation": "小", "given": "怡君", "given-informal": "小君", "given2": "达印", "locale": "fr_AQ", "surname-core": "陈", "surname-prefix": "陈", "title": "教授"}, "file": "yue_Hans", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "陈·陈·怡君·教授,小·达印·博士"} {"fields": {"given": "凯晴", "locale": "fr_AQ"}, "file": "yue_Hans", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "凯晴"} {"fields": {"given": "凯晴", "locale": "fr_AQ"}, "file": "yue_Hans", "label": "foreignG", "order": "sorting", "shape": 4, "text": "凯晴"} {"fields": {"given": "凯晴", "locale": "fr_AQ"}, "file": "yue_Hans", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "凯晴"} {"fields": {"given": "永强", "given2": "德安", "locale": "fr_AQ", "surname": "吴"}, "file": "yue_Hans", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "永强·德安·吴"} {"fields": {"given": "永强", "given2": "德安", "locale": "fr_AQ", "surname": "吴"}, "file": "yue_Hans", "label": "foreignGGS", "order": "sorting", "shape": 4, "text": "吴·永强·德安"} {"fields": {"given": "永强", "given2": "德安", "locale": "fr_AQ", "surname": "吴"}, "file": "yue_Hans", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "吴·永强·德安"} {"fields": {"given": "永翘", "locale": "fr_AQ", "surname": "黄"}, "file": "yue_Hans", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "永翘·黄"} {"fields": {"given": "永翘", "locale": "fr_AQ", "surname": "黄"}, "file": "yue_Hans", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "黄·永翘"} {"fields": {"given": "永翘", "locale": "fr_AQ", "surname": "黄"}, "file": "yue_Hans", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "黄·永翘"} {"fields": {"credentials": "太平绅士", "generation": "小", "given": "子媚", "given-informal": "阿媚", "given2": "惠仪", "locale": "yue_Hans_AQ", "surname-core": "陈", "title": "先生"}, "file": "yue_Hans", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "先生子媚惠仪陈小,太平绅士"} {"fields": {"credentials": "太平绅士", "generation": "小", "given": "子媚", "given-informal": "阿媚", "given2": "惠仪", "locale": "yue_Hans_AQ", "surname-core": "陈", "title": "先生"}, "file": "yue_Hans", "label": "nativeFull", "order": "sorting", "shape": 4, "text": "陈子媚惠仪"} {"fields": {"credentials": "太平绅士", "generation": "小", "given": "子媚", "given-informal": "阿媚", "given2": "惠仪", "locale": "yue_Hans_AQ", "surname-core": "陈", "title": "先生"}, "file": "yue_Hans", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "陈子媚先生,小惠仪太平绅士"} {"fields": {"given": "文杰", "locale": "yue_Hans_AQ"}, "file": "yue_Hans", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "文杰"} {"fields": {"given": "文杰", "locale": "yue_Hans_AQ"}, "file": "yue_Hans", "label": "nativeG", "order": "sorting", "shape": 4, "text": "文杰"} {"fields": {"given": "文杰", "locale": "yue_Hans_AQ"}, "file": "yue_Hans", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "文杰"} {"fields": {"given": "家豪", "given2": "明德", "locale": "yue_Hans_AQ", "surname": "林"}, "file": "yue_Hans", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "家豪明德林"} {"fields": {"given": "家豪", "given2": "明德", "locale": "yue_Hans_AQ", "surname": "林"}, "file": "yue_Hans", "label": "nativeGGS", "order": "sorting", "shape": 4, "text": "林家豪明德"} {"fields": {"given": "家豪", "given2": "明德", "locale": "yue_Hans_AQ", "surname": "林"}, "file": "yue_Hans", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "林家豪明德"} {"fields": {"given": "雅婷", "locale": "yue_Hans_AQ", "surname": "张"}, "file": "yue_Hans", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "雅婷张"} {"fields": {"given": "雅婷", "locale": "yue_Hans_AQ", "surname": "张"}, "file": "yue_Hans", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "张雅婷"} {"fields": {"given": "雅婷", "locale": "yue_Hans_AQ", "surname": "张"}, "file": "yue_Hans", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "张雅婷"} {"fields": {"credentials": "博士", "generation": "小", "given": "艾达·科妮莉亚", "given-informal": "尼尔", "given2": "塞萨尔·马丁", "locale": "en_AQ", "surname-core": "布鲁赫", "surname-prefix": "冯", "title": "教授"}, "file": "zh", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "小艾达·科妮莉亚·塞萨尔·马丁·冯·布鲁赫博士教授"} {"fields": {"credentials": "博士", "generation": "小", "given": "艾达·科妮莉亚", "given-informal": "尼尔", "given2": "塞萨尔·马丁", "locale": "en_AQ", "surname-core": "布鲁赫", "surname-prefix": "冯", "title": "教授"}, "file": "zh", "label": "foreignFull", "order": "sorting", "shape": 4, "text": "布鲁赫·艾达·科妮莉亚·塞萨尔·马丁·冯"} {"fields": {"credentials": "博士", "generation": "小", "given": "艾达·科妮莉亚", "given-informal": "尼尔", "given2": "塞萨尔·马丁", "locale": "en_AQ", "surname-core": "布鲁赫", "surname-prefix": "冯", "title": "教授"}, "file": "zh", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "小冯·布鲁赫·艾达·科妮莉亚·塞萨尔·马丁·博士教授"} {"fields": {"given": "辛巴达", "locale": "en_AQ"}, "file": "zh", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "辛巴达"} {"fields": {"given": "辛巴达", "locale": "en_AQ"}, "file": "zh", "label": "foreignG", "order": "sorting", "shape": 4, "text": "辛巴达"} {"fields": {"given": "辛巴达", "locale": "en_AQ"}, "file": "zh", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "辛巴达"} {"fields": {"given": "塞西莉亚", "given2": "哈米什", "locale": "en_AQ", "surname": "施托贝尔"}, "file": "zh", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "塞西莉亚·哈米什·施托贝尔"} {"fields": {"given": "塞西莉亚", "given2": "哈米什", "locale": "en_AQ", "surname": "施托贝尔"}, "file": "zh", "label": "foreignGGS", "order": "sorting", "shape": 4, "text": "施托贝尔·塞西莉亚·哈米什"} {"fields": {"given": "塞西莉亚", "given2": "哈米什", "locale": "en_AQ", "surname": "施托贝尔"}, "file": "zh", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "施托贝尔·塞西莉亚·哈米什"} {"fields": {"given": "克特", "locale": "en_AQ", "surname": "米勒"}, "file": "zh", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "克特·米勒"} {"fields": {"given": "克特", "locale": "en_AQ", "surname": "米勒"}, "file": "zh", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "米勒·克特"} {"fields": {"given": "克特", "locale": "en_AQ", "surname": "米勒"}, "file": "zh", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "米勒·克特"} {"fields": {"credentials": "议员", "generation": "小", "given": "德威", "given-informal": "小德", "given2": "东升", "locale": "zh_AQ", "surname-core": "彭", "title": "先生"}, "file": "zh", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "小德威东升彭议员先生"} {"fields": {"credentials": "议员", "generation": "小", "given": "德威", "given-informal": "小德", "given2": "东升", "locale": "zh_AQ", "surname-core": "彭", "title": "先生"}, "file": "zh", "label": "nativeFull", "order": "sorting", "shape": 4, "text": "彭德威东升"} {"fields": {"credentials": "议员", "generation": "小", "given": "德威", "given-informal": "小德", "given2": "东升", "locale": "zh_AQ", "surname-core": "彭", "title": "先生"}, "file": "zh", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "小彭德威东升议员先生"} {"fields": {"given": "宇瀚", "locale": "zh_AQ"}, "file": "zh", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "宇瀚"} {"fields": {"given": "宇瀚", "locale": "zh_AQ"}, "file": "zh", "label": "nativeG", "order": "sorting", "shape": 4, "text": "宇瀚"} {"fields": {"given": "宇瀚", "locale": "zh_AQ"}, "file": "zh", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "宇瀚"} {"fields": {"given": "俊年", "given2": "杰思", "locale": "zh_AQ", "surname": "陈"}, "file": "zh", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "俊年杰思陈"} {"fields": {"given": "俊年", "given2": "杰思", "locale": "zh_AQ", "surname": "陈"}, "file": "zh", "label": "nativeGGS", "order": "sorting", "shape": 4, "text": "陈俊年杰思"} {"fields": {"given": "俊年", "given2": "杰思", "locale": "zh_AQ", "surname": "陈"}, "file": "zh", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "陈俊年杰思"} {"fields": {"given": "安怡", "locale": "zh_AQ", "surname": "张"}, "file": "zh", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "安怡张"} {"fields": {"given": "安怡", "locale": "zh_AQ", "surname": "张"}, "file": "zh", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "张安怡"} {"fields": {"given": "安怡", "locale": "zh_AQ", "surname": "张"}, "file": "zh", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "张安怡"} {"fields": {"credentials": "醫學博士", "generation": "二世", "given": "怡君", "given-informal": "小君", "given2": "達印", "locale": "fr_AQ", "surname-core": "陳", "surname-prefix": "馮", "title": "博士"}, "file": "zh_Hant", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "怡君達印馮·陳二世博士,醫學博士"} {"fields": {"credentials": "醫學博士", "generation": "二世", "given": "怡君", "given-informal": "小君", "given2": "達印", "locale": "fr_AQ", "surname-core": "陳", "surname-prefix": "馮", "title": "博士"}, "file": "zh_Hant", "label": "foreignFull", "order": "sorting", "shape": 4, "text": "馮·陳·怡君·達印"} {"fields": {"credentials": "醫學博士", "generation": "二世", "given": "怡君", "given-informal": "小君", "given2": "達印", "locale": "fr_AQ", "surname-core": "陳", "surname-prefix": "馮", "title": "博士"}, "file": "zh_Hant", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "馮·陳·怡君·達印·二世醫學博士"} {"fields": {"given": "雅婷", "locale": "fr_AQ"}, "file": "zh_Hant", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "雅婷"} {"fields": {"given": "雅婷", "locale": "fr_AQ"}, "file": "zh_Hant", "label": "foreignG", "order": "sorting", "shape": 4, "text": "雅婷"} {"fields": {"given": "雅婷", "locale": "fr_AQ"}, "file": "zh_Hant", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "雅婷"} {"fields": {"given": "雅婷", "given2": "婷婷", "locale": "fr_AQ", "surname": "王"}, "file": "zh_Hant", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "雅婷婷婷王"} {"fields": {"given": "雅婷", "given2": "婷婷", "locale": "fr_AQ", "surname": "王"}, "file": "zh_Hant", "label": "foreignGGS", "order": "sorting", "shape": 4, "text": "王·雅婷·婷婷"} {"fields": {"given": "雅婷", "given2": "婷婷", "locale": "fr_AQ", "surname": "王"}, "file": "zh_Hant", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "王·雅婷·婷婷"} {"fields": {"given": "雅婷", "locale": "fr_AQ", "surname": "王"}, "file": "zh_Hant", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "雅婷王"} {"fields": {"given": "雅婷", "locale": "fr_AQ", "surname": "王"}, "file": "zh_Hant", "label": "foreignGS", "order": "sorting", "shape": 4, "text": "王·雅婷"} {"fields": {"given": "雅婷", "locale": "fr_AQ", "surname": "王"}, "file": "zh_Hant", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "王·雅婷"} {"fields": {"credentials": "議員", "generation": "二世", "given": "雅婷", "given-informal": "婷婷", "given2": "婷婷", "locale": "zh_Hant_AQ", "surname-core": "王", "title": "先生"}, "file": "zh_Hant", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "雅婷婷婷王二世先生,議員"} {"fields": {"credentials": "議員", "generation": "二世", "given": "雅婷", "given-informal": "婷婷", "given2": "婷婷", "locale": "zh_Hant_AQ", "surname-core": "王", "title": "先生"}, "file": "zh_Hant", "label": "nativeFull", "order": "sorting", "shape": 4, "text": "王雅婷婷婷"} {"fields": {"credentials": "議員", "generation": "二世", "given": "雅婷", "given-informal": "婷婷", "given2": "婷婷", "locale": "zh_Hant_AQ", "surname-core": "王", "title": "先生"}, "file": "zh_Hant", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "王雅婷婷婷二世議員"} {"fields": {"given": "文傑", "locale": "zh_Hant_AQ"}, "file": "zh_Hant", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "文傑"} {"fields": {"given": "文傑", "locale": "zh_Hant_AQ"}, "file": "zh_Hant", "label": "nativeG", "order": "sorting", "shape": 4, "text": "文傑"} {"fields": {"given": "文傑", "locale": "zh_Hant_AQ"}, "file": "zh_Hant", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "文傑"} {"fields": {"given": "家豪", "given2": "明德", "locale": "zh_Hant_AQ", "surname": "林"}, "file": "zh_Hant", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "家豪明德林"} {"fields": {"given": "家豪", "given2": "明德", "locale": "zh_Hant_AQ", "surname": "林"}, "file": "zh_Hant", "label": "nativeGGS", "order": "sorting", "shape": 4, "text": "林家豪明德"} {"fields": {"given": "家豪", "given2": "明德", "locale": "zh_Hant_AQ", "surname": "林"}, "file": "zh_Hant", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "林家豪明德"} {"fields": {"given": "雅婷", "locale": "zh_Hant_AQ", "surname": "張"}, "file": "zh_Hant", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "雅婷張"} {"fields": {"given": "雅婷", "locale": "zh_Hant_AQ", "surname": "張"}, "file": "zh_Hant", "label": "nativeGS", "order": "sorting", "shape": 4, "text": "張雅婷"} {"fields": {"given": "雅婷", "locale": "zh_Hant_AQ", "surname": "張"}, "file": "zh_Hant", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "張雅婷"} {"fields": {"credentials": "M.D. Ph.D.", "generation": "isizukulwane", "given": "u-Ada Cornelia", "given-informal": "u-Neele", "given2": "u-Eva Sophia", "locale": "ko_AQ", "surname-core": "u-Wold", "surname-prefix": "u-van den", "surname2": "u-Becker Schmidt", "title": "u-Prof. Dr."}, "file": "zu", "label": "foreignFull", "order": "givenFirst", "shape": null, "text": "u-Prof. Dr. u-Ada Cornelia u-Eva Sophia u-van den u-Wold u-Becker Schmidt M.D. Ph.D."} {"fields": {"credentials": "M.D. Ph.D.", "generation": "isizukulwane", "given": "u-Ada Cornelia", "given-informal": "u-Neele", "given2": "u-Eva Sophia", "locale": "ko_AQ", "surname-core": "u-Wold", "surname-prefix": "u-van den", "surname2": "u-Becker Schmidt", "title": "u-Prof. Dr."}, "file": "zu", "label": "foreignFull", "order": "sorting", "shape": null, "text": "u-Wold, u-Ada Cornelia u-Eva Sophia u-van den"} {"fields": {"credentials": "M.D. Ph.D.", "generation": "isizukulwane", "given": "u-Ada Cornelia", "given-informal": "u-Neele", "given2": "u-Eva Sophia", "locale": "ko_AQ", "surname-core": "u-Wold", "surname-prefix": "u-van den", "surname2": "u-Becker Schmidt", "title": "u-Prof. Dr."}, "file": "zu", "label": "foreignFull", "order": "surnameFirst", "shape": 4, "text": "u-van den u-Wold u-Prof. Dr. u-Ada Cornelia u-Eva Sophia isizukulwane, M.D. Ph.D."} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "zu", "label": "foreignG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "zu", "label": "foreignG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "ko_AQ"}, "file": "zu", "label": "foreignG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "zu", "label": "foreignGGS", "order": "givenFirst", "shape": null, "text": "Zäzilia Hamish Stöber"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "zu", "label": "foreignGGS", "order": "sorting", "shape": null, "text": "Stöber, Zäzilia Hamish"} {"fields": {"given": "Zäzilia", "given2": "Hamish", "locale": "ko_AQ", "surname": "Stöber"}, "file": "zu", "label": "foreignGGS", "order": "surnameFirst", "shape": 4, "text": "Stöber Zäzilia Hamish"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "zu", "label": "foreignGS", "order": "givenFirst", "shape": null, "text": "Käthe Müller"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "zu", "label": "foreignGS", "order": "sorting", "shape": null, "text": "Müller, Käthe"} {"fields": {"given": "Käthe", "locale": "ko_AQ", "surname": "Müller"}, "file": "zu", "label": "foreignGS", "order": "surnameFirst", "shape": 4, "text": "Müller Käthe"} {"fields": {"credentials": "MP", "generation": "Jr", "given": "Bertram Wilberforce", "given-informal": "Bertie", "given2": "Henry Robert", "locale": "zu_AQ", "surname-core": "Wooster", "title": "Mr."}, "file": "zu", "label": "nativeFull", "order": "givenFirst", "shape": null, "text": "Mr. Bertram Wilberforce Henry Robert Wooster MP"} {"fields": {"credentials": "MP", "generation": "Jr", "given": "Bertram Wilberforce", "given-informal": "Bertie", "given2": "Henry Robert", "locale": "zu_AQ", "surname-core": "Wooster", "title": "Mr."}, "file": "zu", "label": "nativeFull", "order": "sorting", "shape": null, "text": "Wooster, Bertram Wilberforce Henry Robert"} {"fields": {"credentials": "MP", "generation": "Jr", "given": "Bertram Wilberforce", "given-informal": "Bertie", "given2": "Henry Robert", "locale": "zu_AQ", "surname-core": "Wooster", "title": "Mr."}, "file": "zu", "label": "nativeFull", "order": "surnameFirst", "shape": 4, "text": "Wooster Mr. Bertram Wilberforce Henry Robert Jr, MP"} {"fields": {"given": "Sinbad", "locale": "zu_AQ"}, "file": "zu", "label": "nativeG", "order": "givenFirst", "shape": null, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "zu_AQ"}, "file": "zu", "label": "nativeG", "order": "sorting", "shape": 4, "text": "Sinbad"} {"fields": {"given": "Sinbad", "locale": "zu_AQ"}, "file": "zu", "label": "nativeG", "order": "surnameFirst", "shape": 4, "text": "Sinbad"} {"fields": {"given": "u-John", "given2": "u-Hamish", "locale": "zu_AQ", "surname": "u-Watson"}, "file": "zu", "label": "nativeGGS", "order": "givenFirst", "shape": null, "text": "u-John u-Hamish u-Watson"} {"fields": {"given": "u-John", "given2": "u-Hamish", "locale": "zu_AQ", "surname": "u-Watson"}, "file": "zu", "label": "nativeGGS", "order": "sorting", "shape": null, "text": "u-Watson, u-John u-Hamish"} {"fields": {"given": "u-John", "given2": "u-Hamish", "locale": "zu_AQ", "surname": "u-Watson"}, "file": "zu", "label": "nativeGGS", "order": "surnameFirst", "shape": 4, "text": "u-Watson u-John u-Hamish"} {"fields": {"given": "u-Irene", "locale": "zu_AQ", "surname": "u-Adier"}, "file": "zu", "label": "nativeGS", "order": "givenFirst", "shape": null, "text": "u-Irene u-Adier"} {"fields": {"given": "u-Irene", "locale": "zu_AQ", "surname": "u-Adier"}, "file": "zu", "label": "nativeGS", "order": "sorting", "shape": null, "text": "u-Adier, u-Irene"} {"fields": {"given": "u-Irene", "locale": "zu_AQ", "surname": "u-Adier"}, "file": "zu", "label": "nativeGS", "order": "surnameFirst", "shape": 4, "text": "u-Adier u-Irene"} ����������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/compare.py�������������������������������������0000664�0000000�0000000�00000041137�15273032717�0024511�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""A released nameparser against the working tree, over every corpus. uv run python tools/differential/compare.py [--baseline VERSION] [--corpus PATH] Reads every corpus name with a pinned release installed fresh from PyPI and with the working tree, and prints the names that read differently, grouped by which fields changed -- the same report snapshot.py prints. It classifies nothing and fails on nothing (#664): it answers "how did VERSION read these names?" for a release older than the committed snapshot, where `snapshot.py --since <tag>` cannot. For any release that has a snapshot, that command is the cheaper one. --baseline 1.4.0 compares the facade alone (1.4 has no 2.0 API); from 2.0.0 on both surfaces are compared, ambiguity kinds included. An order-bearing corpus name is skipped below the release its order shipped in. What this file still guards is the trap that makes a comparison report no differences when it compared one tree with itself: see the invocation traps in tools/differential/README.md, and _check_tell and _check_tree below. """ import argparse import importlib.util import json import os import re import subprocess import tempfile from pathlib import Path from types import ModuleType from collections.abc import Mapping from typing import NamedTuple HERE = Path(__file__).resolve().parent DEFAULT_BASELINE = "2.7.0" REPO_ROOT = HERE.parents[1] def _load_snapshot() -> ModuleType: """snapshot.py beside this script, loaded by path: tools/ is not a package, and a test loads this module by path as well.""" spec = importlib.util.spec_from_file_location( "differential_snapshot", HERE / "snapshot.py") assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module def _parse_version(text: str) -> tuple[int, int, int]: """The numeric release tuple, padded to three parts. Every version comparison in this file goes through it. Explicit because string comparison is wrong twice over here: it orders '1.4.0' < '2.0.0' by luck and would misorder a future '10.0.0', and it would call a requested '2.0' unequal to a wheel reporting '2.0.0' -- turning a correct run into a spurious tell mismatch, which is an abort on a run that was fine. A prerelease segment is ignored: '2.0.0rc1' is release (2, 0, 0), because what is being asked is which RELEASE answered. """ m = re.match(r"\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?", text) if not m: raise SystemExit( f"cannot parse a version from {text!r}: expected a numeric " f"release like '2.0.0'") major, minor, micro = (int(p) if p else 0 for p in m.groups()) return (major, minor, micro) def _surfaces_for(version: str) -> frozenset[str]: """Which output surfaces a baseline can be compared on. 1.4 has no v2 API, so a pre-2.0 baseline compares the facade alone. From 2.0 on both are compared: the v2 API is the primary surface for 2.x users, and its ambiguity kinds catch a change the field diff cannot see -- a parse that starts or stops reporting SEGMENTATION while every field stays byte-identical. """ if _parse_version(version) >= (2, 0, 0): return frozenset({"facade", "v2"}) return frozenset({"facade"}) _WORKER_TEMPLATE = '''\ # /// script # requires-python = ">=3.9" # dependencies = ["nameparser==@@VERSION@@"] # /// """GENERATED by tools/differential/compare.py -- edit the template there, not a copy of this. Writes a VERSION TELL as its first stdout line, then one result per input entry. The tell exists because the alternative failure is invisible: a worker that silently resolved to the checkout answers every query as the working tree while the run is labelled with the baseline, so every diff vanishes and the run reports parity -- the precise opposite of the truth. Input lines are {"name": ..., "order": null | "<CONSTANT>"}. An order names a public nameparser constant; only workers whose baseline supports it are ever sent one (compare.py skips below the shape's min_baseline), and an order-bearing entry is compared on the v2 surface alone -- the facade is the v1-compat surface, and a family-first name is not a v1 contract. """ import json import sys import warnings import nameparser from nameparser import HumanName V1_FIELDS = ("title", "first", "middle", "last", "suffix", "nickname", "maiden") V2_FIELDS = ("title", "given", "middle", "family", "suffix", "nickname", "maiden") WANT_V2 = @@WANT_V2@@ # whether this release reports the reading a fork did not take (2.7, # #752 phase 2m): compare() compares the alternatives only where it does _AMBIGUITY = getattr(nameparser, "Ambiguity", None) print(json.dumps({"__version__": nameparser.__version__, "__file__": nameparser.__file__, "alternatives": "alternative" in getattr( _AMBIGUITY, "__dataclass_fields__", {})}), flush=True) if WANT_V2: from nameparser import Parser, Policy, parse _parsers = {} def _parser_for(order): if order not in _parsers: _parsers[order] = Parser(policy=Policy( name_order=getattr(nameparser, order))) return _parsers[order] def _v2_row(p): # must stay identical to snapshot.read()'s tree-side readings -- # duplicated rather than shared across the process boundary row = {f: (getattr(p, f, "") or "") for f in V2_FIELDS} row["_ambiguities"] = sorted( {a.kind.name for a in getattr(p, "ambiguities", ())}) # #752 phase 2m: the reading each report did not take, as # snapshot.alternatives() records it, in report order; none before # 2.7, where compare() leaves the key out of both sides row["_alternatives"] = [ f"{a.kind.name}: {alt.kind.value} as {alt.role.value} " f"({alt.source.value})" for a in getattr(p, "ambiguities", ()) if (alt := getattr(a, "alternative", None)) is not None] # #484: the derived initials() view row["_initials"] = p.initials() or "" # #826: the case-repair views, as snapshot.case_views() records them rendered, plain = str(p), str(p.capitalized()) forced = str(p.capitalized(force=True)) row["_capitalized"] = plain if plain != rendered else "" row["_capitalized_force"] = forced if forced != plain else "" return row for line in sys.stdin: line = line.strip() if not line: continue entry = json.loads(line) name = entry["name"] order = entry.get("order") if order is not None: # never reaches a worker without WANT_V2; see the docstring print(json.dumps( {"v2": _v2_row(_parser_for(order).parse(name))}, ensure_ascii=False), flush=True) continue with warnings.catch_warnings(): # the facade is read on purpose; 2.8's removal warning, by # message (a baseline wheel before 2.8 never emits it) warnings.filterwarnings("ignore", message="HumanName is deprecated", category=DeprecationWarning) hn = HumanName(name) row = {"facade": {k: v or "" for k, v in hn.as_dict().items() if k in V1_FIELDS}} # #484: HumanName.initials() exists at every baseline, 1.4.0 included row["facade"]["_initials"] = hn.initials() or "" if WANT_V2: row["v2"] = _v2_row(parse(name)) print(json.dumps(row, ensure_ascii=False), flush=True) ''' def _worker_source(version: str, want_v2: bool) -> str: """Render the worker with its dependency pin substituted. Sentinel replacement rather than str.format or f-strings: the worker body is mostly literal braces, and escaping every one of them is a defect waiting to happen in a file whose silent misbehavior is the thing this harness exists to prevent. """ return (_WORKER_TEMPLATE .replace("@@VERSION@@", version) .replace("@@WANT_V2@@", "True" if want_v2 else "False")) def _check_tell(tell: dict[str, str], version: str) -> None: """Abort before comparing anything if the wrong library answered. This is the check the README's trap sections exist for. Both halves matter and neither implies the other: an editable install reports the TREE's version, so version-only agreement proves nothing when the tree and the baseline share a number, while a genuine wheel at the wrong version passes any path check. """ got = tell.get("__version__", "") where = tell.get("__file__", "") if not got or not where: raise SystemExit( f"baseline worker produced no usable version tell " f"({tell!r}); comparison aborted") if _parse_version(got) != _parse_version(version): raise SystemExit( f"baseline worker reports nameparser {got!r}, not the " f"requested {version!r} (loaded from {where}). See the " f"invocation traps in tools/differential/README.md; " f"comparison aborted.") if Path(where).resolve().is_relative_to(REPO_ROOT): raise SystemExit( f"baseline worker loaded nameparser from the CHECKOUT " f"({where}), so it answers as the working tree while " f"reporting {got!r} -- every diff would vanish and the run " f"would read as parity. Comparison aborted.") def _worker_env() -> dict[str, str]: """The child's environment, with the import-path overrides stripped. PEP 723 isolation does NOT survive PYTHONPATH: it precedes site-packages, so a directory named there shadows the pinned wheel inside uv's own environment. Measured 2026-08-05 -- with a released 2.0.0 on PYTHONPATH the run reported `intentional diffs: 0` and exited 0, both halves of the tell passing (the version matched, and the path was outside REPO_ROOT because it was outside the repo). That is the README's catastrophic failure by a third road, and it needs no exotic setup: a stale PYTHONPATH entry or a sibling checkout is enough. """ return {k: v for k, v in os.environ.items() if k not in ("PYTHONPATH", "PYTHONHOME")} def _check_tree(module_file: str) -> Path: """Prove the OTHER side of the comparison is the working tree. The baseline side gets a pinned wheel, a temp dir and a version tell; without this the tree side would be a bare import trusted on sight, reachable by the same PYTHONPATH road. snapshot.py holds the predicate (check_source), so the two tools cannot disagree about what counts as the checkout. (Why this is easy to miss when probing by hand: from the repo root, `python -c` puts the CWD on sys.path first, so the checkout wins there and the trap does not reproduce. Only the script invocation shows it.) """ at: Path = _load_snapshot().check_source(module_file, REPO_ROOT) return at def _run_worker(version: str, want_v2: bool, entries: list[dict]) -> tuple[dict[str, str], list[dict]]: """Run the baseline worker from a temp dir OUTSIDE the worktree. The placement is the safety mechanism, not plumbing. uv reads genuine PEP 723 metadata from a real script path, and sys.path[0] is the script's directory -- a temp dir holding no nameparser -- so the checkout cannot shadow the pinned wheel. The README notes that an absolute script path from outside the project is the one invocation variant that does not lie; this makes it the only one reachable. """ with tempfile.TemporaryDirectory() as tmp: script = Path(tmp) / "baseline_worker.py" script.write_text(_worker_source(version, want_v2), encoding="utf-8") proc = subprocess.Popen( ["uv", "run", "--no-project", str(script)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, cwd=tmp, env=_worker_env()) payload = "".join( json.dumps({"name": e["name"], "order": e.get("order")}, ensure_ascii=False) + "\n" for e in entries) out, _ = proc.communicate(payload) # hard checks, not asserts: -O must not turn a crashed worker into # a truncated-but-green comparison if proc.returncode != 0: raise SystemExit( f"baseline worker exited {proc.returncode}; comparison aborted") lines = out.splitlines() if not lines: raise SystemExit( "baseline worker produced no output, not even a version " "tell; comparison aborted") tell = json.loads(lines[0]) _check_tell(tell, version) results = [json.loads(x) for x in lines[1:]] if len(results) != len(entries): raise SystemExit( f"worker returned {len(results)} results for {len(entries)} " f"corpus entries; comparison aborted") return tell, results def _is_latin_only(name: str) -> bool: """Every character below U+0250 -- Latin, ASCII punctuation and Latin-1 accents: whether a user parsing Western names sees a move.""" return all(ord(ch) < 0x250 for ch in name) class Result(NamedTuple): moves: list[str] # snapshot.grouped's report moved: int # entries that read differently latin: int # ... of them Latin-only cldr: int # ... names of them held only by corpus_cldr.jsonl compared: int # entries compared skipped: int # order-bearing entries the baseline predates unrecorded: tuple[str, ...] = () # keys the baseline cannot read def unrecorded(tell: Mapping[str, object]) -> tuple[str, ...]: """The row keys the baseline worker's release cannot record, which compare() leaves out of both sides: the alternatives before 2.7, since a tree reporting one is not a name that moved.""" return () if tell.get("alternatives") else ("alternatives",) def compare(baseline: str, corpora: list[Path] | None = None) -> Result: """The grouped moves between `baseline` and the tree over every corpus (or `corpora`), with the population they were counted over: an entry whose shape's order the baseline predates is skipped, and a figure quoted without that denominator says nothing about it.""" snapshot = _load_snapshot() shapes = snapshot.load_shapes() min_for = {s.order: s.min_baseline for s in shapes.values() if s.order} every = snapshot.corpus_entries(corpora) entries = [(name, order) for name, order in every if order is None or _parse_version(baseline) >= _parse_version(min_for[order])] want_v2 = "v2" in _surfaces_for(baseline) tell, results = _run_worker( baseline, want_v2, [{"name": n, "order": o} for n, o in entries]) old = [snapshot.row(n, o, r.get("v2"), r.get("facade")) for (n, o), r in zip(entries, results)] new = snapshot.read(entries) import nameparser _check_tree(nameparser.__file__) if not want_v2: new = [snapshot.facade_view(r) for r in new] dropped = unrecorded(tell) for r in (*old, *new): for k in dropped: r.pop(k, None) before = {snapshot.key(r): r for r in old} after = {snapshot.key(r): r for r in new} moved = [k for k in after if before[k] != after[k]] latin = sum(_is_latin_only(name) for name, _ in moved) cldr = len(snapshot.only_in(snapshot.CLDR_CORPUS, {name for name, _ in moved})) return Result(snapshot.grouped(before, after), len(moved), latin, cldr, len(entries), len(every) - len(entries), dropped if want_v2 else ()) def main() -> int: ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("--baseline", default=DEFAULT_BASELINE, metavar="VERSION", help=f"released version to compare the tree against " f"(default {DEFAULT_BASELINE})") ap.add_argument("--corpus", action="append", type=Path, metavar="PATH", help="a corpus file to compare instead of every " "corpus*.jsonl here; repeatable. One JSON string " "per line is a name.") args = ap.parse_args() r = compare(args.baseline, args.corpus) if r.unrecorded: print(f"not compared: {', '.join(r.unrecorded)} -- " f"{args.baseline} does not report them\n") print("\n".join(r.moves) or "no differences") print(f"\n{r.moved} of {r.compared} entries read differently from " f"{args.baseline}, {r.latin} of them Latin-only; {r.cldr} of " f"their names held only by corpus_cldr.jsonl") if r.skipped: print(f"{r.skipped} order-bearing entries skipped: {args.baseline} " f"predates their name_order") return 0 if __name__ == "__main__": raise SystemExit(main()) ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/conventions.py���������������������������������0000664�0000000�0000000�00000027422�15273032717�0025431�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Hand-written names for naming conventions the other corpora barely cover (#686): traditions with no family name, religious names, patronymics placed by position, honorifics written after the name. Every name here is INVENTED, patterned on a convention documented in naming-practice references (a 2006 UK government guide distributed by Interpol among them, which carries no licence notice), never copied from one. build_conventions_corpus.py writes corpus_conventions.jsonl from this table, and the snapshot watches those names like every other corpus's. `expect` is a NOTE, not a pin: what a reader who knows the convention would take each part to be, often in terms the model cannot express yet (a father's or grandfather's name, an honorific that is not a title, a married form). Nothing scores the parser against it. `fields` states the parts in CLDR's field vocabulary, as cldr_agreement.py compares them, wherever a field can: `patronymic` is scored inside the given names and `surname2` inside the family, as for the Wikidata source, and `honorific` (a word of address written after the name: Bey, ssi, san) inside the suffix. A row no field can state is left without one rather than stated wrongly, since the comparison reads an unstated part as an empty role, and says why in `unstated`; every row carries `fields` or `unstated`, so neither can be left out by accident. build_conventions_corpus.py writes conventions_fields.jsonl from the `fields`. Today's reading is deliberately not recorded here: snapshot.jsonl holds it, and conventions_agreement.json holds it where it disagrees with `fields`. A second copy would go stale. A row whose reading is settled can graduate to tests/v2/cases.py, where it becomes a contract. `order` names an order some shapes.py shape declares, `"FAMILY_FIRST"` or `"FAMILY_FIRST_GIVEN_LAST"`; the default order is None, not `"GIVEN_FIRST"`, which no shape declares. """ from typing import NamedTuple class Convention(NamedTuple): tradition: str convention: str name: str order: str | None expect: str fields: dict[str, str] | None = None unstated: str | None = None CONVENTIONS: tuple[Convention, ...] = ( Convention("Somali", "given + father's + grandfather's name, no family name", "Hodan Farah Warsame", None, "given Hodan; Farah and Warsame are her father's and grandfather's " "given names, not a family name", fields={'given': 'Hodan', 'patronymic': 'Farah Warsame'}), Convention("Sikh", "religious name between given and family name", "Harpreet Kaur Dhillon", None, "given Harpreet; Kaur is the religious name Sikh women traditionally " "take, written as a surname when no family name is; family Dhillon", fields={'given': 'Harpreet', 'given2': 'Kaur', 'surname': 'Dhillon'}), Convention("Tamil", "father's given name as a leading initial", "K. Sivakumar", None, "K. is the father's given name; Sivakumar is the person's own given " "name; no family name", fields={'patronymic': 'K.', 'given': 'Sivakumar'}), Convention("Spanish", "H. (hijo) as the Jr. equivalent", "Pedro López Ruiz H.", None, "given Pedro; family López Ruiz (paternal and maternal surnames); " "H. a generational suffix", fields={'given': 'Pedro', 'surname': 'López', 'surname2': 'Ruiz', 'generation': 'H.'}), Convention("Spanish", "Vda. de (widow of) before the late husband's surname", "Carmen Díaz Vda. de Mora", None, "given Carmen; her own family name Díaz; Vda. de Mora marks her as " "the widow of a Mora", fields={'given': 'Carmen', 'surname': 'Díaz Vda. de Mora'}), Convention("Turkish", "oğlu ('son of') written apart, part of the family name", "Ali Kara Oğlu", None, "given Ali; family Kara Oğlu, the separated spelling of Karaoğlu", fields={'given': 'Ali', 'surname': 'Kara Oğlu'}), Convention("Turkish", "honorific after the given name", "Murat Bey", None, "given Murat; Bey an honorific, not a family name", fields={'given': 'Murat', 'honorific': 'Bey'}), Convention("Hungarian", "title after the name", "Szabó Péter úr", "FAMILY_FIRST", "family Szabó, given Péter; úr ('Mr.') a word of address written last", fields={'surname': 'Szabó', 'given': 'Péter', 'honorific': 'úr'}), Convention("Hungarian", "married form: -né on the husband's given name", "Szabó Péterné", "FAMILY_FIRST", "family Szabó; Péterné ('wife of Péter') stands where her given " "name would", fields={'surname': 'Szabó', 'given': 'Péterné'}), Convention("Korean", "two-syllable given name written as two words", "Kim Min Ho", "FAMILY_FIRST", "family Kim; given Min Ho, one name in two words", fields={'surname': 'Kim', 'given': 'Min Ho'}), Convention("Vietnamese", "Thi as the female middle name", "Tran Thi Mai", "FAMILY_FIRST", "family Tran; middle Thi; given Mai, which the convention writes " "last (the order FAMILY_FIRST_GIVEN_LAST names; read under " "FAMILY_FIRST as the issue measured it); Mai is also a " "suffix acronym, so either order reads it as a suffix", fields={'surname': 'Tran', 'given2': 'Thi', 'given': 'Mai'}), Convention("Bulgarian", "patronymic between given and family name", "Ivan Petrov Dimitrov", None, "given Ivan; Petrov his patronymic, from his father Petar; family " "Dimitrov", fields={'given': 'Ivan', 'patronymic': 'Petrov', 'surname': 'Dimitrov'}), Convention("Filipino", "Ma. for María, maternal surname in the middle", "Ma. Teresa Cruz Bautista", None, "given Ma. Teresa (Ma. is María, not a title); Cruz the mother's " "surname; family Bautista", fields={'given': 'Ma. Teresa', 'surname2': 'Cruz', 'surname': 'Bautista'}), Convention("Korean", "romanized honorific, spaced", "Park Ji-hoon ssi", "FAMILY_FIRST", "family Park; given Ji-hoon; ssi (씨) an honorific", fields={'surname': 'Park', 'given': 'Ji-hoon', 'honorific': 'ssi'}), Convention("Japanese", "romanized honorific, spaced", "Suzuki san", "FAMILY_FIRST", "family Suzuki; san an honorific; no given name", fields={'surname': 'Suzuki', 'honorific': 'san'}), Convention("Japanese", "romanized honorific, hyphenated", "Suzuki-san", "FAMILY_FIRST", "family Suzuki; -san an honorific glued to it", fields={'surname': 'Suzuki', 'honorific': '-san'}), Convention("Burmese", "no family name; honorific U for a man", "U Kyaw Win", None, "U an honorific; Kyaw Win the whole personal name; no family name", fields={'title': 'U', 'given': 'Kyaw Win'}), Convention("Burmese", "honorific Daw for a woman", "Daw Thida", None, "Daw an honorific; Thida the whole personal name", fields={'title': 'Daw', 'given': 'Thida'}), Convention("Thai", "Khun before the given name", "Khun Malee", None, "Khun a polite title; Malee the given name, which Thai address uses", fields={'title': 'Khun', 'given': 'Malee'}), Convention("Malay", "bin ('son of') patronymic, no family name", "Hassan bin Yusof", None, "given Hassan; bin Yusof his patronymic; no family name", fields={'given': 'Hassan', 'patronymic': 'bin Yusof'}), Convention("Malay", "bin abbreviated B.", "Hassan B. Yusof", None, "as Hassan bin Yusof: B. is bin, not a middle initial", fields={'given': 'Hassan', 'patronymic': 'B. Yusof'}), Convention("Malaysian Indian", "a/l (anak lelaki, 'son of')", "Suresh a/l Muthu", None, "given Suresh; a/l Muthu his patronymic; no family name", fields={'given': 'Suresh', 'patronymic': 'a/l Muthu'}), Convention("Malaysian Indian", "a/p (anak perempuan, 'daughter of')", "Kavitha a/p Raman", None, "given Kavitha; a/p Raman her patronymic; no family name", fields={'given': 'Kavitha', 'patronymic': 'a/p Raman'}), Convention("Indonesian", "H. (Haji), a title shaped like an initial", "H. Budi Santoso", None, "H. a title (one who has made the hajj); given Budi Santoso, " "the whole personal name: Indonesian law recognizes no surname", fields={'title': 'H.', 'given': 'Budi Santoso'}), Convention("Kalenjin", "arap ('son of')", "David arap Kirui", None, "given David; arap Kirui, used as a family name", unstated="arap Kirui is a patronymic or a family name by family"), Convention("Maasai", "ole ('son of')", "Joseph ole Sankale", None, "given Joseph; ole Sankale, used as a family name", unstated="ole Sankale is a patronymic or a family name by family"), Convention("Kikuyu", "wa ('of')", "Wanjiru wa Kamau", None, "given Wanjiru; wa Kamau names a parent, her father or her mother", unstated="wa Kamau is a patronymic, a matronymic or a family name " "by family"), Convention("Hong Kong", "English given name before the Chinese name", "Michael Wong Kar Ming", None, "given Michael; family Wong; Kar Ming the Chinese given name", fields={'given': 'Michael', 'surname': 'Wong', 'given2': 'Kar Ming'}), Convention("Singapore", "English given name after the Chinese name", "Lim Mei Ling Jessica", "FAMILY_FIRST", "family Lim; Mei Ling the Chinese given name; Jessica the English " "given name", fields={'surname': 'Lim', 'given': 'Mei Ling', 'given2': 'Jessica'}), Convention("Cantonese", "given name that is also a title modifier", "Wing Lau", None, "given Wing, not a title; family Lau", fields={'given': 'Wing', 'surname': 'Lau'}), Convention("Hungarian", "married form on the husband's family name", "Szabóné Kiss Éva", "FAMILY_FIRST", "Szabóné ('wife of Szabó') a married form; family Kiss; given Éva", unstated="no field states a married form on the husband's family name"), Convention("Afrikaans/Dutch", "capitalized particle, no given name", "Van der Walt", None, "family Van der Walt; no given name", fields={'surname-prefix': 'Van der', 'surname-core': 'Walt'}), Convention("Arabic", "kunya ('father of')", "Abu Karim", None, "a kunya, 'father of Karim', used as a name of address; not a given " "name plus a family name", unstated="no field states a kunya"), Convention("Iranian", "-zadeh written as a separate word", "Reza Hossein Zadeh", None, "given Reza; family Hossein Zadeh, the separated spelling of " "Hosseinzadeh", fields={'given': 'Reza', 'surname': 'Hossein Zadeh'}), Convention("French", "index form, elided particle after the given name", "Orval, Pierre d'", None, "family d'Orval; given Pierre; d' belongs to the family", fields={'surname-prefix': "d'", 'surname-core': 'Orval', 'given': 'Pierre'}), ) ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/conventions_agreement.json���������������������0000664�0000000�0000000�00000011465�15273032717�0030001�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������{ "conventions/Ali Kara Oğlu/default": { "family": { "expected": "Kara Oğlu", "got": "Oğlu" }, "given_names": { "expected": "Ali", "got": "Ali Kara" }, "text": "Ali Kara Oğlu" }, "conventions/Carmen Díaz Vda. de Mora/default": { "family": { "expected": "Díaz Vda. de Mora", "got": "de Mora" }, "given_names": { "expected": "Carmen", "got": "Carmen Díaz Vda." }, "text": "Carmen Díaz Vda. de Mora" }, "conventions/Daw Thida/default": { "family": { "expected": "", "got": "Thida" }, "given_names": { "expected": "Thida", "got": "Daw" }, "text": "Daw Thida", "title": { "expected": "Daw", "got": "" } }, "conventions/H. Budi Santoso/default": { "family": { "expected": "", "got": "Santoso" }, "given_names": { "expected": "Budi Santoso", "got": "H. Budi" }, "text": "H. Budi Santoso", "title": { "expected": "H.", "got": "" } }, "conventions/Hassan B. Yusof/default": { "family": { "expected": "", "got": "Yusof" }, "given_names": { "expected": "Hassan B. Yusof", "got": "Hassan B." }, "text": "Hassan B. Yusof" }, "conventions/Hassan bin Yusof/default": { "family": { "expected": "", "got": "bin Yusof" }, "given_names": { "expected": "Hassan bin Yusof", "got": "Hassan" }, "text": "Hassan bin Yusof" }, "conventions/Hodan Farah Warsame/default": { "family": { "expected": "", "got": "Warsame" }, "given_names": { "expected": "Hodan Farah Warsame", "got": "Hodan Farah" }, "text": "Hodan Farah Warsame" }, "conventions/K. Sivakumar/default": { "family": { "expected": "", "got": "Sivakumar" }, "given_names": { "expected": "K. Sivakumar", "got": "K." }, "text": "K. Sivakumar" }, "conventions/Kavitha a/p Raman/default": { "family": { "expected": "", "got": "Raman" }, "given_names": { "expected": "Kavitha a/p Raman", "got": "Kavitha a/p" }, "text": "Kavitha a/p Raman" }, "conventions/Khun Malee/default": { "family": { "expected": "", "got": "Malee" }, "given_names": { "expected": "Malee", "got": "Khun" }, "text": "Khun Malee", "title": { "expected": "Khun", "got": "" } }, "conventions/Ma. Teresa Cruz Bautista/default": { "family": { "expected": "Cruz Bautista", "got": "Bautista" }, "given_names": { "expected": "Ma. Teresa", "got": "Ma. Teresa Cruz" }, "text": "Ma. Teresa Cruz Bautista" }, "conventions/Michael Wong Kar Ming/default": { "family": { "expected": "Wong", "got": "Ming" }, "given_names": { "expected": "Michael Kar Ming", "got": "Michael Wong Kar" }, "text": "Michael Wong Kar Ming" }, "conventions/Murat Bey/default": { "family": { "expected": "", "got": "Bey" }, "suffix": { "expected": "Bey", "got": "" }, "text": "Murat Bey" }, "conventions/Park Ji-hoon ssi/FAMILY_FIRST": { "given_names": { "expected": "Ji-hoon", "got": "Ji-hoon ssi" }, "suffix": { "expected": "ssi", "got": "" }, "text": "Park Ji-hoon ssi" }, "conventions/Pedro López Ruiz H./default": { "family": { "expected": "López Ruiz", "got": "H." }, "given_names": { "expected": "Pedro", "got": "Pedro López Ruiz" }, "suffix": { "expected": "H.", "got": "" }, "text": "Pedro López Ruiz H." }, "conventions/Reza Hossein Zadeh/default": { "family": { "expected": "Hossein Zadeh", "got": "Zadeh" }, "given_names": { "expected": "Reza", "got": "Reza Hossein" }, "text": "Reza Hossein Zadeh" }, "conventions/Suresh a/l Muthu/default": { "family": { "expected": "", "got": "Muthu" }, "given_names": { "expected": "Suresh a/l Muthu", "got": "Suresh a/l" }, "text": "Suresh a/l Muthu" }, "conventions/Suzuki san/FAMILY_FIRST": { "given_names": { "expected": "", "got": "san" }, "suffix": { "expected": "san", "got": "" }, "text": "Suzuki san" }, "conventions/Suzuki-san/FAMILY_FIRST": { "family": { "expected": "Suzuki", "got": "Suzuki-san" }, "suffix": { "expected": "-san", "got": "" }, "text": "Suzuki-san" }, "conventions/Szabó Péter úr/FAMILY_FIRST": { "given_names": { "expected": "Péter", "got": "Péter úr" }, "suffix": { "expected": "úr", "got": "" }, "text": "Szabó Péter úr" }, "conventions/U Kyaw Win/default": { "family": { "expected": "", "got": "Win" }, "given_names": { "expected": "Kyaw Win", "got": "U Kyaw" }, "text": "U Kyaw Win", "title": { "expected": "U", "got": "" } }, "conventions/Van der Walt/default": { "family": { "expected": "Van der Walt", "got": "der Walt" }, "family_particles": { "expected": "Van der", "got": "der" }, "given_names": { "expected": "", "got": "Van" }, "text": "Van der Walt" } } �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/conventions_fields.jsonl�����������������������0000664�0000000�0000000�00000012250�15273032717�0027445�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������{"fields": {"given": "Hodan", "patronymic": "Farah Warsame"}, "file": "conventions", "label": "Hodan Farah Warsame", "order": "default", "shape": null, "text": "Hodan Farah Warsame"} {"fields": {"given": "Harpreet", "given2": "Kaur", "surname": "Dhillon"}, "file": "conventions", "label": "Harpreet Kaur Dhillon", "order": "default", "shape": null, "text": "Harpreet Kaur Dhillon"} {"fields": {"given": "Sivakumar", "patronymic": "K."}, "file": "conventions", "label": "K. Sivakumar", "order": "default", "shape": null, "text": "K. Sivakumar"} {"fields": {"generation": "H.", "given": "Pedro", "surname": "López", "surname2": "Ruiz"}, "file": "conventions", "label": "Pedro López Ruiz H.", "order": "default", "shape": null, "text": "Pedro López Ruiz H."} {"fields": {"given": "Carmen", "surname": "Díaz Vda. de Mora"}, "file": "conventions", "label": "Carmen Díaz Vda. de Mora", "order": "default", "shape": null, "text": "Carmen Díaz Vda. de Mora"} {"fields": {"given": "Ali", "surname": "Kara Oğlu"}, "file": "conventions", "label": "Ali Kara Oğlu", "order": "default", "shape": null, "text": "Ali Kara Oğlu"} {"fields": {"given": "Murat", "honorific": "Bey"}, "file": "conventions", "label": "Murat Bey", "order": "default", "shape": null, "text": "Murat Bey"} {"fields": {"given": "Péter", "honorific": "úr", "surname": "Szabó"}, "file": "conventions", "label": "Szabó Péter úr", "order": "FAMILY_FIRST", "shape": 4, "text": "Szabó Péter úr"} {"fields": {"given": "Péterné", "surname": "Szabó"}, "file": "conventions", "label": "Szabó Péterné", "order": "FAMILY_FIRST", "shape": 4, "text": "Szabó Péterné"} {"fields": {"given": "Min Ho", "surname": "Kim"}, "file": "conventions", "label": "Kim Min Ho", "order": "FAMILY_FIRST", "shape": 4, "text": "Kim Min Ho"} {"fields": {"given": "Mai", "given2": "Thi", "surname": "Tran"}, "file": "conventions", "label": "Tran Thi Mai", "order": "FAMILY_FIRST", "shape": 4, "text": "Tran Thi Mai"} {"fields": {"given": "Ivan", "patronymic": "Petrov", "surname": "Dimitrov"}, "file": "conventions", "label": "Ivan Petrov Dimitrov", "order": "default", "shape": null, "text": "Ivan Petrov Dimitrov"} {"fields": {"given": "Ma. Teresa", "surname": "Bautista", "surname2": "Cruz"}, "file": "conventions", "label": "Ma. Teresa Cruz Bautista", "order": "default", "shape": null, "text": "Ma. Teresa Cruz Bautista"} {"fields": {"given": "Ji-hoon", "honorific": "ssi", "surname": "Park"}, "file": "conventions", "label": "Park Ji-hoon ssi", "order": "FAMILY_FIRST", "shape": 4, "text": "Park Ji-hoon ssi"} {"fields": {"honorific": "san", "surname": "Suzuki"}, "file": "conventions", "label": "Suzuki san", "order": "FAMILY_FIRST", "shape": 4, "text": "Suzuki san"} {"fields": {"honorific": "-san", "surname": "Suzuki"}, "file": "conventions", "label": "Suzuki-san", "order": "FAMILY_FIRST", "shape": 4, "text": "Suzuki-san"} {"fields": {"given": "Kyaw Win", "title": "U"}, "file": "conventions", "label": "U Kyaw Win", "order": "default", "shape": null, "text": "U Kyaw Win"} {"fields": {"given": "Thida", "title": "Daw"}, "file": "conventions", "label": "Daw Thida", "order": "default", "shape": null, "text": "Daw Thida"} {"fields": {"given": "Malee", "title": "Khun"}, "file": "conventions", "label": "Khun Malee", "order": "default", "shape": null, "text": "Khun Malee"} {"fields": {"given": "Hassan", "patronymic": "bin Yusof"}, "file": "conventions", "label": "Hassan bin Yusof", "order": "default", "shape": null, "text": "Hassan bin Yusof"} {"fields": {"given": "Hassan", "patronymic": "B. Yusof"}, "file": "conventions", "label": "Hassan B. Yusof", "order": "default", "shape": null, "text": "Hassan B. Yusof"} {"fields": {"given": "Suresh", "patronymic": "a/l Muthu"}, "file": "conventions", "label": "Suresh a/l Muthu", "order": "default", "shape": null, "text": "Suresh a/l Muthu"} {"fields": {"given": "Kavitha", "patronymic": "a/p Raman"}, "file": "conventions", "label": "Kavitha a/p Raman", "order": "default", "shape": null, "text": "Kavitha a/p Raman"} {"fields": {"given": "Budi Santoso", "title": "H."}, "file": "conventions", "label": "H. Budi Santoso", "order": "default", "shape": null, "text": "H. Budi Santoso"} {"fields": {"given": "Michael", "given2": "Kar Ming", "surname": "Wong"}, "file": "conventions", "label": "Michael Wong Kar Ming", "order": "default", "shape": null, "text": "Michael Wong Kar Ming"} {"fields": {"given": "Mei Ling", "given2": "Jessica", "surname": "Lim"}, "file": "conventions", "label": "Lim Mei Ling Jessica", "order": "FAMILY_FIRST", "shape": 4, "text": "Lim Mei Ling Jessica"} {"fields": {"given": "Wing", "surname": "Lau"}, "file": "conventions", "label": "Wing Lau", "order": "default", "shape": null, "text": "Wing Lau"} {"fields": {"surname-core": "Walt", "surname-prefix": "Van der"}, "file": "conventions", "label": "Van der Walt", "order": "default", "shape": null, "text": "Van der Walt"} {"fields": {"given": "Reza", "surname": "Hossein Zadeh"}, "file": "conventions", "label": "Reza Hossein Zadeh", "order": "default", "shape": null, "text": "Reza Hossein Zadeh"} {"fields": {"given": "Pierre", "surname-core": "Orval", "surname-prefix": "d'"}, "file": "conventions", "label": "Orval, Pierre d'", "order": "default", "shape": null, "text": "Orval, Pierre d'"} ��������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/corpus.jsonl�����������������������������������0000664�0000000�0000000�00000131764�15273032717�0025101�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������{"name": "", "tests": ["test_can_add_global_nickname_delimiter", "test_can_change_global_constants", "test_empty_attribute_default", "test_empty_attribute_on_instance", "test_empty_parses_share_a_comparison_key", "test_initials_all_empty_returns_empty_attribute_default", "test_initials_separator_custom_value", "test_initials_separator_kwarg_multiword_part", "test_instance_owned_constants_not_mutated_by_parsing", "test_instances_can_have_own_constants", "test_is_rootname_consistent_with_is_prefix", "test_is_rootname_consistent_with_is_title", "test_iterating_empty_name_yields_nothing", "test_len", "test_none_empty_attribute_string_formatting"]} {"name": "\"Rick\" Edmonds", "tests": ["test_nickname_and_last_name"]} {"name": "()", "tests": ["test_empty_parses_share_a_comparison_key"]} {"name": ",", "tests": ["test_degenerate_comma_input_leaves_no_empty_pieces", "test_empty_parses_share_a_comparison_key"]} {"name": ", John", "tests": ["test_degenerate_comma_input_leaves_no_empty_pieces"]} {"name": "A.B. Vajpayee", "tests": ["bank:TEST_NAMES"]} {"name": "Abdul Salam Ahmed Salem, MD", "tests": ["test_suffix_comma_join_with_middle"]} {"name": "Abdul Salam Hassan, MD", "tests": ["test_suffix_comma_join"]} {"name": "Abdul Salam, MD", "tests": ["test_suffix_comma_guard_two_tokens_no_join"]} {"name": "Abu Bakr Al Baghdadi, MD", "tests": ["test_suffix_comma_abu_bakr_al_baghdadi"]} {"name": "Adolph D", "tests": ["test_phd_conflict"]} {"name": "Ahmad ben Husain", "tests": ["test_ben_as_conjunction"]} {"name": "Al Arnold Gore, Jr.", "tests": ["test33"]} {"name": "Alex Ben Johnson", "tests": ["test_ben_as_middle_name"]} {"name": "Alex van Johnson", "tests": ["test_initials_with_prefix"]} {"name": "Aliyev Vusal Said oglu", "tests": ["test_reversed_order_unchanged"]} {"name": "Alois von und zu Liechtenstein", "tests": ["test_conjunction_bridges_prefix_chain"]} {"name": "Alois von und zu und von Liechtenstein", "tests": ["test_conjunction_bridges_prefix_chain_with_multiple_conjunctions"]} {"name": "Amy E Maid", "tests": ["test_last_name_is_also_title"]} {"name": "Amy E. Maid", "tests": ["bank:TEST_NAMES"]} {"name": "Andrew", "tests": ["test_first_name"]} {"name": "Andrew Boris Petersen", "tests": ["test_initials", "test_initials_list"]} {"name": "Andrew Perkins \"MBA\"", "tests": ["test_suffix_in_double_quotes"]} {"name": "Andrew Perkins 'MBA'", "tests": ["test_suffix_in_single_quotes"]} {"name": "Andrew Perkins (JD)", "tests": ["test_suffix_acronyms_ambiguous_removal_routes_to_suffix"]} {"name": "Andrew Perkins (M.D)", "tests": ["test_acronym_suffix_with_internal_periods_in_parenthesis"]} {"name": "Andrew Perkins (MBA)", "tests": ["test_acronym_suffix_in_parenthesis"]} {"name": "Andrew Perkins (Mgr.)", "tests": ["test_period_terminated_content_in_parenthesis_not_forced_either_way"]} {"name": "Andrew Perkins (XYZ)", "tests": ["test_suffix_acronyms_ambiguous_custom_entry_stays_nickname"]} {"name": "Andrew Perkins, Jr., Col. (Ret)", "tests": ["test_suffix_in_parenthesis_with_other_suffixes"]} {"name": "Andrews, M.D.", "tests": ["test_assume_suffix_title_and_one_other_name_is_last_name"]} {"name": "Anh Do", "tests": ["test_do_guard_surname_equals_prefix_word"]} {"name": "Annette Charlotte Freiherrin von und zu der Tann-Rathsamhausen", "tests": ["test_conjunction_bridges_prefix_chain_with_leading_title"]} {"name": "Assoc Dean of Chemistry Robert Johns", "tests": ["test_add_multiple_arguments"]} {"name": "Baker (Johnson), Jenny", "tests": ["test_maiden_appears_in_as_dict_via_routing", "test_maiden_name_in_parenthesis_with_comma", "test_maiden_off_by_default_parenthesis_still_routes_to_nickname", "test_routing_same_delimiter_to_both_buckets_nickname_wins"]} {"name": "Baker (Jr.), Jenny", "tests": ["test_suffix_shaped_content_in_maiden_bucket_stays_in_place"]} {"name": "Ben Alex Johnson", "tests": ["test_ben_as_first_name_with_middle_name"]} {"name": "Ben Johnson", "tests": ["test_ben_as_first_name"]} {"name": "Benjamin \"Ben\" Franklin", "tests": ["bank:TEST_NAMES", "test_nickname_in_double_quotes"]} {"name": "Benjamin \"Big Ben\" Franklin", "tests": ["test_two_words_in_quotes"]} {"name": "Benjamin 'Ben' Franklin", "tests": ["test_nickname_in_single_quotes"]} {"name": "Benjamin (Ben) Franklin", "tests": ["bank:TEST_NAMES", "test_nickname_in_parenthesis"]} {"name": "Benjamin (Big Ben) Franklin", "tests": ["test_two_word_nickname_in_parenthesis"]} {"name": "Benjamin [Ben] Franklin", "tests": ["test_overriding_builtin_regex_still_affects_nickname_parsing"]} {"name": "Bob Dole", "tests": ["test_maiden_key_always_in_as_dict"]} {"name": "Brian Andrew O'connor", "tests": ["test_single_quotes_on_first_name_not_treated_as_nickname"]} {"name": "Brian O'connor", "tests": ["bank:TEST_NAMES"]} {"name": "Buca di Beppo", "tests": ["bank:TEST_NAMES", "test127"]} {"name": "Cardinal Secretary of State Hillary Clinton", "tests": ["test_compound_title_with_conjunction"]} {"name": "Chancellor Jane Smith", "tests": ["test_constants_subclass_instance_is_used"]} {"name": "Chang, Andy C I", "tests": ["test_roman_numeral_i_after_single_initial_lastname_comma_format"]} {"name": "Charles van der van der Berg", "tests": ["test_repeated_prefix_chain_van_der"]} {"name": "Chemistry Jane Smith", "tests": ["test_constants_kwarg_elements_are_normalized"]} {"name": "Chief Judge J. Leon Holmes", "tests": ["bank:TEST_NAMES"]} {"name": "Chief Judge Sharon Lovelace Blackburn", "tests": ["bank:TEST_NAMES"]} {"name": "Clarke, Kenneth, Q.C. M.P.", "tests": ["test_suffix_containing_periods_lastname_comma_format"]} {"name": "Coach", "tests": ["test_title_is_title"]} {"name": "Col. (Ret.) Smith", "tests": ["test_suffix_in_parenthesis_with_period"]} {"name": "DE MESNIL", "tests": ["test_leading_non_first_name_prefix_case_insensitive"]} {"name": "DR DOE", "tests": ["test_capitalize_title_and_last_only_no_spurious_tokens"]} {"name": "Dame Mary", "tests": ["test_dame_exception_to_first_name_rule"]} {"name": "Dean Ms Hon Solo", "tests": ["test_chain_multiple_arguments"]} {"name": "Del Toro", "tests": ["test_leading_del_is_unchanged"]} {"name": "Della Reese", "tests": ["test_leading_della_is_unchanged"]} {"name": "Designated Judge David A. Ezra", "tests": ["bank:TEST_NAMES"]} {"name": "Di Caprio", "tests": ["test_leading_di_is_unchanged"]} {"name": "Doctor, Jane E.", "tests": ["bank:TEST_NAMES"]} {"name": "Doe, Dr. John", "tests": ["bank:TEST_NAMES", "test22"]} {"name": "Doe, Dr. John A.", "tests": ["bank:TEST_NAMES", "test28"]} {"name": "Doe, Dr. John A. III", "tests": ["bank:TEST_NAMES", "test30"]} {"name": "Doe, Dr. John A. Jr.", "tests": ["bank:TEST_NAMES", "test29"]} {"name": "Doe, Dr. John A. Kenneth", "tests": ["bank:TEST_NAMES", "test35"]} {"name": "Doe, Dr. John A. Kenneth III", "tests": ["bank:TEST_NAMES", "test37"]} {"name": "Doe, Dr. John A. Kenneth Jr.", "tests": ["bank:TEST_NAMES", "test36"]} {"name": "Doe, Dr. John III", "tests": ["bank:TEST_NAMES", "test24"]} {"name": "Doe, Dr. John P., CLU, CFP, LUTC", "tests": ["test_comparison"]} {"name": "Doe, Dr. John, Jr.", "tests": ["bank:TEST_NAMES", "test23"]} {"name": "Doe, John", "tests": ["bank:TEST_NAMES", "test4"]} {"name": "Doe, John A.", "tests": ["bank:TEST_NAMES", "test10", "test_initials_delimiter_empty_string_kwarg", "test_initials_format_empty_string_kwarg"]} {"name": "Doe, John A. III", "tests": ["bank:TEST_NAMES"]} {"name": "Doe, John A. Kenneth", "tests": ["bank:TEST_NAMES", "test_initials_separator_constants_multi_part_middle", "test_initials_separator_empty_multi_part_middle", "test_initials_separator_kwarg"]} {"name": "Doe, John A. Kenneth III", "tests": ["bank:TEST_NAMES"]} {"name": "Doe, John A. Kenneth, Jr.", "tests": ["bank:TEST_NAMES", "test_initials_complex_name", "test_initials_delimiter", "test_initials_delimiter_constants", "test_initials_format", "test_initials_format_constants", "test_initials_list_complex_name"]} {"name": "Doe, John A., III", "tests": ["test12"]} {"name": "Doe, John A., Jr.", "tests": ["bank:TEST_NAMES", "test11"]} {"name": "Doe, John III", "tests": ["bank:TEST_NAMES", "test6"]} {"name": "Doe, John Msc.Ed.", "tests": ["test_suffix_with_periods_with_lastname_comma"]} {"name": "Doe, John jr., MD", "tests": ["test_suffix_with_double_comma_format"]} {"name": "Doe, John, Jr.", "tests": ["bank:TEST_NAMES", "test5"]} {"name": "Doe, John, MD PhD - FACS Fellow", "tests": ["test_suffix_delimiter_detection_with_multi_word_side"]} {"name": "Doe, John,, Jr.", "tests": ["test_empty_comma_segment_does_not_drop_following_suffix"]} {"name": "Doe, John,, Jr.,, III", "tests": ["test_empty_comma_segment_does_not_drop_following_suffix"]} {"name": "Doe, John. A. Kenneth", "tests": ["test16"]} {"name": "Doe, John. A. Kenneth III", "tests": ["test18"]} {"name": "Doe, John. A. Kenneth, Jr.", "tests": ["test17"]} {"name": "Doe, Lt. Gen. John A. Kenneth IV", "tests": ["bank:TEST_NAMES", "test_two_part_title_with_lastname_comma"]} {"name": "Doe, Lt.Gov. John", "tests": ["test_title_with_periods_lastname_comma"]} {"name": "Doe, Mary - Kate, RN", "tests": ["test_suffix_delimiter_inverted_format_not_misparsed"]} {"name": "Doe, Rev. John A. Jr.", "tests": ["bank:TEST_NAMES", "test126"]} {"name": "Doe, Rev. John A., V, Jr.", "tests": ["test_possible_conflict_with_suffix_that_could_be_initial"]} {"name": "Doe, Rev. John V, Jr.", "tests": ["test_possible_conflict_with_middle_initial_that_could_be_suffix"]} {"name": "Doe,, Jr.", "tests": ["test_degenerate_comma_input_leaves_no_empty_pieces"]} {"name": "Doe-Ray, Dr. John P., CLU, CFP, LUTC", "tests": ["bank:TEST_NAMES", "test113", "test_comparison", "test_comparison_case_insensitive", "test_hash_matches_case_insensitive_equality", "test_len", "test_slice", "test_slice_getitem_deprecation_names_issue"]} {"name": "Doe-Ray, Hon. Barrington P. Jr.", "tests": ["bank:TEST_NAMES"]} {"name": "Doe-Ray, Hon. Barrington P. Jr., CFP, LUTC", "tests": ["bank:TEST_NAMES", "test116"]} {"name": "Donovan McNabb-Smith", "tests": ["bank:TEST_NAMES"]} {"name": "Dr King Jr", "tests": ["test_king"]} {"name": "Dr Martin Luther King, Jr.", "tests": ["test_last_name_is_also_title_with_comma"]} {"name": "Dr. Abdul Salam Hassan, MD", "tests": ["test_suffix_comma_title_kept_prefix_joins"]} {"name": "Dr. John A. Doe", "tests": ["bank:TEST_NAMES", "test25"]} {"name": "Dr. John A. Doe III", "tests": ["bank:TEST_NAMES", "test27"]} {"name": "Dr. John A. Doe, Jr.", "tests": ["bank:TEST_NAMES", "test26", "test_str_fallback_without_string_format"]} {"name": "Dr. John A. Kenneth Doe", "tests": ["bank:TEST_NAMES", "test31"]} {"name": "Dr. John A. Kenneth Doe III", "tests": ["bank:TEST_NAMES", "test34"]} {"name": "Dr. John A. Kenneth Doe, Jr.", "tests": ["bank:TEST_NAMES", "test32", "test_getitem", "test_setitem", "test_setitem_emits_deprecation_warning_naming_attribute_assignment", "test_setitem_invalid_key_raises_keyerror"]} {"name": "Dr. John Doe", "tests": ["bank:TEST_NAMES", "test19"]} {"name": "Dr. John Doe III", "tests": ["bank:TEST_NAMES", "test21"]} {"name": "Dr. John Doe, Jr.", "tests": ["bank:TEST_NAMES", "test20"]} {"name": "Dr. John P. Doe-Ray, CLU", "tests": ["test_name_instance_deepcopy"]} {"name": "Dr. John P. Doe-Ray, CLU, CFP, LUTC", "tests": ["bank:TEST_NAMES", "test112", "test_comparison"]} {"name": "Dr. John Smith", "tests": ["test_known_title_with_period_still_a_title"]} {"name": "Dr. Juan Q. Velasquez y Garcia", "tests": ["bank:TEST_NAMES", "test80"]} {"name": "Dr. Juan Q. Velasquez y Garcia III", "tests": ["bank:TEST_NAMES", "test82"]} {"name": "Dr. Juan Q. Velasquez y Garcia, Jr.", "tests": ["bank:TEST_NAMES", "test81"]} {"name": "Dr. Juan Q. Xavier Velasquez y Garcia", "tests": ["bank:TEST_NAMES", "test104"]} {"name": "Dr. Juan Q. Xavier Velasquez y Garcia III", "tests": ["bank:TEST_NAMES", "test106"]} {"name": "Dr. Juan Q. Xavier Velasquez y Garcia, Jr.", "tests": ["bank:TEST_NAMES", "test105"]} {"name": "Dr. Juan Q. Xavier de la Vega", "tests": ["bank:TEST_NAMES", "test92"]} {"name": "Dr. Juan Q. Xavier de la Vega III", "tests": ["bank:TEST_NAMES", "test94", "test_comparison_key_case_insensitive_across_formats", "test_comparison_key_components", "test_matches_str_is_semantic_not_textual"]} {"name": "Dr. Juan Q. Xavier de la Vega, Jr.", "tests": ["bank:TEST_NAMES", "test93"]} {"name": "Dr. Juan Q. Xavier de la dos Vega III", "tests": ["test_three_conjunctions"]} {"name": "Dr. Juan Q. Xavier de la dos Vega, III", "tests": ["test_comma_three_conjunctions"]} {"name": "Dr. Juan Q. de la Vega", "tests": ["bank:TEST_NAMES", "test74"]} {"name": "Dr. Juan Q. de la Vega III", "tests": ["bank:TEST_NAMES", "test76"]} {"name": "Dr. Juan Q. de la Vega, Jr.", "tests": ["bank:TEST_NAMES", "test75"]} {"name": "Dr. Juan Velasquez y Garcia", "tests": ["bank:TEST_NAMES", "test56"]} {"name": "Dr. Juan Velasquez y Garcia III", "tests": ["bank:TEST_NAMES", "test58"]} {"name": "Dr. Juan Velasquez y Garcia, Jr.", "tests": ["bank:TEST_NAMES", "test57"]} {"name": "Dr. Juan de la Vega", "tests": ["bank:TEST_NAMES", "test50"]} {"name": "Dr. Juan de la Vega III", "tests": ["bank:TEST_NAMES", "test52"]} {"name": "Dr. Juan de la Vega, Jr.", "tests": ["bank:TEST_NAMES", "test51"]} {"name": "Dr. Martin Luther King Jr.", "tests": ["test_last_name_is_also_title_no_comma"]} {"name": "Dr. Williams", "tests": ["test_given_names_attribute_empty"]} {"name": "Dr. abdul salam ahmed salem", "tests": ["test_title_kept_prefix_joins"]} {"name": "Dra. Andréia da Silva", "tests": ["bank:TEST_NAMES", "test_da_silva_title_plus_prefix"]} {"name": "E.T. Smith", "tests": ["bank:TEST_NAMES", "test_leading_internal_period_abbreviation_excluded", "test_two_initials_conflict_with_conjunction"]} {"name": "E.T. Smith, II", "tests": ["bank:TEST_NAMES"]} {"name": "Esq Jane Smith", "tests": ["test_set_manager_operators_accept_lists"]} {"name": "Foo. John Smith", "tests": ["test_leading_period_abbreviation_unknown_word"]} {"name": "Foo. Xyz. John Smith", "tests": ["test_leading_period_abbreviation_chained"]} {"name": "Franklin Washington, Jr. MD", "tests": ["test_two_suffixes_suffix_comma_format"]} {"name": "Franklin, Benjamin (Ben)", "tests": ["test_nickname_in_parenthesis_with_comma"]} {"name": "Franklin, Benjamin (Ben), Jr.", "tests": ["test_nickname_in_parenthesis_with_comma_and_suffix"]} {"name": "Frau Anna Müller", "tests": ["bank:TEST_NAMES"]} {"name": "Fritz Freiherr und von Bar", "tests": ["test_conjunction_bridges_word_that_is_both_title_and_prefix"]} {"name": "Frøken Jensen", "tests": ["test_frøken_non_ascii_title"]} {"name": "GREGORY HOUSE M.D.", "tests": ["test_capitalize_suffix_acronym_with_dots"]} {"name": "Gunny de Mesnil", "tests": ["test_non_first_name_prefix_with_custom_title"]} {"name": "Harietta Keopuolani Nahi'ena'ena", "tests": ["test_single_quotes_not_treated_as_nickname_Hawaiian_example"]} {"name": "Harrieta Keōpūolani Nāhiʻenaʻena", "tests": ["test_okina_inside_name_not_treated_as_nickname"]} {"name": "Her Majesty Queen Elizabeth", "tests": ["test_conjunction_in_an_address_with_a_first_name_title"]} {"name": "Herr Klaus Schmidt", "tests": ["bank:TEST_NAMES", "test_herr_title_with_first_name"]} {"name": "Herr Schmidt", "tests": ["test_herr_title_not_first_name"]} {"name": "His Excellency Lord Duncan", "tests": ["test_conjunction_in_an_address_with_a_title"]} {"name": "Hon Solo", "tests": ["test_remove_title"]} {"name": "Hon. Barrington P. Doe-Ray, Jr.", "tests": ["bank:TEST_NAMES", "test115"]} {"name": "Hon. Charles J. Siragusa", "tests": ["bank:TEST_NAMES"]} {"name": "Hon. Marian W. Payson", "tests": ["bank:TEST_NAMES"]} {"name": "Honorable Judge Susan Russ Walker", "tests": ["bank:TEST_NAMES"]} {"name": "Honorable Judge Terry F. Moorer", "tests": ["bank:TEST_NAMES"]} {"name": "Honorable Judge W. Harold Albritton, III", "tests": ["bank:TEST_NAMES"]} {"name": "Honorable Terry F. Moorer", "tests": ["bank:TEST_NAMES"]} {"name": "Honorable W. Harold Albritton, III", "tests": ["bank:TEST_NAMES"]} {"name": "Ivanov Ivan Ivanovich", "tests": ["test_canonical_unchanged"]} {"name": "J. Smith", "tests": ["test_leading_single_letter_initial_excluded"]} {"name": "JEFFREY (JD) BRICKEN", "tests": ["test_ambiguous_suffix_acronym_in_parenthesis_stays_nickname"]} {"name": "JOHN DOE", "tests": ["test_capitalize_empty_suffix_produces_no_spurious_tokens"]} {"name": "JOHN DOE PHD", "tests": ["test_capitalize_single_suffix_still_works"]} {"name": "JOHN DOE PHD MD", "tests": ["test_capitalize_multiple_suffixes_still_split_correctly"]} {"name": "JOHN SMITH", "tests": ["bank:names", "test_comparison_key_usable_for_dedup", "test_matches_humanname_operand"]} {"name": "JOSÉ GARCÍA", "tests": ["test_matches_non_ascii_case_insensitive"]} {"name": "Jack Ma", "tests": ["test_potential_suffix_that_is_also_last_name"]} {"name": "Jack Ma Jr", "tests": ["test_potential_suffix_that_is_also_last_name_with_suffix"]} {"name": "Jane Doctor", "tests": ["bank:TEST_NAMES", "test_last_name_also_prefix"]} {"name": "Jane Doe", "tests": ["test_bytes_full_name_emits_deprecation_warning", "test_capitalize_empty_attributes_produce_no_spurious_tokens", "test_capitalize_force_empty_middle_produces_no_leading_space_in_surnames"]} {"name": "Jane Mac Beth", "tests": ["test_mac_with_spaces"]} {"name": "Jane Smith", "tests": ["bank:names", "test_comparison_key_usable_for_dedup", "test_matches_humanname_operand", "test_not_equal_operator"]} {"name": "Jean de Mesnil", "tests": ["test_non_leading_prefix_is_unchanged"]} {"name": "Jenny \"JJ\" Baker (Johnson)", "tests": ["test_quotes_still_nickname_when_parens_routed_to_maiden"]} {"name": "Jenny (Johnson) Baker", "tests": ["test_unresolvable_string_sentinel_raises"]} {"name": "Jenny Baker", "tests": ["test_maiden_appears_in_as_dict_when_populated", "test_maiden_appears_in_slice", "test_maiden_assignment_and_property", "test_maiden_defaults_empty"]} {"name": "Jenny Baker (Johnson)", "tests": ["test_maiden_name_in_parenthesis_no_comma"]} {"name": "Jill St. John", "tests": ["test_prefix_with_period"]} {"name": "Joao da Silva do Amaral de Souza", "tests": ["test_portuguese_prefixes"]} {"name": "Joe Dentist D.D.S.", "tests": ["test_suffix_with_periods"]} {"name": "Joe Franklin Jr", "tests": ["test_suffix"]} {"name": "John & Jane", "tests": ["test_ampersand_conjunction_short_name_no_titles"]} {"name": "John A. Doe", "tests": ["bank:TEST_NAMES", "test7"]} {"name": "John A. Doe III", "tests": ["bank:TEST_NAMES", "test9"]} {"name": "John A. Doe, Jr", "tests": ["test8"]} {"name": "John A. Doe, Jr.", "tests": ["bank:TEST_NAMES"]} {"name": "John A. Kenneth Doe", "tests": ["bank:TEST_NAMES", "test13"]} {"name": "John A. Kenneth Doe III", "tests": ["bank:TEST_NAMES", "test15"]} {"name": "John A. Kenneth Doe, Jr.", "tests": ["bank:TEST_NAMES", "test14", "test_assign_list_to_attribute", "test_assignment_to_attribute", "test_assignment_to_full_name"]} {"name": "John Doe", "tests": ["bank:TEST_NAMES", "test1", "test_assigning_constants_class_after_construction_raises_with_hint", "test_assigning_invalid_constants_after_construction_raises", "test_assigning_none_to_constants_after_construction_builds_new_instance", "test_assignment_filters_empty_tokens", "test_constants_class_instead_of_instance_raises_with_hint", "test_constants_invalid_type_raises_typeerror", "test_deepcopy_default_name_preserves_singleton_identity", "test_explicit_none_still_produces_a_working_private_config", "test_explicit_none_warning_names_both_replacements", "test_explicit_none_warns_on_c_setter", "test_explicit_none_warns_on_construction", "test_explicit_none_warns_on_positional_argument", "test_explicit_own_constants_instance_does_not_warn", "test_initials_empty_part_with_none_default_not_literal_none", "test_initials_simple_name", "test_instance_is_not_its_own_iterator", "test_iteration_restarts_after_break", "test_iterators_are_independent", "test_len", "test_len_during_iteration", "test_omitted_constants_argument_does_not_warn", "test_pickle_default_name_preserves_singleton_identity", "test_shallow_copy_default_name_preserves_singleton_identity", "test_str_default_behavior_unchanged", "test_string_format_empty_string_kwarg"]} {"name": "John Doe III", "tests": ["bank:TEST_NAMES", "test3"]} {"name": "John Doe MD PhD", "tests": ["test_suffix_delimiter_no_effect_without_comma"]} {"name": "John Doe Msc.Ed.", "tests": ["test_multiple_letter_suffix_with_periods"]} {"name": "John Doe jr., MD", "tests": ["test_suffix_with_single_comma_format"]} {"name": "John Doe, CLU, CFP, LUTC", "tests": ["bank:TEST_NAMES", "test110"]} {"name": "John Doe, Jr.", "tests": ["bank:TEST_NAMES", "test2"]} {"name": "John Doe, Jr.,,", "tests": ["test_suffix_comma_empty_segment_not_added_to_suffix_list"]} {"name": "John Doe, MD - PhD - FACS", "tests": ["test_suffix_delimiter_multiple"]} {"name": "John Doe, MD - PhD, FACS", "tests": ["test_suffix_delimiter_expands_each_comma_segment"]} {"name": "John Doe, MD, PhD", "tests": ["test_suffix_delimiter_comma_space_is_noop"]} {"name": "John Doe, MD-PhD-", "tests": ["test_suffix_delimiter_trailing_delimiter_ignored"]} {"name": "John Doe, Msc.Ed.", "tests": ["test_suffix_with_periods_with_comma"]} {"name": "John E Smith", "tests": ["bank:TEST_NAMES", "test_uppercase_middle_initial_conflict_with_conjunction"]} {"name": "John Edgar Casey Williams III", "tests": ["test_given_names_attribute", "test_given_names_list_attribute", "test_surnames_attribute", "test_surnames_list_attribute"]} {"name": "John Jones (Google Docs)", "tests": ["bank:TEST_NAMES"]} {"name": "John Jones (Google Docs), Jr. (Unknown)", "tests": ["test_duplicate_parenthesis_are_removed_from_name"]} {"name": "John Jones (Unknown)", "tests": ["test_parenthesis_are_removed_from_name"]} {"name": "John King", "tests": ["test_last_name_is_also_title3"]} {"name": "John Major. Smith", "tests": ["test_period_abbreviation_after_first_name_stays_middle"]} {"name": "John P. Doe, CLU, CFP, LUTC", "tests": ["bank:TEST_NAMES", "test111"]} {"name": "John Q. Smith", "tests": ["test_middle_initial_with_period_unaffected"]} {"name": "John Smith", "tests": ["bank:names", "test_comparison_key_independent_of_string_format", "test_comparison_key_usable_for_dedup", "test_empty_field_drops_surrounding_whitespace", "test_eq_emits_deprecation_warning", "test_hash_emits_deprecation_warning", "test_hash_matches_case_insensitive_equality", "test_matches_humanname_operand", "test_matches_rejects_other_types", "test_multiple_empty_fields_before_comma", "test_new_comparison_api_does_not_warn", "test_no_prefix", "test_not_equal_operator", "test_str_full_name_does_not_warn", "test_unparsable_attribute_removed"]} {"name": "John Smith Jr", "tests": ["test_empty_field_present_suffix_unaffected"]} {"name": "John Smith Ph. D.", "tests": ["test_phd_extracted_without_comma"]} {"name": "John Smith VI", "tests": ["test_roman_numeral_suffix_not_in_suffix_list"]} {"name": "John Smith, Ph. D.", "tests": ["test_phd_with_erroneous_space"]} {"name": "John Smith, V Jr.", "tests": ["test_two_suffix_not_acronyms_suffix_comma_format"]} {"name": "John Smith, V MD", "tests": ["test_suffix_not_acronym_then_acronym_suffix_comma_format"]} {"name": "John W. Ingram, V", "tests": ["test_roman_numeral_v_suffix_comma_format"]} {"name": "John W. Smith, I", "tests": ["test_roman_numeral_i_suffix_comma_format"]} {"name": "John Williams", "tests": ["test_get_full_name_attribute_references_internal_lists", "test_given_names_attribute_first_only"]} {"name": "John and Jane Aznar y Lopez", "tests": ["test_couples_names_with_conjunction_lastname"]} {"name": "John and Jane Smith", "tests": ["bank:TEST_NAMES", "test_couples_names"]} {"name": "John e Smith", "tests": ["bank:TEST_NAMES", "test_lowercase_middle_initial_conflict_with_conjunction"]} {"name": "John e Smith III", "tests": ["test_lowercase_middle_initial_and_nocomma_suffix_conflict_with_conjunction"]} {"name": "John e Smith, III", "tests": ["test_lowercase_middle_initial_and_suffix_conflict_with_conjunction"]} {"name": "John y Jane", "tests": ["test_single_char_alpha_conjunction_still_treated_as_initial_when_short"]} {"name": "Jon Dough and", "tests": ["test_ends_with_conjunction"]} {"name": "Jon Dough and of", "tests": ["test_ends_with_two_conjunctions"]} {"name": "Jose Aznar y Lopez", "tests": ["bank:TEST_NAMES", "test_last_name_with_conjunction"]} {"name": "Juan Q. Velasquez y Garcia", "tests": ["bank:TEST_NAMES", "test68"]} {"name": "Juan Q. Velasquez y Garcia III", "tests": ["bank:TEST_NAMES", "test70"]} {"name": "Juan Q. Velasquez y Garcia, Jr.", "tests": ["bank:TEST_NAMES", "test69"]} {"name": "Juan Q. Xavier Velasquez y Garcia", "tests": ["bank:TEST_NAMES", "test98"]} {"name": "Juan Q. Xavier Velasquez y Garcia III", "tests": ["bank:TEST_NAMES", "test100"]} {"name": "Juan Q. Xavier Velasquez y Garcia, Jr.", "tests": ["bank:TEST_NAMES", "test99"]} {"name": "Juan Q. Xavier de la Vega", "tests": ["bank:TEST_NAMES", "test86"]} {"name": "Juan Q. Xavier de la Vega III", "tests": ["bank:TEST_NAMES", "test88"]} {"name": "Juan Q. Xavier de la Vega, Jr.", "tests": ["bank:TEST_NAMES", "test87"]} {"name": "Juan Q. de la Vega", "tests": ["bank:TEST_NAMES", "test62"]} {"name": "Juan Q. de la Vega III", "tests": ["bank:TEST_NAMES", "test64"]} {"name": "Juan Q. de la Vega, Jr.", "tests": ["bank:TEST_NAMES", "test63"]} {"name": "Juan Velasquez y Garcia", "tests": ["bank:TEST_NAMES", "test44"]} {"name": "Juan Velasquez y Garcia III", "tests": ["bank:TEST_NAMES", "test46"]} {"name": "Juan Velasquez y Garcia, Jr.", "tests": ["bank:TEST_NAMES", "test45"]} {"name": "Juan de la Vega", "tests": ["bank:TEST_NAMES", "test38", "test_de_la_vega_multiword_prefix"]} {"name": "Juan de la Vega III", "tests": ["bank:TEST_NAMES", "test40"]} {"name": "Juan de la Vega, Jr.", "tests": ["bank:TEST_NAMES", "test39"]} {"name": "Juan de la de la Vega", "tests": ["test_repeated_prefix_chain_de_la"]} {"name": "Juan de la de la Vega Jr.", "tests": ["test_repeated_prefix_chain_followed_by_suffix"]} {"name": "Juan de la de la de la Vega", "tests": ["test_triple_repeated_prefix_chain"]} {"name": "Juan del Sur", "tests": ["test_prefix"]} {"name": "Judge C Lynwood Smith, Jr", "tests": ["bank:TEST_NAMES"]} {"name": "Judge G. Thomas Eisele", "tests": ["bank:TEST_NAMES"]} {"name": "Judge James M. Moody", "tests": ["bank:TEST_NAMES"]} {"name": "Kenneth Clarke Q.C.", "tests": ["test_suffix_containing_periods"]} {"name": "Kenneth Clarke Q.C., M.P.", "tests": ["test_suffix_containing_periods_suffix_comma_format"]} {"name": "Kenneth Clarke QC MP", "tests": ["test_two_suffixes"]} {"name": "King Henry", "tests": ["test_king_exception_to_first_name_rule"]} {"name": "King John Alexander V", "tests": ["test_title_with_three_part_name_last_initial_is_suffix_uppercase_no_period"]} {"name": "King John V.", "tests": ["test_title_with_last_initial_is_suffix"]} {"name": "LT. GEN. JOHN A. KENNETH DOE IV", "tests": ["test_capitalize_title_to_lower"]} {"name": "La'tanya O'connor", "tests": ["test_single_quotes_on_both_name_not_treated_as_nickname"]} {"name": "Larry James Johnson I", "tests": ["test_four_name_parts_with_suffix_that_could_be_initial_uppercase_no_period"]} {"name": "Larry V I", "tests": ["test_roman_numeral_initials"]} {"name": "Lon (Jr.) Williams", "tests": ["test_suffix_in_parenthesis_mid_name"]} {"name": "Lord God Almighty", "tests": ["test119"]} {"name": "Lord and of the Universe", "tests": ["test_triple_conjunction_on_title"]} {"name": "Lord of the Universe", "tests": ["test_double_conjunction_on_title"]} {"name": "Lord of the Universe and Associate Supreme Queen of the World Lisa Simpson", "tests": ["test_multiple_conjunctions_on_multiple_titles"]} {"name": "Lord of the Universe and Supreme King of the World Lisa Simpson", "tests": ["bank:TEST_NAMES"]} {"name": "Lt. Gen. John A. Kenneth Doe IV", "tests": ["bank:TEST_NAMES", "test_two_part_title"]} {"name": "Lt. Gen. John A. Kenneth Doe, Jr.", "tests": ["bank:TEST_NAMES", "test_two_part_title_with_suffix_comma"]} {"name": "Lt.Gen. John A. Kenneth Doe IV", "tests": ["test_two_title_parts_separated_by_periods"]} {"name": "Lt.Gov. John Doe", "tests": ["test_derivations_reset_between_parses_of_same_instance", "test_title_with_periods"]} {"name": "Lt.Gov. juan e garcia", "tests": ["test_title_with_periods_and_single_letter_middle_name"]} {"name": "Ma III, Jack Jr", "tests": ["test_potential_suffix_that_is_also_last_name_with_suffix_comma"]} {"name": "Ma, Jack", "tests": ["test_potential_suffix_that_is_also_last_name_comma"]} {"name": "Mac Miller", "tests": ["test_mac_as_first_name"]} {"name": "Mag-Judge Harwell G Davis, III", "tests": ["bank:TEST_NAMES", "test_chained_hyphenated_title_with_comma_suffix"]} {"name": "Mag. Judge Byron G. Cudmore", "tests": ["bank:TEST_NAMES"]} {"name": "Magistrate Judge John F. Forster, Jr", "tests": ["bank:TEST_NAMES"]} {"name": "Magistrate-Judge Elizabeth Todd Campbell", "tests": ["bank:TEST_NAMES"]} {"name": "Maid Marion", "tests": ["bank:TEST_NAMES"]} {"name": "Maier, Amy I, Jr.", "tests": ["test_roman_numeral_i_with_explicit_suffix_comma_known_limitation"]} {"name": "Maier, Amy Lauren I", "tests": ["test_roman_numeral_i_lastname_comma_format"]} {"name": "Major. Dona Smith", "tests": ["test_leading_period_abbreviation_is_title"]} {"name": "Major. John Smith, Jr.", "tests": ["test_leading_period_abbreviation_suffix_comma"]} {"name": "Mari' Aube'", "tests": ["test_single_quotes_on_end_of_last_name_not_treated_as_nickname"]} {"name": "Mevrouw Anna de Vries", "tests": ["bank:TEST_NAMES"]} {"name": "Mike van der Velt", "tests": ["test_multiple_prefixes"]} {"name": "Mohamad Ahmad Ali Hassan", "tests": ["test_default_constants_unaffected"]} {"name": "Mohamad Ali Khalil", "tests": ["test_mohamad_unchanged"]} {"name": "Monsieur Jean Dupont", "tests": ["bank:TEST_NAMES"]} {"name": "Mr. & Mrs. John Smith", "tests": ["test_couple_titles_ampersand_conjunction"]} {"name": "Mr. Van Nguyen", "tests": ["test_first_name_is_prefix_if_three_parts"]} {"name": "Mr. and Mrs. John Smith", "tests": ["bank:TEST_NAMES"]} {"name": "Mr. and Mrs. John and Jane Smith", "tests": ["test_couple_titles"]} {"name": "Ms Hon Solo", "tests": ["test_clear_removes_all_entries", "test_remove_multiple_arguments"]} {"name": "Naomi Wambui Ng'ang'a", "tests": ["test_single_quotes_not_treated_as_nickname_Kenyan_example"]} {"name": "Nguyen, Van", "tests": ["test_first_name_is_not_prefix_if_only_two_parts_comma"]} {"name": "No1. John Smith", "tests": ["test_leading_period_abbreviation_excludes_digits"]} {"name": "None Smith", "tests": ["test_name_none_as_literal_name_with_none_empty_attribute_default"]} {"name": "Nonez Smith", "tests": ["test_name_containing_none_substring_with_none_empty_attribute_default"]} {"name": "O'B. John Smith", "tests": ["test_leading_period_abbreviation_excludes_apostrophe"]} {"name": "Q R", "tests": ["bank:<unnamed>"]} {"name": "Queen Elizabeth", "tests": ["test_queen_exception_to_first_name_rule"]} {"name": "RONALD MACDONALD", "tests": ["test_downcasing_mac"]} {"name": "RONALD MCDONALD", "tests": ["test_downcasing_mc"]} {"name": "Rafael Sousa dos Anjos", "tests": ["test_portuguese_dos"]} {"name": "Rev Andrews", "tests": ["test_assume_title_and_one_other_name_is_last_name"]} {"name": "Rev John A. Kenneth Doe", "tests": ["bank:TEST_NAMES", "test125"]} {"name": "Rev John A. Kenneth Doe III (Kenny)", "tests": ["test_formating_of_nicknames_in_middle", "test_formating_of_nicknames_with_double_quotes", "test_formating_of_nicknames_with_parenthesis", "test_formating_of_nicknames_with_single_quotes", "test_formating_removing_keys_from_format_string", "test_formating_removing_pieces_from_name_buckets", "test_formatting_constants_attribute", "test_formatting_init_argument", "test_quote_nickname_formating"]} {"name": "Rev. John A. Kenneth Doe", "tests": ["bank:TEST_NAMES", "test124"]} {"name": "Rt. Hon. Paul E. Mary", "tests": ["bank:TEST_NAMES", "test117"]} {"name": "Sam Smith 😊", "tests": ["test_remove_emojis"]} {"name": "Secretary of State Hillary Clinton", "tests": ["test_title_with_conjunction"]} {"name": "Senator \"Rick\" Edmonds", "tests": ["test_nickname_and_last_name_with_title"]} {"name": "Senior Judge Charles R. Butler, Jr", "tests": ["bank:TEST_NAMES"]} {"name": "Senior Judge Harold D. Vietor", "tests": ["bank:TEST_NAMES"]} {"name": "Senior Judge Virgil Pittman", "tests": ["bank:TEST_NAMES"]} {"name": "Señor Carlos García", "tests": ["bank:TEST_NAMES"]} {"name": "Señora María García", "tests": ["bank:TEST_NAMES", "test_señora_non_ascii_title"]} {"name": "Shirley Maclaine", "tests": ["test_capitalize_name_and_force_mixed_case_capitalization_constants_attributes", "test_force_capitalization", "test_force_mixed_case_capitalization_constants_attribute", "test_no_change_to_mixed_chase"]} {"name": "Signor Marco Rossi", "tests": ["bank:TEST_NAMES"]} {"name": "Sir Gerald", "tests": ["bank:TEST_NAMES", "test_sir_exception_to_first_name_rule"]} {"name": "Smith Jr., John", "tests": ["test_suffix_in_lastname_part_of_lastname_comma_format"]} {"name": "Smith van der", "tests": ["test_all_particles_guard"]} {"name": "Smith, Dr. John", "tests": ["test_name_instance_deepcopy_isolates_instance_config", "test_name_instance_pickle_preserves_instance_config", "test_pickle_instance_config_name_preserves_own_config"]} {"name": "Smith, E.T., Jr.", "tests": ["bank:TEST_NAMES"]} {"name": "Smith, J.R.", "tests": ["test_initials_also_suffix"]} {"name": "Smith, John", "tests": ["bank:names", "test_comparison_key_usable_for_dedup", "test_custom_regexes_without_commas_key_does_not_shatter_name"]} {"name": "Smith, John I", "tests": ["test_roman_numeral_i_no_middle_lastname_comma_format"]} {"name": "Smith, John V", "tests": ["test_roman_numeral_v_lastname_comma_format"]} {"name": "Smith, John e, III, Jr", "tests": ["test_lowercase_middle_initial_comma_lastname_and_suffix_conflict_with_conjunction"]} {"name": "Smith, MD - PhD - FACS", "tests": ["test_suffix_delimiter_no_effect_when_not_suffix_comma"]} {"name": "Smith, Major. John", "tests": ["test_leading_period_abbreviation_lastname_comma"]} {"name": "Smith,John", "tests": ["test_fullwidth_comma_splits_lastname_format"]} {"name": "Sr US District Judge Richard G Kopf", "tests": ["bank:TEST_NAMES"]} {"name": "Srta. Andréia da Silva", "tests": ["bank:TEST_NAMES"]} {"name": "Steven Hardman, RN - CRNA", "tests": ["test_suffix_delimiter_basic", "test_suffix_delimiter_constants_level", "test_suffix_delimiter_kwarg_accepted", "test_suffix_delimiter_none_by_default_known_limitation"]} {"name": "Te Awanui-a-Rangi Black", "tests": ["test_add_title"]} {"name": "The Lord of the Universe", "tests": ["test_conjunction_before_title"]} {"name": "The Right Hon. the President of the Queen's Bench Division", "tests": ["test_title_multiple_titles_with_apostrophe_s"]} {"name": "The Rt Hon John Jones", "tests": ["test_title_starts_with_conjunction"]} {"name": "Title First Middle Middle Last, Jr.", "tests": ["test_name_instance_pickle"]} {"name": "U. S. Grant", "tests": ["test_conflict_with_chained_title_first_name_initial"]} {"name": "U.S. District Judge Marc Thomas Treadwell", "tests": ["bank:TEST_NAMES", "test_chained_title_first_name_title_is_initials"]} {"name": "US Magistrate Judge T Michael Putnam", "tests": ["bank:TEST_NAMES", "test_chained_title_first_name_initial_with_no_period"]} {"name": "US Magistrate-Judge Elizabeth E Campbell", "tests": ["test_chained_hyphenated_title"]} {"name": "VINCENT VAN GOGH", "tests": ["test_case_insensitive_prefix_detection"]} {"name": "Va'apu'u Vitale", "tests": ["test_single_quotes_not_treated_as_nickname_Samoan_example"]} {"name": "Van Jeremy Johnson", "tests": ["test_initials_with_prefix_firstname", "test_prefix_is_first_name_with_middle_name"]} {"name": "Van Johnson", "tests": ["test_leading_van_is_unchanged", "test_prefix_is_first_name"]} {"name": "Van Nguyen", "tests": ["test_first_name_is_not_prefix_if_only_two_parts"]} {"name": "Vega, Juan de la", "tests": ["test_initials_middle_name_all_prefixes"]} {"name": "Velasquez y Garcia, Dr. Juan", "tests": ["bank:TEST_NAMES", "test59"]} {"name": "Velasquez y Garcia, Dr. Juan III", "tests": ["bank:TEST_NAMES", "test61"]} {"name": "Velasquez y Garcia, Dr. Juan Q.", "tests": ["bank:TEST_NAMES", "test83"]} {"name": "Velasquez y Garcia, Dr. Juan Q. III", "tests": ["bank:TEST_NAMES", "test85"]} {"name": "Velasquez y Garcia, Dr. Juan Q. Xavier", "tests": ["bank:TEST_NAMES", "test107"]} {"name": "Velasquez y Garcia, Dr. Juan Q. Xavier III", "tests": ["bank:TEST_NAMES", "test109"]} {"name": "Velasquez y Garcia, Dr. Juan Q. Xavier, Jr.", "tests": ["bank:TEST_NAMES", "test108"]} {"name": "Velasquez y Garcia, Dr. Juan Q., Jr.", "tests": ["bank:TEST_NAMES", "test84"]} {"name": "Velasquez y Garcia, Dr. Juan, Jr.", "tests": ["bank:TEST_NAMES", "test60"]} {"name": "Velasquez y Garcia, Juan", "tests": ["bank:TEST_NAMES", "test47"]} {"name": "Velasquez y Garcia, Juan III", "tests": ["bank:TEST_NAMES", "test49"]} {"name": "Velasquez y Garcia, Juan Q.", "tests": ["bank:TEST_NAMES", "test71"]} {"name": "Velasquez y Garcia, Juan Q. III", "tests": ["bank:TEST_NAMES", "test73"]} {"name": "Velasquez y Garcia, Juan Q. Xavier", "tests": ["bank:TEST_NAMES", "test101"]} {"name": "Velasquez y Garcia, Juan Q. Xavier III", "tests": ["bank:TEST_NAMES", "test103"]} {"name": "Velasquez y Garcia, Juan Q. Xavier, Jr.", "tests": ["bank:TEST_NAMES", "test102"]} {"name": "Velasquez y Garcia, Juan Q., Jr.", "tests": ["bank:TEST_NAMES", "test72"]} {"name": "Velasquez y Garcia, Juan, Jr.", "tests": ["bank:TEST_NAMES", "test48"]} {"name": "Vincent van Gogh", "tests": ["test_van_gogh_last_base", "test_van_gogh_last_base_list", "test_van_gogh_last_prefixes", "test_van_gogh_last_prefixes_list"]} {"name": "Vincent van Gogh van Beethoven", "tests": ["test_2_same_prefixes_in_the_name"]} {"name": "Washington Jr. MD, Franklin", "tests": ["test_two_suffixes_lastname_comma_format"]} {"name": "Xyz. (Bud) Smith", "tests": ["test_leading_period_abbreviation_with_nickname"]} {"name": "Yin Le", "tests": ["test_le_as_last_name"]} {"name": "Yin a Le", "tests": ["test_le_as_last_name_with_middle_initial"]} {"name": "Zephyrmark Jane Smith", "tests": ["test_matches_humanname_operand_keeps_its_own_parse", "test_matches_parses_str_with_instance_constants"]} {"name": "abdul", "tests": ["test_prefix_alone_no_join"]} {"name": "abdul salam", "tests": ["test_no_comma_guard_two_tokens_no_join"]} {"name": "abdul salam ahmed salem", "tests": ["test_no_comma_basic_join", "test_opt_out_via_clear"]} {"name": "abdul salam ahmed salem jr", "tests": ["test_suffix_kept_prefix_joins"]} {"name": "abdul salam jr", "tests": ["test_no_comma_guard_suffix_not_swallowed"]} {"name": "abdul salam salem", "tests": ["test_no_comma_three_tokens_no_middle"]} {"name": "abdulsalam ahmed salem", "tests": ["test_single_token_already_joined_unchanged"]} {"name": "abu bakr al baghdadi", "tests": ["test_abu_bakr_al_baghdadi"]} {"name": "ahmed abu bakr", "tests": ["test_mid_name_prefix_becomes_last_prefix"]} {"name": "and Jon Dough", "tests": ["test_starts_with_conjunction"]} {"name": "and van Buren", "tests": ["test_conjunction_prefix_merge_at_start_stays_first_name"]} {"name": "bob v. de la macdole-eisenhower phd", "tests": ["test_capitalize_name_constants_attribute"]} {"name": "de", "tests": ["test_bare_non_first_name_prefix_guard"]} {"name": "de Mesnil", "tests": ["test_leading_non_first_name_prefix_de", "test_leading_non_first_name_prefix_derived_props", "test_leading_non_first_name_prefix_with_patronymic_name_order"]} {"name": "de Mesnil Garcia", "tests": ["test_leading_non_first_name_prefix_with_middle_name_as_last"]} {"name": "de Mesnil Jr.", "tests": ["test_leading_non_first_name_prefix_with_suffix"]} {"name": "de la Vega", "tests": ["test_leading_non_first_name_prefix_chain"]} {"name": "de la Vega, Dr. Juan", "tests": ["bank:TEST_NAMES", "test53"]} {"name": "de la Vega, Dr. Juan III", "tests": ["bank:TEST_NAMES", "test55"]} {"name": "de la Vega, Dr. Juan Q.", "tests": ["bank:TEST_NAMES", "test77"]} {"name": "de la Vega, Dr. Juan Q. III", "tests": ["bank:TEST_NAMES", "test79"]} {"name": "de la Vega, Dr. Juan Q. Xavier", "tests": ["bank:TEST_NAMES", "test95"]} {"name": "de la Vega, Dr. Juan Q. Xavier III", "tests": ["bank:TEST_NAMES", "test97"]} {"name": "de la Vega, Dr. Juan Q. Xavier, Jr.", "tests": ["bank:TEST_NAMES", "test96"]} {"name": "de la Vega, Dr. Juan Q., Jr.", "tests": ["bank:TEST_NAMES", "test78"]} {"name": "de la Vega, Dr. Juan, Jr.", "tests": ["bank:TEST_NAMES", "test54"]} {"name": "de la Vega, Juan", "tests": ["bank:TEST_NAMES", "test41"]} {"name": "de la Vega, Juan III", "tests": ["bank:TEST_NAMES", "test43"]} {"name": "de la Vega, Juan Q.", "tests": ["bank:TEST_NAMES", "test65"]} {"name": "de la Vega, Juan Q. III", "tests": ["bank:TEST_NAMES", "test67"]} {"name": "de la Vega, Juan Q. Xavier", "tests": ["bank:TEST_NAMES", "test89"]} {"name": "de la Vega, Juan Q. Xavier III", "tests": ["bank:TEST_NAMES", "test91"]} {"name": "de la Vega, Juan Q. Xavier, Jr.", "tests": ["bank:TEST_NAMES", "test90"]} {"name": "de la Vega, Juan Q., Jr.", "tests": ["bank:TEST_NAMES", "test66"]} {"name": "de la Vega, Juan, Jr.", "tests": ["bank:TEST_NAMES", "test42"]} {"name": "de la Véña, Jüan", "tests": ["test_string_output", "test_utf8"]} {"name": "de la dos Vega, Dr. Juan Q. Xavier III", "tests": ["test_lastname_three_conjunctions"]} {"name": "de la vega, dr. juan Q. xavier III", "tests": ["test_comparison_key_case_insensitive_across_formats"]} {"name": "donovan mcnabb-smith", "tests": ["test_capitalization_with_Mac_as_hyphenated_names"]} {"name": "dos Santos", "tests": ["test_leading_non_first_name_prefix_dos"]} {"name": "dr Vincent James van Gogh dr", "tests": ["test_title_before_and_after_prefixed_last_name_with_middle"]} {"name": "dr Vincent van Gogh dr", "tests": ["test_title_before_and_after_prefixed_last_name"]} {"name": "dr Vincent van der Gogh dr", "tests": ["test_suffix_token_collision_with_two_word_prefix"]} {"name": "dr. ben alex johnson III", "tests": ["bank:TEST_NAMES"]} {"name": "dr. john p. doe-Ray, CLU, CFP, LUTC", "tests": ["test_comparison_case_insensitive", "test_hash_matches_case_insensitive_equality"]} {"name": "dr. juan de la vega jr.", "tests": ["test_unpickle_legacy_state_without_derived_sets"]} {"name": "e and e", "tests": ["test_name_is_conjunctions"]} {"name": "e j smith", "tests": ["test_lowercase_first_initial_conflict_with_conjunction"]} {"name": "joao da silva do amaral de souza", "tests": ["test_portuguese_prefixes"]} {"name": "john doe", "tests": ["test_capitalize_empty_middle_produces_no_leading_space_in_surnames"]} {"name": "john e jones", "tests": ["bank:TEST_NAMES"]} {"name": "john e jones, III", "tests": ["bank:TEST_NAMES"]} {"name": "john e. smith", "tests": ["test_lowercase_middle_initial_with_period_conflict_with_conjunction"]} {"name": "john smith", "tests": ["test_eq_emits_deprecation_warning", "test_not_equal_operator"]} {"name": "johnny y", "tests": ["test_conjunction_names"]} {"name": "jones, john e", "tests": ["bank:TEST_NAMES"]} {"name": "juan garcia III", "tests": ["test_capitalization_exception_for_already_capitalized_III_KNOWN_FAILURE"]} {"name": "juan q. xavier velasquez y garcia iii", "tests": ["test_capitalization_exception_for_III"]} {"name": "larry james edward johnson v", "tests": ["test_four_name_parts_with_suffix_that_could_be_initial_lowercase_no_period"]} {"name": "lt. gen. john a. kenneth doe iv", "tests": ["test_capitalize_title"]} {"name": "mack johnson", "tests": ["test_short_names_with_mac"]} {"name": "matthëus schmidt", "tests": ["test_capitalize_diacritics"]} {"name": "part1 of The part2 of the part3 and part4", "tests": ["test_multiple_conjunctions"]} {"name": "part1 of and The part2 of the part3 And part4", "tests": ["test_multiple_conjunctions2"]} {"name": "pennie von bergen wessels", "tests": ["test_prefix_before_two_part_last_name", "test_von_bergen_wessels"]} {"name": "pennie von bergen wessels III", "tests": ["test_prefix_before_two_part_last_name_with_suffix"]} {"name": "pennie von bergen wessels M.D.", "tests": ["test_prefix_before_two_part_last_name_with_acronym_suffix"]} {"name": "pennie von bergen wessels MD, III", "tests": ["test_title_two_part_last_name_with_suffix_in_first_part"]} {"name": "pennie von bergen wessels, III", "tests": ["test_two_part_last_name_with_suffix_comma"]} {"name": "salem, abdul", "tests": ["test_lastname_comma_prefix_only_no_join"]} {"name": "salem, abdul salam", "tests": ["test_lastname_comma_join"]} {"name": "salem, abdul salam ahmed", "tests": ["test_lastname_comma_join_with_middle"]} {"name": "scott e. werner", "tests": ["test_capitization_middle_initial_is_also_a_conjunction"]} {"name": "señora María García", "tests": ["test_señora_lowercase_non_ascii_title"]} {"name": "test", "tests": ["test_is_bound_first_name_false", "test_is_bound_first_name_true"]} {"name": "the and Jon Dough", "tests": ["test_starts_with_two_conjunctions"]} {"name": "vai la", "tests": ["test_prefix_names"]} {"name": "van nguyen", "tests": ["test_capitalize_prefix_clash_on_first_name"]} {"name": "von Braun", "tests": ["test_leading_von_is_unchanged"]} {"name": "von bergen wessels MD, pennie", "tests": ["test_comma_two_part_last_name_with_suffix_in_first_part"]} {"name": "von bergen wessels MD, pennie III", "tests": ["test_last_name_two_part_last_name_with_two_suffixes"]} {"name": "von bergen wessels, pennie III", "tests": ["test_two_part_last_name_with_suffix"]} {"name": "von bergen wessels, pennie MD", "tests": ["test_comma_two_part_last_name_with_acronym_suffix"]} {"name": "xyz. John Smith", "tests": ["test_leading_period_abbreviation_case_insensitive"]} {"name": "سلمان،", "tests": ["test_trailing_arabic_comma_stripped"]} {"name": "سلمان، محمد", "tests": ["test_arabic_comma_does_not_pollute_output", "test_arabic_comma_splits_lastname_format"]} {"name": "‏John‏ Smith", "tests": ["test_keep_bidi_control_chars"]} {"name": "‏محمد بن سلمان‏", "tests": ["test_bidi_stripped_name_compares_equal"]} {"name": "∫≜⩕ Smith 😊", "tests": ["test_keep_non_emojis"]} {"name": "∫≜⩕ Smith😊", "tests": ["test_keep_emojis"]} ������������derek73-python-nameparser-456b024/tools/differential/corpus_build.py��������������������������������0000664�0000000�0000000�00000016570�15273032717�0025560�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""What the labeled-corpus builders share: the fetch, the cache, the files they write. The builders (build_cldr_corpus.py, build_gnd_corpus.py, build_wikidata_corpus.py and build_wikidata_native_corpus.py) each write a corpus file and a fields file in the same formats; every one but CLDR caches its raw responses outside the repository the same way. Every builder's network fetch goes through `fetch_bytes`. Not a builder itself: it writes nothing on import, and has no main(). A builder imports it by name, after appending its own directory to sys.path: that directory is usually sys.path[0] when a builder runs as a script, but not under -P or PYTHONSAFEPATH (which AGENTS.md recommends for comparisons) nor when the tests load a builder by path. It is appended rather than inserted so that it cannot shadow a stdlib or installed module; the cost is that an earlier `corpus_build` on sys.path would shadow this one, and none exists. """ from __future__ import annotations import hashlib import json import os import time import urllib.request from collections.abc import Callable, Iterable, Mapping from pathlib import Path from typing import Any def cache_dir(name: str, *, create: bool = True) -> Path: """The builder's cache directory, outside the repository: $NAMEPARSER_TOOLS_CACHE or ~/.cache/nameparser-tools, as tools/name_usage.py uses. Created unless `create` is false, for a reader that only looks.""" root = Path(os.environ.get("NAMEPARSER_TOOLS_CACHE", Path.home() / ".cache" / "nameparser-tools")) path = root / name if create: path.mkdir(parents=True, exist_ok=True) return path def write_atomic(path: Path, body: bytes) -> None: """Write `body` to `path` through a .part file beside it and a rename, so an interrupted run leaves no truncated file.""" partial = path.with_name(path.name + ".part") partial.write_bytes(body) os.replace(partial, path) def fetch_bytes(url: str, *, user_agent: str, timeout: int, data: bytes | None = None, headers: Mapping[str, str] | None = None, delay: float = 0) -> bytes: """The response body of `url` (a POST when `data` is given), sent with the builder's User-Agent and any further `headers`, then `delay` seconds of sleep: a builder that fetches in a row passes one, so that it stays gentle on the source. Raises what urllib raises. Looks up `urllib.request.urlopen` at call time, which is what lets the tests answer every fetch from one stub.""" request = urllib.request.Request( url, data=data, headers={"User-Agent": user_agent, **(headers or {})}) with urllib.request.urlopen(request, timeout=timeout) as response: body: bytes = response.read() if delay: time.sleep(delay) return body def parse_json(body: bytes | str, source: str) -> object: """`body` parsed, or ValueError naming where it came from.""" try: return json.loads(body) except ValueError as error: raise ValueError(f"{source} is not JSON ({error})") from None def cached_json(cache: Path, key: str, label: str, fetch: Callable[[], bytes], check: Callable[[bytes | str, str], dict[str, Any]], refresh: bool) -> dict[str, Any]: """The response cached under `key`, or fetched and then cached. `check` parses a body and raises ValueError naming its source unless it is the response asked for; it runs on a cached body too, so a damaged file is named with the way out, and on a fetched one BEFORE it is cached, so an error answer (often HTTP 200) never is. The write is atomic: an interrupted run leaves no truncated cache file.""" cached = cache / (hashlib.sha256(key.encode()).hexdigest()[:24] + ".json") if cached.exists() and not refresh: return check(cached.read_text(encoding="utf-8"), f"{cached} (cached {label}; delete it or pass --refresh)") body = fetch() data = check(body, label) write_atomic(cached, body) return data #: The line breaks str.splitlines() cuts at and json.dumps(..., #: ensure_ascii=False) writes unescaped, as JSON escapes to put in their #: place: a name pasted from a document can hold one, and written raw it #: would cut its line in two for a reader that splits lines (AGENTS.md's #: Gotchas, #801). corpus_lines and fields_lines, snapshot.py, #: cldr_agreement.py, local_corpora.py, local_psc.py's extracts and #: local_agreement.py write with jsonl_line and read #: with jsonl_lines; the corpus builders that write their own lines #: (build_cjk, _conventions, _shapes, _rules, _issues) do not yet. _LINE_BREAKS = str.maketrans({"\x85": "\\u0085", "\u2028": "\\u2028", "\u2029": "\\u2029"}) def jsonl_line(obj: object, *, sort_keys: bool = False) -> str: """`obj` as one JSONL line (no newline): json.dumps with the line breaks escaped.""" return json.dumps(obj, ensure_ascii=False, sort_keys=sort_keys).translate(_LINE_BREAKS) def jsonl_lines(text: str) -> list[str]: """A JSONL file's non-blank lines, cut at "\n" only.""" return [line for line in text.split("\n") if line.strip()] def corpus_lines(rows: Iterable[dict[str, Any]]) -> list[str]: """One harness line per distinct (text, shape), sorted for stable diffs: a bare JSON string, or {"name", "shape"} where a shape is set.""" pairs: set[tuple[str, int | None]] = set() for row in rows: shape = row["shape"] if shape is not None and not isinstance(shape, int): raise TypeError(f"shape must be an int or None, not {shape!r}") pairs.add((str(row["text"]), shape)) # An untagged line sorts before every tagged one of the same text, so # the order never rests on set iteration (shape 0 would tie with None). return [jsonl_line(text if shape is None else {"name": text, "shape": shape}) for text, shape in sorted(pairs, key=lambda p: (p[0], -1 if p[1] is None else p[1]))] def fields_lines(rows: Iterable[dict[str, Any]]) -> list[str]: """One line per rendering, sorted by its key (file/label/order, the key cldr_agreement.py records under). A key is unique per rendering; a duplicate means the source was misread.""" keyed: dict[str, dict[str, Any]] = {} for row in rows: key = f"{row['file']}/{row['label']}/{row['order']}" if key in keyed: raise ValueError(f"duplicate rendering {key}") keyed[key] = row return [jsonl_line(keyed[k], sort_keys=True) for k in sorted(keyed)] def write_corpus(rows: list[dict[str, Any]], corpus: Path, fields: Path) -> None: """Write the corpus file and the fields file. Both are built, then written to .part files, before either is renamed into place, so a refused row (a duplicate rendering, a bad shape) or a failed write leaves the committed pair as it was; only a crash between the two renames could leave them out of step.""" texts = {corpus: "\n".join(corpus_lines(rows)) + "\n", fields: "\n".join(fields_lines(rows)) + "\n"} parts = {path: path.with_name(path.name + ".part") for path in texts} try: for path, text in texts.items(): parts[path].write_text(text, encoding="utf-8") except BaseException: for part in parts.values(): # beside the committed files: never leave one part.unlink(missing_ok=True) raise for path, part in parts.items(): os.replace(part, path) ����������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/corpus_cjk.jsonl�������������������������������0000664�0000000�0000000�00000006347�15273032717�0025726�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"(김민준.) John Smith" "Andersonさん" "Anderson선생님" "Dr 김민준, Jr." "Dr 김민준, MA" "Dr 김민준씨, Jr." "Dr 김민준씨, V." "Dr. 김민준씨, MA" "J.씨" "John Smith 田.中." "John Smith, Dr. J.씨" "John 王" "John 田中さん, XYZ" "Jo김민준씨, DO" "Kim김민준씨, MA" "Smith 김민준씨, XYZ" "〆木 ひろ" "〆木 太郎" "〆木太郎" "あ゙い たろう" "さん" "みなみ" "ア゙イ タロウ" "ア゛イ タロウ" "エリザベス二世" "マイケル" "マイケル ア゙イ" "マイケル ジャクソン" "マイケル." "マイケル·ジャクソン" "マイケル・ジャクソン" "ロバート・ダウニー・ジュニア" "佐々木 太郎" "佐藤 花子 \"旧姓・田中\" (旧姓 鈴木)" "佐藤恵子 国会議員" "佐藤恵子国会議員" "司马相如" "夏侯惇" "威廉·莎士比亚" "威廉·莎士比亚, PhD" "威廉·莎士比亚さん" "威廉・莎士比亚" "小明" "山田 ア゙イ" "山田 エミ" "山田 太郎 (マイケル・ジャクソン)" "山田 花子 旧姓 佐藤" "山田 花子(旧姓 佐藤)" "山田 「タロー」 タロウ" "山田 タロウ" "山田 ダイスケ" "山田 ペーター" "山田「タロ」太郎" "山田太郎様" "山田花子 旧姓 佐藤" "山田「タロー」太郎" "山田タロウ" "张伟" "彭德威 议员" "李部長" "毛 泽东" "毛 泽东 Dr." "毛 김" "毛泽东" "毛泽东 MA" "毛泽东, MA" "王 小 明" "王·Smith" "王先生" "王先生, V." "王君" "王小明 先生" "王小明先生" "王氏" "王雅婷,議員" "田中 さん" "田中 太郎 旧姓 佐藤 MA" "田中 太郎 様" "田中 太郎, MA" "田中 殿" "田中 社長" "田中, 太郎 MA" "田中, 太郎さん" "田中." "田中. 太郎" "田中『ハナ』花子" "田中さん" "田中さん II" "田中さん V." "田中さん v" "田中さん 様." "田中さん, Dr." "田中さん, Ph. D." "田中さん, PhD" "田中さん, V." "田中さん, 太郎" "田中さん, 様." "田中さん." "田中さん., V." "田中医学博士" "田中博士" "田中社長" "田中部長" "諸葛亮" "陳怡君 二世" "陳怡君二世" "陳怡君,醫學博士" "马丁·路德·金" "高橋 みなみ" "高橋みなみ" "高橋・一郎" "高橋一郎" "鵜殿" "김 v" "김 민준" "김, 민준 MA" "김, 민준씨" "김, 민준씨 (Jimmy)" "김. 민준" "김゙민준" "김민준" "김민준 님" "김민준 박, MA Jones" "김민준 박, X.Y.Z." "김민준 박사" "김민준 박사 씨" "김민준 박사님" "김민준 씨" "김민준 씨." "김민준 씨。" "김민준 씨." "김민준 씨。" "김민준 양" "김민준 양." "김민준, 씨" "김민준, 씨." "김민준." "김민준. 지훈" "김민준님" "김민준박사님" "김민준씨" "김민준씨 (Jimmy)" "김민준씨 Jr." "김민준씨, J.씨" "김민준씨, MA PhD" "김민준씨, V." "김민준씨." "김민준씨., J.씨" "김선생님" "김씨" "김지양" "남궁" "남궁민수" "남궁민수, 지훈" "로버트 다우니 주니어" "마이클·잭슨" "마틴 킹, MA" "선생님" "선생님, J.씨" "씨" "양 미선" "양 지훈" "양 지훈." "양. 지훈" "이, J.씨" "이, J.씨." "지훈, 남궁민수" "タナカ. John" "タロウ·ヤマダ" "マイケル." "ヤマダ タロウ" �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/corpus_cldr.jsonl������������������������������0000664�0000000�0000000�00000272216�15273032717�0026103�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"A. R., Sr. Maria García Rosa de la Cruz Jr." {"name": "A. R., Sr. de la Cruz Maria García Rosa Jr.", "shape": 4} {"name": "AKIME Fuseena", "shape": 4} "Abɖu-Tɔyib GOMINA MUSA" "Ada" {"name": "Ada", "shape": 4} "Ada Cornelia César Martín von Brühl M.D. Ph.D." "Ada Cornelia César Martín von Brühl MD DDS" "Ada Cornelia Eva Sophia van. den. Wolf M.D. Ph.D." "Ada Cornelia Eva Sophia von Wolf MD DDS" "Ada Kornelia Yeva Sofiya van den Volf M.D. Ph.D." "Adebola Opeyemi Olumide Ajao Adekunle Asofin" {"name": "Adeboye Olabisi", "shape": 4} "Adeboye, Olabisi" {"name": "Adekunle Adebola Opeyemi Olumide Ajao Asofin", "shape": 4} "Adekunle, Adebola Opeyemi Olumide Ajao" "Adeolu Adegboyega Akintola" "Adinata, Dwi Putro bin" {"name": "Adler Irene", "shape": 4} {"name": "Adler İren", "shape": 4} "Adler, Irene" "Adrian Dragos Ionescu" "Aduritz, Irene" {"name": "Aduritz, Irene", "shape": 4} "Adá Cornelia Ẹ́va Sophia ván den Wólf M.D. PhD." "Adèle" {"name": "Adèle", "shape": 4} "Adélaïde Lemaître" {"name": "Adólfsdóttir Íris", "shape": 4} "Ahmad Albar bin Akbar" "Aina Kalniņa" "Aita Caduff" "Akbar, Ahmad Albar bin" {"name": "Akbar, Ahmad Albar bin", "shape": 4} {"name": "Akintola Adeolu Adegboyega", "shape": 4} "Akintola, Adeolu Adegboyega" "Alexandru Ioan Popa" "Alfɔns MANƉELA Rolanɖ" {"name": "Ali Amina", "shape": 4} "Ali Juma Hamisi" "Ali, Amina" "Alina" {"name": "Alina", "shape": 4} "Alisher Salimovich Qodirov Deputat" "Alisher Sattorov" {"name": "Amadi John Chinedu", "shape": 4} {"name": "Amadi Maazị Christopher Chukwudike Jr, MP", "shape": 4} "Amadi, Christopher Chukwudike" "Amadi, John Chinedu" "Amangeldi Saparow" "Amarildo Lombardi" "Amina Ali" "An tOll. An Dr. Brigid Aoibhe Sophia de Bhulbh" "Ana Marija Vera Kovač" "Ana Torres" {"name": "Andersen Irene", "shape": 4} "Andersen, Irene" {"name": "Andersson Ingrid", "shape": 4} {"name": "Andersson, Ingrid", "shape": 4} "Ane Miren Urrestaratzu" "Anh" {"name": "Anh", "shape": 4} "Anh Nguyễn" "Anh Văn Nguyễn" "Anna Liina Eva Sofia von Vall, MD, PhD" "Anna Paula Bērziņa" "Anne Berit Bernhard Nilsen" "Anne Sepp" "Anne Sofie Holm Hansen" "Annelise Weber" "Ardelean, Daniel Ionescu Dragoș Ioan" "Asmar" {"name": "Asmar", "shape": 4} {"name": "Awa Isseu", "shape": 4} {"name": "Aydan Arif Aliyev İlvus Santak Deputat", "shape": 4} {"name": "Aydan Cənab Arif Aliyev İlvus Santak Deputat", "shape": 4} "Babić, Marijana Tomislav" {"name": "Babić, Marijana Tomislav", "shape": 4} {"name": "Bakker Irene", "shape": 4} "Bakker, Irene" {"name": "Balóch Karima", "shape": 4} {"name": "Balóch Karima Mehráb", "shape": 4} {"name": "Balóch Wj. Monir Mómen Monir Mómen", "shape": 4} {"name": "Bapak Adinata, Dwi Putro bin MP", "shape": 4} "Bapak Dwi Putro bin Adinata MP" {"name": "Beauchêne Jacqueline", "shape": 4} "Beauchêne, Jacqueline" "Benno Hans Meyer" "Berg, Cornelia Margarita Sofia Aletta van den" "Bertram Wilberforce Henry Robert Wooster" "Bertram Wilberforce Henry Robert Wooster AS" "Bertram Wilberforce Henry Robert Wooster MP" {"name": "Bharati Lalit", "shape": 4} {"name": "Bharati Lalit Mohan", "shape": 4} {"name": "Bharati Shree Lalit Kumaar Jr, MP", "shape": 4} "Bharati, Lalit" "Bharati, Lalit Kumaar" "Bharati, Lalit Mohan" "Blerim" {"name": "Blerim", "shape": 4} "Bobur" {"name": "Bobur", "shape": 4} "Boluwatife" {"name": "Boluwatife", "shape": 4} "Bouchart, Jean-Nicolas Louis Marcel de" "Brais Manoel Souto" "Bruhel, Ada Cornelia Martina von" "Bruhl, Ada Cornelia Cesar Martin von" "Bruhl, Ada Cornelia Cesar Martín von" "Brühl, Ada Cornelia César Martín von" "Brühl, Ada Cornelia César Martín von (Prof. Dr., M.D. DDS.)" "Brühl, Mary Lou Cesare Martín von" "Brühl, María Florencia Martina Cristina von" "Brül Cabral de Melo, Erika Cornelia Anna Isabelle von" "Brül, Ada Kornelia Sezar Martin fon" "Budi" {"name": "Budi", "shape": 4} {"name": "Bukenya Zamara Imani", "shape": 4} "Bukenya, Zamara Imani" "Bw. Adam Juma Agape Shukurani Masalu MB" {"name": "Bērziņa Anna", "shape": 4} {"name": "Bērziņa Anna Paula", "shape": 4} "Caduff, Aita" {"name": "Caduff, Aita", "shape": 4} {"name": "Caimbeul Dòmhnall Iain Tormod", "shape": 4} "Caimbeul, Dòmhnall Iain Tormod" "Calum" {"name": "Calum", "shape": 4} {"name": "Canth Ulrika Wilhelmina", "shape": 4} "Canth, Ulrika Wilhelmina" "Carlos Zabala" "Carolina" {"name": "Carolina", "shape": 4} "Castro Sáez, Xosé Carlos Francisco Xavier de" "Cecilia Angela Smith" "Cecilija Hemiš Šteber" "Christopher Hans Jacobsen" {"name": "Coleman Zendaya Marie", "shape": 4} "Coleman, Zendaya Marie" "Con Hamiş Vatson" {"name": "Condori Edwin Segundo", "shape": 4} "Condori, Edwin Segundo" {"name": "Conti Gian Carlo Maria", "shape": 4} "Conti, Gian Carlo Maria" "Cruz, de la Alejandro Manuel César Mamani" "Cruz, de la Maria García Rosa" "Cənab Arif Aliyev İlvus Santak Aydan Deputat" {"name": "Dhòmhnallach Oighrig", "shape": 4} "Dhòmhnallach, Oighrig" "Dl Dr. Daniel Ionescu Dragoș Ioan Ardelean Jr." {"name": "Dr. Ardelean Dl Daniel Ionescu Dragoș Ioan Jr.", "shape": 4} "Dr. Paul Vinzent von Fischer jr. MdB" "Dr.philol. Laima Kristīne Eva Sofija van den Volfa Bērziņa-Kalniņa" "Dra. Erika Cornelia Anna Isabelle von Brül Cabral de Melo II" "Dut.ra Prof.ra Maria Giusepa Juana Teresa von Brühl González Domingo" "Dòmhnall Iain Tormod Caimbeul" "Dóra Mátrai" "Edwin Segundo Condori" "Eemil" {"name": "Eemil", "shape": 4} "Elizabeth Windsor Maria Teresa bin Stromae MP" {"name": "Emomoto Yoshi", "shape": 4} "Emomoto, Yoshi" "Ewa Nowak" "Excm. Sr. Josep Antoni Carles Joan Lloret Palol" {"name": "Excm. Sr. Lloret Palol II, Josep Antoni Carles Joan", "shape": 4} "Excma. Dra. Maria da Graça César Martín van Dalen Hoogeveen" {"name": "Excma. Dra. Van Dalen Hoogeveen II, Maria da Graça César Martín", "shape": 4} {"name": "Fabbri Irene", "shape": 4} "Fabbri, Irene" "Falke, Iris" {"name": "Falke, Iris", "shape": 4} "Farf. Dr. Ada Cornelia Eva Sophia van den Wolf Becker Schmidt M.D. Ph.D." "Fatima" {"name": "Fatima", "shape": 4} "Fatima Amina Umar" {"name": "Fernandes Maria Helena", "shape": 4} "Fernandes, Maria Helena" {"name": "Ferrer Rosa Maria Ferran", "shape": 4} "Ferrer, Rosa Maria Ferran" "Fischer, Paul Vinzent von" "Fleksnes, Gregers Werle Marve Almar von" {"name": "Flores Marco Antonio", "shape": 4} "Flores, Marco Antonio" "Foffa, Gian Carlo Ramun" {"name": "Foffa, Gian Carlo Ramun", "shape": 4} "Fonda, Menada" {"name": "Fonda, Menada", "shape": 4} "Frantziscu" {"name": "Frantziscu", "shape": 4} {"name": "Freire Xiana", "shape": 4} "Freire, Xiana" "Fuseena AKIME" {"name": "GOMINA MUSA Abɖu-Tɔyib", "shape": 4} "Gal·la Roig" {"name": "Gaonkar Shanti", "shape": 4} "Gaonkar, Shanti" {"name": "Geir Jón Magnús Brynjar Skeggjason", "shape": 4} "Geir Jón Magnús Brynjar Skeggjason, þingmaður" "Gian Carlo Maria Conti" "Gian Carlo Ramun Foffa" {"name": "Gilot Marie-Agnès Suzanne", "shape": 4} "Gilot, Marie-Agnès Suzanne" "Ginevra Molly Weasley" "Giuanne Marcu Piras" "Giáo sư, Tiến sĩ Ada Cornelia César Martín von Brühl Jr, MD DDS" {"name": "Granger Hermione", "shape": 4} "Granger, Hermione" "Gurban" {"name": "Gurban", "shape": 4} {"name": "Gurbanow Meretgeldi Durdyýewiç", "shape": 4} "Gurbanow, Meretgeldi Durdyýewiç" {"name": "Gurdow Atajan Saparowiç, magistr, ylymlaryň kandidaty", "shape": 4} "Gurdow, Atajan Saparowiç" {"name": "Hamisi Ali Juma", "shape": 4} "Hamisi, Ali Juma" "Hamisi, Salima Farida mwana" "Hana Kulina" "Hanka Šołćina" "Hansen, Anne Sofie Holm" {"name": "Hansen, Anne Sofie Holm", "shape": 4} "Harald" {"name": "Harald", "shape": 4} {"name": "Hasani Maria", "shape": 4} "Hasani, Maria" "Hassan" {"name": "Hassan", "shape": 4} "Heidi Johannes Willemse" {"name": "Hermansson Lars-Åke Karl", "shape": 4} {"name": "Hermansson, Herr Lars-Åke Karl, Riksdagsledamot", "shape": 4} "Hermione Granger" "Horvat, Irena" {"name": "Horvat, Irena", "shape": 4} "Hozier" {"name": "Hozier", "shape": 4} {"name": "Hussein Mariam Geedi Hamish", "shape": 4} "Hussein, Mariam Geedi Hamish" "Iiɖa KANSULO" "Ilir Rustemi" {"name": "Ilmarinen Seppo", "shape": 4} "Ilmarinen, Seppo" "Ingrid Andersson" {"name": "Ionescu Adrian Dragos", "shape": 4} "Ionescu, Adrian Dragos" "Irena Horvat" "Irena Kazlauskienė" "Irena Marković" "Irena Orel" "Irene Adler" "Irene Aduritz" "Irene Andersen" "Irene Bakker" "Irene Fabbri" "Irene Møller" "Irene Obidike" "Irene Rossouw" "Iris Falke" "Irmak Yılmaz" "Ismail, Syed Abd Aziz Syed A" "Isseu Awa" "Ivan" {"name": "Ivan", "shape": 4} "Iñaki" {"name": "Iñaki", "shape": 4} "Jaak Eerik Kask" "Jack" {"name": "Jack", "shape": 4} {"name": "Jacobsen Christopher Hans", "shape": 4} "Jacobsen, Christopher Hans" "Jacqueline Beauchêne" "Jan Beno Krawc" "Jan Koos Van der Merwe" "Jan Měto Šejc" "Jana Kováčová" "Jana Nováková" "Jiří" {"name": "Jiří", "shape": 4} "Johan Harald Wallin" "John" {"name": "John", "shape": 4} "John Chinedu Amadi" "John Hamish Watson" "John Hámish Watson" "John Mkwawa" {"name": "Johnson Mary", "shape": 4} "Johnson, Mary" "Jon Andoni Urrestaratzu" "Jonas Lukas Petrauskas" "Jong, Bertus Wevers Harry Robert de" "Josef Václav Svoboda" "Jovan Dragan Popović" "Jozef" {"name": "Jozef", "shape": 4} "João Pedro Silva" "Julia Roberts" "Jurgis" {"name": "Jurgis", "shape": 4} "Jurij" {"name": "Jurij", "shape": 4} "Juro" {"name": "Juro", "shape": 4} "Ján Matej Slovák" "Jón Hannes Valdimarsson" {"name": "Jón Hannes Valdimarsson", "shape": 4} "Jānis" {"name": "Jānis", "shape": 4} {"name": "KALAM Alɛ Mustafa", "shape": 4} {"name": "KANSULO Iiɖa", "shape": 4} {"name": "Kalniņa Aina", "shape": 4} "Kant, Anna Karolína Eva Kristína von (Mgr. Ing., CSc.)" "Karima" {"name": "Karima", "shape": 4} "Karima Balóch" "Karima Mehráb Balóch" {"name": "Kask Jaak Eerik", "shape": 4} "Kask, Jaak Eerik" "Kate Miller" "Kathe Muller" "Kathi Muller" "Kathy Mueller" "Katie Stefan" "Katrine" {"name": "Katrine", "shape": 4} "Katryn Müller" "Katy Mané" {"name": "Kaya Yağız Can", "shape": 4} "Kaya, Yağız Can" {"name": "Kazlauskienė Irena", "shape": 4} "Kazlauskienė, Irena" "Keita Millere" "Kete Miler" "Kete Myuller" "Kete Müller" "Kevin" {"name": "Kevin", "shape": 4} "Kjetil Bjørn Løseth" "Kotryna Miliūtė" {"name": "Kovač Ana Marija Vera", "shape": 4} "Kovač, Ana Marija Vera" {"name": "Kováčová Jana", "shape": 4} "Kováčová, Jana" {"name": "Kowalska Maria Kamila", "shape": 4} "Kraja, Majlinda" {"name": "Kraja, Majlinda", "shape": 4} {"name": "Kranjčević Silvije Strahimir", "shape": 4} "Krawc, Jan Beno" {"name": "Krawc, Jan Beno", "shape": 4} "Kulina, Hana" {"name": "Kulina, Hana", "shape": 4} "Käthe Müller" {"name": "Käthe Müller", "shape": 4} "Lalit" {"name": "Lalit", "shape": 4} "Lalit Bharati" "Lalit Mohan Bharati" {"name": "Lehtola-Aalto Marja-Liisa Katariina, kansanedustaja", "shape": 4} "Lehtola-Aalto, Marja-Liisa Katariina" "Lejnikowa, Hejba-Marja Hana" {"name": "Lejnikowa, dr. Hejba-Marja Hana jr. phd", "shape": 4} "Lemaître, Adélaïde" {"name": "Lemaître, Adélaïde", "shape": 4} "Lena" {"name": "Lena", "shape": 4} "Lianora Sanna" "Lloret Palol, Josep Antoni Carles Joan" "Lola" {"name": "Lola", "shape": 4} "Lombardi, Amarildo" {"name": "Lombardi, Amarildo A.", "shape": 4} "Louhi" {"name": "Louhi", "shape": 4} "Louise Péricourt" "Luca Sarolta Éva Erzsébet von Kovács M.D. Ph.D." "Lucía Pérez" "Løseth, Kjetil Bjørn" {"name": "Løseth, Kjetil Bjørn", "shape": 4} "M. Jean-Nicolas Louis Marcel de Bouchart fils" {"name": "M. de Bouchart Jean-Nicolas Louis Marcel fils", "shape": 4} {"name": "MANƉELA Rolanɖ Alfɔns", "shape": 4} {"name": "MD Chirurgie dentară von der Ruhr Prof. dr. Eva-Eleonora Karl Hans Jr.", "shape": 4} {"name": "MD DDS Dut.ra Prof.ra von Brühl González Domingo Maria Giusepa Juana Teresa", "shape": 4} {"name": "MD DDS von Brühl César Martín Ada Cornelia", "shape": 4} "MP Pēteris Kārlis Ozoliņš" "Maazị Christopher Chukwudike Amadi MP" {"name": "Mac a’ Ghobhainn Chorùna, Mgr. Dòmhnall Iain Bàn am post", "shape": 4} "Mac a’ Ghobhainn, Dòmhnall Iain Bàn" "Machová, Alexandra Zuzana (paní, Ph.D.)" "Macáková, Alexandra Zuzana (pani, PhD)" "Maider" {"name": "Maider", "shape": 4} "Majlinda Kraja" "Malam Shehu Bello Modibbo MP" {"name": "Mané Katy", "shape": 4} "Marco Antonio Flores" "Mari" {"name": "Mari", "shape": 4} "Mari shu Halimatu Waly" "Mari-Ann Henri Roobert Tammik, parlamendiliige" "Maria" {"name": "Maria", "shape": 4} "Maria Hasani" "Maria Helena Fernandes" "Maria Kowalska" "Maria Popescu" "Maria Silva" "Maria Wijaya" "Mariam Geedi Hamish Hussein" "Marie-Agnès Suzanne Gilot" "Marijana Tomislav Babić" "Mario" {"name": "Mario", "shape": 4} "Marić, Branko Hrvoje" {"name": "Marić, dr. sc. Branko Hrvoje mlađi, prof.", "shape": 4} "Marja-Liisa Katariina Lehtola-Aalto kansanedustaja" "Mark" {"name": "Mark", "shape": 4} {"name": "Marković Irena", "shape": 4} "Marković, Irena" "Martine Sodersen" "Martini, Fabio Massimo Carlo Alberto" "Mary Johnson" "Mary Sue Hamish Watson" {"name": "Masalu Adam Juma Agape Shukurani MB", "shape": 4} "Masalu, Adam Juma Agape Shukurani" "Max Ben Mustermann" "Menada Fonda" "Meretgeldi Durdyýewiç Gurbanow" {"name": "Meyer Benno Hans", "shape": 4} "Meyer, Benno Hans" "Mgr. Dòmhnall Iain Bàn Mac a’ Ghobhainn Chorùna am post" "Mgr. Ing. Anna Karolína Eva Kristína von Kant Nováková Predná, CSc." {"name": "Mgr. Ing. von Kant Nováková Predná Anna Karolína Eva Kristína, CSc.", "shape": 4} {"name": "Milenković dr Slađana Vera mlađi, dipl. inž.", "shape": 4} "Milenković, Slađana Vera" {"name": "Miler Kete", "shape": 4} "Miler, Kete" {"name": "Miliūtė Kotryna", "shape": 4} "Miliūtė, Kotryna" {"name": "Miller Kate", "shape": 4} "Miller, Kate" {"name": "Millere Keita", "shape": 4} {"name": "Millers Pīters", "shape": 4} {"name": "Millers Pīters Džims", "shape": 4} "Mina" {"name": "Mina", "shape": 4} "Miret" {"name": "Miret", "shape": 4} {"name": "Mkwawa John", "shape": 4} "Mkwawa, John" {"name": "Modibbo Malam Shehu Bello MP", "shape": 4} "Modibbo, Shehu Bello" {"name": "Mozart Wolfgang Amadeus", "shape": 4} "Mozart, Wolfgang Amadeus" "Mr. Bertram Wilberforce Henry Robert Wooster Jr, MP" "Mr. Bertram Wilberforce Henry Robert Wooster MP" "Mr. Francisco Xavier Joao Anton de Souza e Menezes Dk, MP" "Mudane Bertram Wilberforce Henry Robert Wooster MP" {"name": "Mueller Kathy", "shape": 4} "Mueller, Kathy" "Muhammad Yusuf Qodirov" {"name": "Muhammed Shehu", "shape": 4} "Muhammed, Shehu" {"name": "Mujanović Razija", "shape": 4} {"name": "Muller Kathe", "shape": 4} {"name": "Muller Kathi", "shape": 4} "Muller, Kathe" {"name": "Muller, Kathe", "shape": 4} "Muller, Kathi" "Muniiru" {"name": "Muniiru", "shape": 4} "Mustermann, Max Ben" {"name": "Mustermann, Max Ben", "shape": 4} {"name": "Myuller Kete", "shape": 4} "Myuller, Kete" {"name": "Mátrai Dóra", "shape": 4} "Mátrai, Dóra" "Máté Bendegúz Szabados" "Møller, Hans Henrik Sven Åge" "Møller, Irene" {"name": "Møller, Irene", "shape": 4} {"name": "Møller, hr. Hans Henrik Sven Åge, MF", "shape": 4} {"name": "Müller Katryn", "shape": 4} {"name": "Müller Kete", "shape": 4} {"name": "Müller Käthe", "shape": 4} "Müller, Katryn" "Müller, Kete" "Müller, Käthe" {"name": "Müller, Käthe", "shape": 4} {"name": "Nguyễn Anh", "shape": 4} {"name": "Nguyễn Văn Anh", "shape": 4} {"name": "Nilsen Anne Berit Bernhard", "shape": 4} "Nilsen, Anne Berit Bernhard" "Novak, Janez Peter" {"name": "Nováková Jana", "shape": 4} "Nováková, Jana" {"name": "Nowak Ewa", "shape": 4} {"name": "Nowak Tomasz Jan", "shape": 4} "Noémie Katerina Yannaki" {"name": "Obidike Irene", "shape": 4} "Obidike, Irene" "Oighrig Dhòmhnallach" {"name": "Okeke Tunde Seun", "shape": 4} "Okeke, Tunde Seun" "Olabisi Adeboye" {"name": "Orel Irena", "shape": 4} "Orel, Irena" "Owura Bertram Wilberforce Henry Robert Wooster MP" {"name": "Ozoliņš Pēteris", "shape": 4} {"name": "Ozoliņš Pēteris Kārlis", "shape": 4} {"name": "Pablo Pérez Miguel Ángel Juan Antonio II", "shape": 4} "Pablo Pérez, Sr. Miguel Ángel Juan Antonio" {"name": "Pan Nowak Tomasz Jan", "shape": 4} "Pan Tomasz Nowak" "Pedro" {"name": "Pedro", "shape": 4} {"name": "Penkauskienė Sofija Rita", "shape": 4} "Penkauskienė, Sofija Rita" "Peter" {"name": "Peter", "shape": 4} "Petraitytė, Gabrielė Aldona" {"name": "Petrauskas Jonas Lukas", "shape": 4} "Petrauskas, Jonas Lukas" "Ph. D., Prof. Dr. Alejandro Manuel César Mamani de la Cruz Jr." {"name": "Ph. D., Prof. Dr. de la Cruz Alejandro Manuel César Mamani Jr.", "shape": 4} "Piet" {"name": "Piet", "shape": 4} {"name": "Piras Giuanne Marcu", "shape": 4} "Piras, Giuanne Marcu" "Planta, Margaritta Marionna Maria de" {"name": "Popa Alexandru Ioan", "shape": 4} "Popa, Alexandru Ioan" {"name": "Popescu Maria", "shape": 4} "Popescu, Maria" {"name": "Popović Jovan Dragan", "shape": 4} "Popović, Jovan Dragan" "Prema" {"name": "Prema", "shape": 4} "Prof. Dk. Salima Farida mwana Hamisi Musa M.D. Ph.D." "Prof. Dr. Ada Cornelia Cesar Martin von Bruhl Jr, MD DDS" "Prof. Dr. Ada Cornelia Cesar Martín von Bruhl Jr, M.D., Ph.D." "Prof. Dr. Ada Cornelia César Martín von Brühl González Domingo Jr, MD DDS" "Prof. Dr. Ada Cornelia César Martín von Brühl González Domingo M.D. Ph.D." "Prof. Dr. Ada Cornelia César Martín von Brühl González Domingo MD DDS" "Prof. Dr. Ada Cornelia César Martín von Brühl González Domingo, M.D. DDS." "Prof. Dr. Ada Cornelia César Martín von Brühl Jr MD DDS" "Prof. Dr. Ada Cornelia César Martín von Brühl Jr, MD DDS" "Prof. Dr. Ada Cornelia Eva Sophia van den Wolf Becker Schmidt M.D. Ph.D." "Prof. Dr. Ada Cornelia Martina von Bruhel Domingo MD DDS" "Prof. Dr. Ada Kornelia Sezar Martin van den Volf Becker Schmidt M.D. Ph.D." "Prof. Dr. Adama Coulibaly Camara Massamba won Baar Dominique Goudiaby MD DDS" "Prof. Dr. Anna Cornelia Eva Sophia van den Wolf jr. M.D. Ph.D." "Prof. Dr. MD DDS Ada Cornelia César Martín von Brühl Jr" "Prof. Dr. Mary Lou Cesare Martín von Brühl Jr., Dr. Ph.D" "Prof. Dr. Mary Sue Marie von Miller II, MD DDS" "Prof. Dr. María Florencia Martina Cristina von Brühl Jr., MD DDS" {"name": "Prof. Dr. von Bruhl, Ada Cornelia Cesar Martín Jr, M.D., Ph.D.", "shape": 4} {"name": "Prof. Dr. von Brühl Ada Cornelia César Martín Jr, MD DDS", "shape": 4} {"name": "Prof. Dr. von Brühl González Domingo Ada Cornelia César Martín, M.D. DDS.", "shape": 4} {"name": "Prof. Dr. von Brühl María Florencia Martina Cristina Jr., MD DDS", "shape": 4} {"name": "Prof. Dr. von Brühl Plácido Domingo Ada Cornelia César Martín, MD DDS", "shape": 4} "Prof. dr. Cornelia Margarita Sofia Aletta van den Berg jnr., MBChB PhD" "Prof. dr. MD Chirurgie dentară Eva-Eleonora Karl Hans von der Ruhr Jr." "Prof. Ɖr. MƉ Samʊwɛl fan ƉAM Fostɛɛŋ" {"name": "Prof. Ɖr. MƉ fan ƉAM ƉOMIŊGO Fostɛɛŋ Samʊwɛl", "shape": 4} "Prop. Dr. Ada Cornelia César Martín von Brühl González Domingo MD DDS" {"name": "Pérez Lucía", "shape": 4} "Pérez, Lucía" {"name": "Péricourt Louise", "shape": 4} "Péricourt, Louise" "Pīters Džims Millers" {"name": "Qodirov Alisher Salimovich Deputat", "shape": 4} {"name": "Qodirov Muhammad Yusuf", "shape": 4} "Qodirov, Alisher Salimovich" "Qodirov, Muhammad Yusuf" "Razija Mujanović" "Riksdagsledamot Lars-Åke Karl Hermansson" {"name": "Roberts Julia", "shape": 4} "Roberts, Julia" {"name": "Roig Gal·la", "shape": 4} "Roig, Gal·la" "Rokas" {"name": "Rokas", "shape": 4} "Rosa Maria Ferran Ferrer" "Rosa María Ruiz" {"name": "Rossouw Irene", "shape": 4} "Rossouw, Irene" "Ruhr, Eva-Eleonora Karl Hans von der" {"name": "Ruiz Rosa María", "shape": 4} "Ruiz, Rosa María" "Rustemi, Ilir" {"name": "Rustemi, Ilir I.", "shape": 4} "Rüdiger" {"name": "Rüdiger", "shape": 4} {"name": "Saar Samira Ahmet", "shape": 4} "Salimah Sulaiman" "Samba" {"name": "Samba", "shape": 4} "Samira Ahmet Saar" {"name": "Sanna Lianora", "shape": 4} "Sanna, Lianora" {"name": "Saparow Amangeldi", "shape": 4} "Saparow, Amangeldi" "Sarel" {"name": "Sarel", "shape": 4} "Sasilia Hamis Stober" {"name": "Sattorov Alisher", "shape": 4} "Sattorov, Alisher" {"name": "Sañsé Señ Baba Wagué Amdy Raas MP", "shape": 4} {"name": "Sepp Anne", "shape": 4} "Sepp, Anne" "Seppo Ilmarinen" "Seynabu" {"name": "Seynabu", "shape": 4} "Seán Seamas Watson" "Señ Baba Wagué Amdy Raas Sañsé MP" "Shanti Gaonkar" "Shehu Muhammed" "Shree Lalit Kumaar Bharati Jr, MP" {"name": "Shtober Zaziliya Hemish", "shape": 4} "Shtober, Zaziliya Hemish" "Sig. Fabio Massimo Carlo Alberto Martini II, AD" {"name": "Sig. Martini Fabio Massimo Carlo Alberto II, AD", "shape": 4} {"name": "Sigríður Jóna Valgerður Fanndís von Bjarnadóttir", "shape": 4} "Sigríður Jóna Valgerður Fanndís von Bjarnadóttir, M.A. í bókmenntafræði" "Sigurður" {"name": "Sigurður", "shape": 4} {"name": "Silva João Pedro", "shape": 4} {"name": "Silva Maria", "shape": 4} "Silva, José Pedro Eduardo da" "Silva, João Pedro" "Silva, Maria" "Silvije Strahimir Kranjčević" "Sinbad" {"name": "Sinbad", "shape": 4} "Sinbads" {"name": "Sinbads", "shape": 4} "Sindbad" {"name": "Sindbad", "shape": 4} {"name": "Skeggjason Hr. Geir Jón Magnús Brynjar yngri, þingmaður", "shape": 4} {"name": "Slovák Ján Matej", "shape": 4} "Slovák, Ján Matej" {"name": "Smith Cecilia Angela", "shape": 4} "Smith, Cecilia Angela" "Sn. Dr. Fatma Su Yıldırım" {"name": "Sn. Yıldırım Kaya Fatma Su, Dr.", "shape": 4} {"name": "Sodersen Martine", "shape": 4} "Sodersen, Martine" "Sofija Rita Penkauskienė" {"name": "Souto Brais Manoel", "shape": 4} "Souto, Brais Manoel" "Souza, Francisco Xavier Joao Anton de" "Sr. Antoni Baìngiu Torchitòriu Comita de Lacon Gunale" "Sr. José Pedro Eduardo da Silva Jr., MP" "Sr. Miguel Ángel Juan Antonio Pablo II" "Sr. Xosé Carlos Francisco Xavier de Castro Sáez" {"name": "Stander Zelda Herman", "shape": 4} "Stander, Zelda Herman" "Stefan" {"name": "Stefan", "shape": 4} {"name": "Stefan Katie", "shape": 4} "Stefan, Katie" {"name": "Stober Sasilia Hamis", "shape": 4} {"name": "Stober Zazilia Hamish", "shape": 4} "Stober, Sasilia Hamis" "Stober, Zazilia Hamish" {"name": "Stober, Zazilia Hamish", "shape": 4} {"name": "Stöber Hamish Zäzilia", "shape": 4} {"name": "Stöber Zezilia Hemiş", "shape": 4} {"name": "Stöber Zäzilia Hamish", "shape": 4} "Stöber, Zezilia Hemiş" "Stöber, Zäzilia Gültan" {"name": "Stöber, Zäzilia Gültan", "shape": 4} "Stöber, Zäzilia Hamish" {"name": "Stöber, Zäzilia Hamish", "shape": 4} {"name": "Støver Zelda Hamish", "shape": 4} "Støver, Zelda Hamish" "Sulaiman" {"name": "Sulaiman", "shape": 4} {"name": "Sulaiman Salimah", "shape": 4} "Sulaiman, Salimah" "Sule" {"name": "Sule", "shape": 4} "Svarun" {"name": "Svarun", "shape": 4} {"name": "Svoboda Josef Václav", "shape": 4} "Svoboda, Josef Václav" {"name": "Syed A Ismail Syed Abd Aziz MP", "shape": 4} {"name": "Syed Hassan Syed Sadiq", "shape": 4} "Syed Hassan, Syed Sadiq" "Syed Sadiq Syed Hassan" {"name": "Szabados Máté Bendegúz", "shape": 4} "Szabados, Máté Bendegúz" {"name": "Tammik hr Mari-Ann Henri Roobert parlamendiliige", "shape": 4} "Tammik, Mari-Ann Henri Roobert, parlamendiliige" "Tn. Syed Abd Aziz Syed A Ismail Petra MP" "Tomás" {"name": "Tomás", "shape": 4} {"name": "Torres Ana", "shape": 4} "Torres, Ana" "Tunde Seun Okeke" "Ulrika Wilhelmina Canth" {"name": "Umar Fatima Amina", "shape": 4} "Umar, Fatima Amina" "Urrestaratzu, Ane Miren" {"name": "Urrestaratzu, Ane Miren", "shape": 4} "Urrestaratzu, Jon Andoni" {"name": "Urrestaratzu, Jon Andoni", "shape": 4} "Ursina" {"name": "Ursina", "shape": 4} "Uxía" {"name": "Uxía", "shape": 4} {"name": "Valdimarsson Jón Hannes", "shape": 4} "Van Dalen Hoogeveen, Maria da Graça César Martín" {"name": "Van der Merwe Jan Koos", "shape": 4} "Van der Merwe, Jan Koos" {"name": "Vatson Con Hamiş", "shape": 4} "Vilkas, Ada Kornelija Eva Sofija von" "Volf, Ada Kornelia Yeva Sofiya van den" "Volf, Aleksandra Eva Sara van den" "Vuk" {"name": "Vuk", "shape": 4} {"name": "Wallin Johan Harald", "shape": 4} {"name": "Wallin, Johan Harald", "shape": 4} {"name": "Waly Mari shu Halimatu", "shape": 4} {"name": "Watson John Hamish", "shape": 4} {"name": "Watson John Hámish", "shape": 4} {"name": "Watson Mary Sue Hamish", "shape": 4} {"name": "Watson Seán Seamas", "shape": 4} "Watson, John Hamish" "Watson, John Hámish" "Watson, Mary Sue Hamish" {"name": "Weasley Ginevra Molly", "shape": 4} "Weasley, Ginevra Molly" {"name": "Weber Annelise", "shape": 4} "Weber, Annelise" "Wijaya, Maria" {"name": "Wijaya, Maria", "shape": 4} {"name": "Willemse Bertus Wilbers Henri Retief LP", "shape": 4} {"name": "Willemse Heidi Johannes", "shape": 4} "Willemse, Bertus Wilbers Henri Retief" "Willemse, Heidi Johannes" "Wićazowa, Jěwa-Marja Hańža" {"name": "Wićazowa, dr. Jěwa-Marja Hańža jr. phd", "shape": 4} "Wj. Monir Mómen Monir Mómen Balóch" "Wolf, Ada Cornelia Eva Sophia van den" "Wolf, Ada Cornelia Eva Sophia von" "Wolf, Anna Cornelia Eva Sophia van den" "Wolf, Inger Marie Adele Synnøve von" "Wolfgang Amadeus Mozart" {"name": "Wooster Bertram Wilberforce Henry Robert", "shape": 4} {"name": "Wooster Bertram Wilberforce Henry Robert AS", "shape": 4} {"name": "Wooster Bertram Wilberforce Henry Robert MP", "shape": 4} {"name": "Wooster Mr. Bertram Wilberforce Henry Robert Jr, MP", "shape": 4} {"name": "Wooster Mudane Bertram Wilberforce Henry Robert Jr, MP", "shape": 4} {"name": "Wooster Owura Bertram Wilberforce Henry Robert MP", "shape": 4} "Wooster, Bertram Wilberforce Henry Robert" "Wooster, Bertram Wilberforce Henry Robert MP" "Xiana Freire" {"name": "Yannaki Noémie Katerina", "shape": 4} "Yannaki, Noémie Katerina" "Yağız Can Kaya" "Yoshi Emomoto" "Yıldırım, Fatma Su" {"name": "Yılmaz Irmak", "shape": 4} "Yılmaz, Irmak" "Z. Dara, Gavril" {"name": "Z. Dara, Gavril A.", "shape": 4} "Z. Gavril Dara" {"name": "Zabala Carlos", "shape": 4} "Zabala, Carlos" "Zamara Imani Bukenya" "Zazilia Hamish Stober" "Zazilia Hamiş Ştöber" "Zaziliya Hemish Shtober" "Zelda Hamish Støver" "Zelda Herman Stander" "Zendaya" {"name": "Zendaya", "shape": 4} "Zendaya Marie Coleman" "Zeynep" {"name": "Zeynep", "shape": 4} "Zezilia Hemiş Stöber" "Zrinka" {"name": "Zrinka", "shape": 4} "Zäzilia Gültan Stöber" "Zäzilia Hamish Stöber" {"name": "Zäzilia Hamish Stöber", "shape": 4} "Zäzilia Stöber" {"name": "bin Stromae Elizabeth Windsor Maria Teresa MP", "shape": 4} "bin Stromae, Elizabeth Windsor Maria Teresa MP" {"name": "da Silva Sr. José Pedro Eduardo Jr., MP", "shape": 4} {"name": "de Bhulbh An tOll. An Dr. Brigid Aoibhe Sophia", "shape": 4} {"name": "de Bhulbh Brigid Aoibhe Sophia", "shape": 4} {"name": "de Castro Xosé Carlos Francisco Xavier", "shape": 4} {"name": "de Jong dhr. Bertus Wevers Harry Robert jr., mp", "shape": 4} "de Lacon, Antoni Baìngiu Torchitòriu Comita" {"name": "de Planta, dr. Margaritta Marionna Maria jr. CF", "shape": 4} {"name": "de Souza e Menezes Mr. Francisco Xavier Joao Anton Dk, MP", "shape": 4} "dhr. Bertus Wevers Harry Robert de Jong jr. mp" "dr Slađana Vera Milenković dipl. inž." "dr. Anastasia Eleni César Martín von Brühl, cand.odont." "dr. Hejba-Marja Hana Lejnikowa jr. phd" "dr. Jěwa-Marja Hańža Wićazowa jr. phd" "dr. Margaritta Marionna Maria de Planta jr. CF" "dr. p. Gabrielė Aldona Petraitytė" "dr. sc. Branko Hrvoje Marić mlađi, prof." {"name": "fan ƉAM Fostɛɛŋ Samʊwɛl", "shape": 4} {"name": "fon Brül Ada Kornelia Sezar Martin, hirurg-stomatolog lukman", "shape": 4} "g. Adnan Mirza Hodžić zastupnik u parlamentu" "g. Hodžić Adnan Mirza, zastupnik u parlamentu" {"name": "g. Hodžić Adnan Mirza, zastupnik u parlamentu", "shape": 4} "g. Janez Peter Novak ml., univ. dipl. inž." {"name": "g. Novak Janez Peter, univ. dipl. inž.", "shape": 4} "hr. Hans Henrik Sven Åge Møller, MF" "jenap Atajan Saparowiç Gurdow magistr, ylymlaryň kandidaty" "m.d ph.d. Inger Marie Adele Synnøve von Wolf jr." {"name": "m.d ph.d. von Wolf Inger Marie Adele Synnøve", "shape": 4} "med. dr. fil. dr. jur. dr. Ada Cornelia César Martín von Brühl González Domingo" "med. m. dr. prof. dr. Ada Kornelija Eva Sofija von Vilkas Jankauskas-Baranauskas" "mevrouw Ada Cornelia César Martín von Brühl jr. PhD" "mnr. Bertus Wilbers Henri Retief Willemse jnr., LP" {"name": "mwana Hamisi Salima Farida M.D. Ph.D.", "shape": 4} {"name": "onorèvole Sr. de Lacon Gunale Antoni Baìngiu Torchitòriu Comita", "shape": 4} {"name": "p. Petraitytė Gabrielė Aldona", "shape": 4} "pani Alexandra Zuzana Macáková PhD" {"name": "pani Macáková Alexandra Zuzana, PhD", "shape": 4} "paní Alexandra Zuzana Machová Ondřejová, Ph.D." {"name": "paní Machová Ondřejová Alexandra Zuzana, Ph.D.", "shape": 4} "prof. dr Ada Cornelia von Brühl" "prof. dr Aleksandra Eva Sara van den Volf Petrović Jugović dipl. inž." {"name": "prof. dr von Brühl Ada Cornelia César Martín", "shape": 4} "prof. dr. Ada Cornelia César Martín von Brühl Jr, univ. bacc" "prof. dr. Ada Cornelia César Martín von Brühl ml., dr. med., dr. dent. med." "prof. dr. Anna Cornelia Eva Sophia van den Wolf jr. m.d. ph.d." "prof. dr. Gentiana van den Popa, dr. shk. mjek." "prof. dr. Izeta Amra von Selimović Hodžić, dipl. stom." "prof. dr. Popa, Gentiana van den" {"name": "prof. dr. van den Popa, Gentiana M., dr. shk. mjek.", "shape": 4} {"name": "prof. dr. von Brühl Ada Cornelia César Martín, dr. med., dr. dent. med.", "shape": 4} "prof. dr. von Selimović Hodžić, Izeta Amra, dipl. stom." {"name": "prof. dr. von Selimović Hodžić, Izeta Amra, dipl. stom.", "shape": 4} {"name": "prof. dr. von Vilkas Jankauskas-Baranauskas, Ada Kornelija Eva Sofija", "shape": 4} "professori Ana Christina Lëja von Wright González Domingo, MD DDS" "st.repr. Gregers Werle Marve Almar von Fleksnes jr." {"name": "st.repr. von Fleksnes Gregers Werle Marve Almar", "shape": 4} {"name": "u-Adier u-Irene", "shape": 4} "u-Adier, u-Irene" "u-Irene u-Adier" "u-John u-Hamish u-Watson" "u-Prof. Dr. u-Ada Cornelia u-Eva Sophia u-van den u-Wold u-Becker Schmidt M.D. Ph.D." {"name": "u-Watson u-John u-Hamish", "shape": 4} "u-Watson, u-John u-Hamish" "u-Wold, u-Ada Cornelia u-Eva Sophia u-van den" {"name": "u-van den u-Wold u-Prof. Dr. u-Ada Cornelia u-Eva Sophia isizukulwane, M.D. Ph.D.", "shape": 4} {"name": "van den Berg Cornelia Margarita Sofia Aletta MBChB PhD", "shape": 4} {"name": "van den Volf Ada Kornelia Sezar Martin M.D. Ph.D.", "shape": 4} {"name": "van den Volf Ada Kornelia Yeva Sofiya M.D. Ph.D.", "shape": 4} "van den Volf Becker Schmidt, Prof. Dr. Ada Kornelia Sezar Martin M.D. Ph.D." {"name": "van den Volf prof. dr Aleksandra Eva Sara mlađi, dipl. inž.", "shape": 4} {"name": "van den Volfa Laima Kristīne", "shape": 4} {"name": "van den Volfa Laima Kristīne Eva Sofija", "shape": 4} {"name": "van den Wolf Becker Schmidt Farf. Dr. Ada Cornelia Eva Sophia M.D. Ph.D.", "shape": 4} {"name": "van den Wolf Prof. Dr. Ada Cornelia Eva Sophia Jr, M.D. Ph.D.", "shape": 4} {"name": "van den Wolf, Prof. Dr. Anna Cornelia Eva Sophia jr. M.D. Ph.D.", "shape": 4} {"name": "van den Wolf, prof. dr. Anna Cornelia Eva Sophia jr. m.d. ph.d.", "shape": 4} {"name": "van. den. Wolf Ada Cornelia Eva Sophia M.D. Ph.D.", "shape": 4} "van. den. Wolf, Ada Cornelia Eva Sophia M.D. Ph.D." {"name": "von Bjarnadóttir dr. Sigríður Jóna Valgerður Fanndís yngri, M.A. í bókmenntafræði", "shape": 4} {"name": "von Bruhel Ada Cornelia Martina MD DDS", "shape": 4} {"name": "von Bruhl Prof. Dr. Ada Cornelia Cesar Martin Jr, MD DDS", "shape": 4} {"name": "von Brühl Ada Cornelia César Martín", "shape": 4} {"name": "von Brühl Ada Cornelia César Martín M.D. Ph.D.", "shape": 4} {"name": "von Brühl César Martín Ada Cornelia", "shape": 4} {"name": "von Brühl González Domingo Prof. Dr. Ada Cornelia César Martín Jr, MD DDS", "shape": 4} {"name": "von Brühl González Domingo Prof. Dr. Ada Cornelia César Martín MD DDS", "shape": 4} {"name": "von Brühl González Domingo Prop. Dr. Ada Cornelia César Martín MD DDS", "shape": 4} "von Brühl González Domingo, Ada Cornelia César Martín" {"name": "von Brühl González Domingo, Prof. Dr. Ada Cornelia César Martín Jr, MD DDS", "shape": 4} "von Brühl González Domingo, Prof. Dr. Ada Cornelia César Martín M.D. Ph.D." "von Brühl González Domingo, Prof. Dr. Ada Cornelia César Martín MD DDS" "von Brühl González Domingo, Prop. Dr. Ada Cornelia César Martín MD DDS" {"name": "von Brühl Prof. Dr. Ada Cornelia César Martín Jr, MD DDS", "shape": 4} {"name": "von Brühl Prof. Dr. Mary Lou Cesare Martín Jr., Dr. Ph.D", "shape": 4} {"name": "von Brühl mevrouw Ada Cornelia César Martín jr., PhD", "shape": 4} "von Brühl, Ada Cornelia César Martín" "von Brühl, Ada Cornelia César Martín MD DDS" {"name": "von Brühl, Ada Cornelia César Martín MD DDS", "shape": 4} "von Brühl, Anastasia Eleni César Martín" "von Brühl, Maria Giusepa Juana Teresa" {"name": "von Brühl, Prof. Dr. Ada Cornelia César Martín Jr MD DDS", "shape": 4} {"name": "von Brühl, Prof. dr. Ada Cornelia César Martín, med. dr. fil. dr. jur. dr.", "shape": 4} {"name": "von Brühl, dr. Anastasia Eleni César Martín, cand.odont.", "shape": 4} {"name": "von Brühl, prof. dr. Ada Cornelia César Martín Jr, univ. bacc", "shape": 4} {"name": "von Brül Erika Cornelia Anna Isabelle II", "shape": 4} {"name": "von Fischer, Dr. Paul Vinzent jr. MdB", "shape": 4} {"name": "von Kovács Luca Sarolta Éva Erzsébet M.D. Ph.D.", "shape": 4} "von Kovács, Luca Sarolta Éva Erzsébet M.D. Ph.D." {"name": "von Miller Jones Mary Sue Marie II, MD DDS", "shape": 4} "von Miller Jones, Prof. Dr. Mary Sue Marie" {"name": "von Vall Heinmann Laas Prof Anna Liina Eva Sofia MD, PhD", "shape": 4} "von Vall, Anna Liina Eva Sofia, MD, PhD" {"name": "von Wolf Ada Cornelia Eva Sophia MD DDS", "shape": 4} {"name": "von Wright González Domingo Ana Christina Lëja, professori MD DDS", "shape": 4} "von Wright, Ana Christina Lëja" {"name": "ván den Wólf Adá Cornelia Ẹ́va Sophia M.D. PhD.", "shape": 4} "ván den Wólf, Adá Cornelia Ẹ́va Sophia M.D. PhD." {"name": "won Baar Dominique Goudiaby Prof. Dr. Adama Coulibaly Camara Massamba MD DDS", "shape": 4} "won Baar Dominique Goudiaby, Prof. Dr. Adama Coulibaly Camara Massamba MD DDS" "ylymlaryň doktory, professor Ada Kornelia Sezar Martin fon Brül Gonzales Domingo, hirurg-stomatolog lukman" "Íris Adólfsdóttir" {"name": "Íris Adólfsdóttir", "shape": 4} "Ông Anh Văn Nguyễn ĐBQH" {"name": "ĐBQH Nguyễn Văn Anh", "shape": 4} "İren Adler" {"name": "Ştöber Zazilia Hamiş", "shape": 4} "Šejc, Jan Měto" {"name": "Šejc, Jan Měto", "shape": 4} "Šołćina, Hanka" {"name": "Šołćina, Hanka", "shape": 4} {"name": "Šteber Cecilija Hemiš", "shape": 4} "Šteber, Cecilija Hemiš" {"name": "ʊŋono Alaaji KALAM Alɛ Mustafa", "shape": 4} "ʊŋono Alaaji Mustafa KALAM Alɛ" "Δημήτρης" {"name": "Δημήτρης", "shape": 4} "Ειρήνη Πολίτη" "Ζεζίλια Χάμις Στέμπερ" "Κάτε Μίλερ" "Καθ. δρ. Άντα Κορνέλια Σέσαρ Μαρτίν φον Μπριλ Γκονθάλεθ Δομίνγκο Δρ.Ι. Δρ.Χ.Ο" {"name": "Καρατζής Πέτρος Νικόλαος", "shape": 4} "Καρατζής, Πέτρος Νικόλαος" "Κος Νικόλαος Καρατζής Κωνσταντίνος Μάριος Χατζής" {"name": "Μίλερ Κάτε", "shape": 4} "Μίλερ, Κάτε" "Μπριλ, φον Άντα Κορνέλια Σέσαρ Μαρτίν" "Πέτρος Νικόλαος Καρατζής" {"name": "Πολίτη Ειρήνη", "shape": 4} "Πολίτη, Ειρήνη" "Σινμπάντ" {"name": "Σινμπάντ", "shape": 4} {"name": "Στέμπερ Ζεζίλια Χάμις", "shape": 4} "Στέμπερ, Ζεζίλια Χάμις" {"name": "Χατζής Νικόλαος Καρατζής Κωνσταντίνος Μάριος", "shape": 4} "Χατζής, Νικόλαος Καρατζής Κωνσταντίνος Μάριος" {"name": "φον Μπριλ Άντα Κορνέλια Σέσαρ Μαρτίν Δρ.Ι. Δρ.Χ.Ο", "shape": 4} "Єва Марія Шевченко" "Ірына Алейнік" "Јован Драган Поповић" "Ада Карнэлія Сесар Марцін фон Бруль д-р" "Ада Корнелия фон Брюль Джуниор, тіс дәрігері" {"name": "Адлер Ирене", "shape": 4} "Азамат Салауат улы Фаттахов" "Айдар Қасым" {"name": "Алейнік Ірына", "shape": 4} "Алейнік, Ірына" {"name": "Алымбекова Каныкей", "shape": 4} {"name": "Амосова Ирина", "shape": 4} "Амосова, Ирина" "Ана Иванова Димитрова" "Анна Мельник" "Аталан" {"name": "Аталан", "shape": 4} {"name": "Ашмарин Николай Иванович", "shape": 4} "Ашмарин, Николай Иванович" "Бат" {"name": "Бат", "shape": 4} "Бертрам Вилберфорс Ҳенру Роберт Вустер" {"name": "Болд Дулмаа", "shape": 4} "Болд, Дулмаа" "Бруль, Ада Карнэлія Сесар Марцін фон" "Брюл, Ада Корнелия Цезар Мартин фон" "Брюль, Ада Корнелия Сезар Мартин фон" "Брюхл, Ада Корнелиа Эва Софиа вон" {"name": "Васильев Дмитрий Петрович", "shape": 4} "Васильев, Дмитрий Петрович" "Васіль" {"name": "Васіль", "shape": 4} {"name": "Ватсон Мэри Сю Хамиш", "shape": 4} "Вернер, Иван Асен фон" "Волф, Александра Ева Сара ван ден" "Волф, Ана Марија Цезар Мартин ван ден" "Вольга Караліна Равенская" {"name": "Вустер Бертрам Вилберфорс Ҳенру Роберт", "shape": 4} "Джон" {"name": "Джон", "shape": 4} "Джоунс, Зара Мери" {"name": "Джоунс, Зара Мери", "shape": 4} "Димитрова, Ана Иванова" {"name": "Димитрова, Ана Иванова", "shape": 4} "Дмитрий Петрович Васильев" {"name": "Дорж Цэцэг", "shape": 4} "Дорж, Цэцэг" "Дулмаа Болд" "Зазилиа Хамиш Стобер" "Зазилия Хамиш Штобер" "Зазилия Ҳамиш Стобер" "Зазилія Хеміш Стобер" "Зазілія Хэміш Шцёбер" "Зара Мери Джоунс" "Заңғар Тәуекел" "Зендая" {"name": "Зендая", "shape": 4} "Иван" {"name": "Иван", "shape": 4} "Иван Васильевич Купцов" {"name": "Иванов Константин Васильевич", "shape": 4} "Иванов, Константин Васильевич" "Илемпи Корнилова" "Ирена Марковић" "Ирене Адлер" "Ирина Амосова" "Ирина Яҡупова" {"name": "Кавалёў Мікалай Аляксандр", "shape": 4} "Кавалёў, Мікалай Аляксандр" "Каныкей Алымбекова" "Касым Жоомарт Токаев" "Кате Мюллер" "Кати Мюлер" "Кейт Мюллер" "Кете Милер" "Кете Мюллер" "Кетэ Мюлер" "Константин Васильевич Иванов" {"name": "Корнилова Илемпи", "shape": 4} "Корнилова, Илемпи" "Крсте" {"name": "Крсте", "shape": 4} "Крсте Мисирков" "Крсте Петков Мисирков" {"name": "Купцов Иван Васильевич", "shape": 4} "Купцов, Иван Васильевич" "Кэйт Мюллер" "Кэти Мюлер" "Мария Петрова" {"name": "Марковић Ирена", "shape": 4} "Марковић, Ирена" {"name": "Мельник Анна", "shape": 4} {"name": "Миленковић др Слађана Вера млађи, дипл. инж.", "shape": 4} "Миленковић, Слађана Вера" {"name": "Милер Кете", "shape": 4} "Милер, Кете" {"name": "Мисирков Крсте", "shape": 4} {"name": "Мисирков Крсте Петков", "shape": 4} "Мисирков, Крсте" "Мисирков, Крсте Петков" "Мырза Чыңгыз Айтматов Исхак Раззаков уулу Төрөкул Махмудов Парламент мүчөсү" "Мэри Сю Хамиш Ватсон" {"name": "Мюлер Кетэ", "shape": 4} {"name": "Мюлер Кэти", "shape": 4} "Мюлер, Кати" {"name": "Мюлер, Кати", "shape": 4} "Мюлер, Кетэ" {"name": "Мюллер Кате", "shape": 4} {"name": "Мюллер Кейт", "shape": 4} {"name": "Мюллер Кете", "shape": 4} {"name": "Мюллер Кэйт", "shape": 4} "Мюллер, Кате" "Мюллер, Кейт" "Мюллер, Кете" "Мікалай Аляксандр Кавалёў" {"name": "Мұхамеджан Әсем", "shape": 4} "Мұхамеджан, Әсем" "Николай Иванович Ашмарин" "Ноён Азжаргал Төмөр" "Нұрлан" {"name": "Нұрлан", "shape": 4} "Петренко Анна Марія Богдан-Зиновій, доц., проф." "Петрова, Мария" {"name": "Петрова, Мария", "shape": 4} {"name": "Поповић Јован Драган", "shape": 4} "Поповић, Јован Драган" "Проф. Док. Ада Корнелиа Сезар Мартин вон Брухи Гонзалес Доминго" "Проф. Доктор. Алтынай Сулайманова Асель Сартбаева ван ден Вольф Бекер Шмидт Илимдин кандидаты" "Проф. Др. Ада Корнелиа Эва Софиа вон Брюхл Жр, Анагаах ухааны доктор" {"name": "Равенская Вольга Караліна", "shape": 4} "Равенская, Вольга Караліна" "Сергей" {"name": "Сергей", "shape": 4} "Сесилия Штобер" "Синбад" {"name": "Синбад", "shape": 4} "Синдбад" {"name": "Синдбад", "shape": 4} {"name": "Стобер Зазилиа Хамиш", "shape": 4} {"name": "Стобер Зазилия Ҳамиш", "shape": 4} {"name": "Стобер Зазилія Хеміш", "shape": 4} "Стобер, Зазилиа Хамиш" "Сыймык" {"name": "Сыймык", "shape": 4} "Сіндбад" {"name": "Сіндбад", "shape": 4} {"name": "Токаев Касым Жоомарт", "shape": 4} {"name": "Тәуекел Заңғар", "shape": 4} "Тәуекел, Заңғар Ермекұлы" {"name": "Төмөр Ноён Азжаргал", "shape": 4} "Төмөр, Азжаргал" "Урал" {"name": "Урал", "shape": 4} {"name": "Фаттахов Азамат Салауат улы", "shape": 4} "Фаттахов, Азамат Салауат улы" "Цецилия Хэмиш Штёбер" "Цецилија Хамиш Штебер" "Цецилија Хемиш Штебер" "Цэцэг Дорж" {"name": "Шевченко Єва Марія", "shape": 4} {"name": "Штебер Цецилија Хамиш", "shape": 4} {"name": "Штебер Цецилија Хемиш", "shape": 4} "Штебер, Цецилија Хамиш" "Штебер, Цецилија Хемиш" {"name": "Штобер Зазилия Хамиш", "shape": 4} {"name": "Штобер Сесилия", "shape": 4} "Штобер, Сесилия Хэмиш" {"name": "Штёбер Цецилия Хэмиш", "shape": 4} "Штёбер, Цецилия Хэмиш" {"name": "Шцёбер Зазілія Хэміш", "shape": 4} "Шцёбер, Зазілія Хэміш" "Юрій" {"name": "Юрій", "shape": 4} {"name": "Яҡупова Ирина", "shape": 4} "Яҡупова, Ирина" {"name": "ван ден Волф проф. др Александра Ева Сара млађи, дипл. инж.", "shape": 4} {"name": "ван ден Вольф Алтынай Сулайманова Асель Сартбаева Илимдин кандидаты", "shape": 4} "ван ден Вольф Бекер Шмидт, Проф. Доктор. Алтынай Сулайманова Асель Сартбаева Илимдин кандидаты" {"name": "вон Брухи Гонзалес Доминго Проф. Док. Ада Корнелиа Сезар Мартин", "shape": 4} "вон Брухи Гонзалес Доминго, Проф. Док. Ада Корнелиа Сезар Мартин" {"name": "вон Брюхл Гонзалес Доминго Проф. Др. Ада Корнелиа Эва Софиа Анагаах ухааны доктор", "shape": 4} {"name": "г-н Вернер, Иван Асен, проф. д-р", "shape": 4} "г-н Иван Асен Вернер, проф. д-р" "др Слађана Вера Миленковић дипл. инж." "проф. Анастасія Єва Софія фон Вольф Гонзалес Домінго, д-р" "проф. Анна Марія Богдан-Зиновій Петренко доц." {"name": "проф. Петренко Анна Марія Богдан-Зиновій доц.", "shape": 4} "проф. д-р Ада Корнелия Цезар Мартин Брюл, доктор" "проф. д-р Ана Марија Цезар Мартин ван ден Волф" {"name": "проф. д-р Брюл, Ада Корнелия Цезар Мартин, доктор", "shape": 4} "проф. д-р Крсте Петков Мисирков" {"name": "проф. д-р Мисирков Крсте Петков", "shape": 4} {"name": "проф. д-р ван ден Волф Ана Марија Цезар Мартин", "shape": 4} "проф. др Александра Ева Сара ван ден Волф Петровић Југовић дипл. инж." {"name": "проф. фон Вольф Гонзалес Домінго Анастасія Єва Софія д-р", "shape": 4} "проф., д-р Ада Корнелия Сезар Мартин фон Брюль кесе, м. ф. д." "проф., д-р Ада Корнелия Сезар Мартин фон Брюль — мл., к. м. н." "проф., д-р Ада Корнелия Сезар Мартин фон Брюль — мл., к.м.н." "уулу Төрөкул Махмудов, Мырза Чыңгыз Айтматов Исхак Раззаков Парламент мүчөсү" {"name": "уулу Төрөкул Чыңгыз Айтматов Исхак Раззаков Парламент мүчөсү", "shape": 4} {"name": "фон Бруль Ада Карнэлія Сесар Марцін д-р", "shape": 4} {"name": "фон Брюль Ада Корнелия", "shape": 4} {"name": "фон Брюль Ада Корнелия Сезар Мартин, к. м. н.", "shape": 4} {"name": "фон Брюль проф., д-р Ада Корнелия Сезар Мартин к.м.н.", "shape": 4} {"name": "фон Брюль проф., д-р Ада Корнелия Сезар Мартин кесе, м. ф. д.", "shape": 4} "фон Вольф Гонзалес Домінго Анастасія Єва Софія, д-р, проф." {"name": "Қасым Айдар", "shape": 4} "Қасым, Айдар" "Әсем Мұхамеджан" {"name": "Ադամյան Հեղինե", "shape": 4} "Արթուր" {"name": "Արթուր", "shape": 4} {"name": "Դարբինյան Հովհաննես", "shape": 4} {"name": "Դարբինյան Հովհաննես Արմենի", "shape": 4} "Հեղինե Ադամյան" "Հովհաննես Արմենի Դարբինյան" {"name": "Մյուլեր Քեյթ", "shape": 4} {"name": "Շտյոբեր Ցեցիլիա", "shape": 4} {"name": "Շտյոբեր Ցեցիլիա Համիշ", "shape": 4} "Սինբադ" {"name": "Սինբադ", "shape": 4} {"name": "Վոլֆ Ադա Կոռնելիա", "shape": 4} {"name": "Տեր-Պողոսյան Հայկ", "shape": 4} "Ցեցիլիա Համիշ Շտյոբեր" "Քեյթ Մյուլեր" {"name": "բ․գ․դ․ Տեր-Պողոսյան Հայկ Պարգևի", "shape": 4} "բ․գ․դ․ դ-ր Ադա Կոռնելիա Սեզար Մարտին վան դեն Վոլֆ" "բ․գ․դ․ պրն Հայկ Պարգևի Տեր-Պողոսյան" {"name": "բ․գ․դ․ վան դեն Վոլֆ Ադա Կոռնելիա Սեզար Մարտին", "shape": 4} {"name": "אבן אבן-גבירול מר בנימין משה ח״כ", "shape": 4} "דנית לוי" "זזיליה חמיש סטובר" "יונתן" {"name": "יונתן", "shape": 4} "יונתן חיים כהן" {"name": "כהן יונתן חיים", "shape": 4} {"name": "לוי דנית", "shape": 4} {"name": "מולר קתה", "shape": 4} "מר בנימין משה אבן אבן-גבירול ח״כ" {"name": "סטובר זזיליה חמיש", "shape": 4} "סינבד" {"name": "סינבד", "shape": 4} {"name": "פון ברוהל גונזלס דומינגו פרופ׳ עדה קורנליה סזאר מרטין ד״ר", "shape": 4} "פון ברוהל גונזלס דומינגו, פרופ׳ עדה קורנליה סזאר מרטין ד״ר" "פרופ׳ עדה קורנליה סזאר מרטין פון ברוהל גונזלס דומינגו ד״ר" "קתה מולר" "آئرين ائڊلر" "آقای امیر هوشنگ رستگار دکتر" "آیتین عدلو" {"name": "أبو الأغا، أحمد رامي نجيب محفوظ", "shape": 4} {"name": "ائڊلر آئرين", "shape": 4} "احمد" {"name": "احمد", "shape": 4} "ادا كورنەليا فون بريۋل جۋنيور، ءتىس دارىگەرى" "ارین اډلر" {"name": "استوبر، زازیلیا هایمیش", "shape": 4} {"name": "اسٽوبر زيزيليا ھئمش", "shape": 4} "اسەم" {"name": "اسەم", "shape": 4} {"name": "اصغر شازیہ", "shape": 4} {"name": "اصغر، شازیہ", "shape": 4} "الدكتور عايدة كورنيليا سيزار مارتن فون برول الإبن، طبيب وجراح" "السيد أحمد رامي نجيب محفوظ أبو الأغا الابن، النائب" {"name": "العامر، كمال مجدي", "shape": 4} {"name": "النجار، سميرة", "shape": 4} "امید رضا ملایری" {"name": "اډلر ارین", "shape": 4} {"name": "بالقىباي ماقسات", "shape": 4} {"name": "بالقىباي، ماقسات توقماحامبەتۇلى", "shape": 4} {"name": "بروهل، آدا کورنلیا سزار مارتین وان", "shape": 4} {"name": "بريۋل، ادا كورنەليا سەزار مارتين فون", "shape": 4} "جان همیش واټسن" "حاتم منصور" {"name": "خان مریم نواز", "shape": 4} {"name": "خان، مریم نواز", "shape": 4} {"name": "رستگار، آقای امیر هوشنگ دکتر", "shape": 4} {"name": "رستگار، امیر هوشنگ", "shape": 4} "زازیلیا هایمیش استوبر" "زيزيليا ھئمش اسٽوبر" "زيزينيا هاميش ستوبر" "زیزیلیا همیش سټوبر" "سامانتا" {"name": "سامانتا", "shape": 4} {"name": "ستوبر، زيزينيا هاميش", "shape": 4} "سميرة النجار" "سنباد" {"name": "سنباد", "shape": 4} "سندباد" {"name": "سندباد", "shape": 4} "سينباد" {"name": "سينباد", "shape": 4} {"name": "سټوبر زیزیلیا همیش", "shape": 4} {"name": "سید عبد الکریم عبد الحمید ایم پی", "shape": 4} {"name": "سید، عبد الکریم عبد الحمید ایم پی", "shape": 4} "سەسيليا شتوبەر" "شازیہ اصغر" {"name": "شتوبەر سەسيليا", "shape": 4} {"name": "شتوبەر، سەسيليا حەمىش", "shape": 4} {"name": "صابری محمد ہاشم", "shape": 4} {"name": "صابری، محمد ہاشم", "shape": 4} "عبد الکریم عبد الحمید سید ایم پی" {"name": "عدلو، آیتین", "shape": 4} {"name": "فون برول، عايدة كورنيليا سيزار مارتن", "shape": 4} {"name": "فون بريۋل ادا كورنەليا", "shape": 4} "قالاجان قايرات" {"name": "قايرات قالاجان", "shape": 4} {"name": "قايرات، قالاجان", "shape": 4} "كاثرين مولر" {"name": "كارىباي مۇرات", "shape": 4} {"name": "كارىباي، مۇرات", "shape": 4} "كمال مجدي العامر" "كەيت مۇللەر" "ماقسات بالقىباي" "محترم برٽرم ولبرفورس ھينري رابرٽ ووسٽر ايم پي" "محمد ہاشم صابری" "مریم نواز خان" {"name": "ملایری، امید رضا", "shape": 4} {"name": "ملر ڪئٿي", "shape": 4} {"name": "منصور حاتم", "shape": 4} {"name": "منصور، حاتم", "shape": 4} "منير" {"name": "منير", "shape": 4} {"name": "مولر کیتی", "shape": 4} {"name": "مولر، كاثرين", "shape": 4} {"name": "مولر، کاتیا", "shape": 4} "ميري سيو ھامش واٽسن" "مۇرات كارىباي" {"name": "مۇللەر كەيت", "shape": 4} {"name": "مۇللەر، كەيت", "shape": 4} {"name": "ندیم رضا یاسمین راشد سدرہ منیر ایم ڈی ڈی ڈی ایس", "shape": 4} {"name": "ندیم رضا، یاسمین راشد سدرہ منیر ایم ڈی ڈی ڈی ایس", "shape": 4} {"name": "وان برل گونزالس ڊومنگو پروفيسر ڊاڪٽر ايڊا ڪورنيليا سيزر مارٽن ايم ڊي ڊي ڊي ايس", "shape": 4} "وان برل گونزالس ڊومنگو, پروفيسر ڊاڪٽر ايڊا ڪورنيليا سيزر مارٽن ايم ڊي ڊي ڊي ايس" {"name": "وان بروهل، پروفسور آدا کورنلیا سزار مارتین جونیور، دکترای دندانپزشکی", "shape": 4} {"name": "واټسن جان همیش", "shape": 4} {"name": "واٽسن ميري سيو ھامش", "shape": 4} {"name": "ووسټر ښاغلی. برټرام ولبرفورس هنري رابرټ ایم پی", "shape": 4} {"name": "ووسٽر محترم برٽرم ولبرفورس ھينري رابرٽ ايم پي", "shape": 4} {"name": "وون لیوه بیکر شمیت پروفیسور ډاکټر اډا کورنیلیا ایوا سوفیا ایم ‌ډی ډیی ډیی ایس", "shape": 4} "وون لیوه بیکر شمیت, پروفیسور ډاکټر اډا کورنیلیا ایوا سوفیا ایم ‌ډی ډیی ډیی ایس" "پروفسور آدا کورنلیا سزار مارتین وان بروهل جونیور، دکترای دندانپزشکی" "پروفيسر ڊاڪٽر ايڊا ڪورنيليا سيزر مارٽن وان برل گونزالس ڊومنگو ايم ڊي ڊي ڊي ايس" "پروفیسور ډاکټر اډا کورنیلیا ایوا سوفیا وون لیوه بیکر شمیت ایم ‌ډی ډیی ډیی ایس" "ژیلا" {"name": "ژیلا", "shape": 4} "ښاغلی. برټرام ولبرفورس هنري رابرټ ووسټر ایم پی" "کاتیا مولر" "کیتی مولر" "ڪئٿي ملر" "یاسمین راشد سدرہ منیر ندیم رضا ایم ڈی ڈی ڈی ایس" "आलिया हमीश हेमंत" "एडा कर्नेलिया सिजर मार्टिन भोन ब्रुहल एमडी डिडिएस" "काठे मुलर" "काथा बाउमान" "कीथ ब्राउन" "क्याथी मुलर" {"name": "गांवकर शांति", "shape": 4} "गांवकर, शांति" "जाजिला हामिश स्टोबा" "जाजिला हार्निस स्टोबर" {"name": "जोनस मैरी सिलविया", "shape": 4} "जोनस, मैरी सिलविया" "दिशा" {"name": "दिशा", "shape": 4} {"name": "दे सोजा ए मेनेजेस श्री फ्रांसिस शावियेर जुआंव आंतोन धा., एमपी", "shape": 4} {"name": "देशपांडे धिरज", "shape": 4} {"name": "देशपांडे श्री. धिरज देशपांडे विजय देशपांडे क., एम. पी.", "shape": 4} "देशपांडे, धिरज" "देशपांडे, धिरज देशपांडे विजय देशपांडे" "धिरज देशपांडे" {"name": "पोख्रेल रमिता", "shape": 4} "पोख्रेल, रमिता" "प्रेमप्रसाद हिमलाल वास्तोला" "प्रो. डॉ. आडा कोर्नेलिया सेजार मार्टिन वॉन ब्रूह्ल गॉन्जालेज डोमिंगो जेआर, बी.ए. एल.एल.बी" "प्रोफे.डॉ. अदा शर्मा सीझर मार्टिन व्हॉन ब्र्यूल ज्यु., एम. डी. डी. डी. एस." "प्रो॰ डॉ॰ माइकल जॉन वॉन वॉटसन चेस्टर, एम॰डी॰, पी॰एच॰डी॰" {"name": "प्रो॰ डॉ॰ वॉन वॉटसन माइकल जॉन, एम॰डी॰, पी॰एच॰डी॰", "shape": 4} {"name": "फडके रिधान समीर", "shape": 4} "फडके, रिधान समीर" {"name": "फर्नॅन्डेज मारिया हेलेना", "shape": 4} "फर्नॅन्डेज, मारिया हेलेना" "बर्ट्राम विलबरफोर्स हेनरी रोबर्ट वुस्टर एमपी" {"name": "बाउमान काथा", "shape": 4} "बाउमान, काथा" {"name": "ब्राउन कीथ", "shape": 4} "ब्राउन, कीथ" "ब्रुहल, एडा कर्नेलिया सिजर मार्टिन भोन" "ब्रूह्ल, आडा कोर्नेलिया सेजार मार्टिन वॉन" "ब्र्यूल, अदा शर्मा सीझर मार्टिन व्हॉन" {"name": "भोन ब्रुहल एडा कर्नेलिया सिजर मार्टिन एमडी डिडिएस", "shape": 4} "माधुरी" {"name": "माधुरी", "shape": 4} "मारिया हेलेना फर्नॅन्डेज" {"name": "मुलर काठे", "shape": 4} {"name": "मुलर क्याथी", "shape": 4} "मुलर, काठे" "मुलर, क्याथी" "मैरी सिलविया जोनस" "रमिता पोख्रेल" "रिधान समीर फडके" "रॉबर्ट" {"name": "रॉबर्ट", "shape": 4} "ललित" {"name": "ललित", "shape": 4} "ललित मोहन शर्मा" "ललित शर्मा" {"name": "वास्तोला प्रेमप्रसाद हिमलाल", "shape": 4} "वास्तोला, प्रेमप्रसाद हिमलाल" {"name": "वुस्टर बर्ट्राम विलबरफोर्स हेनरी रोबर्ट एमपी", "shape": 4} "वुस्टर, बर्ट्राम विलबरफोर्स हेनरी रोबर्ट" {"name": "वॉन ब्रूह्ल गॉन्जालेज डोमिंगो प्रो. डॉ. आडा कोर्नेलिया सेजार मार्टिन जेआर, बी.ए. एल.एल.बी", "shape": 4} "वॉन वॉटसन, माइकल जॉन एम॰डी॰, पी॰एच॰डी॰" {"name": "व्हॉन ब्र्यूल प्रोफे.डॉ. अदा शर्मा सीझर मार्टिन ज्यु., एम. डी. डी. डी. एस.", "shape": 4} {"name": "शर्मा ललित", "shape": 4} {"name": "शर्मा ललित मोहन", "shape": 4} "शर्मा, ललित" "शर्मा, ललित कुमार एम॰डी॰" "शर्मा, ललित मोहन" "शांति गांवकर" "श्री फ्रांसिस शावियेर जुआंव आंतोन दे सोजा ए मेनेजेस धा., एमपी" "श्री ललित कुमार शर्मा एम॰डी॰" {"name": "श्री शर्मा ललित कुमार, एम॰डी॰", "shape": 4} "श्री. धिरज देशपांडे विजय देशपांडे देशपांडे क., एम. पी." "सिंदबाद" {"name": "सिंदबाद", "shape": 4} "सिनबाद" {"name": "सिनबाद", "shape": 4} "सुन्दर" {"name": "सुन्दर", "shape": 4} "सोजा, फ्रांसिस शावियेर जुआंव आंतोन दे" {"name": "स्टोबर जाजिला हार्निस", "shape": 4} "स्टोबर, जाजिला हार्निस" {"name": "स्टोबा जाजिला हामिश", "shape": 4} "स्टोबा, जाजिला हामिश" {"name": "हेमंत आलिया हमीश", "shape": 4} "हेमंत, आलिया हमीश" "আডা কৰ্ণেলিয়া ইভা চ’ফিয়া ভেন ডেন কুকুৰনেচীয়া এম. ডি. ডি. ডি. এছ" {"name": "আহমেদ কিরণ", "shape": 4} {"name": "কাকতি নৰেন কলিতা ৰুমী বৰুৱা এম. পি.", "shape": 4} "কাকতি, নৰেন কলিতা ৰুমী বৰুৱা" "কিরণ আহমেদ" "কিশোৰ" {"name": "কিশোৰ", "shape": 4} "কুকুৰনেচীয়া, আডা কৰ্ণেলিয়া ইভা চ’ফিয়া ভেন ডেন" {"name": "গাঙ্গুলী জনাব এমদাদ আলি অনিল কুমার এমপি", "shape": 4} {"name": "চৌধুরী শ্রেয়া", "shape": 4} "জনাব এমদাদ আলি অনিল কুমার গাঙ্গুলী এমপি" "জাজিলিয়া হামিশ মুন্সি" "জেনদায়া" {"name": "জেনদায়া", "shape": 4} "নগেন বৰা" "নমিতা বৰুৱা" "নৰেন কলিতা ৰুমী বৰুৱা কাকতি এম. পি." "প্রঃ ডঃ আদিরা ইভা সোফিয়া রহমান আলি তালুকদার এম.ডি.পিএইচ.ডি." {"name": "বৰা নগেন", "shape": 4} "বৰা, নগেন" {"name": "বৰুৱা নমিতা", "shape": 4} {"name": "বৰুৱা মণিমালা অজিত", "shape": 4} "বৰুৱা, নমিতা" "বৰুৱা, মণিমালা অজিত" {"name": "ভেন ডেন কুকুৰনেচীয়া আডা কৰ্ণেলিয়া ইভা চ’ফিয়া এম. ডি. ডি. ডি. এছ", "shape": 4} "মণিমালা অজিত বৰুৱা" {"name": "মুন্সি জাজিলিয়া হামিশ", "shape": 4} "মেরি সু হামিশ রহমান" {"name": "রহমান আলি তালুকদার প্রঃ ডঃ আদিরা ইভা সোফিয়া এম.ডি.পিএইচ.ডি.", "shape": 4} "রহমান আলি তালুকদার, প্রঃ ডঃ আদিরা ইভা সোফিয়া এম.ডি.পিএইচ.ডি." {"name": "রহমান মেরি সু হামিশ", "shape": 4} "শ্রেয়া চৌধুরী" {"name": "শৰ্মা ৰুলা কলিতা যতীন", "shape": 4} "শৰ্মা, ৰুলা কলিতা যতীন" "সত্যৰাম" {"name": "সত্যৰাম", "shape": 4} "সিনবাদ" {"name": "সিনবাদ", "shape": 4} "ৰুলা কলিতা যতীন শৰ্মা" "ਆਰੀਨ ਐਡਲਰ" {"name": "ਐਡਲਰ ਆਰੀਨ", "shape": 4} "ਐਡਲਰ, ਆਰੀਨ" "ਕੈਥੀ ਮੂਲਰ" "ਜ਼ਜ਼ਿਲੀਆ ਹੇਮਿਸ਼ ਸਟੋਬਰ" "ਜ਼ੈਨਡਿਆ" {"name": "ਜ਼ੈਨਡਿਆ", "shape": 4} "ਜੌਨ ਹਾਮਿਸ਼ ਵਾਟਸਨ" "ਪ੍ਰੋ. ਡਾ. ਐਡਾ ਕੋਰਨੇਲੀਆ ਈਵਾ ਸੋਫ਼ੀਆ ਵੈਨ ਡੇਨ ਵੌਲਫ਼ ਬੇਕਰ ਸਮਿੱਥ ਐੱਮ.ਡੀ. ਡੀ.ਡੀ.ਐੱਸ." {"name": "ਮੂਲਰ ਕੈਥੀ", "shape": 4} "ਮੂਲਰ, ਕੈਥੀ" {"name": "ਵਾਟਸਨ ਜੌਨ ਹਾਮਿਸ਼", "shape": 4} "ਵਾਟਸਨ, ਜੌਨ ਹਾਮਿਸ਼" {"name": "ਵੂਸਟਰ ਬਰਟਰਮ ਵਿਲਬਰਫ਼ੋਰਸ ਹੈਨਰੀ ਰਾਬਰਟ ਐਮ.ਪੀ.", "shape": 4} "ਵੂਸਟਰ, ਬਰਟਰਮ ਵਿਲਬਰਫ਼ੋਰਸ ਹੈਨਰੀ ਰਾਬਰਟ" {"name": "ਵੈਨ ਡੇਨ ਵੌਲਫ਼ ਐਡਾ ਕੋਰਨੇਲੀਆ ਈਵਾ ਸੋਫ਼ੀਆ ਐੱਮ.ਡੀ. ਡੀ.ਡੀ.ਐੱਸ.", "shape": 4} "ਵੌਲਫ਼, ਐਡਾ ਕੋਰਨੇਲੀਆ ਈਵਾ ਸੋਫ਼ੀਆ ਵੈਨ ਡੇਨ" {"name": "ਸਟੋਬਰ ਜ਼ਜ਼ਿਲੀਆ ਹੇਮਿਸ਼", "shape": 4} "ਸਟੋਬਰ, ਜ਼ਜ਼ਿਲੀਆ ਹੇਮਿਸ਼" "ਸ਼੍ਰੀ ਬਰਟਰਮ ਵਿਲਬਰਫ਼ੋਰਸ ਹੈਨਰੀ ਰਾਬਰਟ ਵੂਸਟਰ ਐਮ.ਪੀ." "ਸਿੰਨਬੈਡ" {"name": "ਸਿੰਨਬੈਡ", "shape": 4} {"name": "અદાણી આઇરીન", "shape": 4} "અદાણી, આઇરીન" "આઇરીન અદાણી" "કિરણ" {"name": "કિરણ", "shape": 4} {"name": "કુમાર શાહ શ્રી. પટેલ ખુશી કિંજલ જૂનિયર, એમપી", "shape": 4} "કૅથિ મુલર" "ખુશી પટેલ હેતલ દવે" "જઝિલીયા હૅમીશ સ્ટૂબર" {"name": "દવે ખુશી પટેલ હેતલ", "shape": 4} "દવે, ખુશી પટેલ હેતલ" "પ્રો. ડૉ. એડા કૉર્નેલિયા સિઝર માર્ટિન ફૉન્ બ્રાઑલ્ જૂનિયર, એમ.ડી. ડી.ડી.એસ." {"name": "ફૉન્ બ્રાઑલ્ પ્રો. ડૉ. એડા કૉર્નેલિયા સિઝર માર્ટિન જૂનિયર, એમ.ડી. ડી.ડી.એસ.", "shape": 4} "બ્રાઑલ્, એડા કૉર્નેલિયા સિઝર માર્ટિન ફૉન્" {"name": "મુલર કૅથિ", "shape": 4} "મુલર, કૅથિ" "શાહ, પટેલ ખુશી કિંજલ કુમાર" "શ્રી. પટેલ ખુશી કિંજલ કુમાર શાહ જૂનિયર, એમપી" "સિનબાદ" {"name": "સિનબાદ", "shape": 4} {"name": "સ્ટૂબર જઝિલીયા હૅમીશ", "shape": 4} "સ્ટૂબર, જઝિલીયા હૅમીશ" "ଆଇରେନ୍ ଆଡଲର୍" {"name": "ଆଡଲର୍ ଆଇରେନ୍", "shape": 4} "ଆଡଲର୍, ଆଇରେନ୍" "ଆଡା କର୍ଣ୍ଣେଲିଆ ଇଭା ସୋଫିଆ ୱାନ୍ ଡେନ୍ ୱୋଲ୍ଫ ଏମ.ଡି.ପିଏଚ.ଡି" "କାଥେ ମୁଲେର୍" "ଜନ୍ ହାମିଶ ୱାଟସନ୍" "ଜେଆର୍ ହାମିଶ ଷ୍ଟୋବର" "ବର୍ଟ୍ରାମ ୱିଲବର୍ଫୋର୍ସ ହେନେରୀ ରୋବର୍ଟ ୱୋଷ୍ଟର ଏମପି" {"name": "ମୁଲେର୍ କାଥେ", "shape": 4} "ମୁଲେର୍, କାଥେ" {"name": "ଷ୍ଟୋବର ଜେଆର୍ ହାମିଶ", "shape": 4} "ଷ୍ଟୋବର, ଜେଆର୍ ହାମିଶ" "ସିନବାଦ" {"name": "ସିନବାଦ", "shape": 4} "ସିନବାଦ୍" {"name": "ସିନବାଦ୍", "shape": 4} {"name": "ୱାଟସନ୍ ଜନ୍ ହାମିଶ", "shape": 4} "ୱାଟସନ୍, ଜନ୍ ହାମିଶ" {"name": "ୱାନ୍ ଡେନ୍ ୱୋଲ୍ଫ ଆଡା କର୍ଣ୍ଣେଲିଆ ଇଭା ସୋଫିଆ ଏମ.ଡି.ପିଏଚ.ଡି", "shape": 4} "ୱୋଲ୍ଫ, ଆଡା କର୍ଣ୍ଣେଲିଆ ଇଭା ସୋଫିଆ ୱାନ୍ ଡେନ୍" {"name": "ୱୋଷ୍ଟର ବର୍ଟ୍ରାମ ୱିଲବର୍ଫୋର୍ସ ହେନେରୀ ରୋବର୍ଟ ଏମପି", "shape": 4} "ୱୋଷ୍ଟର, ବର୍ଟ୍ରାମ ୱିଲବର୍ଫୋର୍ସ ହେନେରୀ ରୋବର୍ଟ" "அல்போன்சோ" {"name": "அல்போன்சோ", "shape": 4} "அல்போன்சோ எர்னஸ்டோ வான் ஹெர்னாண்டஸ் பிஎச்.டி" "அல்போன்சோ எர்னஸ்டோ ஹெர்னாண்டஸ்" "அல்போன்சோ எர்னஸ்டோ, வான் ஹெர்னாண்டஸ்" "அல்போன்சோ எர்னஸ்டோ, ஹெர்னாண்டஸ்" "அல்போன்சோ ஹெர்னாண்டஸ்" {"name": "அல்போன்சோ ஹெர்னாண்டஸ்", "shape": 4} {"name": "சோழன் ராஜ ராஜ", "shape": 4} {"name": "சோழன் ராஜேந்திர", "shape": 4} {"name": "திரு. சோழன் ராஜ ராஜன் ராஜன் எம்பி", "shape": 4} {"name": "பேராசிரியர் வான் ஹெர்னாண்டஸ் அல்போன்சோ எர்னஸ்டோ பிஎச்.டி", "shape": 4} "ராஜ ராஜ சோழன்" "ராஜ ராஜ, சோழன்" "ராஜ ராஜன் ராஜன் சோழன் எம்பி" "ராஜ ராஜன் ராஜன், சோழன்" "ராஜேந்திர சோழன்" {"name": "ராஜேந்திர சோழன்", "shape": 4} "ராஜேந்திரன்" {"name": "ராஜேந்திரன்", "shape": 4} {"name": "ஹெர்னாண்டஸ் அல்போன்சோ", "shape": 4} {"name": "ஹெர்னாண்டஸ் அல்போன்சோ எர்னஸ்டோ", "shape": 4} {"name": "ఏలేటి కుమార్ చంద్ర శేఖర్ అబ్దుల్ ఆజాద్ ఎం. పి.", "shape": 4} "ఏలేటి కుమార్, చంద్ర శేఖర్ అబ్దుల్ ఆజాద్" "చంద్ర శేఖర్ అబ్దుల్ ఆజాద్ ఏలేటి కుమార్ ఎం. పి." "చౌదరి, సూర్య ప్రతాప్ లాస్య ప్రియ శర్మ" {"name": "ప్రసాద్ శంకర్ నరేష్", "shape": 4} "ప్రసాద్, శంకర్ నరేష్" {"name": "భరత్ రఘు", "shape": 4} "భరత్, రఘు" "రఘు భరత్" "రవి" {"name": "రవి", "shape": 4} "రాజశేఖర్" {"name": "రాజశేఖర్", "shape": 4} "రాజశేఖర్ రెడ్డి" {"name": "రెడ్డి రాజశేఖర్", "shape": 4} "రెడ్డి, రాజశేఖర్" {"name": "వర్మ సూర్య ప్రతాప్", "shape": 4} "వర్మ, సూర్య ప్రతాప్" "శంకర్ నరేష్ ప్రసాద్" {"name": "శర్మ చౌదరి సూర్య ప్రతాప్ లాస్య ప్రియ ఎం. డి. పిహెచ్. డి.", "shape": 4} "సూర్య ప్రతాప్ లాస్య ప్రియ శర్మ చౌదరి ఎం. డి. పిహెచ్. డి." "సూర్య ప్రతాప్ వర్మ" "ಕ್ಯಾಥಿ ಮುಲ್ಲರ್" "ಝಝಿಲಿಯಾ ಹಮಿಶ್ ಸ್ಟೋಬರ್" "ಝೆಂಡಾಯಾ" {"name": "ಝೆಂಡಾಯಾ", "shape": 4} {"name": "ಪಾಟೀಲ್ ಶ್ರೀ. ನಾರಯಣ ಕುಮಾರ್ ಶಿವ ರಾಮ ಕಿರಿ, ಎಮ್‌ಪಿ", "shape": 4} "ಪಾಟೀಲ್, ನಾರಯಣ ಕುಮಾರ್ ಶಿವ ರಾಮ" "ಪ್ರೊ. ಡಾ. ಅಡಾ ಕಾರ್ನೆಲಿಯಾ ಸಿಸಾರ್ ಮಾರ್ಟಿನ್ ವಾನ್ ಬ್ರಾಲ್ ಜೂನಿಯರ್, ಎಂ.ಡಿ ಡಿ.ಡಿ.ಎಸ್" "ಬ್ರಾಲ್, ಅಡಾ ಕಾರ್ನೆಲಿಯಾ ಸಿಸಾರ್ ಮಾರ್ಟಿನ್ ವಾನ್" {"name": "ಮುಲ್ಲರ್ ಕ್ಯಾಥಿ", "shape": 4} "ಮುಲ್ಲರ್, ಕ್ಯಾಥಿ" {"name": "ಯಶ್ ವಿಜಯ ಲಕ್ಷ್ಮಿ ಹರೀಶ್", "shape": 4} "ಯಶ್, ವಿಜಯ ಲಕ್ಷ್ಮಿ ಹರೀಶ್" "ರಶ್ಮಿಕಾ ಶೆಟ್ಟಿ" {"name": "ವಾನ್ ಬ್ರಾಲ್ ಪ್ರೊ. ಡಾ. ಅಡಾ ಕಾರ್ನೆಲಿಯಾ ಸಿಸಾರ್ ಮಾರ್ಟಿನ್ ಜೂನಿಯರ್, ಎಂ.ಡಿ ಡಿ.ಡಿ.ಎಸ್", "shape": 4} "ವಿಜಯ ಲಕ್ಷ್ಮಿ ಹರೀಶ್ ಯಶ್" {"name": "ಶೆಟ್ಟಿ ರಶ್ಮಿಕಾ", "shape": 4} "ಶೆಟ್ಟಿ, ರಶ್ಮಿಕಾ" "ಶ್ರೀ. ನಾರಯಣ ಕುಮಾರ್ ಶಿವ ರಾಮ ಪಾಟೀಲ್ ಕಿರಿ, ಎಮ್‌ಪಿ" "ಸಿನ್‍ಬಾದ್" {"name": "ಸಿನ್‍ಬಾದ್", "shape": 4} {"name": "ಸ್ಟೋಬರ್ ಝಝಿಲಿಯಾ ಹಮಿಶ್", "shape": 4} "ಸ್ಟೋಬರ್, ಝಝಿಲಿಯಾ ಹಮಿಶ್" "അശ്വതി നായർ" "എയ്‌ഡ കോർണീലിയ സീസ൪ മാ൪ട്ടിൻ വാൻ ബ്ര്യൂൾ എം.ഡി. ഡി.ഡി.എസ്" {"name": "കറുവാപ്പറമ്പിൽ ശ്രീ. തോമസ് കുരുവിള അന്തോണി ജൂനിയർ, എംപി", "shape": 4} "കറുവാപ്പറമ്പിൽ, തോമസ് കുരുവിള അന്തോണി" "കാത്തി മുള്ളർ" "ജോൺ ചാണ്ടി പടവീട്ടിൽ" "തോമസ് കുരുവിള അന്തോണി കറുവാപ്പറമ്പിൽ എംപി" "ദീപ" {"name": "ദീപ", "shape": 4} {"name": "നായർ അശ്വതി", "shape": 4} "നായർ, അശ്വതി" {"name": "പടവീട്ടിൽ ജോൺ ചാണ്ടി", "shape": 4} "പടവീട്ടിൽ, ജോൺ ചാണ്ടി" "ബ്ര്യൂൾ, എയ്‌ഡ കോർണീലിയ സീസ൪ മാ൪ട്ടിൻ വാൻ" {"name": "മുള്ളർ കാത്തി", "shape": 4} "മുള്ളർ, കാത്തി" {"name": "വാൻ ബ്ര്യൂൾ പ്രൊഫ. ഡോ. എയ്‌ഡ കോർണീലിയ സീസ൪ മാ൪ട്ടിൻ ജൂനിയർ, എം.ഡി. ഡി.ഡി.എസ്", "shape": 4} "സസിലിയ ഹാമിഷ് സ്‌റ്റോബർ" "സിൻബാദ്" {"name": "സിൻബാദ്", "shape": 4} {"name": "സ്‌റ്റോബർ സസിലിയ ഹാമിഷ്", "shape": 4} "സ്‌റ്റോബർ, സസിലിയ ഹാമിഷ്" "අයිරින් ඇඩ්ලර්" {"name": "අයිරින් ඇඩ්ලර්", "shape": 4} {"name": "ඇඩ්ලර් අයිරින්", "shape": 4} "ඇඩා කොර්නේලියා සීසර් මාටින්, වොන් බෲල්" "කේතේ මුලර්" {"name": "කේතේ මුලර්", "shape": 4} "බර්ට්‍රම් විල්බර්ෆෝස් හෙන්රි රොබට්, වූස්ටර්" "මහතා බර්ට්‍රම් විල්බර්ෆෝස් හෙන්රි රොබට් වූස්ටර් පා.ම." "මහාචාර්ය. ආචාර්ය. ඇඩා කොර්නේලියා සීසර් මාටින් වොන් බෲල් ගොන්සාලෙස් ඩොමින්ගෝ එම්.ඩී. පී.එච්.ඩී." {"name": "මුලර් කේතේ", "shape": 4} "මේරි සූ හමිෂ් වොට්සන්" "මේරි සූ හමිෂ්, වොට්සන්" {"name": "වූස්ටර් මහතා බර්ට්‍රම් විල්බර්ෆෝස් හෙන්රි රොබට් බාල, පා.ම.", "shape": 4} {"name": "වොට්සන් මේරි සූ හමිෂ්", "shape": 4} {"name": "වොන් බෲල් මහාචාර්ය. ආචාර්ය. ඇඩා කොර්නේලියා සීසර් මාටින් බාල, එම්.ඩී. පී.එච්.ඩී.", "shape": 4} {"name": "ස්ටෝබර් සාසිලියා හමිෂ්", "shape": 4} "සාසිලියා හමිෂ් ස්ටෝබර්" "සාසිලියා හමිෂ්, ස්ටෝබර්" "සින්බෑඩ්" {"name": "සින්බෑඩ්", "shape": 4} "සෙන්ඩයා" {"name": "සෙන්ඩයා", "shape": 4} {"name": "กล้าหาญ ไอริณ", "shape": 4} "กล้าหาญ, ไอริณ" "คุณ ปรีชา กล้าหาญ แสงระวี" "ซาซิเลีย ฮามิช สโตเบอร์" "ซินแบด" {"name": "ซินแบด", "shape": 4} "ธนา" {"name": "ธนา", "shape": 4} "บรืล, เอดา คอร์เนเลีย เซซาร์ มาร์ติน วอน" {"name": "พิชิตชัย มานี ชัยยศ", "shape": 4} "พิชิตชัย, มานี ชัยยศ" "มานี ชัยยศ พิชิตชัย" {"name": "มึลเลอร์ เคเทอ", "shape": 4} "มึลเลอร์, เคเทอ" {"name": "วอน บรืล กอนซาเลซ โดมิงโก ศ.ดร. เอดา คอร์เนเลีย เซซาร์ มาร์ติน พ.บ. ท.บ.", "shape": 4} "ศ.ดร. เอดา คอร์เนเลีย เซซาร์ มาร์ติน วอน บรืล พ.บ. ท.บ." {"name": "สโตเบอร์ ซาซิเลีย ฮามิช", "shape": 4} "สโตเบอร์, ซาซิเลีย ฮามิช" "เคเทอ มึลเลอร์" {"name": "แสงระวี คุณ ปรีชา กล้าหาญ", "shape": 4} "แสงระวี, ปรีชา กล้าหาญ" "ไอริณ กล้าหาญ" "ຄຳຫຼ້າ" {"name": "ຄຳຫຼ້າ", "shape": 4} "ຊາຊິເລຍ ຮາມີສ ສໂຕເບີ" "ຊິນແບດ" {"name": "ຊິນແບດ", "shape": 4} {"name": "ດຣ. ປອ. ວອນ ບຣູລ ສຈ. ປອ. ເອດາ ຄໍນີເລຍ ເຊຊາ ມາຕິນ ຮຸ່ນລູກ", "shape": 4} "ດຣ. ປອ. ສຈ. ປອ. ເອດາ ຄໍນີເລຍ ເຊຊາ ມາຕິນ ວອນ ບຣູລ ຮຸ່ນລູກ" "ນາລິນ ເອກະພົນ" "ບຣູລ, ເອດາ ຄໍນີເລຍ ເຊຊາ ມາຕິນ ວອນ" "ມະນີ ມະນີຈັນ ວັດທະນາ" {"name": "ມູລເລີ ເຄທີ", "shape": 4} "ມູລເລີ, ເຄທີ" {"name": "ວັດທະນາ ມະນີ ມະນີຈັນ", "shape": 4} "ວັດທະນາ, ມະນີ ມະນີຈັນ" "ວູສເຕີ, ເບີແທຣັມ ວິວເບີຟອດຊ ເຮັນຣີ ໂຣເບີດ" "ສ.ສ. ທ. ເບີແທຣັມ ວິວເບີຟອດຊ ເຮັນຣີ ໂຣເບີດ ວູສເຕີ ຮຸ່ນລູກ" {"name": "ສ.ສ. ວູສເຕີ ທ. ເບີແທຣັມ ວິວເບີຟອດຊ ເຮັນຣີ ໂຣເບີດ ຮຸ່ນລູກ", "shape": 4} {"name": "ສໂຕເບີ ຊາຊິເລຍ ຮາມີສ", "shape": 4} "ສໂຕເບີ, ຊາຊິເລຍ ຮາມີສ" "ເຄທີ ມູລເລີ" {"name": "ເອກະພົນ ນາລິນ", "shape": 4} "ເອກະພົນ, ນາລິນ" "ကက်သီ မူလာ" {"name": "စတိုဘာ ဇာဇီလာ ဟာမစ်ရှ်", "shape": 4} {"name": "စတိုဘာ၊ ဇာဇီလာ ဟာမစ်ရှ်", "shape": 4} "ဆင်းဘတ်" {"name": "ဆင်းဘတ်", "shape": 4} "ဇင်ဒါယာ" {"name": "ဇင်ဒါယာ", "shape": 4} "ဇာဇီလာ ဟာမစ်ရှ် စတိုဘာ" "ပရော်ဖက်ဆာ ဒေါက်တာ အာဒါ ကွန်နီလီယာ ဆီဇာ မာတင် ဝူစ်တာ ဗွန် ဝူစ်တာ ဘရူ ဂျူနီယာ၊ ဒေါက်တာ" "မစ္စတာ ဘာထရမ် ဝဲလ်ဘာဖို့စ် ဟင်နရီ ရောဘတ် ဝူစ်တာ ဂျူနီယာ၊ ဒေါက်တာ" "မိတ်ႇသတႃႇ ပႃႇထရမ်ႇ ဝီလ်ပႃႇၾူတ်ႉ ႁႅၼ်ႇၼရီႇ ရေႃးပၢတ်ႉ ဝုတ်ႉသ်တႃႇ မေႃယႃ" {"name": "မူလာ ကက်သီ", "shape": 4} {"name": "မူလာ၊ ကက်သီ", "shape": 4} {"name": "မူးလႃႇ ၶေးတႃႇ", "shape": 4} "မေရီ စူး ဟာမစ်ရှ် ဝပ်ဆင်" "မေႇရီႇသူး ႁေးမိသျ် ဝတ်ႉသၼ်ႇ" {"name": "ဝတ်ႉသၼ်ႇ မေႇရီႇသူး ႁေးမိသျ်", "shape": 4} {"name": "ဝပ်ဆင် မေရီ စူး ဟာမစ်ရှ်", "shape": 4} {"name": "ဝပ်ဆင်၊ မေရီ စူး ဟာမစ်ရှ်", "shape": 4} {"name": "ဝုတ်ႉသ်တႃႇ မိတ်ႇသတႃႇ ပႃႇထရမ်ႇ ဝီလ်ပႃႇၾူတ်ႉ ႁႅၼ်ႇၼရီႇ ရေႃးပၢတ်ႉ မေႃယႃ", "shape": 4} {"name": "ဝူစ်တာ ဗွန် ဝူစ်တာ ဘရူ ပရော်ဖက်ဆာ ဒေါက်တာ အာဒါ ကွန်နီလီယာ ဆီဇာ မာတင် ဂျူနီယာ၊ ဒေါက်တာ", "shape": 4} {"name": "ဝူစ်တာ ဘရူ၊ အာဒါ ကွန်နီလီယာ ဆီဇာ မာတင် ဝူစ်တာ ဗွန်", "shape": 4} {"name": "ဝူစ်တာ မစ္စတာ ဘာထရမ် ဝဲလ်ဘာဖို့စ် ဟင်နရီ ရောဘတ် ဂျူနီယာ၊ ဒေါက်တာ", "shape": 4} {"name": "ဝူစ်တာ၊ ဘာထရမ် ဝဲလ်ဘာဖို့စ် ဟင်နရီ ရောဘတ်", "shape": 4} {"name": "ဝွၼ်း ပရူဝ်ႇလ် ၵွၼ်ႇၸႃႇလႅတ်ႉ တူဝ်ႇမိၼ်ႇၵူဝ် ၶူးမေႃယႃ ဢေႇတႃႇ ၶူဝ်ႇၼီႇလီႇယႃႇ သီႇသႃႇ မႃႇတိၼ်ႇ မေႃယႃ", "shape": 4} "ဝွၼ်း ပရူဝ်ႇလ် ၵွၼ်ႇၸႃႇလႅတ်ႉ တူဝ်ႇမိၼ်ႇၵူဝ်, ၶူးမေႃယႃ ဢေႇတႃႇ ၶူဝ်ႇၼီႇလီႇယႃႇ သီႇသႃႇ မႃႇတိၼ်ႇ မေႃယႃ" {"name": "သတူႇပႃႇ ၸၸီးလီႇယႃႇ ႁေးမိသျ်", "shape": 4} "သိၼ်းပၢတ်ႉ" {"name": "သိၼ်းပၢတ်ႉ", "shape": 4} {"name": "အက်ဒ်လာ အိုင်ရင်း", "shape": 4} {"name": "အက်ဒ်လာ၊ အိုင်ရင်း", "shape": 4} "အိုင်ရင်း အက်ဒ်လာ" "ဢၢႆႇရိၼ်း ဢႅတ်ႉလႃႇ" {"name": "ဢႅတ်ႉလႃႇ ဢၢႆႇရိၼ်း", "shape": 4} "ၶူးမေႃယႃ ဢေႇတႃႇ ၶူဝ်ႇၼီႇလီႇယႃႇ သီႇသႃႇ မႃႇတိၼ်ႇ ဝွၼ်း ပရူဝ်ႇလ် ၵွၼ်ႇၸႃႇလႅတ်ႉ တူဝ်ႇမိၼ်ႇၵူဝ် မေႃယႃ" "ၶေးတႃႇ မူးလႃႇ" "ၸၸီးလီႇယႃႇ ႁေးမိသျ် သတူႇပႃႇ" "ၸႅၼ်ႇတေႇယႃႇ" {"name": "ၸႅၼ်ႇတေႇယႃႇ", "shape": 4} {"name": "აბაშიძე მარიამ მართა", "shape": 4} {"name": "ბელუჩი მონიკა", "shape": 4} "გიორგი თავაძე" {"name": "თავაძე გიორგი", "shape": 4} "თეკლა მარიამ ნაკაშიძე" "მარიამ მართა აბაშიძე" "მონიკა ბელუჩი" {"name": "ნაკაშიძე თეკლა მარიამ", "shape": 4} "ნინო" {"name": "ნინო", "shape": 4} "პროფ. დოქტ. ადა კორნელია სეზარ მარტინ ფონ ბრიული გონსალეს დომინგო მეცნ. დოქტ." "სინდბად" {"name": "სინდბად", "shape": 4} {"name": "ფონ ბრიული გონსალეს დომინგო პროფ. დოქტ. ადა კორნელია სეზარ მარტინ მეცნ. დოქტ.", "shape": 4} "ფონ ბრიული გონსალეს დომინგო, პროფ. დოქტ. ადა კორნელია სეზარ მარტინ მეცნ. დოქტ." {"name": "შტიობერი ცეცილია ჰეიმიშ", "shape": 4} "ცეცილია ჰეიმიშ შტიობერი" {"name": "ሙለር ካቲ", "shape": 4} {"name": "ሙለር፣ ካቲ", "shape": 4} "ሜሪ ሱ ሃሚሽ ዋትሰን" "ሲንባድ" {"name": "ሲንባድ", "shape": 4} {"name": "ስቶበር ዛዚሊያ ሃሚሽ", "shape": 4} {"name": "ስቶበር፣ ዛዚሊያ ሃሚሽ", "shape": 4} {"name": "ብሩሂ፣ ኣዳ ኮርኒሊያ ሲዘር ማርቲን ቮን", "shape": 4} {"name": "ቮን ብሩሂ ፕሮ. ዶር. ኣዳ ኮርኒሊያ ሲዘር ማርቲን ማንጁስ፣ ሓኪም መጥባሕቲ ስኒ", "shape": 4} {"name": "ቮን ብሩህል ጎንዛሌዝ ዶሚንጎ ፕ/ር ዶ/ር አዳ ኮርኔሊያ ሴዛር ማርቲን ዶ/ር የጥርስ ዶ/ር", "shape": 4} "ቮን ብሩህል ጎንዛሌዝ ዶሚንጎ, ፕ/ር ዶ/ር አዳ ኮርኔሊያ ሴዛር ማርቲን ዶ/ር የጥርስ ዶ/ር" "አቶ በርትራም ዊልበርፎርስ ሄነሪ ሮበርት ዉስተር የፓርላማ አባል" "አይሪን አድለር" {"name": "አድለር አይሪን", "shape": 4} "ኣይሪን ኣድለር" "ኣይተ በርትረም ዊልበርፎርስ ሄንሪ ሮበርት ዉስተር ማንጁስ፣ ኣባል ባይቶ" {"name": "ኣድለር ኣይሪን", "shape": 4} {"name": "ኣድለር፣ ኣይሪን", "shape": 4} "ካቲ ሙለር" {"name": "ዉስተር አቶ በርትራም ዊልበርፎርስ ሄነሪ ሮበርት የፓርላማ አባል", "shape": 4} {"name": "ዉስተር ኣይተ በርትረም ዊልበርፎርስ ሄንሪ ሮበርት ማንጁስ፣ ኣባል ባይቶ", "shape": 4} {"name": "ዉስተር፣ በርትረም ዊልበርፎርስ ሄንሪ ሮበርት", "shape": 4} {"name": "ዋትሰን ሜሪ ሱ ሃሚሽ", "shape": 4} {"name": "ዋትሰን፣ ሜሪ ሱ ሃሚሽ", "shape": 4} "ዘንዳያ" {"name": "ዘንዳያ", "shape": 4} "ዛዚሊያ ሃሚሽ ስቶበር" "ዜንዳያ" {"name": "ዜንዳያ", "shape": 4} "ፕ/ር ዶ/ር አዳ ኮርኔሊያ ሴዛር ማርቲን ቮን ብሩህል ጎንዛሌዝ ዶሚንጎ ዶ/ር የጥርስ ዶ/ር" "ፕሮ. ዶር. ኣዳ ኮርኒሊያ ሲዘር ማርቲን ቮን ብሩሂ ማንጁስ፣ ሓኪም መጥባሕቲ ስኒ" {"name": "ᎠᏕᎸ ᎠᏪᏁ", "shape": 4} "ᎠᏕᎸ, ᎠᏪᏁ" "ᎠᏪᏁ ᎠᏕᎸ" "ᎧᏖ ᎽᎴ" "ᎨᏓᏯ" {"name": "ᎨᏓᏯ", "shape": 4} "ᎺᎵ ᏑᎢ ᎭᎻᏏ ᏩᏔᏒᎢ" "ᎻᏍᏖᎢ ᏇᏔᎻ ᏫᏇᏉᏏ ᎮᏂᏇ ᏆᏇᏘ ᏬᏍᏖᎢ ᏔᎵᏁ, ᎷᏢ" {"name": "ᎽᎴ ᎧᏖ", "shape": 4} "ᎽᎴ, ᎧᏖ" "ᏆᏏᎵ ᎭᎻᏍᎯ ᏍᏙᏇᎢ" "ᏇᎯ, ᎠᏓ ᎪᏁᎵᎠ ᏏᏌ ᎹᏘᏂ ᏉᏂ" "Ꮙ. ᏅᏬᏘ ᎠᏓ ᎪᏁᎵᎠ ᏏᏌ ᎹᏘᏂ ᏉᏂ ᏇᎯ ᏔᎵᏁ, ᎷᎠ ᎠᎠᏚ" {"name": "ᏉᏂ ᏇᎯ Ꮙ. ᏅᏬᏘ ᎠᏓ ᎪᏁᎵᎠ ᏏᏌ ᎹᏘᏂ ᏔᎵᏁ, ᎷᎠ ᎠᎠᏚ", "shape": 4} {"name": "ᏍᏙᏇᎢ ᏆᏏᎵ ᎭᎻᏍᎯ", "shape": 4} "ᏍᏙᏇᎢ, ᏆᏏᎵ ᎭᎻᏍᎯ" "ᏏᎾᏆᏗ" {"name": "ᏏᎾᏆᏗ", "shape": 4} {"name": "ᏩᏔᏒᎢ ᎺᎵ ᏑᎢ ᎭᎻᏏ", "shape": 4} "ᏩᏔᏒᎢ, ᎺᎵ ᏑᎢ ᎭᎻᏏ" {"name": "ᏬᏍᏖᎢ ᎻᏍᏖᎢ ᏇᏔᎻ ᏫᏇᏉᏏ ᎮᏂᏇ ᏆᏇᏘ ᏔᎵᏁ, ᎷᏢ", "shape": 4} "ᏬᏍᏖᎢ, ᏇᏔᎻ ᏫᏇᏉᏏ ᎮᏂᏇ ᏆᏇᏘ" "កាតេ មូលឡើ" "ប្រ៊ូល, អាដា ខរនែលៀ ឆេសារ ម៉ាធីន វន់" {"name": "មូលឡើ កាតេ", "shape": 4} "មូលឡើ, កាតេ" "រិទ្ធី មករា វ៉េង" "លោក សុវណ្ណថេត សុធារ៉ា វេហា សមាជិកសភា" {"name": "វន់ ប្រ៊ូល សាស្ត្រាចារ្យបណ្ឌិត អាដា ខរនែលៀ ឆេសារ ម៉ាធីន ជូនៀ, ទន្ដបណ្ឌិត", "shape": 4} {"name": "វេហា លោក សុវណ្ណថេត សុធារ៉ា សមាជិកសភា", "shape": 4} "វេហា, សុវណ្ណថេត សុធារ៉ា" {"name": "វ៉េង រិទ្ធី មករា", "shape": 4} "វ៉េង, រិទ្ធី មករា" "សាស្ត្រាចារ្យបណ្ឌិត អាដា ខរនែលៀ ឆេសារ ម៉ាធីន វន់ ប្រ៊ូល ជូនៀ, ទន្ដបណ្ឌិត" "សុខា" {"name": "សុខា", "shape": 4} {"name": "សុង សុជាតិ", "shape": 4} "សុង, សុជាតិ" "សុជាតិ សុង" "ស៊ីនប៊ែត" {"name": "ស៊ីនប៊ែត", "shape": 4} {"name": "ស្តូបើ ហ្សាហ្សីលៀ ហេមីស", "shape": 4} "ស្តូបើ, ហ្សាហ្សីលៀ ហេមីស" "ហ្សាហ្សីលៀ ហេមីស ស្តូបើ" {"name": "アインシュタイン・アルベルト", "shape": 4} "アルベルト・アインシュタイン" "ジェニファー・ソフィア・フォン・スミス・ジュニア博士" "セシリア・ローズ・ブラウン" {"name": "フォン・スミス・ソフィア・ジェニファー", "shape": 4} {"name": "フォン・スミス・ソフィア・ジェニファー博士", "shape": 4} {"name": "ブラウン・ローズ・セシリア", "shape": 4} "マイケル" {"name": "マイケル", "shape": 4} "一郎安藤" {"name": "佐藤グレース恵子", "shape": 4} {"name": "佐藤グレース恵子さん", "shape": 4} "俊年杰思陈" "先生子媚惠仪陈小,太平绅士" "先生子媚惠儀陳小,太平紳士" "克特·米勒" "凯晴" {"name": "凯晴", "shape": 4} "凱晴" {"name": "凱晴", "shape": 4} {"name": "吳·永強·德安", "shape": 4} {"name": "吴·永强·德安", "shape": 4} "塞西莉亚·哈米什·施托贝尔" "太郎トーマス山田" "宇瀚" {"name": "宇瀚", "shape": 4} "安怡张" {"name": "安藤一郎", "shape": 4} "家豪明德林" {"name": "小冯·布鲁赫·艾达·科妮莉亚·塞萨尔·马丁·博士教授", "shape": 4} {"name": "小彭德威东升议员先生", "shape": 4} "小德威东升彭议员先生" "小艾达·科妮莉亚·塞萨尔·马丁·冯·布鲁赫博士教授" {"name": "山田トーマス太郎", "shape": 4} {"name": "布鲁赫·艾达·科妮莉亚·塞萨尔·马丁·冯", "shape": 4} {"name": "张安怡", "shape": 4} {"name": "张雅婷", "shape": 4} {"name": "張雅婷", "shape": 4} {"name": "彭德威东升", "shape": 4} "怡君達印馮·陳二世博士,醫學博士" "恵子グレース佐藤ジュニアさん" "慎太郎" {"name": "慎太郎", "shape": 4} "教授·怡君·达印·陈·陈·小,博士" "教授·怡君·達印·陳·陳·小,博士" "文傑" {"name": "文傑", "shape": 4} "文杰" {"name": "文杰", "shape": 4} {"name": "施托贝尔·塞西莉亚·哈米什", "shape": 4} {"name": "林家豪明德", "shape": 4} "永強·德安·吳" "永强·德安·吴" "永翘·黄" "永翹·黃" {"name": "王·雅婷", "shape": 4} {"name": "王·雅婷·婷婷", "shape": 4} {"name": "王雅婷婷婷", "shape": 4} {"name": "王雅婷婷婷二世議員", "shape": 4} {"name": "米勒·克特", "shape": 4} "辛巴达" {"name": "辛巴达", "shape": 4} {"name": "陈·怡君·达印", "shape": 4} {"name": "陈·陈·怡君·教授,小·达印·博士", "shape": 4} {"name": "陈俊年杰思", "shape": 4} {"name": "陈子媚先生,小惠仪太平绅士", "shape": 4} {"name": "陈子媚惠仪", "shape": 4} {"name": "陳·怡君·達印", "shape": 4} {"name": "陳·陳·怡君·教授,小·達印·博士", "shape": 4} {"name": "陳子媚先生,小惠儀太平紳士", "shape": 4} {"name": "陳子媚惠儀", "shape": 4} "雅婷" {"name": "雅婷", "shape": 4} "雅婷婷婷王" "雅婷婷婷王二世先生,議員" "雅婷张" "雅婷張" "雅婷王" {"name": "馮·陳·怡君·達印", "shape": 4} {"name": "馮·陳·怡君·達印·二世醫學博士", "shape": 4} {"name": "黃·永翹", "shape": 4} {"name": "黄·永翘", "shape": 4} {"name": "강유미", "shape": 4} {"name": "김민규헨리", "shape": 4} {"name": "뮐러 케테", "shape": 4} "민규헨리김" {"name": "박소현", "shape": 4} "소현박" {"name": "슈퇴버 제질리아 해미쉬", "shape": 4} "신밧드" {"name": "신밧드", "shape": 4} "에이다 코넬리아 세사르 마르틴 폰 볼프 박사" "유미" {"name": "유미", "shape": 4} "유미강" "제질리아 해미쉬 슈퇴버" "케테 뮐러" {"name": "폰 볼프 에이다 코넬리아 세사르 마르틴", "shape": 4} {"name": "폰 볼프 에이다 코넬리아 세사르 마르틴 박사", "shape": 4} ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/corpus_conventions.jsonl�����������������������0000664�0000000�0000000�00000001507�15273032717�0027515�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"Hodan Farah Warsame" "Harpreet Kaur Dhillon" "K. Sivakumar" "Pedro López Ruiz H." "Carmen Díaz Vda. de Mora" "Ali Kara Oğlu" "Murat Bey" {"name": "Szabó Péter úr", "shape": 4} {"name": "Szabó Péterné", "shape": 4} {"name": "Kim Min Ho", "shape": 4} {"name": "Tran Thi Mai", "shape": 4} "Ivan Petrov Dimitrov" "Ma. Teresa Cruz Bautista" {"name": "Park Ji-hoon ssi", "shape": 4} {"name": "Suzuki san", "shape": 4} {"name": "Suzuki-san", "shape": 4} "U Kyaw Win" "Daw Thida" "Khun Malee" "Hassan bin Yusof" "Hassan B. Yusof" "Suresh a/l Muthu" "Kavitha a/p Raman" "H. Budi Santoso" "David arap Kirui" "Joseph ole Sankale" "Wanjiru wa Kamau" "Michael Wong Kar Ming" {"name": "Lim Mei Ling Jessica", "shape": 4} "Wing Lau" {"name": "Szabóné Kiss Éva", "shape": 4} "Van der Walt" "Abu Karim" "Reza Hossein Zadeh" "Orval, Pierre d'" �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/corpus_gnd.jsonl�������������������������������0000664�0000000�0000000�00000044602�15273032717�0025723�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"Abd al-Aziz ibn Muhammad" "Abdullah bin Haji Ahmad Badawi" "Abe, Shinnosuke" "Abraham van den Hecken" "Abu Bakar" "Abu-'l-Fidāʾ Ismāʿīl Ibn-ʿAlī" "Abu-'l-Qāsim Maslama Ibn-Aḥmad al-Maǧrīṭī" "Abū-Bakr aš-Šiblī" "Abū-Maʿšar Ǧaʿfar Ibn-Muḥammad" "Abū-Muslim" "Abū-Ṣāliḥ al-Armanī" "Achille d'Orsi" "Advayavajra" "Agitu Ideo Gudeta" "Aguado y García, Dionisio" "Ahmad Al-Hadi" "Ahmad Shah Al-Musta’in Billah ibni Almarhum Sultan Abu Bakar Riayatuddin Al-Mu’azzam Shah" "Aitken, Robert" "Alan Ridout" "Albert Guðmundsson" "Albert Lederle" "Albert, Eugen d'" "Albertino della Piazza" "Albrecht IV." "Albrecht von Alvensleben" "Aldephonse Alexandre Félix du Jardin" "Alexis" "Alfonsus Antonius de Sarasa" "Alice Rühle-Gerstel" "Almonde, Philips van" "Alonso de Cardenas" "Alquen, Gunter d'" "Alvensleben, Albrecht von" "Alzon, Emmanuel d'" "Amha Selassie I." "Amigo Vallejo, Carlos" "Amélie Beaury-Saurel" "Amīn as-Sulṭān ʿAlī Aṣġar Ḫān" "Anan ben David" "Andreas Bernardus de Quertenmont" "Andreas Martin" "Angiviller, Charles Claude de La Billarderie d'" "Angoulême, Louis Antoine d'Artois d'" "Anne-Pierre de Montesquiou-Fézensac" "Annebault, Claude d'" "Antico" "Antoine de Gramont" "Antoine de Tounens" "Anton von Tschurtschenthaler" "Antonio García y Bellido" "Antonio Moreno Casamitjana" "Antonio da Sangallo" "Antonio dos Santos Cabral" "Antonius van Dale" "Arco, Giovanni Battista Gherardo d'" "Arenas Betancourt, Rodrigo" "Arenberg, Auguste Marie Raymond d'" "Arend, Arie den" "Arie den Arend" "Armando de Lacerda" "Arnold Neher" "Asas Manterola, Benita" "Assad, Hafis al-" "Ataíde, Luís de" "Aubigné, Théodore Agrippa d'" "August von Cetto" "Auguste Marie Raymond d'Arenberg" "Augustus P. Gardner" "Augustus Seymour Porter" "Aung San" "Auxiron, Claude François Joseph d'" "Avraham ben Mosheh ben Maimon" "Ayala Pérez, Daniel" "Ayolas, Juan de" "Azizi Hj. Abdullah" "Aḥmad Ibn-ʿAlī al-Maqrīzī" "Ba Maw" "Babe D. Zaharias" "Badurad" "Bailey, Liberty Hyde" "Balari i Jovany, Josep" "Barbara Brukalska" "Barrera y Leirado, Cayetano Alberto de la" "Baʿal-Maḥshoves̀" "Beaury-Saurel, Amélie" "Bella Cohen Spewack" "Benedikt Gröndal" "Benita Asas Manterola" "Benito Jerónimo Feijoo y Montenegro" "Benito Quinquela Martín" "Benjamin Louis Eulalie de Bonneville" "Bernard große Broermann" "Bernardino de Sahagún" "Bernhard zu Solms-Braunfels" "Berta Klingberg" "Bisschop, Eric de" "Blas de Lezo" "Bonafini, Hebe de" "Bonifatius VIII." "Bonneville, Benjamin Louis Eulalie de" "Bonvi" "Borcke, Karl August Ferdinand von" "Bourne, Samuel" "Bransfield, Edward" "Breuner, Philipp von" "Broermann, Bernard große" "Brossard, Sébastien de" "Brukalska, Barbara" "Bruno III." "Bunn, Teddy" "Bunsen, Frances von" "Bustamante y Rivero, José Luis" "Buḥturī, al-Walīd Ibn-ʿUbaid al-" "Bābak Yaḥyā Ibn-Muʿāḏ al-Ḫurramī" "Bāqir Ḫān" "Caballero Calderón, Eduardo" "Calcar, Jan Stephan von" "Camilo Castelo Branco" "Campo Serrano, José María" "Cardenas, Alonso de" "Carlos Amigo Vallejo" "Carlos Prats Gonzaléz" "Carnarvon, George Edward Stanhope Molyneux Herbert of" "Carpentier, Pieter de" "Carrillo de Mendoza y Pimentel, Diego" "Carvalho Moreira, Francisco Inácio de" "Cassini, Jean-Dominique de" "Castelo Branco, Camilo" "Cayetano Alberto de la Barrera y Leirado" "Cerda y Aragón, Tomás Antonio de la" "Cetto, August von" "Charles Claude de La Billarderie d'Angiviller" "Charles Wilkins" "Chattel, Frederik Jacobus van Rossum" "Chikamatsu, Monzaemon" "Christian Ulrich II." "Ciro di Pèrs" "Clarice de'Medici" "Claude François Joseph d'Auxiron" "Claude d'Annebault" "Claus Harms" "Clorinda Málaga de Prado" "Cobb, Nathan Augustus" "Colette" "Consuelo de Saint-Exupéry" "Cornelis van Dalen" "Costanza da Varano" "Cramer, Heinz von" "Cummins, George D." "Cunow, Heinrich" "Cunsel, Herman de" "Curt Ludwig von Gienanth" "Cvrček, Radim" "Czigány, Dezső" "Dale, Antonius van" "Dalen, Cornelis van" "Danarto" "Daniel Ayala Pérez" "David zu Sonnegg von Ungnad von Weissenwolf" "Dermot Ryan" "Desvalls i d’Ardena, Joan Antoni" "Deventer, Willem Antonie van" "Diego Carrillo de Mendoza y Pimentel" "Diego Fernández de San Pedro" "Diest, Isala van" "Dietrich Weise" "Dietrich von Hildebrand" "Dietrich von Theobald" "Dietrich, Rolf" "Dietwin" "Dijk, Jan van" "Dionisio Aguado y García" "Diponegoro" "Djuanda" "Domingo Santa María González" "Domingo de Iriarte" "Domènech Estapà, Josep" "Donat, Hans von" "Donna Jean Godchaux" "Drífa Viðar" "Duby-Blom, Gertrude" "Dumreicher, Johann von" "Edmond de La Croix" "Eduard V." "Eduardo Caballero Calderón" "Eduardo López Ochoa" "Edward Bransfield" "Edward Hall" "Egbert van Heemskerck" "Einar Arnórsson" "Einar Benediktsson" "Einar Jónsson" "Eitel Friedrich II." "Elard von Oldenburg-Januschau" "Elslande, Renaat van" "Elʿazar ben Yehudah" "Emmanuel d'Alzon" "Emmanuel d'Hooghvorst" "Emmanuel de Grouchy" "Engelen, Piet van" "Engelhardt, Wolf von" "Enver Pascha" "Epaminondas Nunes de Ávila e Silva" "Eric de Bisschop" "Ernst von Wildenbruch" "Escoto, Miguel d'" "Esteva a Alsina, Maurus Daniel" "Estrées, Victor-Marie d'" "Etiemble" "Eugen d'Albert" "Eyck, Jan van" "F. de Lange" "Fadrique de Toledo Osorio" "Faria, Teodoro de" "Fatimah" "Fauveau, Félicie de" "Faḥḥām, Šākir al-" "Feijoo y Montenegro, Benito Jerónimo" "Ferdinand-Philippe d'Orléans" "Fernando González Gortázar" "Fidus" "Fien de la Mar" "Figueiredo, Jackson de" "Finnur Jónsson" "Fitzhugh, Louise" "Foldès, Peter" "Foncemagne, Étienne Lauréault de" "Fraknói, Vilmos" "Frances von Bunsen" "Francisco Gutiérrez Carreola" "Francisco Inácio de Carvalho Moreira" "Franconetti y Aguilar, Silverio" "Franz I." "François de L'Aubespine" "Friedrich IV." "Friedrich Ludwig von Waldburg-Truchsess" "Friedrich V." "Fritz von Unruh" "Frühbeck de Burgos, Rafael" "Funès, Louis de" "Fuʾād ar-Rikābī" "Félicie de Fauveau" "Gabriel Kolko" "Gandásegui y Gorrochátegui, Remigio" "Garaikoetxea Urriza, Juan C." "Garcia-Roza, Luiz Alfredo" "García y Bellido, Antonio" "Gardner, Augustus P." "Geleen und Amsterad, Gottfried Huyn von" "Gemmingen, Pleikard Dietrich von" "Geng, Biao" "Georg VI." "Georg von Hülsen-Haeseler" "George D. Cummins" "George Edward Stanhope Molyneux Herbert of Carnarvon" "Gertrude Duby-Blom" "Gerður Helgadóttir" "Gesang" "Giambologna" "Gianantonio da Sangiorgio" "Gienanth, Curt Ludwig von" "Gillet" "Giovanni Battista Gherardo d'Arco" "Giuliano de Medici" "Godchaux, Donna Jean" "Goldschmidt, Neil Edward" "González Gortázar, Fernando" "Gorter, Johannes de" "Gottfried Huyn von Geleen und Amsterad" "Gottfried IV." "Goëß, Johann von" "Gragger, Robert" "Gramont, Antoine de" "Grandmaison, Louis de" "Gregorio Modrego Casaus" "Grouchy, Emmanuel de" "Guardia Neyra, Jaime" "Gudina Tumsa" "Gunnar Benediktsson" "Gunter d'Alquen" "Gutiérrez Carreola, Francisco" "Guðmundur Böðvarsson" "Gábor, Andor" "Gössmann, Elisabeth" "Habib Pacha es-Saad" "Hafis al-Assad" "Hagen, Hermann von" "Haider Hān ʿAmū-ūġlī" "Hajós, György" "Hall, Edward" "Hamengku Buwono VIIII." "Hani, Motoko" "Hans Schiltberger" "Hans Walter Klein" "Hans von Donat" "Haren, Willem van" "Harms, Claus" "Hase, Karl August von" "Hashimoto, Chikanobu" "Hebe de Bonafini" "Hecken, Abraham van den" "Heem, Jan Davidsz. de" "Heemskerck, Egbert van" "Heinrich Cunow" "Heinrich I." "Heinz Kägebein" "Heinz von Cramer" "Hempel, Ludwig" "Herma Studeny" "Herman de Cunsel" "Hermann I." "Hermann Martin Popert" "Hermann von Hagen" "Hermann von Kusmanek" "Hermann zu Solms-Hohensolms-Lich" "Hildebrand, Dietrich von" "Hildebrand, Rudolf Friedrich Leopold von" "Hooghvorst, Emmanuel d'" "Horst, Jakob" "Hudā aš-Šaʿrāwī" "Humbelina" "Hussein Kamil" "Hussein Onn" "Håkon III." "Hülsen-Haeseler, Georg von" "Ibn-Ṭufail, Muḥammad Ibn-ʿAbd-al-Malik" "Ibrāhīm Ibn-Sinān" "Ibrāhīm al-Yāzīǧī" "Ibrāhīm al-Ǧaʿfarī" "Ida Kar" "Idwār al-Ḫarrāṭ" "Ignacio López Rayón" "Imam Bonjol" "Imanishi, Kinji" "Ippitsusai, Bunchō" "Iriarte, Domingo de" "Isala van Diest" "Ismail I." "Ismail Nasiruddin Shah" "Iustinus II." "Ivoi, Paul d'" "Jackson de Figueiredo" "Jaime Guardia Neyra" "Jakob Horst" "Jakob von Uexküll" "James Muspratt" "Jan Davidsz. de Heem" "Jan Stephan von Calcar" "Jan van Dijk" "Jan van Eyck" "Jan van Munster" "Jardin, Aldephonse Alexandre Félix du" "Jean Joseph Antoine Marie de Witte" "Jean Louis Charles d'Orléans-Longueville" "Jean de Beaumanoir de Lavardin" "Jean de Montereul" "Jean-Dominique de Cassini" "Jean-François Lefebvre de La Barre" "Joan Antoni Desvalls i d’Ardena" "Job" "Jodocus van Winghe" "Johann Adolf von Krosigk" "Johann Friedrich d'Orville" "Johann II." "Johann Klingenberg" "Johann von Dumreicher" "Johann von Goëß" "Johannes d'Outrein" "Johannes de Gorter" "Jorge A. Livraga Rizzi" "Josep Balari i Jovany" "Josep Domènech Estapà" "Joset, Theodor" "José Antonio Nieves Conde" "José Luis Bustamante y Rivero" "José Luis Martín Descalzo" "José López Domínguez" "José López Portillo y Pacheco" "José Maria Sert y Badia" "José María Campo Serrano" "José Robles Pazos" "Joybert, Marc de" "Juan Antonio Rascón Navarro Seña y Redondo" "Juan C. Garaikoetxea Urriza" "Juan Sordo Madaleno" "Juan de Ayolas" "Julie Traberth" "Julius van Zuylen van Nijevelt" "Jurk" "Jászay, Pál" "Jón Leifs" "Jón Magnússon" "Kamāl Yūsuf al-Ḥāǧǧ" "Kar, Ida" "Karl August Ferdinand von Borcke" "Karl August von Hase" "Karl I." "Karl Lustig-Prean" "Karl Pfeffer-Wildenbruch" "Karl von Struve" "Kazó, Stefan" "Khaled el-Hassani ben el-Hachemi" "Kho Liang Ie" "Khun Sa" "Ki, Tomonori" "Kishi, Kōichi" "Klaus Poeck" "Klaus Schönert" "Klein, Hans Walter" "Klingberg, Berta" "Klingenberg, Johann" "Kolko, Gabriel" "Kong, Yingda" "Konráð Gíslason" "Konstantin VII." "Kovács, Attila" "Kristján Eldjárn" "Krosigk, Johann Adolf von" "Kusmanek, Hermann von" "Kállai, Gábor" "Kägebein, Heinz" "Kōno, Takashi" "Kőrösi, Albin" "L'Aubespine, François de" "L. U. P. O." "La Barre, Jean-François Lefebvre de" "La Croix, Edmond de" "Lacerda, Armando de" "Lange, F. de" "Lansere, Nikolaj Evgenʹevič" "Lavardin, Jean de Beaumanoir de" "Laṭīfa az-Zaiyāt" "Lederle, Albert" "Ledi Sayadaw" "Leopoldo de'Medici" "Lerch, Magda von" "Leschetizky, Walter" "Lezo, Blas de" "Liang, Shuming" "Liberty Hyde Bailey" "Lim Boo Liat" "Lipset, Seymour Martin" "Livraga Rizzi, Jorge A." "Lockroy" "Lodewijk van Oranje-Nassau" "Lorenzo di Pierfranceso de'Medici" "Louis Antoine d'Artois d'Angoulême" "Louis de Funès" "Louis de Grandmaison" "Louise Fitzhugh" "Luciano Mendes de Almeida" "Ludwig Hempel" "Luis Martín-Santos" "Luis Polo de Bernabé" "Luis Van Rooten" "Luis Vélez de Guevara" "Luiz Alfredo Garcia-Roza" "Lukardis" "Lustig-Prean, Karl" "Luís de Ataíde" "László I." "López Domínguez, José" "López Ochoa, Eduardo" "López Portillo y Pacheco, José" "López Rayón, Ignacio" "Lúcio de Mendonça" "Lžedmitrij II." "Machbuba" "Maddalena de'Medici" "Maella, Mariano Salvador de" "Magda von Lerch" "Magnus I." "Magnús Eiríksson" "Mahmud II." "Makonnen Haile Selassie" "Malas Zénāwi" "Maleingreau, Paul de" "Malik Ambar" "Malik al-ʿĀdil al I." "Manuel Marulanda Vélez" "Maqrīzī, Aḥmad Ibn-ʿAlī al-" "Mar, Fien de la" "Marc de Joybert" "Maria Firmina dos Reis" "Marianne Schmidt" "Mariano Ospina Pérez" "Mariano Salvador de Maella" "Marie Laure de Noailles" "Marie Louise Fernande de Montpensier" "Mario Ruiz Armengol" "Markus von Wickenburg" "Marsick, Martin Pierre" "Martin Pierre Marsick" "Martín Descalzo, José Luis" "Martín-Santos, Luis" "Marulanda Vélez, Manuel" "María Dolores de Pablos" "Mathilde Therese von Thurn und Taxis" "Matsumoto, Shunichi" "Matsumura, Jinzo" "Matsuura, Takeshirō" "Matthias von Pischof" "Mattā al-Miskīn" "Maurus Daniel Esteva a Alsina" "Mayo" "Mañé i Miravet, Teresa" "Maǧrīṭī, Abu-'l-Qāsim Maslama Ibn-Aḥmad al-" "Medici, Clarice de'" "Medici, Giuliano de" "Medici, Leopoldo de'" "Medici, Lorenzo di Pierfranceso de'" "Medici, Maddalena de'" "Meister E. S." "Mendes de Almeida, Luciano" "Mendonça, Lúcio de" "Merenberg, Sophie von" "Miguel d'Escoto" "Miranda, Tony" "Miyamoto, Musashi" "Modrego Casaus, Gregorio" "Mohamad Asri Muda" "Mohamed Khir Johari" "Molnar, Thomas" "Montereul, Jean de" "Montesquiou-Fézensac, Anne-Pierre de" "Montpensier, Marie Louise Fernande de" "Moraes, Vinícius de" "Moreno Casamitjana, Antonio" "Morris, Robert" "Munster, Jan van" "Murad III." "Muslim Ibn-al-Ḥaǧǧāǧ al-Qušairī" "Muspratt, James" "Mustafa I." "Muḥammad Ibn-ʿAbd-al-Malik Ibn-Ṭufail" "Muḥammad Ibn-ʿAbd-al-Wahhāb" "Muḥammad Taqī Ḫān Pisyān" "Muḥammad ʿAlī Bāšā" "Málaga de Prado, Clorinda" "Mūsā al-Kāẓīm" "Nakae, Toju" "Nakamura, Masanao" "Nalî" "Naora, Nobuo" "Nathan Augustus Cobb" "Neher, Arnold" "Neidhart, Sebastian" "Neil Edward Goldschmidt" "Ngugi wa Thiong'o" "Ni-Tanjung" "Nieves Conde, José Antonio" "Nikolaj Evgenʹevič Lansere" "Noailles, Marie Laure de" "Nolhac, Pierre de" "Nunes de Ávila e Silva, Epaminondas" "Nuʿaim Ibn-Ḥammād al-H̱uzāʿī" "Nūr al-Dīn" "Oddur Björnsson" "Oldenburg-Januschau, Elard von" "Oranje-Nassau, Lodewijk van" "Orléans, Ferdinand-Philippe d'" "Orléans-Longueville, Jean Louis Charles d'" "Orsi, Achille d'" "Orueta y Duarte, Ricardo de" "Orville, Johann Friedrich d'" "Ospina Pérez, Mariano" "Otto III." "Outrein, Johannes d'" "Ozaki, Shirō" "Ozawa, Seiji" "Pablos, María Dolores de" "Paku Alam VIII" "Paul Phélypeaux de Pontchartrain" "Paul d'Ivoi" "Paul de Maleingreau" "Pendeta Zaba" "Pfeffer-Wildenbruch, Karl" "Philipp von Breuner" "Philips van Almonde" "Pi, Xirui" "Piazza, Albertino della" "Pierre de Nolhac" "Pierre-Louis" "Piet van Engelen" "Pieter de Carpentier" "Pischof, Matthias von" "Pleikard Dietrich von Gemmingen" "Poeck, Klaus" "Polo de Bernabé, Luis" "Pontchartrain, Paul Phélypeaux de" "Popert, Hermann Martin" "Porter, Augustus Seymour" "Prats Gonzaléz, Carlos" "Pèrs, Ciro di" "Pétur Sigurðsson" "Pîremêrd" "Quertenmont, Andreas Bernardus de" "Quinquela Martín, Benito" "Radim Cvrček" "Rafael Frühbeck de Burgos" "Ramadhan Karta Hadimadja" "Rascón Navarro Seña y Redondo, Juan Antonio" "Rashid Maidin" "Reis, Maria Firmina dos" "Remigio Gandásegui y Gorrochátegui" "Renaat van Elslande" "Renaud de Roucy" "Rethar" "Ricardo Valderrama Fernández" "Ricardo de Orueta y Duarte" "Richard von Süßkind-Schwendi" "Ridout, Alan" "Rikābī, Fuʾād ar-" "Robert Aitken" "Robert Morris" "Robles Pazos, José" "Rodrigo Arenas Betancourt" "Rolf Dietrich" "Rudolf Friedrich Leopold von Hildebrand" "Ruiz Armengol, Mario" "Ryan, Dermot" "Rühle-Gerstel, Alice" "Saad, Habib Pacha es-" "Sahagún, Bernardino de" "Saint-Exupéry, Consuelo de" "Saint-Pol-Roux" "Saki" "Samuel Bourne" "San Pedro, Diego Fernández de" "Sancho I." "Sangallo, Antonio da" "Sangiorgio, Gianantonio da" "Santa María González, Domingo" "Santi di Tito" "Santos Cabral, Antonio dos" "Sanz Briz, Ángel" "Sarasa, Alfonsus Antonius de" "Sarkīs ar-Rizzī" "Schiltberger, Hans" "Schlesinger, Ignác" "Schmidt, Marianne" "Schön, Theodor von" "Schönert, Klaus" "Sebastian Neidhart" "Segundo Serrano Poncela" "Sein Lwin" "Serrano Poncela, Segundo" "Sert y Badia, José Maria" "Seymour Martin Lipset" "Shahnon Ahmad" "Shemuʾel ben Ḥofni" "Sigfús Einarsson" "Sighvatur Þórðarson" "Sigríður Hagalín" "Silverio Franconetti y Aguilar" "Simeon bar Sabbaʿe" "Simkoyê Şikak" "Simonyi von Vitézvár, Lajos" "Sirius" "Soedjatmoko" "Solms-Braunfels, Bernhard zu" "Solms-Hohensolms-Lich, Hermann zu" "Sophie von Merenberg" "Sordo Madaleno, Juan" "Spewack, Bella Cohen" "Stefán Hörður Grímsson" "Stefán Karl Stefánsson" "Steingrímur SteinÞórsson" "Stephan Guðmundsson Stephansson" "Struve, Karl von" "Studeny, Herma" "Subandrio" "Sugihara, Chiune" "Suharto" "Sukarno" "Surāqa Ibn-Mirdās al-Aṣġar" "Suzuki, Shōsan" "Svavar Guðnason" "Szigeti, Joseph" "Sébastien de Brossard" "Séverine" "Süßkind-Schwendi, Richard von" "Sābūr Ibn-Sahl" "Sādāt, Ǧīhān as-" "Tadsch ad-Din al-Hasani" "Tahir Jalaluddin" "Takamatsu, Jirō" "Takeyama, Michio" "Taufīq al-Ḥakīm" "Teddy Bunn" "Teodoro de Faria" "Teresa Mañé i Miravet" "Tewolde Berhan Gebre Egziabher" "Theobald, Dietrich von" "Theodor von Schön" "Thibaut II." "Thomas I." "Thorkild Fjeldsted" "Thurn und Taxis, Mathilde Therese von" "Théodore Agrippa d'Aubigné" "Tisza, István" "Tito, Santi di" "Tokugawa, Nariaki" "Toledo Osorio, Fadrique de" "Tomás Antonio de la Cerda y Aragón" "Tony Miranda" "Tounens, Antoine de" "Traberth, Julie" "Tschagribeg" "Tschurtschenthaler, Anton von" "Ueshiba, Morihei" "Uexküll, Jakob von" "Uffenbach, Zacharias Konrad von" "Ulrich I." "Ultra Violet" "Umehara, Takeshi" "Ungnad von Weissenwolf, David zu Sonnegg von" "Unruh, Fritz von" "Valderrama Fernández, Ricardo" "Van Rooten, Luis" "Varano, Costanza da" "Victor-Marie d'Estrées" "Vinícius de Moraes" "Vladislav I." "Vossug ed Dowleh" "Vélez de Guevara, Luis" "Waldburg-Truchsess, Friedrich Ludwig von" "Walter Leschetizky" "Wang, Zhaojun" "Wefayî" "Weigold, Hugo" "Weise, Dietrich" "Werner I." "Wertheimer, Samson" "Wickenburg, Markus von" "Wildenbruch, Ernst von" "Wilhelm IV." "Wilkins, Charles" "Willem Antonie van Deventer" "Willem van Haren" "Winghe, Jodocus van" "Witte, Jean Joseph Antoine Marie de" "Wolf von Engelhardt" "Yamamoto, Baiitsu" "Yamamoto, Senji" "Yamamoto, Tarō" "Yang, Xianyi" "Yāzīǧī, Ibrāhīm al-" "Yūsuf al-ʿAẓma" "Zacharias Konrad von Uffenbach" "Zaharias, Babe D." "Zaid Ibn-Ḥārīṯa" "Zainab al-Ġazālī" "Zaiyāt, Laṭīfa az-" "Zalka, Máté" "Zarathustra" "Zarea Yaeqob" "Zaʿīm, Ḥusnī az-" "Zheng, Yisao" "Ziemowit IV." "Zuylen van Nijevelt, Julius van" "al-Walīd Ibn-ʿUbaid al-Buḥturī" "Ábrányi, Emil" "Ábrányi, Emil von" "Ángel Sanz Briz" "Ásgeir Ásgeirsson" "Ásmundur Sveinsson" "Étienne Lauréault de Foncemagne" "Ísleifur Gizursson" "Þorlákur Þórhallsson" "Āqā Buzurg aṭ-Ṭihrānī" "Ğawīdān _ Hānum" "Ġazālī, Zainab al-" "Ōtani, Kōzui" "Šaʿrāwī, Hudā aš-" "Šiblī, Abū-Bakr aš-" "Šihāb-ad-Dīn Muḥammad Ġūrī" "Šākir al-Faḥḥām" "Ǧalāl-ad-Dīn Rūmī" "Ǧaʿfarī, Ibrāhīm al-" "Ǧīhān as-Sādāt" "ʿAbbās III." "ʿAbd al-ʿAzīz Ibn Marwān" "ʿAbd-al-Qādir Badāʾūnī" "ʿAbd-al-Ǧabbār Ibn-Aḥmad" "ʿAbdallāh Ibn-ʿAbdalqādir" "ʿImād al-Ḥasanī" "ʿĀliya Bint-ʿAlī al-Ḥiǧāzī" "Ḥabīballāh Kalakānī" "Ḥakīm, Taufīq al-" "Ḥamza Fanṣūrī" "Ḥusain Ṯanāʾī Mašhadī" "Ḥusnī az-Zaʿīm" "Ḥāǧǧ, Kamāl Yūsuf al-" "Ḫarrāṭ, Idwār al-" "Ṣafī-ad-Dīn Ardabīlī" "Ṭihrānī, Āqā Buzurg aṭ-" "Ṯābit Ibn-Qurra" ������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/corpus_issues.jsonl����������������������������0000664�0000000�0000000�00000015475�15273032717�0026474�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"1 & 2, 3 4 5, Mr." "A. D." "ANH DO" "Abd Berg" "Abd Berg née Jones" "Abd van der Berg" "Abd van der Berg née Jones" "Abu Bakar" "Ahmad Jayadi, CHA" "Ahmad ben Husain" "Aishwarya Rai" "Aishwarya Rai Bachchan" "Ajay Reddy B" "Alex Ben Johnson" "Andrew Ma" "Andrew Perkins (MBA)" "Anh Do" "Anh do" "Ann H" "Anna () Smith" "Anna Müller (geb. Schmidt)" "Anna Müller geb. Schmidt" "Assoc Dean" "Attorney General of Minnesota" "Baba Ramdev" "Baker (Johnson), Jenny" "Barbara Scharf" "Barbara Scharf, CPPM" "Baron Davis" "Beethoven, Ludwig van" "Berg, Jan vd" "Beth Edmunds" "Beyoncé Knowles" "Bob Jones, author" "Bob Jones, compositeur" "Bridge (1.4)" "Brundridge, Contessa A" "Carod i" "Carod i Rovira" "Charles van der van der Berg" "Cherice J. (Johnson) Williams" "Chief Keef" "Claus SchroederVP of" "Co-Chairs Miller" "Co-Chairs Muffy Miller" "Count Basie" "DELA CRUZ" "David Ben Gurion" "De Groot" "Dean Koontz" "Dean Martin" "Dean Robert Johns" "Dean Smith" "Dean of Chemistry" "Dean of Chemistry Robert Johns" "Dean. Robert Johns" "Del Toro" "Del toro" "Deputy Secretary of State" "Do Quang Minh" "Doe, John A." "Doe, John A. Kenneth" "Don Cheadle" "Donald mc" "Dr John Smith" "Dr 田中さん, V." "Dr 김민준씨, Jr." "Dr. Do Van Johnson, MD" "Dr. Jason" "Dr. Jason Ross Jay" "Dr. Juan Q. Xavier de la Vega III" "Dr. King" "Dr. Mazen Elrouqi" "Dr. Smith née Jones" "Dr. Van Johnson" "Dr. med. univ." "Dr. med. univ. Margit Popp" "Dr. med. univ. Margit Popp, MSc" "Duke Ellington" "Duke Snider" "Duke of Edinburgh" "Duke of Wellington" "E Anne D" "E Anne D,Leonardo" "E Jones" "E Maria" "Earl Campbell" "Ed, Royce" "Eddie Chang Seng Dee Adecco" "El Dine" "Elder Soares" "Esq Smith" "Esq. Smith" "Esq. van Gogh" "Eunsoo Johanna Jeong" "Flávio Juvenal da Silva Jr" "Flávio Juvenal da Silva Junior" "Freiherr von Richthofen" "G. J. M." "G. M." "G. V. M." "GEORGE SHANAHAN IV" "GREGORY HOUSE M.D." "Garcia de Jalon" "Garcia y Lopez" "Gary Strawsburg, PMP, CSCP, CLSSBB" "Gebhart GF" "General of Minnesota" "Give me a private config" "Grand Rounds" "Guru Gobind Singh" "Guru Nanak" "Hans „Hansi“ Müller" "Hardie Leannon DVM" "Hardie Leannon V" "His Holiness" "His Holiness the Dalai Lama" "Hisham Ibrahim, CPA, CFA" "Hon Solo" "Howard G. Buffett" "I don" "III van Gogh" "Imam Khomeini" "Ivan Ivanov" "Ivan Tham Jun Hoe" "Ivanov Ivan" "Ivanov Ivan Ivanovich" "J A D" "J. A. D." "J. None D." "J. S. V." "J. Smith" "J. Smith Jr." "J. Smith Sr." "J. Smith, MD" "J. Smith, PhD" "J. V." "J.A. K.D." "JA KD" "JOHN SMITH MA" "JOSE E MARIA SANTOS" "JOSEP LLUIS CAROD I ROVIRA" "JUAN DELA CRUZ" "Jack M.A." "Jack MA" "Jack Ma" "Jack Wei Ma" "Jan Kees de Jager" "Jane Smith (Jones)" "Jane Smith (née Jones)" "Jane Smith born Jones" "Jane Smith née Jones" "Jane de la née Jones" "Jane née and Jones Smith" "Jane van der Berg née Jones" "Jane van der Berg née Jr Jones" "Jane van der Berg née y Jones" "Jane van der Berg 旧姓 Jones" "Janey née Jones" "Jean St Clair" "Jean «Petit» Dupont" "Jill St. John" "Jo Ann" "Joao da Silva Do Amaral de Souza" "Joao da Silva do Amaral de Souza" "Joe E. Smith" "Joe Smith" "John Doe ☁️" "John Q Doe" "John An Smith" "John Doe" "John Doe, Phd, Jr" "John F Kennedy" "John I Smith" "John Leed" "John Smith" "John Smith Dr." "John Smith E.S.Q." "John Smith Esq" "John Smith Esq." "John Smith Esquire" "John Smith I" "John Smith Jr." "John Smith MA" "John Smith MC" "John Smith Ma" "John Smith Mr." "John Smith Prof." "John Smith R.A.I." "John Smith RAI" "John Smith Rev." "John Smith Xyz." "John Smith, Dr." "John Smith, Jr." "John Smith, Jr. Ph. D." "John Smith, LEED AP" "John Smith, Ph. D." "John Smith, Ph.D." "John Smith, RAI" "John V" "John V. Smith" "John W. Ingram" "John W. Ingram V" "John W. Ingram, V" "John of the Doe" "John “Jack” Kennedy" "John, Smith, Dr." "Jones née" "Jong van der" "Jong, Piet de" "Jong, van der" "Jose E Maria Santos" "Jose E. Maria Santos" "Jose e Maria" "Jose e Maria Santos" "Josep Carod i Rovira" "Josep Lluis Carod i Rovira" "Jr. Ph. D." "Jr. Van Johnson" "Jr., PhD" "Juan Garcia y Lopez" "Juan de la Vega" "Juan de la de la Vega" "Juan van der" "Jun Hoe" "Kajal Patel Hiring Globally" "Kim, Jr." "King Curtis" "King John V." "LEED AP" "La Shawn K. Ford" "Lala Lajpat Rai" "Lama Zopa" "Lamha Yahya Al Mawali" "Lastname, Firstname" "Lisa Schmidt, MBA" "Lluis Carod i" "Ludwig van Beethoven" "MD - DO - DDS" "MD DDS" "MD, DO, DDS" "MD, PHD" "MSc Dr. med. univ." "Ma, Andrew" "Maheen Farooqi, ACA" "Maier, Amy I, Jr." "Major Harris" "Mary Ann H" "Mary Ann H Luther" "Mary Ann H,Luther" "Mary Jane King" "Mary Nicet" "Matthew Matthew" "Maura Cellario-Pingor, PHR" "Max Born" "Mc Donald" "Md Abdul Karim" "Md,, Phd" "Md. Abdul Karim" "Mesnil Garcia Juan" "Mesnil Garcia de" "Mesnil Garcia van" "Mesnil de" "Mohamad Ali" "Mohamad X" "Mohamad X Surname" "Mohamad Zein El Dine" "Mohamad Zeineddine" "Mr XXX" "Mr. Van Nguyen" "Ms A.J. Smith" "Ms. Rebecca Teichman" "Mx A.J. Smith" "Müller geb." "Nguyễn Thị Minh Khai" "Nguyễn, Thị Vân" "None John None Doe None (None)" "Nonez Smith" "Oren ben Horin" "P. V." "Ph. D." "Ph. D. John Smith" "Ph. D. Van Johnson" "Ph. D., Jr." "Pope Francis" "Potential Gotcha" "Prince Charles" "Prince Fielder" "QC MP" "Quincy Adams" "Rabbi Cohen" "Rafael Sousa dos Anjos" "Reem Al-Bahrani, CPA" "Rob And Beth Edmunds" "Rob Edmunds" "Robert Prince" "Royce, Ed" "S. V." "S. V. D." "Said oglu" "San San Chiou" "Sander van" "Sejal Chaturvedi, CSM" "Sheik Abu Bakar" "Sheik abdul salam" "Sir Van Johnson" "Sir de Mesnil" "Sister Mary" "Sister Souljah" "Smith , John" "Smith Dr" "Smith Jones, Ph. D. Jr." "Smith Jr." "Smith Jr., John" "Smith Juan Vega" "Smith born" "Smith née" "Smith, A.B." "Smith, A.P." "Smith, I." "Smith, J. R." "Smith, J.R." "Smith, John" "Smith, John ," "Smith, John E, III, Jr" "Smith, Jr." "Smith, Jr., PhD" "Smith, LEED AP" "Smith, Ph. D." "Smith, Ph. D. III" "Smith, Ph. D. Jr." "Smith, Ph. D. Jr. MD" "Smith, Ph. D. MD" "Smith, Ph.D. Jr." "Smith, PhD Jr." "Smith, Prof." "St Clair" "Ste Marie" "Steven Hardman" "Steven Hardman, MD - DO - DDS" "Steven Hardman, MD, DO, DDS" "Steven Hardman, RN - CRNA" "Swami Vivekananda" "Swami Vivekananda Saraswati" "Syukri @ Shu Qi Mohd Nor" "Tham Jun" "Tham Jun Hoe" "Tham Junhoe" "The Hon" "The Rt Hon" "The Rt Hon John Jones" "The Rt Hon Kenneth Clarke QC MP, HMG" "UNILEVER LTD" "Unilever Ltd" "Ursula von der Leyen (geb. Albrecht)" "Ursula von der Leyen geb. Albrecht" "Van Johnson" "Van Johnson Smith" "Van Maximilian Carlson" "Van Nguyen" "Vega Smith" "Vega, Juan de la" "Venerable Ananda" "Vincent van Gogh" "Virginia G. Essandoh, J.D." "WOOT ALFRED DOUGHERTY III" "Will De Groot" "Y. L." "Zein El Dine" "der, y van" "dr Vincent van Gogh dr" "e and e" "joao da silva do amaral de souza" "scott e. werner" "scott l. werner" "van ma van" "Хосе И Мария Сантос" "г-н Иван Петров" "سلمان, محمد" "سلمان، محمد" "محمد بن سلمان" ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/corpus_rules.jsonl�����������������������������0000664�0000000�0000000�00000054570�15273032717�0026312�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"'Smitty' Dr. Jones" "'Smitty' John Jones" "'Smitty' Jones" "'Smitty' Jones Jr." "'Smitty' Sir John" "(" "(M.A)" "(MA) John Smith" ", Ana García López" ", Dean" ", Dr. John" ", Dr. Jones vd" ", Dr. abdul Rahman" ", Dr. fils" ", Jane" ", Jane Mrs" ", Jane van" ", John (Bob) Smith" ", John Smith" ", John Smith Jr." ", Jr." ", MA" ", MA Smith" ", MD/PhD" ", PhD" ", Sir John" ", Sir Jones vd" ".," "1. née Smith" "A.B. Vajpayee" "AHMAD IBN-ʿALĪ" "ANH DO" "Abdul MA Smith" "Abu Bakar" "Abu Bakar Salim" "Ahmad al-ʿAbbas" "Alexandre Dumas fils" "Ali Ahmad Vali oglu" "Ali Ahmad Vali oglu Jr." "Ali Ahmad oglu" "Alves, Pedro Fils" "Alves, Pedro Neto" "Ana Fo" "Ana García" "Ana García (Anita) López" "Ana J.R. García" "Ana M. García" "Ana María 3 López" "Ana María García - López" "Ana María García López" "Ana María García-López" "Ana María García–López" "Ana María García-López" "Ana María de la Vega Rodríguez" "Ana María van der Berg" "Ana María-José López" "Ana Neto" "Ana abdul Rahman López" "Andrew" "Andrew (Andy) Perkins" "Andrew Perkins (Andy)" "Andrew Perkins (MBA)" "Andrew Perkins «MBA»" "Anh Do" "Anh Van Do" "Anh van Do" "Anna () Smith" "Anna Greve" "Anna z (domu) Nowak" "Anna z Nowak" "Anne, Swami" "Antonio Pavlović mlađi" "Antonio Pavlović mlađi Jones" "Antonio ‌Negri" "Associate Dean Smith" "Asst. Vice Chancellor John Smith" "Attorney General of Minnesota" "Bahadur, Ram B.K." "Bard Larsen" "Beethoven Ludwig Maria van" "Beethoven Ludwig van" "Beethoven, Ludwig van" "Berg Jan de Jr." "Berg, Jan van der" "Berg, Jan van; MD" "Berg, Jan vd" "Berg, abd née Jones" "Berg, abdul MA" "Berg, abdul Prof." "Berg, abdul Prof. Ali" "Berg, abdul V" "Berg, abdul ma" "Berg, abdul van" "Berg, abdul vd" "Berg, de; Smith" "Buddha Bahadur Tamang" "Carod i" "Carod i Rovira, Josep" "Carod y de Rovira i" "Chancellor Smith" "Chas. Smith" "Chas. Smith, Ed" "Cheikh Anta Diop" "Chief Dean and Mary Smith" "Chief Justice John Roberts" "DOE, JANE MA VD PHD" "DOE, JANE PHD VD" "DOE, JANE PHD VD MA" "DOE, JOHN MA JD" "DOE, JOHN Q MR" "DR. NÉE JUAN y LOPEZ" "DUT.RA MARIA SILVA" "Davis Royce, Ed" "De La Cruz, Ed" "De La Cruz, M.J. K.L." "De La Cruz, M.J. PhD" "Dean" "Dean Smith" "Dean Smith, John" "Dean Smith, MD" "Dean and Mary Smith" "Dean of Students Jane Smith" "Dean. Smith" "Dean. and Mary Smith" "Del Toro" "Doe Jane Mrs" "Doe nee Smith Jr. Prof., Jane" "Doe nee Smith, Jane" "Doe, Jane Dr" "Doe, Jane Fru" "Doe, Jane Miss" "Doe, Jane Mr Mrs" "Doe, Jane Mr.." "Doe, Jane Mrs" "Doe, Jane Mrs, PhD" "Doe, Jane Ms" "Doe, Jane PhD MEng" "Doe, Jane nee Smith" "Doe, Jane van Mrs" "Doe, Jane, and Jr." "Doe, John DO" "Doe, John DO Ed" "Doe, John Do" "Doe, John Goodman" "Doe, John Jr Mr" "Doe, John MA" "Doe, John MA JD" "Doe, John MA Smith" "Doe, John MA do" "Doe, John Ma" "Doe, John Ma Smith" "Doe, John Master" "Doe, John Mr Jr" "Doe, John Msc.Ed." "Doe, John Pope" "Doe, John X.Y.Z." "Doe, John ma Ma" "Doe, John ma do" "Doe, John van DO" "Doe, John van Ma" "Doe, MA PhD" "Doe, Ma PhD" "Doe, Mrs" "Doe, PhD Jones" "Doe, abdul Mrs" "Dr Jr" "Dr Pope Jr" "Dr Smith V" "Dr." "Dr. (MA)" "Dr. Ana García López" "Dr. Freiherr von Berg, Ed" "Dr. John Smith Prof." "Dr. John van der Berg" "Dr. Jr née Jones" "Dr. Jr., John" "Dr. Juan Q. Xavier de la Vega III" "Dr. King" "Dr. Mc Donald, John" "Dr. Pope" "Dr. Pope MD" "Dr. Pope, John" "Dr. Sir John" "Dr. Smith" "Dr. Smith II, Sir" "Dr. Smith Jr., AD" "Dr. Smith Sir." "Dr. Smith V, AD" "Dr. Smith and Bishop" "Dr. Smith née Jones" "Dr. Smith, John" "Dr. St. Pierre, Ed" "Dr. abdul salam" "Dr. de la Vega, Juan" "Dr. inž. Janez Novak" "Dr. juan garcia" "Dr. med. univ. Margit Popp, MSc" "Dr. nee Smith PhD Prof." "Dr., John" "Duke of Edinburgh" "Dumas fils" "Dumas, Alexandre Fils" "Dumas, Alexandre fils" "Dumas, fils" "Eric H. Holder Jr. Attorney General" "Eric H. Holder, Jr., Attorney General" "Eric H. Holder, Jr., Secretary of State" "Esq. Smith" "É. y É. Smith" "Field Marshal Bernard Montgomery" "Freiherr St John Smith" "Freiherr von Berg MA" "Freiherr von Berg MA Ma" "Freiherr von Berg, Ed" "Freiherr von Richthofen V" "GRACE SMITH" "Gabriel García Márquez" "Gabriel García Márquez Jr." "Gabriel García Márquez, PhD" "Gal·la Serra" "Garcia" "Garcia Juan Carlos" "Garcia Lopez Jr., Juan" "García López, Ana" "García Márquez Ana María" "García Márquez Gabriel" "García Márquez, Ed G.J." "García Márquez, G.J" "García Márquez, G.J." "García Márquez, G.J.R." "García Márquez, MJ" "García Márquez, MJ JK" "García Márquez, MJ PhD" "García Márquez, Ms G.J." "General Major John Smith" "Georg VI." "Grace Dean Smith" "Grace Smith" "Graf Lambsdorff, Otto" "Grand Duke of Tuscany" "Greve Anna" "Greve Smith, Anna" "Greve, Anna" "Gyani Zail Singh" "Hans „Erster“ und “Zweiter” Müller" "Hassan Mohamad Ali" "Hassan, Mohamad Ahmad Ali" "Her Majesty Queen Elizabeth" "Her Royal Highness Princess Anne" "Her Royal Highness Princess Grand Anne" "His Excellency Lord Duncan" "His Grace John Smith" "His Majesty King Charles" "Hon. Wing Lau" "Hudson, Rock" "II Van Johnson" "IVOI, PAUL D'" "Ivoi, Paul d'" "J NEE SMITH" "J née Smith" "J-E-P DUPONT" "J. Smith" "J. née Jones Smith V" "J. née Smith" "J. Ó. Pérez" "J.MCDONALD SMITH" "J.R. Smith" "J.Smith" "JACK MA" "JANE DOE MA R.T." "JEFFREY (JD) BRICKEN" "JOHN PROF. MA" "JOHN QUINCY SMITH I" "JOHN SMITH (M.A)" "JOHN SMITH (MA)" "JOHN SMITH (m.a) (MA)" "JOHN SMITH JR, AD" "JOHN SMITH MA" "JOHN SMITH MA JONES" "JOHN SMITH NEE X X" "JOHN SMITH PH.D." "JOHN SMITH SAM E." "JOHN SMITH VI" "JOHN SMITH, FNP-BC" "JOHN SMITH, MA" "JOHN SMITH, PU-PH" "JOHN SMITH, VD MA" "JOHN V" "JOSE DA SILVA FH" "JOSE ORTEGA-Y-GASSET" "JOSÉ GARCÍA" "JUAN GARCIA Jr." "JUAN GARCIA Y LOPEZ" "Jack (M.A)" "Jack (MA)" "Jack I-V" "Jack I.-V." "Jack MA" "Jack Ma" "Jack Ma." "Jack PHARM D" "Jack Wei Ma" "Jack X.Y.I." "Jack XY.ZW." "Jane (Janey, Jo) Smith" "Jane (née , Jones) Smith" "Jane (née ,Jones) Smith" "Jane (née Jones) Smith" "Jane (née​Jones) Smith" "Jane Doe (nee Smith MA)" "Jane Doe (nee Smith Ma)" "Jane Doe (nee Smith Prof.)" "Jane Doe (nee Smith) MA" "Jane Doe Dr. Ph. D." "Jane Doe Jr. nee Smith" "Jane Doe MA R.T." "Jane Doe Ma R.T." "Jane Doe Mrs" "Jane Doe PhD nee Smith" "Jane Doe de D Junior" "Jane Doe nee MA" "Jane Doe nee MA Smith" "Jane Doe nee Pope." "Jane Doe nee Prof. Dr." "Jane Doe nee Puig i" "Jane Doe nee Puig i Soler" "Jane Doe nee Smith B.K." "Jane Doe nee Smith DO DO" "Jane Doe nee Smith MA" "Jane Doe nee Smith MA Prof." "Jane Doe nee Smith Ma" "Jane Doe nee Smith Ma JD" "Jane Doe nee Smith PhD Smith" "Jane Doe nee Smith Prof." "Jane Doe nee Smith Prof. MA" "Jane Doe nee Smith V PhD" "Jane Doe nee Smith V Prof." "Jane Doe nee Smith VI PhD" "Jane Doe nee Smith VI Prof." "Jane Doe nee Smith X.Y.Z." "Jane Doe née Jones Smith V" "Jane Doe, MS LAc" "Jane Smith (Nee)" "Jane Smith (Nee) (Jones)" "Jane Smith (née Jones)" "Jane Smith (née Jr.)" "Jane Smith (née PhD)" "Jane Smith (née V)" "Jane Smith (née)" "Jane Smith Vice." "Jane Smith née Jones" "Jane Smith née Jones PhD" "Jane Smith née PhD" "Jane Smith née V" "Jane Smith, née Jones" "Jane and née Jones" "Jane de la née Jones" "Jane nee Smith Jr., AD" "Jane née Jones Smith" "Jane née Jr y Jones" "Jane van der Berg nee Prof. Pope. MA" "Jane van der Berg nee Smith Prof." "Jane van der Berg née" "Jane van der Berg née Jones" "Jane van der Berg née PhD" "Jane van der Berg née y Jones" "Jane „JD Smith" "Jean 'JD' Smith" "Jean (M.A) Smith" "Jean DUPONT" "Jean Marie Fils" "Jean Pierre DUPONT" "Jean Pierre DUPONT-MORETTI" "Jean Pierre MA DUPONT" "Jeffrey (JD) Bricken" "Jiří Dienstbier ml." "John . Smith" "John Dean Smith" "John Doctor Smith" "John III" "John III, Dr." "John III, Sir" "John Ingram V" "John J VI, AD" "John J. III" "John J. V" "John J. VI" "John Ma" "John Ph. D." "John PhD Smith" "John Pharm D" "John Prof. MA" "John Quincy Smith i" "John Smith" "John Smith (Ed)" "John Smith (M.A)" "John Smith (M.A.)" "John Smith (MA PhD)" "John Smith (MA)" "John Smith (MA) Dr." "John Smith (MA) Ma" "John Smith (MA) Ph. D." "John Smith (MA) née Jones" "John Smith (MA), PhD" "John Smith (MA)." "John Smith (MA.)" "John Smith (MBA PhD.)" "John Smith (Ma)" "John Smith (PhD MBA)" "John Smith - Jones" "John Smith 1.4" "John Smith DO Jones" "John Smith Esq." "John Smith II, AD" "John Smith III" "John Smith III MD" "John Smith Iii" "John Smith J. R." "John Smith J.u.n.i.o.r." "John Smith Jr." "John Smith Jr. Prof." "John Smith Jr., AD RA" "John Smith Jr., AD Sr. and Mr." "John Smith Jr., J." "John Smith Jr., M.J." "John Smith Jr., MBChB PhD" "John Smith Jr., MJ" "John Smith Jr., Mr. and Mrs. AD" "John Smith Jr., Sir AD" "John Smith Jr., þingmaður" "John Smith K.C." "John Smith M. D." "John Smith M.A." "John Smith M.A. Jones" "John Smith MA" "John Smith MA JD Jones" "John Smith MA Jones" "John Smith MB ChB" "John Smith MD PhD" "John Smith MD-Jones" "John Smith MD-PhD" "John Smith Ma" "John Smith Ma Jones" "John Smith Ma." "John Smith Mc V" "John Smith Msc.Ed." "John Smith Ph. D. Jones" "John Smith PhD" "John Smith PhD (MA)" "John Smith PhD (Ma)" "John Smith PhD Ed Ma" "John Smith PhD Jones" "John Smith PhD MEng" "John Smith PhD Prof. Ma" "John Smith Pharm D" "John Smith Pharm D Jones" "John Smith Pharm D and Mary" "John Smith Pharm. D." "John Smith Prof." "John Smith Prof. Dr." "John Smith Prof. Jr." "John Smith Psy. D." "John Smith Q.W.E.R.T." "John Smith R.A.I." "John Smith Sir" "John Smith V" "John Smith V Ph. D." "John Smith V PhD" "John Smith V Pharm D" "John Smith V, AD" "John Smith V, M.J." "John Smith V." "John Smith VI" "John Smith VI Jones" "John Smith VI MD" "John Smith VI, AD" "John Smith VI, M.J." "John Smith Vi" "John Smith Viii" "John Smith Wing." "John Smith Wing. Jr." "John Smith X.Y.Z." "John Smith XI" "John Smith XI MD" "John Smith XYZ" "John Smith Xyz." "John Smith vi" "John Smith «MA»" "John Smith 「MA」" "John Smith 博. 士." "John Smith, A-B" "John Smith, A.B." "John Smith, A.B. Ph.D." "John Smith, AD" "John Smith, Dr. / MD" "John Smith, Dr. / and MD" "John Smith, Dr; MD" "John Smith, Ed" "John Smith, Ed Ma" "John Smith, Jones" "John Smith, Jr do" "John Smith, Jr vd" "John Smith, Jr., Lt.Gov." "John Smith, Jr., abdul Dr. Smith" "John Smith, Jr., van der Berg Prof." "John Smith, LEED AP" "John Smith, M. D." "John Smith, M.D./Ph.D." "John Smith, M.D.; Ph.D." "John Smith, MA" "John Smith, MA/PHD" "John Smith, MA/PhD" "John Smith, MA; PhD" "John Smith, MD - Chief of Surgery" "John Smith, MD - PhD" "John Smith, MD / PhD" "John Smith, MD ; PhD" "John Smith, MD – PhD" "John Smith, MD, Bart" "John Smith, MD, Ma" "John Smith, MD, Ms" "John Smith, MD, R.A.I." "John Smith, MD, XYZ" "John Smith, MD,, Jr." "John Smith, MD- PhD" "John Smith, MD/ PhD" "John Smith, MD/PhD" "John Smith, MD; PhD" "John Smith, MD;PhD" "John Smith, MS-OMPT" "John Smith, MS-V" "John Smith, Ma" "John Smith, Md-Rn" "John Smith, Mr." "John Smith, Mr. Jr." "John Smith, Mr. and Mrs." "John Smith, PU-PH" "John Smith, PU/PH, Jr." "John Smith, Ph. D. Jones" "John Smith, Ph. D.; MD" "John Smith, PhD" "John Smith, PhD DO DO" "John Smith, PhD Jones" "John Smith, PhD MEng" "John Smith, PhD X.Y." "John Smith, PhD XYZ" "John Smith, PhD vd DO" "John Smith, PhD-Md" "John Smith, Pharm D" "John Smith, Phd-Md" "John Smith, Prof. - of Surgery" "John Smith, Pu-Ph" "John Smith, RA Jones" "John Smith, V." "John Smith, V; MD" "John Smith, Vice" "John Smith, X.Y. P.Q." "John Smith, X.Y.Z." "John Smith, XYZ" "John Smith, XYZ- QRST" "John Smith, md-phd" "John Smith, otr/l" "John Smith, vd Ma" "John Smith-Jones" "John Smith-Jones, MD-PhD" "John V" "John V, Dr." "John W Ingram V" "John W. Ingram V" "John and Jane Smith" "John nee Prof. ba MA" "John née Jones Smith Ma" "John née Jones Smith V" "John of Bishop" "John of Judge" "John van Buren, Ed" "John van Mc" "John van der Berg" "John van der Berg Ma" "John van der Berg PhD" "John van der Berg Prof." "John van der Berg Smith" "John van der Berg V" "John van der Berg née Jones" "John van der J. V" "John von PhD Jones" "John й Smith" "John 「Jack」 Smith" "John😀Smith" "Jon Dough and" "Jones Jr. née Smith" "Jones née" "Jong Anke de" "Jong, Anke de" "Jong, Piet de" "Jose E Maria Santos" "Jose Ortega-Y-Gasset" "Jose e Maria Santos" "Jose j y Lopez" "Josep Carod i Rovira" "Josep Lluis Carod i III" "Josep i Rovira" "José María Ortega y Gasset" "José Silva Júnior" "Jr." "Juan & Garcia" "Juan Carlos Pérez" "Juan McDonald" "Juan Pérez H." "Juan Pérez de la Vega" "Juan Velasquez y Garcia" "Juan and Garcia" "Juan de" "Juan de la Vega" "Juan de la Vega Rodríguez" "Juan de y" "Juan garcia III" "Juan van der" "Juan y Eva Garcia" "Juan y Garcia" "Juan y Garcia née Jones" "Juan Ó Pérez" "Juan Ó. Pérez" "KIM, AE-RA" "Kim, AE-RA" "Kim, Ae-Ra" "Kim김민준씨, MA" "King Curtis" "King Henry" "Konstantin VII." "LT.GOV. JOHN DOE" "Lau, Wing" "Lau, Wing Commander" "Lloret Palol II, JA" "Lloret Palol II, Josep Antoni" "Lord Buddha Tamang" "Lord Chancellor" "Lord Chancellor John Smith" "López, Ana García" "MSc Dr. med. univ." "Ma." "Mai Le née Nguyen" "Major General John Smith" "Major Smith" "Malcolm X" "Mari' Aube'" "Maria Ana MA Ortega y Gasset" "Maria Ana MA Silva" "Maria Ana MA de la Vega" "Maria Kowalska (z domu Nowak)" "Maria Kowalska (z domu)" "Maria Kowalska (z, domu Nowak)" "Maria Kowalska z domu Nowak" "Maria Luisa y de la Cruz" "Maria López née García Pérez" "Marie FILS" "Marie Fils" "Marquess of Bath" "Marquis Johnson" "Marquis de Lafayette" "Marshal Yanda" "Mary Beth Smith" "Mary II, Dame" "Mary Jane MA Pope" "Mary Jane Pope" "Mary Jane Pope Smith" "Mary Jane Pope." "Mary Smith née Jones Prof." "Mary-​Jane Smith" "Mc Donald" "Md Abdul Karim" "Mesnil de" "Mohamed Ali Abd Allah" "Morse, Det. Insp. Jane" "Mother Superior of the Convent Mary Smith" "Mr Buddha. Tamang" "Mr Deacon Smith" "Mr Dean Smith" "Mr Justice Smith" "Mr Mahdi Karimi" "Mr Shaikh Ahmed" "Mr Smith" "Mr Wing Lau" "Mr. Jack Jill" "Mr. Jack and Jill" "Mr. Johnson" "Mr. and Mrs. Wing Lau" "Mrs Ma. Liza Cruz" "Mrs. Garcia" "Ms Princess Smith" "Ms Queen Smith" "MÜLLER WEIß, HANS" "Ménil Christophe de" "Ménil de" "Nguyen Thi Van" "Nguyen Van Lac" "Nguyen Van Vi" "Nguyen, Thi Van" "Nguyen, Van" "Nguyen, Van Le" "Nguyễn Thị Minh Khai" "Nguyễn, Thị Vân" "Ortega y Gasset, Dr." "Ortega y Gasset, Ed" "PEDRO DA SILVA FILHO" "PEDRO FILHO" "PEDRO JUNIOR FILHO" "Pavlović, Antonio mlađi" "Pedro Alves Filho" "Pedro Alves Junior Filho" "Pedro Alves M.Filho" "Pedro Alves NETO" "Pedro Alves Neto" "Pedro Alves Neto Júnior" "Pedro Alves Neto, AD" "Pedro Alves Neto, PhD" "Pedro Alves Sobr." "Pedro Alves, Neto" "Pedro Filho" "Pedro J. Neto" "Pedro Junior Filho" "Pedro Junior Ma Filho" "Pedro Junior fils" "Pedro M.Filho" "Pedro MA Filho" "Pedro PhD Neto" "Pedro VI Filho" "Pedro de Neto" "Petrov Ivan Sergeevich" "Ph. D. Van Johnson" "Ph. D., John" "Pharm D John Smith" "Pieter Bruegel stariji Jones" "Pope Smith, John" "President of the United States Barack Obama" "Prince Consort Albert" "Prince Fielder" "Prince Harry" "Prince of Wales" "Prince of Wales Jr" "Princess Royal Anne" "Prof. Dr. Brühl, Ada" "Prof. Smith, Dr. John" "Prof. Smith, John, PhD" "Pérez, Juan Ó." "QC MP" "Queen Elizabeth II" "Queen Mother Elizabeth" "RA DDS" "RA PhD Jr., John" "RAM BAHADUR B.K." "Rahman, Abdul MB ChB" "Ram Bahadur B.K." "Ram Bahadur B.K., PhD" "Ram Bahadur PhD B.K." "Rashid, Harun al-" "Rev. John Smith" "Rev. Sr. Smith, Mary" "Rinpoche" "Royce, Ed" "Ruth Ann Neta" "SEÁN Ó MURCHÚ" "SHIRLEY MACLAINE" "SIG.RA MARIA ROSSI" "SMITH VD MA, JOHN" "SMITH VD, JOHN" "SMITH, DEAN MR." "SMITH, JOHN DO" "SMITH, MA" "SMITH, SAM E." "SMITH, VD DE LA MA" "SMITH, VD MA" "STEVEN HARDMAN, MD, DO, DDS" "Salam, abd Allah" "Sean O'Connor" "Sergeant Major John Smith" "Sgt Marshal Smith" "Shaikh Hamad bin Isa" "Shaikh. Sultan Khan" "Sheikh Mohammed bin Rashid" "Shin Ae-ra" "Shirley Maclaine" "Sidorov Ivan Petrovich Jr." "Silva Neto, Pedro" "Silva, Neto" "Sir Abu Bakar" "Sir John" "Sir John Prof." "Sir Ph. D. Van Johnson" "Sir Smith, John" "Sir abdul Jr Smith" "Sir abdul salam" "Sir abdul salam Jr" "Sir abdul van der Berg" "Sir de" "Sir de Mesnil" "Sita Kumari K.C" "Smith" "Smith (Jones)" "Smith (MA), John" "Smith (née Jones)" "Smith Jones Jr., J.A." "Smith Jr." "Smith Jr., John" "Smith Jr., MA" "Smith Jr., Ma" "Smith Jr., Mr." "Smith Pharm D, John" "Smith Prof." "Smith Sir." "Smith V, AD" "Smith V, John" "Smith VI, AD" "Smith VI, John" "Smith Vi, John" "Smith and Pope" "Smith née Jones" "Smith née Jones PhD" "Smith, A.B." "Smith, Abd" "Smith, Chancellor of the Exchequer MP" "Smith, Chas." "Smith, Do. John" "Smith, Dr." "Smith, Dr. Jr." "Smith, Esq." "Smith, Esq. John" "Smith, Freiherr von Berg MA Jones" "Smith, Insp. John" "Smith, Jan van Prof." "Smith, Jane Mrs" "Smith, John" "Smith, John (MA)" "Smith, John - Paul" "Smith, John MA Jones" "Smith, John MD/PhD" "Smith, John Ma" "Smith, John PSM I, Jr." "Smith, John PhD I, Jr." "Smith, John PhD I." "Smith, John PhD I., Jr." "Smith, John PhD Jones" "Smith, John PhD de Jr." "Smith, John PhD É, Jr." "Smith, John Pharm D" "Smith, John Pharm D Jones" "Smith, John Prof." "Smith, John Sam E." "Smith, John V" "Smith, John V MD" "Smith, John V, Jr." "Smith, John V." "Smith, John V;" "Smith, John VI" "Smith, John VI Jones" "Smith, John VI MD" "Smith, John Vi" "Smith, John X" "Smith, John van der Berg Prof." "Smith, John, Bob (MA)" "Smith, John, MD - PhD" "Smith, John, MD - née Jones Smith" "Smith, John, PhD - and MD" "Smith, John, PhD - i Soler" "Smith, John, PhD née Jones" "Smith, John, Prof." "Smith, John, Puig - i Soler" "Smith, John, X.Y.Z. (MA)" "Smith, John, and" "Smith, John; MD" "Smith, Jr." "Smith, MA" "Smith, MD ; PhD" "Smith, MD PhD" "Smith, MD; PhD" "Smith, Ma" "Smith, Ma. John" "Smith, Major. John" "Smith, Mary-Jane" "Smith, Ms Jane" "Smith, Ms Ma" "Smith, Ms." "Smith, Ms. Jane" "Smith, PSM I" "Smith, PSM I." "Smith, PU-PH" "Smith, Ph. D. Jr." "Smith, PhD" "Smith, PhD MEng" "Smith, PhD Ma" "Smith, Pharm D" "Smith, RA PhD Ma" "Smith, RN - CRNA" "Smith, Sgt Major" "Smith, Sir" "Smith, Sr." "Smith, V; MD" "Smith, XYZ" "Smith, de Mesnil Jean" "Smith. John" "St Pierre, Ed" "St Sir de Mesnil" "St van Bishop" "Sta. Maria, Juan" "Stariji Bruegel" "Steven Hardman, MD, DO, DDS" "Steven Hardman, RN - CRNA" "Sultan Hassanal Bolkiah" "Sultana Begum" "The Hon. Justice Mary Smith" "The Hon. Wing Lau" "The Mahdi Muhammad Ahmad" "The Rev. Deacon John Smith" "The Right Hon. the President of the Queen's Bench Division" "VI, AD" "Van Buren, Ed" "Van Johnson" "Vega, Juan de la" "Vincent van Gogh van Beethoven" "Wang M.Eng." "Wang Ma PhD" "Wilhelm I." "William Shakespeare, the Bard" "Wing Commander Lau" "Wing Lau" "Wing Smith, John" "Wing. Lau" "Wm. H. Smith III" "Xyz. John Smith" "Xyz. Smith, John" "Xyz. and Mary Smith" "Xyz. van Johnson" "Zaʿīm, Ḥusnī az-" "abd Allah" "abd Allah Smith" "abd Allah Smith née Jones" "abd Berg née Jones" "abd née Jones" "abdul Jr Smith Berg" "abdul Ma Smith" "abdul Ph. D. Smith Berg" "abdul Salam, Ed" "abdul Sir Smith Berg" "abdul Smith Berg Ma" "abdul Smith Jr Ma" "abdul Smith Jr V" "abdul Smith Ma" "abdul Smith V" "abdul V Smith" "abdul salam ahmed salem" "anh van do" "carod i" "de" "de Anke van y" "de Mesnil Jean" "de Mesnil Jean Paul" "de la Cruz Juan Carlos" "de la Torre Vega" "de la Vega" "de la Vega y Santos Juan" "de los Santos" "dipl.-ing john smith" "doe, jane phd vd ma" "doe, jane v phd do" "donovan mcnabb-smith" "dott.ssa anna bianchi" "dr. juan garcia III" "dr. née juan y lopez" "émile zola" "ibn Awf abdul Rahman" "ina binti navalamar" "j" "j nee Smith" "j nee smith" "j.-e.-p. dupont" "j.r.r. tolkien" "jacobus henricus van 't hoff" "jean-luc picard" "john 3rd smith" "john doe q.c." "john e smith" "john i" "john i dr." "john i phd" "john j v" "john j v, PhD" "john smith (MA)" "john smith EdD" "john smith MEng" "john smith bsc" "john smith ed" "john smith i" "john smith iii" "john smith iii." "john smith ix" "john smith jr" "john smith m.d." "john smith mba" "john smith mcse" "john smith md" "john smith meng" "john smith nee x x" "john smith p.h.d." "john smith pa-c" "john smith ph. d." "john smith ph.d." "john smith pharm d" "john smith phd" "john smith psyd" "john smith v" "john smith v, PhD" "john smith vi" "john smith x.y.z." "john smith xi" "john smith, PHARM D" "john smith, ms-pt" "john smith, otr/l" "john smith, v" "john smith, vi" "john smith-jones" "john v" "john van der berg ma" "john van e phd" "jose e lopez" "jose e maria santos" "jose e phd" "jose j y lopez" "jose ortega-y-gasset" "jose y lopez" "josé garcía" "juan de la vega" "juan e-f smith" "juan garcia III" "juan garcia Iii" "juan garcia PhD" "juan garcia y lopez" "juan mcdonald" "juan y-garcia" "juan y-garcia-lopez" "kim, ae-ra" "lt.gov john doe" "m.ʿali smith" "maria da-silva" "maria silva-e-sousa" "mohamad ali smith" "née Jones" "qc mp" "smith, de ma" "smith, j y lopez" "smith, john e phd" "st.john smith" "tran lac" "van Berg Jan de" "van Doe, Jane Mrs" "van Gogh" "van Gogh Ana María" "van Gogh, Sir" "van der Berg, MA" "van der Berg, PhD" "van der Berg, Sir" "wang meng" "zaʿīm, ḥusnī az-" "É née Smith" "É.J. DUPONT" "Ó. Pérez" "é nee smith" "ʻIOLANI KAMAKA" "Агриппина Младшая" "Ана Хорват старији Џонс" "Гай Плиний Старший Иванов" "Ж née Smith" "Ж. née Smith" "Иван Петрович Абрамович" "Питер Брейгель мл." "Сидоров Иван Петрович" "Хосе И Мария Сантос" "جورج بوش الابن" "سلمان، محمد" "राम शर्मा साहब मोहन" "शर्मा, राम बाबू" "প্রকৌশলী. Sen" "สมชาย ใจดี พ.บ." "김. 민준" "​Dr. John Smith" "​王小明" "ア゙イ タロウ" "マイケル ジャクソン" "マイケル ジャクソン" "マイケル・ジャクソン" "ロバート・ダウニー・ジュニア" "佐藤恵子 国会議員" "威廉·莎士比亚" "威廉・莎士比亚" "安东尼·陈志明" "山田 太郎" "山田 花子 旧姓:佐藤" "山田 タロウ" "岸田文雄衆議院議員" "张简" "柯洁" "毛·泽东" "毛泽东" "王 小 明" "王君" "田中 副社長" "田中 社長" "田中. 太郎" "田中、太郎" "田中さん" "田中さん 様." "田中さん, Dr." "田中さん, PhD" "田中社長" "田中部長" "钟离小凤" "陳子媚 二世" "陳怡君二世" "马丁·路德·金씨" "高橋 みなみ" "高橋・一郎" "高橋一郎" "김 민준" "김민준" "김민준 박사님" "김민준씨" "김민준씨." "김지양" "남궁민수" "남궁민수 지훈" "남궁민수, 지훈" "선생님" "지훈, 남궁민수" "ヤマダ タロウ" ����������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/corpus_shapes.jsonl����������������������������0000664�0000000�0000000�00000030661�15273032717�0026436�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������{"name": "ANH DO", "shape": 1} {"name": "Bridge (1.4)", "shape": 1} {"name": "Bridge (A.B)", "shape": 1} {"name": "Carod i", "shape": 1} {"name": "Carod y de Rovira i", "shape": 1} {"name": "Dr. Juan de la Vega III", "shape": 1} {"name": "Duke of Edinburgh", "shape": 1} {"name": "E SMITH JONES", "shape": 1} {"name": "J.A. K.D.", "shape": 1} {"name": "J.R.R. Tolkien", "shape": 1} {"name": "JACK MA", "shape": 1} {"name": "JANE DOE NEE SMITH MA", "shape": 1} {"name": "JANE DOE NEE YO-YO MA", "shape": 1} {"name": "JANE YO-YO MA", "shape": 1} {"name": "JOHN E SMITH", "shape": 1} {"name": "JOHN MA PHD", "shape": 1} {"name": "JOHN NEE JONES SMITH MA PHD", "shape": 1} {"name": "JOHN SMITH MA", "shape": 1} {"name": "JOSE E MARIA SANTOS", "shape": 1} {"name": "JOSEP CAROD I ROVIRA", "shape": 1} {"name": "JUAN GARCIA Y LOPEZ", "shape": 1} {"name": "JUAN Y GARCIA", "shape": 1} {"name": "Jack MA", "shape": 1} {"name": "Jack MA.", "shape": 1} {"name": "Jack Ma", "shape": 1} {"name": "Jack X.Y.I.", "shape": 1} {"name": "Jack X.Y.Z.", "shape": 1} {"name": "Jane Doe Jr. nee Smith Ma", "shape": 1} {"name": "Jane Doe geb. Smith MA", "shape": 1} {"name": "Jane Doe nee MA", "shape": 1} {"name": "Jane Doe nee MA Smith", "shape": 1} {"name": "Jane Doe nee Puig i", "shape": 1} {"name": "Jane Doe nee Puig i III", "shape": 1} {"name": "Jane Doe nee Puig i Soler", "shape": 1} {"name": "Jane Doe nee Smith 2", "shape": 1} {"name": "Jane Doe nee Smith MA", "shape": 1} {"name": "Jane Doe nee Smith MA PhD", "shape": 1} {"name": "Jane Doe nee Smith MA Prof.", "shape": 1} {"name": "Jane Doe nee Smith MA y", "shape": 1} {"name": "Jane Doe nee Smith Ma", "shape": 1} {"name": "Jane Doe nee Smith Ma JD", "shape": 1} {"name": "Jane Doe nee Smith Ma Prof.", "shape": 1} {"name": "Jane Doe nee Smith Ma y", "shape": 1} {"name": "Jane Doe nee Smith PhD MA", "shape": 1} {"name": "Jane Doe nee Smith PhD MEng", "shape": 1} {"name": "Jane Doe nee Smith Prof.", "shape": 1} {"name": "Jane Doe nee Smith Prof. MA", "shape": 1} {"name": "Jane Doe nee Smith V", "shape": 1} {"name": "Jane Doe nee Smith V Prof.", "shape": 1} {"name": "Jane Doe nee Smith XYZ", "shape": 1} {"name": "Jane Doe nee Smith do", "shape": 1} {"name": "Jane Doe nee Smith ma jd", "shape": 1} {"name": "Jane Doe nee Yo-Yo Ma", "shape": 1} {"name": "Jean DUPONT", "shape": 1} {"name": "Jean Pierre DUPONT", "shape": 1} {"name": "John \"Jack\" Kennedy", "shape": 1} {"name": "John Doe JD.CPA", "shape": 1} {"name": "John Doe do", "shape": 1} {"name": "John Jack Andrew Kennedy", "shape": 1} {"name": "John Quincy Smith i", "shape": 1} {"name": "John Smith", "shape": 1} {"name": "John Smith 1.4", "shape": 1} {"name": "John Smith B.Tech.", "shape": 1} {"name": "John Smith C.H.A.", "shape": 1} {"name": "John Smith E.S.Q.", "shape": 1} {"name": "John Smith J.u.n.i.o.r.", "shape": 1} {"name": "John Smith Jr.", "shape": 1} {"name": "John Smith MEng PhD", "shape": 1} {"name": "John Smith Ma", "shape": 1} {"name": "John Smith Ph.", "shape": 1} {"name": "John Smith PhD Ed Ma", "shape": 1} {"name": "John Smith PhD MEng", "shape": 1} {"name": "John Smith PhD Ma", "shape": 1} {"name": "John Smith Q.W.E.R.T.", "shape": 1} {"name": "John Smith R.A.I.", "shape": 1} {"name": "John Smith X.Y.Z.", "shape": 1} {"name": "John Smith XYZ", "shape": 1} {"name": "John Smith Xyz.", "shape": 1} {"name": "John Smith nee Jones R.A.I.", "shape": 1} {"name": "John V. Smith", "shape": 1} {"name": "John and Jane Smith", "shape": 1} {"name": "John de Ma", "shape": 1} {"name": "John e Smith", "shape": 1} {"name": "John van der Berg Ma", "shape": 1} {"name": "Jon Dough and", "shape": 1} {"name": "Jose E Maria Santos", "shape": 1} {"name": "Jose e Maria Santos", "shape": 1} {"name": "Josep Carod I Rovira", "shape": 1} {"name": "Josep Carod i", "shape": 1} {"name": "Josep Carod i Rovira", "shape": 1} {"name": "Josep Carod i Rovira III", "shape": 1} {"name": "Josep Carod i de Rovira", "shape": 1} {"name": "Josep Lluis Carod i III", "shape": 1} {"name": "Josep Lluis Carod i Rovira", "shape": 1} {"name": "Josep Lluis Carod i V", "shape": 1} {"name": "Josep i Rovira", "shape": 1} {"name": "Juan Garcia Lopez y", "shape": 1} {"name": "Juan Garcia Y Lopez", "shape": 1} {"name": "Juan Garcia y Lopez y", "shape": 1} {"name": "Juan Velasquez y Garcia", "shape": 1} {"name": "Juan de la Vega", "shape": 1} {"name": "Juan de y", "shape": 1} {"name": "Juan y Garcia", "shape": 1} {"name": "Lt.Gov. John Doe", "shape": 1} {"name": "Luo Si", "shape": 1} {"name": "MARIA DA SILVA E SOUZA", "shape": 1} {"name": "Maria Kowalska z domu Nowak MA", "shape": 1} {"name": "Md Abdul Karim", "shape": 1} {"name": "Md Abu Sayed", "shape": 1} {"name": "Md Rahman", "shape": 1} {"name": "Min Chi", "shape": 1} {"name": "Minjun KIM", "shape": 1} {"name": "Mr MA", "shape": 1} {"name": "Mr Md Karim Uddin", "shape": 1} {"name": "Nguyen Van Lac", "shape": 1} {"name": "Sir Bob Andrew Dole", "shape": 1} {"name": "Wang M.Eng.", "shape": 1} {"name": "Wang Ma PhD", "shape": 1} {"name": "X.Y.Z. Smith", "shape": 1} {"name": "abdul Smith Jr Ma", "shape": 1} {"name": "anh van do", "shape": 1} {"name": "anh van mc", "shape": 1} {"name": "jack ma", "shape": 1} {"name": "jane doe nee smith ma", "shape": 1} {"name": "john e smith", "shape": 1} {"name": "john smith MA", "shape": 1} {"name": "john smith MEng", "shape": 1} {"name": "john smith meng", "shape": 1} {"name": "john smith x.y.z.", "shape": 1} {"name": "jose e maria santos", "shape": 1} {"name": "josep carod i rovira", "shape": 1} {"name": "juan garcia y lopez", "shape": 1} {"name": "juan y garcia", "shape": 1} {"name": "meng li", "shape": 1} {"name": "nguyen van lac", "shape": 1} {"name": "tran lac", "shape": 1} {"name": "wang meng", "shape": 1} {"name": "ХОСЕ И МАРИЯ САНТОС", "shape": 1} {"name": "Хосе И Мария Сантос", "shape": 1} {"name": "хосе и мария сантос", "shape": 1} {"name": "محمد و علي", "shape": 1} {"name": "Beethoven, Ludwig van", "shape": 2} {"name": "Berg, Jan mc", "shape": 2} {"name": "Berg, Jane van der nee Smith DO", "shape": 2} {"name": "Berg, abdul MA", "shape": 2} {"name": "Berg, abdul ma", "shape": 2} {"name": "Berg, abdul nee Jones MA", "shape": 2} {"name": "Carod i Rovira, Josep", "shape": 2} {"name": "DOE, JOHN MA", "shape": 2} {"name": "DOE, JOHN MA JD", "shape": 2} {"name": "DOE, JOHN MA SMITH", "shape": 2} {"name": "DOE, MARY JO MA", "shape": 2} {"name": "De La Cruz, Ed", "shape": 2} {"name": "De La Cruz, M.J. K.L.", "shape": 2} {"name": "Doe, Dr. John MA", "shape": 2} {"name": "Doe, Dr. MA", "shape": 2} {"name": "Doe, Dr. MA Smith", "shape": 2} {"name": "Doe, Dr. nee Smith MA", "shape": 2} {"name": "Doe, J. MA", "shape": 2} {"name": "Doe, J. ba", "shape": 2} {"name": "Doe, J. nee MA ba", "shape": 2} {"name": "Doe, Jane PhD MEng", "shape": 2} {"name": "Doe, Jane Q. nee Smith MA", "shape": 2} {"name": "Doe, Jane nee Puig i Soler", "shape": 2} {"name": "Doe, Jane nee Smith DO", "shape": 2} {"name": "Doe, Jane nee Smith Do", "shape": 2} {"name": "Doe, Jane nee Smith MA", "shape": 2} {"name": "Doe, Jane nee Smith MA Prof.", "shape": 2} {"name": "Doe, Jane nee Smith MA do", "shape": 2} {"name": "Doe, Jane nee Smith Ma", "shape": 2} {"name": "Doe, Jane nee Smith PhD MEng", "shape": 2} {"name": "Doe, Jane nee Smith do", "shape": 2} {"name": "Doe, Jane nee Smith ma", "shape": 2} {"name": "Doe, Jane nee Smith ma do", "shape": 2} {"name": "Doe, John A.", "shape": 2} {"name": "Doe, John BA", "shape": 2} {"name": "Doe, John DO", "shape": 2} {"name": "Doe, John DO Ed", "shape": 2} {"name": "Doe, John Do", "shape": 2} {"name": "Doe, John Ed", "shape": 2} {"name": "Doe, John MA", "shape": 2} {"name": "Doe, John MA Jr", "shape": 2} {"name": "Doe, John MA Ma", "shape": 2} {"name": "Doe, John MA PhD", "shape": 2} {"name": "Doe, John MA Smith", "shape": 2} {"name": "Doe, John MA do", "shape": 2} {"name": "Doe, John MA y", "shape": 2} {"name": "Doe, John Ma", "shape": 2} {"name": "Doe, John Msc.Ed.", "shape": 2} {"name": "Doe, John PhD MA", "shape": 2} {"name": "Doe, John Q. MA", "shape": 2} {"name": "Doe, John X.Y.Z.", "shape": 2} {"name": "Doe, John ma Ma", "shape": 2} {"name": "Doe, John ma do", "shape": 2} {"name": "Doe, John van DO", "shape": 2} {"name": "Doe, John van Ma", "shape": 2} {"name": "Doe, MA", "shape": 2} {"name": "Doe, MA PhD", "shape": 2} {"name": "Doe, MA Smith", "shape": 2} {"name": "Doe, Mary Jo Ma", "shape": 2} {"name": "Doe, Mr. MA PhD", "shape": 2} {"name": "Jane Doe (nee Smith MA)", "shape": 2} {"name": "Jane Doe (nee Smith Ma)", "shape": 2} {"name": "Jane Doe (nee Smith) MA", "shape": 2} {"name": "Jane Doe nee MA PhD", "shape": 2} {"name": "Jane Doe nee Smith DO DO", "shape": 2} {"name": "Jane Doe nee Smith Ma jd", "shape": 2} {"name": "Jane Doe nee Smith V MA", "shape": 2} {"name": "John Smith, 1.4", "shape": 2} {"name": "John née Jones Smith Ma", "shape": 2} {"name": "Kennedy, John (Jack)", "shape": 2} {"name": "NASCIMENTO, EDSON ARANTES DO", "shape": 2} {"name": "Nascimento, Edson Arantes do", "shape": 2} {"name": "Rovira, Josep Carod i Jr.", "shape": 2} {"name": "Royce, Ed", "shape": 2} {"name": "SMITH, JOHN DO", "shape": 2} {"name": "Salem, Abdul Rahman Ahmed", "shape": 2} {"name": "Smith Jr., A.B.", "shape": 2} {"name": "Smith Jr., John", "shape": 2} {"name": "Smith Jr., MA", "shape": 2} {"name": "Smith nee Jones, Jane MA", "shape": 2} {"name": "Smith, A.B.", "shape": 2} {"name": "Smith, A.B.C.", "shape": 2} {"name": "Smith, Chi", "shape": 2} {"name": "Smith, Dr. John", "shape": 2} {"name": "Smith, E.S.Q.", "shape": 2} {"name": "Smith, John", "shape": 2} {"name": "Smith, John Jr.", "shape": 2} {"name": "Smith, John MEng", "shape": 2} {"name": "Smith, John V, Jr.", "shape": 2} {"name": "Smith, John, Extra, Jr.", "shape": 2} {"name": "Smith, MA", "shape": 2} {"name": "Smith, MEng", "shape": 2} {"name": "Smith, Ma", "shape": 2} {"name": "Smith, Ms Ma", "shape": 2} {"name": "Smith, Ms RA Ma", "shape": 2} {"name": "Smith, PhD MEng", "shape": 2} {"name": "Smith, PhD Ma", "shape": 2} {"name": "Smith, RA PhD Ma", "shape": 2} {"name": "Smith, meng", "shape": 2} {"name": "Uddin, Md Karim", "shape": 2} {"name": "Uddin, Mr Md Karim", "shape": 2} {"name": "Van Buren, Ed", "shape": 2} {"name": "Wang, Si", "shape": 2} {"name": "de la Cruz, Ma", "shape": 2} {"name": "de la Vega, Juan", "shape": 2} {"name": "doe, jane v phd do", "shape": 2} {"name": "doe, john ma", "shape": 2} {"name": "van der Berg, MA", "shape": 2} {"name": "van der Berg, PhD", "shape": 2} {"name": "Davis Royce, Ed", "shape": 3} {"name": "De La Cruz, M.J. PhD", "shape": 3} {"name": "Dr. John P. Doe-Ray, CLU, CFP, LUTC", "shape": 3} {"name": "García Márquez, Ed G.J.", "shape": 3} {"name": "García Márquez, G.J.", "shape": 3} {"name": "García Márquez, G.J.R.", "shape": 3} {"name": "García Márquez, Ms G.J.", "shape": 3} {"name": "García Márquez, PhD G.J.", "shape": 3} {"name": "JOHN SMITH, MA", "shape": 3} {"name": "Jane Doe, MS LAc", "shape": 3} {"name": "John Smith Jr., PhD", "shape": 3} {"name": "John Smith, A.B.", "shape": 3} {"name": "John Smith, Ed", "shape": 3} {"name": "John Smith, Ed Ma", "shape": 3} {"name": "John Smith, MA", "shape": 3} {"name": "John Smith, MD MEng", "shape": 3} {"name": "John Smith, MD, Ma", "shape": 3} {"name": "John Smith, MD, R.A.I.", "shape": 3} {"name": "John Smith, MEng PhD", "shape": 3} {"name": "John Smith, Ms Ma", "shape": 3} {"name": "John Smith, PhD", "shape": 3} {"name": "John Smith, PhD DO DO", "shape": 3} {"name": "John Smith, PhD MEng", "shape": 3} {"name": "John Smith, PhD Ma", "shape": 3} {"name": "John Smith, PhD X.Y.", "shape": 3} {"name": "John Smith, PhD vd DO", "shape": 3} {"name": "John Smith, X.Y. P.Q.", "shape": 3} {"name": "John Smith, X.Y.Z.", "shape": 3} {"name": "John Smith, X.Y.Z. MA", "shape": 3} {"name": "John van Buren, Ed", "shape": 3} {"name": "Ortega y Gasset, Ed", "shape": 3} {"name": "Steven Hardman, MD, DO, DDS", "shape": 3} {"name": "abdul Salam, Ed", "shape": 3} {"name": "john smith, ma", "shape": 3} {"name": "john smith, md ma", "shape": 3} {"name": "john smith, phd meng", "shape": 3} {"name": "John Smith, Dr.", "shape": 4} {"name": "de Mesnil Jean, Dr.", "shape": 4} {"name": "de la Cruz Juan Carlos", "shape": 4} {"name": "de la Cruz née Vega", "shape": 4} {"name": "de la Cruz Juan Carlos", "shape": 5} {"name": "de la Cruz Juan Carlos, Dr.", "shape": 5} {"name": "de la Cruz née Vega", "shape": 5} {"name": "高橋 みなみ", "shape": 6} {"name": "김 민준", "shape": 6} {"name": "김민준", "shape": 6} {"name": "김민준씨", "shape": 6} {"name": "남궁민수", "shape": 6} {"name": "マイケル・ジャクソン", "shape": 7} {"name": "威廉·莎士比亚", "shape": 7} �������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/corpus_wikidata.jsonl��������������������������0000664�0000000�0000000�00000056041�15273032717�0026750�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"Aarón Padilla Gutiérrez" "Abdul Aziz Yassin" "Abdul Razak Hussein" "Adel López Gómez" "Adiel Ascanio Sepúlveda" "Adolfo Anta Seoane" "Adolfo Calzado y Sanjurjo" "Adolfo Ernesto Echeverría Comas" "Agustín Gómez Campos" "Agustín Yáñez y Girona" "Albert Bertomeu Font" "Albert Bonet Marrugat" "Alberto Abello Vives" "Alberto García Ulecia" "Alberto Ortega Martín" "Alexander Parra Uribe" "Alfonso Reyes Echandía" "Amado Aguirre Santiago" "Andrés David Padilla Mejía" "Aniceto Fernández Alonso" "Anna Maria Martínez Sagi" "Antonio Alemany Dezcallar" "Antonio Alvarez Lleras" "Antonio Atienza y Medrano" "Antonio Garrido y Villazán" "Antonio López Aviña" "Antonio Parra Cabrera" "Antonio París Sanz de Santamaría" "Anuar bin Malek" "Anwar Ibrahim" "Arcadio Poveda Ricalde" "Armando Domínguez Borrás" "Arnar Gunnlaugsson" "Arnbjörg Auður Örnólfsdóttir" "Aron Jóhannsson" "Arturo Acevedo Vallarino" "Aurelio García Lesmes" "Azhar Ibrahim" "Beatriz Elena Paredes Rangel" "Benedikt Erlingsson" "Benigno Cunillera Torrent" "Benjamín Castillo Plascencia" "Bernardo Hernández Villaseñor" "Bjarni Björnsson" "Bjarni Böðvarsson" "Bjarni Viðarsson" "Björgvin Gíslason" "Björk Guðmundsdóttir" "Björn Bjarnason" "Björn Þórðarson" "Brynjólfur Bjarnason" "Carles Busquets Barroso" "Carlos Ardila Lülle" "Carlos Arturo Romero Jiménez" "Carlos Coello de Portugal y Pacheco" "Carlos Colorado Vera" "Carlos Encinas González" "Carlos Garcés Córdoba" "Carlos García y Bocanegra" "Carlos Gaviria Díaz" "Carlos Girón Gutiérrez" "Carlos Landín Martínez" "Carlos Lemos Simmonds" "Carlos Martínez Baena" "Carlos Mauricio Duque Pastrana" "Carlos Obregón Borrero" "Carlos Romero Deschamps" "Carlos Sáenz Echeverría" "Carlos Uribe Cordovez" "Catalina Ruiz Pérez" "Cayetano Cardero y Romeo" "Clara Brugada Molina" "Claudi Ametlla i Coll" "Claudio Zupo Gutiérrez" "Consuelo Rodríguez López" "Cosme Algarra y Hurtado" "Crisanto Luque Sánchez" "Cristóbal Bordiú y Góngora" "Crisóforo Mazón Pineda" "Daniel Bautista Rocha" "Daði Freyr Pétursson" "Diego Alberto Tamayo Garcera" "Diego Prado y Colón de Carvajal" "Diego Uribe Vargas" "Dionisio Guillin Mercado" "Dóra Sigurðsson" "Edda Garðarsdóttir" "Eduardo Ferrer Comas" "Eduardo Iglesias Añino" "Eduardo Pallares Portillo" "Eduardo Rodríguez Osorio" "Eduardo Zalamea Borda" "Egill Eyjólfsson" "Eiður Gunnarsson" "Eladio Andino y del Solar" "Elisa Ruiz Romero" "Eliseo Jiménez Ruiz" "Elizabeth Margot Schreiber Castañón" "Elvia Rangel de la Fuente" "Elvira Palafox Herrán" "Elísabet Erlingsdóttir" "Emilia Dorado Baltazar" "Emilio Murillo Chapull" "Engracia Vidal Estévez" "Enrique Gorostieta Velarde" "Enrique Santos Montejo" "Eugeni Aguiló i Pérez" "Eulalio Ríos Alemán" "Eulogio Despujol y Dusay" "Ever Peña Maldonado" "Fabián Pérez Ximeno" "Federico Sánchez Bedoya" "Felip Lorda i Alaiz" "Felipe Benicio Navarro y Reig" "Felipe Bermejo Araujo" "Felipe Colomo Castro" "Felipe José Abárzuza y Oliva" "Felipe Valdés y López" "Feliu Crespí i Cirera" "Fernando Gordillo Escudero" "Fernando Hiriart Balderrama" "Fernando Rosas Solis" "Fernando de Alva Ixtlilxóchitl" "Fernando de Borbón y Medina" "Francesc Arnau i Cortina" "Francesc Todó Garcia" "Francis Anel Bueno Sánchez" "Francisco Díez-Barroso Govantes" "Francisco Freire Barreiro" "Francisco Giraldo Hernández" "Francisco González Macías" "Francisco Hernández Pla" "Francisco Javier Fortea Pérez" "Francisco Javier Lorenzo Oroza" "Francisco Martorell y Peña" "Francisco Martínez de Cenicero" "Francisco Posada Díaz" "Francisco Santos Calderón" "Frederic Bosch i Serra" "Frederic Viñals i Iscla" "Félix Sardá y Salvany" "Gabriel Jaime Santamaría Montoya" "Gabriel Latorre Jaramillo" "Gabriel Ramos Millán" "Gabriela Abad Miró" "Gerður Benediktsdóttir" "Gerónimo Baqueiro Foster" "Gilberto Martínez Arango" "Gilberto Rodríguez Orejuela" "Gregorio Chillón de la Fuente" "Grettir Björnsson" "Guillermo García Cantú" "Guillermo Rousset Banda" "Gunnar Gunnarsson" "Gustavo León González" "Guðbjörg Gunnarsdóttir" "Guðmunda Elíasdóttir" "Guðmundur Eyjólfsson Geirdal" "Guðmundur Jónsson" "Guðmundur Óli Þorláksson" "Guðrún Björnsdóttir" "Guðrún Eva Mínervudóttir" "Guðrún Helgadóttir" "Guðrún Lárusdóttir" "Gylfi Sigurðsson" "Halla Tómasdóttir" "Hallbjörg Bjarnadóttir" "Hansína Regína Björnsdóttir" "Haraldur Sigurðsson" "Haukur Ágústsson" "Hera Hilmarsdóttir" "Hera Hjartardóttir" "Hernando Calvo Ospina" "Hilda Luisa Valdemar Lima" "Horacio Gómez Bolaños" "Héctor Quintana Suárez" "Ignacio Rodríguez Bahena" "Indalecio Soriano Fuertes" "Ingrid Betancourt Pulecio" "Ismail Abdul Rahman" "Iu Alavedra i Ferrer" "Iván Duque Márquez" "Jacinto López Moreno" "Jaime Blanco García" "Jaime García Terrés" "Jaime Mayor Oreja" "Jaume Berenguer i Amenós" "Jaume Martí i Cabot" "Javier López Díaz" "Jesús Alcolea Fernández" "Jesús Alcántara Miranda" "Jesús Gallego Marquina" "Jesús Huguet i Serrano" "Jesús María Echavarría y Aguirre" "Jesús Ángel González Arias" "Joan Canals i Pigem" "Joaquín Arasanz Raso" "Joaquín García Icazbalceta" "Joaquín Guzmán Loera" "Joaquín Odriozola y Grimaud" "Joaquín Torres García" "Joaquín Traggia Uribarri" "Joaquín Vergara Cárdenas" "Jon Irazabal Agirre" "Jordi Castellanos i Vila" "Jordi Cussà Balaguer" "Jorge Ernesto Guzmán Rodríguez" "Jorge González Camarena" "Jorge González Díaz" "Jorge Lozano Márquez" "Jorge Valdez Muñoz" "Josep Campabadal i Calvet" "Josep Ibáñez i Senserrich" "Josep Janés i Olivé" "Josep Maria Tarridas i Barri" "Josep Vergés i Vallmajor" "José Antonio Osorio Lizarazo" "José Antonio Rodríguez Romero" "José Cabrera Gándara" "José Camacho Carrizosa" "José Francos Rodríguez" "José García Ocejo" "José Gil Pérez" "José Gómez-Mena Vila" "José Luis Arroyo Carreras" "José Luis Covarrubias Garza" "José Manuel López de Abiada" "José María Díaz Casariego" "José María Guerra Cobián" "José María Luzón Cuesta" "José María Otaolaurruchi Tobía" "José María Yáñez Carrillo" "José Mataix Monllor" "José Perianes Carro" "José Vilató Ruiz" "Juan Anglada y Ruiz" "Juan Antonio de los Heros Fernández" "Juan Carlos Rodríguez Agudelo" "Juan Cárdenas Arroyo" "Juan Fernández Bazán" "Juan Harguindey Broussain" "Juan Samper Sordo" "Juan Santos Fernández" "Juan Suñé Benages" "Juan Torres de Vera y Aragón" "Juan Zuleta Ferrer" "Juan de Courten y González" "Juan de Dios Sierra y Velásquez" "Julio Mario Santo Domingo Pumarejo" "Julio Roberto Gómez Esguerra" "Julián Pascual Nunell" "Julián Ruiz Martorell" "Justo Novo Verdes" "Jóhann Konráðsson" "Jóhanna Sigurðardóttir" "Jón Helgason" "Jón Páll Sigmarsson" "Jón Sigurðsson" "Jón Steingrímsson" "Jón Árnason" "Jón Þorláksson" "Jónas Jónasson" "Jónína Jónatansdóttir" "Júlíus Agnarsson" "Karl Einarsson" "Katrín Jakobsdóttir" "Katrín Júlíusdóttir" "Ketill Jensson" "Kristinn Hrafnsson" "Kristján Kristjánsson" "Kristján Örn Sigurðsson" "Kristín Ólafsdóttir" "Kári Árnason" "Laura Ángeles Lastra Santos" "Laureano Veres Acevedo" "Leonardo Porto Egea" "Leonor Alinari Adarve" "Libardo López Gómez" "Libardo Mora Toro" "Libardo Simancas Torres" "Lilja Alfreðsdóttir" "Lisandro Leyva Mazuera" "Llorenç Ferrer Martí" "Loftur Guðmundsson" "Luis Díaz Moreno" "Luis Fernando Giraldo Builes" "Luis Gonzaga de Villava y Aybar" "Luis Guzmán Reveles" "Luis Mora Tovar" "Luisa Álvarez Iglesias" "Lárus Pálsson" "Magdalena Calonge i Panyella" "Magnús Jónsson" "Mahathir bin Mohamad" "Manuel Banet Fontenla" "Manuel Blanco Romasanta" "Manuel Fernández Castrillón" "Manuel Jerónimo Yerena y Camarena" "Manuel López González" "Manuel Puga y Acal" "Manuel Serrano Blanco" "Manuel Sánchez Mármol" "Manuel Tello Macías" "Manuel Uruchurtu Ramírez" "Manuel Velasco y Martínez" "Manuela Lema Villar" "Margarita Peña Muñoz" "Margrét Lára Viðarsdóttir" "Mariana Duque Mariño" "Mariano Aguilar Barrera" "Mariano Cuadra Medina" "Martha Nava García" "Martín Villazón Ochoa" "María Losada Estévez" "María Paz Alonso Romero" "María Rubio Tejero" "María de la O Lejárraga y García" "María del Pilar Hurtado Montaño" "Matthías Jochumsson" "Mercedes Díaz Roig" "Miguel García Martínez" "Miguel Lanz Duret" "Miguel Sánchez Pesquera" "Miguel Treviño Morales" "Miguel Ángel Félix Gallardo" "Milton Santacruz Aguilar" "Misael Eduardo Pastrana Borrero" "Mohamed bin Hamzah" "Mohd Najib bin Abdul Razak" "Nanna Bryndís Hilmarsdóttir" "Narcís Monturiol i Estarriol" "Nemesio Camacho Macías" "Nicolás Leoz Almirón" "Octavio Corral Romero" "Orlando Arvizu Lara" "Pablo Emilio Riveros Reina" "Pascual Ortiz Rubio" "Patricia Terrazas Allen" "Patricio Flores Sandoval" "Pedro Cañas Serrano" "Pedro Galindo Galarza" "Pedro Infante Torrentera" "Pedro Jorba y Gassó" "Pedro Morales Pino" "Pere Bosch i Gimpera" "Pere Rebassa Bisquerra" "Pere Sabaté i Curto" "Pierre David Oriola Garriga" "Pilar Fernández Seijo" "Pompilio Gutiérrez Arango" "Rafael Aguayo Spencer" "Rafael Alonso y Prieto" "Rafael Cepeda Torres" "Rafael Rubio Carrión" "Rafael Ruiz Balerdi" "Rafael Viciana Aráez" "Ragnar Bjarnason" "Ramon Ferran i Pagès" "Ramón Arellano Félix" "Ricardo Bodenlle Corral" "Ricardo Legorreta Vilchis" "Roberto Escobar Gaviria" "Robiro Rojas Benavides" "Rodolfo Landeros Gallegos" "Rodrigo Lara Bonilla" "Roger Alier i Aixalà" "Rogerio Azcárraga Madero" "Román García Rodrigo" "Roque González Garza" "Rosa Castillo Santiago" "Rubén Rojo y Pinto" "Róbert Gunnarsson" "Rómulo González Irigoyen" "Rúnar Gunnarsson" "Rúrik Haraldsson" "Salomón Villada Hoyos" "Salvador Aguirre Anguiano" "Salvador Ortiga Torres" "Salvador Rosas Magallón" "Santiago Díaz Moyano" "Sara Björk Gunnarsdóttir" "Saturnino Rodríguez Fernández" "Saúl González Herrera" "Sebastià Salellas i Magret" "Sebastián Lucumí Mina" "Selma Björnsdóttir" "Sergio Marcos González" "Severo Aguirre Miramón" "Sigrún Jónsdóttir" "Sigurdór Sigurdórsson" "Sigurjón Birgir Sigurðsson" "Sigurður Björnsson" "Sigurður Helgason" "Sigurður Ólafsson" "Silverio Cañada Acebal" "Simón Mesa Soto" "Sinibaldo Gutiérrez Mas" "Siv Friðleifsdóttir" "Skapti Ólafsson" "Soffía Karlsdóttir" "Steingrímur Jóhann Sigfússon" "Steinunn Sigurðardóttir" "Sveinn Ásgeirsson" "Sócrates Gómez Pérez" "Sölvi Ottesen" "Teitur Þórðarson" "Teógenes Díaz-Corralejo Fernández" "Tiburcio Pérez Cuervo" "Tomás González Carvajal" "Tomás Iglesias Pérez" "Una Guðmundsdóttir" "Valentín Puga Franco" "Vicente Becerra Vaca" "Vicente Gómez Mier" "Vicente Martín y Soler" "Vicente Rodríguez Casado" "Vigdís Finnbogadóttir" "Vilborg Davíðsdóttir" "Víctor Luna Gómez" "Willington Baena Ortiz" "Xosé María Brea Segade" "Xosé María Álvarez Blázquez" "Yenifer Córdoba Henao" "Yrsa Sigurðardóttir" "Álvaro Londoño Aristizábal" "Álvaro de Brigard Silva" "Ángel Bassols Batalla" "Ángel Llanos González" "Árni Þorláksson" "Ásta Ragnheiður Jóhannesdóttir" "Ívar Ingimarsson" "Ólafur Ragnarsson" "Ólafur Þ. Jónsson" "Óskar Halldórsson" "Ögmundur Jónasson" "Þorsteinn Hannesson" "Þorsteinn Sveinsson" "Þuríður Pálsdóttir" "Þórunn Egilsdóttir" "Абатуров, Константин Иванович" "Агеева, Валентина Васильевна" "Адамович, Михаил Михайлович" "Алевтина Львовна Юрина" "Александр Александрович Кухаренко" "Александр Александрович Фридман" "Александр Алексеевич Моисеев" "Александр Андреевич Клюшников" "Александр Владимирович Кессених" "Александр Вячеславович Жиров" "Александр Гельевич Дугин" "Александр Емельянович Хмель" "Александр Иванович Алексеев" "Александр Иванович Булгаков" "Александр Иванович Черепанов" "Александр Леонидович Дворкин" "Александр Николаевич Степанов" "Александр Петрович Сумароков" "Александр Семёнович Будилович" "Александров, Николай Григорьевич" "Алексеев, Михаил Николаевич" "Алексей Алексеевич Никитин" "Алексей Борисович Елисеев" "Алексей Валерьевич Серебряков" "Алексей Васильевич Богдан" "Алексей Вячеславович Панин" "Алексей Иванович Давыдов" "Алексей Иванович Савельев" "Алексей Николаевич Плещеев" "Алексей Семёнович Сечкин" "Анатолий Александрович Вассерман" "Анатолий Николаевич Алябьев" "Андрей Васильевич Мягков" "Андрей Сергеевич Кончаловский" "Андрій Володимирович Портнов" "Анна Николаевна Гладкова" "Анна Сергеевна Курникова" "Антипов, Павел Фёдорович" "Антон Дмитриевич Волошин" "Артур Валерьевич Дмитриев" "Асаф Михайлович Мессерер" "Богданов, Владимир Алексеевич" "Богомолов, Владимир Николаевич" "Бойцов, Владимир Емельянович" "Бондарев, Николай Автономович" "Борис Алексеевич Зозуля" "Борис Владимирович Полянский" "Борис Максимович Келлер" "Борис Николаевич Ельцин" "Бычков, Пётр Николаевич" {"name": "Вагнер Николай Петрович", "shape": 4} "Вадим Владимирович Наумов" "Вадим Евгеньевич Бойко" "Валентин Васильевич Васенков" "Валерий Сергеевич Ольховский" "Валерий Фёдорович Залужный" "Виктор Васильевич Ляпунов" "Виктор Владимирович Глухов" "Виктор Иванович Туровцев" "Виктор Михайлович Иванцов" "Виссарион Михайлович Павлов" "Вишняков, Павел Терентьевич" "Владимир Александрович Усов" "Владимир Афанасьевич Фатеев" "Владимир Валерьевич Буре" "Владимир Владимирович Кара-Мурза" "Владимир Владимирович Путин" "Владимир Евграфович Татлин" "Владимир Емельянович Бойцов" "Владимир Михайлович Штейн" "Владимир Сергеевич Шкляев" "Владислав Иванович Мамонтов" "Владислав Юрьевич Сурков" "Воробьёв, Константин Дмитриевич" "Воронцов, Александр Емельянович" "Вронский, Юрий Петрович" "Геннадий Вячеславович Игумнов" "Геннадий Германович Комиссаров" "Геннадий Иванович Воронов" "Георгий Ефремович Катанаев" "Гнилицкая, Нина Тимофеевна" "Гойщик, Татьяна Геннадьевна" "Грипич, Александр Сергеевич" "Гриценко, Анатолий Иванович" "Дарья Александровна Дугина" "Дарья Алексеевна Навальная" "Демидов, Иван Алексеевич" "Дитятин, Иван Иванович" {"name": "Дмитриев Владимир Михайлович", "shape": 4} "Дмитриев, Артур Валерьевич" "Дмитро Олександрович Корчинський" "Дятлов, Владимир Семёнович" "Евгений Александрович Евтушенко" "Евгений Викторович Осин" "Евгений Романович Устюгов" "Евгений Францевич Бауэр" "Евдокия Андреевна Никулина" "Евтеев, Николай Петрович" "Егор Николаевич Булаткин" "Егор Николаевич Свиридов" "Елена Вячеславовна Дементьева" "Елена Игоревна Лядова" "Жанна Трофимовна Прохоренко" "Жилинский, Алексей Александрович" "Жиров, Александр Вячеславович" "Журавлёва, Людмила Васильевна" "Завадовский, Михаил Михайлович" "Залман Иосифович Якуб" "Зорин, Михаил Петрович" "Иван Александрович Гончаров" "Иван Андреевич Созонов" "Иван Гугович Закс" "Иван Савельевич Фатьянов" "Иваницкий, Николай Иванович" "Иванов, Артемий Васильевич" "Игорь Александрович Нетто" "Игорь Дмитриевич Серебряков" "Илья Георгиевич Коробков" "Иосиф Владимирович Гурко" "Кирилл Афанасьевич Мерецков" "Кислов, Александр Степанович" "Козловский, Фёдор Алексеевич" "Колкутин, Андрей Борисович" {"name": "Комиссаров Геннадий Германович", "shape": 4} "Константин Михайлович Попов" "Коробков, Илья Георгиевич" "Корягин, Дмитрий Александрович" "Костин, Иван Алексеевич" "Ксения Сергеевна Александрова" "Кузнецов, Александр Петрович" "Кузнецов, Евгений Николаевич" "Кухаренко, Александр Александрович" "Лев Семёнович Рубинштейн" "Левандовский, Анатолий Петрович" "Леонид Иванович Иванов" "Леонид Эдуардович Слуцкий" "Липицкий, Анатолий Григорьевич" "Любовь Митрофановна Шуляк" "Мария Валерьевна Мазина" "Матвеев, Герман Иванович" "Медведев, Николай Дмитриевич" "Михаил Дмитриевич Чулков" "Михаил Михайлович Зощенко" "Михаил Степанович Полунин" "Моисеев, Александр Алексеевич" "Насонов, Николай Викторович" "Наталья Арсентьевна Якубович" "Наумов, Вадим Владимирович" "Наумов, Михаил Иванович" "Никита Сергеевич Хрущёв" "Никитин, Алексей Алексеевич" "Николай Александрович Боярский" "Николай Александрович Энгельгардт" "Николай Андреевич Рыбин" "Николай Андреевич Смирнов" "Николай Васильевич Владыкин" "Николай Георгиевич Зырин" "Николай Евгеньевич Кабанов" "Николай Иванович Хохлов" "Николай Николаевич Пикус" "Николай Петрович Вагнер" "Николай Порфирьевич Архангельский" "Николай Степанович Гумилёв" "Никулина, Евдокия Андреевна" "Нина Александровна Андреева" "Нина Михайловна Литвинова" "Оксана Сергіївна Баюл" "Олимпиада Ивановна Шмидт" "Ольга Игоревна Кабо" "Ольховский, Валерий Сергеевич" "Павел Алексеевич Рубцов" "Павел Владимирович Сорокин" "Павел Ильич Таланкин" "Павел Михайлович Волков" "Павел Феодосьевич Кальницкий" "Павел Яковлевич Любимов" "Панин, Алексей Вячеславович" "Пелагея Степановна Генералова" "Повалишин, Владимир Михайлович" "Полянский, Борис Владимирович" "Попов, Андрей Александрович" "Попов, Василий Петрович" "Преображенский, Василий Алексеевич" "Пётр Васильевич Быков" "Пётр Иванович Почивалов" "Пётр Николаевич Краснов" "Пётр Олегович Толстой" "Пётр Петрович Буренин" "Рагозин, Евгений Иванович" "Разумовский, Юрий Георгиевич" "Расницын, Александр Павлович" "Розенфельд, Михаил Константинович" "Рубин, Павел Германович" "Рубинштейн, Лев Семёнович" "Рыбаков, Ярослав Владимирович" "Рязанов, Евгений Фёдорович" "Саблин, Александр Алексеевич" "Саблин, Валерий Михайлович" "Самуил Хаимович Агурский" "Сафонов, Эрнст Иванович" "Семён Денисович Игнатьев" "Сергей Александрович Кирдяпкин" "Сергей Дмитриевич Остапов" "Сергей Михайлович Эйзенштейн" "Слуцкий, Леонид Эдуардович" "Смыслов, Василий Васильевич" "Сольский, Семён Мартынович" "Старовойтов, Андрей Васильевич" "Сёмин, Виталий Николаевич" "Татьяна Николаевна Семенова" "Ульяна Петровна Буянова" "Фаусек, Юлия Ивановна" {"name": "Федоренко Сергей Васильевич", "shape": 4} "Федоренко, Сергей Васильевич" "Федот Васильевич Сычков" "Фоменков, Валерий Иванович" {"name": "Френкель Георгий Львович", "shape": 4} "Химченко, Виктор Иванович" "Хржановский, Андрей Юрьевич" "Червенков, Николай Николаевич" "Черепанов, Александр Иванович" "Чернышёв, Пётр Сергеевич" "Шевчик, Дмитрий Владимирович" "Шиллер, Николай Николаевич" "Юрий Борисович Хржановский" "Юрий Георгиевич Разумовский" "Юрий Иванович Смирнов" "Юрий Ильич Скуратов" "Юрий Петрович Любимов" �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/corpus_wikidata_native.jsonl�������������������0000664�0000000�0000000�00000066362�15273032717�0030325�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"Αγγελική Ελευθερίου" "Αθανάσιος Δροσόπουλος" "Αλέξανδρος Παπαδημητρίου" "Αναστάσιος Σταμούλης" "Ανδρέας Καραντώνης" "Αντίοχος Ευαγγελάτος" "Αντώνης Σαμαράς" "Βασίλειος Κόκκινος" "Βασίλειος Ξυδάς" "Βασίλης Καραπατάκης" "Βασίλης Χατζηπαναγής" "Βούλα Ζουμπουλάκη" "Γεώργιος Αντωνόπουλος" "Γεώργιος Δρόσος" "Γεώργιος Μητσόπουλος" "Γεώργιος Παπαδιαμαντόπουλος" "Γιάννης Γκούμας" "Γιάννης Θεοδωράκης" "Γιάννης Κανάκης" "Γιώργος Παπαδάκης" "Δημήτρης Καπράλος" "Δημήτρης Μητρόπουλος" "Δημήτρης Μιχαηλίδης" "Δημήτριος Ιωαννίδης" "Επαμεινώνδας Κριεζής" "Θάνος Κωτσόπουλος" "Θανάσης Παπαδόπουλος" "Ιωάννης Μεταξάς" "Ιωάννης Παπαϊωάννου" "Ιωάννης Πετρίδης" "Κωνσταντίνος Γιαννίτσης" "Κωνσταντίνος Θεοτόκης" "Κωνσταντίνος Πυλαρινός" "Μάνος Σταλάκης" "Μαρίκα Τζιραλίδου" "Νίκος Γαλανός" "Νίκος Γούναρης" "Νίκος Καράμπελας" "Νίκος Κούρκουλος" "Νίκος Νικολαΐδης" "Νίκος Σκυλακάκης" "Νικόλαος Γεωργιάδης" "Νικόλαος Κοντόπουλος" "Νικόλαος Τρικούπης" "Νικόλαος Φράγκος" "Ντόρα Λελούδα" "Πέτρος Παυλίδης" "Παναγιώτης Καραλής" "Παντελής Πετρακάκης" "Παύλος Ιωάννου" "Στέφανος Λάντσας" "Σταύρος Γεωργίου" "Σταύρος Κουγιουμτζής" "Σταύρος Κωστόπουλος" "Σταύρος Τσιώλης" "Στυλιανός Λυκούδης" "Τάκης Κανελλόπουλος" "Φαίδων Θεοφίλου" "Χαράλαμπος Σιμόπουλος" "Χρήστος Αντωνόπουλος" "Євген Гуцало" "Євгенія Казбекова" "Ігор Рибак" "Јелена Беговић" "Јован Мијатовић" "Јован Шајновић" "Љубомир Јовановић" "Александар Антић" "Александар Пантић" "Александар Станковић" "Александр Кучма" "Александър Александров" "Александър Марков" "Александър Цветков" "Ана Дабовић" "Анастас Јовановић" "Ангел Андреев" "Ангел Кънчев" "Андрија Милошевић" "Андрій Володимирович Бондар" "Андрій Володимирович Портнов" "Антон Каблешков" "Анђелија Арбутина" "Анђелија Веснић" "Балдырған Қожамқұлова" "Бақытжан Қанапиянов" "Бисерка Цвејић" "Биљана Плавшић" "Бобан Марковић" "Богдан Несторовић" "Борис Галчев" "Бошко Јанковић" "Бранислав Јовановић" "Бранислав Блажић" "Бранко Борковић" "Валерій Залужний" "Васил Стоилов" "Васил Янакиев" "Велизар Димитров" "Велко Дачев" "Вероніка Марченко" "Виктор Георгиев" "Владимир Вазов" "Владимир Седов" "Володимир Максименко" "Вук Јеремић" "Вук Драшковић" "Віктор Пинзеник" "Галина Савова" "Георги Богданов" "Георги Боков" "Георги Георгиев" "Георги Живков" "Герман Германов" "Данка Илић" "Денис Попов" "Дејан Бодирога" "Дејан Миловановић" "Дејан Стојановић" "Димитрије Нешић" "Динко Цветков Дерменджиев" "Дмитро Олександрович Корчинський" "Драган Стојковић" "Драган Томић" "Драгомир Јовановић" "Драгомир Милошевић" "Душан Милошевић" "Дәулет Болатұлы Тұрлыханов" "Едуард Михайлович Москальов" "Желязко Ангелов" "Здравко Палазов" "Иван Андонов" "Иван Катранов" "Иван Коцев" "Иван Радовановић" "Иван Радулов" "Игор Матић" "Илија Петковић" "Йордан Мутафчиев" "Камен Петков" "Карина Горичева" "Константин Аврамов" "Константин Константинов" "Константин Марков" "Коста Алексић" "Коста Михаиловић" "Красимир Спасов" "Крум Дерменджиев" {"name": "Кучма Леонід Данилович", "shape": 4} {"name": "Лукань Володимир", "shape": 4} {"name": "Луньов Святослав Ігорович", "shape": 4} "Любомир Кръстанов" "Марія Панчишин" "Микола Козленко" "Милко Бјелица" "Миодраг Павловић" "Мирослав Радовановић" "Мија Јовановић" {"name": "Мовчан Борис Олексійович", "shape": 4} "Найден Тодоров" "Небојша Стефановић" "Невен Суботић" "Неда Украден" "Немања Максимовић" "Немања Милетић" {"name": "Немелівський Михайло", "shape": 4} "Нено Неделчев" "Никола Мараш" "Никола Славков" "Николай Кашев" "Николай Михайлов" "Оксана Сергіївна Баюл" "Олег Литвиненко" "Олег Сакиркин" "Олександр Музичко" "Олександр Положинський" "Олексій Олексійович Гончаренко" "Ольга Рыпакова" "Павел Попов" "Пеньо Велков" "Петар Јовичић" "Петко Петков" {"name": "Полунін Михайло Степанович", "shape": 4} "Радован Панков" "Ранко Поповић" "Румяна Бъчварова" "Свобода Бъчварова" "Сергей Каримов" {"name": "Середа Григорій Кузьмич", "shape": 4} "Симона Пейчева" "Синиша Михајловић" "Соња Петровић" "Срђан Новаковић" "Станіслав Станкевич" "Стеван Плетикосић" "Стеван Хорват" {"name": "Степаненко Василиса Іллівна", "shape": 4} "Стефан Огнянов" "Стефан Петров" "Стефка Георгиева" "Стоян Александров" "Стоян Мурин" "Страхил Попов" "Страшимир Димитров" "Татьяна Васильевна Костина" "Теофил Панчић" "Тина Крајишник" "Тијана Богићевић" "Төле Кенжебаев" "Төлеу Шаханов" "Урош Рачић" {"name": "Федоров Іван Сергійович", "shape": 4} "Филип Ђуричић" "Христо Боев" "Христо Бончев" "Христо Далчев" "Христо Койчев" "Христо Миладинов" {"name": "Царук Олександр Васильович", "shape": 4} "Юлия Кръстева" "Юлія Волчкова" "Янко Павлов" "Қайрат Болатұлы Тұрлыханов" "Әбіш Кекілбайұлы" "Ալբերտ Հովհաննիսյան" "Ալեքսանդր Երիցյան" "Ալեքսանդր Թումասյան" "Ալեքսանդր Մանասյան" "Անդրանիկ Հարությունյան" "Անդրանիկ Ղուկասյան" "Անի Խաչիկյան" "Անուշավան Ազատյան" "Աշոտ Պետրոսյան" "Արա Աբրահամյան" "Արամ Սարգսյան" "Արամ Քոչարյան" "Արայիկ Մաթևոսյան" "Արթուր Բաղդասարյան" "Արթուր Մկրտչյան" "Գառնիկ Հովհաննիսյան" "Գոհար Գասպարյան" "Գրիգոր Բրուտյան" "Դավիթ Համբարձումյան" "Դերենիկ Սաֆարյան" "Երվանդ Սուքիասյան" "Զառա Տոնիկյան" "Էդուարդ Մնացականյան" "Էրիկ Սահակյան" "Լենա Նազարյան" "Լորիս Ճգնավորյան" "Լևոն Աղասյան" "Կոնստանտին Օրբելյան" "Հայկ Հակոբյան" "Հայկ Ղազարյան" "Հենրիկ Ազատյան" "Հրանտ Թովմասյան" "Մամիկոն Աքուլյան" "Մանուել Պետրոսյան" "Մարիա Հակոբյան" "Մարտին Սուքիասյան" "Միքայել Հարությունյան" "Յուրի Դավթյան" "Յուրի Հայրապետյան" "Նորայր Ասլանյան" "Նունե Սարգսյան" "Ռաֆիկ Եղոյան" "Ռոբերտ Արզումանյան" "Ռոբերտ Հովհաննիսյան" "Ռոմեն Դավթյան" "Ռուբեն Վարդանյան" "Սերգեյ Մովսեսյան" "Սերգեյ Ներսիսյան" "Սուրեն Բաբայան" "Սուրեն Երեմյան" "Սուրեն Ծատուրյան" "Սուրեն Նազարյան" "Ստեփան Նալբանդյան" "Վազգեն Մանուկյան" "Վահագն Խաչատրյան" "Վարդան Ստեփանյան" "Վլադիմիր Դարբինյան" "Տիգրան Նալբանդյան" "Ֆելիքս Բախչինյան" "אבנר ברזני" "אברהם אדן" "אברהם כץ" "אברהם מור" "אדם מזור" "אהרן הלוי" "אהרן מונדשיין" "אהרן מסקין" "איתי שכטר" "אלישבע מיכאלי" "אלישע אפרת" "אלישע פורת" "אמה תלמי" "אמיר דרורי" "אסתר אלכסנדר" "אריק איינשטיין" "אשר דורי" "בלה קאופמן" "ברוך אורן" "ברוך מודן" "ברכה רוזנפלד" "גיא וסרדם" "דבורה עומר" "דוד בן־גוריון" "דן מורי" "דניאל פאר" "ורה אלכסנדר" "זאב מרגלית" "חיים מולכו" "חנה אייכינגרין" "ידידיה כהן" "יהודה כהן" "יצחק טישלר" "יצחק יעקב" "יצחק רבין" "ישעיהו פרס" "לולה פולמן" "לייב וייספיש" "מאיר ישראל" "מיכאל אלמוג" "מישאל חשין" "מנחם נאור" "מנשה רז" "משה בן-שאול" "משה חלוץ" "משה מזור" "עמוס ברנס" "עמוס הדר" "עמרי שרון" "צבי ארד" "רון ארד" "רון פלר" "רחל שור" "שולה ליטן" "שלמה תמיר" "שמואל דביר" "ששון סומך" "תומר שחורי" "תמיר כהן" "תמרה כהן" "آمال فهمي" "أبو بكر الشاطري" "أبو بكر سالم" "أحلام الجريتلي" "أحمد حجازي" "أحمد سالم" "أحمد قنديل" "أحمد مصطفى" "أصالة نصري" "أكرم التلاوي" "أكرم عجة" "ألكسندر دارسي" "أمجد الطرابلسي" "أمين دابو" "أمين سامي" "أمينة رشيد" "أنطوانيت نجيب" "أنطونيوس نجيب" "أيمن الدُّقر" "إبراهيم البغدادي" "إبراهيم الغلاييني" "إبراهيم صالح" "إحسان الرفاعي" "إلهام حسين" "إلياس الزيَّات" "إيهاب خورشيد" "احمد گلشیری" "اختر رضا خان" "اقبال قریشی" "انو آغا" "انیتا اگنی ہوتری" "بهروز بوچانی" "بهمن جلالی" "بهيج الخطيب" "جلال همتی" "جمال محمد" "جميل عز الدين" "جورج إسحاق" "جورج ميشيل" "جوزف پال" "جيانيس كريستو" "جيهان السادات" "جیسی پال" "حاتم علي" "حازم هاشم" "حسن دانشفر" "حسن شحاتة" "حسن عابدی" "حسن ناصر" "حسني سبح" "حسين خطاب" "حسین امینی" "حسین حسینی" "حكمت هاشم" "حليم بركات" "حمدان شلبي" "حمدي الحريري" "حميدة الطاهر" "حنیف خان" "خالد عزيز" "خضر الأحمد" "دلیپ کور ٹوانہ" "رائد المتني" "رامي مخلوف" "رجاء يوسف" "رشاد رشدي" "رمضان ديب" "رنا أحمد" "ريما الجفالي" "رینوکا شرما" "زكي مبارك" "زكية إبراهيم" "سارہ خان" "سامي الكيالي" "ستیش شرما" "سحر كامل" "سعدیہ خان" "سعدیہ دہلوی" "سعيد حماد" "سلام امینی" "سلطان البرقان" "سليمان الجوخدار" "سمر عبد العزيز" "سمير سرور" "سميرة خاشقجي" "سميرة عزت" "سهيل الغزي" "سوسن ربيع" "سيد كريم" "سیروس قهرمانی" "سیروس گرجستانی" "شاہدہ رضا" "شاہین رضا" "شبیر اختر" "شهره آغداشلو" "شيماء الصباغ" "صالح اسدی" "صالح اللحيدان" "صالح خالد الشهري" "صلاح المُولَّد" "صنع الله إبراهيم" "طارق سلامة" "طارق عبد العزيز" "طارق فتح" "طاہر داوڑ" "طلعت زكريا" "عائشة ذو الغنى" "عادل التويجري" "عارف علوی" "عارف وزیر" "عالي القرشي" "عباس شفیعی" "عباس وفایی" "عباس کریمی" "عبد الأكرم السقَّا" "عبد الإله نبهان" "عبد الباسط بدر" "عبد الجبار الضحاك" "عبد الحليم قدور" "عبد الرحمن السديس" "عبد الرحمن العجلان" "عبد الرحيم منصور" "عبد الستار إبراهيم" "عبد العزيز الربيعي" "عبد القدوس أبو صالح" "عبد اللطيف عبد الحميد" "عبد الله الحقيل" "عبد الهادي الباني" "عبد الوكيل الهاشمي" "عثمان آل حميد" "عدنان بركات" "عدنان خاشقجي" "عدنان مصطفى" "عرفان خان" "عرفان صدیقی" "عزت‌الله انتظامی" "علي الرواس" "علي القرني" "علی حاتمی" "علیرضا بیرانوند" "علی‌اصغر زارعی" "علی‌اکبر حیدری" "عمران خان" "غادة السمان" "غسان جبري" "غلام اللہ خان" "فائق عرقسُوسي" "فاروق خورشيد" "فاروق عطية" "فايزة حسين" "فتحي قورة" "فتح‌الله بنی‌صدر" "فرح خان" "فريد الأطرش" "فریدون ابراهیمی" "لطفي لبيب" "لويس جريس" "مازن المبارك" "مانی شرما" "محمد البغدادي" "محمد الزعبي" "محمد خاتمی" "محمد دهمان" "محمد سليم الحلواني" "محمد سليمان" "محمد ضياء الدين الصابوني" "محمد عطا" "محمد نبيه المسيري" "محمد کوثری" "محمدتقی شریعتی" "محمدعلی حفیظی" "محمدعلی ییلاقی اشرفی" "محمود الزعبي" "محمود جركس" "محمود شاكر" "محمود فروغی" "مريم صالح" "مصطفى خليل" "منال الشريف" "مها عامر" "موفق الخاني" "مُحمَّد علي محجوب" "مُوفَّق دعبول" "نادية زغلول" "نادیہ خان" "ناصر الأنصاري" "ناہید اختر" "نبيل درويش" "نبيل فهمي" "نبيهة حداد" "نسرين طافش" "نعيم عيسى" "نهلة القدسي" "نور الدين عتر" "نورس عبد الواحد" "هالة شكر الله" "هدى شعراوي" "هدى عابدين" "هشام حافظ" "هشام سليم" "هشام عباس" "هشام فؤاد" "وداد أنطوان" "وديع بشور" "وليد الشهري" "يوسف إدريس" "يوسف السالم" "يوسف مكي" "پردیس افکاری" "کمال ایرانی" "کیوان وحدانی" "ہاشم خان" "अंजलि देवी" "अंजलि शर्मा" "अंबिका सोनी" "अक्षय कुमार" "अखिल मिश्रा" "अजित पवार" "अण्णा जोशी" "अदिति शर्मा" "अनिल कपूर" "अनिल जोशी" "अनिल मेहता" "अनिल शर्मा" "अनुपम खेर" "अनुराधा आचार्य" "अनुराधा कोइराला" "अनुराधा भट्टाचार्य" "अभिषेक कपूर" "अमर सिंह" "अमित कुमार" "अरुण कौल" "अरुणा ईरानी" "अर्जुन कपूर" "उत्तम कुँवर" "ऐश्वर्या राय" "किरण देसाई" "किशोर कुमार" "कुणाल कपूर" "गणेश आचार्य" "गिरिजा देवी" "गीता उपाध्याय" "ज्ञानेन्द्र वीर विक्रम शाहदेव" "दीपा मेहता" "दीप्ति भटनागर" "दुर्गाबाई देशमुख" "देवकी पंडित" "नम्रता श्रेष्ठ" "निधि राज़दान" "निधि सिंह" "निरूपा रॉय" "निशा अधिकारी" "नीरज चोपडा" "नीरव मोदी" "पंकज त्रिपाठी" "प्रकाश जावडेकर" "प्रतिभा सिंह" "प्रवीण कुमार" "प्राची देसाई" "प्राची शाह" "प्रिया बापट" "प्रीत भरारा" "प्रीति झंगियानी" "बीना देवी" "भानुभक्त आचार्य" "भारत भूषण" "मनीष मल्होत्रा" "ममता कुलकर्णी" "मानव कौल" "मीना शाह" "मुकेश अंबानी" "मेरी रॉय" "मोहित रैना" "मोहित शर्मा" "यशपाल शर्मा" "रजत कपूर" "रणबीर कपूर" "रमेश सक्सेना" "रवि राय" "रवि शंकर" "राजेश कुमार" "राजेश खन्ना" "राजेश शर्मा" "राजेश हमाल" "राम कपूर" "राम सुतार" {"name": "राय लक्ष्मी", "shape": 4} "राहुल गाँधी" {"name": "राहुल बजाज", "shape": 4} "राहुल रॉय" "राहुल शर्मा" "रितेश देशमुख" "रेणुका सिंह" "रोहित शर्मा" "लक्ष्मी गौतम" "लक्ष्मीप्रसाद देवकोटा" "लता मंगेशकर" "वसंत देसाई" "वसंत बापट" "विजय मल्ल" "शरद कपूर" "शरद जोशी" "शरद पवार" "शरद मल्होत्रा" "शांता कुमार" "शिवांगी जोशी" "संजय ख़ान" "सुनंदा पुष्कर" "सुशील कोइराला" "सोनिया गाँधी" "सौंदर्या शर्मा" "स्मिता बंसल" "অজিত পাণ্ডে" "অজিত রায়" "অনিল দে" "অনুষ্কা শর্মা" "অবন্তিকা মিশ্র" "অরুণ মিত্র" "অসীম বসু" "আইভি রহমান" "আকরাম খান" "আনোয়ার হোসেন" "আবদুল করিম শাহ" "উদয় চোপড়া" "কামাল হোসেন" "কেনেথ পাওয়েল" "জিমি জর্জ" "জিয়াউর রহমান" "জুহি চাওলা" "দেবী শর্মা" "নাহিদ ইসলাম" "নিখিল আডবাণী" "নেহা শর্মা" "পঙ্কজ মিশ্র" "পঙ্কজ রায়" "পুরুষোত্তম লাল" "প্রকাশ মেহরা" "ফারহান আখতার" "মনিকা আলী" "মনোজ মিত্র" "মিমি চক্রবর্তী" "রবিন ঘোষ" "রাণী হামিদ" "রেণুকা দাশগুপ্ত" "সলিল সেন" "সুজাতা মহাপাত্র" "সুনীল কর্মকার" "সুবোধ সরকার" "সুভাষ মুখোপাধ্যায়" "সুলতান মাহমুদ" "সেলিনা হোসেন" "হেলেনা খান" "กุลวุฒิ วิทิตศานต์" "ชวเลิศ ชุ่มคำ" "ชัชวาลย์ แท่งทอง" "ทรัพย์สิรี แต้รัตนชัย" "บุญศักดิ์ พลสนะ" "บุศนันทน์ อึ๊งบำรุงพันธุ์" "ประวัติ ปัตตพงศ์" "พรทิพย์ บูรณะประเสริฐสุข" "เกรียงศักดิ์ โกวิทวาณิช" "เดชาพล พัววรานุเคราะห์" "აბელ მამარდაშვილი" "ალექსანდრე იაშვილი" "ალექსანდრე ციბაძე" "ალექსი მახარაშვილი" "ანასტასია ვირსალაძე" "ბადრი თოიძე" "გია გულუა" "გიგა ჭიკაძე" "გივი წულაია" "გიორგი ბაგრატიონი" "გიორგი გონჯილაშვილი" "გიორგი სხირტლაძე" "გიორგი ჩახავა" "გიორგი ჭოღოშვილი" "გურამ მეტონიძე" "გურანდა ღვალაძე" "დავით ბააზოვი" "დავით გოცირიძე" "ელგუჯა ღონღაძე" "ვაჟა დადიანიძე" "ვახტანგ აბაშიძე" "ვახტანგ ჯაფარიძე" "ვლადიმერ ანთაძე" "ვლადიმერ უზნაძე" "ზაალ სამადაშვილი" "თამაზ კვაჭანტირაძე" "თამაზ წივწივაძე" "თეიმურაზ გორდელაძე" "თეიმურაზ კვირკველია" "თემურ აბულაშვილი" "ილია სულამანიძე" "ირაკლი ელიავა" "ირაკლი მწითური" "კახა ბენდუქიძე" "კახა კალაძე" "ლევან გაჩეჩილაძე" "ლევან ინწკირველი" "ლევან კაჭარავა" "ლევან მაღნარაძე" "ლევან ყენია" "ლეო შერვაშიძე" "მამუკა ზაალიშვილი" "მერაბ დვალიშვილი" "მიხეილ ქორქია" "მიხეილ ჯოჯუა" "მუხრან ვახტანგაძე" "ნელი მგალობლიშვილი" "ნიკო ფიროსმანი" "ნიკოლოზ ბასილაშვილი" "ნიკოლოზ სანიშვილი" "ნოდარ ღვალაძე" "ნოდარ ცინცაძე" "ნოდარ ჯობავა" "პაატა კოღუაშვილი" "რევაზ ადამია" "სერგო რიგვავა" "სოსო ლიპარტელიანი" "სოსო პაიჭაძე" "ტარიელ ჭიჭინაძე" "ხვიჩა კვარაცხელია" "三橋淳" "上山輝一" "中内力" "中島 徹" "中川龍一" "亀井三郎" "伊豆田莉奈" "佐々木 勇人" "佐々木謙一郎" "何炅" "何瑞麟" "内田誠" "内藤博文" "刘婉婷" "刘放" "刘旺" "刘相蓉" "刘维" "前田秀樹" "北野誠" "华楠" "卫惠林" "吉尾弘" "吉川亨" "周美玲" "唐由之" "坂本 龍馬" "城戸 四郎" "堀内 克明" "夏萍" "多田大介" "大沢衛" "太田三郎" "姚明" "孙悦" "孟建柱" "安倍なつみ" "安達禎" "宋楠" "小杉 啓太" "小林宏" "小林由佳" "小柳俊一" "小清水 亜美" "岡正雄" "崔晓桐" "平賀淳" "引地良一" "张翰" "張瑞" "張韶涵" "彭孟緝" "揖斐章" "文强" "方豪" "木内 信夫" "朱世英" "朱旭" "朱霖" "朴璐美" "李俊賢" "李博" "李宗文" "李影" "李正中" "李瞻" "李翔" "李诗沣" "村田喜一" "杨扬" "杨旭" "東郷文彦" "林 力" "林國華" "柯洁" "柴山景綱" "桃田賢斗" "桜井昌司" "森卯一" "汪洋" "汪顺" "沈鵬" "河合亨" "渋谷健一" "湯浅浩" "牧野虎雄" "王鑫" "王镇" "王雪莹" "白文正" "石智勇" "秦彦三郎" "端山 忠左衛門" "米川信夫" "罗建文" "罗隆基" "胡英" "茅祖權" "草刈潤" "荒木茂" "葉克強" "葉陶" "蔡国强" "蔡桂英" "蔡金生" "蔣經國" "藤井旭" "藤田信雄" "蘇貞昌" "詹桓" "諫山博" "謝暉" "谢文清" "趙小蘭" "近藤平三郎" "遠藤晴" "邓志伟" "邓逸凡" "邵毓麟" "郑爽" "郭丹" "郭靜" "鈴木亀蔵" "鎌田 弥彦" "陈凯歌" "陈静" "陳俊傑" "陳婷" "陳明忠" "韩光" "韩旭" "韩红" "馮定國" "駒井玲子" "高橋章" "高田栄二" "魏曦" "黄乃" "黄浩" "黎智英" "강명순" "강혜원" "고 상돈" "고기봉" "고요한" "공옥진" "권재진" "권중동" "김경애" "김계원" "김기영" "김병관" "김상철" "김선영" "김성혜" "김성훈" "김소은" "김약수" "김연경" "김영환" "김용완" "김응천" "김장섭" "김재구" "김정은" "김종삼" "김지수" "김한민" "나익진" "남다름" "노유상" "도민호" "류효영" "민윤기" "박경애" "박경원" "박만원" "박민지" "박성수" "박성우" "박시후" "박정희" "박주영" "박지현" "박진구" "박진영" "박형진" "방응모" "설기현" "손태곤" "손현주" "송대남" "신경림" "신도현" "신용훈" "신춘호" "신효순" "아키야마 요시히로" "오정숙" "유명희" "유영국" "육영수" "육인수" "육지수" "윤아정" "이강훈" "이광노" "이만희" "이병기" "이병훈" "이보나" "이상득" "이상익" "이소연" "이소희" "이유빈" "이정희" "이진주" "이필모" "이현만" "이호왕" "이희완" "임 대규" "임정은" "장명한" "장우현" "장원영" "장정곤" "장효희" "전재규" "정동권" "정석모" "정세균" "정순택" "정호연" "조병득" "조상현" "조세희" "조형부" "주정균" "차태현" "천경자" "천병규" "최국현" "최두환" "최상옥" "최영근" "최종덕" "최희서" "추준석" "하예정" "한성숙" "한순철" "한지성" "허성태" "허정구" "홍남표" "홍순옥" "황윤호" ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/gnd_LICENSE������������������������������������0000664�0000000�0000000�00000001376�15273032717�0024347�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������corpus_gnd.jsonl and gnd_fields.jsonl are derived from the Gemeinsame Normdatei (GND) of the Deutsche Nationalbibliothek, read through lobid-gnd (https://lobid.org/gnd), and carry the GND's license. The Deutsche Nationalbibliothek states that the GND authority data "is available free of charge for general use under Creative Commons Zero (CC0 1.0) terms." https://www.dnb.de/EN/Professionell/Metadatendienste/Datenbezug/geschaeftsmodell.html CC0 1.0 Universal: https://creativecommons.org/publicdomain/zero/1.0/ No attribution is required; this file credits the source anyway. Source: Gemeinsame Normdatei (GND), Deutsche Nationalbibliothek, via lobid-gnd (hbz). Each line of gnd_fields.jsonl names its record's GND id, resolvable at https://d-nb.info/gnd/<id>. ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/gnd_agreement.json�����������������������������0000664�0000000�0000000�00000117322�15273032717�0026203�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������{ "1012101827/direct-is/givenFirst": { "family": { "expected": "", "got": "Eldjárn" }, "given_names": { "expected": "Kristján Eldjárn", "got": "Kristján" }, "text": "Kristján Eldjárn" }, "1012555887/compound-latam/givenFirst": { "family": { "expected": "Quinquela Martín", "got": "Martín" }, "given_names": { "expected": "Benito", "got": "Benito Quinquela" }, "text": "Benito Quinquela Martín" }, "1014265487/compound-latam/givenFirst": { "family": { "expected": "Moreno Casamitjana", "got": "Casamitjana" }, "given_names": { "expected": "Antonio", "got": "Antonio Moreno" }, "text": "Antonio Moreno Casamitjana" }, "101526814/direct-is/givenFirst": { "family": { "expected": "", "got": "Gíslason" }, "given_names": { "expected": "Konráð Gíslason", "got": "Konráð" }, "text": "Konráð Gíslason" }, "1017511667/regnal/givenFirst": { "family": { "expected": "", "got": "I." }, "suffix": { "expected": "I.", "got": "" }, "text": "Sancho I." }, "1017599777/regnal/givenFirst": { "family": { "expected": "", "got": "I." }, "suffix": { "expected": "I.", "got": "" }, "text": "Mustafa I." }, "1017967407/compound-iberia/givenFirst": { "family": { "expected": "Domènech Estapà", "got": "Estapà" }, "given_names": { "expected": "Josep", "got": "Josep Domènech" }, "text": "Josep Domènech Estapà" }, "1018479422/direct-arab/givenFirst": { "family": { "expected": "", "got": "al-Ḥiǧāzī" }, "given_names": { "expected": "ʿĀliya Bint-ʿAlī al-Ḥiǧāzī", "got": "ʿĀliya Bint-ʿAlī" }, "text": "ʿĀliya Bint-ʿAlī al-Ḥiǧāzī" }, "1019728027/regnal/givenFirst": { "family": { "expected": "", "got": "Ulrich" }, "given_names": { "expected": "Christian Ulrich", "got": "Christian" }, "text": "Christian Ulrich II." }, "1021332496/direct-ir-af/givenFirst": { "family": { "expected": "", "got": "Ġūrī" }, "given_names": { "expected": "Šihāb-ad-Dīn Muḥammad Ġūrī", "got": "Šihāb-ad-Dīn Muḥammad" }, "text": "Šihāb-ad-Dīn Muḥammad Ġūrī" }, "1023019787/regnal/givenFirst": { "family": { "expected": "", "got": "I." }, "suffix": { "expected": "I.", "got": "" }, "text": "Karl I." }, "1026632668/direct-et-mm/givenFirst": { "family": { "expected": "", "got": "Zénāwi" }, "given_names": { "expected": "Malas Zénāwi", "got": "Malas" }, "text": "Malas Zénāwi" }, "102836299/direct-arab/givenFirst": { "family": { "expected": "", "got": "Maimon" }, "given_names": { "expected": "Avraham ben Mosheh ben Maimon", "got": "Avraham ben Mosheh ben" }, "text": "Avraham ben Mosheh ben Maimon" }, "1031212523/direct-my-id/givenFirst": { "family": { "expected": "", "got": "Fanṣūrī" }, "given_names": { "expected": "Ḥamza Fanṣūrī", "got": "Ḥamza" }, "text": "Ḥamza Fanṣūrī" }, "1033486469/direct-my-id/givenFirst": { "family": { "expected": "", "got": "Abdullah" }, "given_names": { "expected": "Azizi Hj. Abdullah", "got": "Azizi Hj." }, "text": "Azizi Hj. Abdullah" }, "1036271862/direct-my-id/givenFirst": { "family": { "expected": "", "got": "Hadimadja" }, "given_names": { "expected": "Ramadhan Karta Hadimadja", "got": "Ramadhan Karta" }, "text": "Ramadhan Karta Hadimadja" }, "104056606/direct-arab/givenFirst": { "family": { "expected": "", "got": "Ibn-Sinān" }, "given_names": { "expected": "Ibrāhīm Ibn-Sinān", "got": "Ibrāhīm" }, "text": "Ibrāhīm Ibn-Sinān" }, "104057726/direct-arab/givenFirst": { "family": { "expected": "", "got": "al-H̱uzāʿī" }, "given_names": { "expected": "Nuʿaim Ibn-Ḥammād al-H̱uzāʿī", "got": "Nuʿaim Ibn-Ḥammād" }, "text": "Nuʿaim Ibn-Ḥammād al-H̱uzāʿī" }, "104113405/direct-arab/givenFirst": { "family": { "expected": "", "got": "al-Armanī" }, "given_names": { "expected": "Abū-Ṣāliḥ al-Armanī", "got": "Abū-Ṣāliḥ" }, "text": "Abū-Ṣāliḥ al-Armanī" }, "1041656467/compound-iberia/givenFirst": { "family": { "expected": "Esteva a Alsina", "got": "Alsina" }, "given_names": { "expected": "Maurus Daniel", "got": "Maurus Daniel Esteva a" }, "text": "Maurus Daniel Esteva a Alsina" }, "104175346/direct-is/givenFirst": { "family": { "expected": "", "got": "Þórðarson" }, "given_names": { "expected": "Sighvatur Þórðarson", "got": "Sighvatur" }, "text": "Sighvatur Þórðarson" }, "1042379017/compound-iberia/givenFirst": { "family": { "expected": "Amigo Vallejo", "got": "Vallejo" }, "given_names": { "expected": "Carlos", "got": "Carlos Amigo" }, "text": "Carlos Amigo Vallejo" }, "104303603/direct-ir-af/givenFirst": { "family": { "expected": "", "got": "Badāʾūnī" }, "given_names": { "expected": "ʿAbd-al-Qādir Badāʾūnī", "got": "ʿAbd-al-Qādir" }, "text": "ʿAbd-al-Qādir Badāʾūnī" }, "1048644847/compound-latam/givenFirst": { "family": { "expected": "Valderrama Fernández", "got": "Fernández" }, "given_names": { "expected": "Ricardo", "got": "Ricardo Valderrama" }, "text": "Ricardo Valderrama Fernández" }, "1050400062/direct-et-mm/givenFirst": { "family": { "expected": "", "got": "I." }, "given_names": { "expected": "Amha Selassie I.", "got": "Amha Selassie" }, "text": "Amha Selassie I." }, "1051922143/direct-is/givenFirst": { "family": { "expected": "", "got": "Viðar" }, "given_names": { "expected": "Drífa Viðar", "got": "Drífa" }, "text": "Drífa Viðar" }, "1051927471/direct-is/givenFirst": { "family": { "expected": "", "got": "Björnsson" }, "given_names": { "expected": "Oddur Björnsson", "got": "Oddur" }, "text": "Oddur Björnsson" }, "1053275307/compound-latam/givenFirst": { "family": { "expected": "López Rayón", "got": "Rayón" }, "given_names": { "expected": "Ignacio", "got": "Ignacio López" }, "text": "Ignacio López Rayón" }, "1056353457/compound-latam/givenFirst": { "family": { "expected": "Arenas Betancourt", "got": "Betancourt" }, "given_names": { "expected": "Rodrigo", "got": "Rodrigo Arenas" }, "text": "Rodrigo Arenas Betancourt" }, "1056403667/particle-it/givenFirst": { "family": { "expected": "de'Medici", "got": "di Pierfranceso de'Medici" }, "given_names": { "expected": "Lorenzo di Pierfranceso", "got": "Lorenzo" }, "text": "Lorenzo di Pierfranceso de'Medici" }, "1056425067/compound-latam/givenFirst": { "family": { "expected": "González Gortázar", "got": "Gortázar" }, "given_names": { "expected": "Fernando", "got": "Fernando González" }, "text": "Fernando González Gortázar" }, "1056478497/compound-latam/givenFirst": { "family": { "expected": "Ospina Pérez", "got": "Pérez" }, "given_names": { "expected": "Mariano", "got": "Mariano Ospina" }, "text": "Mariano Ospina Pérez" }, "1056652217/compound-latam/givenFirst": { "family": { "expected": "Ruiz Armengol", "got": "Armengol" }, "given_names": { "expected": "Mario", "got": "Mario Ruiz" }, "text": "Mario Ruiz Armengol" }, "1059795337/direct-eu/givenFirst": { "family": { "expected": "", "got": "de Roucy" }, "given_names": { "expected": "Renaud de Roucy", "got": "Renaud" }, "text": "Renaud de Roucy" }, "1061106225/direct-arab/givenFirst": { "family": { "expected": "", "got": "Ibn-Ḥārīṯa" }, "given_names": { "expected": "Zaid Ibn-Ḥārīṯa", "got": "Zaid" }, "text": "Zaid Ibn-Ḥārīṯa" }, "1061494667/direct-is/givenFirst": { "family": { "expected": "", "got": "Hagalín" }, "given_names": { "expected": "Sigríður Hagalín", "got": "Sigríður" }, "text": "Sigríður Hagalín" }, "1066832900/direct-my-id/givenFirst": { "family": { "expected": "", "got": "Muda" }, "given_names": { "expected": "Mohamad Asri Muda", "got": "Mohamad Asri" }, "text": "Mohamad Asri Muda" }, "1074956427/regnal/givenFirst": { "family": { "expected": "", "got": "I." }, "suffix": { "expected": "I.", "got": "" }, "text": "Ulrich I." }, "1077612168/direct-arab/givenFirst": { "family": { "expected": "", "got": "bar Sabbaʿe" }, "given_names": { "expected": "Simeon bar Sabbaʿe", "got": "Simeon" }, "text": "Simeon bar Sabbaʿe" }, "1081653337/direct-ir-af/givenFirst": { "family": { "expected": "", "got": "Şikak" }, "given_names": { "expected": "Simkoyê Şikak", "got": "Simkoyê" }, "text": "Simkoyê Şikak" }, "1083655477/compound-latam/givenFirst": { "family": { "expected": "Málaga de Prado", "got": "de Prado" }, "given_names": { "expected": "Clorinda", "got": "Clorinda Málaga" }, "text": "Clorinda Málaga de Prado" }, "1100729747/compound-latam/givenFirst": { "family": { "expected": "Sordo Madaleno", "got": "Madaleno" }, "given_names": { "expected": "Juan", "got": "Juan Sordo" }, "text": "Juan Sordo Madaleno" }, "110685716X/direct-et-mm/givenFirst": { "family": { "expected": "", "got": "Ambar" }, "given_names": { "expected": "Malik Ambar", "got": "Malik" }, "text": "Malik Ambar" }, "1115673882/direct-arab/givenFirst": { "family": { "expected": "", "got": "al-ʿAẓma" }, "given_names": { "expected": "Yūsuf al-ʿAẓma", "got": "Yūsuf" }, "text": "Yūsuf al-ʿAẓma" }, "1123958947/compound-iberia/givenFirst": { "family": { "expected": "López Domínguez", "got": "Domínguez" }, "given_names": { "expected": "José", "got": "José López" }, "text": "José López Domínguez" }, "1124785817/direct-arab/givenFirst": { "family": { "expected": "", "got": "el-Hachemi" }, "given_names": { "expected": "Khaled el-Hassani ben el-Hachemi", "got": "Khaled el-Hassani ben" }, "text": "Khaled el-Hassani ben el-Hachemi" }, "112712797/direct-is/givenFirst": { "family": { "expected": "", "got": "Grímsson" }, "given_names": { "expected": "Stefán Hörður Grímsson", "got": "Stefán Hörður" }, "text": "Stefán Hörður Grímsson" }, "1129925617/direct-et-mm/givenFirst": { "family": { "expected": "", "got": "Selassie" }, "given_names": { "expected": "Makonnen Haile Selassie", "got": "Makonnen Haile" }, "text": "Makonnen Haile Selassie" }, "1138377546/direct-is/givenFirst": { "family": { "expected": "", "got": "Eiríksson" }, "given_names": { "expected": "Magnús Eiríksson", "got": "Magnús" }, "text": "Magnús Eiríksson" }, "1143145518/direct-arab/givenFirst": { "family": { "expected": "", "got": "Ibn Marwān" }, "given_names": { "expected": "ʿAbd al-ʿAzīz Ibn Marwān", "got": "ʿAbd al-ʿAzīz" }, "text": "ʿAbd al-ʿAzīz Ibn Marwān" }, "1143970381/direct-arab/givenFirst": { "family": { "expected": "", "got": "ibn Muhammad" }, "given_names": { "expected": "Abd al-Aziz ibn Muhammad", "got": "Abd al-Aziz" }, "text": "Abd al-Aziz ibn Muhammad" }, "1145191851/direct-arab/givenFirst": { "family": { "expected": "", "got": "al-Hasani" }, "given_names": { "expected": "Tadsch ad-Din al-Hasani", "got": "Tadsch ad-Din" }, "text": "Tadsch ad-Din al-Hasani" }, "1147649030/direct-my-id/givenFirst": { "family": { "expected": "", "got": "Johari" }, "given_names": { "expected": "Mohamed Khir Johari", "got": "Mohamed Khir" }, "text": "Mohamed Khir Johari" }, "1155767624/direct-ir-af/givenFirst": { "family": { "expected": "", "got": "Dowleh" }, "given_names": { "expected": "Vossug ed Dowleh", "got": "Vossug ed" }, "text": "Vossug ed Dowleh" }, "1157689973/direct-is/givenFirst": { "family": { "expected": "", "got": "Fjeldsted" }, "given_names": { "expected": "Thorkild Fjeldsted", "got": "Thorkild" }, "text": "Thorkild Fjeldsted" }, "1160043817/compound-latam/givenFirst": { "family": { "expected": "Campo Serrano", "got": "Serrano" }, "given_names": { "expected": "José María", "got": "José María Campo" }, "text": "José María Campo Serrano" }, "1163022667/compound-latam/givenFirst": { "family": { "expected": "Guardia Neyra", "got": "Neyra" }, "given_names": { "expected": "Jaime", "got": "Jaime Guardia" }, "text": "Jaime Guardia Neyra" }, "116424702/direct-is/givenFirst": { "family": { "expected": "", "got": "Einarsson" }, "given_names": { "expected": "Sigfús Einarsson", "got": "Sigfús" }, "text": "Sigfús Einarsson" }, "1165817241/direct-is/givenFirst": { "family": { "expected": "", "got": "Magnússon" }, "given_names": { "expected": "Jón Magnússon", "got": "Jón" }, "text": "Jón Magnússon" }, "117126089X/direct-et-mm/givenFirst": { "family": { "expected": "", "got": "Lwin" }, "given_names": { "expected": "Sein Lwin", "got": "Sein" }, "text": "Sein Lwin" }, "1172012407/compound-iberia/givenFirst": { "family": { "expected": "Rascón Navarro Seña y Redondo", "got": "Seña y Redondo" }, "given_names": { "expected": "Juan Antonio", "got": "Juan Antonio Rascón Navarro" }, "text": "Juan Antonio Rascón Navarro Seña y Redondo" }, "1172046697/particle-nl-be/givenFirst": { "family": { "expected": "van Zuylen van Nijevelt", "got": "van Nijevelt" }, "given_names": { "expected": "Julius", "got": "Julius van Zuylen" }, "text": "Julius van Zuylen van Nijevelt" }, "1172113386/direct-my-id/givenFirst": { "family": { "expected": "", "got": "Abu Bakar Riayatuddin Al-Mu’azzam Shah" }, "given_names": { "expected": "Ahmad Shah Al-Musta’in Billah ibni Almarhum Sultan Abu Bakar Riayatuddin Al-Mu’azzam Shah", "got": "Ahmad Shah Al-Musta’in Billah ibni Almarhum Sultan" }, "text": "Ahmad Shah Al-Musta’in Billah ibni Almarhum Sultan Abu Bakar Riayatuddin Al-Mu’azzam Shah" }, "117310107/particle-de-at/givenFirst": { "family": { "expected": "von Ungnad von Weissenwolf", "got": "von Weissenwolf" }, "given_names": { "expected": "David zu Sonnegg", "got": "David zu Sonnegg von Ungnad" }, "text": "David zu Sonnegg von Ungnad von Weissenwolf" }, "117546712/direct-arab/givenFirst": { "family": { "expected": "", "got": "Kamil" }, "given_names": { "expected": "Hussein Kamil", "got": "Hussein" }, "text": "Hussein Kamil" }, "118049062/direct-is/givenFirst": { "family": { "expected": "", "got": "Jónsson" }, "given_names": { "expected": "Finnur Jónsson", "got": "Finnur" }, "text": "Finnur Jónsson" }, "118500333/direct-arab/givenFirst": { "family": { "expected": "", "got": "Ibn-ʿAlī" }, "given_names": { "expected": "Abu-'l-Fidāʾ Ismāʿīl Ibn-ʿAlī", "got": "Abu-'l-Fidāʾ Ismāʿīl" }, "text": "Abu-'l-Fidāʾ Ismāʿīl Ibn-ʿAlī" }, "118528947/regnal/givenFirst": { "family": { "expected": "", "got": "V." }, "suffix": { "expected": "V.", "got": "" }, "text": "Eduard V." }, "118534947/regnal/givenFirst": { "family": { "expected": "", "got": "I." }, "suffix": { "expected": "I.", "got": "" }, "text": "Franz I." }, "118568647/regnal/givenFirst": { "family": { "expected": "", "got": "I." }, "suffix": { "expected": "I.", "got": "" }, "text": "László I." }, "118578367/compound-iberia/givenFirst": { "family": { "expected": "Martín Descalzo", "got": "Descalzo" }, "given_names": { "expected": "José Luis", "got": "José Luis Martín" }, "text": "José Luis Martín Descalzo" }, "118587587/direct-eu/givenFirst": { "family": { "expected": "", "got": "Thiong'o" }, "given_names": { "expected": "Ngugi wa Thiong'o", "got": "Ngugi wa" }, "text": "Ngugi wa Thiong'o" }, "118624571/direct-et-mm/givenFirst": { "family": { "expected": "", "got": "Tumsa" }, "given_names": { "expected": "Gudina Tumsa", "got": "Gudina" }, "text": "Gudina Tumsa" }, "118668757/compound-iberia/givenFirst": { "family": { "expected": "Castelo Branco", "got": "Branco" }, "given_names": { "expected": "Camilo", "got": "Camilo Castelo" }, "text": "Camilo Castelo Branco" }, "118687387/direct-eu/givenFirst": { "family": { "expected": "", "got": "S." }, "given_names": { "expected": "Meister E. S.", "got": "Meister E." }, "text": "Meister E. S." }, "118716158/direct-ir-af/givenFirst": { "family": { "expected": "", "got": "Rūmī" }, "given_names": { "expected": "Ǧalāl-ad-Dīn Rūmī", "got": "Ǧalāl-ad-Dīn" }, "text": "Ǧalāl-ad-Dīn Rūmī" }, "1187542687/compound-latam/givenFirst": { "family": { "expected": "Nunes de Ávila e Silva", "got": "de Ávila e Silva" }, "given_names": { "expected": "Epaminondas", "got": "Epaminondas Nunes" }, "text": "Epaminondas Nunes de Ávila e Silva" }, "118767917/compound-iberia/givenFirst": { "family": { "expected": "Vélez de Guevara", "got": "de Guevara" }, "given_names": { "expected": "Luis", "got": "Luis Vélez" }, "text": "Luis Vélez de Guevara" }, "118782835/direct-arab/givenFirst": { "family": { "expected": "", "got": "Bāšā" }, "given_names": { "expected": "Muḥammad ʿAlī Bāšā", "got": "Muḥammad ʿAlī" }, "text": "Muḥammad ʿAlī Bāšā" }, "118801724/direct-arab/givenFirst": { "family": { "expected": "", "got": "Ibn-Qurra" }, "given_names": { "expected": "Ṯābit Ibn-Qurra", "got": "Ṯābit" }, "text": "Ṯābit Ibn-Qurra" }, "118850555/direct-ir-af/givenFirst": { "family": { "expected": "", "got": "Ardabīlī" }, "given_names": { "expected": "Ṣafī-ad-Dīn Ardabīlī", "got": "Ṣafī-ad-Dīn" }, "text": "Ṣafī-ad-Dīn Ardabīlī" }, "1188659936/direct-ir-af/givenFirst": { "family": { "expected": "", "got": "Mašhadī" }, "given_names": { "expected": "Ḥusain Ṯanāʾī Mašhadī", "got": "Ḥusain Ṯanāʾī" }, "text": "Ḥusain Ṯanāʾī Mašhadī" }, "118894978/direct-is/givenFirst": { "family": { "expected": "", "got": "Jónsson" }, "given_names": { "expected": "Einar Jónsson", "got": "Einar" }, "text": "Einar Jónsson" }, "118932497/regnal/givenFirst": { "family": { "expected": "", "got": "I." }, "suffix": { "expected": "I.", "got": "" }, "text": "Ismail I." }, "118935577/direct-eu/givenFirst": { "family": { "expected": "", "got": "Pascha" }, "given_names": { "expected": "Enver Pascha", "got": "Enver" }, "text": "Enver Pascha" }, "118963430/direct-arab/givenFirst": { "family": { "expected": "", "got": "Hānum" }, "given_names": { "expected": "Ğawīdān _ Hānum", "got": "Ğawīdān _" }, "text": "Ğawīdān _ Hānum" }, "118972197/direct-eu/givenFirst": { "family": { "expected": "", "got": "Violet" }, "given_names": { "expected": "Ultra Violet", "got": "Ultra" }, "text": "Ultra Violet" }, "118976869/direct-arab/givenFirst": { "family": { "expected": "", "got": "Ibn-Aḥmad" }, "given_names": { "expected": "ʿAbd-al-Ǧabbār Ibn-Aḥmad", "got": "ʿAbd-al-Ǧabbār" }, "text": "ʿAbd-al-Ǧabbār Ibn-Aḥmad" }, "118988417/compound-latam/givenFirst": { "family": { "expected": "Marulanda Vélez", "got": "Vélez" }, "given_names": { "expected": "Manuel", "got": "Manuel Marulanda" }, "text": "Manuel Marulanda Vélez" }, "119075067/compound-latam/givenFirst": { "family": { "expected": "López Portillo y Pacheco", "got": "Portillo y Pacheco" }, "given_names": { "expected": "José", "got": "José López" }, "text": "José López Portillo y Pacheco" }, "1190758520/direct-is/givenFirst": { "family": { "expected": "", "got": "Guðmundsson" }, "given_names": { "expected": "Albert Guðmundsson", "got": "Albert" }, "text": "Albert Guðmundsson" }, "119104644/particle-arab/givenFirst": { "family": { "expected": "of Carnarvon", "got": "Herbert of Carnarvon" }, "family_base": { "expected": "Carnarvon", "got": "Herbert of Carnarvon" }, "family_particles": { "expected": "of", "got": "" }, "given_names": { "expected": "George Edward Stanhope Molyneux Herbert", "got": "George Edward Stanhope Molyneux" }, "text": "George Edward Stanhope Molyneux Herbert of Carnarvon" }, "119104644/particle-arab/sorting": { "family": { "expected": "Carnarvon of", "got": "Carnarvon" }, "family_particles": { "expected": "of", "got": "" }, "given_names": { "expected": "George Edward Stanhope Molyneux Herbert", "got": "George Edward Stanhope Molyneux Herbert of" }, "text": "Carnarvon, George Edward Stanhope Molyneux Herbert of" }, "119112655/direct-is/givenFirst": { "family": { "expected": "", "got": "Gröndal" }, "given_names": { "expected": "Benedikt Gröndal", "got": "Benedikt" }, "text": "Benedikt Gröndal" }, "11914512X/direct-ir-af/givenFirst": { "family": { "expected": "", "got": "Ibn-Muḥammad" }, "given_names": { "expected": "Abū-Maʿšar Ǧaʿfar Ibn-Muḥammad", "got": "Abū-Maʿšar Ǧaʿfar" }, "text": "Abū-Maʿšar Ǧaʿfar Ibn-Muḥammad" }, "119147726/direct-arab/givenFirst": { "family": { "expected": "", "got": "al-Kāẓīm" }, "given_names": { "expected": "Mūsā al-Kāẓīm", "got": "Mūsā" }, "text": "Mūsā al-Kāẓīm" }, "119152169/direct-arab/givenFirst": { "family": { "expected": "", "got": "Ibn-ʿAbd-al-Wahhāb" }, "given_names": { "expected": "Muḥammad Ibn-ʿAbd-al-Wahhāb", "got": "Muḥammad" }, "text": "Muḥammad Ibn-ʿAbd-al-Wahhāb" }, "11915689X/direct-et-mm/givenFirst": { "family": { "expected": "", "got": "Yaeqob" }, "given_names": { "expected": "Zarea Yaeqob", "got": "Zarea" }, "text": "Zarea Yaeqob" }, "119196824/direct-ir-af/givenFirst": { "family": { "expected": "", "got": "al-Qušairī" }, "given_names": { "expected": "Muslim Ibn-al-Ḥaǧǧāǧ al-Qušairī", "got": "Muslim Ibn-al-Ḥaǧǧāǧ" }, "text": "Muslim Ibn-al-Ḥaǧǧāǧ al-Qušairī" }, "11920519X/direct-ir-af/givenFirst": { "family": { "expected": "", "got": "Ibn-Sahl" }, "given_names": { "expected": "Sābūr Ibn-Sahl", "got": "Sābūr" }, "text": "Sābūr Ibn-Sahl" }, "11921203X/direct-ir-af/givenFirst": { "family": { "expected": "", "got": "Kalakānī" }, "given_names": { "expected": "Ḥabīballāh Kalakānī", "got": "Ḥabīballāh" }, "text": "Ḥabīballāh Kalakānī" }, "119236591/direct-ir-af/givenFirst": { "family": { "expected": "", "got": "ʿAmū-ūġlī" }, "given_names": { "expected": "Haider Hān ʿAmū-ūġlī", "got": "Haider Hān" }, "text": "Haider Hān ʿAmū-ūġlī" }, "119255782/direct-ir-af/givenFirst": { "family": { "expected": "", "got": "al-Ḫurramī" }, "given_names": { "expected": "Bābak Yaḥyā Ibn-Muʿāḏ al-Ḫurramī", "got": "Bābak Yaḥyā Ibn-Muʿāḏ" }, "text": "Bābak Yaḥyā Ibn-Muʿāḏ al-Ḫurramī" }, "119270277/regnal/givenFirst": { "family": { "expected": "", "got": "Friedrich" }, "given_names": { "expected": "Eitel Friedrich", "got": "Eitel" }, "text": "Eitel Friedrich II." }, "119288109/direct-et-mm/givenFirst": { "family": { "expected": "", "got": "San" }, "given_names": { "expected": "Aung San", "got": "Aung" }, "text": "Aung San" }, "119288370/direct-is/givenFirst": { "family": { "expected": "", "got": "Benediktsson" }, "given_names": { "expected": "Einar Benediktsson", "got": "Einar" }, "text": "Einar Benediktsson" }, "119392275/direct-is/givenFirst": { "family": { "expected": "", "got": "Sigurðsson" }, "given_names": { "expected": "Pétur Sigurðsson", "got": "Pétur" }, "text": "Pétur Sigurðsson" }, "119398184/direct-arab/givenFirst": { "family": { "expected": "", "got": "Ḥofni" }, "given_names": { "expected": "Shemuʾel ben Ḥofni", "got": "Shemuʾel ben" }, "text": "Shemuʾel ben Ḥofni" }, "1194558798/direct-ir-af/givenFirst": { "family": { "expected": "", "got": "Ḫān" }, "given_names": { "expected": "Amīn as-Sulṭān ʿAlī Aṣġar Ḫān", "got": "Amīn as-Sulṭān ʿAlī Aṣġar" }, "text": "Amīn as-Sulṭān ʿAlī Aṣġar Ḫān" }, "119465175/direct-is/givenFirst": { "family": { "expected": "", "got": "Þórhallsson" }, "given_names": { "expected": "Þorlákur Þórhallsson", "got": "Þorlákur" }, "text": "Þorlákur Þórhallsson" }, "11952645X/direct-ir-af/givenFirst": { "family": { "expected": "", "got": "al-Ḥasanī" }, "given_names": { "expected": "ʿImād al-Ḥasanī", "got": "ʿImād" }, "text": "ʿImād al-Ḥasanī" }, "119534827/direct-eu/givenFirst": { "family": { "expected": "", "got": "Yehudah" }, "given_names": { "expected": "Elʿazar ben Yehudah", "got": "Elʿazar ben" }, "text": "Elʿazar ben Yehudah" }, "119536897/regnal/givenFirst": { "family": { "expected": "", "got": "I." }, "suffix": { "expected": "I.", "got": "" }, "text": "Werner I." }, "1200607953/direct-et-mm/givenFirst": { "family": { "expected": "", "got": "Egziabher" }, "given_names": { "expected": "Tewolde Berhan Gebre Egziabher", "got": "Tewolde Berhan Gebre" }, "text": "Tewolde Berhan Gebre Egziabher" }, "120147327/regnal/givenFirst": { "family": { "expected": "", "got": "VIIII." }, "suffix": { "expected": "VIIII.", "got": "" }, "text": "Hamengku Buwono VIIII." }, "1202958737/compound-iberia/givenFirst": { "family": { "expected": "López Ochoa", "got": "Ochoa" }, "given_names": { "expected": "Eduardo", "got": "Eduardo López" }, "text": "Eduardo López Ochoa" }, "120584727/compound-latam/givenFirst": { "family": { "expected": "Livraga Rizzi", "got": "Rizzi" }, "given_names": { "expected": "Jorge A.", "got": "Jorge A. Livraga" }, "text": "Jorge A. Livraga Rizzi" }, "120770377/regnal/givenFirst": { "family": { "expected": "", "got": "I." }, "suffix": { "expected": "I.", "got": "" }, "text": "Magnus I." }, "120926172/direct-ir-af/givenFirst": { "family": { "expected": "", "got": "Pisyān" }, "given_names": { "expected": "Muḥammad Taqī Ḫān Pisyān", "got": "Muḥammad Taqī Ḫān" }, "text": "Muḥammad Taqī Ḫān Pisyān" }, "120942550/direct-arab/givenFirst": { "family": { "expected": "", "got": "al-Aṣġar" }, "given_names": { "expected": "Surāqa Ibn-Mirdās al-Aṣġar", "got": "Surāqa Ibn-Mirdās" }, "text": "Surāqa Ibn-Mirdās al-Aṣġar" }, "121086135/direct-is/givenFirst": { "family": { "expected": "", "got": "Leifs" }, "given_names": { "expected": "Jón Leifs", "got": "Jón" }, "text": "Jón Leifs" }, "1213251737/compound-iberia/givenFirst": { "family": { "expected": "Asas Manterola", "got": "Manterola" }, "given_names": { "expected": "Benita", "got": "Benita Asas" }, "text": "Benita Asas Manterola" }, "121487237/compound-latam/givenFirst": { "family": { "expected": "Serrano Poncela", "got": "Poncela" }, "given_names": { "expected": "Segundo", "got": "Segundo Serrano" }, "text": "Segundo Serrano Poncela" }, "122313208/direct-is/givenFirst": { "family": { "expected": "", "got": "Ásgeirsson" }, "given_names": { "expected": "Ásgeir Ásgeirsson", "got": "Ásgeir" }, "text": "Ásgeir Ásgeirsson" }, "122734017/regnal/givenFirst": { "family": { "expected": "", "got": "al I." }, "given_names": { "expected": "Malik al-ʿĀdil al", "got": "Malik al-ʿĀdil" }, "suffix": { "expected": "I.", "got": "" }, "text": "Malik al-ʿĀdil al I." }, "122991567/compound-iberia/givenFirst": { "family": { "expected": "Sanz Briz", "got": "Briz" }, "given_names": { "expected": "Ángel", "got": "Ángel Sanz" }, "text": "Ángel Sanz Briz" }, "123089077/compound-iberia/givenFirst": { "family": { "expected": "Nieves Conde", "got": "Conde" }, "given_names": { "expected": "José Antonio", "got": "José Antonio Nieves" }, "text": "José Antonio Nieves Conde" }, "123225663/direct-et-mm/givenFirst": { "family": { "expected": "", "got": "Sayadaw" }, "given_names": { "expected": "Ledi Sayadaw", "got": "Ledi" }, "text": "Ledi Sayadaw" }, "1233224425/direct-arab/givenFirst": { "family": { "expected": "", "got": "ar-Rizzī" }, "given_names": { "expected": "Sarkīs ar-Rizzī", "got": "Sarkīs" }, "text": "Sarkīs ar-Rizzī" }, "123505585/direct-my-id/givenFirst": { "family": { "expected": "", "got": "Al-Hadi" }, "given_names": { "expected": "Ahmad Al-Hadi", "got": "Ahmad" }, "text": "Ahmad Al-Hadi" }, "123676797/regnal/givenFirst": { "family": { "expected": "", "got": "I." }, "suffix": { "expected": "I.", "got": "" }, "text": "Hermann I." }, "1237298350/direct-my-id/givenFirst": { "family": { "expected": "", "got": "Bakar" }, "given_names": { "expected": "Abu Bakar", "got": "Abu" }, "text": "Abu Bakar" }, "1238191037/compound-iberia/givenFirst": { "family": { "expected": "Carrillo de Mendoza y Pimentel", "got": "de Mendoza y Pimentel" }, "given_names": { "expected": "Diego", "got": "Diego Carrillo" }, "text": "Diego Carrillo de Mendoza y Pimentel" }, "124714617/direct-is/givenFirst": { "family": { "expected": "", "got": "Guðnason" }, "given_names": { "expected": "Svavar Guðnason", "got": "Svavar" }, "text": "Svavar Guðnason" }, "124955487/compound-latam/givenFirst": { "family": { "expected": "Prats Gonzaléz", "got": "Gonzaléz" }, "given_names": { "expected": "Carlos", "got": "Carlos Prats" }, "text": "Carlos Prats Gonzaléz" }, "125827547/compound-iberia/givenFirst": { "family": { "expected": "Modrego Casaus", "got": "Casaus" }, "given_names": { "expected": "Gregorio", "got": "Gregorio Modrego" }, "text": "Gregorio Modrego Casaus" }, "1260866602/direct-my-id/givenFirst": { "family": { "expected": "", "got": "Shah" }, "given_names": { "expected": "Ismail Nasiruddin Shah", "got": "Ismail Nasiruddin" }, "text": "Ismail Nasiruddin Shah" }, "1267417625/direct-et-mm/givenFirst": { "family": { "expected": "", "got": "Gudeta" }, "given_names": { "expected": "Agitu Ideo Gudeta", "got": "Agitu Ideo" }, "text": "Agitu Ideo Gudeta" }, "1280472707/compound-iberia/givenFirst": { "family": { "expected": "Polo de Bernabé", "got": "de Bernabé" }, "given_names": { "expected": "Luis", "got": "Luis Polo" }, "text": "Luis Polo de Bernabé" }, "128048638/direct-is/givenFirst": { "family": { "expected": "", "got": "Arnórsson" }, "given_names": { "expected": "Einar Arnórsson", "got": "Einar" }, "text": "Einar Arnórsson" }, "1284598136/direct-ir-af/givenFirst": { "family": { "expected": "", "got": "Ḫān" }, "given_names": { "expected": "Bāqir Ḫān", "got": "Bāqir" }, "text": "Bāqir Ḫān" }, "1285103238/direct-my-id/givenFirst": { "family": { "expected": "", "got": "Alam" }, "given_names": { "expected": "Paku Alam VIII", "got": "Paku" }, "suffix": { "expected": "", "got": "VIII" }, "text": "Paku Alam VIII" }, "128739088/direct-is/givenFirst": { "family": { "expected": "", "got": "Stephansson" }, "given_names": { "expected": "Stephan Guðmundsson Stephansson", "got": "Stephan Guðmundsson" }, "text": "Stephan Guðmundsson Stephansson" }, "128851473/direct-my-id/givenFirst": { "family": { "expected": "", "got": "Ahmad" }, "given_names": { "expected": "Shahnon Ahmad", "got": "Shahnon" }, "text": "Shahnon Ahmad" }, "129229970/direct-is/givenFirst": { "family": { "expected": "", "got": "Helgadóttir" }, "given_names": { "expected": "Gerður Helgadóttir", "got": "Gerður" }, "text": "Gerður Helgadóttir" }, "1293064432/direct-is/givenFirst": { "family": { "expected": "", "got": "Stefánsson" }, "given_names": { "expected": "Stefán Karl Stefánsson", "got": "Stefán Karl" }, "text": "Stefán Karl Stefánsson" }, "12937427X/direct-is/givenFirst": { "family": { "expected": "", "got": "Böðvarsson" }, "given_names": { "expected": "Guðmundur Böðvarsson", "got": "Guðmundur" }, "text": "Guðmundur Böðvarsson" }, "129465046/direct-arab/givenFirst": { "family": { "expected": "", "got": "David" }, "given_names": { "expected": "Anan ben David", "got": "Anan ben" }, "text": "Anan ben David" }, "1298312620/direct-my-id/givenFirst": { "family": { "expected": "", "got": "Liat" }, "given_names": { "expected": "Lim Boo Liat", "got": "Lim Boo" }, "text": "Lim Boo Liat" }, "129854697/particle-elided/givenFirst": { "family": { "expected": "d'Angiviller", "got": "de La Billarderie d'Angiviller" }, "given_names": { "expected": "Charles Claude de La Billarderie", "got": "Charles Claude" }, "text": "Charles Claude de La Billarderie d'Angiviller" }, "130656097/direct-my-id/givenFirst": { "family": { "expected": "", "got": "bin Haji Ahmad Badawi" }, "given_names": { "expected": "Abdullah bin Haji Ahmad Badawi", "got": "Abdullah" }, "text": "Abdullah bin Haji Ahmad Badawi" }, "131443097/compound-iberia/givenFirst": { "family": { "expected": "Robles Pazos", "got": "Pazos" }, "given_names": { "expected": "José", "got": "José Robles" }, "text": "José Robles Pazos" }, "13167398X/direct-my-id/givenFirst": { "family": { "expected": "", "got": "Onn" }, "given_names": { "expected": "Hussein Onn", "got": "Hussein" }, "text": "Hussein Onn" }, "1316845230/direct-et-mm/givenFirst": { "family": { "expected": "", "got": "Maw" }, "given_names": { "expected": "Ba Maw", "got": "Ba" }, "text": "Ba Maw" }, "132686147/compound-iberia/givenFirst": { "family": { "expected": "Frühbeck de Burgos", "got": "de Burgos" }, "given_names": { "expected": "Rafael", "got": "Rafael Frühbeck" }, "text": "Rafael Frühbeck de Burgos" }, "132883317/compound-latam/givenFirst": { "family": { "expected": "Ayala Pérez", "got": "Pérez" }, "given_names": { "expected": "Daniel", "got": "Daniel Ayala" }, "text": "Daniel Ayala Pérez" }, "13358285X/direct-arab/givenFirst": { "family": { "expected": "", "got": "al-Miskīn" }, "given_names": { "expected": "Mattā al-Miskīn", "got": "Mattā" }, "text": "Mattā al-Miskīn" }, "133824047/compound-latam/givenFirst": { "family": { "expected": "Mendes de Almeida", "got": "de Almeida" }, "given_names": { "expected": "Luciano", "got": "Luciano Mendes" }, "text": "Luciano Mendes de Almeida" }, "134456467/direct-eu/givenFirst": { "family": { "expected": "", "got": "Martin" }, "given_names": { "expected": "Andreas Martin", "got": "Andreas" }, "text": "Andreas Martin" }, "135094097/direct-eu/givenFirst": { "family": { "expected": "", "got": "O." }, "given_names": { "expected": "L. U. P. O.", "got": "L. U. P." }, "text": "L. U. P. O." }, "135683637/regnal/givenFirst": { "family": { "expected": "", "got": "I." }, "suffix": { "expected": "I.", "got": "" }, "text": "Ulrich I." }, "135858755/direct-is/givenFirst": { "family": { "expected": "", "got": "SteinÞórsson" }, "given_names": { "expected": "Steingrímur SteinÞórsson", "got": "Steingrímur" }, "text": "Steingrímur SteinÞórsson" }, "13606860X/direct-is/givenFirst": { "family": { "expected": "", "got": "Gizursson" }, "given_names": { "expected": "Ísleifur Gizursson", "got": "Ísleifur" }, "text": "Ísleifur Gizursson" }, "136135064/direct-et-mm/givenFirst": { "family": { "expected": "", "got": "Sa" }, "given_names": { "expected": "Khun Sa", "got": "Khun" }, "text": "Khun Sa" }, "13628471X/direct-my-id/givenFirst": { "family": { "expected": "", "got": "Maidin" }, "given_names": { "expected": "Rashid Maidin", "got": "Rashid" }, "text": "Rashid Maidin" }, "136516106/direct-arab/givenFirst": { "family": { "expected": "", "got": "al-Dīn" }, "given_names": { "expected": "Nūr al-Dīn", "got": "Nūr" }, "text": "Nūr al-Dīn" }, "136563457/compound-latam/givenFirst": { "family": { "expected": "Gutiérrez Carreola", "got": "Carreola" }, "given_names": { "expected": "Francisco", "got": "Francisco Gutiérrez" }, "text": "Francisco Gutiérrez Carreola" }, "137324847/regnal/givenFirst": { "family": { "expected": "", "got": "I." }, "suffix": { "expected": "I.", "got": "" }, "text": "Heinrich I." }, "13775390X/direct-my-id/givenFirst": { "family": { "expected": "", "got": "Jalaluddin" }, "given_names": { "expected": "Tahir Jalaluddin", "got": "Tahir" }, "text": "Tahir Jalaluddin" }, "137841817/regnal/givenFirst": { "family": { "expected": "", "got": "V." }, "suffix": { "expected": "V.", "got": "" }, "text": "Friedrich V." }, "138621497/regnal/givenFirst": { "family": { "expected": "", "got": "I." }, "suffix": { "expected": "I.", "got": "" }, "text": "Thomas I." }, "139287647/regnal/givenFirst": { "family": { "expected": "", "got": "I." }, "suffix": { "expected": "I.", "got": "" }, "text": "Vladislav I." }, "140876936/direct-my-id/givenFirst": { "family": { "expected": "", "got": "Zaba" }, "given_names": { "expected": "Pendeta Zaba", "got": "Pendeta" }, "text": "Pendeta Zaba" }, "141518618/direct-is/givenFirst": { "family": { "expected": "", "got": "Benediktsson" }, "given_names": { "expected": "Gunnar Benediktsson", "got": "Gunnar" }, "text": "Gunnar Benediktsson" }, "14240439X/direct-my-id/givenFirst": { "family": { "expected": "", "got": "Bonjol" }, "given_names": { "expected": "Imam Bonjol", "got": "" }, "text": "Imam Bonjol", "title": { "expected": "", "got": "Imam" } }, "142486175/direct-my-id/givenFirst": { "family": { "expected": "", "got": "Ibn-ʿAbdalqādir" }, "given_names": { "expected": "ʿAbdallāh Ibn-ʿAbdalqādir", "got": "ʿAbdallāh" }, "text": "ʿAbdallāh Ibn-ʿAbdalqādir" }, "170741427/compound-iberia/givenFirst": { "family": { "expected": "Garaikoetxea Urriza", "got": "Urriza" }, "given_names": { "expected": "Juan C.", "got": "Juan C. Garaikoetxea" }, "text": "Juan C. Garaikoetxea Urriza" }, "172013437/compound-latam/givenFirst": { "family": { "expected": "Caballero Calderón", "got": "Calderón" }, "given_names": { "expected": "Eduardo", "got": "Eduardo Caballero" }, "text": "Eduardo Caballero Calderón" }, "174317840/direct-my-id/givenFirst": { "family": { "expected": "", "got": "Ie" }, "given_names": { "expected": "Kho Liang Ie", "got": "Kho Liang" }, "text": "Kho Liang Ie" }, "174346352/direct-is/givenFirst": { "family": { "expected": "", "got": "Sveinsson" }, "given_names": { "expected": "Ásmundur Sveinsson", "got": "Ásmundur" }, "text": "Ásmundur Sveinsson" } } ��������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/gnd_fields.jsonl�������������������������������0000664�0000000�0000000�00000471217�15273032717�0025664�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������{"composed": true, "fields": {"given": "Gianantonio", "surname-core": "Sangiorgio", "surname-prefix": "da"}, "file": "100142117", "label": "particle-it", "order": "givenFirst", "shape": null, "text": "Gianantonio da Sangiorgio"} {"composed": false, "fields": {"given": "Gianantonio", "surname-core": "Sangiorgio", "surname-prefix": "da"}, "file": "100142117", "label": "particle-it", "order": "sorting", "shape": null, "text": "Sangiorgio, Gianantonio da"} {"composed": true, "fields": {"given": "Johannes", "surname-core": "Gorter", "surname-prefix": "de"}, "file": "100153887", "label": "particle-nl-be", "order": "givenFirst", "shape": null, "text": "Johannes de Gorter"} {"composed": false, "fields": {"given": "Johannes", "surname-core": "Gorter", "surname-prefix": "de"}, "file": "100153887", "label": "particle-nl-be", "order": "sorting", "shape": null, "text": "Gorter, Johannes de"} {"composed": true, "fields": {"given": "Jakob", "surname": "Horst"}, "file": "100210007", "label": "plain-europe", "order": "givenFirst", "shape": null, "text": "Jakob Horst"} {"composed": false, "fields": {"given": "Jakob", "surname": "Horst"}, "file": "100210007", "label": "plain-europe", "order": "sorting", "shape": null, "text": "Horst, Jakob"} {"composed": true, "fields": {"given": "Étienne Lauréault", "surname-core": "Foncemagne", "surname-prefix": "de"}, "file": "100278337", "label": "particle-fr", "order": "givenFirst", "shape": null, "text": "Étienne Lauréault de Foncemagne"} {"composed": false, "fields": {"given": "Étienne Lauréault", "surname-core": "Foncemagne", "surname-prefix": "de"}, "file": "100278337", "label": "particle-fr", "order": "sorting", "shape": null, "text": "Foncemagne, Étienne Lauréault de"} {"composed": true, "fields": {"given": "Emmanuel", "surname-core": "Grouchy", "surname-prefix": "de"}, "file": "100491987", "label": "particle-fr", "order": "givenFirst", "shape": null, "text": "Emmanuel de Grouchy"} {"composed": false, "fields": {"given": "Emmanuel", "surname-core": "Grouchy", "surname-prefix": "de"}, "file": "100491987", "label": "particle-fr", "order": "sorting", "shape": null, "text": "Grouchy, Emmanuel de"} {"composed": true, "fields": {"given": "Alonso", "surname-core": "Cardenas", "surname-prefix": "de"}, "file": "100664067", "label": "particle-iberia", "order": "givenFirst", "shape": null, "text": "Alonso de Cardenas"} {"composed": false, "fields": {"given": "Alonso", "surname-core": "Cardenas", "surname-prefix": "de"}, "file": "100664067", "label": "particle-iberia", "order": "sorting", "shape": null, "text": "Cardenas, Alonso de"} {"composed": true, "fields": {"given": "Auguste Marie Raymond", "surname": "d'Arenberg"}, "file": "101063857", "label": "particle-elided", "order": "givenFirst", "shape": null, "text": "Auguste Marie Raymond d'Arenberg"} {"composed": false, "fields": {"given": "Auguste Marie Raymond", "surname-core": "Arenberg", "surname-prefix": "d'"}, "file": "101063857", "label": "particle-elided", "order": "sorting", "shape": null, "text": "Arenberg, Auguste Marie Raymond d'"} {"composed": false, "fields": {"given": "Kristján Eldjárn"}, "file": "1012101827", "label": "direct-is", "order": "givenFirst", "shape": null, "text": "Kristján Eldjárn"} {"composed": false, "fields": {"given": "Senji", "surname": "Yamamoto"}, "file": "1012142507", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Yamamoto, Senji"} {"composed": true, "fields": {"given": "Egbert", "surname-core": "Heemskerck", "surname-prefix": "van"}, "file": "1012185397", "label": "particle-nl-be", "order": "givenFirst", "shape": null, "text": "Egbert van Heemskerck"} {"composed": false, "fields": {"given": "Egbert", "surname-core": "Heemskerck", "surname-prefix": "van"}, "file": "1012185397", "label": "particle-nl-be", "order": "sorting", "shape": null, "text": "Heemskerck, Egbert van"} {"composed": false, "fields": {"given": "Shinnosuke", "surname": "Abe"}, "file": "1012221407", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Abe, Shinnosuke"} {"composed": true, "fields": {"given": "Hans", "surname-core": "Donat", "surname-prefix": "von"}, "file": "1012376907", "label": "particle-de-at", "order": "givenFirst", "shape": null, "text": "Hans von Donat"} {"composed": false, "fields": {"given": "Hans", "surname-core": "Donat", "surname-prefix": "von"}, "file": "1012376907", "label": "particle-de-at", "order": "sorting", "shape": null, "text": "Donat, Hans von"} {"composed": true, "fields": {"given": "Benito", "surname": "Quinquela Martín"}, "file": "1012555887", "label": "compound-latam", "order": "givenFirst", "shape": null, "text": "Benito Quinquela Martín"} {"composed": false, "fields": {"given": "Benito", "surname": "Quinquela Martín"}, "file": "1012555887", "label": "compound-latam", "order": "sorting", "shape": null, "text": "Quinquela Martín, Benito"} {"composed": true, "fields": {"generation": "III.", "given": "ʿAbbās"}, "file": "1013522567", "label": "regnal", "order": "givenFirst", "shape": null, "text": "ʿAbbās III."} {"composed": true, "fields": {"given": "Antonio", "surname": "Moreno Casamitjana"}, "file": "1014265487", "label": "compound-latam", "order": "givenFirst", "shape": null, "text": "Antonio Moreno Casamitjana"} {"composed": false, "fields": {"given": "Antonio", "surname": "Moreno Casamitjana"}, "file": "1014265487", "label": "compound-latam", "order": "sorting", "shape": null, "text": "Moreno Casamitjana, Antonio"} {"composed": true, "fields": {"given": "Lodewijk", "surname-core": "Oranje-Nassau", "surname-prefix": "van"}, "file": "101521707", "label": "particle-de-at", "order": "givenFirst", "shape": null, "text": "Lodewijk van Oranje-Nassau"} {"composed": false, "fields": {"given": "Lodewijk", "surname-core": "Oranje-Nassau", "surname-prefix": "van"}, "file": "101521707", "label": "particle-de-at", "order": "sorting", "shape": null, "text": "Oranje-Nassau, Lodewijk van"} {"composed": false, "fields": {"given": "Konráð Gíslason"}, "file": "101526814", "label": "direct-is", "order": "givenFirst", "shape": null, "text": "Konráð Gíslason"} {"composed": true, "fields": {"given": "Maddalena", "surname": "de'Medici"}, "file": "1016853777", "label": "particle-it", "order": "givenFirst", "shape": null, "text": "Maddalena de'Medici"} {"composed": false, "fields": {"given": "Maddalena", "surname-core": "Medici", "surname-prefix": "de'"}, "file": "1016853777", "label": "particle-it", "order": "sorting", "shape": null, "text": "Medici, Maddalena de'"} {"composed": true, "fields": {"given": "Jean Louis Charles", "surname": "d'Orléans-Longueville"}, "file": "1017076367", "label": "particle-elided", "order": "givenFirst", "shape": null, "text": "Jean Louis Charles d'Orléans-Longueville"} {"composed": false, "fields": {"given": "Jean Louis Charles", "surname-core": "Orléans-Longueville", "surname-prefix": "d'"}, "file": "1017076367", "label": "particle-elided", "order": "sorting", "shape": null, "text": "Orléans-Longueville, Jean Louis Charles d'"} {"composed": true, "fields": {"given": "Jean", "surname-core": "Montereul", "surname-prefix": "de"}, "file": "1017320837", "label": "particle-it", "order": "givenFirst", "shape": null, "text": "Jean de Montereul"} {"composed": false, "fields": {"given": "Jean", "surname-core": "Montereul", "surname-prefix": "de"}, "file": "1017320837", "label": "particle-it", "order": "sorting", "shape": null, "text": "Montereul, Jean de"} {"composed": true, "fields": {"generation": "I.", "given": "Sancho"}, "file": "1017511667", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Sancho I."} {"composed": true, "fields": {"generation": "I.", "given": "Mustafa"}, "file": "1017599777", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Mustafa I."} {"composed": true, "fields": {"given": "Josep", "surname": "Domènech Estapà"}, "file": "1017967407", "label": "compound-iberia", "order": "givenFirst", "shape": null, "text": "Josep Domènech Estapà"} {"composed": false, "fields": {"given": "Josep", "surname": "Domènech Estapà"}, "file": "1017967407", "label": "compound-iberia", "order": "sorting", "shape": null, "text": "Domènech Estapà, Josep"} {"composed": true, "fields": {"given": "Silverio", "surname": "Franconetti y Aguilar"}, "file": "1017995737", "label": "compound-iberia", "order": "givenFirst", "shape": null, "text": "Silverio Franconetti y Aguilar"} {"composed": false, "fields": {"given": "Silverio", "surname": "Franconetti y Aguilar"}, "file": "1017995737", "label": "compound-iberia", "order": "sorting", "shape": null, "text": "Franconetti y Aguilar, Silverio"} {"composed": false, "fields": {"given": "Fatimah"}, "file": "1018184392", "label": "direct-my-id", "order": "givenFirst", "shape": null, "text": "Fatimah"} {"composed": false, "fields": {"given": "ʿĀliya Bint-ʿAlī al-Ḥiǧāzī"}, "file": "1018479422", "label": "direct-arab", "order": "givenFirst", "shape": null, "text": "ʿĀliya Bint-ʿAlī al-Ḥiǧāzī"} {"composed": true, "fields": {"given": "Jean de Beaumanoir", "surname-core": "Lavardin", "surname-prefix": "de"}, "file": "1018811257", "label": "particle-fr", "order": "givenFirst", "shape": null, "text": "Jean de Beaumanoir de Lavardin"} {"composed": false, "fields": {"given": "Jean de Beaumanoir", "surname-core": "Lavardin", "surname-prefix": "de"}, "file": "1018811257", "label": "particle-fr", "order": "sorting", "shape": null, "text": "Lavardin, Jean de Beaumanoir de"} {"composed": true, "fields": {"given": "Cayetano Alberto", "surname": "de la Barrera y Leirado"}, "file": "101938977", "label": "particle-iberia", "order": "givenFirst", "shape": null, "text": "Cayetano Alberto de la Barrera y Leirado"} {"composed": false, "fields": {"given": "Cayetano Alberto", "surname": "de la Barrera y Leirado"}, "file": "101938977", "label": "particle-iberia", "order": "sorting", "shape": null, "text": "Barrera y Leirado, Cayetano Alberto de la"} {"composed": true, "fields": {"given": "Karl August Ferdinand", "surname-core": "Borcke", "surname-prefix": "von"}, "file": "1019726407", "label": "particle-de-at", "order": "givenFirst", "shape": null, "text": "Karl August Ferdinand von Borcke"} {"composed": false, "fields": {"given": "Karl August Ferdinand", "surname-core": "Borcke", "surname-prefix": "von"}, "file": "1019726407", "label": "particle-de-at", "order": "sorting", "shape": null, "text": "Borcke, Karl August Ferdinand von"} {"composed": true, "fields": {"generation": "II.", "given": "Christian Ulrich"}, "file": "1019728027", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Christian Ulrich II."} {"composed": true, "fields": {"given": "Paul Phélypeaux", "surname-core": "Pontchartrain", "surname-prefix": "de"}, "file": "1019729317", "label": "particle-fr", "order": "givenFirst", "shape": null, "text": "Paul Phélypeaux de Pontchartrain"} {"composed": false, "fields": {"given": "Paul Phélypeaux", "surname-core": "Pontchartrain", "surname-prefix": "de"}, "file": "1019729317", "label": "particle-fr", "order": "sorting", "shape": null, "text": "Pontchartrain, Paul Phélypeaux de"} {"composed": true, "fields": {"given": "Victor-Marie", "surname": "d'Estrées"}, "file": "1019736747", "label": "particle-elided", "order": "givenFirst", "shape": null, "text": "Victor-Marie d'Estrées"} {"composed": false, "fields": {"given": "Victor-Marie", "surname-core": "Estrées", "surname-prefix": "d'"}, "file": "1019736747", "label": "particle-elided", "order": "sorting", "shape": null, "text": "Estrées, Victor-Marie d'"} {"composed": false, "fields": {"given": "Masanao", "surname": "Nakamura"}, "file": "1020307307", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Nakamura, Masanao"} {"composed": true, "fields": {"given": "Ibrāhīm", "surname": "al-Ǧaʿfarī"}, "file": "1020770929", "label": "particle-arab", "order": "givenFirst", "shape": null, "text": "Ibrāhīm al-Ǧaʿfarī"} {"composed": false, "fields": {"given": "Ibrāhīm", "surname-core": "Ǧaʿfarī", "surname-prefix": "al-"}, "file": "1020770929", "label": "particle-arab", "order": "sorting", "shape": null, "text": "Ǧaʿfarī, Ibrāhīm al-"} {"composed": true, "fields": {"generation": "IV.", "given": "Wilhelm"}, "file": "102104387", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Wilhelm IV."} {"composed": true, "fields": {"given": "Rolf", "surname": "Dietrich"}, "file": "1021134007", "label": "plain-europe", "order": "givenFirst", "shape": null, "text": "Rolf Dietrich"} {"composed": false, "fields": {"given": "Rolf", "surname": "Dietrich"}, "file": "1021134007", "label": "plain-europe", "order": "sorting", "shape": null, "text": "Dietrich, Rolf"} {"composed": true, "fields": {"given": "Nathan Augustus", "surname": "Cobb"}, "file": "102119007", "label": "plain-anglo", "order": "givenFirst", "shape": null, "text": "Nathan Augustus Cobb"} {"composed": false, "fields": {"given": "Nathan Augustus", "surname": "Cobb"}, "file": "102119007", "label": "plain-anglo", "order": "sorting", "shape": null, "text": "Cobb, Nathan Augustus"} {"composed": false, "fields": {"given": "Šihāb-ad-Dīn Muḥammad Ġūrī"}, "file": "1021332496", "label": "direct-ir-af", "order": "givenFirst", "shape": null, "text": "Šihāb-ad-Dīn Muḥammad Ġūrī"} {"composed": true, "fields": {"generation": "I.", "given": "Karl"}, "file": "1023019787", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Karl I."} {"composed": true, "fields": {"given": "Aḥmad Ibn-ʿAlī", "surname": "al-Maqrīzī"}, "file": "102371512", "label": "particle-arab", "order": "givenFirst", "shape": null, "text": "Aḥmad Ibn-ʿAlī al-Maqrīzī"} {"composed": false, "fields": {"given": "Aḥmad Ibn-ʿAlī", "surname-core": "Maqrīzī", "surname-prefix": "al-"}, "file": "102371512", "label": "particle-arab", "order": "sorting", "shape": null, "text": "Maqrīzī, Aḥmad Ibn-ʿAlī al-"} {"composed": false, "fields": {"given": "Baiitsu", "surname": "Yamamoto"}, "file": "1023745607", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Yamamoto, Baiitsu"} {"composed": false, "fields": {"given": "Thomas", "surname": "Molnar"}, "file": "1024783227", "label": "index-hu", "order": "sorting", "shape": null, "text": "Molnar, Thomas"} {"composed": true, "fields": {"given": "Barbara", "surname": "Brukalska"}, "file": "1025063007", "label": "plain-europe", "order": "givenFirst", "shape": null, "text": "Barbara Brukalska"} {"composed": false, "fields": {"given": "Barbara", "surname": "Brukalska"}, "file": "1025063007", "label": "plain-europe", "order": "sorting", "shape": null, "text": "Brukalska, Barbara"} {"composed": true, "fields": {"generation": "II.", "given": "Iustinus"}, "file": "102518157", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Iustinus II."} {"composed": false, "fields": {"given": "György", "surname": "Hajós"}, "file": "1025877837", "label": "index-hu", "order": "sorting", "shape": null, "text": "Hajós, György"} {"composed": true, "fields": {"given": "Emmanuel", "surname": "d'Alzon"}, "file": "1025906977", "label": "particle-elided", "order": "givenFirst", "shape": null, "text": "Emmanuel d'Alzon"} {"composed": false, "fields": {"given": "Emmanuel", "surname-core": "Alzon", "surname-prefix": "d'"}, "file": "1025906977", "label": "particle-elided", "order": "sorting", "shape": null, "text": "Alzon, Emmanuel d'"} {"composed": false, "fields": {"given": "Albin", "surname": "Kőrösi"}, "file": "1026029007", "label": "index-hu", "order": "sorting", "shape": null, "text": "Kőrösi, Albin"} {"composed": false, "fields": {"given": "Dezső", "surname": "Czigány"}, "file": "1026075297", "label": "index-hu", "order": "sorting", "shape": null, "text": "Czigány, Dezső"} {"composed": false, "fields": {"given": "Takeshirō", "surname": "Matsuura"}, "file": "1026111307", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Matsuura, Takeshirō"} {"composed": true, "fields": {"given": "Matthias", "surname-core": "Pischof", "surname-prefix": "von"}, "file": "1026191157", "label": "particle-it", "order": "givenFirst", "shape": null, "text": "Matthias von Pischof"} {"composed": false, "fields": {"given": "Matthias", "surname-core": "Pischof", "surname-prefix": "von"}, "file": "1026191157", "label": "particle-it", "order": "sorting", "shape": null, "text": "Pischof, Matthias von"} {"composed": false, "fields": {"given": "Malas Zénāwi"}, "file": "1026632668", "label": "direct-et-mm", "order": "givenFirst", "shape": null, "text": "Malas Zénāwi"} {"composed": true, "fields": {"given": "Karl", "surname": "Pfeffer-Wildenbruch"}, "file": "1026828597", "label": "compound-latam", "order": "givenFirst", "shape": null, "text": "Karl Pfeffer-Wildenbruch"} {"composed": false, "fields": {"given": "Karl", "surname": "Pfeffer-Wildenbruch"}, "file": "1026828597", "label": "compound-latam", "order": "sorting", "shape": null, "text": "Pfeffer-Wildenbruch, Karl"} {"composed": false, "fields": {"given": "Avraham ben Mosheh ben Maimon"}, "file": "102836299", "label": "direct-arab", "order": "givenFirst", "shape": null, "text": "Avraham ben Mosheh ben Maimon"} {"composed": false, "fields": {"given": "Ignác", "surname": "Schlesinger"}, "file": "1030034427", "label": "index-hu", "order": "sorting", "shape": null, "text": "Schlesinger, Ignác"} {"composed": true, "fields": {"given": "George D.", "surname": "Cummins"}, "file": "1030131007", "label": "plain-anglo", "order": "givenFirst", "shape": null, "text": "George D. Cummins"} {"composed": false, "fields": {"given": "George D.", "surname": "Cummins"}, "file": "1030131007", "label": "plain-anglo", "order": "sorting", "shape": null, "text": "Cummins, George D."} {"composed": true, "fields": {"given": "Julie", "surname": "Traberth"}, "file": "1030229007", "label": "plain-europe", "order": "givenFirst", "shape": null, "text": "Julie Traberth"} {"composed": false, "fields": {"given": "Julie", "surname": "Traberth"}, "file": "1030229007", "label": "plain-europe", "order": "sorting", "shape": null, "text": "Traberth, Julie"} {"composed": false, "fields": {"given": "Ḥamza Fanṣūrī"}, "file": "1031212523", "label": "direct-my-id", "order": "givenFirst", "shape": null, "text": "Ḥamza Fanṣūrī"} {"composed": true, "fields": {"generation": "II.", "given": "Lžedmitrij"}, "file": "1032892617", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Lžedmitrij II."} {"composed": false, "fields": {"given": "Nalî"}, "file": "1033242748", "label": "direct-arab", "order": "givenFirst", "shape": null, "text": "Nalî"} {"composed": false, "fields": {"given": "Wefayî"}, "file": "1033340979", "label": "direct-arab", "order": "givenFirst", "shape": null, "text": "Wefayî"} {"composed": false, "fields": {"given": "Azizi Hj. Abdullah"}, "file": "1033486469", "label": "direct-my-id", "order": "givenFirst", "shape": null, "text": "Azizi Hj. Abdullah"} {"composed": false, "fields": {"given": "Motoko", "surname": "Hani"}, "file": "1033524107", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Hani, Motoko"} {"composed": false, "fields": {"given": "Lajos", "surname": "Simonyi von Vitézvár"}, "file": "1034118587", "label": "index-hu", "order": "sorting", "shape": null, "text": "Simonyi von Vitézvár, Lajos"} {"composed": true, "fields": {"given": "Lúcio", "surname-core": "Mendonça", "surname-prefix": "de"}, "file": "1034340247", "label": "particle-iberia", "order": "givenFirst", "shape": null, "text": "Lúcio de Mendonça"} {"composed": false, "fields": {"given": "Lúcio", "surname-core": "Mendonça", "surname-prefix": "de"}, "file": "1034340247", "label": "particle-iberia", "order": "sorting", "shape": null, "text": "Mendonça, Lúcio de"} {"composed": true, "fields": {"given": "Bernard große", "surname": "Broermann"}, "file": "1034662007", "label": "plain-europe", "order": "givenFirst", "shape": null, "text": "Bernard große Broermann"} {"composed": false, "fields": {"given": "Bernard große", "surname": "Broermann"}, "file": "1034662007", "label": "plain-europe", "order": "sorting", "shape": null, "text": "Broermann, Bernard große"} {"composed": true, "fields": {"given": "Rudolf Friedrich Leopold", "surname-core": "Hildebrand", "surname-prefix": "von"}, "file": "1034908707", "label": "particle-de-at", "order": "givenFirst", "shape": null, "text": "Rudolf Friedrich Leopold von Hildebrand"} {"composed": false, "fields": {"given": "Rudolf Friedrich Leopold", "surname-core": "Hildebrand", "surname-prefix": "von"}, "file": "1034908707", "label": "particle-de-at", "order": "sorting", "shape": null, "text": "Hildebrand, Rudolf Friedrich Leopold von"} {"composed": false, "fields": {"given": "Ramadhan Karta Hadimadja"}, "file": "1036271862", "label": "direct-my-id", "order": "givenFirst", "shape": null, "text": "Ramadhan Karta Hadimadja"} {"composed": true, "fields": {"given": "Pieter", "surname-core": "Carpentier", "surname-prefix": "de"}, "file": "1036479897", "label": "particle-nl-be", "order": "givenFirst", "shape": null, "text": "Pieter de Carpentier"} {"composed": false, "fields": {"given": "Pieter", "surname-core": "Carpentier", "surname-prefix": "de"}, "file": "1036479897", "label": "particle-nl-be", "order": "sorting", "shape": null, "text": "Carpentier, Pieter de"} {"composed": false, "fields": {"given": "Ibrāhīm Ibn-Sinān"}, "file": "104056606", "label": "direct-arab", "order": "givenFirst", "shape": null, "text": "Ibrāhīm Ibn-Sinān"} {"composed": false, "fields": {"given": "Nuʿaim Ibn-Ḥammād al-H̱uzāʿī"}, "file": "104057726", "label": "direct-arab", "order": "givenFirst", "shape": null, "text": "Nuʿaim Ibn-Ḥammād al-H̱uzāʿī"} {"composed": false, "fields": {"given": "Abū-Ṣāliḥ al-Armanī"}, "file": "104113405", "label": "direct-arab", "order": "givenFirst", "shape": null, "text": "Abū-Ṣāliḥ al-Armanī"} {"composed": true, "fields": {"given": "Maurus Daniel", "surname": "Esteva a Alsina"}, "file": "1041656467", "label": "compound-iberia", "order": "givenFirst", "shape": null, "text": "Maurus Daniel Esteva a Alsina"} {"composed": false, "fields": {"given": "Maurus Daniel", "surname": "Esteva a Alsina"}, "file": "1041656467", "label": "compound-iberia", "order": "sorting", "shape": null, "text": "Esteva a Alsina, Maurus Daniel"} {"composed": false, "fields": {"given": "Sighvatur Þórðarson"}, "file": "104175346", "label": "direct-is", "order": "givenFirst", "shape": null, "text": "Sighvatur Þórðarson"} {"composed": true, "fields": {"given": "Carlos", "surname": "Amigo Vallejo"}, "file": "1042379017", "label": "compound-iberia", "order": "givenFirst", "shape": null, "text": "Carlos Amigo Vallejo"} {"composed": false, "fields": {"given": "Carlos", "surname": "Amigo Vallejo"}, "file": "1042379017", "label": "compound-iberia", "order": "sorting", "shape": null, "text": "Amigo Vallejo, Carlos"} {"composed": true, "fields": {"given": "Anne-Pierre", "surname-core": "Montesquiou-Fézensac", "surname-prefix": "de"}, "file": "104292717", "label": "particle-fr", "order": "givenFirst", "shape": null, "text": "Anne-Pierre de Montesquiou-Fézensac"} {"composed": false, "fields": {"given": "Anne-Pierre", "surname-core": "Montesquiou-Fézensac", "surname-prefix": "de"}, "file": "104292717", "label": "particle-fr", "order": "sorting", "shape": null, "text": "Montesquiou-Fézensac, Anne-Pierre de"} {"composed": false, "fields": {"given": "ʿAbd-al-Qādir Badāʾūnī"}, "file": "104303603", "label": "direct-ir-af", "order": "givenFirst", "shape": null, "text": "ʿAbd-al-Qādir Badāʾūnī"} {"composed": false, "fields": {"given": "Biao", "surname": "Geng"}, "file": "1043168907", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Geng, Biao"} {"composed": true, "fields": {"given": "Alfonsus Antonius", "surname-core": "Sarasa", "surname-prefix": "de"}, "file": "104323507", "label": "particle-nl-be", "order": "givenFirst", "shape": null, "text": "Alfonsus Antonius de Sarasa"} {"composed": false, "fields": {"given": "Alfonsus Antonius", "surname-core": "Sarasa", "surname-prefix": "de"}, "file": "104323507", "label": "particle-nl-be", "order": "sorting", "shape": null, "text": "Sarasa, Alfonsus Antonius de"} {"composed": false, "fields": {"given": "Advayavajra"}, "file": "1047508877", "label": "direct-eu", "order": "givenFirst", "shape": null, "text": "Advayavajra"} {"composed": false, "fields": {"given": "Sirius"}, "file": "1048023907", "label": "direct-eu", "order": "givenFirst", "shape": null, "text": "Sirius"} {"composed": true, "fields": {"given": "Ricardo", "surname": "Valderrama Fernández"}, "file": "1048644847", "label": "compound-latam", "order": "givenFirst", "shape": null, "text": "Ricardo Valderrama Fernández"} {"composed": false, "fields": {"given": "Ricardo", "surname": "Valderrama Fernández"}, "file": "1048644847", "label": "compound-latam", "order": "sorting", "shape": null, "text": "Valderrama Fernández, Ricardo"} {"composed": false, "fields": {"given": "Amha Selassie I."}, "file": "1050400062", "label": "direct-et-mm", "order": "givenFirst", "shape": null, "text": "Amha Selassie I."} {"composed": true, "fields": {"given": "Marie Louise Fernande", "surname-core": "Montpensier", "surname-prefix": "de"}, "file": "1050420527", "label": "particle-iberia", "order": "givenFirst", "shape": null, "text": "Marie Louise Fernande de Montpensier"} {"composed": false, "fields": {"given": "Marie Louise Fernande", "surname-core": "Montpensier", "surname-prefix": "de"}, "file": "1050420527", "label": "particle-iberia", "order": "sorting", "shape": null, "text": "Montpensier, Marie Louise Fernande de"} {"composed": true, "fields": {"given": "F.", "surname-core": "Lange", "surname-prefix": "de"}, "file": "1050991397", "label": "particle-nl-be", "order": "givenFirst", "shape": null, "text": "F. de Lange"} {"composed": false, "fields": {"given": "F.", "surname-core": "Lange", "surname-prefix": "de"}, "file": "1050991397", "label": "particle-nl-be", "order": "sorting", "shape": null, "text": "Lange, F. de"} {"composed": false, "fields": {"given": "Drífa Viðar"}, "file": "1051922143", "label": "direct-is", "order": "givenFirst", "shape": null, "text": "Drífa Viðar"} {"composed": false, "fields": {"given": "Oddur Björnsson"}, "file": "1051927471", "label": "direct-is", "order": "givenFirst", "shape": null, "text": "Oddur Björnsson"} {"composed": true, "fields": {"given": "Tomás Antonio", "surname": "de la Cerda y Aragón"}, "file": "1053081987", "label": "particle-iberia", "order": "givenFirst", "shape": null, "text": "Tomás Antonio de la Cerda y Aragón"} {"composed": false, "fields": {"given": "Tomás Antonio", "surname": "de la Cerda y Aragón"}, "file": "1053081987", "label": "particle-iberia", "order": "sorting", "shape": null, "text": "Cerda y Aragón, Tomás Antonio de la"} {"composed": true, "fields": {"given": "Ignacio", "surname": "López Rayón"}, "file": "1053275307", "label": "compound-latam", "order": "givenFirst", "shape": null, "text": "Ignacio López Rayón"} {"composed": false, "fields": {"given": "Ignacio", "surname": "López Rayón"}, "file": "1053275307", "label": "compound-latam", "order": "sorting", "shape": null, "text": "López Rayón, Ignacio"} {"composed": true, "fields": {"generation": "III.", "given": "Håkon"}, "file": "1053284357", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Håkon III."} {"composed": true, "fields": {"given": "Johann Adolf", "surname-core": "Krosigk", "surname-prefix": "von"}, "file": "1053459807", "label": "particle-de-at", "order": "givenFirst", "shape": null, "text": "Johann Adolf von Krosigk"} {"composed": false, "fields": {"given": "Johann Adolf", "surname-core": "Krosigk", "surname-prefix": "von"}, "file": "1053459807", "label": "particle-de-at", "order": "sorting", "shape": null, "text": "Krosigk, Johann Adolf von"} {"composed": true, "fields": {"given": "Louis", "surname-core": "Grandmaison", "surname-prefix": "de"}, "file": "1055400257", "label": "particle-fr", "order": "givenFirst", "shape": null, "text": "Louis de Grandmaison"} {"composed": false, "fields": {"given": "Louis", "surname-core": "Grandmaison", "surname-prefix": "de"}, "file": "1055400257", "label": "particle-fr", "order": "sorting", "shape": null, "text": "Grandmaison, Louis de"} {"composed": true, "fields": {"given": "Rodrigo", "surname": "Arenas Betancourt"}, "file": "1056353457", "label": "compound-latam", "order": "givenFirst", "shape": null, "text": "Rodrigo Arenas Betancourt"} {"composed": false, "fields": {"given": "Rodrigo", "surname": "Arenas Betancourt"}, "file": "1056353457", "label": "compound-latam", "order": "sorting", "shape": null, "text": "Arenas Betancourt, Rodrigo"} {"composed": true, "fields": {"given": "Lorenzo di Pierfranceso", "surname": "de'Medici"}, "file": "1056403667", "label": "particle-it", "order": "givenFirst", "shape": null, "text": "Lorenzo di Pierfranceso de'Medici"} {"composed": false, "fields": {"given": "Lorenzo di Pierfranceso", "surname-core": "Medici", "surname-prefix": "de'"}, "file": "1056403667", "label": "particle-it", "order": "sorting", "shape": null, "text": "Medici, Lorenzo di Pierfranceso de'"} {"composed": true, "fields": {"given": "Fernando", "surname": "González Gortázar"}, "file": "1056425067", "label": "compound-latam", "order": "givenFirst", "shape": null, "text": "Fernando González Gortázar"} {"composed": false, "fields": {"given": "Fernando", "surname": "González Gortázar"}, "file": "1056425067", "label": "compound-latam", "order": "sorting", "shape": null, "text": "González Gortázar, Fernando"} {"composed": true, "fields": {"given": "Mariano", "surname": "Ospina Pérez"}, "file": "1056478497", "label": "compound-latam", "order": "givenFirst", "shape": null, "text": "Mariano Ospina Pérez"} {"composed": false, "fields": {"given": "Mariano", "surname": "Ospina Pérez"}, "file": "1056478497", "label": "compound-latam", "order": "sorting", "shape": null, "text": "Ospina Pérez, Mariano"} {"composed": true, "fields": {"given": "Mario", "surname": "Ruiz Armengol"}, "file": "1056652217", "label": "compound-latam", "order": "givenFirst", "shape": null, "text": "Mario Ruiz Armengol"} {"composed": false, "fields": {"given": "Mario", "surname": "Ruiz Armengol"}, "file": "1056652217", "label": "compound-latam", "order": "sorting", "shape": null, "text": "Ruiz Armengol, Mario"} {"composed": true, "fields": {"given": "Markus", "surname-core": "Wickenburg", "surname-prefix": "von"}, "file": "1058419307", "label": "particle-de-at", "order": "givenFirst", "shape": null, "text": "Markus von Wickenburg"} {"composed": false, "fields": {"given": "Markus", "surname-core": "Wickenburg", "surname-prefix": "von"}, "file": "1058419307", "label": "particle-de-at", "order": "sorting", "shape": null, "text": "Wickenburg, Markus von"} {"composed": false, "fields": {"given": "Renaud de Roucy"}, "file": "1059795337", "label": "direct-eu", "order": "givenFirst", "shape": null, "text": "Renaud de Roucy"} {"composed": false, "fields": {"given": "Zaid Ibn-Ḥārīṯa"}, "file": "1061106225", "label": "direct-arab", "order": "givenFirst", "shape": null, "text": "Zaid Ibn-Ḥārīṯa"} {"composed": true, "fields": {"given": "Dietrich", "surname-core": "Theobald", "surname-prefix": "von"}, "file": "1061287637", "label": "particle-fr", "order": "givenFirst", "shape": null, "text": "Dietrich von Theobald"} {"composed": false, "fields": {"given": "Dietrich", "surname-core": "Theobald", "surname-prefix": "von"}, "file": "1061287637", "label": "particle-fr", "order": "sorting", "shape": null, "text": "Theobald, Dietrich von"} {"composed": false, "fields": {"given": "Sigríður Hagalín"}, "file": "1061494667", "label": "direct-is", "order": "givenFirst", "shape": null, "text": "Sigríður Hagalín"} {"composed": true, "fields": {"given": "Teresa", "surname": "Mañé i Miravet"}, "file": "1065779747", "label": "compound-iberia", "order": "givenFirst", "shape": null, "text": "Teresa Mañé i Miravet"} {"composed": false, "fields": {"given": "Teresa", "surname": "Mañé i Miravet"}, "file": "1065779747", "label": "compound-iberia", "order": "sorting", "shape": null, "text": "Mañé i Miravet, Teresa"} {"composed": false, "fields": {"given": "Danarto"}, "file": "1066418039", "label": "direct-my-id", "order": "givenFirst", "shape": null, "text": "Danarto"} {"composed": false, "fields": {"given": "Mohamad Asri Muda"}, "file": "1066832900", "label": "direct-my-id", "order": "givenFirst", "shape": null, "text": "Mohamad Asri Muda"} {"composed": true, "fields": {"generation": "I.", "given": "Ulrich"}, "file": "1074956427", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Ulrich I."} {"composed": false, "fields": {"given": "Jurk"}, "file": "107740907", "label": "direct-eu", "order": "givenFirst", "shape": null, "text": "Jurk"} {"composed": false, "fields": {"given": "Simeon bar Sabbaʿe"}, "file": "1077612168", "label": "direct-arab", "order": "givenFirst", "shape": null, "text": "Simeon bar Sabbaʿe"} {"composed": true, "fields": {"given": "Piet", "surname-core": "Engelen", "surname-prefix": "van"}, "file": "1078168407", "label": "particle-nl-be", "order": "givenFirst", "shape": null, "text": "Piet van Engelen"} {"composed": false, "fields": {"given": "Piet", "surname-core": "Engelen", "surname-prefix": "van"}, "file": "1078168407", "label": "particle-nl-be", "order": "sorting", "shape": null, "text": "Engelen, Piet van"} {"composed": true, "fields": {"given": "James", "surname": "Muspratt"}, "file": "1078513007", "label": "plain-anglo", "order": "givenFirst", "shape": null, "text": "James Muspratt"} {"composed": false, "fields": {"given": "James", "surname": "Muspratt"}, "file": "1078513007", "label": "plain-anglo", "order": "sorting", "shape": null, "text": "Muspratt, James"} {"composed": false, "fields": {"given": "Simkoyê Şikak"}, "file": "1081653337", "label": "direct-ir-af", "order": "givenFirst", "shape": null, "text": "Simkoyê Şikak"} {"composed": true, "fields": {"given": "Claude", "surname": "d'Annebault"}, "file": "1081737697", "label": "particle-elided", "order": "givenFirst", "shape": null, "text": "Claude d'Annebault"} {"composed": false, "fields": {"given": "Claude", "surname-core": "Annebault", "surname-prefix": "d'"}, "file": "1081737697", "label": "particle-elided", "order": "sorting", "shape": null, "text": "Annebault, Claude d'"} {"composed": true, "fields": {"given": "Luis", "surname": "Van Rooten"}, "file": "1082774367", "label": "compound-latam", "order": "givenFirst", "shape": null, "text": "Luis Van Rooten"} {"composed": false, "fields": {"given": "Luis", "surname": "Van Rooten"}, "file": "1082774367", "label": "compound-latam", "order": "sorting", "shape": null, "text": "Van Rooten, Luis"} {"composed": true, "fields": {"given": "Clorinda", "surname": "Málaga de Prado"}, "file": "1083655477", "label": "compound-latam", "order": "givenFirst", "shape": null, "text": "Clorinda Málaga de Prado"} {"composed": false, "fields": {"given": "Clorinda", "surname": "Málaga de Prado"}, "file": "1083655477", "label": "compound-latam", "order": "sorting", "shape": null, "text": "Málaga de Prado, Clorinda"} {"composed": true, "fields": {"given": "Karl", "surname-core": "Struve", "surname-prefix": "von"}, "file": "1084023547", "label": "particle-nl-be", "order": "givenFirst", "shape": null, "text": "Karl von Struve"} {"composed": false, "fields": {"given": "Karl", "surname-core": "Struve", "surname-prefix": "von"}, "file": "1084023547", "label": "particle-nl-be", "order": "sorting", "shape": null, "text": "Struve, Karl von"} {"composed": false, "fields": {"given": "Pierre-Louis"}, "file": "1093196017", "label": "direct-eu", "order": "givenFirst", "shape": null, "text": "Pierre-Louis"} {"composed": true, "fields": {"given": "Juan", "surname": "Sordo Madaleno"}, "file": "1100729747", "label": "compound-latam", "order": "givenFirst", "shape": null, "text": "Juan Sordo Madaleno"} {"composed": false, "fields": {"given": "Juan", "surname": "Sordo Madaleno"}, "file": "1100729747", "label": "compound-latam", "order": "sorting", "shape": null, "text": "Sordo Madaleno, Juan"} {"composed": false, "fields": {"given": "Malik Ambar"}, "file": "110685716X", "label": "direct-et-mm", "order": "givenFirst", "shape": null, "text": "Malik Ambar"} {"composed": false, "fields": {"given": "Shunichi", "surname": "Matsumoto"}, "file": "1107650607", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Matsumoto, Shunichi"} {"composed": false, "fields": {"given": "Bonvi"}, "file": "110883187", "label": "direct-eu", "order": "givenFirst", "shape": null, "text": "Bonvi"} {"composed": true, "fields": {"given": "Ḥusnī", "surname": "az-Zaʿīm"}, "file": "1112629513", "label": "particle-arab", "order": "givenFirst", "shape": null, "text": "Ḥusnī az-Zaʿīm"} {"composed": false, "fields": {"given": "Ḥusnī", "surname-core": "Zaʿīm", "surname-prefix": "az-"}, "file": "1112629513", "label": "particle-arab", "order": "sorting", "shape": null, "text": "Zaʿīm, Ḥusnī az-"} {"composed": true, "fields": {"given": "Paul", "surname": "d'Ivoi"}, "file": "111277957", "label": "particle-fr", "order": "givenFirst", "shape": null, "text": "Paul d'Ivoi"} {"composed": false, "fields": {"given": "Paul", "surname-core": "Ivoi", "surname-prefix": "d'"}, "file": "111277957", "label": "particle-fr", "order": "sorting", "shape": null, "text": "Ivoi, Paul d'"} {"composed": false, "fields": {"given": "Yūsuf al-ʿAẓma"}, "file": "1115673882", "label": "direct-arab", "order": "givenFirst", "shape": null, "text": "Yūsuf al-ʿAẓma"} {"composed": true, "fields": {"given": "Anton", "surname-core": "Tschurtschenthaler", "surname-prefix": "von"}, "file": "1120625947", "label": "particle-it", "order": "givenFirst", "shape": null, "text": "Anton von Tschurtschenthaler"} {"composed": false, "fields": {"given": "Anton", "surname-core": "Tschurtschenthaler", "surname-prefix": "von"}, "file": "1120625947", "label": "particle-it", "order": "sorting", "shape": null, "text": "Tschurtschenthaler, Anton von"} {"composed": true, "fields": {"given": "Luís", "surname-core": "Ataíde", "surname-prefix": "de"}, "file": "1121111637", "label": "particle-iberia", "order": "givenFirst", "shape": null, "text": "Luís de Ataíde"} {"composed": false, "fields": {"given": "Luís", "surname-core": "Ataíde", "surname-prefix": "de"}, "file": "1121111637", "label": "particle-iberia", "order": "sorting", "shape": null, "text": "Ataíde, Luís de"} {"composed": false, "fields": {"given": "Alexis"}, "file": "112146147", "label": "direct-eu", "order": "givenFirst", "shape": null, "text": "Alexis"} {"composed": true, "fields": {"given": "José", "surname": "López Domínguez"}, "file": "1123958947", "label": "compound-iberia", "order": "givenFirst", "shape": null, "text": "José López Domínguez"} {"composed": false, "fields": {"given": "José", "surname": "López Domínguez"}, "file": "1123958947", "label": "compound-iberia", "order": "sorting", "shape": null, "text": "López Domínguez, José"} {"composed": false, "fields": {"given": "Khaled el-Hassani ben el-Hachemi"}, "file": "1124785817", "label": "direct-arab", "order": "givenFirst", "shape": null, "text": "Khaled el-Hassani ben el-Hachemi"} {"composed": true, "fields": {"given": "Teodoro", "surname-core": "Faria", "surname-prefix": "de"}, "file": "1127072587", "label": "particle-iberia", "order": "givenFirst", "shape": null, "text": "Teodoro de Faria"} {"composed": false, "fields": {"given": "Teodoro", "surname-core": "Faria", "surname-prefix": "de"}, "file": "1127072587", "label": "particle-iberia", "order": "sorting", "shape": null, "text": "Faria, Teodoro de"} {"composed": false, "fields": {"given": "Stefán Hörður Grímsson"}, "file": "112712797", "label": "direct-is", "order": "givenFirst", "shape": null, "text": "Stefán Hörður Grímsson"} {"composed": false, "fields": {"given": "Makonnen Haile Selassie"}, "file": "1129925617", "label": "direct-et-mm", "order": "givenFirst", "shape": null, "text": "Makonnen Haile Selassie"} {"composed": true, "fields": {"given": "Clarice", "surname": "de'Medici"}, "file": "1130808467", "label": "particle-it", "order": "givenFirst", "shape": null, "text": "Clarice de'Medici"} {"composed": false, "fields": {"given": "Clarice", "surname-core": "Medici", "surname-prefix": "de'"}, "file": "1130808467", "label": "particle-it", "order": "sorting", "shape": null, "text": "Medici, Clarice de'"} {"composed": false, "fields": {"given": "Xirui", "surname": "Pi"}, "file": "1131897307", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Pi, Xirui"} {"composed": true, "fields": {"given": "Domingo", "surname-core": "Iriarte", "surname-prefix": "de"}, "file": "1135675287", "label": "particle-iberia", "order": "givenFirst", "shape": null, "text": "Domingo de Iriarte"} {"composed": false, "fields": {"given": "Domingo", "surname-core": "Iriarte", "surname-prefix": "de"}, "file": "1135675287", "label": "particle-iberia", "order": "sorting", "shape": null, "text": "Iriarte, Domingo de"} {"composed": true, "fields": {"given": "Pleikard Dietrich", "surname-core": "Gemmingen", "surname-prefix": "von"}, "file": "1136030107", "label": "particle-de-at", "order": "givenFirst", "shape": null, "text": "Pleikard Dietrich von Gemmingen"} {"composed": false, "fields": {"given": "Pleikard Dietrich", "surname-core": "Gemmingen", "surname-prefix": "von"}, "file": "1136030107", "label": "particle-de-at", "order": "sorting", "shape": null, "text": "Gemmingen, Pleikard Dietrich von"} {"composed": false, "fields": {"given": "Magnús Eiríksson"}, "file": "1138377546", "label": "direct-is", "order": "givenFirst", "shape": null, "text": "Magnús Eiríksson"} {"composed": true, "fields": {"given": "Fuʾād", "surname": "ar-Rikābī"}, "file": "1139846442", "label": "particle-arab", "order": "givenFirst", "shape": null, "text": "Fuʾād ar-Rikābī"} {"composed": false, "fields": {"given": "Fuʾād", "surname-core": "Rikābī", "surname-prefix": "ar-"}, "file": "1139846442", "label": "particle-arab", "order": "sorting", "shape": null, "text": "Rikābī, Fuʾād ar-"} {"composed": false, "fields": {"given": "ʿAbd al-ʿAzīz Ibn Marwān"}, "file": "1143145518", "label": "direct-arab", "order": "givenFirst", "shape": null, "text": "ʿAbd al-ʿAzīz Ibn Marwān"} {"composed": false, "fields": {"given": "Abd al-Aziz ibn Muhammad"}, "file": "1143970381", "label": "direct-arab", "order": "givenFirst", "shape": null, "text": "Abd al-Aziz ibn Muhammad"} {"composed": true, "fields": {"given": "Abraham", "surname-core": "Hecken", "surname-prefix": "van den"}, "file": "1145158137", "label": "particle-nl-be", "order": "givenFirst", "shape": null, "text": "Abraham van den Hecken"} {"composed": false, "fields": {"given": "Abraham", "surname-core": "Hecken", "surname-prefix": "van den"}, "file": "1145158137", "label": "particle-nl-be", "order": "sorting", "shape": null, "text": "Hecken, Abraham van den"} {"composed": false, "fields": {"given": "Tadsch ad-Din al-Hasani"}, "file": "1145191851", "label": "direct-arab", "order": "givenFirst", "shape": null, "text": "Tadsch ad-Din al-Hasani"} {"composed": true, "fields": {"given": "Emmanuel", "surname": "d'Hooghvorst"}, "file": "1147042357", "label": "particle-elided", "order": "givenFirst", "shape": null, "text": "Emmanuel d'Hooghvorst"} {"composed": false, "fields": {"given": "Emmanuel", "surname-core": "Hooghvorst", "surname-prefix": "d'"}, "file": "1147042357", "label": "particle-elided", "order": "sorting", "shape": null, "text": "Hooghvorst, Emmanuel d'"} {"composed": true, "fields": {"given": "Edmond", "surname": "de La Croix"}, "file": "1147237387", "label": "particle-iberia", "order": "givenFirst", "shape": null, "text": "Edmond de La Croix"} {"composed": false, "fields": {"given": "Edmond", "surname": "de La Croix"}, "file": "1147237387", "label": "particle-iberia", "order": "sorting", "shape": null, "text": "La Croix, Edmond de"} {"composed": false, "fields": {"given": "Mohamed Khir Johari"}, "file": "1147649030", "label": "direct-my-id", "order": "givenFirst", "shape": null, "text": "Mohamed Khir Johari"} {"composed": true, "fields": {"given": "Johann", "surname": "Klingenberg"}, "file": "1150683007", "label": "plain-europe", "order": "givenFirst", "shape": null, "text": "Johann Klingenberg"} {"composed": false, "fields": {"given": "Johann", "surname": "Klingenberg"}, "file": "1150683007", "label": "plain-europe", "order": "sorting", "shape": null, "text": "Klingenberg, Johann"} {"composed": true, "fields": {"given": "Herman", "surname-core": "Cunsel", "surname-prefix": "de"}, "file": "1154414957", "label": "particle-nl-be", "order": "givenFirst", "shape": null, "text": "Herman de Cunsel"} {"composed": false, "fields": {"given": "Herman", "surname-core": "Cunsel", "surname-prefix": "de"}, "file": "1154414957", "label": "particle-nl-be", "order": "sorting", "shape": null, "text": "Cunsel, Herman de"} {"composed": true, "fields": {"given": "Habib Pacha", "surname": "es-Saad"}, "file": "1155661249", "label": "particle-arab", "order": "givenFirst", "shape": null, "text": "Habib Pacha es-Saad"} {"composed": false, "fields": {"given": "Habib Pacha", "surname-core": "Saad", "surname-prefix": "es-"}, "file": "1155661249", "label": "particle-arab", "order": "sorting", "shape": null, "text": "Saad, Habib Pacha es-"} {"composed": false, "fields": {"given": "Vossug ed Dowleh"}, "file": "1155767624", "label": "direct-ir-af", "order": "givenFirst", "shape": null, "text": "Vossug ed Dowleh"} {"composed": false, "fields": {"given": "Thorkild Fjeldsted"}, "file": "1157689973", "label": "direct-is", "order": "givenFirst", "shape": null, "text": "Thorkild Fjeldsted"} {"composed": true, "fields": {"given": "José María", "surname": "Campo Serrano"}, "file": "1160043817", "label": "compound-latam", "order": "givenFirst", "shape": null, "text": "José María Campo Serrano"} {"composed": false, "fields": {"given": "José María", "surname": "Campo Serrano"}, "file": "1160043817", "label": "compound-latam", "order": "sorting", "shape": null, "text": "Campo Serrano, José María"} {"composed": true, "fields": {"given": "Antonius", "surname-core": "Dale", "surname-prefix": "van"}, "file": "116016337", "label": "particle-nl-be", "order": "givenFirst", "shape": null, "text": "Antonius van Dale"} {"composed": false, "fields": {"given": "Antonius", "surname-core": "Dale", "surname-prefix": "van"}, "file": "116016337", "label": "particle-nl-be", "order": "sorting", "shape": null, "text": "Dale, Antonius van"} {"composed": true, "fields": {"given": "Willem Antonie", "surname-core": "Deventer", "surname-prefix": "van"}, "file": "116088427", "label": "particle-nl-be", "order": "givenFirst", "shape": null, "text": "Willem Antonie van Deventer"} {"composed": false, "fields": {"given": "Willem Antonie", "surname-core": "Deventer", "surname-prefix": "van"}, "file": "116088427", "label": "particle-nl-be", "order": "sorting", "shape": null, "text": "Deventer, Willem Antonie van"} {"composed": true, "fields": {"given": "Hermann", "surname-core": "Solms-Hohensolms-Lich", "surname-prefix": "zu"}, "file": "1161432507", "label": "particle-de-at", "order": "givenFirst", "shape": null, "text": "Hermann zu Solms-Hohensolms-Lich"} {"composed": false, "fields": {"given": "Hermann", "surname-core": "Solms-Hohensolms-Lich", "surname-prefix": "zu"}, "file": "1161432507", "label": "particle-de-at", "order": "sorting", "shape": null, "text": "Solms-Hohensolms-Lich, Hermann zu"} {"composed": true, "fields": {"given": "Johann", "surname-core": "Dumreicher", "surname-prefix": "von"}, "file": "116251107", "label": "particle-de-at", "order": "givenFirst", "shape": null, "text": "Johann von Dumreicher"} {"composed": false, "fields": {"given": "Johann", "surname-core": "Dumreicher", "surname-prefix": "von"}, "file": "116251107", "label": "particle-de-at", "order": "sorting", "shape": null, "text": "Dumreicher, Johann von"} {"composed": true, "fields": {"given": "Jaime", "surname": "Guardia Neyra"}, "file": "1163022667", "label": "compound-latam", "order": "givenFirst", "shape": null, "text": "Jaime Guardia Neyra"} {"composed": false, "fields": {"given": "Jaime", "surname": "Guardia Neyra"}, "file": "1163022667", "label": "compound-latam", "order": "sorting", "shape": null, "text": "Guardia Neyra, Jaime"} {"composed": true, "fields": {"given": "Francisco Inácio", "surname": "de Carvalho Moreira"}, "file": "1163326437", "label": "particle-iberia", "order": "givenFirst", "shape": null, "text": "Francisco Inácio de Carvalho Moreira"} {"composed": false, "fields": {"given": "Francisco Inácio", "surname": "de Carvalho Moreira"}, "file": "1163326437", "label": "particle-iberia", "order": "sorting", "shape": null, "text": "Carvalho Moreira, Francisco Inácio de"} {"composed": false, "fields": {"given": "Sigfús Einarsson"}, "file": "116424702", "label": "direct-is", "order": "givenFirst", "shape": null, "text": "Sigfús Einarsson"} {"composed": true, "fields": {"given": "Jean-Dominique", "surname-core": "Cassini", "surname-prefix": "de"}, "file": "116470887", "label": "particle-fr", "order": "givenFirst", "shape": null, "text": "Jean-Dominique de Cassini"} {"composed": false, "fields": {"given": "Jean-Dominique", "surname-core": "Cassini", "surname-prefix": "de"}, "file": "116470887", "label": "particle-fr", "order": "sorting", "shape": null, "text": "Cassini, Jean-Dominique de"} {"composed": true, "fields": {"given": "August", "surname-core": "Cetto", "surname-prefix": "von"}, "file": "116480807", "label": "particle-de-at", "order": "givenFirst", "shape": null, "text": "August von Cetto"} {"composed": false, "fields": {"given": "August", "surname-core": "Cetto", "surname-prefix": "von"}, "file": "116480807", "label": "particle-de-at", "order": "sorting", "shape": null, "text": "Cetto, August von"} {"composed": true, "fields": {"given": "Wolf", "surname-core": "Engelhardt", "surname-prefix": "von"}, "file": "116496207", "label": "particle-de-at", "order": "givenFirst", "shape": null, "text": "Wolf von Engelhardt"} {"composed": false, "fields": {"given": "Wolf", "surname-core": "Engelhardt", "surname-prefix": "von"}, "file": "116496207", "label": "particle-de-at", "order": "sorting", "shape": null, "text": "Engelhardt, Wolf von"} {"composed": true, "fields": {"given": "Gottfried Huyn", "surname": "von Geleen und Amsterad"}, "file": "116522607", "label": "particle-de-at", "order": "givenFirst", "shape": null, "text": "Gottfried Huyn von Geleen und Amsterad"} {"composed": false, "fields": {"given": "Gottfried Huyn", "surname": "von Geleen und Amsterad"}, "file": "116522607", "label": "particle-de-at", "order": "sorting", "shape": null, "text": "Geleen und Amsterad, Gottfried Huyn von"} {"composed": false, "fields": {"given": "Jón Magnússon"}, "file": "1165817241", "label": "direct-is", "order": "givenFirst", "shape": null, "text": "Jón Magnússon"} {"composed": true, "fields": {"given": "Heinz", "surname": "Kägebein"}, "file": "1166467007", "label": "plain-europe", "order": "givenFirst", "shape": null, "text": "Heinz Kägebein"} {"composed": false, "fields": {"given": "Heinz", "surname": "Kägebein"}, "file": "1166467007", "label": "plain-europe", "order": "sorting", "shape": null, "text": "Kägebein, Heinz"} {"composed": false, "fields": {"given": "Vilmos", "surname": "Fraknói"}, "file": "116692537", "label": "index-hu", "order": "sorting", "shape": null, "text": "Fraknói, Vilmos"} {"composed": true, "fields": {"given": "Heinz", "surname-core": "Cramer", "surname-prefix": "von"}, "file": "116709197", "label": "particle-it", "order": "givenFirst", "shape": null, "text": "Heinz von Cramer"} {"composed": false, "fields": {"given": "Heinz", "surname-core": "Cramer", "surname-prefix": "von"}, "file": "116709197", "label": "particle-it", "order": "sorting", "shape": null, "text": "Cramer, Heinz von"} {"composed": true, "fields": {"given": "Marie Laure", "surname-core": "Noailles", "surname-prefix": "de"}, "file": "116755687", "label": "particle-fr", "order": "givenFirst", "shape": null, "text": "Marie Laure de Noailles"} {"composed": false, "fields": {"given": "Marie Laure", "surname-core": "Noailles", "surname-prefix": "de"}, "file": "116755687", "label": "particle-fr", "order": "sorting", "shape": null, "text": "Noailles, Marie Laure de"} {"composed": true, "fields": {"given": "Martin Pierre", "surname": "Marsick"}, "file": "116793007", "label": "plain-anglo", "order": "givenFirst", "shape": null, "text": "Martin Pierre Marsick"} {"composed": false, "fields": {"given": "Martin Pierre", "surname": "Marsick"}, "file": "116793007", "label": "plain-anglo", "order": "sorting", "shape": null, "text": "Marsick, Martin Pierre"} {"composed": false, "fields": {"given": "Robert", "surname": "Gragger"}, "file": "116819987", "label": "index-hu", "order": "sorting", "shape": null, "text": "Gragger, Robert"} {"composed": true, "fields": {"given": "Sophie", "surname-core": "Merenberg", "surname-prefix": "von"}, "file": "116891807", "label": "particle-de-at", "order": "givenFirst", "shape": null, "text": "Sophie von Merenberg"} {"composed": false, "fields": {"given": "Sophie", "surname-core": "Merenberg", "surname-prefix": "von"}, "file": "116891807", "label": "particle-de-at", "order": "sorting", "shape": null, "text": "Merenberg, Sophie von"} {"composed": true, "fields": {"given": "Georg", "surname-core": "Hülsen-Haeseler", "surname-prefix": "von"}, "file": "117049107", "label": "particle-de-at", "order": "givenFirst", "shape": null, "text": "Georg von Hülsen-Haeseler"} {"composed": false, "fields": {"given": "Georg", "surname-core": "Hülsen-Haeseler", "surname-prefix": "von"}, "file": "117049107", "label": "particle-de-at", "order": "sorting", "shape": null, "text": "Hülsen-Haeseler, Georg von"} {"composed": true, "fields": {"given": "Albrecht", "surname-core": "Alvensleben", "surname-prefix": "von"}, "file": "1171008007", "label": "particle-de-at", "order": "givenFirst", "shape": null, "text": "Albrecht von Alvensleben"} {"composed": false, "fields": {"given": "Albrecht", "surname-core": "Alvensleben", "surname-prefix": "von"}, "file": "1171008007", "label": "particle-de-at", "order": "sorting", "shape": null, "text": "Alvensleben, Albrecht von"} {"composed": true, "fields": {"given": "Friedrich Ludwig", "surname-core": "Waldburg-Truchsess", "surname-prefix": "von"}, "file": "117117587", "label": "particle-it", "order": "givenFirst", "shape": null, "text": "Friedrich Ludwig von Waldburg-Truchsess"} {"composed": false, "fields": {"given": "Friedrich Ludwig", "surname-core": "Waldburg-Truchsess", "surname-prefix": "von"}, "file": "117117587", "label": "particle-it", "order": "sorting", "shape": null, "text": "Waldburg-Truchsess, Friedrich Ludwig von"} {"composed": false, "fields": {"given": "Sein Lwin"}, "file": "117126089X", "label": "direct-et-mm", "order": "givenFirst", "shape": null, "text": "Sein Lwin"} {"composed": true, "fields": {"given": "Frances", "surname-core": "Bunsen", "surname-prefix": "von"}, "file": "117157007", "label": "particle-de-at", "order": "givenFirst", "shape": null, "text": "Frances von Bunsen"} {"composed": false, "fields": {"given": "Frances", "surname-core": "Bunsen", "surname-prefix": "von"}, "file": "117157007", "label": "particle-de-at", "order": "sorting", "shape": null, "text": "Bunsen, Frances von"} {"composed": true, "fields": {"given": "Juan Antonio", "surname": "Rascón Navarro Seña y Redondo"}, "file": "1172012407", "label": "compound-iberia", "order": "givenFirst", "shape": null, "text": "Juan Antonio Rascón Navarro Seña y Redondo"} {"composed": false, "fields": {"given": "Juan Antonio", "surname": "Rascón Navarro Seña y Redondo"}, "file": "1172012407", "label": "compound-iberia", "order": "sorting", "shape": null, "text": "Rascón Navarro Seña y Redondo, Juan Antonio"} {"composed": true, "fields": {"given": "Julius", "surname": "van Zuylen van Nijevelt"}, "file": "1172046697", "label": "particle-nl-be", "order": "givenFirst", "shape": null, "text": "Julius van Zuylen van Nijevelt"} {"composed": false, "fields": {"given": "Julius", "surname": "van Zuylen van Nijevelt"}, "file": "1172046697", "label": "particle-nl-be", "order": "sorting", "shape": null, "text": "Zuylen van Nijevelt, Julius van"} {"composed": false, "fields": {"given": "Ahmad Shah Al-Musta’in Billah ibni Almarhum Sultan Abu Bakar Riayatuddin Al-Mu’azzam Shah"}, "file": "1172113386", "label": "direct-my-id", "order": "givenFirst", "shape": null, "text": "Ahmad Shah Al-Musta’in Billah ibni Almarhum Sultan Abu Bakar Riayatuddin Al-Mu’azzam Shah"} {"composed": false, "fields": {"given": "Hugo", "surname": "Weigold"}, "file": "117249807", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Weigold, Hugo"} {"composed": true, "fields": {"given": "David zu Sonnegg", "surname": "von Ungnad von Weissenwolf"}, "file": "117310107", "label": "particle-de-at", "order": "givenFirst", "shape": null, "text": "David zu Sonnegg von Ungnad von Weissenwolf"} {"composed": false, "fields": {"given": "David zu Sonnegg", "surname": "von Ungnad von Weissenwolf"}, "file": "117310107", "label": "particle-de-at", "order": "sorting", "shape": null, "text": "Ungnad von Weissenwolf, David zu Sonnegg von"} {"composed": true, "fields": {"given": "Herma", "surname": "Studeny"}, "file": "117354007", "label": "plain-europe", "order": "givenFirst", "shape": null, "text": "Herma Studeny"} {"composed": false, "fields": {"given": "Herma", "surname": "Studeny"}, "file": "117354007", "label": "plain-europe", "order": "sorting", "shape": null, "text": "Studeny, Herma"} {"composed": true, "fields": {"given": "Richard", "surname-core": "Süßkind-Schwendi", "surname-prefix": "von"}, "file": "117372307", "label": "particle-de-at", "order": "givenFirst", "shape": null, "text": "Richard von Süßkind-Schwendi"} {"composed": false, "fields": {"given": "Richard", "surname-core": "Süßkind-Schwendi", "surname-prefix": "von"}, "file": "117372307", "label": "particle-de-at", "order": "sorting", "shape": null, "text": "Süßkind-Schwendi, Richard von"} {"composed": true, "fields": {"given": "Jean Joseph Antoine Marie", "surname-core": "Witte", "surname-prefix": "de"}, "file": "117420557", "label": "particle-nl-be", "order": "givenFirst", "shape": null, "text": "Jean Joseph Antoine Marie de Witte"} {"composed": false, "fields": {"given": "Jean Joseph Antoine Marie", "surname-core": "Witte", "surname-prefix": "de"}, "file": "117420557", "label": "particle-nl-be", "order": "sorting", "shape": null, "text": "Witte, Jean Joseph Antoine Marie de"} {"composed": true, "fields": {"given": "Augustus P.", "surname": "Gardner"}, "file": "1174822007", "label": "plain-anglo", "order": "givenFirst", "shape": null, "text": "Augustus P. Gardner"} {"composed": false, "fields": {"given": "Augustus P.", "surname": "Gardner"}, "file": "1174822007", "label": "plain-anglo", "order": "sorting", "shape": null, "text": "Gardner, Augustus P."} {"composed": true, "fields": {"given": "Bella Cohen", "surname": "Spewack"}, "file": "117493007", "label": "plain-anglo", "order": "givenFirst", "shape": null, "text": "Bella Cohen Spewack"} {"composed": false, "fields": {"given": "Bella Cohen", "surname": "Spewack"}, "file": "117493007", "label": "plain-anglo", "order": "sorting", "shape": null, "text": "Spewack, Bella Cohen"} {"composed": false, "fields": {"given": "Hussein Kamil"}, "file": "117546712", "label": "direct-arab", "order": "givenFirst", "shape": null, "text": "Hussein Kamil"} {"composed": false, "fields": {"given": "Jinzo", "surname": "Matsumura"}, "file": "117557307", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Matsumura, Jinzo"} {"composed": true, "fields": {"given": "Elard", "surname-core": "Oldenburg-Januschau", "surname-prefix": "von"}, "file": "117593907", "label": "particle-de-at", "order": "givenFirst", "shape": null, "text": "Elard von Oldenburg-Januschau"} {"composed": false, "fields": {"given": "Elard", "surname-core": "Oldenburg-Januschau", "surname-prefix": "von"}, "file": "117593907", "label": "particle-de-at", "order": "sorting", "shape": null, "text": "Oldenburg-Januschau, Elard von"} {"composed": true, "fields": {"given": "Achille", "surname": "d'Orsi"}, "file": "117606227", "label": "particle-it", "order": "givenFirst", "shape": null, "text": "Achille d'Orsi"} {"composed": false, "fields": {"given": "Achille", "surname-core": "Orsi", "surname-prefix": "d'"}, "file": "117606227", "label": "particle-it", "order": "sorting", "shape": null, "text": "Orsi, Achille d'"} {"composed": true, "fields": {"given": "Giovanni Battista Gherardo", "surname": "d'Arco"}, "file": "117763497", "label": "particle-it", "order": "givenFirst", "shape": null, "text": "Giovanni Battista Gherardo d'Arco"} {"composed": false, "fields": {"given": "Giovanni Battista Gherardo", "surname-core": "Arco", "surname-prefix": "d'"}, "file": "117763497", "label": "particle-it", "order": "sorting", "shape": null, "text": "Arco, Giovanni Battista Gherardo d'"} {"composed": true, "fields": {"given": "Liberty Hyde", "surname": "Bailey"}, "file": "117765007", "label": "plain-anglo", "order": "givenFirst", "shape": null, "text": "Liberty Hyde Bailey"} {"composed": false, "fields": {"given": "Liberty Hyde", "surname": "Bailey"}, "file": "117765007", "label": "plain-anglo", "order": "sorting", "shape": null, "text": "Bailey, Liberty Hyde"} {"composed": false, "fields": {"given": "Finnur Jónsson"}, "file": "118049062", "label": "direct-is", "order": "givenFirst", "shape": null, "text": "Finnur Jónsson"} {"composed": true, "fields": {"given": "Renaat", "surname-core": "Elslande", "surname-prefix": "van"}, "file": "1180768477", "label": "particle-nl-be", "order": "givenFirst", "shape": null, "text": "Renaat van Elslande"} {"composed": false, "fields": {"given": "Renaat", "surname-core": "Elslande", "surname-prefix": "van"}, "file": "1180768477", "label": "particle-nl-be", "order": "sorting", "shape": null, "text": "Elslande, Renaat van"} {"composed": true, "fields": {"given": "Curt Ludwig", "surname-core": "Gienanth", "surname-prefix": "von"}, "file": "1183333307", "label": "particle-de-at", "order": "givenFirst", "shape": null, "text": "Curt Ludwig von Gienanth"} {"composed": false, "fields": {"given": "Curt Ludwig", "surname-core": "Gienanth", "surname-prefix": "von"}, "file": "1183333307", "label": "particle-de-at", "order": "sorting", "shape": null, "text": "Gienanth, Curt Ludwig von"} {"composed": false, "fields": {"given": "Abu-'l-Fidāʾ Ismāʿīl Ibn-ʿAlī"}, "file": "118500333", "label": "direct-arab", "order": "givenFirst", "shape": null, "text": "Abu-'l-Fidāʾ Ismāʿīl Ibn-ʿAlī"} {"composed": true, "fields": {"given": "Eugen", "surname": "d'Albert"}, "file": "118501437", "label": "particle-elided", "order": "givenFirst", "shape": null, "text": "Eugen d'Albert"} {"composed": false, "fields": {"given": "Eugen", "surname-core": "Albert", "surname-prefix": "d'"}, "file": "118501437", "label": "particle-elided", "order": "sorting", "shape": null, "text": "Albert, Eugen d'"} {"composed": true, "fields": {"generation": "VIII.", "given": "Bonifatius"}, "file": "118513257", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Bonifatius VIII."} {"composed": false, "fields": {"given": "Colette"}, "file": "118521527", "label": "direct-eu", "order": "givenFirst", "shape": null, "text": "Colette"} {"composed": true, "fields": {"given": "Heinrich", "surname": "Cunow"}, "file": "118523007", "label": "plain-europe", "order": "givenFirst", "shape": null, "text": "Heinrich Cunow"} {"composed": false, "fields": {"given": "Heinrich", "surname": "Cunow"}, "file": "118523007", "label": "plain-europe", "order": "sorting", "shape": null, "text": "Cunow, Heinrich"} {"composed": true, "fields": {"generation": "V.", "given": "Eduard"}, "file": "118528947", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Eduard V."} {"composed": true, "fields": {"given": "Miguel", "surname": "d'Escoto"}, "file": "118531107", "label": "particle-elided", "order": "givenFirst", "shape": null, "text": "Miguel d'Escoto"} {"composed": false, "fields": {"given": "Miguel", "surname-core": "Escoto", "surname-prefix": "d'"}, "file": "118531107", "label": "particle-elided", "order": "sorting", "shape": null, "text": "Escoto, Miguel d'"} {"composed": true, "fields": {"given": "Jan", "surname-core": "Eyck", "surname-prefix": "van"}, "file": "118531557", "label": "particle-nl-be", "order": "givenFirst", "shape": null, "text": "Jan van Eyck"} {"composed": false, "fields": {"given": "Jan", "surname-core": "Eyck", "surname-prefix": "van"}, "file": "118531557", "label": "particle-nl-be", "order": "sorting", "shape": null, "text": "Eyck, Jan van"} {"composed": true, "fields": {"generation": "I.", "given": "Franz"}, "file": "118534947", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Franz I."} {"composed": true, "fields": {"given": "Louis", "surname-core": "Funès", "surname-prefix": "de"}, "file": "118536877", "label": "particle-fr", "order": "givenFirst", "shape": null, "text": "Louis de Funès"} {"composed": false, "fields": {"given": "Louis", "surname-core": "Funès", "surname-prefix": "de"}, "file": "118536877", "label": "particle-fr", "order": "sorting", "shape": null, "text": "Funès, Louis de"} {"composed": true, "fields": {"given": "Claus", "surname": "Harms"}, "file": "118546007", "label": "plain-europe", "order": "givenFirst", "shape": null, "text": "Claus Harms"} {"composed": false, "fields": {"given": "Claus", "surname": "Harms"}, "file": "118546007", "label": "plain-europe", "order": "sorting", "shape": null, "text": "Harms, Claus"} {"composed": true, "fields": {"generation": "I.", "given": "László"}, "file": "118568647", "label": "regnal", "order": "givenFirst", "shape": null, "text": "László I."} {"composed": true, "fields": {"given": "José Luis", "surname": "Martín Descalzo"}, "file": "118578367", "label": "compound-iberia", "order": "givenFirst", "shape": null, "text": "José Luis Martín Descalzo"} {"composed": false, "fields": {"given": "José Luis", "surname": "Martín Descalzo"}, "file": "118578367", "label": "compound-iberia", "order": "sorting", "shape": null, "text": "Martín Descalzo, José Luis"} {"composed": true, "fields": {"given": "Šākir", "surname": "al-Faḥḥām"}, "file": "1185824316", "label": "particle-arab", "order": "givenFirst", "shape": null, "text": "Šākir al-Faḥḥām"} {"composed": false, "fields": {"given": "Šākir", "surname-core": "Faḥḥām", "surname-prefix": "al-"}, "file": "1185824316", "label": "particle-arab", "order": "sorting", "shape": null, "text": "Faḥḥām, Šākir al-"} {"composed": false, "fields": {"given": "Ngugi wa Thiong'o"}, "file": "118587587", "label": "direct-eu", "order": "givenFirst", "shape": null, "text": "Ngugi wa Thiong'o"} {"composed": true, "fields": {"given": "Ǧīhān", "surname": "as-Sādāt"}, "file": "118604740", "label": "particle-arab", "order": "givenFirst", "shape": null, "text": "Ǧīhān as-Sādāt"} {"composed": false, "fields": {"given": "Ǧīhān", "surname-core": "Sādāt", "surname-prefix": "as-"}, "file": "118604740", "label": "particle-arab", "order": "sorting", "shape": null, "text": "Sādāt, Ǧīhān as-"} {"composed": true, "fields": {"given": "Theodor", "surname-core": "Schön", "surname-prefix": "von"}, "file": "118610007", "label": "particle-de-at", "order": "givenFirst", "shape": null, "text": "Theodor von Schön"} {"composed": false, "fields": {"given": "Theodor", "surname-core": "Schön", "surname-prefix": "von"}, "file": "118610007", "label": "particle-de-at", "order": "sorting", "shape": null, "text": "Schön, Theodor von"} {"composed": false, "fields": {"given": "Sukarno"}, "file": "118619985", "label": "direct-my-id", "order": "givenFirst", "shape": null, "text": "Sukarno"} {"composed": false, "fields": {"given": "Gudina Tumsa"}, "file": "118624571", "label": "direct-et-mm", "order": "givenFirst", "shape": null, "text": "Gudina Tumsa"} {"composed": true, "fields": {"given": "Fritz", "surname-core": "Unruh", "surname-prefix": "von"}, "file": "118625527", "label": "particle-it", "order": "givenFirst", "shape": null, "text": "Fritz von Unruh"} {"composed": false, "fields": {"given": "Fritz", "surname-core": "Unruh", "surname-prefix": "von"}, "file": "118625527", "label": "particle-it", "order": "sorting", "shape": null, "text": "Unruh, Fritz von"} {"composed": false, "fields": {"given": "Zarathustra"}, "file": "118636227", "label": "direct-ir-af", "order": "givenFirst", "shape": null, "text": "Zarathustra"} {"composed": true, "fields": {"given": "Bernardino", "surname-core": "Sahagún", "surname-prefix": "de"}, "file": "118637827", "label": "particle-iberia", "order": "givenFirst", "shape": null, "text": "Bernardino de Sahagún"} {"composed": false, "fields": {"given": "Bernardino", "surname-core": "Sahagún", "surname-prefix": "de"}, "file": "118637827", "label": "particle-iberia", "order": "sorting", "shape": null, "text": "Sahagún, Bernardino de"} {"composed": false, "fields": {"given": "Saki"}, "file": "118641239", "label": "direct-et-mm", "order": "givenFirst", "shape": null, "text": "Saki"} {"composed": true, "fields": {"given": "Santi", "surname-core": "Tito", "surname-prefix": "di"}, "file": "118642197", "label": "particle-it", "order": "givenFirst", "shape": null, "text": "Santi di Tito"} {"composed": false, "fields": {"given": "Santi", "surname-core": "Tito", "surname-prefix": "di"}, "file": "118642197", "label": "particle-it", "order": "sorting", "shape": null, "text": "Tito, Santi di"} {"composed": false, "fields": {"given": "Séverine"}, "file": "118642367", "label": "direct-eu", "order": "givenFirst", "shape": null, "text": "Séverine"} {"composed": true, "fields": {"generation": "IV.", "given": "Albrecht"}, "file": "118644327", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Albrecht IV."} {"composed": true, "fields": {"given": "Théodore Agrippa", "surname": "d'Aubigné"}, "file": "118650947", "label": "particle-fr", "order": "givenFirst", "shape": null, "text": "Théodore Agrippa d'Aubigné"} {"composed": false, "fields": {"given": "Théodore Agrippa", "surname-core": "Aubigné", "surname-prefix": "d'"}, "file": "118650947", "label": "particle-fr", "order": "sorting", "shape": null, "text": "Aubigné, Théodore Agrippa d'"} {"composed": true, "fields": {"given": "Sébastien", "surname-core": "Brossard", "surname-prefix": "de"}, "file": "118660217", "label": "particle-fr", "order": "givenFirst", "shape": null, "text": "Sébastien de Brossard"} {"composed": false, "fields": {"given": "Sébastien", "surname-core": "Brossard", "surname-prefix": "de"}, "file": "118660217", "label": "particle-fr", "order": "sorting", "shape": null, "text": "Brossard, Sébastien de"} {"composed": true, "fields": {"given": "Camilo", "surname": "Castelo Branco"}, "file": "118668757", "label": "compound-iberia", "order": "givenFirst", "shape": null, "text": "Camilo Castelo Branco"} {"composed": false, "fields": {"given": "Camilo", "surname": "Castelo Branco"}, "file": "118668757", "label": "compound-iberia", "order": "sorting", "shape": null, "text": "Castelo Branco, Camilo"} {"composed": false, "fields": {"given": "Giambologna"}, "file": "118678477", "label": "direct-eu", "order": "givenFirst", "shape": null, "text": "Giambologna"} {"composed": true, "fields": {"given": "Benito Jerónimo", "surname": "Feijoo y Montenegro"}, "file": "118683217", "label": "compound-iberia", "order": "givenFirst", "shape": null, "text": "Benito Jerónimo Feijoo y Montenegro"} {"composed": false, "fields": {"given": "Benito Jerónimo", "surname": "Feijoo y Montenegro"}, "file": "118683217", "label": "compound-iberia", "order": "sorting", "shape": null, "text": "Feijoo y Montenegro, Benito Jerónimo"} {"composed": false, "fields": {"given": "Fidus"}, "file": "118687077", "label": "direct-eu", "order": "givenFirst", "shape": null, "text": "Fidus"} {"composed": false, "fields": {"given": "Meister E. S."}, "file": "118687387", "label": "direct-eu", "order": "givenFirst", "shape": null, "text": "Meister E. S."} {"composed": true, "fields": {"generation": "VI.", "given": "Georg"}, "file": "118690477", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Georg VI."} {"composed": true, "fields": {"given": "Dietrich", "surname-core": "Hildebrand", "surname-prefix": "von"}, "file": "118704737", "label": "particle-fr", "order": "givenFirst", "shape": null, "text": "Dietrich von Hildebrand"} {"composed": false, "fields": {"given": "Dietrich", "surname-core": "Hildebrand", "surname-prefix": "von"}, "file": "118704737", "label": "particle-fr", "order": "sorting", "shape": null, "text": "Hildebrand, Dietrich von"} {"composed": true, "fields": {"given": "Jan Stephan", "surname-core": "Calcar", "surname-prefix": "von"}, "file": "118714937", "label": "particle-it", "order": "givenFirst", "shape": null, "text": "Jan Stephan von Calcar"} {"composed": false, "fields": {"given": "Jan Stephan", "surname-core": "Calcar", "surname-prefix": "von"}, "file": "118714937", "label": "particle-it", "order": "sorting", "shape": null, "text": "Calcar, Jan Stephan von"} {"composed": false, "fields": {"given": "Ǧalāl-ad-Dīn Rūmī"}, "file": "118716158", "label": "direct-ir-af", "order": "givenFirst", "shape": null, "text": "Ǧalāl-ad-Dīn Rūmī"} {"composed": true, "fields": {"given": "Edward", "surname": "Hall"}, "file": "118720007", "label": "plain-anglo", "order": "givenFirst", "shape": null, "text": "Edward Hall"} {"composed": false, "fields": {"given": "Edward", "surname": "Hall"}, "file": "118720007", "label": "plain-anglo", "order": "sorting", "shape": null, "text": "Hall, Edward"} {"composed": true, "fields": {"given": "Robert", "surname": "Morris"}, "file": "118737007", "label": "plain-anglo", "order": "givenFirst", "shape": null, "text": "Robert Morris"} {"composed": false, "fields": {"given": "Robert", "surname": "Morris"}, "file": "118737007", "label": "plain-anglo", "order": "sorting", "shape": null, "text": "Morris, Robert"} {"composed": false, "fields": {"given": "Rethar"}, "file": "118750127", "label": "direct-eu", "order": "givenFirst", "shape": null, "text": "Rethar"} {"composed": true, "fields": {"given": "Diego Fernández", "surname": "de San Pedro"}, "file": "118751107", "label": "particle-iberia", "order": "givenFirst", "shape": null, "text": "Diego Fernández de San Pedro"} {"composed": false, "fields": {"given": "Diego Fernández", "surname": "de San Pedro"}, "file": "118751107", "label": "particle-iberia", "order": "sorting", "shape": null, "text": "San Pedro, Diego Fernández de"} {"composed": true, "fields": {"given": "Epaminondas", "surname": "Nunes de Ávila e Silva"}, "file": "1187542687", "label": "compound-latam", "order": "givenFirst", "shape": null, "text": "Epaminondas Nunes de Ávila e Silva"} {"composed": false, "fields": {"given": "Epaminondas", "surname": "Nunes de Ávila e Silva"}, "file": "1187542687", "label": "compound-latam", "order": "sorting", "shape": null, "text": "Nunes de Ávila e Silva, Epaminondas"} {"composed": false, "fields": {"given": "Suharto"}, "file": "118757741", "label": "direct-my-id", "order": "givenFirst", "shape": null, "text": "Suharto"} {"composed": false, "fields": {"given": "Joseph", "surname": "Szigeti"}, "file": "118758187", "label": "index-hu", "order": "sorting", "shape": null, "text": "Szigeti, Joseph"} {"composed": true, "fields": {"given": "Jakob", "surname-core": "Uexküll", "surname-prefix": "von"}, "file": "118763407", "label": "particle-de-at", "order": "givenFirst", "shape": null, "text": "Jakob von Uexküll"} {"composed": false, "fields": {"given": "Jakob", "surname-core": "Uexküll", "surname-prefix": "von"}, "file": "118763407", "label": "particle-de-at", "order": "sorting", "shape": null, "text": "Uexküll, Jakob von"} {"composed": true, "fields": {"given": "Luis", "surname": "Vélez de Guevara"}, "file": "118767917", "label": "compound-iberia", "order": "givenFirst", "shape": null, "text": "Luis Vélez de Guevara"} {"composed": false, "fields": {"given": "Luis", "surname": "Vélez de Guevara"}, "file": "118767917", "label": "compound-iberia", "order": "sorting", "shape": null, "text": "Vélez de Guevara, Luis"} {"composed": true, "fields": {"given": "Ernst", "surname-core": "Wildenbruch", "surname-prefix": "von"}, "file": "118771760", "label": "particle-arab", "order": "givenFirst", "shape": null, "text": "Ernst von Wildenbruch"} {"composed": false, "fields": {"given": "Ernst", "surname-core": "Wildenbruch", "surname-prefix": "von"}, "file": "118771760", "label": "particle-arab", "order": "sorting", "shape": null, "text": "Wildenbruch, Ernst von"} {"composed": false, "fields": {"given": "Muḥammad ʿAlī Bāšā"}, "file": "118782835", "label": "direct-arab", "order": "givenFirst", "shape": null, "text": "Muḥammad ʿAlī Bāšā"} {"composed": true, "fields": {"given": "Pierre", "surname-core": "Nolhac", "surname-prefix": "de"}, "file": "118786377", "label": "particle-fr", "order": "givenFirst", "shape": null, "text": "Pierre de Nolhac"} {"composed": false, "fields": {"given": "Pierre", "surname-core": "Nolhac", "surname-prefix": "de"}, "file": "118786377", "label": "particle-fr", "order": "sorting", "shape": null, "text": "Nolhac, Pierre de"} {"composed": false, "fields": {"given": "Saint-Pol-Roux"}, "file": "118794167", "label": "direct-eu", "order": "givenFirst", "shape": null, "text": "Saint-Pol-Roux"} {"composed": true, "fields": {"given": "Hans", "surname": "Schiltberger"}, "file": "118795007", "label": "plain-europe", "order": "givenFirst", "shape": null, "text": "Hans Schiltberger"} {"composed": false, "fields": {"given": "Hans", "surname": "Schiltberger"}, "file": "118795007", "label": "plain-europe", "order": "sorting", "shape": null, "text": "Schiltberger, Hans"} {"composed": false, "fields": {"given": "Ṯābit Ibn-Qurra"}, "file": "118801724", "label": "direct-arab", "order": "givenFirst", "shape": null, "text": "Ṯābit Ibn-Qurra"} {"composed": true, "fields": {"given": "Zacharias Konrad", "surname-core": "Uffenbach", "surname-prefix": "von"}, "file": "118803107", "label": "particle-de-at", "order": "givenFirst", "shape": null, "text": "Zacharias Konrad von Uffenbach"} {"composed": false, "fields": {"given": "Zacharias Konrad", "surname-core": "Uffenbach", "surname-prefix": "von"}, "file": "118803107", "label": "particle-de-at", "order": "sorting", "shape": null, "text": "Uffenbach, Zacharias Konrad von"} {"composed": false, "fields": {"given": "Musashi", "surname": "Miyamoto"}, "file": "118820907", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Miyamoto, Musashi"} {"composed": true, "fields": {"given": "Ludwig", "surname": "Hempel"}, "file": "118824007", "label": "plain-europe", "order": "givenFirst", "shape": null, "text": "Ludwig Hempel"} {"composed": false, "fields": {"given": "Ludwig", "surname": "Hempel"}, "file": "118824007", "label": "plain-europe", "order": "sorting", "shape": null, "text": "Hempel, Ludwig"} {"composed": true, "fields": {"given": "Jean-François Lefebvre", "surname": "de La Barre"}, "file": "118835467", "label": "particle-fr", "order": "givenFirst", "shape": null, "text": "Jean-François Lefebvre de La Barre"} {"composed": false, "fields": {"given": "Jean-François Lefebvre", "surname": "de La Barre"}, "file": "118835467", "label": "particle-fr", "order": "sorting", "shape": null, "text": "La Barre, Jean-François Lefebvre de"} {"composed": true, "fields": {"given": "Muḥammad Ibn-ʿAbd-al-Malik", "surname": "Ibn-Ṭufail"}, "file": "118847147", "label": "compound-iberia", "order": "givenFirst", "shape": null, "text": "Muḥammad Ibn-ʿAbd-al-Malik Ibn-Ṭufail"} {"composed": false, "fields": {"given": "Muḥammad Ibn-ʿAbd-al-Malik", "surname": "Ibn-Ṭufail"}, "file": "118847147", "label": "compound-iberia", "order": "sorting", "shape": null, "text": "Ibn-Ṭufail, Muḥammad Ibn-ʿAbd-al-Malik"} {"composed": true, "fields": {"given": "Hafis", "surname": "al-Assad"}, "file": "118848062", "label": "particle-arab", "order": "givenFirst", "shape": null, "text": "Hafis al-Assad"} {"composed": false, "fields": {"given": "Hafis", "surname-core": "Assad", "surname-prefix": "al-"}, "file": "118848062", "label": "particle-arab", "order": "sorting", "shape": null, "text": "Assad, Hafis al-"} {"composed": false, "fields": {"given": "Ṣafī-ad-Dīn Ardabīlī"}, "file": "118850555", "label": "direct-ir-af", "order": "givenFirst", "shape": null, "text": "Ṣafī-ad-Dīn Ardabīlī"} {"composed": true, "fields": {"given": "al-Walīd Ibn-ʿUbaid", "surname": "al-Buḥturī"}, "file": "118857770", "label": "particle-arab", "order": "givenFirst", "shape": null, "text": "al-Walīd Ibn-ʿUbaid al-Buḥturī"} {"composed": false, "fields": {"given": "al-Walīd Ibn-ʿUbaid", "surname-core": "Buḥturī", "surname-prefix": "al-"}, "file": "118857770", "label": "particle-arab", "order": "sorting", "shape": null, "text": "Buḥturī, al-Walīd Ibn-ʿUbaid al-"} {"composed": false, "fields": {"given": "Ḥusain Ṯanāʾī Mašhadī"}, "file": "1188659936", "label": "direct-ir-af", "order": "givenFirst", "shape": null, "text": "Ḥusain Ṯanāʾī Mašhadī"} {"composed": true, "fields": {"given": "Jan", "surname-core": "Munster", "surname-prefix": "van"}, "file": "118873377", "label": "particle-nl-be", "order": "givenFirst", "shape": null, "text": "Jan van Munster"} {"composed": false, "fields": {"given": "Jan", "surname-core": "Munster", "surname-prefix": "van"}, "file": "118873377", "label": "particle-nl-be", "order": "sorting", "shape": null, "text": "Munster, Jan van"} {"composed": true, "fields": {"given": "Babe D.", "surname": "Zaharias"}, "file": "118876007", "label": "plain-anglo", "order": "givenFirst", "shape": null, "text": "Babe D. Zaharias"} {"composed": false, "fields": {"given": "Babe D.", "surname": "Zaharias"}, "file": "118876007", "label": "plain-anglo", "order": "sorting", "shape": null, "text": "Zaharias, Babe D."} {"composed": false, "fields": {"given": "Attila", "surname": "Kovács"}, "file": "118879057", "label": "index-hu", "order": "sorting", "shape": null, "text": "Kovács, Attila"} {"composed": true, "fields": {"given": "Leopoldo", "surname": "de'Medici"}, "file": "118886207", "label": "particle-it", "order": "givenFirst", "shape": null, "text": "Leopoldo de'Medici"} {"composed": false, "fields": {"given": "Leopoldo", "surname-core": "Medici", "surname-prefix": "de'"}, "file": "118886207", "label": "particle-it", "order": "sorting", "shape": null, "text": "Medici, Leopoldo de'"} {"composed": false, "fields": {"given": "Mayo"}, "file": "118888625", "label": "direct-arab", "order": "givenFirst", "shape": null, "text": "Mayo"} {"composed": false, "fields": {"given": "Morihei", "surname": "Ueshiba"}, "file": "118892207", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Ueshiba, Morihei"} {"composed": true, "fields": {"generation": "II.", "given": "Thibaut"}, "file": "118893467", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Thibaut II."} {"composed": false, "fields": {"given": "Einar Jónsson"}, "file": "118894978", "label": "direct-is", "order": "givenFirst", "shape": null, "text": "Einar Jónsson"} {"composed": false, "fields": {"given": "Etiemble"}, "file": "118903357", "label": "direct-eu", "order": "givenFirst", "shape": null, "text": "Etiemble"} {"composed": false, "fields": {"given": "Monzaemon", "surname": "Chikamatsu"}, "file": "118914707", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Chikamatsu, Monzaemon"} {"composed": true, "fields": {"given": "Karl August", "surname-core": "Hase", "surname-prefix": "von"}, "file": "118927507", "label": "particle-de-at", "order": "givenFirst", "shape": null, "text": "Karl August von Hase"} {"composed": false, "fields": {"given": "Karl August", "surname-core": "Hase", "surname-prefix": "von"}, "file": "118927507", "label": "particle-de-at", "order": "sorting", "shape": null, "text": "Hase, Karl August von"} {"composed": true, "fields": {"generation": "I.", "given": "Ismail"}, "file": "118932497", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Ismail I."} {"composed": false, "fields": {"given": "Enver Pascha"}, "file": "118935577", "label": "direct-eu", "order": "givenFirst", "shape": null, "text": "Enver Pascha"} {"composed": true, "fields": {"given": "Antoine", "surname-core": "Tounens", "surname-prefix": "de"}, "file": "118937227", "label": "particle-fr", "order": "givenFirst", "shape": null, "text": "Antoine de Tounens"} {"composed": false, "fields": {"given": "Antoine", "surname-core": "Tounens", "surname-prefix": "de"}, "file": "118937227", "label": "particle-fr", "order": "sorting", "shape": null, "text": "Tounens, Antoine de"} {"composed": true, "fields": {"given": "Dermot", "surname": "Ryan"}, "file": "118940007", "label": "plain-anglo", "order": "givenFirst", "shape": null, "text": "Dermot Ryan"} {"composed": false, "fields": {"given": "Dermot", "surname": "Ryan"}, "file": "118940007", "label": "plain-anglo", "order": "sorting", "shape": null, "text": "Ryan, Dermot"} {"composed": true, "fields": {"given": "Alice", "surname": "Rühle-Gerstel"}, "file": "118953877", "label": "compound-latam", "order": "givenFirst", "shape": null, "text": "Alice Rühle-Gerstel"} {"composed": false, "fields": {"given": "Alice", "surname": "Rühle-Gerstel"}, "file": "118953877", "label": "compound-latam", "order": "sorting", "shape": null, "text": "Rühle-Gerstel, Alice"} {"composed": true, "fields": {"given": "Antonio", "surname-core": "Sangallo", "surname-prefix": "da"}, "file": "118956337", "label": "particle-it", "order": "givenFirst", "shape": null, "text": "Antonio da Sangallo"} {"composed": false, "fields": {"given": "Antonio", "surname-core": "Sangallo", "surname-prefix": "da"}, "file": "118956337", "label": "particle-it", "order": "sorting", "shape": null, "text": "Sangallo, Antonio da"} {"composed": false, "fields": {"given": "Ğawīdān _ Hānum"}, "file": "118963430", "label": "direct-arab", "order": "givenFirst", "shape": null, "text": "Ğawīdān _ Hānum"} {"composed": false, "fields": {"given": "Ultra Violet"}, "file": "118972197", "label": "direct-eu", "order": "givenFirst", "shape": null, "text": "Ultra Violet"} {"composed": false, "fields": {"given": "ʿAbd-al-Ǧabbār Ibn-Aḥmad"}, "file": "118976869", "label": "direct-arab", "order": "givenFirst", "shape": null, "text": "ʿAbd-al-Ǧabbār Ibn-Aḥmad"} {"composed": true, "fields": {"given": "Manuel", "surname": "Marulanda Vélez"}, "file": "118988417", "label": "compound-latam", "order": "givenFirst", "shape": null, "text": "Manuel Marulanda Vélez"} {"composed": false, "fields": {"given": "Manuel", "surname": "Marulanda Vélez"}, "file": "118988417", "label": "compound-latam", "order": "sorting", "shape": null, "text": "Marulanda Vélez, Manuel"} {"composed": false, "fields": {"given": "István", "surname": "Tisza"}, "file": "118998897", "label": "index-hu", "order": "sorting", "shape": null, "text": "Tisza, István"} {"composed": true, "fields": {"given": "José Maria", "surname": "Sert y Badia"}, "file": "119000717", "label": "compound-iberia", "order": "givenFirst", "shape": null, "text": "José Maria Sert y Badia"} {"composed": false, "fields": {"given": "José Maria", "surname": "Sert y Badia"}, "file": "119000717", "label": "compound-iberia", "order": "sorting", "shape": null, "text": "Sert y Badia, José Maria"} {"composed": true, "fields": {"given": "Ida", "surname": "Kar"}, "file": "119003007", "label": "plain-anglo", "order": "givenFirst", "shape": null, "text": "Ida Kar"} {"composed": false, "fields": {"given": "Ida", "surname": "Kar"}, "file": "119003007", "label": "plain-anglo", "order": "sorting", "shape": null, "text": "Kar, Ida"} {"composed": true, "fields": {"given": "Zainab", "surname": "al-Ġazālī"}, "file": "119007703", "label": "particle-arab", "order": "givenFirst", "shape": null, "text": "Zainab al-Ġazālī"} {"composed": false, "fields": {"given": "Zainab", "surname-core": "Ġazālī", "surname-prefix": "al-"}, "file": "119007703", "label": "particle-arab", "order": "sorting", "shape": null, "text": "Ġazālī, Zainab al-"} {"composed": true, "fields": {"given": "Jan Davidsz.", "surname-core": "Heem", "surname-prefix": "de"}, "file": "119020157", "label": "particle-nl-be", "order": "givenFirst", "shape": null, "text": "Jan Davidsz. de Heem"} {"composed": false, "fields": {"given": "Jan Davidsz.", "surname-core": "Heem", "surname-prefix": "de"}, "file": "119020157", "label": "particle-nl-be", "order": "sorting", "shape": null, "text": "Heem, Jan Davidsz. de"} {"composed": true, "fields": {"generation": "VII.", "given": "Konstantin"}, "file": "119022397", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Konstantin VII."} {"composed": false, "fields": {"given": "Andor", "surname": "Gábor"}, "file": "119033887", "label": "index-hu", "order": "sorting", "shape": null, "text": "Gábor, Andor"} {"composed": false, "fields": {"given": "Shuming", "surname": "Liang"}, "file": "119046407", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Liang, Shuming"} {"composed": true, "fields": {"given": "Louise", "surname": "Fitzhugh"}, "file": "119055007", "label": "plain-anglo", "order": "givenFirst", "shape": null, "text": "Louise Fitzhugh"} {"composed": false, "fields": {"given": "Louise", "surname": "Fitzhugh"}, "file": "119055007", "label": "plain-anglo", "order": "sorting", "shape": null, "text": "Fitzhugh, Louise"} {"composed": true, "fields": {"given": "José", "surname": "López Portillo y Pacheco"}, "file": "119075067", "label": "compound-latam", "order": "givenFirst", "shape": null, "text": "José López Portillo y Pacheco"} {"composed": false, "fields": {"given": "José", "surname": "López Portillo y Pacheco"}, "file": "119075067", "label": "compound-latam", "order": "sorting", "shape": null, "text": "López Portillo y Pacheco, José"} {"composed": false, "fields": {"given": "Albert Guðmundsson"}, "file": "1190758520", "label": "direct-is", "order": "givenFirst", "shape": null, "text": "Albert Guðmundsson"} {"composed": false, "fields": {"given": "Zhaojun", "surname": "Wang"}, "file": "119096307", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Wang, Zhaojun"} {"composed": true, "fields": {"given": "George Edward Stanhope Molyneux Herbert", "surname-core": "Carnarvon", "surname-prefix": "of"}, "file": "119104644", "label": "particle-arab", "order": "givenFirst", "shape": null, "text": "George Edward Stanhope Molyneux Herbert of Carnarvon"} {"composed": false, "fields": {"given": "George Edward Stanhope Molyneux Herbert", "surname-core": "Carnarvon", "surname-prefix": "of"}, "file": "119104644", "label": "particle-arab", "order": "sorting", "shape": null, "text": "Carnarvon, George Edward Stanhope Molyneux Herbert of"} {"composed": false, "fields": {"given": "Benedikt Gröndal"}, "file": "119112655", "label": "direct-is", "order": "givenFirst", "shape": null, "text": "Benedikt Gröndal"} {"composed": true, "fields": {"given": "Jackson", "surname-core": "Figueiredo", "surname-prefix": "de"}, "file": "119114607", "label": "particle-iberia", "order": "givenFirst", "shape": null, "text": "Jackson de Figueiredo"} {"composed": false, "fields": {"given": "Jackson", "surname-core": "Figueiredo", "surname-prefix": "de"}, "file": "119114607", "label": "particle-iberia", "order": "sorting", "shape": null, "text": "Figueiredo, Jackson de"} {"composed": false, "fields": {"given": "Elisabeth", "surname": "Gössmann"}, "file": "119115107", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Gössmann, Elisabeth"} {"composed": true, "fields": {"given": "Aldephonse Alexandre Félix", "surname-core": "Jardin", "surname-prefix": "du"}, "file": "1191284387", "label": "particle-nl-be", "order": "givenFirst", "shape": null, "text": "Aldephonse Alexandre Félix du Jardin"} {"composed": false, "fields": {"given": "Aldephonse Alexandre Félix", "surname-core": "Jardin", "surname-prefix": "du"}, "file": "1191284387", "label": "particle-nl-be", "order": "sorting", "shape": null, "text": "Jardin, Aldephonse Alexandre Félix du"} {"composed": false, "fields": {"given": "Nariaki", "surname": "Tokugawa"}, "file": "1191443507", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Tokugawa, Nariaki"} {"composed": false, "fields": {"given": "Abū-Maʿšar Ǧaʿfar Ibn-Muḥammad"}, "file": "11914512X", "label": "direct-ir-af", "order": "givenFirst", "shape": null, "text": "Abū-Maʿšar Ǧaʿfar Ibn-Muḥammad"} {"composed": false, "fields": {"given": "Mūsā al-Kāẓīm"}, "file": "119147726", "label": "direct-arab", "order": "givenFirst", "shape": null, "text": "Mūsā al-Kāẓīm"} {"composed": true, "fields": {"given": "Giuliano", "surname-core": "Medici", "surname-prefix": "de"}, "file": "119151197", "label": "particle-it", "order": "givenFirst", "shape": null, "text": "Giuliano de Medici"} {"composed": false, "fields": {"given": "Giuliano", "surname-core": "Medici", "surname-prefix": "de"}, "file": "119151197", "label": "particle-it", "order": "sorting", "shape": null, "text": "Medici, Giuliano de"} {"composed": false, "fields": {"given": "Muḥammad Ibn-ʿAbd-al-Wahhāb"}, "file": "119152169", "label": "direct-arab", "order": "givenFirst", "shape": null, "text": "Muḥammad Ibn-ʿAbd-al-Wahhāb"} {"composed": false, "fields": {"given": "Zarea Yaeqob"}, "file": "11915689X", "label": "direct-et-mm", "order": "givenFirst", "shape": null, "text": "Zarea Yaeqob"} {"composed": true, "fields": {"given": "Antonio", "surname": "dos Santos Cabral"}, "file": "119160617", "label": "particle-iberia", "order": "givenFirst", "shape": null, "text": "Antonio dos Santos Cabral"} {"composed": false, "fields": {"given": "Antonio", "surname": "dos Santos Cabral"}, "file": "119160617", "label": "particle-iberia", "order": "sorting", "shape": null, "text": "Santos Cabral, Antonio dos"} {"composed": true, "fields": {"given": "Ferdinand-Philippe", "surname": "d'Orléans"}, "file": "119177447", "label": "particle-elided", "order": "givenFirst", "shape": null, "text": "Ferdinand-Philippe d'Orléans"} {"composed": false, "fields": {"given": "Ferdinand-Philippe", "surname-core": "Orléans", "surname-prefix": "d'"}, "file": "119177447", "label": "particle-elided", "order": "sorting", "shape": null, "text": "Orléans, Ferdinand-Philippe d'"} {"composed": false, "fields": {"given": "Nobuo", "surname": "Naora"}, "file": "119179407", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Naora, Nobuo"} {"composed": true, "fields": {"given": "Gertrude", "surname": "Duby-Blom"}, "file": "119185237", "label": "compound-latam", "order": "givenFirst", "shape": null, "text": "Gertrude Duby-Blom"} {"composed": false, "fields": {"given": "Gertrude", "surname": "Duby-Blom"}, "file": "119185237", "label": "compound-latam", "order": "sorting", "shape": null, "text": "Duby-Blom, Gertrude"} {"composed": true, "fields": {"given": "Abu-'l-Qāsim Maslama Ibn-Aḥmad", "surname": "al-Maǧrīṭī"}, "file": "119192047", "label": "particle-iberia", "order": "givenFirst", "shape": null, "text": "Abu-'l-Qāsim Maslama Ibn-Aḥmad al-Maǧrīṭī"} {"composed": false, "fields": {"given": "Abu-'l-Qāsim Maslama Ibn-Aḥmad", "surname-core": "Maǧrīṭī", "surname-prefix": "al-"}, "file": "119192047", "label": "particle-iberia", "order": "sorting", "shape": null, "text": "Maǧrīṭī, Abu-'l-Qāsim Maslama Ibn-Aḥmad al-"} {"composed": false, "fields": {"given": "Muslim Ibn-al-Ḥaǧǧāǧ al-Qušairī"}, "file": "119196824", "label": "direct-ir-af", "order": "givenFirst", "shape": null, "text": "Muslim Ibn-al-Ḥaǧǧāǧ al-Qušairī"} {"composed": false, "fields": {"given": "Sābūr Ibn-Sahl"}, "file": "11920519X", "label": "direct-ir-af", "order": "givenFirst", "shape": null, "text": "Sābūr Ibn-Sahl"} {"composed": false, "fields": {"given": "Ḥabīballāh Kalakānī"}, "file": "11921203X", "label": "direct-ir-af", "order": "givenFirst", "shape": null, "text": "Ḥabīballāh Kalakānī"} {"composed": false, "fields": {"given": "Haider Hān ʿAmū-ūġlī"}, "file": "119236591", "label": "direct-ir-af", "order": "givenFirst", "shape": null, "text": "Haider Hān ʿAmū-ūġlī"} {"composed": false, "fields": {"given": "Bābak Yaḥyā Ibn-Muʿāḏ al-Ḫurramī"}, "file": "119255782", "label": "direct-ir-af", "order": "givenFirst", "shape": null, "text": "Bābak Yaḥyā Ibn-Muʿāḏ al-Ḫurramī"} {"composed": true, "fields": {"given": "Vinícius", "surname-core": "Moraes", "surname-prefix": "de"}, "file": "119255987", "label": "particle-iberia", "order": "givenFirst", "shape": null, "text": "Vinícius de Moraes"} {"composed": false, "fields": {"given": "Vinícius", "surname-core": "Moraes", "surname-prefix": "de"}, "file": "119255987", "label": "particle-iberia", "order": "sorting", "shape": null, "text": "Moraes, Vinícius de"} {"composed": false, "fields": {"given": "Shōsan", "surname": "Suzuki"}, "file": "119256207", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Suzuki, Shōsan"} {"composed": true, "fields": {"generation": "II.", "given": "Eitel Friedrich"}, "file": "119270277", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Eitel Friedrich II."} {"composed": true, "fields": {"given": "Taufīq", "surname": "al-Ḥakīm"}, "file": "119272911", "label": "particle-arab", "order": "givenFirst", "shape": null, "text": "Taufīq al-Ḥakīm"} {"composed": false, "fields": {"given": "Taufīq", "surname-core": "Ḥakīm", "surname-prefix": "al-"}, "file": "119272911", "label": "particle-arab", "order": "sorting", "shape": null, "text": "Ḥakīm, Taufīq al-"} {"composed": true, "fields": {"generation": "II.", "given": "Johann"}, "file": "119273187", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Johann II."} {"composed": false, "fields": {"given": "Pál", "surname": "Jászay"}, "file": "119283867", "label": "index-hu", "order": "sorting", "shape": null, "text": "Jászay, Pál"} {"composed": false, "fields": {"given": "Aung San"}, "file": "119288109", "label": "direct-et-mm", "order": "givenFirst", "shape": null, "text": "Aung San"} {"composed": false, "fields": {"given": "Einar Benediktsson"}, "file": "119288370", "label": "direct-is", "order": "givenFirst", "shape": null, "text": "Einar Benediktsson"} {"composed": true, "fields": {"given": "Dietrich", "surname": "Weise"}, "file": "1192937007", "label": "plain-europe", "order": "givenFirst", "shape": null, "text": "Dietrich Weise"} {"composed": false, "fields": {"given": "Dietrich", "surname": "Weise"}, "file": "1192937007", "label": "plain-europe", "order": "sorting", "shape": null, "text": "Weise, Dietrich"} {"composed": true, "fields": {"given": "José Luis", "surname": "Bustamante y Rivero"}, "file": "119313707", "label": "compound-latam", "order": "givenFirst", "shape": null, "text": "José Luis Bustamante y Rivero"} {"composed": false, "fields": {"given": "José Luis", "surname": "Bustamante y Rivero"}, "file": "119313707", "label": "compound-latam", "order": "sorting", "shape": null, "text": "Bustamante y Rivero, José Luis"} {"composed": true, "fields": {"given": "Samuel", "surname": "Bourne"}, "file": "119333007", "label": "plain-anglo", "order": "givenFirst", "shape": null, "text": "Samuel Bourne"} {"composed": false, "fields": {"given": "Samuel", "surname": "Bourne"}, "file": "119333007", "label": "plain-anglo", "order": "sorting", "shape": null, "text": "Bourne, Samuel"} {"composed": false, "fields": {"given": "Gesang"}, "file": "1193450314", "label": "direct-my-id", "order": "givenFirst", "shape": null, "text": "Gesang"} {"composed": false, "fields": {"given": "Pétur Sigurðsson"}, "file": "119392275", "label": "direct-is", "order": "givenFirst", "shape": null, "text": "Pétur Sigurðsson"} {"composed": false, "fields": {"given": "Shemuʾel ben Ḥofni"}, "file": "119398184", "label": "direct-arab", "order": "givenFirst", "shape": null, "text": "Shemuʾel ben Ḥofni"} {"composed": false, "fields": {"given": "Máté", "surname": "Zalka"}, "file": "119399377", "label": "index-hu", "order": "sorting", "shape": null, "text": "Zalka, Máté"} {"composed": true, "fields": {"given": "Neil Edward", "surname": "Goldschmidt"}, "file": "1194065007", "label": "plain-anglo", "order": "givenFirst", "shape": null, "text": "Neil Edward Goldschmidt"} {"composed": false, "fields": {"given": "Neil Edward", "surname": "Goldschmidt"}, "file": "1194065007", "label": "plain-anglo", "order": "sorting", "shape": null, "text": "Goldschmidt, Neil Edward"} {"composed": true, "fields": {"given": "Laṭīfa", "surname": "az-Zaiyāt"}, "file": "119418029", "label": "particle-arab", "order": "givenFirst", "shape": null, "text": "Laṭīfa az-Zaiyāt"} {"composed": false, "fields": {"given": "Laṭīfa", "surname-core": "Zaiyāt", "surname-prefix": "az-"}, "file": "119418029", "label": "particle-arab", "order": "sorting", "shape": null, "text": "Zaiyāt, Laṭīfa az-"} {"composed": true, "fields": {"given": "Idwār", "surname": "al-Ḫarrāṭ"}, "file": "119426838", "label": "particle-arab", "order": "givenFirst", "shape": null, "text": "Idwār al-Ḫarrāṭ"} {"composed": false, "fields": {"given": "Idwār", "surname-core": "Ḫarrāṭ", "surname-prefix": "al-"}, "file": "119426838", "label": "particle-arab", "order": "sorting", "shape": null, "text": "Ḫarrāṭ, Idwār al-"} {"composed": true, "fields": {"given": "Ciro", "surname-core": "Pèrs", "surname-prefix": "di"}, "file": "119436167", "label": "particle-it", "order": "givenFirst", "shape": null, "text": "Ciro di Pèrs"} {"composed": false, "fields": {"given": "Ciro", "surname-core": "Pèrs", "surname-prefix": "di"}, "file": "119436167", "label": "particle-it", "order": "sorting", "shape": null, "text": "Pèrs, Ciro di"} {"composed": true, "fields": {"given": "Willem", "surname-core": "Haren", "surname-prefix": "van"}, "file": "119453797", "label": "particle-nl-be", "order": "givenFirst", "shape": null, "text": "Willem van Haren"} {"composed": false, "fields": {"given": "Willem", "surname-core": "Haren", "surname-prefix": "van"}, "file": "119453797", "label": "particle-nl-be", "order": "sorting", "shape": null, "text": "Haren, Willem van"} {"composed": false, "fields": {"given": "Amīn as-Sulṭān ʿAlī Aṣġar Ḫān"}, "file": "1194558798", "label": "direct-ir-af", "order": "givenFirst", "shape": null, "text": "Amīn as-Sulṭān ʿAlī Aṣġar Ḫān"} {"composed": false, "fields": {"given": "Þorlákur Þórhallsson"}, "file": "119465175", "label": "direct-is", "order": "givenFirst", "shape": null, "text": "Þorlákur Þórhallsson"} {"composed": false, "fields": {"given": "Chiune", "surname": "Sugihara"}, "file": "119508907", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Sugihara, Chiune"} {"composed": false, "fields": {"given": "Abū-Muslim"}, "file": "11951625X", "label": "direct-ir-af", "order": "givenFirst", "shape": null, "text": "Abū-Muslim"} {"composed": true, "fields": {"generation": "III.", "given": "Murad"}, "file": "119524767", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Murad III."} {"composed": false, "fields": {"given": "ʿImād al-Ḥasanī"}, "file": "11952645X", "label": "direct-ir-af", "order": "givenFirst", "shape": null, "text": "ʿImād al-Ḥasanī"} {"composed": false, "fields": {"given": "Soedjatmoko"}, "file": "119533936", "label": "direct-my-id", "order": "givenFirst", "shape": null, "text": "Soedjatmoko"} {"composed": false, "fields": {"given": "Elʿazar ben Yehudah"}, "file": "119534827", "label": "direct-eu", "order": "givenFirst", "shape": null, "text": "Elʿazar ben Yehudah"} {"composed": true, "fields": {"generation": "I.", "given": "Werner"}, "file": "119536897", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Werner I."} {"composed": true, "fields": {"given": "Johann Friedrich", "surname": "d'Orville"}, "file": "1199143707", "label": "particle-elided", "order": "givenFirst", "shape": null, "text": "Johann Friedrich d'Orville"} {"composed": false, "fields": {"given": "Johann Friedrich", "surname-core": "Orville", "surname-prefix": "d'"}, "file": "1199143707", "label": "particle-elided", "order": "sorting", "shape": null, "text": "Orville, Johann Friedrich d'"} {"composed": true, "fields": {"given": "Louis Antoine d'Artois", "surname": "d'Angoulême"}, "file": "120010887", "label": "particle-elided", "order": "givenFirst", "shape": null, "text": "Louis Antoine d'Artois d'Angoulême"} {"composed": false, "fields": {"given": "Louis Antoine d'Artois", "surname-core": "Angoulême", "surname-prefix": "d'"}, "file": "120010887", "label": "particle-elided", "order": "sorting", "shape": null, "text": "Angoulême, Louis Antoine d'Artois d'"} {"composed": false, "fields": {"given": "Tewolde Berhan Gebre Egziabher"}, "file": "1200607953", "label": "direct-et-mm", "order": "givenFirst", "shape": null, "text": "Tewolde Berhan Gebre Egziabher"} {"composed": false, "fields": {"given": "Machbuba"}, "file": "120092166", "label": "direct-et-mm", "order": "givenFirst", "shape": null, "text": "Machbuba"} {"composed": true, "fields": {"generation": "VIIII.", "given": "Hamengku Buwono"}, "file": "120147327", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Hamengku Buwono VIIII."} {"composed": true, "fields": {"given": "Klaus", "surname": "Poeck"}, "file": "120217007", "label": "plain-europe", "order": "givenFirst", "shape": null, "text": "Klaus Poeck"} {"composed": false, "fields": {"given": "Klaus", "surname": "Poeck"}, "file": "120217007", "label": "plain-europe", "order": "sorting", "shape": null, "text": "Poeck, Klaus"} {"composed": true, "fields": {"given": "Eduardo", "surname": "López Ochoa"}, "file": "1202958737", "label": "compound-iberia", "order": "givenFirst", "shape": null, "text": "Eduardo López Ochoa"} {"composed": false, "fields": {"given": "Eduardo", "surname": "López Ochoa"}, "file": "1202958737", "label": "compound-iberia", "order": "sorting", "shape": null, "text": "López Ochoa, Eduardo"} {"composed": true, "fields": {"given": "Edward", "surname": "Bransfield"}, "file": "1203263007", "label": "plain-anglo", "order": "givenFirst", "shape": null, "text": "Edward Bransfield"} {"composed": false, "fields": {"given": "Edward", "surname": "Bransfield"}, "file": "1203263007", "label": "plain-anglo", "order": "sorting", "shape": null, "text": "Bransfield, Edward"} {"composed": true, "fields": {"given": "María Dolores", "surname-core": "Pablos", "surname-prefix": "de"}, "file": "1203586507", "label": "particle-iberia", "order": "givenFirst", "shape": null, "text": "María Dolores de Pablos"} {"composed": false, "fields": {"given": "María Dolores", "surname-core": "Pablos", "surname-prefix": "de"}, "file": "1203586507", "label": "particle-iberia", "order": "sorting", "shape": null, "text": "Pablos, María Dolores de"} {"composed": true, "fields": {"given": "Magda", "surname-core": "Lerch", "surname-prefix": "von"}, "file": "1204166307", "label": "particle-de-at", "order": "givenFirst", "shape": null, "text": "Magda von Lerch"} {"composed": false, "fields": {"given": "Magda", "surname-core": "Lerch", "surname-prefix": "von"}, "file": "1204166307", "label": "particle-de-at", "order": "sorting", "shape": null, "text": "Lerch, Magda von"} {"composed": true, "fields": {"given": "Robert", "surname": "Aitken"}, "file": "120495007", "label": "plain-anglo", "order": "givenFirst", "shape": null, "text": "Robert Aitken"} {"composed": false, "fields": {"given": "Robert", "surname": "Aitken"}, "file": "120495007", "label": "plain-anglo", "order": "sorting", "shape": null, "text": "Aitken, Robert"} {"composed": true, "fields": {"given": "Jorge A.", "surname": "Livraga Rizzi"}, "file": "120584727", "label": "compound-latam", "order": "givenFirst", "shape": null, "text": "Jorge A. Livraga Rizzi"} {"composed": false, "fields": {"given": "Jorge A.", "surname": "Livraga Rizzi"}, "file": "120584727", "label": "compound-latam", "order": "sorting", "shape": null, "text": "Livraga Rizzi, Jorge A."} {"composed": false, "fields": {"given": "Kōzui", "surname": "Ōtani"}, "file": "120764407", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Ōtani, Kōzui"} {"composed": true, "fields": {"generation": "I.", "given": "Magnus"}, "file": "120770377", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Magnus I."} {"composed": true, "fields": {"given": "Philipp", "surname-core": "Breuner", "surname-prefix": "von"}, "file": "1207986607", "label": "particle-de-at", "order": "givenFirst", "shape": null, "text": "Philipp von Breuner"} {"composed": false, "fields": {"given": "Philipp", "surname-core": "Breuner", "surname-prefix": "von"}, "file": "1207986607", "label": "particle-de-at", "order": "sorting", "shape": null, "text": "Breuner, Philipp von"} {"composed": false, "fields": {"given": "Lockroy"}, "file": "120820757", "label": "direct-eu", "order": "givenFirst", "shape": null, "text": "Lockroy"} {"composed": true, "fields": {"given": "Klaus", "surname": "Schönert"}, "file": "1208853007", "label": "plain-europe", "order": "givenFirst", "shape": null, "text": "Klaus Schönert"} {"composed": false, "fields": {"given": "Klaus", "surname": "Schönert"}, "file": "1208853007", "label": "plain-europe", "order": "sorting", "shape": null, "text": "Schönert, Klaus"} {"composed": true, "fields": {"given": "Marc", "surname-core": "Joybert", "surname-prefix": "de"}, "file": "1209153777", "label": "particle-fr", "order": "givenFirst", "shape": null, "text": "Marc de Joybert"} {"composed": false, "fields": {"given": "Marc", "surname-core": "Joybert", "surname-prefix": "de"}, "file": "1209153777", "label": "particle-fr", "order": "sorting", "shape": null, "text": "Joybert, Marc de"} {"composed": false, "fields": {"given": "Muḥammad Taqī Ḫān Pisyān"}, "file": "120926172", "label": "direct-ir-af", "order": "givenFirst", "shape": null, "text": "Muḥammad Taqī Ḫān Pisyān"} {"composed": true, "fields": {"given": "Gabriel", "surname": "Kolko"}, "file": "120929007", "label": "plain-anglo", "order": "givenFirst", "shape": null, "text": "Gabriel Kolko"} {"composed": false, "fields": {"given": "Gabriel", "surname": "Kolko"}, "file": "120929007", "label": "plain-anglo", "order": "sorting", "shape": null, "text": "Kolko, Gabriel"} {"composed": false, "fields": {"given": "Surāqa Ibn-Mirdās al-Aṣġar"}, "file": "120942550", "label": "direct-arab", "order": "givenFirst", "shape": null, "text": "Surāqa Ibn-Mirdās al-Aṣġar"} {"composed": false, "fields": {"given": "Seiji", "surname": "Ozawa"}, "file": "121084507", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Ozawa, Seiji"} {"composed": false, "fields": {"given": "Jón Leifs"}, "file": "121086135", "label": "direct-is", "order": "givenFirst", "shape": null, "text": "Jón Leifs"} {"composed": false, "fields": {"given": "Stefan", "surname": "Kazó"}, "file": "1212139917", "label": "index-hu", "order": "sorting", "shape": null, "text": "Kazó, Stefan"} {"composed": true, "fields": {"given": "Benita", "surname": "Asas Manterola"}, "file": "1213251737", "label": "compound-iberia", "order": "givenFirst", "shape": null, "text": "Benita Asas Manterola"} {"composed": false, "fields": {"given": "Benita", "surname": "Asas Manterola"}, "file": "1213251737", "label": "compound-iberia", "order": "sorting", "shape": null, "text": "Asas Manterola, Benita"} {"composed": true, "fields": {"generation": "IV.", "given": "Ziemowit"}, "file": "121434397", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Ziemowit IV."} {"composed": true, "fields": {"given": "Segundo", "surname": "Serrano Poncela"}, "file": "121487237", "label": "compound-latam", "order": "givenFirst", "shape": null, "text": "Segundo Serrano Poncela"} {"composed": false, "fields": {"given": "Segundo", "surname": "Serrano Poncela"}, "file": "121487237", "label": "compound-latam", "order": "sorting", "shape": null, "text": "Serrano Poncela, Segundo"} {"composed": true, "fields": {"given": "Amélie", "surname": "Beaury-Saurel"}, "file": "1215994877", "label": "compound-iberia", "order": "givenFirst", "shape": null, "text": "Amélie Beaury-Saurel"} {"composed": false, "fields": {"given": "Amélie", "surname": "Beaury-Saurel"}, "file": "1215994877", "label": "compound-iberia", "order": "sorting", "shape": null, "text": "Beaury-Saurel, Amélie"} {"composed": true, "fields": {"given": "Mathilde Therese", "surname": "von Thurn und Taxis"}, "file": "121734307", "label": "particle-de-at", "order": "givenFirst", "shape": null, "text": "Mathilde Therese von Thurn und Taxis"} {"composed": false, "fields": {"given": "Mathilde Therese", "surname": "von Thurn und Taxis"}, "file": "121734307", "label": "particle-de-at", "order": "sorting", "shape": null, "text": "Thurn und Taxis, Mathilde Therese von"} {"composed": true, "fields": {"given": "Alan", "surname": "Ridout"}, "file": "121803007", "label": "plain-anglo", "order": "givenFirst", "shape": null, "text": "Alan Ridout"} {"composed": false, "fields": {"given": "Alan", "surname": "Ridout"}, "file": "121803007", "label": "plain-anglo", "order": "sorting", "shape": null, "text": "Ridout, Alan"} {"composed": false, "fields": {"given": "Lukardis"}, "file": "121968987", "label": "direct-eu", "order": "givenFirst", "shape": null, "text": "Lukardis"} {"composed": true, "fields": {"given": "Jodocus", "surname-core": "Winghe", "surname-prefix": "van"}, "file": "121978877", "label": "particle-nl-be", "order": "givenFirst", "shape": null, "text": "Jodocus van Winghe"} {"composed": false, "fields": {"given": "Jodocus", "surname-core": "Winghe", "surname-prefix": "van"}, "file": "121978877", "label": "particle-nl-be", "order": "sorting", "shape": null, "text": "Winghe, Jodocus van"} {"composed": true, "fields": {"given": "Sebastian", "surname": "Neidhart"}, "file": "122063007", "label": "plain-europe", "order": "givenFirst", "shape": null, "text": "Sebastian Neidhart"} {"composed": false, "fields": {"given": "Sebastian", "surname": "Neidhart"}, "file": "122063007", "label": "plain-europe", "order": "sorting", "shape": null, "text": "Neidhart, Sebastian"} {"composed": true, "fields": {"given": "Augustus Seymour", "surname": "Porter"}, "file": "1221135007", "label": "plain-anglo", "order": "givenFirst", "shape": null, "text": "Augustus Seymour Porter"} {"composed": false, "fields": {"given": "Augustus Seymour", "surname": "Porter"}, "file": "1221135007", "label": "plain-anglo", "order": "sorting", "shape": null, "text": "Porter, Augustus Seymour"} {"composed": true, "fields": {"given": "Albertino", "surname-core": "Piazza", "surname-prefix": "della"}, "file": "122246527", "label": "particle-it", "order": "givenFirst", "shape": null, "text": "Albertino della Piazza"} {"composed": false, "fields": {"given": "Albertino", "surname-core": "Piazza", "surname-prefix": "della"}, "file": "122246527", "label": "particle-it", "order": "sorting", "shape": null, "text": "Piazza, Albertino della"} {"composed": true, "fields": {"given": "Consuelo", "surname-core": "Saint-Exupéry", "surname-prefix": "de"}, "file": "122310047", "label": "particle-fr", "order": "givenFirst", "shape": null, "text": "Consuelo de Saint-Exupéry"} {"composed": false, "fields": {"given": "Consuelo", "surname-core": "Saint-Exupéry", "surname-prefix": "de"}, "file": "122310047", "label": "particle-fr", "order": "sorting", "shape": null, "text": "Saint-Exupéry, Consuelo de"} {"composed": false, "fields": {"given": "Ásgeir Ásgeirsson"}, "file": "122313208", "label": "direct-is", "order": "givenFirst", "shape": null, "text": "Ásgeir Ásgeirsson"} {"composed": true, "fields": {"given": "Eric", "surname-core": "Bisschop", "surname-prefix": "de"}, "file": "1225143187", "label": "particle-fr", "order": "givenFirst", "shape": null, "text": "Eric de Bisschop"} {"composed": false, "fields": {"given": "Eric", "surname-core": "Bisschop", "surname-prefix": "de"}, "file": "1225143187", "label": "particle-fr", "order": "sorting", "shape": null, "text": "Bisschop, Eric de"} {"composed": true, "fields": {"generation": "I.", "given": "Malik al-ʿĀdil al"}, "file": "122734017", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Malik al-ʿĀdil al I."} {"composed": true, "fields": {"given": "Luis", "surname": "Martín-Santos"}, "file": "122760247", "label": "compound-iberia", "order": "givenFirst", "shape": null, "text": "Luis Martín-Santos"} {"composed": false, "fields": {"given": "Luis", "surname": "Martín-Santos"}, "file": "122760247", "label": "compound-iberia", "order": "sorting", "shape": null, "text": "Martín-Santos, Luis"} {"composed": true, "fields": {"given": "Antoine", "surname-core": "Gramont", "surname-prefix": "de"}, "file": "122810627", "label": "particle-fr", "order": "givenFirst", "shape": null, "text": "Antoine de Gramont"} {"composed": false, "fields": {"given": "Antoine", "surname-core": "Gramont", "surname-prefix": "de"}, "file": "122810627", "label": "particle-fr", "order": "sorting", "shape": null, "text": "Gramont, Antoine de"} {"composed": true, "fields": {"given": "Ángel", "surname": "Sanz Briz"}, "file": "122991567", "label": "compound-iberia", "order": "givenFirst", "shape": null, "text": "Ángel Sanz Briz"} {"composed": false, "fields": {"given": "Ángel", "surname": "Sanz Briz"}, "file": "122991567", "label": "compound-iberia", "order": "sorting", "shape": null, "text": "Sanz Briz, Ángel"} {"composed": true, "fields": {"given": "José Antonio", "surname": "Nieves Conde"}, "file": "123089077", "label": "compound-iberia", "order": "givenFirst", "shape": null, "text": "José Antonio Nieves Conde"} {"composed": false, "fields": {"given": "José Antonio", "surname": "Nieves Conde"}, "file": "123089077", "label": "compound-iberia", "order": "sorting", "shape": null, "text": "Nieves Conde, José Antonio"} {"composed": false, "fields": {"given": "Ledi Sayadaw"}, "file": "123225663", "label": "direct-et-mm", "order": "givenFirst", "shape": null, "text": "Ledi Sayadaw"} {"composed": false, "fields": {"given": "Sarkīs ar-Rizzī"}, "file": "1233224425", "label": "direct-arab", "order": "givenFirst", "shape": null, "text": "Sarkīs ar-Rizzī"} {"composed": true, "fields": {"given": "Hans Walter", "surname": "Klein"}, "file": "1233390007", "label": "plain-europe", "order": "givenFirst", "shape": null, "text": "Hans Walter Klein"} {"composed": false, "fields": {"given": "Hans Walter", "surname": "Klein"}, "file": "1233390007", "label": "plain-europe", "order": "sorting", "shape": null, "text": "Klein, Hans Walter"} {"composed": false, "fields": {"given": "Job"}, "file": "123438667", "label": "direct-eu", "order": "givenFirst", "shape": null, "text": "Job"} {"composed": true, "fields": {"given": "Gunter", "surname": "d'Alquen"}, "file": "123439647", "label": "particle-elided", "order": "givenFirst", "shape": null, "text": "Gunter d'Alquen"} {"composed": false, "fields": {"given": "Gunter", "surname-core": "Alquen", "surname-prefix": "d'"}, "file": "123439647", "label": "particle-elided", "order": "sorting", "shape": null, "text": "Alquen, Gunter d'"} {"composed": false, "fields": {"given": "Ahmad Al-Hadi"}, "file": "123505585", "label": "direct-my-id", "order": "givenFirst", "shape": null, "text": "Ahmad Al-Hadi"} {"composed": true, "fields": {"given": "Seymour Martin", "surname": "Lipset"}, "file": "123556007", "label": "plain-anglo", "order": "givenFirst", "shape": null, "text": "Seymour Martin Lipset"} {"composed": false, "fields": {"given": "Seymour Martin", "surname": "Lipset"}, "file": "123556007", "label": "plain-anglo", "order": "sorting", "shape": null, "text": "Lipset, Seymour Martin"} {"composed": true, "fields": {"given": "Johann", "surname-core": "Goëß", "surname-prefix": "von"}, "file": "123662427", "label": "particle-nl-be", "order": "givenFirst", "shape": null, "text": "Johann von Goëß"} {"composed": false, "fields": {"given": "Johann", "surname-core": "Goëß", "surname-prefix": "von"}, "file": "123662427", "label": "particle-nl-be", "order": "sorting", "shape": null, "text": "Goëß, Johann von"} {"composed": true, "fields": {"generation": "I.", "given": "Hermann"}, "file": "123676797", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Hermann I."} {"composed": false, "fields": {"given": "Abu Bakar"}, "file": "1237298350", "label": "direct-my-id", "order": "givenFirst", "shape": null, "text": "Abu Bakar"} {"composed": true, "fields": {"given": "Diego", "surname": "Carrillo de Mendoza y Pimentel"}, "file": "1238191037", "label": "compound-iberia", "order": "givenFirst", "shape": null, "text": "Diego Carrillo de Mendoza y Pimentel"} {"composed": false, "fields": {"given": "Diego", "surname": "Carrillo de Mendoza y Pimentel"}, "file": "1238191037", "label": "compound-iberia", "order": "sorting", "shape": null, "text": "Carrillo de Mendoza y Pimentel, Diego"} {"composed": true, "fields": {"given": "Abū-Bakr", "surname": "aš-Šiblī"}, "file": "123907373", "label": "particle-arab", "order": "givenFirst", "shape": null, "text": "Abū-Bakr aš-Šiblī"} {"composed": false, "fields": {"given": "Abū-Bakr", "surname-core": "Šiblī", "surname-prefix": "aš-"}, "file": "123907373", "label": "particle-arab", "order": "sorting", "shape": null, "text": "Šiblī, Abū-Bakr aš-"} {"composed": false, "fields": {"given": "Badurad"}, "file": "123910277", "label": "direct-eu", "order": "givenFirst", "shape": null, "text": "Badurad"} {"composed": false, "fields": {"given": "Kinji", "surname": "Imanishi"}, "file": "123938007", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Imanishi, Kinji"} {"composed": false, "fields": {"given": "Pîremêrd"}, "file": "124120750X", "label": "direct-arab", "order": "givenFirst", "shape": null, "text": "Pîremêrd"} {"composed": false, "fields": {"given": "Toju", "surname": "Nakae"}, "file": "124210007", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Nakae, Toju"} {"composed": true, "fields": {"given": "Claude François Joseph", "surname": "d'Auxiron"}, "file": "124211917", "label": "particle-fr", "order": "givenFirst", "shape": null, "text": "Claude François Joseph d'Auxiron"} {"composed": false, "fields": {"given": "Claude François Joseph", "surname-core": "Auxiron", "surname-prefix": "d'"}, "file": "124211917", "label": "particle-fr", "order": "sorting", "shape": null, "text": "Auxiron, Claude François Joseph d'"} {"composed": true, "fields": {"given": "Mariano Salvador", "surname-core": "Maella", "surname-prefix": "de"}, "file": "124391397", "label": "particle-iberia", "order": "givenFirst", "shape": null, "text": "Mariano Salvador de Maella"} {"composed": false, "fields": {"given": "Mariano Salvador", "surname-core": "Maella", "surname-prefix": "de"}, "file": "124391397", "label": "particle-iberia", "order": "sorting", "shape": null, "text": "Maella, Mariano Salvador de"} {"composed": false, "fields": {"given": "Takeshi", "surname": "Umehara"}, "file": "124470807", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Umehara, Takeshi"} {"composed": true, "fields": {"generation": "II.", "given": "Mahmud"}, "file": "124530877", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Mahmud II."} {"composed": true, "fields": {"given": "Luiz Alfredo", "surname": "Garcia-Roza"}, "file": "124665047", "label": "compound-latam", "order": "givenFirst", "shape": null, "text": "Luiz Alfredo Garcia-Roza"} {"composed": false, "fields": {"given": "Luiz Alfredo", "surname": "Garcia-Roza"}, "file": "124665047", "label": "compound-latam", "order": "sorting", "shape": null, "text": "Garcia-Roza, Luiz Alfredo"} {"composed": true, "fields": {"given": "Cornelis", "surname-core": "Dalen", "surname-prefix": "van"}, "file": "124665837", "label": "particle-nl-be", "order": "givenFirst", "shape": null, "text": "Cornelis van Dalen"} {"composed": false, "fields": {"given": "Cornelis", "surname-core": "Dalen", "surname-prefix": "van"}, "file": "124665837", "label": "particle-nl-be", "order": "sorting", "shape": null, "text": "Dalen, Cornelis van"} {"composed": false, "fields": {"given": "Svavar Guðnason"}, "file": "124714617", "label": "direct-is", "order": "givenFirst", "shape": null, "text": "Svavar Guðnason"} {"composed": true, "fields": {"given": "Ibrāhīm", "surname": "al-Yāzīǧī"}, "file": "124788165", "label": "particle-arab", "order": "givenFirst", "shape": null, "text": "Ibrāhīm al-Yāzīǧī"} {"composed": false, "fields": {"given": "Ibrāhīm", "surname-core": "Yāzīǧī", "surname-prefix": "al-"}, "file": "124788165", "label": "particle-arab", "order": "sorting", "shape": null, "text": "Yāzīǧī, Ibrāhīm al-"} {"composed": false, "fields": {"given": "Gábor", "surname": "Kállai"}, "file": "1248655907", "label": "index-hu", "order": "sorting", "shape": null, "text": "Kállai, Gábor"} {"composed": false, "fields": {"given": "Gillet"}, "file": "124880037", "label": "direct-eu", "order": "givenFirst", "shape": null, "text": "Gillet"} {"composed": true, "fields": {"given": "Carlos", "surname": "Prats Gonzaléz"}, "file": "124955487", "label": "compound-latam", "order": "givenFirst", "shape": null, "text": "Carlos Prats Gonzaléz"} {"composed": false, "fields": {"given": "Carlos", "surname": "Prats Gonzaléz"}, "file": "124955487", "label": "compound-latam", "order": "sorting", "shape": null, "text": "Prats Gonzaléz, Carlos"} {"composed": true, "fields": {"given": "Berta", "surname": "Klingberg"}, "file": "1254103007", "label": "plain-europe", "order": "givenFirst", "shape": null, "text": "Berta Klingberg"} {"composed": false, "fields": {"given": "Berta", "surname": "Klingberg"}, "file": "1254103007", "label": "plain-europe", "order": "sorting", "shape": null, "text": "Klingberg, Berta"} {"composed": true, "fields": {"given": "Gregorio", "surname": "Modrego Casaus"}, "file": "125827547", "label": "compound-iberia", "order": "givenFirst", "shape": null, "text": "Gregorio Modrego Casaus"} {"composed": false, "fields": {"given": "Gregorio", "surname": "Modrego Casaus"}, "file": "125827547", "label": "compound-iberia", "order": "sorting", "shape": null, "text": "Modrego Casaus, Gregorio"} {"composed": false, "fields": {"given": "Ismail Nasiruddin Shah"}, "file": "1260866602", "label": "direct-my-id", "order": "givenFirst", "shape": null, "text": "Ismail Nasiruddin Shah"} {"composed": false, "fields": {"given": "Emil", "surname": "Ábrányi"}, "file": "126561087", "label": "index-hu", "order": "sorting", "shape": null, "text": "Ábrányi, Emil"} {"composed": false, "fields": {"given": "Agitu Ideo Gudeta"}, "file": "1267417625", "label": "direct-et-mm", "order": "givenFirst", "shape": null, "text": "Agitu Ideo Gudeta"} {"composed": true, "fields": {"given": "François", "surname-core": "L'Aubespine", "surname-prefix": "de"}, "file": "1270634917", "label": "particle-fr", "order": "givenFirst", "shape": null, "text": "François de L'Aubespine"} {"composed": false, "fields": {"given": "François", "surname-core": "L'Aubespine", "surname-prefix": "de"}, "file": "1270634917", "label": "particle-fr", "order": "sorting", "shape": null, "text": "L'Aubespine, François de"} {"composed": true, "fields": {"given": "Andreas Bernardus", "surname-core": "Quertenmont", "surname-prefix": "de"}, "file": "1274116457", "label": "particle-nl-be", "order": "givenFirst", "shape": null, "text": "Andreas Bernardus de Quertenmont"} {"composed": false, "fields": {"given": "Andreas Bernardus", "surname-core": "Quertenmont", "surname-prefix": "de"}, "file": "1274116457", "label": "particle-nl-be", "order": "sorting", "shape": null, "text": "Quertenmont, Andreas Bernardus de"} {"composed": true, "fields": {"given": "Armando", "surname-core": "Lacerda", "surname-prefix": "de"}, "file": "127448187", "label": "particle-iberia", "order": "givenFirst", "shape": null, "text": "Armando de Lacerda"} {"composed": false, "fields": {"given": "Armando", "surname-core": "Lacerda", "surname-prefix": "de"}, "file": "127448187", "label": "particle-iberia", "order": "sorting", "shape": null, "text": "Lacerda, Armando de"} {"composed": true, "fields": {"given": "Albert", "surname": "Lederle"}, "file": "1276153007", "label": "plain-europe", "order": "givenFirst", "shape": null, "text": "Albert Lederle"} {"composed": false, "fields": {"given": "Albert", "surname": "Lederle"}, "file": "1276153007", "label": "plain-europe", "order": "sorting", "shape": null, "text": "Lederle, Albert"} {"composed": true, "fields": {"given": "Radim", "surname": "Cvrček"}, "file": "1277669007", "label": "plain-europe", "order": "givenFirst", "shape": null, "text": "Radim Cvrček"} {"composed": false, "fields": {"given": "Radim", "surname": "Cvrček"}, "file": "1277669007", "label": "plain-europe", "order": "sorting", "shape": null, "text": "Cvrček, Radim"} {"composed": true, "fields": {"given": "Luis", "surname": "Polo de Bernabé"}, "file": "1280472707", "label": "compound-iberia", "order": "givenFirst", "shape": null, "text": "Luis Polo de Bernabé"} {"composed": false, "fields": {"given": "Luis", "surname": "Polo de Bernabé"}, "file": "1280472707", "label": "compound-iberia", "order": "sorting", "shape": null, "text": "Polo de Bernabé, Luis"} {"composed": false, "fields": {"given": "Einar Arnórsson"}, "file": "128048638", "label": "direct-is", "order": "givenFirst", "shape": null, "text": "Einar Arnórsson"} {"composed": false, "fields": {"given": "Bāqir Ḫān"}, "file": "1284598136", "label": "direct-ir-af", "order": "givenFirst", "shape": null, "text": "Bāqir Ḫān"} {"composed": false, "fields": {"given": "Paku Alam VIII"}, "file": "1285103238", "label": "direct-my-id", "order": "givenFirst", "shape": null, "text": "Paku Alam VIII"} {"composed": false, "fields": {"given": "Yisao", "surname": "Zheng"}, "file": "1287179207", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Zheng, Yisao"} {"composed": false, "fields": {"given": "Stephan Guðmundsson Stephansson"}, "file": "128739088", "label": "direct-is", "order": "givenFirst", "shape": null, "text": "Stephan Guðmundsson Stephansson"} {"composed": true, "fields": {"given": "Marianne", "surname": "Schmidt"}, "file": "128741007", "label": "plain-europe", "order": "givenFirst", "shape": null, "text": "Marianne Schmidt"} {"composed": false, "fields": {"given": "Marianne", "surname": "Schmidt"}, "file": "128741007", "label": "plain-europe", "order": "sorting", "shape": null, "text": "Schmidt, Marianne"} {"composed": false, "fields": {"given": "Shahnon Ahmad"}, "file": "128851473", "label": "direct-my-id", "order": "givenFirst", "shape": null, "text": "Shahnon Ahmad"} {"composed": true, "fields": {"given": "Kamāl Yūsuf", "surname": "al-Ḥāǧǧ"}, "file": "128897732", "label": "particle-arab", "order": "givenFirst", "shape": null, "text": "Kamāl Yūsuf al-Ḥāǧǧ"} {"composed": false, "fields": {"given": "Kamāl Yūsuf", "surname-core": "Ḥāǧǧ", "surname-prefix": "al-"}, "file": "128897732", "label": "particle-arab", "order": "sorting", "shape": null, "text": "Ḥāǧǧ, Kamāl Yūsuf al-"} {"composed": false, "fields": {"given": "Tschagribeg"}, "file": "128967781", "label": "direct-ir-af", "order": "givenFirst", "shape": null, "text": "Tschagribeg"} {"composed": false, "fields": {"given": "Shirō", "surname": "Ozaki"}, "file": "129055107", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Ozaki, Shirō"} {"composed": false, "fields": {"given": "Antico"}, "file": "129065307", "label": "direct-eu", "order": "givenFirst", "shape": null, "text": "Antico"} {"composed": true, "fields": {"given": "Costanza", "surname-core": "Varano", "surname-prefix": "da"}, "file": "129069787", "label": "particle-it", "order": "givenFirst", "shape": null, "text": "Costanza da Varano"} {"composed": false, "fields": {"given": "Costanza", "surname-core": "Varano", "surname-prefix": "da"}, "file": "129069787", "label": "particle-it", "order": "sorting", "shape": null, "text": "Varano, Costanza da"} {"composed": true, "fields": {"given": "Fadrique", "surname": "de Toledo Osorio"}, "file": "1292092467", "label": "particle-iberia", "order": "givenFirst", "shape": null, "text": "Fadrique de Toledo Osorio"} {"composed": false, "fields": {"given": "Fadrique", "surname": "de Toledo Osorio"}, "file": "1292092467", "label": "particle-iberia", "order": "sorting", "shape": null, "text": "Toledo Osorio, Fadrique de"} {"composed": false, "fields": {"given": "Gerður Helgadóttir"}, "file": "129229970", "label": "direct-is", "order": "givenFirst", "shape": null, "text": "Gerður Helgadóttir"} {"composed": false, "fields": {"given": "Stefán Karl Stefánsson"}, "file": "1293064432", "label": "direct-is", "order": "givenFirst", "shape": null, "text": "Stefán Karl Stefánsson"} {"composed": false, "fields": {"given": "Guðmundur Böðvarsson"}, "file": "12937427X", "label": "direct-is", "order": "givenFirst", "shape": null, "text": "Guðmundur Böðvarsson"} {"composed": false, "fields": {"given": "Anan ben David"}, "file": "129465046", "label": "direct-arab", "order": "givenFirst", "shape": null, "text": "Anan ben David"} {"composed": true, "fields": {"given": "Hermann Martin", "surname": "Popert"}, "file": "129574007", "label": "plain-europe", "order": "givenFirst", "shape": null, "text": "Hermann Martin Popert"} {"composed": false, "fields": {"given": "Hermann Martin", "surname": "Popert"}, "file": "129574007", "label": "plain-europe", "order": "sorting", "shape": null, "text": "Popert, Hermann Martin"} {"composed": false, "fields": {"given": "Xianyi", "surname": "Yang"}, "file": "129708607", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Yang, Xianyi"} {"composed": true, "fields": {"given": "Isala", "surname-core": "Diest", "surname-prefix": "van"}, "file": "129764477", "label": "particle-nl-be", "order": "givenFirst", "shape": null, "text": "Isala van Diest"} {"composed": false, "fields": {"given": "Isala", "surname-core": "Diest", "surname-prefix": "van"}, "file": "129764477", "label": "particle-nl-be", "order": "sorting", "shape": null, "text": "Diest, Isala van"} {"composed": true, "fields": {"given": "Johannes", "surname": "d'Outrein"}, "file": "129765767", "label": "particle-nl-be", "order": "givenFirst", "shape": null, "text": "Johannes d'Outrein"} {"composed": false, "fields": {"given": "Johannes", "surname-core": "Outrein", "surname-prefix": "d'"}, "file": "129765767", "label": "particle-nl-be", "order": "sorting", "shape": null, "text": "Outrein, Johannes d'"} {"composed": true, "fields": {"given": "Maria Firmina", "surname-core": "Reis", "surname-prefix": "dos"}, "file": "129778567", "label": "particle-iberia", "order": "givenFirst", "shape": null, "text": "Maria Firmina dos Reis"} {"composed": false, "fields": {"given": "Maria Firmina", "surname-core": "Reis", "surname-prefix": "dos"}, "file": "129778567", "label": "particle-iberia", "order": "sorting", "shape": null, "text": "Reis, Maria Firmina dos"} {"composed": true, "fields": {"given": "Charles", "surname": "Wilkins"}, "file": "129794007", "label": "plain-anglo", "order": "givenFirst", "shape": null, "text": "Charles Wilkins"} {"composed": false, "fields": {"given": "Charles", "surname": "Wilkins"}, "file": "129794007", "label": "plain-anglo", "order": "sorting", "shape": null, "text": "Wilkins, Charles"} {"composed": false, "fields": {"given": "Lim Boo Liat"}, "file": "1298312620", "label": "direct-my-id", "order": "givenFirst", "shape": null, "text": "Lim Boo Liat"} {"composed": true, "fields": {"given": "Charles Claude de La Billarderie", "surname": "d'Angiviller"}, "file": "129854697", "label": "particle-elided", "order": "givenFirst", "shape": null, "text": "Charles Claude de La Billarderie d'Angiviller"} {"composed": false, "fields": {"given": "Charles Claude de La Billarderie", "surname-core": "Angiviller", "surname-prefix": "d'"}, "file": "129854697", "label": "particle-elided", "order": "sorting", "shape": null, "text": "Angiviller, Charles Claude de La Billarderie d'"} {"composed": true, "fields": {"generation": "IV.", "given": "Friedrich"}, "file": "129981117", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Friedrich IV."} {"composed": false, "fields": {"given": "Subandrio"}, "file": "130044741", "label": "direct-my-id", "order": "givenFirst", "shape": null, "text": "Subandrio"} {"composed": true, "fields": {"given": "Joan Antoni", "surname": "Desvalls i d’Ardena"}, "file": "1302221957", "label": "compound-iberia", "order": "givenFirst", "shape": null, "text": "Joan Antoni Desvalls i d’Ardena"} {"composed": false, "fields": {"given": "Joan Antoni", "surname": "Desvalls i d’Ardena"}, "file": "1302221957", "label": "compound-iberia", "order": "sorting", "shape": null, "text": "Desvalls i d’Ardena, Joan Antoni"} {"composed": true, "fields": {"given": "Karl", "surname": "Lustig-Prean"}, "file": "130234737", "label": "compound-latam", "order": "givenFirst", "shape": null, "text": "Karl Lustig-Prean"} {"composed": false, "fields": {"given": "Karl", "surname": "Lustig-Prean"}, "file": "130234737", "label": "compound-latam", "order": "sorting", "shape": null, "text": "Lustig-Prean, Karl"} {"composed": false, "fields": {"given": "Takashi", "surname": "Kōno"}, "file": "130370207", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Kōno, Takashi"} {"composed": false, "fields": {"given": "Samson", "surname": "Wertheimer"}, "file": "130548677", "label": "index-hu", "order": "sorting", "shape": null, "text": "Wertheimer, Samson"} {"composed": false, "fields": {"given": "Abdullah bin Haji Ahmad Badawi"}, "file": "130656097", "label": "direct-my-id", "order": "givenFirst", "shape": null, "text": "Abdullah bin Haji Ahmad Badawi"} {"composed": true, "fields": {"given": "Hebe", "surname-core": "Bonafini", "surname-prefix": "de"}, "file": "130823767", "label": "particle-iberia", "order": "givenFirst", "shape": null, "text": "Hebe de Bonafini"} {"composed": false, "fields": {"given": "Hebe", "surname-core": "Bonafini", "surname-prefix": "de"}, "file": "130823767", "label": "particle-iberia", "order": "sorting", "shape": null, "text": "Bonafini, Hebe de"} {"composed": false, "fields": {"given": "Diponegoro"}, "file": "130894346", "label": "direct-my-id", "order": "givenFirst", "shape": null, "text": "Diponegoro"} {"composed": true, "fields": {"given": "Āqā Buzurg", "surname": "aṭ-Ṭihrānī"}, "file": "130897035", "label": "particle-arab", "order": "givenFirst", "shape": null, "text": "Āqā Buzurg aṭ-Ṭihrānī"} {"composed": false, "fields": {"given": "Āqā Buzurg", "surname-core": "Ṭihrānī", "surname-prefix": "aṭ-"}, "file": "130897035", "label": "particle-arab", "order": "sorting", "shape": null, "text": "Ṭihrānī, Āqā Buzurg aṭ-"} {"composed": true, "fields": {"given": "José", "surname": "Robles Pazos"}, "file": "131443097", "label": "compound-iberia", "order": "givenFirst", "shape": null, "text": "José Robles Pazos"} {"composed": false, "fields": {"given": "José", "surname": "Robles Pazos"}, "file": "131443097", "label": "compound-iberia", "order": "sorting", "shape": null, "text": "Robles Pazos, José"} {"composed": false, "fields": {"given": "Hussein Onn"}, "file": "13167398X", "label": "direct-my-id", "order": "givenFirst", "shape": null, "text": "Hussein Onn"} {"composed": false, "fields": {"given": "Ba Maw"}, "file": "1316845230", "label": "direct-et-mm", "order": "givenFirst", "shape": null, "text": "Ba Maw"} {"composed": true, "fields": {"given": "Antonio", "surname": "García y Bellido"}, "file": "131889877", "label": "compound-iberia", "order": "givenFirst", "shape": null, "text": "Antonio García y Bellido"} {"composed": false, "fields": {"given": "Antonio", "surname": "García y Bellido"}, "file": "131889877", "label": "compound-iberia", "order": "sorting", "shape": null, "text": "García y Bellido, Antonio"} {"composed": false, "fields": {"given": "Frederik Jacobus van Rossum", "surname": "Chattel"}, "file": "132187507", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Chattel, Frederik Jacobus van Rossum"} {"composed": true, "fields": {"generation": "II.", "given": "Johann"}, "file": "132255847", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Johann II."} {"composed": true, "fields": {"given": "Rafael", "surname": "Frühbeck de Burgos"}, "file": "132686147", "label": "compound-iberia", "order": "givenFirst", "shape": null, "text": "Rafael Frühbeck de Burgos"} {"composed": false, "fields": {"given": "Rafael", "surname": "Frühbeck de Burgos"}, "file": "132686147", "label": "compound-iberia", "order": "sorting", "shape": null, "text": "Frühbeck de Burgos, Rafael"} {"composed": true, "fields": {"given": "Daniel", "surname": "Ayala Pérez"}, "file": "132883317", "label": "compound-latam", "order": "givenFirst", "shape": null, "text": "Daniel Ayala Pérez"} {"composed": false, "fields": {"given": "Daniel", "surname": "Ayala Pérez"}, "file": "132883317", "label": "compound-latam", "order": "sorting", "shape": null, "text": "Ayala Pérez, Daniel"} {"composed": false, "fields": {"given": "Chikanobu", "surname": "Hashimoto"}, "file": "132919907", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Hashimoto, Chikanobu"} {"composed": false, "fields": {"given": "Humbelina"}, "file": "133115437", "label": "direct-eu", "order": "givenFirst", "shape": null, "text": "Humbelina"} {"composed": false, "fields": {"given": "Peter", "surname": "Foldès"}, "file": "133392597", "label": "index-hu", "order": "sorting", "shape": null, "text": "Foldès, Peter"} {"composed": true, "fields": {"given": "Félicie", "surname-core": "Fauveau", "surname-prefix": "de"}, "file": "133414507", "label": "particle-fr", "order": "givenFirst", "shape": null, "text": "Félicie de Fauveau"} {"composed": false, "fields": {"given": "Félicie", "surname-core": "Fauveau", "surname-prefix": "de"}, "file": "133414507", "label": "particle-fr", "order": "sorting", "shape": null, "text": "Fauveau, Félicie de"} {"composed": false, "fields": {"given": "Mattā al-Miskīn"}, "file": "13358285X", "label": "direct-arab", "order": "givenFirst", "shape": null, "text": "Mattā al-Miskīn"} {"composed": false, "fields": {"given": "Michio", "surname": "Takeyama"}, "file": "133597407", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Takeyama, Michio"} {"composed": false, "fields": {"given": "Emil", "surname-core": "Ábrányi", "surname-prefix": "von"}, "file": "133641147", "label": "index-hu", "order": "sorting", "shape": null, "text": "Ábrányi, Emil von"} {"composed": true, "fields": {"given": "Luciano", "surname": "Mendes de Almeida"}, "file": "133824047", "label": "compound-latam", "order": "givenFirst", "shape": null, "text": "Luciano Mendes de Almeida"} {"composed": false, "fields": {"given": "Luciano", "surname": "Mendes de Almeida"}, "file": "133824047", "label": "compound-latam", "order": "sorting", "shape": null, "text": "Mendes de Almeida, Luciano"} {"composed": true, "fields": {"given": "Philips", "surname-core": "Almonde", "surname-prefix": "van"}, "file": "133827887", "label": "particle-nl-be", "order": "givenFirst", "shape": null, "text": "Philips van Almonde"} {"composed": false, "fields": {"given": "Philips", "surname-core": "Almonde", "surname-prefix": "van"}, "file": "133827887", "label": "particle-nl-be", "order": "sorting", "shape": null, "text": "Almonde, Philips van"} {"composed": true, "fields": {"given": "Nikolaj Evgenʹevič", "surname": "Lansere"}, "file": "133890007", "label": "plain-europe", "order": "givenFirst", "shape": null, "text": "Nikolaj Evgenʹevič Lansere"} {"composed": false, "fields": {"given": "Nikolaj Evgenʹevič", "surname": "Lansere"}, "file": "133890007", "label": "plain-europe", "order": "sorting", "shape": null, "text": "Lansere, Nikolaj Evgenʹevič"} {"composed": true, "fields": {"given": "Dionisio", "surname": "Aguado y García"}, "file": "133912027", "label": "compound-iberia", "order": "givenFirst", "shape": null, "text": "Dionisio Aguado y García"} {"composed": false, "fields": {"given": "Dionisio", "surname": "Aguado y García"}, "file": "133912027", "label": "compound-iberia", "order": "sorting", "shape": null, "text": "Aguado y García, Dionisio"} {"composed": true, "fields": {"given": "Paul", "surname-core": "Maleingreau", "surname-prefix": "de"}, "file": "134453387", "label": "particle-nl-be", "order": "givenFirst", "shape": null, "text": "Paul de Maleingreau"} {"composed": false, "fields": {"given": "Paul", "surname-core": "Maleingreau", "surname-prefix": "de"}, "file": "134453387", "label": "particle-nl-be", "order": "sorting", "shape": null, "text": "Maleingreau, Paul de"} {"composed": false, "fields": {"given": "Andreas Martin"}, "file": "134456467", "label": "direct-eu", "order": "givenFirst", "shape": null, "text": "Andreas Martin"} {"composed": true, "fields": {"given": "Tony", "surname": "Miranda"}, "file": "134827007", "label": "plain-anglo", "order": "givenFirst", "shape": null, "text": "Tony Miranda"} {"composed": false, "fields": {"given": "Tony", "surname": "Miranda"}, "file": "134827007", "label": "plain-anglo", "order": "sorting", "shape": null, "text": "Miranda, Tony"} {"composed": true, "fields": {"given": "Teddy", "surname": "Bunn"}, "file": "134856007", "label": "plain-anglo", "order": "givenFirst", "shape": null, "text": "Teddy Bunn"} {"composed": false, "fields": {"given": "Teddy", "surname": "Bunn"}, "file": "134856007", "label": "plain-anglo", "order": "sorting", "shape": null, "text": "Bunn, Teddy"} {"composed": false, "fields": {"given": "L. U. P. O."}, "file": "135094097", "label": "direct-eu", "order": "givenFirst", "shape": null, "text": "L. U. P. O."} {"composed": true, "fields": {"given": "Walter", "surname": "Leschetizky"}, "file": "135249007", "label": "plain-europe", "order": "givenFirst", "shape": null, "text": "Walter Leschetizky"} {"composed": false, "fields": {"given": "Walter", "surname": "Leschetizky"}, "file": "135249007", "label": "plain-europe", "order": "sorting", "shape": null, "text": "Leschetizky, Walter"} {"composed": true, "fields": {"given": "Donna Jean", "surname": "Godchaux"}, "file": "135510007", "label": "plain-anglo", "order": "givenFirst", "shape": null, "text": "Donna Jean Godchaux"} {"composed": false, "fields": {"given": "Donna Jean", "surname": "Godchaux"}, "file": "135510007", "label": "plain-anglo", "order": "sorting", "shape": null, "text": "Godchaux, Donna Jean"} {"composed": true, "fields": {"generation": "I.", "given": "Ulrich"}, "file": "135683637", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Ulrich I."} {"composed": false, "fields": {"given": "Theodor", "surname": "Joset"}, "file": "135723507", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Joset, Theodor"} {"composed": false, "fields": {"given": "Djuanda"}, "file": "135828465", "label": "direct-my-id", "order": "givenFirst", "shape": null, "text": "Djuanda"} {"composed": false, "fields": {"given": "Steingrímur SteinÞórsson"}, "file": "135858755", "label": "direct-is", "order": "givenFirst", "shape": null, "text": "Steingrímur SteinÞórsson"} {"composed": true, "fields": {"generation": "III.", "given": "Bruno"}, "file": "135951437", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Bruno III."} {"composed": true, "fields": {"generation": "IV.", "given": "Gottfried"}, "file": "136052967", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Gottfried IV."} {"composed": false, "fields": {"given": "Ísleifur Gizursson"}, "file": "13606860X", "label": "direct-is", "order": "givenFirst", "shape": null, "text": "Ísleifur Gizursson"} {"composed": false, "fields": {"given": "Khun Sa"}, "file": "136135064", "label": "direct-et-mm", "order": "givenFirst", "shape": null, "text": "Khun Sa"} {"composed": true, "fields": {"given": "Hermann", "surname-core": "Kusmanek", "surname-prefix": "von"}, "file": "136223907", "label": "particle-de-at", "order": "givenFirst", "shape": null, "text": "Hermann von Kusmanek"} {"composed": false, "fields": {"given": "Hermann", "surname-core": "Kusmanek", "surname-prefix": "von"}, "file": "136223907", "label": "particle-de-at", "order": "sorting", "shape": null, "text": "Kusmanek, Hermann von"} {"composed": false, "fields": {"given": "Rashid Maidin"}, "file": "13628471X", "label": "direct-my-id", "order": "givenFirst", "shape": null, "text": "Rashid Maidin"} {"composed": true, "fields": {"given": "Fien", "surname-core": "Mar", "surname-prefix": "de la"}, "file": "136386687", "label": "particle-nl-be", "order": "givenFirst", "shape": null, "text": "Fien de la Mar"} {"composed": false, "fields": {"given": "Fien", "surname-core": "Mar", "surname-prefix": "de la"}, "file": "136386687", "label": "particle-nl-be", "order": "sorting", "shape": null, "text": "Mar, Fien de la"} {"composed": false, "fields": {"given": "Nūr al-Dīn"}, "file": "136516106", "label": "direct-arab", "order": "givenFirst", "shape": null, "text": "Nūr al-Dīn"} {"composed": true, "fields": {"given": "Francisco", "surname": "Gutiérrez Carreola"}, "file": "136563457", "label": "compound-latam", "order": "givenFirst", "shape": null, "text": "Francisco Gutiérrez Carreola"} {"composed": false, "fields": {"given": "Francisco", "surname": "Gutiérrez Carreola"}, "file": "136563457", "label": "compound-latam", "order": "sorting", "shape": null, "text": "Gutiérrez Carreola, Francisco"} {"composed": true, "fields": {"generation": "III.", "given": "Otto"}, "file": "136732607", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Otto III."} {"composed": true, "fields": {"given": "Hermann", "surname-core": "Hagen", "surname-prefix": "von"}, "file": "136818307", "label": "particle-de-at", "order": "givenFirst", "shape": null, "text": "Hermann von Hagen"} {"composed": false, "fields": {"given": "Hermann", "surname-core": "Hagen", "surname-prefix": "von"}, "file": "136818307", "label": "particle-de-at", "order": "sorting", "shape": null, "text": "Hagen, Hermann von"} {"composed": false, "fields": {"given": "Dietwin"}, "file": "137260997", "label": "direct-eu", "order": "givenFirst", "shape": null, "text": "Dietwin"} {"composed": true, "fields": {"generation": "I.", "given": "Heinrich"}, "file": "137324847", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Heinrich I."} {"composed": false, "fields": {"given": "Tahir Jalaluddin"}, "file": "13775390X", "label": "direct-my-id", "order": "givenFirst", "shape": null, "text": "Tahir Jalaluddin"} {"composed": true, "fields": {"generation": "V.", "given": "Friedrich"}, "file": "137841817", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Friedrich V."} {"composed": true, "fields": {"given": "Arnold", "surname": "Neher"}, "file": "137998007", "label": "plain-europe", "order": "givenFirst", "shape": null, "text": "Arnold Neher"} {"composed": false, "fields": {"given": "Arnold", "surname": "Neher"}, "file": "137998007", "label": "plain-europe", "order": "sorting", "shape": null, "text": "Neher, Arnold"} {"composed": true, "fields": {"given": "Blas", "surname-core": "Lezo", "surname-prefix": "de"}, "file": "138122067", "label": "particle-iberia", "order": "givenFirst", "shape": null, "text": "Blas de Lezo"} {"composed": false, "fields": {"given": "Blas", "surname-core": "Lezo", "surname-prefix": "de"}, "file": "138122067", "label": "particle-iberia", "order": "sorting", "shape": null, "text": "Lezo, Blas de"} {"composed": true, "fields": {"given": "Bernhard", "surname-core": "Solms-Braunfels", "surname-prefix": "zu"}, "file": "138468907", "label": "particle-de-at", "order": "givenFirst", "shape": null, "text": "Bernhard zu Solms-Braunfels"} {"composed": false, "fields": {"given": "Bernhard", "surname-core": "Solms-Braunfels", "surname-prefix": "zu"}, "file": "138468907", "label": "particle-de-at", "order": "sorting", "shape": null, "text": "Solms-Braunfels, Bernhard zu"} {"composed": false, "fields": {"given": "Ni-Tanjung"}, "file": "138569819", "label": "direct-my-id", "order": "givenFirst", "shape": null, "text": "Ni-Tanjung"} {"composed": true, "fields": {"generation": "I.", "given": "Thomas"}, "file": "138621497", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Thomas I."} {"composed": true, "fields": {"given": "Jan", "surname-core": "Dijk", "surname-prefix": "van"}, "file": "138741247", "label": "particle-nl-be", "order": "givenFirst", "shape": null, "text": "Jan van Dijk"} {"composed": false, "fields": {"given": "Jan", "surname-core": "Dijk", "surname-prefix": "van"}, "file": "138741247", "label": "particle-nl-be", "order": "sorting", "shape": null, "text": "Dijk, Jan van"} {"composed": true, "fields": {"generation": "I.", "given": "Vladislav"}, "file": "139287647", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Vladislav I."} {"composed": true, "fields": {"given": "Juan", "surname-core": "Ayolas", "surname-prefix": "de"}, "file": "139612467", "label": "particle-iberia", "order": "givenFirst", "shape": null, "text": "Juan de Ayolas"} {"composed": false, "fields": {"given": "Juan", "surname-core": "Ayolas", "surname-prefix": "de"}, "file": "139612467", "label": "particle-iberia", "order": "sorting", "shape": null, "text": "Ayolas, Juan de"} {"composed": false, "fields": {"given": "Tomonori", "surname": "Ki"}, "file": "140612807", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Ki, Tomonori"} {"composed": false, "fields": {"given": "Pendeta Zaba"}, "file": "140876936", "label": "direct-my-id", "order": "givenFirst", "shape": null, "text": "Pendeta Zaba"} {"composed": true, "fields": {"given": "Arie", "surname-core": "Arend", "surname-prefix": "den"}, "file": "140971807", "label": "particle-nl-be", "order": "givenFirst", "shape": null, "text": "Arie den Arend"} {"composed": false, "fields": {"given": "Arie", "surname-core": "Arend", "surname-prefix": "den"}, "file": "140971807", "label": "particle-nl-be", "order": "sorting", "shape": null, "text": "Arend, Arie den"} {"composed": true, "fields": {"given": "Benjamin Louis Eulalie", "surname-core": "Bonneville", "surname-prefix": "de"}, "file": "141311207", "label": "particle-fr", "order": "givenFirst", "shape": null, "text": "Benjamin Louis Eulalie de Bonneville"} {"composed": false, "fields": {"given": "Benjamin Louis Eulalie", "surname-core": "Bonneville", "surname-prefix": "de"}, "file": "141311207", "label": "particle-fr", "order": "sorting", "shape": null, "text": "Bonneville, Benjamin Louis Eulalie de"} {"composed": false, "fields": {"given": "Kōichi", "surname": "Kishi"}, "file": "141381507", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Kishi, Kōichi"} {"composed": false, "fields": {"given": "Gunnar Benediktsson"}, "file": "141518618", "label": "direct-is", "order": "givenFirst", "shape": null, "text": "Gunnar Benediktsson"} {"composed": false, "fields": {"given": "Imam Bonjol"}, "file": "14240439X", "label": "direct-my-id", "order": "givenFirst", "shape": null, "text": "Imam Bonjol"} {"composed": false, "fields": {"given": "ʿAbdallāh Ibn-ʿAbdalqādir"}, "file": "142486175", "label": "direct-my-id", "order": "givenFirst", "shape": null, "text": "ʿAbdallāh Ibn-ʿAbdalqādir"} {"composed": true, "fields": {"given": "Josep", "surname": "Balari i Jovany"}, "file": "142675067", "label": "compound-iberia", "order": "givenFirst", "shape": null, "text": "Josep Balari i Jovany"} {"composed": false, "fields": {"given": "Josep", "surname": "Balari i Jovany"}, "file": "142675067", "label": "compound-iberia", "order": "sorting", "shape": null, "text": "Balari i Jovany, Josep"} {"composed": false, "fields": {"given": "Jirō", "surname": "Takamatsu"}, "file": "142729507", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Takamatsu, Jirō"} {"composed": true, "fields": {"generation": "III.", "given": "Otto"}, "file": "142734527", "label": "regnal", "order": "givenFirst", "shape": null, "text": "Otto III."} {"composed": false, "fields": {"given": "Bunchō", "surname": "Ippitsusai"}, "file": "142870307", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Ippitsusai, Bunchō"} {"composed": true, "fields": {"given": "Hudā", "surname": "aš-Šaʿrāwī"}, "file": "151529558", "label": "particle-arab", "order": "givenFirst", "shape": null, "text": "Hudā aš-Šaʿrāwī"} {"composed": false, "fields": {"given": "Hudā", "surname-core": "Šaʿrāwī", "surname-prefix": "aš-"}, "file": "151529558", "label": "particle-arab", "order": "sorting", "shape": null, "text": "Šaʿrāwī, Hudā aš-"} {"composed": true, "fields": {"given": "Juan C.", "surname": "Garaikoetxea Urriza"}, "file": "170741427", "label": "compound-iberia", "order": "givenFirst", "shape": null, "text": "Juan C. Garaikoetxea Urriza"} {"composed": false, "fields": {"given": "Juan C.", "surname": "Garaikoetxea Urriza"}, "file": "170741427", "label": "compound-iberia", "order": "sorting", "shape": null, "text": "Garaikoetxea Urriza, Juan C."} {"composed": false, "fields": {"given": "Baʿal-Maḥshoves̀"}, "file": "171963997", "label": "direct-eu", "order": "givenFirst", "shape": null, "text": "Baʿal-Maḥshoves̀"} {"composed": true, "fields": {"given": "Eduardo", "surname": "Caballero Calderón"}, "file": "172013437", "label": "compound-latam", "order": "givenFirst", "shape": null, "text": "Eduardo Caballero Calderón"} {"composed": false, "fields": {"given": "Eduardo", "surname": "Caballero Calderón"}, "file": "172013437", "label": "compound-latam", "order": "sorting", "shape": null, "text": "Caballero Calderón, Eduardo"} {"composed": false, "fields": {"given": "Tarō", "surname": "Yamamoto"}, "file": "172468507", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Yamamoto, Tarō"} {"composed": false, "fields": {"given": "Kho Liang Ie"}, "file": "174317840", "label": "direct-my-id", "order": "givenFirst", "shape": null, "text": "Kho Liang Ie"} {"composed": false, "fields": {"given": "Ásmundur Sveinsson"}, "file": "174346352", "label": "direct-is", "order": "givenFirst", "shape": null, "text": "Ásmundur Sveinsson"} {"composed": false, "fields": {"given": "Yingda", "surname": "Kong"}, "file": "176221107", "label": "index-cjk", "order": "sorting", "shape": null, "text": "Kong, Yingda"} {"composed": true, "fields": {"given": "Ricardo", "surname": "de Orueta y Duarte"}, "file": "188410767", "label": "particle-iberia", "order": "givenFirst", "shape": null, "text": "Ricardo de Orueta y Duarte"} {"composed": false, "fields": {"given": "Ricardo", "surname": "de Orueta y Duarte"}, "file": "188410767", "label": "particle-iberia", "order": "sorting", "shape": null, "text": "Orueta y Duarte, Ricardo de"} {"composed": true, "fields": {"given": "Domingo", "surname": "Santa María González"}, "file": "189570547", "label": "compound-latam", "order": "givenFirst", "shape": null, "text": "Domingo Santa María González"} {"composed": false, "fields": {"given": "Domingo", "surname": "Santa María González"}, "file": "189570547", "label": "compound-latam", "order": "sorting", "shape": null, "text": "Santa María González, Domingo"} {"composed": true, "fields": {"given": "Remigio", "surname": "Gandásegui y Gorrochátegui"}, "file": "189575697", "label": "compound-iberia", "order": "givenFirst", "shape": null, "text": "Remigio Gandásegui y Gorrochátegui"} {"composed": false, "fields": {"given": "Remigio", "surname": "Gandásegui y Gorrochátegui"}, "file": "189575697", "label": "compound-iberia", "order": "sorting", "shape": null, "text": "Gandásegui y Gorrochátegui, Remigio"} ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/local_agreement.py�����������������������������0000664�0000000�0000000�00000061757�15273032717�0026216�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Where the parser's reading of a local corpus disagrees with the fields its filers typed. The SCORE job (mechanisms.md#NAME-DATA-JOBS ("the job decides where its data lives")) for a local corpus whose records carry the name's fields beside it: each record's display name is parsed with the tree, and each field is compared with the box the filer typed it into (`psc-individuals`' title, forename, middle name and surname boxes; `orcid-researchers`' given-names box, holding the middle names too, and family-name box, typed by the holder). What cldr_agreement.py does for the committed fields files, over names that are never committed -- so no CI run and no reviewer sees this score, and its record lives in the cache beside the corpus, where a re-run reports what moved since it was recorded: uv run python tools/differential/local_agreement.py psc-individuals --base ../master-checkout --base PATH scores the base checkout from a record kept under agreements/ beside the corpus, keyed like snapshot.py's baselines by the fetch, a hash of the base's nameparser/ files, the interpreter's minor version and the scorer's fingerprint, and scored in a child process where none is kept; it never reads or writes the shared record, which whoever last recorded decides. The records are scored in a worker process per CPU (--jobs N), the base's child too. Without --base a check compares against that shared record: uv run python tools/differential/local_agreement.py psc-individuals --record --tree ../master-checkout uv run python tools/differential/local_agreement.py psc-individuals The report prints counts and shapes, never a record: agreement overall and by the group a source states (a nationality, a country), the misses per field, the commonest disagreement shapes, a shape being each disagreeing field's word count as filed and as parsed (`middle 0->1, family 2->1` is a two-word surname whose first word was read as a middle name), and the set-aside title boxes at least TITLE_FLOOR records share (a word that many filers typed is no one person's name, though it may be a name). `--list N` prints up to N moved records of each kind, names and all, for reading at one's own terminal. What counts: - Each field is compared case-folded, with periods dropped, commas read as spaces and runs of spaces read as one. The display name is the boxes joined, so the parser sees the filer's own spelling, and the fold only keeps a box's case, periods, commas or spacing from reading as a miss: a suffix typed into the surname box after a comma (`Smith, Jr.`) is the parser's family and suffix rejoined. - There is no suffix box. A suffix the parser peels (`Jr`, `OBE`) stood last in the display name, so it was typed into the last filled box, normally the surname: the parser's family is compared with its suffix appended, and the report counts the agreements that needed it. - Where a source has a title box, a filed title the parser does not read as the title, holding a word the vocabulary lists in neither its titles nor its connectives, is set aside rather than scored: a job (`Managing Partner`) or a name typed into the title box is the filer's error, and it can move the parse's other fields too. The set-aside titles are printed with their counts where at least TITLE_FLOOR records share one (the --top commonest), as leads for the vocabulary (a title nobody listed lands there as well). The record is keyed to the corpus's records and to the scorer's own code (scorer_fingerprint), so a refetch, or a change to how a field is compared, is refused rather than reported as the parser's moves. """ from __future__ import annotations import argparse import json import os import shutil import subprocess import sys import types from collections import Counter from collections.abc import Callable, Iterator from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any HERE = Path(__file__).resolve().parent # The shared helpers and the local corpora, found as the corpus builders # find corpus_build (see its docstring for why the directory is appended). if str(HERE) not in sys.path: sys.path.append(str(HERE)) import corpus_build # noqa: E402 import local_corpora # noqa: E402 if TYPE_CHECKING: # Imported at run time only once --tree has put the tree on sys.path. from nameparser import Lexicon, Parser RECORD = "agreement.jsonl" #: The parser's roles a Fielded box may name, held to nameparser's Role #: by test_local_agreement (nameparser is imported only once --tree runs). ROLES = frozenset({"title", "given", "middle", "family", "suffix", "nickname", "maiden"}) @dataclass(frozen=True) class Fielded: """How a local corpus states its fields: record key -> the parser's role it is compared with, in the order the display name joins them, and the record key the report groups by. A box holding more than one role names them joined by "+", and is compared with those roles' values joined by a space in that order: ORCID's given-names box holds the middle names too. A source with no title box sets nothing aside. The group is title-cased for the report unless `group_cased` says it is written as it should print (a country code).""" fields: dict[str, str] group: str group_cased: bool = False def __post_init__(self) -> None: # A misspelled role would find no box (_box), and a misspelled # title would quietly turn the set-aside off. unknown = {r for role in self.fields.values() for r in role.split("+")} - ROLES if unknown or "family" not in "+".join(self.fields.values()).split("+"): raise ValueError(f"Fielded({self.fields}) names {sorted(unknown) or 'no family'}:" f" each box names roles of {sorted(ROLES)}, a family among them") FIELDED = {local_corpora.PSC: Fielded( {"title": "title", "forename": "given", "middle_name": "middle", "surname": "family"}, group="nationality"), local_corpora.ORCID: Fielded( {"given": "given+middle", "family": "family"}, group="country", group_cased=True), local_corpora.PV: Fielded( {"given": "given+middle", "family": "family"}, group="country", group_cased=True)} #: The fewest records sharing a set-aside title for the report to print #: it: a word typed into the title box by this many filers is no one #: person's name. TITLE_FLOOR = 100 #: The fewest records of a group for the report to give it a row. GROUP_FLOOR = 1_000 def fold(text: str) -> str: return " ".join(text.replace(".", "").replace(",", " ").casefold().split()) def _title_words(lexicon: Lexicon) -> frozenset[str]: return frozenset(lexicon.titles) | frozenset(lexicon.conjunctions) def unlisted(title: str, listed: frozenset[str]) -> bool: """Whether a filed title holds a word the vocabulary lists in neither its titles nor its connectives (`listed`).""" return any(word.strip(".").casefold() not in listed for word in title.split()) @dataclass(frozen=True) class Reading: """One record's reading: each field as parsed where it disagrees with the box, whether the record was set aside, and whether the family agreed only with the parser's suffix appended.""" got: dict[str, str] set_aside: bool = False by_suffix: bool = False def entry(self) -> dict[str, Any]: return {"got": self.got, **({"set_aside": True} if self.set_aside else {})} def read(record: dict[str, str], fielded: Fielded, parser: Parser, listed: frozenset[str]) -> Reading: """`record` parsed and compared box by box (see the module docstring).""" name = parser.parse(record["name"]) got = {role: " ".join(v for r in role.split("+") if (v := getattr(name, r))) for role in fielded.fields.values()} by_suffix = False if name.suffix: family = fielded.fields[_box(fielded, "family")] with_suffix = f"{got[family]} {name.suffix}".strip() by_suffix = fold(with_suffix) == fold(record.get(_box(fielded, "family"), "")) got[family] = with_suffix wrong = {role: got[role] for box, role in fielded.fields.items() if fold(record.get(box, "")) != fold(got[role])} title = record.get(_box(fielded, "title"), "") if title and "title" in wrong and unlisted(title, listed): return Reading(wrong, set_aside=True) return Reading(wrong, by_suffix=by_suffix and not wrong) def _box(fielded: Fielded, role: str) -> str: """The box `role` is compared with, or "" where none holds it.""" return next((box for box, r in fielded.fields.items() if role in r.split("+")), "") def shape(record: dict[str, str], fielded: Fielded, got: dict[str, str]) -> str: """Each disagreeing field's word count as filed and as parsed.""" return ", ".join(f"{role} {len(record.get(box, '').split())}->{len(got[role].split())}" for box, role in fielded.fields.items() if role in got) def key(record: dict[str, str]) -> str: return corpus_build.jsonl_line(record, sort_keys=True) def group_of(record: dict[str, str], fielded: Fielded) -> str: group = " ".join(record.get(fielded.group, "").split()) return (group if fielded.group_cased else group.title()) or "(none)" def records(local: local_corpora.Local) -> Iterator[dict[str, str]]: """raw.jsonl's records, every one read and checked before the first is handed out (local_corpora.read_records), each nothing but strings: CorpusDamaged before any caller reports or records what it read.""" return iter(local_corpora.read_records(local.source, local.meta, text_only=True)) @dataclass class Score: """What one run read: per group, the records scored and agreeing and the misses per field; the set-aside titles; the shapes; and every record that did not wholly agree, by key.""" scored: Counter[str] agreed: Counter[str] misses: dict[str, Counter[str]] set_aside: Counter[str] shapes: Counter[str] by_suffix: int entries: dict[str, dict[str, Any]] def score(rows: Iterator[dict[str, str]] | list[dict[str, str]], fielded: Fielded, parser: Parser, *, jobs: int = 1) -> Score: """Every record read (read) and counted. With `jobs` above 1 the records are read in that many worker processes (snapshot.parallel), each with a Parser() of its own, so `parser` must be the default one: a configured parser cannot be sent to a worker by its name.""" rows = list(rows) if jobs > 1 and parser != type(parser)(): raise ValueError("score() reads in workers with the default Parser(); " "pass jobs=1 to score with another parser") if jobs > 1: from snapshot import parallel source = next(s for s, f in FIELDED.items() if f == fielded) readings = parallel(_read_chunk, rows, jobs, source, module="local_agreement") else: listed = _title_words(parser.lexicon) readings = [read(record, fielded, parser, listed) for record in rows] result = Score(Counter(), Counter(), {}, Counter(), Counter(), 0, {}) for record, reading in zip(rows, readings, strict=True): if reading.got: result.entries[key(record)] = reading.entry() if reading.set_aside: result.set_aside[record.get(_box(fielded, "title"), "")] += 1 continue group = group_of(record, fielded) result.scored[group] += 1 if not reading.got: result.agreed[group] += 1 result.by_suffix += reading.by_suffix continue result.misses.setdefault(group, Counter()).update(reading.got.keys()) result.shapes[shape(record, fielded, reading.got)] += 1 return result def _read_chunk(rows: list[dict[str, str]], source: str) -> tuple[str, list[Reading]]: """The nameparser this process imported, and its reading of every record of `source`: score()'s unit of work in a worker.""" import nameparser parser = nameparser.Parser() listed = _title_words(parser.lexicon) return nameparser.__file__, [read(r, FIELDED[source], parser, listed) for r in rows] def _pct(n: int, of: int) -> str: return f"{100 * n / of:5.1f}%" if of else " n/a" def report(result: Score, fielded: Fielded, top: int) -> list[str]: roles = list(fielded.fields.values()) total, agreed = sum(result.scored.values()), sum(result.agreed.values()) misses: Counter[str] = sum(result.misses.values(), Counter()) out = [f"scored {total:,}; set aside {sum(result.set_aside.values()):,} " f"(a title box the parser read otherwise, holding an unlisted word)", f"every field agrees: {agreed:,} of {total:,} ({_pct(agreed, total).strip()}); " f"{result.by_suffix:,} of them with the parser's suffix in the surname box", "misses per field: " + ", ".join(f"{r} {misses[r]:,}" for r in roles), "", f"by {fielded.group} (at least {GROUP_FLOOR:,} records), misses per field:", f" {fielded.group:<24} {'records':>10} {'agree':>7} " + " ".join(f"{r:>8}" for r in roles)] for group, n in result.scored.most_common(): if n < GROUP_FLOOR: break row = result.misses.get(group, Counter()) out.append(f" {group[:24]:<24} {n:>10,} {_pct(result.agreed[group], n)} " + " ".join(f"{row[r]:>8,}" for r in roles)) out += ["", f"the {top} commonest disagreement shapes (field: words filed->parsed):"] out += [f" {n:>9,} {s}" for s, n in result.shapes.most_common(top)] out += ["", f"set-aside title boxes written by at least {TITLE_FLOOR} records:"] out += [f" {n:>9,} {t}" for t, n in result.set_aside.most_common() if n >= TITLE_FLOOR][:top] return out def record_path(source: str) -> Path: return local_corpora.directory(source, create=False) / RECORD def write_record(path: Path, local: local_corpora.Local, entries: dict[str, dict[str, Any]], tree: str) -> None: header = {"raw_sha256": local.meta["sha256"], "scorer": scorer_fingerprint(local.source), "data_date": local.meta["data_date"], "tree": tree, "entries": len(entries)} lines = [corpus_build.jsonl_line(header)] lines += [corpus_build.jsonl_line({"key": k, **v}) for k, v in sorted(entries.items())] corpus_build.write_atomic(path, ("\n".join(lines) + "\n").encode()) def read_record(path: Path, local: local_corpora.Local) -> dict[str, dict[str, Any]]: """The recorded entries, or SystemExit naming the record command where there is no record, one that is not a record this tool wrote, one recorded from another fetch of the corpus, which would report the records the source gained and lost as moves, or one recorded by another version of the scorer, which would report its own change as the parser's.""" again = f"Record it again from the base checkout: {_record_command(local.source)}" if not path.exists(): raise SystemExit(f"no record at {path}: record one first, from the base " f"checkout: {_record_command(local.source)}") try: header, *rows = corpus_build.jsonl_lines(path.read_text(encoding="utf-8")) recorded = json.loads(header) entries = {row["key"]: {k: v for k, v in row.items() if k != "key"} for row in map(json.loads, rows)} if not isinstance(recorded, dict): raise TypeError("its header is not an object") except (OSError, ValueError, KeyError, TypeError, AttributeError) as exc: raise SystemExit(f"{path} is not a record this tool wrote ({exc}). {again}" ) from None if recorded.get("raw_sha256") != local.meta["sha256"]: raise SystemExit( f"{path} was recorded from another fetch of {local.source} (data as " f"of {recorded.get('data_date', 'unknown')}; the corpus now is " f"{local.meta['data_date']}). {again}") # A record written before the header counted its entries passes; a # count that disagrees is a write cut short on a line boundary. if (count := recorded.get("entries", len(entries))) != len(entries): raise SystemExit(f"{path} holds {len(entries):,} entries where its header " f"counts {count!r}. {again}") if recorded.get("scorer") != scorer_fingerprint(local.source): raise SystemExit(f"{path} was recorded by another version of this scorer, " f"so its moves would be the scorer's. {again}") return entries #: What decides a record's reading, besides the parser: scorer_fingerprint #: hashes them, as local_corpora.fold_fingerprint hashes the word fold. _SCORER_CODE = ("fold", "_title_words", "unlisted", "read", "_box", "Reading", "key", "score", "_read_chunk") def scorer_fingerprint(source: str) -> str: """local_corpora.code_fingerprint of the code that turns a parse into a record's entry and decides which records get one (_SCORER_CODE, a class by its methods), of corpus_build.jsonl_line, which writes an entry's key, and of `source`'s own FIELDED entry, so a source added or changed leaves the others' records standing.""" module = globals() functions: list[Callable[..., object]] = [] for name in _SCORER_CODE: target = module[name] functions += ([v for v in vars(target).values() if isinstance(v, types.FunctionType)] if isinstance(target, type) else [target]) return local_corpora.code_fingerprint( [*functions, corpus_build.jsonl_line], [repr((source, FIELDED[source]))]) #: Where --base's kept records live, beside the shared one. KEPT = "agreements" def kept_path(source: str, local: local_corpora.Local, digest: str) -> Path: """Where a record of `local`'s fetch scored by a package of `digest` is kept: keyed by everything a record depends on besides the parser (the fetch, the interpreter's minor version, the scorer), so a key that matches is a record of this base.""" py = f"py{sys.version_info[0]}{sys.version_info[1]}" return (record_path(source).parent / KEPT / f"{local.meta['sha256'][:12]}-{digest[:16]}-{py}" f"-{scorer_fingerprint(source)[:12]}.jsonl") def _score_base(root: Path, source: str, out: Path, jobs: int = 1) -> None: """`root` scored into `out` in a child process (one process imports one nameparser), which refuses a package other than `root`'s.""" import snapshot command = [sys.executable, str(HERE / "local_agreement.py"), source, "--record", "--tree", str(root), "--out", str(out), "--jobs", str(jobs)] done = subprocess.run(command, env=snapshot.child_env(), capture_output=True, text=True) if done.returncode: raise SystemExit(f"scoring the base {root} failed:\n{done.stdout}{done.stderr}") def base_entries(root: Path, local: local_corpora.Local, jobs: int = 1 ) -> dict[str, dict[str, Any]]: """The base checkout's record, kept for its package's digest or scored into one now, and a line saying which base was read. Never the shared record: a sibling session's would pass for this base.""" import snapshot digest = snapshot._source_digest(root) path = kept_path(local.source, local, digest) how = f"reused the kept record {path.name}" if path.exists(): try: entries = read_record(path, local) except SystemExit as exc: print(f"deleted the kept record {path.name}: {exc}; scoring the base\n") path.unlink(missing_ok=True) else: try: os.utime(path) except FileNotFoundError: # another run pruned it meanwhile pass print(f"base: {root}, nameparser/ {digest[:16]}; {how}\n") return entries # Scored into a folder of this run's own and renamed into place only # once the digest, taken again, says the base did not change under # the child: a record published under the key it was scored for. at = snapshot.scratch(path.parent) try: _score_base(root, local.source, at / path.name, jobs) entries = read_record(at / path.name, local) if snapshot._source_digest(root) != digest: raise SystemExit(f"{root / 'nameparser'} changed while it was scored, " f"so its record may be half old and half new; run again") os.replace(at / path.name, path) finally: shutil.rmtree(at, ignore_errors=True) snapshot._prune(path.parent, "*.jsonl") print(f"base: {root}, nameparser/ {digest[:16]}; scored it now\n") return entries def _record_command(source: str) -> str: return (f"local_agreement.py {source} --record --tree <base checkout>; or, " f"where another session may be measuring, check against a base you " f"name instead: local_agreement.py {source} --base <base checkout>") def moves(old: dict[str, dict[str, Any]], new: dict[str, dict[str, Any]], ) -> dict[str, list[str]]: """Each record whose reading moved, by kind: `regression` (it agreed and now does not), `progress` (it disagreed and now agrees), `changed` (it disagrees, or is set aside, differently). Both sides read the same fetch, so no record is gained or lost.""" found: dict[str, list[str]] = {"regression": [], "progress": [], "changed": []} for k in sorted(set(old) | set(new)): if k not in old: found["regression"].append(k) elif k not in new: found["progress"].append(k) elif old[k] != new[k]: found["changed"].append(k) return found def summarize(found: dict[str, list[str]], old: dict[str, dict[str, Any]], new: dict[str, dict[str, Any]]) -> list[str]: """One line per kind of move, with the fields it moved: a field counts where the record disagrees on it on either side.""" out = [] for kind, keys in found.items(): if not keys: continue fields: Counter[str] = Counter() for k in keys: fields.update(set(old.get(k, {}).get("got", {})) | set(new.get(k, {}).get("got", {}))) out.append(f"{kind}: {len(keys):,} records (" + ", ".join(f"{f} {n:,}" for f, n in fields.most_common()) + ")") return out def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("source", choices=sorted(FIELDED)) ap.add_argument("--record", action="store_true", help="rewrite the record kept beside the corpus") ap.add_argument("--tree", metavar="PATH", help="read with another checkout") ap.add_argument("--base", metavar="PATH", help="compare against this checkout's score, kept by its " "package's digest, instead of the shared record") ap.add_argument("--out", type=Path, metavar="PATH", help="where --record writes (default: the shared record)") ap.add_argument("--top", type=int, default=15, metavar="N") ap.add_argument("--list", type=int, default=0, metavar="N", help="print up to N moved records of each kind, names and all") ap.add_argument("--jobs", type=int, metavar="N", default=os.cpu_count() or 1, help="score in N worker processes (default: every CPU)") args = ap.parse_args(argv) if args.base and (args.record or args.out): ap.error("--base compares and records nothing but its kept record; " "drop --record and --out") if args.out and not args.record: ap.error("--out names where --record writes") local = local_corpora.require(args.source) root = Path(args.tree).resolve() if args.tree else HERE.parents[1] if args.base: from snapshot import base_root old = base_entries(base_root(args.base, root), local, args.jobs) elif not args.record: old = read_record(record_path(args.source), local) if args.tree: sys.path.insert(0, str(root)) import nameparser from snapshot import check_source at = check_source(nameparser.__file__, root) fielded = FIELDED[args.source] print(local.header()) try: result = score(records(local), fielded, nameparser.Parser(), jobs=args.jobs) except local_corpora.CorpusDamaged as exc: raise SystemExit(str(exc)) from None print("\n".join(report(result, fielded, args.top))) if args.record: out = args.out or record_path(args.source) write_record(out, local, result.entries, str(at.parent)) print(f"\nrecorded {len(result.entries):,} records that did not wholly " f"agree to {out}") return 0 found = moves(old, result.entries) print() if args.base: from snapshot import head_line print(head_line(root, Path(args.base).resolve())) print("\n".join(summarize(found, old, result.entries)) or ("matches the base" if args.base else "matches the record")) for kind, keys in found.items(): for k in keys[:args.list]: print(f" {kind}: {k} recorded {old.get(k)} now {result.entries.get(k)}") return 1 if any(found.values()) else 0 if __name__ == "__main__": sys.exit(main()) �����������������derek73-python-nameparser-456b024/tools/differential/local_core.py����������������������������������0000664�0000000�0000000�00000022121�15273032717�0025155�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""The machinery the local corpora's sources share: what a fetch returns, the retry policy, the gate on what a reader reads past, downloads, and the cache folder. local_corpora.py holds the SOURCES registry, the cache's own files, the views (tally, pairs, the word index) and the command line; each local_<source>.py holds one source's fetch. A source module imports this one and never local_corpora, which imports the source modules to build SOURCES. """ from __future__ import annotations import http.client import os import sys import time import urllib.error import urllib.request import zipfile from collections.abc import Callable, Iterable, Iterator, Mapping from dataclasses import dataclass from pathlib import Path from typing import Any, TypeVar import corpus_build USER_AGENT = ("python-nameparser local_corpora " "(https://github.com/derek73/python-nameparser)") @dataclass(frozen=True) class Fetched: """What a source's fetch returns: one record per name, each holding at least "name", and the source's own date for the data.""" records: list[dict[str, str]] data_date: str #: Anything else worth keeping (API version, counts), stored in #: meta.json under "detail", where it cannot replace a key local_corpora.load() checks. detail: dict[str, Any] @dataclass(frozen=True) class Source: credit: str #: What one record is, for counts: "officials", "names". unit: str fetch: Callable[[], Fetched] #: What `fetch --refresh` runs first, for a source whose fetch reads #: an input cached beside the corpus: fetches that input anew, #: replacing the cached copy only once the new one is whole. renew: Callable[[], None] | None = None #: Why local_corpora.load() should refuse a meta.json this version of the source #: did not write (another FEC cycle), or "" to accept it. check: Callable[[dict[str, Any]], str] | None = None #: The record keys whose values the corpus and the word index hold as #: names, an empty value skipped: "name", and for a source whose #: records carry the name in a second script, that script's key #: (nepal-candidates' "devanagari"). meta.json records them, and a #: corpus written under other keys is refused on load. corpus_keys: tuple[str, ...] = ("name",) #: For a source whose cached extract holds every record uncapped, its #: name in two boxes and a country (orcid-researchers, #: patentsview-inventors): the extract's path, and a reader yielding #: each record as (given box, family box, country, weight), the #: weight being the people the record stands for. #: local_corpora.box_index counts words by box and country from them, #: for tools/name_usage.py's per-country rows. extract: Callable[[], Path] | None = None boxes: Callable[[Path], Iterator[tuple[str, str, str, int]]] | None = None #: Attempts per request, for every source that retries. RETRIES = 4 #: How much of a streamed download is read at a time. CHUNK = 1 << 20 #: What a long run of requests meets and a retry survives: a refused or #: dropped connection or a timeout (OSError, which URLError and HTTPError #: are), a body cut short in the read (http.client's IncompleteRead), a #: 5xx or a 429; and, caught apart from these, a body that arrived but #: does not parse. _TRANSIENT = (OSError, http.client.HTTPException) _T = TypeVar("_T") class _Unparsed(ValueError): """A response body that is not JSON: cut short in transit.""" def _get_json(url: str) -> dict[str, Any]: """`url`'s JSON object, fetched by _retried: a long paged run (ClinicalTrials.gov's 608 pages on 2026-10-21) should not start over for one failed page.""" data = _retried(url, lambda body: corpus_build.parse_json(body, url), 0.1) if not isinstance(data, dict): raise ValueError(f"{url} answered {type(data).__name__}, not an object") return data def _zip_stamp(info: zipfile.ZipInfo) -> str: """A zip member's own timestamp, as a data date: the sources that ship a zip state no other date for what is in it.""" return "{:04d}-{:02d}-{:02d}T{:02d}:{:02d}:{:02d}".format(*info.date_time) def _pinned(key: str, wanted: Callable[[], str], says: str ) -> Callable[[dict[str, Any]], str]: """A Source.check refusing a corpus whose meta.json detail `key` is not `wanted()` now, read at check time so that a change to the constant behind it refuses corpora fetched before; `says` words the refusal, with {held} and {wanted}.""" def check(meta: dict[str, Any]) -> str: held, now = meta["detail"].get(key), wanted() return "" if held == now else says.format(held=held, wanted=now) return check def _refuse_past(rows: Iterable[tuple[int, int, float, str]], why: str) -> None: """The gate every source puts on what it reads past: for each (count, of, share, what), refuse with "count of `of` what: why" once count is more than `share` of `of`. A file whose layout moved under a reader would otherwise read as a smaller corpus.""" for count, of, share, what in rows: if count > of * share: raise ValueError(f"{count:,} of {of:,} {what}: {why}") def _backed_off(exc: Exception, attempt: int, url: str, retries: int | None = None) -> bool: """The retry policy every source shares, after `exc` on try `attempt` of `url`: False where the caller should raise it (a 4xx other than 429 is the request's fault, and the last of `retries` attempts' error is the fetch's; RETRIES unless a caller whose attempt is cheap beside what giving up loses asks for more), else True once a backoff of 2**attempt seconds up to a minute, or the Retry-After a server asks for up to a minute, has been slept, with a line on stderr.""" if (isinstance(exc, urllib.error.HTTPError) and exc.code < 500 and exc.code != 429 or attempt == (RETRIES if retries is None else retries) - 1): return False wait = min(2 ** attempt, 60) if isinstance(exc, urllib.error.HTTPError): after = exc.headers.get("Retry-After", "") if exc.headers else "" if after.isdigit(): wait = max(wait, min(int(after), 60)) exc.close() # an HTTPError holds the response open print(f" retrying in {wait}s after {type(exc).__name__}: {url}", file=sys.stderr) time.sleep(wait) return True def _retried(url: str, read: Callable[[bytes], _T], delay: float, *, timeout: int = 60, headers: Mapping[str, str] | None = None) -> _T: """`url`'s body as `read` reads it, under _backed_off's policy; any ValueError from `read` is retried as a body cut short in transit, and `delay` seconds of sleep follow each request. `headers` go with each request, a User-Agent among them replacing USER_AGENT.""" for attempt in range(RETRIES): try: body = corpus_build.fetch_bytes(url, user_agent=USER_AGENT, timeout=timeout, delay=delay, headers=headers) try: return read(body) except ValueError as exc: raise _Unparsed(f"{url}: {exc}") from None except (*_TRANSIENT, _Unparsed) as exc: if not _backed_off(exc, attempt, url): raise raise AssertionError("unreachable: the last attempt raises") def _download(url: str, path: Path) -> None: """Download `url` to `path` through a .part file, refusing a body whose length differs from the stated Content-Length, or with no Content-Length at all: a cut zip would otherwise fail later as a BadZipFile that says nothing about the download. `path` is replaced only once the new file is whole, and a failed download leaves no .part behind.""" print(f"downloading {url} ...", file=sys.stderr) request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) partial = path.with_name(path.name + ".part") try: got = 0 with urllib.request.urlopen(request, timeout=60) as response, \ partial.open("wb") as out: stated = response.headers.get("Content-Length", "") while chunk := response.read(CHUNK): out.write(chunk) got += len(chunk) if not stated.isdigit() or int(stated) != got: raise ValueError(f"{url} sent {got:,} bytes where it stated " f"{stated or 'no length'}; fetch again") except BaseException: partial.unlink(missing_ok=True) raise os.replace(partial, path) def fetch_command(source: str, *, refresh: bool = False) -> str: """The command that fetches `source`, for messages that name it.""" return (f"tools/differential/local_corpora.py fetch {source}" + (" --refresh" if refresh else "")) def _again(source: str) -> str: return f"; fetch it again: {fetch_command(source, refresh=True)}" def directory(source: str, *, create: bool = True) -> Path: """`source`'s folder in the cache, unchecked: a source module names its own corpus. local_corpora.directory is the checked one.""" return corpus_build.cache_dir(f"local/{source}", create=create) �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/local_corpora.py�������������������������������0000664�0000000�0000000�00000213137�15273032717�0025703�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Local corpora: whole names fetched into the cache, never committed. A local corpus does the SURVEY job of the five in mechanisms.md#NAME-DATA-JOBS ("the job decides where its data lives"): real names as people type them, read with the snapshot's machinery, kept out of the repository because the names are real people's and nobody vetted them for publication. It also does WATCH outside CI (snapshot.py --local), and, for a source whose records carry the fields their filers typed, SCORE outside CI (local_agreement.py): no reviewer and no CI run can see either. What reaches the repository is what a contributor cites: counts, and shapes written with made-up names. uv run python tools/differential/local_corpora.py fetch ctgov-officials uv run python tools/differential/local_corpora.py tally ctgov-officials uv run python tools/differential/local_corpora.py pairs tse-donors uv run python tools/differential/local_corpora.py groups jrc-names uv run python tools/differential/local_corpora.py alike jrc-names --base ../master-checkout uv run python tools/differential/snapshot.py --local ctgov-officials `pairs --agreement`, `groups` and `alike` parse each distinct name once, in a worker process per CPU (`--jobs N`). `alike` counts the persons (or the pairs, for a source of pairs) whose spellings read alike in a source that pairs them (ALIKE_SOURCES), and with `--base` the ones that now read alike and now apart: the base's flags are kept under alike/ beside the corpus, keyed like snapshot.py's baselines by the fetch, a hash of the base's nameparser/ files, the interpreter's minor version and the view's code (alike_fingerprint), and read in a child process where none is kept. `fetch` writes three files into local/<source>/ under $NAMEPARSER_TOOLS_CACHE (default ~/.cache/nameparser-tools), and `snapshot.py --local --record` keeps its snapshot beside them: - raw.jsonl -- one record per name as the source gave it, with where it came from where the source says: a ClinicalTrials.gov official, with the study and role; one distinct FEC, Taiwan or UK donor name, alone; one distinct pair of a TSE donor's filed name and the tax registry's name for the same donor; one distinct PSC filed name, with its four boxes and nationality; one distinct spelling of a JRC-Names person, with its entity and script; one distinct pair of a Nepali candidate's romanized name and the Devanagari one; one distinct ORCID record's two boxes joined, with the boxes, the credit name and the country with where it came from; one distinct PatentsView inventor name in a country, its two boxes joined, with the boxes, the country and how many people filed under it. The counts are counted from here. - corpus.jsonl -- the distinct names, in the corpus line format snapshot.py and compare.py --corpus read: each record's "name", and for nepal-candidates its Devanagari name too, and for orcid-researchers the credit name (Source.corpus_keys). - meta.json -- the source's own date for the data, what was fetched, and the sha256 of both files, checked on every load: a truncated file would otherwise read as a run of false zeros. - snapshot.jsonl, and snapshot.jsonl.source.json naming the corpus it was recorded from, so a snapshot is never compared with a corpus fetched after it; snapshot.sample-N.jsonl and its .source.json for a --sample N reading; and baselines/, the recordings snapshot.py reuses. - wordindex.json -- the word index tools/name_usage.py looks words up in, written on first use and keyed to the records, the fold and the record keys it reads (cached_index), so a single-word lookup parses neither raw.jsonl's records nor the names (load() still reads both files for their sha256). - boxindex.json.gz -- for a source whose extract holds every record's boxes and country (orcid-researchers, patentsview-inventors), its words counted by box and country over the whole extract, uncapped, for tools/name_usage.py's per-country rows; written on first use and keyed to the extract's bytes and the code that counts them (cached_box_index). A live source has no fixed snapshot to pin, so the cached copy IS the pin: a count quoted from it names the data date the header prints, and a later fetch may differ. A new source is a module of its own, local_<source>.py, built on local_core.py's shared machinery, and a SOURCES row: a fetch function returning the records and the source's data date, and for a source whose fetch depends on a cached input or a pinned setting, the hooks that renew it on a refresh and refuse a corpus read from another version of it (tw-donors needs neither: each zip it caches is named by the version the listing gives). What each source is, what it is the population for and what its terms allow is its module's docstring: local_ctgov.py (ctgov-officials), local_fec.py (fec-individuals), local_tse.py (tse-donors), local_tw.py (tw-donors), local_ukec.py (ukec-individuals), local_psc.py (psc-individuals), local_jrc.py (jrc-names), local_nepal.py (nepal-candidates), local_orcid.py (orcid-researchers) and local_patentsview.py (patentsview-inventors). """ from __future__ import annotations import argparse import functools import dataclasses import difflib import gzip import hashlib import inspect import json import os import re import sys import time import types import unicodedata import zlib from collections import Counter from collections.abc import Callable, Iterable from dataclasses import dataclass, field from pathlib import Path from typing import Any, Literal, NamedTuple # The shared fetch and cache helpers (corpus_build.py, local_core.py) and # the source modules (local_<source>.py), found by putting this directory # on sys.path, which the source modules rely on rather than doing it # themselves: see corpus_build's docstring for why. _HERE = str(Path(__file__).resolve().parent) if _HERE not in sys.path: sys.path.append(_HERE) import corpus_build # noqa: E402 import local_core # noqa: E402 from local_core import Fetched as Fetched, Source, _again, fetch_command # noqa: E402 from local_ctgov import fetch_ctgov # noqa: E402 import local_fec # noqa: E402 import local_tse # noqa: E402 from local_fec import check_fec, fetch_fec, renew_fec # noqa: E402 from local_jrc import JRC, check_jrc, fetch_jrc, renew_jrc # noqa: E402 from local_nepal import check_nepal, fetch_nepal # noqa: E402 from local_orcid import (ORCID, check_orcid, extract_path as orcid_extract, # noqa: E402 fetch_orcid, orcid_boxes, renew_orcid) from local_patentsview import (PV, check_pv, extract_path as pv_extract, # noqa: E402 fetch_pv, pv_boxes, renew_pv) from local_psc import PSC, check_psc, fetch_psc, renew_psc # noqa: E402 from local_tse import check_tse, fetch_tse # noqa: E402 from local_tw import fetch_tw # noqa: E402 from local_ukec import fetch_ukec # noqa: E402 RAW, CORPUS, META, SNAPSHOT = "raw.jsonl", "corpus.jsonl", "meta.json", "snapshot.jsonl" INDEX = "wordindex.json" BOX_INDEX = "boxindex.json.gz" META_VERSION = 2 # 2: corpus.jsonl's sha256, and the source's detail nested #: The keys load() needs in meta.json, with their types. _META_KEYS = {"version": int, "source": str, "data_date": str, "records": int, "names": int, "sha256": str, "corpus_sha256": str, "detail": dict} class CorpusDamaged(ValueError): """A cached corpus that is not what fetch() wrote: its message names the fetch that replaces it. A ValueError, which every caller already catches, but a class of its own, so that a caller guarding a lookup catches the corpus's damage and not a bug of its own.""" @dataclass(frozen=True) class Local: """A local corpus as loaded: meta.json, and its records, read from raw.jsonl on first use: a lookup answered from the cached word index needs none of them (1,893,964 FEC records took 1.1s to parse on 2026-10-22). Two loads of one fetch compare equal, the meta.json a fetch writes holding the sha256 of the records.""" source: str meta: dict[str, Any] #: The records when they are already in hand (a fetch, a test). preloaded: list[dict[str, str]] | None = field(default=None, compare=False, repr=False) # No slots=True: the cached properties write the instance __dict__. @functools.cached_property def records(self) -> list[dict[str, str]]: if self.preloaded is not None: return self.preloaded return read_records(self.source, self.meta) @property def size(self) -> int: """How many records, without reading them where meta.json says.""" return self.meta["records"] if "records" in self.meta else len(self.records) @property def credit(self) -> str: """The source's credit and its data date, which ClinicalTrials.gov's terms ask every report to carry, as the FEC's use restriction is carried in its credit.""" return f"{SOURCES[self.source].credit}, data as of {self.meta['data_date']}" def header(self) -> str: """The attribution every report opens with.""" return (f"{self.credit}; {self.size:,} " f"{SOURCES[self.source].unit} (local corpus {self.source})") @property def corpus_keys(self) -> tuple[str, ...]: """The record keys whose names the corpus and the word index read (Source.corpus_keys), or "name" alone for a source not in SOURCES.""" source = SOURCES.get(self.source) return source.corpus_keys if source else ("name",) @functools.cached_property def index(self) -> WordIndex: """The word index, read from its cache beside the corpus or built and cached there (cached_index).""" return cached_index(self) SOURCES: dict[str, Source] = { "ctgov-officials": Source( credit="ClinicalTrials.gov (NLM) study officials", unit="officials", fetch=fetch_ctgov), "fec-individuals": Source( credit=f"FEC individual contributions, {local_fec.FEC_CYCLE} cycle " f"(52 U.S.C. 30111(a)(4): not for soliciting or commercial use)", unit="names", fetch=fetch_fec, renew=renew_fec, check=check_fec), "tse-donors": Source( credit=f"TSE (Tribunal Superior Eleitoral) candidates' receipts from " f"individuals, {' and '.join(local_tse.TSE_ELECTIONS)} elections " f"(dadosabertos.tse.jus.br, CC BY)", unit="name pairs", fetch=fetch_tse, check=check_tse), "tw-donors": Source( credit="Control Yuan political donation reports (ardata.cy.gov.tw), " "Open Government Data License, version 1.0 (Taiwan) " "(the platform's terms: not for profit, credit investigation " "or other improper use)", unit="names", fetch=fetch_tw), "ukec-individuals": Source( credit="UK Electoral Commission register of donations, individual " "donors (reuse terms for the register not stated)", unit="names", fetch=fetch_ukec), "nepal-candidates": Source( credit="Election Commission, Nepal: candidates of the 2079 BS (2022) " "local elections (result.election.gov.np; no licence stated)", unit="name pairs", fetch=fetch_nepal, check=check_nepal, corpus_keys=("name", "devanagari")), PSC: Source( credit="Companies House people with significant control, individuals " "(a public register: Companies House sets no terms on its use, " "and data protection law applies to the names)", unit="filed names", fetch=fetch_psc, renew=renew_psc, check=check_psc), JRC: Source( credit="JRC-Names (European Commission Joint Research Centre), person " "entities (licensed under the JRC-Names EULA: personal use, no " "redistribution or derivative works)", unit="spellings", fetch=fetch_jrc, renew=renew_jrc, check=check_jrc), ORCID: Source( credit="ORCID Public Data File 2025, public names and countries " "(CC0 1.0; ORCID asks for credit and respect for the privacy " "of the people in it)", unit="records", fetch=fetch_orcid, renew=renew_orcid, check=check_orcid, corpus_keys=("name", "credit"), extract=orcid_extract, boxes=orcid_boxes), PV: Source( credit="PatentsView granted-patent inventors, 1976-2025, from the USPTO " "Open Data Portal (CC BY 4.0; credit PatentsView)", unit="names", fetch=fetch_pv, renew=renew_pv, check=check_pv, extract=pv_extract, boxes=pv_boxes), } # -- the cache --------------------------------------------------------------- def directory(source: str, *, create: bool = True) -> Path: if source not in SOURCES: raise ValueError(f"no local corpus {source!r}; the sources are " + ", ".join(sorted(SOURCES))) return local_core.directory(source, create=create) def fetch(source: str) -> Local: """Fetch `source` and write raw.jsonl, corpus.jsonl and meta.json, in that order. A run that dies between the writes leaves files the old meta.json's sha256s do not match, which load() refuses.""" path = directory(source) fetched = SOURCES[source].fetch() if not fetched.records: raise ValueError(f"{source}: the fetch found no names") if any(not isinstance(r.get("name"), str) or not r["name"].strip() for r in fetched.records): raise ValueError(f"{source}: a fetched record has no name") keys = SOURCES[source].corpus_keys for key in keys[1:]: if any(not isinstance(r.get(key, ""), str) for r in fetched.records): raise ValueError(f"{source}: a fetched record's {key!r} is not text") if not any(r.get(key, "").strip() for r in fetched.records): raise ValueError(f"{source}: no fetched record holds the corpus key " f"{key!r}: a key misspelled, or a column emptied") raw = "".join(corpus_build.jsonl_line(r, sort_keys=True) + "\n" for r in fetched.records).encode() lines = corpus_build.corpus_lines( {"text": r[key], "shape": None} for r in fetched.records for key in keys if r.get(key, "").strip()) corpus = ("\n".join(lines) + "\n").encode() corpus_build.write_atomic(path / RAW, raw) corpus_build.write_atomic(path / CORPUS, corpus) meta = {"version": META_VERSION, "source": source, "data_date": fetched.data_date, "fetched": time.strftime("%Y-%m-%d"), "records": len(fetched.records), "names": len(lines), "corpus_keys": list(keys), "sha256": hashlib.sha256(raw).hexdigest(), "corpus_sha256": hashlib.sha256(corpus).hexdigest(), "detail": fetched.detail} corpus_build.write_atomic(path / META, json.dumps(meta, indent=2).encode()) return Local(source, meta, fetched.records) def load(source: str) -> Local | None: """The cached corpus, or None when it was never fetched. Anything else that is not the corpus fetch() wrote -- a meta.json of another version or shape, a raw.jsonl or corpus.jsonl its sha256 does not match -- raises CorpusDamaged naming the way out: a damaged file must not read as a smaller corpus. The records are read on first use, and a record without a name raises there (read_records).""" path = directory(source, create=False) if not (path / META).exists(): return None again = _again(source) try: meta = json.loads((path / META).read_text(encoding="utf-8")) except ValueError as exc: raise CorpusDamaged(f"{path / META} is not JSON ({exc}){again}") from None if not isinstance(meta, dict) or meta.get("version") != META_VERSION: raise CorpusDamaged(f"{path / META} was not written by this version of " f"this tool{again}") for key, kind in _META_KEYS.items(): value = meta.get(key) if not isinstance(value, kind) or isinstance(value, bool): raise CorpusDamaged(f"{path / META} has no {kind.__name__} {key!r}{again}") if (check := SOURCES[source].check) and (why := check(meta)): raise CorpusDamaged(f"{path / META} {why}{again}") # A meta.json written before corpus_keys were recorded holds "name"'s # corpus, as every source's then was. keys = list(SOURCES[source].corpus_keys) if meta.get("corpus_keys", ["name"]) != keys: raise CorpusDamaged(f"{path / META} holds a corpus of the record keys " f"{meta.get('corpus_keys', ['name'])}, and this tool " f"reads {keys}{again}") _verified(source, RAW, meta["sha256"]) _verified(source, CORPUS, meta["corpus_sha256"]) return Local(source, meta) def _verified(source: str, name: str, sha256: str) -> bytes: """`source`'s file `name`, or ValueError naming the way out unless it matches the sha256 meta.json gives it.""" path = directory(source, create=False) / name body = path.read_bytes() if path.exists() else b"" if hashlib.sha256(body).hexdigest() != sha256: raise CorpusDamaged(f"{path} does not match the sha256 in {META}{_again(source)}") return body def read_records(source: str, meta: dict[str, Any], *, text_only: bool = False) -> list[dict[str, str]]: """raw.jsonl's records, the file checked against its sha256 first (load() checked it, but the file is read later and may have changed) and cut by corpus_build.jsonl_lines, each holding a name, and with `text_only` nothing but strings; CorpusDamaged naming the way out otherwise. Read whole: streaming the lines cost more than holding them (1.99s against 1.15s for fec-individuals, 2026-10-23).""" path = directory(source, create=False) / RAW raw = _verified(source, RAW, meta["sha256"]) try: records = [json.loads(line) for line in corpus_build.jsonl_lines(raw.decode())] except ValueError: raise CorpusDamaged(f"{path} holds a line that is not JSON{_again(source)}") from None if any(not isinstance(r, dict) or not isinstance(r.get("name"), str) for r in records): raise CorpusDamaged(f"{path} holds a record without a name{_again(source)}") if text_only and any(not isinstance(v, str) for r in records for v in r.values()): raise CorpusDamaged(f"{path} holds a record with a value that is not " f"text{_again(source)}") return records def require(source: str) -> Local: """The cached corpus for a command that cannot go on without it: exits naming the fetch where it is missing or damaged. Damage to the records shows only once they are read, which main's tally catches the same way.""" try: local = load(source) except ValueError as exc: raise SystemExit(str(exc)) from None if local is None: raise SystemExit(f"{source} is not fetched: {fetch_command(source)}") return local def corpus_path(source: str) -> Path: return directory(source, create=False) / CORPUS # -- views ------------------------------------------------------------------- def after_commas(name: str) -> list[str]: """Each part of `name` after a comma, as written, stripped of the spaces around it: 'Ann Lee, MD, PhD' gives ['MD', 'PhD'], and 'MBBS,FCPS' is two parts, as the parser splits it. Unlike the parser, it does not first set aside a comma inside parentheses or quotes: 'Ann (Lee, Jr) Roe, MD' gives ['Jr) Roe', 'MD'].""" return [part.strip() for part in name.split(",")[1:]] def tally(local: Local) -> tuple[Counter[str], int]: """Each after-comma part, counted by the records writing it (each once however often its name repeats it), and how many records hold a comma at all.""" parts: Counter[str] = Counter() with_comma = 0 for record in local.records: found = after_commas(record["name"]) if found: with_comma += 1 parts.update(set(found)) return parts, with_comma #: How a filed name departs from the reference name beside it (a #: record's "registry"), in the order pair_shape asks: a pair takes the #: first that fits. PAIR_SHAPES = ( "the same", "no reference name", "no filed name", "case only", "accents only", "case and accents", "punctuation or spacing only", "the same words reordered", "words left out", "words added", "words shortened", "words misspelled", "words left out and shortened", "mostly the same words", "few words in common") #: A word of a pair: what a space, period, comma, apostrophe or hyphen #: does not cut. _PAIR_WORD = re.compile(r"[^\s.,'’-]+") #: How alike two words must be (difflib's ratio) to read as one word #: misspelled: JUNIIOR for JUNIOR is 0.92, SOUSA for SOUZA 0.8. PAIR_MISSPELLED = 0.75 def _unmarked(name: str) -> str: """`name` with its combining marks dropped (accents; a letter that does not decompose, ø or ł, stays).""" return "".join(c for c in unicodedata.normalize("NFD", name) if not unicodedata.combining(c)) def _bare(name: str) -> str: """`name` in capitals with its accents dropped.""" return _unmarked(name.upper()) def _pair_words(name: str) -> list[str]: return _PAIR_WORD.findall(_bare(name)) def _in_order(short: list[str], long: list[str]) -> bool: """Whether every word of `short` stands in `long`, in order.""" rest = iter(long) return all(word in rest for word in short) def pair_shape(filed: str, reference: str) -> str: """The first of PAIR_SHAPES that `filed` and `reference` fit: 'ANA SILVA' beside 'ANA DA SILVA' is words left out, 'ANA M SILVA' beside 'ANA MARIA SILVA' words shortened, 'JOSE' beside 'JOSÉ' accents only.""" if filed == reference: return "the same" if not reference: return "no reference name" a, b = _pair_words(filed), _pair_words(reference) if not a: return "no filed name" if filed.upper() == reference.upper(): return "case only" if _unmarked(filed) == _unmarked(reference): return "accents only" if _bare(filed) == _bare(reference): return "case and accents" if "".join(a) == "".join(b): return "punctuation or spacing only" if sorted(a) == sorted(b): return "the same words reordered" if _in_order(a, b): return "words left out" if _in_order(b, a): return "words added" if len(a) == len(b): changed = [(x, y) for x, y in zip(a, b) if x != y] if all(y.startswith(x) for x, y in changed): return "words shortened" if len(changed) <= 2 and all( difflib.SequenceMatcher(None, x, y).ratio() >= PAIR_MISSPELLED for x, y in changed): return "words misspelled" rest = iter(b) if all(any(y.startswith(x) for y in rest) for x in a): return "words left out and shortened" if len(set(a) & set(b)) >= max(1, min(len(a), len(b)) - 1): return "mostly the same words" return "few words in common" #: The least pairs a word must be left out of, or added to, to be #: listed (each pair counting a word once): a word in fewer could be #: part of one person's name. The same floor holds for the accents-only #: disagreements, listed by the two spellings' last words. PAIR_WORD_FLOOR = 10 def format_pairs(found: PairCounts, top: int) -> str: """The report `pairs` prints: each shape's pairs and share of the pairs that differ, the surname agreement where it was counted, and the words most often left out and added, counts and words only.""" same = found.shapes["the same"] differ = found.total - same lines = [f"{found.total:,} pairs; {same:,} the same, {differ:,} differing:"] for shape in PAIR_SHAPES[1:]: n = found.shapes[shape] if not n: continue line = f" {n:>9,} {n / differ:6.1%} {shape}" if found.agreement is not None and shape in found.agreement: agree, parsed = found.agreement[shape] line += f" (one surname in {agree / parsed:.0%})" lines.append(line) for words, what in ((found.left_out, "words most often left out"), (found.added, "words most often added"), (found.accent_splits, "accents-only pairs read with two " "surnames, by their last words")): listed = [(w, n) for w, n in words.most_common(top) if n >= PAIR_WORD_FLOOR] if listed: lines.append(f"{what}: " + ", ".join(f"{w} {n:,}" for w, n in listed)) return "\n".join(lines) @dataclass(frozen=True) class PairCounts: """What pair_counts finds: the pairs of each shape, the reference name's words a filed name leaves out and the words it adds (in the pairs of those two shapes), and, where asked, how many pairs of each shape parse() reads with one surname, and the accents-only pairs it reads with two, keyed by the two spellings' last words ("JÚNIOR / JUNIOR"). Each pair counts a word once.""" shapes: Counter[str] left_out: Counter[str] added: Counter[str] #: shape -> (pairs whose surnames agree, pairs parsed) agreement: dict[str, tuple[int, int]] | None = None accent_splits: Counter[str] = field(default_factory=Counter) @property def total(self) -> int: return sum(self.shapes.values()) def surname(name: str) -> str: """The last word of the family name parse() reads in `name`, in capitals without accents, or "" where it reads none: the word two spellings of one person should share unless the last surname itself is what one leaves out (a particle or a middle name left out, a middle name shortened, accents or case changed). Two readings with no surname do not share one.""" from nameparser import parse family = _pair_words(parse(name).family) return family[-1] if family else "" def _surnames(names: list[str]) -> tuple[str, list[str]]: """The nameparser this process imported, and surname() of each name: a unit of work for _parsed.""" import nameparser return nameparser.__file__, [surname(n) for n in names] def _parsed(chunk: Callable[[list[str]], tuple[str, list[Any]]], names: Iterable[str], jobs: int) -> dict[str, Any]: """chunk's answer for each distinct name, read in `jobs` worker processes where there are enough (snapshot.parallel).""" from snapshot import parallel distinct = list(dict.fromkeys(names)) return dict(zip(distinct, parallel(chunk, distinct, jobs, module="local_corpora"), strict=True)) def pair_counts(local: Local, *, agreement: bool = False, jobs: int = 1) -> PairCounts: """Each record's pair_shape. A source whose records carry no reference name raises ValueError. With `agreement`, every pair with a reference name that differs is parsed both ways and its two surnames compared, agreeing only where both read one, which parses each distinct name once, in `jobs` worker processes (snapshot.parallel).""" if not local.records: raise ValueError(f"{local.source} holds no records") if any("registry" not in r for r in local.records): raise ValueError(f"{local.source}'s records carry no reference name " f"to pair a filed name with") shapes: Counter[str] = Counter() left_out: Counter[str] = Counter() added: Counter[str] = Counter() tally_agree: dict[str, list[int]] = {} accent_splits: Counter[str] = Counter() seen: dict[str, str] = {} if agreement: seen = _parsed(_surnames, [n for r in local.records if pair_shape(r["name"], r["registry"]) not in ("the same", "no reference name") for n in (r["name"], r["registry"])], jobs) for record in local.records: filed, reference = record["name"], record["registry"] shape = pair_shape(filed, reference) shapes[shape] += 1 if shape in ("words left out", "words added"): a, b = Counter(_pair_words(filed)), Counter(_pair_words(reference)) if shape == "words left out": left_out.update((b - a).keys()) else: added.update((a - b).keys()) if agreement and shape not in ("the same", "no reference name"): counts = tally_agree.setdefault(shape, [0, 0]) one = seen[filed] == seen[reference] != "" counts[0] += one counts[1] += 1 if shape == "accents only" and not one: accent_splits[f"{filed.split()[-1].upper()} / " f"{reference.split()[-1].upper()}"] += 1 return PairCounts(shapes, left_out, added, {s: (n[0], n[1]) for s, n in tally_agree.items()} if agreement else None, accent_splits) #: Where reading places the family, as Reading.family_at. FamilyAt = Literal["first", "last", "whole", "inner", "none"] class Reading(NamedTuple): """The structure parse() gives one spelling (reading): counts and positions, never the words, so spellings in two scripts compare.""" title: bool suffix: bool given: bool family: bool family_words: int #: Where the family stands among the given, middle and family words. family_at: FamilyAt #: What the groups view compares between two spellings of one person, #: in the order it reports them: Reading's fields, as the report names #: them. GROUP_FIELDS = tuple(name.replace("_", " ") for name in Reading._fields) #: The name-bearing roles, among which reading places the family. _NAME_ROLES = frozenset({"given", "middle", "family"}) #: What group_counts counts, beside GROUP_FIELDS, for two scripts whose #: spellings read alike in no one pair, though every field has a value #: in common across them: a combination of fields reads apart. NO_ONE_FIELD = "no one field" class _Read(NamedTuple): """reading(name), and the title and the suffix parse() reads.""" reading: Reading title: str suffix: str def _read(name: str) -> _Read: from nameparser import parse parsed = parse(name) roles = [str(t.role) for t in parsed.tokens if str(t.role) in _NAME_ROLES] family = [i for i, role in enumerate(roles) if role == "family"] at: FamilyAt if not family: at = "none" elif len(family) == len(roles): at = "whole" elif family == list(range(len(family))): at = "first" elif family == list(range(len(roles) - len(family), len(roles))): at = "last" else: at = "inner" return _Read(Reading(bool(parsed.title), bool(parsed.suffix), bool(parsed.given), bool(parsed.family), len(parsed.family.split()), at), parsed.title, parsed.suffix) def reading(name: str) -> Reading: """The structure parse() gives `name`, one value per GROUP_FIELDS: title read, suffix read, given found, family found, the family's words, and where the family stands among the given, middle and family words (titles, suffixes, nicknames and maiden names left out): "first" or "last" where it is one run at that edge, "whole" where it is all of them, "none" where there is none, and "inner" otherwise, a run inside or a family split in two: rare (1 of the 1,315,051 distinct jrc-names spellings on 2026-10-24), and kept so that such a family is not labelled an edge.""" return _read(name).reading def _differing(one: set[Reading], other: set[Reading] | None = None) -> set[str]: """The GROUP_FIELDS in which the readings `one` disagree among themselves (more than one value), or, given `other`, in which no reading of `one` agrees with any of `other` (no value in common): two Latin spellings that differ between themselves do not make the Latin and the Cyrillic spellings differ where a Cyrillic one reads as either Latin one does.""" if other is None: return {f for i, f in enumerate(GROUP_FIELDS) if len({r[i] for r in one}) > 1} return {f for i, f in enumerate(GROUP_FIELDS) if {r[i] for r in one}.isdisjoint({r[i] for r in other})} class Ungroupable(ValueError): """A corpus group_counts cannot read: no records, or records with no entity and script. A class of its own, as CorpusDamaged is, so that the command catches these and not a ValueError from the parser.""" @dataclass(frozen=True) class GroupCounts: """What group_counts finds, every count a count of PERSONS, not of spellings or of pairs, so that a person spelled 600 ways weighs what one spelled twice does. Keyed by a pair of scripts ("Cyrillic / Latin", "Latin / Latin"), and by "any" for all of a person's spellings taken together; a script() label that is no script ("mixed", "none") keys a pair like one.""" #: The persons with two or more spellings, and those spellings. persons: int spellings: int #: pair -> the persons with two spellings in those scripts ("any": #: every person, so groups["any"] == persons). groups: Counter[str] #: pair -> those whose spellings read alike: within one script, all #: of them read one way; across two, some spelling in one reads, in #: every field, as some spelling in the other does. agree: Counter[str] #: (pair, a GROUP_FIELDS member) -> those whose spellings disagree in #: the field (_differing): within one script's for "Latin / Latin", #: between the two scripts' for "Cyrillic / Latin". (pair, #: NO_ONE_FIELD) -> those across two scripts that read alike in no #: pair of spellings yet in no one field apart, so that a pair's #: persons are each alike, apart in a field, or here. differ: Counter[tuple[str, str]] #: The words of the titles, and of the suffixes, of a person some of #: whose spellings read one and others none, folded by word_key, #: counted once a person: two spellings reading two titles (Dr., #: Doctor) with no untitled spelling beside them add nothing. titles: Counter[str] suffixes: Counter[str] def _by_entity(local: Local) -> dict[str, list[tuple[str, str]]]: """Each entity's (script, spelling) pairs, from a source whose records carry an "entity" and a "script"; Ungroupable otherwise.""" if not local.records: raise Ungroupable(f"{local.source} holds no records") if any("entity" not in r or "script" not in r for r in local.records): raise Ungroupable(f"{local.source}'s records carry no entity and script " f"to group spellings by") by_entity: dict[str, list[tuple[str, str]]] = {} for record in local.records: by_entity.setdefault(record["entity"], []).append((record["script"], record["name"])) return by_entity def _reads(names: list[str]) -> tuple[str, list[_Read]]: """The nameparser this process imported, and _read of each name: a unit of work for _parsed.""" import nameparser return nameparser.__file__, [_read(n) for n in names] def group_counts(local: Local, *, jobs: int = 1) -> GroupCounts: """Each person with two or more spellings, from a source whose records carry an "entity" and a "script": every spelling's reading compared by the pair of scripts the spellings are written in, as _differing says. A distinct name is parsed once, in `jobs` worker processes (snapshot.parallel). A source without entities raises Ungroupable.""" by_entity = _by_entity(local) read: dict[str, _Read] = _parsed( _reads, (name for variants in by_entity.values() if len(variants) > 1 for _, name in variants), jobs) groups: Counter[str] = Counter() agree: Counter[str] = Counter() differ: Counter[tuple[str, str]] = Counter() titles: Counter[str] = Counter() suffixes: Counter[str] = Counter() persons = spellings = 0 for variants in by_entity.values(): if len(variants) < 2: continue persons += 1 spellings += len(variants) by_script: dict[str, set[Reading]] = {} written: Counter[str] = Counter() for script, name in variants: by_script.setdefault(script, set()).add(read[name].reading) written[script] += 1 everything = set().union(*by_script.values()) # pair -> (the fields read apart, whether the spellings read alike) compared = {"any": (_differing(everything), len(everything) == 1)} scripts = sorted(by_script) for i, first in enumerate(scripts): for second in scripts[i:]: one = by_script[first] if first != second: compared[f"{first} / {second}"] = ( _differing(one, by_script[second]), not one.isdisjoint(by_script[second])) elif written[first] > 1: compared[f"{first} / {first}"] = (_differing(one), len(one) == 1) for pair, (fields, alike) in compared.items(): groups[pair] += 1 if alike: agree[pair] += 1 elif not fields: differ[(pair, NO_ONE_FIELD)] += 1 differ.update((pair, f) for f in fields) for counter, mark in ((titles, "title"), (suffixes, "suffix")): if len({getattr(r, mark) for r in everything}) > 1: counter.update({key for _, name in variants if getattr(read[name].reading, mark) for key in words(getattr(read[name], mark))}) return GroupCounts(persons, spellings, groups, agree, differ, titles, suffixes) def format_groups(found: GroupCounts, top: int) -> str: """The report `groups` prints: per pair of scripts, the persons with two spellings in them, the share whose spellings read alike (GroupCounts.agree), and the share whose spellings read each field apart (_differing), then NO_ONE_FIELD's; then the title and suffix words read in some of a person's spellings and in none of the others, listed only where PAIR_WORD_FLOOR persons share one. The `top` largest pairs are listed, and the `top` commonest words. Counts and words only.""" lines = [f"{found.persons:,} persons with two or more spellings " f"({found.spellings:,} spellings); by the scripts of two of them:"] pairs = ["any", *sorted((p for p in found.groups if p != "any"), key=lambda p: (-found.groups[p], p))[:top]] for pair in pairs: n = found.groups[pair] if not n: continue shares = ", ".join(f"{field} {found.differ[(pair, field)] / n:.1%}" for field in (*GROUP_FIELDS, NO_ONE_FIELD) if found.differ[(pair, field)]) lines.append(f" {n:>9,} {found.agree[pair] / n:6.1%} alike {pair}" + (f"; differ in {shares}" if shares else "")) for counted, what in ((found.titles, "title words read in some spellings only"), (found.suffixes, "suffix words read in some spellings only")): listed = [(w, n) for w, n in counted.most_common(top) if n >= PAIR_WORD_FLOOR] if listed: lines.append(f"{what} ({PAIR_WORD_FLOOR} persons or more): " + ", ".join(f"{w} {n:,}" for w, n in listed)) return "\n".join(lines) # -- alike: the spelling views against a base ---------------------------------- #: What the alike view counts in each source whose records hold two #: spellings of one person, and the noun for one of them. ALIKE_UNITS = {"entity": "persons", "devanagari": "pairs", "registry": "pairs"} #: The sources whose records pair spellings: the ones the alike view #: reads, and measure.py runs it for. ALIKE_SOURCES = (JRC, "nepal-candidates", "tse-donors") def alike_kind(local: Local) -> str: """Which spellings `local`'s records pair: "entity" (a person's spellings, grouped as group_counts groups them), "devanagari" (a romanized name and the Devanagari one) or "registry" (a filed name and the reference name); Ungroupable for a source that pairs none.""" if not local.records: raise Ungroupable(f"{local.source} holds no records") for kind in ALIKE_UNITS: if all(kind in r for r in local.records): return kind raise Ungroupable(f"{local.source}'s records pair no two spellings of one person") def alike_flags(local: Local, *, jobs: int = 1) -> dict[str, bool]: """Whether each person's spellings read alike, keyed by the entity or by the record's place in raw.jsonl, so that a key names no one. A person spelled two or more ways (alike_kind "entity") reads alike where every spelling's reading() is one; a romanized and Devanagari pair where the two readings are one; a filed and reference pair whose spellings differ where both read one surname, the same (surname(), as pair_counts' agreement counts it). Each distinct name is parsed once, in `jobs` worker processes.""" kind = alike_kind(local) if kind == "entity": groups = {e: [n for _, n in v] for e, v in _by_entity(local).items() if len(v) > 1} read = _parsed(_reads, (n for names in groups.values() for n in names), jobs) return {e: len({read[n].reading for n in names}) == 1 for e, names in groups.items()} if kind == "devanagari": pairs = {str(i): (r["name"], r["devanagari"]) for i, r in enumerate(local.records) if r["devanagari"].strip()} read = _parsed(_reads, (n for pair in pairs.values() for n in pair), jobs) return {i: read[a].reading == read[b].reading for i, (a, b) in pairs.items()} pairs = {str(i): (r["name"], r["registry"]) for i, r in enumerate(local.records) if pair_shape(r["name"], r["registry"]) not in ("the same", "no reference name")} family = _parsed(_surnames, (n for pair in pairs.values() for n in pair), jobs) return {i: family[a] == family[b] != "" for i, (a, b) in pairs.items()} #: What decides a flag besides the parser: alike_fingerprint hashes them, #: with _ALIKE_CONSTANTS, the record cut (read_records and #: corpus_build.jsonl_lines, which place a record at its index) and the #: fields of the readings compared. _ALIKE_CODE = ("alike_kind", "alike_flags", "_by_entity", "_reads", "_read", "reading", "_surnames", "_parsed", "surname", "_pair_words", "_bare", "_unmarked", "pair_shape", "_in_order") _ALIKE_CONSTANTS = ("ALIKE_UNITS", "PAIR_MISSPELLED", "_PAIR_WORD", "_NAME_ROLES") #: Where the alike view keeps a base's flags, beside the corpus. ALIKE_KEPT = "alike" def alike_fingerprint() -> str: """code_fingerprint of what turns the records into flags (_ALIKE_CODE).""" module = globals() return code_fingerprint( [*(module[name] for name in _ALIKE_CODE), read_records, corpus_build.jsonl_lines], [*(_stable(module[name]) for name in _ALIKE_CONSTANTS), repr(Reading._fields), repr(_Read._fields)]) def alike_path(local: Local, digest: str) -> Path: """Where `local`'s flags read by a package of `digest` are kept: keyed by everything they depend on besides the parser (the fetch, the interpreter's minor version, the code), so a key that matches is a reading of this base.""" py = f"py{sys.version_info[0]}{sys.version_info[1]}" return (directory(local.source, create=False) / ALIKE_KEPT / f"{local.meta['sha256'][:12]}-{digest[:16]}-{py}-{alike_fingerprint()[:12]}.json") def write_flags(path: Path, local: Local, flags: dict[str, bool]) -> None: body = {"raw_sha256": local.meta["sha256"], "code": alike_fingerprint(), "count": len(flags), "flags": flags} corpus_build.write_atomic(path, json.dumps(body, sort_keys=True).encode()) def read_flags(path: Path, local: Local) -> dict[str, bool]: """Flags write_flags wrote for this fetch and this code; ValueError for anything else (a file cut short, another fetch's, another version's).""" try: body = json.loads(path.read_text(encoding="utf-8")) flags = body["flags"] ok = (isinstance(flags, dict) and body["count"] == len(flags) and all(isinstance(v, bool) for v in flags.values())) except (OSError, ValueError, KeyError, TypeError) as exc: raise ValueError(f"{path} is not a reading this tool kept ({exc})") from None if not ok: raise ValueError(f"{path} holds another number of flags than it counts") if body.get("raw_sha256") != local.meta["sha256"] or body.get("code") != alike_fingerprint(): raise ValueError(f"{path} was read from another fetch or by another version " f"of this view") return flags def _alike_base(root: Path, local: Local, out: Path, jobs: int) -> None: """`root`'s flags written to `out` in a child process (one process imports one nameparser), which refuses a package other than `root`'s.""" import subprocess import snapshot command = [sys.executable, str(Path(_HERE) / "local_corpora.py"), "alike", local.source, "--tree", str(root), "--out", str(out), "--jobs", str(jobs)] done = subprocess.run(command, env=snapshot.child_env(), capture_output=True, text=True) if done.returncode: raise SystemExit(f"reading the base {root} failed:\n{done.stdout}{done.stderr}") def base_flags(root: Path, local: Local, jobs: int) -> dict[str, bool]: """The base checkout's flags, kept for its package's digest or read into a kept file now, and a line saying which. Read into a folder of this run's own and renamed into place only once the digest, taken again, says the base did not change under the child (AGENTS.md's cache-key gotcha: publish a kept reading only once it is whole).""" import shutil import snapshot digest = snapshot._source_digest(root) path = alike_path(local, digest) if path.exists(): try: flags = read_flags(path, local) except ValueError as exc: print(f"deleted the kept flags {path.name}: {exc}; reading the base\n") path.unlink(missing_ok=True) else: try: os.utime(path) except FileNotFoundError: # another run pruned it meanwhile pass print(f"base: {root}, nameparser/ {digest[:16]}; reused the kept " f"flags {path.name}\n") return flags at = snapshot.scratch(path.parent) try: _alike_base(root, local, at / path.name, jobs) flags = read_flags(at / path.name, local) if snapshot._source_digest(root) != digest: raise SystemExit(f"{root / 'nameparser'} changed while it was read, " f"so its flags may be half old and half new; run again") os.replace(at / path.name, path) finally: shutil.rmtree(at, ignore_errors=True) snapshot._prune(path.parent, "*.json") print(f"base: {root}, nameparser/ {digest[:16]}; read it now\n") return flags def format_alike(local: Local, flags: dict[str, bool], base: dict[str, bool] | None = None) -> list[str]: """The lines `alike` prints: how many read alike, and against a base how many now read alike and how many now apart. Counts only.""" unit = ALIKE_UNITS[alike_kind(local)] alike = sum(flags.values()) line = f"{alike:,} of {len(flags):,} {unit} read alike" if base is None: return [line] if base.keys() != flags.keys(): raise SystemExit("the base's flags and the tree's name other persons, " "though both read the same fetch") now_alike = sum(flags[k] and not base[k] for k in flags) now_apart = sum(base[k] and not flags[k] for k in flags) return [f"{line} ({sum(base.values()):,} in the base)", f"{now_alike:,} {unit} now alike, {now_apart:,} now apart"] #: Where a name is cut into words: spaces, commas, semicolons, slashes. #: A hyphen stays inside a word (FNP-C), and a period inside a token #: (M.D.) is folded away by word_key. _TOKEN_SPLIT = re.compile(r"[\s,;/]+") #: Kept at a word's edge though punctuation: what marks a particle #: written to join the next word (d', al-), which the vocabulary lists #: and this lookup is asked about. _JOINERS = frozenset("'\u2019-") def _strippable(char: str) -> bool: return unicodedata.category(char)[0] in "PS" and char not in _JOINERS @functools.cache def word_key(word: str) -> str: """One word as the lookup matches it: composed (NFC), periods dropped, other punctuation and symbols at its edges stripped, apostrophes and hyphens kept, and case folded with casefold(). 'M.D.', 'MD;' and '(MD)' all find 'md', while d' stays d' and never finds the initial D. A combining mark is part of its letter, not punctuation, so a final vowel sign or haraka stays. Close to the parser's acronym match (which drops periods and lowers case) but not the same fold: casefold() also folds 'ß' to 'ss'. Cached: a corpus repeats its words (1.88 million tokens, 169,208 distinct, on 2026-10-21).""" word = unicodedata.normalize("NFC", word).replace(".", "") start, end = 0, len(word) while start < end and _strippable(word[start]): start += 1 while end > start and _strippable(word[end - 1]): end -= 1 return word[start:end].casefold() def _keyed(text: str) -> list[tuple[str, str]]: """`text` cut into words, each as (its key, the token as written), words that fold to nothing dropped.""" return [(key, token) for token in _TOKEN_SPLIT.split(text) if (key := word_key(token))] def words(text: str) -> list[str]: """`text` as the lookup's words: cut where the index cuts a name, each folded by word_key, empty words dropped.""" return [key for key, _ in _keyed(text)] @dataclass(frozen=True) class WordIndex: """Per word key: the records writing it anywhere, those writing it after a comma, and its spellings as written, each counted by the records writing it (once a record, however often it repeats one). Plain dicts, built or read back from the cache alike, so a missing key reads the same from either: read them with .get(key, 0). Not Counters, so that a cached index needs none rebuilt over it (0.37s of the 0.61s a cached FEC index took to load with them, measured 2026-10-22).""" anywhere: dict[str, int] after_comma: dict[str, int] spellings: dict[str, dict[str, int]] def _names(local: Local) -> Counter[tuple[str, ...]]: """Each distinct tuple of a record's names (its distinct non-empty values under the source's corpus_keys, as the corpus reads them), with the records writing it: a name repeats across a source's records (536,981 officials, 352,104 names on 2026-10-21). Reading every key lets a lookup of a Devanagari word in nepal-candidates find the records whose Devanagari name holds it; keeping a record's names together counts a word both of them write once.""" keys = local.corpus_keys return Counter(tuple(dict.fromkeys(record[key] for key in keys if record.get(key))) for record in local.records) def word_index(local: Local) -> WordIndex: anywhere: Counter[str] = Counter() after: Counter[str] = Counter() spellings: dict[str, Counter[str]] = {} for names, n in _names(local).items(): before: list[tuple[str, str]] = [] behind: list[tuple[str, str]] = [] for name in names: head, _, tail = name.partition(",") before += _keyed(head) behind += _keyed(tail) after_keys = {key for key, _ in behind} for key in {key for key, _ in before} | after_keys: anywhere[key] += n for key in after_keys: after[key] += n for key, token in set(before + behind): spellings.setdefault(key, Counter())[token] += n return WordIndex(anywhere=dict(anywhere), after_comma=dict(after), spellings={key: dict(seen) for key, seen in spellings.items()}) #: The functions that decide an index's contents from the records, and #: the constants they read: a change to any makes a cached index stale, #: so fold_fingerprint hashes them rather than trusting a version number #: to be bumped. tests/v2/test_local_corpora.py holds these lists to what #: the functions call and read. _FOLD_CODE = ("word_key", "_strippable", "_keyed", "_names", "word_index") _FOLD_CONSTANTS = ("_TOKEN_SPLIT", "_JOINERS") def _stable(value: object) -> str: """repr(), with sets sorted: a frozenset's order moves with the process's string hashing.""" if isinstance(value, (set, frozenset)): return "{" + ", ".join(sorted(_stable(v) for v in value)) + "}" if isinstance(value, tuple): return "(" + ", ".join(_stable(v) for v in value) + ")" if isinstance(value, re.Pattern): return f"re({value.pattern!r}, {value.flags})" return repr(value) def _code_digest(code: types.CodeType) -> list[str]: """What a function does, as the interpreter will run it: its bytecode, the names and constants it reads, and its nested code, without line numbers, so moving it does not count as a change.""" parts = [code.co_code.hex(), repr(code.co_names), repr(code.co_varnames)] for const in code.co_consts: parts += (_code_digest(const) if isinstance(const, types.CodeType) else [_stable(const)]) return parts def code_fingerprint(functions: Iterable[Callable[..., object]], extra: Iterable[str] = ()) -> str: """A hash of `functions` as the interpreter will run them, read from the running code rather than the source on disk, which may have been edited since; of `extra`; and of the Unicode database that casefold(), NFC and the category tests read and the interpreter whose bytecode it is, which a new Python may move: the key for a result cached from what that code made.""" parts = [part for fn in functions for part in _code_digest(inspect.unwrap(fn).__code__)] parts += [*extra, unicodedata.unidata_version, sys.version] return hashlib.sha256("\0".join(parts).encode()).hexdigest() def fold_fingerprint() -> str: """code_fingerprint of what turns the records into an index: _FOLD_CODE's functions, read_records and corpus_build.jsonl_lines (which read raw.jsonl into the records Local.records holds, where _names takes them), _FOLD_CONSTANTS and WordIndex's fields.""" module = globals() return code_fingerprint( [*(module[name] for name in _FOLD_CODE), read_records, corpus_build.jsonl_lines], [*(_stable(module[name]) for name in _FOLD_CONSTANTS), repr([f.name for f in dataclasses.fields(WordIndex)])]) def _is_counts(value: object) -> bool: return isinstance(value, dict) and all( type(n) is int for n in value.values()) def cached_index(local: Local) -> WordIndex: """`local`'s word index, read from wordindex.json beside the corpus where its key matches and its counts are counts, else built and written there. The key is the records' sha256, fold_fingerprint() and the corpus_keys read, so a refetch, or a change to how a word is cut or folded or to which of a record's names are read, rebuilds it rather than answering from a stale index. A Local with no sha256 (a test's) is built and not cached. An index file that is not an index, or not this one, is built over, with a line on stderr: it is derived from the corpus, never the corpus itself.""" sha = local.meta.get("sha256") if not isinstance(sha, str): return word_index(local) path = directory(local.source, create=False) / INDEX key = f"{sha}:{fold_fingerprint()}:{','.join(local.corpus_keys)}" if path.exists(): try: data = json.loads(path.read_text(encoding="utf-8")) if (isinstance(data, dict) and data.get("key") == key and _is_counts(data.get("anywhere")) and _is_counts(data.get("after_comma")) and isinstance(data.get("spellings"), dict) and all(_is_counts(seen) for seen in data["spellings"].values())): return WordIndex(anywhere=data["anywhere"], after_comma=data["after_comma"], spellings=data["spellings"]) why = "built from other records or by another fold, or not an index" except (OSError, ValueError) as exc: why = str(exc) print(f"rebuilding the word index at {path}: {why}", file=sys.stderr) index = word_index(local) body = json.dumps({"key": key, **dataclasses.asdict(index)}, ensure_ascii=False) try: corpus_build.write_atomic(path, body.encode()) except OSError as exc: print(f"could not cache the word index at {path}: {exc}", file=sys.stderr) return index @dataclass(frozen=True) class BoxIndex: """A fielded source's words by box and country, counted over its whole extract rather than the capped corpus: per country the weight of every record ("" for none), and per word key and country the weight of the records whose given box, or family box, writes it (once a record, however often the box repeats it). The weight is the people a record stands for (Source.boxes): one ORCID holder, or the inventors PatentsView counts under a name.""" totals: dict[str, int] given: dict[str, dict[str, int]] family: dict[str, dict[str, int]] def box_index(rows: Iterable[tuple[str, str, str, int]]) -> BoxIndex: """The BoxIndex of `rows`, (given, family, country, weight) as a Source.boxes reader yields them, each box cut and folded into words as the word index cuts and folds a name (words).""" totals: Counter[str] = Counter() boxes: tuple[dict[str, Counter[str]], dict[str, Counter[str]]] = ({}, {}) for given, family, country, weight in rows: totals[country] += weight for box, text in zip(boxes, (given, family)): for key in set(words(text)): box.setdefault(key, Counter())[country] += weight return BoxIndex(totals=dict(totals), given={k: dict(v) for k, v in boxes[0].items()}, family={k: dict(v) for k, v in boxes[1].items()}) def box_fingerprint(source: str) -> str: """code_fingerprint of what turns `source`'s extract into its BoxIndex: its Source.boxes reader, box_index and the fold the word index shares (words, word_key, _strippable, _keyed, _FOLD_CONSTANTS), and BoxIndex's fields.""" reader = SOURCES[source].boxes assert reader is not None module = globals() return code_fingerprint( [reader, box_index, words, word_key, _strippable, _keyed], [*(_stable(module[name]) for name in _FOLD_CONSTANTS), repr([f.name for f in dataclasses.fields(BoxIndex)])]) def _file_sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as held: while chunk := held.read(local_core.CHUNK): digest.update(chunk) return digest.hexdigest() def cached_box_index(source: str) -> BoxIndex: """`source`'s BoxIndex, read from boxindex.json.gz beside the corpus where its key matches, else built from the cached extract and written there. The key is the extract's sha256, taken from the bytes on disk as they are read, and box_fingerprint(source), so a refetch or a change to how a word is cut, folded or counted rebuilds it. A file that is not an index, or not this one, is built over, with a line on stderr: it is derived from the extract, never the extract itself. ValueError naming the refresh where the extract is missing or cannot be read.""" found = SOURCES[source] if found.extract is None or found.boxes is None: raise ValueError(f"{source} keeps no extract with its names' boxes") extract = found.extract() if not extract.exists(): raise ValueError(f"{extract} is missing{_again(source)}") path = directory(source, create=False) / BOX_INDEX key = f"{_file_sha256(extract)}:{box_fingerprint(source)}" if path.exists(): try: data = json.loads(gzip.decompress(path.read_bytes())) if (isinstance(data, dict) and data.get("key") == key and _is_counts(data.get("totals")) and all(isinstance(data.get(box), dict) and all(_is_counts(v) for v in data[box].values()) for box in ("given", "family"))): return BoxIndex(totals=data["totals"], given=data["given"], family=data["family"]) why = "built from another extract or by another fold, or not an index" except (OSError, ValueError, EOFError, zlib.error) as exc: why = str(exc) print(f"rebuilding the box index at {path}: {why}", file=sys.stderr) else: print(f"building the box index of {source} from {extract.name} ...", file=sys.stderr) try: index = box_index(found.boxes(extract)) except (EOFError, zlib.error, KeyError, AttributeError, TypeError, RuntimeError, ValueError) as exc: # A truncated or damaged extract raises any of these from gzip, # json or the reader (an empty one, StopIteration from inside a # generator, as RuntimeError); one row fails, not the run. raise ValueError(f"{extract} could not be read ({type(exc).__name__}: {exc})" f"{_again(source)}") from None if _file_sha256(extract) != key.split(":", 1)[0]: raise ValueError(f"{extract} changed while it was read{_again(source)}") body = json.dumps({"key": key, **dataclasses.asdict(index)}, ensure_ascii=False) try: corpus_build.write_atomic(path, gzip.compress(body.encode(), 6, mtime=0)) except OSError as exc: print(f"could not cache the box index at {path}: {exc}", file=sys.stderr) return index def phrase_counts(local: Local, keys: list[str]) -> tuple[int, int]: """The records writing the words `keys` in a row (a spaced query, 'Ph. D.' as ['ph', 'd']), and those where such a run starts after the first comma. The separators between the words are not compared: 'MD/PhD' finds 'MD, PhD' and 'Smith MD, PhD' too. Scans the names, since a phrase is no key of the index; none is scanned when one of its words is written nowhere.""" if any(not local.index.anywhere.get(key, 0) for key in keys): return 0, 0 n = len(keys) anywhere = after = 0 for names, count in _names(local).items(): found = found_after = False for name in names: head, _, tail = name.partition(",") first = words(head) got = first + words(tail) starts = [i for i in range(len(got) - n + 1) if got[i:i + n] == keys] found = found or bool(starts) found_after = found_after or any(i >= len(first) for i in starts) anywhere += count * found after += count * found_after return anywhere, after @dataclass(frozen=True) class Lookup: """One word's answer: the records writing it anywhere and after a comma; for a single word its spellings, for a spaced one the records writing its words joined into one.""" anywhere: int after_comma: int spellings: Counter[str] joined: int | None def lookup(local: Local, word: str) -> Lookup | None: """`word` looked up in `local`, or None when nothing is left of it once folded (words). A spaced word is matched as its words in a row.""" keys = words(word) if not keys: return None index = local.index if len(keys) == 1: return Lookup(index.anywhere.get(keys[0], 0), index.after_comma.get(keys[0], 0), Counter(index.spellings.get(keys[0], {})), None) anywhere, after = phrase_counts(local, keys) return Lookup(anywhere, after, Counter(), index.anywhere.get("".join(keys), 0)) # -- the command line ---------------------------------------------------------- def _positive(text: str) -> int: """An argparse type: a whole number of 1 or more.""" value = int(text) if value < 1: raise argparse.ArgumentTypeError(f"{text} is not 1 or more") return value def _alike_command(local: Local, args: argparse.Namespace) -> int: """`alike`: the tree's flags (this checkout's, or --tree's, checked to be the package imported), against --base's where one is named; exits 1 where a person moved, as the other base checks do.""" import snapshot root = Path(args.tree).resolve() if args.tree else snapshot.REPO_ROOT base = snapshot.base_root(args.base, root) if args.base else None try: alike_kind(local) old = base_flags(base, local, args.jobs) if base is not None else None except (CorpusDamaged, Ungroupable) as exc: raise SystemExit(str(exc)) from None if args.tree: sys.path.insert(0, str(root)) import nameparser snapshot.check_source(nameparser.__file__, root) flags = alike_flags(local, jobs=args.jobs) snapshot.check_source(nameparser.__file__, root) if args.out: write_flags(args.out, local, flags) return 0 print(local.header()) if base is not None: print(snapshot.head_line(root, base)) print("\n".join(format_alike(local, flags, old))) return int(old is not None and any(flags[k] != old[k] for k in flags)) def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) sub = ap.add_subparsers(dest="command", required=True) get = sub.add_parser("fetch", help="fetch a source into the cache") get.add_argument("source", choices=sorted(SOURCES)) get.add_argument("--refresh", action="store_true", help="fetch again although a copy is cached") show = sub.add_parser("tally", help="count the text after each comma") show.add_argument("source", choices=sorted(SOURCES)) show.add_argument("--top", type=int, default=50, metavar="N") pair = sub.add_parser("pairs", help="sort each filed name by how it departs " "from the reference name beside it") pair.add_argument("source", choices=sorted(SOURCES)) pair.add_argument("--top", type=int, default=15, metavar="N", help="words left out or added to list (default 15)") pair.add_argument("--agreement", action="store_true", help="also parse each pair that differs and count the " "pairs whose two spellings share a surname") group = sub.add_parser("groups", help="compare the readings of each person's " "spellings, by the scripts they are in") group.add_argument("source", choices=sorted(SOURCES)) group.add_argument("--top", type=_positive, default=20, metavar="N", help="pairs of scripts, and titles and suffixes, to list " "(default 20)") alike = sub.add_parser("alike", help="count the persons whose spellings read " "alike, and against --base the ones that " "moved") alike.add_argument("source", choices=sorted(ALIKE_SOURCES)) alike.add_argument("--base", metavar="PATH", help="compare with this checkout's flags, kept by its " "package's digest") alike.add_argument("--tree", metavar="PATH", help="read with another checkout") alike.add_argument("--out", type=Path, metavar="PATH", help="write the tree's flags here (what --base's child does)") for command in (pair, group, alike): command.add_argument("--jobs", type=_positive, metavar="N", default=os.cpu_count() or 1, help="parse in N worker processes (default: every CPU)") args = ap.parse_args(argv) if args.command == "alike" and args.base and args.out: ap.error("--base compares; --out writes the tree's flags, for a child") if args.command == "fetch": local = None if not args.refresh: try: local = load(args.source) except ValueError as exc: # a damaged copy: fetch over it print(f"the cached copy is unusable, fetching again: {exc}", file=sys.stderr) if local is None: if args.refresh and (renew := SOURCES[args.source].renew): renew() local = fetch(args.source) where = f"fetched into {directory(args.source)}" else: where = f"cached in {directory(args.source)}; --refresh fetches again" print(local.header()) print(where) print(f"{local.meta['names']:,} distinct names in {corpus_path(args.source)}") if local.meta["detail"]: print(", ".join(f"{k} {v:,}" if isinstance(v, int) else f"{k} {v}" for k, v in sorted(local.meta["detail"].items()))) return 0 local = require(args.source) if args.command == "alike": return _alike_command(local, args) if args.command == "pairs": try: found = pair_counts(local, agreement=args.agreement, jobs=args.jobs) except ValueError as exc: # CorpusDamaged included: the records, read here first raise SystemExit(str(exc)) from None print(local.header()) print(format_pairs(found, args.top)) return 0 if args.command == "groups": try: grouped = group_counts(local, jobs=args.jobs) except (CorpusDamaged, Ungroupable) as exc: # the records, read here first raise SystemExit(str(exc)) from None print(local.header()) print(format_groups(grouped, args.top)) return 0 try: parts, with_comma = tally(local) except CorpusDamaged as exc: # the records, read here first raise SystemExit(str(exc)) from None print(local.header()) print(f"{with_comma:,} hold a comma; the text after each comma, by " f"{SOURCES[args.source].unit} writing it:") for text, n in parts.most_common(args.top): print(f" {n:>9,} {text}") return 0 if __name__ == "__main__": sys.exit(main()) ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/local_ctgov.py���������������������������������0000664�0000000�0000000�00000007370�15273032717�0025360�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""The ctgov-officials local corpus: ClinicalTrials.gov's study officials. ctgov-officials -- every overall official of every study on ClinicalTrials.gov, whatever the role (principal investigators, study directors and chairs, and a few sub-investigators and officials with no role), the name as the sponsor's staff typed it into a free-text field, with credentials, job titles and typos left in. Fetched from API v2 (NLM). ClinicalTrials.gov's terms ask anyone redistributing the data to credit it, show NLM's processing date and say what was changed: every report local_corpora.py prints and the tools/name_usage.py row carry the credit and the date, and the names are kept unchanged. """ from __future__ import annotations import sys import urllib.parse from collections.abc import Iterator from typing import Any from local_core import Fetched, _get_json CTGOV_API = "https://clinicaltrials.gov/api/v2" CTGOV_PAGE = 1000 # the API's largest page CTGOV_FIELDS = "NCTId,OverallOfficialName,OverallOfficialRole" def _ctgov_version() -> tuple[str, str]: data = _get_json(f"{CTGOV_API}/version") return str(data["dataTimestamp"]), str(data["apiVersion"]) def _ctgov_pages() -> Iterator[dict[str, Any]]: token = "" while True: query = {"pageSize": CTGOV_PAGE, "fields": CTGOV_FIELDS} if token: query["pageToken"] = token else: query["countTotal"] = "true" page = _get_json(f"{CTGOV_API}/studies?{urllib.parse.urlencode(query)}") yield page token = page.get("nextPageToken", "") if not token: return def fetch_ctgov() -> Fetched: """Every study's overall officials, in the API's order. The registry is refreshed daily, so the data date is read before and after: a fetch that straddles a refresh mixes two days and is refused. A study paged twice is refused, since its officials would count twice, and the studies paged must number the totalCount the first page states, so a fetch cut short cannot pass for the registry. An official with no name is counted rather than dropped in silence, so a renamed field shows as a count in the header instead of a smaller registry.""" before, api = _ctgov_version() records: list[dict[str, str]] = [] total: int | None = None studies: set[str] = set() nameless = 0 for number, page in enumerate(_ctgov_pages(), 1): if total is None: total = int(page["totalCount"]) if number % 50 == 0: print(f" {len(studies):,} of {total:,} studies", file=sys.stderr) for study in page["studies"]: section = study["protocolSection"] nct = section["identificationModule"]["nctId"] if nct in studies: raise ValueError(f"{nct} was paged twice: the pages shifted " f"under the fetch; fetch again") studies.add(nct) for official in section.get("contactsLocationsModule", {}).get( "overallOfficials", []): if official.get("name"): records.append({"name": official["name"], "study": nct, "role": official.get("role", "")}) else: nameless += 1 after, _ = _ctgov_version() if after != before: raise ValueError(f"ClinicalTrials.gov refreshed during the fetch " f"({before} -> {after}); fetch again") if len(studies) != total: raise ValueError(f"paged {len(studies):,} studies of the " f"{total:,} the API counted; fetch again") return Fetched(records, before, {"api_version": api, "studies": total, "officials_without_a_name": nameless}) ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/local_fec.py�����������������������������������0000664�0000000�0000000�00000014302�15273032717�0024764�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""The fec-individuals local corpus: the FEC's individual contributors. fec-individuals -- the distinct names of every individual contributor in the FEC's bulk file of itemized contributions by individuals for the cycle (indiv26.zip, 2.3 GB on 2026-10-18, downloaded into the cache once and read without unpacking; `fetch --refresh` downloads it again). Itemized contributions only: a committee need itemize only a donor whose gifts to it pass $200, so a smaller donor is there only where a committee itemized the gift anyway. Mostly a filer's software writing a form's fields in a fixed order, family, comma, given, in capitals -- every one of the 1,893,964 names on 2026-10-18 was in capitals and all but three held a comma -- with hand-typed text inside the fields: 3,306 held a second comma, for a credential run or a suffix in a part of its own. So it is the population for the one-case family-comma shape, and blind to case and to names written in their spoken order. The FEC's terms (52 U.S.C. 30111(a)(4)) bar using the names to solicit contributions or for any commercial purpose, which the credit line every report opens with repeats; only the NAME column is kept. """ from __future__ import annotations import zipfile from collections import Counter from pathlib import Path from local_core import (Fetched, _download, _pinned, _refuse_past, _zip_stamp, directory, fetch_command) FEC_CYCLE = "2026" FEC_ZIP = f"indiv{FEC_CYCLE[2:]}.zip" FEC_URL = f"https://www.fec.gov/files/bulk-downloads/{FEC_CYCLE}/{FEC_ZIP}" #: The whole cycle in one file; the zip's by_date/ members repeat it in slices. FEC_MEMBER = "itcont.txt" #: The file's columns (the FEC's contributions-by-individuals file #: description): 21, pipe-separated, ENTITY_TP the 7th and NAME the 8th. FEC_COLUMNS, FEC_ENTITY, FEC_NAME = 21, 6, 7 #: The file has no header row to check the columns against, so a fetch #: refuses one that no longer looks like the file the constants above #: were written for: more than this share of its lines malformed or not #: UTF-8 (none were, of 34,264,742 lines in the file dated 2026-10-18), #: or fewer than FEC_MIN_COMMA of its names holding a comma (1,893,961 #: of 1,893,964 did), which is what a column moved under NAME would show. FEC_MAX_BAD = 1 / 10_000 FEC_MIN_COMMA = 0.9 def _fec_download(path: Path) -> None: """Download FEC_URL over the cached zip (_download), and remove a zip of another cycle once this one has arrived.""" _download(FEC_URL, path) for stale in path.parent.glob("indiv*.zip"): if stale != path: stale.unlink() def _fec_read(path: Path) -> Fetched: names: set[str] = set() entities: Counter[str] = Counter() rows = malformed = latin1 = nameless = 0 with zipfile.ZipFile(path) as archive: if FEC_MEMBER not in archive.namelist(): raise ValueError(f"{path} holds no {FEC_MEMBER}") member = archive.getinfo(FEC_MEMBER) stamp = _zip_stamp(member) with archive.open(member) as lines: for line in lines: rows += 1 try: text = line.decode("utf-8") except UnicodeDecodeError: latin1 += 1 text = line.decode("latin-1") cols = text.rstrip("\r\n").split("|") if len(cols) != FEC_COLUMNS: malformed += 1 continue entities[cols[FEC_ENTITY]] += 1 if cols[FEC_ENTITY] != "IND": continue if name := cols[FEC_NAME].strip(): names.add(name) else: nameless += 1 _refuse_past([(malformed, rows, FEC_MAX_BAD, f"lines of {path} are not {FEC_COLUMNS} columns"), (latin1, rows, FEC_MAX_BAD, f"lines of {path} are not UTF-8")], "the file's layout has changed under FEC_COLUMNS") with_comma = sum("," in name for name in names) if names and with_comma < len(names) * FEC_MIN_COMMA: raise ValueError(f"{with_comma:,} of {len(names):,} names hold a comma: " f"column {FEC_NAME + 1} of {path} no longer holds NAME") return Fetched([{"name": name} for name in sorted(names)], stamp, { "cycle": FEC_CYCLE, "file": f"{FEC_ZIP}/{FEC_MEMBER}", "rows": rows, "individual_contributions": entities["IND"], "rows_from_other_entities": sum(entities.values()) - entities["IND"], "individuals_without_a_name": nameless, "lines_not_21_columns": malformed, "lines_not_utf8": latin1}) def fetch_fec() -> Fetched: """The distinct names of every individual contributor in the cycle's file, one record per name, read from the zip cached beside the corpus (downloaded when missing, and again by `fetch --refresh`). A record is a NAME, not a contribution or a contributor: one donor gives many times (34,264,742 rows held 1,893,964 names in the file dated 2026-10-18), and a contribution count would weight a shape by how often its bearers give. Only the name is kept, stripped of the spaces around it; the address, employer and amount columns are read past. The data date is the member's own timestamp, which carries no time zone. A line that is not 21 columns is counted and skipped, a line that is not UTF-8 is counted and read as Latin-1, and a row from an entity that is not a person and a person with no name are counted; past FEC_MAX_BAD, or with too few names holding a comma, the file is refused rather than read as a smaller or a different corpus.""" path = directory("fec-individuals") / FEC_ZIP if not path.exists(): _fec_download(path) try: return _fec_read(path) except zipfile.BadZipFile as exc: raise ValueError(f"{path} is not a readable zip ({exc}); download it " f"again: {fetch_command('fec-individuals', refresh=True)}" ) from None def renew_fec() -> None: """Download the current file over the cached zip.""" _fec_download(directory("fec-individuals") / FEC_ZIP) check_fec = _pinned("cycle", lambda: FEC_CYCLE, "holds the {held} cycle, and this tool reads the {wanted} cycle") ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/local_jrc.py�����������������������������������0000664�0000000�0000000�00000040266�15273032717�0025015�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""The jrc-names local corpus: the person names of the JRC-Names resource. jrc-names -- the person entities of the EU Joint Research Centre's JRC-Names (wt-public.emm4u.eu), each with the spellings of its name harvested from news text in many languages and scripts: a record is one spelling, with the entity it spells and the script it is written in. The one local corpus that holds several spellings of one person across scripts (tse-donors pairs two spellings, both Latin), so it is the population for spelling and transliteration variants, read by local_corpora.py's `groups` view (25,415 persons are spelled in two scripts or more, a "mixed" or "none" script() not counted), and for Arabic-script names: 17,074 spellings of 7,833 persons, 5,347 of them spelled in Latin too, in the file dated 2024-03-15. Latin is most of it (1,203,634 of 1,317,586 spellings, then Cyrillic 66,673, Arabic 17,074, Katakana 13,039 and Han 8,016); the Indic scripts are few (Devanagari 827 spellings, Bengali 419, Tamil 328, Malayalam 183, the rest under 80 each). 1,510 of the Arabic spellings hold an Arabic comma (U+060C), which the parser reads as a comma. Licensed under the JRC-Names EULA (Resources/LICENCE-EULA_JRC-Names_2011.pdf on the same site): a personal, non-exclusive, non-transferable licence that bars redistribution, sublicensing and derivative works. So nothing read from it is committed, not a name, not a per-name reading: the download, the corpus, its snapshot and every file derived from them stay in the cache, and what a contributor cites is counts and shapes written with made-up names, as for every local corpus. """ from __future__ import annotations import gzip import hashlib import os import re import unicodedata import zlib from collections import Counter from pathlib import Path from typing import Any from local_core import Fetched, _download, _pinned, _refuse_past, directory, fetch_command JRC = "jrc-names" JRC_URL = "https://wt-public.emm4u.eu/data/entities.gzip" #: The download, cached beside the corpus. JRC_FILE = "entities.gzip" #: The file this tool was written against, pinned: the JRC says the #: resource is updated daily, but the file served on 2026-10-24 was last #: modified on the date below. A download with another sha256 is refused #: until someone has measured it and moved the pin, since a layout the #: constants below were not measured on would otherwise read as a #: smaller corpus. JRC_SHA256 = "43144cee881ba8a81573ae28c089dffce0ca634d342791414628a5d54997a7d0" #: The pinned file's Last-Modified, its data date: the file states none. JRC_DATE = "2024-03-15T14:08:28Z" #: How the file is read into records, stored in meta.json's detail with #: the Unicode version script() read: a change to what _jrc_read keeps, #: to how it builds a name or to script() must bump it by hand, and a #: corpus read the old way is then refused on load (check_jrc) rather #: than counted under labels the code no longer gives. JRC_READ_VERSION = 2 #: A line: entity id, type, language and name, tab-separated, the name's #: words joined by "+". Lines end in CRLF. JRC_COLUMNS = 4 JRC_PERSON, JRC_ORGANISATION = "P", "O" #: The language of a spelling the JRC did not mark: 1,317,644 of the #: 1,338,463 person rows. A row the JRC marks with a language is, where #: measured, a morphological variant of an unmarked spelling of the same #: entity: an inflected form (a Russian name in another case, a Slovene #: or Polish one with its case ending) or an Arabic one with a word glued #: to it (و "and", ل "for", ال "the"). Of the 20,819 marked rows in the #: pinned file, every one's entity also held an unmarked spelling, and #: 18,594 had the same number of words as one and, word by word in #: order, either every word the same or sharing at least its first #: letter with the other and no more than three letters past their #: common start (an ending), or every word the other with no more than #: three letters glued to its front (a prefix). The other 2,225 (1,680 of #: them Arabic) fit neither as stated and were not measured further. The #: fetch drops every marked row, all 20,819, on the JRC's marking alone, #: counted by language, rather than read an ending or a conjunction as #: part of a name, and counts the entities left with no spelling #: (JRC_MAX_LOST). The #: unmarked spellings still hold some inflected forms the JRC did not #: mark: 22,732 of the 66,673 distinct unmarked spellings whose script() #: is Cyrillic are, by the ending rule above, another Cyrillic spelling #: of the same entity (some may be two transliterations rather than an #: inflection; the two were not measured apart), and they are kept. JRC_UNMARKED = "u" _LANGUAGE = re.compile(r"[a-z]{2,3}") #: The share of lines read past (not JRC_COLUMNS columns, not UTF-8, a #: type other than JRC_PERSON or JRC_ORGANISATION, an id that is not #: digits, a language that is not a code, a name that is empty) before #: the file is refused: 1 of the pinned file's 1,445,013 lines was, its #: first, "#". JRC_MAX_BAD = 1 / 10_000 #: The least share of well-formed lines that are people before the file #: is refused: 1,338,463 of 1,445,012 (93%) in the pinned file. A type #: renamed would otherwise read as a corpus of none. JRC_MIN_PERSONS = 1 / 2 #: The most person rows that may carry a language before the file is #: refused: 20,819 of 1,338,463 (1.6%) in the pinned file. A file whose #: every row came marked would otherwise be dropped whole. JRC_MAX_MARKED = 1 / 10 #: The most entities that may be left with no spelling, every one of #: theirs marked, before the file is refused: none of the pinned file's #: 819,209 were. JRC_MAX_LOST = 1 / 1000 #: The most person rows that may repeat a spelling their entity already #: has before the file is refused: 58 of 1,338,463 in the pinned file. A #: layout that collapsed rows would otherwise read as a smaller corpus. JRC_MAX_REPEATED = 1 / 1000 #: A script's name as the first word of its letters' Unicode names #: gives it, where that word is not the script's own name. _SCRIPT_NAMES = {"CJK": "Han", "KATAKANA-HIRAGANA": "Katakana"} #: First words that name no script: letters written in several (ʿ and ʼ, #: MODIFIER LETTER, in a Latin and in a Ukrainian Cyrillic spelling; the #: ordinals ª and º; the iteration mark 々, in kanji and kana names #: alike), which script() reads past rather than call a second script. _NO_SCRIPT = frozenset({"MODIFIER", "FEMININE", "MASCULINE", "IDEOGRAPHIC"}) #: A first word that names a form, not a script: the script is the next #: word (HALFWIDTH KATAKANA LETTER A, FULLWIDTH LATIN CAPITAL LETTER A). _FORMS = frozenset({"HALFWIDTH", "FULLWIDTH"}) _ID = re.compile(r"[0-9]+") def script(name: str) -> str: """The script `name`'s letters are written in, from the first word of each letter's Unicode name ("Latin", "Cyrillic", "Arabic", "Han" for CJK, "Katakana" counting the prolonged sound mark), "mixed" where its letters are in two or more, and "none" where no letter names a script. A heuristic over the names, not the Unicode Script property the parser's own script test reads; enough to key a pair of scripts by in the groups view.""" found = set() for char in name: if char.isalpha(): words = unicodedata.name(char, "UNKNOWN").split() word = words[1] if words[0] in _FORMS and len(words) > 1 else words[0] if word not in _NO_SCRIPT: found.add(_SCRIPT_NAMES.get(word, word.title())) if not found: return "none" return found.pop() if len(found) == 1 else "mixed" def _kept(path: Path) -> Path: """Where a download that is not the pinned file is kept beside `path`.""" return path.with_name(path.name + ".new") def _settled(path: Path) -> bool: """Whether `path` is the pinned file, once a download kept beside it (_kept) has replaced it where the pin has moved to that download: no download is needed then.""" if path.exists() and _sha256(path) == JRC_SHA256: return True kept = _kept(path) if kept.exists() and _sha256(kept) == JRC_SHA256: os.replace(kept, path) return True return False def _jrc_download(path: Path) -> None: """Download JRC_URL to `path`, through a file beside it (_kept) that replaces `path` only once its sha256 is JRC_SHA256's: the JRC serves only its current file, so a download that overwrote the pinned one with a newer one would leave the corpus nothing to be rebuilt from, and the pinned file could not be downloaded again. A download that is not the pinned file is left beside it, to be measured.""" new = _kept(path) _download(JRC_URL, new) held = _sha256(new) if held != JRC_SHA256: old = (f"the cached file at {path} is untouched, and cannot be " f"downloaded again if it is lost" if path.exists() else "no file is cached") raise ValueError(f"{JRC_URL} served a file with sha256 {held}, not the " f"pinned JRC_SHA256: it is kept at {new}, and {old}. " f"Measure the new file and move JRC_SHA256 and JRC_DATE " f"to read it") os.replace(new, path) def _sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as file: while block := file.read(1 << 20): digest.update(block) return digest.hexdigest() def _jrc_read(path: Path) -> Fetched: """The person spellings of the file at `path`, read as fetch_jrc says.""" counts: Counter[str] = Counter() marked: Counter[str] = Counter() kept: set[tuple[str, str]] = set() marked_entities: set[str] = set() last = b"\n" with gzip.open(path, "rb") as lines: for raw in lines: counts["lines"] += 1 last = raw try: text = raw.decode("utf-8") except UnicodeDecodeError: counts["bad"] += 1 continue cols = text.rstrip("\r\n").split("\t") if len(cols) != JRC_COLUMNS: counts["bad"] += 1 continue entity, kind, language, written = cols name = " ".join(written.replace("+", " ").split()) if (not _ID.fullmatch(entity) or kind not in (JRC_PERSON, JRC_ORGANISATION) or not (language == JRC_UNMARKED or _LANGUAGE.fullmatch(language)) or not name): counts["bad"] += 1 continue if kind == JRC_ORGANISATION: counts["organisations"] += 1 continue counts["persons"] += 1 if language != JRC_UNMARKED: marked[language] += 1 marked_entities.add(entity) continue if (entity, name) in kept: counts["repeated"] += 1 continue kept.add((entity, name)) if not last.endswith(b"\n"): raise ValueError(f"{path} ends inside a line: the file is cut short; " f"download it again: {fetch_command(JRC, refresh=True)}") well_formed = counts["lines"] - counts["bad"] _refuse_past([(counts["bad"], counts["lines"], JRC_MAX_BAD, f"lines of {path} are not {JRC_COLUMNS} well-formed columns")], "the file's layout has changed") if counts["persons"] < well_formed * JRC_MIN_PERSONS or not counts["persons"]: raise ValueError(f"{counts['persons']:,} of {well_formed:,} lines of {path} " f"name a person ({JRC_PERSON}): the file's types have changed") _refuse_past([(sum(marked.values()), counts["persons"], JRC_MAX_MARKED, f"person rows of {path} carry a language")], f"the file no longer marks a plain spelling {JRC_UNMARKED!r}") entities = Counter(entity for entity, _ in kept) lost = len(marked_entities - entities.keys()) _refuse_past([(lost, len(entities) + lost, JRC_MAX_LOST, f"persons of {path} have marked spellings only")], "dropping the marked rows would drop the persons") _refuse_past([(counts["repeated"], counts["persons"], JRC_MAX_REPEATED, f"person rows of {path} repeat a spelling")], "the file's rows may have collapsed") # Sorted by the number an id is, then as written: "01" and "1" stay # two entities, as the file writes them. records = [{"entity": entity, "name": name, "script": script(name)} for entity, name in sorted(kept, key=lambda k: (int(k[0]), k))] detail: dict[str, int | str] = { "sha256": JRC_SHA256, "read_version": JRC_READ_VERSION, "unicode": unicodedata.unidata_version, "lines": counts["lines"], "lines_read_past": counts["bad"], "organisation_rows": counts["organisations"], "person_rows": counts["persons"], "marked_variants_dropped": sum(marked.values()), "marked_variants_by_language": " ".join( f"{lang}:{n}" for lang, n in sorted(marked.items(), key=lambda kv: (-kv[1], kv[0]))), "repeated_spellings": counts["repeated"], "entities": len(entities), "entities_with_marked_spellings_only": lost, "entities_with_one_spelling": sum(n == 1 for n in entities.values()), "spellings_holding_a_replacement_character": sum( "\ufffd" in r["name"] for r in records)} return Fetched(records, JRC_DATE, detail) def fetch_jrc() -> Fetched: """One record per distinct spelling of a person entity: its entity id, the name with its "+" and its whitespace read as single spaces, and its script(). Read from the gzip cached beside the corpus, downloaded when no file is cached (a kept download the pin has moved to is adopted instead, _settled) and refused unless its sha256 is JRC_SHA256's. Organisation rows are counted and read past, as are person rows carrying a language (JRC_UNMARKED says why), by language, and a spelling an entity repeats. A line that is not JRC_COLUMNS well-formed columns or not UTF-8 is counted, and past JRC_MAX_BAD refuses the file; so do a gzip cut short or a file ending inside a line, person rows under JRC_MIN_PERSONS of the well-formed lines (or none), marked rows past JRC_MAX_MARKED, entities left with no spelling past JRC_MAX_LOST and repeated spellings past JRC_MAX_REPEATED.""" path = directory(JRC) / JRC_FILE if not _settled(path) and not path.exists(): _jrc_download(path) held = _sha256(path) if held != JRC_SHA256: raise ValueError(f"{path} has sha256 {held}, not the pinned JRC_SHA256: the " f"cached file is damaged, or was read under another pin. " f"Download it again: {fetch_command(JRC, refresh=True)}") try: return _jrc_read(path) except (EOFError, gzip.BadGzipFile, zlib.error) as exc: raise ValueError(f"{path} is not a whole gzip ({type(exc).__name__}: {exc}); " f"download it again: {fetch_command(JRC, refresh=True)}") from None def renew_jrc() -> None: """What `fetch --refresh` runs first: a download, unless the cached file is the pinned one already (_settled). The pinned file cannot change, so downloading it again gains nothing, and once the JRC serves a newer file a refresh would fail where a corpus read the old way only needed reading again from the cached file.""" path = directory(JRC) / JRC_FILE if not _settled(path): _jrc_download(path) _check_file = _pinned("sha256", lambda: JRC_SHA256, "was read from the file with sha256 {held}, and this tool " "reads {wanted}") _check_read = _pinned("read_version", lambda: JRC_READ_VERSION, "was read by read version {held}, and this tool reads " "version {wanted}") _check_unicode = _pinned("unicode", lambda: unicodedata.unidata_version, "was labelled under Unicode {held}, and this Python " "reads Unicode {wanted}") def check_jrc(meta: dict[str, Any]) -> str: """Why a corpus fetched under another pin, read another way, or labelled by script() under another Unicode version is refused: its file's sha256, its read version, then its Unicode version.""" return _check_file(meta) or _check_read(meta) or _check_unicode(meta) ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/local_nepal.py���������������������������������0000664�0000000�0000000�00000042750�15273032717�0025336�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""The nepal-candidates local corpus: Nepal's 2079 BS local election. nepal-candidates -- every candidate in Nepal's local elections of 2079 BS (2022), as the Election Commission publishes them on its results site (result.election.gov.np): one file per local body (municipality or rural municipality), each candidate's name written twice, in Devanagari as filed and in the Commission's own romanization. It is the repository's first large population of South Asian names: romanized names with surnames written as initials (B.K., K.C., G.C.), honorific words that are titles in front and names elsewhere (Guru; Shekh until #752's phase 2j made it a title only in a title phrase), Md., Muhammad's abbreviation, which its period makes a title by shape, and Devanagari written with vowel signs and the virama, combining marks the letter-shape tests do not count as letters (decisions/nfd-shapes), and with the joiners (ZWJ, ZWNJ). The site states no licence; only the two name columns are kept. """ from __future__ import annotations import json import sys import time import urllib.error import urllib.request from collections import Counter from typing import Any import local_core from local_core import Fetched, _backed_off, _pinned, _refuse_past, _retried NEPAL_SITE = "https://result.election.gov.np/" NEPAL_HANDLER = NEPAL_SITE + "Handlers/SecureJson.ashx?file=" NEPAL_ELECTION = "Election2079" NEPAL_LOOKUP = f"JSONFiles/{NEPAL_ELECTION}/Local/Lookup/localbodies.json" NEPAL_BODY = f"JSONFiles/{NEPAL_ELECTION}/Local/{{id}}.json" #: The User-Agent this source sends: a browser's form, naming the tool #: in it as a crawler does. The site refuses (HTTP 403) a User-Agent #: holding "python" or "curl" (2026-10-24), which the shared #: local_core.USER_AGENT does in the repository's URL. NEPAL_USER_AGENT = "Mozilla/5.0 (compatible; nameparser local_corpora)" #: The seconds of sleep after each request: the 754 requests of a fetch #: took 28 minutes at 0.3 s on 2026-10-24. NEPAL_DELAY = 0.3 #: The local bodies the lookup listed on 2026-10-24. The election is #: over and its files final, so a lookup listing another number is a #: changed site, refused rather than read as a smaller election. NEPAL_BODIES = 753 #: The candidates the 753 files held on 2026-10-24, pinned as the #: bodies are: a fetch reading every file whole must read exactly this #: many, so a site that trimmed its files (to the winners, or to one #: post) is refused however well formed what is left. NEPAL_CANDIDATES = 152_960 #: The share of the listed bodies whose file the site may answer as #: missing (HTTP 404) or as empty (a list of no candidates) before the #: fetch is refused: a site that blanked its files would otherwise read #: as a smaller election. Every listed file held candidates on #: 2026-10-24: the 18 a survey on 2026-10-23 saw refused, among them ids #: 5551 to 5554, answered when asked again, so they were refused by a #: lapsed token (403), not missing. A hundredth (7 bodies) lets a few #: lost files cost their names rather than the fetch, and refuses a #: site that has moved its files. NEPAL_MAX_MISSING = 1 / 100 #: Where files are missing or empty, the share of NEPAL_CANDIDATES the #: fetch may fall short by (3,059 candidates, 15 bodies of the average #: 203): the pin cannot be exact without those files, so a site that #: also trimmed a few candidates elsewhere is not seen then. Not sized #: against the largest bodies, which were not measured: losing a few #: of them refuses the fetch, and the detail's largest_file says how #: large the largest file read was. NEPAL_MAX_SHORT = 1 / 50 #: The record keys read; every other key is read past. NEPAL_NAME, NEPAL_ROMANIZED = "CandidateName", "CandidateNameEng" #: The share of candidates without the two name keys, without a #: romanized name, or without a Devanagari one, past which the fetch is #: refused, a value holding no letter counted as none (_named): a column #: renamed or emptied wholesale would otherwise read as a smaller #: election. On 2026-10-24, 0, 26 and 0 of 152,960. NEPAL_MAX_NAMELESS = 1 / 100 #: The share of the candidates kept (those with a romanized name) past #: which a column holding the wrong script refuses the fetch: a #: Devanagari name with no Devanagari letter, or a romanized one with a #: letter outside Latin, is what two columns swapped, or a romanization #: dropped, would show. 0 and 3 of the 152,934 kept on 2026-10-24 (a #: romanization left partly in Devanagari). NEPAL_MAX_WRONG_SCRIPT = 1 / 100 #: The distinct romanized names, and the distinct Devanagari ones, must #: each be at least this share of the candidates kept: a column masked #: with one word would otherwise pass, its pairs kept distinct by the #: other column. 118,126 and 119,282 of 152,934 (77% and 78%) on #: 2026-10-24. NEPAL_MIN_DISTINCT = 1 / 4 def _cookies(headers: list[str]) -> dict[str, str]: """The cookies `headers` (Set-Cookie values) set, by name, each value as sent: a header is cut at its first semicolon, where its attributes start, and at its first equals sign. One that holds no name raises, rather than leaving a cookie out of every request.""" cookies: dict[str, str] = {} for header in headers: name, sep, value = header.split(";", 1)[0].partition("=") if not sep or not name.strip(): raise ValueError(f"{NEPAL_SITE} set a cookie with no name: the " f"site's check has changed") cookies[name.strip()] = value.strip() return cookies def _session() -> dict[str, str]: """The headers every handler request carries: the site's own double-submit check, the CsrfToken cookie a GET of the site sets echoed in X-CSRF-Token, beside every cookie it sets, with the Referer its pages send. Retried under local_core's policy.""" request = urllib.request.Request(NEPAL_SITE, headers={"User-Agent": NEPAL_USER_AGENT}) for attempt in range(local_core.RETRIES): try: with urllib.request.urlopen(request, timeout=60) as response: response.read() cookies = _cookies(response.headers.get_all("Set-Cookie") or []) break except local_core._TRANSIENT as exc: if not _backed_off(exc, attempt, NEPAL_SITE): raise else: raise AssertionError("unreachable: the last attempt raises") if not cookies.get("CsrfToken"): raise ValueError(f"{NEPAL_SITE} set no CsrfToken cookie: the site's " f"check has changed") return {"User-Agent": NEPAL_USER_AGENT, "Referer": NEPAL_SITE, "X-CSRF-Token": cookies["CsrfToken"], "Cookie": "; ".join(f"{k}={v}" for k, v in sorted(cookies.items()))} def _json(body: bytes, path: str) -> object: """`body` parsed as UTF-8, with or without a byte-order mark (a survey on 2026-10-23 saw one, the fetch on 2026-10-24 none). A body cut short is no JSON, and raises.""" try: return json.loads(body.decode("utf-8-sig")) except (UnicodeDecodeError, ValueError) as exc: raise ValueError(f"{path} is not UTF-8 JSON ({exc})") from None def _get(path: str, headers: dict[str, str], counts: Counter[str]) -> object: """`path`'s JSON, retried under local_core's policy. A 403, what the site answers a token it no longer holds, renews the session in `headers`, counted, and asks once more (a fetch can outlast ASP.NET's default twenty-minute session, which the token seems bound to); a second 403 is raised.""" url = NEPAL_HANDLER + path try: return _retried(url, lambda body: _json(body, path), NEPAL_DELAY, headers=headers) except urllib.error.HTTPError as exc: if exc.code != 403: raise exc.close() print(f" renewing the session after a 403: {url}", file=sys.stderr) counts["sessions_renewed"] += 1 headers.update(_session()) return _retried(url, lambda body: _json(body, path), NEPAL_DELAY, headers=headers) def _bodies(headers: dict[str, str], counts: Counter[str]) -> list[int]: """The ids of the local bodies the lookup lists; a list that is not NEPAL_BODIES distinct int ids is refused.""" listed = _get(NEPAL_LOOKUP, headers, counts) if not isinstance(listed, list) or any( not isinstance(b, dict) or type(b.get("id")) is not int for b in listed): raise ValueError(f"{NEPAL_LOOKUP} is not a list of local bodies with ids") ids = sorted({b["id"] for b in listed}) if len(ids) != len(listed) or len(ids) != NEPAL_BODIES: raise ValueError(f"{NEPAL_LOOKUP} lists {len(listed):,} local bodies " f"({len(ids):,} distinct ids), not the {NEPAL_BODIES:,} " f"of NEPAL_BODIES: the site has changed") return ids def _candidates(body: int, headers: dict[str, str], counts: Counter[str]) -> list[dict[str, Any]] | None: """`body`'s candidates, or None where the site answers its file as missing (HTTP 404).""" path = NEPAL_BODY.format(id=body) try: rows = _get(path, headers, counts) except urllib.error.HTTPError as exc: if exc.code != 404: raise exc.close() return None if not isinstance(rows, list) or any(not isinstance(r, dict) for r in rows): raise ValueError(f"{path} is not a list of candidates") return rows def _devanagari(text: str) -> bool: return any("\u0900" <= char <= "\u097f" and char.isalpha() for char in text) #: The letters a romanization may write beside ASCII's: Latin-1 and #: Latin Extended-A and B, the modifier letters (an apostrophe written #: as a letter), and Latin Extended Additional, where IAST's dotted #: letters stand. _LATIN = (("\u00c0", "\u024f"), ("\u02b0", "\u02ff"), ("\u1e00", "\u1eff")) def _latin(text: str) -> bool: """Whether every letter of `text` is ASCII or in one of _LATIN's ranges.""" return all(char.isascii() or any(low <= char <= high for low, high in _LATIN) for char in text if char.isalpha()) def _named(value: object) -> str: """`value` stripped, or "" where it is no text or holds no letter: the Commission wrote a placeholder of dashes or bars (`- - -`) for the romanization of 26 of 152,960 candidates fetched 2026-10-24, each beside a Devanagari name.""" text = value.strip() if isinstance(value, str) else "" return text if any(char.isalpha() for char in text) else "" def _read(rows: list[dict[str, Any]], pairs: set[tuple[str, str]], counts: Counter[str]) -> None: """Add one body's (romanized, Devanagari) pairs to `pairs`. A candidate without the name keys or a romanized name is counted and read past; of those kept, one without a Devanagari name is kept with an empty one, and one with a name in the wrong script is kept, each counted.""" for row in rows: counts["candidates"] += 1 if NEPAL_NAME not in row or NEPAL_ROMANIZED not in row: counts["without_the_name_keys"] += 1 continue name, romanized = _named(row[NEPAL_NAME]), _named(row[NEPAL_ROMANIZED]) if not romanized: counts["without_a_romanized_name"] += 1 continue if not name: counts["without_a_devanagari_name"] += 1 elif not _devanagari(name): counts["devanagari_name_without_devanagari"] += 1 if not _latin(romanized): counts["romanized_name_not_latin"] += 1 counts["with_a_romanized_name"] += 1 pairs.add((romanized, name)) def _refuse_a_short_election(candidates: int, absent: int) -> None: """Refuse a fetch reading other than NEPAL_CANDIDATES with every file read (`absent` 0); with `absent` files missing or empty, one reading more than NEPAL_CANDIDATES less one a file (every file held a candidate), or short by more than NEPAL_MAX_SHORT.""" if candidates > NEPAL_CANDIDATES - absent or not absent and candidates != NEPAL_CANDIDATES: raise ValueError(f"the files read hold {candidates:,} candidates, " f"{absent:,} missing or empty, against the " f"{NEPAL_CANDIDATES:,} of NEPAL_CANDIDATES: the site's " f"files have changed") _refuse_past([(NEPAL_CANDIDATES - candidates, NEPAL_CANDIDATES, NEPAL_MAX_SHORT, "NEPAL_CANDIDATES are missing")], "more than a few missing or empty files account for, unless " "they were among the largest") def fetch_nepal() -> Fetched: """Every candidate of every local body the lookup lists, one record per distinct pair of the romanized name and the Devanagari one. The record's "name", the one parse() reads, is the romanized name (CandidateNameEng), since romanized South Asian names are where the parser misreads: surnames written as initials read as credentials where the name has a word to spare (B.K., K.C.; #864), and a leading Md. read as a title by its period. The Devanagari name (CandidateName) is kept beside it as "devanagari", and the corpus holds both (Source.corpus_keys), so a snapshot watches the script too. A candidate with no romanized name is dropped; one with no Devanagari name is kept, its "devanagari" empty. A record is a distinct pair, as tse-donors' are, not a candidate row: a body lists a candidate once per post, and a common name recurs across bodies, so a row count would weight a name by how often it stood; a person with two romanizations is two records. Gender, age, ward and post are read past, since no view reads them. A file the site answers as missing (HTTP 404) is counted, its id kept in the detail as an empty file's is, and with the empty files past NEPAL_MAX_MISSING of the bodies refuses the fetch. A 403 renews the session once (_get); a 5xx, a 429 or a dropped connection is retried and then raised, and any other 4xx is raised at once. A file is JSON, so one cut short does not parse, and is retried and then refused rather than read as fewer candidates. A lookup not listing NEPAL_BODIES bodies, a candidate count other than NEPAL_CANDIDATES (or short of it by more than NEPAL_MAX_SHORT where files are missing or empty), too many candidates without the name keys, a romanized name or a Devanagari one (NEPAL_MAX_NAMELESS) or with a name in the wrong script (NEPAL_MAX_WRONG_SCRIPT), and too few distinct names in either column (NEPAL_MIN_DISTINCT) refuse it too. The site states no date for the data, so the data date is the fetch's own, in UTC.""" when = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) counts: Counter[str] = Counter() headers = _session() ids = _bodies(headers, counts) pairs: set[tuple[str, str]] = set() missing: list[int] = [] empty: list[int] = [] for number, body in enumerate(ids, 1): if number % 50 == 0: print(f" {number:,} of {len(ids):,} local bodies", file=sys.stderr) rows = _candidates(body, headers, counts) if rows is None: missing.append(body) continue counts["files"] += 1 if not rows: empty.append(body) counts["largest_file"] = max(counts["largest_file"], len(rows)) _read(rows, pairs, counts) candidates = counts["candidates"] _refuse_past([(len(missing) + len(empty), len(ids), NEPAL_MAX_MISSING, "local bodies' files are missing (HTTP 404) or empty")], "the site has moved, dropped or emptied its files") _refuse_a_short_election(candidates, len(missing) + len(empty)) _refuse_past([(counts["without_the_name_keys"], candidates, NEPAL_MAX_NAMELESS, f"candidates have no {NEPAL_NAME} or {NEPAL_ROMANIZED} key"), (counts["without_a_romanized_name"], candidates, NEPAL_MAX_NAMELESS, "candidates have no romanized name"), (counts["without_a_devanagari_name"], candidates, NEPAL_MAX_NAMELESS, "candidates have no Devanagari name")], "a name column may be renamed or emptied") kept = counts["with_a_romanized_name"] _refuse_past([(counts["devanagari_name_without_devanagari"], kept, NEPAL_MAX_WRONG_SCRIPT, f"{NEPAL_NAME} values hold no Devanagari"), (counts["romanized_name_not_latin"], kept, NEPAL_MAX_WRONG_SCRIPT, f"{NEPAL_ROMANIZED} values are not Latin")], "the two name columns may be swapped or replaced") if not pairs: raise ValueError("the local bodies' files hold no candidate with a name") for column, index in ((NEPAL_ROMANIZED, 0), (NEPAL_NAME, 1)): seen = len({pair[index] for pair in pairs}) if seen < kept * NEPAL_MIN_DISTINCT: raise ValueError(f"{kept:,} candidates hold {seen:,} distinct " f"{column}: the column may be masked") detail: dict[str, Any] = { "election": NEPAL_ELECTION, "local_bodies": len(ids), "local_bodies_missing": " ".join(map(str, missing)), "local_bodies_empty": " ".join(map(str, empty)), **{key: counts[key] for key in ( "files", "candidates", "without_the_name_keys", "without_a_romanized_name", "without_a_devanagari_name", "devanagari_name_without_devanagari", "romanized_name_not_latin", "sessions_renewed", "largest_file")}} return Fetched([{"name": romanized, "devanagari": name} for romanized, name in sorted(pairs)], when, detail) check_nepal = _pinned("election", lambda: NEPAL_ELECTION, "holds the election {held}, and this tool reads {wanted}") ������������������������derek73-python-nameparser-456b024/tools/differential/local_orcid.py���������������������������������0000664�0000000�0000000�00000057500�15273032717�0025336�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""The orcid-researchers local corpus: the names ORCID's holders gave their own records. orcid-researchers -- the people holding an ORCID iD, from ORCID's yearly Public Data File: each record's public name as its holder typed it into the registry's two boxes (given names, family name), the published name ("credit name") the holder typed beside them, and a country. A local corpus whose names carry their fields (local_agreement.FIELDED), so local_agreement.py scores the parser against them; the person split the name, not a clerk or a filer's software. The name the parser reads is the two boxes joined, given first, as the registry displays it; the credit name is a corpus key of its own, the same person's name as typed for publication. Most holders are researchers, and they come from every country: from 217 countries in the probe of the file's first GiB (2026-10-27). The country is the holder's own public address where there is one, else the first employment that names one, else the first education that does: of 605,872 records in that probe, 10.9%, 26.7% and 3.2%, and none for the other 59.1%; where a record gave both an address and an employment the two agreed in 92%. A country is where a holder works or lives, not where the name comes from. The corpus keeps at most ORCID_CAP records per country (records with none count as one more country), chosen by a hash of the record, so a country with few holders is kept whole and one with many is sampled; the corpus therefore shows what each country holds, never how often anything occurs across them. The fetch streams the one file it needs, the summaries (46.3 GB for the 2025 release), through ranged requests that resume where a dropped connection left off, and parses its records in a worker process per CPU (renew_orcid), reading the extract back through every check a load makes before it replaces the cached one. Only the extract is cached, never the tar: one line per record with a public name, every country kept, so a change to the cap reads the extract again rather than the file. The file is pinned by its figshare id, size, MD5 and count of summaries (ORCID_URL, ORCID_SIZE, ORCID_MD5, ORCID_SUMMARIES); a new year's release is measured and the pin moved by hand, and the floors below catch a release whose layout moved under the reader. ORCID releases the file under CC0 1.0; its use policy asks that ORCID be credited and that the privacy of the people in it be respected, which keeping it out of the repository does. """ from __future__ import annotations import collections import contextlib import email.utils import gzip import hashlib import heapq import http.client import importlib import io import json import multiprocessing import os import re import sys import tarfile import urllib.request import xml.etree.ElementTree as ET import zlib from collections import Counter from collections.abc import Callable, Iterator from concurrent.futures import Future, ProcessPoolExecutor from pathlib import Path from typing import Any import corpus_build import local_core from local_core import Fetched, _again, _backed_off, _pinned, _refuse_past, directory ORCID = "orcid-researchers" #: The pinned release: the 2025 Public Data File's summaries #: (figshare article 30375589, DOI 10.23640/07243.30375589.v1), #: as figshare's API described it on 2026-10-27. ORCID_FILE = "ORCID_2025_10_summaries.tar.gz" ORCID_URL = "https://ndownloader.figshare.com/files/58834837" ORCID_SIZE = 46_328_319_190 ORCID_MD5 = "210edf71f4a2bb44dd33aaa3037b3f17" #: The summaries the pinned file holds, read whole on 2026-10-27: tarfile #: ends a stream at a damaged header without raising, so a count short of #: this is a tar read only in part, which its MD5 cannot show. ORCID_SUMMARIES = 26_078_951 #: A record in the tar: one XML file per iD, under its iD's last three #: characters, the check character (a digit or X) among them. _SUMMARY = re.compile(r"ORCID_\d{4}_\d{2}_summaries/\d{2}[\dX]/\d{4}-\d{4}-\d{4}-\d{3}[\dX]\.xml") #: The extract format, in the extract's file name: a change to what #: _summary keeps or refuses bumps it, so an extract written by another #: version is not read. ORCID_EXTRACT_VERSION = 1 #: The most records a country keeps (orcid_kept). ORCID_CAP = 20_000 #: The share of summaries a read of the extract (and so a fetch) skips #: as not XML, or as XML declaring a DTD or an entity, the share of the #: tar's files that are not summaries at all, and the share of the #: countries kept that are not two capital letters, before it refuses #: the file: the whole file met none of the four (2026-10-27). ORCID_MAX_BAD = 1 / 10_000 #: The least share of summaries that give a record, of records with a #: family name, and of records with a country, before a read refuses the #: file as one whose layout moved under the reader (an element renamed, #: a namespace moved): 95.8%, 94.8% and 42.8% on 2026-10-27. ORCID_MIN_RECORDS = 1 / 2 ORCID_MIN_FAMILY = 1 / 2 ORCID_MIN_COUNTRY = 1 / 5 #: Attempts at each read of the stream: a reopen costs a request, and #: giving up costs the 46 GB, so it rides out minutes of outage #: (local_core._backed_off's nine waits, capped at a minute: 243 s, each #: attempt's own 60 s timeout on top). ORCID_RETRIES = 10 #: Summaries handed to a worker at a time. _BATCH = 2_000 _ADDRESS = "{http://www.orcid.org/ns/address}address" _EMPLOYMENT = "{http://www.orcid.org/ns/employment}employment-summary" _EDUCATION = "{http://www.orcid.org/ns/education}education-summary" _LOCALE = "{http://www.orcid.org/ns/preferences}locale" #: What a worker hands back: a batch's extract lines and counts. _Batch = tuple[list[str], dict[str, int]] #: Where a record's country is read from, in order of preference. COUNTRY_FROM = ("address", "employment", "education") _COUNTRY = re.compile(r"[A-Z]{2}") def extract_path() -> Path: """The extract cached for the pinned file.""" stem = ORCID_FILE.removesuffix(".tar.gz") return directory(ORCID) / f"{stem}.v{ORCID_EXTRACT_VERSION}.jsonl.gz" def _local(tag: str) -> str: return tag.rsplit("}", 1)[-1] def _text(element: ET.Element) -> str: return " ".join((element.text or "").split()) def _summary(body: bytes) -> tuple[str, dict[str, Any] | None]: """One summary's outcome and the record it gives: ("record", the record) for a public name, else a count's key and None. The record holds the boxes, the credit name, the other names, the country with where it came from and the locale the holder set for ORCID's own pages (a language, kept for a later view), the empty ones left out. A summary that declares a DTD is refused unread ("dtd_refused"): ORCID's never does, and one that did could define entities. The standard library's parser expands no external entity, and the Expat it ships bounds entity amplification; refusing the declaration keeps a parse from depending on either.""" if b"<!DOCTYPE" in body or b"<!ENTITY" in body: return "dtd_refused", None try: root = ET.fromstring(body) except ET.ParseError: return "not_xml", None boxes = {"given": "", "family": "", "credit": ""} other: list[str] = [] countries: dict[str, str] = {} public = False locale = "" for element in root.iter(): tag = _local(element.tag) if element.tag == _LOCALE: locale = _text(element) elif tag == "person": for name in element: if _local(name.tag) != "name": continue public = True for box in name: key = {"given-names": "given", "family-name": "family", "credit-name": "credit"}.get(_local(box.tag)) if key: boxes[key] = _text(box) elif tag == "other-name": other += [t for c in element if _local(c.tag) == "content" and (t := _text(c))] elif element.tag in (_ADDRESS, _EMPLOYMENT, _EDUCATION): where = {_ADDRESS: "address", _EMPLOYMENT: "employment", _EDUCATION: "education"}[element.tag] if where not in countries: found = next((t for c in element.iter() if _local(c.tag) == "country" and (t := _text(c))), "") if found: countries[where] = found.upper() if not public: return "name_private", None if not boxes["given"] and not boxes["family"]: return "name_empty", None record: dict[str, Any] = {k: v for k, v in boxes.items() if v} if other: record["other"] = other where = next((w for w in COUNTRY_FROM if w in countries), "") if where: record["country"], record["country_from"] = countries[where], where if locale: record["locale"] = locale return "record", record def _summaries(bodies: list[bytes]) -> tuple[list[str], dict[str, int]]: """A batch of summaries read (_summary): each record as an extract line, and the counts of the rest. A worker's unit of work.""" lines, counts = [], Counter[str]() for body in bodies: outcome, record = _summary(body) counts[outcome] += 1 if record is not None: lines.append(corpus_build.jsonl_line(record, sort_keys=True)) return lines, dict(counts) class _RangeStream(io.RawIOBase): """`url`'s body, `size` bytes, read through ranged requests: a connection dropped or timed out mid-body is opened again from the byte reached, under local_core's retry policy with ORCID_RETRIES attempts, so an outage of a few minutes costs no byte already read. Each response must be a 206 saying it is the range asked of a body of `size` bytes, last modified when the first one was (which must say when), or the file was replaced and the stream refuses it. finish() checks the bytes read against `size` and `md5`.""" def __init__(self, url: str, size: int, md5: str) -> None: self.url, self.size, self.md5 = url, size, md5 self.pos = 0 self.modified = "" self._digest = hashlib.md5() self._response: http.client.HTTPResponse | None = None def readable(self) -> bool: return True def _open(self) -> http.client.HTTPResponse: request = urllib.request.Request(self.url, headers={ "User-Agent": local_core.USER_AGENT, "Range": f"bytes={self.pos}-"}) response = urllib.request.urlopen(request, timeout=60) stated = response.headers.get("Content-Range", "") modified = response.headers.get("Last-Modified", "") wanted = f"bytes {self.pos}-{self.size - 1}/{self.size}" if response.status != 206 or stated != wanted or not modified or ( self.modified and modified != self.modified): response.close() raise ValueError( f"{self.url} answered {response.status} {stated or 'no range'}" f" (last modified {modified or 'not stated'}) where {wanted}" f"{' of the file last modified ' + self.modified if self.modified else ''}" f" was asked: the file is not the pinned one") self.modified = modified return response def readinto(self, buffer: memoryview | bytearray) -> int: # type: ignore[override] if self.pos >= self.size: return 0 for attempt in range(ORCID_RETRIES): try: if self._response is None: self._response = self._open() got = self._response.readinto(buffer) if not got: raise http.client.IncompleteRead(b"", self.size - self.pos) except local_core._TRANSIENT as exc: self._drop() if not _backed_off(exc, attempt, self.url, ORCID_RETRIES): raise continue self._digest.update(memoryview(buffer)[:got]) self.pos += got return got raise AssertionError("unreachable: the last attempt raises") def _drop(self) -> None: if self._response is not None: self._response.close() self._response = None def close(self) -> None: self._drop() super().close() def finish(self) -> None: """ValueError naming the refresh unless every byte was read and their MD5 is the pinned one.""" if self.pos != self.size or self._digest.hexdigest() != self.md5: raise ValueError(f"{self.url}: read {self.pos:,} of {self.size:,} bytes " f"with MD5 {self._digest.hexdigest()}, where {self.md5} " f"is pinned{_again(ORCID)}") class _Inline: """An executor that runs each call as it is submitted: one job.""" def submit(self, fn: Callable[[list[bytes]], _Batch], batch: list[bytes]) -> Future[_Batch]: done: Future[_Batch] = Future() done.set_result(fn(batch)) return done def _drain(pending: collections.deque[Future[_Batch]], out: io.TextIOBase, counts: Counter[str], keep: int) -> None: """Write the oldest batches' lines until at most `keep` are pending.""" while len(pending) > keep: lines, batch_counts = pending.popleft().result() counts.update(batch_counts) out.writelines(f"{line}\n" for line in lines) def _extract(raw: io.RawIOBase, out: io.TextIOBase, jobs: int) -> Counter[str]: """Every summary of the tar.gz read from `raw`, parsed `jobs` at a time (_summaries), its records written to `out` as they come; the counts. The gzip stream is read to its end, past the tar's own end, so that its CRC is checked.""" counts: Counter[str] = Counter() unzipped = gzip.GzipFile(fileobj=io.BufferedReader(raw, local_core.CHUNK)) batch: list[bytes] = [] pending: collections.deque[Future[_Batch]] = ( collections.deque()) # A worker unpickles the function by its module's bare name, which # resolves there because this directory is on the inherited sys.path # (snapshot.parallel says why). read = getattr(importlib.import_module("local_orcid"), "_summaries") with (ProcessPoolExecutor(jobs, mp_context=multiprocessing.get_context("spawn")) if jobs > 1 else contextlib.nullcontext(_Inline())) as pool: with tarfile.open(fileobj=unzipped, mode="r|") as tar: for member in tar: # A TarFile keeps every member it has read, even read as # a stream: 26M of them would hold the whole file's # headers in memory (2.3 GB a GiB in, measured 2026-10-27). tar.members = [] if not member.isfile(): continue if not _SUMMARY.fullmatch(member.name): counts["members_not_summaries"] += 1 continue file = tar.extractfile(member) assert file is not None, "a regular member has a body" batch.append(file.read()) if len(batch) == _BATCH: pending.append(pool.submit(read, batch)) batch = [] _drain(pending, out, counts, 2 * jobs) if batch: pending.append(pool.submit(read, batch)) _drain(pending, out, counts, 0) while unzipped.read(local_core.CHUNK): pass return counts def renew_orcid(jobs: int | None = None) -> None: """Stream the pinned summaries file (_RangeStream) and keep its extract: a header naming the file, its data date (the file's Last-Modified) and the counts, then one line per record with a public name, in the tar's order. Written beside its final name and renamed into place only once the whole file was read, its MD5 checked and the extract read back through every check a load makes (_orcid_read), so a fetch that dies or is refused leaves the old extract, or none. The header is a gzip member of its own ahead of the records' (gzip reads the members as one stream), since the counts are known only at the end and the records are not compressed twice.""" target = extract_path() body, staged = (target.with_name(target.name + end) for end in (".part", ".new")) stream = _RangeStream(ORCID_URL, ORCID_SIZE, ORCID_MD5) print(f"streaming {ORCID_FILE} ({ORCID_SIZE / 1e9:.1f} GB) from {ORCID_URL} ...", file=sys.stderr) try: with gzip.open(body, "wt", encoding="utf-8") as out: counts = _extract(stream, out, jobs or os.cpu_count() or 1) stream.finish() header = corpus_build.jsonl_line({"file": ORCID_FILE, "data_date": _iso(stream.modified), "counts": dict(counts)}) with staged.open("wb") as joined, body.open("rb") as records: joined.write(gzip.compress(f"{header}\n".encode())) while chunk := records.read(local_core.CHUNK): joined.write(chunk) _orcid_read(staged) os.replace(staged, target) finally: stream.close() body.unlink(missing_ok=True) staged.unlink(missing_ok=True) def _iso(http_date: str) -> str: """An HTTP date as the corpora's data dates are written.""" if not http_date: raise ValueError(f"{ORCID_URL} stated no Last-Modified to date the data by") return email.utils.parsedate_to_datetime(http_date).strftime("%Y-%m-%dT%H:%M:%S") def orcid_record(line: dict[str, Any]) -> dict[str, str]: """An extract line as a corpus record: the boxes joined given first as "name", the boxes, the credit name and the country with where it came from. The other names stay in the extract: a list, where a record holds text.""" record = {k: line[k] for k in ("given", "family", "credit", "country", "country_from") if line.get(k)} record["name"] = " ".join(v for v in (line.get("given"), line.get("family")) if v) return record def orcid_kept(records: Iterator[dict[str, str]], cap: int) -> list[dict[str, str]]: """The distinct records, at most `cap` per country (no country is a country of its own): those whose line hashes lowest, so a country under the cap is kept whole and the same record is kept whatever else the file holds.""" heaps: dict[str, list[tuple[int, str]]] = collections.defaultdict(list) seen: dict[str, set[int]] = collections.defaultdict(set) for record in records: line = corpus_build.jsonl_line(record, sort_keys=True) rank = -int(hashlib.sha256(line.encode()).hexdigest()[:16], 16) country = record.get("country", "") heap, held = heaps[country], seen[country] if rank in held: continue if len(heap) < cap: heapq.heappush(heap, (rank, line)) held.add(rank) elif rank > heap[0][0]: held.discard(heapq.heapreplace(heap, (rank, line))[0]) held.add(rank) return [json.loads(line) for heap in heaps.values() for _, line in sorted(heap)] def _orcid_read(path: Path) -> Fetched: """The corpus from the cached extract (orcid_record, orcid_kept), or ValueError naming the refresh where it is not an extract of the pinned file, where it holds another number of summaries than ORCID_SUMMARIES or another number of records than its header counts, where past ORCID_MAX_BAD of its summaries were not XML or declared a DTD, of the tar's files were not summaries (a layout that moved under _SUMMARY) or of its countries are not two capital letters, or where fewer than the ORCID_MIN_ shares of its summaries gave a record or of its records hold a family name or a country (an element the reader looks for, renamed). The extract is cut at "\n" only, as corpus_build.jsonl_lines cuts, and streamed rather than read whole.""" seen: Counter[str] = Counter() def read(lines: Iterator[str]) -> Iterator[dict[str, str]]: for line in lines: record = orcid_record(json.loads(line)) seen["records"] += 1 seen["family"] += "family" in record if country := record.get("country"): seen["country"] += 1 seen["country_malformed"] += not _COUNTRY.fullmatch(country) yield record try: with gzip.open(path, "rt", encoding="utf-8", newline="\n") as lines: head = json.loads(next(lines)) counts, data_date = head["counts"], head["data_date"] if (head.get("file") != ORCID_FILE or not isinstance(counts, dict) or not all(type(n) is int for n in counts.values()) or not isinstance(data_date, str)): raise ValueError("its header does not describe the pinned file") records = orcid_kept(read(lines), ORCID_CAP) except (OSError, EOFError, StopIteration, ValueError, KeyError, TypeError, zlib.error) as exc: raise ValueError(f"{path} is not an extract this tool wrote ({exc})" f"{_again(ORCID)}") from None summaries = sum(n for key, n in counts.items() if key != "members_not_summaries") if summaries != ORCID_SUMMARIES or seen["records"] != counts.get("record", 0): raise ValueError( f"{path} holds {seen['records']:,} records of {summaries:,} summaries, where its " f"header counts {counts.get('record', 0):,} records and {ORCID_SUMMARIES:,} " f"summaries are pinned: the tar was read in part{_again(ORCID)}") why = "the file is not the Public Data File this tool reads" _refuse_past([(counts.get("not_xml", 0), summaries, ORCID_MAX_BAD, "summaries are not XML"), (counts.get("dtd_refused", 0), summaries, ORCID_MAX_BAD, "summaries declare a DTD or an entity"), (counts.get("members_not_summaries", 0), summaries + counts.get("members_not_summaries", 0), ORCID_MAX_BAD, "files in the tar are not summaries"), (seen["country_malformed"], seen["country"], ORCID_MAX_BAD, "countries are not two capital letters")], why) for count, of, least, what in ( (seen["records"], summaries, ORCID_MIN_RECORDS, "summaries give a record"), (seen["family"], seen["records"], ORCID_MIN_FAMILY, "records hold a family name"), (seen["country"], seen["records"], ORCID_MIN_COUNTRY, "records hold a country")): if count < of * least: raise ValueError(f"only {count:,} of {of:,} {what}: {why}") by_country = Counter(r["country"] for r in records if "country" in r) return Fetched(records, data_date, { "kept": _kept(), "summaries": summaries, **{key: counts[key] for key in sorted(counts)}, "countries": len(by_country), "countries_at_the_cap": sum(n == ORCID_CAP for n in by_country.values())}) def orcid_boxes(path: Path) -> Iterator[tuple[str, str, str, int]]: """Every record of the extract at `path` as (given, family, country, 1): one holder each, uncapped, for local_corpora.box_index. The extract was checked when it was fetched (_orcid_read); a file whose header names another release is refused.""" with gzip.open(path, "rt", encoding="utf-8", newline="\n") as lines: if json.loads(next(lines)).get("file") != ORCID_FILE: raise ValueError(f"{path} is not an extract of {ORCID_FILE}{_again(ORCID)}") for line in lines: record = json.loads(line) yield (record.get("given", ""), record.get("family", ""), record.get("country", ""), 1) def _kept() -> str: return f"{ORCID_FILE}, at most {ORCID_CAP:,} records a country" def fetch_orcid() -> Fetched: """The corpus (_orcid_read), read from the extract cached beside it, streamed from the pinned file when missing and again by `fetch --refresh`.""" if not extract_path().exists(): renew_orcid() return _orcid_read(extract_path()) check_orcid = _pinned("kept", _kept, "keeps {held}, and this tool keeps {wanted}") ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/local_patentsview.py���������������������������0000664�0000000�0000000�00000050545�15273032717�0026611�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""The patentsview-inventors local corpus: US patents' inventors, as their filings name them. patentsview-inventors -- the inventor of every patent the USPTO granted from 1976 to 2025, from PatentsView's granted-patent tables on the USPTO Open Data Portal: each inventor's name as the filing gives it, in its two boxes (first name, last name), and the country of the inventor's address. A local corpus whose names carry their fields (local_agreement.FIELDED), so local_agreement.py scores the parser against them. A filing is written by the applicant's attorney, so the split is a professional filer's, not the inventor's own as in orcid-researchers: the two read together tell how a name is written from how its bearer types it. The name the parser reads is the two boxes joined, first name first, as a filing writes it. A record is a distinct name in a country, with the number of people who filed under it ("people", counted by PatentsView's disambiguated inventor id): 24,037,380 inventor rows, one per inventor per patent, held 5,319,206 such names of 4,294,034 inventors on 2026-10-28, filed under 5,508,446 pairs of a name and an inventor. The count is what lets a pack ask how common a word is in a country's names (a frequency ORCID's capped corpus cannot give); it counts people, not patents, so a prolific inventor weighs once. The extract keeps every name; the corpus keeps at most PV_CAP a country, chosen by a hash of the record as orcid-researchers chooses, for the tools that hold a corpus in memory. The fetch downloads three of the product's files (PV_FILES) and reads them without unpacking: the raw inventor table for the names as filed, the disambiguated one, row for row the same inventors, for the inventor id and location, and the disambiguated locations for the country. The portal needs an API key, sent as X-API-KEY: a USPTO.gov account verified through ID.me gets one (data.uspto.gov, My ODP), saved alone in PV_KEY, read there and sent to https uspto.gov hosts only. The release is pinned by each file's size, date and sha256 and the rows it holds; a new quarter's is measured and the pin moved by hand. The zips are deleted once the extract is built and read back, a failed run keeping those already whole for the next; `fetch --refresh` downloads them again. PatentsView licenses its data under CC BY 4.0 and asks for credit; the inventors are living people, which keeps their names in the cache. """ from __future__ import annotations import collections import csv import email.message import gzip import hashlib import heapq import io import json import os import shutil import sys import urllib.error import urllib.parse import urllib.request import zipfile from collections import Counter from collections.abc import Iterator from pathlib import Path from typing import IO, Any, cast import corpus_build import local_core from local_core import Fetched, _again, _backed_off, _pinned, _refuse_past, directory PV = "patentsview-inventors" PV_PRODUCT = "PVGPATDIS" PV_API = "https://api.uspto.gov/api/v1/datasets/products" #: The pinned release, as the portal listed it on 2026-10-28: grants to #: this date, and for each file its size, its last modification as the #: listing states it, and the sha256 of the bytes downloaded that day. PV_RELEASE = "2025-12-31" PV_RAW = "g_inventor_not_disambiguated.tsv.zip" PV_INVENTORS = "g_inventor_disambiguated.tsv.zip" PV_LOCATIONS = "g_location_disambiguated.tsv.zip" PV_FILES = { PV_RAW: (1_035_476_374, "2026-03-12 22:26:59", "18bcef31301b216012a292242ea64393a5c88083ccb648972634df4492017c7b"), PV_INVENTORS: (707_503_691, "2026-03-12 22:26:35", "f1148d8c57e2c7d28062b4f572d9a9060eb537c4c62f54d69c63a047b7944385"), PV_LOCATIONS: (2_697_159, "2026-03-12 22:27:08", "526023908343f28491ebe6ea9d0568dfae219418f9c128d8a0c4f49e0ce9d6d9"), } #: The inventor rows the pinned release holds, each table the same: every #: field is quoted, so a stray quote cannot fold rows together, and a #: count short of this is a table read in part. PV_ROWS = 24_037_380 #: The columns read, by name: the tables state them in a header. PV_COLUMNS = { PV_RAW: ("patent_id", "inventor_sequence", "raw_inventor_name_first", "raw_inventor_name_last"), PV_INVENTORS: ("patent_id", "inventor_sequence", "inventor_id", "location_id"), PV_LOCATIONS: ("location_id", "disambig_country"), } PV_KEY = Path.home() / ".config" / "nameparser-tools" / "uspto-odp-key" #: The extract format, in the extract's file name. PV_EXTRACT_VERSION = 1 #: The most names a country keeps in the corpus. PV_CAP = 20_000 #: The share of rows a read skips as not the header's width before it #: refuses the file (none of 24,037,380 on 2026-10-28), and the least #: share whose location names a country (99.3%) and whose boxes are both #: filled (all but 676). PV_MAX_BAD = 1 / 10_000 PV_MIN_COUNTRY = 0.9 PV_MIN_BOTH = 0.9 _CHUNK = 1 << 20 def extract_path() -> Path: """The extract cached for the pinned release.""" return directory(PV) / f"inventors_{PV_RELEASE}.v{PV_EXTRACT_VERSION}.jsonl.gz" def _get_a_key() -> str: return ("sign in at data.uspto.gov with a USPTO.gov account verified through ID.me, " f"copy the key under My ODP, and save it alone in {PV_KEY}, readable by you " "only (chmod 600)") def _key() -> str: try: key = PV_KEY.read_text(encoding="utf-8").strip() except FileNotFoundError: key = "" if not key or any(c.isspace() for c in key): raise ValueError(f"{PV} needs a USPTO Open Data Portal API key: {_get_a_key()}") return key def _uspto(url: str) -> bool: parts = urllib.parse.urlsplit(url) host = parts.hostname or "" return parts.scheme == "https" and (host == "uspto.gov" or host.endswith(".uspto.gov")) class _KeyStaysHome(urllib.request.HTTPRedirectHandler): """A redirect off an https uspto.gov host goes without the key.""" def redirect_request(self, req: urllib.request.Request, fp: IO[bytes], code: int, msg: str, headers: email.message.Message, newurl: str ) -> urllib.request.Request | None: new = super().redirect_request(req, fp, code, msg, headers, newurl) if new is not None and not _uspto(newurl): new.remove_header("X-api-key") return new def _open(url: str) -> IO[bytes]: if not _uspto(url): raise ValueError(f"{url} is not an https uspto.gov address: the key goes nowhere else") request = urllib.request.Request(url, headers={ "X-API-KEY": _key(), "User-Agent": local_core.USER_AGENT}) try: return cast(IO[bytes], urllib.request.build_opener(_KeyStaysHome).open(request, timeout=120)) except urllib.error.HTTPError as exc: if exc.code not in (401, 403): raise exc.close() raise ValueError(f"the portal refused the key in {PV_KEY} ({exc.code}): it may " f"have expired or been revoked; for a new one, {_get_a_key()}" ) from None def _listed() -> dict[str, dict[str, Any]]: """The product's files as the portal lists them, refused where a pinned file is missing or not the size and date pinned: another quarter's release, whose pin is moved by hand.""" with _open(f"{PV_API}/{PV_PRODUCT}?includeFiles=true") as response: data = corpus_build.parse_json(response.read(), PV_API) found: dict[str, dict[str, Any]] = {} def walk(node: object) -> None: if isinstance(node, dict): if isinstance(node.get("fileName"), str): found[node["fileName"]] = node for value in node.values(): walk(value) elif isinstance(node, list): for value in node: walk(value) walk(data) for name, (size, modified, _) in PV_FILES.items(): entry = found.get(name) if entry is None or (entry.get("fileSize"), entry.get("fileLastModifiedDateTime")) != ( size, modified): held = entry and (entry.get("fileSize"), entry.get("fileLastModifiedDateTime")) raise ValueError(f"{PV_PRODUCT} lists {name} as {held or 'missing'} where " f"{(size, modified)} is pinned: another release, measured " f"and pinned by hand in local_patentsview.PV_FILES") return found def _whole(path: Path, name: str) -> bool: """Whether `path` is already the pinned file `name`, size and sha256.""" size, _, sha = PV_FILES[name] if not path.exists() or path.stat().st_size != size: return False digest = hashlib.sha256() with path.open("rb") as held: while chunk := held.read(_CHUNK): digest.update(chunk) return digest.hexdigest() == sha def _download(entry: dict[str, Any], path: Path) -> None: """One pinned file into `path` through a .part file, refused unless it is the size and sha256 pinned; kept where a run before left it whole, and tried again under local_core's retry policy where a connection drops, each try from the start.""" name = entry["fileName"] if _whole(path, name): return size, _, sha = PV_FILES[name] url = entry.get("fileDownloadURI") or f"{PV_API}/files/{PV_PRODUCT}/{name}" partial = path.with_name(path.name + ".part") for attempt in range(local_core.RETRIES): print(f"downloading {name} ({size / 1e6:,.0f} MB) ...", file=sys.stderr) digest, got = hashlib.sha256(), 0 try: with _open(url) as response, partial.open("wb") as out: while chunk := response.read(_CHUNK): out.write(chunk) digest.update(chunk) got += len(chunk) except local_core._TRANSIENT as exc: partial.unlink(missing_ok=True) if not _backed_off(exc, attempt, url): raise continue except BaseException: partial.unlink(missing_ok=True) raise if (got, digest.hexdigest()) != (size, sha): partial.unlink(missing_ok=True) raise ValueError(f"{name} arrived as {got:,} bytes with sha256 " f"{digest.hexdigest()}, where {size:,} and {sha} are pinned") os.replace(partial, path) return raise AssertionError("unreachable: the last attempt raises") def _rows(path: Path, name: str, counts: Counter[str]) -> Iterator[dict[str, str]]: """A table's rows by the columns PV_COLUMNS names, read without unpacking; a row not the header's width is counted and skipped.""" with zipfile.ZipFile(path) as archive: members = archive.namelist() if len(members) != 1: raise ValueError(f"{name} holds {len(members)} members where one is expected") with archive.open(members[0]) as raw: table = csv.reader(io.TextIOWrapper(raw, encoding="utf-8", newline=""), delimiter="\t") header = next(table) missing = [c for c in PV_COLUMNS[name] if c not in header] if missing: raise ValueError(f"{name}'s header holds no {', '.join(missing)}") at = [header.index(c) for c in PV_COLUMNS[name]] for row in table: if len(row) != len(header): counts[f"{name}: rows not {len(header)} fields"] += 1 continue yield {c: row[i] for c, i in zip(PV_COLUMNS[name], at)} def _extract(folder: Path, out: IO[str]) -> Counter[str]: """Every distinct (first, last, country) of the raw table, with the distinct inventors who filed under it, written to `out` as extract lines; the counts. The raw and disambiguated tables are read in step, row for row, and a pair of rows naming different inventor slots is refused rather than joined.""" counts: Counter[str] = Counter() country_of = {r["location_id"]: r["disambig_country"] for r in _rows(folder / PV_LOCATIONS, PV_LOCATIONS, counts)} inventors = _rows(folder / PV_INVENTORS, PV_INVENTORS, counts) people: dict[tuple[str, str, str], int] = {} seen: set[int] = set() for raw in _rows(folder / PV_RAW, PV_RAW, counts): row = next(inventors, None) slot = (raw["patent_id"], raw["inventor_sequence"]) if row is None or (row["patent_id"], row["inventor_sequence"]) != slot: raise ValueError(f"{PV_RAW} and {PV_INVENTORS} part at row {counts['rows'] + 1:,}:" f" the two tables are not row for row the same inventors") counts["rows"] += 1 first = " ".join(raw["raw_inventor_name_first"].split()) last = " ".join(raw["raw_inventor_name_last"].split()) country = country_of.get(row["location_id"], "").strip().upper() counts["rows_with_a_country"] += bool(country) if not first or not last: counts["rows_without_both_boxes"] += 1 if not first and not last: continue key = (first, last, country) # One inventor counts once under a name, however many patents. filed = hash((key, row["inventor_id"])) if filed not in seen: seen.add(filed) people[key] = people.get(key, 0) + 1 if next(inventors, None) is not None: raise ValueError(f"{PV_INVENTORS} holds more rows than {PV_RAW}") for (first, last, country), n in people.items(): out.write(corpus_build.jsonl_line( {k: v for k, v in (("given", first), ("family", last), ("country", country)) if v} | {"people": n}, sort_keys=True) + "\n") counts["names"] = len(people) counts["people_by_name"] = sum(people.values()) return counts def renew_pv() -> None: """Download the pinned files (_download), build the extract (_extract) and read it back through every check a load makes (_pv_read) before it replaces the cached one. The zips are deleted once it is in place; a run that fails keeps those already whole, so the next downloads only the rest. The extract opens with a header of the release, the files' sha256 and the counts.""" target = extract_path() folder = target.parent / "downloads" body, staged = (target.with_name(target.name + end) for end in (".part", ".new")) folder.mkdir(parents=True, exist_ok=True) try: listed = _listed() for name in PV_FILES: _download(listed[name], folder / name) with gzip.open(body, "wt", encoding="utf-8", newline="\n") as out: counts = _extract(folder, out) header = corpus_build.jsonl_line({ "release": PV_RELEASE, "files": {n: sha for n, (_, _, sha) in PV_FILES.items()}, "counts": dict(counts)}) # The header is a gzip member of its own ahead of the names', as # orcid-researchers' is: the counts are known only at the end. with staged.open("wb") as joined, body.open("rb") as names: joined.write(gzip.compress(f"{header}\n".encode())) shutil.copyfileobj(names, joined, _CHUNK) _pv_read(staged) os.replace(staged, target) shutil.rmtree(folder, ignore_errors=True) finally: body.unlink(missing_ok=True) staged.unlink(missing_ok=True) def pv_record(line: dict[str, Any]) -> dict[str, str]: """An extract line as a corpus record: the boxes joined first name first as "name", the boxes, the country and the people, written as text since a corpus record holds only text (local_corpora.read_records); the extract keeps the number.""" record = {k: line[k] for k in ("given", "family", "country") if line.get(k)} record["name"] = " ".join(v for v in (line.get("given"), line.get("family")) if v) record["people"] = str(line["people"]) return record def pv_kept(records: Iterator[dict[str, str]], cap: int) -> list[dict[str, str]]: """At most `cap` records a country (none is a country of its own): those whose line hashes lowest, so a country under the cap is kept whole and the same names are kept whatever else the file holds.""" heaps: dict[str, list[tuple[int, str]]] = collections.defaultdict(list) for record in records: line = corpus_build.jsonl_line(record, sort_keys=True) rank = -int(hashlib.sha256(line.encode()).hexdigest()[:16], 16) heap = heaps[record.get("country", "")] if len(heap) < cap: heapq.heappush(heap, (rank, line)) elif rank > heap[0][0]: heapq.heapreplace(heap, (rank, line)) return [json.loads(line) for heap in heaps.values() for _, line in sorted(heap)] def _pv_read(path: Path) -> Fetched: """The corpus from the cached extract (pv_record, pv_kept), or ValueError naming the refresh where it is not an extract of the pinned release, where it holds another number of names than its header counts, where its rows are not PV_ROWS, or past PV_MAX_BAD of them were malformed, or fewer than PV_MIN_COUNTRY named a country or PV_MIN_BOTH filled both boxes. The extract is cut at "\\n" only, as corpus_build.jsonl_lines cuts, and streamed rather than read whole.""" lines_read = 0 def read(lines: Iterator[str]) -> Iterator[dict[str, str]]: nonlocal lines_read for line in lines: lines_read += 1 yield pv_record(json.loads(line)) try: with gzip.open(path, "rt", encoding="utf-8", newline="\n") as lines: head = json.loads(next(lines)) counts = head["counts"] if (head.get("release") != PV_RELEASE or head.get("files") != {n: sha for n, (_, _, sha) in PV_FILES.items()} or not isinstance(counts, dict) or not all(type(n) is int for n in counts.values())): raise ValueError("its header does not describe the pinned release") records = pv_kept(read(lines), PV_CAP) except (OSError, EOFError, StopIteration, ValueError, KeyError, TypeError) as exc: raise ValueError(f"{path} is not an extract this tool wrote ({exc}){_again(PV)}" ) from None rows = counts.get("rows", 0) if rows != PV_ROWS or lines_read != counts.get("names", -1): raise ValueError(f"{path} holds {lines_read:,} names of {rows:,} rows, where its " f"header counts {counts.get('names', 0):,} names and {PV_ROWS:,} " f"rows are pinned: a table was read in part{_again(PV)}") why = "the files are not the PatentsView tables this tool reads" _refuse_past([(n, rows, PV_MAX_BAD, what) for what, n in counts.items() if what.endswith("fields")], why) for count, least, what in ((counts.get("rows_with_a_country", 0), PV_MIN_COUNTRY, "rows name a country"), (rows - counts.get("rows_without_both_boxes", 0), PV_MIN_BOTH, "rows fill both boxes")): if count < rows * least: raise ValueError(f"only {count:,} of {rows:,} {what}: {why}") by_country = Counter(r["country"] for r in records if "country" in r) return Fetched(records, PV_RELEASE, { "kept": _kept(), **{key: counts[key] for key in sorted(counts)}, "countries": len(by_country), "countries_at_the_cap": sum(n == PV_CAP for n in by_country.values())}) def pv_boxes(path: Path) -> Iterator[tuple[str, str, str, int]]: """Every name of the extract at `path` as (given, family, country, people), uncapped, for local_corpora.box_index. The extract was checked when it was fetched (_pv_read); a file whose header names another release is refused.""" with gzip.open(path, "rt", encoding="utf-8", newline="\n") as lines: if json.loads(next(lines)).get("release") != PV_RELEASE: raise ValueError(f"{path} is not an extract of the {PV_RELEASE} release{_again(PV)}") for line in lines: record = json.loads(line) yield (record.get("given", ""), record.get("family", ""), record.get("country", ""), record["people"]) def _kept() -> str: return f"release {PV_RELEASE}, at most {PV_CAP:,} names a country" def fetch_pv() -> Fetched: """The corpus (_pv_read), read from the extract cached beside it, downloaded and built when missing and again by `fetch --refresh`.""" if not extract_path().exists(): renew_pv() return _pv_read(extract_path()) check_pv = _pinned("kept", _kept, "keeps {held}, and this tool keeps {wanted}") �����������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/local_psc.py�����������������������������������0000664�0000000�0000000�00000034776�15273032717�0025035�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""The psc-individuals local corpus: Companies House's people with significant control. psc-individuals -- the people with significant control of UK companies, and the beneficial owners of overseas entities, from Companies House's daily snapshot: each person's display name, the four boxes the filer typed it into (title, forename, middle name, surname) and the stated nationality, and nothing else. A local corpus whose names carry their fields (local_agreement.FIELDED), so local_agreement.py scores the parser against them. A record is a distinct filed name, and the corpus keeps one in PSC_SAMPLE of them (1,040,708 of 8,322,939 on 2026-10-22). The snapshot comes in parts (33 on 2026-10-22), whose extracts are cached beside the corpus (renew_psc says why), and `fetch --refresh` downloads the day's snapshot again. Companies House sets no terms on the register's use; the names are personal data, which the credit line says. """ from __future__ import annotations import gzip import hashlib import json import os import re import shutil import urllib.error import urllib.parse import zipfile import zlib from collections import Counter from pathlib import Path import corpus_build from local_core import (Fetched, _again, _download, _pinned, _refuse_past, _retried, directory) PSC = "psc-individuals" PSC_PAGE = "https://download.companieshouse.gov.uk/en_pscdata.html" #: A part of the day's snapshot as the page links it: its file, date, #: number, and how many parts there are. PSC_PART = re.compile(r'href="(psc-snapshot-(\d{4}-\d{2}-\d{2})_(\d+)of(\d+)\.zip)"') #: The two kinds of record that name a person: a company's PSC, and an #: overseas entity's registrable beneficial owner, filed in the same boxes #: (70 of part 1's 461,778 individuals on 2026-10-22). The corporate, #: legal-person and super-secure kinds name no person or none at all. PSC_KINDS = ("individual-person-with-significant-control", "individual-beneficial-owner") #: The four name boxes, in the order the display name joins them. PSC_ELEMENTS = ("title", "forename", "middle_name", "surname") #: What a record keeps: the display name, the boxes and the nationality. PSC_KEPT = ("name", *PSC_ELEMENTS, "nationality") #: The share of individuals (of lines, for a line not JSON) a fetch skips #: before it refuses the snapshot. Of part 1's 461,778 individuals on #: 2026-10-22 none had no name_elements and every display name was its #: boxes joined; 2 of the whole snapshot's 14,025,738 were not. PSC_MAX_BAD = 1 / 10_000 #: The least share of lines that are PSC_KINDS records before a fetch #: refuses the snapshot: 14,025,738 of 16,071,681 (87%) on 2026-10-22. #: A kind renamed by Companies House is counted as another kind, and #: without this floor the corpus would shrink to whatever kind is left. PSC_MIN_INDIVIDUALS = 1 / 2 #: The cached extracts, one per part, beside the corpus. PSC_PARTS = "parts" #: The extract format, in each extract's file name: a change to what #: _psc_extract keeps or refuses bumps it, so an extract written by #: another version is neither resumed from nor read. PSC_EXTRACT_VERSION = 1 _PSC_EXTRACT = re.compile(r"psc-snapshot-(\d{4}-\d{2}-\d{2})_(\d+)of(\d+)\.v(\d+)\.jsonl\.gz") #: The corpus keeps one filed name in PSC_SAMPLE, chosen by a hash of the #: record (psc_sampled). The whole snapshot held 8,322,939 distinct filed #: names on 2026-10-22, 1.09 GB as JSON lines, and a local corpus is held #: in memory whole (local_corpora.Local.records), and snapshot.py --local reads every #: name with both APIs. A record's hash does not move with the snapshot, #: so a refetch keeps or drops the same record as before, and no #: nationality or shape is favoured. PSC_SAMPLE = 8 def psc_sampled(line: str) -> bool: """Whether the corpus keeps the record written as this line.""" return int(hashlib.sha256(line.encode()).hexdigest()[:8], 16) % PSC_SAMPLE == 0 def _extract_name(file: str) -> str: """The extract cached for a part's zip.""" return file.replace(".zip", f".v{PSC_EXTRACT_VERSION}.jsonl.gz") def _psc_extract(path: Path, member: str) -> bytes: """One downloaded part, read down to what the corpus keeps: gzipped JSON lines, a header naming the part and holding its counts, and then its distinct records, each its PSC_KEPT values with the empty ones left out. A record that is not one of PSC_KINDS is counted (records_of_other_kinds) and dropped, as is an individual whose name_elements are missing or not strings, or whose display name is not its boxes joined by spaces: the score compares the boxes with a parse of that name. Everything else in a record (address, date of birth, company number, natures of control) is read past.""" counts: Counter[str] = Counter() lines: set[str] = set() with zipfile.ZipFile(path) as archive: if member not in archive.namelist(): raise ValueError(f"{path} holds no {member}") with archive.open(member) as source: for line in source: counts["lines"] += 1 try: record = json.loads(line) except ValueError: counts["lines_not_json"] += 1 continue data = record.get("data") if isinstance(record, dict) else None kind = data.get("kind") if isinstance(data, dict) else None if not isinstance(data, dict) or kind not in PSC_KINDS: counts["records_of_other_kinds"] += 1 continue counts[kind] += 1 name, elements = data.get("name"), data.get("name_elements") if (not isinstance(name, str) or not isinstance(elements, dict) or not all(isinstance(elements.get(f, ""), str) for f in PSC_ELEMENTS)): counts["individuals_without_name_elements"] += 1 continue if " ".join(v for f in PSC_ELEMENTS if (v := elements.get(f))) != name: counts["names_not_their_boxes_joined"] += 1 continue nationality = data.get("nationality") if not isinstance(nationality, str) or not nationality: counts["individuals_without_a_nationality"] += 1 nationality = "" kept = {"name": name, "nationality": nationality, **{f: elements.get(f, "") for f in PSC_ELEMENTS}} lines.add(corpus_build.jsonl_line( {k: v for k, v in kept.items() if v}, sort_keys=True)) header = corpus_build.jsonl_line({"part": member, "counts": dict(counts)}) return gzip.compress("".join(f"{line}\n" for line in [header, *sorted(lines)]).encode()) def _one_snapshot(dates: set[str], totals: set[int], numbers: list[int]) -> bool: """Whether parts `numbers`, dated `dates` and each one of `totals`, are every part of one snapshot.""" return (len(dates) == 1 and len(totals) == 1 and sorted(numbers) == list(range(1, max(totals) + 1))) def _psc_listing() -> tuple[str, list[tuple[int, str]]]: """The day's snapshot as the download page lists it: its date and each part's number and file, or ValueError where the page links no parts, or parts of more than one snapshot, or not every part. A part linked twice is one part.""" page = _retried(PSC_PAGE, lambda body: body, 0) links = PSC_PART.findall(page.decode("utf-8", "replace")) if not links: raise ValueError(f"{PSC_PAGE} links no snapshot parts: the page has changed") dates, totals = {d for _, d, _, _ in links}, {int(m) for *_, m in links} numbers = sorted(int(n) for _, _, n, _ in set(links)) if not _one_snapshot(dates, totals, numbers): raise ValueError(f"{PSC_PAGE} links parts {numbers} of {sorted(totals)} " f"dated {sorted(dates)}, not every part of one snapshot; " f"fetch again") return dates.pop(), sorted({(int(n), f) for f, _, n, _ in links}) def renew_psc() -> None: """Download every part of the day's snapshot and keep its extract (_psc_extract) in parts.new/, then swap parts.new/ in for parts/. Parts, not the single zip, and extracts, not zips: the 33 parts of 2026-10-22 were 2.4 GB, as the single zip was (2,443,088,441 bytes), and their extracts 168 MB (part 1's 5.6 MB of its 78.6 MB). A download that fails costs one part, and a refresh run again resumes: the extracts of the same date and version already in parts.new/ are kept, and anything else there is removed. The page's files are overwritten each morning, so a part that is gone mid-fetch most likely means the snapshot was replaced; the next run lists the new one.""" date, parts = _psc_listing() root = directory(PSC) staging, target, old = (root / f"{PSC_PARTS}{end}" for end in (".new", "", ".old")) staging.mkdir(exist_ok=True) keep = {_extract_name(file) for _, file in parts} for stale in staging.iterdir(): if stale.name not in keep: stale.unlink() for _, file in parts: out = staging / _extract_name(file) if out.exists(): continue path, url = staging / file, urllib.parse.urljoin(PSC_PAGE, file) try: _download(url, path) body = _psc_extract(path, file.replace(".zip", ".txt")) except urllib.error.HTTPError as exc: if exc.code != 404: raise exc.close() raise ValueError(f"{url} is gone (404): the {date} snapshot was most " f"likely replaced during the fetch; fetch again") from None except (zipfile.BadZipFile, zlib.error) as exc: raise ValueError(f"{file} is not a readable zip ({exc}); fetch again" ) from None finally: path.unlink(missing_ok=True) corpus_build.write_atomic(out, body) shutil.rmtree(old, ignore_errors=True) if target.exists(): os.replace(target, old) os.replace(staging, target) shutil.rmtree(old, ignore_errors=True) def _psc_part(path: Path) -> tuple[dict[str, int], list[str]]: """One extract's counts and record lines, or ValueError naming the refresh where it is not an extract _psc_extract wrote for its name.""" member = re.sub(r"\.v\d+\.jsonl\.gz$", ".txt", path.name) try: header, *records = corpus_build.jsonl_lines( gzip.decompress(path.read_bytes()).decode()) head = json.loads(header) counts = head["counts"] if (head["part"] != member or not isinstance(counts, dict) or not all(type(n) is int for n in counts.values())): raise ValueError("its header does not describe it") except (OSError, EOFError, ValueError, KeyError, TypeError, zlib.error) as exc: raise ValueError(f"{path} is not an extract this tool wrote ({exc})" f"{_again(PSC)}") from None return counts, records def _psc_read(parts: Path) -> Fetched: """The corpus from the cached extracts: every part of one snapshot, checked by name before any is read, or ValueError naming the refresh. A record is a distinct filed name -- the display name, its four boxes and the nationality -- however many companies file it, one in PSC_SAMPLE of them kept; a sampled name with every box empty is counted and dropped. Past PSC_MAX_BAD of the individuals skipped for either reason _psc_extract skips one, or of the lines not JSON, or with fewer than PSC_MIN_INDIVIDUALS of the lines individuals, the snapshot is refused rather than read as a smaller corpus.""" found: dict[int, Path] = {} dates, totals = set(), set() for path in sorted(parts.glob("*.jsonl.gz")): named = _PSC_EXTRACT.fullmatch(path.name) if not named or int(named[4]) != PSC_EXTRACT_VERSION: raise ValueError(f"{path} is not an extract of version " f"{PSC_EXTRACT_VERSION}{_again(PSC)}") number = int(named[2]) if number in found: raise ValueError(f"{parts} holds part {number} twice{_again(PSC)}") found[number] = path dates.add(named[1]) totals.add(int(named[3])) if not _one_snapshot(dates, totals, list(found)): raise ValueError(f"{parts} holds parts {sorted(found)} of {sorted(totals)} " f"dated {sorted(dates)}, not every part of one snapshot" f"{_again(PSC)}") counts: Counter[str] = Counter() lines: set[str] = set() for number in sorted(found): part_counts, records = _psc_part(found[number]) counts.update(part_counts) lines.update(line for line in records if psc_sampled(line)) individuals = sum(counts[kind] for kind in PSC_KINDS) if individuals < counts["lines"] * PSC_MIN_INDIVIDUALS: raise ValueError(f"{individuals:,} of {counts['lines']:,} lines are " f"{' or '.join(PSC_KINDS)} records: a kind has been " f"renamed or moved under PSC_KINDS") _refuse_past([(counts["lines_not_json"], counts["lines"], PSC_MAX_BAD, "lines are not JSON"), (counts["individuals_without_name_elements"], individuals, PSC_MAX_BAD, "individuals have no name_elements"), (counts["names_not_their_boxes_joined"], individuals, PSC_MAX_BAD, "individuals' display names are not their four boxes joined")], "the snapshot's layout has changed under PSC_ELEMENTS") records = [json.loads(line) for line in sorted(lines)] named_records = [record for record in records if "name" in record] if len(named_records) < len(records): counts["sampled_names_that_are_empty"] = len(records) - len(named_records) return Fetched(named_records, dates.pop(), { "parts": max(totals), "sample": f"1 in {PSC_SAMPLE}", **{key: counts[key] for key in sorted(counts)}}) def fetch_psc() -> Fetched: """One in PSC_SAMPLE of the snapshot's distinct filed names (_psc_read), read from the extracts cached beside the corpus (downloaded when missing, and again by `fetch --refresh`). The data date is the date the part files are named by; the snapshot holds what was filed up to the end of the day before.""" parts = directory(PSC) / PSC_PARTS if not parts.is_dir(): renew_psc() return _psc_read(parts) check_psc = _pinned("sample", lambda: f"1 in {PSC_SAMPLE}", "keeps {held} filed names, and this tool keeps {wanted}") ��derek73-python-nameparser-456b024/tools/differential/local_tse.py�����������������������������������0000664�0000000�0000000�00000046465�15273032717�0025041�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""The tse-donors local corpus: Brazil's TSE campaign donors. tse-donors -- every individual donor to a candidate in Brazil's 2022 general and 2024 municipal elections, from the TSE's candidates' accounts (receitas, rows whose origin is "Recursos de pessoas físicas"): the name as the campaign's accounts filed it, beside the federal tax registry's name for the same CPF. Each election's national member is read from the CDN in ranges, not downloaded whole, so a fetch (the first, or `fetch --refresh`) reads the current file. A record is a pair, so counts are of pairs. Like the FEC's, nearly all in capitals (845,498 of 848,351 filed names fetched 2026-10-22) but in spoken order with no comma (184 records held one), and with particles (DE, DA, DOS) and agnomens (FILHO, NETO, JUNIOR) throughout. The portal publishes the data under CC BY, which the credit line names; only the two name columns are kept, and the CPF beside them, unmasked in the file, is read past. """ from __future__ import annotations import csv import http.client import io import sys import urllib.request import zipfile import zlib from collections import Counter from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from _typeshed import WriteableBuffer import local_core from local_core import Fetched, _backed_off, _pinned, _refuse_past, _zip_stamp #: The latest general election and the latest municipal one. An older #: election is one more year here: 2018's national member was 26 MB #: compressed and 2020's 203 MB on 2026-10-22; 2014 and 2016 publish no #: zip at this path. TSE_ELECTIONS = ("2022", "2024") TSE_URL = ("https://cdn.tse.jus.br/estatistica/sead/odsele/prestacao_contas/" "prestacao_de_contas_eleitorais_candidatos_{year}.zip") #: Every state's receipts in one member (its Bahia rows were the 2022 #: state member's 10,788, row for row, in the file dated 2026-10-18); #: the per-state members repeat #: it, and the doador_originario members name who gave to a party #: before the party passed the money on, a different question. TSE_MEMBER = "receitas_candidatos_{year}_BRASIL.csv" #: The header's width and the three columns read, found by name: 60 #: columns in 2022 and 2024. TSE_COLUMNS = 60 TSE_ORIGIN, TSE_NAME, TSE_REGISTRY = "DS_ORIGEM_RECEITA", "NM_DOADOR", "NM_DOADOR_RFB" TSE_INDIVIDUALS = "Recursos de pessoas físicas" #: Every DS_ORIGEM_RECEITA value 2022 and 2024 hold. A value outside it #: refuses the file, since a renamed TSE_INDIVIDUALS would otherwise #: read as an election with no individual donors, and a file no longer #: in Latin-1 shows here first (físicas spelled another way). TSE_ORIGINS = frozenset({ TSE_INDIVIDUALS, "Recursos de partido político", "Recursos de outros candidatos", "Recursos próprios", "Recursos de Financiamento Coletivo", "#NULO", "Rendimentos de aplicações financeiras", "Recursos de origens não identificadas", "Doações pela Internet", "Comercialização de Bens com OR", "Comercialização de Bens com FEFC"}) #: The bytes a Range request asks for: 43 requests read 2022's member #: in the file dated 2026-10-18. TSE_BLOCK = 1 << 20 #: As local_fec.FEC_MAX_BAD, for rows that are not TSE_COLUMNS columns and for #: physical lines that fall inside a quoted field (a quote left open, #: or a line break typed into a name): none of 2,715,955 rows in the #: two files dated 2026-10-18 and 2026-10-23 was either. TSE_MAX_BAD = 1 / 10_000 #: An election is refused where fewer of its rows than this are from #: individuals (78% of 2022's and 28% of 2024's on 2026-10-22), since a #: member published as its header alone, or with the individuals' #: donations moved under another listed category, would otherwise pass #: as an election with few donors; where more of its individuals' rows #: than TSE_MAX_MISSING carry no name or no registry name, a value with #: no letter counted as none (20 and 41 rows did, in the files dated #: 2026-10-18 and 2026-10-23), which is what a masked column would #: show; where more than TSE_MAX_NUMBERED carry a number beside letters #: (7 and 42 did), which is what a numeric column moved under a name #: would show; or where its distinct filed names, or its distinct #: registry names, are fewer than TSE_MIN_DISTINCT of its individuals' #: rows (filed names 89% of 2022's and 72% of 2024's on 2026-10-22), #: which is what a column masked with one word would show. TSE_MIN_INDIVIDUALS = 1 / 10 TSE_MAX_MISSING = 1 / 100 TSE_MAX_NUMBERED = 1 / 1000 TSE_MIN_DISTINCT = 1 / 4 #: A name holding more digits than this is not kept, nor its pair: some #: campaigns typed a number into the name field (98 individuals' rows #: in the files dated 2026-10-18 and 2026-10-23 before a name with no #: letter was counted as missing, 54 of their names exactly 11 digits, #: a CPF's length), and a record must hold no identifier. No registry #: name held more than 4. TSE_MAX_DIGITS = 4 #: An unknown DS_ORIGEM_RECEITA value is quoted in the refusal only when #: it holds no digit and at least this many rows, and this share of all #: rows, hold it, as a category does: a header that no longer matches #: its rows puts names and CPFs in that column, and a frequent donor's #: name can repeat, but not in a thousandth of an election's rows. TSE_QUOTED_ORIGIN = 10 TSE_QUOTED_SHARE = 1 / 1000 #: The file is Latin-1 by its bytes but written from Windows: read as #: cp1252, where the five bytes cp1252 leaves undefined stay Latin-1. #: One byte in 0x80-0x9F appeared in 2022's 432 MB, in no name, and none #: in 2024's (files dated 2026-10-18 and 2026-10-22). _CP1252 = {b: bytes([b]).decode("cp1252") for b in range(0x80, 0xA0) if b not in (0x81, 0x8D, 0x8F, 0x90, 0x9D)} class _RangeFile(io.RawIOBase): """A file on the TSE's CDN read by HTTP Range requests, for zipfile: the national member of a candidates' zip without the zip around it (41 MB of 467 MB for 2022, 168 MB of 1.36 GB for 2024, on 2026-10-22). Every range request carries If-Range with the strong validator the HEAD gave, so a file replaced during the read is answered whole instead of in part, which is refused, as is a part whose Content-Range is not the range asked for: the TSE regenerates these zips every few days (all four of 2018-2024 were stamped within the week before 2026-10-22), and a member read across two versions would be neither.""" def __init__(self, url: str) -> None: super().__init__() self.url, self.position = url, 0 #: The error a request ended on: zipfile turns an OSError raised #: while it looks for the end of the archive into a BadZipFile #: that does not name it, and fetch_tse raises this instead. self.error: Exception | None = None request = urllib.request.Request(url, method="HEAD", headers={"User-Agent": local_core.USER_AGENT}) _, headers, _ = self._request(request) length = headers.get("Content-Length", "") etag = headers.get("ETag", "") # If-Range takes a strong validator only (RFC 9110 13.1.5): a # server answers a weak ETag with the whole file every time. self.validator = (etag if etag and not etag.startswith("W/") else headers.get("Last-Modified", "")) if (not length.isdigit() or not self.validator or headers.get("Accept-Ranges", "bytes") != "bytes"): raise ValueError(f"{url} states no length, no strong ETag or " f"Last-Modified, or no byte ranges: a read in ranges " f"could not tell a replaced file from this one") self.size = int(length) def readable(self) -> bool: return True def seekable(self) -> bool: return True def tell(self) -> int: return self.position def seek(self, offset: int, whence: int = io.SEEK_SET) -> int: base = {io.SEEK_SET: 0, io.SEEK_CUR: self.position, io.SEEK_END: self.size} if whence not in base or base[whence] + offset < 0: raise OSError(f"cannot seek to {offset} from {whence} in {self.url}") self.position = base[whence] + offset return self.position def readinto(self, buffer: WriteableBuffer, /) -> int: view = memoryview(buffer).cast("B") want = min(len(view), self.size - self.position) if want <= 0: return 0 data = self._range(self.position, want) view[:want] = data self.position += want return want def _range(self, start: int, want: int) -> bytes: """`want` bytes from `start`. A part that is not the range asked for raises a ValueError naming what came instead.""" end = start + want - 1 request = urllib.request.Request(self.url, headers={ "User-Agent": local_core.USER_AGENT, "If-Range": self.validator, "Range": f"bytes={start}-{end}"}) status, headers, data = self._request(request, want) if status != 206: self.error = ValueError( f"{self.url} answered a range request whole (HTTP {status}): " f"the file changed during the fetch, or the server no longer " f"serves ranges; fetch again") raise self.error stated = headers.get("Content-Range", "") if stated != f"bytes {start}-{end}/{self.size}" or len(data) > want: self.error = ValueError( f"{self.url} answered bytes {start}-{end} of {self.size:,} with " f"{stated or 'no Content-Range'} and {len(data):,} bytes; fetch again") raise self.error return data def _request(self, request: urllib.request.Request, want: int | None = None ) -> tuple[int, Any, bytes]: """The status, headers and body of `request`'s answer, retried under local_core's policy (_backed_off), Retry-After included (a member is up to about 170 requests, and starting over costs them all). The body is read only from a 206, up to one byte past `want` so that a longer part shows; a shorter one is retried as a body cut short. The last attempt's error, or any 4xx but a 429, is kept in self.error and raised.""" for attempt in range(local_core.RETRIES): try: with urllib.request.urlopen(request, timeout=60) as response: body = b"" if want is not None and response.status == 206: body = response.read(want + 1) if len(body) < want: raise http.client.IncompleteRead(body, want - len(body)) return response.status, response.headers, body except local_core._TRANSIENT as exc: if not _backed_off(exc, attempt, self.url): self.error = exc raise raise AssertionError("unreachable: the last attempt raises") def _tse_read(year: str, archive: zipfile.ZipFile, pairs: set[tuple[str, str]]) -> tuple[str, dict[str, int]]: """Add one election's individual donors to `pairs`; the member's timestamp and the counts.""" member = TSE_MEMBER.format(year=year) if member not in archive.namelist(): raise ValueError(f"{TSE_URL.format(year=year)} holds no {member}") info = archive.getinfo(member) stamp = _zip_stamp(info) counts: Counter[str] = Counter() before = len(pairs) distinct: set[tuple[str, str]] = set() with archive.open(info) as binary, \ io.TextIOWrapper(binary, encoding="latin-1", newline="") as text: rows = csv.reader(text, delimiter=";") header = next(rows, []) missing = [c for c in (TSE_ORIGIN, TSE_NAME, TSE_REGISTRY) if c not in header] if len(header) != TSE_COLUMNS or missing: raise ValueError(f"{member}'s header is {len(header)} columns, not " f"{TSE_COLUMNS}, or lacks {', '.join(missing) or '-'}: " f"the file's layout has changed") origin, name_at, registry_at = (header.index(c) for c in (TSE_ORIGIN, TSE_NAME, TSE_REGISTRY)) unknown: Counter[str] = Counter() for row in rows: counts["rows"] += 1 if len(row) != TSE_COLUMNS: counts["malformed"] += 1 continue if row[origin] not in TSE_ORIGINS: unknown[row[origin]] += 1 if row[origin] != TSE_INDIVIDUALS: continue counts["individual"] += 1 raw_name, raw_registry = row[name_at], row[registry_at] name = raw_name.translate(_CP1252).strip() registry = raw_registry.translate(_CP1252).strip() counts["cp1252"] += name != raw_name.strip() or registry != raw_registry.strip() if _missing(name): counts["nameless"] += 1 continue if _missing(registry): registry = "" if max(_digits(name), _digits(registry)) > TSE_MAX_DIGITS: counts["numbered"] += 1 continue counts["no_registry"] += not registry distinct.add((name, registry)) # The physical lines read past the header, against the rows: a # line inside a quoted field, a quote left open or a line break # typed into a name, is read without making a row. folded = rows.line_num - 1 - counts["rows"] pairs |= distinct if unknown: floor = max(TSE_QUOTED_ORIGIN, counts["rows"] * TSE_QUOTED_SHARE) common = sorted(v for v, n in unknown.items() if n >= floor and not _digits(v)) shown = (f"such as {common[0]!r}" if common else "none held by enough rows to be a category (not shown)") raise ValueError(f"{member} holds {len(unknown)} {TSE_ORIGIN} value(s) " f"outside TSE_ORIGINS, {shown}: the file's categories, " f"its layout or its encoding have changed") for count, what in ((counts["malformed"], f"rows not {TSE_COLUMNS} columns"), (folded, "lines folded into another row's field")): if count > counts["rows"] * TSE_MAX_BAD: raise ValueError(f"{member} holds {count:,} {what} in {counts['rows']:,} " f"rows: the file's layout has changed") if counts["individual"] < counts["rows"] * TSE_MIN_INDIVIDUALS or not counts["individual"]: raise ValueError(f"{counts['individual']:,} of {counts['rows']:,} rows of " f"{member} are from individuals: the file is cut short or " f"its categories have moved") carry = f"individuals' rows of {member} carry" _refuse_past([(counts["nameless"], counts["individual"], TSE_MAX_MISSING, f"{carry} no name"), (counts["no_registry"], counts["individual"], TSE_MAX_MISSING, f"{carry} no registry name"), (counts["numbered"], counts["individual"], TSE_MAX_NUMBERED, f"{carry} a number in a name")], "a name column may be masked or moved") for column, index in ((TSE_NAME, 0), (TSE_REGISTRY, 1)): seen = len({pair[index] for pair in distinct}) if seen < counts["individual"] * TSE_MIN_DISTINCT: raise ValueError(f"{member}'s {counts['individual']:,} individuals' rows " f"hold {seen:,} distinct {column}: the column may be masked") return stamp, {f"{year}_rows": counts["rows"], f"{year}_rows_from_individuals": counts["individual"], f"{year}_records": len(distinct), f"{year}_new_records": len(pairs) - before, f"{year}_rows_not_{TSE_COLUMNS}_columns": counts["malformed"], f"{year}_lines_folded_into_a_field": folded, f"{year}_individuals_without_a_name": counts["nameless"], f"{year}_individuals_without_a_registry_name": counts["no_registry"], f"{year}_individuals_whose_name_holds_a_number": counts["numbered"], f"{year}_rows_read_as_cp1252": counts["cp1252"]} def _digits(text: str) -> int: return sum(char.isdigit() for char in text) def _missing(name: str) -> bool: """No name: empty, a placeholder the TSE writes with a leading # (its #NULO, #NE), or one with no letter (***, -4).""" return name.startswith("#") or not any(char.isalpha() for char in name) def fetch_tse() -> Fetched: """The individual donors to candidates in each of TSE_ELECTIONS, one record per distinct pair of NM_DOADOR, the name as the candidate's accounts filed it, and NM_DOADOR_RFB, the federal tax registry's name for the same donor's CPF: a typed name with its reference form beside it (the pairs differ in 205,925 of 854,531 records fetched 2026-10-22). A donor in both elections under the same pair is one record; under two spellings, two. Only those two columns are read into a record. The CPF beside them is unmasked in the file, and like every other column it is read past, never stored or printed; a pair either of whose names holds more than TSE_MAX_DIGITS digits is dropped and counted, since a number typed into the name field may be one. Each election's national member is read in ranges from the CDN (_RangeFile) rather than cached: the zips are replaced every few days, so a cached one goes stale within the week, and a fetch reads 209 MB of members where the zips are 1.82 GB (2026-10-22). A row that is not TSE_COLUMNS columns, or a line folded into another row's field, is counted, past TSE_MAX_BAD refusing the file, as do a zip without the national member, a header that is not TSE_COLUMNS wide or lacks the three columns, a category outside TSE_ORIGINS, too few rows from individuals (TSE_MIN_INDIVIDUALS), too many without a name or a registry name (TSE_MAX_MISSING) or with a number in one (TSE_MAX_NUMBERED), and too few distinct pairs (TSE_MIN_DISTINCT).""" pairs: set[tuple[str, str]] = set() detail: dict[str, Any] = {"elections": " ".join(TSE_ELECTIONS)} stamps = [] for year in TSE_ELECTIONS: url = TSE_URL.format(year=year) print(f"reading {TSE_MEMBER.format(year=year)} from {url} ...", file=sys.stderr) reader = _RangeFile(url) try: with zipfile.ZipFile(io.BufferedReader(reader, TSE_BLOCK)) as archive: stamp, counts = _tse_read(year, archive, pairs) except zipfile.BadZipFile as exc: if reader.error is not None: raise reader.error from None raise ValueError(f"{url} is not a readable zip ({exc}); fetch again") from None except (csv.Error, zlib.error, EOFError) as exc: raise ValueError(f"{TSE_MEMBER.format(year=year)} in {url} cannot be " f"read ({type(exc).__name__}: {exc}); fetch again") from None stamps.append(f"{stamp} ({year})") detail.update(counts) detail["records_whose_name_differs_from_the_registry"] = sum( name != registry for name, registry in pairs) return Fetched([{"name": name, "registry": registry} for name, registry in sorted(pairs)], ", ".join(stamps), detail) check_tse = _pinned("elections", lambda: " ".join(TSE_ELECTIONS), "holds the elections {held}, and this tool reads {wanted}") �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/local_tw.py������������������������������������0000664�0000000�0000000�00000047222�15273032717�0024670�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""The tw-donors local corpus: Taiwan's political donation reports. tw-donors -- the distinct names of every individual donor in every campaign finance report Taiwan's Control Yuan publishes on its political donation platform (ardata.cy.gov.tw, the zips data.gov.tw lists): 454 reports of 64 elections on 2026-10-22, presidential, legislative and local: every report from 2018's elections on, and the residual reports of 2014's and 2016's. Each report's zip is cached whole in a reports/ folder beside the corpus (40 MB on 2026-10-22), under the name the platform's listing gives it, so a refresh downloads only the reports published or rebuilt since. Names as a candidate's staff typed them into a filing, published in full where the ID, address and phone beside them are masked: of the 308,667 on 2026-10-05, 296,820 were three Han characters, 5,449 four and 5,260 two, so it is the population for unspaced Han names written family first, and for the married double surname (冠夫姓) a four- character name often is. Under the Open Government Data License, version 1.0, which the credit line every report opens with names, with the platform's own bar on using the data for profit, credit investigation or another improper purpose; only the donor column is kept. """ from __future__ import annotations import csv import io import re import sys import urllib.parse import zipfile import zlib from collections import Counter from collections.abc import Iterator from pathlib import Path from typing import Any import corpus_build from local_core import (Fetched, _get_json, _refuse_past, _retried, _zip_stamp, directory) TW_API = "https://ardata.cy.gov.tw/api/v1" TW_PAGE = 1000 # one page held all 454 reports on 2026-10-22 #: The seconds of sleep after each report's download. TW_DELAY = 1.0 TW_INCOMES = "incomes.csv" #: The columns incomes.csv may hold, in the order the newest reports #: write them; an older report omits some (a 2020 by-election's had no #: 更正註記), so a file is read by its own header's names. TW_HEADER = ( "序號", "擬參選人/政黨", "選舉名稱", "申報序號/年度", "交易日期", "收支科目", "捐贈者/支出對象", "身分證/統一編號", "收入金額", "支出金額", "捐贈方式", "存入專戶日期", "返還/繳庫", "支出用途", "金錢類", "地址", "聯絡電話", "應揭露之支出對象", "支出對象之內部人員姓名", "支出對象之內部人員職稱", "政黨之內部人員姓名", "政黨之內部人員職稱", "關係", "更正註記", "資料更正日期") TW_CATEGORY, TW_DONOR, TW_SERIAL = "收支科目", "捐贈者/支出對象", "申報序號/年度" #: The candidate's column, the one a report with no income fills in #: the single row it writes, every other column holding TW_EMPTY. TW_CANDIDATE = "擬參選人/政黨" #: The columns every file must hold, the ones read. TW_NEEDED = frozenset({TW_CATEGORY, TW_DONOR, TW_SERIAL, TW_CANDIDATE}) TW_EMPTY = "-" TW_INDIVIDUAL = "個人捐贈收入" #: The income categories a candidate's report holds (the platform's #: classTypes with ctrlTp 0): individual, business, party and civic #: group donations, anonymous gifts, other income. TW_CATEGORIES = frozenset({ TW_INDIVIDUAL, "營利事業捐贈收入", "政黨捐贈收入", "人民團體捐贈收入", "匿名捐贈", "其他收入"}) #: A report's serial: 1 the first report, 2 to 6 the five reports on #: the money left over. TW_SERIALS = range(1, 7) _TW_NUMERALS = "一二三四五" #: A donor cell that holds no name: anonymous (19 individual donations #: wrote it on 2026-10-22) or a dash. TW_NO_NAME = frozenset({"匿名", TW_EMPTY}) #: A character that masks part of a name (3 donor names held an #: asterisk on 2026-10-22): such a name is counted, not kept. _TW_MASK = re.compile(r"[**○]") #: More than this share of the fetch's rows not as long as their file's #: header, without a category, or cut off by a stray quote, or of its #: individual donations naming a masked donor, refuses the fetch, as #: local_fec.FEC_MAX_BAD does. One 2021 residual report held a row with no #: category, an income of NT$53 with no donor ID (2026-10-22). TW_MAX_BAD = 1 / 10_000 #: More than this share of ONE report's rows bad refuses the fetch too: #: the share over the whole fetch would let a small report's rows all #: go (the worst report on 2026-10-22 had 1 bad row of 6). TW_MAX_REPORT_BAD = 1 / 2 #: What a row is counted as when it is read past without a name. _TW_BAD = ("rows_not_as_long_as_the_header", "rows_without_a_category", "rows_cut_by_a_stray_quote") def _tw_reports() -> list[dict[str, Any]]: """Every election report the platform lists, paged: the listing is what says a report exists, since the download answers a report that does not and a fault alike, with a 500 and an incident number. Two reports listed under one zip name are refused, since the cache keeps a zip by its name.""" reports: list[dict[str, Any]] = [] seen: set[tuple[str, str, int]] = set() zips: set[object] = set() page, count = 1, None while count is None or len(reports) < count: query = urllib.parse.urlencode({"page": page, "pageSize": TW_PAGE}) data = _get_json(f"{TW_API}/search/elections?{query}") count = int(data["paging"]["recordCount"]) if not data["data"]: break for row in data["data"]: serial = row["yearOrSerial"] if row["type"] != "Election" or serial not in TW_SERIALS: raise ValueError(f"a listed report is not an election's first or " f"residual report: type {row['type']!r}, serial " f"{serial!r}; the listing has changed") key = (row["electionName"], row["electionArea"], int(serial)) if key in seen: raise ValueError(f"{key} was listed twice: the listing shifted " f"under the fetch; fetch again") if row["zipFileName"] in zips: raise ValueError(f"{key} is listed under the zip name " f"{row['zipFileName']!r} another report has") seen.add(key) zips.add(row["zipFileName"]) reports.append(row) page += 1 if len(reports) != count: raise ValueError(f"listed {len(reports):,} reports of the {count:,} the " f"platform counted; fetch again") return reports def _tw_versions(reports: list[dict[str, Any]]) -> set[tuple[str, str, int, str]]: """Each report with the zip its listing names, which carries the date the platform built it: a replaced report lists another.""" return {(r["electionName"], r["electionArea"], int(r["yearOrSerial"]), r["zipFileName"]) for r in reports} def _tw_url(report: dict[str, Any]) -> str: """A report's zip, asked for by the fields its listing row gives.""" return f"{TW_API}/Search/download?" + urllib.parse.urlencode({ "ElectionName": report["electionName"], "ElectionArea": report["electionArea"], "AccountNumber": "", "YearOrSerial": int(report["yearOrSerial"]), "Version": "", "SearchType": 2, "DownloadType": 3}) def _tw_serial_names(serial: int) -> frozenset[str]: """How a report's rows write its serial: 首次申報 for the first, 第N次賸餘 for a residual one, N in either numerals (both are written).""" if serial == 1: return frozenset({"首次申報"}) return frozenset({f"第{serial - 1}次賸餘", f"第{_TW_NUMERALS[serial - 2]}次賸餘"}) def _tw_incomes(body: bytes) -> tuple[bytes, str] | None: """A report zip's incomes.csv and its timestamp, None where the zip holds none, or ValueError where the body is no whole zip or its member does not inflate: a download cut short or damaged, which _retried retries.""" try: with zipfile.ZipFile(io.BytesIO(body)) as archive: if TW_INCOMES not in archive.namelist(): return None member = archive.getinfo(TW_INCOMES) stamp = _zip_stamp(member) return archive.read(member), stamp except (zipfile.BadZipFile, zlib.error, EOFError) as exc: start = body[:2] raise ValueError(f"not a whole zip ({type(exc).__name__}: {exc}; it starts " f"{start!r})") from None #: The cached reports' folder, beside the corpus. TW_REPORTS = "reports" #: A listed zip's name, the only kind cached under it: the name is the #: platform's and becomes a path. _TW_ZIP_NAME = re.compile(r"[\w-]+\.zip", re.ASCII) def _tw_cached(report: dict[str, Any]) -> Path: return directory("tw-donors") / TW_REPORTS / report["zipFileName"] def _tw_report_zip(report: dict[str, Any], label: str) -> tuple[bytes, str, bytes | None]: """A listed report's incomes.csv, its timestamp, and the zip when it was downloaded rather than read from the reports folder, where an earlier fetch cached it under the name the listing gives. The name carries the date the platform built the zip, so a rebuilt report is downloaded anew, and a cached zip that is no whole zip is too. A zip holding no incomes.csv is refused without being retried: a layout the tool was not written for, or one bad answer. The caller caches a downloaded zip only once its rows are read (_tw_cache_zip), since a cached zip that is refused would refuse every later fetch.""" name = report["zipFileName"] if not isinstance(name, str) or not _TW_ZIP_NAME.fullmatch(name): raise ValueError(f"{label} is listed with the zip name {name!r}, which is " f"no file name this tool caches; the listing has changed") path = _tw_cached(report) if path.exists(): try: if got := _tw_incomes(path.read_bytes()): return (*got, None) except ValueError: print(f" downloading again a cached zip that is not whole: {path}", file=sys.stderr) # Read inside _retried, so that a zip cut short is retried, and once. body, got = _retried(_tw_url(report), lambda body: (body, _tw_incomes(body)), TW_DELAY) if got is None: raise ValueError(f"{label}'s zip holds no {TW_INCOMES}: the reports' " f"layout has changed") return (*got, body) def _tw_cache_zip(report: dict[str, Any], body: bytes) -> None: path = _tw_cached(report) path.parent.mkdir(exist_ok=True) corpus_build.write_atomic(path, body) def _tw_lines(text: str) -> list[str]: """`text` cut into its lines at CRLF, CR or LF, each keeping its ending, as csv reads a file opened with newline="" and no further: str.splitlines() would cut at U+2028 and the other separators too, which a name may hold (corpus_build.jsonl_lines).""" return re.findall(r"[^\r\n]*(?:\r\n|\r|\n)|[^\r\n]+$", text) def _tw_rows(text: str, width: int, counts: Counter[str]) -> Iterator[list[str]]: """incomes.csv's records after its header. A record whose field holds line breaks is a quoted field spanning lines, or a stray quote opening a field and taking the rows after it in until another quote closes it: on 2026-10-22, 12 records in three reports took in 169 rows that way, and every line of all 12 read as a whole row with quotes taken literally. So where every line of a record does, those are its rows; this also parts two rows that two stray quotes, one after the other, joined into one record exactly as wide as the header (8 of the 12). Else, where one of the lines after its first reads as a whole row alone as quoted, the record is cut back into its lines, each read alone: the lines as wide as the header are rows, and the rest are counted as cut by the quote. Otherwise it stays one record, a quoted field that truly spans lines.""" lines = _tw_lines(text) reader = csv.reader(lines) next(reader, None) start = reader.line_num for record in reader: end = reader.line_num if end - start > 1: literal = [next(csv.reader([line], quoting=csv.QUOTE_NONE), []) for line in lines[start:end]] if all(len(row) == width for row in literal): counts["records_cut_back_into_their_lines"] += 1 yield from literal start = end continue alone = [next(csv.reader([line]), []) for line in lines[start:end]] if any(len(row) == width for row in alone[1:]): counts["records_cut_back_into_their_lines"] += 1 for row in alone: if len(row) == width: yield row else: counts["rows_cut_by_a_stray_quote"] += 1 start = end continue start = end yield record def _tw_read(incomes: bytes, report: dict[str, Any], label: str, names: set[str], counts: Counter[str]) -> None: """Add a report's donor names to `names`, counting into `counts` what is read past. A header or a category this tool was not written for is refused, not read: a moved column would put another field's values in the corpus, so a header holding a column TW_HEADER does not name, a column twice, or not every column TW_NEEDED names is refused; one missing a column the tool does not read is read. A row whose serial is not the report's is refused: the zip is another report's. The one row a report with no income writes, every column a dash but the candidate's, is counted apart.""" try: text = incomes.decode("utf-8-sig") except UnicodeDecodeError as exc: raise ValueError(f"{label}'s {TW_INCOMES} is not UTF-8 ({exc})") from None header = next(csv.reader(_tw_lines(text)), []) if (not set(header) <= set(TW_HEADER) or len(set(header)) != len(header) or not TW_NEEDED <= set(header)): raise ValueError(f"{label}'s {TW_INCOMES} has the header {tuple(header)}, " f"which is not columns of TW_HEADER, each once, holding " f"those TW_NEEDED names") category_at, donor_at, serial_at, candidate_at = ( header.index(column) for column in (TW_CATEGORY, TW_DONOR, TW_SERIAL, TW_CANDIDATE)) serials = _tw_serial_names(int(report["yearOrSerial"])) mine = Counter[str]() try: rows = list(_tw_rows(text, len(header), mine)) except csv.Error as exc: raise ValueError(f"{label}'s {TW_INCOMES} is not a CSV file csv can " f"read ({exc})") from None for row in rows: mine["rows"] += 1 if len(row) != len(header): mine["rows_not_as_long_as_the_header"] += 1 continue if {f for i, f in enumerate(row) if i != candidate_at} == {TW_EMPTY}: mine["rows_of_a_report_with_no_income"] += 1 continue category = row[category_at] if not category: mine["rows_without_a_category"] += 1 continue if category not in TW_CATEGORIES: raise ValueError(f"{label}'s {TW_INCOMES} holds the category " f"{category!r}, which TW_CATEGORIES does not list") if row[serial_at] not in serials: raise ValueError(f"{label}'s {TW_INCOMES} holds a row of the report " f"{row[serial_at]!r}: the zip is another report's") if category != TW_INDIVIDUAL: mine["rows_from_other_categories"] += 1 continue mine["individual_donations"] += 1 name = row[donor_at].strip() if not name or name in TW_NO_NAME: mine["donations_without_a_name"] += 1 elif _TW_MASK.search(name): mine["donations_naming_a_masked_donor"] += 1 else: names.add(name) bad = sum(mine[key] for key in _TW_BAD) if bad > (mine["rows"] + mine["rows_cut_by_a_stray_quote"]) * TW_MAX_REPORT_BAD: raise ValueError(f"{bad:,} of {label}'s rows are read past as malformed: " f"the file's layout has changed") counts.update(mine) def fetch_tw() -> Fetched: """The distinct names of every individual donor in every election report the platform lists, one record per name. Every report is read, the first and the residual ones alike, and a name is kept once however many reports, candidates or elections it gave to, as the FEC source keeps one per name. Only the donor column of the rows the category marks as individual donations is kept, its spaces stripped; the masked ID, address and phone columns and the amounts are read past, and a donor cell that holds no name or a masked one is counted. Each report's zip is cached (_tw_report_zip), so a fetch cut short or refused goes on where it stopped, and a refresh downloads only the reports published or rebuilt since: a zip rebuilt under the name it had is not seen, and deleting the reports folder downloads every one again. A cached zip the listing no longer names is deleted at the end of a fetch that passes its checks, and a downloaded zip is cached only once its rows are read, so a zip refused is downloaded again rather than refused from the cache for good. The listing is read again at the end, and a report published, replaced or withdrawn during the fetch refuses it. The data date is the newest incomes.csv's own timestamp, which carries no time zone.""" reports = _tw_reports() names: set[str] = set() counts: Counter[str] = Counter() stamps: list[str] = [] for number, report in enumerate(reports, 1): if number % 50 == 0: print(f" {number:,} of {len(reports):,} reports", file=sys.stderr) label = f"{report['electionName']} {report['electionArea']} " \ f"report {int(report['yearOrSerial'])}" incomes, stamp, downloaded = _tw_report_zip(report, label) stamps.append(stamp) _tw_read(incomes, report, label, names, counts) if downloaded is not None: _tw_cache_zip(report, downloaded) rows, donations = counts["rows"], counts["individual_donations"] _refuse_past([(counts["rows_not_as_long_as_the_header"], rows, TW_MAX_BAD, "rows are not as long as their file's header"), (counts["rows_without_a_category"], rows, TW_MAX_BAD, "rows are without a category"), (counts["rows_cut_by_a_stray_quote"], rows, TW_MAX_BAD, "rows are cut by a stray quote"), (counts["donations_naming_a_masked_donor"], donations, TW_MAX_BAD, "individual donations name a masked donor")], "the files' layout has changed") if _tw_versions(_tw_reports()) != _tw_versions(reports): raise ValueError("the platform published, replaced or withdrew a report " "during the fetch; fetch again") listed = {r["zipFileName"] for r in reports} for stale in (directory("tw-donors") / TW_REPORTS).glob("*.zip"): if stale.name not in listed: stale.unlink() detail: dict[str, Any] = { "reports": len(reports), "first_reports": sum(int(r["yearOrSerial"]) == 1 for r in reports), "elections": len({r["electionName"] for r in reports})} for key in ("rows", "individual_donations", "rows_from_other_categories", "donations_without_a_name", "donations_naming_a_masked_donor", "rows_of_a_report_with_no_income", "records_cut_back_into_their_lines", *_TW_BAD): detail[key] = counts[key] return Fetched([{"name": name} for name in sorted(names)], max(stamps, default=""), detail) ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/local_ukec.py����������������������������������0000664�0000000�0000000�00000021645�15273032717�0025166�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""The ukec-individuals local corpus: the UK Electoral Commission's donors. ukec-individuals -- the distinct names of the individual donors in the UK Electoral Commission's register of donations, to parties, their members, third parties, permitted participants and regulated donees, from the search's CSV export under the site's own default filter (9.5 MB on 2026-10-22, fetched whole on every fetch). That default leaves out donations reported only before a poll: 374 rows and 21 names on 2026-10-22. The export's accepted dates ran from 2001 to 2026. A name as the donee's return gave it, so mostly in its spoken order, mixed case, with the title the donor used: of its 15,214 names on 2026-10-22, parse() read a title in 8,219 and a suffix in 320 (MP, OBE, CBE, QC), 9 were in capitals and 2 held a comma. So it is the population for titled British names, and blind to the family-comma shape. The counts are dated by the day they were fetched; the data date the fetch stamps, the search's own time in UTC, was 2026-10-23T03:13:19Z. The Commission publishes its documents under the Open Government Licence v3.0, but the register states no reuse terms of its own, which the credit line says; only the DonorName column is kept. """ from __future__ import annotations import csv import dataclasses import io import re import sys import time import urllib.parse from collections import Counter import corpus_build from local_core import Fetched, _refuse_past, _retried #: The register's search, filtered to donations from individuals, with #: the search page's other defaults (prePoll=false among them, which #: leaves out the donations reported only before a poll). The CSV export ignores `rows` #: (rows=10 returned all 42,355 on 2026-10-22), and the JSON search #: answering the same filter states the register's total, which is how a #: capped or cut export is told from the whole register. UKEC_API = "https://search.electoralcommission.org.uk/api" UKEC_QUERY: list[tuple[str, str]] = [ ("start", "0"), ("rows", "1000000"), ("query", ""), ("sort", "AcceptedDate"), ("order", "desc"), *(("et", et) for et in ("pp", "ppm", "tp", "perpar", "rd")), ("date", "Reported"), ("from", ""), ("to", ""), ("rptPd", ""), ("prePoll", "false"), ("postPoll", "true"), ("donorStatus", "individual"), ("isIrishSourceYes", "true"), ("isIrishSourceNo", "true")] UKEC_CSV_URL = f"{UKEC_API}/csv/Donations?{urllib.parse.urlencode(UKEC_QUERY)}" UKEC_COUNT_URL = f"{UKEC_API}/search/Donations?" + urllib.parse.urlencode( [(k, "1" if k == "rows" else v) for k, v in UKEC_QUERY]) #: The export's header on 2026-10-22, which a fetch requires exactly: the #: names are read by column name, but a renamed or added column is a #: changed export whose meaning nobody has checked. UKEC_COLUMNS = ( "ECRef", "RegulatedEntityName", "RegulatedEntityType", "Value", "AcceptedDate", "AccountingUnitName", "DonorName", "AccountingUnitsAsCentralParty", "IsSponsorship", "DonorStatus", "RegulatedDoneeType", "CompanyRegistrationNumber", "Postcode", "DonationType", "NatureOfDonation", "PurposeOfVisit", "DonationAction", "ReceivedDate", "ReportedDate", "IsReportedPrePoll", "ReportingPeriodName", "IsBequest", "IsAggregation", "RegulatedEntityId", "AccountingUnitId", "DonorId", "CampaigningName", "RegisterName", "IsIrishSource") UKEC_INDIVIDUAL = "Individual" #: The share of rows a fetch skips before refusing the export: rows that #: are not 29 fields, and rows from a donor that is not an individual, #: which the filter asks the server to leave out (none of 42,355 rows #: was either on 2026-10-22). UKEC_MAX_BAD = 1 / 10_000 #: The share of individuals with no name a fetch accepts: 4 of 42,355 #: rows on 2026-10-22, so a tenth of a percent leaves room for the #: register's own blanks and refuses a column emptied wholesale. UKEC_MAX_NAMELESS = 1 / 1000 def _ukec_total() -> tuple[int, str]: """The register's total for the filter, and the time the search answered, from the JSON search.""" data = _retried(UKEC_COUNT_URL, lambda body: corpus_build.parse_json(body, UKEC_COUNT_URL), 0) if not isinstance(data, dict): raise ValueError(f"{UKEC_COUNT_URL} states no Total") total = data.get("Total") if not isinstance(total, int) or isinstance(total, bool): raise ValueError(f"{UKEC_COUNT_URL} states no Total") stamp = re.fullmatch(r"/Date\((\d+)\)/", str(data.get("Generated", ""))) if not stamp: raise ValueError(f"{UKEC_COUNT_URL} states no Generated time") when = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(int(stamp[1]) / 1000)) return total, when def _ukec_read(body: bytes, total: int) -> Fetched: try: text = body.decode("utf-8-sig") except UnicodeDecodeError as exc: raise ValueError(f"the export is not UTF-8 ({exc}); fetch again") from None try: return _ukec_rows(list(csv.reader(io.StringIO(text, newline=""))), text, total) except csv.Error as exc: raise ValueError(f"the export is not CSV this reads ({exc}); " f"fetch again") from None def _ukec_rows(rows: list[list[str]], text: str, total: int) -> Fetched: header = tuple(rows[0]) if rows else () if header != UKEC_COLUMNS: raise ValueError(f"the export's header is not the {len(UKEC_COLUMNS)} " f"columns UKEC_COLUMNS names: {', '.join(header)}") # csv returns a body cut inside its last row as a short last row, # which the count below would take for a whole one. A cut only # shortens a row, so a long last row is left to the malformed share; # the export ended with a line break on 2026-10-22. if not text.endswith("\n") or len(rows[-1]) < len(UKEC_COLUMNS): raise ValueError("the export's last row is cut short or blank; " "fetch again") status, name_at, donor_at = (UKEC_COLUMNS.index(c) for c in ( "DonorStatus", "DonorName", "DonorId")) names: set[str] = set() donor_ids: set[str] = set() statuses: Counter[str] = Counter() count = malformed = nameless = 0 for row in rows[1:]: count += 1 if len(row) != len(UKEC_COLUMNS): malformed += 1 continue statuses[row[status]] += 1 if row[status] != UKEC_INDIVIDUAL: continue if row[donor_at]: donor_ids.add(row[donor_at]) if name := row[name_at].strip(): names.add(name) else: nameless += 1 other = count - malformed - statuses[UKEC_INDIVIDUAL] if count != total: raise ValueError(f"the export held {count:,} donations where the search " f"counts {total:,}: a capped or cut export; fetch again") are = "rows of the export are" _refuse_past([(malformed, count, UKEC_MAX_BAD, f"{are} not {len(UKEC_COLUMNS)} fields"), (other, count, UKEC_MAX_BAD, f"{are} from a donor that is not {UKEC_INDIVIDUAL!r}"), (nameless, count, UKEC_MAX_NAMELESS, f"{are} from an individual with no name")], "the export is not the one this reads") return Fetched([{"name": name} for name in sorted(names)], "", { "donations": count, "donor_ids": len(donor_ids), "individuals_without_a_name": nameless, "rows_not_29_fields": malformed, "rows_not_individual": other}) def fetch_ukec() -> Fetched: """The distinct names of every individual donor in the register, one record per name, from the export's DonorName column; the other columns are read past. A record is a NAME, not a donation or a DonorId: one name often holds several IDs, which seem to come with reports rather than with people (15,214 names held 24,473 IDs on 2026-10-22, and 2,663 names held several IDs at one donee alone, though some of those may be two people of one name), so a count of IDs would weight a name by how often it was reported. The register's total is read before and after the export, which must hold exactly that many rows and end with a whole row, so a register that changed during the fetch, or an export cut or capped, is refused. The data date is when the first search answered: the register states no date of its own, so it is the fetch's time, in UTC. A row that is not 29 fields, or from a donor that is not an individual, is counted and skipped, as is an individual with no name; past UKEC_MAX_BAD of either of the first two, or UKEC_MAX_NAMELESS of the third, the export is refused.""" before, when = _ukec_total() print(f"downloading {before:,} donations from {UKEC_API} ...", file=sys.stderr) body = _retried(UKEC_CSV_URL, lambda body: body, 0, timeout=300) after, _ = _ukec_total() if after != before: raise ValueError(f"the register changed during the fetch ({before:,} -> " f"{after:,} donations); fetch again") return dataclasses.replace(_ukec_read(body, before), data_date=when) �������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/measure.py�������������������������������������0000664�0000000�0000000�00000024562�15273032717�0024527�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Every corpus check of a change, in sequence, with one summary. What a parser change is measured with before its PR (AGENTS.md's "Validating a new parsing rule"), run as one command rather than a hand-written script per batch: uv run python tools/differential/measure.py --base ../master-checkout The steps, in order, run one at a time so that no two contend for the CPUs, each but cldr_agreement.py parallel inside itself (--jobs worker processes): 1. the committed snapshot: snapshot.py, the check tests/v2/test_snapshot.py makes, printing every committed name that moved, grouped by field; 2. cldr_agreement.py, the CLDR and GND agreement records; 3. snapshot.py --local SOURCE --base, for every fetched local corpus; 4. local_agreement.py SOURCE --base, for every fetched corpus whose records carry their fields (local_agreement.FIELDED); 5. local_corpora.py alike SOURCE --base, for every fetched corpus whose records pair two spellings of one person (ALIKE_SOURCES). Only steps 3 to 5 read --base. Steps 1 and 2 compare the tree with the records committed on its own branch, so once a branch has re-recorded snapshot.jsonl (as it should once its moves are intended), step 1 says "no moves" and the moves are in the PR's diff of that file instead; the summary labels each step with what it compared against. A local corpus that is not fetched gets no step, and the summary names it, and a run in which no step read --base fails, since it measured nothing against the base it was given. A step that finds no move exits 0, one that finds moves 1, and anything else is a failure the summary names. A tool refuses with SystemExit("message"), which exits 1 like a move, so each step runs through this script's --step wrapper, which turns a message exit or an uncaught exception into exit 2: a broken step must not read as a result. Each step's output is written whole to its own file under --out (by default a folder named for the time under the tools' cache, outside the repository: a local corpus's moves are listed by name, and the local corpora are never quoted). The summary, written beside them as summary.txt and printed, keeps each step's unindented lines -- the counts, the verdicts, the base and head lines, the attribution -- up to SUMMARY_LINES of them, and none of the indented ones, which are where the tools list names. An uncaught exception's message may hold a name, so the wrapper prints its traceback indented and only its type flush left. """ from __future__ import annotations import argparse import os import subprocess import sys import time from dataclasses import dataclass from pathlib import Path HERE = Path(__file__).resolve().parent if str(HERE) not in sys.path: sys.path.append(str(HERE)) import corpus_build # noqa: E402 import local_agreement # noqa: E402 import local_corpora # noqa: E402 #: How many of a step's unindented lines the summary keeps: the first #: HEAD_LINES (the attribution, the base and head lines) and the rest #: from the end, where the tools print their verdicts. SUMMARY_LINES = 12 HEAD_LINES = 4 #: Kept whatever the cap: a line saying part of a check was not read, #: which "no moves" beside it would otherwise hide. ALWAYS_KEPT = ("not measured", "not compared") #: What a step's result is compared against, as the summary says it. AGAINST_RECORD = "against this branch's committed record" AGAINST_BASE = "against --base" @dataclass(frozen=True) class Step: label: str command: list[str] against: str = AGAINST_BASE @dataclass(frozen=True) class Done: step: Step code: int seconds: float output: str @property def status(self) -> str: return {0: "no moves", 1: "moves"}.get(self.code, f"FAILED (exit {self.code})") def fetched(source: str) -> bool: """Whether `source` has been fetched into the cache: its step then runs, and refuses a damaged copy itself, naming the fetch.""" return (local_corpora.directory(source, create=False) / local_corpora.META).exists() def plan(base: Path, jobs: int) -> list[Step]: """The steps, in order, for the corpora fetched now, each run through --step (run_step()).""" py = [sys.executable, str(HERE / "measure.py"), "--step"] tool = {name: str(HERE / f"{name}.py") for name in ("snapshot", "cldr_agreement", "local_agreement", "local_corpora")} j = ["--jobs", str(jobs)] steps = [Step("committed snapshot", [*py, tool["snapshot"], *j], AGAINST_RECORD), Step("CLDR and GND agreement", [*py, tool["cldr_agreement"]], AGAINST_RECORD)] sources = [s for s in sorted(local_corpora.SOURCES) if fetched(s)] steps += [Step(f"local snapshot {s}", [*py, tool["snapshot"], "--local", s, "--base", str(base), *j]) for s in sources] steps += [Step(f"agreement {s}", [*py, tool["local_agreement"], s, "--base", str(base), *j]) for s in sources if s in local_agreement.FIELDED] steps += [Step(f"alike {s}", [*py, tool["local_corpora"], "alike", s, "--base", str(base), *j]) for s in sources if s in local_corpora.ALIKE_SOURCES] return steps def unmeasured() -> list[str]: """The local corpora plan() gives no step: those not fetched.""" return [s for s in sorted(local_corpora.SOURCES) if not fetched(s)] def run_step(tool: str, argv: list[str]) -> int: """Run `tool` as its own __main__ with `argv`, its exit code passed through where it is a number, and 2 where it exits with a message (a refusal) or raises: the code measure reads as a failure. A traceback is printed indented, below a flush-left line naming only the exception's type, since its message may hold a name.""" import runpy import traceback sys.argv = [tool, *argv] try: runpy.run_path(tool, run_name="__main__") except SystemExit as exc: if exc.code is None or isinstance(exc.code, int): return exc.code or 0 print(exc.code, file=sys.stderr) return 2 except BaseException as exc: print(f"uncaught {type(exc).__name__}; its traceback, indented:", file=sys.stderr) for line in traceback.format_exc().splitlines(): print(f" {line}", file=sys.stderr) return 2 return 0 def run(step: Step) -> Done: """`step` run from the checkout, its output read as bytes and decoded without newline translation, so that a carriage return in a name stays inside its line (digest).""" start = time.monotonic() done = subprocess.run(step.command, capture_output=True, cwd=HERE.parents[1]) output = (done.stdout + done.stderr).decode("utf-8", errors="replace") return Done(step, done.returncode, time.monotonic() - start, output) def digest(output: str) -> list[str]: """A step's unindented lines: the tools print counts and verdicts flush left and list names indented (snapshot.py's grouped moves, local_agreement.py's --list). Past SUMMARY_LINES, the first HEAD_LINES and the last of them, and every ALWAYS_KEPT line between. Cut at "\\n" only, never by str.splitlines(), which also cuts at a carriage return, U+2028 and the other line breaks a name can hold, and would bring the rest of that name to the left edge.""" kept = [line for line in output.split("\n") if line.strip() and not line[0].isspace()] if len(kept) <= SUMMARY_LINES: return kept head, middle, tail = (kept[:HEAD_LINES], kept[HEAD_LINES:HEAD_LINES - SUMMARY_LINES], kept[HEAD_LINES - SUMMARY_LINES:]) shown = [line for line in middle if any(mark in line for mark in ALWAYS_KEPT)] return [*head, *shown, f"... {len(middle) - len(shown)} more lines", *tail] def summary(results: list[Done], base: Path, skipped: list[str]) -> list[str]: lines = [f"measured {HERE.parents[1]}, {time.strftime('%Y-%m-%d %H:%M')}; " f"base: {base}", ""] for done in results: lines.append(f"== {done.step.label} ({done.step.against}): {done.status}, " f"{done.seconds:.0f}s") lines += [f" {line}" for line in digest(done.output)] failed = [d.step.label for d in results if d.code not in (0, 1)] lines += ["", f"{len(results)} steps, {sum(d.seconds for d in results):.0f}s; " + (f"failed: {', '.join(failed)}" if failed else "none failed")] if skipped: lines.append(f"not measured, not fetched: {', '.join(skipped)}") if not any(d.step.against == AGAINST_BASE for d in results): lines.append("no step read --base: no local corpus is fetched " "(local_corpora.py fetch SOURCE)") return lines def default_out() -> Path: return corpus_build.cache_dir("measure") / time.strftime("%Y%m%d-%H%M%S") def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("--base", metavar="PATH", help="the checkout the local-corpus steps measure against") ap.add_argument("--step", nargs=argparse.REMAINDER, help=argparse.SUPPRESS) ap.add_argument("--jobs", type=int, metavar="N", default=os.cpu_count() or 1, help="worker processes inside each step (default: every CPU)") ap.add_argument("--out", type=Path, metavar="DIR", help="where each step's output and the summary go (default: " "a folder named for the time under the tools' cache)") args = ap.parse_args(argv) if args.step: return run_step(args.step[0], args.step[1:]) if not args.base: ap.error("--base is required") from snapshot import REPO_ROOT, base_root base = base_root(args.base, REPO_ROOT) out = args.out or default_out() out.mkdir(parents=True, exist_ok=True) results = [] for n, step in enumerate(plan(base, args.jobs), 1): print(f"[{n}] {step.label} ...", flush=True) done = run(step) (out / f"{n:02d}-{step.label.replace(' ', '-')}.txt").write_text( done.output, encoding="utf-8") print(f" {done.status}, {done.seconds:.0f}s", flush=True) results.append(done) text = "\n".join(summary(results, base, unmeasured())) + "\n" (out / "summary.txt").write_text(text, encoding="utf-8") print(f"\n{text}\neach step's whole output is in {out}") failed = any(d.code not in (0, 1) for d in results) return 1 if failed or not any(d.step.against == AGAINST_BASE for d in results) else 0 if __name__ == "__main__": sys.exit(main()) ����������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/shapes.py��������������������������������������0000664�0000000�0000000�00000007002�15273032717�0024337�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""The input-shape inventory (#469): each shape's notation, the name_order it is an input shape FOR, and the oldest baseline whose worker can honor that order. Shapes 1-3 are docs/usage.rst's three given-first arrangements. Shapes 4-5 are the family-first arrangements: their names are only supported UNDER their order, so the snapshot reads them under it, and compare.py does not compare them at all below min_baseline -- "we don't care what they do in other orders" is structural (name_order and both constants shipped in 2.0.0). The notations are canonical SKELETONS, not exhaustive grammars: which bucket a written word lands in (a lone post-comma credential reading as Suffix, a trailing particle joining the Family) is the parser's vocabulary question, not this table's. Tagging a case row with a shape asserts that the row instantiates the shape's ARRANGEMENT under its declared order -- not that every word-level reading the notation could admit is pinned here. Shapes 6-7 are the CJK arrangements (#469's third-shape question, settled 2026-09-01): shape 6 is family-first CJK, shape 7 is a source-order transcription listing. Both carry `order=None` -- not because they are order-less, but because the family-first (resp. source-order) reading is SCRIPT-carried rather than declared: a pure shape 6/7 string parses correctly under the DEFAULT policy already (rules/W4, T2/T3), the same way DEFAULT_SCRIPT_ORDERS routes Han/ Hangul/Hiragana to family-first without a Policy saying so. A shape whose reading depends on the string's own script has nothing for `order` to name. Their `min_baseline` (2.1.0, when East Asian support shipped) is DOCUMENTARY: compare.py skips only an ORDER-bearing entry below its shape's minimum, since an order sent to a pre-Policy worker has nothing to apply. An order-None string is compared at every baseline; the old baselines parse it, just without the script rules. `order` is the PUBLIC constant name on the nameparser package, as a string, because the consumer that matters is the generated baseline worker: it imports a released wheel and resolves the name with getattr, so a misspelling fails loudly there and this module needs no nameparser import at all. """ from typing import NamedTuple class Shape(NamedTuple): order: str | None # public constant name on nameparser, None = default notation: str # For an order-bearing shape: the oldest baseline that can honor # the order (compare.py skips an earlier one). For an order-None # shape: documentary only, recording when the reading shipped -- # see the module docstring's shapes-6/7 paragraph for why that is # not a skip trigger. min_baseline: str SHAPES: dict[int, Shape] = { 1: Shape(None, 'Title Given "Nickname" Middle Middle Family Suffix', "1.4.0"), 2: Shape(None, "Family [Suffix], Title Given (Nickname) Middle Middle[,] " "Suffix [, Suffix or Title]", "1.4.0"), 3: Shape(None, "Title Given Middle Family [Suffix], Suffix or Title " "[, Suffix or Title]", "1.4.0"), 4: Shape("FAMILY_FIRST", "Title Family Given Middle Middle [Particle] [, Suffix]", "2.0.0"), 5: Shape("FAMILY_FIRST_GIVEN_LAST", "Title Family Middle Middle Given [, Suffix]", "2.0.0"), 6: Shape(None, "Family Given [Honorific]", "2.1.0"), 7: Shape(None, "Given[·Given]·Family / katakana transcription (source order)", "2.1.0"), } ������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/snapshot.jsonl���������������������������������0000664�0000000�0000000�00003566263�15273032717�0025436�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������{"recorded": ["capitalized", "capitalized_force", "alternatives"]} {"name": ""} {"name": "\"Rick\" Edmonds", "family": "Edmonds", "nickname": "Rick", "initials": "E."} {"name": "'Smitty' Dr. Jones", "title": "Dr.", "family": "Jones", "nickname": "Smitty", "initials": "J."} {"name": "'Smitty' John Jones", "given": "John", "family": "Jones", "nickname": "Smitty", "initials": "J. J."} {"name": "'Smitty' Jones", "family": "Jones", "nickname": "Smitty", "initials": "J."} {"name": "'Smitty' Jones Jr.", "given": "Jones", "suffix": "Jr.", "nickname": "Smitty", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "J."} {"name": "'Smitty' Sir John", "title": "Sir", "given": "John", "nickname": "Smitty", "initials": "J."} {"name": "(", "ambiguities": ["UNBALANCED_DELIMITER"]} {"name": "()"} {"name": "(M.A)", "nickname": "M.A", "ambiguities": ["SUFFIX_OR_NICKNAME"], "alternatives": ["SUFFIX_OR_NICKNAME: credential as suffix (vocabulary)"]} {"name": "(MA) John Smith", "given": "John", "family": "Smith", "nickname": "MA", "ambiguities": ["SUFFIX_OR_NICKNAME"], "alternatives": ["SUFFIX_OR_NICKNAME: credential as suffix (vocabulary)"], "initials": "J. S.", "capitalized_force": "John \"Ma\" Smith"} {"name": "(김민준.) John Smith", "given": "김", "middle": "민준. John", "family": "Smith", "initials": "김. 민. J. S."} {"name": ","} {"name": ", Ana García López", "given": "Ana", "middle": "García López", "initials": "A. G. L."} {"name": ", Dean", "given": "Dean", "initials": "D."} {"name": ", Dr. John", "title": "Dr.", "family": "John", "initials": "J."} {"name": ", Dr. Jones vd", "title": "Dr.", "given": "Jones", "family": "vd", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "J. v.", "capitalized_force": "Dr. Jones Vd"} {"name": ", Dr. abdul Rahman", "title": "Dr.", "family": "abdul Rahman", "initials": "a. R.", "capitalized_force": "Dr. Abdul Rahman"} {"name": ", Dr. fils", "title": "Dr.", "family": "fils", "initials": "f.", "capitalized_force": "Dr. Fils"} {"name": ", Jane", "given": "Jane", "initials": "J."} {"name": ", Jane Mrs", "title": "Mrs", "family": "Jane", "initials": "J."} {"name": ", Jane van", "given": "Jane", "family": "van", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "J. v.", "capitalized_force": "Jane Van"} {"name": ", John", "given": "John", "initials": "J."} {"name": ", John (Bob) Smith", "given": "John", "middle": "Smith", "nickname": "Bob", "initials": "J. S."} {"name": ", John Smith", "given": "John", "middle": "Smith", "initials": "J. S."} {"name": ", John Smith Jr.", "given": "John", "middle": "Smith", "suffix": "Jr.", "initials": "J. S."} {"name": ", Jr.", "suffix": "Jr."} {"name": ", MA", "given": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "M.", "capitalized": "Ma"} {"name": ", MA Smith", "given": "MA", "middle": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "M. S.", "capitalized_force": "Ma Smith"} {"name": ", MD/PhD", "suffix": "MD/PhD"} {"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": ".,"} {"name": "1 & 2, 3 4 5, Mr.", "title": "Mr.", "given": "3", "middle": "4 5", "family": "1 & 2", "initials": "3. 4. 5. 1. 2."} {"name": "1. née Smith", "given": "1.", "maiden": "Smith", "initials": "1."} {"name": "A. D.", "given": "A.", "family": "D.", "initials": "A. D."} {"name": "A. R., Sr. Maria García Rosa de la Cruz Jr.", "title": "Sr.", "given": "Maria", "middle": "García Rosa de la Cruz", "family": "A. R.", "suffix": "Jr.", "initials": "M. G. R. C. A. R."} {"name": "A. R., Sr. de la Cruz Maria García Rosa Jr.", "order": "FAMILY_FIRST", "title": "Sr.", "family": "A. R. de la Cruz Maria García Rosa", "suffix": "Jr.", "initials": "A. R. C. M. G. R."} {"name": "A.B. Vajpayee", "given": "A.B.", "family": "Vajpayee", "initials": "A. V."} {"name": "AHMAD IBN-ʿALĪ", "given": "AHMAD", "family": "IBN-ʿALĪ", "initials": "A. I.", "capitalized": "Ahmad ibn-ʿAlī"} {"name": "AKIME Fuseena", "order": "FAMILY_FIRST", "given": "Fuseena", "family": "AKIME", "initials": "F. A.", "capitalized_force": "Fuseena Akime"} {"name": "ANH DO", "given": "ANH", "family": "DO", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "A. D.", "capitalized": "Anh Do"} {"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 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 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": "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."} {"name": "Abu Bakar Salim", "given": "Abu Bakar", "family": "Salim", "initials": "A. B. S."} {"name": "Abu Bakr Al Baghdadi, MD", "given": "Abu Bakr", "family": "Al Baghdadi", "suffix": "MD", "initials": "A. B. B.", "capitalized_force": "Abu Bakr al Baghdadi MD"} {"name": "Abu Karim", "given": "Abu", "family": "Karim", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "A. K."} {"name": "Abu-'l-Fidāʾ Ismāʿīl Ibn-ʿAlī", "given": "Abu-'l-Fidāʾ", "middle": "Ismāʿīl", "family": "Ibn-ʿAlī", "initials": "A. I. I.", "capitalized_force": "Abu-'L-Fidāʾ Ismāʿīl ibn-ʿAlī"} {"name": "Abu-'l-Qāsim Maslama Ibn-Aḥmad al-Maǧrīṭī", "given": "Abu-'l-Qāsim", "middle": "Maslama Ibn-Aḥmad", "family": "al-Maǧrīṭī", "initials": "A. M. I. a.", "capitalized_force": "Abu-'L-Qāsim Maslama ibn-Aḥmad al-Maǧrīṭī"} {"name": "Abū-Bakr aš-Šiblī", "given": "Abū-Bakr", "family": "aš-Šiblī", "initials": "A. a.", "capitalized_force": "Abū-Bakr Aš-Šiblī"} {"name": "Abū-Maʿšar Ǧaʿfar Ibn-Muḥammad", "given": "Abū-Maʿšar", "middle": "Ǧaʿfar", "family": "Ibn-Muḥammad", "initials": "A. Ǧ. I.", "capitalized_force": "Abū-Maʿšar Ǧaʿfar ibn-Muḥammad"} {"name": "Abū-Muslim", "given": "Abū-Muslim", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "A."} {"name": "Abū-Ṣāliḥ al-Armanī", "given": "Abū-Ṣāliḥ", "family": "al-Armanī", "initials": "A. a."} {"name": "Abɖu-Tɔyib GOMINA MUSA", "given": "Abɖu-Tɔyib", "middle": "GOMINA", "family": "MUSA", "initials": "A. G. M.", "capitalized_force": "Abɖu-Tɔyib Gomina Musa"} {"name": "Achille d'Orsi", "given": "Achille", "family": "d'Orsi", "initials": "A. d.", "capitalized_force": "Achille D'Orsi"} {"name": "Ada", "given": "Ada", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "A."} {"name": "Ada", "order": "FAMILY_FIRST", "family": "Ada", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "A."} {"name": "Ada Cornelia César Martín von Brühl M.D. Ph.D.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl", "suffix": "M.D. Ph.D.", "initials": "A. C. C. M. B."} {"name": "Ada Cornelia César Martín von Brühl MD DDS", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl", "suffix": "MD DDS", "initials": "A. C. C. M. B."} {"name": "Ada Cornelia Eva Sophia van. den. Wolf M.D. Ph.D.", "given": "Ada", "middle": "Cornelia Eva Sophia", "family": "van. den. Wolf", "suffix": "M.D. Ph.D.", "initials": "A. C. E. S. W."} {"name": "Ada Cornelia Eva Sophia von Wolf MD DDS", "given": "Ada", "middle": "Cornelia Eva Sophia", "family": "von Wolf", "suffix": "MD DDS", "initials": "A. C. E. S. W."} {"name": "Ada Kornelia Yeva Sofiya van den Volf M.D. Ph.D.", "given": "Ada", "middle": "Kornelia Yeva Sofiya", "family": "van den Volf", "suffix": "M.D. Ph.D.", "initials": "A. K. Y. S. V."} {"name": "Adebola Opeyemi Olumide Ajao Adekunle Asofin", "given": "Adebola", "middle": "Opeyemi Olumide Ajao Adekunle", "family": "Asofin", "initials": "A. O. O. A. A. A."} {"name": "Adeboye Olabisi", "order": "FAMILY_FIRST", "given": "Olabisi", "family": "Adeboye", "initials": "O. A."} {"name": "Adeboye, Olabisi", "given": "Olabisi", "family": "Adeboye", "initials": "O. A."} {"name": "Adekunle Adebola Opeyemi Olumide Ajao Asofin", "order": "FAMILY_FIRST", "given": "Adebola", "middle": "Opeyemi Olumide Ajao Asofin", "family": "Adekunle", "initials": "A. O. O. A. A. A."} {"name": "Adekunle, Adebola Opeyemi Olumide Ajao", "given": "Adebola", "middle": "Opeyemi Olumide Ajao", "family": "Adekunle", "initials": "A. O. O. A. A."} {"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": "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."} {"name": "Adolfo Anta Seoane", "given": "Adolfo", "middle": "Anta", "family": "Seoane", "initials": "A. A. S."} {"name": "Adolfo Calzado y Sanjurjo", "given": "Adolfo", "family": "Calzado y Sanjurjo", "initials": "A. C. S."} {"name": "Adolfo Ernesto Echeverría Comas", "given": "Adolfo", "middle": "Ernesto Echeverría", "family": "Comas", "initials": "A. E. E. C."} {"name": "Adolph D", "given": "Adolph", "family": "D", "initials": "A. D."} {"name": "Adrian Dragos Ionescu", "given": "Adrian", "middle": "Dragos", "family": "Ionescu", "initials": "A. D. I."} {"name": "Aduritz, Irene", "given": "Irene", "family": "Aduritz", "initials": "I. A."} {"name": "Aduritz, Irene", "order": "FAMILY_FIRST", "given": "Irene", "family": "Aduritz", "initials": "I. A."} {"name": "Advayavajra", "given": "Advayavajra", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "A."} {"name": "Adá Cornelia Ẹ́va Sophia ván den Wólf M.D. PhD.", "given": "Adá", "middle": "Cornelia Ẹ́va Sophia ván", "family": "den Wólf", "suffix": "M.D. PhD.", "initials": "A. C. Ẹ. S. v. W.", "capitalized_force": "Adá Cornelia Ẹ́va Sophia Ván den Wólf M.D. PhD."} {"name": "Adèle", "given": "Adèle", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "A."} {"name": "Adèle", "order": "FAMILY_FIRST", "family": "Adèle", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "A."} {"name": "Adélaïde Lemaître", "given": "Adélaïde", "family": "Lemaître", "initials": "A. L."} {"name": "Adólfsdóttir Íris", "order": "FAMILY_FIRST", "given": "Íris", "family": "Adólfsdóttir", "initials": "Í. A."} {"name": "Agitu Ideo Gudeta", "given": "Agitu", "middle": "Ideo", "family": "Gudeta", "initials": "A. I. G."} {"name": "Aguado y García, Dionisio", "given": "Dionisio", "family": "Aguado y García", "initials": "D. A. G."} {"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 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": "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": "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."} {"name": "Alan Ridout", "given": "Alan", "family": "Ridout", "initials": "A. R."} {"name": "Albert Bertomeu Font", "given": "Albert", "middle": "Bertomeu", "family": "Font", "initials": "A. B. F."} {"name": "Albert Bonet Marrugat", "given": "Albert", "middle": "Bonet", "family": "Marrugat", "initials": "A. B. M."} {"name": "Albert Guðmundsson", "given": "Albert", "family": "Guðmundsson", "initials": "A. G."} {"name": "Albert Lederle", "given": "Albert", "family": "Lederle", "initials": "A. L."} {"name": "Albert, Eugen d'", "given": "Eugen", "family": "d' Albert", "initials": "E. A."} {"name": "Albertino della Piazza", "given": "Albertino", "family": "della Piazza", "initials": "A. P."} {"name": "Alberto Abello Vives", "given": "Alberto", "middle": "Abello", "family": "Vives", "initials": "A. A. V."} {"name": "Alberto García Ulecia", "given": "Alberto", "middle": "García", "family": "Ulecia", "initials": "A. G. U."} {"name": "Alberto Ortega Martín", "given": "Alberto", "middle": "Ortega", "family": "Martín", "initials": "A. O. M."} {"name": "Albrecht IV.", "given": "Albrecht", "suffix": "IV.", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "A."} {"name": "Albrecht von Alvensleben", "given": "Albrecht", "family": "von Alvensleben", "initials": "A. A."} {"name": "Aldephonse Alexandre Félix du Jardin", "given": "Aldephonse", "middle": "Alexandre Félix", "family": "du Jardin", "initials": "A. A. F. J."} {"name": "Alex Ben Johnson", "given": "Alex", "middle": "Ben", "family": "Johnson", "initials": "A. B. J."} {"name": "Alex van Johnson", "given": "Alex", "family": "van Johnson", "initials": "A. J."} {"name": "Alexander Parra Uribe", "given": "Alexander", "middle": "Parra", "family": "Uribe", "initials": "A. P. U."} {"name": "Alexandre Dumas fils", "given": "Alexandre", "family": "Dumas", "suffix": "fils", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (declared)"], "initials": "A. D."} {"name": "Alexandru Ioan Popa", "given": "Alexandru", "middle": "Ioan", "family": "Popa", "initials": "A. I. P."} {"name": "Alexis", "given": "Alexis", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "A."} {"name": "Alfonso Reyes Echandía", "given": "Alfonso", "middle": "Reyes", "family": "Echandía", "initials": "A. R. E."} {"name": "Alfonsus Antonius de Sarasa", "given": "Alfonsus", "middle": "Antonius", "family": "de Sarasa", "initials": "A. A. S."} {"name": "Alfɔns MANƉELA Rolanɖ", "given": "Alfɔns", "middle": "MANƉELA", "family": "Rolanɖ", "initials": "A. M. R.", "capitalized_force": "Alfɔns Manɖela Rolanɖ"} {"name": "Ali Ahmad Vali oglu", "given": "Ali", "middle": "Ahmad Vali", "family": "oglu", "initials": "A. A. V. o.", "capitalized_force": "Ali Ahmad Vali Oglu"} {"name": "Ali Ahmad Vali oglu Jr.", "given": "Ali", "middle": "Ahmad Vali", "family": "oglu", "suffix": "Jr.", "initials": "A. A. V. o.", "capitalized_force": "Ali Ahmad Vali Oglu Jr."} {"name": "Ali Ahmad oglu", "given": "Ali", "middle": "Ahmad", "family": "oglu", "initials": "A. A. o.", "capitalized_force": "Ali Ahmad Oglu"} {"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, 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."} {"name": "Alina", "order": "FAMILY_FIRST", "family": "Alina", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "A."} {"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": "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."} {"name": "Alonso de Cardenas", "given": "Alonso", "family": "de Cardenas", "initials": "A. C."} {"name": "Alquen, Gunter d'", "given": "Gunter", "family": "d' Alquen", "initials": "G. A."} {"name": "Alvensleben, Albrecht von", "given": "Albrecht", "family": "von Alvensleben", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "A. A."} {"name": "Alves, Pedro Fils", "given": "Pedro", "middle": "Fils", "family": "Alves", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: generation as suffix (vocabulary)"], "initials": "P. F. A."} {"name": "Alves, Pedro Neto", "given": "Pedro", "family": "Alves", "suffix": "Neto", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as middle (declared)"], "initials": "P. A."} {"name": "Alzon, Emmanuel d'", "given": "Emmanuel", "family": "d' Alzon", "initials": "E. A."} {"name": "Amadi John Chinedu", "order": "FAMILY_FIRST", "given": "John", "middle": "Chinedu", "family": "Amadi", "initials": "J. C. A."} {"name": "Amadi Maazị Christopher Chukwudike Jr, MP", "order": "FAMILY_FIRST", "given": "Maazị", "middle": "Christopher Chukwudike", "family": "Amadi", "suffix": "Jr, MP", "initials": "M. C. C. A."} {"name": "Amadi, Christopher Chukwudike", "given": "Christopher", "middle": "Chukwudike", "family": "Amadi", "initials": "C. C. A."} {"name": "Amadi, John Chinedu", "given": "John", "middle": "Chinedu", "family": "Amadi", "initials": "J. C. A."} {"name": "Amado Aguirre Santiago", "given": "Amado", "middle": "Aguirre", "family": "Santiago", "initials": "A. A. S."} {"name": "Amangeldi Saparow", "given": "Amangeldi", "family": "Saparow", "initials": "A. S."} {"name": "Amarildo Lombardi", "given": "Amarildo", "family": "Lombardi", "initials": "A. L."} {"name": "Amha Selassie I.", "given": "Amha", "middle": "Selassie", "family": "I.", "initials": "A. S. I."} {"name": "Amigo Vallejo, Carlos", "given": "Carlos", "family": "Amigo Vallejo", "initials": "C. A. V."} {"name": "Amina Ali", "given": "Amina", "family": "Ali", "initials": "A. A."} {"name": "Amy E Maid", "given": "Amy", "middle": "E", "family": "Maid", "initials": "A. E. M."} {"name": "Amy E. Maid", "given": "Amy", "middle": "E.", "family": "Maid", "initials": "A. E. M."} {"name": "Amélie Beaury-Saurel", "given": "Amélie", "family": "Beaury-Saurel", "initials": "A. B."} {"name": "Amīn as-Sulṭān ʿAlī Aṣġar Ḫān", "given": "Amīn", "middle": "as-Sulṭān ʿAlī Aṣġar", "family": "Ḫān", "initials": "A. a. ʿ. A. Ḫ.", "capitalized_force": "Amīn As-Sulṭān ʿAlī Aṣġar Ḫān"} {"name": "An tOll. An Dr. Brigid Aoibhe Sophia de Bhulbh", "given": "An", "middle": "tOll. An Dr. Brigid Aoibhe Sophia", "family": "de Bhulbh", "initials": "A. t. A. D. B. A. S. B.", "capitalized_force": "An Toll. An Dr. Brigid Aoibhe Sophia de Bhulbh"} {"name": "Ana Fo", "given": "Ana", "family": "Fo", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: agnomen as suffix (vocabulary)"], "initials": "A. F."} {"name": "Ana García", "given": "Ana", "family": "García", "initials": "A. G."} {"name": "Ana García (Anita) López", "given": "Ana", "middle": "García", "family": "López", "nickname": "Anita", "initials": "A. G. L."} {"name": "Ana J.R. García", "given": "Ana", "middle": "J.R.", "family": "García", "initials": "A. J. G."} {"name": "Ana M. García", "given": "Ana", "middle": "M.", "family": "García", "initials": "A. M. G."} {"name": "Ana Marija Vera Kovač", "given": "Ana", "middle": "Marija Vera", "family": "Kovač", "initials": "A. M. V. K."} {"name": "Ana María 3 López", "given": "Ana", "middle": "María 3", "family": "López", "initials": "A. M. 3. L."} {"name": "Ana María García - López", "given": "Ana", "middle": "María García -", "family": "López", "initials": "A. M. G. -. L."} {"name": "Ana María García López", "given": "Ana", "middle": "María García", "family": "López", "initials": "A. M. G. L."} {"name": "Ana María García-López", "given": "Ana", "middle": "María", "family": "García-López", "initials": "A. M. G."} {"name": "Ana María García–López", "given": "Ana", "middle": "María", "family": "García–López", "initials": "A. M. G."} {"name": "Ana María García-López", "given": "Ana", "middle": "María", "family": "García-López", "initials": "A. M. G."} {"name": "Ana María de la Vega Rodríguez", "given": "Ana", "middle": "María", "family": "de la Vega Rodríguez", "initials": "A. M. V. R."} {"name": "Ana María van der Berg", "given": "Ana", "middle": "María", "family": "van der Berg", "initials": "A. M. B."} {"name": "Ana María-José López", "given": "Ana", "middle": "María-José", "family": "López", "initials": "A. M. L."} {"name": "Ana Neto", "given": "Ana", "family": "Neto", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: agnomen as suffix (vocabulary)"], "initials": "A. N."} {"name": "Ana Torres", "given": "Ana", "family": "Torres", "initials": "A. T."} {"name": "Ana abdul Rahman López", "given": "Ana", "middle": "abdul Rahman", "family": "López", "initials": "A. a. R. L.", "capitalized_force": "Ana Abdul Rahman López"} {"name": "Anan ben David", "given": "Anan", "middle": "ben", "family": "David", "initials": "A. b. D.", "capitalized_force": "Anan Ben David"} {"name": "Andersen Irene", "order": "FAMILY_FIRST", "given": "Irene", "family": "Andersen", "initials": "I. A."} {"name": "Andersen, Irene", "given": "Irene", "family": "Andersen", "initials": "I. A."} {"name": "Andersonさん", "given": "Anderson", "suffix": "さん", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "A."} {"name": "Anderson선생님", "given": "Anderson", "suffix": "선생님", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "A."} {"name": "Andersson Ingrid", "order": "FAMILY_FIRST", "given": "Ingrid", "family": "Andersson", "initials": "I. A."} {"name": "Andersson, Ingrid", "order": "FAMILY_FIRST", "given": "Ingrid", "family": "Andersson", "initials": "I. A."} {"name": "Andreas Bernardus de Quertenmont", "given": "Andreas", "middle": "Bernardus", "family": "de Quertenmont", "initials": "A. B. Q."} {"name": "Andreas Martin", "given": "Andreas", "family": "Martin", "initials": "A. M."} {"name": "Andrew", "given": "Andrew", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "A."} {"name": "Andrew (Andy) Perkins", "given": "Andrew", "family": "Perkins", "nickname": "Andy", "initials": "A. P."} {"name": "Andrew Boris Petersen", "given": "Andrew", "middle": "Boris", "family": "Petersen", "initials": "A. B. P."} {"name": "Andrew Ma", "given": "Andrew", "family": "Ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "A. M."} {"name": "Andrew Perkins \"MBA\"", "given": "Andrew", "family": "Perkins", "suffix": "MBA", "initials": "A. P."} {"name": "Andrew Perkins 'MBA'", "given": "Andrew", "family": "Perkins", "suffix": "MBA", "initials": "A. P."} {"name": "Andrew Perkins (Andy)", "given": "Andrew", "family": "Perkins", "nickname": "Andy", "initials": "A. P."} {"name": "Andrew Perkins (JD)", "given": "Andrew", "family": "Perkins", "suffix": "JD", "initials": "A. P."} {"name": "Andrew Perkins (M.D)", "given": "Andrew", "family": "Perkins", "suffix": "M.D", "initials": "A. P."} {"name": "Andrew Perkins (MBA)", "given": "Andrew", "family": "Perkins", "suffix": "MBA", "initials": "A. P."} {"name": "Andrew Perkins (Mgr.)", "title": "Mgr.", "given": "Andrew", "family": "Perkins", "initials": "A. P."} {"name": "Andrew Perkins (XYZ)", "given": "Andrew", "family": "Perkins", "nickname": "XYZ", "initials": "A. P.", "capitalized_force": "Andrew \"Xyz\" Perkins"} {"name": "Andrew Perkins «MBA»", "given": "Andrew", "family": "Perkins", "suffix": "MBA", "initials": "A. P."} {"name": "Andrew Perkins, Jr., Col. (Ret)", "title": "Col.", "given": "Andrew", "family": "Perkins", "suffix": "Jr., Ret", "initials": "A. P."} {"name": "Andrews, M.D.", "family": "Andrews", "suffix": "M.D.", "initials": "A."} {"name": "Andrés David Padilla Mejía", "given": "Andrés", "middle": "David Padilla", "family": "Mejía", "initials": "A. D. P. M."} {"name": "Ane Miren Urrestaratzu", "given": "Ane", "middle": "Miren", "family": "Urrestaratzu", "initials": "A. M. U."} {"name": "Angiviller, Charles Claude de La Billarderie d'", "given": "Charles", "middle": "Claude de La Billarderie", "family": "d' Angiviller", "initials": "C. C. B. A.", "capitalized_force": "Charles Claude de la Billarderie d' Angiviller"} {"name": "Angoulême, Louis Antoine d'Artois d'", "given": "Louis", "middle": "Antoine d'Artois", "family": "d' Angoulême", "initials": "L. A. d. A.", "capitalized_force": "Louis Antoine D'Artois d' Angoulême"} {"name": "Anh", "given": "Anh", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "A."} {"name": "Anh", "order": "FAMILY_FIRST", "family": "Anh", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "A."} {"name": "Anh Do", "given": "Anh", "family": "Do", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "A. D."} {"name": "Anh Nguyễn", "given": "Anh", "family": "Nguyễn", "initials": "A. N."} {"name": "Anh Van Do", "given": "Anh", "family": "Van Do", "initials": "A. V. D."} {"name": "Anh Văn Nguyễn", "given": "Anh", "middle": "Văn", "family": "Nguyễn", "initials": "A. V. N."} {"name": "Anh do", "given": "Anh", "family": "do", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "A. d.", "capitalized_force": "Anh Do"} {"name": "Anh van Do", "given": "Anh", "family": "van Do", "initials": "A. v. D.", "capitalized_force": "Anh Van Do"} {"name": "Aniceto Fernández Alonso", "given": "Aniceto", "middle": "Fernández", "family": "Alonso", "initials": "A. F. A."} {"name": "Ann H", "given": "Ann", "family": "H", "initials": "A. H."} {"name": "Anna () Smith", "given": "Anna", "family": "Smith", "initials": "A. S."} {"name": "Anna Greve", "given": "Anna", "family": "Greve", "initials": "A. G."} {"name": "Anna Liina Eva Sofia von Vall, MD, PhD", "given": "Anna", "middle": "Liina Eva Sofia", "family": "von Vall", "suffix": "MD, PhD", "initials": "A. L. E. S. V."} {"name": "Anna Maria Martínez Sagi", "given": "Anna", "middle": "Maria Martínez", "family": "Sagi", "initials": "A. M. M. S."} {"name": "Anna Müller (geb. Schmidt)", "given": "Anna", "family": "Müller", "maiden": "Schmidt", "initials": "A. M."} {"name": "Anna Müller geb. Schmidt", "given": "Anna", "family": "Müller", "maiden": "Schmidt", "initials": "A. M."} {"name": "Anna Paula Bērziņa", "given": "Anna", "middle": "Paula", "family": "Bērziņa", "initials": "A. P. B."} {"name": "Anna z (domu) Nowak", "given": "Anna", "middle": "z", "family": "Nowak", "nickname": "domu", "initials": "A. z. N.", "capitalized_force": "Anna \"Domu\" Z Nowak"} {"name": "Anna z Nowak", "given": "Anna", "middle": "z", "family": "Nowak", "initials": "A. z. N.", "capitalized_force": "Anna Z Nowak"} {"name": "Anne Berit Bernhard Nilsen", "given": "Anne", "middle": "Berit Bernhard", "family": "Nilsen", "initials": "A. B. B. N."} {"name": "Anne Sepp", "given": "Anne", "family": "Sepp", "initials": "A. S."} {"name": "Anne Sofie Holm Hansen", "given": "Anne", "middle": "Sofie Holm", "family": "Hansen", "initials": "A. S. H. H."} {"name": "Anne, Swami", "title": "Swami", "given": "Anne", "initials": "A."} {"name": "Anne-Pierre de Montesquiou-Fézensac", "given": "Anne-Pierre", "family": "de Montesquiou-Fézensac", "initials": "A. M."} {"name": "Annebault, Claude d'", "given": "Claude", "family": "d' Annebault", "initials": "C. A."} {"name": "Annelise Weber", "given": "Annelise", "family": "Weber", "initials": "A. W."} {"name": "Annette Charlotte Freiherrin von und zu der Tann-Rathsamhausen", "given": "Annette", "middle": "Charlotte", "family": "Freiherrin von und zu der Tann-Rathsamhausen", "initials": "A. C. T.", "capitalized_force": "Annette Charlotte freiherrin von und zu der Tann-Rathsamhausen"} {"name": "Antico", "given": "Antico", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "A."} {"name": "Antoine de Gramont", "given": "Antoine", "family": "de Gramont", "initials": "A. G."} {"name": "Antoine de Tounens", "given": "Antoine", "family": "de Tounens", "initials": "A. T."} {"name": "Anton von Tschurtschenthaler", "given": "Anton", "family": "von Tschurtschenthaler", "initials": "A. T."} {"name": "Antonio Alemany Dezcallar", "given": "Antonio", "middle": "Alemany", "family": "Dezcallar", "initials": "A. A. D."} {"name": "Antonio Alvarez Lleras", "given": "Antonio", "middle": "Alvarez", "family": "Lleras", "initials": "A. A. L."} {"name": "Antonio Atienza y Medrano", "given": "Antonio", "family": "Atienza y Medrano", "initials": "A. A. M."} {"name": "Antonio García y Bellido", "given": "Antonio", "family": "García y Bellido", "initials": "A. G. B."} {"name": "Antonio Garrido y Villazán", "given": "Antonio", "family": "Garrido y Villazán", "initials": "A. G. V."} {"name": "Antonio López Aviña", "given": "Antonio", "middle": "López", "family": "Aviña", "initials": "A. L. A."} {"name": "Antonio Moreno Casamitjana", "given": "Antonio", "middle": "Moreno", "family": "Casamitjana", "initials": "A. M. C."} {"name": "Antonio Parra Cabrera", "given": "Antonio", "middle": "Parra", "family": "Cabrera", "initials": "A. P. C."} {"name": "Antonio París Sanz de Santamaría", "given": "Antonio", "middle": "París Sanz", "family": "de Santamaría", "initials": "A. P. S. S."} {"name": "Antonio Pavlović mlađi", "given": "Antonio", "family": "Pavlović", "suffix": "mlađi", "initials": "A. P.", "capitalized_force": "Antonio Pavlović Mlađi"} {"name": "Antonio Pavlović mlađi Jones", "given": "Antonio", "family": "Pavlović", "suffix": "mlađi Jones", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "A. P.", "capitalized_force": "Antonio Pavlović Mlađi Jones"} {"name": "Antonio da Sangallo", "given": "Antonio", "family": "da Sangallo", "initials": "A. S."} {"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": "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."} {"name": "Ardelean, Daniel Ionescu Dragoș Ioan", "given": "Daniel", "middle": "Ionescu Dragoș Ioan", "family": "Ardelean", "initials": "D. I. D. I. A."} {"name": "Arenas Betancourt, Rodrigo", "given": "Rodrigo", "family": "Arenas Betancourt", "initials": "R. A. B."} {"name": "Arenberg, Auguste Marie Raymond d'", "given": "Auguste", "middle": "Marie Raymond", "family": "d' Arenberg", "initials": "A. M. R. A."} {"name": "Arend, Arie den", "given": "Arie", "family": "den Arend", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "A. A."} {"name": "Arie den Arend", "given": "Arie", "family": "den Arend", "initials": "A. A."} {"name": "Armando Domínguez Borrás", "given": "Armando", "middle": "Domínguez", "family": "Borrás", "initials": "A. D. B."} {"name": "Armando de Lacerda", "given": "Armando", "family": "de Lacerda", "initials": "A. L."} {"name": "Arnar Gunnlaugsson", "given": "Arnar", "family": "Gunnlaugsson", "initials": "A. G."} {"name": "Arnbjörg Auður Örnólfsdóttir", "given": "Arnbjörg", "middle": "Auður", "family": "Örnólfsdóttir", "initials": "A. A. Ö."} {"name": "Arnold Neher", "given": "Arnold", "family": "Neher", "initials": "A. N."} {"name": "Aron Jóhannsson", "given": "Aron", "family": "Jóhannsson", "initials": "A. J."} {"name": "Arturo Acevedo Vallarino", "given": "Arturo", "middle": "Acevedo", "family": "Vallarino", "initials": "A. A. V."} {"name": "Asas Manterola, Benita", "given": "Benita", "family": "Asas Manterola", "initials": "B. A. M."} {"name": "Asmar", "given": "Asmar", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "A."} {"name": "Asmar", "order": "FAMILY_FIRST", "family": "Asmar", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "A."} {"name": "Assad, Hafis al-", "given": "Hafis", "family": "al- Assad", "initials": "H. A."} {"name": "Assoc Dean", "title": "Assoc", "family": "Dean", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "D."} {"name": "Assoc Dean of Chemistry Robert Johns", "title": "Assoc Dean of Chemistry", "given": "Robert", "family": "Johns", "initials": "R. J."} {"name": "Associate Dean Smith", "title": "Associate Dean", "family": "Smith", "initials": "S."} {"name": "Asst. Vice Chancellor John Smith", "title": "Asst. Vice Chancellor", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "Ataíde, Luís de", "given": "Luís", "family": "de Ataíde", "initials": "L. A."} {"name": "Attorney General of Minnesota", "title": "Attorney", "family": "General of Minnesota", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "G. M."} {"name": "Aubigné, Théodore Agrippa d'", "given": "Théodore", "middle": "Agrippa", "family": "d' Aubigné", "initials": "T. A. A."} {"name": "August von Cetto", "given": "August", "family": "von Cetto", "initials": "A. C."} {"name": "Auguste Marie Raymond d'Arenberg", "given": "Auguste", "middle": "Marie Raymond", "family": "d'Arenberg", "initials": "A. M. R. d.", "capitalized_force": "Auguste Marie Raymond D'Arenberg"} {"name": "Augustus P. Gardner", "given": "Augustus", "middle": "P.", "family": "Gardner", "initials": "A. P. G."} {"name": "Augustus Seymour Porter", "given": "Augustus", "middle": "Seymour", "family": "Porter", "initials": "A. S. P."} {"name": "Aung San", "given": "Aung", "family": "San", "initials": "A. S."} {"name": "Aurelio García Lesmes", "given": "Aurelio", "middle": "García", "family": "Lesmes", "initials": "A. G. L."} {"name": "Auxiron, Claude François Joseph d'", "given": "Claude", "middle": "François Joseph", "family": "d' Auxiron", "initials": "C. F. J. A."} {"name": "Avraham ben Mosheh ben Maimon", "given": "Avraham", "middle": "ben Mosheh ben", "family": "Maimon", "initials": "A. b. M. b. M.", "capitalized_force": "Avraham Ben Mosheh Ben Maimon"} {"name": "Awa Isseu", "order": "FAMILY_FIRST", "given": "Isseu", "family": "Awa", "initials": "I. A."} {"name": "Ayala Pérez, Daniel", "given": "Daniel", "family": "Ayala Pérez", "initials": "D. A. P."} {"name": "Aydan Arif Aliyev İlvus Santak Deputat", "order": "FAMILY_FIRST", "given": "Arif", "middle": "Aliyev İlvus Santak Deputat", "family": "Aydan", "initials": "A. A. İ. S. D. A."} {"name": "Aydan Cənab Arif Aliyev İlvus Santak Deputat", "order": "FAMILY_FIRST", "given": "Cənab", "middle": "Arif Aliyev İlvus Santak Deputat", "family": "Aydan", "initials": "C. A. A. İ. S. D. A."} {"name": "Ayolas, Juan de", "given": "Juan", "family": "de Ayolas", "initials": "J. A."} {"name": "Azhar Ibrahim", "given": "Azhar", "family": "Ibrahim", "initials": "A. I."} {"name": "Azizi Hj. Abdullah", "given": "Azizi", "middle": "Hj.", "family": "Abdullah", "initials": "A. H. A."} {"name": "Aḥmad Ibn-ʿAlī al-Maqrīzī", "given": "Aḥmad", "middle": "Ibn-ʿAlī", "family": "al-Maqrīzī", "initials": "A. I. a.", "capitalized_force": "Aḥmad ibn-ʿAlī al-Maqrīzī"} {"name": "Ba Maw", "given": "Ba", "family": "Maw", "initials": "B. M."} {"name": "Baba Ramdev", "title": "Baba", "given": "Ramdev", "initials": "R."} {"name": "Babe D. Zaharias", "given": "Babe", "middle": "D.", "family": "Zaharias", "initials": "B. D. Z."} {"name": "Babić, Marijana Tomislav", "given": "Marijana", "middle": "Tomislav", "family": "Babić", "initials": "M. T. B."} {"name": "Babić, Marijana Tomislav", "order": "FAMILY_FIRST", "given": "Marijana", "middle": "Tomislav", "family": "Babić", "initials": "M. T. B."} {"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": "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."} {"name": "Bakker Irene", "order": "FAMILY_FIRST", "given": "Irene", "family": "Bakker", "initials": "I. B."} {"name": "Bakker, Irene", "given": "Irene", "family": "Bakker", "initials": "I. B."} {"name": "Balari i Jovany, Josep", "given": "Josep", "family": "Balari i Jovany", "initials": "J. B. J."} {"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": "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."} {"name": "Bard Larsen", "given": "Bard", "family": "Larsen", "initials": "B. L."} {"name": "Baron Davis", "title": "Baron", "family": "Davis", "initials": "D."} {"name": "Barrera y Leirado, Cayetano Alberto de la", "given": "Cayetano", "middle": "Alberto", "family": "de la Barrera y Leirado", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "C. A. B. L."} {"name": "Baʿal-Maḥshoves̀", "given": "Baʿal-Maḥshoves̀", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "B."} {"name": "Beatriz Elena Paredes Rangel", "given": "Beatriz", "middle": "Elena Paredes", "family": "Rangel", "initials": "B. E. P. R."} {"name": "Beauchêne Jacqueline", "order": "FAMILY_FIRST", "given": "Jacqueline", "family": "Beauchêne", "initials": "J. B."} {"name": "Beauchêne, Jacqueline", "given": "Jacqueline", "family": "Beauchêne", "initials": "J. B."} {"name": "Beaury-Saurel, Amélie", "given": "Amélie", "family": "Beaury-Saurel", "initials": "A. B."} {"name": "Beethoven Ludwig Maria van", "given": "Beethoven", "middle": "Ludwig Maria", "family": "van", "initials": "B. L. M. v.", "capitalized_force": "Beethoven Ludwig Maria Van"} {"name": "Beethoven Ludwig van", "given": "Beethoven", "middle": "Ludwig", "family": "van", "initials": "B. L. v.", "capitalized_force": "Beethoven Ludwig Van"} {"name": "Beethoven, Ludwig van", "given": "Ludwig", "family": "van Beethoven", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "L. B."} {"name": "Bella Cohen Spewack", "given": "Bella", "middle": "Cohen", "family": "Spewack", "initials": "B. C. S."} {"name": "Ben Alex Johnson", "given": "Ben", "middle": "Alex", "family": "Johnson", "initials": "B. A. J."} {"name": "Ben Johnson", "given": "Ben", "family": "Johnson", "initials": "B. J."} {"name": "Benedikt Erlingsson", "given": "Benedikt", "family": "Erlingsson", "initials": "B. E."} {"name": "Benedikt Gröndal", "given": "Benedikt", "family": "Gröndal", "initials": "B. G."} {"name": "Benigno Cunillera Torrent", "given": "Benigno", "middle": "Cunillera", "family": "Torrent", "initials": "B. C. T."} {"name": "Benita Asas Manterola", "given": "Benita", "middle": "Asas", "family": "Manterola", "initials": "B. A. M."} {"name": "Benito Jerónimo Feijoo y Montenegro", "given": "Benito", "middle": "Jerónimo", "family": "Feijoo y Montenegro", "initials": "B. J. F. M."} {"name": "Benito Quinquela Martín", "given": "Benito", "middle": "Quinquela", "family": "Martín", "initials": "B. Q. M."} {"name": "Benjamin \"Ben\" Franklin", "given": "Benjamin", "family": "Franklin", "nickname": "Ben", "initials": "B. F."} {"name": "Benjamin \"Big Ben\" Franklin", "given": "Benjamin", "family": "Franklin", "nickname": "Big Ben", "initials": "B. F."} {"name": "Benjamin 'Ben' Franklin", "given": "Benjamin", "family": "Franklin", "nickname": "Ben", "initials": "B. F."} {"name": "Benjamin (Ben) Franklin", "given": "Benjamin", "family": "Franklin", "nickname": "Ben", "initials": "B. F."} {"name": "Benjamin (Big Ben) Franklin", "given": "Benjamin", "family": "Franklin", "nickname": "Big Ben", "initials": "B. F."} {"name": "Benjamin Louis Eulalie de Bonneville", "given": "Benjamin", "middle": "Louis Eulalie", "family": "de Bonneville", "initials": "B. L. E. B."} {"name": "Benjamin [Ben] Franklin", "given": "Benjamin", "middle": "[Ben]", "family": "Franklin", "initials": "B. [. F."} {"name": "Benjamín Castillo Plascencia", "given": "Benjamín", "middle": "Castillo", "family": "Plascencia", "initials": "B. C. P."} {"name": "Benno Hans Meyer", "given": "Benno", "middle": "Hans", "family": "Meyer", "initials": "B. H. M."} {"name": "Berg Jan de Jr.", "given": "Berg", "middle": "Jan", "family": "de", "suffix": "Jr.", "initials": "B. J. d.", "capitalized_force": "Berg Jan De Jr."} {"name": "Berg, Cornelia Margarita Sofia Aletta van den", "given": "Cornelia", "middle": "Margarita Sofia Aletta", "family": "van den Berg", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "C. M. S. A. B."} {"name": "Berg, Jan mc", "given": "Jan", "family": "mc Berg", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "J. B."} {"name": "Berg, Jan van der", "given": "Jan", "family": "van der Berg", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "J. B."} {"name": "Berg, Jan van; MD", "given": "Jan", "middle": "van;", "family": "Berg", "suffix": "MD", "initials": "J. v. B."} {"name": "Berg, Jan vd", "given": "Jan", "family": "vd Berg", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "J. B."} {"name": "Berg, Jane van der nee Smith DO", "given": "Jane", "middle": "van der nee Smith", "family": "Berg", "suffix": "DO", "initials": "J. n. S. B.", "capitalized_force": "Jane van der Nee Smith Berg DO"} {"name": "Berg, abd née Jones", "given": "abd", "middle": "née Jones", "family": "Berg", "initials": "a. n. J. B.", "capitalized_force": "Abd Née Jones Berg"} {"name": "Berg, abdul MA", "given": "abdul", "family": "Berg", "suffix": "MA", "initials": "a. B.", "capitalized_force": "Abdul Berg MA"} {"name": "Berg, abdul Prof.", "title": "Prof.", "given": "abdul", "family": "Berg", "initials": "a. B.", "capitalized_force": "Prof. Abdul Berg"} {"name": "Berg, abdul Prof. Ali", "given": "abdul Prof.", "middle": "Ali", "family": "Berg", "initials": "a. P. A. B.", "capitalized_force": "Abdul Prof. Ali Berg"} {"name": "Berg, abdul V", "given": "abdul V", "family": "Berg", "initials": "a. V. B.", "capitalized_force": "Abdul V Berg"} {"name": "Berg, abdul ma", "given": "abdul ma", "family": "Berg", "initials": "a. m. B.", "capitalized_force": "Abdul Ma Berg"} {"name": "Berg, abdul nee Jones MA", "given": "abdul", "middle": "nee Jones", "family": "Berg", "suffix": "MA", "initials": "a. n. J. B.", "capitalized_force": "Abdul Nee Jones Berg MA"} {"name": "Berg, abdul van", "given": "abdul van", "family": "Berg", "initials": "a. v. B.", "capitalized_force": "Abdul Van Berg"} {"name": "Berg, abdul vd", "given": "abdul", "family": "vd Berg", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "a. B.", "capitalized_force": "Abdul vd Berg"} {"name": "Berg, de; Smith", "given": "de;", "middle": "Smith", "family": "Berg", "initials": "d. S. B.", "capitalized_force": "De; Smith Berg"} {"name": "Bernard große Broermann", "given": "Bernard", "middle": "große", "family": "Broermann", "initials": "B. g. B.", "capitalized_force": "Bernard Große Broermann"} {"name": "Bernardino de Sahagún", "given": "Bernardino", "family": "de Sahagún", "initials": "B. S."} {"name": "Bernardo Hernández Villaseñor", "given": "Bernardo", "middle": "Hernández", "family": "Villaseñor", "initials": "B. H. V."} {"name": "Bernhard zu Solms-Braunfels", "given": "Bernhard", "family": "zu Solms-Braunfels", "initials": "B. S."} {"name": "Berta Klingberg", "given": "Berta", "family": "Klingberg", "initials": "B. K."} {"name": "Bertram Wilberforce Henry Robert Wooster", "given": "Bertram", "middle": "Wilberforce Henry Robert", "family": "Wooster", "initials": "B. W. H. R. W."} {"name": "Bertram Wilberforce Henry Robert Wooster AS", "given": "Bertram", "middle": "Wilberforce Henry Robert Wooster", "family": "AS", "initials": "B. W. H. R. W. A.", "capitalized_force": "Bertram Wilberforce Henry Robert Wooster As"} {"name": "Bertram Wilberforce Henry Robert Wooster MP", "given": "Bertram", "middle": "Wilberforce Henry Robert", "family": "Wooster", "suffix": "MP", "initials": "B. W. H. R. W."} {"name": "Beth Edmunds", "given": "Beth", "family": "Edmunds", "initials": "B. E."} {"name": "Beyoncé Knowles", "given": "Beyoncé", "family": "Knowles", "initials": "B. K."} {"name": "Bharati Lalit", "order": "FAMILY_FIRST", "given": "Lalit", "family": "Bharati", "initials": "L. B."} {"name": "Bharati Lalit Mohan", "order": "FAMILY_FIRST", "given": "Lalit", "middle": "Mohan", "family": "Bharati", "initials": "L. M. B."} {"name": "Bharati Shree Lalit Kumaar Jr, MP", "order": "FAMILY_FIRST", "given": "Shree", "middle": "Lalit Kumaar", "family": "Bharati", "suffix": "Jr, MP", "initials": "S. L. K. B."} {"name": "Bharati, Lalit", "given": "Lalit", "family": "Bharati", "initials": "L. B."} {"name": "Bharati, Lalit Kumaar", "given": "Lalit", "middle": "Kumaar", "family": "Bharati", "initials": "L. K. B."} {"name": "Bharati, Lalit Mohan", "given": "Lalit", "middle": "Mohan", "family": "Bharati", "initials": "L. M. B."} {"name": "Bisschop, Eric de", "given": "Eric", "family": "de Bisschop", "initials": "E. B."} {"name": "Bjarni Björnsson", "given": "Bjarni", "family": "Björnsson", "initials": "B. B."} {"name": "Bjarni Böðvarsson", "given": "Bjarni", "family": "Böðvarsson", "initials": "B. B."} {"name": "Bjarni Viðarsson", "given": "Bjarni", "family": "Viðarsson", "initials": "B. V."} {"name": "Björgvin Gíslason", "given": "Björgvin", "family": "Gíslason", "initials": "B. G."} {"name": "Björk Guðmundsdóttir", "given": "Björk", "family": "Guðmundsdóttir", "initials": "B. G."} {"name": "Björn Bjarnason", "given": "Björn", "family": "Bjarnason", "initials": "B. B."} {"name": "Björn Þórðarson", "given": "Björn", "family": "Þórðarson", "initials": "B. Þ."} {"name": "Blas de Lezo", "given": "Blas", "family": "de Lezo", "initials": "B. L."} {"name": "Blerim", "given": "Blerim", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "B."} {"name": "Blerim", "order": "FAMILY_FIRST", "family": "Blerim", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "B."} {"name": "Bob Dole", "given": "Bob", "family": "Dole", "initials": "B. D."} {"name": "Bob Jones, author", "title": "author", "given": "Bob", "family": "Jones", "initials": "B. J.", "capitalized_force": "Author Bob Jones"} {"name": "Bob Jones, compositeur", "title": "compositeur", "given": "Bob", "family": "Jones", "initials": "B. J.", "capitalized_force": "Compositeur Bob Jones"} {"name": "Bobur", "given": "Bobur", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "B."} {"name": "Bobur", "order": "FAMILY_FIRST", "family": "Bobur", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "B."} {"name": "Boluwatife", "given": "Boluwatife", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "B."} {"name": "Boluwatife", "order": "FAMILY_FIRST", "family": "Boluwatife", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "B."} {"name": "Bonafini, Hebe de", "given": "Hebe", "family": "de Bonafini", "initials": "H. B."} {"name": "Bonifatius VIII.", "given": "Bonifatius", "suffix": "VIII.", "ambiguities": ["GIVEN_OR_FAMILY", "SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)", "GIVEN_OR_FAMILY: name as family (position)"], "initials": "B."} {"name": "Bonneville, Benjamin Louis Eulalie de", "given": "Benjamin", "middle": "Louis Eulalie", "family": "de Bonneville", "initials": "B. L. E. B."} {"name": "Bonvi", "given": "Bonvi", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "B."} {"name": "Borcke, Karl August Ferdinand von", "given": "Karl", "middle": "August Ferdinand", "family": "von Borcke", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "K. A. F. B."} {"name": "Bouchart, Jean-Nicolas Louis Marcel de", "given": "Jean-Nicolas", "middle": "Louis Marcel", "family": "de Bouchart", "initials": "J. L. M. B."} {"name": "Bourne, Samuel", "given": "Samuel", "family": "Bourne", "initials": "S. B."} {"name": "Brais Manoel Souto", "given": "Brais", "middle": "Manoel", "family": "Souto", "initials": "B. M. S."} {"name": "Bransfield, Edward", "given": "Edward", "family": "Bransfield", "initials": "E. B."} {"name": "Breuner, Philipp von", "given": "Philipp", "family": "von Breuner", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "P. B."} {"name": "Brian Andrew O'connor", "given": "Brian", "middle": "Andrew", "family": "O'connor", "initials": "B. A. O.", "capitalized_force": "Brian Andrew O'Connor"} {"name": "Brian O'connor", "given": "Brian", "family": "O'connor", "initials": "B. O.", "capitalized_force": "Brian O'Connor"} {"name": "Bridge (1.4)", "family": "Bridge", "nickname": "1.4", "initials": "B."} {"name": "Bridge (A.B)", "family": "Bridge", "nickname": "A.B", "initials": "B."} {"name": "Broermann, Bernard große", "given": "Bernard", "middle": "große", "family": "Broermann", "initials": "B. g. B.", "capitalized_force": "Bernard Große Broermann"} {"name": "Brossard, Sébastien de", "given": "Sébastien", "family": "de Brossard", "initials": "S. B."} {"name": "Bruhel, Ada Cornelia Martina von", "given": "Ada", "middle": "Cornelia Martina", "family": "von Bruhel", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "A. C. M. B."} {"name": "Bruhl, Ada Cornelia Cesar Martin von", "given": "Ada", "middle": "Cornelia Cesar Martin", "family": "von Bruhl", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "A. C. C. M. B."} {"name": "Bruhl, Ada Cornelia Cesar Martín von", "given": "Ada", "middle": "Cornelia Cesar Martín", "family": "von Bruhl", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "A. C. C. M. B."} {"name": "Brukalska, Barbara", "given": "Barbara", "family": "Brukalska", "initials": "B. B."} {"name": "Brundridge, Contessa A", "given": "Contessa", "middle": "A", "family": "Brundridge", "initials": "C. A. B."} {"name": "Bruno III.", "given": "Bruno", "suffix": "III.", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "B."} {"name": "Brynjólfur Bjarnason", "given": "Brynjólfur", "family": "Bjarnason", "initials": "B. B."} {"name": "Brühl, Ada Cornelia César Martín von", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "A. C. C. M. B."} {"name": "Brühl, Ada Cornelia César Martín von (Prof. Dr., M.D. DDS.)", "title": "Prof. Dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl", "suffix": "M.D. DDS.", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "A. C. C. M. B."} {"name": "Brühl, Mary Lou Cesare Martín von", "given": "Mary", "middle": "Lou Cesare Martín", "family": "von Brühl", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "M. L. C. M. B."} {"name": "Brühl, María Florencia Martina Cristina von", "given": "María", "middle": "Florencia Martina Cristina", "family": "von Brühl", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "M. F. M. C. B."} {"name": "Brül Cabral de Melo, Erika Cornelia Anna Isabelle von", "given": "Erika", "middle": "Cornelia Anna Isabelle", "family": "von Brül Cabral de Melo", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "E. C. A. I. B. C. M."} {"name": "Brül, Ada Kornelia Sezar Martin fon", "given": "Ada", "middle": "Kornelia Sezar Martin fon", "family": "Brül", "initials": "A. K. S. M. f. B.", "capitalized_force": "Ada Kornelia Sezar Martin Fon Brül"} {"name": "Buca di Beppo", "given": "Buca", "family": "di Beppo", "initials": "B. B."} {"name": "Buddha Bahadur Tamang", "given": "Buddha", "middle": "Bahadur", "family": "Tamang", "initials": "B. B. T."} {"name": "Budi", "given": "Budi", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "B."} {"name": "Budi", "order": "FAMILY_FIRST", "family": "Budi", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "B."} {"name": "Bukenya Zamara Imani", "order": "FAMILY_FIRST", "given": "Zamara", "middle": "Imani", "family": "Bukenya", "initials": "Z. I. B."} {"name": "Bukenya, Zamara Imani", "given": "Zamara", "middle": "Imani", "family": "Bukenya", "initials": "Z. I. B."} {"name": "Bunn, Teddy", "given": "Teddy", "family": "Bunn", "initials": "T. B."} {"name": "Bunsen, Frances von", "given": "Frances", "family": "von Bunsen", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "F. B."} {"name": "Bustamante y Rivero, José Luis", "given": "José", "middle": "Luis", "family": "Bustamante y Rivero", "initials": "J. L. B. R."} {"name": "Buḥturī, al-Walīd Ibn-ʿUbaid al-", "given": "al-Walīd", "middle": "Ibn-ʿUbaid", "family": "al- Buḥturī", "initials": "a. I. B.", "capitalized_force": "Al-Walīd ibn-ʿUbaid al- Buḥturī"} {"name": "Bw. Adam Juma Agape Shukurani Masalu MB", "title": "Bw.", "given": "Adam", "middle": "Juma Agape Shukurani", "family": "Masalu", "suffix": "MB", "initials": "A. J. A. S. M."} {"name": "Bābak Yaḥyā Ibn-Muʿāḏ al-Ḫurramī", "given": "Bābak", "middle": "Yaḥyā Ibn-Muʿāḏ", "family": "al-Ḫurramī", "initials": "B. Y. I. a.", "capitalized_force": "Bābak Yaḥyā ibn-Muʿāḏ al-Ḫurramī"} {"name": "Bāqir Ḫān", "given": "Bāqir", "family": "Ḫān", "initials": "B. Ḫ."} {"name": "Bērziņa Anna", "order": "FAMILY_FIRST", "given": "Anna", "family": "Bērziņa", "initials": "A. B."} {"name": "Bērziņa Anna Paula", "order": "FAMILY_FIRST", "given": "Anna", "middle": "Paula", "family": "Bērziņa", "initials": "A. P. B."} {"name": "Caballero Calderón, Eduardo", "given": "Eduardo", "family": "Caballero Calderón", "initials": "E. C. C."} {"name": "Caduff, Aita", "given": "Aita", "family": "Caduff", "initials": "A. C."} {"name": "Caduff, Aita", "order": "FAMILY_FIRST", "given": "Aita", "family": "Caduff", "initials": "A. C."} {"name": "Caimbeul Dòmhnall Iain Tormod", "order": "FAMILY_FIRST", "given": "Dòmhnall", "middle": "Iain Tormod", "family": "Caimbeul", "initials": "D. I. T. C."} {"name": "Caimbeul, Dòmhnall Iain Tormod", "given": "Dòmhnall", "middle": "Iain Tormod", "family": "Caimbeul", "initials": "D. I. T. C."} {"name": "Calcar, Jan Stephan von", "given": "Jan", "middle": "Stephan", "family": "von Calcar", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "J. S. C."} {"name": "Calum", "given": "Calum", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "C."} {"name": "Calum", "order": "FAMILY_FIRST", "family": "Calum", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "C."} {"name": "Camilo Castelo Branco", "given": "Camilo", "middle": "Castelo", "family": "Branco", "initials": "C. C. B."} {"name": "Campo Serrano, José María", "given": "José", "middle": "María", "family": "Campo Serrano", "initials": "J. M. C. S."} {"name": "Canth Ulrika Wilhelmina", "order": "FAMILY_FIRST", "given": "Ulrika", "middle": "Wilhelmina", "family": "Canth", "initials": "U. W. C."} {"name": "Canth, Ulrika Wilhelmina", "given": "Ulrika", "middle": "Wilhelmina", "family": "Canth", "initials": "U. W. C."} {"name": "Cardenas, Alonso de", "given": "Alonso", "family": "de Cardenas", "initials": "A. C."} {"name": "Cardinal Secretary of State Hillary Clinton", "title": "Cardinal Secretary of State", "given": "Hillary", "family": "Clinton", "initials": "H. C."} {"name": "Carles Busquets Barroso", "given": "Carles", "middle": "Busquets", "family": "Barroso", "initials": "C. B. B."} {"name": "Carlos Amigo Vallejo", "given": "Carlos", "middle": "Amigo", "family": "Vallejo", "initials": "C. A. V."} {"name": "Carlos Ardila Lülle", "given": "Carlos", "middle": "Ardila", "family": "Lülle", "initials": "C. A. L."} {"name": "Carlos Arturo Romero Jiménez", "given": "Carlos", "middle": "Arturo Romero", "family": "Jiménez", "initials": "C. A. R. J."} {"name": "Carlos Coello de Portugal y Pacheco", "given": "Carlos", "middle": "Coello", "family": "de Portugal y Pacheco", "initials": "C. C. P. P."} {"name": "Carlos Colorado Vera", "given": "Carlos", "middle": "Colorado", "family": "Vera", "initials": "C. C. V."} {"name": "Carlos Encinas González", "given": "Carlos", "middle": "Encinas", "family": "González", "initials": "C. E. G."} {"name": "Carlos Garcés Córdoba", "given": "Carlos", "middle": "Garcés", "family": "Córdoba", "initials": "C. G. C."} {"name": "Carlos García y Bocanegra", "given": "Carlos", "family": "García y Bocanegra", "initials": "C. G. B."} {"name": "Carlos Gaviria Díaz", "given": "Carlos", "middle": "Gaviria", "family": "Díaz", "initials": "C. G. D."} {"name": "Carlos Girón Gutiérrez", "given": "Carlos", "middle": "Girón", "family": "Gutiérrez", "initials": "C. G. G."} {"name": "Carlos Landín Martínez", "given": "Carlos", "middle": "Landín", "family": "Martínez", "initials": "C. L. M."} {"name": "Carlos Lemos Simmonds", "given": "Carlos", "middle": "Lemos", "family": "Simmonds", "initials": "C. L. S."} {"name": "Carlos Martínez Baena", "given": "Carlos", "middle": "Martínez", "family": "Baena", "initials": "C. M. B."} {"name": "Carlos Mauricio Duque Pastrana", "given": "Carlos", "middle": "Mauricio Duque", "family": "Pastrana", "initials": "C. M. D. P."} {"name": "Carlos Obregón Borrero", "given": "Carlos", "middle": "Obregón", "family": "Borrero", "initials": "C. O. B."} {"name": "Carlos Prats Gonzaléz", "given": "Carlos", "middle": "Prats", "family": "Gonzaléz", "initials": "C. P. G."} {"name": "Carlos Romero Deschamps", "given": "Carlos", "middle": "Romero", "family": "Deschamps", "initials": "C. R. D."} {"name": "Carlos Sáenz Echeverría", "given": "Carlos", "middle": "Sáenz", "family": "Echeverría", "initials": "C. S. E."} {"name": "Carlos Uribe Cordovez", "given": "Carlos", "middle": "Uribe", "family": "Cordovez", "initials": "C. U. C."} {"name": "Carlos Zabala", "given": "Carlos", "family": "Zabala", "initials": "C. Z."} {"name": "Carmen Díaz Vda. de Mora", "given": "Carmen", "middle": "Díaz Vda.", "family": "de Mora", "initials": "C. D. V. M."} {"name": "Carnarvon, George Edward Stanhope Molyneux Herbert of", "given": "George", "middle": "Edward Stanhope Molyneux Herbert of", "family": "Carnarvon", "initials": "G. E. S. M. H. C."} {"name": "Carod i", "given": "Carod", "suffix": "i", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "C.", "capitalized_force": "Carod I"} {"name": "Carod i Rovira", "given": "Carod", "middle": "i", "family": "Rovira", "initials": "C. i. R."} {"name": "Carod i Rovira, Josep", "given": "Josep", "family": "Carod i Rovira", "initials": "J. C. R."} {"name": "Carod y de Rovira i", "given": "Carod", "middle": "y", "family": "de Rovira", "suffix": "i", "initials": "C. y. R.", "capitalized_force": "Carod y de Rovira I"} {"name": "Carolina", "given": "Carolina", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "C."} {"name": "Carolina", "order": "FAMILY_FIRST", "family": "Carolina", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "C."} {"name": "Carpentier, Pieter de", "given": "Pieter", "family": "de Carpentier", "initials": "P. C."} {"name": "Carrillo de Mendoza y Pimentel, Diego", "given": "Diego", "family": "Carrillo de Mendoza y Pimentel", "initials": "D. C. M. P."} {"name": "Carvalho Moreira, Francisco Inácio de", "given": "Francisco", "middle": "Inácio", "family": "de Carvalho Moreira", "initials": "F. I. C. M."} {"name": "Cassini, Jean-Dominique de", "given": "Jean-Dominique", "family": "de Cassini", "initials": "J. C."} {"name": "Castelo Branco, Camilo", "given": "Camilo", "family": "Castelo Branco", "initials": "C. C. B."} {"name": "Castro Sáez, Xosé Carlos Francisco Xavier de", "given": "Xosé", "middle": "Carlos Francisco Xavier", "family": "de Castro Sáez", "initials": "X. C. F. X. C. S."} {"name": "Catalina Ruiz Pérez", "given": "Catalina", "middle": "Ruiz", "family": "Pérez", "initials": "C. R. P."} {"name": "Cayetano Alberto de la Barrera y Leirado", "given": "Cayetano", "middle": "Alberto", "family": "de la Barrera y Leirado", "initials": "C. A. B. L."} {"name": "Cayetano Cardero y Romeo", "given": "Cayetano", "family": "Cardero y Romeo", "initials": "C. C. R."} {"name": "Cecilia Angela Smith", "given": "Cecilia", "middle": "Angela", "family": "Smith", "initials": "C. A. S."} {"name": "Cecilija Hemiš Šteber", "given": "Cecilija", "middle": "Hemiš", "family": "Šteber", "initials": "C. H. Š."} {"name": "Cerda y Aragón, Tomás Antonio de la", "given": "Tomás", "middle": "Antonio", "family": "de la Cerda y Aragón", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "T. A. C. A."} {"name": "Cetto, August von", "given": "August", "family": "von Cetto", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "A. C."} {"name": "Chancellor Jane Smith", "given": "Chancellor", "middle": "Jane", "family": "Smith", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "C. J. S."} {"name": "Chancellor Smith", "given": "Chancellor", "family": "Smith", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "C. S."} {"name": "Chang, Andy C I", "given": "Andy", "middle": "C", "family": "Chang", "suffix": "I", "initials": "A. C. C."} {"name": "Charles Claude de La Billarderie d'Angiviller", "given": "Charles", "middle": "Claude", "family": "de La Billarderie d'Angiviller", "initials": "C. C. B. d.", "capitalized_force": "Charles Claude de la Billarderie D'Angiviller"} {"name": "Charles Wilkins", "given": "Charles", "family": "Wilkins", "initials": "C. W."} {"name": "Charles van der van der Berg", "given": "Charles", "family": "van der van der Berg", "initials": "C. B."} {"name": "Chas. Smith", "given": "Chas.", "family": "Smith", "initials": "C. S."} {"name": "Chas. Smith, Ed", "given": "Chas.", "family": "Smith", "suffix": "Ed", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (declared)"], "initials": "C. S.", "capitalized_force": "Chas. Smith ED"} {"name": "Chattel, Frederik Jacobus van Rossum", "given": "Frederik", "middle": "Jacobus van Rossum", "family": "Chattel", "initials": "F. J. R. C."} {"name": "Cheikh Anta Diop", "given": "Cheikh", "middle": "Anta", "family": "Diop", "initials": "C. A. D."} {"name": "Chemistry Jane Smith", "given": "Chemistry", "middle": "Jane", "family": "Smith", "initials": "C. J. S."} {"name": "Cherice J. (Johnson) Williams", "given": "Cherice", "middle": "J.", "family": "Williams", "nickname": "Johnson", "initials": "C. J. W."} {"name": "Chief Dean and Mary Smith", "title": "Chief Dean and Mary", "family": "Smith", "initials": "S."} {"name": "Chief Judge J. Leon Holmes", "title": "Chief Judge", "given": "J.", "middle": "Leon", "family": "Holmes", "initials": "J. L. H."} {"name": "Chief Judge Sharon Lovelace Blackburn", "title": "Chief Judge", "given": "Sharon", "middle": "Lovelace", "family": "Blackburn", "initials": "S. L. B."} {"name": "Chief Justice John Roberts", "title": "Chief Justice", "given": "John", "family": "Roberts", "initials": "J. R."} {"name": "Chief Keef", "title": "Chief", "family": "Keef", "initials": "K."} {"name": "Chikamatsu, Monzaemon", "given": "Monzaemon", "family": "Chikamatsu", "initials": "M. C."} {"name": "Christian Ulrich II.", "given": "Christian", "family": "Ulrich", "suffix": "II.", "initials": "C. U."} {"name": "Christopher Hans Jacobsen", "given": "Christopher", "middle": "Hans", "family": "Jacobsen", "initials": "C. H. J."} {"name": "Ciro di Pèrs", "given": "Ciro", "family": "di Pèrs", "initials": "C. P."} {"name": "Clara Brugada Molina", "given": "Clara", "middle": "Brugada", "family": "Molina", "initials": "C. B. M."} {"name": "Clarice de'Medici", "given": "Clarice", "family": "de'Medici", "initials": "C. d."} {"name": "Clarke, Kenneth, Q.C. M.P.", "given": "Kenneth", "family": "Clarke", "suffix": "Q.C. M.P.", "initials": "K. C."} {"name": "Claude François Joseph d'Auxiron", "given": "Claude", "middle": "François Joseph", "family": "d'Auxiron", "initials": "C. F. J. d.", "capitalized_force": "Claude François Joseph D'Auxiron"} {"name": "Claude d'Annebault", "given": "Claude", "family": "d'Annebault", "initials": "C. d.", "capitalized_force": "Claude D'Annebault"} {"name": "Claudi Ametlla i Coll", "given": "Claudi", "family": "Ametlla i Coll", "initials": "C. A. C."} {"name": "Claudio Zupo Gutiérrez", "given": "Claudio", "middle": "Zupo", "family": "Gutiérrez", "initials": "C. Z. G."} {"name": "Claus Harms", "given": "Claus", "family": "Harms", "initials": "C. H."} {"name": "Claus SchroederVP of", "given": "Claus", "family": "SchroederVP of", "initials": "C. S.", "capitalized_force": "Claus Schroedervp of"} {"name": "Clorinda Málaga de Prado", "given": "Clorinda", "middle": "Málaga", "family": "de Prado", "initials": "C. M. P."} {"name": "Co-Chairs Miller", "title": "Co-Chairs", "family": "Miller", "initials": "M."} {"name": "Co-Chairs Muffy Miller", "title": "Co-Chairs", "given": "Muffy", "family": "Miller", "initials": "M. M."} {"name": "Coach", "title": "Coach"} {"name": "Cobb, Nathan Augustus", "given": "Nathan", "middle": "Augustus", "family": "Cobb", "initials": "N. A. C."} {"name": "Col. (Ret.) Smith", "title": "Col. Ret.", "family": "Smith", "initials": "S."} {"name": "Coleman Zendaya Marie", "order": "FAMILY_FIRST", "given": "Zendaya", "middle": "Marie", "family": "Coleman", "initials": "Z. M. C."} {"name": "Coleman, Zendaya Marie", "given": "Zendaya", "middle": "Marie", "family": "Coleman", "initials": "Z. M. C."} {"name": "Colette", "given": "Colette", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "C."} {"name": "Con Hamiş Vatson", "given": "Con", "middle": "Hamiş", "family": "Vatson", "initials": "C. H. V."} {"name": "Condori Edwin Segundo", "order": "FAMILY_FIRST", "given": "Edwin", "middle": "Segundo", "family": "Condori", "initials": "E. S. C."} {"name": "Condori, Edwin Segundo", "given": "Edwin", "middle": "Segundo", "family": "Condori", "initials": "E. S. C."} {"name": "Consuelo Rodríguez López", "given": "Consuelo", "middle": "Rodríguez", "family": "López", "initials": "C. R. L."} {"name": "Consuelo de Saint-Exupéry", "given": "Consuelo", "family": "de Saint-Exupéry", "initials": "C. S."} {"name": "Conti Gian Carlo Maria", "order": "FAMILY_FIRST", "given": "Gian", "middle": "Carlo Maria", "family": "Conti", "initials": "G. C. M. C."} {"name": "Conti, Gian Carlo Maria", "given": "Gian", "middle": "Carlo Maria", "family": "Conti", "initials": "G. C. M. C."} {"name": "Cornelis van Dalen", "given": "Cornelis", "family": "van Dalen", "initials": "C. D."} {"name": "Cosme Algarra y Hurtado", "given": "Cosme", "family": "Algarra y Hurtado", "initials": "C. A. H."} {"name": "Costanza da Varano", "given": "Costanza", "family": "da Varano", "initials": "C. V."} {"name": "Count Basie", "title": "Count", "family": "Basie", "initials": "B."} {"name": "Cramer, Heinz von", "given": "Heinz", "family": "von Cramer", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "H. C."} {"name": "Crisanto Luque Sánchez", "given": "Crisanto", "middle": "Luque", "family": "Sánchez", "initials": "C. L. S."} {"name": "Cristóbal Bordiú y Góngora", "given": "Cristóbal", "family": "Bordiú y Góngora", "initials": "C. B. G."} {"name": "Crisóforo Mazón Pineda", "given": "Crisóforo", "middle": "Mazón", "family": "Pineda", "initials": "C. M. P."} {"name": "Cruz, de la Alejandro Manuel César Mamani", "family": "Cruz de la Alejandro Manuel César Mamani", "initials": "C. A. M. C. M."} {"name": "Cruz, de la Maria García Rosa", "family": "Cruz de la Maria García Rosa", "initials": "C. M. G. R."} {"name": "Cummins, George D.", "given": "George", "middle": "D.", "family": "Cummins", "initials": "G. D. C."} {"name": "Cunow, Heinrich", "given": "Heinrich", "family": "Cunow", "initials": "H. C."} {"name": "Cunsel, Herman de", "given": "Herman", "family": "de Cunsel", "initials": "H. C."} {"name": "Curt Ludwig von Gienanth", "given": "Curt", "middle": "Ludwig", "family": "von Gienanth", "initials": "C. L. G."} {"name": "Cvrček, Radim", "given": "Radim", "family": "Cvrček", "initials": "R. C."} {"name": "Czigány, Dezső", "given": "Dezső", "family": "Czigány", "initials": "D. C."} {"name": "Cənab Arif Aliyev İlvus Santak Aydan Deputat", "given": "Cənab", "middle": "Arif Aliyev İlvus Santak Aydan", "family": "Deputat", "initials": "C. A. A. İ. S. A. D."} {"name": "DE MESNIL", "family": "DE MESNIL", "initials": "M.", "capitalized": "de Mesnil"} {"name": "DELA CRUZ", "given": "DELA", "family": "CRUZ", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "D. C.", "capitalized": "Dela Cruz"} {"name": "DOE, JANE MA VD PHD", "given": "JANE", "family": "DOE", "suffix": "MA VD PHD", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as middle (declared)", "SUFFIX_OR_NAME: particle as family (vocabulary)"], "initials": "J. D.", "capitalized": "Jane Doe MA VD PhD"} {"name": "DOE, JANE PHD VD", "given": "JANE", "family": "VD DOE", "suffix": "PHD", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "J. D.", "capitalized": "Jane vd Doe PhD"} {"name": "DOE, JANE PHD VD MA", "given": "JANE", "family": "DOE", "suffix": "PHD VD MA", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as middle (declared)", "SUFFIX_OR_NAME: particle as family (vocabulary)"], "initials": "J. D.", "capitalized": "Jane Doe PhD VD MA"} {"name": "DOE, JOHN MA", "given": "JOHN", "family": "DOE", "suffix": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as middle (declared)"], "initials": "J. D.", "capitalized": "John Doe MA"} {"name": "DOE, JOHN MA JD", "given": "JOHN", "family": "DOE", "suffix": "MA JD", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as middle (declared)", "SUFFIX_OR_NAME: name as middle (declared)"], "initials": "J. D.", "capitalized": "John Doe MA JD"} {"name": "DOE, JOHN MA SMITH", "given": "JOHN", "middle": "MA SMITH", "family": "DOE", "initials": "J. M. S. D.", "capitalized": "John Ma Smith Doe"} {"name": "DOE, JOHN Q MR", "title": "MR", "given": "JOHN", "middle": "Q", "family": "DOE", "initials": "J. Q. D.", "capitalized": "Mr John Q Doe"} {"name": "DOE, MARY JO MA", "given": "MARY", "middle": "JO", "family": "DOE", "suffix": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as middle (declared)"], "initials": "M. J. D.", "capitalized": "Mary Jo Doe MA"} {"name": "DR DOE", "title": "DR", "family": "DOE", "initials": "D.", "capitalized": "Dr Doe"} {"name": "DR. NÉE JUAN y LOPEZ", "title": "DR.", "given": "NÉE", "family": "JUAN y LOPEZ", "initials": "N. J. L.", "capitalized_force": "Dr. Née Juan y Lopez"} {"name": "DUT.RA MARIA SILVA", "title": "DUT.RA", "given": "MARIA", "family": "SILVA", "initials": "M. S.", "capitalized": "Dut.Ra Maria Silva"} {"name": "Dale, Antonius van", "given": "Antonius", "family": "van Dale", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "A. D."} {"name": "Dalen, Cornelis van", "given": "Cornelis", "family": "van Dalen", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "C. D."} {"name": "Dame Mary", "title": "Dame", "given": "Mary", "initials": "M."} {"name": "Danarto", "given": "Danarto", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "D."} {"name": "Daniel Ayala Pérez", "given": "Daniel", "middle": "Ayala", "family": "Pérez", "initials": "D. A. P."} {"name": "Daniel Bautista Rocha", "given": "Daniel", "middle": "Bautista", "family": "Rocha", "initials": "D. B. R."} {"name": "David Ben Gurion", "given": "David", "middle": "Ben", "family": "Gurion", "initials": "D. B. G."} {"name": "David arap Kirui", "given": "David", "middle": "arap", "family": "Kirui", "initials": "D. a. K.", "capitalized_force": "David Arap Kirui"} {"name": "David zu Sonnegg von Ungnad von Weissenwolf", "given": "David", "middle": "zu Sonnegg von Ungnad", "family": "von Weissenwolf", "initials": "D. S. U. W."} {"name": "Davis Royce, Ed", "given": "Davis", "family": "Royce", "suffix": "Ed", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (declared)"], "initials": "D. R.", "capitalized_force": "Davis Royce ED"} {"name": "Daw Thida", "given": "Daw", "family": "Thida", "initials": "D. T."} {"name": "Daði Freyr Pétursson", "given": "Daði", "middle": "Freyr", "family": "Pétursson", "initials": "D. F. P."} {"name": "De Groot", "family": "De Groot", "initials": "G.", "capitalized_force": "de Groot"} {"name": "De La Cruz, Ed", "given": "Ed", "family": "De La Cruz", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "E. C.", "capitalized_force": "Ed de la Cruz"} {"name": "De La Cruz, M.J. K.L.", "given": "M.J.", "middle": "K.L.", "family": "De La Cruz", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (shape)", "SUFFIX_OR_NAME: credential as suffix (shape)"], "initials": "M. K. C.", "capitalized_force": "M.J. K.L. de la Cruz"} {"name": "De La Cruz, M.J. PhD", "given": "M.J.", "family": "De La Cruz", "suffix": "PhD", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (shape)"], "initials": "M. C.", "capitalized_force": "M.J. de la Cruz PhD"} {"name": "Dean", "given": "Dean", "ambiguities": ["GIVEN_OR_FAMILY", "TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)", "GIVEN_OR_FAMILY: name as family (position)"], "initials": "D."} {"name": "Dean Koontz", "given": "Dean", "family": "Koontz", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "D. K."} {"name": "Dean Martin", "given": "Dean", "family": "Martin", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "D. M."} {"name": "Dean Ms Hon Solo", "given": "Dean", "middle": "Ms Hon", "family": "Solo", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "D. M. H. S."} {"name": "Dean Robert Johns", "given": "Dean", "middle": "Robert", "family": "Johns", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "D. R. J."} {"name": "Dean Smith", "given": "Dean", "family": "Smith", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "D. S."} {"name": "Dean Smith, John", "given": "John", "family": "Dean Smith", "initials": "J. D. S."} {"name": "Dean Smith, MD", "given": "Dean", "family": "Smith", "suffix": "MD", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "D. S."} {"name": "Dean and Mary Smith", "given": "Dean and Mary", "family": "Smith", "initials": "D. M. S."} {"name": "Dean of Chemistry", "title": "Dean of Chemistry"} {"name": "Dean of Chemistry Robert Johns", "title": "Dean of Chemistry", "given": "Robert", "family": "Johns", "initials": "R. J."} {"name": "Dean of Students Jane Smith", "title": "Dean of Students", "given": "Jane", "family": "Smith", "initials": "J. S."} {"name": "Dean. Robert Johns", "title": "Dean.", "given": "Robert", "family": "Johns", "initials": "R. J."} {"name": "Dean. Smith", "title": "Dean.", "family": "Smith", "initials": "S."} {"name": "Dean. and Mary Smith", "given": "Dean. and Mary", "family": "Smith", "initials": "D. M. S."} {"name": "Del Toro", "given": "Del", "family": "Toro", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "D. T."} {"name": "Del toro", "given": "Del", "family": "toro", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "D. t.", "capitalized_force": "Del Toro"} {"name": "Della Reese", "given": "Della", "family": "Reese", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "D. R."} {"name": "Deputy Secretary of State", "title": "Deputy", "family": "Secretary of State", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "S. S."} {"name": "Dermot Ryan", "given": "Dermot", "family": "Ryan", "initials": "D. R."} {"name": "Designated Judge David A. Ezra", "title": "Designated Judge", "given": "David", "middle": "A.", "family": "Ezra", "initials": "D. A. E."} {"name": "Desvalls i d’Ardena, Joan Antoni", "given": "Joan", "middle": "Antoni", "family": "Desvalls i d’Ardena", "initials": "J. A. D. d.", "capitalized_force": "Joan Antoni Desvalls i D’Ardena"} {"name": "Deventer, Willem Antonie van", "given": "Willem", "middle": "Antonie", "family": "van Deventer", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "W. A. D."} {"name": "Dhòmhnallach Oighrig", "order": "FAMILY_FIRST", "given": "Oighrig", "family": "Dhòmhnallach", "initials": "O. D."} {"name": "Dhòmhnallach, Oighrig", "given": "Oighrig", "family": "Dhòmhnallach", "initials": "O. D."} {"name": "Di Caprio", "given": "Di", "family": "Caprio", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "D. C."} {"name": "Diego Alberto Tamayo Garcera", "given": "Diego", "middle": "Alberto Tamayo", "family": "Garcera", "initials": "D. A. T. G."} {"name": "Diego Carrillo de Mendoza y Pimentel", "given": "Diego", "middle": "Carrillo", "family": "de Mendoza y Pimentel", "initials": "D. C. M. P."} {"name": "Diego Fernández de San Pedro", "given": "Diego", "middle": "Fernández", "family": "de San Pedro", "initials": "D. F. P.", "capitalized_force": "Diego Fernández de san Pedro"} {"name": "Diego Prado y Colón de Carvajal", "given": "Diego", "middle": "Prado y Colón", "family": "de Carvajal", "initials": "D. P. C. C."} {"name": "Diego Uribe Vargas", "given": "Diego", "middle": "Uribe", "family": "Vargas", "initials": "D. U. V."} {"name": "Diest, Isala van", "given": "Isala", "family": "van Diest", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "I. D."} {"name": "Dietrich Weise", "given": "Dietrich", "family": "Weise", "initials": "D. W."} {"name": "Dietrich von Hildebrand", "given": "Dietrich", "family": "von Hildebrand", "initials": "D. H."} {"name": "Dietrich von Theobald", "given": "Dietrich", "family": "von Theobald", "initials": "D. T."} {"name": "Dietrich, Rolf", "given": "Rolf", "family": "Dietrich", "initials": "R. D."} {"name": "Dietwin", "given": "Dietwin", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "D."} {"name": "Dijk, Jan van", "given": "Jan", "family": "van Dijk", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "J. D."} {"name": "Dionisio Aguado y García", "given": "Dionisio", "family": "Aguado y García", "initials": "D. A. G."} {"name": "Dionisio Guillin Mercado", "given": "Dionisio", "middle": "Guillin", "family": "Mercado", "initials": "D. G. M."} {"name": "Diponegoro", "given": "Diponegoro", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "D."} {"name": "Djuanda", "given": "Djuanda", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "D."} {"name": "Dl Dr. Daniel Ionescu Dragoș Ioan Ardelean Jr.", "given": "Dl", "middle": "Dr. Daniel Ionescu Dragoș Ioan", "family": "Ardelean", "suffix": "Jr.", "initials": "D. D. D. I. D. I. A."} {"name": "Do Quang Minh", "given": "Do", "middle": "Quang", "family": "Minh", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "D. Q. M."} {"name": "Doctor, Jane E.", "given": "Jane", "middle": "E.", "family": "Doctor", "initials": "J. E. D."} {"name": "Doe Jane Mrs", "given": "Doe", "middle": "Jane", "family": "Mrs", "initials": "D. J. M."} {"name": "Doe nee Smith Jr. Prof., Jane", "given": "Jane", "family": "Doe Prof.", "suffix": "Jr.", "maiden": "Smith", "initials": "J. D. P."} {"name": "Doe nee Smith, Jane", "given": "Jane", "family": "Doe", "maiden": "Smith", "initials": "J. D."} {"name": "Doe, Dr. John", "title": "Dr.", "given": "John", "family": "Doe", "initials": "J. D."} {"name": "Doe, Dr. John A.", "title": "Dr.", "given": "John", "middle": "A.", "family": "Doe", "initials": "J. A. D."} {"name": "Doe, Dr. John A. III", "title": "Dr.", "given": "John", "middle": "A.", "family": "Doe", "suffix": "III", "initials": "J. A. D."} {"name": "Doe, Dr. John A. Jr.", "title": "Dr.", "given": "John", "middle": "A.", "family": "Doe", "suffix": "Jr.", "initials": "J. A. D."} {"name": "Doe, Dr. John A. Kenneth", "title": "Dr.", "given": "John", "middle": "A. Kenneth", "family": "Doe", "initials": "J. A. K. D."} {"name": "Doe, Dr. John A. Kenneth III", "title": "Dr.", "given": "John", "middle": "A. Kenneth", "family": "Doe", "suffix": "III", "initials": "J. A. K. D."} {"name": "Doe, Dr. John A. Kenneth Jr.", "title": "Dr.", "given": "John", "middle": "A. Kenneth", "family": "Doe", "suffix": "Jr.", "initials": "J. A. K. D."} {"name": "Doe, Dr. John III", "title": "Dr.", "given": "John", "family": "Doe", "suffix": "III", "initials": "J. D."} {"name": "Doe, Dr. John MA", "title": "Dr.", "given": "John", "family": "Doe", "suffix": "MA", "initials": "J. D."} {"name": "Doe, Dr. John P., CLU, CFP, LUTC", "title": "Dr.", "given": "John", "middle": "P.", "family": "Doe", "suffix": "CLU, CFP, LUTC", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. P. D.", "capitalized_force": "Dr. John P. Doe CLU, CFP, Lutc"} {"name": "Doe, Dr. John, Jr.", "title": "Dr.", "given": "John", "family": "Doe", "suffix": "Jr.", "initials": "J. D."} {"name": "Doe, Dr. MA", "title": "Dr.", "family": "Doe", "suffix": "MA", "initials": "D."} {"name": "Doe, Dr. MA Smith", "title": "Dr.", "given": "MA", "middle": "Smith", "family": "Doe", "initials": "M. S. D.", "capitalized_force": "Dr. Ma Smith Doe"} {"name": "Doe, Dr. nee Smith MA", "title": "Dr.", "given": "nee", "middle": "Smith", "family": "Doe", "suffix": "MA", "initials": "n. S. D.", "capitalized_force": "Dr. Nee Smith Doe MA"} {"name": "Doe, J. MA", "given": "J.", "family": "Doe", "suffix": "MA", "initials": "J. D."} {"name": "Doe, J. ba", "given": "J.", "family": "Doe", "suffix": "ba", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as middle (declared)"], "initials": "J. D.", "capitalized_force": "J. Doe BA"} {"name": "Doe, J. nee MA ba", "given": "J.", "middle": "nee", "family": "Doe", "suffix": "MA ba", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as middle (declared)"], "initials": "J. n. D.", "capitalized_force": "J. Nee Doe MA BA"} {"name": "Doe, Jane Dr", "given": "Jane", "middle": "Dr", "family": "Doe", "initials": "J. D. D."} {"name": "Doe, Jane Fru", "given": "Jane", "middle": "Fru", "family": "Doe", "initials": "J. F. D."} {"name": "Doe, Jane Miss", "title": "Miss", "given": "Jane", "family": "Doe", "initials": "J. D."} {"name": "Doe, Jane Mr Mrs", "title": "Mr Mrs", "given": "Jane", "family": "Doe", "initials": "J. D."} {"name": "Doe, Jane Mr..", "title": "Mr..", "given": "Jane", "family": "Doe", "initials": "J. D."} {"name": "Doe, Jane Mrs", "title": "Mrs", "given": "Jane", "family": "Doe", "initials": "J. D."} {"name": "Doe, Jane Mrs, PhD", "title": "Mrs", "given": "Jane", "family": "Doe", "suffix": "PhD", "initials": "J. D."} {"name": "Doe, Jane Ms", "given": "Jane", "family": "Doe", "suffix": "Ms", "initials": "J. D.", "capitalized_force": "Jane Doe MS"} {"name": "Doe, Jane PhD MEng", "given": "Jane", "family": "Doe", "suffix": "PhD MEng", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as middle (declared)"], "initials": "J. D.", "capitalized_force": "Jane Doe PhD MENG"} {"name": "Doe, Jane Q. nee Smith MA", "given": "Jane", "middle": "Q. nee Smith", "family": "Doe", "suffix": "MA", "initials": "J. Q. n. S. D.", "capitalized_force": "Jane Q. Nee Smith Doe MA"} {"name": "Doe, Jane nee Puig i Soler", "given": "Jane", "middle": "nee Puig i Soler", "family": "Doe", "initials": "J. n. P. S. D.", "capitalized_force": "Jane Nee Puig i Soler Doe"} {"name": "Doe, Jane nee Smith", "given": "Jane", "middle": "nee Smith", "family": "Doe", "initials": "J. n. S. D.", "capitalized_force": "Jane Nee Smith Doe"} {"name": "Doe, Jane nee Smith DO", "given": "Jane", "middle": "nee Smith", "family": "Doe", "suffix": "DO", "initials": "J. n. S. D.", "capitalized_force": "Jane Nee Smith Doe DO"} {"name": "Doe, Jane nee Smith Do", "given": "Jane", "middle": "nee Smith", "family": "Do Doe", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "J. n. S. D.", "capitalized_force": "Jane Nee Smith do Doe"} {"name": "Doe, Jane nee Smith MA", "given": "Jane", "middle": "nee Smith", "family": "Doe", "suffix": "MA", "initials": "J. n. S. D.", "capitalized_force": "Jane Nee Smith Doe MA"} {"name": "Doe, Jane nee Smith MA Prof.", "title": "Prof.", "given": "Jane", "middle": "nee Smith", "family": "Doe", "suffix": "MA", "initials": "J. n. S. D.", "capitalized_force": "Prof. Jane Nee Smith Doe MA"} {"name": "Doe, Jane nee Smith MA do", "given": "Jane", "middle": "nee Smith", "family": "Doe", "suffix": "MA do", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as middle (declared)"], "initials": "J. n. S. D.", "capitalized_force": "Jane Nee Smith Doe MA DO"} {"name": "Doe, Jane nee Smith Ma", "given": "Jane", "middle": "nee Smith Ma", "family": "Doe", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "J. n. S. M. D.", "capitalized_force": "Jane Nee Smith Ma Doe"} {"name": "Doe, Jane nee Smith PhD MEng", "given": "Jane", "middle": "nee Smith", "family": "Doe", "suffix": "PhD MEng", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as middle (declared)"], "initials": "J. n. S. D.", "capitalized_force": "Jane Nee Smith Doe PhD MENG"} {"name": "Doe, Jane nee Smith do", "given": "Jane", "middle": "nee Smith", "family": "do Doe", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "J. n. S. D.", "capitalized_force": "Jane Nee Smith do Doe"} {"name": "Doe, Jane nee Smith ma", "given": "Jane", "middle": "nee Smith", "family": "Doe", "suffix": "ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as middle (declared)"], "initials": "J. n. S. D.", "capitalized_force": "Jane Nee Smith Doe MA"} {"name": "Doe, Jane nee Smith ma do", "given": "Jane", "middle": "nee Smith ma", "family": "do Doe", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "J. n. S. m. D.", "capitalized_force": "Jane Nee Smith Ma do Doe"} {"name": "Doe, Jane van Mrs", "title": "Mrs", "given": "Jane", "family": "van Doe", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "J. D."} {"name": "Doe, Jane, and Jr.", "given": "Jane", "family": "Doe", "suffix": "and Jr.", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. D."} {"name": "Doe, John", "given": "John", "family": "Doe", "initials": "J. D."} {"name": "Doe, John A.", "given": "John", "middle": "A.", "family": "Doe", "initials": "J. A. D."} {"name": "Doe, John A. III", "given": "John", "middle": "A.", "family": "Doe", "suffix": "III", "initials": "J. A. D."} {"name": "Doe, John A. Kenneth", "given": "John", "middle": "A. Kenneth", "family": "Doe", "initials": "J. A. K. D."} {"name": "Doe, John A. Kenneth III", "given": "John", "middle": "A. Kenneth", "family": "Doe", "suffix": "III", "initials": "J. A. K. D."} {"name": "Doe, John A. Kenneth, Jr.", "given": "John", "middle": "A. Kenneth", "family": "Doe", "suffix": "Jr.", "initials": "J. A. K. D."} {"name": "Doe, John A., III", "given": "John", "middle": "A.", "family": "Doe", "suffix": "III", "initials": "J. A. D."} {"name": "Doe, John A., Jr.", "given": "John", "middle": "A.", "family": "Doe", "suffix": "Jr.", "initials": "J. A. D."} {"name": "Doe, John BA", "given": "John", "family": "Doe", "suffix": "BA", "initials": "J. D."} {"name": "Doe, John DO", "given": "John", "family": "Doe", "suffix": "DO", "initials": "J. D."} {"name": "Doe, John DO Ed", "given": "John", "middle": "DO Ed", "family": "Doe", "initials": "J. E. D.", "capitalized_force": "John do Ed Doe"} {"name": "Doe, John Do", "given": "John", "family": "Do Doe", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "J. D.", "capitalized_force": "John do Doe"} {"name": "Doe, John Ed", "given": "John", "middle": "Ed", "family": "Doe", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "J. E. D."} {"name": "Doe, John Goodman", "given": "John", "middle": "Goodman", "family": "Doe", "initials": "J. G. D."} {"name": "Doe, John III", "given": "John", "family": "Doe", "suffix": "III", "initials": "J. D."} {"name": "Doe, John Jr Mr", "title": "Mr", "given": "John", "family": "Doe", "suffix": "Jr", "initials": "J. D."} {"name": "Doe, John MA", "given": "John", "family": "Doe", "suffix": "MA", "initials": "J. D."} {"name": "Doe, John MA JD", "given": "John", "family": "Doe", "suffix": "MA JD", "initials": "J. D."} {"name": "Doe, John MA Jr", "given": "John", "family": "Doe", "suffix": "MA Jr", "initials": "J. D."} {"name": "Doe, John MA Ma", "given": "John", "family": "Doe", "suffix": "MA Ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as middle (declared)"], "initials": "J. D.", "capitalized_force": "John Doe MA MA"} {"name": "Doe, John MA PhD", "given": "John", "family": "Doe", "suffix": "MA PhD", "initials": "J. D."} {"name": "Doe, John MA Smith", "given": "John", "family": "Doe", "suffix": "MA Smith", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as middle (position)"], "initials": "J. D."} {"name": "Doe, John MA do", "given": "John", "family": "Doe", "suffix": "MA do", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as middle (declared)"], "initials": "J. D.", "capitalized_force": "John Doe MA DO"} {"name": "Doe, John MA y", "given": "John", "middle": "MA y", "family": "Doe", "initials": "J. M. D.", "capitalized_force": "John Ma y Doe"} {"name": "Doe, John Ma", "given": "John", "middle": "Ma", "family": "Doe", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "J. M. D."} {"name": "Doe, John Ma Smith", "given": "John", "middle": "Ma Smith", "family": "Doe", "initials": "J. M. S. D."} {"name": "Doe, John Master", "given": "John", "middle": "Master", "family": "Doe", "initials": "J. M. D."} {"name": "Doe, John Mr Jr", "title": "Mr", "given": "John", "family": "Doe", "suffix": "Jr", "initials": "J. D."} {"name": "Doe, John Msc.Ed.", "given": "John", "family": "Doe", "suffix": "Msc.Ed.", "initials": "J. D."} {"name": "Doe, John PhD MA", "given": "John", "family": "Doe", "suffix": "PhD MA", "initials": "J. D."} {"name": "Doe, John Pope", "given": "John", "middle": "Pope", "family": "Doe", "initials": "J. P. D."} {"name": "Doe, John Q. MA", "given": "John", "middle": "Q.", "family": "Doe", "suffix": "MA", "initials": "J. Q. D."} {"name": "Doe, John X.Y.Z.", "given": "John", "family": "Doe", "suffix": "X.Y.Z.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as middle (position)"], "initials": "J. D."} {"name": "Doe, John jr., MD", "given": "John", "family": "Doe", "suffix": "jr., MD", "initials": "J. D.", "capitalized_force": "John Doe Jr., MD"} {"name": "Doe, John ma Ma", "given": "John", "middle": "ma Ma", "family": "Doe", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "J. m. M. D.", "capitalized_force": "John Ma Ma Doe"} {"name": "Doe, John ma do", "given": "John", "middle": "ma", "family": "do Doe", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "J. m. D.", "capitalized_force": "John Ma do Doe"} {"name": "Doe, John van DO", "given": "John", "family": "van DO Doe", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "J. D.", "capitalized_force": "John van do Doe"} {"name": "Doe, John van Ma", "given": "John", "middle": "van Ma", "family": "Doe", "initials": "J. M. D."} {"name": "Doe, John, Jr.", "given": "John", "family": "Doe", "suffix": "Jr.", "initials": "J. D."} {"name": "Doe, John, MD PhD - FACS Fellow", "given": "John", "family": "Doe", "suffix": "MD PhD, FACS Fellow", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. D."} {"name": "Doe, John,, Jr.", "given": "John", "family": "Doe", "suffix": "Jr.", "initials": "J. D."} {"name": "Doe, John,, Jr.,, III", "given": "John", "family": "Doe", "suffix": "Jr., III", "initials": "J. D."} {"name": "Doe, John. A. Kenneth", "title": "John.", "given": "A.", "middle": "Kenneth", "family": "Doe", "initials": "A. K. D."} {"name": "Doe, John. A. Kenneth III", "title": "John.", "given": "A.", "middle": "Kenneth", "family": "Doe", "suffix": "III", "initials": "A. K. D."} {"name": "Doe, John. A. Kenneth, Jr.", "title": "John.", "given": "A.", "middle": "Kenneth", "family": "Doe", "suffix": "Jr.", "initials": "A. K. D."} {"name": "Doe, Lt. Gen. John A. Kenneth IV", "title": "Lt. Gen.", "given": "John", "middle": "A. Kenneth", "family": "Doe", "suffix": "IV", "initials": "J. A. K. D."} {"name": "Doe, Lt.Gov. John", "title": "Lt.Gov.", "given": "John", "family": "Doe", "initials": "J. D."} {"name": "Doe, MA", "family": "Doe", "suffix": "MA", "initials": "D."} {"name": "Doe, MA PhD", "family": "Doe", "suffix": "MA PhD", "initials": "D."} {"name": "Doe, MA Smith", "given": "MA", "middle": "Smith", "family": "Doe", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "M. S. D.", "capitalized_force": "Ma Smith Doe"} {"name": "Doe, Ma PhD", "given": "Ma", "family": "Doe", "suffix": "PhD", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "M. D."} {"name": "Doe, Mary - Kate, RN", "given": "Mary", "middle": "- Kate", "family": "Doe", "suffix": "RN", "initials": "M. -. K. D."} {"name": "Doe, Mary Jo Ma", "given": "Mary", "middle": "Jo Ma", "family": "Doe", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "M. J. M. D."} {"name": "Doe, Mr. MA PhD", "title": "Mr.", "family": "Doe", "suffix": "MA PhD", "initials": "D."} {"name": "Doe, Mrs", "title": "Mrs", "family": "Doe", "initials": "D."} {"name": "Doe, PhD Jones", "family": "Doe", "suffix": "PhD Jones", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "D."} {"name": "Doe, Rev. John A. Jr.", "title": "Rev.", "given": "John", "middle": "A.", "family": "Doe", "suffix": "Jr.", "initials": "J. A. D."} {"name": "Doe, Rev. John A., V, Jr.", "title": "Rev.", "given": "John", "middle": "A.", "family": "Doe", "suffix": "V, Jr.", "initials": "J. A. D."} {"name": "Doe, Rev. John V, Jr.", "title": "Rev.", "given": "John", "middle": "V", "family": "Doe", "suffix": "Jr.", "initials": "J. V. D."} {"name": "Doe, abdul Mrs", "title": "Mrs", "given": "abdul", "family": "Doe", "initials": "a. D.", "capitalized_force": "Mrs Abdul Doe"} {"name": "Doe,, Jr.", "family": "Doe", "suffix": "Jr.", "initials": "D."} {"name": "Doe-Ray, Dr. John P., CLU, CFP, LUTC", "title": "Dr.", "given": "John", "middle": "P.", "family": "Doe-Ray", "suffix": "CLU, CFP, LUTC", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. P. D.", "capitalized_force": "Dr. John P. Doe-Ray CLU, CFP, Lutc"} {"name": "Doe-Ray, Hon. Barrington P. Jr.", "title": "Hon.", "given": "Barrington", "middle": "P.", "family": "Doe-Ray", "suffix": "Jr.", "initials": "B. P. D."} {"name": "Doe-Ray, Hon. Barrington P. Jr., CFP, LUTC", "title": "Hon.", "given": "Barrington", "middle": "P.", "family": "Doe-Ray", "suffix": "Jr., CFP, LUTC", "ambiguities": ["COMMA_STRUCTURE"], "initials": "B. P. D.", "capitalized_force": "Hon. Barrington P. Doe-Ray Jr., CFP, Lutc"} {"name": "Domingo Santa María González", "given": "Domingo", "family": "Santa María González", "initials": "D. M. G.", "capitalized_force": "Domingo santa María González"} {"name": "Domingo de Iriarte", "given": "Domingo", "family": "de Iriarte", "initials": "D. I."} {"name": "Domènech Estapà, Josep", "given": "Josep", "family": "Domènech Estapà", "initials": "J. D. E."} {"name": "Don Cheadle", "given": "Don", "family": "Cheadle", "initials": "D. C."} {"name": "Donald mc", "given": "Donald", "suffix": "mc", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "D.", "capitalized_force": "Donald MC"} {"name": "Donat, Hans von", "given": "Hans", "family": "von Donat", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "H. D."} {"name": "Donna Jean Godchaux", "given": "Donna", "middle": "Jean", "family": "Godchaux", "initials": "D. J. G."} {"name": "Donovan McNabb-Smith", "given": "Donovan", "family": "McNabb-Smith", "initials": "D. M."} {"name": "Dr John Smith", "title": "Dr", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "Dr Jr", "given": "Dr", "suffix": "Jr", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "D."} {"name": "Dr King Jr", "title": "Dr", "family": "King", "suffix": "Jr", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "K."} {"name": "Dr Martin Luther King, Jr.", "title": "Dr", "given": "Martin", "middle": "Luther", "family": "King", "suffix": "Jr.", "initials": "M. L. K."} {"name": "Dr Pope Jr", "title": "Dr", "family": "Pope", "suffix": "Jr", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "P."} {"name": "Dr Smith V", "title": "Dr", "given": "Smith", "family": "V", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: generation as suffix (vocabulary)"], "initials": "S. V."} {"name": "Dr 田中さん, V.", "title": "Dr", "family": "田中", "suffix": "さん, V.", "initials": "田."} {"name": "Dr 김민준, Jr.", "title": "Dr", "given": "민준", "family": "김", "suffix": "Jr.", "initials": "민. 김."} {"name": "Dr 김민준, MA", "title": "Dr", "given": "민준", "family": "김", "suffix": "MA", "initials": "민. 김."} {"name": "Dr 김민준씨, Jr.", "title": "Dr", "given": "민준", "family": "김", "suffix": "씨, Jr.", "initials": "민. 김."} {"name": "Dr 김민준씨, V.", "title": "Dr", "given": "민준", "family": "김", "suffix": "씨, V.", "initials": "민. 김."} {"name": "Dr.", "title": "Dr."} {"name": "Dr. (MA)", "title": "Dr.", "nickname": "MA", "ambiguities": ["SUFFIX_OR_NICKNAME"], "alternatives": ["SUFFIX_OR_NICKNAME: credential as suffix (vocabulary)"], "capitalized_force": "Dr. \"Ma\""} {"name": "Dr. Abdul Salam Hassan, MD", "title": "Dr.", "given": "Abdul Salam", "family": "Hassan", "suffix": "MD", "initials": "A. S. H."} {"name": "Dr. Ana García López", "title": "Dr.", "given": "Ana", "middle": "García", "family": "López", "initials": "A. G. L."} {"name": "Dr. Ardelean Dl Daniel Ionescu Dragoș Ioan Jr.", "order": "FAMILY_FIRST", "title": "Dr.", "given": "Dl", "middle": "Daniel Ionescu Dragoș Ioan", "family": "Ardelean", "suffix": "Jr.", "initials": "D. D. I. D. I. A."} {"name": "Dr. Do Van Johnson, MD", "title": "Dr.", "given": "Do", "family": "Van Johnson", "suffix": "MD", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "D. J.", "capitalized_force": "Dr. Do van Johnson MD"} {"name": "Dr. Freiherr von Berg, Ed", "title": "Dr.", "given": "Ed", "family": "Freiherr von Berg", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "E. B.", "capitalized_force": "Dr. Ed freiherr von Berg"} {"name": "Dr. Jason", "title": "Dr.", "family": "Jason", "initials": "J."} {"name": "Dr. Jason Ross Jay", "title": "Dr.", "given": "Jason", "middle": "Ross", "family": "Jay", "initials": "J. R. J."} {"name": "Dr. John A. Doe", "title": "Dr.", "given": "John", "middle": "A.", "family": "Doe", "initials": "J. A. D."} {"name": "Dr. John A. Doe III", "title": "Dr.", "given": "John", "middle": "A.", "family": "Doe", "suffix": "III", "initials": "J. A. D."} {"name": "Dr. John A. Doe, Jr.", "title": "Dr.", "given": "John", "middle": "A.", "family": "Doe", "suffix": "Jr.", "initials": "J. A. D."} {"name": "Dr. John A. Kenneth Doe", "title": "Dr.", "given": "John", "middle": "A. Kenneth", "family": "Doe", "initials": "J. A. K. D."} {"name": "Dr. John A. Kenneth Doe III", "title": "Dr.", "given": "John", "middle": "A. Kenneth", "family": "Doe", "suffix": "III", "initials": "J. A. K. D."} {"name": "Dr. John A. Kenneth Doe, Jr.", "title": "Dr.", "given": "John", "middle": "A. Kenneth", "family": "Doe", "suffix": "Jr.", "initials": "J. A. K. D."} {"name": "Dr. John Doe", "title": "Dr.", "given": "John", "family": "Doe", "initials": "J. D."} {"name": "Dr. John Doe III", "title": "Dr.", "given": "John", "family": "Doe", "suffix": "III", "initials": "J. D."} {"name": "Dr. John Doe, Jr.", "title": "Dr.", "given": "John", "family": "Doe", "suffix": "Jr.", "initials": "J. D."} {"name": "Dr. John P. Doe-Ray, CLU", "title": "Dr.", "given": "John", "middle": "P.", "family": "Doe-Ray", "suffix": "CLU", "initials": "J. P. D."} {"name": "Dr. John P. Doe-Ray, CLU, CFP, LUTC", "title": "Dr.", "given": "John", "middle": "P.", "family": "Doe-Ray", "suffix": "CLU, CFP, LUTC", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. P. D.", "capitalized_force": "Dr. John P. Doe-Ray CLU, CFP, Lutc"} {"name": "Dr. John Smith", "title": "Dr.", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "Dr. John Smith Prof.", "title": "Dr. Prof.", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "Dr. John van der Berg", "title": "Dr.", "given": "John", "family": "van der Berg", "initials": "J. B."} {"name": "Dr. Jr née Jones", "title": "Dr.", "given": "Jr", "middle": "née", "family": "Jones", "initials": "J. n. J.", "capitalized_force": "Dr. Jr Née Jones"} {"name": "Dr. Jr., John", "given": "John", "family": "Dr.", "suffix": "Jr.", "initials": "J. D."} {"name": "Dr. Juan Q. Velasquez y Garcia", "title": "Dr.", "given": "Juan", "middle": "Q.", "family": "Velasquez y Garcia", "initials": "J. Q. V. G."} {"name": "Dr. Juan Q. Velasquez y Garcia III", "title": "Dr.", "given": "Juan", "middle": "Q.", "family": "Velasquez y Garcia", "suffix": "III", "initials": "J. Q. V. G."} {"name": "Dr. Juan Q. Velasquez y Garcia, Jr.", "title": "Dr.", "given": "Juan", "middle": "Q.", "family": "Velasquez y Garcia", "suffix": "Jr.", "initials": "J. Q. V. G."} {"name": "Dr. Juan Q. Xavier Velasquez y Garcia", "title": "Dr.", "given": "Juan", "middle": "Q. Xavier", "family": "Velasquez y Garcia", "initials": "J. Q. X. V. G."} {"name": "Dr. Juan Q. Xavier Velasquez y Garcia III", "title": "Dr.", "given": "Juan", "middle": "Q. Xavier", "family": "Velasquez y Garcia", "suffix": "III", "initials": "J. Q. X. V. G."} {"name": "Dr. Juan Q. Xavier Velasquez y Garcia, Jr.", "title": "Dr.", "given": "Juan", "middle": "Q. Xavier", "family": "Velasquez y Garcia", "suffix": "Jr.", "initials": "J. Q. X. V. G."} {"name": "Dr. Juan Q. Xavier de la Vega", "title": "Dr.", "given": "Juan", "middle": "Q. Xavier", "family": "de la Vega", "initials": "J. Q. X. V."} {"name": "Dr. Juan Q. Xavier de la Vega III", "title": "Dr.", "given": "Juan", "middle": "Q. Xavier", "family": "de la Vega", "suffix": "III", "initials": "J. Q. X. V."} {"name": "Dr. Juan Q. Xavier de la Vega, Jr.", "title": "Dr.", "given": "Juan", "middle": "Q. Xavier", "family": "de la Vega", "suffix": "Jr.", "initials": "J. Q. X. V."} {"name": "Dr. Juan Q. Xavier de la dos Vega III", "title": "Dr.", "given": "Juan", "middle": "Q. Xavier", "family": "de la dos Vega", "suffix": "III", "initials": "J. Q. X. V."} {"name": "Dr. Juan Q. Xavier de la dos Vega, III", "title": "Dr.", "given": "Juan", "middle": "Q. Xavier", "family": "de la dos Vega", "suffix": "III", "initials": "J. Q. X. V."} {"name": "Dr. Juan Q. de la Vega", "title": "Dr.", "given": "Juan", "middle": "Q.", "family": "de la Vega", "initials": "J. Q. V."} {"name": "Dr. Juan Q. de la Vega III", "title": "Dr.", "given": "Juan", "middle": "Q.", "family": "de la Vega", "suffix": "III", "initials": "J. Q. V."} {"name": "Dr. Juan Q. de la Vega, Jr.", "title": "Dr.", "given": "Juan", "middle": "Q.", "family": "de la Vega", "suffix": "Jr.", "initials": "J. Q. V."} {"name": "Dr. Juan Velasquez y Garcia", "title": "Dr.", "given": "Juan", "family": "Velasquez y Garcia", "initials": "J. V. G."} {"name": "Dr. Juan Velasquez y Garcia III", "title": "Dr.", "given": "Juan", "family": "Velasquez y Garcia", "suffix": "III", "initials": "J. V. G."} {"name": "Dr. Juan Velasquez y Garcia, Jr.", "title": "Dr.", "given": "Juan", "family": "Velasquez y Garcia", "suffix": "Jr.", "initials": "J. V. G."} {"name": "Dr. Juan de la Vega", "title": "Dr.", "given": "Juan", "family": "de la Vega", "initials": "J. V."} {"name": "Dr. Juan de la Vega III", "title": "Dr.", "given": "Juan", "family": "de la Vega", "suffix": "III", "initials": "J. V."} {"name": "Dr. Juan de la Vega, Jr.", "title": "Dr.", "given": "Juan", "family": "de la Vega", "suffix": "Jr.", "initials": "J. V."} {"name": "Dr. King", "title": "Dr.", "family": "King", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "K."} {"name": "Dr. Martin Luther King Jr.", "title": "Dr.", "given": "Martin", "middle": "Luther", "family": "King", "suffix": "Jr.", "initials": "M. L. K."} {"name": "Dr. Mazen Elrouqi", "title": "Dr.", "given": "Mazen", "family": "Elrouqi", "initials": "M. E."} {"name": "Dr. Mc Donald, John", "title": "Dr.", "given": "John", "family": "Mc Donald", "initials": "J. D.", "capitalized_force": "Dr. John mc Donald"} {"name": "Dr. Paul Vinzent von Fischer jr. MdB", "title": "Dr.", "given": "Paul", "middle": "Vinzent", "family": "von Fischer", "suffix": "jr. MdB", "initials": "P. V. F.", "capitalized_force": "Dr. Paul Vinzent von Fischer Jr. MDB"} {"name": "Dr. Pope", "title": "Dr.", "family": "Pope", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "P."} {"name": "Dr. Pope MD", "title": "Dr.", "family": "Pope", "suffix": "MD", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "P."} {"name": "Dr. Pope, John", "title": "Dr.", "given": "John", "family": "Pope", "initials": "J. P."} {"name": "Dr. Sir John", "title": "Dr. Sir", "given": "John", "initials": "J."} {"name": "Dr. Smith", "title": "Dr.", "family": "Smith", "initials": "S."} {"name": "Dr. Smith II, Sir", "title": "Dr. Sir", "family": "Smith", "suffix": "II", "initials": "S."} {"name": "Dr. Smith Jr., AD", "title": "Dr.", "family": "Smith", "suffix": "Jr., AD", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "S."} {"name": "Dr. Smith Sir.", "title": "Dr. Sir.", "family": "Smith", "initials": "S."} {"name": "Dr. Smith V, AD", "title": "Dr.", "given": "AD", "family": "Smith V", "initials": "A. S. V.", "capitalized_force": "Dr. Ad Smith V"} {"name": "Dr. Smith and Bishop", "title": "Dr.", "family": "Smith and Bishop", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "S. B."} {"name": "Dr. Smith née Jones", "title": "Dr.", "family": "Smith", "maiden": "Jones", "initials": "S."} {"name": "Dr. Smith, John", "title": "Dr.", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "Dr. St. Pierre, Ed", "title": "Dr.", "given": "Ed", "family": "St. Pierre", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "E. P.", "capitalized_force": "Dr. Ed st. Pierre"} {"name": "Dr. Van Johnson", "title": "Dr.", "given": "Van", "family": "Johnson", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "V. J."} {"name": "Dr. Williams", "title": "Dr.", "family": "Williams", "initials": "W."} {"name": "Dr. abdul salam", "title": "Dr.", "given": "abdul", "family": "salam", "initials": "a. s.", "capitalized_force": "Dr. Abdul Salam"} {"name": "Dr. abdul salam ahmed salem", "title": "Dr.", "given": "abdul salam", "middle": "ahmed", "family": "salem", "initials": "a. s. a. s.", "capitalized_force": "Dr. Abdul Salam Ahmed Salem"} {"name": "Dr. de la Vega, Juan", "title": "Dr.", "given": "Juan", "family": "de la Vega", "initials": "J. V."} {"name": "Dr. inž. Janez Novak", "title": "Dr. inž.", "given": "Janez", "family": "Novak", "initials": "J. N.", "capitalized_force": "Dr. Inž. Janez Novak"} {"name": "Dr. juan garcia", "title": "Dr.", "given": "juan", "family": "garcia", "initials": "j. g.", "capitalized_force": "Dr. Juan Garcia"} {"name": "Dr. med. univ.", "title": "Dr. med.", "family": "univ.", "initials": "u.", "capitalized_force": "Dr. Med. Univ."} {"name": "Dr. med. univ. Margit Popp", "title": "Dr. med. univ.", "given": "Margit", "family": "Popp", "initials": "M. P.", "capitalized_force": "Dr. Med. Univ. Margit Popp"} {"name": "Dr. med. univ. Margit Popp, MSc", "title": "Dr. med. univ.", "given": "Margit", "family": "Popp", "suffix": "MSc", "initials": "M. P.", "capitalized_force": "Dr. Med. Univ. Margit Popp MSc"} {"name": "Dr. nee Smith PhD Prof.", "title": "Dr. Prof.", "given": "nee", "family": "Smith", "suffix": "PhD", "initials": "n. S.", "capitalized_force": "Dr. Prof. Nee Smith PhD"} {"name": "Dr. 김민준씨, MA", "title": "Dr.", "given": "민준", "family": "김", "suffix": "씨, MA", "initials": "민. 김."} {"name": "Dr., John", "given": "John", "family": "Dr.", "initials": "J. D."} {"name": "Dr.philol. Laima Kristīne Eva Sofija van den Volfa Bērziņa-Kalniņa", "title": "Dr.philol.", "given": "Laima", "middle": "Kristīne Eva Sofija", "family": "van den Volfa Bērziņa-Kalniņa", "initials": "L. K. E. S. V. B.", "capitalized_force": "Dr.Philol. Laima Kristīne Eva Sofija van den Volfa Bērziņa-Kalniņa"} {"name": "Dra. Andréia da Silva", "title": "Dra.", "given": "Andréia", "family": "da Silva", "initials": "A. S."} {"name": "Dra. Erika Cornelia Anna Isabelle von Brül Cabral de Melo II", "title": "Dra.", "given": "Erika", "middle": "Cornelia Anna Isabelle von Brül Cabral", "family": "de Melo", "suffix": "II", "initials": "E. C. A. I. B. C. M."} {"name": "Drífa Viðar", "given": "Drífa", "family": "Viðar", "initials": "D. V."} {"name": "Duby-Blom, Gertrude", "given": "Gertrude", "family": "Duby-Blom", "initials": "G. D."} {"name": "Duke Ellington", "given": "Duke", "family": "Ellington", "initials": "D. E."} {"name": "Duke Snider", "given": "Duke", "family": "Snider", "initials": "D. S."} {"name": "Duke of Edinburgh", "given": "Duke of Edinburgh", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "D. E."} {"name": "Duke of Wellington", "given": "Duke of Wellington", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "D. W."} {"name": "Dumas fils", "given": "Dumas", "family": "fils", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: generation as suffix (vocabulary)"], "initials": "D. f.", "capitalized_force": "Dumas Fils"} {"name": "Dumas, Alexandre Fils", "given": "Alexandre", "middle": "Fils", "family": "Dumas", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: generation as suffix (vocabulary)"], "initials": "A. F. D."} {"name": "Dumas, Alexandre fils", "given": "Alexandre", "family": "Dumas", "suffix": "fils", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as middle (declared)"], "initials": "A. D."} {"name": "Dumas, fils", "given": "fils", "family": "Dumas", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: generation as suffix (vocabulary)"], "initials": "f. D.", "capitalized_force": "Fils Dumas"} {"name": "Dumreicher, Johann von", "given": "Johann", "family": "von Dumreicher", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "J. D."} {"name": "Dut.ra Prof.ra Maria Giusepa Juana Teresa von Brühl González Domingo", "title": "Dut.ra Prof.ra", "given": "Maria", "middle": "Giusepa Juana Teresa", "family": "von Brühl González Domingo", "initials": "M. G. J. T. B. G. D.", "capitalized_force": "Dut.Ra Prof.Ra Maria Giusepa Juana Teresa von Brühl González Domingo"} {"name": "Dòmhnall Iain Tormod Caimbeul", "given": "Dòmhnall", "middle": "Iain Tormod", "family": "Caimbeul", "initials": "D. I. T. C."} {"name": "Dóra Mátrai", "given": "Dóra", "family": "Mátrai", "initials": "D. M."} {"name": "Dóra Sigurðsson", "given": "Dóra", "family": "Sigurðsson", "initials": "D. S."} {"name": "E Anne D", "given": "E", "middle": "Anne", "family": "D", "initials": "E. A. D."} {"name": "E Anne D,Leonardo", "given": "Leonardo", "family": "E Anne D", "initials": "L. E. A. D."} {"name": "E Jones", "given": "E", "family": "Jones", "initials": "E. J."} {"name": "E Maria", "given": "E", "family": "Maria", "initials": "E. M."} {"name": "E SMITH JONES", "given": "E", "middle": "SMITH", "family": "JONES", "ambiguities": ["CONJUNCTION_OR_INITIAL"], "initials": "E. S. J.", "capitalized": "E Smith Jones"} {"name": "E.T. Smith", "given": "E.T.", "family": "Smith", "initials": "E. S."} {"name": "E.T. Smith, II", "given": "E.T.", "family": "Smith", "suffix": "II", "initials": "E. S."} {"name": "Earl Campbell", "given": "Earl", "family": "Campbell", "initials": "E. C."} {"name": "Ed, Royce", "given": "Royce", "family": "Ed", "initials": "R. E."} {"name": "Edda Garðarsdóttir", "given": "Edda", "family": "Garðarsdóttir", "initials": "E. G."} {"name": "Eddie Chang Seng Dee Adecco", "given": "Eddie", "middle": "Chang Seng Dee", "family": "Adecco", "initials": "E. C. S. D. A."} {"name": "Edmond de La Croix", "given": "Edmond", "family": "de La Croix", "initials": "E. C.", "capitalized_force": "Edmond de la Croix"} {"name": "Eduard V.", "given": "Eduard", "family": "V.", "initials": "E. V."} {"name": "Eduardo Caballero Calderón", "given": "Eduardo", "middle": "Caballero", "family": "Calderón", "initials": "E. C. C."} {"name": "Eduardo Ferrer Comas", "given": "Eduardo", "middle": "Ferrer", "family": "Comas", "initials": "E. F. C."} {"name": "Eduardo Iglesias Añino", "given": "Eduardo", "middle": "Iglesias", "family": "Añino", "initials": "E. I. A."} {"name": "Eduardo López Ochoa", "given": "Eduardo", "middle": "López", "family": "Ochoa", "initials": "E. L. O."} {"name": "Eduardo Pallares Portillo", "given": "Eduardo", "middle": "Pallares", "family": "Portillo", "initials": "E. P. P."} {"name": "Eduardo Rodríguez Osorio", "given": "Eduardo", "middle": "Rodríguez", "family": "Osorio", "initials": "E. R. O."} {"name": "Eduardo Zalamea Borda", "given": "Eduardo", "middle": "Zalamea", "family": "Borda", "initials": "E. Z. B."} {"name": "Edward Bransfield", "given": "Edward", "family": "Bransfield", "initials": "E. B."} {"name": "Edward Hall", "given": "Edward", "family": "Hall", "initials": "E. H."} {"name": "Edwin Segundo Condori", "given": "Edwin", "middle": "Segundo", "family": "Condori", "initials": "E. S. C."} {"name": "Eemil", "given": "Eemil", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "E."} {"name": "Eemil", "order": "FAMILY_FIRST", "family": "Eemil", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "E."} {"name": "Egbert van Heemskerck", "given": "Egbert", "family": "van Heemskerck", "initials": "E. H."} {"name": "Egill Eyjólfsson", "given": "Egill", "family": "Eyjólfsson", "initials": "E. E."} {"name": "Einar Arnórsson", "given": "Einar", "family": "Arnórsson", "initials": "E. A."} {"name": "Einar Benediktsson", "given": "Einar", "family": "Benediktsson", "initials": "E. B."} {"name": "Einar Jónsson", "given": "Einar", "family": "Jónsson", "initials": "E. J."} {"name": "Eitel Friedrich II.", "given": "Eitel", "family": "Friedrich", "suffix": "II.", "initials": "E. F."} {"name": "Eiður Gunnarsson", "given": "Eiður", "family": "Gunnarsson", "initials": "E. G."} {"name": "El Dine", "given": "El", "family": "Dine", "initials": "E. D."} {"name": "Eladio Andino y del Solar", "given": "Eladio", "middle": "Andino y del", "family": "Solar", "initials": "E. A. S."} {"name": "Elard von Oldenburg-Januschau", "given": "Elard", "family": "von Oldenburg-Januschau", "initials": "E. O."} {"name": "Elder Soares", "given": "Elder", "family": "Soares", "initials": "E. S."} {"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": "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."} {"name": "Elísabet Erlingsdóttir", "given": "Elísabet", "family": "Erlingsdóttir", "initials": "E. E."} {"name": "Elʿazar ben Yehudah", "given": "Elʿazar", "middle": "ben", "family": "Yehudah", "initials": "E. b. Y.", "capitalized_force": "Elʿazar Ben Yehudah"} {"name": "Emilia Dorado Baltazar", "given": "Emilia", "middle": "Dorado", "family": "Baltazar", "initials": "E. D. B."} {"name": "Emilio Murillo Chapull", "given": "Emilio", "middle": "Murillo", "family": "Chapull", "initials": "E. M. C."} {"name": "Emmanuel d'Alzon", "given": "Emmanuel", "family": "d'Alzon", "initials": "E. d.", "capitalized_force": "Emmanuel D'Alzon"} {"name": "Emmanuel d'Hooghvorst", "given": "Emmanuel", "family": "d'Hooghvorst", "initials": "E. d.", "capitalized_force": "Emmanuel D'Hooghvorst"} {"name": "Emmanuel de Grouchy", "given": "Emmanuel", "family": "de Grouchy", "initials": "E. G."} {"name": "Emomoto Yoshi", "order": "FAMILY_FIRST", "given": "Yoshi", "family": "Emomoto", "initials": "Y. E."} {"name": "Emomoto, Yoshi", "given": "Yoshi", "family": "Emomoto", "initials": "Y. E."} {"name": "Engelen, Piet van", "given": "Piet", "family": "van Engelen", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "P. E."} {"name": "Engelhardt, Wolf von", "given": "Wolf", "family": "von Engelhardt", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "W. E."} {"name": "Engracia Vidal Estévez", "given": "Engracia", "middle": "Vidal", "family": "Estévez", "initials": "E. V. E."} {"name": "Enrique Gorostieta Velarde", "given": "Enrique", "middle": "Gorostieta", "family": "Velarde", "initials": "E. G. V."} {"name": "Enrique Santos Montejo", "given": "Enrique", "middle": "Santos", "family": "Montejo", "initials": "E. S. M."} {"name": "Enver Pascha", "given": "Enver", "family": "Pascha", "initials": "E. P."} {"name": "Epaminondas Nunes de Ávila e Silva", "given": "Epaminondas", "middle": "Nunes", "family": "de Ávila e Silva", "initials": "E. N. Á. S."} {"name": "Eric H. Holder Jr. Attorney General", "title": "Attorney General", "given": "Eric", "middle": "H.", "family": "Holder", "suffix": "Jr.", "initials": "E. H. H."} {"name": "Eric H. Holder, Jr., Attorney General", "title": "Attorney General", "given": "Eric", "middle": "H.", "family": "Holder", "suffix": "Jr.", "initials": "E. H. H."} {"name": "Eric H. Holder, Jr., Secretary of State", "title": "Secretary of State", "given": "Eric", "middle": "H.", "family": "Holder", "suffix": "Jr.", "ambiguities": ["COMMA_STRUCTURE"], "initials": "E. H. H."} {"name": "Eric de Bisschop", "given": "Eric", "family": "de Bisschop", "initials": "E. B."} {"name": "Ernst von Wildenbruch", "given": "Ernst", "family": "von Wildenbruch", "initials": "E. W."} {"name": "Escoto, Miguel d'", "given": "Miguel", "family": "d' Escoto", "initials": "M. E."} {"name": "Esq Jane Smith", "given": "Esq", "middle": "Jane", "family": "Smith", "initials": "E. J. S."} {"name": "Esq Smith", "given": "Esq", "family": "Smith", "initials": "E. S."} {"name": "Esq. Smith", "title": "Esq.", "family": "Smith", "initials": "S."} {"name": "Esq. van Gogh", "title": "Esq.", "given": "van", "family": "Gogh", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "v. G.", "capitalized_force": "Esq. Van Gogh"} {"name": "Esteva a Alsina, Maurus Daniel", "given": "Maurus", "middle": "Daniel", "family": "Esteva a Alsina", "initials": "M. D. E. a. A.", "capitalized_force": "Maurus Daniel Esteva A Alsina"} {"name": "Estrées, Victor-Marie d'", "given": "Victor-Marie", "family": "d' Estrées", "initials": "V. E."} {"name": "Etiemble", "given": "Etiemble", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "E."} {"name": "Eugen d'Albert", "given": "Eugen", "family": "d'Albert", "initials": "E. d.", "capitalized_force": "Eugen D'Albert"} {"name": "Eugeni Aguiló i Pérez", "given": "Eugeni", "family": "Aguiló i Pérez", "initials": "E. A. P."} {"name": "Eulalio Ríos Alemán", "given": "Eulalio", "middle": "Ríos", "family": "Alemán", "initials": "E. R. A."} {"name": "Eulogio Despujol y Dusay", "given": "Eulogio", "family": "Despujol y Dusay", "initials": "E. D. D."} {"name": "Eunsoo Johanna Jeong", "given": "Eunsoo", "middle": "Johanna", "family": "Jeong", "initials": "E. J. J."} {"name": "Ever Peña Maldonado", "given": "Ever", "middle": "Peña", "family": "Maldonado", "initials": "E. P. M."} {"name": "Ewa Nowak", "given": "Ewa", "family": "Nowak", "initials": "E. N."} {"name": "Excm. Sr. Josep Antoni Carles Joan Lloret Palol", "title": "Excm. Sr.", "given": "Josep", "middle": "Antoni Carles Joan Lloret", "family": "Palol", "initials": "J. A. C. J. L. P."} {"name": "Excm. Sr. Lloret Palol II, Josep Antoni Carles Joan", "order": "FAMILY_FIRST", "given": "Josep", "middle": "Antoni Carles Joan", "family": "Excm. Lloret Palol", "suffix": "Sr., II", "initials": "J. A. C. J. E. L. P."} {"name": "Excma. Dra. Maria da Graça César Martín van Dalen Hoogeveen", "title": "Excma. Dra.", "given": "Maria", "middle": "da Graça César Martín", "family": "van Dalen Hoogeveen", "initials": "M. G. C. M. D. H."} {"name": "Excma. Dra. Van Dalen Hoogeveen II, Maria da Graça César Martín", "order": "FAMILY_FIRST", "given": "Maria", "middle": "da Graça César Martín", "family": "Excma. Dra. Van Dalen Hoogeveen", "suffix": "II", "initials": "M. G. C. M. E. D. D. H.", "capitalized_force": "Maria da Graça César Martín Excma. Dra. van Dalen Hoogeveen II"} {"name": "Eyck, Jan van", "given": "Jan", "family": "van Eyck", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "J. E."} {"name": "É. y É. Smith", "given": "É.", "middle": "y É.", "family": "Smith", "initials": "É. É. S."} {"name": "F. de Lange", "given": "F.", "family": "de Lange", "initials": "F. L."} {"name": "Fabbri Irene", "order": "FAMILY_FIRST", "given": "Irene", "family": "Fabbri", "initials": "I. F."} {"name": "Fabbri, Irene", "given": "Irene", "family": "Fabbri", "initials": "I. F."} {"name": "Fabián Pérez Ximeno", "given": "Fabián", "middle": "Pérez", "family": "Ximeno", "initials": "F. P. X."} {"name": "Fadrique de Toledo Osorio", "given": "Fadrique", "family": "de Toledo Osorio", "initials": "F. T. O."} {"name": "Falke, Iris", "given": "Iris", "family": "Falke", "initials": "I. F."} {"name": "Falke, Iris", "order": "FAMILY_FIRST", "given": "Iris", "family": "Falke", "initials": "I. F."} {"name": "Farf. Dr. Ada Cornelia Eva Sophia van den Wolf Becker Schmidt M.D. Ph.D.", "title": "Farf. Dr.", "given": "Ada", "middle": "Cornelia Eva Sophia", "family": "van den Wolf Becker Schmidt", "suffix": "M.D. Ph.D.", "initials": "A. C. E. S. W. B. S."} {"name": "Faria, Teodoro de", "given": "Teodoro", "family": "de Faria", "initials": "T. F."} {"name": "Fatima", "given": "Fatima", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "F."} {"name": "Fatima", "order": "FAMILY_FIRST", "family": "Fatima", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "F."} {"name": "Fatima Amina Umar", "given": "Fatima", "middle": "Amina", "family": "Umar", "initials": "F. A. U."} {"name": "Fatimah", "given": "Fatimah", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "F."} {"name": "Fauveau, Félicie de", "given": "Félicie", "family": "de Fauveau", "initials": "F. F."} {"name": "Faḥḥām, Šākir al-", "given": "Šākir", "family": "al- Faḥḥām", "initials": "Š. F."} {"name": "Federico Sánchez Bedoya", "given": "Federico", "middle": "Sánchez", "family": "Bedoya", "initials": "F. S. B."} {"name": "Feijoo y Montenegro, Benito Jerónimo", "given": "Benito", "middle": "Jerónimo", "family": "Feijoo y Montenegro", "initials": "B. J. F. M."} {"name": "Felip Lorda i Alaiz", "given": "Felip", "family": "Lorda i Alaiz", "initials": "F. L. A."} {"name": "Felipe Benicio Navarro y Reig", "given": "Felipe", "middle": "Benicio", "family": "Navarro y Reig", "initials": "F. B. N. R."} {"name": "Felipe Bermejo Araujo", "given": "Felipe", "middle": "Bermejo", "family": "Araujo", "initials": "F. B. A."} {"name": "Felipe Colomo Castro", "given": "Felipe", "middle": "Colomo", "family": "Castro", "initials": "F. C. C."} {"name": "Felipe José Abárzuza y Oliva", "given": "Felipe", "middle": "José", "family": "Abárzuza y Oliva", "initials": "F. J. A. O."} {"name": "Felipe Valdés y López", "given": "Felipe", "family": "Valdés y López", "initials": "F. V. L."} {"name": "Feliu Crespí i Cirera", "given": "Feliu", "family": "Crespí i Cirera", "initials": "F. C. C."} {"name": "Ferdinand-Philippe d'Orléans", "given": "Ferdinand-Philippe", "family": "d'Orléans", "initials": "F. d.", "capitalized_force": "Ferdinand-Philippe D'Orléans"} {"name": "Fernandes Maria Helena", "order": "FAMILY_FIRST", "given": "Maria", "middle": "Helena", "family": "Fernandes", "initials": "M. H. F."} {"name": "Fernandes, Maria Helena", "given": "Maria", "middle": "Helena", "family": "Fernandes", "initials": "M. H. F."} {"name": "Fernando González Gortázar", "given": "Fernando", "middle": "González", "family": "Gortázar", "initials": "F. G. G."} {"name": "Fernando Gordillo Escudero", "given": "Fernando", "middle": "Gordillo", "family": "Escudero", "initials": "F. G. E."} {"name": "Fernando Hiriart Balderrama", "given": "Fernando", "middle": "Hiriart", "family": "Balderrama", "initials": "F. H. B."} {"name": "Fernando Rosas Solis", "given": "Fernando", "middle": "Rosas", "family": "Solis", "initials": "F. R. S."} {"name": "Fernando de Alva Ixtlilxóchitl", "given": "Fernando", "family": "de Alva Ixtlilxóchitl", "initials": "F. A. I."} {"name": "Fernando de Borbón y Medina", "given": "Fernando", "family": "de Borbón y Medina", "initials": "F. B. M."} {"name": "Ferrer Rosa Maria Ferran", "order": "FAMILY_FIRST", "given": "Rosa", "middle": "Maria Ferran", "family": "Ferrer", "initials": "R. M. F. F."} {"name": "Ferrer, Rosa Maria Ferran", "given": "Rosa", "middle": "Maria Ferran", "family": "Ferrer", "initials": "R. M. F. F."} {"name": "Fidus", "given": "Fidus", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "F."} {"name": "Field Marshal Bernard Montgomery", "title": "Field Marshal", "given": "Bernard", "family": "Montgomery", "initials": "B. M."} {"name": "Fien de la Mar", "given": "Fien", "family": "de la Mar", "initials": "F. M."} {"name": "Figueiredo, Jackson de", "given": "Jackson", "family": "de Figueiredo", "initials": "J. F."} {"name": "Finnur Jónsson", "given": "Finnur", "family": "Jónsson", "initials": "F. J."} {"name": "Fischer, Paul Vinzent von", "given": "Paul", "middle": "Vinzent", "family": "von Fischer", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "P. V. F."} {"name": "Fitzhugh, Louise", "given": "Louise", "family": "Fitzhugh", "initials": "L. F."} {"name": "Fleksnes, Gregers Werle Marve Almar von", "given": "Gregers", "middle": "Werle Marve Almar", "family": "von Fleksnes", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "G. W. M. A. F."} {"name": "Flores Marco Antonio", "order": "FAMILY_FIRST", "given": "Marco", "middle": "Antonio", "family": "Flores", "initials": "M. A. F."} {"name": "Flores, Marco Antonio", "given": "Marco", "middle": "Antonio", "family": "Flores", "initials": "M. A. F."} {"name": "Flávio Juvenal da Silva Jr", "given": "Flávio", "middle": "Juvenal", "family": "da Silva", "suffix": "Jr", "initials": "F. J. S."} {"name": "Flávio Juvenal da Silva Junior", "given": "Flávio", "middle": "Juvenal", "family": "da Silva", "suffix": "Junior", "initials": "F. J. S."} {"name": "Foffa, Gian Carlo Ramun", "given": "Gian", "middle": "Carlo Ramun", "family": "Foffa", "initials": "G. C. R. F."} {"name": "Foffa, Gian Carlo Ramun", "order": "FAMILY_FIRST", "given": "Gian", "middle": "Carlo Ramun", "family": "Foffa", "initials": "G. C. R. F."} {"name": "Foldès, Peter", "given": "Peter", "family": "Foldès", "initials": "P. F."} {"name": "Foncemagne, Étienne Lauréault de", "given": "Étienne", "middle": "Lauréault", "family": "de Foncemagne", "initials": "É. L. F."} {"name": "Fonda, Menada", "given": "Menada", "family": "Fonda", "initials": "M. F."} {"name": "Fonda, Menada", "order": "FAMILY_FIRST", "given": "Menada", "family": "Fonda", "initials": "M. F."} {"name": "Foo. John Smith", "title": "Foo.", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "Foo. Xyz. John Smith", "title": "Foo. Xyz.", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "Fraknói, Vilmos", "given": "Vilmos", "family": "Fraknói", "initials": "V. F."} {"name": "Frances von Bunsen", "given": "Frances", "family": "von Bunsen", "initials": "F. B."} {"name": "Francesc Arnau i Cortina", "given": "Francesc", "family": "Arnau i Cortina", "initials": "F. A. C."} {"name": "Francesc Todó Garcia", "given": "Francesc", "middle": "Todó", "family": "Garcia", "initials": "F. T. G."} {"name": "Francis Anel Bueno Sánchez", "given": "Francis", "middle": "Anel Bueno", "family": "Sánchez", "initials": "F. A. B. S."} {"name": "Francisco Díez-Barroso Govantes", "given": "Francisco", "middle": "Díez-Barroso", "family": "Govantes", "initials": "F. D. G."} {"name": "Francisco Freire Barreiro", "given": "Francisco", "middle": "Freire", "family": "Barreiro", "initials": "F. F. B."} {"name": "Francisco Giraldo Hernández", "given": "Francisco", "middle": "Giraldo", "family": "Hernández", "initials": "F. G. H."} {"name": "Francisco González Macías", "given": "Francisco", "middle": "González", "family": "Macías", "initials": "F. G. M.", "capitalized_force": "Francisco González MacÍas"} {"name": "Francisco Gutiérrez Carreola", "given": "Francisco", "middle": "Gutiérrez", "family": "Carreola", "initials": "F. G. C."} {"name": "Francisco Hernández Pla", "given": "Francisco", "family": "Hernández", "suffix": "Pla", "initials": "F. H.", "capitalized_force": "Francisco Hernández PLA"} {"name": "Francisco Inácio de Carvalho Moreira", "given": "Francisco", "middle": "Inácio", "family": "de Carvalho Moreira", "initials": "F. I. C. M."} {"name": "Francisco Javier Fortea Pérez", "given": "Francisco", "middle": "Javier Fortea", "family": "Pérez", "initials": "F. J. F. P."} {"name": "Francisco Javier Lorenzo Oroza", "given": "Francisco", "middle": "Javier Lorenzo", "family": "Oroza", "initials": "F. J. L. O."} {"name": "Francisco Martorell y Peña", "given": "Francisco", "family": "Martorell y Peña", "initials": "F. M. P."} {"name": "Francisco Martínez de Cenicero", "given": "Francisco", "middle": "Martínez", "family": "de Cenicero", "initials": "F. M. C."} {"name": "Francisco Posada Díaz", "given": "Francisco", "middle": "Posada", "family": "Díaz", "initials": "F. P. D."} {"name": "Francisco Santos Calderón", "given": "Francisco", "middle": "Santos", "family": "Calderón", "initials": "F. S. C."} {"name": "Franconetti y Aguilar, Silverio", "given": "Silverio", "family": "Franconetti y Aguilar", "initials": "S. F. A."} {"name": "Franklin Washington, Jr. MD", "given": "Franklin", "family": "Washington", "suffix": "Jr. MD", "initials": "F. W."} {"name": "Franklin, Benjamin (Ben)", "given": "Benjamin", "family": "Franklin", "nickname": "Ben", "initials": "B. F."} {"name": "Franklin, Benjamin (Ben), Jr.", "given": "Benjamin", "family": "Franklin", "suffix": "Jr.", "nickname": "Ben", "initials": "B. F."} {"name": "Frantziscu", "given": "Frantziscu", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "F."} {"name": "Frantziscu", "order": "FAMILY_FIRST", "family": "Frantziscu", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "F."} {"name": "Franz I.", "given": "Franz", "family": "I.", "initials": "F. I."} {"name": "François de L'Aubespine", "given": "François", "family": "de L'Aubespine", "initials": "F. L."} {"name": "Frau Anna Müller", "title": "Frau", "given": "Anna", "family": "Müller", "initials": "A. M."} {"name": "Frederic Bosch i Serra", "given": "Frederic", "family": "Bosch i Serra", "initials": "F. B. S."} {"name": "Frederic Viñals i Iscla", "given": "Frederic", "family": "Viñals i Iscla", "initials": "F. V. I."} {"name": "Freiherr St John Smith", "title": "Freiherr St", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "Freiherr von Berg MA", "title": "Freiherr", "family": "von Berg", "suffix": "MA", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as given (declared)"], "initials": "B."} {"name": "Freiherr von Berg MA Ma", "title": "Freiherr", "family": "von Berg MA Ma", "ambiguities": ["PARTICLE_OR_GIVEN", "SUFFIX_OR_NAME"], "alternatives": ["PARTICLE_OR_GIVEN: name as given (declared)", "SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "B. M. M.", "capitalized_force": "Freiherr von Berg Ma Ma"} {"name": "Freiherr von Berg, Ed", "given": "Ed", "family": "Freiherr von Berg", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "E. B.", "capitalized_force": "Ed freiherr von Berg"} {"name": "Freiherr von Richthofen", "title": "Freiherr", "family": "von Richthofen", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as given (declared)"], "initials": "R."} {"name": "Freiherr von Richthofen V", "title": "Freiherr", "family": "von Richthofen V", "ambiguities": ["PARTICLE_OR_GIVEN", "SUFFIX_OR_NAME"], "alternatives": ["PARTICLE_OR_GIVEN: name as given (declared)", "SUFFIX_OR_NAME: generation as suffix (vocabulary)"], "initials": "R. V."} {"name": "Freire Xiana", "order": "FAMILY_FIRST", "given": "Xiana", "family": "Freire", "initials": "X. F."} {"name": "Freire, Xiana", "given": "Xiana", "family": "Freire", "initials": "X. F."} {"name": "Friedrich IV.", "given": "Friedrich", "suffix": "IV.", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "F."} {"name": "Friedrich Ludwig von Waldburg-Truchsess", "given": "Friedrich", "middle": "Ludwig", "family": "von Waldburg-Truchsess", "initials": "F. L. W."} {"name": "Friedrich V.", "given": "Friedrich", "family": "V.", "initials": "F. V."} {"name": "Fritz Freiherr und von Bar", "given": "Fritz", "family": "Freiherr und von Bar", "initials": "F. u.", "capitalized_force": "Fritz freiherr und von bar"} {"name": "Fritz von Unruh", "given": "Fritz", "family": "von Unruh", "initials": "F. U."} {"name": "Frøken Jensen", "title": "Frøken", "family": "Jensen", "initials": "J."} {"name": "Frühbeck de Burgos, Rafael", "given": "Rafael", "family": "Frühbeck de Burgos", "initials": "R. F. B."} {"name": "Funès, Louis de", "given": "Louis", "family": "de Funès", "initials": "L. F."} {"name": "Fuseena AKIME", "given": "Fuseena", "family": "AKIME", "initials": "F. A.", "capitalized_force": "Fuseena Akime"} {"name": "Fuʾād ar-Rikābī", "given": "Fuʾād", "family": "ar-Rikābī", "initials": "F. a.", "capitalized_force": "Fuʾād Ar-Rikābī"} {"name": "Félicie de Fauveau", "given": "Félicie", "family": "de Fauveau", "initials": "F. F."} {"name": "Félix Sardá y Salvany", "given": "Félix", "family": "Sardá y Salvany", "initials": "F. S. S."} {"name": "G. J. M.", "given": "G.", "middle": "J.", "family": "M.", "initials": "G. J. M."} {"name": "G. M.", "given": "G.", "family": "M.", "initials": "G. M."} {"name": "G. V. M.", "given": "G.", "middle": "V.", "family": "M.", "initials": "G. V. M."} {"name": "GEORGE SHANAHAN IV", "given": "GEORGE", "family": "SHANAHAN", "suffix": "IV", "initials": "G. S.", "capitalized": "George Shanahan IV"} {"name": "GOMINA MUSA Abɖu-Tɔyib", "order": "FAMILY_FIRST", "given": "MUSA", "middle": "Abɖu-Tɔyib", "family": "GOMINA", "initials": "M. A. G.", "capitalized_force": "Musa Abɖu-Tɔyib Gomina"} {"name": "GRACE SMITH", "given": "GRACE", "family": "SMITH", "initials": "G. S.", "capitalized": "Grace Smith"} {"name": "GREGORY HOUSE M.D.", "given": "GREGORY", "family": "HOUSE", "suffix": "M.D.", "initials": "G. H.", "capitalized": "Gregory House M.D."} {"name": "Gabriel García Márquez", "given": "Gabriel", "middle": "García", "family": "Márquez", "initials": "G. G. M."} {"name": "Gabriel García Márquez Jr.", "given": "Gabriel", "middle": "García", "family": "Márquez", "suffix": "Jr.", "initials": "G. G. M."} {"name": "Gabriel García Márquez, PhD", "given": "Gabriel", "middle": "García", "family": "Márquez", "suffix": "PhD", "initials": "G. G. M."} {"name": "Gabriel Jaime Santamaría Montoya", "given": "Gabriel", "middle": "Jaime Santamaría", "family": "Montoya", "initials": "G. J. S. M."} {"name": "Gabriel Kolko", "given": "Gabriel", "family": "Kolko", "initials": "G. K."} {"name": "Gabriel Latorre Jaramillo", "given": "Gabriel", "middle": "Latorre", "family": "Jaramillo", "initials": "G. L. J."} {"name": "Gabriel Ramos Millán", "given": "Gabriel", "middle": "Ramos", "family": "Millán", "initials": "G. R. M."} {"name": "Gabriela Abad Miró", "given": "Gabriela", "middle": "Abad", "family": "Miró", "initials": "G. A. M."} {"name": "Gal·la Roig", "given": "Gal·la", "family": "Roig", "initials": "G. R.", "capitalized_force": "Gal·La Roig"} {"name": "Gal·la Serra", "given": "Gal·la", "family": "Serra", "initials": "G. S.", "capitalized_force": "Gal·La Serra"} {"name": "Gandásegui y Gorrochátegui, Remigio", "given": "Remigio", "family": "Gandásegui y Gorrochátegui", "initials": "R. G. G."} {"name": "Gaonkar Shanti", "order": "FAMILY_FIRST", "given": "Shanti", "family": "Gaonkar", "initials": "S. G."} {"name": "Gaonkar, Shanti", "given": "Shanti", "family": "Gaonkar", "initials": "S. G."} {"name": "Garaikoetxea Urriza, Juan C.", "given": "Juan", "middle": "C.", "family": "Garaikoetxea Urriza", "initials": "J. C. G. U."} {"name": "Garcia", "given": "Garcia", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "G."} {"name": "Garcia Juan Carlos", "given": "Garcia", "middle": "Juan", "family": "Carlos", "initials": "G. J. C."} {"name": "Garcia Lopez Jr., Juan", "given": "Juan", "family": "Garcia Lopez", "suffix": "Jr.", "initials": "J. G. L."} {"name": "Garcia de Jalon", "given": "Garcia", "family": "de Jalon", "initials": "G. J."} {"name": "Garcia y Lopez", "given": "Garcia", "middle": "y", "family": "Lopez", "initials": "G. y. L."} {"name": "Garcia-Roza, Luiz Alfredo", "given": "Luiz", "middle": "Alfredo", "family": "Garcia-Roza", "initials": "L. A. G."} {"name": "García López, Ana", "given": "Ana", "family": "García López", "initials": "A. G. L."} {"name": "García Márquez Ana María", "given": "García", "middle": "Márquez Ana", "family": "María", "initials": "G. M. A. M."} {"name": "García Márquez Gabriel", "given": "García", "middle": "Márquez", "family": "Gabriel", "initials": "G. M. G."} {"name": "García Márquez, Ed G.J.", "given": "Ed", "middle": "G.J.", "family": "García Márquez", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)", "SUFFIX_OR_NAME: credential as suffix (shape)"], "initials": "E. G. G. M."} {"name": "García Márquez, G.J", "given": "G.J", "family": "García Márquez", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (shape)"], "initials": "G. G. M."} {"name": "García Márquez, G.J.", "given": "G.J.", "family": "García Márquez", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (shape)"], "initials": "G. G. M."} {"name": "García Márquez, G.J.R.", "given": "García", "family": "Márquez", "suffix": "G.J.R.", "initials": "G. M."} {"name": "García Márquez, MJ", "given": "MJ", "family": "García Márquez", "initials": "M. G. M.", "capitalized_force": "Mj García Márquez"} {"name": "García Márquez, MJ JK", "given": "García", "family": "Márquez", "suffix": "MJ JK", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "G. M."} {"name": "García Márquez, MJ PhD", "given": "MJ", "family": "García Márquez", "suffix": "PhD", "initials": "M. G. M.", "capitalized_force": "Mj García Márquez PhD"} {"name": "García Márquez, Ms G.J.", "title": "Ms", "given": "G.J.", "family": "García Márquez", "initials": "G. G. M."} {"name": "García Márquez, PhD G.J.", "given": "García", "family": "Márquez", "suffix": "PhD G.J.", "initials": "G. M."} {"name": "García y Bellido, Antonio", "given": "Antonio", "family": "García y Bellido", "initials": "A. G. B."} {"name": "Gardner, Augustus P.", "given": "Augustus", "middle": "P.", "family": "Gardner", "initials": "A. P. G."} {"name": "Gary Strawsburg, PMP, CSCP, CLSSBB", "given": "Gary", "family": "Strawsburg", "suffix": "PMP, CSCP, CLSSBB", "ambiguities": ["COMMA_STRUCTURE"], "initials": "G. S.", "capitalized_force": "Gary Strawsburg PMP, CSCP, Clssbb"} {"name": "Gebhart GF", "given": "Gebhart", "family": "GF", "initials": "G. G.", "capitalized_force": "Gebhart Gf"} {"name": "Geir Jón Magnús Brynjar Skeggjason", "order": "FAMILY_FIRST", "given": "Jón", "middle": "Magnús Brynjar Skeggjason", "family": "Geir", "initials": "J. M. B. S. G."} {"name": "Geir Jón Magnús Brynjar Skeggjason, þingmaður", "given": "þingmaður", "family": "Geir Jón Magnús Brynjar Skeggjason", "initials": "þ. G. J. M. B. S.", "capitalized_force": "Þingmaður Geir Jón Magnús Brynjar Skeggjason"} {"name": "Geleen und Amsterad, Gottfried Huyn von", "given": "Gottfried", "middle": "Huyn", "family": "von Geleen und Amsterad", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "G. H. G. A."} {"name": "Gemmingen, Pleikard Dietrich von", "given": "Pleikard", "middle": "Dietrich", "family": "von Gemmingen", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "P. D. G."} {"name": "General Major John Smith", "title": "General", "given": "Major", "middle": "John", "family": "Smith", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "M. J. S."} {"name": "General of Minnesota", "title": "General of Minnesota"} {"name": "Geng, Biao", "given": "Biao", "family": "Geng", "initials": "B. G."} {"name": "Georg VI.", "given": "Georg", "suffix": "VI.", "ambiguities": ["GIVEN_OR_FAMILY", "SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)", "GIVEN_OR_FAMILY: name as family (position)"], "initials": "G."} {"name": "Georg von Hülsen-Haeseler", "given": "Georg", "family": "von Hülsen-Haeseler", "initials": "G. H."} {"name": "George D. Cummins", "given": "George", "middle": "D.", "family": "Cummins", "initials": "G. D. C."} {"name": "George Edward Stanhope Molyneux Herbert of Carnarvon", "given": "George", "middle": "Edward Stanhope Molyneux", "family": "Herbert of Carnarvon", "initials": "G. E. S. M. H. C."} {"name": "Gertrude Duby-Blom", "given": "Gertrude", "family": "Duby-Blom", "initials": "G. D."} {"name": "Gerður Benediktsdóttir", "given": "Gerður", "family": "Benediktsdóttir", "initials": "G. B."} {"name": "Gerður Helgadóttir", "given": "Gerður", "family": "Helgadóttir", "initials": "G. H."} {"name": "Gerónimo Baqueiro Foster", "given": "Gerónimo", "middle": "Baqueiro", "family": "Foster", "initials": "G. B. F."} {"name": "Gesang", "given": "Gesang", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "G."} {"name": "Giambologna", "given": "Giambologna", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "G."} {"name": "Gian Carlo Maria Conti", "given": "Gian", "middle": "Carlo Maria", "family": "Conti", "initials": "G. C. M. C."} {"name": "Gian Carlo Ramun Foffa", "given": "Gian", "middle": "Carlo Ramun", "family": "Foffa", "initials": "G. C. R. F."} {"name": "Gianantonio da Sangiorgio", "given": "Gianantonio", "family": "da Sangiorgio", "initials": "G. S."} {"name": "Gienanth, Curt Ludwig von", "given": "Curt", "middle": "Ludwig", "family": "von Gienanth", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "C. L. G."} {"name": "Gilberto Martínez Arango", "given": "Gilberto", "middle": "Martínez", "family": "Arango", "initials": "G. M. A."} {"name": "Gilberto Rodríguez Orejuela", "given": "Gilberto", "middle": "Rodríguez", "family": "Orejuela", "initials": "G. R. O."} {"name": "Gillet", "given": "Gillet", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "G."} {"name": "Gilot Marie-Agnès Suzanne", "order": "FAMILY_FIRST", "given": "Marie-Agnès", "middle": "Suzanne", "family": "Gilot", "initials": "M. S. G."} {"name": "Gilot, Marie-Agnès Suzanne", "given": "Marie-Agnès", "middle": "Suzanne", "family": "Gilot", "initials": "M. S. G."} {"name": "Ginevra Molly Weasley", "given": "Ginevra", "middle": "Molly", "family": "Weasley", "initials": "G. M. W."} {"name": "Giovanni Battista Gherardo d'Arco", "given": "Giovanni", "middle": "Battista Gherardo", "family": "d'Arco", "initials": "G. B. G. d.", "capitalized_force": "Giovanni Battista Gherardo D'Arco"} {"name": "Giuanne Marcu Piras", "given": "Giuanne", "middle": "Marcu", "family": "Piras", "initials": "G. M. P."} {"name": "Giuliano de Medici", "given": "Giuliano", "family": "de Medici", "initials": "G. M."} {"name": "Give me a private config", "given": "Give", "middle": "me a private", "family": "config", "initials": "G. m. a. p. c.", "capitalized_force": "Give Me A Private Config"} {"name": "Giáo sư, Tiến sĩ Ada Cornelia César Martín von Brühl Jr, MD DDS", "given": "Tiến", "middle": "sĩ Ada Cornelia César Martín von Brühl", "family": "Giáo sư", "suffix": "Jr, MD DDS", "initials": "T. s. A. C. C. M. B. G. s.", "capitalized_force": "Tiến Sĩ Ada Cornelia César Martín von Brühl Giáo Sư Jr, MD DDS"} {"name": "Godchaux, Donna Jean", "given": "Donna", "middle": "Jean", "family": "Godchaux", "initials": "D. J. G."} {"name": "Goldschmidt, Neil Edward", "given": "Neil", "middle": "Edward", "family": "Goldschmidt", "initials": "N. E. G."} {"name": "González Gortázar, Fernando", "given": "Fernando", "family": "González Gortázar", "initials": "F. G. G."} {"name": "Gorter, Johannes de", "given": "Johannes", "family": "de Gorter", "initials": "J. G."} {"name": "Gottfried Huyn von Geleen und Amsterad", "given": "Gottfried", "middle": "Huyn", "family": "von Geleen und Amsterad", "initials": "G. H. G. A."} {"name": "Gottfried IV.", "given": "Gottfried", "suffix": "IV.", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "G."} {"name": "Goëß, Johann von", "given": "Johann", "family": "von Goëß", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "J. G."} {"name": "Grace Dean Smith", "given": "Grace", "middle": "Dean", "family": "Smith", "initials": "G. D. S."} {"name": "Grace Smith", "given": "Grace", "family": "Smith", "initials": "G. S."} {"name": "Graf Lambsdorff, Otto", "title": "Graf", "given": "Otto", "family": "Lambsdorff", "initials": "O. L."} {"name": "Gragger, Robert", "given": "Robert", "family": "Gragger", "initials": "R. G."} {"name": "Gramont, Antoine de", "given": "Antoine", "family": "de Gramont", "initials": "A. G."} {"name": "Grand Duke of Tuscany", "given": "Grand", "family": "Duke of Tuscany", "initials": "G. D. T."} {"name": "Grand Rounds", "given": "Grand", "family": "Rounds", "initials": "G. R."} {"name": "Grandmaison, Louis de", "given": "Louis", "family": "de Grandmaison", "initials": "L. G."} {"name": "Granger Hermione", "order": "FAMILY_FIRST", "given": "Hermione", "family": "Granger", "initials": "H. G."} {"name": "Granger, Hermione", "given": "Hermione", "family": "Granger", "initials": "H. G."} {"name": "Gregorio Chillón de la Fuente", "given": "Gregorio", "middle": "Chillón", "family": "de la Fuente", "initials": "G. C. F."} {"name": "Gregorio Modrego Casaus", "given": "Gregorio", "middle": "Modrego", "family": "Casaus", "initials": "G. M. C."} {"name": "Grettir Björnsson", "given": "Grettir", "family": "Björnsson", "initials": "G. B."} {"name": "Greve Anna", "title": "Greve", "family": "Anna", "initials": "A."} {"name": "Greve Smith, Anna", "title": "Greve", "given": "Anna", "family": "Smith", "initials": "A. S."} {"name": "Greve, Anna", "given": "Anna", "family": "Greve", "initials": "A. G."} {"name": "Grouchy, Emmanuel de", "given": "Emmanuel", "family": "de Grouchy", "initials": "E. G."} {"name": "Guardia Neyra, Jaime", "given": "Jaime", "family": "Guardia Neyra", "initials": "J. G. N."} {"name": "Gudina Tumsa", "given": "Gudina", "family": "Tumsa", "initials": "G. T."} {"name": "Guillermo García Cantú", "given": "Guillermo", "middle": "García", "family": "Cantú", "initials": "G. G. C."} {"name": "Guillermo Rousset Banda", "given": "Guillermo", "middle": "Rousset", "family": "Banda", "initials": "G. R. B."} {"name": "Gunnar Benediktsson", "given": "Gunnar", "family": "Benediktsson", "initials": "G. B."} {"name": "Gunnar Gunnarsson", "given": "Gunnar", "family": "Gunnarsson", "initials": "G. G."} {"name": "Gunny de Mesnil", "given": "Gunny", "family": "de Mesnil", "initials": "G. M."} {"name": "Gunter d'Alquen", "given": "Gunter", "family": "d'Alquen", "initials": "G. d.", "capitalized_force": "Gunter D'Alquen"} {"name": "Gurban", "given": "Gurban", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "G."} {"name": "Gurban", "order": "FAMILY_FIRST", "family": "Gurban", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "G."} {"name": "Gurbanow Meretgeldi Durdyýewiç", "order": "FAMILY_FIRST", "given": "Meretgeldi", "middle": "Durdyýewiç", "family": "Gurbanow", "initials": "M. D. G."} {"name": "Gurbanow, Meretgeldi Durdyýewiç", "given": "Meretgeldi", "middle": "Durdyýewiç", "family": "Gurbanow", "initials": "M. D. G."} {"name": "Gurdow Atajan Saparowiç, magistr, ylymlaryň kandidaty", "order": "FAMILY_FIRST", "given": "magistr", "family": "Gurdow Atajan Saparowiç", "suffix": "ylymlaryň kandidaty", "ambiguities": ["COMMA_STRUCTURE"], "initials": "m. G. A. S.", "capitalized_force": "Magistr Gurdow Atajan Saparowiç Ylymlaryň Kandidaty"} {"name": "Gurdow, Atajan Saparowiç", "given": "Atajan", "middle": "Saparowiç", "family": "Gurdow", "initials": "A. S. G."} {"name": "Guru Gobind Singh", "title": "Guru", "given": "Gobind", "family": "Singh", "initials": "G. S."} {"name": "Guru Nanak", "title": "Guru", "given": "Nanak", "initials": "N."} {"name": "Gustavo León González", "given": "Gustavo", "middle": "León", "family": "González", "initials": "G. L. G."} {"name": "Gutiérrez Carreola, Francisco", "given": "Francisco", "family": "Gutiérrez Carreola", "initials": "F. G. C."} {"name": "Guðbjörg Gunnarsdóttir", "given": "Guðbjörg", "family": "Gunnarsdóttir", "initials": "G. G."} {"name": "Guðmunda Elíasdóttir", "given": "Guðmunda", "family": "Elíasdóttir", "initials": "G. E."} {"name": "Guðmundur Böðvarsson", "given": "Guðmundur", "family": "Böðvarsson", "initials": "G. B."} {"name": "Guðmundur Eyjólfsson Geirdal", "given": "Guðmundur", "middle": "Eyjólfsson", "family": "Geirdal", "initials": "G. E. G."} {"name": "Guðmundur Jónsson", "given": "Guðmundur", "family": "Jónsson", "initials": "G. J."} {"name": "Guðmundur Óli Þorláksson", "given": "Guðmundur", "middle": "Óli", "family": "Þorláksson", "initials": "G. Ó. Þ."} {"name": "Guðrún Björnsdóttir", "given": "Guðrún", "family": "Björnsdóttir", "initials": "G. B."} {"name": "Guðrún Eva Mínervudóttir", "given": "Guðrún", "middle": "Eva", "family": "Mínervudóttir", "initials": "G. E. M."} {"name": "Guðrún Helgadóttir", "given": "Guðrún", "family": "Helgadóttir", "initials": "G. H."} {"name": "Guðrún Lárusdóttir", "given": "Guðrún", "family": "Lárusdóttir", "initials": "G. L."} {"name": "Gyani Zail Singh", "given": "Gyani", "middle": "Zail", "family": "Singh", "initials": "G. Z. S."} {"name": "Gylfi Sigurðsson", "given": "Gylfi", "family": "Sigurðsson", "initials": "G. S."} {"name": "Gábor, Andor", "given": "Andor", "family": "Gábor", "initials": "A. G."} {"name": "Gössmann, Elisabeth", "given": "Elisabeth", "family": "Gössmann", "initials": "E. G."} {"name": "H. Budi Santoso", "given": "H.", "middle": "Budi", "family": "Santoso", "initials": "H. B. S."} {"name": "Habib Pacha es-Saad", "given": "Habib", "middle": "Pacha", "family": "es-Saad", "initials": "H. P. e.", "capitalized_force": "Habib Pacha Es-Saad"} {"name": "Hafis al-Assad", "given": "Hafis", "family": "al-Assad", "initials": "H. a."} {"name": "Hagen, Hermann von", "given": "Hermann", "family": "von Hagen", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "H. H."} {"name": "Haider Hān ʿAmū-ūġlī", "given": "Haider", "middle": "Hān", "family": "ʿAmū-ūġlī", "initials": "H. H. ʿ.", "capitalized_force": "Haider Hān ʿAmū-Ūġlī"} {"name": "Hajós, György", "given": "György", "family": "Hajós", "initials": "G. H."} {"name": "Hall, Edward", "given": "Edward", "family": "Hall", "initials": "E. H."} {"name": "Halla Tómasdóttir", "given": "Halla", "family": "Tómasdóttir", "initials": "H. T."} {"name": "Hallbjörg Bjarnadóttir", "given": "Hallbjörg", "family": "Bjarnadóttir", "initials": "H. B."} {"name": "Hamengku Buwono VIIII.", "given": "Hamengku", "middle": "Buwono", "family": "VIIII.", "initials": "H. B. V.", "capitalized_force": "Hamengku Buwono Viiii."} {"name": "Hamisi Ali Juma", "order": "FAMILY_FIRST", "given": "Ali", "middle": "Juma", "family": "Hamisi", "initials": "A. J. H."} {"name": "Hamisi, Ali Juma", "given": "Ali", "middle": "Juma", "family": "Hamisi", "initials": "A. J. H."} {"name": "Hamisi, Salima Farida mwana", "given": "Salima", "middle": "Farida mwana", "family": "Hamisi", "initials": "S. F. m. H.", "capitalized_force": "Salima Farida Mwana Hamisi"} {"name": "Hana Kulina", "given": "Hana", "family": "Kulina", "initials": "H. K."} {"name": "Hani, Motoko", "given": "Motoko", "family": "Hani", "initials": "M. H."} {"name": "Hanka Šołćina", "given": "Hanka", "family": "Šołćina", "initials": "H. Š."} {"name": "Hans Schiltberger", "given": "Hans", "family": "Schiltberger", "initials": "H. S."} {"name": "Hans Walter Klein", "given": "Hans", "middle": "Walter", "family": "Klein", "initials": "H. W. K."} {"name": "Hans von Donat", "given": "Hans", "family": "von Donat", "initials": "H. D."} {"name": "Hans „Erster“ und “Zweiter” Müller", "family": "Hans und Müller", "nickname": "Erster Zweiter", "initials": "H. M."} {"name": "Hans „Hansi“ Müller", "given": "Hans", "family": "Müller", "nickname": "Hansi", "initials": "H. M."} {"name": "Hansen, Anne Sofie Holm", "given": "Anne", "middle": "Sofie Holm", "family": "Hansen", "initials": "A. S. H. H."} {"name": "Hansen, Anne Sofie Holm", "order": "FAMILY_FIRST", "given": "Anne", "middle": "Sofie Holm", "family": "Hansen", "initials": "A. S. H. H."} {"name": "Hansína Regína Björnsdóttir", "given": "Hansína", "middle": "Regína", "family": "Björnsdóttir", "initials": "H. R. B."} {"name": "Harald", "given": "Harald", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "H."} {"name": "Harald", "order": "FAMILY_FIRST", "family": "Harald", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "H."} {"name": "Haraldur Sigurðsson", "given": "Haraldur", "family": "Sigurðsson", "initials": "H. S."} {"name": "Hardie Leannon DVM", "given": "Hardie", "family": "Leannon", "suffix": "DVM", "initials": "H. L."} {"name": "Hardie Leannon V", "given": "Hardie", "family": "Leannon", "suffix": "V", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: initial as family (shape)"], "initials": "H. L."} {"name": "Haren, Willem van", "given": "Willem", "family": "van Haren", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "W. H."} {"name": "Harietta Keopuolani Nahi'ena'ena", "given": "Harietta", "middle": "Keopuolani", "family": "Nahi'ena'ena", "initials": "H. K. N.", "capitalized_force": "Harietta Keopuolani Nahi'Ena'Ena"} {"name": "Harms, Claus", "given": "Claus", "family": "Harms", "initials": "C. H."} {"name": "Harpreet Kaur Dhillon", "given": "Harpreet", "middle": "Kaur", "family": "Dhillon", "initials": "H. K. D."} {"name": "Harrieta Keōpūolani Nāhiʻenaʻena", "given": "Harrieta", "middle": "Keōpūolani", "family": "Nāhiʻenaʻena", "initials": "H. K. N."} {"name": "Hasani Maria", "order": "FAMILY_FIRST", "given": "Maria", "family": "Hasani", "initials": "M. H."} {"name": "Hasani, Maria", "given": "Maria", "family": "Hasani", "initials": "M. H."} {"name": "Hase, Karl August von", "given": "Karl", "middle": "August", "family": "von Hase", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "K. A. H."} {"name": "Hashimoto, Chikanobu", "given": "Chikanobu", "family": "Hashimoto", "initials": "C. H."} {"name": "Hassan", "given": "Hassan", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "H."} {"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, 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."} {"name": "Hecken, Abraham van den", "given": "Abraham", "family": "van den Hecken", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "A. H."} {"name": "Heem, Jan Davidsz. de", "given": "Jan", "middle": "Davidsz.", "family": "de Heem", "initials": "J. D. H."} {"name": "Heemskerck, Egbert van", "given": "Egbert", "family": "van Heemskerck", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "E. H."} {"name": "Heidi Johannes Willemse", "given": "Heidi", "middle": "Johannes", "family": "Willemse", "initials": "H. J. W."} {"name": "Heinrich Cunow", "given": "Heinrich", "family": "Cunow", "initials": "H. C."} {"name": "Heinrich I.", "given": "Heinrich", "family": "I.", "initials": "H. I."} {"name": "Heinz Kägebein", "given": "Heinz", "family": "Kägebein", "initials": "H. K."} {"name": "Heinz von Cramer", "given": "Heinz", "family": "von Cramer", "initials": "H. C."} {"name": "Hempel, Ludwig", "given": "Ludwig", "family": "Hempel", "initials": "L. H."} {"name": "Her Majesty Queen Elizabeth", "title": "Her Majesty Queen", "given": "Elizabeth", "initials": "E."} {"name": "Her Royal Highness Princess Anne", "title": "Her Royal Highness Princess", "given": "Anne", "initials": "A."} {"name": "Her Royal Highness Princess Grand Anne", "title": "Her Royal Highness Princess", "given": "Grand", "family": "Anne", "initials": "G. A."} {"name": "Hera Hilmarsdóttir", "given": "Hera", "family": "Hilmarsdóttir", "initials": "H. H."} {"name": "Hera Hjartardóttir", "given": "Hera", "family": "Hjartardóttir", "initials": "H. H."} {"name": "Herma Studeny", "given": "Herma", "family": "Studeny", "initials": "H. S."} {"name": "Herman de Cunsel", "given": "Herman", "family": "de Cunsel", "initials": "H. C."} {"name": "Hermann I.", "given": "Hermann", "family": "I.", "initials": "H. I."} {"name": "Hermann Martin Popert", "given": "Hermann", "middle": "Martin", "family": "Popert", "initials": "H. M. P."} {"name": "Hermann von Hagen", "given": "Hermann", "family": "von Hagen", "initials": "H. H."} {"name": "Hermann von Kusmanek", "given": "Hermann", "family": "von Kusmanek", "initials": "H. K."} {"name": "Hermann zu Solms-Hohensolms-Lich", "given": "Hermann", "family": "zu Solms-Hohensolms-Lich", "initials": "H. S."} {"name": "Hermansson Lars-Åke Karl", "order": "FAMILY_FIRST", "given": "Lars-Åke", "middle": "Karl", "family": "Hermansson", "initials": "L. K. H."} {"name": "Hermansson, Herr Lars-Åke Karl, Riksdagsledamot", "order": "FAMILY_FIRST", "title": "Herr", "given": "Lars-Åke", "middle": "Karl", "family": "Hermansson", "suffix": "Riksdagsledamot", "ambiguities": ["COMMA_STRUCTURE"], "initials": "L. K. H."} {"name": "Hermione Granger", "given": "Hermione", "family": "Granger", "initials": "H. G."} {"name": "Hernando Calvo Ospina", "given": "Hernando", "middle": "Calvo", "family": "Ospina", "initials": "H. C. O."} {"name": "Herr Klaus Schmidt", "title": "Herr", "given": "Klaus", "family": "Schmidt", "initials": "K. S."} {"name": "Herr Schmidt", "title": "Herr", "family": "Schmidt", "initials": "S."} {"name": "Hilda Luisa Valdemar Lima", "given": "Hilda", "middle": "Luisa Valdemar", "family": "Lima", "initials": "H. L. V. L."} {"name": "Hildebrand, Dietrich von", "given": "Dietrich", "family": "von Hildebrand", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "D. H."} {"name": "Hildebrand, Rudolf Friedrich Leopold von", "given": "Rudolf", "middle": "Friedrich Leopold", "family": "von Hildebrand", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "R. F. L. H."} {"name": "His Excellency Lord Duncan", "title": "His Excellency Lord", "family": "Duncan", "initials": "D."} {"name": "His Grace John Smith", "title": "His Grace", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "His Holiness", "title": "His", "family": "Holiness", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "H."} {"name": "His Holiness the Dalai Lama", "title": "His Holiness the Dalai", "family": "Lama", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "L."} {"name": "His Majesty King Charles", "title": "His Majesty King", "given": "Charles", "initials": "C."} {"name": "Hisham Ibrahim, CPA, CFA", "given": "Hisham", "family": "Ibrahim", "suffix": "CPA, CFA", "initials": "H. I."} {"name": "Hodan Farah Warsame", "given": "Hodan", "middle": "Farah", "family": "Warsame", "initials": "H. F. W."} {"name": "Hon Solo", "title": "Hon", "family": "Solo", "initials": "S."} {"name": "Hon. Barrington P. Doe-Ray, Jr.", "title": "Hon.", "given": "Barrington", "middle": "P.", "family": "Doe-Ray", "suffix": "Jr.", "initials": "B. P. D."} {"name": "Hon. Charles J. Siragusa", "title": "Hon.", "given": "Charles", "middle": "J.", "family": "Siragusa", "initials": "C. J. S."} {"name": "Hon. Marian W. Payson", "title": "Hon.", "given": "Marian", "middle": "W.", "family": "Payson", "initials": "M. W. P."} {"name": "Hon. Wing Lau", "title": "Hon.", "given": "Wing", "family": "Lau", "initials": "W. L."} {"name": "Honorable Judge Susan Russ Walker", "title": "Honorable Judge", "given": "Susan", "middle": "Russ", "family": "Walker", "initials": "S. R. W."} {"name": "Honorable Judge Terry F. Moorer", "title": "Honorable Judge", "given": "Terry", "middle": "F.", "family": "Moorer", "initials": "T. F. M."} {"name": "Honorable Judge W. Harold Albritton, III", "title": "Honorable Judge", "given": "W.", "middle": "Harold", "family": "Albritton", "suffix": "III", "initials": "W. H. A."} {"name": "Honorable Terry F. Moorer", "title": "Honorable", "given": "Terry", "middle": "F.", "family": "Moorer", "initials": "T. F. M."} {"name": "Honorable W. Harold Albritton, III", "title": "Honorable", "given": "W.", "middle": "Harold", "family": "Albritton", "suffix": "III", "initials": "W. H. A."} {"name": "Hooghvorst, Emmanuel d'", "given": "Emmanuel", "family": "d' Hooghvorst", "initials": "E. H."} {"name": "Horacio Gómez Bolaños", "given": "Horacio", "middle": "Gómez", "family": "Bolaños", "initials": "H. G. B."} {"name": "Horst, Jakob", "given": "Jakob", "family": "Horst", "initials": "J. H."} {"name": "Horvat, Irena", "given": "Irena", "family": "Horvat", "initials": "I. H."} {"name": "Horvat, Irena", "order": "FAMILY_FIRST", "given": "Irena", "family": "Horvat", "initials": "I. H."} {"name": "Howard G. Buffett", "given": "Howard", "middle": "G.", "family": "Buffett", "initials": "H. G. B."} {"name": "Hozier", "given": "Hozier", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "H."} {"name": "Hozier", "order": "FAMILY_FIRST", "family": "Hozier", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "H."} {"name": "Hudson, Rock", "title": "Rock", "family": "Hudson", "initials": "H."} {"name": "Hudā aš-Šaʿrāwī", "given": "Hudā", "family": "aš-Šaʿrāwī", "initials": "H. a.", "capitalized_force": "Hudā Aš-Šaʿrāwī"} {"name": "Humbelina", "given": "Humbelina", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "H."} {"name": "Hussein Kamil", "given": "Hussein", "family": "Kamil", "initials": "H. K."} {"name": "Hussein Mariam Geedi Hamish", "order": "FAMILY_FIRST", "given": "Mariam", "middle": "Geedi Hamish", "family": "Hussein", "initials": "M. G. H. H."} {"name": "Hussein Onn", "given": "Hussein", "family": "Onn", "initials": "H. O."} {"name": "Hussein, Mariam Geedi Hamish", "given": "Mariam", "middle": "Geedi Hamish", "family": "Hussein", "initials": "M. G. H. H."} {"name": "Håkon III.", "given": "Håkon", "suffix": "III.", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "H."} {"name": "Héctor Quintana Suárez", "given": "Héctor", "middle": "Quintana", "family": "Suárez", "initials": "H. Q. S."} {"name": "Hülsen-Haeseler, Georg von", "given": "Georg", "family": "von Hülsen-Haeseler", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "G. H."} {"name": "I don", "given": "I", "family": "don", "initials": "I. d.", "capitalized_force": "I Don"} {"name": "II Van Johnson", "given": "II", "family": "Van Johnson", "initials": "I. J.", "capitalized_force": "Ii van Johnson"} {"name": "III van Gogh", "given": "III", "family": "van Gogh", "initials": "I. G.", "capitalized_force": "Iii van Gogh"} {"name": "IVOI, PAUL D'", "given": "PAUL", "family": "D' IVOI", "initials": "P. I.", "capitalized": "Paul d' Ivoi"} {"name": "Ibn-Ṭufail, Muḥammad Ibn-ʿAbd-al-Malik", "given": "Muḥammad", "middle": "Ibn-ʿAbd-al-Malik", "family": "Ibn-Ṭufail", "initials": "M. I. I.", "capitalized_force": "Muḥammad ibn-ʿAbd-al-Malik ibn-Ṭufail"} {"name": "Ibrāhīm Ibn-Sinān", "given": "Ibrāhīm", "family": "Ibn-Sinān", "initials": "I. I.", "capitalized_force": "Ibrāhīm ibn-Sinān"} {"name": "Ibrāhīm al-Yāzīǧī", "given": "Ibrāhīm", "family": "al-Yāzīǧī", "initials": "I. a."} {"name": "Ibrāhīm al-Ǧaʿfarī", "given": "Ibrāhīm", "family": "al-Ǧaʿfarī", "initials": "I. a."} {"name": "Ida Kar", "given": "Ida", "family": "Kar", "initials": "I. K."} {"name": "Idwār al-Ḫarrāṭ", "given": "Idwār", "family": "al-Ḫarrāṭ", "initials": "I. a."} {"name": "Ignacio López Rayón", "given": "Ignacio", "middle": "López", "family": "Rayón", "initials": "I. L. R."} {"name": "Ignacio Rodríguez Bahena", "given": "Ignacio", "middle": "Rodríguez", "family": "Bahena", "initials": "I. R. B."} {"name": "Iiɖa KANSULO", "given": "Iiɖa", "family": "KANSULO", "initials": "I. K.", "capitalized_force": "Iiɖa Kansulo"} {"name": "Ilir Rustemi", "given": "Ilir", "family": "Rustemi", "initials": "I. R."} {"name": "Ilmarinen Seppo", "order": "FAMILY_FIRST", "given": "Seppo", "family": "Ilmarinen", "initials": "S. I."} {"name": "Ilmarinen, Seppo", "given": "Seppo", "family": "Ilmarinen", "initials": "S. I."} {"name": "Imam Bonjol", "title": "Imam", "family": "Bonjol", "initials": "B."} {"name": "Imam Khomeini", "title": "Imam", "family": "Khomeini", "initials": "K."} {"name": "Imanishi, Kinji", "given": "Kinji", "family": "Imanishi", "initials": "K. I."} {"name": "Indalecio Soriano Fuertes", "given": "Indalecio", "middle": "Soriano", "family": "Fuertes", "initials": "I. S. F."} {"name": "Ingrid Andersson", "given": "Ingrid", "family": "Andersson", "initials": "I. A."} {"name": "Ingrid Betancourt Pulecio", "given": "Ingrid", "middle": "Betancourt", "family": "Pulecio", "initials": "I. B. P."} {"name": "Ionescu Adrian Dragos", "order": "FAMILY_FIRST", "given": "Adrian", "middle": "Dragos", "family": "Ionescu", "initials": "A. D. I."} {"name": "Ionescu, Adrian Dragos", "given": "Adrian", "middle": "Dragos", "family": "Ionescu", "initials": "A. D. I."} {"name": "Ippitsusai, Bunchō", "given": "Bunchō", "family": "Ippitsusai", "initials": "B. I."} {"name": "Irena Horvat", "given": "Irena", "family": "Horvat", "initials": "I. H."} {"name": "Irena Kazlauskienė", "given": "Irena", "family": "Kazlauskienė", "initials": "I. K."} {"name": "Irena Marković", "given": "Irena", "family": "Marković", "initials": "I. M."} {"name": "Irena Orel", "given": "Irena", "family": "Orel", "initials": "I. O."} {"name": "Irene Adler", "given": "Irene", "family": "Adler", "initials": "I. A."} {"name": "Irene Aduritz", "given": "Irene", "family": "Aduritz", "initials": "I. A."} {"name": "Irene Andersen", "given": "Irene", "family": "Andersen", "initials": "I. A."} {"name": "Irene Bakker", "given": "Irene", "family": "Bakker", "initials": "I. B."} {"name": "Irene Fabbri", "given": "Irene", "family": "Fabbri", "initials": "I. F."} {"name": "Irene Møller", "given": "Irene", "family": "Møller", "initials": "I. M."} {"name": "Irene Obidike", "given": "Irene", "family": "Obidike", "initials": "I. O."} {"name": "Irene Rossouw", "given": "Irene", "family": "Rossouw", "initials": "I. R."} {"name": "Iriarte, Domingo de", "given": "Domingo", "family": "de Iriarte", "initials": "D. I."} {"name": "Iris Falke", "given": "Iris", "family": "Falke", "initials": "I. F."} {"name": "Irmak Yılmaz", "given": "Irmak", "family": "Yılmaz", "initials": "I. Y."} {"name": "Isala van Diest", "given": "Isala", "family": "van Diest", "initials": "I. D."} {"name": "Ismail Abdul Rahman", "given": "Ismail", "middle": "Abdul", "family": "Rahman", "initials": "I. A. R."} {"name": "Ismail I.", "given": "Ismail", "family": "I.", "initials": "I. I."} {"name": "Ismail Nasiruddin Shah", "given": "Ismail", "middle": "Nasiruddin", "family": "Shah", "initials": "I. N. S."} {"name": "Ismail, Syed Abd Aziz Syed A", "given": "Syed", "middle": "Aziz Syed A", "family": "Ismail", "suffix": "Abd", "initials": "S. A. S. A. I.", "capitalized_force": "Syed Aziz Syed A Ismail ABD"} {"name": "Isseu Awa", "given": "Isseu", "family": "Awa", "initials": "I. A."} {"name": "Iu Alavedra i Ferrer", "given": "Iu", "family": "Alavedra i Ferrer", "initials": "I. A. F."} {"name": "Iustinus II.", "given": "Iustinus", "suffix": "II.", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "I."} {"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 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": "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."} {"name": "Iñaki", "order": "FAMILY_FIRST", "family": "Iñaki", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "I."} {"name": "J A D", "given": "J", "middle": "A", "family": "D", "initials": "J. A. D."} {"name": "J NEE SMITH", "given": "J", "maiden": "SMITH", "initials": "J.", "capitalized": "J (Smith)"} {"name": "J née Smith", "given": "J", "maiden": "Smith", "initials": "J."} {"name": "J-E-P DUPONT", "given": "J-E-P", "family": "DUPONT", "initials": "J. D.", "capitalized": "J-e-P Dupont"} {"name": "J. A. D.", "given": "J.", "middle": "A.", "family": "D.", "initials": "J. A. D."} {"name": "J. None D.", "given": "J.", "middle": "None", "family": "D.", "initials": "J. N. D."} {"name": "J. S. V.", "given": "J.", "middle": "S.", "family": "V.", "initials": "J. S. V."} {"name": "J. Smith", "given": "J.", "family": "Smith", "initials": "J. S."} {"name": "J. Smith Jr.", "given": "J.", "family": "Smith", "suffix": "Jr.", "initials": "J. S."} {"name": "J. Smith Sr.", "given": "J.", "family": "Smith", "suffix": "Sr.", "initials": "J. S."} {"name": "J. Smith, MD", "given": "J.", "family": "Smith", "suffix": "MD", "initials": "J. S."} {"name": "J. Smith, PhD", "given": "J.", "family": "Smith", "suffix": "PhD", "initials": "J. S."} {"name": "J. V.", "given": "J.", "family": "V.", "initials": "J. V."} {"name": "J. née Jones Smith V", "given": "J.", "maiden": "Jones Smith V", "initials": "J."} {"name": "J. née Smith", "given": "J.", "maiden": "Smith", "initials": "J."} {"name": "J. Ó. Pérez", "given": "J.", "middle": "Ó.", "family": "Pérez", "initials": "J. Ó. P."} {"name": "J.A. K.D.", "given": "J.A.", "family": "K.D.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (shape)"], "initials": "J. K."} {"name": "J.MCDONALD SMITH", "given": "J.MCDONALD", "family": "SMITH", "initials": "J. S.", "capitalized": "J.McDonald Smith"} {"name": "J.R. Smith", "given": "J.R.", "family": "Smith", "initials": "J. S."} {"name": "J.R.R. Tolkien", "given": "J.R.R.", "family": "Tolkien", "initials": "J. T."} {"name": "J.Smith", "given": "J.Smith", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "J."} {"name": "J.씨", "given": "J.", "suffix": "씨", "initials": "J."} {"name": "JA KD", "given": "JA", "family": "KD", "initials": "J. K.", "capitalized": "Ja Kd"} {"name": "JACK MA", "given": "JACK", "family": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "J. M.", "capitalized": "Jack Ma"} {"name": "JANE DOE MA R.T.", "given": "JANE", "middle": "DOE MA", "family": "R.T.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (shape)"], "initials": "J. D. M. R.", "capitalized": "Jane Doe Ma R.T."} {"name": "JANE DOE NEE SMITH MA", "given": "JANE", "family": "DOE", "suffix": "MA", "maiden": "SMITH", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as maiden (declared)"], "initials": "J. D.", "capitalized": "Jane Doe (Smith) MA"} {"name": "JANE DOE NEE YO-YO MA", "given": "JANE", "family": "DOE", "suffix": "MA", "maiden": "YO-YO", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as maiden (declared)"], "initials": "J. D.", "capitalized": "Jane Doe (Yo-Yo) MA"} {"name": "JANE YO-YO MA", "given": "JANE", "family": "YO-YO", "suffix": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (declared)"], "initials": "J. Y.", "capitalized": "Jane Yo-Yo MA"} {"name": "JEFFREY (JD) BRICKEN", "given": "JEFFREY", "family": "BRICKEN", "nickname": "JD", "ambiguities": ["SUFFIX_OR_NICKNAME"], "alternatives": ["SUFFIX_OR_NICKNAME: credential as suffix (vocabulary)"], "initials": "J. B.", "capitalized": "Jeffrey \"Jd\" Bricken"} {"name": "JOHN DOE", "given": "JOHN", "family": "DOE", "initials": "J. D.", "capitalized": "John Doe"} {"name": "JOHN DOE PHD", "given": "JOHN", "family": "DOE", "suffix": "PHD", "initials": "J. D.", "capitalized": "John Doe PhD"} {"name": "JOHN DOE PHD MD", "given": "JOHN", "family": "DOE", "suffix": "PHD MD", "initials": "J. D.", "capitalized": "John Doe PhD MD"} {"name": "JOHN E SMITH", "given": "JOHN", "middle": "E", "family": "SMITH", "ambiguities": ["CONJUNCTION_OR_INITIAL"], "alternatives": ["CONJUNCTION_OR_INITIAL: conjunction as middle (vocabulary)"], "initials": "J. E. S.", "capitalized": "John E Smith"} {"name": "JOHN MA PHD", "given": "JOHN", "family": "MA", "suffix": "PHD", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "J. M.", "capitalized": "John Ma PhD"} {"name": "JOHN NEE JONES SMITH MA PHD", "family": "JOHN", "suffix": "PHD", "maiden": "JONES SMITH MA", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "J.", "capitalized": "John (Jones Smith Ma) PhD"} {"name": "JOHN PROF. MA", "title": "PROF.", "given": "JOHN", "family": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "J. M.", "capitalized": "Prof. John Ma"} {"name": "JOHN QUINCY SMITH I", "given": "JOHN", "middle": "QUINCY", "family": "SMITH", "suffix": "I", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: initial as family (shape)"], "initials": "J. Q. S.", "capitalized": "John Quincy Smith I"} {"name": "JOHN SMITH", "given": "JOHN", "family": "SMITH", "initials": "J. S.", "capitalized": "John Smith"} {"name": "JOHN SMITH (M.A)", "given": "JOHN", "family": "SMITH", "suffix": "M.A", "initials": "J. S.", "capitalized": "John Smith M.A"} {"name": "JOHN SMITH (MA)", "given": "JOHN", "family": "SMITH", "nickname": "MA", "ambiguities": ["SUFFIX_OR_NICKNAME"], "alternatives": ["SUFFIX_OR_NICKNAME: credential as suffix (vocabulary)"], "initials": "J. S.", "capitalized": "John \"Ma\" Smith"} {"name": "JOHN SMITH (m.a) (MA)", "given": "JOHN", "family": "SMITH", "suffix": "m.a", "nickname": "MA", "ambiguities": ["SUFFIX_OR_NICKNAME"], "alternatives": ["SUFFIX_OR_NICKNAME: credential as suffix (vocabulary)"], "initials": "J. S.", "capitalized": "John \"Ma\" Smith M.A"} {"name": "JOHN SMITH JR, AD", "given": "AD", "family": "JOHN SMITH", "suffix": "JR", "initials": "A. J. S.", "capitalized": "Ad John Smith Jr"} {"name": "JOHN SMITH MA", "given": "JOHN", "family": "SMITH", "suffix": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (declared)"], "initials": "J. S.", "capitalized": "John Smith MA"} {"name": "JOHN SMITH MA JONES", "given": "JOHN", "middle": "SMITH MA", "family": "JONES", "initials": "J. S. M. J.", "capitalized": "John Smith Ma Jones"} {"name": "JOHN SMITH NEE X X", "given": "JOHN", "family": "SMITH", "maiden": "X X", "initials": "J. S.", "capitalized": "John Smith (X X)"} {"name": "JOHN SMITH PH.D.", "given": "JOHN", "family": "SMITH", "suffix": "PH.D.", "initials": "J. S.", "capitalized": "John Smith Ph.D."} {"name": "JOHN SMITH SAM E.", "given": "JOHN", "family": "SMITH", "suffix": "SAM E.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "J. S.", "capitalized": "John Smith SAM E."} {"name": "JOHN SMITH VI", "given": "JOHN", "family": "SMITH", "suffix": "VI", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "J. S.", "capitalized": "John Smith VI"} {"name": "JOHN SMITH, FNP-BC", "given": "JOHN", "family": "SMITH", "suffix": "FNP-BC", "initials": "J. S.", "capitalized": "John Smith FNP-BC"} {"name": "JOHN SMITH, MA", "given": "JOHN", "family": "SMITH", "suffix": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (declared)"], "initials": "J. S.", "capitalized": "John Smith MA"} {"name": "JOHN SMITH, PU-PH", "given": "PU-PH", "family": "JOHN SMITH", "initials": "P. J. S.", "capitalized": "Pu-Ph John Smith"} {"name": "JOHN SMITH, VD MA", "given": "JOHN", "family": "SMITH", "suffix": "VD MA", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "J. S.", "capitalized": "John Smith VD MA"} {"name": "JOHN V", "given": "JOHN", "family": "V", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: generation as suffix (vocabulary)"], "initials": "J. V.", "capitalized": "John V"} {"name": "JOSE DA SILVA FH", "given": "JOSE", "family": "DA SILVA", "suffix": "FH", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (declared)"], "initials": "J. S.", "capitalized": "Jose da Silva Fh"} {"name": "JOSE E MARIA SANTOS", "given": "JOSE", "middle": "E MARIA", "family": "SANTOS", "ambiguities": ["CONJUNCTION_OR_INITIAL"], "alternatives": ["CONJUNCTION_OR_INITIAL: conjunction as given (vocabulary)"], "initials": "J. E. M. S.", "capitalized": "Jose E Maria Santos"} {"name": "JOSE ORTEGA-Y-GASSET", "given": "JOSE", "family": "ORTEGA-Y-GASSET", "initials": "J. O.", "capitalized": "Jose Ortega-y-Gasset"} {"name": "JOSEP CAROD I ROVIRA", "given": "JOSEP", "middle": "CAROD I", "family": "ROVIRA", "ambiguities": ["CONJUNCTION_OR_INITIAL"], "alternatives": ["CONJUNCTION_OR_INITIAL: conjunction as family (vocabulary)"], "initials": "J. C. I. R.", "capitalized": "Josep Carod I Rovira"} {"name": "JOSEP LLUIS CAROD I ROVIRA", "given": "JOSEP", "middle": "LLUIS CAROD I", "family": "ROVIRA", "ambiguities": ["CONJUNCTION_OR_INITIAL"], "alternatives": ["CONJUNCTION_OR_INITIAL: conjunction as family (vocabulary)"], "initials": "J. L. C. I. R.", "capitalized": "Josep Lluis Carod I Rovira"} {"name": "JOSÉ GARCÍA", "given": "JOSÉ", "family": "GARCÍA", "initials": "J. G.", "capitalized": "José García"} {"name": "JOSÉ GARCÍA", "given": "JOSÉ", "family": "GARCÍA", "initials": "J. G.", "capitalized": "José García"} {"name": "JUAN DELA CRUZ", "given": "JUAN", "family": "DELA CRUZ", "initials": "J. C.", "capitalized": "Juan dela Cruz"} {"name": "JUAN GARCIA Jr.", "given": "JUAN", "family": "GARCIA", "suffix": "Jr.", "initials": "J. G.", "capitalized": "Juan Garcia Jr."} {"name": "JUAN GARCIA Y LOPEZ", "given": "JUAN", "family": "GARCIA Y LOPEZ", "initials": "J. G. L.", "capitalized": "Juan Garcia y Lopez"} {"name": "JUAN Y GARCIA", "given": "JUAN", "middle": "Y", "family": "GARCIA", "initials": "J. Y. G.", "capitalized": "Juan y Garcia"} {"name": "Jaak Eerik Kask", "given": "Jaak", "middle": "Eerik", "family": "Kask", "initials": "J. E. K."} {"name": "Jacinto López Moreno", "given": "Jacinto", "middle": "López", "family": "Moreno", "initials": "J. L. M."} {"name": "Jack", "given": "Jack", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "J."} {"name": "Jack", "order": "FAMILY_FIRST", "family": "Jack", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "J."} {"name": "Jack (M.A)", "family": "Jack", "suffix": "M.A", "initials": "J."} {"name": "Jack (MA)", "family": "Jack", "suffix": "MA", "initials": "J."} {"name": "Jack I-V", "given": "Jack", "family": "I-V", "initials": "J. I."} {"name": "Jack I.-V.", "given": "Jack", "family": "I.-V.", "initials": "J. I."} {"name": "Jack M.A.", "given": "Jack", "suffix": "M.A.", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "J."} {"name": "Jack MA", "given": "Jack", "suffix": "MA", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "J."} {"name": "Jack MA.", "given": "Jack", "suffix": "MA.", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "J."} {"name": "Jack Ma", "given": "Jack", "family": "Ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "J. M."} {"name": "Jack Ma Jr", "given": "Jack", "family": "Ma", "suffix": "Jr", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "J. M."} {"name": "Jack Ma.", "given": "Jack", "family": "Ma.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "J. M."} {"name": "Jack PHARM D", "given": "Jack", "middle": "PHARM", "family": "D", "initials": "J. P. D.", "capitalized_force": "Jack Pharm D"} {"name": "Jack Wei Ma", "given": "Jack", "middle": "Wei", "family": "Ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "J. W. M."} {"name": "Jack X.Y.I.", "given": "Jack", "family": "X.Y.I.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (shape)"], "initials": "J. X."} {"name": "Jack X.Y.Z.", "given": "Jack", "family": "X.Y.Z.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (shape)"], "initials": "J. X."} {"name": "Jack XY.ZW.", "given": "Jack", "family": "XY.ZW.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (shape)"], "initials": "J. X.", "capitalized_force": "Jack Xy.Zw."} {"name": "Jackson de Figueiredo", "given": "Jackson", "family": "de Figueiredo", "initials": "J. F."} {"name": "Jacobsen Christopher Hans", "order": "FAMILY_FIRST", "given": "Christopher", "middle": "Hans", "family": "Jacobsen", "initials": "C. H. J."} {"name": "Jacobsen, Christopher Hans", "given": "Christopher", "middle": "Hans", "family": "Jacobsen", "initials": "C. H. J."} {"name": "Jacqueline Beauchêne", "given": "Jacqueline", "family": "Beauchêne", "initials": "J. B."} {"name": "Jaime Blanco García", "given": "Jaime", "middle": "Blanco", "family": "García", "initials": "J. B. G."} {"name": "Jaime García Terrés", "given": "Jaime", "middle": "García", "family": "Terrés", "initials": "J. G. T."} {"name": "Jaime Guardia Neyra", "given": "Jaime", "middle": "Guardia", "family": "Neyra", "initials": "J. G. N."} {"name": "Jaime Mayor Oreja", "given": "Jaime", "middle": "Mayor", "family": "Oreja", "initials": "J. M. O."} {"name": "Jakob Horst", "given": "Jakob", "family": "Horst", "initials": "J. H."} {"name": "Jakob von Uexküll", "given": "Jakob", "family": "von Uexküll", "initials": "J. U."} {"name": "James Muspratt", "given": "James", "family": "Muspratt", "initials": "J. M."} {"name": "Jan Beno Krawc", "given": "Jan", "middle": "Beno", "family": "Krawc", "initials": "J. B. K."} {"name": "Jan Davidsz. de Heem", "given": "Jan", "middle": "Davidsz.", "family": "de Heem", "initials": "J. D. H."} {"name": "Jan Kees de Jager", "given": "Jan", "middle": "Kees", "family": "de Jager", "initials": "J. K. J."} {"name": "Jan Koos Van der Merwe", "given": "Jan", "middle": "Koos", "family": "Van der Merwe", "initials": "J. K. M.", "capitalized_force": "Jan Koos van der Merwe"} {"name": "Jan Měto Šejc", "given": "Jan", "middle": "Měto", "family": "Šejc", "initials": "J. M. Š."} {"name": "Jan Stephan von Calcar", "given": "Jan", "middle": "Stephan", "family": "von Calcar", "initials": "J. S. C."} {"name": "Jan van Dijk", "given": "Jan", "family": "van Dijk", "initials": "J. D."} {"name": "Jan van Eyck", "given": "Jan", "family": "van Eyck", "initials": "J. E."} {"name": "Jan van Munster", "given": "Jan", "family": "van Munster", "initials": "J. M."} {"name": "Jana Kováčová", "given": "Jana", "family": "Kováčová", "initials": "J. K."} {"name": "Jana Nováková", "given": "Jana", "family": "Nováková", "initials": "J. N."} {"name": "Jane (Janey, Jo) Smith", "given": "Jane", "family": "Smith", "nickname": "Janey, Jo", "initials": "J. S."} {"name": "Jane (née , Jones) Smith", "given": "Jane", "family": "Smith", "nickname": "née , Jones", "initials": "J. S.", "capitalized_force": "Jane \"Née, Jones\" Smith"} {"name": "Jane (née ,Jones) Smith", "given": "Jane", "family": "Smith", "nickname": "née ,Jones", "initials": "J. S.", "capitalized_force": "Jane \"Née,Jones\" Smith"} {"name": "Jane (née Jones) Smith", "given": "Jane", "family": "Smith", "maiden": "Jones", "initials": "J. S."} {"name": "Jane (née​Jones) Smith", "given": "Jane", "family": "Smith", "nickname": "née Jones", "initials": "J. S.", "capitalized_force": "Jane \"Née Jones\" Smith"} {"name": "Jane Doctor", "given": "Jane", "family": "Doctor", "initials": "J. D."} {"name": "Jane Doe", "given": "Jane", "family": "Doe", "initials": "J. D."} {"name": "Jane Doe (nee Smith MA)", "given": "Jane", "family": "Doe", "maiden": "Smith MA", "initials": "J. D.", "capitalized_force": "Jane Doe (Smith Ma)"} {"name": "Jane Doe (nee Smith Ma)", "given": "Jane", "family": "Doe", "maiden": "Smith Ma", "initials": "J. D."} {"name": "Jane Doe (nee Smith Prof.)", "title": "Prof.", "given": "Jane", "family": "Doe", "maiden": "Smith", "initials": "J. D."} {"name": "Jane Doe (nee Smith) MA", "given": "Jane", "family": "Doe", "suffix": "MA", "maiden": "Smith", "initials": "J. D."} {"name": "Jane Doe Dr. Ph. D.", "title": "Dr.", "given": "Jane", "family": "Doe", "suffix": "Ph. D.", "initials": "J. D."} {"name": "Jane Doe Jr. nee Smith", "given": "Jane", "family": "Doe", "suffix": "Jr. nee Smith", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)", "SUFFIX_OR_NAME: name as family (position)"], "initials": "J. D.", "capitalized_force": "Jane Doe Jr. Nee Smith"} {"name": "Jane Doe Jr. nee Smith Ma", "given": "Jane", "family": "Doe", "suffix": "Jr. nee Smith Ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)", "SUFFIX_OR_NAME: name as family (position)", "SUFFIX_OR_NAME: name as family (declared)"], "initials": "J. D.", "capitalized_force": "Jane Doe Jr. Nee Smith MA"} {"name": "Jane Doe MA R.T.", "given": "Jane", "family": "Doe", "suffix": "MA R.T.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "J. D."} {"name": "Jane Doe Ma R.T.", "given": "Jane", "middle": "Doe Ma", "family": "R.T.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (shape)"], "initials": "J. D. M. R."} {"name": "Jane Doe Mrs", "given": "Jane", "middle": "Doe", "family": "Mrs", "initials": "J. D. M."} {"name": "Jane Doe PhD nee Smith", "given": "Jane", "family": "Doe", "suffix": "PhD nee Smith", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)", "SUFFIX_OR_NAME: name as family (position)"], "initials": "J. D.", "capitalized_force": "Jane Doe PhD Nee Smith"} {"name": "Jane Doe de D Junior", "given": "Jane", "middle": "Doe", "family": "de D", "suffix": "Junior", "initials": "J. D. D."} {"name": "Jane Doe geb. Smith MA", "given": "Jane", "family": "Doe", "suffix": "MA", "maiden": "Smith", "initials": "J. D."} {"name": "Jane Doe nee MA", "given": "Jane", "family": "Doe", "maiden": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "J. D.", "capitalized_force": "Jane Doe (Ma)"} {"name": "Jane Doe nee MA PhD", "given": "Jane", "family": "Doe", "suffix": "PhD", "maiden": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "J. D.", "capitalized_force": "Jane Doe (Ma) PhD"} {"name": "Jane Doe nee MA Smith", "given": "Jane", "family": "Doe", "maiden": "MA Smith", "initials": "J. D.", "capitalized_force": "Jane Doe (Ma Smith)"} {"name": "Jane Doe nee Pope.", "given": "Jane", "family": "Doe", "maiden": "Pope.", "initials": "J. D."} {"name": "Jane Doe nee Prof. Dr.", "title": "Dr.", "given": "Jane", "family": "Doe", "maiden": "Prof.", "initials": "J. D."} {"name": "Jane Doe nee Puig i", "given": "Jane", "family": "Doe", "suffix": "i", "maiden": "Puig", "initials": "J. D.", "capitalized_force": "Jane Doe (Puig) I"} {"name": "Jane Doe nee Puig i III", "given": "Jane", "family": "Doe", "suffix": "i III", "maiden": "Puig", "initials": "J. D.", "capitalized_force": "Jane Doe (Puig) I III"} {"name": "Jane Doe nee Puig i Soler", "given": "Jane", "family": "Doe", "maiden": "Puig i Soler", "initials": "J. D."} {"name": "Jane Doe nee Smith 2", "given": "Jane", "family": "Doe", "suffix": "2", "maiden": "Smith", "initials": "J. D."} {"name": "Jane Doe nee Smith B.K.", "given": "Jane", "family": "Doe", "maiden": "Smith B.K.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (shape)"], "initials": "J. D."} {"name": "Jane Doe nee Smith DO DO", "given": "Jane", "family": "Doe", "suffix": "DO DO", "maiden": "Smith", "initials": "J. D."} {"name": "Jane Doe nee Smith MA", "given": "Jane", "family": "Doe", "suffix": "MA", "maiden": "Smith", "initials": "J. D."} {"name": "Jane Doe nee Smith MA PhD", "given": "Jane", "family": "Doe", "suffix": "MA PhD", "maiden": "Smith", "initials": "J. D."} {"name": "Jane Doe nee Smith MA Prof.", "title": "Prof.", "given": "Jane", "family": "Doe", "suffix": "MA", "maiden": "Smith", "initials": "J. D."} {"name": "Jane Doe nee Smith MA y", "given": "Jane", "family": "Doe", "suffix": "MA y", "maiden": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: initial as maiden (shape)"], "initials": "J. D."} {"name": "Jane Doe nee Smith Ma", "given": "Jane", "family": "Doe", "maiden": "Smith Ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "J. D."} {"name": "Jane Doe nee Smith Ma JD", "given": "Jane", "family": "Doe", "suffix": "JD", "maiden": "Smith Ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "J. D."} {"name": "Jane Doe nee Smith Ma Prof.", "title": "Prof.", "given": "Jane", "family": "Doe", "maiden": "Smith Ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "J. D."} {"name": "Jane Doe nee Smith Ma jd", "given": "Jane", "family": "Doe", "suffix": "jd", "maiden": "Smith Ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as maiden (declared)", "SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "J. D.", "capitalized_force": "Jane Doe (Smith Ma) JD"} {"name": "Jane Doe nee Smith Ma y", "given": "Jane", "family": "Doe", "maiden": "Smith Ma y", "initials": "J. D."} {"name": "Jane Doe nee Smith PhD MA", "given": "Jane", "family": "Doe", "suffix": "PhD MA", "maiden": "Smith", "initials": "J. D."} {"name": "Jane Doe nee Smith PhD MEng", "given": "Jane", "family": "Doe", "suffix": "PhD MEng", "maiden": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as maiden (declared)"], "initials": "J. D.", "capitalized_force": "Jane Doe (Smith) PhD MENG"} {"name": "Jane Doe nee Smith PhD Smith", "given": "Jane", "family": "Doe", "suffix": "PhD Smith", "maiden": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as maiden (position)"], "initials": "J. D."} {"name": "Jane Doe nee Smith Prof.", "title": "Prof.", "given": "Jane", "family": "Doe", "maiden": "Smith", "initials": "J. D."} {"name": "Jane Doe nee Smith Prof. MA", "title": "Prof.", "given": "Jane", "family": "Doe", "suffix": "MA", "maiden": "Smith", "initials": "J. D."} {"name": "Jane Doe nee Smith V", "given": "Jane", "family": "Doe", "suffix": "V", "maiden": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: initial as maiden (shape)"], "initials": "J. D."} {"name": "Jane Doe nee Smith V MA", "given": "Jane", "family": "Doe", "suffix": "MA", "maiden": "Smith V", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: generation as suffix (vocabulary)"], "initials": "J. D."} {"name": "Jane Doe nee Smith V PhD", "given": "Jane", "family": "Doe", "suffix": "PhD", "maiden": "Smith V", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: generation as suffix (vocabulary)"], "initials": "J. D."} {"name": "Jane Doe nee Smith V Prof.", "title": "Prof.", "given": "Jane", "family": "Doe", "suffix": "V", "maiden": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: initial as maiden (shape)"], "initials": "J. D."} {"name": "Jane Doe nee Smith VI PhD", "given": "Jane", "family": "Doe", "suffix": "VI PhD", "maiden": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as maiden (position)"], "initials": "J. D."} {"name": "Jane Doe nee Smith VI Prof.", "title": "Prof.", "given": "Jane", "family": "Doe", "suffix": "VI", "maiden": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as maiden (position)"], "initials": "J. D."} {"name": "Jane Doe nee Smith X.Y.Z.", "given": "Jane", "family": "Doe", "suffix": "X.Y.Z.", "maiden": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as maiden (position)"], "initials": "J. D."} {"name": "Jane Doe nee Smith XYZ", "given": "Jane", "family": "Doe", "maiden": "Smith XYZ", "initials": "J. D.", "capitalized_force": "Jane Doe (Smith Xyz)"} {"name": "Jane Doe nee Smith do", "given": "Jane", "family": "Doe", "suffix": "do", "maiden": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as maiden (declared)"], "initials": "J. D.", "capitalized_force": "Jane Doe (Smith) DO"} {"name": "Jane Doe nee Smith ma jd", "given": "Jane", "family": "Doe", "suffix": "ma jd", "maiden": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as maiden (declared)", "SUFFIX_OR_NAME: name as maiden (declared)"], "initials": "J. D.", "capitalized_force": "Jane Doe (Smith) MA JD"} {"name": "Jane Doe nee Yo-Yo Ma", "given": "Jane", "family": "Doe", "maiden": "Yo-Yo Ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "J. D."} {"name": "Jane Doe née Jones Smith V", "given": "Jane", "family": "Doe", "suffix": "V", "maiden": "Jones Smith", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: initial as maiden (shape)"], "initials": "J. D."} {"name": "Jane Doe, MS LAc", "given": "Jane", "family": "Doe", "suffix": "MS LAc", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "J. D.", "capitalized_force": "Jane Doe MS LAC"} {"name": "Jane Mac Beth", "given": "Jane", "family": "Mac Beth", "initials": "J. B.", "capitalized_force": "Jane mac Beth"} {"name": "Jane Smith", "given": "Jane", "family": "Smith", "initials": "J. S."} {"name": "Jane Smith (Jones)", "given": "Jane", "family": "Smith", "nickname": "Jones", "initials": "J. S."} {"name": "Jane Smith (Nee)", "given": "Jane", "family": "Smith", "nickname": "Nee", "initials": "J. S."} {"name": "Jane Smith (Nee) (Jones)", "given": "Jane", "family": "Smith", "nickname": "Nee Jones", "initials": "J. S."} {"name": "Jane Smith (née Jones)", "given": "Jane", "family": "Smith", "maiden": "Jones", "initials": "J. S."} {"name": "Jane Smith (née Jr.)", "given": "Jane", "middle": "Smith", "family": "née", "suffix": "Jr.", "initials": "J. S. n.", "capitalized_force": "Jane Smith Née Jr."} {"name": "Jane Smith (née PhD)", "given": "Jane", "family": "Smith", "maiden": "PhD", "initials": "J. S."} {"name": "Jane Smith (née V)", "given": "Jane", "family": "Smith", "maiden": "V", "initials": "J. S."} {"name": "Jane Smith (née)", "given": "Jane", "family": "Smith", "nickname": "née", "initials": "J. S.", "capitalized_force": "Jane \"Née\" Smith"} {"name": "Jane Smith Vice.", "title": "Vice.", "given": "Jane", "family": "Smith", "initials": "J. S."} {"name": "Jane Smith born Jones", "given": "Jane", "middle": "Smith born", "family": "Jones", "initials": "J. S. b. J.", "capitalized_force": "Jane Smith Born Jones"} {"name": "Jane Smith née Jones", "given": "Jane", "family": "Smith", "maiden": "Jones", "initials": "J. S."} {"name": "Jane Smith née Jones PhD", "given": "Jane", "family": "Smith", "suffix": "PhD", "maiden": "Jones", "initials": "J. S."} {"name": "Jane Smith née PhD", "given": "Jane", "middle": "Smith", "family": "née", "suffix": "PhD", "initials": "J. S. n.", "capitalized_force": "Jane Smith Née PhD"} {"name": "Jane Smith née V", "given": "Jane", "family": "Smith", "maiden": "V", "initials": "J. S."} {"name": "Jane Smith, née Jones", "given": "née", "middle": "Jones", "family": "Jane Smith", "initials": "n. J. J. S.", "capitalized_force": "Née Jones Jane Smith"} {"name": "Jane and née Jones", "given": "Jane and née", "family": "Jones", "initials": "J. n. J.", "capitalized_force": "Jane and Née Jones"} {"name": "Jane de la née Jones", "given": "Jane", "family": "de la", "maiden": "Jones", "initials": "J. d. l.", "capitalized_force": "Jane De La (Jones)"} {"name": "Jane nee Smith Jr., AD", "given": "AD", "family": "Jane", "suffix": "Jr.", "maiden": "Smith", "initials": "A. J.", "capitalized_force": "Ad Jane (Smith) Jr."} {"name": "Jane née Jones Smith", "family": "Jane", "maiden": "Jones Smith", "initials": "J."} {"name": "Jane née Jr y Jones", "given": "Jane", "middle": "née", "family": "Jr y Jones", "initials": "J. n. J. J.", "capitalized_force": "Jane Née Jr y Jones"} {"name": "Jane née and Jones Smith", "family": "Jane", "maiden": "and Jones Smith", "initials": "J."} {"name": "Jane van der Berg nee Prof. Pope. MA", "title": "Pope.", "given": "Jane", "family": "van der Berg", "suffix": "MA", "maiden": "Prof.", "initials": "J. B."} {"name": "Jane van der Berg nee Smith Prof.", "title": "Prof.", "given": "Jane", "family": "van der Berg", "maiden": "Smith", "initials": "J. B."} {"name": "Jane van der Berg née", "given": "Jane", "family": "van der Berg née", "initials": "J. B. n.", "capitalized_force": "Jane van der Berg Née"} {"name": "Jane van der Berg née Jones", "given": "Jane", "family": "van der Berg", "maiden": "Jones", "initials": "J. B."} {"name": "Jane van der Berg née Jr Jones", "given": "Jane", "family": "van der Berg née", "suffix": "Jr Jones", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "J. B. n.", "capitalized_force": "Jane van der Berg Née Jr Jones"} {"name": "Jane van der Berg née PhD", "given": "Jane", "family": "van der Berg née", "suffix": "PhD", "initials": "J. B. n.", "capitalized_force": "Jane van der Berg Née PhD"} {"name": "Jane van der Berg née y Jones", "given": "Jane", "family": "van der Berg", "maiden": "y Jones", "initials": "J. B."} {"name": "Jane van der Berg 旧姓 Jones", "given": "Jane", "family": "van der Berg", "maiden": "Jones", "initials": "J. B."} {"name": "Jane „JD Smith", "given": "Jane", "middle": "„JD", "family": "Smith", "ambiguities": ["UNBALANCED_DELIMITER"], "initials": "J. „. S.", "capitalized_force": "Jane „Jd Smith"} {"name": "Janey née Jones", "family": "Janey", "maiden": "Jones", "initials": "J."} {"name": "Jardin, Aldephonse Alexandre Félix du", "given": "Aldephonse", "middle": "Alexandre Félix", "family": "du Jardin", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "A. A. F. J."} {"name": "Jaume Berenguer i Amenós", "given": "Jaume", "family": "Berenguer i Amenós", "initials": "J. B. A."} {"name": "Jaume Martí i Cabot", "given": "Jaume", "family": "Martí i Cabot", "initials": "J. M. C."} {"name": "Javier López Díaz", "given": "Javier", "middle": "López", "family": "Díaz", "initials": "J. L. D."} {"name": "Jean 'JD' Smith", "given": "Jean", "family": "Smith", "nickname": "JD", "ambiguities": ["SUFFIX_OR_NICKNAME"], "alternatives": ["SUFFIX_OR_NICKNAME: credential as suffix (vocabulary)"], "initials": "J. S.", "capitalized_force": "Jean \"Jd\" Smith"} {"name": "Jean (M.A) Smith", "given": "Jean", "family": "Smith", "nickname": "M.A", "ambiguities": ["SUFFIX_OR_NICKNAME"], "alternatives": ["SUFFIX_OR_NICKNAME: credential as suffix (vocabulary)"], "initials": "J. S."} {"name": "Jean DUPONT", "given": "Jean", "family": "DUPONT", "initials": "J. D.", "capitalized_force": "Jean Dupont"} {"name": "Jean Joseph Antoine Marie de Witte", "given": "Jean", "middle": "Joseph Antoine Marie", "family": "de Witte", "initials": "J. J. A. M. W."} {"name": "Jean Louis Charles d'Orléans-Longueville", "given": "Jean", "middle": "Louis Charles", "family": "d'Orléans-Longueville", "initials": "J. L. C. d.", "capitalized_force": "Jean Louis Charles D'Orléans-Longueville"} {"name": "Jean Marie Fils", "given": "Jean", "middle": "Marie", "family": "Fils", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: generation as suffix (vocabulary)"], "initials": "J. M. F."} {"name": "Jean Pierre DUPONT", "given": "Jean", "middle": "Pierre", "family": "DUPONT", "initials": "J. P. D.", "capitalized_force": "Jean Pierre Dupont"} {"name": "Jean Pierre DUPONT-MORETTI", "given": "Jean", "middle": "Pierre", "family": "DUPONT-MORETTI", "initials": "J. P. D.", "capitalized_force": "Jean Pierre Dupont-Moretti"} {"name": "Jean Pierre MA DUPONT", "given": "Jean", "middle": "Pierre MA", "family": "DUPONT", "initials": "J. P. M. D.", "capitalized_force": "Jean Pierre Ma Dupont"} {"name": "Jean St Clair", "given": "Jean", "family": "St Clair", "initials": "J. C.", "capitalized_force": "Jean st Clair"} {"name": "Jean de Beaumanoir de Lavardin", "given": "Jean", "middle": "de Beaumanoir", "family": "de Lavardin", "initials": "J. B. L."} {"name": "Jean de Mesnil", "given": "Jean", "family": "de Mesnil", "initials": "J. M."} {"name": "Jean de Montereul", "given": "Jean", "family": "de Montereul", "initials": "J. M."} {"name": "Jean «Petit» Dupont", "given": "Jean", "family": "Dupont", "nickname": "Petit", "initials": "J. D."} {"name": "Jean-Dominique de Cassini", "given": "Jean-Dominique", "family": "de Cassini", "initials": "J. C."} {"name": "Jean-François Lefebvre de La Barre", "given": "Jean-François", "middle": "Lefebvre", "family": "de La Barre", "initials": "J. L. B.", "capitalized_force": "Jean-François Lefebvre de la Barre"} {"name": "Jeffrey (JD) Bricken", "given": "Jeffrey", "family": "Bricken", "nickname": "JD", "ambiguities": ["SUFFIX_OR_NICKNAME"], "alternatives": ["SUFFIX_OR_NICKNAME: credential as suffix (vocabulary)"], "initials": "J. B.", "capitalized_force": "Jeffrey \"Jd\" Bricken"} {"name": "Jenny \"JJ\" Baker (Johnson)", "given": "Jenny", "family": "Baker", "nickname": "JJ Johnson", "initials": "J. B.", "capitalized_force": "Jenny \"Jj Johnson\" Baker"} {"name": "Jenny (Johnson) Baker", "given": "Jenny", "family": "Baker", "nickname": "Johnson", "initials": "J. B."} {"name": "Jenny Baker", "given": "Jenny", "family": "Baker", "initials": "J. B."} {"name": "Jenny Baker (Johnson)", "given": "Jenny", "family": "Baker", "nickname": "Johnson", "initials": "J. B."} {"name": "Jesús Alcolea Fernández", "given": "Jesús", "middle": "Alcolea", "family": "Fernández", "initials": "J. A. F."} {"name": "Jesús Alcántara Miranda", "given": "Jesús", "middle": "Alcántara", "family": "Miranda", "initials": "J. A. M."} {"name": "Jesús Gallego Marquina", "given": "Jesús", "middle": "Gallego", "family": "Marquina", "initials": "J. G. M."} {"name": "Jesús Huguet i Serrano", "given": "Jesús", "family": "Huguet i Serrano", "initials": "J. H. S."} {"name": "Jesús María Echavarría y Aguirre", "given": "Jesús", "middle": "María", "family": "Echavarría y Aguirre", "initials": "J. M. E. A."} {"name": "Jesús Ángel González Arias", "given": "Jesús", "middle": "Ángel González", "family": "Arias", "initials": "J. Á. G. A."} {"name": "Jill St. John", "given": "Jill", "family": "St. John", "initials": "J. J.", "capitalized_force": "Jill st. John"} {"name": "Jiří", "given": "Jiří", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "J."} {"name": "Jiří", "order": "FAMILY_FIRST", "family": "Jiří", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "J."} {"name": "Jiří Dienstbier ml.", "given": "Jiří", "middle": "Dienstbier", "family": "ml.", "initials": "J. D. m.", "capitalized_force": "Jiří Dienstbier Ml."} {"name": "Jo Ann", "given": "Jo", "family": "Ann", "initials": "J. A."} {"name": "Joan Antoni Desvalls i d’Ardena", "given": "Joan", "middle": "Antoni", "family": "Desvalls i d’Ardena", "initials": "J. A. D. d.", "capitalized_force": "Joan Antoni Desvalls i D’Ardena"} {"name": "Joan Canals i Pigem", "given": "Joan", "family": "Canals i Pigem", "initials": "J. C. P."} {"name": "Joao da Silva Do Amaral de Souza", "given": "Joao", "middle": "da Silva Do Amaral", "family": "de Souza", "initials": "J. S. A. S.", "capitalized_force": "Joao da Silva do Amaral de Souza"} {"name": "Joao da Silva do Amaral de Souza", "given": "Joao", "middle": "da Silva do Amaral", "family": "de Souza", "initials": "J. S. A. S."} {"name": "Joaquín Arasanz Raso", "given": "Joaquín", "middle": "Arasanz", "family": "Raso", "initials": "J. A. R."} {"name": "Joaquín García Icazbalceta", "given": "Joaquín", "middle": "García", "family": "Icazbalceta", "initials": "J. G. I."} {"name": "Joaquín Guzmán Loera", "given": "Joaquín", "middle": "Guzmán", "family": "Loera", "initials": "J. G. L."} {"name": "Joaquín Odriozola y Grimaud", "given": "Joaquín", "family": "Odriozola y Grimaud", "initials": "J. O. G."} {"name": "Joaquín Torres García", "given": "Joaquín", "middle": "Torres", "family": "García", "initials": "J. T. G."} {"name": "Joaquín Traggia Uribarri", "given": "Joaquín", "middle": "Traggia", "family": "Uribarri", "initials": "J. T. U."} {"name": "Joaquín Vergara Cárdenas", "given": "Joaquín", "middle": "Vergara", "family": "Cárdenas", "initials": "J. V. C."} {"name": "Job", "given": "Job", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "J."} {"name": "Jodocus van Winghe", "given": "Jodocus", "family": "van Winghe", "initials": "J. W."} {"name": "Joe Dentist D.D.S.", "given": "Joe", "family": "Dentist", "suffix": "D.D.S.", "initials": "J. D."} {"name": "Joe E. Smith", "given": "Joe", "middle": "E.", "family": "Smith", "initials": "J. E. S."} {"name": "Joe Franklin Jr", "given": "Joe", "family": "Franklin", "suffix": "Jr", "initials": "J. F."} {"name": "Joe Smith", "given": "Joe", "family": "Smith", "initials": "J. S."} {"name": "Johan Harald Wallin", "given": "Johan", "middle": "Harald", "family": "Wallin", "initials": "J. H. W."} {"name": "Johann Adolf von Krosigk", "given": "Johann", "middle": "Adolf", "family": "von Krosigk", "initials": "J. A. K."} {"name": "Johann Friedrich d'Orville", "given": "Johann", "middle": "Friedrich", "family": "d'Orville", "initials": "J. F. d.", "capitalized_force": "Johann Friedrich D'Orville"} {"name": "Johann II.", "given": "Johann", "suffix": "II.", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "J."} {"name": "Johann Klingenberg", "given": "Johann", "family": "Klingenberg", "initials": "J. K."} {"name": "Johann von Dumreicher", "given": "Johann", "family": "von Dumreicher", "initials": "J. D."} {"name": "Johann von Goëß", "given": "Johann", "family": "von Goëß", "initials": "J. G."} {"name": "Johannes d'Outrein", "given": "Johannes", "family": "d'Outrein", "initials": "J. d.", "capitalized_force": "Johannes D'Outrein"} {"name": "Johannes de Gorter", "given": "Johannes", "family": "de Gorter", "initials": "J. G."} {"name": "John", "given": "John", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "J."} {"name": "John", "order": "FAMILY_FIRST", "family": "John", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "J."} {"name": "John Doe ☁️", "given": "John", "middle": "Doe", "family": "️", "initials": "J. D. ️."} {"name": "John Q Doe", "given": "John", "middle": "Q", "family": "Doe", "initials": "J. Q. D."} {"name": "John \"Jack\" Kennedy", "given": "John", "family": "Kennedy", "nickname": "Jack", "initials": "J. K."} {"name": "John & Jane", "given": "John & Jane", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "J. J."} {"name": "John . Smith", "given": "John", "middle": ".", "family": "Smith", "initials": "J. .. S."} {"name": "John A. Doe", "given": "John", "middle": "A.", "family": "Doe", "initials": "J. A. D."} {"name": "John A. Doe III", "given": "John", "middle": "A.", "family": "Doe", "suffix": "III", "initials": "J. A. D."} {"name": "John A. Doe, Jr", "given": "John", "middle": "A.", "family": "Doe", "suffix": "Jr", "initials": "J. A. D."} {"name": "John A. Doe, Jr.", "given": "John", "middle": "A.", "family": "Doe", "suffix": "Jr.", "initials": "J. A. D."} {"name": "John A. Kenneth Doe", "given": "John", "middle": "A. Kenneth", "family": "Doe", "initials": "J. A. K. D."} {"name": "John A. Kenneth Doe III", "given": "John", "middle": "A. Kenneth", "family": "Doe", "suffix": "III", "initials": "J. A. K. D."} {"name": "John A. Kenneth Doe, Jr.", "given": "John", "middle": "A. Kenneth", "family": "Doe", "suffix": "Jr.", "initials": "J. A. K. D."} {"name": "John An Smith", "given": "John", "middle": "An", "family": "Smith", "initials": "J. A. S."} {"name": "John Chinedu Amadi", "given": "John", "middle": "Chinedu", "family": "Amadi", "initials": "J. C. A."} {"name": "John Dean Smith", "given": "John", "middle": "Dean", "family": "Smith", "initials": "J. D. S."} {"name": "John Doctor Smith", "given": "John", "middle": "Doctor", "family": "Smith", "initials": "J. D. S."} {"name": "John Doe", "given": "John", "family": "Doe", "initials": "J. D."} {"name": "John Doe III", "given": "John", "family": "Doe", "suffix": "III", "initials": "J. D."} {"name": "John Doe JD.CPA", "given": "John", "family": "Doe", "suffix": "JD.CPA", "initials": "J. D.", "capitalized_force": "John Doe Jd.Cpa"} {"name": "John Doe MD PhD", "given": "John", "family": "Doe", "suffix": "MD PhD", "initials": "J. D."} {"name": "John Doe Msc.Ed.", "given": "John", "family": "Doe", "suffix": "Msc.Ed.", "initials": "J. D."} {"name": "John Doe do", "given": "John", "family": "Doe", "suffix": "do", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (declared)"], "initials": "J. D.", "capitalized_force": "John Doe DO"} {"name": "John Doe jr., MD", "given": "John", "family": "Doe", "suffix": "jr., MD", "initials": "J. D.", "capitalized_force": "John Doe Jr., MD"} {"name": "John Doe, CLU, CFP, LUTC", "given": "John", "family": "Doe", "suffix": "CLU, CFP, LUTC", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. D.", "capitalized_force": "John Doe CLU, CFP, Lutc"} {"name": "John Doe, Jr.", "given": "John", "family": "Doe", "suffix": "Jr.", "initials": "J. D."} {"name": "John Doe, Jr.,,", "given": "John", "family": "Doe", "suffix": "Jr.", "initials": "J. D."} {"name": "John Doe, MD - PhD - FACS", "given": "John", "family": "Doe", "suffix": "MD, PhD, FACS", "initials": "J. D."} {"name": "John Doe, MD - PhD, FACS", "given": "John", "family": "Doe", "suffix": "MD, PhD, FACS", "initials": "J. D."} {"name": "John Doe, MD, PhD", "given": "John", "family": "Doe", "suffix": "MD, PhD", "initials": "J. D."} {"name": "John Doe, MD-PhD-", "given": "MD-PhD-", "family": "John Doe", "initials": "M. J. D.", "capitalized_force": "Md-PhD- John Doe"} {"name": "John Doe, Msc.Ed.", "given": "John", "family": "Doe", "suffix": "Msc.Ed.", "initials": "J. D."} {"name": "John Doe, Phd, Jr", "given": "John", "family": "Doe", "suffix": "Phd, Jr", "initials": "J. D.", "capitalized_force": "John Doe PhD, Jr"} {"name": "John E Smith", "given": "John", "middle": "E", "family": "Smith", "initials": "J. E. S."} {"name": "John Edgar Casey Williams III", "given": "John", "middle": "Edgar Casey", "family": "Williams", "suffix": "III", "initials": "J. E. C. W."} {"name": "John F Kennedy", "given": "John", "middle": "F", "family": "Kennedy", "initials": "J. F. K."} {"name": "John Hamish Watson", "given": "John", "middle": "Hamish", "family": "Watson", "initials": "J. H. W."} {"name": "John Hámish Watson", "given": "John", "middle": "Hámish", "family": "Watson", "initials": "J. H. W."} {"name": "John I Smith", "given": "John", "middle": "I", "family": "Smith", "initials": "J. I. S."} {"name": "John III", "given": "John", "suffix": "III", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "J."} {"name": "John III, Dr.", "title": "Dr.", "family": "John", "suffix": "III", "initials": "J."} {"name": "John III, Sir", "title": "Sir", "given": "John", "suffix": "III", "initials": "J."} {"name": "John Ingram V", "given": "John", "family": "Ingram", "suffix": "V", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: initial as family (shape)"], "initials": "J. I."} {"name": "John J VI, AD", "given": "AD", "family": "John J VI", "initials": "A. J. J. V.", "capitalized_force": "Ad John J Vi"} {"name": "John J. III", "given": "John", "family": "J.", "suffix": "III", "initials": "J. J."} {"name": "John J. V", "given": "John", "middle": "J.", "family": "V", "initials": "J. J. V."} {"name": "John J. VI", "given": "John", "middle": "J.", "family": "VI", "initials": "J. J. V.", "capitalized_force": "John J. Vi"} {"name": "John Jack Andrew Kennedy", "given": "John", "middle": "Jack Andrew", "family": "Kennedy", "initials": "J. J. A. K."} {"name": "John Jones (Google Docs)", "given": "John", "family": "Jones", "nickname": "Google Docs", "initials": "J. J."} {"name": "John Jones (Google Docs), Jr. (Unknown)", "given": "John", "family": "Jones", "suffix": "Jr.", "nickname": "Google Docs Unknown", "initials": "J. J."} {"name": "John Jones (Unknown)", "given": "John", "family": "Jones", "nickname": "Unknown", "initials": "J. J."} {"name": "John King", "given": "John", "family": "King", "initials": "J. K."} {"name": "John Leed", "given": "John", "family": "Leed", "initials": "J. L."} {"name": "John Ma", "given": "John", "family": "Ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "J. M."} {"name": "John Major. Smith", "given": "John", "middle": "Major.", "family": "Smith", "initials": "J. M. S."} {"name": "John Mkwawa", "given": "John", "family": "Mkwawa", "initials": "J. M."} {"name": "John P. Doe, CLU, CFP, LUTC", "given": "John", "middle": "P.", "family": "Doe", "suffix": "CLU, CFP, LUTC", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. P. D.", "capitalized_force": "John P. Doe CLU, CFP, Lutc"} {"name": "John Ph. D.", "given": "John", "suffix": "Ph. D.", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "J."} {"name": "John PhD Smith", "given": "John", "middle": "PhD", "family": "Smith", "initials": "J. P. S."} {"name": "John Pharm D", "given": "John", "middle": "Pharm", "family": "D", "initials": "J. P. D."} {"name": "John Prof. MA", "title": "Prof.", "family": "John", "suffix": "MA", "initials": "J."} {"name": "John Q. Smith", "given": "John", "middle": "Q.", "family": "Smith", "initials": "J. Q. S."} {"name": "John Quincy Smith i", "given": "John", "middle": "Quincy", "family": "Smith", "suffix": "i", "initials": "J. Q. S.", "capitalized_force": "John Quincy Smith I"} {"name": "John Smith", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "John Smith (Ed)", "given": "John", "family": "Smith", "nickname": "Ed", "ambiguities": ["SUFFIX_OR_NICKNAME"], "alternatives": ["SUFFIX_OR_NICKNAME: credential as suffix (vocabulary)"], "initials": "J. S."} {"name": "John Smith (M.A)", "given": "John", "family": "Smith", "suffix": "M.A", "initials": "J. S."} {"name": "John Smith (M.A.)", "given": "John", "family": "Smith", "suffix": "M.A.", "initials": "J. S."} {"name": "John Smith (MA PhD)", "given": "John", "family": "Smith", "nickname": "MA PhD", "ambiguities": ["SUFFIX_OR_NICKNAME"], "alternatives": ["SUFFIX_OR_NICKNAME: credential as suffix (vocabulary)"], "initials": "J. S.", "capitalized_force": "John \"Ma PhD\" Smith"} {"name": "John Smith (MA)", "given": "John", "family": "Smith", "suffix": "MA", "initials": "J. S."} {"name": "John Smith (MA) Dr.", "title": "Dr.", "given": "John", "family": "Smith", "suffix": "MA", "initials": "J. S."} {"name": "John Smith (MA) Ma", "given": "John", "middle": "Smith", "family": "Ma", "nickname": "MA", "ambiguities": ["SUFFIX_OR_NAME", "SUFFIX_OR_NICKNAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)", "SUFFIX_OR_NICKNAME: credential as suffix (vocabulary)"], "initials": "J. S. M.", "capitalized_force": "John \"Ma\" Smith Ma"} {"name": "John Smith (MA) Ph. D.", "given": "John", "family": "Smith", "suffix": "MA Ph. D.", "initials": "J. S."} {"name": "John Smith (MA) née Jones", "given": "John", "family": "Smith", "suffix": "MA", "maiden": "Jones", "initials": "J. S."} {"name": "John Smith (MA), PhD", "given": "John", "family": "Smith", "suffix": "MA, PhD", "initials": "J. S."} {"name": "John Smith (MA).", "given": "John", "middle": "Smith", "family": ".", "nickname": "MA", "ambiguities": ["SUFFIX_OR_NICKNAME"], "alternatives": ["SUFFIX_OR_NICKNAME: credential as suffix (vocabulary)"], "initials": "J. S. ..", "capitalized_force": "John \"Ma\" Smith ."} {"name": "John Smith (MA.)", "given": "John", "family": "Smith", "suffix": "MA.", "initials": "J. S."} {"name": "John Smith (MBA PhD.)", "given": "John", "family": "Smith", "suffix": "MBA PhD.", "initials": "J. S."} {"name": "John Smith (Ma)", "given": "John", "family": "Smith", "nickname": "Ma", "ambiguities": ["SUFFIX_OR_NICKNAME"], "alternatives": ["SUFFIX_OR_NICKNAME: credential as suffix (vocabulary)"], "initials": "J. S."} {"name": "John Smith (PhD MBA)", "given": "John", "family": "Smith", "nickname": "PhD MBA", "initials": "J. S.", "capitalized_force": "John \"PhD Mba\" Smith"} {"name": "John Smith - Jones", "given": "John", "middle": "Smith -", "family": "Jones", "initials": "J. S. -. J."} {"name": "John Smith 1.4", "given": "John", "middle": "Smith", "family": "1.4", "initials": "J. S. 1."} {"name": "John Smith B.Tech.", "given": "John", "family": "Smith", "suffix": "B.Tech.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "J. S.", "capitalized_force": "John Smith B.TECH."} {"name": "John Smith C.H.A.", "given": "John", "family": "Smith", "suffix": "C.H.A.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "J. S."} {"name": "John Smith DO Jones", "given": "John", "middle": "Smith", "family": "DO Jones", "initials": "J. S. J.", "capitalized_force": "John Smith do Jones"} {"name": "John Smith Dr.", "title": "Dr.", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "John Smith E.S.Q.", "given": "John", "family": "Smith", "suffix": "E.S.Q.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "J. S."} {"name": "John Smith Esq", "given": "John", "family": "Smith", "suffix": "Esq", "initials": "J. S."} {"name": "John Smith Esq.", "given": "John", "family": "Smith", "suffix": "Esq.", "initials": "J. S."} {"name": "John Smith Esquire", "given": "John", "family": "Smith", "suffix": "Esquire", "initials": "J. S."} {"name": "John Smith I", "given": "John", "family": "Smith", "suffix": "I", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: initial as family (shape)"], "initials": "J. S."} {"name": "John Smith II, AD", "given": "John", "family": "Smith", "suffix": "II, AD", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "J. S."} {"name": "John Smith III", "given": "John", "family": "Smith", "suffix": "III", "initials": "J. S."} {"name": "John Smith III MD", "given": "John", "family": "Smith", "suffix": "III MD", "initials": "J. S."} {"name": "John Smith Iii", "given": "John", "family": "Smith", "suffix": "Iii", "initials": "J. S.", "capitalized_force": "John Smith III"} {"name": "John Smith J. R.", "given": "John", "middle": "Smith J.", "family": "R.", "initials": "J. S. J. R."} {"name": "John Smith J.u.n.i.o.r.", "given": "John", "family": "Smith", "suffix": "J.u.n.i.o.r.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "J. S.", "capitalized_force": "John Smith J.U.N.I.O.R."} {"name": "John Smith Jr", "given": "John", "family": "Smith", "suffix": "Jr", "initials": "J. S."} {"name": "John Smith Jr.", "given": "John", "family": "Smith", "suffix": "Jr.", "initials": "J. S."} {"name": "John Smith Jr. Prof.", "title": "Prof.", "given": "John", "family": "Smith", "suffix": "Jr.", "initials": "J. S."} {"name": "John Smith Jr., AD RA", "given": "John", "family": "Smith", "suffix": "Jr., AD RA", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "J. S."} {"name": "John Smith Jr., AD Sr. and Mr.", "title": "Sr. and Mr.", "given": "John", "family": "Smith", "suffix": "Jr., AD", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "J. S."} {"name": "John Smith Jr., J.", "given": "J.", "family": "John Smith", "suffix": "Jr.", "initials": "J. J. S."} {"name": "John Smith Jr., M.J.", "given": "M.J.", "family": "John Smith", "suffix": "Jr.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (shape)"], "initials": "M. J. S."} {"name": "John Smith Jr., MBChB PhD", "given": "John", "family": "Smith", "suffix": "Jr., MBChB PhD", "initials": "J. S."} {"name": "John Smith Jr., MJ", "given": "John", "family": "Smith", "suffix": "Jr., MJ", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "J. S."} {"name": "John Smith Jr., Mr. and Mrs. AD", "title": "Mr. and Mrs.", "given": "John", "family": "Smith", "suffix": "Jr., AD", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "J. S."} {"name": "John Smith Jr., PhD", "given": "John", "family": "Smith", "suffix": "Jr., PhD", "initials": "J. S."} {"name": "John Smith Jr., Sir AD", "title": "Sir", "given": "John", "family": "Smith", "suffix": "Jr., AD", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "J. S."} {"name": "John Smith Jr., þingmaður", "given": "John", "family": "Smith", "suffix": "Jr., þingmaður", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "J. S.", "capitalized_force": "John Smith Jr., Þingmaður"} {"name": "John Smith K.C.", "given": "John", "middle": "Smith", "family": "K.C.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (shape)"], "initials": "J. S. K."} {"name": "John Smith M. D.", "given": "John", "middle": "Smith M.", "family": "D.", "initials": "J. S. M. D."} {"name": "John Smith M.A.", "given": "John", "family": "Smith", "suffix": "M.A.", "initials": "J. S."} {"name": "John Smith M.A. Jones", "given": "John", "family": "Smith", "suffix": "M.A. Jones", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "J. S."} {"name": "John Smith MA", "given": "John", "family": "Smith", "suffix": "MA", "initials": "J. S."} {"name": "John Smith MA JD Jones", "given": "John", "family": "Smith", "suffix": "MA JD Jones", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (declared)", "SUFFIX_OR_NAME: name as family (position)"], "initials": "J. S."} {"name": "John Smith MA Jones", "given": "John", "middle": "Smith MA", "family": "Jones", "initials": "J. S. M. J.", "capitalized_force": "John Smith Ma Jones"} {"name": "John Smith MB ChB", "given": "John", "family": "Smith", "suffix": "MB ChB", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "J. S."} {"name": "John Smith MC", "given": "John", "family": "Smith", "suffix": "MC", "initials": "J. S."} {"name": "John Smith MD PhD", "given": "John", "family": "Smith", "suffix": "MD PhD", "initials": "J. S."} {"name": "John Smith MD-Jones", "given": "John", "middle": "Smith", "family": "MD-Jones", "initials": "J. S. M.", "capitalized_force": "John Smith Md-Jones"} {"name": "John Smith MD-PhD", "given": "John", "family": "Smith", "suffix": "MD-PhD", "initials": "J. S."} {"name": "John Smith MEng PhD", "given": "John", "middle": "Smith", "family": "MEng", "suffix": "PhD", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "J. S. M.", "capitalized_force": "John Smith Meng PhD"} {"name": "John Smith Ma", "given": "John", "middle": "Smith", "family": "Ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "J. S. M."} {"name": "John Smith Ma Jones", "given": "John", "middle": "Smith Ma", "family": "Jones", "initials": "J. S. M. J."} {"name": "John Smith Ma.", "given": "John", "middle": "Smith", "family": "Ma.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "J. S. M."} {"name": "John Smith Mc V", "given": "John", "family": "Smith", "suffix": "Mc V", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: initial as family (shape)"], "initials": "J. S.", "capitalized_force": "John Smith MC V"} {"name": "John Smith Mr.", "title": "Mr.", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "John Smith Msc.Ed.", "given": "John", "family": "Smith", "suffix": "Msc.Ed.", "initials": "J. S."} {"name": "John Smith Ph.", "given": "John", "middle": "Smith", "family": "Ph.", "initials": "J. S. P."} {"name": "John Smith Ph. D.", "given": "John", "family": "Smith", "suffix": "Ph. D.", "initials": "J. S."} {"name": "John Smith Ph. D. Jones", "given": "John", "family": "Smith", "suffix": "Ph. D. Jones", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "J. S."} {"name": "John Smith PhD", "given": "John", "family": "Smith", "suffix": "PhD", "initials": "J. S."} {"name": "John Smith PhD (MA)", "given": "John", "family": "Smith", "suffix": "PhD MA", "initials": "J. S."} {"name": "John Smith PhD (Ma)", "given": "John", "family": "Smith", "suffix": "PhD", "nickname": "Ma", "ambiguities": ["SUFFIX_OR_NICKNAME"], "alternatives": ["SUFFIX_OR_NICKNAME: credential as suffix (vocabulary)"], "initials": "J. S."} {"name": "John Smith PhD Ed Ma", "given": "John", "family": "Smith", "suffix": "PhD Ed Ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (declared)", "SUFFIX_OR_NAME: name as family (declared)"], "initials": "J. S.", "capitalized_force": "John Smith PhD ED MA"} {"name": "John Smith PhD Jones", "given": "John", "family": "Smith", "suffix": "PhD Jones", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "J. S."} {"name": "John Smith PhD MEng", "given": "John", "family": "Smith", "suffix": "PhD MEng", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (declared)"], "initials": "J. S.", "capitalized_force": "John Smith PhD MENG"} {"name": "John Smith PhD Ma", "given": "John", "family": "Smith", "suffix": "PhD Ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (declared)"], "initials": "J. S.", "capitalized_force": "John Smith PhD MA"} {"name": "John Smith PhD Prof. Ma", "title": "Prof.", "given": "John", "family": "Smith", "suffix": "PhD Ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (declared)"], "initials": "J. S.", "capitalized_force": "Prof. John Smith PhD MA"} {"name": "John Smith Pharm D", "given": "John", "family": "Smith", "suffix": "Pharm D", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "J. S."} {"name": "John Smith Pharm D Jones", "given": "John", "family": "Smith", "suffix": "Pharm D Jones", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)", "SUFFIX_OR_NAME: name as family (position)"], "initials": "J. S."} {"name": "John Smith Pharm D and Mary", "given": "John", "middle": "Smith Pharm", "family": "D and Mary", "initials": "J. S. P. D. M."} {"name": "John Smith Pharm. D.", "given": "John", "family": "Smith", "suffix": "Pharm. D.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "J. S."} {"name": "John Smith Prof.", "title": "Prof.", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "John Smith Prof. Dr.", "title": "Prof. Dr.", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "John Smith Prof. Jr.", "title": "Prof.", "given": "John", "family": "Smith", "suffix": "Jr.", "initials": "J. S."} {"name": "John Smith Psy. D.", "given": "John", "family": "Smith", "suffix": "Psy. D.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "J. S."} {"name": "John Smith Q.W.E.R.T.", "given": "John", "family": "Smith", "suffix": "Q.W.E.R.T.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "J. S."} {"name": "John Smith R.A.I.", "given": "John", "family": "Smith", "suffix": "R.A.I.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "J. S."} {"name": "John Smith RAI", "given": "John", "middle": "Smith", "family": "RAI", "initials": "J. S. R.", "capitalized_force": "John Smith Rai"} {"name": "John Smith Rev.", "title": "Rev.", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "John Smith Sir", "given": "John", "middle": "Smith", "family": "Sir", "initials": "J. S. S."} {"name": "John Smith V", "given": "John", "family": "Smith", "suffix": "V", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: initial as family (shape)"], "initials": "J. S."} {"name": "John Smith V Ph. D.", "given": "John", "middle": "Smith", "family": "V", "suffix": "Ph. D.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: generation as suffix (vocabulary)"], "initials": "J. S. V."} {"name": "John Smith V PhD", "given": "John", "middle": "Smith", "family": "V", "suffix": "PhD", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: generation as suffix (vocabulary)"], "initials": "J. S. V."} {"name": "John Smith V Pharm D", "given": "John", "middle": "Smith", "family": "V", "suffix": "Pharm D", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: generation as suffix (vocabulary)", "SUFFIX_OR_NAME: name as family (position)"], "initials": "J. S. V."} {"name": "John Smith V, AD", "given": "John", "family": "Smith", "suffix": "V, AD", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)", "SUFFIX_OR_NAME: initial as family (shape)"], "initials": "J. S."} {"name": "John Smith V, M.J.", "given": "M.J.", "family": "John Smith", "suffix": "V", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (shape)", "SUFFIX_OR_NAME: initial as family (shape)"], "initials": "M. J. S."} {"name": "John Smith V.", "given": "John", "middle": "Smith", "family": "V.", "initials": "J. S. V."} {"name": "John Smith VI", "given": "John", "family": "Smith", "suffix": "VI", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "J. S."} {"name": "John Smith VI Jones", "given": "John", "family": "Smith", "suffix": "VI Jones", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)", "SUFFIX_OR_NAME: name as family (position)"], "initials": "J. S."} {"name": "John Smith VI MD", "given": "John", "family": "Smith", "suffix": "VI MD", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "J. S."} {"name": "John Smith VI, AD", "given": "John", "family": "Smith", "suffix": "VI, AD", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)", "SUFFIX_OR_NAME: name as family (position)"], "initials": "J. S."} {"name": "John Smith VI, M.J.", "given": "M.J.", "family": "John Smith", "suffix": "VI", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (shape)", "SUFFIX_OR_NAME: name as family (position)"], "initials": "M. J. S."} {"name": "John Smith Vi", "given": "John", "middle": "Smith", "family": "Vi", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: generation as suffix (shape)"], "initials": "J. S. V."} {"name": "John Smith Viii", "given": "John", "middle": "Smith", "family": "Viii", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: generation as suffix (shape)"], "initials": "J. S. V."} {"name": "John Smith Wing.", "given": "John", "middle": "Smith", "family": "Wing.", "initials": "J. S. W."} {"name": "John Smith Wing. Jr.", "title": "Wing.", "given": "John", "family": "Smith", "suffix": "Jr.", "initials": "J. S."} {"name": "John Smith X.Y.Z.", "given": "John", "family": "Smith", "suffix": "X.Y.Z.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "J. S."} {"name": "John Smith XI", "given": "John", "middle": "Smith", "family": "XI", "initials": "J. S. X.", "capitalized_force": "John Smith Xi"} {"name": "John Smith XI MD", "given": "John", "middle": "Smith", "family": "XI", "suffix": "MD", "initials": "J. S. X.", "capitalized_force": "John Smith Xi MD"} {"name": "John Smith XYZ", "given": "John", "middle": "Smith", "family": "XYZ", "initials": "J. S. X.", "capitalized_force": "John Smith Xyz"} {"name": "John Smith Xyz.", "given": "John", "middle": "Smith", "family": "Xyz.", "initials": "J. S. X."} {"name": "John Smith nee Jones R.A.I.", "given": "John", "family": "Smith", "suffix": "R.A.I.", "maiden": "Jones", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as maiden (position)"], "initials": "J. S."} {"name": "John Smith vi", "given": "John", "family": "Smith", "suffix": "vi", "initials": "J. S.", "capitalized_force": "John Smith VI"} {"name": "John Smith «MA»", "given": "John", "family": "Smith", "suffix": "MA", "initials": "J. S."} {"name": "John Smith 「MA」", "given": "John", "family": "Smith", "suffix": "MA", "initials": "J. S."} {"name": "John Smith 博. 士.", "given": "John", "middle": "Smith 博.", "family": "士.", "initials": "J. S. 博. 士."} {"name": "John Smith 田.中.", "given": "John", "middle": "Smith", "family": "田.中.", "initials": "J. S. 田."} {"name": "John Smith, 1.4", "given": "1.4", "family": "John Smith", "initials": "1. J. S."} {"name": "John Smith, A-B", "given": "A-B", "family": "John Smith", "initials": "A. J. S."} {"name": "John Smith, A.B.", "given": "A.B.", "family": "John Smith", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (shape)"], "initials": "A. J. S."} {"name": "John Smith, A.B. Ph.D.", "given": "A.B.", "family": "John Smith", "suffix": "Ph.D.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (shape)"], "initials": "A. J. S."} {"name": "John Smith, AD", "given": "AD", "family": "John Smith", "initials": "A. J. S.", "capitalized_force": "Ad John Smith"} {"name": "John Smith, Dr.", "title": "Dr.", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "John Smith, Dr.", "order": "FAMILY_FIRST", "title": "Dr.", "given": "Smith", "family": "John", "initials": "S. J."} {"name": "John Smith, Dr. / MD", "title": "Dr.", "given": "John", "family": "Smith", "suffix": "MD", "initials": "J. S."} {"name": "John Smith, Dr. / and MD", "title": "Dr. and MD", "given": "John", "family": "Smith", "initials": "J. S.", "capitalized_force": "Dr. and Md John Smith"} {"name": "John Smith, Dr. J.씨", "title": "Dr.", "given": "John", "family": "Smith", "suffix": "J.씨", "initials": "J. S."} {"name": "John Smith, Dr; MD", "title": "Dr;", "given": "John", "family": "Smith", "suffix": "MD", "initials": "J. S."} {"name": "John Smith, Ed", "given": "John", "family": "Smith", "suffix": "Ed", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (declared)"], "initials": "J. S.", "capitalized_force": "John Smith ED"} {"name": "John Smith, Ed Ma", "given": "John", "family": "Smith", "suffix": "Ed Ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (declared)"], "initials": "J. S.", "capitalized_force": "John Smith ED MA"} {"name": "John Smith, Jones", "given": "Jones", "family": "John Smith", "initials": "J. J. S."} {"name": "John Smith, Jr do", "given": "John", "family": "Smith", "suffix": "Jr do", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "J. S.", "capitalized_force": "John Smith Jr DO"} {"name": "John Smith, Jr vd", "given": "John", "family": "Smith", "suffix": "Jr vd", "initials": "J. S.", "capitalized_force": "John Smith Jr VD"} {"name": "John Smith, Jr.", "given": "John", "family": "Smith", "suffix": "Jr.", "initials": "J. S."} {"name": "John Smith, Jr. Ph. D.", "given": "John", "family": "Smith", "suffix": "Jr. Ph. D.", "initials": "J. S."} {"name": "John Smith, Jr., Lt.Gov.", "title": "Lt.Gov.", "given": "John", "family": "Smith", "suffix": "Jr.", "initials": "J. S."} {"name": "John Smith, Jr., abdul Dr. Smith", "title": "Dr.", "given": "John", "family": "Smith", "suffix": "Jr., abdul Smith", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. S.", "capitalized_force": "Dr. John Smith Jr., Abdul Smith"} {"name": "John Smith, Jr., van der Berg Prof.", "title": "Prof.", "given": "John", "family": "Smith", "suffix": "Jr., van der Berg", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. S.", "capitalized_force": "Prof. John Smith Jr., Van Der Berg"} {"name": "John Smith, LEED AP", "given": "John", "family": "Smith", "suffix": "LEED AP", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "J. S."} {"name": "John Smith, M. D.", "given": "M.", "middle": "D.", "family": "John Smith", "initials": "M. D. J. S."} {"name": "John Smith, M.D./Ph.D.", "given": "John", "family": "Smith", "suffix": "M.D./Ph.D.", "initials": "J. S."} {"name": "John Smith, M.D.; Ph.D.", "given": "John", "family": "Smith", "suffix": "M.D.; Ph.D.", "initials": "J. S."} {"name": "John Smith, MA", "given": "John", "family": "Smith", "suffix": "MA", "initials": "J. S."} {"name": "John Smith, MA/PHD", "given": "John", "family": "Smith", "suffix": "MA/PHD", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "J. S.", "capitalized_force": "John Smith MA/PhD"} {"name": "John Smith, MA/PhD", "given": "MA/PhD", "family": "John Smith", "initials": "M. J. S.", "capitalized_force": "Ma/PhD John Smith"} {"name": "John Smith, MA; PhD", "given": "John", "family": "Smith", "suffix": "MA; PhD", "initials": "J. S."} {"name": "John Smith, MD - Chief of Surgery", "title": "Chief of Surgery", "given": "John", "family": "Smith", "suffix": "MD", "initials": "J. S."} {"name": "John Smith, MD - PhD", "given": "John", "family": "Smith", "suffix": "MD, PhD", "initials": "J. S."} {"name": "John Smith, MD / PhD", "given": "John", "family": "Smith", "suffix": "MD, PhD", "initials": "J. S."} {"name": "John Smith, MD ; PhD", "given": "John", "family": "Smith", "suffix": "MD, PhD", "initials": "J. S."} {"name": "John Smith, MD MEng", "given": "John", "family": "Smith", "suffix": "MD MEng", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "J. S.", "capitalized_force": "John Smith MD MENG"} {"name": "John Smith, MD – PhD", "given": "John", "family": "Smith", "suffix": "MD – PhD", "initials": "J. S."} {"name": "John Smith, MD, Bart", "given": "John", "family": "Smith", "suffix": "MD, Bart", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. S."} {"name": "John Smith, MD, Ma", "given": "John", "family": "Smith", "suffix": "MD, Ma", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. S.", "capitalized_force": "John Smith MD, MA"} {"name": "John Smith, MD, Ms", "given": "John", "family": "Smith", "suffix": "MD, Ms", "initials": "J. S.", "capitalized_force": "John Smith MD, MS"} {"name": "John Smith, MD, R.A.I.", "given": "John", "family": "Smith", "suffix": "MD, R.A.I.", "initials": "J. S."} {"name": "John Smith, MD, XYZ", "given": "John", "family": "Smith", "suffix": "MD, XYZ", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. S.", "capitalized_force": "John Smith MD, Xyz"} {"name": "John Smith, MD,, Jr.", "given": "John", "family": "Smith", "suffix": "MD, Jr.", "initials": "J. S."} {"name": "John Smith, MD- PhD", "given": "MD-", "family": "John Smith", "suffix": "PhD", "initials": "M. J. S.", "capitalized_force": "Md- John Smith PhD"} {"name": "John Smith, MD/ PhD", "given": "MD/", "family": "John Smith", "suffix": "PhD", "initials": "M. J. S.", "capitalized_force": "Md/ John Smith PhD"} {"name": "John Smith, MD/PhD", "given": "John", "family": "Smith", "suffix": "MD/PhD", "initials": "J. S."} {"name": "John Smith, MD; PhD", "given": "John", "family": "Smith", "suffix": "MD; PhD", "initials": "J. S."} {"name": "John Smith, MD;PhD", "given": "John", "family": "Smith", "suffix": "MD;PhD", "initials": "J. S."} {"name": "John Smith, MEng PhD", "given": "John", "family": "Smith", "suffix": "MEng PhD", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (declared)"], "initials": "J. S.", "capitalized_force": "John Smith MENG PhD"} {"name": "John Smith, MS-OMPT", "given": "John", "family": "Smith", "suffix": "MS-OMPT", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "J. S."} {"name": "John Smith, MS-V", "given": "John", "family": "Smith", "suffix": "MS-V", "initials": "J. S."} {"name": "John Smith, Ma", "given": "John", "family": "Smith", "suffix": "Ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (declared)"], "initials": "J. S.", "capitalized_force": "John Smith MA"} {"name": "John Smith, Md-Rn", "given": "Md-Rn", "family": "John Smith", "initials": "M. J. S."} {"name": "John Smith, Mr.", "title": "Mr.", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "John Smith, Mr. Jr.", "title": "Mr.", "given": "John", "family": "Smith", "suffix": "Jr.", "initials": "J. S."} {"name": "John Smith, Mr. and Mrs.", "title": "Mr. and Mrs.", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "John Smith, Ms Ma", "given": "John", "family": "Smith", "suffix": "Ms Ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "J. S.", "capitalized_force": "John Smith MS MA"} {"name": "John Smith, PU-PH", "given": "John", "family": "Smith", "suffix": "PU-PH", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "J. S."} {"name": "John Smith, PU/PH, Jr.", "given": "John", "family": "Smith", "suffix": "PU/PH, Jr.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "J. S."} {"name": "John Smith, Ph. D.", "given": "John", "family": "Smith", "suffix": "Ph. D.", "initials": "J. S."} {"name": "John Smith, Ph. D. Jones", "given": "John", "family": "Smith", "suffix": "Ph. D. Jones", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "J. S."} {"name": "John Smith, Ph. D.; MD", "title": "Ph.", "given": "D.;", "family": "John Smith", "suffix": "MD", "initials": "D. J. S."} {"name": "John Smith, Ph.D.", "given": "John", "family": "Smith", "suffix": "Ph.D.", "initials": "J. S."} {"name": "John Smith, PhD", "given": "John", "family": "Smith", "suffix": "PhD", "initials": "J. S."} {"name": "John Smith, PhD DO DO", "given": "John", "family": "Smith", "suffix": "PhD DO DO", "initials": "J. S."} {"name": "John Smith, PhD Jones", "given": "John", "family": "Smith", "suffix": "PhD Jones", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "J. S."} {"name": "John Smith, PhD MEng", "given": "John", "family": "Smith", "suffix": "PhD MEng", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "J. S.", "capitalized_force": "John Smith PhD MENG"} {"name": "John Smith, PhD Ma", "given": "John", "family": "Smith", "suffix": "PhD Ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "J. S.", "capitalized_force": "John Smith PhD MA"} {"name": "John Smith, PhD X.Y.", "given": "John", "family": "Smith", "suffix": "PhD X.Y.", "initials": "J. S."} {"name": "John Smith, PhD XYZ", "given": "John", "family": "Smith", "suffix": "PhD XYZ", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "J. S."} {"name": "John Smith, PhD vd DO", "given": "John", "family": "Smith", "suffix": "PhD vd DO", "initials": "J. S.", "capitalized_force": "John Smith PhD VD DO"} {"name": "John Smith, PhD-Md", "given": "John", "family": "Smith", "suffix": "PhD-Md", "initials": "J. S.", "capitalized_force": "John Smith PhD-MD"} {"name": "John Smith, Pharm D", "given": "John", "family": "Smith", "suffix": "Pharm D", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "J. S."} {"name": "John Smith, Phd-Md", "given": "Phd-Md", "family": "John Smith", "initials": "P. J. S.", "capitalized_force": "PhD-Md John Smith"} {"name": "John Smith, Prof. - of Surgery", "title": "Prof. of Surgery", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "John Smith, Pu-Ph", "given": "Pu-Ph", "family": "John Smith", "initials": "P. J. S."} {"name": "John Smith, RA Jones", "title": "RA", "given": "Jones", "family": "John Smith", "initials": "J. J. S.", "capitalized_force": "Ra Jones John Smith"} {"name": "John Smith, RAI", "given": "John", "family": "Smith", "suffix": "RAI", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "J. S."} {"name": "John Smith, V Jr.", "given": "John", "family": "Smith", "suffix": "V Jr.", "initials": "J. S."} {"name": "John Smith, V MD", "given": "John", "family": "Smith", "suffix": "V MD", "initials": "J. S."} {"name": "John Smith, V.", "given": "John", "family": "Smith", "suffix": "V.", "initials": "J. S."} {"name": "John Smith, V; MD", "given": "John", "family": "Smith", "suffix": "V; MD", "initials": "J. S."} {"name": "John Smith, Vice", "title": "Vice", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "John Smith, X.Y. P.Q.", "given": "John", "family": "Smith", "suffix": "X.Y. P.Q.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "J. S."} {"name": "John Smith, X.Y.Z.", "given": "John", "family": "Smith", "suffix": "X.Y.Z.", "initials": "J. S."} {"name": "John Smith, X.Y.Z. MA", "given": "John", "family": "Smith", "suffix": "X.Y.Z. MA", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "J. S."} {"name": "John Smith, XYZ", "given": "John", "family": "Smith", "suffix": "XYZ", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "J. S."} {"name": "John Smith, XYZ- QRST", "given": "XYZ-", "middle": "QRST", "family": "John Smith", "initials": "X. Q. J. S.", "capitalized_force": "Xyz- Qrst John Smith"} {"name": "John Smith, md-phd", "given": "John", "family": "Smith", "suffix": "md-phd", "initials": "J. S.", "capitalized_force": "John Smith MD-PhD"} {"name": "John Smith, otr/l", "given": "John", "family": "Smith", "suffix": "otr/l", "initials": "J. S.", "capitalized_force": "John Smith OTR/L"} {"name": "John Smith, vd Ma", "given": "John", "family": "Smith", "suffix": "vd Ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "J. S.", "capitalized_force": "John Smith VD MA"} {"name": "John Smith-Jones", "given": "John", "family": "Smith-Jones", "initials": "J. S."} {"name": "John Smith-Jones, MD-PhD", "given": "John", "family": "Smith-Jones", "suffix": "MD-PhD", "initials": "J. S."} {"name": "John V", "given": "John", "family": "V", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: generation as suffix (vocabulary)"], "initials": "J. V."} {"name": "John V, Dr.", "title": "Dr.", "given": "John", "family": "V", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: generation as suffix (vocabulary)"], "initials": "J. V."} {"name": "John V. Smith", "given": "John", "middle": "V.", "family": "Smith", "initials": "J. V. S."} {"name": "John W Ingram V", "given": "John", "middle": "W", "family": "Ingram", "suffix": "V", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: initial as family (shape)"], "initials": "J. W. I."} {"name": "John W. Ingram", "given": "John", "middle": "W.", "family": "Ingram", "initials": "J. W. I."} {"name": "John W. Ingram V", "given": "John", "middle": "W.", "family": "Ingram", "suffix": "V", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: initial as family (shape)"], "initials": "J. W. I."} {"name": "John W. Ingram, V", "given": "John", "middle": "W.", "family": "Ingram", "suffix": "V", "initials": "J. W. I."} {"name": "John W. Smith, I", "given": "John", "middle": "W.", "family": "Smith", "suffix": "I", "initials": "J. W. S."} {"name": "John Williams", "given": "John", "family": "Williams", "initials": "J. W."} {"name": "John and Jane Aznar y Lopez", "given": "John and Jane", "family": "Aznar y Lopez", "initials": "J. J. A. L."} {"name": "John and Jane Smith", "given": "John and Jane", "family": "Smith", "initials": "J. J. S."} {"name": "John de Ma", "given": "John", "family": "de Ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "J. M."} {"name": "John e Smith", "given": "John", "middle": "e", "family": "Smith", "initials": "J. e. S."} {"name": "John e Smith III", "given": "John", "middle": "e", "family": "Smith", "suffix": "III", "initials": "J. e. S."} {"name": "John e Smith, III", "given": "John", "middle": "e", "family": "Smith", "suffix": "III", "initials": "J. e. S."} {"name": "John nee Prof. ba MA", "family": "John", "suffix": "MA", "maiden": "Prof. ba", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "J.", "capitalized_force": "John (Prof. Ba) MA"} {"name": "John née Jones Smith Ma", "family": "John", "maiden": "Jones Smith Ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "J."} {"name": "John née Jones Smith V", "family": "John", "maiden": "Jones Smith V", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: generation as suffix (vocabulary)"], "initials": "J."} {"name": "John of Bishop", "given": "John of Bishop", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "J. B."} {"name": "John of Judge", "given": "John of Judge", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "J. J."} {"name": "John of the Doe", "given": "John of the Doe", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "J. D."} {"name": "John van Buren, Ed", "given": "John", "family": "van Buren", "suffix": "Ed", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (declared)"], "initials": "J. B.", "capitalized_force": "John van Buren ED"} {"name": "John van Mc", "given": "John", "family": "van Mc", "initials": "J. v. M.", "capitalized_force": "John Van Mc"} {"name": "John van der Berg", "given": "John", "family": "van der Berg", "initials": "J. B."} {"name": "John van der Berg Ma", "given": "John", "family": "van der Berg Ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "J. B. M."} {"name": "John van der Berg PhD", "given": "John", "family": "van der Berg", "suffix": "PhD", "initials": "J. B."} {"name": "John van der Berg Prof.", "title": "Prof.", "given": "John", "family": "van der Berg", "initials": "J. B."} {"name": "John van der Berg Smith", "given": "John", "family": "van der Berg Smith", "initials": "J. B. S."} {"name": "John van der Berg V", "given": "John", "family": "van der Berg", "suffix": "V", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: initial as family (shape)"], "initials": "J. B."} {"name": "John van der Berg née Jones", "given": "John", "family": "van der Berg", "maiden": "Jones", "initials": "J. B."} {"name": "John van der J. V", "given": "John", "family": "van der J. V", "initials": "J. J. V."} {"name": "John von PhD Jones", "given": "John", "family": "von", "suffix": "PhD Jones", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "J. v.", "capitalized_force": "John Von PhD Jones"} {"name": "John y Jane", "given": "John", "middle": "y", "family": "Jane", "initials": "J. y. J."} {"name": "John й Smith", "given": "John", "middle": "й", "family": "Smith", "initials": "J. й. S."} {"name": "John “Jack” Kennedy", "given": "John", "family": "Kennedy", "nickname": "Jack", "initials": "J. K."} {"name": "John 王", "given": "John", "family": "王", "initials": "J. 王."} {"name": "John 田中さん, XYZ", "given": "John", "family": "田中", "suffix": "さん, XYZ", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "J. 田."} {"name": "John 「Jack」 Smith", "given": "John", "family": "Smith", "nickname": "Jack", "initials": "J. S."} {"name": "John, Smith, Dr.", "title": "Dr.", "given": "Smith", "family": "John", "initials": "S. J."} {"name": "Johnson Mary", "order": "FAMILY_FIRST", "given": "Mary", "family": "Johnson", "initials": "M. J."} {"name": "Johnson, Mary", "given": "Mary", "family": "Johnson", "initials": "M. J."} {"name": "John😀Smith", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "Jon Andoni Urrestaratzu", "given": "Jon", "middle": "Andoni", "family": "Urrestaratzu", "initials": "J. A. U."} {"name": "Jon Dough and", "given": "Jon", "family": "Dough and", "initials": "J. D."} {"name": "Jon Dough and of", "given": "Jon", "family": "Dough and of", "initials": "J. D."} {"name": "Jon Irazabal Agirre", "given": "Jon", "middle": "Irazabal", "family": "Agirre", "initials": "J. I. A."} {"name": "Jonas Lukas Petrauskas", "given": "Jonas", "middle": "Lukas", "family": "Petrauskas", "initials": "J. L. P."} {"name": "Jones Jr. née Smith", "given": "Jones", "middle": "Jr. née", "family": "Smith", "initials": "J. J. n. S.", "capitalized_force": "Jones Jr. Née Smith"} {"name": "Jones née", "given": "Jones", "family": "née", "initials": "J. n.", "capitalized_force": "Jones Née"} {"name": "Jong Anke de", "given": "Jong", "middle": "Anke", "family": "de", "initials": "J. A. d.", "capitalized_force": "Jong Anke De"} {"name": "Jong van der", "given": "Jong", "family": "van der", "initials": "J. v. d.", "capitalized_force": "Jong Van Der"} {"name": "Jong, Anke de", "given": "Anke", "family": "de Jong", "initials": "A. J."} {"name": "Jong, Bertus Wevers Harry Robert de", "given": "Bertus", "middle": "Wevers Harry Robert", "family": "de Jong", "initials": "B. W. H. R. J."} {"name": "Jong, Piet de", "given": "Piet", "family": "de Jong", "initials": "P. J."} {"name": "Jong, van der", "given": "van", "middle": "der", "family": "Jong", "initials": "v. d. J.", "capitalized_force": "Van Der Jong"} {"name": "Jordi Castellanos i Vila", "given": "Jordi", "family": "Castellanos i Vila", "initials": "J. C. V."} {"name": "Jordi Cussà Balaguer", "given": "Jordi", "middle": "Cussà", "family": "Balaguer", "initials": "J. C. B."} {"name": "Jorge A. Livraga Rizzi", "given": "Jorge", "middle": "A. Livraga", "family": "Rizzi", "initials": "J. A. L. R."} {"name": "Jorge Ernesto Guzmán Rodríguez", "given": "Jorge", "middle": "Ernesto Guzmán", "family": "Rodríguez", "initials": "J. E. G. R."} {"name": "Jorge González Camarena", "given": "Jorge", "middle": "González", "family": "Camarena", "initials": "J. G. C."} {"name": "Jorge González Díaz", "given": "Jorge", "middle": "González", "family": "Díaz", "initials": "J. G. D."} {"name": "Jorge Lozano Márquez", "given": "Jorge", "middle": "Lozano", "family": "Márquez", "initials": "J. L. M."} {"name": "Jorge Valdez Muñoz", "given": "Jorge", "middle": "Valdez", "family": "Muñoz", "initials": "J. V. M."} {"name": "Jose Aznar y Lopez", "given": "Jose", "family": "Aznar y Lopez", "initials": "J. A. L."} {"name": "Jose E Maria Santos", "given": "Jose", "middle": "E Maria", "family": "Santos", "initials": "J. E. M. S."} {"name": "Jose E. Maria Santos", "given": "Jose", "middle": "E. Maria", "family": "Santos", "initials": "J. E. M. S."} {"name": "Jose Ortega-Y-Gasset", "given": "Jose", "family": "Ortega-Y-Gasset", "initials": "J. O.", "capitalized_force": "Jose Ortega-y-Gasset"} {"name": "Jose e Maria", "given": "Jose", "middle": "e", "family": "Maria", "initials": "J. e. M."} {"name": "Jose e Maria Santos", "given": "Jose e Maria", "family": "Santos", "initials": "J. M. S."} {"name": "Jose j y Lopez", "given": "Jose", "family": "j y Lopez", "initials": "J. j. L.", "capitalized_force": "Jose J y Lopez"} {"name": "Josef Václav Svoboda", "given": "Josef", "middle": "Václav", "family": "Svoboda", "initials": "J. V. S."} {"name": "Josep Balari i Jovany", "given": "Josep", "family": "Balari i Jovany", "initials": "J. B. J."} {"name": "Josep Campabadal i Calvet", "given": "Josep", "family": "Campabadal i Calvet", "initials": "J. C. C."} {"name": "Josep Carod I Rovira", "given": "Josep", "middle": "Carod I", "family": "Rovira", "initials": "J. C. I. R."} {"name": "Josep Carod i", "given": "Josep", "family": "Carod", "suffix": "i", "initials": "J. C.", "capitalized_force": "Josep Carod I"} {"name": "Josep Carod i Rovira", "given": "Josep", "family": "Carod i Rovira", "initials": "J. C. R."} {"name": "Josep Carod i Rovira III", "given": "Josep", "family": "Carod i Rovira", "suffix": "III", "initials": "J. C. R."} {"name": "Josep Carod i de Rovira", "given": "Josep", "middle": "Carod i de", "family": "Rovira", "initials": "J. C. R."} {"name": "Josep Domènech Estapà", "given": "Josep", "middle": "Domènech", "family": "Estapà", "initials": "J. D. E."} {"name": "Josep Ibáñez i Senserrich", "given": "Josep", "family": "Ibáñez i Senserrich", "initials": "J. I. S."} {"name": "Josep Janés i Olivé", "given": "Josep", "family": "Janés i Olivé", "initials": "J. J. O."} {"name": "Josep Lluis Carod i III", "given": "Josep", "middle": "Lluis", "family": "Carod", "suffix": "i III", "initials": "J. L. C.", "capitalized_force": "Josep Lluis Carod I III"} {"name": "Josep Lluis Carod i Rovira", "given": "Josep", "middle": "Lluis", "family": "Carod i Rovira", "initials": "J. L. C. R."} {"name": "Josep Lluis Carod i V", "given": "Josep", "middle": "Lluis", "family": "Carod", "suffix": "i V", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: initial as family (shape)"], "initials": "J. L. C.", "capitalized_force": "Josep Lluis Carod I V"} {"name": "Josep Maria Tarridas i Barri", "given": "Josep", "middle": "Maria", "family": "Tarridas i Barri", "initials": "J. M. T. B."} {"name": "Josep Vergés i Vallmajor", "given": "Josep", "family": "Vergés i Vallmajor", "initials": "J. V. V."} {"name": "Josep i Rovira", "given": "Josep", "middle": "i", "family": "Rovira", "initials": "J. i. R."} {"name": "Joseph ole Sankale", "given": "Joseph", "middle": "ole", "family": "Sankale", "initials": "J. o. S.", "capitalized_force": "Joseph Ole Sankale"} {"name": "Joset, Theodor", "given": "Theodor", "family": "Joset", "initials": "T. J."} {"name": "José Antonio Nieves Conde", "given": "José", "middle": "Antonio Nieves", "family": "Conde", "initials": "J. A. N. C."} {"name": "José Antonio Osorio Lizarazo", "given": "José", "middle": "Antonio Osorio", "family": "Lizarazo", "initials": "J. A. O. L."} {"name": "José Antonio Rodríguez Romero", "given": "José", "middle": "Antonio Rodríguez", "family": "Romero", "initials": "J. A. R. R."} {"name": "José Cabrera Gándara", "given": "José", "middle": "Cabrera", "family": "Gándara", "initials": "J. C. G."} {"name": "José Camacho Carrizosa", "given": "José", "middle": "Camacho", "family": "Carrizosa", "initials": "J. C. C."} {"name": "José Francos Rodríguez", "given": "José", "middle": "Francos", "family": "Rodríguez", "initials": "J. F. R."} {"name": "José García Ocejo", "given": "José", "middle": "García", "family": "Ocejo", "initials": "J. G. O."} {"name": "José Gil Pérez", "given": "José", "middle": "Gil", "family": "Pérez", "initials": "J. G. P."} {"name": "José Gómez-Mena Vila", "given": "José", "middle": "Gómez-Mena", "family": "Vila", "initials": "J. G. V."} {"name": "José Luis Arroyo Carreras", "given": "José", "middle": "Luis Arroyo", "family": "Carreras", "initials": "J. L. A. C."} {"name": "José Luis Bustamante y Rivero", "given": "José", "middle": "Luis", "family": "Bustamante y Rivero", "initials": "J. L. B. R."} {"name": "José Luis Covarrubias Garza", "given": "José", "middle": "Luis Covarrubias", "family": "Garza", "initials": "J. L. C. G."} {"name": "José Luis Martín Descalzo", "given": "José", "middle": "Luis Martín", "family": "Descalzo", "initials": "J. L. M. D."} {"name": "José López Domínguez", "given": "José", "middle": "López", "family": "Domínguez", "initials": "J. L. D."} {"name": "José López Portillo y Pacheco", "given": "José", "middle": "López", "family": "Portillo y Pacheco", "initials": "J. L. P. P."} {"name": "José Manuel López de Abiada", "given": "José", "middle": "Manuel López", "family": "de Abiada", "initials": "J. M. L. A."} {"name": "José Maria Sert y Badia", "given": "José", "middle": "Maria", "family": "Sert y Badia", "initials": "J. M. S. B."} {"name": "José María Campo Serrano", "given": "José", "middle": "María Campo", "family": "Serrano", "initials": "J. M. C. S."} {"name": "José María Díaz Casariego", "given": "José", "middle": "María Díaz", "family": "Casariego", "initials": "J. M. D. C."} {"name": "José María Guerra Cobián", "given": "José", "middle": "María Guerra", "family": "Cobián", "initials": "J. M. G. C."} {"name": "José María Luzón Cuesta", "given": "José", "middle": "María Luzón", "family": "Cuesta", "initials": "J. M. L. C."} {"name": "José María Ortega y Gasset", "given": "José", "middle": "María", "family": "Ortega y Gasset", "initials": "J. M. O. G."} {"name": "José María Otaolaurruchi Tobía", "given": "José", "middle": "María Otaolaurruchi", "family": "Tobía", "initials": "J. M. O. T."} {"name": "José María Yáñez Carrillo", "given": "José", "middle": "María Yáñez", "family": "Carrillo", "initials": "J. M. Y. C."} {"name": "José Mataix Monllor", "given": "José", "middle": "Mataix", "family": "Monllor", "initials": "J. M. M."} {"name": "José Perianes Carro", "given": "José", "middle": "Perianes", "family": "Carro", "initials": "J. P. C."} {"name": "José Robles Pazos", "given": "José", "middle": "Robles", "family": "Pazos", "initials": "J. R. P."} {"name": "José Silva Júnior", "given": "José", "family": "Silva", "suffix": "Júnior", "initials": "J. S."} {"name": "José Vilató Ruiz", "given": "José", "middle": "Vilató", "family": "Ruiz", "initials": "J. V. R."} {"name": "Jovan Dragan Popović", "given": "Jovan", "middle": "Dragan", "family": "Popović", "initials": "J. D. P."} {"name": "Joybert, Marc de", "given": "Marc", "family": "de Joybert", "initials": "M. J."} {"name": "Jozef", "given": "Jozef", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "J."} {"name": "Jozef", "order": "FAMILY_FIRST", "family": "Jozef", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "J."} {"name": "João Pedro Silva", "given": "João", "middle": "Pedro", "family": "Silva", "initials": "J. P. S."} {"name": "Jo김민준씨, DO", "family": "Jo김민준", "suffix": "씨, DO", "initials": "J."} {"name": "Jr.", "title": "Jr."} {"name": "Jr. Ph. D.", "title": "Jr.", "suffix": "Ph. D."} {"name": "Jr. Van Johnson", "title": "Jr.", "given": "Van", "family": "Johnson", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "V. J."} {"name": "Jr., PhD", "family": "Jr.", "suffix": "PhD", "initials": "J."} {"name": "Juan & Garcia", "given": "Juan & Garcia", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "J. G."} {"name": "Juan Anglada y Ruiz", "given": "Juan", "family": "Anglada y Ruiz", "initials": "J. A. R."} {"name": "Juan Antonio Rascón Navarro Seña y Redondo", "given": "Juan", "middle": "Antonio Rascón Navarro", "family": "Seña y Redondo", "initials": "J. A. R. N. S. R."} {"name": "Juan Antonio de los Heros Fernández", "given": "Juan", "middle": "Antonio", "family": "de los Heros Fernández", "initials": "J. A. H. F."} {"name": "Juan C. Garaikoetxea Urriza", "given": "Juan", "middle": "C. Garaikoetxea", "family": "Urriza", "initials": "J. C. G. U."} {"name": "Juan Carlos Pérez", "given": "Juan", "middle": "Carlos", "family": "Pérez", "initials": "J. C. P."} {"name": "Juan Carlos Rodríguez Agudelo", "given": "Juan", "middle": "Carlos Rodríguez", "family": "Agudelo", "initials": "J. C. R. A."} {"name": "Juan Cárdenas Arroyo", "given": "Juan", "middle": "Cárdenas", "family": "Arroyo", "initials": "J. C. A."} {"name": "Juan Fernández Bazán", "given": "Juan", "middle": "Fernández", "family": "Bazán", "initials": "J. F. B."} {"name": "Juan Garcia Lopez y", "given": "Juan", "middle": "Garcia", "family": "Lopez y", "initials": "J. G. L."} {"name": "Juan Garcia Y Lopez", "given": "Juan", "middle": "Garcia Y", "family": "Lopez", "initials": "J. G. Y. L."} {"name": "Juan Garcia y Lopez", "given": "Juan", "family": "Garcia y Lopez", "initials": "J. G. L."} {"name": "Juan Garcia y Lopez y", "given": "Juan", "family": "Garcia y Lopez y", "initials": "J. G. L."} {"name": "Juan Harguindey Broussain", "given": "Juan", "middle": "Harguindey", "family": "Broussain", "initials": "J. H. B."} {"name": "Juan McDonald", "given": "Juan", "family": "McDonald", "initials": "J. M."} {"name": "Juan Pérez H.", "given": "Juan", "middle": "Pérez", "family": "H.", "initials": "J. P. H."} {"name": "Juan Pérez de la Vega", "given": "Juan", "middle": "Pérez", "family": "de la Vega", "initials": "J. P. V."} {"name": "Juan Q. Velasquez y Garcia", "given": "Juan", "middle": "Q.", "family": "Velasquez y Garcia", "initials": "J. Q. V. G."} {"name": "Juan Q. Velasquez y Garcia III", "given": "Juan", "middle": "Q.", "family": "Velasquez y Garcia", "suffix": "III", "initials": "J. Q. V. G."} {"name": "Juan Q. Velasquez y Garcia, Jr.", "given": "Juan", "middle": "Q.", "family": "Velasquez y Garcia", "suffix": "Jr.", "initials": "J. Q. V. G."} {"name": "Juan Q. Xavier Velasquez y Garcia", "given": "Juan", "middle": "Q. Xavier", "family": "Velasquez y Garcia", "initials": "J. Q. X. V. G."} {"name": "Juan Q. Xavier Velasquez y Garcia III", "given": "Juan", "middle": "Q. Xavier", "family": "Velasquez y Garcia", "suffix": "III", "initials": "J. Q. X. V. G."} {"name": "Juan Q. Xavier Velasquez y Garcia, Jr.", "given": "Juan", "middle": "Q. Xavier", "family": "Velasquez y Garcia", "suffix": "Jr.", "initials": "J. Q. X. V. G."} {"name": "Juan Q. Xavier de la Vega", "given": "Juan", "middle": "Q. Xavier", "family": "de la Vega", "initials": "J. Q. X. V."} {"name": "Juan Q. Xavier de la Vega III", "given": "Juan", "middle": "Q. Xavier", "family": "de la Vega", "suffix": "III", "initials": "J. Q. X. V."} {"name": "Juan Q. Xavier de la Vega, Jr.", "given": "Juan", "middle": "Q. Xavier", "family": "de la Vega", "suffix": "Jr.", "initials": "J. Q. X. V."} {"name": "Juan Q. de la Vega", "given": "Juan", "middle": "Q.", "family": "de la Vega", "initials": "J. Q. V."} {"name": "Juan Q. de la Vega III", "given": "Juan", "middle": "Q.", "family": "de la Vega", "suffix": "III", "initials": "J. Q. V."} {"name": "Juan Q. de la Vega, Jr.", "given": "Juan", "middle": "Q.", "family": "de la Vega", "suffix": "Jr.", "initials": "J. Q. V."} {"name": "Juan Samper Sordo", "given": "Juan", "middle": "Samper", "family": "Sordo", "initials": "J. S. S."} {"name": "Juan Santos Fernández", "given": "Juan", "middle": "Santos", "family": "Fernández", "initials": "J. S. F."} {"name": "Juan Sordo Madaleno", "given": "Juan", "middle": "Sordo", "family": "Madaleno", "initials": "J. S. M."} {"name": "Juan Suñé Benages", "given": "Juan", "middle": "Suñé", "family": "Benages", "initials": "J. S. B."} {"name": "Juan Torres de Vera y Aragón", "given": "Juan", "middle": "Torres", "family": "de Vera y Aragón", "initials": "J. T. V. A."} {"name": "Juan Velasquez y Garcia", "given": "Juan", "family": "Velasquez y Garcia", "initials": "J. V. G."} {"name": "Juan Velasquez y Garcia III", "given": "Juan", "family": "Velasquez y Garcia", "suffix": "III", "initials": "J. V. G."} {"name": "Juan Velasquez y Garcia, Jr.", "given": "Juan", "family": "Velasquez y Garcia", "suffix": "Jr.", "initials": "J. V. G."} {"name": "Juan Zuleta Ferrer", "given": "Juan", "middle": "Zuleta", "family": "Ferrer", "initials": "J. Z. F."} {"name": "Juan and Garcia", "given": "Juan and Garcia", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "J. G."} {"name": "Juan de", "given": "Juan", "family": "de", "initials": "J. d.", "capitalized_force": "Juan De"} {"name": "Juan de Ayolas", "given": "Juan", "family": "de Ayolas", "initials": "J. A."} {"name": "Juan de Courten y González", "given": "Juan", "family": "de Courten y González", "initials": "J. C. G."} {"name": "Juan de Dios Sierra y Velásquez", "given": "Juan", "family": "de Dios Sierra y Velásquez", "initials": "J. D. S. V."} {"name": "Juan de la Vega", "given": "Juan", "family": "de la Vega", "initials": "J. V."} {"name": "Juan de la Vega III", "given": "Juan", "family": "de la Vega", "suffix": "III", "initials": "J. V."} {"name": "Juan de la Vega Rodríguez", "given": "Juan", "family": "de la Vega Rodríguez", "initials": "J. V. R."} {"name": "Juan de la Vega, Jr.", "given": "Juan", "family": "de la Vega", "suffix": "Jr.", "initials": "J. V."} {"name": "Juan de la de la Vega", "given": "Juan", "family": "de la de la Vega", "initials": "J. V."} {"name": "Juan de la de la Vega Jr.", "given": "Juan", "family": "de la de la Vega", "suffix": "Jr.", "initials": "J. V."} {"name": "Juan de la de la de la Vega", "given": "Juan", "family": "de la de la de la Vega", "initials": "J. V."} {"name": "Juan de y", "given": "Juan", "family": "de y", "initials": "J. y."} {"name": "Juan del Sur", "given": "Juan", "family": "del Sur", "initials": "J. S."} {"name": "Juan garcia III", "given": "Juan", "family": "garcia", "suffix": "III", "initials": "J. g.", "capitalized_force": "Juan Garcia III"} {"name": "Juan van der", "given": "Juan", "family": "van der", "initials": "J. v. d.", "capitalized_force": "Juan Van Der"} {"name": "Juan y Eva Garcia", "given": "Juan y Eva", "family": "Garcia", "initials": "J. E. G."} {"name": "Juan y Garcia", "given": "Juan", "middle": "y", "family": "Garcia", "initials": "J. y. G."} {"name": "Juan y Garcia née Jones", "given": "Juan", "middle": "y", "family": "Garcia", "maiden": "Jones", "initials": "J. y. G."} {"name": "Juan Ó Pérez", "given": "Juan", "family": "Ó Pérez", "initials": "J. P."} {"name": "Juan Ó. Pérez", "given": "Juan", "middle": "Ó.", "family": "Pérez", "initials": "J. Ó. P."} {"name": "Judge C Lynwood Smith, Jr", "title": "Judge", "given": "C", "middle": "Lynwood", "family": "Smith", "suffix": "Jr", "initials": "C. L. S."} {"name": "Judge G. Thomas Eisele", "title": "Judge", "given": "G.", "middle": "Thomas", "family": "Eisele", "initials": "G. T. E."} {"name": "Judge James M. Moody", "title": "Judge", "given": "James", "middle": "M.", "family": "Moody", "initials": "J. M. M."} {"name": "Julia Roberts", "given": "Julia", "family": "Roberts", "initials": "J. R."} {"name": "Julie Traberth", "given": "Julie", "family": "Traberth", "initials": "J. T."} {"name": "Julio Mario Santo Domingo Pumarejo", "given": "Julio", "middle": "Mario Santo Domingo", "family": "Pumarejo", "initials": "J. M. S. D. P."} {"name": "Julio Roberto Gómez Esguerra", "given": "Julio", "middle": "Roberto Gómez", "family": "Esguerra", "initials": "J. R. G. E."} {"name": "Julius van Zuylen van Nijevelt", "given": "Julius", "middle": "van Zuylen", "family": "van Nijevelt", "initials": "J. Z. N."} {"name": "Julián Pascual Nunell", "given": "Julián", "middle": "Pascual", "family": "Nunell", "initials": "J. P. N."} {"name": "Julián Ruiz Martorell", "given": "Julián", "middle": "Ruiz", "family": "Martorell", "initials": "J. R. M."} {"name": "Jun Hoe", "given": "Jun", "family": "Hoe", "initials": "J. H."} {"name": "Jurgis", "given": "Jurgis", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "J."} {"name": "Jurgis", "order": "FAMILY_FIRST", "family": "Jurgis", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "J."} {"name": "Jurij", "given": "Jurij", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "J."} {"name": "Jurij", "order": "FAMILY_FIRST", "family": "Jurij", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "J."} {"name": "Jurk", "given": "Jurk", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "J."} {"name": "Juro", "given": "Juro", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "J."} {"name": "Juro", "order": "FAMILY_FIRST", "family": "Juro", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "J."} {"name": "Justo Novo Verdes", "given": "Justo", "middle": "Novo", "family": "Verdes", "initials": "J. N. V."} {"name": "Ján Matej Slovák", "given": "Ján", "middle": "Matej", "family": "Slovák", "initials": "J. M. S."} {"name": "Jászay, Pál", "given": "Pál", "family": "Jászay", "initials": "P. J."} {"name": "Jóhann Konráðsson", "given": "Jóhann", "family": "Konráðsson", "initials": "J. K."} {"name": "Jóhanna Sigurðardóttir", "given": "Jóhanna", "family": "Sigurðardóttir", "initials": "J. S."} {"name": "Jón Hannes Valdimarsson", "given": "Jón", "middle": "Hannes", "family": "Valdimarsson", "initials": "J. H. V."} {"name": "Jón Hannes Valdimarsson", "order": "FAMILY_FIRST", "given": "Hannes", "middle": "Valdimarsson", "family": "Jón", "initials": "H. V. J."} {"name": "Jón Helgason", "given": "Jón", "family": "Helgason", "initials": "J. H."} {"name": "Jón Leifs", "given": "Jón", "family": "Leifs", "initials": "J. L."} {"name": "Jón Magnússon", "given": "Jón", "family": "Magnússon", "initials": "J. M."} {"name": "Jón Páll Sigmarsson", "given": "Jón", "middle": "Páll", "family": "Sigmarsson", "initials": "J. P. S."} {"name": "Jón Sigurðsson", "given": "Jón", "family": "Sigurðsson", "initials": "J. S."} {"name": "Jón Steingrímsson", "given": "Jón", "family": "Steingrímsson", "initials": "J. S."} {"name": "Jón Árnason", "given": "Jón", "family": "Árnason", "initials": "J. Á."} {"name": "Jón Þorláksson", "given": "Jón", "family": "Þorláksson", "initials": "J. Þ."} {"name": "Jónas Jónasson", "given": "Jónas", "family": "Jónasson", "initials": "J. J."} {"name": "Jónína Jónatansdóttir", "given": "Jónína", "family": "Jónatansdóttir", "initials": "J. J."} {"name": "Júlíus Agnarsson", "given": "Júlíus", "family": "Agnarsson", "initials": "J. A."} {"name": "Jānis", "given": "Jānis", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "J."} {"name": "Jānis", "order": "FAMILY_FIRST", "family": "Jānis", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "J."} {"name": "K. Sivakumar", "given": "K.", "family": "Sivakumar", "initials": "K. S."} {"name": "KALAM Alɛ Mustafa", "order": "FAMILY_FIRST", "given": "Alɛ", "middle": "Mustafa", "family": "KALAM", "initials": "A. M. K.", "capitalized_force": "Alɛ Mustafa Kalam"} {"name": "KANSULO Iiɖa", "order": "FAMILY_FIRST", "given": "Iiɖa", "family": "KANSULO", "initials": "I. K.", "capitalized_force": "Iiɖa Kansulo"} {"name": "KIM, AE-RA", "family": "KIM", "suffix": "AE-RA", "initials": "K.", "capitalized": "Kim AE-RA"} {"name": "Kajal Patel Hiring Globally", "given": "Kajal", "middle": "Patel Hiring", "family": "Globally", "initials": "K. P. H. G."} {"name": "Kalniņa Aina", "order": "FAMILY_FIRST", "given": "Aina", "family": "Kalniņa", "initials": "A. K."} {"name": "Kamāl Yūsuf al-Ḥāǧǧ", "given": "Kamāl", "middle": "Yūsuf", "family": "al-Ḥāǧǧ", "initials": "K. Y. a."} {"name": "Kant, Anna Karolína Eva Kristína von (Mgr. Ing., CSc.)", "given": "Anna", "middle": "Karolína Eva Kristína von Mgr. Ing.", "family": "Kant", "suffix": "CSc.", "initials": "A. K. E. K. M. I. K.", "capitalized_force": "Anna Karolína Eva Kristína von Mgr. Ing. Kant CSC."} {"name": "Kar, Ida", "given": "Ida", "family": "Kar", "initials": "I. K."} {"name": "Karima", "given": "Karima", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "K."} {"name": "Karima", "order": "FAMILY_FIRST", "family": "Karima", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "K."} {"name": "Karima Balóch", "given": "Karima", "family": "Balóch", "initials": "K. B."} {"name": "Karima Mehráb Balóch", "given": "Karima", "middle": "Mehráb", "family": "Balóch", "initials": "K. M. B."} {"name": "Karl August Ferdinand von Borcke", "given": "Karl", "middle": "August Ferdinand", "family": "von Borcke", "initials": "K. A. F. B."} {"name": "Karl August von Hase", "given": "Karl", "middle": "August", "family": "von Hase", "initials": "K. A. H."} {"name": "Karl Einarsson", "given": "Karl", "family": "Einarsson", "initials": "K. E."} {"name": "Karl I.", "given": "Karl", "family": "I.", "initials": "K. I."} {"name": "Karl Lustig-Prean", "given": "Karl", "family": "Lustig-Prean", "initials": "K. L."} {"name": "Karl Pfeffer-Wildenbruch", "given": "Karl", "family": "Pfeffer-Wildenbruch", "initials": "K. P."} {"name": "Karl von Struve", "given": "Karl", "family": "von Struve", "initials": "K. S."} {"name": "Kask Jaak Eerik", "order": "FAMILY_FIRST", "given": "Jaak", "middle": "Eerik", "family": "Kask", "initials": "J. E. K."} {"name": "Kask, Jaak Eerik", "given": "Jaak", "middle": "Eerik", "family": "Kask", "initials": "J. E. K."} {"name": "Kate Miller", "given": "Kate", "family": "Miller", "initials": "K. M."} {"name": "Kathe Muller", "given": "Kathe", "family": "Muller", "initials": "K. M."} {"name": "Kathi Muller", "given": "Kathi", "family": "Muller", "initials": "K. M."} {"name": "Kathy Mueller", "given": "Kathy", "family": "Mueller", "initials": "K. M."} {"name": "Katie Stefan", "given": "Katie", "family": "Stefan", "initials": "K. S."} {"name": "Katrine", "given": "Katrine", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "K."} {"name": "Katrine", "order": "FAMILY_FIRST", "family": "Katrine", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "K."} {"name": "Katryn Müller", "given": "Katryn", "family": "Müller", "initials": "K. M."} {"name": "Katrín Jakobsdóttir", "given": "Katrín", "family": "Jakobsdóttir", "initials": "K. J."} {"name": "Katrín Júlíusdóttir", "given": "Katrín", "family": "Júlíusdóttir", "initials": "K. J."} {"name": "Katy Mané", "given": "Katy", "family": "Mané", "initials": "K. M."} {"name": "Kavitha a/p Raman", "given": "Kavitha", "middle": "a/p", "family": "Raman", "initials": "K. a. R.", "capitalized_force": "Kavitha A/P Raman"} {"name": "Kaya Yağız Can", "order": "FAMILY_FIRST", "given": "Yağız", "middle": "Can", "family": "Kaya", "initials": "Y. C. K."} {"name": "Kaya, Yağız Can", "given": "Yağız", "middle": "Can", "family": "Kaya", "initials": "Y. C. K."} {"name": "Kazlauskienė Irena", "order": "FAMILY_FIRST", "given": "Irena", "family": "Kazlauskienė", "initials": "I. K."} {"name": "Kazlauskienė, Irena", "given": "Irena", "family": "Kazlauskienė", "initials": "I. K."} {"name": "Kazó, Stefan", "given": "Stefan", "family": "Kazó", "initials": "S. K."} {"name": "Keita Millere", "given": "Keita", "family": "Millere", "initials": "K. M."} {"name": "Kennedy, John (Jack)", "given": "John", "family": "Kennedy", "nickname": "Jack", "initials": "J. K."} {"name": "Kenneth Clarke Q.C.", "given": "Kenneth", "family": "Clarke", "suffix": "Q.C.", "initials": "K. C."} {"name": "Kenneth Clarke Q.C., M.P.", "given": "Kenneth", "family": "Clarke", "suffix": "Q.C., M.P.", "initials": "K. C."} {"name": "Kenneth Clarke QC MP", "given": "Kenneth", "family": "Clarke", "suffix": "QC MP", "initials": "K. C."} {"name": "Kete Miler", "given": "Kete", "family": "Miler", "initials": "K. M."} {"name": "Kete Myuller", "given": "Kete", "family": "Myuller", "initials": "K. M."} {"name": "Kete Müller", "given": "Kete", "family": "Müller", "initials": "K. M."} {"name": "Ketill Jensson", "given": "Ketill", "family": "Jensson", "initials": "K. J."} {"name": "Kevin", "given": "Kevin", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "K."} {"name": "Kevin", "order": "FAMILY_FIRST", "family": "Kevin", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "K."} {"name": "Khaled el-Hassani ben el-Hachemi", "given": "Khaled", "middle": "el-Hassani ben", "family": "el-Hachemi", "initials": "K. e. b. e.", "capitalized_force": "Khaled El-Hassani Ben El-Hachemi"} {"name": "Kho Liang Ie", "given": "Kho", "middle": "Liang", "family": "Ie", "initials": "K. L. I."} {"name": "Khun Malee", "given": "Khun", "family": "Malee", "initials": "K. M."} {"name": "Khun Sa", "given": "Khun", "family": "Sa", "initials": "K. S."} {"name": "Ki, Tomonori", "given": "Tomonori", "family": "Ki", "initials": "T. K."} {"name": "Kim Min Ho", "order": "FAMILY_FIRST", "given": "Min", "middle": "Ho", "family": "Kim", "initials": "M. H. K."} {"name": "Kim, AE-RA", "family": "Kim", "suffix": "AE-RA", "initials": "K."} {"name": "Kim, Ae-Ra", "given": "Ae-Ra", "family": "Kim", "initials": "A. K."} {"name": "Kim, Jr.", "family": "Kim", "suffix": "Jr.", "initials": "K."} {"name": "Kim김민준씨, MA", "family": "Kim김민준", "suffix": "씨, MA", "initials": "K."} {"name": "King Curtis", "given": "King", "family": "Curtis", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "K. C."} {"name": "King Henry", "given": "King", "family": "Henry", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "K. H."} {"name": "King John Alexander V", "given": "King", "middle": "John", "family": "Alexander", "suffix": "V", "ambiguities": ["SUFFIX_OR_NAME", "TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)", "SUFFIX_OR_NAME: initial as family (shape)"], "initials": "K. J. A."} {"name": "King John V.", "given": "King", "middle": "John", "family": "V.", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "K. J. V."} {"name": "Kishi, Kōichi", "given": "Kōichi", "family": "Kishi", "initials": "K. K."} {"name": "Kjetil Bjørn Løseth", "given": "Kjetil", "middle": "Bjørn", "family": "Løseth", "initials": "K. B. L."} {"name": "Klaus Poeck", "given": "Klaus", "family": "Poeck", "initials": "K. P."} {"name": "Klaus Schönert", "given": "Klaus", "family": "Schönert", "initials": "K. S."} {"name": "Klein, Hans Walter", "given": "Hans", "middle": "Walter", "family": "Klein", "initials": "H. W. K."} {"name": "Klingberg, Berta", "given": "Berta", "family": "Klingberg", "initials": "B. K."} {"name": "Klingenberg, Johann", "given": "Johann", "family": "Klingenberg", "initials": "J. K."} {"name": "Kolko, Gabriel", "given": "Gabriel", "family": "Kolko", "initials": "G. K."} {"name": "Kong, Yingda", "given": "Yingda", "family": "Kong", "initials": "Y. K."} {"name": "Konráð Gíslason", "given": "Konráð", "family": "Gíslason", "initials": "K. G."} {"name": "Konstantin VII.", "given": "Konstantin", "suffix": "VII.", "ambiguities": ["GIVEN_OR_FAMILY", "SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)", "GIVEN_OR_FAMILY: name as family (position)"], "initials": "K."} {"name": "Kotryna Miliūtė", "given": "Kotryna", "family": "Miliūtė", "initials": "K. M."} {"name": "Kovač Ana Marija Vera", "order": "FAMILY_FIRST", "given": "Ana", "middle": "Marija Vera", "family": "Kovač", "initials": "A. M. V. K."} {"name": "Kovač, Ana Marija Vera", "given": "Ana", "middle": "Marija Vera", "family": "Kovač", "initials": "A. M. V. K."} {"name": "Kovács, Attila", "given": "Attila", "family": "Kovács", "initials": "A. K."} {"name": "Kováčová Jana", "order": "FAMILY_FIRST", "given": "Jana", "family": "Kováčová", "initials": "J. K."} {"name": "Kováčová, Jana", "given": "Jana", "family": "Kováčová", "initials": "J. K."} {"name": "Kowalska Maria Kamila", "order": "FAMILY_FIRST", "given": "Maria", "middle": "Kamila", "family": "Kowalska", "initials": "M. K. K."} {"name": "Kraja, Majlinda", "given": "Majlinda", "family": "Kraja", "initials": "M. K."} {"name": "Kraja, Majlinda", "order": "FAMILY_FIRST", "given": "Majlinda", "family": "Kraja", "initials": "M. K."} {"name": "Kranjčević Silvije Strahimir", "order": "FAMILY_FIRST", "given": "Silvije", "middle": "Strahimir", "family": "Kranjčević", "initials": "S. S. K."} {"name": "Krawc, Jan Beno", "given": "Jan", "middle": "Beno", "family": "Krawc", "initials": "J. B. K."} {"name": "Krawc, Jan Beno", "order": "FAMILY_FIRST", "given": "Jan", "middle": "Beno", "family": "Krawc", "initials": "J. B. K."} {"name": "Kristinn Hrafnsson", "given": "Kristinn", "family": "Hrafnsson", "initials": "K. H."} {"name": "Kristján Eldjárn", "given": "Kristján", "family": "Eldjárn", "initials": "K. E."} {"name": "Kristján Kristjánsson", "given": "Kristján", "family": "Kristjánsson", "initials": "K. K."} {"name": "Kristján Örn Sigurðsson", "given": "Kristján", "middle": "Örn", "family": "Sigurðsson", "initials": "K. Ö. S."} {"name": "Kristín Ólafsdóttir", "given": "Kristín", "family": "Ólafsdóttir", "initials": "K. Ó."} {"name": "Krosigk, Johann Adolf von", "given": "Johann", "middle": "Adolf", "family": "von Krosigk", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "J. A. K."} {"name": "Kulina, Hana", "given": "Hana", "family": "Kulina", "initials": "H. K."} {"name": "Kulina, Hana", "order": "FAMILY_FIRST", "given": "Hana", "family": "Kulina", "initials": "H. K."} {"name": "Kusmanek, Hermann von", "given": "Hermann", "family": "von Kusmanek", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "H. K."} {"name": "Kállai, Gábor", "given": "Gábor", "family": "Kállai", "initials": "G. K."} {"name": "Kári Árnason", "given": "Kári", "family": "Árnason", "initials": "K. Á."} {"name": "Kägebein, Heinz", "given": "Heinz", "family": "Kägebein", "initials": "H. K."} {"name": "Käthe Müller", "given": "Käthe", "family": "Müller", "initials": "K. M."} {"name": "Käthe Müller", "order": "FAMILY_FIRST", "given": "Müller", "family": "Käthe", "initials": "M. K."} {"name": "Kōno, Takashi", "given": "Takashi", "family": "Kōno", "initials": "T. K."} {"name": "Kőrösi, Albin", "given": "Albin", "family": "Kőrösi", "initials": "A. K."} {"name": "L'Aubespine, François de", "given": "François", "family": "de L'Aubespine", "initials": "F. L."} {"name": "L. U. P. O.", "given": "L.", "middle": "U. P.", "family": "O.", "initials": "L. U. P. O."} {"name": "LEED AP", "given": "LEED", "family": "AP", "initials": "L. A.", "capitalized": "Leed Ap"} {"name": "LT. GEN. JOHN A. KENNETH DOE IV", "title": "LT. GEN.", "given": "JOHN", "middle": "A. KENNETH", "family": "DOE", "suffix": "IV", "initials": "J. A. K. D.", "capitalized": "Lt. Gen. John A. Kenneth Doe IV"} {"name": "LT.GOV. JOHN DOE", "title": "LT.GOV.", "given": "JOHN", "family": "DOE", "initials": "J. D.", "capitalized": "Lt.Gov. John Doe"} {"name": "La Barre, Jean-François Lefebvre de", "given": "Jean-François", "middle": "Lefebvre", "family": "de La Barre", "initials": "J. L. B.", "capitalized_force": "Jean-François Lefebvre de la Barre"} {"name": "La Croix, Edmond de", "given": "Edmond", "family": "de La Croix", "initials": "E. C.", "capitalized_force": "Edmond de la Croix"} {"name": "La Shawn K. Ford", "given": "La", "middle": "Shawn K.", "family": "Ford", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "L. S. K. F."} {"name": "La'tanya O'connor", "given": "La'tanya", "family": "O'connor", "initials": "L. O.", "capitalized_force": "La'Tanya O'Connor"} {"name": "Lacerda, Armando de", "given": "Armando", "family": "de Lacerda", "initials": "A. L."} {"name": "Lala Lajpat Rai", "given": "Lala", "middle": "Lajpat", "family": "Rai", "initials": "L. L. R."} {"name": "Lalit", "given": "Lalit", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "L."} {"name": "Lalit", "order": "FAMILY_FIRST", "family": "Lalit", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "L."} {"name": "Lalit Bharati", "given": "Lalit", "family": "Bharati", "initials": "L. B."} {"name": "Lalit Mohan Bharati", "given": "Lalit", "middle": "Mohan", "family": "Bharati", "initials": "L. M. B."} {"name": "Lama Zopa", "title": "Lama", "given": "Zopa", "initials": "Z."} {"name": "Lamha Yahya Al Mawali", "given": "Lamha", "middle": "Yahya", "family": "Al Mawali", "initials": "L. Y. M.", "capitalized_force": "Lamha Yahya al Mawali"} {"name": "Lange, F. de", "given": "F.", "family": "de Lange", "initials": "F. L."} {"name": "Lansere, Nikolaj Evgenʹevič", "given": "Nikolaj", "middle": "Evgenʹevič", "family": "Lansere", "initials": "N. E. L."} {"name": "Larry James Johnson I", "given": "Larry", "middle": "James", "family": "Johnson", "suffix": "I", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: initial as family (shape)"], "initials": "L. J. J."} {"name": "Larry V I", "given": "Larry", "middle": "V", "family": "I", "initials": "L. V. I."} {"name": "Lastname, Firstname", "given": "Firstname", "family": "Lastname", "initials": "F. L."} {"name": "Lau, Wing", "given": "Wing", "family": "Lau", "initials": "W. L."} {"name": "Lau, Wing Commander", "title": "Wing Commander", "family": "Lau", "initials": "L."} {"name": "Laura Ángeles Lastra Santos", "given": "Laura", "middle": "Ángeles Lastra", "family": "Santos", "initials": "L. Á. L. S."} {"name": "Laureano Veres Acevedo", "given": "Laureano", "middle": "Veres", "family": "Acevedo", "initials": "L. V. A."} {"name": "Lavardin, Jean de Beaumanoir de", "given": "Jean", "middle": "de Beaumanoir", "family": "de Lavardin", "initials": "J. B. L."} {"name": "Laṭīfa az-Zaiyāt", "given": "Laṭīfa", "family": "az-Zaiyāt", "initials": "L. a.", "capitalized_force": "Laṭīfa Az-Zaiyāt"} {"name": "Lederle, Albert", "given": "Albert", "family": "Lederle", "initials": "A. L."} {"name": "Ledi Sayadaw", "given": "Ledi", "family": "Sayadaw", "initials": "L. S."} {"name": "Lehtola-Aalto Marja-Liisa Katariina, kansanedustaja", "order": "FAMILY_FIRST", "given": "kansanedustaja", "family": "Lehtola-Aalto Marja-Liisa Katariina", "initials": "k. L. M. K.", "capitalized_force": "Kansanedustaja Lehtola-Aalto Marja-Liisa Katariina"} {"name": "Lehtola-Aalto, Marja-Liisa Katariina", "given": "Marja-Liisa", "middle": "Katariina", "family": "Lehtola-Aalto", "initials": "M. K. L."} {"name": "Lejnikowa, Hejba-Marja Hana", "given": "Hejba-Marja", "middle": "Hana", "family": "Lejnikowa", "initials": "H. H. L."} {"name": "Lejnikowa, dr. Hejba-Marja Hana jr. phd", "order": "FAMILY_FIRST", "title": "dr.", "given": "Hejba-Marja", "middle": "Hana", "family": "Lejnikowa", "suffix": "jr. phd", "initials": "H. H. L.", "capitalized_force": "Dr. Hejba-Marja Hana Lejnikowa Jr. PhD"} {"name": "Lemaître, Adélaïde", "given": "Adélaïde", "family": "Lemaître", "initials": "A. L."} {"name": "Lemaître, Adélaïde", "order": "FAMILY_FIRST", "given": "Adélaïde", "family": "Lemaître", "initials": "A. L."} {"name": "Lena", "given": "Lena", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "L."} {"name": "Lena", "order": "FAMILY_FIRST", "family": "Lena", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "L."} {"name": "Leonardo Porto Egea", "given": "Leonardo", "middle": "Porto", "family": "Egea", "initials": "L. P. E."} {"name": "Leonor Alinari Adarve", "given": "Leonor", "middle": "Alinari", "family": "Adarve", "initials": "L. A. A."} {"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": "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."} {"name": "Libardo López Gómez", "given": "Libardo", "middle": "López", "family": "Gómez", "initials": "L. L. G."} {"name": "Libardo Mora Toro", "given": "Libardo", "middle": "Mora", "family": "Toro", "initials": "L. M. T."} {"name": "Libardo Simancas Torres", "given": "Libardo", "middle": "Simancas", "family": "Torres", "initials": "L. S. T."} {"name": "Liberty Hyde Bailey", "given": "Liberty", "middle": "Hyde", "family": "Bailey", "initials": "L. H. B."} {"name": "Lilja Alfreðsdóttir", "given": "Lilja", "family": "Alfreðsdóttir", "initials": "L. A."} {"name": "Lim Boo Liat", "given": "Lim", "middle": "Boo", "family": "Liat", "initials": "L. B. L."} {"name": "Lim Mei Ling Jessica", "order": "FAMILY_FIRST", "given": "Mei", "middle": "Ling Jessica", "family": "Lim", "initials": "M. L. J. L."} {"name": "Lipset, Seymour Martin", "given": "Seymour", "middle": "Martin", "family": "Lipset", "initials": "S. M. L."} {"name": "Lisa Schmidt, MBA", "given": "Lisa", "family": "Schmidt", "suffix": "MBA", "initials": "L. S."} {"name": "Lisandro Leyva Mazuera", "given": "Lisandro", "middle": "Leyva", "family": "Mazuera", "initials": "L. L. M."} {"name": "Livraga Rizzi, Jorge A.", "given": "Jorge", "middle": "A.", "family": "Livraga Rizzi", "initials": "J. A. L. R."} {"name": "Llorenç Ferrer Martí", "given": "Llorenç", "middle": "Ferrer", "family": "Martí", "initials": "L. F. M."} {"name": "Lloret Palol II, JA", "given": "Lloret", "family": "Palol", "suffix": "II, JA", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "L. P."} {"name": "Lloret Palol II, Josep Antoni", "given": "Josep", "middle": "Antoni", "family": "Lloret Palol", "suffix": "II", "initials": "J. A. L. P."} {"name": "Lloret Palol, Josep Antoni Carles Joan", "given": "Josep", "middle": "Antoni Carles Joan", "family": "Lloret Palol", "initials": "J. A. C. J. L. P."} {"name": "Lluis Carod i", "given": "Lluis", "family": "Carod", "suffix": "i", "initials": "L. C.", "capitalized_force": "Lluis Carod I"} {"name": "Lockroy", "given": "Lockroy", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "L."} {"name": "Lodewijk van Oranje-Nassau", "given": "Lodewijk", "family": "van Oranje-Nassau", "initials": "L. O."} {"name": "Loftur Guðmundsson", "given": "Loftur", "family": "Guðmundsson", "initials": "L. G."} {"name": "Lola", "given": "Lola", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "L."} {"name": "Lola", "order": "FAMILY_FIRST", "family": "Lola", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "L."} {"name": "Lombardi, Amarildo", "given": "Amarildo", "family": "Lombardi", "initials": "A. L."} {"name": "Lombardi, Amarildo A.", "order": "FAMILY_FIRST", "given": "Amarildo", "middle": "A.", "family": "Lombardi", "initials": "A. A. L."} {"name": "Lon (Jr.) Williams", "given": "Lon", "middle": "Jr.", "family": "Williams", "initials": "L. J. W."} {"name": "Lord Buddha Tamang", "title": "Lord", "given": "Buddha", "family": "Tamang", "initials": "B. T."} {"name": "Lord Chancellor", "title": "Lord", "family": "Chancellor", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "C."} {"name": "Lord Chancellor John Smith", "title": "Lord Chancellor", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "Lord God Almighty", "title": "Lord", "given": "God", "family": "Almighty", "initials": "G. A."} {"name": "Lord and of the Universe", "title": "Lord and of the Universe"} {"name": "Lord of the Universe", "title": "Lord of the Universe"} {"name": "Lord of the Universe and Associate Supreme Queen of the World Lisa Simpson", "title": "Lord of the Universe and Associate Supreme Queen of the World", "given": "Lisa", "family": "Simpson", "initials": "L. S."} {"name": "Lord of the Universe and Supreme King of the World Lisa Simpson", "title": "Lord of the Universe and Supreme King of the World", "given": "Lisa", "family": "Simpson", "initials": "L. S."} {"name": "Lorenzo di Pierfranceso de'Medici", "given": "Lorenzo", "family": "di Pierfranceso de'Medici", "initials": "L. P. d."} {"name": "Louhi", "given": "Louhi", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "L."} {"name": "Louhi", "order": "FAMILY_FIRST", "family": "Louhi", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "L."} {"name": "Louis Antoine d'Artois d'Angoulême", "given": "Louis", "middle": "Antoine d'Artois", "family": "d'Angoulême", "initials": "L. A. d. d.", "capitalized_force": "Louis Antoine D'Artois D'Angoulême"} {"name": "Louis de Funès", "given": "Louis", "family": "de Funès", "initials": "L. F."} {"name": "Louis de Grandmaison", "given": "Louis", "family": "de Grandmaison", "initials": "L. G."} {"name": "Louise Fitzhugh", "given": "Louise", "family": "Fitzhugh", "initials": "L. F."} {"name": "Louise Péricourt", "given": "Louise", "family": "Péricourt", "initials": "L. P."} {"name": "Lt. Gen. John A. Kenneth Doe IV", "title": "Lt. Gen.", "given": "John", "middle": "A. Kenneth", "family": "Doe", "suffix": "IV", "initials": "J. A. K. D."} {"name": "Lt. Gen. John A. Kenneth Doe, Jr.", "title": "Lt. Gen.", "given": "John", "middle": "A. Kenneth", "family": "Doe", "suffix": "Jr.", "initials": "J. A. K. D."} {"name": "Lt.Gen. John A. Kenneth Doe IV", "title": "Lt.Gen.", "given": "John", "middle": "A. Kenneth", "family": "Doe", "suffix": "IV", "initials": "J. A. K. D."} {"name": "Lt.Gov. John Doe", "title": "Lt.Gov.", "given": "John", "family": "Doe", "initials": "J. D."} {"name": "Lt.Gov. juan e garcia", "title": "Lt.Gov.", "given": "juan", "middle": "e", "family": "garcia", "initials": "j. e. g.", "capitalized_force": "Lt.Gov. Juan e Garcia"} {"name": "Luca Sarolta Éva Erzsébet von Kovács M.D. Ph.D.", "given": "Luca", "middle": "Sarolta Éva Erzsébet", "family": "von Kovács", "suffix": "M.D. Ph.D.", "initials": "L. S. É. E. K."} {"name": "Luciano Mendes de Almeida", "given": "Luciano", "middle": "Mendes", "family": "de Almeida", "initials": "L. M. A."} {"name": "Lucía Pérez", "given": "Lucía", "family": "Pérez", "initials": "L. P."} {"name": "Ludwig Hempel", "given": "Ludwig", "family": "Hempel", "initials": "L. H."} {"name": "Ludwig van Beethoven", "given": "Ludwig", "family": "van Beethoven", "initials": "L. B."} {"name": "Luis Díaz Moreno", "given": "Luis", "middle": "Díaz", "family": "Moreno", "initials": "L. D. M."} {"name": "Luis Fernando Giraldo Builes", "given": "Luis", "middle": "Fernando Giraldo", "family": "Builes", "initials": "L. F. G. B."} {"name": "Luis Gonzaga de Villava y Aybar", "given": "Luis", "middle": "Gonzaga", "family": "de Villava y Aybar", "initials": "L. G. V. A."} {"name": "Luis Guzmán Reveles", "given": "Luis", "middle": "Guzmán", "family": "Reveles", "initials": "L. G. R."} {"name": "Luis Martín-Santos", "given": "Luis", "family": "Martín-Santos", "initials": "L. M."} {"name": "Luis Mora Tovar", "given": "Luis", "middle": "Mora", "family": "Tovar", "initials": "L. M. T."} {"name": "Luis Polo de Bernabé", "given": "Luis", "middle": "Polo", "family": "de Bernabé", "initials": "L. P. B."} {"name": "Luis Van Rooten", "given": "Luis", "family": "Van Rooten", "initials": "L. R.", "capitalized_force": "Luis van Rooten"} {"name": "Luis Vélez de Guevara", "given": "Luis", "middle": "Vélez", "family": "de Guevara", "initials": "L. V. G."} {"name": "Luisa Álvarez Iglesias", "given": "Luisa", "middle": "Álvarez", "family": "Iglesias", "initials": "L. Á. I."} {"name": "Luiz Alfredo Garcia-Roza", "given": "Luiz", "middle": "Alfredo", "family": "Garcia-Roza", "initials": "L. A. G."} {"name": "Lukardis", "given": "Lukardis", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "L."} {"name": "Luo Si", "given": "Luo", "family": "Si", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "L. S."} {"name": "Lustig-Prean, Karl", "given": "Karl", "family": "Lustig-Prean", "initials": "K. L."} {"name": "Luís de Ataíde", "given": "Luís", "family": "de Ataíde", "initials": "L. A."} {"name": "Lárus Pálsson", "given": "Lárus", "family": "Pálsson", "initials": "L. P."} {"name": "László I.", "given": "László", "family": "I.", "initials": "L. I."} {"name": "López Domínguez, José", "given": "José", "family": "López Domínguez", "initials": "J. L. D."} {"name": "López Ochoa, Eduardo", "given": "Eduardo", "family": "López Ochoa", "initials": "E. L. O."} {"name": "López Portillo y Pacheco, José", "given": "José", "family": "López Portillo y Pacheco", "initials": "J. L. P. P."} {"name": "López Rayón, Ignacio", "given": "Ignacio", "family": "López Rayón", "initials": "I. L. R."} {"name": "López, Ana García", "given": "Ana", "middle": "García", "family": "López", "initials": "A. G. L."} {"name": "Løseth, Kjetil Bjørn", "given": "Kjetil", "middle": "Bjørn", "family": "Løseth", "initials": "K. B. L."} {"name": "Løseth, Kjetil Bjørn", "order": "FAMILY_FIRST", "given": "Kjetil", "middle": "Bjørn", "family": "Løseth", "initials": "K. B. L."} {"name": "Lúcio de Mendonça", "given": "Lúcio", "family": "de Mendonça", "initials": "L. M."} {"name": "Lžedmitrij II.", "given": "Lžedmitrij", "suffix": "II.", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "L."} {"name": "M. Jean-Nicolas Louis Marcel de Bouchart fils", "given": "M.", "middle": "Jean-Nicolas Louis Marcel", "family": "de Bouchart", "suffix": "fils", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (declared)"], "initials": "M. J. L. M. B."} {"name": "M. de Bouchart Jean-Nicolas Louis Marcel fils", "order": "FAMILY_FIRST", "given": "de Bouchart Jean-Nicolas Louis Marcel", "family": "M.", "suffix": "fils", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (declared)"], "initials": "d. B. J. L. M. M.", "capitalized_force": "De Bouchart Jean-Nicolas Louis Marcel M. fils"} {"name": "MANƉELA Rolanɖ Alfɔns", "order": "FAMILY_FIRST", "given": "Rolanɖ", "middle": "Alfɔns", "family": "MANƉELA", "initials": "R. A. M.", "capitalized_force": "Rolanɖ Alfɔns Manɖela"} {"name": "MARIA DA SILVA E SOUZA", "given": "MARIA", "family": "DA SILVA E SOUZA", "ambiguities": ["CONJUNCTION_OR_INITIAL"], "alternatives": ["CONJUNCTION_OR_INITIAL: conjunction as family (vocabulary)"], "initials": "M. S. E. S.", "capitalized": "Maria da Silva E Souza"} {"name": "MD - DO - DDS", "given": "MD", "middle": "-", "family": "DO -", "suffix": "DDS", "initials": "M. -. -.", "capitalized": "Md - do - DDS"} {"name": "MD Chirurgie dentară von der Ruhr Prof. dr. Eva-Eleonora Karl Hans Jr.", "order": "FAMILY_FIRST", "given": "Chirurgie", "middle": "dentară von der Ruhr Prof. dr. Eva-Eleonora Karl Hans", "family": "MD", "suffix": "Jr.", "initials": "C. d. R. P. d. E. K. H. M.", "capitalized_force": "Chirurgie Dentară von der Ruhr Prof. Dr. Eva-Eleonora Karl Hans Md Jr."} {"name": "MD DDS", "given": "MD", "suffix": "DDS", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "M.", "capitalized": "Md DDS"} {"name": "MD DDS Dut.ra Prof.ra von Brühl González Domingo Maria Giusepa Juana Teresa", "order": "FAMILY_FIRST", "given": "DDS", "middle": "Dut.ra Prof.ra von Brühl González Domingo Maria Giusepa Juana Teresa", "family": "MD", "initials": "D. D. P. B. G. D. M. G. J. T. M.", "capitalized_force": "Dds Dut.Ra Prof.Ra von Brühl González Domingo Maria Giusepa Juana Teresa Md"} {"name": "MD DDS von Brühl César Martín Ada Cornelia", "order": "FAMILY_FIRST", "given": "DDS", "middle": "von Brühl César Martín Ada Cornelia", "family": "MD", "initials": "D. B. C. M. A. C. M.", "capitalized_force": "Dds von Brühl César Martín Ada Cornelia Md"} {"name": "MD, DO, DDS", "given": "DO", "family": "MD", "suffix": "DDS", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "D. M.", "capitalized": "Do Md DDS"} {"name": "MD, PHD", "family": "MD", "suffix": "PHD", "initials": "M.", "capitalized": "Md PhD"} {"name": "MP Pēteris Kārlis Ozoliņš", "given": "MP", "middle": "Pēteris Kārlis", "family": "Ozoliņš", "initials": "M. P. K. O.", "capitalized_force": "Mp Pēteris Kārlis Ozoliņš"} {"name": "MSc Dr. med. univ.", "given": "MSc", "middle": "Dr. med.", "family": "univ.", "initials": "M. D. m. u.", "capitalized_force": "MSc Dr. Med. Univ."} {"name": "Ma III, Jack Jr", "given": "Jack", "family": "Ma", "suffix": "III, Jr", "initials": "J. M."} {"name": "Ma, Andrew", "given": "Andrew", "family": "Ma", "initials": "A. M."} {"name": "Ma, Jack", "given": "Jack", "family": "Ma", "initials": "J. M."} {"name": "Ma.", "given": "Ma.", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "M."} {"name": "Ma. Teresa Cruz Bautista", "given": "Ma.", "middle": "Teresa Cruz", "family": "Bautista", "initials": "M. T. C. B."} {"name": "Maazị Christopher Chukwudike Amadi MP", "given": "Maazị", "middle": "Christopher Chukwudike", "family": "Amadi", "suffix": "MP", "initials": "M. C. C. A."} {"name": "Mac Miller", "given": "Mac", "family": "Miller", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "M. M."} {"name": "Mac a’ Ghobhainn Chorùna, Mgr. Dòmhnall Iain Bàn am post", "order": "FAMILY_FIRST", "title": "Mgr.", "given": "Dòmhnall", "middle": "Iain Bàn", "family": "Mac a’ Ghobhainn Chorùna", "suffix": "am post", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as middle (position)"], "initials": "D. I. B. a. G. C.", "capitalized_force": "Mgr. Dòmhnall Iain Bàn mac A’ Ghobhainn Chorùna AM Post"} {"name": "Mac a’ Ghobhainn, Dòmhnall Iain Bàn", "given": "Dòmhnall", "middle": "Iain Bàn", "family": "Mac a’ Ghobhainn", "initials": "D. I. B. a. G.", "capitalized_force": "Dòmhnall Iain Bàn mac A’ Ghobhainn"} {"name": "Machbuba", "given": "Machbuba", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "M.", "capitalized_force": "MacHbuba"} {"name": "Machová, Alexandra Zuzana (paní, Ph.D.)", "given": "Alexandra", "middle": "Zuzana paní", "family": "Machová", "suffix": "Ph.D.", "initials": "A. Z. p. M.", "capitalized_force": "Alexandra Zuzana Paní MacHová Ph.D."} {"name": "Macáková, Alexandra Zuzana (pani, PhD)", "given": "Alexandra", "middle": "Zuzana", "family": "Macáková", "nickname": "pani, PhD", "initials": "A. Z. M.", "capitalized_force": "Alexandra \"Pani, PhD\" Zuzana MacÁková"} {"name": "Maddalena de'Medici", "given": "Maddalena", "family": "de'Medici", "initials": "M. d."} {"name": "Maella, Mariano Salvador de", "given": "Mariano", "middle": "Salvador", "family": "de Maella", "initials": "M. S. M."} {"name": "Mag-Judge Harwell G Davis, III", "title": "Mag-Judge", "given": "Harwell", "middle": "G", "family": "Davis", "suffix": "III", "initials": "H. G. D."} {"name": "Mag. Judge Byron G. Cudmore", "title": "Mag. Judge", "given": "Byron", "middle": "G.", "family": "Cudmore", "initials": "B. G. C."} {"name": "Magda von Lerch", "given": "Magda", "family": "von Lerch", "initials": "M. L."} {"name": "Magdalena Calonge i Panyella", "given": "Magdalena", "family": "Calonge i Panyella", "initials": "M. C. P."} {"name": "Magistrate Judge John F. Forster, Jr", "title": "Magistrate Judge", "given": "John", "middle": "F.", "family": "Forster", "suffix": "Jr", "initials": "J. F. F."} {"name": "Magistrate-Judge Elizabeth Todd Campbell", "title": "Magistrate-Judge", "given": "Elizabeth", "middle": "Todd", "family": "Campbell", "initials": "E. T. C."} {"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": "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."} {"name": "Mai Le née Nguyen", "given": "Mai", "family": "Le", "maiden": "Nguyen", "initials": "M. L."} {"name": "Maid Marion", "title": "Maid", "given": "Marion", "initials": "M."} {"name": "Maider", "given": "Maider", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "M."} {"name": "Maider", "order": "FAMILY_FIRST", "family": "Maider", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "M."} {"name": "Maier, Amy I, Jr.", "given": "Amy", "middle": "I", "family": "Maier", "suffix": "Jr.", "initials": "A. I. M."} {"name": "Maier, Amy Lauren I", "given": "Amy", "middle": "Lauren", "family": "Maier", "suffix": "I", "initials": "A. L. M."} {"name": "Majlinda Kraja", "given": "Majlinda", "family": "Kraja", "initials": "M. K."} {"name": "Major General John Smith", "title": "Major General", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "Major Harris", "given": "Major", "family": "Harris", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "M. H."} {"name": "Major Smith", "given": "Major", "family": "Smith", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "M. S."} {"name": "Major. Dona Smith", "title": "Major.", "given": "Dona", "family": "Smith", "initials": "D. S."} {"name": "Major. John Smith, Jr.", "title": "Major.", "given": "John", "family": "Smith", "suffix": "Jr.", "initials": "J. S."} {"name": "Makonnen Haile Selassie", "given": "Makonnen", "middle": "Haile", "family": "Selassie", "initials": "M. H. S."} {"name": "Malam Shehu Bello Modibbo MP", "given": "Malam", "middle": "Shehu Bello", "family": "Modibbo", "suffix": "MP", "initials": "M. S. B. M."} {"name": "Malas Zénāwi", "given": "Malas", "family": "Zénāwi", "initials": "M. Z."} {"name": "Malcolm X", "given": "Malcolm", "family": "X", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: generation as suffix (shape)"], "initials": "M. X."} {"name": "Maleingreau, Paul de", "given": "Paul", "family": "de Maleingreau", "initials": "P. M."} {"name": "Malik Ambar", "given": "Malik", "family": "Ambar", "initials": "M. A."} {"name": "Malik al-ʿĀdil al I.", "given": "Malik", "middle": "al-ʿĀdil", "family": "al I.", "initials": "M. a. I."} {"name": "Manuel Banet Fontenla", "given": "Manuel", "middle": "Banet", "family": "Fontenla", "initials": "M. B. F."} {"name": "Manuel Blanco Romasanta", "given": "Manuel", "middle": "Blanco", "family": "Romasanta", "initials": "M. B. R."} {"name": "Manuel Fernández Castrillón", "given": "Manuel", "middle": "Fernández", "family": "Castrillón", "initials": "M. F. C."} {"name": "Manuel Jerónimo Yerena y Camarena", "given": "Manuel", "middle": "Jerónimo", "family": "Yerena y Camarena", "initials": "M. J. Y. C."} {"name": "Manuel López González", "given": "Manuel", "middle": "López", "family": "González", "initials": "M. L. G."} {"name": "Manuel Marulanda Vélez", "given": "Manuel", "middle": "Marulanda", "family": "Vélez", "initials": "M. M. V."} {"name": "Manuel Puga y Acal", "given": "Manuel", "family": "Puga y Acal", "initials": "M. P. A."} {"name": "Manuel Serrano Blanco", "given": "Manuel", "middle": "Serrano", "family": "Blanco", "initials": "M. S. B."} {"name": "Manuel Sánchez Mármol", "given": "Manuel", "middle": "Sánchez", "family": "Mármol", "initials": "M. S. M."} {"name": "Manuel Tello Macías", "given": "Manuel", "middle": "Tello", "family": "Macías", "initials": "M. T. M.", "capitalized_force": "Manuel Tello MacÍas"} {"name": "Manuel Uruchurtu Ramírez", "given": "Manuel", "middle": "Uruchurtu", "family": "Ramírez", "initials": "M. U. R."} {"name": "Manuel Velasco y Martínez", "given": "Manuel", "family": "Velasco y Martínez", "initials": "M. V. M."} {"name": "Manuela Lema Villar", "given": "Manuela", "middle": "Lema", "family": "Villar", "initials": "M. L. V."} {"name": "Mané Katy", "order": "FAMILY_FIRST", "given": "Katy", "family": "Mané", "initials": "K. M."} {"name": "Maqrīzī, Aḥmad Ibn-ʿAlī al-", "given": "Aḥmad", "middle": "Ibn-ʿAlī", "family": "al- Maqrīzī", "initials": "A. I. M.", "capitalized_force": "Aḥmad ibn-ʿAlī al- Maqrīzī"} {"name": "Mar, Fien de la", "given": "Fien", "family": "de la Mar", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "F. M."} {"name": "Marc de Joybert", "given": "Marc", "family": "de Joybert", "initials": "M. J."} {"name": "Marco Antonio Flores", "given": "Marco", "middle": "Antonio", "family": "Flores", "initials": "M. A. F."} {"name": "Margarita Peña Muñoz", "given": "Margarita", "middle": "Peña", "family": "Muñoz", "initials": "M. P. M."} {"name": "Margrét Lára Viðarsdóttir", "given": "Margrét", "middle": "Lára", "family": "Viðarsdóttir", "initials": "M. L. V."} {"name": "Mari", "given": "Mari", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "M."} {"name": "Mari", "order": "FAMILY_FIRST", "family": "Mari", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "M."} {"name": "Mari shu Halimatu Waly", "given": "Mari", "middle": "shu Halimatu", "family": "Waly", "initials": "M. s. H. W.", "capitalized_force": "Mari Shu Halimatu Waly"} {"name": "Mari' Aube'", "given": "Mari'", "family": "Aube'", "initials": "M. A."} {"name": "Mari-Ann Henri Roobert Tammik, parlamendiliige", "given": "parlamendiliige", "family": "Mari-Ann Henri Roobert Tammik", "initials": "p. M. H. R. T.", "capitalized_force": "Parlamendiliige Mari-Ann Henri Roobert Tammik"} {"name": "Maria", "given": "Maria", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "M."} {"name": "Maria", "order": "FAMILY_FIRST", "family": "Maria", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "M."} {"name": "Maria Ana MA Ortega y Gasset", "given": "Maria", "middle": "Ana MA", "family": "Ortega y Gasset", "initials": "M. A. M. O. G.", "capitalized_force": "Maria Ana Ma Ortega y Gasset"} {"name": "Maria Ana MA Silva", "given": "Maria", "middle": "Ana MA", "family": "Silva", "initials": "M. A. M. S.", "capitalized_force": "Maria Ana Ma Silva"} {"name": "Maria Ana MA de la Vega", "given": "Maria", "middle": "Ana MA", "family": "de la Vega", "initials": "M. A. M. V.", "capitalized_force": "Maria Ana Ma de la Vega"} {"name": "Maria Firmina dos Reis", "given": "Maria", "middle": "Firmina", "family": "dos Reis", "initials": "M. F. R."} {"name": "Maria Hasani", "given": "Maria", "family": "Hasani", "initials": "M. H."} {"name": "Maria Helena Fernandes", "given": "Maria", "middle": "Helena", "family": "Fernandes", "initials": "M. H. F."} {"name": "Maria Kowalska", "given": "Maria", "family": "Kowalska", "initials": "M. K."} {"name": "Maria Kowalska (z domu Nowak)", "given": "Maria", "family": "Kowalska", "maiden": "Nowak", "initials": "M. K."} {"name": "Maria Kowalska (z domu)", "given": "Maria", "family": "Kowalska", "nickname": "z domu", "initials": "M. K.", "capitalized_force": "Maria \"Z Domu\" Kowalska"} {"name": "Maria Kowalska (z, domu Nowak)", "given": "Maria", "family": "Kowalska", "nickname": "z, domu Nowak", "initials": "M. K.", "capitalized_force": "Maria \"Z, Domu Nowak\" Kowalska"} {"name": "Maria Kowalska z domu Nowak", "given": "Maria", "family": "Kowalska", "maiden": "Nowak", "initials": "M. K."} {"name": "Maria Kowalska z domu Nowak MA", "given": "Maria", "family": "Kowalska", "suffix": "MA", "maiden": "Nowak", "initials": "M. K."} {"name": "Maria Luisa y de la Cruz", "given": "Maria", "middle": "Luisa y de", "family": "la Cruz", "initials": "M. L. C."} {"name": "Maria López née García Pérez", "given": "Maria", "family": "López", "maiden": "García Pérez", "initials": "M. L."} {"name": "Maria Popescu", "given": "Maria", "family": "Popescu", "initials": "M. P."} {"name": "Maria Silva", "given": "Maria", "family": "Silva", "initials": "M. S."} {"name": "Maria Wijaya", "given": "Maria", "family": "Wijaya", "initials": "M. W."} {"name": "Mariam Geedi Hamish Hussein", "given": "Mariam", "middle": "Geedi Hamish", "family": "Hussein", "initials": "M. G. H. H."} {"name": "Mariana Duque Mariño", "given": "Mariana", "middle": "Duque", "family": "Mariño", "initials": "M. D. M."} {"name": "Marianne Schmidt", "given": "Marianne", "family": "Schmidt", "initials": "M. S."} {"name": "Mariano Aguilar Barrera", "given": "Mariano", "middle": "Aguilar", "family": "Barrera", "initials": "M. A. B."} {"name": "Mariano Cuadra Medina", "given": "Mariano", "middle": "Cuadra", "family": "Medina", "initials": "M. C. M."} {"name": "Mariano Ospina Pérez", "given": "Mariano", "middle": "Ospina", "family": "Pérez", "initials": "M. O. P."} {"name": "Mariano Salvador de Maella", "given": "Mariano", "middle": "Salvador", "family": "de Maella", "initials": "M. S. M."} {"name": "Marie FILS", "given": "Marie", "family": "FILS", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: generation as suffix (vocabulary)"], "initials": "M. F.", "capitalized_force": "Marie Fils"} {"name": "Marie Fils", "given": "Marie", "family": "Fils", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: generation as suffix (vocabulary)"], "initials": "M. F."} {"name": "Marie Laure de Noailles", "given": "Marie", "middle": "Laure", "family": "de Noailles", "initials": "M. L. N."} {"name": "Marie Louise Fernande de Montpensier", "given": "Marie", "middle": "Louise Fernande", "family": "de Montpensier", "initials": "M. L. F. M."} {"name": "Marie-Agnès Suzanne Gilot", "given": "Marie-Agnès", "middle": "Suzanne", "family": "Gilot", "initials": "M. S. G."} {"name": "Marijana Tomislav Babić", "given": "Marijana", "middle": "Tomislav", "family": "Babić", "initials": "M. T. B."} {"name": "Mario", "given": "Mario", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "M."} {"name": "Mario", "order": "FAMILY_FIRST", "family": "Mario", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "M."} {"name": "Mario Ruiz Armengol", "given": "Mario", "middle": "Ruiz", "family": "Armengol", "initials": "M. R. A."} {"name": "Marić, Branko Hrvoje", "given": "Branko", "middle": "Hrvoje", "family": "Marić", "initials": "B. H. M."} {"name": "Marić, dr. sc. Branko Hrvoje mlađi, prof.", "order": "FAMILY_FIRST", "title": "dr. sc. prof.", "given": "Branko", "middle": "Hrvoje", "family": "Marić", "suffix": "mlađi", "initials": "B. H. M.", "capitalized_force": "Dr. Sc. Prof. Branko Hrvoje Marić Mlađi"} {"name": "Marja-Liisa Katariina Lehtola-Aalto kansanedustaja", "given": "Marja-Liisa", "middle": "Katariina Lehtola-Aalto", "family": "kansanedustaja", "initials": "M. K. L. k.", "capitalized_force": "Marja-Liisa Katariina Lehtola-Aalto Kansanedustaja"} {"name": "Mark", "given": "Mark", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "M."} {"name": "Mark", "order": "FAMILY_FIRST", "family": "Mark", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "M."} {"name": "Marković Irena", "order": "FAMILY_FIRST", "given": "Irena", "family": "Marković", "initials": "I. M."} {"name": "Marković, Irena", "given": "Irena", "family": "Marković", "initials": "I. M."} {"name": "Markus von Wickenburg", "given": "Markus", "family": "von Wickenburg", "initials": "M. W."} {"name": "Marquess of Bath", "title": "Marquess of Bath"} {"name": "Marquis Johnson", "given": "Marquis", "family": "Johnson", "initials": "M. J."} {"name": "Marquis de Lafayette", "given": "Marquis", "family": "de Lafayette", "initials": "M. L."} {"name": "Marshal Yanda", "given": "Marshal", "family": "Yanda", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "M. Y."} {"name": "Marsick, Martin Pierre", "given": "Martin", "middle": "Pierre", "family": "Marsick", "initials": "M. P. M."} {"name": "Martha Nava García", "given": "Martha", "middle": "Nava", "family": "García", "initials": "M. N. G."} {"name": "Martin Pierre Marsick", "given": "Martin", "middle": "Pierre", "family": "Marsick", "initials": "M. P. M."} {"name": "Martine Sodersen", "given": "Martine", "family": "Sodersen", "initials": "M. S."} {"name": "Martini, Fabio Massimo Carlo Alberto", "given": "Fabio", "middle": "Massimo Carlo Alberto", "family": "Martini", "initials": "F. M. C. A. M."} {"name": "Martín Descalzo, José Luis", "given": "José", "middle": "Luis", "family": "Martín Descalzo", "initials": "J. L. M. D."} {"name": "Martín Villazón Ochoa", "given": "Martín", "middle": "Villazón", "family": "Ochoa", "initials": "M. V. O."} {"name": "Martín-Santos, Luis", "given": "Luis", "family": "Martín-Santos", "initials": "L. M."} {"name": "Marulanda Vélez, Manuel", "given": "Manuel", "family": "Marulanda Vélez", "initials": "M. M. V."} {"name": "Mary Ann H", "given": "Mary", "middle": "Ann", "family": "H", "initials": "M. A. H."} {"name": "Mary Ann H Luther", "given": "Mary", "middle": "Ann H", "family": "Luther", "initials": "M. A. H. L."} {"name": "Mary Ann H,Luther", "given": "Luther", "family": "Mary Ann H", "initials": "L. M. A. H."} {"name": "Mary Beth Smith", "given": "Mary", "middle": "Beth", "family": "Smith", "initials": "M. B. S."} {"name": "Mary II, Dame", "title": "Dame", "given": "Mary", "suffix": "II", "initials": "M."} {"name": "Mary Jane King", "given": "Mary", "middle": "Jane", "family": "King", "initials": "M. J. K."} {"name": "Mary Jane MA Pope", "given": "Mary", "middle": "Jane MA", "family": "Pope", "initials": "M. J. M. P.", "capitalized_force": "Mary Jane Ma Pope"} {"name": "Mary Jane Pope", "given": "Mary", "middle": "Jane", "family": "Pope", "initials": "M. J. P."} {"name": "Mary Jane Pope Smith", "given": "Mary", "middle": "Jane Pope", "family": "Smith", "initials": "M. J. P. S."} {"name": "Mary Jane Pope.", "title": "Pope.", "given": "Mary", "family": "Jane", "initials": "M. J."} {"name": "Mary Johnson", "given": "Mary", "family": "Johnson", "initials": "M. J."} {"name": "Mary Nicet", "given": "Mary", "family": "Nicet", "initials": "M. N."} {"name": "Mary Smith née Jones Prof.", "title": "Prof.", "given": "Mary", "family": "Smith", "maiden": "Jones", "initials": "M. S."} {"name": "Mary Sue Hamish Watson", "given": "Mary", "middle": "Sue Hamish", "family": "Watson", "initials": "M. S. H. W."} {"name": "Mary-​Jane Smith", "given": "Mary-", "middle": "Jane", "family": "Smith", "initials": "M. J. S."} {"name": "María Dolores de Pablos", "given": "María", "middle": "Dolores", "family": "de Pablos", "initials": "M. D. P."} {"name": "María Losada Estévez", "given": "María", "middle": "Losada", "family": "Estévez", "initials": "M. L. E."} {"name": "María Paz Alonso Romero", "given": "María", "middle": "Paz Alonso", "family": "Romero", "initials": "M. P. A. R."} {"name": "María Rubio Tejero", "given": "María", "middle": "Rubio", "family": "Tejero", "initials": "M. R. T."} {"name": "María de la O Lejárraga y García", "given": "María", "family": "de la O Lejárraga y García", "initials": "M. O. L. G."} {"name": "María del Pilar Hurtado Montaño", "given": "María", "family": "del Pilar Hurtado Montaño", "initials": "M. P. H. M."} {"name": "Masalu Adam Juma Agape Shukurani MB", "order": "FAMILY_FIRST", "given": "Adam", "middle": "Juma Agape Shukurani", "family": "Masalu", "suffix": "MB", "initials": "A. J. A. S. M."} {"name": "Masalu, Adam Juma Agape Shukurani", "given": "Adam", "middle": "Juma Agape Shukurani", "family": "Masalu", "initials": "A. J. A. S. M."} {"name": "Mathilde Therese von Thurn und Taxis", "given": "Mathilde", "middle": "Therese", "family": "von Thurn und Taxis", "initials": "M. T. T. T."} {"name": "Matsumoto, Shunichi", "given": "Shunichi", "family": "Matsumoto", "initials": "S. M."} {"name": "Matsumura, Jinzo", "given": "Jinzo", "family": "Matsumura", "initials": "J. M."} {"name": "Matsuura, Takeshirō", "given": "Takeshirō", "family": "Matsuura", "initials": "T. M."} {"name": "Matthew Matthew", "given": "Matthew", "family": "Matthew", "initials": "M. M."} {"name": "Matthias von Pischof", "given": "Matthias", "family": "von Pischof", "initials": "M. P."} {"name": "Matthías Jochumsson", "given": "Matthías", "family": "Jochumsson", "initials": "M. J."} {"name": "Mattā al-Miskīn", "given": "Mattā", "family": "al-Miskīn", "initials": "M. a."} {"name": "Maura Cellario-Pingor, PHR", "given": "Maura", "family": "Cellario-Pingor", "suffix": "PHR", "initials": "M. C."} {"name": "Maurus Daniel Esteva a Alsina", "given": "Maurus", "middle": "Daniel Esteva a", "family": "Alsina", "initials": "M. D. E. a. A.", "capitalized_force": "Maurus Daniel Esteva A Alsina"} {"name": "Max Ben Mustermann", "given": "Max", "middle": "Ben", "family": "Mustermann", "initials": "M. B. M."} {"name": "Max Born", "given": "Max", "family": "Born", "initials": "M. B."} {"name": "Mayo", "given": "Mayo", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "M."} {"name": "Mañé i Miravet, Teresa", "given": "Teresa", "family": "Mañé i Miravet", "initials": "T. M. M."} {"name": "Maǧrīṭī, Abu-'l-Qāsim Maslama Ibn-Aḥmad al-", "given": "Abu-'l-Qāsim", "middle": "Maslama Ibn-Aḥmad", "family": "al- Maǧrīṭī", "initials": "A. M. I. M.", "capitalized_force": "Abu-'L-Qāsim Maslama ibn-Aḥmad al- Maǧrīṭī"} {"name": "Mc Donald", "family": "Mc Donald", "initials": "D.", "capitalized_force": "mc Donald"} {"name": "Md Abdul Karim", "given": "Md", "middle": "Abdul", "family": "Karim", "initials": "M. A. K."} {"name": "Md Abu Sayed", "given": "Md", "family": "Abu Sayed", "initials": "M. S.", "capitalized_force": "Md abu Sayed"} {"name": "Md Rahman", "given": "Md", "family": "Rahman", "initials": "M. R."} {"name": "Md,, Phd", "family": "Md", "suffix": "Phd", "initials": "M.", "capitalized_force": "Md PhD"} {"name": "Md. Abdul Karim", "title": "Md.", "given": "Abdul", "family": "Karim", "initials": "A. K."} {"name": "Medici, Clarice de'", "given": "Clarice", "family": "de' Medici", "initials": "C. M."} {"name": "Medici, Giuliano de", "given": "Giuliano", "family": "de Medici", "initials": "G. M."} {"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": "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."} {"name": "Mendonça, Lúcio de", "given": "Lúcio", "family": "de Mendonça", "initials": "L. M."} {"name": "Mercedes Díaz Roig", "given": "Mercedes", "middle": "Díaz", "family": "Roig", "initials": "M. D. R."} {"name": "Merenberg, Sophie von", "given": "Sophie", "family": "von Merenberg", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "S. M."} {"name": "Meretgeldi Durdyýewiç Gurbanow", "given": "Meretgeldi", "middle": "Durdyýewiç", "family": "Gurbanow", "initials": "M. D. G."} {"name": "Mesnil Garcia Juan", "given": "Mesnil", "middle": "Garcia", "family": "Juan", "initials": "M. G. J."} {"name": "Mesnil Garcia de", "given": "Mesnil", "middle": "Garcia", "family": "de", "initials": "M. G. d.", "capitalized_force": "Mesnil Garcia De"} {"name": "Mesnil Garcia van", "given": "Mesnil", "middle": "Garcia", "family": "van", "initials": "M. G. v.", "capitalized_force": "Mesnil Garcia Van"} {"name": "Mesnil de", "given": "Mesnil", "family": "de", "initials": "M. d.", "capitalized_force": "Mesnil De"} {"name": "Mevrouw Anna de Vries", "title": "Mevrouw", "given": "Anna", "family": "de Vries", "initials": "A. V."} {"name": "Meyer Benno Hans", "order": "FAMILY_FIRST", "given": "Benno", "middle": "Hans", "family": "Meyer", "initials": "B. H. M."} {"name": "Meyer, Benno Hans", "given": "Benno", "middle": "Hans", "family": "Meyer", "initials": "B. H. M."} {"name": "Mgr. Dòmhnall Iain Bàn Mac a’ Ghobhainn Chorùna am post", "title": "Mgr.", "given": "Dòmhnall", "middle": "Iain Bàn", "family": "Mac a’ Ghobhainn Chorùna", "suffix": "am post", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "D. I. B. a. G. C.", "capitalized_force": "Mgr. Dòmhnall Iain Bàn mac A’ Ghobhainn Chorùna AM Post"} {"name": "Mgr. Ing. Anna Karolína Eva Kristína von Kant Nováková Predná, CSc.", "title": "Mgr. Ing.", "given": "Anna", "middle": "Karolína Eva Kristína", "family": "von Kant Nováková Predná", "suffix": "CSc.", "initials": "A. K. E. K. K. N. P.", "capitalized_force": "Mgr. Ing. Anna Karolína Eva Kristína von Kant Nováková Predná CSC."} {"name": "Mgr. Ing. von Kant Nováková Predná Anna Karolína Eva Kristína, CSc.", "order": "FAMILY_FIRST", "title": "Mgr. Ing.", "given": "Kant", "middle": "Nováková Predná Anna Karolína Eva Kristína", "family": "von", "suffix": "CSc.", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "K. N. P. A. K. E. K. v.", "capitalized_force": "Mgr. Ing. Kant Nováková Predná Anna Karolína Eva Kristína Von CSC."} {"name": "Michael Wong Kar Ming", "given": "Michael", "middle": "Wong Kar", "family": "Ming", "initials": "M. W. K. M."} {"name": "Miguel García Martínez", "given": "Miguel", "middle": "García", "family": "Martínez", "initials": "M. G. M."} {"name": "Miguel Lanz Duret", "given": "Miguel", "middle": "Lanz", "family": "Duret", "initials": "M. L. D."} {"name": "Miguel Sánchez Pesquera", "given": "Miguel", "middle": "Sánchez", "family": "Pesquera", "initials": "M. S. P."} {"name": "Miguel Treviño Morales", "given": "Miguel", "middle": "Treviño", "family": "Morales", "initials": "M. T. M."} {"name": "Miguel d'Escoto", "given": "Miguel", "family": "d'Escoto", "initials": "M. d.", "capitalized_force": "Miguel D'Escoto"} {"name": "Miguel Ángel Félix Gallardo", "given": "Miguel", "middle": "Ángel Félix", "family": "Gallardo", "initials": "M. Á. F. G."} {"name": "Mike van der Velt", "given": "Mike", "family": "van der Velt", "initials": "M. V."} {"name": "Milenković dr Slađana Vera mlađi, dipl. inž.", "order": "FAMILY_FIRST", "title": "dipl. inž.", "given": "dr", "middle": "Slađana Vera", "family": "Milenković", "suffix": "mlađi", "initials": "d. S. V. M.", "capitalized_force": "Dipl. Inž. Dr Slađana Vera Milenković Mlađi"} {"name": "Milenković, Slađana Vera", "given": "Slađana", "middle": "Vera", "family": "Milenković", "initials": "S. V. M."} {"name": "Miler Kete", "order": "FAMILY_FIRST", "given": "Kete", "family": "Miler", "initials": "K. M."} {"name": "Miler, Kete", "given": "Kete", "family": "Miler", "initials": "K. M."} {"name": "Miliūtė Kotryna", "order": "FAMILY_FIRST", "given": "Kotryna", "family": "Miliūtė", "initials": "K. M."} {"name": "Miliūtė, Kotryna", "given": "Kotryna", "family": "Miliūtė", "initials": "K. M."} {"name": "Miller Kate", "order": "FAMILY_FIRST", "given": "Kate", "family": "Miller", "initials": "K. M."} {"name": "Miller, Kate", "given": "Kate", "family": "Miller", "initials": "K. M."} {"name": "Millere Keita", "order": "FAMILY_FIRST", "given": "Keita", "family": "Millere", "initials": "K. M."} {"name": "Millers Pīters", "order": "FAMILY_FIRST", "given": "Pīters", "family": "Millers", "initials": "P. M."} {"name": "Millers Pīters Džims", "order": "FAMILY_FIRST", "given": "Pīters", "middle": "Džims", "family": "Millers", "initials": "P. D. M."} {"name": "Milton Santacruz Aguilar", "given": "Milton", "middle": "Santacruz", "family": "Aguilar", "initials": "M. S. A."} {"name": "Min Chi", "given": "Min", "family": "Chi", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "M. C."} {"name": "Mina", "given": "Mina", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "M."} {"name": "Mina", "order": "FAMILY_FIRST", "family": "Mina", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "M."} {"name": "Minjun KIM", "given": "Minjun", "family": "KIM", "initials": "M. K.", "capitalized_force": "Minjun Kim"} {"name": "Miranda, Tony", "given": "Tony", "family": "Miranda", "initials": "T. M."} {"name": "Miret", "given": "Miret", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "M."} {"name": "Miret", "order": "FAMILY_FIRST", "family": "Miret", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "M."} {"name": "Misael Eduardo Pastrana Borrero", "given": "Misael", "middle": "Eduardo Pastrana", "family": "Borrero", "initials": "M. E. P. B."} {"name": "Miyamoto, Musashi", "given": "Musashi", "family": "Miyamoto", "initials": "M. M."} {"name": "Mkwawa John", "order": "FAMILY_FIRST", "given": "John", "family": "Mkwawa", "initials": "J. M."} {"name": "Mkwawa, John", "given": "John", "family": "Mkwawa", "initials": "J. M."} {"name": "Modibbo Malam Shehu Bello MP", "order": "FAMILY_FIRST", "given": "Malam", "middle": "Shehu Bello", "family": "Modibbo", "suffix": "MP", "initials": "M. S. B. M."} {"name": "Modibbo, Shehu Bello", "given": "Shehu", "middle": "Bello", "family": "Modibbo", "initials": "S. B. M."} {"name": "Modrego Casaus, Gregorio", "given": "Gregorio", "family": "Modrego Casaus", "initials": "G. M. C."} {"name": "Mohamad Ahmad Ali Hassan", "given": "Mohamad", "middle": "Ahmad Ali", "family": "Hassan", "initials": "M. A. A. H."} {"name": "Mohamad Ali", "given": "Mohamad", "family": "Ali", "initials": "M. A."} {"name": "Mohamad Ali Khalil", "given": "Mohamad", "middle": "Ali", "family": "Khalil", "initials": "M. A. K."} {"name": "Mohamad Asri Muda", "given": "Mohamad", "middle": "Asri", "family": "Muda", "initials": "M. A. M."} {"name": "Mohamad X", "given": "Mohamad", "family": "X", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: generation as suffix (shape)"], "initials": "M. X."} {"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": "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": "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."} {"name": "Montesquiou-Fézensac, Anne-Pierre de", "given": "Anne-Pierre", "family": "de Montesquiou-Fézensac", "initials": "A. M."} {"name": "Montpensier, Marie Louise Fernande de", "given": "Marie", "middle": "Louise Fernande", "family": "de Montpensier", "initials": "M. L. F. M."} {"name": "Moraes, Vinícius de", "given": "Vinícius", "family": "de Moraes", "initials": "V. M."} {"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": "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."} {"name": "Mr Buddha. Tamang", "title": "Mr Buddha.", "family": "Tamang", "initials": "T."} {"name": "Mr Deacon Smith", "title": "Mr", "given": "Deacon", "family": "Smith", "initials": "D. S."} {"name": "Mr Dean Smith", "title": "Mr", "given": "Dean", "family": "Smith", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "D. S."} {"name": "Mr Justice Smith", "title": "Mr", "given": "Justice", "family": "Smith", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "J. S."} {"name": "Mr MA", "title": "Mr", "family": "MA", "initials": "M.", "capitalized_force": "Mr Ma"} {"name": "Mr Mahdi Karimi", "title": "Mr", "given": "Mahdi", "family": "Karimi", "initials": "M. K."} {"name": "Mr Md Karim Uddin", "title": "Mr", "given": "Md", "middle": "Karim", "family": "Uddin", "initials": "M. K. U."} {"name": "Mr Shaikh Ahmed", "title": "Mr", "given": "Shaikh", "family": "Ahmed", "initials": "S. A."} {"name": "Mr Smith", "title": "Mr", "family": "Smith", "initials": "S."} {"name": "Mr Wing Lau", "title": "Mr", "given": "Wing", "family": "Lau", "initials": "W. L."} {"name": "Mr XXX", "title": "Mr", "family": "XXX", "initials": "X.", "capitalized_force": "Mr Xxx"} {"name": "Mr. & Mrs. John Smith", "title": "Mr. & Mrs.", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "Mr. Bertram Wilberforce Henry Robert Wooster Jr, MP", "title": "Mr.", "given": "Bertram", "middle": "Wilberforce Henry Robert", "family": "Wooster", "suffix": "Jr, MP", "initials": "B. W. H. R. W."} {"name": "Mr. Bertram Wilberforce Henry Robert Wooster MP", "title": "Mr.", "given": "Bertram", "middle": "Wilberforce Henry Robert", "family": "Wooster", "suffix": "MP", "initials": "B. W. H. R. W."} {"name": "Mr. Francisco Xavier Joao Anton de Souza e Menezes Dk, MP", "title": "Mr.", "given": "Francisco", "middle": "Xavier Joao Anton", "family": "de Souza e Menezes Dk", "suffix": "MP", "initials": "F. X. J. A. S. M. D."} {"name": "Mr. Jack Jill", "title": "Mr.", "given": "Jack", "family": "Jill", "initials": "J. J."} {"name": "Mr. Jack and Jill", "title": "Mr.", "family": "Jack and Jill", "initials": "J. J."} {"name": "Mr. Johnson", "title": "Mr.", "family": "Johnson", "initials": "J."} {"name": "Mr. Van Nguyen", "title": "Mr.", "given": "Van", "family": "Nguyen", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "V. N."} {"name": "Mr. and Mrs. John Smith", "title": "Mr. and Mrs.", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "Mr. and Mrs. John and Jane Smith", "title": "Mr. and Mrs.", "given": "John and Jane", "family": "Smith", "initials": "J. J. S."} {"name": "Mr. and Mrs. Wing Lau", "title": "Mr. and Mrs.", "given": "Wing", "family": "Lau", "initials": "W. L."} {"name": "Mrs Ma. Liza Cruz", "title": "Mrs", "given": "Ma.", "middle": "Liza", "family": "Cruz", "initials": "M. L. C."} {"name": "Mrs. Garcia", "title": "Mrs.", "family": "Garcia", "initials": "G."} {"name": "Ms A.J. Smith", "title": "Ms", "given": "A.J.", "family": "Smith", "initials": "A. S."} {"name": "Ms Hon Solo", "title": "Ms Hon", "family": "Solo", "initials": "S."} {"name": "Ms Princess Smith", "title": "Ms", "given": "Princess", "family": "Smith", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "P. S."} {"name": "Ms Queen Smith", "title": "Ms", "given": "Queen", "family": "Smith", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "Q. S."} {"name": "Ms. Rebecca Teichman", "title": "Ms.", "given": "Rebecca", "family": "Teichman", "initials": "R. T."} {"name": "Mudane Bertram Wilberforce Henry Robert Wooster MP", "given": "Mudane", "middle": "Bertram Wilberforce Henry Robert", "family": "Wooster", "suffix": "MP", "initials": "M. B. W. H. R. W."} {"name": "Mueller Kathy", "order": "FAMILY_FIRST", "given": "Kathy", "family": "Mueller", "initials": "K. M."} {"name": "Mueller, Kathy", "given": "Kathy", "family": "Mueller", "initials": "K. M."} {"name": "Muhammad Yusuf Qodirov", "given": "Muhammad", "middle": "Yusuf", "family": "Qodirov", "initials": "M. Y. Q."} {"name": "Muhammed Shehu", "order": "FAMILY_FIRST", "given": "Shehu", "family": "Muhammed", "initials": "S. M."} {"name": "Muhammed, Shehu", "given": "Shehu", "family": "Muhammed", "initials": "S. M."} {"name": "Mujanović Razija", "order": "FAMILY_FIRST", "given": "Razija", "family": "Mujanović", "initials": "R. M."} {"name": "Muller Kathe", "order": "FAMILY_FIRST", "given": "Kathe", "family": "Muller", "initials": "K. M."} {"name": "Muller Kathi", "order": "FAMILY_FIRST", "given": "Kathi", "family": "Muller", "initials": "K. M."} {"name": "Muller, Kathe", "given": "Kathe", "family": "Muller", "initials": "K. M."} {"name": "Muller, Kathe", "order": "FAMILY_FIRST", "given": "Kathe", "family": "Muller", "initials": "K. M."} {"name": "Muller, Kathi", "given": "Kathi", "family": "Muller", "initials": "K. M."} {"name": "Muniiru", "given": "Muniiru", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "M."} {"name": "Muniiru", "order": "FAMILY_FIRST", "family": "Muniiru", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "M."} {"name": "Munster, Jan van", "given": "Jan", "family": "van Munster", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "J. M."} {"name": "Murad III.", "given": "Murad", "suffix": "III.", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "M."} {"name": "Murat Bey", "given": "Murat", "family": "Bey", "initials": "M. B."} {"name": "Muslim Ibn-al-Ḥaǧǧāǧ al-Qušairī", "given": "Muslim", "middle": "Ibn-al-Ḥaǧǧāǧ", "family": "al-Qušairī", "initials": "M. I. a.", "capitalized_force": "Muslim ibn-al-Ḥaǧǧāǧ al-Qušairī"} {"name": "Muspratt, James", "given": "James", "family": "Muspratt", "initials": "J. M."} {"name": "Mustafa I.", "given": "Mustafa", "family": "I.", "initials": "M. I."} {"name": "Mustermann, Max Ben", "given": "Max", "middle": "Ben", "family": "Mustermann", "initials": "M. B. M."} {"name": "Mustermann, Max Ben", "order": "FAMILY_FIRST", "given": "Max", "middle": "Ben", "family": "Mustermann", "initials": "M. B. M."} {"name": "Muḥammad Ibn-ʿAbd-al-Malik Ibn-Ṭufail", "given": "Muḥammad", "middle": "Ibn-ʿAbd-al-Malik", "family": "Ibn-Ṭufail", "initials": "M. I. I.", "capitalized_force": "Muḥammad ibn-ʿAbd-al-Malik ibn-Ṭufail"} {"name": "Muḥammad Ibn-ʿAbd-al-Wahhāb", "given": "Muḥammad", "family": "Ibn-ʿAbd-al-Wahhāb", "initials": "M. I.", "capitalized_force": "Muḥammad ibn-ʿAbd-al-Wahhāb"} {"name": "Muḥammad Taqī Ḫān Pisyān", "given": "Muḥammad", "middle": "Taqī Ḫān", "family": "Pisyān", "initials": "M. T. Ḫ. P."} {"name": "Muḥammad ʿAlī Bāšā", "given": "Muḥammad", "middle": "ʿAlī", "family": "Bāšā", "initials": "M. ʿ. B."} {"name": "Mx A.J. Smith", "title": "Mx", "given": "A.J.", "family": "Smith", "initials": "A. S."} {"name": "Myuller Kete", "order": "FAMILY_FIRST", "given": "Kete", "family": "Myuller", "initials": "K. M."} {"name": "Myuller, Kete", "given": "Kete", "family": "Myuller", "initials": "K. M."} {"name": "MÜLLER WEIß, HANS", "given": "HANS", "family": "MÜLLER WEIß", "initials": "H. M. W.", "capitalized_force": "Hans Müller Weiß"} {"name": "Málaga de Prado, Clorinda", "given": "Clorinda", "family": "Málaga de Prado", "initials": "C. M. P."} {"name": "Mátrai Dóra", "order": "FAMILY_FIRST", "given": "Dóra", "family": "Mátrai", "initials": "D. M."} {"name": "Mátrai, Dóra", "given": "Dóra", "family": "Mátrai", "initials": "D. M."} {"name": "Máté Bendegúz Szabados", "given": "Máté", "middle": "Bendegúz", "family": "Szabados", "initials": "M. B. S."} {"name": "Ménil Christophe de", "given": "Ménil", "middle": "Christophe", "family": "de", "initials": "M. C. d.", "capitalized_force": "Ménil Christophe De"} {"name": "Ménil de", "given": "Ménil", "family": "de", "initials": "M. d.", "capitalized_force": "Ménil De"} {"name": "Møller, Hans Henrik Sven Åge", "given": "Hans", "middle": "Henrik Sven Åge", "family": "Møller", "initials": "H. H. S. Å. M."} {"name": "Møller, Irene", "given": "Irene", "family": "Møller", "initials": "I. M."} {"name": "Møller, Irene", "order": "FAMILY_FIRST", "given": "Irene", "family": "Møller", "initials": "I. M."} {"name": "Møller, hr. Hans Henrik Sven Åge, MF", "order": "FAMILY_FIRST", "title": "hr.", "given": "Hans", "middle": "Henrik Sven Åge", "family": "Møller", "suffix": "MF", "ambiguities": ["COMMA_STRUCTURE"], "initials": "H. H. S. Å. M.", "capitalized_force": "Hr. Hans Henrik Sven Åge Møller Mf"} {"name": "Müller Katryn", "order": "FAMILY_FIRST", "given": "Katryn", "family": "Müller", "initials": "K. M."} {"name": "Müller Kete", "order": "FAMILY_FIRST", "given": "Kete", "family": "Müller", "initials": "K. M."} {"name": "Müller Käthe", "order": "FAMILY_FIRST", "given": "Käthe", "family": "Müller", "initials": "K. M."} {"name": "Müller geb.", "given": "Müller", "family": "geb.", "initials": "M. g.", "capitalized_force": "Müller Geb."} {"name": "Müller, Katryn", "given": "Katryn", "family": "Müller", "initials": "K. M."} {"name": "Müller, Kete", "given": "Kete", "family": "Müller", "initials": "K. M."} {"name": "Müller, Käthe", "given": "Käthe", "family": "Müller", "initials": "K. M."} {"name": "Müller, Käthe", "order": "FAMILY_FIRST", "given": "Käthe", "family": "Müller", "initials": "K. M."} {"name": "Mūsā al-Kāẓīm", "given": "Mūsā", "family": "al-Kāẓīm", "initials": "M. a."} {"name": "NASCIMENTO, EDSON ARANTES DO", "given": "EDSON", "middle": "ARANTES", "family": "DO NASCIMENTO", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "E. A. N.", "capitalized": "Edson Arantes do Nascimento"} {"name": "Nakae, Toju", "given": "Toju", "family": "Nakae", "initials": "T. N."} {"name": "Nakamura, Masanao", "given": "Masanao", "family": "Nakamura", "initials": "M. N."} {"name": "Nalî", "given": "Nalî", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "N."} {"name": "Nanna Bryndís Hilmarsdóttir", "given": "Nanna", "middle": "Bryndís", "family": "Hilmarsdóttir", "initials": "N. B. H."} {"name": "Naomi Wambui Ng'ang'a", "given": "Naomi", "middle": "Wambui", "family": "Ng'ang'a", "initials": "N. W. N.", "capitalized_force": "Naomi Wambui Ng'Ang'A"} {"name": "Naora, Nobuo", "given": "Nobuo", "family": "Naora", "initials": "N. N."} {"name": "Narcís Monturiol i Estarriol", "given": "Narcís", "family": "Monturiol i Estarriol", "initials": "N. M. E."} {"name": "Nascimento, Edson Arantes do", "given": "Edson", "middle": "Arantes", "family": "do Nascimento", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "E. A. N."} {"name": "Nathan Augustus Cobb", "given": "Nathan", "middle": "Augustus", "family": "Cobb", "initials": "N. A. C."} {"name": "Neher, Arnold", "given": "Arnold", "family": "Neher", "initials": "A. N."} {"name": "Neidhart, Sebastian", "given": "Sebastian", "family": "Neidhart", "initials": "S. N."} {"name": "Neil Edward Goldschmidt", "given": "Neil", "middle": "Edward", "family": "Goldschmidt", "initials": "N. E. G."} {"name": "Nemesio Camacho Macías", "given": "Nemesio", "middle": "Camacho", "family": "Macías", "initials": "N. C. M.", "capitalized_force": "Nemesio Camacho MacÍas"} {"name": "Ngugi wa Thiong'o", "given": "Ngugi", "middle": "wa", "family": "Thiong'o", "initials": "N. w. T.", "capitalized_force": "Ngugi Wa Thiong'O"} {"name": "Nguyen Thi Van", "given": "Nguyen", "middle": "Thi", "family": "Van", "initials": "N. T. V."} {"name": "Nguyen Van Lac", "given": "Nguyen", "family": "Van Lac", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "N. L.", "capitalized_force": "Nguyen van Lac"} {"name": "Nguyen Van Vi", "given": "Nguyen", "family": "Van Vi", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: generation as suffix (shape)"], "initials": "N. V.", "capitalized_force": "Nguyen van Vi"} {"name": "Nguyen, Thi Van", "given": "Thi", "family": "Van Nguyen", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "T. N.", "capitalized_force": "Thi van Nguyen"} {"name": "Nguyen, Van", "given": "Van", "family": "Nguyen", "initials": "V. N."} {"name": "Nguyen, Van Le", "given": "Van", "middle": "Le", "family": "Nguyen", "initials": "V. L. N."} {"name": "Nguyễn Anh", "order": "FAMILY_FIRST", "given": "Anh", "family": "Nguyễn", "initials": "A. N."} {"name": "Nguyễn Thị Minh Khai", "given": "Nguyễn", "middle": "Thị Minh", "family": "Khai", "initials": "N. T. M. K."} {"name": "Nguyễn Văn Anh", "order": "FAMILY_FIRST", "given": "Văn", "middle": "Anh", "family": "Nguyễn", "initials": "V. A. N."} {"name": "Nguyễn, Thị Vân", "given": "Thị", "middle": "Vân", "family": "Nguyễn", "initials": "T. V. N."} {"name": "Ni-Tanjung", "given": "Ni-Tanjung", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "N."} {"name": "Nicolás Leoz Almirón", "given": "Nicolás", "middle": "Leoz", "family": "Almirón", "initials": "N. L. A."} {"name": "Nieves Conde, José Antonio", "given": "José", "middle": "Antonio", "family": "Nieves Conde", "initials": "J. A. N. C."} {"name": "Nikolaj Evgenʹevič Lansere", "given": "Nikolaj", "middle": "Evgenʹevič", "family": "Lansere", "initials": "N. E. L."} {"name": "Nilsen Anne Berit Bernhard", "order": "FAMILY_FIRST", "given": "Anne", "middle": "Berit Bernhard", "family": "Nilsen", "initials": "A. B. B. N."} {"name": "Nilsen, Anne Berit Bernhard", "given": "Anne", "middle": "Berit Bernhard", "family": "Nilsen", "initials": "A. B. B. N."} {"name": "No1. John Smith", "given": "No1.", "middle": "John", "family": "Smith", "initials": "N. J. S."} {"name": "Noailles, Marie Laure de", "given": "Marie", "middle": "Laure", "family": "de Noailles", "initials": "M. L. N."} {"name": "Nolhac, Pierre de", "given": "Pierre", "family": "de Nolhac", "initials": "P. N."} {"name": "None John None Doe None (None)", "given": "None", "middle": "John None Doe", "family": "None", "nickname": "None", "initials": "N. J. N. D. N."} {"name": "None Smith", "given": "None", "family": "Smith", "initials": "N. S."} {"name": "Nonez Smith", "given": "Nonez", "family": "Smith", "initials": "N. S."} {"name": "Novak, Janez Peter", "given": "Janez", "middle": "Peter", "family": "Novak", "initials": "J. P. N."} {"name": "Nováková Jana", "order": "FAMILY_FIRST", "given": "Jana", "family": "Nováková", "initials": "J. N."} {"name": "Nováková, Jana", "given": "Jana", "family": "Nováková", "initials": "J. N."} {"name": "Nowak Ewa", "order": "FAMILY_FIRST", "given": "Ewa", "family": "Nowak", "initials": "E. N."} {"name": "Nowak Tomasz Jan", "order": "FAMILY_FIRST", "given": "Tomasz", "middle": "Jan", "family": "Nowak", "initials": "T. J. N."} {"name": "Noémie Katerina Yannaki", "given": "Noémie", "middle": "Katerina", "family": "Yannaki", "initials": "N. K. Y."} {"name": "Nunes de Ávila e Silva, Epaminondas", "given": "Epaminondas", "family": "Nunes de Ávila e Silva", "initials": "E. N. Á. S."} {"name": "Nuʿaim Ibn-Ḥammād al-H̱uzāʿī", "given": "Nuʿaim", "middle": "Ibn-Ḥammād", "family": "al-H̱uzāʿī", "initials": "N. I. a.", "capitalized_force": "Nuʿaim ibn-Ḥammād al-H̱uzāʿī"} {"name": "Nūr al-Dīn", "given": "Nūr", "family": "al-Dīn", "initials": "N. a."} {"name": "O'B. John Smith", "given": "O'B.", "middle": "John", "family": "Smith", "initials": "O. J. S."} {"name": "Obidike Irene", "order": "FAMILY_FIRST", "given": "Irene", "family": "Obidike", "initials": "I. O."} {"name": "Obidike, Irene", "given": "Irene", "family": "Obidike", "initials": "I. O."} {"name": "Octavio Corral Romero", "given": "Octavio", "middle": "Corral", "family": "Romero", "initials": "O. C. R."} {"name": "Oddur Björnsson", "given": "Oddur", "family": "Björnsson", "initials": "O. B."} {"name": "Oighrig Dhòmhnallach", "given": "Oighrig", "family": "Dhòmhnallach", "initials": "O. D."} {"name": "Okeke Tunde Seun", "order": "FAMILY_FIRST", "given": "Tunde", "middle": "Seun", "family": "Okeke", "initials": "T. S. O."} {"name": "Okeke, Tunde Seun", "given": "Tunde", "middle": "Seun", "family": "Okeke", "initials": "T. S. O."} {"name": "Olabisi Adeboye", "given": "Olabisi", "family": "Adeboye", "initials": "O. A."} {"name": "Oldenburg-Januschau, Elard von", "given": "Elard", "family": "von Oldenburg-Januschau", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "E. O."} {"name": "Oranje-Nassau, Lodewijk van", "given": "Lodewijk", "family": "van Oranje-Nassau", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "L. O."} {"name": "Orel Irena", "order": "FAMILY_FIRST", "given": "Irena", "family": "Orel", "initials": "I. O."} {"name": "Orel, Irena", "given": "Irena", "family": "Orel", "initials": "I. O."} {"name": "Oren ben Horin", "given": "Oren", "middle": "ben", "family": "Horin", "initials": "O. b. H.", "capitalized_force": "Oren Ben Horin"} {"name": "Orlando Arvizu Lara", "given": "Orlando", "middle": "Arvizu", "family": "Lara", "initials": "O. A. L."} {"name": "Orléans, Ferdinand-Philippe d'", "given": "Ferdinand-Philippe", "family": "d' Orléans", "initials": "F. O."} {"name": "Orléans-Longueville, Jean Louis Charles d'", "given": "Jean", "middle": "Louis Charles", "family": "d' Orléans-Longueville", "initials": "J. L. C. O."} {"name": "Orsi, Achille d'", "given": "Achille", "family": "d' Orsi", "initials": "A. O."} {"name": "Ortega y Gasset, Dr.", "title": "Dr.", "given": "Ortega", "middle": "y", "family": "Gasset", "initials": "O. y. G."} {"name": "Ortega y Gasset, Ed", "given": "Ortega", "middle": "y", "family": "Gasset", "suffix": "Ed", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (declared)"], "initials": "O. y. G.", "capitalized_force": "Ortega y Gasset ED"} {"name": "Orueta y Duarte, Ricardo de", "given": "Ricardo", "family": "de Orueta y Duarte", "initials": "R. O. D."} {"name": "Orval, Pierre d'", "given": "Pierre", "family": "d' Orval", "initials": "P. O."} {"name": "Orville, Johann Friedrich d'", "given": "Johann", "middle": "Friedrich", "family": "d' Orville", "initials": "J. F. O."} {"name": "Ospina Pérez, Mariano", "given": "Mariano", "family": "Ospina Pérez", "initials": "M. O. P."} {"name": "Otto III.", "given": "Otto", "suffix": "III.", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "O."} {"name": "Outrein, Johannes d'", "given": "Johannes", "family": "d' Outrein", "initials": "J. O."} {"name": "Owura Bertram Wilberforce Henry Robert Wooster MP", "title": "Owura", "given": "Bertram", "middle": "Wilberforce Henry Robert", "family": "Wooster", "suffix": "MP", "initials": "B. W. H. R. W."} {"name": "Ozaki, Shirō", "given": "Shirō", "family": "Ozaki", "initials": "S. O."} {"name": "Ozawa, Seiji", "given": "Seiji", "family": "Ozawa", "initials": "S. O."} {"name": "Ozoliņš Pēteris", "order": "FAMILY_FIRST", "given": "Pēteris", "family": "Ozoliņš", "initials": "P. O."} {"name": "Ozoliņš Pēteris Kārlis", "order": "FAMILY_FIRST", "given": "Pēteris", "middle": "Kārlis", "family": "Ozoliņš", "initials": "P. K. O."} {"name": "P. V.", "given": "P.", "family": "V.", "initials": "P. V."} {"name": "PEDRO DA SILVA FILHO", "given": "PEDRO", "family": "DA SILVA", "suffix": "FILHO", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (declared)"], "initials": "P. S.", "capitalized": "Pedro da Silva Filho"} {"name": "PEDRO FILHO", "given": "PEDRO", "family": "FILHO", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: agnomen as suffix (vocabulary)"], "initials": "P. F.", "capitalized": "Pedro Filho"} {"name": "PEDRO JUNIOR FILHO", "given": "PEDRO", "middle": "JUNIOR", "family": "FILHO", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: agnomen as suffix (vocabulary)"], "initials": "P. J. F.", "capitalized": "Pedro Junior Filho"} {"name": "Pablo Emilio Riveros Reina", "given": "Pablo", "middle": "Emilio Riveros", "family": "Reina", "initials": "P. E. R. R."} {"name": "Pablo Pérez Miguel Ángel Juan Antonio II", "order": "FAMILY_FIRST", "given": "Pérez", "middle": "Miguel Ángel Juan Antonio", "family": "Pablo", "suffix": "II", "initials": "P. M. Á. J. A. P."} {"name": "Pablo Pérez, Sr. Miguel Ángel Juan Antonio", "title": "Sr.", "given": "Miguel", "middle": "Ángel Juan Antonio", "family": "Pablo Pérez", "initials": "M. Á. J. A. P. P."} {"name": "Pablos, María Dolores de", "given": "María", "middle": "Dolores", "family": "de Pablos", "initials": "M. D. P."} {"name": "Paku Alam VIII", "given": "Paku", "family": "Alam", "suffix": "VIII", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "P. A."} {"name": "Pan Nowak Tomasz Jan", "order": "FAMILY_FIRST", "given": "Nowak", "middle": "Tomasz Jan", "family": "Pan", "initials": "N. T. J. P."} {"name": "Pan Tomasz Nowak", "given": "Pan", "middle": "Tomasz", "family": "Nowak", "initials": "P. T. N."} {"name": "Park Ji-hoon ssi", "order": "FAMILY_FIRST", "given": "Ji-hoon", "middle": "ssi", "family": "Park", "initials": "J. s. P.", "capitalized_force": "Ji-Hoon Ssi Park"} {"name": "Pascual Ortiz Rubio", "given": "Pascual", "middle": "Ortiz", "family": "Rubio", "initials": "P. O. R."} {"name": "Patricia Terrazas Allen", "given": "Patricia", "middle": "Terrazas", "family": "Allen", "initials": "P. T. A."} {"name": "Patricio Flores Sandoval", "given": "Patricio", "middle": "Flores", "family": "Sandoval", "initials": "P. F. S."} {"name": "Paul Phélypeaux de Pontchartrain", "given": "Paul", "middle": "Phélypeaux", "family": "de Pontchartrain", "initials": "P. P. P."} {"name": "Paul d'Ivoi", "given": "Paul", "family": "d'Ivoi", "initials": "P. d.", "capitalized_force": "Paul D'Ivoi"} {"name": "Paul de Maleingreau", "given": "Paul", "family": "de Maleingreau", "initials": "P. M."} {"name": "Pavlović, Antonio mlađi", "given": "Antonio", "family": "Pavlović", "suffix": "mlađi", "initials": "A. P.", "capitalized_force": "Antonio Pavlović Mlađi"} {"name": "Pedro", "given": "Pedro", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "P."} {"name": "Pedro", "order": "FAMILY_FIRST", "family": "Pedro", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "P."} {"name": "Pedro Alves Filho", "given": "Pedro", "family": "Alves", "suffix": "Filho", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (declared)"], "initials": "P. A."} {"name": "Pedro Alves Junior Filho", "given": "Pedro", "family": "Alves", "suffix": "Junior Filho", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (declared)"], "initials": "P. A."} {"name": "Pedro Alves M.Filho", "given": "Pedro", "family": "Alves", "suffix": "M.Filho", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "P. A.", "capitalized_force": "Pedro Alves M.FILHO"} {"name": "Pedro Alves NETO", "given": "Pedro", "family": "Alves", "suffix": "NETO", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (declared)"], "initials": "P. A.", "capitalized_force": "Pedro Alves Neto"} {"name": "Pedro Alves Neto", "given": "Pedro", "family": "Alves", "suffix": "Neto", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (declared)"], "initials": "P. A."} {"name": "Pedro Alves Neto Júnior", "given": "Pedro", "family": "Alves", "suffix": "Neto Júnior", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (declared)"], "initials": "P. A."} {"name": "Pedro Alves Neto, AD", "given": "AD", "family": "Pedro Alves Neto", "initials": "A. P. A. N.", "capitalized_force": "Ad Pedro Alves Neto"} {"name": "Pedro Alves Neto, PhD", "given": "Pedro", "family": "Alves", "suffix": "Neto, PhD", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (declared)"], "initials": "P. A."} {"name": "Pedro Alves Sobr.", "given": "Pedro", "family": "Alves", "suffix": "Sobr.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (declared)"], "initials": "P. A."} {"name": "Pedro Alves, Neto", "given": "Pedro", "family": "Alves", "suffix": "Neto", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (declared)"], "initials": "P. A."} {"name": "Pedro Cañas Serrano", "given": "Pedro", "middle": "Cañas", "family": "Serrano", "initials": "P. C. S."} {"name": "Pedro Filho", "given": "Pedro", "family": "Filho", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: agnomen as suffix (vocabulary)"], "initials": "P. F."} {"name": "Pedro Galindo Galarza", "given": "Pedro", "middle": "Galindo", "family": "Galarza", "initials": "P. G. G."} {"name": "Pedro Infante Torrentera", "given": "Pedro", "middle": "Infante", "family": "Torrentera", "initials": "P. I. T."} {"name": "Pedro J. Neto", "given": "Pedro", "family": "J.", "suffix": "Neto", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (declared)"], "initials": "P. J."} {"name": "Pedro Jorba y Gassó", "given": "Pedro", "family": "Jorba y Gassó", "initials": "P. J. G."} {"name": "Pedro Junior Filho", "given": "Pedro", "middle": "Junior", "family": "Filho", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: agnomen as suffix (vocabulary)"], "initials": "P. J. F."} {"name": "Pedro Junior Ma Filho", "given": "Pedro", "middle": "Junior Ma", "family": "Filho", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: agnomen as suffix (vocabulary)"], "initials": "P. J. M. F."} {"name": "Pedro Junior fils", "given": "Pedro", "middle": "Junior", "family": "fils", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: generation as suffix (vocabulary)"], "initials": "P. J. f.", "capitalized_force": "Pedro Junior Fils"} {"name": "Pedro López Ruiz H.", "given": "Pedro", "middle": "López Ruiz", "family": "H.", "initials": "P. L. R. H."} {"name": "Pedro M.Filho", "given": "Pedro", "family": "M.Filho", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (shape)"], "initials": "P. M."} {"name": "Pedro MA Filho", "given": "Pedro", "middle": "MA", "family": "Filho", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: agnomen as suffix (vocabulary)"], "initials": "P. M. F.", "capitalized_force": "Pedro Ma Filho"} {"name": "Pedro Morales Pino", "given": "Pedro", "middle": "Morales", "family": "Pino", "initials": "P. M. P."} {"name": "Pedro PhD Neto", "given": "Pedro", "middle": "PhD", "family": "Neto", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: agnomen as suffix (vocabulary)"], "initials": "P. P. N."} {"name": "Pedro VI Filho", "given": "Pedro", "middle": "VI", "family": "Filho", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: agnomen as suffix (vocabulary)"], "initials": "P. V. F.", "capitalized_force": "Pedro Vi Filho"} {"name": "Pedro de Neto", "given": "Pedro", "family": "de", "suffix": "Neto", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (declared)"], "initials": "P. d.", "capitalized_force": "Pedro De Neto"} {"name": "Pendeta Zaba", "given": "Pendeta", "family": "Zaba", "initials": "P. Z."} {"name": "Penkauskienė Sofija Rita", "order": "FAMILY_FIRST", "given": "Sofija", "middle": "Rita", "family": "Penkauskienė", "initials": "S. R. P."} {"name": "Penkauskienė, Sofija Rita", "given": "Sofija", "middle": "Rita", "family": "Penkauskienė", "initials": "S. R. P."} {"name": "Pere Bosch i Gimpera", "given": "Pere", "family": "Bosch i Gimpera", "initials": "P. B. G."} {"name": "Pere Rebassa Bisquerra", "given": "Pere", "middle": "Rebassa", "family": "Bisquerra", "initials": "P. R. B."} {"name": "Pere Sabaté i Curto", "given": "Pere", "family": "Sabaté i Curto", "initials": "P. S. C."} {"name": "Peter", "given": "Peter", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "P."} {"name": "Peter", "order": "FAMILY_FIRST", "family": "Peter", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "P."} {"name": "Petraitytė, Gabrielė Aldona", "given": "Gabrielė", "middle": "Aldona", "family": "Petraitytė", "initials": "G. A. P."} {"name": "Petrauskas Jonas Lukas", "order": "FAMILY_FIRST", "given": "Jonas", "middle": "Lukas", "family": "Petrauskas", "initials": "J. L. P."} {"name": "Petrauskas, Jonas Lukas", "given": "Jonas", "middle": "Lukas", "family": "Petrauskas", "initials": "J. L. P."} {"name": "Petrov Ivan Sergeevich", "given": "Petrov", "middle": "Ivan", "family": "Sergeevich", "initials": "P. I. S."} {"name": "Pfeffer-Wildenbruch, Karl", "given": "Karl", "family": "Pfeffer-Wildenbruch", "initials": "K. P."} {"name": "Ph. D.", "title": "Ph.", "family": "D.", "initials": "D."} {"name": "Ph. D. John Smith", "title": "Ph.", "given": "D.", "middle": "John", "family": "Smith", "initials": "D. J. S."} {"name": "Ph. D. Van Johnson", "title": "Ph.", "given": "D.", "family": "Van Johnson", "initials": "D. J.", "capitalized_force": "Ph. D. van Johnson"} {"name": "Ph. D., John", "given": "John", "family": "Ph. D.", "initials": "J. P. D.", "facade": {"initials": "J. P D."}} {"name": "Ph. D., Jr.", "family": "Ph. D.", "suffix": "Jr.", "initials": "P. D.", "facade": {"initials": "P D."}} {"name": "Ph. D., Prof. Dr. Alejandro Manuel César Mamani de la Cruz Jr.", "title": "Prof. Dr.", "given": "Alejandro", "middle": "Manuel César Mamani de la Cruz", "family": "Ph. D.", "suffix": "Jr.", "initials": "A. M. C. M. C. P. D.", "facade": {"initials": "A. M. C. M. C. P D."}} {"name": "Ph. D., Prof. Dr. de la Cruz Alejandro Manuel César Mamani Jr.", "order": "FAMILY_FIRST", "title": "Prof. Dr.", "family": "Ph. D. de la Cruz Alejandro Manuel César Mamani", "suffix": "Jr.", "initials": "P. D. C. A. M. C. M."} {"name": "Pharm D John Smith", "given": "Pharm", "middle": "D John", "family": "Smith", "initials": "P. D. J. S."} {"name": "Philipp von Breuner", "given": "Philipp", "family": "von Breuner", "initials": "P. B."} {"name": "Philips van Almonde", "given": "Philips", "family": "van Almonde", "initials": "P. A."} {"name": "Pi, Xirui", "given": "Xirui", "family": "Pi", "initials": "X. P."} {"name": "Piazza, Albertino della", "given": "Albertino", "family": "della Piazza", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "A. P."} {"name": "Pierre David Oriola Garriga", "given": "Pierre", "middle": "David Oriola", "family": "Garriga", "initials": "P. D. O. G."} {"name": "Pierre de Nolhac", "given": "Pierre", "family": "de Nolhac", "initials": "P. N."} {"name": "Pierre-Louis", "given": "Pierre-Louis", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "P."} {"name": "Piet", "given": "Piet", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "P."} {"name": "Piet", "order": "FAMILY_FIRST", "family": "Piet", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "P."} {"name": "Piet van Engelen", "given": "Piet", "family": "van Engelen", "initials": "P. E."} {"name": "Pieter Bruegel stariji Jones", "given": "Pieter", "family": "Bruegel", "suffix": "stariji Jones", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "P. B.", "capitalized_force": "Pieter Bruegel Stariji Jones"} {"name": "Pieter de Carpentier", "given": "Pieter", "family": "de Carpentier", "initials": "P. C."} {"name": "Pilar Fernández Seijo", "given": "Pilar", "middle": "Fernández", "family": "Seijo", "initials": "P. F. S."} {"name": "Piras Giuanne Marcu", "order": "FAMILY_FIRST", "given": "Giuanne", "middle": "Marcu", "family": "Piras", "initials": "G. M. P."} {"name": "Piras, Giuanne Marcu", "given": "Giuanne", "middle": "Marcu", "family": "Piras", "initials": "G. M. P."} {"name": "Pischof, Matthias von", "given": "Matthias", "family": "von Pischof", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "M. P."} {"name": "Planta, Margaritta Marionna Maria de", "given": "Margaritta", "middle": "Marionna Maria", "family": "de Planta", "initials": "M. M. M. P."} {"name": "Pleikard Dietrich von Gemmingen", "given": "Pleikard", "middle": "Dietrich", "family": "von Gemmingen", "initials": "P. D. G."} {"name": "Poeck, Klaus", "given": "Klaus", "family": "Poeck", "initials": "K. P."} {"name": "Polo de Bernabé, Luis", "given": "Luis", "family": "Polo de Bernabé", "initials": "L. P. B."} {"name": "Pompilio Gutiérrez Arango", "given": "Pompilio", "middle": "Gutiérrez", "family": "Arango", "initials": "P. G. A."} {"name": "Pontchartrain, Paul Phélypeaux de", "given": "Paul", "middle": "Phélypeaux", "family": "de Pontchartrain", "initials": "P. P. P."} {"name": "Popa Alexandru Ioan", "order": "FAMILY_FIRST", "given": "Alexandru", "middle": "Ioan", "family": "Popa", "initials": "A. I. P."} {"name": "Popa, Alexandru Ioan", "given": "Alexandru", "middle": "Ioan", "family": "Popa", "initials": "A. I. P."} {"name": "Pope Francis", "title": "Pope", "given": "Francis", "initials": "F."} {"name": "Pope Smith, John", "title": "Pope", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "Popert, Hermann Martin", "given": "Hermann", "middle": "Martin", "family": "Popert", "initials": "H. M. P."} {"name": "Popescu Maria", "order": "FAMILY_FIRST", "given": "Maria", "family": "Popescu", "initials": "M. P."} {"name": "Popescu, Maria", "given": "Maria", "family": "Popescu", "initials": "M. P."} {"name": "Popović Jovan Dragan", "order": "FAMILY_FIRST", "given": "Jovan", "middle": "Dragan", "family": "Popović", "initials": "J. D. P."} {"name": "Popović, Jovan Dragan", "given": "Jovan", "middle": "Dragan", "family": "Popović", "initials": "J. D. P."} {"name": "Porter, Augustus Seymour", "given": "Augustus", "middle": "Seymour", "family": "Porter", "initials": "A. S. P."} {"name": "Potential Gotcha", "given": "Potential", "family": "Gotcha", "initials": "P. G."} {"name": "Prats Gonzaléz, Carlos", "given": "Carlos", "family": "Prats Gonzaléz", "initials": "C. P. G."} {"name": "Prema", "given": "Prema", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "P."} {"name": "Prema", "order": "FAMILY_FIRST", "family": "Prema", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "P."} {"name": "President of the United States Barack Obama", "title": "President of the United States", "given": "Barack", "family": "Obama", "initials": "B. O."} {"name": "Prince Charles", "given": "Prince", "family": "Charles", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "P. C."} {"name": "Prince Consort Albert", "title": "Prince Consort", "given": "Albert", "initials": "A."} {"name": "Prince Fielder", "given": "Prince", "family": "Fielder", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "P. F."} {"name": "Prince Harry", "given": "Prince", "family": "Harry", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "P. H."} {"name": "Prince of Wales", "title": "Prince of Wales"} {"name": "Prince of Wales Jr", "title": "Prince of Wales", "family": "Jr", "initials": "J."} {"name": "Princess Royal Anne", "title": "Princess Royal", "given": "Anne", "initials": "A."} {"name": "Prof. Dk. Salima Farida mwana Hamisi Musa M.D. Ph.D.", "title": "Prof. Dk.", "given": "Salima", "middle": "Farida mwana Hamisi", "family": "Musa", "suffix": "M.D. Ph.D.", "initials": "S. F. m. H. M.", "capitalized_force": "Prof. Dk. Salima Farida Mwana Hamisi Musa M.D. Ph.D."} {"name": "Prof. Dr. Ada Cornelia Cesar Martin von Bruhl Jr, MD DDS", "title": "Prof. Dr.", "given": "Ada", "middle": "Cornelia Cesar Martin", "family": "von Bruhl", "suffix": "Jr, MD DDS", "initials": "A. C. C. M. B."} {"name": "Prof. Dr. Ada Cornelia Cesar Martín von Bruhl Jr, M.D., Ph.D.", "title": "Prof. Dr.", "given": "Ada", "middle": "Cornelia Cesar Martín", "family": "von Bruhl", "suffix": "Jr, M.D., Ph.D.", "initials": "A. C. C. M. B."} {"name": "Prof. Dr. Ada Cornelia César Martín von Brühl González Domingo Jr, MD DDS", "title": "Prof. Dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl González Domingo", "suffix": "Jr, MD DDS", "initials": "A. C. C. M. B. G. D."} {"name": "Prof. Dr. Ada Cornelia César Martín von Brühl González Domingo M.D. Ph.D.", "title": "Prof. Dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl González Domingo", "suffix": "M.D. Ph.D.", "initials": "A. C. C. M. B. G. D."} {"name": "Prof. Dr. Ada Cornelia César Martín von Brühl González Domingo MD DDS", "title": "Prof. Dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl González Domingo", "suffix": "MD DDS", "initials": "A. C. C. M. B. G. D."} {"name": "Prof. Dr. Ada Cornelia César Martín von Brühl González Domingo, M.D. DDS.", "title": "Prof. Dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl González Domingo", "suffix": "M.D. DDS.", "initials": "A. C. C. M. B. G. D."} {"name": "Prof. Dr. Ada Cornelia César Martín von Brühl Jr MD DDS", "title": "Prof. Dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl", "suffix": "Jr MD DDS", "initials": "A. C. C. M. B."} {"name": "Prof. Dr. Ada Cornelia César Martín von Brühl Jr, MD DDS", "title": "Prof. Dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl", "suffix": "Jr, MD DDS", "initials": "A. C. C. M. B."} {"name": "Prof. Dr. Ada Cornelia Eva Sophia van den Wolf Becker Schmidt M.D. Ph.D.", "title": "Prof. Dr.", "given": "Ada", "middle": "Cornelia Eva Sophia", "family": "van den Wolf Becker Schmidt", "suffix": "M.D. Ph.D.", "initials": "A. C. E. S. W. B. S."} {"name": "Prof. Dr. Ada Cornelia Martina von Bruhel Domingo MD DDS", "title": "Prof. Dr.", "given": "Ada", "middle": "Cornelia Martina", "family": "von Bruhel Domingo", "suffix": "MD DDS", "initials": "A. C. M. B. D."} {"name": "Prof. Dr. Ada Kornelia Sezar Martin van den Volf Becker Schmidt M.D. Ph.D.", "title": "Prof. Dr.", "given": "Ada", "middle": "Kornelia Sezar Martin", "family": "van den Volf Becker Schmidt", "suffix": "M.D. Ph.D.", "initials": "A. K. S. M. V. B. S."} {"name": "Prof. Dr. Adama Coulibaly Camara Massamba won Baar Dominique Goudiaby MD DDS", "title": "Prof. Dr.", "given": "Adama", "middle": "Coulibaly Camara Massamba won Baar Dominique", "family": "Goudiaby", "suffix": "MD DDS", "initials": "A. C. C. M. w. B. D. G.", "capitalized_force": "Prof. Dr. Adama Coulibaly Camara Massamba Won Baar Dominique Goudiaby MD DDS"} {"name": "Prof. Dr. Anna Cornelia Eva Sophia van den Wolf jr. M.D. Ph.D.", "title": "Prof. Dr.", "given": "Anna", "middle": "Cornelia Eva Sophia", "family": "van den Wolf", "suffix": "jr. M.D. Ph.D.", "initials": "A. C. E. S. W.", "capitalized_force": "Prof. Dr. Anna Cornelia Eva Sophia van den Wolf Jr. M.D. Ph.D."} {"name": "Prof. Dr. Brühl, Ada", "title": "Prof. Dr.", "given": "Ada", "family": "Brühl", "initials": "A. B."} {"name": "Prof. Dr. MD DDS Ada Cornelia César Martín von Brühl Jr", "title": "Prof. Dr.", "given": "MD", "middle": "DDS Ada Cornelia César Martín", "family": "von Brühl", "suffix": "Jr", "initials": "M. D. A. C. C. M. B.", "capitalized_force": "Prof. Dr. Md Dds Ada Cornelia César Martín von Brühl Jr"} {"name": "Prof. Dr. Mary Lou Cesare Martín von Brühl Jr., Dr. Ph.D", "title": "Prof. Dr. Dr.", "given": "Mary", "middle": "Lou Cesare Martín", "family": "von Brühl", "suffix": "Jr., Ph.D", "initials": "M. L. C. M. B."} {"name": "Prof. Dr. Mary Sue Marie von Miller II, MD DDS", "title": "Prof. Dr.", "given": "Mary", "middle": "Sue Marie", "family": "von Miller", "suffix": "II, MD DDS", "initials": "M. S. M. M."} {"name": "Prof. Dr. María Florencia Martina Cristina von Brühl Jr., MD DDS", "title": "Prof. Dr.", "given": "María", "middle": "Florencia Martina Cristina", "family": "von Brühl", "suffix": "Jr., MD DDS", "initials": "M. F. M. C. B."} {"name": "Prof. Dr. von Bruhl, Ada Cornelia Cesar Martín Jr, M.D., Ph.D.", "order": "FAMILY_FIRST", "title": "Prof. Dr.", "given": "Ada", "middle": "Cornelia Cesar Martín", "family": "von Bruhl", "suffix": "Jr, M.D., Ph.D.", "initials": "A. C. C. M. B."} {"name": "Prof. Dr. von Brühl Ada Cornelia César Martín Jr, MD DDS", "order": "FAMILY_FIRST", "title": "Prof. Dr.", "given": "Brühl", "middle": "Ada Cornelia César Martín", "family": "von", "suffix": "Jr, MD DDS", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "B. A. C. C. M. v.", "capitalized_force": "Prof. Dr. Brühl Ada Cornelia César Martín Von Jr, MD DDS"} {"name": "Prof. Dr. von Brühl González Domingo Ada Cornelia César Martín, M.D. DDS.", "order": "FAMILY_FIRST", "title": "Prof. Dr.", "given": "Brühl", "middle": "González Domingo Ada Cornelia César Martín", "family": "von", "suffix": "M.D. DDS.", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "B. G. D. A. C. C. M. v.", "capitalized_force": "Prof. Dr. Brühl González Domingo Ada Cornelia César Martín Von M.D. DDS."} {"name": "Prof. Dr. von Brühl María Florencia Martina Cristina Jr., MD DDS", "order": "FAMILY_FIRST", "title": "Prof. Dr.", "given": "Brühl", "middle": "María Florencia Martina Cristina", "family": "von", "suffix": "Jr., MD DDS", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "B. M. F. M. C. v.", "capitalized_force": "Prof. Dr. Brühl María Florencia Martina Cristina Von Jr., MD DDS"} {"name": "Prof. Dr. von Brühl Plácido Domingo Ada Cornelia César Martín, MD DDS", "order": "FAMILY_FIRST", "title": "Prof. Dr.", "given": "Brühl", "middle": "Plácido Domingo Ada Cornelia César Martín", "family": "von", "suffix": "MD DDS", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "B. P. D. A. C. C. M. v.", "capitalized_force": "Prof. Dr. Brühl Plácido Domingo Ada Cornelia César Martín Von MD DDS"} {"name": "Prof. Smith, Dr. John", "title": "Prof. Dr.", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "Prof. Smith, John, PhD", "title": "Prof.", "given": "John", "family": "Smith", "suffix": "PhD", "initials": "J. S."} {"name": "Prof. dr. Cornelia Margarita Sofia Aletta van den Berg jnr., MBChB PhD", "title": "Prof. dr.", "given": "Cornelia", "middle": "Margarita Sofia Aletta", "family": "van den Berg", "suffix": "jnr., MBChB PhD", "initials": "C. M. S. A. B.", "capitalized_force": "Prof. Dr. Cornelia Margarita Sofia Aletta van den Berg Jnr., MBChB PhD"} {"name": "Prof. dr. MD Chirurgie dentară Eva-Eleonora Karl Hans von der Ruhr Jr.", "title": "Prof. dr.", "given": "MD", "middle": "Chirurgie dentară Eva-Eleonora Karl Hans", "family": "von der Ruhr", "suffix": "Jr.", "initials": "M. C. d. E. K. H. R.", "capitalized_force": "Prof. Dr. Md Chirurgie Dentară Eva-Eleonora Karl Hans von der Ruhr Jr."} {"name": "Prof. Ɖr. MƉ Samʊwɛl fan ƉAM Fostɛɛŋ", "title": "Prof. Ɖr.", "given": "MƉ", "middle": "Samʊwɛl fan ƉAM", "family": "Fostɛɛŋ", "initials": "M. S. f. Ɖ. F.", "capitalized_force": "Prof. Ɖr. Mɖ Samʊwɛl Fan Ɖam Fostɛɛŋ"} {"name": "Prof. Ɖr. MƉ fan ƉAM ƉOMIŊGO Fostɛɛŋ Samʊwɛl", "order": "FAMILY_FIRST", "title": "Prof. Ɖr.", "given": "fan", "middle": "ƉAM ƉOMIŊGO Fostɛɛŋ Samʊwɛl", "family": "MƉ", "initials": "f. Ɖ. Ɖ. F. S. M.", "capitalized_force": "Prof. Ɖr. Fan Ɖam Ɖomiŋgo Fostɛɛŋ Samʊwɛl Mɖ"} {"name": "Prop. Dr. Ada Cornelia César Martín von Brühl González Domingo MD DDS", "title": "Prop. Dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl González Domingo", "suffix": "MD DDS", "initials": "A. C. C. M. B. G. D."} {"name": "Pèrs, Ciro di", "given": "Ciro", "family": "di Pèrs", "ambiguities": ["PARTICLE_OR_GIVEN", "SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)", "PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "C. P."} {"name": "Pérez Lucía", "order": "FAMILY_FIRST", "given": "Lucía", "family": "Pérez", "initials": "L. P."} {"name": "Pérez, Juan Ó.", "given": "Juan", "middle": "Ó.", "family": "Pérez", "initials": "J. Ó. P."} {"name": "Pérez, Lucía", "given": "Lucía", "family": "Pérez", "initials": "L. P."} {"name": "Péricourt Louise", "order": "FAMILY_FIRST", "given": "Louise", "family": "Péricourt", "initials": "L. P."} {"name": "Péricourt, Louise", "given": "Louise", "family": "Péricourt", "initials": "L. P."} {"name": "Pétur Sigurðsson", "given": "Pétur", "family": "Sigurðsson", "initials": "P. S."} {"name": "Pîremêrd", "given": "Pîremêrd", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "P."} {"name": "Pīters Džims Millers", "given": "Pīters", "middle": "Džims", "family": "Millers", "initials": "P. D. M."} {"name": "Q R", "given": "Q", "family": "R", "initials": "Q. R."} {"name": "QC MP", "given": "QC", "suffix": "MP", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "Q.", "capitalized": "Qc MP"} {"name": "Qodirov Alisher Salimovich Deputat", "order": "FAMILY_FIRST", "given": "Alisher", "middle": "Salimovich Deputat", "family": "Qodirov", "initials": "A. S. D. Q."} {"name": "Qodirov Muhammad Yusuf", "order": "FAMILY_FIRST", "given": "Muhammad", "middle": "Yusuf", "family": "Qodirov", "initials": "M. Y. Q."} {"name": "Qodirov, Alisher Salimovich", "given": "Alisher", "middle": "Salimovich", "family": "Qodirov", "initials": "A. S. Q."} {"name": "Qodirov, Muhammad Yusuf", "given": "Muhammad", "middle": "Yusuf", "family": "Qodirov", "initials": "M. Y. Q."} {"name": "Queen Elizabeth", "given": "Queen", "family": "Elizabeth", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "Q. E."} {"name": "Queen Elizabeth II", "given": "Queen", "family": "Elizabeth", "suffix": "II", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "Q. E."} {"name": "Queen Mother Elizabeth", "title": "Queen Mother", "given": "Elizabeth", "initials": "E."} {"name": "Quertenmont, Andreas Bernardus de", "given": "Andreas", "middle": "Bernardus", "family": "de Quertenmont", "initials": "A. B. Q."} {"name": "Quincy Adams", "given": "Quincy", "family": "Adams", "initials": "Q. A."} {"name": "Quinquela Martín, Benito", "given": "Benito", "family": "Quinquela Martín", "initials": "B. Q. M."} {"name": "RA DDS", "title": "RA", "family": "DDS", "initials": "D.", "capitalized": "Ra Dds"} {"name": "RA PhD Jr., John", "given": "John", "family": "RA", "suffix": "PhD Jr.", "initials": "J. R.", "capitalized_force": "John Ra PhD Jr."} {"name": "RAM BAHADUR B.K.", "given": "RAM", "middle": "BAHADUR", "family": "B.K.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (shape)"], "initials": "R. B. B.", "capitalized": "Ram Bahadur B.K."} {"name": "RONALD MACDONALD", "given": "RONALD", "family": "MACDONALD", "initials": "R. M.", "capitalized": "Ronald MacDonald"} {"name": "RONALD MCDONALD", "given": "RONALD", "family": "MCDONALD", "initials": "R. M.", "capitalized": "Ronald McDonald"} {"name": "Rabbi Cohen", "title": "Rabbi", "family": "Cohen", "initials": "C."} {"name": "Radim Cvrček", "given": "Radim", "family": "Cvrček", "initials": "R. C."} {"name": "Rafael Aguayo Spencer", "given": "Rafael", "middle": "Aguayo", "family": "Spencer", "initials": "R. A. S."} {"name": "Rafael Alonso y Prieto", "given": "Rafael", "family": "Alonso y Prieto", "initials": "R. A. P."} {"name": "Rafael Cepeda Torres", "given": "Rafael", "middle": "Cepeda", "family": "Torres", "initials": "R. C. T."} {"name": "Rafael Frühbeck de Burgos", "given": "Rafael", "middle": "Frühbeck", "family": "de Burgos", "initials": "R. F. B."} {"name": "Rafael Rubio Carrión", "given": "Rafael", "middle": "Rubio", "family": "Carrión", "initials": "R. R. C."} {"name": "Rafael Ruiz Balerdi", "given": "Rafael", "middle": "Ruiz", "family": "Balerdi", "initials": "R. R. B."} {"name": "Rafael Sousa dos Anjos", "given": "Rafael", "middle": "Sousa", "family": "dos Anjos", "initials": "R. S. A."} {"name": "Rafael Viciana Aráez", "given": "Rafael", "middle": "Viciana", "family": "Aráez", "initials": "R. V. A."} {"name": "Ragnar Bjarnason", "given": "Ragnar", "family": "Bjarnason", "initials": "R. B."} {"name": "Rahman, Abdul MB ChB", "given": "Abdul", "family": "Rahman", "suffix": "MB ChB", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "A. R."} {"name": "Ram Bahadur B.K.", "given": "Ram", "middle": "Bahadur", "family": "B.K.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (shape)"], "initials": "R. B. B."} {"name": "Ram Bahadur B.K., PhD", "given": "Ram", "middle": "Bahadur", "family": "B.K.", "suffix": "PhD", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (shape)"], "initials": "R. B. B."} {"name": "Ram Bahadur PhD B.K.", "given": "Ram", "family": "Bahadur", "suffix": "PhD B.K.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "R. B."} {"name": "Ramadhan Karta Hadimadja", "given": "Ramadhan", "middle": "Karta", "family": "Hadimadja", "initials": "R. K. H."} {"name": "Ramon Ferran i Pagès", "given": "Ramon", "family": "Ferran i Pagès", "initials": "R. F. P."} {"name": "Ramón Arellano Félix", "given": "Ramón", "middle": "Arellano", "family": "Félix", "initials": "R. A. F."} {"name": "Rascón Navarro Seña y Redondo, Juan Antonio", "given": "Juan", "middle": "Antonio", "family": "Rascón Navarro Seña y Redondo", "initials": "J. A. R. N. S. R."} {"name": "Rashid Maidin", "given": "Rashid", "family": "Maidin", "initials": "R. M."} {"name": "Rashid, Harun al-", "given": "Harun", "family": "al- Rashid", "initials": "H. R."} {"name": "Razija Mujanović", "given": "Razija", "family": "Mujanović", "initials": "R. M."} {"name": "Reem Al-Bahrani, CPA", "given": "Reem", "family": "Al-Bahrani", "suffix": "CPA", "initials": "R. A.", "capitalized_force": "Reem al-Bahrani CPA"} {"name": "Reis, Maria Firmina dos", "given": "Maria", "middle": "Firmina", "family": "dos Reis", "initials": "M. F. R."} {"name": "Remigio Gandásegui y Gorrochátegui", "given": "Remigio", "family": "Gandásegui y Gorrochátegui", "initials": "R. G. G."} {"name": "Renaat van Elslande", "given": "Renaat", "family": "van Elslande", "initials": "R. E."} {"name": "Renaud de Roucy", "given": "Renaud", "family": "de Roucy", "initials": "R. R."} {"name": "Rethar", "given": "Rethar", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "R."} {"name": "Rev Andrews", "title": "Rev", "family": "Andrews", "initials": "A."} {"name": "Rev John A. Kenneth Doe", "title": "Rev", "given": "John", "middle": "A. Kenneth", "family": "Doe", "initials": "J. A. K. D."} {"name": "Rev John A. Kenneth Doe III (Kenny)", "title": "Rev", "given": "John", "middle": "A. Kenneth", "family": "Doe", "suffix": "III", "nickname": "Kenny", "initials": "J. A. K. D."} {"name": "Rev. John A. Kenneth Doe", "title": "Rev.", "given": "John", "middle": "A. Kenneth", "family": "Doe", "initials": "J. A. K. D."} {"name": "Rev. John Smith", "title": "Rev.", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "Rev. Sr. Smith, Mary", "title": "Rev. Sr.", "given": "Mary", "family": "Smith", "initials": "M. S."} {"name": "Reza Hossein Zadeh", "given": "Reza", "middle": "Hossein", "family": "Zadeh", "initials": "R. H. Z."} {"name": "Ricardo Bodenlle Corral", "given": "Ricardo", "middle": "Bodenlle", "family": "Corral", "initials": "R. B. C."} {"name": "Ricardo Legorreta Vilchis", "given": "Ricardo", "middle": "Legorreta", "family": "Vilchis", "initials": "R. L. V."} {"name": "Ricardo Valderrama Fernández", "given": "Ricardo", "middle": "Valderrama", "family": "Fernández", "initials": "R. V. F."} {"name": "Ricardo de Orueta y Duarte", "given": "Ricardo", "family": "de Orueta y Duarte", "initials": "R. O. D."} {"name": "Richard von Süßkind-Schwendi", "given": "Richard", "family": "von Süßkind-Schwendi", "initials": "R. S."} {"name": "Ridout, Alan", "given": "Alan", "family": "Ridout", "initials": "A. R."} {"name": "Riksdagsledamot Lars-Åke Karl Hermansson", "given": "Riksdagsledamot", "middle": "Lars-Åke Karl", "family": "Hermansson", "initials": "R. L. K. H."} {"name": "Rikābī, Fuʾād ar-", "given": "Fuʾād", "family": "ar- Rikābī", "initials": "F. R."} {"name": "Rinpoche", "given": "Rinpoche", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: post-nominal as suffix (vocabulary)"], "initials": "R."} {"name": "Rob And Beth Edmunds", "given": "Rob And Beth", "family": "Edmunds", "initials": "R. B. E.", "capitalized_force": "Rob and Beth Edmunds"} {"name": "Rob Edmunds", "given": "Rob", "family": "Edmunds", "initials": "R. E."} {"name": "Robert Aitken", "given": "Robert", "family": "Aitken", "initials": "R. A."} {"name": "Robert Morris", "given": "Robert", "family": "Morris", "initials": "R. M."} {"name": "Robert Prince", "given": "Robert", "family": "Prince", "initials": "R. P."} {"name": "Roberto Escobar Gaviria", "given": "Roberto", "middle": "Escobar", "family": "Gaviria", "initials": "R. E. G."} {"name": "Roberts Julia", "order": "FAMILY_FIRST", "given": "Julia", "family": "Roberts", "initials": "J. R."} {"name": "Roberts, Julia", "given": "Julia", "family": "Roberts", "initials": "J. R."} {"name": "Robiro Rojas Benavides", "given": "Robiro", "middle": "Rojas", "family": "Benavides", "initials": "R. R. B."} {"name": "Robles Pazos, José", "given": "José", "family": "Robles Pazos", "initials": "J. R. P."} {"name": "Rodolfo Landeros Gallegos", "given": "Rodolfo", "middle": "Landeros", "family": "Gallegos", "initials": "R. L. G."} {"name": "Rodrigo Arenas Betancourt", "given": "Rodrigo", "middle": "Arenas", "family": "Betancourt", "initials": "R. A. B."} {"name": "Rodrigo Lara Bonilla", "given": "Rodrigo", "middle": "Lara", "family": "Bonilla", "initials": "R. L. B."} {"name": "Roger Alier i Aixalà", "given": "Roger", "family": "Alier i Aixalà", "initials": "R. A. A."} {"name": "Rogerio Azcárraga Madero", "given": "Rogerio", "middle": "Azcárraga", "family": "Madero", "initials": "R. A. M."} {"name": "Roig Gal·la", "order": "FAMILY_FIRST", "given": "Gal·la", "family": "Roig", "initials": "G. R.", "capitalized_force": "Gal·La Roig"} {"name": "Roig, Gal·la", "given": "Gal·la", "family": "Roig", "initials": "G. R.", "capitalized_force": "Gal·La Roig"} {"name": "Rokas", "given": "Rokas", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "R."} {"name": "Rokas", "order": "FAMILY_FIRST", "family": "Rokas", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "R."} {"name": "Rolf Dietrich", "given": "Rolf", "family": "Dietrich", "initials": "R. D."} {"name": "Román García Rodrigo", "given": "Román", "middle": "García", "family": "Rodrigo", "initials": "R. G. R."} {"name": "Roque González Garza", "given": "Roque", "middle": "González", "family": "Garza", "initials": "R. G. G."} {"name": "Rosa Castillo Santiago", "given": "Rosa", "middle": "Castillo", "family": "Santiago", "initials": "R. C. S."} {"name": "Rosa Maria Ferran Ferrer", "given": "Rosa", "middle": "Maria Ferran", "family": "Ferrer", "initials": "R. M. F. F."} {"name": "Rosa María Ruiz", "given": "Rosa", "middle": "María", "family": "Ruiz", "initials": "R. M. R."} {"name": "Rossouw Irene", "order": "FAMILY_FIRST", "given": "Irene", "family": "Rossouw", "initials": "I. R."} {"name": "Rossouw, Irene", "given": "Irene", "family": "Rossouw", "initials": "I. R."} {"name": "Rovira, Josep Carod i Jr.", "given": "Josep", "middle": "Carod", "family": "Rovira", "suffix": "i Jr.", "initials": "J. C. R.", "capitalized_force": "Josep Carod Rovira I Jr."} {"name": "Royce, Ed", "given": "Ed", "family": "Royce", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "E. R."} {"name": "Rt. Hon. Paul E. Mary", "title": "Rt. Hon.", "given": "Paul", "middle": "E.", "family": "Mary", "initials": "P. E. M."} {"name": "Rubén Rojo y Pinto", "given": "Rubén", "family": "Rojo y Pinto", "initials": "R. R. P."} {"name": "Rudolf Friedrich Leopold von Hildebrand", "given": "Rudolf", "middle": "Friedrich Leopold", "family": "von Hildebrand", "initials": "R. F. L. H."} {"name": "Ruhr, Eva-Eleonora Karl Hans von der", "given": "Eva-Eleonora", "middle": "Karl Hans", "family": "von der Ruhr", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "E. K. H. R."} {"name": "Ruiz Armengol, Mario", "given": "Mario", "family": "Ruiz Armengol", "initials": "M. R. A."} {"name": "Ruiz Rosa María", "order": "FAMILY_FIRST", "given": "Rosa", "middle": "María", "family": "Ruiz", "initials": "R. M. R."} {"name": "Ruiz, Rosa María", "given": "Rosa", "middle": "María", "family": "Ruiz", "initials": "R. M. R."} {"name": "Rustemi, Ilir", "given": "Ilir", "family": "Rustemi", "initials": "I. R."} {"name": "Rustemi, Ilir I.", "order": "FAMILY_FIRST", "given": "Ilir", "middle": "I.", "family": "Rustemi", "initials": "I. I. R."} {"name": "Ruth Ann Neta", "given": "Ruth", "family": "Ann", "suffix": "Neta", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (declared)"], "initials": "R. A."} {"name": "Ryan, Dermot", "given": "Dermot", "family": "Ryan", "initials": "D. R."} {"name": "Róbert Gunnarsson", "given": "Róbert", "family": "Gunnarsson", "initials": "R. G."} {"name": "Rómulo González Irigoyen", "given": "Rómulo", "middle": "González", "family": "Irigoyen", "initials": "R. G. I."} {"name": "Rúnar Gunnarsson", "given": "Rúnar", "family": "Gunnarsson", "initials": "R. G."} {"name": "Rúrik Haraldsson", "given": "Rúrik", "family": "Haraldsson", "initials": "R. H."} {"name": "Rüdiger", "given": "Rüdiger", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "R."} {"name": "Rüdiger", "order": "FAMILY_FIRST", "family": "Rüdiger", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "R."} {"name": "Rühle-Gerstel, Alice", "given": "Alice", "family": "Rühle-Gerstel", "initials": "A. R."} {"name": "S. V.", "given": "S.", "family": "V.", "initials": "S. V."} {"name": "S. V. D.", "given": "S.", "middle": "V.", "family": "D.", "initials": "S. V. D."} {"name": "SEÁN Ó MURCHÚ", "given": "SEÁN", "family": "Ó MURCHÚ", "initials": "S. M.", "capitalized": "Seán Ó Murchú"} {"name": "SHIRLEY MACLAINE", "given": "SHIRLEY", "family": "MACLAINE", "initials": "S. M.", "capitalized": "Shirley MacLaine"} {"name": "SIG.RA MARIA ROSSI", "title": "SIG.RA", "given": "MARIA", "family": "ROSSI", "initials": "M. R.", "capitalized": "Sig.ra Maria Rossi"} {"name": "SMITH VD MA, JOHN", "given": "JOHN", "family": "SMITH VD MA", "initials": "J. S. M.", "capitalized": "John Smith vd Ma"} {"name": "SMITH VD, JOHN", "given": "JOHN", "family": "SMITH", "suffix": "VD", "initials": "J. S.", "capitalized": "John Smith VD"} {"name": "SMITH, DEAN MR.", "title": "MR.", "given": "DEAN", "family": "SMITH", "initials": "D. S.", "capitalized": "Mr. Dean Smith"} {"name": "SMITH, JOHN DO", "given": "JOHN", "family": "DO SMITH", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "J. S.", "capitalized": "John do Smith"} {"name": "SMITH, MA", "given": "MA", "family": "SMITH", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "M. S.", "capitalized": "Ma Smith"} {"name": "SMITH, SAM E.", "given": "SAM", "middle": "E.", "family": "SMITH", "initials": "S. E. S.", "capitalized": "Sam E. Smith"} {"name": "SMITH, VD DE LA MA", "family": "SMITH VD DE LA MA", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "S. M.", "capitalized": "Smith vd de la Ma"} {"name": "SMITH, VD MA", "family": "SMITH VD MA", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "S. M.", "capitalized": "Smith vd Ma"} {"name": "STEVEN HARDMAN, MD, DO, DDS", "given": "STEVEN", "family": "HARDMAN", "suffix": "MD, DO, DDS", "ambiguities": ["COMMA_STRUCTURE"], "initials": "S. H.", "capitalized": "Steven Hardman MD, DO, DDS"} {"name": "Saad, Habib Pacha es-", "given": "Habib", "middle": "Pacha", "family": "es- Saad", "initials": "H. P. S."} {"name": "Saar Samira Ahmet", "order": "FAMILY_FIRST", "given": "Samira", "middle": "Ahmet", "family": "Saar", "initials": "S. A. S."} {"name": "Sahagún, Bernardino de", "given": "Bernardino", "family": "de Sahagún", "initials": "B. S."} {"name": "Said oglu", "given": "Said", "family": "oglu", "initials": "S. o.", "capitalized_force": "Said Oglu"} {"name": "Saint-Exupéry, Consuelo de", "given": "Consuelo", "family": "de Saint-Exupéry", "initials": "C. S."} {"name": "Saint-Pol-Roux", "given": "Saint-Pol-Roux", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "S."} {"name": "Saki", "given": "Saki", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "S."} {"name": "Salam, abd Allah", "given": "abd Allah", "family": "Salam", "initials": "a. A. S.", "capitalized_force": "Abd Allah Salam"} {"name": "Salem, Abdul Rahman Ahmed", "given": "Abdul Rahman", "middle": "Ahmed", "family": "Salem", "initials": "A. R. A. S."} {"name": "Salimah Sulaiman", "given": "Salimah", "family": "Sulaiman", "initials": "S. S."} {"name": "Salomón Villada Hoyos", "given": "Salomón", "middle": "Villada", "family": "Hoyos", "initials": "S. V. H."} {"name": "Salvador Aguirre Anguiano", "given": "Salvador", "middle": "Aguirre", "family": "Anguiano", "initials": "S. A. A."} {"name": "Salvador Ortiga Torres", "given": "Salvador", "middle": "Ortiga", "family": "Torres", "initials": "S. O. T."} {"name": "Salvador Rosas Magallón", "given": "Salvador", "middle": "Rosas", "family": "Magallón", "initials": "S. R. M."} {"name": "Sam Smith 😊", "given": "Sam", "family": "Smith", "initials": "S. S."} {"name": "Samba", "given": "Samba", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "S."} {"name": "Samba", "order": "FAMILY_FIRST", "family": "Samba", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "S."} {"name": "Samira Ahmet Saar", "given": "Samira", "middle": "Ahmet", "family": "Saar", "initials": "S. A. S."} {"name": "Samuel Bourne", "given": "Samuel", "family": "Bourne", "initials": "S. B."} {"name": "San Pedro, Diego Fernández de", "given": "Diego", "middle": "Fernández", "family": "de San Pedro", "initials": "D. F. P.", "capitalized_force": "Diego Fernández de san Pedro"} {"name": "San San Chiou", "given": "San", "family": "San Chiou", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "S. C.", "capitalized_force": "San san Chiou"} {"name": "Sancho I.", "given": "Sancho", "family": "I.", "initials": "S. I."} {"name": "Sander van", "given": "Sander", "family": "van", "initials": "S. v.", "capitalized_force": "Sander Van"} {"name": "Sangallo, Antonio da", "given": "Antonio", "family": "da Sangallo", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "A. S."} {"name": "Sangiorgio, Gianantonio da", "given": "Gianantonio", "family": "da Sangiorgio", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "G. S."} {"name": "Sanna Lianora", "order": "FAMILY_FIRST", "given": "Lianora", "family": "Sanna", "initials": "L. S."} {"name": "Sanna, Lianora", "given": "Lianora", "family": "Sanna", "initials": "L. S."} {"name": "Santa María González, Domingo", "given": "Domingo", "family": "Santa María González", "initials": "D. M. G.", "capitalized_force": "Domingo santa María González"} {"name": "Santi di Tito", "given": "Santi", "family": "di Tito", "initials": "S. T."} {"name": "Santiago Díaz Moyano", "given": "Santiago", "middle": "Díaz", "family": "Moyano", "initials": "S. D. M."} {"name": "Santos Cabral, Antonio dos", "given": "Antonio", "family": "dos Santos Cabral", "initials": "A. S. C."} {"name": "Sanz Briz, Ángel", "given": "Ángel", "family": "Sanz Briz", "initials": "Á. S. B."} {"name": "Saparow Amangeldi", "order": "FAMILY_FIRST", "given": "Amangeldi", "family": "Saparow", "initials": "A. S."} {"name": "Saparow, Amangeldi", "given": "Amangeldi", "family": "Saparow", "initials": "A. S."} {"name": "Sara Björk Gunnarsdóttir", "given": "Sara", "middle": "Björk", "family": "Gunnarsdóttir", "initials": "S. B. G."} {"name": "Sarasa, Alfonsus Antonius de", "given": "Alfonsus", "middle": "Antonius", "family": "de Sarasa", "initials": "A. A. S."} {"name": "Sarel", "given": "Sarel", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "S."} {"name": "Sarel", "order": "FAMILY_FIRST", "family": "Sarel", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "S."} {"name": "Sarkīs ar-Rizzī", "given": "Sarkīs", "family": "ar-Rizzī", "initials": "S. a.", "capitalized_force": "Sarkīs Ar-Rizzī"} {"name": "Sasilia Hamis Stober", "given": "Sasilia", "middle": "Hamis", "family": "Stober", "initials": "S. H. S."} {"name": "Sattorov Alisher", "order": "FAMILY_FIRST", "given": "Alisher", "family": "Sattorov", "initials": "A. S."} {"name": "Sattorov, Alisher", "given": "Alisher", "family": "Sattorov", "initials": "A. S."} {"name": "Saturnino Rodríguez Fernández", "given": "Saturnino", "middle": "Rodríguez", "family": "Fernández", "initials": "S. R. F."} {"name": "Sañsé Señ Baba Wagué Amdy Raas MP", "order": "FAMILY_FIRST", "given": "Señ", "middle": "Baba Wagué Amdy Raas", "family": "Sañsé", "suffix": "MP", "initials": "S. B. W. A. R. S."} {"name": "Saúl González Herrera", "given": "Saúl", "middle": "González", "family": "Herrera", "initials": "S. G. H."} {"name": "Schiltberger, Hans", "given": "Hans", "family": "Schiltberger", "initials": "H. S."} {"name": "Schlesinger, Ignác", "given": "Ignác", "family": "Schlesinger", "initials": "I. S."} {"name": "Schmidt, Marianne", "given": "Marianne", "family": "Schmidt", "initials": "M. S."} {"name": "Schön, Theodor von", "given": "Theodor", "family": "von Schön", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "T. S."} {"name": "Schönert, Klaus", "given": "Klaus", "family": "Schönert", "initials": "K. S."} {"name": "Sean O'Connor", "given": "Sean", "family": "O'Connor", "initials": "S. O."} {"name": "Sebastian Neidhart", "given": "Sebastian", "family": "Neidhart", "initials": "S. N."} {"name": "Sebastià Salellas i Magret", "given": "Sebastià", "family": "Salellas i Magret", "initials": "S. S. M."} {"name": "Sebastián Lucumí Mina", "given": "Sebastián", "middle": "Lucumí", "family": "Mina", "initials": "S. L. M."} {"name": "Secretary of State Hillary Clinton", "title": "Secretary of State", "given": "Hillary", "family": "Clinton", "initials": "H. C."} {"name": "Segundo Serrano Poncela", "given": "Segundo", "middle": "Serrano", "family": "Poncela", "initials": "S. S. P."} {"name": "Sein Lwin", "given": "Sein", "family": "Lwin", "initials": "S. L."} {"name": "Sejal Chaturvedi, CSM", "given": "Sejal", "family": "Chaturvedi", "suffix": "CSM", "initials": "S. C."} {"name": "Selma Björnsdóttir", "given": "Selma", "family": "Björnsdóttir", "initials": "S. B."} {"name": "Senator \"Rick\" Edmonds", "title": "Senator", "family": "Edmonds", "nickname": "Rick", "initials": "E."} {"name": "Senior Judge Charles R. Butler, Jr", "title": "Senior Judge", "given": "Charles", "middle": "R.", "family": "Butler", "suffix": "Jr", "initials": "C. R. B."} {"name": "Senior Judge Harold D. Vietor", "title": "Senior Judge", "given": "Harold", "middle": "D.", "family": "Vietor", "initials": "H. D. V."} {"name": "Senior Judge Virgil Pittman", "title": "Senior Judge", "given": "Virgil", "family": "Pittman", "initials": "V. P."} {"name": "Sepp Anne", "order": "FAMILY_FIRST", "given": "Anne", "family": "Sepp", "initials": "A. S."} {"name": "Sepp, Anne", "given": "Anne", "family": "Sepp", "initials": "A. S."} {"name": "Seppo Ilmarinen", "given": "Seppo", "family": "Ilmarinen", "initials": "S. I."} {"name": "Sergeant Major John Smith", "title": "Sergeant Major", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "Sergio Marcos González", "given": "Sergio", "middle": "Marcos", "family": "González", "initials": "S. M. G."} {"name": "Serrano Poncela, Segundo", "given": "Segundo", "family": "Serrano Poncela", "initials": "S. S. P."} {"name": "Sert y Badia, José Maria", "given": "José", "middle": "Maria", "family": "Sert y Badia", "initials": "J. M. S. B."} {"name": "Severo Aguirre Miramón", "given": "Severo", "middle": "Aguirre", "family": "Miramón", "initials": "S. A. M."} {"name": "Seymour Martin Lipset", "given": "Seymour", "middle": "Martin", "family": "Lipset", "initials": "S. M. L."} {"name": "Seynabu", "given": "Seynabu", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "S."} {"name": "Seynabu", "order": "FAMILY_FIRST", "family": "Seynabu", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "S."} {"name": "Seán Seamas Watson", "given": "Seán", "middle": "Seamas", "family": "Watson", "initials": "S. S. W."} {"name": "Señ Baba Wagué Amdy Raas Sañsé MP", "given": "Señ", "middle": "Baba Wagué Amdy Raas", "family": "Sañsé", "suffix": "MP", "initials": "S. B. W. A. R. S."} {"name": "Señor Carlos García", "title": "Señor", "given": "Carlos", "family": "García", "initials": "C. G."} {"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. 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": "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."} {"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 Jr.", "given": "Sidorov", "middle": "Ivan", "family": "Petrovich", "suffix": "Jr.", "initials": "S. I. P."} {"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."} {"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."} {"name": "Sigfús Einarsson", "given": "Sigfús", "family": "Einarsson", "initials": "S. E."} {"name": "Sighvatur Þórðarson", "given": "Sighvatur", "family": "Þórðarson", "initials": "S. Þ."} {"name": "Signor Marco Rossi", "title": "Signor", "given": "Marco", "family": "Rossi", "initials": "M. R."} {"name": "Sigríður Hagalín", "given": "Sigríður", "family": "Hagalín", "initials": "S. H."} {"name": "Sigríður Jóna Valgerður Fanndís von Bjarnadóttir", "order": "FAMILY_FIRST", "given": "Jóna", "middle": "Valgerður Fanndís von Bjarnadóttir", "family": "Sigríður", "initials": "J. V. F. B. S."} {"name": "Sigríður Jóna Valgerður Fanndís von Bjarnadóttir, M.A. í bókmenntafræði", "given": "Sigríður", "middle": "Jóna Valgerður Fanndís", "family": "von Bjarnadóttir", "suffix": "M.A. í bókmenntafræði", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: initial as given (shape)", "SUFFIX_OR_NAME: name as given (position)"], "initials": "S. J. V. F. B.", "capitalized_force": "Sigríður Jóna Valgerður Fanndís von Bjarnadóttir M.A. Í Bókmenntafræði"} {"name": "Sigrún Jónsdóttir", "given": "Sigrún", "family": "Jónsdóttir", "initials": "S. J."} {"name": "Sigurdór Sigurdórsson", "given": "Sigurdór", "family": "Sigurdórsson", "initials": "S. S."} {"name": "Sigurjón Birgir Sigurðsson", "given": "Sigurjón", "middle": "Birgir", "family": "Sigurðsson", "initials": "S. B. S."} {"name": "Sigurður", "given": "Sigurður", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "S."} {"name": "Sigurður", "order": "FAMILY_FIRST", "family": "Sigurður", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "S."} {"name": "Sigurður Björnsson", "given": "Sigurður", "family": "Björnsson", "initials": "S. B."} {"name": "Sigurður Helgason", "given": "Sigurður", "family": "Helgason", "initials": "S. H."} {"name": "Sigurður Ólafsson", "given": "Sigurður", "family": "Ólafsson", "initials": "S. Ó."} {"name": "Silva João Pedro", "order": "FAMILY_FIRST", "given": "João", "middle": "Pedro", "family": "Silva", "initials": "J. P. S."} {"name": "Silva Maria", "order": "FAMILY_FIRST", "given": "Maria", "family": "Silva", "initials": "M. S."} {"name": "Silva Neto, Pedro", "given": "Pedro", "family": "Silva Neto", "initials": "P. S. N."} {"name": "Silva, José Pedro Eduardo da", "given": "José", "middle": "Pedro Eduardo", "family": "da Silva", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "J. P. E. S."} {"name": "Silva, João Pedro", "given": "João", "middle": "Pedro", "family": "Silva", "initials": "J. P. S."} {"name": "Silva, Maria", "given": "Maria", "family": "Silva", "initials": "M. S."} {"name": "Silva, Neto", "given": "Neto", "family": "Silva", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: agnomen as suffix (vocabulary)"], "initials": "N. S."} {"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": "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."} {"name": "Sinbad", "given": "Sinbad", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "S."} {"name": "Sinbad", "order": "FAMILY_FIRST", "family": "Sinbad", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "S."} {"name": "Sinbads", "given": "Sinbads", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "S."} {"name": "Sinbads", "order": "FAMILY_FIRST", "family": "Sinbads", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "S."} {"name": "Sindbad", "given": "Sindbad", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "S."} {"name": "Sindbad", "order": "FAMILY_FIRST", "family": "Sindbad", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "S."} {"name": "Sinibaldo Gutiérrez Mas", "given": "Sinibaldo", "middle": "Gutiérrez", "family": "Mas", "initials": "S. G. M."} {"name": "Sir Abu Bakar", "title": "Sir", "given": "Abu Bakar", "initials": "A. B."} {"name": "Sir Bob Andrew Dole", "title": "Sir", "given": "Bob", "middle": "Andrew", "family": "Dole", "initials": "B. A. D."} {"name": "Sir Gerald", "title": "Sir", "given": "Gerald", "initials": "G."} {"name": "Sir John", "title": "Sir", "given": "John", "initials": "J."} {"name": "Sir John Prof.", "title": "Sir Prof.", "given": "John", "initials": "J."} {"name": "Sir Ph. D. Van Johnson", "title": "Sir", "given": "Van Johnson", "suffix": "Ph. D.", "initials": "V. J."} {"name": "Sir Smith, John", "title": "Sir", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "Sir Van Johnson", "title": "Sir", "given": "Van", "family": "Johnson", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "V. J."} {"name": "Sir abdul Jr Smith", "title": "Sir", "given": "abdul", "middle": "Jr", "family": "Smith", "initials": "a. J. S.", "capitalized_force": "Sir Abdul Jr Smith"} {"name": "Sir abdul salam", "title": "Sir", "given": "abdul salam", "initials": "a. s.", "capitalized_force": "Sir Abdul Salam"} {"name": "Sir abdul salam Jr", "title": "Sir", "given": "abdul salam", "suffix": "Jr", "initials": "a. s.", "capitalized_force": "Sir Abdul Salam Jr"} {"name": "Sir abdul van der Berg", "title": "Sir", "given": "abdul", "family": "van der Berg", "initials": "a. B.", "capitalized_force": "Sir Abdul van der Berg"} {"name": "Sir de", "title": "Sir", "given": "de", "initials": "d.", "capitalized_force": "Sir De"} {"name": "Sir de Mesnil", "title": "Sir", "family": "de Mesnil", "initials": "M."} {"name": "Sirius", "given": "Sirius", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "S."} {"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": "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": "Hr. Geir Jón Magnús Brynjar Skeggjason Yngri, Þingmaður"} {"name": "Slovák Ján Matej", "order": "FAMILY_FIRST", "given": "Ján", "middle": "Matej", "family": "Slovák", "initials": "J. M. S."} {"name": "Slovák, Ján Matej", "given": "Ján", "middle": "Matej", "family": "Slovák", "initials": "J. M. S."} {"name": "Smith", "given": "Smith", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "S."} {"name": "Smith (Jones)", "family": "Smith", "nickname": "Jones", "initials": "S."} {"name": "Smith (MA), John", "given": "John", "family": "Smith", "nickname": "MA", "ambiguities": ["SUFFIX_OR_NICKNAME"], "alternatives": ["SUFFIX_OR_NICKNAME: credential as suffix (vocabulary)"], "initials": "J. S.", "capitalized_force": "John \"Ma\" Smith"} {"name": "Smith (née Jones)", "family": "Smith", "maiden": "Jones", "initials": "S."} {"name": "Smith , John", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "Smith Cecilia Angela", "order": "FAMILY_FIRST", "given": "Cecilia", "middle": "Angela", "family": "Smith", "initials": "C. A. S."} {"name": "Smith Dr", "given": "Smith", "family": "Dr", "initials": "S. D."} {"name": "Smith Jones Jr., J.A.", "given": "J.A.", "family": "Smith Jones", "suffix": "Jr.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (shape)"], "initials": "J. S. J."} {"name": "Smith Jones, Ph. D. Jr.", "given": "Smith", "family": "Jones", "suffix": "Ph. D. Jr.", "initials": "S. J."} {"name": "Smith Jr.", "given": "Smith", "suffix": "Jr.", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "S."} {"name": "Smith Jr., A.B.", "given": "A.B.", "family": "Smith", "suffix": "Jr.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (shape)"], "initials": "A. S."} {"name": "Smith Jr., John", "given": "John", "family": "Smith", "suffix": "Jr.", "initials": "J. S."} {"name": "Smith Jr., MA", "family": "Smith", "suffix": "Jr., MA", "initials": "S."} {"name": "Smith Jr., Ma", "given": "Ma", "family": "Smith", "suffix": "Jr.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "M. S."} {"name": "Smith Jr., Mr.", "title": "Mr.", "family": "Smith", "suffix": "Jr.", "initials": "S."} {"name": "Smith Juan Vega", "given": "Smith", "middle": "Juan", "family": "Vega", "initials": "S. J. V."} {"name": "Smith Pharm D, John", "given": "John", "family": "Smith Pharm D", "initials": "J. S. P. D."} {"name": "Smith Prof.", "title": "Prof.", "family": "Smith", "initials": "S."} {"name": "Smith Sir.", "title": "Sir.", "given": "Smith", "initials": "S."} {"name": "Smith V, AD", "given": "AD", "family": "Smith V", "initials": "A. S. V.", "capitalized_force": "Ad Smith V"} {"name": "Smith V, John", "given": "John", "family": "Smith V", "initials": "J. S. V."} {"name": "Smith VI, AD", "given": "AD", "family": "Smith", "suffix": "VI", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "A. S.", "capitalized_force": "Ad Smith VI"} {"name": "Smith VI, John", "given": "John", "family": "Smith", "suffix": "VI", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "J. S."} {"name": "Smith Vi, John", "given": "John", "family": "Smith Vi", "initials": "J. S. V."} {"name": "Smith and Pope", "given": "Smith and Pope", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "S. P."} {"name": "Smith born", "given": "Smith", "family": "born", "initials": "S. b.", "capitalized_force": "Smith Born"} {"name": "Smith nee Jones, Jane MA", "given": "Jane", "family": "Smith", "suffix": "MA", "maiden": "Jones", "initials": "J. S."} {"name": "Smith née", "given": "Smith", "family": "née", "initials": "S. n.", "capitalized_force": "Smith Née"} {"name": "Smith née Jones", "family": "Smith", "maiden": "Jones", "initials": "S."} {"name": "Smith née Jones PhD", "family": "Smith", "suffix": "PhD", "maiden": "Jones", "initials": "S."} {"name": "Smith van der", "given": "Smith", "family": "van der", "initials": "S. v. d.", "capitalized_force": "Smith Van Der"} {"name": "Smith 김민준씨, XYZ", "given": "Smith", "middle": "김", "family": "민준", "suffix": "씨, XYZ", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "S. 김. 민."} {"name": "Smith, A.B.", "given": "A.B.", "family": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (shape)"], "initials": "A. S."} {"name": "Smith, A.B.C.", "family": "Smith", "suffix": "A.B.C.", "initials": "S."} {"name": "Smith, A.P.", "given": "A.P.", "family": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (shape)"], "initials": "A. S."} {"name": "Smith, Abd", "family": "Smith", "suffix": "Abd", "initials": "S.", "capitalized_force": "Smith ABD"} {"name": "Smith, Cecilia Angela", "given": "Cecilia", "middle": "Angela", "family": "Smith", "initials": "C. A. S."} {"name": "Smith, Chancellor of the Exchequer MP", "title": "Chancellor of the Exchequer", "family": "Smith", "suffix": "MP", "initials": "S."} {"name": "Smith, Chas.", "given": "Chas.", "family": "Smith", "initials": "C. S."} {"name": "Smith, Chi", "given": "Chi", "family": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "C. S."} {"name": "Smith, Do. John", "title": "Do.", "given": "John", "family": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "J. S."} {"name": "Smith, Dr.", "title": "Dr.", "family": "Smith", "initials": "S."} {"name": "Smith, Dr. John", "title": "Dr.", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "Smith, Dr. Jr.", "title": "Dr.", "family": "Smith", "suffix": "Jr.", "initials": "S."} {"name": "Smith, E.S.Q.", "given": "E.S.Q.", "family": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (shape)"], "initials": "E. S."} {"name": "Smith, E.T., Jr.", "given": "E.T.", "family": "Smith", "suffix": "Jr.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (shape)"], "initials": "E. S."} {"name": "Smith, Esq.", "family": "Smith", "suffix": "Esq.", "initials": "S."} {"name": "Smith, Esq. John", "family": "Smith", "suffix": "Esq. John", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "S."} {"name": "Smith, Freiherr von Berg MA Jones", "title": "Freiherr", "given": "von Berg", "family": "Smith", "suffix": "MA Jones", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as middle (position)"], "initials": "v. B. S.", "capitalized_force": "Freiherr Von Berg Smith MA Jones"} {"name": "Smith, I.", "given": "I.", "family": "Smith", "initials": "I. S."} {"name": "Smith, Insp. John", "title": "Insp.", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "Smith, J. R.", "given": "J.", "middle": "R.", "family": "Smith", "initials": "J. R. S."} {"name": "Smith, J.R.", "given": "J.R.", "family": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (shape)"], "initials": "J. S."} {"name": "Smith, Jan van Prof.", "title": "Prof.", "given": "Jan", "family": "van Smith", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "J. S."} {"name": "Smith, Jane Mrs", "title": "Mrs", "given": "Jane", "family": "Smith", "initials": "J. S."} {"name": "Smith, John", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "Smith, John (MA)", "given": "John", "family": "Smith", "suffix": "MA", "initials": "J. S."} {"name": "Smith, John ,", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "Smith, John - Paul", "given": "John", "middle": "- Paul", "family": "Smith", "initials": "J. -. P. S."} {"name": "Smith, John E, III, Jr", "given": "John", "middle": "E", "family": "Smith", "suffix": "III, Jr", "initials": "J. E. S."} {"name": "Smith, John I", "given": "John", "family": "Smith", "suffix": "I", "initials": "J. S."} {"name": "Smith, John Jr.", "given": "John", "family": "Smith", "suffix": "Jr.", "initials": "J. S."} {"name": "Smith, John MA Jones", "given": "John", "family": "Smith", "suffix": "MA Jones", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as middle (position)"], "initials": "J. S."} {"name": "Smith, John MD/PhD", "given": "John", "family": "Smith", "suffix": "MD/PhD", "initials": "J. S."} {"name": "Smith, John MEng", "given": "John", "middle": "MEng", "family": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "J. M. S.", "capitalized_force": "John Meng Smith"} {"name": "Smith, John Ma", "given": "John", "middle": "Ma", "family": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "J. M. S."} {"name": "Smith, John PSM I, Jr.", "given": "John", "middle": "I", "family": "Smith", "suffix": "PSM, Jr.", "initials": "J. I. S."} {"name": "Smith, John PhD I, Jr.", "given": "John", "middle": "I", "family": "Smith", "suffix": "PhD, Jr.", "initials": "J. I. S."} {"name": "Smith, John PhD I.", "given": "John", "family": "Smith", "suffix": "PhD I.", "initials": "J. S."} {"name": "Smith, John PhD I., Jr.", "given": "John", "middle": "I.", "family": "Smith", "suffix": "PhD, Jr.", "initials": "J. I. S."} {"name": "Smith, John PhD Jones", "given": "John", "family": "Smith", "suffix": "PhD Jones", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as middle (position)"], "initials": "J. S."} {"name": "Smith, John PhD de Jr.", "given": "John", "family": "de Smith", "suffix": "PhD, Jr.", "initials": "J. S."} {"name": "Smith, John PhD É, Jr.", "given": "John", "family": "Smith", "suffix": "PhD É, Jr.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: initial as middle (shape)"], "initials": "J. S."} {"name": "Smith, John Pharm D", "given": "John", "family": "Smith", "suffix": "Pharm D", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as middle (position)"], "initials": "J. S."} {"name": "Smith, John Pharm D Jones", "given": "John", "family": "Smith", "suffix": "Pharm D Jones", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as middle (position)", "SUFFIX_OR_NAME: name as middle (position)"], "initials": "J. S."} {"name": "Smith, John Prof.", "title": "Prof.", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "Smith, John Sam E.", "given": "John", "family": "Smith", "suffix": "Sam E.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as middle (position)"], "initials": "J. S.", "capitalized_force": "John Smith SAM E."} {"name": "Smith, John V", "given": "John", "family": "Smith", "suffix": "V", "initials": "J. S."} {"name": "Smith, John V MD", "given": "John", "middle": "V", "family": "Smith", "suffix": "MD", "initials": "J. V. S."} {"name": "Smith, John V, Jr.", "given": "John", "middle": "V", "family": "Smith", "suffix": "Jr.", "initials": "J. V. S."} {"name": "Smith, John V.", "given": "John", "middle": "V.", "family": "Smith", "initials": "J. V. S."} {"name": "Smith, John V;", "given": "John", "middle": "V;", "family": "Smith", "initials": "J. V. S."} {"name": "Smith, John VI", "given": "John", "family": "Smith", "suffix": "VI", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as middle (position)"], "initials": "J. S."} {"name": "Smith, John VI Jones", "given": "John", "family": "Smith", "suffix": "VI Jones", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as middle (position)", "SUFFIX_OR_NAME: name as middle (position)"], "initials": "J. S."} {"name": "Smith, John VI MD", "given": "John", "family": "Smith", "suffix": "VI MD", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as middle (position)"], "initials": "J. S."} {"name": "Smith, John Vi", "given": "John", "middle": "Vi", "family": "Smith", "initials": "J. V. S."} {"name": "Smith, John X", "given": "John", "middle": "X", "family": "Smith", "initials": "J. X. S."} {"name": "Smith, John e, III, Jr", "given": "John", "middle": "e", "family": "Smith", "suffix": "III, Jr", "initials": "J. e. S."} {"name": "Smith, John van der Berg Prof.", "title": "Prof.", "given": "John", "middle": "van der Berg", "family": "Smith", "initials": "J. B. S."} {"name": "Smith, John, Bob (MA)", "given": "John", "family": "Smith", "suffix": "Bob MA", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. S."} {"name": "Smith, John, Extra, Jr.", "given": "John", "family": "Smith", "suffix": "Extra, Jr.", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. S."} {"name": "Smith, John, MD - PhD", "given": "John", "family": "Smith", "suffix": "MD, PhD", "initials": "J. S."} {"name": "Smith, John, MD - née Jones Smith", "given": "John", "family": "Smith", "suffix": "MD, née Jones Smith", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. S.", "capitalized_force": "John Smith MD, Née Jones Smith"} {"name": "Smith, John, PhD - and MD", "given": "John", "family": "Smith", "suffix": "PhD, and MD", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. S."} {"name": "Smith, John, PhD - i Soler", "given": "John", "family": "Smith", "suffix": "PhD, i Soler", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. S.", "capitalized_force": "John Smith PhD, I Soler"} {"name": "Smith, John, PhD née Jones", "given": "John", "family": "Smith", "suffix": "PhD née Jones", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. S.", "capitalized_force": "John Smith PhD Née Jones"} {"name": "Smith, John, Prof.", "title": "Prof.", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "Smith, John, Puig - i Soler", "given": "John", "family": "Smith", "suffix": "Puig, i Soler", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. S.", "capitalized_force": "John Smith Puig, I Soler"} {"name": "Smith, John, X.Y.Z. (MA)", "given": "John", "family": "Smith", "suffix": "X.Y.Z. MA", "initials": "J. S."} {"name": "Smith, John, and", "given": "John", "family": "Smith", "suffix": "and", "ambiguities": ["COMMA_STRUCTURE"], "initials": "J. S."} {"name": "Smith, John; MD", "given": "John;", "family": "Smith", "suffix": "MD", "initials": "J. S."} {"name": "Smith, Jr.", "family": "Smith", "suffix": "Jr.", "initials": "S."} {"name": "Smith, Jr., PhD", "family": "Smith", "suffix": "Jr., PhD", "initials": "S."} {"name": "Smith, LEED AP", "given": "LEED", "middle": "AP", "family": "Smith", "initials": "L. A. S.", "capitalized_force": "Leed Ap Smith"} {"name": "Smith, MA", "family": "Smith", "suffix": "MA", "initials": "S."} {"name": "Smith, MD - PhD - FACS", "family": "Smith", "suffix": "MD - PhD - FACS", "initials": "S."} {"name": "Smith, MD ; PhD", "family": "Smith", "suffix": "MD ; PhD", "initials": "S."} {"name": "Smith, MD PhD", "family": "Smith", "suffix": "MD PhD", "initials": "S."} {"name": "Smith, MD; PhD", "family": "Smith", "suffix": "MD; PhD", "initials": "S."} {"name": "Smith, MEng", "given": "MEng", "family": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "M. S.", "capitalized_force": "Meng Smith"} {"name": "Smith, Ma", "given": "Ma", "family": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "M. S."} {"name": "Smith, Ma. John", "given": "Ma.", "middle": "John", "family": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "M. J. S."} {"name": "Smith, Major. John", "title": "Major.", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "Smith, Mary-Jane", "given": "Mary-Jane", "family": "Smith", "initials": "M. S."} {"name": "Smith, Ms Jane", "title": "Ms", "given": "Jane", "family": "Smith", "initials": "J. S."} {"name": "Smith, Ms Ma", "title": "Ms", "given": "Ma", "family": "Smith", "initials": "M. S."} {"name": "Smith, Ms RA Ma", "title": "Ms RA", "given": "Ma", "family": "Smith", "initials": "M. S.", "capitalized_force": "Ms Ra Ma Smith"} {"name": "Smith, Ms.", "family": "Smith", "suffix": "Ms.", "initials": "S.", "capitalized_force": "Smith MS."} {"name": "Smith, Ms. Jane", "title": "Ms.", "given": "Jane", "family": "Smith", "initials": "J. S."} {"name": "Smith, PSM I", "family": "Smith", "suffix": "PSM I", "initials": "S."} {"name": "Smith, PSM I.", "family": "Smith", "suffix": "PSM I.", "initials": "S."} {"name": "Smith, PU-PH", "given": "PU-PH", "family": "Smith", "initials": "P. S.", "capitalized_force": "Pu-Ph Smith"} {"name": "Smith, Ph. D.", "family": "Smith", "suffix": "Ph. D.", "initials": "S."} {"name": "Smith, Ph. D. III", "family": "Smith", "suffix": "Ph. D. III", "initials": "S."} {"name": "Smith, Ph. D. Jr.", "family": "Smith", "suffix": "Ph. D. Jr.", "initials": "S."} {"name": "Smith, Ph. D. Jr. MD", "family": "Smith", "suffix": "Ph. D. Jr. MD", "initials": "S."} {"name": "Smith, Ph. D. MD", "family": "Smith", "suffix": "Ph. D. MD", "initials": "S."} {"name": "Smith, Ph.D. Jr.", "family": "Smith", "suffix": "Ph.D. Jr.", "initials": "S."} {"name": "Smith, PhD", "family": "Smith", "suffix": "PhD", "initials": "S."} {"name": "Smith, PhD Jr.", "family": "Smith", "suffix": "PhD Jr.", "initials": "S."} {"name": "Smith, PhD MEng", "family": "Smith", "suffix": "PhD MEng", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (declared)"], "initials": "S.", "capitalized_force": "Smith PhD MENG"} {"name": "Smith, PhD Ma", "family": "Smith", "suffix": "PhD Ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (declared)"], "initials": "S.", "capitalized_force": "Smith PhD MA"} {"name": "Smith, Pharm D", "given": "Pharm", "middle": "D", "family": "Smith", "initials": "P. D. S."} {"name": "Smith, Prof.", "title": "Prof.", "family": "Smith", "initials": "S."} {"name": "Smith, RA PhD Ma", "title": "RA", "given": "PhD", "middle": "Ma", "family": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "P. M. S.", "capitalized_force": "Ra PhD Ma Smith"} {"name": "Smith, RN - CRNA", "family": "Smith", "suffix": "RN - CRNA", "initials": "S."} {"name": "Smith, Sgt Major", "title": "Sgt Major", "family": "Smith", "initials": "S."} {"name": "Smith, Sir", "title": "Sir", "given": "Smith", "initials": "S."} {"name": "Smith, Sr.", "family": "Smith", "suffix": "Sr.", "initials": "S."} {"name": "Smith, V; MD", "given": "V;", "family": "Smith", "suffix": "MD", "initials": "V. S."} {"name": "Smith, XYZ", "given": "XYZ", "family": "Smith", "initials": "X. S.", "capitalized_force": "Xyz Smith"} {"name": "Smith, de Mesnil Jean", "family": "Smith de Mesnil Jean", "initials": "S. M. J."} {"name": "Smith, meng", "given": "meng", "family": "Smith", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "m. S.", "capitalized_force": "Meng Smith"} {"name": "Smith. John", "title": "Smith.", "family": "John", "initials": "J."} {"name": "Smith,John", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "Sn. Dr. Fatma Su Yıldırım", "title": "Sn. Dr.", "given": "Fatma", "middle": "Su", "family": "Yıldırım", "initials": "F. S. Y."} {"name": "Sn. Yıldırım Kaya Fatma Su, Dr.", "order": "FAMILY_FIRST", "title": "Sn. Dr.", "given": "Kaya", "middle": "Fatma Su", "family": "Yıldırım", "initials": "K. F. S. Y."} {"name": "Sodersen Martine", "order": "FAMILY_FIRST", "given": "Martine", "family": "Sodersen", "initials": "M. S."} {"name": "Sodersen, Martine", "given": "Martine", "family": "Sodersen", "initials": "M. S."} {"name": "Soedjatmoko", "given": "Soedjatmoko", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "S."} {"name": "Soffía Karlsdóttir", "given": "Soffía", "family": "Karlsdóttir", "initials": "S. K."} {"name": "Sofija Rita Penkauskienė", "given": "Sofija", "middle": "Rita", "family": "Penkauskienė", "initials": "S. R. P."} {"name": "Solms-Braunfels, Bernhard zu", "given": "Bernhard", "family": "zu Solms-Braunfels", "initials": "B. S."} {"name": "Solms-Hohensolms-Lich, Hermann zu", "given": "Hermann", "family": "zu Solms-Hohensolms-Lich", "initials": "H. S."} {"name": "Sophie von Merenberg", "given": "Sophie", "family": "von Merenberg", "initials": "S. M."} {"name": "Sordo Madaleno, Juan", "given": "Juan", "family": "Sordo Madaleno", "initials": "J. S. M."} {"name": "Souto Brais Manoel", "order": "FAMILY_FIRST", "given": "Brais", "middle": "Manoel", "family": "Souto", "initials": "B. M. S."} {"name": "Souto, Brais Manoel", "given": "Brais", "middle": "Manoel", "family": "Souto", "initials": "B. M. S."} {"name": "Souza, Francisco Xavier Joao Anton de", "given": "Francisco", "middle": "Xavier Joao Anton", "family": "de Souza", "initials": "F. X. J. A. S."} {"name": "Spewack, Bella Cohen", "given": "Bella", "middle": "Cohen", "family": "Spewack", "initials": "B. C. S."} {"name": "Sr US District Judge Richard G Kopf", "title": "Sr US District Judge", "given": "Richard", "middle": "G", "family": "Kopf", "initials": "R. G. K.", "capitalized_force": "Sr Us District Judge Richard G Kopf"} {"name": "Sr. Antoni Baìngiu Torchitòriu Comita de Lacon Gunale", "title": "Sr.", "given": "Antoni", "middle": "Baìngiu Torchitòriu Comita", "family": "de Lacon Gunale", "initials": "A. B. T. C. L. G."} {"name": "Sr. José Pedro Eduardo da Silva Jr., MP", "title": "Sr.", "given": "José", "middle": "Pedro Eduardo", "family": "da Silva", "suffix": "Jr., MP", "initials": "J. P. E. S."} {"name": "Sr. Miguel Ángel Juan Antonio Pablo II", "title": "Sr.", "given": "Miguel", "middle": "Ángel Juan Antonio", "family": "Pablo", "suffix": "II", "initials": "M. Á. J. A. P."} {"name": "Sr. Xosé Carlos Francisco Xavier de Castro Sáez", "title": "Sr.", "given": "Xosé", "middle": "Carlos Francisco Xavier", "family": "de Castro Sáez", "initials": "X. C. F. X. C. S."} {"name": "Srta. Andréia da Silva", "title": "Srta.", "given": "Andréia", "family": "da Silva", "initials": "A. S."} {"name": "St Clair", "title": "St", "family": "Clair", "initials": "C."} {"name": "St Pierre, Ed", "given": "Ed", "family": "St Pierre", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "E. P.", "capitalized_force": "Ed st Pierre"} {"name": "St Sir de Mesnil", "title": "St Sir", "given": "de Mesnil", "initials": "d. M.", "capitalized_force": "St Sir De Mesnil"} {"name": "St van Bishop", "title": "St", "family": "van Bishop", "ambiguities": ["PARTICLE_OR_GIVEN", "TITLE_OR_NAME"], "alternatives": ["PARTICLE_OR_GIVEN: name as given (declared)", "TITLE_OR_NAME: title as title (vocabulary)"], "initials": "B."} {"name": "Sta. Maria, Juan", "given": "Juan", "family": "Sta. Maria", "initials": "J. S. M."} {"name": "Stander Zelda Herman", "order": "FAMILY_FIRST", "given": "Zelda", "middle": "Herman", "family": "Stander", "initials": "Z. H. S."} {"name": "Stander, Zelda Herman", "given": "Zelda", "middle": "Herman", "family": "Stander", "initials": "Z. H. S."} {"name": "Stariji Bruegel", "given": "Stariji", "family": "Bruegel", "initials": "S. B."} {"name": "Ste Marie", "family": "Ste Marie", "initials": "M.", "capitalized_force": "ste Marie"} {"name": "Stefan", "given": "Stefan", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "S."} {"name": "Stefan", "order": "FAMILY_FIRST", "family": "Stefan", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "S."} {"name": "Stefan Katie", "order": "FAMILY_FIRST", "given": "Katie", "family": "Stefan", "initials": "K. S."} {"name": "Stefan, Katie", "given": "Katie", "family": "Stefan", "initials": "K. S."} {"name": "Stefán Hörður Grímsson", "given": "Stefán", "middle": "Hörður", "family": "Grímsson", "initials": "S. H. G."} {"name": "Stefán Karl Stefánsson", "given": "Stefán", "middle": "Karl", "family": "Stefánsson", "initials": "S. K. S."} {"name": "Steingrímur Jóhann Sigfússon", "given": "Steingrímur", "middle": "Jóhann", "family": "Sigfússon", "initials": "S. J. S."} {"name": "Steingrímur SteinÞórsson", "given": "Steingrímur", "family": "SteinÞórsson", "initials": "S. S.", "capitalized_force": "Steingrímur Steinþórsson"} {"name": "Steinunn Sigurðardóttir", "given": "Steinunn", "family": "Sigurðardóttir", "initials": "S. S."} {"name": "Stephan Guðmundsson Stephansson", "given": "Stephan", "middle": "Guðmundsson", "family": "Stephansson", "initials": "S. G. S."} {"name": "Steven Hardman", "given": "Steven", "family": "Hardman", "initials": "S. H."} {"name": "Steven Hardman, MD - DO - DDS", "given": "Steven", "family": "Hardman", "suffix": "MD, DO, DDS", "initials": "S. H."} {"name": "Steven Hardman, MD, DO, DDS", "given": "Steven", "family": "Hardman", "suffix": "MD, DO, DDS", "initials": "S. H."} {"name": "Steven Hardman, RN - CRNA", "given": "Steven", "family": "Hardman", "suffix": "RN, CRNA", "initials": "S. H."} {"name": "Stober Sasilia Hamis", "order": "FAMILY_FIRST", "given": "Sasilia", "middle": "Hamis", "family": "Stober", "initials": "S. H. S."} {"name": "Stober Zazilia Hamish", "order": "FAMILY_FIRST", "given": "Zazilia", "middle": "Hamish", "family": "Stober", "initials": "Z. H. S."} {"name": "Stober, Sasilia Hamis", "given": "Sasilia", "middle": "Hamis", "family": "Stober", "initials": "S. H. S."} {"name": "Stober, Zazilia Hamish", "given": "Zazilia", "middle": "Hamish", "family": "Stober", "initials": "Z. H. S."} {"name": "Stober, Zazilia Hamish", "order": "FAMILY_FIRST", "given": "Zazilia", "middle": "Hamish", "family": "Stober", "initials": "Z. H. S."} {"name": "Struve, Karl von", "given": "Karl", "family": "von Struve", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "K. S."} {"name": "Studeny, Herma", "given": "Herma", "family": "Studeny", "initials": "H. S."} {"name": "Stöber Hamish Zäzilia", "order": "FAMILY_FIRST", "given": "Hamish", "middle": "Zäzilia", "family": "Stöber", "initials": "H. Z. S."} {"name": "Stöber Zezilia Hemiş", "order": "FAMILY_FIRST", "given": "Zezilia", "middle": "Hemiş", "family": "Stöber", "initials": "Z. H. S."} {"name": "Stöber Zäzilia Hamish", "order": "FAMILY_FIRST", "given": "Zäzilia", "middle": "Hamish", "family": "Stöber", "initials": "Z. H. S."} {"name": "Stöber, Zezilia Hemiş", "given": "Zezilia", "middle": "Hemiş", "family": "Stöber", "initials": "Z. H. S."} {"name": "Stöber, Zäzilia Gültan", "given": "Zäzilia", "middle": "Gültan", "family": "Stöber", "initials": "Z. G. S."} {"name": "Stöber, Zäzilia Gültan", "order": "FAMILY_FIRST", "given": "Zäzilia", "middle": "Gültan", "family": "Stöber", "initials": "Z. G. S."} {"name": "Stöber, Zäzilia Hamish", "given": "Zäzilia", "middle": "Hamish", "family": "Stöber", "initials": "Z. H. S."} {"name": "Stöber, Zäzilia Hamish", "order": "FAMILY_FIRST", "given": "Zäzilia", "middle": "Hamish", "family": "Stöber", "initials": "Z. H. S."} {"name": "Støver Zelda Hamish", "order": "FAMILY_FIRST", "given": "Zelda", "middle": "Hamish", "family": "Støver", "initials": "Z. H. S."} {"name": "Støver, Zelda Hamish", "given": "Zelda", "middle": "Hamish", "family": "Støver", "initials": "Z. H. S."} {"name": "Subandrio", "given": "Subandrio", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "S."} {"name": "Sugihara, Chiune", "given": "Chiune", "family": "Sugihara", "initials": "C. S."} {"name": "Suharto", "given": "Suharto", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "S."} {"name": "Sukarno", "given": "Sukarno", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "S."} {"name": "Sulaiman", "given": "Sulaiman", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "S."} {"name": "Sulaiman", "order": "FAMILY_FIRST", "family": "Sulaiman", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "S."} {"name": "Sulaiman Salimah", "order": "FAMILY_FIRST", "given": "Salimah", "family": "Sulaiman", "initials": "S. S."} {"name": "Sulaiman, Salimah", "given": "Salimah", "family": "Sulaiman", "initials": "S. S."} {"name": "Sule", "given": "Sule", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "S."} {"name": "Sule", "order": "FAMILY_FIRST", "family": "Sule", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "S."} {"name": "Sultan Hassanal Bolkiah", "given": "Sultan", "middle": "Hassanal", "family": "Bolkiah", "initials": "S. H. B."} {"name": "Sultana Begum", "given": "Sultana", "family": "Begum", "initials": "S. B."} {"name": "Suresh a/l Muthu", "given": "Suresh", "middle": "a/l", "family": "Muthu", "initials": "S. a. M.", "capitalized_force": "Suresh A/L Muthu"} {"name": "Surāqa Ibn-Mirdās al-Aṣġar", "given": "Surāqa", "middle": "Ibn-Mirdās", "family": "al-Aṣġar", "initials": "S. I. a.", "capitalized_force": "Surāqa ibn-Mirdās al-Aṣġar"} {"name": "Suzuki san", "order": "FAMILY_FIRST", "given": "san", "family": "Suzuki", "initials": "s. S.", "capitalized_force": "San Suzuki"} {"name": "Suzuki, Shōsan", "given": "Shōsan", "family": "Suzuki", "initials": "S. S."} {"name": "Suzuki-san", "order": "FAMILY_FIRST", "family": "Suzuki-san", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "S."} {"name": "Svarun", "given": "Svarun", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "S."} {"name": "Svarun", "order": "FAMILY_FIRST", "family": "Svarun", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "S."} {"name": "Svavar Guðnason", "given": "Svavar", "family": "Guðnason", "initials": "S. G."} {"name": "Sveinn Ásgeirsson", "given": "Sveinn", "family": "Ásgeirsson", "initials": "S. Á."} {"name": "Svoboda Josef Václav", "order": "FAMILY_FIRST", "given": "Josef", "middle": "Václav", "family": "Svoboda", "initials": "J. V. S."} {"name": "Svoboda, Josef Václav", "given": "Josef", "middle": "Václav", "family": "Svoboda", "initials": "J. V. S."} {"name": "Swami Vivekananda", "title": "Swami", "given": "Vivekananda", "initials": "V."} {"name": "Swami Vivekananda Saraswati", "title": "Swami", "given": "Vivekananda", "family": "Saraswati", "initials": "V. S."} {"name": "Syed A Ismail Syed Abd Aziz MP", "order": "FAMILY_FIRST", "given": "A", "middle": "Ismail Syed Abd Aziz", "family": "Syed", "suffix": "MP", "initials": "A. I. S. A. A. S."} {"name": "Syed Hassan Syed Sadiq", "order": "FAMILY_FIRST", "given": "Hassan", "middle": "Syed Sadiq", "family": "Syed", "initials": "H. S. S. S."} {"name": "Syed Hassan, Syed Sadiq", "given": "Syed", "middle": "Sadiq", "family": "Syed Hassan", "initials": "S. S. S. H."} {"name": "Syed Sadiq Syed Hassan", "given": "Syed", "middle": "Sadiq Syed", "family": "Hassan", "initials": "S. S. S. H."} {"name": "Syukri @ Shu Qi Mohd Nor", "given": "Syukri", "middle": "@ Shu Qi Mohd", "family": "Nor", "initials": "S. @. S. Q. M. N."} {"name": "Szabados Máté Bendegúz", "order": "FAMILY_FIRST", "given": "Máté", "middle": "Bendegúz", "family": "Szabados", "initials": "M. B. S."} {"name": "Szabados, Máté Bendegúz", "given": "Máté", "middle": "Bendegúz", "family": "Szabados", "initials": "M. B. S."} {"name": "Szabó Péter úr", "order": "FAMILY_FIRST", "given": "Péter", "middle": "úr", "family": "Szabó", "initials": "P. ú. S.", "capitalized_force": "Péter Úr Szabó"} {"name": "Szabó Péterné", "order": "FAMILY_FIRST", "given": "Péterné", "family": "Szabó", "initials": "P. S."} {"name": "Szabóné Kiss Éva", "order": "FAMILY_FIRST", "given": "Kiss", "middle": "Éva", "family": "Szabóné", "initials": "K. É. S."} {"name": "Szigeti, Joseph", "given": "Joseph", "family": "Szigeti", "initials": "J. S."} {"name": "Sébastien de Brossard", "given": "Sébastien", "family": "de Brossard", "initials": "S. B."} {"name": "Séverine", "given": "Séverine", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "S."} {"name": "Sócrates Gómez Pérez", "given": "Sócrates", "middle": "Gómez", "family": "Pérez", "initials": "S. G. P."} {"name": "Sölvi Ottesen", "given": "Sölvi", "family": "Ottesen", "initials": "S. O."} {"name": "Süßkind-Schwendi, Richard von", "given": "Richard", "family": "von Süßkind-Schwendi", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "R. S."} {"name": "Sābūr Ibn-Sahl", "given": "Sābūr", "family": "Ibn-Sahl", "initials": "S. I.", "capitalized_force": "Sābūr ibn-Sahl"} {"name": "Sādāt, Ǧīhān as-", "given": "Ǧīhān", "family": "as- Sādāt", "initials": "Ǧ. S."} {"name": "Tadsch ad-Din al-Hasani", "given": "Tadsch", "middle": "ad-Din", "family": "al-Hasani", "initials": "T. a. a.", "capitalized_force": "Tadsch Ad-Din al-Hasani"} {"name": "Tahir Jalaluddin", "given": "Tahir", "family": "Jalaluddin", "initials": "T. J."} {"name": "Takamatsu, Jirō", "given": "Jirō", "family": "Takamatsu", "initials": "J. T."} {"name": "Takeyama, Michio", "given": "Michio", "family": "Takeyama", "initials": "M. T."} {"name": "Tammik hr Mari-Ann Henri Roobert parlamendiliige", "order": "FAMILY_FIRST", "given": "hr", "middle": "Mari-Ann Henri Roobert parlamendiliige", "family": "Tammik", "initials": "h. M. H. R. p. T.", "capitalized_force": "Hr Mari-Ann Henri Roobert Parlamendiliige Tammik"} {"name": "Tammik, Mari-Ann Henri Roobert, parlamendiliige", "given": "Mari-Ann", "middle": "Henri Roobert", "family": "Tammik", "suffix": "parlamendiliige", "ambiguities": ["COMMA_STRUCTURE"], "initials": "M. H. R. T.", "capitalized_force": "Mari-Ann Henri Roobert Tammik Parlamendiliige"} {"name": "Taufīq al-Ḥakīm", "given": "Taufīq", "family": "al-Ḥakīm", "initials": "T. a."} {"name": "Te Awanui-a-Rangi Black", "given": "Te", "middle": "Awanui-a-Rangi", "family": "Black", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "T. A. B.", "capitalized_force": "Te Awanui-A-Rangi Black"} {"name": "Teddy Bunn", "given": "Teddy", "family": "Bunn", "initials": "T. B."} {"name": "Teitur Þórðarson", "given": "Teitur", "family": "Þórðarson", "initials": "T. Þ."} {"name": "Teodoro de Faria", "given": "Teodoro", "family": "de Faria", "initials": "T. F."} {"name": "Teresa Mañé i Miravet", "given": "Teresa", "family": "Mañé i Miravet", "initials": "T. M. M."} {"name": "Tewolde Berhan Gebre Egziabher", "given": "Tewolde", "middle": "Berhan Gebre", "family": "Egziabher", "initials": "T. B. G. E."} {"name": "Teógenes Díaz-Corralejo Fernández", "given": "Teógenes", "middle": "Díaz-Corralejo", "family": "Fernández", "initials": "T. D. F."} {"name": "Tham Jun", "given": "Tham", "family": "Jun", "initials": "T. J."} {"name": "Tham Jun Hoe", "given": "Tham", "middle": "Jun", "family": "Hoe", "initials": "T. J. H."} {"name": "Tham Junhoe", "given": "Tham", "family": "Junhoe", "initials": "T. J."} {"name": "The Hon", "given": "The", "family": "Hon", "initials": "T. H.", "capitalized_force": "the Hon"} {"name": "The Hon. Justice Mary Smith", "title": "The Hon. Justice", "given": "Mary", "family": "Smith", "initials": "M. S.", "capitalized_force": "the Hon. Justice Mary Smith"} {"name": "The Hon. Wing Lau", "title": "The Hon. Wing", "family": "Lau", "initials": "L.", "capitalized_force": "the Hon. Wing Lau"} {"name": "The Lord of the Universe", "title": "The Lord of the Universe", "capitalized_force": "the Lord of the Universe"} {"name": "The Mahdi Muhammad Ahmad", "given": "The Mahdi", "middle": "Muhammad", "family": "Ahmad", "initials": "M. M. A.", "capitalized_force": "the Mahdi Muhammad Ahmad"} {"name": "The Rev. Deacon John Smith", "title": "The Rev.", "given": "Deacon", "middle": "John", "family": "Smith", "initials": "D. J. S.", "capitalized_force": "the Rev. Deacon John Smith"} {"name": "The Right Hon. the President of the Queen's Bench Division", "title": "The Right Hon. the President of the Queen's Bench", "family": "Division", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "D.", "capitalized_force": "the Right Hon. the President of the Queen'S Bench Division"} {"name": "The Rt Hon", "title": "The Rt", "family": "Hon", "ambiguities": ["TITLE_OR_NAME"], "alternatives": ["TITLE_OR_NAME: title as title (vocabulary)"], "initials": "H.", "capitalized_force": "the Rt Hon"} {"name": "The Rt Hon John Jones", "title": "The Rt Hon", "given": "John", "family": "Jones", "initials": "J. J.", "capitalized_force": "the Rt Hon John Jones"} {"name": "The Rt Hon Kenneth Clarke QC MP, HMG", "title": "The Rt Hon", "given": "Kenneth", "family": "Clarke", "suffix": "QC MP, HMG", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "K. C.", "capitalized_force": "the Rt Hon Kenneth Clarke QC MP, HMG"} {"name": "Theobald, Dietrich von", "given": "Dietrich", "family": "von Theobald", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "D. T."} {"name": "Theodor von Schön", "given": "Theodor", "family": "von Schön", "initials": "T. S."} {"name": "Thibaut II.", "given": "Thibaut", "suffix": "II.", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "T."} {"name": "Thomas I.", "given": "Thomas", "family": "I.", "initials": "T. I."} {"name": "Thorkild Fjeldsted", "given": "Thorkild", "family": "Fjeldsted", "initials": "T. F."} {"name": "Thurn und Taxis, Mathilde Therese von", "given": "Mathilde", "middle": "Therese", "family": "von Thurn und Taxis", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "M. T. T. T."} {"name": "Théodore Agrippa d'Aubigné", "given": "Théodore", "middle": "Agrippa", "family": "d'Aubigné", "initials": "T. A. d.", "capitalized_force": "Théodore Agrippa D'Aubigné"} {"name": "Tiburcio Pérez Cuervo", "given": "Tiburcio", "middle": "Pérez", "family": "Cuervo", "initials": "T. P. C."} {"name": "Tisza, István", "given": "István", "family": "Tisza", "initials": "I. T."} {"name": "Title First Middle Middle Last, Jr.", "given": "Title", "middle": "First Middle Middle", "family": "Last", "suffix": "Jr.", "initials": "T. F. M. M. L."} {"name": "Tito, Santi di", "given": "Santi", "family": "di Tito", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "S. T."} {"name": "Tn. Syed Abd Aziz Syed A Ismail Petra MP", "title": "Tn.", "given": "Syed", "middle": "Abd Aziz Syed A Ismail", "family": "Petra", "suffix": "MP", "initials": "S. A. A. S. A. I. P."} {"name": "Tokugawa, Nariaki", "given": "Nariaki", "family": "Tokugawa", "initials": "N. T."} {"name": "Toledo Osorio, Fadrique de", "given": "Fadrique", "family": "de Toledo Osorio", "initials": "F. T. O."} {"name": "Tomás", "given": "Tomás", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "T."} {"name": "Tomás", "order": "FAMILY_FIRST", "family": "Tomás", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "T."} {"name": "Tomás Antonio de la Cerda y Aragón", "given": "Tomás", "middle": "Antonio", "family": "de la Cerda y Aragón", "initials": "T. A. C. A."} {"name": "Tomás González Carvajal", "given": "Tomás", "middle": "González", "family": "Carvajal", "initials": "T. G. C."} {"name": "Tomás Iglesias Pérez", "given": "Tomás", "middle": "Iglesias", "family": "Pérez", "initials": "T. I. P."} {"name": "Tony Miranda", "given": "Tony", "family": "Miranda", "initials": "T. M."} {"name": "Torres Ana", "order": "FAMILY_FIRST", "given": "Ana", "family": "Torres", "initials": "A. T."} {"name": "Torres, Ana", "given": "Ana", "family": "Torres", "initials": "A. T."} {"name": "Tounens, Antoine de", "given": "Antoine", "family": "de Tounens", "initials": "A. T."} {"name": "Traberth, Julie", "given": "Julie", "family": "Traberth", "initials": "J. T."} {"name": "Tran Thi Mai", "order": "FAMILY_FIRST", "given": "Thi", "middle": "Mai", "family": "Tran", "initials": "T. M. T."} {"name": "Tschagribeg", "given": "Tschagribeg", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "T."} {"name": "Tschurtschenthaler, Anton von", "given": "Anton", "family": "von Tschurtschenthaler", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "A. T."} {"name": "Tunde Seun Okeke", "given": "Tunde", "middle": "Seun", "family": "Okeke", "initials": "T. S. O."} {"name": "U Kyaw Win", "given": "U", "middle": "Kyaw", "family": "Win", "initials": "U. K. W."} {"name": "U. S. Grant", "given": "U.", "middle": "S.", "family": "Grant", "initials": "U. S. G."} {"name": "U.S. District Judge Marc Thomas Treadwell", "given": "U.S.", "middle": "District Judge Marc Thomas", "family": "Treadwell", "initials": "U. D. J. M. T. T."} {"name": "UNILEVER LTD", "given": "UNILEVER", "family": "LTD", "initials": "U. L.", "capitalized": "Unilever Ltd"} {"name": "US Magistrate Judge T Michael Putnam", "title": "US Magistrate Judge", "given": "T", "middle": "Michael", "family": "Putnam", "initials": "T. M. P.", "capitalized_force": "Us Magistrate Judge T Michael Putnam"} {"name": "US Magistrate-Judge Elizabeth E Campbell", "title": "US Magistrate-Judge", "given": "Elizabeth", "middle": "E", "family": "Campbell", "initials": "E. E. C.", "capitalized_force": "Us Magistrate-Judge Elizabeth E Campbell"} {"name": "Uddin, Md Karim", "family": "Uddin", "suffix": "Md Karim", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "U.", "capitalized_force": "Uddin MD Karim"} {"name": "Uddin, Mr Md Karim", "title": "Mr", "family": "Uddin", "suffix": "Md Karim", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "U.", "capitalized_force": "Mr Uddin MD Karim"} {"name": "Ueshiba, Morihei", "given": "Morihei", "family": "Ueshiba", "initials": "M. U."} {"name": "Uexküll, Jakob von", "given": "Jakob", "family": "von Uexküll", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "J. U."} {"name": "Uffenbach, Zacharias Konrad von", "given": "Zacharias", "middle": "Konrad", "family": "von Uffenbach", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "Z. K. U."} {"name": "Ulrich I.", "given": "Ulrich", "family": "I.", "initials": "U. I."} {"name": "Ulrika Wilhelmina Canth", "given": "Ulrika", "middle": "Wilhelmina", "family": "Canth", "initials": "U. W. C."} {"name": "Ultra Violet", "given": "Ultra", "family": "Violet", "initials": "U. V."} {"name": "Umar Fatima Amina", "order": "FAMILY_FIRST", "given": "Fatima", "middle": "Amina", "family": "Umar", "initials": "F. A. U."} {"name": "Umar, Fatima Amina", "given": "Fatima", "middle": "Amina", "family": "Umar", "initials": "F. A. U."} {"name": "Umehara, Takeshi", "given": "Takeshi", "family": "Umehara", "initials": "T. U."} {"name": "Una Guðmundsdóttir", "given": "Una", "family": "Guðmundsdóttir", "initials": "U. G."} {"name": "Ungnad von Weissenwolf, David zu Sonnegg von", "given": "David", "middle": "zu Sonnegg", "family": "von Ungnad von Weissenwolf", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "D. S. U. W."} {"name": "Unilever Ltd", "given": "Unilever", "family": "Ltd", "initials": "U. L."} {"name": "Unruh, Fritz von", "given": "Fritz", "family": "von Unruh", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "F. U."} {"name": "Urrestaratzu, Ane Miren", "given": "Ane", "middle": "Miren", "family": "Urrestaratzu", "initials": "A. M. U."} {"name": "Urrestaratzu, Ane Miren", "order": "FAMILY_FIRST", "given": "Ane", "middle": "Miren", "family": "Urrestaratzu", "initials": "A. M. U."} {"name": "Urrestaratzu, Jon Andoni", "given": "Jon", "middle": "Andoni", "family": "Urrestaratzu", "initials": "J. A. U."} {"name": "Urrestaratzu, Jon Andoni", "order": "FAMILY_FIRST", "given": "Jon", "middle": "Andoni", "family": "Urrestaratzu", "initials": "J. A. U."} {"name": "Ursina", "given": "Ursina", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "U."} {"name": "Ursina", "order": "FAMILY_FIRST", "family": "Ursina", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "U."} {"name": "Ursula von der Leyen (geb. Albrecht)", "given": "Ursula", "family": "von der Leyen", "maiden": "Albrecht", "initials": "U. L."} {"name": "Ursula von der Leyen geb. Albrecht", "given": "Ursula", "family": "von der Leyen", "maiden": "Albrecht", "initials": "U. L."} {"name": "Uxía", "given": "Uxía", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "U."} {"name": "Uxía", "order": "FAMILY_FIRST", "family": "Uxía", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "U."} {"name": "VI, AD", "given": "AD", "family": "VI", "initials": "A. V.", "capitalized": "Ad Vi"} {"name": "VINCENT VAN GOGH", "given": "VINCENT", "family": "VAN GOGH", "initials": "V. G.", "capitalized": "Vincent van Gogh"} {"name": "Va'apu'u Vitale", "given": "Va'apu'u", "family": "Vitale", "initials": "V. V.", "capitalized_force": "Va'Apu'U Vitale"} {"name": "Valderrama Fernández, Ricardo", "given": "Ricardo", "family": "Valderrama Fernández", "initials": "R. V. F."} {"name": "Valdimarsson Jón Hannes", "order": "FAMILY_FIRST", "given": "Jón", "middle": "Hannes", "family": "Valdimarsson", "initials": "J. H. V."} {"name": "Valentín Puga Franco", "given": "Valentín", "middle": "Puga", "family": "Franco", "initials": "V. P. F."} {"name": "Van Buren, Ed", "given": "Ed", "family": "Van Buren", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "E. B.", "capitalized_force": "Ed van Buren"} {"name": "Van Dalen Hoogeveen, Maria da Graça César Martín", "given": "Maria", "middle": "da Graça César Martín", "family": "Van Dalen Hoogeveen", "initials": "M. G. C. M. D. H.", "capitalized_force": "Maria da Graça César Martín van Dalen Hoogeveen"} {"name": "Van Jeremy Johnson", "given": "Van", "middle": "Jeremy", "family": "Johnson", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "V. J. J."} {"name": "Van Johnson", "given": "Van", "family": "Johnson", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "V. J."} {"name": "Van Johnson Smith", "given": "Van", "middle": "Johnson", "family": "Smith", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "V. J. S."} {"name": "Van Maximilian Carlson", "given": "Van", "middle": "Maximilian", "family": "Carlson", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "V. M. C."} {"name": "Van Nguyen", "given": "Van", "family": "Nguyen", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "V. N."} {"name": "Van Rooten, Luis", "given": "Luis", "family": "Van Rooten", "initials": "L. R.", "capitalized_force": "Luis van Rooten"} {"name": "Van der Merwe Jan Koos", "order": "FAMILY_FIRST", "given": "der Merwe Jan Koos", "family": "Van", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "d. M. J. K. V.", "capitalized_force": "Der Merwe Jan Koos Van"} {"name": "Van der Merwe, Jan Koos", "given": "Jan", "middle": "Koos", "family": "Van der Merwe", "initials": "J. K. M.", "capitalized_force": "Jan Koos van der Merwe"} {"name": "Van der Walt", "given": "Van", "family": "der Walt", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "V. W."} {"name": "Varano, Costanza da", "given": "Costanza", "family": "da Varano", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "C. V."} {"name": "Vatson Con Hamiş", "order": "FAMILY_FIRST", "given": "Con", "middle": "Hamiş", "family": "Vatson", "initials": "C. H. V."} {"name": "Vega Smith", "given": "Vega", "family": "Smith", "initials": "V. S."} {"name": "Vega, Juan de la", "given": "Juan", "family": "de la Vega", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "J. V."} {"name": "Velasquez y Garcia, Dr. Juan", "title": "Dr.", "given": "Juan", "family": "Velasquez y Garcia", "initials": "J. V. G."} {"name": "Velasquez y Garcia, Dr. Juan III", "title": "Dr.", "given": "Juan", "family": "Velasquez y Garcia", "suffix": "III", "initials": "J. V. G."} {"name": "Velasquez y Garcia, Dr. Juan Q.", "title": "Dr.", "given": "Juan", "middle": "Q.", "family": "Velasquez y Garcia", "initials": "J. Q. V. G."} {"name": "Velasquez y Garcia, Dr. Juan Q. III", "title": "Dr.", "given": "Juan", "middle": "Q.", "family": "Velasquez y Garcia", "suffix": "III", "initials": "J. Q. V. G."} {"name": "Velasquez y Garcia, Dr. Juan Q. Xavier", "title": "Dr.", "given": "Juan", "middle": "Q. Xavier", "family": "Velasquez y Garcia", "initials": "J. Q. X. V. G."} {"name": "Velasquez y Garcia, Dr. Juan Q. Xavier III", "title": "Dr.", "given": "Juan", "middle": "Q. Xavier", "family": "Velasquez y Garcia", "suffix": "III", "initials": "J. Q. X. V. G."} {"name": "Velasquez y Garcia, Dr. Juan Q. Xavier, Jr.", "title": "Dr.", "given": "Juan", "middle": "Q. Xavier", "family": "Velasquez y Garcia", "suffix": "Jr.", "initials": "J. Q. X. V. G."} {"name": "Velasquez y Garcia, Dr. Juan Q., Jr.", "title": "Dr.", "given": "Juan", "middle": "Q.", "family": "Velasquez y Garcia", "suffix": "Jr.", "initials": "J. Q. V. G."} {"name": "Velasquez y Garcia, Dr. Juan, Jr.", "title": "Dr.", "given": "Juan", "family": "Velasquez y Garcia", "suffix": "Jr.", "initials": "J. V. G."} {"name": "Velasquez y Garcia, Juan", "given": "Juan", "family": "Velasquez y Garcia", "initials": "J. V. G."} {"name": "Velasquez y Garcia, Juan III", "given": "Juan", "family": "Velasquez y Garcia", "suffix": "III", "initials": "J. V. G."} {"name": "Velasquez y Garcia, Juan Q.", "given": "Juan", "middle": "Q.", "family": "Velasquez y Garcia", "initials": "J. Q. V. G."} {"name": "Velasquez y Garcia, Juan Q. III", "given": "Juan", "middle": "Q.", "family": "Velasquez y Garcia", "suffix": "III", "initials": "J. Q. V. G."} {"name": "Velasquez y Garcia, Juan Q. Xavier", "given": "Juan", "middle": "Q. Xavier", "family": "Velasquez y Garcia", "initials": "J. Q. X. V. G."} {"name": "Velasquez y Garcia, Juan Q. Xavier III", "given": "Juan", "middle": "Q. Xavier", "family": "Velasquez y Garcia", "suffix": "III", "initials": "J. Q. X. V. G."} {"name": "Velasquez y Garcia, Juan Q. Xavier, Jr.", "given": "Juan", "middle": "Q. Xavier", "family": "Velasquez y Garcia", "suffix": "Jr.", "initials": "J. Q. X. V. G."} {"name": "Velasquez y Garcia, Juan Q., Jr.", "given": "Juan", "middle": "Q.", "family": "Velasquez y Garcia", "suffix": "Jr.", "initials": "J. Q. V. G."} {"name": "Velasquez y Garcia, Juan, Jr.", "given": "Juan", "family": "Velasquez y Garcia", "suffix": "Jr.", "initials": "J. V. G."} {"name": "Venerable Ananda", "title": "Venerable", "family": "Ananda", "initials": "A."} {"name": "Vicente Becerra Vaca", "given": "Vicente", "middle": "Becerra", "family": "Vaca", "initials": "V. B. V."} {"name": "Vicente Gómez Mier", "given": "Vicente", "middle": "Gómez", "family": "Mier", "initials": "V. G. M."} {"name": "Vicente Martín y Soler", "given": "Vicente", "family": "Martín y Soler", "initials": "V. M. S."} {"name": "Vicente Rodríguez Casado", "given": "Vicente", "middle": "Rodríguez", "family": "Casado", "initials": "V. R. C."} {"name": "Victor-Marie d'Estrées", "given": "Victor-Marie", "family": "d'Estrées", "initials": "V. d.", "capitalized_force": "Victor-Marie D'Estrées"} {"name": "Vigdís Finnbogadóttir", "given": "Vigdís", "family": "Finnbogadóttir", "initials": "V. F."} {"name": "Vilborg Davíðsdóttir", "given": "Vilborg", "family": "Davíðsdóttir", "initials": "V. D."} {"name": "Vilkas, Ada Kornelija Eva Sofija von", "given": "Ada", "middle": "Kornelija Eva Sofija", "family": "von Vilkas", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "A. K. E. S. V."} {"name": "Vincent van Gogh", "given": "Vincent", "family": "van Gogh", "initials": "V. G."} {"name": "Vincent van Gogh van Beethoven", "given": "Vincent", "middle": "van Gogh", "family": "van Beethoven", "initials": "V. G. B."} {"name": "Vinícius de Moraes", "given": "Vinícius", "family": "de Moraes", "initials": "V. M."} {"name": "Virginia G. Essandoh, J.D.", "given": "Virginia", "middle": "G.", "family": "Essandoh", "suffix": "J.D.", "initials": "V. G. E."} {"name": "Vladislav I.", "given": "Vladislav", "family": "I.", "initials": "V. I."} {"name": "Volf, Ada Kornelia Yeva Sofiya van den", "given": "Ada", "middle": "Kornelia Yeva Sofiya", "family": "van den Volf", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "A. K. Y. S. V."} {"name": "Volf, Aleksandra Eva Sara van den", "given": "Aleksandra", "middle": "Eva Sara", "family": "van den Volf", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "A. E. S. V."} {"name": "Vossug ed Dowleh", "given": "Vossug", "middle": "ed", "family": "Dowleh", "initials": "V. e. D.", "capitalized_force": "Vossug Ed Dowleh"} {"name": "Vuk", "given": "Vuk", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "V."} {"name": "Vuk", "order": "FAMILY_FIRST", "family": "Vuk", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "V."} {"name": "Vélez de Guevara, Luis", "given": "Luis", "family": "Vélez de Guevara", "initials": "L. V. G."} {"name": "Víctor Luna Gómez", "given": "Víctor", "middle": "Luna", "family": "Gómez", "initials": "V. L. G."} {"name": "WOOT ALFRED DOUGHERTY III", "given": "WOOT", "middle": "ALFRED", "family": "DOUGHERTY", "suffix": "III", "initials": "W. A. D.", "capitalized": "Woot Alfred Dougherty III"} {"name": "Waldburg-Truchsess, Friedrich Ludwig von", "given": "Friedrich", "middle": "Ludwig", "family": "von Waldburg-Truchsess", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "F. L. W."} {"name": "Wallin Johan Harald", "order": "FAMILY_FIRST", "given": "Johan", "middle": "Harald", "family": "Wallin", "initials": "J. H. W."} {"name": "Wallin, Johan Harald", "order": "FAMILY_FIRST", "given": "Johan", "middle": "Harald", "family": "Wallin", "initials": "J. H. W."} {"name": "Walter Leschetizky", "given": "Walter", "family": "Leschetizky", "initials": "W. L."} {"name": "Waly Mari shu Halimatu", "order": "FAMILY_FIRST", "given": "Mari", "middle": "shu Halimatu", "family": "Waly", "initials": "M. s. H. W.", "capitalized_force": "Mari Shu Halimatu Waly"} {"name": "Wang M.Eng.", "given": "Wang", "suffix": "M.Eng.", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "W.", "capitalized_force": "Wang M.ENG."} {"name": "Wang Ma PhD", "given": "Wang", "family": "Ma", "suffix": "PhD", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "W. M."} {"name": "Wang, Si", "given": "Si", "family": "Wang", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "S. W."} {"name": "Wang, Zhaojun", "given": "Zhaojun", "family": "Wang", "initials": "Z. W."} {"name": "Wanjiru wa Kamau", "given": "Wanjiru", "middle": "wa", "family": "Kamau", "initials": "W. w. K.", "capitalized_force": "Wanjiru Wa Kamau"} {"name": "Washington Jr. MD, Franklin", "given": "Franklin", "family": "Washington", "suffix": "Jr. MD", "initials": "F. W."} {"name": "Watson John Hamish", "order": "FAMILY_FIRST", "given": "John", "middle": "Hamish", "family": "Watson", "initials": "J. H. W."} {"name": "Watson John Hámish", "order": "FAMILY_FIRST", "given": "John", "middle": "Hámish", "family": "Watson", "initials": "J. H. W."} {"name": "Watson Mary Sue Hamish", "order": "FAMILY_FIRST", "given": "Mary", "middle": "Sue Hamish", "family": "Watson", "initials": "M. S. H. W."} {"name": "Watson Seán Seamas", "order": "FAMILY_FIRST", "given": "Seán", "middle": "Seamas", "family": "Watson", "initials": "S. S. W."} {"name": "Watson, John Hamish", "given": "John", "middle": "Hamish", "family": "Watson", "initials": "J. H. W."} {"name": "Watson, John Hámish", "given": "John", "middle": "Hámish", "family": "Watson", "initials": "J. H. W."} {"name": "Watson, Mary Sue Hamish", "given": "Mary", "middle": "Sue Hamish", "family": "Watson", "initials": "M. S. H. W."} {"name": "Weasley Ginevra Molly", "order": "FAMILY_FIRST", "given": "Ginevra", "middle": "Molly", "family": "Weasley", "initials": "G. M. W."} {"name": "Weasley, Ginevra Molly", "given": "Ginevra", "middle": "Molly", "family": "Weasley", "initials": "G. M. W."} {"name": "Weber Annelise", "order": "FAMILY_FIRST", "given": "Annelise", "family": "Weber", "initials": "A. W."} {"name": "Weber, Annelise", "given": "Annelise", "family": "Weber", "initials": "A. W."} {"name": "Wefayî", "given": "Wefayî", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "W."} {"name": "Weigold, Hugo", "given": "Hugo", "family": "Weigold", "initials": "H. W."} {"name": "Weise, Dietrich", "given": "Dietrich", "family": "Weise", "initials": "D. W."} {"name": "Werner I.", "given": "Werner", "family": "I.", "initials": "W. I."} {"name": "Wertheimer, Samson", "given": "Samson", "family": "Wertheimer", "initials": "S. W."} {"name": "Wickenburg, Markus von", "given": "Markus", "family": "von Wickenburg", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "M. W."} {"name": "Wijaya, Maria", "given": "Maria", "family": "Wijaya", "initials": "M. W."} {"name": "Wijaya, Maria", "order": "FAMILY_FIRST", "given": "Maria", "family": "Wijaya", "initials": "M. W."} {"name": "Wildenbruch, Ernst von", "given": "Ernst", "family": "von Wildenbruch", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "E. W."} {"name": "Wilhelm I.", "given": "Wilhelm", "family": "I.", "initials": "W. I."} {"name": "Wilhelm IV.", "given": "Wilhelm", "suffix": "IV.", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "W."} {"name": "Wilkins, Charles", "given": "Charles", "family": "Wilkins", "initials": "C. W."} {"name": "Will De Groot", "given": "Will", "family": "De Groot", "initials": "W. G.", "capitalized_force": "Will de Groot"} {"name": "Willem Antonie van Deventer", "given": "Willem", "middle": "Antonie", "family": "van Deventer", "initials": "W. A. D."} {"name": "Willem van Haren", "given": "Willem", "family": "van Haren", "initials": "W. H."} {"name": "Willemse Bertus Wilbers Henri Retief LP", "order": "FAMILY_FIRST", "given": "Bertus", "middle": "Wilbers Henri Retief", "family": "Willemse", "suffix": "LP", "initials": "B. W. H. R. W."} {"name": "Willemse Heidi Johannes", "order": "FAMILY_FIRST", "given": "Heidi", "middle": "Johannes", "family": "Willemse", "initials": "H. J. W."} {"name": "Willemse, Bertus Wilbers Henri Retief", "given": "Bertus", "middle": "Wilbers Henri Retief", "family": "Willemse", "initials": "B. W. H. R. W."} {"name": "Willemse, Heidi Johannes", "given": "Heidi", "middle": "Johannes", "family": "Willemse", "initials": "H. J. W."} {"name": "William Shakespeare, the Bard", "given": "the Bard", "family": "William Shakespeare", "initials": "B. W. S."} {"name": "Willington Baena Ortiz", "given": "Willington", "middle": "Baena", "family": "Ortiz", "initials": "W. B. O."} {"name": "Wing Commander Lau", "title": "Wing Commander", "family": "Lau", "initials": "L."} {"name": "Wing Lau", "given": "Wing", "family": "Lau", "initials": "W. L."} {"name": "Wing Smith, John", "given": "John", "family": "Wing Smith", "initials": "J. W. S."} {"name": "Wing. Lau", "title": "Wing.", "family": "Lau", "initials": "L."} {"name": "Winghe, Jodocus van", "given": "Jodocus", "family": "van Winghe", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "J. W."} {"name": "Witte, Jean Joseph Antoine Marie de", "given": "Jean", "middle": "Joseph Antoine Marie", "family": "de Witte", "initials": "J. J. A. M. W."} {"name": "Wićazowa, Jěwa-Marja Hańža", "given": "Jěwa-Marja", "middle": "Hańža", "family": "Wićazowa", "initials": "J. H. W."} {"name": "Wićazowa, dr. Jěwa-Marja Hańža jr. phd", "order": "FAMILY_FIRST", "title": "dr.", "given": "Jěwa-Marja", "middle": "Hańža", "family": "Wićazowa", "suffix": "jr. phd", "initials": "J. H. W.", "capitalized_force": "Dr. Jěwa-Marja Hańža Wićazowa Jr. PhD"} {"name": "Wj. Monir Mómen Monir Mómen Balóch", "title": "Wj.", "given": "Monir", "middle": "Mómen Monir Mómen", "family": "Balóch", "initials": "M. M. M. M. B."} {"name": "Wm. H. Smith III", "given": "Wm.", "middle": "H.", "family": "Smith", "suffix": "III", "initials": "W. H. S."} {"name": "Wolf von Engelhardt", "given": "Wolf", "family": "von Engelhardt", "initials": "W. E."} {"name": "Wolf, Ada Cornelia Eva Sophia van den", "given": "Ada", "middle": "Cornelia Eva Sophia", "family": "van den Wolf", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "A. C. E. S. W."} {"name": "Wolf, Ada Cornelia Eva Sophia von", "given": "Ada", "middle": "Cornelia Eva Sophia", "family": "von Wolf", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "A. C. E. S. W."} {"name": "Wolf, Anna Cornelia Eva Sophia van den", "given": "Anna", "middle": "Cornelia Eva Sophia", "family": "van den Wolf", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "A. C. E. S. W."} {"name": "Wolf, Inger Marie Adele Synnøve von", "given": "Inger", "middle": "Marie Adele Synnøve", "family": "von Wolf", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "I. M. A. S. W."} {"name": "Wolfgang Amadeus Mozart", "given": "Wolfgang", "middle": "Amadeus", "family": "Mozart", "initials": "W. A. M."} {"name": "Wooster Bertram Wilberforce Henry Robert", "order": "FAMILY_FIRST", "given": "Bertram", "middle": "Wilberforce Henry Robert", "family": "Wooster", "initials": "B. W. H. R. W."} {"name": "Wooster Bertram Wilberforce Henry Robert AS", "order": "FAMILY_FIRST", "given": "Bertram", "middle": "Wilberforce Henry Robert AS", "family": "Wooster", "initials": "B. W. H. R. A. W.", "capitalized_force": "Bertram Wilberforce Henry Robert As Wooster"} {"name": "Wooster Bertram Wilberforce Henry Robert MP", "order": "FAMILY_FIRST", "given": "Bertram", "middle": "Wilberforce Henry Robert", "family": "Wooster", "suffix": "MP", "initials": "B. W. H. R. W."} {"name": "Wooster Mr. Bertram Wilberforce Henry Robert Jr, MP", "order": "FAMILY_FIRST", "given": "Mr.", "middle": "Bertram Wilberforce Henry Robert", "family": "Wooster", "suffix": "Jr, MP", "initials": "M. B. W. H. R. W."} {"name": "Wooster Mudane Bertram Wilberforce Henry Robert Jr, MP", "order": "FAMILY_FIRST", "given": "Mudane", "middle": "Bertram Wilberforce Henry Robert", "family": "Wooster", "suffix": "Jr, MP", "initials": "M. B. W. H. R. W."} {"name": "Wooster Owura Bertram Wilberforce Henry Robert MP", "order": "FAMILY_FIRST", "given": "Owura", "middle": "Bertram Wilberforce Henry Robert", "family": "Wooster", "suffix": "MP", "initials": "O. B. W. H. R. W."} {"name": "Wooster, Bertram Wilberforce Henry Robert", "given": "Bertram", "middle": "Wilberforce Henry Robert", "family": "Wooster", "initials": "B. W. H. R. W."} {"name": "Wooster, Bertram Wilberforce Henry Robert MP", "given": "Bertram", "middle": "Wilberforce Henry Robert", "family": "Wooster", "suffix": "MP", "initials": "B. W. H. R. W."} {"name": "X.Y.Z. Smith", "given": "X.Y.Z.", "family": "Smith", "initials": "X. S."} {"name": "Xiana Freire", "given": "Xiana", "family": "Freire", "initials": "X. F."} {"name": "Xosé María Brea Segade", "given": "Xosé", "middle": "María Brea", "family": "Segade", "initials": "X. M. B. S."} {"name": "Xosé María Álvarez Blázquez", "given": "Xosé", "middle": "María Álvarez", "family": "Blázquez", "initials": "X. M. Á. B."} {"name": "Xyz. (Bud) Smith", "title": "Xyz.", "family": "Smith", "nickname": "Bud", "initials": "S."} {"name": "Xyz. John Smith", "title": "Xyz.", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "Xyz. Smith, John", "given": "John", "family": "Xyz. Smith", "initials": "J. X. S."} {"name": "Xyz. and Mary Smith", "given": "Xyz. and Mary", "family": "Smith", "initials": "X. M. S."} {"name": "Xyz. van Johnson", "title": "Xyz.", "given": "van", "family": "Johnson", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "v. J.", "capitalized_force": "Xyz. Van Johnson"} {"name": "Y. L.", "given": "Y.", "family": "L.", "initials": "Y. L."} {"name": "Yamamoto, Baiitsu", "given": "Baiitsu", "family": "Yamamoto", "initials": "B. Y."} {"name": "Yamamoto, Senji", "given": "Senji", "family": "Yamamoto", "initials": "S. Y."} {"name": "Yamamoto, Tarō", "given": "Tarō", "family": "Yamamoto", "initials": "T. Y."} {"name": "Yang, Xianyi", "given": "Xianyi", "family": "Yang", "initials": "X. Y."} {"name": "Yannaki Noémie Katerina", "order": "FAMILY_FIRST", "given": "Noémie", "middle": "Katerina", "family": "Yannaki", "initials": "N. K. Y."} {"name": "Yannaki, Noémie Katerina", "given": "Noémie", "middle": "Katerina", "family": "Yannaki", "initials": "N. K. Y."} {"name": "Yağız Can Kaya", "given": "Yağız", "middle": "Can", "family": "Kaya", "initials": "Y. C. K."} {"name": "Yenifer Córdoba Henao", "given": "Yenifer", "middle": "Córdoba", "family": "Henao", "initials": "Y. C. H."} {"name": "Yin Le", "given": "Yin", "family": "Le", "initials": "Y. L."} {"name": "Yin a Le", "given": "Yin", "middle": "a", "family": "Le", "initials": "Y. a. L.", "capitalized_force": "Yin A Le"} {"name": "Yoshi Emomoto", "given": "Yoshi", "family": "Emomoto", "initials": "Y. E."} {"name": "Yrsa Sigurðardóttir", "given": "Yrsa", "family": "Sigurðardóttir", "initials": "Y. S."} {"name": "Yāzīǧī, Ibrāhīm al-", "given": "Ibrāhīm", "family": "al- Yāzīǧī", "initials": "I. Y."} {"name": "Yıldırım, Fatma Su", "given": "Fatma", "middle": "Su", "family": "Yıldırım", "initials": "F. S. Y."} {"name": "Yılmaz Irmak", "order": "FAMILY_FIRST", "given": "Irmak", "family": "Yılmaz", "initials": "I. Y."} {"name": "Yılmaz, Irmak", "given": "Irmak", "family": "Yılmaz", "initials": "I. Y."} {"name": "Yūsuf al-ʿAẓma", "given": "Yūsuf", "family": "al-ʿAẓma", "initials": "Y. a."} {"name": "Z. Dara, Gavril", "given": "Gavril", "family": "Z. Dara", "initials": "G. Z. D."} {"name": "Z. Dara, Gavril A.", "order": "FAMILY_FIRST", "given": "Gavril", "middle": "A.", "family": "Z. Dara", "initials": "G. A. Z. D."} {"name": "Z. Gavril Dara", "given": "Z.", "middle": "Gavril", "family": "Dara", "initials": "Z. G. D."} {"name": "Zabala Carlos", "order": "FAMILY_FIRST", "given": "Carlos", "family": "Zabala", "initials": "C. Z."} {"name": "Zabala, Carlos", "given": "Carlos", "family": "Zabala", "initials": "C. Z."} {"name": "Zacharias Konrad von Uffenbach", "given": "Zacharias", "middle": "Konrad", "family": "von Uffenbach", "initials": "Z. K. U."} {"name": "Zaharias, Babe D.", "given": "Babe", "middle": "D.", "family": "Zaharias", "initials": "B. D. Z."} {"name": "Zaid Ibn-Ḥārīṯa", "given": "Zaid", "family": "Ibn-Ḥārīṯa", "initials": "Z. I.", "capitalized_force": "Zaid ibn-Ḥārīṯa"} {"name": "Zainab al-Ġazālī", "given": "Zainab", "family": "al-Ġazālī", "initials": "Z. a."} {"name": "Zaiyāt, Laṭīfa az-", "given": "Laṭīfa", "family": "az- Zaiyāt", "initials": "L. Z."} {"name": "Zalka, Máté", "given": "Máté", "family": "Zalka", "initials": "M. Z."} {"name": "Zamara Imani Bukenya", "given": "Zamara", "middle": "Imani", "family": "Bukenya", "initials": "Z. I. B."} {"name": "Zarathustra", "given": "Zarathustra", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "Z."} {"name": "Zarea Yaeqob", "given": "Zarea", "family": "Yaeqob", "initials": "Z. Y."} {"name": "Zazilia Hamish Stober", "given": "Zazilia", "middle": "Hamish", "family": "Stober", "initials": "Z. H. S."} {"name": "Zazilia Hamiş Ştöber", "given": "Zazilia", "middle": "Hamiş", "family": "Ştöber", "initials": "Z. H. Ş."} {"name": "Zaziliya Hemish Shtober", "given": "Zaziliya", "middle": "Hemish", "family": "Shtober", "initials": "Z. H. S."} {"name": "Zaʿīm, Ḥusnī az-", "given": "Ḥusnī", "family": "az- Zaʿīm", "initials": "Ḥ. Z."} {"name": "Zein El Dine", "given": "Zein", "middle": "El", "family": "Dine", "initials": "Z. E. D."} {"name": "Zelda Hamish Støver", "given": "Zelda", "middle": "Hamish", "family": "Støver", "initials": "Z. H. S."} {"name": "Zelda Herman Stander", "given": "Zelda", "middle": "Herman", "family": "Stander", "initials": "Z. H. S."} {"name": "Zendaya", "given": "Zendaya", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "Z."} {"name": "Zendaya", "order": "FAMILY_FIRST", "family": "Zendaya", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "Z."} {"name": "Zendaya Marie Coleman", "given": "Zendaya", "middle": "Marie", "family": "Coleman", "initials": "Z. M. C."} {"name": "Zephyrmark Jane Smith", "given": "Zephyrmark", "middle": "Jane", "family": "Smith", "initials": "Z. J. S."} {"name": "Zeynep", "given": "Zeynep", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "Z."} {"name": "Zeynep", "order": "FAMILY_FIRST", "family": "Zeynep", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "Z."} {"name": "Zezilia Hemiş Stöber", "given": "Zezilia", "middle": "Hemiş", "family": "Stöber", "initials": "Z. H. S."} {"name": "Zheng, Yisao", "given": "Yisao", "family": "Zheng", "initials": "Y. Z."} {"name": "Ziemowit IV.", "given": "Ziemowit", "suffix": "IV.", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "Z."} {"name": "Zrinka", "given": "Zrinka", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "Z."} {"name": "Zrinka", "order": "FAMILY_FIRST", "family": "Zrinka", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "Z."} {"name": "Zuylen van Nijevelt, Julius van", "given": "Julius", "family": "van Zuylen van Nijevelt", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "J. Z. N."} {"name": "Zäzilia Gültan Stöber", "given": "Zäzilia", "middle": "Gültan", "family": "Stöber", "initials": "Z. G. S."} {"name": "Zäzilia Hamish Stöber", "given": "Zäzilia", "middle": "Hamish", "family": "Stöber", "initials": "Z. H. S."} {"name": "Zäzilia Hamish Stöber", "order": "FAMILY_FIRST", "given": "Hamish", "middle": "Stöber", "family": "Zäzilia", "initials": "H. S. Z."} {"name": "Zäzilia Stöber", "given": "Zäzilia", "family": "Stöber", "initials": "Z. S."} {"name": "abd Allah", "given": "abd", "family": "Allah", "initials": "a. A.", "capitalized_force": "Abd Allah"} {"name": "abd Allah Smith", "given": "abd Allah", "family": "Smith", "initials": "a. A. S.", "capitalized_force": "Abd Allah Smith"} {"name": "abd Allah Smith née Jones", "given": "abd Allah", "family": "Smith", "maiden": "Jones", "initials": "a. A. S.", "capitalized_force": "Abd Allah Smith (Jones)"} {"name": "abd Berg née Jones", "given": "abd", "family": "Berg", "maiden": "Jones", "initials": "a. B.", "capitalized_force": "Abd Berg (Jones)"} {"name": "abd née Jones", "given": "abd", "maiden": "Jones", "initials": "a.", "capitalized_force": "Abd (Jones)"} {"name": "abdul", "given": "abdul", "initials": "a.", "capitalized": "Abdul"} {"name": "abdul Jr Smith Berg", "given": "abdul", "middle": "Jr Smith", "family": "Berg", "initials": "a. J. S. B.", "capitalized_force": "Abdul Jr Smith Berg"} {"name": "abdul Ma Smith", "given": "abdul Ma", "family": "Smith", "initials": "a. M. S.", "capitalized_force": "Abdul Ma Smith"} {"name": "abdul Ph. D. Smith Berg", "given": "abdul", "middle": "Smith", "family": "Berg", "suffix": "Ph. D.", "initials": "a. S. B.", "capitalized_force": "Abdul Smith Berg Ph. D."} {"name": "abdul Salam, Ed", "given": "abdul", "family": "Salam", "suffix": "Ed", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (declared)"], "initials": "a. S.", "capitalized_force": "Abdul Salam ED"} {"name": "abdul Sir Smith Berg", "given": "abdul Sir", "middle": "Smith", "family": "Berg", "initials": "a. S. S. B.", "capitalized_force": "Abdul Sir Smith Berg"} {"name": "abdul Smith Berg Ma", "given": "abdul Smith", "middle": "Berg", "family": "Ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "a. S. B. M.", "capitalized_force": "Abdul Smith Berg Ma"} {"name": "abdul Smith Jr Ma", "given": "abdul", "family": "Smith", "suffix": "Jr Ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (declared)"], "initials": "a. S.", "capitalized_force": "Abdul Smith Jr MA"} {"name": "abdul Smith Jr V", "given": "abdul", "family": "Smith", "suffix": "Jr V", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: initial as family (shape)"], "initials": "a. S.", "capitalized_force": "Abdul Smith Jr V"} {"name": "abdul Smith Ma", "given": "abdul Smith", "family": "Ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "a. S. M.", "capitalized_force": "Abdul Smith Ma"} {"name": "abdul Smith V", "given": "abdul", "family": "Smith", "suffix": "V", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: initial as family (shape)"], "initials": "a. S.", "capitalized_force": "Abdul Smith V"} {"name": "abdul V Smith", "given": "abdul V", "family": "Smith", "initials": "a. V. S.", "capitalized_force": "Abdul V Smith"} {"name": "abdul salam", "given": "abdul", "family": "salam", "initials": "a. s.", "capitalized": "Abdul Salam"} {"name": "abdul salam ahmed salem", "given": "abdul salam", "middle": "ahmed", "family": "salem", "initials": "a. s. a. s.", "capitalized": "Abdul Salam Ahmed Salem"} {"name": "abdul salam ahmed salem jr", "given": "abdul salam", "middle": "ahmed", "family": "salem", "suffix": "jr", "initials": "a. s. a. s.", "capitalized": "Abdul Salam Ahmed Salem Jr"} {"name": "abdul salam jr", "given": "abdul", "family": "salam", "suffix": "jr", "initials": "a. s.", "capitalized": "Abdul Salam Jr"} {"name": "abdul salam salem", "given": "abdul salam", "family": "salem", "initials": "a. s. s.", "capitalized": "Abdul Salam Salem"} {"name": "abdulsalam ahmed salem", "given": "abdulsalam", "middle": "ahmed", "family": "salem", "initials": "a. a. s.", "capitalized": "Abdulsalam Ahmed Salem"} {"name": "abu bakr al baghdadi", "given": "abu bakr", "family": "al baghdadi", "initials": "a. b. b.", "capitalized": "Abu Bakr al Baghdadi"} {"name": "ahmed abu bakr", "given": "ahmed", "family": "abu bakr", "initials": "a. b.", "capitalized": "Ahmed abu Bakr"} {"name": "al-Walīd Ibn-ʿUbaid al-Buḥturī", "given": "al-Walīd", "middle": "Ibn-ʿUbaid", "family": "al-Buḥturī", "initials": "a. I. a.", "capitalized_force": "Al-Walīd ibn-ʿUbaid al-Buḥturī"} {"name": "and Jon Dough", "given": "and Jon", "family": "Dough", "initials": "J. D."} {"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": "Stromae Elizabeth Windsor Maria Teresa Bin 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": "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": "Silva Sr. José Pedro Eduardo Da Jr., MP"} {"name": "de", "given": "de", "initials": "d.", "capitalized": "De"} {"name": "de Anke van y", "family": "de Anke van y", "initials": "A."} {"name": "de Bhulbh An tOll. An Dr. Brigid Aoibhe Sophia", "order": "FAMILY_FIRST", "given": "An", "middle": "tOll. An Dr. Brigid Aoibhe Sophia", "family": "de Bhulbh", "initials": "A. t. A. D. B. A. S. B.", "capitalized_force": "An Toll. An Dr. Brigid Aoibhe Sophia de Bhulbh"} {"name": "de Bhulbh Brigid Aoibhe Sophia", "order": "FAMILY_FIRST", "given": "Brigid", "middle": "Aoibhe Sophia", "family": "de Bhulbh", "initials": "B. A. S. B."} {"name": "de Castro Xosé Carlos Francisco Xavier", "order": "FAMILY_FIRST", "given": "Xosé", "middle": "Carlos Francisco Xavier", "family": "de Castro", "initials": "X. C. F. X. C."} {"name": "de Jong dhr. Bertus Wevers Harry Robert jr., mp", "order": "FAMILY_FIRST", "given": "dhr.", "middle": "Bertus Wevers Harry Robert", "family": "de Jong", "suffix": "jr., mp", "initials": "d. B. W. H. R. J.", "capitalized_force": "Dhr. Bertus Wevers Harry Robert de Jong Jr., MP"} {"name": "de Lacon, Antoni Baìngiu Torchitòriu Comita", "given": "Antoni", "middle": "Baìngiu Torchitòriu Comita", "family": "de Lacon", "initials": "A. B. T. C. L."} {"name": "de Mesnil", "family": "de Mesnil", "initials": "M."} {"name": "de Mesnil Garcia", "family": "de Mesnil Garcia", "initials": "M. G."} {"name": "de Mesnil Jean", "family": "de Mesnil Jean", "initials": "M. J."} {"name": "de Mesnil Jean Paul", "family": "de Mesnil Jean Paul", "initials": "M. J. P."} {"name": "de Mesnil Jean, Dr.", "order": "FAMILY_FIRST", "title": "Dr.", "given": "Jean", "family": "de Mesnil", "initials": "J. M."} {"name": "de Mesnil Jr.", "family": "de Mesnil", "suffix": "Jr.", "initials": "M."} {"name": "de Planta, dr. Margaritta Marionna Maria jr. CF", "order": "FAMILY_FIRST", "title": "dr.", "given": "Margaritta", "middle": "Marionna Maria", "family": "de Planta", "suffix": "jr. CF", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as middle (position)"], "initials": "M. M. M. P.", "capitalized_force": "Dr. Margaritta Marionna Maria de Planta Jr. Cf"} {"name": "de Souza e Menezes Mr. Francisco Xavier Joao Anton Dk, MP", "order": "FAMILY_FIRST", "given": "Mr.", "middle": "Francisco Xavier Joao Anton Dk", "family": "de Souza e Menezes", "suffix": "MP", "initials": "M. F. X. J. A. D. S. M."} {"name": "de la Cruz Juan Carlos", "family": "de la Cruz Juan Carlos", "initials": "C. J. C."} {"name": "de la Cruz Juan Carlos", "order": "FAMILY_FIRST", "given": "Juan", "middle": "Carlos", "family": "de la Cruz", "initials": "J. C. C."} {"name": "de la Cruz Juan Carlos", "order": "FAMILY_FIRST_GIVEN_LAST", "given": "Carlos", "middle": "Juan", "family": "de la Cruz", "initials": "C. J. C."} {"name": "de la Cruz Juan Carlos, Dr.", "order": "FAMILY_FIRST_GIVEN_LAST", "title": "Dr.", "given": "Carlos", "middle": "Juan", "family": "de la Cruz", "initials": "C. J. C."} {"name": "de la Cruz née Vega", "order": "FAMILY_FIRST", "family": "de la Cruz", "maiden": "Vega", "initials": "C."} {"name": "de la Cruz née Vega", "order": "FAMILY_FIRST_GIVEN_LAST", "family": "de la Cruz", "maiden": "Vega", "initials": "C."} {"name": "de la Cruz, Ma", "given": "Ma", "family": "de la Cruz", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "M. C."} {"name": "de la Torre Vega", "family": "de la Torre Vega", "initials": "T. V."} {"name": "de la Vega", "family": "de la Vega", "initials": "V."} {"name": "de la Vega y Santos Juan", "family": "de la Vega y Santos Juan", "initials": "V. S. J."} {"name": "de la Vega, Dr. Juan", "title": "Dr.", "given": "Juan", "family": "de la Vega", "initials": "J. V."} {"name": "de la Vega, Dr. Juan III", "title": "Dr.", "given": "Juan", "family": "de la Vega", "suffix": "III", "initials": "J. V."} {"name": "de la Vega, Dr. Juan Q.", "title": "Dr.", "given": "Juan", "middle": "Q.", "family": "de la Vega", "initials": "J. Q. V."} {"name": "de la Vega, Dr. Juan Q. III", "title": "Dr.", "given": "Juan", "middle": "Q.", "family": "de la Vega", "suffix": "III", "initials": "J. Q. V."} {"name": "de la Vega, Dr. Juan Q. Xavier", "title": "Dr.", "given": "Juan", "middle": "Q. Xavier", "family": "de la Vega", "initials": "J. Q. X. V."} {"name": "de la Vega, Dr. Juan Q. Xavier III", "title": "Dr.", "given": "Juan", "middle": "Q. Xavier", "family": "de la Vega", "suffix": "III", "initials": "J. Q. X. V."} {"name": "de la Vega, Dr. Juan Q. Xavier, Jr.", "title": "Dr.", "given": "Juan", "middle": "Q. Xavier", "family": "de la Vega", "suffix": "Jr.", "initials": "J. Q. X. V."} {"name": "de la Vega, Dr. Juan Q., Jr.", "title": "Dr.", "given": "Juan", "middle": "Q.", "family": "de la Vega", "suffix": "Jr.", "initials": "J. Q. V."} {"name": "de la Vega, Dr. Juan, Jr.", "title": "Dr.", "given": "Juan", "family": "de la Vega", "suffix": "Jr.", "initials": "J. V."} {"name": "de la Vega, Juan", "given": "Juan", "family": "de la Vega", "initials": "J. V."} {"name": "de la Vega, Juan III", "given": "Juan", "family": "de la Vega", "suffix": "III", "initials": "J. V."} {"name": "de la Vega, Juan Q.", "given": "Juan", "middle": "Q.", "family": "de la Vega", "initials": "J. Q. V."} {"name": "de la Vega, Juan Q. III", "given": "Juan", "middle": "Q.", "family": "de la Vega", "suffix": "III", "initials": "J. Q. V."} {"name": "de la Vega, Juan Q. Xavier", "given": "Juan", "middle": "Q. Xavier", "family": "de la Vega", "initials": "J. Q. X. V."} {"name": "de la Vega, Juan Q. Xavier III", "given": "Juan", "middle": "Q. Xavier", "family": "de la Vega", "suffix": "III", "initials": "J. Q. X. V."} {"name": "de la Vega, Juan Q. Xavier, Jr.", "given": "Juan", "middle": "Q. Xavier", "family": "de la Vega", "suffix": "Jr.", "initials": "J. Q. X. V."} {"name": "de la Vega, Juan Q., Jr.", "given": "Juan", "middle": "Q.", "family": "de la Vega", "suffix": "Jr.", "initials": "J. Q. V."} {"name": "de la Vega, Juan, Jr.", "given": "Juan", "family": "de la Vega", "suffix": "Jr.", "initials": "J. V."} {"name": "de la Véña, Jüan", "given": "Jüan", "family": "de la Véña", "initials": "J. V."} {"name": "de la dos Vega, Dr. Juan Q. Xavier III", "title": "Dr.", "given": "Juan", "middle": "Q. Xavier", "family": "de la dos Vega", "suffix": "III", "initials": "J. Q. X. V."} {"name": "de la vega, dr. juan Q. xavier III", "title": "dr.", "given": "juan", "middle": "Q. xavier", "family": "de la vega", "suffix": "III", "initials": "j. Q. x. v.", "capitalized_force": "Dr. Juan Q. Xavier de la Vega III"} {"name": "de los Santos", "family": "de los Santos", "initials": "S."} {"name": "der, y van", "given": "y", "family": "van der", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "y. v. d.", "capitalized": "y Van Der"} {"name": "dhr. Bertus Wevers Harry Robert de Jong jr. mp", "title": "dhr.", "given": "Bertus", "middle": "Wevers Harry Robert", "family": "de Jong", "suffix": "jr. mp", "initials": "B. W. H. R. J.", "capitalized_force": "Dhr. Bertus Wevers Harry Robert de Jong Jr. MP"} {"name": "dipl.-ing john smith", "title": "dipl.-ing", "given": "john", "family": "smith", "initials": "j. s.", "capitalized": "Dipl.-Ing John Smith"} {"name": "doe, jane phd vd ma", "given": "jane", "family": "doe", "suffix": "phd vd ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as middle (declared)", "SUFFIX_OR_NAME: particle as family (vocabulary)"], "initials": "j. d.", "capitalized": "Jane Doe PhD VD MA"} {"name": "doe, jane v phd do", "given": "jane", "middle": "v", "family": "doe", "suffix": "phd do", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as middle (declared)"], "initials": "j. v. d.", "capitalized": "Jane V Doe PhD DO"} {"name": "doe, john ma", "given": "john", "family": "doe", "suffix": "ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as middle (declared)"], "initials": "j. d.", "capitalized": "John Doe MA"} {"name": "donovan mcnabb-smith", "given": "donovan", "family": "mcnabb-smith", "initials": "d. m.", "capitalized": "Donovan McNabb-Smith"} {"name": "dos Santos", "family": "dos Santos", "initials": "S."} {"name": "dott.ssa anna bianchi", "title": "dott.ssa", "given": "anna", "family": "bianchi", "initials": "a. b.", "capitalized": "Dott.ssa Anna Bianchi"} {"name": "dr Slađana Vera Milenković dipl. inž.", "title": "dr", "given": "Slađana", "middle": "Vera Milenković dipl.", "family": "inž.", "initials": "S. V. M. d. i.", "capitalized_force": "Dr Slađana Vera Milenković Dipl. Inž."} {"name": "dr Vincent James van Gogh dr", "title": "dr", "given": "Vincent", "middle": "James", "family": "van Gogh dr", "initials": "V. J. G. d.", "capitalized_force": "Dr Vincent James van Gogh Dr"} {"name": "dr Vincent van Gogh dr", "title": "dr", "given": "Vincent", "family": "van Gogh dr", "initials": "V. G. d.", "capitalized_force": "Dr Vincent van Gogh Dr"} {"name": "dr Vincent van der Gogh dr", "title": "dr", "given": "Vincent", "family": "van der Gogh dr", "initials": "V. G. d.", "capitalized_force": "Dr Vincent van der Gogh Dr"} {"name": "dr. Anastasia Eleni César Martín von Brühl, cand.odont.", "title": "dr.", "given": "Anastasia", "middle": "Eleni César Martín", "family": "von Brühl", "suffix": "cand.odont.", "initials": "A. E. C. M. B.", "capitalized_force": "Dr. Anastasia Eleni César Martín von Brühl CAND.ODONT."} {"name": "dr. Hejba-Marja Hana Lejnikowa jr. phd", "title": "dr.", "given": "Hejba-Marja", "middle": "Hana", "family": "Lejnikowa", "suffix": "jr. phd", "initials": "H. H. L.", "capitalized_force": "Dr. Hejba-Marja Hana Lejnikowa Jr. PhD"} {"name": "dr. Jěwa-Marja Hańža Wićazowa jr. phd", "title": "dr.", "given": "Jěwa-Marja", "middle": "Hańža", "family": "Wićazowa", "suffix": "jr. phd", "initials": "J. H. W.", "capitalized_force": "Dr. Jěwa-Marja Hańža Wićazowa Jr. PhD"} {"name": "dr. Margaritta Marionna Maria de Planta jr. CF", "title": "dr.", "given": "Margaritta", "middle": "Marionna Maria", "family": "de Planta", "suffix": "jr. CF", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "M. M. M. P.", "capitalized_force": "Dr. Margaritta Marionna Maria de Planta Jr. Cf"} {"name": "dr. ben alex johnson III", "title": "dr.", "given": "ben", "middle": "alex", "family": "johnson", "suffix": "III", "initials": "b. a. j.", "capitalized": "Dr. Ben Alex Johnson III"} {"name": "dr. john p. doe-Ray, CLU, CFP, LUTC", "title": "dr.", "given": "john", "middle": "p.", "family": "doe-Ray", "suffix": "CLU, CFP, LUTC", "ambiguities": ["COMMA_STRUCTURE"], "initials": "j. p. d.", "capitalized_force": "Dr. John P. Doe-Ray CLU, CFP, Lutc"} {"name": "dr. juan de la vega jr.", "title": "dr.", "given": "juan", "family": "de la vega", "suffix": "jr.", "initials": "j. v.", "capitalized": "Dr. Juan de la Vega Jr."} {"name": "dr. juan garcia III", "title": "dr.", "given": "juan", "family": "garcia", "suffix": "III", "initials": "j. g.", "capitalized": "Dr. Juan Garcia III"} {"name": "dr. née juan y lopez", "title": "dr.", "given": "née", "middle": "juan y", "family": "lopez", "initials": "n. j. y. l.", "capitalized": "Dr. Née Juan Y Lopez"} {"name": "dr. p. Gabrielė Aldona Petraitytė", "title": "dr.", "given": "p.", "middle": "Gabrielė Aldona", "family": "Petraitytė", "initials": "p. G. A. P.", "capitalized_force": "Dr. P. Gabrielė Aldona Petraitytė"} {"name": "dr. sc. Branko Hrvoje Marić mlađi, prof.", "title": "dr. sc. prof.", "given": "Branko", "middle": "Hrvoje", "family": "Marić", "suffix": "mlađi", "initials": "B. H. M.", "capitalized_force": "Dr. Sc. Prof. Branko Hrvoje Marić Mlađi"} {"name": "e and e", "given": "e and e", "ambiguities": ["CONJUNCTION_OR_INITIAL"], "initials": "e. e.", "capitalized": "E and E"} {"name": "e j smith", "given": "e", "middle": "j", "family": "smith", "ambiguities": ["CONJUNCTION_OR_INITIAL"], "initials": "e. j. s.", "capitalized": "E J Smith"} {"name": "émile zola", "given": "émile", "family": "zola", "initials": "é. z.", "capitalized": "Émile Zola"} {"name": "fan ƉAM Fostɛɛŋ Samʊwɛl", "order": "FAMILY_FIRST", "given": "ƉAM", "middle": "Fostɛɛŋ Samʊwɛl", "family": "fan", "initials": "Ɖ. F. S. f.", "capitalized_force": "Ɖam Fostɛɛŋ Samʊwɛl Fan"} {"name": "fon Brül Ada Kornelia Sezar Martin, hirurg-stomatolog lukman", "order": "FAMILY_FIRST", "given": "hirurg-stomatolog", "middle": "lukman", "family": "fon Brül Ada Kornelia Sezar Martin", "initials": "h. l. f. B. A. K. S. M.", "capitalized_force": "Hirurg-Stomatolog Lukman Fon Brül Ada Kornelia Sezar Martin"} {"name": "g. Adnan Mirza Hodžić zastupnik u parlamentu", "given": "g.", "middle": "Adnan Mirza Hodžić zastupnik u", "family": "parlamentu", "initials": "g. A. M. H. z. u. p.", "capitalized_force": "G. Adnan Mirza Hodžić Zastupnik U Parlamentu"} {"name": "g. Hodžić Adnan Mirza, zastupnik u parlamentu", "given": "zastupnik", "middle": "u parlamentu", "family": "g. Hodžić Adnan Mirza", "initials": "z. u. p. g. H. A. M.", "capitalized_force": "Zastupnik U Parlamentu G. Hodžić Adnan Mirza"} {"name": "g. Hodžić Adnan Mirza, zastupnik u parlamentu", "order": "FAMILY_FIRST", "given": "zastupnik", "middle": "u parlamentu", "family": "g. Hodžić Adnan Mirza", "initials": "z. u. p. g. H. A. M.", "capitalized_force": "Zastupnik U Parlamentu G. Hodžić Adnan Mirza"} {"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ž. Novak Janez Peter G."} {"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": "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)"} {"name": "j.-e.-p. dupont", "given": "j.-e.-p.", "family": "dupont", "initials": "j. d.", "capitalized": "J.-E.-P. Dupont"} {"name": "j.r.r. tolkien", "given": "j.r.r.", "family": "tolkien", "initials": "j. t.", "capitalized": "J.R.R. Tolkien"} {"name": "jack ma", "given": "jack", "family": "ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "j. m.", "capitalized": "Jack Ma"} {"name": "jacobus henricus van 't hoff", "given": "jacobus", "middle": "henricus", "family": "van 't hoff", "ambiguities": ["UNBALANCED_DELIMITER"], "initials": "j. h. h.", "capitalized": "Jacobus Henricus van 't Hoff"} {"name": "jane doe nee smith ma", "given": "jane", "family": "doe", "suffix": "ma", "maiden": "smith", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as maiden (declared)"], "initials": "j. d.", "capitalized": "Jane Doe (Smith) MA"} {"name": "jean-luc picard", "given": "jean-luc", "family": "picard", "initials": "j. p.", "capitalized": "Jean-Luc Picard"} {"name": "jenap Atajan Saparowiç Gurdow magistr, ylymlaryň kandidaty", "given": "ylymlaryň", "middle": "kandidaty", "family": "jenap Atajan Saparowiç Gurdow magistr", "initials": "y. k. j. A. S. G. m.", "capitalized_force": "Ylymlaryň Kandidaty Jenap Atajan Saparowiç Gurdow Magistr"} {"name": "joao da silva do amaral de souza", "given": "joao", "middle": "da silva do amaral", "family": "de souza", "initials": "j. s. a. s.", "capitalized": "Joao da Silva do Amaral de Souza"} {"name": "john 3rd smith", "given": "john", "middle": "3rd", "family": "smith", "initials": "j. 3. s.", "capitalized": "John 3rd Smith"} {"name": "john doe", "given": "john", "family": "doe", "initials": "j. d.", "capitalized": "John Doe"} {"name": "john doe q.c.", "given": "john", "family": "doe", "suffix": "q.c.", "initials": "j. d.", "capitalized": "John Doe Q.C."} {"name": "john e jones", "given": "john", "middle": "e", "family": "jones", "ambiguities": ["CONJUNCTION_OR_INITIAL"], "alternatives": ["CONJUNCTION_OR_INITIAL: conjunction as middle (vocabulary)"], "initials": "j. e. j.", "capitalized": "John E Jones"} {"name": "john e jones, III", "given": "john", "middle": "e", "family": "jones", "suffix": "III", "initials": "j. e. j.", "capitalized": "John e Jones III"} {"name": "john e smith", "given": "john", "middle": "e", "family": "smith", "ambiguities": ["CONJUNCTION_OR_INITIAL"], "alternatives": ["CONJUNCTION_OR_INITIAL: conjunction as middle (vocabulary)"], "initials": "j. e. s.", "capitalized": "John E Smith"} {"name": "john e. smith", "given": "john", "middle": "e.", "family": "smith", "initials": "j. e. s.", "capitalized": "John E. Smith"} {"name": "john i", "given": "john", "family": "i", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: generation as suffix (vocabulary)"], "initials": "j. i.", "capitalized": "John I"} {"name": "john i dr.", "title": "dr.", "given": "john", "family": "i", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: generation as suffix (vocabulary)"], "initials": "j. i.", "capitalized": "Dr. John I"} {"name": "john i phd", "given": "john", "family": "i", "suffix": "phd", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: generation as suffix (vocabulary)"], "initials": "j. i.", "capitalized": "John I PhD"} {"name": "john j v", "given": "john", "middle": "j", "family": "v", "initials": "j. j. v.", "capitalized": "John J V"} {"name": "john j v, PhD", "given": "john", "family": "j", "suffix": "v, PhD", "initials": "j. j.", "capitalized": "John J V, PhD"} {"name": "john smith", "given": "john", "family": "smith", "initials": "j. s.", "capitalized": "John Smith"} {"name": "john smith (MA)", "given": "john", "family": "smith", "nickname": "MA", "ambiguities": ["SUFFIX_OR_NICKNAME"], "alternatives": ["SUFFIX_OR_NICKNAME: credential as suffix (vocabulary)"], "initials": "j. s.", "capitalized_force": "John \"Ma\" Smith"} {"name": "john smith EdD", "given": "john", "family": "smith", "suffix": "EdD", "initials": "j. s.", "capitalized": "John Smith EdD", "capitalized_force": "John Smith EDD"} {"name": "john smith MA", "given": "john", "family": "smith", "suffix": "MA", "initials": "j. s.", "capitalized": "John Smith MA"} {"name": "john smith MEng", "given": "john", "middle": "smith", "family": "MEng", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "j. s. M.", "capitalized_force": "John Smith Meng"} {"name": "john smith bsc", "given": "john", "family": "smith", "suffix": "bsc", "initials": "j. s.", "capitalized": "John Smith BSc"} {"name": "john smith ed", "given": "john", "family": "smith", "suffix": "ed", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (declared)"], "initials": "j. s.", "capitalized": "John Smith ED"} {"name": "john smith i", "given": "john", "family": "smith", "suffix": "i", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: initial as family (shape)"], "initials": "j. s.", "capitalized": "John Smith I"} {"name": "john smith iii", "given": "john", "family": "smith", "suffix": "iii", "initials": "j. s.", "capitalized": "John Smith III"} {"name": "john smith iii.", "given": "john", "family": "smith", "suffix": "iii.", "initials": "j. s.", "capitalized": "John Smith III."} {"name": "john smith ix", "given": "john", "family": "smith", "suffix": "ix", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "j. s.", "capitalized": "John Smith IX"} {"name": "john smith jr", "given": "john", "family": "smith", "suffix": "jr", "initials": "j. s.", "capitalized": "John Smith Jr"} {"name": "john smith m.d.", "given": "john", "family": "smith", "suffix": "m.d.", "initials": "j. s.", "capitalized": "John Smith M.D."} {"name": "john smith mba", "given": "john", "family": "smith", "suffix": "mba", "initials": "j. s.", "capitalized": "John Smith MBA"} {"name": "john smith mcse", "given": "john", "family": "smith", "suffix": "mcse", "initials": "j. s.", "capitalized": "John Smith MCSE"} {"name": "john smith md", "given": "john", "family": "smith", "suffix": "md", "initials": "j. s.", "capitalized": "John Smith MD"} {"name": "john smith meng", "given": "john", "family": "smith", "suffix": "meng", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (declared)"], "initials": "j. s.", "capitalized": "John Smith MENG"} {"name": "john smith nee x x", "given": "john", "family": "smith", "suffix": "x", "maiden": "x", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: initial as maiden (shape)"], "initials": "j. s.", "capitalized": "John Smith (X) X"} {"name": "john smith p.h.d.", "given": "john", "family": "smith", "suffix": "p.h.d.", "initials": "j. s.", "capitalized": "John Smith P.H.D."} {"name": "john smith pa-c", "given": "john", "family": "smith", "suffix": "pa-c", "initials": "j. s.", "capitalized": "John Smith PA-C"} {"name": "john smith ph. d.", "given": "john", "family": "smith", "suffix": "ph. d.", "initials": "j. s.", "capitalized": "John Smith Ph. D."} {"name": "john smith ph.d.", "given": "john", "family": "smith", "suffix": "ph.d.", "initials": "j. s.", "capitalized": "John Smith Ph.D."} {"name": "john smith pharm d", "given": "john", "family": "smith", "suffix": "pharm d", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "j. s.", "capitalized": "John Smith Pharm D"} {"name": "john smith phd", "given": "john", "family": "smith", "suffix": "phd", "initials": "j. s.", "capitalized": "John Smith PhD"} {"name": "john smith psyd", "given": "john", "family": "smith", "suffix": "psyd", "initials": "j. s.", "capitalized": "John Smith PsyD"} {"name": "john smith v", "given": "john", "family": "smith", "suffix": "v", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: initial as family (shape)"], "initials": "j. s.", "capitalized": "John Smith V"} {"name": "john smith v, PhD", "given": "john", "family": "smith", "suffix": "v, PhD", "initials": "j. s.", "capitalized": "John Smith V, PhD"} {"name": "john smith vi", "given": "john", "family": "smith", "suffix": "vi", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "j. s.", "capitalized": "John Smith VI"} {"name": "john smith x.y.z.", "given": "john", "family": "smith", "suffix": "x.y.z.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "j. s.", "capitalized": "John Smith X.Y.Z."} {"name": "john smith xi", "given": "john", "middle": "smith", "family": "xi", "initials": "j. s. x.", "capitalized": "John Smith Xi"} {"name": "john smith, PHARM D", "given": "john", "family": "smith", "suffix": "PHARM D", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "j. s.", "capitalized": "John Smith Pharm D"} {"name": "john smith, ma", "given": "john", "family": "smith", "suffix": "ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (declared)"], "initials": "j. s.", "capitalized": "John Smith MA"} {"name": "john smith, md ma", "given": "john", "family": "smith", "suffix": "md ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "j. s.", "capitalized": "John Smith MD MA"} {"name": "john smith, ms-pt", "given": "john", "family": "smith", "suffix": "ms-pt", "initials": "j. s.", "capitalized": "John Smith MS-PT"} {"name": "john smith, otr/l", "given": "john", "family": "smith", "suffix": "otr/l", "initials": "j. s.", "capitalized": "John Smith OTR/L"} {"name": "john smith, phd meng", "given": "john", "family": "smith", "suffix": "phd meng", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "j. s.", "capitalized": "John Smith PhD MENG"} {"name": "john smith, v", "given": "john", "family": "smith", "suffix": "v", "initials": "j. s.", "capitalized": "John Smith V"} {"name": "john smith, vi", "given": "vi", "family": "john smith", "initials": "v. j. s.", "capitalized": "Vi John Smith"} {"name": "john smith-jones", "given": "john", "family": "smith-jones", "initials": "j. s.", "capitalized": "John Smith-Jones"} {"name": "john v", "given": "john", "family": "v", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: generation as suffix (vocabulary)"], "initials": "j. v.", "capitalized": "John V"} {"name": "john van der berg ma", "given": "john", "family": "van der berg", "suffix": "ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (declared)"], "initials": "j. b.", "capitalized": "John van der Berg MA"} {"name": "john van e phd", "given": "john", "family": "van e", "suffix": "phd", "initials": "j. e.", "capitalized": "John van E PhD"} {"name": "johnny y", "given": "johnny", "family": "y", "initials": "j. y.", "capitalized": "Johnny y"} {"name": "jones, john e", "given": "john", "middle": "e", "family": "jones", "initials": "j. e. j.", "capitalized": "John E Jones"} {"name": "jose e lopez", "given": "jose", "middle": "e", "family": "lopez", "ambiguities": ["CONJUNCTION_OR_INITIAL"], "alternatives": ["CONJUNCTION_OR_INITIAL: conjunction as middle (vocabulary)"], "initials": "j. e. l.", "capitalized": "Jose E Lopez"} {"name": "jose e maria santos", "given": "jose", "middle": "e maria", "family": "santos", "ambiguities": ["CONJUNCTION_OR_INITIAL"], "alternatives": ["CONJUNCTION_OR_INITIAL: conjunction as given (vocabulary)"], "initials": "j. e. m. s.", "capitalized": "Jose E Maria Santos"} {"name": "jose e phd", "given": "jose", "family": "e", "suffix": "phd", "initials": "j. e.", "capitalized": "Jose E PhD"} {"name": "jose j y lopez", "given": "jose", "middle": "j y", "family": "lopez", "initials": "j. j. l.", "capitalized": "Jose J y Lopez"} {"name": "jose ortega-y-gasset", "given": "jose", "family": "ortega-y-gasset", "initials": "j. o.", "capitalized": "Jose Ortega-y-Gasset"} {"name": "jose y lopez", "given": "jose", "middle": "y", "family": "lopez", "initials": "j. y. l.", "capitalized": "Jose y Lopez"} {"name": "josep carod i rovira", "given": "josep", "middle": "carod i", "family": "rovira", "ambiguities": ["CONJUNCTION_OR_INITIAL"], "alternatives": ["CONJUNCTION_OR_INITIAL: conjunction as family (vocabulary)"], "initials": "j. c. i. r.", "capitalized": "Josep Carod I Rovira"} {"name": "josé garcía", "given": "josé", "family": "garcía", "initials": "j. g.", "capitalized": "José García"} {"name": "juan de la vega", "given": "juan", "family": "de la vega", "initials": "j. v.", "capitalized": "Juan de la Vega"} {"name": "juan e-f smith", "given": "juan", "middle": "e-f", "family": "smith", "initials": "j. e. s.", "capitalized": "Juan E-F Smith"} {"name": "juan garcia III", "given": "juan", "family": "garcia", "suffix": "III", "initials": "j. g.", "capitalized": "Juan Garcia III"} {"name": "juan garcia Iii", "given": "juan", "family": "garcia", "suffix": "Iii", "initials": "j. g.", "capitalized": "Juan Garcia Iii", "capitalized_force": "Juan Garcia III"} {"name": "juan garcia PhD", "given": "juan", "family": "garcia", "suffix": "PhD", "initials": "j. g.", "capitalized": "Juan Garcia PhD"} {"name": "juan garcia y lopez", "given": "juan", "family": "garcia y lopez", "initials": "j. g. l.", "capitalized": "Juan Garcia y Lopez"} {"name": "juan mcdonald", "given": "juan", "family": "mcdonald", "initials": "j. m.", "capitalized": "Juan McDonald"} {"name": "juan q. xavier velasquez y garcia iii", "given": "juan", "middle": "q. xavier", "family": "velasquez y garcia", "suffix": "iii", "initials": "j. q. x. v. g.", "capitalized": "Juan Q. Xavier Velasquez y Garcia III"} {"name": "juan y garcia", "given": "juan", "middle": "y", "family": "garcia", "initials": "j. y. g.", "capitalized": "Juan y Garcia"} {"name": "juan y-garcia", "given": "juan", "family": "y-garcia", "initials": "j. y.", "capitalized": "Juan Y-Garcia"} {"name": "juan y-garcia-lopez", "given": "juan", "family": "y-garcia-lopez", "initials": "j. y.", "capitalized": "Juan Y-Garcia-Lopez"} {"name": "kim, ae-ra", "family": "kim", "suffix": "ae-ra", "initials": "k.", "capitalized": "Kim AE-RA"} {"name": "larry james edward johnson v", "given": "larry", "middle": "james edward", "family": "johnson", "suffix": "v", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: initial as family (shape)"], "initials": "l. j. e. j.", "capitalized": "Larry James Edward Johnson V"} {"name": "lt. gen. john a. kenneth doe iv", "title": "lt. gen.", "given": "john", "middle": "a. kenneth", "family": "doe", "suffix": "iv", "initials": "j. a. k. d.", "capitalized": "Lt. Gen. John A. Kenneth Doe IV"} {"name": "lt.gov john doe", "title": "lt.gov", "given": "john", "family": "doe", "initials": "j. d.", "capitalized": "Lt.Gov John Doe"} {"name": "m.d ph.d. Inger Marie Adele Synnøve von Wolf jr.", "given": "m.d", "middle": "ph.d. Inger Marie Adele Synnøve", "family": "von Wolf", "suffix": "jr.", "initials": "m. p. I. M. A. S. W.", "capitalized_force": "M.D Ph.D. Inger Marie Adele Synnøve von Wolf Jr."} {"name": "m.d ph.d. von Wolf Inger Marie Adele Synnøve", "order": "FAMILY_FIRST", "given": "ph.d.", "middle": "von Wolf Inger Marie Adele Synnøve", "family": "m.d", "initials": "p. W. I. M. A. S. m.", "capitalized_force": "Ph.D. von Wolf Inger Marie Adele Synnøve M.D"} {"name": "m.ʿali smith", "given": "m.ʿali", "family": "smith", "initials": "m. s.", "capitalized": "M.ʿAli Smith"} {"name": "mack johnson", "given": "mack", "family": "johnson", "initials": "m. j.", "capitalized": "Mack Johnson"} {"name": "maria da-silva", "given": "maria", "family": "da-silva", "initials": "m. d.", "capitalized": "Maria da-Silva"} {"name": "maria silva-e-sousa", "given": "maria", "family": "silva-e-sousa", "initials": "m. s.", "capitalized": "Maria Silva-e-Sousa"} {"name": "matthëus schmidt", "given": "matthëus", "family": "schmidt", "initials": "m. s.", "capitalized": "Matthëus Schmidt"} {"name": "med. dr. fil. dr. jur. dr. Ada Cornelia César Martín von Brühl González Domingo", "title": "med. dr. fil. dr. jur. dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl González Domingo", "initials": "A. C. C. M. B. G. D.", "capitalized_force": "Med. Dr. Fil. Dr. Jur. Dr. Ada Cornelia César Martín von Brühl González Domingo"} {"name": "med. m. dr. prof. dr. Ada Kornelija Eva Sofija von Vilkas Jankauskas-Baranauskas", "title": "med.", "given": "m.", "middle": "dr. prof. dr. Ada Kornelija Eva Sofija", "family": "von Vilkas Jankauskas-Baranauskas", "initials": "m. d. p. d. A. K. E. S. V. J.", "capitalized_force": "Med. M. Dr. Prof. Dr. Ada Kornelija Eva Sofija von Vilkas Jankauskas-Baranauskas"} {"name": "meng li", "given": "meng", "family": "li", "initials": "m. l.", "capitalized": "Meng Li"} {"name": "mevrouw Ada Cornelia César Martín von Brühl jr. PhD", "title": "mevrouw", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl", "suffix": "jr. PhD", "initials": "A. C. C. M. B.", "capitalized_force": "Mevrouw Ada Cornelia César Martín von Brühl Jr. PhD"} {"name": "mnr. Bertus Wilbers Henri Retief Willemse jnr., LP", "title": "mnr.", "given": "Bertus", "middle": "Wilbers Henri Retief", "family": "Willemse", "suffix": "jnr., LP", "initials": "B. W. H. R. W.", "capitalized_force": "Mnr. Bertus Wilbers Henri Retief Willemse Jnr., LP"} {"name": "mohamad ali smith", "given": "mohamad", "middle": "ali", "family": "smith", "initials": "m. a. s.", "capitalized": "Mohamad Ali Smith"} {"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": "Hamisi Salima Farida Mwana 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": "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": "Sr. de Lacon Gunale Antoni Baìngiu Torchitòriu Comita Onorèvole"} {"name": "p. Petraitytė Gabrielė Aldona", "order": "FAMILY_FIRST", "given": "Petraitytė", "middle": "Gabrielė Aldona", "family": "p.", "initials": "P. G. A. p.", "capitalized_force": "Petraitytė Gabrielė Aldona P."} {"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"} {"name": "pani Macáková Alexandra Zuzana, PhD", "order": "FAMILY_FIRST", "given": "Macáková", "middle": "Alexandra Zuzana", "family": "pani", "suffix": "PhD", "initials": "M. A. Z. p.", "capitalized_force": "MacÁková Alexandra Zuzana Pani PhD"} {"name": "paní Alexandra Zuzana Machová Ondřejová, Ph.D.", "title": "paní", "given": "Alexandra", "middle": "Zuzana Machová", "family": "Ondřejová", "suffix": "Ph.D.", "initials": "A. Z. M. O.", "capitalized_force": "Paní Alexandra Zuzana MacHová Ondřejová Ph.D."} {"name": "paní Machová Ondřejová Alexandra Zuzana, Ph.D.", "order": "FAMILY_FIRST", "title": "paní", "given": "Ondřejová", "middle": "Alexandra Zuzana", "family": "Machová", "suffix": "Ph.D.", "initials": "O. A. Z. M.", "capitalized_force": "Paní Ondřejová Alexandra Zuzana MacHová Ph.D."} {"name": "part1 of The part2 of the part3 and part4", "given": "part1 of The part2 of the part3 and part4", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "p. p. p. p.", "capitalized_force": "Part1 of the Part2 of the Part3 and Part4"} {"name": "part1 of and The part2 of the part3 And part4", "given": "part1 of and The part2 of the part3 And part4", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "p. p. p. p.", "capitalized_force": "Part1 of and the Part2 of the Part3 and Part4"} {"name": "pennie von bergen wessels", "given": "pennie", "family": "von bergen wessels", "initials": "p. b. w.", "capitalized": "Pennie von Bergen Wessels"} {"name": "pennie von bergen wessels III", "given": "pennie", "family": "von bergen wessels", "suffix": "III", "initials": "p. b. w.", "capitalized": "Pennie von Bergen Wessels III"} {"name": "pennie von bergen wessels M.D.", "given": "pennie", "family": "von bergen wessels", "suffix": "M.D.", "initials": "p. b. w.", "capitalized": "Pennie von Bergen Wessels M.D."} {"name": "pennie von bergen wessels MD, III", "given": "pennie", "family": "von bergen wessels", "suffix": "MD, III", "initials": "p. b. w.", "capitalized": "Pennie von Bergen Wessels MD, III"} {"name": "pennie von bergen wessels, III", "given": "pennie", "family": "von bergen wessels", "suffix": "III", "initials": "p. b. w.", "capitalized": "Pennie von Bergen Wessels III"} {"name": "prof. dr Ada Cornelia von Brühl", "title": "prof. dr", "given": "Ada", "middle": "Cornelia", "family": "von Brühl", "initials": "A. C. B.", "capitalized_force": "Prof. Dr Ada Cornelia von Brühl"} {"name": "prof. dr Aleksandra Eva Sara van den Volf Petrović Jugović dipl. inž.", "title": "prof. dr", "given": "Aleksandra", "middle": "Eva Sara", "family": "van den Volf Petrović Jugović dipl. inž.", "initials": "A. E. S. V. P. J. d. i.", "capitalized_force": "Prof. Dr Aleksandra Eva Sara van den Volf Petrović Jugović Dipl. Inž."} {"name": "prof. dr von Brühl Ada Cornelia César Martín", "order": "FAMILY_FIRST", "title": "prof. dr", "given": "Brühl", "middle": "Ada Cornelia César Martín", "family": "von", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "B. A. C. C. M. v.", "capitalized_force": "Prof. Dr Brühl Ada Cornelia César Martín Von"} {"name": "prof. dr. Ada Cornelia César Martín von Brühl Jr, univ. bacc", "title": "prof. dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl", "suffix": "Jr, univ. bacc", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (position)"], "initials": "A. C. C. M. B.", "capitalized_force": "Prof. Dr. Ada Cornelia César Martín von Brühl Jr, Univ. Bacc"} {"name": "prof. dr. Ada Cornelia César Martín von Brühl ml., dr. med., dr. dent. med.", "title": "prof. dr. dr. med. dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl ml.", "suffix": "dent. med.", "ambiguities": ["COMMA_STRUCTURE"], "initials": "A. C. C. M. B. m.", "capitalized_force": "Prof. Dr. Dr. Med. Dr. Ada Cornelia César Martín von Brühl Ml. Dent. Med."} {"name": "prof. dr. Anna Cornelia Eva Sophia van den Wolf jr. m.d. ph.d.", "title": "prof. dr.", "given": "Anna", "middle": "Cornelia Eva Sophia", "family": "van den Wolf", "suffix": "jr. m.d. ph.d.", "initials": "A. C. E. S. W.", "capitalized_force": "Prof. Dr. Anna Cornelia Eva Sophia van den Wolf Jr. M.D. Ph.D."} {"name": "prof. dr. Gentiana van den Popa, dr. shk. mjek.", "title": "prof. dr. dr. shk. mjek.", "given": "Gentiana", "family": "van den Popa", "initials": "G. P.", "capitalized_force": "Prof. Dr. Dr. Shk. Mjek. Gentiana van den Popa"} {"name": "prof. dr. Izeta Amra von Selimović Hodžić, dipl. stom.", "title": "prof. dr. dipl. stom.", "given": "Izeta", "middle": "Amra", "family": "von Selimović Hodžić", "initials": "I. A. S. H.", "capitalized_force": "Prof. Dr. Dipl. Stom. Izeta Amra von Selimović Hodžić"} {"name": "prof. dr. Popa, Gentiana van den", "title": "prof. dr.", "given": "Gentiana", "family": "van den Popa", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "G. P.", "capitalized_force": "Prof. Dr. Gentiana van den Popa"} {"name": "prof. dr. van den Popa, Gentiana M., dr. shk. mjek.", "order": "FAMILY_FIRST", "title": "prof. dr. dr.", "given": "Gentiana", "middle": "M.", "family": "van den Popa", "suffix": "shk. mjek.", "ambiguities": ["COMMA_STRUCTURE"], "initials": "G. M. P.", "capitalized_force": "Prof. Dr. Dr. Gentiana M. van den Popa Shk. Mjek."} {"name": "prof. dr. von Brühl Ada Cornelia César Martín, dr. med., dr. dent. med.", "order": "FAMILY_FIRST", "title": "prof. dr. dr. med. dr.", "given": "Brühl", "middle": "Ada Cornelia César Martín", "family": "von", "suffix": "dent. med.", "ambiguities": ["COMMA_STRUCTURE", "PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "B. A. C. C. M. v.", "capitalized_force": "Prof. Dr. Dr. Med. Dr. Brühl Ada Cornelia César Martín Von Dent. Med."} {"name": "prof. dr. von Selimović Hodžić, Izeta Amra, dipl. stom.", "title": "prof. dr.", "given": "Izeta", "middle": "Amra", "family": "von Selimović Hodžić", "suffix": "dipl. stom.", "ambiguities": ["COMMA_STRUCTURE"], "initials": "I. A. S. H.", "capitalized_force": "Prof. Dr. Izeta Amra von Selimović Hodžić Dipl. Stom."} {"name": "prof. dr. von Selimović Hodžić, Izeta Amra, dipl. stom.", "order": "FAMILY_FIRST", "title": "prof. dr.", "given": "Izeta", "middle": "Amra", "family": "von Selimović Hodžić", "suffix": "dipl. stom.", "ambiguities": ["COMMA_STRUCTURE"], "initials": "I. A. S. H.", "capitalized_force": "Prof. Dr. Izeta Amra von Selimović Hodžić Dipl. Stom."} {"name": "prof. dr. von Vilkas Jankauskas-Baranauskas, Ada Kornelija Eva Sofija", "order": "FAMILY_FIRST", "title": "prof. dr.", "given": "Ada", "middle": "Kornelija Eva Sofija", "family": "von Vilkas Jankauskas-Baranauskas", "initials": "A. K. E. S. V. J.", "capitalized_force": "Prof. Dr. Ada Kornelija Eva Sofija von Vilkas Jankauskas-Baranauskas"} {"name": "professori Ana Christina Lëja von Wright González Domingo, MD DDS", "given": "professori", "middle": "Ana Christina Lëja", "family": "von Wright González Domingo", "suffix": "MD DDS", "initials": "p. A. C. L. W. G. D.", "capitalized_force": "Professori Ana Christina Lëja von Wright González Domingo MD DDS"} {"name": "qc mp", "given": "qc", "suffix": "mp", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "q.", "capitalized": "Qc MP"} {"name": "salem, abdul", "given": "abdul", "family": "salem", "initials": "a. s.", "capitalized": "Abdul Salem"} {"name": "salem, abdul salam", "given": "abdul salam", "family": "salem", "initials": "a. s. s.", "capitalized": "Abdul Salam Salem"} {"name": "salem, abdul salam ahmed", "given": "abdul salam", "middle": "ahmed", "family": "salem", "initials": "a. s. a. s.", "capitalized": "Abdul Salam Ahmed Salem"} {"name": "scott e. werner", "given": "scott", "middle": "e.", "family": "werner", "initials": "s. e. w.", "capitalized": "Scott E. Werner"} {"name": "scott l. werner", "given": "scott", "middle": "l.", "family": "werner", "initials": "s. l. w.", "capitalized": "Scott L. Werner"} {"name": "señora María García", "title": "señora", "given": "María", "family": "García", "initials": "M. G.", "capitalized_force": "Señora María García"} {"name": "smith, de ma", "family": "smith de ma", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "s. m.", "capitalized": "Smith de Ma"} {"name": "smith, j y lopez", "given": "j", "middle": "y lopez", "family": "smith", "initials": "j. l. s.", "capitalized": "J y Lopez Smith"} {"name": "smith, john e phd", "given": "john", "middle": "e", "family": "smith", "suffix": "phd", "initials": "j. e. s.", "capitalized": "John E Smith PhD"} {"name": "st.john smith", "title": "st.john", "family": "smith", "initials": "s.", "capitalized": "St.John Smith"} {"name": "st.repr. Gregers Werle Marve Almar von Fleksnes jr.", "title": "st.repr.", "given": "Gregers", "middle": "Werle Marve Almar", "family": "von Fleksnes", "suffix": "jr.", "initials": "G. W. M. A. F.", "capitalized_force": "St.Repr. Gregers Werle Marve Almar von Fleksnes Jr."} {"name": "st.repr. von Fleksnes Gregers Werle Marve Almar", "order": "FAMILY_FIRST", "title": "st.repr.", "given": "Fleksnes", "middle": "Gregers Werle Marve Almar", "family": "von", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "F. G. W. M. A. v.", "capitalized_force": "St.Repr. Fleksnes Gregers Werle Marve Almar Von"} {"name": "test", "given": "test", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "t.", "capitalized": "Test"} {"name": "the and Jon Dough", "given": "the and Jon", "family": "Dough", "initials": "J. D."} {"name": "tran lac", "given": "tran", "family": "lac", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "t. l.", "capitalized": "Tran Lac"} {"name": "u-Adier u-Irene", "order": "FAMILY_FIRST", "given": "u-Irene", "family": "u-Adier", "initials": "u. u.", "capitalized_force": "U-Irene U-Adier"} {"name": "u-Adier, u-Irene", "given": "u-Irene", "family": "u-Adier", "initials": "u. u.", "capitalized_force": "U-Irene U-Adier"} {"name": "u-Irene u-Adier", "given": "u-Irene", "family": "u-Adier", "initials": "u. u.", "capitalized_force": "U-Irene U-Adier"} {"name": "u-John u-Hamish u-Watson", "given": "u-John", "middle": "u-Hamish", "family": "u-Watson", "initials": "u. u. u.", "capitalized_force": "U-John U-Hamish U-Watson"} {"name": "u-Prof. Dr. u-Ada Cornelia u-Eva Sophia u-van den u-Wold u-Becker Schmidt M.D. Ph.D.", "given": "u-Prof.", "middle": "Dr. u-Ada Cornelia u-Eva Sophia u-van", "family": "den u-Wold u-Becker Schmidt", "suffix": "M.D. Ph.D.", "initials": "u. D. u. C. u. S. u. u. u. S.", "capitalized_force": "U-Prof. Dr. U-Ada Cornelia U-Eva Sophia U-van den U-Wold U-Becker Schmidt M.D. Ph.D."} {"name": "u-Watson u-John u-Hamish", "order": "FAMILY_FIRST", "given": "u-John", "middle": "u-Hamish", "family": "u-Watson", "initials": "u. u. u.", "capitalized_force": "U-John U-Hamish U-Watson"} {"name": "u-Watson, u-John u-Hamish", "given": "u-John", "middle": "u-Hamish", "family": "u-Watson", "initials": "u. u. u.", "capitalized_force": "U-John U-Hamish U-Watson"} {"name": "u-Wold, u-Ada Cornelia u-Eva Sophia u-van den", "given": "u-Ada", "middle": "Cornelia u-Eva Sophia u-van", "family": "den u-Wold", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "u. C. u. S. u. u.", "capitalized_force": "U-Ada Cornelia U-Eva Sophia U-van den U-Wold"} {"name": "u-van den u-Wold u-Prof. Dr. u-Ada Cornelia u-Eva Sophia isizukulwane, M.D. Ph.D.", "order": "FAMILY_FIRST", "given": "den u-Wold u-Prof. Dr. u-Ada Cornelia u-Eva Sophia isizukulwane", "family": "u-van", "suffix": "M.D. Ph.D.", "initials": "d. u. u. D. u. C. u. S. i. u.", "capitalized_force": "Den U-Wold U-Prof. Dr. U-Ada Cornelia U-Eva Sophia Isizukulwane U-van M.D. Ph.D."} {"name": "vai la", "given": "vai", "family": "la", "initials": "v. l.", "capitalized": "Vai La"} {"name": "van Berg Jan de", "given": "van", "middle": "Berg Jan", "family": "de", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "v. B. J. d.", "capitalized_force": "Van Berg Jan De"} {"name": "van Doe, Jane Mrs", "title": "Mrs", "given": "Jane", "family": "van Doe", "initials": "J. D."} {"name": "van Gogh", "given": "van", "family": "Gogh", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "v. G.", "capitalized_force": "Van Gogh"} {"name": "van Gogh Ana María", "given": "van", "middle": "Gogh Ana", "family": "María", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "v. G. A. M.", "capitalized_force": "Van Gogh Ana María"} {"name": "van Gogh, Sir", "title": "Sir", "family": "van Gogh", "initials": "G."} {"name": "van den Berg Cornelia Margarita Sofia Aletta MBChB PhD", "order": "FAMILY_FIRST", "given": "den Berg Cornelia Margarita Sofia Aletta", "family": "van", "suffix": "MBChB PhD", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "d. B. C. M. S. A. v.", "capitalized_force": "Den Berg Cornelia Margarita Sofia Aletta Van MBChB PhD"} {"name": "van den Volf Ada Kornelia Sezar Martin M.D. Ph.D.", "order": "FAMILY_FIRST", "given": "den Volf Ada Kornelia Sezar Martin", "family": "van", "suffix": "M.D. Ph.D.", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "d. V. A. K. S. M. v.", "capitalized_force": "Den Volf Ada Kornelia Sezar Martin Van M.D. Ph.D."} {"name": "van den Volf Ada Kornelia Yeva Sofiya M.D. Ph.D.", "order": "FAMILY_FIRST", "given": "den Volf Ada Kornelia Yeva Sofiya", "family": "van", "suffix": "M.D. Ph.D.", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "d. V. A. K. Y. S. v.", "capitalized_force": "Den Volf Ada Kornelia Yeva Sofiya Van M.D. Ph.D."} {"name": "van den Volf Becker Schmidt, Prof. Dr. Ada Kornelia Sezar Martin M.D. Ph.D.", "title": "Prof. Dr.", "given": "Ada", "middle": "Kornelia Sezar Martin", "family": "van den Volf Becker Schmidt", "suffix": "M.D. Ph.D.", "initials": "A. K. S. M. V. B. S."} {"name": "van den Volf prof. dr Aleksandra Eva Sara mlađi, dipl. inž.", "order": "FAMILY_FIRST", "title": "dipl. inž.", "given": "den Volf prof. dr Aleksandra Eva Sara", "family": "van", "suffix": "mlađi", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "d. V. p. d. A. E. S. v.", "capitalized_force": "Dipl. Inž. Den Volf Prof. Dr Aleksandra Eva Sara Van Mlađi"} {"name": "van den Volfa Laima Kristīne", "order": "FAMILY_FIRST", "given": "den Volfa Laima Kristīne", "family": "van", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "d. V. L. K. v.", "capitalized_force": "Den Volfa Laima Kristīne Van"} {"name": "van den Volfa Laima Kristīne Eva Sofija", "order": "FAMILY_FIRST", "given": "den Volfa Laima Kristīne Eva Sofija", "family": "van", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "d. V. L. K. E. S. v.", "capitalized_force": "Den Volfa Laima Kristīne Eva Sofija Van"} {"name": "van den Wolf Becker Schmidt Farf. Dr. Ada Cornelia Eva Sophia M.D. Ph.D.", "order": "FAMILY_FIRST", "given": "den Wolf Becker Schmidt Farf. Dr. Ada Cornelia Eva Sophia", "family": "van", "suffix": "M.D. Ph.D.", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "d. W. B. S. F. D. A. C. E. S. v.", "capitalized_force": "Den Wolf Becker Schmidt Farf. Dr. Ada Cornelia Eva Sophia Van M.D. Ph.D."} {"name": "van den Wolf Prof. Dr. Ada Cornelia Eva Sophia Jr, M.D. Ph.D.", "order": "FAMILY_FIRST", "given": "den Wolf Prof. Dr. Ada Cornelia Eva Sophia", "family": "van", "suffix": "Jr, M.D. Ph.D.", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "d. W. P. D. A. C. E. S. v.", "capitalized_force": "Den Wolf Prof. Dr. Ada Cornelia Eva Sophia Van Jr, M.D. Ph.D."} {"name": "van den Wolf, Prof. Dr. Anna Cornelia Eva Sophia jr. M.D. Ph.D.", "order": "FAMILY_FIRST", "title": "Prof. Dr.", "given": "Anna", "middle": "Cornelia Eva Sophia", "family": "van den Wolf", "suffix": "jr. M.D. Ph.D.", "initials": "A. C. E. S. W.", "capitalized_force": "Prof. Dr. Anna Cornelia Eva Sophia van den Wolf Jr. M.D. Ph.D."} {"name": "van den Wolf, prof. dr. Anna Cornelia Eva Sophia jr. m.d. ph.d.", "order": "FAMILY_FIRST", "title": "prof. dr.", "given": "Anna", "middle": "Cornelia Eva Sophia", "family": "van den Wolf", "suffix": "jr. m.d. ph.d.", "initials": "A. C. E. S. W.", "capitalized_force": "Prof. Dr. Anna Cornelia Eva Sophia van den Wolf Jr. M.D. Ph.D."} {"name": "van der Berg, MA", "family": "van der Berg", "suffix": "MA", "initials": "B."} {"name": "van der Berg, PhD", "family": "van der Berg", "suffix": "PhD", "initials": "B."} {"name": "van der Berg, Sir", "title": "Sir", "family": "van der Berg", "initials": "B."} {"name": "van ma van", "given": "van", "middle": "ma", "family": "van", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "v. m. v.", "capitalized": "Van Ma Van"} {"name": "van nguyen", "given": "van", "family": "nguyen", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "v. n.", "capitalized": "Van Nguyen"} {"name": "van. den. Wolf Ada Cornelia Eva Sophia M.D. Ph.D.", "order": "FAMILY_FIRST", "title": "van. den.", "given": "Ada", "middle": "Cornelia Eva Sophia", "family": "Wolf", "suffix": "M.D. Ph.D.", "initials": "A. C. E. S. W.", "capitalized_force": "Van. Den. Ada Cornelia Eva Sophia Wolf M.D. Ph.D."} {"name": "van. den. Wolf, Ada Cornelia Eva Sophia M.D. Ph.D.", "given": "Ada", "middle": "Cornelia Eva Sophia", "family": "van. den. Wolf", "suffix": "M.D. Ph.D.", "initials": "A. C. E. S. W."} {"name": "von Bjarnadóttir dr. Sigríður Jóna Valgerður Fanndís yngri, M.A. í bókmenntafræði", "order": "FAMILY_FIRST", "given": "Bjarnadóttir", "middle": "dr. Sigríður Jóna Valgerður Fanndís", "family": "von", "suffix": "yngri, M.A. í bókmenntafræði", "ambiguities": ["PARTICLE_OR_GIVEN", "SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: initial as given (shape)", "SUFFIX_OR_NAME: name as given (position)", "PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "B. d. S. J. V. F. v.", "capitalized_force": "Bjarnadóttir Dr. Sigríður Jóna Valgerður Fanndís Von Yngri, M.A. Í Bókmenntafræði"} {"name": "von Braun", "given": "von", "family": "Braun", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "v. B.", "capitalized_force": "Von Braun"} {"name": "von Bruhel Ada Cornelia Martina MD DDS", "order": "FAMILY_FIRST", "given": "Bruhel", "middle": "Ada Cornelia Martina", "family": "von", "suffix": "MD DDS", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "B. A. C. M. v.", "capitalized_force": "Bruhel Ada Cornelia Martina Von MD DDS"} {"name": "von Bruhl Prof. Dr. Ada Cornelia Cesar Martin Jr, MD DDS", "order": "FAMILY_FIRST", "given": "Bruhl", "middle": "Prof. Dr. Ada Cornelia Cesar Martin", "family": "von", "suffix": "Jr, MD DDS", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "B. P. D. A. C. C. M. v.", "capitalized_force": "Bruhl Prof. Dr. Ada Cornelia Cesar Martin Von Jr, MD DDS"} {"name": "von Brühl Ada Cornelia César Martín", "order": "FAMILY_FIRST", "given": "Brühl", "middle": "Ada Cornelia César Martín", "family": "von", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "B. A. C. C. M. v.", "capitalized_force": "Brühl Ada Cornelia César Martín Von"} {"name": "von Brühl Ada Cornelia César Martín M.D. Ph.D.", "order": "FAMILY_FIRST", "given": "Brühl", "middle": "Ada Cornelia César Martín", "family": "von", "suffix": "M.D. Ph.D.", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "B. A. C. C. M. v.", "capitalized_force": "Brühl Ada Cornelia César Martín Von M.D. Ph.D."} {"name": "von Brühl César Martín Ada Cornelia", "order": "FAMILY_FIRST", "given": "Brühl", "middle": "César Martín Ada Cornelia", "family": "von", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "B. C. M. A. C. v.", "capitalized_force": "Brühl César Martín Ada Cornelia Von"} {"name": "von Brühl González Domingo Prof. Dr. Ada Cornelia César Martín Jr, MD DDS", "order": "FAMILY_FIRST", "given": "Brühl", "middle": "González Domingo Prof. Dr. Ada Cornelia César Martín", "family": "von", "suffix": "Jr, MD DDS", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "B. G. D. P. D. A. C. C. M. v.", "capitalized_force": "Brühl González Domingo Prof. Dr. Ada Cornelia César Martín Von Jr, MD DDS"} {"name": "von Brühl González Domingo Prof. Dr. Ada Cornelia César Martín MD DDS", "order": "FAMILY_FIRST", "given": "Brühl", "middle": "González Domingo Prof. Dr. Ada Cornelia César Martín", "family": "von", "suffix": "MD DDS", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "B. G. D. P. D. A. C. C. M. v.", "capitalized_force": "Brühl González Domingo Prof. Dr. Ada Cornelia César Martín Von MD DDS"} {"name": "von Brühl González Domingo Prop. Dr. Ada Cornelia César Martín MD DDS", "order": "FAMILY_FIRST", "given": "Brühl", "middle": "González Domingo Prop. Dr. Ada Cornelia César Martín", "family": "von", "suffix": "MD DDS", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "B. G. D. P. D. A. C. C. M. v.", "capitalized_force": "Brühl González Domingo Prop. Dr. Ada Cornelia César Martín Von MD DDS"} {"name": "von Brühl González Domingo, Ada Cornelia César Martín", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl González Domingo", "initials": "A. C. C. M. B. G. D."} {"name": "von Brühl González Domingo, Prof. Dr. Ada Cornelia César Martín Jr, MD DDS", "order": "FAMILY_FIRST", "title": "Prof. Dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl González Domingo", "suffix": "Jr, MD DDS", "initials": "A. C. C. M. B. G. D."} {"name": "von Brühl González Domingo, Prof. Dr. Ada Cornelia César Martín M.D. Ph.D.", "title": "Prof. Dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl González Domingo", "suffix": "M.D. Ph.D.", "initials": "A. C. C. M. B. G. D."} {"name": "von Brühl González Domingo, Prof. Dr. Ada Cornelia César Martín MD DDS", "title": "Prof. Dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl González Domingo", "suffix": "MD DDS", "initials": "A. C. C. M. B. G. D."} {"name": "von Brühl González Domingo, Prop. Dr. Ada Cornelia César Martín MD DDS", "title": "Prop. Dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl González Domingo", "suffix": "MD DDS", "initials": "A. C. C. M. B. G. D."} {"name": "von Brühl Prof. Dr. Ada Cornelia César Martín Jr, MD DDS", "order": "FAMILY_FIRST", "given": "Brühl", "middle": "Prof. Dr. Ada Cornelia César Martín", "family": "von", "suffix": "Jr, MD DDS", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "B. P. D. A. C. C. M. v.", "capitalized_force": "Brühl Prof. Dr. Ada Cornelia César Martín Von Jr, MD DDS"} {"name": "von Brühl Prof. Dr. Mary Lou Cesare Martín Jr., Dr. Ph.D", "order": "FAMILY_FIRST", "title": "Dr.", "given": "Brühl", "middle": "Prof. Dr. Mary Lou Cesare Martín", "family": "von", "suffix": "Jr., Ph.D", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "B. P. D. M. L. C. M. v.", "capitalized_force": "Dr. Brühl Prof. Dr. Mary Lou Cesare Martín Von Jr., Ph.D"} {"name": "von Brühl mevrouw Ada Cornelia César Martín jr., PhD", "order": "FAMILY_FIRST", "given": "Brühl", "middle": "mevrouw Ada Cornelia César Martín", "family": "von", "suffix": "jr., PhD", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "B. m. A. C. C. M. v.", "capitalized_force": "Brühl Mevrouw Ada Cornelia César Martín Von Jr., PhD"} {"name": "von Brühl, Ada Cornelia César Martín", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl", "initials": "A. C. C. M. B."} {"name": "von Brühl, Ada Cornelia César Martín MD DDS", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl", "suffix": "MD DDS", "initials": "A. C. C. M. B."} {"name": "von Brühl, Ada Cornelia César Martín MD DDS", "order": "FAMILY_FIRST", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl", "suffix": "MD DDS", "initials": "A. C. C. M. B."} {"name": "von Brühl, Anastasia Eleni César Martín", "given": "Anastasia", "middle": "Eleni César Martín", "family": "von Brühl", "initials": "A. E. C. M. B."} {"name": "von Brühl, Maria Giusepa Juana Teresa", "given": "Maria", "middle": "Giusepa Juana Teresa", "family": "von Brühl", "initials": "M. G. J. T. B."} {"name": "von Brühl, Prof. Dr. Ada Cornelia César Martín Jr MD DDS", "order": "FAMILY_FIRST", "title": "Prof. Dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl", "suffix": "Jr MD DDS", "initials": "A. C. C. M. B."} {"name": "von Brühl, Prof. dr. Ada Cornelia César Martín, med. dr. fil. dr. jur. dr.", "order": "FAMILY_FIRST", "title": "Prof. dr. dr. dr. dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl", "suffix": "med. fil. jur.", "ambiguities": ["COMMA_STRUCTURE"], "initials": "A. C. C. M. B.", "capitalized_force": "Prof. Dr. Dr. Dr. Dr. Ada Cornelia César Martín von Brühl Med. Fil. Jur."} {"name": "von Brühl, dr. Anastasia Eleni César Martín, cand.odont.", "order": "FAMILY_FIRST", "title": "dr.", "given": "Anastasia", "middle": "Eleni César Martín", "family": "von Brühl", "suffix": "cand.odont.", "initials": "A. E. C. M. B.", "capitalized_force": "Dr. Anastasia Eleni César Martín von Brühl CAND.ODONT."} {"name": "von Brühl, prof. dr. Ada Cornelia César Martín Jr, univ. bacc", "order": "FAMILY_FIRST", "title": "prof. dr.", "given": "Ada", "middle": "Cornelia César Martín", "family": "von Brühl", "suffix": "Jr, univ. bacc", "ambiguities": ["COMMA_STRUCTURE"], "initials": "A. C. C. M. B.", "capitalized_force": "Prof. Dr. Ada Cornelia César Martín von Brühl Jr, Univ. Bacc"} {"name": "von Brül Erika Cornelia Anna Isabelle II", "order": "FAMILY_FIRST", "given": "Brül", "middle": "Erika Cornelia Anna Isabelle", "family": "von", "suffix": "II", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "B. E. C. A. I. v.", "capitalized_force": "Brül Erika Cornelia Anna Isabelle Von II"} {"name": "von Fischer, Dr. Paul Vinzent jr. MdB", "order": "FAMILY_FIRST", "title": "Dr.", "given": "Paul", "middle": "Vinzent", "family": "von Fischer", "suffix": "jr. MdB", "initials": "P. V. F.", "capitalized_force": "Dr. Paul Vinzent von Fischer Jr. MDB"} {"name": "von Kovács Luca Sarolta Éva Erzsébet M.D. Ph.D.", "order": "FAMILY_FIRST", "given": "Kovács", "middle": "Luca Sarolta Éva Erzsébet", "family": "von", "suffix": "M.D. Ph.D.", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "K. L. S. É. E. v.", "capitalized_force": "Kovács Luca Sarolta Éva Erzsébet Von M.D. Ph.D."} {"name": "von Kovács, Luca Sarolta Éva Erzsébet M.D. Ph.D.", "given": "Luca", "middle": "Sarolta Éva Erzsébet", "family": "von Kovács", "suffix": "M.D. Ph.D.", "initials": "L. S. É. E. K."} {"name": "von Miller Jones Mary Sue Marie II, MD DDS", "order": "FAMILY_FIRST", "given": "Miller", "middle": "Jones Mary Sue Marie", "family": "von", "suffix": "II, MD DDS", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "M. J. M. S. M. v.", "capitalized_force": "Miller Jones Mary Sue Marie Von II, MD DDS"} {"name": "von Miller Jones, Prof. Dr. Mary Sue Marie", "title": "Prof. Dr.", "given": "Mary", "middle": "Sue Marie", "family": "von Miller Jones", "initials": "M. S. M. M. J."} {"name": "von Vall Heinmann Laas Prof Anna Liina Eva Sofia MD, PhD", "order": "FAMILY_FIRST", "given": "Vall", "middle": "Heinmann Laas Prof Anna Liina Eva Sofia", "family": "von", "suffix": "MD, PhD", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "V. H. L. P. A. L. E. S. v.", "capitalized_force": "Vall Heinmann Laas Prof Anna Liina Eva Sofia Von MD, PhD"} {"name": "von Vall, Anna Liina Eva Sofia, MD, PhD", "given": "Anna", "middle": "Liina Eva Sofia", "family": "von Vall", "suffix": "MD, PhD", "initials": "A. L. E. S. V."} {"name": "von Wolf Ada Cornelia Eva Sophia MD DDS", "order": "FAMILY_FIRST", "given": "Wolf", "middle": "Ada Cornelia Eva Sophia", "family": "von", "suffix": "MD DDS", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: particle as family (vocabulary)"], "initials": "W. A. C. E. S. v.", "capitalized_force": "Wolf Ada Cornelia Eva Sophia Von MD DDS"} {"name": "von Wright González Domingo Ana Christina Lëja, professori MD DDS", "order": "FAMILY_FIRST", "given": "professori", "family": "von Wright González Domingo Ana Christina Lëja", "suffix": "MD DDS", "initials": "p. W. G. D. A. C. L.", "capitalized_force": "Professori von Wright González Domingo Ana Christina Lëja MD DDS"} {"name": "von Wright, Ana Christina Lëja", "given": "Ana", "middle": "Christina Lëja", "family": "von Wright", "initials": "A. C. L. W."} {"name": "von bergen wessels MD, pennie", "given": "pennie", "family": "von bergen wessels", "suffix": "MD", "initials": "p. b. w.", "capitalized": "Pennie von Bergen Wessels MD"} {"name": "von bergen wessels MD, pennie III", "given": "pennie", "family": "von bergen wessels", "suffix": "MD, III", "initials": "p. b. w.", "capitalized": "Pennie von Bergen Wessels MD, III"} {"name": "von bergen wessels, pennie III", "given": "pennie", "family": "von bergen wessels", "suffix": "III", "initials": "p. b. w.", "capitalized": "Pennie von Bergen Wessels III"} {"name": "von bergen wessels, pennie MD", "given": "pennie", "family": "von bergen wessels", "suffix": "MD", "initials": "p. b. w.", "capitalized": "Pennie von Bergen Wessels MD"} {"name": "ván den Wólf Adá Cornelia Ẹ́va Sophia M.D. PhD.", "order": "FAMILY_FIRST", "given": "den Wólf Adá Cornelia Ẹ́va Sophia", "family": "ván", "suffix": "M.D. PhD.", "initials": "d. W. A. C. Ẹ. S. v.", "capitalized_force": "Den Wólf Adá Cornelia Ẹ́va Sophia Ván M.D. PhD."} {"name": "ván den Wólf, Adá Cornelia Ẹ́va Sophia M.D. PhD.", "given": "Adá", "middle": "Cornelia Ẹ́va Sophia", "family": "ván den Wólf", "suffix": "M.D. PhD.", "initials": "A. C. Ẹ. S. v. W.", "capitalized_force": "Adá Cornelia Ẹ́va Sophia Ván den Wólf M.D. PhD."} {"name": "wang meng", "given": "wang", "family": "meng", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "w. m.", "capitalized": "Wang Meng"} {"name": "won Baar Dominique Goudiaby Prof. Dr. Adama Coulibaly Camara Massamba MD DDS", "order": "FAMILY_FIRST", "given": "Baar", "middle": "Dominique Goudiaby Prof. Dr. Adama Coulibaly Camara Massamba", "family": "won", "suffix": "MD DDS", "initials": "B. D. G. P. D. A. C. C. M. w.", "capitalized_force": "Baar Dominique Goudiaby Prof. Dr. Adama Coulibaly Camara Massamba Won MD DDS"} {"name": "won Baar Dominique Goudiaby, Prof. Dr. Adama Coulibaly Camara Massamba MD DDS", "title": "Prof. Dr.", "given": "Adama", "middle": "Coulibaly Camara Massamba", "family": "won Baar Dominique Goudiaby", "suffix": "MD DDS", "initials": "A. C. C. M. w. B. D. G.", "capitalized_force": "Prof. Dr. Adama Coulibaly Camara Massamba Won Baar Dominique Goudiaby MD DDS"} {"name": "xyz. John Smith", "title": "xyz.", "given": "John", "family": "Smith", "initials": "J. S.", "capitalized_force": "Xyz. John Smith"} {"name": "ylymlaryň doktory, professor Ada Kornelia Sezar Martin fon Brül Gonzales Domingo, hirurg-stomatolog lukman", "title": "professor", "given": "Ada", "middle": "Kornelia Sezar Martin fon Brül Gonzales Domingo", "family": "ylymlaryň doktory", "suffix": "hirurg-stomatolog lukman", "ambiguities": ["COMMA_STRUCTURE"], "initials": "A. K. S. M. f. B. G. D. y. d.", "capitalized_force": "Professor Ada Kornelia Sezar Martin Fon Brül Gonzales Domingo Ylymlaryň Doktory Hirurg-Stomatolog Lukman"} {"name": "zaʿīm, ḥusnī az-", "given": "ḥusnī", "family": "az- zaʿīm", "initials": "ḥ. z.", "capitalized": "Ḥusnī az- Zaʿīm"} {"name": "Ábrányi, Emil", "given": "Emil", "family": "Ábrányi", "initials": "E. Á."} {"name": "Ábrányi, Emil von", "given": "Emil", "family": "von Ábrányi", "ambiguities": ["PARTICLE_OR_GIVEN"], "alternatives": ["PARTICLE_OR_GIVEN: name as middle (declared)"], "initials": "E. Á."} {"name": "Álvaro Londoño Aristizábal", "given": "Álvaro", "middle": "Londoño", "family": "Aristizábal", "initials": "Á. L. A."} {"name": "Álvaro de Brigard Silva", "given": "Álvaro", "family": "de Brigard Silva", "initials": "Á. B. S."} {"name": "Ángel Bassols Batalla", "given": "Ángel", "middle": "Bassols", "family": "Batalla", "initials": "Á. B. B."} {"name": "Ángel Llanos González", "given": "Ángel", "middle": "Llanos", "family": "González", "initials": "Á. L. G."} {"name": "Ángel Sanz Briz", "given": "Ángel", "middle": "Sanz", "family": "Briz", "initials": "Á. S. B."} {"name": "Árni Þorláksson", "given": "Árni", "family": "Þorláksson", "initials": "Á. Þ."} {"name": "Ásgeir Ásgeirsson", "given": "Ásgeir", "family": "Ásgeirsson", "initials": "Á. Á."} {"name": "Ásmundur Sveinsson", "given": "Ásmundur", "family": "Sveinsson", "initials": "Á. S."} {"name": "Ásta Ragnheiður Jóhannesdóttir", "given": "Ásta", "middle": "Ragnheiður", "family": "Jóhannesdóttir", "initials": "Á. R. J."} {"name": "É née Smith", "family": "É", "maiden": "Smith", "initials": "É."} {"name": "É.J. DUPONT", "given": "É.J.", "family": "DUPONT", "initials": "É. D.", "capitalized": "É.J. Dupont"} {"name": "Étienne Lauréault de Foncemagne", "given": "Étienne", "middle": "Lauréault", "family": "de Foncemagne", "initials": "É. L. F."} {"name": "Íris Adólfsdóttir", "given": "Íris", "family": "Adólfsdóttir", "initials": "Í. A."} {"name": "Íris Adólfsdóttir", "order": "FAMILY_FIRST", "given": "Adólfsdóttir", "family": "Íris", "initials": "A. Í."} {"name": "Ísleifur Gizursson", "given": "Ísleifur", "family": "Gizursson", "initials": "Í. G."} {"name": "Ívar Ingimarsson", "given": "Ívar", "family": "Ingimarsson", "initials": "Í. I."} {"name": "Ó. Pérez", "given": "Ó.", "family": "Pérez", "initials": "Ó. P."} {"name": "Ólafur Ragnarsson", "given": "Ólafur", "family": "Ragnarsson", "initials": "Ó. R."} {"name": "Ólafur Þ. Jónsson", "given": "Ólafur", "middle": "Þ.", "family": "Jónsson", "initials": "Ó. Þ. J."} {"name": "Óskar Halldórsson", "given": "Óskar", "family": "Halldórsson", "initials": "Ó. H."} {"name": "Ông Anh Văn Nguyễn ĐBQH", "given": "Ông", "middle": "Anh Văn Nguyễn", "family": "ĐBQH", "initials": "Ô. A. V. N. Đ.", "capitalized_force": "Ông Anh Văn Nguyễn Đbqh"} {"name": "Ögmundur Jónasson", "given": "Ögmundur", "family": "Jónasson", "initials": "Ö. J."} {"name": "Þorlákur Þórhallsson", "given": "Þorlákur", "family": "Þórhallsson", "initials": "Þ. Þ."} {"name": "Þorsteinn Hannesson", "given": "Þorsteinn", "family": "Hannesson", "initials": "Þ. H."} {"name": "Þorsteinn Sveinsson", "given": "Þorsteinn", "family": "Sveinsson", "initials": "Þ. S."} {"name": "Þuríður Pálsdóttir", "given": "Þuríður", "family": "Pálsdóttir", "initials": "Þ. P."} {"name": "Þórunn Egilsdóttir", "given": "Þórunn", "family": "Egilsdóttir", "initials": "Þ. E."} {"name": "é nee smith", "family": "é", "maiden": "smith", "initials": "é.", "capitalized": "É (Smith)"} {"name": "Āqā Buzurg aṭ-Ṭihrānī", "given": "Āqā", "middle": "Buzurg", "family": "aṭ-Ṭihrānī", "initials": "Ā. B. a.", "capitalized_force": "Āqā Buzurg Aṭ-Ṭihrānī"} {"name": "ĐBQH Nguyễn Văn Anh", "order": "FAMILY_FIRST", "given": "Nguyễn", "middle": "Văn Anh", "family": "ĐBQH", "initials": "N. V. A. Đ.", "capitalized_force": "Nguyễn Văn Anh Đbqh"} {"name": "Ğawīdān _ Hānum", "given": "Ğawīdān", "middle": "_", "family": "Hānum", "initials": "Ğ. _. H."} {"name": "Ġazālī, Zainab al-", "given": "Zainab", "family": "al- Ġazālī", "initials": "Z. Ġ."} {"name": "İren Adler", "given": "İren", "family": "Adler", "initials": "İ. A."} {"name": "Ōtani, Kōzui", "given": "Kōzui", "family": "Ōtani", "initials": "K. Ō."} {"name": "Ştöber Zazilia Hamiş", "order": "FAMILY_FIRST", "given": "Zazilia", "middle": "Hamiş", "family": "Ştöber", "initials": "Z. H. Ş."} {"name": "Šaʿrāwī, Hudā aš-", "given": "Hudā", "family": "aš- Šaʿrāwī", "initials": "H. Š."} {"name": "Šejc, Jan Měto", "given": "Jan", "middle": "Měto", "family": "Šejc", "initials": "J. M. Š."} {"name": "Šejc, Jan Měto", "order": "FAMILY_FIRST", "given": "Jan", "middle": "Měto", "family": "Šejc", "initials": "J. M. Š."} {"name": "Šiblī, Abū-Bakr aš-", "given": "Abū-Bakr", "family": "aš- Šiblī", "initials": "A. Š."} {"name": "Šihāb-ad-Dīn Muḥammad Ġūrī", "given": "Šihāb-ad-Dīn", "middle": "Muḥammad", "family": "Ġūrī", "initials": "Š. M. Ġ.", "capitalized_force": "Šihāb-Ad-Dīn Muḥammad Ġūrī"} {"name": "Šołćina, Hanka", "given": "Hanka", "family": "Šołćina", "initials": "H. Š."} {"name": "Šołćina, Hanka", "order": "FAMILY_FIRST", "given": "Hanka", "family": "Šołćina", "initials": "H. Š."} {"name": "Šteber Cecilija Hemiš", "order": "FAMILY_FIRST", "given": "Cecilija", "middle": "Hemiš", "family": "Šteber", "initials": "C. H. Š."} {"name": "Šteber, Cecilija Hemiš", "given": "Cecilija", "middle": "Hemiš", "family": "Šteber", "initials": "C. H. Š."} {"name": "Šākir al-Faḥḥām", "given": "Šākir", "family": "al-Faḥḥām", "initials": "Š. a."} {"name": "Ǧalāl-ad-Dīn Rūmī", "given": "Ǧalāl-ad-Dīn", "family": "Rūmī", "initials": "Ǧ. R.", "capitalized_force": "Ǧalāl-Ad-Dīn Rūmī"} {"name": "Ǧaʿfarī, Ibrāhīm al-", "given": "Ibrāhīm", "family": "al- Ǧaʿfarī", "initials": "I. Ǧ."} {"name": "Ǧīhān as-Sādāt", "given": "Ǧīhān", "family": "as-Sādāt", "initials": "Ǧ. a.", "capitalized_force": "Ǧīhān As-Sādāt"} {"name": "ʊŋono Alaaji KALAM Alɛ Mustafa", "order": "FAMILY_FIRST", "given": "Alaaji", "middle": "KALAM Alɛ Mustafa", "family": "ʊŋono", "initials": "A. K. A. M. ʊ.", "capitalized_force": "Alaaji Kalam Alɛ Mustafa Ʊŋono"} {"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": "ʿ."} {"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-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"} {"name": "ʿImād al-Ḥasanī", "given": "ʿImād", "family": "al-Ḥasanī", "initials": "ʿ. a."} {"name": "ʿĀliya Bint-ʿAlī al-Ḥiǧāzī", "given": "ʿĀliya", "middle": "Bint-ʿAlī", "family": "al-Ḥiǧāzī", "initials": "ʿ. B. a.", "capitalized_force": "ʿĀliya bint-ʿAlī al-Ḥiǧāzī"} {"name": "Αγγελική Ελευθερίου", "given": "Αγγελική", "family": "Ελευθερίου", "initials": "Α. Ε."} {"name": "Αθανάσιος Δροσόπουλος", "given": "Αθανάσιος", "family": "Δροσόπουλος", "initials": "Α. Δ."} {"name": "Αλέξανδρος Παπαδημητρίου", "given": "Αλέξανδρος", "family": "Παπαδημητρίου", "initials": "Α. Π."} {"name": "Αναστάσιος Σταμούλης", "given": "Αναστάσιος", "family": "Σταμούλης", "initials": "Α. Σ."} {"name": "Ανδρέας Καραντώνης", "given": "Ανδρέας", "family": "Καραντώνης", "initials": "Α. Κ."} {"name": "Αντίοχος Ευαγγελάτος", "given": "Αντίοχος", "family": "Ευαγγελάτος", "initials": "Α. Ε."} {"name": "Αντώνης Σαμαράς", "given": "Αντώνης", "family": "Σαμαράς", "initials": "Α. Σ."} {"name": "Βασίλειος Κόκκινος", "given": "Βασίλειος", "family": "Κόκκινος", "initials": "Β. Κ."} {"name": "Βασίλειος Ξυδάς", "given": "Βασίλειος", "family": "Ξυδάς", "initials": "Β. Ξ."} {"name": "Βασίλης Καραπατάκης", "given": "Βασίλης", "family": "Καραπατάκης", "initials": "Β. Κ."} {"name": "Βασίλης Χατζηπαναγής", "given": "Βασίλης", "family": "Χατζηπαναγής", "initials": "Β. Χ."} {"name": "Βούλα Ζουμπουλάκη", "given": "Βούλα", "family": "Ζουμπουλάκη", "initials": "Β. Ζ."} {"name": "Γεώργιος Αντωνόπουλος", "given": "Γεώργιος", "family": "Αντωνόπουλος", "initials": "Γ. Α."} {"name": "Γεώργιος Δρόσος", "given": "Γεώργιος", "family": "Δρόσος", "initials": "Γ. Δ."} {"name": "Γεώργιος Μητσόπουλος", "given": "Γεώργιος", "family": "Μητσόπουλος", "initials": "Γ. Μ."} {"name": "Γεώργιος Παπαδιαμαντόπουλος", "given": "Γεώργιος", "family": "Παπαδιαμαντόπουλος", "initials": "Γ. Π."} {"name": "Γιάννης Γκούμας", "given": "Γιάννης", "family": "Γκούμας", "initials": "Γ. Γ."} {"name": "Γιάννης Θεοδωράκης", "given": "Γιάννης", "family": "Θεοδωράκης", "initials": "Γ. Θ."} {"name": "Γιάννης Κανάκης", "given": "Γιάννης", "family": "Κανάκης", "initials": "Γ. Κ."} {"name": "Γιώργος Παπαδάκης", "given": "Γιώργος", "family": "Παπαδάκης", "initials": "Γ. Π."} {"name": "Δημήτρης", "given": "Δημήτρης", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "Δ."} {"name": "Δημήτρης", "order": "FAMILY_FIRST", "family": "Δημήτρης", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "Δ."} {"name": "Δημήτρης Καπράλος", "given": "Δημήτρης", "family": "Καπράλος", "initials": "Δ. Κ."} {"name": "Δημήτρης Μητρόπουλος", "given": "Δημήτρης", "family": "Μητρόπουλος", "initials": "Δ. Μ."} {"name": "Δημήτρης Μιχαηλίδης", "given": "Δημήτρης", "family": "Μιχαηλίδης", "initials": "Δ. Μ."} {"name": "Δημήτριος Ιωαννίδης", "given": "Δημήτριος", "family": "Ιωαννίδης", "initials": "Δ. Ι."} {"name": "Ειρήνη Πολίτη", "given": "Ειρήνη", "family": "Πολίτη", "initials": "Ε. Π."} {"name": "Επαμεινώνδας Κριεζής", "given": "Επαμεινώνδας", "family": "Κριεζής", "initials": "Ε. Κ."} {"name": "Ζεζίλια Χάμις Στέμπερ", "given": "Ζεζίλια", "middle": "Χάμις", "family": "Στέμπερ", "initials": "Ζ. Χ. Σ."} {"name": "Θάνος Κωτσόπουλος", "given": "Θάνος", "family": "Κωτσόπουλος", "initials": "Θ. Κ."} {"name": "Θανάσης Παπαδόπουλος", "given": "Θανάσης", "family": "Παπαδόπουλος", "initials": "Θ. Π."} {"name": "Ιωάννης Μεταξάς", "given": "Ιωάννης", "family": "Μεταξάς", "initials": "Ι. Μ."} {"name": "Ιωάννης Παπαϊωάννου", "given": "Ιωάννης", "family": "Παπαϊωάννου", "initials": "Ι. Π."} {"name": "Ιωάννης Πετρίδης", "given": "Ιωάννης", "family": "Πετρίδης", "initials": "Ι. Π."} {"name": "Κάτε Μίλερ", "given": "Κάτε", "family": "Μίλερ", "initials": "Κ. Μ."} {"name": "Καθ. δρ. Άντα Κορνέλια Σέσαρ Μαρτίν φον Μπριλ Γκονθάλεθ Δομίνγκο Δρ.Ι. Δρ.Χ.Ο", "title": "Καθ. δρ.", "given": "Άντα", "middle": "Κορνέλια Σέσαρ Μαρτίν φον Μπριλ Γκονθάλεθ Δομίνγκο Δρ.Ι.", "family": "Δρ.Χ.Ο", "initials": "Ά. Κ. Σ. Μ. φ. Μ. Γ. Δ. Δ. Δ.", "capitalized_force": "Καθ. Δρ. Άντα Κορνέλια Σέσαρ Μαρτίν Φον Μπριλ Γκονθάλεθ Δομίνγκο Δρ.Ι. Δρ.Χ.Ο"} {"name": "Καρατζής Πέτρος Νικόλαος", "order": "FAMILY_FIRST", "given": "Πέτρος", "middle": "Νικόλαος", "family": "Καρατζής", "initials": "Π. Ν. Κ."} {"name": "Καρατζής, Πέτρος Νικόλαος", "given": "Πέτρος", "middle": "Νικόλαος", "family": "Καρατζής", "initials": "Π. Ν. Κ."} {"name": "Κος Νικόλαος Καρατζής Κωνσταντίνος Μάριος Χατζής", "title": "Κος", "given": "Νικόλαος", "middle": "Καρατζής Κωνσταντίνος Μάριος", "family": "Χατζής", "initials": "Ν. Κ. Κ. Μ. Χ."} {"name": "Κωνσταντίνος Γιαννίτσης", "given": "Κωνσταντίνος", "family": "Γιαννίτσης", "initials": "Κ. Γ."} {"name": "Κωνσταντίνος Θεοτόκης", "given": "Κωνσταντίνος", "family": "Θεοτόκης", "initials": "Κ. Θ."} {"name": "Κωνσταντίνος Πυλαρινός", "given": "Κωνσταντίνος", "family": "Πυλαρινός", "initials": "Κ. Π."} {"name": "Μάνος Σταλάκης", "given": "Μάνος", "family": "Σταλάκης", "initials": "Μ. Σ."} {"name": "Μίλερ Κάτε", "order": "FAMILY_FIRST", "given": "Κάτε", "family": "Μίλερ", "initials": "Κ. Μ."} {"name": "Μίλερ, Κάτε", "given": "Κάτε", "family": "Μίλερ", "initials": "Κ. Μ."} {"name": "Μαρίκα Τζιραλίδου", "given": "Μαρίκα", "family": "Τζιραλίδου", "initials": "Μ. Τ."} {"name": "Μπριλ, φον Άντα Κορνέλια Σέσαρ Μαρτίν", "given": "φον", "middle": "Άντα Κορνέλια Σέσαρ Μαρτίν", "family": "Μπριλ", "initials": "φ. Ά. Κ. Σ. Μ. Μ.", "capitalized_force": "Φον Άντα Κορνέλια Σέσαρ Μαρτίν Μπριλ"} {"name": "Νίκος Γαλανός", "given": "Νίκος", "family": "Γαλανός", "initials": "Ν. Γ."} {"name": "Νίκος Γούναρης", "given": "Νίκος", "family": "Γούναρης", "initials": "Ν. Γ."} {"name": "Νίκος Καράμπελας", "given": "Νίκος", "family": "Καράμπελας", "initials": "Ν. Κ."} {"name": "Νίκος Κούρκουλος", "given": "Νίκος", "family": "Κούρκουλος", "initials": "Ν. Κ."} {"name": "Νίκος Νικολαΐδης", "given": "Νίκος", "family": "Νικολαΐδης", "initials": "Ν. Ν."} {"name": "Νίκος Σκυλακάκης", "given": "Νίκος", "family": "Σκυλακάκης", "initials": "Ν. Σ."} {"name": "Νικόλαος Γεωργιάδης", "given": "Νικόλαος", "family": "Γεωργιάδης", "initials": "Ν. Γ."} {"name": "Νικόλαος Κοντόπουλος", "given": "Νικόλαος", "family": "Κοντόπουλος", "initials": "Ν. Κ."} {"name": "Νικόλαος Τρικούπης", "given": "Νικόλαος", "family": "Τρικούπης", "initials": "Ν. Τ."} {"name": "Νικόλαος Φράγκος", "given": "Νικόλαος", "family": "Φράγκος", "initials": "Ν. Φ."} {"name": "Ντόρα Λελούδα", "given": "Ντόρα", "family": "Λελούδα", "initials": "Ν. Λ."} {"name": "Πέτρος Νικόλαος Καρατζής", "given": "Πέτρος", "middle": "Νικόλαος", "family": "Καρατζής", "initials": "Π. Ν. Κ."} {"name": "Πέτρος Παυλίδης", "given": "Πέτρος", "family": "Παυλίδης", "initials": "Π. Π."} {"name": "Παναγιώτης Καραλής", "given": "Παναγιώτης", "family": "Καραλής", "initials": "Π. Κ."} {"name": "Παντελής Πετρακάκης", "given": "Παντελής", "family": "Πετρακάκης", "initials": "Π. Π."} {"name": "Παύλος Ιωάννου", "given": "Παύλος", "family": "Ιωάννου", "initials": "Π. Ι."} {"name": "Πολίτη Ειρήνη", "order": "FAMILY_FIRST", "given": "Ειρήνη", "family": "Πολίτη", "initials": "Ε. Π."} {"name": "Πολίτη, Ειρήνη", "given": "Ειρήνη", "family": "Πολίτη", "initials": "Ε. Π."} {"name": "Σινμπάντ", "given": "Σινμπάντ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "Σ."} {"name": "Σινμπάντ", "order": "FAMILY_FIRST", "family": "Σινμπάντ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "Σ."} {"name": "Στέμπερ Ζεζίλια Χάμις", "order": "FAMILY_FIRST", "given": "Ζεζίλια", "middle": "Χάμις", "family": "Στέμπερ", "initials": "Ζ. Χ. Σ."} {"name": "Στέμπερ, Ζεζίλια Χάμις", "given": "Ζεζίλια", "middle": "Χάμις", "family": "Στέμπερ", "initials": "Ζ. Χ. Σ."} {"name": "Στέφανος Λάντσας", "given": "Στέφανος", "family": "Λάντσας", "initials": "Σ. Λ."} {"name": "Σταύρος Γεωργίου", "given": "Σταύρος", "family": "Γεωργίου", "initials": "Σ. Γ."} {"name": "Σταύρος Κουγιουμτζής", "given": "Σταύρος", "family": "Κουγιουμτζής", "initials": "Σ. Κ."} {"name": "Σταύρος Κωστόπουλος", "given": "Σταύρος", "family": "Κωστόπουλος", "initials": "Σ. Κ."} {"name": "Σταύρος Τσιώλης", "given": "Σταύρος", "family": "Τσιώλης", "initials": "Σ. Τ."} {"name": "Στυλιανός Λυκούδης", "given": "Στυλιανός", "family": "Λυκούδης", "initials": "Σ. Λ."} {"name": "Τάκης Κανελλόπουλος", "given": "Τάκης", "family": "Κανελλόπουλος", "initials": "Τ. Κ."} {"name": "Φαίδων Θεοφίλου", "given": "Φαίδων", "family": "Θεοφίλου", "initials": "Φ. Θ."} {"name": "Χαράλαμπος Σιμόπουλος", "given": "Χαράλαμπος", "family": "Σιμόπουλος", "initials": "Χ. Σ."} {"name": "Χατζής Νικόλαος Καρατζής Κωνσταντίνος Μάριος", "order": "FAMILY_FIRST", "given": "Νικόλαος", "middle": "Καρατζής Κωνσταντίνος Μάριος", "family": "Χατζής", "initials": "Ν. Κ. Κ. Μ. Χ."} {"name": "Χατζής, Νικόλαος Καρατζής Κωνσταντίνος Μάριος", "given": "Νικόλαος", "middle": "Καρατζής Κωνσταντίνος Μάριος", "family": "Χατζής", "initials": "Ν. Κ. Κ. Μ. Χ."} {"name": "Χρήστος Αντωνόπουλος", "given": "Χρήστος", "family": "Αντωνόπουλος", "initials": "Χ. Α."} {"name": "φον Μπριλ Άντα Κορνέλια Σέσαρ Μαρτίν Δρ.Ι. Δρ.Χ.Ο", "order": "FAMILY_FIRST", "given": "Μπριλ", "middle": "Άντα Κορνέλια Σέσαρ Μαρτίν Δρ.Ι. Δρ.Χ.Ο", "family": "φον", "initials": "Μ. Ά. Κ. Σ. Μ. Δ. Δ. φ.", "capitalized_force": "Μπριλ Άντα Κορνέλια Σέσαρ Μαρτίν Δρ.Ι. Δρ.Χ.Ο Φον"} {"name": "Єва Марія Шевченко", "given": "Єва", "middle": "Марія", "family": "Шевченко", "initials": "Є. М. Ш."} {"name": "Євген Гуцало", "given": "Євген", "family": "Гуцало", "initials": "Є. Г."} {"name": "Євгенія Казбекова", "given": "Євгенія", "family": "Казбекова", "initials": "Є. К."} {"name": "Ігор Рибак", "given": "Ігор", "family": "Рибак", "initials": "І. Р."} {"name": "Ірына Алейнік", "given": "Ірына", "family": "Алейнік", "initials": "І. А."} {"name": "Јелена Беговић", "given": "Јелена", "family": "Беговић", "initials": "Ј. Б."} {"name": "Јован Драган Поповић", "given": "Јован", "middle": "Драган", "family": "Поповић", "initials": "Ј. Д. П."} {"name": "Јован Мијатовић", "given": "Јован", "family": "Мијатовић", "initials": "Ј. М."} {"name": "Јован Шајновић", "given": "Јован", "family": "Шајновић", "initials": "Ј. Ш."} {"name": "Љубомир Јовановић", "given": "Љубомир", "family": "Јовановић", "initials": "Љ. Ј."} {"name": "Абатуров, Константин Иванович", "given": "Константин", "middle": "Иванович", "family": "Абатуров", "initials": "К. И. А."} {"name": "Агеева, Валентина Васильевна", "given": "Валентина", "middle": "Васильевна", "family": "Агеева", "initials": "В. В. А."} {"name": "Агриппина Младшая", "given": "Агриппина", "suffix": "Младшая", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "А."} {"name": "Ада Карнэлія Сесар Марцін фон Бруль д-р", "given": "Ада", "middle": "Карнэлія Сесар Марцін фон Бруль", "family": "д-р", "initials": "А. К. С. М. ф. Б. д.", "capitalized_force": "Ада Карнэлія Сесар Марцін Фон Бруль Д-Р"} {"name": "Ада Корнелия фон Брюль Джуниор, тіс дәрігері", "given": "тіс", "middle": "дәрігері", "family": "Ада Корнелия фон Брюль Джуниор", "initials": "т. д. А. К. ф. Б. Д.", "capitalized_force": "Тіс Дәрігері Ада Корнелия Фон Брюль Джуниор"} {"name": "Адамович, Михаил Михайлович", "given": "Михаил", "middle": "Михайлович", "family": "Адамович", "initials": "М. М. А."} {"name": "Адлер Ирене", "order": "FAMILY_FIRST", "given": "Ирене", "family": "Адлер", "initials": "И. А."} {"name": "Азамат Салауат улы Фаттахов", "given": "Азамат", "middle": "Салауат улы", "family": "Фаттахов", "initials": "А. С. у. Ф.", "capitalized_force": "Азамат Салауат Улы Фаттахов"} {"name": "Айдар Қасым", "given": "Айдар", "family": "Қасым", "initials": "А. Қ."} {"name": "Алевтина Львовна Юрина", "given": "Алевтина", "middle": "Львовна", "family": "Юрина", "initials": "А. Л. Ю."} {"name": "Алейнік Ірына", "order": "FAMILY_FIRST", "given": "Ірына", "family": "Алейнік", "initials": "І. А."} {"name": "Алейнік, Ірына", "given": "Ірына", "family": "Алейнік", "initials": "І. А."} {"name": "Александар Антић", "given": "Александар", "family": "Антић", "initials": "А. А."} {"name": "Александар Пантић", "given": "Александар", "family": "Пантић", "initials": "А. П."} {"name": "Александар Станковић", "given": "Александар", "family": "Станковић", "initials": "А. С."} {"name": "Александр Александрович Кухаренко", "given": "Александр", "middle": "Александрович", "family": "Кухаренко", "initials": "А. А. К."} {"name": "Александр Александрович Фридман", "given": "Александр", "middle": "Александрович", "family": "Фридман", "initials": "А. А. Ф."} {"name": "Александр Алексеевич Моисеев", "given": "Александр", "middle": "Алексеевич", "family": "Моисеев", "initials": "А. А. М."} {"name": "Александр Андреевич Клюшников", "given": "Александр", "middle": "Андреевич", "family": "Клюшников", "initials": "А. А. К."} {"name": "Александр Владимирович Кессених", "given": "Александр", "middle": "Владимирович", "family": "Кессених", "initials": "А. В. К."} {"name": "Александр Вячеславович Жиров", "given": "Александр", "middle": "Вячеславович", "family": "Жиров", "initials": "А. В. Ж."} {"name": "Александр Гельевич Дугин", "given": "Александр", "middle": "Гельевич", "family": "Дугин", "initials": "А. Г. Д."} {"name": "Александр Емельянович Хмель", "given": "Александр", "middle": "Емельянович", "family": "Хмель", "initials": "А. Е. Х."} {"name": "Александр Иванович Алексеев", "given": "Александр", "middle": "Иванович", "family": "Алексеев", "initials": "А. И. А."} {"name": "Александр Иванович Булгаков", "given": "Александр", "middle": "Иванович", "family": "Булгаков", "initials": "А. И. Б."} {"name": "Александр Иванович Черепанов", "given": "Александр", "middle": "Иванович", "family": "Черепанов", "initials": "А. И. Ч."} {"name": "Александр Кучма", "given": "Александр", "family": "Кучма", "initials": "А. К."} {"name": "Александр Леонидович Дворкин", "given": "Александр", "middle": "Леонидович", "family": "Дворкин", "initials": "А. Л. Д."} {"name": "Александр Николаевич Степанов", "given": "Александр", "middle": "Николаевич", "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": "Александър Цветков", "given": "Александър", "family": "Цветков", "initials": "А. Ц."} {"name": "Алексеев, Михаил Николаевич", "given": "Михаил", "middle": "Николаевич", "family": "Алексеев", "initials": "М. Н. А."} {"name": "Алексей Алексеевич Никитин", "given": "Алексей", "middle": "Алексеевич", "family": "Никитин", "initials": "А. А. Н."} {"name": "Алексей Борисович Елисеев", "given": "Алексей", "middle": "Борисович", "family": "Елисеев", "initials": "А. Б. Е."} {"name": "Алексей Валерьевич Серебряков", "given": "Алексей", "middle": "Валерьевич", "family": "Серебряков", "initials": "А. В. С."} {"name": "Алексей Васильевич Богдан", "given": "Алексей", "middle": "Васильевич", "family": "Богдан", "initials": "А. В. Б."} {"name": "Алексей Вячеславович Панин", "given": "Алексей", "middle": "Вячеславович", "family": "Панин", "initials": "А. В. П."} {"name": "Алексей Иванович Давыдов", "given": "Алексей", "middle": "Иванович", "family": "Давыдов", "initials": "А. И. Д."} {"name": "Алексей Иванович Савельев", "given": "Алексей", "middle": "Иванович", "family": "Савельев", "initials": "А. И. С."} {"name": "Алексей Николаевич Плещеев", "given": "Алексей", "middle": "Николаевич", "family": "Плещеев", "initials": "А. Н. П."} {"name": "Алексей Семёнович Сечкин", "given": "Алексей", "middle": "Семёнович", "family": "Сечкин", "initials": "А. С. С."} {"name": "Алымбекова Каныкей", "order": "FAMILY_FIRST", "given": "Каныкей", "family": "Алымбекова", "initials": "К. А."} {"name": "Амосова Ирина", "order": "FAMILY_FIRST", "given": "Ирина", "family": "Амосова", "initials": "И. А."} {"name": "Амосова, Ирина", "given": "Ирина", "family": "Амосова", "initials": "И. А."} {"name": "Ана Дабовић", "given": "Ана", "family": "Дабовић", "initials": "А. Д."} {"name": "Ана Иванова Димитрова", "given": "Ана", "middle": "Иванова", "family": "Димитрова", "initials": "А. И. Д."} {"name": "Ана Хорват старији Џонс", "given": "Ана", "family": "Хорват", "suffix": "старији Џонс", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "А. Х.", "capitalized_force": "Ана Хорват Старији Џонс"} {"name": "Анастас Јовановић", "given": "Анастас", "family": "Јовановић", "initials": "А. Ј."} {"name": "Анатолий Александрович Вассерман", "given": "Анатолий", "middle": "Александрович", "family": "Вассерман", "initials": "А. А. В."} {"name": "Анатолий Николаевич Алябьев", "given": "Анатолий", "middle": "Николаевич", "family": "Алябьев", "initials": "А. Н. А."} {"name": "Ангел Андреев", "given": "Ангел", "family": "Андреев", "initials": "А. А."} {"name": "Ангел Кънчев", "given": "Ангел", "family": "Кънчев", "initials": "А. К."} {"name": "Андрей Васильевич Мягков", "given": "Андрей", "middle": "Васильевич", "family": "Мягков", "initials": "А. В. М."} {"name": "Андрей Сергеевич Кончаловский", "given": "Андрей", "middle": "Сергеевич", "family": "Кончаловский", "initials": "А. С. К."} {"name": "Андрија Милошевић", "given": "Андрија", "family": "Милошевић", "initials": "А. М."} {"name": "Андрій Володимирович Бондар", "given": "Андрій", "middle": "Володимирович", "family": "Бондар", "initials": "А. В. Б."} {"name": "Андрій Володимирович Портнов", "given": "Андрій", "middle": "Володимирович", "family": "Портнов", "initials": "А. В. П."} {"name": "Анна Мельник", "given": "Анна", "family": "Мельник", "initials": "А. М."} {"name": "Анна Николаевна Гладкова", "given": "Анна", "middle": "Николаевна", "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": "Анђелија Веснић", "given": "Анђелија", "family": "Веснић", "initials": "А. В."} {"name": "Артур Валерьевич Дмитриев", "given": "Артур", "middle": "Валерьевич", "family": "Дмитриев", "initials": "А. В. Д."} {"name": "Асаф Михайлович Мессерер", "given": "Асаф", "middle": "Михайлович", "family": "Мессерер", "initials": "А. М. М."} {"name": "Аталан", "given": "Аталан", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "А."} {"name": "Аталан", "order": "FAMILY_FIRST", "family": "Аталан", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "А."} {"name": "Ашмарин Николай Иванович", "order": "FAMILY_FIRST", "given": "Николай", "middle": "Иванович", "family": "Ашмарин", "initials": "Н. И. А."} {"name": "Ашмарин, Николай Иванович", "given": "Николай", "middle": "Иванович", "family": "Ашмарин", "initials": "Н. И. А."} {"name": "Балдырған Қожамқұлова", "given": "Балдырған", "family": "Қожамқұлова", "initials": "Б. Қ."} {"name": "Бат", "given": "Бат", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "Б."} {"name": "Бат", "order": "FAMILY_FIRST", "family": "Бат", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "Б."} {"name": "Бақытжан Қанапиянов", "given": "Бақытжан", "family": "Қанапиянов", "initials": "Б. Қ."} {"name": "Бертрам Вилберфорс Ҳенру Роберт Вустер", "given": "Бертрам", "middle": "Вилберфорс Ҳенру Роберт", "family": "Вустер", "initials": "Б. В. Ҳ. Р. В."} {"name": "Бисерка Цвејић", "given": "Бисерка", "family": "Цвејић", "initials": "Б. Ц."} {"name": "Биљана Плавшић", "given": "Биљана", "family": "Плавшић", "initials": "Б. П."} {"name": "Бобан Марковић", "given": "Бобан", "family": "Марковић", "initials": "Б. М."} {"name": "Богдан Несторовић", "given": "Богдан", "family": "Несторовић", "initials": "Б. Н."} {"name": "Богданов, Владимир Алексеевич", "given": "Владимир", "middle": "Алексеевич", "family": "Богданов", "initials": "В. А. Б."} {"name": "Богомолов, Владимир Николаевич", "given": "Владимир", "middle": "Николаевич", "family": "Богомолов", "initials": "В. Н. Б."} {"name": "Бойцов, Владимир Емельянович", "given": "Владимир", "middle": "Емельянович", "family": "Бойцов", "initials": "В. Е. Б."} {"name": "Болд Дулмаа", "order": "FAMILY_FIRST", "given": "Дулмаа", "family": "Болд", "initials": "Д. Б."} {"name": "Болд, Дулмаа", "given": "Дулмаа", "family": "Болд", "initials": "Д. Б."} {"name": "Бондарев, Николай Автономович", "given": "Николай", "middle": "Автономович", "family": "Бондарев", "initials": "Н. А. Б."} {"name": "Борис Алексеевич Зозуля", "given": "Борис", "middle": "Алексеевич", "family": "Зозуля", "initials": "Б. А. З."} {"name": "Борис Владимирович Полянский", "given": "Борис", "middle": "Владимирович", "family": "Полянский", "initials": "Б. В. П."} {"name": "Борис Галчев", "given": "Борис", "family": "Галчев", "initials": "Б. Г."} {"name": "Борис Максимович Келлер", "given": "Борис", "middle": "Максимович", "family": "Келлер", "initials": "Б. М. К."} {"name": "Борис Николаевич Ельцин", "given": "Борис", "middle": "Николаевич", "family": "Ельцин", "initials": "Б. Н. Е."} {"name": "Бошко Јанковић", "given": "Бошко", "family": "Јанковић", "initials": "Б. Ј."} {"name": "Бранислав Јовановић", "given": "Бранислав", "family": "Јовановић", "initials": "Б. Ј."} {"name": "Бранислав Блажић", "given": "Бранислав", "family": "Блажић", "initials": "Б. Б."} {"name": "Бранко Борковић", "given": "Бранко", "family": "Борковић", "initials": "Б. Б."} {"name": "Бруль, Ада Карнэлія Сесар Марцін фон", "given": "Ада", "middle": "Карнэлія Сесар Марцін фон", "family": "Бруль", "initials": "А. К. С. М. ф. Б.", "capitalized_force": "Ада Карнэлія Сесар Марцін Фон Бруль"} {"name": "Брюл, Ада Корнелия Цезар Мартин фон", "given": "Ада", "middle": "Корнелия Цезар Мартин фон", "family": "Брюл", "initials": "А. К. Ц. М. ф. Б.", "capitalized_force": "Ада Корнелия Цезар Мартин Фон Брюл"} {"name": "Брюль, Ада Корнелия Сезар Мартин фон", "given": "Ада", "middle": "Корнелия Сезар Мартин фон", "family": "Брюль", "initials": "А. К. С. М. ф. Б.", "capitalized_force": "Ада Корнелия Сезар Мартин Фон Брюль"} {"name": "Брюхл, Ада Корнелиа Эва Софиа вон", "given": "Ада", "middle": "Корнелиа Эва Софиа вон", "family": "Брюхл", "initials": "А. К. Э. С. в. Б.", "capitalized_force": "Ада Корнелиа Эва Софиа Вон Брюхл"} {"name": "Бычков, Пётр Николаевич", "given": "Пётр", "middle": "Николаевич", "family": "Бычков", "initials": "П. Н. Б."} {"name": "Вагнер Николай Петрович", "order": "FAMILY_FIRST", "given": "Николай", "middle": "Петрович", "family": "Вагнер", "initials": "Н. П. В."} {"name": "Вадим Владимирович Наумов", "given": "Вадим", "middle": "Владимирович", "family": "Наумов", "initials": "В. В. Н."} {"name": "Вадим Евгеньевич Бойко", "given": "Вадим", "middle": "Евгеньевич", "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": "Васил Янакиев", "given": "Васил", "family": "Янакиев", "initials": "В. Я."} {"name": "Васильев Дмитрий Петрович", "order": "FAMILY_FIRST", "given": "Дмитрий", "middle": "Петрович", "family": "Васильев", "initials": "Д. П. В."} {"name": "Васильев, Дмитрий Петрович", "given": "Дмитрий", "middle": "Петрович", "family": "Васильев", "initials": "Д. П. В."} {"name": "Васіль", "given": "Васіль", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "В."} {"name": "Васіль", "order": "FAMILY_FIRST", "family": "Васіль", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "В."} {"name": "Ватсон Мэри Сю Хамиш", "order": "FAMILY_FIRST", "given": "Мэри", "middle": "Сю Хамиш", "family": "Ватсон", "initials": "М. С. Х. В."} {"name": "Велизар Димитров", "given": "Велизар", "family": "Димитров", "initials": "В. Д."} {"name": "Велко Дачев", "given": "Велко", "family": "Дачев", "initials": "В. Д."} {"name": "Вернер, Иван Асен фон", "given": "Иван", "middle": "Асен фон", "family": "Вернер", "initials": "И. А. ф. В.", "capitalized_force": "Иван Асен Фон Вернер"} {"name": "Вероніка Марченко", "given": "Вероніка", "family": "Марченко", "initials": "В. М."} {"name": "Виктор Васильевич Ляпунов", "given": "Виктор", "middle": "Васильевич", "family": "Ляпунов", "initials": "В. В. Л."} {"name": "Виктор Владимирович Глухов", "given": "Виктор", "middle": "Владимирович", "family": "Глухов", "initials": "В. В. Г."} {"name": "Виктор Георгиев", "given": "Виктор", "family": "Георгиев", "initials": "В. Г."} {"name": "Виктор Иванович Туровцев", "given": "Виктор", "middle": "Иванович", "family": "Туровцев", "initials": "В. И. Т."} {"name": "Виктор Михайлович Иванцов", "given": "Виктор", "middle": "Михайлович", "family": "Иванцов", "initials": "В. М. И."} {"name": "Виссарион Михайлович Павлов", "given": "Виссарион", "middle": "Михайлович", "family": "Павлов", "initials": "В. М. П."} {"name": "Вишняков, Павел Терентьевич", "given": "Павел", "middle": "Терентьевич", "family": "Вишняков", "initials": "П. Т. В."} {"name": "Владимир Александрович Усов", "given": "Владимир", "middle": "Александрович", "family": "Усов", "initials": "В. А. У."} {"name": "Владимир Афанасьевич Фатеев", "given": "Владимир", "middle": "Афанасьевич", "family": "Фатеев", "initials": "В. А. Ф."} {"name": "Владимир Вазов", "given": "Владимир", "family": "Вазов", "initials": "В. В."} {"name": "Владимир Валерьевич Буре", "given": "Владимир", "middle": "Валерьевич", "family": "Буре", "initials": "В. В. Б."} {"name": "Владимир Владимирович Кара-Мурза", "given": "Владимир", "middle": "Владимирович", "family": "Кара-Мурза", "initials": "В. В. К."} {"name": "Владимир Владимирович Путин", "given": "Владимир", "middle": "Владимирович", "family": "Путин", "initials": "В. В. П."} {"name": "Владимир Евграфович Татлин", "given": "Владимир", "middle": "Евграфович", "family": "Татлин", "initials": "В. Е. Т."} {"name": "Владимир Емельянович Бойцов", "given": "Владимир", "middle": "Емельянович", "family": "Бойцов", "initials": "В. Е. Б."} {"name": "Владимир Михайлович Штейн", "given": "Владимир", "middle": "Михайлович", "family": "Штейн", "initials": "В. М. Ш."} {"name": "Владимир Седов", "given": "Владимир", "family": "Седов", "initials": "В. С."} {"name": "Владимир Сергеевич Шкляев", "given": "Владимир", "middle": "Сергеевич", "family": "Шкляев", "initials": "В. С. Ш."} {"name": "Владислав Иванович Мамонтов", "given": "Владислав", "middle": "Иванович", "family": "Мамонтов", "initials": "В. И. М."} {"name": "Владислав Юрьевич Сурков", "given": "Владислав", "middle": "Юрьевич", "family": "Сурков", "initials": "В. Ю. С."} {"name": "Володимир Максименко", "given": "Володимир", "family": "Максименко", "initials": "В. М."} {"name": "Волф, Александра Ева Сара ван ден", "given": "Александра", "middle": "Ева Сара ван ден", "family": "Волф", "initials": "А. Е. С. в. д. В.", "capitalized_force": "Александра Ева Сара Ван Ден Волф"} {"name": "Волф, Ана Марија Цезар Мартин ван ден", "given": "Ана", "middle": "Марија Цезар Мартин ван ден", "family": "Волф", "initials": "А. М. Ц. М. в. д. В.", "capitalized_force": "Ана Марија Цезар Мартин Ван Ден Волф"} {"name": "Вольга Караліна Равенская", "given": "Вольга", "middle": "Караліна", "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": "Бертрам", "middle": "Вилберфорс Ҳенру Роберт", "family": "Вустер", "initials": "Б. В. Ҳ. Р. В."} {"name": "Віктор Пинзеник", "given": "Віктор", "family": "Пинзеник", "initials": "В. П."} {"name": "Гай Плиний Старший Иванов", "given": "Гай", "family": "Плиний", "suffix": "Старший Иванов", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "Г. П."} {"name": "Галина Савова", "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": "Георги Георгиев", "given": "Георги", "family": "Георгиев", "initials": "Г. Г."} {"name": "Георги Живков", "given": "Георги", "family": "Живков", "initials": "Г. Ж."} {"name": "Георгий Ефремович Катанаев", "given": "Георгий", "middle": "Ефремович", "family": "Катанаев", "initials": "Г. Е. К."} {"name": "Герман Германов", "given": "Герман", "family": "Германов", "initials": "Г. Г."} {"name": "Гнилицкая, Нина Тимофеевна", "given": "Нина", "middle": "Тимофеевна", "family": "Гнилицкая", "initials": "Н. Т. Г."} {"name": "Гойщик, Татьяна Геннадьевна", "given": "Татьяна", "middle": "Геннадьевна", "family": "Гойщик", "initials": "Т. Г. Г."} {"name": "Грипич, Александр Сергеевич", "given": "Александр", "middle": "Сергеевич", "family": "Грипич", "initials": "А. С. Г."} {"name": "Гриценко, Анатолий Иванович", "given": "Анатолий", "middle": "Иванович", "family": "Гриценко", "initials": "А. И. Г."} {"name": "Данка Илић", "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": "Дејан Миловановић", "given": "Дејан", "family": "Миловановић", "initials": "Д. М."} {"name": "Дејан Стојановић", "given": "Дејан", "family": "Стојановић", "initials": "Д. С."} {"name": "Джон", "given": "Джон", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "Д."} {"name": "Джон", "order": "FAMILY_FIRST", "family": "Джон", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "Д."} {"name": "Джоунс, Зара Мери", "given": "Зара", "middle": "Мери", "family": "Джоунс", "initials": "З. М. Д."} {"name": "Джоунс, Зара Мери", "order": "FAMILY_FIRST", "given": "Зара", "middle": "Мери", "family": "Джоунс", "initials": "З. М. Д."} {"name": "Димитрије Нешић", "given": "Димитрије", "family": "Нешић", "initials": "Д. Н."} {"name": "Димитрова, Ана Иванова", "given": "Ана", "middle": "Иванова", "family": "Димитрова", "initials": "А. И. Д."} {"name": "Димитрова, Ана Иванова", "order": "FAMILY_FIRST", "given": "Ана", "middle": "Иванова", "family": "Димитрова", "initials": "А. И. Д."} {"name": "Динко Цветков Дерменджиев", "given": "Динко", "middle": "Цветков", "family": "Дерменджиев", "initials": "Д. Ц. Д."} {"name": "Дитятин, Иван Иванович", "given": "Иван", "middle": "Иванович", "family": "Дитятин", "initials": "И. И. Д."} {"name": "Дмитриев Владимир Михайлович", "order": "FAMILY_FIRST", "given": "Владимир", "middle": "Михайлович", "family": "Дмитриев", "initials": "В. М. Д."} {"name": "Дмитриев, Артур Валерьевич", "given": "Артур", "middle": "Валерьевич", "family": "Дмитриев", "initials": "А. В. Д."} {"name": "Дмитрий Петрович Васильев", "given": "Дмитрий", "middle": "Петрович", "family": "Васильев", "initials": "Д. П. В."} {"name": "Дмитро Олександрович Корчинський", "given": "Дмитро", "middle": "Олександрович", "family": "Корчинський", "initials": "Д. О. К."} {"name": "Дорж Цэцэг", "order": "FAMILY_FIRST", "given": "Цэцэг", "family": "Дорж", "initials": "Ц. Д."} {"name": "Дорж, Цэцэг", "given": "Цэцэг", "family": "Дорж", "initials": "Ц. Д."} {"name": "Драган Стојковић", "given": "Драган", "family": "Стојковић", "initials": "Д. С."} {"name": "Драган Томић", "given": "Драган", "family": "Томић", "initials": "Д. Т."} {"name": "Драгомир Јовановић", "given": "Драгомир", "family": "Јовановић", "initials": "Д. Ј."} {"name": "Драгомир Милошевић", "given": "Драгомир", "family": "Милошевић", "initials": "Д. М."} {"name": "Дулмаа Болд", "given": "Дулмаа", "family": "Болд", "initials": "Д. Б."} {"name": "Душан Милошевић", "given": "Душан", "family": "Милошевић", "initials": "Д. М."} {"name": "Дятлов, Владимир Семёнович", "given": "Владимир", "middle": "Семёнович", "family": "Дятлов", "initials": "В. С. Д."} {"name": "Дәулет Болатұлы Тұрлыханов", "given": "Дәулет", "middle": "Болатұлы", "family": "Тұрлыханов", "initials": "Д. Б. Т."} {"name": "Евгений Александрович Евтушенко", "given": "Евгений", "middle": "Александрович", "family": "Евтушенко", "initials": "Е. А. Е."} {"name": "Евгений Викторович Осин", "given": "Евгений", "middle": "Викторович", "family": "Осин", "initials": "Е. В. О."} {"name": "Евгений Романович Устюгов", "given": "Евгений", "middle": "Романович", "family": "Устюгов", "initials": "Е. Р. У."} {"name": "Евгений Францевич Бауэр", "given": "Евгений", "middle": "Францевич", "family": "Бауэр", "initials": "Е. Ф. Б."} {"name": "Евдокия Андреевна Никулина", "given": "Евдокия", "middle": "Андреевна", "family": "Никулина", "initials": "Е. А. Н."} {"name": "Евтеев, Николай Петрович", "given": "Николай", "middle": "Петрович", "family": "Евтеев", "initials": "Н. П. Е."} {"name": "Егор Николаевич Булаткин", "given": "Егор", "middle": "Николаевич", "family": "Булаткин", "initials": "Е. Н. Б."} {"name": "Егор Николаевич Свиридов", "given": "Егор", "middle": "Николаевич", "family": "Свиридов", "initials": "Е. Н. С."} {"name": "Едуард Михайлович Москальов", "given": "Едуард", "middle": "Михайлович", "family": "Москальов", "initials": "Е. М. М."} {"name": "Елена Вячеславовна Дементьева", "given": "Елена", "middle": "Вячеславовна", "family": "Дементьева", "initials": "Е. В. Д."} {"name": "Елена Игоревна Лядова", "given": "Елена", "middle": "Игоревна", "family": "Лядова", "initials": "Е. И. Л."} {"name": "Ж née Smith", "family": "Ж", "maiden": "Smith", "initials": "Ж."} {"name": "Ж. née Smith", "given": "Ж.", "maiden": "Smith", "initials": "Ж."} {"name": "Жанна Трофимовна Прохоренко", "given": "Жанна", "middle": "Трофимовна", "family": "Прохоренко", "initials": "Ж. Т. П."} {"name": "Желязко Ангелов", "given": "Желязко", "family": "Ангелов", "initials": "Ж. А."} {"name": "Жилинский, Алексей Александрович", "given": "Алексей", "middle": "Александрович", "family": "Жилинский", "initials": "А. А. Ж."} {"name": "Жиров, Александр Вячеславович", "given": "Александр", "middle": "Вячеславович", "family": "Жиров", "initials": "А. В. Ж."} {"name": "Журавлёва, Людмила Васильевна", "given": "Людмила", "middle": "Васильевна", "family": "Журавлёва", "initials": "Л. В. Ж."} {"name": "Завадовский, Михаил Михайлович", "given": "Михаил", "middle": "Михайлович", "family": "Завадовский", "initials": "М. М. З."} {"name": "Зазилиа Хамиш Стобер", "given": "Зазилиа", "middle": "Хамиш", "family": "Стобер", "initials": "З. Х. С."} {"name": "Зазилия Хамиш Штобер", "given": "Зазилия", "middle": "Хамиш", "family": "Штобер", "initials": "З. Х. Ш."} {"name": "Зазилия Ҳамиш Стобер", "given": "Зазилия", "middle": "Ҳамиш", "family": "Стобер", "initials": "З. Ҳ. С."} {"name": "Зазилія Хеміш Стобер", "given": "Зазилія", "middle": "Хеміш", "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": "Зендая", "given": "Зендая", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "З."} {"name": "Зендая", "order": "FAMILY_FIRST", "family": "Зендая", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "З."} {"name": "Зорин, Михаил Петрович", "given": "Михаил", "middle": "Петрович", "family": "Зорин", "initials": "М. П. З."} {"name": "Иван", "given": "Иван", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "И."} {"name": "Иван", "order": "FAMILY_FIRST", "family": "Иван", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "И."} {"name": "Иван Александрович Гончаров", "given": "Иван", "middle": "Александрович", "family": "Гончаров", "initials": "И. А. Г."} {"name": "Иван Андонов", "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": "Иван Петрович Абрамович", "given": "Иван", "middle": "Петрович", "family": "Абрамович", "initials": "И. П. А."} {"name": "Иван Радовановић", "given": "Иван", "family": "Радовановић", "initials": "И. Р."} {"name": "Иван Радулов", "given": "Иван", "family": "Радулов", "initials": "И. Р."} {"name": "Иван Савельевич Фатьянов", "given": "Иван", "middle": "Савельевич", "family": "Фатьянов", "initials": "И. С. Ф."} {"name": "Иваницкий, Николай Иванович", "given": "Николай", "middle": "Иванович", "family": "Иваницкий", "initials": "Н. И. И."} {"name": "Иванов Константин Васильевич", "order": "FAMILY_FIRST", "given": "Константин", "middle": "Васильевич", "family": "Иванов", "initials": "К. В. И."} {"name": "Иванов, Артемий Васильевич", "given": "Артемий", "middle": "Васильевич", "family": "Иванов", "initials": "А. В. И."} {"name": "Иванов, Константин Васильевич", "given": "Константин", "middle": "Васильевич", "family": "Иванов", "initials": "К. В. И."} {"name": "Игор Матић", "given": "Игор", "family": "Матић", "initials": "И. М."} {"name": "Игорь Александрович Нетто", "given": "Игорь", "middle": "Александрович", "family": "Нетто", "initials": "И. А. Н."} {"name": "Игорь Дмитриевич Серебряков", "given": "Игорь", "middle": "Дмитриевич", "family": "Серебряков", "initials": "И. Д. С."} {"name": "Илемпи Корнилова", "given": "Илемпи", "family": "Корнилова", "initials": "И. К."} {"name": "Илија Петковић", "given": "Илија", "family": "Петковић", "initials": "И. П."} {"name": "Илья Георгиевич Коробков", "given": "Илья", "middle": "Георгиевич", "family": "Коробков", "initials": "И. Г. К."} {"name": "Иосиф Владимирович Гурко", "given": "Иосиф", "middle": "Владимирович", "family": "Гурко", "initials": "И. В. Г."} {"name": "Ирена Марковић", "given": "Ирена", "family": "Марковић", "initials": "И. М."} {"name": "Ирене Адлер", "given": "Ирене", "family": "Адлер", "initials": "И. А."} {"name": "Ирина Амосова", "given": "Ирина", "family": "Амосова", "initials": "И. А."} {"name": "Ирина Яҡупова", "given": "Ирина", "family": "Яҡупова", "initials": "И. Я."} {"name": "Йордан Мутафчиев", "given": "Йордан", "family": "Мутафчиев", "initials": "Й. М."} {"name": "Кавалёў Мікалай Аляксандр", "order": "FAMILY_FIRST", "given": "Мікалай", "middle": "Аляксандр", "family": "Кавалёў", "initials": "М. А. К."} {"name": "Кавалёў, Мікалай Аляксандр", "given": "Мікалай", "middle": "Аляксандр", "family": "Кавалёў", "initials": "М. А. К."} {"name": "Камен Петков", "given": "Камен", "family": "Петков", "initials": "К. П."} {"name": "Каныкей Алымбекова", "given": "Каныкей", "family": "Алымбекова", "initials": "К. А."} {"name": "Карина Горичева", "given": "Карина", "family": "Горичева", "initials": "К. Г."} {"name": "Касым Жоомарт Токаев", "given": "Касым", "middle": "Жоомарт", "family": "Токаев", "initials": "К. Ж. Т."} {"name": "Кате Мюллер", "given": "Кате", "family": "Мюллер", "initials": "К. М."} {"name": "Кати Мюлер", "given": "Кати", "family": "Мюлер", "initials": "К. М."} {"name": "Кейт Мюллер", "given": "Кейт", "family": "Мюллер", "initials": "К. М."} {"name": "Кете Милер", "given": "Кете", "family": "Милер", "initials": "К. М."} {"name": "Кете Мюллер", "given": "Кете", "family": "Мюллер", "initials": "К. М."} {"name": "Кетэ Мюлер", "given": "Кетэ", "family": "Мюлер", "initials": "К. М."} {"name": "Кирилл Афанасьевич Мерецков", "given": "Кирилл", "middle": "Афанасьевич", "family": "Мерецков", "initials": "К. А. М."} {"name": "Кислов, Александр Степанович", "given": "Александр", "middle": "Степанович", "family": "Кислов", "initials": "А. С. К."} {"name": "Козловский, Фёдор Алексеевич", "given": "Фёдор", "middle": "Алексеевич", "family": "Козловский", "initials": "Ф. А. К."} {"name": "Колкутин, Андрей Борисович", "given": "Андрей", "middle": "Борисович", "family": "Колкутин", "initials": "А. Б. К."} {"name": "Комиссаров Геннадий Германович", "order": "FAMILY_FIRST", "given": "Геннадий", "middle": "Германович", "family": "Комиссаров", "initials": "Г. Г. К."} {"name": "Константин Аврамов", "given": "Константин", "family": "Аврамов", "initials": "К. А."} {"name": "Константин Васильевич Иванов", "given": "Константин", "middle": "Васильевич", "family": "Иванов", "initials": "К. В. И."} {"name": "Константин Константинов", "given": "Константин", "family": "Константинов", "initials": "К. К."} {"name": "Константин Марков", "given": "Константин", "family": "Марков", "initials": "К. М."} {"name": "Константин Михайлович Попов", "given": "Константин", "middle": "Михайлович", "family": "Попов", "initials": "К. М. П."} {"name": "Корнилова Илемпи", "order": "FAMILY_FIRST", "given": "Илемпи", "family": "Корнилова", "initials": "И. К."} {"name": "Корнилова, Илемпи", "given": "Илемпи", "family": "Корнилова", "initials": "И. К."} {"name": "Коробков, Илья Георгиевич", "given": "Илья", "middle": "Георгиевич", "family": "Коробков", "initials": "И. Г. К."} {"name": "Корягин, Дмитрий Александрович", "given": "Дмитрий", "middle": "Александрович", "family": "Корягин", "initials": "Д. А. К."} {"name": "Коста Алексић", "given": "Коста", "family": "Алексић", "initials": "К. А."} {"name": "Коста Михаиловић", "given": "Коста", "family": "Михаиловић", "initials": "К. М."} {"name": "Костин, Иван Алексеевич", "given": "Иван", "middle": "Алексеевич", "family": "Костин", "initials": "И. А. К."} {"name": "Красимир Спасов", "given": "Красимир", "family": "Спасов", "initials": "К. С."} {"name": "Крсте", "given": "Крсте", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "К."} {"name": "Крсте", "order": "FAMILY_FIRST", "family": "Крсте", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "К."} {"name": "Крсте Мисирков", "given": "Крсте", "family": "Мисирков", "initials": "К. М."} {"name": "Крсте Петков Мисирков", "given": "Крсте", "middle": "Петков", "family": "Мисирков", "initials": "К. П. М."} {"name": "Крум Дерменджиев", "given": "Крум", "family": "Дерменджиев", "initials": "К. Д."} {"name": "Ксения Сергеевна Александрова", "given": "Ксения", "middle": "Сергеевна", "family": "Александрова", "initials": "К. С. А."} {"name": "Кузнецов, Александр Петрович", "given": "Александр", "middle": "Петрович", "family": "Кузнецов", "initials": "А. П. К."} {"name": "Кузнецов, Евгений Николаевич", "given": "Евгений", "middle": "Николаевич", "family": "Кузнецов", "initials": "Е. Н. К."} {"name": "Купцов Иван Васильевич", "order": "FAMILY_FIRST", "given": "Иван", "middle": "Васильевич", "family": "Купцов", "initials": "И. В. К."} {"name": "Купцов, Иван Васильевич", "given": "Иван", "middle": "Васильевич", "family": "Купцов", "initials": "И. В. К."} {"name": "Кухаренко, Александр Александрович", "given": "Александр", "middle": "Александрович", "family": "Кухаренко", "initials": "А. А. К."} {"name": "Кучма Леонід Данилович", "order": "FAMILY_FIRST", "given": "Леонід", "middle": "Данилович", "family": "Кучма", "initials": "Л. Д. К."} {"name": "Кэйт Мюллер", "given": "Кэйт", "family": "Мюллер", "initials": "К. М."} {"name": "Кэти Мюлер", "given": "Кэти", "family": "Мюлер", "initials": "К. М."} {"name": "Лев Семёнович Рубинштейн", "given": "Лев", "middle": "Семёнович", "family": "Рубинштейн", "initials": "Л. С. Р."} {"name": "Левандовский, Анатолий Петрович", "given": "Анатолий", "middle": "Петрович", "family": "Левандовский", "initials": "А. П. Л."} {"name": "Леонид Иванович Иванов", "given": "Леонид", "middle": "Иванович", "family": "Иванов", "initials": "Л. И. И."} {"name": "Леонид Эдуардович Слуцкий", "given": "Леонид", "middle": "Эдуардович", "family": "Слуцкий", "initials": "Л. Э. С."} {"name": "Липицкий, Анатолий Григорьевич", "given": "Анатолий", "middle": "Григорьевич", "family": "Липицкий", "initials": "А. Г. Л."} {"name": "Лукань Володимир", "order": "FAMILY_FIRST", "given": "Володимир", "family": "Лукань", "initials": "В. Л."} {"name": "Луньов Святослав Ігорович", "order": "FAMILY_FIRST", "given": "Святослав", "middle": "Ігорович", "family": "Луньов", "initials": "С. І. Л."} {"name": "Любовь Митрофановна Шуляк", "given": "Любовь", "middle": "Митрофановна", "family": "Шуляк", "initials": "Л. М. Ш."} {"name": "Любомир Кръстанов", "given": "Любомир", "family": "Кръстанов", "initials": "Л. К."} {"name": "Мария Валерьевна Мазина", "given": "Мария", "middle": "Валерьевна", "family": "Мазина", "initials": "М. В. М."} {"name": "Мария Петрова", "given": "Мария", "family": "Петрова", "initials": "М. П."} {"name": "Марковић Ирена", "order": "FAMILY_FIRST", "given": "Ирена", "family": "Марковић", "initials": "И. М."} {"name": "Марковић, Ирена", "given": "Ирена", "family": "Марковић", "initials": "И. М."} {"name": "Марія Панчишин", "given": "Марія", "family": "Панчишин", "initials": "М. П."} {"name": "Матвеев, Герман Иванович", "given": "Герман", "middle": "Иванович", "family": "Матвеев", "initials": "Г. И. М."} {"name": "Медведев, Николай Дмитриевич", "given": "Николай", "middle": "Дмитриевич", "family": "Медведев", "initials": "Н. Д. М."} {"name": "Мельник Анна", "order": "FAMILY_FIRST", "given": "Анна", "family": "Мельник", "initials": "А. М."} {"name": "Микола Козленко", "given": "Микола", "family": "Козленко", "initials": "М. К."} {"name": "Миленковић др Слађана Вера млађи, дипл. инж.", "order": "FAMILY_FIRST", "title": "дипл. инж.", "given": "др", "middle": "Слађана Вера", "family": "Миленковић", "suffix": "млађи", "initials": "д. С. В. М.", "capitalized_force": "Дипл. Инж. Др Слађана Вера Миленковић Млађи"} {"name": "Миленковић, Слађана Вера", "given": "Слађана", "middle": "Вера", "family": "Миленковић", "initials": "С. В. М."} {"name": "Милер Кете", "order": "FAMILY_FIRST", "given": "Кете", "family": "Милер", "initials": "К. М."} {"name": "Милер, Кете", "given": "Кете", "family": "Милер", "initials": "К. М."} {"name": "Милко Бјелица", "given": "Милко", "family": "Бјелица", "initials": "М. Б."} {"name": "Миодраг Павловић", "given": "Миодраг", "family": "Павловић", "initials": "М. П."} {"name": "Мирослав Радовановић", "given": "Мирослав", "family": "Радовановић", "initials": "М. Р."} {"name": "Мисирков Крсте", "order": "FAMILY_FIRST", "given": "Крсте", "family": "Мисирков", "initials": "К. М."} {"name": "Мисирков Крсте Петков", "order": "FAMILY_FIRST", "given": "Крсте", "middle": "Петков", "family": "Мисирков", "initials": "К. П. М."} {"name": "Мисирков, Крсте", "given": "Крсте", "family": "Мисирков", "initials": "К. М."} {"name": "Мисирков, Крсте Петков", "given": "Крсте", "middle": "Петков", "family": "Мисирков", "initials": "К. П. М."} {"name": "Михаил Дмитриевич Чулков", "given": "Михаил", "middle": "Дмитриевич", "family": "Чулков", "initials": "М. Д. Ч."} {"name": "Михаил Михайлович Зощенко", "given": "Михаил", "middle": "Михайлович", "family": "Зощенко", "initials": "М. М. З."} {"name": "Михаил Степанович Полунин", "given": "Михаил", "middle": "Степанович", "family": "Полунин", "initials": "М. С. П."} {"name": "Мија Јовановић", "given": "Мија", "family": "Јовановић", "initials": "М. Ј."} {"name": "Мовчан Борис Олексійович", "order": "FAMILY_FIRST", "given": "Борис", "middle": "Олексійович", "family": "Мовчан", "initials": "Б. О. М."} {"name": "Моисеев, Александр Алексеевич", "given": "Александр", "middle": "Алексеевич", "family": "Моисеев", "initials": "А. А. М."} {"name": "Мырза Чыңгыз Айтматов Исхак Раззаков уулу Төрөкул Махмудов Парламент мүчөсү", "given": "Мырза", "middle": "Чыңгыз Айтматов Исхак Раззаков уулу Төрөкул Махмудов Парламент", "family": "мүчөсү", "initials": "М. Ч. А. И. Р. у. Т. М. П. м.", "capitalized_force": "Мырза Чыңгыз Айтматов Исхак Раззаков Уулу Төрөкул Махмудов Парламент Мүчөсү"} {"name": "Мэри Сю Хамиш Ватсон", "given": "Мэри", "middle": "Сю Хамиш", "family": "Ватсон", "initials": "М. С. Х. В."} {"name": "Мюлер Кетэ", "order": "FAMILY_FIRST", "given": "Кетэ", "family": "Мюлер", "initials": "К. М."} {"name": "Мюлер Кэти", "order": "FAMILY_FIRST", "given": "Кэти", "family": "Мюлер", "initials": "К. М."} {"name": "Мюлер, Кати", "given": "Кати", "family": "Мюлер", "initials": "К. М."} {"name": "Мюлер, Кати", "order": "FAMILY_FIRST", "given": "Кати", "family": "Мюлер", "initials": "К. М."} {"name": "Мюлер, Кетэ", "given": "Кетэ", "family": "Мюлер", "initials": "К. М."} {"name": "Мюллер Кате", "order": "FAMILY_FIRST", "given": "Кате", "family": "Мюллер", "initials": "К. М."} {"name": "Мюллер Кейт", "order": "FAMILY_FIRST", "given": "Кейт", "family": "Мюллер", "initials": "К. М."} {"name": "Мюллер Кете", "order": "FAMILY_FIRST", "given": "Кете", "family": "Мюллер", "initials": "К. М."} {"name": "Мюллер Кэйт", "order": "FAMILY_FIRST", "given": "Кэйт", "family": "Мюллер", "initials": "К. М."} {"name": "Мюллер, Кате", "given": "Кате", "family": "Мюллер", "initials": "К. М."} {"name": "Мюллер, Кейт", "given": "Кейт", "family": "Мюллер", "initials": "К. М."} {"name": "Мюллер, Кете", "given": "Кете", "family": "Мюллер", "initials": "К. М."} {"name": "Мікалай Аляксандр Кавалёў", "given": "Мікалай", "middle": "Аляксандр", "family": "Кавалёў", "initials": "М. А. К."} {"name": "Мұхамеджан Әсем", "order": "FAMILY_FIRST", "given": "Әсем", "family": "Мұхамеджан", "initials": "Ә. М."} {"name": "Мұхамеджан, Әсем", "given": "Әсем", "family": "Мұхамеджан", "initials": "Ә. М."} {"name": "Найден Тодоров", "given": "Найден", "family": "Тодоров", "initials": "Н. Т."} {"name": "Насонов, Николай Викторович", "given": "Николай", "middle": "Викторович", "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": "Неда Украден", "given": "Неда", "family": "Украден", "initials": "Н. У."} {"name": "Немања Максимовић", "given": "Немања", "family": "Максимовић", "initials": "Н. М."} {"name": "Немања Милетић", "given": "Немања", "family": "Милетић", "initials": "Н. М."} {"name": "Немелівський Михайло", "order": "FAMILY_FIRST", "given": "Михайло", "family": "Немелівський", "initials": "М. Н."} {"name": "Нено Неделчев", "given": "Нено", "family": "Неделчев", "initials": "Н. Н."} {"name": "Никита Сергеевич Хрущёв", "given": "Никита", "middle": "Сергеевич", "family": "Хрущёв", "initials": "Н. С. Х."} {"name": "Никитин, Алексей Алексеевич", "given": "Алексей", "middle": "Алексеевич", "family": "Никитин", "initials": "А. А. Н."} {"name": "Никола Мараш", "given": "Никола", "family": "Мараш", "initials": "Н. М."} {"name": "Никола Славков", "given": "Никола", "family": "Славков", "initials": "Н. С."} {"name": "Николай Александрович Боярский", "given": "Николай", "middle": "Александрович", "family": "Боярский", "initials": "Н. А. Б."} {"name": "Николай Александрович Энгельгардт", "given": "Николай", "middle": "Александрович", "family": "Энгельгардт", "initials": "Н. А. Э."} {"name": "Николай Андреевич Рыбин", "given": "Николай", "middle": "Андреевич", "family": "Рыбин", "initials": "Н. А. Р."} {"name": "Николай Андреевич Смирнов", "given": "Николай", "middle": "Андреевич", "family": "Смирнов", "initials": "Н. А. С."} {"name": "Николай Васильевич Владыкин", "given": "Николай", "middle": "Васильевич", "family": "Владыкин", "initials": "Н. В. В."} {"name": "Николай Георгиевич Зырин", "given": "Николай", "middle": "Георгиевич", "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": "Николай Николаевич Пикус", "given": "Николай", "middle": "Николаевич", "family": "Пикус", "initials": "Н. Н. П."} {"name": "Николай Петрович Вагнер", "given": "Николай", "middle": "Петрович", "family": "Вагнер", "initials": "Н. П. В."} {"name": "Николай Порфирьевич Архангельский", "given": "Николай", "middle": "Порфирьевич", "family": "Архангельский", "initials": "Н. П. А."} {"name": "Николай Степанович Гумилёв", "given": "Николай", "middle": "Степанович", "family": "Гумилёв", "initials": "Н. С. Г."} {"name": "Никулина, Евдокия Андреевна", "given": "Евдокия", "middle": "Андреевна", "family": "Никулина", "initials": "Е. А. Н."} {"name": "Нина Александровна Андреева", "given": "Нина", "middle": "Александровна", "family": "Андреева", "initials": "Н. А. А."} {"name": "Нина Михайловна Литвинова", "given": "Нина", "middle": "Михайловна", "family": "Литвинова", "initials": "Н. М. Л."} {"name": "Ноён Азжаргал Төмөр", "given": "Ноён", "middle": "Азжаргал", "family": "Төмөр", "initials": "Н. А. Т."} {"name": "Нұрлан", "given": "Нұрлан", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "Н."} {"name": "Нұрлан", "order": "FAMILY_FIRST", "family": "Нұрлан", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "Н."} {"name": "Оксана Сергіївна Баюл", "given": "Оксана", "middle": "Сергіївна", "family": "Баюл", "initials": "О. С. Б."} {"name": "Олег Литвиненко", "given": "Олег", "family": "Литвиненко", "initials": "О. Л."} {"name": "Олег Сакиркин", "given": "Олег", "family": "Сакиркин", "initials": "О. С."} {"name": "Олександр Музичко", "given": "Олександр", "family": "Музичко", "initials": "О. М."} {"name": "Олександр Положинський", "given": "Олександр", "family": "Положинський", "initials": "О. П."} {"name": "Олексій Олексійович Гончаренко", "given": "Олексій", "middle": "Олексійович", "family": "Гончаренко", "initials": "О. О. Г."} {"name": "Олимпиада Ивановна Шмидт", "given": "Олимпиада", "middle": "Ивановна", "family": "Шмидт", "initials": "О. И. Ш."} {"name": "Ольга Игоревна Кабо", "given": "Ольга", "middle": "Игоревна", "family": "Кабо", "initials": "О. И. К."} {"name": "Ольга Рыпакова", "given": "Ольга", "family": "Рыпакова", "initials": "О. Р."} {"name": "Ольховский, Валерий Сергеевич", "given": "Валерий", "middle": "Сергеевич", "family": "Ольховский", "initials": "В. С. О."} {"name": "Павел Алексеевич Рубцов", "given": "Павел", "middle": "Алексеевич", "family": "Рубцов", "initials": "П. А. Р."} {"name": "Павел Владимирович Сорокин", "given": "Павел", "middle": "Владимирович", "family": "Сорокин", "initials": "П. В. С."} {"name": "Павел Ильич Таланкин", "given": "Павел", "middle": "Ильич", "family": "Таланкин", "initials": "П. И. Т."} {"name": "Павел Михайлович Волков", "given": "Павел", "middle": "Михайлович", "family": "Волков", "initials": "П. М. В."} {"name": "Павел Попов", "given": "Павел", "family": "Попов", "initials": "П. П."} {"name": "Павел Феодосьевич Кальницкий", "given": "Павел", "middle": "Феодосьевич", "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": "Петко Петков", "given": "Петко", "family": "Петков", "initials": "П. П."} {"name": "Петренко Анна Марія Богдан-Зиновій, доц., проф.", "title": "доц. проф.", "given": "Петренко", "middle": "Анна Марія", "family": "Богдан-Зиновій", "initials": "П. А. М. Б.", "capitalized_force": "Доц. Проф. Петренко Анна Марія Богдан-Зиновій"} {"name": "Петрова, Мария", "given": "Мария", "family": "Петрова", "initials": "М. П."} {"name": "Петрова, Мария", "order": "FAMILY_FIRST", "given": "Мария", "family": "Петрова", "initials": "М. П."} {"name": "Питер Брейгель мл.", "given": "Питер", "family": "Брейгель", "suffix": "мл.", "initials": "П. Б.", "capitalized_force": "Питер Брейгель Мл."} {"name": "Повалишин, Владимир Михайлович", "given": "Владимир", "middle": "Михайлович", "family": "Повалишин", "initials": "В. М. П."} {"name": "Полунін Михайло Степанович", "order": "FAMILY_FIRST", "given": "Михайло", "middle": "Степанович", "family": "Полунін", "initials": "М. С. П."} {"name": "Полянский, Борис Владимирович", "given": "Борис", "middle": "Владимирович", "family": "Полянский", "initials": "Б. В. П."} {"name": "Попов, Андрей Александрович", "given": "Андрей", "middle": "Александрович", "family": "Попов", "initials": "А. А. П."} {"name": "Попов, Василий Петрович", "given": "Василий", "middle": "Петрович", "family": "Попов", "initials": "В. П. П."} {"name": "Поповић Јован Драган", "order": "FAMILY_FIRST", "given": "Јован", "middle": "Драган", "family": "Поповић", "initials": "Ј. Д. П."} {"name": "Поповић, Јован Драган", "given": "Јован", "middle": "Драган", "family": "Поповић", "initials": "Ј. Д. П."} {"name": "Преображенский, Василий Алексеевич", "given": "Василий", "middle": "Алексеевич", "family": "Преображенский", "initials": "В. А. П."} {"name": "Проф. Док. Ада Корнелиа Сезар Мартин вон Брухи Гонзалес Доминго", "title": "Проф. Док.", "given": "Ада", "middle": "Корнелиа Сезар Мартин вон Брухи Гонзалес", "family": "Доминго", "initials": "А. К. С. М. в. Б. Г. Д.", "capitalized_force": "Проф. Док. Ада Корнелиа Сезар Мартин Вон Брухи Гонзалес Доминго"} {"name": "Проф. Доктор. Алтынай Сулайманова Асель Сартбаева ван ден Вольф Бекер Шмидт Илимдин кандидаты", "title": "Проф. Доктор.", "given": "Алтынай", "middle": "Сулайманова Асель Сартбаева ван ден Вольф Бекер Шмидт Илимдин", "family": "кандидаты", "initials": "А. С. А. С. в. д. В. Б. Ш. И. к.", "capitalized_force": "Проф. Доктор. Алтынай Сулайманова Асель Сартбаева Ван Ден Вольф Бекер Шмидт Илимдин Кандидаты"} {"name": "Проф. Др. Ада Корнелиа Эва Софиа вон Брюхл Жр, Анагаах ухааны доктор", "title": "Проф.", "given": "Анагаах", "middle": "ухааны доктор", "family": "Др. Ада Корнелиа Эва Софиа вон Брюхл Жр", "initials": "А. у. д. Д. А. К. Э. С. в. Б. Ж.", "capitalized_force": "Проф. Анагаах Ухааны Доктор Др. Ада Корнелиа Эва Софиа Вон Брюхл Жр"} {"name": "Пётр Васильевич Быков", "given": "Пётр", "middle": "Васильевич", "family": "Быков", "initials": "П. В. Б."} {"name": "Пётр Иванович Почивалов", "given": "Пётр", "middle": "Иванович", "family": "Почивалов", "initials": "П. И. П."} {"name": "Пётр Николаевич Краснов", "given": "Пётр", "middle": "Николаевич", "family": "Краснов", "initials": "П. Н. К."} {"name": "Пётр Олегович Толстой", "given": "Пётр", "middle": "Олегович", "family": "Толстой", "initials": "П. О. Т."} {"name": "Пётр Петрович Буренин", "given": "Пётр", "middle": "Петрович", "family": "Буренин", "initials": "П. П. Б."} {"name": "Равенская Вольга Караліна", "order": "FAMILY_FIRST", "given": "Вольга", "middle": "Караліна", "family": "Равенская", "initials": "В. К. Р."} {"name": "Равенская, Вольга Караліна", "given": "Вольга", "middle": "Караліна", "family": "Равенская", "initials": "В. К. Р."} {"name": "Рагозин, Евгений Иванович", "given": "Евгений", "middle": "Иванович", "family": "Рагозин", "initials": "Е. И. Р."} {"name": "Радован Панков", "given": "Радован", "family": "Панков", "initials": "Р. П."} {"name": "Разумовский, Юрий Георгиевич", "given": "Юрий", "middle": "Георгиевич", "family": "Разумовский", "initials": "Ю. Г. Р."} {"name": "Ранко Поповић", "given": "Ранко", "family": "Поповић", "initials": "Р. П."} {"name": "Расницын, Александр Павлович", "given": "Александр", "middle": "Павлович", "family": "Расницын", "initials": "А. П. Р."} {"name": "Розенфельд, Михаил Константинович", "given": "Михаил", "middle": "Константинович", "family": "Розенфельд", "initials": "М. К. Р."} {"name": "Рубин, Павел Германович", "given": "Павел", "middle": "Германович", "family": "Рубин", "initials": "П. Г. Р."} {"name": "Рубинштейн, Лев Семёнович", "given": "Лев", "middle": "Семёнович", "family": "Рубинштейн", "initials": "Л. С. Р."} {"name": "Румяна Бъчварова", "given": "Румяна", "family": "Бъчварова", "initials": "Р. Б."} {"name": "Рыбаков, Ярослав Владимирович", "given": "Ярослав", "middle": "Владимирович", "family": "Рыбаков", "initials": "Я. В. Р."} {"name": "Рязанов, Евгений Фёдорович", "given": "Евгений", "middle": "Фёдорович", "family": "Рязанов", "initials": "Е. Ф. Р."} {"name": "Саблин, Александр Алексеевич", "given": "Александр", "middle": "Алексеевич", "family": "Саблин", "initials": "А. А. С."} {"name": "Саблин, Валерий Михайлович", "given": "Валерий", "middle": "Михайлович", "family": "Саблин", "initials": "В. М. С."} {"name": "Самуил Хаимович Агурский", "given": "Самуил", "middle": "Хаимович", "family": "Агурский", "initials": "С. Х. А."} {"name": "Сафонов, Эрнст Иванович", "given": "Эрнст", "middle": "Иванович", "family": "Сафонов", "initials": "Э. И. С."} {"name": "Свобода Бъчварова", "given": "Свобода", "family": "Бъчварова", "initials": "С. Б."} {"name": "Семён Денисович Игнатьев", "given": "Семён", "middle": "Денисович", "family": "Игнатьев", "initials": "С. Д. И."} {"name": "Сергей", "given": "Сергей", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "С."} {"name": "Сергей", "order": "FAMILY_FIRST", "family": "Сергей", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "С."} {"name": "Сергей Александрович Кирдяпкин", "given": "Сергей", "middle": "Александрович", "family": "Кирдяпкин", "initials": "С. А. К."} {"name": "Сергей Дмитриевич Остапов", "given": "Сергей", "middle": "Дмитриевич", "family": "Остапов", "initials": "С. Д. О."} {"name": "Сергей Каримов", "given": "Сергей", "family": "Каримов", "initials": "С. К."} {"name": "Сергей Михайлович Эйзенштейн", "given": "Сергей", "middle": "Михайлович", "family": "Эйзенштейн", "initials": "С. М. Э."} {"name": "Середа Григорій Кузьмич", "order": "FAMILY_FIRST", "given": "Григорій", "middle": "Кузьмич", "family": "Середа", "initials": "Г. К. С."} {"name": "Сесилия Штобер", "given": "Сесилия", "family": "Штобер", "initials": "С. Ш."} {"name": "Сидоров Иван Петрович", "given": "Сидоров", "middle": "Иван", "family": "Петрович", "initials": "С. И. П."} {"name": "Симона Пейчева", "given": "Симона", "family": "Пейчева", "initials": "С. П."} {"name": "Синбад", "given": "Синбад", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "С."} {"name": "Синбад", "order": "FAMILY_FIRST", "family": "Синбад", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "С."} {"name": "Синдбад", "given": "Синдбад", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "С."} {"name": "Синдбад", "order": "FAMILY_FIRST", "family": "Синдбад", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "С."} {"name": "Синиша Михајловић", "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": "Станіслав Станкевич", "given": "Станіслав", "family": "Станкевич", "initials": "С. С."} {"name": "Старовойтов, Андрей Васильевич", "given": "Андрей", "middle": "Васильевич", "family": "Старовойтов", "initials": "А. В. С."} {"name": "Стеван Плетикосић", "given": "Стеван", "family": "Плетикосић", "initials": "С. П."} {"name": "Стеван Хорват", "given": "Стеван", "family": "Хорват", "initials": "С. Х."} {"name": "Степаненко Василиса Іллівна", "order": "FAMILY_FIRST", "given": "Василиса", "middle": "Іллівна", "family": "Степаненко", "initials": "В. І. С."} {"name": "Стефан Огнянов", "given": "Стефан", "family": "Огнянов", "initials": "С. О."} {"name": "Стефан Петров", "given": "Стефан", "family": "Петров", "initials": "С. П."} {"name": "Стефка Георгиева", "given": "Стефка", "family": "Георгиева", "initials": "С. Г."} {"name": "Стобер Зазилиа Хамиш", "order": "FAMILY_FIRST", "given": "Зазилиа", "middle": "Хамиш", "family": "Стобер", "initials": "З. Х. С."} {"name": "Стобер Зазилия Ҳамиш", "order": "FAMILY_FIRST", "given": "Зазилия", "middle": "Ҳамиш", "family": "Стобер", "initials": "З. Ҳ. С."} {"name": "Стобер Зазилія Хеміш", "order": "FAMILY_FIRST", "given": "Зазилія", "middle": "Хеміш", "family": "Стобер", "initials": "З. Х. С."} {"name": "Стобер, Зазилиа Хамиш", "given": "Зазилиа", "middle": "Хамиш", "family": "Стобер", "initials": "З. Х. С."} {"name": "Стоян Александров", "given": "Стоян", "family": "Александров", "initials": "С. А."} {"name": "Стоян Мурин", "given": "Стоян", "family": "Мурин", "initials": "С. М."} {"name": "Страхил Попов", "given": "Страхил", "family": "Попов", "initials": "С. П."} {"name": "Страшимир Димитров", "given": "Страшимир", "family": "Димитров", "initials": "С. Д."} {"name": "Сыймык", "given": "Сыймык", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "С."} {"name": "Сыймык", "order": "FAMILY_FIRST", "family": "Сыймык", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "С."} {"name": "Сёмин, Виталий Николаевич", "given": "Виталий", "middle": "Николаевич", "family": "Сёмин", "initials": "В. Н. С."} {"name": "Сіндбад", "given": "Сіндбад", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "С."} {"name": "Сіндбад", "order": "FAMILY_FIRST", "family": "Сіндбад", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "С."} {"name": "Татьяна Васильевна Костина", "given": "Татьяна", "middle": "Васильевна", "family": "Костина", "initials": "Т. В. К."} {"name": "Татьяна Николаевна Семенова", "given": "Татьяна", "middle": "Николаевна", "family": "Семенова", "initials": "Т. Н. С."} {"name": "Теофил Панчић", "given": "Теофил", "family": "Панчић", "initials": "Т. П."} {"name": "Тина Крајишник", "given": "Тина", "family": "Крајишник", "initials": "Т. К."} {"name": "Тијана Богићевић", "given": "Тијана", "family": "Богићевић", "initials": "Т. Б."} {"name": "Токаев Касым Жоомарт", "order": "FAMILY_FIRST", "given": "Касым", "middle": "Жоомарт", "family": "Токаев", "initials": "К. Ж. Т."} {"name": "Тәуекел Заңғар", "order": "FAMILY_FIRST", "given": "Заңғар", "family": "Тәуекел", "initials": "З. Т."} {"name": "Тәуекел, Заңғар Ермекұлы", "given": "Заңғар", "middle": "Ермекұлы", "family": "Тәуекел", "initials": "З. Е. Т."} {"name": "Төле Кенжебаев", "given": "Төле", "family": "Кенжебаев", "initials": "Т. К."} {"name": "Төлеу Шаханов", "given": "Төлеу", "family": "Шаханов", "initials": "Т. Ш."} {"name": "Төмөр Ноён Азжаргал", "order": "FAMILY_FIRST", "given": "Ноён", "middle": "Азжаргал", "family": "Төмөр", "initials": "Н. А. Т."} {"name": "Төмөр, Азжаргал", "given": "Азжаргал", "family": "Төмөр", "initials": "А. Т."} {"name": "Ульяна Петровна Буянова", "given": "Ульяна", "middle": "Петровна", "family": "Буянова", "initials": "У. П. Б."} {"name": "Урал", "given": "Урал", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "У."} {"name": "Урал", "order": "FAMILY_FIRST", "family": "Урал", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "У."} {"name": "Урош Рачић", "given": "Урош", "family": "Рачић", "initials": "У. Р."} {"name": "Фаттахов Азамат Салауат улы", "order": "FAMILY_FIRST", "given": "Азамат", "middle": "Салауат улы", "family": "Фаттахов", "initials": "А. С. у. Ф.", "capitalized_force": "Азамат Салауат Улы Фаттахов"} {"name": "Фаттахов, Азамат Салауат улы", "given": "Азамат", "middle": "Салауат улы", "family": "Фаттахов", "initials": "А. С. у. Ф.", "capitalized_force": "Азамат Салауат Улы Фаттахов"} {"name": "Фаусек, Юлия Ивановна", "given": "Юлия", "middle": "Ивановна", "family": "Фаусек", "initials": "Ю. И. Ф."} {"name": "Федоренко Сергей Васильевич", "order": "FAMILY_FIRST", "given": "Сергей", "middle": "Васильевич", "family": "Федоренко", "initials": "С. В. Ф."} {"name": "Федоренко, Сергей Васильевич", "given": "Сергей", "middle": "Васильевич", "family": "Федоренко", "initials": "С. В. Ф."} {"name": "Федоров Іван Сергійович", "order": "FAMILY_FIRST", "given": "Іван", "middle": "Сергійович", "family": "Федоров", "initials": "І. С. Ф."} {"name": "Федот Васильевич Сычков", "given": "Федот", "middle": "Васильевич", "family": "Сычков", "initials": "Ф. В. С."} {"name": "Филип Ђуричић", "given": "Филип", "family": "Ђуричић", "initials": "Ф. Ђ."} {"name": "Фоменков, Валерий Иванович", "given": "Валерий", "middle": "Иванович", "family": "Фоменков", "initials": "В. И. Ф."} {"name": "Френкель Георгий Львович", "order": "FAMILY_FIRST", "given": "Георгий", "middle": "Львович", "family": "Френкель", "initials": "Г. Л. Ф."} {"name": "ХОСЕ И МАРИЯ САНТОС", "given": "ХОСЕ И МАРИЯ", "family": "САНТОС", "initials": "Х. М. С.", "capitalized": "Хосе и Мария Сантос"} {"name": "Химченко, Виктор Иванович", "given": "Виктор", "middle": "Иванович", "family": "Химченко", "initials": "В. И. Х."} {"name": "Хосе И Мария Сантос", "given": "Хосе И Мария", "family": "Сантос", "initials": "Х. М. С.", "capitalized_force": "Хосе и Мария Сантос"} {"name": "Хржановский, Андрей Юрьевич", "given": "Андрей", "middle": "Юрьевич", "family": "Хржановский", "initials": "А. Ю. Х."} {"name": "Христо Боев", "given": "Христо", "family": "Боев", "initials": "Х. Б."} {"name": "Христо Бончев", "given": "Христо", "family": "Бончев", "initials": "Х. Б."} {"name": "Христо Далчев", "given": "Христо", "family": "Далчев", "initials": "Х. Д."} {"name": "Христо Койчев", "given": "Христо", "family": "Койчев", "initials": "Х. К."} {"name": "Христо Миладинов", "given": "Христо", "family": "Миладинов", "initials": "Х. М."} {"name": "Царук Олександр Васильович", "order": "FAMILY_FIRST", "given": "Олександр", "middle": "Васильович", "family": "Царук", "initials": "О. В. Ц."} {"name": "Цецилия Хэмиш Штёбер", "given": "Цецилия", "middle": "Хэмиш", "family": "Штёбер", "initials": "Ц. Х. Ш."} {"name": "Цецилија Хамиш Штебер", "given": "Цецилија", "middle": "Хамиш", "family": "Штебер", "initials": "Ц. Х. Ш."} {"name": "Цецилија Хемиш Штебер", "given": "Цецилија", "middle": "Хемиш", "family": "Штебер", "initials": "Ц. Х. Ш."} {"name": "Цэцэг Дорж", "given": "Цэцэг", "family": "Дорж", "initials": "Ц. Д."} {"name": "Червенков, Николай Николаевич", "given": "Николай", "middle": "Николаевич", "family": "Червенков", "initials": "Н. Н. Ч."} {"name": "Черепанов, Александр Иванович", "given": "Александр", "middle": "Иванович", "family": "Черепанов", "initials": "А. И. Ч."} {"name": "Чернышёв, Пётр Сергеевич", "given": "Пётр", "middle": "Сергеевич", "family": "Чернышёв", "initials": "П. С. Ч."} {"name": "Шевченко Єва Марія", "order": "FAMILY_FIRST", "given": "Єва", "middle": "Марія", "family": "Шевченко", "initials": "Є. М. Ш."} {"name": "Шевчик, Дмитрий Владимирович", "given": "Дмитрий", "middle": "Владимирович", "family": "Шевчик", "initials": "Д. В. Ш."} {"name": "Шиллер, Николай Николаевич", "given": "Николай", "middle": "Николаевич", "family": "Шиллер", "initials": "Н. Н. Ш."} {"name": "Штебер Цецилија Хамиш", "order": "FAMILY_FIRST", "given": "Цецилија", "middle": "Хамиш", "family": "Штебер", "initials": "Ц. Х. Ш."} {"name": "Штебер Цецилија Хемиш", "order": "FAMILY_FIRST", "given": "Цецилија", "middle": "Хемиш", "family": "Штебер", "initials": "Ц. Х. Ш."} {"name": "Штебер, Цецилија Хамиш", "given": "Цецилија", "middle": "Хамиш", "family": "Штебер", "initials": "Ц. Х. Ш."} {"name": "Штебер, Цецилија Хемиш", "given": "Цецилија", "middle": "Хемиш", "family": "Штебер", "initials": "Ц. Х. Ш."} {"name": "Штобер Зазилия Хамиш", "order": "FAMILY_FIRST", "given": "Зазилия", "middle": "Хамиш", "family": "Штобер", "initials": "З. Х. Ш."} {"name": "Штобер Сесилия", "order": "FAMILY_FIRST", "given": "Сесилия", "family": "Штобер", "initials": "С. Ш."} {"name": "Штобер, Сесилия Хэмиш", "given": "Сесилия", "middle": "Хэмиш", "family": "Штобер", "initials": "С. Х. Ш."} {"name": "Штёбер Цецилия Хэмиш", "order": "FAMILY_FIRST", "given": "Цецилия", "middle": "Хэмиш", "family": "Штёбер", "initials": "Ц. Х. Ш."} {"name": "Штёбер, Цецилия Хэмиш", "given": "Цецилия", "middle": "Хэмиш", "family": "Штёбер", "initials": "Ц. Х. Ш."} {"name": "Шцёбер Зазілія Хэміш", "order": "FAMILY_FIRST", "given": "Зазілія", "middle": "Хэміш", "family": "Шцёбер", "initials": "З. Х. Ш."} {"name": "Шцёбер, Зазілія Хэміш", "given": "Зазілія", "middle": "Хэміш", "family": "Шцёбер", "initials": "З. Х. Ш."} {"name": "Юлия Кръстева", "given": "Юлия", "family": "Кръстева", "initials": "Ю. К."} {"name": "Юлія Волчкова", "given": "Юлія", "family": "Волчкова", "initials": "Ю. В."} {"name": "Юрий Борисович Хржановский", "given": "Юрий", "middle": "Борисович", "family": "Хржановский", "initials": "Ю. Б. Х."} {"name": "Юрий Георгиевич Разумовский", "given": "Юрий", "middle": "Георгиевич", "family": "Разумовский", "initials": "Ю. Г. Р."} {"name": "Юрий Иванович Смирнов", "given": "Юрий", "middle": "Иванович", "family": "Смирнов", "initials": "Ю. И. С."} {"name": "Юрий Ильич Скуратов", "given": "Юрий", "middle": "Ильич", "family": "Скуратов", "initials": "Ю. И. С."} {"name": "Юрий Петрович Любимов", "given": "Юрий", "middle": "Петрович", "family": "Любимов", "initials": "Ю. П. Л."} {"name": "Юрій", "given": "Юрій", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "Ю."} {"name": "Юрій", "order": "FAMILY_FIRST", "family": "Юрій", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "Ю."} {"name": "Янко Павлов", "given": "Янко", "family": "Павлов", "initials": "Я. П."} {"name": "Яҡупова Ирина", "order": "FAMILY_FIRST", "given": "Ирина", "family": "Яҡупова", "initials": "И. Я."} {"name": "Яҡупова, Ирина", "given": "Ирина", "family": "Яҡупова", "initials": "И. Я."} {"name": "ван ден Волф проф. др Александра Ева Сара млађи, дипл. инж.", "order": "FAMILY_FIRST", "title": "дипл. инж.", "given": "ден", "middle": "Волф проф. др Александра Ева Сара", "family": "ван", "suffix": "млађи", "initials": "д. В. п. д. А. Е. С. в.", "capitalized_force": "Дипл. Инж. Ден Волф Проф. Др Александра Ева Сара Ван Млађи"} {"name": "ван ден Вольф Алтынай Сулайманова Асель Сартбаева Илимдин кандидаты", "order": "FAMILY_FIRST", "given": "ден", "middle": "Вольф Алтынай Сулайманова Асель Сартбаева Илимдин кандидаты", "family": "ван", "initials": "д. В. А. С. А. С. И. к. в.", "capitalized_force": "Ден Вольф Алтынай Сулайманова Асель Сартбаева Илимдин Кандидаты Ван"} {"name": "ван ден Вольф Бекер Шмидт, Проф. Доктор. Алтынай Сулайманова Асель Сартбаева Илимдин кандидаты", "title": "Проф. Доктор.", "given": "Алтынай", "middle": "Сулайманова Асель Сартбаева Илимдин кандидаты", "family": "ван ден Вольф Бекер Шмидт", "initials": "А. С. А. С. И. к. в. д. В. Б. Ш.", "capitalized_force": "Проф. Доктор. Алтынай Сулайманова Асель Сартбаева Илимдин Кандидаты Ван Ден Вольф Бекер Шмидт"} {"name": "вон Брухи Гонзалес Доминго Проф. Док. Ада Корнелиа Сезар Мартин", "order": "FAMILY_FIRST", "given": "Брухи", "middle": "Гонзалес Доминго Проф. Док. Ада Корнелиа Сезар Мартин", "family": "вон", "initials": "Б. Г. Д. П. Д. А. К. С. М. в.", "capitalized_force": "Брухи Гонзалес Доминго Проф. Док. Ада Корнелиа Сезар Мартин Вон"} {"name": "вон Брухи Гонзалес Доминго, Проф. Док. Ада Корнелиа Сезар Мартин", "title": "Проф. Док.", "given": "Ада", "middle": "Корнелиа Сезар Мартин", "family": "вон Брухи Гонзалес Доминго", "initials": "А. К. С. М. в. Б. Г. Д.", "capitalized_force": "Проф. Док. Ада Корнелиа Сезар Мартин Вон Брухи Гонзалес Доминго"} {"name": "вон Брюхл Гонзалес Доминго Проф. Др. Ада Корнелиа Эва Софиа Анагаах ухааны доктор", "order": "FAMILY_FIRST", "given": "Брюхл", "middle": "Гонзалес Доминго Проф. Др. Ада Корнелиа Эва Софиа Анагаах ухааны доктор", "family": "вон", "initials": "Б. Г. Д. П. Д. А. К. Э. С. А. у. д. в.", "capitalized_force": "Брюхл Гонзалес Доминго Проф. Др. Ада Корнелиа Эва Софиа Анагаах Ухааны Доктор Вон"} {"name": "г-н Вернер, Иван Асен, проф. д-р", "order": "FAMILY_FIRST", "title": "г-н проф. д-р", "given": "Иван", "middle": "Асен", "family": "Вернер", "initials": "И. А. В.", "capitalized_force": "Г-Н Проф. Д-Р Иван Асен Вернер"} {"name": "г-н Иван Асен Вернер, проф. д-р", "title": "г-н проф. д-р", "given": "Иван", "middle": "Асен", "family": "Вернер", "initials": "И. А. В.", "capitalized_force": "Г-Н Проф. Д-Р Иван Асен Вернер"} {"name": "г-н Иван Петров", "title": "г-н", "given": "Иван", "family": "Петров", "initials": "И. П.", "capitalized_force": "Г-Н Иван Петров"} {"name": "др Слађана Вера Миленковић дипл. инж.", "given": "др", "middle": "Слађана Вера Миленковић дипл.", "family": "инж.", "initials": "д. С. В. М. д. и.", "capitalized_force": "Др Слађана Вера Миленковић Дипл. Инж."} {"name": "проф. Анастасія Єва Софія фон Вольф Гонзалес Домінго, д-р", "title": "проф. д-р", "given": "Анастасія", "middle": "Єва Софія фон Вольф Гонзалес", "family": "Домінго", "initials": "А. Є. С. ф. В. Г. Д.", "capitalized_force": "Проф. Д-Р Анастасія Єва Софія Фон Вольф Гонзалес Домінго"} {"name": "проф. Анна Марія Богдан-Зиновій Петренко доц.", "title": "проф.", "given": "Анна", "middle": "Марія Богдан-Зиновій Петренко", "family": "доц.", "initials": "А. М. Б. П. д.", "capitalized_force": "Проф. Анна Марія Богдан-Зиновій Петренко Доц."} {"name": "проф. Петренко Анна Марія Богдан-Зиновій доц.", "order": "FAMILY_FIRST", "title": "проф.", "given": "Анна", "middle": "Марія Богдан-Зиновій доц.", "family": "Петренко", "initials": "А. М. Б. д. П.", "capitalized_force": "Проф. Анна Марія Богдан-Зиновій Доц. Петренко"} {"name": "проф. д-р Ада Корнелия Цезар Мартин Брюл, доктор", "title": "проф. д-р", "given": "доктор", "family": "Ада Корнелия Цезар Мартин Брюл", "initials": "д. А. К. Ц. М. Б.", "capitalized_force": "Проф. Д-Р Доктор Ада Корнелия Цезар Мартин Брюл"} {"name": "проф. д-р Ана Марија Цезар Мартин ван ден Волф", "title": "проф. д-р", "given": "Ана", "middle": "Марија Цезар Мартин ван ден", "family": "Волф", "initials": "А. М. Ц. М. в. д. В.", "capitalized_force": "Проф. Д-Р Ана Марија Цезар Мартин Ван Ден Волф"} {"name": "проф. д-р Брюл, Ада Корнелия Цезар Мартин, доктор", "order": "FAMILY_FIRST", "title": "проф. д-р", "given": "Ада", "middle": "Корнелия Цезар Мартин", "family": "Брюл", "suffix": "доктор", "ambiguities": ["COMMA_STRUCTURE"], "initials": "А. К. Ц. М. Б.", "capitalized_force": "Проф. Д-Р Ада Корнелия Цезар Мартин Брюл Доктор"} {"name": "проф. д-р Крсте Петков Мисирков", "title": "проф. д-р", "given": "Крсте", "middle": "Петков", "family": "Мисирков", "initials": "К. П. М.", "capitalized_force": "Проф. Д-Р Крсте Петков Мисирков"} {"name": "проф. д-р Мисирков Крсте Петков", "order": "FAMILY_FIRST", "title": "проф. д-р", "given": "Крсте", "middle": "Петков", "family": "Мисирков", "initials": "К. П. М.", "capitalized_force": "Проф. Д-Р Крсте Петков Мисирков"} {"name": "проф. д-р ван ден Волф Ана Марија Цезар Мартин", "order": "FAMILY_FIRST", "title": "проф. д-р", "given": "ден", "middle": "Волф Ана Марија Цезар Мартин", "family": "ван", "initials": "д. В. А. М. Ц. М. в.", "capitalized_force": "Проф. Д-Р Ден Волф Ана Марија Цезар Мартин Ван"} {"name": "проф. др Александра Ева Сара ван ден Волф Петровић Југовић дипл. инж.", "title": "проф.", "given": "др", "middle": "Александра Ева Сара ван ден Волф Петровић Југовић дипл.", "family": "инж.", "initials": "д. А. Е. С. в. д. В. П. Ј. д. и.", "capitalized_force": "Проф. Др Александра Ева Сара Ван Ден Волф Петровић Југовић Дипл. Инж."} {"name": "проф. фон Вольф Гонзалес Домінго Анастасія Єва Софія д-р", "order": "FAMILY_FIRST", "title": "проф.", "given": "Вольф", "middle": "Гонзалес Домінго Анастасія Єва Софія д-р", "family": "фон", "initials": "В. Г. Д. А. Є. С. д. ф.", "capitalized_force": "Проф. Вольф Гонзалес Домінго Анастасія Єва Софія Д-Р Фон"} {"name": "проф., д-р Ада Корнелия Сезар Мартин фон Брюль кесе, м. ф. д.", "title": "д-р", "given": "Ада", "middle": "Корнелия Сезар Мартин фон Брюль кесе", "family": "проф.", "suffix": "м. ф. д.", "ambiguities": ["COMMA_STRUCTURE"], "initials": "А. К. С. М. ф. Б. к. п.", "capitalized_force": "Д-Р Ада Корнелия Сезар Мартин Фон Брюль Кесе Проф. М. Ф. Д."} {"name": "проф., д-р Ада Корнелия Сезар Мартин фон Брюль — мл., к. м. н.", "title": "д-р", "given": "Ада", "middle": "Корнелия Сезар Мартин фон Брюль —", "family": "проф.", "suffix": "мл., к. м. н.", "ambiguities": ["COMMA_STRUCTURE"], "initials": "А. К. С. М. ф. Б. —. п.", "capitalized_force": "Д-Р Ада Корнелия Сезар Мартин Фон Брюль — Проф. Мл., К. М. Н."} {"name": "проф., д-р Ада Корнелия Сезар Мартин фон Брюль — мл., к.м.н.", "title": "д-р", "given": "Ада", "middle": "Корнелия Сезар Мартин фон Брюль —", "family": "проф.", "suffix": "мл., к.м.н.", "initials": "А. К. С. М. ф. Б. —. п.", "capitalized_force": "Д-Р Ада Корнелия Сезар Мартин Фон Брюль — Проф. Мл., К.М.Н."} {"name": "уулу Төрөкул Махмудов, Мырза Чыңгыз Айтматов Исхак Раззаков Парламент мүчөсү", "given": "Мырза", "middle": "Чыңгыз Айтматов Исхак Раззаков Парламент мүчөсү", "family": "уулу Төрөкул Махмудов", "initials": "М. Ч. А. И. Р. П. м. у. Т. М.", "capitalized_force": "Мырза Чыңгыз Айтматов Исхак Раззаков Парламент Мүчөсү Уулу Төрөкул Махмудов"} {"name": "уулу Төрөкул Чыңгыз Айтматов Исхак Раззаков Парламент мүчөсү", "order": "FAMILY_FIRST", "given": "Төрөкул", "middle": "Чыңгыз Айтматов Исхак Раззаков Парламент мүчөсү", "family": "уулу", "initials": "Т. Ч. А. И. Р. П. м. у.", "capitalized_force": "Төрөкул Чыңгыз Айтматов Исхак Раззаков Парламент Мүчөсү Уулу"} {"name": "фон Бруль Ада Карнэлія Сесар Марцін д-р", "order": "FAMILY_FIRST", "given": "Бруль", "middle": "Ада Карнэлія Сесар Марцін д-р", "family": "фон", "initials": "Б. А. К. С. М. д. ф.", "capitalized_force": "Бруль Ада Карнэлія Сесар Марцін Д-Р Фон"} {"name": "фон Брюль Ада Корнелия", "order": "FAMILY_FIRST", "given": "Брюль", "middle": "Ада Корнелия", "family": "фон", "initials": "Б. А. К. ф.", "capitalized_force": "Брюль Ада Корнелия Фон"} {"name": "фон Брюль Ада Корнелия Сезар Мартин, к. м. н.", "order": "FAMILY_FIRST", "given": "к.", "middle": "м. н.", "family": "фон Брюль Ада Корнелия Сезар Мартин", "initials": "к. м. н. ф. Б. А. К. С. М.", "capitalized_force": "К. М. Н. Фон Брюль Ада Корнелия Сезар Мартин"} {"name": "фон Брюль проф., д-р Ада Корнелия Сезар Мартин к.м.н.", "order": "FAMILY_FIRST", "title": "д-р", "given": "Ада", "middle": "Корнелия Сезар Мартин", "family": "фон Брюль проф.", "suffix": "к.м.н.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as middle (position)"], "initials": "А. К. С. М. ф. Б. п.", "capitalized_force": "Д-Р Ада Корнелия Сезар Мартин Фон Брюль Проф. К.М.Н."} {"name": "фон Брюль проф., д-р Ада Корнелия Сезар Мартин кесе, м. ф. д.", "order": "FAMILY_FIRST", "title": "д-р", "given": "Ада", "middle": "Корнелия Сезар Мартин кесе", "family": "фон Брюль проф.", "suffix": "м. ф. д.", "ambiguities": ["COMMA_STRUCTURE"], "initials": "А. К. С. М. к. ф. Б. п.", "capitalized_force": "Д-Р Ада Корнелия Сезар Мартин Кесе Фон Брюль Проф. М. Ф. Д."} {"name": "фон Вольф Гонзалес Домінго Анастасія Єва Софія, д-р, проф.", "title": "д-р проф.", "given": "фон", "middle": "Вольф Гонзалес Домінго Анастасія Єва", "family": "Софія", "initials": "ф. В. Г. Д. А. Є. С.", "capitalized_force": "Д-Р Проф. Фон Вольф Гонзалес Домінго Анастасія Єва Софія"} {"name": "хосе и мария сантос", "given": "хосе и мария", "family": "сантос", "initials": "х. м. с.", "capitalized": "Хосе и Мария Сантос"} {"name": "Қайрат Болатұлы Тұрлыханов", "given": "Қайрат", "middle": "Болатұлы", "family": "Тұрлыханов", "initials": "Қ. Б. Т."} {"name": "Қасым Айдар", "order": "FAMILY_FIRST", "given": "Айдар", "family": "Қасым", "initials": "А. Қ."} {"name": "Қасым, Айдар", "given": "Айдар", "family": "Қасым", "initials": "А. Қ."} {"name": "Әбіш Кекілбайұлы", "given": "Әбіш", "family": "Кекілбайұлы", "initials": "Ә. К."} {"name": "Әсем Мұхамеджан", "given": "Әсем", "family": "Мұхамеджан", "initials": "Ә. М."} {"name": "Ադամյան Հեղինե", "order": "FAMILY_FIRST", "given": "Հեղինե", "family": "Ադամյան", "initials": "Հ. Ա."} {"name": "Ալբերտ Հովհաննիսյան", "given": "Ալբերտ", "family": "Հովհաննիսյան", "initials": "Ա. Հ."} {"name": "Ալեքսանդր Երիցյան", "given": "Ալեքսանդր", "family": "Երիցյան", "initials": "Ա. Ե."} {"name": "Ալեքսանդր Թումասյան", "given": "Ալեքսանդր", "family": "Թումասյան", "initials": "Ա. Թ."} {"name": "Ալեքսանդր Մանասյան", "given": "Ալեքսանդր", "family": "Մանասյան", "initials": "Ա. Մ."} {"name": "Անդրանիկ Հարությունյան", "given": "Անդրանիկ", "family": "Հարությունյան", "initials": "Ա. Հ."} {"name": "Անդրանիկ Ղուկասյան", "given": "Անդրանիկ", "family": "Ղուկասյան", "initials": "Ա. Ղ."} {"name": "Անի Խաչիկյան", "given": "Անի", "family": "Խաչիկյան", "initials": "Ա. Խ."} {"name": "Անուշավան Ազատյան", "given": "Անուշավան", "family": "Ազատյան", "initials": "Ա. Ա."} {"name": "Աշոտ Պետրոսյան", "given": "Աշոտ", "family": "Պետրոսյան", "initials": "Ա. Պ."} {"name": "Արա Աբրահամյան", "given": "Արա", "family": "Աբրահամյան", "initials": "Ա. Ա."} {"name": "Արամ Սարգսյան", "given": "Արամ", "family": "Սարգսյան", "initials": "Ա. Ս."} {"name": "Արամ Քոչարյան", "given": "Արամ", "family": "Քոչարյան", "initials": "Ա. Ք."} {"name": "Արայիկ Մաթևոսյան", "given": "Արայիկ", "family": "Մաթևոսյան", "initials": "Ա. Մ."} {"name": "Արթուր", "given": "Արթուր", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "Ա."} {"name": "Արթուր", "order": "FAMILY_FIRST", "family": "Արթուր", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "Ա."} {"name": "Արթուր Բաղդասարյան", "given": "Արթուր", "family": "Բաղդասարյան", "initials": "Ա. Բ."} {"name": "Արթուր Մկրտչյան", "given": "Արթուր", "family": "Մկրտչյան", "initials": "Ա. Մ."} {"name": "Գառնիկ Հովհաննիսյան", "given": "Գառնիկ", "family": "Հովհաննիսյան", "initials": "Գ. Հ."} {"name": "Գոհար Գասպարյան", "given": "Գոհար", "family": "Գասպարյան", "initials": "Գ. Գ."} {"name": "Գրիգոր Բրուտյան", "given": "Գրիգոր", "family": "Բրուտյան", "initials": "Գ. Բ."} {"name": "Դավիթ Համբարձումյան", "given": "Դավիթ", "family": "Համբարձումյան", "initials": "Դ. Հ."} {"name": "Դարբինյան Հովհաննես", "order": "FAMILY_FIRST", "given": "Հովհաննես", "family": "Դարբինյան", "initials": "Հ. Դ."} {"name": "Դարբինյան Հովհաննես Արմենի", "order": "FAMILY_FIRST", "given": "Հովհաննես", "middle": "Արմենի", "family": "Դարբինյան", "initials": "Հ. Ա. Դ."} {"name": "Դերենիկ Սաֆարյան", "given": "Դերենիկ", "family": "Սաֆարյան", "initials": "Դ. Ս."} {"name": "Երվանդ Սուքիասյան", "given": "Երվանդ", "family": "Սուքիասյան", "initials": "Ե. Ս."} {"name": "Զառա Տոնիկյան", "given": "Զառա", "family": "Տոնիկյան", "initials": "Զ. Տ."} {"name": "Էդուարդ Մնացականյան", "given": "Էդուարդ", "family": "Մնացականյան", "initials": "Է. Մ."} {"name": "Էրիկ Սահակյան", "given": "Էրիկ", "family": "Սահակյան", "initials": "Է. Ս."} {"name": "Լենա Նազարյան", "given": "Լենա", "family": "Նազարյան", "initials": "Լ. Ն."} {"name": "Լորիս Ճգնավորյան", "given": "Լորիս", "family": "Ճգնավորյան", "initials": "Լ. Ճ."} {"name": "Լևոն Աղասյան", "given": "Լևոն", "family": "Աղասյան", "initials": "Լ. Ա."} {"name": "Կոնստանտին Օրբելյան", "given": "Կոնստանտին", "family": "Օրբելյան", "initials": "Կ. Օ."} {"name": "Հայկ Հակոբյան", "given": "Հայկ", "family": "Հակոբյան", "initials": "Հ. Հ."} {"name": "Հայկ Ղազարյան", "given": "Հայկ", "family": "Ղազարյան", "initials": "Հ. Ղ."} {"name": "Հեղինե Ադամյան", "given": "Հեղինե", "family": "Ադամյան", "initials": "Հ. Ա."} {"name": "Հենրիկ Ազատյան", "given": "Հենրիկ", "family": "Ազատյան", "initials": "Հ. Ա."} {"name": "Հովհաննես Արմենի Դարբինյան", "given": "Հովհաննես", "middle": "Արմենի", "family": "Դարբինյան", "initials": "Հ. Ա. Դ."} {"name": "Հրանտ Թովմասյան", "given": "Հրանտ", "family": "Թովմասյան", "initials": "Հ. Թ."} {"name": "Մամիկոն Աքուլյան", "given": "Մամիկոն", "family": "Աքուլյան", "initials": "Մ. Ա."} {"name": "Մանուել Պետրոսյան", "given": "Մանուել", "family": "Պետրոսյան", "initials": "Մ. Պ."} {"name": "Մարիա Հակոբյան", "given": "Մարիա", "family": "Հակոբյան", "initials": "Մ. Հ."} {"name": "Մարտին Սուքիասյան", "given": "Մարտին", "family": "Սուքիասյան", "initials": "Մ. Ս."} {"name": "Միքայել Հարությունյան", "given": "Միքայել", "family": "Հարությունյան", "initials": "Մ. Հ."} {"name": "Մյուլեր Քեյթ", "order": "FAMILY_FIRST", "given": "Քեյթ", "family": "Մյուլեր", "initials": "Ք. Մ."} {"name": "Յուրի Դավթյան", "given": "Յուրի", "family": "Դավթյան", "initials": "Յ. Դ."} {"name": "Յուրի Հայրապետյան", "given": "Յուրի", "family": "Հայրապետյան", "initials": "Յ. Հ."} {"name": "Նորայր Ասլանյան", "given": "Նորայր", "family": "Ասլանյան", "initials": "Ն. Ա."} {"name": "Նունե Սարգսյան", "given": "Նունե", "family": "Սարգսյան", "initials": "Ն. Ս."} {"name": "Շտյոբեր Ցեցիլիա", "order": "FAMILY_FIRST", "given": "Ցեցիլիա", "family": "Շտյոբեր", "initials": "Ց. Շ."} {"name": "Շտյոբեր Ցեցիլիա Համիշ", "order": "FAMILY_FIRST", "given": "Ցեցիլիա", "middle": "Համիշ", "family": "Շտյոբեր", "initials": "Ց. Հ. Շ."} {"name": "Ռաֆիկ Եղոյան", "given": "Ռաֆիկ", "family": "Եղոյան", "initials": "Ռ. Ե."} {"name": "Ռոբերտ Արզումանյան", "given": "Ռոբերտ", "family": "Արզումանյան", "initials": "Ռ. Ա."} {"name": "Ռոբերտ Հովհաննիսյան", "given": "Ռոբերտ", "family": "Հովհաննիսյան", "initials": "Ռ. Հ."} {"name": "Ռոմեն Դավթյան", "given": "Ռոմեն", "family": "Դավթյան", "initials": "Ռ. Դ."} {"name": "Ռուբեն Վարդանյան", "given": "Ռուբեն", "family": "Վարդանյան", "initials": "Ռ. Վ."} {"name": "Սերգեյ Մովսեսյան", "given": "Սերգեյ", "family": "Մովսեսյան", "initials": "Ս. Մ."} {"name": "Սերգեյ Ներսիսյան", "given": "Սերգեյ", "family": "Ներսիսյան", "initials": "Ս. Ն."} {"name": "Սինբադ", "given": "Սինբադ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "Ս."} {"name": "Սինբադ", "order": "FAMILY_FIRST", "family": "Սինբադ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "Ս."} {"name": "Սուրեն Բաբայան", "given": "Սուրեն", "family": "Բաբայան", "initials": "Ս. Բ."} {"name": "Սուրեն Երեմյան", "given": "Սուրեն", "family": "Երեմյան", "initials": "Ս. Ե."} {"name": "Սուրեն Ծատուրյան", "given": "Սուրեն", "family": "Ծատուրյան", "initials": "Ս. Ծ."} {"name": "Սուրեն Նազարյան", "given": "Սուրեն", "family": "Նազարյան", "initials": "Ս. Ն."} {"name": "Ստեփան Նալբանդյան", "given": "Ստեփան", "family": "Նալբանդյան", "initials": "Ս. Ն."} {"name": "Վազգեն Մանուկյան", "given": "Վազգեն", "family": "Մանուկյան", "initials": "Վ. Մ."} {"name": "Վահագն Խաչատրյան", "given": "Վահագն", "family": "Խաչատրյան", "initials": "Վ. Խ."} {"name": "Վարդան Ստեփանյան", "given": "Վարդան", "family": "Ստեփանյան", "initials": "Վ. Ս."} {"name": "Վլադիմիր Դարբինյան", "given": "Վլադիմիր", "family": "Դարբինյան", "initials": "Վ. Դ."} {"name": "Վոլֆ Ադա Կոռնելիա", "order": "FAMILY_FIRST", "given": "Ադա", "middle": "Կոռնելիա", "family": "Վոլֆ", "initials": "Ա. Կ. Վ."} {"name": "Տեր-Պողոսյան Հայկ", "order": "FAMILY_FIRST", "given": "Հայկ", "family": "Տեր-Պողոսյան", "initials": "Հ. Տ."} {"name": "Տիգրան Նալբանդյան", "given": "Տիգրան", "family": "Նալբանդյան", "initials": "Տ. Ն."} {"name": "Ցեցիլիա Համիշ Շտյոբեր", "given": "Ցեցիլիա", "middle": "Համիշ", "family": "Շտյոբեր", "initials": "Ց. Հ. Շ."} {"name": "Քեյթ Մյուլեր", "given": "Քեյթ", "family": "Մյուլեր", "initials": "Ք. Մ."} {"name": "Ֆելիքս Բախչինյան", "given": "Ֆելիքս", "family": "Բախչինյան", "initials": "Ֆ. Բ."} {"name": "բ․գ․դ․ Տեր-Պողոսյան Հայկ Պարգևի", "order": "FAMILY_FIRST", "given": "Տեր-Պողոսյան", "middle": "Հայկ Պարգևի", "family": "բ․գ․դ․", "initials": "Տ. Հ. Պ. բ.", "capitalized_force": "Տեր-Պողոսյան Հայկ Պարգևի Բ․Գ․Դ․"} {"name": "բ․գ․դ․ դ-ր Ադա Կոռնելիա Սեզար Մարտին վան դեն Վոլֆ", "given": "բ․գ․դ․", "middle": "դ-ր Ադա Կոռնելիա Սեզար Մարտին վան դեն", "family": "Վոլֆ", "initials": "բ. դ. Ա. Կ. Ս. Մ. վ. դ. Վ.", "capitalized_force": "Բ․Գ․Դ․ Դ-Ր Ադա Կոռնելիա Սեզար Մարտին Վան Դեն Վոլֆ"} {"name": "բ․գ․դ․ պրն Հայկ Պարգևի Տեր-Պողոսյան", "given": "բ․գ․դ․", "middle": "պրն Հայկ Պարգևի", "family": "Տեր-Պողոսյան", "initials": "բ. պ. Հ. Պ. Տ.", "capitalized_force": "Բ․Գ․Դ․ Պրն Հայկ Պարգևի Տեր-Պողոսյան"} {"name": "բ․գ․դ․ վան դեն Վոլֆ Ադա Կոռնելիա Սեզար Մարտին", "order": "FAMILY_FIRST", "given": "վան", "middle": "դեն Վոլֆ Ադա Կոռնելիա Սեզար Մարտին", "family": "բ․գ․դ․", "initials": "վ. դ. Վ. Ա. Կ. Ս. Մ. բ.", "capitalized_force": "Վան Դեն Վոլֆ Ադա Կոռնելիա Սեզար Մարտին Բ․Գ․Դ․"} {"name": "אבן אבן-גבירול מר בנימין משה ח״כ", "order": "FAMILY_FIRST", "given": "אבן-גבירול", "middle": "מר בנימין משה ח״כ", "family": "אבן", "initials": "א. מ. ב. מ. ח. א."} {"name": "אבנר ברזני", "given": "אבנר", "family": "ברזני", "initials": "א. ב."} {"name": "אברהם אדן", "given": "אברהם", "family": "אדן", "initials": "א. א."} {"name": "אברהם כץ", "given": "אברהם", "family": "כץ", "initials": "א. כ."} {"name": "אברהם מור", "given": "אברהם", "family": "מור", "initials": "א. מ."} {"name": "אדם מזור", "given": "אדם", "family": "מזור", "initials": "א. מ."} {"name": "אהרן הלוי", "given": "אהרן", "family": "הלוי", "initials": "א. ה."} {"name": "אהרן מונדשיין", "given": "אהרן", "family": "מונדשיין", "initials": "א. מ."} {"name": "אהרן מסקין", "given": "אהרן", "family": "מסקין", "initials": "א. מ."} {"name": "איתי שכטר", "given": "איתי", "family": "שכטר", "initials": "א. ש."} {"name": "אלישבע מיכאלי", "given": "אלישבע", "family": "מיכאלי", "initials": "א. מ."} {"name": "אלישע אפרת", "given": "אלישע", "family": "אפרת", "initials": "א. א."} {"name": "אלישע פורת", "given": "אלישע", "family": "פורת", "initials": "א. פ."} {"name": "אמה תלמי", "given": "אמה", "family": "תלמי", "initials": "א. ת."} {"name": "אמיר דרורי", "given": "אמיר", "family": "דרורי", "initials": "א. ד."} {"name": "אסתר אלכסנדר", "given": "אסתר", "family": "אלכסנדר", "initials": "א. א."} {"name": "אריק איינשטיין", "given": "אריק", "family": "איינשטיין", "initials": "א. א."} {"name": "אשר דורי", "given": "אשר", "family": "דורי", "initials": "א. ד."} {"name": "בלה קאופמן", "given": "בלה", "family": "קאופמן", "initials": "ב. ק."} {"name": "ברוך אורן", "given": "ברוך", "family": "אורן", "initials": "ב. א."} {"name": "ברוך מודן", "given": "ברוך", "family": "מודן", "initials": "ב. מ."} {"name": "ברכה רוזנפלד", "given": "ברכה", "family": "רוזנפלד", "initials": "ב. ר."} {"name": "גיא וסרדם", "given": "גיא", "family": "וסרדם", "initials": "ג. ו."} {"name": "דבורה עומר", "given": "דבורה", "family": "עומר", "initials": "ד. ע."} {"name": "דוד בן־גוריון", "given": "דוד", "family": "בן־גוריון", "initials": "ד. ב."} {"name": "דן מורי", "given": "דן", "family": "מורי", "initials": "ד. מ."} {"name": "דניאל פאר", "given": "דניאל", "family": "פאר", "initials": "ד. פ."} {"name": "דנית לוי", "given": "דנית", "family": "לוי", "initials": "ד. ל."} {"name": "ורה אלכסנדר", "given": "ורה", "family": "אלכסנדר", "initials": "ו. א."} {"name": "זאב מרגלית", "given": "זאב", "family": "מרגלית", "initials": "ז. מ."} {"name": "זזיליה חמיש סטובר", "given": "זזיליה", "middle": "חמיש", "family": "סטובר", "initials": "ז. ח. ס."} {"name": "חיים מולכו", "given": "חיים", "family": "מולכו", "initials": "ח. מ."} {"name": "חנה אייכינגרין", "given": "חנה", "family": "אייכינגרין", "initials": "ח. א."} {"name": "ידידיה כהן", "given": "ידידיה", "family": "כהן", "initials": "י. כ."} {"name": "יהודה כהן", "given": "יהודה", "family": "כהן", "initials": "י. כ."} {"name": "יונתן", "given": "יונתן", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "י."} {"name": "יונתן", "order": "FAMILY_FIRST", "family": "יונתן", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "י."} {"name": "יונתן חיים כהן", "given": "יונתן", "middle": "חיים", "family": "כהן", "initials": "י. ח. כ."} {"name": "יצחק טישלר", "given": "יצחק", "family": "טישלר", "initials": "י. ט."} {"name": "יצחק יעקב", "given": "יצחק", "family": "יעקב", "initials": "י. י."} {"name": "יצחק רבין", "given": "יצחק", "family": "רבין", "initials": "י. ר."} {"name": "ישעיהו פרס", "given": "ישעיהו", "family": "פרס", "initials": "י. פ."} {"name": "כהן יונתן חיים", "order": "FAMILY_FIRST", "given": "יונתן", "middle": "חיים", "family": "כהן", "initials": "י. ח. כ."} {"name": "לוי דנית", "order": "FAMILY_FIRST", "given": "דנית", "family": "לוי", "initials": "ד. ל."} {"name": "לולה פולמן", "given": "לולה", "family": "פולמן", "initials": "ל. פ."} {"name": "לייב וייספיש", "given": "לייב", "family": "וייספיש", "initials": "ל. ו."} {"name": "מאיר ישראל", "given": "מאיר", "family": "ישראל", "initials": "מ. י."} {"name": "מולר קתה", "order": "FAMILY_FIRST", "given": "קתה", "family": "מולר", "initials": "ק. מ."} {"name": "מיכאל אלמוג", "given": "מיכאל", "family": "אלמוג", "initials": "מ. א."} {"name": "מישאל חשין", "given": "מישאל", "family": "חשין", "initials": "מ. ח."} {"name": "מנחם נאור", "given": "מנחם", "family": "נאור", "initials": "מ. נ."} {"name": "מנשה רז", "given": "מנשה", "family": "רז", "initials": "מ. ר."} {"name": "מר בנימין משה אבן אבן-גבירול ח״כ", "title": "מר", "given": "בנימין", "middle": "משה אבן אבן-גבירול", "family": "ח״כ", "initials": "ב. מ. א. א. ח."} {"name": "משה בן-שאול", "given": "משה", "family": "בן-שאול", "initials": "מ. ב."} {"name": "משה חלוץ", "given": "משה", "family": "חלוץ", "initials": "מ. ח."} {"name": "משה מזור", "given": "משה", "family": "מזור", "initials": "מ. מ."} {"name": "סטובר זזיליה חמיש", "order": "FAMILY_FIRST", "given": "זזיליה", "middle": "חמיש", "family": "סטובר", "initials": "ז. ח. ס."} {"name": "סינבד", "given": "סינבד", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "ס."} {"name": "סינבד", "order": "FAMILY_FIRST", "family": "סינבד", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "ס."} {"name": "עמוס ברנס", "given": "עמוס", "family": "ברנס", "initials": "ע. ב."} {"name": "עמוס הדר", "given": "עמוס", "family": "הדר", "initials": "ע. ה."} {"name": "עמרי שרון", "given": "עמרי", "family": "שרון", "initials": "ע. ש."} {"name": "פון ברוהל גונזלס דומינגו פרופ׳ עדה קורנליה סזאר מרטין ד״ר", "order": "FAMILY_FIRST", "given": "ברוהל", "middle": "גונזלס דומינגו פרופ׳ עדה קורנליה סזאר מרטין ד״ר", "family": "פון", "initials": "ב. ג. ד. פ. ע. ק. ס. מ. ד. פ."} {"name": "פון ברוהל גונזלס דומינגו, פרופ׳ עדה קורנליה סזאר מרטין ד״ר", "title": "פרופ׳", "given": "עדה", "middle": "קורנליה סזאר מרטין ד״ר", "family": "פון ברוהל גונזלס דומינגו", "initials": "ע. ק. ס. מ. ד. פ. ב. ג. ד."} {"name": "פרופ׳ עדה קורנליה סזאר מרטין פון ברוהל גונזלס דומינגו ד״ר", "title": "פרופ׳", "given": "עדה", "middle": "קורנליה סזאר מרטין פון ברוהל גונזלס דומינגו", "family": "ד״ר", "initials": "ע. ק. ס. מ. פ. ב. ג. ד. ד."} {"name": "צבי ארד", "given": "צבי", "family": "ארד", "initials": "צ. א."} {"name": "קתה מולר", "given": "קתה", "family": "מולר", "initials": "ק. מ."} {"name": "רון ארד", "given": "רון", "family": "ארד", "initials": "ר. א."} {"name": "רון פלר", "given": "רון", "family": "פלר", "initials": "ר. פ."} {"name": "רחל שור", "given": "רחל", "family": "שור", "initials": "ר. ש."} {"name": "שולה ליטן", "given": "שולה", "family": "ליטן", "initials": "ש. ל."} {"name": "שלמה תמיר", "given": "שלמה", "family": "תמיר", "initials": "ש. ת."} {"name": "שמואל דביר", "given": "שמואל", "family": "דביר", "initials": "ש. ד."} {"name": "ששון סומך", "given": "ששון", "family": "סומך", "initials": "ש. ס."} {"name": "תומר שחורי", "given": "תומר", "family": "שחורי", "initials": "ת. ש."} {"name": "תמיר כהן", "given": "תמיר", "family": "כהן", "initials": "ת. כ."} {"name": "תמרה כהן", "given": "תמרה", "family": "כהן", "initials": "ת. כ."} {"name": "آئرين ائڊلر", "given": "آئرين", "family": "ائڊلر", "initials": "آ. ا."} {"name": "آقای امیر هوشنگ رستگار دکتر", "given": "آقای", "middle": "امیر هوشنگ رستگار", "family": "دکتر", "initials": "آ. ا. ه. ر. د."} {"name": "آمال فهمي", "given": "آمال", "family": "فهمي", "initials": "آ. ف."} {"name": "آیتین عدلو", "given": "آیتین", "family": "عدلو", "initials": "آ. ع."} {"name": "أبو الأغا، أحمد رامي نجيب محفوظ", "order": "FAMILY_FIRST", "given": "أحمد", "middle": "رامي نجيب محفوظ", "family": "أبو الأغا", "initials": "أ. ر. ن. م. ا."} {"name": "أبو بكر الشاطري", "given": "أبو بكر", "family": "الشاطري", "initials": "أ. ب. ا."} {"name": "أبو بكر سالم", "given": "أبو بكر", "family": "سالم", "initials": "أ. ب. س."} {"name": "أحلام الجريتلي", "given": "أحلام", "family": "الجريتلي", "initials": "أ. ا."} {"name": "أحمد حجازي", "given": "أحمد", "family": "حجازي", "initials": "أ. ح."} {"name": "أحمد سالم", "given": "أحمد", "family": "سالم", "initials": "أ. س."} {"name": "أحمد قنديل", "given": "أحمد", "family": "قنديل", "initials": "أ. ق."} {"name": "أحمد مصطفى", "given": "أحمد", "family": "مصطفى", "initials": "أ. م."} {"name": "أصالة نصري", "given": "أصالة", "family": "نصري", "initials": "أ. ن."} {"name": "أكرم التلاوي", "given": "أكرم", "family": "التلاوي", "initials": "أ. ا."} {"name": "أكرم عجة", "given": "أكرم", "family": "عجة", "initials": "أ. ع."} {"name": "ألكسندر دارسي", "given": "ألكسندر", "family": "دارسي", "initials": "أ. د."} {"name": "أمجد الطرابلسي", "given": "أمجد", "family": "الطرابلسي", "initials": "أ. ا."} {"name": "أمين دابو", "given": "أمين", "family": "دابو", "initials": "أ. د."} {"name": "أمين سامي", "given": "أمين", "family": "سامي", "initials": "أ. س."} {"name": "أمينة رشيد", "given": "أمينة", "family": "رشيد", "initials": "أ. ر."} {"name": "أنطوانيت نجيب", "given": "أنطوانيت", "family": "نجيب", "initials": "أ. ن."} {"name": "أنطونيوس نجيب", "given": "أنطونيوس", "family": "نجيب", "initials": "أ. ن."} {"name": "أيمن الدُّقر", "given": "أيمن", "family": "الدُّقر", "initials": "أ. ا."} {"name": "إبراهيم البغدادي", "given": "إبراهيم", "family": "البغدادي", "initials": "إ. ا."} {"name": "إبراهيم الغلاييني", "given": "إبراهيم", "family": "الغلاييني", "initials": "إ. ا."} {"name": "إبراهيم صالح", "given": "إبراهيم", "family": "صالح", "initials": "إ. ص."} {"name": "إحسان الرفاعي", "given": "إحسان", "family": "الرفاعي", "initials": "إ. ا."} {"name": "إلهام حسين", "given": "إلهام", "family": "حسين", "initials": "إ. ح."} {"name": "إلياس الزيَّات", "given": "إلياس", "family": "الزيَّات", "initials": "إ. ا."} {"name": "إيهاب خورشيد", "given": "إيهاب", "family": "خورشيد", "initials": "إ. خ."} {"name": "ائڊلر آئرين", "order": "FAMILY_FIRST", "given": "آئرين", "family": "ائڊلر", "initials": "آ. ا."} {"name": "احمد", "given": "احمد", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "ا."} {"name": "احمد", "order": "FAMILY_FIRST", "family": "احمد", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "ا."} {"name": "احمد گلشیری", "given": "احمد", "family": "گلشیری", "initials": "ا. گ."} {"name": "اختر رضا خان", "given": "اختر", "middle": "رضا", "family": "خان", "initials": "ا. ر. خ."} {"name": "ادا كورنەليا فون بريۋل جۋنيور، ءتىس دارىگەرى", "given": "ءتىس", "middle": "دارىگەرى", "family": "ادا كورنەليا فون بريۋل جۋنيور", "initials": "ء. د. ا. ك. ف. ب. ج."} {"name": "ارین اډلر", "given": "ارین", "family": "اډلر", "initials": "ا. ا."} {"name": "استوبر، زازیلیا هایمیش", "order": "FAMILY_FIRST", "given": "زازیلیا", "middle": "هایمیش", "family": "استوبر", "initials": "ز. ه. ا."} {"name": "اسٽوبر زيزيليا ھئمش", "order": "FAMILY_FIRST", "given": "زيزيليا", "middle": "ھئمش", "family": "اسٽوبر", "initials": "ز. ھ. ا."} {"name": "اسەم", "given": "اسەم", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "ا."} {"name": "اسەم", "order": "FAMILY_FIRST", "family": "اسەم", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "ا."} {"name": "اصغر شازیہ", "order": "FAMILY_FIRST", "given": "شازیہ", "family": "اصغر", "initials": "ش. ا."} {"name": "اصغر، شازیہ", "order": "FAMILY_FIRST", "given": "شازیہ", "family": "اصغر", "initials": "ش. ا."} {"name": "اقبال قریشی", "given": "اقبال", "family": "قریشی", "initials": "ا. ق."} {"name": "الدكتور عايدة كورنيليا سيزار مارتن فون برول الإبن، طبيب وجراح", "title": "الدكتور", "given": "طبيب", "middle": "وجراح", "family": "عايدة كورنيليا سيزار مارتن فون برول", "suffix": "الإبن", "initials": "ط. و. ع. ك. س. م. ف. ب."} {"name": "السيد أحمد رامي نجيب محفوظ أبو الأغا الابن، النائب", "given": "النائب", "family": "السيد أحمد رامي نجيب محفوظ أبو الأغا", "suffix": "الابن", "initials": "ا. ا. أ. ر. ن. م. ا."} {"name": "العامر، كمال مجدي", "order": "FAMILY_FIRST", "given": "كمال", "middle": "مجدي", "family": "العامر", "initials": "ك. م. ا."} {"name": "النجار، سميرة", "order": "FAMILY_FIRST", "given": "سميرة", "family": "النجار", "initials": "س. ا."} {"name": "امید رضا ملایری", "given": "امید", "middle": "رضا", "family": "ملایری", "initials": "ا. ر. م."} {"name": "انو آغا", "given": "انو", "family": "آغا", "initials": "ا. آ."} {"name": "انیتا اگنی ہوتری", "given": "انیتا", "middle": "اگنی", "family": "ہوتری", "initials": "ا. ا. ہ."} {"name": "اډلر ارین", "order": "FAMILY_FIRST", "given": "ارین", "family": "اډلر", "initials": "ا. ا."} {"name": "بالقىباي ماقسات", "order": "FAMILY_FIRST", "given": "ماقسات", "family": "بالقىباي", "initials": "م. ب."} {"name": "بالقىباي، ماقسات توقماحامبەتۇلى", "order": "FAMILY_FIRST", "given": "ماقسات", "middle": "توقماحامبەتۇلى", "family": "بالقىباي", "initials": "م. ت. ب."} {"name": "بروهل، آدا کورنلیا سزار مارتین وان", "order": "FAMILY_FIRST", "given": "آدا", "middle": "کورنلیا سزار مارتین وان", "family": "بروهل", "initials": "آ. ک. س. م. و. ب."} {"name": "بريۋل، ادا كورنەليا سەزار مارتين فون", "order": "FAMILY_FIRST", "given": "ادا", "middle": "كورنەليا سەزار مارتين فون", "family": "بريۋل", "initials": "ا. ك. س. م. ف. ب."} {"name": "بهروز بوچانی", "given": "بهروز", "family": "بوچانی", "initials": "ب. ب."} {"name": "بهمن جلالی", "given": "بهمن", "family": "جلالی", "initials": "ب. ج."} {"name": "بهيج الخطيب", "given": "بهيج", "family": "الخطيب", "initials": "ب. ا."} {"name": "جان همیش واټسن", "given": "جان", "middle": "همیش", "family": "واټسن", "initials": "ج. ه. و."} {"name": "جلال همتی", "given": "جلال", "family": "همتی", "initials": "ج. ه."} {"name": "جمال محمد", "given": "جمال", "family": "محمد", "initials": "ج. م."} {"name": "جميل عز الدين", "given": "جميل", "middle": "عز", "family": "الدين", "initials": "ج. ع. ا."} {"name": "جورج إسحاق", "given": "جورج", "family": "إسحاق", "initials": "ج. إ."} {"name": "جورج بوش الابن", "given": "جورج", "family": "بوش", "suffix": "الابن", "initials": "ج. ب."} {"name": "جورج ميشيل", "given": "جورج", "family": "ميشيل", "initials": "ج. م."} {"name": "جوزف پال", "given": "جوزف", "family": "پال", "initials": "ج. پ."} {"name": "جيانيس كريستو", "given": "جيانيس", "family": "كريستو", "initials": "ج. ك."} {"name": "جيهان السادات", "given": "جيهان", "family": "السادات", "initials": "ج. ا."} {"name": "جیسی پال", "given": "جیسی", "family": "پال", "initials": "ج. پ."} {"name": "حاتم علي", "given": "حاتم", "family": "علي", "initials": "ح. ع."} {"name": "حاتم منصور", "given": "حاتم", "family": "منصور", "initials": "ح. م."} {"name": "حازم هاشم", "given": "حازم", "family": "هاشم", "initials": "ح. ه."} {"name": "حسن دانشفر", "given": "حسن", "family": "دانشفر", "initials": "ح. د."} {"name": "حسن شحاتة", "given": "حسن", "family": "شحاتة", "initials": "ح. ش."} {"name": "حسن عابدی", "given": "حسن", "family": "عابدی", "initials": "ح. ع."} {"name": "حسن ناصر", "given": "حسن", "family": "ناصر", "initials": "ح. ن."} {"name": "حسني سبح", "given": "حسني", "family": "سبح", "initials": "ح. س."} {"name": "حسين خطاب", "given": "حسين", "family": "خطاب", "initials": "ح. خ."} {"name": "حسین امینی", "given": "حسین", "family": "امینی", "initials": "ح. ا."} {"name": "حسین حسینی", "given": "حسین", "family": "حسینی", "initials": "ح. ح."} {"name": "حكمت هاشم", "given": "حكمت", "family": "هاشم", "initials": "ح. ه."} {"name": "حليم بركات", "given": "حليم", "family": "بركات", "initials": "ح. ب."} {"name": "حمدان شلبي", "given": "حمدان", "family": "شلبي", "initials": "ح. ش."} {"name": "حمدي الحريري", "given": "حمدي", "family": "الحريري", "initials": "ح. ا."} {"name": "حميدة الطاهر", "given": "حميدة", "family": "الطاهر", "initials": "ح. ا."} {"name": "حنیف خان", "given": "حنیف", "family": "خان", "initials": "ح. خ."} {"name": "خالد عزيز", "given": "خالد", "family": "عزيز", "initials": "خ. ع."} {"name": "خان مریم نواز", "order": "FAMILY_FIRST", "given": "مریم", "middle": "نواز", "family": "خان", "initials": "م. ن. خ."} {"name": "خان، مریم نواز", "order": "FAMILY_FIRST", "given": "مریم", "middle": "نواز", "family": "خان", "initials": "م. ن. خ."} {"name": "خضر الأحمد", "given": "خضر", "family": "الأحمد", "initials": "خ. ا."} {"name": "دلیپ کور ٹوانہ", "given": "دلیپ", "middle": "کور", "family": "ٹوانہ", "initials": "د. ک. ٹ."} {"name": "رائد المتني", "given": "رائد", "family": "المتني", "initials": "ر. ا."} {"name": "رامي مخلوف", "given": "رامي", "family": "مخلوف", "initials": "ر. م."} {"name": "رجاء يوسف", "given": "رجاء", "family": "يوسف", "initials": "ر. ي."} {"name": "رستگار، آقای امیر هوشنگ دکتر", "order": "FAMILY_FIRST", "given": "آقای", "middle": "امیر هوشنگ دکتر", "family": "رستگار", "initials": "آ. ا. ه. د. ر."} {"name": "رستگار، امیر هوشنگ", "order": "FAMILY_FIRST", "given": "امیر", "middle": "هوشنگ", "family": "رستگار", "initials": "ا. ه. ر."} {"name": "رشاد رشدي", "given": "رشاد", "family": "رشدي", "initials": "ر. ر."} {"name": "رمضان ديب", "given": "رمضان", "family": "ديب", "initials": "ر. د."} {"name": "رنا أحمد", "given": "رنا", "family": "أحمد", "initials": "ر. أ."} {"name": "ريما الجفالي", "given": "ريما", "family": "الجفالي", "initials": "ر. ا."} {"name": "رینوکا شرما", "given": "رینوکا", "family": "شرما", "initials": "ر. ش."} {"name": "زازیلیا هایمیش استوبر", "given": "زازیلیا", "middle": "هایمیش", "family": "استوبر", "initials": "ز. ه. ا."} {"name": "زكي مبارك", "given": "زكي", "family": "مبارك", "initials": "ز. م."} {"name": "زكية إبراهيم", "given": "زكية", "family": "إبراهيم", "initials": "ز. إ."} {"name": "زيزيليا ھئمش اسٽوبر", "given": "زيزيليا", "middle": "ھئمش", "family": "اسٽوبر", "initials": "ز. ھ. ا."} {"name": "زيزينيا هاميش ستوبر", "given": "زيزينيا", "middle": "هاميش", "family": "ستوبر", "initials": "ز. ه. س."} {"name": "زیزیلیا همیش سټوبر", "given": "زیزیلیا", "middle": "همیش", "family": "سټوبر", "initials": "ز. ه. س."} {"name": "سارہ خان", "given": "سارہ", "family": "خان", "initials": "س. خ."} {"name": "سامانتا", "given": "سامانتا", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "س."} {"name": "سامانتا", "order": "FAMILY_FIRST", "family": "سامانتا", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "س."} {"name": "سامي الكيالي", "given": "سامي", "family": "الكيالي", "initials": "س. ا."} {"name": "ستوبر، زيزينيا هاميش", "order": "FAMILY_FIRST", "given": "زيزينيا", "middle": "هاميش", "family": "ستوبر", "initials": "ز. ه. س."} {"name": "ستیش شرما", "given": "ستیش", "family": "شرما", "initials": "س. ش."} {"name": "سحر كامل", "given": "سحر", "family": "كامل", "initials": "س. ك."} {"name": "سعدیہ خان", "given": "سعدیہ", "family": "خان", "initials": "س. خ."} {"name": "سعدیہ دہلوی", "given": "سعدیہ", "family": "دہلوی", "initials": "س. د."} {"name": "سعيد حماد", "given": "سعيد", "family": "حماد", "initials": "س. ح."} {"name": "سلام امینی", "given": "سلام", "family": "امینی", "initials": "س. ا."} {"name": "سلطان البرقان", "given": "سلطان", "family": "البرقان", "initials": "س. ا."} {"name": "سلمان, محمد", "given": "محمد", "family": "سلمان", "initials": "م. س."} {"name": "سلمان،", "given": "سلمان", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "س."} {"name": "سلمان، محمد", "given": "محمد", "family": "سلمان", "initials": "م. س."} {"name": "سليمان الجوخدار", "given": "سليمان", "family": "الجوخدار", "initials": "س. ا."} {"name": "سمر عبد العزيز", "given": "سمر", "middle": "عبد", "family": "العزيز", "initials": "س. ع. ا."} {"name": "سمير سرور", "given": "سمير", "family": "سرور", "initials": "س. س."} {"name": "سميرة النجار", "given": "سميرة", "family": "النجار", "initials": "س. ا."} {"name": "سميرة خاشقجي", "given": "سميرة", "family": "خاشقجي", "initials": "س. خ."} {"name": "سميرة عزت", "given": "سميرة", "family": "عزت", "initials": "س. ع."} {"name": "سنباد", "given": "سنباد", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "س."} {"name": "سنباد", "order": "FAMILY_FIRST", "family": "سنباد", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "س."} {"name": "سندباد", "given": "سندباد", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "س."} {"name": "سندباد", "order": "FAMILY_FIRST", "family": "سندباد", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "س."} {"name": "سهيل الغزي", "given": "سهيل", "family": "الغزي", "initials": "س. ا."} {"name": "سوسن ربيع", "given": "سوسن", "family": "ربيع", "initials": "س. ر."} {"name": "سيد كريم", "given": "سيد", "family": "كريم", "initials": "س. ك."} {"name": "سينباد", "given": "سينباد", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "س."} {"name": "سينباد", "order": "FAMILY_FIRST", "family": "سينباد", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "س."} {"name": "سټوبر زیزیلیا همیش", "order": "FAMILY_FIRST", "given": "زیزیلیا", "middle": "همیش", "family": "سټوبر", "initials": "ز. ه. س."} {"name": "سید عبد الکریم عبد الحمید ایم پی", "order": "FAMILY_FIRST", "given": "عبد", "middle": "الکریم عبد الحمید ایم پی", "family": "سید", "initials": "ع. ا. ع. ا. ا. پ. س."} {"name": "سید، عبد الکریم عبد الحمید ایم پی", "order": "FAMILY_FIRST", "given": "عبد الکریم", "middle": "عبد الحمید ایم پی", "family": "سید", "initials": "ع. ا. ع. ا. ا. پ. س."} {"name": "سیروس قهرمانی", "given": "سیروس", "family": "قهرمانی", "initials": "س. ق."} {"name": "سیروس گرجستانی", "given": "سیروس", "family": "گرجستانی", "initials": "س. گ."} {"name": "سەسيليا شتوبەر", "given": "سەسيليا", "family": "شتوبەر", "initials": "س. ش."} {"name": "شازیہ اصغر", "given": "شازیہ", "family": "اصغر", "initials": "ش. ا."} {"name": "شاہدہ رضا", "given": "شاہدہ", "family": "رضا", "initials": "ش. ر."} {"name": "شاہین رضا", "given": "شاہین", "family": "رضا", "initials": "ش. ر."} {"name": "شبیر اختر", "given": "شبیر", "family": "اختر", "initials": "ش. ا."} {"name": "شتوبەر سەسيليا", "order": "FAMILY_FIRST", "given": "سەسيليا", "family": "شتوبەر", "initials": "س. ش."} {"name": "شتوبەر، سەسيليا حەمىش", "order": "FAMILY_FIRST", "given": "سەسيليا", "middle": "حەمىش", "family": "شتوبەر", "initials": "س. ح. ش."} {"name": "شهره آغداشلو", "given": "شهره", "family": "آغداشلو", "initials": "ش. آ."} {"name": "شيماء الصباغ", "given": "شيماء", "family": "الصباغ", "initials": "ش. ا."} {"name": "صابری محمد ہاشم", "order": "FAMILY_FIRST", "given": "محمد", "middle": "ہاشم", "family": "صابری", "initials": "م. ہ. ص."} {"name": "صابری، محمد ہاشم", "order": "FAMILY_FIRST", "given": "محمد", "middle": "ہاشم", "family": "صابری", "initials": "م. ہ. ص."} {"name": "صالح اسدی", "given": "صالح", "family": "اسدی", "initials": "ص. ا."} {"name": "صالح اللحيدان", "given": "صالح", "family": "اللحيدان", "initials": "ص. ا."} {"name": "صالح خالد الشهري", "given": "صالح", "middle": "خالد", "family": "الشهري", "initials": "ص. خ. ا."} {"name": "صلاح المُولَّد", "given": "صلاح", "family": "المُولَّد", "initials": "ص. ا."} {"name": "صنع الله إبراهيم", "given": "صنع", "middle": "الله", "family": "إبراهيم", "initials": "ص. ا. إ."} {"name": "طارق سلامة", "given": "طارق", "family": "سلامة", "initials": "ط. س."} {"name": "طارق عبد العزيز", "given": "طارق", "middle": "عبد", "family": "العزيز", "initials": "ط. ع. ا."} {"name": "طارق فتح", "given": "طارق", "family": "فتح", "initials": "ط. ف."} {"name": "طاہر داوڑ", "given": "طاہر", "family": "داوڑ", "initials": "ط. د."} {"name": "طلعت زكريا", "given": "طلعت", "family": "زكريا", "initials": "ط. ز."} {"name": "عائشة ذو الغنى", "given": "عائشة", "middle": "ذو", "family": "الغنى", "initials": "ع. ذ. ا."} {"name": "عادل التويجري", "given": "عادل", "family": "التويجري", "initials": "ع. ا."} {"name": "عارف علوی", "given": "عارف", "family": "علوی", "initials": "ع. ع."} {"name": "عارف وزیر", "given": "عارف", "family": "وزیر", "initials": "ع. و."} {"name": "عالي القرشي", "given": "عالي", "family": "القرشي", "initials": "ع. ا."} {"name": "عباس شفیعی", "given": "عباس", "family": "شفیعی", "initials": "ع. ش."} {"name": "عباس وفایی", "given": "عباس", "family": "وفایی", "initials": "ع. و."} {"name": "عباس کریمی", "given": "عباس", "family": "کریمی", "initials": "ع. ک."} {"name": "عبد الأكرم السقَّا", "given": "عبد الأكرم", "family": "السقَّا", "initials": "ع. ا. ا."} {"name": "عبد الإله نبهان", "given": "عبد الإله", "family": "نبهان", "initials": "ع. ا. ن."} {"name": "عبد الباسط بدر", "given": "عبد الباسط", "family": "بدر", "initials": "ع. ا. ب."} {"name": "عبد الجبار الضحاك", "given": "عبد الجبار", "family": "الضحاك", "initials": "ع. ا. ا."} {"name": "عبد الحليم قدور", "given": "عبد الحليم", "family": "قدور", "initials": "ع. ا. ق."} {"name": "عبد الرحمن السديس", "given": "عبد الرحمن", "family": "السديس", "initials": "ع. ا. ا."} {"name": "عبد الرحمن العجلان", "given": "عبد الرحمن", "family": "العجلان", "initials": "ع. ا. ا."} {"name": "عبد الرحيم منصور", "given": "عبد الرحيم", "family": "منصور", "initials": "ع. ا. م."} {"name": "عبد الستار إبراهيم", "given": "عبد الستار", "family": "إبراهيم", "initials": "ع. ا. إ."} {"name": "عبد العزيز الربيعي", "given": "عبد العزيز", "family": "الربيعي", "initials": "ع. ا. ا."} {"name": "عبد القدوس أبو صالح", "given": "عبد القدوس", "family": "أبو صالح", "initials": "ع. ا. ص."} {"name": "عبد اللطيف عبد الحميد", "given": "عبد اللطيف", "middle": "عبد", "family": "الحميد", "initials": "ع. ا. ع. ا."} {"name": "عبد الله الحقيل", "given": "عبد الله", "family": "الحقيل", "initials": "ع. ا. ا."} {"name": "عبد الهادي الباني", "given": "عبد الهادي", "family": "الباني", "initials": "ع. ا. ا."} {"name": "عبد الوكيل الهاشمي", "given": "عبد الوكيل", "family": "الهاشمي", "initials": "ع. ا. ا."} {"name": "عبد الکریم عبد الحمید سید ایم پی", "given": "عبد الکریم", "middle": "عبد الحمید سید ایم", "family": "پی", "initials": "ع. ا. ع. ا. س. ا. پ."} {"name": "عثمان آل حميد", "given": "عثمان", "family": "آل حميد", "initials": "ع. ح."} {"name": "عدلو، آیتین", "order": "FAMILY_FIRST", "given": "آیتین", "family": "عدلو", "initials": "آ. ع."} {"name": "عدنان بركات", "given": "عدنان", "family": "بركات", "initials": "ع. ب."} {"name": "عدنان خاشقجي", "given": "عدنان", "family": "خاشقجي", "initials": "ع. خ."} {"name": "عدنان مصطفى", "given": "عدنان", "family": "مصطفى", "initials": "ع. م."} {"name": "عرفان خان", "given": "عرفان", "family": "خان", "initials": "ع. خ."} {"name": "عرفان صدیقی", "given": "عرفان", "family": "صدیقی", "initials": "ع. ص."} {"name": "عزت‌الله انتظامی", "given": "عزت‌الله", "family": "انتظامی", "initials": "ع. ا."} {"name": "علي الرواس", "given": "علي", "family": "الرواس", "initials": "ع. ا."} {"name": "علي القرني", "given": "علي", "family": "القرني", "initials": "ع. ا."} {"name": "علی حاتمی", "given": "علی", "family": "حاتمی", "initials": "ع. ح."} {"name": "علیرضا بیرانوند", "given": "علیرضا", "family": "بیرانوند", "initials": "ع. ب."} {"name": "علی‌اصغر زارعی", "given": "علی‌اصغر", "family": "زارعی", "initials": "ع. ز."} {"name": "علی‌اکبر حیدری", "given": "علی‌اکبر", "family": "حیدری", "initials": "ع. ح."} {"name": "عمران خان", "given": "عمران", "family": "خان", "initials": "ع. خ."} {"name": "غادة السمان", "given": "غادة", "family": "السمان", "initials": "غ. ا."} {"name": "غسان جبري", "given": "غسان", "family": "جبري", "initials": "غ. ج."} {"name": "غلام اللہ خان", "given": "غلام", "middle": "اللہ", "family": "خان", "initials": "غ. ا. خ."} {"name": "فائق عرقسُوسي", "given": "فائق", "family": "عرقسُوسي", "initials": "ف. ع."} {"name": "فاروق خورشيد", "given": "فاروق", "family": "خورشيد", "initials": "ف. خ."} {"name": "فاروق عطية", "given": "فاروق", "family": "عطية", "initials": "ف. ع."} {"name": "فايزة حسين", "given": "فايزة", "family": "حسين", "initials": "ف. ح."} {"name": "فتحي قورة", "given": "فتحي", "family": "قورة", "initials": "ف. ق."} {"name": "فتح‌الله بنی‌صدر", "given": "فتح‌الله", "family": "بنی‌صدر", "initials": "ف. ب."} {"name": "فرح خان", "given": "فرح", "family": "خان", "initials": "ف. خ."} {"name": "فريد الأطرش", "given": "فريد", "family": "الأطرش", "initials": "ف. ا."} {"name": "فریدون ابراهیمی", "given": "فریدون", "family": "ابراهیمی", "initials": "ف. ا."} {"name": "فون برول، عايدة كورنيليا سيزار مارتن", "order": "FAMILY_FIRST", "given": "عايدة", "middle": "كورنيليا سيزار مارتن", "family": "فون برول", "initials": "ع. ك. س. م. ف. ب."} {"name": "فون بريۋل ادا كورنەليا", "order": "FAMILY_FIRST", "given": "بريۋل", "middle": "ادا كورنەليا", "family": "فون", "initials": "ب. ا. ك. ف."} {"name": "قالاجان قايرات", "given": "قالاجان", "family": "قايرات", "initials": "ق. ق."} {"name": "قايرات قالاجان", "order": "FAMILY_FIRST", "given": "قالاجان", "family": "قايرات", "initials": "ق. ق."} {"name": "قايرات، قالاجان", "order": "FAMILY_FIRST", "given": "قالاجان", "family": "قايرات", "initials": "ق. ق."} {"name": "كاثرين مولر", "given": "كاثرين", "family": "مولر", "initials": "ك. م."} {"name": "كارىباي مۇرات", "order": "FAMILY_FIRST", "given": "مۇرات", "family": "كارىباي", "initials": "م. ك."} {"name": "كارىباي، مۇرات", "order": "FAMILY_FIRST", "given": "مۇرات", "family": "كارىباي", "initials": "م. ك."} {"name": "كمال مجدي العامر", "given": "كمال", "middle": "مجدي", "family": "العامر", "initials": "ك. م. ا."} {"name": "كەيت مۇللەر", "given": "كەيت", "family": "مۇللەر", "initials": "ك. م."} {"name": "لطفي لبيب", "given": "لطفي", "family": "لبيب", "initials": "ل. ل."} {"name": "لويس جريس", "given": "لويس", "family": "جريس", "initials": "ل. ج."} {"name": "مازن المبارك", "given": "مازن", "family": "المبارك", "initials": "م. ا."} {"name": "ماقسات بالقىباي", "given": "ماقسات", "family": "بالقىباي", "initials": "م. ب."} {"name": "مانی شرما", "given": "مانی", "family": "شرما", "initials": "م. ش."} {"name": "محترم برٽرم ولبرفورس ھينري رابرٽ ووسٽر ايم پي", "given": "محترم", "middle": "برٽرم ولبرفورس ھينري رابرٽ ووسٽر ايم", "family": "پي", "initials": "م. ب. و. ھ. ر. و. ا. پ."} {"name": "محمد البغدادي", "given": "محمد", "family": "البغدادي", "initials": "م. ا."} {"name": "محمد الزعبي", "given": "محمد", "family": "الزعبي", "initials": "م. ا."} {"name": "محمد بن سلمان", "given": "محمد", "family": "بن سلمان", "initials": "م. س."} {"name": "محمد خاتمی", "given": "محمد", "family": "خاتمی", "initials": "م. خ."} {"name": "محمد دهمان", "given": "محمد", "family": "دهمان", "initials": "م. د."} {"name": "محمد سليم الحلواني", "given": "محمد", "middle": "سليم", "family": "الحلواني", "initials": "م. س. ا."} {"name": "محمد سليمان", "given": "محمد", "family": "سليمان", "initials": "م. س."} {"name": "محمد ضياء الدين الصابوني", "given": "محمد", "middle": "ضياء الدين", "family": "الصابوني", "initials": "م. ض. ا. ا."} {"name": "محمد عطا", "given": "محمد", "family": "عطا", "initials": "م. ع."} {"name": "محمد نبيه المسيري", "given": "محمد", "middle": "نبيه", "family": "المسيري", "initials": "م. ن. ا."} {"name": "محمد و علي", "given": "محمد", "middle": "و", "family": "علي", "initials": "م. و. ع."} {"name": "محمد کوثری", "given": "محمد", "family": "کوثری", "initials": "م. ک."} {"name": "محمد ہاشم صابری", "given": "محمد", "middle": "ہاشم", "family": "صابری", "initials": "م. ہ. ص."} {"name": "محمدتقی شریعتی", "given": "محمدتقی", "family": "شریعتی", "initials": "م. ش."} {"name": "محمدعلی حفیظی", "given": "محمدعلی", "family": "حفیظی", "initials": "م. ح."} {"name": "محمدعلی ییلاقی اشرفی", "given": "محمدعلی", "middle": "ییلاقی", "family": "اشرفی", "initials": "م. ی. ا."} {"name": "محمود الزعبي", "given": "محمود", "family": "الزعبي", "initials": "م. ا."} {"name": "محمود جركس", "given": "محمود", "family": "جركس", "initials": "م. ج."} {"name": "محمود شاكر", "given": "محمود", "family": "شاكر", "initials": "م. ش."} {"name": "محمود فروغی", "given": "محمود", "family": "فروغی", "initials": "م. ف."} {"name": "مريم صالح", "given": "مريم", "family": "صالح", "initials": "م. ص."} {"name": "مریم نواز خان", "given": "مریم", "middle": "نواز", "family": "خان", "initials": "م. ن. خ."} {"name": "مصطفى خليل", "given": "مصطفى", "family": "خليل", "initials": "م. خ."} {"name": "ملایری، امید رضا", "order": "FAMILY_FIRST", "given": "امید", "middle": "رضا", "family": "ملایری", "initials": "ا. ر. م."} {"name": "ملر ڪئٿي", "order": "FAMILY_FIRST", "given": "ڪئٿي", "family": "ملر", "initials": "ڪ. م."} {"name": "منال الشريف", "given": "منال", "family": "الشريف", "initials": "م. ا."} {"name": "منصور حاتم", "order": "FAMILY_FIRST", "given": "حاتم", "family": "منصور", "initials": "ح. م."} {"name": "منصور، حاتم", "order": "FAMILY_FIRST", "given": "حاتم", "family": "منصور", "initials": "ح. م."} {"name": "منير", "given": "منير", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "م."} {"name": "منير", "order": "FAMILY_FIRST", "family": "منير", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "م."} {"name": "مها عامر", "given": "مها", "family": "عامر", "initials": "م. ع."} {"name": "موفق الخاني", "given": "موفق", "family": "الخاني", "initials": "م. ا."} {"name": "مولر کیتی", "order": "FAMILY_FIRST", "given": "کیتی", "family": "مولر", "initials": "ک. م."} {"name": "مولر، كاثرين", "order": "FAMILY_FIRST", "given": "كاثرين", "family": "مولر", "initials": "ك. م."} {"name": "مولر، کاتیا", "order": "FAMILY_FIRST", "given": "کاتیا", "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": "ك. م."} {"name": "مۇللەر، كەيت", "order": "FAMILY_FIRST", "given": "كەيت", "family": "مۇللەر", "initials": "ك. م."} {"name": "نادية زغلول", "given": "نادية", "family": "زغلول", "initials": "ن. ز."} {"name": "نادیہ خان", "given": "نادیہ", "family": "خان", "initials": "ن. خ."} {"name": "ناصر الأنصاري", "given": "ناصر", "family": "الأنصاري", "initials": "ن. ا."} {"name": "ناہید اختر", "given": "ناہید", "family": "اختر", "initials": "ن. ا."} {"name": "نبيل درويش", "given": "نبيل", "family": "درويش", "initials": "ن. د."} {"name": "نبيل فهمي", "given": "نبيل", "family": "فهمي", "initials": "ن. ف."} {"name": "نبيهة حداد", "given": "نبيهة", "family": "حداد", "initials": "ن. ح."} {"name": "ندیم رضا یاسمین راشد سدرہ منیر ایم ڈی ڈی ڈی ایس", "order": "FAMILY_FIRST", "given": "رضا", "middle": "یاسمین راشد سدرہ منیر ایم ڈی ڈی ڈی ایس", "family": "ندیم", "initials": "ر. ی. ر. س. م. ا. ڈ. ڈ. ڈ. ا. ن."} {"name": "ندیم رضا، یاسمین راشد سدرہ منیر ایم ڈی ڈی ڈی ایس", "order": "FAMILY_FIRST", "given": "یاسمین", "middle": "راشد سدرہ منیر ایم ڈی ڈی ڈی ایس", "family": "ندیم رضا", "initials": "ی. ر. س. م. ا. ڈ. ڈ. ڈ. ا. ن. ر."} {"name": "نسرين طافش", "given": "نسرين", "family": "طافش", "initials": "ن. ط."} {"name": "نعيم عيسى", "given": "نعيم", "family": "عيسى", "initials": "ن. ع."} {"name": "نهلة القدسي", "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": "هشام حافظ", "given": "هشام", "family": "حافظ", "initials": "ه. ح."} {"name": "هشام سليم", "given": "هشام", "family": "سليم", "initials": "ه. س."} {"name": "هشام عباس", "given": "هشام", "family": "عباس", "initials": "ه. ع."} {"name": "هشام فؤاد", "given": "هشام", "family": "فؤاد", "initials": "ه. ف."} {"name": "وان برل گونزالس ڊومنگو پروفيسر ڊاڪٽر ايڊا ڪورنيليا سيزر مارٽن ايم ڊي ڊي ڊي ايس", "order": "FAMILY_FIRST", "given": "برل", "middle": "گونزالس ڊومنگو پروفيسر ڊاڪٽر ايڊا ڪورنيليا سيزر مارٽن ايم ڊي ڊي ڊي ايس", "family": "وان", "initials": "ب. گ. ڊ. پ. ڊ. ا. ڪ. س. م. ا. ڊ. ڊ. ڊ. ا. و."} {"name": "وان برل گونزالس ڊومنگو, پروفيسر ڊاڪٽر ايڊا ڪورنيليا سيزر مارٽن ايم ڊي ڊي ڊي ايس", "given": "پروفيسر", "middle": "ڊاڪٽر ايڊا ڪورنيليا سيزر مارٽن ايم ڊي ڊي ڊي ايس", "family": "وان برل گونزالس ڊومنگو", "initials": "پ. ڊ. ا. ڪ. س. م. ا. ڊ. ڊ. ڊ. ا. و. ب. گ. ڊ."} {"name": "وان بروهل، پروفسور آدا کورنلیا سزار مارتین جونیور، دکترای دندانپزشکی", "order": "FAMILY_FIRST", "given": "پروفسور", "middle": "آدا کورنلیا سزار مارتین", "family": "وان بروهل", "suffix": "جونیور, دکترای دندانپزشکی", "ambiguities": ["COMMA_STRUCTURE"], "initials": "پ. آ. ک. س. م. و. ب."} {"name": "واټسن جان همیش", "order": "FAMILY_FIRST", "given": "جان", "middle": "همیش", "family": "واټسن", "initials": "ج. ه. و."} {"name": "واٽسن ميري سيو ھامش", "order": "FAMILY_FIRST", "given": "ميري", "middle": "سيو ھامش", "family": "واٽسن", "initials": "م. س. ھ. و."} {"name": "وداد أنطوان", "given": "وداد", "family": "أنطوان", "initials": "و. أ."} {"name": "وديع بشور", "given": "وديع", "family": "بشور", "initials": "و. ب."} {"name": "وليد الشهري", "given": "وليد", "family": "الشهري", "initials": "و. ا."} {"name": "ووسټر ښاغلی. برټرام ولبرفورس هنري رابرټ ایم پی", "order": "FAMILY_FIRST", "given": "ښاغلی.", "middle": "برټرام ولبرفورس هنري رابرټ ایم پی", "family": "ووسټر", "initials": "ښ. ب. و. ه. ر. ا. پ. و."} {"name": "ووسٽر محترم برٽرم ولبرفورس ھينري رابرٽ ايم پي", "order": "FAMILY_FIRST", "given": "محترم", "middle": "برٽرم ولبرفورس ھينري رابرٽ ايم پي", "family": "ووسٽر", "initials": "م. ب. و. ھ. ر. ا. پ. و."} {"name": "وون لیوه بیکر شمیت پروفیسور ډاکټر اډا کورنیلیا ایوا سوفیا ایم ‌ډی ډیی ډیی ایس", "order": "FAMILY_FIRST", "given": "لیوه", "middle": "بیکر شمیت پروفیسور ډاکټر اډا کورنیلیا ایوا سوفیا ایم ‌ډی ډیی ډیی ایس", "family": "وون", "initials": "ل. ب. ش. پ. ډ. ا. ک. ا. س. ا. ‌. ډ. ډ. ا. و."} {"name": "وون لیوه بیکر شمیت, پروفیسور ډاکټر اډا کورنیلیا ایوا سوفیا ایم ‌ډی ډیی ډیی ایس", "given": "پروفیسور", "middle": "ډاکټر اډا کورنیلیا ایوا سوفیا ایم ‌ډی ډیی ډیی ایس", "family": "وون لیوه بیکر شمیت", "initials": "پ. ډ. ا. ک. ا. س. ا. ‌. ډ. ډ. ا. و. ل. ب. ش."} {"name": "يوسف إدريس", "given": "يوسف", "family": "إدريس", "initials": "ي. إ."} {"name": "يوسف السالم", "given": "يوسف", "family": "السالم", "initials": "ي. ا."} {"name": "يوسف مكي", "given": "يوسف", "family": "مكي", "initials": "ي. م."} {"name": "پردیس افکاری", "given": "پردیس", "family": "افکاری", "initials": "پ. ا."} {"name": "پروفسور آدا کورنلیا سزار مارتین وان بروهل جونیور، دکترای دندانپزشکی", "given": "دکترای", "middle": "دندانپزشکی", "family": "پروفسور آدا کورنلیا سزار مارتین وان بروهل", "suffix": "جونیور", "initials": "د. د. پ. آ. ک. س. م. و. ب."} {"name": "پروفيسر ڊاڪٽر ايڊا ڪورنيليا سيزر مارٽن وان برل گونزالس ڊومنگو ايم ڊي ڊي ڊي ايس", "given": "پروفيسر", "middle": "ڊاڪٽر ايڊا ڪورنيليا سيزر مارٽن وان برل گونزالس ڊومنگو ايم ڊي ڊي ڊي", "family": "ايس", "initials": "پ. ڊ. ا. ڪ. س. م. و. ب. گ. ڊ. ا. ڊ. ڊ. ڊ. ا."} {"name": "پروفیسور ډاکټر اډا کورنیلیا ایوا سوفیا وون لیوه بیکر شمیت ایم ‌ډی ډیی ډیی ایس", "given": "پروفیسور", "middle": "ډاکټر اډا کورنیلیا ایوا سوفیا وون لیوه بیکر شمیت ایم ‌ډی ډیی ډیی", "family": "ایس", "initials": "پ. ډ. ا. ک. ا. س. و. ل. ب. ش. ا. ‌. ډ. ډ. ا."} {"name": "ژیلا", "given": "ژیلا", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "ژ."} {"name": "ژیلا", "order": "FAMILY_FIRST", "family": "ژیلا", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "ژ."} {"name": "ښاغلی. برټرام ولبرفورس هنري رابرټ ووسټر ایم پی", "title": "ښاغلی.", "given": "برټرام", "middle": "ولبرفورس هنري رابرټ ووسټر ایم", "family": "پی", "initials": "ب. و. ه. ر. و. ا. پ."} {"name": "کاتیا مولر", "given": "کاتیا", "family": "مولر", "initials": "ک. م."} {"name": "کمال ایرانی", "given": "کمال", "family": "ایرانی", "initials": "ک. ا."} {"name": "کیتی مولر", "given": "کیتی", "family": "مولر", "initials": "ک. م."} {"name": "کیوان وحدانی", "given": "کیوان", "family": "وحدانی", "initials": "ک. و."} {"name": "ڪئٿي ملر", "given": "ڪئٿي", "family": "ملر", "initials": "ڪ. م."} {"name": "ہاشم خان", "given": "ہاشم", "family": "خان", "initials": "ہ. خ."} {"name": "یاسمین راشد سدرہ منیر ندیم رضا ایم ڈی ڈی ڈی ایس", "given": "یاسمین", "middle": "راشد سدرہ منیر ندیم رضا ایم ڈی ڈی ڈی", "family": "ایس", "initials": "ی. ر. س. م. ن. ر. ا. ڈ. ڈ. ڈ. ا."} {"name": "अंजलि देवी", "given": "अंजलि", "family": "देवी", "initials": "अ. द."} {"name": "अंजलि शर्मा", "given": "अंजलि", "family": "शर्मा", "initials": "अ. श."} {"name": "अंबिका सोनी", "given": "अंबिका", "family": "सोनी", "initials": "अ. स."} {"name": "अक्षय कुमार", "given": "अक्षय", "family": "कुमार", "initials": "अ. क."} {"name": "अखिल मिश्रा", "given": "अखिल", "family": "मिश्रा", "initials": "अ. म."} {"name": "अजित पवार", "given": "अजित", "family": "पवार", "initials": "अ. प."} {"name": "अण्णा जोशी", "given": "अण्णा", "family": "जोशी", "initials": "अ. ज."} {"name": "अदिति शर्मा", "given": "अदिति", "family": "शर्मा", "initials": "अ. श."} {"name": "अनिल कपूर", "given": "अनिल", "family": "कपूर", "initials": "अ. क."} {"name": "अनिल जोशी", "given": "अनिल", "family": "जोशी", "initials": "अ. ज."} {"name": "अनिल मेहता", "given": "अनिल", "family": "मेहता", "initials": "अ. म."} {"name": "अनिल शर्मा", "given": "अनिल", "family": "शर्मा", "initials": "अ. श."} {"name": "अनुपम खेर", "given": "अनुपम", "family": "खेर", "initials": "अ. ख."} {"name": "अनुराधा आचार्य", "given": "अनुराधा", "family": "आचार्य", "initials": "अ. आ."} {"name": "अनुराधा कोइराला", "given": "अनुराधा", "family": "कोइराला", "initials": "अ. क."} {"name": "अनुराधा भट्टाचार्य", "given": "अनुराधा", "family": "भट्टाचार्य", "initials": "अ. भ."} {"name": "अभिषेक कपूर", "given": "अभिषेक", "family": "कपूर", "initials": "अ. क."} {"name": "अमर सिंह", "given": "अमर", "family": "सिंह", "initials": "अ. स."} {"name": "अमित कुमार", "given": "अमित", "family": "कुमार", "initials": "अ. क."} {"name": "अरुण कौल", "given": "अरुण", "family": "कौल", "initials": "अ. क."} {"name": "अरुणा ईरानी", "given": "अरुणा", "family": "ईरानी", "initials": "अ. ई."} {"name": "अर्जुन कपूर", "given": "अर्जुन", "family": "कपूर", "initials": "अ. क."} {"name": "आलिया हमीश हेमंत", "given": "आलिया", "middle": "हमीश", "family": "हेमंत", "initials": "आ. ह. ह."} {"name": "उत्तम कुँवर", "given": "उत्तम", "family": "कुँवर", "initials": "उ. क."} {"name": "एडा कर्नेलिया सिजर मार्टिन भोन ब्रुहल एमडी डिडिएस", "given": "एडा", "middle": "कर्नेलिया सिजर मार्टिन भोन ब्रुहल एमडी", "family": "डिडिएस", "initials": "ए. क. स. म. भ. ब. ए. ड."} {"name": "ऐश्वर्या राय", "given": "ऐश्वर्या", "family": "राय", "initials": "ऐ. र."} {"name": "काठे मुलर", "given": "काठे", "family": "मुलर", "initials": "क. म."} {"name": "काथा बाउमान", "given": "काथा", "family": "बाउमान", "initials": "क. ब."} {"name": "किरण देसाई", "given": "किरण", "family": "देसाई", "initials": "क. द."} {"name": "किशोर कुमार", "given": "किशोर", "family": "कुमार", "initials": "क. क."} {"name": "कीथ ब्राउन", "given": "कीथ", "family": "ब्राउन", "initials": "क. ब."} {"name": "कुणाल कपूर", "given": "कुणाल", "family": "कपूर", "initials": "क. क."} {"name": "क्याथी मुलर", "given": "क्याथी", "family": "मुलर", "initials": "क. म."} {"name": "गणेश आचार्य", "given": "गणेश", "family": "आचार्य", "initials": "ग. आ."} {"name": "गांवकर शांति", "order": "FAMILY_FIRST", "given": "शांति", "family": "गांवकर", "initials": "श. ग."} {"name": "गांवकर, शांति", "given": "शांति", "family": "गांवकर", "initials": "श. ग."} {"name": "गिरिजा देवी", "given": "गिरिजा", "family": "देवी", "initials": "ग. द."} {"name": "गीता उपाध्याय", "given": "गीता", "family": "उपाध्याय", "initials": "ग. उ."} {"name": "जाजिला हामिश स्टोबा", "given": "जाजिला", "middle": "हामिश", "family": "स्टोबा", "initials": "ज. ह. स."} {"name": "जाजिला हार्निस स्टोबर", "given": "जाजिला", "middle": "हार्निस", "family": "स्टोबर", "initials": "ज. ह. स."} {"name": "जोनस मैरी सिलविया", "order": "FAMILY_FIRST", "given": "मैरी", "middle": "सिलविया", "family": "जोनस", "initials": "म. स. ज."} {"name": "जोनस, मैरी सिलविया", "given": "मैरी", "middle": "सिलविया", "family": "जोनस", "initials": "म. स. ज."} {"name": "ज्ञानेन्द्र वीर विक्रम शाहदेव", "given": "ज्ञानेन्द्र", "middle": "वीर विक्रम", "family": "शाहदेव", "initials": "ज. व. व. श."} {"name": "दिशा", "given": "दिशा", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "द."} {"name": "दिशा", "order": "FAMILY_FIRST", "family": "दिशा", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "द."} {"name": "दीपा मेहता", "given": "दीपा", "family": "मेहता", "initials": "द. म."} {"name": "दीप्ति भटनागर", "given": "दीप्ति", "family": "भटनागर", "initials": "द. भ."} {"name": "दुर्गाबाई देशमुख", "given": "दुर्गाबाई", "family": "देशमुख", "initials": "द. द."} {"name": "दे सोजा ए मेनेजेस श्री फ्रांसिस शावियेर जुआंव आंतोन धा., एमपी", "order": "FAMILY_FIRST", "given": "एमपी", "family": "दे सोजा ए मेनेजेस श्री फ्रांसिस शावियेर जुआंव आंतोन धा.", "initials": "ए. द. स. ए. म. श. फ. श. ज. आ. ध."} {"name": "देवकी पंडित", "given": "देवकी", "family": "पंडित", "initials": "द. प."} {"name": "देशपांडे धिरज", "order": "FAMILY_FIRST", "given": "धिरज", "family": "देशपांडे", "initials": "ध. द."} {"name": "देशपांडे श्री. धिरज देशपांडे विजय देशपांडे क., एम. पी.", "order": "FAMILY_FIRST", "title": "एम.", "given": "पी.", "family": "देशपांडे श्री. धिरज देशपांडे विजय देशपांडे क.", "initials": "प. द. श. ध. द. व. द. क."} {"name": "देशपांडे, धिरज", "given": "धिरज", "family": "देशपांडे", "initials": "ध. द."} {"name": "देशपांडे, धिरज देशपांडे विजय देशपांडे", "given": "धिरज", "middle": "देशपांडे विजय देशपांडे", "family": "देशपांडे", "initials": "ध. द. व. द. द."} {"name": "धिरज देशपांडे", "given": "धिरज", "family": "देशपांडे", "initials": "ध. द."} {"name": "नम्रता श्रेष्ठ", "given": "नम्रता", "family": "श्रेष्ठ", "initials": "न. श."} {"name": "निधि राज़दान", "given": "निधि", "family": "राज़दान", "initials": "न. र."} {"name": "निधि सिंह", "given": "निधि", "family": "सिंह", "initials": "न. स."} {"name": "निरूपा रॉय", "given": "निरूपा", "family": "रॉय", "initials": "न. र."} {"name": "निशा अधिकारी", "given": "निशा", "family": "अधिकारी", "initials": "न. अ."} {"name": "नीरज चोपडा", "given": "नीरज", "family": "चोपडा", "initials": "न. च."} {"name": "नीरव मोदी", "given": "नीरव", "family": "मोदी", "initials": "न. म."} {"name": "पंकज त्रिपाठी", "given": "पंकज", "family": "त्रिपाठी", "initials": "प. त."} {"name": "पोख्रेल रमिता", "order": "FAMILY_FIRST", "given": "रमिता", "family": "पोख्रेल", "initials": "र. प."} {"name": "पोख्रेल, रमिता", "given": "रमिता", "family": "पोख्रेल", "initials": "र. प."} {"name": "प्रकाश जावडेकर", "given": "प्रकाश", "family": "जावडेकर", "initials": "प. ज."} {"name": "प्रतिभा सिंह", "given": "प्रतिभा", "family": "सिंह", "initials": "प. स."} {"name": "प्रवीण कुमार", "given": "प्रवीण", "family": "कुमार", "initials": "प. क."} {"name": "प्राची देसाई", "given": "प्राची", "family": "देसाई", "initials": "प. द."} {"name": "प्राची शाह", "given": "प्राची", "family": "शाह", "initials": "प. श."} {"name": "प्रिया बापट", "given": "प्रिया", "family": "बापट", "initials": "प. ब."} {"name": "प्रीत भरारा", "given": "प्रीत", "family": "भरारा", "initials": "प. भ."} {"name": "प्रीति झंगियानी", "given": "प्रीति", "family": "झंगियानी", "initials": "प. झ."} {"name": "प्रेमप्रसाद हिमलाल वास्तोला", "given": "प्रेमप्रसाद", "middle": "हिमलाल", "family": "वास्तोला", "initials": "प. ह. व."} {"name": "प्रो. डॉ. आडा कोर्नेलिया सेजार मार्टिन वॉन ब्रूह्ल गॉन्जालेज डोमिंगो जेआर, बी.ए. एल.एल.बी", "title": "प्रो. डॉ.", "given": "बी.ए.", "middle": "एल.एल.बी", "family": "आडा कोर्नेलिया सेजार मार्टिन वॉन ब्रूह्ल गॉन्जालेज डोमिंगो जेआर", "initials": "ब. ए. आ. क. स. म. व. ब. ग. ड. ज."} {"name": "प्रोफे.डॉ. अदा शर्मा सीझर मार्टिन व्हॉन ब्र्यूल ज्यु., एम. डी. डी. डी. एस.", "title": "प्रोफे.डॉ. एम.", "given": "डी.", "middle": "डी. डी. एस.", "family": "अदा शर्मा सीझर मार्टिन व्हॉन ब्र्यूल ज्यु.", "initials": "ड. ड. ड. ए. अ. श. स. म. व. ब. ज."} {"name": "प्रो॰ डॉ॰ माइकल जॉन वॉन वॉटसन चेस्टर, एम॰डी॰, पी॰एच॰डी॰", "given": "एम॰डी॰", "family": "प्रो॰ डॉ॰ माइकल जॉन वॉन वॉटसन चेस्टर", "suffix": "पी॰एच॰डी॰", "ambiguities": ["COMMA_STRUCTURE"], "initials": "ए. प. ड. म. ज. व. व. च."} {"name": "प्रो॰ डॉ॰ वॉन वॉटसन माइकल जॉन, एम॰डी॰, पी॰एच॰डी॰", "order": "FAMILY_FIRST", "given": "एम॰डी॰", "family": "प्रो॰ डॉ॰ वॉन वॉटसन माइकल जॉन", "suffix": "पी॰एच॰डी॰", "ambiguities": ["COMMA_STRUCTURE"], "initials": "ए. प. ड. व. व. म. ज."} {"name": "फडके रिधान समीर", "order": "FAMILY_FIRST", "given": "रिधान", "middle": "समीर", "family": "फडके", "initials": "र. स. फ."} {"name": "फडके, रिधान समीर", "given": "रिधान", "middle": "समीर", "family": "फडके", "initials": "र. स. फ."} {"name": "फर्नॅन्डेज मारिया हेलेना", "order": "FAMILY_FIRST", "given": "मारिया", "middle": "हेलेना", "family": "फर्नॅन्डेज", "initials": "म. ह. फ."} {"name": "फर्नॅन्डेज, मारिया हेलेना", "given": "मारिया", "middle": "हेलेना", "family": "फर्नॅन्डेज", "initials": "म. ह. फ."} {"name": "बर्ट्राम विलबरफोर्स हेनरी रोबर्ट वुस्टर एमपी", "given": "बर्ट्राम", "middle": "विलबरफोर्स हेनरी रोबर्ट वुस्टर", "family": "एमपी", "initials": "ब. व. ह. र. व. ए."} {"name": "बाउमान काथा", "order": "FAMILY_FIRST", "given": "काथा", "family": "बाउमान", "initials": "क. ब."} {"name": "बाउमान, काथा", "given": "काथा", "family": "बाउमान", "initials": "क. ब."} {"name": "बीना देवी", "given": "बीना", "family": "देवी", "initials": "ब. द."} {"name": "ब्राउन कीथ", "order": "FAMILY_FIRST", "given": "कीथ", "family": "ब्राउन", "initials": "क. ब."} {"name": "ब्राउन, कीथ", "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": "ब्रुहल", "middle": "एडा कर्नेलिया सिजर मार्टिन एमडी डिडिएस", "family": "भोन", "initials": "ब. ए. क. स. म. ए. ड. भ."} {"name": "मनीष मल्होत्रा", "given": "मनीष", "family": "मल्होत्रा", "initials": "म. म."} {"name": "ममता कुलकर्णी", "given": "ममता", "family": "कुलकर्णी", "initials": "म. क."} {"name": "माधुरी", "given": "माधुरी", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "म."} {"name": "माधुरी", "order": "FAMILY_FIRST", "family": "माधुरी", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "म."} {"name": "मानव कौल", "given": "मानव", "family": "कौल", "initials": "म. क."} {"name": "मारिया हेलेना फर्नॅन्डेज", "given": "मारिया", "middle": "हेलेना", "family": "फर्नॅन्डेज", "initials": "म. ह. फ."} {"name": "मीना शाह", "given": "मीना", "family": "शाह", "initials": "म. श."} {"name": "मुकेश अंबानी", "given": "मुकेश", "family": "अंबानी", "initials": "म. अ."} {"name": "मुलर काठे", "order": "FAMILY_FIRST", "given": "काठे", "family": "मुलर", "initials": "क. म."} {"name": "मुलर क्याथी", "order": "FAMILY_FIRST", "given": "क्याथी", "family": "मुलर", "initials": "क. म."} {"name": "मुलर, काठे", "given": "काठे", "family": "मुलर", "initials": "क. म."} {"name": "मुलर, क्याथी", "given": "क्याथी", "family": "मुलर", "initials": "क. म."} {"name": "मेरी रॉय", "given": "मेरी", "family": "रॉय", "initials": "म. र."} {"name": "मैरी सिलविया जोनस", "given": "मैरी", "middle": "सिलविया", "family": "जोनस", "initials": "म. स. ज."} {"name": "मोहित रैना", "given": "मोहित", "family": "रैना", "initials": "म. र."} {"name": "मोहित शर्मा", "given": "मोहित", "family": "शर्मा", "initials": "म. श."} {"name": "यशपाल शर्मा", "given": "यशपाल", "family": "शर्मा", "initials": "य. श."} {"name": "रजत कपूर", "given": "रजत", "family": "कपूर", "initials": "र. क."} {"name": "रणबीर कपूर", "given": "रणबीर", "family": "कपूर", "initials": "र. क."} {"name": "रमिता पोख्रेल", "given": "रमिता", "family": "पोख्रेल", "initials": "र. प."} {"name": "रमेश सक्सेना", "given": "रमेश", "family": "सक्सेना", "initials": "र. स."} {"name": "रवि राय", "given": "रवि", "family": "राय", "initials": "र. र."} {"name": "रवि शंकर", "given": "रवि", "family": "शंकर", "initials": "र. श."} {"name": "राजेश कुमार", "given": "राजेश", "family": "कुमार", "initials": "र. क."} {"name": "राजेश खन्ना", "given": "राजेश", "family": "खन्ना", "initials": "र. ख."} {"name": "राजेश शर्मा", "given": "राजेश", "family": "शर्मा", "initials": "र. श."} {"name": "राजेश हमाल", "given": "राजेश", "family": "हमाल", "initials": "र. ह."} {"name": "राम कपूर", "given": "राम", "family": "कपूर", "initials": "र. क."} {"name": "राम शर्मा साहब मोहन", "given": "राम", "family": "शर्मा", "suffix": "साहब मोहन", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "र. श."} {"name": "राम सुतार", "given": "राम", "family": "सुतार", "initials": "र. स."} {"name": "राय लक्ष्मी", "order": "FAMILY_FIRST", "given": "लक्ष्मी", "family": "राय", "initials": "ल. र."} {"name": "राहुल गाँधी", "given": "राहुल", "family": "गाँधी", "initials": "र. ग."} {"name": "राहुल बजाज", "order": "FAMILY_FIRST", "given": "बजाज", "family": "राहुल", "initials": "ब. र."} {"name": "राहुल रॉय", "given": "राहुल", "family": "रॉय", "initials": "र. र."} {"name": "राहुल शर्मा", "given": "राहुल", "family": "शर्मा", "initials": "र. श."} {"name": "रितेश देशमुख", "given": "रितेश", "family": "देशमुख", "initials": "र. द."} {"name": "रिधान समीर फडके", "given": "रिधान", "middle": "समीर", "family": "फडके", "initials": "र. स. फ."} {"name": "रेणुका सिंह", "given": "रेणुका", "family": "सिंह", "initials": "र. स."} {"name": "रॉबर्ट", "given": "रॉबर्ट", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "र."} {"name": "रॉबर्ट", "order": "FAMILY_FIRST", "family": "रॉबर्ट", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "र."} {"name": "रोहित शर्मा", "given": "रोहित", "family": "शर्मा", "initials": "र. श."} {"name": "लक्ष्मी गौतम", "given": "लक्ष्मी", "family": "गौतम", "initials": "ल. ग."} {"name": "लक्ष्मीप्रसाद देवकोटा", "given": "लक्ष्मीप्रसाद", "family": "देवकोटा", "initials": "ल. द."} {"name": "लता मंगेशकर", "given": "लता", "family": "मंगेशकर", "initials": "ल. म."} {"name": "ललित", "given": "ललित", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "ल."} {"name": "ललित", "order": "FAMILY_FIRST", "family": "ललित", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "ल."} {"name": "ललित मोहन शर्मा", "given": "ललित", "middle": "मोहन", "family": "शर्मा", "initials": "ल. म. श."} {"name": "ललित शर्मा", "given": "ललित", "family": "शर्मा", "initials": "ल. श."} {"name": "वसंत देसाई", "given": "वसंत", "family": "देसाई", "initials": "व. द."} {"name": "वसंत बापट", "given": "वसंत", "family": "बापट", "initials": "व. ब."} {"name": "वास्तोला प्रेमप्रसाद हिमलाल", "order": "FAMILY_FIRST", "given": "प्रेमप्रसाद", "middle": "हिमलाल", "family": "वास्तोला", "initials": "प. ह. व."} {"name": "वास्तोला, प्रेमप्रसाद हिमलाल", "given": "प्रेमप्रसाद", "middle": "हिमलाल", "family": "वास्तोला", "initials": "प. ह. व."} {"name": "विजय मल्ल", "given": "विजय", "family": "मल्ल", "initials": "व. म."} {"name": "वुस्टर बर्ट्राम विलबरफोर्स हेनरी रोबर्ट एमपी", "order": "FAMILY_FIRST", "given": "बर्ट्राम", "middle": "विलबरफोर्स हेनरी रोबर्ट एमपी", "family": "वुस्टर", "initials": "ब. व. ह. र. ए. व."} {"name": "वुस्टर, बर्ट्राम विलबरफोर्स हेनरी रोबर्ट", "given": "बर्ट्राम", "middle": "विलबरफोर्स हेनरी रोबर्ट", "family": "वुस्टर", "initials": "ब. व. ह. र. व."} {"name": "वॉन ब्रूह्ल गॉन्जालेज डोमिंगो प्रो. डॉ. आडा कोर्नेलिया सेजार मार्टिन जेआर, बी.ए. एल.एल.बी", "order": "FAMILY_FIRST", "given": "बी.ए.", "middle": "एल.एल.बी", "family": "वॉन ब्रूह्ल गॉन्जालेज डोमिंगो प्रो. डॉ. आडा कोर्नेलिया सेजार मार्टिन जेआर", "initials": "ब. ए. व. ब. ग. ड. प. ड. आ. क. स. म. ज."} {"name": "वॉन वॉटसन, माइकल जॉन एम॰डी॰, पी॰एच॰डी॰", "given": "माइकल", "middle": "जॉन एम॰डी॰", "family": "वॉन वॉटसन", "suffix": "पी॰एच॰डी॰", "ambiguities": ["COMMA_STRUCTURE"], "initials": "म. ज. ए. व. व."} {"name": "व्हॉन ब्र्यूल प्रोफे.डॉ. अदा शर्मा सीझर मार्टिन ज्यु., एम. डी. डी. डी. एस.", "order": "FAMILY_FIRST", "title": "एम.", "given": "डी.", "middle": "डी. डी. एस.", "family": "व्हॉन ब्र्यूल प्रोफे.डॉ. अदा शर्मा सीझर मार्टिन ज्यु.", "initials": "ड. ड. ड. ए. व. ब. प. अ. श. स. म. ज."} {"name": "शरद कपूर", "given": "शरद", "family": "कपूर", "initials": "श. क."} {"name": "शरद जोशी", "given": "शरद", "family": "जोशी", "initials": "श. ज."} {"name": "शरद पवार", "given": "शरद", "family": "पवार", "initials": "श. प."} {"name": "शरद मल्होत्रा", "given": "शरद", "family": "मल्होत्रा", "initials": "श. म."} {"name": "शर्मा ललित", "order": "FAMILY_FIRST", "given": "ललित", "family": "शर्मा", "initials": "ल. श."} {"name": "शर्मा ललित मोहन", "order": "FAMILY_FIRST", "given": "ललित", "middle": "मोहन", "family": "शर्मा", "initials": "ल. म. श."} {"name": "शर्मा, राम बाबू", "given": "राम", "middle": "बाबू", "family": "शर्मा", "initials": "र. ब. श."} {"name": "शर्मा, ललित", "given": "ललित", "family": "शर्मा", "initials": "ल. श."} {"name": "शर्मा, ललित कुमार एम॰डी॰", "given": "ललित", "middle": "कुमार एम॰डी॰", "family": "शर्मा", "initials": "ल. क. ए. श."} {"name": "शर्मा, ललित मोहन", "given": "ललित", "middle": "मोहन", "family": "शर्मा", "initials": "ल. म. श."} {"name": "शांता कुमार", "given": "शांता", "family": "कुमार", "initials": "श. क."} {"name": "शांति गांवकर", "given": "शांति", "family": "गांवकर", "initials": "श. ग."} {"name": "शिवांगी जोशी", "given": "शिवांगी", "family": "जोशी", "initials": "श. ज."} {"name": "श्री फ्रांसिस शावियेर जुआंव आंतोन दे सोजा ए मेनेजेस धा., एमपी", "title": "श्री", "given": "एमपी", "family": "फ्रांसिस शावियेर जुआंव आंतोन दे सोजा ए मेनेजेस धा.", "initials": "ए. फ. श. ज. आ. द. स. ए. म. ध."} {"name": "श्री ललित कुमार शर्मा एम॰डी॰", "title": "श्री", "given": "ललित", "middle": "कुमार शर्मा", "family": "एम॰डी॰", "initials": "ल. क. श. ए."} {"name": "श्री शर्मा ललित कुमार, एम॰डी॰", "order": "FAMILY_FIRST", "title": "श्री", "given": "एम॰डी॰", "family": "शर्मा ललित कुमार", "initials": "ए. श. ल. क."} {"name": "श्री. धिरज देशपांडे विजय देशपांडे देशपांडे क., एम. पी.", "title": "श्री. एम.", "given": "पी.", "family": "धिरज देशपांडे विजय देशपांडे देशपांडे क.", "initials": "प. ध. द. व. द. द. क."} {"name": "संजय ख़ान", "given": "संजय", "family": "ख़ान", "initials": "स. ख."} {"name": "सिंदबाद", "given": "सिंदबाद", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "स."} {"name": "सिंदबाद", "order": "FAMILY_FIRST", "family": "सिंदबाद", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "स."} {"name": "सिनबाद", "given": "सिनबाद", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "स."} {"name": "सिनबाद", "order": "FAMILY_FIRST", "family": "सिनबाद", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "स."} {"name": "सुनंदा पुष्कर", "given": "सुनंदा", "family": "पुष्कर", "initials": "स. प."} {"name": "सुन्दर", "given": "सुन्दर", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "स."} {"name": "सुन्दर", "order": "FAMILY_FIRST", "family": "सुन्दर", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "स."} {"name": "सुशील कोइराला", "given": "सुशील", "family": "कोइराला", "initials": "स. क."} {"name": "सोजा, फ्रांसिस शावियेर जुआंव आंतोन दे", "given": "फ्रांसिस", "middle": "शावियेर जुआंव आंतोन दे", "family": "सोजा", "initials": "फ. श. ज. आ. द. स."} {"name": "सोनिया गाँधी", "given": "सोनिया", "family": "गाँधी", "initials": "स. ग."} {"name": "सौंदर्या शर्मा", "given": "सौंदर्या", "family": "शर्मा", "initials": "स. श."} {"name": "स्टोबर जाजिला हार्निस", "order": "FAMILY_FIRST", "given": "जाजिला", "middle": "हार्निस", "family": "स्टोबर", "initials": "ज. ह. स."} {"name": "स्टोबर, जाजिला हार्निस", "given": "जाजिला", "middle": "हार्निस", "family": "स्टोबर", "initials": "ज. ह. स."} {"name": "स्टोबा जाजिला हामिश", "order": "FAMILY_FIRST", "given": "जाजिला", "middle": "हामिश", "family": "स्टोबा", "initials": "ज. ह. स."} {"name": "स्टोबा, जाजिला हामिश", "given": "जाजिला", "middle": "हामिश", "family": "स्टोबा", "initials": "ज. ह. स."} {"name": "स्मिता बंसल", "given": "स्मिता", "family": "बंसल", "initials": "स. ब."} {"name": "हेमंत आलिया हमीश", "order": "FAMILY_FIRST", "given": "आलिया", "middle": "हमीश", "family": "हेमंत", "initials": "आ. ह. ह."} {"name": "हेमंत, आलिया हमीश", "given": "आलिया", "middle": "हमीश", "family": "हेमंत", "initials": "आ. ह. ह."} {"name": "অজিত পাণ্ডে", "given": "অজিত", "family": "পাণ্ডে", "initials": "অ. প."} {"name": "অজিত রায়", "given": "অজিত", "family": "রায়", "initials": "অ. র."} {"name": "অনিল দে", "given": "অনিল", "family": "দে", "initials": "অ. দ."} {"name": "অনুষ্কা শর্মা", "given": "অনুষ্কা", "family": "শর্মা", "initials": "অ. শ."} {"name": "অবন্তিকা মিশ্র", "given": "অবন্তিকা", "family": "মিশ্র", "initials": "অ. ম."} {"name": "অরুণ মিত্র", "given": "অরুণ", "family": "মিত্র", "initials": "অ. ম."} {"name": "অসীম বসু", "given": "অসীম", "family": "বসু", "initials": "অ. ব."} {"name": "আইভি রহমান", "given": "আইভি", "family": "রহমান", "initials": "আ. র."} {"name": "আকরাম খান", "given": "আকরাম", "family": "খান", "initials": "আ. খ."} {"name": "আডা কৰ্ণেলিয়া ইভা চ’ফিয়া ভেন ডেন কুকুৰনেচীয়া এম. ডি. ডি. ডি. এছ", "given": "আডা", "middle": "কৰ্ণেলিয়া ইভা চ’ফিয়া ভেন ডেন কুকুৰনেচীয়া এম. ডি. ডি. ডি.", "family": "এছ", "initials": "আ. ক. ই. চ. ভ. ড. ক. এ. ড. ড. ড. এ."} {"name": "আনোয়ার হোসেন", "given": "আনোয়ার", "family": "হোসেন", "initials": "আ. হ."} {"name": "আবদুল করিম শাহ", "given": "আবদুল", "middle": "করিম", "family": "শাহ", "initials": "আ. ক. শ."} {"name": "আহমেদ কিরণ", "order": "FAMILY_FIRST", "given": "কিরণ", "family": "আহমেদ", "initials": "ক. আ."} {"name": "উদয় চোপড়া", "given": "উদয়", "family": "চোপড়া", "initials": "উ. চ."} {"name": "কাকতি নৰেন কলিতা ৰুমী বৰুৱা এম. পি.", "order": "FAMILY_FIRST", "given": "নৰেন", "middle": "কলিতা ৰুমী বৰুৱা এম. পি.", "family": "কাকতি", "initials": "ন. ক. ৰ. ব. এ. প. ক."} {"name": "কাকতি, নৰেন কলিতা ৰুমী বৰুৱা", "given": "নৰেন", "middle": "কলিতা ৰুমী বৰুৱা", "family": "কাকতি", "initials": "ন. ক. ৰ. ব. ক."} {"name": "কামাল হোসেন", "given": "কামাল", "family": "হোসেন", "initials": "ক. হ."} {"name": "কিরণ আহমেদ", "given": "কিরণ", "family": "আহমেদ", "initials": "ক. আ."} {"name": "কিশোৰ", "given": "কিশোৰ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "ক."} {"name": "কিশোৰ", "order": "FAMILY_FIRST", "family": "কিশোৰ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "ক."} {"name": "কুকুৰনেচীয়া, আডা কৰ্ণেলিয়া ইভা চ’ফিয়া ভেন ডেন", "given": "আডা", "middle": "কৰ্ণেলিয়া ইভা চ’ফিয়া ভেন ডেন", "family": "কুকুৰনেচীয়া", "initials": "আ. ক. ই. চ. ভ. ড. ক."} {"name": "কেনেথ পাওয়েল", "given": "কেনেথ", "family": "পাওয়েল", "initials": "ক. প."} {"name": "গাঙ্গুলী জনাব এমদাদ আলি অনিল কুমার এমপি", "order": "FAMILY_FIRST", "given": "জনাব", "middle": "এমদাদ আলি অনিল কুমার এমপি", "family": "গাঙ্গুলী", "initials": "জ. এ. আ. অ. ক. এ. গ."} {"name": "চৌধুরী শ্রেয়া", "order": "FAMILY_FIRST", "given": "শ্রেয়া", "family": "চৌধুরী", "initials": "শ. চ."} {"name": "জনাব এমদাদ আলি অনিল কুমার গাঙ্গুলী এমপি", "title": "জনাব", "given": "এমদাদ", "middle": "আলি অনিল কুমার গাঙ্গুলী", "family": "এমপি", "initials": "এ. আ. অ. ক. গ. এ."} {"name": "জাজিলিয়া হামিশ মুন্সি", "given": "জাজিলিয়া", "middle": "হামিশ", "family": "মুন্সি", "initials": "জ. হ. ম."} {"name": "জিমি জর্জ", "given": "জিমি", "family": "জর্জ", "initials": "জ. জ."} {"name": "জিয়াউর রহমান", "given": "জিয়াউর", "family": "রহমান", "initials": "জ. র."} {"name": "জুহি চাওলা", "given": "জুহি", "family": "চাওলা", "initials": "জ. চ."} {"name": "জেনদায়া", "given": "জেনদায়া", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "জ."} {"name": "জেনদায়া", "order": "FAMILY_FIRST", "family": "জেনদায়া", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "জ."} {"name": "দেবী শর্মা", "given": "দেবী", "family": "শর্মা", "initials": "দ. শ."} {"name": "নগেন বৰা", "given": "নগেন", "family": "বৰা", "initials": "ন. ব."} {"name": "নমিতা বৰুৱা", "given": "নমিতা", "family": "বৰুৱা", "initials": "ন. ব."} {"name": "নাহিদ ইসলাম", "given": "নাহিদ", "family": "ইসলাম", "initials": "ন. ই."} {"name": "নিখিল আডবাণী", "given": "নিখিল", "family": "আডবাণী", "initials": "ন. আ."} {"name": "নেহা শর্মা", "given": "নেহা", "family": "শর্মা", "initials": "ন. শ."} {"name": "নৰেন কলিতা ৰুমী বৰুৱা কাকতি এম. পি.", "given": "নৰেন", "middle": "কলিতা ৰুমী বৰুৱা কাকতি এম.", "family": "পি.", "initials": "ন. ক. ৰ. ব. ক. এ. প."} {"name": "পঙ্কজ মিশ্র", "given": "পঙ্কজ", "family": "মিশ্র", "initials": "প. ম."} {"name": "পঙ্কজ রায়", "given": "পঙ্কজ", "family": "রায়", "initials": "প. র."} {"name": "পুরুষোত্তম লাল", "given": "পুরুষোত্তম", "family": "লাল", "initials": "প. ল."} {"name": "প্রঃ ডঃ আদিরা ইভা সোফিয়া রহমান আলি তালুকদার এম.ডি.পিএইচ.ডি.", "given": "প্রঃ", "middle": "ডঃ আদিরা ইভা সোফিয়া রহমান আলি তালুকদার", "family": "এম.ডি.পিএইচ.ডি.", "initials": "প. ড. আ. ই. স. র. আ. ত. এ."} {"name": "প্রকাশ মেহরা", "given": "প্রকাশ", "family": "মেহরা", "initials": "প. ম."} {"name": "প্রকৌশলী. Sen", "given": "প্রকৌশলী.", "family": "Sen", "initials": "প. S."} {"name": "ফারহান আখতার", "given": "ফারহান", "family": "আখতার", "initials": "ফ. আ."} {"name": "বৰা নগেন", "order": "FAMILY_FIRST", "given": "নগেন", "family": "বৰা", "initials": "ন. ব."} {"name": "বৰা, নগেন", "given": "নগেন", "family": "বৰা", "initials": "ন. ব."} {"name": "বৰুৱা নমিতা", "order": "FAMILY_FIRST", "given": "নমিতা", "family": "বৰুৱা", "initials": "ন. ব."} {"name": "বৰুৱা মণিমালা অজিত", "order": "FAMILY_FIRST", "given": "মণিমালা", "middle": "অজিত", "family": "বৰুৱা", "initials": "ম. অ. ব."} {"name": "বৰুৱা, নমিতা", "given": "নমিতা", "family": "বৰুৱা", "initials": "ন. ব."} {"name": "বৰুৱা, মণিমালা অজিত", "given": "মণিমালা", "middle": "অজিত", "family": "বৰুৱা", "initials": "ম. অ. ব."} {"name": "ভেন ডেন কুকুৰনেচীয়া আডা কৰ্ণেলিয়া ইভা চ’ফিয়া এম. ডি. ডি. ডি. এছ", "order": "FAMILY_FIRST", "given": "ডেন", "middle": "কুকুৰনেচীয়া আডা কৰ্ণেলিয়া ইভা চ’ফিয়া এম. ডি. ডি. ডি. এছ", "family": "ভেন", "initials": "ড. ক. আ. ক. ই. চ. এ. ড. ড. ড. এ. ভ."} {"name": "মণিমালা অজিত বৰুৱা", "given": "মণিমালা", "middle": "অজিত", "family": "বৰুৱা", "initials": "ম. অ. ব."} {"name": "মনিকা আলী", "given": "মনিকা", "family": "আলী", "initials": "ম. আ."} {"name": "মনোজ মিত্র", "given": "মনোজ", "family": "মিত্র", "initials": "ম. ম."} {"name": "মিমি চক্রবর্তী", "given": "মিমি", "family": "চক্রবর্তী", "initials": "ম. চ."} {"name": "মুন্সি জাজিলিয়া হামিশ", "order": "FAMILY_FIRST", "given": "জাজিলিয়া", "middle": "হামিশ", "family": "মুন্সি", "initials": "জ. হ. ম."} {"name": "মেরি সু হামিশ রহমান", "given": "মেরি", "middle": "সু হামিশ", "family": "রহমান", "initials": "ম. স. হ. র."} {"name": "রবিন ঘোষ", "given": "রবিন", "family": "ঘোষ", "initials": "র. ঘ."} {"name": "রহমান আলি তালুকদার প্রঃ ডঃ আদিরা ইভা সোফিয়া এম.ডি.পিএইচ.ডি.", "order": "FAMILY_FIRST", "given": "আলি", "middle": "তালুকদার প্রঃ ডঃ আদিরা ইভা সোফিয়া এম.ডি.পিএইচ.ডি.", "family": "রহমান", "initials": "আ. ত. প. ড. আ. ই. স. এ. র."} {"name": "রহমান আলি তালুকদার, প্রঃ ডঃ আদিরা ইভা সোফিয়া এম.ডি.পিএইচ.ডি.", "given": "প্রঃ", "middle": "ডঃ আদিরা ইভা সোফিয়া এম.ডি.পিএইচ.ডি.", "family": "রহমান আলি তালুকদার", "initials": "প. ড. আ. ই. স. এ. র. আ. ত."} {"name": "রহমান মেরি সু হামিশ", "order": "FAMILY_FIRST", "given": "মেরি", "middle": "সু হামিশ", "family": "রহমান", "initials": "ম. স. হ. র."} {"name": "রাণী হামিদ", "given": "রাণী", "family": "হামিদ", "initials": "র. হ."} {"name": "রেণুকা দাশগুপ্ত", "given": "রেণুকা", "family": "দাশগুপ্ত", "initials": "র. দ."} {"name": "শ্রেয়া চৌধুরী", "given": "শ্রেয়া", "family": "চৌধুরী", "initials": "শ. চ."} {"name": "শৰ্মা ৰুলা কলিতা যতীন", "order": "FAMILY_FIRST", "given": "ৰুলা", "middle": "কলিতা যতীন", "family": "শৰ্মা", "initials": "ৰ. ক. য. শ."} {"name": "শৰ্মা, ৰুলা কলিতা যতীন", "given": "ৰুলা", "middle": "কলিতা যতীন", "family": "শৰ্মা", "initials": "ৰ. ক. য. শ."} {"name": "সত্যৰাম", "given": "সত্যৰাম", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "স."} {"name": "সত্যৰাম", "order": "FAMILY_FIRST", "family": "সত্যৰাম", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "স."} {"name": "সলিল সেন", "given": "সলিল", "family": "সেন", "initials": "স. স."} {"name": "সিনবাদ", "given": "সিনবাদ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "স."} {"name": "সিনবাদ", "order": "FAMILY_FIRST", "family": "সিনবাদ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "স."} {"name": "সুজাতা মহাপাত্র", "given": "সুজাতা", "family": "মহাপাত্র", "initials": "স. ম."} {"name": "সুনীল কর্মকার", "given": "সুনীল", "family": "কর্মকার", "initials": "স. ক."} {"name": "সুবোধ সরকার", "given": "সুবোধ", "family": "সরকার", "initials": "স. স."} {"name": "সুভাষ মুখোপাধ্যায়", "given": "সুভাষ", "family": "মুখোপাধ্যায়", "initials": "স. ম."} {"name": "সুলতান মাহমুদ", "given": "সুলতান", "family": "মাহমুদ", "initials": "স. ম."} {"name": "সেলিনা হোসেন", "given": "সেলিনা", "family": "হোসেন", "initials": "স. হ."} {"name": "হেলেনা খান", "given": "হেলেনা", "family": "খান", "initials": "হ. খ."} {"name": "ৰুলা কলিতা যতীন শৰ্মা", "given": "ৰুলা", "middle": "কলিতা যতীন", "family": "শৰ্মা", "initials": "ৰ. ক. য. শ."} {"name": "ਆਰੀਨ ਐਡਲਰ", "given": "ਆਰੀਨ", "family": "ਐਡਲਰ", "initials": "ਆ. ਐ."} {"name": "ਐਡਲਰ ਆਰੀਨ", "order": "FAMILY_FIRST", "given": "ਆਰੀਨ", "family": "ਐਡਲਰ", "initials": "ਆ. ਐ."} {"name": "ਐਡਲਰ, ਆਰੀਨ", "given": "ਆਰੀਨ", "family": "ਐਡਲਰ", "initials": "ਆ. ਐ."} {"name": "ਕੈਥੀ ਮੂਲਰ", "given": "ਕੈਥੀ", "family": "ਮੂਲਰ", "initials": "ਕ. ਮ."} {"name": "ਜ਼ਜ਼ਿਲੀਆ ਹੇਮਿਸ਼ ਸਟੋਬਰ", "given": "ਜ਼ਜ਼ਿਲੀਆ", "middle": "ਹੇਮਿਸ਼", "family": "ਸਟੋਬਰ", "initials": "ਜ. ਹ. ਸ."} {"name": "ਜ਼ੈਨਡਿਆ", "given": "ਜ਼ੈਨਡਿਆ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "ਜ."} {"name": "ਜ਼ੈਨਡਿਆ", "order": "FAMILY_FIRST", "family": "ਜ਼ੈਨਡਿਆ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "ਜ."} {"name": "ਜੌਨ ਹਾਮਿਸ਼ ਵਾਟਸਨ", "given": "ਜੌਨ", "middle": "ਹਾਮਿਸ਼", "family": "ਵਾਟਸਨ", "initials": "ਜ. ਹ. ਵ."} {"name": "ਪ੍ਰੋ. ਡਾ. ਐਡਾ ਕੋਰਨੇਲੀਆ ਈਵਾ ਸੋਫ਼ੀਆ ਵੈਨ ਡੇਨ ਵੌਲਫ਼ ਬੇਕਰ ਸਮਿੱਥ ਐੱਮ.ਡੀ. ਡੀ.ਡੀ.ਐੱਸ.", "given": "ਪ੍ਰੋ.", "middle": "ਡਾ. ਐਡਾ ਕੋਰਨੇਲੀਆ ਈਵਾ ਸੋਫ਼ੀਆ ਵੈਨ ਡੇਨ ਵੌਲਫ਼ ਬੇਕਰ ਸਮਿੱਥ ਐੱਮ.ਡੀ.", "family": "ਡੀ.ਡੀ.ਐੱਸ.", "initials": "ਪ. ਡ. ਐ. ਕ. ਈ. ਸ. ਵ. ਡ. ਵ. ਬ. ਸ. ਐ. ਡ."} {"name": "ਮੂਲਰ ਕੈਥੀ", "order": "FAMILY_FIRST", "given": "ਕੈਥੀ", "family": "ਮੂਲਰ", "initials": "ਕ. ਮ."} {"name": "ਮੂਲਰ, ਕੈਥੀ", "given": "ਕੈਥੀ", "family": "ਮੂਲਰ", "initials": "ਕ. ਮ."} {"name": "ਵਾਟਸਨ ਜੌਨ ਹਾਮਿਸ਼", "order": "FAMILY_FIRST", "given": "ਜੌਨ", "middle": "ਹਾਮਿਸ਼", "family": "ਵਾਟਸਨ", "initials": "ਜ. ਹ. ਵ."} {"name": "ਵਾਟਸਨ, ਜੌਨ ਹਾਮਿਸ਼", "given": "ਜੌਨ", "middle": "ਹਾਮਿਸ਼", "family": "ਵਾਟਸਨ", "initials": "ਜ. ਹ. ਵ."} {"name": "ਵੂਸਟਰ ਬਰਟਰਮ ਵਿਲਬਰਫ਼ੋਰਸ ਹੈਨਰੀ ਰਾਬਰਟ ਐਮ.ਪੀ.", "order": "FAMILY_FIRST", "given": "ਬਰਟਰਮ", "middle": "ਵਿਲਬਰਫ਼ੋਰਸ ਹੈਨਰੀ ਰਾਬਰਟ ਐਮ.ਪੀ.", "family": "ਵੂਸਟਰ", "initials": "ਬ. ਵ. ਹ. ਰ. ਐ. ਵ."} {"name": "ਵੂਸਟਰ, ਬਰਟਰਮ ਵਿਲਬਰਫ਼ੋਰਸ ਹੈਨਰੀ ਰਾਬਰਟ", "given": "ਬਰਟਰਮ", "middle": "ਵਿਲਬਰਫ਼ੋਰਸ ਹੈਨਰੀ ਰਾਬਰਟ", "family": "ਵੂਸਟਰ", "initials": "ਬ. ਵ. ਹ. ਰ. ਵ."} {"name": "ਵੈਨ ਡੇਨ ਵੌਲਫ਼ ਐਡਾ ਕੋਰਨੇਲੀਆ ਈਵਾ ਸੋਫ਼ੀਆ ਐੱਮ.ਡੀ. ਡੀ.ਡੀ.ਐੱਸ.", "order": "FAMILY_FIRST", "given": "ਡੇਨ", "middle": "ਵੌਲਫ਼ ਐਡਾ ਕੋਰਨੇਲੀਆ ਈਵਾ ਸੋਫ਼ੀਆ ਐੱਮ.ਡੀ. ਡੀ.ਡੀ.ਐੱਸ.", "family": "ਵੈਨ", "initials": "ਡ. ਵ. ਐ. ਕ. ਈ. ਸ. ਐ. ਡ. ਵ."} {"name": "ਵੌਲਫ਼, ਐਡਾ ਕੋਰਨੇਲੀਆ ਈਵਾ ਸੋਫ਼ੀਆ ਵੈਨ ਡੇਨ", "given": "ਐਡਾ", "middle": "ਕੋਰਨੇਲੀਆ ਈਵਾ ਸੋਫ਼ੀਆ ਵੈਨ ਡੇਨ", "family": "ਵੌਲਫ਼", "initials": "ਐ. ਕ. ਈ. ਸ. ਵ. ਡ. ਵ."} {"name": "ਸਟੋਬਰ ਜ਼ਜ਼ਿਲੀਆ ਹੇਮਿਸ਼", "order": "FAMILY_FIRST", "given": "ਜ਼ਜ਼ਿਲੀਆ", "middle": "ਹੇਮਿਸ਼", "family": "ਸਟੋਬਰ", "initials": "ਜ. ਹ. ਸ."} {"name": "ਸਟੋਬਰ, ਜ਼ਜ਼ਿਲੀਆ ਹੇਮਿਸ਼", "given": "ਜ਼ਜ਼ਿਲੀਆ", "middle": "ਹੇਮਿਸ਼", "family": "ਸਟੋਬਰ", "initials": "ਜ. ਹ. ਸ."} {"name": "ਸ਼੍ਰੀ ਬਰਟਰਮ ਵਿਲਬਰਫ਼ੋਰਸ ਹੈਨਰੀ ਰਾਬਰਟ ਵੂਸਟਰ ਐਮ.ਪੀ.", "given": "ਸ਼੍ਰੀ", "middle": "ਬਰਟਰਮ ਵਿਲਬਰਫ਼ੋਰਸ ਹੈਨਰੀ ਰਾਬਰਟ ਵੂਸਟਰ", "family": "ਐਮ.ਪੀ.", "initials": "ਸ. ਬ. ਵ. ਹ. ਰ. ਵ. ਐ."} {"name": "ਸਿੰਨਬੈਡ", "given": "ਸਿੰਨਬੈਡ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "ਸ."} {"name": "ਸਿੰਨਬੈਡ", "order": "FAMILY_FIRST", "family": "ਸਿੰਨਬੈਡ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "ਸ."} {"name": "અદાણી આઇરીન", "order": "FAMILY_FIRST", "given": "આઇરીન", "family": "અદાણી", "initials": "આ. અ."} {"name": "અદાણી, આઇરીન", "given": "આઇરીન", "family": "અદાણી", "initials": "આ. અ."} {"name": "આઇરીન અદાણી", "given": "આઇરીન", "family": "અદાણી", "initials": "આ. અ."} {"name": "કિરણ", "given": "કિરણ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "ક."} {"name": "કિરણ", "order": "FAMILY_FIRST", "family": "કિરણ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "ક."} {"name": "કુમાર શાહ શ્રી. પટેલ ખુશી કિંજલ જૂનિયર, એમપી", "order": "FAMILY_FIRST", "given": "એમપી", "family": "કુમાર શાહ શ્રી. પટેલ ખુશી કિંજલ", "suffix": "જૂનિયર", "initials": "એ. ક. શ. શ. પ. ખ. ક."} {"name": "કૅથિ મુલર", "given": "કૅથિ", "family": "મુલર", "initials": "ક. મ."} {"name": "ખુશી પટેલ હેતલ દવે", "given": "ખુશી", "middle": "પટેલ હેતલ", "family": "દવે", "initials": "ખ. પ. હ. દ."} {"name": "જઝિલીયા હૅમીશ સ્ટૂબર", "given": "જઝિલીયા", "middle": "હૅમીશ", "family": "સ્ટૂબર", "initials": "જ. હ. સ."} {"name": "દવે ખુશી પટેલ હેતલ", "order": "FAMILY_FIRST", "given": "ખુશી", "middle": "પટેલ હેતલ", "family": "દવે", "initials": "ખ. પ. હ. દ."} {"name": "દવે, ખુશી પટેલ હેતલ", "given": "ખુશી", "middle": "પટેલ હેતલ", "family": "દવે", "initials": "ખ. પ. હ. દ."} {"name": "પ્રો. ડૉ. એડા કૉર્નેલિયા સિઝર માર્ટિન ફૉન્ બ્રાઑલ્ જૂનિયર, એમ.ડી. ડી.ડી.એસ.", "given": "એમ.ડી.", "middle": "ડી.ડી.એસ.", "family": "પ્રો. ડૉ. એડા કૉર્નેલિયા સિઝર માર્ટિન ફૉન્ બ્રાઑલ્", "suffix": "જૂનિયર", "initials": "એ. ડ. પ. ડ. એ. ક. સ. મ. ફ. બ."} {"name": "ફૉન્ બ્રાઑલ્ પ્રો. ડૉ. એડા કૉર્નેલિયા સિઝર માર્ટિન જૂનિયર, એમ.ડી. ડી.ડી.એસ.", "order": "FAMILY_FIRST", "given": "એમ.ડી.", "middle": "ડી.ડી.એસ.", "family": "ફૉન્ બ્રાઑલ્ પ્રો. ડૉ. એડા કૉર્નેલિયા સિઝર માર્ટિન", "suffix": "જૂનિયર", "initials": "એ. ડ. ફ. બ. પ. ડ. એ. ક. સ. મ."} {"name": "બ્રાઑલ્, એડા કૉર્નેલિયા સિઝર માર્ટિન ફૉન્", "given": "એડા", "middle": "કૉર્નેલિયા સિઝર માર્ટિન ફૉન્", "family": "બ્રાઑલ્", "initials": "એ. ક. સ. મ. ફ. બ."} {"name": "મુલર કૅથિ", "order": "FAMILY_FIRST", "given": "કૅથિ", "family": "મુલર", "initials": "ક. મ."} {"name": "મુલર, કૅથિ", "given": "કૅથિ", "family": "મુલર", "initials": "ક. મ."} {"name": "શાહ, પટેલ ખુશી કિંજલ કુમાર", "given": "પટેલ", "middle": "ખુશી કિંજલ કુમાર", "family": "શાહ", "initials": "પ. ખ. ક. ક. શ."} {"name": "શ્રી. પટેલ ખુશી કિંજલ કુમાર શાહ જૂનિયર, એમપી", "given": "એમપી", "family": "શ્રી. પટેલ ખુશી કિંજલ કુમાર શાહ", "suffix": "જૂનિયર", "initials": "એ. શ. પ. ખ. ક. ક. શ."} {"name": "સિનબાદ", "given": "સિનબાદ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "સ."} {"name": "સિનબાદ", "order": "FAMILY_FIRST", "family": "સિનબાદ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "સ."} {"name": "સ્ટૂબર જઝિલીયા હૅમીશ", "order": "FAMILY_FIRST", "given": "જઝિલીયા", "middle": "હૅમીશ", "family": "સ્ટૂબર", "initials": "જ. હ. સ."} {"name": "સ્ટૂબર, જઝિલીયા હૅમીશ", "given": "જઝિલીયા", "middle": "હૅમીશ", "family": "સ્ટૂબર", "initials": "જ. હ. સ."} {"name": "ଆଇରେନ୍ ଆଡଲର୍", "given": "ଆଇରେନ୍", "family": "ଆଡଲର୍", "initials": "ଆ. ଆ."} {"name": "ଆଡଲର୍ ଆଇରେନ୍", "order": "FAMILY_FIRST", "given": "ଆଇରେନ୍", "family": "ଆଡଲର୍", "initials": "ଆ. ଆ."} {"name": "ଆଡଲର୍, ଆଇରେନ୍", "given": "ଆଇରେନ୍", "family": "ଆଡଲର୍", "initials": "ଆ. ଆ."} {"name": "ଆଡା କର୍ଣ୍ଣେଲିଆ ଇଭା ସୋଫିଆ ୱାନ୍ ଡେନ୍ ୱୋଲ୍ଫ ଏମ.ଡି.ପିଏଚ.ଡି", "given": "ଆଡା", "middle": "କର୍ଣ୍ଣେଲିଆ ଇଭା ସୋଫିଆ ୱାନ୍ ଡେନ୍ ୱୋଲ୍ଫ", "family": "ଏମ.ଡି.ପିଏଚ.ଡି", "initials": "ଆ. କ. ଇ. ସ. ୱ. ଡ. ୱ. ଏ."} {"name": "କାଥେ ମୁଲେର୍", "given": "କାଥେ", "family": "ମୁଲେର୍", "initials": "କ. ମ."} {"name": "ଜନ୍ ହାମିଶ ୱାଟସନ୍", "given": "ଜନ୍", "middle": "ହାମିଶ", "family": "ୱାଟସନ୍", "initials": "ଜ. ହ. ୱ."} {"name": "ଜେଆର୍ ହାମିଶ ଷ୍ଟୋବର", "given": "ଜେଆର୍", "middle": "ହାମିଶ", "family": "ଷ୍ଟୋବର", "initials": "ଜ. ହ. ଷ."} {"name": "ବର୍ଟ୍ରାମ ୱିଲବର୍ଫୋର୍ସ ହେନେରୀ ରୋବର୍ଟ ୱୋଷ୍ଟର ଏମପି", "given": "ବର୍ଟ୍ରାମ", "middle": "ୱିଲବର୍ଫୋର୍ସ ହେନେରୀ ରୋବର୍ଟ ୱୋଷ୍ଟର", "family": "ଏମପି", "initials": "ବ. ୱ. ହ. ର. ୱ. ଏ."} {"name": "ମୁଲେର୍ କାଥେ", "order": "FAMILY_FIRST", "given": "କାଥେ", "family": "ମୁଲେର୍", "initials": "କ. ମ."} {"name": "ମୁଲେର୍, କାଥେ", "given": "କାଥେ", "family": "ମୁଲେର୍", "initials": "କ. ମ."} {"name": "ଷ୍ଟୋବର ଜେଆର୍ ହାମିଶ", "order": "FAMILY_FIRST", "given": "ଜେଆର୍", "middle": "ହାମିଶ", "family": "ଷ୍ଟୋବର", "initials": "ଜ. ହ. ଷ."} {"name": "ଷ୍ଟୋବର, ଜେଆର୍ ହାମିଶ", "given": "ଜେଆର୍", "middle": "ହାମିଶ", "family": "ଷ୍ଟୋବର", "initials": "ଜ. ହ. ଷ."} {"name": "ସିନବାଦ", "given": "ସିନବାଦ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "ସ."} {"name": "ସିନବାଦ", "order": "FAMILY_FIRST", "family": "ସିନବାଦ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "ସ."} {"name": "ସିନବାଦ୍", "given": "ସିନବାଦ୍", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "ସ."} {"name": "ସିନବାଦ୍", "order": "FAMILY_FIRST", "family": "ସିନବାଦ୍", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "ସ."} {"name": "ୱାଟସନ୍ ଜନ୍ ହାମିଶ", "order": "FAMILY_FIRST", "given": "ଜନ୍", "middle": "ହାମିଶ", "family": "ୱାଟସନ୍", "initials": "ଜ. ହ. ୱ."} {"name": "ୱାଟସନ୍, ଜନ୍ ହାମିଶ", "given": "ଜନ୍", "middle": "ହାମିଶ", "family": "ୱାଟସନ୍", "initials": "ଜ. ହ. ୱ."} {"name": "ୱାନ୍ ଡେନ୍ ୱୋଲ୍ଫ ଆଡା କର୍ଣ୍ଣେଲିଆ ଇଭା ସୋଫିଆ ଏମ.ଡି.ପିଏଚ.ଡି", "order": "FAMILY_FIRST", "given": "ଡେନ୍", "middle": "ୱୋଲ୍ଫ ଆଡା କର୍ଣ୍ଣେଲିଆ ଇଭା ସୋଫିଆ ଏମ.ଡି.ପିଏଚ.ଡି", "family": "ୱାନ୍", "initials": "ଡ. ୱ. ଆ. କ. ଇ. ସ. ଏ. ୱ."} {"name": "ୱୋଲ୍ଫ, ଆଡା କର୍ଣ୍ଣେଲିଆ ଇଭା ସୋଫିଆ ୱାନ୍ ଡେନ୍", "given": "ଆଡା", "middle": "କର୍ଣ୍ଣେଲିଆ ଇଭା ସୋଫିଆ ୱାନ୍ ଡେନ୍", "family": "ୱୋଲ୍ଫ", "initials": "ଆ. କ. ଇ. ସ. ୱ. ଡ. ୱ."} {"name": "ୱୋଷ୍ଟର ବର୍ଟ୍ରାମ ୱିଲବର୍ଫୋର୍ସ ହେନେରୀ ରୋବର୍ଟ ଏମପି", "order": "FAMILY_FIRST", "given": "ବର୍ଟ୍ରାମ", "middle": "ୱିଲବର୍ଫୋର୍ସ ହେନେରୀ ରୋବର୍ଟ ଏମପି", "family": "ୱୋଷ୍ଟର", "initials": "ବ. ୱ. ହ. ର. ଏ. ୱ."} {"name": "ୱୋଷ୍ଟର, ବର୍ଟ୍ରାମ ୱିଲବର୍ଫୋର୍ସ ହେନେରୀ ରୋବର୍ଟ", "given": "ବର୍ଟ୍ରାମ", "middle": "ୱିଲବର୍ଫୋର୍ସ ହେନେରୀ ରୋବର୍ଟ", "family": "ୱୋଷ୍ଟର", "initials": "ବ. ୱ. ହ. ର. ୱ."} {"name": "அல்போன்சோ", "given": "அல்போன்சோ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "அ."} {"name": "அல்போன்சோ", "order": "FAMILY_FIRST", "family": "அல்போன்சோ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "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": "ஹ. அ."} {"name": "சோழன் ராஜ ராஜ", "order": "FAMILY_FIRST", "given": "ராஜ", "middle": "ராஜ", "family": "சோழன்", "initials": "ர. ர. ச."} {"name": "சோழன் ராஜேந்திர", "order": "FAMILY_FIRST", "given": "ராஜேந்திர", "family": "சோழன்", "initials": "ர. ச."} {"name": "திரு. சோழன் ராஜ ராஜன் ராஜன் எம்பி", "order": "FAMILY_FIRST", "given": "சோழன்", "middle": "ராஜ ராஜன் ராஜன் எம்பி", "family": "திரு.", "initials": "ச. ர. ர. ர. எ. த."} {"name": "பேராசிரியர் வான் ஹெர்னாண்டஸ் அல்போன்சோ எர்னஸ்டோ பிஎச்.டி", "order": "FAMILY_FIRST", "given": "வான்", "middle": "ஹெர்னாண்டஸ் அல்போன்சோ எர்னஸ்டோ பிஎச்.டி", "family": "பேராசிரியர்", "initials": "வ. ஹ. அ. எ. ப. ப."} {"name": "ராஜ ராஜ சோழன்", "given": "ராஜ", "middle": "ராஜ", "family": "சோழன்", "initials": "ர. ர. ச."} {"name": "ராஜ ராஜ, சோழன்", "given": "சோழன்", "family": "ராஜ ராஜ", "initials": "ச. ர. ர."} {"name": "ராஜ ராஜன் ராஜன் சோழன் எம்பி", "given": "ராஜ", "middle": "ராஜன் ராஜன் சோழன்", "family": "எம்பி", "initials": "ர. ர. ர. ச. எ."} {"name": "ராஜ ராஜன் ராஜன், சோழன்", "given": "சோழன்", "family": "ராஜ ராஜன் ராஜன்", "initials": "ச. ர. ர. ர."} {"name": "ராஜேந்திர சோழன்", "given": "ராஜேந்திர", "family": "சோழன்", "initials": "ர. ச."} {"name": "ராஜேந்திர சோழன்", "order": "FAMILY_FIRST", "given": "சோழன்", "family": "ராஜேந்திர", "initials": "ச. ர."} {"name": "ராஜேந்திரன்", "given": "ராஜேந்திரன்", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "ர."} {"name": "ராஜேந்திரன்", "order": "FAMILY_FIRST", "family": "ராஜேந்திரன்", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "ர."} {"name": "ஹெர்னாண்டஸ் அல்போன்சோ", "order": "FAMILY_FIRST", "given": "அல்போன்சோ", "family": "ஹெர்னாண்டஸ்", "initials": "அ. ஹ."} {"name": "ஹெர்னாண்டஸ் அல்போன்சோ எர்னஸ்டோ", "order": "FAMILY_FIRST", "given": "அல்போன்சோ", "middle": "எர்னஸ்டோ", "family": "ஹெர்னாண்டஸ்", "initials": "அ. எ. ஹ."} {"name": "ఏలేటి కుమార్ చంద్ర శేఖర్ అబ్దుల్ ఆజాద్ ఎం. పి.", "order": "FAMILY_FIRST", "given": "కుమార్", "middle": "చంద్ర శేఖర్ అబ్దుల్ ఆజాద్ ఎం. పి.", "family": "ఏలేటి", "initials": "క. చ. శ. అ. ఆ. ఎ. ప. ఏ."} {"name": "ఏలేటి కుమార్, చంద్ర శేఖర్ అబ్దుల్ ఆజాద్", "given": "చంద్ర", "middle": "శేఖర్ అబ్దుల్ ఆజాద్", "family": "ఏలేటి కుమార్", "initials": "చ. శ. అ. ఆ. ఏ. క."} {"name": "చంద్ర శేఖర్ అబ్దుల్ ఆజాద్ ఏలేటి కుమార్ ఎం. పి.", "given": "చంద్ర", "middle": "శేఖర్ అబ్దుల్ ఆజాద్ ఏలేటి కుమార్ ఎం.", "family": "పి.", "initials": "చ. శ. అ. ఆ. ఏ. క. ఎ. ప."} {"name": "చౌదరి, సూర్య ప్రతాప్ లాస్య ప్రియ శర్మ", "given": "సూర్య", "middle": "ప్రతాప్ లాస్య ప్రియ శర్మ", "family": "చౌదరి", "initials": "స. ప. ల. ప. శ. చ."} {"name": "ప్రసాద్ శంకర్ నరేష్", "order": "FAMILY_FIRST", "given": "శంకర్", "middle": "నరేష్", "family": "ప్రసాద్", "initials": "శ. న. ప."} {"name": "ప్రసాద్, శంకర్ నరేష్", "given": "శంకర్", "middle": "నరేష్", "family": "ప్రసాద్", "initials": "శ. న. ప."} {"name": "భరత్ రఘు", "order": "FAMILY_FIRST", "given": "రఘు", "family": "భరత్", "initials": "ర. భ."} {"name": "భరత్, రఘు", "given": "రఘు", "family": "భరత్", "initials": "ర. భ."} {"name": "రఘు భరత్", "given": "రఘు", "family": "భరత్", "initials": "ర. భ."} {"name": "రవి", "given": "రవి", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "ర."} {"name": "రవి", "order": "FAMILY_FIRST", "family": "రవి", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "ర."} {"name": "రాజశేఖర్", "given": "రాజశేఖర్", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "ర."} {"name": "రాజశేఖర్", "order": "FAMILY_FIRST", "family": "రాజశేఖర్", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "ర."} {"name": "రాజశేఖర్ రెడ్డి", "given": "రాజశేఖర్", "family": "రెడ్డి", "initials": "ర. ర."} {"name": "రెడ్డి రాజశేఖర్", "order": "FAMILY_FIRST", "given": "రాజశేఖర్", "family": "రెడ్డి", "initials": "ర. ర."} {"name": "రెడ్డి, రాజశేఖర్", "given": "రాజశేఖర్", "family": "రెడ్డి", "initials": "ర. ర."} {"name": "వర్మ సూర్య ప్రతాప్", "order": "FAMILY_FIRST", "given": "సూర్య", "middle": "ప్రతాప్", "family": "వర్మ", "initials": "స. ప. వ."} {"name": "వర్మ, సూర్య ప్రతాప్", "given": "సూర్య", "middle": "ప్రతాప్", "family": "వర్మ", "initials": "స. ప. వ."} {"name": "శంకర్ నరేష్ ప్రసాద్", "given": "శంకర్", "middle": "నరేష్", "family": "ప్రసాద్", "initials": "శ. న. ప."} {"name": "శర్మ చౌదరి సూర్య ప్రతాప్ లాస్య ప్రియ ఎం. డి. పిహెచ్. డి.", "order": "FAMILY_FIRST", "given": "చౌదరి", "middle": "సూర్య ప్రతాప్ లాస్య ప్రియ ఎం. డి. పిహెచ్. డి.", "family": "శర్మ", "initials": "చ. స. ప. ల. ప. ఎ. డ. ప. డ. శ."} {"name": "సూర్య ప్రతాప్ లాస్య ప్రియ శర్మ చౌదరి ఎం. డి. పిహెచ్. డి.", "given": "సూర్య", "middle": "ప్రతాప్ లాస్య ప్రియ శర్మ చౌదరి ఎం. డి. పిహెచ్.", "family": "డి.", "initials": "స. ప. ల. ప. శ. చ. ఎ. డ. ప. డ."} {"name": "సూర్య ప్రతాప్ వర్మ", "given": "సూర్య", "middle": "ప్రతాప్", "family": "వర్మ", "initials": "స. ప. వ."} {"name": "ಕ್ಯಾಥಿ ಮುಲ್ಲರ್", "given": "ಕ್ಯಾಥಿ", "family": "ಮುಲ್ಲರ್", "initials": "ಕ. ಮ."} {"name": "ಝಝಿಲಿಯಾ ಹಮಿಶ್ ಸ್ಟೋಬರ್", "given": "ಝಝಿಲಿಯಾ", "middle": "ಹಮಿಶ್", "family": "ಸ್ಟೋಬರ್", "initials": "ಝ. ಹ. ಸ."} {"name": "ಝೆಂಡಾಯಾ", "given": "ಝೆಂಡಾಯಾ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "ಝ."} {"name": "ಝೆಂಡಾಯಾ", "order": "FAMILY_FIRST", "family": "ಝೆಂಡಾಯಾ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "ಝ."} {"name": "ಪಾಟೀಲ್ ಶ್ರೀ. ನಾರಯಣ ಕುಮಾರ್ ಶಿವ ರಾಮ ಕಿರಿ, ಎಮ್‌ಪಿ", "order": "FAMILY_FIRST", "given": "ಎಮ್‌ಪಿ", "family": "ಪಾಟೀಲ್ ಶ್ರೀ. ನಾರಯಣ ಕುಮಾರ್ ಶಿವ ರಾಮ ಕಿರಿ", "initials": "ಎ. ಪ. ಶ. ನ. ಕ. ಶ. ರ. ಕ."} {"name": "ಪಾಟೀಲ್, ನಾರಯಣ ಕುಮಾರ್ ಶಿವ ರಾಮ", "given": "ನಾರಯಣ", "middle": "ಕುಮಾರ್ ಶಿವ ರಾಮ", "family": "ಪಾಟೀಲ್", "initials": "ನ. ಕ. ಶ. ರ. ಪ."} {"name": "ಪ್ರೊ. ಡಾ. ಅಡಾ ಕಾರ್ನೆಲಿಯಾ ಸಿಸಾರ್ ಮಾರ್ಟಿನ್ ವಾನ್ ಬ್ರಾಲ್ ಜೂನಿಯರ್, ಎಂ.ಡಿ ಡಿ.ಡಿ.ಎಸ್", "given": "ಎಂ.ಡಿ", "middle": "ಡಿ.ಡಿ.ಎಸ್", "family": "ಪ್ರೊ. ಡಾ. ಅಡಾ ಕಾರ್ನೆಲಿಯಾ ಸಿಸಾರ್ ಮಾರ್ಟಿನ್ ವಾನ್ ಬ್ರಾಲ್", "suffix": "ಜೂನಿಯರ್", "initials": "ಎ. ಡ. ಪ. ಡ. ಅ. ಕ. ಸ. ಮ. ವ. ಬ."} {"name": "ಬ್ರಾಲ್, ಅಡಾ ಕಾರ್ನೆಲಿಯಾ ಸಿಸಾರ್ ಮಾರ್ಟಿನ್ ವಾನ್", "given": "ಅಡಾ", "middle": "ಕಾರ್ನೆಲಿಯಾ ಸಿಸಾರ್ ಮಾರ್ಟಿನ್ ವಾನ್", "family": "ಬ್ರಾಲ್", "initials": "ಅ. ಕ. ಸ. ಮ. ವ. ಬ."} {"name": "ಮುಲ್ಲರ್ ಕ್ಯಾಥಿ", "order": "FAMILY_FIRST", "given": "ಕ್ಯಾಥಿ", "family": "ಮುಲ್ಲರ್", "initials": "ಕ. ಮ."} {"name": "ಮುಲ್ಲರ್, ಕ್ಯಾಥಿ", "given": "ಕ್ಯಾಥಿ", "family": "ಮುಲ್ಲರ್", "initials": "ಕ. ಮ."} {"name": "ಯಶ್ ವಿಜಯ ಲಕ್ಷ್ಮಿ ಹರೀಶ್", "order": "FAMILY_FIRST", "given": "ವಿಜಯ", "middle": "ಲಕ್ಷ್ಮಿ ಹರೀಶ್", "family": "ಯಶ್", "initials": "ವ. ಲ. ಹ. ಯ."} {"name": "ಯಶ್, ವಿಜಯ ಲಕ್ಷ್ಮಿ ಹರೀಶ್", "given": "ವಿಜಯ", "middle": "ಲಕ್ಷ್ಮಿ ಹರೀಶ್", "family": "ಯಶ್", "initials": "ವ. ಲ. ಹ. ಯ."} {"name": "ರಶ್ಮಿಕಾ ಶೆಟ್ಟಿ", "given": "ರಶ್ಮಿಕಾ", "family": "ಶೆಟ್ಟಿ", "initials": "ರ. ಶ."} {"name": "ವಾನ್ ಬ್ರಾಲ್ ಪ್ರೊ. ಡಾ. ಅಡಾ ಕಾರ್ನೆಲಿಯಾ ಸಿಸಾರ್ ಮಾರ್ಟಿನ್ ಜೂನಿಯರ್, ಎಂ.ಡಿ ಡಿ.ಡಿ.ಎಸ್", "order": "FAMILY_FIRST", "given": "ಎಂ.ಡಿ", "middle": "ಡಿ.ಡಿ.ಎಸ್", "family": "ವಾನ್ ಬ್ರಾಲ್ ಪ್ರೊ. ಡಾ. ಅಡಾ ಕಾರ್ನೆಲಿಯಾ ಸಿಸಾರ್ ಮಾರ್ಟಿನ್", "suffix": "ಜೂನಿಯರ್", "initials": "ಎ. ಡ. ವ. ಬ. ಪ. ಡ. ಅ. ಕ. ಸ. ಮ."} {"name": "ವಿಜಯ ಲಕ್ಷ್ಮಿ ಹರೀಶ್ ಯಶ್", "given": "ವಿಜಯ", "middle": "ಲಕ್ಷ್ಮಿ ಹರೀಶ್", "family": "ಯಶ್", "initials": "ವ. ಲ. ಹ. ಯ."} {"name": "ಶೆಟ್ಟಿ ರಶ್ಮಿಕಾ", "order": "FAMILY_FIRST", "given": "ರಶ್ಮಿಕಾ", "family": "ಶೆಟ್ಟಿ", "initials": "ರ. ಶ."} {"name": "ಶೆಟ್ಟಿ, ರಶ್ಮಿಕಾ", "given": "ರಶ್ಮಿಕಾ", "family": "ಶೆಟ್ಟಿ", "initials": "ರ. ಶ."} {"name": "ಶ್ರೀ. ನಾರಯಣ ಕುಮಾರ್ ಶಿವ ರಾಮ ಪಾಟೀಲ್ ಕಿರಿ, ಎಮ್‌ಪಿ", "given": "ಎಮ್‌ಪಿ", "family": "ಶ್ರೀ. ನಾರಯಣ ಕುಮಾರ್ ಶಿವ ರಾಮ ಪಾಟೀಲ್ ಕಿರಿ", "initials": "ಎ. ಶ. ನ. ಕ. ಶ. ರ. ಪ. ಕ."} {"name": "ಸಿನ್‍ಬಾದ್", "given": "ಸಿನ್‍ಬಾದ್", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "ಸ."} {"name": "ಸಿನ್‍ಬಾದ್", "order": "FAMILY_FIRST", "family": "ಸಿನ್‍ಬಾದ್", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "ಸ."} {"name": "ಸ್ಟೋಬರ್ ಝಝಿಲಿಯಾ ಹಮಿಶ್", "order": "FAMILY_FIRST", "given": "ಝಝಿಲಿಯಾ", "middle": "ಹಮಿಶ್", "family": "ಸ್ಟೋಬರ್", "initials": "ಝ. ಹ. ಸ."} {"name": "ಸ್ಟೋಬರ್, ಝಝಿಲಿಯಾ ಹಮಿಶ್", "given": "ಝಝಿಲಿಯಾ", "middle": "ಹಮಿಶ್", "family": "ಸ್ಟೋಬರ್", "initials": "ಝ. ಹ. ಸ."} {"name": "അശ്വതി നായർ", "given": "അശ്വതി", "family": "നായർ", "initials": "അ. ന."} {"name": "എയ്‌ഡ കോർണീലിയ സീസ൪ മാ൪ട്ടിൻ വാൻ ബ്ര്യൂൾ എം.ഡി. ഡി.ഡി.എസ്", "given": "എയ്‌ഡ", "middle": "കോർണീലിയ സീസ൪ മാ൪ട്ടിൻ വാൻ ബ്ര്യൂൾ എം.ഡി.", "family": "ഡി.ഡി.എസ്", "initials": "എ. ക. സ. മ. വ. ബ. എ. ഡ."} {"name": "കറുവാപ്പറമ്പിൽ ശ്രീ. തോമസ് കുരുവിള അന്തോണി ജൂനിയർ, എംപി", "order": "FAMILY_FIRST", "given": "എംപി", "family": "കറുവാപ്പറമ്പിൽ ശ്രീ. തോമസ് കുരുവിള അന്തോണി", "suffix": "ജൂനിയർ", "initials": "എ. ക. ശ. ത. ക. അ."} {"name": "കറുവാപ്പറമ്പിൽ, തോമസ് കുരുവിള അന്തോണി", "given": "തോമസ്", "middle": "കുരുവിള അന്തോണി", "family": "കറുവാപ്പറമ്പിൽ", "initials": "ത. ക. അ. ക."} {"name": "കാത്തി മുള്ളർ", "given": "കാത്തി", "family": "മുള്ളർ", "initials": "ക. മ."} {"name": "ജോൺ ചാണ്ടി പടവീട്ടിൽ", "given": "ജോൺ", "middle": "ചാണ്ടി", "family": "പടവീട്ടിൽ", "initials": "ജ. ച. പ."} {"name": "തോമസ് കുരുവിള അന്തോണി കറുവാപ്പറമ്പിൽ എംപി", "given": "തോമസ്", "middle": "കുരുവിള അന്തോണി കറുവാപ്പറമ്പിൽ", "family": "എംപി", "initials": "ത. ക. അ. ക. എ."} {"name": "ദീപ", "given": "ദീപ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "ദ."} {"name": "ദീപ", "order": "FAMILY_FIRST", "family": "ദീപ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "ദ."} {"name": "നായർ അശ്വതി", "order": "FAMILY_FIRST", "given": "അശ്വതി", "family": "നായർ", "initials": "അ. ന."} {"name": "നായർ, അശ്വതി", "given": "അശ്വതി", "family": "നായർ", "initials": "അ. ന."} {"name": "പടവീട്ടിൽ ജോൺ ചാണ്ടി", "order": "FAMILY_FIRST", "given": "ജോൺ", "middle": "ചാണ്ടി", "family": "പടവീട്ടിൽ", "initials": "ജ. ച. പ."} {"name": "പടവീട്ടിൽ, ജോൺ ചാണ്ടി", "given": "ജോൺ", "middle": "ചാണ്ടി", "family": "പടവീട്ടിൽ", "initials": "ജ. ച. പ."} {"name": "ബ്ര്യൂൾ, എയ്‌ഡ കോർണീലിയ സീസ൪ മാ൪ട്ടിൻ വാൻ", "given": "എയ്‌ഡ", "middle": "കോർണീലിയ സീസ൪ മാ൪ട്ടിൻ വാൻ", "family": "ബ്ര്യൂൾ", "initials": "എ. ക. സ. മ. വ. ബ."} {"name": "മുള്ളർ കാത്തി", "order": "FAMILY_FIRST", "given": "കാത്തി", "family": "മുള്ളർ", "initials": "ക. മ."} {"name": "മുള്ളർ, കാത്തി", "given": "കാത്തി", "family": "മുള്ളർ", "initials": "ക. മ."} {"name": "വാൻ ബ്ര്യൂൾ പ്രൊഫ. ഡോ. എയ്‌ഡ കോർണീലിയ സീസ൪ മാ൪ട്ടിൻ ജൂനിയർ, എം.ഡി. ഡി.ഡി.എസ്", "order": "FAMILY_FIRST", "given": "എം.ഡി.", "middle": "ഡി.ഡി.എസ്", "family": "വാൻ ബ്ര്യൂൾ പ്രൊഫ. ഡോ. എയ്‌ഡ കോർണീലിയ സീസ൪ മാ൪ട്ടിൻ", "suffix": "ജൂനിയർ", "initials": "എ. ഡ. വ. ബ. പ. ഡ. എ. ക. സ. മ."} {"name": "സസിലിയ ഹാമിഷ് സ്‌റ്റോബർ", "given": "സസിലിയ", "middle": "ഹാമിഷ്", "family": "സ്‌റ്റോബർ", "initials": "സ. ഹ. സ."} {"name": "സിൻബാദ്", "given": "സിൻബാദ്", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "സ."} {"name": "സിൻബാദ്", "order": "FAMILY_FIRST", "family": "സിൻബാദ്", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "സ."} {"name": "സ്‌റ്റോബർ സസിലിയ ഹാമിഷ്", "order": "FAMILY_FIRST", "given": "സസിലിയ", "middle": "ഹാമിഷ്", "family": "സ്‌റ്റോബർ", "initials": "സ. ഹ. സ."} {"name": "സ്‌റ്റോബർ, സസിലിയ ഹാമിഷ്", "given": "സസിലിയ", "middle": "ഹാമിഷ്", "family": "സ്‌റ്റോബർ", "initials": "സ. ഹ. സ."} {"name": "අයිරින් ඇඩ්ලර්", "given": "අයිරින්", "family": "ඇඩ්ලර්", "initials": "අ. ඇ."} {"name": "අයිරින් ඇඩ්ලර්", "order": "FAMILY_FIRST", "given": "ඇඩ්ලර්", "family": "අයිරින්", "initials": "ඇ. අ."} {"name": "ඇඩ්ලර් අයිරින්", "order": "FAMILY_FIRST", "given": "අයිරින්", "family": "ඇඩ්ලර්", "initials": "අ. ඇ."} {"name": "ඇඩා කොර්නේලියා සීසර් මාටින්, වොන් බෲල්", "given": "වොන්", "middle": "බෲල්", "family": "ඇඩා කොර්නේලියා සීසර් මාටින්", "initials": "ව. බ. ඇ. ක. ස. ම."} {"name": "කේතේ මුලර්", "given": "කේතේ", "family": "මුලර්", "initials": "ක. ම."} {"name": "කේතේ මුලර්", "order": "FAMILY_FIRST", "given": "මුලර්", "family": "කේතේ", "initials": "ම. ක."} {"name": "බර්ට්‍රම් විල්බර්ෆෝස් හෙන්රි රොබට්, වූස්ටර්", "given": "වූස්ටර්", "family": "බර්ට්‍රම් විල්බර්ෆෝස් හෙන්රි රොබට්", "initials": "ව. බ. ව. හ. ර."} {"name": "මහතා බර්ට්‍රම් විල්බර්ෆෝස් හෙන්රි රොබට් වූස්ටර් පා.ම.", "given": "මහතා", "middle": "බර්ට්‍රම් විල්බර්ෆෝස් හෙන්රි රොබට් වූස්ටර්", "family": "පා.ම.", "initials": "ම. බ. ව. හ. ර. ව. ප."} {"name": "මහාචාර්ය. ආචාර්ය. ඇඩා කොර්නේලියා සීසර් මාටින් වොන් බෲල් ගොන්සාලෙස් ඩොමින්ගෝ එම්.ඩී. පී.එච්.ඩී.", "given": "මහාචාර්ය.", "middle": "ආචාර්ය. ඇඩා කොර්නේලියා සීසර් මාටින් වොන් බෲල් ගොන්සාලෙස් ඩොමින්ගෝ එම්.ඩී.", "family": "පී.එච්.ඩී.", "initials": "ම. ආ. ඇ. ක. ස. ම. ව. බ. ග. ඩ. එ. ප."} {"name": "මුලර් කේතේ", "order": "FAMILY_FIRST", "given": "කේතේ", "family": "මුලර්", "initials": "ක. ම."} {"name": "මේරි සූ හමිෂ් වොට්සන්", "given": "මේරි", "middle": "සූ හමිෂ්", "family": "වොට්සන්", "initials": "ම. ස. හ. ව."} {"name": "මේරි සූ හමිෂ්, වොට්සන්", "given": "වොට්සන්", "family": "මේරි සූ හමිෂ්", "initials": "ව. ම. ස. හ."} {"name": "වූස්ටර් මහතා බර්ට්‍රම් විල්බර්ෆෝස් හෙන්රි රොබට් බාල, පා.ම.", "order": "FAMILY_FIRST", "given": "පා.ම.", "family": "වූස්ටර් මහතා බර්ට්‍රම් විල්බර්ෆෝස් හෙන්රි රොබට් බාල", "initials": "ප. ව. ම. බ. ව. හ. ර. බ."} {"name": "වොට්සන් මේරි සූ හමිෂ්", "order": "FAMILY_FIRST", "given": "මේරි", "middle": "සූ හමිෂ්", "family": "වොට්සන්", "initials": "ම. ස. හ. ව."} {"name": "වොන් බෲල් මහාචාර්ය. ආචාර්ය. ඇඩා කොර්නේලියා සීසර් මාටින් බාල, එම්.ඩී. පී.එච්.ඩී.", "order": "FAMILY_FIRST", "given": "එම්.ඩී.", "middle": "පී.එච්.ඩී.", "family": "වොන් බෲල් මහාචාර්ය. ආචාර්ය. ඇඩා කොර්නේලියා සීසර් මාටින් බාල", "initials": "එ. ප. ව. බ. ම. ආ. ඇ. ක. ස. ම. බ."} {"name": "ස්ටෝබර් සාසිලියා හමිෂ්", "order": "FAMILY_FIRST", "given": "සාසිලියා", "middle": "හමිෂ්", "family": "ස්ටෝබර්", "initials": "ස. හ. ස."} {"name": "සාසිලියා හමිෂ් ස්ටෝබර්", "given": "සාසිලියා", "middle": "හමිෂ්", "family": "ස්ටෝබර්", "initials": "ස. හ. ස."} {"name": "සාසිලියා හමිෂ්, ස්ටෝබර්", "given": "ස්ටෝබර්", "family": "සාසිලියා හමිෂ්", "initials": "ස. ස. හ."} {"name": "සින්බෑඩ්", "given": "සින්බෑඩ්", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "ස."} {"name": "සින්බෑඩ්", "order": "FAMILY_FIRST", "family": "සින්බෑඩ්", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "ස."} {"name": "සෙන්ඩයා", "given": "සෙන්ඩයා", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "ස."} {"name": "සෙන්ඩයා", "order": "FAMILY_FIRST", "family": "සෙන්ඩයා", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "ස."} {"name": "กล้าหาญ ไอริณ", "order": "FAMILY_FIRST", "given": "ไอริณ", "family": "กล้าหาญ", "initials": "ไ. ก."} {"name": "กล้าหาญ, ไอริณ", "given": "ไอริณ", "family": "กล้าหาญ", "initials": "ไ. ก."} {"name": "กุลวุฒิ วิทิตศานต์", "given": "กุลวุฒิ", "family": "วิทิตศานต์", "initials": "ก. ว."} {"name": "คุณ ปรีชา กล้าหาญ แสงระวี", "given": "คุณ", "middle": "ปรีชา กล้าหาญ", "family": "แสงระวี", "initials": "ค. ป. ก. แ."} {"name": "ชวเลิศ ชุ่มคำ", "given": "ชวเลิศ", "family": "ชุ่มคำ", "initials": "ช. ช."} {"name": "ชัชวาลย์ แท่งทอง", "given": "ชัชวาลย์", "family": "แท่งทอง", "initials": "ช. แ."} {"name": "ซาซิเลีย ฮามิช สโตเบอร์", "given": "ซาซิเลีย", "middle": "ฮามิช", "family": "สโตเบอร์", "initials": "ซ. ฮ. ส."} {"name": "ซินแบด", "given": "ซินแบด", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "ซ."} {"name": "ซินแบด", "order": "FAMILY_FIRST", "family": "ซินแบด", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "ซ."} {"name": "ทรัพย์สิรี แต้รัตนชัย", "given": "ทรัพย์สิรี", "family": "แต้รัตนชัย", "initials": "ท. แ."} {"name": "ธนา", "given": "ธนา", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "ธ."} {"name": "ธนา", "order": "FAMILY_FIRST", "family": "ธนา", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "ธ."} {"name": "บรืล, เอดา คอร์เนเลีย เซซาร์ มาร์ติน วอน", "given": "เอดา", "middle": "คอร์เนเลีย เซซาร์ มาร์ติน วอน", "family": "บรืล", "initials": "เ. ค. เ. ม. ว. บ."} {"name": "บุญศักดิ์ พลสนะ", "given": "บุญศักดิ์", "family": "พลสนะ", "initials": "บ. พ."} {"name": "บุศนันทน์ อึ๊งบำรุงพันธุ์", "given": "บุศนันทน์", "family": "อึ๊งบำรุงพันธุ์", "initials": "บ. อ."} {"name": "ประวัติ ปัตตพงศ์", "given": "ประวัติ", "family": "ปัตตพงศ์", "initials": "ป. ป."} {"name": "พรทิพย์ บูรณะประเสริฐสุข", "given": "พรทิพย์", "family": "บูรณะประเสริฐสุข", "initials": "พ. บ."} {"name": "พิชิตชัย มานี ชัยยศ", "order": "FAMILY_FIRST", "given": "มานี", "middle": "ชัยยศ", "family": "พิชิตชัย", "initials": "ม. ช. พ."} {"name": "พิชิตชัย, มานี ชัยยศ", "given": "มานี", "middle": "ชัยยศ", "family": "พิชิตชัย", "initials": "ม. ช. พ."} {"name": "มานี ชัยยศ พิชิตชัย", "given": "มานี", "middle": "ชัยยศ", "family": "พิชิตชัย", "initials": "ม. ช. พ."} {"name": "มึลเลอร์ เคเทอ", "order": "FAMILY_FIRST", "given": "เคเทอ", "family": "มึลเลอร์", "initials": "เ. ม."} {"name": "มึลเลอร์, เคเทอ", "given": "เคเทอ", "family": "มึลเลอร์", "initials": "เ. ม."} {"name": "วอน บรืล กอนซาเลซ โดมิงโก ศ.ดร. เอดา คอร์เนเลีย เซซาร์ มาร์ติน พ.บ. ท.บ.", "order": "FAMILY_FIRST", "given": "บรืล", "middle": "กอนซาเลซ โดมิงโก ศ.ดร. เอดา คอร์เนเลีย เซซาร์ มาร์ติน", "family": "วอน", "suffix": "พ.บ. ท.บ.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as middle (position)", "SUFFIX_OR_NAME: name as middle (position)"], "initials": "บ. ก. โ. ศ. เ. ค. เ. ม. ว."} {"name": "ศ.ดร. เอดา คอร์เนเลีย เซซาร์ มาร์ติน วอน บรืล พ.บ. ท.บ.", "given": "ศ.ดร.", "middle": "เอดา คอร์เนเลีย เซซาร์ มาร์ติน วอน", "family": "บรืล", "suffix": "พ.บ. ท.บ.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)", "SUFFIX_OR_NAME: name as family (position)"], "initials": "ศ. เ. ค. เ. ม. ว. บ."} {"name": "สมชาย ใจดี พ.บ.", "given": "สมชาย", "family": "ใจดี", "suffix": "พ.บ.", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as family (position)"], "initials": "ส. ใ."} {"name": "สโตเบอร์ ซาซิเลีย ฮามิช", "order": "FAMILY_FIRST", "given": "ซาซิเลีย", "middle": "ฮามิช", "family": "สโตเบอร์", "initials": "ซ. ฮ. ส."} {"name": "สโตเบอร์, ซาซิเลีย ฮามิช", "given": "ซาซิเลีย", "middle": "ฮามิช", "family": "สโตเบอร์", "initials": "ซ. ฮ. ส."} {"name": "เกรียงศักดิ์ โกวิทวาณิช", "given": "เกรียงศักดิ์", "family": "โกวิทวาณิช", "initials": "เ. โ."} {"name": "เคเทอ มึลเลอร์", "given": "เคเทอ", "family": "มึลเลอร์", "initials": "เ. ม."} {"name": "เดชาพล พัววรานุเคราะห์", "given": "เดชาพล", "family": "พัววรานุเคราะห์", "initials": "เ. พ."} {"name": "แสงระวี คุณ ปรีชา กล้าหาญ", "order": "FAMILY_FIRST", "given": "คุณ", "middle": "ปรีชา กล้าหาญ", "family": "แสงระวี", "initials": "ค. ป. ก. แ."} {"name": "แสงระวี, ปรีชา กล้าหาญ", "given": "ปรีชา", "middle": "กล้าหาญ", "family": "แสงระวี", "initials": "ป. ก. แ."} {"name": "ไอริณ กล้าหาญ", "given": "ไอริณ", "family": "กล้าหาญ", "initials": "ไ. ก."} {"name": "ຄຳຫຼ້າ", "given": "ຄຳຫຼ້າ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "ຄ."} {"name": "ຄຳຫຼ້າ", "order": "FAMILY_FIRST", "family": "ຄຳຫຼ້າ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "ຄ."} {"name": "ຊາຊິເລຍ ຮາມີສ ສໂຕເບີ", "given": "ຊາຊິເລຍ", "middle": "ຮາມີສ", "family": "ສໂຕເບີ", "initials": "ຊ. ຮ. ສ."} {"name": "ຊິນແບດ", "given": "ຊິນແບດ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "ຊ."} {"name": "ຊິນແບດ", "order": "FAMILY_FIRST", "family": "ຊິນແບດ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "ຊ."} {"name": "ດຣ. ປອ. ວອນ ບຣູລ ສຈ. ປອ. ເອດາ ຄໍນີເລຍ ເຊຊາ ມາຕິນ ຮຸ່ນລູກ", "order": "FAMILY_FIRST", "title": "ດຣ. ປອ.", "given": "ບຣູລ", "middle": "ສຈ. ປອ. ເອດາ ຄໍນີເລຍ ເຊຊາ ມາຕິນ ຮຸ່ນລູກ", "family": "ວອນ", "initials": "ບ. ສ. ປ. ເ. ຄ. ເ. ມ. ຮ. ວ."} {"name": "ດຣ. ປອ. ສຈ. ປອ. ເອດາ ຄໍນີເລຍ ເຊຊາ ມາຕິນ ວອນ ບຣູລ ຮຸ່ນລູກ", "title": "ດຣ. ປອ. ສຈ. ປອ.", "given": "ເອດາ", "middle": "ຄໍນີເລຍ ເຊຊາ ມາຕິນ ວອນ ບຣູລ", "family": "ຮຸ່ນລູກ", "initials": "ເ. ຄ. ເ. ມ. ວ. ບ. ຮ."} {"name": "ນາລິນ ເອກະພົນ", "given": "ນາລິນ", "family": "ເອກະພົນ", "initials": "ນ. ເ."} {"name": "ບຣູລ, ເອດາ ຄໍນີເລຍ ເຊຊາ ມາຕິນ ວອນ", "given": "ເອດາ", "middle": "ຄໍນີເລຍ ເຊຊາ ມາຕິນ ວອນ", "family": "ບຣູລ", "initials": "ເ. ຄ. ເ. ມ. ວ. ບ."} {"name": "ມະນີ ມະນີຈັນ ວັດທະນາ", "given": "ມະນີ", "middle": "ມະນີຈັນ", "family": "ວັດທະນາ", "initials": "ມ. ມ. ວ."} {"name": "ມູລເລີ ເຄທີ", "order": "FAMILY_FIRST", "given": "ເຄທີ", "family": "ມູລເລີ", "initials": "ເ. ມ."} {"name": "ມູລເລີ, ເຄທີ", "given": "ເຄທີ", "family": "ມູລເລີ", "initials": "ເ. ມ."} {"name": "ວັດທະນາ ມະນີ ມະນີຈັນ", "order": "FAMILY_FIRST", "given": "ມະນີ", "middle": "ມະນີຈັນ", "family": "ວັດທະນາ", "initials": "ມ. ມ. ວ."} {"name": "ວັດທະນາ, ມະນີ ມະນີຈັນ", "given": "ມະນີ", "middle": "ມະນີຈັນ", "family": "ວັດທະນາ", "initials": "ມ. ມ. ວ."} {"name": "ວູສເຕີ, ເບີແທຣັມ ວິວເບີຟອດຊ ເຮັນຣີ ໂຣເບີດ", "given": "ເບີແທຣັມ", "middle": "ວິວເບີຟອດຊ ເຮັນຣີ ໂຣເບີດ", "family": "ວູສເຕີ", "initials": "ເ. ວ. ເ. ໂ. ວ."} {"name": "ສ.ສ. ທ. ເບີແທຣັມ ວິວເບີຟອດຊ ເຮັນຣີ ໂຣເບີດ ວູສເຕີ ຮຸ່ນລູກ", "given": "ສ.ສ.", "middle": "ທ. ເບີແທຣັມ ວິວເບີຟອດຊ ເຮັນຣີ ໂຣເບີດ ວູສເຕີ", "family": "ຮຸ່ນລູກ", "initials": "ສ. ທ. ເ. ວ. ເ. ໂ. ວ. ຮ."} {"name": "ສ.ສ. ວູສເຕີ ທ. ເບີແທຣັມ ວິວເບີຟອດຊ ເຮັນຣີ ໂຣເບີດ ຮຸ່ນລູກ", "order": "FAMILY_FIRST", "given": "ວູສເຕີ", "middle": "ທ. ເບີແທຣັມ ວິວເບີຟອດຊ ເຮັນຣີ ໂຣເບີດ ຮຸ່ນລູກ", "family": "ສ.ສ.", "initials": "ວ. ທ. ເ. ວ. ເ. ໂ. ຮ. ສ."} {"name": "ສໂຕເບີ ຊາຊິເລຍ ຮາມີສ", "order": "FAMILY_FIRST", "given": "ຊາຊິເລຍ", "middle": "ຮາມີສ", "family": "ສໂຕເບີ", "initials": "ຊ. ຮ. ສ."} {"name": "ສໂຕເບີ, ຊາຊິເລຍ ຮາມີສ", "given": "ຊາຊິເລຍ", "middle": "ຮາມີສ", "family": "ສໂຕເບີ", "initials": "ຊ. ຮ. ສ."} {"name": "ເຄທີ ມູລເລີ", "given": "ເຄທີ", "family": "ມູລເລີ", "initials": "ເ. ມ."} {"name": "ເອກະພົນ ນາລິນ", "order": "FAMILY_FIRST", "given": "ນາລິນ", "family": "ເອກະພົນ", "initials": "ນ. ເ."} {"name": "ເອກະພົນ, ນາລິນ", "given": "ນາລິນ", "family": "ເອກະພົນ", "initials": "ນ. ເ."} {"name": "ကက်သီ မူလာ", "given": "ကက်သီ", "family": "မူလာ", "initials": "က. မ."} {"name": "စတိုဘာ ဇာဇီလာ ဟာမစ်ရှ်", "order": "FAMILY_FIRST", "given": "ဇာဇီလာ", "middle": "ဟာမစ်ရှ်", "family": "စတိုဘာ", "initials": "ဇ. ဟ. စ."} {"name": "စတိုဘာ၊ ဇာဇီလာ ဟာမစ်ရှ်", "order": "FAMILY_FIRST", "given": "ဇာဇီလာ", "middle": "ဟာမစ်ရှ်", "family": "စတိုဘာ၊", "initials": "ဇ. ဟ. စ."} {"name": "ဆင်းဘတ်", "given": "ဆင်းဘတ်", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "ဆ."} {"name": "ဆင်းဘတ်", "order": "FAMILY_FIRST", "family": "ဆင်းဘတ်", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "ဆ."} {"name": "ဇင်ဒါယာ", "given": "ဇင်ဒါယာ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "ဇ."} {"name": "ဇင်ဒါယာ", "order": "FAMILY_FIRST", "family": "ဇင်ဒါယာ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "ဇ."} {"name": "ဇာဇီလာ ဟာမစ်ရှ် စတိုဘာ", "given": "ဇာဇီလာ", "middle": "ဟာမစ်ရှ်", "family": "စတိုဘာ", "initials": "ဇ. ဟ. စ."} {"name": "ပရော်ဖက်ဆာ ဒေါက်တာ အာဒါ ကွန်နီလီယာ ဆီဇာ မာတင် ဝူစ်တာ ဗွန် ဝူစ်တာ ဘရူ ဂျူနီယာ၊ ဒေါက်တာ", "given": "ပရော်ဖက်ဆာ", "middle": "ဒေါက်တာ အာဒါ ကွန်နီလီယာ ဆီဇာ မာတင် ဝူစ်တာ ဗွန် ဝူစ်တာ ဘရူ ဂျူနီယာ၊", "family": "ဒေါက်တာ", "initials": "ပ. ဒ. အ. က. ဆ. မ. ဝ. ဗ. ဝ. ဘ. ဂ. ဒ."} {"name": "မစ္စတာ ဘာထရမ် ဝဲလ်ဘာဖို့စ် ဟင်နရီ ရောဘတ် ဝူစ်တာ ဂျူနီယာ၊ ဒေါက်တာ", "given": "မစ္စတာ", "middle": "ဘာထရမ် ဝဲလ်ဘာဖို့စ် ဟင်နရီ ရောဘတ် ဝူစ်တာ ဂျူနီယာ၊", "family": "ဒေါက်တာ", "initials": "မ. ဘ. ဝ. ဟ. ရ. ဝ. ဂ. ဒ."} {"name": "မိတ်ႇသတႃႇ ပႃႇထရမ်ႇ ဝီလ်ပႃႇၾူတ်ႉ ႁႅၼ်ႇၼရီႇ ရေႃးပၢတ်ႉ ဝုတ်ႉသ်တႃႇ မေႃယႃ", "given": "မိတ်ႇသတႃႇ", "middle": "ပႃႇထရမ်ႇ ဝီလ်ပႃႇၾူတ်ႉ ႁႅၼ်ႇၼရီႇ ရေႃးပၢတ်ႉ ဝုတ်ႉသ်တႃႇ", "family": "မေႃယႃ", "initials": "မ. ပ. ဝ. ႁ. ရ. ဝ. မ."} {"name": "မူလာ ကက်သီ", "order": "FAMILY_FIRST", "given": "ကက်သီ", "family": "မူလာ", "initials": "က. မ."} {"name": "မူလာ၊ ကက်သီ", "order": "FAMILY_FIRST", "given": "ကက်သီ", "family": "မူလာ၊", "initials": "က. မ."} {"name": "မူးလႃႇ ၶေးတႃႇ", "order": "FAMILY_FIRST", "given": "ၶေးတႃႇ", "family": "မူးလႃႇ", "initials": "ၶ. မ."} {"name": "မေရီ စူး ဟာမစ်ရှ် ဝပ်ဆင်", "given": "မေရီ", "middle": "စူး ဟာမစ်ရှ်", "family": "ဝပ်ဆင်", "initials": "မ. စ. ဟ. ဝ."} {"name": "မေႇရီႇသူး ႁေးမိသျ် ဝတ်ႉသၼ်ႇ", "given": "မေႇရီႇသူး", "middle": "ႁေးမိသျ်", "family": "ဝတ်ႉသၼ်ႇ", "initials": "မ. ႁ. ဝ."} {"name": "ဝတ်ႉသၼ်ႇ မေႇရီႇသူး ႁေးမိသျ်", "order": "FAMILY_FIRST", "given": "မေႇရီႇသူး", "middle": "ႁေးမိသျ်", "family": "ဝတ်ႉသၼ်ႇ", "initials": "မ. ႁ. ဝ."} {"name": "ဝပ်ဆင် မေရီ စူး ဟာမစ်ရှ်", "order": "FAMILY_FIRST", "given": "မေရီ", "middle": "စူး ဟာမစ်ရှ်", "family": "ဝပ်ဆင်", "initials": "မ. စ. ဟ. ဝ."} {"name": "ဝပ်ဆင်၊ မေရီ စူး ဟာမစ်ရှ်", "order": "FAMILY_FIRST", "given": "မေရီ", "middle": "စူး ဟာမစ်ရှ်", "family": "ဝပ်ဆင်၊", "initials": "မ. စ. ဟ. ဝ."} {"name": "ဝုတ်ႉသ်တႃႇ မိတ်ႇသတႃႇ ပႃႇထရမ်ႇ ဝီလ်ပႃႇၾူတ်ႉ ႁႅၼ်ႇၼရီႇ ရေႃးပၢတ်ႉ မေႃယႃ", "order": "FAMILY_FIRST", "given": "မိတ်ႇသတႃႇ", "middle": "ပႃႇထရမ်ႇ ဝီလ်ပႃႇၾူတ်ႉ ႁႅၼ်ႇၼရီႇ ရေႃးပၢတ်ႉ မေႃယႃ", "family": "ဝုတ်ႉသ်တႃႇ", "initials": "မ. ပ. ဝ. ႁ. ရ. မ. ဝ."} {"name": "ဝူစ်တာ ဗွန် ဝူစ်တာ ဘရူ ပရော်ဖက်ဆာ ဒေါက်တာ အာဒါ ကွန်နီလီယာ ဆီဇာ မာတင် ဂျူနီယာ၊ ဒေါက်တာ", "order": "FAMILY_FIRST", "given": "ဗွန်", "middle": "ဝူစ်တာ ဘရူ ပရော်ဖက်ဆာ ဒေါက်တာ အာဒါ ကွန်နီလီယာ ဆီဇာ မာတင် ဂျူနီယာ၊ ဒေါက်တာ", "family": "ဝူစ်တာ", "initials": "ဗ. ဝ. ဘ. ပ. ဒ. အ. က. ဆ. မ. ဂ. ဒ. ဝ."} {"name": "ဝူစ်တာ ဘရူ၊ အာဒါ ကွန်နီလီယာ ဆီဇာ မာတင် ဝူစ်တာ ဗွန်", "order": "FAMILY_FIRST", "given": "ဘရူ၊", "middle": "အာဒါ ကွန်နီလီယာ ဆီဇာ မာတင် ဝူစ်တာ ဗွန်", "family": "ဝူစ်တာ", "initials": "ဘ. အ. က. ဆ. မ. ဝ. ဗ. ဝ."} {"name": "ဝူစ်တာ မစ္စတာ ဘာထရမ် ဝဲလ်ဘာဖို့စ် ဟင်နရီ ရောဘတ် ဂျူနီယာ၊ ဒေါက်တာ", "order": "FAMILY_FIRST", "given": "မစ္စတာ", "middle": "ဘာထရမ် ဝဲလ်ဘာဖို့စ် ဟင်နရီ ရောဘတ် ဂျူနီယာ၊ ဒေါက်တာ", "family": "ဝူစ်တာ", "initials": "မ. ဘ. ဝ. ဟ. ရ. ဂ. ဒ. ဝ."} {"name": "ဝူစ်တာ၊ ဘာထရမ် ဝဲလ်ဘာဖို့စ် ဟင်နရီ ရောဘတ်", "order": "FAMILY_FIRST", "given": "ဘာထရမ်", "middle": "ဝဲလ်ဘာဖို့စ် ဟင်နရီ ရောဘတ်", "family": "ဝူစ်တာ၊", "initials": "ဘ. ဝ. ဟ. ရ. ဝ."} {"name": "ဝွၼ်း ပရူဝ်ႇလ် ၵွၼ်ႇၸႃႇလႅတ်ႉ တူဝ်ႇမိၼ်ႇၵူဝ် ၶူးမေႃယႃ ဢေႇတႃႇ ၶူဝ်ႇၼီႇလီႇယႃႇ သီႇသႃႇ မႃႇတိၼ်ႇ မေႃယႃ", "order": "FAMILY_FIRST", "given": "ပရူဝ်ႇလ်", "middle": "ၵွၼ်ႇၸႃႇလႅတ်ႉ တူဝ်ႇမိၼ်ႇၵူဝ် ၶူးမေႃယႃ ဢေႇတႃႇ ၶူဝ်ႇၼီႇလီႇယႃႇ သီႇသႃႇ မႃႇတိၼ်ႇ မေႃယႃ", "family": "ဝွၼ်း", "initials": "ပ. ၵ. တ. ၶ. ဢ. ၶ. သ. မ. မ. ဝ."} {"name": "ဝွၼ်း ပရူဝ်ႇလ် ၵွၼ်ႇၸႃႇလႅတ်ႉ တူဝ်ႇမိၼ်ႇၵူဝ်, ၶူးမေႃယႃ ဢေႇတႃႇ ၶူဝ်ႇၼီႇလီႇယႃႇ သီႇသႃႇ မႃႇတိၼ်ႇ မေႃယႃ", "given": "ၶူးမေႃယႃ", "middle": "ဢေႇတႃႇ ၶူဝ်ႇၼီႇလီႇယႃႇ သီႇသႃႇ မႃႇတိၼ်ႇ မေႃယႃ", "family": "ဝွၼ်း ပရူဝ်ႇလ် ၵွၼ်ႇၸႃႇလႅတ်ႉ တူဝ်ႇမိၼ်ႇၵူဝ်", "initials": "ၶ. ဢ. ၶ. သ. မ. မ. ဝ. ပ. ၵ. တ."} {"name": "သတူႇပႃႇ ၸၸီးလီႇယႃႇ ႁေးမိသျ်", "order": "FAMILY_FIRST", "given": "ၸၸီးလီႇယႃႇ", "middle": "ႁေးမိသျ်", "family": "သတူႇပႃႇ", "initials": "ၸ. ႁ. သ."} {"name": "သိၼ်းပၢတ်ႉ", "given": "သိၼ်းပၢတ်ႉ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "သ."} {"name": "သိၼ်းပၢတ်ႉ", "order": "FAMILY_FIRST", "family": "သိၼ်းပၢတ်ႉ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "သ."} {"name": "အက်ဒ်လာ အိုင်ရင်း", "order": "FAMILY_FIRST", "given": "အိုင်ရင်း", "family": "အက်ဒ်လာ", "initials": "အ. အ."} {"name": "အက်ဒ်လာ၊ အိုင်ရင်း", "order": "FAMILY_FIRST", "given": "အိုင်ရင်း", "family": "အက်ဒ်လာ၊", "initials": "အ. အ."} {"name": "အိုင်ရင်း အက်ဒ်လာ", "given": "အိုင်ရင်း", "family": "အက်ဒ်လာ", "initials": "အ. အ."} {"name": "ဢၢႆႇရိၼ်း ဢႅတ်ႉလႃႇ", "given": "ဢၢႆႇရိၼ်း", "family": "ဢႅတ်ႉလႃႇ", "initials": "ဢ. ဢ."} {"name": "ဢႅတ်ႉလႃႇ ဢၢႆႇရိၼ်း", "order": "FAMILY_FIRST", "given": "ဢၢႆႇရိၼ်း", "family": "ဢႅတ်ႉလႃႇ", "initials": "ဢ. ဢ."} {"name": "ၶူးမေႃယႃ ဢေႇတႃႇ ၶူဝ်ႇၼီႇလီႇယႃႇ သီႇသႃႇ မႃႇတိၼ်ႇ ဝွၼ်း ပရူဝ်ႇလ် ၵွၼ်ႇၸႃႇလႅတ်ႉ တူဝ်ႇမိၼ်ႇၵူဝ် မေႃယႃ", "given": "ၶူးမေႃယႃ", "middle": "ဢေႇတႃႇ ၶူဝ်ႇၼီႇလီႇယႃႇ သီႇသႃႇ မႃႇတိၼ်ႇ ဝွၼ်း ပရူဝ်ႇလ် ၵွၼ်ႇၸႃႇလႅတ်ႉ တူဝ်ႇမိၼ်ႇၵူဝ်", "family": "မေႃယႃ", "initials": "ၶ. ဢ. ၶ. သ. မ. ဝ. ပ. ၵ. တ. မ."} {"name": "ၶေးတႃႇ မူးလႃႇ", "given": "ၶေးတႃႇ", "family": "မူးလႃႇ", "initials": "ၶ. မ."} {"name": "ၸၸီးလီႇယႃႇ ႁေးမိသျ် သတူႇပႃႇ", "given": "ၸၸီးလီႇယႃႇ", "middle": "ႁေးမိသျ်", "family": "သတူႇပႃႇ", "initials": "ၸ. ႁ. သ."} {"name": "ၸႅၼ်ႇတေႇယႃႇ", "given": "ၸႅၼ်ႇတေႇယႃႇ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "ၸ."} {"name": "ၸႅၼ်ႇတေႇယႃႇ", "order": "FAMILY_FIRST", "family": "ၸႅၼ်ႇတေႇယႃႇ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "ၸ."} {"name": "აბაშიძე მარიამ მართა", "order": "FAMILY_FIRST", "given": "მარიამ", "middle": "მართა", "family": "აბაშიძე", "initials": "მ. მ. ა."} {"name": "აბელ მამარდაშვილი", "given": "აბელ", "family": "მამარდაშვილი", "initials": "ა. მ."} {"name": "ალექსანდრე იაშვილი", "given": "ალექსანდრე", "family": "იაშვილი", "initials": "ა. ი."} {"name": "ალექსანდრე ციბაძე", "given": "ალექსანდრე", "family": "ციბაძე", "initials": "ა. ც."} {"name": "ალექსი მახარაშვილი", "given": "ალექსი", "family": "მახარაშვილი", "initials": "ა. მ."} {"name": "ანასტასია ვირსალაძე", "given": "ანასტასია", "family": "ვირსალაძე", "initials": "ა. ვ."} {"name": "ბადრი თოიძე", "given": "ბადრი", "family": "თოიძე", "initials": "ბ. თ."} {"name": "ბელუჩი მონიკა", "order": "FAMILY_FIRST", "given": "მონიკა", "family": "ბელუჩი", "initials": "მ. ბ."} {"name": "გია გულუა", "given": "გია", "family": "გულუა", "initials": "გ. გ."} {"name": "გიგა ჭიკაძე", "given": "გიგა", "family": "ჭიკაძე", "initials": "გ. ჭ."} {"name": "გივი წულაია", "given": "გივი", "family": "წულაია", "initials": "გ. წ."} {"name": "გიორგი ბაგრატიონი", "given": "გიორგი", "family": "ბაგრატიონი", "initials": "გ. ბ."} {"name": "გიორგი გონჯილაშვილი", "given": "გიორგი", "family": "გონჯილაშვილი", "initials": "გ. გ."} {"name": "გიორგი თავაძე", "given": "გიორგი", "family": "თავაძე", "initials": "გ. თ."} {"name": "გიორგი სხირტლაძე", "given": "გიორგი", "family": "სხირტლაძე", "initials": "გ. ს."} {"name": "გიორგი ჩახავა", "given": "გიორგი", "family": "ჩახავა", "initials": "გ. ჩ."} {"name": "გიორგი ჭოღოშვილი", "given": "გიორგი", "family": "ჭოღოშვილი", "initials": "გ. ჭ."} {"name": "გურამ მეტონიძე", "given": "გურამ", "family": "მეტონიძე", "initials": "გ. მ."} {"name": "გურანდა ღვალაძე", "given": "გურანდა", "family": "ღვალაძე", "initials": "გ. ღ."} {"name": "დავით ბააზოვი", "given": "დავით", "family": "ბააზოვი", "initials": "დ. ბ."} {"name": "დავით გოცირიძე", "given": "დავით", "family": "გოცირიძე", "initials": "დ. გ."} {"name": "ელგუჯა ღონღაძე", "given": "ელგუჯა", "family": "ღონღაძე", "initials": "ე. ღ."} {"name": "ვაჟა დადიანიძე", "given": "ვაჟა", "family": "დადიანიძე", "initials": "ვ. დ."} {"name": "ვახტანგ აბაშიძე", "given": "ვახტანგ", "family": "აბაშიძე", "initials": "ვ. ა."} {"name": "ვახტანგ ჯაფარიძე", "given": "ვახტანგ", "family": "ჯაფარიძე", "initials": "ვ. ჯ."} {"name": "ვლადიმერ ანთაძე", "given": "ვლადიმერ", "family": "ანთაძე", "initials": "ვ. ა."} {"name": "ვლადიმერ უზნაძე", "given": "ვლადიმერ", "family": "უზნაძე", "initials": "ვ. უ."} {"name": "ზაალ სამადაშვილი", "given": "ზაალ", "family": "სამადაშვილი", "initials": "ზ. ს."} {"name": "თავაძე გიორგი", "order": "FAMILY_FIRST", "given": "გიორგი", "family": "თავაძე", "initials": "გ. თ."} {"name": "თამაზ კვაჭანტირაძე", "given": "თამაზ", "family": "კვაჭანტირაძე", "initials": "თ. კ."} {"name": "თამაზ წივწივაძე", "given": "თამაზ", "family": "წივწივაძე", "initials": "თ. წ."} {"name": "თეიმურაზ გორდელაძე", "given": "თეიმურაზ", "family": "გორდელაძე", "initials": "თ. გ."} {"name": "თეიმურაზ კვირკველია", "given": "თეიმურაზ", "family": "კვირკველია", "initials": "თ. კ."} {"name": "თეკლა მარიამ ნაკაშიძე", "given": "თეკლა", "middle": "მარიამ", "family": "ნაკაშიძე", "initials": "თ. მ. ნ."} {"name": "თემურ აბულაშვილი", "given": "თემურ", "family": "აბულაშვილი", "initials": "თ. ა."} {"name": "ილია სულამანიძე", "given": "ილია", "family": "სულამანიძე", "initials": "ი. ს."} {"name": "ირაკლი ელიავა", "given": "ირაკლი", "family": "ელიავა", "initials": "ი. ე."} {"name": "ირაკლი მწითური", "given": "ირაკლი", "family": "მწითური", "initials": "ი. მ."} {"name": "კახა ბენდუქიძე", "given": "კახა", "family": "ბენდუქიძე", "initials": "კ. ბ."} {"name": "კახა კალაძე", "given": "კახა", "family": "კალაძე", "initials": "კ. კ."} {"name": "ლევან გაჩეჩილაძე", "given": "ლევან", "family": "გაჩეჩილაძე", "initials": "ლ. გ."} {"name": "ლევან ინწკირველი", "given": "ლევან", "family": "ინწკირველი", "initials": "ლ. ი."} {"name": "ლევან კაჭარავა", "given": "ლევან", "family": "კაჭარავა", "initials": "ლ. კ."} {"name": "ლევან მაღნარაძე", "given": "ლევან", "family": "მაღნარაძე", "initials": "ლ. მ."} {"name": "ლევან ყენია", "given": "ლევან", "family": "ყენია", "initials": "ლ. ყ."} {"name": "ლეო შერვაშიძე", "given": "ლეო", "family": "შერვაშიძე", "initials": "ლ. შ."} {"name": "მამუკა ზაალიშვილი", "given": "მამუკა", "family": "ზაალიშვილი", "initials": "მ. ზ."} {"name": "მარიამ მართა აბაშიძე", "given": "მარიამ", "middle": "მართა", "family": "აბაშიძე", "initials": "მ. მ. ა."} {"name": "მერაბ დვალიშვილი", "given": "მერაბ", "family": "დვალიშვილი", "initials": "მ. დ."} {"name": "მიხეილ ქორქია", "given": "მიხეილ", "family": "ქორქია", "initials": "მ. ქ."} {"name": "მიხეილ ჯოჯუა", "given": "მიხეილ", "family": "ჯოჯუა", "initials": "მ. ჯ."} {"name": "მონიკა ბელუჩი", "given": "მონიკა", "family": "ბელუჩი", "initials": "მ. ბ."} {"name": "მუხრან ვახტანგაძე", "given": "მუხრან", "family": "ვახტანგაძე", "initials": "მ. ვ."} {"name": "ნაკაშიძე თეკლა მარიამ", "order": "FAMILY_FIRST", "given": "თეკლა", "middle": "მარიამ", "family": "ნაკაშიძე", "initials": "თ. მ. ნ."} {"name": "ნელი მგალობლიშვილი", "given": "ნელი", "family": "მგალობლიშვილი", "initials": "ნ. მ."} {"name": "ნიკო ფიროსმანი", "given": "ნიკო", "family": "ფიროსმანი", "initials": "ნ. ფ."} {"name": "ნიკოლოზ ბასილაშვილი", "given": "ნიკოლოზ", "family": "ბასილაშვილი", "initials": "ნ. ბ."} {"name": "ნიკოლოზ სანიშვილი", "given": "ნიკოლოზ", "family": "სანიშვილი", "initials": "ნ. ს."} {"name": "ნინო", "given": "ნინო", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "ნ."} {"name": "ნინო", "order": "FAMILY_FIRST", "family": "ნინო", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "ნ."} {"name": "ნოდარ ღვალაძე", "given": "ნოდარ", "family": "ღვალაძე", "initials": "ნ. ღ."} {"name": "ნოდარ ცინცაძე", "given": "ნოდარ", "family": "ცინცაძე", "initials": "ნ. ც."} {"name": "ნოდარ ჯობავა", "given": "ნოდარ", "family": "ჯობავა", "initials": "ნ. ჯ."} {"name": "პაატა კოღუაშვილი", "given": "პაატა", "family": "კოღუაშვილი", "initials": "პ. კ."} {"name": "პროფ. დოქტ. ადა კორნელია სეზარ მარტინ ფონ ბრიული გონსალეს დომინგო მეცნ. დოქტ.", "title": "პროფ. დოქტ.", "given": "ადა", "middle": "კორნელია სეზარ მარტინ ფონ ბრიული გონსალეს დომინგო მეცნ.", "family": "დოქტ.", "initials": "ა. კ. ს. მ. ფ. ბ. გ. დ. მ. დ."} {"name": "რევაზ ადამია", "given": "რევაზ", "family": "ადამია", "initials": "რ. ა."} {"name": "სერგო რიგვავა", "given": "სერგო", "family": "რიგვავა", "initials": "ს. რ."} {"name": "სინდბად", "given": "სინდბად", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "ს."} {"name": "სინდბად", "order": "FAMILY_FIRST", "family": "სინდბად", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "ს."} {"name": "სოსო ლიპარტელიანი", "given": "სოსო", "family": "ლიპარტელიანი", "initials": "ს. ლ."} {"name": "სოსო პაიჭაძე", "given": "სოსო", "family": "პაიჭაძე", "initials": "ს. პ."} {"name": "ტარიელ ჭიჭინაძე", "given": "ტარიელ", "family": "ჭიჭინაძე", "initials": "ტ. ჭ."} {"name": "ფონ ბრიული გონსალეს დომინგო პროფ. დოქტ. ადა კორნელია სეზარ მარტინ მეცნ. დოქტ.", "order": "FAMILY_FIRST", "given": "ბრიული", "middle": "გონსალეს დომინგო პროფ. დოქტ. ადა კორნელია სეზარ მარტინ მეცნ. დოქტ.", "family": "ფონ", "initials": "ბ. გ. დ. პ. დ. ა. კ. ს. მ. მ. დ. ფ."} {"name": "ფონ ბრიული გონსალეს დომინგო, პროფ. დოქტ. ადა კორნელია სეზარ მარტინ მეცნ. დოქტ.", "title": "პროფ. დოქტ.", "given": "ადა", "middle": "კორნელია სეზარ მარტინ მეცნ. დოქტ.", "family": "ფონ ბრიული გონსალეს დომინგო", "initials": "ა. კ. ს. მ. მ. დ. ფ. ბ. გ. დ."} {"name": "შტიობერი ცეცილია ჰეიმიშ", "order": "FAMILY_FIRST", "given": "ცეცილია", "middle": "ჰეიმიშ", "family": "შტიობერი", "initials": "ც. ჰ. შ."} {"name": "ცეცილია ჰეიმიშ შტიობერი", "given": "ცეცილია", "middle": "ჰეიმიშ", "family": "შტიობერი", "initials": "ც. ჰ. შ."} {"name": "ხვიჩა კვარაცხელია", "given": "ხვიჩა", "family": "კვარაცხელია", "initials": "ხ. კ."} {"name": "김. 민준", "given": "민준", "family": "김.", "initials": "민. 김."} {"name": "ሙለር ካቲ", "order": "FAMILY_FIRST", "given": "ካቲ", "family": "ሙለር", "initials": "ካ. ሙ."} {"name": "ሙለር፣ ካቲ", "order": "FAMILY_FIRST", "given": "ካቲ", "family": "ሙለር፣", "initials": "ካ. ሙ."} {"name": "ሜሪ ሱ ሃሚሽ ዋትሰን", "given": "ሜሪ", "middle": "ሱ ሃሚሽ", "family": "ዋትሰን", "initials": "ሜ. ሱ. ሃ. ዋ."} {"name": "ሲንባድ", "given": "ሲንባድ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "ሲ."} {"name": "ሲንባድ", "order": "FAMILY_FIRST", "family": "ሲንባድ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "ሲ."} {"name": "ስቶበር ዛዚሊያ ሃሚሽ", "order": "FAMILY_FIRST", "given": "ዛዚሊያ", "middle": "ሃሚሽ", "family": "ስቶበር", "initials": "ዛ. ሃ. ስ."} {"name": "ስቶበር፣ ዛዚሊያ ሃሚሽ", "order": "FAMILY_FIRST", "given": "ዛዚሊያ", "middle": "ሃሚሽ", "family": "ስቶበር፣", "initials": "ዛ. ሃ. ስ."} {"name": "ብሩሂ፣ ኣዳ ኮርኒሊያ ሲዘር ማርቲን ቮን", "order": "FAMILY_FIRST", "given": "ኣዳ", "middle": "ኮርኒሊያ ሲዘር ማርቲን ቮን", "family": "ብሩሂ፣", "initials": "ኣ. ኮ. ሲ. ማ. ቮ. ብ."} {"name": "ቮን ብሩሂ ፕሮ. ዶር. ኣዳ ኮርኒሊያ ሲዘር ማርቲን ማንጁስ፣ ሓኪም መጥባሕቲ ስኒ", "order": "FAMILY_FIRST", "given": "ብሩሂ", "middle": "ፕሮ. ዶር. ኣዳ ኮርኒሊያ ሲዘር ማርቲን ማንጁስ፣ ሓኪም መጥባሕቲ ስኒ", "family": "ቮን", "initials": "ብ. ፕ. ዶ. ኣ. ኮ. ሲ. ማ. ማ. ሓ. መ. ስ. ቮ."} {"name": "ቮን ብሩህል ጎንዛሌዝ ዶሚንጎ ፕ/ር ዶ/ር አዳ ኮርኔሊያ ሴዛር ማርቲን ዶ/ር የጥርስ ዶ/ር", "order": "FAMILY_FIRST", "given": "ብሩህል", "middle": "ጎንዛሌዝ ዶሚንጎ ፕ/ር ዶ/ር አዳ ኮርኔሊያ ሴዛር ማርቲን ዶ/ር የጥርስ ዶ/ር", "family": "ቮን", "initials": "ብ. ጎ. ዶ. ፕ. ዶ. አ. ኮ. ሴ. ማ. ዶ. የ. ዶ. ቮ."} {"name": "ቮን ብሩህል ጎንዛሌዝ ዶሚንጎ, ፕ/ር ዶ/ር አዳ ኮርኔሊያ ሴዛር ማርቲን ዶ/ር የጥርስ ዶ/ር", "given": "ፕ/ር", "middle": "ዶ/ር አዳ ኮርኔሊያ ሴዛር ማርቲን ዶ/ር የጥርስ ዶ/ር", "family": "ቮን ብሩህል ጎንዛሌዝ ዶሚንጎ", "initials": "ፕ. ዶ. አ. ኮ. ሴ. ማ. ዶ. የ. ዶ. ቮ. ብ. ጎ. ዶ."} {"name": "አቶ በርትራም ዊልበርፎርስ ሄነሪ ሮበርት ዉስተር የፓርላማ አባል", "given": "አቶ", "middle": "በርትራም ዊልበርፎርስ ሄነሪ ሮበርት ዉስተር የፓርላማ", "family": "አባል", "initials": "አ. በ. ዊ. ሄ. ሮ. ዉ. የ. አ."} {"name": "አይሪን አድለር", "given": "አይሪን", "family": "አድለር", "initials": "አ. አ."} {"name": "አድለር አይሪን", "order": "FAMILY_FIRST", "given": "አይሪን", "family": "አድለር", "initials": "አ. አ."} {"name": "ኣይሪን ኣድለር", "given": "ኣይሪን", "family": "ኣድለር", "initials": "ኣ. ኣ."} {"name": "ኣይተ በርትረም ዊልበርፎርስ ሄንሪ ሮበርት ዉስተር ማንጁስ፣ ኣባል ባይቶ", "given": "ኣይተ", "middle": "በርትረም ዊልበርፎርስ ሄንሪ ሮበርት ዉስተር ማንጁስ፣ ኣባል", "family": "ባይቶ", "initials": "ኣ. በ. ዊ. ሄ. ሮ. ዉ. ማ. ኣ. ባ."} {"name": "ኣድለር ኣይሪን", "order": "FAMILY_FIRST", "given": "ኣይሪን", "family": "ኣድለር", "initials": "ኣ. ኣ."} {"name": "ኣድለር፣ ኣይሪን", "order": "FAMILY_FIRST", "given": "ኣይሪን", "family": "ኣድለር፣", "initials": "ኣ. ኣ."} {"name": "ካቲ ሙለር", "given": "ካቲ", "family": "ሙለር", "initials": "ካ. ሙ."} {"name": "ዉስተር አቶ በርትራም ዊልበርፎርስ ሄነሪ ሮበርት የፓርላማ አባል", "order": "FAMILY_FIRST", "given": "አቶ", "middle": "በርትራም ዊልበርፎርስ ሄነሪ ሮበርት የፓርላማ አባል", "family": "ዉስተር", "initials": "አ. በ. ዊ. ሄ. ሮ. የ. አ. ዉ."} {"name": "ዉስተር ኣይተ በርትረም ዊልበርፎርስ ሄንሪ ሮበርት ማንጁስ፣ ኣባል ባይቶ", "order": "FAMILY_FIRST", "given": "ኣይተ", "middle": "በርትረም ዊልበርፎርስ ሄንሪ ሮበርት ማንጁስ፣ ኣባል ባይቶ", "family": "ዉስተር", "initials": "ኣ. በ. ዊ. ሄ. ሮ. ማ. ኣ. ባ. ዉ."} {"name": "ዉስተር፣ በርትረም ዊልበርፎርስ ሄንሪ ሮበርት", "order": "FAMILY_FIRST", "given": "በርትረም", "middle": "ዊልበርፎርስ ሄንሪ ሮበርት", "family": "ዉስተር፣", "initials": "በ. ዊ. ሄ. ሮ. ዉ."} {"name": "ዋትሰን ሜሪ ሱ ሃሚሽ", "order": "FAMILY_FIRST", "given": "ሜሪ", "middle": "ሱ ሃሚሽ", "family": "ዋትሰን", "initials": "ሜ. ሱ. ሃ. ዋ."} {"name": "ዋትሰን፣ ሜሪ ሱ ሃሚሽ", "order": "FAMILY_FIRST", "given": "ሜሪ", "middle": "ሱ ሃሚሽ", "family": "ዋትሰን፣", "initials": "ሜ. ሱ. ሃ. ዋ."} {"name": "ዘንዳያ", "given": "ዘንዳያ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "ዘ."} {"name": "ዘንዳያ", "order": "FAMILY_FIRST", "family": "ዘንዳያ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "ዘ."} {"name": "ዛዚሊያ ሃሚሽ ስቶበር", "given": "ዛዚሊያ", "middle": "ሃሚሽ", "family": "ስቶበር", "initials": "ዛ. ሃ. ስ."} {"name": "ዜንዳያ", "given": "ዜንዳያ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "ዜ."} {"name": "ዜንዳያ", "order": "FAMILY_FIRST", "family": "ዜንዳያ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "ዜ."} {"name": "ፕ/ር ዶ/ር አዳ ኮርኔሊያ ሴዛር ማርቲን ቮን ብሩህል ጎንዛሌዝ ዶሚንጎ ዶ/ር የጥርስ ዶ/ር", "given": "ፕ/ር", "middle": "ዶ/ር አዳ ኮርኔሊያ ሴዛር ማርቲን ቮን ብሩህል ጎንዛሌዝ ዶሚንጎ ዶ/ር የጥርስ", "family": "ዶ/ር", "initials": "ፕ. ዶ. አ. ኮ. ሴ. ማ. ቮ. ብ. ጎ. ዶ. ዶ. የ. ዶ."} {"name": "ፕሮ. ዶር. ኣዳ ኮርኒሊያ ሲዘር ማርቲን ቮን ብሩሂ ማንጁስ፣ ሓኪም መጥባሕቲ ስኒ", "title": "ፕሮ. ዶር.", "given": "ኣዳ", "middle": "ኮርኒሊያ ሲዘር ማርቲን ቮን ብሩሂ ማንጁስ፣ ሓኪም መጥባሕቲ", "family": "ስኒ", "initials": "ኣ. ኮ. ሲ. ማ. ቮ. ብ. ማ. ሓ. መ. ስ."} {"name": "ᎠᏕᎸ ᎠᏪᏁ", "order": "FAMILY_FIRST", "given": "ᎠᏪᏁ", "family": "ᎠᏕᎸ", "initials": "Ꭰ. Ꭰ.", "capitalized": "Ꭰꮺꮑ Ꭰꮥꮈ"} {"name": "ᎠᏕᎸ, ᎠᏪᏁ", "given": "ᎠᏪᏁ", "family": "ᎠᏕᎸ", "initials": "Ꭰ. Ꭰ.", "capitalized": "Ꭰꮺꮑ Ꭰꮥꮈ"} {"name": "ᎠᏪᏁ ᎠᏕᎸ", "given": "ᎠᏪᏁ", "family": "ᎠᏕᎸ", "initials": "Ꭰ. Ꭰ.", "capitalized": "Ꭰꮺꮑ Ꭰꮥꮈ"} {"name": "ᎧᏖ ᎽᎴ", "given": "ᎧᏖ", "family": "ᎽᎴ", "initials": "Ꭷ. Ꮍ.", "capitalized": "Ꭷꮦ Ꮍꮄ"} {"name": "ᎨᏓᏯ", "given": "ᎨᏓᏯ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "Ꭸ.", "capitalized": "Ꭸꮣꮿ"} {"name": "ᎨᏓᏯ", "order": "FAMILY_FIRST", "family": "ᎨᏓᏯ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "Ꭸ.", "capitalized": "Ꭸꮣꮿ"} {"name": "ᎺᎵ ᏑᎢ ᎭᎻᏏ ᏩᏔᏒᎢ", "given": "ᎺᎵ", "middle": "ᏑᎢ ᎭᎻᏏ", "family": "ᏩᏔᏒᎢ", "initials": "Ꮊ. Ꮡ. Ꭽ. Ꮹ.", "capitalized": "Ꮊꮅ Ꮡꭲ Ꭽꮋꮟ Ꮹꮤꮢꭲ"} {"name": "ᎻᏍᏖᎢ ᏇᏔᎻ ᏫᏇᏉᏏ ᎮᏂᏇ ᏆᏇᏘ ᏬᏍᏖᎢ ᏔᎵᏁ, ᎷᏢ", "given": "ᎷᏢ", "family": "ᎻᏍᏖᎢ ᏇᏔᎻ ᏫᏇᏉᏏ ᎮᏂᏇ ᏆᏇᏘ ᏬᏍᏖᎢ ᏔᎵᏁ", "initials": "Ꮇ. Ꮋ. Ꮗ. Ꮻ. Ꭾ. Ꮖ. Ꮼ. Ꮤ.", "capitalized": "Ꮇꮲ Ꮋꮝꮦꭲ Ꮗꮤꮋ Ꮻꮗꮙꮟ Ꭾꮒꮗ Ꮖꮗꮨ Ꮼꮝꮦꭲ Ꮤꮅꮑ"} {"name": "ᎽᎴ ᎧᏖ", "order": "FAMILY_FIRST", "given": "ᎧᏖ", "family": "ᎽᎴ", "initials": "Ꭷ. Ꮍ.", "capitalized": "Ꭷꮦ Ꮍꮄ"} {"name": "ᎽᎴ, ᎧᏖ", "given": "ᎧᏖ", "family": "ᎽᎴ", "initials": "Ꭷ. Ꮍ.", "capitalized": "Ꭷꮦ Ꮍꮄ"} {"name": "ᏆᏏᎵ ᎭᎻᏍᎯ ᏍᏙᏇᎢ", "given": "ᏆᏏᎵ", "middle": "ᎭᎻᏍᎯ", "family": "ᏍᏙᏇᎢ", "initials": "Ꮖ. Ꭽ. Ꮝ.", "capitalized": "Ꮖꮟꮅ Ꭽꮋꮝꭿ Ꮝꮩꮗꭲ"} {"name": "ᏇᎯ, ᎠᏓ ᎪᏁᎵᎠ ᏏᏌ ᎹᏘᏂ ᏉᏂ", "given": "ᎠᏓ", "middle": "ᎪᏁᎵᎠ ᏏᏌ ᎹᏘᏂ ᏉᏂ", "family": "ᏇᎯ", "initials": "Ꭰ. Ꭺ. Ꮟ. Ꮉ. Ꮙ. Ꮗ.", "capitalized": "Ꭰꮣ Ꭺꮑꮅꭰ Ꮟꮜ Ꮉꮨꮒ Ꮙꮒ Ꮗꭿ"} {"name": "Ꮙ. ᏅᏬᏘ ᎠᏓ ᎪᏁᎵᎠ ᏏᏌ ᎹᏘᏂ ᏉᏂ ᏇᎯ ᏔᎵᏁ, ᎷᎠ ᎠᎠᏚ", "given": "ᎷᎠ", "middle": "ᎠᎠᏚ", "family": "Ꮙ. ᏅᏬᏘ ᎠᏓ ᎪᏁᎵᎠ ᏏᏌ ᎹᏘᏂ ᏉᏂ ᏇᎯ ᏔᎵᏁ", "initials": "Ꮇ. Ꭰ. Ꮙ. Ꮕ. Ꭰ. Ꭺ. Ꮟ. Ꮉ. Ꮙ. Ꮗ. Ꮤ.", "capitalized": "Ꮇꭰ Ꭰꭰꮪ Ꮙ. Ꮕꮼꮨ Ꭰꮣ Ꭺꮑꮅꭰ Ꮟꮜ Ꮉꮨꮒ Ꮙꮒ Ꮗꭿ Ꮤꮅꮑ"} {"name": "ᏉᏂ ᏇᎯ Ꮙ. ᏅᏬᏘ ᎠᏓ ᎪᏁᎵᎠ ᏏᏌ ᎹᏘᏂ ᏔᎵᏁ, ᎷᎠ ᎠᎠᏚ", "order": "FAMILY_FIRST", "given": "ᎷᎠ", "middle": "ᎠᎠᏚ", "family": "ᏉᏂ ᏇᎯ Ꮙ. ᏅᏬᏘ ᎠᏓ ᎪᏁᎵᎠ ᏏᏌ ᎹᏘᏂ ᏔᎵᏁ", "initials": "Ꮇ. Ꭰ. Ꮙ. Ꮗ. Ꮙ. Ꮕ. Ꭰ. Ꭺ. Ꮟ. Ꮉ. Ꮤ.", "capitalized": "Ꮇꭰ Ꭰꭰꮪ Ꮙꮒ Ꮗꭿ Ꮙ. Ꮕꮼꮨ Ꭰꮣ Ꭺꮑꮅꭰ Ꮟꮜ Ꮉꮨꮒ Ꮤꮅꮑ"} {"name": "ᏍᏙᏇᎢ ᏆᏏᎵ ᎭᎻᏍᎯ", "order": "FAMILY_FIRST", "given": "ᏆᏏᎵ", "middle": "ᎭᎻᏍᎯ", "family": "ᏍᏙᏇᎢ", "initials": "Ꮖ. Ꭽ. Ꮝ.", "capitalized": "Ꮖꮟꮅ Ꭽꮋꮝꭿ Ꮝꮩꮗꭲ"} {"name": "ᏍᏙᏇᎢ, ᏆᏏᎵ ᎭᎻᏍᎯ", "given": "ᏆᏏᎵ", "middle": "ᎭᎻᏍᎯ", "family": "ᏍᏙᏇᎢ", "initials": "Ꮖ. Ꭽ. Ꮝ.", "capitalized": "Ꮖꮟꮅ Ꭽꮋꮝꭿ Ꮝꮩꮗꭲ"} {"name": "ᏏᎾᏆᏗ", "given": "ᏏᎾᏆᏗ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "Ꮟ.", "capitalized": "Ꮟꮎꮖꮧ"} {"name": "ᏏᎾᏆᏗ", "order": "FAMILY_FIRST", "family": "ᏏᎾᏆᏗ", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "Ꮟ.", "capitalized": "Ꮟꮎꮖꮧ"} {"name": "ᏩᏔᏒᎢ ᎺᎵ ᏑᎢ ᎭᎻᏏ", "order": "FAMILY_FIRST", "given": "ᎺᎵ", "middle": "ᏑᎢ ᎭᎻᏏ", "family": "ᏩᏔᏒᎢ", "initials": "Ꮊ. Ꮡ. Ꭽ. Ꮹ.", "capitalized": "Ꮊꮅ Ꮡꭲ Ꭽꮋꮟ Ꮹꮤꮢꭲ"} {"name": "ᏩᏔᏒᎢ, ᎺᎵ ᏑᎢ ᎭᎻᏏ", "given": "ᎺᎵ", "middle": "ᏑᎢ ᎭᎻᏏ", "family": "ᏩᏔᏒᎢ", "initials": "Ꮊ. Ꮡ. Ꭽ. Ꮹ.", "capitalized": "Ꮊꮅ Ꮡꭲ Ꭽꮋꮟ Ꮹꮤꮢꭲ"} {"name": "ᏬᏍᏖᎢ ᎻᏍᏖᎢ ᏇᏔᎻ ᏫᏇᏉᏏ ᎮᏂᏇ ᏆᏇᏘ ᏔᎵᏁ, ᎷᏢ", "order": "FAMILY_FIRST", "given": "ᎷᏢ", "family": "ᏬᏍᏖᎢ ᎻᏍᏖᎢ ᏇᏔᎻ ᏫᏇᏉᏏ ᎮᏂᏇ ᏆᏇᏘ ᏔᎵᏁ", "initials": "Ꮇ. Ꮼ. Ꮋ. Ꮗ. Ꮻ. Ꭾ. Ꮖ. Ꮤ.", "capitalized": "Ꮇꮲ Ꮼꮝꮦꭲ Ꮋꮝꮦꭲ Ꮗꮤꮋ Ꮻꮗꮙꮟ Ꭾꮒꮗ Ꮖꮗꮨ Ꮤꮅꮑ"} {"name": "ᏬᏍᏖᎢ, ᏇᏔᎻ ᏫᏇᏉᏏ ᎮᏂᏇ ᏆᏇᏘ", "given": "ᏇᏔᎻ", "middle": "ᏫᏇᏉᏏ ᎮᏂᏇ ᏆᏇᏘ", "family": "ᏬᏍᏖᎢ", "initials": "Ꮗ. Ꮻ. Ꭾ. Ꮖ. Ꮼ.", "capitalized": "Ꮗꮤꮋ Ꮻꮗꮙꮟ Ꭾꮒꮗ Ꮖꮗꮨ Ꮼꮝꮦꭲ"} {"name": "កាតេ មូលឡើ", "given": "កាតេ", "family": "មូលឡើ", "initials": "ក. ម."} {"name": "ប្រ៊ូល, អាដា ខរនែលៀ ឆេសារ ម៉ាធីន វន់", "given": "អាដា", "middle": "ខរនែលៀ ឆេសារ ម៉ាធីន វន់", "family": "ប្រ៊ូល", "initials": "អ. ខ. ឆ. ម. វ. ប."} {"name": "មូលឡើ កាតេ", "order": "FAMILY_FIRST", "given": "កាតេ", "family": "មូលឡើ", "initials": "ក. ម."} {"name": "មូលឡើ, កាតេ", "given": "កាតេ", "family": "មូលឡើ", "initials": "ក. ម."} {"name": "រិទ្ធី មករា វ៉េង", "given": "រិទ្ធី", "middle": "មករា", "family": "វ៉េង", "initials": "រ. ម. វ."} {"name": "លោក សុវណ្ណថេត សុធារ៉ា វេហា សមាជិកសភា", "given": "លោក", "middle": "សុវណ្ណថេត សុធារ៉ា វេហា", "family": "សមាជិកសភា", "initials": "ល. ស. ស. វ. ស."} {"name": "វន់ ប្រ៊ូល សាស្ត្រាចារ្យបណ្ឌិត អាដា ខរនែលៀ ឆេសារ ម៉ាធីន ជូនៀ, ទន្ដបណ្ឌិត", "order": "FAMILY_FIRST", "given": "ទន្ដបណ្ឌិត", "family": "វន់ ប្រ៊ូល សាស្ត្រាចារ្យបណ្ឌិត អាដា ខរនែលៀ ឆេសារ ម៉ាធីន ជូនៀ", "initials": "ទ. វ. ប. ស. អ. ខ. ឆ. ម. ជ."} {"name": "វេហា លោក សុវណ្ណថេត សុធារ៉ា សមាជិកសភា", "order": "FAMILY_FIRST", "given": "លោក", "middle": "សុវណ្ណថេត សុធារ៉ា សមាជិកសភា", "family": "វេហា", "initials": "ល. ស. ស. ស. វ."} {"name": "វេហា, សុវណ្ណថេត សុធារ៉ា", "given": "សុវណ្ណថេត", "middle": "សុធារ៉ា", "family": "វេហា", "initials": "ស. ស. វ."} {"name": "វ៉េង រិទ្ធី មករា", "order": "FAMILY_FIRST", "given": "រិទ្ធី", "middle": "មករា", "family": "វ៉េង", "initials": "រ. ម. វ."} {"name": "វ៉េង, រិទ្ធី មករា", "given": "រិទ្ធី", "middle": "មករា", "family": "វ៉េង", "initials": "រ. ម. វ."} {"name": "សាស្ត្រាចារ្យបណ្ឌិត អាដា ខរនែលៀ ឆេសារ ម៉ាធីន វន់ ប្រ៊ូល ជូនៀ, ទន្ដបណ្ឌិត", "given": "ទន្ដបណ្ឌិត", "family": "សាស្ត្រាចារ្យបណ្ឌិត អាដា ខរនែលៀ ឆេសារ ម៉ាធីន វន់ ប្រ៊ូល ជូនៀ", "initials": "ទ. ស. អ. ខ. ឆ. ម. វ. ប. ជ."} {"name": "សុខា", "given": "សុខា", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "ស."} {"name": "សុខា", "order": "FAMILY_FIRST", "family": "សុខា", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "ស."} {"name": "សុង សុជាតិ", "order": "FAMILY_FIRST", "given": "សុជាតិ", "family": "សុង", "initials": "ស. ស."} {"name": "សុង, សុជាតិ", "given": "សុជាតិ", "family": "សុង", "initials": "ស. ស."} {"name": "សុជាតិ សុង", "given": "សុជាតិ", "family": "សុង", "initials": "ស. ស."} {"name": "ស៊ីនប៊ែត", "given": "ស៊ីនប៊ែត", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "ស."} {"name": "ស៊ីនប៊ែត", "order": "FAMILY_FIRST", "family": "ស៊ីនប៊ែត", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "ស."} {"name": "ស្តូបើ ហ្សាហ្សីលៀ ហេមីស", "order": "FAMILY_FIRST", "given": "ហ្សាហ្សីលៀ", "middle": "ហេមីស", "family": "ស្តូបើ", "initials": "ហ. ហ. ស."} {"name": "ស្តូបើ, ហ្សាហ្សីលៀ ហេមីស", "given": "ហ្សាហ្សីលៀ", "middle": "ហេមីស", "family": "ស្តូបើ", "initials": "ហ. ហ. ស."} {"name": "ហ្សាហ្សីលៀ ហេមីស ស្តូបើ", "given": "ហ្សាហ្សីលៀ", "middle": "ហេមីស", "family": "ស្តូបើ", "initials": "ហ. ហ. ស."} {"name": "Ḥabīballāh Kalakānī", "given": "Ḥabīballāh", "family": "Kalakānī", "initials": "Ḥ. K."} {"name": "Ḥakīm, Taufīq al-", "given": "Taufīq", "family": "al- Ḥakīm", "initials": "T. Ḥ."} {"name": "Ḥamza Fanṣūrī", "given": "Ḥamza", "family": "Fanṣūrī", "initials": "Ḥ. F."} {"name": "Ḥusain Ṯanāʾī Mašhadī", "given": "Ḥusain", "middle": "Ṯanāʾī", "family": "Mašhadī", "initials": "Ḥ. Ṯ. M."} {"name": "Ḥusnī az-Zaʿīm", "given": "Ḥusnī", "family": "az-Zaʿīm", "initials": "Ḥ. a.", "capitalized_force": "Ḥusnī Az-Zaʿīm"} {"name": "Ḥāǧǧ, Kamāl Yūsuf al-", "given": "Kamāl", "middle": "Yūsuf", "family": "al- Ḥāǧǧ", "initials": "K. Y. Ḥ."} {"name": "Ḫarrāṭ, Idwār al-", "given": "Idwār", "family": "al- Ḫarrāṭ", "initials": "I. Ḫ."} {"name": "Ṣafī-ad-Dīn Ardabīlī", "given": "Ṣafī-ad-Dīn", "family": "Ardabīlī", "initials": "Ṣ. A.", "capitalized_force": "Ṣafī-Ad-Dīn Ardabīlī"} {"name": "Ṭihrānī, Āqā Buzurg aṭ-", "given": "Āqā", "middle": "Buzurg", "family": "aṭ- Ṭihrānī", "initials": "Ā. B. Ṭ."} {"name": "Ṯābit Ibn-Qurra", "given": "Ṯābit", "family": "Ibn-Qurra", "initials": "Ṯ. I.", "capitalized_force": "Ṯābit ibn-Qurra"} {"name": "​Dr. John Smith", "title": "Dr.", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "​王小明", "family": "王小明", "initials": "王."} {"name": "‏John‏ Smith", "given": "John", "family": "Smith", "initials": "J. S."} {"name": "‏محمد بن سلمان‏", "given": "محمد", "family": "بن سلمان", "initials": "م. س."} {"name": "∫≜⩕ Smith 😊", "given": "∫≜⩕", "family": "Smith", "initials": "∫. S."} {"name": "∫≜⩕ Smith😊", "given": "∫≜⩕", "family": "Smith", "initials": "∫. S."} {"name": "〆木 ひろ", "given": "ひろ", "family": "〆木", "initials": "ひ. 〆."} {"name": "〆木 太郎", "given": "太郎", "family": "〆木", "initials": "太. 〆."} {"name": "〆木太郎", "family": "〆木太郎", "initials": "〆."} {"name": "あ゙い たろう", "given": "たろう", "family": "あ゙い", "initials": "た. あ."} {"name": "さん", "family": "さん", "initials": "さ."} {"name": "みなみ", "family": "みなみ", "initials": "み."} {"name": "ア゙イ タロウ", "given": "ア゙イ", "family": "タロウ", "initials": "ア. タ."} {"name": "ア゛イ タロウ", "given": "ア゛イ", "family": "タロウ", "initials": "ア. タ."} {"name": "アインシュタイン・アルベルト", "order": "FAMILY_FIRST", "given": "アルベルト", "family": "アインシュタイン", "initials": "ア. ア."} {"name": "アルベルト・アインシュタイン", "given": "アルベルト", "family": "アインシュタイン", "initials": "ア. ア."} {"name": "エリザベス二世", "given": "エリザベス", "suffix": "二世", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "エ."} {"name": "ジェニファー・ソフィア・フォン・スミス・ジュニア博士", "given": "ソフィア", "middle": "フォン スミス ジュニア博士", "family": "ジェニファー", "initials": "ソ. フ. ス. ジ. ジ."} {"name": "セシリア・ローズ・ブラウン", "given": "セシリア", "middle": "ローズ", "family": "ブラウン", "initials": "セ. ロ. ブ."} {"name": "フォン・スミス・ソフィア・ジェニファー", "order": "FAMILY_FIRST", "given": "スミス", "middle": "ソフィア ジェニファー", "family": "フォン", "initials": "ス. ソ. ジ. フ."} {"name": "フォン・スミス・ソフィア・ジェニファー博士", "order": "FAMILY_FIRST", "given": "スミス", "middle": "ソフィア ジェニファー博士", "family": "フォン", "initials": "ス. ソ. ジ. フ."} {"name": "ブラウン・ローズ・セシリア", "order": "FAMILY_FIRST", "given": "ローズ", "middle": "セシリア", "family": "ブラウン", "initials": "ロ. セ. ブ."} {"name": "マイケル", "given": "マイケル", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "マ."} {"name": "マイケル", "order": "FAMILY_FIRST", "family": "マイケル", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as given (position)"], "initials": "マ."} {"name": "マイケル ア゙イ", "given": "マイケル", "family": "ア゙イ", "initials": "マ. ア."} {"name": "マイケル ジャクソン", "given": "マイケル", "family": "ジャクソン", "initials": "マ. ジ."} {"name": "マイケル ジャクソン", "given": "マイケル", "family": "ジャクソン", "initials": "マ. ジ."} {"name": "マイケル.", "given": "マイケル.", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "マ."} {"name": "マイケル·ジャクソン", "given": "マイケル", "family": "ジャクソン", "initials": "マ. ジ."} {"name": "マイケル・ジャクソン", "given": "マイケル", "family": "ジャクソン", "initials": "マ. ジ."} {"name": "ロバート・ダウニー・ジュニア", "given": "ロバート", "family": "ダウニー", "suffix": "ジュニア", "initials": "ロ. ダ."} {"name": "一郎安藤", "family": "一郎安藤", "initials": "一."} {"name": "三橋淳", "family": "三橋淳", "initials": "三."} {"name": "上山輝一", "family": "上山輝一", "initials": "上."} {"name": "中内力", "family": "中内力", "initials": "中."} {"name": "中島 徹", "given": "徹", "family": "中島", "initials": "徹. 中."} {"name": "中川龍一", "family": "中川龍一", "initials": "中."} {"name": "亀井三郎", "family": "亀井三郎", "initials": "亀."} {"name": "伊豆田莉奈", "family": "伊豆田莉奈", "initials": "伊."} {"name": "佐々木 勇人", "given": "勇人", "family": "佐々木", "initials": "勇. 佐."} {"name": "佐々木 太郎", "given": "太郎", "family": "佐々木", "initials": "太. 佐."} {"name": "佐々木謙一郎", "family": "佐々木謙一郎", "initials": "佐."} {"name": "佐藤 花子 \"旧姓・田中\" (旧姓 鈴木)", "given": "花子", "family": "佐藤", "nickname": "旧姓 田中", "maiden": "鈴木", "initials": "花. 佐."} {"name": "佐藤グレース恵子", "order": "FAMILY_FIRST", "family": "佐藤グレース恵子", "initials": "佐."} {"name": "佐藤グレース恵子さん", "order": "FAMILY_FIRST", "family": "佐藤グレース恵子", "suffix": "さん", "initials": "佐."} {"name": "佐藤恵子 国会議員", "family": "佐藤恵子", "suffix": "国会議員", "initials": "佐."} {"name": "佐藤恵子国会議員", "family": "佐藤恵子", "suffix": "国会議員", "initials": "佐."} {"name": "何炅", "family": "何炅", "initials": "何."} {"name": "何瑞麟", "family": "何瑞麟", "initials": "何."} {"name": "俊年杰思陈", "family": "俊年杰思陈", "initials": "俊."} {"name": "先生子媚惠仪陈小,太平绅士", "family": "先生子媚惠仪陈小", "suffix": "太平绅士", "initials": "先."} {"name": "先生子媚惠儀陳小,太平紳士", "family": "先生子媚惠儀陳小", "suffix": "太平紳士", "initials": "先."} {"name": "克特·米勒", "given": "克特", "family": "米勒", "initials": "克. 米."} {"name": "内田誠", "family": "内田誠", "initials": "内."} {"name": "内藤博文", "family": "内藤博文", "initials": "内."} {"name": "凯晴", "family": "凯晴", "initials": "凯."} {"name": "凯晴", "order": "FAMILY_FIRST", "family": "凯晴", "initials": "凯."} {"name": "凱晴", "family": "凱晴", "initials": "凱."} {"name": "凱晴", "order": "FAMILY_FIRST", "family": "凱晴", "initials": "凱."} {"name": "刘婉婷", "family": "刘婉婷", "initials": "刘."} {"name": "刘放", "family": "刘放", "initials": "刘."} {"name": "刘旺", "family": "刘旺", "initials": "刘."} {"name": "刘相蓉", "family": "刘相蓉", "initials": "刘."} {"name": "刘维", "family": "刘维", "initials": "刘."} {"name": "前田秀樹", "family": "前田秀樹", "initials": "前."} {"name": "北野誠", "family": "北野誠", "initials": "北."} {"name": "华楠", "family": "华楠", "initials": "华."} {"name": "卫惠林", "family": "卫惠林", "initials": "卫."} {"name": "司马相如", "family": "司马相如", "initials": "司."} {"name": "吉尾弘", "family": "吉尾弘", "initials": "吉."} {"name": "吉川亨", "family": "吉川亨", "initials": "吉."} {"name": "吳·永強·德安", "order": "FAMILY_FIRST", "given": "永強", "middle": "德安", "family": "吳", "initials": "永. 德. 吳."} {"name": "吴·永强·德安", "order": "FAMILY_FIRST", "given": "永强", "middle": "德安", "family": "吴", "initials": "永. 德. 吴."} {"name": "周美玲", "family": "周美玲", "initials": "周."} {"name": "唐由之", "family": "唐由之", "initials": "唐."} {"name": "坂本 龍馬", "given": "龍馬", "family": "坂本", "initials": "龍. 坂."} {"name": "城戸 四郎", "given": "四郎", "family": "城戸", "initials": "四. 城."} {"name": "堀内 克明", "given": "克明", "family": "堀内", "initials": "克. 堀."} {"name": "塞西莉亚·哈米什·施托贝尔", "given": "塞西莉亚", "middle": "哈米什", "family": "施托贝尔", "initials": "塞. 哈. 施."} {"name": "夏侯惇", "family": "夏侯惇", "initials": "夏."} {"name": "夏萍", "family": "夏萍", "initials": "夏."} {"name": "多田大介", "family": "多田大介", "initials": "多."} {"name": "大沢衛", "family": "大沢衛", "initials": "大."} {"name": "太田三郎", "family": "太田三郎", "initials": "太."} {"name": "太郎トーマス山田", "family": "太郎トーマス山田", "initials": "太."} {"name": "姚明", "family": "姚明", "initials": "姚."} {"name": "威廉·莎士比亚", "given": "威廉", "family": "莎士比亚", "initials": "威. 莎."} {"name": "威廉·莎士比亚, PhD", "given": "威廉", "family": "莎士比亚", "suffix": "PhD", "initials": "威. 莎."} {"name": "威廉·莎士比亚さん", "given": "威廉", "family": "莎士比亚", "suffix": "さん", "initials": "威. 莎."} {"name": "威廉・莎士比亚", "given": "莎士比亚", "family": "威廉", "initials": "莎. 威."} {"name": "孙悦", "family": "孙悦", "initials": "孙."} {"name": "孟建柱", "family": "孟建柱", "initials": "孟."} {"name": "宇瀚", "family": "宇瀚", "initials": "宇."} {"name": "宇瀚", "order": "FAMILY_FIRST", "family": "宇瀚", "initials": "宇."} {"name": "安东尼·陈志明", "given": "安东尼", "family": "陈志明", "initials": "安. 陈."} {"name": "安倍なつみ", "family": "安倍なつみ", "initials": "安."} {"name": "安怡张", "family": "安怡张", "initials": "安."} {"name": "安藤一郎", "order": "FAMILY_FIRST", "family": "安藤一郎", "initials": "安."} {"name": "安達禎", "family": "安達禎", "initials": "安."} {"name": "宋楠", "family": "宋楠", "initials": "宋."} {"name": "家豪明德林", "family": "家豪明德林", "initials": "家."} {"name": "小冯·布鲁赫·艾达·科妮莉亚·塞萨尔·马丁·博士教授", "order": "FAMILY_FIRST", "given": "布鲁赫", "middle": "艾达 科妮莉亚 塞萨尔 马丁", "family": "小冯", "suffix": "博士 教授", "initials": "布. 艾. 科. 塞. 马. 小."} {"name": "小彭德威东升议员先生", "order": "FAMILY_FIRST", "family": "小彭德威东升议员", "suffix": "先生", "initials": "小."} {"name": "小德威东升彭议员先生", "family": "小德威东升彭议员", "suffix": "先生", "initials": "小."} {"name": "小明", "family": "小明", "initials": "小."} {"name": "小杉 啓太", "given": "啓太", "family": "小杉", "initials": "啓. 小."} {"name": "小林宏", "family": "小林宏", "initials": "小."} {"name": "小林由佳", "family": "小林由佳", "initials": "小."} {"name": "小柳俊一", "family": "小柳俊一", "initials": "小."} {"name": "小清水 亜美", "given": "亜美", "family": "小清水", "initials": "亜. 小."} {"name": "小艾达·科妮莉亚·塞萨尔·马丁·冯·布鲁赫博士教授", "given": "小艾达", "middle": "科妮莉亚 塞萨尔 马丁 冯", "family": "布鲁赫博士", "suffix": "教授", "initials": "小. 科. 塞. 马. 冯. 布."} {"name": "山田 ア゙イ", "given": "ア゙イ", "family": "山田", "initials": "ア. 山."} {"name": "山田 エミ", "given": "エミ", "family": "山田", "initials": "エ. 山."} {"name": "山田 太郎", "given": "太郎", "family": "山田", "initials": "太. 山."} {"name": "山田 太郎 (マイケル・ジャクソン)", "given": "太郎", "family": "山田", "nickname": "マイケル ジャクソン", "initials": "太. 山."} {"name": "山田 花子 旧姓 佐藤", "given": "花子", "family": "山田", "maiden": "佐藤", "initials": "花. 山."} {"name": "山田 花子 旧姓:佐藤", "given": "山田", "middle": "花子", "family": "旧姓:佐藤", "initials": "山. 花. 旧."} {"name": "山田 花子(旧姓 佐藤)", "given": "花子", "family": "山田", "maiden": "佐藤", "initials": "花. 山."} {"name": "山田 「タロー」 タロウ", "given": "タロウ", "family": "山田", "nickname": "タロー", "initials": "タ. 山."} {"name": "山田 タロウ", "given": "タロウ", "family": "山田", "initials": "タ. 山."} {"name": "山田 ダイスケ", "given": "ダイスケ", "family": "山田", "initials": "タ. 山."} {"name": "山田 ペーター", "given": "ペーター", "family": "山田", "initials": "ヘ. 山."} {"name": "山田「タロ」太郎", "given": "太郎", "family": "山田", "nickname": "タロ", "initials": "太. 山."} {"name": "山田トーマス太郎", "order": "FAMILY_FIRST", "family": "山田トーマス太郎", "initials": "山."} {"name": "山田太郎様", "family": "山田太郎", "suffix": "様", "initials": "山."} {"name": "山田花子 旧姓 佐藤", "family": "山田花子", "maiden": "佐藤", "initials": "山."} {"name": "山田「タロー」太郎", "given": "太郎", "family": "山田", "nickname": "タロー", "initials": "太. 山."} {"name": "山田タロウ", "family": "山田タロウ", "initials": "山."} {"name": "岡正雄", "family": "岡正雄", "initials": "岡."} {"name": "岸田文雄衆議院議員", "family": "岸田文雄衆議院", "suffix": "議員", "initials": "岸."} {"name": "崔晓桐", "family": "崔晓桐", "initials": "崔."} {"name": "布鲁赫·艾达·科妮莉亚·塞萨尔·马丁·冯", "order": "FAMILY_FIRST", "given": "艾达", "middle": "科妮莉亚 塞萨尔 马丁 冯", "family": "布鲁赫", "initials": "艾. 科. 塞. 马. 冯. 布."} {"name": "平賀淳", "family": "平賀淳", "initials": "平."} {"name": "引地良一", "family": "引地良一", "initials": "引."} {"name": "张伟", "family": "张伟", "initials": "张."} {"name": "张安怡", "order": "FAMILY_FIRST", "family": "张安怡", "initials": "张."} {"name": "张简", "family": "张简", "initials": "张."} {"name": "张翰", "family": "张翰", "initials": "张."} {"name": "张雅婷", "order": "FAMILY_FIRST", "family": "张雅婷", "initials": "张."} {"name": "張瑞", "family": "張瑞", "initials": "張."} {"name": "張雅婷", "order": "FAMILY_FIRST", "family": "張雅婷", "initials": "張."} {"name": "張韶涵", "family": "張韶涵", "initials": "張."} {"name": "彭孟緝", "family": "彭孟緝", "initials": "彭."} {"name": "彭德威 议员", "family": "彭德威", "suffix": "议员", "initials": "彭."} {"name": "彭德威东升", "order": "FAMILY_FIRST", "family": "彭德威东升", "initials": "彭."} {"name": "怡君達印馮·陳二世博士,醫學博士", "given": "怡君達印馮", "family": "陳二世博士", "suffix": "醫學博士", "initials": "怡. 陳."} {"name": "恵子グレース佐藤ジュニアさん", "family": "恵子グレース佐藤ジュニア", "suffix": "さん", "initials": "恵."} {"name": "慎太郎", "family": "慎太郎", "initials": "慎."} {"name": "慎太郎", "order": "FAMILY_FIRST", "family": "慎太郎", "initials": "慎."} {"name": "揖斐章", "family": "揖斐章", "initials": "揖."} {"name": "教授·怡君·达印·陈·陈·小,博士", "given": "教授", "middle": "怡君 达印 陈 陈", "family": "小", "suffix": "博士", "initials": "教. 怡. 达. 陈. 陈. 小."} {"name": "教授·怡君·達印·陳·陳·小,博士", "given": "教授", "middle": "怡君 達印 陳 陳", "family": "小", "suffix": "博士", "initials": "教. 怡. 達. 陳. 陳. 小."} {"name": "文傑", "family": "文傑", "initials": "文."} {"name": "文傑", "order": "FAMILY_FIRST", "family": "文傑", "initials": "文."} {"name": "文强", "family": "文强", "initials": "文."} {"name": "文杰", "family": "文杰", "initials": "文."} {"name": "文杰", "order": "FAMILY_FIRST", "family": "文杰", "initials": "文."} {"name": "方豪", "family": "方豪", "initials": "方."} {"name": "施托贝尔·塞西莉亚·哈米什", "order": "FAMILY_FIRST", "given": "塞西莉亚", "middle": "哈米什", "family": "施托贝尔", "initials": "塞. 哈. 施."} {"name": "木内 信夫", "given": "信夫", "family": "木内", "initials": "信. 木."} {"name": "朱世英", "family": "朱世英", "initials": "朱."} {"name": "朱旭", "family": "朱旭", "initials": "朱."} {"name": "朱霖", "family": "朱霖", "initials": "朱."} {"name": "朴璐美", "family": "朴璐美", "initials": "朴."} {"name": "李俊賢", "family": "李俊賢", "initials": "李."} {"name": "李博", "family": "李博", "initials": "李."} {"name": "李宗文", "family": "李宗文", "initials": "李."} {"name": "李影", "family": "李影", "initials": "李."} {"name": "李正中", "family": "李正中", "initials": "李."} {"name": "李瞻", "family": "李瞻", "initials": "李."} {"name": "李翔", "family": "李翔", "initials": "李."} {"name": "李诗沣", "family": "李诗沣", "initials": "李."} {"name": "李部長", "family": "李部長", "initials": "李."} {"name": "村田喜一", "family": "村田喜一", "initials": "村."} {"name": "杨扬", "family": "杨扬", "initials": "杨."} {"name": "杨旭", "family": "杨旭", "initials": "杨."} {"name": "東郷文彦", "family": "東郷文彦", "initials": "東."} {"name": "林 力", "given": "力", "family": "林", "initials": "力. 林."} {"name": "林國華", "family": "林國華", "initials": "林."} {"name": "林家豪明德", "order": "FAMILY_FIRST", "family": "林家豪明德", "initials": "林."} {"name": "柯洁", "family": "柯洁", "initials": "柯."} {"name": "柴山景綱", "family": "柴山景綱", "initials": "柴."} {"name": "桃田賢斗", "family": "桃田賢斗", "initials": "桃."} {"name": "桜井昌司", "family": "桜井昌司", "initials": "桜."} {"name": "森卯一", "family": "森卯一", "initials": "森."} {"name": "毛 泽东", "given": "泽东", "family": "毛", "initials": "泽. 毛."} {"name": "毛 泽东 Dr.", "title": "Dr.", "given": "泽东", "family": "毛", "initials": "泽. 毛."} {"name": "毛 김", "given": "毛", "family": "김", "initials": "毛. 김."} {"name": "毛·泽东", "given": "毛", "family": "泽东", "initials": "毛. 泽."} {"name": "毛泽东", "family": "毛泽东", "initials": "毛."} {"name": "毛泽东 MA", "given": "毛泽东", "family": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "毛. M.", "capitalized": "毛泽东 Ma"} {"name": "毛泽东, MA", "given": "MA", "family": "毛泽东", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "M. 毛.", "capitalized": "Ma 毛泽东"} {"name": "永強·德安·吳", "given": "永強", "middle": "德安", "family": "吳", "initials": "永. 德. 吳."} {"name": "永强·德安·吴", "given": "永强", "middle": "德安", "family": "吴", "initials": "永. 德. 吴."} {"name": "永翘·黄", "given": "永翘", "family": "黄", "initials": "永. 黄."} {"name": "永翹·黃", "given": "永翹", "family": "黃", "initials": "永. 黃."} {"name": "汪洋", "family": "汪洋", "initials": "汪."} {"name": "汪顺", "family": "汪顺", "initials": "汪."} {"name": "沈鵬", "family": "沈鵬", "initials": "沈."} {"name": "河合亨", "family": "河合亨", "initials": "河."} {"name": "渋谷健一", "family": "渋谷健一", "initials": "渋."} {"name": "湯浅浩", "family": "湯浅浩", "initials": "湯."} {"name": "牧野虎雄", "family": "牧野虎雄", "initials": "牧."} {"name": "王 小 明", "given": "小", "middle": "明", "family": "王", "initials": "小. 明. 王."} {"name": "王·Smith", "given": "王·Smith", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "王."} {"name": "王·雅婷", "order": "FAMILY_FIRST", "given": "雅婷", "family": "王", "initials": "雅. 王."} {"name": "王·雅婷·婷婷", "order": "FAMILY_FIRST", "given": "雅婷", "middle": "婷婷", "family": "王", "initials": "雅. 婷. 王."} {"name": "王先生", "family": "王", "suffix": "先生", "initials": "王."} {"name": "王先生, V.", "given": "V.", "family": "王", "suffix": "先生", "initials": "V. 王."} {"name": "王君", "family": "王君", "initials": "王."} {"name": "王小明 先生", "family": "王小明", "suffix": "先生", "initials": "王."} {"name": "王小明先生", "family": "王小明", "suffix": "先生", "initials": "王."} {"name": "王氏", "family": "王氏", "initials": "王."} {"name": "王鑫", "family": "王鑫", "initials": "王."} {"name": "王镇", "family": "王镇", "initials": "王."} {"name": "王雅婷婷婷", "order": "FAMILY_FIRST", "family": "王雅婷婷婷", "initials": "王."} {"name": "王雅婷婷婷二世議員", "order": "FAMILY_FIRST", "family": "王雅婷婷婷二世", "suffix": "議員", "initials": "王."} {"name": "王雅婷,議員", "family": "王雅婷", "suffix": "議員", "initials": "王."} {"name": "王雪莹", "family": "王雪莹", "initials": "王."} {"name": "田中 さん", "family": "田中", "suffix": "さん", "initials": "田."} {"name": "田中 副社長", "given": "副", "family": "田中", "suffix": "社長", "initials": "副. 田."} {"name": "田中 太郎 旧姓 佐藤 MA", "given": "太郎", "family": "田中", "suffix": "MA", "maiden": "佐藤", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as maiden (declared)"], "initials": "太. 田."} {"name": "田中 太郎 様", "given": "太郎", "family": "田中", "suffix": "様", "initials": "太. 田."} {"name": "田中 太郎, MA", "given": "太郎", "family": "田中", "suffix": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (declared)"], "initials": "太. 田."} {"name": "田中 殿", "family": "田中", "suffix": "殿", "initials": "田."} {"name": "田中 社長", "family": "田中", "suffix": "社長", "initials": "田."} {"name": "田中, 太郎 MA", "given": "太郎", "family": "田中", "suffix": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as middle (declared)"], "initials": "太. 田."} {"name": "田中, 太郎さん", "given": "太郎", "family": "田中", "suffix": "さん", "initials": "太. 田."} {"name": "田中.", "family": "田中.", "initials": "田."} {"name": "田中. 太郎", "given": "太郎", "family": "田中.", "initials": "太. 田."} {"name": "田中、太郎", "given": "田中、太郎", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "田."} {"name": "田中『ハナ』花子", "given": "花子", "family": "田中", "nickname": "ハナ", "initials": "花. 田."} {"name": "田中さん", "family": "田中", "suffix": "さん", "initials": "田."} {"name": "田中さん II", "family": "田中", "suffix": "さん II", "initials": "田."} {"name": "田中さん V.", "given": "田中さん", "family": "V.", "initials": "田. V."} {"name": "田中さん v", "given": "田中さん", "family": "v", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: generation as suffix (vocabulary)"], "initials": "田. v.", "capitalized": "田中さん V"} {"name": "田中さん 様.", "family": "田中", "suffix": "さん 様.", "initials": "田."} {"name": "田中さん, Dr.", "title": "Dr.", "family": "田中さん", "initials": "田."} {"name": "田中さん, Ph. D.", "family": "田中", "suffix": "さん, Ph. D.", "initials": "田."} {"name": "田中さん, PhD", "family": "田中", "suffix": "さん, PhD", "initials": "田."} {"name": "田中さん, V.", "given": "V.", "family": "田中", "suffix": "さん", "initials": "V. 田."} {"name": "田中さん, 太郎", "given": "太郎", "family": "田中さん", "initials": "太. 田."} {"name": "田中さん, 様.", "family": "田中", "suffix": "さん, 様.", "initials": "田."} {"name": "田中さん.", "family": "田中", "suffix": "さん.", "initials": "田."} {"name": "田中さん., V.", "given": "V.", "family": "田中", "suffix": "さん.", "initials": "V. 田."} {"name": "田中医学博士", "family": "田中", "suffix": "医学博士", "initials": "田."} {"name": "田中博士", "family": "田中博士", "initials": "田."} {"name": "田中社長", "family": "田中", "suffix": "社長", "initials": "田."} {"name": "田中部長", "family": "田中部長", "initials": "田."} {"name": "白文正", "family": "白文正", "initials": "白."} {"name": "石智勇", "family": "石智勇", "initials": "石."} {"name": "秦彦三郎", "family": "秦彦三郎", "initials": "秦."} {"name": "端山 忠左衛門", "given": "忠左衛門", "family": "端山", "initials": "忠. 端."} {"name": "米勒·克特", "order": "FAMILY_FIRST", "given": "克特", "family": "米勒", "initials": "克. 米."} {"name": "米川信夫", "family": "米川信夫", "initials": "米."} {"name": "罗建文", "family": "罗建文", "initials": "罗."} {"name": "罗隆基", "family": "罗隆基", "initials": "罗."} {"name": "胡英", "family": "胡英", "initials": "胡."} {"name": "茅祖權", "family": "茅祖權", "initials": "茅."} {"name": "草刈潤", "family": "草刈潤", "initials": "草."} {"name": "荒木茂", "family": "荒木茂", "initials": "荒."} {"name": "葉克強", "family": "葉克強", "initials": "葉."} {"name": "葉陶", "family": "葉陶", "initials": "葉."} {"name": "蔡国强", "family": "蔡国强", "initials": "蔡."} {"name": "蔡桂英", "family": "蔡桂英", "initials": "蔡."} {"name": "蔡金生", "family": "蔡金生", "initials": "蔡."} {"name": "蔣經國", "family": "蔣經國", "initials": "蔣."} {"name": "藤井旭", "family": "藤井旭", "initials": "藤."} {"name": "藤田信雄", "family": "藤田信雄", "initials": "藤."} {"name": "蘇貞昌", "family": "蘇貞昌", "initials": "蘇."} {"name": "詹桓", "family": "詹桓", "initials": "詹."} {"name": "諫山博", "family": "諫山博", "initials": "諫."} {"name": "諸葛亮", "family": "諸葛亮", "initials": "諸."} {"name": "謝暉", "family": "謝暉", "initials": "謝."} {"name": "谢文清", "family": "谢文清", "initials": "谢."} {"name": "趙小蘭", "family": "趙小蘭", "initials": "趙."} {"name": "辛巴达", "family": "辛巴达", "initials": "辛."} {"name": "辛巴达", "order": "FAMILY_FIRST", "family": "辛巴达", "initials": "辛."} {"name": "近藤平三郎", "family": "近藤平三郎", "initials": "近."} {"name": "遠藤晴", "family": "遠藤晴", "initials": "遠."} {"name": "邓志伟", "family": "邓志伟", "initials": "邓."} {"name": "邓逸凡", "family": "邓逸凡", "initials": "邓."} {"name": "邵毓麟", "family": "邵毓麟", "initials": "邵."} {"name": "郑爽", "family": "郑爽", "initials": "郑."} {"name": "郭丹", "family": "郭丹", "initials": "郭."} {"name": "郭靜", "family": "郭靜", "initials": "郭."} {"name": "鈴木亀蔵", "family": "鈴木亀蔵", "initials": "鈴."} {"name": "鎌田 弥彦", "given": "弥彦", "family": "鎌田", "initials": "弥. 鎌."} {"name": "钟离小凤", "family": "钟离小凤", "initials": "钟."} {"name": "陈·怡君·达印", "order": "FAMILY_FIRST", "given": "怡君", "middle": "达印", "family": "陈", "initials": "怡. 达. 陈."} {"name": "陈·陈·怡君·教授,小·达印·博士", "order": "FAMILY_FIRST", "given": "小", "middle": "达印", "family": "陈 陈 怡君", "suffix": "教授, 博士", "initials": "小. 达. 陈. 陈. 怡."} {"name": "陈俊年杰思", "order": "FAMILY_FIRST", "family": "陈俊年杰思", "initials": "陈."} {"name": "陈凯歌", "family": "陈凯歌", "initials": "陈."} {"name": "陈子媚先生,小惠仪太平绅士", "order": "FAMILY_FIRST", "given": "小惠仪", "family": "陈子媚先生", "suffix": "太平绅士", "initials": "小. 陈."} {"name": "陈子媚惠仪", "order": "FAMILY_FIRST", "family": "陈子媚惠仪", "initials": "陈."} {"name": "陈静", "family": "陈静", "initials": "陈."} {"name": "陳·怡君·達印", "order": "FAMILY_FIRST", "given": "怡君", "middle": "達印", "family": "陳", "initials": "怡. 達. 陳."} {"name": "陳·陳·怡君·教授,小·達印·博士", "order": "FAMILY_FIRST", "given": "小", "middle": "達印", "family": "陳 陳 怡君", "suffix": "教授, 博士", "initials": "小. 達. 陳. 陳. 怡."} {"name": "陳俊傑", "family": "陳俊傑", "initials": "陳."} {"name": "陳婷", "family": "陳婷", "initials": "陳."} {"name": "陳子媚 二世", "family": "陳子媚", "suffix": "二世", "initials": "陳."} {"name": "陳子媚先生,小惠儀太平紳士", "order": "FAMILY_FIRST", "given": "小惠儀", "family": "陳子媚先生", "suffix": "太平紳士", "initials": "小. 陳."} {"name": "陳子媚惠儀", "order": "FAMILY_FIRST", "family": "陳子媚惠儀", "initials": "陳."} {"name": "陳怡君 二世", "family": "陳怡君", "suffix": "二世", "initials": "陳."} {"name": "陳怡君二世", "family": "陳怡君", "suffix": "二世", "initials": "陳."} {"name": "陳怡君,醫學博士", "family": "陳怡君", "suffix": "醫學博士", "initials": "陳."} {"name": "陳明忠", "family": "陳明忠", "initials": "陳."} {"name": "雅婷", "family": "雅婷", "initials": "雅."} {"name": "雅婷", "order": "FAMILY_FIRST", "family": "雅婷", "initials": "雅."} {"name": "雅婷婷婷王", "family": "雅婷婷婷王", "initials": "雅."} {"name": "雅婷婷婷王二世先生,議員", "family": "雅婷婷婷王二世", "suffix": "先生, 議員", "initials": "雅."} {"name": "雅婷张", "family": "雅婷张", "initials": "雅."} {"name": "雅婷張", "family": "雅婷張", "initials": "雅."} {"name": "雅婷王", "family": "雅婷王", "initials": "雅."} {"name": "韩光", "family": "韩光", "initials": "韩."} {"name": "韩旭", "family": "韩旭", "initials": "韩."} {"name": "韩红", "family": "韩红", "initials": "韩."} {"name": "馮·陳·怡君·達印", "order": "FAMILY_FIRST", "given": "陳", "middle": "怡君 達印", "family": "馮", "initials": "陳. 怡. 達. 馮."} {"name": "馮·陳·怡君·達印·二世醫學博士", "order": "FAMILY_FIRST", "given": "陳", "middle": "怡君 達印", "family": "馮", "suffix": "二世 醫學博士", "initials": "陳. 怡. 達. 馮."} {"name": "馮定國", "family": "馮定國", "initials": "馮."} {"name": "駒井玲子", "family": "駒井玲子", "initials": "駒."} {"name": "马丁·路德·金", "given": "马丁", "middle": "路德", "family": "金", "initials": "马. 路. 金."} {"name": "马丁·路德·金씨", "given": "马丁", "middle": "路德", "family": "金", "suffix": "씨", "initials": "马. 路. 金."} {"name": "高橋 みなみ", "given": "みなみ", "family": "高橋", "initials": "み. 高."} {"name": "高橋みなみ", "family": "高橋みなみ", "initials": "高."} {"name": "高橋・一郎", "given": "一郎", "family": "高橋", "initials": "一. 高."} {"name": "高橋一郎", "family": "高橋一郎", "initials": "高."} {"name": "高橋章", "family": "高橋章", "initials": "高."} {"name": "高田栄二", "family": "高田栄二", "initials": "高."} {"name": "魏曦", "family": "魏曦", "initials": "魏."} {"name": "鵜殿", "family": "鵜殿", "initials": "鵜."} {"name": "黃·永翹", "order": "FAMILY_FIRST", "given": "永翹", "family": "黃", "initials": "永. 黃."} {"name": "黄·永翘", "order": "FAMILY_FIRST", "given": "永翘", "family": "黄", "initials": "永. 黄."} {"name": "黄乃", "family": "黄乃", "initials": "黄."} {"name": "黄浩", "family": "黄浩", "initials": "黄."} {"name": "黎智英", "family": "黎智英", "initials": "黎."} {"name": "강명순", "given": "명순", "family": "강", "initials": "명. 강."} {"name": "강유미", "order": "FAMILY_FIRST", "given": "유미", "family": "강", "initials": "유. 강."} {"name": "강혜원", "given": "혜원", "family": "강", "initials": "혜. 강."} {"name": "고 상돈", "given": "상돈", "family": "고", "initials": "상. 고."} {"name": "고기봉", "given": "기봉", "family": "고", "initials": "기. 고."} {"name": "고요한", "given": "요한", "family": "고", "initials": "요. 고."} {"name": "공옥진", "given": "옥진", "family": "공", "initials": "옥. 공."} {"name": "권재진", "given": "재진", "family": "권", "initials": "재. 권."} {"name": "권중동", "given": "중동", "family": "권", "initials": "중. 권."} {"name": "김 v", "given": "김", "family": "v", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: generation as suffix (vocabulary)"], "initials": "김. v.", "capitalized": "김 V"} {"name": "김 민준", "given": "민준", "family": "김", "initials": "민. 김."} {"name": "김, 민준 MA", "given": "민준", "family": "김", "suffix": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as middle (declared)"], "initials": "민. 김."} {"name": "김, 민준씨", "given": "민준", "family": "김", "suffix": "씨", "initials": "민. 김."} {"name": "김, 민준씨 (Jimmy)", "given": "민준", "family": "김", "suffix": "씨", "nickname": "Jimmy", "initials": "민. 김."} {"name": "김. 민준", "given": "민준", "family": "김.", "initials": "민. 김."} {"name": "김゙민준", "given": "김゙민준", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "김."} {"name": "김경애", "given": "경애", "family": "김", "initials": "경. 김."} {"name": "김계원", "given": "계원", "family": "김", "initials": "계. 김."} {"name": "김기영", "given": "기영", "family": "김", "initials": "기. 김."} {"name": "김민규헨리", "order": "FAMILY_FIRST", "given": "민규헨리", "family": "김", "initials": "민. 김."} {"name": "김민준", "given": "민준", "family": "김", "initials": "민. 김."} {"name": "김민준 님", "given": "민준", "family": "김", "suffix": "님", "initials": "민. 김."} {"name": "김민준 박, MA Jones", "given": "MA", "middle": "Jones", "family": "김민준 박", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: credential as suffix (vocabulary)"], "initials": "M. J. 김. 박.", "capitalized_force": "Ma Jones 김민준 박"} {"name": "김민준 박, X.Y.Z.", "given": "민준", "middle": "박", "family": "김", "suffix": "X.Y.Z.", "initials": "민. 박. 김."} {"name": "김민준 박사", "given": "민준", "family": "김", "suffix": "박사", "initials": "민. 김."} {"name": "김민준 박사 씨", "given": "민준", "family": "김", "suffix": "박사 씨", "initials": "민. 김."} {"name": "김민준 박사님", "given": "민준", "family": "김", "suffix": "박사님", "initials": "민. 김."} {"name": "김민준 씨", "given": "민준", "family": "김", "suffix": "씨", "initials": "민. 김."} {"name": "김민준 씨.", "given": "민준", "family": "김", "suffix": "씨.", "initials": "민. 김."} {"name": "김민준 씨。", "given": "민준", "family": "김", "suffix": "씨。", "initials": "민. 김."} {"name": "김민준 씨.", "given": "민준", "family": "김", "suffix": "씨.", "initials": "민. 김."} {"name": "김민준 씨。", "given": "민준", "family": "김", "suffix": "씨。", "initials": "민. 김."} {"name": "김민준 양", "given": "민준", "family": "김", "suffix": "양", "initials": "민. 김."} {"name": "김민준 양.", "given": "민준", "family": "김", "suffix": "양.", "initials": "민. 김."} {"name": "김민준, 씨", "family": "김민준", "suffix": "씨", "initials": "김."} {"name": "김민준, 씨.", "family": "김민준", "suffix": "씨.", "initials": "김."} {"name": "김민준.", "given": "민준.", "family": "김", "initials": "민. 김."} {"name": "김민준. 지훈", "given": "민준.", "middle": "지훈", "family": "김", "initials": "민. 지. 김."} {"name": "김민준님", "given": "민준", "family": "김", "suffix": "님", "initials": "민. 김."} {"name": "김민준박사님", "given": "민준", "family": "김", "suffix": "박사님", "initials": "민. 김."} {"name": "김민준씨", "given": "민준", "family": "김", "suffix": "씨", "initials": "민. 김."} {"name": "김민준씨 (Jimmy)", "given": "민준", "family": "김", "suffix": "씨", "nickname": "Jimmy", "initials": "민. 김."} {"name": "김민준씨 Jr.", "given": "민준", "family": "김", "suffix": "씨 Jr.", "initials": "민. 김."} {"name": "김민준씨, J.씨", "family": "김민준", "suffix": "씨, J.씨", "initials": "김."} {"name": "김민준씨, MA PhD", "family": "김민준", "suffix": "씨, MA PhD", "initials": "김."} {"name": "김민준씨, V.", "given": "V.", "family": "김민준", "suffix": "씨", "initials": "V. 김."} {"name": "김민준씨.", "given": "민준", "family": "김", "suffix": "씨.", "initials": "민. 김."} {"name": "김민준씨., J.씨", "family": "김민준", "suffix": "씨., J.씨", "initials": "김."} {"name": "김병관", "given": "병관", "family": "김", "initials": "병. 김."} {"name": "김상철", "given": "상철", "family": "김", "initials": "상. 김."} {"name": "김선생님", "family": "김", "suffix": "선생님", "initials": "김."} {"name": "김선영", "given": "선영", "family": "김", "initials": "선. 김."} {"name": "김성혜", "given": "성혜", "family": "김", "initials": "성. 김."} {"name": "김성훈", "given": "성훈", "family": "김", "initials": "성. 김."} {"name": "김소은", "given": "소은", "family": "김", "initials": "소. 김."} {"name": "김씨", "family": "김", "suffix": "씨", "initials": "김."} {"name": "김약수", "given": "약수", "family": "김", "initials": "약. 김."} {"name": "김연경", "given": "연경", "family": "김", "initials": "연. 김."} {"name": "김영환", "given": "영환", "family": "김", "initials": "영. 김."} {"name": "김용완", "given": "용완", "family": "김", "initials": "용. 김."} {"name": "김응천", "given": "응천", "family": "김", "initials": "응. 김."} {"name": "김장섭", "given": "장섭", "family": "김", "initials": "장. 김."} {"name": "김재구", "given": "재구", "family": "김", "initials": "재. 김."} {"name": "김정은", "given": "정은", "family": "김", "initials": "정. 김."} {"name": "김종삼", "given": "종삼", "family": "김", "initials": "종. 김."} {"name": "김지수", "given": "지수", "family": "김", "initials": "지. 김."} {"name": "김지양", "given": "지양", "family": "김", "initials": "지. 김."} {"name": "김한민", "given": "한민", "family": "김", "initials": "한. 김."} {"name": "나익진", "given": "익진", "family": "나", "initials": "익. 나."} {"name": "남궁", "family": "남궁", "initials": "남."} {"name": "남궁민수", "given": "민수", "family": "남궁", "ambiguities": ["SEGMENTATION"], "initials": "민. 남."} {"name": "남궁민수 지훈", "given": "민수", "middle": "지훈", "family": "남궁", "ambiguities": ["SEGMENTATION"], "initials": "민. 지. 남."} {"name": "남궁민수, 지훈", "given": "지훈", "family": "남궁민수", "initials": "지. 남."} {"name": "남다름", "given": "다름", "family": "남", "initials": "다. 남."} {"name": "노유상", "given": "유상", "family": "노", "initials": "유. 노."} {"name": "도민호", "given": "민호", "family": "도", "initials": "민. 도."} {"name": "로버트 다우니 주니어", "given": "다우니", "family": "로버트", "suffix": "주니어", "initials": "다. 로."} {"name": "류효영", "given": "효영", "family": "류", "initials": "효. 류."} {"name": "마이클·잭슨", "given": "마이클", "family": "잭슨", "initials": "마. 잭."} {"name": "마틴 킹, MA", "given": "틴", "middle": "킹", "family": "마", "suffix": "MA", "ambiguities": ["SUFFIX_OR_NAME"], "alternatives": ["SUFFIX_OR_NAME: name as given (declared)"], "initials": "틴. 킹. 마."} {"name": "뮐러 케테", "order": "FAMILY_FIRST", "given": "케테", "family": "뮐러", "initials": "케. 뮐."} {"name": "민규헨리김", "given": "규헨리김", "family": "민", "initials": "규. 민."} {"name": "민윤기", "given": "윤기", "family": "민", "initials": "윤. 민."} {"name": "박경애", "given": "경애", "family": "박", "initials": "경. 박."} {"name": "박경원", "given": "경원", "family": "박", "initials": "경. 박."} {"name": "박만원", "given": "만원", "family": "박", "initials": "만. 박."} {"name": "박민지", "given": "민지", "family": "박", "initials": "민. 박."} {"name": "박성수", "given": "성수", "family": "박", "initials": "성. 박."} {"name": "박성우", "given": "성우", "family": "박", "initials": "성. 박."} {"name": "박소현", "order": "FAMILY_FIRST", "given": "소현", "family": "박", "initials": "소. 박."} {"name": "박시후", "given": "시후", "family": "박", "initials": "시. 박."} {"name": "박정희", "given": "정희", "family": "박", "initials": "정. 박."} {"name": "박주영", "given": "주영", "family": "박", "initials": "주. 박."} {"name": "박지현", "given": "지현", "family": "박", "initials": "지. 박."} {"name": "박진구", "given": "진구", "family": "박", "initials": "진. 박."} {"name": "박진영", "given": "진영", "family": "박", "initials": "진. 박."} {"name": "박형진", "given": "형진", "family": "박", "initials": "형. 박."} {"name": "방응모", "given": "응모", "family": "방", "initials": "응. 방."} {"name": "선생님", "family": "선생님", "initials": "선."} {"name": "선생님, J.씨", "given": "J.", "family": "선생님", "suffix": "씨", "initials": "J. 선."} {"name": "설기현", "given": "기현", "family": "설", "initials": "기. 설."} {"name": "소현박", "given": "현박", "family": "소", "initials": "현. 소."} {"name": "손태곤", "given": "태곤", "family": "손", "initials": "태. 손."} {"name": "손현주", "given": "현주", "family": "손", "initials": "현. 손."} {"name": "송대남", "given": "대남", "family": "송", "initials": "대. 송."} {"name": "슈퇴버 제질리아 해미쉬", "order": "FAMILY_FIRST", "given": "제질리아", "middle": "해미쉬", "family": "슈퇴버", "initials": "제. 해. 슈."} {"name": "신경림", "given": "경림", "family": "신", "initials": "경. 신."} {"name": "신도현", "given": "도현", "family": "신", "initials": "도. 신."} {"name": "신밧드", "given": "밧드", "family": "신", "initials": "밧. 신."} {"name": "신밧드", "order": "FAMILY_FIRST", "given": "밧드", "family": "신", "initials": "밧. 신."} {"name": "신용훈", "given": "용훈", "family": "신", "initials": "용. 신."} {"name": "신춘호", "given": "춘호", "family": "신", "initials": "춘. 신."} {"name": "신효순", "given": "효순", "family": "신", "initials": "효. 신."} {"name": "씨", "family": "씨", "initials": "씨."} {"name": "아키야마 요시히로", "given": "요시히로", "family": "아키야마", "initials": "요. 아."} {"name": "양 미선", "given": "미선", "family": "양", "initials": "미. 양."} {"name": "양 지훈", "given": "지훈", "family": "양", "initials": "지. 양."} {"name": "양 지훈.", "given": "지훈.", "family": "양", "initials": "지. 양."} {"name": "양. 지훈", "given": "지훈", "family": "양.", "initials": "지. 양."} {"name": "에이다 코넬리아 세사르 마르틴 폰 볼프 박사", "given": "코넬리아", "middle": "세사르 마르틴 폰 볼프", "family": "에이다", "suffix": "박사", "initials": "코. 세. 마. 폰. 볼. 에."} {"name": "오정숙", "given": "정숙", "family": "오", "initials": "정. 오."} {"name": "유명희", "given": "명희", "family": "유", "initials": "명. 유."} {"name": "유미", "given": "미", "family": "유", "initials": "미. 유."} {"name": "유미", "order": "FAMILY_FIRST", "given": "미", "family": "유", "initials": "미. 유."} {"name": "유미강", "given": "미강", "family": "유", "initials": "미. 유."} {"name": "유영국", "given": "영국", "family": "유", "initials": "영. 유."} {"name": "육영수", "given": "영수", "family": "육", "initials": "영. 육."} {"name": "육인수", "given": "인수", "family": "육", "initials": "인. 육."} {"name": "육지수", "given": "지수", "family": "육", "initials": "지. 육."} {"name": "윤아정", "given": "아정", "family": "윤", "initials": "아. 윤."} {"name": "이, J.씨", "given": "J.", "family": "이", "suffix": "씨", "initials": "J. 이."} {"name": "이, J.씨.", "given": "J.", "family": "이", "suffix": "씨.", "initials": "J. 이."} {"name": "이강훈", "given": "강훈", "family": "이", "initials": "강. 이."} {"name": "이광노", "given": "광노", "family": "이", "initials": "광. 이."} {"name": "이만희", "given": "만희", "family": "이", "initials": "만. 이."} {"name": "이병기", "given": "병기", "family": "이", "initials": "병. 이."} {"name": "이병훈", "given": "병훈", "family": "이", "initials": "병. 이."} {"name": "이보나", "given": "보나", "family": "이", "initials": "보. 이."} {"name": "이상득", "given": "상득", "family": "이", "initials": "상. 이."} {"name": "이상익", "given": "상익", "family": "이", "initials": "상. 이."} {"name": "이소연", "given": "소연", "family": "이", "initials": "소. 이."} {"name": "이소희", "given": "소희", "family": "이", "initials": "소. 이."} {"name": "이유빈", "given": "유빈", "family": "이", "initials": "유. 이."} {"name": "이정희", "given": "정희", "family": "이", "initials": "정. 이."} {"name": "이진주", "given": "진주", "family": "이", "initials": "진. 이."} {"name": "이필모", "given": "필모", "family": "이", "initials": "필. 이."} {"name": "이현만", "given": "현만", "family": "이", "initials": "현. 이."} {"name": "이호왕", "given": "호왕", "family": "이", "initials": "호. 이."} {"name": "이희완", "given": "희완", "family": "이", "initials": "희. 이."} {"name": "임 대규", "given": "대규", "family": "임", "initials": "대. 임."} {"name": "임정은", "given": "정은", "family": "임", "initials": "정. 임."} {"name": "장명한", "given": "명한", "family": "장", "initials": "명. 장."} {"name": "장우현", "given": "우현", "family": "장", "initials": "우. 장."} {"name": "장원영", "given": "원영", "family": "장", "initials": "원. 장."} {"name": "장정곤", "given": "정곤", "family": "장", "initials": "정. 장."} {"name": "장효희", "given": "효희", "family": "장", "initials": "효. 장."} {"name": "전재규", "given": "재규", "family": "전", "initials": "재. 전."} {"name": "정동권", "given": "동권", "family": "정", "initials": "동. 정."} {"name": "정석모", "given": "석모", "family": "정", "initials": "석. 정."} {"name": "정세균", "given": "세균", "family": "정", "initials": "세. 정."} {"name": "정순택", "given": "순택", "family": "정", "initials": "순. 정."} {"name": "정호연", "given": "호연", "family": "정", "initials": "호. 정."} {"name": "제질리아 해미쉬 슈퇴버", "given": "질리아", "middle": "해미쉬 슈퇴버", "family": "제", "initials": "질. 해. 슈. 제."} {"name": "조병득", "given": "병득", "family": "조", "initials": "병. 조."} {"name": "조상현", "given": "상현", "family": "조", "initials": "상. 조."} {"name": "조세희", "given": "세희", "family": "조", "initials": "세. 조."} {"name": "조형부", "given": "형부", "family": "조", "initials": "형. 조."} {"name": "주정균", "given": "정균", "family": "주", "initials": "정. 주."} {"name": "지훈, 남궁민수", "given": "남궁민수", "family": "지훈", "initials": "남. 지."} {"name": "차태현", "given": "태현", "family": "차", "initials": "태. 차."} {"name": "천경자", "given": "경자", "family": "천", "initials": "경. 천."} {"name": "천병규", "given": "병규", "family": "천", "initials": "병. 천."} {"name": "최국현", "given": "국현", "family": "최", "initials": "국. 최."} {"name": "최두환", "given": "두환", "family": "최", "initials": "두. 최."} {"name": "최상옥", "given": "상옥", "family": "최", "initials": "상. 최."} {"name": "최영근", "given": "영근", "family": "최", "initials": "영. 최."} {"name": "최종덕", "given": "종덕", "family": "최", "initials": "종. 최."} {"name": "최희서", "given": "희서", "family": "최", "initials": "희. 최."} {"name": "추준석", "given": "준석", "family": "추", "initials": "준. 추."} {"name": "케테 뮐러", "given": "뮐러", "family": "케테", "initials": "뮐. 케."} {"name": "폰 볼프 에이다 코넬리아 세사르 마르틴", "order": "FAMILY_FIRST", "given": "볼프", "middle": "에이다 코넬리아 세사르 마르틴", "family": "폰", "initials": "볼. 에. 코. 세. 마. 폰."} {"name": "폰 볼프 에이다 코넬리아 세사르 마르틴 박사", "order": "FAMILY_FIRST", "given": "볼프", "middle": "에이다 코넬리아 세사르 마르틴", "family": "폰", "suffix": "박사", "initials": "볼. 에. 코. 세. 마. 폰."} {"name": "하예정", "given": "예정", "family": "하", "initials": "예. 하."} {"name": "한성숙", "given": "성숙", "family": "한", "initials": "성. 한."} {"name": "한순철", "given": "순철", "family": "한", "initials": "순. 한."} {"name": "한지성", "given": "지성", "family": "한", "initials": "지. 한."} {"name": "허성태", "given": "성태", "family": "허", "initials": "성. 허."} {"name": "허정구", "given": "정구", "family": "허", "initials": "정. 허."} {"name": "홍남표", "given": "남표", "family": "홍", "initials": "남. 홍."} {"name": "홍순옥", "given": "순옥", "family": "홍", "initials": "순. 홍."} {"name": "황윤호", "given": "윤호", "family": "황", "initials": "윤. 황."} {"name": "タナカ. John", "given": "タナカ.", "family": "John", "initials": "タ. J."} {"name": "タロウ·ヤマダ", "given": "タロウ", "family": "ヤマダ", "initials": "タ. ヤ."} {"name": "マイケル.", "given": "マイケル.", "ambiguities": ["GIVEN_OR_FAMILY"], "alternatives": ["GIVEN_OR_FAMILY: name as family (position)"], "initials": "マ."} {"name": "ヤマダ タロウ", "given": "ヤマダ", "family": "タロウ", "initials": "ヤ. タ."} ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/snapshot.py������������������������������������0000664�0000000�0000000�00000124613�15273032717�0024723�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Every corpus name's current reading, committed -- a snapshot. snapshot.jsonl holds one line per (name, order) across every corpus*.jsonl beside this script: the seven role fields, the reported ambiguity kinds, initials() and the two case-repair views on the 2.0 surface, plus the facade's v1 fields wherever they say something the 2.0 row does not. The case views are recorded only where they move something: `capitalized` where capitalized() renders other than the name as parsed, `capitalized_force` where capitalized(force=True) renders other than capitalized() does (#826). 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 the review reads it directly and nothing has to be classified (#664). uv run python tools/differential/snapshot.py # check, grouped uv run python tools/differential/snapshot.py --record # re-record uv run python tools/differential/snapshot.py --since v2.5.0 # vs a ref --since REF compares the tree against the snapshot as committed at a git ref, which is how a release asks what moved since the last tag. --tree PATH reads with the nameparser package of another checkout (the corpus is always this one's), for comparing two trees. --local SOURCE reads a local corpus (local_corpora.py) instead of the committed ones, against a snapshot kept beside it in the cache, so a change can be read against real names that are never committed: uv run python tools/differential/snapshot.py --local ctgov-officials --base ../master-checkout --base PATH reads the base checkout's readings from a kept baseline (below), recording one in a child process where none is kept, and compares this tree against them; it never reads or writes the shared snapshot beside the corpus, so a session measuring while another records cannot be handed that session's readings as its base. Without --base a check compares against that shared snapshot, which whoever last recorded decides: uv run python tools/differential/snapshot.py --local ctgov-officials --record --tree ../master-checkout uv run python tools/differential/snapshot.py --local ctgov-officials A local snapshot does not read the facade: v1 compatibility is the committed corpora's question, and the facade's parse was about half the cost of a local reading (measured 2026-10-23, tools/differential/ README.md's "Local corpora"). --sample N reads a fixed 1-in-N sample of a local corpus, chosen by a hash of the name so a sample recorded from one tree and checked from another holds the same names, into a snapshot of its own (snapshot.sample-N.jsonl). A local recording is also kept, gzipped, under baselines/ beside the snapshot, keyed by the corpus fetch, a hash of the files in the read tree's nameparser/, the interpreter's minor version and the code that writes a row (snapshot.py, corpus_build.py, shapes.py), so recording the same base again copies it instead of parsing. Any reading of PARALLEL_FLOOR entries or more, committed or local, parses in --jobs worker processes (default: every CPU). A corpus that loses names shows them as removed, so a truncated file cannot pass for a clean run. compare.py builds its baseline rows with row() too, so a released wheel's readings and the tree's are compared in one shape. """ from __future__ import annotations import argparse import gzip import hashlib import importlib import importlib.util import json import multiprocessing import os import shutil import subprocess import sys import tempfile import time import warnings import zlib from collections import defaultdict from concurrent.futures import ProcessPoolExecutor from pathlib import Path from collections.abc import Callable, Sequence from typing import TYPE_CHECKING, Any, TypeVar if TYPE_CHECKING: from nameparser import ParsedName HERE = Path(__file__).resolve().parent # The shared corpus helpers, found as the corpus builders find them (see # corpus_build's docstring for why the directory is appended). if str(HERE) not in sys.path: sys.path.append(str(HERE)) from corpus_build import jsonl_line, jsonl_lines # noqa: E402 REPO_ROOT = HERE.parents[1] T = TypeVar("T") SNAPSHOT = HERE / "snapshot.jsonl" RECORD_COMMAND = "uv run python tools/differential/snapshot.py --record" V2_FIELDS = ("title", "given", "middle", "family", "suffix", "nickname", "maiden") V1_FIELDS = ("title", "first", "middle", "last", "suffix", "nickname", "maiden") #: The facade's v1 field each 2.0 field corresponds to. V1_FOR = dict(zip(V2_FIELDS, V1_FIELDS)) #: The keys a corpus line may carry. A misspelled key is refused rather #: than ignored: ignoring it would drop the shape the line declares and #: read the name under the default order with nothing saying so. _CORPUS_KEYS = frozenset({"name", "tests", "shape"}) Entry = tuple[str, "str | None"] def load_shapes() -> dict[int, Any]: """SHAPES from shapes.py, loaded by path: tools/ is not a package.""" spec = importlib.util.spec_from_file_location("_shapes", HERE / "shapes.py") assert spec and spec.loader module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module.SHAPES # type: ignore[no-any-return] def _corpus_line(path: Path, line: str, orders: dict[int, str | None]) -> Entry: raw = json.loads(line) if isinstance(raw, str): return raw, None if not (isinstance(raw, dict) and isinstance(raw.get("name"), str)): raise SystemExit(f"{path.name}: corpus line is neither a JSON string " f"nor an object with a string 'name': {line!r}") if unknown := sorted(set(raw) - _CORPUS_KEYS): raise SystemExit(f"{path.name}: a corpus line has unknown key(s) " f"{unknown}; expected only {sorted(_CORPUS_KEYS)}: " f"{line!r}") tests = raw.get("tests") if tests is not None and not (isinstance(tests, list) and all(isinstance(t, str) for t in tests)): raise SystemExit(f"{path.name}: a corpus line's 'tests' must be a " f"list of strings, not {tests!r}: {line!r}") shape = raw.get("shape") if shape is None: return raw["name"], None # bool is an int subclass and hash(True) == hash(1): unexcluded, # {"shape": true} would read the name under shape 1's order. if isinstance(shape, bool) or shape not in orders: raise SystemExit(f"{path.name}: a corpus line's 'shape' must name a " f"shapes.py entry {sorted(orders)}, not {shape!r}: " f"{line!r}") return raw["name"], orders[shape] def corpus_entries(paths: list[Path] | None = None) -> list[Entry]: """Every distinct (name, order) in the corpora -- every corpus*.jsonl beside this file, or `paths` -- sorted. The same string tagged with two shapes is two entries, one per order.""" orders = {k: s.order for k, s in load_shapes().items()} paths = paths or sorted(HERE.glob("corpus*.jsonl")) if not paths: raise SystemExit(f"no corpus files found in {HERE}") found: set[Entry] = set() for path in paths: lines = jsonl_lines(path.read_text(encoding="utf-8")) if not lines: raise SystemExit(f"{path.name} is empty") found.update(_corpus_line(path, ln, orders) for ln in lines) return sorted(found, key=lambda e: (e[0], e[1] or "")) #: The corpus a release figure counts out (AGENTS.md, Release-log #: claims): fictional CLDR renderings, not names anyone reported. CLDR_CORPUS = HERE / "corpus_cldr.jsonl" def only_in(path: Path, names: set[str]) -> set[str]: """The `names` that `path` holds and no other corpus beside it does.""" orders = {k: s.order for k, s in load_shapes().items()} def held(p: Path) -> set[str]: return {_corpus_line(p, ln, orders)[0] for ln in jsonl_lines(p.read_text(encoding="utf-8"))} others = set().union(*(held(p) for p in sorted(HERE.glob("corpus*.jsonl")) if p != path)) return (names & held(path)) - others def row(name: str, order: str | None, v2: dict[str, Any] | None, facade: dict[str, str] | None) -> dict[str, Any]: """One snapshot line from one name's readings. `v2` is the 2.0 surface as {role: value, "_ambiguities": [...], "_initials": ..., "_capitalized": ..., "_capitalized_force": ...}, the case views as case_views() gives them; `facade` is HumanName's as {v1 field: value, "_initials": ...}, or None where it is not read (an order-bearing entry, whose facade is never consulted). Empty values are left out, so a reading that gains a field shows as one added key. With no v2 surface (a 1.4 baseline) the facade's reading IS the row, under role names; otherwise the facade is recorded only where it differs.""" out: dict[str, Any] = {"name": name} if order: out["order"] = order if v2 is None: assert facade is not None out.update({f: v for f in V2_FIELDS if (v := facade[V1_FOR[f]])}) if facade["_initials"]: out["initials"] = facade["_initials"] return out out.update({f: v for f in V2_FIELDS if (v := v2[f])}) if v2["_ambiguities"]: out["ambiguities"] = list(v2["_ambiguities"]) if v2.get("_alternatives"): out["alternatives"] = list(v2["_alternatives"]) if v2["_initials"]: out["initials"] = v2["_initials"] for view in CASE_VIEWS: if v2[f"_{view}"]: out[view] = v2[f"_{view}"] if facade is not None: differs = {f: facade[V1_FOR[f]] for f in V2_FIELDS if facade[V1_FOR[f]] != v2[f]} if facade["_initials"] != v2["_initials"]: differs["initials"] = facade["_initials"] if differs: out["facade"] = differs return out #: The case-repair views a row records, in the order case_views() #: gives them. The facade's capitalize() is not read: it renders #: through the same repair, so it differs only where its fields do. CASE_VIEWS = ("capitalized", "capitalized_force") def case_views(rendered: str, plain: str, forced: str) -> dict[str, str]: """The case views as a row records them: each only where it moves something -- capitalized() against the name as parsed, and capitalized(force=True) against capitalized() -- so a name the repair leaves alone records nothing. A view is the whole repaired name, so in a name repair does change, a role move that changes the rendering moves the view too, with no word changing case.""" return {"_capitalized": plain if plain != rendered else "", "_capitalized_force": forced if forced != plain else ""} def alternatives(p: ParsedName) -> list[str]: """The reading each of a parse's reports did not take (#752 phase 2m, `Ambiguity.alternative`): its kind, the reading's kind, the field it names and its source, one string per report that carries one, in report order, so a report losing its alternative beside an identical one still moves the row (`DOE, JOHN MA JD` holds two); none from a tree before 2.7, which `--base` reads through this too. compare.py's worker holds a copy.""" return [f"{a.kind.name}: {alt.kind.value} as {alt.role.value} " f"({alt.source.value})" for a in p.ambiguities if (alt := getattr(a, "alternative", None)) is not None] #: 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")) #: 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 #: comparison asks the snapshot what it recorded rather than inferring #: it from whether a row happens to hold one: a 2.7 snapshot of names #: no report reaches holds no alternative and still records them. HEADER = "recorded" def recorded_keys() -> tuple[str, ...]: """The `RECORDED_LATER` keys the imported nameparser records: the case views, which every 2.x tree renders, and the alternatives where its Ambiguity has the field (2.7).""" import dataclasses import nameparser fields = {f.name for f in dataclasses.fields(nameparser.Ambiguity)} return CASE_VIEWS + (("alternatives",) if "alternative" in fields else ()) def recorded(text: str) -> frozenset[str] | None: """The keys a snapshot's header says it records, or None for a snapshot recorded before the header existed.""" first = text.split("\n", 1)[0] if not first.strip(): return None r = json.loads(first) return frozenset(r[HEADER]) if HEADER in r and "name" not in r else None def unrecorded_views(old_recorded: frozenset[str] | None, new_recorded: frozenset[str], old: dict[tuple[str, str], dict[str, Any]], new: dict[tuple[str, str], dict[str, Any]] ) -> tuple[str, ...]: """The `RECORDED_LATER` keys one side did not record, dropped from both sides' rows in place, so a snapshot recorded before the change that added them (v2.5.0's for the case views, v2.6.0's for the alternatives, or a local snapshot) does not report every name they reach as moved: 910 of the 961 entries `--since v2.5.0` reported on 2026-10-22 were the case views' keys appearing, not moves. `old_recorded` is the old snapshot's header (`recorded`). A snapshot with none predates the header, so it records no alternatives, and its case views are read off its rows: exact for those frozen files, v2.5.0's and earlier recording none, and v2.6.0's and every local snapshot since #826 recording each where it moves something, which some corpus name does.""" dropped: tuple[str, ...] = () for keys, _ in RECORDED_LATER: if old_recorded is not None: had = set(keys) <= old_recorded elif keys == CASE_VIEWS: had = any(v in r for r in old.values() for v in keys) else: had = False if had and set(keys) <= new_recorded: continue for rows in (old, new): for r in rows.values(): for v in keys: r.pop(v, None) dropped += keys return dropped def facade_view(r: dict[str, Any]) -> dict[str, Any]: """A row as the facade alone reads it, for comparing against a baseline that has no 2.0 surface.""" facade = r.get("facade", {}) out = {k: r[k] for k in ("name", "order") if k in r} for f in (*V2_FIELDS, "initials"): if v := facade.get(f, r.get(f, "")): out[f] = v return out #: Below this many items parallel() reads in-process even when given #: jobs: a worker costs an interpreter start and a nameparser import. PARALLEL_FLOOR = 5000 def read(entries: list[Entry], *, facade: bool = True, jobs: int = 1) -> list[dict[str, Any]]: """The tree's row for every entry, in the entries' order, read in `jobs` worker processes where there are enough (parallel). With `facade` false the v1 facade is not read and no row carries a `facade` key.""" return parallel(_read_chunk, entries, jobs, facade, module="snapshot") def parallel(chunk: Callable[..., tuple[str, list[T]]], items: Sequence[Any], jobs: int, *args: object, module: str) -> list[T]: """chunk(part, *args)'s results for every item, in the items' order. `chunk` is a module-level function returning the nameparser file its process imported beside its results. With `jobs` above 1 and at least PARALLEL_FLOOR items, contiguous chunks are handed to that many spawned worker processes, which inherit this process's sys.path (so a --tree checkout comes first there too), and a worker that imported nameparser from anywhere but where this process did aborts the run: the check_source trap in a place check_source cannot see. A worker unpickles `chunk` by the bare name of its `module`, which resolves there because HERE is on the inherited sys.path, as it does for a test that loaded the module by path under another name.""" if jobs <= 1 or len(items) < PARALLEL_FLOOR: return chunk(items, *args)[1] import nameparser here = Path(nameparser.__file__).resolve() by_name = getattr(importlib.import_module(module), chunk.__name__) size = -(-len(items) // (jobs * 4)) chunks = [items[i:i + size] for i in range(0, len(items), size)] results: list[T] = [] with ProcessPoolExecutor(jobs, mp_context=multiprocessing.get_context("spawn")) as pool: for at, part in pool.map(by_name, chunks, *([a] * len(chunks) for a in args)): if Path(at).resolve() != here: raise SystemExit( f"a worker imported nameparser from {at}, not {here}, " f"so its results would be another tree's readings. Aborted.") results.extend(part) return results def _read_chunk(entries: Sequence[Entry], facade: bool ) -> tuple[str, list[dict[str, Any]]]: """The nameparser this process imported, and its row for every entry; read()'s unit of work in a worker and out of one.""" import nameparser from nameparser import HumanName, Parser, Policy parsers: dict[str | None, Any] = {None: Parser()} rows = [] for name, order in entries: if order not in parsers: parsers[order] = Parser(policy=Policy(name_order=getattr(nameparser, order))) p = parsers[order].parse(name) v2 = {f: getattr(p, f) or "" for f in V2_FIELDS} v2["_ambiguities"] = sorted({a.kind.name for a in p.ambiguities}) v2["_alternatives"] = alternatives(p) v2["_initials"] = p.initials() or "" v2.update(case_views(str(p), str(parsers[order].capitalized(p)), str(parsers[order].capitalized(p, force=True)))) v1 = None if facade and order is None: with warnings.catch_warnings(): # the facade is read on purpose; 2.8's removal warning # (by message, so any other warning still surfaces) warnings.filterwarnings( "ignore", message="HumanName is deprecated", category=DeprecationWarning) hn = HumanName(name) v1 = {k: v or "" for k, v in hn.as_dict().items() if k in V1_FIELDS} v1["_initials"] = hn.initials() or "" rows.append(row(name, order, v2, v1)) return nameparser.__file__, rows def key(r: dict[str, Any]) -> tuple[str, str]: return r["name"], r.get("order", "") def dump(rows: list[dict[str, Any]], keys: tuple[str, ...]) -> str: """The snapshot of `rows`, headed by the `RECORDED_LATER` keys the tree that read them records (`recorded_keys`).""" return jsonl_line({HEADER: list(keys)}) + "\n" + "".join( jsonl_line(r) + "\n" for r in rows) def load(text: str) -> dict[tuple[str, str], dict[str, Any]]: return {key(r): r for r in map(json.loads, jsonl_lines(text)) if r and "name" in r} def row_count(text: str) -> int: """How many rows a snapshot's text holds, its header not one.""" return text.count("\n") - (recorded(text) is not None) def _flat(r: dict[str, Any]) -> dict[str, str]: out = {k: ("|".join(v) if isinstance(v, list) else v) for k, v in r.items() if k not in ("name", "order", "facade")} out.update({f"facade.{k}": v for k, v in r.get("facade", {}).items()}) return out def grouped(old: dict[tuple[str, str], dict[str, Any]], new: dict[tuple[str, str], dict[str, Any]]) -> list[str]: """The moves, grouped by which fields changed, so a large move reads as a few kinds of change rather than a wall of names.""" groups: dict[tuple[str, ...], list[str]] = defaultdict(list) for k in sorted(set(old) | set(new)): # A line break inside a name would put the rest of it flush left, # where measure.py's summary keeps lines; escaped, it stays here. label = (k[0].replace("\n", "\\n").replace("\r", "\\r") + (f" [{k[1]}]" if k[1] else "")) if k not in new: groups[("(removed from corpus)",)].append(label) continue if k not in old: groups[("(new to corpus)",)].append(label) continue a, b = _flat(old[k]), _flat(new[k]) fields = tuple(sorted(f for f in set(a) | set(b) if a.get(f) != b.get(f))) if fields: groups[fields].append(f"{label}\n " + "; ".join( f"{f}: {a.get(f, '')!r} -> {b.get(f, '')!r}" for f in fields)) out = [] for fields, names in sorted(groups.items(), key=lambda g: -len(g[1])): out.append(f"{', '.join(fields)}: {len(names)}") out.extend(f" {n}" for n in names) return out def check_source(module_file: str, root: Path = REPO_ROOT) -> Path: """Abort unless `module_file` is inside `root`'s source package. Run as a script, sys.path[0] is tools/differential/, which holds no nameparser, so a PYTHONPATH entry outranks the editable install and the tree side reads a released wheel while believing it read the checkout -- and a comparison of two wheels, or of a wheel with the snapshot it wrote, reports no moves. The test is "is this the SOURCE PACKAGE", not "is this under the root": the root also holds .venv/, build/lib/ and dist/, any of which can hold a wheel, and an ancestor of the checkout passes the weaker test while the import falls through to the checkout itself.""" at = Path(module_file).resolve() source = (root / "nameparser").resolve() if not at.is_relative_to(source): raise SystemExit( f"imported nameparser from {at}, not from the source package " f"{source} -- so this run would read that module instead of " f"the tree it names. Unset PYTHONPATH; uninstall a " f"non-editable nameparser from the active environment; or, in " f"a git worktree, check that the editable install points at " f"THIS worktree rather than the main checkout. Aborted.") return at def _local(source: str, sample: int | None ) -> tuple[Path, list[Path], dict[str, Any]]: """A local corpus's snapshot path (a sample's own, for --sample), its corpus file (checked by local_corpora.load against its sha256), and what names this fetch of it and the sample, with its attribution printed: a report on it carries its source and date.""" import local_corpora corpus = local_corpora.require(source) print(corpus.header()) fetch: dict[str, Any] = {"data_date": corpus.meta["data_date"], "corpus_sha256": corpus.meta["corpus_sha256"]} snapshot = local_corpora.directory(source) / local_corpora.SNAPSHOT if sample is not None: fetch["sample"] = sample snapshot = snapshot.with_name(f"{snapshot.stem}.sample-{sample}{snapshot.suffix}") return snapshot, [local_corpora.corpus_path(source)], fetch def sampled(entries: list[Entry], every: int) -> list[Entry]: """The entries a 1-in-`every` sample keeps, chosen by a hash of the name rather than at random: a sample recorded from one tree and checked from another holds the same names, and a name read under two orders stays whole.""" return [e for e in entries if int.from_bytes(hashlib.blake2b( e[0].encode("utf-8", "surrogatepass"), digest_size=8).digest(), "big") % every == 0] #: How many recorded baselines a local corpus keeps, whole or sampled #: alike, the least recently recorded or reused dropped first. Sized for #: four sessions measuring at once, each keeping a base and a head with #: room for one more: at 4 (before --base) parallel sessions evicted #: each other's bases (2026-10-25). The largest is FEC's, 249 MB plain #: and 41.7 MB gzipped (2026-10-25), so ten cost about 420 MB for it and #: about 1.2 GB across the eight local corpora (122 MB a reading). BASELINES_KEPT = 10 #: A partial baseline older than this is a crashed write's, not a #: running one's, and is deleted when a baseline is kept. _PARTIAL_STALE_SECONDS = 86400 def _source_digest(root: Path) -> str: """A hash of every file under `root`'s nameparser/ as it is on disk, __pycache__ aside: what the import reads, so an uncommitted edit, a gitignored file, a sourceless module or a copy outside any checkout keys as itself. git's view of the tree was the first key and could name the wrong one: an ignored copy inside a checkout (build/lib) took the checkout's tree, and status shows neither ignored nor assume-unchanged files (#859's review).""" h = hashlib.sha256() package = root / "nameparser" files = [] # os.walk follows a symlinked subpackage, as the import does; # Path.rglob does not descend into one. for at, dirs, names in os.walk(package, followlinks=True): dirs[:] = [d for d in dirs if d != "__pycache__"] files.extend(Path(at) / n for n in names) for path in sorted(files): if path.is_file(): h.update(path.relative_to(package).as_posix().encode() + b"\0") h.update(path.read_bytes() + b"\0") return h.hexdigest() def _baseline(snapshot: Path, fetch: dict[str, Any], digest: str) -> Path: """Where a recording of `fetch` from a package of `digest` is kept. The key holds what a reading depends on besides the names: the package's files, the interpreter's minor version (unicodedata moves between Python versions), and the code that decides what a row records and how its line is written.""" tool = hashlib.sha256(b"".join( (HERE / f).read_bytes() for f in ("snapshot.py", "corpus_build.py", "shapes.py"))).hexdigest()[:12] sample = f"-sample-{fetch['sample']}" if "sample" in fetch else "" py = f"py{sys.version_info[0]}{sys.version_info[1]}" return (snapshot.parent / "baselines" / f"{fetch['corpus_sha256'][:12]}-{digest[:16]}-{py}-{tool}{sample}.jsonl.gz") def _reused(baseline: Path, entries: int) -> str | None: """A kept baseline's text, or None where there is none to reuse. A damaged one -- unreadable, or holding another number of rows than the corpus has entries, as a write cut short would -- is deleted with a note, and the names are read instead.""" try: with gzip.open(baseline, "rt", encoding="utf-8") as f: text = f.read() except FileNotFoundError: # none kept, or another run pruned it return None except (OSError, EOFError, zlib.error, UnicodeDecodeError) as e: why = f"unreadable ({e})" else: if (rows := row_count(text)) == entries: return text why = f"{rows} rows for {entries} entries" print(f"deleted the kept baseline {baseline.name}: {why}; reading the names\n") baseline.unlink(missing_ok=True) # another run may have found it too return None def _keep_baseline(path: Path, text: str) -> None: """Keep `text` at `path`, written whole and synced before it takes the name, then drop the least recently used baselines and stale partial writes. A failure is reported, not raised: the snapshot it would have saved recording again is already written.""" try: path.parent.mkdir(exist_ok=True) partial = path.with_name(f"{path.name}.{os.getpid()}.partial") with open(partial, "wb") as raw: # No file name or time in the header: the partial's name is # not the baseline's, and identical readings give equal bytes. with gzip.GzipFile(filename="", fileobj=raw, mode="wb", compresslevel=1, mtime=0) as f: f.write(text.encode("utf-8")) raw.flush() os.fsync(raw.fileno()) partial.replace(path) except OSError as e: print(f"the snapshot is recorded, but its baseline was not kept: {e}") return _prune(path.parent) def _mtime(path: Path) -> float | None: try: return path.stat().st_mtime except FileNotFoundError: # another run pruned it meanwhile return None def _prune(folder: Path, pattern: str = "*.jsonl.gz") -> None: """Drop all but the BASELINES_KEPT most recently used `pattern` files, and partial writes old enough to be a crashed run's, a base's scratch folder (scratch()) among them. A file another run removed first is skipped, not reported.""" stale = time.time() - _PARTIAL_STALE_SECONDS kept = sorted((m, p) for p in folder.glob(pattern) if (m := _mtime(p)) is not None) old = [p for _, p in kept[:-BASELINES_KEPT]] old += [p for glob in ("*.partial", "*.part") for p in folder.glob(glob) if (m := _mtime(p)) is not None and m < stale] for p in old: if p.is_dir(): shutil.rmtree(p, ignore_errors=True) else: p.unlink(missing_ok=True) def scratch(folder: Path) -> Path: """A folder of this run's own for a child's recording, inside the kept records' `folder`: unique, so two sessions recording the same base at once never share a file, and named as a partial write, so _prune sweeps one a killed run left behind.""" folder.mkdir(parents=True, exist_ok=True) return Path(tempfile.mkdtemp(prefix=".base-", suffix=".partial", dir=folder)) def _fetch_record(snapshot: Path) -> Path: """Beside a local snapshot: the fetch of the corpus it was recorded from. A corpus fetched again holds other names, and compared with a snapshot of the old fetch every name the registry gained or lost would read as a move of the parser's.""" return snapshot.with_name(snapshot.name + ".source.json") def _check_fetch(snapshot: Path, fetch: dict[str, Any], source: str) -> None: """Refuse a local snapshot recorded from another fetch of its corpus, or with no readable record of its fetch, before reading anything.""" try: recorded = json.loads(_fetch_record(snapshot).read_text(encoding="utf-8")) except (OSError, ValueError): recorded = {} if not isinstance(recorded, dict): recorded = {} if recorded.get("corpus_sha256") != fetch["corpus_sha256"]: raise SystemExit( f"{snapshot} was recorded from another fetch of {source} (data " f"as of {recorded.get('data_date', 'unknown')}; the corpus now " f"is {fetch['data_date']}), so it would report the names the " f"source gained and lost as moves. Record it again from the " f"base checkout: snapshot.py --local {source} --record " f"--tree <base checkout>; or, where another session may be " f"measuring, check against a base you name instead: " f"snapshot.py --local {source} --base <base checkout>") if recorded.get("sample") != fetch.get("sample"): raise SystemExit( f"{snapshot} was recorded from " f"{_describe_sample(recorded.get('sample'))} of {source}, and " f"this run reads {_describe_sample(fetch.get('sample'))}: every " f"name in one and not the other would read as a move") def _describe_sample(sample: object) -> str: return "the whole corpus" if sample is None else f"a 1-in-{sample} sample" def base_root(path: str, head: Path) -> Path: """--base's checkout, resolved: refused where it holds no nameparser package, whose digest would key an empty tree, or where it is the tree being read, which would compare a tree with itself and report no moves whatever the change.""" root = Path(path).resolve() if not (root / "nameparser" / "__init__.py").is_file(): raise SystemExit(f"--base {path}: no nameparser package at " f"{root / 'nameparser'}; name a checkout") if root == head: raise SystemExit(f"--base {path} is the tree being read ({head}): a " f"tree compared with itself reports no moves. Name " f"the base checkout, or pass --tree for the other side") return root def head_line(root: Path, base: Path) -> str: """Which tree was read against the base, by its package's digest, taken after the read so that it covers what the import saw. Two equal digests are said outright: a check that cannot move is a clean run's look-alike.""" head = _source_digest(root) if head == _source_digest(base): return (f"head: {root} holds a nameparser/ byte-identical to the " f"base's ({head[:16]}), so no reading can move\n") return f"head: {root}, nameparser/ {head[:16]}\n" def child_env() -> dict[str, str]: """A child's environment with the import-path overrides stripped, as compare.py's worker has it: a PYTHONPATH entry would outrank the --tree the child is handed (check_source then aborts it).""" return {k: v for k, v in os.environ.items() if k not in ("PYTHONPATH", "PYTHONHOME")} def _record_base(root: Path, source: str, sample: int | None, jobs: int, out: Path) -> None: """`root`'s readings of `source` recorded to `out` in a child process (one process imports one nameparser), which keeps them as a baseline and refuses a package other than `root`'s, as --record --tree does.""" command = [sys.executable, str(HERE / "snapshot.py"), "--local", source, "--record", "--tree", str(root), "--out", str(out), "--jobs", str(jobs)] if sample is not None: command += ["--sample", str(sample)] done = subprocess.run(command, env=child_env(), capture_output=True, text=True) if done.returncode: raise SystemExit(f"recording the base {root} failed:\n{done.stdout}{done.stderr}") def _base_text(root: Path, snapshot: Path, fetch: dict[str, Any], source: str, sample: int | None, entries: int, jobs: int) -> str: """The base checkout's readings, from the baseline kept for its package's digest, or recorded into one now, and a line saying which base was read. Never the shared snapshot: that is whoever recorded last, and a sibling session's record would pass for this base.""" digest = _source_digest(root) baseline = _baseline(snapshot, fetch, digest) if (text := _reused(baseline, entries)) is not None: try: os.utime(baseline) except FileNotFoundError: # another run pruned it meanwhile pass how = f"reused the kept baseline {baseline.name}" else: at = scratch(baseline.parent) try: _record_base(root, source, sample, jobs, at / "base.jsonl") text = (at / "base.jsonl").read_text(encoding="utf-8") finally: shutil.rmtree(at, ignore_errors=True) if _source_digest(root) != digest: raise SystemExit(f"{root / 'nameparser'} changed while it was read, " f"so its readings may be half old and half new; " f"run again") how = "recorded it now" print(f"base: {root}, nameparser/ {digest[:16]}; {how}\n") return text def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("--record", action="store_true", help="rewrite the snapshot") ap.add_argument("--since", metavar="REF", help="compare against the snapshot committed at a git ref") ap.add_argument("--tree", metavar="PATH", help="read with another checkout") ap.add_argument("--base", metavar="PATH", help="with --local: compare against the readings of this " "checkout, kept by its package's digest, instead of " "the shared snapshot") ap.add_argument("--out", type=Path, help="where --record writes (default: snapshot.jsonl, " "or the local corpus's own snapshot)") ap.add_argument("--local", metavar="SOURCE", help="read a local corpus from the cache instead of the " "committed corpora (local_corpora.py)") ap.add_argument("--sample", type=int, metavar="N", help="with --local: read a fixed 1-in-N sample of the " "corpus, against a snapshot of the same sample") ap.add_argument("--jobs", type=int, metavar="N", default=os.cpu_count() or 1, help="parse in N worker processes (default: every CPU)") args = ap.parse_args(argv) if args.local and args.since: ap.error("--since reads a committed snapshot; a local corpus has none") if args.sample is not None and not args.local: ap.error("--sample reads a local corpus; the committed snapshot is whole") if args.sample is not None and args.sample < 2: ap.error("--sample N keeps 1 name in N, so N must be 2 or more") if args.base and not args.local: ap.error("--base reads a local corpus; for the committed corpora, " "--since compares against a ref") if args.base and (args.record or args.out): ap.error("--base compares and records nothing but its kept baseline; " "drop --record and --out") snapshot, corpora, fetch = SNAPSHOT, None, None if args.local: snapshot, corpora, fetch = _local(args.local, args.sample) out = args.out or snapshot root = Path(args.tree).resolve() if args.tree else REPO_ROOT base = base_root(args.base, root) if args.base else None if not (args.record or args.since or base or snapshot.exists()): raise SystemExit(f"no snapshot at {snapshot}: record one first, " f"with --record (and --tree for another checkout)") if fetch is not None and not (args.record or base): _check_fetch(snapshot, fetch, args.local) entries = corpus_entries(corpora) if args.sample is not None: whole, entries = len(entries), sampled(entries, args.sample) print(f"a 1-in-{args.sample} sample: {len(entries)} of {whole} entries\n") baseline = digest = None if fetch is not None and args.record: digest = _source_digest(root) baseline = _baseline(snapshot, fetch, digest) if (text := _reused(baseline, len(entries))) is not None: out.write_text(text, encoding="utf-8") _fetch_record(out).write_text(json.dumps(fetch), encoding="utf-8") try: os.utime(baseline) except FileNotFoundError: # another run pruned it meanwhile pass print(f"recorded {len(entries)} readings to {out}, copied from " f"{baseline.name}: this nameparser/ was read from this " f"fetch before") return 0 if args.tree: sys.path.insert(0, str(root)) based = None if base is not None: assert fetch is not None based = _base_text(base, snapshot, fetch, args.local, args.sample, len(entries), args.jobs) rows = read(entries, facade=not args.local, jobs=args.jobs) import nameparser check_source(nameparser.__file__, root) if base is not None: print(head_line(root, base)) if args.record: text = dump(rows, recorded_keys()) out.write_text(text, encoding="utf-8") if fetch is not None: _fetch_record(out).write_text(json.dumps(fetch), encoding="utf-8") # Keyed before the read: a package that changed during it # may have been read half old and half new, so it keeps none. if baseline is not None and _source_digest(root) == digest: _keep_baseline(baseline, text) elif baseline is not None: print(f"{root / 'nameparser'} changed while it was read, so " f"no baseline was kept; record again") print(f"recorded {len(rows)} readings to {out}") return 0 if args.since: rel = SNAPSHOT.relative_to(Path(subprocess.check_output( ["git", "rev-parse", "--show-toplevel"], cwd=HERE, text=True).strip())) try: text = subprocess.check_output( ["git", "show", f"{args.since}:{rel}"], cwd=HERE, text=True) except subprocess.CalledProcessError: raise SystemExit( f"no {rel} at {args.since}: an unknown ref, or one from " f"before the snapshot existed. For a release tagged " f"before it, use `compare.py --baseline <version>`") from None elif based is not None: text = based else: text = snapshot.read_text(encoding="utf-8") old, new = load(text), {key(r): r for r in rows} if args.local and any("facade" in r for r in old.values()): for r in old.values(): r.pop("facade", None) print("not compared: facade -- the snapshot read against was " "recorded when a local reading still read the facade\n") if dropped := unrecorded_views(recorded(text), frozenset(recorded_keys()), old, new): print(f"not compared: {', '.join(dropped)} -- one side of the " f"comparison predates " f"{' and '.join(c for k, c in RECORDED_LATER if k[0] in dropped)}" # a release records the case views from 2.6.0, so compare.py # can read them for an older tag; none records the # alternatives until 2.7 ships + ("; compare.py --baseline compares the case views" if CASE_VIEWS[0] in dropped else "") + "\n") moves = grouped(old, new) print("\n".join(moves) or ("matches the base" if base else "matches the snapshot")) if not moves: return 0 moved = {k for k in set(old) | set(new) if old.get(k) != new.get(k)} if base is not None: print(f"\n{len(moved)} entries differ from the base {base}") return 1 if args.local: print(f"\n{len(moved)} entries differ from the snapshot recorded " f"from this fetch, which whoever recorded last chose. To make " f"another tree the baseline: snapshot.py --local {args.local} " f"--record --tree <checkout>; to check against a base you " f"name instead: snapshot.py --local {args.local} --base <checkout>") return 1 cldr = only_in(CLDR_CORPUS, {name for name, _ in moved}) print(f"\n{len(moved)} entries differ, {len(cldr)} of their names " f"held only by {CLDR_CORPUS.name}") if not args.since: print(f"\nif these moves are intended: {RECORD_COMMAND}") return 1 if __name__ == "__main__": sys.exit(main()) ���������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/wikidata_LICENSE�������������������������������0000664�0000000�0000000�00000001430�15273032717�0025363�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������corpus_wikidata.jsonl and wikidata_fields.jsonl are derived from Wikidata (https://www.wikidata.org), read through the Wikidata Query Service and the wbgetentities API, and carry Wikidata's license. Wikidata states that "All structured data (i.e. the main, Property, Lexeme, and EntitySchema namespaces) is released into the public domain". https://www.wikidata.org/wiki/Wikidata:Licensing CC0 1.0 Universal: https://creativecommons.org/publicdomain/zero/1.0/ No attribution is required; this file credits the source anyway. Source: Wikidata, Wikimedia Foundation. Each line of wikidata_fields.jsonl names its item's QID, resolvable at https://www.wikidata.org/wiki/<QID>. Only public figures are sampled (deceased, or with at least 10 sitelinks), and only name strings and QIDs are kept. ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/wikidata_agreement.json������������������������0000664�0000000�0000000�00000253350�15273032717�0027232�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������{ "Q100755460/surname2-es/givenFirst": { "family": { "expected": "Viciana Aráez", "got": "Aráez" }, "given_names": { "expected": "Rafael", "got": "Rafael Viciana" }, "text": "Rafael Viciana Aráez" }, "Q101688196/surname2-co/givenFirst": { "family": { "expected": "Gutiérrez Arango", "got": "Arango" }, "given_names": { "expected": "Pompilio", "got": "Pompilio Gutiérrez" }, "text": "Pompilio Gutiérrez Arango" }, "Q104155694/surname2-es/givenFirst": { "family": { "expected": "García Rodrigo", "got": "Rodrigo" }, "given_names": { "expected": "Román", "got": "Román García" }, "text": "Román García Rodrigo" }, "Q1042940/surname2-co/givenFirst": { "family": { "expected": "Gaviria Díaz", "got": "Díaz" }, "given_names": { "expected": "Carlos", "got": "Carlos Gaviria" }, "text": "Carlos Gaviria Díaz" }, "Q1042947/surname2-mx/givenFirst": { "family": { "expected": "Girón Gutiérrez", "got": "Gutiérrez" }, "given_names": { "expected": "Carlos", "got": "Carlos Girón" }, "text": "Carlos Girón Gutiérrez" }, "Q105208032/surname2-co/givenFirst": { "family": { "expected": "Gómez Esguerra", "got": "Esguerra" }, "given_names": { "expected": "Julio Roberto", "got": "Julio Roberto Gómez" }, "text": "Julio Roberto Gómez Esguerra" }, "Q106561769/surname2-co/givenFirst": { "family": { "expected": "Camacho Carrizosa", "got": "Carrizosa" }, "given_names": { "expected": "José", "got": "José Camacho" }, "text": "José Camacho Carrizosa" }, "Q106769309/surname2-co/givenFirst": { "family": { "expected": "López Gómez", "got": "Gómez" }, "given_names": { "expected": "Libardo", "got": "Libardo López" }, "text": "Libardo López Gómez" }, "Q107542992/surname2-mx/givenFirst": { "family": { "expected": "Covarrubias Garza", "got": "Garza" }, "given_names": { "expected": "José Luis", "got": "José Luis Covarrubias" }, "text": "José Luis Covarrubias Garza" }, "Q108111287/surname2-co/givenFirst": { "family": { "expected": "Santamaría Montoya", "got": "Montoya" }, "given_names": { "expected": "Gabriel Jaime", "got": "Gabriel Jaime Santamaría" }, "text": "Gabriel Jaime Santamaría Montoya" }, "Q109044495/surname2-co/givenFirst": { "family": { "expected": "González Arias", "got": "Arias" }, "given_names": { "expected": "Jesús Ángel", "got": "Jesús Ángel González" }, "text": "Jesús Ángel González Arias" }, "Q109738043/surname2-mx/givenFirst": { "family": { "expected": "López Díaz", "got": "Díaz" }, "given_names": { "expected": "Javier", "got": "Javier López" }, "text": "Javier López Díaz" }, "Q109853607/surname2-co/givenFirst": { "family": { "expected": "Sáenz Echeverría", "got": "Echeverría" }, "given_names": { "expected": "Carlos", "got": "Carlos Sáenz" }, "text": "Carlos Sáenz Echeverría" }, "Q110144093/surname2-co/givenFirst": { "family": { "expected": "Murillo Chapull", "got": "Chapull" }, "given_names": { "expected": "Emilio", "got": "Emilio Murillo" }, "text": "Emilio Murillo Chapull" }, "Q110463644/surname2-co/givenFirst": { "family": { "expected": "Uribe Cordovez", "got": "Cordovez" }, "given_names": { "expected": "Carlos", "got": "Carlos Uribe" }, "text": "Carlos Uribe Cordovez" }, "Q111234704/surname2-es/givenFirst": { "family": { "expected": "Díaz-Corralejo Fernández", "got": "Fernández" }, "given_names": { "expected": "Teógenes", "got": "Teógenes Díaz-Corralejo" }, "text": "Teógenes Díaz-Corralejo Fernández" }, "Q111473046/surname2-es/givenFirst": { "family": { "expected": "Alonso Romero", "got": "Romero" }, "given_names": { "expected": "María Paz", "got": "María Paz Alonso" }, "text": "María Paz Alonso Romero" }, "Q111523698/surname2-mx/givenFirst": { "family": { "expected": "Arvizu Lara", "got": "Lara" }, "given_names": { "expected": "Orlando", "got": "Orlando Arvizu" }, "text": "Orlando Arvizu Lara" }, "Q111603657/surname2-mx/givenFirst": { "family": { "expected": "Terrazas Allen", "got": "Allen" }, "given_names": { "expected": "Patricia", "got": "Patricia Terrazas" }, "text": "Patricia Terrazas Allen" }, "Q112116024/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Ólafsson" }, "given_names": { "expected": "Sigurður Ólafsson", "got": "Sigurður" }, "text": "Sigurður Ólafsson" }, "Q112116260/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Böðvarsson" }, "given_names": { "expected": "Bjarni Böðvarsson", "got": "Bjarni" }, "text": "Bjarni Böðvarsson" }, "Q112116353/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Karlsdóttir" }, "given_names": { "expected": "Soffía Karlsdóttir", "got": "Soffía" }, "text": "Soffía Karlsdóttir" }, "Q112128748/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Jensson" }, "given_names": { "expected": "Ketill Jensson", "got": "Ketill" }, "text": "Ketill Jensson" }, "Q112130218/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Jónsson" }, "given_names": { "expected": "Magnús Jónsson", "got": "Magnús" }, "text": "Magnús Jónsson" }, "Q112130399/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Ólafsson" }, "given_names": { "expected": "Skapti Ólafsson", "got": "Skapti" }, "text": "Skapti Ólafsson" }, "Q112131085/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Sigurðsson" }, "given_names": { "expected": "Jón Sigurðsson", "got": "Jón" }, "text": "Jón Sigurðsson" }, "Q112131257/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Benediktsdóttir" }, "given_names": { "expected": "Gerður Benediktsdóttir", "got": "Gerður" }, "text": "Gerður Benediktsdóttir" }, "Q112135101/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Kristjánsson" }, "given_names": { "expected": "Kristján Kristjánsson", "got": "Kristján" }, "text": "Kristján Kristjánsson" }, "Q112135757/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Sigurdórsson" }, "given_names": { "expected": "Sigurdór Sigurdórsson", "got": "Sigurdór" }, "text": "Sigurdór Sigurdórsson" }, "Q112184094/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Gunnarsson" }, "given_names": { "expected": "Rúnar Gunnarsson", "got": "Rúnar" }, "text": "Rúnar Gunnarsson" }, "Q112184468/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Björnsson" }, "given_names": { "expected": "Grettir Björnsson", "got": "Grettir" }, "text": "Grettir Björnsson" }, "Q112208455/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Konráðsson" }, "given_names": { "expected": "Jóhann Konráðsson", "got": "Jóhann" }, "text": "Jóhann Konráðsson" }, "Q112340787/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Einarsson" }, "given_names": { "expected": "Karl Einarsson", "got": "Karl" }, "text": "Karl Einarsson" }, "Q112455664/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Jónsson" }, "given_names": { "expected": "Ólafur Þ. Jónsson", "got": "Ólafur Þ." }, "text": "Ólafur Þ. Jónsson" }, "Q112579257/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Gunnarsson" }, "given_names": { "expected": "Eiður Gunnarsson", "got": "Eiður" }, "text": "Eiður Gunnarsson" }, "Q112579515/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Þorláksson" }, "given_names": { "expected": "Guðmundur Óli Þorláksson", "got": "Guðmundur Óli" }, "text": "Guðmundur Óli Þorláksson" }, "Q112723929/surname2-es/givenFirst": { "family": { "expected": "Hernández Pla", "got": "Hernández" }, "suffix": { "expected": "", "got": "Pla" }, "text": "Francisco Hernández Pla" }, "Q113001057/surname2-co/givenFirst": { "family": { "expected": "Simancas Torres", "got": "Torres" }, "given_names": { "expected": "Libardo", "got": "Libardo Simancas" }, "text": "Libardo Simancas Torres" }, "Q113129508/surname2-mx/givenFirst": { "family": { "expected": "Rosas Solis", "got": "Solis" }, "given_names": { "expected": "Fernando", "got": "Fernando Rosas" }, "text": "Fernando Rosas Solis" }, "Q113514978/surname2-mx/givenFirst": { "family": { "expected": "Pérez Ximeno", "got": "Ximeno" }, "given_names": { "expected": "Fabián", "got": "Fabián Pérez" }, "text": "Fabián Pérez Ximeno" }, "Q113548032/surname2-es/givenFirst": { "family": { "expected": "Díaz Moreno", "got": "Moreno" }, "given_names": { "expected": "Luis", "got": "Luis Díaz" }, "text": "Luis Díaz Moreno" }, "Q113557761/surname2-es/givenFirst": { "family": { "expected": "Novo Verdes", "got": "Verdes" }, "given_names": { "expected": "Justo", "got": "Justo Novo" }, "text": "Justo Novo Verdes" }, "Q114799707/surname2-es/givenFirst": { "family": { "expected": "Gil Pérez", "got": "Pérez" }, "given_names": { "expected": "José", "got": "José Gil" }, "text": "José Gil Pérez" }, "Q114973166/surname2-es/givenFirst": { "family": { "expected": "Bodenlle Corral", "got": "Corral" }, "given_names": { "expected": "Ricardo", "got": "Ricardo Bodenlle" }, "text": "Ricardo Bodenlle Corral" }, "Q115641830/surname2-co/givenFirst": { "family": { "expected": "Leyva Mazuera", "got": "Mazuera" }, "given_names": { "expected": "Lisandro", "got": "Lisandro Leyva" }, "text": "Lisandro Leyva Mazuera" }, "Q116059/surname2-co/givenFirst": { "family": { "expected": "Lara Bonilla", "got": "Bonilla" }, "given_names": { "expected": "Rodrigo", "got": "Rodrigo Lara" }, "text": "Rodrigo Lara Bonilla" }, "Q11686420/surname2-co/givenFirst": { "family": { "expected": "Cárdenas Arroyo", "got": "Arroyo" }, "given_names": { "expected": "Juan", "got": "Juan Cárdenas" }, "text": "Juan Cárdenas Arroyo" }, "Q11699292/surname2-es/givenFirst": { "family": { "expected": "Oriola Garriga", "got": "Garriga" }, "given_names": { "expected": "Pierre David", "got": "Pierre David Oriola" }, "text": "Pierre David Oriola Garriga" }, "Q117125906/surname2-es/givenFirst": { "family": { "expected": "Lorenzo Oroza", "got": "Oroza" }, "given_names": { "expected": "Francisco Javier", "got": "Francisco Javier Lorenzo" }, "text": "Francisco Javier Lorenzo Oroza" }, "Q117327794/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Sveinsson" }, "given_names": { "expected": "Þorsteinn Sveinsson", "got": "Þorsteinn" }, "text": "Þorsteinn Sveinsson" }, "Q117459965/surname2-mx/givenFirst": { "family": { "expected": "Rangel de la Fuente", "got": "de la Fuente" }, "given_names": { "expected": "Elvia", "got": "Elvia Rangel" }, "text": "Elvia Rangel de la Fuente" }, "Q11905781/surname2-es/givenFirst": { "family": { "expected": "Alemany Dezcallar", "got": "Dezcallar" }, "given_names": { "expected": "Antonio", "got": "Antonio Alemany" }, "text": "Antonio Alemany Dezcallar" }, "Q11946783/surname2-es/givenFirst": { "family": { "expected": "Ortiga Torres", "got": "Torres" }, "given_names": { "expected": "Salvador", "got": "Salvador Ortiga" }, "text": "Salvador Ortiga Torres" }, "Q11947672/surname2-es/givenFirst": { "family": { "expected": "Díaz Moyano", "got": "Moyano" }, "given_names": { "expected": "Santiago", "got": "Santiago Díaz" }, "text": "Santiago Díaz Moyano" }, "Q11949860/surname2-es/givenFirst": { "family": { "expected": "Gutiérrez Mas", "got": "Mas" }, "given_names": { "expected": "Sinibaldo", "got": "Sinibaldo Gutiérrez" }, "text": "Sinibaldo Gutiérrez Mas" }, "Q11957/surname2-co/givenFirst": { "family": { "expected": "Santos Montejo", "got": "Montejo" }, "given_names": { "expected": "Enrique", "got": "Enrique Santos" }, "text": "Enrique Santos Montejo" }, "Q119792168/surname2-es/givenFirst": { "family": { "expected": "Arroyo Carreras", "got": "Carreras" }, "given_names": { "expected": "José Luis", "got": "José Luis Arroyo" }, "text": "José Luis Arroyo Carreras" }, "Q119898412/surname2-mx/givenFirst": { "family": { "expected": "Guzmán Reveles", "got": "Reveles" }, "given_names": { "expected": "Luis", "got": "Luis Guzmán" }, "text": "Luis Guzmán Reveles" }, "Q120212131/surname2-mx/givenFirst": { "family": { "expected": "Valdemar Lima", "got": "Lima" }, "given_names": { "expected": "Hilda Luisa", "got": "Hilda Luisa Valdemar" }, "text": "Hilda Luisa Valdemar Lima" }, "Q122156972/surname2-mx/givenFirst": { "family": { "expected": "Dorado Baltazar", "got": "Baltazar" }, "given_names": { "expected": "Emilia", "got": "Emilia Dorado" }, "text": "Emilia Dorado Baltazar" }, "Q123013116/surname2-co/givenFirst": { "family": { "expected": "Giraldo Builes", "got": "Builes" }, "given_names": { "expected": "Luis Fernando", "got": "Luis Fernando Giraldo" }, "text": "Luis Fernando Giraldo Builes" }, "Q123410560/surname2-co/givenFirst": { "family": { "expected": "Serrano Blanco", "got": "Blanco" }, "given_names": { "expected": "Manuel", "got": "Manuel Serrano" }, "text": "Manuel Serrano Blanco" }, "Q123410567/surname2-co/givenFirst": { "family": { "expected": "López Gómez", "got": "Gómez" }, "given_names": { "expected": "Adel", "got": "Adel López" }, "text": "Adel López Gómez" }, "Q123518919/surname2-mx/givenFirst": { "family": { "expected": "Valdez Muñoz", "got": "Muñoz" }, "given_names": { "expected": "Jorge", "got": "Jorge Valdez" }, "text": "Jorge Valdez Muñoz" }, "Q123681393/surname2-mx/givenFirst": { "family": { "expected": "Quintana Suárez", "got": "Suárez" }, "given_names": { "expected": "Héctor", "got": "Héctor Quintana" }, "text": "Héctor Quintana Suárez" }, "Q123792443/surname2-co/givenFirst": { "family": { "expected": "Becerra Vaca", "got": "Vaca" }, "given_names": { "expected": "Vicente", "got": "Vicente Becerra" }, "text": "Vicente Becerra Vaca" }, "Q123819744/surname2-es/givenFirst": { "family": { "expected": "Losada Estévez", "got": "Estévez" }, "given_names": { "expected": "María", "got": "María Losada" }, "text": "María Losada Estévez" }, "Q124228701/surname2-co/givenFirst": { "family": { "expected": "Giraldo Hernández", "got": "Hernández" }, "given_names": { "expected": "Francisco", "got": "Francisco Giraldo" }, "text": "Francisco Giraldo Hernández" }, "Q124294405/surname2-co/givenFirst": { "family": { "expected": "Londoño Aristizábal", "got": "Aristizábal" }, "given_names": { "expected": "Álvaro", "got": "Álvaro Londoño" }, "text": "Álvaro Londoño Aristizábal" }, "Q124364476/surname2-co/givenFirst": { "family": { "expected": "Latorre Jaramillo", "got": "Jaramillo" }, "given_names": { "expected": "Gabriel", "got": "Gabriel Latorre" }, "text": "Gabriel Latorre Jaramillo" }, "Q125025465/surname2-co/givenFirst": { "family": { "expected": "Garcés Córdoba", "got": "Córdoba" }, "given_names": { "expected": "Carlos", "got": "Carlos Garcés" }, "text": "Carlos Garcés Córdoba" }, "Q126949794/surname2-es/givenFirst": { "family": { "expected": "Sánchez Pesquera", "got": "Pesquera" }, "given_names": { "expected": "Miguel", "got": "Miguel Sánchez" }, "text": "Miguel Sánchez Pesquera" }, "Q129570988/surname2-es/givenFirst": { "family": { "expected": "Irazabal Agirre", "got": "Agirre" }, "given_names": { "expected": "Jon", "got": "Jon Irazabal" }, "text": "Jon Irazabal Agirre" }, "Q130383803/surname2-co/givenFirst": { "family": { "expected": "Rodríguez Agudelo", "got": "Agudelo" }, "given_names": { "expected": "Juan Carlos", "got": "Juan Carlos Rodríguez" }, "text": "Juan Carlos Rodríguez Agudelo" }, "Q130492677/surname2-mx/givenFirst": { "family": { "expected": "Jiménez Ruiz", "got": "Ruiz" }, "given_names": { "expected": "Eliseo", "got": "Eliseo Jiménez" }, "text": "Eliseo Jiménez Ruiz" }, "Q130546670/surname2-mx/givenFirst": { "family": { "expected": "Mora Tovar", "got": "Tovar" }, "given_names": { "expected": "Luis", "got": "Luis Mora" }, "text": "Luis Mora Tovar" }, "Q130706816/surname2-co/givenFirst": { "family": { "expected": "Tamayo Garcera", "got": "Garcera" }, "given_names": { "expected": "Diego Alberto", "got": "Diego Alberto Tamayo" }, "text": "Diego Alberto Tamayo Garcera" }, "Q131151443/surname2-mx/givenFirst": { "family": { "expected": "Gómez Campos", "got": "Campos" }, "given_names": { "expected": "Agustín", "got": "Agustín Gómez" }, "text": "Agustín Gómez Campos" }, "Q131387351/surname2-es/givenFirst": { "family": { "expected": "Sánchez Bedoya", "got": "Bedoya" }, "given_names": { "expected": "Federico", "got": "Federico Sánchez" }, "text": "Federico Sánchez Bedoya" }, "Q131567217/surname2-mx/givenFirst": { "family": { "expected": "Alcántara Miranda", "got": "Miranda" }, "given_names": { "expected": "Jesús", "got": "Jesús Alcántara" }, "text": "Jesús Alcántara Miranda" }, "Q131623018/surname2-es/givenFirst": { "family": { "expected": "Lema Villar", "got": "Villar" }, "given_names": { "expected": "Manuela", "got": "Manuela Lema" }, "text": "Manuela Lema Villar" }, "Q131756403/surname2-mx/givenFirst": { "family": { "expected": "Palafox Herrán", "got": "Herrán" }, "given_names": { "expected": "Elvira", "got": "Elvira Palafox" }, "text": "Elvira Palafox Herrán" }, "Q1330120/surname2-mx/givenFirst": { "family": { "expected": "Lozano Márquez", "got": "Márquez" }, "given_names": { "expected": "Jorge", "got": "Jorge Lozano" }, "text": "Jorge Lozano Márquez" }, "Q13419335/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Bjarnason" }, "given_names": { "expected": "Ragnar Bjarnason", "got": "Ragnar" }, "text": "Ragnar Bjarnason" }, "Q13423492/surname2-es/givenFirst": { "family": { "expected": "Cañada Acebal", "got": "Acebal" }, "given_names": { "expected": "Silverio", "got": "Silverio Cañada" }, "text": "Silverio Cañada Acebal" }, "Q134423201/surname2-co/givenFirst": { "family": { "expected": "Baena Ortiz", "got": "Ortiz" }, "given_names": { "expected": "Willington", "got": "Willington Baena" }, "text": "Willington Baena Ortiz" }, "Q134424045/surname2-co/givenFirst": { "family": { "expected": "Vergara Cárdenas", "got": "Cárdenas" }, "given_names": { "expected": "Joaquín", "got": "Joaquín Vergara" }, "text": "Joaquín Vergara Cárdenas" }, "Q134424647/surname2-co/givenFirst": { "family": { "expected": "Porto Egea", "got": "Egea" }, "given_names": { "expected": "Leonardo", "got": "Leonardo Porto" }, "text": "Leonardo Porto Egea" }, "Q134424654/surname2-co/givenFirst": { "family": { "expected": "Villazón Ochoa", "got": "Ochoa" }, "given_names": { "expected": "Martín", "got": "Martín Villazón" }, "text": "Martín Villazón Ochoa" }, "Q134479122/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Sigurðsson" }, "given_names": { "expected": "Haraldur Sigurðsson", "got": "Haraldur" }, "text": "Haraldur Sigurðsson" }, "Q134544566/surname2-es/givenFirst": { "family": { "expected": "Cunillera Torrent", "got": "Torrent" }, "given_names": { "expected": "Benigno", "got": "Benigno Cunillera" }, "text": "Benigno Cunillera Torrent" }, "Q1351501/surname2-co/givenFirst": { "family": { "expected": "Rodríguez Orejuela", "got": "Orejuela" }, "given_names": { "expected": "Gilberto", "got": "Gilberto Rodríguez" }, "text": "Gilberto Rodríguez Orejuela" }, "Q135270599/surname2-co/givenFirst": { "family": { "expected": "Romero Jiménez", "got": "Jiménez" }, "given_names": { "expected": "Carlos Arturo", "got": "Carlos Arturo Romero" }, "text": "Carlos Arturo Romero Jiménez" }, "Q135275204/surname2-mx/givenFirst": { "family": { "expected": "Rosas Magallón", "got": "Magallón" }, "given_names": { "expected": "Salvador", "got": "Salvador Rosas" }, "text": "Salvador Rosas Magallón" }, "Q13534607/surname2-es/givenFirst": { "family": { "expected": "Rodríguez López", "got": "López" }, "given_names": { "expected": "Consuelo", "got": "Consuelo Rodríguez" }, "text": "Consuelo Rodríguez López" }, "Q135792718/surname2-co/givenFirst": { "family": { "expected": "Peña Maldonado", "got": "Maldonado" }, "given_names": { "expected": "Ever", "got": "Ever Peña" }, "text": "Ever Peña Maldonado" }, "Q136100927/surname2-es/givenFirst": { "family": { "expected": "Rodríguez Fernández", "got": "Fernández" }, "given_names": { "expected": "Saturnino", "got": "Saturnino Rodríguez" }, "text": "Saturnino Rodríguez Fernández" }, "Q13634266/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Eyjólfsson" }, "given_names": { "expected": "Egill Eyjólfsson", "got": "Egill" }, "text": "Egill Eyjólfsson" }, "Q136342922/surname2-es/givenFirst": { "family": { "expected": "Alinari Adarve", "got": "Adarve" }, "given_names": { "expected": "Leonor", "got": "Leonor Alinari" }, "text": "Leonor Alinari Adarve" }, "Q136349166/surname2-es/givenFirst": { "family": { "expected": "Anta Seoane", "got": "Seoane" }, "given_names": { "expected": "Adolfo", "got": "Adolfo Anta" }, "text": "Adolfo Anta Seoane" }, "Q136461155/surname2-co/givenFirst": { "family": { "expected": "Samper Sordo", "got": "Sordo" }, "given_names": { "expected": "Juan", "got": "Juan Samper" }, "text": "Juan Samper Sordo" }, "Q136469205/surname2-co/givenFirst": { "family": { "expected": "Ascanio Sepúlveda", "got": "Sepúlveda" }, "given_names": { "expected": "Adiel", "got": "Adiel Ascanio" }, "text": "Adiel Ascanio Sepúlveda" }, "Q136487682/surname2-co/givenFirst": { "family": { "expected": "Padilla Mejía", "got": "Mejía" }, "given_names": { "expected": "Andrés David", "got": "Andrés David Padilla" }, "text": "Andrés David Padilla Mejía" }, "Q1364903/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Þórðarson" }, "given_names": { "expected": "Teitur Þórðarson", "got": "Teitur" }, "text": "Teitur Þórðarson" }, "Q137351899/surname2-es/givenFirst": { "family": { "expected": "Álvarez Iglesias", "got": "Iglesias" }, "given_names": { "expected": "Luisa", "got": "Luisa Álvarez" }, "text": "Luisa Álvarez Iglesias" }, "Q137963805/surname2-es/givenFirst": { "family": { "expected": "López González", "got": "González" }, "given_names": { "expected": "Manuel", "got": "Manuel López" }, "text": "Manuel López González" }, "Q138690631/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Ásgeirsson" }, "given_names": { "expected": "Sveinn Ásgeirsson", "got": "Sveinn" }, "text": "Sveinn Ásgeirsson" }, "Q138703138/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Ágústsson" }, "given_names": { "expected": "Haukur Ágústsson", "got": "Haukur" }, "text": "Haukur Ágústsson" }, "Q139381793/surname2-co/givenFirst": { "family": { "expected": "Córdoba Henao", "got": "Henao" }, "given_names": { "expected": "Yenifer", "got": "Yenifer Córdoba" }, "text": "Yenifer Córdoba Henao" }, "Q139505098/surname2-co/givenFirst": { "family": { "expected": "Duque Pastrana", "got": "Pastrana" }, "given_names": { "expected": "Carlos Mauricio", "got": "Carlos Mauricio Duque" }, "text": "Carlos Mauricio Duque Pastrana" }, "Q1395848/surname2-co/givenFirst": { "family": { "expected": "Leoz Almirón", "got": "Almirón" }, "given_names": { "expected": "Nicolás", "got": "Nicolás Leoz" }, "text": "Nicolás Leoz Almirón" }, "Q139969004/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Agnarsson" }, "given_names": { "expected": "Júlíus Agnarsson", "got": "Júlíus" }, "text": "Júlíus Agnarsson" }, "Q140266874/surname2-co/givenFirst": { "family": { "expected": "Lucumí Mina", "got": "Mina" }, "given_names": { "expected": "Sebastián", "got": "Sebastián Lucumí" }, "text": "Sebastián Lucumí Mina" }, "Q140420991/surname2-co/givenFirst": { "family": { "expected": "Rojas Benavides", "got": "Benavides" }, "given_names": { "expected": "Robiro", "got": "Robiro Rojas" }, "text": "Robiro Rojas Benavides" }, "Q141607976/surname2-co/givenFirst": { "family": { "expected": "Santacruz Aguilar", "got": "Aguilar" }, "given_names": { "expected": "Milton", "got": "Milton Santacruz" }, "text": "Milton Santacruz Aguilar" }, "Q141668335/surname2-co/givenFirst": { "family": { "expected": "Guillin Mercado", "got": "Mercado" }, "given_names": { "expected": "Dionisio", "got": "Dionisio Guillin" }, "text": "Dionisio Guillin Mercado" }, "Q148075/surname2-mx/givenFirst": { "family": { "expected": "González Garza", "got": "Garza" }, "given_names": { "expected": "Roque", "got": "Roque González" }, "text": "Roque González Garza" }, "Q15079846/surname2-mx/givenFirst": { "family": { "expected": "Ramos Millán", "got": "Millán" }, "given_names": { "expected": "Gabriel", "got": "Gabriel Ramos" }, "text": "Gabriel Ramos Millán" }, "Q15109949/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Egilsdóttir" }, "given_names": { "expected": "Þórunn Egilsdóttir", "got": "Þórunn" }, "text": "Þórunn Egilsdóttir" }, "Q1516038/surname2-mx/givenFirst": { "family": { "expected": "Baqueiro Foster", "got": "Foster" }, "given_names": { "expected": "Gerónimo", "got": "Gerónimo Baqueiro" }, "text": "Gerónimo Baqueiro Foster" }, "Q15242226/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Björnsdóttir" }, "given_names": { "expected": "Guðrún Björnsdóttir", "got": "Guðrún" }, "text": "Guðrún Björnsdóttir" }, "Q152472/surname2-co/givenFirst": { "family": { "expected": "Betancourt Pulecio", "got": "Pulecio" }, "given_names": { "expected": "Ingrid", "got": "Ingrid Betancourt" }, "text": "Ingrid Betancourt Pulecio" }, "Q15635899/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Erlingsson" }, "given_names": { "expected": "Benedikt Erlingsson", "got": "Benedikt" }, "text": "Benedikt Erlingsson" }, "Q15711621/surname2-es/givenFirst": { "family": { "expected": "Cuadra Medina", "got": "Medina" }, "given_names": { "expected": "Mariano", "got": "Mariano Cuadra" }, "text": "Mariano Cuadra Medina" }, "Q1601312/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Helgason" }, "given_names": { "expected": "Sigurður Helgason", "got": "Sigurður" }, "text": "Sigurður Helgason" }, "Q16143814/surname2-co/givenFirst": { "family": { "expected": "Echeverría Comas", "got": "Comas" }, "given_names": { "expected": "Adolfo Ernesto", "got": "Adolfo Ernesto Echeverría" }, "text": "Adolfo Ernesto Echeverría Comas" }, "Q16151254/surname2-mx/givenFirst": { "family": { "expected": "Flores Sandoval", "got": "Sandoval" }, "given_names": { "expected": "Patricio", "got": "Patricio Flores" }, "text": "Patricio Flores Sandoval" }, "Q16190279/surname2-es/givenFirst": { "family": { "expected": "Cussà Balaguer", "got": "Balaguer" }, "given_names": { "expected": "Jordi", "got": "Jordi Cussà" }, "text": "Jordi Cussà Balaguer" }, "Q1621643/surname2-mx/givenFirst": { "family": { "expected": "Félix Gallardo", "got": "Gallardo" }, "given_names": { "expected": "Miguel Ángel", "got": "Miguel Ángel Félix" }, "text": "Miguel Ángel Félix Gallardo" }, "Q16419219/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Ólafsdóttir" }, "given_names": { "expected": "Kristín Ólafsdóttir", "got": "Kristín" }, "text": "Kristín Ólafsdóttir" }, "Q16420505/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Ragnarsson" }, "given_names": { "expected": "Ólafur Ragnarsson", "got": "Ólafur" }, "text": "Ólafur Ragnarsson" }, "Q16420913/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Guðmundsson" }, "given_names": { "expected": "Loftur Guðmundsson", "got": "Loftur" }, "text": "Loftur Guðmundsson" }, "Q16421121/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Björnsson" }, "given_names": { "expected": "Bjarni Björnsson", "got": "Bjarni" }, "text": "Bjarni Björnsson" }, "Q16421247/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Gíslason" }, "given_names": { "expected": "Björgvin Gíslason", "got": "Björgvin" }, "text": "Björgvin Gíslason" }, "Q16421751/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Hannesson" }, "given_names": { "expected": "Þorsteinn Hannesson", "got": "Þorsteinn" }, "text": "Þorsteinn Hannesson" }, "Q16424675/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Haraldsson" }, "given_names": { "expected": "Rúrik Haraldsson", "got": "Rúrik" }, "text": "Rúrik Haraldsson" }, "Q16425767/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Björnsson" }, "given_names": { "expected": "Sigurður Björnsson", "got": "Sigurður" }, "text": "Sigurður Björnsson" }, "Q16426100/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Jónsson" }, "given_names": { "expected": "Guðmundur Jónsson", "got": "Guðmundur" }, "text": "Guðmundur Jónsson" }, "Q16426181/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Lárusdóttir" }, "given_names": { "expected": "Guðrún Lárusdóttir", "got": "Guðrún" }, "text": "Guðrún Lárusdóttir" }, "Q16426629/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Bjarnadóttir" }, "given_names": { "expected": "Hallbjörg Bjarnadóttir", "got": "Hallbjörg" }, "text": "Hallbjörg Bjarnadóttir" }, "Q16429743/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Jónasson" }, "given_names": { "expected": "Jónas Jónasson", "got": "Jónas" }, "text": "Jónas Jónasson" }, "Q16429802/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Jónatansdóttir" }, "given_names": { "expected": "Jónína Jónatansdóttir", "got": "Jónína" }, "text": "Jónína Jónatansdóttir" }, "Q16490264/surname2-mx/givenFirst": { "family": { "expected": "Colorado Vera", "got": "Vera" }, "given_names": { "expected": "Carlos", "got": "Carlos Colorado" }, "text": "Carlos Colorado Vera" }, "Q16599489/surname2-mx/givenFirst": { "family": { "expected": "Peña Muñoz", "got": "Muñoz" }, "given_names": { "expected": "Margarita", "got": "Margarita Peña" }, "text": "Margarita Peña Muñoz" }, "Q1684202/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Gunnarsson" }, "given_names": { "expected": "Róbert Gunnarsson", "got": "Róbert" }, "text": "Róbert Gunnarsson" }, "Q16941958/surname2-co/givenFirst": { "family": { "expected": "Mora Toro", "got": "Toro" }, "given_names": { "expected": "Libardo", "got": "Libardo Mora" }, "text": "Libardo Mora Toro" }, "Q16942160/surname2-co/givenFirst": { "family": { "expected": "Prado y Colón de Carvajal", "got": "de Carvajal" }, "given_names": { "expected": "Diego", "got": "Diego Prado y Colón" }, "text": "Diego Prado y Colón de Carvajal" }, "Q17037033/surname2-es/givenFirst": { "family": { "expected": "Todó Garcia", "got": "Garcia" }, "given_names": { "expected": "Francesc", "got": "Francesc Todó" }, "text": "Francesc Todó Garcia" }, "Q1703837/surname2-mx/givenFirst": { "family": { "expected": "González Camarena", "got": "Camarena" }, "given_names": { "expected": "Jorge", "got": "Jorge González" }, "text": "Jorge González Camarena" }, "Q1715055/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Steingrímsson" }, "given_names": { "expected": "Jón Steingrímsson", "got": "Jón" }, "text": "Jón Steingrímsson" }, "Q17155819/surname2-es/givenFirst": { "family": { "expected": "Vilató Ruiz", "got": "Ruiz" }, "given_names": { "expected": "José", "got": "José Vilató" }, "text": "José Vilató Ruiz" }, "Q174100/surname2-mx/givenFirst": { "family": { "expected": "Bautista Rocha", "got": "Rocha" }, "given_names": { "expected": "Daniel", "got": "Daniel Bautista" }, "text": "Daniel Bautista Rocha" }, "Q17413229/surname2-es/givenFirst": { "family": { "expected": "Brea Segade", "got": "Segade" }, "given_names": { "expected": "Xosé María", "got": "Xosé María Brea" }, "text": "Xosé María Brea Segade" }, "Q17478000/surname2-co/givenFirst": { "family": { "expected": "Duque Márquez", "got": "Márquez" }, "given_names": { "expected": "Iván", "got": "Iván Duque" }, "text": "Iván Duque Márquez" }, "Q175119/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Björnsdóttir" }, "given_names": { "expected": "Selma Björnsdóttir", "got": "Selma" }, "text": "Selma Björnsdóttir" }, "Q1766810/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Ottesen" }, "given_names": { "expected": "Sölvi Ottesen", "got": "Sölvi" }, "text": "Sölvi Ottesen" }, "Q177712/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Þórðarson" }, "given_names": { "expected": "Björn Þórðarson", "got": "Björn" }, "text": "Björn Þórðarson" }, "Q181383/patronymic-my/givenFirst": { "family": { "expected": "", "got": "bin Mohamad" }, "given_names": { "expected": "Mahathir bin Mohamad", "got": "Mahathir" }, "text": "Mahathir bin Mohamad" }, "Q1859374/surname2-mx/givenFirst": { "family": { "expected": "Fernández Castrillón", "got": "Castrillón" }, "given_names": { "expected": "Manuel", "got": "Manuel Fernández" }, "text": "Manuel Fernández Castrillón" }, "Q1880038/surname2-mx/givenFirst": { "family": { "expected": "Yáñez Carrillo", "got": "Carrillo" }, "given_names": { "expected": "José María", "got": "José María Yáñez" }, "text": "José María Yáñez Carrillo" }, "Q189797/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Finnbogadóttir" }, "given_names": { "expected": "Vigdís Finnbogadóttir", "got": "Vigdís" }, "text": "Vigdís Finnbogadóttir" }, "Q19561528/surname2-co/givenFirst": { "family": { "expected": "Mesa Soto", "got": "Soto" }, "given_names": { "expected": "Simón", "got": "Simón Mesa" }, "text": "Simón Mesa Soto" }, "Q19601974/surname2-es/givenFirst": { "family": { "expected": "Marcos González", "got": "González" }, "given_names": { "expected": "Sergio", "got": "Sergio Marcos" }, "text": "Sergio Marcos González" }, "Q1960343/surname2-es/givenFirst": { "family": { "expected": "González Carvajal", "got": "Carvajal" }, "given_names": { "expected": "Tomás", "got": "Tomás González" }, "text": "Tomás González Carvajal" }, "Q19684894/surname2-es/givenFirst": { "family": { "expected": "Ferrer Comas", "got": "Comas" }, "given_names": { "expected": "Eduardo", "got": "Eduardo Ferrer" }, "text": "Eduardo Ferrer Comas" }, "Q19694625/surname2-mx/givenFirst": { "family": { "expected": "Martínez Baena", "got": "Baena" }, "given_names": { "expected": "Carlos", "got": "Carlos Martínez" }, "text": "Carlos Martínez Baena" }, "Q1976742/surname2-mx/givenFirst": { "family": { "expected": "Arellano Félix", "got": "Félix" }, "given_names": { "expected": "Ramón", "got": "Ramón Arellano" }, "text": "Ramón Arellano Félix" }, "Q1981295/surname2-co/givenFirst": { "family": { "expected": "Santos Calderón", "got": "Calderón" }, "given_names": { "expected": "Francisco", "got": "Francisco Santos" }, "text": "Francisco Santos Calderón" }, "Q19816923/surname2-co/givenFirst": { "family": { "expected": "Sierra y Velásquez", "got": "de Dios Sierra y Velásquez" }, "given_names": { "expected": "Juan de Dios", "got": "Juan" }, "text": "Juan de Dios Sierra y Velásquez" }, "Q1993924/surname2-es/givenFirst": { "family": { "expected": "González Díaz", "got": "Díaz" }, "given_names": { "expected": "Jorge", "got": "Jorge González" }, "text": "Jorge González Díaz" }, "Q20006244/surname2-es/givenFirst": { "family": { "expected": "Rebassa Bisquerra", "got": "Bisquerra" }, "given_names": { "expected": "Pere", "got": "Pere Rebassa" }, "text": "Pere Rebassa Bisquerra" }, "Q20242421/surname2-es/givenFirst": { "family": { "expected": "Bonet Marrugat", "got": "Marrugat" }, "given_names": { "expected": "Albert", "got": "Albert Bonet" }, "text": "Albert Bonet Marrugat" }, "Q2029076/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Bjarnason" }, "given_names": { "expected": "Brynjólfur Bjarnason", "got": "Brynjólfur" }, "text": "Brynjólfur Bjarnason" }, "Q20534308/surname2-es/givenFirst": { "family": { "expected": "Banet Fontenla", "got": "Fontenla" }, "given_names": { "expected": "Manuel", "got": "Manuel Banet" }, "text": "Manuel Banet Fontenla" }, "Q20535225/surname2-es/givenFirst": { "family": { "expected": "Guerra Cobián", "got": "Cobián" }, "given_names": { "expected": "José María", "got": "José María Guerra" }, "text": "José María Guerra Cobián" }, "Q20535338/surname2-es/givenFirst": { "family": { "expected": "Harguindey Broussain", "got": "Broussain" }, "given_names": { "expected": "Juan", "got": "Juan Harguindey" }, "text": "Juan Harguindey Broussain" }, "Q20536051/surname2-es/givenFirst": { "family": { "expected": "Veres Acevedo", "got": "Acevedo" }, "given_names": { "expected": "Laureano", "got": "Laureano Veres" }, "text": "Laureano Veres Acevedo" }, "Q20558835/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Pálsson" }, "given_names": { "expected": "Lárus Pálsson", "got": "Lárus" }, "text": "Lárus Pálsson" }, "Q20743706/surname2-es/givenFirst": { "family": { "expected": "Ortega Martín", "got": "Martín" }, "given_names": { "expected": "Alberto", "got": "Alberto Ortega" }, "text": "Alberto Ortega Martín" }, "Q20829083/surname2-es/givenFirst": { "family": { "expected": "Ruiz Romero", "got": "Romero" }, "given_names": { "expected": "Elisa", "got": "Elisa Ruiz" }, "text": "Elisa Ruiz Romero" }, "Q2083675/surname2-mx/givenFirst": { "family": { "expected": "Gorostieta Velarde", "got": "Velarde" }, "given_names": { "expected": "Enrique", "got": "Enrique Gorostieta" }, "text": "Enrique Gorostieta Velarde" }, "Q2259207/surname2-mx/givenFirst": { "family": { "expected": "Guzmán Rodríguez", "got": "Rodríguez" }, "given_names": { "expected": "Jorge Ernesto", "got": "Jorge Ernesto Guzmán" }, "text": "Jorge Ernesto Guzmán Rodríguez" }, "Q23000865/surname2-es/givenFirst": { "family": { "expected": "Llanos González", "got": "González" }, "given_names": { "expected": "Ángel", "got": "Ángel Llanos" }, "text": "Ángel Llanos González" }, "Q235562/surname2-co/givenFirst": { "family": { "expected": "Duque Mariño", "got": "Mariño" }, "given_names": { "expected": "Mariana", "got": "Mariana Duque" }, "text": "Mariana Duque Mariño" }, "Q23753778/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Alfreðsdóttir" }, "given_names": { "expected": "Lilja Alfreðsdóttir", "got": "Lilja" }, "text": "Lilja Alfreðsdóttir" }, "Q23927812/surname2-mx/givenFirst": { "family": { "expected": "Zupo Gutiérrez", "got": "Gutiérrez" }, "given_names": { "expected": "Claudio", "got": "Claudio Zupo" }, "text": "Claudio Zupo Gutiérrez" }, "Q24013732/surname2-es/givenFirst": { "family": { "expected": "Puga Franco", "got": "Franco" }, "given_names": { "expected": "Valentín", "got": "Valentín Puga" }, "text": "Valentín Puga Franco" }, "Q24239355/surname2-mx/givenFirst": { "family": { "expected": "González Irigoyen", "got": "Irigoyen" }, "given_names": { "expected": "Rómulo", "got": "Rómulo González" }, "text": "Rómulo González Irigoyen" }, "Q24255040/surname2-es/givenFirst": { "family": { "expected": "Parra Cabrera", "got": "Cabrera" }, "given_names": { "expected": "Antonio", "got": "Antonio Parra" }, "text": "Antonio Parra Cabrera" }, "Q24279112/surname2-es/givenFirst": { "family": { "expected": "Andino y del Solar", "got": "Solar" }, "given_names": { "expected": "Eladio", "got": "Eladio Andino y del" }, "text": "Eladio Andino y del Solar" }, "Q2449340/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Sigurðsson" }, "given_names": { "expected": "Kristján Örn Sigurðsson", "got": "Kristján Örn" }, "text": "Kristján Örn Sigurðsson" }, "Q24494577/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Tómasdóttir" }, "given_names": { "expected": "Halla Tómasdóttir", "got": "Halla" }, "text": "Halla Tómasdóttir" }, "Q2463622/surname2-es/givenFirst": { "family": { "expected": "Blanco Romasanta", "got": "Romasanta" }, "given_names": { "expected": "Manuel", "got": "Manuel Blanco" }, "text": "Manuel Blanco Romasanta" }, "Q24961757/surname2-mx/givenFirst": { "family": { "expected": "García Ocejo", "got": "Ocejo" }, "given_names": { "expected": "José", "got": "José García" }, "text": "José García Ocejo" }, "Q25038297/patronymic-my/givenFirst": { "family": { "expected": "", "got": "bin Hamzah" }, "given_names": { "expected": "Mohamed bin Hamzah", "got": "Mohamed" }, "text": "Mohamed bin Hamzah" }, "Q252282/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Þorláksson" }, "given_names": { "expected": "Árni Þorláksson", "got": "Árni" }, "text": "Árni Þorláksson" }, "Q252586/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Jóhannesdóttir" }, "given_names": { "expected": "Ásta Ragnheiður Jóhannesdóttir", "got": "Ásta Ragnheiður" }, "text": "Ásta Ragnheiður Jóhannesdóttir" }, "Q25402592/surname2-co/givenFirst": { "family": { "expected": "Posada Díaz", "got": "Díaz" }, "given_names": { "expected": "Francisco", "got": "Francisco Posada" }, "text": "Francisco Posada Díaz" }, "Q262253/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Sigurðardóttir" }, "given_names": { "expected": "Yrsa Sigurðardóttir", "got": "Yrsa" }, "text": "Yrsa Sigurðardóttir" }, "Q2623568/surname2-mx/givenFirst": { "family": { "expected": "Infante Torrentera", "got": "Torrentera" }, "given_names": { "expected": "Pedro", "got": "Pedro Infante" }, "text": "Pedro Infante Torrentera" }, "Q2625826/surname2-mx/givenFirst": { "family": { "expected": "Rodríguez Romero", "got": "Romero" }, "given_names": { "expected": "José Antonio", "got": "José Antonio Rodríguez" }, "text": "José Antonio Rodríguez Romero" }, "Q2635734/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Hjartardóttir" }, "given_names": { "expected": "Hera Hjartardóttir", "got": "Hera" }, "text": "Hera Hjartardóttir" }, "Q264516/patronymic-my/givenFirst": { "family": { "expected": "", "got": "Hussein" }, "given_names": { "expected": "Abdul Razak Hussein", "got": "Abdul Razak" }, "text": "Abdul Razak Hussein" }, "Q2718231/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Árnason" }, "given_names": { "expected": "Kári Árnason", "got": "Kári" }, "text": "Kári Árnason" }, "Q28028537/surname2-mx/givenFirst": { "family": { "expected": "Bassols Batalla", "got": "Batalla" }, "given_names": { "expected": "Ángel", "got": "Ángel Bassols" }, "text": "Ángel Bassols Batalla" }, "Q28163843/surname2-co/givenFirst": { "family": { "expected": "Cepeda Torres", "got": "Torres" }, "given_names": { "expected": "Rafael", "got": "Rafael Cepeda" }, "text": "Rafael Cepeda Torres" }, "Q28343272/surname2-co/givenFirst": { "family": { "expected": "Martínez Arango", "got": "Arango" }, "given_names": { "expected": "Gilberto", "got": "Gilberto Martínez" }, "text": "Gilberto Martínez Arango" }, "Q28355768/surname2-es/givenFirst": { "family": { "expected": "Chillón de la Fuente", "got": "de la Fuente" }, "given_names": { "expected": "Gregorio", "got": "Gregorio Chillón" }, "text": "Gregorio Chillón de la Fuente" }, "Q2839714/surname2-co/givenFirst": { "family": { "expected": "Acevedo Vallarino", "got": "Vallarino" }, "given_names": { "expected": "Arturo", "got": "Arturo Acevedo" }, "text": "Arturo Acevedo Vallarino" }, "Q28501494/surname2-mx/givenFirst": { "family": { "expected": "Mazón Pineda", "got": "Pineda" }, "given_names": { "expected": "Crisóforo", "got": "Crisóforo Mazón" }, "text": "Crisóforo Mazón Pineda" }, "Q28739698/surname2-es/givenFirst": { "family": { "expected": "Coello de Portugal y Pacheco", "got": "de Portugal y Pacheco" }, "given_names": { "expected": "Carlos", "got": "Carlos Coello" }, "text": "Carlos Coello de Portugal y Pacheco" }, "Q28792898/surname2-mx/givenFirst": { "family": { "expected": "Lanz Duret", "got": "Duret" }, "given_names": { "expected": "Miguel", "got": "Miguel Lanz" }, "text": "Miguel Lanz Duret" }, "Q28792973/surname2-co/givenFirst": { "family": { "expected": "Zuleta Ferrer", "got": "Ferrer" }, "given_names": { "expected": "Juan", "got": "Juan Zuleta" }, "text": "Juan Zuleta Ferrer" }, "Q2917254/surname2-co/givenFirst": { "family": { "expected": "Cañas Serrano", "got": "Serrano" }, "given_names": { "expected": "Pedro", "got": "Pedro Cañas" }, "text": "Pedro Cañas Serrano" }, "Q291858/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Ingimarsson" }, "given_names": { "expected": "Ívar Ingimarsson", "got": "Ívar" }, "text": "Ívar Ingimarsson" }, "Q294666/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Jónasson" }, "given_names": { "expected": "Ögmundur Jónasson", "got": "Ögmundur" }, "text": "Ögmundur Jónasson" }, "Q29649988/surname2-co/givenFirst": { "family": { "expected": "Villada Hoyos", "got": "Hoyos" }, "given_names": { "expected": "Salomón", "got": "Salomón Villada" }, "text": "Salomón Villada Hoyos" }, "Q29860266/surname2-mx/givenFirst": { "family": { "expected": "Colomo Castro", "got": "Castro" }, "given_names": { "expected": "Felipe", "got": "Felipe Colomo" }, "text": "Felipe Colomo Castro" }, "Q299574/patronymic-my/givenFirst": { "family": { "expected": "", "got": "Ibrahim" }, "given_names": { "expected": "Anwar Ibrahim", "got": "Anwar" }, "text": "Anwar Ibrahim" }, "Q29959449/surname2-mx/givenFirst": { "family": { "expected": "Corral Romero", "got": "Romero" }, "given_names": { "expected": "Octavio", "got": "Octavio Corral" }, "text": "Octavio Corral Romero" }, "Q3031692/surname2-mx/givenFirst": { "family": { "expected": "Gómez Bolaños", "got": "Bolaños" }, "given_names": { "expected": "Horacio", "got": "Horacio Gómez" }, "text": "Horacio Gómez Bolaños" }, "Q303293/surname2-mx/givenFirst": { "family": { "expected": "Padilla Gutiérrez", "got": "Gutiérrez" }, "given_names": { "expected": "Aarón", "got": "Aarón Padilla" }, "text": "Aarón Padilla Gutiérrez" }, "Q30899494/surname2-es/givenFirst": { "family": { "expected": "Freire Barreiro", "got": "Barreiro" }, "given_names": { "expected": "Francisco", "got": "Francisco Freire" }, "text": "Francisco Freire Barreiro" }, "Q31186764/patronymic-my/givenFirst": { "family": { "expected": "", "got": "Yassin" }, "given_names": { "expected": "Abdul Aziz Yassin", "got": "Abdul Aziz" }, "text": "Abdul Aziz Yassin" }, "Q3124411/surname2-mx/givenFirst": { "family": { "expected": "Uruchurtu Ramírez", "got": "Ramírez" }, "given_names": { "expected": "Manuel", "got": "Manuel Uruchurtu" }, "text": "Manuel Uruchurtu Ramírez" }, "Q31749372/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Pétursson" }, "given_names": { "expected": "Daði Freyr Pétursson", "got": "Daði Freyr" }, "text": "Daði Freyr Pétursson" }, "Q3294918/surname2-mx/givenFirst": { "family": { "expected": "García Cantú", "got": "Cantú" }, "given_names": { "expected": "Guillermo", "got": "Guillermo García" }, "text": "Guillermo García Cantú" }, "Q3397750/surname2-es/givenFirst": { "family": { "expected": "Álvarez Blázquez", "got": "Blázquez" }, "given_names": { "expected": "Xosé María", "got": "Xosé María Álvarez" }, "text": "Xosé María Álvarez Blázquez" }, "Q3480844/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Garðarsdóttir" }, "given_names": { "expected": "Edda Garðarsdóttir", "got": "Edda" }, "text": "Edda Garðarsdóttir" }, "Q3481235/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Gunnarsdóttir" }, "given_names": { "expected": "Sara Björk Gunnarsdóttir", "got": "Sara Björk" }, "text": "Sara Björk Gunnarsdóttir" }, "Q35160252/surname2-mx/givenFirst": { "family": { "expected": "Nava García", "got": "García" }, "given_names": { "expected": "Martha", "got": "Martha Nava" }, "text": "Martha Nava García" }, "Q3548080/surname2-mx/givenFirst": { "family": { "expected": "Schreiber Castañón", "got": "Castañón" }, "given_names": { "expected": "Elizabeth Margot", "got": "Elizabeth Margot Schreiber" }, "text": "Elizabeth Margot Schreiber Castañón" }, "Q356302/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Sigurðardóttir" }, "given_names": { "expected": "Steinunn Sigurðardóttir", "got": "Steinunn" }, "text": "Steinunn Sigurðardóttir" }, "Q35689580/surname2-es/givenFirst": { "family": { "expected": "Gómez-Mena Vila", "got": "Vila" }, "given_names": { "expected": "José", "got": "José Gómez-Mena" }, "text": "José Gómez-Mena Vila" }, "Q362531/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Sigurðsson" }, "given_names": { "expected": "Sigurjón Birgir Sigurðsson", "got": "Sigurjón Birgir" }, "text": "Sigurjón Birgir Sigurðsson" }, "Q3808593/surname2-mx/givenFirst": { "family": { "expected": "García Icazbalceta", "got": "Icazbalceta" }, "given_names": { "expected": "Joaquín", "got": "Joaquín García" }, "text": "Joaquín García Icazbalceta" }, "Q380925/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Jochumsson" }, "given_names": { "expected": "Matthías Jochumsson", "got": "Matthías" }, "text": "Matthías Jochumsson" }, "Q3810410/surname2-mx/givenFirst": { "family": { "expected": "Cabrera Gándara", "got": "Gándara" }, "given_names": { "expected": "José", "got": "José Cabrera" }, "text": "José Cabrera Gándara" }, "Q381086/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Gunnarsson" }, "given_names": { "expected": "Gunnar Gunnarsson", "got": "Gunnar" }, "text": "Gunnar Gunnarsson" }, "Q3816879/surname2-es/givenFirst": { "family": { "expected": "Lejárraga y García", "got": "de la O Lejárraga y García" }, "given_names": { "expected": "María de la O", "got": "María" }, "text": "María de la O Lejárraga y García" }, "Q4016455/surname2-co/givenFirst": { "family": { "expected": "Lemos Simmonds", "got": "Simmonds" }, "given_names": { "expected": "Carlos", "got": "Carlos Lemos" }, "text": "Carlos Lemos Simmonds" }, "Q4198159/patronymic-my/givenFirst": { "family": { "expected": "", "got": "Rahman" }, "given_names": { "expected": "Ismail Abdul Rahman", "got": "Ismail Abdul" }, "text": "Ismail Abdul Rahman" }, "Q41991777/surname2-es/givenFirst": { "family": { "expected": "Fernández Bazán", "got": "Bazán" }, "given_names": { "expected": "Juan", "got": "Juan Fernández" }, "text": "Juan Fernández Bazán" }, "Q42455/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Guðmundsdóttir" }, "given_names": { "expected": "Björk Guðmundsdóttir", "got": "Björk" }, "text": "Björk Guðmundsdóttir" }, "Q425825/surname2-co/givenFirst": { "family": { "expected": "Calvo Ospina", "got": "Ospina" }, "given_names": { "expected": "Hernando", "got": "Hernando Calvo" }, "text": "Hernando Calvo Ospina" }, "Q4283792/surname2-mx/givenFirst": { "family": { "expected": "Tello Macías", "got": "Macías" }, "given_names": { "expected": "Manuel", "got": "Manuel Tello" }, "text": "Manuel Tello Macías" }, "Q42907085/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Halldórsson" }, "given_names": { "expected": "Óskar Halldórsson", "got": "Óskar" }, "text": "Óskar Halldórsson" }, "Q437932/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Friðleifsdóttir" }, "given_names": { "expected": "Siv Friðleifsdóttir", "got": "Siv" }, "text": "Siv Friðleifsdóttir" }, "Q438008/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Mínervudóttir" }, "given_names": { "expected": "Guðrún Eva Mínervudóttir", "got": "Guðrún Eva" }, "text": "Guðrún Eva Mínervudóttir" }, "Q438031/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Gunnarsdóttir" }, "given_names": { "expected": "Guðbjörg Gunnarsdóttir", "got": "Guðbjörg" }, "text": "Guðbjörg Gunnarsdóttir" }, "Q4429030/surname2-es/givenFirst": { "family": { "expected": "Soriano Fuertes", "got": "Fuertes" }, "given_names": { "expected": "Indalecio", "got": "Indalecio Soriano" }, "text": "Indalecio Soriano Fuertes" }, "Q448642/surname2-mx/givenFirst": { "family": { "expected": "Paredes Rangel", "got": "Rangel" }, "given_names": { "expected": "Beatriz Elena", "got": "Beatriz Elena Paredes" }, "text": "Beatriz Elena Paredes Rangel" }, "Q455683/surname2-mx/givenFirst": { "family": { "expected": "Ortiz Rubio", "got": "Rubio" }, "given_names": { "expected": "Pascual", "got": "Pascual Ortiz" }, "text": "Pascual Ortiz Rubio" }, "Q456321/surname2-mx/givenFirst": { "family": { "expected": "Guzmán Loera", "got": "Loera" }, "given_names": { "expected": "Joaquín", "got": "Joaquín Guzmán" }, "text": "Joaquín Guzmán Loera" }, "Q462196/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Davíðsdóttir" }, "given_names": { "expected": "Vilborg Davíðsdóttir", "got": "Vilborg" }, "text": "Vilborg Davíðsdóttir" }, "Q467464/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Jakobsdóttir" }, "given_names": { "expected": "Katrín Jakobsdóttir", "got": "Katrín" }, "text": "Katrín Jakobsdóttir" }, "Q47087536/surname2-es/givenFirst": { "family": { "expected": "Mataix Monllor", "got": "Monllor" }, "given_names": { "expected": "José", "got": "José Mataix" }, "text": "José Mataix Monllor" }, "Q4739054/surname2-mx/givenFirst": { "family": { "expected": "Aguirre Santiago", "got": "Santiago" }, "given_names": { "expected": "Amado", "got": "Amado Aguirre" }, "text": "Amado Aguirre Santiago" }, "Q47495573/surname2-mx/givenFirst": { "family": { "expected": "Pallares Portillo", "got": "Portillo" }, "given_names": { "expected": "Eduardo", "got": "Eduardo Pallares" }, "text": "Eduardo Pallares Portillo" }, "Q4776859/surname2-co/givenFirst": { "family": { "expected": "París Sanz de Santamaría", "got": "de Santamaría" }, "given_names": { "expected": "Antonio", "got": "Antonio París Sanz" }, "text": "Antonio París Sanz de Santamaría" }, "Q47772146/surname2-es/givenFirst": { "family": { "expected": "Pascual Nunell", "got": "Nunell" }, "given_names": { "expected": "Julián", "got": "Julián Pascual" }, "text": "Julián Pascual Nunell" }, "Q47792390/surname2-es/givenFirst": { "family": { "expected": "Bertomeu Font", "got": "Font" }, "given_names": { "expected": "Albert", "got": "Albert Bertomeu" }, "text": "Albert Bertomeu Font" }, "Q4785197/surname2-mx/givenFirst": { "family": { "expected": "Poveda Ricalde", "got": "Ricalde" }, "given_names": { "expected": "Arcadio", "got": "Arcadio Poveda" }, "text": "Arcadio Poveda Ricalde" }, "Q48554/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Jóhannsson" }, "given_names": { "expected": "Aron Jóhannsson", "got": "Aron" }, "text": "Aron Jóhannsson" }, "Q5041784/surname2-co/givenFirst": { "family": { "expected": "Ardila Lülle", "got": "Lülle" }, "given_names": { "expected": "Carlos", "got": "Carlos Ardila" }, "text": "Carlos Ardila Lülle" }, "Q50422593/surname2-es/givenFirst": { "family": { "expected": "Ruiz Pérez", "got": "Pérez" }, "given_names": { "expected": "Catalina", "got": "Catalina Ruiz" }, "text": "Catalina Ruiz Pérez" }, "Q504250/surname2-mx/givenFirst": { "family": { "expected": "Rodríguez Bahena", "got": "Bahena" }, "given_names": { "expected": "Ignacio", "got": "Ignacio Rodríguez" }, "text": "Ignacio Rodríguez Bahena" }, "Q511882/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Sigfússon" }, "given_names": { "expected": "Steingrímur Jóhann Sigfússon", "got": "Steingrímur Jóhann" }, "text": "Steingrímur Jóhann Sigfússon" }, "Q5125955/surname2-mx/givenFirst": { "family": { "expected": "Brugada Molina", "got": "Molina" }, "given_names": { "expected": "Clara", "got": "Clara Brugada" }, "text": "Clara Brugada Molina" }, "Q512843/surname2-es/givenFirst": { "family": { "expected": "Gómez Pérez", "got": "Pérez" }, "given_names": { "expected": "Sócrates", "got": "Sócrates Gómez" }, "text": "Sócrates Gómez Pérez" }, "Q520713/surname2-es/givenFirst": { "family": { "expected": "Torres García", "got": "García" }, "given_names": { "expected": "Joaquín", "got": "Joaquín Torres" }, "text": "Joaquín Torres García" }, "Q525581/surname2-mx/givenFirst": { "family": { "expected": "Hernández Villaseñor", "got": "Villaseñor" }, "given_names": { "expected": "Bernardo", "got": "Bernardo Hernández" }, "text": "Bernardo Hernández Villaseñor" }, "Q539394/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Viðarsdóttir" }, "given_names": { "expected": "Margrét Lára Viðarsdóttir", "got": "Margrét Lára" }, "text": "Margrét Lára Viðarsdóttir" }, "Q539421/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Júlíusdóttir" }, "given_names": { "expected": "Katrín Júlíusdóttir", "got": "Katrín" }, "text": "Katrín Júlíusdóttir" }, "Q5399460/surname2-co/givenFirst": { "family": { "expected": "Santo Domingo Pumarejo", "got": "Pumarejo" }, "given_names": { "expected": "Julio Mario", "got": "Julio Mario Santo Domingo" }, "text": "Julio Mario Santo Domingo Pumarejo" }, "Q540138/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Helgadóttir" }, "given_names": { "expected": "Guðrún Helgadóttir", "got": "Guðrún" }, "text": "Guðrún Helgadóttir" }, "Q5412830/surname2-es/givenFirst": { "family": { "expected": "González Macías", "got": "Macías" }, "given_names": { "expected": "Francisco", "got": "Francisco González" }, "text": "Francisco González Macías" }, "Q55229105/surname2-es/givenFirst": { "family": { "expected": "García Ulecia", "got": "Ulecia" }, "given_names": { "expected": "Alberto", "got": "Alberto García" }, "text": "Alberto García Ulecia" }, "Q554086/surname2-es/givenFirst": { "family": { "expected": "Busquets Barroso", "got": "Barroso" }, "given_names": { "expected": "Carles", "got": "Carles Busquets" }, "text": "Carles Busquets Barroso" }, "Q555086/surname2-es/givenFirst": { "family": { "expected": "Ruiz Martorell", "got": "Martorell" }, "given_names": { "expected": "Julián", "got": "Julián Ruiz" }, "text": "Julián Ruiz Martorell" }, "Q555661/surname2-mx/givenFirst": { "family": { "expected": "Treviño Morales", "got": "Morales" }, "given_names": { "expected": "Miguel", "got": "Miguel Treviño" }, "text": "Miguel Treviño Morales" }, "Q5576768/surname2-mx/givenFirst": { "family": { "expected": "Encinas González", "got": "González" }, "given_names": { "expected": "Carlos", "got": "Carlos Encinas" }, "text": "Carlos Encinas González" }, "Q55769527/surname2-es/givenFirst": { "family": { "expected": "Rodríguez Osorio", "got": "Osorio" }, "given_names": { "expected": "Eduardo", "got": "Eduardo Rodríguez" }, "text": "Eduardo Rodríguez Osorio" }, "Q55949540/surname2-es/givenFirst": { "family": { "expected": "Arasanz Raso", "got": "Raso" }, "given_names": { "expected": "Joaquín", "got": "Joaquín Arasanz" }, "text": "Joaquín Arasanz Raso" }, "Q55999012/surname2-mx/givenFirst": { "family": { "expected": "Díaz Roig", "got": "Roig" }, "given_names": { "expected": "Mercedes", "got": "Mercedes Díaz" }, "text": "Mercedes Díaz Roig" }, "Q562079/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Þorláksson" }, "given_names": { "expected": "Jón Þorláksson", "got": "Jón" }, "text": "Jón Þorláksson" }, "Q5667134/surname2-co/givenFirst": { "family": { "expected": "Reyes Echandía", "got": "Echandía" }, "given_names": { "expected": "Alfonso", "got": "Alfonso Reyes" }, "text": "Alfonso Reyes Echandía" }, "Q5677081/surname2-co/givenFirst": { "family": { "expected": "Zalamea Borda", "got": "Borda" }, "given_names": { "expected": "Eduardo", "got": "Eduardo Zalamea" }, "text": "Eduardo Zalamea Borda" }, "Q5696422/surname2-co/givenFirst": { "family": { "expected": "Osorio Lizarazo", "got": "Lizarazo" }, "given_names": { "expected": "José Antonio", "got": "José Antonio Osorio" }, "text": "José Antonio Osorio Lizarazo" }, "Q5704474/surname2-mx/givenFirst": { "family": { "expected": "Domínguez Borrás", "got": "Borrás" }, "given_names": { "expected": "Armando", "got": "Armando Domínguez" }, "text": "Armando Domínguez Borrás" }, "Q5711984/surname2-mx/givenFirst": { "family": { "expected": "García Lesmes", "got": "Lesmes" }, "given_names": { "expected": "Aurelio", "got": "Aurelio García" }, "text": "Aurelio García Lesmes" }, "Q5751162/surname2-co/givenFirst": { "family": { "expected": "Obregón Borrero", "got": "Borrero" }, "given_names": { "expected": "Carlos", "got": "Carlos Obregón" }, "text": "Carlos Obregón Borrero" }, "Q57553590/surname2-es/givenFirst": { "family": { "expected": "Vidal Estévez", "got": "Estévez" }, "given_names": { "expected": "Engracia", "got": "Engracia Vidal" }, "text": "Engracia Vidal Estévez" }, "Q57675/patronymic-my/givenFirst": { "family": { "expected": "", "got": "bin Abdul Razak" }, "given_names": { "expected": "Mohd Najib bin Abdul Razak", "got": "Mohd Najib" }, "text": "Mohd Najib bin Abdul Razak" }, "Q57772/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Sigurðardóttir" }, "given_names": { "expected": "Jóhanna Sigurðardóttir", "got": "Jóhanna" }, "text": "Jóhanna Sigurðardóttir" }, "Q5806647/surname2-co/givenFirst": { "family": { "expected": "Uribe Vargas", "got": "Vargas" }, "given_names": { "expected": "Diego", "got": "Diego Uribe" }, "text": "Diego Uribe Vargas" }, "Q5846831/surname2-es/givenFirst": { "family": { "expected": "Francos Rodríguez", "got": "Rodríguez" }, "given_names": { "expected": "José", "got": "José Francos" }, "text": "José Francos Rodríguez" }, "Q585266/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Sigmarsson" }, "given_names": { "expected": "Jón Páll Sigmarsson", "got": "Jón Páll" }, "text": "Jón Páll Sigmarsson" }, "Q5858108/surname2-mx/givenFirst": { "family": { "expected": "Bermejo Araujo", "got": "Araujo" }, "given_names": { "expected": "Felipe", "got": "Felipe Bermejo" }, "text": "Felipe Bermejo Araujo" }, "Q5859665/surname2-es/givenFirst": { "family": { "expected": "Gordillo Escudero", "got": "Escudero" }, "given_names": { "expected": "Fernando", "got": "Fernando Gordillo" }, "text": "Fernando Gordillo Escudero" }, "Q5859707/surname2-mx/givenFirst": { "family": { "expected": "Hiriart Balderrama", "got": "Balderrama" }, "given_names": { "expected": "Fernando", "got": "Fernando Hiriart" }, "text": "Fernando Hiriart Balderrama" }, "Q5865541/surname2-mx/givenFirst": { "family": { "expected": "Díez-Barroso Govantes", "got": "Govantes" }, "given_names": { "expected": "Francisco", "got": "Francisco Díez-Barroso" }, "text": "Francisco Díez-Barroso Govantes" }, "Q5866696/surname2-es/givenFirst": { "family": { "expected": "Martínez de Cenicero", "got": "de Cenicero" }, "given_names": { "expected": "Francisco", "got": "Francisco Martínez" }, "text": "Francisco Martínez de Cenicero" }, "Q5888901/surname2-mx/givenFirst": { "family": { "expected": "Rousset Banda", "got": "Banda" }, "given_names": { "expected": "Guillermo", "got": "Guillermo Rousset" }, "text": "Guillermo Rousset Banda" }, "Q5889711/surname2-mx/givenFirst": { "family": { "expected": "León González", "got": "González" }, "given_names": { "expected": "Gustavo", "got": "Gustavo León" }, "text": "Gustavo León González" }, "Q5894640/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Hilmarsdóttir" }, "given_names": { "expected": "Hera Hilmarsdóttir", "got": "Hera" }, "text": "Hera Hilmarsdóttir" }, "Q59105/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Sigurðsson" }, "given_names": { "expected": "Gylfi Sigurðsson", "got": "Gylfi" }, "text": "Gylfi Sigurðsson" }, "Q5924935/surname2-es/givenFirst": { "family": { "expected": "Blanco García", "got": "García" }, "given_names": { "expected": "Jaime", "got": "Jaime Blanco" }, "text": "Jaime Blanco García" }, "Q5925060/surname2-mx/givenFirst": { "family": { "expected": "García Terrés", "got": "Terrés" }, "given_names": { "expected": "Jaime", "got": "Jaime García" }, "text": "Jaime García Terrés" }, "Q5930137/surname2-es/givenFirst": { "family": { "expected": "Gallego Marquina", "got": "Marquina" }, "given_names": { "expected": "Jesús", "got": "Jesús Gallego" }, "text": "Jesús Gallego Marquina" }, "Q5932430/surname2-es/givenFirst": { "family": { "expected": "Traggia Uribarri", "got": "Uribarri" }, "given_names": { "expected": "Joaquín", "got": "Joaquín Traggia" }, "text": "Joaquín Traggia Uribarri" }, "Q5942856/surname2-es/givenFirst": { "family": { "expected": "Díaz Casariego", "got": "Casariego" }, "given_names": { "expected": "José María", "got": "José María Díaz" }, "text": "José María Díaz Casariego" }, "Q5943286/surname2-es/givenFirst": { "family": { "expected": "Otaolaurruchi Tobía", "got": "Tobía" }, "given_names": { "expected": "José María", "got": "José María Otaolaurruchi" }, "text": "José María Otaolaurruchi Tobía" }, "Q5952614/surname2-es/givenFirst": { "family": { "expected": "Suñé Benages", "got": "Benages" }, "given_names": { "expected": "Juan", "got": "Juan Suñé" }, "text": "Juan Suñé Benages" }, "Q5952704/surname2-es/givenFirst": { "family": { "expected": "Torres de Vera y Aragón", "got": "de Vera y Aragón" }, "given_names": { "expected": "Juan", "got": "Juan Torres" }, "text": "Juan Torres de Vera y Aragón" }, "Q59747508/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Björnsdóttir" }, "given_names": { "expected": "Hansína Regína Björnsdóttir", "got": "Hansína Regína" }, "text": "Hansína Regína Björnsdóttir" }, "Q6055948/surname2-co/givenFirst": { "family": { "expected": "Riveros Reina", "got": "Reina" }, "given_names": { "expected": "Pablo Emilio", "got": "Pablo Emilio Riveros" }, "text": "Pablo Emilio Riveros Reina" }, "Q606189/surname2-co/givenFirst": { "family": { "expected": "Alvarez Lleras", "got": "Lleras" }, "given_names": { "expected": "Antonio", "got": "Antonio Alvarez" }, "text": "Antonio Alvarez Lleras" }, "Q6068853/surname2-mx/givenFirst": { "family": { "expected": "Galindo Galarza", "got": "Galarza" }, "given_names": { "expected": "Pedro", "got": "Pedro Galindo" }, "text": "Pedro Galindo Galarza" }, "Q6069555/surname2-co/givenFirst": { "family": { "expected": "Morales Pino", "got": "Pino" }, "given_names": { "expected": "Pedro", "got": "Pedro Morales" }, "text": "Pedro Morales Pino" }, "Q6097928/surname2-es/givenFirst": { "family": { "expected": "Rubio Carrión", "got": "Carrión" }, "given_names": { "expected": "Rafael", "got": "Rafael Rubio" }, "text": "Rafael Rubio Carrión" }, "Q6109726/surname2-co/givenFirst": { "family": { "expected": "Escobar Gaviria", "got": "Gaviria" }, "given_names": { "expected": "Roberto", "got": "Roberto Escobar" }, "text": "Roberto Escobar Gaviria" }, "Q6110535/surname2-mx/givenFirst": { "family": { "expected": "Landeros Gallegos", "got": "Gallegos" }, "given_names": { "expected": "Rodolfo", "got": "Rodolfo Landeros" }, "text": "Rodolfo Landeros Gallegos" }, "Q6146414/surname2-es/givenFirst": { "family": { "expected": "Pérez Cuervo", "got": "Cuervo" }, "given_names": { "expected": "Tiburcio", "got": "Tiburcio Pérez" }, "text": "Tiburcio Pérez Cuervo" }, "Q6161316/surname2-es/givenFirst": { "family": { "expected": "Rodríguez Casado", "got": "Casado" }, "given_names": { "expected": "Vicente", "got": "Vicente Rodríguez" }, "text": "Vicente Rodríguez Casado" }, "Q6165597/surname2-co/givenFirst": { "family": { "expected": "Luna Gómez", "got": "Gómez" }, "given_names": { "expected": "Víctor", "got": "Víctor Luna" }, "text": "Víctor Luna Gómez" }, "Q61697989/surname2-mx/givenFirst": { "family": { "expected": "Azcárraga Madero", "got": "Madero" }, "given_names": { "expected": "Rogerio", "got": "Rogerio Azcárraga" }, "text": "Rogerio Azcárraga Madero" }, "Q61743963/surname2-es/givenFirst": { "family": { "expected": "Iglesias Pérez", "got": "Pérez" }, "given_names": { "expected": "Tomás", "got": "Tomás Iglesias" }, "text": "Tomás Iglesias Pérez" }, "Q61972124/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Örnólfsdóttir" }, "given_names": { "expected": "Arnbjörg Auður Örnólfsdóttir", "got": "Arnbjörg Auður" }, "text": "Arnbjörg Auður Örnólfsdóttir" }, "Q61972280/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Sigurðsson" }, "given_names": { "expected": "Dóra Sigurðsson", "got": "Dóra" }, "text": "Dóra Sigurðsson" }, "Q61972351/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Erlingsdóttir" }, "given_names": { "expected": "Elísabet Erlingsdóttir", "got": "Elísabet" }, "text": "Elísabet Erlingsdóttir" }, "Q61972491/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Elíasdóttir" }, "given_names": { "expected": "Guðmunda Elíasdóttir", "got": "Guðmunda" }, "text": "Guðmunda Elíasdóttir" }, "Q61973674/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Jónsdóttir" }, "given_names": { "expected": "Sigrún Jónsdóttir", "got": "Sigrún" }, "text": "Sigrún Jónsdóttir" }, "Q61974032/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Pálsdóttir" }, "given_names": { "expected": "Þuríður Pálsdóttir", "got": "Þuríður" }, "text": "Þuríður Pálsdóttir" }, "Q62837944/surname2-es/givenFirst": { "family": { "expected": "Lastra Santos", "got": "Santos" }, "given_names": { "expected": "Laura Ángeles", "got": "Laura Ángeles Lastra" }, "text": "Laura Ángeles Lastra Santos" }, "Q62995489/surname2-mx/givenFirst": { "family": { "expected": "Castillo Santiago", "got": "Santiago" }, "given_names": { "expected": "Rosa", "got": "Rosa Castillo" }, "text": "Rosa Castillo Santiago" }, "Q63761164/surname2-es/givenFirst": { "family": { "expected": "Fernández Alonso", "got": "Alonso" }, "given_names": { "expected": "Aniceto", "got": "Aniceto Fernández" }, "text": "Aniceto Fernández Alonso" }, "Q63970106/surname2-co/givenFirst": { "family": { "expected": "Abello Vives", "got": "Vives" }, "given_names": { "expected": "Alberto", "got": "Alberto Abello" }, "text": "Alberto Abello Vives" }, "Q64509480/surname2-es/givenFirst": { "family": { "expected": "García Martínez", "got": "Martínez" }, "given_names": { "expected": "Miguel", "got": "Miguel García" }, "text": "Miguel García Martínez" }, "Q66464825/surname2-mx/givenFirst": { "family": { "expected": "Landín Martínez", "got": "Martínez" }, "given_names": { "expected": "Carlos", "got": "Carlos Landín" }, "text": "Carlos Landín Martínez" }, "Q6672254/patronymic-my/givenFirst": { "family": { "expected": "", "got": "Ibrahim" }, "given_names": { "expected": "Azhar Ibrahim", "got": "Azhar" }, "text": "Azhar Ibrahim" }, "Q6737805/surname2-es/givenFirst": { "family": { "expected": "Iglesias Añino", "got": "Añino" }, "given_names": { "expected": "Eduardo", "got": "Eduardo Iglesias" }, "text": "Eduardo Iglesias Añino" }, "Q6762303/surname2-mx/givenFirst": { "family": { "expected": "Aguilar Barrera", "got": "Barrera" }, "given_names": { "expected": "Mariano", "got": "Mariano Aguilar" }, "text": "Mariano Aguilar Barrera" }, "Q690876/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Gunnlaugsson" }, "given_names": { "expected": "Arnar Gunnlaugsson", "got": "Arnar" }, "text": "Arnar Gunnlaugsson" }, "Q6963798/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Hilmarsdóttir" }, "given_names": { "expected": "Nanna Bryndís Hilmarsdóttir", "got": "Nanna Bryndís" }, "text": "Nanna Bryndís Hilmarsdóttir" }, "Q701362/surname2-es/givenFirst": { "family": { "expected": "Mayor Oreja", "got": "Oreja" }, "given_names": { "expected": "Jaime", "got": "Jaime Mayor" }, "text": "Jaime Mayor Oreja" }, "Q723705/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Viðarsson" }, "given_names": { "expected": "Bjarni Viðarsson", "got": "Bjarni" }, "text": "Bjarni Viðarsson" }, "Q728833/surname2-co/givenFirst": { "family": { "expected": "Pastrana Borrero", "got": "Borrero" }, "given_names": { "expected": "Misael Eduardo", "got": "Misael Eduardo Pastrana" }, "text": "Misael Eduardo Pastrana Borrero" }, "Q732502/surname2-mx/givenFirst": { "family": { "expected": "Legorreta Vilchis", "got": "Vilchis" }, "given_names": { "expected": "Ricardo", "got": "Ricardo Legorreta" }, "text": "Ricardo Legorreta Vilchis" }, "Q7429440/surname2-mx/givenFirst": { "family": { "expected": "González Herrera", "got": "Herrera" }, "given_names": { "expected": "Saúl", "got": "Saúl González" }, "text": "Saúl González Herrera" }, "Q7882106/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Guðmundsdóttir" }, "given_names": { "expected": "Una Guðmundsdóttir", "got": "Una" }, "text": "Una Guðmundsdóttir" }, "Q81619240/surname2-es/givenFirst": { "family": { "expected": "Aguirre Miramón", "got": "Miramón" }, "given_names": { "expected": "Severo", "got": "Severo Aguirre" }, "text": "Severo Aguirre Miramón" }, "Q817764/surname2-mx/givenFirst": { "family": { "expected": "Castillo Plascencia", "got": "Plascencia" }, "given_names": { "expected": "Benjamín", "got": "Benjamín Castillo" }, "text": "Benjamín Castillo Plascencia" }, "Q8198059/surname2-es/givenFirst": { "family": { "expected": "Martínez Sagi", "got": "Sagi" }, "given_names": { "expected": "Anna Maria", "got": "Anna Maria Martínez" }, "text": "Anna Maria Martínez Sagi" }, "Q8338233/surname2-mx/givenFirst": { "family": { "expected": "Romero Deschamps", "got": "Deschamps" }, "given_names": { "expected": "Carlos", "got": "Carlos Romero" }, "text": "Carlos Romero Deschamps" }, "Q83983039/surname2-es/givenFirst": { "family": { "expected": "Alcolea Fernández", "got": "Fernández" }, "given_names": { "expected": "Jesús", "got": "Jesús Alcolea" }, "text": "Jesús Alcolea Fernández" }, "Q85632107/surname2-co/givenFirst": { "family": { "expected": "Hurtado Montaño", "got": "del Pilar Hurtado Montaño" }, "given_names": { "expected": "María del Pilar", "got": "María" }, "text": "María del Pilar Hurtado Montaño" }, "Q85689041/surname2-co/givenFirst": { "family": { "expected": "Parra Uribe", "got": "Uribe" }, "given_names": { "expected": "Alexander", "got": "Alexander Parra" }, "text": "Alexander Parra Uribe" }, "Q85740163/surname2-es/givenFirst": { "family": { "expected": "Gómez Mier", "got": "Mier" }, "given_names": { "expected": "Vicente", "got": "Vicente Gómez" }, "text": "Vicente Gómez Mier" }, "Q87124000/surname2-es/givenFirst": { "family": { "expected": "Abad Miró", "got": "Miró" }, "given_names": { "expected": "Gabriela", "got": "Gabriela Abad" }, "text": "Gabriela Abad Miró" }, "Q879568/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Bjarnason" }, "given_names": { "expected": "Björn Bjarnason", "got": "Björn" }, "text": "Björn Bjarnason" }, "Q8846206/surname2-mx/givenFirst": { "family": { "expected": "Ríos Alemán", "got": "Alemán" }, "given_names": { "expected": "Eulalio", "got": "Eulalio Ríos" }, "text": "Eulalio Ríos Alemán" }, "Q88677122/surname2-es/givenFirst": { "family": { "expected": "Perianes Carro", "got": "Carro" }, "given_names": { "expected": "José", "got": "José Perianes" }, "text": "José Perianes Carro" }, "Q9023260/surname2-es/givenFirst": { "family": { "expected": "Ferrer Martí", "got": "Martí" }, "given_names": { "expected": "Llorenç", "got": "Llorenç Ferrer" }, "text": "Llorenç Ferrer Martí" }, "Q9028171/surname2-mx/givenFirst": { "family": { "expected": "Sánchez Mármol", "got": "Mármol" }, "given_names": { "expected": "Manuel", "got": "Manuel Sánchez" }, "text": "Manuel Sánchez Mármol" }, "Q9030148/surname2-mx/givenFirst": { "family": { "expected": "Rubio Tejero", "got": "Tejero" }, "given_names": { "expected": "María", "got": "María Rubio" }, "text": "María Rubio Tejero" }, "Q9065896/surname2-es/givenFirst": { "family": { "expected": "Ruiz Balerdi", "got": "Balerdi" }, "given_names": { "expected": "Rafael", "got": "Rafael Ruiz" }, "text": "Rafael Ruiz Balerdi" }, "Q9072991/surname2-mx/givenFirst": { "family": { "expected": "Aguirre Anguiano", "got": "Anguiano" }, "given_names": { "expected": "Salvador", "got": "Salvador Aguirre" }, "text": "Salvador Aguirre Anguiano" }, "Q9157913/surname2-mx/givenFirst": { "family": { "expected": "López Aviña", "got": "Aviña" }, "given_names": { "expected": "Antonio", "got": "Antonio López" }, "text": "Antonio López Aviña" }, "Q93221888/surname2-mx/givenFirst": { "family": { "expected": "Aguayo Spencer", "got": "Spencer" }, "given_names": { "expected": "Rafael", "got": "Rafael Aguayo" }, "text": "Rafael Aguayo Spencer" }, "Q93419447/surname2-es/givenFirst": { "family": { "expected": "López de Abiada", "got": "de Abiada" }, "given_names": { "expected": "José Manuel", "got": "José Manuel López" }, "text": "José Manuel López de Abiada" }, "Q93432465/surname2-es/givenFirst": { "family": { "expected": "Luzón Cuesta", "got": "Cuesta" }, "given_names": { "expected": "José María", "got": "José María Luzón" }, "text": "José María Luzón Cuesta" }, "Q94643621/surname2-mx/givenFirst": { "family": { "expected": "López Moreno", "got": "Moreno" }, "given_names": { "expected": "Jacinto", "got": "Jacinto López" }, "text": "Jacinto López Moreno" }, "Q956043/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Hrafnsson" }, "given_names": { "expected": "Kristinn Hrafnsson", "got": "Kristinn" }, "text": "Kristinn Hrafnsson" }, "Q959666/surname2-co/givenFirst": { "family": { "expected": "Luque Sánchez", "got": "Sánchez" }, "given_names": { "expected": "Crisanto", "got": "Crisanto Luque" }, "text": "Crisanto Luque Sánchez" }, "Q96060653/surname2-mx/givenFirst": { "family": { "expected": "Bueno Sánchez", "got": "Sánchez" }, "given_names": { "expected": "Francis Anel", "got": "Francis Anel Bueno" }, "text": "Francis Anel Bueno Sánchez" }, "Q96063461/surname2-es/givenFirst": { "family": { "expected": "Fortea Pérez", "got": "Pérez" }, "given_names": { "expected": "Francisco Javier", "got": "Francisco Javier Fortea" }, "text": "Francisco Javier Fortea Pérez" }, "Q970917/surname2-co/givenFirst": { "family": { "expected": "Camacho Macías", "got": "Macías" }, "given_names": { "expected": "Nemesio", "got": "Nemesio Camacho" }, "text": "Nemesio Camacho Macías" }, "Q97413787/patronymic-my/givenFirst": { "family": { "expected": "", "got": "bin Malek" }, "given_names": { "expected": "Anuar bin Malek", "got": "Anuar" }, "text": "Anuar bin Malek" }, "Q975791/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Árnason" }, "given_names": { "expected": "Jón Árnason", "got": "Jón" }, "text": "Jón Árnason" }, "Q98069704/surname2-es/givenFirst": { "family": { "expected": "Fernández Seijo", "got": "Seijo" }, "given_names": { "expected": "Pilar", "got": "Pilar Fernández" }, "text": "Pilar Fernández Seijo" }, "Q984003/patronymic-is/givenFirst": { "family": { "expected": "", "got": "Helgason" }, "given_names": { "expected": "Jón Helgason", "got": "Jón" }, "text": "Jón Helgason" }, "Q99318879/surname2-es/givenFirst": { "family": { "expected": "Santos Fernández", "got": "Fernández" }, "given_names": { "expected": "Juan", "got": "Juan Santos" }, "text": "Juan Santos Fernández" } } ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/wikidata_fields.jsonl��������������������������0000664�0000000�0000000�00000456654�15273032717�0026721�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������{"composed": false, "fields": {"given": "Rafael", "surname": "Viciana", "surname2": "Aráez"}, "file": "Q100755460", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Rafael Viciana Aráez"} {"composed": false, "fields": {"given": "Пётр", "patronymic": "Николаевич", "surname": "Бычков"}, "file": "Q101644147", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Бычков, Пётр Николаевич"} {"composed": false, "fields": {"given": "Pompilio", "surname": "Gutiérrez", "surname2": "Arango"}, "file": "Q101688196", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Pompilio Gutiérrez Arango"} {"composed": false, "fields": {"given": "Любовь", "patronymic": "Митрофановна", "surname": "Шуляк"}, "file": "Q102398858", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Любовь Митрофановна Шуляк"} {"composed": false, "fields": {"given": "Василий", "patronymic": "Васильевич", "surname": "Смыслов"}, "file": "Q104148", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Смыслов, Василий Васильевич"} {"composed": false, "fields": {"given": "Román", "surname": "García", "surname2": "Rodrigo"}, "file": "Q104155694", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Román García Rodrigo"} {"composed": false, "fields": {"given": "Carlos", "surname": "Gaviria", "surname2": "Díaz"}, "file": "Q1042940", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Carlos Gaviria Díaz"} {"composed": false, "fields": {"given": "Carlos", "surname": "Girón", "surname2": "Gutiérrez"}, "file": "Q1042947", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Carlos Girón Gutiérrez"} {"composed": false, "fields": {"given": "Julio Roberto", "surname": "Gómez", "surname2": "Esguerra"}, "file": "Q105208032", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Julio Roberto Gómez Esguerra"} {"composed": false, "fields": {"given": "Александр", "patronymic": "Андреевич", "surname": "Клюшников"}, "file": "Q106089866", "label": "patronymic-ri", "order": "givenFirst", "shape": null, "text": "Александр Андреевич Клюшников"} {"composed": false, "fields": {"given": "Juan", "surname": "Anglada", "surname2": "y Ruiz"}, "file": "Q106272670", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Juan Anglada y Ruiz"} {"composed": false, "fields": {"given": "José", "surname": "Camacho", "surname2": "Carrizosa"}, "file": "Q106561769", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "José Camacho Carrizosa"} {"composed": false, "fields": {"given": "Libardo", "surname": "López", "surname2": "Gómez"}, "file": "Q106769309", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Libardo López Gómez"} {"composed": false, "fields": {"given": "Дарья", "patronymic": "Алексеевна", "surname": "Навальная"}, "file": "Q107182509", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Дарья Алексеевна Навальная"} {"composed": false, "fields": {"given": "José Luis", "surname": "Covarrubias", "surname2": "Garza"}, "file": "Q107542992", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "José Luis Covarrubias Garza"} {"composed": false, "fields": {"given": "Gabriel Jaime", "surname": "Santamaría", "surname2": "Montoya"}, "file": "Q108111287", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Gabriel Jaime Santamaría Montoya"} {"composed": false, "fields": {"given": "Александр", "patronymic": "Владимирович", "surname": "Кессених"}, "file": "Q108702510", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Александр Владимирович Кессених"} {"composed": false, "fields": {"given": "Jesús Ángel", "surname": "González", "surname2": "Arias"}, "file": "Q109044495", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Jesús Ángel González Arias"} {"composed": false, "fields": {"given": "Павел", "patronymic": "Терентьевич", "surname": "Вишняков"}, "file": "Q109484835", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Вишняков, Павел Терентьевич"} {"composed": false, "fields": {"given": "Алексей", "patronymic": "Алексеевич", "surname": "Никитин"}, "file": "Q109485696", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Алексей Алексеевич Никитин"} {"composed": false, "fields": {"given": "Алексей", "patronymic": "Алексеевич", "surname": "Никитин"}, "file": "Q109485696", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Никитин, Алексей Алексеевич"} {"composed": false, "fields": {"given": "Владимир", "patronymic": "Михайлович", "surname": "Штейн"}, "file": "Q109486438", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Владимир Михайлович Штейн"} {"composed": false, "fields": {"given": "Владимир", "patronymic": "Михайлович", "surname": "Повалишин"}, "file": "Q109486503", "label": "patronymic-ri", "order": "sorting", "shape": null, "text": "Повалишин, Владимир Михайлович"} {"composed": false, "fields": {"given": "Владимир", "patronymic": "Афанасьевич", "surname": "Фатеев"}, "file": "Q109487083", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Владимир Афанасьевич Фатеев"} {"composed": false, "fields": {"given": "Евгений", "patronymic": "Фёдорович", "surname": "Рязанов"}, "file": "Q109487989", "label": "patronymic-su", "order": "sorting", "shape": null, "text": "Рязанов, Евгений Фёдорович"} {"composed": false, "fields": {"given": "Александр", "patronymic": "Емельянович", "surname": "Воронцов"}, "file": "Q109488265", "label": "patronymic-su", "order": "sorting", "shape": null, "text": "Воронцов, Александр Емельянович"} {"composed": false, "fields": {"given": "Алексей", "patronymic": "Семёнович", "surname": "Сечкин"}, "file": "Q109491939", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Алексей Семёнович Сечкин"} {"composed": false, "fields": {"given": "Дмитрий", "patronymic": "Владимирович", "surname": "Шевчик"}, "file": "Q109621635", "label": "patronymic-su", "order": "sorting", "shape": null, "text": "Шевчик, Дмитрий Владимирович"} {"composed": false, "fields": {"given": "Javier", "surname": "López", "surname2": "Díaz"}, "file": "Q109738043", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Javier López Díaz"} {"composed": false, "fields": {"given": "Залман", "patronymic": "Иосифович", "surname": "Якуб"}, "file": "Q109831094", "label": "patronymic-su", "order": "givenFirst", "shape": null, "text": "Залман Иосифович Якуб"} {"composed": false, "fields": {"given": "Carlos", "surname": "Sáenz", "surname2": "Echeverría"}, "file": "Q109853607", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Carlos Sáenz Echeverría"} {"composed": false, "fields": {"given": "Emilio", "surname": "Murillo", "surname2": "Chapull"}, "file": "Q110144093", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Emilio Murillo Chapull"} {"composed": false, "fields": {"given": "Борис", "patronymic": "Алексеевич", "surname": "Зозуля"}, "file": "Q110165407", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Борис Алексеевич Зозуля"} {"composed": false, "fields": {"given": "Carlos", "surname": "Uribe", "surname2": "Cordovez"}, "file": "Q110463644", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Carlos Uribe Cordovez"} {"composed": false, "fields": {"given": "Михаил", "patronymic": "Петрович", "surname": "Зорин"}, "file": "Q110721389", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Зорин, Михаил Петрович"} {"composed": false, "fields": {"given": "Juan Antonio", "surname": "de los Heros", "surname2": "Fernández"}, "file": "Q111020949", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Juan Antonio de los Heros Fernández"} {"composed": false, "fields": {"given": "Павел", "patronymic": "Алексеевич", "surname": "Рубцов"}, "file": "Q111080043", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Павел Алексеевич Рубцов"} {"composed": false, "fields": {"given": "Teógenes", "surname": "Díaz-Corralejo", "surname2": "Fernández"}, "file": "Q111234704", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Teógenes Díaz-Corralejo Fernández"} {"composed": false, "fields": {"given": "María Paz", "surname": "Alonso", "surname2": "Romero"}, "file": "Q111473046", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "María Paz Alonso Romero"} {"composed": false, "fields": {"given": "Orlando", "surname": "Arvizu", "surname2": "Lara"}, "file": "Q111523698", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Orlando Arvizu Lara"} {"composed": false, "fields": {"given": "Patricia", "surname": "Terrazas", "surname2": "Allen"}, "file": "Q111603657", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Patricia Terrazas Allen"} {"composed": false, "fields": {"given": "Sigurður", "patronymic": "Ólafsson"}, "file": "Q112116024", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Sigurður Ólafsson"} {"composed": false, "fields": {"given": "Bjarni", "patronymic": "Böðvarsson"}, "file": "Q112116260", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Bjarni Böðvarsson"} {"composed": false, "fields": {"given": "Soffía", "patronymic": "Karlsdóttir"}, "file": "Q112116353", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Soffía Karlsdóttir"} {"composed": false, "fields": {"given": "Ketill", "patronymic": "Jensson"}, "file": "Q112128748", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Ketill Jensson"} {"composed": false, "fields": {"given": "Magnús", "patronymic": "Jónsson"}, "file": "Q112130218", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Magnús Jónsson"} {"composed": false, "fields": {"given": "Skapti", "patronymic": "Ólafsson"}, "file": "Q112130399", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Skapti Ólafsson"} {"composed": false, "fields": {"given": "Jón", "patronymic": "Sigurðsson"}, "file": "Q112131085", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Jón Sigurðsson"} {"composed": false, "fields": {"given": "Gerður", "patronymic": "Benediktsdóttir"}, "file": "Q112131257", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Gerður Benediktsdóttir"} {"composed": false, "fields": {"given": "Kristján", "patronymic": "Kristjánsson"}, "file": "Q112135101", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Kristján Kristjánsson"} {"composed": false, "fields": {"given": "Sigurdór", "patronymic": "Sigurdórsson"}, "file": "Q112135757", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Sigurdór Sigurdórsson"} {"composed": false, "fields": {"given": "Rúnar", "patronymic": "Gunnarsson"}, "file": "Q112184094", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Rúnar Gunnarsson"} {"composed": false, "fields": {"given": "Grettir", "patronymic": "Björnsson"}, "file": "Q112184468", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Grettir Björnsson"} {"composed": false, "fields": {"given": "Jóhann", "patronymic": "Konráðsson"}, "file": "Q112208455", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Jóhann Konráðsson"} {"composed": false, "fields": {"given": "Karl", "patronymic": "Einarsson"}, "file": "Q112340787", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Karl Einarsson"} {"composed": false, "fields": {"given": "Ólafur Þ.", "patronymic": "Jónsson"}, "file": "Q112455664", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Ólafur Þ. Jónsson"} {"composed": false, "fields": {"given": "Юрий", "patronymic": "Иванович", "surname": "Смирнов"}, "file": "Q112506734", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Юрий Иванович Смирнов"} {"composed": false, "fields": {"given": "Eiður", "patronymic": "Gunnarsson"}, "file": "Q112579257", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Eiður Gunnarsson"} {"composed": false, "fields": {"given": "Guðmundur Óli", "patronymic": "Þorláksson"}, "file": "Q112579515", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Guðmundur Óli Þorláksson"} {"composed": false, "fields": {"given": "Константин", "patronymic": "Михайлович", "surname": "Попов"}, "file": "Q112666922", "label": "patronymic-su", "order": "givenFirst", "shape": null, "text": "Константин Михайлович Попов"} {"composed": false, "fields": {"given": "Francisco", "surname": "Hernández", "surname2": "Pla"}, "file": "Q112723929", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Francisco Hernández Pla"} {"composed": false, "fields": {"given": "Виктор", "patronymic": "Иванович", "surname": "Химченко"}, "file": "Q112994217", "label": "patronymic-su", "order": "sorting", "shape": null, "text": "Химченко, Виктор Иванович"} {"composed": false, "fields": {"given": "Валентина", "patronymic": "Васильевна", "surname": "Агеева"}, "file": "Q112994560", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Агеева, Валентина Васильевна"} {"composed": false, "fields": {"given": "Libardo", "surname": "Simancas", "surname2": "Torres"}, "file": "Q113001057", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Libardo Simancas Torres"} {"composed": false, "fields": {"given": "Fernando", "surname": "Rosas", "surname2": "Solis"}, "file": "Q113129508", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Fernando Rosas Solis"} {"composed": false, "fields": {"given": "Николай", "patronymic": "Николаевич", "surname": "Пикус"}, "file": "Q113469289", "label": "patronymic-su", "order": "givenFirst", "shape": null, "text": "Николай Николаевич Пикус"} {"composed": false, "fields": {"given": "Fabián", "surname": "Pérez", "surname2": "Ximeno"}, "file": "Q113514978", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Fabián Pérez Ximeno"} {"composed": false, "fields": {"given": "Luis", "surname": "Díaz", "surname2": "Moreno"}, "file": "Q113548032", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Luis Díaz Moreno"} {"composed": false, "fields": {"given": "Justo", "surname": "Novo", "surname2": "Verdes"}, "file": "Q113557761", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Justo Novo Verdes"} {"composed": false, "fields": {"given": "Дарья", "patronymic": "Александровна", "surname": "Дугина"}, "file": "Q113568088", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Дарья Александровна Дугина"} {"composed": false, "fields": {"given": "José", "surname": "Gil", "surname2": "Pérez"}, "file": "Q114799707", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "José Gil Pérez"} {"composed": false, "fields": {"given": "Ricardo", "surname": "Bodenlle", "surname2": "Corral"}, "file": "Q114973166", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Ricardo Bodenlle Corral"} {"composed": false, "fields": {"given": "Иван", "patronymic": "Алексеевич", "surname": "Демидов"}, "file": "Q115325153", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Демидов, Иван Алексеевич"} {"composed": false, "fields": {"given": "Rubén", "surname": "Rojo", "surname2": "y Pinto"}, "file": "Q1153528", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Rubén Rojo y Pinto"} {"composed": false, "fields": {"given": "Вадим", "patronymic": "Евгеньевич", "surname": "Бойко"}, "file": "Q115495110", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Вадим Евгеньевич Бойко"} {"composed": false, "fields": {"given": "Lisandro", "surname": "Leyva", "surname2": "Mazuera"}, "file": "Q115641830", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Lisandro Leyva Mazuera"} {"composed": false, "fields": {"given": "Анна", "patronymic": "Николаевна", "surname": "Гладкова"}, "file": "Q115734976", "label": "patronymic-ri", "order": "givenFirst", "shape": null, "text": "Анна Николаевна Гладкова"} {"composed": false, "fields": {"given": "Rodrigo", "surname": "Lara", "surname2": "Bonilla"}, "file": "Q116059", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Rodrigo Lara Bonilla"} {"composed": false, "fields": {"given": "Елена", "patronymic": "Вячеславовна", "surname": "Дементьева"}, "file": "Q11667", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Елена Вячеславовна Дементьева"} {"composed": false, "fields": {"given": "Juan", "surname": "Cárdenas", "surname2": "Arroyo"}, "file": "Q11686420", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Juan Cárdenas Arroyo"} {"composed": false, "fields": {"given": "Сергей", "patronymic": "Васильевич", "surname": "Федоренко"}, "file": "Q116926100", "label": "patronymic-su", "order": "sorting", "shape": null, "text": "Федоренко, Сергей Васильевич"} {"composed": false, "fields": {"given": "Сергей", "patronymic": "Васильевич", "surname": "Федоренко"}, "file": "Q116926100", "label": "patronymic-su", "order": "surnameFirst", "shape": 4, "text": "Федоренко Сергей Васильевич"} {"composed": false, "fields": {"given": "Pierre David", "surname": "Oriola", "surname2": "Garriga"}, "file": "Q11699292", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Pierre David Oriola Garriga"} {"composed": false, "fields": {"given": "Francisco Javier", "surname": "Lorenzo", "surname2": "Oroza"}, "file": "Q117125906", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Francisco Javier Lorenzo Oroza"} {"composed": false, "fields": {"given": "Þorsteinn", "patronymic": "Sveinsson"}, "file": "Q117327794", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Þorsteinn Sveinsson"} {"composed": false, "fields": {"given": "Elvia", "surname": "Rangel", "surname2": "de la Fuente"}, "file": "Q117459965", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Elvia Rangel de la Fuente"} {"composed": false, "fields": {"given": "Manuel Jerónimo", "surname": "Yerena", "surname2": "y Camarena"}, "file": "Q11880808", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Manuel Jerónimo Yerena y Camarena"} {"composed": false, "fields": {"given": "Adolfo", "surname": "Calzado", "surname2": "y Sanjurjo"}, "file": "Q11904189", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Adolfo Calzado y Sanjurjo"} {"composed": false, "fields": {"given": "Antonio", "surname": "Alemany", "surname2": "Dezcallar"}, "file": "Q11905781", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Antonio Alemany Dezcallar"} {"composed": false, "fields": {"given": "Feliu", "surname": "Crespí", "surname2": "i Cirera"}, "file": "Q11921769", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Feliu Crespí i Cirera"} {"composed": false, "fields": {"given": "Josep", "surname": "Campabadal", "surname2": "i Calvet"}, "file": "Q11928486", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Josep Campabadal i Calvet"} {"composed": false, "fields": {"given": "Salvador", "surname": "Ortiga", "surname2": "Torres"}, "file": "Q11946783", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Salvador Ortiga Torres"} {"composed": false, "fields": {"given": "Santiago", "surname": "Díaz", "surname2": "Moyano"}, "file": "Q11947672", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Santiago Díaz Moyano"} {"composed": false, "fields": {"given": "Sinibaldo", "surname": "Gutiérrez", "surname2": "Mas"}, "file": "Q11949860", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Sinibaldo Gutiérrez Mas"} {"composed": false, "fields": {"given": "Enrique", "surname": "Santos", "surname2": "Montejo"}, "file": "Q11957", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Enrique Santos Montejo"} {"composed": false, "fields": {"given": "José Luis", "surname": "Arroyo", "surname2": "Carreras"}, "file": "Q119792168", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "José Luis Arroyo Carreras"} {"composed": false, "fields": {"given": "Luis", "surname": "Guzmán", "surname2": "Reveles"}, "file": "Q119898412", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Luis Guzmán Reveles"} {"composed": false, "fields": {"given": "Hilda Luisa", "surname": "Valdemar", "surname2": "Lima"}, "file": "Q120212131", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Hilda Luisa Valdemar Lima"} {"composed": false, "fields": {"given": "Павел", "patronymic": "Феодосьевич", "surname": "Кальницкий"}, "file": "Q12107891", "label": "patronymic-su", "order": "givenFirst", "shape": null, "text": "Павел Феодосьевич Кальницкий"} {"composed": false, "fields": {"given": "Пётр", "patronymic": "Иванович", "surname": "Почивалов"}, "file": "Q121322436", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Пётр Иванович Почивалов"} {"composed": false, "fields": {"given": "Михаил", "patronymic": "Степанович", "surname": "Полунин"}, "file": "Q12141961", "label": "patronymic-su", "order": "givenFirst", "shape": null, "text": "Михаил Степанович Полунин"} {"composed": false, "fields": {"given": "Павел", "patronymic": "Германович", "surname": "Рубин"}, "file": "Q12149073", "label": "patronymic-ri", "order": "sorting", "shape": null, "text": "Рубин, Павел Германович"} {"composed": false, "fields": {"given": "Emilia", "surname": "Dorado", "surname2": "Baltazar"}, "file": "Q122156972", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Emilia Dorado Baltazar"} {"composed": false, "fields": {"given": "Виктор", "patronymic": "Васильевич", "surname": "Ляпунов"}, "file": "Q122199307", "label": "patronymic-ri", "order": "givenFirst", "shape": null, "text": "Виктор Васильевич Ляпунов"} {"composed": false, "fields": {"given": "Luis Fernando", "surname": "Giraldo", "surname2": "Builes"}, "file": "Q123013116", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Luis Fernando Giraldo Builes"} {"composed": false, "fields": {"given": "Manuel", "surname": "Serrano", "surname2": "Blanco"}, "file": "Q123410560", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Manuel Serrano Blanco"} {"composed": false, "fields": {"given": "Adel", "surname": "López", "surname2": "Gómez"}, "file": "Q123410567", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Adel López Gómez"} {"composed": false, "fields": {"given": "Jorge", "surname": "Valdez", "surname2": "Muñoz"}, "file": "Q123518919", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Jorge Valdez Muñoz"} {"composed": false, "fields": {"given": "Héctor", "surname": "Quintana", "surname2": "Suárez"}, "file": "Q123681393", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Héctor Quintana Suárez"} {"composed": false, "fields": {"given": "Vicente", "surname": "Becerra", "surname2": "Vaca"}, "file": "Q123792443", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Vicente Becerra Vaca"} {"composed": false, "fields": {"given": "María", "surname": "Losada", "surname2": "Estévez"}, "file": "Q123819744", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "María Losada Estévez"} {"composed": false, "fields": {"given": "Antonio", "surname": "Garrido", "surname2": "y Villazán"}, "file": "Q12383078", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Antonio Garrido y Villazán"} {"composed": false, "fields": {"given": "Francisco", "surname": "Giraldo", "surname2": "Hernández"}, "file": "Q124228701", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Francisco Giraldo Hernández"} {"composed": false, "fields": {"given": "Álvaro", "surname": "Londoño", "surname2": "Aristizábal"}, "file": "Q124294405", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Álvaro Londoño Aristizábal"} {"composed": false, "fields": {"given": "Gabriel", "surname": "Latorre", "surname2": "Jaramillo"}, "file": "Q124364476", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Gabriel Latorre Jaramillo"} {"composed": false, "fields": {"given": "Владимир", "patronymic": "Николаевич", "surname": "Богомолов"}, "file": "Q124771331", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Богомолов, Владимир Николаевич"} {"composed": false, "fields": {"given": "Carlos", "surname": "Garcés", "surname2": "Córdoba"}, "file": "Q125025465", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Carlos Garcés Córdoba"} {"composed": false, "fields": {"given": "Пётр", "patronymic": "Петрович", "surname": "Буренин"}, "file": "Q125319126", "label": "patronymic-ri", "order": "givenFirst", "shape": null, "text": "Пётр Петрович Буренин"} {"composed": false, "fields": {"given": "Алексей", "patronymic": "Иванович", "surname": "Давыдов"}, "file": "Q125322517", "label": "patronymic-ri", "order": "givenFirst", "shape": null, "text": "Алексей Иванович Давыдов"} {"composed": false, "fields": {"given": "Пелагея", "patronymic": "Степановна", "surname": "Генералова"}, "file": "Q12558493", "label": "patronymic-su", "order": "givenFirst", "shape": null, "text": "Пелагея Степановна Генералова"} {"composed": false, "fields": {"given": "Miguel", "surname": "Sánchez", "surname2": "Pesquera"}, "file": "Q126949794", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Miguel Sánchez Pesquera"} {"composed": false, "fields": {"given": "Николай", "patronymic": "Андреевич", "surname": "Смирнов"}, "file": "Q126973227", "label": "patronymic-su", "order": "givenFirst", "shape": null, "text": "Николай Андреевич Смирнов"} {"composed": false, "fields": {"given": "Николай", "patronymic": "Порфирьевич", "surname": "Архангельский"}, "file": "Q12818587", "label": "patronymic-su", "order": "givenFirst", "shape": null, "text": "Николай Порфирьевич Архангельский"} {"composed": false, "fields": {"given": "Jon", "surname": "Irazabal", "surname2": "Agirre"}, "file": "Q129570988", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Jon Irazabal Agirre"} {"composed": false, "fields": {"given": "Luis Gonzaga", "surname": "de Villava", "surname2": "y Aybar"}, "file": "Q130199445", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Luis Gonzaga de Villava y Aybar"} {"composed": false, "fields": {"given": "Иван", "patronymic": "Савельевич", "surname": "Фатьянов"}, "file": "Q130223803", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Иван Савельевич Фатьянов"} {"composed": false, "fields": {"given": "Juan Carlos", "surname": "Rodríguez", "surname2": "Agudelo"}, "file": "Q130383803", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Juan Carlos Rodríguez Agudelo"} {"composed": false, "fields": {"given": "Eliseo", "surname": "Jiménez", "surname2": "Ruiz"}, "file": "Q130492677", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Eliseo Jiménez Ruiz"} {"composed": false, "fields": {"given": "Luis", "surname": "Mora", "surname2": "Tovar"}, "file": "Q130546670", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Luis Mora Tovar"} {"composed": false, "fields": {"given": "Diego Alberto", "surname": "Tamayo", "surname2": "Garcera"}, "file": "Q130706816", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Diego Alberto Tamayo Garcera"} {"composed": false, "fields": {"given": "Анна", "patronymic": "Сергеевна", "surname": "Курникова"}, "file": "Q131120", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Анна Сергеевна Курникова"} {"composed": false, "fields": {"given": "Agustín", "surname": "Gómez", "surname2": "Campos"}, "file": "Q131151443", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Agustín Gómez Campos"} {"composed": false, "fields": {"given": "Manuel", "surname": "Velasco", "surname2": "y Martínez"}, "file": "Q131384539", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Manuel Velasco y Martínez"} {"composed": false, "fields": {"given": "Federico", "surname": "Sánchez", "surname2": "Bedoya"}, "file": "Q131387351", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Federico Sánchez Bedoya"} {"composed": false, "fields": {"given": "Jesús", "surname": "Alcántara", "surname2": "Miranda"}, "file": "Q131567217", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Jesús Alcántara Miranda"} {"composed": false, "fields": {"given": "Manuela", "surname": "Lema", "surname2": "Villar"}, "file": "Q131623018", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Manuela Lema Villar"} {"composed": false, "fields": {"given": "Алевтина", "patronymic": "Львовна", "surname": "Юрина"}, "file": "Q131630714", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Алевтина Львовна Юрина"} {"composed": false, "fields": {"given": "Rafael", "surname": "Alonso", "surname2": "y Prieto"}, "file": "Q131701734", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Rafael Alonso y Prieto"} {"composed": false, "fields": {"given": "Илья", "patronymic": "Георгиевич", "surname": "Коробков"}, "file": "Q131722489", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Илья Георгиевич Коробков"} {"composed": false, "fields": {"given": "Илья", "patronymic": "Георгиевич", "surname": "Коробков"}, "file": "Q131722489", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Коробков, Илья Георгиевич"} {"composed": false, "fields": {"given": "Elvira", "surname": "Palafox", "surname2": "Herrán"}, "file": "Q131756403", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Elvira Palafox Herrán"} {"composed": false, "fields": {"given": "Павел", "patronymic": "Ильич", "surname": "Таланкин"}, "file": "Q131976309", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Павел Ильич Таланкин"} {"composed": false, "fields": {"given": "Jorge", "surname": "Lozano", "surname2": "Márquez"}, "file": "Q1330120", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Jorge Lozano Márquez"} {"composed": false, "fields": {"given": "Егор", "patronymic": "Николаевич", "surname": "Свиридов"}, "file": "Q133208265", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Егор Николаевич Свиридов"} {"composed": false, "fields": {"given": "Евгений", "patronymic": "Николаевич", "surname": "Кузнецов"}, "file": "Q133299619", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Кузнецов, Евгений Николаевич"} {"composed": false, "fields": {"given": "Борис", "patronymic": "Владимирович", "surname": "Полянский"}, "file": "Q133346769", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Борис Владимирович Полянский"} {"composed": false, "fields": {"given": "Борис", "patronymic": "Владимирович", "surname": "Полянский"}, "file": "Q133346769", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Полянский, Борис Владимирович"} {"composed": false, "fields": {"given": "Ragnar", "patronymic": "Bjarnason"}, "file": "Q13419335", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Ragnar Bjarnason"} {"composed": false, "fields": {"given": "Jesús María", "surname": "Echavarría", "surname2": "y Aguirre"}, "file": "Q13422888", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Jesús María Echavarría y Aguirre"} {"composed": false, "fields": {"given": "Silverio", "surname": "Cañada", "surname2": "Acebal"}, "file": "Q13423492", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Silverio Cañada Acebal"} {"composed": false, "fields": {"given": "Владимир", "patronymic": "Сергеевич", "surname": "Шкляев"}, "file": "Q134369454", "label": "patronymic-su", "order": "givenFirst", "shape": null, "text": "Владимир Сергеевич Шкляев"} {"composed": false, "fields": {"given": "Willington", "surname": "Baena", "surname2": "Ortiz"}, "file": "Q134423201", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Willington Baena Ortiz"} {"composed": false, "fields": {"given": "Joaquín", "surname": "Vergara", "surname2": "Cárdenas"}, "file": "Q134424045", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Joaquín Vergara Cárdenas"} {"composed": false, "fields": {"given": "Leonardo", "surname": "Porto", "surname2": "Egea"}, "file": "Q134424647", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Leonardo Porto Egea"} {"composed": false, "fields": {"given": "Martín", "surname": "Villazón", "surname2": "Ochoa"}, "file": "Q134424654", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Martín Villazón Ochoa"} {"composed": false, "fields": {"given": "Алексей", "patronymic": "Александрович", "surname": "Жилинский"}, "file": "Q134434774", "label": "patronymic-su", "order": "sorting", "shape": null, "text": "Жилинский, Алексей Александрович"} {"composed": false, "fields": {"given": "Haraldur", "patronymic": "Sigurðsson"}, "file": "Q134479122", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Haraldur Sigurðsson"} {"composed": false, "fields": {"given": "Benigno", "surname": "Cunillera", "surname2": "Torrent"}, "file": "Q134544566", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Benigno Cunillera Torrent"} {"composed": false, "fields": {"given": "Владимир", "patronymic": "Емельянович", "surname": "Бойцов"}, "file": "Q134604187", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Владимир Емельянович Бойцов"} {"composed": false, "fields": {"given": "Владимир", "patronymic": "Емельянович", "surname": "Бойцов"}, "file": "Q134604187", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Бойцов, Владимир Емельянович"} {"composed": false, "fields": {"given": "Ramon", "surname": "Ferran", "surname2": "i Pagès"}, "file": "Q13476315", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Ramon Ferran i Pagès"} {"composed": false, "fields": {"given": "Gilberto", "surname": "Rodríguez", "surname2": "Orejuela"}, "file": "Q1351501", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Gilberto Rodríguez Orejuela"} {"composed": false, "fields": {"given": "Carlos Arturo", "surname": "Romero", "surname2": "Jiménez"}, "file": "Q135270599", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Carlos Arturo Romero Jiménez"} {"composed": false, "fields": {"given": "Salvador", "surname": "Rosas", "surname2": "Magallón"}, "file": "Q135275204", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Salvador Rosas Magallón"} {"composed": false, "fields": {"given": "Consuelo", "surname": "Rodríguez", "surname2": "López"}, "file": "Q13534607", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Consuelo Rodríguez López"} {"composed": false, "fields": {"given": "Ever", "surname": "Peña", "surname2": "Maldonado"}, "file": "Q135792718", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Ever Peña Maldonado"} {"composed": false, "fields": {"given": "Владимир", "patronymic": "Михайлович", "surname": "Дмитриев"}, "file": "Q136085718", "label": "patronymic-su", "order": "surnameFirst", "shape": 4, "text": "Дмитриев Владимир Михайлович"} {"composed": false, "fields": {"given": "Saturnino", "surname": "Rodríguez", "surname2": "Fernández"}, "file": "Q136100927", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Saturnino Rodríguez Fernández"} {"composed": false, "fields": {"given": "Egill", "patronymic": "Eyjólfsson"}, "file": "Q13634266", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Egill Eyjólfsson"} {"composed": false, "fields": {"given": "Leonor", "surname": "Alinari", "surname2": "Adarve"}, "file": "Q136342922", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Leonor Alinari Adarve"} {"composed": false, "fields": {"given": "Adolfo", "surname": "Anta", "surname2": "Seoane"}, "file": "Q136349166", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Adolfo Anta Seoane"} {"composed": false, "fields": {"given": "Juan", "surname": "Samper", "surname2": "Sordo"}, "file": "Q136461155", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Juan Samper Sordo"} {"composed": false, "fields": {"given": "Adiel", "surname": "Ascanio", "surname2": "Sepúlveda"}, "file": "Q136469205", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Adiel Ascanio Sepúlveda"} {"composed": false, "fields": {"given": "Andrés David", "surname": "Padilla", "surname2": "Mejía"}, "file": "Q136487682", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Andrés David Padilla Mejía"} {"composed": false, "fields": {"given": "Teitur", "patronymic": "Þórðarson"}, "file": "Q1364903", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Teitur Þórðarson"} {"composed": false, "fields": {"given": "Семён", "patronymic": "Денисович", "surname": "Игнатьев"}, "file": "Q1368874", "label": "patronymic-ri", "order": "givenFirst", "shape": null, "text": "Семён Денисович Игнатьев"} {"composed": false, "fields": {"given": "Guðmundur", "patronymic": "Eyjólfsson", "surname": "Geirdal"}, "file": "Q136927895", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Guðmundur Eyjólfsson Geirdal"} {"composed": false, "fields": {"given": "Luisa", "surname": "Álvarez", "surname2": "Iglesias"}, "file": "Q137351899", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Luisa Álvarez Iglesias"} {"composed": false, "fields": {"given": "Татьяна", "patronymic": "Николаевна", "surname": "Семенова"}, "file": "Q137642434", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Татьяна Николаевна Семенова"} {"composed": false, "fields": {"given": "Геннадий", "patronymic": "Иванович", "surname": "Воронов"}, "file": "Q1376506", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Геннадий Иванович Воронов"} {"composed": false, "fields": {"given": "Лев", "patronymic": "Семёнович", "surname": "Рубинштейн"}, "file": "Q1376522", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Лев Семёнович Рубинштейн"} {"composed": false, "fields": {"given": "Лев", "patronymic": "Семёнович", "surname": "Рубинштейн"}, "file": "Q1376522", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Рубинштейн, Лев Семёнович"} {"composed": false, "fields": {"given": "Álvaro", "surname": "de Brigard", "surname2": "Silva"}, "file": "Q137940646", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Álvaro de Brigard Silva"} {"composed": false, "fields": {"given": "Manuel", "surname": "López", "surname2": "González"}, "file": "Q137963805", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Manuel López González"} {"composed": false, "fields": {"given": "Анатолий", "patronymic": "Григорьевич", "surname": "Липицкий"}, "file": "Q138647796", "label": "patronymic-su", "order": "sorting", "shape": null, "text": "Липицкий, Анатолий Григорьевич"} {"composed": false, "fields": {"given": "Sveinn", "patronymic": "Ásgeirsson"}, "file": "Q138690631", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Sveinn Ásgeirsson"} {"composed": false, "fields": {"given": "Haukur", "patronymic": "Ágústsson"}, "file": "Q138703138", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Haukur Ágústsson"} {"composed": false, "fields": {"given": "Yenifer", "surname": "Córdoba", "surname2": "Henao"}, "file": "Q139381793", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Yenifer Córdoba Henao"} {"composed": false, "fields": {"given": "Carlos Mauricio", "surname": "Duque", "surname2": "Pastrana"}, "file": "Q139505098", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Carlos Mauricio Duque Pastrana"} {"composed": false, "fields": {"given": "Nicolás", "surname": "Leoz", "surname2": "Almirón"}, "file": "Q1395848", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Nicolás Leoz Almirón"} {"composed": false, "fields": {"given": "Fernando", "surname": "de Alva", "surname2": "Ixtlilxóchitl"}, "file": "Q1396999", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Fernando de Alva Ixtlilxóchitl"} {"composed": false, "fields": {"given": "Júlíus", "patronymic": "Agnarsson"}, "file": "Q139969004", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Júlíus Agnarsson"} {"composed": false, "fields": {"given": "Sebastián", "surname": "Lucumí", "surname2": "Mina"}, "file": "Q140266874", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Sebastián Lucumí Mina"} {"composed": false, "fields": {"given": "Robiro", "surname": "Rojas", "surname2": "Benavides"}, "file": "Q140420991", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Robiro Rojas Benavides"} {"composed": false, "fields": {"given": "Олимпиада", "patronymic": "Ивановна", "surname": "Шмидт"}, "file": "Q141108157", "label": "patronymic-ri", "order": "givenFirst", "shape": null, "text": "Олимпиада Ивановна Шмидт"} {"composed": false, "fields": {"given": "Иван", "patronymic": "Гугович", "surname": "Закс"}, "file": "Q141441031", "label": "patronymic-su", "order": "givenFirst", "shape": null, "text": "Иван Гугович Закс"} {"composed": false, "fields": {"given": "Milton", "surname": "Santacruz", "surname2": "Aguilar"}, "file": "Q141607976", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Milton Santacruz Aguilar"} {"composed": false, "fields": {"given": "Dionisio", "surname": "Guillin", "surname2": "Mercado"}, "file": "Q141668335", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Dionisio Guillin Mercado"} {"composed": false, "fields": {"given": "Roque", "surname": "González", "surname2": "Garza"}, "file": "Q148075", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Roque González Garza"} {"composed": false, "fields": {"given": "Артемий", "patronymic": "Васильевич", "surname": "Иванов"}, "file": "Q15067243", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Иванов, Артемий Васильевич"} {"composed": false, "fields": {"given": "Иван", "patronymic": "Алексеевич", "surname": "Костин"}, "file": "Q15069662", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Костин, Иван Алексеевич"} {"composed": false, "fields": {"given": "Gabriel", "surname": "Ramos", "surname2": "Millán"}, "file": "Q15079846", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Gabriel Ramos Millán"} {"composed": false, "fields": {"given": "Эрнст", "patronymic": "Иванович", "surname": "Сафонов"}, "file": "Q15081249", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Сафонов, Эрнст Иванович"} {"composed": false, "fields": {"given": "Þórunn", "patronymic": "Egilsdóttir"}, "file": "Q15109949", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Þórunn Egilsdóttir"} {"composed": false, "fields": {"given": "Gerónimo", "surname": "Baqueiro", "surname2": "Foster"}, "file": "Q1516038", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Gerónimo Baqueiro Foster"} {"composed": false, "fields": {"given": "Guðrún", "patronymic": "Björnsdóttir"}, "file": "Q15242226", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Guðrún Björnsdóttir"} {"composed": false, "fields": {"given": "Ingrid", "surname": "Betancourt", "surname2": "Pulecio"}, "file": "Q152472", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Ingrid Betancourt Pulecio"} {"composed": false, "fields": {"given": "Ярослав", "patronymic": "Владимирович", "surname": "Рыбаков"}, "file": "Q15571", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Рыбаков, Ярослав Владимирович"} {"composed": false, "fields": {"given": "Benedikt", "patronymic": "Erlingsson"}, "file": "Q15635899", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Benedikt Erlingsson"} {"composed": false, "fields": {"given": "Mariano", "surname": "Cuadra", "surname2": "Medina"}, "file": "Q15711621", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Mariano Cuadra Medina"} {"composed": false, "fields": {"given": "Sigurður", "patronymic": "Helgason"}, "file": "Q1601312", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Sigurður Helgason"} {"composed": false, "fields": {"given": "Adolfo Ernesto", "surname": "Echeverría", "surname2": "Comas"}, "file": "Q16143814", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Adolfo Ernesto Echeverría Comas"} {"composed": false, "fields": {"given": "Patricio", "surname": "Flores", "surname2": "Sandoval"}, "file": "Q16151254", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Patricio Flores Sandoval"} {"composed": false, "fields": {"given": "Iu", "surname": "Alavedra", "surname2": "i Ferrer"}, "file": "Q16162913", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Iu Alavedra i Ferrer"} {"composed": false, "fields": {"given": "Magdalena", "surname": "Calonge", "surname2": "i Panyella"}, "file": "Q16183326", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Magdalena Calonge i Panyella"} {"composed": false, "fields": {"given": "Jordi", "surname": "Castellanos", "surname2": "i Vila"}, "file": "Q16187988", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Jordi Castellanos i Vila"} {"composed": false, "fields": {"given": "Jordi", "surname": "Cussà", "surname2": "Balaguer"}, "file": "Q16190279", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Jordi Cussà Balaguer"} {"composed": false, "fields": {"given": "Miguel Ángel", "surname": "Félix", "surname2": "Gallardo"}, "file": "Q1621643", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Miguel Ángel Félix Gallardo"} {"composed": false, "fields": {"given": "Kristín", "patronymic": "Ólafsdóttir"}, "file": "Q16419219", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Kristín Ólafsdóttir"} {"composed": false, "fields": {"given": "Ólafur", "patronymic": "Ragnarsson"}, "file": "Q16420505", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Ólafur Ragnarsson"} {"composed": false, "fields": {"given": "Loftur", "patronymic": "Guðmundsson"}, "file": "Q16420913", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Loftur Guðmundsson"} {"composed": false, "fields": {"given": "Bjarni", "patronymic": "Björnsson"}, "file": "Q16421121", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Bjarni Björnsson"} {"composed": false, "fields": {"given": "Björgvin", "patronymic": "Gíslason"}, "file": "Q16421247", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Björgvin Gíslason"} {"composed": false, "fields": {"given": "Þorsteinn", "patronymic": "Hannesson"}, "file": "Q16421751", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Þorsteinn Hannesson"} {"composed": false, "fields": {"given": "Rúrik", "patronymic": "Haraldsson"}, "file": "Q16424675", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Rúrik Haraldsson"} {"composed": false, "fields": {"given": "Sigurður", "patronymic": "Björnsson"}, "file": "Q16425767", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Sigurður Björnsson"} {"composed": false, "fields": {"given": "Guðmundur", "patronymic": "Jónsson"}, "file": "Q16426100", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Guðmundur Jónsson"} {"composed": false, "fields": {"given": "Guðrún", "patronymic": "Lárusdóttir"}, "file": "Q16426181", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Guðrún Lárusdóttir"} {"composed": false, "fields": {"given": "Hallbjörg", "patronymic": "Bjarnadóttir"}, "file": "Q16426629", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Hallbjörg Bjarnadóttir"} {"composed": false, "fields": {"given": "Jónas", "patronymic": "Jónasson"}, "file": "Q16429743", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Jónas Jónasson"} {"composed": false, "fields": {"given": "Jónína", "patronymic": "Jónatansdóttir"}, "file": "Q16429802", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Jónína Jónatansdóttir"} {"composed": false, "fields": {"given": "Carlos", "surname": "Colorado", "surname2": "Vera"}, "file": "Q16490264", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Carlos Colorado Vera"} {"composed": false, "fields": {"given": "Margarita", "surname": "Peña", "surname2": "Muñoz"}, "file": "Q16599489", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Margarita Peña Muñoz"} {"composed": false, "fields": {"given": "Александр", "patronymic": "Семёнович", "surname": "Будилович"}, "file": "Q16628165", "label": "patronymic-ri", "order": "givenFirst", "shape": null, "text": "Александр Семёнович Будилович"} {"composed": false, "fields": {"given": "Róbert", "patronymic": "Gunnarsson"}, "file": "Q1684202", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Róbert Gunnarsson"} {"composed": false, "fields": {"given": "Евгений", "patronymic": "Францевич", "surname": "Бауэр"}, "file": "Q1688645", "label": "patronymic-ri", "order": "givenFirst", "shape": null, "text": "Евгений Францевич Бауэр"} {"composed": false, "fields": {"given": "Jaume", "surname": "Berenguer", "surname2": "i Amenós"}, "file": "Q16941606", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Jaume Berenguer i Amenós"} {"composed": false, "fields": {"given": "Libardo", "surname": "Mora", "surname2": "Toro"}, "file": "Q16941958", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Libardo Mora Toro"} {"composed": false, "fields": {"given": "Diego", "surname": "Prado", "surname2": "y Colón de Carvajal"}, "file": "Q16942160", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Diego Prado y Colón de Carvajal"} {"composed": false, "fields": {"given": "Josep", "surname": "Janés", "surname2": "i Olivé"}, "file": "Q16943121", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Josep Janés i Olivé"} {"composed": false, "fields": {"given": "Francesc", "surname": "Todó", "surname2": "Garcia"}, "file": "Q17037033", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Francesc Todó Garcia"} {"composed": false, "fields": {"given": "Jorge", "surname": "González", "surname2": "Camarena"}, "file": "Q1703837", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Jorge González Camarena"} {"composed": false, "fields": {"given": "Jón", "patronymic": "Steingrímsson"}, "file": "Q1715055", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Jón Steingrímsson"} {"composed": false, "fields": {"given": "José", "surname": "Vilató", "surname2": "Ruiz"}, "file": "Q17155819", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "José Vilató Ruiz"} {"composed": false, "fields": {"given": "Сергей", "patronymic": "Александрович", "surname": "Кирдяпкин"}, "file": "Q1717", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Сергей Александрович Кирдяпкин"} {"composed": false, "fields": {"given": "Francisco", "surname": "Martorell", "surname2": "y Peña"}, "file": "Q17301888", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Francisco Martorell y Peña"} {"composed": false, "fields": {"given": "Daniel", "surname": "Bautista", "surname2": "Rocha"}, "file": "Q174100", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Daniel Bautista Rocha"} {"composed": false, "fields": {"given": "Xosé María", "surname": "Brea", "surname2": "Segade"}, "file": "Q17413229", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Xosé María Brea Segade"} {"composed": false, "fields": {"given": "Iván", "surname": "Duque", "surname2": "Márquez"}, "file": "Q17478000", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Iván Duque Márquez"} {"composed": false, "fields": {"given": "Selma", "patronymic": "Björnsdóttir"}, "file": "Q175119", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Selma Björnsdóttir"} {"composed": false, "fields": {"given": "Sölvi", "patronymic": "Ottesen"}, "file": "Q1766810", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Sölvi Ottesen"} {"composed": false, "fields": {"given": "Татьяна", "patronymic": "Геннадьевна", "surname": "Гойщик"}, "file": "Q177406", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Гойщик, Татьяна Геннадьевна"} {"composed": false, "fields": {"given": "Björn", "patronymic": "Þórðarson"}, "file": "Q177712", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Björn Þórðarson"} {"composed": false, "fields": {"given": "Mahathir", "patronymic": "bin Mohamad"}, "file": "Q181383", "label": "patronymic-my", "order": "givenFirst", "shape": null, "text": "Mahathir bin Mohamad"} {"composed": false, "fields": {"given": "Николай", "patronymic": "Григорьевич", "surname": "Александров"}, "file": "Q18145410", "label": "patronymic-ri", "order": "sorting", "shape": null, "text": "Александров, Николай Григорьевич"} {"composed": false, "fields": {"given": "Михаил", "patronymic": "Михайлович", "surname": "Завадовский"}, "file": "Q18407860", "label": "patronymic-su", "order": "sorting", "shape": null, "text": "Завадовский, Михаил Михайлович"} {"composed": false, "fields": {"given": "Manuel", "surname": "Fernández", "surname2": "Castrillón"}, "file": "Q1859374", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Manuel Fernández Castrillón"} {"composed": false, "fields": {"given": "Валерий", "patronymic": "Михайлович", "surname": "Саблин"}, "file": "Q1860669", "label": "patronymic-su", "order": "sorting", "shape": null, "text": "Саблин, Валерий Михайлович"} {"composed": false, "fields": {"given": "José María", "surname": "Yáñez", "surname2": "Carrillo"}, "file": "Q1880038", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "José María Yáñez Carrillo"} {"composed": false, "fields": {"given": "Иван", "patronymic": "Александрович", "surname": "Гончаров"}, "file": "Q189665", "label": "patronymic-ri", "order": "givenFirst", "shape": null, "text": "Иван Александрович Гончаров"} {"composed": false, "fields": {"given": "Vigdís", "patronymic": "Finnbogadóttir"}, "file": "Q189797", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Vigdís Finnbogadóttir"} {"composed": false, "fields": {"given": "Agustín", "surname": "Yáñez", "surname2": "y Girona"}, "file": "Q19257718", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Agustín Yáñez y Girona"} {"composed": false, "fields": {"given": "Francesc", "surname": "Arnau", "surname2": "i Cortina"}, "file": "Q19290799", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Francesc Arnau i Cortina"} {"composed": false, "fields": {"given": "Jaume", "surname": "Martí", "surname2": "i Cabot"}, "file": "Q19299695", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Jaume Martí i Cabot"} {"composed": false, "fields": {"given": "Josep Maria", "surname": "Tarridas", "surname2": "i Barri"}, "file": "Q19300555", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Josep Maria Tarridas i Barri"} {"composed": false, "fields": {"given": "Pedro", "surname": "Jorba", "surname2": "y Gassó"}, "file": "Q19301510", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Pedro Jorba y Gassó"} {"composed": false, "fields": {"given": "Pere", "surname": "Sabaté", "surname2": "i Curto"}, "file": "Q19301571", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Pere Sabaté i Curto"} {"composed": false, "fields": {"given": "Sebastià", "surname": "Salellas", "surname2": "i Magret"}, "file": "Q19301867", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Sebastià Salellas i Magret"} {"composed": false, "fields": {"given": "Николай", "patronymic": "Васильевич", "surname": "Владыкин"}, "file": "Q19317268", "label": "patronymic-su", "order": "givenFirst", "shape": null, "text": "Николай Васильевич Владыкин"} {"composed": false, "fields": {"given": "Simón", "surname": "Mesa", "surname2": "Soto"}, "file": "Q19561528", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Simón Mesa Soto"} {"composed": false, "fields": {"given": "Sergio", "surname": "Marcos", "surname2": "González"}, "file": "Q19601974", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Sergio Marcos González"} {"composed": false, "fields": {"given": "Tomás", "surname": "González", "surname2": "Carvajal"}, "file": "Q1960343", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Tomás González Carvajal"} {"composed": false, "fields": {"given": "Николай", "patronymic": "Иванович", "surname": "Хохлов"}, "file": "Q1968214", "label": "patronymic-su", "order": "givenFirst", "shape": null, "text": "Николай Иванович Хохлов"} {"composed": false, "fields": {"given": "Eduardo", "surname": "Ferrer", "surname2": "Comas"}, "file": "Q19684894", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Eduardo Ferrer Comas"} {"composed": false, "fields": {"given": "Carlos", "surname": "Martínez", "surname2": "Baena"}, "file": "Q19694625", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Carlos Martínez Baena"} {"composed": false, "fields": {"given": "Ramón", "surname": "Arellano", "surname2": "Félix"}, "file": "Q1976742", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Ramón Arellano Félix"} {"composed": false, "fields": {"given": "Анатолий", "patronymic": "Александрович", "surname": "Вассерман"}, "file": "Q1978432", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Анатолий Александрович Вассерман"} {"composed": false, "fields": {"given": "Francisco", "surname": "Santos", "surname2": "Calderón"}, "file": "Q1981295", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Francisco Santos Calderón"} {"composed": false, "fields": {"given": "Juan de Dios", "surname": "Sierra", "surname2": "y Velásquez"}, "file": "Q19816923", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Juan de Dios Sierra y Velásquez"} {"composed": false, "fields": {"given": "Георгий", "patronymic": "Ефремович", "surname": "Катанаев"}, "file": "Q19906568", "label": "patronymic-ri", "order": "givenFirst", "shape": null, "text": "Георгий Ефремович Катанаев"} {"composed": false, "fields": {"given": "Антон", "patronymic": "Дмитриевич", "surname": "Волошин"}, "file": "Q19907651", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Антон Дмитриевич Волошин"} {"composed": false, "fields": {"given": "Jorge", "surname": "González", "surname2": "Díaz"}, "file": "Q1993924", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Jorge González Díaz"} {"composed": false, "fields": {"given": "Pere", "surname": "Rebassa", "surname2": "Bisquerra"}, "file": "Q20006244", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Pere Rebassa Bisquerra"} {"composed": false, "fields": {"given": "Albert", "surname": "Bonet", "surname2": "Marrugat"}, "file": "Q20242421", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Albert Bonet Marrugat"} {"composed": false, "fields": {"given": "Brynjólfur", "patronymic": "Bjarnason"}, "file": "Q2029076", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Brynjólfur Bjarnason"} {"composed": false, "fields": {"given": "Леонид", "patronymic": "Эдуардович", "surname": "Слуцкий"}, "file": "Q2037108", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Леонид Эдуардович Слуцкий"} {"composed": false, "fields": {"given": "Леонид", "patronymic": "Эдуардович", "surname": "Слуцкий"}, "file": "Q2037108", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Слуцкий, Леонид Эдуардович"} {"composed": false, "fields": {"given": "Егор", "patronymic": "Николаевич", "surname": "Булаткин"}, "file": "Q20483301", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Егор Николаевич Булаткин"} {"composed": false, "fields": {"given": "Manuel", "surname": "Banet", "surname2": "Fontenla"}, "file": "Q20534308", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Manuel Banet Fontenla"} {"composed": false, "fields": {"given": "José María", "surname": "Guerra", "surname2": "Cobián"}, "file": "Q20535225", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "José María Guerra Cobián"} {"composed": false, "fields": {"given": "Juan", "surname": "Harguindey", "surname2": "Broussain"}, "file": "Q20535338", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Juan Harguindey Broussain"} {"composed": false, "fields": {"given": "Laureano", "surname": "Veres", "surname2": "Acevedo"}, "file": "Q20536051", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Laureano Veres Acevedo"} {"composed": false, "fields": {"given": "Lárus", "patronymic": "Pálsson"}, "file": "Q20558835", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Lárus Pálsson"} {"composed": false, "fields": {"given": "Александр", "patronymic": "Александрович", "surname": "Фридман"}, "file": "Q205981", "label": "patronymic-su", "order": "givenFirst", "shape": null, "text": "Александр Александрович Фридман"} {"composed": false, "fields": {"given": "Alberto", "surname": "Ortega", "surname2": "Martín"}, "file": "Q20743706", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Alberto Ortega Martín"} {"composed": false, "fields": {"given": "Elisa", "surname": "Ruiz", "surname2": "Romero"}, "file": "Q20829083", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Elisa Ruiz Romero"} {"composed": false, "fields": {"given": "Enrique", "surname": "Gorostieta", "surname2": "Velarde"}, "file": "Q2083675", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Enrique Gorostieta Velarde"} {"composed": false, "fields": {"given": "Frederic", "surname": "Viñals", "surname2": "i Iscla"}, "file": "Q21001221", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Frederic Viñals i Iscla"} {"composed": false, "fields": {"given": "Виктор", "patronymic": "Михайлович", "surname": "Иванцов"}, "file": "Q21099074", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Виктор Михайлович Иванцов"} {"composed": false, "fields": {"given": "Фёдор", "patronymic": "Алексеевич", "surname": "Козловский"}, "file": "Q21101505", "label": "patronymic-ri", "order": "sorting", "shape": null, "text": "Козловский, Фёдор Алексеевич"} {"composed": false, "fields": {"given": "Николай", "patronymic": "Дмитриевич", "surname": "Медведев"}, "file": "Q21106710", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Медведев, Николай Дмитриевич"} {"composed": false, "fields": {"given": "Николай", "patronymic": "Евгеньевич", "surname": "Кабанов"}, "file": "Q21517195", "label": "patronymic-su", "order": "givenFirst", "shape": null, "text": "Николай Евгеньевич Кабанов"} {"composed": false, "fields": {"given": "Jorge Ernesto", "surname": "Guzmán", "surname2": "Rodríguez"}, "file": "Q2259207", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Jorge Ernesto Guzmán Rodríguez"} {"composed": false, "fields": {"given": "Cristóbal", "surname": "Bordiú", "surname2": "y Góngora"}, "file": "Q22962489", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Cristóbal Bordiú y Góngora"} {"composed": false, "fields": {"given": "Ángel", "surname": "Llanos", "surname2": "González"}, "file": "Q23000865", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Ángel Llanos González"} {"composed": false, "fields": {"given": "Оксана", "patronymic": "Сергіївна", "surname": "Баюл"}, "file": "Q232995", "label": "patronymic-su", "order": "givenFirst", "shape": null, "text": "Оксана Сергіївна Баюл"} {"composed": false, "fields": {"given": "Mariana", "surname": "Duque", "surname2": "Mariño"}, "file": "Q235562", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Mariana Duque Mariño"} {"composed": false, "fields": {"given": "Ольга", "patronymic": "Игоревна", "surname": "Кабо"}, "file": "Q2370506", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Ольга Игоревна Кабо"} {"composed": false, "fields": {"given": "Lilja", "patronymic": "Alfreðsdóttir"}, "file": "Q23753778", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Lilja Alfreðsdóttir"} {"composed": false, "fields": {"given": "Алексей", "patronymic": "Валерьевич", "surname": "Серебряков"}, "file": "Q2376200", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Алексей Валерьевич Серебряков"} {"composed": false, "fields": {"given": "Андрей", "patronymic": "Александрович", "surname": "Попов"}, "file": "Q2386694", "label": "patronymic-ri", "order": "sorting", "shape": null, "text": "Попов, Андрей Александрович"} {"composed": false, "fields": {"given": "Cayetano", "surname": "Cardero", "surname2": "y Romeo"}, "file": "Q23906983", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Cayetano Cardero y Romeo"} {"composed": false, "fields": {"given": "Claudio", "surname": "Zupo", "surname2": "Gutiérrez"}, "file": "Q23927812", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Claudio Zupo Gutiérrez"} {"composed": false, "fields": {"given": "Valentín", "surname": "Puga", "surname2": "Franco"}, "file": "Q24013732", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Valentín Puga Franco"} {"composed": false, "fields": {"given": "Rómulo", "surname": "González", "surname2": "Irigoyen"}, "file": "Q24239355", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Rómulo González Irigoyen"} {"composed": false, "fields": {"given": "Antonio", "surname": "Parra", "surname2": "Cabrera"}, "file": "Q24255040", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Antonio Parra Cabrera"} {"composed": false, "fields": {"given": "Eladio", "surname": "Andino", "surname2": "y del Solar"}, "file": "Q24279112", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Eladio Andino y del Solar"} {"composed": false, "fields": {"given": "Kristján Örn", "patronymic": "Sigurðsson"}, "file": "Q2449340", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Kristján Örn Sigurðsson"} {"composed": false, "fields": {"given": "Halla", "patronymic": "Tómasdóttir"}, "file": "Q24494577", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Halla Tómasdóttir"} {"composed": false, "fields": {"given": "Manuel", "surname": "Blanco", "surname2": "Romasanta"}, "file": "Q2463622", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Manuel Blanco Romasanta"} {"composed": false, "fields": {"given": "Асаф", "patronymic": "Михайлович", "surname": "Мессерер"}, "file": "Q2472305", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Асаф Михайлович Мессерер"} {"composed": false, "fields": {"given": "José", "surname": "García", "surname2": "Ocejo"}, "file": "Q24961757", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "José García Ocejo"} {"composed": false, "fields": {"given": "Mohamed", "patronymic": "bin Hamzah"}, "file": "Q25038297", "label": "patronymic-my", "order": "givenFirst", "shape": null, "text": "Mohamed bin Hamzah"} {"composed": false, "fields": {"given": "Árni", "patronymic": "Þorláksson"}, "file": "Q252282", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Árni Þorláksson"} {"composed": false, "fields": {"given": "Ásta Ragnheiður", "patronymic": "Jóhannesdóttir"}, "file": "Q252586", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Ásta Ragnheiður Jóhannesdóttir"} {"composed": false, "fields": {"given": "Francisco", "surname": "Posada", "surname2": "Díaz"}, "file": "Q25402592", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Francisco Posada Díaz"} {"composed": false, "fields": {"given": "Cosme", "surname": "Algarra", "surname2": "y Hurtado"}, "file": "Q25594319", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Cosme Algarra y Hurtado"} {"composed": false, "fields": {"given": "Георгий", "patronymic": "Львович", "surname": "Френкель"}, "file": "Q25598013", "label": "patronymic-su", "order": "surnameFirst", "shape": 4, "text": "Френкель Георгий Львович"} {"composed": false, "fields": {"given": "Нина", "patronymic": "Михайловна", "surname": "Литвинова"}, "file": "Q25796385", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Нина Михайловна Литвинова"} {"composed": false, "fields": {"given": "Мария", "patronymic": "Валерьевна", "surname": "Мазина"}, "file": "Q25895", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Мария Валерьевна Мазина"} {"composed": false, "fields": {"given": "Yrsa", "patronymic": "Sigurðardóttir"}, "file": "Q262253", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Yrsa Sigurðardóttir"} {"composed": false, "fields": {"given": "Pedro", "surname": "Infante", "surname2": "Torrentera"}, "file": "Q2623568", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Pedro Infante Torrentera"} {"composed": false, "fields": {"given": "José Antonio", "surname": "Rodríguez", "surname2": "Romero"}, "file": "Q2625826", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "José Antonio Rodríguez Romero"} {"composed": false, "fields": {"given": "Hera", "patronymic": "Hjartardóttir"}, "file": "Q2635734", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Hera Hjartardóttir"} {"composed": false, "fields": {"given": "Abdul Razak", "patronymic": "Hussein"}, "file": "Q264516", "label": "patronymic-my", "order": "givenFirst", "shape": null, "text": "Abdul Razak Hussein"} {"composed": false, "fields": {"given": "Жанна", "patronymic": "Трофимовна", "surname": "Прохоренко"}, "file": "Q265610", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Жанна Трофимовна Прохоренко"} {"composed": false, "fields": {"given": "Александр", "patronymic": "Иванович", "surname": "Булгаков"}, "file": "Q26736115", "label": "patronymic-ri", "order": "givenFirst", "shape": null, "text": "Александр Иванович Булгаков"} {"composed": false, "fields": {"given": "Kári", "patronymic": "Árnason"}, "file": "Q2718231", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Kári Árnason"} {"composed": false, "fields": {"given": "Ángel", "surname": "Bassols", "surname2": "Batalla"}, "file": "Q28028537", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Ángel Bassols Batalla"} {"composed": false, "fields": {"given": "Rafael", "surname": "Cepeda", "surname2": "Torres"}, "file": "Q28163843", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Rafael Cepeda Torres"} {"composed": false, "fields": {"given": "Александр", "patronymic": "Сергеевич", "surname": "Грипич"}, "file": "Q2832542", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Грипич, Александр Сергеевич"} {"composed": false, "fields": {"given": "Александр", "patronymic": "Павлович", "surname": "Расницын"}, "file": "Q2833996", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Расницын, Александр Павлович"} {"composed": false, "fields": {"given": "Александр", "patronymic": "Николаевич", "surname": "Степанов"}, "file": "Q2834049", "label": "patronymic-su", "order": "givenFirst", "shape": null, "text": "Александр Николаевич Степанов"} {"composed": false, "fields": {"given": "Gilberto", "surname": "Martínez", "surname2": "Arango"}, "file": "Q28343272", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Gilberto Martínez Arango"} {"composed": false, "fields": {"given": "Gregorio", "surname": "Chillón", "surname2": "de la Fuente"}, "file": "Q28355768", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Gregorio Chillón de la Fuente"} {"composed": false, "fields": {"given": "Arturo", "surname": "Acevedo", "surname2": "Vallarino"}, "file": "Q2839714", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Arturo Acevedo Vallarino"} {"composed": false, "fields": {"given": "Андрей", "patronymic": "Васильевич", "surname": "Старовойтов"}, "file": "Q2846841", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Старовойтов, Андрей Васильевич"} {"composed": false, "fields": {"given": "Валентин", "patronymic": "Васильевич", "surname": "Васенков"}, "file": "Q28497247", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Валентин Васильевич Васенков"} {"composed": false, "fields": {"given": "Crisóforo", "surname": "Mazón", "surname2": "Pineda"}, "file": "Q28501494", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Crisóforo Mazón Pineda"} {"composed": false, "fields": {"given": "Carlos", "surname": "Coello de Portugal", "surname2": "y Pacheco"}, "file": "Q28739698", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Carlos Coello de Portugal y Pacheco"} {"composed": false, "fields": {"given": "Владимир", "patronymic": "Алексеевич", "surname": "Богданов"}, "file": "Q28760444", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Богданов, Владимир Алексеевич"} {"composed": false, "fields": {"given": "Miguel", "surname": "Lanz", "surname2": "Duret"}, "file": "Q28792898", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Miguel Lanz Duret"} {"composed": false, "fields": {"given": "Juan", "surname": "Zuleta", "surname2": "Ferrer"}, "file": "Q28792973", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Juan Zuleta Ferrer"} {"composed": false, "fields": {"given": "Pedro", "surname": "Cañas", "surname2": "Serrano"}, "file": "Q2917254", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Pedro Cañas Serrano"} {"composed": false, "fields": {"given": "Ívar", "patronymic": "Ingimarsson"}, "file": "Q291858", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Ívar Ingimarsson"} {"composed": false, "fields": {"given": "Ögmundur", "patronymic": "Jónasson"}, "file": "Q294666", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Ögmundur Jónasson"} {"composed": false, "fields": {"given": "Борис", "patronymic": "Максимович", "surname": "Келлер"}, "file": "Q29649854", "label": "patronymic-su", "order": "givenFirst", "shape": null, "text": "Борис Максимович Келлер"} {"composed": false, "fields": {"given": "Salomón", "surname": "Villada", "surname2": "Hoyos"}, "file": "Q29649988", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Salomón Villada Hoyos"} {"composed": false, "fields": {"given": "Евгений", "patronymic": "Александрович", "surname": "Евтушенко"}, "file": "Q297736", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Евгений Александрович Евтушенко"} {"composed": false, "fields": {"given": "Felipe", "surname": "Colomo", "surname2": "Castro"}, "file": "Q29860266", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Felipe Colomo Castro"} {"composed": false, "fields": {"given": "Anwar", "patronymic": "Ibrahim"}, "file": "Q299574", "label": "patronymic-my", "order": "givenFirst", "shape": null, "text": "Anwar Ibrahim"} {"composed": false, "fields": {"given": "Octavio", "surname": "Corral", "surname2": "Romero"}, "file": "Q29959449", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Octavio Corral Romero"} {"composed": false, "fields": {"given": "Алексей", "patronymic": "Иванович", "surname": "Савельев"}, "file": "Q30079822", "label": "patronymic-su", "order": "givenFirst", "shape": null, "text": "Алексей Иванович Савельев"} {"composed": false, "fields": {"given": "Horacio", "surname": "Gómez", "surname2": "Bolaños"}, "file": "Q3031692", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Horacio Gómez Bolaños"} {"composed": false, "fields": {"given": "Aarón", "surname": "Padilla", "surname2": "Gutiérrez"}, "file": "Q303293", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Aarón Padilla Gutiérrez"} {"composed": false, "fields": {"given": "Francisco", "surname": "Freire", "surname2": "Barreiro"}, "file": "Q30899494", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Francisco Freire Barreiro"} {"composed": false, "fields": {"given": "Владимир", "patronymic": "Евграфович", "surname": "Татлин"}, "file": "Q310320", "label": "patronymic-su", "order": "givenFirst", "shape": null, "text": "Владимир Евграфович Татлин"} {"composed": false, "fields": {"given": "Николай", "patronymic": "Степанович", "surname": "Гумилёв"}, "file": "Q311033", "label": "patronymic-ri", "order": "givenFirst", "shape": null, "text": "Николай Степанович Гумилёв"} {"composed": false, "fields": {"given": "Abdul Aziz", "patronymic": "Yassin"}, "file": "Q31186764", "label": "patronymic-my", "order": "givenFirst", "shape": null, "text": "Abdul Aziz Yassin"} {"composed": false, "fields": {"given": "Manuel", "surname": "Uruchurtu", "surname2": "Ramírez"}, "file": "Q3124411", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Manuel Uruchurtu Ramírez"} {"composed": false, "fields": {"given": "Daði Freyr", "patronymic": "Pétursson"}, "file": "Q31749372", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Daði Freyr Pétursson"} {"composed": false, "fields": {"given": "Владимир", "patronymic": "Валерьевич", "surname": "Буре"}, "file": "Q32717", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Владимир Валерьевич Буре"} {"composed": false, "fields": {"given": "Андрей", "patronymic": "Борисович", "surname": "Колкутин"}, "file": "Q32827", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Колкутин, Андрей Борисович"} {"composed": false, "fields": {"given": "Guillermo", "surname": "García", "surname2": "Cantú"}, "file": "Q3294918", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Guillermo García Cantú"} {"composed": false, "fields": {"given": "Eulogio", "surname": "Despujol", "surname2": "y Dusay"}, "file": "Q3360558", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Eulogio Despujol y Dusay"} {"composed": false, "fields": {"given": "Xosé María", "surname": "Álvarez", "surname2": "Blázquez"}, "file": "Q3397750", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Xosé María Álvarez Blázquez"} {"composed": false, "fields": {"given": "Борис", "patronymic": "Николаевич", "surname": "Ельцин"}, "file": "Q34453", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Борис Николаевич Ельцин"} {"composed": false, "fields": {"given": "Edda", "patronymic": "Garðarsdóttir"}, "file": "Q3480844", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Edda Garðarsdóttir"} {"composed": false, "fields": {"given": "Sara Björk", "patronymic": "Gunnarsdóttir"}, "file": "Q3481235", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Sara Björk Gunnarsdóttir"} {"composed": false, "fields": {"given": "Вадим", "patronymic": "Владимирович", "surname": "Наумов"}, "file": "Q351080", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Вадим Владимирович Наумов"} {"composed": false, "fields": {"given": "Вадим", "patronymic": "Владимирович", "surname": "Наумов"}, "file": "Q351080", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Наумов, Вадим Владимирович"} {"composed": false, "fields": {"given": "Martha", "surname": "Nava", "surname2": "García"}, "file": "Q35160252", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Martha Nava García"} {"composed": false, "fields": {"given": "Евгений", "patronymic": "Романович", "surname": "Устюгов"}, "file": "Q351886", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Евгений Романович Устюгов"} {"composed": false, "fields": {"given": "Пётр", "patronymic": "Николаевич", "surname": "Краснов"}, "file": "Q35448", "label": "patronymic-ri", "order": "givenFirst", "shape": null, "text": "Пётр Николаевич Краснов"} {"composed": false, "fields": {"given": "Elizabeth Margot", "surname": "Schreiber", "surname2": "Castañón"}, "file": "Q3548080", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Elizabeth Margot Schreiber Castañón"} {"composed": false, "fields": {"given": "Steinunn", "patronymic": "Sigurðardóttir"}, "file": "Q356302", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Steinunn Sigurðardóttir"} {"composed": false, "fields": {"given": "José", "surname": "Gómez-Mena", "surname2": "Vila"}, "file": "Q35689580", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "José Gómez-Mena Vila"} {"composed": false, "fields": {"given": "Михаил", "patronymic": "Михайлович", "surname": "Зощенко"}, "file": "Q360507", "label": "patronymic-ri", "order": "givenFirst", "shape": null, "text": "Михаил Михайлович Зощенко"} {"composed": false, "fields": {"given": "Sigurjón Birgir", "patronymic": "Sigurðsson"}, "file": "Q362531", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Sigurjón Birgir Sigurðsson"} {"composed": false, "fields": {"given": "Алексей", "patronymic": "Васильевич", "surname": "Богдан"}, "file": "Q36499621", "label": "patronymic-ri", "order": "givenFirst", "shape": null, "text": "Алексей Васильевич Богдан"} {"composed": false, "fields": {"given": "Юрий", "patronymic": "Ильич", "surname": "Скуратов"}, "file": "Q367029", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Юрий Ильич Скуратов"} {"composed": false, "fields": {"given": "Vicente", "surname": "Martín", "surname2": "y Soler"}, "file": "Q377409", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Vicente Martín y Soler"} {"composed": false, "fields": {"given": "Андрей", "patronymic": "Юрьевич", "surname": "Хржановский"}, "file": "Q3791296", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Хржановский, Андрей Юрьевич"} {"composed": false, "fields": {"given": "Joaquín", "surname": "García", "surname2": "Icazbalceta"}, "file": "Q3808593", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Joaquín García Icazbalceta"} {"composed": false, "fields": {"given": "Matthías", "patronymic": "Jochumsson"}, "file": "Q380925", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Matthías Jochumsson"} {"composed": false, "fields": {"given": "José", "surname": "Cabrera", "surname2": "Gándara"}, "file": "Q3810410", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "José Cabrera Gándara"} {"composed": false, "fields": {"given": "Gunnar", "patronymic": "Gunnarsson"}, "file": "Q381086", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Gunnar Gunnarsson"} {"composed": false, "fields": {"given": "María de la O", "surname": "Lejárraga", "surname2": "y García"}, "file": "Q3816879", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "María de la O Lejárraga y García"} {"composed": false, "fields": {"given": "Félix", "surname": "Sardá", "surname2": "y Salvany"}, "file": "Q3827052", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Félix Sardá y Salvany"} {"composed": false, "fields": {"given": "Михаил", "patronymic": "Дмитриевич", "surname": "Чулков"}, "file": "Q3856322", "label": "patronymic-ri", "order": "givenFirst", "shape": null, "text": "Михаил Дмитриевич Чулков"} {"composed": false, "fields": {"given": "Ксения", "patronymic": "Сергеевна", "surname": "Александрова"}, "file": "Q38668675", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Ксения Сергеевна Александрова"} {"composed": false, "fields": {"given": "Александр", "patronymic": "Леонидович", "surname": "Дворкин"}, "file": "Q3920829", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Александр Леонидович Дворкин"} {"composed": false, "fields": {"given": "Carlos", "surname": "Lemos", "surname2": "Simmonds"}, "file": "Q4016455", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Carlos Lemos Simmonds"} {"composed": false, "fields": {"given": "Константин", "patronymic": "Иванович", "surname": "Абатуров"}, "file": "Q4054347", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Абатуров, Константин Иванович"} {"composed": false, "fields": {"given": "Самуил", "patronymic": "Хаимович", "surname": "Агурский"}, "file": "Q4057185", "label": "patronymic-su", "order": "givenFirst", "shape": null, "text": "Самуил Хаимович Агурский"} {"composed": false, "fields": {"given": "Александр", "patronymic": "Иванович", "surname": "Алексеев"}, "file": "Q4061598", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Александр Иванович Алексеев"} {"composed": false, "fields": {"given": "Михаил", "patronymic": "Николаевич", "surname": "Алексеев"}, "file": "Q4061685", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Алексеев, Михаил Николаевич"} {"composed": false, "fields": {"given": "Николай", "patronymic": "Автономович", "surname": "Бондарев"}, "file": "Q4093129", "label": "patronymic-su", "order": "sorting", "shape": null, "text": "Бондарев, Николай Автономович"} {"composed": false, "fields": {"given": "Николай", "patronymic": "Александрович", "surname": "Боярский"}, "file": "Q4095525", "label": "patronymic-su", "order": "givenFirst", "shape": null, "text": "Николай Александрович Боярский"} {"composed": false, "fields": {"given": "Пётр", "patronymic": "Васильевич", "surname": "Быков"}, "file": "Q4100955", "label": "patronymic-su", "order": "givenFirst", "shape": null, "text": "Пётр Васильевич Быков"} {"composed": false, "fields": {"given": "Николай", "patronymic": "Петрович", "surname": "Вагнер"}, "file": "Q4102225", "label": "patronymic-ri", "order": "givenFirst", "shape": null, "text": "Николай Петрович Вагнер"} {"composed": false, "fields": {"given": "Николай", "patronymic": "Петрович", "surname": "Вагнер"}, "file": "Q4102225", "label": "patronymic-ri", "order": "surnameFirst", "shape": 4, "text": "Вагнер Николай Петрович"} {"composed": false, "fields": {"given": "Павел", "patronymic": "Михайлович", "surname": "Волков"}, "file": "Q4123342", "label": "patronymic-ri", "order": "givenFirst", "shape": null, "text": "Павел Михайлович Волков"} {"composed": false, "fields": {"given": "Константин", "patronymic": "Дмитриевич", "surname": "Воробьёв"}, "file": "Q4125181", "label": "patronymic-su", "order": "sorting", "shape": null, "text": "Воробьёв, Константин Дмитриевич"} {"composed": false, "fields": {"given": "Юрий", "patronymic": "Петрович", "surname": "Вронский"}, "file": "Q4127201", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Вронский, Юрий Петрович"} {"composed": false, "fields": {"given": "Виктор", "patronymic": "Владимирович", "surname": "Глухов"}, "file": "Q4140268", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Виктор Владимирович Глухов"} {"composed": false, "fields": {"given": "Нина", "patronymic": "Тимофеевна", "surname": "Гнилицкая"}, "file": "Q4140733", "label": "patronymic-su", "order": "sorting", "shape": null, "text": "Гнилицкая, Нина Тимофеевна"} {"composed": false, "fields": {"given": "Анатолий", "patronymic": "Иванович", "surname": "Гриценко"}, "file": "Q4149792", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Гриценко, Анатолий Иванович"} {"composed": false, "fields": {"given": "Иван", "patronymic": "Иванович", "surname": "Дитятин"}, "file": "Q4162468", "label": "patronymic-ri", "order": "sorting", "shape": null, "text": "Дитятин, Иван Иванович"} {"composed": false, "fields": {"given": "Владимир", "patronymic": "Семёнович", "surname": "Дятлов"}, "file": "Q4172503", "label": "patronymic-su", "order": "sorting", "shape": null, "text": "Дятлов, Владимир Семёнович"} {"composed": false, "fields": {"given": "Алексей", "patronymic": "Борисович", "surname": "Елисеев"}, "file": "Q4174932", "label": "patronymic-su", "order": "givenFirst", "shape": null, "text": "Алексей Борисович Елисеев"} {"composed": false, "fields": {"given": "Николай", "patronymic": "Иванович", "surname": "Иваницкий"}, "file": "Q4195761", "label": "patronymic-ri", "order": "sorting", "shape": null, "text": "Иваницкий, Николай Иванович"} {"composed": false, "fields": {"given": "Геннадий", "patronymic": "Вячеславович", "surname": "Игумнов"}, "file": "Q4197788", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Геннадий Вячеславович Игумнов"} {"composed": false, "fields": {"given": "Ismail", "patronymic": "Abdul Rahman"}, "file": "Q4198159", "label": "patronymic-my", "order": "givenFirst", "shape": null, "text": "Ismail Abdul Rahman"} {"composed": false, "fields": {"given": "Juan", "surname": "Fernández", "surname2": "Bazán"}, "file": "Q41991777", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Juan Fernández Bazán"} {"composed": false, "fields": {"given": "Владимир", "patronymic": "Владимирович", "surname": "Кара-Мурза"}, "file": "Q4213414", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Владимир Владимирович Кара-Мурза"} {"composed": false, "fields": {"given": "Александр", "patronymic": "Степанович", "surname": "Кислов"}, "file": "Q4222186", "label": "patronymic-ri", "order": "sorting", "shape": null, "text": "Кислов, Александр Степанович"} {"composed": false, "fields": {"given": "Дмитро", "patronymic": "Олександрович", "surname": "Корчинський"}, "file": "Q4234503", "label": "patronymic-su", "order": "givenFirst", "shape": null, "text": "Дмитро Олександрович Корчинський"} {"composed": false, "fields": {"given": "Björk", "patronymic": "Guðmundsdóttir"}, "file": "Q42455", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Björk Guðmundsdóttir"} {"composed": false, "fields": {"given": "Анатолий", "patronymic": "Петрович", "surname": "Левандовский"}, "file": "Q4256204", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Левандовский, Анатолий Петрович"} {"composed": false, "fields": {"given": "Hernando", "surname": "Calvo", "surname2": "Ospina"}, "file": "Q425825", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Hernando Calvo Ospina"} {"composed": false, "fields": {"given": "Павел", "patronymic": "Яковлевич", "surname": "Любимов"}, "file": "Q4271524", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Павел Яковлевич Любимов"} {"composed": false, "fields": {"given": "Елена", "patronymic": "Игоревна", "surname": "Лядова"}, "file": "Q4272531", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Елена Игоревна Лядова"} {"composed": false, "fields": {"given": "Manuel", "surname": "Tello", "surname2": "Macías"}, "file": "Q4283792", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Manuel Tello Macías"} {"composed": false, "fields": {"given": "Герман", "patronymic": "Иванович", "surname": "Матвеев"}, "file": "Q4284526", "label": "patronymic-su", "order": "sorting", "shape": null, "text": "Матвеев, Герман Иванович"} {"composed": false, "fields": {"given": "Óskar", "patronymic": "Halldórsson"}, "file": "Q42907085", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Óskar Halldórsson"} {"composed": false, "fields": {"given": "Александр", "patronymic": "Петрович", "surname": "Сумароков"}, "file": "Q429915", "label": "patronymic-ri", "order": "givenFirst", "shape": null, "text": "Александр Петрович Сумароков"} {"composed": false, "fields": {"given": "Александр", "patronymic": "Алексеевич", "surname": "Моисеев"}, "file": "Q4299705", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Александр Алексеевич Моисеев"} {"composed": false, "fields": {"given": "Александр", "patronymic": "Алексеевич", "surname": "Моисеев"}, "file": "Q4299705", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Моисеев, Александр Алексеевич"} {"composed": false, "fields": {"given": "Николай", "patronymic": "Викторович", "surname": "Насонов"}, "file": "Q4313956", "label": "patronymic-ri", "order": "sorting", "shape": null, "text": "Насонов, Николай Викторович"} {"composed": false, "fields": {"given": "Михаил", "patronymic": "Иванович", "surname": "Наумов"}, "file": "Q4314363", "label": "patronymic-su", "order": "sorting", "shape": null, "text": "Наумов, Михаил Иванович"} {"composed": false, "fields": {"given": "Валерий", "patronymic": "Сергеевич", "surname": "Ольховский"}, "file": "Q4334317", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Валерий Сергеевич Ольховский"} {"composed": false, "fields": {"given": "Валерий", "patronymic": "Сергеевич", "surname": "Ольховский"}, "file": "Q4334317", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Ольховский, Валерий Сергеевич"} {"composed": false, "fields": {"given": "Евгений", "patronymic": "Викторович", "surname": "Осин"}, "file": "Q4337703", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Евгений Викторович Осин"} {"composed": false, "fields": {"given": "Виктор", "patronymic": "Иванович", "surname": "Туровцев"}, "file": "Q43425443", "label": "patronymic-su", "order": "givenFirst", "shape": null, "text": "Виктор Иванович Туровцев"} {"composed": false, "fields": {"given": "Алексей", "patronymic": "Вячеславович", "surname": "Панин"}, "file": "Q4343990", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Алексей Вячеславович Панин"} {"composed": false, "fields": {"given": "Алексей", "patronymic": "Вячеславович", "surname": "Панин"}, "file": "Q4343990", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Панин, Алексей Вячеславович"} {"composed": false, "fields": {"given": "Василий", "patronymic": "Петрович", "surname": "Попов"}, "file": "Q4372428", "label": "patronymic-ri", "order": "sorting", "shape": null, "text": "Попов, Василий Петрович"} {"composed": false, "fields": {"given": "Андрій", "patronymic": "Володимирович", "surname": "Портнов"}, "file": "Q4373277", "label": "patronymic-su", "order": "givenFirst", "shape": null, "text": "Андрій Володимирович Портнов"} {"composed": false, "fields": {"given": "Василий", "patronymic": "Алексеевич", "surname": "Преображенский"}, "file": "Q4377304", "label": "patronymic-ri", "order": "sorting", "shape": null, "text": "Преображенский, Василий Алексеевич"} {"composed": false, "fields": {"given": "Siv", "patronymic": "Friðleifsdóttir"}, "file": "Q437932", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Siv Friðleifsdóttir"} {"composed": false, "fields": {"given": "Guðrún Eva", "patronymic": "Mínervudóttir"}, "file": "Q438008", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Guðrún Eva Mínervudóttir"} {"composed": false, "fields": {"given": "Guðbjörg", "patronymic": "Gunnarsdóttir"}, "file": "Q438031", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Guðbjörg Gunnarsdóttir"} {"composed": false, "fields": {"given": "Евгений", "patronymic": "Иванович", "surname": "Рагозин"}, "file": "Q4387043", "label": "patronymic-ri", "order": "sorting", "shape": null, "text": "Рагозин, Евгений Иванович"} {"composed": false, "fields": {"given": "Александр", "patronymic": "Гельевич", "surname": "Дугин"}, "file": "Q438839", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Александр Гельевич Дугин"} {"composed": false, "fields": {"given": "Юрий", "patronymic": "Георгиевич", "surname": "Разумовский"}, "file": "Q4388811", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Юрий Георгиевич Разумовский"} {"composed": false, "fields": {"given": "Юрий", "patronymic": "Георгиевич", "surname": "Разумовский"}, "file": "Q4388811", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Разумовский, Юрий Георгиевич"} {"composed": false, "fields": {"given": "Михаил", "patronymic": "Константинович", "surname": "Розенфельд"}, "file": "Q4396579", "label": "patronymic-su", "order": "sorting", "shape": null, "text": "Розенфельд, Михаил Константинович"} {"composed": false, "fields": {"given": "Николай", "patronymic": "Андреевич", "surname": "Рыбин"}, "file": "Q4401515", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Николай Андреевич Рыбин"} {"composed": false, "fields": {"given": "Александр", "patronymic": "Алексеевич", "surname": "Саблин"}, "file": "Q4403531", "label": "patronymic-ri", "order": "sorting", "shape": null, "text": "Саблин, Александр Алексеевич"} {"composed": false, "fields": {"given": "Семён", "patronymic": "Мартынович", "surname": "Сольский"}, "file": "Q4428563", "label": "patronymic-ri", "order": "sorting", "shape": null, "text": "Сольский, Семён Мартынович"} {"composed": false, "fields": {"given": "Indalecio", "surname": "Soriano", "surname2": "Fuertes"}, "file": "Q4429030", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Indalecio Soriano Fuertes"} {"composed": false, "fields": {"given": "Федот", "patronymic": "Васильевич", "surname": "Сычков"}, "file": "Q4448299", "label": "patronymic-su", "order": "givenFirst", "shape": null, "text": "Федот Васильевич Сычков"} {"composed": false, "fields": {"given": "Виталий", "patronymic": "Николаевич", "surname": "Сёмин"}, "file": "Q4448725", "label": "patronymic-su", "order": "sorting", "shape": null, "text": "Сёмин, Виталий Николаевич"} {"composed": false, "fields": {"given": "Пётр", "patronymic": "Олегович", "surname": "Толстой"}, "file": "Q4459965", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Пётр Олегович Толстой"} {"composed": false, "fields": {"given": "Владимир", "patronymic": "Александрович", "surname": "Усов"}, "file": "Q4477826", "label": "patronymic-su", "order": "givenFirst", "shape": null, "text": "Владимир Александрович Усов"} {"composed": false, "fields": {"given": "Юлия", "patronymic": "Ивановна", "surname": "Фаусек"}, "file": "Q4481541", "label": "patronymic-su", "order": "sorting", "shape": null, "text": "Фаусек, Юлия Ивановна"} {"composed": false, "fields": {"given": "Beatriz Elena", "surname": "Paredes", "surname2": "Rangel"}, "file": "Q448642", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Beatriz Elena Paredes Rangel"} {"composed": false, "fields": {"given": "Юрий", "patronymic": "Борисович", "surname": "Хржановский"}, "file": "Q4501355", "label": "patronymic-su", "order": "givenFirst", "shape": null, "text": "Юрий Борисович Хржановский"} {"composed": false, "fields": {"given": "Никита", "patronymic": "Сергеевич", "surname": "Хрущёв"}, "file": "Q4501920", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Никита Сергеевич Хрущёв"} {"composed": false, "fields": {"given": "Николай", "patronymic": "Николаевич", "surname": "Червенков"}, "file": "Q4511967", "label": "patronymic-su", "order": "sorting", "shape": null, "text": "Червенков, Николай Николаевич"} {"composed": false, "fields": {"given": "Александр", "patronymic": "Иванович", "surname": "Черепанов"}, "file": "Q4512838", "label": "patronymic-su", "order": "givenFirst", "shape": null, "text": "Александр Иванович Черепанов"} {"composed": false, "fields": {"given": "Александр", "patronymic": "Иванович", "surname": "Черепанов"}, "file": "Q4512838", "label": "patronymic-su", "order": "sorting", "shape": null, "text": "Черепанов, Александр Иванович"} {"composed": false, "fields": {"given": "Пётр", "patronymic": "Сергеевич", "surname": "Чернышёв"}, "file": "Q4514365", "label": "patronymic-su", "order": "sorting", "shape": null, "text": "Чернышёв, Пётр Сергеевич"} {"composed": false, "fields": {"given": "Николай", "patronymic": "Николаевич", "surname": "Шиллер"}, "file": "Q4523811", "label": "patronymic-ri", "order": "sorting", "shape": null, "text": "Шиллер, Николай Николаевич"} {"composed": false, "fields": {"given": "Николай", "patronymic": "Александрович", "surname": "Энгельгардт"}, "file": "Q4531782", "label": "patronymic-su", "order": "givenFirst", "shape": null, "text": "Николай Александрович Энгельгардт"} {"composed": false, "fields": {"given": "Ульяна", "patronymic": "Петровна", "surname": "Буянова"}, "file": "Q45527009", "label": "patronymic-ri", "order": "givenFirst", "shape": null, "text": "Ульяна Петровна Буянова"} {"composed": false, "fields": {"given": "Pascual", "surname": "Ortiz", "surname2": "Rubio"}, "file": "Q455683", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Pascual Ortiz Rubio"} {"composed": false, "fields": {"given": "Joaquín", "surname": "Guzmán", "surname2": "Loera"}, "file": "Q456321", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Joaquín Guzmán Loera"} {"composed": false, "fields": {"given": "Юрий", "patronymic": "Петрович", "surname": "Любимов"}, "file": "Q460023", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Юрий Петрович Любимов"} {"composed": false, "fields": {"given": "Vilborg", "patronymic": "Davíðsdóttir"}, "file": "Q462196", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Vilborg Davíðsdóttir"} {"composed": false, "fields": {"given": "Katrín", "patronymic": "Jakobsdóttir"}, "file": "Q467464", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Katrín Jakobsdóttir"} {"composed": false, "fields": {"given": "José", "surname": "Mataix", "surname2": "Monllor"}, "file": "Q47087536", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "José Mataix Monllor"} {"composed": false, "fields": {"given": "Александр", "patronymic": "Вячеславович", "surname": "Жиров"}, "file": "Q4715527", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Александр Вячеславович Жиров"} {"composed": false, "fields": {"given": "Александр", "patronymic": "Вячеславович", "surname": "Жиров"}, "file": "Q4715527", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Жиров, Александр Вячеславович"} {"composed": false, "fields": {"given": "Amado", "surname": "Aguirre", "surname2": "Santiago"}, "file": "Q4739054", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Amado Aguirre Santiago"} {"composed": false, "fields": {"given": "Eduardo", "surname": "Pallares", "surname2": "Portillo"}, "file": "Q47495573", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Eduardo Pallares Portillo"} {"composed": false, "fields": {"given": "Иван", "patronymic": "Андреевич", "surname": "Созонов"}, "file": "Q476758", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Иван Андреевич Созонов"} {"composed": false, "fields": {"given": "Леонид", "patronymic": "Иванович", "surname": "Иванов"}, "file": "Q47711697", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Леонид Иванович Иванов"} {"composed": false, "fields": {"given": "Antonio", "surname": "París", "surname2": "Sanz de Santamaría"}, "file": "Q4776859", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Antonio París Sanz de Santamaría"} {"composed": false, "fields": {"given": "Julián", "surname": "Pascual", "surname2": "Nunell"}, "file": "Q47772146", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Julián Pascual Nunell"} {"composed": false, "fields": {"given": "Albert", "surname": "Bertomeu", "surname2": "Font"}, "file": "Q47792390", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Albert Bertomeu Font"} {"composed": false, "fields": {"given": "Arcadio", "surname": "Poveda", "surname2": "Ricalde"}, "file": "Q4785197", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Arcadio Poveda Ricalde"} {"composed": false, "fields": {"given": "Кирилл", "patronymic": "Афанасьевич", "surname": "Мерецков"}, "file": "Q48145", "label": "patronymic-ri", "order": "givenFirst", "shape": null, "text": "Кирилл Афанасьевич Мерецков"} {"composed": false, "fields": {"given": "Игорь", "patronymic": "Александрович", "surname": "Нетто"}, "file": "Q48355", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Игорь Александрович Нетто"} {"composed": false, "fields": {"given": "Aron", "patronymic": "Jóhannsson"}, "file": "Q48554", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Aron Jóhannsson"} {"composed": false, "fields": {"given": "Анатолий", "patronymic": "Николаевич", "surname": "Алябьев"}, "file": "Q487927", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Анатолий Николаевич Алябьев"} {"composed": false, "fields": {"given": "Андрей", "patronymic": "Васильевич", "surname": "Мягков"}, "file": "Q502651", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Андрей Васильевич Мягков"} {"composed": false, "fields": {"given": "Carlos", "surname": "Ardila", "surname2": "Lülle"}, "file": "Q5041784", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Carlos Ardila Lülle"} {"composed": false, "fields": {"given": "Catalina", "surname": "Ruiz", "surname2": "Pérez"}, "file": "Q50422593", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Catalina Ruiz Pérez"} {"composed": false, "fields": {"given": "Ignacio", "surname": "Rodríguez", "surname2": "Bahena"}, "file": "Q504250", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Ignacio Rodríguez Bahena"} {"composed": false, "fields": {"given": "Steingrímur Jóhann", "patronymic": "Sigfússon"}, "file": "Q511882", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Steingrímur Jóhann Sigfússon"} {"composed": false, "fields": {"given": "Clara", "surname": "Brugada", "surname2": "Molina"}, "file": "Q5125955", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Clara Brugada Molina"} {"composed": false, "fields": {"given": "Sócrates", "surname": "Gómez", "surname2": "Pérez"}, "file": "Q512843", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Sócrates Gómez Pérez"} {"composed": false, "fields": {"given": "Евдокия", "patronymic": "Андреевна", "surname": "Никулина"}, "file": "Q516275", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Евдокия Андреевна Никулина"} {"composed": false, "fields": {"given": "Евдокия", "patronymic": "Андреевна", "surname": "Никулина"}, "file": "Q516275", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Никулина, Евдокия Андреевна"} {"composed": false, "fields": {"given": "Павел", "patronymic": "Владимирович", "surname": "Сорокин"}, "file": "Q51668995", "label": "patronymic-su", "order": "givenFirst", "shape": null, "text": "Павел Владимирович Сорокин"} {"composed": false, "fields": {"given": "Joaquín", "surname": "Torres", "surname2": "García"}, "file": "Q520713", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Joaquín Torres García"} {"composed": false, "fields": {"given": "Bernardo", "surname": "Hernández", "surname2": "Villaseñor"}, "file": "Q525581", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Bernardo Hernández Villaseñor"} {"composed": false, "fields": {"given": "Margrét Lára", "patronymic": "Viðarsdóttir"}, "file": "Q539394", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Margrét Lára Viðarsdóttir"} {"composed": false, "fields": {"given": "Katrín", "patronymic": "Júlíusdóttir"}, "file": "Q539421", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Katrín Júlíusdóttir"} {"composed": false, "fields": {"given": "Julio Mario", "surname": "Santo Domingo", "surname2": "Pumarejo"}, "file": "Q5399460", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Julio Mario Santo Domingo Pumarejo"} {"composed": false, "fields": {"given": "Guðrún", "patronymic": "Helgadóttir"}, "file": "Q540138", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Guðrún Helgadóttir"} {"composed": false, "fields": {"given": "Francisco", "surname": "González", "surname2": "Macías"}, "file": "Q5412830", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Francisco González Macías"} {"composed": false, "fields": {"given": "Владислав", "patronymic": "Юрьевич", "surname": "Сурков"}, "file": "Q548164", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Владислав Юрьевич Сурков"} {"composed": false, "fields": {"given": "Claudi", "surname": "Ametlla", "surname2": "i Coll"}, "file": "Q5485242", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Claudi Ametlla i Coll"} {"composed": false, "fields": {"given": "Валерий", "patronymic": "Фёдорович", "surname": "Залужный"}, "file": "Q54964679", "label": "patronymic-su", "order": "givenFirst", "shape": null, "text": "Валерий Фёдорович Залужный"} {"composed": false, "fields": {"given": "Андрей", "patronymic": "Сергеевич", "surname": "Кончаловский"}, "file": "Q55208", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Андрей Сергеевич Кончаловский"} {"composed": false, "fields": {"given": "Alberto", "surname": "García", "surname2": "Ulecia"}, "file": "Q55229105", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Alberto García Ulecia"} {"composed": false, "fields": {"given": "Carles", "surname": "Busquets", "surname2": "Barroso"}, "file": "Q554086", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Carles Busquets Barroso"} {"composed": false, "fields": {"given": "Julián", "surname": "Ruiz", "surname2": "Martorell"}, "file": "Q555086", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Julián Ruiz Martorell"} {"composed": false, "fields": {"given": "Miguel", "surname": "Treviño", "surname2": "Morales"}, "file": "Q555661", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Miguel Treviño Morales"} {"composed": false, "fields": {"given": "Carlos", "surname": "Encinas", "surname2": "González"}, "file": "Q5576768", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Carlos Encinas González"} {"composed": false, "fields": {"given": "Eduardo", "surname": "Rodríguez", "surname2": "Osorio"}, "file": "Q55769527", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Eduardo Rodríguez Osorio"} {"composed": false, "fields": {"given": "Joaquín", "surname": "Arasanz", "surname2": "Raso"}, "file": "Q55949540", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Joaquín Arasanz Raso"} {"composed": false, "fields": {"given": "Mercedes", "surname": "Díaz", "surname2": "Roig"}, "file": "Q55999012", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Mercedes Díaz Roig"} {"composed": false, "fields": {"given": "Jón", "patronymic": "Þorláksson"}, "file": "Q562079", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Jón Þorláksson"} {"composed": false, "fields": {"given": "Alfonso", "surname": "Reyes", "surname2": "Echandía"}, "file": "Q5667134", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Alfonso Reyes Echandía"} {"composed": false, "fields": {"given": "Jesús", "surname": "Huguet", "surname2": "i Serrano"}, "file": "Q56677240", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Jesús Huguet i Serrano"} {"composed": false, "fields": {"given": "Eduardo", "surname": "Zalamea", "surname2": "Borda"}, "file": "Q5677081", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Eduardo Zalamea Borda"} {"composed": false, "fields": {"given": "Виссарион", "patronymic": "Михайлович", "surname": "Павлов"}, "file": "Q56822727", "label": "patronymic-ri", "order": "givenFirst", "shape": null, "text": "Виссарион Михайлович Павлов"} {"composed": false, "fields": {"given": "José Antonio", "surname": "Osorio", "surname2": "Lizarazo"}, "file": "Q5696422", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "José Antonio Osorio Lizarazo"} {"composed": false, "fields": {"given": "Armando", "surname": "Domínguez", "surname2": "Borrás"}, "file": "Q5704474", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Armando Domínguez Borrás"} {"composed": false, "fields": {"given": "Aurelio", "surname": "García", "surname2": "Lesmes"}, "file": "Q5711984", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Aurelio García Lesmes"} {"composed": false, "fields": {"given": "Carlos", "surname": "García", "surname2": "y Bocanegra"}, "file": "Q5750459", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Carlos García y Bocanegra"} {"composed": false, "fields": {"given": "Carlos", "surname": "Obregón", "surname2": "Borrero"}, "file": "Q5751162", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Carlos Obregón Borrero"} {"composed": false, "fields": {"given": "Engracia", "surname": "Vidal", "surname2": "Estévez"}, "file": "Q57553590", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Engracia Vidal Estévez"} {"composed": false, "fields": {"given": "Mohd Najib", "patronymic": "bin Abdul Razak"}, "file": "Q57675", "label": "patronymic-my", "order": "givenFirst", "shape": null, "text": "Mohd Najib bin Abdul Razak"} {"composed": false, "fields": {"given": "Jóhanna", "patronymic": "Sigurðardóttir"}, "file": "Q57772", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Jóhanna Sigurðardóttir"} {"composed": false, "fields": {"given": "Diego", "surname": "Uribe", "surname2": "Vargas"}, "file": "Q5806647", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Diego Uribe Vargas"} {"composed": false, "fields": {"given": "José", "surname": "Francos", "surname2": "Rodríguez"}, "file": "Q5846831", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "José Francos Rodríguez"} {"composed": false, "fields": {"given": "Jón Páll", "patronymic": "Sigmarsson"}, "file": "Q585266", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Jón Páll Sigmarsson"} {"composed": false, "fields": {"given": "Felipe", "surname": "Bermejo", "surname2": "Araujo"}, "file": "Q5858108", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Felipe Bermejo Araujo"} {"composed": false, "fields": {"given": "Felipe José", "surname": "Abárzuza", "surname2": "y Oliva"}, "file": "Q5858263", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Felipe José Abárzuza y Oliva"} {"composed": false, "fields": {"given": "Fernando", "surname": "Gordillo", "surname2": "Escudero"}, "file": "Q5859665", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Fernando Gordillo Escudero"} {"composed": false, "fields": {"given": "Fernando", "surname": "Hiriart", "surname2": "Balderrama"}, "file": "Q5859707", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Fernando Hiriart Balderrama"} {"composed": false, "fields": {"given": "Francisco", "surname": "Díez-Barroso", "surname2": "Govantes"}, "file": "Q5865541", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Francisco Díez-Barroso Govantes"} {"composed": false, "fields": {"given": "Francisco", "surname": "Martínez", "surname2": "de Cenicero"}, "file": "Q5866696", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Francisco Martínez de Cenicero"} {"composed": false, "fields": {"given": "Людмила", "patronymic": "Васильевна", "surname": "Журавлёва"}, "file": "Q58709", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Журавлёва, Людмила Васильевна"} {"composed": false, "fields": {"given": "Guillermo", "surname": "Rousset", "surname2": "Banda"}, "file": "Q5888901", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Guillermo Rousset Banda"} {"composed": false, "fields": {"given": "Gustavo", "surname": "León", "surname2": "González"}, "file": "Q5889711", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Gustavo León González"} {"composed": false, "fields": {"given": "Hera", "patronymic": "Hilmarsdóttir"}, "file": "Q5894640", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Hera Hilmarsdóttir"} {"composed": false, "fields": {"given": "Gylfi", "patronymic": "Sigurðsson"}, "file": "Q59105", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Gylfi Sigurðsson"} {"composed": false, "fields": {"given": "Jaime", "surname": "Blanco", "surname2": "García"}, "file": "Q5924935", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Jaime Blanco García"} {"composed": false, "fields": {"given": "Jaime", "surname": "García", "surname2": "Terrés"}, "file": "Q5925060", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Jaime García Terrés"} {"composed": false, "fields": {"given": "Jesús", "surname": "Gallego", "surname2": "Marquina"}, "file": "Q5930137", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Jesús Gallego Marquina"} {"composed": false, "fields": {"given": "Joaquín", "surname": "Traggia", "surname2": "Uribarri"}, "file": "Q5932430", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Joaquín Traggia Uribarri"} {"composed": false, "fields": {"given": "Josep", "surname": "Vergés", "surname2": "i Vallmajor"}, "file": "Q5936948", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Josep Vergés i Vallmajor"} {"composed": false, "fields": {"given": "José María", "surname": "Díaz", "surname2": "Casariego"}, "file": "Q5942856", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "José María Díaz Casariego"} {"composed": false, "fields": {"given": "José María", "surname": "Otaolaurruchi", "surname2": "Tobía"}, "file": "Q5943286", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "José María Otaolaurruchi Tobía"} {"composed": false, "fields": {"given": "Juan", "surname": "Suñé", "surname2": "Benages"}, "file": "Q5952614", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Juan Suñé Benages"} {"composed": false, "fields": {"given": "Juan", "surname": "de Vera y Aragón", "surname2": "Torres"}, "file": "Q5952704", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Juan Torres de Vera y Aragón"} {"composed": false, "fields": {"given": "Hansína Regína", "patronymic": "Björnsdóttir"}, "file": "Q59747508", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Hansína Regína Björnsdóttir"} {"composed": false, "fields": {"given": "Александр", "patronymic": "Емельянович", "surname": "Хмель"}, "file": "Q59938868", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Александр Емельянович Хмель"} {"composed": false, "fields": {"given": "Manuel", "surname": "Puga", "surname2": "y Acal"}, "file": "Q5994074", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Manuel Puga y Acal"} {"composed": false, "fields": {"given": "Алексей", "patronymic": "Николаевич", "surname": "Плещеев"}, "file": "Q600678", "label": "patronymic-ri", "order": "givenFirst", "shape": null, "text": "Алексей Николаевич Плещеев"} {"composed": false, "fields": {"given": "Pablo Emilio", "surname": "Riveros", "surname2": "Reina"}, "file": "Q6055948", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Pablo Emilio Riveros Reina"} {"composed": false, "fields": {"given": "Antonio", "surname": "Alvarez", "surname2": "Lleras"}, "file": "Q606189", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Antonio Alvarez Lleras"} {"composed": false, "fields": {"given": "Pedro", "surname": "Galindo", "surname2": "Galarza"}, "file": "Q6068853", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Pedro Galindo Galarza"} {"composed": false, "fields": {"given": "Дмитрий", "patronymic": "Александрович", "surname": "Корягин"}, "file": "Q60695279", "label": "patronymic-su", "order": "sorting", "shape": null, "text": "Корягин, Дмитрий Александрович"} {"composed": false, "fields": {"given": "Pedro", "surname": "Morales", "surname2": "Pino"}, "file": "Q6069555", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Pedro Morales Pino"} {"composed": false, "fields": {"given": "Rafael", "surname": "Rubio", "surname2": "Carrión"}, "file": "Q6097928", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Rafael Rubio Carrión"} {"composed": false, "fields": {"given": "Roberto", "surname": "Escobar", "surname2": "Gaviria"}, "file": "Q6109726", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Roberto Escobar Gaviria"} {"composed": false, "fields": {"given": "Rodolfo", "surname": "Landeros", "surname2": "Gallegos"}, "file": "Q6110535", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Rodolfo Landeros Gallegos"} {"composed": false, "fields": {"given": "Tiburcio", "surname": "Pérez", "surname2": "Cuervo"}, "file": "Q6146414", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Tiburcio Pérez Cuervo"} {"composed": false, "fields": {"given": "Vicente", "surname": "Rodríguez", "surname2": "Casado"}, "file": "Q6161316", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Vicente Rodríguez Casado"} {"composed": false, "fields": {"given": "Víctor", "surname": "Luna", "surname2": "Gómez"}, "file": "Q6165597", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Víctor Luna Gómez"} {"composed": false, "fields": {"given": "Николай", "patronymic": "Петрович", "surname": "Евтеев"}, "file": "Q61668399", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Евтеев, Николай Петрович"} {"composed": false, "fields": {"given": "Rogerio", "surname": "Azcárraga", "surname2": "Madero"}, "file": "Q61697989", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Rogerio Azcárraga Madero"} {"composed": false, "fields": {"given": "Александр", "patronymic": "Петрович", "surname": "Кузнецов"}, "file": "Q617112", "label": "patronymic-su", "order": "sorting", "shape": null, "text": "Кузнецов, Александр Петрович"} {"composed": false, "fields": {"given": "Tomás", "surname": "Iglesias", "surname2": "Pérez"}, "file": "Q61743963", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Tomás Iglesias Pérez"} {"composed": false, "fields": {"given": "Arnbjörg Auður", "patronymic": "Örnólfsdóttir"}, "file": "Q61972124", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Arnbjörg Auður Örnólfsdóttir"} {"composed": false, "fields": {"given": "Dóra", "patronymic": "Sigurðsson"}, "file": "Q61972280", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Dóra Sigurðsson"} {"composed": false, "fields": {"given": "Elísabet", "patronymic": "Erlingsdóttir"}, "file": "Q61972351", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Elísabet Erlingsdóttir"} {"composed": false, "fields": {"given": "Guðmunda", "patronymic": "Elíasdóttir"}, "file": "Q61972491", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Guðmunda Elíasdóttir"} {"composed": false, "fields": {"given": "Sigrún", "patronymic": "Jónsdóttir"}, "file": "Q61973674", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Sigrún Jónsdóttir"} {"composed": false, "fields": {"given": "Þuríður", "patronymic": "Pálsdóttir"}, "file": "Q61974032", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Þuríður Pálsdóttir"} {"composed": false, "fields": {"given": "Laura Ángeles", "surname": "Lastra", "surname2": "Santos"}, "file": "Q62837944", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Laura Ángeles Lastra Santos"} {"composed": false, "fields": {"given": "Rosa", "surname": "Castillo", "surname2": "Santiago"}, "file": "Q62995489", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Rosa Castillo Santiago"} {"composed": false, "fields": {"given": "Juan", "surname": "de Courten", "surname2": "y González"}, "file": "Q6301224", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Juan de Courten y González"} {"composed": false, "fields": {"given": "Александр", "patronymic": "Александрович", "surname": "Кухаренко"}, "file": "Q63196700", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Александр Александрович Кухаренко"} {"composed": false, "fields": {"given": "Александр", "patronymic": "Александрович", "surname": "Кухаренко"}, "file": "Q63196700", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Кухаренко, Александр Александрович"} {"composed": false, "fields": {"given": "Aniceto", "surname": "Fernández", "surname2": "Alonso"}, "file": "Q63761164", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Aniceto Fernández Alonso"} {"composed": false, "fields": {"given": "Alberto", "surname": "Abello", "surname2": "Vives"}, "file": "Q63970106", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Alberto Abello Vives"} {"composed": false, "fields": {"given": "Miguel", "surname": "García", "surname2": "Martínez"}, "file": "Q64509480", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Miguel García Martínez"} {"composed": false, "fields": {"given": "Frederic", "surname": "Bosch", "surname2": "i Serra"}, "file": "Q64691618", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Frederic Bosch i Serra"} {"composed": false, "fields": {"given": "Сергей", "patronymic": "Дмитриевич", "surname": "Остапов"}, "file": "Q65201524", "label": "patronymic-su", "order": "givenFirst", "shape": null, "text": "Сергей Дмитриевич Остапов"} {"composed": false, "fields": {"given": "Игорь", "patronymic": "Дмитриевич", "surname": "Серебряков"}, "file": "Q658050", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Игорь Дмитриевич Серебряков"} {"composed": false, "fields": {"given": "Геннадий", "patronymic": "Германович", "surname": "Комиссаров"}, "file": "Q66124882", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Геннадий Германович Комиссаров"} {"composed": false, "fields": {"given": "Геннадий", "patronymic": "Германович", "surname": "Комиссаров"}, "file": "Q66124882", "label": "patronymic-ru", "order": "surnameFirst", "shape": 4, "text": "Комиссаров Геннадий Германович"} {"composed": false, "fields": {"given": "Carlos", "surname": "Landín", "surname2": "Martínez"}, "file": "Q66464825", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Carlos Landín Martínez"} {"composed": false, "fields": {"given": "Azhar", "patronymic": "Ibrahim"}, "file": "Q6672254", "label": "patronymic-my", "order": "givenFirst", "shape": null, "text": "Azhar Ibrahim"} {"composed": false, "fields": {"given": "Eduardo", "surname": "Iglesias", "surname2": "Añino"}, "file": "Q6737805", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Eduardo Iglesias Añino"} {"composed": false, "fields": {"given": "Mariano", "surname": "Aguilar", "surname2": "Barrera"}, "file": "Q6762303", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Mariano Aguilar Barrera"} {"composed": false, "fields": {"given": "Михаил", "patronymic": "Михайлович", "surname": "Адамович"}, "file": "Q6849481", "label": "patronymic-su", "order": "sorting", "shape": null, "text": "Адамович, Михаил Михайлович"} {"composed": false, "fields": {"given": "Arnar", "patronymic": "Gunnlaugsson"}, "file": "Q690876", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Arnar Gunnlaugsson"} {"composed": false, "fields": {"given": "Nanna Bryndís", "patronymic": "Hilmarsdóttir"}, "file": "Q6963798", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Nanna Bryndís Hilmarsdóttir"} {"composed": false, "fields": {"given": "Jaime", "surname": "Mayor", "surname2": "Oreja"}, "file": "Q701362", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Jaime Mayor Oreja"} {"composed": false, "fields": {"given": "Иосиф", "patronymic": "Владимирович", "surname": "Гурко"}, "file": "Q701635", "label": "patronymic-ri", "order": "givenFirst", "shape": null, "text": "Иосиф Владимирович Гурко"} {"composed": false, "fields": {"given": "Narcís", "surname": "Monturiol", "surname2": "i Estarriol"}, "file": "Q713959", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Narcís Monturiol i Estarriol"} {"composed": false, "fields": {"given": "Артур", "patronymic": "Валерьевич", "surname": "Дмитриев"}, "file": "Q714964", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Артур Валерьевич Дмитриев"} {"composed": false, "fields": {"given": "Артур", "patronymic": "Валерьевич", "surname": "Дмитриев"}, "file": "Q714964", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Дмитриев, Артур Валерьевич"} {"composed": false, "fields": {"given": "Bjarni", "patronymic": "Viðarsson"}, "file": "Q723705", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Bjarni Viðarsson"} {"composed": false, "fields": {"given": "Misael Eduardo", "surname": "Pastrana", "surname2": "Borrero"}, "file": "Q728833", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Misael Eduardo Pastrana Borrero"} {"composed": false, "fields": {"given": "Ricardo", "surname": "Legorreta", "surname2": "Vilchis"}, "file": "Q732502", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Ricardo Legorreta Vilchis"} {"composed": false, "fields": {"given": "Нина", "patronymic": "Александровна", "surname": "Андреева"}, "file": "Q7429", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Нина Александровна Андреева"} {"composed": false, "fields": {"given": "Saúl", "surname": "González", "surname2": "Herrera"}, "file": "Q7429440", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Saúl González Herrera"} {"composed": false, "fields": {"given": "Fernando", "surname": "de Borbón", "surname2": "y Medina"}, "file": "Q75370021", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Fernando de Borbón y Medina"} {"composed": false, "fields": {"given": "Николай", "patronymic": "Георгиевич", "surname": "Зырин"}, "file": "Q76718786", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Николай Георгиевич Зырин"} {"composed": false, "fields": {"given": "Владимир", "patronymic": "Владимирович", "surname": "Путин"}, "file": "Q7747", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Владимир Владимирович Путин"} {"composed": false, "fields": {"given": "Владислав", "patronymic": "Иванович", "surname": "Мамонтов"}, "file": "Q78286708", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Владислав Иванович Мамонтов"} {"composed": false, "fields": {"given": "Una", "patronymic": "Guðmundsdóttir"}, "file": "Q7882106", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Una Guðmundsdóttir"} {"composed": false, "fields": {"given": "Сергей", "patronymic": "Михайлович", "surname": "Эйзенштейн"}, "file": "Q8003", "label": "patronymic-su", "order": "givenFirst", "shape": null, "text": "Сергей Михайлович Эйзенштейн"} {"composed": false, "fields": {"given": "Severo", "surname": "Aguirre", "surname2": "Miramón"}, "file": "Q81619240", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Severo Aguirre Miramón"} {"composed": false, "fields": {"given": "Felipe", "surname": "Valdés", "surname2": "y López"}, "file": "Q81620714", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Felipe Valdés y López"} {"composed": false, "fields": {"given": "Benjamín", "surname": "Castillo", "surname2": "Plascencia"}, "file": "Q817764", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Benjamín Castillo Plascencia"} {"composed": false, "fields": {"given": "Anna Maria", "surname": "Martínez", "surname2": "Sagi"}, "file": "Q8198059", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Anna Maria Martínez Sagi"} {"composed": false, "fields": {"given": "Валерий", "patronymic": "Иванович", "surname": "Фоменков"}, "file": "Q82381", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Фоменков, Валерий Иванович"} {"composed": false, "fields": {"given": "Carlos", "surname": "Romero", "surname2": "Deschamps"}, "file": "Q8338233", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Carlos Romero Deschamps"} {"composed": false, "fields": {"given": "Jesús", "surname": "Alcolea", "surname2": "Fernández"}, "file": "Q83983039", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Jesús Alcolea Fernández"} {"composed": false, "fields": {"given": "María del Pilar", "surname": "Hurtado", "surname2": "Montaño"}, "file": "Q85632107", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "María del Pilar Hurtado Montaño"} {"composed": false, "fields": {"given": "Felipe Benicio", "surname": "Navarro", "surname2": "y Reig"}, "file": "Q85633810", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Felipe Benicio Navarro y Reig"} {"composed": false, "fields": {"given": "Alexander", "surname": "Parra", "surname2": "Uribe"}, "file": "Q85689041", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Alexander Parra Uribe"} {"composed": false, "fields": {"given": "Vicente", "surname": "Gómez", "surname2": "Mier"}, "file": "Q85740163", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Vicente Gómez Mier"} {"composed": false, "fields": {"given": "Павел", "patronymic": "Фёдорович", "surname": "Антипов"}, "file": "Q86094778", "label": "patronymic-ru", "order": "sorting", "shape": null, "text": "Антипов, Павел Фёдорович"} {"composed": false, "fields": {"given": "Gabriela", "surname": "Abad", "surname2": "Miró"}, "file": "Q87124000", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Gabriela Abad Miró"} {"composed": false, "fields": {"given": "Наталья", "patronymic": "Арсентьевна", "surname": "Якубович"}, "file": "Q87463195", "label": "patronymic-ru", "order": "givenFirst", "shape": null, "text": "Наталья Арсентьевна Якубович"} {"composed": false, "fields": {"given": "Björn", "patronymic": "Bjarnason"}, "file": "Q879568", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Björn Bjarnason"} {"composed": false, "fields": {"given": "Eulalio", "surname": "Ríos", "surname2": "Alemán"}, "file": "Q8846206", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Eulalio Ríos Alemán"} {"composed": false, "fields": {"given": "José", "surname": "Perianes", "surname2": "Carro"}, "file": "Q88677122", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "José Perianes Carro"} {"composed": false, "fields": {"given": "Joan", "surname": "Canals", "surname2": "i Pigem"}, "file": "Q89408005", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Joan Canals i Pigem"} {"composed": false, "fields": {"given": "Felip", "surname": "Lorda", "surname2": "i Alaiz"}, "file": "Q8961097", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Felip Lorda i Alaiz"} {"composed": false, "fields": {"given": "Llorenç", "surname": "Ferrer", "surname2": "Martí"}, "file": "Q9023260", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Llorenç Ferrer Martí"} {"composed": false, "fields": {"given": "Manuel", "surname": "Sánchez", "surname2": "Mármol"}, "file": "Q9028171", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Manuel Sánchez Mármol"} {"composed": false, "fields": {"given": "María", "surname": "Rubio", "surname2": "Tejero"}, "file": "Q9030148", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "María Rubio Tejero"} {"composed": false, "fields": {"given": "Rafael", "surname": "Ruiz", "surname2": "Balerdi"}, "file": "Q9065896", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Rafael Ruiz Balerdi"} {"composed": false, "fields": {"given": "Salvador", "surname": "Aguirre", "surname2": "Anguiano"}, "file": "Q9072991", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Salvador Aguirre Anguiano"} {"composed": false, "fields": {"given": "Antonio", "surname": "López", "surname2": "Aviña"}, "file": "Q9157913", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Antonio López Aviña"} {"composed": false, "fields": {"given": "Rafael", "surname": "Aguayo", "surname2": "Spencer"}, "file": "Q93221888", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Rafael Aguayo Spencer"} {"composed": false, "fields": {"given": "Eugeni", "surname": "Aguiló", "surname2": "i Pérez"}, "file": "Q93370135", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Eugeni Aguiló i Pérez"} {"composed": false, "fields": {"given": "José Manuel", "surname": "López", "surname2": "de Abiada"}, "file": "Q93419447", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "José Manuel López de Abiada"} {"composed": false, "fields": {"given": "José María", "surname": "Luzón", "surname2": "Cuesta"}, "file": "Q93432465", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "José María Luzón Cuesta"} {"composed": false, "fields": {"given": "Jacinto", "surname": "López", "surname2": "Moreno"}, "file": "Q94643621", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Jacinto López Moreno"} {"composed": false, "fields": {"given": "Joaquín", "surname": "Odriozola", "surname2": "y Grimaud"}, "file": "Q94657090", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Joaquín Odriozola y Grimaud"} {"composed": false, "fields": {"given": "Josep", "surname": "Ibáñez", "surname2": "i Senserrich"}, "file": "Q95422056", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Josep Ibáñez i Senserrich"} {"composed": false, "fields": {"given": "Kristinn", "patronymic": "Hrafnsson"}, "file": "Q956043", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Kristinn Hrafnsson"} {"composed": false, "fields": {"given": "Crisanto", "surname": "Luque", "surname2": "Sánchez"}, "file": "Q959666", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Crisanto Luque Sánchez"} {"composed": false, "fields": {"given": "Francis Anel", "surname": "Bueno", "surname2": "Sánchez"}, "file": "Q96060653", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Francis Anel Bueno Sánchez"} {"composed": false, "fields": {"given": "Francisco Javier", "surname": "Fortea", "surname2": "Pérez"}, "file": "Q96063461", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Francisco Javier Fortea Pérez"} {"composed": false, "fields": {"given": "Antonio", "surname": "Atienza", "surname2": "y Medrano"}, "file": "Q96181785", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Antonio Atienza y Medrano"} {"composed": false, "fields": {"given": "Pere", "surname": "Bosch", "surname2": "i Gimpera"}, "file": "Q965486", "label": "surname2-mx", "order": "givenFirst", "shape": null, "text": "Pere Bosch i Gimpera"} {"composed": false, "fields": {"given": "Roger", "surname": "Alier", "surname2": "i Aixalà"}, "file": "Q969084", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Roger Alier i Aixalà"} {"composed": false, "fields": {"given": "Nemesio", "surname": "Camacho", "surname2": "Macías"}, "file": "Q970917", "label": "surname2-co", "order": "givenFirst", "shape": null, "text": "Nemesio Camacho Macías"} {"composed": false, "fields": {"given": "Anuar", "patronymic": "bin Malek"}, "file": "Q97413787", "label": "patronymic-my", "order": "givenFirst", "shape": null, "text": "Anuar bin Malek"} {"composed": false, "fields": {"given": "Jón", "patronymic": "Árnason"}, "file": "Q975791", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Jón Árnason"} {"composed": false, "fields": {"given": "Pilar", "surname": "Fernández", "surname2": "Seijo"}, "file": "Q98069704", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Pilar Fernández Seijo"} {"composed": false, "fields": {"given": "Jón", "patronymic": "Helgason"}, "file": "Q984003", "label": "patronymic-is", "order": "givenFirst", "shape": null, "text": "Jón Helgason"} {"composed": false, "fields": {"given": "Juan", "surname": "Santos", "surname2": "Fernández"}, "file": "Q99318879", "label": "surname2-es", "order": "givenFirst", "shape": null, "text": "Juan Santos Fernández"} ������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/wikidata_native_LICENSE������������������������0000664�0000000�0000000�00000001455�15273032717�0026740�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������corpus_wikidata_native.jsonl and wikidata_native_fields.jsonl are derived from Wikidata (https://www.wikidata.org), read through the Wikidata Query Service and the wbgetentities API, and carry Wikidata's license. Wikidata states that "All structured data (i.e. the main, Property, Lexeme, and EntitySchema namespaces) is released into the public domain". https://www.wikidata.org/wiki/Wikidata:Licensing CC0 1.0 Universal: https://creativecommons.org/publicdomain/zero/1.0/ No attribution is required; this file credits the source anyway. Source: Wikidata, Wikimedia Foundation. Each line of wikidata_native_fields.jsonl names its item's QID, resolvable at https://www.wikidata.org/wiki/<QID>. Only public figures are sampled (deceased, or with at least 10 sitelinks), and only name strings and QIDs are kept. �������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/wikidata_native_agreement.json�����������������0000664�0000000�0000000�00000063661�15273032717�0030604�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������{ "Q100152143/zh-tw/surnameFirst": { "family": { "expected": "葉", "got": "葉陶" }, "given_names": { "expected": "陶", "got": "" }, "text": "葉陶" }, "Q102246922/ja-jp/surnameFirst": { "family": { "expected": "小柳", "got": "小柳俊一" }, "given_names": { "expected": "俊一", "got": "" }, "text": "小柳俊一" }, "Q103785817/zh-tw/surnameFirst": { "family": { "expected": "詹", "got": "詹桓" }, "given_names": { "expected": "桓", "got": "" }, "text": "詹桓" }, "Q103791050/zh-tw/surnameFirst": { "family": { "expected": "蔡", "got": "蔡金生" }, "given_names": { "expected": "金生", "got": "" }, "text": "蔡金生" }, "Q103791103/zh-tw/surnameFirst": { "family": { "expected": "李", "got": "李宗文" }, "given_names": { "expected": "宗文", "got": "" }, "text": "李宗文" }, "Q104782874/zh-cn/surnameFirst": { "family": { "expected": "黄", "got": "黄乃" }, "given_names": { "expected": "乃", "got": "" }, "text": "黄乃" }, "Q105260064/ja-jp/surnameFirst": { "family": { "expected": "上山", "got": "上山輝一" }, "given_names": { "expected": "輝一", "got": "" }, "text": "上山輝一" }, "Q108855843/ja-jp/surnameFirst": { "family": { "expected": "森", "got": "森卯一" }, "given_names": { "expected": "卯一", "got": "" }, "text": "森卯一" }, "Q10897593/zh-cn/surnameFirst": { "family": { "expected": "刘", "got": "刘放" }, "given_names": { "expected": "放", "got": "" }, "text": "刘放" }, "Q109343195/zh-tw/surnameFirst": { "family": { "expected": "蔡", "got": "蔡桂英" }, "given_names": { "expected": "桂英", "got": "" }, "text": "蔡桂英" }, "Q110406653/zh-cn/surnameFirst": { "family": { "expected": "朱", "got": "朱世英" }, "given_names": { "expected": "世英", "got": "" }, "text": "朱世英" }, "Q11085014/zh-tw/surnameFirst": { "family": { "expected": "方", "got": "方豪" }, "given_names": { "expected": "豪", "got": "" }, "text": "方豪" }, "Q111081424/ja-jp/surnameFirst": { "family": { "expected": "安達", "got": "安達禎" }, "given_names": { "expected": "禎", "got": "" }, "text": "安達禎" }, "Q111117644/ja-jp/surnameFirst": { "family": { "expected": "鈴木", "got": "鈴木亀蔵" }, "given_names": { "expected": "亀蔵", "got": "" }, "text": "鈴木亀蔵" }, "Q112577331/zh-tw/surnameFirst": { "family": { "expected": "何", "got": "何瑞麟" }, "given_names": { "expected": "瑞麟", "got": "" }, "text": "何瑞麟" }, "Q11356021/ja-jp/surnameFirst": { "family": { "expected": "三橋", "got": "三橋淳" }, "given_names": { "expected": "淳", "got": "" }, "text": "三橋淳" }, "Q113567973/ja-jp/surnameFirst": { "family": { "expected": "平賀", "got": "平賀淳" }, "given_names": { "expected": "淳", "got": "" }, "text": "平賀淳" }, "Q11363041/ja-jp/surnameFirst": { "family": { "expected": "中内", "got": "中内力" }, "given_names": { "expected": "力", "got": "" }, "text": "中内力" }, "Q113644075/zh-tw/surnameFirst": { "family": { "expected": "李", "got": "李瞻" }, "given_names": { "expected": "瞻", "got": "" }, "text": "李瞻" }, "Q11364982/ja-jp/surnameFirst": { "family": { "expected": "中川", "got": "中川龍一" }, "given_names": { "expected": "龍一", "got": "" }, "text": "中川龍一" }, "Q11394360/ja-jp/surnameFirst": { "family": { "expected": "内田", "got": "内田誠" }, "given_names": { "expected": "誠", "got": "" }, "text": "内田誠" }, "Q11394483/ja-jp/surnameFirst": { "family": { "expected": "内藤", "got": "内藤博文" }, "given_names": { "expected": "博文", "got": "" }, "text": "内藤博文" }, "Q11412770/ja-jp/surnameFirst": { "family": { "expected": "吉尾", "got": "吉尾弘" }, "given_names": { "expected": "弘", "got": "" }, "text": "吉尾弘" }, "Q1142423/ja-jp/surnameFirst": { "family": { "expected": "安倍", "got": "安倍なつみ" }, "given_names": { "expected": "なつみ", "got": "" }, "text": "安倍なつみ" }, "Q11436998/ja-jp/surnameFirst": { "family": { "expected": "大沢", "got": "大沢衛" }, "given_names": { "expected": "衛", "got": "" }, "text": "大沢衛" }, "Q11444084/ja-jp/surnameFirst": { "family": { "expected": "太田", "got": "太田三郎" }, "given_names": { "expected": "三郎", "got": "" }, "text": "太田三郎" }, "Q11461529/ja-jp/surnameFirst": { "family": { "expected": "小林", "got": "小林宏" }, "given_names": { "expected": "宏", "got": "" }, "text": "小林宏" }, "Q11461743/ja-jp/surnameFirst": { "family": { "expected": "小林", "got": "小林由佳" }, "given_names": { "expected": "由佳", "got": "" }, "text": "小林由佳" }, "Q11498424/ja-jp/surnameFirst": { "family": { "expected": "揖斐", "got": "揖斐章" }, "given_names": { "expected": "章", "got": "" }, "text": "揖斐章" }, "Q11523418/ja-jp/surnameFirst": { "family": { "expected": "村田", "got": "村田喜一" }, "given_names": { "expected": "喜一", "got": "" }, "text": "村田喜一" }, "Q11553704/ja-jp/surnameFirst": { "family": { "expected": "河合", "got": "河合亨" }, "given_names": { "expected": "亨", "got": "" }, "text": "河合亨" }, "Q11570608/ja-jp/surnameFirst": { "family": { "expected": "牧野", "got": "牧野虎雄" }, "given_names": { "expected": "虎雄", "got": "" }, "text": "牧野虎雄" }, "Q11596044/ja-jp/surnameFirst": { "family": { "expected": "秦", "got": "秦彦三郎" }, "given_names": { "expected": "彦三郎", "got": "" }, "text": "秦彦三郎" }, "Q11604183/ja-jp/surnameFirst": { "family": { "expected": "米川", "got": "米川信夫" }, "given_names": { "expected": "信夫", "got": "" }, "text": "米川信夫" }, "Q11608580/zh-cn/surnameFirst": { "family": { "expected": "罗", "got": "罗建文" }, "given_names": { "expected": "建文", "got": "" }, "text": "罗建文" }, "Q11618744/ja-jp/surnameFirst": { "family": { "expected": "荒木", "got": "荒木茂" }, "given_names": { "expected": "茂", "got": "" }, "text": "荒木茂" }, "Q11631995/ja-jp/surnameFirst": { "family": { "expected": "諫山", "got": "諫山博" }, "given_names": { "expected": "博", "got": "" }, "text": "諫山博" }, "Q11638789/ja-jp/surnameFirst": { "family": { "expected": "近藤", "got": "近藤平三郎" }, "given_names": { "expected": "平三郎", "got": "" }, "text": "近藤平三郎" }, "Q11642264/ja-jp/surnameFirst": { "family": { "expected": "遠藤", "got": "遠藤晴" }, "given_names": { "expected": "晴", "got": "" }, "text": "遠藤晴" }, "Q11671502/ja-jp/surnameFirst": { "family": { "expected": "高橋", "got": "高橋章" }, "given_names": { "expected": "章", "got": "" }, "text": "高橋章" }, "Q118125689/ja-jp/surnameFirst": { "family": { "expected": "渋谷", "got": "渋谷健一" }, "given_names": { "expected": "健一", "got": "" }, "text": "渋谷健一" }, "Q121061159/ar-sy/givenFirst": { "family": { "expected": "ذو الغنى", "got": "الغنى" }, "given_names": { "expected": "عائشة", "got": "عائشة ذو" }, "text": "عائشة ذو الغنى" }, "Q12221498/ar-eg/givenFirst": { "family": { "expected": "عبد العزيز", "got": "العزيز" }, "given_names": { "expected": "طارق", "got": "طارق عبد" }, "text": "طارق عبد العزيز" }, "Q123945439/zh-tw/surnameFirst": { "family": { "expected": "朱", "got": "朱霖" }, "given_names": { "expected": "霖", "got": "" }, "text": "朱霖" }, "Q125699198/ar-eg/givenFirst": { "family": { "expected": "عز الدين", "got": "الدين" }, "given_names": { "expected": "جميل", "got": "جميل عز" }, "text": "جميل عز الدين" }, "Q127605824/zh-tw/surnameFirst": { "family": { "expected": "卫", "got": "卫惠林" }, "given_names": { "expected": "惠林", "got": "" }, "text": "卫惠林" }, "Q1318216/zh-cn/surnameFirst": { "family": { "expected": "杨", "got": "杨旭" }, "given_names": { "expected": "旭", "got": "" }, "text": "杨旭" }, "Q1335601/zh-cn/surnameFirst": { "family": { "expected": "宋", "got": "宋楠" }, "given_names": { "expected": "楠", "got": "" }, "text": "宋楠" }, "Q134298671/ar-sy/givenFirst": { "family": { "expected": "عبد العزيز", "got": "العزيز" }, "given_names": { "expected": "سمر", "got": "سمر عبد" }, "text": "سمر عبد العزيز" }, "Q1365182/zh-cn/surnameFirst": { "family": { "expected": "孟", "got": "孟建柱" }, "given_names": { "expected": "建柱", "got": "" }, "text": "孟建柱" }, "Q138590768/ja-jp/surnameFirst": { "family": { "expected": "駒井", "got": "駒井玲子" }, "given_names": { "expected": "玲子", "got": "" }, "text": "駒井玲子" }, "Q1447095/zh-tw/surnameFirst": { "family": { "expected": "黎", "got": "黎智英" }, "given_names": { "expected": "智英", "got": "" }, "text": "黎智英" }, "Q15916581/zh-cn/surnameFirst": { "family": { "expected": "韩", "got": "韩光" }, "given_names": { "expected": "光", "got": "" }, "text": "韩光" }, "Q15950247/zh-tw/surnameFirst": { "family": { "expected": "邵", "got": "邵毓麟" }, "given_names": { "expected": "毓麟", "got": "" }, "text": "邵毓麟" }, "Q15950777/zh-cn/surnameFirst": { "family": { "expected": "王", "got": "王雪莹" }, "given_names": { "expected": "雪莹", "got": "" }, "text": "王雪莹" }, "Q16097636/ja-jp/surnameFirst": { "family": { "expected": "伊豆田", "got": "伊豆田莉奈" }, "given_names": { "expected": "莉奈", "got": "" }, "text": "伊豆田莉奈" }, "Q16230492/ar-eg/givenFirst": { "family": { "expected": "شكر الله", "got": "الله" }, "given_names": { "expected": "هالة", "got": "هالة شكر" }, "text": "هالة شكر الله" }, "Q16262307/zh-cn/surnameFirst": { "family": { "expected": "柯", "got": "柯洁" }, "given_names": { "expected": "洁", "got": "" }, "text": "柯洁" }, "Q16539540/zh-cn/surnameFirst": { "family": { "expected": "邓", "got": "邓志伟" }, "given_names": { "expected": "志伟", "got": "" }, "text": "邓志伟" }, "Q17020840/zh-cn/surnameFirst": { "family": { "expected": "刘", "got": "刘维" }, "given_names": { "expected": "维", "got": "" }, "text": "刘维" }, "Q18653800/zh-tw/surnameFirst": { "family": { "expected": "李", "got": "李影" }, "given_names": { "expected": "影", "got": "" }, "text": "李影" }, "Q191916/zh-tw/surnameFirst": { "family": { "expected": "周", "got": "周美玲" }, "given_names": { "expected": "美玲", "got": "" }, "text": "周美玲" }, "Q19825302/zh-cn/surnameFirst": { "family": { "expected": "茅", "got": "茅祖權" }, "given_names": { "expected": "祖權", "got": "" }, "text": "茅祖權" }, "Q20063761/zh-cn/surnameFirst": { "family": { "expected": "沈", "got": "沈鵬" }, "given_names": { "expected": "鵬", "got": "" }, "text": "沈鵬" }, "Q2017050/ja-jp/surnameFirst": { "family": { "expected": "岡", "got": "岡正雄" }, "given_names": { "expected": "正雄", "got": "" }, "text": "岡正雄" }, "Q202597/zh-cn/surnameFirst": { "family": { "expected": "陈", "got": "陈凯歌" }, "given_names": { "expected": "凯歌", "got": "" }, "text": "陈凯歌" }, "Q2057423/ja-jp/surnameFirst": { "family": { "expected": "吉川", "got": "吉川亨" }, "given_names": { "expected": "亨", "got": "" }, "text": "吉川亨" }, "Q21074354/ur-in/givenFirst": { "family": { "expected": "اگنی ہوتری", "got": "ہوتری" }, "given_names": { "expected": "انیتا", "got": "انیتا اگنی" }, "text": "انیتا اگنی ہوتری" }, "Q21438706/ja-jp/surnameFirst": { "family": { "expected": "桜井", "got": "桜井昌司" }, "given_names": { "expected": "昌司", "got": "" }, "text": "桜井昌司" }, "Q2275823/zh-cn/surnameFirst": { "family": { "expected": "罗", "got": "罗隆基" }, "given_names": { "expected": "隆基", "got": "" }, "text": "罗隆基" }, "Q233902/ja-jp/surnameFirst": { "family": { "expected": "朴", "got": "朴璐美" }, "given_names": { "expected": "璐美", "got": "" }, "text": "朴璐美" }, "Q236282/zh-cn/surnameFirst": { "family": { "expected": "杨", "got": "杨扬" }, "given_names": { "expected": "扬", "got": "" }, "text": "杨扬" }, "Q2404303/ja-jp/surnameFirst": { "family": { "expected": "藤田", "got": "藤田信雄" }, "given_names": { "expected": "信雄", "got": "" }, "text": "藤田信雄" }, "Q2444179/bg-bg/givenFirst": { "family": { "expected": "Цветков Дерменджиев", "got": "Дерменджиев" }, "given_names": { "expected": "Динко", "got": "Динко Цветков" }, "text": "Динко Цветков Дерменджиев" }, "Q253708/zh-tw/surnameFirst": { "family": { "expected": "陈", "got": "陈静" }, "given_names": { "expected": "静", "got": "" }, "text": "陈静" }, "Q263322/zh-tw/surnameFirst": { "family": { "expected": "趙", "got": "趙小蘭" }, "given_names": { "expected": "小蘭", "got": "" }, "text": "趙小蘭" }, "Q26776575/ja-jp/surnameFirst": { "family": { "expected": "佐々木", "got": "佐々木謙一郎" }, "given_names": { "expected": "謙一郎", "got": "" }, "text": "佐々木謙一郎" }, "Q2789989/zh-cn/surnameFirst": { "family": { "expected": "汪", "got": "汪顺" }, "given_names": { "expected": "顺", "got": "" }, "text": "汪顺" }, "Q27914098/ar-sy/givenFirst": { "family": { "expected": "عبد الواحد", "got": "الواحد" }, "given_names": { "expected": "نورس", "got": "نورس عبد" }, "text": "نورس عبد الواحد" }, "Q28068683/ja-jp/surnameFirst": { "family": { "expected": "湯浅", "got": "湯浅浩" }, "given_names": { "expected": "浩", "got": "" }, "text": "湯浅浩" }, "Q28355175/zh-cn/surnameFirst": { "family": { "expected": "魏", "got": "魏曦" }, "given_names": { "expected": "曦", "got": "" }, "text": "魏曦" }, "Q28417390/zh-tw/surnameFirst": { "family": { "expected": "陳", "got": "陳明忠" }, "given_names": { "expected": "明忠", "got": "" }, "text": "陳明忠" }, "Q313598/zh-tw/surnameFirst": { "family": { "expected": "蔣", "got": "蔣經國" }, "given_names": { "expected": "經國", "got": "" }, "text": "蔣經國" }, "Q3244756/ja-jp/surnameFirst": { "family": { "expected": "高田", "got": "高田栄二" }, "given_names": { "expected": "栄二", "got": "" }, "text": "高田栄二" }, "Q3245427/ja-jp/surnameFirst": { "family": { "expected": "多田", "got": "多田大介" }, "given_names": { "expected": "大介", "got": "" }, "text": "多田大介" }, "Q3603413/ar-sy/givenFirst": { "family": { "expected": "عبد الحميد", "got": "الحميد" }, "given_names": { "expected": "عبد اللطيف", "got": "عبد اللطيف عبد" }, "text": "عبد اللطيف عبد الحميد" }, "Q360402/zh-cn/surnameFirst": { "family": { "expected": "謝", "got": "謝暉" }, "given_names": { "expected": "暉", "got": "" }, "text": "謝暉" }, "Q37878853/ja-jp/surnameFirst": { "family": { "expected": "柴山", "got": "柴山景綱" }, "given_names": { "expected": "景綱", "got": "" }, "text": "柴山景綱" }, "Q39040747/zh-cn/surnameFirst": { "family": { "expected": "华", "got": "华楠" }, "given_names": { "expected": "楠", "got": "" }, "text": "华楠" }, "Q3991527/zh-cn/surnameFirst": { "family": { "expected": "陳", "got": "陳婷" }, "given_names": { "expected": "婷", "got": "" }, "text": "陳婷" }, "Q422959/ja-jp/surnameFirst": { "family": { "expected": "桃田", "got": "桃田賢斗" }, "given_names": { "expected": "賢斗", "got": "" }, "text": "桃田賢斗" }, "Q445244/zh-cn/surnameFirst": { "family": { "expected": "郭", "got": "郭丹" }, "given_names": { "expected": "丹", "got": "" }, "text": "郭丹" }, "Q460899/zh-cn/surnameFirst": { "family": { "expected": "刘", "got": "刘相蓉" }, "given_names": { "expected": "相蓉", "got": "" }, "text": "刘相蓉" }, "Q463835/zh-cn/surnameFirst": { "family": { "expected": "韩", "got": "韩红" }, "given_names": { "expected": "红", "got": "" }, "text": "韩红" }, "Q46675/ur-in/givenFirst": { "family": { "expected": "رضا خان", "got": "خان" }, "given_names": { "expected": "اختر", "got": "اختر رضا" }, "text": "اختر رضا خان" }, "Q470059/zh-cn/surnameFirst": { "family": { "expected": "刘", "got": "刘旺" }, "given_names": { "expected": "旺", "got": "" }, "text": "刘旺" }, "Q4701131/ja-jp/surnameFirst": { "family": { "expected": "藤井", "got": "藤井旭" }, "given_names": { "expected": "旭", "got": "" }, "text": "藤井旭" }, "Q47462831/zh-cn/surnameFirst": { "family": { "expected": "崔", "got": "崔晓桐" }, "given_names": { "expected": "晓桐", "got": "" }, "text": "崔晓桐" }, "Q4801752/zh-cn/surnameFirst": { "family": { "expected": "刘", "got": "刘婉婷" }, "given_names": { "expected": "婉婷", "got": "" }, "text": "刘婉婷" }, "Q514117/zh-cn/surnameFirst": { "family": { "expected": "孙", "got": "孙悦" }, "given_names": { "expected": "悦", "got": "" }, "text": "孙悦" }, "Q551904/zh-tw/surnameFirst": { "family": { "expected": "蘇", "got": "蘇貞昌" }, "given_names": { "expected": "貞昌", "got": "" }, "text": "蘇貞昌" }, "Q5650855/zh-cn/surnameFirst": { "family": { "expected": "张", "got": "张翰" }, "given_names": { "expected": "翰", "got": "" }, "text": "张翰" }, "Q57273226/zh-cn/surnameFirst": { "family": { "expected": "李", "got": "李诗沣" }, "given_names": { "expected": "诗沣", "got": "" }, "text": "李诗沣" }, "Q58590/zh-cn/surnameFirst": { "family": { "expected": "姚", "got": "姚明" }, "given_names": { "expected": "明", "got": "" }, "text": "姚明" }, "Q589739/zh-cn/surnameFirst": { "family": { "expected": "夏", "got": "夏萍" }, "given_names": { "expected": "萍", "got": "" }, "text": "夏萍" }, "Q59828188/zh-cn/surnameFirst": { "family": { "expected": "韩", "got": "韩旭" }, "given_names": { "expected": "旭", "got": "" }, "text": "韩旭" }, "Q6145892/zh-tw/surnameFirst": { "family": { "expected": "葉", "got": "葉克強" }, "given_names": { "expected": "克強", "got": "" }, "text": "葉克強" }, "Q6147652/ja-jp/surnameFirst": { "family": { "expected": "東郷", "got": "東郷文彦" }, "given_names": { "expected": "文彦", "got": "" }, "text": "東郷文彦" }, "Q6538654/zh-cn/surnameFirst": { "family": { "expected": "李", "got": "李博" }, "given_names": { "expected": "博", "got": "" }, "text": "李博" }, "Q6539157/zh-cn/surnameFirst": { "family": { "expected": "李", "got": "李翔" }, "given_names": { "expected": "翔", "got": "" }, "text": "李翔" }, "Q65548699/fa-ir/givenFirst": { "family": { "expected": "ییلاقی اشرفی", "got": "اشرفی" }, "given_names": { "expected": "محمدعلی", "got": "محمدعلی ییلاقی" }, "text": "محمدعلی ییلاقی اشرفی" }, "Q6739942/ja-jp/surnameFirst": { "family": { "expected": "北野", "got": "北野誠" }, "given_names": { "expected": "誠", "got": "" }, "text": "北野誠" }, "Q702557/zh-tw/surnameFirst": { "family": { "expected": "張", "got": "張韶涵" }, "given_names": { "expected": "韶涵", "got": "" }, "text": "張韶涵" }, "Q703861/zh-cn/surnameFirst": { "family": { "expected": "王", "got": "王镇" }, "given_names": { "expected": "镇", "got": "" }, "text": "王镇" }, "Q70544716/ja-jp/surnameFirst": { "family": { "expected": "引地", "got": "引地良一" }, "given_names": { "expected": "良一", "got": "" }, "text": "引地良一" }, "Q716055/zh-tw/surnameFirst": { "family": { "expected": "郭", "got": "郭靜" }, "given_names": { "expected": "靜", "got": "" }, "text": "郭靜" }, "Q7396492/ja-jp/surnameFirst": { "family": { "expected": "亀井", "got": "亀井三郎" }, "given_names": { "expected": "三郎", "got": "" }, "text": "亀井三郎" }, "Q75078779/zh-tw/surnameFirst": { "family": { "expected": "陳", "got": "陳俊傑" }, "given_names": { "expected": "俊傑", "got": "" }, "text": "陳俊傑" }, "Q7927961/zh-cn/surnameFirst": { "family": { "expected": "文", "got": "文强" }, "given_names": { "expected": "强", "got": "" }, "text": "文强" }, "Q7976520/zh-tw/surnameFirst": { "family": { "expected": "白", "got": "白文正" }, "given_names": { "expected": "文正", "got": "" }, "text": "白文正" }, "Q8070736/zh-cn/surnameFirst": { "family": { "expected": "郑", "got": "郑爽" }, "given_names": { "expected": "爽", "got": "" }, "text": "郑爽" }, "Q8071157/zh-cn/surnameFirst": { "family": { "expected": "朱", "got": "朱旭" }, "given_names": { "expected": "旭", "got": "" }, "text": "朱旭" }, "Q8275740/zh-tw/surnameFirst": { "family": { "expected": "彭", "got": "彭孟緝" }, "given_names": { "expected": "孟緝", "got": "" }, "text": "彭孟緝" }, "Q8318748/zh-cn/surnameFirst": { "family": { "expected": "邓", "got": "邓逸凡" }, "given_names": { "expected": "逸凡", "got": "" }, "text": "邓逸凡" }, "Q8321539/zh-cn/surnameFirst": { "family": { "expected": "汪", "got": "汪洋" }, "given_names": { "expected": "洋", "got": "" }, "text": "汪洋" }, "Q8325220/zh-cn/surnameFirst": { "family": { "expected": "張", "got": "張瑞" }, "given_names": { "expected": "瑞", "got": "" }, "text": "張瑞" }, "Q8966024/zh-tw/surnameFirst": { "family": { "expected": "馮", "got": "馮定國" }, "given_names": { "expected": "定國", "got": "" }, "text": "馮定國" }, "Q8969884/zh-cn/surnameFirst": { "family": { "expected": "何", "got": "何炅" }, "given_names": { "expected": "炅", "got": "" }, "text": "何炅" }, "Q8995497/zh-tw/surnameFirst": { "family": { "expected": "李", "got": "李正中" }, "given_names": { "expected": "正中", "got": "" }, "text": "李正中" }, "Q9066967/zh-tw/surnameFirst": { "family": { "expected": "王", "got": "王鑫" }, "given_names": { "expected": "鑫", "got": "" }, "text": "王鑫" }, "Q9183343/zh-tw/surnameFirst": { "family": { "expected": "林", "got": "林國華" }, "given_names": { "expected": "國華", "got": "" }, "text": "林國華" }, "Q930462/zh-cn/surnameFirst": { "family": { "expected": "蔡", "got": "蔡国强" }, "given_names": { "expected": "国强", "got": "" }, "text": "蔡国强" }, "Q9329762/zh-cn/surnameFirst": { "family": { "expected": "胡", "got": "胡英" }, "given_names": { "expected": "英", "got": "" }, "text": "胡英" }, "Q95163244/ja-jp/surnameFirst": { "family": { "expected": "草刈", "got": "草刈潤" }, "given_names": { "expected": "潤", "got": "" }, "text": "草刈潤" }, "Q954851/zh-cn/surnameFirst": { "family": { "expected": "石", "got": "石智勇" }, "given_names": { "expected": "智勇", "got": "" }, "text": "石智勇" }, "Q970696/ja-jp/surnameFirst": { "family": { "expected": "前田", "got": "前田秀樹" }, "given_names": { "expected": "秀樹", "got": "" }, "text": "前田秀樹" }, "Q97202579/zh-cn/surnameFirst": { "family": { "expected": "谢", "got": "谢文清" }, "given_names": { "expected": "文清", "got": "" }, "text": "谢文清" }, "Q97300636/zh-cn/surnameFirst": { "family": { "expected": "唐", "got": "唐由之" }, "given_names": { "expected": "由之", "got": "" }, "text": "唐由之" }, "Q97588844/zh-tw/surnameFirst": { "family": { "expected": "李", "got": "李俊賢" }, "given_names": { "expected": "俊賢", "got": "" }, "text": "李俊賢" }, "Q99630410/zh-cn/surnameFirst": { "family": { "expected": "黄", "got": "黄浩" }, "given_names": { "expected": "浩", "got": "" }, "text": "黄浩" } } �������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/wikidata_native_agreement_locales.json���������0000664�0000000�0000000�00000000276�15273032717�0032277�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������{ "Q111081424/ja-jp/surnameFirst": { "family": { "expected": "安達", "got": "安" }, "given_names": { "expected": "禎", "got": "達禎" }, "text": "安達禎" } } ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/differential/wikidata_native_fields.jsonl�������������������0000664�0000000�0000000�00000612050�15273032717�0030247�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������{"composed": false, "fields": {"given": "陶", "surname": "葉"}, "file": "Q100152143", "label": "zh-tw", "order": "surnameFirst", "shape": null, "text": "葉陶"} {"composed": false, "fields": {"given": "ალექსი", "surname": "მახარაშვილი"}, "file": "Q100163409", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "ალექსი მახარაშვილი"} {"composed": false, "fields": {"given": "Γιάννης", "surname": "Θεοδωράκης"}, "file": "Q100804515", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Γιάννης Θεοδωράκης"} {"composed": false, "fields": {"given": "عبد الإله", "surname": "نبهان"}, "file": "Q100995846", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "عبد الإله نبهان"} {"composed": false, "fields": {"given": "Ալբերտ", "surname": "Հովհաննիսյան"}, "file": "Q101236956", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Ալբերտ Հովհաննիսյան"} {"composed": false, "fields": {"given": "გიორგი", "surname": "ჭოღოშვილი"}, "file": "Q102123708", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "გიორგი ჭოღოშვილი"} {"composed": false, "fields": {"given": "राहुल", "surname": "गाँधी"}, "file": "Q10218", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "राहुल गाँधी"} {"composed": false, "fields": {"given": "俊一", "surname": "小柳"}, "file": "Q102246922", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "小柳俊一"} {"composed": false, "fields": {"given": "سعيد", "surname": "حماد"}, "file": "Q102280881", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "سعيد حماد"} {"composed": false, "fields": {"given": "ლევან", "surname": "მაღნარაძე"}, "file": "Q102407470", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "ლევან მაღნარაძე"} {"composed": false, "fields": {"given": "هشام", "surname": "عباس"}, "file": "Q1036918", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "هشام عباس"} {"composed": false, "fields": {"given": "रवि", "surname": "शंकर"}, "file": "Q103774", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "रवि शंकर"} {"composed": false, "fields": {"given": "桓", "surname": "詹"}, "file": "Q103785817", "label": "zh-tw", "order": "surnameFirst", "shape": null, "text": "詹桓"} {"composed": false, "fields": {"given": "金生", "surname": "蔡"}, "file": "Q103791050", "label": "zh-tw", "order": "surnameFirst", "shape": null, "text": "蔡金生"} {"composed": false, "fields": {"given": "宗文", "surname": "李"}, "file": "Q103791103", "label": "zh-tw", "order": "surnameFirst", "shape": null, "text": "李宗文"} {"composed": false, "fields": {"given": "اقبال", "surname": "قریشی"}, "file": "Q104587964", "label": "ur-in", "order": "givenFirst", "shape": null, "text": "اقبال قریشی"} {"composed": false, "fields": {"given": "Олег", "surname": "Литвиненко"}, "file": "Q1045997", "label": "kk-kz", "order": "givenFirst", "shape": null, "text": "Олег Литвиненко"} {"composed": false, "fields": {"given": "乃", "surname": "黄"}, "file": "Q104782874", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "黄乃"} {"composed": false, "fields": {"given": "محمد سليم", "surname": "الحلواني"}, "file": "Q104846821", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "محمد سليم الحلواني"} {"composed": false, "fields": {"given": "موفق", "surname": "الخاني"}, "file": "Q105080162", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "موفق الخاني"} {"composed": false, "fields": {"given": "سليمان", "surname": "الجوخدار"}, "file": "Q105238222", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "سليمان الجوخدار"} {"composed": false, "fields": {"given": "輝一", "surname": "上山"}, "file": "Q105260064", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "上山輝一"} {"composed": false, "fields": {"given": "Іван", "patronymic": "Сергійович", "surname": "Федоров"}, "file": "Q105302343", "label": "uk-ua", "order": "surnameFirst", "shape": 4, "text": "Федоров Іван Сергійович"} {"composed": false, "fields": {"given": "إبراهيم", "surname": "الغلاييني"}, "file": "Q105371002", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "إبراهيم الغلاييني"} {"composed": false, "fields": {"given": "عبد الوكيل", "surname": "الهاشمي"}, "file": "Q105450688", "label": "ar-sa", "order": "givenFirst", "shape": null, "text": "عبد الوكيل الهاشمي"} {"composed": false, "fields": {"given": "Ստեփան", "surname": "Նալբանդյան"}, "file": "Q105601587", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Ստեփան Նալբանդյան"} {"composed": false, "fields": {"given": "Δημήτρης", "surname": "Μιχαηλίδης"}, "file": "Q105697966", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Δημήτρης Μιχαηλίδης"} {"composed": false, "fields": {"given": "रणबीर", "surname": "कपूर"}, "file": "Q1063412", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "रणबीर कपूर"} {"composed": false, "fields": {"given": "ורה", "surname": "אלכסנדר"}, "file": "Q106471667", "label": "he-il", "order": "givenFirst", "shape": null, "text": "ורה אלכסנדר"} {"composed": false, "fields": {"given": "عبد الباسط", "surname": "بدر"}, "file": "Q106534219", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "عبد الباسط بدر"} {"composed": false, "fields": {"given": "عادل", "surname": "التويجري"}, "file": "Q106768850", "label": "ar-sa", "order": "givenFirst", "shape": null, "text": "عادل التويجري"} {"composed": false, "fields": {"given": "בלה", "surname": "קאופמן"}, "file": "Q106829524", "label": "he-il", "order": "givenFirst", "shape": null, "text": "בלה קאופמן"} {"composed": false, "fields": {"given": "재규", "surname": "전"}, "file": "Q106853957", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "전재규"} {"composed": false, "fields": {"given": "Κωνσταντίνος", "surname": "Γιαννίτσης"}, "file": "Q106929737", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Κωνσταντίνος Γιαννίτσης"} {"composed": false, "fields": {"given": "राजेश", "surname": "खन्ना"}, "file": "Q107130", "label": "mr-in", "order": "givenFirst", "shape": null, "text": "राजेश खन्ना"} {"composed": false, "fields": {"given": "حازم", "surname": "هاشم"}, "file": "Q107171437", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "حازم هاشم"} {"composed": false, "fields": {"given": "ილია", "surname": "სულამანიძე"}, "file": "Q107191545", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "ილია სულამანიძე"} {"composed": false, "fields": {"given": "نبيهة", "surname": "حداد"}, "file": "Q107323523", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "نبيهة حداد"} {"composed": false, "fields": {"given": "عبد الستار", "surname": "إبراهيم"}, "file": "Q107370114", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "عبد الستار إبراهيم"} {"composed": false, "fields": {"given": "سحر", "surname": "كامل"}, "file": "Q107424104", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "سحر كامل"} {"composed": false, "fields": {"given": "יהודה", "surname": "כהן"}, "file": "Q108101296", "label": "he-il", "order": "givenFirst", "shape": null, "text": "יהודה כהן"} {"composed": false, "fields": {"given": "مُوفَّق", "surname": "دعبول"}, "file": "Q108593267", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "مُوفَّق دعبول"} {"composed": false, "fields": {"given": "Пеньо", "surname": "Велков"}, "file": "Q108689436", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Пеньо Велков"} {"composed": false, "fields": {"given": "سیروس", "surname": "گرجستانی"}, "file": "Q10869896", "label": "fa-ir", "order": "givenFirst", "shape": null, "text": "سیروس گرجستانی"} {"composed": false, "fields": {"given": "卯一", "surname": "森"}, "file": "Q108855843", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "森卯一"} {"composed": false, "fields": {"given": "राहुल", "surname": "शर्मा"}, "file": "Q1088902", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "राहुल शर्मा"} {"composed": false, "fields": {"given": "放", "surname": "刘"}, "file": "Q10897593", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "刘放"} {"composed": false, "fields": {"given": "桂英", "surname": "蔡"}, "file": "Q109343195", "label": "zh-tw", "order": "surnameFirst", "shape": null, "text": "蔡桂英"} {"composed": false, "fields": {"given": "शरद", "surname": "जोशी"}, "file": "Q10980214", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "शरद जोशी"} {"composed": false, "fields": {"given": "محمد", "surname": "عطا"}, "file": "Q110067", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "محمد عطا"} {"composed": false, "fields": {"given": "Балдырған", "surname": "Қожамқұлова"}, "file": "Q110114060", "label": "kk-kz", "order": "givenFirst", "shape": null, "text": "Балдырған Қожамқұлова"} {"composed": false, "fields": {"given": "صلاح", "surname": "المُولَّد"}, "file": "Q110301591", "label": "ar-sa", "order": "givenFirst", "shape": null, "text": "صلاح المُولَّد"} {"composed": false, "fields": {"given": "世英", "surname": "朱"}, "file": "Q110406653", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "朱世英"} {"composed": false, "fields": {"given": "豪", "surname": "方"}, "file": "Q11085014", "label": "zh-tw", "order": "surnameFirst", "shape": null, "text": "方豪"} {"composed": false, "fields": {"given": "禎", "surname": "安達"}, "file": "Q111081424", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "安達禎"} {"composed": false, "fields": {"given": "亀蔵", "surname": "鈴木"}, "file": "Q111117644", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "鈴木亀蔵"} {"composed": false, "fields": {"given": "عبد الرحيم", "surname": "منصور"}, "file": "Q11162165", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "عبد الرحيم منصور"} {"composed": false, "fields": {"given": "Јелена", "surname": "Беговић"}, "file": "Q111654011", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Јелена Беговић"} {"composed": false, "fields": {"given": "აბელ", "surname": "მამარდაშვილი"}, "file": "Q111697223", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "აბელ მამარდაშვილი"} {"composed": false, "fields": {"given": "خضر", "surname": "الأحمد"}, "file": "Q112334101", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "خضر الأحمد"} {"composed": false, "fields": {"given": "瑞麟", "surname": "何"}, "file": "Q112577331", "label": "zh-tw", "order": "surnameFirst", "shape": null, "text": "何瑞麟"} {"composed": false, "fields": {"given": "병기", "surname": "이"}, "file": "Q11265902", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "이병기"} {"composed": false, "fields": {"given": "세균", "surname": "정"}, "file": "Q11270093", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "정세균"} {"composed": false, "fields": {"given": "מנחם", "surname": "נאור"}, "file": "Q113004767", "label": "he-il", "order": "givenFirst", "shape": null, "text": "מנחם נאור"} {"composed": false, "fields": {"given": "Любомир", "surname": "Кръстанов"}, "file": "Q1130902", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Любомир Кръстанов"} {"composed": false, "fields": {"given": "淳", "surname": "三橋"}, "file": "Q11356021", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "三橋淳"} {"composed": false, "fields": {"given": "淳", "surname": "平賀"}, "file": "Q113567973", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "平賀淳"} {"composed": false, "fields": {"given": "力", "surname": "中内"}, "file": "Q11363041", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "中内力"} {"composed": false, "fields": {"given": "瞻", "surname": "李"}, "file": "Q113644075", "label": "zh-tw", "order": "surnameFirst", "shape": null, "text": "李瞻"} {"composed": false, "fields": {"given": "徹", "surname": "中島"}, "file": "Q11364497", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "中島 徹"} {"composed": false, "fields": {"given": "龍一", "surname": "中川"}, "file": "Q11364982", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "中川龍一"} {"composed": false, "fields": {"given": "誠", "surname": "内田"}, "file": "Q11394360", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "内田誠"} {"composed": false, "fields": {"given": "博文", "surname": "内藤"}, "file": "Q11394483", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "内藤博文"} {"composed": false, "fields": {"given": "弘", "surname": "吉尾"}, "file": "Q11412770", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "吉尾弘"} {"composed": false, "fields": {"given": "Јован", "surname": "Мијатовић"}, "file": "Q114162918", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Јован Мијатовић"} {"composed": false, "fields": {"given": "なつみ", "surname": "安倍"}, "file": "Q1142423", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "安倍なつみ"} {"composed": false, "fields": {"given": "四郎", "surname": "城戸"}, "file": "Q11426161", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "城戸 四郎"} {"composed": false, "fields": {"given": "克明", "surname": "堀内"}, "file": "Q11427266", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "堀内 克明"} {"composed": false, "fields": {"given": "衛", "surname": "大沢"}, "file": "Q11436998", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "大沢衛"} {"composed": false, "fields": {"given": "三郎", "surname": "太田"}, "file": "Q11444084", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "太田三郎"} {"composed": false, "fields": {"given": "宏", "surname": "小林"}, "file": "Q11461529", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "小林宏"} {"composed": false, "fields": {"given": "由佳", "surname": "小林"}, "file": "Q11461743", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "小林由佳"} {"composed": false, "fields": {"given": "章", "surname": "揖斐"}, "file": "Q11498424", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "揖斐章"} {"composed": false, "fields": {"given": "Александар", "surname": "Станковић"}, "file": "Q115110251", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Александар Станковић"} {"composed": false, "fields": {"given": "형진", "surname": "박"}, "file": "Q11521580", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "박형진"} {"composed": false, "fields": {"given": "喜一", "surname": "村田"}, "file": "Q11523418", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "村田喜一"} {"composed": false, "fields": {"given": "وديع", "surname": "بشور"}, "file": "Q115300746", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "وديع بشور"} {"composed": false, "fields": {"given": "力", "surname": "林"}, "file": "Q11532869", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "林 力"} {"composed": false, "fields": {"given": "亨", "surname": "河合"}, "file": "Q11553704", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "河合亨"} {"composed": false, "fields": {"given": "虎雄", "surname": "牧野"}, "file": "Q11570608", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "牧野虎雄"} {"composed": false, "fields": {"given": "Соња", "surname": "Петровић"}, "file": "Q1158152", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Соња Петровић"} {"composed": false, "fields": {"given": "彦三郎", "surname": "秦"}, "file": "Q11596044", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "秦彦三郎"} {"composed": false, "fields": {"given": "Νίκος", "surname": "Σκυλακάκης"}, "file": "Q116029945", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Νίκος Σκυλακάκης"} {"composed": false, "fields": {"given": "信夫", "surname": "米川"}, "file": "Q11604183", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "米川信夫"} {"composed": false, "fields": {"given": "建文", "surname": "罗"}, "file": "Q11608580", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "罗建文"} {"composed": false, "fields": {"given": "茂", "surname": "荒木"}, "file": "Q11618744", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "荒木茂"} {"composed": false, "fields": {"given": "דניאל", "surname": "פאר"}, "file": "Q1162057", "label": "he-il", "order": "givenFirst", "shape": null, "text": "דניאל פאר"} {"composed": false, "fields": {"given": "博", "surname": "諫山"}, "file": "Q11631995", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "諫山博"} {"composed": false, "fields": {"given": "平三郎", "surname": "近藤"}, "file": "Q11638789", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "近藤平三郎"} {"composed": false, "fields": {"given": "晴", "surname": "遠藤"}, "file": "Q11642264", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "遠藤晴"} {"composed": false, "fields": {"given": "재구", "surname": "김"}, "file": "Q11646612", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "김재구"} {"composed": false, "fields": {"given": "弥彦", "surname": "鎌田"}, "file": "Q11651055", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "鎌田 弥彦"} {"composed": false, "fields": {"given": "章", "surname": "高橋"}, "file": "Q11671502", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "高橋章"} {"composed": false, "fields": {"given": "علي", "surname": "القرني"}, "file": "Q116765522", "label": "ar-sa", "order": "givenFirst", "shape": null, "text": "علي القرني"} {"composed": false, "fields": {"given": "Игор", "surname": "Матић"}, "file": "Q1168604", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Игор Матић"} {"composed": false, "fields": {"given": "شاہدہ", "surname": "رضا"}, "file": "Q116953275", "label": "ur-pk", "order": "givenFirst", "shape": null, "text": "شاہدہ رضا"} {"composed": false, "fields": {"given": "Էրիկ", "surname": "Սահակյան"}, "file": "Q117133476", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Էրիկ Սահակյան"} {"composed": false, "fields": {"given": "मानव", "surname": "कौल"}, "file": "Q11766987", "label": "mr-in", "order": "givenFirst", "shape": null, "text": "मानव कौल"} {"composed": false, "fields": {"given": "Олександр", "surname": "Положинський"}, "file": "Q11801469", "label": "uk-ua", "order": "givenFirst", "shape": null, "text": "Олександр Положинський"} {"composed": false, "fields": {"given": "健一", "surname": "渋谷"}, "file": "Q118125689", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "渋谷健一"} {"composed": false, "fields": {"given": "نعيم", "surname": "عيسى"}, "file": "Q118190274", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "نعيم عيسى"} {"composed": false, "fields": {"given": "Василиса", "patronymic": "Іллівна", "surname": "Степаненко"}, "file": "Q118224301", "label": "uk-ua", "order": "surnameFirst", "shape": 4, "text": "Степаненко Василиса Іллівна"} {"composed": false, "fields": {"given": "Дејан", "surname": "Стојановић"}, "file": "Q1183503", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Дејан Стојановић"} {"composed": false, "fields": {"given": "გიორგი", "surname": "სხირტლაძე"}, "file": "Q1194871", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "გიორგი სხირტლაძე"} {"composed": false, "fields": {"given": "محمد نبيه", "surname": "المسيري"}, "file": "Q119534307", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "محمد نبيه المسيري"} {"composed": false, "fields": {"given": "Πέτρος", "surname": "Παυλίδης"}, "file": "Q11995504", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Πέτρος Παυλίδης"} {"composed": false, "fields": {"given": "दीप्ति", "surname": "भटनागर"}, "file": "Q12053145", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "दीप्ति भटनागर"} {"composed": false, "fields": {"given": "पंकज", "surname": "त्रिपाठी"}, "file": "Q12071488", "label": "mr-in", "order": "givenFirst", "shape": null, "text": "पंकज त्रिपाठी"} {"composed": false, "fields": {"given": "Յուրի", "surname": "Հայրապետյան"}, "file": "Q12077223", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Յուրի Հայրապետյան"} {"composed": false, "fields": {"given": "Юлія", "surname": "Волчкова"}, "file": "Q12090553", "label": "uk-ua", "order": "givenFirst", "shape": null, "text": "Юлія Волчкова"} {"composed": false, "fields": {"given": "عائشة", "surname": "ذو الغنى"}, "file": "Q121061159", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "عائشة ذو الغنى"} {"composed": false, "fields": {"given": "Микола", "surname": "Козленко"}, "file": "Q12111417", "label": "uk-ua", "order": "givenFirst", "shape": null, "text": "Микола Козленко"} {"composed": false, "fields": {"given": "Володимир", "surname": "Лукань"}, "file": "Q12118705", "label": "uk-ua", "order": "surnameFirst", "shape": 4, "text": "Лукань Володимир"} {"composed": false, "fields": {"given": "Борис", "patronymic": "Олексійович", "surname": "Мовчан"}, "file": "Q12123956", "label": "uk-ua", "order": "surnameFirst", "shape": 4, "text": "Мовчан Борис Олексійович"} {"composed": false, "fields": {"given": "Ալեքսանդր", "surname": "Երիցյան"}, "file": "Q121288238", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Ալեքսանդր Երիցյան"} {"composed": false, "fields": {"given": "Михайло", "patronymic": "Степанович", "surname": "Полунін"}, "file": "Q12141961", "label": "uk-ua", "order": "surnameFirst", "shape": 4, "text": "Полунін Михайло Степанович"} {"composed": false, "fields": {"given": "Олександр", "patronymic": "Васильович", "surname": "Царук"}, "file": "Q12168221", "label": "uk-ua", "order": "surnameFirst", "shape": 4, "text": "Царук Олександр Васильович"} {"composed": false, "fields": {"given": "أبو بكر", "surname": "سالم"}, "file": "Q12177902", "label": "ar-sa", "order": "givenFirst", "shape": null, "text": "أبو بكر سالم"} {"composed": false, "fields": {"given": "أحلام", "surname": "الجريتلي"}, "file": "Q12178431", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "أحلام الجريتلي"} {"composed": false, "fields": {"given": "أحمد", "surname": "قنديل"}, "file": "Q12179060", "label": "ar-sa", "order": "givenFirst", "shape": null, "text": "أحمد قنديل"} {"composed": false, "fields": {"given": "أمين", "surname": "دابو"}, "file": "Q12180687", "label": "ar-sa", "order": "givenFirst", "shape": null, "text": "أمين دابو"} {"composed": false, "fields": {"given": "أنطوانيت", "surname": "نجيب"}, "file": "Q12180850", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "أنطوانيت نجيب"} {"composed": false, "fields": {"given": "أيمن", "surname": "الدُّقر"}, "file": "Q12181415", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "أيمن الدُّقر"} {"composed": false, "fields": {"given": "إبراهيم", "surname": "البغدادي"}, "file": "Q12181553", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "إبراهيم البغدادي"} {"composed": false, "fields": {"given": "إلياس", "surname": "الزيَّات"}, "file": "Q12194988", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "إلياس الزيَّات"} {"composed": false, "fields": {"given": "حسني", "surname": "سبح"}, "file": "Q12208060", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "حسني سبح"} {"composed": false, "fields": {"given": "حسين", "surname": "خطاب"}, "file": "Q12208195", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "حسين خطاب"} {"composed": false, "fields": {"given": "رجاء", "surname": "يوسف"}, "file": "Q12213144", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "رجاء يوسف"} {"composed": false, "fields": {"given": "رشاد", "surname": "رشدي"}, "file": "Q12213374", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "رشاد رشدي"} {"composed": false, "fields": {"given": "رمضان", "surname": "ديب"}, "file": "Q12213708", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "رمضان ديب"} {"composed": false, "fields": {"given": "زكي", "surname": "مبارك"}, "file": "Q12214522", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "زكي مبارك"} {"composed": false, "fields": {"given": "سمير", "surname": "سرور"}, "file": "Q12216881", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "سمير سرور"} {"composed": false, "fields": {"given": "سوسن", "surname": "ربيع"}, "file": "Q12217290", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "سوسن ربيع"} {"composed": false, "fields": {"given": "سيد", "surname": "كريم"}, "file": "Q12217558", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "سيد كريم"} {"composed": false, "fields": {"given": "أبو بكر", "surname": "الشاطري"}, "file": "Q12219331", "label": "ar-sa", "order": "givenFirst", "shape": null, "text": "أبو بكر الشاطري"} {"composed": false, "fields": {"given": "صالح", "surname": "اللحيدان"}, "file": "Q12219592", "label": "ar-sa", "order": "givenFirst", "shape": null, "text": "صالح اللحيدان"} {"composed": false, "fields": {"given": "طارق", "surname": "عبد العزيز"}, "file": "Q12221498", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "طارق عبد العزيز"} {"composed": false, "fields": {"given": "طلعت", "surname": "زكريا"}, "file": "Q12221848", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "طلعت زكريا"} {"composed": false, "fields": {"given": "عبد الرحمن", "surname": "العجلان"}, "file": "Q12222701", "label": "ar-sa", "order": "givenFirst", "shape": null, "text": "عبد الرحمن العجلان"} {"composed": false, "fields": {"given": "عبد الهادي", "surname": "الباني"}, "file": "Q12223882", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "عبد الهادي الباني"} {"composed": false, "fields": {"given": "عدنان", "surname": "بركات"}, "file": "Q12224508", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "عدنان بركات"} {"composed": false, "fields": {"given": "فائق", "surname": "عرقسُوسي"}, "file": "Q12229998", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "فائق عرقسُوسي"} {"composed": false, "fields": {"given": "فاروق", "surname": "خورشيد"}, "file": "Q12230099", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "فاروق خورشيد"} {"composed": false, "fields": {"given": "لطفي", "surname": "لبيب"}, "file": "Q12237244", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "لطفي لبيب"} {"composed": false, "fields": {"given": "مازن", "surname": "المبارك"}, "file": "Q12238255", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "مازن المبارك"} {"composed": false, "fields": {"given": "محمد ضياء الدين", "surname": "الصابوني"}, "file": "Q12240653", "label": "ar-sa", "order": "givenFirst", "shape": null, "text": "محمد ضياء الدين الصابوني"} {"composed": false, "fields": {"given": "محمود", "surname": "شاكر"}, "file": "Q12241249", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "محمود شاكر"} {"composed": false, "fields": {"given": "نبيل", "surname": "فهمي"}, "file": "Q12247944", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "نبيل فهمي"} {"composed": false, "fields": {"given": "نسرين", "surname": "طافش"}, "file": "Q12248386", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "نسرين طافش"} {"composed": false, "fields": {"given": "هدى", "surname": "شعراوي"}, "file": "Q12249862", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "هدى شعراوي"} {"composed": false, "fields": {"given": "Александър", "surname": "Александров"}, "file": "Q12271398", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Александър Александров"} {"composed": false, "fields": {"given": "Ангел", "surname": "Андреев"}, "file": "Q12271832", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Ангел Андреев"} {"composed": false, "fields": {"given": "Васил", "surname": "Стоилов"}, "file": "Q12274835", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Васил Стоилов"} {"composed": false, "fields": {"given": "Георги", "surname": "Боков"}, "file": "Q12276311", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Георги Боков"} {"composed": false, "fields": {"given": "Георги", "surname": "Живков"}, "file": "Q12276443", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Георги Живков"} {"composed": false, "fields": {"given": "Иван", "surname": "Андонов"}, "file": "Q12280706", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Иван Андонов"} {"composed": false, "fields": {"given": "Константин", "surname": "Константинов"}, "file": "Q12283564", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Константин Константинов"} {"composed": false, "fields": {"given": "Красимир", "surname": "Спасов"}, "file": "Q12284027", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Красимир Спасов"} {"composed": false, "fields": {"given": "Крум", "surname": "Дерменджиев"}, "file": "Q12284129", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Крум Дерменджиев"} {"composed": false, "fields": {"given": "Никола", "surname": "Славков"}, "file": "Q12288560", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Никола Славков"} {"composed": false, "fields": {"given": "Παύλος", "surname": "Ιωάννου"}, "file": "Q12289677", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Παύλος Ιωάννου"} {"composed": false, "fields": {"given": "Свобода", "surname": "Бъчварова"}, "file": "Q12293389", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Свобода Бъчварова"} {"composed": false, "fields": {"given": "Стефан", "surname": "Огнянов"}, "file": "Q12295264", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Стефан Огнянов"} {"composed": false, "fields": {"given": "Христо", "surname": "Боев"}, "file": "Q12298224", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Христо Боев"} {"composed": false, "fields": {"given": "Христо", "surname": "Далчев"}, "file": "Q12298272", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Христо Далчев"} {"composed": false, "fields": {"given": "소희", "surname": "이"}, "file": "Q1234482", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "이소희"} {"composed": false, "fields": {"given": "هدى", "surname": "عابدين"}, "file": "Q123474039", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "هدى عابدين"} {"composed": false, "fields": {"given": "সলিল", "surname": "সেন"}, "file": "Q123564925", "label": "bn-in", "order": "givenFirst", "shape": null, "text": "সলিল সেন"} {"composed": false, "fields": {"given": "إبراهيم", "surname": "صالح"}, "file": "Q123818706", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "إبراهيم صالح"} {"composed": false, "fields": {"given": "שמואל", "surname": "דביר"}, "file": "Q123939041", "label": "he-il", "order": "givenFirst", "shape": null, "text": "שמואל דביר"} {"composed": false, "fields": {"given": "霖", "surname": "朱"}, "file": "Q123945439", "label": "zh-tw", "order": "surnameFirst", "shape": null, "text": "朱霖"} {"composed": false, "fields": {"given": "אדם", "surname": "מזור"}, "file": "Q12403456", "label": "he-il", "order": "givenFirst", "shape": null, "text": "אדם מזור"} {"composed": false, "fields": {"given": "אלישע", "surname": "אפרת"}, "file": "Q12403983", "label": "he-il", "order": "givenFirst", "shape": null, "text": "אלישע אפרת"} {"composed": false, "fields": {"given": "יצחק", "surname": "יעקב"}, "file": "Q12408261", "label": "he-il", "order": "givenFirst", "shape": null, "text": "יצחק יעקב"} {"composed": false, "fields": {"given": "משה", "surname": "בן-שאול"}, "file": "Q12409569", "label": "he-il", "order": "givenFirst", "shape": null, "text": "משה בן-שאול"} {"composed": false, "fields": {"given": "Срђан", "surname": "Новаковић"}, "file": "Q124096162", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Срђан Новаковић"} {"composed": false, "fields": {"given": "Սուրեն", "surname": "Ծատուրյան"}, "file": "Q124101304", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Սուրեն Ծատուրյան"} {"composed": false, "fields": {"given": "עמוס", "surname": "ברנס"}, "file": "Q12410362", "label": "he-il", "order": "givenFirst", "shape": null, "text": "עמוס ברנס"} {"composed": false, "fields": {"given": "שולה", "surname": "ליטן"}, "file": "Q124881923", "label": "he-il", "order": "givenFirst", "shape": null, "text": "שולה ליטן"} {"composed": false, "fields": {"given": "সুনীল", "surname": "কর্মকার"}, "file": "Q124936063", "label": "bn-bd", "order": "givenFirst", "shape": null, "text": "সুনীল কর্মকার"} {"composed": false, "fields": {"given": "啓太", "surname": "小杉"}, "file": "Q125044295", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "小杉 啓太"} {"composed": false, "fields": {"given": "Данка", "surname": "Илић"}, "file": "Q125351913", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Данка Илић"} {"composed": false, "fields": {"given": "Төле", "surname": "Кенжебаев"}, "file": "Q12567555", "label": "kk-kz", "order": "givenFirst", "shape": null, "text": "Төле Кенжебаев"} {"composed": false, "fields": {"given": "جميل", "surname": "عز الدين"}, "file": "Q125699198", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "جميل عز الدين"} {"composed": false, "fields": {"given": "상돈", "surname": "고"}, "file": "Q12584259", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "고 상돈"} {"composed": false, "fields": {"given": "중동", "surname": "권"}, "file": "Q12586148", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "권중동"} {"composed": false, "fields": {"given": "선영", "surname": "김"}, "file": "Q12587669", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "김선영"} {"composed": false, "fields": {"given": "약수", "surname": "김"}, "file": "Q12587949", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "김약수"} {"composed": false, "fields": {"given": "영환", "surname": "김"}, "file": "Q12588113", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "김영환"} {"composed": false, "fields": {"given": "종삼", "surname": "김"}, "file": "Q12588664", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "김종삼"} {"composed": false, "fields": {"given": "익진", "surname": "나"}, "file": "Q12589621", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "나익진"} {"composed": false, "fields": {"given": "다름", "surname": "남"}, "file": "Q12589788", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "남다름"} {"composed": false, "fields": {"given": "유상", "surname": "노"}, "file": "Q12590425", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "노유상"} {"composed": false, "fields": {"given": "만원", "surname": "박"}, "file": "Q12596625", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "박만원"} {"composed": false, "fields": {"given": "응모", "surname": "방"}, "file": "Q12597567", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "방응모"} {"composed": false, "fields": {"given": "용훈", "surname": "신"}, "file": "Q12604481", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "신용훈"} {"composed": false, "fields": {"given": "춘호", "surname": "신"}, "file": "Q12604649", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "신춘호"} {"composed": false, "fields": {"given": "영국", "surname": "유"}, "file": "Q12609905", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "유영국"} {"composed": false, "fields": {"given": "인수", "surname": "육"}, "file": "Q12610163", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "육인수"} {"composed": false, "fields": {"given": "상익", "surname": "이"}, "file": "Q12611662", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "이상익"} {"composed": false, "fields": {"given": "순택", "surname": "정"}, "file": "Q12615774", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "정순택"} {"composed": false, "fields": {"given": "경자", "surname": "천"}, "file": "Q12618820", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "천경자"} {"composed": false, "fields": {"given": "국현", "surname": "최"}, "file": "Q12619473", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "최국현"} {"composed": false, "fields": {"given": "희서", "surname": "최"}, "file": "Q12619546", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "최희서"} {"composed": false, "fields": {"given": "윤호", "surname": "황"}, "file": "Q12625879", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "황윤호"} {"composed": false, "fields": {"given": "우현", "surname": "장"}, "file": "Q126285829", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "장우현"} {"composed": false, "fields": {"given": "فاروق", "surname": "عطية"}, "file": "Q126362537", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "فاروق عطية"} {"composed": false, "fields": {"given": "Մամիկոն", "surname": "Աքուլյան"}, "file": "Q126548284", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Մամիկոն Աքուլյան"} {"composed": false, "fields": {"given": "ნელი", "surname": "მგალობლიშვილი"}, "file": "Q126720860", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "ნელი მგალობლიშვილი"} {"composed": false, "fields": {"given": "ტარიელ", "surname": "ჭიჭინაძე"}, "file": "Q126949502", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "ტარიელ ჭიჭინაძე"} {"composed": false, "fields": {"given": "حمدي", "surname": "الحريري"}, "file": "Q127095296", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "حمدي الحريري"} {"composed": false, "fields": {"given": "Љубомир", "surname": "Јовановић"}, "file": "Q12747930", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Љубомир Јовановић"} {"composed": false, "fields": {"given": "محمد", "surname": "الزعبي"}, "file": "Q127510341", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "محمد الزعبي"} {"composed": false, "fields": {"given": "惠林", "surname": "卫"}, "file": "Q127605824", "label": "zh-tw", "order": "surnameFirst", "shape": null, "text": "卫惠林"} {"composed": false, "fields": {"given": "فریدون", "surname": "ابراهیمی"}, "file": "Q12839522", "label": "fa-ir", "order": "givenFirst", "shape": null, "text": "فریدون ابراهیمی"} {"composed": false, "fields": {"given": "নাহিদ", "surname": "ইসলাম"}, "file": "Q128605495", "label": "bn-bd", "order": "givenFirst", "shape": null, "text": "নাহিদ ইসলাম"} {"composed": false, "fields": {"given": "ნოდარ", "surname": "ღვალაძე"}, "file": "Q12867684", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "ნოდარ ღვალაძე"} {"composed": false, "fields": {"given": "Αναστάσιος", "surname": "Σταμούλης"}, "file": "Q12873591", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Αναστάσιος Σταμούλης"} {"composed": false, "fields": {"given": "Βούλα", "surname": "Ζουμπουλάκη"}, "file": "Q12874729", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Βούλα Ζουμπουλάκη"} {"composed": false, "fields": {"given": "Γεώργιος", "surname": "Αντωνόπουλος"}, "file": "Q12875030", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Γεώργιος Αντωνόπουλος"} {"composed": false, "fields": {"given": "Γεώργιος", "surname": "Παπαδιαμαντόπουλος"}, "file": "Q12875137", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Γεώργιος Παπαδιαμαντόπουλος"} {"composed": false, "fields": {"given": "Δημήτρης", "surname": "Καπράλος"}, "file": "Q12875911", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Δημήτρης Καπράλος"} {"composed": false, "fields": {"given": "Επαμεινώνδας", "surname": "Κριεζής"}, "file": "Q12877156", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Επαμεινώνδας Κριεζής"} {"composed": false, "fields": {"given": "Νικόλαος", "surname": "Γεωργιάδης"}, "file": "Q12881780", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Νικόλαος Γεωργιάδης"} {"composed": false, "fields": {"given": "Στυλιανός", "surname": "Λυκούδης"}, "file": "Q12884909", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Στυλιανός Λυκούδης"} {"composed": false, "fields": {"given": "Χρήστος", "surname": "Αντωνόπουλος"}, "file": "Q12886126", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Χρήστος Αντωνόπουλος"} {"composed": false, "fields": {"given": "ნოდარ", "surname": "ჯობავა"}, "file": "Q129264137", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "ნოდარ ჯობავა"} {"composed": false, "fields": {"given": "ელგუჯა", "surname": "ღონღაძე"}, "file": "Q130118585", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "ელგუჯა ღონღაძე"} {"composed": false, "fields": {"given": "عبد القدوس", "surname": "أبو صالح"}, "file": "Q130263795", "label": "ar-sa", "order": "givenFirst", "shape": null, "text": "عبد القدوس أبو صالح"} {"composed": false, "fields": {"given": "Νικόλαος", "surname": "Φράγκος"}, "file": "Q130325486", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Νικόλαος Φράγκος"} {"composed": false, "fields": {"given": "현만", "surname": "이"}, "file": "Q130376125", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "이현만"} {"composed": false, "fields": {"given": "أحمد", "surname": "سالم"}, "file": "Q130483261", "label": "ar-sa", "order": "givenFirst", "shape": null, "text": "أحمد سالم"} {"composed": false, "fields": {"given": "Անդրանիկ", "surname": "Ղուկասյան"}, "file": "Q13050839", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Անդրանիկ Ղուկասյան"} {"composed": false, "fields": {"given": "명순", "surname": "강"}, "file": "Q130539951", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "강명순"} {"composed": false, "fields": {"given": "Մարիա", "surname": "Հակոբյան"}, "file": "Q13054009", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Մարիա Հակոբյան"} {"composed": false, "fields": {"given": "თამაზ", "surname": "წივწივაძე"}, "file": "Q130741831", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "თამაზ წივწივაძე"} {"composed": false, "fields": {"given": "Здравко", "surname": "Палазов"}, "file": "Q130753246", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Здравко Палазов"} {"composed": false, "fields": {"given": "نبيل", "surname": "درويش"}, "file": "Q131048129", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "نبيل درويش"} {"composed": false, "fields": {"given": "თემურ", "surname": "აბულაშვილი"}, "file": "Q131310719", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "თემურ აბულაშვილი"} {"composed": false, "fields": {"given": "فايزة", "surname": "حسين"}, "file": "Q131338682", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "فايزة حسين"} {"composed": false, "fields": {"given": "سميرة", "surname": "عزت"}, "file": "Q131366763", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "سميرة عزت"} {"composed": false, "fields": {"given": "Иван", "surname": "Коцев"}, "file": "Q131390261", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Иван Коцев"} {"composed": false, "fields": {"given": "إلهام", "surname": "حسين"}, "file": "Q13141377", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "إلهام حسين"} {"composed": false, "fields": {"given": "لويس", "surname": "جريس"}, "file": "Q13142108", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "لويس جريس"} {"composed": false, "fields": {"given": "Михайло", "surname": "Немелівський"}, "file": "Q131538079", "label": "uk-ua", "order": "surnameFirst", "shape": 4, "text": "Немелівський Михайло"} {"composed": false, "fields": {"given": "ناصر", "surname": "الأنصاري"}, "file": "Q131593921", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "ناصر الأنصاري"} {"composed": false, "fields": {"given": "उत्तम", "surname": "कुँवर"}, "file": "Q13179613", "label": "ne-np", "order": "givenFirst", "shape": null, "text": "उत्तम कुँवर"} {"composed": false, "fields": {"given": "旭", "surname": "杨"}, "file": "Q1318216", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "杨旭"} {"composed": false, "fields": {"given": "विजय", "surname": "मल्ल"}, "file": "Q13186060", "label": "ne-np", "order": "givenFirst", "shape": null, "text": "विजय मल्ल"} {"composed": false, "fields": {"given": "გიორგი", "surname": "გონჯილაშვილი"}, "file": "Q132177850", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "გიორგი გონჯილაშვილი"} {"composed": false, "fields": {"given": "მამუკა", "surname": "ზაალიშვილი"}, "file": "Q132893275", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "მამუკა ზაალიშვილი"} {"composed": false, "fields": {"given": "אלישבע", "surname": "מיכאלי"}, "file": "Q1330746", "label": "he-il", "order": "givenFirst", "shape": null, "text": "אלישבע מיכאלי"} {"composed": false, "fields": {"given": "Иван", "surname": "Катранов"}, "file": "Q133462003", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Иван Катранов"} {"composed": false, "fields": {"given": "楠", "surname": "宋"}, "file": "Q1335601", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "宋楠"} {"composed": false, "fields": {"given": "نادية", "surname": "زغلول"}, "file": "Q133885394", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "نادية زغلول"} {"composed": false, "fields": {"given": "عبد الحليم", "surname": "قدور"}, "file": "Q133932317", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "عبد الحليم قدور"} {"composed": false, "fields": {"given": "ვლადიმერ", "surname": "უზნაძე"}, "file": "Q134029617", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "ვლადიმერ უზნაძე"} {"composed": false, "fields": {"given": "გიორგი", "surname": "ჩახავა"}, "file": "Q13425862", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "გიორგი ჩახავა"} {"composed": false, "fields": {"given": "ვახტანგ", "surname": "აბაშიძე"}, "file": "Q134269176", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "ვახტანგ აბაშიძე"} {"composed": false, "fields": {"given": "علي", "surname": "الرواس"}, "file": "Q134284583", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "علي الرواس"} {"composed": false, "fields": {"given": "ალექსანდრე", "surname": "ციბაძე"}, "file": "Q134288567", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "ალექსანდრე ციბაძე"} {"composed": false, "fields": {"given": "سمر", "surname": "عبد العزيز"}, "file": "Q134298671", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "سمر عبد العزيز"} {"composed": false, "fields": {"given": "Стеван", "surname": "Плетикосић"}, "file": "Q1343084", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Стеван Плетикосић"} {"composed": false, "fields": {"given": "حكمت", "surname": "هاشم"}, "file": "Q134461786", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "حكمت هاشم"} {"composed": false, "fields": {"given": "עמרי", "surname": "שרון"}, "file": "Q1344931", "label": "he-il", "order": "givenFirst", "shape": null, "text": "עמרי שרון"} {"composed": false, "fields": {"given": "ვაჟა", "surname": "დადიანიძე"}, "file": "Q134502009", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "ვაჟა დადიანიძე"} {"composed": false, "fields": {"given": "অজিত", "surname": "পাণ্ডে"}, "file": "Q13455620", "label": "bn-in", "order": "givenFirst", "shape": null, "text": "অজিত পাণ্ডে"} {"composed": false, "fields": {"given": "Սերգեյ", "surname": "Ներսիսյան"}, "file": "Q134688519", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Սերգեյ Ներսիսյան"} {"composed": false, "fields": {"given": "ბადრი", "surname": "თოიძე"}, "file": "Q134700717", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "ბადრი თოიძე"} {"composed": false, "fields": {"given": "ვახტანგ", "surname": "ჯაფარიძე"}, "file": "Q134721071", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "ვახტანგ ჯაფარიძე"} {"composed": false, "fields": {"given": "Віктор", "surname": "Пинзеник"}, "file": "Q1348657", "label": "uk-ua", "order": "givenFirst", "shape": null, "text": "Віктор Пинзеник"} {"composed": false, "fields": {"given": "سهيل", "surname": "الغزي"}, "file": "Q135298243", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "سهيل الغزي"} {"composed": false, "fields": {"given": "חנה", "surname": "אייכינגרין"}, "file": "Q135451437", "label": "he-il", "order": "givenFirst", "shape": null, "text": "חנה אייכינגרין"} {"composed": false, "fields": {"given": "Ігор", "surname": "Рибак"}, "file": "Q1358195", "label": "uk-ua", "order": "givenFirst", "shape": null, "text": "Ігор Рибак"} {"composed": false, "fields": {"given": "Γιάννης", "surname": "Κανάκης"}, "file": "Q13582466", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Γιάννης Κανάκης"} {"composed": false, "fields": {"given": "Павел", "surname": "Попов"}, "file": "Q136030194", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Павел Попов"} {"composed": false, "fields": {"given": "성수", "surname": "박"}, "file": "Q136304620", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "박성수"} {"composed": false, "fields": {"given": "호왕", "surname": "이"}, "file": "Q1363894", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "이호왕"} {"composed": false, "fields": {"given": "建柱", "surname": "孟"}, "file": "Q1365182", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "孟建柱"} {"composed": false, "fields": {"given": "예정", "surname": "하"}, "file": "Q136644064", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "하예정"} {"composed": false, "fields": {"given": "Петар", "surname": "Јовичић"}, "file": "Q136945566", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Петар Јовичић"} {"composed": false, "fields": {"given": "حسین", "surname": "امینی"}, "file": "Q1371315", "label": "fa-ir", "order": "givenFirst", "shape": null, "text": "حسین امینی"} {"composed": false, "fields": {"given": "Стефан", "surname": "Петров"}, "file": "Q137170665", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Стефан Петров"} {"composed": false, "fields": {"given": "رائد", "surname": "المتني"}, "file": "Q137186977", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "رائد المتني"} {"composed": false, "fields": {"given": "Марія", "surname": "Панчишин"}, "file": "Q137719745", "label": "uk-ua", "order": "givenFirst", "shape": null, "text": "Марія Панчишин"} {"composed": false, "fields": {"given": "ชัชวาลย์", "surname": "แท่งทอง"}, "file": "Q137724131", "label": "th-th", "order": "givenFirst", "shape": null, "text": "ชัชวาลย์ แท่งทอง"} {"composed": false, "fields": {"given": "صالح", "surname": "اسدی"}, "file": "Q138522333", "label": "fa-ir", "order": "givenFirst", "shape": null, "text": "صالح اسدی"} {"composed": false, "fields": {"given": "هشام", "surname": "فؤاد"}, "file": "Q138580013", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "هشام فؤاد"} {"composed": false, "fields": {"given": "玲子", "surname": "駒井"}, "file": "Q138590768", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "駒井玲子"} {"composed": false, "fields": {"given": "정균", "surname": "주"}, "file": "Q138608130", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "주정균"} {"composed": false, "fields": {"given": "جيانيس", "surname": "كريستو"}, "file": "Q1391648", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "جيانيس كريستو"} {"composed": false, "fields": {"given": "فريد", "surname": "الأطرش"}, "file": "Q1391669", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "فريد الأطرش"} {"composed": false, "fields": {"given": "إحسان", "surname": "الرفاعي"}, "file": "Q139379782", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "إحسان الرفاعي"} {"composed": false, "fields": {"given": "კახა", "surname": "ბენდუქიძე"}, "file": "Q1395782", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "კახა ბენდუქიძე"} {"composed": false, "fields": {"given": "เกรียงศักดิ์", "surname": "โกวิทวาณิช"}, "file": "Q1396370", "label": "th-th", "order": "givenFirst", "shape": null, "text": "เกรียงศักดิ์ โกวิทวาณิช"} {"composed": false, "fields": {"given": "عدنان", "surname": "مصطفى"}, "file": "Q140264039", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "عدنان مصطفى"} {"composed": false, "fields": {"given": "Անուշավան", "surname": "Ազատյան"}, "file": "Q140324778", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Անուշավան Ազատյան"} {"composed": false, "fields": {"given": "Σταύρος", "surname": "Γεωργίου"}, "file": "Q140661872", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Σταύρος Γεωργίου"} {"composed": false, "fields": {"given": "Виктор", "surname": "Георгиев"}, "file": "Q141135124", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Виктор Георгиев"} {"composed": false, "fields": {"given": "محمد", "surname": "کوثری"}, "file": "Q141536821", "label": "fa-ir", "order": "givenFirst", "shape": null, "text": "محمد کوثری"} {"composed": false, "fields": {"given": "Արայիկ", "surname": "Մաթևոսյան"}, "file": "Q141610362", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Արայիկ Մաթևոսյան"} {"composed": false, "fields": {"given": "Бранко", "surname": "Борковић"}, "file": "Q141749349", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Бранко Борковић"} {"composed": false, "fields": {"given": "Ανδρέας", "surname": "Καραντώνης"}, "file": "Q14192722", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Ανδρέας Καραντώνης"} {"composed": false, "fields": {"given": "정희", "surname": "박"}, "file": "Q14356", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "박정희"} {"composed": false, "fields": {"given": "智英", "surname": "黎"}, "file": "Q1447095", "label": "zh-tw", "order": "surnameFirst", "shape": null, "text": "黎智英"} {"composed": false, "fields": {"given": "Վարդան", "surname": "Ստեփանյան"}, "file": "Q14478838", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Վարդան Ստեփանյան"} {"composed": false, "fields": {"given": "Ιωάννης", "surname": "Πετρίδης"}, "file": "Q14535892", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Ιωάννης Πετρίδης"} {"composed": false, "fields": {"given": "शरद", "surname": "मल्होत्रा"}, "file": "Q1495755", "label": "mr-in", "order": "givenFirst", "shape": null, "text": "शरद मल्होत्रा"} {"composed": false, "fields": {"given": "Աշոտ", "surname": "Պետրոսյան"}, "file": "Q15062430", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Աշոտ Պետրոսյան"} {"composed": false, "fields": {"given": "Милко", "surname": "Бјелица"}, "file": "Q1506733", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Милко Бјелица"} {"composed": false, "fields": {"given": "გურამ", "surname": "მეტონიძე"}, "file": "Q15072393", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "გურამ მეტონიძე"} {"composed": false, "fields": {"given": "Մարտին", "surname": "Սուքիասյան"}, "file": "Q15075331", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Մարտին Սուքիասյան"} {"composed": false, "fields": {"given": "Κωνσταντίνος", "surname": "Θεοτόκης"}, "file": "Q15135101", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Κωνσταντίνος Θεοτόκης"} {"composed": false, "fields": {"given": "Լևոն", "surname": "Աղասյան"}, "file": "Q15234643", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Լևոն Աղասյան"} {"composed": false, "fields": {"given": "شبیر", "surname": "اختر"}, "file": "Q15431085", "label": "ur-pk", "order": "givenFirst", "shape": null, "text": "شبیر اختر"} {"composed": false, "fields": {"given": "عمران", "surname": "خان"}, "file": "Q155164", "label": "ur-pk", "order": "givenFirst", "shape": null, "text": "عمران خان"} {"composed": false, "fields": {"given": "გურანდა", "surname": "ღვალაძე"}, "file": "Q1555152", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "გურანდა ღვალაძე"} {"composed": false, "fields": {"given": "আনোয়ার", "surname": "হোসেন"}, "file": "Q15633501", "label": "bn-bd", "order": "givenFirst", "shape": null, "text": "আনোয়ার হোসেন"} {"composed": false, "fields": {"given": "लता", "surname": "मंगेशकर"}, "file": "Q156347", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "लता मंगेशकर"} {"composed": false, "fields": {"given": "मनीष", "surname": "मल्होत्रा"}, "file": "Q1563710", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "मनीष मल्होत्रा"} {"composed": false, "fields": {"given": "Теофил", "surname": "Панчић"}, "file": "Q1568494", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Теофил Панчић"} {"composed": false, "fields": {"given": "अरुणा", "surname": "ईरानी"}, "file": "Q158225", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "अरुणा ईरानी"} {"composed": false, "fields": {"given": "Γιώργος", "surname": "Παπαδάκης"}, "file": "Q15839351", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Γιώργος Παπαδάκης"} {"composed": false, "fields": {"given": "জুহি", "surname": "চাওলা"}, "file": "Q158745", "label": "bn-in", "order": "givenFirst", "shape": null, "text": "জুহি চাওলা"} {"composed": false, "fields": {"given": "光", "surname": "韩"}, "file": "Q15916581", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "韩光"} {"composed": false, "fields": {"given": "毓麟", "surname": "邵"}, "file": "Q15950247", "label": "zh-tw", "order": "surnameFirst", "shape": null, "text": "邵毓麟"} {"composed": false, "fields": {"given": "雪莹", "surname": "王"}, "file": "Q15950777", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "王雪莹"} {"composed": false, "fields": {"given": "حسن", "surname": "عابدی"}, "file": "Q15982470", "label": "ur-pk", "order": "givenFirst", "shape": null, "text": "حسن عابدی"} {"composed": false, "fields": {"given": "Юлия", "surname": "Кръстева"}, "file": "Q159876", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Юлия Кръстева"} {"composed": false, "fields": {"given": "Мија", "surname": "Јовановић"}, "file": "Q16010018", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Мија Јовановић"} {"composed": false, "fields": {"given": "Θάνος", "surname": "Κωτσόπουλος"}, "file": "Q16061999", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Θάνος Κωτσόπουλος"} {"composed": false, "fields": {"given": "계원", "surname": "김"}, "file": "Q16067532", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "김계원"} {"composed": false, "fields": {"given": "재진", "surname": "권"}, "file": "Q16080127", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "권재진"} {"composed": false, "fields": {"given": "병관", "surname": "김"}, "file": "Q16080234", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "김병관"} {"composed": false, "fields": {"given": "Александар", "surname": "Антић"}, "file": "Q16082744", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Александар Антић"} {"composed": false, "fields": {"given": "광노", "surname": "이"}, "file": "Q16090710", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "이광노"} {"composed": false, "fields": {"given": "석모", "surname": "정"}, "file": "Q16091629", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "정석모"} {"composed": false, "fields": {"given": "준석", "surname": "추"}, "file": "Q16092276", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "추준석"} {"composed": false, "fields": {"given": "莉奈", "surname": "伊豆田"}, "file": "Q16097636", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "伊豆田莉奈"} {"composed": false, "fields": {"given": "Невен", "surname": "Суботић"}, "file": "Q161038", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Невен Суботић"} {"composed": false, "fields": {"given": "কেনেথ", "surname": "পাওয়েল"}, "file": "Q16105455", "label": "bn-in", "order": "givenFirst", "shape": null, "text": "কেনেথ পাওয়েল"} {"composed": false, "fields": {"given": "أمينة", "surname": "رشيد"}, "file": "Q16123001", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "أمينة رشيد"} {"composed": false, "fields": {"given": "نهلة", "surname": "القدسي"}, "file": "Q16124000", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "نهلة القدسي"} {"composed": false, "fields": {"given": "طارق", "surname": "سلامة"}, "file": "Q16126549", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "طارق سلامة"} {"composed": false, "fields": {"given": "אשר", "surname": "דורי"}, "file": "Q16128860", "label": "he-il", "order": "givenFirst", "shape": null, "text": "אשר דורי"} {"composed": false, "fields": {"given": "ברכה", "surname": "רוזנפלד"}, "file": "Q16131281", "label": "he-il", "order": "givenFirst", "shape": null, "text": "ברכה רוזנפלד"} {"composed": false, "fields": {"given": "יצחק", "surname": "טישלר"}, "file": "Q16132207", "label": "he-il", "order": "givenFirst", "shape": null, "text": "יצחק טישלר"} {"composed": false, "fields": {"given": "Олексій", "patronymic": "Олексійович", "surname": "Гончаренко"}, "file": "Q16172176", "label": "uk-ua", "order": "givenFirst", "shape": null, "text": "Олексій Олексійович Гончаренко"} {"composed": false, "fields": {"given": "경애", "surname": "박"}, "file": "Q16173397", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "박경애"} {"composed": false, "fields": {"given": "성우", "surname": "박"}, "file": "Q16174643", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "박성우"} {"composed": false, "fields": {"given": "강훈", "surname": "이"}, "file": "Q16174783", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "이강훈"} {"composed": false, "fields": {"given": "병훈", "surname": "이"}, "file": "Q16177359", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "이병훈"} {"composed": false, "fields": {"given": "장섭", "surname": "김"}, "file": "Q16178152", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "김장섭"} {"composed": false, "fields": {"given": "순옥", "surname": "홍"}, "file": "Q16179838", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "홍순옥"} {"composed": false, "fields": {"given": "대규", "surname": "임"}, "file": "Q16189078", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "임 대규"} {"composed": false, "fields": {"given": "अखिल", "surname": "मिश्रा"}, "file": "Q16194703", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "अखिल मिश्रा"} {"composed": false, "fields": {"given": "هالة", "surname": "شكر الله"}, "file": "Q16230492", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "هالة شكر الله"} {"composed": false, "fields": {"given": "Немања", "surname": "Максимовић"}, "file": "Q16236874", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Немања Максимовић"} {"composed": false, "fields": {"given": "Никола", "surname": "Мараш"}, "file": "Q16236878", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Никола Мараш"} {"composed": false, "fields": {"given": "정구", "surname": "허"}, "file": "Q16262220", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "허정구"} {"composed": false, "fields": {"given": "洁", "surname": "柯"}, "file": "Q16262307", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "柯洁"} {"composed": false, "fields": {"given": "Вук", "surname": "Драшковић"}, "file": "Q162740", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Вук Драшковић"} {"composed": false, "fields": {"given": "ნიკოლოზ", "surname": "ბასილაშვილი"}, "file": "Q16297666", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "ნიკოლოზ ბასილაშვილი"} {"composed": false, "fields": {"given": "सोनिया", "surname": "गाँधी"}, "file": "Q163225", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "सोनिया गाँधी"} {"composed": false, "fields": {"given": "Βασίλης", "surname": "Καραπατάκης"}, "file": "Q16328534", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Βασίλης Καραπατάκης"} {"composed": false, "fields": {"given": "Χαράλαμπος", "surname": "Σιμόπουλος"}, "file": "Q16332430", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Χαράλαμπος Σιμόπουλος"} {"composed": false, "fields": {"given": "რევაზ", "surname": "ადამია"}, "file": "Q16334103", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "რევაზ ადამია"} {"composed": false, "fields": {"given": "ანასტასია", "surname": "ვირსალაძე"}, "file": "Q16364800", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "ანასტასია ვირსალაძე"} {"composed": false, "fields": {"given": "ვლადიმერ", "surname": "ანთაძე"}, "file": "Q16369188", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "ვლადიმერ ანთაძე"} {"composed": false, "fields": {"given": "სოსო", "surname": "პაიჭაძე"}, "file": "Q16375930", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "სოსო პაიჭაძე"} {"composed": false, "fields": {"given": "Հենրիկ", "surname": "Ազատյան"}, "file": "Q16388316", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Հենրիկ Ազատյան"} {"composed": false, "fields": {"given": "Սուրեն", "surname": "Բաբայան"}, "file": "Q16399896", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Սուրեն Բաբայան"} {"composed": false, "fields": {"given": "Φαίδων", "surname": "Θεοφίλου"}, "file": "Q16512416", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Φαίδων Θεοφίλου"} {"composed": false, "fields": {"given": "Γεώργιος", "surname": "Μητσόπουλος"}, "file": "Q16512475", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Γεώργιος Μητσόπουλος"} {"composed": false, "fields": {"given": "Σταύρος", "surname": "Κωστόπουλος"}, "file": "Q16512517", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Σταύρος Κωστόπουλος"} {"composed": false, "fields": {"given": "志伟", "surname": "邓"}, "file": "Q16539540", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "邓志伟"} {"composed": false, "fields": {"given": "응천", "surname": "김"}, "file": "Q16630799", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "김응천"} {"composed": false, "fields": {"given": "Иван", "surname": "Радулов"}, "file": "Q1675990", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Иван Радулов"} {"composed": false, "fields": {"given": "فرح", "surname": "خان"}, "file": "Q167636", "label": "ur-in", "order": "givenFirst", "shape": null, "text": "فرح خان"} {"composed": false, "fields": {"given": "रेणुका", "surname": "सिंह"}, "file": "Q16885696", "label": "mr-in", "order": "givenFirst", "shape": null, "text": "रेणुका सिंह"} {"composed": false, "fields": {"given": "Радован", "surname": "Панков"}, "file": "Q16935153", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Радован Панков"} {"composed": false, "fields": {"given": "Немања", "surname": "Милетић"}, "file": "Q16980084", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Немања Милетић"} {"composed": false, "fields": {"given": "维", "surname": "刘"}, "file": "Q17020840", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "刘维"} {"composed": false, "fields": {"given": "Йордан", "surname": "Мутафчиев"}, "file": "Q1703641", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Йордан Мутафчиев"} {"composed": false, "fields": {"given": "Ալեքսանդր", "surname": "Թումասյան"}, "file": "Q17285756", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Ալեքսանդր Թումասյան"} {"composed": false, "fields": {"given": "Илија", "surname": "Петковић"}, "file": "Q173923", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Илија Петковић"} {"composed": false, "fields": {"given": "상현", "surname": "조"}, "file": "Q17401441", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "조상현"} {"composed": false, "fields": {"given": "অবন্তিকা", "surname": "মিশ্র"}, "file": "Q17708728", "label": "bn-in", "order": "givenFirst", "shape": null, "text": "অবন্তিকা মিশ্র"} {"composed": false, "fields": {"given": "فتح‌الله", "surname": "بنی‌صدر"}, "file": "Q17993037", "label": "fa-ir", "order": "givenFirst", "shape": null, "text": "فتح‌الله بنی‌صدر"} {"composed": false, "fields": {"given": "عباس", "surname": "وفایی"}, "file": "Q17994757", "label": "fa-ir", "order": "givenFirst", "shape": null, "text": "عباس وفایی"} {"composed": false, "fields": {"given": "მიხეილ", "surname": "ჯოჯუა"}, "file": "Q18015439", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "მიხეილ ჯოჯუა"} {"composed": false, "fields": {"given": "Στέφανος", "surname": "Λάντσας"}, "file": "Q18019609", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Στέφανος Λάντσας"} {"composed": false, "fields": {"given": "محمد", "surname": "خاتمی"}, "file": "Q180795", "label": "fa-ir", "order": "givenFirst", "shape": null, "text": "محمد خاتمی"} {"composed": false, "fields": {"given": "희완", "surname": "이"}, "file": "Q1811820", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "이희완"} {"composed": false, "fields": {"given": "मोहित", "surname": "शर्मा"}, "file": "Q18390894", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "मोहित शर्मा"} {"composed": false, "fields": {"given": "Румяна", "surname": "Бъчварова"}, "file": "Q18535066", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Румяна Бъчварова"} {"composed": false, "fields": {"given": "राजेश", "surname": "कुमार"}, "file": "Q18645824", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "राजेश कुमार"} {"composed": false, "fields": {"given": "影", "surname": "李"}, "file": "Q18653800", "label": "zh-tw", "order": "surnameFirst", "shape": null, "text": "李影"} {"composed": false, "fields": {"given": "เดชาพล", "surname": "พัววรานุเคราะห์"}, "file": "Q18670515", "label": "th-th", "order": "givenFirst", "shape": null, "text": "เดชาพล พัววรานุเคราะห์"} {"composed": false, "fields": {"given": "ชวเลิศ", "surname": "ชุ่มคำ"}, "file": "Q186736", "label": "th-th", "order": "givenFirst", "shape": null, "text": "ชวเลิศ ชุ่มคำ"} {"composed": false, "fields": {"given": "أحمد", "surname": "مصطفى"}, "file": "Q18710313", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "أحمد مصطفى"} {"composed": false, "fields": {"given": "태곤", "surname": "손"}, "file": "Q18815574", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "손태곤"} {"composed": false, "fields": {"given": "محمود", "surname": "الزعبي"}, "file": "Q1884964", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "محمود الزعبي"} {"composed": false, "fields": {"given": "Ιωάννης", "surname": "Μεταξάς"}, "file": "Q189139", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Ιωάννης Μεταξάς"} {"composed": false, "fields": {"given": "شيماء", "surname": "الصباغ"}, "file": "Q18918574", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "شيماء الصباغ"} {"composed": false, "fields": {"given": "기봉", "surname": "고"}, "file": "Q18998322", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "고기봉"} {"composed": false, "fields": {"given": "경원", "surname": "박"}, "file": "Q18999980", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "박경원"} {"composed": false, "fields": {"given": "형부", "surname": "조"}, "file": "Q19162058", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "조형부"} {"composed": false, "fields": {"given": "병규", "surname": "천"}, "file": "Q19162110", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "천병규"} {"composed": false, "fields": {"given": "두환", "surname": "최"}, "file": "Q19162130", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "최두환"} {"composed": false, "fields": {"given": "मीना", "surname": "शाह"}, "file": "Q1916767", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "मीना शाह"} {"composed": false, "fields": {"given": "美玲", "surname": "周"}, "file": "Q191916", "label": "zh-tw", "order": "surnameFirst", "shape": null, "text": "周美玲"} {"composed": false, "fields": {"given": "კახა", "surname": "კალაძე"}, "file": "Q192031", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "კახა კალაძე"} {"composed": false, "fields": {"given": "Антон", "surname": "Каблешков"}, "file": "Q19203803", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Антон Каблешков"} {"composed": false, "fields": {"given": "ლევან", "surname": "ინწკირველი"}, "file": "Q19516152", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "ლევან ინწკირველი"} {"composed": false, "fields": {"given": "ალექსანდრე", "surname": "იაშვილი"}, "file": "Q195317", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "ალექსანდრე იაშვილი"} {"composed": false, "fields": {"given": "رینوکا", "surname": "شرما"}, "file": "Q19599587", "label": "ur-in", "order": "givenFirst", "shape": null, "text": "رینوکا شرما"} {"composed": false, "fields": {"given": "Найден", "surname": "Тодоров"}, "file": "Q1963895", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Найден Тодоров"} {"composed": false, "fields": {"given": "আবদুল করিম", "surname": "শাহ"}, "file": "Q19646586", "label": "bn-bd", "order": "givenFirst", "shape": null, "text": "আবদুল করিম শাহ"} {"composed": false, "fields": {"given": "হেলেনা", "surname": "খান"}, "file": "Q19661679", "label": "bn-bd", "order": "givenFirst", "shape": null, "text": "হেলেনা খান"} {"composed": false, "fields": {"given": "অসীম", "surname": "বসু"}, "file": "Q19663402", "label": "bn-in", "order": "givenFirst", "shape": null, "text": "অসীম বসু"} {"composed": false, "fields": {"given": "Симона", "surname": "Пейчева"}, "file": "Q1968919", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Симона Пейчева"} {"composed": false, "fields": {"given": "Желязко", "surname": "Ангелов"}, "file": "Q19705456", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Желязко Ангелов"} {"composed": false, "fields": {"given": "Գոհար", "surname": "Գասպարյան"}, "file": "Q1971489", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Գոհար Գասպարյան"} {"composed": false, "fields": {"given": "祖權", "surname": "茅"}, "file": "Q19825302", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "茅祖權"} {"composed": false, "fields": {"given": "জিয়াউর", "surname": "রহমান"}, "file": "Q198313", "label": "bn-bd", "order": "givenFirst", "shape": null, "text": "জিয়াউর রহমান"} {"composed": false, "fields": {"given": "정곤", "surname": "장"}, "file": "Q19940974", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "장정곤"} {"composed": false, "fields": {"given": "स्मिता", "surname": "बंसल"}, "file": "Q2003885", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "स्मिता बंसल"} {"composed": false, "fields": {"given": "鵬", "surname": "沈"}, "file": "Q20063761", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "沈鵬"} {"composed": false, "fields": {"given": "Едуард", "patronymic": "Михайлович", "surname": "Москальов"}, "file": "Q20068674", "label": "uk-ua", "order": "givenFirst", "shape": null, "text": "Едуард Михайлович Москальов"} {"composed": false, "fields": {"given": "Անդրանիկ", "surname": "Հարությունյան"}, "file": "Q20091434", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Անդրանիկ Հարությունյան"} {"composed": false, "fields": {"given": "ज्ञानेन्द्र वीर विक्रम", "surname": "शाहदेव"}, "file": "Q201327", "label": "ne-np", "order": "givenFirst", "shape": null, "text": "ज्ञानेन्द्र वीर विक्रम शाहदेव"} {"composed": false, "fields": {"given": "正雄", "surname": "岡"}, "file": "Q2017050", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "岡正雄"} {"composed": false, "fields": {"given": "Олег", "surname": "Сакиркин"}, "file": "Q2019276", "label": "kk-kz", "order": "givenFirst", "shape": null, "text": "Олег Сакиркин"} {"composed": false, "fields": {"given": "凯歌", "surname": "陈"}, "file": "Q202597", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "陈凯歌"} {"composed": false, "fields": {"given": "Владимир", "surname": "Вазов"}, "file": "Q2039111", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Владимир Вазов"} {"composed": false, "fields": {"given": "शांता", "surname": "कुमार"}, "file": "Q2045095", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "शांता कुमार"} {"composed": false, "fields": {"given": "Արա", "surname": "Աբրահամյան"}, "file": "Q204689", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Արա Աբրահամյան"} {"composed": false, "fields": {"given": "Սուրեն", "surname": "Երեմյան"}, "file": "Q2047004", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Սուրեն Երեմյան"} {"composed": false, "fields": {"given": "Յուրի", "surname": "Դավթյան"}, "file": "Q20508268", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Յուրի Դավթյան"} {"composed": false, "fields": {"given": "Ալեքսանդր", "surname": "Մանասյան"}, "file": "Q20508413", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Ալեքսանդր Մանասյան"} {"composed": false, "fields": {"given": "Գրիգոր", "surname": "Բրուտյան"}, "file": "Q20509962", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Գրիգոր Բրուտյան"} {"composed": false, "fields": {"given": "Ռաֆիկ", "surname": "Եղոյան"}, "file": "Q20510173", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Ռաֆիկ Եղոյան"} {"composed": false, "fields": {"given": "Զառա", "surname": "Տոնիկյան"}, "file": "Q20511451", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Զառա Տոնիկյան"} {"composed": false, "fields": {"given": "Հրանտ", "surname": "Թովմասյան"}, "file": "Q20513355", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Հրանտ Թովմասյան"} {"composed": false, "fields": {"given": "თეიმურაზ", "surname": "კვირკველია"}, "file": "Q20559095", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "თეიმურაზ კვირკველია"} {"composed": false, "fields": {"given": "ნოდარ", "surname": "ცინცაძე"}, "file": "Q20562083", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "ნოდარ ცინცაძე"} {"composed": false, "fields": {"given": "ირაკლი", "surname": "ელიავა"}, "file": "Q20573528", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "ირაკლი ელიავა"} {"composed": false, "fields": {"given": "亨", "surname": "吉川"}, "file": "Q2057423", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "吉川亨"} {"composed": false, "fields": {"given": "თამაზ", "surname": "კვაჭანტირაძე"}, "file": "Q20576060", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "თამაზ კვაჭანტირაძე"} {"composed": false, "fields": {"given": "영근", "surname": "최"}, "file": "Q2059290", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "최영근"} {"composed": false, "fields": {"given": "أمين", "surname": "سامي"}, "file": "Q20608488", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "أمين سامي"} {"composed": false, "fields": {"given": "상옥", "surname": "최"}, "file": "Q20644439", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "최상옥"} {"composed": false, "fields": {"given": "Александър", "surname": "Цветков"}, "file": "Q2065023", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Александър Цветков"} {"composed": false, "fields": {"given": "Душан", "surname": "Милошевић"}, "file": "Q2068173", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Душан Милошевић"} {"composed": false, "fields": {"given": "Βασίλειος", "surname": "Κόκκινος"}, "file": "Q20722812", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Βασίλειος Κόκκινος"} {"composed": false, "fields": {"given": "مريم", "surname": "صالح"}, "file": "Q20730848", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "مريم صالح"} {"composed": false, "fields": {"given": "Կոնստանտին", "surname": "Օրբելյան"}, "file": "Q2074401", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Կոնստանտին Օրբելյան"} {"composed": false, "fields": {"given": "Γεώργιος", "surname": "Δρόσος"}, "file": "Q20894440", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Γεώργιος Δρόσος"} {"composed": false, "fields": {"given": "勇人", "surname": "佐々木"}, "file": "Q210231", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "佐々木 勇人"} {"composed": false, "fields": {"given": "Вероніка", "surname": "Марченко"}, "file": "Q21067016", "label": "uk-ua", "order": "givenFirst", "shape": null, "text": "Вероніка Марченко"} {"composed": false, "fields": {"given": "انیتا", "surname": "اگنی ہوتری"}, "file": "Q21074354", "label": "ur-in", "order": "givenFirst", "shape": null, "text": "انیتا اگنی ہوتری"} {"composed": false, "fields": {"given": "윤기", "surname": "민"}, "file": "Q21075020", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "민윤기"} {"composed": false, "fields": {"given": "ประวัติ", "surname": "ปัตตพงศ์"}, "file": "Q2108027", "label": "th-th", "order": "givenFirst", "shape": null, "text": "ประวัติ ปัตตพงศ์"} {"composed": false, "fields": {"given": "Գոհար", "surname": "Գասպարյան"}, "file": "Q2109153", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Գոհար Գասպարյան"} {"composed": false, "fields": {"given": "주영", "surname": "박"}, "file": "Q210928", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "박주영"} {"composed": false, "fields": {"given": "جيهان", "surname": "السادات"}, "file": "Q212190", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "جيهان السادات"} {"composed": false, "fields": {"given": "昌司", "surname": "桜井"}, "file": "Q21438706", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "桜井昌司"} {"composed": false, "fields": {"given": "명희", "surname": "유"}, "file": "Q21445432", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "유명희"} {"composed": false, "fields": {"given": "명한", "surname": "장"}, "file": "Q21825038", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "장명한"} {"composed": false, "fields": {"given": "성태", "surname": "허"}, "file": "Q21825451", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "허성태"} {"composed": false, "fields": {"given": "Христо", "surname": "Миладинов"}, "file": "Q21994789", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Христо Миладинов"} {"composed": false, "fields": {"given": "정희", "surname": "이"}, "file": "Q224639", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "이정희"} {"composed": false, "fields": {"given": "أكرم", "surname": "التلاوي"}, "file": "Q22684231", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "أكرم التلاوي"} {"composed": false, "fields": {"given": "حميدة", "surname": "الطاهر"}, "file": "Q22687617", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "حميدة الطاهر"} {"composed": false, "fields": {"given": "隆基", "surname": "罗"}, "file": "Q2275823", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "罗隆基"} {"composed": false, "fields": {"given": "किरण", "surname": "देसाई"}, "file": "Q228589", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "किरण देसाई"} {"composed": false, "fields": {"given": "شهره", "surname": "آغداشلو"}, "file": "Q229766", "label": "fa-ir", "order": "givenFirst", "shape": null, "text": "شهره آغداشلو"} {"composed": false, "fields": {"given": "მიხეილ", "surname": "ქორქია"}, "file": "Q2323687", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "მიხეილ ქორქია"} {"composed": false, "fields": {"given": "Оксана", "patronymic": "Сергіївна", "surname": "Баюл"}, "file": "Q232995", "label": "uk-ua", "order": "givenFirst", "shape": null, "text": "Оксана Сергіївна Баюл"} {"composed": false, "fields": {"given": "নিখিল", "surname": "আডবাণী"}, "file": "Q2332619", "label": "bn-in", "order": "givenFirst", "shape": null, "text": "নিখিল আডবাণী"} {"composed": false, "fields": {"given": "Տիգրան", "surname": "Նալբանդյան"}, "file": "Q2335548", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Տիգրան Նալբանդյան"} {"composed": false, "fields": {"given": "अक्षय", "surname": "कुमार"}, "file": "Q233748", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "अक्षय कुमार"} {"composed": false, "fields": {"given": "璐美", "surname": "朴"}, "file": "Q233902", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "朴璐美"} {"composed": false, "fields": {"given": "أصالة", "surname": "نصري"}, "file": "Q2353139", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "أصالة نصري"} {"composed": false, "fields": {"given": "扬", "surname": "杨"}, "file": "Q236282", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "杨扬"} {"composed": false, "fields": {"given": "亜美", "surname": "小清水"}, "file": "Q236715", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "小清水 亜美"} {"composed": false, "fields": {"given": "Константин", "surname": "Марков"}, "file": "Q23680686", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Константин Марков"} {"composed": false, "fields": {"given": "سارہ", "surname": "خان"}, "file": "Q23712226", "label": "ur-pk", "order": "givenFirst", "shape": null, "text": "سارہ خان"} {"composed": false, "fields": {"given": "अर्जुन", "surname": "कपूर"}, "file": "Q2383191", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "अर्जुन कपूर"} {"composed": false, "fields": {"given": "Μαρίκα", "surname": "Τζιραλίδου"}, "file": "Q23888864", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Μαρίκα Τζιραλίδου"} {"composed": false, "fields": {"given": "Վազգեն", "surname": "Մանուկյան"}, "file": "Q2389099", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Վազգեն Մանուկյան"} {"composed": false, "fields": {"given": "Արթուր", "surname": "Մկրտչյան"}, "file": "Q2389126", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Արթուր Մկրտչյան"} {"composed": false, "fields": {"given": "信雄", "surname": "藤田"}, "file": "Q2404303", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "藤田信雄"} {"composed": false, "fields": {"given": "Урош", "surname": "Рачић"}, "file": "Q24084219", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Урош Рачић"} {"composed": false, "fields": {"given": "أحمد", "surname": "حجازي"}, "file": "Q2409638", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "أحمد حجازي"} {"composed": false, "fields": {"given": "سلام", "surname": "امینی"}, "file": "Q24175830", "label": "fa-ir", "order": "givenFirst", "shape": null, "text": "سلام امینی"} {"composed": false, "fields": {"given": "진주", "surname": "이"}, "file": "Q24287375", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "이진주"} {"composed": false, "fields": {"given": "Динко", "surname": "Цветков Дерменджиев"}, "file": "Q2444179", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Динко Цветков Дерменджиев"} {"composed": false, "fields": {"given": "信夫", "surname": "木内"}, "file": "Q24876650", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "木内 信夫"} {"composed": false, "fields": {"given": "नीरज", "surname": "चोपडा"}, "file": "Q24955843", "label": "mr-in", "order": "givenFirst", "shape": null, "text": "नीरज चोपडा"} {"composed": false, "fields": {"given": "علی", "surname": "حاتمی"}, "file": "Q250608", "label": "fa-ir", "order": "givenFirst", "shape": null, "text": "علی حاتمی"} {"composed": false, "fields": {"given": "भानुभक्त", "surname": "आचार्य"}, "file": "Q2510589", "label": "ne-np", "order": "givenFirst", "shape": null, "text": "भानुभक्त आचार्य"} {"composed": false, "fields": {"given": "وليد", "surname": "الشهري"}, "file": "Q2529866", "label": "ar-sa", "order": "givenFirst", "shape": null, "text": "وليد الشهري"} {"composed": false, "fields": {"given": "静", "surname": "陈"}, "file": "Q253708", "label": "zh-tw", "order": "surnameFirst", "shape": null, "text": "陈静"} {"composed": false, "fields": {"given": "Александър", "surname": "Марков"}, "file": "Q25487517", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Александър Марков"} {"composed": false, "fields": {"given": "Қайрат", "patronymic": "Болатұлы", "surname": "Тұрлыханов"}, "file": "Q25490264", "label": "kk-kz", "order": "givenFirst", "shape": null, "text": "Қайрат Болатұлы Тұрлыханов"} {"composed": false, "fields": {"given": "תמרה", "surname": "כהן"}, "file": "Q25491919", "label": "he-il", "order": "givenFirst", "shape": null, "text": "תמרה כהן"} {"composed": false, "fields": {"given": "Төлеу", "surname": "Шаханов"}, "file": "Q25510301", "label": "kk-kz", "order": "givenFirst", "shape": null, "text": "Төлеу Шаханов"} {"composed": false, "fields": {"given": "Драган", "surname": "Стојковић"}, "file": "Q257548", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Драган Стојковић"} {"composed": false, "fields": {"given": "רון", "surname": "ארד"}, "file": "Q258128", "label": "he-il", "order": "givenFirst", "shape": null, "text": "רון ארד"} {"composed": false, "fields": {"given": "ألكسندر", "surname": "دارسي"}, "file": "Q2603039", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "ألكسندر دارسي"} {"composed": false, "fields": {"given": "غادة", "surname": "السمان"}, "file": "Q2612563", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "غادة السمان"} {"composed": false, "fields": {"given": "Анастас", "surname": "Јовановић"}, "file": "Q2613585", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Анастас Јовановић"} {"composed": false, "fields": {"given": "Ռոբերտ", "surname": "Հովհաննիսյան"}, "file": "Q2616692", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Ռոբերտ Հովհաննիսյան"} {"composed": false, "fields": {"given": "Нено", "surname": "Неделчев"}, "file": "Q26212354", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Нено Неделчев"} {"composed": false, "fields": {"given": "אברהם", "surname": "אדן"}, "file": "Q2624233", "label": "he-il", "order": "givenFirst", "shape": null, "text": "אברהם אדן"} {"composed": false, "fields": {"given": "Карина", "surname": "Горичева"}, "file": "Q26248683", "label": "kk-kz", "order": "givenFirst", "shape": null, "text": "Карина Горичева"} {"composed": false, "fields": {"given": "小蘭", "surname": "趙"}, "file": "Q263322", "label": "zh-tw", "order": "surnameFirst", "shape": null, "text": "趙小蘭"} {"composed": false, "fields": {"given": "Бранислав", "surname": "Јовановић"}, "file": "Q2638729", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Бранислав Јовановић"} {"composed": false, "fields": {"given": "Бақытжан", "surname": "Қанапиянов"}, "file": "Q2640776", "label": "kk-kz", "order": "givenFirst", "shape": null, "text": "Бақытжан Қанапиянов"} {"composed": false, "fields": {"given": "Վլադիմիր", "surname": "Դարբինյան"}, "file": "Q2644893", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Վլադիմիր Դարբինյան"} {"composed": false, "fields": {"given": "ہاشم", "surname": "خان"}, "file": "Q2648565", "label": "ur-pk", "order": "givenFirst", "shape": null, "text": "ہاشم خان"} {"composed": false, "fields": {"given": "Драгомир", "surname": "Јовановић"}, "file": "Q265772", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Драгомир Јовановић"} {"composed": false, "fields": {"given": "مها", "surname": "عامر"}, "file": "Q26664055", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "مها عامر"} {"composed": false, "fields": {"given": "Биљана", "surname": "Плавшић"}, "file": "Q26769", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Биљана Плавшић"} {"composed": false, "fields": {"given": "謙一郎", "surname": "佐々木"}, "file": "Q26776575", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "佐々木謙一郎"} {"composed": false, "fields": {"given": "शिवांगी", "surname": "जोशी"}, "file": "Q26923060", "label": "mr-in", "order": "givenFirst", "shape": null, "text": "शिवांगी जोशी"} {"composed": false, "fields": {"given": "Ֆելիքս", "surname": "Բախչինյան"}, "file": "Q26973541", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Ֆելիքս Բախչինյան"} {"composed": false, "fields": {"given": "연경", "surname": "김"}, "file": "Q270135", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "김연경"} {"composed": false, "fields": {"given": "अनिल", "surname": "जोशी"}, "file": "Q2722277", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "अनिल जोशी"} {"composed": false, "fields": {"given": "बजाज", "surname": "राहुल"}, "file": "Q2724084", "label": "mr-in", "order": "surnameFirst", "shape": 4, "text": "राहुल बजाज"} {"composed": false, "fields": {"given": "Νίκος", "surname": "Γαλανός"}, "file": "Q27262754", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Νίκος Γαλανός"} {"composed": false, "fields": {"given": "कुणाल", "surname": "कपूर"}, "file": "Q2737601", "label": "mr-in", "order": "givenFirst", "shape": null, "text": "कुणाल कपूर"} {"composed": false, "fields": {"given": "Մանուել", "surname": "Պետրոսյան"}, "file": "Q27529864", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Մանուել Պետրոսյան"} {"composed": false, "fields": {"given": "निरूपा", "surname": "रॉय"}, "file": "Q2763244", "label": "mr-in", "order": "givenFirst", "shape": null, "text": "निरूपा रॉय"} {"composed": false, "fields": {"given": "পঙ্কজ", "surname": "মিশ্র"}, "file": "Q277575", "label": "bn-in", "order": "givenFirst", "shape": null, "text": "পঙ্কজ মিশ্র"} {"composed": false, "fields": {"given": "顺", "surname": "汪"}, "file": "Q2789989", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "汪顺"} {"composed": false, "fields": {"given": "نورس", "surname": "عبد الواحد"}, "file": "Q27914098", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "نورس عبد الواحد"} {"composed": false, "fields": {"given": "용완", "surname": "김"}, "file": "Q27919953", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "김용완"} {"composed": false, "fields": {"given": "지수", "surname": "육"}, "file": "Q27953830", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "육지수"} {"composed": false, "fields": {"given": "浩", "surname": "湯浅"}, "file": "Q28068683", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "湯浅浩"} {"composed": false, "fields": {"given": "Νίκος", "surname": "Καράμπελας"}, "file": "Q2814369", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Νίκος Καράμπελας"} {"composed": false, "fields": {"given": "সুলতান", "surname": "মাহমুদ"}, "file": "Q28179433", "label": "bn-bd", "order": "givenFirst", "shape": null, "text": "সুলতান মাহমুদ"} {"composed": false, "fields": {"given": "曦", "surname": "魏"}, "file": "Q28355175", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "魏曦"} {"composed": false, "fields": {"given": "明忠", "surname": "陳"}, "file": "Q28417390", "label": "zh-tw", "order": "surnameFirst", "shape": null, "text": "陳明忠"} {"composed": false, "fields": {"given": "תמיר", "surname": "כהן"}, "file": "Q284590", "label": "he-il", "order": "givenFirst", "shape": null, "text": "תמיר כהן"} {"composed": false, "fields": {"given": "Андрија", "surname": "Милошевић"}, "file": "Q2846579", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Андрија Милошевић"} {"composed": false, "fields": {"given": "Васил", "surname": "Янакиев"}, "file": "Q28480344", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Васил Янакиев"} {"composed": false, "fields": {"given": "Янко", "surname": "Павлов"}, "file": "Q28548236", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Янко Павлов"} {"composed": false, "fields": {"given": "Филип", "surname": "Ђуричић"}, "file": "Q285518", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Филип Ђуричић"} {"composed": false, "fields": {"given": "אהרן", "surname": "מונדשיין"}, "file": "Q28605098", "label": "he-il", "order": "givenFirst", "shape": null, "text": "אהרן מונדשיין"} {"composed": false, "fields": {"given": "Татьяна", "patronymic": "Васильевна", "surname": "Костина"}, "file": "Q28664784", "label": "kk-kz", "order": "givenFirst", "shape": null, "text": "Татьяна Васильевна Костина"} {"composed": false, "fields": {"given": "호연", "surname": "정"}, "file": "Q28699137", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "정호연"} {"composed": false, "fields": {"given": "진구", "surname": "박"}, "file": "Q28700409", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "박진구"} {"composed": false, "fields": {"given": "Мирослав", "surname": "Радовановић"}, "file": "Q28760891", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Мирослав Радовановић"} {"composed": false, "fields": {"given": "Николай", "surname": "Кашев"}, "file": "Q28836262", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Николай Кашев"} {"composed": false, "fields": {"given": "Тијана", "surname": "Богићевић"}, "file": "Q28849470", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Тијана Богићевић"} {"composed": false, "fields": {"given": "Бранислав", "surname": "Блажић"}, "file": "Q28911753", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Бранислав Блажић"} {"composed": false, "fields": {"given": "سامي", "surname": "الكيالي"}, "file": "Q28936497", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "سامي الكيالي"} {"composed": false, "fields": {"given": "경애", "surname": "김"}, "file": "Q28966728", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "김경애"} {"composed": false, "fields": {"given": "עמוס", "surname": "הדר"}, "file": "Q2900487", "label": "he-il", "order": "givenFirst", "shape": null, "text": "עמוס הדר"} {"composed": false, "fields": {"given": "אברהם", "surname": "כץ"}, "file": "Q2901672", "label": "he-il", "order": "givenFirst", "shape": null, "text": "אברהם כץ"} {"composed": false, "fields": {"given": "علی‌اصغر", "surname": "زارعی"}, "file": "Q29018353", "label": "fa-ir", "order": "givenFirst", "shape": null, "text": "علی‌اصغر زارعی"} {"composed": false, "fields": {"given": "ששון", "surname": "סומך"}, "file": "Q2903564", "label": "he-il", "order": "givenFirst", "shape": null, "text": "ששון סומך"} {"composed": false, "fields": {"given": "Богдан", "surname": "Несторовић"}, "file": "Q2908636", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Богдан Несторовић"} {"composed": false, "fields": {"given": "אהרן", "surname": "מסקין"}, "file": "Q2909433", "label": "he-il", "order": "givenFirst", "shape": null, "text": "אהרן מסקין"} {"composed": false, "fields": {"given": "אמה", "surname": "תלמי"}, "file": "Q2909759", "label": "he-il", "order": "givenFirst", "shape": null, "text": "אמה תלמי"} {"composed": false, "fields": {"given": "דבורה", "surname": "עומר"}, "file": "Q2910028", "label": "he-il", "order": "givenFirst", "shape": null, "text": "דבורה עומר"} {"composed": false, "fields": {"given": "אמיר", "surname": "דרורי"}, "file": "Q2910092", "label": "he-il", "order": "givenFirst", "shape": null, "text": "אמיר דרורי"} {"composed": false, "fields": {"given": "Синиша", "surname": "Михајловић"}, "file": "Q294351", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Синиша Михајловић"} {"composed": false, "fields": {"given": "بهروز", "surname": "بوچانی"}, "file": "Q29452528", "label": "fa-ir", "order": "givenFirst", "shape": null, "text": "بهروز بوچانی"} {"composed": false, "fields": {"given": "وداد", "surname": "أنطوان"}, "file": "Q29514393", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "وداد أنطوان"} {"composed": false, "fields": {"given": "अनुराधा", "surname": "भट्टाचार्य"}, "file": "Q29764394", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "अनुराधा भट्टाचार्य"} {"composed": false, "fields": {"given": "Լորիս", "surname": "Ճգնավորյան"}, "file": "Q2984588", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Լորիս Ճգնավորյան"} {"composed": false, "fields": {"given": "मुकेश", "surname": "अंबानी"}, "file": "Q298547", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "मुकेश अंबानी"} {"composed": false, "fields": {"given": "दीपा", "surname": "मेहता"}, "file": "Q299140", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "दीपा मेहता"} {"composed": false, "fields": {"given": "Євген", "surname": "Гуцало"}, "file": "Q2993752", "label": "uk-ua", "order": "givenFirst", "shape": null, "text": "Євген Гуцало"} {"composed": false, "fields": {"given": "Αθανάσιος", "surname": "Δροσόπουλος"}, "file": "Q30085506", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Αθανάσιος Δροσόπουλος"} {"composed": false, "fields": {"given": "Լենա", "surname": "Նազարյան"}, "file": "Q30195988", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Լենա Նազարյան"} {"composed": false, "fields": {"given": "Κωνσταντίνος", "surname": "Πυλαρινός"}, "file": "Q30276264", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Κωνσταντίνος Πυλαρινός"} {"composed": false, "fields": {"given": "Παναγιώτης", "surname": "Καραλής"}, "file": "Q30302667", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Παναγιώτης Καραλής"} {"composed": false, "fields": {"given": "משה", "surname": "חלוץ"}, "file": "Q30527587", "label": "he-il", "order": "givenFirst", "shape": null, "text": "משה חלוץ"} {"composed": false, "fields": {"given": "صنع الله", "surname": "إبراهيم"}, "file": "Q3053937", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "صنع الله إبراهيم"} {"composed": false, "fields": {"given": "Νίκος", "surname": "Νικολαΐδης"}, "file": "Q3107029", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Νίκος Νικολαΐδης"} {"composed": false, "fields": {"given": "गिरिजा", "surname": "देवी"}, "file": "Q3107617", "label": "mr-in", "order": "givenFirst", "shape": null, "text": "गिरिजा देवी"} {"composed": false, "fields": {"given": "ნიკო", "surname": "ფიროსმანი"}, "file": "Q310896", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "ნიკო ფიროსმანი"} {"composed": false, "fields": {"given": "Коста", "surname": "Михаиловић"}, "file": "Q31184057", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Коста Михаиловић"} {"composed": false, "fields": {"given": "Анђелија", "surname": "Веснић"}, "file": "Q31185519", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Анђелија Веснић"} {"composed": false, "fields": {"given": "Константин", "surname": "Марков"}, "file": "Q31192684", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Константин Марков"} {"composed": false, "fields": {"given": "Тина", "surname": "Крајишник"}, "file": "Q31271087", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Тина Крајишник"} {"composed": false, "fields": {"given": "經國", "surname": "蔣"}, "file": "Q313598", "label": "zh-tw", "order": "surnameFirst", "shape": null, "text": "蔣經國"} {"composed": false, "fields": {"given": "Дејан", "surname": "Бодирога"}, "file": "Q313622", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Дејан Бодирога"} {"composed": false, "fields": {"given": "अनिल", "surname": "कपूर"}, "file": "Q313956", "label": "mr-in", "order": "givenFirst", "shape": null, "text": "अनिल कपूर"} {"composed": false, "fields": {"given": "عبد الرحمن", "surname": "السديس"}, "file": "Q317948", "label": "ar-sa", "order": "givenFirst", "shape": null, "text": "عبد الرحمن السديس"} {"composed": false, "fields": {"given": "Δημήτρης", "surname": "Μητρόπουλος"}, "file": "Q319741", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Δημήτρης Μητρόπουλος"} {"composed": false, "fields": {"given": "성혜", "surname": "김"}, "file": "Q32059867", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "김성혜"} {"composed": false, "fields": {"given": "栄二", "surname": "高田"}, "file": "Q3244756", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "高田栄二"} {"composed": false, "fields": {"given": "大介", "surname": "多田"}, "file": "Q3245427", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "多田大介"} {"composed": false, "fields": {"given": "কামাল", "surname": "হোসেন"}, "file": "Q327967", "label": "bn-bd", "order": "givenFirst", "shape": null, "text": "কামাল হোসেন"} {"composed": false, "fields": {"given": "Ранко", "surname": "Поповић"}, "file": "Q3301248", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Ранко Поповић"} {"composed": false, "fields": {"given": "Стеван", "surname": "Хорват"}, "file": "Q330152", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Стеван Хорват"} {"composed": false, "fields": {"given": "Бошко", "surname": "Јанковић"}, "file": "Q332675", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Бошко Јанковић"} {"composed": false, "fields": {"given": "আকরাম", "surname": "খান"}, "file": "Q3345604", "label": "bn-bd", "order": "givenFirst", "shape": null, "text": "আকরাম খান"} {"composed": false, "fields": {"given": "अंबिका", "surname": "सोनी"}, "file": "Q3347864", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "अंबिका सोनी"} {"composed": false, "fields": {"given": "সুভাষ", "surname": "মুখোপাধ্যায়"}, "file": "Q3347955", "label": "bn-in", "order": "givenFirst", "shape": null, "text": "সুভাষ মুখোপাধ্যায়"} {"composed": false, "fields": {"given": "সেলিনা", "surname": "হোসেন"}, "file": "Q3348154", "label": "bn-bd", "order": "givenFirst", "shape": null, "text": "সেলিনা হোসেন"} {"composed": false, "fields": {"given": "अमर", "surname": "सिंह"}, "file": "Q3348637", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "अमर सिंह"} {"composed": false, "fields": {"given": "Георги", "surname": "Богданов"}, "file": "Q3400984", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Георги Богданов"} {"composed": false, "fields": {"given": "יצחק", "surname": "רבין"}, "file": "Q34060", "label": "he-il", "order": "givenFirst", "shape": null, "text": "יצחק רבין"} {"composed": false, "fields": {"given": "경림", "surname": "신"}, "file": "Q3482149", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "신경림"} {"composed": false, "fields": {"given": "Николай", "surname": "Михайлов"}, "file": "Q350412", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Николай Михайлов"} {"composed": false, "fields": {"given": "ममता", "surname": "कुलकर्णी"}, "file": "Q3519476", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "ममता कुलकर्णी"} {"composed": false, "fields": {"given": "प्रवीण", "surname": "कुमार"}, "file": "Q3519988", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "प्रवीण कुमार"} {"composed": false, "fields": {"given": "रोहित", "surname": "शर्मा"}, "file": "Q3520045", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "रोहित शर्मा"} {"composed": false, "fields": {"given": "लक्ष्मी", "surname": "राय"}, "file": "Q3520472", "label": "hi-in", "order": "surnameFirst", "shape": 4, "text": "राय लक्ष्मी"} {"composed": false, "fields": {"given": "अनुराधा", "surname": "कोइराला"}, "file": "Q3522396", "label": "ne-np", "order": "givenFirst", "shape": null, "text": "अनुराधा कोइराला"} {"composed": false, "fields": {"given": "रमेश", "surname": "सक्सेना"}, "file": "Q3528958", "label": "mr-in", "order": "givenFirst", "shape": null, "text": "रमेश सक्सेना"} {"composed": false, "fields": {"given": "Վահագն", "surname": "Խաչատրյան"}, "file": "Q3553274", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Վահագն Խաչատրյան"} {"composed": false, "fields": {"given": "Σταύρος", "surname": "Κουγιουμτζής"}, "file": "Q3567196", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Σταύρος Κουγιουμτζής"} {"composed": false, "fields": {"given": "عدنان", "surname": "خاشقجي"}, "file": "Q359381", "label": "ar-sa", "order": "givenFirst", "shape": null, "text": "عدنان خاشقجي"} {"composed": false, "fields": {"given": "प्रीति", "surname": "झंगियानी"}, "file": "Q3595236", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "प्रीति झंगियानी"} {"composed": false, "fields": {"given": "জিমি", "surname": "জর্জ"}, "file": "Q3595581", "label": "bn-in", "order": "givenFirst", "shape": null, "text": "জিমি জর্জ"} {"composed": false, "fields": {"given": "دلیپ کور", "surname": "ٹوانہ"}, "file": "Q3595768", "label": "ur-in", "order": "givenFirst", "shape": null, "text": "دلیپ کور ٹوانہ"} {"composed": false, "fields": {"given": "সুজাতা", "surname": "মহাপাত্র"}, "file": "Q3595802", "label": "bn-in", "order": "givenFirst", "shape": null, "text": "সুজাতা মহাপাত্র"} {"composed": false, "fields": {"given": "기현", "surname": "설"}, "file": "Q359789", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "설기현"} {"composed": false, "fields": {"given": "عبد اللطيف", "surname": "عبد الحميد"}, "file": "Q3603413", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "عبد اللطيف عبد الحميد"} {"composed": false, "fields": {"given": "暉", "surname": "謝"}, "file": "Q360402", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "謝暉"} {"composed": false, "fields": {"given": "عزت‌الله", "surname": "انتظامی"}, "file": "Q360665", "label": "fa-ir", "order": "givenFirst", "shape": null, "text": "عزت‌الله انتظامی"} {"composed": false, "fields": {"given": "عرفان", "surname": "خان"}, "file": "Q360927", "label": "ur-in", "order": "givenFirst", "shape": null, "text": "عرفان خان"} {"composed": false, "fields": {"given": "अनिल", "surname": "मेहता"}, "file": "Q3617485", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "अनिल मेहता"} {"composed": false, "fields": {"given": "Анђелија", "surname": "Арбутина"}, "file": "Q3620534", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Анђелија Арбутина"} {"composed": false, "fields": {"given": "लक्ष्मीप्रसाद", "surname": "देवकोटा"}, "file": "Q3629422", "label": "ne-np", "order": "givenFirst", "shape": null, "text": "लक्ष्मीप्रसाद देवकोटा"} {"composed": false, "fields": {"given": "राम", "surname": "कपूर"}, "file": "Q3635459", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "राम कपूर"} {"composed": false, "fields": {"given": "Ιωάννης", "surname": "Παπαϊωάννου"}, "file": "Q3657177", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Ιωάννης Παπαϊωάννου"} {"composed": false, "fields": {"given": "효순", "surname": "신"}, "file": "Q36967357", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "신효순"} {"composed": false, "fields": {"given": "Ռոբերտ", "surname": "Արզումանյան"}, "file": "Q373390", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Ռոբերտ Արզումանյան"} {"composed": false, "fields": {"given": "زكية", "surname": "إبراهيم"}, "file": "Q37530921", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "زكية إبراهيم"} {"composed": false, "fields": {"given": "דוד", "surname": "בן־גוריון"}, "file": "Q37610", "label": "he-il", "order": "givenFirst", "shape": null, "text": "דוד בן־גוריון"} {"composed": false, "fields": {"given": "दुर्गाबाई", "surname": "देशमुख"}, "file": "Q3763917", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "दुर्गाबाई देशमुख"} {"composed": false, "fields": {"given": "علی‌اکبر", "surname": "حیدری"}, "file": "Q377334", "label": "fa-ir", "order": "givenFirst", "shape": null, "text": "علی‌اکبر حیدری"} {"composed": false, "fields": {"given": "龍馬", "surname": "坂本"}, "file": "Q378450", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "坂本 龍馬"} {"composed": false, "fields": {"given": "景綱", "surname": "柴山"}, "file": "Q37878853", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "柴山景綱"} {"composed": false, "fields": {"given": "Јован", "surname": "Шајновић"}, "file": "Q3810649", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Јован Шајновић"} {"composed": false, "fields": {"given": "أكرم", "surname": "عجة"}, "file": "Q381574", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "أكرم عجة"} {"composed": false, "fields": {"given": "Петко", "surname": "Петков"}, "file": "Q382172", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Петко Петков"} {"composed": false, "fields": {"given": "Τάκης", "surname": "Κανελλόπουλος"}, "file": "Q384122", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Τάκης Κανελλόπουλος"} {"composed": false, "fields": {"given": "영수", "surname": "육"}, "file": "Q390099", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "육영수"} {"composed": false, "fields": {"given": "楠", "surname": "华"}, "file": "Q39040747", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "华楠"} {"composed": false, "fields": {"given": "Велко", "surname": "Дачев"}, "file": "Q39357276", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Велко Дачев"} {"composed": false, "fields": {"given": "محمود", "surname": "جركس"}, "file": "Q39524743", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "محمود جركس"} {"composed": false, "fields": {"given": "Страхил", "surname": "Попов"}, "file": "Q3975764", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Страхил Попов"} {"composed": false, "fields": {"given": "婷", "surname": "陳"}, "file": "Q3991527", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "陳婷"} {"composed": false, "fields": {"given": "مصطفى", "surname": "خليل"}, "file": "Q403740", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "مصطفى خليل"} {"composed": false, "fields": {"given": "Միքայել", "surname": "Հարությունյան"}, "file": "Q4070523", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Միքայել Հարությունյան"} {"composed": false, "fields": {"given": "حاتم", "surname": "علي"}, "file": "Q4119326", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "حاتم علي"} {"composed": false, "fields": {"given": "هشام", "surname": "حافظ"}, "file": "Q4119921", "label": "ar-sa", "order": "givenFirst", "shape": null, "text": "هشام حافظ"} {"composed": false, "fields": {"given": "დავით", "surname": "ბააზოვი"}, "file": "Q4128091", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "დავით ბააზოვი"} {"composed": false, "fields": {"given": "Ана", "surname": "Дабовић"}, "file": "Q4153527", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Ана Дабовић"} {"composed": false, "fields": {"given": "سميرة", "surname": "خاشقجي"}, "file": "Q4164708", "label": "ar-sa", "order": "givenFirst", "shape": null, "text": "سميرة خاشقجي"} {"composed": false, "fields": {"given": "هشام", "surname": "سليم"}, "file": "Q4165425", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "هشام سليم"} {"composed": false, "fields": {"given": "Камен", "surname": "Петков"}, "file": "Q41789800", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Камен Петков"} {"composed": false, "fields": {"given": "অনিল", "surname": "দে"}, "file": "Q41790847", "label": "bn-in", "order": "givenFirst", "shape": null, "text": "অনিল দে"} {"composed": false, "fields": {"given": "Հայկ", "surname": "Ղազարյան"}, "file": "Q4208031", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Հայկ Ղազարյան"} {"composed": false, "fields": {"given": "Әбіш", "surname": "Кекілбайұлы"}, "file": "Q4219024", "label": "kk-kz", "order": "givenFirst", "shape": null, "text": "Әбіш Кекілбайұлы"} {"composed": false, "fields": {"given": "Христо", "surname": "Койчев"}, "file": "Q4227223", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Христо Койчев"} {"composed": false, "fields": {"given": "賢斗", "surname": "桃田"}, "file": "Q422959", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "桃田賢斗"} {"composed": false, "fields": {"given": "सुशील", "surname": "कोइराला"}, "file": "Q422969", "label": "ne-np", "order": "givenFirst", "shape": null, "text": "सुशील कोइराला"} {"composed": false, "fields": {"given": "กุลวุฒิ", "surname": "วิทิตศานต์"}, "file": "Q42306393", "label": "th-th", "order": "givenFirst", "shape": null, "text": "กุลวุฒิ วิทิตศานต์"} {"composed": false, "fields": {"given": "Дмитро", "patronymic": "Олександрович", "surname": "Корчинський"}, "file": "Q4234503", "label": "uk-ua", "order": "givenFirst", "shape": null, "text": "Дмитро Олександрович Корчинський"} {"composed": false, "fields": {"given": "Արամ", "surname": "Քոչարյան"}, "file": "Q4236635", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Արամ Քոչարյան"} {"composed": false, "fields": {"given": "Αλέξανδρος", "surname": "Παπαδημητρίου"}, "file": "Q426030", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Αλέξανδρος Παπαδημητρίου"} {"composed": false, "fields": {"given": "Святослав", "patronymic": "Ігорович", "surname": "Луньов"}, "file": "Q4269586", "label": "uk-ua", "order": "surnameFirst", "shape": 4, "text": "Луньов Святослав Ігорович"} {"composed": false, "fields": {"given": "Володимир", "surname": "Максименко"}, "file": "Q4276114", "label": "uk-ua", "order": "givenFirst", "shape": null, "text": "Володимир Максименко"} {"composed": false, "fields": {"given": "Էդուարդ", "surname": "Մնացականյան"}, "file": "Q4298775", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Էդուարդ Մնացականյան"} {"composed": false, "fields": {"given": "Олександр", "surname": "Музичко"}, "file": "Q4306709", "label": "uk-ua", "order": "givenFirst", "shape": null, "text": "Олександр Музичко"} {"composed": false, "fields": {"given": "민호", "surname": "도"}, "file": "Q43088843", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "도민호"} {"composed": false, "fields": {"given": "유빈", "surname": "이"}, "file": "Q43142694", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "이유빈"} {"composed": false, "fields": {"given": "রাণী", "surname": "হামিদ"}, "file": "Q432748", "label": "bn-bd", "order": "givenFirst", "shape": null, "text": "রাণী হামিদ"} {"composed": false, "fields": {"given": "Герман", "surname": "Германов"}, "file": "Q43358517", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Герман Германов"} {"composed": false, "fields": {"given": "جورج", "surname": "ميشيل"}, "file": "Q43380826", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "جورج ميشيل"} {"composed": false, "fields": {"given": "Андрій", "patronymic": "Володимирович", "surname": "Портнов"}, "file": "Q4373277", "label": "uk-ua", "order": "givenFirst", "shape": null, "text": "Андрій Володимирович Портнов"} {"composed": false, "fields": {"given": "ნიკოლოზ", "surname": "სანიშვილი"}, "file": "Q4407618", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "ნიკოლოზ სანიშვილი"} {"composed": false, "fields": {"given": "Արամ", "surname": "Սարգսյան"}, "file": "Q4408661", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Արամ Սարգսյան"} {"composed": false, "fields": {"given": "Григорій", "patronymic": "Кузьмич", "surname": "Середа"}, "file": "Q4417458", "label": "uk-ua", "order": "surnameFirst", "shape": 4, "text": "Середа Григорій Кузьмич"} {"composed": false, "fields": {"given": "ლევან", "surname": "ყენია"}, "file": "Q442746", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "ლევან ყენია"} {"composed": false, "fields": {"given": "Станіслав", "surname": "Станкевич"}, "file": "Q4439452", "label": "uk-ua", "order": "givenFirst", "shape": null, "text": "Станіслав Станкевич"} {"composed": false, "fields": {"given": "Небојша", "surname": "Стефановић"}, "file": "Q4442477", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Небојша Стефановић"} {"composed": false, "fields": {"given": "丹", "surname": "郭"}, "file": "Q445244", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "郭丹"} {"composed": false, "fields": {"given": "منال", "surname": "الشريف"}, "file": "Q445754", "label": "ar-sa", "order": "givenFirst", "shape": null, "text": "منال الشريف"} {"composed": false, "fields": {"given": "ทรัพย์สิรี", "surname": "แต้รัตนชัย"}, "file": "Q448045", "label": "th-th", "order": "givenFirst", "shape": null, "text": "ทรัพย์สิรี แต้รัตนชัย"} {"composed": false, "fields": {"given": "გივი", "surname": "წულაია"}, "file": "Q4506156", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "გივი წულაია"} {"composed": false, "fields": {"given": "ლეო", "surname": "შერვაშიძე"}, "file": "Q4523035", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "ლეო შერვაშიძე"} {"composed": false, "fields": {"given": "Неда", "surname": "Украден"}, "file": "Q453228", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Неда Украден"} {"composed": false, "fields": {"given": "Սերգեյ", "surname": "Մովսեսյան"}, "file": "Q460020", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Սերգեյ Մովսեսյան"} {"composed": false, "fields": {"given": "Հայկ", "surname": "Հակոբյան"}, "file": "Q460071", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Հայկ Հակոբյան"} {"composed": false, "fields": {"given": "相蓉", "surname": "刘"}, "file": "Q460899", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "刘相蓉"} {"composed": false, "fields": {"given": "Δημήτριος", "surname": "Ιωαννίδης"}, "file": "Q460912", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Δημήτριος Ιωαννίδης"} {"composed": false, "fields": {"given": "Владимир", "surname": "Седов"}, "file": "Q461051", "label": "kk-kz", "order": "givenFirst", "shape": null, "text": "Владимир Седов"} {"composed": false, "fields": {"given": "红", "surname": "韩"}, "file": "Q463835", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "韩红"} {"composed": false, "fields": {"given": "মনিকা", "surname": "আলী"}, "file": "Q464960", "label": "bn-bd", "order": "givenFirst", "shape": null, "text": "মনিকা আলী"} {"composed": false, "fields": {"given": "অনুষ্কা", "surname": "শর্মা"}, "file": "Q465815", "label": "bn-in", "order": "givenFirst", "shape": null, "text": "অনুষ্কা শর্মা"} {"composed": false, "fields": {"given": "عباس", "surname": "شفیعی"}, "file": "Q4663791", "label": "fa-ir", "order": "givenFirst", "shape": null, "text": "عباس شفیعی"} {"composed": false, "fields": {"given": "अभिषेक", "surname": "कपूर"}, "file": "Q4667468", "label": "mr-in", "order": "givenFirst", "shape": null, "text": "अभिषेक कपूर"} {"composed": false, "fields": {"given": "اختر", "surname": "رضا خان"}, "file": "Q46675", "label": "ur-in", "order": "givenFirst", "shape": null, "text": "اختر رضا خان"} {"composed": false, "fields": {"given": "Անի", "surname": "Խաչիկյան"}, "file": "Q467299", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Անի Խաչիկյան"} {"composed": false, "fields": {"given": "अदिति", "surname": "शर्मा"}, "file": "Q4683044", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "अदिति शर्मा"} {"composed": false, "fields": {"given": "अजित", "surname": "पवार"}, "file": "Q4699799", "label": "mr-in", "order": "givenFirst", "shape": null, "text": "अजित पवार"} {"composed": false, "fields": {"given": "অজিত", "surname": "রায়"}, "file": "Q4699800", "label": "bn-bd", "order": "givenFirst", "shape": null, "text": "অজিত রায়"} {"composed": false, "fields": {"given": "旺", "surname": "刘"}, "file": "Q470059", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "刘旺"} {"composed": false, "fields": {"given": "旭", "surname": "藤井"}, "file": "Q4701131", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "藤井旭"} {"composed": false, "fields": {"given": "ऐश्वर्या", "surname": "राय"}, "file": "Q47059", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "ऐश्वर्या राय"} {"composed": false, "fields": {"given": "Александар", "surname": "Пантић"}, "file": "Q4714843", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Александар Пантић"} {"composed": false, "fields": {"given": "किशोर", "surname": "कुमार"}, "file": "Q471542", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "किशोर कुमार"} {"composed": false, "fields": {"given": "علیرضا", "surname": "بیرانوند"}, "file": "Q4726869", "label": "fa-ir", "order": "givenFirst", "shape": null, "text": "علیرضا بیرانوند"} {"composed": false, "fields": {"given": "晓桐", "surname": "崔"}, "file": "Q47462831", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "崔晓桐"} {"composed": false, "fields": {"given": "अमित", "surname": "कुमार"}, "file": "Q4746854", "label": "mr-in", "order": "givenFirst", "shape": null, "text": "अमित कुमार"} {"composed": false, "fields": {"given": "अनिल", "surname": "शर्मा"}, "file": "Q4764669", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "अनिल शर्मा"} {"composed": false, "fields": {"given": "अंजलि", "surname": "देवी"}, "file": "Q4765733", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "अंजलि देवी"} {"composed": false, "fields": {"given": "अंजलि", "surname": "शर्मा"}, "file": "Q4765744", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "अंजलि शर्मा"} {"composed": false, "fields": {"given": "अण्णा", "surname": "जोशी"}, "file": "Q4767128", "label": "mr-in", "order": "givenFirst", "shape": null, "text": "अण्णा जोशी"} {"composed": false, "fields": {"given": "انو", "surname": "آغا"}, "file": "Q4777683", "label": "ur-in", "order": "givenFirst", "shape": null, "text": "انو آغا"} {"composed": false, "fields": {"given": "अनुराधा", "surname": "आचार्य"}, "file": "Q4777865", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "अनुराधा आचार्य"} {"composed": false, "fields": {"given": "عارف", "surname": "علوی"}, "file": "Q4790529", "label": "ur-pk", "order": "givenFirst", "shape": null, "text": "عارف علوی"} {"composed": false, "fields": {"given": "婉婷", "surname": "刘"}, "file": "Q4801752", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "刘婉婷"} {"composed": false, "fields": {"given": "अरुण", "surname": "कौल"}, "file": "Q4802101", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "अरुण कौल"} {"composed": false, "fields": {"given": "অরুণ", "surname": "মিত্র"}, "file": "Q4802120", "label": "bn-in", "order": "givenFirst", "shape": null, "text": "অরুণ মিত্র"} {"composed": false, "fields": {"given": "Константин", "surname": "Аврамов"}, "file": "Q48347979", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Константин Аврамов"} {"composed": false, "fields": {"given": "태현", "surname": "차"}, "file": "Q483704", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "차태현"} {"composed": false, "fields": {"given": "მერაბ", "surname": "დვალიშვილი"}, "file": "Q48471377", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "მერაბ დვალიშვილი"} {"composed": false, "fields": {"given": "효희", "surname": "장"}, "file": "Q485293", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "장효희"} {"composed": false, "fields": {"given": "병득", "surname": "조"}, "file": "Q485833", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "조병득"} {"composed": false, "fields": {"given": "시후", "surname": "박"}, "file": "Q486158", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "박시후"} {"composed": false, "fields": {"given": "ברוך", "surname": "מודן"}, "file": "Q4865865", "label": "he-il", "order": "givenFirst", "shape": null, "text": "ברוך מודן"} {"composed": false, "fields": {"given": "효영", "surname": "류"}, "file": "Q486594", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "류효영"} {"composed": false, "fields": {"given": "순철", "surname": "한"}, "file": "Q486987", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "한순철"} {"composed": false, "fields": {"given": "요시히로", "surname": "아키야마"}, "file": "Q487335", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "아키야마 요시히로"} {"composed": false, "fields": {"given": "정은", "surname": "김"}, "file": "Q489001", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "김정은"} {"composed": false, "fields": {"given": "भारत", "surname": "भूषण"}, "file": "Q4901101", "label": "mr-in", "order": "givenFirst", "shape": null, "text": "भारत भूषण"} {"composed": false, "fields": {"given": "Бисерка", "surname": "Цвејић"}, "file": "Q4917296", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Бисерка Цвејић"} {"composed": false, "fields": {"given": "מישאל", "surname": "חשין"}, "file": "Q4920714", "label": "he-il", "order": "givenFirst", "shape": null, "text": "מישאל חשין"} {"composed": false, "fields": {"given": "진영", "surname": "박"}, "file": "Q492313", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "박진영"} {"composed": false, "fields": {"given": "정은", "surname": "임"}, "file": "Q493257", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "임정은"} {"composed": false, "fields": {"given": "대남", "surname": "송"}, "file": "Q493875", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "송대남"} {"composed": false, "fields": {"given": "기영", "surname": "김"}, "file": "Q494258", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "김기영"} {"composed": false, "fields": {"given": "필모", "surname": "이"}, "file": "Q494987", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "이필모"} {"composed": false, "fields": {"given": "소연", "surname": "이"}, "file": "Q496370", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "이소연"} {"composed": false, "fields": {"given": "소은", "surname": "김"}, "file": "Q496433", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "김소은"} {"composed": false, "fields": {"given": "Драган", "surname": "Томић"}, "file": "Q506587", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Драган Томић"} {"composed": false, "fields": {"given": "Նունե", "surname": "Սարգսյան"}, "file": "Q50818130", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Նունե Սարգսյան"} {"composed": false, "fields": {"given": "אריק", "surname": "איינשטיין"}, "file": "Q509660", "label": "he-il", "order": "givenFirst", "shape": null, "text": "אריק איינשטיין"} {"composed": false, "fields": {"given": "세희", "surname": "조"}, "file": "Q5103384", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "조세희"} {"composed": false, "fields": {"given": "บุศนันทน์", "surname": "อึ๊งบำรุงพันธุ์"}, "file": "Q510884", "label": "th-th", "order": "givenFirst", "shape": null, "text": "บุศนันทน์ อึ๊งบำรุงพันธุ์"} {"composed": false, "fields": {"given": "悦", "surname": "孙"}, "file": "Q514117", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "孙悦"} {"composed": false, "fields": {"given": "Євгенія", "surname": "Казбекова"}, "file": "Q51712036", "label": "uk-ua", "order": "givenFirst", "shape": null, "text": "Євгенія Казбекова"} {"composed": false, "fields": {"given": "Дејан", "surname": "Миловановић"}, "file": "Q517302", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Дејан Миловановић"} {"composed": false, "fields": {"given": "حمدان", "surname": "شلبي"}, "file": "Q51843930", "label": "ar-sa", "order": "givenFirst", "shape": null, "text": "حمدان شلبي"} {"composed": false, "fields": {"given": "Նորայր", "surname": "Ասլանյան"}, "file": "Q518501", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Նորայր Ասլանյան"} {"composed": false, "fields": {"given": "Велизар", "surname": "Димитров"}, "file": "Q519280", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Велизар Димитров"} {"composed": false, "fields": {"given": "राजेश", "surname": "हमाल"}, "file": "Q5203004", "label": "ne-np", "order": "givenFirst", "shape": null, "text": "राजेश हमाल"} {"composed": false, "fields": {"given": "พรทิพย์", "surname": "บูรณะประเสริฐสุข"}, "file": "Q524232", "label": "th-th", "order": "givenFirst", "shape": null, "text": "พรทิพย์ บูรณะประเสริฐสุข"} {"composed": false, "fields": {"given": "देवकी", "surname": "पंडित"}, "file": "Q5266447", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "देवकी पंडित"} {"composed": false, "fields": {"given": "দেবী", "surname": "শর্মা"}, "file": "Q5266964", "label": "bn-in", "order": "givenFirst", "shape": null, "text": "দেবী শর্মা"} {"composed": false, "fields": {"given": "Димитрије", "surname": "Нешић"}, "file": "Q5277558", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Димитрије Нешић"} {"composed": false, "fields": {"given": "Ангел", "surname": "Кънчев"}, "file": "Q531739", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Ангел Кънчев"} {"composed": false, "fields": {"given": "أنطونيوس", "surname": "نجيب"}, "file": "Q532882", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "أنطونيوس نجيب"} {"composed": false, "fields": {"given": "خالد", "surname": "عزيز"}, "file": "Q533525", "label": "ar-sa", "order": "givenFirst", "shape": null, "text": "خالد عزيز"} {"composed": false, "fields": {"given": "প্রকাশ", "surname": "মেহরা"}, "file": "Q535404", "label": "bn-in", "order": "givenFirst", "shape": null, "text": "প্রকাশ মেহরা"} {"composed": false, "fields": {"given": "지수", "surname": "김"}, "file": "Q53764925", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "김지수"} {"composed": false, "fields": {"given": "عبد العزيز", "surname": "الربيعي"}, "file": "Q54875231", "label": "ar-sa", "order": "givenFirst", "shape": null, "text": "عبد العزيز الربيعي"} {"composed": false, "fields": {"given": "Валерій", "surname": "Залужний"}, "file": "Q54964679", "label": "uk-ua", "order": "givenFirst", "shape": null, "text": "Валерій Залужний"} {"composed": false, "fields": {"given": "성숙", "surname": "한"}, "file": "Q55082182", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "한성숙"} {"composed": false, "fields": {"given": "Галина", "surname": "Савова"}, "file": "Q5518683", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Галина Савова"} {"composed": false, "fields": {"given": "貞昌", "surname": "蘇"}, "file": "Q551904", "label": "zh-tw", "order": "surnameFirst", "shape": null, "text": "蘇貞昌"} {"composed": false, "fields": {"given": "गणेश", "surname": "आचार्य"}, "file": "Q5520821", "label": "mr-in", "order": "givenFirst", "shape": null, "text": "गणेश आचार्य"} {"composed": false, "fields": {"given": "सौंदर्या", "surname": "शर्मा"}, "file": "Q55231409", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "सौंदर्या शर्मा"} {"composed": false, "fields": {"given": "אהרן", "surname": "הלוי"}, "file": "Q55293464", "label": "he-il", "order": "givenFirst", "shape": null, "text": "אהרן הלוי"} {"composed": false, "fields": {"given": "جمال", "surname": "محمد"}, "file": "Q55389628", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "جمال محمد"} {"composed": false, "fields": {"given": "جورج", "surname": "إسحاق"}, "file": "Q5540966", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "جورج إسحاق"} {"composed": false, "fields": {"given": "محمدتقی", "surname": "شریعتی"}, "file": "Q5555662", "label": "fa-ir", "order": "givenFirst", "shape": null, "text": "محمدتقی شریعتی"} {"composed": false, "fields": {"given": "გიორგი", "surname": "ბაგრატიონი"}, "file": "Q5563405", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "გიორგი ბაგრატიონი"} {"composed": false, "fields": {"given": "მუხრან", "surname": "ვახტანგაძე"}, "file": "Q556656", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "მუხრან ვახტანგაძე"} {"composed": false, "fields": {"given": "지현", "surname": "박"}, "file": "Q55730087", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "박지현"} {"composed": false, "fields": {"given": "도현", "surname": "신"}, "file": "Q55731330", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "신도현"} {"composed": false, "fields": {"given": "남표", "surname": "홍"}, "file": "Q55736063", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "홍남표"} {"composed": false, "fields": {"given": "উদয়", "surname": "চোপড়া"}, "file": "Q560094", "label": "bn-in", "order": "givenFirst", "shape": null, "text": "উদয় চোপড়া"} {"composed": false, "fields": {"given": "अनुपम", "surname": "खेर"}, "file": "Q560163", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "अनुपम खेर"} {"composed": false, "fields": {"given": "شاہین", "surname": "رضا"}, "file": "Q56065399", "label": "ur-pk", "order": "givenFirst", "shape": null, "text": "شاہین رضا"} {"composed": false, "fields": {"given": "만희", "surname": "이"}, "file": "Q562322", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "이만희"} {"composed": false, "fields": {"given": "Νικόλαος", "surname": "Τρικούπης"}, "file": "Q563117", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Νικόλαος Τρικούπης"} {"composed": false, "fields": {"given": "سیروس", "surname": "قهرمانی"}, "file": "Q56364577", "label": "fa-ir", "order": "givenFirst", "shape": null, "text": "سیروس قهرمانی"} {"composed": false, "fields": {"given": "حليم", "surname": "بركات"}, "file": "Q5642325", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "حليم بركات"} {"composed": false, "fields": {"given": "원영", "surname": "장"}, "file": "Q56476186", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "장원영"} {"composed": false, "fields": {"given": "حنیف", "surname": "خان"}, "file": "Q5648196", "label": "ur-pk", "order": "givenFirst", "shape": null, "text": "حنیف خان"} {"composed": false, "fields": {"given": "혜원", "surname": "강"}, "file": "Q56506824", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "강혜원"} {"composed": false, "fields": {"given": "翰", "surname": "张"}, "file": "Q5650855", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "张翰"} {"composed": false, "fields": {"given": "Денис", "surname": "Попов"}, "file": "Q56524763", "label": "uk-ua", "order": "givenFirst", "shape": null, "text": "Денис Попов"} {"composed": false, "fields": {"given": "حسن", "surname": "ناصر"}, "file": "Q5679772", "label": "ur-pk", "order": "givenFirst", "shape": null, "text": "حسن ناصر"} {"composed": false, "fields": {"given": "정은", "surname": "김"}, "file": "Q56816418", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "김정은"} {"composed": false, "fields": {"given": "محمود", "surname": "فروغی"}, "file": "Q5685124", "label": "fa-ir", "order": "givenFirst", "shape": null, "text": "محمود فروغی"} {"composed": false, "fields": {"given": "احمد", "surname": "گلشیری"}, "file": "Q5722569", "label": "fa-ir", "order": "givenFirst", "shape": null, "text": "احمد گلشیری"} {"composed": false, "fields": {"given": "诗沣", "surname": "李"}, "file": "Q57273226", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "李诗沣"} {"composed": false, "fields": {"given": "Αντώνης", "surname": "Σαμαράς"}, "file": "Q57806", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Αντώνης Σαμαράς"} {"composed": false, "fields": {"given": "Αντίοχος", "surname": "Ευαγγελάτος"}, "file": "Q579624", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Αντίοχος Ευαγγελάτος"} {"composed": false, "fields": {"given": "عبد الله", "surname": "الحقيل"}, "file": "Q58117337", "label": "ar-sa", "order": "givenFirst", "shape": null, "text": "عبد الله الحقيل"} {"composed": false, "fields": {"given": "동권", "surname": "정"}, "file": "Q58476446", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "정동권"} {"composed": false, "fields": {"given": "明", "surname": "姚"}, "file": "Q58590", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "姚明"} {"composed": false, "fields": {"given": "حسین", "surname": "حسینی"}, "file": "Q5865783", "label": "fa-ir", "order": "givenFirst", "shape": null, "text": "حسین حسینی"} {"composed": false, "fields": {"given": "萍", "surname": "夏"}, "file": "Q589739", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "夏萍"} {"composed": false, "fields": {"given": "ফারহান", "surname": "আখতার"}, "file": "Q590853", "label": "bn-in", "order": "givenFirst", "shape": null, "text": "ফারহান আখতার"} {"composed": false, "fields": {"given": "جلال", "surname": "همتی"}, "file": "Q5920016", "label": "fa-ir", "order": "givenFirst", "shape": null, "text": "جلال همتی"} {"composed": false, "fields": {"given": "পুরুষোত্তম", "surname": "লাল"}, "file": "Q5932306", "label": "bn-in", "order": "givenFirst", "shape": null, "text": "পুরুষোত্তম লাল"} {"composed": false, "fields": {"given": "طاہر", "surname": "داوڑ"}, "file": "Q59326830", "label": "ur-pk", "order": "givenFirst", "shape": null, "text": "طاہر داوڑ"} {"composed": false, "fields": {"given": "ლევან", "surname": "კაჭარავა"}, "file": "Q59348487", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "ლევან კაჭარავა"} {"composed": false, "fields": {"given": "پردیس", "surname": "افکاری"}, "file": "Q5936609", "label": "fa-ir", "order": "givenFirst", "shape": null, "text": "پردیس افکاری"} {"composed": false, "fields": {"given": "عباس", "surname": "کریمی"}, "file": "Q5955168", "label": "fa-ir", "order": "givenFirst", "shape": null, "text": "عباس کریمی"} {"composed": false, "fields": {"given": "სოსო", "surname": "ლიპარტელიანი"}, "file": "Q5959714", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "სოსო ლიპარტელიანი"} {"composed": false, "fields": {"given": "حسن", "surname": "دانشفر"}, "file": "Q5960213", "label": "fa-ir", "order": "givenFirst", "shape": null, "text": "حسن دانشفر"} {"composed": false, "fields": {"given": "محمدعلی", "surname": "حفیظی"}, "file": "Q5960923", "label": "fa-ir", "order": "givenFirst", "shape": null, "text": "محمدعلی حفیظی"} {"composed": false, "fields": {"given": "निधि", "surname": "सिंह"}, "file": "Q59678195", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "निधि सिंह"} {"composed": false, "fields": {"given": "旭", "surname": "韩"}, "file": "Q59828188", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "韩旭"} {"composed": false, "fields": {"given": "رامي", "surname": "مخلوف"}, "file": "Q599233", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "رامي مخلوف"} {"composed": false, "fields": {"given": "Стоян", "surname": "Мурин"}, "file": "Q60155437", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Стоян Мурин"} {"composed": false, "fields": {"given": "محمد", "surname": "سليمان"}, "file": "Q60578283", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "محمد سليمان"} {"composed": false, "fields": {"given": "Μάνος", "surname": "Σταλάκης"}, "file": "Q60583087", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Μάνος Σταλάκης"} {"composed": false, "fields": {"given": "रवि", "surname": "राय"}, "file": "Q6064863", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "रवि राय"} {"composed": false, "fields": {"given": "상철", "surname": "김"}, "file": "Q60694327", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "김상철"} {"composed": false, "fields": {"given": "Ντόρα", "surname": "Λελούδα"}, "file": "Q60752013", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Ντόρα Λελούδα"} {"composed": false, "fields": {"given": "আইভি", "surname": "রহমান"}, "file": "Q6099929", "label": "bn-bd", "order": "givenFirst", "shape": null, "text": "আইভি রহমান"} {"composed": false, "fields": {"given": "Стефка", "surname": "Георгиева"}, "file": "Q61315430", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Стефка Георгиева"} {"composed": false, "fields": {"given": "克強", "surname": "葉"}, "file": "Q6145892", "label": "zh-tw", "order": "surnameFirst", "shape": null, "text": "葉克強"} {"composed": false, "fields": {"given": "नीरव", "surname": "मोदी"}, "file": "Q61468097", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "नीरव मोदी"} {"composed": false, "fields": {"given": "文彦", "surname": "東郷"}, "file": "Q6147652", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "東郷文彦"} {"composed": false, "fields": {"given": "غسان", "surname": "جبري"}, "file": "Q61740872", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "غسان جبري"} {"composed": false, "fields": {"given": "ხვიჩა", "surname": "კვარაცხელია"}, "file": "Q61780902", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "ხვიჩა კვარაცხელია"} {"composed": false, "fields": {"given": "جیسی", "surname": "پال"}, "file": "Q6187790", "label": "ur-in", "order": "givenFirst", "shape": null, "text": "جیسی پال"} {"composed": false, "fields": {"given": "رنا", "surname": "أحمد"}, "file": "Q61885794", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "رنا أحمد"} {"composed": false, "fields": {"given": "गीता", "surname": "उपाध्याय"}, "file": "Q61929805", "label": "mr-in", "order": "givenFirst", "shape": null, "text": "गीता उपाध्याय"} {"composed": false, "fields": {"given": "Դերենիկ", "surname": "Սաֆարյան"}, "file": "Q61985947", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Դերենիկ Սաֆարյան"} {"composed": false, "fields": {"given": "Θανάσης", "surname": "Παπαδόπουλος"}, "file": "Q61992018", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Θανάσης Παπαδόπουλος"} {"composed": false, "fields": {"given": "کیوان", "surname": "وحدانی"}, "file": "Q62116767", "label": "fa-ir", "order": "givenFirst", "shape": null, "text": "کیوان وحدانی"} {"composed": false, "fields": {"given": "Αγγελική", "surname": "Ελευθερίου"}, "file": "Q62121126", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Αγγελική Ελευθερίου"} {"composed": false, "fields": {"given": "प्राची", "surname": "देसाई"}, "file": "Q62136", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "प्राची देसाई"} {"composed": false, "fields": {"given": "현주", "surname": "손"}, "file": "Q624683", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "손현주"} {"composed": false, "fields": {"given": "상득", "surname": "이"}, "file": "Q625487", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "이상득"} {"composed": false, "fields": {"given": "옥진", "surname": "공"}, "file": "Q626819", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "공옥진"} {"composed": false, "fields": {"given": "جوزف", "surname": "پال"}, "file": "Q6286131", "label": "ur-pk", "order": "givenFirst", "shape": null, "text": "جوزف پال"} {"composed": false, "fields": {"given": "Леонід", "patronymic": "Данилович", "surname": "Кучма"}, "file": "Q629702", "label": "uk-ua", "order": "surnameFirst", "shape": 4, "text": "Кучма Леонід Данилович"} {"composed": false, "fields": {"given": "ირაკლი", "surname": "მწითური"}, "file": "Q63089630", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "ირაკლი მწითური"} {"composed": false, "fields": {"given": "Գառնիկ", "surname": "Հովհաննիսյան"}, "file": "Q63213360", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Գառնիկ Հովհաննիսյան"} {"composed": false, "fields": {"given": "Ольга", "surname": "Рыпакова"}, "file": "Q6338", "label": "kk-kz", "order": "givenFirst", "shape": null, "text": "Ольга Рыпакова"} {"composed": false, "fields": {"given": "Христо", "surname": "Бончев"}, "file": "Q63448778", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Христо Бончев"} {"composed": false, "fields": {"given": "کمال", "surname": "ایرانی"}, "file": "Q6355444", "label": "ur-pk", "order": "givenFirst", "shape": null, "text": "کمال ایرانی"} {"composed": false, "fields": {"given": "지성", "surname": "한"}, "file": "Q63997686", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "한지성"} {"composed": false, "fields": {"given": "한민", "surname": "김"}, "file": "Q6408704", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "김한민"} {"composed": false, "fields": {"given": "요한", "surname": "고"}, "file": "Q6424182", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "고요한"} {"composed": false, "fields": {"given": "রবিন", "surname": "ঘোষ"}, "file": "Q6435638", "label": "bn-bd", "order": "givenFirst", "shape": null, "text": "রবিন ঘোষ"} {"composed": false, "fields": {"given": "محمد", "surname": "البغدادي"}, "file": "Q64744436", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "محمد البغدادي"} {"composed": false, "fields": {"given": "תומר", "surname": "שחורי"}, "file": "Q65031190", "label": "he-il", "order": "givenFirst", "shape": null, "text": "תומר שחורי"} {"composed": false, "fields": {"given": "Андрій", "patronymic": "Володимирович", "surname": "Бондар"}, "file": "Q651264", "label": "uk-ua", "order": "givenFirst", "shape": null, "text": "Андрій Володимирович Бондар"} {"composed": false, "fields": {"given": "보나", "surname": "이"}, "file": "Q6513102", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "이보나"} {"composed": false, "fields": {"given": "თეიმურაზ", "surname": "გორდელაძე"}, "file": "Q65284773", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "თეიმურაზ გორდელაძე"} {"composed": false, "fields": {"given": "آمال", "surname": "فهمي"}, "file": "Q6536650", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "آمال فهمي"} {"composed": false, "fields": {"given": "博", "surname": "李"}, "file": "Q6538654", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "李博"} {"composed": false, "fields": {"given": "翔", "surname": "李"}, "file": "Q6539157", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "李翔"} {"composed": false, "fields": {"given": "محمدعلی", "surname": "ییلاقی اشرفی"}, "file": "Q65548699", "label": "fa-ir", "order": "givenFirst", "shape": null, "text": "محمدعلی ییلاقی اشرفی"} {"composed": false, "fields": {"given": "זאב", "surname": "מרגלית"}, "file": "Q6563391", "label": "he-il", "order": "givenFirst", "shape": null, "text": "זאב מרגלית"} {"composed": false, "fields": {"given": "מאיר", "surname": "ישראל"}, "file": "Q6572546", "label": "he-il", "order": "givenFirst", "shape": null, "text": "מאיר ישראל"} {"composed": false, "fields": {"given": "אבנר", "surname": "ברזני"}, "file": "Q6597500", "label": "he-il", "order": "givenFirst", "shape": null, "text": "אבנר ברזני"} {"composed": false, "fields": {"given": "גיא", "surname": "וסרדם"}, "file": "Q6643335", "label": "he-il", "order": "givenFirst", "shape": null, "text": "גיא וסרדם"} {"composed": false, "fields": {"given": "Сергей", "surname": "Каримов"}, "file": "Q664728", "label": "kk-kz", "order": "givenFirst", "shape": null, "text": "Сергей Каримов"} {"composed": false, "fields": {"given": "לולה", "surname": "פולמן"}, "file": "Q6668818", "label": "he-il", "order": "givenFirst", "shape": null, "text": "לולה פולמן"} {"composed": false, "fields": {"given": "רחל", "surname": "שור"}, "file": "Q6702967", "label": "he-il", "order": "givenFirst", "shape": null, "text": "רחל שור"} {"composed": false, "fields": {"given": "誠", "surname": "北野"}, "file": "Q6739942", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "北野誠"} {"composed": false, "fields": {"given": "مانی", "surname": "شرما"}, "file": "Q6749421", "label": "ur-in", "order": "givenFirst", "shape": null, "text": "مانی شرما"} {"composed": false, "fields": {"given": "মনোজ", "surname": "মিত্র"}, "file": "Q6751180", "label": "bn-in", "order": "givenFirst", "shape": null, "text": "মনোজ মিত্র"} {"composed": false, "fields": {"given": "אסתר", "surname": "אלכסנדר"}, "file": "Q6776510", "label": "he-il", "order": "givenFirst", "shape": null, "text": "אסתר אלכסנדר"} {"composed": false, "fields": {"given": "मेरी", "surname": "रॉय"}, "file": "Q6780651", "label": "mr-in", "order": "givenFirst", "shape": null, "text": "मेरी रॉय"} {"composed": false, "fields": {"given": "ברוך", "surname": "אורן"}, "file": "Q6808832", "label": "he-il", "order": "givenFirst", "shape": null, "text": "ברוך אורן"} {"composed": false, "fields": {"given": "아정", "surname": "윤"}, "file": "Q6814532", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "윤아정"} {"composed": false, "fields": {"given": "فتحي", "surname": "قورة"}, "file": "Q6823735", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "فتحي قورة"} {"composed": false, "fields": {"given": "أمجد", "surname": "الطرابلسي"}, "file": "Q6827466", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "أمجد الطرابلسي"} {"composed": false, "fields": {"given": "মিমি", "surname": "চক্রবর্তী"}, "file": "Q6862116", "label": "bn-in", "order": "givenFirst", "shape": null, "text": "মিমি চক্রবর্তী"} {"composed": false, "fields": {"given": "გია", "surname": "გულუა"}, "file": "Q68814638", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "გია გულუა"} {"composed": false, "fields": {"given": "محمد", "surname": "دهمان"}, "file": "Q6893052", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "محمد دهمان"} {"composed": false, "fields": {"given": "मोहित", "surname": "रैना"}, "file": "Q6894209", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "मोहित रैना"} {"composed": false, "fields": {"given": "نادیہ", "surname": "خان"}, "file": "Q6957704", "label": "ur-pk", "order": "givenFirst", "shape": null, "text": "نادیہ خان"} {"composed": false, "fields": {"given": "ناہید", "surname": "اختر"}, "file": "Q6959236", "label": "ur-pk", "order": "givenFirst", "shape": null, "text": "ناہید اختر"} {"composed": false, "fields": {"given": "नम्रता", "surname": "श्रेष्ठ"}, "file": "Q6962061", "label": "ne-np", "order": "givenFirst", "shape": null, "text": "नम्रता श्रेष्ठ"} {"composed": false, "fields": {"given": "אברהם", "surname": "מור"}, "file": "Q6976086", "label": "he-il", "order": "givenFirst", "shape": null, "text": "אברהם מור"} {"composed": false, "fields": {"given": "ידידיה", "surname": "כהן"}, "file": "Q6986039", "label": "he-il", "order": "givenFirst", "shape": null, "text": "ידידיה כהן"} {"composed": false, "fields": {"given": "לייב", "surname": "וייספיש"}, "file": "Q6993554", "label": "he-il", "order": "givenFirst", "shape": null, "text": "לייב וייספיש"} {"composed": false, "fields": {"given": "מנשה", "surname": "רז"}, "file": "Q6997275", "label": "he-il", "order": "givenFirst", "shape": null, "text": "מנשה רז"} {"composed": false, "fields": {"given": "韶涵", "surname": "張"}, "file": "Q702557", "label": "zh-tw", "order": "surnameFirst", "shape": null, "text": "張韶涵"} {"composed": false, "fields": {"given": "निधि", "surname": "राज़दान"}, "file": "Q7030844", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "निधि राज़दान"} {"composed": false, "fields": {"given": "Νικόλαος", "surname": "Κοντόπουλος"}, "file": "Q7036059", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Νικόλαος Κοντόπουλος"} {"composed": false, "fields": {"given": "Νίκος", "surname": "Γούναρης"}, "file": "Q7036608", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Νίκος Γούναρης"} {"composed": false, "fields": {"given": "镇", "surname": "王"}, "file": "Q703861", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "王镇"} {"composed": false, "fields": {"given": "निशा", "surname": "अधिकारी"}, "file": "Q7040240", "label": "ne-np", "order": "givenFirst", "shape": null, "text": "निशा अधिकारी"} {"composed": false, "fields": {"given": "Ռոմեն", "surname": "Դավթյան"}, "file": "Q70429926", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Ռոմեն Դավթյան"} {"composed": false, "fields": {"given": "חיים", "surname": "מולכו"}, "file": "Q7043178", "label": "he-il", "order": "givenFirst", "shape": null, "text": "חיים מולכו"} {"composed": false, "fields": {"given": "良一", "surname": "引地"}, "file": "Q70544716", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "引地良一"} {"composed": false, "fields": {"given": "רון", "surname": "פלר"}, "file": "Q7065151", "label": "he-il", "order": "givenFirst", "shape": null, "text": "רון פלר"} {"composed": false, "fields": {"given": "정숙", "surname": "오"}, "file": "Q7080586", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "오정숙"} {"composed": false, "fields": {"given": "Արթուր", "surname": "Բաղդասարյան"}, "file": "Q708540", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Արթուր Բաղդասարյան"} {"composed": false, "fields": {"given": "পঙ্কজ", "surname": "রায়"}, "file": "Q7131294", "label": "bn-in", "order": "givenFirst", "shape": null, "text": "পঙ্কজ রায়"} {"composed": false, "fields": {"given": "민지", "surname": "박"}, "file": "Q7137905", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "박민지"} {"composed": false, "fields": {"given": "איתי", "surname": "שכטר"}, "file": "Q715963", "label": "he-il", "order": "givenFirst", "shape": null, "text": "איתי שכטר"} {"composed": false, "fields": {"given": "靜", "surname": "郭"}, "file": "Q716055", "label": "zh-tw", "order": "surnameFirst", "shape": null, "text": "郭靜"} {"composed": false, "fields": {"given": "Вук", "surname": "Јеремић"}, "file": "Q722579", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Вук Јеремић"} {"composed": false, "fields": {"given": "Иван", "surname": "Радовановић"}, "file": "Q722916", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Иван Радовановић"} {"composed": false, "fields": {"given": "प्राची", "surname": "शाह"}, "file": "Q7237298", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "प्राची शाह"} {"composed": false, "fields": {"given": "प्रकाश", "surname": "जावडेकर"}, "file": "Q7238178", "label": "mr-in", "order": "givenFirst", "shape": null, "text": "प्रकाश जावडेकर"} {"composed": false, "fields": {"given": "प्रतिभा", "surname": "सिंह"}, "file": "Q7238683", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "प्रतिभा सिंह"} {"composed": false, "fields": {"given": "प्रीत", "surname": "भरारा"}, "file": "Q7239755", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "प्रीत भरारा"} {"composed": false, "fields": {"given": "प्रिया", "surname": "बापट"}, "file": "Q7246459", "label": "mr-in", "order": "givenFirst", "shape": null, "text": "प्रिया बापट"} {"composed": false, "fields": {"given": "Νίκος", "surname": "Κούρκουλος"}, "file": "Q724724", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Νίκος Κούρκουλος"} {"composed": false, "fields": {"given": "ლევან", "surname": "გაჩეჩილაძე"}, "file": "Q728094", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "ლევან გაჩეჩილაძე"} {"composed": false, "fields": {"given": "राहुल", "surname": "रॉय"}, "file": "Q7283617", "label": "mr-in", "order": "givenFirst", "shape": null, "text": "राहुल रॉय"} {"composed": false, "fields": {"given": "रजत", "surname": "कपूर"}, "file": "Q7285873", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "रजत कपूर"} {"composed": false, "fields": {"given": "राजेश", "surname": "शर्मा"}, "file": "Q7286088", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "राजेश शर्मा"} {"composed": false, "fields": {"given": "राम", "surname": "सुतार"}, "file": "Q7288610", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "राम सुतार"} {"composed": false, "fields": {"given": "রেণুকা", "surname": "দাশগুপ্ত"}, "file": "Q7313582", "label": "bn-in", "order": "givenFirst", "shape": null, "text": "রেণুকা দাশগুপ্ত"} {"composed": false, "fields": {"given": "يوسف", "surname": "إدريس"}, "file": "Q733393", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "يوسف إدريس"} {"composed": false, "fields": {"given": "حسن", "surname": "شحاتة"}, "file": "Q734006", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "حسن شحاتة"} {"composed": false, "fields": {"given": "Ռուբեն", "surname": "Վարդանյան"}, "file": "Q7376010", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Ռուբեն Վարդանյան"} {"composed": false, "fields": {"given": "Դավիթ", "surname": "Համբարձումյան"}, "file": "Q737852", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Դավիթ Համբարձումյան"} {"composed": false, "fields": {"given": "रितेश", "surname": "देशमुख"}, "file": "Q738291", "label": "mr-in", "order": "givenFirst", "shape": null, "text": "रितेश देशमुख"} {"composed": false, "fields": {"given": "三郎", "surname": "亀井"}, "file": "Q7396492", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "亀井三郎"} {"composed": false, "fields": {"given": "سعدیہ", "surname": "دہلوی"}, "file": "Q7397899", "label": "ur-in", "order": "givenFirst", "shape": null, "text": "سعدیہ دہلوی"} {"composed": false, "fields": {"given": "سعدیہ", "surname": "خان"}, "file": "Q7397901", "label": "ur-pk", "order": "givenFirst", "shape": null, "text": "سعدیہ خان"} {"composed": false, "fields": {"given": "صالح خالد", "surname": "الشهري"}, "file": "Q7403894", "label": "ar-sa", "order": "givenFirst", "shape": null, "text": "صالح خالد الشهري"} {"composed": false, "fields": {"given": "संजय", "surname": "ख़ान"}, "file": "Q7418443", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "संजय ख़ान"} {"composed": false, "fields": {"given": "ستیش", "surname": "شرما"}, "file": "Q7426302", "label": "ur-in", "order": "givenFirst", "shape": null, "text": "ستیش شرما"} {"composed": false, "fields": {"given": "शरद", "surname": "कपूर"}, "file": "Q7489358", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "शरद कपूर"} {"composed": false, "fields": {"given": "俊傑", "surname": "陳"}, "file": "Q75078779", "label": "zh-tw", "order": "surnameFirst", "shape": null, "text": "陳俊傑"} {"composed": false, "fields": {"given": "Σταύρος", "surname": "Τσιώλης"}, "file": "Q7604867", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Σταύρος Τσιώλης"} {"composed": false, "fields": {"given": "Страшимир", "surname": "Димитров"}, "file": "Q7621704", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Страшимир Димитров"} {"composed": false, "fields": {"given": "সুবোধ", "surname": "সরকার"}, "file": "Q7631762", "label": "bn-in", "order": "givenFirst", "shape": null, "text": "সুবোধ সরকার"} {"composed": false, "fields": {"given": "سلطان", "surname": "البرقان"}, "file": "Q7636629", "label": "ar-sa", "order": "givenFirst", "shape": null, "text": "سلطان البرقان"} {"composed": false, "fields": {"given": "सुनंदा", "surname": "पुष्कर"}, "file": "Q7638829", "label": "mr-in", "order": "givenFirst", "shape": null, "text": "सुनंदा पुष्कर"} {"composed": false, "fields": {"given": "Սուրեն", "surname": "Նազարյան"}, "file": "Q7645634", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Սուրեն Նազարյան"} {"composed": false, "fields": {"given": "Βασίλειος", "surname": "Ξυδάς"}, "file": "Q765694", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Βασίλειος Ξυδάς"} {"composed": false, "fields": {"given": "ريما", "surname": "الجفالي"}, "file": "Q76823843", "label": "ar-sa", "order": "givenFirst", "shape": null, "text": "ريما الجفالي"} {"composed": false, "fields": {"given": "طارق", "surname": "فتح"}, "file": "Q7685765", "label": "ur-pk", "order": "givenFirst", "shape": null, "text": "طارق فتح"} {"composed": false, "fields": {"given": "Бобан", "surname": "Марковић"}, "file": "Q772645", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Бобан Марковић"} {"composed": false, "fields": {"given": "Александр", "surname": "Кучма"}, "file": "Q77450", "label": "kk-kz", "order": "givenFirst", "shape": null, "text": "Александр Кучма"} {"composed": false, "fields": {"given": "بهيج", "surname": "الخطيب"}, "file": "Q780862", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "بهيج الخطيب"} {"composed": false, "fields": {"given": "Георги", "surname": "Георгиев"}, "file": "Q782926", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Георги Георгиев"} {"composed": false, "fields": {"given": "वसंत", "surname": "बापट"}, "file": "Q7916323", "label": "mr-in", "order": "givenFirst", "shape": null, "text": "वसंत बापट"} {"composed": false, "fields": {"given": "वसंत", "surname": "देसाई"}, "file": "Q7916328", "label": "mr-in", "order": "givenFirst", "shape": null, "text": "वसंत देसाई"} {"composed": false, "fields": {"given": "强", "surname": "文"}, "file": "Q7927961", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "文强"} {"composed": false, "fields": {"given": "إيهاب", "surname": "خورشيد"}, "file": "Q79351570", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "إيهاب خورشيد"} {"composed": false, "fields": {"given": "文正", "surname": "白"}, "file": "Q7976520", "label": "zh-tw", "order": "surnameFirst", "shape": null, "text": "白文正"} {"composed": false, "fields": {"given": "بهمن", "surname": "جلالی"}, "file": "Q800254", "label": "fa-ir", "order": "givenFirst", "shape": null, "text": "بهمن جلالی"} {"composed": false, "fields": {"given": "यशपाल", "surname": "शर्मा"}, "file": "Q8049765", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "यशपाल शर्मा"} {"composed": false, "fields": {"given": "Երվանդ", "surname": "Սուքիասյան"}, "file": "Q8052695", "label": "hy-am", "order": "givenFirst", "shape": null, "text": "Երվանդ Սուքիասյան"} {"composed": false, "fields": {"given": "ישעיהו", "surname": "פרס"}, "file": "Q8052802", "label": "he-il", "order": "givenFirst", "shape": null, "text": "ישעיהו פרס"} {"composed": false, "fields": {"given": "يوسف", "surname": "السالم"}, "file": "Q8059032", "label": "ar-sa", "order": "givenFirst", "shape": null, "text": "يوسف السالم"} {"composed": false, "fields": {"given": "ზაალ", "surname": "სამადაშვილი"}, "file": "Q8063398", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "ზაალ სამადაშვილი"} {"composed": false, "fields": {"given": "爽", "surname": "郑"}, "file": "Q8070736", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "郑爽"} {"composed": false, "fields": {"given": "旭", "surname": "朱"}, "file": "Q8071157", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "朱旭"} {"composed": false, "fields": {"given": "צבי", "surname": "ארד"}, "file": "Q8075532", "label": "he-il", "order": "givenFirst", "shape": null, "text": "צבי ארד"} {"composed": false, "fields": {"given": "אלישע", "surname": "פורת"}, "file": "Q8195632", "label": "he-il", "order": "givenFirst", "shape": null, "text": "אלישע פורת"} {"composed": false, "fields": {"given": "孟緝", "surname": "彭"}, "file": "Q8275740", "label": "zh-tw", "order": "surnameFirst", "shape": null, "text": "彭孟緝"} {"composed": false, "fields": {"given": "逸凡", "surname": "邓"}, "file": "Q8318748", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "邓逸凡"} {"composed": false, "fields": {"given": "洋", "surname": "汪"}, "file": "Q8321539", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "汪洋"} {"composed": false, "fields": {"given": "瑞", "surname": "張"}, "file": "Q8325220", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "張瑞"} {"composed": false, "fields": {"given": "忠左衛門", "surname": "端山"}, "file": "Q84463020", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "端山 忠左衛門"} {"composed": false, "fields": {"given": "عالي", "surname": "القرشي"}, "file": "Q85202166", "label": "ar-sa", "order": "givenFirst", "shape": null, "text": "عالي القرشي"} {"composed": false, "fields": {"given": "דן", "surname": "מורי"}, "file": "Q862940", "label": "he-il", "order": "givenFirst", "shape": null, "text": "דן מורי"} {"composed": false, "fields": {"given": "নেহা", "surname": "শর্মা"}, "file": "Q863745", "label": "bn-in", "order": "givenFirst", "shape": null, "text": "নেহা শর্মা"} {"composed": false, "fields": {"given": "बीना", "surname": "देवी"}, "file": "Q87630227", "label": "hi-in", "order": "givenFirst", "shape": null, "text": "बीना देवी"} {"composed": false, "fields": {"given": "Παντελής", "surname": "Πετρακάκης"}, "file": "Q89191445", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Παντελής Πετρακάκης"} {"composed": false, "fields": {"given": "บุญศักดิ์", "surname": "พลสนะ"}, "file": "Q893081", "label": "th-th", "order": "givenFirst", "shape": null, "text": "บุญศักดิ์ พลสนะ"} {"composed": false, "fields": {"given": "Борис", "surname": "Галчев"}, "file": "Q893657", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Борис Галчев"} {"composed": false, "fields": {"given": "定國", "surname": "馮"}, "file": "Q8966024", "label": "zh-tw", "order": "surnameFirst", "shape": null, "text": "馮定國"} {"composed": false, "fields": {"given": "炅", "surname": "何"}, "file": "Q8969884", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "何炅"} {"composed": false, "fields": {"given": "正中", "surname": "李"}, "file": "Q8995497", "label": "zh-tw", "order": "surnameFirst", "shape": null, "text": "李正中"} {"composed": false, "fields": {"given": "鑫", "surname": "王"}, "file": "Q9066967", "label": "zh-tw", "order": "surnameFirst", "shape": null, "text": "王鑫"} {"composed": false, "fields": {"given": "عارف", "surname": "وزیر"}, "file": "Q90751659", "label": "ur-pk", "order": "givenFirst", "shape": null, "text": "عارف وزیر"} {"composed": false, "fields": {"given": "國華", "surname": "林"}, "file": "Q9183343", "label": "zh-tw", "order": "surnameFirst", "shape": null, "text": "林國華"} {"composed": false, "fields": {"given": "Миодраг", "surname": "Павловић"}, "file": "Q929359", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Миодраг Павловић"} {"composed": false, "fields": {"given": "国强", "surname": "蔡"}, "file": "Q930462", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "蔡国强"} {"composed": false, "fields": {"given": "英", "surname": "胡"}, "file": "Q9329762", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "胡英"} {"composed": false, "fields": {"given": "عثمان", "surname": "آل حميد"}, "file": "Q93766497", "label": "ar-sa", "order": "givenFirst", "shape": null, "text": "عثمان آل حميد"} {"composed": false, "fields": {"given": "Βασίλης", "surname": "Χατζηπαναγής"}, "file": "Q943558", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Βασίλης Χατζηπαναγής"} {"composed": false, "fields": {"given": "שלמה", "surname": "תמיר"}, "file": "Q94536900", "label": "he-il", "order": "givenFirst", "shape": null, "text": "שלמה תמיר"} {"composed": false, "fields": {"given": "غلام اللہ", "surname": "خان"}, "file": "Q94657967", "label": "ur-pk", "order": "givenFirst", "shape": null, "text": "غلام اللہ خان"} {"composed": false, "fields": {"given": "潤", "surname": "草刈"}, "file": "Q95163244", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "草刈潤"} {"composed": false, "fields": {"given": "عبد الأكرم", "surname": "السقَّا"}, "file": "Q95373668", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "عبد الأكرم السقَّا"} {"composed": false, "fields": {"given": "გიგა", "surname": "ჭიკაძე"}, "file": "Q95453345", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "გიგა ჭიკაძე"} {"composed": false, "fields": {"given": "智勇", "surname": "石"}, "file": "Q954851", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "石智勇"} {"composed": false, "fields": {"given": "מיכאל", "surname": "אלמוג"}, "file": "Q95994375", "label": "he-il", "order": "givenFirst", "shape": null, "text": "מיכאל אלמוג"} {"composed": false, "fields": {"given": "مُحمَّد علي", "surname": "محجوب"}, "file": "Q96099631", "label": "ar-eg", "order": "givenFirst", "shape": null, "text": "مُحمَّد علي محجوب"} {"composed": false, "fields": {"given": "Γιάννης", "surname": "Γκούμας"}, "file": "Q96146719", "label": "el-gr", "order": "givenFirst", "shape": null, "text": "Γιάννης Γκούμας"} {"composed": false, "fields": {"given": "성훈", "surname": "김"}, "file": "Q96742497", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "김성훈"} {"composed": false, "fields": {"given": "लक्ष्मी", "surname": "गौतम"}, "file": "Q96773906", "label": "mr-in", "order": "givenFirst", "shape": null, "text": "लक्ष्मी गौतम"} {"composed": false, "fields": {"given": "عبد الجبار", "surname": "الضحاك"}, "file": "Q96788805", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "عبد الجبار الضحاك"} {"composed": false, "fields": {"given": "秀樹", "surname": "前田"}, "file": "Q970696", "label": "ja-jp", "order": "surnameFirst", "shape": null, "text": "前田秀樹"} {"composed": false, "fields": {"given": "종덕", "surname": "최"}, "file": "Q97198853", "label": "ko-kr", "order": "surnameFirst", "shape": null, "text": "최종덕"} {"composed": false, "fields": {"given": "文清", "surname": "谢"}, "file": "Q97202579", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "谢文清"} {"composed": false, "fields": {"given": "عرفان", "surname": "صدیقی"}, "file": "Q97228769", "label": "ur-pk", "order": "givenFirst", "shape": null, "text": "عرفان صدیقی"} {"composed": false, "fields": {"given": "პაატა", "surname": "კოღუაშვილი"}, "file": "Q97231535", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "პაატა კოღუაშვილი"} {"composed": false, "fields": {"given": "დავით", "surname": "გოცირიძე"}, "file": "Q97244061", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "დავით გოცირიძე"} {"composed": false, "fields": {"given": "由之", "surname": "唐"}, "file": "Q97300636", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "唐由之"} {"composed": false, "fields": {"given": "სერგო", "surname": "რიგვავა"}, "file": "Q97359916", "label": "ka-ge", "order": "givenFirst", "shape": null, "text": "სერგო რიგვავა"} {"composed": false, "fields": {"given": "俊賢", "surname": "李"}, "file": "Q97588844", "label": "zh-tw", "order": "surnameFirst", "shape": null, "text": "李俊賢"} {"composed": false, "fields": {"given": "Стоян", "surname": "Александров"}, "file": "Q977718", "label": "bg-bg", "order": "givenFirst", "shape": null, "text": "Стоян Александров"} {"composed": false, "fields": {"given": "Дәулет", "patronymic": "Болатұлы", "surname": "Тұрлыханов"}, "file": "Q981187", "label": "kk-kz", "order": "givenFirst", "shape": null, "text": "Дәулет Болатұлы Тұрлыханов"} {"composed": false, "fields": {"given": "शरद", "surname": "पवार"}, "file": "Q982813", "label": "mr-in", "order": "givenFirst", "shape": null, "text": "शरद पवार"} {"composed": false, "fields": {"given": "משה", "surname": "מזור"}, "file": "Q98320452", "label": "he-il", "order": "givenFirst", "shape": null, "text": "משה מזור"} {"composed": false, "fields": {"given": "يوسف", "surname": "مكي"}, "file": "Q98362001", "label": "ar-sa", "order": "givenFirst", "shape": null, "text": "يوسف مكي"} {"composed": false, "fields": {"given": "Драгомир", "surname": "Милошевић"}, "file": "Q984195", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Драгомир Милошевић"} {"composed": false, "fields": {"given": "Коста", "surname": "Алексић"}, "file": "Q99315136", "label": "sr-rs", "order": "givenFirst", "shape": null, "text": "Коста Алексић"} {"composed": false, "fields": {"given": "نور الدين", "surname": "عتر"}, "file": "Q99540872", "label": "ar-sy", "order": "givenFirst", "shape": null, "text": "نور الدين عتر"} {"composed": false, "fields": {"given": "浩", "surname": "黄"}, "file": "Q99630410", "label": "zh-cn", "order": "surnameFirst", "shape": null, "text": "黄浩"} ����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/name_usage.py�����������������������������������������������0000664�0000000�0000000�00000276626�15273032717�0022550�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""How often is a word borne as a given name, and as a family name -- and how often is it written as a credential? WHY THIS IS IN THE TREE. Vocabulary claims are weighed by decisions/vocabulary-collisions: a word the parser claims by position (titles, never-given particles) is safe to claim outright only if it is not borne as a name IN THE POSITION THE CLAIM ACTS ON (C-i; decisions/salutation-titles records the one TITLES-only exception, decided on counts). That check was often run from memory, which misses what the data shows at once: `shehu`, a title until #660, is also an Albanian and Nigerian surname, with 561 bearers in the 2010 US Census. This answers the question with counts instead. uv run python tools/name_usage.py shehu greve marshal uv run python tools/name_usage.py McDonald # pass names as written uv run python tools/name_usage.py --no-wikidata knight # offline uv run python tools/name_usage.py --json justice # machine-readable uv run python tools/name_usage.py 殿 議員 二世 # CJK: ends-with too uv run python tools/name_usage.py In Bay Bin # non-US given names uv run python tools/name_usage.py --fetch-nppes ma do # credentials too uv run python tools/name_usage.py --rank 50 --unlisted # top credentials uv run python tools/name_usage.py --boxes del --country PH # by box, country A suffix that collides with a name is the same question from the other side: `ma`, `do` and `ba` are credentials and surnames both, and decisions/suffix-acronym-collisions weighs which reading to keep. The credential column answers its half, and --rank lists the commonest credentials beside nameparser's vocabulary, their name counts and how a comma name ending in each one parses (#797 came from that list). It reports; it does not decide. Read the counts against the rule's position qualifier: given-name bearers threaten a title or a leading particle in every culture, while family-name bearers threaten one only where the family name is written first -- which is what Wikidata's per-country breakdown is for. What a number means depends on its source, and the report says so: - US Census 2010 surnames (public domain): surnames with 100 or more bearers, with rank and ethnic shares. A surname under 100 is not listed, so it reports "<100", never 0. Downloaded once into the cache. - US Social Security given names, 1880 onward (CC0 via data.gov), summed by sex across all years. A name-sex pair is listed for a year only with 5 or more births, so a count is a floor and an unlisted name reports "<5/yr". ssa.gov refuses scripted downloads, so this file is fetched by hand -- the tool says where to put it. - Wikidata (CC0), queried live: people whose given name (P735) or family name (P734) is an item labelled with the word, with their countries of citizenship. A LOWER BOUND: many people have no P735 statement at all (Shehu Shagari has none), so a small count proves little and a zero proves nothing. Labels are matched exactly, in the spellings the report lists, so a name with internal capitals is found only when passed as written (`McDonald`, not `mcdonald`). - Wikidata, name ends with (CC0), only for a word holding at least one Han, kana or hangul letter: people bearing a name item whose CJK label ENDS in the word, which is what a glued honorific's peel risks (#707). The matching runs against a local index of every CJK rdfs:label (not aliases) on the name-item classes, fetched once (resumable; 33 minutes on 2026-10-17) and refreshed with --refresh-index; the bearers are counted live. It also counts the names the word ends ACROSS the family/given join, family ending 部 + given 長 for 部長. A lower bound, like the row above, and blind to people whose name has no item at all. - nam_dict given names (Jörg Michael, 2008; GNU FDL 1.2), the dictionary shipped in the gender-guesser 0.4.0 sdist: 45,371 given-name spellings (counted 2026-10-19; the file is pinned), each with a frequency band in 55 columns, 54 countries and "other countries" in the order its own legend gives, 1 = rare up to D = extremely common. The scale is logarithmic, a factor of two a step only where a country's statistics are good. It is the non-US given-name evidence the other sources lack (Korean `In` is band A in Korea, Vietnamese `Bay` band 6 in Vietnam). A band is not a count, so the row's given cell shows the peak band and the bands go in `given_bands`. A SELECTION, not a census, and written in Latin letters only: a missing name proves nothing (`Shehu` has no entry), and a non-Latin word is unrepresentable. Matched ignoring case, in the Turkish fold too (`Ilker` finds `İlker`, `ışıl` finds `Işıl`), with a `+` in a listed name (`Jun+Wei`; the header says Arabic, Chinese and Korean names only) standing for a hyphen, a space or nothing. Other letters are matched as written: `Käte` and `Işıl` are listed, `Kaete` and `Isil` are not. Downloaded once into the cache, pinned by the sdist's sha256; only that one file is kept, and never in the tree. - NPPES credentials (CMS, public; NBER's monthly mirror, since CMS keeps only the current month): the free-text credential field of every individual US healthcare provider, 7,415,294 of them in the pinned August 2026 file, 4,824,544 with a credential (counted 2026-10-21). The credential cell counts PROVIDERS writing the word, each once however often their field repeats it. A census of that field, so 0 is a real zero -- but only for US healthcare: law, accounting, academic and British credentials (`Esq`, `CPA`, `FRS`) are missing because the source holds no such people, not because nobody writes them. The file is upper-cased, so case is not recorded, and a word is matched ignoring case and periods, as the parser matches an acronym. 673 MB, so it is downloaded only when asked (--fetch-nppes, or --rank online), pinned by sha256, and read once into a tally cached beside it; the zip itself is never unpacked. - Local corpora (tools/differential/local_corpora.py): whole names as a source holds them, fetched into the cache by that script and never committed, so this tool only reads them. One row per local corpus, one not yet fetched naming the command that fetches it: the source's credit and data date, how many records write the word anywhere in the name and how many after a comma, and the spellings as written. Words are matched ignoring case, periods and edge punctuation (local_corpora.word_key); a spaced word ('Ph. D.') is matched as its words in a row, with the joined spelling's count beside it. The first is ClinicalTrials.gov's study officials, where `MD` after a comma is the usual case and a credential before it is the noise worth seeing; the second the FEC's individual contributors, nearly every name written family, comma, given in capitals by a filer's form, so a count there is a word typed into a field rather than into a whole name. Not a census of anyone: it counts what each source holds. Asked for with --boxes (or --country), a fielded source whose extract keeps every record's boxes and country (orcid-researchers, patentsview-inventors) adds a row "boxes by country": the people whose given box and whose family box write the word, counted over the whole extract rather than the capped corpus, and the --countries N countries with the most of them in each box, each as a share of that country's people, the records stating no country last; --country CC lists a country there too. PatentsView counts the inventors filing under a name, ORCID one holder a record, and a country is where they work or live, of researchers and US-patent inventors, not of a country's population. A spaced word is not looked up there. The first lookup builds each index (2 min 19 s for ORCID's 25M records on 2026-10-28) and caches it beside the corpus. Both US sources spell names in ASCII letters only, without spaces or punctuation (O'Brien is OBRIEN), so a word is folded the same way before lookup. A letter the fold cannot carry over (any non-Latin script) makes the word unrepresentable there: no answer, never a zero. Data is cached outside the repository, in $NAMEPARSER_TOOLS_CACHE or ~/.cache/nameparser-tools, so third-party data stays out of the tree. Exit status is 2 when any source failed to answer (a query error, an unusable download), so a script cannot mistake a partial report for a complete one; a source that is merely not downloaded is not a failure. """ from __future__ import annotations import argparse import csv import hashlib import http.client import io import json import os import re import shutil import sys import tarfile import time import unicodedata import urllib.error import urllib.parse import urllib.request import zipfile import zlib from collections import Counter from collections.abc import Callable, Iterator from dataclasses import asdict, dataclass, field from datetime import date from pathlib import Path from types import ModuleType from typing import TYPE_CHECKING, Any, Literal, TypeVar if TYPE_CHECKING: # loaded by path at run time, _local_corpora from local_corpora import BoxIndex, Local CENSUS_URL = "https://www2.census.gov/topics/genealogy/2010surnames/names.zip" CENSUS_FILE = "census2010_surnames.zip" CENSUS_MEMBER = "Names_2010Census.csv" CENSUS_COLUMNS = {"name", "rank", "count", "pctwhite", "pctblack", "pctapi", "pcthispanic"} SSA_URL = "https://www.ssa.gov/oact/babynames/names.zip" SSA_FILE = "ssa_names.zip" #: The national file holds one yobYYYY.txt per year from 1880. Fewer than #: this many is a partial or wrong download (the state-level zip holds #: AK.TXT and so on, none of them yob files). SSA_MIN_YEARS = 100 NAMDICT_URL = ("https://files.pythonhosted.org/packages/a8/dc/" "69939b7af56b7adf3aa2736771b7c3e7191f7cd36fbc80b0727570c275fa/" "gender-guesser-0.4.0.tar.gz") NAMDICT_SHA256 = ("1591c14592805ca7da06a46d5f7202511f7cb87547049a68dfccbeedb879f31b") NAMDICT_MEMBER = "gender-guesser-0.4.0/gender_guesser/data/nam_dict.txt" NAMDICT_FILE = "nam_dict.txt" NAMDICT_SOURCE = "nam_dict given names" NAMDICT_CREDIT = ("nam_dict.txt by Jörg Michael (2008), GNU Free Documentation " "License 1.2, from the gender-guesser 0.4.0 sdist") #: Countries the legend must name; a file giving another number is not #: the one the column reading was written for. NAMDICT_COUNTRIES = 55 #: NBER keeps every month at a fixed URL (2025/08 still answered on #: 2026-10-21); CMS's own page keeps only the current one. #: The pinned month; re-pinning is this and NPPES_SHA256. NPPES_MONTH = "202608" NPPES_URL = (f"https://data.nber.org/npi/{NPPES_MONTH[:4]}/{NPPES_MONTH[4:]}/" f"core_{NPPES_MONTH}_csv.zip") NPPES_SHA256 = ("5f431813422d843c2021ec839d2eb5f80d3117cc19de660ce70af79767bc1a13") NPPES_MEMBER = f"core_{NPPES_MONTH}.csv" NPPES_FILE = f"nppes_core_{NPPES_MONTH}_csv.zip" NPPES_TALLY = f"nppes_core_{NPPES_MONTH}_credentials.json" NPPES_SOURCE = "NPPES US healthcare credentials" NPPES_SIZE = "673 MB" #: Bumped whenever what the tally holds or how a field becomes keys #: changes -- the `[,;]` split in tally_credentials, credential_key, #: _squash or _resolve; a tally written by another version is rebuilt. #: NPPES_MIN_ALONE and NPPES_SPELLINGS, beside _resolve, are written into #: the tally and checked too, so changing either rebuilds it without a #: bump. TALLY_VERSION = 2 # 2: the tally keeps _resolve's spelling choice WIKIDATA_ENDPOINT = "https://query.wikidata.org/sparql" USER_AGENT = ("nameparser-name-usage/0.1 " "(https://github.com/derek73/python-nameparser)") #: Times a rate-limited query is retried after its Retry-After. SPARQL_RETRIES = 5 #: Label languages a name item is matched in. `mul` is Wikidata's #: language-independent label, which many name items carry; the rest #: catch items labelled only in a language of their own. Matching one #: exact literal per language keeps the query on the label index -- a #: FILTER over every label times out. WIKIDATA_LANGUAGES = ( "mul", "en", "de", "fr", "es", "it", "pt", "nl", "sv", "da", "nb", "fi", "et", "lv", "lt", "pl", "cs", "sk", "sl", "hr", "sr", "bs", "hu", "ro", "bg", "ru", "uk", "el", "tr", "az", "sq", "ga", "cy", "is", "ar", "fa", "ur", "he", "hi", "bn", "ta", "te", "ja", "ko", "vi", "th", "id", "ms", "tl", "sw", "ha", "yo", "ig", "am", "so", # Chinese labels sit under script and region tags far more often # than plain `zh`: neither 关 surname item has a `zh` or `mul` label, # so without these a simplified-script surname finds nothing. "zh", "zh-hans", "zh-hant", "zh-cn", "zh-tw", "zh-hk", "zh-sg", "yue", ) #: Letters NFKD leaves whole, so an ASCII fold would delete them and look #: up a different name (Sørensen as SRENSEN). Spelled as English-language #: records spell them. _TRANSLITERATE = str.maketrans({ "ß": "ss", "ẞ": "SS", "ø": "o", "Ø": "O", "æ": "ae", "Æ": "AE", "œ": "oe", "Œ": "OE", "ł": "l", "Ł": "L", "đ": "d", "Đ": "D", "ð": "d", "Ð": "D", "þ": "th", "Þ": "TH", "ı": "i", "ŋ": "ng", "Ŋ": "NG", }) Status = Literal["ok", "unrepresentable", "not downloaded", "failed"] T = TypeVar("T") @dataclass(slots=True) class Usage: """One source's answer for one word. `given`/`family`/`credential` are None when the source gave no answer for that role -- it does not cover the role, or `status` says why it could not answer. A number is a count; `floor` says what an unlisted name reads as instead of 0 ("<100"), and `lower_bound` that the true count may be higher. """ source: str status: Status = "ok" given: int | None = None family: int | None = None #: Providers writing the word as a credential, NPPES only. credential: int | None = None floor: str = "" lower_bound: bool = False #: Top countries of citizenship per role, Wikidata only. given_countries: list[tuple[str, int]] = field(default_factory=list) family_countries: list[tuple[str, int]] = field(default_factory=list) #: People of that role with no citizenship statement, kept out of the #: country lists: they are often the largest group and say nothing #: about which culture bears the name. given_stateless: int = 0 family_stateless: int = 0 #: Frequency band per country as a given name, nam_dict only: 1 #: (rare) to 13 (extremely common), highest first. Not a count, so #: `given` stays None beside it. given_bands: list[tuple[str, int]] = field(default_factory=list) #: Per country, the people whose given or family box writes the word #: and the people of that country, a fielded local corpus's box index #: only: (country, people writing it, people in all). given_shares: list[tuple[str, int, int]] = field(default_factory=list) family_shares: list[tuple[str, int, int]] = field(default_factory=list) note: str = "" #: Where the word can end a name across the family/given boundary, #: the ends-with row only. splits: list[Split] = field(default_factory=list) def cache_dir() -> Path: path = Path(os.environ.get("NAMEPARSER_TOOLS_CACHE", Path.home() / ".cache" / "nameparser-tools")) path.mkdir(parents=True, exist_ok=True) return path # -- the US sources' spelling --------------------------------------------- def _folded(word: str) -> Iterator[str]: """The word's characters with the letters NFKD leaves whole transliterated (_TRANSLITERATE) and the rest decomposed, less the marks no ASCII spelling keeps. Combining marks are accents NFKD split off; modifier letters (category Lm) are apostrophe-like marks such as the Hawaiian ʻokina in Kaʻahumanu. Both are dropped, as O'Brien's apostrophe is -- refusing them would leave such names with no answer.""" for ch in unicodedata.normalize("NFKD", word.translate(_TRANSLITERATE)): if not (unicodedata.combining(ch) or unicodedata.category(ch) == "Lm"): yield ch def us_key(word: str) -> str: """Fold a word the way the Census and SSA lists spell names: ASCII letters only, upper-cased. Empty when any letter of the word has no ASCII spelling -- a lossy fold looks up a DIFFERENT name, which is worse than no lookup.""" letters = [] for ch in _folded(word): if ch.isascii(): if ch.isalpha(): letters.append(ch) elif ch.isalpha(): return "" return "".join(letters).upper() def _unrepresentable(source: str, word: str) -> str: if is_cjk(word): return (f"does not apply to a CJK word: the {source} list spells " "names in Latin letters only") return f"a letter has no ASCII spelling in the {source} list" def _lookup_note(word: str, key: str) -> str: return "" if key == word.upper() else f"looked up as {key}" def _join(*parts: str) -> str: return "; ".join(p for p in parts if p) # -- US Census surnames -------------------------------------------------- def read_census(csv_text: str) -> dict[str, dict[str, str]]: reader = csv.DictReader(io.StringIO(csv_text)) missing = CENSUS_COLUMNS - set(reader.fieldnames or ()) if missing: raise ValueError(f"Census file lacks columns {sorted(missing)}; " f"has {reader.fieldnames}") return {row["name"]: row for row in reader} def load_census(download: bool) -> dict[str, dict[str, str]] | None: """The cached Census table; None when it is not cached and `download` is off. Raises ValueError naming the cache file when the cached file is unusable.""" path = cache_dir() / CENSUS_FILE if not path.exists(): if not download: return None print(f"downloading {CENSUS_URL} ...", file=sys.stderr) _download(CENSUS_URL, path, required_member=CENSUS_MEMBER) try: with zipfile.ZipFile(path) as zf: return read_census(zf.read(CENSUS_MEMBER).decode("utf-8")) except (zipfile.BadZipFile, KeyError, ValueError) as exc: raise ValueError(f"{path} is unusable ({exc}); delete it and run " "again to re-download") from exc def census_usage(word: str, table: dict[str, dict[str, str]] | None) -> Usage: usage = Usage("US Census 2010 surnames", floor="<100") if table is None: usage.status = "not downloaded" usage.note = "not cached, and --no-wikidata does not download" return usage key = us_key(word) if not key: usage.status = "unrepresentable" usage.note = _unrepresentable("Census", word) return usage row = table.get(key) if row is None: usage.family = 0 usage.note = _join("not listed: under 100 bearers, or none", _lookup_note(word, key)) return usage shares = ", ".join( f"{label} {row[col]}%" for label, col in ( ("white", "pctwhite"), ("black", "pctblack"), ("asian/PI", "pctapi"), ("hispanic", "pcthispanic")) if row[col] not in ("(S)", "")) usage.family = int(row["count"]) usage.note = _join(f"rank {int(row['rank']):,}", shares, _lookup_note(word, key)) return usage # -- US Social Security given names -------------------------------------- _YOB = re.compile(r"yob(\d{4})\.txt", re.IGNORECASE) def read_ssa(zf: zipfile.ZipFile, source: str = "the SSA zip", ) -> dict[str, Counter[str]]: """Total each given name's births by sex across every yobYYYY.txt, matched by basename in any case so a re-zipped folder still reads. Raises ValueError when the zip is not the national file.""" totals: dict[str, Counter[str]] = {} years = 0 for member in zf.namelist(): if not _YOB.fullmatch(Path(member).name): continue years += 1 for number, line in enumerate( zf.read(member).decode("utf-8").splitlines(), 1): if not line.strip(): continue try: name, sex, count = line.split(",") totals.setdefault(name.lower(), Counter())[sex] += int(count) except ValueError as exc: raise ValueError(f"{source}: {member} line {number} is not " f"Name,Sex,Count: {line!r}") from exc if years < SSA_MIN_YEARS: raise ValueError( f"{source} holds {years} yobYYYY.txt files; the national file " f"from {SSA_URL} holds one per year since 1880") return totals def load_ssa() -> dict[str, Counter[str]] | None: path = cache_dir() / SSA_FILE if not path.exists(): return None try: with zipfile.ZipFile(path) as zf: return read_ssa(zf, str(path)) except zipfile.BadZipFile as exc: raise ValueError(f"{path} is not a zip ({exc}); save {SSA_URL} " "there again") from exc def ssa_missing_message() -> str: return (f"SSA given names not downloaded. ssa.gov refuses scripted " f"downloads, so open {SSA_URL} in a browser and save it as " f"{cache_dir() / SSA_FILE}") def ssa_usage(word: str, table: dict[str, Counter[str]] | None) -> Usage: usage = Usage("US SSA given names 1880-", floor="<5/yr", lower_bound=True) if table is None: usage.status = "not downloaded" usage.note = "see the end of the report" return usage key = us_key(word) if not key: usage.status = "unrepresentable" usage.note = _unrepresentable("SSA", word) return usage sexes = table.get(key.lower()) if not sexes: usage.given = 0 usage.note = _join("not listed: under 5 births of either sex in " "every year, or none", _lookup_note(word, key)) return usage usage.given = sum(sexes.values()) usage.note = _join(", ".join(f"{sex} {n:,}" for sex, n in sorted(sexes.items())), _lookup_note(word, key)) return usage # -- nam_dict given names -------------------------------------------------- # # Fixed-width lines, 0-based: a gender code in [0:2] (M, F, ?M, ..., # which this tool does not report), the name from [3] to the sort flag at # [29], one hexadecimal band per country from [30], in the order the # comment legend gives, and " $" to end the line. Lines opening with "=" # are nickname equivalences ("= Al Albert"). A "+" sort flag marks the # lines re-sorted with expanded umlauts, which the header says to ignore; # most repeat a "-" line, and the handful that do not (Saša) are skipped # with them. _NAME_START, _SORT_FLAG, _BANDS_START = 3, 29, 30 _LINE_END = " $" #: The cached file's own sha256, checked at every load. A file cut at a #: line boundary still parses and reports every name it lost as not #: listed; a floor on the names read missed a cut that kept most of #: them, and the file is pinned, so only its exact bytes are accepted. NAMDICT_FILE_SHA256 = ("bfe79b1f3533d188333c36d7b0722868b873b2d366bb15bed3ed9a4f85db3c8c") @dataclass(slots=True) class NamDict: countries: list[str] #: Folded spelling -> a list of (spelling as listed, {country: band}), #: one per listing line and per "+" expansion: `dean` holds the M and #: ? lines of Dean and the ? line of De+An. names: dict[str, list[tuple[str, dict[str, int]]]] #: Folded short form -> the long forms it is listed as short for. short_for: dict[str, list[str]] def lookup(self, word: str) -> list[tuple[str, dict[str, int]]]: found = {id(e): e for key in namdict_keys(word) for e in self.names.get(key, [])} return list(found.values()) def short_forms(self, word: str) -> list[str]: return list(dict.fromkeys(long for key in namdict_keys(word) for long in self.short_for.get(key, []))) def namdict_keys(word: str) -> list[str]: """The folds the dictionary is matched under, for stored names and looked-up words alike: the default case fold, with the dot it leaves on a Turkish İ (i + U+0307) dropped so Ilker finds İlker, and the Turkish one, where I lowers to dotless ı, so ışıl and IŞIL find Işıl. The dotless ı itself is a letter, not a case: Isil does not find Işıl, as Kaete does not find Käte.""" turkish = word.replace("I", "ı").replace("İ", "i").lower() return list(dict.fromkeys([ word.casefold().replace("i\u0307", "i"), turkish])) def _legend(comments: list[str]) -> list[str]: """The countries in column order, read from the legend: each label line is followed by a line holding only a "|" in that country's column. Raises ValueError unless the bars fill NAMDICT_COUNTRIES consecutive columns from _BANDS_START.""" countries, columns = [], [] for above, line in zip(comments, comments[1:]): if line.strip("# $") == "|": countries.append(above.strip("# $")) columns.append(line.index("|")) expected = list(range(_BANDS_START, _BANDS_START + NAMDICT_COUNTRIES)) if columns != expected: raise ValueError(f"nam_dict legend marks columns {columns}, not the " f"{NAMDICT_COUNTRIES} from {_BANDS_START} this tool " "reads") return countries def expand_plus(name: str) -> list[str]: """The spellings a listed name stands for. A "+" is a hyphen, a space or nothing, the last joining lower-case as the header's example does: Jun+Wei is Jun-Wei, Jun Wei and Junwei.""" first, *rest = name.split("+") forms = [first] for part in rest: forms = [f + joined for f in forms for joined in ( "-" + part, " " + part, part[:1].lower() + part[1:])] return forms def _fixed_width(line: str, countries: int) -> bool: # A line shifted by a column keeps its length, so the gaps around # the name are checked too: read shifted, In's Korea band lands # under Vietnam and the name gains a leading space. return (len(line) == _BANDS_START + countries + len(_LINE_END) and line.endswith(_LINE_END) and line[2] == " " and line[_NAME_START] != " " and line[_SORT_FLAG - 1] == " ") def read_namdict(text: str) -> NamDict: lines = text.splitlines() countries = _legend([line for line in lines if line.startswith("#")]) names: dict[str, list[tuple[str, dict[str, int]]]] = {} short_for: dict[str, list[str]] = {} for number, line in enumerate(lines, 1): if not line or line.startswith("#"): continue if not _fixed_width(line, len(countries)): raise ValueError(f"nam_dict line {number} is not fixed-width: " f"{line!r}") if line[_SORT_FLAG] == "+": continue name = line[_NAME_START:_SORT_FLAG].rstrip() if line.startswith("="): pair = name.split() if len(pair) != 2: raise ValueError(f"nam_dict line {number} is not " f"'= short long': {line!r}") for key in namdict_keys(pair[0]): short_for.setdefault(key, []).append(pair[1]) continue bands = {} for country, digit in zip( countries, line[_BANDS_START:_BANDS_START + len(countries)]): if digit != " ": try: bands[country] = int(digit, 16) except ValueError: raise ValueError(f"nam_dict line {number} has band " f"{digit!r} for {country}") from None # The name as listed is filed too, so Jun+Wei copied from the # file finds itself. entry = (name, bands) for key in dict.fromkeys(k for form in [name, *expand_plus(name)] for k in namdict_keys(form)): names.setdefault(key, []).append(entry) return NamDict(countries, names, short_for) def _fetch_namdict(path: Path) -> None: """Download the sdist, check it against NAMDICT_SHA256, and cache the one member. The archive is read in memory and never unpacked to disk. Raises ValueError when the pinned constants do not describe the archive (a hash or member left stale by an edit), so the other sources still answer.""" request = urllib.request.Request(NAMDICT_URL, headers={"User-Agent": USER_AGENT}) with urllib.request.urlopen(request, timeout=120) as response: data = response.read() digest = hashlib.sha256(data).hexdigest() if digest != NAMDICT_SHA256: raise ValueError(f"{NAMDICT_URL} has sha256 {digest}, not the " f"pinned {NAMDICT_SHA256}") try: with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tf: member = tf.extractfile(NAMDICT_MEMBER) if member is None: raise ValueError("it is not a file") text = member.read() except (tarfile.TarError, KeyError, EOFError, zlib.error) as exc: raise ValueError(f"cannot read {NAMDICT_MEMBER} from {NAMDICT_URL} " f"({exc})") from exc digest = hashlib.sha256(text).hexdigest() if digest != NAMDICT_FILE_SHA256: raise ValueError(f"{NAMDICT_MEMBER} in {NAMDICT_URL} has sha256 " f"{digest}, not the pinned {NAMDICT_FILE_SHA256}") tmp = path.with_suffix(".part") tmp.write_bytes(text) tmp.replace(path) def load_namdict(download: bool) -> NamDict | None: """The cached dictionary; None when it is not cached and `download` is off. Raises ValueError naming the cache file when it is unusable, and lets a failed download through as raised (OSError, or ValueError from _fetch_namdict).""" path = cache_dir() / NAMDICT_FILE if not path.exists(): if not download: return None print(f"downloading {NAMDICT_CREDIT} ...", file=sys.stderr) _fetch_namdict(path) try: data = path.read_bytes() digest = hashlib.sha256(data).hexdigest() if digest != NAMDICT_FILE_SHA256: raise ValueError(f"sha256 {digest}, not the pinned file's " f"{NAMDICT_FILE_SHA256}") return read_namdict(data.decode("utf-8")) except ValueError as exc: # UnicodeDecodeError included raise ValueError(f"{path} is unusable ({exc}); delete it and run " "again to re-download") from exc def _latin_only(word: str) -> bool: return all(not ch.isalpha() or unicodedata.name(ch, "").startswith("LATIN") for ch in word) def namdict_usage(word: str, table: NamDict | None, top: int) -> Usage: usage = Usage(NAMDICT_SOURCE, lower_bound=True) if table is None: usage.status = "not downloaded" usage.note = "not cached, and --no-wikidata does not download" return usage if not _latin_only(word): usage.status = "unrepresentable" usage.note = "the dictionary spells names in Latin letters only" return usage entries = table.lookup(word) short = ", ".join(table.short_forms(word)) short_note = f"listed as a short form of {short}" if short else "" if not entries: usage.note = _join("not listed, which proves nothing: the dictionary " "is a selection, and letters are matched as " "written (Käte, not Kaete; Işıl, not Isil)", short_note) return usage peak: dict[str, int] = {} for _, bands in entries: for country, band in bands.items(): peak[country] = max(band, peak.get(country, 0)) order = {c: i for i, c in enumerate(table.countries)} ranked = sorted(peak.items(), key=lambda cb: (-cb[1], order[cb[0]])) usage.given_bands = ranked[:top] listed = ", ".join(dict.fromkeys(name for name, _ in entries)) more = len(ranked) - top usage.note = _join( f"listed as {listed}", f"banded in {len(ranked)} " + ("country" if len(ranked) == 1 else "countries") + ( f", the top {top} shown" if more > 0 else ""), "1 rare to D extremely common, logarithmic: x2 a step where the " "statistics are good", short_note) return usage # -- NPPES credentials ----------------------------------------------------- # # A provider's credential field is a list written by hand: 'MD', # 'M.D., PH.D.', 'MS CCC-SLP', 'PHARM D', 'NURSE PRACTITIONER'. Commas # and semicolons divide it into segments, which are taken as written. A # segment holding a space is one of three things, and the data tells # them apart, since every piece is also written somewhere as a whole # segment of its own: # # - one credential in pieces ('PHARM D', 'M. D.', 'PA C', 'OTR L'): the # pieces joined are written alone far more often than the rarest # piece is -- PHARMD 138,758 times against PHARM's 160 and D's 839; # - several credentials ('MD PHD', 'MS CCC-SLP', 'RN BSN'): each piece # is written alone more often than the pieces joined (MD/PHD and MD-PHD # 755 times against PHD's 112,945); # - a job title ('NURSE PRACTITIONER', 'REGISTERED NURSE'): some piece is # rarely written alone at all (PRACTITIONER 19 times, REGISTERED 12), # and the segment stays whole. # # A segment kept whole takes any credential in it along: 'MA COUNSELING' # is a key of its own, and its providers do not count toward MA. The # kept segments holding MS have 1,448 providers, MA 1,212, MD 684 and PHD # 420 -- an undercount of 1.2% of MA, 0.95% of MS and less of the others, # a little less still since a few of them write the credential in # another segment too. Splitting the credential off # instead would split 'NURSE PRACTITIONER' as well, NURSE being written # alone 583 times, and give NURSE up to 12,213 more providers (687 write # it as a credential); the undercount is the smaller error. # # (Counts measured on the pinned file, 2026-10-21.) #: A piece written alone fewer times than this, that joins with nothing, #: marks its segment as a phrase. Measured 2026-10-21: the job-title #: words sit at 11-22 (ASSISTANT, REGISTERED, PRACTITIONER, ASSOCIATE) #: and the rarest fragment that must still split off at 97 (LIC). NPPES_MIN_ALONE = 50 NPPES_SPELLINGS = 5 def credential_key(text: str) -> str: """A credential as the tally files it: upper-cased with every period dropped and spaces collapsed, which is how the parser matches an acronym (M.D. and md are MD). Hyphens and slashes are kept, so FNP-C and OTR/L are their own keys.""" return " ".join(text.upper().replace(".", "").split()) def _squash(text: str) -> str: return "".join(ch for ch in text if ch.isalnum()) def _resolve(segment: str, alone: Counter[str], joined: dict[str, tuple[str, int]]) -> list[tuple[str, str]]: """The (key, spelling) pairs a spaced segment stands for, by the rules above. `alone` counts each key written as a whole segment, and `joined` maps a key's letters and digits to its commonest spelling among those keys and their summed count.""" # A piece that is only periods ('M . D.') folds to nothing and is # dropped with its spelling, so the two lists stay aligned. written = [w for w in segment.split() if credential_key(w)] pieces = [credential_key(w) for w in written] out: list[tuple[str, str]] = [] i = 0 while i < len(pieces): for j in range(len(pieces), i + 1, -1): hit = joined.get(_squash("".join(pieces[i:j]))) if hit and hit[1] > min(alone[p] for p in pieces[i:j]): out.append((hit[0], " ".join(written[i:j]))) i = j break else: if alone[pieces[i]] < NPPES_MIN_ALONE: return [(" ".join(pieces), " ".join(written))] out.append((pieces[i], written[i])) i += 1 return out @dataclass(slots=True) class CredentialTally: #: Individual providers read, and those with any credential. people: int credentialed: int #: Key -> (providers writing it, its commonest spellings with counts). tokens: dict[str, tuple[int, list[tuple[str, int]]]] #: A key's letters and digits -> the key _resolve files a spaced #: segment of those letters under ('PAC' -> 'PA-C'), kept so the #: lookup reads the tally's own choice instead of deriving one. joined: dict[str, str] def __post_init__(self) -> None: """What every tally must hold, built or loaded. An empty one would answer 0 for every word -- the false zero this source must not give -- and happens if the entity column's coding changes ('Individual' read as 1), which the pin cannot see. A share divides by `credentialed`, and --rank prints each token's first spelling, so a cache breaking either would crash the run outside the loader's error handling.""" if not self.tokens: raise ValueError(f"no individual provider with a credential " f"({self.people:,} individuals read)") if not 0 < self.credentialed <= self.people: raise ValueError(f"credentialed {self.credentialed:,} is not " f"between 1 and people {self.people:,}") for key, (n, spellings) in self.tokens.items(): if not (0 < n <= self.credentialed and spellings and all(c > 0 for _, c in spellings)): raise ValueError(f"token {key!r} needs a count from 1 to " "credentialed and a spelling") for letters, key in self.joined.items(): if key not in self.tokens or _squash(key) != letters: raise ValueError(f"joined {letters!r} names {key!r}, not a " "token with those letters") def ranked(self) -> list[tuple[str, int, list[tuple[str, int]]]]: return sorted(((k, n, s) for k, (n, s) in self.tokens.items()), key=lambda row: (-row[1], row[0])) def tally_credentials(rows: Iterator[dict[str, str]]) -> CredentialTally: """Count the credential tokens of the individual providers in `rows` (dicts with `entity` and `pcredential`). Spaced segments are settled after the scan, once `alone` holds every whole-segment count.""" people = 0 fields: Counter[tuple[str, ...]] = Counter() for row in rows: if row["entity"] != "Individual": continue people += 1 segments = tuple(" ".join(s.split()) for s in re.split(r"[,;]", row["pcredential"])) segments = tuple(s for s in segments if s.strip(".")) if segments: fields[segments] += 1 alone: Counter[str] = Counter() for segments, n in fields.items(): for s in segments: if " " not in s: alone[credential_key(s)] += n joined: dict[str, tuple[str, int]] = {} for key, n in alone.most_common(): # The commonest spelling comes first and keeps the entry. spelling, total = joined.get(_squash(key), (key, 0)) joined[_squash(key)] = (spelling, total + n) providers: Counter[str] = Counter() spellings: dict[str, Counter[str]] = {} for segments, n in fields.items(): keys = set() for s in segments: pairs = (_resolve(s, alone, joined) if " " in s else [(credential_key(s), s)]) for key, spelling in pairs: keys.add(key) spellings.setdefault(key, Counter())[spelling] += n for key in keys: providers[key] += n return CredentialTally( people, sum(fields.values()), {k: (n, spellings[k].most_common(NPPES_SPELLINGS)) for k, n in providers.items()}, {letters: key for letters, (key, _) in joined.items()}) def _fetch_nppes(path: Path) -> None: """Stream the pinned zip into the cache, hashing as it lands, and keep it only when the hash and the member are the pinned ones.""" print(f"downloading {NPPES_URL} ({NPPES_SIZE}) ...", file=sys.stderr) request = urllib.request.Request(NPPES_URL, headers={"User-Agent": USER_AGENT}) tmp = path.with_suffix(".part") digest = hashlib.sha256() try: with urllib.request.urlopen(request, timeout=120) as response, \ tmp.open("wb") as out: while chunk := response.read(1 << 20): digest.update(chunk) out.write(chunk) if digest.hexdigest() != NPPES_SHA256: raise ValueError(f"{NPPES_URL} has sha256 {digest.hexdigest()}, " f"not the pinned {NPPES_SHA256}") try: with zipfile.ZipFile(tmp) as zf: zf.getinfo(NPPES_MEMBER) except (zipfile.BadZipFile, KeyError) as exc: raise ValueError(f"cannot read {NPPES_MEMBER} from {NPPES_URL} " f"({exc})") from exc tmp.replace(path) finally: tmp.unlink(missing_ok=True) def _read_nppes(path: Path) -> CredentialTally: """Tally the zip, read in place. The zip is checked against its pin first, since the tally written from it is trusted afterwards.""" digest = hashlib.sha256() with path.open("rb") as fh: while chunk := fh.read(1 << 20): digest.update(chunk) if digest.hexdigest() != NPPES_SHA256: raise ValueError(f"sha256 {digest.hexdigest()}, not the pinned " f"{NPPES_SHA256}") print(f"tallying credentials in {path} (about a minute, once) ...", file=sys.stderr) try: with zipfile.ZipFile(path) as zf, zf.open(NPPES_MEMBER) as raw: reader = csv.DictReader(io.TextIOWrapper(raw, encoding="utf-8")) missing = {"entity", "pcredential"} - set(reader.fieldnames or ()) if missing: raise ValueError(f"{NPPES_MEMBER} lacks columns {sorted(missing)}") return tally_credentials(reader) except (zipfile.BadZipFile, KeyError, EOFError, zlib.error, csv.Error) as exc: raise ValueError(f"cannot read {NPPES_MEMBER} ({exc})") from exc def _tally_rules() -> dict[str, int]: return {"min_alone": NPPES_MIN_ALONE, "spellings": NPPES_SPELLINGS} def _tally_json(tally: CredentialTally) -> dict[str, Any]: return {"version": TALLY_VERSION, "source_sha256": NPPES_SHA256, "rules": _tally_rules(), "people": tally.people, "credentialed": tally.credentialed, "tokens": {k: [n, [list(s) for s in spellings]] for k, (n, spellings) in tally.tokens.items()}, "joined": tally.joined} def _tally_from_json(data: object) -> CredentialTally | None: """The tally a cache file holds; None when it was written by another version or from another zip, so it is rebuilt. Raises ValueError on anything else that is not a tally.""" if not isinstance(data, dict): raise ValueError("not a tally object") if (data.get("version") != TALLY_VERSION or data.get("source_sha256") != NPPES_SHA256 or data.get("rules") != _tally_rules()): return None people, credentialed, tokens, joined = ( data.get("people"), data.get("credentialed"), data.get("tokens"), data.get("joined")) if not (isinstance(people, int) and isinstance(credentialed, int) and isinstance(tokens, dict) and isinstance(joined, dict) and all(isinstance(v, str) for v in joined.values())): raise ValueError("needs people, credentialed, a tokens map and a " "joined map") out: dict[str, tuple[int, list[tuple[str, int]]]] = {} for key, value in tokens.items(): if not (isinstance(value, list) and len(value) == 2 and isinstance(value[0], int) and isinstance(value[1], list) and all(isinstance(s, list) and len(s) == 2 and isinstance(s[0], str) and isinstance(s[1], int) for s in value[1])): raise ValueError(f"token {key!r} is not [count, spellings]") out[key] = (value[0], [(s, n) for s, n in value[1]]) # Types are checked here; values by CredentialTally itself. return CredentialTally(people, credentialed, out, joined) def load_nppes(download: bool) -> CredentialTally | None: """The cached tally, built from the cached zip when it is missing or stale, and the zip downloaded first only when `download` is on; None when neither is cached and `download` is off. Raises ValueError naming the file it cannot use, and lets a failed download through as raised.""" zip_path, tally_path = cache_dir() / NPPES_FILE, cache_dir() / NPPES_TALLY if tally_path.exists(): try: cached = _tally_from_json(json.loads( tally_path.read_text(encoding="utf-8"))) except ValueError as exc: # json.JSONDecodeError included raise ValueError(f"{tally_path} is unusable ({exc}); delete it " "and run again to re-tally") from exc if cached is not None: return cached if not zip_path.exists(): if not download: return None _fetch_nppes(zip_path) try: tally = _read_nppes(zip_path) except ValueError as exc: raise ValueError(f"{zip_path} is unusable ({exc}); delete it and run " "again with --fetch-nppes to re-download") from exc _write(tally_path, _tally_json(tally)) return tally def _share(fraction: float) -> str: # A credential a few hundred providers write is under 0.01%, which # 2 places would print as a zero. return "<0.01%" if 0 < fraction < 0.0001 else f"{fraction:.2%}" def ascii_credential(word: str) -> str: """The word as the NPPES file could spell it: accents dropped, the letters us_key transliterates spelled out, any dash written as a hyphen (PA–C is PA-C), and ASCII punctuation and digits kept for credential_key. Empty when anything else non-ASCII is left -- a letter, a curly quote -- since the file holds no such character (0 of 49,116 keys, 2026-10-21), and a word that can only miss must not read as a zero.""" kept = [] for ch in _folded(word): if unicodedata.category(ch) == "Pd": ch = "-" if not ch.isascii(): return "" kept.append(ch) return "".join(kept) _NPPES_SCOPE = "US healthcare only: a credential of another field reads 0 here" def credential_usage(word: str, tally: CredentialTally | None) -> Usage: usage = Usage(NPPES_SOURCE) if tally is None: usage.status = "not downloaded" usage.note = (f"not cached; --fetch-nppes downloads it " f"({NPPES_SIZE}, once)") return usage # Empty too for a word of nothing but spaces, periods or combining # marks, which would otherwise read "no provider writes" no key. key = credential_key(ascii_credential(word)) if not key: usage.status = "unrepresentable" usage.note = ("nothing in it is a credential the NPPES file could " "spell; it spells every one in ASCII") return usage # Keys differing only in punctuation are spellings of one credential # kept apart (PA-C, PAC). A spaced word is looked up under the spelling # the tally files those letters under when _resolve joins a segment, # so 'PA C' finds PA-C and not PAC. It does not ask whether _resolve # would join it: 'MD PHD' finds MD/PHD's 721, though the tally counted # those segments as MD and PHD. Letters only spaced keys hold (a job # title, 'REGISTERED NURSE') have no stored choice, and a word spaced # unlike all of them takes the commonest: 'Registered / Nurse' finds # REGISTERED NURSE's 5,054. And every answer names the others, so a # spelling few write cannot stand alone: OTR-L's 416 providers are # reported beside OTR/L's 61,789. A word with no letters or digits # ('-') shares them with nothing. letters = _squash(key) sharing = sorted(((k, n) for k, (n, _) in tally.tokens.items() if k != key and _squash(k) == letters), key=lambda kn: (-kn[1], kn[0])) if letters else [] if key in tally.tokens: found: str | None = key elif " " in key and sharing: found = tally.joined.get(letters, sharing[0][0]) sharing = [kn for kn in sharing if kn[0] != found] else: found = None others = ("also written " + ", ".join(f"{k} {n:,}" for k, n in sharing[:3]) if sharing else "") if found is None: usage.credential = 0 usage.note = _join(f"no provider writes {key}", others, _NPPES_SCOPE) return usage n, spellings = tally.tokens[found] usage.credential = n usage.note = _join( f"{_share(n / tally.credentialed)} of {tally.credentialed:,} credentialed " "providers", "written " + ", ".join(f"{s} {c:,}" for s, c in spellings), _lookup_note(word, found), others, _NPPES_SCOPE) return usage # -- local corpora --------------------------------------------------------- def _local_corpora() -> ModuleType: """tools/differential/local_corpora.py, which owns the local corpora's cache layout and the checks on loading one, loaded by path the way the differential tools load corpus_build.""" here = str(Path(__file__).resolve().parent / "differential") if here not in sys.path: sys.path.append(here) import local_corpora return local_corpora def load_locals() -> list[tuple[str, Local | None, str]]: """Every local corpus: (source, the corpus or None when not fetched, the error that stopped it loading). load() refuses with ValueError anything it cannot read, so a damaged corpus fails its own row; the records are read later, in the lookup, which guards its own.""" module = _local_corpora() return [(source, *_attempt(lambda source=source: module.load(source))) for source in sorted(module.SOURCES)] def local_usage(word: str, source: str, local: Local | None) -> Usage: module = _local_corpora() usage = Usage(f"{source} (local corpus)") if local is None: usage.status = "not downloaded" usage.note = f"not fetched; {module.fetch_command(source)}" return usage found = module.lookup(local, word) if found is None: usage.status = "unrepresentable" usage.note = "nothing in it is left once periods and punctuation go" return usage if found.joined is None: spelled = ("written " + ", ".join(f"{s} {n:,}" for s, n in found.spellings.most_common(3)) if found.spellings else "") else: joined = "".join(module.words(word)) spelled = f"as one word '{joined}' {found.joined:,}" if found.joined else "" usage.note = _join( local.credit, f"{found.anywhere:,} of {local.size:,} " f"{module.SOURCES[source].unit} write it, {found.after_comma:,} after " f"a comma", spelled) return usage def local_rows(word: str, locals_: list[tuple[str, Local | None, str]]) -> list[Usage]: """One row per local corpus, a corpus that failed to load reported as failed. A function of its own so its loop names cannot shadow main's: written inline there, its `index` replaced the CJK name index the ends-with row reads.""" return [Usage(f"{source} (local corpus)", status="failed", note=error) if error else _guarded_local_usage(word, source, local) for source, local, error in locals_] def load_box_indexes(locals_: list[tuple[str, Local | None, str]] ) -> list[tuple[str, BoxIndex | None, str]]: """The box index of every fetched local corpus whose extract holds its names' boxes and a country (local_corpora.cached_box_index): (source, the index or None, the error that stopped it), built or read once a run however many words are looked up. A corpus not fetched gets None and no error; one that did not load, its load's error.""" module = _local_corpora() found: list[tuple[str, BoxIndex | None, str]] = [] for source, local, error in locals_: if module.SOURCES[source].boxes is None: continue if error or local is None: found.append((source, None, error)) continue index, failed = _attempt(lambda source=source: module.cached_box_index(source)) found.append((source, index, failed)) return found def box_usage(word: str, source: str, index: BoxIndex | None, error: str, top: int, wanted: list[str]) -> Usage: """One fielded local corpus's answer by box and country: the people whose given box and whose family box write the word, over the whole extract, and the `top` countries by people writing it in each box, with `wanted` countries always among them and the records stating no country last, each with the people of that country it is a share of.""" module = _local_corpora() usage = Usage(f"{source} (boxes by country)") if error: usage.status, usage.note = "failed", error return usage if index is None: usage.status = "not downloaded" usage.note = f"not fetched; {module.fetch_command(source)}" return usage keys = module.words(word) if len(keys) != 1: usage.status = "unrepresentable" usage.note = ("nothing in it is left once periods and punctuation go" if not keys else "a box index holds single words; a spaced word is not looked up") return usage total = sum(index.totals.values()) def shares(box: dict[str, dict[str, int]]) -> list[tuple[str, int, int]]: found = box.get(keys[0], {}) # Records with no country stated (most of ORCID's) would lead # every list and say nothing of where a name is borne: they go # last, after the countries ranked and those asked for. ranked = sorted((c for c in found if c), key=lambda c: (-found[c], c))[:top] ranked += [c for c in wanted if c not in ranked] ranked += [""] if found.get("") else [] return [(c or "--", found.get(c, 0), index.totals.get(c, 0)) for c in ranked] usage.given = sum(index.given.get(keys[0], {}).values()) usage.family = sum(index.family.get(keys[0], {}).values()) usage.given_shares = shares(index.given) usage.family_shares = shares(index.family) usage.note = _join(module.SOURCES[source].credit, f"counted over the whole extract, {total:,} people in " f"{sum(1 for c in index.totals if c):,} countries; " "-- is no country stated") return usage def _guarded_local_usage(word: str, source: str, local: Local | None) -> Usage: """local_usage, a corpus failing its own row: the records are read first here (building an uncached index, or scanning the names for a spaced word), after load_locals' guard. Only the corpus's damage is caught, and a file it cannot read; a bug raises.""" try: return local_usage(word, source, local) except (_local_corpora().CorpusDamaged, OSError) as exc: return Usage(f"{source} (local corpus)", status="failed", note=f"{type(exc).__name__}: {exc}") # -- Wikidata ------------------------------------------------------------ def spellings(word: str) -> list[str]: """The label spellings a word is matched in: as written, lower-case, and capitalized per word. A name with internal capitals (McDonald) has no rule that reconstructs it, so it must be passed as written.""" capped = " ".join(w[:1].upper() + w[1:].lower() for w in word.split(" ")) return list(dict.fromkeys([word, word.lower(), capped, word.title()])) def _values(forms: list[str], languages: tuple[str, ...]) -> str: return " ".join(f"{json.dumps(form, ensure_ascii=False)}@{lang}" for form in forms for lang in languages) def _label_match(forms: list[str], languages: tuple[str, ...]) -> str: # Every spelling in one VALUES block, so COUNT(DISTINCT ?p) counts a # person reached through two spellings once. return f"""VALUES ?label {{ {_values(forms, languages)} }} ?item rdfs:label ?label .""" def _item_match(qids: list[str]) -> str: return "VALUES ?item { " + " ".join(f"wd:{q}" for q in qids) + " }" def _people(match: str) -> str: return f""" {match} {{ ?p wdt:P735 ?item . BIND("given" AS ?role) }} UNION {{ ?p wdt:P734 ?item . BIND("family" AS ?role) }}""" def wikidata_totals_query(forms: list[str], languages: tuple[str, ...] = WIKIDATA_LANGUAGES, ) -> str: return _totals_query(_label_match(forms, languages)) def wikidata_countries_query(forms: list[str], languages: tuple[str, ...] = WIKIDATA_LANGUAGES, ) -> str: return _countries_query(_label_match(forms, languages)) def _totals_query(match: str) -> str: return f""" SELECT ?role (COUNT(DISTINCT ?p) AS ?n) WHERE {{{_people(match)} }} GROUP BY ?role""" def _countries_query(match: str) -> str: """People per role and country of citizenship. A country with no English label is named by its Q-id rather than dropped, and an "unknown value" citizenship (a blank-node IRI, not an item) is named as such, so only a person with no P27 at all lands in the empty country. A person with two citizenships counts under both, so these rows may sum past the role's total, which comes from _totals_query.""" return f""" SELECT ?role ?country (COUNT(DISTINCT ?p) AS ?n) WHERE {{{_people(match)} OPTIONAL {{ ?p wdt:P27 ?c . OPTIONAL {{ ?c rdfs:label ?cl . FILTER(LANG(?cl) = "en") }} }} BIND(IF(!BOUND(?c), "", COALESCE(?cl, IF(STRSTARTS(STR(?c), STR(wd:)), STRAFTER(STR(?c), STR(wd:)), "unknown citizenship"))) AS ?country) }} GROUP BY ?role ?country""" def _bindings(response: dict[str, Any]) -> list[dict[str, str]]: return [{key: cell["value"] for key, cell in row.items()} for row in response["results"]["bindings"]] def read_wikidata(totals: list[dict[str, str]], by_country: list[dict[str, str]], top: int) -> Usage: """Raises ValueError on a role the queries cannot produce, rather than letting it fall through to a count of 0. Rows from several queries over disjoint item sets are summed.""" roles = {"given", "family"} for row in totals + by_country: if row["role"] not in roles: raise ValueError(f"unexpected role {row['role']!r}") counts = {role: 0 for role in roles} for row in totals: counts[row["role"]] += int(row["n"]) countries: dict[str, Counter[str]] = {role: Counter() for role in roles} stateless = {role: 0 for role in roles} for row in by_country: if row.get("country"): countries[row["role"]][row["country"]] += int(row["n"]) else: stateless[row["role"]] += int(row["n"]) return Usage( "Wikidata people", given=counts["given"], family=counts["family"], lower_bound=True, given_countries=countries["given"].most_common(top), family_countries=countries["family"].most_common(top), given_stateless=stateless["given"], family_stateless=stateless["family"]) def wikidata_usage(word: str, top: int) -> Usage: forms = spellings(word) try: usage = read_wikidata( _bindings(_sparql(wikidata_totals_query(forms))), _bindings(_sparql(wikidata_countries_query(forms))), top) except (OSError, ValueError, KeyError, http.client.HTTPException) as exc: return Usage("Wikidata people", status="failed", lower_bound=True, note=_failure(exc)) zero = "a zero proves nothing" if not (usage.given or usage.family) else "" usage.note = _join("matched as " + ", ".join(forms), zero) return usage #: Patched out by the tests. _sleep = time.sleep def _sparql(query: str) -> dict[str, Any]: result: dict[str, Any] = json.loads( _post(query, "application/sparql-results+json")) return result def _sparql_csv(query: str) -> list[dict[str, str]]: """The rows as CSV, for a listing too big for JSON: the 700,581 family-name listing rows are 30MB as CSV and were cut off at 73MB as JSON (2026-10-17).""" body = _post(query, "text/csv").decode() if body and not body.endswith("\n"): # A line cut inside its IRI is still a valid Q-id (Q1234567 # arriving as Q12); the missing line end is the only sign. raise ValueError("CSV response ends mid-line; it was cut short") try: return list(csv.DictReader(io.StringIO(body))) except csv.Error as exc: raise ValueError(f"unreadable CSV response: {exc}") from exc def _post(query: str, accept: str) -> bytes: # POSTed, not a GET: the VALUES block repeats every spelling once per # label language, so a seven-letter Cyrillic word already pushes the # URL past the endpoint's limit (HTTP 431, #680). body = urllib.parse.urlencode({"query": query}).encode() request = urllib.request.Request(WIKIDATA_ENDPOINT, data=body, headers={ "Accept": accept, "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", "User-Agent": USER_AGENT}) for attempt in range(SPARQL_RETRIES + 1): try: with urllib.request.urlopen(request, timeout=90) as response: data: bytes = response.read() return data except urllib.error.HTTPError as exc: # WDQS gives each client about a minute of query time a # minute and answers 429 past it; the index build reaches # that within a few queries. if exc.code != 429 or attempt == SPARQL_RETRIES: raise retry = exc.headers.get("Retry-After", "") wait = int(retry) if retry.isdigit() else 60 exc.close() # it holds the response; 3.14 warns on a leak print(f"rate-limited by {WIKIDATA_ENDPOINT}; waiting {wait}s", file=sys.stderr) _sleep(wait) raise AssertionError("unreachable") def _download(url: str, path: Path, required_member: str) -> None: """Fetch a zip into the cache, refusing to cache anything that is not a zip holding `required_member` -- an error page saved under the cache name would otherwise break every later run.""" request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) with urllib.request.urlopen(request, timeout=120) as response: data = response.read() try: with zipfile.ZipFile(io.BytesIO(data)) as zf: zf.getinfo(required_member) except (zipfile.BadZipFile, KeyError) as exc: raise ValueError(f"{url} did not return a zip holding " f"{required_member} ({exc}); it began {data[:80]!r}" ) from exc tmp = path.with_suffix(".part") tmp.write_bytes(data) tmp.replace(path) # -- Wikidata CJK name index ---------------------------------------------- # # Exact labels answer "is this word a name?"; a glued honorific asks # whether a name can END in the word (#707). WDQS cannot answer that # live: STRENDS over every label of every name item ran past its 60s # limit even with no person join (measured 2026-10-17), and its full-text # search matches a CJK word only where the tokenizer split it out (4 of # the ~90 Japanese surnames ending in 殿). So the CJK labels of the name # classes are fetched once into the cache, a word is matched there, and # only the bearers of the matching items are counted live. # # The fetch lists a class's items, then asks for their labels by VALUES # batch. Slicing the class inside one query instead (by Q-id suffix) # scans the whole class every time -- 700,581 family-name listing rows, # 700,304 items once lexemes are dropped -- so a slice of 250 labels # overran as surely as one of 25,000. Role = Literal["given", "family"] #: Name-item classes the index lists, and the role their items play. #: Counted on the index, 2026-10-17: Japanese and Chinese family-name #: items and Chinese given-name items nearly all carry the general class #: beside their own, but Japanese given names almost never do (13 of #: 4,620), nor do most male and female given names, and the #: undifferentiated kanji surnames (高橋, 阿部 as bare kanji, no #: reading) carry only theirs -- so every class is listed. NAME_CLASSES: dict[str, Role] = { "Q101352": "family", # family name "Q19914123": "family", # Japanese family name "Q88865432": "family", # undifferentiated kanji surname "Q1093580": "family", # Chinese family name "Q27951364": "family", # Korean family name "Q202444": "given", # given name "Q12308941": "given", # male given name "Q11879590": "given", # female given name "Q3409032": "given", # unisex given name "Q22812564": "given", # Japanese given name "Q22809413": "given", # Chinese given name } # A misspelled role would drop its class out of the boundary count # without a word, so the roles are checked here, where a typo lands. assert set(NAME_CLASSES.values()) == {"given", "family"}, NAME_CLASSES INDEX_DIR = "wikidata_cjk_names" #: Bumped whenever what the index holds changes (2 widened the character #: class to supplementary-plane Han, 3 to Extension H and keyed each batch #: by its whole membership); a cache built by another version is rebuilt #: rather than trusted. INDEX_VERSION = 3 #: Items per label query. Measured 2026-10-17 on family-name items not #: queried before: 1,000 took 2.2-2.4s and 2,000 took 24s. INDEX_BATCH = 1000 #: A batch that overruns is halved; one this small that still overruns #: is an error, not a reason to keep halving. INDEX_MIN_BATCH = 50 #: Items per bearer query; more are counted in several and summed. ITEM_CHUNK = 500 ENDS_WITH_SOURCE = "Wikidata people, name ends with" #: The iteration mark 々 and 〇, kana, the CJK ideograph blocks (Extension #: A, the unified block, compatibility, and the supplementary planes from #: Extension B through Extension H as of Unicode 15, where rare surname #: characters live), hangul syllables, and halfwidth katakana. The Yijing #: hexagrams between Extension A and the unified block are left out. One #: such letter makes a label or a word CJK; the query and is_cjk share #: the class, so widening it is an INDEX_VERSION bump. _CJK_CHARS = ("\u3005\u3007\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff" "\uac00-\ud7af\uf900-\ufaff\uff65-\uff9f" "\U00020000-\U000323af") _CJK = re.compile(f"[{_CJK_CHARS}]") def is_cjk(word: str) -> bool: return _CJK.search(word) is not None @dataclass(frozen=True, slots=True) class IndexEntry: qid: str role: Role label: str @dataclass(slots=True) class Split: """People whose family name ends in `family_tail` and whose given name is `given`, so that the two written together end in the word: 阿部 + 長 ends in 部長. `people` is None until counted, and stays None, with `error` saying why, when the count failed.""" family_tail: str given: str family_items: list[str] given_items: list[str] family_examples: list[str] = field(default_factory=list) people: int | None = None #: The Q-id of one bearer, for looking the pairing up. example: str = "" error: str = "" @dataclass(slots=True) class NameIndex: entries: list[IndexEntry] fetched: str def ending_in(self, word: str) -> list[IndexEntry]: return [e for e in self.entries if e.label.endswith(word)] def boundaries(self, word: str) -> list[Split]: given: dict[str, set[str]] = {} for e in self.entries: if e.role == "given": given.setdefault(e.label, set()).add(e.qid) splits = [] for cut in range(1, len(word)): tail, rest = word[:cut], word[cut:] if rest not in given: continue family = [e for e in self.entries if e.role == "family" and e.label.endswith(tail)] if family: splits.append(Split( tail, rest, sorted({e.qid for e in family}), sorted(given[rest]), sorted({e.label for e in family})[:3])) return splits def ids_query(cls: str) -> str: return f"SELECT ?item WHERE {{ ?item wdt:P31 wd:{cls} }}" def count_query(cls: str) -> str: return f"SELECT (COUNT(?item) AS ?n) WHERE {{ ?item wdt:P31 wd:{cls} }}" def labels_query(qids: list[str]) -> str: """The CJK rdfs:labels of the items, every language. Aliases and native-label statements are not read.""" return f""" SELECT ?item ?label WHERE {{ {_item_match(qids)} ?item rdfs:label ?label . FILTER(REGEX(STR(?label), "[{_CJK_CHARS}]")) }}""" class _Overrun(Exception): """The endpoint ran out of time on a query; a smaller one may not.""" class _Missing(Exception): """Part of the index is not cached and fetching is off.""" def _qid(iri: str) -> str: qid = iri.rsplit("/", 1)[-1] if not re.fullmatch(r"Q\d+", qid): raise ValueError(f"index row names {iri!r}, not an item") return qid def _index_sparql(query: str, as_csv: bool = False) -> list[dict[str, str]]: try: return _sparql_csv(query) if as_csv else _bindings(_sparql(query)) except urllib.error.HTTPError as exc: # A 500 is an overrun only when it says so: halving a malformed # query would send it again and again. try: overran = exc.code in (502, 504) or ( exc.code == 500 and b"TimeoutException" in exc.read()) finally: exc.close() if overran: raise _Overrun(f"HTTP {exc.code}") from None raise except (ValueError, TimeoutError, http.client.IncompleteRead) as exc: # At its limit WDQS cuts a 200 off mid-stream, which surfaces as # JSON that stops (ValueError), a CSV line with no end, or a # short read. raise _Overrun(f"{type(exc).__name__}: {exc}") from exc def _write(path: Path, data: object) -> None: tmp = path.with_suffix(".part") tmp.parent.mkdir(parents=True, exist_ok=True) tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") tmp.replace(path) def _unusable(path: Path, why: str) -> ValueError: return ValueError(f"{path} is unusable ({why}); delete it, or run " "again with --refresh-index") def _read_json(path: Path) -> object: try: return json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError) as exc: raise _unusable(path, f"{type(exc).__name__}: {exc}") from exc def _is_qid(value: object) -> bool: return isinstance(value, str) and re.fullmatch(r"Q\d+", value) is not None def _class_ids(cls: str, fetch: bool) -> list[str]: path = cache_dir() / INDEX_DIR / f"{cls}-ids.json" if path.exists(): cached = _read_json(path) if not (isinstance(cached, list) and cached and all(map(_is_qid, cached))): raise _unusable(path, "not a non-empty list of Q-ids") return [str(q) for q in cached] if not fetch: raise _Missing(path.name) try: [count] = _index_sparql(count_query(cls)) rows = _index_sparql(ids_query(cls), as_csv=True) except _Overrun as exc: raise ValueError(f"listing wd:{cls} items overran ({exc})") from exc if rows and "item" not in rows[0]: raise ValueError(f"listing wd:{cls} has columns {sorted(rows[0])}, " "not item") # CSV has no closing bracket to show a stream cut short, so the # listing is held to a count taken just before it. The slack is for # edits landing between the two queries; a cut loses far more. expected = int(count["n"]) if len(rows) < expected - expected // 1000: raise ValueError(f"listing wd:{cls} returned {len(rows):,} of " f"{expected:,} items; it was cut short") # Lexemes, their senses and their forms (L1000723, L2873-S1) carry # P31 family name too, and no person bears one; anything else that # is not an item is still an error. ids = sorted({_qid(row["item"]) for row in rows if not re.search(r"/L\d+(-[SF]\d+)?$", row["item"])}, key=lambda q: int(q[1:])) if not ids: # A merged or mistyped class answers 0 to both queries; caching # that would make every later ends-with row a silent zero. raise ValueError(f"wd:{cls} lists no items; check NAME_CLASSES") _write(path, ids) print(f" wd:{cls}: {len(ids):,} items", file=sys.stderr) return ids def _batch_digest(qids: list[str]) -> str: """What a batch file is keyed by: its whole membership, since batches are named by position and, after a class is listed again, the same position can hold other items -- at either end or in between.""" return hashlib.sha256(" ".join(qids).encode()).hexdigest() def _cached_batch(path: Path, digest: str, ids: list[str]) -> tuple[str, list[list[str]]] | None: """A batch file's date and rows, or None when it was built for other items.""" data = _read_json(path) if not (isinstance(data, dict) and isinstance(data.get("fetched"), str) and isinstance(data.get("rows"), list)): raise _unusable(path, "not a batch: needs fetched, digest and rows") if data.get("digest") != digest: return None rows = data["rows"] batch = set(ids) for row in rows: if not (isinstance(row, list) and len(row) == 2 and row[0] in batch and isinstance(row[1], str)): raise _unusable(path, f"row {row!r} is not an item of the batch") return data["fetched"], rows def _fetch_batch(cls: str, ids: list[str], start: int, end: int, fetch: bool, out: list[IndexEntry], dates: set[str]) -> bool: """Read the labels of ids[start:end] into `out`, querying them if they are not cached and halving the batch when it overruns. A finished batch is written at once and a halved one is marked, so a build that stops resumes where it stopped and never re-asks a halved batch. True when anything was queried.""" base = cache_dir() / INDEX_DIR / f"{cls}-{start}-{end}" done, halved = base.with_suffix(".json"), base.with_suffix(".halved") batch = ids[start:end] middle = (start + end) // 2 # The marker is read first: a whole batch's file from an earlier # listing must not shadow the halves this one was cached as. A marker # left by an earlier listing only splits the batch, which costs a # query and loses nothing, since each half is checked by itself. if halved.exists(): first = _fetch_batch(cls, ids, start, middle, fetch, out, dates) second = _fetch_batch(cls, ids, middle, end, fetch, out, dates) return first or second digest = _batch_digest(batch) if done.exists(): cached = _cached_batch(done, digest, batch) if cached is not None: dates.add(cached[0]) out.extend(IndexEntry(q, NAME_CLASSES[cls], label) for q, label in cached[1]) return False if not fetch: raise _Missing(done.name) try: rows = [[_qid(row["item"]), row["label"]] for row in _index_sparql(labels_query(batch))] except _Overrun as exc: if end - start <= INDEX_MIN_BATCH: raise ValueError(f"wd:{cls} items {start}-{end} still " f"overran ({exc})") from exc done.unlink(missing_ok=True) halved.parent.mkdir(parents=True, exist_ok=True) halved.touch() _fetch_batch(cls, ids, start, middle, fetch, out, dates) _fetch_batch(cls, ids, middle, end, fetch, out, dates) return True today = date.today().isoformat() _write(done, {"fetched": today, "digest": digest, "rows": rows}) dates.add(today) out.extend(IndexEntry(q, NAME_CLASSES[cls], label) for q, label in rows) return True def load_index(download: bool, refresh: bool = False) -> NameIndex | None: """The cached CJK name index; None when part of it is not cached and `download` is off. Building it is a one-time fetch of about 900 queries (2026-10-17), paced by the endpoint's rate limit, and resumable. Raises ValueError naming any cache file it cannot use.""" path = cache_dir() / INDEX_DIR version = path / "version.json" if refresh and path.exists(): shutil.rmtree(path) if path.exists() and (not version.exists() or _read_json(version) != INDEX_VERSION): if not download: return None print(f"the CJK name index in {path} was built by an older version " "of this tool; rebuilding it", file=sys.stderr) shutil.rmtree(path) if not path.exists(): if not download: return None print(f"building the CJK name index in {path} -- a one-time fetch " "of about half an hour; an interrupted build resumes", file=sys.stderr) _write(version, INDEX_VERSION) entries: list[IndexEntry] = [] dates: set[str] = set() try: for cls in NAME_CLASSES: ids = _class_ids(cls, download) for start in range(0, len(ids), INDEX_BATCH): end = min(start + INDEX_BATCH, len(ids)) fetched = _fetch_batch(cls, ids, start, end, download, entries, dates) if fetched and (start // INDEX_BATCH) % 50 == 49: print(f" wd:{cls}: {end:,} of {len(ids):,} items", file=sys.stderr) except _Missing: return None return NameIndex(list(dict.fromkeys(entries)), fetched=min(dates) if dates else "") def _chunks(qids: list[str]) -> list[list[str]]: return [qids[i:i + ITEM_CHUNK] for i in range(0, len(qids), ITEM_CHUNK)] def _count_items(qids: list[str], top: int) -> tuple[Usage, int]: chunks = _chunks(qids) totals: list[dict[str, str]] = [] by_country: list[dict[str, str]] = [] for chunk in chunks: totals += _bindings(_sparql(_totals_query(_item_match(chunk)))) by_country += _bindings(_sparql(_countries_query(_item_match(chunk)))) return read_wikidata(totals, by_country, top), len(chunks) def _count_split(split: Split) -> None: """The distinct bearers of the pairing, the family items asked in chunks (a one-letter tail such as 部 or 田 matches hundreds) and the people gathered here, so a person is counted once across chunks.""" given = " ".join(f"wd:{q}" for q in split.given_items) people: set[str] = set() for chunk in _chunks(split.family_items): family = " ".join(f"wd:{q}" for q in chunk) rows = _bindings(_sparql(f""" SELECT DISTINCT ?p WHERE {{ VALUES ?f {{ {family} }} VALUES ?g {{ {given} }} ?p wdt:P734 ?f ; wdt:P735 ?g . }}""")) people |= {_qid(row["p"]) for row in rows} split.people = len(people) split.example = min(people, key=lambda q: int(q[1:])) if people else "" _QUERY_ERRORS = (OSError, ValueError, KeyError, http.client.HTTPException) def _failure(exc: BaseException) -> str: """The note for a query that failed. An HTTPError holds its response open, and since 3.14 one collected unclosed warns, so it is closed here, where the report lets go of it.""" if isinstance(exc, urllib.error.HTTPError): exc.close() return f"query failed: {type(exc).__name__}: {exc}" def ends_with_usage(word: str, index: NameIndex, top: int) -> Usage: """Bearers of the indexed name items whose label ends in the word, and of the family/given pairs that end in it together. A failed pairing count fails only that pairing (Split.error).""" matches = index.ending_in(word) qids = sorted({e.qid for e in matches}) about = (f"index of {len(index.entries):,} entries fetched " f"{index.fetched}") try: if qids: usage, queries = _count_items(qids, top) # With its Q-id, so an item reached through a transcription or # a mislabel (厄德二世 is the given name Odo) can be looked up. named = sorted({(e.label, e.qid) for e in matches}) shown = ", ".join(f"{label} {qid}" for label, qid in named[:8]) if len(named) > 8: shown += ", ..." twice = (f"summed over {queries} queries, so a person bearing " "two of these names may count twice" if queries > 1 else "") items = "1 name item ends" if len(qids) == 1 else ( f"{len(qids)} name items end") usage.note = _join(f"{items} in it: {shown}", twice, about) else: usage = Usage("", given=0, family=0, lower_bound=True, note=_join(f"no indexed name item ends in {word}", about)) except _QUERY_ERRORS as exc: return Usage(ENDS_WITH_SOURCE, status="failed", lower_bound=True, note=_failure(exc)) usage.source = ENDS_WITH_SOURCE usage.splits = index.boundaries(word) for split in usage.splits: try: _count_split(split) except _QUERY_ERRORS as exc: split.error = _failure(exc) return usage # -- output -------------------------------------------------------------- def _attempt(load: Callable[[], T | None]) -> tuple[T | None, str]: """Run a source's loader, returning its table (None when not downloaded) and "" -- or None and the error it raised. A source that cannot be loaded is reported per word as failed; it must not take the other sources' answers down with it.""" try: return load(), "" except (OSError, ValueError, http.client.HTTPException) as exc: if isinstance(exc, urllib.error.HTTPError): exc.close() return None, f"{type(exc).__name__}: {exc}" def _count(n: int | None, usage: Usage) -> str: if n is None: return "-" if n == 0 and usage.floor: return usage.floor return f"{n:,}+" if usage.lower_bound else f"{n:,}" def render(word: str, usages: list[Usage]) -> str: lines = [word] width = max(len(u.source) for u in usages) lines.append(f" {'source':<{width}} {'given':>9} {'family':>9} " f"{'credential':>10}") for u in usages: given = (f"band {u.given_bands[0][1]:X}" if u.given_bands else _count(u.given, u)) lines.append(f" {u.source:<{width}} {given:>9} " f"{_count(u.family, u):>9} {_count(u.credential, u):>10}") note = u.note if u.status == "ok" else _join(u.status, u.note) if note: lines.append(f" {'':<{width}} {note}") for role, countries, stateless in ( ("given", u.given_countries, u.given_stateless), ("family", u.family_countries, u.family_stateless)): if countries or stateless: parts = [f"{c} {n}" for c, n in countries] if stateless: parts.append(f"{stateless} with no citizenship stated") lines.append(f" {'':<{width}} {role} by citizenship: " + ", ".join(parts)) if u.given_bands: lines.append(f" {'':<{width}} given band by country: " + ", ".join(f"{c} {b:X}" for c, b in u.given_bands)) for role, shares in (("given", u.given_shares), ("family", u.family_shares)): if shares: lines.append(f" {'':<{width}} {role} box by country: " + ", ".join( f"{c} {n:,} ({_share(n / of) if of else '-'} of {of:,})" for c, n, of in shares)) for split in u.splits: example = "; ".join( split.family_examples + ([f"bearer {split.example}"] if split.example else [])) detail = split.error or (f"e.g. {example}" if example else "") lines.append( f" {'':<{width}} family ending {split.family_tail} + " f"given {split.given}: {_count(split.people, u)} people" + (f" ({detail})" if detail else "")) return "\n".join(lines) #: Fields a parse may put a credential in, in the order a reading is #: named by; the first holding all of it wins. _FIELDS = ("suffix", "title", "given", "middle", "family", "nickname", "maiden") def _lands(name: str, text: str) -> str: """The field `text` lands in when `name` is parsed, or "split" when no one field holds all of it.""" from nameparser import parse parsed = parse(name) want = text.split() for role in _FIELDS: words = getattr(parsed, role).replace(",", " ").split() if any(words[i:i + len(want)] == want for i in range(len(words))): return role return "split" def _vocabulary(key: str) -> str: """How nameparser's default vocabulary lists a key: its suffix kind (an ambiguous acronym is marked so), then any title or particle reading, matched as the parser matches -- an acronym with every period gone, a word with its edge periods gone (the key has none).""" from nameparser import Lexicon lexicon = Lexicon.default() low = key.lower() kinds = [] if low in lexicon.suffix_acronyms_ambiguous: kinds.append("ambiguous acronym") elif low in lexicon.suffix_acronyms: kinds.append("acronym") if low in lexicon.suffix_words: kinds.append("suffix word") if low in lexicon.titles: kinds.append("title") if low in lexicon.particles: kinds.append("particle") return ", ".join(kinds) or "-" def _listed_suffix(key: str) -> bool: from nameparser import Lexicon lexicon, low = Lexicon.default(), key.lower() return (low in lexicon.suffix_acronyms_ambiguous or low in lexicon.suffix_acronyms or low in lexicon.suffix_words) def _cell(usage: Usage, count: int | None, error: str) -> str: """A name source's --rank cell: its count, or why it has none.""" if error: return "failed" return _count(count, usage) if usage.status == "ok" else usage.status @dataclass(slots=True) class RankRow: rank: int credential: str providers: int share: float spellings: list[tuple[str, int]] vocabulary: str #: Where the commonest spelling lands in "John Smith, X" and in #: "John Smith X". after_comma: str bare: str census: str ssa: str def rank_rows(tally: CredentialTally, top: int, unlisted: bool, census: dict[str, dict[str, str]] | None, census_error: str, ssa: dict[str, Counter[str]] | None, ssa_error: str) -> list[RankRow]: """The `top` commonest credentials -- only those the vocabulary does not list as a suffix, with `unlisted` -- each with the name counts and readings. The rank is the credential's place among all of them, so a filtered list shows where each one stands.""" rows = [] for rank, (key, n, spellings) in enumerate(tally.ranked(), 1): if unlisted and _listed_suffix(key): continue spelling = spellings[0][0] census_row, ssa_row = census_usage(key, census), ssa_usage(key, ssa) rows.append(RankRow( rank, key, n, n / tally.credentialed, spellings, _vocabulary(key), _lands(f"John Smith, {spelling}", spelling), _lands(f"John Smith {spelling}", spelling), _cell(census_row, census_row.family, census_error), _cell(ssa_row, ssa_row.given, ssa_error))) if len(rows) == top: break return rows def render_rank(rows: list[RankRow], tally: CredentialTally) -> str: header = (f"{'rank':>6} {'credential':<12} {'providers':>10} {'share':>7} " f"{'nameparser':<27} {'Smith, X':<9} {'Smith X':<8} " f"{'census':>7} {'ssa':>7} spellings") lines = [f"{tally.credentialed:,} of {tally.people:,} individual providers " "write a credential; Smith, X and Smith X are where the " "commonest spelling lands in 'John Smith, X' and 'John Smith X'", header] for r in rows: lines.append( f"{r.rank:>6} {r.credential:<12} {r.providers:>10,} {_share(r.share):>7} " f"{r.vocabulary:<27} {r.after_comma:<9} {r.bare:<8} " f"{r.census:>7} {r.ssa:>7} " + ", ".join(f"{s} {c:,}" for s, c in r.spellings[:3])) return "\n".join(lines) def _ssa_notice(ssa: dict[str, Counter[str]] | None, ssa_error: str) -> None: if ssa is None and not ssa_error: sys.stdout.flush() # keep the notice after the report, not before it print("\n" + ssa_missing_message(), file=sys.stderr) def _positive(text: str) -> int: # 0 would empty nam_dict's band list, and its given cell would then # show the "-" a name that is not listed shows. n = int(text) if n < 1: raise argparse.ArgumentTypeError(f"must be 1 or more, not {n}") return n def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( description="Count how often words are borne as given and family " "names, and written as credentials.", epilog="Sources: US Census 2010 surnames (public domain); US SSA " "given names (CC0); Wikidata (CC0); " + NAMDICT_CREDIT + "; NPPES (CMS, public) via NBER. Downloads are cached in " "$NAMEPARSER_TOOLS_CACHE or ~/.cache/nameparser-tools, never " "in the repository.") parser.add_argument("words", nargs="*") parser.add_argument("--no-wikidata", action="store_true", help="offline: cached sources only, nothing downloaded") parser.add_argument("--fetch-nppes", action="store_true", help=f"download the NPPES credentials ({NPPES_SIZE}) " "if they are not cached") parser.add_argument("--rank", type=_positive, metavar="N", help="list the N commonest credentials instead of " "reporting words (downloads NPPES unless " "offline, --no-wikidata)") parser.add_argument("--unlisted", action="store_true", help="with --rank: only credentials nameparser's " "vocabulary does not list as a suffix") parser.add_argument("--countries", type=_positive, default=5, metavar="N", help="countries to list per role (citizenship, " "or nam_dict's bands)") parser.add_argument("--boxes", action="store_true", help="add the fielded local corpora's rows by box and " "country (orcid-researchers, patentsview-inventors), " "read from an index each builds on first use " "(minutes) and caches") parser.add_argument("--country", action="append", default=[], metavar="CC", help="a country to list in every by-country row even " "outside the top N (an ISO code, repeatable; " "implies --boxes)") parser.add_argument("--json", action="store_true") parser.add_argument("--refresh-index", action="store_true", help="fetch the CJK name index again") args = parser.parse_args(argv) if args.rank is None and not args.words: parser.error("give words to report, or --rank N") if args.rank is not None and args.words: parser.error("--rank lists credentials; it takes no words") if args.unlisted and args.rank is None: parser.error("--unlisted goes with --rank") census, census_error = _attempt( lambda: load_census(download=not args.no_wikidata)) ssa, ssa_error = _attempt(load_ssa) nppes, nppes_error = _attempt(lambda: load_nppes( download=not args.no_wikidata and (args.fetch_nppes or args.rank is not None))) if args.rank is not None: return _main_rank(args, nppes, nppes_error, census, census_error, ssa, ssa_error) namdict, namdict_error = _attempt( lambda: load_namdict(download=not args.no_wikidata)) # Wikidata labels are NFC: a decomposed word (hangul as conjoining # jamo, an accent as a combining mark) would match no label at all. words = [unicodedata.normalize("NFC", word) for word in args.words] index: NameIndex | None = None index_error = "" if not args.no_wikidata and (args.refresh_index or any(map(is_cjk, words))): index, index_error = _attempt( lambda: load_index(download=True, refresh=args.refresh_index)) locals_ = load_locals() wanted = list(dict.fromkeys(c.strip().upper() for c in args.country if c.strip())) boxed = load_box_indexes(locals_) if args.boxes or wanted else [] report = {} for word in words: census_row, ssa_row = census_usage(word, census), ssa_usage(word, ssa) namdict_row = namdict_usage(word, namdict, args.countries) nppes_row = credential_usage(word, nppes) for row, error in ((census_row, census_error), (ssa_row, ssa_error), (namdict_row, namdict_error), (nppes_row, nppes_error)): if error: row.status, row.note = "failed", error usages = [census_row, ssa_row, namdict_row, nppes_row, *local_rows(word, locals_), *(box_usage(word, source, index, error, args.countries, wanted) for source, index, error in boxed)] if not args.no_wikidata: usages.append(wikidata_usage(word, args.countries)) if is_cjk(word): usages.append( ends_with_usage(word, index, args.countries) if index is not None else Usage(ENDS_WITH_SOURCE, status="failed", lower_bound=True, note=index_error or "the index did not load")) report[word] = usages if args.json: print(json.dumps({w: [asdict(u) for u in us] for w, us in report.items()}, ensure_ascii=False, indent=2)) else: print("\n\n".join(render(w, us) for w, us in report.items())) _ssa_notice(ssa, ssa_error) failed = any(u.status == "failed" or any(s.error for s in u.splits) for us in report.values() for u in us) if args.refresh_index and index_error: # A Latin word has no ends-with row to carry this, and the refresh # has already deleted the old index. print(f"CJK name index: {index_error}", file=sys.stderr) failed = True return 2 if failed else 0 def _main_rank(args: argparse.Namespace, nppes: CredentialTally | None, nppes_error: str, census: dict[str, dict[str, str]] | None, census_error: str, ssa: dict[str, Counter[str]] | None, ssa_error: str) -> int: """--rank: the list needs the tally, so a tally that did not load fails the run; a name source that did not load leaves its column saying so and fails the run too, as a failed row does.""" if nppes is None: print(f"{NPPES_SOURCE}: " + (nppes_error or ( "not cached, and --no-wikidata does not download")), file=sys.stderr) return 2 rows = rank_rows(nppes, args.rank, args.unlisted, census, census_error, ssa, ssa_error) if args.json: print(json.dumps({"people": nppes.people, "credentialed": nppes.credentialed, "rows": [asdict(r) for r in rows]}, indent=2)) else: print(render_rank(rows, nppes)) for name, error in (("US Census 2010 surnames", census_error), ("US SSA given names", ssa_error)): if error: print(f"{name}: {error}", file=sys.stderr) _ssa_notice(ssa, ssa_error) return 2 if census_error or ssa_error else 0 if __name__ == "__main__": sys.exit(main()) ����������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/perf/�������������������������������������������������������0000775�0000000�0000000�00000000000�15273032717�0021003�5����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/perf/call_count.py������������������������������������������0000664�0000000�0000000�00000013735�15273032717�0023511�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Count Python function calls for one parse, as tests/v2/test_benchmark.py counts them. WHY THIS IS IN THE TREE. #475 replaced a wall-clock benchmark with a call-count budget, and the decision entry that justifies it quotes a dozen numbers -- per interpreter, per module, per commit. The first draft of that entry took them across several sessions with throwaway scripts, on whichever interpreter was to hand, and published a table that spliced one interpreter's "before" column onto another's "after". Four of its eight module figures were wrong and its four stage figures were off by 2x, none of it visible without the harness. docs/design/AGENTS.md already asks for this: "Where an entry quotes something that drifts, give the one-liner that recomputes it." This is that one-liner. Every number in decisions/parse-cost comes from a mode of this script, named beside it. uv run python tools/perf/call_count.py # both entry points uv run python tools/perf/call_count.py --modules # calls by module uv run python tools/perf/call_count.py --stages # ms per 1000, per stage uv run python tools/perf/call_count.py --against REF # this tree vs a git ref The interpreter is printed with every result and belongs beside any number quoted from it: the count is deterministic for a given (tree, interpreter), not across interpreters. Measured 2026-08-31, one parse of the reference name: 407 on 3.11, 385 on 3.12, 403 on 3.13/3.14/3.15 -- PEP 709 inlined the comprehension frames 3.11 counts, and 3.13 added others back. """ from __future__ import annotations import argparse import subprocess import sys import tempfile import time from collections.abc import Callable from pathlib import Path #: The name every figure is quoted for. Fixed width on purpose: an #: incrementing counter grows a digit and adds one call as it does, so #: a mean over N names is a function of N rather than of the parser. REFERENCE = "Dr. Juan{i:04d} de la Vega III" def calls_for(fn: Callable[[str], object], n: int = 50) -> float: """Mean Python frame entries for one parse of the reference name.""" fn("warm up the caches") calls = 0 def counter(frame: object, event: str, arg: object) -> None: nonlocal calls if event == "call": calls += 1 sys.setprofile(counter) try: for i in range(n): fn(REFERENCE.format(i=i)) finally: sys.setprofile(None) return calls / n def by_module(fn: Callable[[str], object], n: int = 500) -> dict[str, float]: """Calls per parse attributed to each nameparser module.""" import cProfile import pstats fn("warm up the caches") pr = cProfile.Profile() pr.enable() for i in range(n): fn(REFERENCE.format(i=i)) pr.disable() out: dict[str, float] = {} for (path, _, _), stat in pstats.Stats(pr).stats.items(): if "nameparser" in path: mod = path.split("nameparser/")[-1] out[mod] = out.get(mod, 0) + stat[0] / n return out def by_stage(n: int = 1000) -> dict[str, float]: """Milliseconds per 1000 parses, per pipeline stage. Wall-clock, so unlike the counts above this drifts with the machine; quote it as a dated snapshot or not at all. """ from nameparser import Lexicon, Policy from nameparser._pipeline import STAGES from nameparser._pipeline._state import ParseState lex, pol = Lexicon.default(), Policy() names = [REFERENCE.format(i=i) for i in range(n)] for name in names[:50]: state = ParseState(original=name, policy=pol, lexicon=lex) for stage in STAGES: state = stage(state) total: dict[str, float] = {} for name in names: state = ParseState(original=name, policy=pol, lexicon=lex) for stage in STAGES: start = time.perf_counter() state = stage(state) total[stage.__name__] = (total.get(stage.__name__, 0) + time.perf_counter() - start) return {k: v * 1000 * (1000 / n) for k, v in total.items()} def _in_ref(ref: str, mode: str) -> str: """Run this script's own measurement inside a checkout of `ref`.""" with tempfile.TemporaryDirectory() as tmp: subprocess.run(f"git archive {ref} | tar -x -C {tmp}", shell=True, check=True) me = Path(tmp) / "tools" / "perf" / "call_count.py" if not me.exists(): # ref predates this script me.parent.mkdir(parents=True, exist_ok=True) me.write_text(Path(__file__).read_text(encoding="utf-8"), encoding="utf-8") done = subprocess.run( [sys.executable, str(me)] + ([mode] if mode else []), cwd=tmp, capture_output=True, text=True) return done.stdout.strip() or done.stderr.strip() def main() -> None: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--modules", action="store_true") ap.add_argument("--stages", action="store_true") ap.add_argument("--against", metavar="REF", help="also measure a git ref, in its own checkout") args = ap.parse_args() version = f"{sys.version_info.major}.{sys.version_info.minor}" sys.path.insert(0, str(Path.cwd())) from nameparser import HumanName, parse if args.modules: for mod, count in sorted(by_module(HumanName).items(), key=lambda kv: -kv[1]): print(f"py{version} {count:7.1f} {mod}") elif args.stages: for stage, ms in sorted(by_stage().items(), key=lambda kv: -kv[1]): print(f"py{version} {ms:7.1f}ms per 1000 {stage}") else: print(f"py{version} parse={calls_for(parse):.2f} " f"facade={calls_for(HumanName):.2f}") if args.against: mode = ("--modules" if args.modules else "--stages" if args.stages else "") print(f"--- {args.against} ---") print(_in_ref(args.against, mode)) if __name__ == "__main__": main() �����������������������������������derek73-python-nameparser-456b024/tools/vocab/������������������������������������������������������0000775�0000000�0000000�00000000000�15273032717�0021141�5����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/vocab/build.py����������������������������������������������0000664�0000000�0000000�00000210335�15273032717�0022616�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Compile nameparser/vocab/*.toml into nameparser/config/_compiled.py. Usage:: uv run python tools/vocab/build.py # rewrite _compiled.py uv run python tools/vocab/build.py --check # exit 1 if it is stale Either exits 2, printing the failed check, when the vocabulary fails one or a vocabulary file cannot be read, and 3 on an internal error or a module it cannot write, so neither is ever mistaken for exit 1's "stale". An unreadable _compiled.py IS stale, rebuilding replacing it, and --check reports it so. The vocabulary is written per word, in one TOML file per BCP 47 language tag (`und` for words not yet assigned one, `mul` for words shared on purpose). Each word lists its READINGS -- what it can be read as, keyed by kind -- and the compiler folds them into the sets the parser reads (decisions/vocabulary-model). The parser never parses TOML: the build writes a module of frozenset literals, committed beside the data, and tests/v2/test_vocab.py fails when the two disagree. The build checks replace the import-time asserts the data modules used to carry. Lexicon.__post_init__ still runs on the result, and three checks repeat one of its own -- 7's case mask, 9, and through 4 and 5 its gate-bypass raise -- so the shipped data fails here, naming the file and the word, rather than at the first import. Each raises VocabError naming the word, and the file where one file is at fault: 1. `schema` present and supported, no unknown key anywhere (a name a later phase will use says so), the file name a BCP 47 tag, at least one entry in the file; 2. `text` normalized, non-empty, once per file, each word of a phrase normalized on its own, and a text some token can equal: no comma or nakaguro, no character the tokenizer strips by default (an emoji), no interpunct between two East Asian characters, no invisible character but the two joiners; 3. every kind registered, every property allowed on its kind with an allowed value (a date, never a date-time; `as_of` only beside a count), an acronym's text free of periods (it is matched with them removed), a `note` on a name reading with no count, saying why it is declared, and no entry without a reading or an exclusion; 4. one suffix kind per word, across files; 5. the same category of the same word agrees on every behavioral property in every file, whatever kind each file gives it; 6. an excluded kind is absent from every file, along with every other kind that compiles to the same set (a name kind refuses only itself), and an excluded property never takes a value other than the one it has unwritten on a reading of the excluding kind, on any reading of its category (`cased`: of the word); run before 4 and 5, so the recorded reason is what a contributor sees; 7. `same_as` names another word of the same category, `cased` spells its word's letters (#459), at most one `cased` per word; on a `given` reading `same_as` is the full given name the word abbreviates, a name rather than a word of the vocabulary, written normalized, and a `family` reading takes none; 8. a phrase only as a birth_marker or an addresses = "given" title, and never with a `cased`, which is looked up per word; 9. a bound given name that is also a particle has a given reading; 10. no same_as chain loops, in any file, and the words of every file but und.toml in canonical order (_order_key): by kind, then by the word a spelling is the same_as of, then by text; 11. a clash with a name is stated as a name reading, from which the markers are derived (decisions/vocabulary-model, 2026-10-25): `may_be_name` and `may_be_given = true` are refused, each naming the reading to write, a particle reading states `may_be_given = false` exactly where its word has no `given` reading in any file, an agnomen reading has a name reading on its word, which gates it (rules/S11), and no other suffix word kind has one, since the gate reads only those two; numbered by subject, not by when they run: 1-3, 8, 7's case mask and 11's refusals run per entry as each file loads, and 4-7, 9, 11's particle statement and agnomen and 10 over the whole pool in check_pool, 6 first and 10 last. A reading written `default = false` is held to 1-3, 7, 8 and 11 like any other and compiles to nothing, so 4, 5, 6 and 9 do not weigh it; plus the data invariants that were asserts: may_be_initial only on a cased single letter, an elided particle in both apostrophes (#700), and after_given only on a salutation reading (#835). No post-nominal is both an acronym and a word, by checks 4 and 5; a final check in compile_pool is the backstop. The subset relations (GIVEN_NAME_TITLES and AFTER_GIVEN_TITLES in TITLES and TITLES_AMBIGUOUS together, each marker set in its base) need no check: a flag cannot exist without its reading, and a derived marker is read off the reading it marks. The one soft finding, a name reading on a word with no vocabulary reading and no exclusion (save an abbreviated given name, which compiles), is a VocabWarning, which the shipped-data test turns into an error. """ from __future__ import annotations import argparse import datetime import re import sys import tomllib import traceback import unicodedata import warnings from collections.abc import Iterable, Mapping from dataclasses import dataclass, field from pathlib import Path from types import ModuleType from typing import Any, TypedDict ROOT = Path(__file__).resolve().parents[2] # bound at import like VOCAB and OUTPUT below, so a test repointing ROOT # at a fixture tree does not move the parser's own module out from under # a cold _LEXICON cache _LEXICON_PATH = ROOT / "nameparser" / "_lexicon.py" def _load_lexicon_module() -> ModuleType: """nameparser/_lexicon.py, loaded by path rather than imported. Importing it through the package would run nameparser/__init__, which imports the config modules and so _compiled.py itself -- and a _compiled.py left broken (merge-conflict markers, or a new constant the module does not define yet) would then stop the one tool that regenerates it. _lexicon imports nothing internal at module level (its config imports sit inside _default_lexicon), so it loads on its own; tests/v2/test_vocab.py's test_the_build_does_not_import_the_package_it_writes holds that.""" import importlib.util spec = importlib.util.spec_from_file_location("_vocab_build_lexicon", _LEXICON_PATH) assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) # registered before it runs: its dataclasses look their module up sys.modules[spec.name] = module try: spec.loader.exec_module(module) except BaseException: del sys.modules[spec.name] raise return module _LEXICON: list[ModuleType] = [] def _lexicon() -> ModuleType: # loaded at first use, inside main's error handling, so a broken # _lexicon.py exits 3 rather than failing the import with exit 1 if not _LEXICON: _LEXICON.append(_load_lexicon_module()) return _LEXICON[0] def _normalize(word: str) -> str: result: str = _lexicon()._normalize(word) return result def _alnum(text: str) -> str: result: str = _lexicon()._alnum(text) return result VOCAB = ROOT / "nameparser" / "vocab" OUTPUT = ROOT / "nameparser" / "config" / "_compiled.py" REFRESH = "uv run python tools/vocab/build.py" #: The folder's reference for editors. README = "README.md" #: The agent notes beside it (nameparser/vocab/AGENTS.md), loaded by #: coding agents that read a file in the folder. AGENTS = "AGENTS.md" #: The files read_dir skips, by exact name. SKIPPED = frozenset({README, AGENTS}) #: The format version this compiler reads. A new kind or property is #: additive and does not bump it; a change to what an existing key means #: does. SCHEMA = 1 #: kind -> category. The category decides which compiled field a reading #: feeds. For title, suffix, particle and conjunction, the category's own #: name is also a kind (`title = {}`: a title not yet classified), so #: classifying a word later moves no set. bound_given's one kind shares #: its category's name; the relation (birth_marker) and name (given, #: family) categories have no generic kind. Kind #: names are unique across categories; a fine kind arrives with its #: category's classification, in a core PR, as one row here. CATEGORY: dict[str, str] = { "title": "title", "salutation": "title", "style": "title", "academic": "title", "religious": "title", "nobility": "title", "military": "title", "office": "title", "occupation": "title", "kinship": "title", "modifier": "title", "suffix": "suffix", "generation": "suffix", "agnomen": "suffix", "credential": "suffix", "honorific": "suffix", "professional_honorific": "suffix", "status": "suffix", "courtesy": "suffix", "particle": "particle", "preposition": "particle", "article": "particle", "patronymic": "particle", "teknonymic": "particle", "clan": "particle", "rank": "particle", "saint": "particle", "alias": "particle", "conjunction": "conjunction", "bound_given": "bound_given", "birth_marker": "relation", "given": "name", "family": "name", } #: The categories a word-level `excluded` may name besides the kinds. CATEGORIES = frozenset(CATEGORY.values()) #: CATEGORY's order is the canonical order of the kinds, and of the #: categories by their first kind: check 10 sorts a file's words by it, #: and the README's kind table lists the kinds in it. A category's kinds #: are contiguous, so ordering by kind orders by category first. KIND_ORDER = {kind: i for i, kind in enumerate(CATEGORY)} _RUNS = [c for i, c in enumerate(CATEGORY.values()) if i == 0 or c != list(CATEGORY.values())[i - 1]] assert len(_RUNS) == len(set(_RUNS)), "a category's kinds are listed apart in CATEGORY" #: The file exempt from check 10: it holds no language's words, only #: those no language file fits yet (nameparser/vocab/README.md). UNORDERED = "und.toml" #: Behavioral properties per category: each compiles into a field, and #: its allowed values are exactly what today's pipeline does. BEHAVIORAL: dict[str, frozenset[str]] = { "title": frozenset({"addresses", "after_given", "attaches", "cased", "default"}), "suffix": frozenset({"acronym", "attached", "cased", "default"}), "particle": frozenset({"cased", "default"}), "conjunction": frozenset({"may_be_initial", "cased", "default"}), "bound_given": frozenset({"cased", "default"}), "relation": frozenset({"cased", "default"}), } #: Stated properties per category: compiled to nothing, but held to the #: word's readings (check 11). A particle states `may_be_given = false` #: where its word has no given reading, so a new particle never falls to #: the never-given side unremarked (decisions/vocabulary-collisions C-i). STATED: dict[str, frozenset[str]] = { "particle": frozenset({"may_be_given"}), } #: The two markers phase 1 wrote as flags and phase 2 derives from a #: name reading (check 11), each with the reading that now states it. DERIVED = { "may_be_name": "a `given` or `family` reading on the word", "may_be_given": "a `given` reading on the word", } #: The suffix word kinds a name reading gates (rules/S11): the third #: derived marker reads these, and a name reading beside another word #: kind marks nothing, no gate reading that clash yet. GATED_WORDS = frozenset({"generation", "agnomen"}) #: Descriptive properties: recorded, validated, compiled to nothing -- #: save `same_as` on a `given` reading, which is a NAME_KEYS entry, not #: this set's, and compiles the word into GIVEN_NAME_ABBREVIATIONS. DESCRIPTIVE = frozenset({"note", "issue", "decided", "expands", "same_as", "native", "gender"}) #: What a name reading (`given`, `family`) may carry. COUNT_SOURCES = frozenset({"us_census_2010", "us_ssa", "wikidata"}) #: `same_as` on a `given` reading is the full name the word abbreviates #: (`chas` -> charles), which compiles it into GIVEN_NAME_ABBREVIATIONS: #: a period-marked abbreviation opening the given part is that given #: name, not rules/H2's title (#789) NAME_KEYS = COUNT_SOURCES | {"as_of", "native", "note", "gender", "same_as"} #: The keys of an `excluded` value's table, and of an `attached` table. EXCLUSION_KEYS = frozenset({"reason", "issue", "decided", "note"}) ATTACHED_KEYS = frozenset({"side", "issue", "decided", "note"}) #: Names a later phase will give meaning to. Writing one now is an error #: that says so, rather than "unknown", so nothing reads as working. #: Phase 2 gave `default` and `attaches` their meaning; the gate's slots #: carry position, and shape settles a bare letter (rules/P7), so the #: position and bare-letter names wait with `separators` for phase 4's #: shapes and glued words. RESERVED = { "prefer": 3, "strict": 3, "bare_only": 4, "leading_only": 4, "trailing_only": 4, "separators": 4, } _TAG = re.compile(r"[a-z]{2,3}(-[A-Z][a-z]{3})?(-([A-Z]{2}|[0-9]{3}))?") class VocabError(ValueError): """A build check failed; the message names the file and the word.""" class VocabWarning(UserWarning): """A finding that compiles but is probably a mistake.""" @dataclass(frozen=True) class Reading: file: str text: str kind: str props: dict[str, Any] @property def category(self) -> str: return CATEGORY[self.kind] # the defaults of the two behavioral properties whose unwritten value # is not false, each defined once: checks, compile_pool and # _behavior all read them here @property def acronym(self) -> bool: """Matched with its periods removed: unwritten, true only for a credential.""" return bool(self.props.get("acronym", self.kind == "credential")) @property def on_by_default(self) -> bool: """Compiled into the default vocabulary: unwritten, true. A reading written `default = false` is recorded and checked as a reading but compiles to nothing, for a pack to turn on in phase 3 (decisions/vocabulary-model).""" return self.props.get("default", True) is not False def flag(self, prop: str) -> bool: """A `may_be_*` flag or `after_given`: unwritten, false.""" return bool(self.props.get(prop, False)) @property def addresses(self) -> object: """Which name a title addresses: unwritten, the family name. An object, not a str, because it is read before check 3 has refused a value that is not one of the allowed strings.""" return self.props.get("addresses", "family") def where(self) -> str: return f"{self.file}: {self.text!r} {self.kind}" def _exclusion_where(file: str, text: str, kind: str, prop: str | None) -> str: what = f"excluded {kind}" if prop is None else f"{kind} excluded {prop}" return f"{file}: {text!r} {what}" @dataclass(frozen=True) class Exclusion: """A refused reading (`prop` None: `kind` is a kind or a category) or a refused property of the `kind` reading the word keeps. `props["reason"]` is always a non-empty string: __post_init__ refuses any other, so _exclusion builds one even from a value with no reason and lets the refusal come from here.""" file: str text: str kind: str prop: str | None props: dict[str, Any] # the value's table: reason, issue, decided, note def __post_init__(self) -> None: reason = self.props.get("reason") if not isinstance(reason, str) or not reason: raise VocabError(f"{self.where()}: an exclusion needs a reason, as a " f"string or a table with `reason`") @property def reason(self) -> str: reason: str = self.props["reason"] return reason def where(self) -> str: return _exclusion_where(self.file, self.text, self.kind, self.prop) @dataclass class Pool: """Every reading and exclusion in the files, in file order.""" readings: list[Reading] = field(default_factory=list) exclusions: list[Exclusion] = field(default_factory=list) #: each file's entries as written, for check 10's word order entries: dict[str, list[dict[str, Any]]] = field(default_factory=dict) # ---------------------------------------------------------------- loading def _reserved_or_unknown(where: str, key: str, allowed: Iterable[str]) -> VocabError: if key in RESERVED: return VocabError(f"{where}: {key!r} is reserved for phase {RESERVED[key]}; " f"it has no behavior yet") return VocabError(f"{where}: unknown key {key!r}; allowed: {sorted(allowed)}") def _exclusion(file: str, text: str, kind: str, prop: str | None, value: object) -> Exclusion: """An `excluded` value: a reason string, or a table with `reason`.""" where = _exclusion_where(file, text, kind, prop) if isinstance(value, str) and value: return Exclusion(file, text, kind, prop, {"reason": value}) if isinstance(value, dict): for k in value: if k not in EXCLUSION_KEYS: raise _reserved_or_unknown(where, k, EXCLUSION_KEYS) _check_descriptive(where, value) return Exclusion(file, text, kind, prop, value) # a bare value that is no reason string: Exclusion's own refusal return Exclusion(file, text, kind, prop, {}) def _is_date(value: object) -> bool: # tomllib reads a TOML date-time as a datetime, which IS a date to # isinstance: a recorded day must not carry an hour it never had return isinstance(value, datetime.date) and not isinstance(value, datetime.datetime) def _check_descriptive(where: str, props: Mapping[str, Any]) -> None: for k in ("note", "native"): if k in props and not isinstance(props[k], str): raise VocabError(f"{where}: {k} must be a string") if "issue" in props: v = props["issue"] vs = v if isinstance(v, list) else [v] if not vs or not all(isinstance(i, int) and not isinstance(i, bool) and i > 0 for i in vs): raise VocabError(f"{where}: issue must be an issue number or a list of them") if "decided" in props and not _is_date(props["decided"]): raise VocabError(f"{where}: decided must be a TOML date (2026-10-18), " f"not a string or a date-time") if "expands" in props: v = props["expands"] vs = v if isinstance(v, list) else [v] if not vs or not all(isinstance(s, str) and s for s in vs): raise VocabError(f"{where}: expands must be a string or a list of strings") if "gender" in props and props["gender"] not in ("f", "m", "n"): raise VocabError(f"{where}: gender must be 'f', 'm' or 'n'") if "same_as" in props and not isinstance(props["same_as"], str): raise VocabError(f"{where}: same_as must be a text") def check_file_names(names: Iterable[str]) -> None: """Build check 1, the file half: each name a BCP 47 tag in canonical case. Canonical case is also what keeps two names from differing only by case, which a macOS or Windows checkout cannot hold: of any two such names, at most one is canonical.""" for name in names: if not name.endswith(".toml") or not _TAG.fullmatch(name[:-5]): raise VocabError( f"{name}: a vocabulary file is named for a BCP 47 tag in " f"canonical case (pt.toml, pt-BR.toml, zh-Hant.toml), or " f"und.toml / mul.toml") def load(files: Mapping[str, str]) -> Pool: """Parse {file name: TOML text} and run the per-entry checks (1-3, 8).""" check_file_names(files) pool = Pool() for name in sorted(files): try: data = tomllib.loads(files[name]) except tomllib.TOMLDecodeError as e: raise VocabError(f"{name}: not valid TOML: {e}") from None schema = data.get("schema") if not isinstance(schema, int) or isinstance(schema, bool): raise VocabError(f"{name}: needs `schema = {SCHEMA}` at the top") if schema > SCHEMA: raise VocabError(f"{name}: schema {schema} is newer than this " f"compiler reads ({SCHEMA}); upgrade nameparser") if schema != SCHEMA: raise VocabError(f"{name}: schema {schema} is not supported") for k in data: if k not in ("schema", "word"): raise _reserved_or_unknown(name, k, ("schema", "word")) words = data.get("word", []) if not isinstance(words, list): raise VocabError(f"{name}: `word` must be an array of tables ([[word]])") if not words: # a file with the header and nothing else -- a botched move, # every entry cut and none pasted -- compiles to nothing raise VocabError(f"{name}: no [[word]] entries; delete the file " f"or add its words") seen: set[str] = set() for entry in words: if not isinstance(entry, dict): raise VocabError(f"{name}: `word` must be an array of tables ([[word]])") _load_entry(name, entry, seen, pool) pool.entries[name] = words return pool # ------------------------------------------------------------ word order def _sort_kind(e: Mapping[str, Any]) -> str: """The kind an entry sorts under: the earliest of its vocabulary readings in canonical order; else, for an entry that records only a refusal (`fils`, `senior`), the earliest kind it excludes, a category counting as its first kind; else its earliest name reading. A name reading is evidence for something else, so it places the entry only alone.""" kinds = [k for k in e if k in CATEGORY] vocabulary = [k for k in kinds if CATEGORY[k] != "name"] if vocabulary: return min(vocabulary, key=KIND_ORDER.__getitem__) refused = [k if k in CATEGORY else next(c for c in CATEGORY if CATEGORY[c] == k) for k in e.get("excluded", ())] return min(refused or kinds, key=KIND_ORDER.__getitem__) def _heads(words: Iterable[Mapping[str, Any]]) -> dict[tuple[str, str], str]: """Each word's same_as in each category, from one file's entries: the same_as of the word's earliest reading of that category that carries one. A same_as naming its own word is left out, for check 7 to refuse, so following these always ends.""" heads: dict[tuple[str, str], str] = {} for e in words: for k in sorted((k for k in e if k in CATEGORY), key=KIND_ORDER.__getitem__): if e[k].get("same_as") not in (None, e["text"]): heads.setdefault((CATEGORY[k], e["text"]), e[k]["same_as"]) return heads def _order_key(e: Mapping[str, Any], heads: Mapping[tuple[str, str], str]) -> tuple[int, tuple[str, ...]]: """Check 10's sort key: the entry's kind (_sort_kind), then its same_as PATH -- the word it is a spelling of, that word's own head, and so on as far as the file's own entries go, written root first and ending in the entry's text. So a head sorts before its spellings (`jr`, then `jnr` and `junior`), a spelling's own spellings follow it (`el-` under `al-`, then el-'s assimilated forms), and an entry with no same_as is a path of one, sorted by its text. Only the file's own chains are followed (`heads` is _heads of the file), so no other file decides its order. Texts compare by codepoint: they are already normalized (check 2), so this is the codepoint order of the normalized text, whatever the script -- defined everywhere, stable across Python versions and locales, and no one language's collation.""" kind = _sort_kind(e) category = CATEGORY[kind] path = [e["text"]] head = e[kind].get("same_as") if kind in e else None if head == path[0]: head = None # check 7's to refuse # _heads holds no self-reference and _check_same_as_cycles has # refused every longer loop, so this ends while head is not None: path.append(head) head = heads.get((category, head)) return KIND_ORDER[kind], tuple(reversed(path)) def _check_same_as_cycles(entries: Mapping[str, list[dict[str, Any]]]) -> None: """Check 10's first half: no same_as chain loops, in any file, und.toml's included, over EVERY same_as written -- a word may name a different head in each file or each kind, and a loop through any of them is refused, not only through the one a file's order follows.""" edges: dict[tuple[str, str], set[str]] = {} for words in entries.values(): for e in words: for k, v in e.items(): if k in CATEGORY and "same_as" in v and v["same_as"] != e["text"]: edges.setdefault((CATEGORY[k], e["text"]), set()).add(v["same_as"]) done: set[tuple[str, str]] = set() for start in sorted(edges): # depth first, iteratively (a chain can be longer than Python's # recursion limit): meeting a word already on the open path is a # loop, and the loop is the path from that word on, since a # chain may run INTO a loop its first word is not part of path = [start] todo = [iter(sorted(edges[start]))] while path: head = next(todo[-1], None) if head is None: done.add(path.pop()) todo.pop() continue node = (start[0], head) if node in path: loop = [t for _, t in path[path.index(node):]] + [head] raise VocabError(f"{head!r} {start[0]}: same_as loops, " f"{' -> '.join(loop)}; a spelling names the " f"word it is a spelling of, which does not " f"name it back") if node not in done: path.append(node) todo.append(iter(sorted(edges.get(node, ())))) def _misplaced(ranks: list[int]) -> tuple[int, set[int]]: """The entry to move and the entries to keep: the first entry outside a longest run of entries already in their relative order, and that run. Placed against the run, each move the check names lengthens it by one, so following the messages takes the fewest moves, and a word appended at the end of a file is the one named, not the neighbours it displaced. Ties keep the later entries, so of two swapped words the first is named.""" best = [1] * len(ranks) prev = [-1] * len(ranks) for i in range(len(ranks)): for j in range(i): if ranks[j] < ranks[i] and best[j] + 1 >= best[i]: best[i], prev[i] = best[j] + 1, j i = max(range(len(ranks)), key=lambda k: (best[k], k)) kept = set() while i != -1: kept.add(i) i = prev[i] return min(set(range(len(ranks))) - kept), kept def check_order(entries: Mapping[str, list[dict[str, Any]]]) -> None: """Build check 10: no same_as chain loops, and every file but und.toml lists its words in _order_key's order. Checked, never rewritten: a comment above an entry belongs to it and must move with it, which a TOML reader cannot see. Run last, so a file out of order is reported only once every other check has passed: a contributor sees an exclusion's reason (6) or a same_as typo (7) before being told where the entry goes.""" _check_same_as_cycles(entries) for name, words in sorted(entries.items()): if name == UNORDERED: continue heads = _heads(words) keys = [_order_key(e, heads) for e in words] ordered = sorted(range(len(words)), key=keys.__getitem__) if ordered == list(range(len(words))): continue ranks = [0] * len(words) for at, i in enumerate(ordered): ranks[i] = at m, kept = _misplaced(ranks) # its place among the kept entries, which are in order already below = [i for i in kept if ranks[i] < ranks[m]] kind = _sort_kind(words[m]) what = kind if kind in words[m] else f"sorted as {kind}, the kind it refuses" if below: place = f"after {words[max(below, key=ranks.__getitem__)]['text']!r}" else: above = min(kept, key=ranks.__getitem__) place = f"before {words[above]['text']!r}" raise VocabError( f"{name}: {words[m]['text']!r} ({what}) belongs {place}. A file " f"lists its words by kind in the README's table order, then by " f"the word a spelling is the same_as of, head first, then by " f"text; move the entry with any comment above it ({UNORDERED} " f"is exempt)") #: Hand copies of what the tokenizer divides or strips at under the #: default Policy (this tool cannot import the pipeline without the #: package), each held to its source by tests/v2/test_vocab.py, which #: also sweeps every codepoint the tokenizer ignores. #: #: The separators: nameparser/_pipeline/_state.py's COMMA_CHARS and #: _tokenize.py's _NAME_DOT_SEPARATORS. The main stream divides at both; #: inside a delimited clause a comma is kept as written (rules/N1), and #: there rules/M3 reads a marker followed by a comma as no marker, which #: holds only while the vocabulary stores no comma. DIVIDERS = frozenset({",", "\u060c", "\uff0c", "\u30fb", "\uff65"}) #: _tokenize.py's _EMOJI_RANGES, stripped under strip_emoji (default on). EMOJI_RANGES = ((0x1F300, 0x1F64F), (0x1F680, 0x1F6FF), (0x2600, 0x26FF), (0x2700, 0x27BF)) #: The spans of nameparser/_policy.py's _SCRIPT_RANGES (Han, Hangul, #: hiragana, katakana): an interpunct between two of these characters #: divides the name (rules/T3), anywhere else it is part of the word. EAST_ASIAN_RANGES = ((0x3005, 0x3006), (0x3400, 0x4DBF), (0x4E00, 0x9FFF), (0xF900, 0xFAFF), (0x20000, 0x323AF), (0xAC00, 0xD7A3), (0x3040, 0x309F), (0x30A0, 0x30FF), (0xFF65, 0xFF9F)) INTERPUNCT = "\u00b7" #: The format characters a word may hold: the zero-width non-joiner and #: joiner are spelling in Persian and the Indic scripts, and a token #: keeps them. JOINERS = frozenset({"\u200c", "\u200d"}) def _in(ch: str, ranges: tuple[tuple[int, int], ...]) -> bool: return any(lo <= ord(ch) <= hi for lo, hi in ranges) def _check_tokenizable(name: str, text: str) -> None: """Build check 2's second half. The normalized-form test passes each of these. An emoji and an interpunct between East Asian characters match no token under the default Policy; a comma could only match inside a delimited clause, where rules/M3 needs the vocabulary to hold none; an invisible character is a spelling the file does not show; and a phrase word with an edge period is a second spelling of the key Lexicon folds it to.""" for i, ch in enumerate(text): if ch in DIVIDERS: raise VocabError( f"{name}: {text!r} holds {ch!r} (U+{ord(ch):04X}), which the " f"tokenizer divides a name at; no vocabulary word holds one") if _in(ch, EMOJI_RANGES): raise VocabError( f"{name}: {text!r} holds U+{ord(ch):04X}, which the tokenizer " f"strips by default, so no token can equal it") if (ch == INTERPUNCT and 0 < i < len(text) - 1 and _in(text[i - 1], EAST_ASIAN_RANGES) and _in(text[i + 1], EAST_ASIAN_RANGES)): raise VocabError( f"{name}: {text!r} holds an interpunct between two East Asian " f"characters, where the tokenizer divides the name (rules/T3), " f"so no token can equal it") category = unicodedata.category(ch) if (category == "Cf" and ch not in JOINERS) or category == "Cc": # a bidi mark is stripped by default; a zero-width space, word # joiner, BOM or control character is kept but unseen, so the # entry reads as the word without it and matches only text # carrying the same invisible character raise VocabError( f"{name}: {text!r} holds the invisible character " f"U+{ord(ch):04X} ({unicodedata.name(ch, 'a control character')}); " f"remove it") if " " in text: # Lexicon folds each word of a phrase on its own (_title_key), so # 'z. domu' is stored and matched as 'z domu': the entry works, # but the compiled set would hold a spelling Lexicon never keeps. # One key, one spelling, as check 2 asks of every text words = [_normalize(w) for w in text.split(" ")] if " ".join(words) != text: raise VocabError( f"{name}: phrase {text!r} is not in normalized form, which " f"folds each word; write {' '.join(w for w in words if w)!r}") def _load_entry(name: str, e: dict[str, Any], seen: set[str], pool: Pool) -> None: text = e.get("text") if not isinstance(text, str) or not text: raise VocabError(f"{name}: an entry needs a non-empty `text`, got {text!r}") if _normalize(text) != text or " ".join(text.split()) != text: raise VocabError( f"{name}: text {text!r} is not in normalized form; write " f"{' '.join(_normalize(text).split())!r} (lowercase, NFC, no edge " f"periods, single-spaced)") _check_tokenizable(name, text) if text in seen: raise VocabError(f"{name}: {text!r} is listed twice in one file") seen.add(text) where = f"{name}: {text!r}" has_reading = False for key, value in e.items(): if key == "text": continue if key == "excluded": if not isinstance(value, dict) or not value: raise VocabError(f"{where}: excluded must be a table of kinds") for kind, why in value.items(): if kind not in CATEGORY and kind not in CATEGORIES: raise VocabError(f"{where}: excluded names unknown kind {kind!r}") pool.exclusions.append(_exclusion(name, text, kind, None, why)) continue if key not in CATEGORY: if key in DESCRIPTIVE: raise VocabError(f"{where}: {key!r} belongs on a reading, not the word") raise _reserved_or_unknown(where, key, set(CATEGORY) | {"text", "excluded"}) if not isinstance(value, dict): raise VocabError(f"{where}: reading {key!r} must be a table " f"({key} = {{}}), never a bare value") has_reading = True _check_reading(Reading(name, text, key, value), pool) if not has_reading and not any(x.text == text and x.file == name for x in pool.exclusions): raise VocabError(f"{where}: an entry with no reading and no exclusion is inert") def _check_reading(r: Reading, pool: Pool) -> None: where = r.where() p = r.props if r.category == "name": for k in p: if k not in NAME_KEYS: raise _reserved_or_unknown(where, k, NAME_KEYS) for src in COUNT_SOURCES & p.keys(): if not isinstance(p[src], int) or isinstance(p[src], bool) or p[src] < 1: raise VocabError(f"{where}: {src} must be a positive count " f"(absent means below the source's floor)") if COUNT_SOURCES & p.keys() and "as_of" not in p: raise VocabError(f"{where}: a count needs `as_of`, a TOML date") if "as_of" in p and not _is_date(p["as_of"]): raise VocabError(f"{where}: as_of must be a TOML date (2026-10-16), " f"not a string or a date-time") if "as_of" in p and not COUNT_SOURCES & p.keys(): raise VocabError(f"{where}: as_of dates a count, and this reading " f"has none ({', '.join(sorted(COUNT_SOURCES))})") if not COUNT_SOURCES & p.keys() and "note" not in p: # a name reading may stand without a count -- a particle kept # possibly given under C-i's default, with no source counting # it -- but says why, so an unexamined word stays findable raise VocabError(f"{where}: a name reading with no count needs a " f"`note` saying why it is declared") _check_descriptive(where, p) if "same_as" in p: if r.kind != "given": raise VocabError(f"{where}: same_as on a name reading names the " f"given name a word abbreviates, so only a " f"`given` reading takes one") if _normalize(p["same_as"]) != p["same_as"]: raise VocabError(f"{where}: same_as {p['same_as']!r} is not in " f"normalized form; write " f"{_normalize(p['same_as'])!r}") pool.readings.append(r) return stated = STATED.get(r.category, frozenset()) allowed = BEHAVIORAL[r.category] | stated | DESCRIPTIVE | {"excluded"} for k in p: if k in stated and not isinstance(p[k], bool): raise VocabError(f"{where}: {k} must be true or false") if k in DERIVED and not (k in stated and p[k] is False): # build check 11: the marker is derived from a name reading raise VocabError(f"{where}: {k} = {_shown(p[k])} is derived since " f"phase 2; write {DERIVED[k]} instead, with dated " f"counts from tools/name_usage.py or a `note` " f"saying why") if k not in allowed: raise _reserved_or_unknown(where, k, allowed) _check_descriptive(where, p) for flag in ("acronym", "may_be_initial", "after_given"): if flag in p and not isinstance(p[flag], bool): raise VocabError(f"{where}: {flag} must be true or false") if "default" in p and p["default"] is not False: # every reading is on by default unwritten, so the one value # worth writing is the one that turns it off raise VocabError(f"{where}: default = {_shown(p['default'])}; write " f"default = false for a reading a pack turns on, " f"or leave it out") if r.category == "title": addresses = r.addresses if addresses in ("full", "any"): raise VocabError(f"{where}: addresses = {addresses!r} is reserved " f"for a later phase; phase 1 has 'family' and 'given'") if addresses not in ("family", "given"): raise VocabError(f"{where}: addresses must be 'family' or 'given'") if "attaches" in p: # the phrase partner: the title kinds whose words this one # stands before in a title phrase (rules/H9) att = p["attaches"] if (not isinstance(att, list) or not att or not all(isinstance(k, str) for k in att) or len(set(att)) != len(att)): raise VocabError(f"{where}: attaches must be a non-empty " f"list of title kinds, each once") # a title kind, or one title word where a kind would reach # too far ('sergeant' attaching to 'major', not to every # military title); a word is checked against the pool in # check_pool for k in att: if k in CATEGORY and (CATEGORY[k] != "title" or k == "title"): raise VocabError( f"{where}: attaches names {k!r}, which is not a " f"title kind or a title word; the kinds: " f"{sorted(t for t, c in CATEGORY.items() if c == 'title' and t != 'title')}") if r.flag("after_given") and r.kind != "salutation": # a salutation is a form of address, where an office or a # rank is borne as a middle name: the reason the given # part's end may read one as a title (decisions/H8, #835) raise VocabError(f"{where}: after_given is allowed on a " f"salutation reading only, not on {r.kind!r} " f"(rules/H8)") if r.category == "suffix": acronym = r.acronym if r.kind == "agnomen" and acronym: # an agnomen is a word whose writing never speaks (rules/S11); # as an acronym it would compile into the credential class, # its capitals leaning to the credential raise VocabError(f"{where}: an agnomen is a word; acronym = " f"true is refused") if acronym and "." in r.text: # suffix_as_written looks an acronym up with EVERY period # removed, so a dotted entry is never matched -- and it slips # past the per-text checks beside its undotted twin raise VocabError( f"{where}: an acronym is matched with every period removed, " f"so {r.text!r} can never match; write " f"{r.text.replace('.', '')!r} (it reads the dotted spelling too)") if "attached" in p: att = p["attached"] if not isinstance(att, dict): raise VocabError(f"{where}: attached must be a table") for k in att: if k not in ATTACHED_KEYS: raise _reserved_or_unknown(f"{where} attached", k, ATTACHED_KEYS) if att.get("side") != "after": raise VocabError(f"{where}: attached supports only side = 'after' " f"in phase 1 (the #308 peel)") if acronym: raise VocabError(f"{where}: attached needs a word reading " f"(acronym = false); the peel splits word tails") _check_descriptive(f"{where} attached", att) if r.category == "conjunction" and r.flag("may_be_initial"): if len(r.text) != 1 or r.text.upper() == r.text.lower(): raise VocabError( f"{where}: may_be_initial needs a CASED single letter; the " f"fork reads only a cased one-letter token, so this is inert") if "cased" in p: cased = p["cased"] if " " in r.text: # capitalization_exceptions is looked up one word at a time raise VocabError(f"{where}: cased is looked up per word, so a " f"phrase's is never read; put it on each word") if not isinstance(cased, str) or _alnum(_normalize(cased)) != _alnum(r.text): raise VocabError(f"{where}: cased {cased!r} must spell {r.text!r}'s " f"letters and digits, recased (a case mask, #459)") if "excluded" in p: refused = p["excluded"] if not isinstance(refused, dict) or not refused: raise VocabError(f"{where}: excluded must be a table of properties") for prop, why in refused.items(): if prop not in BEHAVIORAL[r.category]: raise VocabError(f"{where}: excluded names {prop!r}, which is not " f"a behavioral property of a {r.category}") # a refused property set on this same reading is caught by # check 6 with the rest of the pool pool.exclusions.append(_exclusion(r.file, r.text, r.kind, prop, why)) # build check 8: a phrase is matched whole only as a given-addressing # title run or a birth marker; anywhere else it can never match if " " in r.text and not (r.kind == "birth_marker" or ( r.category == "title" and r.addresses == "given")): raise VocabError(f"{where}: a phrase can never match as a {r.kind}; " f"only birth_marker and addresses = 'given' titles are " f"matched as a whole run") pool.readings.append(r) def _refused_by(r: Reading, excluded: str) -> bool: """Whether a word-level exclusion of `excluded` refuses reading r. A category name refuses every reading of the category (`excluded = { suffix = ... }`). A kind refuses every reading of that kind, whatever its properties, and also every OTHER kind that compiles into the set the excluded kind would, written with its defaults: refusing `generation` refuses a generic `suffix = {}` and a `status = {}`, either of which would put the word in the very set the recorded reason keeps it out of. In the suffix category `acronym` splits the kinds between two sets. The name kinds compile to nothing and mean opposite things (a word refused as a given name may well be a surname), so there a kind refuses only itself.""" if excluded in CATEGORIES: return r.category == excluded if r.kind == excluded: return True if CATEGORY[excluded] != r.category or r.category == "name": return False return r.category != "suffix" or r.acronym == (excluded == "credential") def _shown(value: object) -> str: """A property value as TOML writes it, for a refusal message.""" if isinstance(value, bool): return "true" if value else "false" if isinstance(value, str): return f'"{value}"' if isinstance(value, dict): return "{ " + ", ".join(f"{k} = {_shown(v)}" for k, v in value.items()) + " }" if isinstance(value, datetime.date): return value.isoformat() return repr(value) def _overrides(r: Reading, x: Exclusion) -> bool: """Whether r gives x's property a value other than the one it takes UNWRITTEN ON A READING OF x's KIND -- what a property exclusion refuses. The baseline is the excluding kind's, not r's: in the suffix category `acronym` is unwritten-true for a credential and unwritten-false for every other kind, so an exclusion of a credential's `acronym` refuses a generic `suffix = {}` (a word, where the exclusion keeps the acronym) and lets `suffix = { acronym = true }` through. Writing the baseline (`after_given = false`) is the same as omitting it.""" assert x.prop is not None baseline = Reading(x.file, x.text, x.kind, {}) return _behavior(r, x.prop) != _behavior(baseline, x.prop) # ------------------------------------------------------------- compiling def check_pool(pool: Pool) -> None: """The cross-file build checks (4-7, 9, 10 last) and the data invariants.""" by_text: dict[str, list[Reading]] = {} for r in pool.readings: by_text.setdefault(r.text, []).append(r) # a reading written `default = false` compiles to nothing, so it # cannot read two ways beside the word's other readings, and no # exclusion refuses it: an exclusion keeps a reading out of the # default, which is where this one already is (checks 4, 5, 6, 9). # It is still a reading: check 3 has held its properties, and 7 and # 11 read it with the rest live: dict[str, list[Reading]] = { t: [r for r in rs if r.on_by_default] for t, rs in by_text.items()} # 6: an excluded kind is absent from the whole pool, and an excluded # property never takes a value other than the one it has unwritten on # a reading of the excluding kind (_overrides) -- # run first, so a contributor adding a refused reading meets the # recorded reason rather than check 4's or 5's complaint about it. # A refused property holds over every kind of its category, the sets # it feeds being sets of WORDS (a second suffix kind would be refused # by check 4 anyway, but without the reason), and `cased`, compiled # once per word whatever the reading, over every reading of the word for x in pool.exclusions: for r in live.get(x.text, ()): if x.prop is None and _refused_by(r, x.kind): raise VocabError(f"{r.where()}: {x.kind} is excluded for " f"{x.text!r} in {x.file}: {x.reason}") if (x.prop is not None and (r.category == CATEGORY[x.kind] or x.prop == "cased") and _overrides(r, x)): # the value r actually takes, written or not: the refused one value = r.props[x.prop] if x.prop in r.props else _behavior(r, x.prop) raise VocabError( f"{r.where()}: {x.prop} = {_shown(value)} is excluded for " f"{x.text!r} in {x.file}: {x.reason}") # 4: one suffix kind per word, across files for text, rs in live.items(): kinds = sorted({r.kind for r in rs if r.category == "suffix"}) if len(kinds) > 1: raise VocabError(f"{text!r}: one suffix kind per word, got {kinds} " f"({', '.join(sorted({r.file for r in rs}))})") # 5: the same category of the same word agrees on every behavioral # property in every file -- the default loads them all, so a word # cannot read two ways (per-language behavior is phase 3's). Keyed # on the CATEGORY, not the kind: the sets are sets of words, and a # particle may take a different kind in each language (`de`, a # preposition in French and an article in Dutch), and two kinds # disagreeing on a property would compile silently to one of them for text, rs in live.items(): first: dict[str, Reading] = {} for r in rs: if r.category == "name": continue seen = first.setdefault(r.category, r) for prop in BEHAVIORAL[r.category]: a, b = _behavior(seen, prop), _behavior(r, prop) if a != b: raise VocabError( f"{text!r} {r.kind}: {prop} is {a!r} in {seen.file} " f"but {b!r} in {r.file}; one word reads one way") # 7: same_as names another word of the same category; one cased # per word for r in pool.readings: target = r.props.get("same_as") if target == r.text: raise VocabError(f"{r.where()}: same_as names the word itself; it " f"names the word this one is a spelling of") # a given reading's same_as is the full name it abbreviates, and # a name is no word of the vocabulary if target is not None and r.category != "name" and not any( t.category == r.category for t in by_text.get(target, ())): raise VocabError(f"{r.where()}: same_as {target!r} has no " f"{r.category} reading") for text, rs in by_text.items(): masks = {r.props["cased"] for r in rs if "cased" in r.props} if len(masks) > 1: raise VocabError(f"{text!r}: one cased mask per word, got {sorted(masks)}") # 11, the pool's half: a particle reading states may_be_given = false # exactly where its word has no given reading in any file. Stated, # never defaulted: the old encoding listed the never-given particles, # so a new one was ambiguous unless argued otherwise # (decisions/vocabulary-collisions C-i), and a default here would # silently pick one side of that; a false beside a given reading # would say the opposite of the marker it derives given = {t for t, rs in by_text.items() if any(r.kind == "given" for r in rs)} for r in pool.readings: if r.category != "particle": continue if r.text in given and "may_be_given" in r.props: raise VocabError(f"{r.where()}: may_be_given = false contradicts " f"the word's given reading, which derives it; " f"drop the property") if r.text not in given and "may_be_given" not in r.props: raise VocabError(f"{r.where()}: a particle states may_be_given = " f"false, or its word carries a given reading; " f"under uncertainty, the given reading " f"(vocabulary-collisions C-i)") # 11, the agnomen's half: an agnomen is gated by the count alone, and # the gate is derived from a name reading, so one without a name # reading would read as the suffix wherever it ends a name, taking # the family name from every two-word name ('Maria Filha') named = {t for t, rs in by_text.items() if any(r.category == "name" for r in rs)} for r in pool.readings: if r.kind == "agnomen" and r.text not in named: raise VocabError(f"{r.where()}: an agnomen needs a `given` or " f"`family` reading on its word, which gates it " f"by the count (rules/S11); with dated counts " f"from tools/name_usage.py or a `note` saying why") # 11, the other suffix words: the third marker reads only a # generation's and an agnomen's clash with a name (GATED_WORDS), so a # name reading beside another word kind would mark nothing and leave # the word an ungated suffix, taking the family name from every # two-word name it ends; refused rather than compiled to nothing for r in pool.readings: if (r.category == "suffix" and not r.acronym and r.on_by_default and r.kind not in GATED_WORDS and r.text in named): raise VocabError(f"{r.where()}: a name reading beside this " f"reading gates nothing, the gate reading only " f"{' and '.join(sorted(GATED_WORDS))} readings " f"(rules/S11); classify the word as one, or " f"keep the counts in this reading's `note`") # the words `attaches` names: each a word with a live title reading, # else the phrase it asks for can never be made title_words = {t for t, rs in live.items() if any(r.category == "title" for r in rs)} for r in pool.readings: for k in r.props.get("attaches", ()) if r.category == "title" else (): if k not in CATEGORY and k not in title_words: raise VocabError(f"{r.where()}: attaches names {k!r}, which " f"is no title kind and no title word") # 9: a bound given name that is also a particle must be one that may # be given -- the bound join reads it as a given name. Lexicon raises # on the same contradiction; this says which file and word for text, rs in live.items(): if any(r.kind == "bound_given" for r in rs) and text not in given: for r in rs: if r.category == "particle": raise VocabError(f"{r.where()}: also a bound given name, so " f"the word needs a given reading") # an elided particle reads only the apostrophe it is spelled with, # nothing folding U+2019 to U+0027, so both spellings ship (#700) particles = {r.text for r in pool.readings if r.category == "particle"} for text in sorted(particles): for a, b in (("’", "'"), ("'", "’")): if a in text and text.replace(a, b) not in particles: raise VocabError(f"{text!r}: an elided particle needs its ASCII " f"and its U+2019 spelling; add " f"{text.replace(a, b)!r}") # 10: no same_as loop, and each file but und.toml in word order -- # last, so every other refusal is seen first check_order(pool.entries) # soft: a name reading compiles to nothing, so one on a word with no # vocabulary reading and no exclusion anywhere -- an exclusion's # evidence is what a name reading usually is -- informs nothing excluded_texts = {x.text for x in pool.exclusions if x.prop is None} for text, rs in by_text.items(): # an abbreviated given name compiles (GIVEN_NAME_ABBREVIATIONS) if (all(r.category == "name" and "same_as" not in r.props for r in rs) and text not in excluded_texts): warnings.warn(f"{rs[0].where()}: a name reading on a word with no " f"vocabulary reading is inert", VocabWarning, stacklevel=2) def _behavior(r: Reading, prop: str) -> object: if prop == "acronym": return r.acronym if prop == "addresses": return r.addresses if prop == "attached": att = r.props.get("attached") return None if att is None else att.get("side") if prop == "may_be_initial" or prop == "after_given": return r.flag(prop) if prop == "attaches": att = r.props.get("attaches") return None if att is None else frozenset(att) if prop == "default": return r.on_by_default return r.props.get(prop) #: The constants _compiled.py defines, in its order, each with the #: nameparser.config module that re-exports it. CONSTANTS = ( ("GIVEN_NAME_TITLES", "titles"), ("TITLES", "titles"), ("AFTER_GIVEN_TITLES", "titles"), ("TITLES_AMBIGUOUS", "titles"), ("TITLE_MODIFIERS", "titles"), ("TITLE_STYLES", "titles"), ("TITLE_PHRASES", "titles"), ("NON_GIVEN_NAME_PARTICLES", "particles"), ("PARTICLES", "particles"), ("CONJUNCTIONS", "conjunctions"), ("CONJUNCTIONS_AMBIGUOUS", "conjunctions"), ("BOUND_GIVEN_NAMES", "bound_given_names"), ("GIVEN_NAME_ABBREVIATIONS", "given_names"), ("MAIDEN_MARKERS", "maiden_markers"), ("SUFFIX_WORDS", "suffixes"), ("GLUED_HONORIFICS", "suffixes"), ("SUFFIX_WORDS_AMBIGUOUS", "suffixes"), ("AGNOMENS", "suffixes"), ("SUFFIX_ACRONYMS_AMBIGUOUS", "suffixes"), ("SUFFIX_ACRONYMS", "suffixes"), ("CAPITALIZATION_EXCEPTIONS", "capitalization"), ) class Compiled(TypedDict): """What compile_pool returns: one key per constant, in CONSTANTS order, typed as _compiled.py declares it.""" GIVEN_NAME_TITLES: frozenset[str] TITLES: frozenset[str] AFTER_GIVEN_TITLES: frozenset[str] TITLES_AMBIGUOUS: frozenset[str] TITLE_MODIFIERS: frozenset[str] TITLE_STYLES: frozenset[str] TITLE_PHRASES: frozenset[str] NON_GIVEN_NAME_PARTICLES: frozenset[str] PARTICLES: frozenset[str] CONJUNCTIONS: frozenset[str] CONJUNCTIONS_AMBIGUOUS: frozenset[str] BOUND_GIVEN_NAMES: frozenset[str] GIVEN_NAME_ABBREVIATIONS: frozenset[str] MAIDEN_MARKERS: frozenset[str] SUFFIX_WORDS: frozenset[str] GLUED_HONORIFICS: frozenset[str] SUFFIX_WORDS_AMBIGUOUS: frozenset[str] AGNOMENS: frozenset[str] SUFFIX_ACRONYMS_AMBIGUOUS: frozenset[str] SUFFIX_ACRONYMS: frozenset[str] CAPITALIZATION_EXCEPTIONS: dict[str, str] # the one tie between the two: mypy holds compile_pool to Compiled's # keys, and this holds Compiled's keys to the roster render writes assert tuple(Compiled.__annotations__) == tuple(name for name, _ in CONSTANTS), ( "Compiled and CONSTANTS list different constants") def compile_pool(pool: Pool) -> Compiled: """Fold the readings into today's constants.""" check_pool(pool) # the two derived markers (check 11): a clash with a name is a word's # name reading beside the reading it clashes with named = {r.text for r in pool.readings if r.category == "name"} given = {r.text for r in pool.readings if r.kind == "given"} titles: set[str] = set() given_titles: set[str] = set() after_given: set[str] = set() title_kinds: dict[str, set[str]] = {} attaches: dict[str, set[str]] = {} particles: set[str] = set() never_given: set[str] = set() conjunctions: set[str] = set() initial_conjunctions: set[str] = set() bound: set[str] = set() abbreviations: set[str] = set() markers: set[str] = set() words: set[str] = set() glued: set[str] = set() ambiguous_words: set[str] = set() agnomens: set[str] = set() ambiguous: set[str] = set() acronyms: set[str] = set() cased: dict[str, str] = {} for r in pool.readings: t, p = r.text, r.props if not r.on_by_default: continue if "cased" in p: cased[t] = p["cased"] if r.category == "title": titles.add(t) if r.addresses == "given": given_titles.add(t) if r.flag("after_given"): after_given.add(t) title_kinds.setdefault(t, set()).add(r.kind) attaches.setdefault(t, set()).update(p.get("attaches", ())) elif r.category == "suffix": if r.acronym: acronyms.add(t) if t in named: ambiguous.add(t) else: words.add(t) if "attached" in p: glued.add(t) if t in named and r.kind in GATED_WORDS: ambiguous_words.add(t) if r.kind == "agnomen": agnomens.add(t) elif r.category == "particle": particles.add(t) if t not in given: never_given.add(t) elif r.category == "conjunction": conjunctions.add(t) if r.flag("may_be_initial"): initial_conjunctions.add(t) elif r.category == "bound_given": bound.add(t) elif r.category == "relation": markers.add(t) elif r.kind == "given" and "same_as" in p: abbreviations.add(t) contested = titles & given - particles out = Compiled( GIVEN_NAME_TITLES=frozenset(given_titles), # a title that may be a given name compiles into TITLES_AMBIGUOUS # INSTEAD: a word in TITLES is a title wherever it stands, which # is what a caller listing one there has always asked for TITLES=frozenset(titles - contested), AFTER_GIVEN_TITLES=frozenset(after_given), # the fourth derived marker (check 11): a title that may be a # given name, a title only inside a title phrase (rules/H9), and # so not in TITLES above. A # title that is also a particle ('st', 'freiherr') is left to the # particle rules, which weigh its given reading at the front # already (rules/P4) TITLES_AMBIGUOUS=frozenset(contested), # a word every title reading of which is a modifier: a title only # with a head title behind it in its run (decision 10, rules/H9) TITLE_MODIFIERS=frozenset( t for t, ks in title_kinds.items() if ks == {"modifier"}), TITLE_STYLES=frozenset( t for t, ks in title_kinds.items() if "style" in ks), # `attaches` resolved to the two-word phrases it licenses, the # word before and each title word of a kind it names TITLE_PHRASES=frozenset( f"{t} {h}" for t, kinds in attaches.items() if kinds for h, hk in title_kinds.items() if h != t and (hk & kinds or h in kinds)), NON_GIVEN_NAME_PARTICLES=frozenset(never_given), PARTICLES=frozenset(particles), CONJUNCTIONS=frozenset(conjunctions), CONJUNCTIONS_AMBIGUOUS=frozenset(initial_conjunctions), BOUND_GIVEN_NAMES=frozenset(bound), # a given name written short (`chas`, Filipino `ma`): opening the # given part period-marked, it is the given name, which outranks # rules/H2's shape (#789) GIVEN_NAME_ABBREVIATIONS=frozenset(abbreviations), MAIDEN_MARKERS=frozenset(markers), SUFFIX_WORDS=frozenset(words), GLUED_HONORIFICS=frozenset(glued), SUFFIX_WORDS_AMBIGUOUS=frozenset(ambiguous_words), AGNOMENS=frozenset(agnomens), SUFFIX_ACRONYMS_AMBIGUOUS=frozenset(ambiguous), SUFFIX_ACRONYMS=frozenset(acronyms), CAPITALIZATION_EXCEPTIONS=dict(sorted(cased.items())), ) # One form per post-nominal: the word test strips only edge periods # and the acronym test strips all of them, so a word in both is # matched twice by two rules, and which one fired is unreadable from # outside. The sharper stake is the ambiguous set's: suffix_as_written # ORs the two branches, so an ambiguous acronym that were also a word # would be claimed through the word and bypass the period gate the # marking exists to impose. The single overlap was 'esq', dropped # 2026-09-08 (decisions/suffix-acronym-collisions). Lexicon guards # only that ambiguous half (its gate-bypass raise), so a caller may # still put an unambiguous word in both. In the TOML the overlap is # unreachable -- check 4 refuses two suffix kinds on one word, check 5 # one kind with `acronym` differing between files, and check 2 a word # twice in one file -- so this is a backstop kept because it costs one # set intersection, not a check any input reaches today. both = out["SUFFIX_ACRONYMS"] & out["SUFFIX_WORDS"] if both: raise VocabError(f"{sorted(both)}: a post-nominal is an acronym or a " f"word, never both (check `acronym` in every file)") return out def read_dir(path: Path = VOCAB) -> dict[str, str]: """Every file in the vocabulary folder, by name. Every entry but a dotfile and the two by-name skips (README.md, the folder's reference, and AGENTS.md, its agent notes) is read, so a misnamed one (EN.TOML, en.toml.orig, readme.md, agents.md) reaches check 1's file-name check instead of being skipped, and a missing or empty folder is an error rather than an empty vocabulary.""" if not path.is_dir(): raise VocabError(f"{path}: no vocabulary folder there") files = {} for f in sorted(path.iterdir()): if f.name.startswith(".") or f.name in SKIPPED: continue if f.is_symlink() and not f.exists(): raise VocabError(f"{f.name}: a link to nothing ({f.readlink()})") if not f.is_file(): raise VocabError(f"{f.name}: the vocabulary folder holds no subfolders; " f"a file there would not be read") try: files[f.name] = f.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError) as e: raise VocabError(f"{f.name}: cannot be read as UTF-8 text: {e}") from None if not files: raise VocabError(f"{path}: no vocabulary files; compiling would write an " f"empty vocabulary") return files def compile_files(files: Mapping[str, str]) -> Compiled: return compile_pool(load(files)) # --------------------------------------------------------------- output HEADER = f'''"""The compiled vocabulary. GENERATED from nameparser/vocab/*.toml by tools/vocab/build.py -- do not edit; edit the TOML and run {REFRESH} The public names are re-exported, with their documentation, by the nameparser.config modules (titles, particles, suffixes, ...). """ ''' def render(compiled: Compiled) -> str: """One sorted entry per line, so a one-word change is a one-line diff.""" out = [HEADER] # a TypedDict is read by literal key; the roster loop reads it as # the Mapping it also is values: Mapping[str, object] = compiled for name, _ in CONSTANTS: v = values[name] out.append("") if isinstance(v, dict): out.append(f"{name}: dict[str, str] = {{") out += [f" {k!r}: {v[k]!r}," for k in sorted(v)] out.append("}") else: assert isinstance(v, frozenset), (name, type(v)) out.append(f"{name}: frozenset[str] = frozenset({{") out += [f" {w!r}," for w in sorted(v)] out.append("})") return "\n".join(out) + "\n" def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) ap.add_argument("--check", action="store_true", help="exit 1 if the committed module is stale " "(2 if the vocabulary fails a check, 3 on an " "internal error)") args = ap.parse_args(argv) try: return _run(check=args.check) except (VocabError, VocabWarning) as e: # exit 2, not 1: a script must tell broken data from a stale module # (a VocabWarning arrives here raised, under -W error) print(f"vocabulary error: {e}", file=sys.stderr) return 2 except Exception: # a bug in the compiler, or a module it could not write: still # never exit 1 traceback.print_exc() return 3 def _run(*, check: bool) -> int: text = render(compile_files(read_dir())) try: current: str | None = OUTPUT.read_text(encoding="utf-8") if OUTPUT.exists() else "" except (OSError, UnicodeDecodeError): # an unreadable module is stale in the one sense that matters: # rebuilding replaces it current = None name = OUTPUT.relative_to(ROOT) if check: if current != text: state = "stale" if current is not None else "unreadable" print(f"{name} is {state}; run: {REFRESH}") return 1 print(f"{name} is current") return 0 if current == text: print(f"{name} is current") else: OUTPUT.write_text(text, encoding="utf-8") print(f"wrote {name}") return 0 if __name__ == "__main__": sys.exit(main()) ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/vocab/query.py����������������������������������������������0000664�0000000�0000000�00000016015�15273032717�0022663�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Query the per-word vocabulary in nameparser/vocab/*.toml. A decisions/ entry points at its words with a query instead of a list, so the pointer cannot drift from the data:: uv run python tools/vocab/query.py --issue 661 uv run python tools/vocab/query.py --kind honorific uv run python tools/vocab/query.py --excluded generation uv run python tools/vocab/query.py --word 殿 Filters combine (AND). Each match prints as ``file text kind`` and the reading's properties; an exclusion prints as ``excluded:<kind>``, or ``<kind> excluded:<property>`` for a property refused on a reading the word keeps, with its reason. - ``--kind`` matches a kind or every kind of a category (``--kind suffix``). Alone it lists readings; beside ``--excluded`` it narrows the exclusions to that kind (``--kind honorific --excluded attached``, the honorifics refused the glued peel). - ``--excluded`` matches a refused reading by its kind or category (``--excluded generation``), or a property refused on a reading the word keeps by the property's name (``--excluded attached``), never both. - ``--issue`` matches an issue cited by a reading, by its ``attached`` table, or by an exclusion. - ``--word`` is normalized as the vocabulary is (``--word Dr.`` finds ``dr``); ``--file`` takes a tag (``en``) or a file name (``en.toml``). ``--count`` prints only the number of matches. Exits 1 when nothing matches, 2 for an unknown kind, property or file, an empty ``--word``, or vocabulary that cannot be read or fails a per-entry check, and 3 on an internal error. The query reads the files as loaded, with the per-entry checks only: it answers over a pool whose cross-file checks may fail, so a contributor can look words up in a tree that does not compile yet. """ from __future__ import annotations import argparse import sys import traceback from dataclasses import dataclass from pathlib import Path from typing import Any # query.py runs as a script, so it imports its sibling by path; a test # loading both modules by file gets two copies of build's classes sys.path.insert(0, str(Path(__file__).resolve().parent)) import build # noqa: E402 @dataclass(frozen=True) class Row: """A reading, or an exclusion: of a reading (`prop` None) or of a property of the `kind` reading the word keeps.""" file: str text: str kind: str excluded: bool prop: str | None props: dict[str, Any] def __post_init__(self) -> None: # a reading refuses no property assert self.excluded or self.prop is None, self @property def label(self) -> str: """How the row prints; select reads the fields, never this.""" if not self.excluded: return self.kind if self.prop is None: return f"excluded:{self.kind}" return f"{self.kind} excluded:{self.prop}" def issues(self) -> set[int]: found: set[int] = set() for table in (self.props, self.props.get("attached")): if isinstance(table, dict): v = table.get("issue", []) found |= set(v if isinstance(v, list) else [v]) return found def rows(pool: build.Pool) -> list[Row]: out = [Row(r.file, r.text, r.kind, False, None, r.props) for r in pool.readings] out += [Row(x.file, x.text, x.kind, True, x.prop, x.props) for x in pool.exclusions] return out def _is(kind: str, wanted: str) -> bool: return kind == wanted or build.CATEGORY.get(kind, kind) == wanted def select(pool: build.Pool, *, issue: int | None = None, kind: str | None = None, excluded: str | None = None, word: str | None = None, file: str | None = None) -> list[Row]: found = [] for row in rows(pool): if issue is not None and issue not in row.issues(): continue if kind is not None and not _is(row.kind, kind): continue # --kind without --excluded lists readings; beside it, it narrows # the exclusions if kind is not None and excluded is None and row.excluded: continue # a refused reading matches its kind or category; a refused # PROPERTY matches only the property's name, so `--excluded # honorific` is the words refused the honorific reading, not the # honorifics refused the glued peel if excluded is not None and not (row.excluded and ( (row.prop is None and _is(row.kind, excluded)) or row.prop == excluded)): continue if word is not None and row.text != word: continue if file is not None and row.file != file: continue found.append(row) return found KINDS = frozenset(build.CATEGORY) | build.CATEGORIES PROPERTIES = frozenset().union(*build.BEHAVIORAL.values()) def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) ap.add_argument("--issue", type=int, help="readings or exclusions citing this issue") ap.add_argument("--kind", help="readings of this kind, or of every kind of this category") ap.add_argument("--excluded", metavar="KIND_OR_PROPERTY", help="exclusions of this kind or category, or of this " "property refused on a reading the word keeps") ap.add_argument("--word", help="every reading and exclusion of this text") ap.add_argument("--file", help="only this file, by tag (en) or name (en.toml)") ap.add_argument("--count", action="store_true", help="print the number of matches only") args = ap.parse_args(argv) if args.kind is not None and args.kind not in KINDS: ap.error(f"unknown kind {args.kind!r}; known: {', '.join(sorted(KINDS))}") if args.excluded is not None and args.excluded not in KINDS | PROPERTIES: ap.error(f"unknown kind or property {args.excluded!r}; known: " f"{', '.join(sorted(KINDS | PROPERTIES))}") try: return _run(ap, args) except build.VocabError as e: print(f"vocabulary error: {e}", file=sys.stderr) return 2 except Exception: # never exit 1 on an error: 1 means "nothing matches" traceback.print_exc() return 3 def _run(ap: argparse.ArgumentParser, args: argparse.Namespace) -> int: word = None if args.word is None else " ".join(build._normalize(args.word).split()) if word == "": ap.error(f"--word {args.word!r} normalizes to nothing") files = build.read_dir() file = args.file if file is not None: file = file if file.endswith(".toml") else f"{file}.toml" if file not in files: ap.error(f"no vocabulary file {file!r}") found = select(build.load(files), issue=args.issue, kind=args.kind, excluded=args.excluded, word=word, file=file) if args.count: print(len(found)) else: for row in found: shown = ", ".join(f"{k}={v!r}" for k, v in row.props.items()) print(f"{row.file:10} {row.text:14} {row.label:24} {shown}") return 0 if found else 1 if __name__ == "__main__": sys.exit(main()) �������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/vocab/word_report.py����������������������������������������0000664�0000000�0000000�00000067363�15273032717�0024100�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""What a vocabulary word does: a shape grid, its incidence in the local corpora, its usage counts. The per-word measurements a title that is also a given name needs before it moves to the title phrase (rules/H9, #752 phase 2j, #913), which each batch used to write as throwaway probes: uv run python tools/vocab/word_report.py queen deacon --base ../master-checkout uv run python tools/vocab/word_report.py --rank marquis queen deacon bishop Each word gets three sections, and a ranking closes the report: - **grid** -- the word in each of SHAPES (made-up names around it: a head, behind a title or a family comma, after a full name and a comma, past a second comma, trailing with a period, inside a title phrase), each spelled three ways (CASES: as written, upper case, lower case) and read under all three name orders: the fields and the ambiguity kinds, folded: a shape's three case spellings into one block where they read alike once lowercased, a text's orders into one `all` line where they read alike. With --base only what the two trees read differently is printed, base and head, folded the same way, so an order-specific move prints for its order alone. - **corpora** -- in every fetched local corpus (local_corpora.py), the names that open with the word and the names whose part after the first comma does, each counted by how the tree reads the word there: `title`, `given` or `other` (a family name, a middle name, a suffix). With --base a count the two trees read differently is printed as base -> head. In a corpus that keeps the filer's boxes, the names opening with the word are also counted by the box it was typed into. Counts only: a local corpus is never quoted. The report says which corpora it read and which are not fetched, and a damaged one ends the run, so that nothing unread passes for a zero. A name holding the word at both positions (`Queen Lau, Queen`) is classed once for both, by its title. - **usage** -- tools/name_usage.py's row for the word, offline (--no-wikidata). The ranking separates what a move to the title phrase would give back from what it would cost. For each word it counts the corpus names that read it as the title where it stands in a given name's place, opening the name or opening the part after the name's first comma (a register's LAU, BARON, though any comma counts): `Baron Davis` reads title 'Baron' and no given name, `Sir Lau` title 'Sir', given 'Lau'. That count holds real titles too (Sir, Lady), so beside it stand the names opening with the word in a corpus that keeps the filer's boxes (psc-individuals), by the box the word was typed into: the title box, a real title, which the move would cost, and the forename box, a given name the title reading takes, which the move would give back (#912's 13 sheikh filings in the title box were the 13 agreement records it moved away from agreeing), the forename box printed beside their sum (`28/34`). The words are ordered by the forename box's count, what a move gives back, with the title box beside it as the cost, then by the forename box's share and by titles; a share alone would put a word three filers typed as a forename above one 164 did, and a count cannot be rebuilt from a share. That is the triage #913 asks for. It needs no change in the tree, so it runs before a batch is cut; --rank prints it alone. Each tree is read in a child process of its own, which refuses a nameparser imported from anywhere but that tree's source package (snapshot.check_source): one process imports one nameparser, and a comparison of a tree with itself reports no moves. """ from __future__ import annotations import argparse import json import multiprocessing import os import re import subprocess import sys from collections import Counter from collections.abc import Sequence from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor from pathlib import Path from typing import Any HERE = Path(__file__).resolve().parent ROOT = HERE.parents[1] DIFFERENTIAL = ROOT / "tools" / "differential" # The differential tools (snapshot's child and tree checks, the local # corpora), and this folder for a worker unpickling a function by its # module's bare name; appended, as corpus_build's docstring says why. for _folder in (str(DIFFERENTIAL), str(HERE)): if _folder not in sys.path: sys.path.append(_folder) import local_corpora # noqa: E402 import snapshot # noqa: E402 #: The H9 shape grid: `{W}` is the word. Each says what it tests. SHAPES = ( "{W} Lau", # the head, before a surname "{W}", # alone "{W}. Lau", # the head with a period (H2's shape) "{W} Lau Jr.", # the head, before a suffix "{W} Lau, MD", # the head, before a credential "{W} and Mary Lau", # the head of a joined pair "Mr {W} Lau", # behind a title "Mr. and Mrs. {W} Lau", # behind a joined title pair "Dr {W} Lau", # behind a professional title "His Royal Highness {W} Ali", # behind a style "Vice {W} Lau", # behind a modifier "{W} of Kuwait Ali", # the word's own title phrase "Ana {W}", # the surname slot "Ana Maria {W}", # the surname behind two given names "John Smith {W}.", # trailing, with a period (H5) "Lau, {W}", # after a family comma "Lau, Mr {W}", # behind a title after a family comma "Lau, {W} Mr", # before a trailing salutation (H8) "Smith, John {W}", # ending the given part ", {W}", # nothing before the comma (C15) "(Bob), {W}", # a nickname alone before the comma "John Smith, {W}", # after a full name and a comma "John Smith, Jr., {W}", # past a second comma (C2) "Muhammad Ahmad, the {W}", # the epithet with its article "{W} Commander Lau", # before a title word ) #: How each shape is spelled: the whole text as written, upper-cased #: (a register's LAU, KING) and lower-cased. CASES = {"as written": str, "upper": str.upper, "lower": str.lower} #: The name orders each spelling is read under: None is the default. ORDERS = (None, "FAMILY_FIRST", "FAMILY_FIRST_GIVEN_LAST") _ORDER_LABEL = {None: "GF", "FAMILY_FIRST": "FF", "FAMILY_FIRST_GIVEN_LAST": "FG"} #: Where a corpus name holds the word: opening the name, or opening the #: part after its first comma (a register's LAU, KING). POSITIONS = ("opens", "after a comma") #: How the tree reads the word at that position, in report order. READINGS = ("title", "given", "other") #: The reading the ranking counts. RANKED = "title" #: The filer's boxes a name opening with the word may have typed it #: into, where a source keeps them (psc-individuals): a word filed as #: a forename is a given name the title reading takes, a word filed as #: a title is a title, which a move to the title phrase would cost. BOXES = ("title", "forename") #: scan()'s note for a corpus that was never fetched; any other note is #: damage, which ends the run rather than counting as nothing. NOT_FETCHED = "not fetched" Item = tuple[str, "str | None"] # -- reading, in a child process per tree ---------------------------------------- def _parse_chunk(items: Sequence[Item]) -> tuple[str, list[dict[str, Any]]]: """The nameparser this process imported, and each item's reading: the fields that hold something, and the ambiguity kinds.""" import nameparser from nameparser import Parser, Policy parsers: dict[str | None, Any] = {None: Parser()} rows = [] for text, order in items: if order not in parsers: parsers[order] = Parser(policy=Policy(name_order=getattr(nameparser, order))) parsed = parsers[order].parse(text) rows.append({"fields": {k: v for k, v in parsed.as_dict().items() if v}, "ambiguities": sorted({a.kind.name for a in parsed.ambiguities})}) return nameparser.__file__, rows def _child(tree: str, jobs: int) -> int: """The child's side: items as JSON on stdin, readings as JSON on stdout, read with `tree`'s package, which check_source holds the import to.""" root = Path(tree).resolve() sys.path.insert(0, str(root)) import nameparser snapshot.check_source(nameparser.__file__, root) items = [(text, order) for text, order in json.load(sys.stdin)] rows = snapshot.parallel(_parse_chunk, items, jobs, module="word_report") json.dump({"file": nameparser.__file__, "rows": rows}, sys.stdout) return 0 def read_trees(roots: Sequence[Path], items: Sequence[Item], jobs: int, ) -> list[list[dict[str, Any]]]: """Each tree's reading of every item, in a child process per tree, run at once. A child that fails, or that reports a nameparser from outside its tree's source package, ends the run: its readings would be another tree's.""" body = json.dumps(list(items)) share = max(1, jobs // len(roots)) children = [subprocess.Popen( [sys.executable, str(HERE / "word_report.py"), "--child", str(root), "--jobs", str(share)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=snapshot.child_env()) for root in roots] # Each child is fed and drained from a thread of its own: fed in turn, # the second would wait on its stdin until the first had finished. with ThreadPoolExecutor(len(children)) as feed: answers = list(feed.map(lambda child: child.communicate(body), children)) results = [] for root, child, (out, err) in zip(roots, children, answers, strict=True): if child.returncode: raise SystemExit(f"reading with {root} failed:\n{err}") answer = json.loads(out) snapshot.check_source(answer["file"], root) if len(answer["rows"]) != len(items): raise SystemExit(f"{root}'s child read {len(answer['rows'])} of " f"{len(items)} texts") results.append(answer["rows"]) return results # -- the grid ---------------------------------------------------------------------- def grid_items(word: str) -> list[Item]: """Every distinct spelling of every shape, under every order, the word written with a capital as a name is.""" written = word[:1].upper() + word[1:] texts = dict.fromkeys(spell(shape.format(W=written)) for shape in SHAPES for spell in CASES.values()) return [(text, order) for text in texts for order in ORDERS] def render(row: dict[str, Any]) -> str: fields = " ".join(f"{k}={v!r}" for k, v in row["fields"].items()) kinds = f" [{', '.join(row['ambiguities'])}]" if row["ambiguities"] else "" return (fields or "(empty)") + kinds def format_grid(items: Sequence[Item], head: Sequence[dict[str, Any]], base: Sequence[dict[str, Any]] | None = None) -> list[str]: """The grid, folded twice. A shape's case spellings fold into one block, labelled by the first and `(3 cases)`, where every order reads them alike once lowercased (`QUEEN LAU` reads as `Queen Lau` does); and a text's orders fold into one line, labelled `all`, where every order reads it alike. With `base`, only what the trees read differently is printed, each as a base line and a head line, folded the same way: an order-specific move prints for its order alone.""" rows: dict[str, list[tuple[str | None, dict[str, Any], dict[str, Any]]]] = {} for (text, order), now, was in zip(items, head, base or head, strict=True): rows.setdefault(text, []).append((order, now, was)) shapes: dict[str, list[str]] = {} for text in rows: shapes.setdefault(text.lower(), []).append(text) def lowered(text: str) -> list[tuple[str, str]]: return [(render(now).lower(), render(was).lower()) for _, now, was in rows[text]] lines = [] for texts in shapes.values(): folded = all(lowered(t) == lowered(texts[0]) for t in texts) for text in texts[:1] if folded else texts: label = f"{text!r}" + (f" ({len(texts)} cases)" if folded and len(texts) > 1 else "") lines += _text_lines(label, rows[text], base is not None) return lines def _text_lines(label: str, rows: list[tuple[str | None, dict[str, Any], dict[str, Any]]], compared: bool) -> list[str]: """One text's lines: one `all` line (or base and head pair) where every order reads it alike, else a line (or pair) per order; with `compared`, only the orders the trees read differently.""" pad = f" {'':<42} {'':<3}" if not compared: if all(now == rows[0][1] for _, now, _ in rows): return [f" {label:<42} all {render(rows[0][1])}"] return [f" {label:<42} {_ORDER_LABEL[o]:<3} {render(now)}" for o, now, _ in rows] moved = [(o, now, was) for o, now, was in rows if now != was] if not moved: return [] if len(moved) == len(rows) and all((n, w) == moved[0][1:] for _, n, w in moved): return [f" {label:<42} all base {render(moved[0][2])}", f"{pad} head {render(moved[0][1])}"] return [line for o, now, was in moved for line in (f" {label:<42} {_ORDER_LABEL[o]:<3} base {render(was)}", f"{pad} head {render(now)}")] # -- the corpora ------------------------------------------------------------------- def positions(name: str, keys: frozenset[str]) -> list[tuple[str, str]]: """(the word's key, the position) for each of `keys` that opens `name` or opens the part after its first comma.""" found = [] first = name.split(None, 1) if first and (key := local_corpora.word_key(first[0])) in keys: found.append((key, POSITIONS[0])) if "," in name: after = name.split(",", 1)[1].split(None, 1) if after and (key := local_corpora.word_key(after[0])) in keys: found.append((key, POSITIONS[1])) return found def filed_box(record: dict[str, str], key: str) -> str | None: """Which of BOXES the filer typed `key` into as the box's first word, for a record that carries its boxes (psc-individuals); None where neither box opens with it or the record has no boxes.""" for box in BOXES: words = record.get(box, "").split(None, 1) if words and local_corpora.word_key(words[0]) == key: return box return None def scan(source: str, keys: frozenset[str]) -> dict[str, Any]: """`source`'s names holding one of `keys` at a position, by key and position, and for a source whose records carry the filer's boxes, the box each name opening with a key was typed into, by key; or a note where the corpus is not fetched or is damaged. Names travel only between this tool's processes, never to a file.""" try: local = local_corpora.load(source) if local is None: return {"note": NOT_FETCHED} found: dict[str, dict[str, list[str]]] = {} boxes: dict[str, Counter[str]] = {} for record in local.records: for key, at in positions(record["name"], keys): found.setdefault(key, {}).setdefault(at, []).append(record["name"]) if at == POSITIONS[0] and (box := filed_box(record, key)): boxes.setdefault(key, Counter())[box] += 1 except local_corpora.CorpusDamaged as exc: return {"note": f"damaged: {exc}"} return {"found": found, "boxes": boxes, "records": local.size} def coverage(scanned: dict[str, dict[str, Any]]) -> str: """Which local corpora the counts read, and which they could not: a corpus that is not fetched counts nothing, and a zero beside it is no zero at all.""" read = [s for s, got in scanned.items() if "found" in got] missing = [s for s, got in scanned.items() if "found" not in got] if not read: return (f"read no local corpus: none of {len(scanned)} is fetched, so every " f"count below is empty, not zero (local_corpora.py fetch SOURCE)") return (f"read {len(read)} of {len(scanned)} local corpora" + (f"; not fetched: {', '.join(missing)}" if missing else "")) def scan_all(keys: frozenset[str], jobs: int) -> dict[str, dict[str, Any]]: """scan() of every local corpus, one worker process each: reading a corpus's records is most of the time, and they are independent.""" sources = sorted(local_corpora.SOURCES) if jobs <= 1: return {s: scan(s, keys) for s in sources} import importlib by_name = importlib.import_module("word_report").scan with ProcessPoolExecutor(min(jobs, len(sources)), mp_context=multiprocessing.get_context("spawn")) as pool: return dict(zip(sources, pool.map(by_name, sources, [keys] * len(sources)), strict=True)) def classify(key: str, at: str, row: dict[str, Any]) -> str: """How a reading took the word at `at`: as (part of) the title, as the given name's first word, or otherwise.""" fields = row["fields"] if key in local_corpora.words(fields.get("title", "")): return "title" given = local_corpora.words(fields.get("given", "")) return "given" if given and given[0] == key else "other" #: source -> key -> position -> reading -> count Incidence = dict[str, dict[str, dict[str, Counter[str]]]] def incidence(scanned: dict[str, dict[str, Any]], readings: dict[str, dict[str, Any]], ) -> Incidence: """Each scanned name's reading (`readings`, by name) classified.""" out: Incidence = {} for source, got in scanned.items(): for key, at_names in got.get("found", {}).items(): for at, names in at_names.items(): counts = out.setdefault(source, {}).setdefault(key, {}).setdefault( at, Counter()) counts.update(classify(key, at, readings[name]) for name in names) return out def format_corpora(key: str, scanned: dict[str, dict[str, Any]], head: Incidence, base: Incidence | None = None) -> list[str]: """Per corpus and position, the count of each reading: base -> head with a base. Counts only.""" lines = [] for source, got in scanned.items(): if "note" in got: lines.append(f" {source}: {got['note']}") continue for at in POSITIONS: now = head.get(source, {}).get(key, {}).get(at, Counter()) if not now: continue was = (base or {}).get(source, {}).get(key, {}).get(at, Counter()) cells = ", ".join( f"{r} {was[r]:,} -> {now[r]:,}" if base is not None and was[r] != now[r] else f"{r} {now[r]:,}" for r in READINGS if now[r] or was[r]) filed = got.get("boxes", {}).get(key) if at == POSITIONS[0] else None boxed = ("; filed in the " + ", ".join(f"{b} box {filed[b]:,}" for b in BOXES) if filed else "") lines.append(f" {source}, {at}: {sum(now.values()):,} ({cells}{boxed})") return lines or [" no fetched corpus holds it at either position"] #: Per key: the counts per position, and the filed boxes ("boxes"). Ranked = list[tuple[str, dict[str, Counter[str]]]] def forename_share(by: dict[str, Counter[str]]) -> float | None: """Of the filers who typed the word into the title or the forename box, the share who typed it as a forename; None where none did.""" filed = by["boxes"]["title"] + by["boxes"]["forename"] return by["boxes"]["forename"] / filed if filed else None def forename_fraction(by: dict[str, Counter[str]]) -> str: """The forename box over the two boxes, `28/34`, or `-` where no filer typed the word in either: counts, which a share would hide.""" filed = by["boxes"]["title"] + by["boxes"]["forename"] return f"{by['boxes']['forename']:,}/{filed:,}" if filed else "-" def ranking(keys: Sequence[str], head: Incidence, scanned: dict[str, dict[str, Any]]) -> Ranked: """Each key's counts per position summed over the corpora, with the boxes filers typed it into, ordered by the forename box's count (what a move gives back), then by forename_share (the ones never filed in either box last), then by the names reading the word as the title.""" totals: dict[str, dict[str, Counter[str]]] = {} for k in keys: totals[k] = {at: sum((head.get(s, {}).get(k, {}).get(at, Counter()) for s in head), Counter()) for at in POSITIONS} totals[k]["boxes"] = sum((got.get("boxes", {}).get(k, Counter()) for got in scanned.values()), Counter()) def order(kv: tuple[str, dict[str, Counter[str]]]) -> tuple[int, bool, float, int, str]: share = forename_share(kv[1]) return (-kv[1]["boxes"]["forename"], share is None, -(share or 0.0), -sum(kv[1][at][RANKED] for at in POSITIONS), kv[0]) return sorted(totals.items(), key=order) def format_ranking(ranked: Ranked) -> list[str]: """Per position, the names holding the word and those reading it as the title, the two titles together; then the boxes filers typed it into, the forename box first (the order's key), and the forename box over the two.""" head = " ".join(f"{a:>8} {RANKED:>6}" for a in ("opens", "comma")) lines = [f" {'word':<16} {head} {'titles':>7} {'forename box':>12} {'title box':>9} " f"{'forename/both':>13}"] for k, by in ranked: cells = " ".join(f"{sum(by[a].values()):>8,} {by[a][RANKED]:>6,}" for a in POSITIONS) lines.append(f" {k:<16} {cells} {sum(by[a][RANKED] for a in POSITIONS):>7,} " f"{by['boxes']['forename']:>12,} {by['boxes']['title']:>9,} " f"{forename_fraction(by):>13}") return lines # -- usage ------------------------------------------------------------------------- def usage(words: Sequence[str]) -> dict[str, list[dict[str, Any]]]: """tools/name_usage.py's rows for `words`, offline.""" done = subprocess.run( [sys.executable, str(ROOT / "tools" / "name_usage.py"), "--no-wikidata", "--json", *words], capture_output=True, text=True, env=snapshot.child_env()) why = (done.stderr.strip().splitlines() or [f"exit {done.returncode}"])[-1] if not done.returncode: try: answer: dict[str, list[dict[str, Any]]] = json.loads(done.stdout) except ValueError as exc: why = f"its output is not JSON ({exc})" else: return answer return {w: [{"source": "name_usage.py", "status": "failed", "note": why}] for w in words} _CREDIT = re.compile(r"^.*?, data as of [^;]*; ") def format_usage(rows: list[dict[str, Any]]) -> list[str]: lines = [] for row in rows: counts = ", ".join(f"{k} {row[k]:,}" for k in ("given", "family", "credential") if isinstance(row.get(k), int)) # A local corpus's note opens with its credit, which the report # needs once, not per word. note = _CREDIT.sub("", row.get("note") or "") if row.get("status") != "ok": note = f"{row.get('status')}: {note}" lines.append(f" {row.get('source')}: {counts or '-'}" + (f"; {note}" if note else "")) return lines # -- the command ------------------------------------------------------------------- def report(words: Sequence[str], root: Path, base: Path | None, *, rank_only: bool, jobs: int) -> list[str]: keys = list(dict.fromkeys(local_corpora.word_key(w) for w in words)) roots = [root] if base is None else [root, base] scanned = scan_all(frozenset(keys), jobs) if damaged := {s: g["note"] for s, g in scanned.items() if "note" in g and g["note"] != NOT_FETCHED}: raise SystemExit("\n".join(f"{s}: {note}" for s, note in damaged.items())) names = sorted({n for got in scanned.values() for at_names in got.get("found", {}).values() for ns in at_names.values() for n in ns}) grids = {} if rank_only else {w: grid_items(w) for w in words} items = [i for g in grids.values() for i in g] + [(n, None) for n in names] read = read_trees(roots, items, jobs) head_rows, base_rows = read[0], (read[1] if base is not None else None) split = sum(len(g) for g in grids.values()) def corpora_of(rows: list[dict[str, Any]]) -> Incidence: return incidence(scanned, dict(zip(names, rows[split:], strict=True))) head = corpora_of(head_rows) was = corpora_of(base_rows) if base_rows is not None else None lines = [f"tree: {root}" + (f"; base: {base}" if base else ""), coverage(scanned)] if base is not None: lines.append(snapshot.head_line(root, base).rstrip()) used = usage(words) if not rank_only else {} at = 0 for word in words: if rank_only: break n = len(grids[word]) lines += ["", f"== {word}", f"grid ({len(SHAPES)} shapes x {len(CASES)} cases x " f"{len(ORDERS)} orders; GF given-first, FF family-first, " f"FG family-first given-last, all: every order alike)" + (": rows the trees read differently" if base else "")] lines += format_grid(grids[word], head_rows[at:at + n], base_rows[at:at + n] if base_rows is not None else None ) or [" (none)"] at += n key = local_corpora.word_key(word) lines += ["corpora (names counted, never quoted)"] lines += format_corpora(key, scanned, head, was) lines += ["usage (name_usage.py --no-wikidata)"] lines += format_usage(used.get(word) or used.get(key) or [ {"source": "name_usage.py", "status": "missing", "note": "it gave no row for the word"}]) lines += ["", f"ranking: corpus names holding the word where it opens the name and " f"where it opens the part after a comma, and those reading it as the " f"title there (read by {root}); then, of the names opening with it in a " f"corpus that keeps the filer's boxes, those typed into the title box " f"(a real title: what a move would cost) and the forename box (a given " f"name the title takes: what it would give back), ordered by the " f"forename box's count; forename/both is the forename box over the " f"two, '-' where no filer typed it in either"] lines += [f" ({coverage(scanned)})"] lines += format_ranking(ranking(keys, head, scanned)) return lines def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("words", nargs="*") ap.add_argument("--base", metavar="PATH", help="print only the grid rows this checkout reads differently, " "and each changed corpus count as base -> head") ap.add_argument("--tree", metavar="PATH", help="read with another checkout") ap.add_argument("--rank", action="store_true", help="print the ranking alone: no grid and no usage") ap.add_argument("--jobs", type=int, metavar="N", default=os.cpu_count() or 1, help="work in N processes (default: every CPU)") ap.add_argument("--child", metavar="TREE", help=argparse.SUPPRESS) args = ap.parse_args(argv) if args.child: return _child(args.child, args.jobs) if not args.words: ap.error("name at least one word") if args.rank and args.base: ap.error("--rank reads one tree; drop --base") root = Path(args.tree).resolve() if args.tree else ROOT base = snapshot.base_root(args.base, root) if args.base else None print("\n".join(report(args.words, root, base, rank_only=args.rank, jobs=args.jobs))) return 0 if __name__ == "__main__": sys.exit(main()) �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/tools/zh_surnames.py����������������������������������������������0000664�0000000�0000000�00000031623�15273032717�0022764�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������"""Build the single-character surname list locales.ZH ships past the census. WHY THIS IS IN THE TREE. The ZH pack divides an unspaced Chinese name after a listed surname and leaves it whole otherwise (rules/W1). Up to 2.4 it listed the 2020 PRC census top 100, and 6 of the 80 real Chinese names in the native-script corpus fell outside it (柯洁, 詹桓, #695). The census publishes nothing past rank 100, so the list is extended from two openly licensed rankings instead, and this script is how the extension is re-derived rather than transcribed by hand: - Taiwan, Ministry of the Interior, Department of Household Registration (內政部戶政司), surname ranking by population, 2023 (data.gov.tw dataset 126774; Open Government Data License, version 1, which asks for attribution). Traditional script, Taiwan's own frequencies. - Wikidata (CC0): items that are an instance of "Chinese family name" (Q1093580), ranked by how many people carry them as family name (P734), with their Chinese labels. Both scripts; it skews toward notable people and counts Korean and Vietnamese bearers of the same character. Each ranking contributes its top N single-character surnames (N=300 by decision, decisions/W1 of 2026-10-18), and the union is what ships, so neither region's bias decides alone. Where a source gives one script only, Unicode's Unihan variant table supplies the other, in ONE direction: a traditional character's simplified form is added when Unihan gives exactly one in the Basic Multilingual Plane. The reverse is one-to-many (余 has 餘 among its traditional variants, and 餘 is no one's surname), so a traditional form ships only where a source attests it -- a Wikidata item labelled with both, or the Taiwan ranking itself. Two-character surnames are NOT taken from here. Taiwan's multi-character rows are mostly married double surnames (王李, 李林), and an entry like 李林 would cut 李林伟 into 李林 + 伟, so compounds stay a hand-curated list in zh.py. uv run python tools/zh_surnames.py # the selection, with ranks uv run python tools/zh_surnames.py --write # rewrite zh.py's block uv run python tools/zh_surnames.py --refresh # fetch the sources again Sources are cached outside the repository, in $NAMEPARSER_TOOLS_CACHE or ~/.cache/nameparser-tools (shared with name_usage.py); --refresh fetches them again, and the Wikidata ranking moves when it does. """ from __future__ import annotations import argparse import csv import io import json import re import sys import unicodedata import urllib.request import zipfile from dataclasses import dataclass, field from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) import name_usage # noqa: E402 (a sibling script, not a package) ZH_MODULE = Path(__file__).parents[1] / "nameparser" / "locales" / "zh.py" TOP = 300 MOI_URL = ( "https://opdadm.moi.gov.tw/api/v1/no-auth/resource/api/dataset/" "5D318346-EDFF-42C7-B914-946665D483F5/resource/" "E4A6518E-FF4C-4925-8D59-48ECE4401820/download") MOI_FILE = "moi_surnames_2023.csv" UNIHAN_URL = "https://www.unicode.org/Public/16.0.0/ucd/Unihan.zip" UNIHAN_FILE = "unihan_16.0.0.zip" UNIHAN_MEMBER = "Unihan_Variants.txt" WIKIDATA_FILE = "wikidata_chinese_family_names.json" #: Chinese label languages; Wikidata keeps each script under several. LABEL_LANGUAGES = ("zh", "zh-hans", "zh-hant", "zh-cn", "zh-tw", "zh-hk", "zh-sg", "zh-mo") WIKIDATA_QUERY = f""" SELECT ?item ?n ?label WHERE {{ {{ SELECT ?item (COUNT(DISTINCT ?person) AS ?n) WHERE {{ ?item wdt:P31 wd:Q1093580 . ?person wdt:P734 ?item . }} GROUP BY ?item }} OPTIONAL {{ ?item rdfs:label ?label FILTER(LANG(?label) IN ({", ".join(f'"{x}"' for x in LABEL_LANGUAGES)})) }} }}""" BEGIN = "# -- BEGIN generated by tools/zh_surnames.py; edit the tool, not this block --" END = "# -- END generated --" #: Some items are labelled "X姓" or "X氏" ("surname X"), which is how #: 易, 鄺 and 閆 appear; the label names the single character. _SURNAME_WORD = re.compile(r"^(.)[姓氏]$") def is_han(char: str) -> bool: """A unified ideograph. Compatibility ideographs and private-use codepoints (about 60 rows of the Taiwan file) are left out: no keyboard produces them for a name, and the compatibility block NFC-folds to a unified twin anyway.""" return len(char) == 1 and unicodedata.name(char, "").startswith( "CJK UNIFIED IDEOGRAPH") def read_moi(csv_text: str) -> list[str]: """Taiwan's single-character surnames in rank order. The file has one row per surname per age band, all carrying the surname's rank, and a placeholder row 其他 ("other") that is not a surname.""" reader = csv.DictReader(io.StringIO(csv_text.lstrip(""))) if not reader.fieldnames or not {"ranking", "lastname"} <= set( reader.fieldnames): raise ValueError(f"unexpected Taiwan surname columns: " f"{reader.fieldnames}") rank: dict[str, int] = {} for row in reader: name = row["lastname"].strip() rank.setdefault(name, int(row["ranking"])) if not rank: raise ValueError("the Taiwan surname file holds no rows") return [name for name in sorted(rank, key=lambda n: (rank[n], n)) if is_han(name)] @dataclass class Item: qid: str bearers: int labels: set[str] = field(default_factory=set) def singles(self) -> set[str]: out = set() for label in self.labels: match = _SURNAME_WORD.match(label) word = match.group(1) if match else label if is_han(word): out.add(word) return out def read_wikidata(rows: list[dict[str, str]]) -> list[Item]: """Chinese family-name items with at least one single-character label, most-borne first.""" items: dict[str, Item] = {} for row in rows: qid = row["item"].rsplit("/", 1)[-1] item = items.setdefault(qid, Item(qid, int(row["n"]))) if row.get("label"): item.labels.add(row["label"]) ranked = [item for item in items.values() if item.singles()] return sorted(ranked, key=lambda item: (-item.bearers, item.qid)) def read_unihan(text: str) -> dict[str, set[str]]: """Each traditional character's simplified variants.""" simplified: dict[str, set[str]] = {} for line in text.splitlines(): if line.startswith("#") or "\tkSimplifiedVariant\t" not in line: continue source, _, targets = line.split("\t") char = chr(int(source[2:], 16)) simplified[char] = {chr(int(t[2:], 16)) for t in targets.split()} return simplified @dataclass class Surname: forms: set[str] moi_rank: int | None = None wikidata_rank: int | None = None @property def rank(self) -> int: return min(r for r in (self.moi_rank, self.wikidata_rank) if r is not None) def select(moi: list[str], items: list[Item], simplified: dict[str, set[str]], top: int = TOP) -> list[Surname]: """The union of each ranking's top `top` single-character surnames, as surnames (one per set of script forms), best rank first. A character two sources share joins their entries, so 衛 from Taiwan and the item labelled 卫 and 衛 are one surname.""" surnames: list[Surname] = [] by_form: dict[str, Surname] = {} def add(forms: set[str], **ranks: int) -> None: found = {id(by_form[f]): by_form[f] for f in forms if f in by_form} if found: target, *others = found.values() for other in others: # two entries this one bridges target.forms |= other.forms for key in ("moi_rank", "wikidata_rank"): mine, theirs = getattr(target, key), getattr(other, key) if theirs is not None and (mine is None or theirs < mine): setattr(target, key, theirs) surnames.remove(other) else: target = Surname(set()) surnames.append(target) target.forms |= forms for key, value in ranks.items(): if getattr(target, key) is None: setattr(target, key, value) for form in target.forms: by_form[form] = target for i, char in enumerate(moi[:top], 1): add({char}, moi_rank=i) for i, item in enumerate(items[:top], 1): add(item.singles(), wikidata_rank=i) for surname in list(surnames): for form in list(surname.forms): # Unihan also names supplementary-plane forms only GB 18030 # extensions spell (蘭 → 兰 or 𬞕), an alternative to the BMP # spelling a name is typed with. A surname whose ONLY # spelling is supplementary (Taiwan's 𡍼) ships as ranked. variants = {v for v in simplified.get(form, ()) if ord(v) < 0x10000} if len(variants) == 1 and not variants <= surname.forms: add(variants | {form}) return sorted(surnames, key=lambda s: (s.rank, min(s.forms))) def additions(surnames: list[Surname], listed: set[str]) -> list[str]: """The forms to generate: every form of every selected surname that the hand-kept census rows do not already hold, in rank order.""" out: list[str] = [] for surname in surnames: out.extend(sorted(surname.forms - listed - set(out))) return out def render(forms: list[str]) -> str: rows = [", ".join(f'"{c}"' for c in forms[i:i + 10]) for i in range(0, len(forms), 10)] body = "\n".join(f" {row}," for row in rows) return f"{BEGIN}\n_SINGLE_RANKED = (\n{body}\n)\n{END}" def rewrite(module_text: str, block: str) -> str: start, end = module_text.find(BEGIN), module_text.find(END) if start < 0 or end < start: raise ValueError(f"{ZH_MODULE} has no generated block to replace") return module_text[:start] + block + module_text[end + len(END):] def listed_by_hand(module_text: str) -> set[str]: """The census rows zh.py keeps by hand, read from its source so the script never imports the package it writes.""" start = module_text.find(BEGIN) head = module_text if start < 0 else module_text[:start] head = head.split("_COMPOUND = (")[0] return {c for c in re.findall(r'"(.)"', head) if is_han(c)} # -- sources -------------------------------------------------------------- def _fetch(url: str) -> bytes: request = urllib.request.Request( url, headers={"User-Agent": name_usage.USER_AGENT}) with urllib.request.urlopen(request, timeout=120) as response: data: bytes = response.read() return data def load_moi(refresh: bool) -> list[str]: path = name_usage.cache_dir() / MOI_FILE if refresh or not path.exists(): text = _fetch(MOI_URL).decode("utf-8-sig") read_moi(text) # refuse to cache an error page path.write_text(text, encoding="utf-8") return read_moi(path.read_text(encoding="utf-8")) def load_wikidata(refresh: bool) -> list[Item]: path = name_usage.cache_dir() / WIKIDATA_FILE if refresh or not path.exists(): rows = name_usage._bindings(name_usage._sparql(WIKIDATA_QUERY)) if not read_wikidata(rows): raise ValueError("the Wikidata query returned no surnames") path.write_text(json.dumps(rows, ensure_ascii=False), encoding="utf-8") return read_wikidata(json.loads(path.read_text(encoding="utf-8"))) def load_unihan(refresh: bool) -> dict[str, set[str]]: path = name_usage.cache_dir() / UNIHAN_FILE if refresh or not path.exists(): name_usage._download(UNIHAN_URL, path, UNIHAN_MEMBER) with zipfile.ZipFile(path) as zf: return read_unihan(zf.read(UNIHAN_MEMBER).decode("utf-8")) def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) parser.add_argument("--write", action="store_true", help="rewrite the generated block in zh.py") parser.add_argument("--refresh", action="store_true", help="fetch the sources again") parser.add_argument("--top", type=int, default=TOP) args = parser.parse_args(argv) surnames = select(load_moi(args.refresh), load_wikidata(args.refresh), load_unihan(args.refresh), args.top) module_text = ZH_MODULE.read_text(encoding="utf-8") listed = listed_by_hand(module_text) forms = additions(surnames, listed) for s in surnames: new = "+" if s.forms - listed else " " print(f"{new} {s.rank:4} Taiwan {s.moi_rank or '-':>4} " f"Wikidata {s.wikidata_rank or '-':>4} " f"{' '.join(sorted(s.forms))}") print(f"{len(surnames)} surnames; {len(forms)} forms not in the census " "rows", file=sys.stderr) if args.write: ZH_MODULE.write_text(rewrite(module_text, render(forms)), encoding="utf-8") print(f"wrote {ZH_MODULE}", file=sys.stderr) return 0 if __name__ == "__main__": sys.exit(main()) �������������������������������������������������������������������������������������������������������������derek73-python-nameparser-456b024/uv.lock�����������������������������������������������������������0000664�0000000�0000000�00001233553�15273032717�0020227�0����������������������������������������������������������������������������������������������������ustar�00root����������������������������root����������������������������0000000�0000000������������������������������������������������������������������������������������������������������������������������������������������������������������������������version = 1 revision = 5 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.15' and sys_platform == 'win32'", "python_full_version >= '3.15' and sys_platform == 'emscripten'", "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.14.*' and sys_platform == 'win32'", "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version < '3.12' and sys_platform == 'win32'", "python_full_version < '3.12' and sys_platform == 'emscripten'", "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] [[package]] name = "accessible-pygments" version = "0.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bc/c1/bbac6a50d02774f91572938964c582fff4270eee73ab822a4aeea4d8b11b/accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872", size = 1377899, upload-time = "2024-05-10T11:23:10.216Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7", size = 1395903, upload-time = "2024-05-10T11:23:08.421Z" }, ] [[package]] name = "alabaster" version = "1.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, ] [[package]] name = "annotated-doc" version = "0.0.5" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, ] [[package]] name = "ast-serialize" version = "0.5.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520, upload-time = "2026-05-17T17:47:30.831Z" }, { url = "https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779, upload-time = "2026-05-17T17:47:32.551Z" }, { url = "https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750, upload-time = "2026-05-17T17:47:34.731Z" }, { url = "https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942, upload-time = "2026-05-17T17:47:36.287Z" }, { url = "https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517, upload-time = "2026-05-17T17:47:38.17Z" }, { url = "https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081, upload-time = "2026-05-17T17:47:39.826Z" }, { url = "https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910, upload-time = "2026-05-17T17:47:41.369Z" }, { url = "https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678, upload-time = "2026-05-17T17:47:43.702Z" }, { url = "https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603, upload-time = "2026-05-17T17:47:46.256Z" }, { url = "https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332, upload-time = "2026-05-17T17:47:47.899Z" }, { url = "https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979, upload-time = "2026-05-17T17:47:50.942Z" }, { url = "https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002, upload-time = "2026-05-17T17:47:54.093Z" }, { url = "https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231, upload-time = "2026-05-17T17:47:56.311Z" }, { url = "https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668, upload-time = "2026-05-17T17:47:58.305Z" }, { url = "https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075, upload-time = "2026-05-17T17:48:00.35Z" }, { url = "https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347, upload-time = "2026-05-17T17:48:01.753Z" }, { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" }, { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" }, { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" }, { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" }, { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" }, { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" }, { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" }, { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" }, { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" }, { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" }, { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" }, { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" }, { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" }, { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" }, { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" }, { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, ] [[package]] name = "babel" version = "2.18.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] [[package]] name = "beautifulsoup4" version = "4.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "soupsieve" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, ] [[package]] name = "certifi" version = "2026.10.23" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" }, ] [[package]] name = "charset-normalizer" version = "3.4.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] name = "coverage" version = "7.15.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/cc/8b/adeb62ea8951f13c4c7fef2e7a85e1a06b499c8d8237ea589d496029e53f/coverage-7.15.0.tar.gz", hash = "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f", size = 925362, upload-time = "2026-07-02T13:10:50.535Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ae/23/82e910835ef4b8391047025e1d53aa48d66029f444eb8b25373c849bf503/coverage-7.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:003fff99412ea848c0aaebcc78ed2b6ce7d8a1227ed17e68470672770b78a02a", size = 220662, upload-time = "2026-07-02T13:08:39.205Z" }, { url = "https://files.pythonhosted.org/packages/6d/0d/c7b213dde2f1579de5231062b386d8413f79c11667eb58c39319b25991da/coverage-7.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5cbd804bf2784ce7b45114516050f346ecd50f960c4bb630a7ee9e1d78fa2118", size = 221168, upload-time = "2026-07-02T13:08:40.471Z" }, { url = "https://files.pythonhosted.org/packages/33/77/d000aeedfac085088337b3c7becdad328474b1f8a9e4c9368a0c99605d68/coverage-7.15.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8773e15c23305b58882a4611fb9b2755977eae0dc2e515366a1b6c98866cc4c2", size = 251587, upload-time = "2026-07-02T13:08:42.033Z" }, { url = "https://files.pythonhosted.org/packages/cc/e0/86787c56b9df17afd370d5e293515dd4d9a107a561d13054873eefad8ecc/coverage-7.15.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f50e40081494c1dc4239ebb202014cbcc3306ea96fb6302a34c8cc0967fc5ae8", size = 253497, upload-time = "2026-07-02T13:08:43.387Z" }, { url = "https://files.pythonhosted.org/packages/3f/02/181bc917359299c07dead6270f94e411151c8b60cec905c33499da69afe6/coverage-7.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daf96f37f5fc3a7b6c6da862eb4aee61c426bd63da236ed4a73ef0e503b4bca5", size = 255607, upload-time = "2026-07-02T13:08:44.897Z" }, { url = "https://files.pythonhosted.org/packages/b9/35/ca5e7427699913da6788c4f910e73ab16c5f4b59ec5d3a999dce2a45112f/coverage-7.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:51aa20f6ae2788fd197747766edf4cd8234fd9423309b934257fa6b21a592723", size = 257563, upload-time = "2026-07-02T13:08:46.334Z" }, { url = "https://files.pythonhosted.org/packages/0b/4d/b8220bacc2fc3c4e9078e27c32e99fb411479a4718a72bdd00036a9891c8/coverage-7.15.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03d1f922757662eb7af586e77834792274cff776bc7b1d1a0b66a49ea9d84735", size = 251726, upload-time = "2026-07-02T13:08:47.941Z" }, { url = "https://files.pythonhosted.org/packages/c4/e4/2e145da1991d72189b9c3cf7eca05c716ee7080d099aaea6757cfc7df008/coverage-7.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a6d6acc9a7666245e6133dd15144ca038a85a9cd5026bb06d6bbae9e77440dc9", size = 253301, upload-time = "2026-07-02T13:08:49.5Z" }, { url = "https://files.pythonhosted.org/packages/72/28/d2c841d698bf762e481f08bd4839d370246b6d9b61dab085a7b20b201a08/coverage-7.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1ac2c4c27c7df851dc9a017c2d7de00b69147e84ba3d96f37a530b0b6fb51035", size = 251361, upload-time = "2026-07-02T13:08:51.304Z" }, { url = "https://files.pythonhosted.org/packages/9d/ed/55d9ffde994fba3897c0c783f77a7d053b0c18787f6892ed5b0aed73f469/coverage-7.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b761a1d504fd4bd1f20f418753964dca9f5862a511fc854dac58296b3b223671", size = 255129, upload-time = "2026-07-02T13:08:52.661Z" }, { url = "https://files.pythonhosted.org/packages/1d/c0/ecbf33b8c460ea2718aeb813e2df8140d0370e5f67261c31524ceb0a2a8d/coverage-7.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e43b045e11c16e897895758ae90e4a90cf99e93d58549e2f90c0e2272e155695", size = 251081, upload-time = "2026-07-02T13:08:54.188Z" }, { url = "https://files.pythonhosted.org/packages/a9/de/fb87b4261f54448dd2b9504ef19a58be42cef0d9520595fbfe1219b15234/coverage-7.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:589b54513e901739f4b4582c705ce96b80c96f57641b1464607e2367a270e540", size = 251988, upload-time = "2026-07-02T13:08:55.726Z" }, { url = "https://files.pythonhosted.org/packages/df/27/3494d5f291b9a4cb868f73c11221a8bd2d5bd761a8f9acea61ff57128dd1/coverage-7.15.0-cp311-cp311-win32.whl", hash = "sha256:106781b8482749162d0b47056937ba0933508e5d9447f65a5e7d5c422f0d6bb4", size = 222754, upload-time = "2026-07-02T13:08:57.091Z" }, { url = "https://files.pythonhosted.org/packages/2a/ee/cd4847ebc9be6a9c0123d763645a6f1f3be6b8c58c962706368b79cbac07/coverage-7.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:821e92b3631d762a339695824cadbbc73020354eba2a23a551a99ad34938fbe6", size = 223225, upload-time = "2026-07-02T13:08:58.594Z" }, { url = "https://files.pythonhosted.org/packages/57/37/5011581aa7f2be498b97dcc7c9902192442a42f4f9a748aeadb3d6506b42/coverage-7.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:309990eb5fb8014b9f67cb211f7fd41876ec8a88a88d3ae76de0ed1d611e3640", size = 222774, upload-time = "2026-07-02T13:09:00.074Z" }, { url = "https://files.pythonhosted.org/packages/2a/74/fd4c0901137c4f8d81a76ada99e43c65163b4c94a02ece107a4ec0c6b615/coverage-7.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b75ee5e8cb7575636ac598719b4307ac529ec8fcd79608a35c3cd4d4dada812d", size = 220838, upload-time = "2026-07-02T13:09:02.084Z" }, { url = "https://files.pythonhosted.org/packages/0f/2e/2347583467bd7f0402635101a916961915cc68fce652cd0db5f173ea04fc/coverage-7.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffb31267816b93b075302248cc1737506081b4f163df4401e9df1a6424aafabe", size = 221197, upload-time = "2026-07-02T13:09:03.617Z" }, { url = "https://files.pythonhosted.org/packages/f0/17/99fa688541ae1d6e84543a0e544f83de0c944815b63e9e7b1ed411d15036/coverage-7.15.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4d0bb73455bf97ab243a8f12c37c686ccf1c13bb614b7b85f1d062f06f42b2c", size = 252705, upload-time = "2026-07-02T13:09:05.059Z" }, { url = "https://files.pythonhosted.org/packages/fb/02/6a95a5cd83b74839017ef9cf48d2d8c9ae60af919e17a3f336e6f9f1b7bd/coverage-7.15.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:20d9ccc4ebd0edc434d86dfd2a1dd2a8efa6b6b3073d0485a394fee86459ebb4", size = 255441, upload-time = "2026-07-02T13:09:06.559Z" }, { url = "https://files.pythonhosted.org/packages/67/f2/406f6c57d600f68185942422c4c00f1a3255d60aee6e5fd961425cd9987e/coverage-7.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20c8a976c365c8cb12f0cbd099508772ea41fb5fa80657a8506df0e11bd278c5", size = 256556, upload-time = "2026-07-02T13:09:08.197Z" }, { url = "https://files.pythonhosted.org/packages/74/8e/d3fa48489c15ecdec1ba48fd61f68798555dddd2f6716f9ad42adeb1a2a9/coverage-7.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f948fd5ba1b9cbca91f0ae08b4c1ce2b139509149a435e2585d056d57d70bf01", size = 258815, upload-time = "2026-07-02T13:09:09.691Z" }, { url = "https://files.pythonhosted.org/packages/47/2e/2d40ddd110462c6a2769677cf7f1c119a52b45f568978fc6c98e4cc0dd0f/coverage-7.15.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f58185f06edf6ad68ec9fb155d63ef650c82f3fbd7e1770e2867751fb13158f4", size = 253117, upload-time = "2026-07-02T13:09:11.212Z" }, { url = "https://files.pythonhosted.org/packages/51/c0/310782f0d7c3cb2b5ac05ba8d205fe91f24a36f6bf3256098f1782181c38/coverage-7.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02adc79a920c73c647c5d117f55747df7f2de94571884758ce8bc58e04f0a796", size = 254475, upload-time = "2026-07-02T13:09:13.029Z" }, { url = "https://files.pythonhosted.org/packages/86/f7/702da6c275f8ae6ade423d2877243122932c9b27f5403003b9ef8c927d12/coverage-7.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6eb7c300fbed667fd6e3588eba71c1904cdb06110ca6fdf908c26bdd88b8e382", size = 252619, upload-time = "2026-07-02T13:09:14.699Z" }, { url = "https://files.pythonhosted.org/packages/fb/84/c5b15a7e5ecba4e56218d772d99fe80a63e63f8d11f12783723a6005ab45/coverage-7.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b5fb23fa2de9dce1f5c36c09066d8fcda16cd96e8e26686caa2d7cb9b567d65c", size = 256689, upload-time = "2026-07-02T13:09:16.103Z" }, { url = "https://files.pythonhosted.org/packages/95/2f/c8b07559b57701230c61b23a953858c052890c12ef568d81780c6c46e92e/coverage-7.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cec79341dbe6281484024979976d0c7f22beae08b4a254655decd25d42cbe766", size = 252189, upload-time = "2026-07-02T13:09:17.828Z" }, { url = "https://files.pythonhosted.org/packages/6b/80/6d2f049dd3fd3dbfd60b62ba6b2162a04009e2c002ce70b24cf3878dec7a/coverage-7.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c664c5444b1d970b1b2a450e21fb19ee5c9cfdf151ded2dda37260031cca0da", size = 254059, upload-time = "2026-07-02T13:09:19.304Z" }, { url = "https://files.pythonhosted.org/packages/ce/92/b0287a2c42031d25c628f815f89a3cd9f8268ee78bb1252c9356cda1c689/coverage-7.15.0-cp312-cp312-win32.whl", hash = "sha256:5f764a3fa339bde6b3aa97657f5a6a3a9451e4a5b4ea98a2892c773a43525f77", size = 222893, upload-time = "2026-07-02T13:09:20.812Z" }, { url = "https://files.pythonhosted.org/packages/a9/69/e34c481915fecb499b3146975061dac528752e37706edc1804f32c822469/coverage-7.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:52f9a4d2c4c56c8848bc2f524916698354b0211488b38c49ad9ae54f6cafbff6", size = 223429, upload-time = "2026-07-02T13:09:22.315Z" }, { url = "https://files.pythonhosted.org/packages/fe/98/6e878f0b571d32684ef3f38d7c03db241ca5b82a5da8a5391596a8f209c4/coverage-7.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:31e5c3e70c85307ea35a12964e2e40f56ca2ee4b1c8c721ccf4609d17071080b", size = 222810, upload-time = "2026-07-02T13:09:23.812Z" }, { url = "https://files.pythonhosted.org/packages/76/04/145a3748098bcc86b631a85408d2c3dc5c104e0bd86d605468239b25b6c4/coverage-7.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5be4caf3b28836f078abe700f8944dac4a65d78f16d6c600c89cb624e5535782", size = 220863, upload-time = "2026-07-02T13:09:25.371Z" }, { url = "https://files.pythonhosted.org/packages/a4/5c/4ed55708fed2c64b63c9bc5715daef670872202101938869b7fe5d5fbb8f/coverage-7.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dd58ad1404704303ca8d4f4b8a1095e7cbc7040ef17a66df1e6619aa10176430", size = 221230, upload-time = "2026-07-02T13:09:26.897Z" }, { url = "https://files.pythonhosted.org/packages/7b/19/3a80b97d3b2a5c77a01ae359c6bed20c13738fe3d9380f08616d4fec0281/coverage-7.15.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bbcbb317c2e5ded5b21104af81c29f391be2af98d065693ffbe8d23949b948e5", size = 252227, upload-time = "2026-07-02T13:09:28.543Z" }, { url = "https://files.pythonhosted.org/packages/a1/fa/b70062750686bd7da454da27927622f48bbac6990ac7a4c4a4653e7b0036/coverage-7.15.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:27f31ecb458da3f859aab3f15ada871eb7a7768807d88df4a9f186bb17737970", size = 254823, upload-time = "2026-07-02T13:09:30.177Z" }, { url = "https://files.pythonhosted.org/packages/a9/09/dad6a75a2e561b9dc5086a8c5257a7591d584246f67e23e70d2995b89ab6/coverage-7.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fb759be317fdc62e0f56bffdf61cfcb45c7761ad6b71e3e583e71a67ae753c", size = 256059, upload-time = "2026-07-02T13:09:31.979Z" }, { url = "https://files.pythonhosted.org/packages/e6/e7/b5d2941fa9564573d44b693a871ff3156f0c42cbefe977a09fa7fdc59971/coverage-7.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5cf007add5ab4bb8fa9f4c77e3732127c9e6cad501d7db43355fbfafca0be84", size = 258190, upload-time = "2026-07-02T13:09:34.035Z" }, { url = "https://files.pythonhosted.org/packages/7c/1d/8e895bcde3c57ccd46d896dda5f2b3d5df761a1b0c6c9d450d175dedc632/coverage-7.15.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc78d9843bd576fbe2118248258d485e968dc535f95ed504a7b0867ba9b51389", size = 252456, upload-time = "2026-07-02T13:09:35.765Z" }, { url = "https://files.pythonhosted.org/packages/14/4c/f6997da343ddeb959be82c3b05322793f92c071ad45f7cb8a96336e2dd5f/coverage-7.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a263060f1de0b4b74b4e089c2a70b8003b3781c733329a9c8fd54995328f9950", size = 254192, upload-time = "2026-07-02T13:09:37.445Z" }, { url = "https://files.pythonhosted.org/packages/17/27/a0bc09d032267b9da89d95a2d874cfbef2a5aebbf0e87cf7aba221d79a99/coverage-7.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c48decf16e0dfd5b049c7d5e82200c23c08126719142998d4f172444e3d0529e", size = 252153, upload-time = "2026-07-02T13:09:39.422Z" }, { url = "https://files.pythonhosted.org/packages/54/c0/77fc233d9fba07b244c40948c53fe27308b8f21732fb3417f87fbd6fd992/coverage-7.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:08fb028000ed0aaa0a4cbdfbb98be7cb42f370db973fbbb469733505ab20e13e", size = 256310, upload-time = "2026-07-02T13:09:41.006Z" }, { url = "https://files.pythonhosted.org/packages/d5/24/601cecfb5825becacb8d45219a018a3b55b9dbaec624efdb0ea249d08be2/coverage-7.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb7dc0c3b7d8a1077abea0b8546ebc5e26d6ef6ecefc2f0f5ad2b8a53bdad837", size = 251974, upload-time = "2026-07-02T13:09:42.733Z" }, { url = "https://files.pythonhosted.org/packages/47/1e/6f45e5a5b3d5484318d368702af6716b5ab8913b0428bec981a562fcf296/coverage-7.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cb3602054ccbe9f0d8c2dc04bbeba90d5719236e2cd06e042ddd6d3fc7b6e37", size = 253745, upload-time = "2026-07-02T13:09:44.376Z" }, { url = "https://files.pythonhosted.org/packages/8e/db/4df027a77bd11d0e527f44c53557c76e54ad027413d0304252ea3a78d67e/coverage-7.15.0-cp313-cp313-win32.whl", hash = "sha256:0bf781da64326b677be344df505171435b6f58716108606621d5d27d964fff8b", size = 222902, upload-time = "2026-07-02T13:09:46.122Z" }, { url = "https://files.pythonhosted.org/packages/a0/10/0355894d34e231f2c5449e71287e81a50793a325df2e2b027b7bcd9dfd19/coverage-7.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:2c57a275078ee3fa185f83e400f765bc764a549de66d99b47881645cbd4ea629", size = 223444, upload-time = "2026-07-02T13:09:47.687Z" }, { url = "https://files.pythonhosted.org/packages/06/ef/bb725f263befaaff851203ab338e68af15e195d7f7b5f323162532d9b6a8/coverage-7.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:3812c61afc6685c7999b39320779ab8f43b7a3081fdb0def39976e56fbdb9a21", size = 222839, upload-time = "2026-07-02T13:09:49.717Z" }, { url = "https://files.pythonhosted.org/packages/4f/9c/1e3ca54f72a3185ece06c58d871099898c48f0ed6430d17b6ab75f0d180a/coverage-7.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:41cb79af843222e11da87127ad0ecbfa878abadd0f770a4a99391a27d3887324", size = 220906, upload-time = "2026-07-02T13:09:51.339Z" }, { url = "https://files.pythonhosted.org/packages/09/37/f718613d83b274880382f6b67e78f3802549ae39b0b3e65ae5b5974df56e/coverage-7.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d2008989ef8fe54188d3f3bfa2e3099b025af11e90a6a1b9e7dc433d04263d8", size = 221239, upload-time = "2026-07-02T13:09:53.138Z" }, { url = "https://files.pythonhosted.org/packages/a7/ce/22bae91e0b75445f68d365c7643ed0aa4880bbf77450ee74ca65bdae53a7/coverage-7.15.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:769e8ece11a596315ebf5aa7ec383aeeed016c091d2bf6363ffb996d41529092", size = 252286, upload-time = "2026-07-02T13:09:54.996Z" }, { url = "https://files.pythonhosted.org/packages/dd/1e/bec5e32aa508615d9d7a2790effb25fb4dc28606e995816afe400b25ece3/coverage-7.15.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:65a6b6164ee5c39e2f3803f314292d6c61a607ba7fee253d1e03c42dc3903502", size = 254789, upload-time = "2026-07-02T13:09:56.678Z" }, { url = "https://files.pythonhosted.org/packages/17/29/0e865435b4354e4a7c03b1b7920046d31d0a273d55decefea27e011cb9bf/coverage-7.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75128817f95a5c45bb01d65fd2d8b9cb54bbe03d81608fb70e3e14b437ad56c2", size = 256135, upload-time = "2026-07-02T13:09:58.343Z" }, { url = "https://files.pythonhosted.org/packages/84/ff/33a870b58a13325d62fc0a6c8f01fa0ff667cef60c7498e2382a147dfa18/coverage-7.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9887bb428fe2d4cd4bee89bac1a6c9932f484afd5b36fbd4ff6ea5f825bb1f5e", size = 258449, upload-time = "2026-07-02T13:10:00.057Z" }, { url = "https://files.pythonhosted.org/packages/18/7b/6fffe596bf3ddba8462758d02c5dad730fd91055a6634aa2e4226229181a/coverage-7.15.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0bfc0be1f702042207a93a00523b1065ee1fe951e96edf311581c0bbc2e34888", size = 252313, upload-time = "2026-07-02T13:10:01.946Z" }, { url = "https://files.pythonhosted.org/packages/58/1b/11468dd6c1676ab831a70cb9a8d4e198e8607fa0b7220ab918b73fe9bfbd/coverage-7.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f64627d55def5a43282d70e08396672692f77e4da610a5bb8bb4060b432b6859", size = 254142, upload-time = "2026-07-02T13:10:04.065Z" }, { url = "https://files.pythonhosted.org/packages/79/41/29328e21d16b1b95092c30dd700e08cf915bd3734f836df8f3bdb0e8fa9f/coverage-7.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2c6f0fa473003905c6d5bac328ee4eba9fbea654f15bc24b8a3274b23363fa99", size = 252108, upload-time = "2026-07-02T13:10:06.11Z" }, { url = "https://files.pythonhosted.org/packages/9b/de/05ccfb990439655b35afbfd8e0d13fe66677565a7d4eb38c3f5ef2635e1c/coverage-7.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2bcf9afaf064172c6ec3c58a325a9957ad1178c05dd934e25f253321776e0676", size = 256385, upload-time = "2026-07-02T13:10:08.141Z" }, { url = "https://files.pythonhosted.org/packages/51/0e/486828a3d2695ea7a2609f17ff572f6b01905e608379440a11da4b8dffbe/coverage-7.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:baf06bc987115d6fb938d403f7eab684a057766c490367999a2b71a6883110c6", size = 251923, upload-time = "2026-07-02T13:10:10.179Z" }, { url = "https://files.pythonhosted.org/packages/18/c7/03582b6715f078e5e558354c87616d945b9894cda2dace8e4009b17035e4/coverage-7.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f0405f2ff97b1c4c0e782cb32e02f32369bcf2e6b618b591d67e1ea754575dfe", size = 253580, upload-time = "2026-07-02T13:10:12.052Z" }, { url = "https://files.pythonhosted.org/packages/db/dc/9e578bbaf2ecb4959a81b7e7601ad8cca772cba2892e8d144cb749b4a71a/coverage-7.15.0-cp314-cp314-win32.whl", hash = "sha256:ab282853ed5fbd64bbb162f19cb8fcb7087187508a6374b4f9c34ec1577c4e8f", size = 223107, upload-time = "2026-07-02T13:10:13.994Z" }, { url = "https://files.pythonhosted.org/packages/ae/3e/c8c3b75d8dbe0e35f7b0cc3ff5e949fc59500f70b21d0398813f66740664/coverage-7.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bb3040e9f4bbe26fcb0cd7cc85ac63e630d3f3a9c74f027abf4caa27e706663", size = 223597, upload-time = "2026-07-02T13:10:15.906Z" }, { url = "https://files.pythonhosted.org/packages/cd/bc/3cbc9fb036eb388519bccd521f783499c39b64256013fbc362782f196fe1/coverage-7.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:346771144d34f7fa84ec28386f78e0f31653f33cf35e19d253d5b35f9e8201da", size = 223020, upload-time = "2026-07-02T13:10:17.844Z" }, { url = "https://files.pythonhosted.org/packages/28/00/199c4a8d656dff63102577a056c0fce2ff6a79e40adac092fc986c49cbf1/coverage-7.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d34a010905fb6401324ba016b5da03d574967f7b21ce48ea41e66f0f1f95f641", size = 221638, upload-time = "2026-07-02T13:10:19.703Z" }, { url = "https://files.pythonhosted.org/packages/ba/8e/9d0092c96a3d3a26951ecc7020826aa57bcb1b119ca81acbba996884ab13/coverage-7.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bb25d825d885ca8036795dacfc3924d33091fc76d71ebc99420c6b79e77d96fa", size = 221903, upload-time = "2026-07-02T13:10:21.514Z" }, { url = "https://files.pythonhosted.org/packages/6d/b4/c0ca3028f42c9a08e51feb4561ef1192e5de99797cd1db5b04590c215bda/coverage-7.15.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:94c9686bfe8a9a6810297aecbd99beaa3445f9e8dc2f80b1382cca0d86b64461", size = 263267, upload-time = "2026-07-02T13:10:23.261Z" }, { url = "https://files.pythonhosted.org/packages/5f/aa/a375e3846e5d3c013dc600b2a3231089055c73d77f5393dd2192a8d64da6/coverage-7.15.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9bd671c25f9d85f09d7ec481d0e43d5139f486c06a37139847a7ce569788af72", size = 265390, upload-time = "2026-07-02T13:10:25.152Z" }, { url = "https://files.pythonhosted.org/packages/92/e1/5783cdabb797305e1c9e4809fea496d31834c51fa772514f73dc148bcfc9/coverage-7.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:110cbdf8d2e216577312cf06ccf85539c0e5a5420ef747e4a4719b5e483c88cd", size = 267811, upload-time = "2026-07-02T13:10:27.249Z" }, { url = "https://files.pythonhosted.org/packages/85/31/96d8bbf58b8e9193bc8389574a91a0db48355ee98feb66aa6bf8d1b32eea/coverage-7.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c5d4619214f1d9993e7b00a8600d14614b7e9d84e89507460b126aa5e6559e5", size = 268928, upload-time = "2026-07-02T13:10:29.242Z" }, { url = "https://files.pythonhosted.org/packages/5e/7a/5294567e811a1cb7eda93140c628fa050d66189da28da320f93d1d815c73/coverage-7.15.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:781a704516e2d8346fbbd5be6c6f3412dd824785146528b3a01816f26c081007", size = 262378, upload-time = "2026-07-02T13:10:31.107Z" }, { url = "https://files.pythonhosted.org/packages/69/3f/3f48538421f899f28946f90a3d272136a4686e1abf461cc9249a783ee0f3/coverage-7.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd4a1b44bcb65ee29e947ac92bbee04956df3a6bfc6143641bb6cae7ede00fc9", size = 265263, upload-time = "2026-07-02T13:10:32.942Z" }, { url = "https://files.pythonhosted.org/packages/ce/d3/092df15efcab8a9c1467ee960eb8019bbad3f9300d115d89ea6195f369ff/coverage-7.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0e4950c9d6d3e39c64c991814ff315e2d0b9cb8152363594212c9e55208c0a8f", size = 262866, upload-time = "2026-07-02T13:10:35.104Z" }, { url = "https://files.pythonhosted.org/packages/e5/ab/0254d2b88665efb2c57ad368cc77ab5de3435bd8d5add4729c1b0e79431e/coverage-7.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:fe9c87ff42e5472d80d21704972e1f96e104a0a599d77c5e35db5a3c562e2571", size = 266599, upload-time = "2026-07-02T13:10:37.05Z" }, { url = "https://files.pythonhosted.org/packages/a8/79/1cfa4023e489ce6fbc7be4a5d442dbc375edb4f4fda39a352cedb53263c2/coverage-7.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f00d5ae1dd2fe13fb8186e3e7d37bcbd8b25c0d764ff7d1b32cef9be058510a8", size = 261714, upload-time = "2026-07-02T13:10:38.966Z" }, { url = "https://files.pythonhosted.org/packages/b7/eb/fee5c8665656be63f497418d410484637c438172568688e8ac92e06574e7/coverage-7.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:363ab38cc78b615f11c9cac3cf1d7eef950c18b9fdedfb9066f59461dcf84d68", size = 264025, upload-time = "2026-07-02T13:10:40.789Z" }, { url = "https://files.pythonhosted.org/packages/ab/99/63005db722f91edc81abc16302f9cc2f6228c1679e46e15be9ae144b14d0/coverage-7.15.0-cp314-cp314t-win32.whl", hash = "sha256:54fd9c53a5fafff509195f1b6a3f9be615d8e8362a3629ff1de23d270c03c86b", size = 223413, upload-time = "2026-07-02T13:10:42.597Z" }, { url = "https://files.pythonhosted.org/packages/c1/e8/2bc6181c4fb06f1a6b981eb85330cc57bfad7e3f710fc9c9d350013ba228/coverage-7.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:87b47553097ba185ed964866078e7e63adea9f5f51b5f39691c34f30afd21080", size = 224245, upload-time = "2026-07-02T13:10:44.47Z" }, { url = "https://files.pythonhosted.org/packages/79/b8/4d959bf9cc45d0cfed2f4d35cafcab978cdb6ea02eb5100009cd740632a3/coverage-7.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aeefb2dd178fe7eee79f0ad25d75855cb35ee9ed472db2c5ea06f5b4fd00cec5", size = 223558, upload-time = "2026-07-02T13:10:46.368Z" }, { url = "https://files.pythonhosted.org/packages/52/30/21b2ad45959cd50e909e02ebac1e30b4ceb7162e91c11d4c570223a458b7/coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19", size = 212632, upload-time = "2026-07-02T13:10:48.641Z" }, ] [package.optional-dependencies] toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] [[package]] name = "dill" version = "0.4.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, ] [[package]] name = "docutils" version = "0.22.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, ] [[package]] name = "execnet" version = "2.1.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, ] [[package]] name = "furo" version = "2025.12.19" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "accessible-pygments" }, { name = "beautifulsoup4" }, { name = "pygments" }, { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "sphinx-basic-ng" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ec/20/5f5ad4da6a5a27c80f2ed2ee9aee3f9e36c66e56e21c00fde467b2f8f88f/furo-2025.12.19.tar.gz", hash = "sha256:188d1f942037d8b37cd3985b955839fea62baa1730087dc29d157677c857e2a7", size = 1661473, upload-time = "2025-12-19T17:34:40.889Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f4/b2/50e9b292b5cac13e9e81272c7171301abc753a60460d21505b606e15cf21/furo-2025.12.19-py3-none-any.whl", hash = "sha256:bb0ead5309f9500130665a26bee87693c41ce4dbdff864dbfb6b0dae4673d24f", size = 339262, upload-time = "2025-12-19T17:34:38.905Z" }, ] [[package]] name = "hypothesis" version = "6.156.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sortedcontainers" }, ] sdist = { url = "https://files.pythonhosted.org/packages/20/83/8dbe89bdb8c6f25a7a52e7898af6d82fe35dfef08e5c702f6e33231ce6c6/hypothesis-6.156.6.tar.gz", hash = "sha256:96de02faefa3ce079873541da96f42595583bb001e8e4219294ed7d4501cc4cc", size = 476304, upload-time = "2026-07-10T20:56:49.96Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/1a/dc/0c2a851f06c91d5ac9ef0f3b9615efc1ed650411d2eee23b6334f491c85e/hypothesis-6.156.6-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:caf6a93d011c10972da111c38ceb34ced20feaa8581e2b350c0655b022e27875", size = 747998, upload-time = "2026-07-10T20:56:16.311Z" }, { url = "https://files.pythonhosted.org/packages/8e/f8/59203ca978ab51595d12d6bc7e7a63300d7373431ab42ca3f1742e45db68/hypothesis-6.156.6-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:07f2bc9df1aeba80e12029c1618e2ee54abc440068c305d7075ffd6b85251843", size = 743073, upload-time = "2026-07-10T20:55:36.825Z" }, { url = "https://files.pythonhosted.org/packages/68/d8/86a0023740434098d1b187a62bd5f99b198f098fb43e7fc58342283a8270/hypothesis-6.156.6-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7baca17f4803ad4aa151732326f3990baf54c3127df44aa872ac5bdf8a98a9a6", size = 1070169, upload-time = "2026-07-10T20:55:49.47Z" }, { url = "https://files.pythonhosted.org/packages/9b/82/673453915fd0c67673f35a4876ba88f48c621335f293f3537d77b27d4286/hypothesis-6.156.6-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8083806645f84243aade727f4978185caaa0b7190af4318673999ee15fdbf424", size = 1121760, upload-time = "2026-07-10T20:55:53.502Z" }, { url = "https://files.pythonhosted.org/packages/8a/c3/3a5557f52912f2fecc6ed59642dcf80dd8e89d0d9664502b68e23d66bf3d/hypothesis-6.156.6-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a922eedcd8618f9c2e17b79fa7b3f3f0b2df34e201958611cc3f0f46cca33c10", size = 1111440, upload-time = "2026-07-10T20:55:43.054Z" }, { url = "https://files.pythonhosted.org/packages/38/a6/ae636d4ca7f996a1ccb4b3d5997d949f1718fba52b01559b3ab53b237b3f/hypothesis-6.156.6-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5291bd33c4704d274d7c214d5c200e77f372a06644f5cbbe96dcbe53cb2fbf10", size = 1244944, upload-time = "2026-07-10T20:55:56.109Z" }, { url = "https://files.pythonhosted.org/packages/1e/79/c425d22d734be0268ca60d120c6296299e4220a1783cb1a4cc76232807bb/hypothesis-6.156.6-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55f3ec50161b4a95bae63bff2b5166e45935b493013d3be30ede279bf6192318", size = 1288808, upload-time = "2026-07-10T20:56:06.249Z" }, { url = "https://files.pythonhosted.org/packages/c5/3a/cc9f479d22cbdd36ddfc55a978378eddadd183b09339ebdb81be33bb18e7/hypothesis-6.156.6-cp310-abi3-win32.whl", hash = "sha256:e96570ca5cdd9a5f2ff9e80a6fb2fd5420ebf33b833d7de5b09b6ebb26a3eb6c", size = 634868, upload-time = "2026-07-10T20:55:37.959Z" }, { url = "https://files.pythonhosted.org/packages/d6/89/2008d287289841a936456cb13443ca89d88da6e4527d611d482e9544164d/hypothesis-6.156.6-cp310-abi3-win_amd64.whl", hash = "sha256:32710718c22fe8c5571464e898bb87d282837b02617d6ad68130abf7cb4843cb", size = 640382, upload-time = "2026-07-10T20:55:30.634Z" }, { url = "https://files.pythonhosted.org/packages/13/64/e4a0796190d8089e85f06731e21fdddd7e8edd3a4e562101527a048e21c4/hypothesis-6.156.6-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:5baec7943a14d106e982121dd4f74cfc5ef45e37c17f94fe49338d3d1377f38c", size = 748988, upload-time = "2026-07-10T20:56:48.22Z" }, { url = "https://files.pythonhosted.org/packages/9a/a2/4a789b286cd2cced31992e1f683036b51dd6909b934ea007ffb43aa3a32f/hypothesis-6.156.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b5f519905ddeb10e23b8ba2c254541a5b1a8f146fe0551be94d972f4a77226f4", size = 743754, upload-time = "2026-07-10T20:56:09.113Z" }, { url = "https://files.pythonhosted.org/packages/d2/f7/3dd36c1c03d24ae3ffc3c5b0eca8cc4ae90c07abc320f76509eceb37019a/hypothesis-6.156.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c108655960b58ded3ca71b2dc5c69fb2ba7e9c723aeb6106facec3892d09087", size = 1070732, upload-time = "2026-07-10T20:56:28.738Z" }, { url = "https://files.pythonhosted.org/packages/57/51/befc4b816b471078034a875eb1ef69e0411ab84bcce582b4be173258785a/hypothesis-6.156.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c8d37bebb6924729bc0bbf5852689df568842948abe4d93dd0ad3377adf76fa", size = 1121988, upload-time = "2026-07-10T20:56:07.676Z" }, { url = "https://files.pythonhosted.org/packages/05/b7/a796f5e3e4b7cb911ff346008d49720296d1f4073490b8bc1cce6b3fbb07/hypothesis-6.156.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:74137bef6d502305c3648b2ed1a9bb4bc05fb1025e96b30a2c092204c40fe097", size = 1245596, upload-time = "2026-07-10T20:55:50.662Z" }, { url = "https://files.pythonhosted.org/packages/37/65/849c4cba44a6f6cc888fd931124429b24180234ccc4883abab8cad5fcfcb/hypothesis-6.156.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5e0afdf79cceed20fcf0a9fb80d4064a9b2b53d4d4eecbac0e21208a13f5a31b", size = 1289172, upload-time = "2026-07-10T20:55:58.915Z" }, { url = "https://files.pythonhosted.org/packages/13/03/7106a110df29eb631d66776e8aa8128f82f04a9dd2b6b22b612e6025e3a2/hypothesis-6.156.6-cp311-cp311-win_amd64.whl", hash = "sha256:84dc89caaf741a02f904ca7bd02b1af99650c75552868162290208aeecb70858", size = 640222, upload-time = "2026-07-10T20:56:10.396Z" }, { url = "https://files.pythonhosted.org/packages/8c/45/9f009005b9c796f4a40424484ac7e70847bc088456fd940a937f96bb4b6d/hypothesis-6.156.6-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a2a728b514fceb81e3f0464508911d5220fd74dadc3270f859427a686b60c4cf", size = 748844, upload-time = "2026-07-10T20:56:38.036Z" }, { url = "https://files.pythonhosted.org/packages/02/2f/4d852bb8a9c73a68b18eca9b5b085285282122166e158f4d2a477639bfee/hypothesis-6.156.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7489b9a8f9df8227edd6c7cd8b9ccfab2483bab24da6a474c175973ca2294f58", size = 741936, upload-time = "2026-07-10T20:55:27.539Z" }, { url = "https://files.pythonhosted.org/packages/74/89/b9968070ae042f9bf3149bb6ba6399d5f28f452e0fb7f638cafc69ff0b9a/hypothesis-6.156.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42760873d6db1069d6edbaa355a61b9078a9950259efcfc72fc695741d7db7cd", size = 1069749, upload-time = "2026-07-10T20:56:43.017Z" }, { url = "https://files.pythonhosted.org/packages/00/a9/753806f5292b40aeab1d269e408e3a7e85be3c0d88828fb78ab4a34d6626/hypothesis-6.156.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4e66aaa7385538a5d617174d47c198ee807f06de99e282a67c6cb724c69340d", size = 1120983, upload-time = "2026-07-10T20:56:25.424Z" }, { url = "https://files.pythonhosted.org/packages/85/88/8386d064d680be27e936eba94f1448bc93ef6fa05473ee5034139f1c4284/hypothesis-6.156.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:08796b674c0b31a5dd4119b2173823390055921588d13eb77324e861b00fd7f8", size = 1243911, upload-time = "2026-07-10T20:55:54.799Z" }, { url = "https://files.pythonhosted.org/packages/c1/8c/7524c1e5279e7728eb47c99f2357cbc5f08ae92e9bce49bf50118b53f9c9/hypothesis-6.156.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4ca8cc26ea2d31d22cf7710e92951cfaa921f0f8aa1b6db33a5176335f583a4f", size = 1287806, upload-time = "2026-07-10T20:56:02.176Z" }, { url = "https://files.pythonhosted.org/packages/5a/b3/c347ad913e1c5f2988956fe17826c0400b4ce470b973e6c248e97b6a0acf/hypothesis-6.156.6-cp312-cp312-win_amd64.whl", hash = "sha256:c3363d3fb8015594636689572510bb6090602d8e8e838a5693c2d52d3b5b09d8", size = 637679, upload-time = "2026-07-10T20:55:39.056Z" }, { url = "https://files.pythonhosted.org/packages/70/5d/9583fe153573523dac27226c89e041a86ad4aeeae08c868160cbb93d39d2/hypothesis-6.156.6-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:59a8def90d9a5a9b67e1ac529e903a2363ceb6cf873c209da6b4284c5daab671", size = 749264, upload-time = "2026-07-10T20:56:46.118Z" }, { url = "https://files.pythonhosted.org/packages/86/35/e4113d06769b544f0fb77ffea9195b598b4c56a298905c21fd47c4eed388/hypothesis-6.156.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c574c3224563d730848bc5d1ef1683c4f83993400c0167899fe328f4bfcd4725", size = 742095, upload-time = "2026-07-10T20:56:41.412Z" }, { url = "https://files.pythonhosted.org/packages/d8/5c/a47666ede10384e8978722cade7ab96a42df71d2ab577317092d0fed7c8a/hypothesis-6.156.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01bb8270c46b3ef53b0c2d23ff613ea506d609d06f936d823ea57c58b66b05f7", size = 1069917, upload-time = "2026-07-10T20:56:04.845Z" }, { url = "https://files.pythonhosted.org/packages/79/93/75f6057dadd9dc0134f37c08d5d14d04d3cd7374debbcb0cc4569c6712f1/hypothesis-6.156.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4ea6559c13606e13b645927f2e0906e52b5ac5d99b40d3abaaeb2e8c7ceeb75", size = 1121204, upload-time = "2026-07-10T20:55:52.008Z" }, { url = "https://files.pythonhosted.org/packages/62/87/308efef08bc60d1e673d035e8ca8e9663f4b6b3ba519c3cdebf6583c2b76/hypothesis-6.156.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2d47054d0230f0dd9b6868fc030126c7a6c25527144272ff376cc4e9c39f7540", size = 1244168, upload-time = "2026-07-10T20:55:40.288Z" }, { url = "https://files.pythonhosted.org/packages/3b/66/de8fff5bd9a40a4056dafbe7f904887ef12632282bbbac90f1977c30dd3b/hypothesis-6.156.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:050c8c0815f88d47dd0875a92698d20d61639b7b721ee043a6d687c7f14ff7d8", size = 1288127, upload-time = "2026-07-10T20:56:00.541Z" }, { url = "https://files.pythonhosted.org/packages/cf/8d/794fb26e1fd3ff004978f8f18b7aa7e1c2270ba72e1f977b987a812064f8/hypothesis-6.156.6-cp313-cp313-win_amd64.whl", hash = "sha256:f0d73edab7b8a0051b3634f2d04d62b7e7282f8f274963b11188ee4957d672ef", size = 637954, upload-time = "2026-07-10T20:56:33.35Z" }, { url = "https://files.pythonhosted.org/packages/a5/be/5b4b27984cb43c60e95f570b069660335dad34cb67f7d226017c5d35d31e/hypothesis-6.156.6-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:34a70a7b8226e34d658072d8fb81d03f97f0a75ceb536329a321b94ce2232fd6", size = 749312, upload-time = "2026-07-10T20:55:46.902Z" }, { url = "https://files.pythonhosted.org/packages/31/11/709cceffc28666c9d4cb75ffc6df5ce30db8c7dd5cc2c8b38a2fd837427f/hypothesis-6.156.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f1969646beead7d8cf6a2537d2765af89d73056e2cb218e7fae92b83802250a3", size = 742332, upload-time = "2026-07-10T20:56:30.254Z" }, { url = "https://files.pythonhosted.org/packages/84/52/cfc79b13d8dd3cd6de6b9df921c557efe8528a9c90a3a7cd93b37188d57e/hypothesis-6.156.6-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cbc2ec7b7d905e6b6ec1635f6340bfa52aaab718101c59f052bc012a6b486cd8", size = 1070109, upload-time = "2026-07-10T20:55:48.244Z" }, { url = "https://files.pythonhosted.org/packages/ca/ac/1da4def1f006b5ad01187ff96379e24c37439d659ec10c3e944c03436c0f/hypothesis-6.156.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9367ae25dfa6dc1af37904785e43c4f8fe1c4118cafdc2f06514154fbdd90992", size = 1121528, upload-time = "2026-07-10T20:56:39.665Z" }, { url = "https://files.pythonhosted.org/packages/68/47/744e4f5e3d635dea20dbedf3fa486e2a6fa5210e0a52a0d5c4da56babd84/hypothesis-6.156.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:455f09107ec07c78f2a83cb8fc19e23879c9d51cdc831de6f9cb6ec4059cb9af", size = 1244690, upload-time = "2026-07-10T20:56:31.854Z" }, { url = "https://files.pythonhosted.org/packages/25/8a/42252fcd5e521d140dac532f29c2a13ca8f22908cb545ffdd64b5e225680/hypothesis-6.156.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c76634c45a3ceee4c4fdfed39aebd08b8b822ec8b0c556877ef82846fd777730", size = 1288519, upload-time = "2026-07-10T20:56:03.429Z" }, { url = "https://files.pythonhosted.org/packages/44/e7/176df9e47cf583d2b8d234b78c0aac3a47075ad5d147e60b2c21a1338bb1/hypothesis-6.156.6-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:eb7e9f8343bc6b948937e6ec12e6879ed25a17b53ceccbd2b84adadd3d511698", size = 586452, upload-time = "2026-07-10T20:56:22.285Z" }, { url = "https://files.pythonhosted.org/packages/8c/75/2c8a0411bbe76429f3ae738ef9a00107201bf6146d9534350014ce369d98/hypothesis-6.156.6-cp314-cp314-win_amd64.whl", hash = "sha256:f9631cd604ae6032c3edf99160dc1b9e33873f2e52762246b24f07fb758652ae", size = 637774, upload-time = "2026-07-10T20:56:34.97Z" }, { url = "https://files.pythonhosted.org/packages/2a/22/8115005e9aa72c8d63d90e9db5e0b8425fd8950fbc5d6e332805d4d32c9e/hypothesis-6.156.6-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:1f81163d36d3763b09ffaef7c3a71e88174ca3e6816201fca9d1d159f448fdb5", size = 747428, upload-time = "2026-07-10T20:56:44.611Z" }, { url = "https://files.pythonhosted.org/packages/e8/c2/66bfe9337f4a4b1f7754ee6d01d950280152a81d0d797e6c1d9eb0909750/hypothesis-6.156.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:556b905767e36147918634a64356aa05d8c956576f00aee01eb351678f193908", size = 740889, upload-time = "2026-07-10T20:55:57.656Z" }, { url = "https://files.pythonhosted.org/packages/95/3b/69f45af2d4f0950b7d1af3cdbdd800b88a6c2370331481eda79d6171fbe3/hypothesis-6.156.6-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3f2604b28d16d696aaaf4954d20f907b27e54034df98e64746a20c74c319f03", size = 1069270, upload-time = "2026-07-10T20:56:12.024Z" }, { url = "https://files.pythonhosted.org/packages/f8/43/6b2549885da08f5e50ba34fb8e0d0a60b2f190ffd516fac220f8db5b5869/hypothesis-6.156.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffe012ad66dbe7b8e8ddef6f6992ab1b36719ea64430c2bf1ff7135521052a15", size = 1120409, upload-time = "2026-07-10T20:55:34.551Z" }, { url = "https://files.pythonhosted.org/packages/70/97/745c778c3eb29befa2367b1ded8437eecfbbe6932359d0f831275bc32170/hypothesis-6.156.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5bfa3c7b758f7278081c6bfec5f89b43c4eb075c0c9eb095323f7a9eb019b513", size = 1243111, upload-time = "2026-07-10T20:56:17.83Z" }, { url = "https://files.pythonhosted.org/packages/ab/d7/c5ec6a442dc9b822f47064bda4b6d3e739dccdd1c5bf44c9a57fb6136830/hypothesis-6.156.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d0db1f4573800c618773622f03cb6533bb3377430ef938c9476ba10c39d22591", size = 1287262, upload-time = "2026-07-10T20:56:23.749Z" }, { url = "https://files.pythonhosted.org/packages/11/0c/c134d61710e14b68b010215dcf6bd57d2ec05cd169dff8bfab8fefc2d410/hypothesis-6.156.6-cp314-cp314t-win_amd64.whl", hash = "sha256:38cd0c4a7b9f809f1e23a4d15adfa9c5d99869b9afc327350a5e563350b78e48", size = 637862, upload-time = "2026-07-10T20:56:13.347Z" }, { url = "https://files.pythonhosted.org/packages/59/9c/b94f3a31665527b6181616b72990fcf8d6d5fa82b4187aab104ab5f548f0/hypothesis-6.156.6-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:8893b4da90e06828846c1b100c3414a7729d047a020d854c0899ae9339df0e70", size = 749575, upload-time = "2026-07-10T20:55:29.371Z" }, { url = "https://files.pythonhosted.org/packages/21/74/dcf695f79f526543ae5d0f8c1325508e9fe990a996c0e0853129a9a5d81d/hypothesis-6.156.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b7fc0b7df9b28d028e4cc295b2ac8fbbbc22e090a23382c92fff5e37696be74f", size = 744351, upload-time = "2026-07-10T20:56:36.46Z" }, { url = "https://files.pythonhosted.org/packages/11/d3/5bff4c55c6995a6c43f66ec8e5866b56e34f03837fd0be0e4922f3bab168/hypothesis-6.156.6-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38ed3178526382d392d04ad699ad7a2e53845e521a09d40f1cbbc1e1ff63ba48", size = 1070916, upload-time = "2026-07-10T20:56:19.256Z" }, { url = "https://files.pythonhosted.org/packages/ba/af/5ed42117a69221ea118caaff933d8212039a0ac0bc15afa915635f13984c/hypothesis-6.156.6-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ad94e28aabf4db0d479297d43b8a2a01e7caaa9bdfccfdac7a4a3717e05b993", size = 1122625, upload-time = "2026-07-10T20:56:14.758Z" }, { url = "https://files.pythonhosted.org/packages/3b/d3/02499badc6e3f3e980941021edf5fd780c895d8d08c9015e78516340ed83/hypothesis-6.156.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:983a5cfd955994bffc7eb02976241f7a1f3c2d94dbc3389430c45858fa5c1ae0", size = 640823, upload-time = "2026-07-10T20:55:45.461Z" }, ] [[package]] name = "idna" version = "3.18" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] name = "imagesize" version = "2.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, ] [[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] name = "jinja2" version = "3.1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] [[package]] name = "librt" version = "0.11.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092, upload-time = "2026-05-10T18:15:34.795Z" }, { url = "https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035, upload-time = "2026-05-10T18:15:36.242Z" }, { url = "https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022, upload-time = "2026-05-10T18:15:37.56Z" }, { url = "https://files.pythonhosted.org/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size = 467273, upload-time = "2026-05-10T18:15:39.182Z" }, { url = "https://files.pythonhosted.org/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size = 497083, upload-time = "2026-05-10T18:15:40.634Z" }, { url = "https://files.pythonhosted.org/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size = 489139, upload-time = "2026-05-10T18:15:41.934Z" }, { url = "https://files.pythonhosted.org/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size = 508442, upload-time = "2026-05-10T18:15:43.206Z" }, { url = "https://files.pythonhosted.org/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size = 514230, upload-time = "2026-05-10T18:15:44.761Z" }, { url = "https://files.pythonhosted.org/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size = 494231, upload-time = "2026-05-10T18:15:46.308Z" }, { url = "https://files.pythonhosted.org/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size = 537585, upload-time = "2026-05-10T18:15:47.629Z" }, { url = "https://files.pythonhosted.org/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size = 100509, upload-time = "2026-05-10T18:15:49.157Z" }, { url = "https://files.pythonhosted.org/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size = 118628, upload-time = "2026-05-10T18:15:50.345Z" }, { url = "https://files.pythonhosted.org/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size = 103122, upload-time = "2026-05-10T18:15:52.068Z" }, { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" }, { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" }, { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" }, { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" }, { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" }, { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" }, { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" }, { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" }, { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" }, { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" }, { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" }, { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" }, { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" }, { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" }, { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" }, { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" }, { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" }, { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" }, { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" }, { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" }, { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" }, { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" }, { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" }, { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" }, { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" }, { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" }, { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" }, { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" }, { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, ] [[package]] name = "lightgbm" version = "4.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "narwhals" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.5.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "scipy", version = "1.18.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/63/8e/4db5e29290d7e619c307fdb8dab0a0514090af2ce3ec483050e024ec6126/lightgbm-4.7.0.tar.gz", hash = "sha256:f8e20f682c9aabd000bcf4a7ed8aa6f473c1adfecccae34ec24e823d156f4af0", size = 1792896, upload-time = "2026-07-18T21:00:56.139Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cd/05/7213965863cba1ed0150ad045bceed6276a1afaaaedbaeff4699ec4f0ccb/lightgbm-4.7.0-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:dfc1cfe8e760387be1e7ba7a214688be21fdff96e4ed9749188f83e1877c2477", size = 1877851, upload-time = "2026-07-18T21:00:35.225Z" }, { url = "https://files.pythonhosted.org/packages/b2/86/f4fe714f2e0bf3941705a20d7f6849dc476276d71236e82ea6b0d6539b86/lightgbm-4.7.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:129535462686f274df179133643118c5c5c5667167fe6c3a28d955f0b3c8e868", size = 1498914, upload-time = "2026-07-18T21:00:36.549Z" }, { url = "https://files.pythonhosted.org/packages/c6/a3/b29580948b92e8c2f84dea70118ac702ff067dc52ec4ffb5d73c953536a5/lightgbm-4.7.0-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4529acec5c6fefe4768302a529707d0ead90f6a6f42df694b856212e09695b8", size = 3349492, upload-time = "2026-07-18T21:00:37.943Z" }, { url = "https://files.pythonhosted.org/packages/15/eb/837ea3b40cc36e22eeebb9785c01e42b2c255d033eea1d2d9ee8e2540e55/lightgbm-4.7.0-py3-none-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d23e922acd891e77212e4d0fbcee9ba973c96dee479491341d05ba595357ebb7", size = 3476028, upload-time = "2026-07-18T21:00:39.331Z" }, { url = "https://files.pythonhosted.org/packages/d5/0b/c5c17d862b12ce292f24cd85d40f2f8f8981668fbdbd43fdc2625eccbc79/lightgbm-4.7.0-py3-none-win_amd64.whl", hash = "sha256:f42d1e5b32b6f170e606d7c689c6165671da98d7bf37f1addec2623efc8740c9", size = 1360833, upload-time = "2026-07-18T21:00:40.865Z" }, ] [[package]] name = "markdown-it-py" version = "4.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] [[package]] name = "markupsafe" version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] [[package]] name = "mdurl" version = "0.1.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] [[package]] name = "mypy" version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ast-serialize" }, { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size = 14691685, upload-time = "2026-05-11T18:33:27.973Z" }, { url = "https://files.pythonhosted.org/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size = 13555165, upload-time = "2026-05-11T18:32:16.107Z" }, { url = "https://files.pythonhosted.org/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size = 13994376, upload-time = "2026-05-11T18:32:39.256Z" }, { url = "https://files.pythonhosted.org/packages/d4/fa/285946c33bce716e082c11dfeee9ee196eaf1f5042efb3581a31f9f205e4/mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398", size = 14864618, upload-time = "2026-05-11T18:34:49.765Z" }, { url = "https://files.pythonhosted.org/packages/2b/83/82397f48af6c27e295d57979ded8490c9829040152cf7571b2f026aeb9a0/mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563", size = 15102063, upload-time = "2026-05-11T18:34:05.855Z" }, { url = "https://files.pythonhosted.org/packages/40/68/b02dec39057b88eb03dc0aa854732e26e8361f34f9d0e20c7614967d1eba/mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389", size = 11060564, upload-time = "2026-05-11T18:35:36.494Z" }, { url = "https://files.pythonhosted.org/packages/cf/a8/ea3dcbef31f99b634f2ee23bb0321cbc8c1b388b76a861eb849f13c347dc/mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666", size = 9966983, upload-time = "2026-05-11T18:37:14.139Z" }, { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" }, { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" }, { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" }, { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" }, { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" }, { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" }, { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" }, { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, { url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" }, { url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" }, { url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" }, { url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" }, { url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" }, { url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" }, { url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" }, { url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" }, { url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" }, { url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" }, { url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" }, { url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" }, { url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" }, { url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" }, { url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" }, { url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" }, { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, ] [[package]] name = "mypy-extensions" version = "1.1.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] [[package]] name = "namedivider-python" version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "lightgbm" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.5.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "pandas" }, { name = "regex" }, { name = "typer" }, ] sdist = { url = "https://files.pythonhosted.org/packages/34/ec/2ad22a0c09101278b4541334b1b3468ce436f46a9150a3339e66f3066168/namedivider_python-0.4.2.tar.gz", hash = "sha256:0c5975511edffcd7cadad705cb903ab24ece9a0a12db65e6a9358e1624d709f6", size = 38063, upload-time = "2026-10-12T06:13:42.804Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f7/7d/3b7a793ac2660ddad925ba2f5de226bea958ee340ec3c89bded3da954a48/namedivider_python-0.4.2-py2.py3-none-any.whl", hash = "sha256:e4fe25f17d1da59612e1556dab96eccad53017f320db84cc855e670ef7eb4b4e", size = 49239, upload-time = "2026-10-12T06:13:41.561Z" }, ] [[package]] name = "nameparser" source = { editable = "." } [package.optional-dependencies] ja = [ { name = "namedivider-python" }, ] [package.dev-dependencies] dev = [ { name = "dill" }, { name = "furo" }, { name = "hypothesis" }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-timeout" }, { name = "pytest-xdist" }, { name = "ruff" }, { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] [package.metadata] requires-dist = [{ name = "namedivider-python", marker = "extra == 'ja'", specifier = ">=0.4" }] provides-extras = ["ja"] [package.metadata.requires-dev] dev = [ { name = "dill", specifier = ">=0.2.5" }, { name = "furo" }, { name = "hypothesis" }, { name = "mypy", specifier = ">=2.1" }, { name = "pytest", specifier = ">=8" }, { name = "pytest-cov", specifier = ">=6" }, { name = "pytest-timeout", specifier = ">=2.4.0" }, { name = "pytest-xdist", specifier = ">=3.6" }, { name = "ruff", specifier = ">=0.15" }, { name = "sphinx", specifier = ">=8" }, ] [[package]] name = "narwhals" version = "2.24.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/2b/1d/58946e5aab18393e793bd4add6985b95d0e01c3a2d832f38f54468b10dcd/narwhals-2.24.0.tar.gz", hash = "sha256:b5c0f684ccd9d7475b564111e319a4964abcf2baf79d3cf6b1003d06ac9b828d", size = 661143, upload-time = "2026-07-13T10:49:19.086Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/7e/85/a5bfaebfd305ac18b57b0854d74e37e586809061a91fda62f0bd50c8518e/narwhals-2.24.0-py3-none-any.whl", hash = "sha256:42fdedf44e5b2ca7505630d45b4ac3058f38d8485cba9fe1652ca23152df7489", size = 461030, upload-time = "2026-07-13T10:49:17.571Z" }, ] [[package]] name = "numpy" version = "2.4.6" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.12' and sys_platform == 'win32'", "python_full_version < '3.12' and sys_platform == 'emscripten'", "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, ] [[package]] name = "numpy" version = "2.5.4" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15' and sys_platform == 'win32'", "python_full_version >= '3.15' and sys_platform == 'emscripten'", "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.14.*' and sys_platform == 'win32'", "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] sdist = { url = "https://files.pythonhosted.org/packages/95/b0/c7453d0b6e2073c3264468b106ee1563750cecc910965e67357e3698c83e/numpy-2.5.4.tar.gz", hash = "sha256:9a94cf751c9ad8ebaa835bcd3d40dacf8534ad086b88c38029b65123c7999d2a", size = 20866315, upload-time = "2026-10-10T20:05:31.422Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d0/97/ba2074e92b7befea137e77ea8471e768bbd87c339b7e8c9f5a931949f977/numpy-2.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c6342f54c67093cae5c0227eb0eb772fdb79f2a2c37a6eb278b9909ee06aa356", size = 17001609, upload-time = "2026-10-10T20:02:40.843Z" }, { url = "https://files.pythonhosted.org/packages/ff/a9/bac826765e971d8e16e2064e9ac7525fd69b40ac17c905033a7f5442023f/numpy-2.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b11e8fda06a7d69f15ebf542660b74466c2e51094800c1fb794f47ad4faeef17", size = 12015718, upload-time = "2026-10-10T20:02:43.45Z" }, { url = "https://files.pythonhosted.org/packages/31/2f/5ea3570fcb8ccd0882bea99436a513b2c85dad8f774a2057849130a8fb99/numpy-2.5.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9cb18a327b49c5c337f972b03682f6a49855525faaf3c0d3e9c96cd0fd8880a8", size = 5451717, upload-time = "2026-10-10T20:02:46.169Z" }, { url = "https://files.pythonhosted.org/packages/34/f2/b4fc1bafca03868220b5eaf729d2f21ebd7d7b151c0f9e144fe212bbca35/numpy-2.5.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:aec3fc4b32ff82421274f5d205c559c51c840c8df66a78efd7f3612dd005a26a", size = 6789926, upload-time = "2026-10-10T20:02:48.139Z" }, { url = "https://files.pythonhosted.org/packages/dc/96/8319e2457ae4333c62c815c7006b869a4f60985c1e01024c2f8c6c040fe5/numpy-2.5.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe4d21ab149f15e4e6043dfb0de87e6e5f34ac176cde83060e9802981fca2ac2", size = 15695312, upload-time = "2026-10-10T20:02:50.115Z" }, { url = "https://files.pythonhosted.org/packages/43/a3/c799c62e19c337e6d3770b08e475887fb30ce8477d3c09efca6b2f0228a6/numpy-2.5.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fbde6962867ee75b48b0ee29b2b9372ec5d617799dbaf38e82dc0596f2f7738a", size = 16727283, upload-time = "2026-10-10T20:02:53.186Z" }, { url = "https://files.pythonhosted.org/packages/39/6b/3604e53fb00314d0dc1b94ec9125a1484f649c0a17480b1f0f0c7a9d6250/numpy-2.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:381a7a3d2e65e64c0ec302795ab9dc12bb1e73f150904699c153716177eebdaf", size = 17047890, upload-time = "2026-10-10T20:02:56.038Z" }, { url = "https://files.pythonhosted.org/packages/4a/7a/e8b58a5289a0d464c52885de47c35a935cdd70c03a4c3ab94a5126416dd0/numpy-2.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b89d0aaae2fe498c648f4c4795c084db535af5bd98ef942b2a3681fb74ce8645", size = 18485839, upload-time = "2026-10-10T20:02:59.018Z" }, { url = "https://files.pythonhosted.org/packages/6f/c9/47094f597015009f310b8c900def59065ef1ff5a6fe7b51fc65ec58ec2c6/numpy-2.5.4-cp312-cp312-win32.whl", hash = "sha256:9968ab7e49b93ac6e1c3b2239732183152c9150f16308d30b66a372cffe3483c", size = 6138936, upload-time = "2026-10-10T20:03:01.626Z" }, { url = "https://files.pythonhosted.org/packages/12/33/fefe62073dc8acfd0f2b9ed7c003af2f50aa61555e113e6db02b8f79f145/numpy-2.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7b1b6353e36a7e50de2973a38d705c88ee93adcf120673cee7f45a4a3fa223a", size = 12573091, upload-time = "2026-10-10T20:03:04.349Z" }, { url = "https://files.pythonhosted.org/packages/1a/07/161270b0c2eec56e4c905f6d6d22e1b836887b2cb189d3f5820aa588e9dd/numpy-2.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:aa1cce2ff3f8d953de38b76bf44602caeb69f101430208f64a10067f7cb4b1d3", size = 10521630, upload-time = "2026-10-10T20:03:06.767Z" }, { url = "https://files.pythonhosted.org/packages/67/14/1c3ee0118a8fce08565a5d8482631608426a33af10a01077fada5dc7c119/numpy-2.5.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2377da2dd3ba2c1200956acbab2a358c83b8e1f8531191672d1cd6ad83250d53", size = 16997729, upload-time = "2026-10-10T20:03:09.291Z" }, { url = "https://files.pythonhosted.org/packages/83/8c/b0ea9477fb1f0d4484bbc5cba21678cc9969704d8d7f3f158d1db35f8e14/numpy-2.5.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7415db95818b39ec475a5eea54d9e3b6bc83e3912158e46da3438cdce399804d", size = 12009826, upload-time = "2026-10-10T20:03:11.946Z" }, { url = "https://files.pythonhosted.org/packages/e2/84/6a3d75b3ba3dfe84ac0053450753d1e6d250a8bf80f66474cc46d1fb643f/numpy-2.5.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6d6a71b9d9a97c03633aa12565ef2825ffa036cc1d99cfd50dacf0f128af4fe2", size = 5445803, upload-time = "2026-10-10T20:03:14.329Z" }, { url = "https://files.pythonhosted.org/packages/61/18/bb993f267ca20b376e07092a16793a5b31ed3138751e9ba480011a14d742/numpy-2.5.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d8200f16437b289a5bb927c6e184eccc3e8389bc0070fea4cd5b9e13c1757959", size = 6786220, upload-time = "2026-10-10T20:03:16.602Z" }, { url = "https://files.pythonhosted.org/packages/db/b6/135bb0953b61dc21c6cafa14b424ae666944e4899cf140e00c2b322a1a45/numpy-2.5.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2e71b04c6cad90026e544501bbe0ab9290fa8a4d845e7e8c0d124fb429c988", size = 15689178, upload-time = "2026-10-10T20:03:18.721Z" }, { url = "https://files.pythonhosted.org/packages/da/24/3bd070f3269dc609d8f26b2643f62ef91bb415841c0b294805aaf7fe06da/numpy-2.5.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6ffa07666f8da0eef81d149934a626d0d95fbd6838432a33e66245423a9062c0", size = 16718044, upload-time = "2026-10-10T20:03:21.386Z" }, { url = "https://files.pythonhosted.org/packages/c7/8e/9d15bd356b0a019c965312b1a3c6a727cac4cae5bc40045fbc12ce4cff9c/numpy-2.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2fa3328f784fc8277fc48026f6cad516f5c561c5d8e2e39b3c9e0c8f23223b34", size = 17048364, upload-time = "2026-10-10T20:03:24.468Z" }, { url = "https://files.pythonhosted.org/packages/dc/fe/9d5b560db964f15871885f2250795d15945f8699e17ef90c0c2ff4c875b2/numpy-2.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b86966fbe4ad7de710422175572bcdc75fdedadfb54bc6fab7deabccddd7780b", size = 18474904, upload-time = "2026-10-10T20:03:27.895Z" }, { url = "https://files.pythonhosted.org/packages/e9/98/d27552990f1bd611ef3e7466adadc78312ea2df63b83aad47fdc3d3ca8df/numpy-2.5.4-cp313-cp313-win32.whl", hash = "sha256:5258bc06526964be5face2fc6f756857a3f24f21ec3e72ca131337a75b165d6c", size = 6134537, upload-time = "2026-10-10T20:03:30.511Z" }, { url = "https://files.pythonhosted.org/packages/90/8c/140a40398a66b4471211be1affdb6ed24c486d581bd28d07b7f2fcb69540/numpy-2.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:8b4d2fd2d34e5f8c9235ee787de5631a37a28402b15cb80814df973d2be54129", size = 12566113, upload-time = "2026-10-10T20:03:32.612Z" }, { url = "https://files.pythonhosted.org/packages/34/52/01d205e5e8ccb27b2b0b141e801f22b830198c979111b0fa44771438d9a9/numpy-2.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:bc39ac66a7a9a3fbd6134fda43136b60ffde99c8f4501e64e0d2b24da137babf", size = 10519523, upload-time = "2026-10-10T20:03:35.163Z" }, { url = "https://files.pythonhosted.org/packages/99/ba/005cb5edd580d2f84d7ca3206b92dc17d4388e56e6f87ffe8f2762f83139/numpy-2.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c668b2f0d651605b58892644b0e302c7157f7159544227758c896982ef384b18", size = 17005499, upload-time = "2026-10-10T20:03:37.961Z" }, { url = "https://files.pythonhosted.org/packages/f3/49/fee7587c33ee35f7977f9051d7f2023d4e7246d62710c80f20c2361ea232/numpy-2.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ffa6ce09a1c6a08e9667dd9c97aa0b14184e8d18f2a14b78b2a2328c9147f076", size = 12019666, upload-time = "2026-10-10T20:03:40.606Z" }, { url = "https://files.pythonhosted.org/packages/d5/b2/c6ce165acffceb15a82c07b9cc77d391f86b3f379ba62911908ae5d34b91/numpy-2.5.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:956555e0603a4d38019ae6925711cb9dc43195c076a928accf7ea5d50bddfe53", size = 5455617, upload-time = "2026-10-10T20:03:43.138Z" }, { url = "https://files.pythonhosted.org/packages/77/7f/dd85ce260a669a89be06842cf355d7353a33e6cfbc590fb8ebb947d88dc9/numpy-2.5.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:2c2c4afffdeb7920e445028dd71eb932cac3e704792e964bc2a232426d4f1255", size = 6791932, upload-time = "2026-10-10T20:03:44.874Z" }, { url = "https://files.pythonhosted.org/packages/63/d6/34b0a2b0741386a63025a65a2c09caaaaaad6d0ca95b66cd65c30dd7fcb5/numpy-2.5.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4054173604cd8658796053f1f3bc0befb68ec1c0762c57fdad61e199256a8617", size = 15710899, upload-time = "2026-10-10T20:03:46.839Z" }, { url = "https://files.pythonhosted.org/packages/16/d5/928078d2b28f26829b138b4a6c3980045022fb409f570657a224ae60ef4e/numpy-2.5.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d549420b8858885cea8838a727842249218b9c1da24dd517e25c9c7a948310a3", size = 16721710, upload-time = "2026-10-10T20:03:49.489Z" }, { url = "https://files.pythonhosted.org/packages/f9/cf/673fd1b8f4cd78eb6320e87ec4c90ac19c095644259e3749853a405c70f4/numpy-2.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:823874a507a84af050493b622affde94b6f7c3a0dc22cb2801381bc03b871c00", size = 17066182, upload-time = "2026-10-10T20:03:52.25Z" }, { url = "https://files.pythonhosted.org/packages/f3/92/a77b5061b1b3e2643928c37976d79ee173e1b171ed158b7a3c61056b41bc/numpy-2.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4e263278bfb5ee6409db8aedbc4cc32973b1b82bc1e8d3c668551d04d83a7e37", size = 18480315, upload-time = "2026-10-10T20:03:55.39Z" }, { url = "https://files.pythonhosted.org/packages/bb/1d/1486ef3d3fb2279fd93c4c43c1bbbf1ca389a19816696684409f71babaab/numpy-2.5.4-cp314-cp314-win32.whl", hash = "sha256:cfd73180400042a7c532d30c5e287bdd03c59ff9ee1b4c0316af0539e29dfe23", size = 6185739, upload-time = "2026-10-10T20:03:58.186Z" }, { url = "https://files.pythonhosted.org/packages/52/9a/e1e512ebc948d5b9dd33b08736760f0ebbed2848fd4eda1f553088a6dcee/numpy-2.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:2ca144f15135b6212a5c47b1e2aeca6e412f102f95a2d5d88d8aec77eb255de3", size = 12703552, upload-time = "2026-10-10T20:04:00.28Z" }, { url = "https://files.pythonhosted.org/packages/2c/05/de709a982d7bbcd688a3fad71f002e9ff80c2db39e03ee726609b610f1d1/numpy-2.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:468397ba3c64427474706e5c9123fe266395496714dc684294eac75cd4930d1e", size = 10803901, upload-time = "2026-10-10T20:04:02.659Z" }, { url = "https://files.pythonhosted.org/packages/13/34/083570ada3bb2a30fbe5d77c8c6fef9141144a15d33e6f793a67e9749ab8/numpy-2.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1ef3aa6d7e29bb13677323114280b05acc57607fa2300e66432d665d5418a162", size = 12138695, upload-time = "2026-10-10T20:04:05.012Z" }, { url = "https://files.pythonhosted.org/packages/94/06/1f9c24db48eef0c2d1207e3b11fffb0478e39dfd8c1e1be7476936885eed/numpy-2.5.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:98b053943e5a0474ec0da309d2cb9d3f18ea57f8a2067c2ab7b5f763d1068380", size = 5574615, upload-time = "2026-10-10T20:04:07.316Z" }, { url = "https://files.pythonhosted.org/packages/da/0f/593fba2e1560e949123bc7d2fc48b5893d56e58cd4bd5a273d2fbf60b220/numpy-2.5.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:b64a85f40e154983960a4167d4c1d57a50c7f109b3d3264a3a984154e90a8454", size = 6889383, upload-time = "2026-10-10T20:04:09.918Z" }, { url = "https://files.pythonhosted.org/packages/eb/9f/b799dfdce4e05e80ed4bc815c71ff343a11533b2c0ffc221cae8538cda63/numpy-2.5.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a813ed7719bf45463c51779e6a98d0385fe905e48447526938a4b8337333d551", size = 15753763, upload-time = "2026-10-10T20:04:12.278Z" }, { url = "https://files.pythonhosted.org/packages/34/88/16c5f12f86f5ad2817c4d103205131fc6c8acb3d1878af05a1a4f23ec859/numpy-2.5.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9b80cdf5cedba0e90d93fa5f9a333c4d65bd545cd669b71bb97ce2b703c9d73", size = 16757212, upload-time = "2026-10-10T20:04:14.799Z" }, { url = "https://files.pythonhosted.org/packages/ff/4f/a1fe40e18a898e6a5089f4f0d891f0a493eb0574d5b34458f0fbe5aa3e5c/numpy-2.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2199ed071f460487c8db2c0e5c0b564494190edb4772fe80f9aad88b2604def5", size = 17116471, upload-time = "2026-10-10T20:04:17.58Z" }, { url = "https://files.pythonhosted.org/packages/aa/46/e923a11c78e65c1722e7aaad817c06bd591324174b9d28ce5d31eee4d432/numpy-2.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:64f9c9878c1938476365e11ccfb6b770f3b9e5f045ccddc514235041e6959365", size = 18524063, upload-time = "2026-10-10T20:04:20.365Z" }, { url = "https://files.pythonhosted.org/packages/5a/fa/84ab064514440c1f64a1b21088f2c82756defdd05e07c75ab233899565b2/numpy-2.5.4-cp314-cp314t-win32.whl", hash = "sha256:64d1c8ac28a4077cf987e0a71a7a0ef7e2df70722f07f0baa42dbb7eb6938647", size = 6340926, upload-time = "2026-10-10T20:04:22.865Z" }, { url = "https://files.pythonhosted.org/packages/7e/7e/6cd886876f435b10685db9b9f7eeb70356f99e052116f4e5f11c5792c714/numpy-2.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:067374eb538c34c745436365cf7b0112595c1d326f21ce4ff340f61230239fbb", size = 12901584, upload-time = "2026-10-10T20:04:24.99Z" }, { url = "https://files.pythonhosted.org/packages/38/1b/3c1684f6a06f7307f2335fca6e486cb162847fb97e91d65f8eb5cabad213/numpy-2.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:e94aef2c639da4a960ad0db8e06471208d8589974953d78b61d345b4eb99e394", size = 10891152, upload-time = "2026-10-10T20:04:27.52Z" }, { url = "https://files.pythonhosted.org/packages/08/f4/3224deff3af2bef6bc0b175369698d8cb348f3d91d9bb0286cd5c9eae9e0/numpy-2.5.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8dddfbee2e68d26d0d7d7d9cb247b1fd4409241cce32d815a11d97ec2cfde179", size = 17003231, upload-time = "2026-10-10T20:04:30.021Z" }, { url = "https://files.pythonhosted.org/packages/be/75/fee0b8c6d94b44b2fdfae74f6a4ad5a138739589a8aebaec28ce4e713ed5/numpy-2.5.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:81e3420b27048b65eb14c3acf0c174a8cb0e023277716110347d2dcb26026dad", size = 12018300, upload-time = "2026-10-10T20:04:32.519Z" }, { url = "https://files.pythonhosted.org/packages/47/c0/d0b335a499a04b65f532c3f034346ef390f81299060f928492dabc1e0272/numpy-2.5.4-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:0b4724a19de67bea8cfc4970798efa78bcbbe2ac2613cfac16721a42d44de2a5", size = 5454250, upload-time = "2026-10-10T20:04:34.943Z" }, { url = "https://files.pythonhosted.org/packages/5a/0e/461b3783c03d668052e6a21b01b673db6ffcb7831fd32d9aa5368c1cd426/numpy-2.5.4-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:2132418bf8dd124a427ca9e6a1daf9ee1a87185344c95119ceae868b99466da1", size = 6789644, upload-time = "2026-10-10T20:04:37.258Z" }, { url = "https://files.pythonhosted.org/packages/b3/02/5dad269b02166965a7b4ca14adaddd75dbee0de42435bfecf561b84ba5a6/numpy-2.5.4-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:325518d4245b9e331387702aa58c2ce1dc4cdcbb41dfb4ccd5dcbc7e08db1266", size = 15704353, upload-time = "2026-10-10T20:04:39.616Z" }, { url = "https://files.pythonhosted.org/packages/93/3a/01360c8036822ed9f7aa32189a77d1476567ec1e8e1383522389e4faac45/numpy-2.5.4-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56733449d2544178beaa4545cee357370440cf056c197f9c7bfb19dbfdd0e86d", size = 16718648, upload-time = "2026-10-10T20:04:42.383Z" }, { url = "https://files.pythonhosted.org/packages/7d/5c/b863a2c093c4d6f21a597fcaf24ead0835c09ab16a8312d5a5a8868af683/numpy-2.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:5ec3753760c1a6d8bb91200666e545c3a9728e6269dfb5d6ce02340996698aa3", size = 17059053, upload-time = "2026-10-10T20:04:44.976Z" }, { url = "https://files.pythonhosted.org/packages/0a/60/ced4f57f9a1258a0af74f17cb0b0c2700b5c67cd6678823c803b263e4df3/numpy-2.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b1185012870173de7ae33d370bd45b1cf5baee747ea4b97036b65f4e93016877", size = 18477406, upload-time = "2026-10-10T20:04:47.863Z" }, { url = "https://files.pythonhosted.org/packages/f9/bd/0ef22dafaafcc7d4bb3ca26b8d2afbd55dedad8eaba99a8c864e1997456f/numpy-2.5.4-cp315-cp315-win32.whl", hash = "sha256:298eca75243f2cbbfdb460560b9fb2a1792a33cf2ab4286efd43d92e8d3df508", size = 6185133, upload-time = "2026-10-10T20:04:50.467Z" }, { url = "https://files.pythonhosted.org/packages/50/bc/d2651b155ecc608a77e6f4d15495c11f14f19bb98f8bf0c5b0d38f86dda1/numpy-2.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:332f3378fe077dd850e677ec01bdcc4f22368fb5d50ef10b2c79230b1bf5a592", size = 12703085, upload-time = "2026-10-10T20:04:52.63Z" }, { url = "https://files.pythonhosted.org/packages/dc/d2/45e404f8abb26fb9eda12b94012936873e827b1be76f2ee7890be128312e/numpy-2.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:d4cccbbc78717966f764cd3af4fb70276fa01fc7a2688af11c78901fa5c04f05", size = 10801451, upload-time = "2026-10-10T20:04:55.677Z" }, { url = "https://files.pythonhosted.org/packages/c6/c3/2ae14e09cfdb67dc187a342e15308a21c15bf4d2071f8079e6aee5fe56dc/numpy-2.5.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:950ea81d57ef070665581b6e1b5f6a029306423cd1739c5b95fe78aa30db6b9d", size = 17097121, upload-time = "2026-10-10T20:04:58.403Z" }, { url = "https://files.pythonhosted.org/packages/f5/cf/305ae624ef8a039414317224abe9ec9c2fe7ea3c2e1cf204d43ff6b2ffb9/numpy-2.5.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c05ede731b03fb1b7591faca9389ade3267d2bddf1ad8882bb3f2cc5e101694f", size = 12135439, upload-time = "2026-10-10T20:05:01.65Z" }, { url = "https://files.pythonhosted.org/packages/a9/a8/f75c63813aef95827bb2c0d13b12803016853056e8792c280058cdbfe783/numpy-2.5.4-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:5fbf7141bbfd63aea22f435c9062a032b9ea0082fe9845dad7f021d3f1234e71", size = 5571451, upload-time = "2026-10-10T20:05:04.135Z" }, { url = "https://files.pythonhosted.org/packages/6f/0f/f17763f983868b5c49b4101ebd7e00760bd1769478a6bb6a8de6e085bbac/numpy-2.5.4-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:3573cd22564692a5b899ec344e5d5b9cc4576f2985b96f22af3564ed54f2710f", size = 6883356, upload-time = "2026-10-10T20:05:06.249Z" }, { url = "https://files.pythonhosted.org/packages/67/a7/8af04c5a79e047996cfa38854dcfbececdd0343a7c933a46fdd03ef6f5da/numpy-2.5.4-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c109eac9cd439193678f69d70733c1108487546ca8eafc107b510ae10c1aecd", size = 15750991, upload-time = "2026-10-10T20:05:08.376Z" }, { url = "https://files.pythonhosted.org/packages/57/7a/648254290d0c504faa8f2d07aa206660c728802c781a6f3fc68ab7cb5d71/numpy-2.5.4-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80d6ef6e8620eb2c2b4c4caad50b5935d6db3cde2d51581b55dcc79e14016d1d", size = 16757675, upload-time = "2026-10-10T20:05:11.393Z" }, { url = "https://files.pythonhosted.org/packages/b8/fe/4a8c3cdb0c70400cfe4c5bec42d3099a5673802a95064614b33e07b82aa1/numpy-2.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:77045a4b175bbf5316ec08003880804336c78f92281a1b72222b274ea85ec5ac", size = 17113846, upload-time = "2026-10-10T20:05:14.49Z" }, { url = "https://files.pythonhosted.org/packages/1b/7e/619692bb67778702c0e9eb2d468568a7573f4e269386ea61aed01ee4e557/numpy-2.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:0f02a46e49cfb6c73bdb7aea1c0d3461dbae9aba613542b65f657cd3d17b9fab", size = 18522915, upload-time = "2026-10-10T20:05:17.33Z" }, { url = "https://files.pythonhosted.org/packages/b7/b5/4da41c328788f575838f97a098fe8ca691ebc6f6fd73ad4a262ee40b184d/numpy-2.5.4-cp315-cp315t-win32.whl", hash = "sha256:ad62a416ddcf863bf44bba76fbf6b53366ab0692e294f51cae4b5fbe0d246788", size = 6335804, upload-time = "2026-10-10T20:05:19.921Z" }, { url = "https://files.pythonhosted.org/packages/98/94/6482ddfa3d312490cb9358f375bf2ad56427dbea8769187158e94d653753/numpy-2.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:38f47be9f74ab870d2633b5456ae519c43758a8d1fd05342f0ce4ecc034396ee", size = 12890095, upload-time = "2026-10-10T20:05:21.875Z" }, { url = "https://files.pythonhosted.org/packages/48/7f/c2d1b436b6e7cfebac140c2579a298344b85f2991a2ce5c3615cefb29400/numpy-2.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:7a14a461d9340f1b46b8648578aed9cdb8b3b018a8fac6c1dde2c9192a01a87f", size = 10883718, upload-time = "2026-10-10T20:05:28.547Z" }, ] [[package]] name = "packaging" version = "26.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] [[package]] name = "pandas" version = "3.0.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.5.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "python-dateutil" }, { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e2/17/d7b106e05bfa642e8694451e7d3d759c6a241c5386a5d962e4f66c047e06/pandas-3.0.6.tar.gz", hash = "sha256:66b07ef7315a31bfe1089cd3d71a7de781c9dca986762d0b4fe7c0ef17465d10", size = 4667686, upload-time = "2026-09-17T23:23:18.345Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/7d/48/88e8d250d28efa8163294f6809a71683c7ee67f63ac7c33021c0503b3547/pandas-3.0.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:085e3786ae6b2e82b406266bce36690f72b9dc1421903ba9296b2981a9fcf586", size = 10391798, upload-time = "2026-09-17T23:20:20.96Z" }, { url = "https://files.pythonhosted.org/packages/55/a6/39db5d41f3eb5d7846626312cb30e0cea48e988fc47a3725698bc931ad3c/pandas-3.0.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d7564d86a94c2eb8ab290b07f63ddaae5c032fa53897c29a2ff2197d43aee8af", size = 10023718, upload-time = "2026-09-17T23:20:25.094Z" }, { url = "https://files.pythonhosted.org/packages/54/b7/707e966129f77ee8a39d41a41bbd989b790b3fef581c6e26b821315cba6b/pandas-3.0.6-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7c0afdcaf6661d795fcefc2f647ddd1136f62cdc153fba177c685d97a87808", size = 10612800, upload-time = "2026-09-17T23:20:27.99Z" }, { url = "https://files.pythonhosted.org/packages/63/be/dfb6cc9329d0bbe76dadda8a1113d026bb996ec76c509221368dc68349f4/pandas-3.0.6-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47121f9571503f724c9b93e297ab6254ac99c77adf5e9ed085ea419fd585c258", size = 11108900, upload-time = "2026-09-17T23:20:30.65Z" }, { url = "https://files.pythonhosted.org/packages/1a/6f/3d58f15bbe972f3d7bfa13ee06d731785a76fe8d232c92b2655a8de14127/pandas-3.0.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:994a79608263fe1c14cc48ffa7300e2b834b7d1cb406ffe96a08828cb0cdd79b", size = 11630226, upload-time = "2026-09-17T23:20:33.582Z" }, { url = "https://files.pythonhosted.org/packages/58/54/9b494de4a3dd92fc6eb19187db1b21afb50fdc21962f32ea1aca9f270cb2/pandas-3.0.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a3a22e07fe75347eaacc75b0e85297947af4fba6b4aae23916bd8b6828d0bba3", size = 12146942, upload-time = "2026-09-17T23:20:36.792Z" }, { url = "https://files.pythonhosted.org/packages/d3/dc/d2df02854aec5d47659acfb2be352eecc691845b2f86e99c84f1010a8671/pandas-3.0.6-cp311-cp311-win_amd64.whl", hash = "sha256:2e5fa32ff162dfdbc280157d664f44d23049ae414725af9676df339c501d82cd", size = 9859246, upload-time = "2026-09-17T23:20:43.976Z" }, { url = "https://files.pythonhosted.org/packages/79/1e/2a30df0d7dede5c195300a1820b0bc21cea3aa24e4c8b6c4431565ecc79a/pandas-3.0.6-cp311-cp311-win_arm64.whl", hash = "sha256:5e75072773c1b2f7cb63faa3a6f562aede11f3976f68ed34cb538bc091a28171", size = 9106923, upload-time = "2026-09-17T23:20:46.611Z" }, { url = "https://files.pythonhosted.org/packages/77/4c/597d588c055d4373cff19cbbc32d4dd046c7be8fadee957585b5ba9e5b24/pandas-3.0.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7dac2d65e9087e8e7b5a45fe15c4920911a221df061ab629943ce016489145c7", size = 10397411, upload-time = "2026-09-17T23:20:49.465Z" }, { url = "https://files.pythonhosted.org/packages/18/8f/48907c7c707b61a8e5018c32e1a3f70623209bfb59020a2f6196159d3aa7/pandas-3.0.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9dab635a549e58a053c7b0fa054dc0bd7be22f0ed9a720f4a85d5fb993276172", size = 10051279, upload-time = "2026-09-17T23:20:52.409Z" }, { url = "https://files.pythonhosted.org/packages/67/fa/613d867c3d9554a61bafdec6f79565c8a3e73235feb52cc4a72ad2e0fa6a/pandas-3.0.6-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3dccb584123b399c07562ac4d62543e90ede49ddf8ce3c13ffc64cbe828c281", size = 10303429, upload-time = "2026-09-17T23:20:55.597Z" }, { url = "https://files.pythonhosted.org/packages/cb/67/0c0f18e38d7f2d2af8c24b3315bc4046e73bbdd4a5540405506671ad0c0d/pandas-3.0.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0704044b676496b8350e023b09f174a26772456c974a2b11c36bebb558c9490d", size = 10788193, upload-time = "2026-09-17T23:20:58.617Z" }, { url = "https://files.pythonhosted.org/packages/39/53/1b57f3162501fe36687e4870e1b918a6458ca7386b173af75663ace95857/pandas-3.0.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e7c1905ef02c3d6d43d9dbd5b6ccb4da4870a0b0c821bbc103fbdb6f3ad2707b", size = 11323501, upload-time = "2026-09-17T23:21:01.911Z" }, { url = "https://files.pythonhosted.org/packages/f2/d2/b1182e8d39100369d7f13f4a125a3fb6b096fef112c46d0566c25781ff68/pandas-3.0.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:569e114072b24fc4970c12e2b4bab252671668a40b324318903380cab0254c0c", size = 11838173, upload-time = "2026-09-17T23:21:04.85Z" }, { url = "https://files.pythonhosted.org/packages/c7/33/5b717af24d2f27995e51e0875a269dddd373045216e34cf62e3aa764eaa1/pandas-3.0.6-cp312-cp312-pyemscripten_2024_0_wasm32.whl", hash = "sha256:2a8fc94be2ee5f1d86f97aacd8cc566f81680b6498e76f3007421bb5d98151bf", size = 7118499, upload-time = "2026-09-17T23:21:07.661Z" }, { url = "https://files.pythonhosted.org/packages/bd/2a/14b3b17cd75cef4a1ee1af4234eb41cc1afe4103b98c2d80e8916abfd42b/pandas-3.0.6-cp312-cp312-win_amd64.whl", hash = "sha256:3ef908d28590b3f42d7070e7ad8f9b34b442b260b7f3c1afb57e0040c58cdb1b", size = 9661439, upload-time = "2026-09-17T23:21:10.959Z" }, { url = "https://files.pythonhosted.org/packages/3b/11/3d580a604a1e35d69f6676847bd12db7d14344bc677b717f4413e79c5d0d/pandas-3.0.6-cp312-cp312-win_arm64.whl", hash = "sha256:f4e7c52eb108d752e7592268108fd3e98efd76d83a3125cdd06c621c2e44359b", size = 8959806, upload-time = "2026-09-17T23:21:13.851Z" }, { url = "https://files.pythonhosted.org/packages/8e/1c/143605a1f6443ad50ebda78a31e5a3a10147fec2590e931584aaa5ff0a09/pandas-3.0.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ae8073aed8e21d1a7fe263dcdc6840743549722a6738198a0a46000fa9476f2", size = 10418900, upload-time = "2026-09-17T23:21:16.594Z" }, { url = "https://files.pythonhosted.org/packages/ea/ca/87f8548f73d452aab35e4a90f8b39ae303295e0f2ef0b4055c44d6b3f1be/pandas-3.0.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:60d81f9e1799b36f3739e7fff44d1fbb2e8fd5a271b3863e03de9715fccda0fa", size = 10064785, upload-time = "2026-09-17T23:21:19.677Z" }, { url = "https://files.pythonhosted.org/packages/43/1a/d951442e5607c6e3b2462eff8f420797d428aa74b87c6ecfe4f48553626e/pandas-3.0.6-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:097090508a1dd335013d39106fc10b20f4fd4a171638e47b77d55798ed9dab6c", size = 10245290, upload-time = "2026-09-17T23:21:22.797Z" }, { url = "https://files.pythonhosted.org/packages/50/fa/96d50e1e6cd0b08b5e2b7c838f65ae644940f75a124063380b5ef73b6866/pandas-3.0.6-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e92d9fa834c7d877130027cddc0cad8dcff97c1f6cca26bd6310f847228b658", size = 10757657, upload-time = "2026-09-17T23:21:25.673Z" }, { url = "https://files.pythonhosted.org/packages/7b/12/f82d13a2cb703e1a8acee7e01fdc2b898d9cd0c00f07d1dfce63af43e350/pandas-3.0.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b27c8d890e4aa2171437ae2a39de1d215e674158e4865c4023a8b31c932513b2", size = 11249114, upload-time = "2026-09-17T23:21:28.898Z" }, { url = "https://files.pythonhosted.org/packages/1a/ce/8aef2e561a2f2c8b38c913c67373c65ba6748174e763d27c80271b24bd17/pandas-3.0.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8029ec0f1f89e4f985929ce1f6626dabf3140d61a4e9c1215afdab34eaf9a5d", size = 11820511, upload-time = "2026-09-17T23:21:32.11Z" }, { url = "https://files.pythonhosted.org/packages/c0/bd/63cb67e6903ef6d9c2871916dbcbc09d254da0fe8b870cf62e16b21945f2/pandas-3.0.6-cp313-cp313-win_amd64.whl", hash = "sha256:f3ce8a6968045481e91a3990e797e348ce13db45ee164a7095bbc824e26c09dd", size = 9638092, upload-time = "2026-09-17T23:21:34.883Z" }, { url = "https://files.pythonhosted.org/packages/75/2e/e7b35b712edb068d382ddc8b2bea8a04974100515ba2daa22b478b265842/pandas-3.0.6-cp313-cp313-win_arm64.whl", hash = "sha256:cc39303913e2ea129915670de5d1c9fbd647f543bb72e5543bac8baa94e9e42f", size = 8952032, upload-time = "2026-09-17T23:21:37.729Z" }, { url = "https://files.pythonhosted.org/packages/75/55/1a8875395b05ccd572cbca0b9255dcd2db6e6508e632a558c1a6884b39ad/pandas-3.0.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ee913a91669056c1de1a6b733fbfeab711de9e54e3bee2dfa5fe79d9457247d1", size = 10492213, upload-time = "2026-09-17T23:21:40.746Z" }, { url = "https://files.pythonhosted.org/packages/35/61/47ae13476995cc8a40cd609e93e7cf11f273d8692925c2903cb6d38aa0d1/pandas-3.0.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ff51a4459ed036e93d1eb1bb5e6e7b28685d3cb6b7c12b91c05b31024e234729", size = 10156618, upload-time = "2026-09-17T23:21:44.142Z" }, { url = "https://files.pythonhosted.org/packages/bc/f2/cc5f2adb8d6e86a85d9fb5128f8cf205a61189336f70d1f7faf0d1b53ec9/pandas-3.0.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654aae059295dbba6ecd2328ca12712a2cf1676214c8699f1c29213f7ccf9c34", size = 10375479, upload-time = "2026-09-17T23:21:47.159Z" }, { url = "https://files.pythonhosted.org/packages/ca/ba/ffdcb19be4ff6bfe7d969e7cef2c567c633df5a3a1cc1053394ad053bca8/pandas-3.0.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f51d7f651c8054c5e82a69265c98082e795d1442df7ca6edc3a545d61214b1", size = 10783244, upload-time = "2026-09-17T23:21:50.367Z" }, { url = "https://files.pythonhosted.org/packages/77/5b/e150075b2c6eb69fae896f2d9239bc6ed07db97735971d53d66de6553460/pandas-3.0.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:22172a92e7ee678ec0140c7af4fc9366b55413834a1cd86af78b3caa0b0574de", size = 11382223, upload-time = "2026-09-17T23:21:53.355Z" }, { url = "https://files.pythonhosted.org/packages/d6/8a/b441c587dc7355bf6e1f68a91b4f76a6c29740f0c23be3acc5d4ebbeea6d/pandas-3.0.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:583be68728a31d0d750d5b8d9e00f02b153df0d4655f858bde93cb84cfc4227c", size = 11854881, upload-time = "2026-09-17T23:21:56.342Z" }, { url = "https://files.pythonhosted.org/packages/b7/e9/f43410fada510b43fec09993c08f552086c3d247d3ee801a678f3cb10ea5/pandas-3.0.6-cp314-cp314-win_amd64.whl", hash = "sha256:77ccbe5057aece6fc172b9b77f19c04335af6882bc2e10c8f3ee4e6bfb3da553", size = 9791672, upload-time = "2026-09-17T23:21:59.332Z" }, { url = "https://files.pythonhosted.org/packages/8b/9e/db14c059c21f9baa1907d436f8bf30e0c76c6288225c5e8b79a08ba8b2c5/pandas-3.0.6-cp314-cp314-win_arm64.whl", hash = "sha256:fb625f426b375bcc96e3a04c5d5d266cd7be6ae5d6866e0e703382ab5164068c", size = 9121246, upload-time = "2026-09-17T23:22:02.123Z" }, { url = "https://files.pythonhosted.org/packages/67/ba/bad0f8dac020ab38a8637fddab01a57a82da7a496a6e6f19590aad53ab62/pandas-3.0.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9e492cd4bdba6778de4fe0df7f4590c012161ebcf9902dce01b01dc683105514", size = 10920612, upload-time = "2026-09-17T23:22:05.404Z" }, { url = "https://files.pythonhosted.org/packages/c4/a9/b500982e9aac6d52a58da4ad3f11e14168a315b06906b3f397c427878065/pandas-3.0.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d7dcd21238cbb4828ff148481ba01cac8946dc5121457b5aeba28636f8f99a60", size = 10570213, upload-time = "2026-09-17T23:22:08.44Z" }, { url = "https://files.pythonhosted.org/packages/4b/fa/e6ecd0073c98be8f840ac3125b955272835d7d9fd69f5944383b164deb5e/pandas-3.0.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ff482fa91fa2bafd92e8fe66ce3645c851824310f295c1f0a2f96e928fc4541", size = 10252356, upload-time = "2026-09-17T23:22:11.302Z" }, { url = "https://files.pythonhosted.org/packages/04/f5/001e230a7a7803590d9275a1a3f7e1bb605e3a495cfe5e8d3a532090621b/pandas-3.0.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:db7ec631f26223beee8e5c9e0b8f23c24d8197bbd1d982421d4e3188bea51965", size = 10655952, upload-time = "2026-09-17T23:22:14.283Z" }, { url = "https://files.pythonhosted.org/packages/ca/ab/bab587148a3852c96aae26c4b5f9e04ce2221801ad94e166b4fbf969ede0/pandas-3.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd75ed0c840f709fc2ae26ddd9534ac77ca1a48ac0cce521a74acaa85f3340a7", size = 11274177, upload-time = "2026-09-17T23:22:17.352Z" }, { url = "https://files.pythonhosted.org/packages/f3/32/74b48d87df2b80892d713c149abfe36d5db4de41b4eccb042a2bc07dafc1/pandas-3.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ef738d71d1059245b6bb03e312be06d8b3821326a83486c1ad03b9aba3710e44", size = 11719041, upload-time = "2026-09-17T23:22:20.227Z" }, { url = "https://files.pythonhosted.org/packages/f6/c6/d64b72d64d7eb0fad9fe424d34138e45dee70ddbea1360dcd0adf30e28f6/pandas-3.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:429d9df32731ab01383ed98f2baa7a60368090d1a94fc06019a12062510e8630", size = 10191388, upload-time = "2026-09-17T23:22:23.524Z" }, { url = "https://files.pythonhosted.org/packages/7a/30/5e5b2ccabeca73ae2b03fc82bca3eabb7466cf43737f05ac08d591665d47/pandas-3.0.6-cp314-cp314t-win_arm64.whl", hash = "sha256:a4dbd4dc65cbe645b92b8785d0f96dd7311010dc6606cf620e51b07b8788a12a", size = 9387796, upload-time = "2026-09-17T23:22:26.64Z" }, { url = "https://files.pythonhosted.org/packages/b6/77/47c5fb0be8bdd00116814c2c40d9ec42dbeb943865ef95130fe58a898226/pandas-3.0.6-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:50c44cbf5820b6b91a5f74aae04972472aefadd3cd9fbd1010409d85528bd570", size = 10483960, upload-time = "2026-09-17T23:22:30.071Z" }, { url = "https://files.pythonhosted.org/packages/09/08/a310cb2fefe6d2b4623ab2150da818d93d4e73e33b58e4d163bb74243c5a/pandas-3.0.6-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:eb6900de08ac85f93ac4948aa6b80842eba555875337b8359035ac9c43e92d34", size = 10149432, upload-time = "2026-09-17T23:22:32.818Z" }, { url = "https://files.pythonhosted.org/packages/54/36/6af478ec3a26d7754555cd62c3101c589c0931b1d85398e4fa5403910a1c/pandas-3.0.6-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e25e2e1adee99ddfada6f7206a79ae8e9c8a8861b0e3eaaba165006d3eef18e", size = 10360981, upload-time = "2026-09-17T23:22:35.621Z" }, { url = "https://files.pythonhosted.org/packages/43/20/5ece0e9cb79a6473216620db6a90546f578001c2bd857b77c444b27acad1/pandas-3.0.6-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ff44b2cb51cbd691c91f92c4ea6c71e34003f239ebd67c2e857dc898466b49c", size = 10776585, upload-time = "2026-09-17T23:22:38.427Z" }, { url = "https://files.pythonhosted.org/packages/2f/b6/cd3038f31ade5e8d2b4e1c9549d4b31e4d598469562f47142b2ad9171c0a/pandas-3.0.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:5edd0a7abb0986ecce1ac81f56d99b6763f86aa6946dceb6c661224f90af5a19", size = 11385249, upload-time = "2026-09-17T23:22:41.18Z" }, { url = "https://files.pythonhosted.org/packages/0a/87/05bb3003737f80375d7311774916a24d161e2e591abe8c672aed7813defc/pandas-3.0.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1bcb3e9ed29e74a7439cedff9e2aefd3ea65de84d7de9ccb6c194192541bd60e", size = 11848595, upload-time = "2026-09-17T23:22:44.207Z" }, { url = "https://files.pythonhosted.org/packages/c0/30/1c0d46acf236d19ef975e9cdd5d1e0dd54924b03f0ed8287096ad4a18152/pandas-3.0.6-cp315-cp315-win_amd64.whl", hash = "sha256:253e12cb9081b0afbac607920f6142975966bc315135e09de275fdbaa415d2de", size = 9783810, upload-time = "2026-09-17T23:22:47.097Z" }, { url = "https://files.pythonhosted.org/packages/87/03/df3304a9c2833c4810e7f1c887b04105b24731f9deef8b5d5d04522a375b/pandas-3.0.6-cp315-cp315-win_arm64.whl", hash = "sha256:97274c9adf6255bb48c620cd6959805efa7f09ea2167f0e0ae006a448cd2fca7", size = 9116820, upload-time = "2026-09-17T23:22:50.149Z" }, { url = "https://files.pythonhosted.org/packages/47/25/d5f6cce5efa17c38e4f752a875b4a26d66cfadd529cb8c81672f0376d6a6/pandas-3.0.6-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:265f562fdd1079f69f3de96dd425c3405224038c0af4f920c54bd240ee2c4640", size = 10894614, upload-time = "2026-09-17T23:22:53.223Z" }, { url = "https://files.pythonhosted.org/packages/ea/8b/e1876bfdc1df06bafc022d33202b5663bd86c81df2a6140aacafd0344669/pandas-3.0.6-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c6e4aae3e9bea26c6c9a20d88d96c86ec4a99b4db5fd516bcb4e829ab2c0ee36", size = 10546973, upload-time = "2026-09-17T23:22:56.155Z" }, { url = "https://files.pythonhosted.org/packages/e9/27/e0a27a5a5c27f7db66657b44def9e93121fd0ad4f0808355b9898fcbf204/pandas-3.0.6-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a77a1a44e4d88f1c6a2a64d3eb12efec8420875722e14279800b173a7c7c2804", size = 10239717, upload-time = "2026-09-17T23:22:59.603Z" }, { url = "https://files.pythonhosted.org/packages/d5/4f/4eadb7d86a921c1e8bc70916cfe601ed667c4169118d17d69958611c21f8/pandas-3.0.6-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86fa853a12e0b70927e2b1ee00d56d2224ec9cbb4b9d58348b5ad52d2f21150e", size = 10641275, upload-time = "2026-09-17T23:23:02.81Z" }, { url = "https://files.pythonhosted.org/packages/c7/d1/eba72e9d905e84e79aefeefdcc6bc1abe15d9077973566643f4211636966/pandas-3.0.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:c826e9babb7790142c399f58599d8de679bea059d7b39c5b6efa2096fac37266", size = 11262919, upload-time = "2026-09-17T23:23:06.038Z" }, { url = "https://files.pythonhosted.org/packages/9d/8a/1c5bd2642b450e374b6191189f47c49538fe41f82348a66de6f647e6ab59/pandas-3.0.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8fe77b408d82e2615674dfed62533b95e18a03610573877422aada4f625d4947", size = 11709148, upload-time = "2026-09-17T23:23:09.082Z" }, { url = "https://files.pythonhosted.org/packages/73/3d/1b142bd0d0f1326a5d98c91c955b923cd1e06b5eecb428cd9d8817fa01c0/pandas-3.0.6-cp315-cp315t-win_amd64.whl", hash = "sha256:83e91d15738d7783c050197cef2f2cf82fc6353dae9865aa87ed1fa16aa4d55a", size = 10161518, upload-time = "2026-09-17T23:23:12.365Z" }, { url = "https://files.pythonhosted.org/packages/0b/a3/6419c14da2adc1f09a6a183b8f91d7494d325b287f4ca984ac04f663638a/pandas-3.0.6-cp315-cp315t-win_arm64.whl", hash = "sha256:963ca21199097a84c7827c4678b04e30833084fbf8ef44fde3fa7180a29f8fa0", size = 9364529, upload-time = "2026-09-17T23:23:15.274Z" }, ] [[package]] name = "pathspec" version = "1.1.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] [[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] name = "pygments" version = "2.20.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] name = "pytest" version = "9.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" }, ] [[package]] name = "pytest-cov" version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage", extra = ["toml"] }, { name = "pluggy" }, { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] [[package]] name = "pytest-timeout" version = "2.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, ] [[package]] name = "pytest-xdist" version = "3.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "execnet" }, { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, ] [[package]] name = "python-dateutil" version = "2.9.0.post0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] [[package]] name = "regex" version = "2026.9.29" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/fc/f2/af1da9d3ceed77bfcdce40427d49ba0be94e4fe84245e3bfef68c10e75b6/regex-2026.9.29.tar.gz", hash = "sha256:8b5fcc4771732191b2b7d1dd68d8f0353f47f8d90b6150f6dce58bf1112442cb", size = 419199, upload-time = "2026-09-29T00:49:58.298Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e8/6b/6dea87689c3a06a6e79d254bf824e6f3e3d724b5ba027c6112559aa6cd2c/regex-2026.9.29-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6abb75ab16bc3281714a5b99548a2225db70dba1f995f6d7f7419b76eb5a8fbe", size = 495413, upload-time = "2026-09-29T00:46:14.51Z" }, { url = "https://files.pythonhosted.org/packages/3a/a5/0c791a0e83ad1013d262c13247c4c77e0f4a8d05bdc167df96aba6681c0d/regex-2026.9.29-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b7b893976e7fe42053da64f2aa27239c24252fd2ec6df471e1be197c0addc3b1", size = 295145, upload-time = "2026-09-29T00:46:16.292Z" }, { url = "https://files.pythonhosted.org/packages/b1/07/9bf3607d8d13a12e436ab9d63f9791e10706827d535695b23964ad79fd79/regex-2026.9.29-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:066d0e3dbfdd739bce2bf8c2a41dd16f73e3d8adc2eb06dd803a36a307f56075", size = 292123, upload-time = "2026-09-29T00:46:17.646Z" }, { url = "https://files.pythonhosted.org/packages/64/6b/32c2e6fc617e1d3f247e250fea31a9a35b1265bd32f585968aa13b9999b9/regex-2026.9.29-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7020ed44df30b3aa492c00ee3b52d0548c1f30c2c6c5bb13ae897680900d3413", size = 799537, upload-time = "2026-09-29T00:46:18.976Z" }, { url = "https://files.pythonhosted.org/packages/bf/72/f041177f3c7a4606f7c81a95fe7eea03e2a0c4e8bff9e439a01432cbc9f2/regex-2026.9.29-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4613d7d9dda60fcba95f846cc6f808017f1843f392cf9daad14a6534493d71", size = 873255, upload-time = "2026-09-29T00:46:20.684Z" }, { url = "https://files.pythonhosted.org/packages/d0/4e/a78948e11dd715e0e46716c2e0f3404b3fe6a44e2a2e9abdc7d965cab2b3/regex-2026.9.29-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bec37990e3d6121f29ecfb594bd8f1bf009e9f7926daba2e50e3b27d3892a783", size = 914570, upload-time = "2026-09-29T00:46:22.599Z" }, { url = "https://files.pythonhosted.org/packages/8a/70/aa08d1d2b294894b365e5f8ba5380fe3f8546acdb81f10639dfd74209c37/regex-2026.9.29-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:612b709381c0355b70d89cdb51b7f670591ed5cbbc0e3b5337488019dc667b65", size = 804978, upload-time = "2026-09-29T00:46:23.981Z" }, { url = "https://files.pythonhosted.org/packages/21/32/1b03534c4715aca3b564416d28d518083ed4dab3bc913267600d2256140d/regex-2026.9.29-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a760da040b47767b4b873adfb7c3b691e9ba2fc60f113f9d0b88f1a62f323e85", size = 779297, upload-time = "2026-09-29T00:46:25.318Z" }, { url = "https://files.pythonhosted.org/packages/76/a7/378f6f558d9e4444af315a307c5953565a511d1e3666f1bb7bdc82012b6b/regex-2026.9.29-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:49ee178ca31c94621294bf9b8b676a92a2e6bba8af0529591753719e57edb621", size = 786961, upload-time = "2026-09-29T00:46:26.963Z" }, { url = "https://files.pythonhosted.org/packages/59/13/79f0b1846f5f342f92ddbd4b27b18bcb86da96d902c1a0be26520bde98d7/regex-2026.9.29-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:5eeb8edc6110d9194a4d0d54610f64c37a31c605b5dbb7e407fc6ec7fa34a4a1", size = 863234, upload-time = "2026-09-29T00:46:28.58Z" }, { url = "https://files.pythonhosted.org/packages/97/19/05af70dec9f2eed6ba34e08d2dcc6a48e7ae5e307659d5fe4201a5d7bbee/regex-2026.9.29-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ccb64d887a9db1cd76dbc0f92051a1a478a2a67e7f56c62d915cb881d7734704", size = 766487, upload-time = "2026-09-29T00:46:29.941Z" }, { url = "https://files.pythonhosted.org/packages/01/e1/9c7486d4afe8fdd1fe0ad60139f8aa91427381f409af6a29b609d8fdcb3a/regex-2026.9.29-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9e4482589065c8ecd761cff522dcd85f2d39e62f551e37e025d1c7d54772def3", size = 854792, upload-time = "2026-09-29T00:46:31.358Z" }, { url = "https://files.pythonhosted.org/packages/26/c7/49d008ff5f741d9a9799d7315556f3a12b983ff0fcd2cdfb62904bedafbf/regex-2026.9.29-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d60030baaa7bfbb02d650c126cdcddcb6e33dbff14d819434c8fa2fdcaeeeba5", size = 793050, upload-time = "2026-09-29T00:46:32.775Z" }, { url = "https://files.pythonhosted.org/packages/cb/a1/46ba549e65562ca04608b24179b8a7bb6f146ae0e7c6d7f5e70f3339c8ba/regex-2026.9.29-cp311-cp311-win32.whl", hash = "sha256:18ae8eed4526e35bdb754d61562b90bf5c00a67fdcf3cc1380dd59597486631b", size = 268940, upload-time = "2026-09-29T00:46:34.179Z" }, { url = "https://files.pythonhosted.org/packages/4d/4a/aab232183c70fdcf77bcf0c51819da02ec522e393e6a0bf00bcf2142e21f/regex-2026.9.29-cp311-cp311-win_amd64.whl", hash = "sha256:1043aedf5917caa861bcb25a9c11460049656bdf0017a90a309fa8f255467725", size = 280641, upload-time = "2026-09-29T00:46:35.484Z" }, { url = "https://files.pythonhosted.org/packages/33/b1/7c05954af0f51de376df2ba97f7f78a8b79334c7e5b3d2d9f2aead1f4d3d/regex-2026.9.29-cp311-cp311-win_arm64.whl", hash = "sha256:352cf115a810b357caa35193ab656ecf5ef41056855e82f292c99e8514f8d954", size = 279411, upload-time = "2026-09-29T00:46:37.193Z" }, { url = "https://files.pythonhosted.org/packages/84/48/3fdcde9a0baa84d7d25571223265d6e434e114763b438601d54a8028bf3e/regex-2026.9.29-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dc79d36d0618752265f0d575915bdc5c5130ecb9c9f6b3bcefeae32e4bdfafcf", size = 497903, upload-time = "2026-09-29T00:46:38.938Z" }, { url = "https://files.pythonhosted.org/packages/2e/1c/4ee3e97c76f53940488dfe7a7e18705e78daac8cd7fb161d246b9e328449/regex-2026.9.29-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3a21a9509d0ee88e7a70e1ad228cd2f0e0fd1e187458db132e8a8d18c97daf9d", size = 296416, upload-time = "2026-09-29T00:46:40.406Z" }, { url = "https://files.pythonhosted.org/packages/37/14/f3f0ba083d2094392d5eabf56db5ea6ba469fd6e927afd187042054ea68a/regex-2026.9.29-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f57dc6b8fef170f105d2cf5cdce254f47b137d7755086cf7050f47e16582abba", size = 293633, upload-time = "2026-09-29T00:46:41.959Z" }, { url = "https://files.pythonhosted.org/packages/c9/72/67e7a8ce17f1aea49df215564048efb49cc8c2b31a0e0fc30f36838f8516/regex-2026.9.29-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f93bc1c3486ef3747e07c9d7c1d0a147b8fbaab975f80e348aed6f71309dfaca", size = 805885, upload-time = "2026-09-29T00:46:43.373Z" }, { url = "https://files.pythonhosted.org/packages/f6/78/25436bcfd4d2260b4b4090094d55d7ab53ec8a1ab4865a0b8bcb33c7d5c0/regex-2026.9.29-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e1d3a4cb7993b708f0ada8d0c84590efd853f169e7147d2202c9da503180242", size = 878344, upload-time = "2026-09-29T00:46:45.328Z" }, { url = "https://files.pythonhosted.org/packages/97/e6/a09ec3a23ae41d6179880e67f0aace9284b2d95f2d7b326eff203f8eec5e/regex-2026.9.29-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dabee8f4935e731fb46b2a3091bdda0d3d94b3bbfb907d2b4f12eefce4009619", size = 919181, upload-time = "2026-09-29T00:46:47.041Z" }, { url = "https://files.pythonhosted.org/packages/26/83/d2fbd2e4e3afb1167daa825187d196f313cbaa1a4768f311fb041bb0e3d2/regex-2026.9.29-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39ab5894d971f9ac68baa6eca5c50387db579cfcacf36ae8df3feceb1815e6d0", size = 807783, upload-time = "2026-09-29T00:46:48.894Z" }, { url = "https://files.pythonhosted.org/packages/46/0b/eb429a7016610d44fc89a597163f8c9127505f0d7dc724dc9effbb6a3ac0/regex-2026.9.29-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c1a9a6651197fbed6f0212591418b9def774fc3f8324f78d1bf0e6a63e5f8aa1", size = 783465, upload-time = "2026-09-29T00:46:50.64Z" }, { url = "https://files.pythonhosted.org/packages/1b/07/58a3c0153c7476898430f6a7cf3d9062a1d17fbea4f43399ecaf411c7b4c/regex-2026.9.29-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:87fb80cbe3557e27e7b28b995c2b2eedf689b8886f941ab93e0e288f0976518a", size = 793519, upload-time = "2026-09-29T00:46:52.396Z" }, { url = "https://files.pythonhosted.org/packages/2a/e8/161b94d39164520e21a7befe0245569bf7fda4c7cf1fc4e2df2b5def49da/regex-2026.9.29-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3c5c2ef13797466aa64170cbb66ad98a32351dd4127694cea7199f80f213750d", size = 869293, upload-time = "2026-09-29T00:46:54.128Z" }, { url = "https://files.pythonhosted.org/packages/8f/07/3b02ed829aa2decdc1955d222bd1e2f99d1c8bb4873bbb9a66b2f0a36bff/regex-2026.9.29-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:59b49507f47479e299a9e1bc41b5cb83a7afda0540625f1dbae886615978acbf", size = 770239, upload-time = "2026-09-29T00:46:56.106Z" }, { url = "https://files.pythonhosted.org/packages/42/5b/ba61f6fe062eb8562e742367d177bb75370434138ef6c9d2a27114f8d613/regex-2026.9.29-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0dd8af32e9f7b56b7f95cc1fd79b23054c3bdc172392ae560acc24d57b7ffe71", size = 861973, upload-time = "2026-09-29T00:46:57.665Z" }, { url = "https://files.pythonhosted.org/packages/cc/27/767259b20e8a842948990f5e99138d6c077248fd42f8b5468b1d9ca4b814/regex-2026.9.29-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db5e82ba15c142425b8406690032df89e39cca4a2e8afbbb9a3d84edc2373ac3", size = 796470, upload-time = "2026-09-29T00:46:59.236Z" }, { url = "https://files.pythonhosted.org/packages/a0/05/2566c4ba849b68a8ab81a6bf428fa79d20aae7ddee83979103c0381df254/regex-2026.9.29-cp312-cp312-win32.whl", hash = "sha256:d0c3082bf79bcd6a614d55916590ad4b8f93200e10b97f463ea5d9d07c9b5f23", size = 269327, upload-time = "2026-09-29T00:47:01.135Z" }, { url = "https://files.pythonhosted.org/packages/93/19/489bc8db91196381c935752df01ba3f607140daece33b78d88573f028e64/regex-2026.9.29-cp312-cp312-win_amd64.whl", hash = "sha256:fdd88ed5e20b1bcdd234421e454962c971aa44b653bdb7f1ea9ef683e90fb649", size = 280334, upload-time = "2026-09-29T00:47:04.436Z" }, { url = "https://files.pythonhosted.org/packages/0b/47/fb88ba779d0e5e7d4b0ec1aceeb13845948a2cb876bd572a2d1dfdba090b/regex-2026.9.29-cp312-cp312-win_arm64.whl", hash = "sha256:4fe97894d1b306c919b4e50def1e6f6c522f4d03a7283811f4d108f1ce5d3ac2", size = 279614, upload-time = "2026-09-29T00:47:06.541Z" }, { url = "https://files.pythonhosted.org/packages/79/d5/6080f7d1a6e7e36aa720f806ac93c035ba39c209ae6cc510e8ef4c0279c6/regex-2026.9.29-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f1a0d5117230dd46b399a30a38afa44f79c99f3168988fdc4f425c3f928b39df", size = 497622, upload-time = "2026-09-29T00:47:08.251Z" }, { url = "https://files.pythonhosted.org/packages/00/71/c87fc7a2e21a42f9d57489db32951c37eef56d153840459a80d464f0321d/regex-2026.9.29-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f0fe9834e5aeccaf19a0d8feb296d66a24be1a7c9922002f842a682cd5abb787", size = 296281, upload-time = "2026-09-29T00:47:09.764Z" }, { url = "https://files.pythonhosted.org/packages/11/9e/aa0f4cde3bc4688c1d58b0cd8415edd708339bc0bc401a195b0b1e8c8f0c/regex-2026.9.29-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c90fcf7804ea0a54b896ce0f2b9565350220b8d4890fd0db461a476a4c687963", size = 293472, upload-time = "2026-09-29T00:47:11.723Z" }, { url = "https://files.pythonhosted.org/packages/90/d4/e835c487850ed922a8d6074f953b888c8ea99775c76b9ed5f8a4d72eab92/regex-2026.9.29-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e11edba5bc344a32b029a7af9d4b3173982dd79eeafa0b9dbd787364414b0509", size = 805966, upload-time = "2026-09-29T00:47:13.235Z" }, { url = "https://files.pythonhosted.org/packages/2c/57/ba8809847fbae8d2cbc71367c6ded510a7ec88bf52493c65efc1acf4effb/regex-2026.9.29-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb90e7177944b6684738c1fc36aabd2dd00d1de3be7dbe09f91e196f1bc0dc81", size = 878346, upload-time = "2026-09-29T00:47:14.877Z" }, { url = "https://files.pythonhosted.org/packages/1a/52/e3da19fc3cc15ef67ab67e121e87887c3bccfdb683a7a9ec557c460ca5b7/regex-2026.9.29-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d06fcdecc10fc7954d7c8f27a03c96055fe525274dc84a7b0dbdc3d6b9e03dab", size = 919250, upload-time = "2026-09-29T00:47:16.622Z" }, { url = "https://files.pythonhosted.org/packages/9a/8e/c1ed81f55f992f6aa0b699a592a50c1ce9e6d44ff1aee2c14c0537dcef9c/regex-2026.9.29-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d49c18f1ea294cf4adde2e5ac256e98c82ea9d708462ce4bf799dffa7cfe8a2c", size = 807902, upload-time = "2026-09-29T00:47:18.268Z" }, { url = "https://files.pythonhosted.org/packages/ad/bc/5a6886eb470e41040e21e05b75024a18b6ebfe7ea400b72094a60f949101/regex-2026.9.29-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e778bfccd63075167709136afbc251c1f683758d5bf49c803c60ac3f894ce6b", size = 783514, upload-time = "2026-09-29T00:47:19.921Z" }, { url = "https://files.pythonhosted.org/packages/cb/52/6d951d453b023c6edb880f1ba474291b53b8ce1cc438b96a9db6d791d991/regex-2026.9.29-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:686ac5350fceae63830bb98805fcb8039325bf4c06d9f6f048ff65229d5bffa5", size = 793466, upload-time = "2026-09-29T00:47:21.552Z" }, { url = "https://files.pythonhosted.org/packages/99/b9/d5a41adc08360f5eee0dc4846c578f002366947211fc8af5a69a64ee7b9f/regex-2026.9.29-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:26ec4ccce55aa533fbd603d08911b01101a8fcfec987845ac3ae2c7087b2bde3", size = 869464, upload-time = "2026-09-29T00:47:23.276Z" }, { url = "https://files.pythonhosted.org/packages/4b/32/d76c9d91f5d798e2e9e67f6f85ec4ae35445ac425f7454797311cecb80ca/regex-2026.9.29-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a655d34b2a6943af32401f3d94f72e9d731f6ad16285815550bf2b4ee69d420a", size = 770278, upload-time = "2026-09-29T00:47:25.193Z" }, { url = "https://files.pythonhosted.org/packages/24/00/aeebdb540c620a0f7317f6d6fad80a47729ecf0599a24b5c34ec155351f5/regex-2026.9.29-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0c992c19cd45058a4b92f68f139c93db168b48fb1f322c9a7cd620806afb6b51", size = 861949, upload-time = "2026-09-29T00:47:27.005Z" }, { url = "https://files.pythonhosted.org/packages/12/62/d0314bcedfd3586197e4596931fa220260eb2385bf53184e5b9ae67db24b/regex-2026.9.29-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ebb8912f565b8cdbbf27debfe00df04202c20e2f651b9e32767930c5eace3621", size = 796602, upload-time = "2026-09-29T00:47:29.233Z" }, { url = "https://files.pythonhosted.org/packages/ae/c7/d5a8c13a613facb03e0fb55c1ebaaf7bb35d8e2c1abe8bef8dca809fc1d9/regex-2026.9.29-cp313-cp313-win32.whl", hash = "sha256:4d7d93613b01b0199961330e49cfc52d479b3d5776c56c691db31130c0a07d91", size = 269306, upload-time = "2026-09-29T00:47:31.14Z" }, { url = "https://files.pythonhosted.org/packages/80/a7/bf93a3a6afa5f7bc16b7afb94ae581b01cae620b8ad56bd8f9572a985959/regex-2026.9.29-cp313-cp313-win_amd64.whl", hash = "sha256:61956f074ecd123f55adca68ee3eab46e6a07ad3f8e64e6db95dfacb444f55c4", size = 280307, upload-time = "2026-09-29T00:47:32.709Z" }, { url = "https://files.pythonhosted.org/packages/b2/7d/388274e53605a86297f433a08102a7bbdcf9379d47683d307ccaefd88e2c/regex-2026.9.29-cp313-cp313-win_arm64.whl", hash = "sha256:bfc71e6d970419c1309b3640305298643e2a734cad3f7cfb6d2ddee4175ab53d", size = 279599, upload-time = "2026-09-29T00:47:34.674Z" }, { url = "https://files.pythonhosted.org/packages/93/1f/d9dc6f02f569625faf67a4daec926cd5023472dcd69bb44286dccd5a5ab3/regex-2026.9.29-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:957bb708e8057ab1649ba566456429d691ec9b90d1c9ad1af1ba7ffbbeaf05f2", size = 497598, upload-time = "2026-09-29T00:47:36.541Z" }, { url = "https://files.pythonhosted.org/packages/9c/83/9b693a3fd1451381e812031a8961ec5b3b8f0c8cc6871f14c5223642804d/regex-2026.9.29-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c9b602fae1e00b7c035d661ce85575365719192a7b46784bd71cf64c68053aa0", size = 296250, upload-time = "2026-09-29T00:47:38.233Z" }, { url = "https://files.pythonhosted.org/packages/dd/5f/52bc2abc3fef040cd9de76ab29c918d6a717a454ae2b9dd7938b0c95656d/regex-2026.9.29-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0166844493626c5015c6088ee15c9ca2fd060ca15b7641d1657da6a58432ae33", size = 293518, upload-time = "2026-09-29T00:47:39.957Z" }, { url = "https://files.pythonhosted.org/packages/dc/fc/cf50671215ee0057046980b4571ef8646a005819bb67f0957e779ed107a5/regex-2026.9.29-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b97a38fb4c732b6832db6bf108963adbcd82ef1268ba2025dce390f45af75efa", size = 806037, upload-time = "2026-09-29T00:47:41.676Z" }, { url = "https://files.pythonhosted.org/packages/14/4b/dddef8fc15c63e4347cc9efb138d0cd306f30e6c98acbcc81a8f780083b9/regex-2026.9.29-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a540abfab208e1b7ef2df231c40ef3b6cbb30a0aad6204e9b6a81c10a6794628", size = 878881, upload-time = "2026-09-29T00:47:43.755Z" }, { url = "https://files.pythonhosted.org/packages/9f/cb/38daabed32d28f7e58a06e9344ce00dc67952e9996bc578ed6a29fe1240e/regex-2026.9.29-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ddfa987262763c3c22a8367d2a49c244b018a74c3a8e3ab1a864119ad45c5633", size = 918684, upload-time = "2026-09-29T00:47:45.594Z" }, { url = "https://files.pythonhosted.org/packages/a9/4d/041d9458a645fee4fce4d642a89d27271a3cfcd91095104f6dde44da70bf/regex-2026.9.29-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f7f7aa47b229f2b39a2ae2596d2ad5625d77b5eb9856fac2dab3eb506cdd0a0", size = 807176, upload-time = "2026-09-29T00:47:47.372Z" }, { url = "https://files.pythonhosted.org/packages/bf/c4/4383eed7aa5aef67616cb1b3f3ad06b7c624c4e6cced48630cd5ce133d85/regex-2026.9.29-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d9b77b25b4f395f92de6099ab08e8ae2bc7e51dfe157f22900902243a5cc90c7", size = 784315, upload-time = "2026-09-29T00:47:49.518Z" }, { url = "https://files.pythonhosted.org/packages/5c/a6/0086ad31cebb183c637d3198547075aa493afde308e1ff61fccccb29ba6e/regex-2026.9.29-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34b6925af9853bf461950e6508910f179fd6e9b1a7ec8548e069606b7e51a26b", size = 793748, upload-time = "2026-09-29T00:47:51.279Z" }, { url = "https://files.pythonhosted.org/packages/d5/a0/f9005cba3f629a859573fc5d1224ea4e1f97919ec8581d018e03a351a604/regex-2026.9.29-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:addd736a0547d553283adaf4e05d7104e7f2c7b0b092e9b4d28756825f14531f", size = 870302, upload-time = "2026-09-29T00:47:53.368Z" }, { url = "https://files.pythonhosted.org/packages/01/4f/e1a3e46bb5315a4e18b01a990e7a28e2a16595609d50c442baf2815a3c65/regex-2026.9.29-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fe3fa1dd453ed5c7f5ea23a26218329790ed7197a99b90e94330e313959a7f52", size = 770299, upload-time = "2026-09-29T00:47:55.606Z" }, { url = "https://files.pythonhosted.org/packages/2c/fe/f303b4acfda44e1ff1379368748c1ef2dad04a6a8e9c0ecbc970b19d97ca/regex-2026.9.29-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0cc63b5e47c12a48d90c7e9d7de6a035dd14f62868aaedbb4e0ff8ba2b8bfe7b", size = 861570, upload-time = "2026-09-29T00:47:57.617Z" }, { url = "https://files.pythonhosted.org/packages/60/b6/b4f7e99249f596017c60ccad5faf9310fc8e3e59bb2244940a90a1b0bdff/regex-2026.9.29-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:724184b4aafed865e4f13ca313fdcb43024300c028ec67319cfa16847d84685e", size = 795967, upload-time = "2026-09-29T00:47:59.922Z" }, { url = "https://files.pythonhosted.org/packages/fb/d3/fc865a4638d9f6762192b6bab5b7aa1f33a90e9e99578c2e111e2a63c8c3/regex-2026.9.29-cp314-cp314-win32.whl", hash = "sha256:c6c8fabf1dafc1f1ddcbb67896d3f93efb092e8c4b6322d7389b944e76a484e5", size = 274758, upload-time = "2026-09-29T00:48:01.8Z" }, { url = "https://files.pythonhosted.org/packages/31/e2/c2b466924ccbeb874862968ca638051b15a8fd29d994a0e99004a5cbf78e/regex-2026.9.29-cp314-cp314-win_amd64.whl", hash = "sha256:1c2a0026062abcc321a53db4a185ceba0b59a66b5d37b0808917a88b55a5257f", size = 283817, upload-time = "2026-09-29T00:48:03.614Z" }, { url = "https://files.pythonhosted.org/packages/c6/42/ea0f8dbaa924fa75c6338935eaee2f44dab369b27f02db1e03d74344b049/regex-2026.9.29-cp314-cp314-win_arm64.whl", hash = "sha256:121a76a0985db80ceae9e171c337f8c927868e37d01b54e3ce87bc87f9c6a208", size = 283663, upload-time = "2026-09-29T00:48:05.624Z" }, { url = "https://files.pythonhosted.org/packages/44/48/d58e5081119f5c223bbb37d2340acde3d069e1df8e8cd166c37502eee4da/regex-2026.9.29-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e31f72490b7c12f7790e1e25c3afffd20503ee1bfb43461d7838b871ff244b19", size = 501393, upload-time = "2026-09-29T00:48:07.833Z" }, { url = "https://files.pythonhosted.org/packages/72/3c/c49945287d4f9efee7d41f98072f8ad880efb8f430595a612fbdea996a4e/regex-2026.9.29-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:80ea96f5c1a30bf09007d48466521d9c294bebe197c708c3359096e3e3691632", size = 298237, upload-time = "2026-09-29T00:48:09.684Z" }, { url = "https://files.pythonhosted.org/packages/f9/1f/688cb61c3d4cf7bcc1ed444b5cc49399eba3e51c469ae285cf87fea3022e/regex-2026.9.29-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:554bffadcbcb6d5f4e5fb10a61cc52084b9a63d1dab5f10bcd2c4343972e8e2c", size = 295936, upload-time = "2026-09-29T00:48:11.454Z" }, { url = "https://files.pythonhosted.org/packages/26/a3/de43ac6b877b7d09c19a3a426b1bd5acdd209eaaf68f406466f80439ccf6/regex-2026.9.29-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:864e9b87ac33c3fb9fb4ad48166d4fdb579c351d5c77deb0d34bccb36a775cd9", size = 816905, upload-time = "2026-09-29T00:48:13.321Z" }, { url = "https://files.pythonhosted.org/packages/62/14/9940763201c51d537786304984c67d0fc3d2ed18837ffb6f09a869f6b6c9/regex-2026.9.29-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:044265d77d94f5e3cb2fd72c76723807c429cb8c533e9d4672d0334a6f14f588", size = 881527, upload-time = "2026-09-29T00:48:15.313Z" }, { url = "https://files.pythonhosted.org/packages/d3/e1/c842d8df0b23245ebf202f8ab9c39fd48e2db39959454ec39a41c8c72082/regex-2026.9.29-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2089fe39c406784d90101c726755ffa1497bb74638fd434300d2b88006186de8", size = 923115, upload-time = "2026-09-29T00:48:17.328Z" }, { url = "https://files.pythonhosted.org/packages/d8/c1/98622479e3c354a446a75232e522d747d2b3df23092dcd8a5309380a2020/regex-2026.9.29-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0def9fb6abac55492d6d51cddb7225d07d6f279e774e0adc08569a54a5fc8d46", size = 820674, upload-time = "2026-09-29T00:48:19.32Z" }, { url = "https://files.pythonhosted.org/packages/6c/d0/5808c95f9c79ed27b5eedaafc3df6239ec56a49f2e23ea8f831b18427c82/regex-2026.9.29-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:888d60953908dcf761aa320c3e390ab8556efbdb551ace63921de90f6ae0848d", size = 793306, upload-time = "2026-09-29T00:48:21.615Z" }, { url = "https://files.pythonhosted.org/packages/bf/d3/021ca2638671ad20603bcd9b4d5bfa35d2610cd216a043ea7f0b44ea39f6/regex-2026.9.29-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed511a0708e2297e1d6431e7fb217e3402791e491e02da800658ace4973df1bb", size = 803103, upload-time = "2026-09-29T00:48:23.871Z" }, { url = "https://files.pythonhosted.org/packages/6b/2d/755c6d13ef9c657378013676c391c7a402166b3f419a464a3e058dcbe533/regex-2026.9.29-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e1172147d28d8fbcf8cb8d26c41506169f5ad8fe9ec969cb116835a19d4d8eca", size = 872175, upload-time = "2026-09-29T00:48:26.255Z" }, { url = "https://files.pythonhosted.org/packages/6c/fc/e1cab183b9dafe8597f58c1c766da9bf96204d3b2f232bcf3eeb75ff7b6c/regex-2026.9.29-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:92f05c9c42bde5785dc48770bc2194d9f7442544156f951e19cd31b096cec562", size = 777362, upload-time = "2026-09-29T00:48:28.389Z" }, { url = "https://files.pythonhosted.org/packages/06/7c/e10ea17fba31fb4a1f9d13ed53a2d2a9066a2aea58d7557e263f6d99e7b0/regex-2026.9.29-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f37964e4a5e993d2fd45147741e9dff7f34a2d8c00ab94c4ea0514a4677f959e", size = 865606, upload-time = "2026-09-29T00:48:30.4Z" }, { url = "https://files.pythonhosted.org/packages/8e/6e/69824d9aee1fd41c54ea7264654a47c8d9d84d8a228e11c2bcf4c201ed81/regex-2026.9.29-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:951733b1bbdb71e377cec567b409f1a7881b47cfcad84121aa74cb575fa425ea", size = 807945, upload-time = "2026-09-29T00:48:32.375Z" }, { url = "https://files.pythonhosted.org/packages/89/22/857050a86e21ce60193e02a8ef662521f2e263a645c8b1b905fc136b61a7/regex-2026.9.29-cp314-cp314t-win32.whl", hash = "sha256:65b408d8fcb273e3499e7ef2ce796810da1becd208c7fb4373692a242d79d461", size = 276758, upload-time = "2026-09-29T00:48:34.72Z" }, { url = "https://files.pythonhosted.org/packages/4d/96/56808fe029553d7d4c703414f2a527faad2ea2bfa9ca094a2e7f8762b530/regex-2026.9.29-cp314-cp314t-win_amd64.whl", hash = "sha256:bf48516e35cf848390ea68850aba53e7c333720d2945b4d2c25b69fc5171723f", size = 286527, upload-time = "2026-09-29T00:48:36.864Z" }, { url = "https://files.pythonhosted.org/packages/01/aa/074e2cfb3d8101a6a764aba5f7c5d1e21de087483e35bdc0c4ce2eb60364/regex-2026.9.29-cp314-cp314t-win_arm64.whl", hash = "sha256:9173db3be74a35cb6731701094b98120f7ee4876a287882a59cdea1fa7da342f", size = 285954, upload-time = "2026-09-29T00:48:38.901Z" }, { url = "https://files.pythonhosted.org/packages/a7/dc/d84990386c9dfdf8c377f00f371b241fdc9a2c8aea0e3d66941b2e51be0b/regex-2026.9.29-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:c3589f40749acce747510bf5d589d54e376cb0930ea58b35effac97e5312b0c1", size = 497836, upload-time = "2026-09-29T00:48:40.858Z" }, { url = "https://files.pythonhosted.org/packages/c2/ab/a569ebde875fa12ff8c6c9a30e07503620f195e4be4d54c3d3ee8eecc283/regex-2026.9.29-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:32ab11df9677ca80bcbb5fe4eb1da9109a5019239a054836efc6fa1c64e683cf", size = 296244, upload-time = "2026-09-29T00:48:42.952Z" }, { url = "https://files.pythonhosted.org/packages/f3/3e/7d548e82a108e7c8b2d5246650e397a2f8db599f9b2e975466939c5b4e70/regex-2026.9.29-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:7c03031610e3e6ed1768a2b7a8fc84637c1257b50c5eacaf094c6e17a84fc563", size = 293748, upload-time = "2026-09-29T00:48:44.985Z" }, { url = "https://files.pythonhosted.org/packages/40/34/a8e19a52f452bbb07b32a2bef70dcdf90c2737049749f74cc12d7486fb4f/regex-2026.9.29-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:42e82e578c904445d4c8a35b8f28052cf567593215fa5db06266fbc6f77aaa2e", size = 807840, upload-time = "2026-09-29T00:48:46.948Z" }, { url = "https://files.pythonhosted.org/packages/88/7b/11fbd4640b3bb82b72822a63c20ade4013d562d291703a9debeedc24e682/regex-2026.9.29-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0b65c72739f981377c9c22e0c5c3cd7f42da7bd8a3c9209330fac772c7d893ed", size = 879330, upload-time = "2026-09-29T00:48:49.168Z" }, { url = "https://files.pythonhosted.org/packages/f3/55/de58c74f1f4e31586d83eb39c56872d686c4e0d0966d151884c833b94ced/regex-2026.9.29-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4408b2b27a95ca8cc48b7411945753773353b5c93b307754781086c99d3a576f", size = 919251, upload-time = "2026-09-29T00:48:51.322Z" }, { url = "https://files.pythonhosted.org/packages/81/42/a8c480f6dd5ac59fa28ddae79afd9d7ac7e596fdb61813adc65bb6e674b8/regex-2026.9.29-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a714befaacbd10092ffe4cea0d3c5f008fb9efe9bc322c715bcdfdee414b9a3d", size = 808808, upload-time = "2026-09-29T00:48:53.529Z" }, { url = "https://files.pythonhosted.org/packages/68/60/0bc0d1ec8b37ad64be6fa30e035251f11de9667a0fac9e82ee74517d81be/regex-2026.9.29-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33026515aebc0e70d1c89978e53e8d695d35d9e472f8d5b34465ba3c74028650", size = 789907, upload-time = "2026-09-29T00:48:56.036Z" }, { url = "https://files.pythonhosted.org/packages/da/84/116a3ef19b3acfe81077f0bf2cbc7714a5e94bc8935b7243ab61cb0f1c3c/regex-2026.9.29-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:31b003f9a070335e2a8233ee9b14a3ca8e6d792012ae011f741bf0aaf11744c5", size = 795770, upload-time = "2026-09-29T00:48:58.284Z" }, { url = "https://files.pythonhosted.org/packages/96/ba/e38c3f203e7e7e18c957d48e6cb6dbf96c11e95a44efa4a480522afc5d6d/regex-2026.9.29-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:c03c6eb6ece86dfdcbb34799efaa339b093132e1aceed491ba5e08fe06cdf699", size = 870671, upload-time = "2026-09-29T00:49:00.506Z" }, { url = "https://files.pythonhosted.org/packages/2f/0f/9ee0b0cb76c55f63684bd7fff554978e8773b4fc86e2bcb2d50772dc1086/regex-2026.9.29-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:a5300757f8a68f5b6cc33f57338d72a0e3589c5cc9ad5f8504ea06f028be582a", size = 778631, upload-time = "2026-09-29T00:49:02.984Z" }, { url = "https://files.pythonhosted.org/packages/b6/19/e6e3eeb226af5872c4958002f6edef4e4f40ea4cc5f5665023f2019eb045/regex-2026.9.29-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:80c7cadd3fd2bfde5df8aa0787e315812cad0c313a753095d02f4c2b6c01677b", size = 862187, upload-time = "2026-09-29T00:49:05.264Z" }, { url = "https://files.pythonhosted.org/packages/5b/62/823c102e106bb2711d6b7dfe5981552fe4467b2969c46a20c5c383cf498c/regex-2026.9.29-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3f1e6cb402a89457582cd696f982559217d13484a193202c394015297968c86d", size = 798423, upload-time = "2026-09-29T00:49:07.644Z" }, { url = "https://files.pythonhosted.org/packages/37/e0/e927776258fa70b2f6feffc3be584ffc85ba4c1e20a320f0aee9a632fc7d/regex-2026.9.29-cp315-cp315-win32.whl", hash = "sha256:a64b85a4760337cfefdb27d42da6ed8b58e8cde3f2d57b6ef43e76ef6ea9ef47", size = 274757, upload-time = "2026-09-29T00:49:10.513Z" }, { url = "https://files.pythonhosted.org/packages/77/04/358de85d1860238e1b4fa98fc2c80c990124a25d2e14739e28cc02c25562/regex-2026.9.29-cp315-cp315-win_amd64.whl", hash = "sha256:b3e445b66c80b4eb4234e855ce94d9adc183eedbd632816228d89930b91b2c5b", size = 283824, upload-time = "2026-09-29T00:49:12.849Z" }, { url = "https://files.pythonhosted.org/packages/92/d3/d5c5b264784a5ab2b0f8cf620c1eeb4dbf3440d306761905e7d99345bef5/regex-2026.9.29-cp315-cp315-win_arm64.whl", hash = "sha256:8f39588af4731c8923c26810eb3b33f76f17633985e40f59c3cd45a33805a895", size = 283664, upload-time = "2026-09-29T00:49:15.331Z" }, { url = "https://files.pythonhosted.org/packages/02/dc/f63ec2c201445ce1150fe780f5c56f16a10124d9a9da3a93161dbb0d8892/regex-2026.9.29-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:fb99cc9d45f48895d9d67f6a0b8a57f08d39c174d9f25ad97a313e0470267b1c", size = 501549, upload-time = "2026-09-29T00:49:17.705Z" }, { url = "https://files.pythonhosted.org/packages/9a/5c/d2a698dc6bfc11fbce03f1cb0249c13284e93b79ed11f893edf6fac431c9/regex-2026.9.29-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:720537c7ea6f80dc61913184edb0ce2497a306b39ef19f28505b322553d52bdb", size = 298187, upload-time = "2026-09-29T00:49:20.171Z" }, { url = "https://files.pythonhosted.org/packages/85/b7/88dcdb38cd3935d4ee9e9ce9b8e56cb3b3518d1f020acfa7dd62ad289bf8/regex-2026.9.29-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0fd2c901cc307a745ad4bc87f20060d7a0825a3371d1e93488af22e7a387f78f", size = 296157, upload-time = "2026-09-29T00:49:22.342Z" }, { url = "https://files.pythonhosted.org/packages/d3/8e/ba6c01dde33a69fc294b38b43f6677baaa5735a6248f39708031a738158a/regex-2026.9.29-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b11b589e00095ec69cf79841a76360f9b079e95b0368a25b5ebb951ab0c157ff", size = 818468, upload-time = "2026-09-29T00:49:24.612Z" }, { url = "https://files.pythonhosted.org/packages/2a/f1/2586693e3a2d6b1247852593d37a6c17b42a92ee44f7cdcb9a0c1494e64a/regex-2026.9.29-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d7cab119d0df0b9413f106b4d7fc34f2872d3574ed3806fb48959c830b1537da", size = 882825, upload-time = "2026-09-29T00:49:26.996Z" }, { url = "https://files.pythonhosted.org/packages/30/51/084f3e7bdcd0e9c33665c938cf5d134dc3548cbb4a75f0197ec7bfd754b1/regex-2026.9.29-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b89efc38431793d28b7cd91227e2f952ad7c48df19132b17f43a5fec3c14143b", size = 923314, upload-time = "2026-09-29T00:49:29.822Z" }, { url = "https://files.pythonhosted.org/packages/5a/f1/066c6fc23b7dc229789c21c880b5ba5ad689fb95fed12e078266f55a1f9b/regex-2026.9.29-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80a5ea3b4fd9d6a5b9a44f7976a9acaaab35aa3c1f6b29e5bd857dfabaded223", size = 822222, upload-time = "2026-09-29T00:49:32.404Z" }, { url = "https://files.pythonhosted.org/packages/0a/56/592cd46fdb8f2f8682a1d7fd1310e4d0bcb93fbd0e6bbe4141ac28240227/regex-2026.9.29-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:19959129885356df0e97556856f77eb2888380dac18bed075a7c05c5128c618d", size = 794882, upload-time = "2026-09-29T00:49:35.076Z" }, { url = "https://files.pythonhosted.org/packages/ee/4d/d65384bb071c864b01aa8314e3a6a687845ebd57588390976edc960c218b/regex-2026.9.29-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:6a1a824fbed817e0a891103886b68f063b1e83cc51bc97192a90a60195a9291f", size = 805887, upload-time = "2026-09-29T00:49:37.395Z" }, { url = "https://files.pythonhosted.org/packages/65/b6/358de0d8f40d5178e4f7e7e121cfd5b961c812b77a055d11f5079e3f8fd7/regex-2026.9.29-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1ba8c6a416569ce0d37e83e28a254a61dc99a419084dfb6476cea02d997f74fa", size = 872901, upload-time = "2026-09-29T00:49:39.927Z" }, { url = "https://files.pythonhosted.org/packages/00/06/6bfded72d043240c6b52bbb5e16f639d81affbf7484b4fe2ec45f3d4afc9/regex-2026.9.29-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:446654b29bfaa30500d80947eda42cef1449dc8a87f4e3cf061cc8485d3a1f0b", size = 782970, upload-time = "2026-09-29T00:49:42.581Z" }, { url = "https://files.pythonhosted.org/packages/5a/20/9f418a50baa78b3ed8308fcb0cc49e472dd000b7ef935a7295af202ea744/regex-2026.9.29-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:bf3c49863c23a1ad6da9c30351aed6cff8d5ddbeb63c5c8420ae54e98c7d0138", size = 865441, upload-time = "2026-09-29T00:49:45.238Z" }, { url = "https://files.pythonhosted.org/packages/2c/29/817c7eacdeaf8463123e949bd394c39ad024eea1ec38ddf5ad141da2f3bd/regex-2026.9.29-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:01000ddf0e3ffef97f2413ceb514f6313040106b6d18a03ee00a4fe35c1eb1db", size = 809431, upload-time = "2026-09-29T00:49:47.878Z" }, { url = "https://files.pythonhosted.org/packages/63/0b/83aab3b5b739947f744135a7a3a446e25433ebc92b05e01aae197ccbfdda/regex-2026.9.29-cp315-cp315t-win32.whl", hash = "sha256:c4e38dd8f39c43a91d2410ad2b85610701b0979342c3df1d69eaf8e838c757d8", size = 276964, upload-time = "2026-09-29T00:49:50.524Z" }, { url = "https://files.pythonhosted.org/packages/72/f2/6314b5fc68789b5dcc38885bc6e3d6986b34fb3372b7231088ee5cecaa05/regex-2026.9.29-cp315-cp315t-win_amd64.whl", hash = "sha256:e2c89e9b762c57f59d5e99ee8b20202adb892e35f8d3485741340999ca55058e", size = 286487, upload-time = "2026-09-29T00:49:53.224Z" }, { url = "https://files.pythonhosted.org/packages/56/bc/97b2245c8c7b2dd01f2db74f2bea003cd33c15009b4996a2447f46b5325c/regex-2026.9.29-cp315-cp315t-win_arm64.whl", hash = "sha256:e8c65ef3862a8ad6e86492b6ed9327805dd66904c012bd3649dc67d822ed6c34", size = 285955, upload-time = "2026-09-29T00:49:55.655Z" }, ] [[package]] name = "requests" version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "charset-normalizer" }, { name = "idna" }, { name = "urllib3" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] name = "rich" version = "15.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] [[package]] name = "roman-numerals" version = "4.1.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, ] [[package]] name = "ruff" version = "0.15.16" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/5f7ec371001337d8fa61701c186ff8b613ecac1651848c5950f4c4d5f2e9/ruff-0.15.16.tar.gz", hash = "sha256:d05e78d38c78caf020b03789e25106c93017db5a0cb6e2819885018c61343b78", size = 4714267, upload-time = "2026-06-04T16:33:09.974Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0c/42/53ef1c3953f157956db9bf7861e3bc50b9b887ce93300aa48cdba8336fe6/ruff-0.15.16-py3-none-linux_armv6l.whl", hash = "sha256:6ac3c0b3969cc6cf6b158c4e2f8f682acb58e7d700d8a44b65ecdc72d66ab0b2", size = 10709025, upload-time = "2026-06-04T16:32:51.935Z" }, { url = "https://files.pythonhosted.org/packages/93/9a/a79159346f19134a956607754e57d8d128f7a4c00f4ad2f7514d224c172c/ruff-0.15.16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:197c207ed75ffba54a0dec23db4aa939a27a3053073e085e0042433cbdc58e4a", size = 11063550, upload-time = "2026-06-04T16:32:42.24Z" }, { url = "https://files.pythonhosted.org/packages/bc/72/3ce2ac000a5299ec238e01f51397b3b653c93b077d9b1bfe8715bb895f20/ruff-0.15.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3a39fec45ab316cc23e7558f23fea4a70403ddb5648ea9a4a3854a16973d0071", size = 10421345, upload-time = "2026-06-04T16:32:37.251Z" }, { url = "https://files.pythonhosted.org/packages/b0/c2/cc7fad3ec9169373f5b6a18f1917b91080feec40c3f9658334a1d28e2f03/ruff-0.15.16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba93191d79003116b95128c9d306e045200fdbd0bccb782b110f3cd1d4abc5cf", size = 10757217, upload-time = "2026-06-04T16:32:54.722Z" }, { url = "https://files.pythonhosted.org/packages/69/d2/3474009eaa0a65b31fa7152a2fad5e2f050c640ceb1e6b02ee6922e94c82/ruff-0.15.16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6ee4b90520630120ef032aa5cc10db483852dff950e78b1d717e2993a61ac8d", size = 10507035, upload-time = "2026-06-04T16:33:05.343Z" }, { url = "https://files.pythonhosted.org/packages/ca/81/b7ae6ccbd11f0c8dc3d5d67fc4be9b57ff57ca86ba56152021378e1277f2/ruff-0.15.16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e4215bc938bc3c8215c1472c1aa437e310fee20cd427335fec9d7e609563628", size = 11255291, upload-time = "2026-06-04T16:32:49.49Z" }, { url = "https://files.pythonhosted.org/packages/d9/e1/46e526f1a7cc90857ce6ddf25fbb77eb6568651ac38d71b033af07076dd5/ruff-0.15.16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c8d26be963b090f10e29abc8b3e74a2a321f6fa34e02424e30b5af89350ecbb", size = 12124922, upload-time = "2026-06-04T16:33:07.821Z" }, { url = "https://files.pythonhosted.org/packages/1a/da/5c791b088b596b24d0deb967fa28ae02ad751a140c0b9ea81c5ab915d6c0/ruff-0.15.16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f198cf4123602a2280ed46c307bcbafe41758d6fee5b456b6b6058ca1514b3b4", size = 11332186, upload-time = "2026-06-04T16:33:02.971Z" }, { url = "https://files.pythonhosted.org/packages/72/11/5da87abe20047c8962361473923ebb2f62b595250126aadfad8c20649c1e/ruff-0.15.16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb27515fa6240fb586ae82b901a59e67d24acff86f2190b433dc542fe0435aeb", size = 11373541, upload-time = "2026-06-04T16:32:47.007Z" }, { url = "https://files.pythonhosted.org/packages/fe/2a/8554754c23a854ae3fd6b507e36ad61ddb121e298c6d5d617dec94ed0f14/ruff-0.15.16-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a267c46ba1593fc26b8eecbea050b39d40c0b6bb7781ee11c90a02cd10032951", size = 11353014, upload-time = "2026-06-04T16:32:34.795Z" }, { url = "https://files.pythonhosted.org/packages/62/25/62ea41529ec89f742ea3fed9cb1059c72877ec7cf9b9e99ac9cf3294d1d9/ruff-0.15.16-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:528c68f39a91498a8d50e91ff5985df3d105782bab49cc378e73ac26bff083e8", size = 10737467, upload-time = "2026-06-04T16:32:26.348Z" }, { url = "https://files.pythonhosted.org/packages/90/17/334d3ad9de4d40f9dd58fdd09e35ce64553bb501e2f19a839e2fb6be14fc/ruff-0.15.16-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7ed55c58950df60589a9a7a5d2f8fa5f54ebd287163be805adfe6ee95a9de123", size = 10521910, upload-time = "2026-06-04T16:32:32.54Z" }, { url = "https://files.pythonhosted.org/packages/4d/bd/3ac7c6ae77a885c1004b3dda2446ea401768d24f851c14b4ad4b24f6639c/ruff-0.15.16-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d482feaf51512b50f9790ceb417a56a61dd1e9d9bf967662b9ed27c01b34f53a", size = 10979190, upload-time = "2026-06-04T16:32:57.492Z" }, { url = "https://files.pythonhosted.org/packages/33/d7/609546e6a413c3f216fbf2a50c928f97c80939154f6a0503114094a86191/ruff-0.15.16-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1e15bc8c94513dae2a40cc9ef07c94fdd4ecc9e29dabebeebe170f952322c9e3", size = 11477014, upload-time = "2026-06-04T16:32:44.687Z" }, { url = "https://files.pythonhosted.org/packages/74/0d/f2cd247ad32633a5c36e97141a2c21b11c6279f7957bc2ff360b1e08fddd/ruff-0.15.16-py3-none-win32.whl", hash = "sha256:580378f7bd4aa25f72e74aa54948a9622f142b1e509521dd10902e886681cc1e", size = 10735541, upload-time = "2026-06-04T16:32:30.145Z" }, { url = "https://files.pythonhosted.org/packages/8b/9e/02e845ef151b1dee585e55c4739f8e1734ae1d9f1221dff65761c162208b/ruff-0.15.16-py3-none-win_amd64.whl", hash = "sha256:408256017284eddf98fff77b29aa4fb30f586042d535b2d9befc6512f400aaec", size = 11843403, upload-time = "2026-06-04T16:32:39.76Z" }, { url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" }, ] [[package]] name = "scipy" version = "1.17.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.12' and sys_platform == 'win32'", "python_full_version < '3.12' and sys_platform == 'emscripten'", "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, ] [[package]] name = "scipy" version = "1.18.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15' and sys_platform == 'win32'", "python_full_version >= '3.15' and sys_platform == 'emscripten'", "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.14.*' and sys_platform == 'win32'", "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ { name = "numpy", version = "2.5.4", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/7e/74/66de6258867beb2ef08f35f9f2ac017a52cacd5081714d239ff1a442d458/scipy-1.18.1.tar.gz", hash = "sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307", size = 30781235, upload-time = "2026-08-21T23:28:50.599Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/18/f7/240c110c08693826b4513a52f5717d62ec7c7af72f2920821247c03b17b3/scipy-1.18.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:457fd7a2a8edeb044ab6ffbc0aa03ff6cd18491356e5e0c834d76ce621b916d1", size = 31111061, upload-time = "2026-08-21T23:23:44.522Z" }, { url = "https://files.pythonhosted.org/packages/05/4a/78c6285577c375e7cf27277ea8ee6961224327f1e1a0c44af5f17f23635c/scipy-1.18.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:e708533e8b2ae2497d65346538a7dcc92814410b25b81432eac66de0f2af8265", size = 28733332, upload-time = "2026-08-21T23:23:50.015Z" }, { url = "https://files.pythonhosted.org/packages/a5/f6/a5b82f8abbe14d134691b8b903696f701d25a081353a29dc655c364d9e62/scipy-1.18.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:7bbf207c4453ce1ad2e00b17313852b33310b83090c2311bdaf97f93c0380d12", size = 20475078, upload-time = "2026-08-21T23:23:54.138Z" }, { url = "https://files.pythonhosted.org/packages/23/22/0858a0bbd6b3e825ceb8cd9baf9eaf3b2f2b1d77727eb6be40500bcdc92f/scipy-1.18.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:78c0665edead396b1abb4897c41a5c1d9bf090c8a637a4c20a61678e0a264e66", size = 23108904, upload-time = "2026-08-21T23:23:57.824Z" }, { url = "https://files.pythonhosted.org/packages/75/9a/2e71719f31eaefe0e3a1706c4a1ded94e664bfd95ffca2b219a671faee01/scipy-1.18.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c085faa2cfa879c5141df483f836f4d691045a078224a670fa570fa01612d89", size = 34025113, upload-time = "2026-08-21T23:24:02.209Z" }, { url = "https://files.pythonhosted.org/packages/df/64/ff35eb9e54894cf471ff4716abd3c81eb0a0626869217ce3e6ba4ccf17d7/scipy-1.18.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f55fa87b6c612ecd6b058f167c53231b1d14e412efe361d3d6e38b3631c73218", size = 35344199, upload-time = "2026-08-21T23:24:07.844Z" }, { url = "https://files.pythonhosted.org/packages/d3/af/c5538be1792f7034c12c7db6ee67cace58253c7b87b122d68253eaf5de89/scipy-1.18.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c35d74ce0e193ff740c2f2be2ac913ddc232fe6c1ff40b26cfecb9c670c63314", size = 35639587, upload-time = "2026-08-21T23:24:13.05Z" }, { url = "https://files.pythonhosted.org/packages/91/4c/075e4f66471bac101141ac739e9e135549be1bae584571bd03a530c056e1/scipy-1.18.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d2924a03db38dc2e848bca2fe9f077dafb891480b91a00a0963a8cf86dfc31c1", size = 37480330, upload-time = "2026-08-21T23:24:19.608Z" }, { url = "https://files.pythonhosted.org/packages/39/e7/979fd14e75008623df31ba70d6bb144700f68feadcea042021c06a05bf82/scipy-1.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:5e4d44984abc0020154ea81b247adeddcc3ac5527b975ff798bd1ba0adc513c2", size = 36658278, upload-time = "2026-08-21T23:24:25.463Z" }, { url = "https://files.pythonhosted.org/packages/c7/0b/e1525354ff9d7d5feb6d1b31af6d14072e5c91e9607b421fa1ec889660b3/scipy-1.18.1-cp312-cp312-win_arm64.whl", hash = "sha256:d65d448389b8436493abcf629cc94ad0cf32aecaf06e1acca1de53cc795f2f12", size = 24400588, upload-time = "2026-08-21T23:24:30.579Z" }, { url = "https://files.pythonhosted.org/packages/b6/55/4540ee0f9c42a9ad7109d0d1a8cc70de54c3572b01c6693a2b1c70e90ceb/scipy-1.18.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:3ab3523da44749156e1f68b464dc56af11ae4cbc5c739a49d05f32b982eca9f3", size = 31089958, upload-time = "2026-08-21T23:24:35.8Z" }, { url = "https://files.pythonhosted.org/packages/2a/f5/769f36d14922b8071a43e95d24d18b6bdafad10d7f5cf647867e1ac052bc/scipy-1.18.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e6fb6a55cc0ba97b59a1f288fb86dc6fce8bdfc0fffcbfd015e3a954bf2a2d93", size = 28715106, upload-time = "2026-08-21T23:24:40.775Z" }, { url = "https://files.pythonhosted.org/packages/9a/d7/21d890274f75ea37a8209d5519e72da3da90302e3b9fb8397a0918386a62/scipy-1.18.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ea324d9dd34c38bfb9bec8ca4d1b407db97dbb74029f566b8e322b1b6fe56fe6", size = 20456846, upload-time = "2026-08-21T23:24:45.066Z" }, { url = "https://files.pythonhosted.org/packages/ec/01/798430ecea2e78ec7c02663d5f71c007bb6abeca931080debd40d7fa55ea/scipy-1.18.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:75b00eb8fb802090aa903f4ea1c7f5a584779f967361e68b7e98e531cc2d7174", size = 23087986, upload-time = "2026-08-21T23:24:49.539Z" }, { url = "https://files.pythonhosted.org/packages/e6/5f/4634e9d35c68496e4e34cb6946eafab044458e6cedab42b40b6588e475b6/scipy-1.18.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d416b16cccfd70fbf62400e84d0bb2f4e6af519a45557f1692c749b37f14b315", size = 33998146, upload-time = "2026-08-21T23:24:54.714Z" }, { url = "https://files.pythonhosted.org/packages/41/48/6450ed9243315322bbc19ac57b9b70d66a20bf1d38d124c96bc4bf6af9ea/scipy-1.18.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fdaf5ea890a6183d0565f51a61799d67081bd5b1cf03c5f4b3fd3732108625c9", size = 35312578, upload-time = "2026-08-21T23:25:00.44Z" }, { url = "https://files.pythonhosted.org/packages/00/bd/bf5a4be6a3525676499f6dff307991739ff6fdcad1481b1aeb6745339f58/scipy-1.18.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c825cef2f49e46753726a7181a8e199804a912b29519ada542c6ebc654951899", size = 35612621, upload-time = "2026-08-21T23:25:06.144Z" }, { url = "https://files.pythonhosted.org/packages/bd/4e/3c45c33e00a77996c4b1cb707929f833ba7b1d522ee29f882512c330676d/scipy-1.18.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3b417bf8c2c7c16e8f58ad91db17783ec911ac16e7b50eb6eab6e809b4f5b07", size = 37457323, upload-time = "2026-08-21T23:25:12.483Z" }, { url = "https://files.pythonhosted.org/packages/93/0e/e0348fbc0dbab65c114cf78957e7dfeb49f8e8b556b4d930cc12ff195e18/scipy-1.18.1-cp313-cp313-win_amd64.whl", hash = "sha256:559ed65f60c1af5a03f3912605a1b5114f522c7c32fb23c3376ae8f03219fe28", size = 36622841, upload-time = "2026-08-21T23:25:18.722Z" }, { url = "https://files.pythonhosted.org/packages/50/a8/6a77f5f267c555108f0a864b6db714363dab567a8266422a79a385f9232b/scipy-1.18.1-cp313-cp313-win_arm64.whl", hash = "sha256:cd479fc04dd9401e3b4f49e76518768ef99c4f517a98c284eb091fd725719adf", size = 24399315, upload-time = "2026-08-21T23:25:23.458Z" }, { url = "https://files.pythonhosted.org/packages/06/d5/d8eb4e280ddb56a4ab2c6f02ee49b56b23f6e977cf0802fd6d68dbef14f5/scipy-1.18.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:83de5453a7799afc9048b4616bd085cef126e36412f0ea2f6370c36a2a3a51e7", size = 31090936, upload-time = "2026-08-21T23:25:28.686Z" }, { url = "https://files.pythonhosted.org/packages/2a/49/59ea385dc3a62ff498ddf3cfff7c2b41b0f9f9d3c4122b3f1dcb6d6327fe/scipy-1.18.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:9554bcc6d715ee87a633a3cc8e7703c6628b100dd29cb8a2efc4c0533c7ff729", size = 28725221, upload-time = "2026-08-21T23:25:33.244Z" }, { url = "https://files.pythonhosted.org/packages/70/e8/6b0c288c50942d78193696c9f15f9a0874f5178aa0ddf40f83d9924b3e8d/scipy-1.18.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:011413b7426b75012840e35649e00fe0a2c3bae89fed433876e3a99251572efc", size = 20466839, upload-time = "2026-08-21T23:25:37.516Z" }, { url = "https://files.pythonhosted.org/packages/4b/e0/54fd3793c729e3b936782f181b59cbb1205bf250ab605a16cb1ba61cdd5e/scipy-1.18.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:88f0e784020649f88ea48c9f5ddfa403bf9205820667c0914740b392035afb82", size = 23089121, upload-time = "2026-08-21T23:25:42.019Z" }, { url = "https://files.pythonhosted.org/packages/0b/56/030af62bea3cf878e0028515dff78c123b01633606a879b63f42d2db99cc/scipy-1.18.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d3ab0e8c69a17dd3559eab8cbb88f258e285c94d572c2719033f90f83290c89", size = 34053851, upload-time = "2026-08-21T23:25:47.998Z" }, { url = "https://files.pythonhosted.org/packages/6b/89/2a844506d49651e9aa1af6ef95b6bd8031cb1d5a4375edec6155037e04cf/scipy-1.18.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac0333bdf38309aa3dcbe7e3fa7ea29e7a2c37c6ea306a757b700ded8e4596ad", size = 35329183, upload-time = "2026-08-21T23:25:53.522Z" }, { url = "https://files.pythonhosted.org/packages/eb/56/c7370c3640e92ac9613cbf26cb3f729f9b12ddf1727b55b94b53b24d6f48/scipy-1.18.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:911de823097db8b63f034299d12662db93344e6ffa0b881cbb57748974b70168", size = 35672551, upload-time = "2026-08-21T23:25:59.387Z" }, { url = "https://files.pythonhosted.org/packages/24/16/ec8536f351421f8bf60a1120930638f83790f4710b8230446aca3d6159d4/scipy-1.18.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:95298364e251be3e60249facbeeca03631d3bb7584f85879516ec55ac717b81f", size = 37469416, upload-time = "2026-08-21T23:26:05.432Z" }, { url = "https://files.pythonhosted.org/packages/52/94/d73da0d28f16c45bb9b0a5691b91610b0275c5ef0eb5e43c87cf2dc1bf31/scipy-1.18.1-cp314-cp314-win_amd64.whl", hash = "sha256:78a0d7c918e74a232394117160e7e3db503377572a45bcef8826e4ab8a35feba", size = 37362755, upload-time = "2026-08-21T23:26:11.366Z" }, { url = "https://files.pythonhosted.org/packages/89/25/e996e4dc74e10e227b1e14db5eaf6608bb6dd33884a64851c38f18dd4249/scipy-1.18.1-cp314-cp314-win_arm64.whl", hash = "sha256:cbf38d043c1aa4ab306e1ada6ab6eddacc3322a20b7af1b30bc93254b366fe09", size = 25036090, upload-time = "2026-08-21T23:26:15.887Z" }, { url = "https://files.pythonhosted.org/packages/fa/c9/c00213f92309d753b48903e6a451b87eb52ff5b7a16e789d1568bbf221c4/scipy-1.18.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0fcb3c93519f27bb4f0c4b0f7802cdcaca7fcf93267b75edda2e9f4e8a55cbd7", size = 31485550, upload-time = "2026-08-21T23:26:20.776Z" }, { url = "https://files.pythonhosted.org/packages/74/b2/e3067c487982d4eeab2938928529410370c06fea84a4d3f4925e7d96647d/scipy-1.18.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ddef79fb382df40104a19bb7151b3b23e57c1778fcf857c71ceecd9bd264513f", size = 29174642, upload-time = "2026-08-21T23:26:25.395Z" }, { url = "https://files.pythonhosted.org/packages/d5/ab/374c9fe2d1ec014e576c781a4b5d8e1ba340e8f6b4638c16f711d2b194f0/scipy-1.18.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0e82073ecc7acc6436fac4b31674109c7e1d3e596789767eda01258a8c9e8123", size = 20916357, upload-time = "2026-08-21T23:26:30.112Z" }, { url = "https://files.pythonhosted.org/packages/90/38/223915c88a17317cafbf8ca2a42b11c265a9fb1e804aa665544132b5fe8a/scipy-1.18.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8bcf3c1ba5d6456e2effd30fcbd3459b044d683fcdac79a2e6830f0bdf7de487", size = 23482611, upload-time = "2026-08-21T23:26:34.846Z" }, { url = "https://files.pythonhosted.org/packages/c4/d1/db0948da8ca57a80b36520ef0a768b967d99f3af65f4b6f1bf6362ad4dd4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cfbf154f2ba187f2ed6cce2639efff7d105f1140573642c0161615b6d91d6a87", size = 34143202, upload-time = "2026-08-21T23:26:40.4Z" }, { url = "https://files.pythonhosted.org/packages/87/53/39d046cc7574ed6acacb6bd5723e220107ece80bff12faaf3efc4ddeede4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1d33a7836f7ddc1993427966a0823468ec41bcbdb1a9f9942d1d7e57f803ba3", size = 35380876, upload-time = "2026-08-21T23:26:46.1Z" }, { url = "https://files.pythonhosted.org/packages/f9/da/32e0e799d875a85ca57d9bde6c78148afcc0e38276df683d95854eadc8c3/scipy-1.18.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4b8bc363b6d65ee2152bec57568e3c52639bb34c46057b09857a307ed5e21d", size = 35770885, upload-time = "2026-08-21T23:26:51.533Z" }, { url = "https://files.pythonhosted.org/packages/88/2e/f97a666d362fee68b18f41c9c30ed502ca5c98b549749bfcb52a8b74d1eb/scipy-1.18.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11c423f1049c5755ad4409af52a9ada1cff96fe9b50795d4af3619f292901239", size = 37525424, upload-time = "2026-08-21T23:26:56.751Z" }, { url = "https://files.pythonhosted.org/packages/ca/d5/a9e765a84654ebba8479a1fd1b059ced1af72b168a3b2a3a46540ea38d20/scipy-1.18.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c24acac1e18912761c4700239bbc1fd32f615af690f1584d49b35859be51324d", size = 37416961, upload-time = "2026-08-21T23:27:01.546Z" }, { url = "https://files.pythonhosted.org/packages/ee/16/e79e0d1c63ef698879d85439d37e9fb434e3b804e506a6991038d086ebd9/scipy-1.18.1-cp314-cp314t-win_arm64.whl", hash = "sha256:9f2897bf7737392ad0d5213ea7b6add72a4edf5679b3153106aeb88b6507b3b9", size = 25331848, upload-time = "2026-08-21T23:27:05.884Z" }, { url = "https://files.pythonhosted.org/packages/be/4f/1bd37c883b67163e2ca1f60977a399500e6879c15defecac62831c8d078d/scipy-1.18.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:eb0dfcf4e28a99c12c999744a2ff67c9b06200e20401c7c88186e33552a46331", size = 31091484, upload-time = "2026-08-21T23:27:11.051Z" }, { url = "https://files.pythonhosted.org/packages/8c/c5/ba929d7feb9b2332f96827c12e0e924b61973b59b4dea383b603372c65ce/scipy-1.18.1-cp315-cp315-macosx_12_0_arm64.whl", hash = "sha256:30f464bee641fa8e282577c7dce027308403213c6ca8270bba73285c91024bc5", size = 28725057, upload-time = "2026-08-21T23:27:15.9Z" }, { url = "https://files.pythonhosted.org/packages/a4/19/68f1c50f609d955d230e66d25d02bd3e1e167ec540232135354fb9a4b9e3/scipy-1.18.1-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:1bca3b943fc2567ea49cd02c99abde49da4d5178ec46f624bd8255cda8755beb", size = 20466734, upload-time = "2026-08-21T23:27:20.044Z" }, { url = "https://files.pythonhosted.org/packages/ef/6d/319fa29b73d1802fa80b32a6eaf3f5be456ef81526da2716a9493bcb5501/scipy-1.18.1-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:c9d18a33309122074ea483dd92dd444189166b8b2ec429fe9ed5ac73c7a0aa23", size = 23089664, upload-time = "2026-08-21T23:27:24.345Z" }, { url = "https://files.pythonhosted.org/packages/b7/db/30992f9b51a63de671daf3888ffd18378b6cb9ec9f2c972264238ffa7fd6/scipy-1.18.1-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82f201b4c878551d48558337aab270d3c6cca5507b8737c8d8a608d234cccde0", size = 34054035, upload-time = "2026-08-21T23:27:29.409Z" }, { url = "https://files.pythonhosted.org/packages/91/d4/bf3e735dc0b9d5a8ff45079d2540e17d3aff7a2f0048dd8f552ffd031d2b/scipy-1.18.1-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ac49ea97594532dd44b7136094d35f5440fa06e6d9c6384a74c01764df388c5", size = 35333883, upload-time = "2026-08-21T23:27:34.293Z" }, { url = "https://files.pythonhosted.org/packages/19/93/12d78ce9f871fe945fca588d32644e6e63f553c2a35c564d73f3b22a3313/scipy-1.18.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:ceb30a00ce7c92d459819443d29ca486d882b83fb6738bdcbb2a1cce94ac5daa", size = 35673124, upload-time = "2026-08-21T23:27:39.059Z" }, { url = "https://files.pythonhosted.org/packages/70/cd/886219313a1012a48e6ae0ec4f302c837151beb92e1ff0d709ef8fdfc488/scipy-1.18.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f29633129f9fa7e88a3f0fca835de2d030bfc9643f7799e1a0c46cee24d38fc7", size = 37470753, upload-time = "2026-08-21T23:27:44.435Z" }, { url = "https://files.pythonhosted.org/packages/17/6c/a776888ce618bee54fbde26172f0f46ac1da70d27b63861797fe78e1904b/scipy-1.18.1-cp315-cp315-win_amd64.whl", hash = "sha256:92c14f5bdbfb6216315ce33e78080474082de8b3830122ba97809bfbe65f75c0", size = 37361483, upload-time = "2026-08-21T23:27:49.334Z" }, { url = "https://files.pythonhosted.org/packages/ab/09/97b651691322ebee97999b017ffc18a15a0b815103844c97e8da9d469731/scipy-1.18.1-cp315-cp315-win_arm64.whl", hash = "sha256:e402cf31eb68f453dbb2d36fc6d722b33f24a55d68b2ae1d92fa6305ca71c298", size = 25035883, upload-time = "2026-08-21T23:27:53.596Z" }, { url = "https://files.pythonhosted.org/packages/ed/0f/9ec20467bbabd0d44e2a77d0fd3d124f884b4d67df92af82c91d2d6a486f/scipy-1.18.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2a0b02f9fc46f8520330c23d45e6560db7e3a0d927232139427637f98943e11d", size = 31474926, upload-time = "2026-08-21T23:27:57.993Z" }, { url = "https://files.pythonhosted.org/packages/8a/58/dcb79161e56efbedc50079fcd2f5fe427a0ebb53022eb476aa73c015ad8f/scipy-1.18.1-cp315-cp315t-macosx_12_0_arm64.whl", hash = "sha256:1d73131e358976663dd969e1fb4ed1404b815cd977eaaedc3b3a133ba2d81c35", size = 29164940, upload-time = "2026-08-21T23:28:03.062Z" }, { url = "https://files.pythonhosted.org/packages/71/d3/1eeea80c817fcb8ef7bd4a05a58824977a0e57a375cfc3d7ea7c911c01ad/scipy-1.18.1-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:bff0b729edd992766136b34e39cc76bc2fad905aa58897ee72a9cd000a6d8443", size = 20906742, upload-time = "2026-08-21T23:28:07.642Z" }, { url = "https://files.pythonhosted.org/packages/54/46/e59350428b6099301a20128108c995e2eb175a43f383af9a346e38824f9b/scipy-1.18.1-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:10ac20c69d880f77f375db44c22e3e6a644f9fefa291d4cd2fb9790a89fc99fd", size = 23472183, upload-time = "2026-08-21T23:28:12.109Z" }, { url = "https://files.pythonhosted.org/packages/89/31/cc91623fa98f0621766a0f0aaaadb2c66de74a7ea7e3837164f6e4354260/scipy-1.18.1-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33a834464fdabc0f26a45508df31b3cc5d028e04dbf6c5ed398541418e0a12fe", size = 34130796, upload-time = "2026-08-21T23:28:17.906Z" }, { url = "https://files.pythonhosted.org/packages/fc/3e/8572ef536957ddb8aa81bb4090d9e25f257e3b4e05d97deb54319deb8a3a/scipy-1.18.1-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49023963c193dacee096301452f223ee24d86ec5807f8df93c0f7221d119e305", size = 35374253, upload-time = "2026-08-21T23:28:23.732Z" }, { url = "https://files.pythonhosted.org/packages/b5/c6/59fdeffb4f1435299f93d9dc8140b43ad2916e6cfc944be6c3041fcec86d/scipy-1.18.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:d84a09d0dad90ba6525d8ac1c2334b33e64bf3ccfe9e841f02feb867a22681e4", size = 35758543, upload-time = "2026-08-21T23:28:29.431Z" }, { url = "https://files.pythonhosted.org/packages/cf/d9/135be205d9de8783193aff9cc3bf483a03a38e4b29432c954e8cb66ac14e/scipy-1.18.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:179ce34a8d0fe273d8883ba59e17e052247d08973dfcb743ca52bb1cce2d60b0", size = 37521946, upload-time = "2026-08-21T23:28:35.245Z" }, { url = "https://files.pythonhosted.org/packages/5c/a2/5b7d5270621ab7cfa3f7766067bf95dc360b5efb6394694e8143b4156e2b/scipy-1.18.1-cp315-cp315t-win_amd64.whl", hash = "sha256:5632e3ae3d09197c446310cd5187de63e28448ce22f0f67b2b93d97503c0c230", size = 37408295, upload-time = "2026-08-21T23:28:40.724Z" }, { url = "https://files.pythonhosted.org/packages/63/ad/741c19fcb66755ff953daf9243af8480e4bf3d7fbe57583c178c7d2b6b51/scipy-1.18.1-cp315-cp315t-win_arm64.whl", hash = "sha256:eda632a7981f69730d6281f451db9c1c370993a2c0d7ddb43e2a809a2862b83a", size = 25319710, upload-time = "2026-08-21T23:28:45.713Z" }, ] [[package]] name = "shellingham" version = "1.5.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] [[package]] name = "six" version = "1.17.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] [[package]] name = "snowballstemmer" version = "3.1.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/43/f8/0a71edf031f03c40db17503cb8ca78a69a171254e568e7db241b0ab57ea1/snowballstemmer-3.1.1.tar.gz", hash = "sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260", size = 123314, upload-time = "2026-06-03T00:56:40.194Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752", size = 104164, upload-time = "2026-06-03T00:56:38.614Z" }, ] [[package]] name = "sortedcontainers" version = "2.4.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, ] [[package]] name = "soupsieve" version = "2.10" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/71/c3/1b817965ac12dc002d7c9cd7dfffdd7d4fbf9b45763ef2ffe7b86ee94670/soupsieve-2.10.tar.gz", hash = "sha256:49e9380d7d2905463583bafe285e818c7366a9ed7b3aee221c1ac79c905d8bc0", size = 124337, upload-time = "2026-09-24T02:36:35.048Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/66/87/5ed59e1d0290564e2027ed066c52b059892c4637741337fc0967183c8d4d/soupsieve-2.10-py3-none-any.whl", hash = "sha256:8596eb8967d744174820280fa62b4542a2e955bfaccca73ed8a13c6eb8e9b502", size = 38019, upload-time = "2026-09-24T02:36:33.709Z" }, ] [[package]] name = "sphinx" version = "9.0.4" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.12' and sys_platform == 'win32'", "python_full_version < '3.12' and sys_platform == 'emscripten'", "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ { name = "alabaster" }, { name = "babel" }, { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "docutils" }, { name = "imagesize" }, { name = "jinja2" }, { name = "packaging" }, { name = "pygments" }, { name = "requests" }, { name = "roman-numerals" }, { name = "snowballstemmer" }, { name = "sphinxcontrib-applehelp" }, { name = "sphinxcontrib-devhelp" }, { name = "sphinxcontrib-htmlhelp" }, { name = "sphinxcontrib-jsmath" }, { name = "sphinxcontrib-qthelp" }, { name = "sphinxcontrib-serializinghtml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c6/3f/4bbd76424c393caead2e1eb89777f575dee5c8653e2d4b6afd7a564f5974/sphinx-9.0.4-py3-none-any.whl", hash = "sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb", size = 3917713, upload-time = "2025-12-04T07:45:24.944Z" }, ] [[package]] name = "sphinx" version = "9.1.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15' and sys_platform == 'win32'", "python_full_version >= '3.15' and sys_platform == 'emscripten'", "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.14.*' and sys_platform == 'win32'", "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ { name = "alabaster" }, { name = "babel" }, { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "docutils" }, { name = "imagesize" }, { name = "jinja2" }, { name = "packaging" }, { name = "pygments" }, { name = "requests" }, { name = "roman-numerals" }, { name = "snowballstemmer" }, { name = "sphinxcontrib-applehelp" }, { name = "sphinxcontrib-devhelp" }, { name = "sphinxcontrib-htmlhelp" }, { name = "sphinxcontrib-jsmath" }, { name = "sphinxcontrib-qthelp" }, { name = "sphinxcontrib-serializinghtml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, ] [[package]] name = "sphinx-basic-ng" version = "1.0.0b2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/0b/a866924ded68efec7a1759587a4e478aec7559d8165fac8b2ad1c0e774d6/sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9", size = 20736, upload-time = "2023-07-08T18:40:54.166Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3c/dd/018ce05c532a22007ac58d4f45232514cd9d6dd0ee1dc374e309db830983/sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b", size = 22496, upload-time = "2023-07-08T18:40:52.659Z" }, ] [[package]] name = "sphinxcontrib-applehelp" version = "2.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, ] [[package]] name = "sphinxcontrib-devhelp" version = "2.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, ] [[package]] name = "sphinxcontrib-htmlhelp" version = "2.1.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, ] [[package]] name = "sphinxcontrib-jsmath" version = "1.0.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, ] [[package]] name = "sphinxcontrib-qthelp" version = "2.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, ] [[package]] name = "sphinxcontrib-serializinghtml" version = "2.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, ] [[package]] name = "tomli" version = "2.4.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] [[package]] name = "typer" version = "0.27.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "rich" }, { name = "shellingham" }, ] sdist = { url = "https://files.pythonhosted.org/packages/37/78/fda3361b56efc27944f24225f6ecd13d96d6fcfe37bd0eb34e2f4c63f9fc/typer-0.27.0.tar.gz", hash = "sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5", size = 203430, upload-time = "2026-07-15T19:21:07.007Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl", hash = "sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1", size = 122716, upload-time = "2026-07-15T19:21:05.553Z" }, ] [[package]] name = "typing-extensions" version = "4.15.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]] name = "tzdata" version = "2026.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, ] [[package]] name = "urllib3" version = "2.8.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, ] �����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������